Sealed-иерархии и объекты
Обе половины этой темы про одно и то же — про знание, которое обычный класс компилятору не сообщает. sealed фиксирует набор подтипов: прямые наследники обязаны лежать в том же пакете и том же модуле компиляции, поэтому на этапе компиляции известен полный список случаев. Отсюда единственная реальная выгода sealed — исчерпывающий when: ветки проверяются на полноту, и добавление нового подтипа превращается из молчаливой ошибки рантайма в ошибку сборки. object, в свою очередь, фиксирует число экземпляров: объявление компилируется в класс с приватным конструктором и статическим полем INSTANCE, которое присваивается в статическом инициализаторе, — а его JVM запускает ровно один раз, лениво, под блокировкой инициализации класса.
Ловушки темы растут ровно из непонимания этих двух механизмов. else в when по sealed-типу — это обещание компилятору, что остальные случаи обработаны одинаково; пока оно есть, проверки на полноту нет вовсе, и новый подтип тихо получает чужое поведение. abstract тоже нельзя инстанцировать — но набор его наследников открыт, и никакой исчерпывающести он не даёт. companion object — не статика: это настоящий объект в статическом поле Companion, поэтому из Java вызов выглядит как Foo.Companion.bar(), зато companion умеет реализовывать интерфейсы. А анонимный object : Listener { … } — выражение: каждое его вычисление создаёт новый экземпляр и захватывает окружение вместе с внешним this. Разбор по слоям — ниже.
Карта темы
- Sealed-иерархии — закрытый набор подтипов, где именно разрешено объявлять наследников, чем sealed interface отличается от sealed class и почему это не enum и не abstract.
- Исчерпывающий when — когда компилятор обязан проверить полноту веток, почему
elseотключает эту проверку и как умные приведения работают внутри ветки. - object-объявление как синглтон — приватный конструктор, статическое поле
INSTANCEи гарантии инициализации класса, из-за которых double-checked locking в Kotlin бессмыслен. - companion object — один объект на класс, статическое поле
Companion, что видит Java без@JvmStaticи почему companion может реализовать интерфейс. - Анонимные объекты — выражение, дающее новый экземпляр при каждом вычислении, несколько супертипов сразу, захват области видимости и утечка через внешний
this.
Частые ошибки и ловушки
| Ошибка | Последствие |
|---|---|
Считать, что sealed означает лишь «класс нельзя создать» | Это умеет и abstract; смысл sealed — закрытый набор подтипов, известный компилятору |
| Объявить прямого наследника sealed-типа в другом модуле | Не компилируется: прямые наследники обязаны лежать в том же пакете и том же модуле компиляции |
Ждать, что when по sealed-типу всё равно требует else | Не требует — и наоборот, else отключает проверку на полноту |
Дописать else, чтобы «успокоить компилятор» | Вы отказались от исчерпывающести: новый подтип молча уйдёт в else уже в рантайме |
| Считать, что раз код компилируется, новый подтип не мог проскочить | При наличии else компилятор полноту не проверяет вообще — зелёная сборка ничего не гарантирует |
Думать, что sealed interface открыт для любого реализатора | Набор реализаций закрыт так же: тот же пакет, тот же модуль |
| Ждать, что подтип унаследует sealed-класс и другой класс | Наследование реализации одиночное — ровно для этого и существует sealed interface |
Ждать состояния в конструкторе sealed interface | Интерфейс не хранит состояние: общее состояние подтипов — повод взять sealed class |
Искать в Kotlin permits или non-sealed | Их нет: набор выводится из пакета и модуля; permits — требование Java 17, а «люка» non-sealed в Kotlin нет вовсе |
Считать object-объявление просто пространством имён для функций | Это полноценный экземпляр: у него есть состояние, он может реализовать интерфейс и быть передан как значение |
Добавлять @Volatile и synchronized, чтобы «сделать object потокобезопасным» | Лишняя работа: INSTANCE присваивается в статическом инициализаторе, а его JVM исполняет ровно один раз |
Ждать, что object создаётся при старте процесса | Инициализация ленивая — при первом обращении к классу |
Держать в object ссылку на Context или Activity | Утечка на всё время жизни процесса: у синглтона нет момента, когда его освободят |
Называть companion object статикой класса | Это объект в статическом поле Companion; из Java без @JvmStatic вызов идёт как Foo.Companion.bar() |
Объявить два companion object в одном классе | Не компилируется: companion допустим ровно один (именованный или нет) |
Ждать, что анонимный object вернёт тот же экземпляр при каждом вычислении | Это выражение: каждый раз — новый объект; внутри цикла вы создадите их столько же, сколько итераций |
| Думать, что анонимный объект реализует только один интерфейс | Он может реализовать несколько сразу (и ещё унаследовать класс) — именно этим он отличается от SAM-лямбды |
| Переносить на Kotlin правило Java про effectively final для захваченных переменных | Kotlin захватывает var и позволяет его изменять — вместе с ним захватывается и внешний this |
Значение для собеседований
Тему любят за то, что она мгновенно показывает, думает ли кандидат моделью исполнения или словарём. На вопрос «что даёт sealed?» слабый ответ — «нельзя наследоваться извне», сильный — «компилятор знает полный список подтипов, поэтому when проверяется на полноту, и новый случай ломает сборку у всех потребителей». Ровно так же со второй половиной: «object — это синглтон» — это пересказ слова, а «object компилируется в класс с приватным конструктором и статическим INSTANCE, присваиваемым в статическом инициализаторе, поэтому он ленивый и потокобезопасный без единой блокировки» — это ответ.
Что обычно проверяют:
- Чем
sealedотличается отabstractи отenum, и где обязаны лежать прямые наследники. - Когда брать
sealed interface, а когдаsealed class. - Что произойдёт с существующим
when, когда в иерархию добавят новый подтип — и что изменится, если в нём естьelse. - Во что компилируется
object-объявление и откуда берётся его потокобезопасность. - Почему double-checked locking в Kotlin не нужен и что он воспроизводит вручную.
- Чем
companion objectотличается от статических членов Java и что делает@JvmStatic. - Сколько экземпляров даёт анонимный
objectвнутри цикла и что он захватывает.
Типичный неверный ответ: «Добавил else — и when стал безопаснее: теперь ни один случай не останется без обработки». На деле всё наоборот. Без else компилятор обязан убедиться, что покрыты все подтипы, и добавление Pending в иерархию не соберётся, пока вы не обработаете его явно. С else проверка выключена: Pending молча получает ветку «остальные» и показывается пользователю как отказ. Второй классический провал — «companion object это то же самое, что static в Java». Из Java без @JvmStatic этот вызов пишется как Foo.Companion.bar(), а companion, в отличие от статики, — объект: он может реализовать интерфейс, быть передан в функцию и получить функцию-расширение.