Перейти к содержанию
Мобильная разработка

11 вопросов по теме «Android: Kotlin и типы» на собеседовании

В этом материале — 11 вопросов из русской колоды RecallDeck по теме «Android: Kotlin и типы». Сначала сформулируйте короткий ответ сами, затем откройте подробный разбор и проверьте примеры, ограничения и отказные случаи.

11 мин чтения11 подробных ответовПроверено 24 августа 2026
Главная мысль

Привязывайте платформенное решение к lifecycle, состоянию UI, обратной связи, доступности и цене поддержки для команды.

Вопросы и ответы

11 подробных ответов

01

Чем val отличается от var и делает ли val объект неизменяемым?

Короткий ответ: 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
  1. val ≠ immutable — гарантирует лишь стабильность ссылки; изменяемость зависит от типа (List vs MutableList).
  2. val с кастомным getter — вычисляется при каждом обращении, значение может меняться от вызова к вызову.
  3. const val — подставляется компилятором в место использования; допустим только на top-level или в object/companion, значение — примитив или String.

⚠️ Частая ошибка: считать val синонимом «immutable». val про ссылку, а не про глубину объекта.

02

Что генерирует data class и какие у него подводные камни?

Короткий ответ: Компилятор генерирует 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
  1. Генерируется по конструктору — только val/var в первичном конструкторе; поля тела молча выпадают из equals.
  2. copy() — поверхностная копия: вложенные объекты общие. copy() также игнорирует поля тела.
  3. Массивыequals/hashCode берут ссылку, а не содержимое: переопределяйте вручную через contentEquals.
  4. Наследование — data class не может быть open и наследоваться от другого data class; equals по контракту сломался бы.

⚠️ Частая ошибка: класть изменяемое состояние в тело data class и удивляться, что два «разных» объекта равны, а в HashSet схлопываются в один.

03

Операторы ?., ?:, !! и платформенные типы из Java — как устроена null-безопасность?

Короткий ответ: ?. — безопасный вызов (вернёт 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 только при железной гарантии
  1. Платформенные типы (T!) — Kotlin не знает nullability из Java, поэтому доверяет вам; аннотируйте Java-код @Nullable/@NonNull.
  2. Элвис + return/throw — идиома val x = maybe() ?: return для раннего выхода.

⚠️ Частая ошибка: сыпать !!, чтобы «заткнуть компилятор». Каждый !! — это отложенный NPE; чаще подходит ?. с ?:.

04

Когда выбрать sealed class/interface вместо enum?

Короткий ответ: 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 да да (при известных ветках)
  1. enum — когда варианты взаимозаменяемы по форме: дни недели, статусы без нагрузки.
  2. sealed — моделирование состояний экрана/результата, где веткам нужны разные данные.

⚠️ Частая ошибка: тащить в enum разнородные данные через nullable-поля «на все случаи». Это ровно тот случай, где просится sealed.

05

let, run, with, apply, also — как выбрать нужную scope-функцию?

Короткий ответ: Выбор по двум осям: как обращаться к объекту (получатель 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
  1. it vs thisit удобнее, когда объект передают дальше как аргумент; this — когда дёргают его члены.
  2. вернуть объект vs результатapply/also встраиваются в цепочку, не разрывая её; let/run меняют тип.

⚠️ Частая ошибка: вложенные let c переопределённым it — теряется, к какому объекту относится it. Дайте лямбде явное имя параметра.

06

Чем lateinit отличается от by lazy и когда что применять?

Короткий ответ: lateinit var — изменяемое свойство, которое вы инициализируете позже вручную (DI, onCreate); до присвоения обращение бросает UninitializedPropertyAccessException. by lazyval, вычисляемый один раз при первом обращении и потом кэшируемый. 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() } // без синхронизации
  1. lateinit — для var, который жизненный цикл или DI заполнит гарантированно раньше чтения; проверка через isInitialized.
  2. lazy — для дорогого val, который может не понадобиться; по умолчанию SYNCHRONIZED (потокобезопасно).

⚠️ Частая ошибка: lateinit на Int/Boolean — не компилируется, примитивы не поддерживаются. И обращение к lateinit до инициализации — не null, а исключение.

07

Как разрешаются вызовы extension-функций и почему это не полиморфизм?

Короткий ответ: 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"; }
  1. Статическое разрешение — какая extension вызовется, решает объявленный (статический) тип выражения, а не рантайм-класс.
  2. Не переопределяет member — если у класса есть метод с той же сигнатурой, всегда побеждает член класса, а extension игнорируется.
  3. Под капотом — обычный static-метод с receiver-параметром; поэтому нет доступа к private-членам и нет виртуальности.

⚠️ Частая ошибка: ждать от extension-функций полиморфизма — «перекрою поведение для подкласса». Для рантайм-диспатча нужны обычные open/override методы.

08

Чем == отличается от === и почему === на Int? иногда врёт?

Короткий ответ: == — структурное равенство, компилируется в 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"    // зависит от интернирования — не полагайтесь
  1. == → equalsa == b это a?.equals(b) ?: (b === null); для своих типов переопределяйте equals/hashCode в паре.
  2. === → идентичность — один и тот же объект; для примитивов (не boxed) вырождается в сравнение значений.
  3. Контракт equals/hashCode — равные объекты обязаны иметь равный hashCode, иначе HashMap/HashSet ломаются.

⚠️ Частая ошибка: проверять Int?/Integer через ===. Из-за кэша −128..127 результат зависит от значения — используйте ==.

09

Вариативность в generics: что означают out, in и звёздная проекция?

Короткий ответ: Дженерики в 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-границу, писать нельзя
  1. Declaration-siteout/in на объявлении типа действуют для всех использований (в отличие от Java wildcards на месте использования).
  2. Звёздная проекция <*> — «какой-то конкретный, но неизвестный тип»: безопасно читать как верхнюю границу, писать запрещено.

⚠️ Частая ошибка: пытаться поставить out-параметр в позицию входа (аргумент функции) — компилятор запретит: это сломало бы безопасность типов.

10

Что делает reified и почему он работает только у inline-функций?

Короткий ответ: На 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))
  1. Type erasure — обычный <T> в рантайме неотличим от Object; проверки типа и рефлексия по T недоступны.
  2. inline даёт тело в точке вызова — там конкретный аргумент типа известен компилятору, и он физически подставляет User.class.
  3. Что можно с reifiedT::class, x is T, x as T, вызвать другую reified-функцию.

⚠️ Частая ошибка: ждать reified от обычной (не inline) функции. Тело не копируется — тип восстановить неоткуда, компилятор откажет.

11

Зачем нужны inline, noinline и crossinline у функций высшего порядка?

Короткий ответ: Каждая лямбда без 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 лямбду зовут из вложенного контекста
  1. Выигрыш inline — нет объекта лямбды и лишнего вызова; поддерживается return из inline-лямбды наружу (non-local).
  2. Цена — тело копируется в каждый вызов: инлайнить крупные функции — раздувание байткода. Для мелких HOF — оправдано.
  3. noinline/crossinline — точечные оговорки к общему inline, а не самостоятельные режимы.

⚠️ Частая ошибка: лепить inline на всё подряд «ради скорости». Для функций без лямбда-параметров выигрыша почти нет, а код пухнет — компилятор даже выдаёт предупреждение.

Источники

Источники и редакционная политика

Материалы RecallDeck сопоставлены с официальной документацией и открытыми публикациями компаний, когда первичный источник доступен. Мы не связаны с упомянутыми работодателями, не публикуем конфиденциальные задания и не продаём места в подборках. Формат найма может меняться — уточняйте его у рекрутера.

От чтения к воспроизведению

Отрепетируйте полный цикл интервью.

RecallDeck возвращает сложные темы по расписанию и помогает удерживать в памяти язык, SQL, архитектуру и поведенческие истории.

Начать подготовку

Продолжить подготовку

Мобильная разработка8 мин

10 вопросов по теме «Android: Компоненты и жизненный цикл» на собеседовании

10 вопросов по теме «Android: Компоненты и жизненный цикл» с подробными ответами, примерами, компромиссами и частыми ошибками кандидатов на техническом интервью.

10 подробных ответов
Библиотека собеседований RecallDeck

Подробные русские ответы, разборы этапов найма и планы подготовки для российского IT-рынка.

RSS