1. Введение
В прошлой лекции мы научились переопределять методы и свойства через override и открывать нужные точки расширения через open, а также познакомились с protected как «доступно наследникам, но не внешнему миру». Сегодня мы добавим к этому важный практический навык: как не сломать базовую логику, когда вы переопределяете поведение, и почему иногда super — это не «я сдался», а «я сделал правильно». Плюс разберём порядок инициализации, из-за которого у новичков часто возникает ощущение «Kotlin меня троллит».
Когда вы переопределяете метод, у вас появляется соблазн: «ну всё, теперь я главный, базовый класс — на пенсию». Но в реальном коде базовый класс часто делает что-то важное: проверяет входные данные, нормализует текст, ведёт лог, считает какие-то поля, поддерживает инварианты. Если вы «вырежете» это поведение, программа может начать работать странно — причём без ошибок компиляции. И вот тут super выступает как культурный человек, который говорит: «сначала сделаем базовую часть, потом добавим моё».
В Kotlin ключевое слово super позволяет из переопределённого члена обратиться к реализации базового класса (метода или аксессора свойства). Официальная формулировка обычно звучит примерно так: «код в наследнике может вызывать реализацию суперкласса через super».
Самая важная мысль: super нужен не всегда, но если базовый класс выполняет обязательные шаги, super — это способ расширить, а не сломать.
2. super в переопределённом методе
Обычно жизнь подкидывает нам не «заменить метод целиком», а «сделать то же самое, но ещё чуть-чуть». Например, базовый класс уже умеет печатать сообщение, а наследник хочет добавлять префикс. Или базовый класс валидирует строку, а наследник добавляет ещё одну проверку и всё равно хочет использовать базовую.
Сначала посмотрим минимальный пример, чтобы нащупать ощущение:
open class Logger {
open fun log(message: String) {
println("[LOG] $message")
}
}
class TaggedLogger : Logger() {
override fun log(message: String) {
super.log("TAG: $message")
}
}
fun main() {
val logger = TaggedLogger()
logger.log("Hello")
// [LOG] TAG: Hello
}
Здесь наследник не переизобретает печать, а пользуется уже готовым «как именно выводить лог», добавляя своё.
Теперь перенесём идею в наш учебный контекст (консольное приложение учёта расходов). Представим, что мы хотим печатать «строки отчёта». Базовый класс отвечает за общий формат (например, одинаковый отступ и символы), а наследники добавляют конкретное содержимое.
open class ReportLine {
open fun render(): String = "- (empty)"
}
class TextLine(private val text: String) : ReportLine() {
override fun render(): String = super.render() + " $text"
}
fun main() {
val line = TextLine("hello")
println(line.render())
// - (empty) hello
}
Пример нарочно «туповатый», но он показывает механику: super.render() — это базовая часть формата, дальше добавили своё. В реальной жизни базовый render() будет делать что-то полезнее, чем писать "empty", и именно поэтому нам выгодно расширять через super, а не копировать формат по всем наследникам.
Чуть более жизненный вариант: базовый класс гарантирует, что строка отчёта никогда не пустая (например, возвращает "(no data)"), а наследник добавляет детали, не забывая об этом контракте.
3. super и свойства: доступ к геттеру
Новичку часто кажется, что свойство — это «просто поле», а метод — это «логика». В Kotlin это опасная иллюзия: свойство почти всегда означает геттер (а иногда и сеттер), то есть «маленький метод под капотом». Поэтому super.someProp — это не магия, а «вызови реализацию геттера базового класса».
В документации про наследование это обычно показывают так: наследник может обратиться к свойству суперкласса через super.property и использовать его как часть своей логики.
Пример:
open class Document {
open val title: String = "Untitled"
}
class NamedDocument(private val name: String) : Document() {
override val title: String
get() = super.title + ": " + name
}
fun main() {
val doc = NamedDocument("Budget report")
println(doc.title)
// Untitled: Budget report
}
Обратите внимание на деталь: мы переопределили val title не через «хранимое значение», а через get(). Это удобно, когда «значение» нужно вычислять, а не хранить. И да, super.title здесь вызывает базовый геттер.
В реальных проектах это часто используется для «расширения описания». Базовый класс хранит общий кусок, наследник добавляет хвост. И если в базовом классе описание тоже вычисляется (а не просто хранится), super помогает повторно использовать эту базовую логику.
4. Порядок инициализации в наследовании
Пока мы не дошли до подводных камней, давайте разберёмся с тем, в каком порядке создаётся объект, если есть базовый класс и наследник. Многие баги с наследованием выглядят как «почему моё поле ещё 0/null, я же его присвоил!», и почти всегда ответ — порядок инициализации.
Интуитивное правило такое: при создании объекта наследника сначала создаётся «базовая часть» объекта, и только потом — «часть наследника». Это звучит логично, но есть важные нюансы: перед инициализацией базового класса ещё вычисляются аргументы для вызова его конструктора. В Kotlin это описано напрямую: базовая инициализация выполняется первой (кроме вычисления аргументов для конструктора суперкласса), и это означает, что переопределённые в наследнике свойства ещё не проинициализированы, когда выполняется код базового конструктора или init.
Давайте посмотрим на демонстрационный пример с печатью шагов. Тут специально используется also { println(...) }, чтобы показать «когда именно что вычислилось»:
open class Base(name: String) {
init {
println("Base.init, name=$name")
}
open val size: Int = name.length.also {
println("Base.size initialized: $it")
}
}
class Derived(name: String, private val lastName: String) : Base(
name.uppercase().also { println("Argument for Base: $it") }
) {
init {
println("Derived.init, lastName=$lastName")
}
override val size: Int = (super.size + lastName.length).also {
println("Derived.size initialized: $it")
}
}
fun main() {
println("Constructing...")
Derived("hello", "world")
}
Ожидаемый вывод (примерно такой; порядок важнее конкретного текста):
Constructing...
Argument for Base: HELLO
Base.init, name=HELLO
Base.size initialized: 5
Derived.init, lastName=world
Derived.size initialized: 10
Если из этого вынести «скелет происходящего», получится такая схема:
flowchart TD
A["Создаём Derived(...)"] --> B["Вычисляем аргументы для Base(...)"]
B --> C["Инициализация Base: свойства + init-блоки"]
C --> D["Инициализация Derived: свойства + init-блоки"]
Именно поэтому базовый класс должен быть осторожен: во время его инициализации наследник ещё «не собран».
Кстати, если вам хочется быстро проверить порядок в своём коде, println в init { } — это честный отладочный инструмент. Kotlin не осуждает (а ваш будущий тимлид слегка осудит, но это уже другая история).
5. Почему open-вызовы в init опасны
Сейчас мы подходим к тому месту, где наследование перестаёт быть милым котиком и становится котиком, который роняет кружку со стола, потому что «а что вы мне сделаете». Проблема вот в чём: если базовый класс в init вызывает open-метод или обращается к open-свойству, Kotlin может вызвать переопределение в наследнике, хотя наследник ещё не успел инициализировать свои поля.
Это не «особенность компилятора», а логическое следствие полиморфизма: вызов должен пойти в наиболее конкретную реализацию. Документация прямо предупреждает: при проектировании базового класса стоит избегать использования open-членов в конструкторах, инициализаторах свойств и init-блоках.
Посмотрим на пример, который выглядит невинно, но может упасть:
open class BaseGreeter {
init {
// Плохая идея: open-метод вызовется в момент,
// когда наследник может быть ещё не готов.
println(buildGreeting())
}
open fun buildGreeting(): String = "Hello from Base"
}
class FancyGreeter : BaseGreeter() {
private val prefix: String = "[Fancy]"
override fun buildGreeting(): String = "$prefix Hello from Fancy"
}
fun main() {
FancyGreeter()
}
На первый взгляд всё нормально: prefix же объявлен. Но порядок такой, что BaseGreeter.init выполняется до инициализации FancyGreeter (его свойств и init). В некоторых случаях это приведёт к тому, что переопределение попытается использовать ещё не готовое состояние.
Чтобы сделать проблему максимально наглядной и гарантированно получить ошибку, используем lateinit. Это честная ситуация: вы откладываете инициализацию, но при раннем вызове получите исключение.
open class BaseFormatter {
init {
println(format()) // риск
}
open fun format(): String = "(base)"
}
class UserFormatter : BaseFormatter() {
private lateinit var userName: String
init {
userName = "Alice"
}
override fun format(): String = "User=$userName"
}
fun main() {
UserFormatter()
// UninitializedPropertyAccessException: lateinit property userName has not been initialized
}
Это и есть тот самый «почему оно вообще полезло в наследника» момент. Полезло потому, что метод open, и реальный объект — UserFormatter.
Как чинить? Идея простая: базовый класс не должен требовать переопределения для своей инициализации. Один из безопасных приёмов — сделать в базовом классе final-метод, который вызывается в init, а расширение вынести в отдельную точку, которую вы вызываете уже после того, как объект точно собран.
Например, базовый init печатает только базовую часть, а «дополнительное» можно вызвать уже после создания объекта (из внешнего кода или из init наследника, когда он готов).
open class SafeBaseFormatter {
init {
println(baseFormat()) // базовая часть, не open
}
fun baseFormat(): String = "(base safe)"
}
class SafeUserFormatter : SafeBaseFormatter() {
private val userName: String = "Alice"
fun fullFormat(): String = "User=$userName"
}
fun main() {
val f = SafeUserFormatter()
println(f.fullFormat()) // User=Alice
}
Да, это чуть менее «магично», но зато предсказуемо. А предсказуемость — это вообще главная валюта в программировании (после кофе).
6. Мини‑шаг в проекте: форматирование строк отчёта
Чтобы закрепить тему не на абстрактных «прямоугольниках», давайте встроим super в маленький фрагмент нашего консольного приложения учёта расходов. Представим, что у нас уже есть список расходов и мы печатаем отчёт. Мы хотим, чтобы все строки отчёта имели общий стиль: отступ, маркер, ограничение по длине, и чтобы наследники добавляли детали, не копируя этот стиль руками.
Сделаем базовый класс ReportLine, который знает, как сделать «шапку строки», а наследники добавляют содержимое:
open class ReportLine {
protected fun bullet(): String = "•"
open fun render(): String = "${bullet()} "
}
class TextReportLine(private val text: String) : ReportLine() {
override fun render(): String = super.render() + text
}
fun main() {
println(TextReportLine("Expenses").render())
// • Expenses
}
Тут уже видно две идеи дня: super.render() добавляет общую часть, а protected оставляет «служебный» bullet() доступным наследникам, но не внешнему миру.
Теперь добавим строку «итого», где хотим усилить формат: пусть базовая часть остаётся общей, но слово "TOTAL" добавляется сверху. Тут специально покажу, что super можно вызывать хоть в середине строки — это обычная функция.
class TotalReportLine(private val total: Int) : ReportLine() {
override fun render(): String = super.render() + "TOTAL = $total"
}
fun main() {
println(TotalReportLine(1200).render())
// • TOTAL = 1200
}
А теперь покажем пример, где super используется в свойствах, потому что «заголовок» тоже удобно сделать свойством:
open class TitledLine {
open val title: String = "line"
open fun render(): String = "[$title]"
}
class CategoryLine(private val category: String) : TitledLine() {
override val title: String
get() = super.title + ":" + category
}
fun main() {
println(CategoryLine("Food").render())
// [line:Food]
}
Здесь super.title обращается к базовому геттеру. Даже если базовый title станет вычисляемым, код наследника не придётся переписывать.
И маленькая практическая мораль: super помогает сделать так, чтобы общий стиль отчёта жил в одном месте. Когда вы решите сменить маркер "•" на "-", вам не придётся искать 15 копий форматирования по проекту.
Когда вызывать super, а когда нет
В реальности «всегда вызывай super» — плохой совет, и «никогда не вызывай super» — тоже. Нужен трезвый критерий: является ли базовая реализация частью обязательного контракта, или вы её действительно заменяете.
Удобно держать такую табличку в голове:
| Ситуация | Что обычно означает | Что чаще делать |
|---|---|---|
| Базовый метод валидирует данные, логирует, обновляет счётчик | Важная инфраструктура, без неё класс может быть «поломан» | Вызывать super и добавлять своё до/после |
| Базовый метод возвращает «заглушку» или дефолт | База не несёт смысла, это просто «чтобы было» | Можно полностью заменить без super |
| Переопределение меняет формат, но общий каркас один | Общая часть нужна всем наследникам | super + добавка |
| Базовый класс в init вызывает open-метод | Риск раннего вызова наследника | Переделать дизайн: super тут не спасает |
И отдельное правило, которое стоит написать на стикере: если базовый класс делает что-то важное для корректности, а вы не вызвали super, ваш код может работать «почти всегда» — а это самый коварный режим поломки.
7. Типичные ошибки
Ошибка №1: “Я переопределил метод — значит базовая логика больше не нужна”.
Такой подход часто ломает инварианты базового класса: например, он нормализует входные данные, ведёт счётчик, заполняет служебные поля или печатает обязательную часть отчёта. Если вы не вызываете super, вы не просто «меняете поведение», вы можете случайно отменить важную часть контракта.
Ошибка №2: вызов super “не в том месте”, из-за чего логика становится странной.
Иногда порядок важен: базовый метод может рассчитывать, что его вызов — первый шаг (например, он проверяет аргументы), или наоборот — последний шаг (например, он финализирует состояние). Если вы вызываете super после того, как уже сделали действия, зависящие от базовой подготовки, вы получаете баги «вроде всё правильно, но результат кривой».
Ошибка №3: обращение к super.someProperty и ожидание, что это просто поле, а не вычисление.
В Kotlin свойство — это, по сути, методы get()/set(). super.title может быть вычисляемым, может обращаться к другим полям и даже иметь побочные эффекты (хотя так делать не стоит). Поэтому super.property надо воспринимать как вызов логики, а не чтение памяти.
Ошибка №4: вызов open-методов/свойств из init базового класса.
Это один из самых неприятных «профессиональных» багов: код компилируется, иногда даже работает, но в момент усложнения наследника начинает падать с тем, что поля «ещё не готовы». Kotlin отдельно предупреждает об этом паттерне: при проектировании базового класса лучше избегать open-вызовов в конструкторе и init.
Ошибка №5: попытка лечить проблему порядка инициализации “ещё одним lateinit”.
lateinit — полезный инструмент, но он не делает порядок инициализации безопаснее. Наоборот: если open-вызов случится слишком рано, вы получите исключение. Правильное лечение тут не в том, чтобы «отложить инициализацию ещё сильнее», а в том, чтобы базовый класс не требовал переопределения для собственного создания, учитывая порядок инициализации в иерархии.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ