JavaRush /Курси /Swift SELF /Семантика значень: struct...

Семантика значень: struct/ enum як «значення, що копіюється»

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

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-типів це зазвичай виглядає так:

  1. Створили значення (ініціалізація, літерал, повернення з функції).
  2. Скопіювали значення (присвоєння, передавання у функцію).
  3. Змінили значення (лише за наявності 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.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ