JavaRush /Курси /Swift SELF /Value semantics на практиці: копії, мутації

Value semantics на практиці: копії, мутації

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

1. Value semantics: основна ідея

Коли ви тільки починаєте писати код, легко подумати так: «Якщо дві змінні виглядають однаково, значить, вони якось пов’язані». І справді, у деяких мовах і для деяких типів так і є. Але у Swift для struct за замовчуванням діє ідея value semantics: значення поводиться як число або рядок — як самостійна «річ». Якщо ви присвоїли одне значення іншому, ви отримали копію значення, і далі вони живуть незалежно.

Щоб це не здавалося філософією, зафіксуймо просту побутову аналогію. struct — це як роздрукований рецепт на аркуші. Якщо ви зробили ксерокопію рецепта і на копії перекреслили «200 г цукру» та написали «50 г», оригінальний аркуш удома в папці не зміниться. І це прекрасно: інакше ваша бабуся одного дня отримала б борщ зі смаком чізкейка, а винними були б ви та «спільні посилання».

Схематично це можна уявити так:

flowchart LR
    A[Змінна a: Book] -->|присвоєння| B[Змінна b: Book]
    B -->|змінюємо b.title| B2["b: Book (нове значення)"]
    A -->|не змінюється| A2["a: Book (старе значення)"]

2. Копіювання та незмінність

Копія під час присвоєння

Найпростіше почати з присвоєння. Саме тут зазвичай і стається «злам очікувань»: студент змінює поле у b і чекає, що a теж зміниться, бо «вони ж однакові». Але у struct це не так: a і b — два різні значення. Так, спочатку вони однакові, але не пов’язані.

Давайте опишемо нашу модель «книга» для мінібібліотеки. Поки що без складнощів: лише id, title та author.

import Foundation

struct Book {
    let id: Int
    var title: String
    var author: String
}

Тепер подивімося на копіювання безпосередньо.

import Foundation

struct Book {
    let id: Int
    var title: String
    var author: String
}

var a = Book(id: 1, title: "Dune", author: "Frank Herbert")
var b = a

b.title = "Dune (Director's Cut?)"

print(a.title) // Dune
print(b.title) // Dune (Director's Cut?)

Зверніть увагу: ми змінили b.title, але a.title залишився попереднім. Ось це і є value semantics у чистому вигляді.

let повністю фіксує екземпляр

Дуже поширена думка новачка: «Ну в мене ж усередині struct поле var, отже я можу його змінювати». І тут Swift спокійно відповідає: «Можете. Але лише якщо сам екземпляр — var». Якщо екземпляр оголошено через let, він стає повністю незмінюваним. Це не покарання, а захист: значення має залишатися значенням, а не перетворюватися на «напівзаморожений об’єкт».

Давайте зловимо це на маленькому прикладі й заодно закріпимо правило.

import Foundation

struct Book {
    let id: Int
    var title: String
}

let book = Book(id: 1, title: "Dune")
// book.title = "Dune 2" // ❌ не можна: екземпляр оголошено як let

print(book.title) // Dune

Якщо вам потрібно змінювати поля — оголошуйте екземпляр як var. Якщо ні — let допомагає і компілятору, і вашому мозку: менше місць, де щось може «несподівано змінитися».

3. Передавання у функції та повернення копії

Чому зміни всередині функції не видно зовні

Наступний момент, який часто дивує: ви передаєте Book у функцію, змінюєте title всередині функції, але зовні нічого не змінюється. І це теж value semantics: функція отримує свою копію значення — логічно незалежну, — і ви змінюєте саме її.

Є ще один нюанс: параметри функції в Swift за замовчуванням незмінювані. Тобто ви навіть не зможете написати book.title = ... прямо в параметрі — компілятор вас зупинить. У Swift це зроблено навмисно, щоб не плутати локальну правку зі зміною аргументу в коді, що викликає.

Історично Swift навіть відмовився від ідеї мутабельних параметрів (var param), бо їх легко було сплутати з inout.

Подивімося на приклад.

import Foundation

struct Book {
    let id: Int
    var title: String
}

func renameToUppercased(_ book: Book) -> Book {
    var copy = book
    copy.title = copy.title.uppercased()
    return copy
}

let a = Book(id: 1, title: "Dune")
let b = renameToUppercased(a)

print(a.title) // Dune
print(b.title) // DUNE

Тут важливо: ми не намагаємося «змінити a». Ми чесно кажемо: «Дай книгу — я поверну нову книгу». Це стиль через копію і повернення.

Чернетка та відкат: реалістичний сценарій

Поки що ми копіювали Book і Library на рівні однієї змінної. У реальному коді частіше трапляється такий сценарій: у вас є поточний стан, ви робите чернетку, пробуєте зміни, а потім вирішуєте, приймати їх чи ні.

Value semantics ідеально підходить для цього: ви можете вільно робити копії, не боячись випадково зламати оригінал.

Уявімо це як маленьку історію: спробуємо перейменувати книгу й відкотимо зміни, якщо нова назва виявиться порожньою.

import Foundation

struct Book {
    let id: Int
    var title: String
}

func tryRename(_ book: Book, to newTitle: String) -> Book {
    var copy = book
    if !newTitle.isEmpty {
        copy.title = newTitle
    }
    return copy
}

let original = Book(id: 1, title: "Dune")
let attempt = tryRename(original, to: "")

print(original.title) // Dune
print(attempt.title)  // Dune

Зверніть увагу: ми попрацювали з attempt, але оригінал не зачепили. Це дуже спокійна модель: менше сюрпризів, менше запитань «чому воно змінилося саме собою».

4. Як оновлювати дані: копія чи мутація

У реальному коді стан доводиться оновлювати постійно. І в Swift із value semantics зазвичай є два базові шляхи: або ви повертаєте нову версію значення — і це часто читається дуже чисто, або змінюєте існуючу змінну на місці через inout.

Обидва підходи нормальні, але читаються вони по-різному, і ризики в них теж різні.

Щоб не бути голослівними, візьмімо нашу мінібібліотеку: це просто список книг у масиві.

import Foundation

struct Book {
    let id: Int
    var title: String
    var author: String
}

struct Library {
    var books: [Book]
}

Два варіанти: повернути нову копію або змінити на місці

Тепер зробімо два варіанти «додати книгу».

Варіант А: повернути нову копію Library.

import Foundation

struct Book { let id: Int; var title: String; var author: String }
struct Library { var books: [Book] }

func adding(_ book: Book, to library: Library) -> Library {
    var copy = library
    copy.books.append(book)
    return copy
}

Варіант Б: змінити існуючу змінну через inout.

import Foundation

struct Book { let id: Int; var title: String; var author: String }
struct Library { var books: [Book] }

func addInPlace(_ book: Book, to library: inout Library) {
    library.books.append(book)
}

Різниця у використанні відчувається одразу.

import Foundation

struct Book { let id: Int; var title: String; var author: String }
struct Library { var books: [Book] }

func adding(_ book: Book, to library: Library) -> Library {
    var copy = library
    copy.books.append(book)
    return copy
}

func addInPlace(_ book: Book, to library: inout Library) {
    library.books.append(book)
}

let dune = Book(id: 1, title: "Dune", author: "Frank Herbert")

var lib1 = Library(books: [])
let lib2 = adding(dune, to: lib1)

print(lib1.books.count) // 0
print(lib2.books.count) // 1

addInPlace(dune, to: &lib1)
print(lib1.books.count) // 1

Зверніть увагу на & при inout: це не прикраса, а спеціальний маркер «увага, зараз буде зміна». І це добре: ви бачите зміну прямо в місці виклику.

Мініпідсумок: коли що обирати

Іноді студентам хочеться «одне правило на все». На жаль, а може, й на щастя, у програмуванні так буває рідко. Але можна тримати в голові просте порівняння: копія, яку повертають, робить код більш функціональним і передбачуваним, а inout підкреслює, що змінюється конкретна змінна.

Підхід Як виглядає виклик Що видно в місці виклику Типове застосування
Повернути нову копію
let new = updated(old)
«створюю нову версію» безпечне перетворення
inout
update(&value)
«зараз value зміниться» навмисна зміна стану

Так, інколи ви поєднуватимете обидва підходи. Наприклад, усередині mutating методу ви можете використовувати стиль «створили копію, змінили, присвоїли назад» — це нормально. Головне, щоб зовнішній контракт був зрозумілим.

5. inout, mutating і ексклюзивний доступ

inout — контракт на зміну, а не вказівник

На цьому етапі у студентів часто виникає небезпечна думка: «О, inout — це як посилання або вказівник, тепер я зможу змінювати все звідусіль». І Swift такий: «Стоп. Спокійно. inout — це не “дайте адресу змінної”, а контракт на тимчасову ексклюзивну зміну».

Якщо говорити простими словами, inout у Swift працює як «доки документ у мене на столі, внести в нього правку, а потім передати його назад». У документації Swift прямо підкреслюється: відмінність inout від звичайної мутабельної локальної копії полягає в тому, що зміни записуються назад у код, що викликає.

Є ще одна важлива деталь: & у Swift — не «взяти адресу як у C». Swift взагалі не обіцяє, що змінна обов’язково матиме стабільну адресу в пам’яті. У багатьох випадках & — це просто спосіб дати тимчасовий доступ у межах операції, а не справжній «address-of».

Для нас, як для новачків, практичне правило таке: думайте про inout як про «дозвіл функції змінити вашу змінну», а не як про «ми тепер живемо у світі вказівників».

mutating метод і inout параметр: одна ідея

Усередині struct ви вже бачили mutating-методи. Зовні ви бачите inout. Це насправді дуже близькі ідеї: і там, і там Swift вимагає ексклюзивного доступу, щоб безпечно змінювати значення.

Зробімо бібліотеку трохи більш «об’єктною» у хорошому сенсі: додамо метод add.

import Foundation

struct Book { let id: Int; var title: String; var author: String }

struct Library {
    var books: [Book] = []

    mutating func add(_ book: Book) {
        books.append(book)
    }
}

Використання:

import Foundation

struct Book { let id: Int; var title: String; var author: String }

struct Library {
    var books: [Book] = []
    mutating func add(_ book: Book) { books.append(book) }
}

var library = Library()
library.add(Book(id: 1, title: "Dune", author: "Frank Herbert"))

print(library.books.count) // 1

Чому це корисно? Бо правила зміни стану лежать поруч із даними. Чим далі ви просуватиметеся, тим частіше обиратимете «метод усередині struct» замість «функція десь зовні», просто тому, що так читається краще.

Чому Swift іноді свариться через conflicting access

На цьому місці багато хто вперше зустрічає повідомлення компілятора або середовища виконання про «conflicting access» або «exclusivity violation». Це звучить грізно, ніби ви зламали матрицю, але по суті Swift просто захищає правило: коли значення змінюють через inout/mutating, доступ має бути ексклюзивним. Тобто поки триває зміна, ніхто інший не повинен одночасно читати чи писати те саме значення.

У документації Swift це часто називають «law of exclusivity», і це поширюється на inout та мутабельні сетери.

Ми не будемо зараз заглиблюватися в багатопотоковість і складні випадки. Нам достатньо одного практичного відчуття: якщо ви намагаєтеся в одному виразі і читати, і змінювати той самий var, Swift може попросити вас переписати код трохи ясніше.

Наприклад, замість «зробити все в одному рядку» ви часто виносите фрагмент у тимчасову змінну, і конфлікт зникає. Це не «костиль», а спосіб зробити доступи очевидними.

6. Колекції всередині struct і Copy-on-Write

Тут часто виникає другий шар тривоги: «Гаразд, Book — це значення. Але Library містить Array. Раптом масив спільний, і зміни “витечуть”?». Гарна новина: з погляду API Array у Swift теж поводиться як значення. Тобто під час копіювання Library ви й надалі очікуєте незалежності.

Ви вже зустрічали ідею Copy-on-Write (COW) у темі про масиви: Swift намагається не копіювати пам’ять раніше часу, але логічно це все одно value semantics.

Перевіримо це на простому прикладі, не занурюючись в оптимізації.

import Foundation

struct Library {
    var tags: [String]
}

var a = Library(tags: ["sci-fi", "classic"])
var b = a

b.tags.append("epic")

print(a.tags) // ["sci-fi", "classic"]
print(b.tags) // ["sci-fi", "classic", "epic"]

Ззовні ви бачите саме те, чого й очікуєте від значень: a не змінився. А те, що всередині Swift у певний момент міг «поділити буфер масиву», а під час мутації відокремити його, — це приємна оптимізація, яка не повинна ламати вашу модель світу.

7. Приклад: бібліотека як значення

Тепер зберемо маленький застосунок: у нас є бібліотека, ми робимо копію, змінюємо копію, друкуємо обидві. Саме тут value semantics перестає бути терміном і стає практичною зручністю.

import Foundation

struct Book {
    let id: Int
    var title: String
    var author: String
}

struct Library {
    var books: [Book] = []

    mutating func add(_ book: Book) {
        books.append(book)
    }
}

var mainLibrary = Library()
mainLibrary.add(Book(id: 1, title: "Dune", author: "Frank Herbert"))

var draftLibrary = mainLibrary
draftLibrary.add(Book(id: 2, title: "Neuromancer", author: "William Gibson"))

print(mainLibrary.books.count)  // 1
print(draftLibrary.books.count) // 2

Якщо ви колись робили чернетку документа або копію налаштувань перед експериментом — ви вже інтуїтивно розумієте, навіщо це потрібно.

8. Типові помилки під час роботи з value semantics

Помилка №1: очікування спільного стану після b = a.
Якщо ви присвоїли один struct іншому, ви отримали незалежну копію значення. Часто новачок інтуїтивно чекає поведінки «дві змінні дивляться на один об’єкт», а потім дивується, що зміни не поширилися. Виправляється це не трюками, а правильною моделлю: struct у Swift — це значення, і присвоєння створює копію.

Помилка №2: спроба змінити аргумент функції без inout, а потім здивування, що зовні все залишилося по-старому.
Звичайний параметр функції — це логічно окреме значення, і навіть якщо ви створите локальну змінну var copy = param і зміните її, назовні це не вийде. Якщо потрібно змінити змінну сторони, що викликає, використовуйте inout і & як явний контракт запису назад. Відмінність inout від звичайної мутабельної локальної копії — саме в механізмі запису назад.

Помилка №3: сприйняття & як «взяти адресу змінної, тепер я майже на C».
& у Swift — не обіцянка стабільної адреси і не запрошення жити на вказівниках. Це спосіб тимчасово дати функції ексклюзивний доступ для зміни, і компілятор або середовище виконання дуже уважно стежать, щоб не було конфліктних доступів. Навіть у документації щодо моделі пам’яті підкреслюється, що & — не address-of, а механізм тимчасового доступу.

Помилка №4: спроба запхати inout у замикання «на потім».
Іноді хочеться зробити так: «Отримаю inout параметр, сформую замикання, а виконаю його пізніше». Це призводить до дуже заплутаних ситуацій, бо inout за змістом живе лише в обмеженому часовому вікні. Swift спеціально обмежує такі випадки, бо вони приводили до неочікуваних результатів із «shadow copy».

Помилка №5: перетворення inout на універсальний молоток «аби працювало».
inout — потужний інструмент, але він має відображати намір API: «ця функція змінює ваш аргумент». Якщо ви ставите inout просто тому, що не хочеться повертати нове значення, код стає менш передбачуваним: його складніше читати й тестувати. Часто копія, яку повертають, виходить простішою та чеснішою, а inout залишають для тих випадків, де зміна справді природна (наприклад, методи керування станом Library.add(...)).

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