1. Чим корисний Copy-on-Write
Уявіть, що у вас є масив із 100000 елементів. Ви присвоюєте його новій змінній — і… що далі? Якби Swift робив «чесну копію» просто в момент присвоєння, то будь-яке невинне var b = a перетворювалося б на дорогу операцію: памʼять, час, смуток і вентилятор ноутбука, який починає імітувати зліт.
Втім, Swift обіцяє нам важливу річ: Array — value type. Тобто якщо ми скопіювали змінну й змінили «копію», початкове значення не повинно раптово змінитися «за компанію». Отже, Swift має поєднати дві, на перший погляд, суперечливі цілі: поведінку як у незалежних значень і швидкість, ніби копіювання ми уникаємо.
Саме це й розв’язує Copy-on-Write: «копіюй не тоді, коли можна, а тоді, коли треба».
Value semantics: поведінка, а не байти
Коли ми кажемо «Array — value type», ми насамперед говоримо про поведінку. З погляду програміста value semantics означає: якщо ви присвоїли значення іншій змінній, у вас ніби зʼявилося друге незалежне значення. Навіть якщо під капотом усе влаштовано складніше.
Давайте подивимося на очікувану поведінку:
import Foundation
var a = [1, 2, 3]
var b = a // створюється новий масив
b.append(4)
print(a) // [1, 2, 3]
print(b) // [1, 2, 3, 4]
Якби a раптом стало [1, 2, 3, 4], це була б поведінка «за посиланням», і половина ваших програм перетворювалася б на детектив: «Хто зіпсував масив?»
І ось тут ключ: Swift зобовʼязаний поводитися так, ніби a і b незалежні. Але він не зобовʼязаний фізично копіювати 3 елементи (або 100000) просто в момент var b = a.
2. Інтуїтивна модель CoW: спільний буфер до мутації
Copy-on-Write можна уявити як дуже побутову історію.
Поки ви лише читаєте дані, можна «ділити» одне й те саме внутрішнє сховище. Але щойно хтось хоче змінити дані, йому видають окрему копію, щоб не псувати життя іншим.
Ось схема (це не код, а модель):
flowchart TD
A["Масив a"] --> S["Спільний буфер елементів"]
B["Масив b = a"] --> S
B -->|"append / insert / remove / b[i] = ..."| C{"Буфер унікальний?"}
C -->|Так| M["Змінюємо буфер на місці"]
C -->|Ні| Copy["Копіюємо буфер"]
Copy --> NewS["Новий буфер для b"]
NewS --> M
Саме тому це називається Copy-on-Write: «копія під час запису», тобто копіювання відкладається до моменту мутації.
Подібний підхід активно використовують в екосистемі Swift, тому що він дозволяє структурам поводитися як значення і при цьому не копіювати великі обʼєкти щоразу. У матеріалах про Swift Evolution і в документації до стандартної бібліотеки часто описують загальну ідею: структури можуть тримати посилання на внутрішній обʼєкт і копіювати його лише під час мутації, перевіряючи унікальність посилання.
4. CoW на практиці: «список для читання»
Щоб приклади були не абстрактним «масивом із чисел», продовжимо навчальний мінізастосунок: список книг, які ми хочемо прочитати. Поки без struct і красивої моделі — просто [String], тому що до типів-моделей ми дійдемо пізніше.
«Копія списку» виглядає як незалежне значення
import Foundation
var readingList = ["Dune", "1984", "Foundation"]
var weekendList = readingList
weekendList.append("The Martian")
print(readingList) // ["Dune", "1984", "Foundation"]
print(weekendList) // ["Dune", "1984", "Foundation", "The Martian"]
З погляду поведінки — усе ідеально: базовий список не змінився.
І тепер важливий момент: ми не бачимо, чи зробив Swift реальну копію під час weekendList = readingList, чи відклав її до append. Але сенс CoW якраз у тому, що копію часто відкладають до моменту зміни.
Функціональний стиль: «поверни новий масив»
Дуже типовий прийом: функція приймає масив, робить «локальну змінювану копію», додає елемент і повертає новий масив.
import Foundation
func addingBook(_ title: String, to list: [String]) -> [String] {
var copy = list
copy.append(title)
return copy
}
let base = ["Dune", "1984"]
let extended = addingBook("The Martian", to: base)
print(base) // ["Dune", "1984"]
print(extended) // ["Dune", "1984", "The Martian"]
Це дуже читабельно: функція не змінює вхідний масив, а створює новий результат. При цьому всередині copy = list може бути дешевим завдяки CoW, доки не станеться мутація. І щойно ми викликаємо append, копіювання відбувається лише за потреби.
5. Коли відбувається копіювання
Тут ми підходимо до головного питання: що означає «не копіюється завжди»?
Інтуїтивно це виглядає так:
- якщо ви присвоїли масив в іншу змінну і обидві версії лише читають — можна ділити внутрішній буфер;
- якщо хоча б одна версія хоче змінити масив — треба переконатися, що зміна не зачепить «іншого власника»;
- якщо буфер розділений між кількома масивами — перед зміною робиться копія.
У Swift це реалізовано через перевірку «унікальності» внутрішнього сховища. Технічно в Array це сховано всередині стандартної бібліотеки, але вам важливо розуміти цю ідею саме на рівні поведінки: копіювання — це захисний крок перед записом, якщо дані розділені.
Звідси випливає корисне практичне спостереження: два рядки коду можуть виглядати однаково, але за «ціною» поводитися по-різному, якщо в одному випадку масив унікальний, а в іншому ділить буфер із копією.
6. CoW і ціна операцій: append іноді дорожчає
У минулій лекції ви вже звикли до думки, що append зазвичай швидкий, а insert(at: 0) і removeFirst() часто дорогі, тому що зсувають елементи.
Тепер додамо ще один шар до цієї картини: іноді операція стає дорожчою не лише через зсуви, а й тому, що перед мутацією може знадобитися копіювання буфера через CoW.
Тобто в мутації масиву є дві потенційні складові витрат:
| Що відбувається | Чому це може бути дорогим | Приклад |
|---|---|---|
| Зсув елементів | Треба фізично перемістити багато значень | |
| Розширення буфера | Масиву потрібно більше памʼяті | серія append після заповнення capacity |
| CoW-копіювання | Буфер розділений з іншою копією | |
І це добре узгоджується з ідеєю з документації: Array є copy-on-write типом, і зайві «проміжні копії» можуть змусити його копіювати буфер раніше, ніж хотілося б.
Слово «амортизовано» тепер можна зрозуміти дуже приземлено: «зазвичай швидко, але іноді стається подія, після якої доводиться сплачувати накопичені борги».
7. CoW і ArraySlice: зріз утримує великий масив
Ви вже бачили, що зріз масиву — це не Array, а ArraySlice. І одна з причин, чому так зробили, — саме ефективна робота з памʼяттю: зріз може бути «виглядом» на шматок даних початкового масиву.
Але в такої ефективності є зворотний бік: якщо ви взяли маленький зріз від величезного масиву і зберегли його надовго, у вас є шанс, що він утримуватиме внутрішнє сховище початкового масиву (бо зріз «дивиться» на той самий буфер). Це не помилка, а плата за те, що зріз можна отримати дешево.
Тому правило з минулої лекції лишається золотим: якщо вам потрібен автономний маленький масив, робіть Array(slice).
import Foundation
let big = Array(1...10)
let tailSlice = big[8...9] // ArraySlice<Int>
let tail = Array(tailSlice) // Array<Int>
print(tailSlice) // [9, 10]
print(tail) // [9, 10]
З погляду CoW ідея така: Array(slice) створює самостійне копіювання потрібного діапазону. Так, це копіювання. Але воно осмислене: ви свідомо платите за те, щоб маленькі дані жили окремо.
8. Практика і трохи зазирнемо «під капот»
Copy-on-Write — це оптимізація, а не релігія. Добра новина: у більшості навчальних і навіть робочих задач вам не потрібно спеціально підлаштовуватися під CoW. Якщо ви пишете простий, читабельний код і не копіюєте гігантські масиви в циклі без потреби, стандартна бібліотека зазвичай справляється чудово.
Але кілька практичних звичок справді допомагають.
Як «дружити» з Copy-on-Write у своєму коді
По-перше, корисно розрізняти «функція повертає новий масив» і «функція змінює масив через inout». Обидва підходи нормальні; важливо, щоб вибір був усвідомленим і читачеві коду було зрозуміло, де саме відбудеться мутація.
По-друге, якщо ви хочете зробити ланцюжок змін, часто зручно створити одну локальну змінну var, внести серію мутацій і повернути результат, замість того щоб робити багато проміжних копій змінної (навіть якщо вони CoW). Це не тому, що «інакше все зламається», а тому, що так простіше читати й зазвичай ефективніше.
Порівняймо два стилі на нашому застосунку «список для читання».
import Foundation
func prepareWeekendList(from base: [String]) -> [String] {
var result = base
result.append("The Martian")
result.append("Neuromancer")
return result
}
let base = ["Dune", "1984"]
let weekend = prepareWeekendList(from: base)
print(base) // ["Dune", "1984"]
print(weekend) // ["Dune", "1984", "The Martian", "Neuromancer"]
І варіант із явною мутацією через inout:
import Foundation
func addWeekendPicks(_ list: inout [String]) {
list.append("The Martian")
list.append("Neuromancer")
}
var weekend = ["Dune", "1984"]
addWeekendPicks(&weekend)
print(weekend) // ["Dune", "1984", "The Martian", "Neuromancer"]
У першому варіанті функція «чиста» зовні: отримали вхід — повернули вихід, початкове значення не змінюється. У другому варіанті функція прямо говорить: «я змінюю те, що мені передали». В обох випадках усередині стандартна бібліотека може використовувати CoW, щоб не копіювати дані зайвий раз.
9. Типові помилки
Помилка № 1: думати, що var b = a завжди робить повну копію всіх елементів.
Через шкільну асоціацію «присвоєння = копіювання» легко уявити, що кожен такий крок негайно дублює памʼять. Але у Swift це копіювання радше про поведінку, а реальна копія може бути відкладена до мутації. Тому не дивуйтеся, що на практиці такі операції зазвичай швидкі: CoW саме й придумали, щоб не копіювати «про всяк випадок».
Помилка № 2: вважати, що CoW означає «копій не буває взагалі».
Copy-on-Write не скасовує копіювання — він робить його умовним. Щойно ви починаєте змінювати масив, а його буфер розділений з іншою копією, копіювання цілком реально відбудеться. Це нормально й очікувано: так Swift захищає value semantics. І так, іноді це може бути помітно за часом на великих даних.
Помилка № 3: випадково писати «багато копій» у циклі й сподіватися, що CoW усе врятує.
CoW допомагає, але якщо ви в циклі постійно створюєте нові масиви й одразу їх мутуєте, ви майже гарантовано змушуєте систему копіювати буфери знову і знову. Зазвичай краще тримати одну змінну-результат і акуратно доповнювати її, а не без потреби створювати нові масиви на кожному кроці.
Помилка № 4: зберігати ArraySlice як «маленький масив» і дивуватися витраті памʼяті.
Зріз виглядає як маленький список, друкується як маленький список, але може бути «вікном» у великий початковий буфер. Якщо вам потрібен незалежний невеликий масив, краще свідомо перетворити зріз у Array(slice). Це явне копіювання, але воно розв’язує задачу автономності даних.
Помилка № 5: намагатися «оптимізувати» передчасно, жертвуючи читабельністю.
Іноді новачок, почувши про CoW, починає писати код у стилі «аби тільки не копіювалося», і виходять дивні конструкції, які ніхто не хоче підтримувати (включно з автором через тиждень). У навчальних задачах головний критерій — коректність і зрозумілість. CoW — приємний бонус, який допомагає вашому зрозумілому коду бути достатньо швидким.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ