JavaRush /Курсы /Kotlin SELF /Наследование в Kotlin: fi...

Наследование в Kotlin: final по умолчанию и синтаксис : Base (...)

Kotlin SELF
35 уровень , 0 лекция
Открыта

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 ...
Можно написать
class Child : Base(...)
Разрешаем переопределять метод/свойство
open fun ...
/
open val ...
Можно написать
override ...

Ключевая мысль: 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 наследниками.

1
Задача
Kotlin SELF, 35 уровень, 0 лекция
Недоступна
Сломанное наследование
Сломанное наследование
1
Задача
Kotlin SELF, 35 уровень, 0 лекция
Недоступна
Статья в CMS
Статья в CMS
1
Задача
Kotlin SELF, 35 уровень, 0 лекция
Недоступна
Плагин сервиса
Плагин сервиса
1
Задача
Kotlin SELF, 35 уровень, 0 лекция
Недоступна
Ошибка в логе
Ошибка в логе
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ