data class, sealed class и scope-функции

Три конструкции, которые в Android-коде встречаются на каждом экране — и которые почти всегда спрашивают подряд.

sealed class — класс с закрытой иерархией: все наследники известны компилятору на этапе компиляции. Именно поэтому when по нему может быть исчерпывающим без ветки else.

Вопрос 1: «Что генерирует data class и какие у него подводные камни?»

Что проверяет интервьюер

Знаете ли вы, что генерация идёт только по свойствам первичного конструктора. На этом ломается больше кандидатов, чем можно ожидать.

Развёрнутый ответ

Компилятор генерирует equals(), hashCode(), toString(), copy() и componentN() для деструктуризации. Ключевая деталь — в расчёт берутся только свойства, объявленные в первичном конструкторе:

data class User(val id: Long, val name: String) {
    var lastSeen: Long = 0   // объявлено в теле — НЕ участвует в equals/hashCode/copy
}

fun main() {
    val a = User(1, "Аня").apply { lastSeen = 100 }
    val b = User(1, "Аня").apply { lastSeen = 999 }
    println(a == b)          // true — lastSeen игнорируется
    println(a.copy().lastSeen) // 0 — copy не переносит поля из тела класса
}

Это регулярно всплывает на практике: два состояния экрана «равны», DiffUtil решает, что элемент не изменился, и список не обновляется.

Ещё несколько фактов, которые ценят на собеседовании:

  • data class не может быть abstract, open, sealed или inner.
  • Первичный конструктор обязан иметь хотя бы один параметр, и все параметры должны быть val или var.
  • copy() обходит приватный конструктор: сделать data class с контролируемым созданием не выйдет.
  • Наследование data class от обычного класса разрешено, но если родитель определяет equals, генерация в наследнике всё равно переопределит его.

Вопрос 2: «Чем sealed class отличается от enum и от абстрактного класса?»

Что проверяет интервьюер

Понимаете ли, что sealed решает конкретную задачу — моделирование состояния с данными и исчерпывающая проверка вариантов.

Развёрнутый ответ

enum — это фиксированный набор экземпляров. Все константы одинакового типа и не могут нести разные наборы полей. sealed class — фиксированный набор подтипов: каждый наследник имеет собственные свойства, и наследников может быть сколько угодно экземпляров.

sealed interface UiState {
    data object Loading : UiState
    data class Success(val items: List<Item>, val fromCache: Boolean) : UiState
    data class Error(val message: String, val retryable: Boolean) : UiState
}

fun render(state: UiState) = when (state) {
    UiState.Loading -> showProgress()
    is UiState.Success -> showItems(state.items)
    is UiState.Error -> showError(state.message)
    // else не нужен: компилятор знает все варианты
}

Главная практическая выгода — исчерпывающий when. Добавили новый вариант состояния — компилятор подсветит все места, где его не обработали. Если написать else, эта выгода пропадает: новый вариант молча уедет в общую ветку. Это одна из причин, почему ветку else в таком when стараются не писать.

От абстрактного класса sealed отличается закрытостью иерархии: наследники должны находиться в том же модуле и пакете. Обычный abstract class может расширить кто угодно, поэтому компилятор не может доказать полноту when.

sealed interface (доступен с Kotlin 1.5) гибче: тип может реализовать несколько sealed-интерфейсов сразу. Для вариантов без данных используйте data object — он даёт нормальный toString() вместо адреса объекта.

Вопрос 3: «Чем различаются let, run, with, apply и also?»

Что проверяет интервьюер

Два измерения: как доступен объект внутри блока (this или it) и что функция возвращает (результат лямбды или сам объект). Всё остальное — стилистика.

ФункцияОбъект внутриВозвращаетТипичный случай
letitрезультат лямбдыработа с nullable через ?.let, преобразование значения
runthisрезультат лямбдывычислить значение, обращаясь к членам объекта
withthisрезультат лямбдыто же, но объект передаётся аргументом, а не через точку
applythisсам объектнастройка объекта при создании
alsoitсам объектпобочное действие в цепочке: логирование, проверка
// apply — настройка и возврат объекта
val intent = Intent(context, DetailsActivity::class.java).apply {
    putExtra("id", itemId)
    flags = Intent.FLAG_ACTIVITY_CLEAR_TOP
}

// let — безопасная работа с nullable и преобразование
val length = user?.name?.let { it.trim().length } ?: 0

// also — побочный эффект без разрыва цепочки
val items = repository.load()
    .also { Log.d("TAG", "loaded ${it.size} items") }
    .filter { it.isVisible }

// with — сгруппировать обращения к одному объекту
with(binding) {
    title.text = state.title
    subtitle.text = state.subtitle
    progress.isVisible = state.loading
}

Типичные ошибки кандидатов

  • «apply и also — одно и то же». Оба возвращают получателя, но apply даёт this, а alsoit. Это меняет читаемость и разрешает вложенность без затенения.
  • Используют let для проверки на null там, где хватило бы Elvis: value?.let { it } ?: default — избыточно.
  • Вкладывают scope-функции в три слоя. Тогда it и this перестают читаться, и код становится хуже, чем без них.
  • Считают, что ?.let { } — «атомарная» проверка. Для var-свойства она безопасна тем, что делает локальную копию, но потокобезопасности не даёт.

Как ответить кратко (20–30 секунд)

«data class генерирует equals, hashCode, toString, copy и componentN, но только по свойствам первичного конструктора — поля в теле класса в сравнении не участвуют, и это регулярно ломает DiffUtil. sealed class задаёт закрытую иерархию: наследники известны компилятору, поэтому when исчерпывающий без else, и при добавлении нового состояния компилятор покажет все необработанные места. От enum отличается тем, что каждый вариант может нести свои данные. Scope-функции различаю по двум признакам: let и also дают it, run, with и applythis; apply и also возвращают сам объект, остальные — результат лямбды.»

Проверьте себя
1. У data class User(val id: Long) есть свойство var cached: Boolean, объявленное в теле класса. Как оно повлияет на equals()?
Aбудет участвовать в сравнении наравне с id
Bне будет участвовать: генерация идёт только по свойствам первичного конструктора
Cвызовет ошибку компиляции — data class запрещает свойства в теле
Dбудет участвовать, только если помечено как val
2. Зачем в when по sealed class стараются не писать ветку else?
Aelse запрещён компилятором для sealed-типов
Belse замедляет выполнение when примерно вдвое
Cс else when перестаёт быть исчерпывающим, и новый подтип молча уйдёт в общую ветку вместо ошибки компиляции
Delse мешает работе smart cast внутри ветвей
3. Какая пара scope-функций возвращает сам объект-получатель, а не результат лямбды?
Alet и run
Brun и with
Capply и also
Dwith и also