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 val → override 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. Это модификатор видимости со смыслом: член доступен внутри класса и внутри наследников, но недоступен для внешнего кода. Это один из главных инструментов, чтобы наследование не превращалось в «раздачу внутренностей всем желающим».
Чтобы было легче держать это в голове, вот маленькая таблица:
| Модификатор | Доступ внутри класса | Доступ в наследнике | Доступ «снаружи» |
|---|---|---|---|
|
да | нет | нет |
|
да | да | нет |
|
да | да | да |
|
да | да | да (но только внутри модуля) |
Теперь применим к нашему примеру: пусть базовый класс умеет форматировать сумму в «нормальный вид» и чистить заметку. Мы хотим, чтобы наследники могли этим пользоваться, но внешний код — нет.
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.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ