1. Reference semantics и когда нужен class
Value‑подход — это мечта: предсказуемо, безопасно, без загадочных побочных эффектов. Но иногда нам нужно ровно обратное: чтобы несколько частей программы работали с одним и тем же состоянием, и изменения были видны всем. Например, вы хотите передать «общую библиотеку книг» в несколько функций, чтобы каждая могла добавить или удалить книгу, и это было одно хранилище, а не пять независимых копий.
Здесь и появляется модель reference semantics: переменная хранит не «значение целиком», а ссылку на объект, который живёт отдельно. У такого подхода есть цена — побочные эффекты, aliasing и потенциальная путаница, — но есть и выгода: удобно строить «разделяемое состояние», которое должно изменяться из разных мест.
Чтобы не звучало как философия, зафиксируем простую метафору. struct — это как ксерокопия документа: у каждого своя, правки на одной копии не меняют остальные. class — это как один общий документ в Google Docs: ссылок может быть много, но правка в одном месте видна всем, потому что документ один.
Минимальный синтаксис class: свойства и init
Сейчас мы не будем превращать class в «космический корабль» из ООП‑терминов. Нам достаточно самого минимума: объявить класс, дать ему хранимые свойства и инициализатор. Внешне это похоже на struct, но семантика принципиально другая: экземпляр класса — это объект, на который могут ссылаться несколько переменных.
Вот простейший пример объекта, который хранит число (и уже намекает на «разделяемое состояние»):
import Foundation
final class Counter {
var value: Int
init(value: Int) {
self.value = value
}
}
Обратите внимание: здесь value — var, то есть состояние объекта можно менять. А слово final мы используем просто как «стоп‑табличку»: «не планируем усложнять иерархию». Это не обязательно, но для учебного кода часто делает намерение яснее.
2. Что значит «ссылка»: aliasing, let и вызовы функций
Присваивание копирует ссылку: aliasing
Самое важное поведение, которое нужно почувствовать руками: когда вы пишете b = a, где a — объект класса, вы не создаёте новый объект. Вы просто делаете ещё одну переменную, которая указывает туда же. Это и называется aliasing: два имени для одного объекта.
Давайте посмотрим на эффект так, чтобы он был виден в выводе:
import Foundation
final class Person {
var name: String
init(name: String) { self.name = name }
}
let p1 = Person(name: "Ann")
let p2 = p1
p2.name = "Bob"
print(p1.name) // Bob
print(p2.name) // Bob
Мы поменяли name через p2, но изменился и p1. На самом деле ничего внезапного: объект один, просто две переменные указывают на него.
Вот полезная схемка, которую стоит держать в голове:
flowchart LR
p1["p1"] --> obj["Person(name: 'Ann')"]
p2["p2"] --> obj
После p2.name = "Bob" изменился obj, а не какая-то «копия p2».
let фиксирует ссылку, а не состояние объекта
Здесь обычно ломается мозг у новичков, и это нормально (потому что мозг честно пытается вас защитить). Мы привыкли: let означает «не меняется». Но для класса let означает лишь одно: нельзя переназначить переменную на другой объект. При этом состояние объекта (его var‑свойства) менять можно.
Пример:
import Foundation
final class Box {
var x: Int
init(x: Int) { self.x = x }
}
let b = Box(x: 10)
b.x = 20
print(b.x) // 20
// b = Box(x: 30) // ❌ нельзя: b — let, ссылку переназначить нельзя
Как это запомнить без боли: let «замораживает» стрелочку, а не коробку. Стрелочка (ссылка) остаётся той же, но коробку можно открыть и переложить содержимое, если содержимое объявлено как var.
И это же — причина, почему классы могут быть источником «магии»: вы смотрите на let и ожидаете абсолютной неизменяемости, а она не наступает. Абсолютная неизменяемость для объекта — это уже отдельный дизайн (например, делать все свойства let), но это не автоматическое свойство let‑переменной.
Передача объекта в функцию меняет общее состояние
Один из самых практичных моментов: если вы передаёте объект класса в функцию, вы передаёте ссылку на тот же объект. Поэтому функция может изменить состояние, и эти изменения будут видны снаружи — даже без inout.
Сравним с тем, как это выглядит на примере «счётчика»:
import Foundation
final class Counter {
var value: Int
init(value: Int) { self.value = value }
}
func bump(_ c: Counter) {
c.value += 1
}
let counter = Counter(value: 10)
bump(counter)
print(counter.value) // 11
Тут нет &, нет inout, но эффект «как будто функция изменила аргумент» присутствует. На самом деле функция изменила объект, на который аргумент ссылается.
И вот здесь важно не сделать неправильный вывод: это не «магическая передача по ссылке как в C», это именно следствие reference semantics. У значения (struct) аргумент без inout — отдельная копия по смыслу. У объекта (class) аргумент — ещё одна ссылка на тот же объект.
3. Пример на LibraryCLI: общее хранилище как объект
Очень легко обсуждать ссылки абстрактно, но полезнее показать мотивацию на «почти реальном» фрагменте учебного приложения про библиотеку. Пусть у нас есть книга как value‑тип (это логично: книга — данные):
import Foundation
struct Book {
let id: Int
let title: String
}
А теперь представим, что мы хотим хранить коллекцию книг и менять её из разных функций: добавлять, удалять, печатать. Если сделать хранилище тоже struct, нам придётся постоянно возвращать новое значение или передавать inout. Иногда это правильно, но сегодня мы пробуем другой путь: сделаем хранилище объектом.
import Foundation
final class LibraryStore {
var books: [Book] = []
func add(_ book: Book) {
books.append(book)
}
}
Теперь покажем ключевой эффект: два имени — одно хранилище:
import Foundation
let store1 = LibraryStore()
let store2 = store1
store2.add(Book(id: 1, title: "Swift для выживших"))
print(store1.books.count) // 1
print(store2.books.count) // 1
С точки зрения программы это «одна библиотека, две переменные‑входа». Иногда это прям то, что нужно: например, вы парсите команды, и в разных функциях хотите работать с одной общей коллекцией книг, не протаскивая inout через все уровни.
Если нарисовать схему, будет так:
flowchart LR
s1["store1"] --> L["LibraryStore(books: [...])"]
s2["store2"] --> L
4. Ссылки внутри struct: как value‑тип неожиданно становится «разделяемым»
На этом месте полезно заранее снять одну частую мину. Иногда вы делаете всё «по учебнику»: модель данных как struct, всё красиво. А потом внезапно выясняется, что «копии» ведут себя как ссылки. Обычно причина проста: внутри struct лежит поле ссылочного типа, и копирование структуры копирует… ссылку на вложенный объект.
Покажем минимальный пример без экзотики:
import Foundation
final class Notes {
var text: String
init(text: String) { self.text = text }
}
struct BookWithNotes {
let title: String
let notes: Notes
}
let a = BookWithNotes(title: "Swift", notes: Notes(text: "draft"))
var b = a
b.notes.text = "final"
print(a.notes.text) // final
a и b — структуры, но внутри у них одна и та же Notes. Это не «плохой Swift», это честная логика: value‑копия структуры не обязана «клонировать» все вложенные объекты. Просто теперь вы знаете, где искать причину, если value‑тип ведёт себя «как ссылка».
5. Как держать aliasing под контролем
Reference semantics часто ругают за «побочные эффекты», но на практике проблема обычно не в самих классах, а в том, что программа перестаёт быть очевидной: где-то изменили объект, а вы ожидали, что это «копия». Поэтому рабочая дисциплина звучит скучно, но спасает нервы: если объект предполагается разделяемым, мутация должна быть максимально читаемой по коду.
В учебных проектах хороший стиль — делать методы, которые явно описывают изменения, вместо того чтобы менять поля из любого места. Например, вместо store.books.append(...) лучше store.add(...), потому что это сразу читается как «я изменяю общее состояние». Ещё полезно ограничивать поверхность мутаций: меньше публичных var‑свойств, больше маленьких методов. Не потому что «так правильно по ООП‑религии», а потому что меньше шансов устроить себе квест «кто же изменил мой массив».
И ещё один прагматичный ориентир: если вам не нужно разделяемое состояние, то struct почти всегда спокойнее. Класс стоит выбирать не «по привычке», а когда вы действительно хотите эффект общего объекта, разделяемого между несколькими владельцами.
6. Типичные ошибки
Ошибка №1: ожидать, что b = a создаёт копию объекта класса.
Такое ожидание приходит из мира значений: «присваивание = копия». У класса присваивание копирует ссылку, и это моментально создаёт aliasing. Если вы хотели независимое значение, то либо используйте struct, либо создавайте новый объект явно (с новой инициализацией и переносом нужных данных).
Ошибка №2: думать, что let obj = ... делает объект неизменяемым.
let запрещает переназначить переменную на другой объект, но не запрещает менять var‑свойства внутри объекта. Новички часто видят let и расслабляются, а потом удивляются, что состояние поменялось. Если нужна настоящая неизменяемость, проектируйте объект так, чтобы его свойства были let или чтобы у него не было публичных мутаций.
Ошибка №3: «случайно» разделить состояние между частями программы и потом ловить странные эффекты.
Самый частый сценарий — вы передали объект в функцию «просто чтобы прочитать», а функция (или другой код) внезапно изменила его. Это особенно неприятно, если изменение происходит через прямой доступ к свойствам. Лечится стилем: меньше прямых obj.x = ... по всему проекту, больше методов с говорящими названиями, которые вы не вызовете «случайно».
Ошибка №4: спрятать class внутрь struct и забыть, что вы принесли ссылки в мир значений.
Структура может выглядеть как value‑тип, но если внутри лежит ссылочный объект, то копии структуры будут разделять это ссылочное состояние. Это не всегда плохо, но почти всегда неожиданно, если вы не держите это в голове. Если вы действительно хотите value‑поведение, иногда приходится либо избегать ссылок внутри, либо проектировать отдельную стратегию копирования.
Ошибка №5: выбирать class , потому что «в других языках так принято».
В Swift value‑типы — не второсортные, а основной инструмент. Класс — мощная штука, но он приносит aliasing и побочные эффекты как «комплект поставки». Если у вас нет ясной причины хотеть разделяемый объект, выбор struct обычно делает код проще для чтения и тестирования.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ