Классы и свойства
Механика классов в повседневном Kotlin — первичные и вторичные конструкторы, порядок инициализации, open против final, модификатор видимости internal, вложенные и inner-классы, backing field и реализации по умолчанию в интерфейсах.
8 вопросов
JuniorТеорияЧастоПочему класс в Kotlin нельзя наследовать, пока он не помечен open?
Почему класс в Kotlin нельзя наследовать, пока он не помечен open?
Классы Kotlin и их члены по умолчанию final — в отличие от Java. Наследование и переопределение включаются явно через open. Это сделано намеренно: класс должен быть либо спроектирован под наследование (описанные точки расширения, стабильный контракт), либо запрещать его, чтобы подкласс случайно не сломал инварианты, которых автор не предвидел.
Типичные ошибки
- ✗Считать, что Kotlin унаследовал правило Java — открыто по умолчанию — для классов и членов
- ✗Читать
finalпо умолчанию как приём ради производительности, а не как решение дизайна - ✗Пометить класс
open, забыв, что его члены по отдельности всё ещёfinal
Уточняющие вопросы
- →Какие объявления являются
openнеявно, без ключевого слова? - →Как плагины компилятора для Spring обходят
final-классы?
JuniorТеорияИногдаКогда у свойства в Kotlin появляется backing field, и что такое field?
Когда у свойства в Kotlin появляется backing field, и что такое field?
Backing field появляется у свойства, только если его аксессоры реально используют field — неявную ссылку на хранимое значение, видимую лишь внутри get/set. Аксессор по умолчанию его использует, поэтому обычный val/var что-то хранит. Свойство с собственным getter, считающим значение из других свойств (val area get() = w * h), не хранит ничего.
Типичные ошибки
- ✗Считать, что за каждым свойством стоит хранимое поле, даже за вычисляемым
- ✗Пробовать использовать
fieldвне аксессора свойства, где его просто нет - ✗Писать
name = valueвнутри setter свойстваname, получая бесконечную рекурсию
Уточняющие вопросы
- →Что произойдёт, если собственный setter присвоит значение имени свойства, а не
field? - →Почему интерфейс может объявить свойство, но никогда не даёт ему backing field?
JuniorТеорияИногдаЧем первичный конструктор отличается от вторичного в Kotlin?
Чем первичный конструктор отличается от вторичного в Kotlin?
Первичный конструктор находится в заголовке класса — class User(val name: String) — и не имеет тела: его работа выполняется в инициализаторах свойств и блоках init. Вторичный объявляется в теле через constructor(...) и обязан делегировать первичному через this(...), если тот есть.
Типичные ошибки
- ✗Думать, что первичный конструктор может содержать инструкции вместо блоков
init - ✗Забывать, что вторичный обязан делегировать первичному через
this(...), если тот есть - ✗Читать
val/varв заголовке как обычный параметр, а не как объявление свойства
Уточняющие вопросы
- →В каком порядке выполняются инициализаторы свойств и блоки
init? - →Когда объявление вторичного конструктора в Kotlin действительно необходимо?
JuniorТеорияИногдаЧто делает видимым internal, и чем он не похож на package-private из Java?
Что делает видимым internal, и чем он не похож на package-private из Java?
internal открывает объявление всему модулю компиляции — одному source set Gradle, одному модулю Maven — независимо от пакета. Package-private в Java работает по другой оси: только внутри своего пакета, но зато во всех jar, где это имя пакета встречается. Имена internal к тому же искажаются в байткоде, чтобы Java-код не вызвал их случайно.
Типичные ошибки
- ✗Считать
internalточным синонимом package-private из Java - ✗Полагать, что зависимый модуль всё ещё видит
internal-объявление - ✗Удивляться, что Java видит
internal-член под искажённым именем
Уточняющие вопросы
- →Что именно считается одним модулем для целей
internal? - →Зачем Kotlin искажает имя
internal-функции в байткоде?
MiddleДебаггингИногдаПочему этот класс печатает files=0, хотя limit равен 3?
Почему этот класс печатает files=0, хотя limit равен 3?
Инициализаторы свойств и блоки init выполняются сверху вниз, в порядке объявления, как единое тело первичного конструктора. files = load() выполняется до присваивания limit, поэтому load() читает значение по умолчанию 0 и строит пустой список. Компилятор этого не видит, так как чтение идёт через вызов функции. Решение — объявить limit выше files.
Типичные ошибки
- ✗Считать, что блоки
initвыполняются после всех инициализаторов, а не вперемежку с ними - ✗Ждать, что компилятор поймает опережающее чтение, идущее через вызов функции
- ✗Думать, что
valдоступен с момента открытия тела класса
Уточняющие вопросы
- →Какое значение хранит ещё не инициализированное свойство
Int, пока конструктор работает? - →Как
by lazyнаfilesтоже чинит это без перестановки объявлений?
MiddleТеорияИногдаЧто меняется, когда вы добавляете inner к вложенному классу?
Что меняется, когда вы добавляете inner к вложенному классу?
По умолчанию вложенный класс — как static-вложенный класс в Java: он не хранит ссылку на внешний экземпляр и не может обращаться к свойствам внешнего класса. inner добавляет неявную ссылку на охватывающий объект, поэтому он читает свойства внешнего класса и использует this@Outer — ценой того, что внешний объект живёт, пока жив внутренний.
Типичные ошибки
- ✗Считать, что вложенный класс Kotlin по умолчанию ведёт себя как нестатический inner-класс Java
- ✗Забывать, что
innerудерживает внешний объект в памяти — классический источник утечки - ✗Думать, что
inner— про видимость, а не про неявную ссылку на внешний объект
Уточняющие вопросы
- →Как
inner-класс отличает свойthisотthisвнешнего объекта? - →Почему
inner-класс — частая причина утечки памяти в Android?
MiddleТеорияИногдаЧем реализации методов в интерфейсе Kotlin отличаются от default-методов Java 8?
Чем реализации методов в интерфейсе Kotlin отличаются от default-методов Java 8?
Оба дают интерфейсу реализацию. В Kotlin не нужно ключевое слово default — вы просто пишете тело, — а члены интерфейса неявно open, в отличие от членов класса, поэтому реализующий класс может переопределить любой. Kotlin идёт дальше: интерфейс может объявлять абстрактные свойства с аксессорами, тогда как интерфейс Java 8 хранит лишь константы.
Типичные ошибки
- ✗Искать ключевое слово
default, которого в Kotlin нет - ✗Считать, что члены интерфейса
finalпо умолчанию, как члены класса - ✗Думать, что интерфейс Kotlin не может объявить свойство, раз этого не может Java 8
Уточняющие вопросы
- →Как класс разрешает один и тот же метод с телом, унаследованный от двух интерфейсов?
- →Почему у свойства в интерфейсе никогда не может быть backing field?
SeniorДизайнИногдаВ pull request один из ваших ключевых доменных классов помечают как open и добавляют open каждому его члену, включая два свойства с собственными getter, — чтобы юнит-тест мог унаследоваться и подменить пару методов. Класс используется из нескольких модулей, а его первичный конструктор проверяет инвариант в блоке init. Проведите ревью: объясните, к чему на самом деле обязывает open на классе и его членах, почему Kotlin вообще делает final значением по умолчанию и что вы предложите вместо этого, чтобы тест всё же смог изолировать нужного ему соседа.
В pull request один из ваших ключевых доменных классов помечают как open и добавляют open каждому его члену, включая два свойства с собственными getter, — чтобы юнит-тест мог унаследоваться и подменить пару методов. Класс используется из нескольких модулей, а его первичный конструктор проверяет инвариант в блоке init. Проведите ревью: объясните, к чему на самом деле обязывает open на классе и его членах, почему Kotlin вообще делает final значением по умолчанию и что вы предложите вместо этого, чтобы тест всё же смог изолировать нужного ему соседа.
open — это постоянный публичный контракт: любой подкласс может подменить эти члены, поэтому инвариант из init и оба собственных getter молча переопределяемы, а класс обязан навсегда остаться спроектированным под наследование во всех модулях. Именно поэтому Kotlin делает члены final по умолчанию. Вместо этого вынесите соседа за интерфейс и внедряйте его, чтобы тест подставил fake, не открывая продакшен-код.
Типичные ошибки
- ✗Считать
openлокальным обратимым удобством, а не публичным контрактом наследования - ✗Открывать продакшен-код ради тестируемости вместо внедрения соседа за интерфейсом
- ✗Считать, что переопределённый собственный getter не может сломать инвариант из
init
Уточняющие вопросы
- →Какая библиотека даст тесту подменить
final-класс, не открывая его, и какой ценой? - →Как оставить класс
final, но всё же разрешить переопределять одну назначенную точку расширения?