Привязывайте платформенное решение к lifecycle, состоянию UI, обратной связи, доступности и цене поддержки для команды.
Вопросы и ответы
11 подробных ответов
01Чем val отличается от var и делает ли val объект неизменяемым?
junior
Короткий ответ: var — перезаписываемая ссылка, val — read-only: присвоить повторно нельзя. Но val замораживает только ссылку, а не содержимое объекта: val list = mutableListOf<Int>() спокойно принимает list.add(1). Настоящая неизменяемость — это read-only тип плюс val.
Подробно:
val a = mutableListOf(1, 2)
a.add(3) // ✅ ссылка та же, содержимое меняется
// a = mutableListOf() // ❌ val нельзя переприсвоить
var n = 0
n = 1 // ✅ var переприсваивается
val now: Long get() = System.currentTimeMillis() // val с getter — не константа!
const val API = "v1" // const — время компиляции, только примитивы и String
- val ≠ immutable — гарантирует лишь стабильность ссылки; изменяемость зависит от типа (
ListvsMutableList). - val с кастомным getter — вычисляется при каждом обращении, значение может меняться от вызова к вызову.
- const val — подставляется компилятором в место использования; допустим только на top-level или в
object/companion, значение — примитив или String.
⚠️ Частая ошибка: считать val синонимом «immutable». val про ссылку, а не про глубину объекта.
02Что генерирует data class и какие у него подводные камни?
junior
Короткий ответ: Компилятор генерирует equals()/hashCode(), toString(), copy() и componentN() — но только по свойствам из первичного конструктора. Свойства из тела класса в эти методы не попадают, а массивы сравниваются по ссылке.
Подробно:
data class User(val id: Int, val name: String) {
var lastSeen: Long = 0 // ⚠️ НЕ участвует в equals/hashCode/toString/copy
}
val a = User(1, "Ann").apply { lastSeen = 100 }
val b = User(1, "Ann").apply { lastSeen = 999 }
a == b // true! lastSeen игнорируется
data class Packet(val bytes: ByteArray) // ⚠️ equals по ссылке массива,
// нужен ручной contentEquals
- Генерируется по конструктору — только
val/varв первичном конструкторе; поля тела молча выпадают изequals. - copy() — поверхностная копия: вложенные объекты общие.
copy()также игнорирует поля тела. - Массивы —
equals/hashCodeберут ссылку, а не содержимое: переопределяйте вручную черезcontentEquals. - Наследование — data class не может быть
openи наследоваться от другого data class;equalsпо контракту сломался бы.
⚠️ Частая ошибка: класть изменяемое состояние в тело data class и удивляться, что два «разных» объекта равны, а в HashSet схлопываются в один.
03Операторы ?., ?:, !! и платформенные типы из Java — как устроена null-безопасность?
junior
Короткий ответ: ?. — безопасный вызов (вернёт null вместо NPE), ?: (Элвис) — значение по умолчанию, !! — «я гарантирую не-null» и бросает NPE, если ошибся. Дыра в системе — платформенные типы: значения из Java (String!) компилятор не проверяет на null.
Подробно:
val len: Int? = user?.name?.length // цепочка оборвётся на первом null
val safe: Int = user?.name?.length ?: 0 // Элвис даёт дефолт
val forced: Int = user!!.name!!.length // ⚠️ два потенциальных NPE
// Java-метод: String getName() -> в Kotlin это String! (платформенный тип)
val n = javaObj.name // тип String! — проверок нет
val ok: String = javaObj.name // NPE прямо здесь, если вернулся null
| Оператор | Если null | Когда |
|---|---|---|
?. |
вернёт null | безопасная навигация |
?: |
подставит правую часть | дефолт/ранний выход |
!! |
бросит NPE | только при железной гарантии |
- Платформенные типы (
T!) — Kotlin не знает nullability из Java, поэтому доверяет вам; аннотируйте Java-код@Nullable/@NonNull. - Элвис + return/throw — идиома
val x = maybe() ?: returnдля раннего выхода.
⚠️ Частая ошибка: сыпать !!, чтобы «заткнуть компилятор». Каждый !! — это отложенный NPE; чаще подходит ?. с ?:.
04Когда выбрать sealed class/interface вместо enum?
middle
Короткий ответ: enum — фиксированный набор одинаковых по форме констант-синглтонов. sealed — закрытая иерархия, где каждый наследник несёт свои данные и свою структуру. Оба дают исчерпывающий when без else, но enum — один экземпляр на константу, а sealed-наследников можно создавать сколько угодно с разными полями.
Подробно:
enum class Status { LOADING, SUCCESS, ERROR } // константы без данных
sealed interface UiState {
data object Loading : UiState
data class Success(val items: List<Item>) : UiState // несёт данные
data class Error(val cause: Throwable) : UiState // свои поля
}
fun render(s: UiState) = when (s) { // else не нужен — компилятор знает всех
UiState.Loading -> showSpinner()
is UiState.Success -> showList(s.items)
is UiState.Error -> showError(s.cause)
}
| enum | sealed | |
|---|---|---|
| Данные у вариантов | одинаковые поля | у каждого свои |
| Число экземпляров | один на константу | сколько угодно (кроме object) |
| Иерархия | плоская | дерево любой глубины |
| when без else | да | да (при известных ветках) |
- enum — когда варианты взаимозаменяемы по форме: дни недели, статусы без нагрузки.
- sealed — моделирование состояний экрана/результата, где веткам нужны разные данные.
⚠️ Частая ошибка: тащить в enum разнородные данные через nullable-поля «на все случаи». Это ровно тот случай, где просится sealed.
05let, run, with, apply, also — как выбрать нужную scope-функцию?
middle
Короткий ответ: Выбор по двум осям: как обращаться к объекту (получатель this или аргумент it) и что возвращается (сам объект или результат лямбды). apply/also возвращают объект — для настройки; let/run/with возвращают результат лямбды — для трансформации.
Подробно:
| Функция | Объект как | Возвращает | Типичное применение |
|---|---|---|---|
let |
it |
результат лямбды | null-safe трансформация x?.let { } |
run |
this |
результат лямбды | вычисление + конфиг с доступом к членам |
with |
this |
результат лямбды | группа вызовов над одним объектом |
apply |
this |
сам объект | настройка: View().apply { } |
also |
it |
сам объект | побочный эффект: лог, валидация |
val name = user?.let { it.first + " " + it.last } // трансформация под null-check
val view = TextView(ctx).apply { // конфиг, вернёт view
text = "Hi"; textSize = 16f
}
repo.save(item).also { log("saved id=${it.id}") } // сквозной side-effect
- it vs this —
itудобнее, когда объект передают дальше как аргумент;this— когда дёргают его члены. - вернуть объект vs результат —
apply/alsoвстраиваются в цепочку, не разрывая её;let/runменяют тип.
⚠️ Частая ошибка: вложенные let c переопределённым it — теряется, к какому объекту относится it. Дайте лямбде явное имя параметра.
06Чем lateinit отличается от by lazy и когда что применять?
middle
Короткий ответ: lateinit var — изменяемое свойство, которое вы инициализируете позже вручную (DI, onCreate); до присвоения обращение бросает UninitializedPropertyAccessException. by lazy — val, вычисляемый один раз при первом обращении и потом кэшируемый. lateinit — «проинициализирую сам», lazy — «посчитается само при первом чтении».
Подробно:
lateinit var |
by lazy |
|
|---|---|---|
| Мутабельность | var | val |
| Кто инициализирует | вы, вручную | блок при первом доступе |
| Примитивы | нельзя (только объекты) | можно |
| Nullable | нельзя | можно |
| Проверка | ::x.isInitialized |
всегда инициализирован после 1-го чтения |
@Inject lateinit var repo: Repo // присвоит DI до использования
val db by lazy { // тяжёлый объект — по требованию
Room.databaseBuilder(ctx, Db::class.java, "app").build()
}
// потокобезопасность lazy настраивается:
val cfg by lazy(LazyThreadSafetyMode.NONE) { parse() } // без синхронизации
- lateinit — для var, который жизненный цикл или DI заполнит гарантированно раньше чтения; проверка через
isInitialized. - lazy — для дорогого
val, который может не понадобиться; по умолчаниюSYNCHRONIZED(потокобезопасно).
⚠️ Частая ошибка: lateinit на Int/Boolean — не компилируется, примитивы не поддерживаются. И обращение к lateinit до инициализации — не null, а исключение.
07Как разрешаются вызовы extension-функций и почему это не полиморфизм?
middle
Короткий ответ: Extension-функции разрешаются статически — по объявленному типу переменной, а не по фактическому типу объекта в рантайме. Компилятор превращает их в статические методы, куда получатель передаётся первым аргументом; никакого виртуального диспатча, как у обычных методов, нет.
Подробно:
open class A
class B : A()
fun A.who() = "A"
fun B.who() = "B"
val x: A = B()
println(x.who()) // "A" — выбор по типу переменной (A), не по B!
// компилятор фактически генерирует:
// static String who(A receiver) { return "A"; }
- Статическое разрешение — какая extension вызовется, решает объявленный (статический) тип выражения, а не рантайм-класс.
- Не переопределяет member — если у класса есть метод с той же сигнатурой, всегда побеждает член класса, а extension игнорируется.
- Под капотом — обычный static-метод с receiver-параметром; поэтому нет доступа к
private-членам и нет виртуальности.
⚠️ Частая ошибка: ждать от extension-функций полиморфизма — «перекрою поведение для подкласса». Для рантайм-диспатча нужны обычные open/override методы.
08Чем == отличается от === и почему === на Int? иногда врёт?
middle
Короткий ответ: == — структурное равенство, компилируется в null-безопасный вызов equals(). === — ссылочное равенство (тот же объект в памяти). На боксированных числах === даёт коварный результат: JVM кэширует Integer в диапазоне −128..127, поэтому одинаковые Int? со значением 100 могут быть ===, а со значением 1000 — нет.
Подробно:
val a: Int? = 127
val b: Int? = 127
a == b // true — сравнение значений
a === b // true — оба берутся из кэша Integer (−128..127)
val c: Int? = 1000
val d: Int? = 1000
c == d // true
c === d // false! за пределами кэша — разные объекты
"ab" == "ab" // true (equals)
"ab" === "ab" // зависит от интернирования — не полагайтесь
- == → equals —
a == bэтоa?.equals(b) ?: (b === null); для своих типов переопределяйтеequals/hashCodeв паре. - === → идентичность — один и тот же объект; для примитивов (не boxed) вырождается в сравнение значений.
- Контракт equals/hashCode — равные объекты обязаны иметь равный
hashCode, иначеHashMap/HashSetломаются.
⚠️ Частая ошибка: проверять Int?/Integer через ===. Из-за кэша −128..127 результат зависит от значения — используйте ==.
09Вариативность в generics: что означают out, in и звёздная проекция?
senior
Короткий ответ: Дженерики в Kotlin по умолчанию инвариантны: List<String> — не подтип List<Any>. out T (ковариантность) — тип только «производит» T (позиция возврата), тогда Producer<String> — подтип Producer<Any>. in T (контравариантность) — тип только «потребляет» T (позиция аргумента). Мнемоника — PECS: Producer-out, Consumer-in.
Подробно:
interface Producer<out T> { fun get(): T } // T только на выходе
interface Consumer<in T> { fun put(item: T) } // T только на входе
val strs: Producer<String> = ...
val anys: Producer<Any> = strs // ✅ ковариантно: String -> Any
val anyC: Consumer<Any> = ...
val strC: Consumer<String> = anyC // ✅ контравариантно: Any -> String
fun printAll(items: List<*>) { // звёздная проекция: тип неизвестен
items.forEach(::println) // читаем как Any?, писать нельзя
}
| Модификатор | Роль T | Отношение подтипов |
|---|---|---|
out T |
производитель (выход) | C<Sub> <: C<Super> |
in T |
потребитель (вход) | C<Super> <: C<Sub> |
<*> |
неизвестен | читаем out-границу, писать нельзя |
- Declaration-site —
out/inна объявлении типа действуют для всех использований (в отличие от Java wildcards на месте использования). - Звёздная проекция
<*>— «какой-то конкретный, но неизвестный тип»: безопасно читать как верхнюю границу, писать запрещено.
⚠️ Частая ошибка: пытаться поставить out-параметр в позицию входа (аргумент функции) — компилятор запретит: это сломало бы безопасность типов.
10Что делает reified и почему он работает только у inline-функций?
senior
Короткий ответ: На JVM дженерики стираются (type erasure): в рантайме T превращается в Object, поэтому нельзя написать T::class или x is T. inline-функция копирует своё тело в место вызова, где конкретный тип известен на этапе компиляции; reified разрешает подставить этот тип прямо в тело. Без inline подставлять некуда — отсюда ограничение.
Подробно:
// ❌ так нельзя: T стёрт, is T невозможен
fun <T> parseBad(json: String): T = gson.fromJson(json, T::class.java)
// ✅ reified: тип известен на месте вызова
inline fun <reified T> parse(json: String): T =
gson.fromJson(json, T::class.java)
val user = parse<User>(json) // компилятор вставляет User.class сюда
// частый android-хелпер:
inline fun <reified T> Context.startActivity() =
startActivity(Intent(this, T::class.java))
- Type erasure — обычный
<T>в рантайме неотличим отObject; проверки типа и рефлексия поTнедоступны. - inline даёт тело в точке вызова — там конкретный аргумент типа известен компилятору, и он физически подставляет
User.class. - Что можно с reified —
T::class,x is T,x as T, вызвать другую reified-функцию.
⚠️ Частая ошибка: ждать reified от обычной (не inline) функции. Тело не копируется — тип восстановить неоткуда, компилятор откажет.
11Зачем нужны inline, noinline и crossinline у функций высшего порядка?
senior
Короткий ответ: Каждая лямбда без inline — это аллокация объекта Function. inline встраивает тело функции и её лямбд в место вызова, убирая аллокацию и позволяя non-local return. noinline — исключить конкретную лямбду из встраивания (чтобы её можно было сохранить/передать дальше). crossinline — запретить в лямбде non-local return, когда её вызывают из другого контекста.
Подробно:
inline fun measure(block: () -> Unit) { // block встроится, объекта лямбды нет
val t = System.nanoTime(); block(); log(System.nanoTime() - t)
}
inline fun run2(
a: () -> Unit,
noinline b: () -> Unit // b сохраняем в поле -> её встраивать нельзя
) { a(); store(b) }
inline fun forEachSafe(block: crossinline () -> Unit) {
val r = Runnable { block() } // block зовётся из другого контекста ->
r.run() // crossinline запрещает здесь return из вызывающего
}
| Модификатор | Что делает | Когда |
|---|---|---|
inline |
встроить функцию и лямбды | горячий HOF, убрать аллокации |
noinline |
НЕ встраивать эту лямбду | нужно сохранить/передать лямбду |
crossinline |
запретить non-local return | лямбду зовут из вложенного контекста |
- Выигрыш inline — нет объекта лямбды и лишнего вызова; поддерживается
returnиз inline-лямбды наружу (non-local). - Цена — тело копируется в каждый вызов: инлайнить крупные функции — раздувание байткода. Для мелких HOF — оправдано.
- noinline/crossinline — точечные оговорки к общему
inline, а не самостоятельные режимы.
⚠️ Частая ошибка: лепить inline на всё подряд «ради скорости». Для функций без лямбда-параметров выигрыша почти нет, а код пухнет — компилятор даже выдаёт предупреждение.
Источники
Источники и редакционная политика
Материалы RecallDeck сопоставлены с официальной документацией и открытыми публикациями компаний, когда первичный источник доступен. Мы не связаны с упомянутыми работодателями, не публикуем конфиденциальные задания и не продаём места в подборках. Формат найма может меняться — уточняйте его у рекрутера.
От чтения к воспроизведению
Отрепетируйте полный цикл интервью.
RecallDeck возвращает сложные темы по расписанию и помогает удерживать в памяти язык, SQL, архитектуру и поведенческие истории.