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) и что функция возвращает (результат лямбды или сам объект). Всё остальное — стилистика.
| Функция | Объект внутри | Возвращает | Типичный случай |
let | it | результат лямбды | работа с nullable через ?.let, преобразование значения |
run | this | результат лямбды | вычислить значение, обращаясь к членам объекта |
with | this | результат лямбды | то же, но объект передаётся аргументом, а не через точку |
apply | this | сам объект | настройка объекта при создании |
also | it | сам объект | побочное действие в цепочке: логирование, проверка |
// 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, аalso—it. Это меняет читаемость и разрешает вложенность без затенения. - Используют
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иapply—this;applyиalsoвозвращают сам объект, остальные — результат лямбды.»