1. Value semantics: основная идея
Если вы только начинаете, вам легко думать так: «переменная — это коробка, а внутри лежит штука». И это… удивительно хорошая модель, пока мы говорим про value‑типы. Value semantics — это про наблюдаемое поведение программы: когда вы присваиваете значение другой переменной или передаёте в функцию, у вас получается независимая копия “штуки”, и изменения одной коробки не должны магически менять содержимое другой.
Почему это важно в реальном коде? Потому что так проще рассуждать о состоянии. Особенно в CLI‑приложении вроде LibraryCLI, где мы постоянно «берём текущее состояние библиотеки → считаем новое состояние → печатаем → сохраняем/возвращаем». Value semantics помогает делать это предсказуемо: меньше сюрпризов, меньше “призраков в машине”.
Небольшая схема‑интуиция:
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 semantics он обычный 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, вы не можете менять его stored properties и вы не можете вызывать mutating‑методы. Swift заставляет вас быть честными: либо значение меняется (тогда var), либо нет (тогда let). И это очень полезная строгость: она экономит часы отладки.
Пример:
import Foundation
struct BookDraft {
var title: String
}
let draft = BookDraft(title: "Черновик")
// draft.title = "Не черновик" // ❌ ошибка компиляции
print(draft.title) // Черновик
Зачем нужен mutating
А теперь добавим mutating‑метод. Ключевое слово mutating — это как табличка «Осторожно, меняю себя!». Если метод меняет self (а значит меняет value целиком), 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(), компилятор справедливо скажет: «Вы обещали не менять значение».
Важно понимать одну тонкость. Value semantics — это не «про поля», а про значение целиком. Поэтому mutating меняет весь self, даже если вы поменяли только value += 1. Это всё равно считается изменением значения.
Небольшая полезная таблица для запоминания:
| Ситуация | struct в let | struct в var |
|---|---|---|
| Изменить stored property | нельзя | можно |
| Вызвать mutating метод | нельзя | можно |
| Присвоить в новую переменную | можно (копия) | можно (копия) |
3. Функции: где проходят границы изменений
Почему изменения “не выходят наружу”
Дальше начинается магия, которая не магия, а просто контракт функций. Новички часто думают: «я передал переменную в функцию — значит функция может её поменять». В Swift по умолчанию не может. Параметры функции (без inout) — это локальные значения, и любые изменения внутри функции остаются внутри.
Это снова value semantics в действии: вы дали функции копию значения (по смыслу), и функция работает с ней.
Пример на Int (чтобы вообще не отвлекаться):
import Foundation
func bump(_ x: Int) {
var x = x
x += 1
print("inside:", x) // inside: 11
}
let n = 10
bump(n)
print("outside:", n) // outside: 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. Value semantics в LibraryCLI: состояние как значение
Теперь давайте приземлим всё это на наш учебный проект. Представим, что у нас есть простейшее состояние приложения: список книг. Мы хотим уметь брать состояние, делать новый вариант состояния и выбирать, какой дальше использовать. Value semantics идеально подходит под такой стиль: “данные = значение”.
Начнём с модели 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["Текущее Library"] --> B["Команда пользователя"]
B --> C["Новая Library (копия + изменения)"]
C --> D["Печать/сохранение"]
Именно так часто и проектируют приложения, где состояние важно держать аккуратно.
5. Поведение и производительность value‑типов
Value semantics — это про поведение, а не про “копировать память всегда”
Сейчас будет момент, где я аккуратно сниму с вас розовые очки и тут же выдам взамен нормальные рабочие. Value semantics означает: «по поведению это копия». Но это не обещание, что компьютер каждый раз физически копирует все байты в памяти немедленно. Я специально говорю об этом сейчас, чтобы вы не попали в ловушку «value‑типы всегда медленные».
Swift умеет оптимизировать value‑типы так, чтобы они вели себя как копии, но при этом не копировали лишнее. В стандартной библиотеке большинство базовых типов — value‑семантические (включая коллекции), и язык построен вокруг этого подхода.
Но здесь есть тонкая опасность: если вы сделаете struct, который внутри хранит ссылочный изменяемый объект, вы можете случайно сломать эту предсказуемость. И тогда у вас получится «struct, который ведёт себя как class», что обычно заканчивается тем, что вы разговариваете с компилятором на повышенных тонах.
В Swift‑экосистеме это известная проблема: value‑тип, который внутри держит изменяемый reference‑тип, может неожиданно “протекать” изменениями, если не реализовать правильную стратегию копирования (часто через Copy-on-Write).
Ещё один похожий источник неожиданной стоимости — property observers. Например, didSet может заставлять систему извлекать oldValue, и если внутри большой массив, это потенциально создаёт лишнюю работу. В Swift это даже отдельно уточняли и улучшали на уровне семантики, чтобы избежать ненужных копий там, где oldValue не используется.
Сегодня мы не будем углубляться в оптимизации (это отдельная лекция дня), но мораль простая: value semantics — ваш главный “модельный контракт”, а оптимизации — приятный бонус.
Как “увидеть” границу копии в коде
Сейчас мы соберём понятную привычку чтения кода. Когда вы смотрите на программу и пытаетесь понять, “что где меняется”, вам нужно видеть две вещи: где появляется новое значение и где происходит мутация существующего.
У 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. Типичные ошибки при работе с value semantics
Ошибка №1: ожидать, что после b = a изменения “протекут” из b в a.
Это частая привычка после языков, где всё вокруг “объекты”. В Swift для struct/enum такое ожидание неверно: b — это независимое значение. Если вы хотите “общую сущность”, это уже другой тип семантики (и другая лекция дня). На практике помогает простое правило: увидели struct — по умолчанию думайте «копия по смыслу».
Ошибка №2: объявить let, а потом пытаться менять поля и ругаться на компилятор.
Компилятор здесь не вредничает, а спасает. let для value‑типа означает «всё значение неизменяемо», даже если внутри поля объявлены как var. Если вам нужна мутация, делайте переменную var. А если мутация нужна только “иногда”, это хороший повод задуматься, где именно должно жить изменяемое состояние.
Ошибка №3: забыть mutating у метода структуры и долго искать, “почему нельзя менять поле”.
Если метод меняет stored properties, он обязан быть mutating. Это не бюрократия: это часть контракта value‑типа. Вы как автор типа прямо говорите вызывающему коду: «этот метод меняет значение». И дальше let‑экземпляры защищены от случайных изменений.
Ошибка №4: думать, что функция “обязана” менять аргумент, раз вы его туда передали.
Без inout аргументы функции — это локальные значения. Изменения внутри функции не обязаны отражаться снаружи, и чаще всего не отражаются. Если вы хотите изменить внешний аргумент, это должно быть явно: отдельный дизайн, отдельная сигнатура, отдельный вызов (в Swift это делается очень заметно).
Ошибка №5: случайно сломать value semantics, спрятав внутрь struct ссылочный изменяемый объект.
Это уже более продвинутая ловушка, но о ней важно знать заранее. Value‑тип должен вести себя как значение. Если внутри сидит изменяемый reference‑тип и вы не контролируете копирование, вы можете получить поведение, где “копия” меняется вместе с оригиналом — и это как раз тот случай, когда let снаружи не спасает от внутренней мутации. В Swift‑экосистеме это давно известный класс проблем, и для него обычно применяют подход Copy-on-Write.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ