$ sudo teach IT

Когда несколько классов описывают похожие сущности — например, разные виды сотрудников или разные виды транспорта — у них обычно есть общий набор свойств и поведения. Можно скопировать этот набор в каждый класс, но тогда любое исправление придётся вносить во все копии сразу. Наследование позволяет описать общее один раз в одном классе, а различия — в классах, которые от него наследуются.

Важная оговорка сразу: наследование работает только у 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 — его менять не нужно.