За годы я сидел по обе стороны стола: и как кандидат, и как собеседующий — в том числе на позиции в крупные продуктовые компании. И именно Copy-on-Write раз за разом оказывался той темой, на которой видно разницу между «слышал слово» и «понимаю механизм». Тема звучит обманчиво просто — «копируем только при записи», — но крупняк любит докапываться до формулировок: не «массив копируется по значению», а когда именно копируется буфер, что проверяется перед записью, почему у функции проверки именно такая сигнатура. Один неаккуратный оборот — и за него тут же цепляются уточняющим вопросом.
Сразу скажу про планку ожиданий, чтобы снять тревогу: на практике от кандидата редко хотят академически точного описания рантайма Swift до последнего бита. Хотят, чтобы вы держали в голове рабочую модель («struct снаружи, общий буфер с refcount внутри, копия на первой записи в разделяемый буфер») и могли её развернуть на пару уровней вглубь, не плавая в базовых понятиях вроде семантики значения и ссылки. Сидя по другую сторону стола, я отсекаю не тех, кто не знает внутренностей компилятора, а тех, кто путается в фундаменте и выдаёт заученные фразы, под которыми ничего нет. Поэтому статья идёт от фундамента к деталям: сначала то, что обязательно надо понимать, потом то, чем можно приятно удивить.
Вопрос-ловушка на 5 строк, с которого всё обычно и начинается:
var a = [1, 2, 3] var b = a // сколько памяти выделилось под копию? b.append(4) // а теперь?
Ответ «нисколько» на первой строке и «вот теперь — да» на второй — это и есть Copy-on-Write. Ниже разберём, как это устроено внутри, как написать свой CoW-тип руками, где он ломается, и чем всё это связано с фундаментальной разницей struct и class.
Статья построена в формате подготовки к собеседованию: сначала компактная шпаргалка с вопросами и ответами для быстрого повторения, затем детальный разбор с примерами и диаграммами.
Шпаргалка: 14 вопросов с короткими ответами
Что такое Copy-on-Write?
Оптимизация, при которой объект с value-семантикой физически делит хранилище (буфер) с другими копиями до тех пор, пока кто-то не попытается его изменить. В момент мутации, если буфер не уникален, делается реальная копия. Снаружи — поведение value-типа, по цене — почти как у reference-типа.
Зачем CoW вообще нужен?
Чтобы value-семантика Array, String, Dictionary, Set не стоила полной копии при каждом присваивании. Без CoW let b = a для массива на миллион элементов копировал бы миллион элементов. С CoW — копируется одно слово (указатель на буфер) + инкремент счётчика ссылок.
Какие типы в стандартной библиотеке используют CoW?
Array, ContiguousArray, String, Dictionary, Set, Data. Все они — struct-обёртки над внутренним буфером-классом, который и шарится между копиями.
Получают ли обычные struct CoW автоматически?
Нет. CoW не встроен в язык для произвольных структур. Это паттерн: struct, внутри которого лежит ссылка на класс-хранилище, плюс ручная проверка уникальности перед мутацией. Stdlib-типы реализуют его сами; свой тип получит CoW только если вы напишете его руками.
Какой ключевой API делает CoW возможным?
isKnownUniquelyReferenced(_:) — функция стандартной библиотеки. Принимает inout-ссылку на экземпляр класса и возвращает true, если на объект существует ровно одна сильная ссылка. На этой проверке держится решение «копировать буфер или можно писать на месте».
Почему isKnownUniquelyReferenced принимает inout?
Чтобы гарантировать эксклюзивный доступ к переменной на время проверки и не дать создать временную лишнюю ссылку, которая исказила бы счётчик. inout обеспечивает exclusive access (закон эксклюзивности доступа Swift) — без этого результат проверки был бы недетерминированным.
Работает ли isKnownUniquelyReferenced с Objective-C классами?
Нет. Для @objc-классов и объектов, пришедших по мосту (bridging) из Foundation, функция возвращает false. Она рассчитана на нативные Swift-классы.
Когда массив реально копируется?
Только при мутирующей операции (append, subscript-set, removeLast, ...) И при условии, что буфер в этот момент разделяется более чем одной ссылкой. Если ссылка одна — мутация идёт на месте, без копии.
Спасает ли CoW массива от шаринга, если элементы — классы?
Нет. CoW копирует буфер массива, но элементы-классы копируются как ссылки (поверхностная копия). Оба массива после копии будут указывать на одни и те же объекты-классы. Это классический источник неожиданного общего состояния.
inout-параметр — это передача по ссылке?
Нет. inout — это copy-in / copy-out: значение копируется в функцию, изменяется, и копируется обратно при выходе. Семантически это не указатель. Для CoW-типа это означает: внутри функции буфер может оказаться уникальным (мутация на месте) либо нет.
Делает ли CoW тип потокобезопасным?
Нет. Проверка уникальности и последующая мутация не атомарны. Два потока, мутирующие общий Array, дают гонку данных и UB. CoW — про память и копирование, не про синхронизацию.
Чем reserveCapacity помогает CoW?
Резервирует ёмкость заранее, чтобы серия append не вызывала повторных реаллокаций буфера. На уникальном буфере убирает лишние копии при росте. Полезно в горячих циклах.
В чём фундаментальная разница struct и class?
struct — value-тип: при присваивании/передаче семантически копируется, у него нет идентичности (=== к нему неприменим), нет наследования, управляется не через ARC напрямую. class — reference-тип: копируется ссылка, есть идентичность, наследование, lifecycle через ARC (retain/release). CoW — это способ дать struct дешёвую value-семантику, заняв у class механизм подсчёта ссылок.
Чем Swift в этом смысле отличается от Objective-C?
В Objective-C по умолчанию доминирует reference-семантика: NSArray, NSString, любой NSObject — это указатели, копирование = копия указателя + retain, value-семантика достигается ручным -copy (через NSCopying). Swift сделал value-типы первоклассными и безопасными по умолчанию — а CoW нужен именно затем, чтобы эта повсеместная value-семантика не стоила дорого.
Детальный разбор
1. Фундамент: семантика значения против семантики ссылки
Прежде чем говорить о CoW, нужно зафиксировать, что вообще копируется при b = a. Это не про «struct против class» как синтаксис, а про поведение при присваивании и передаче.
Семантика значения (value semantics): при присваивании или передаче в функцию создаётся независимая копия. Изменение одной копии не видно другим. Так ведут себя struct, enum, кортежи.
Семантика ссылки (reference semantics): при присваивании копируется ссылка, обе переменные указывают на один и тот же объект. Изменение через одну ссылку видно через все. Так ведут себя class, замыкания, actor.
struct PointV { var x: Int } class PointR { var x: Int; init(_ x: Int) { self.x = x } } var v1 = PointV(x: 1) var v2 = v1 // КОПИЯ значения v2.x = 99 print(v1.x, v2.x) // 1 99 — независимы let r1 = PointR(1) let r2 = r1 // копия ССЫЛКИ r2.x = 99 print(r1.x, r2.x) // 99 99 — один объект
С этим связано важное различие — идентичность против равенства:
==(Equatable) — равны ли значения.===— это один и тот же объект в памяти. Применим только к reference-типам; кstructего применить нельзя в принципе, потому что у значения нет идентичности.
Упрощённая (и не вполне точная) ментальная модель — «struct на стеке, class в куче». На практике value-тип, у которого есть поле-ссылка (например, struct с полем-String или полем-классом), живёт сложнее: сама структура может лежать на стеке, но её внутренний буфер — в куче. Именно эта ситуация и порождает CoW.
Любимый способ крупных компаний проверить, понимаете ли вы это «снаружи value — внутри куча» — задача на рекурсивные типы:
class A { var a: A? } // ✅ компилируется struct C { var c: C? } // ❌ ошибка компиляции struct B { var b: [B]? } // ✅ компилируется
class A живёт без проблем: ссылка имеет фиксированный размер (одно слово), сколько бы уровней вложенности ни было. struct C не компилируется — error: value type 'C' cannot have a stored property that recursively contains it: чтобы положить C целиком внутрь C (а Optional<C> хранит C инлайн), компилятору нужно вычислить размер, который оказывается бесконечным. А вот struct B с полем [B]? компилируется — и это ключевой момент: Array хранит элементы в куче за указателем, поэтому само поле b занимает фиксированный размер независимо от содержимого. Массив здесь работает ровно как «прослойка с heap-буфером», разрывающая рекурсию, — та самая конструкция «value снаружи, ссылка на буфер внутри», на которой стоит и CoW. (Тот же эффект дают indirect enum и любая обёртка-класс.)
Грубая карта типов Swift:
Value semantics Reference semantics ───────────────── ─────────────────── struct class enum closure (функция-замыкание) tuple actor Int, Double, Bool metatype классов Array, String, Dict, Set ... (value снаружи, CoW-буфер внутри)
Обратите внимание на последнюю строку: коллекции — это value-типы снаружи, но внутри у них reference-хранилище. Это и есть гибрид, ради которого существует Copy-on-Write.
Врезка: чем Swift отличается от Objective-C. В Objective-C по умолчанию доминирует reference-семантика —
NSArray,NSString,NSDictionaryи вообще любой потомокNSObjectпредставлены указателями, а присваиваниеNSArray *b = a;копирует указатель и делаетretain, не значение. Чтобы получить независимую копию, программист обязан явно вызвать-copy/-mutableCopy(контрактNSCopying), и для immutable-объектов-copyчасто вырождается вretain, а для mutable — в реальное копирование. Value-типов в ObjC по сути только сишные примитивы иstructиз C. Swift перевернул умолчание: value-типы стали первоклассными и безопасными по умолчанию (Array,String, вашиstruct), а чтобы эта повсеместная value-семантика не убивала производительность копированием, в стандартную библиотеку встроили Copy-on-Write. То есть CoW — это плата за то, что в Swift «по умолчанию всё копируется».
2. Какую проблему решает CoW
Возьмём наивную value-семантику без оптимизаций. Каждое присваивание массива — полная копия буфера:
var a = [Int](repeating: 0, count: 1_000_000) var b = a // в наивной модели: malloc + копирование 8 МБ var c = b // ещё 8 МБ
Три переменные — 24 МБ, хотя ни одну мы пока не меняли. Для коллекций, которые в Swift копируются на каждом шагу (присваивание, передача в функцию, возврат из функции, захват), это неприемлемо.
Противоположная крайность — сделать массив class (reference-семантикой) — ломает безопасность: две переменные начинают незаметно делить состояние, и мутация в одном месте «простреливает» в другое.
CoW — компромисс: вести себя как value (полная изоляция изменений), а платить как reference, пока никто не пишет. Цель — отложить реальную копию буфера до первой мутации и сделать её только если буфер действительно с кем-то разделён.
3. Анатомия CoW-типа: struct снаружи, класс-буфер внутри
Упрощённая модель того, как устроен Array:
// Внутреннее хранилище — КЛАСС (reference-тип), живёт в куче, // управляется ARC и имеет счётчик ссылок. final class ArrayBuffer { var elements: UnsafeMutablePointer<Element> var count: Int var capacity: Int // ... } // Сам Array — это STRUCT (value-тип) с единственным полем-ссылкой. struct Array<Element> { var buffer: ArrayBuffer }
Что происходит на var b = a:
Шаг 1: var a = [1, 2, 3] a (struct на стеке) ┌─────────────────┐ │ buffer ─────────┼────► ArrayBuffer (куча), refcount = 1 └─────────────────┘ ┌──────────────────────────┐ │ refcount: 1 │ │ [1, 2, 3] │ └──────────────────────────┘ Шаг 2: var b = a (копируется struct — то есть одно поле-ссылка) a ──────────────┐ ├──► ArrayBuffer (куча), refcount = 2 b ──────────────┘ ┌──────────────────────────┐ │ refcount: 2 │ │ [1, 2, 3] │ └──────────────────────────┘
b = a скопировал не миллион элементов, а одно машинное слово (указатель buffer) и сделал ARC-инкремент счётчика буфера: refcount 1 → 2. Дёшево и постоянно по времени.
Теперь — мутация:
Шаг 3: b.append(4) Проверка: буфер уникален? refcount == 2 → НЕТ. Значит, прежде чем писать, клонируем буфер: a ──────────────────► ArrayBuffer #1, refcount = 1 │ [1, 2, 3] │ b ──────────────────► ArrayBuffer #2, refcount = 1 (свежая копия) │ [1, 2, 3, 4] │
a осталась [1, 2, 3], b стала [1, 2, 3, 4] — value-семантика соблюдена. Но копия буфера случилась ровно один раз и ровно тогда, когда понадобилась.
А если бы ссылка была единственной:
var a = [1, 2, 3] a.append(4) // refcount == 1 → буфер уникален → пишем НА МЕСТЕ, без копии
Вот почему это называется Copy-on-Write: копия привязана не к присваиванию, а к первой записи в разделяемый буфер.
4. Сердце механизма: isKnownUniquelyReferenced
Решение «копировать или писать на месте» опирается на один вопрос: сколько сильных ссылок указывает на буфер прямо сейчас? Ответ даёт функция стандартной библиотеки:
func isKnownUniquelyReferenced<T: AnyObject>(_ object: inout T) -> Bool
Она возвращает true, если на переданный экземпляр класса существует ровно одна сильная ссылка (та самая, что вы передали).
final class Box { var value: Int init(_ value: Int) { self.value = value } } var box1 = Box(10) print(isKnownUniquelyReferenced(&box1)) // true — одна ссылка var box2 = box1 // вторая сильная ссылка print(isKnownUniquelyReferenced(&box1)) // false — refcount == 2 box2 = Box(20) // box1 снова уникален print(isKnownUniquelyReferenced(&box1)) // true
Три нюанса, которые любят на собеседовании:
Почему inout? Функции нужен эксклюзивный доступ к переменной, и она не должна сама создавать временную лишнюю ссылку (которая исказила бы счётчик до 2). inout гарантирует exclusive access по закону эксклюзивности доступа Swift и не плодит retain. Если бы сигнатура брала T по значению, на время вызова существовала бы вторая ссылка-аргумент, и функция всегда видела бы минимум две.
Только нативные классы. Для @objc-классов и объектов, пришедших по мосту (bridging) из Foundation, функция консервативно возвращает false. Поэтому свой CoW-буфер надо делать нативным Swift-классом (часто final).
weak/unowned ссылки не считаются. Функция смотрит на сильные ссылки. Слабая ссылка на тот же объект не сделает его «неуникальным».
5. Видим CoW глазами: адреса буфера и счётчик ссылок
Лучший способ убедиться, что буфер реально шарится, — посмотреть на адрес его хранилища до и после мутации.
func bufferAddress<T>(_ array: [T]) -> UnsafeRawPointer { array.withUnsafeBufferPointer { UnsafeRawPointer($0.baseAddress!) } } var a = [1, 2, 3] var b = a print(bufferAddress(a)) // 0x600000abc000 print(bufferAddress(b)) // 0x600000abc000 ← ТОТ ЖЕ буфер, копии не было b.append(4) print(bufferAddress(a)) // 0x600000abc000 ← a осталась на старом буфере print(bufferAddress(b)) // 0x600000def000 ← b переехала на копию
(Конкретные адреса у вас будут свои, важно их совпадение/расхождение.)
То же самое можно подтвердить через счётчик ссылок. Для нативного класса есть «нечестный», но рабочий способ заглянуть в refcount в отладке:
final class Storage { var data = [Int]() } let s1 = Storage() print(CFGetRetainCount(s1)) // 2 — не 1! (рантайм держит временные retain'ы) let s2 = s1 print(CFGetRetainCount(s1)) // 3 — на 1 больше
Обратите внимание: на единственной «логической» ссылке счётчик уже равен 2, а не 1 — рантайм держит временные retain'ы. Именно поэтому CFGetRetainCount — инструмент исключительно для исследования/отладки: абсолютное значение включает служебные retain'ы и не годится для логики в продакшене. Важна только разница (+1 на каждую новую ссылку). Для логики есть ровно один правильный инструмент — isKnownUniquelyReferenced.
6. Пишем свой CoW-тип руками
Теперь соберём всё вместе и реализуем собственный value-тип с Copy-on-Write. Это любимая «практическая» задача на senior-интервью.
// 1. Хранилище — КЛАСС. Именно его refcount мы будем проверять. private final class Storage { var data: [Int] init(_ data: [Int]) { self.data = data } // удобный клон для копирования func copy() -> Storage { Storage(data) } } // 2. Публичный тип — STRUCT с value-семантикой. struct CoWBuffer { private var storage: Storage init(_ data: [Int] = []) { storage = Storage(data) } // Чтение — без копий, отдаём общий буфер. var values: [Int] { storage.data } // Любая мутация проходит через единую точку: ensureUnique(). private mutating func ensureUnique() { if !isKnownUniquelyReferenced(&storage) { print("⚙️ буфер разделён — делаем копию") storage = storage.copy() } } mutating func append(_ x: Int) { ensureUnique() storage.data.append(x) } mutating func update(at index: Int, to value: Int) { ensureUnique() storage.data[index] = value } }
Поведение полностью повторяет стандартные коллекции:
var a = CoWBuffer([1, 2, 3]) var b = a // делят один Storage, копии нет print(a.values, b.values) // [1, 2, 3] [1, 2, 3] b.append(4) // ⚙️ буфер разделён — делаем копию print(a.values) // [1, 2, 3] — не задета print(b.values) // [1, 2, 3, 4] — изменилась только b b.update(at: 0, to: 99) // буфер b уже уникален — копии НЕ будет, пишем на месте print(b.values) // [99, 2, 3, 4]
Ключевые моменты, на которые смотрят на интервью:
Хранилище — обязательно
class(нужен refcount), публичный тип —struct(нужна value-семантика).Все мутирующие методы должны проходить через
ensureUnique()до записи. Забыли в одном методе — словили общий стейт и нарушение value-семантики.isKnownUniquelyReferenced(&storage)требует, чтобыstorageбылvarи передавалсяinout— поэтому проверка живёт внутриmutating-метода.
7. Подводные камни — то, что отделяет middle от senior
7.1. struct с полем-классом: CoW вас не спасёт
CoW массива копирует буфер массива, но элементы копируются «как есть». Если элемент — класс, копируется ссылка, а не объект:
final class Node { var value = 0 } var a = [Node()] var b = a // буфер скопируется при мутации, но Node — общий b[0].value = 42 // мутация ОБЪЕКТА, не буфера print(a[0].value) // 42 (!) — a и b делят один Node
Здесь b[0].value = 42 не меняет сам массив (его длину/состав), а мутирует объект по общей ссылке. CoW массива тут ни при чём — это поверхностная (shallow) копия. То же самое случается со struct, внутри которого лежит поле-класс: скопировав структуру, вы делите её внутренний объект.
7.2. Захват в замыкании ломает уникальность
Замыкание, захватившее переменную, держит дополнительную ссылку на буфер. Пока замыкание живо, буфер не уникален — и мутация массива даст лишнюю копию:
var data = [1, 2, 3] let printer = { print(data) } // замыкание захватило data, +1 ссылка на буфер data.append(4) // буфер не уникален → копия, хотя «логически» data одна
В горячем коде такие невидимые ссылки (замыкания, лишние временные переменные, передача в функции) приводят к копиям, которых не ждёшь.
7.3. inout — это не «по ссылке»
func mutate(_ arr: inout [Int]) { arr.append(0) }
inout реализован как copy-in / copy-out: на входе значение копируется внутрь, на выходе — обратно. Это не указатель на оригинал. Для CoW важно следствие: внутри функции буфер обычно оказывается уникальным (внешняя ссылка на время вызова «заморожена» эксклюзивным доступом), поэтому мутация чаще идёт на месте.
7.4. CoW не делает тип потокобезопасным
Проверка уникальности и мутация — две отдельные операции, между ними нет атомарности:
var shared = [1, 2, 3] // Поток 1: shared.append(4) // Поток 2: shared.append(5) // → гонка данных на refcount/буфере, неопределённое поведение
Array/Dictionary/Set не потокобезопасны. CoW — про память и копирование, синхронизацию обеспечивайте сами (очередь, actor, блокировка).
7.5. Рост буфера, count vs capacity и reserveCapacity
Любимая задача-«что не так с этим кодом»:
var array = [0, 1, 2] for i in 3...10_000_000_000 { // условно «много раз» добавляем в конец array.append(i) }
Здесь полезно различать два понятия. count — сколько элементов в массиве сейчас. capacity — сколько влезет в текущий буфер до следующей реаллокации. Когда count упирается в capacity, append выделяет новый буфер (примерно вдвое больше), копирует туда все элементы и освобождает старый. За счёт геометрического роста средняя стоимость одного append остаётся амортизированной O(1): да, отдельные вставки дорогие (полное копирование), но они случаются всё реже, и в сумме на N вставок приходится ~2N копирований — то есть O(1) на элемент.
Реальную прогрессию легко увидеть, печатая capacity в момент её изменения (Swift 6, 64-бит):
count=1 capacity: 0 → 2 count=3 capacity: 2 → 4 count=5 capacity: 4 → 8 count=9 capacity: 8 → 16 count=17 capacity: 16 → 36 count=37 capacity: 36 → 76 count=77 capacity: 76 → 156 ...
Коэффициент роста — примерно ×2 (точная формула — деталь реализации stdlib и не гарантирована: как видно, после 16 это ближе к 2·n + 4). Важно не конкретное число, а сам принцип: ёмкость растёт мультипликативно, а не на +1 за раз, — иначе append был бы O(n) на каждую вставку и O(n²) на цикл.
Что цепляет интервьюер в этой задаче:
«Каждый ли
appendкопирует массив?» — Нет. Реаллокация происходит только при исчерпанииcapacity, между нимиappendпишет в уже выделенный буфер. Плюс к этому каждая вставка проверяет уникальность буфера: если массив ни с кем не делится — пишем на месте, копии буфера нет вовсе.«Что физически не так с этим циклом?» —
10^10элементов по 8 байт — это ~80 ГБ. Код упрётся в память задолго до конца. Это вопрос на здравый смысл, а не на синтаксис.«Как ускорить, если итоговый размер известен заранее?» —
reserveCapacity. Он выделяет буфер нужной ёмкости один раз и убирает промежуточные реаллокации:
var array = [0, 1, 2] array.reserveCapacity(1_000_000) // одна аллокация вместо ~20 удвоений for i in 3..<1_000_000 { array.append(i) }
И обратная сторона роста — лишние копии, когда буфер вдобавок ещё и разделён:
func appendAll(to base: [Int], _ items: [Int]) -> [Int] { var result = base // делит буфер с base for x in items { result.append(x) // первая итерация — копия (base ещё жива), } // дальше — на месте return result }
Первая мутация отвяжет result от base (одна CoW-копия), дальше пишем на месте — но реаллокации при росте всё равно будут, если не зарезервировать ёмкость заранее.
7.6. Мутация коллекции во время итерации
var elements = [1, 2, 3] for e in elements { print(e) elements = [4, 5, 6] // переприсваиваем прямо внутри цикла }
Что выведет? — 1 2 3, а не 4 5 6. И это прямое следствие value-семантики плюс CoW.
for e in elements под капотом берёт у массива итератор (makeIterator()), а IndexingIterator для Array хранит собственную копию массива — то есть свою ссылку на буфер. Когда внутри цикла мы пишем elements = [4, 5, 6], мы лишь перенаправляем переменную elements на новый массив; копия, которую держит итератор, продолжает указывать на исходный буфер [1, 2, 3]. Цикл честно доходит до конца по старым данным.
Тот же исход будет и при мутации вместо переприсваивания:
var elements = [1, 2, 3] for e in elements { print(e) elements.append(99) // мутация общего буфера } // напечатает 1 2 3; итератор работает по своей копии
append увидит, что буфер делится с итератором (не уникален), сделает CoW-копию для elements, а итератор останется на нетронутом исходном буфере. В Objective-C аналогичный трюк с NSMutableArray (reference-семантика) кинул бы NSGenericException: collection was mutated while being enumerated — потому что энумератор и массив делят один объект. В Swift коллекция — value-тип, и итерация защищена собственной копией: то, что в ObjC было рантайм-краш, в Swift стало предсказуемым и безопасным поведением.
8. Как это связано с struct vs class (и снова про Objective-C)
CoW — прямое следствие выбора Swift в пользу value-семантики по умолчанию. Соберём картину целиком.
struct (value): копируется значение, нет идентичности (=== неприменим), нет наследования, не управляется ARC напрямую — у самой структуры нет счётчика ссылок (он есть только у её внутренних полей-классов, если они есть). class (reference): копируется ссылка, есть идентичность, наследование, lifecycle через ARC.
CoW занимает у мира классов ровно одну вещь — счётчик ссылок ARC — и приклеивает её к value-типу через внутренний класс-буфер. Получается «struct снаружи, refcount внутри»: дешёвое копирование reference-типа плюс изоляция value-типа.
В Objective-C этой проблемы не было, потому что не было и повсеместной value-семантики: коллекции были классами (NSArray), копирование по умолчанию означало копию указателя, а независимую копию приходилось запрашивать вручную через -copy/NSCopying. Swift поменял умолчание на «копируется значение» — и, чтобы это умолчание не било по производительности, встроил Copy-on-Write в ключевые типы стандартной библиотеки. Иначе говоря: в ObjC вы платили за value-семантику явным -copy там, где она нужна; в Swift вы получаете её бесплатно везде, а CoW гарантирует, что «бесплатно» не превратится в «дорого».
9. Типичные вопросы на собеседовании
«Что выведет код?»
var a = [1, 2, 3] var b = a b.append(4) print(a, b)
[1, 2, 3] [1, 2, 3, 4]. На b = a копии нет (общий буфер), на append буфер не уникален → копия, мутируется только b.
«А этот?»
final class Node { var v = 0 } var a = [Node()] var b = a b[0].v = 7 print(a[0].v)
7. CoW копирует буфер массива, но элемент — ссылка на общий Node. Это поверхностная копия.
«Получают ли мои собственные struct CoW автоматически?» — Нет. Это паттерн (struct + класс-буфер + isKnownUniquelyReferenced), а не фича компилятора. Stdlib-коллекции реализуют его вручную; ваш тип — только если вы напишете сами.
«Когда массив реально копирует буфер?» — При мутирующей операции и только если буфер в этот момент разделён (refcount > 1). Уникальный буфер мутируется на месте.
«Зачем isKnownUniquelyReferenced принимает inout?» — Для эксклюзивного доступа и чтобы не создать временную лишнюю ссылку, которая исказила бы счётчик.
«Делает ли CoW тип потокобезопасным?» — Нет. Проверка уникальности и мутация не атомарны; общий доступ из нескольких потоков — гонка данных.
«Чем inout отличается от передачи по ссылке?» — inout — copy-in/copy-out, а не указатель. Семантически это значение, скопированное туда и обратно.
«Как CoW связан с разницей struct и class?» — CoW даёт value-типу (struct) дешёвое копирование, заняв у reference-типа (class) механизм подсчёта ссылок: внутри value-обёртки лежит class-буфер, чей refcount и проверяется перед записью.
«Чем Swift отличается от Objective-C в этом вопросе?» — ObjC по умолчанию reference-семантичен (копия = копия указателя + retain, value-копия вручную через -copy), Swift по умолчанию value-семантичен, и CoW — плата за то, чтобы это умолчание было дешёвым.
10. Чек-лист «что сказать на собесе про CoW»
CoW = делим буфер, пока не пишем; на первой записи в разделяемый буфер — реальная копия.
Работает на паре «struct-обёртка + class-буфер»; проверка —
isKnownUniquelyReferenced(&storage).Используют
Array,String,Dictionary,Set,Data; обычные struct — только руками.Копия привязана к записи, а не к присваиванию, и только если буфер не уникален.
Поверхностная копия: элементы-классы остаются общими.
Не потокобезопасен;
inout— copy-in/copy-out, не ссылка.Это следствие value-семантики Swift по умолчанию — в отличие от reference-умолчания Objective-C.
Fynjy007
Просто любопытно, я из c# у нас массивы и прочее: классы. Почему в swift сделали cow вместо того, чтобы оставить массивы просто ссылочными типами? Есть ли в swift просто ссылочные массивы без всего этого cow? Шарить можно и ссылку, а если вот сильно надо, то можно сделать и копию. Явно. Без этих танцев с бубном под капотом.
fort_croquet Автор
Хороший вопрос, и он на самом деле не про CoW, а про уровень выше: CoW — это лишь оптимизация, а реальное решение — «value-семантика по умолчанию». Swift сделал коллекции value-типами специально, а CoW прикрутили потом, чтобы за эту семантику не платить полной копией на каждом присваивании.
Зачем вообще value по умолчанию? Чтобы убрать целую категорию багов, когда две переменные незаметно ссылаются на один массив и меняют друг друга. В C# знакомая ситуация:
var b = a; b.Add(x);— иaтоже изменился, потому что это одна ссылка. Пока массив не уехал в чужой метод/поток — всё хорошо, а как уехал, начинаются защитные копии «на всякий случай»,ReadOnlyCollection,ImmutableArray<T>и прочие приёмы, которые приходится держать в голове. Swift просто инвертировал дефолт: безопасно по умолчанию, а шаринг — это уже осознанное действие. C# тоже движется в ту же сторону —record,readonly struct,ImmutableArray, иммутабельные строки. Та же идея, с другого конца.Про «явную копию, когда надо»: проблема в том, что надо почти всегда, и разработчик об этом забывает. Дефолт «копируй руками» на практике даёт либо лишние защитные копии, либо пропущенные баги. CoW переворачивает: по умолчанию безопасно, а копия делается сама и только когда буфер реально с кем-то делится и в него пишут. То есть «бубен под капотом» как раз снимает бубен с тебя — ты ничего не копируешь вручную.
И да — ссылочный массив в Swift получается в одну строчку, никто не запрещает:
Хочешь reference — заверни в
class(или возьмиNSMutableArray). Swift даёт оба режима, просто дефолтом выбрал value: класс багов на общем мутабельном состоянии он закрывает бесплатно, а ценой за это стал CoW, которого ты как пользователь не замечаешь.Fynjy007
Понятно. Спасибо. По последнему примеру в коробке: там двойное разыменование указателя (указатель на коробку со счётчиком + указатель из коробки на массив в памяти)?
По поводу шарпа: зависит. Если межпоточное, то можно просто положить весь массив (или какую-то ещё штуку) под синхронизацию, что кстати и свифт требует, или использовать специальные массивы (к/л струкутуры данных), которые уже под капотом уже имеют в межпоточное с разными плюшками. Или да использовать Immutable типы с абстракциями IReadOnlyList.
В общем согласен. Мы всегда за что-то платим. Так или иначе.
fort_croquet Автор
Да, ровно так.
Box— это класс, то есть отдельная heap-аллокация со своим refcount-заголовком, а внутри него лежитArray, который сам по себе struct с одним полем — указателем на буфер. Буфер — это уже вторая heap-аллокация, тоже со своим счётчиком ссылок. Чтобы добраться до элемента, рантайм идётa → Box → буфер → элемент: два разыменования и два независимых refcount'а. Получается, что «ссылочный массив» через обёртку — это на один уровень индирекции больше, чем обычный значимыйArray(тот ведь и так всего лишь struct с указателем на единственный буфер).Любопытный побочный эффект: в этом сценарии CoW для внутреннего массива вообще не срабатывает. Когда делаешь
let b = a, инкрементится только счётчикBox, а буфер остаётся в единоличном владении этого одногоBox— то есть уникален. Поэтомуb.items.append(...)пишет в буфер на месте, без копии, иaвидит изменение. Шаринг живёт на уровне коробки, а не буфера — это и даёт чистую reference-семантику.Про шарп — полностью согласен, тут всё про trade-off. Для межпотока CoW ничего не решает, Swift точно так же требует явной синхронизации (очередь,
actor, лок) или специализированных потокобезопасных структур — здесь языки в одной лодке. Разница только в дефолте: Swift по умолчанию платит автоматизацией CoW и редкими копиями, C# — ручной дисциплиной и защитными копиями там, где значимость нужна. Бесплатного обеда нет, согласен на сто процентов.