override и protected в Kotlin

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

1. Введение

Когда новичок впервые видит override, реакция часто такая: «Зачем мне это писать, компилятор и так видит, что я хочу сделать такой же метод». И вот тут Kotlin делает очень по-взрослому: он заставляет вас явно подтвердить намерение. Потому что в реальных проектах «похожий метод» и «переопределённый метод» — это разные вещи, и цена ошибки обычно измеряется часами отладки.

В Kotlin переопределение возможно только если в базовом классе член помечен как open, а в наследнике вы пишете override. Это правило прямое и жёсткое: компилятор не даёт вам случайно «подменить» метод с опечаткой в сигнатуре или с другим типом. Kotlin прямо требует явных модификаторов для переопределяемых членов и для самих переопределений.

Небольшая демонстрация (мы продолжаем условное консольное приложение учёта денег; назовём его MoneyFlow):


open class Operation {
    open fun title(): String = "Operation"
}

class ExpenseOperation : Operation() {
    // fun title(): String = "Expense" // не компилируется без override
    override fun title(): String = "Expense"
}

Здесь важно почувствовать идею: Kotlin не «придирается», он помогает не ошибиться. Если вы действительно хотите переопределить — напишите override. Если вы написали метод «случайно похожий» — компилятор вас остановит.

2. Переопределение методов: open fun в базе и override fun в наследнике

Обычно мотивация для override появляется очень жизненная: вы сделали базовый класс «в целом про операции», а потом поняли, что расход и доход нужно описывать по-разному. Хороший стиль — дать базовому классу контракт (метод, который «обязан существовать»), а наследникам — возможность реализовать его по-своему.

Правило очень простое: метод базового класса должен быть open, иначе его нельзя переопределить. Kotlin отдельно подчёркивает: если метод не open, то объявить в наследнике метод с такой же сигнатурой нельзя — ни с override, ни без него.

Сделаем базовый класс Operation и наследника. Пока без «сложной архитектуры», просто чтобы рука привыкла:

open class Operation(
    val amountCents: Int,
) {
    open fun signedAmountCents(): Int = amountCents

    open fun renderLine(): String =
        "Amount: ${signedAmountCents()} cents"
}

class Expense(amountCents: Int) : Operation(amountCents) {
    override fun signedAmountCents(): Int = -amountCents

    override fun renderLine(): String =
        "Expense: ${signedAmountCents()} cents"
}

Обратите внимание на одну полезную привычку: мы не переопределяем «всё подряд», а только то, что действительно должно отличаться. Наследование — инструмент, а не религия.

Проверим, что работает:

fun main() {
    val op = Operation(amountCents = 500)
    val exp = Expense(amountCents = 500)

    println(op.renderLine())   // Amount: 500 cents
    println(exp.renderLine())  // Expense: -500 cents
}

Да, пока мы даже не обсуждаем «умные коллекции базового типа» — это будет отдельная тема. Сейчас нам важно увидеть базовую механику: наследник реально меняет поведение через override.

3. Переопределение свойств: open val, совместимость типов и трюк val var

Методы — это «поведение». Но в Kotlin переопределять можно и свойства. И это обычно ещё более практично, чем кажется: базовый класс может обещать «у каждой операции есть kind», а конкретные операции уточняют, что именно это за kind.

С точки зрения языка свойства переопределяются почти так же, как методы: в базовом классе open val/var, в наследнике — override val/var. Механизм переопределения свойств описан в официальных правилах наследования: тип должен быть совместимым, а переопределение можно писать как свойство с инициализацией или как свойство с кастомным get().

Простой пример: open valoverride val

open class Operation(
    val amountCents: Int,
) {
    open val kind: String = "OPERATION"
}

class Income(amountCents: Int) : Operation(amountCents) {
    override val kind: String = "INCOME"
}

class Expense(amountCents: Int) : Operation(amountCents) {
    override val kind: String = "EXPENSE"
}

Проверка:

fun main() {
    val inc = Income(1200)
    val exp = Expense(700)

    println(inc.kind) // INCOME
    println(exp.kind) // EXPENSE
}

Переопределение через get(): когда значение вычисляется

Иногда «тип операции» хочется собирать из нескольких частей. Тогда удобно переопределять свойство через геттер:

open class Operation(val amountCents: Int) {
    open val kind: String
        get() = "OPERATION"
}

class Expense(amountCents: Int, private val category: String) : Operation(amountCents) {
    override val kind: String
        get() = "EXPENSE($category)"
}

Тест:

fun main() {
    val exp = Expense(amountCents = 500, category = "Food")
    println(exp.kind) // EXPENSE(Food)
}

Почему можно val переопределить как var (и почему наоборот нельзя)

Это место часто вызывает ощущение «магии», но объяснение довольно приземлённое. val — это по сути «есть геттер». var — это «есть геттер и сеттер». Если базовый класс обещал только геттер (val), наследник может дать больше возможностей и добавить сеттер (var). Обратное направление запрещено: если базовый класс обещал сеттер, наследник не имеет права «урезать контракт» и оставить только геттер.

Короткая иллюстрация:

open class Operation {
    open val note: String = ""
}

class EditableOperation : Operation() {
    override var note: String = "draft" // val -> var: можно
}

4. Полезные нюансы: Kotlin 2.x и final override

Kotlin 2.x: open-свойства с backing field инициализируются сразу

С Kotlin 2.0+ есть правило, про которое приятно знать заранее, чтобы не словить «почему вчера работало, а сегодня нет». Если свойство open и у него есть backing field (то есть это не чисто вычисляемое get()), то оно должно быть инициализировано сразу — в месте объявления, а не «потом в init». В Kotlin 2.0 это стало строже: теперь и open val с backing field нельзя откладывать до init.

Плохой (или, точнее, больше не разрешённый) стиль:

open class Base {
    // open val a: Int  // допустим, хотели так
    // init { a = 1 }   // Kotlin 2.x скажет: нельзя
}

Хороший стиль — сразу дать значение:

open class Base {
    open val a: Int = 1
}

Или сделать свойство вычисляемым (без backing field), если оно действительно вычисляется:

open class Base {
    open val a: Int
        get() = 1
}

Для учебных проектов это правило особенно полезно: оно заставляет писать инициализацию так, чтобы поведение не зависело от «тонких моментов» порядка создания объекта.

final override: как «закрыть дверь» после переопределения

Есть тонкость, которую очень легко пропустить: в Kotlin член, помеченный как override, сам становится открытым для дальнейшего переопределения (если вы его явно не закрыли). Это логично: вы продолжаете цепочку наследования.

Но иногда вам нужно сделать наоборот: «на этом уровне иерархии мы фиксируем реализацию и дальше не даём её менять». Для этого используется final override. Если нужно запретить переопределение дальше, используйте final.

Пример с нашей «финансовой» тематикой:

open class Operation {
    open fun kindCode(): String = "OP"
}

open class ExpenseBase : Operation() {
    final override fun kindCode(): String = "EXP" // дальше переопределять нельзя
}

class FoodExpense : ExpenseBase() {
    override fun kindCode(): String = "FOOD" // не компилируется
}

Это удобно, когда вы хотите стабилизировать поведение на определённом уровне. Например, все расходы должны иметь код "EXP", а уточнения «еда/транспорт» будут храниться отдельным свойством (а не ломать общий контракт).

5. protected: видно наследникам, но не видно снаружи

До этого момента мы обсуждали «как менять поведение». Но наследование быстро приводит к другой проблеме: наследнику иногда нужно воспользоваться какими-то внутренними деталями базового класса, а вы не хотите делать эти детали public (потому что тогда ими начнёт пользоваться кто угодно, и вы потеряете контроль).

Для этого существует protected. Это модификатор видимости со смыслом: член доступен внутри класса и внутри наследников, но недоступен для внешнего кода. Это один из главных инструментов, чтобы наследование не превращалось в «раздачу внутренностей всем желающим».

Чтобы было легче держать это в голове, вот маленькая таблица:

Модификатор Доступ внутри класса Доступ в наследнике Доступ «снаружи»
private
да нет нет
protected
да да нет
public
да да да
internal
да да да (но только внутри модуля)

Теперь применим к нашему примеру: пусть базовый класс умеет форматировать сумму в «нормальный вид» и чистить заметку. Мы хотим, чтобы наследники могли этим пользоваться, но внешний код — нет.

open class Operation(
    val amountCents: Int,
    note: String,
) {
    // Храним "сырую" заметку, но не отдаём её наружу
    protected var normalizedNote: String = note.trim()

    protected fun formatMoney(): String {
        val dollars = amountCents / 100
        val cents = amountCents % 100
        return "$dollars.${cents.toString().padStart(2, '0')}"
    }

    open fun renderLine(): String =
        "Amount: ${formatMoney()} USD, note='$normalizedNote'"
}

class Expense(amountCents: Int, note: String) : Operation(amountCents, note) {
    override fun renderLine(): String {
        // Наследник видит protected-элементы и может ими пользоваться
        normalizedNote = "[EXP] $normalizedNote"
        return "Expense: -${formatMoney()} USD, note='$normalizedNote'"
    }
}

Проверим:

fun main() {
    val exp = Expense(amountCents = 2599, note = "  lunch  ")
    println(exp.renderLine()) // Expense: -25.99 USD, note='[EXP] lunch'

    println(exp.normalizedNote) // не компилируется: protected
    println(exp.formatMoney())  // не компилируется: protected
}

Вот это и есть хороший компромисс: наследники получают «инструменты» и доступ к нужной внутренней части состояния, но внешний код видит только публичный контракт.

Отдельный интересный момент: если вы пишете extension-функцию снаружи класса, она не может «вломиться» в private/protected члены этого класса — расширения работают снаружи и подчиняются правилам видимости. Это полезно помнить, когда появляется желание «обойти» инкапсуляцию через extension.

6. Практический пример: контракт + override + protected

Соберём всё в одну маленькую «проверочную» программу, чтобы увидеть, что темы лекции сочетаются в реальном коде, а не живут в вакууме.

open class Operation(val amountCents: Int) {
    open val kind: String = "OP"

    protected fun formatMoney(): String {
        val dollars = amountCents / 100
        val cents = amountCents % 100
        return "$dollars.${cents.toString().padStart(2, '0')}"
    }

    open fun renderLine(): String = "$kind ${formatMoney()} USD"
}

class Income(amountCents: Int) : Operation(amountCents) {
    override val kind: String = "IN"
    override fun renderLine(): String = "$kind +${formatMoney()} USD"
}

class Expense(amountCents: Int) : Operation(amountCents) {
    override val kind: String = "EX"
    override fun renderLine(): String = "$kind -${formatMoney()} USD"
}

fun main() {
    val a = Income(1250)
    val b = Expense(399)

    println(a.renderLine()) // IN +12.50 USD
    println(b.renderLine()) // EX -3.99 USD
}

Здесь видно сразу три ключевых идеи: базовый класс задаёт «скелет» API, наследники уточняют детали через override, а общая полезная логика остаётся в базовом классе и доступна наследникам через protected, не становясь публичной.

7. Типичные ошибки при override и protected

Ошибка №1: попытка переопределить то, что не open.
Это самая частая история: вы пишете в наследнике override fun …, а компилятор говорит, что переопределять нечего. Обычно причина в том, что базовый метод (или свойство) забыли пометить open. В Kotlin «закрыто по умолчанию» — нормальное состояние, и open нужно добавлять осознанно.

Ошибка №2: «Я точно переопределил», но сигнатура чуть-чуть другая.
Иногда меняется тип параметра, порядок аргументов или тип результата — и внезапно это уже не переопределение, а совсем другой метод. Kotlin частично спасает вас тем, что требует override: если сигнатура не совпала, код просто не скомпилируется. Но начинающие иногда убирают override «чтобы хоть как-то работало» — и получают два разных метода, один из которых никто не вызывает. Лучшее лекарство — не бороться с компилятором, а читать ошибку и сравнивать сигнатуры.

Ошибка №3: ожидание, что override «по умолчанию final».
Новички иногда думают: «Ну я же уже переопределил, значит дальше нельзя». В Kotlin наоборот: переопределённый член обычно остаётся открытым для дальнейших переопределений, и если вы хотите зафиксировать реализацию, нужен final override.

Ошибка №4: откладывать инициализацию open-свойства на init (Kotlin 2.x).
На старом опыте из других языков хочется объявить open val x: Int, а значение присвоить в init. В Kotlin 2.x это может сломаться: open-свойства с backing field должны инициализироваться сразу. Если вам нужно отложенное вычисление — лучше сделать вычисляемый get() или поменять дизайн (например, убрать open, если это реально не нужно).

Ошибка №5: делать поле public, потому что «наследнику надо», и случайно открыть внутренности всему миру.
Это архитектурная ошибка, а не синтаксическая. Если наружному коду не нужно трогать внутреннюю переменную или helper-метод, но наследнику нужно — это прямой кейс для protected. Так вы оставляете возможность расширения, не превращая класс в «витрину внутренней кухни».

Ошибка №6: пытаться добраться до protected через extension-функцию «снаружи».
Иногда кажется, что extension — это как «дописать метод прямо в класс», значит можно залезть в protected. Но extension живёт вне класса и подчиняется видимости: снаружи protected недоступен. Если хочется использовать внутреннюю деталь — значит, либо делайте это внутри наследника, либо пересматривайте API.

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