Классы и свойства
Класс в Kotlin состоит из заголовка и тела, и почти вся механика темы живёт на границе между ними. Заголовок несёт первичный конструктор — у него нет тела вовсе, а val/var в его списке параметров вообще не параметры, а объявления свойств. Тело класса компилятор не выполняет как попало: он сшивает все инициализаторы свойств и все блоки init в одно тело первичного конструктора и прогоняет их строго в порядке объявления, вперемежку. Отсюда флагманская ловушка темы: свойство, прочитанное раньше собственного инициализатора, не даёт ошибки компиляции — оно молча отдаёт значение по умолчанию, 0 или null.
Второй водораздел — свойство против поля. val/var компилируются в аксессоры, и лишь при необходимости к ним добавляется backing field: он появляется, только если оставлен аксессор по умолчанию или внутри собственного get/set упомянут магический идентификатор field. Поэтому вычисляемый val с собственным getter не хранит ничего и пересчитывается при каждом обращении, а интерфейс может объявить свойство, но никогда не даст ему поле — состояния в интерфейсе нет. Дальше идут решения, где Kotlin осознанно инвертировал Java: классы и члены здесь final по умолчанию (open — не удобство, а публичный контракт наследования навсегда), вложенный класс по умолчанию не хранит ссылку на внешний объект (inner — согласие на эту ссылку и на утечку памяти вместе с ней), а internal открывает объявление модулю компиляции, а не пакету, потому что package-private в Kotlin просто нет. Разбор по слоям — ниже.
Карта темы
- Первичный и вторичный конструкторы — заголовок без тела,
valв заголовке как объявление свойства, обязательное делегирование черезthis(...)и почему значения по умолчанию почти всегда лучше вторичного конструктора. - Порядок init-блоков — инициализаторы и
initкак единое тело конструктора, значение по умолчанию до присваивания и вызовopen-члена из конструктора базы. - Свойства и backing field — когда поле вообще создаётся, что такое
field, бесконечная рекурсия в собственном setter,private setиconst val. - open против final —
finalпо умолчанию как решение против хрупкого базового класса,openна классе не открывает члены,final overrideи цена открытия ради тестов. - Видимость и internal — модуль компиляции вместо пакета, отсутствие package-private, искажение имён в байткоде и
private constructor. - Вложенные и inner-классы — вложенный класс без ссылки на внешний объект,
innerиthis@Outer, классическая утечка на Android и инверсия правила Java. - Интерфейсы с реализацией — тело метода без ключевого слова
default, неявноopen-члены, свойства без состояния и разрешение ромба черезsuper<A>.
Частые ошибки и ловушки
| Ошибка | Последствие |
|---|---|
| Думать, что первичный конструктор может содержать инструкции | У него нет тела: работа идёт в инициализаторах свойств и блоках init |
Читать val/var в заголовке как обычный параметр | Это объявление свойства; голый name: String виден только в init и инициализаторах, дальше исчезает |
Забывать, что вторичный обязан делегировать через this(...) | Ошибка компиляции: при наличии первичного конструктора его нельзя обойти |
Считать, что блоки init выполняются после всех инициализаторов | Они идут вперемежку, строго в порядке объявления, как одно тело конструктора |
| Ждать, что компилятор поймает опережающее чтение через вызов функции | Прямое val a = b он ловит, а чтение через load() — нет: молча читается 0 или null |
Вызывать open-член из конструктора базового класса | Тело базы выполняется первым — переопределённый член увидит неинициализированные поля наследника |
| Считать, что за каждым свойством стоит хранимое поле | Backing field создаётся, лишь когда используется аксессор по умолчанию или упомянут field |
Пробовать использовать field вне аксессора | Идентификатор существует только внутри get/set своего свойства |
Писать name = value внутри setter свойства name | Setter зовёт сам себя — бесконечная рекурсия и StackOverflowError |
| Считать, что Kotlin унаследовал правило Java «открыто по умолчанию» | Классы и члены final по умолчанию; наследование включается явным open |
Пометить класс open и ждать, что члены тоже открылись | Каждый член остаётся final до отдельного open; открыт лишь сам класс |
Читать final по умолчанию как приём ради производительности | Это решение дизайна: защита от хрупкого базового класса, а не подсказка JIT |
| Открывать продакшен-класс ради тестируемости | open — постоянный публичный контракт во всех модулях; правильный ход — вынести соседа за интерфейс |
Считать internal синонимом package-private | internal — весь модуль компиляции независимо от пакета; package-private в Kotlin нет вовсе |
Полагать, что зависимый модуль всё ещё видит internal | Он не видит: граница — модуль, а не граф зависимостей |
Удивляться, что Java видит internal-член под искажённым именем | В байткоде он public с суффиксом $module_name — отсюда и «уродливое» имя |
| Считать, что вложенный класс по умолчанию ведёт себя как inner-класс Java | По умолчанию он без ссылки на внешний объект — это инверсия правила Java |
Забывать, что inner удерживает внешний объект в памяти | Классическая утечка: слушатель-inner переживает Activity и держит весь её граф |
Искать в Kotlin ключевое слово default для метода интерфейса | Его нет: метод интерфейса просто несёт тело |
Считать члены интерфейса final по умолчанию, как члены класса | Они неявно open — реализующий класс может переопределить любой |
| Ждать, что свойство интерфейса будет что-то хранить | У него нет backing field: интерфейс не держит состояния, только требует его |
Значение для собеседований
Тема выглядит «джуновской» ровно до момента, когда интервьюер показывает класс на пятнадцать строк и спрашивает, почему печатается 0. Здесь проверяют не словарь, а модель исполнения: что именно компилятор делает с телом класса, в какой момент поле получает значение, что физически стоит за словом «свойство» и почему open — это обещание, а не модификатор. Кандидат, который отвечает «init выполняется при создании объекта», отвечает верно и при этом не отвечает ни на что.
Что обычно проверяют:
- Чем первичный конструктор отличается от вторичного и когда
this(...)обязателен. - В каком порядке выполняются инициализаторы свойств и блоки
init— и что вернёт свойство, прочитанное раньше времени. - Что произойдёт, если конструктор базового класса вызовет
open-метод, переопределённый в наследнике. - Когда у свойства появляется backing field, что такое
fieldи чем опасен собственный setter. - Почему в Kotlin
finalпо умолчанию и к чему обязываетopenна классе и его членах. - Что именно открывает
internalи чем он не похож на package-private из Java. - Что меняет
innerу вложенного класса и почему это классический источник утечки памяти. - Чем реализации в интерфейсе Kotlin отличаются от
default-методов Java 8 и как разрешается ромб.
Типичный неверный ответ: «сначала инициализируются все свойства, потом выполняются блоки init». Из этой модели мира следует, что порядок объявления не важен, — и кандидат уверенно объявляет val files = load() выше val limit = 3, а потом ищет баг в load(). На деле инициализаторы и init — это одно тело конструктора, выполняемое сверху вниз в порядке объявления: load() читает ещё не присвоенный limit и получает 0. Второй классический провал — «open ничего не стоит, это же просто разрешение унаследоваться». Стоит: любой переопределённый член, включая собственный getter, может молча сломать инвариант, проверенный в init, и снять open обратно уже нельзя — это публичный контракт во всех модулях, которые успели им воспользоваться.