1. Семантика значень: основна ідея
Якщо ви тільки починаєте, легко думати так: «змінна — це коробка, а всередині лежить річ». І це… напрочуд вдала модель, доки ми говоримо про типи зі семантикою значень. Семантика значень — це про спостережувану поведінку застосунку: коли ви присвоюєте значення іншій змінній або передаєте його у функцію, ви отримуєте незалежну копію «речі», а зміни в одній коробці не повинні магічно змінювати вміст іншої.
Чому це важливо в реальному коді? Тому що так простіше міркувати про стан. Особливо в CLI-застосунку на кшталт LibraryCLI, де ми постійно беремо поточний стан бібліотеки, обчислюємо новий, друкуємо результат і або зберігаємо його, або повертаємо. Семантика значень допомагає робити це передбачуваним: менше сюрпризів, менше «привидів у машині».
Невелика схема для інтуїції:
flowchart LR
A["var a = Value(...)"] --> B["var b = a"]
B --> C["b змінили"]
A --> D["a не змінився"]
І це не філософія. Це саме те, що ви побачите у print.
struct і enum як value-типи
Зараз ми зробимо головний «вау-експеримент дня»: присвоїмо одне значення іншому й змінимо копію. У value-типів це має бути нудно й передбачувано: змінили копію — оригінал не чіпаємо. І так, «нудно» тут — це комплімент: нудний код зазвичай надійніший, а веселий код зазвичай весело падає.
Почнемо з максимально простого прикладу — лічильника:
import Foundation
struct Counter {
var value: Int
}
var a = Counter(value: 0)
var b = a
b.value += 1
print(a.value) // 0
print(b.value) // 1
Тут важлива не математика, а поведінка. b = a означає: «створи значення b, рівне значенню a». Після цього b живе своїм життям.
Так само працює і з enum. Часто здається, що enum — це щось «особливе», але для семантики значень це звичайний value-тип.
import Foundation
enum Command {
case add(title: String)
case list
}
var c1 = Command.add(title: "Swift 6.2 для людей")
var c2 = c1
c2 = .list
print(c1) // add(title: "Swift 6.2 для людей")
print(c2) // list
З enum це навіть легше помітити: ми не «підкручуємо» поле, а повністю замінюємо значення.
2. Мутації: let, var і mutating
Чому let «заморожує» значення цілком
На цьому місці багато хто спотикається через дуже людське очікування: «Ну в структури ж усередині var, чому я не можу змінити одне поле, якщо сама змінна — let?». Тому що для value-типу let означає: вся коробка незмінна. Не «поле незмінне», а саме усе значення.
Тобто, якщо екземпляр struct лежить у let, ви не можете змінювати його збережені властивості і не можете викликати mutating-методи. Swift змушує вас бути чесними: або значення змінюється — тоді var, або ні — тоді let. І це дуже корисна строгість: вона економить години налагодження.
Приклад:
import Foundation
struct BookDraft {
var title: String
}
let draft = BookDraft(title: "Чернетка")
// draft.title = "Не чернетка" // ❌ помилка компіляції
print(draft.title) // Чернетка
Навіщо потрібен mutating
А тепер додамо mutating-метод. Ключове слово mutating — це як табличка «Обережно, я змінюю себе!». Якщо метод змінює self (а отже змінює значення цілком), Swift вимагає написати це явно.
import Foundation
struct Counter {
var value: Int
mutating func increment() {
value += 1
}
}
var c = Counter(value: 10)
c.increment()
print(c.value) // 11
Якщо ви спробуєте оголосити let c = Counter(...) і викликати increment(), компілятор справедливо скаже: «Ви обіцяли не змінювати значення».
Важливо розуміти одну тонкість. Семантика значень — це не «про поля», а про значення цілком. Тому mutating змінює весь self, навіть якщо ви змінили лише value += 1. Це все одно вважається зміною значення.
Невелика корисна таблиця для запам'ятовування:
| Ситуація | struct у let | struct у var |
|---|---|---|
| Змінити збережену властивість | не можна | можна |
| Викликати mutating-метод | не можна | можна |
| Присвоїти новій змінній | можна (копія) | можна (копія) |
3. Функції: де проходять межі змін
Чому зміни «не виходять назовні»
Далі починається магія, яка насправді не магія, а просто контракт функцій. Початківці часто думають: «Я передав змінну у функцію — отже, функція може її змінити». У Swift за замовчуванням функція цього не може зробити. Параметри функції без inout — це локальні значення, і будь-які зміни всередині функції залишаються всередині.
Це знову семантика значень у дії: ви передали функції копію значення, і вона працює саме з нею.
Приклад на Int — щоб зовсім не відволікатися:
import Foundation
func bump(_ x: Int) {
var x = x
x += 1
print("всередині:", x) // всередині: 11
}
let n = 10
bump(n)
print("зовні:", n) // зовні: 10
Навіть якщо ви всередині функції створюєте var x = x, ви змінюєте локальне значення.
Тепер зробимо те саме на нашій моделі бібліотеки, бо Int занадто «ідеальний» і може створити враження, ніби йдеться лише про числа.
import Foundation
struct LibraryState {
var titles: [String]
mutating func add(_ title: String) {
titles.append(title)
}
}
func addInsideFunction(_ state: LibraryState) {
var copy = state
copy.add("Книга з функції")
print(copy.titles.count) // наприклад, 2
}
var state = LibraryState(titles: ["Перша"])
addInsideFunction(state)
print(state.titles.count) // 1
Зовні state не змінився, бо функція не отримала права змінювати ваш аргумент.
Якщо ви хочете, щоб функція змінювала аргумент, ви використовуєте inout. Але це вже окрема тема: виключний доступ і те, чому inout не дозволяє «одночасно всюди змінювати все одразу». Сьогодні нам важливо побачити межу: звичайні параметри — це локальні значення.
4. Семантика значень у LibraryCLI: стан як значення
Тепер давайте приземлимо все це на наш навчальний проєкт. Уявімо, що в нас є найпростіший стан застосунку: список книг. Ми хочемо вміти брати стан, створювати новий варіант стану і обирати, який із них далі використовувати. Семантика значень ідеально підходить для такого стилю: «дані = значення».
Почнемо з моделі Book і Library:
import Foundation
struct Book {
let id: Int
var title: String
}
struct Library {
var books: [Book]
mutating func addBook(title: String) {
let nextId = (books.last?.id ?? 0) + 1
books.append(Book(id: nextId, title: title))
}
}
Тепер ключовий експеримент: ми «зберегли знімок» стану, а потім змінили поточний.
import Foundation
var lib = Library(books: [])
lib.addBook(title: "Swift для початківців")
let snapshot = lib // знімок (копія за змістом)
lib.addBook(title: "Swift для тих, хто продовжує")
print(snapshot.books.count) // 1
print(lib.books.count) // 2
Це дуже потужна ідея. Ви можете безпечно зберігати «знімки» стану, передавати їх у функції, порівнювати, логувати, відкотити зміни — і все це без відчуття, що десь усередині живе спільний об’єкт, який усі потай крутять.
У CLI-застосунку це особливо зручно: ви парсите команду, створюєте новий стан, друкуєте результат. Коли ви мислите в термінах value-типів, ви буквально бачите, де є «до», а де — «після».
Можна навіть уявити це як конвеєр:
flowchart TD
A["Поточний стан бібліотеки"] --> B["Команда користувача"]
B --> C["Нова бібліотека (копія + зміни)"]
C --> D["Друк/збереження"]
Саме так часто й проєктують застосунки, де стан важливо тримати охайно.
5. Поведінка і продуктивність value-типів
Семантика значень — це про поведінку, а не про «завжди копіювати пам'ять»
Зараз буде момент, коли я обережно зніму з вас рожеві окуляри й одразу дам натомість нормальні робочі. Семантика значень означає: «за поведінкою це копія». Але це не обіцянка, що комп'ютер щоразу фізично копіює всі байти в пам'яті негайно. Я спеціально кажу про це зараз, щоб ви не потрапили в пастку «value-типи завжди повільні».
Swift уміє оптимізувати value-типи так, щоб вони поводилися як копії, але при цьому не копіювали зайвого. У стандартній бібліотеці більшість базових типів — це типи зі семантикою значень, зокрема колекції, і мова побудована навколо цього підходу.
Але тут є тонка небезпека: якщо ви зробите struct, який усередині зберігає посилальний змінний обʼєкт, ви можете випадково зламати цю передбачуваність. І тоді у вас вийде «struct, який поводиться як class», що зазвичай закінчується тим, що ви розмовляєте з компілятором на підвищених тонах.
У Swift-екосистемі це відома проблема: value-тип, який усередині тримає змінний reference-тип, може несподівано «протікати» змінами, якщо не реалізувати правильну стратегію копіювання (часто через Copy-on-Write).
Ще одним подібним джерелом неочікуваних витрат є property observers. Наприклад, didSet може змусити систему витягувати oldValue, і якщо всередині великий масив, це потенційно створює зайву роботу. У Swift це навіть окремо уточнювали й покращували на рівні семантики, щоб уникнути непотрібних копій там, де oldValue не використовується.
Сьогодні ми не будемо заглиблюватися в оптимізації — це окрема лекція. Мораль проста: семантика значень — ваш головний модельний контракт, а оптимізації — приємний бонус.
Як побачити межу копії в коді
Зараз ми виробимо корисну звичку читати код. Коли ви дивитеся на програму і намагаєтеся зрозуміти, що де змінюється, вам потрібно бачити дві речі: де зʼявляється нове значення і де відбувається мутація наявного.
У value-типів це зазвичай виглядає так:
- Створили значення (ініціалізація, літерал, повернення з функції).
- Скопіювали значення (присвоєння, передавання у функцію).
- Змінили значення (лише за наявності var і тільки через mutating-операції).
Ось короткий приклад, де всі три пункти поруч:
import Foundation
struct Year {
var value: Int
mutating func addOne() {
value += 1
}
}
var y1 = Year(value: 2026) // 1) створили
var y2 = y1 // 2) скопіювали (за змістом)
y2.addOne() // 3) змінили копію
print(y1.value) // 2026
print(y2.value) // 2027
Коли ви навчитеся бачити ці кроки з першого погляду, багато речей перестануть бути загадкою.
6. Типові помилки під час роботи із семантикою значень
Помилка №1: очікувати, що після b = a зміни «перейдуть» від b до a.
Це часта звичка після мов, у яких усе навколо — «обʼєкти». У Swift для struct/enum таке очікування хибне: b — це незалежне значення. Якщо ви хочете «спільну сутність», це вже інший тип семантики і тема іншої лекції. На практиці допомагає просте правило: побачили struct — за замовчуванням думайте «копія за змістом».
Помилка №2: оголосити let, а потім намагатися змінювати поля й сваритися з компілятором.
Компілятор тут не шкідничає, а рятує. let для value-типу означає «усе значення незмінне», навіть якщо всередині поля оголошені як var. Якщо вам потрібна мутація, робіть змінну var. А якщо мутація потрібна лише «інколи», це добрий привід замислитися, де саме має жити змінний стан.
Помилка №3: забути mutating у методі структури й довго шукати, «чому не можна змінити поле».
Якщо метод змінює збережені властивості, він зобовʼязаний бути mutating. Це не бюрократія: це частина контракту value-типу. Ви як автор типу прямо кажете: «цей метод змінює значення». І далі let-екземпляри захищені від випадкових змін.
Помилка №4: думати, що функція «зобовʼязана» змінити аргумент, якщо ви його туди передали.
Без inout аргументи функції — це локальні значення. Зміни всередині функції не зобовʼязані відображатися назовні й зазвичай не відображаються. Якщо ви хочете змінити зовнішній аргумент, це має бути явно: інший дизайн, інша сигнатура, інший виклик. У Swift це робиться дуже помітно.
Помилка №5: випадково зламати семантику значень, сховавши всередині struct змінний посилальний обʼєкт.
Це вже більш просунута пастка, але про неї важливо знати завчасно. Value-тип має поводитися як значення. Якщо всередині сидить змінний reference-тип і ви не контролюєте копіювання, ви можете отримати поведінку, де «копія» змінюється разом з оригіналом — і це якраз той випадок, коли let зовні не рятує від внутрішньої мутації. У Swift-екосистемі це давно відомий клас проблем, і для нього зазвичай застосовують підхід Copy-on-Write.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ