Когда несколько классов описывают похожие сущности — например, разные виды сотрудников или разные виды транспорта — у них обычно есть общий набор свойств и поведения. Можно скопировать этот набор в каждый класс, но тогда любое исправление придётся вносить во все копии сразу. Наследование позволяет описать общее один раз в одном классе, а различия — в классах, которые от него наследуются.
Важная оговорка сразу: наследование работает только у class. У struct и enum его нет — это одно из тех различий, из-за которых для моделей данных чаще выбирают структуры, а классы оставляют для случаев, где общее поведение и общее состояние важнее.
Подкласс и родительский класс
Класс, от которого наследуются, называется родительским или суперклассом (superclass). Класс, который наследуется, — подклассом (subclass). Синтаксис такой же, как у соответствия протоколу: имя через двоеточие.
class Animal {
let name: String
init(name: String) {
self.name = name
}
func sound() -> String {
return "..."
}
}
class Dog: Animal {
}
Класс Dog ничего не добавляет, но уже получил и свойство name, и метод sound() — просто по факту наследования. Экземпляр Dog можно передавать всюду, где ожидается Animal: подкласс — это тоже Animal, только более конкретный.
override: замена унаследованного поведения
Чтобы подкласс вёл себя иначе, чем родитель, метод или свойство нужно переопределить — написать заново с ключевым словом override.
class Dog: Animal {
override func sound() -> String {
return "Гав"
}
}
let rex = Dog(name: "Рекс")
print(rex.sound()) // Гав
Ключевое слово override здесь не украшение, а обязательное требование компилятора. Если убрать его, а метод с таким именем и такой же сигнатурой уже есть в Animal, компиляция упадёт с ошибкой: Swift подозревает, что вы либо забыли про переопределение, либо случайно совпало имя. И наоборот: если написать override, а подходящего метода в родителе нет — тоже ошибка. Это защита от опечаток в обе стороны: без явного слова легко было бы либо случайно перекрыть чужой метод, либо решить, что вы что-то переопределили, а на самом деле объявили новый независимый метод.
Переопределять можно не только методы, но и computed-свойства — если в родителе свойство только читается (get), в подклассе его тоже можно переопределить только на чтение, тип должен совпадать.
class Employee {
let baseSalary: Int
init(baseSalary: Int) {
self.baseSalary = baseSalary
}
var salary: Int {
return baseSalary
}
}
class Manager: Employee {
let bonus: Int
init(baseSalary: Int, bonus: Int) {
self.bonus = bonus
super.init(baseSalary: baseSalary)
}
override var salary: Int {
return baseSalary + bonus
}
}
Хранимые свойства (обычные let и var с значением) переопределить нельзя — у подкласса просто появляются новые свои свойства, как bonus в примере выше.
super: обращение к родителю
Иногда переопределённое поведение не заменяет родительское полностью, а дополняет его. Для этого внутри подкласса есть ключевое слово super — ссылка на реализацию из родительского класса.
class Manager: Employee {
let bonus: Int
init(baseSalary: Int, bonus: Int) {
self.bonus = bonus
super.init(baseSalary: baseSalary)
}
func summary() -> String {
return "Оклад: \(baseSalary), бонус: \(bonus)"
}
}
Здесь super.init(baseSalary:) вызывает инициализатор родителя. Это не формальность: у подкласса нет прямого доступа к тому, чтобы заполнить свойства родителя напрямую (даже если бы они были видимы), поэтому правило простое — сначала подкласс настраивает свои собственные свойства, потом обязательно вызывает super.init, чтобы родитель настроил свои. Только после этого экземпляр считается полностью готовым.
Точно так же super используют и в обычных методах — например, чтобы переопределённый метод не заменял поведение родителя, а расширял его:
override func summary() -> String {
return super.summary() + ", менеджер"
}
final: запрет на дальнейшее наследование
Модификатор final запрещает переопределять метод или свойство в подклассах, а final class запрещает наследоваться от класса вообще.
final class Configuration {
let apiURL: String
init(apiURL: String) {
self.apiURL = apiURL
}
}
Попытка написать class DebugConfiguration: Configuration закончится ошибкой компиляции. Это полезно по двум причинам: во-первых, компилятор может лучше оптимизировать вызовы методов, если точно знает, что их не переопределят. Во-вторых, это документирует намерение — класс не проектировался с расчётом на расширение, и наследоваться от него небезопасно или бессмысленно.
Почему наследование часто лишнее
Наследование выглядит соблазнительно простым способом переиспользовать код, но у него есть цена. Подкласс жёстко привязан к устройству родителя: если родитель меняется, все подклассы могут сломаться незаметным образом — это называют хрупкостью базового класса. Кроме того, у класса в Swift может быть только один родитель, а значит, если сущности одновременно похожи на «сотрудника» и на «участника проекта», через наследование выразить это не получится.
Часто более гибкое решение — композиция: вместо того чтобы наследоваться от класса, хранить его экземпляр как свойство и обращаться к нему явно.
struct Engine {
func start() -> String {
return "Двигатель запущен"
}
}
struct Car {
let engine: Engine
func start() -> String {
return engine.start()
}
}
Здесь Car не является Engine, а просто пользуется им. Такой код проще менять: можно подставить другой двигатель, не трогая иерархию классов. В экосистеме Swift это настолько распространённый совет, что дальше в курсе будет отдельная тема — протоколы вместо иерархии классов. Наследование остаётся уместным там, где подклассы — это действительно частные случаи одной и той же сущности и вы контролируете всю иерархию сами.
Частые ошибки
- Забыть
overrideпри переопределении — компилятор не даст скомпилировать код и подскажет, что нужно добавить это слово. - Забыть вызвать
super.init(...)в инициализаторе подкласса — компилятор потребует это сделать, прежде чем позволит использоватьself. - Пытаться переопределить хранимое свойство как обычное — переопределять можно только методы и
computed-свойства, а неlet/varс готовым значением. - Строить глубокую цепочку наследования «на будущее», когда родство сущностей на самом деле условное — это тот самый случай, где стоит присмотреться к композиции.
Резюме
- Наследование есть только у
class: подкласс получает все свойства и методы родителя (суперкласса). - Чтобы заменить унаследованное поведение, метод или computed-свойство переопределяют с ключевым словом
override— оно обязательно в обе стороны. superдаёт доступ к реализации родителя: инициализатор подкласса обязан вызватьsuper.init(...).finalзапрещает переопределение метода или свойства, аfinal class— наследование от класса целиком.- Наследование хорошо для настоящих частных случаев одной сущности; когда родство условное, обычно проще и надёжнее композиция.
Проверьте себя
4 вопроса
Собака переопределяет звук
Дан класс Animal с инициализатором по имени, методом sound(), который возвращает "...", и методом describe(), который возвращает строку вида "Имя издаёт звук: Звук".
Допишите класс Dog, унаследованный от Animal, и переопределите в нём sound() так, чтобы он возвращал "Гав". Класс Animal и функции ниже менять не нужно — реализуйте только тело override func sound() в Dog.
Менеджер с бонусом
Дан класс Employee с полями name, baseSalary, вычисляемым свойством salary (по умолчанию равно baseSalary) и методом summary(), возвращающим строку вида "Имя: Оклад ₽".
Допишите класс Manager, унаследованный от Employee, с дополнительным полем bonus: Int. Переопределите в нём:
salary— должен возвращатьbaseSalary + bonus;summary()— должен возвращать результат родительскогоsummary()(черезsuper) с добавленной в конец строкой" (бонус: N ₽)", гдеN— значениеbonus.
Инициализатор Manager уже написан и вызывает super.init — его менять не нужно.