Привет, Хабр! В прошлой статье мы поговорили о строках в Swift - об их особенностях, внутреннем устройстве и подводных камнях. И я подумал: ведь строки это по сути - коллекции. А какая главная и самая популярная коллекция в Swift? Конечно же, массивы. Их используют повсюду. Но вы когда-нибудь задумывались, как они устроены внутри? Давайте погрузимся внутрь массивов и посмотрим, что они из себя представляют.

Базовые понятия
Итак, все мы знаем различные способы объявления массива:
let array = [1, 2, 3, 4]
let array2 = Array<Int>()
let array3 = Array(repeating: 5, count: 10)
Массив - это структура данных, которая хранит в себе коллекцию элементов одного типа. Это могут быть числа, строки, классы и т. д.
Массив поддерживает доступ по индексу, и этот доступ работает за O(1):
let array = [1, 2, 3, 4]
print(array[0]) // 1
Добавление в массив также работает за амортизированное константное время:
var array = [1, 2, 3, 4]
array.append(5)
Тут есть подводный камень: массив расширяется экспоненциально при заполнении буфера. Это значит, что при росте он будет увеличивать размер внутреннего хранилища и копировать все свои элементы в новое пространство. В такие моменты метод добавления будет работать дольше. Из-за этой особенности Apple рекомендует задавать изначальный размер массива через
reserveCapacity(_:)
, если вам известно примерное количество элементов.
Метод insert
работает за O(n), кроме случая, когда мы добавляем элемент в конец массива, тогда он работает также как метод append(_:)
:
var array = [1, 2, 3, 4]
array.insert(5, at: 2)
Удаление из массива работает за O(n):
var array = [1, 2, 3, 4]
array.remove(at: 2)
array.removeFirst()
array.removeLast()
Кроме метода removeLast()
- он работает за O(1).
Почему так? Всё дело в том, что при удалении элемента из середины или из начала массива нужно сдвинуть все оставшиеся элементы на один индекс назад. А если удаляем элемент из конца, то делать этого не требуется.
Базовые понятия мы разобрали, теперь давайте поговорим о том, как массивы копируются и как они работают с различными типами.
Взаимодействие массива с value и reference типами
Массив - это тип значения, как и любая другая коллекция в Swift. Как уже было сказано выше, он хранит в себе коллекцию элементов. Эти элементы могут быть как ссылочного типа, так и типа значения.
С типами значения всё просто: меняем один массив - все его копии остаются нетронутыми:
var array1 = [1, 2, 3, 4]
let array2 = array1
array1[0] = 2
print(array1) // [2, 2, 3, 4]
print(array2) // [1, 2, 3, 4]
А вот с ссылочными типами всё чуть сложнее. Объекты классов лежат не в самом массиве, а в куче, а в массиве хранятся только ссылки на них. Если изменять сами ссылки, всё будет работать так же, как и со структурами. Но если менять свойства объектов по этим ссылкам, картина сильно меняется:
class Example {
var a = 10
}
let array1 = [Example(), Example(), Example(), Example()]
let array2 = array1
array1[0].a = 20
// Ошибка: Cannot assign through subscript: 'array1' is a 'let' constant
//array1[0] = Example()
print(array1.map(\.a)) // [20, 10, 10, 10]
print(array2.map(\.a)) // [20, 10, 10, 10]
Поле поменялось в обоих массивах. Почему так происходит? Подробности можно почитать тут, но концептуально мы меняем объект по ссылке, поэтому все его копии также меняются, так как все копии ссылаются на один и тот же объект в куче.
Как этого избежать? Можно применить технику глубокого копирования. Её суть заключается в том, что мы копируем не только сам массив, но и все элементы внутри массива. Таким образом у нас получаются две копии массива с уникальными ссылками на разные объекты в куче.
Один из вариантов глубокого копирования:
protocol Copying {
func copy() -> Self
}
class Person: Copying {
var name: String
var age: Int
required init(name: String, age: Int) {
self.name = name
self.age = age
}
func copy() -> Self {
return Self(name: name, age: age)
}
}
extension Array where Element: Copying {
func deepCopy() -> [Element] {
return self.map { $0.copy() }
}
}
let original: [Person] = [
Person(name: "Sasha", age: 30),
Person(name: "Masha", age: 25)
]
let copied = original.deepCopy()
copied[0].name = "Vanya"
print(original[0].name) // Sasha
print(copied[0].name) // Vanya
Бонусом приведу один интересный кейс при работе с массивами, которые являются частью структуры. Итак, что будет, если сделать так?
struct A {
let a: A
}
Мы получим ошибку компиляции:
Value type 'A' cannot have a stored property that recursively contains it
Что она означает? Размер структуры рассчитывается на этапе компиляции, но в данном случае это невозможно, так как структура содержит саму себя.
Но если сделать так:
struct A {
let a: [A]
}
то всё будет нормально, и ошибка компиляции нас больше не побеспокоит. Это связано с особенностями устройства массива, о которых мы поговорим далее.
Устройство массива
Массив, несмотря то, что он является value типом, не хранит элементы внутри себя на стеке. Он представляет собой обёртку над буфером. Конкретный тип буфера определяется на этапе компиляции: если включён objc runtime
, используется _ArrayBuffer
, в противном случае - _ContiguousArrayBuffer
.
Напоминание: Objective-C runtime включен всегда, когда мы разрабатываем под iOS, MacOS, tvOS и watchOS
@frozen
@_eagerMove
public struct Array<Element>: _DestructorSafeContainer {
#if _runtime(_ObjC)
@usableFromInline
internal typealias _Buffer = _ArrayBuffer<Element>
#else
@usableFromInline
internal typealias _Buffer = _ContiguousArrayBuffer<Element>
#endif
@usableFromInline
internal var _buffer: _Buffer
@inlinable
internal init(_buffer: _Buffer) {
self._buffer = _buffer
}
}
Array
выступает как внешний интерфейс: методы вроде count
, capacity
или доступ по индексу передаются буферу.
_ContiguousArrayBuffer
_ContiguousArrayBuffer
используется, если массив и его элементы представлены только в Swift. Он хранит _ContiguousArrayStorage
, которое работает с данными как с непрерывным блоком памяти в куче.
Буфер хранит _ContiguousArrayStorage
не напрямую, а через абстрактный класс __ContiguousArrayStorageBase
. Это сделано для совместимости с Objective-C, так как __ContiguousArrayStorageBase
наследует обёртку __SwiftNativeNSArrayWithContiguousStorage
, которая добавляет Swift'овую поддержку подсчёта ссылок к NSArray.
Назревает логичный вопрос: зачем нам
__ContiguousArrayStorageBase
для поддержки работы с Objective-C, если_ContiguousArrayBuffer
работает только со Swift-типами? Дело в том, что есть несколько типов хранилищ, например__EmptyArrayStorage
или__StaticArrayStorage
(о них расскажу чуть ниже). В таком случае__ContiguousArrayStorageBase
нужен, чтобы избежать жёсткой привязки к конкретному типу. При этом, если Objective-C рантайм выключен, у нас нет оверхеда с__SwiftNativeNSArrayWithContiguousStorage
, так как без objc это просто класс-заглушка.
@usableFromInline
@_fixed_layout
internal class __SwiftNativeNSArrayWithContiguousStorage {
@inlinable
internal init() {}
@inlinable
deinit {}
}
_ArrayBuffer и _BridgeStorage
Если включён Objective-C runtime, используется _ArrayBuffer
. Он хранит __ContiguousArrayStorageBase
через вспомогательную обёртку _BridgeStorage
, которая определяет, нативные ли элементы хранятся в массиве, или они связаны с Objective-C.
internal typealias _ArrayBridgeStorage
= _BridgeStorage<__ContiguousArrayStorageBase>
@usableFromInline
@frozen
internal struct _ArrayBuffer<Element>: _ArrayBufferProtocol {
@usableFromInline
internal var _storage: _ArrayBridgeStorage
// ...
}
_BridgeStorage
абстрагирует работу с нативными и Objective-C типами, обеспечивая корректное хранение и доступ в зависимости от происхождения элементов массива.
Чтобы упростить взаимодействие с массивами Objective-C, используется _CocoaArrayWrapper
. Если элементы связаны с Objective-C, _ArrayBuffer
при обращении проверяет «нативность» массива: если элементы массива нативные — доступ идёт напрямую к хранилищу, если нет — то через wrapper.
@inlinable
internal var _isNative: Bool {
if !_isClassOrObjCExistential(Element.self) {
return true
} else {
return _storage.isNative
}
}
// ...
// пример обращения к сабскрипту для получения буфера среза (ArraySlice)
@inlinable
internal subscript(bounds: Range<Int>) -> _SliceBuffer<Element> {
get {
_typeCheck(bounds)
if _fastPath(_isNative) {
return _native[bounds]
}
return _nonNative[bounds].unsafeCastElements(to: Element.self)
}
set {
fatalError("not implemented")
}
}
// ...
@inlinable
internal var _native: NativeBuffer {
return NativeBuffer(
_isClassOrObjCExistential(Element.self)
? _storage.nativeInstance : _storage.unflaggedNativeInstance)
}
@inlinable
internal var _nonNative: _CocoaArrayWrapper {
get {
_internalInvariant(_isClassOrObjCExistential(Element.self))
return _CocoaArrayWrapper(_storage.objCInstance)
}
}
Хранилище массива может быть трёх типов:
_ContiguousArrayStorage
- основное хранилище для данных.__EmptyArrayStorage
- хранилище для пустых массивов. Все пустые массивы в коде ссылаются на него, что избавляет от дополнительных аллокаций.__StaticArrayStorage
- для массивов-констант. Они неизменяемы в рантайме, поэтому методы модификации для этого типа завершаются ошибкой, возвращаютfalse
илиnil
Возвращаясь к примеру из прошлого раздела:
struct A {
let a: [A]
}
print(MemoryLayout<A>.size) // 8
Swift позволяет объявить такую структуру, так как массив не хранит сами элементы, а хранит ссылку на буфер, который содержит хранилище с данными в куче. Эта ссылка всегда имеет фиксированный размер (8 байт на 64-битных системах). Следовательно, компилятор может вычислить размер структуры A
на этапе компиляции: он будет равен 8 байтам.
Copy-on-Write
Массивы в Swift реализуют механизм Copy-on-Write (COW). Это также достигается за счёт буферов:
@inlinable
@_semantics("array.make_mutable")
@_effects(notEscaping self.**)
internal mutating func _makeMutableAndUnique() {
if _slowPath(!_buffer.beginCOWMutation()) {
_buffer = _buffer._consumeAndCreateNew()
}
}
Здесь метод проверяет, что у буфера есть только одна уникальная ссылка (вызов beginCOWMutation
). Если это не так, то создаётся новый буфер с копией данных. Таким образом, все копии массива до его первого изменения ссылаются на одну область памяти.
Array
- это всего лишь обёртка, вся реальная работа происходит через буфер с хранилищем. Однако у массивов есть ещё один тесно связанный тип - ArraySlice
, которые мы уже упомянули выше. Он позволяет работать с подмножеством элементов массива без их копирования.
ArraySlice
Рассмотрим задачку, которая иногда встречается на собеседованиях:
let numbers = [10, 20, 30, 40, 50]
let slice = numbers[1...3]
print(slice[0]) // Что будет выведено?
Кажется, что ответ - 10
. Но на самом деле в рантайме произойдёт креш.
Почему? Особенность ArraySlice
в том, что он не копирует массив, а является ограниченным видом на ту же область памяти. Для простоты это можно представить так: есть стена - это массив, когда мы светим на стену фонариком, то подсвечивается только определенное пространство на стене - это срез.
В примере выше slice
покрывает индексы 1...3
, поэтому индекса 0
у него попросту нет.
Схематично это можно представить так:
numbers
:[10, 20, 30, 40, 50]
(индексы 0...4)slice
:[20, 30, 40]
, но с индексами 1...3.
Код под капотом выглядит так:
public subscript(bounds: Range<Int>) -> ArraySlice<Element> {
get {
_checkIndex(bounds.lowerBound)
_checkIndex(bounds.upperBound)
return ArraySlice(_buffer: _buffer[bounds])
}
...
}
Таким образом, в slice
просто отсутствует индекс 0
. Чтобы обратиться к первому элементу, нужно использовать его реальный индекс:
print(slice[1]) // 20
Выводы
Мы разобрали, как устроены массивы в Swift под капотом: от базовых операций добавления и удаления до тонкостей работы с value/reference типами. Посмотрели на Copy-on-Write и устройство буфера. Также узнали, почему массивы - это всего лишь обёртки над хранилищем в куче, и чем ArraySlice
отличается от обычного массива.
На практике из всего выше сказанного наиболее полезными будут разделы про сложность методов и про работу с value и reference типами. Остальное - это скорее для интереса и общего понимания, а также то, что может встретиться на собеседованиях.