Помимо копирования и разделения, у структуры нет наследования, deinit и идентичности, а у класса нет поэлементного (memberwise) инициализатора, mutating и автоматического Sendable.
struct: нет наследования, бесплатный поэлементный инициализатор, методы, меняющие хранимые свойства, должны быть mutating, и структура неявно Sendable, если она не public и все её свойства Sendable.
class: наследование, deinit, проверка идентичности через ===, weak-ссылки, совместимость с Objective-C. Всегда размещается в куче.
enum и actor: enum служит значимым типом для закрытого набора вариантов; actor является ссылочным типом, который сериализует доступ к своему изменяемому состоянию, и это безопасный выбор для общего состояния в Swift 6.
iOSJunior
Swift: ООП и протоколы
Классы, struct, enum, протоколы, расширения, protocol-oriented programming
Закрыто вопросов
0 / 24
- 0Повторить
- 11Часто спрашивают
Вопросы
Инкапсуляция, наследование, полиморфизм и абстракция; Swift поддерживает все четыре, но там, где классические ООП-языки опираются на иерархии классов, Swift опирается на протоколы и значимые типы.
Инкапсуляция: состояние прячется за API с помощью уровней доступа: хранилище private, private(set) для доступа снаружи только на чтение, небольшая public-поверхность у модуля.
Наследование: наследуются только классы, и только от одного суперкласса. Переопределение требует обязательного ключевого слова override, а final закрывает класс или член.
Полиморфизм: один вызов, разное поведение: переопределения в подклассах вызываются через vtable, а соответствие протоколу работает и для структур, и для enum. Дженерики добавляют полиморфизм на этапе компиляции.
Абстракция: абстрактных классов в Swift нет; их роль играет протокол: он описывает, что тип умеет, но не как, а расширение протокола даёт общие реализации по умолчанию.
Акцент, который ждут на собеседовании: предпочитайте композицию маленьких протоколов глубокому наследованию, а иерархии классов оставьте для фреймворковых типов вроде UIView.
Перегрузка означает несколько функций с одним именем и разными сигнатурами, нужную выбирает компилятор в точке вызова; переопределение означает замену унаследованной реализации в подклассе, и выбирается она во время выполнения по реальному классу объекта.
Перегрузка: функции различаются типами параметров, метками аргументов, числом параметров или даже типом результата. Работает для функций, методов, инициализаторов и операторов, в структурах так же, как в классах.
Переопределение: только у классов, и override обязателен, поэтому случайно переопределить метод нельзя. Версию родителя вызывают через super.
Свойства: подкласс может переопределить унаследованное свойство вычисляемым геттером и сеттером или добавить willSet/didSet, но хранилище остаётся в суперклассе. Свойство только для чтения можно сделать доступным для записи, а наоборот нельзя, и тип должен совпадать точно, если только свойство не только для чтения: там допустим подтип.
Что переопределить нельзя: final- и static-члены, а также методы, объявленные в extension, если они не @objc.
Протокол представляет собой контракт из методов, свойств, инициализаторов и ассоциированных типов, который могут принять классы, структуры и enum, в том числе чужие типы; в отличие от абстрактного класса он не хранит состояние.
Требования к свойствам: всегда пишутся как var с { get } или { get set }. let удовлетворяет { get }; для { get set } нужен var или вычисляемое свойство с сеттером.
Реализации по умолчанию: расширение протокола даёт тела методов, и конформеры реализуют только то, что отличается.
Наследование и композиция: protocol Greeter: Named уточняет другой протокол; Named & Codable объединяет несколько протоколов в месте использования. Тип может соответствовать любому числу протоколов.
Ассоциированные типы: associatedtype Item делает протокол обобщённым по типу, который выбирает каждый конформер, как Collection делает с Element.
В отличие от абстрактного класса: нет хранимых свойств, нет кода инициализации, нет super; общее состояние должно жить в типе, который соответствует протоколу.
Raw value задаёт для каждого case одну фиксированную константу одного и того же типа, известную на этапе компиляции; associated value представляет собой данные, которые прикрепляются к case при создании значения, и у разных case они могут быть разных типов.
Raw values: объявляются как enum HTTPStatus: Int. Появляются rawValue и failable-инициализатор init?(rawValue:), который возвращает nil для неизвестного значения (это удобно для JSON и UserDefaults).
Associated values: case failed(Error). С ними enum становится правильной моделью состояния экрана вроде idle / loading / loaded / failed, где у каждого состояния свои данные.
Enum с associated values не может одновременно иметь raw-тип.
**Исчерпывающий switch**: добавьте case, и каждый switch, который его не обрабатывает, перестанет компилироваться. В паттернах можно извлекать данные и добавлять условия where.
**CaseIterable**: синтезирует allCases для enum без associated values, удобно для пикеров и тестов.
**indirect**: позволяет case содержать сам enum, для рекурсивных структур вроде деревьев.
Extension добавляет вычисляемые свойства, методы, инициализаторы, сабскрипты, вложенные типы и соответствие протоколам к любому типу, даже чужому; добавить хранимые свойства или переопределить существующие члены он не может.
Хранимые свойства: нельзя: раскладка типа в памяти зафиксирована его исходным объявлением. Используйте вычисляемое свойство или тип-обёртку.
Переопределение: extension добавляет поведение, но никогда не заменяет его. Подкласс тоже не может переопределить не-@objc метод, объявленный в extension.
Инициализаторы: структура может получить init в extension и при этом сохранить поэлементный инициализатор; классу так можно добавить только convenience-инициализаторы.
Расширения с ограничениями: extension Array where Element: Numeric добавляет методы только там, где выполняется условие.
Ретроактивное соответствие: если чужой тип подписывается на чужой протокол, Swift 6 выдаёт предупреждение; пометка @retroactive подтверждает, что вы понимаете риск: владелец может позже добавить такое же соответствие.
Типичное применение: разбить большой тип на части по протоколам и вынести хелперы из основного объявления.
В Swift шесть уровней: open, public, package, internal (по умолчанию), fileprivate и private; public и open видны из других модулей, но только open позволяет этим модулям наследоваться от класса и переопределять его члены.
package: виден всем модулям того же Swift-пакета и скрыт от приложений, которые этот пакет подключают.
internal: весь модуль; это уровень по умолчанию, если ничего не написать.
fileprivate: текущий файл.
private: объемлющее объявление и его extension в том же файле.
**private(set)**: отдельный, более узкий уровень только для сеттера: public private(set) var balance читается отовсюду, а записывается только внутри типа.
Два правила, на которых часто ошибаются. Член ограничен уровнем своего типа: public-метод у internal-типа фактически internal. А синтезированные инициализаторы получают уровень internal, поэтому public struct во фреймворке нельзя создать из другого модуля, пока вы не напишете явный public init.
lazy var выполняет свой инициализатор при первом чтении и запоминает результат, а willSet/didSet выполняют код вокруг каждого присваивания; lazy вычисляется один раз, если только два потока не прочитают его впервые одновременно.
lazy: должен быть var, может обращаться к self в инициализаторе и подходит для дорогих объектов, которые могут вообще не понадобиться (форматтеры, тяжёлые вью). Он не потокобезопасен, а у структуры первое чтение считается мутацией, поэтому на let-экземпляре он не скомпилируется.
willSet / didSet: willSet видит newValue, didSet видит oldValue. Они срабатывают на каждое присваивание, даже равного значения, поэтому перед дорогой работой сравнивайте с oldValue. При присваивании в собственном инициализаторе типа они не срабатывают.
Вычисляемое свойство: хранилища нет совсем, значение пересчитывается при каждом чтении. Подходит, когда значение всегда можно получить из другого состояния.
mutating помечает метод структуры или enum, который меняет self: self передаётся как inout, и изменение записывается обратно в переменную вызывающего кода. Метод класса меняет объект через ссылку, а сама ссылка при этом не меняется.
Нельзя вызвать mutating-метод у константы let или у временного значения, например результата функции: записывать изменение некуда.
Полная замена self, как в методе advance() у enum, тоже требует mutating.
Метод протокола, который должны реализовывать структуры, объявляется как mutating func; классы-конформеры просто не пишут это слово.
Частая ловушка: for item in items { item.bump() } не компилируется, потому что переменная цикла является неизменяемой копией. Меняйте через индекс: items[i].bump().
Equatable требует ==, а Hashable добавляет hash(into:) с одним правилом: равные значения должны давать равные хэши. Одинаковый хэш у двух разных значений означает обычную коллизию: Set и Dictionary различают их через ==, просто немного медленнее.
Синтез: структура или enum получают обе реализации бесплатно, если все хранимые свойства или associated values им соответствуют. Классам ничего не синтезируется, обе функции пишутся вручную.
Настоящий баг: обратная ситуация: равные значения с разными хэшами. Если хэшировать поле, которое == игнорирует, или менять хэшируемое свойство экземпляра класса, пока он лежит в Set или служит ключом словаря, поиск молча промахивается.
Comparable: добавляет < для сортировки; остальные операторы сравнения появляются бесплатно.
Identifiable: не про равенство: он даёт SwiftUI и diffable data source стабильный id, и два значения с одним id всё равно могут различаться.
У классов == проверяет равенство, которое вы определили, а === проверяет, указывают ли две ссылки на один и тот же экземпляр.
weak работает только с экземплярами классов, а этому протоколу может соответствовать и структура, поэтому компилятор выдаёт 'weak' must not be applied to non-class-bound 'any DownloaderDelegate'. Протокол нужно ограничить классами: protocol DownloaderDelegate: AnyObject.
**Зачем вообще weak: делегат обычно владеет объектом, который его вызывает (экран владеет своим загрузчиком), поэтому сильная обратная ссылка создала бы цикл удержания (retain cycle), и ни один из объектов не освободился бы.
Почему опционал**: weak-ссылка становится nil, когда делегат освобождается, поэтому свойство должно быть опциональным var.
При строгой конкурентности: если колбэки трогают UI, пометьте протокол @MainActor, и вызов из фонового потока станет ошибкой компиляции.
Сейчас принято писать AnyObject; старая форма protocol X: class устарела.
deinit служит методом очистки класса, который вызывается автоматически прямо перед тем, как ARC освободит экземпляр, то есть когда исчезает последняя сильная ссылка. Он выполняется синхронно в том потоке, который отпустил эту последнюю ссылку, и это не обязательно главный поток.
Он есть у классов и акторов, а также у некопируемых (~Copyable) структур и enum; обычной структуре очищать нечего.
В нём отменяют задачи, останавливают таймеры, закрывают файлы и снимают наблюдателей. Никогда не вызывайте его сами и не давайте self «утечь» из него.
Не вызывается? Почти всегда это цикл удержания: объект всё ещё держит замыкание, Task или сильная ссылка на делегат.
Swift 6: обычный deinit у @MainActor-класса не изолирован и не может обращаться к состоянию главного актора. Начиная со Swift 6.2 можно написать isolated deinit, и очистка выполнится на акторе класса.
Оба объявляют члены типа, а не экземпляра, но class-член подкласс может переопределить, а static нет: в классе static означает class final.
static: работает в структурах, enum, классах и протоколах. Хранимые свойства типа всегда static, они инициализируются лениво и один раз.
class: только в классах и только для методов и вычисляемых свойств; подкласс заменяет его через override class.
Протоколы: требования уровня типа пишутся через static; класс может выполнить такое требование через class, чтобы оставить его переопределяемым.
final: на классе запрещает наследование, а на члене запрещает переопределение. Кроме того, компилятор может заменить вызов через vtable прямым вызовом и встроить его (inline). При whole-module optimization компилятор сам выводит final для internal-классов без наследников, но явная пометка документирует намерение.
Swift 6: изменяемый static var представляет собой общее глобальное состояние, поэтому это ошибка компиляции, если свойство не изолировано на глобальном акторе вроде @MainActor, не перенесено в актор и не помечено nonisolated(unsafe) с вашей собственной синхронизацией.
Designated-инициализатор присваивает значения всем хранимым свойствам своего класса и затем вызывает designated-инициализатор суперкласса; convenience-инициализатор обязан вызвать другой инициализатор того же класса. Так self не читается до полной инициализации цепочки.
Две фазы
Фаза 1, вверх: каждый класс задаёт свои хранимые свойства и вызывает super.init. Пока не достигнут корневой класс, нельзя вызывать методы и читать self; именно об этом ошибка «'self' used before all stored properties are initialized».
Фаза 2, вниз: когда корень готов, каждый инициализатор может донастроить объект: вызывать методы, менять унаследованные свойства.
Правила наследования
Подкласс наследует designated-инициализаторы суперкласса, только если не объявил ни одного своего, а convenience-инициализаторы наследует, когда реализовал все designated.
required init заставляет каждый подкласс иметь этот инициализатор, поэтому подклассы UIView постоянно пишут init?(coder:).
Другие формы
**init?**: failable, возвращает nil для некорректных данных.
**init() throws**: падает с ошибкой, которая объясняет причину, например throw ValidationError.empty.
Поэлементный (memberwise) инициализатор: есть у структур и нет у классов, потому что класс должен ещё инициализировать суперкласс. private-свойство делает memberwise-инициализатор private, а собственный init в теле структуры убирает его, если только этот init не вынесен в extension.
Протокол-ориентированное программирование (POP) собирает поведение из маленьких протоколов с реализациями по умолчанию в extension, а не из дерева базовых классов. Тип берёт ровно те возможности, которые ему нужны, и участвовать могут структуры и enum, а не только классы.
Композиция вместо наследования: тип может соответствовать многим протоколам, но наследоваться только от одного класса, поэтому не появляется BaseViewController, к которому каждая фича прикручивает свои методы.
Нет проблемы хрупкого базового класса: изменение суперкласса может сломать все подклассы, а протокол обещает только интерфейс.
Значимые типы: POP работает со структурами, поэтому модели данных безопасно копируются и остаются Sendable.
Тестируемость: протокол служит естественной точкой для подстановки фейка в тестах.
Реализации по умолчанию с ограничениями: extension Loggable where Self: Identifiable даёт реализацию только тем типам, у которых есть ещё и id.
Наследование по-прежнему уместно во фреймворковых иерархиях вроде UIView и UIViewController и в шаблонных методах, которые вызывают переопределяемые хуки. За POP платят косвенностью: слишком много мелких протоколов усложняют навигацию по коду, а вызовы через any диспетчеризуются динамически.
Выведет beep, а затем default shout. greet() объявлен в протоколе, значит, это требование, которое вызывается через witness table, и побеждает версия типа; shout() есть только в extension, поэтому компилятор выбирает его статически по объявленному типу any Greeter.
Через переменную типа Robot оба вызова напечатали бы версии робота: один и тот же объект даёт два разных ответа в зависимости от статического типа.
Обобщённый код (func f<T: Greeter>(_ x: T)) ведёт себя здесь так же, как экзистенциал: требования вызываются динамически, а методы только из extension вызываются статически.
Исправление: объявить shout() в теле протокола и оставить версию из extension как реализацию по умолчанию. Всё, что конформер может настроить под себя, должно быть в самом протоколе.
Это же правило объясняет, почему подкласс не может переопределить не-@objc метод из extension своего суперкласса: у членов extension нет слота в vtable.
Помечать требования как optional можно только в @objc-протоколе, и тогда соответствовать ему могут только классы; в чистом Swift тот же эффект даёт реализация по умолчанию в расширении протокола.
**@objc optional**: метода может не быть, поэтому вызывающий код обязан это проверить: source.title?() возвращает опционал. Так устроен UITableViewDelegate. Цена: соответствовать могут только классы, все типы в сигнатуре должны быть представимы в Objective-C (никаких собственных структур, дженериков и enum с associated values), а вызовы идут через objc_msgSend.
Реализация по умолчанию: тело метода даёт extension. Соответствовать могут структуры и enum, в сигнатуре допустим любой тип Swift, вызовы остаются быстрыми. Вызывающий код не знает, реализовал ли конформер метод, и обычно это нормально.
Другие варианты: разделить протокол на обязательную и необязательную части (ChartSource и ChartStyling) или сделать опциональное свойство-замыкание.
@objc optional стоит использовать только тогда, когда нужна совместимость с Objective-C.
Структура хранится inline, поэтому Node, содержащий опциональный Node, имел бы бесконечный размер; компилятор сообщает «value type 'Node' cannot have a stored property that recursively contains it». Optional не спасает, потому что это тоже значимый тип, хранящийся inline.
**indirect enum: данные рекурсивного case упаковываются в кучу, и у enum появляется фиксированный размер. Естественный выбор для неизменяемых списков и деревьев выражений.
Класс**: ссылка занимает один указатель, поэтому var next: ListNode? работает. Выбирайте класс, когда узлам нужна идентичность или общее изменение, а обратные ссылки делайте weak, чтобы не было циклов.
Коллекция: var children: [TreeNode] в структуре компилируется, потому что массив хранит элементы в отдельном буфере в куче. Для деревьев это часто самый простой вариант.
Enum выбирают, когда весь набор вариантов принадлежит вам и вы хотите, чтобы компилятор нашёл каждое место, которое надо поменять; протокол выбирают, когда новые варианты должны добавляться без правки существующего кода, например из другого модуля.
Enum: закрытый набор. switch исчерпывающий, поэтому новый case ломает на этапе компиляции каждый switch, который его не учёл. Семантика значений, синтезированные Equatable/Hashable/Codable, никаких аллокаций.
Протокол: открытый набор. Новый конформер не требует правок в других местах, что подходит для плагинов и точек расширения SDK. Исчерпываемость теряется, а вызовы через any диспетчеризуются динамически.
Компромисс: у enum добавить case дорого, а добавить операцию легко; у протокола наоборот, потому что новое требование придётся реализовать в каждом конформере.
На практике: состояние экрана, способы оплаты внутри вашего приложения и результаты API описывают через enum, а поведение оформляется вычисляемыми свойствами самого enum. Блоки контента, которые могут добавлять сторонние разработчики, описывают через протокол.
public-enum в библиотеке, собранной с library evolution, не заморожен (non-frozen), поэтому в switch на стороне клиента нужен @unknown default.
Неявные Int raw values задаются позициями: вставка draft в начало сдвигает все кейсы на единицу, поэтому сохранённая 1, означавшая shipped в 2.2, теперь декодируется как paid, а старые заказы delivered показываются как shipped. Ничего не падает, данные просто молча неверные.
Почему: без явных значений Swift нумерует кейсы 0, 1, 2… в порядке объявления, и Codable сохраняет именно это число. Перестановка, вставка или удаление кейса меняет смысл данных, которые уже лежат на диске, в ответе сервера или в пуше.
Исправление: зафиксировать каждое raw value, а новому кейсу дать новый номер (draft = 3). Старый кэш декодируется ровно как раньше, в каком бы порядке ни были записаны кейсы.
Для новых enum: лучше String raw values: в JSON видно "shipped", и порядок кейсов перестаёт иметь значение. Перевод существующего Int-enum на String ломает старый кэш, поэтому нужна миграция или свой init(from:).
Неизвестные значения: удалённый кейс или значение из более новой версии приложения заставляет decode бросить ошибку; если один битый заказ не должен ронять весь список, декодируйте через свой init(from:) с запасным кейсом.
Как поймать: тест, который декодирует JSON-фикстуру, сохранённую предыдущим релизом; он падает, как только raw value сдвигается.
Каждый подкласс наследует соответствие протоколу, значит, у каждого подкласса тоже должен быть этот инициализатор, и через required компилятор это гарантирует. У final-класса, структуры или enum подклассов нет, и ключевое слово не нужно.
Почему это важно: обобщённый код вроде func make<T: Buildable>(_ type: T.Type) -> T можно вызвать с любым подклассом, и вызов type.init(id:) внутри должен для него сработать.
Если инициализатор ещё и переопределяет инициализатор суперкласса, пишутся оба слова: required override init().
Codable: то же правило на практике: Decodable объявляет init(from:), поэтому не-final класс пишет required init(from:). Подкласс, добавивший хранимые свойства, должен сам написать и init(from:), и encode(to:); если забыть encode(to:), новые поля молча не попадут в JSON.
Как обойти: сделать класс final, использовать структуру или внедрять объект-фабрику вместо вызова init через метатип.
Swift использует самую дешёвую диспетчеризацию, которую допускает объявление: прямые вызовы для значимых типов и final-членов, vtable для переопределяемых методов классов, witness table для требований протоколов и сообщения Objective-C только для @objc dynamic.
Четыре вида
Статическая (прямой вызов): методы структур и enum, final- и private-члены классов, static-методы, методы, которые есть только в расширении протокола. Адрес известен на этапе компиляции, поэтому оптимизатор может встраивать (inline) и специализировать вызов. Самая быстрая.
vtable: не-final методы классов. Одно чтение по индексу из таблицы класса и косвенный вызов. Дёшево, но мешает встраиванию.
Witness table: требования протокола, вызываемые через any P или неспециализированный обобщённый код. Стоимость сопоставима с vtable, плюс упаковка в экзистенциал и retain/release для any. Когда оптимизатор специализирует дженерик, вызов снова становится прямым.
**Сообщение (objc_msgSend)**: @objc dynamic-члены и классы Objective-C. Поиск селектора через кэш методов; самый медленный вид, но единственный, который можно изменить во время выполнения, и на нём держатся KVO и method swizzling.
Что из этого следует
У не-@objc метода в extension класса нет слота в vtable, поэтому подклассы не могут его переопределить; пометка @objc в подклассе NSObject делает его переопределяемым через сообщения.
final, private и whole-module optimization превращают вызовы через vtable в прямые. Это важно в горячих циклах, а не во viewDidLoad контроллера.
some P и дженерики сохраняют знание о конкретном типе и могут быть специализированы; any P всегда идёт через witness table.
В профайлере цена редко видна как сама диспетчеризация; она проявляется как потерянное встраивание и лишние retain/release.
BaseViewController соответствует протоколу через пустую реализацию по умолчанию из extension, поэтому witness table протокола указывает на неё. FeedViewController.refresh() оказывается просто новым методом, refreshAll его никогда не вызывает, и pull-to-refresh на экране ленты ничего не делает.
Почему: соответствие протоколу разрешается один раз, там, где оно объявлено. У BaseViewController нет своего refresh(), поэтому компилятор записывает в witness реализацию по умолчанию. Подклассы наследуют это соответствие вместе с его witness table и подменить witness не могут.
Почему компилируется молча: у базового класса нет refresh() в vtable, поэтому методу подкласса не нужен override. Он срабатывает только при вызове через переменную типа FeedViewController, но никогда не срабатывает через any Refreshable или дженерик T: Refreshable.
Исправление: реализовать refresh() в базовом классе. Тогда witness становится методом класса с диспетчеризацией через vtable, а подклассы обязаны писать override, что заодно документирует намерение.
Что не работает: повторно указать Refreshable у подкласса нельзя: ошибка «redundant conformance of 'FeedViewController' to protocol 'Refreshable'».
Как поймать: тестировать поведение через тип протокола, так, как его вызывает приложение; на ревью метод подкласса, совпадающий с требованием протокола, но без override, служит тревожным сигналом.
Если ни одна реализация по умолчанию не специфичнее другой, Swift не угадывает: либо соответствие протоколу не компилируется, либо вызов неоднозначен, а исправить это можно, реализовав refresh() в самом типе.
Две формы конфликта
**Оба протокола требуют refresh(): каждое расширение предлагает свою реализацию, и ни одна не побеждает, поэтому тип падает с ошибкой «type 'Feed' does not conform to protocol». Когда тип реализует метод сам, этот один метод обслуживает оба протокола.
Члены только в расширениях** (emit() в Logging и Tracing): соответствие компилируется, но Both().emit() падает с «ambiguous use of 'emit()'». Выбрать одну версию можно приведением, (Both() as any Logging).emit(), потому что члены только из extension выбираются по статическому типу.
Как компилятор разрешает ничью, когда может
Член самого конкретного типа побеждает любое расширение.
Расширение с ограничением (where Self: Identifiable) побеждает расширение без ограничений.
Расширение протокола-наследника побеждает расширение протокола, который он уточняет.
Вывод для дизайна
В Swift нет super<A>.refresh(), как в Kotlin, поэтому собственная реализация не может вызвать одну из версий по умолчанию. Считайте реализацию по умолчанию удобством, а не миксином, и называйте члены по ответственности (reloadFromCache(), pollServer()): коллизия обычно означает, что два протокола претендуют на одну и ту же задачу.