JavaRush /Курси /Swift SELF /Copy-on-Write у Array

Copy-on-Write у Array

Swift SELF
Рівень 12 , Лекція 5
Відкрита

1. Чим корисний Copy-on-Write

Уявіть, що у вас є масив із 100000 елементів. Ви присвоюєте його новій змінній — і… що далі? Якби Swift робив «чесну копію» просто в момент присвоєння, то будь-яке невинне var b = a перетворювалося б на дорогу операцію: памʼять, час, смуток і вентилятор ноутбука, який починає імітувати зліт.

Втім, Swift обіцяє нам важливу річ: Arrayvalue 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.

Тобто в мутації масиву є дві потенційні складові витрат:

Що відбувається Чому це може бути дорогим Приклад
Зсув елементів Треба фізично перемістити багато значень
insert(0, at: 0)
Розширення буфера Масиву потрібно більше памʼяті серія append після заповнення capacity
CoW-копіювання Буфер розділений з іншою копією
b.append(...)

І це добре узгоджується з ідеєю з документації: 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 — приємний бонус, який допомагає вашому зрозумілому коду бути достатньо швидким.

1
Задача
Swift SELF, 12 рівень, 5 лекція
Недоступна
Два кошики
Два кошики
1
Задача
Swift SELF, 12 рівень, 5 лекція
Недоступна
Обережне додавання
Обережне додавання
1
Задача
Swift SELF, 12 рівень, 5 лекція
Недоступна
Список вихідних
Список вихідних
1
Задача
Swift SELF, 12 рівень, 5 лекція
Недоступна
Хвіст і копія
Хвіст і копія
1
Опитування
Слайси масивів, рівень 12, лекція 5
Недоступний
Слайси масивів
Робота з ArraySlice і двовимірними масивами
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ