Tie implementation or design choices to platform behavior, user feedback, lifecycle, accessibility, and the trade-off the team must maintain.
Question set
11 detailed answers
01How does val differ from var, and does val make an object immutable?
junior
Short answer: var is a reassignable reference, val is read-only: you cannot reassign it. But val freezes only the reference, not the object's contents: val list = mutableListOf<Int>() still accepts list.add(1). Real immutability is a read-only type plus val.
In depth:
val a = mutableListOf(1, 2)
a.add(3) // ✅ same reference, contents change
// a = mutableListOf() // ❌ val cannot be reassigned
var n = 0
n = 1 // ✅ var is reassignable
val now: Long get() = System.currentTimeMillis() // val with a getter — not a constant!
const val API = "v1" // const — compile time, primitives and String only
- val ≠ immutable — it only guarantees a stable reference; mutability depends on the type (
ListvsMutableList). - val with a custom getter — is computed on every access, and its value can differ from call to call.
- const val — is inlined by the compiler at the use site; allowed only at top level or in an
object/companion, with a primitive or String value.
⚠️ Common mistake: treating val as a synonym for immutable. val is about the reference, not the depth of the object.
02What does a data class generate, and what are its pitfalls?
junior
Short answer: The compiler generates equals()/hashCode(), toString(), copy(), and componentN() — but only from the primary-constructor properties. Properties declared in the class body are excluded, and arrays are compared by reference.
In depth:
data class User(val id: Int, val name: String) {
var lastSeen: Long = 0 // ⚠️ NOT part of equals/hashCode/toString/copy
}
val a = User(1, "Ann").apply { lastSeen = 100 }
val b = User(1, "Ann").apply { lastSeen = 999 }
a == b // true! lastSeen is ignored
data class Packet(val bytes: ByteArray) // ⚠️ equals on the array reference,
// needs a manual contentEquals
- Generated from the constructor — only
val/varin the primary constructor; body fields silently drop out ofequals. - copy() — a shallow copy: nested objects are shared.
copy()also ignores body fields. - Arrays —
equals/hashCodeuse the reference, not the contents: override them manually withcontentEquals. - Inheritance — a data class cannot be
openor extend another data class; theequalscontract would break.
⚠️ Common mistake: putting mutable state in the data-class body and being surprised that two "different" objects are equal — and collapse into one inside a HashSet.
03Operators ?., ?:, !! and platform types from Java — how does null-safety work?
junior
Short answer: ?. is a safe call (returns null instead of an NPE), ?: (Elvis) supplies a default, !! asserts "I guarantee non-null" and throws an NPE if you're wrong. The hole in the system is platform types: values from Java (String!) are not null-checked by the compiler.
In depth:
val len: Int? = user?.name?.length // the chain short-circuits on the first null
val safe: Int = user?.name?.length ?: 0 // Elvis gives a default
val forced: Int = user!!.name!!.length // ⚠️ two potential NPEs
// Java method: String getName() -> in Kotlin this is String! (a platform type)
val n = javaObj.name // type String! — no checks
val ok: String = javaObj.name // NPE right here if it returned null
| Operator | If null | When |
|---|---|---|
?. |
returns null | safe navigation |
?: |
substitutes the right side | default / early exit |
!! |
throws an NPE | only with an ironclad guarantee |
- Platform types (
T!) — Kotlin doesn't know nullability from Java, so it trusts you; annotate Java code with@Nullable/@NonNull. - Elvis + return/throw — the idiom
val x = maybe() ?: returnfor an early exit.
⚠️ Common mistake: scattering !! to silence the compiler. Every !! is a deferred NPE; usually ?. with ?: is the right tool.
04When do you choose a sealed class/interface over an enum?
middle
Short answer: An enum is a fixed set of same-shaped singleton constants. A sealed type is a closed hierarchy where each subtype carries its own data and structure. Both give an exhaustive when with no else, but an enum is one instance per constant, while you can create as many sealed subtypes as you want with different fields.
In depth:
enum class Status { LOADING, SUCCESS, ERROR } // constants with no data
sealed interface UiState {
data object Loading : UiState
data class Success(val items: List<Item>) : UiState // carries data
data class Error(val cause: Throwable) : UiState // its own fields
}
fun render(s: UiState) = when (s) { // no else needed — the compiler knows them all
UiState.Loading -> showSpinner()
is UiState.Success -> showList(s.items)
is UiState.Error -> showError(s.cause)
}
| enum | sealed | |
|---|---|---|
| Per-variant data | same fields | each its own |
| Instance count | one per constant | as many as you like (except object) |
| Hierarchy | flat | a tree of any depth |
| when without else | yes | yes (when branches are known) |
- enum — when variants are interchangeable in shape: weekdays, statuses with no payload.
- sealed — modeling screen/result states where branches need different data.
⚠️ Common mistake: cramming heterogeneous data into an enum via just-in-case nullable fields. That is exactly where a sealed type belongs.
05let, run, with, apply, also — how do you pick the right scope function?
middle
Short answer: Choose along two axes: how you reference the object (receiver this or argument it) and what it returns (the object itself or the lambda result). apply/also return the object — for configuration; let/run/with return the lambda result — for transformation.
In depth:
| Function | Object as | Returns | Typical use |
|---|---|---|---|
let |
it |
lambda result | null-safe transform x?.let { } |
run |
this |
lambda result | compute + config with member access |
with |
this |
lambda result | a group of calls on one object |
apply |
this |
the object | setup: View().apply { } |
also |
it |
the object | side effect: logging, validation |
val name = user?.let { it.first + " " + it.last } // transform under a null-check
val view = TextView(ctx).apply { // config, returns the view
text = "Hi"; textSize = 16f
}
repo.save(item).also { log("saved id=${it.id}") } // pass-through side effect
- it vs this —
itreads better when the object is passed onward as an argument;thiswhen you call its members. - return object vs result —
apply/alsoslot into a chain without breaking it;let/runchange the type.
⚠️ Common mistake: nested lets with shadowed it — you lose track of which object it refers to. Give the lambda an explicit parameter name.
06How does lateinit differ from by lazy, and when do you use each?
middle
Short answer: lateinit var is a mutable property you initialize later by hand (DI, onCreate); accessing it before assignment throws UninitializedPropertyAccessException. by lazy is a val computed once on first access and cached thereafter. lateinit is "I'll initialize it myself"; lazy is "it computes itself on first read".
In depth:
lateinit var |
by lazy |
|
|---|---|---|
| Mutability | var | val |
| Who initializes | you, manually | the block on first access |
| Primitives | not allowed (objects only) | allowed |
| Nullable | not allowed | allowed |
| Check | ::x.isInitialized |
always initialized after 1st read |
@Inject lateinit var repo: Repo // DI assigns it before use
val db by lazy { // heavy object — on demand
Room.databaseBuilder(ctx, Db::class.java, "app").build()
}
// lazy's thread-safety is configurable:
val cfg by lazy(LazyThreadSafetyMode.NONE) { parse() } // no synchronization
- lateinit — for a var that the lifecycle or DI is guaranteed to fill before any read; check with
isInitialized. - lazy — for an expensive
valthat might never be needed;SYNCHRONIZEDby default (thread-safe).
⚠️ Common mistake: lateinit on Int/Boolean — it won't compile, primitives are unsupported. And accessing lateinit before init is not null but an exception.
07How are extension-function calls resolved, and why isn't it polymorphism?
middle
Short answer: Extension functions are resolved statically — by the declared type of the variable, not the object's actual runtime type. The compiler turns them into static methods that take the receiver as the first argument; there is no virtual dispatch like a regular method has.
In depth:
open class A
class B : A()
fun A.who() = "A"
fun B.who() = "B"
val x: A = B()
println(x.who()) // "A" — chosen by the variable's type (A), not by B!
// the compiler effectively generates:
// static String who(A receiver) { return "A"; }
- Static resolution — which extension is called is decided by the declared (static) type of the expression, not the runtime class.
- Does not override a member — if the class has a method with the same signature, the member always wins and the extension is ignored.
- Under the hood — a plain static method with a receiver parameter; hence no access to
privatemembers and no virtuality.
⚠️ Common mistake: expecting polymorphism from extensions — "I'll override the behavior for the subclass." Runtime dispatch needs real open/override methods.
08How does == differ from ===, and why does === on Int? sometimes lie?
middle
Short answer: == is structural equality, compiled to a null-safe equals() call. === is referential equality (the same object in memory). On boxed numbers === gives a treacherous result: the JVM caches Integer in the range −128..127, so equal Int? values of 100 may be ===, while values of 1000 are not.
In depth:
val a: Int? = 127
val b: Int? = 127
a == b // true — value comparison
a === b // true — both come from the Integer cache (−128..127)
val c: Int? = 1000
val d: Int? = 1000
c == d // true
c === d // false! outside the cache — different objects
"ab" == "ab" // true (equals)
"ab" === "ab" // depends on interning — don't rely on it
- == → equals —
a == bisa?.equals(b) ?: (b === null); for your own types overrideequals/hashCodeas a pair. - === → identity — the very same object; for non-boxed primitives it degenerates into a value comparison.
- equals/hashCode contract — equal objects must have equal
hashCode, orHashMap/HashSetbreak.
⚠️ Common mistake: checking Int?/Integer with ===. Because of the −128..127 cache the result depends on the value — use ==.
09Variance in generics: what do out, in, and star-projection mean?
senior
Short answer: Generics in Kotlin are invariant by default: List<String> is not a subtype of List<Any>. out T (covariance) means the type only "produces" T (return position), so Producer<String> is a subtype of Producer<Any>. in T (contravariance) means it only "consumes" T (argument position). Mnemonic — PECS: Producer-out, Consumer-in.
In depth:
interface Producer<out T> { fun get(): T } // T is output-only
interface Consumer<in T> { fun put(item: T) } // T is input-only
val strs: Producer<String> = ...
val anys: Producer<Any> = strs // ✅ covariant: String -> Any
val anyC: Consumer<Any> = ...
val strC: Consumer<String> = anyC // ✅ contravariant: Any -> String
fun printAll(items: List<*>) { // star-projection: type unknown
items.forEach(::println) // read as Any?, cannot write
}
| Modifier | Role of T | Subtyping |
|---|---|---|
out T |
producer (output) | C<Sub> <: C<Super> |
in T |
consumer (input) | C<Super> <: C<Sub> |
<*> |
unknown | read the out bound, cannot write |
- Declaration-site —
out/inon the type declaration apply to every use (unlike Java's use-site wildcards). - Star-projection
<*>— "some specific but unknown type": safe to read as the upper bound, forbidden to write.
⚠️ Common mistake: trying to put an out parameter in an input position (a function argument) — the compiler forbids it: it would break type safety.
10What does reified do, and why does it work only on inline functions?
senior
Short answer: On the JVM generics are erased (type erasure): at runtime T becomes Object, so you can't write T::class or x is T. An inline function copies its body into the call site, where the concrete type is known at compile time; reified lets you use that type directly in the body. Without inline there is nowhere to substitute it — hence the restriction.
In depth:
// ❌ not allowed: T is erased, is T is impossible
fun <T> parseBad(json: String): T = gson.fromJson(json, T::class.java)
// ✅ reified: the type is known at the call site
inline fun <reified T> parse(json: String): T =
gson.fromJson(json, T::class.java)
val user = parse<User>(json) // the compiler inserts User.class here
// a common Android helper:
inline fun <reified T> Context.startActivity() =
startActivity(Intent(this, T::class.java))
- Type erasure — a plain
<T>is indistinguishable fromObjectat runtime; type checks and reflection onTare unavailable. - inline puts the body at the call site — there the concrete type argument is known to the compiler, which physically substitutes
User.class. - What reified allows —
T::class,x is T,x as T, calling another reified function.
⚠️ Common mistake: expecting reified from a regular (non-inline) function. The body isn't copied — there is nowhere to recover the type, so the compiler refuses.
11Why do higher-order functions need inline, noinline, and crossinline?
senior
Short answer: Every non-inline lambda is a Function object allocation. inline embeds the function's body and its lambdas at the call site, removing the allocation and enabling non-local return. noinline excludes a specific lambda from inlining (so it can be stored/passed on). crossinline forbids non-local return in a lambda when it's invoked from another context.
In depth:
inline fun measure(block: () -> Unit) { // block is inlined, no lambda object
val t = System.nanoTime(); block(); log(System.nanoTime() - t)
}
inline fun run2(
a: () -> Unit,
noinline b: () -> Unit // we store b in a field -> it cannot be inlined
) { a(); store(b) }
inline fun forEachSafe(block: crossinline () -> Unit) {
val r = Runnable { block() } // block is called from another context ->
r.run() // crossinline forbids returning from the caller here
}
| Modifier | What it does | When |
|---|---|---|
inline |
inline the function and lambdas | hot HOF, remove allocations |
noinline |
do NOT inline this lambda | you need to store/pass the lambda |
crossinline |
forbid non-local return | the lambda is called from a nested context |
- inline's win — no lambda object and no extra call; a
returnfrom an inline lambda to the outside (non-local) is supported. - The cost — the body is copied into every call: inlining large functions bloats bytecode. For small HOFs it pays off.
- noinline/crossinline — targeted exceptions to a blanket
inline, not standalone modes.
⚠️ Common mistake: slapping inline on everything "for speed." For functions without lambda parameters there's almost no gain and the code bloats — the compiler even warns you.
Source notes
References and review policy
RecallDeck’s interview answers are editorial material, reviewed against maintained official documentation where a primary reference is available. Tool selections use direct provider links and contain no affiliate placements. Features can change after the review date.
From reading to recall
Practice the full interview loop.
RecallDeck schedules the concepts you miss and keeps coding, design, and behavioral fundamentals available when the interviewer changes direction.