1. Введение
Наследование — это когда мы создаём новый класс на основе существующего. Новый класс (наследник) получает свойства и поведение базового класса и может добавлять свои детали. В учебниках это обычно описывают фразой «это чтобы не копировать код», но на практике мысль чуть глубже: наследование помогает выразить связь «является разновидностью».
Представьте, что в нашем консольном приложении учёта денег (расходы/доходы) мы хотим хранить разные записи: расход и доход. У них есть общий минимум: сумма, комментарий, идентификатор. Но у расхода есть категория, а у дохода — источник. Нам хочется описать общий фундамент один раз, а потом расширять. Вот тут и появляется наследование.
Но Kotlin относится к наследованию осторожно. И это не занудство языка, а прагматичная позиция: наследование — мощный инструмент, который легко превратить в «макаронную иерархию», где один класс тянет за собой десяток других, и никто уже не понимает, где чей код.
2. final по умолчанию и ключевое слово open
Начнём с самого важного правила: классы в Kotlin по умолчанию final. Это означает, что от них нельзя наследоваться. И это поведение — норма языка: если автор класса не сказал «можно наследоваться», значит — нельзя.
Идея такая: наследование — это не просто «фича», это обещание. Если вы разрешили наследоваться от вашего класса, значит вы как автор класса допускаете, что кто-то будет расширять его поведение. А значит, вам нужно думать про стабильность контрактов, про защищённые детали, про то, какие методы можно переопределять, и так далее. Kotlin заставляет вас принять это решение явно.
С практической точки зрения это выглядит так: вы пишете обычный класс, а потом пытаетесь сделать наследника — и компилятор говорит «нельзя». Пример:
class Box(val value: Int)
// class IntBox(value: Int) : Box(value) // не скомпилируется: Box final по умолчанию
Как разрешить наследование через open
Чтобы от класса можно было наследоваться, его нужно пометить ключевым словом open.
open class Message(val text: String)
class ErrorMessage(text: String) : Message(text)
Здесь Message открыт для наследования, поэтому ErrorMessage может от него наследоваться. Важно не перепутать: open относится именно к возможности быть базовым классом. Если open не написан — класс закрыт.
Иногда новичок воспринимает open как «пусть будет на всякий случай». Я бы так не делал. В Kotlin хороший тон — открывать наследование только тогда, когда вы действительно планируете иерархию и хотите разрешить расширение.
3. Синтаксис : Base(...) и вызов конструктора базового класса
Теперь перейдём к самой «механической» части: как выглядит наследование в коде.
В Kotlin наследование пишется через двоеточие после заголовка класса:
open class Base(p: Int)
class Derived(p: Int) : Base(p)
После : указывается базовый тип, а если это класс, то дальше идёт вызов его конструктора.
Почему мы вызываем конструктор базового класса
Потому что базовый класс — это часть объекта. Когда вы создаёте наследника, внутри него сначала должен быть корректно создан «кусок базового класса». А значит базовый класс должен получить параметры, которые ему нужны.
Если у базового класса есть параметры в первичном конструкторе, наследник обязан эти параметры передать.
Нужны ли скобки после Base
Если базовый тип — класс, то да: скобки нужны, даже если конструктор без параметров. Это частая мелкая «спотыкалка» у начинающих: визуально хочется написать просто : Base, но Kotlin ждёт : Base().
Пример с «пустым» конструктором:
open class Parent
class Child : Parent()
У Parent есть неявный конструктор без параметров, и мы его вызываем через Parent().
4. Наследник может добавлять свои свойства и методы
В этой лекции мы не переопределяем поведение через override (это будет следующая тема). Сегодня нам важно понять другое: даже без override наследование уже полезно, потому что наследник может расширить модель данными и новыми методами.
Скажем, у нас есть базовый класс «Пользователь», а наследник «Сотрудник», у которого есть должность:
open class Person(val name: String)
class Employee(name: String, val role: String) : Person(name)
Обратите внимание на две вещи.
Во-первых, Employee получает name, но не хранит его повторно: name уже объявлен в Person. То есть мы избегаем дублирования данных.
Во-вторых, у Employee появляется новое свойство role. Это классический сценарий: «добавляем детали».
5. Что именно включает open
Чтобы не возникло ложного ожидания «я написал open, и теперь в наследнике можно менять что угодно», полезно зафиксировать различие.
| Что мы делаем | Что нужно написать в базовом классе | Что мы получаем |
|---|---|---|
| Разрешаем наследоваться от класса | |
Можно написать |
| Разрешаем переопределять метод/свойство | / |
Можно написать |
Ключевая мысль: open class разрешает наследование, но не делает все члены автоматически переопределяемыми. В Kotlin и классы, и их члены по умолчанию final, и «опасные возможности» нужно включать явно.
6. Пример: общий базовый класс для записей бюджета
Теперь давайте аккуратно привяжем тему к нашему «практическому» консольному приложению учёта денег. До этого момента у нас могла быть одна модель, например Expense (расход). Но как только мы захотим добавить доходы, быстро выяснится, что расход и доход имеют общие поля.
Сделаем базовый класс LedgerEntry (запись в журнале), который будет хранить общий минимум. Мы пока не будем усложнять логику и ограничимся данными.
Базовый класс LedgerEntry
open class LedgerEntry(
val id: Int,
val amount: Int,
val note: String,
)
Тут всё просто: open, потому что мы планируем наследников.
Наследник: расход
Расходу добавим категорию. Категории у нас уже могли быть сделаны через enum class в предыдущих лекциях.
enum class Category { FOOD, TRANSPORT, OTHER }
data class ExpenseEntry(
val category: Category,
val merchant: String,
val entryId: Int,
val entryAmount: Int,
val entryNote: String,
) : LedgerEntry(entryId, entryAmount, entryNote)
Да, здесь есть немного «механики» в параметрах: чтобы пример оставался коротким, мы явно назвали параметры entryId/entryAmount/entryNote, а потом передали их в базовый класс.
Самое важное в этом куске кода — финальная часть:
) : LedgerEntry(entryId, entryAmount, entryNote)
Это ровно тот синтаксис : Base(...), который мы сегодня изучаем.
Наследник: доход
Доходу добавим источник:
data class IncomeEntry(
val source: String,
val entryId: Int,
val entryAmount: Int,
val entryNote: String,
) : LedgerEntry(entryId, entryAmount, entryNote)
Пока мы не делаем никаких переопределений: мы просто создаём общий «каркас» и несколько конкретных вариантов.
Небольшой тест в main
Чтобы увидеть, что это вообще работает, сделаем маленький вывод:
fun main() {
val e = ExpenseEntry(Category.FOOD, "Coffee Shop", 1, 450, "Латте")
val i = IncomeEntry("Salary", 2, 120_000, "Я молодец")
println(e.note) // Латте
println(i.note) // Я молодец
}
Мы обращаемся к note, хотя note объявлен в LedgerEntry, а объект — наследник. Это и есть базовая практическая ценность наследования: общие свойства живут в одном месте.
7. Ограничения наследования в Kotlin
data class и наследование
Полезно аккуратно проговорить момент, который часто удивляет.
data class может наследоваться от обычного класса. Это нормально и часто используется, если базовый класс даёт какую-то общую структуру или контракт. При этом важно помнить, что data class не может быть open.
То есть вот так можно:
open class Base(val id: Int)
data class Item(val title: String, val itemId: Int) : Base(itemId)
А вот так нельзя:
open data class Item(...) // так не бывает: data class не может быть open
Почему это важно? Потому что у новичков часто появляется желание сделать «универсальную data-модель», а потом от неё наследоваться. Kotlin этому сопротивляется: data class задуман как «конкретная модель данных», а не как «точка расширения».
Один базовый класс
Ещё одно правило Kotlin: класс может иметь только один базовый класс. То есть вы можете написать class A : B(), но не можете написать class A : B(), C() если C тоже класс.
Если вы когда-нибудь видели в других языках множественное наследование классов, вы могли подумать: «А почему Kotlin так не умеет?» Если по-человечески — потому что это почти всегда приводит к конфликтам: какие поля брать, какие методы вызывать, что делать, если два базовых класса определяют одно и то же.
Схема иерархии
classDiagram
LedgerEntry <|-- ExpenseEntry
LedgerEntry <|-- IncomeEntry
class LedgerEntry {
+id: Int
+amount: Int
+note: String
}
class ExpenseEntry {
+category: Category
+merchant: String
}
class IncomeEntry {
+source: String
}
Смысл диаграммы простой: ExpenseEntry и IncomeEntry — разные классы, но у них общий фундамент LedgerEntry.
8. Типичные ошибки
Ошибка №1: попытка унаследоваться от класса без open.
Это самый частый стартовый промах: пишем class Child : Parent() и искренне удивляемся, почему компилятор недоволен. В Kotlin по умолчанию всё final, и наследование нужно включать явно. Если класс ваш — добавляйте open. Если класс чужой (например, из библиотеки) — значит автор библиотеки сознательно запретил наследование, и это лучше уважать.
Ошибка №2: забыть, что базовый класс нужно инициализировать.
Новичку иногда кажется, что наследование — это просто «двоеточие и имя», а параметры как-нибудь «протекут». Но базовый класс — это часть объекта, и он должен быть создан корректно. Поэтому, если у базового конструктора есть параметры, наследник обязан вызвать Base(...) с подходящими аргументами.
Ошибка №3: написать : Base вместо : Base() и потом долго смотреть в ошибку.
Если базовый тип — класс, у него вызывается конструктор, значит нужны скобки. Даже если параметров нет, пишем Base().
Ошибка №4: ожидать, что open class автоматически разрешает переопределять всё подряд.
open class означает «разрешить наследование», а не «разрешить изменять любые методы». В Kotlin члены тоже final по умолчанию, и чтобы переопределять метод, он должен быть open.
Ошибка №5: попытка сделать data class базовым иерархическим классом.
Интуитивно хочется: «пусть базовый будет data class, чтобы был красивый toString()». Но Kotlin не даёт сделать data class открытым для наследования, и это ограничение стоит принять как подсказку дизайна: базовый класс — это чаще каркас, а data class — конкретная запись. Если нужно и то, и другое — делайте open class базой, а data class наследниками.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ