Механизм Copy on write в Swift
В этой статье я хочу разобрать механизм Copy-on-Write (COW) в Swift — как он работает с типами-значениями, и что происходит в момент, когда мы присваиваем значение одной переменной другой. Такая ситуация часто встречается в разработке, поэтому важно понимать, как именно всё устроено «под капотом».
Простые типы данных
Для начала рассмотрим простые типы данных. Возьмём Int и посмотрим, что происходит при присваивании одной переменной другой:
var intValue1: Int = 5var intValue2: Int = intValue1 let ptrA = UnsafeRawPointer(&intValue1)let ptrB = UnsafeRawPointer(&intValue2) print("Same address:", ptrA == ptrB) // → false
В этом примере, когда мы с помощью UnsafeRawPointer создаём указатели на переменные и сравниваем их, программа выводит false. Это означает, что переменные находятся по разным адресам в памяти. Значит, при присваивании система копирует значение в новое адресное пространство.
То же поведение наблюдается и у других простых типов данных — Bool, Float, Double и т. д.
Copy-on-Write для коллекций
В Swift существуют более сложные типы значений: Array, String, Dictionary, Set. Разработчики используют их каждый день, и важно понимать, как они работают. Рассмотрим тип Array (остальные работают аналогично).
Чтобы получить адрес первого элемента массива, используем функцию:
public func withUnsafeBufferPointer(_ body: (UnsafeBufferPointer) throws(E) -> R) throws(E) -> R where E : Error
Немного опишу как работает функция, чтобы понимать как можно взять адрес определенного элемента массива:
var array = [10, 20, 30] array.withUnsafeBufferPointer { buffer in // адрес элемента массива с индексом 0 let ptr0 = buffer.baseAddress // адрес элемента массива с индексом 1 let ptr1 = buffer.baseAddress?.advanced(by: 1) // адрес элемента массива с индексом 2 let ptr2 = buffer.baseAddress?.advanced(by: 2)}
Теперь мы можем перейти к изучению того, как устроен механизм Copy-on-Write для коллекций. Создадим массив, присвоим его другой переменной, затем посмотрим адреса первых элементов:
var array1: Array = [10, 20, 30]var array2: Array = array1 array1.withUnsafeBufferPointer { buffer in let ptr0 = buffer.baseAddress print(ptr0)} array2.withUnsafeBufferPointer { buffer in let ptr0 = buffer.baseAddress print(ptr0)}
Вывод программы будет следующим:
Optional(0x0000600001726aa0)Optional(0x0000600001726aa0)
Из этого мы можем сделать вывод, что никакого копирования не происходит: обе переменные указывают на один и тот же адрес первого элемента массива. Такая оптимизация позволяет не создавать два одинаковых объекта и не резервировать под них дополнительную память.
Но когда же происходит копирование объекта в другое адресное пространство? Как понятно из названия, это происходит именно в тот момент, когда мы изменяем объект — удаляем, добавляем или заменяем элементы:
var array1: Array = [10, 20, 30]var array2: Array = array1// добавляем новый элементarray2.append(40) array1.withUnsafeBufferPointer { buffer in let ptr0 = buffer.baseAddress print(ptr0)} array2.withUnsafeBufferPointer { buffer in let ptr0 = buffer.baseAddress print(ptr0)}
Вывод программы:
Optional(0x0000600001714aa0)Optional(0x000060000210c700)
Мы изменили `array2`, добавив в него элемент `40`. В этот момент система зарезервировала новое место под массив, скопировала все элементы `array1` по новому адресу и добавила новый элемент. Поэтому мы видим два разных адреса памяти.
То же самое поведение будет наблюдаться и с другими коллекциями, такими как String, Set, Dictionary. Правда, для этих коллекций метод withUnsafeBufferPointer не подходит: `String` может храниться не в непрерывной области памяти, а Set и Dictionary скорее всего реализованы таким образом, что их элементы не располагаются последовательно в памяти. Однако механизм COW для них тоже работает.
Копирование при передаче в функцию
Интересный вопрос: что происходит, когда мы передаём массив в функцию? Происходит ли копирование, или внутри функции используется тот же объект?
func printArrayAdress(array: [Int]) { array.withUnsafeBufferPointer { buffer in let ptr0 = buffer.baseAddress print(ptr0) }} var array1: Array = [10, 20, 30] array1.withUnsafeBufferPointer { buffer in let ptr0 = buffer.baseAddress print(ptr0)} printArrayAdress(array: array1)
Вывод программы:
Optional(0x000060000172d0e0)Optional(0x000060000172d0e0)
Это значит, что внутри функции используется тот же адрес массива, что и снаружи. Копирования не происходит.
Пользовательские структуры
Теперь перейдём к важной части, ради которой и написана данная статья: что происходит с пользовательскими структурами?
Для всех пользовательских структур механизм Copy-on-Write, к сожалению, не работает. Следующий пример это наглядно демонстрирует.
Создадим структуру точки с двумя координатами:
struct Point { var x: Double var y: Double}
Теперь создадим объект и присвоим его другой переменной:
var firstPointRef: Point = Point(x: 1, y: 1)var secondPointRef = firstPointRef withUnsafePointer(to: &firstPointRef) { pointer in print(pointer)} withUnsafePointer(to: &secondPointRef) { pointer in print(pointer)}
Вывод:
0x00000001049fc3e00x00000001049fc3f0
Адреса различаются. То есть, без каких-либо дополнительных действий система всегда копирует объект.
Реализация COW вручную
Но что делать, если нам всё же нужен подобный механизм? Ответ прост: его можно реализовать с помощью классов, так как классы — ссылочный тип, и на один объект может ссылаться несколько переменных.
Создадим класс, в котором можно хранить любую пользовательскую структуру:
final class COWObject { var value: T init(_ value: T) { self.value = value } }
Мы создаём шаблонный класс, в котором будет храниться пользовательская структура. Само свойство value открыто на запись и чтение — мы можем изменять его как угодно, и это именно то, что нам нужно.
Далее необходимо создать контейнер — структуру, которая будет хранить объект COWObject и сможет самостоятельно делать перекопирование объекта в тот момент, когда на него существует несколько ссылок и происходит его изменение.
Чтобы узнать, есть ли более одной ссылки на объект, мы будем использовать функцию:
@inlinable public func isKnownUniquelyReferenced(_ object: inout T) -> Bool where T : AnyObject
С этой функцией всё просто: она принимает объект класса и возвращает true, если на объект есть только одна сильная ссылка, и false — если ссылок больше одной. Таким образом, контейнер будет выглядеть следующим образом:
struct COWContainer { private var object: COWObject var value: T { get { object.value } set { if !isKnownUniquelyReferenced(&object) { object = COWObject(newValue) } else { object.value = newValue } } } init(_ value: T) { object = COWObject(value) } func printAdress() { print(Unmanaged.passUnretained(object).toOpaque()) } }
В структуре хранится объект COWObject. Это свойство скрыто извне, чтобы к нему не было прямого доступа, поэтому оно помечено как private. Снаружи доступ к пользовательскому типу T осуществляется через свойство value, которое открыто на чтение и запись. Как только объект изменяется, срабатывает блок set, в котором выполняется проверка: если на данный объект существует более одной ссылки, создаётся новый объект; если ссылка одна — значение заменяется у текущего объекта.
Для проверки добавим функцию, которая выводит адрес объекта object.
Теперь проверим, как будет работать наш механизм.
var firstPointRef: COWContainer = .init(Point(x: 1, y: 1))var secondPointRef = firstPointRef firstPointRef.printAdress()secondPointRef.printAdress()
Программа выводит следующий результат:
0x00006000002180400x0000600000218040
Видно, что в данный момент существует один объект, который хранит в себе значение точки, поскольку этот объект является экземпляром класса. На объект COWObject, который хранит точку, ссылаются два объекта — firstPointRef и secondPointRef.
Давайте теперь попробуем изменить свойства у второго объекта и посмотрим, что будет происходить.
var firstPointRef: COWContainer = .init(Point(x: 1, y: 1))var secondPointRef = firstPointRef// Меняем свойства объектаsecondPointRef.value.x = 2 firstPointRef.printAdress()secondPointRef.printAdress()
Вывод:
0x000060000020f1a00x0000600000228a20
Мы изменили второй объект secondPointRef, изменив координату x на значение 2. Сработал блок set, и поскольку на объект COWObject ссылаются два контейнера, произошло создание нового объекта COWObject. Таким образом, теперь существует два объекта COWObject и, соответственно, две разные точки.
В этой статье мы рассмотрели, как в Swift происходит копирование объектов и как можно реализовать механизм Copy-on-Write для пользовательских структур. Это особенно полезно, когда структуры занимают относительно большой объём памяти, и важно избегать лишнего копирования.