A data class is a class meant to hold values: from the properties of its primary constructor the compiler generates equals()/hashCode(), toString(), copy() and componentN() for destructuring.
Only the primary constructor counts: a property declared in the class body is ignored by equals, hashCode, toString and copy.
Requirements: at least one primary-constructor parameter, each one val or var.
Inheritance: a data class may extend a class and implement interfaces, but it cannot be open, abstract, sealed or inner, so nothing can inherit from it.
**var properties**: legal but risky: changing one changes hashCode, so an object already stored in a HashSet or used as a HashMap key can no longer be found. Prefer val plus copy().
**copy() is shallow: nested objects and lists are shared between the original and the copy.
data object** (Kotlin 1.9): a singleton with a readable toString(), typically data object Loading inside a sealed state.
AndroidJunior
Kotlin OOP & Functional
Classes, data classes, sealed classes, objects, extension functions, scope functions
Questions closed
0 / 25
- 0Review
- 9Asked often
Questions
A sealed class or interface has a closed set of direct subtypes known at compile time, so a when over it covers every case without else, and the compiler flags every such when the day you add a new subtype.
Where subtypes may live: direct subtypes must be in the same module and the same package; a sealed class is abstract and its constructors are protected or private.
Each subtype is a full class: it can carry its own data (data class Error(val message: String)) or be a singleton (data object Loading). That is the difference from an enum, whose constants all share one shape.
Sealed interface vs sealed class: an interface lets a type belong to several sealed hierarchies and lets an enum or object implement it; a class lets subtypes share state through its constructor.
Typical uses: screen state (Loading/Content/Error), operation results, one-off UI events, parser tokens.
Avoid an else branch on a sealed type: it silently swallows the subtype you add later, exactly the error you wanted the compiler to report.
An enum class is a fixed set of named instances of one type; constants can have constructor properties, methods and even per-constant overrides, but they all share one shape and each exists exactly once.
Data and behaviour: enum class Plan(val priceCents: Int) { FREE(0), PRO(999) }; an abstract method can be implemented separately by each constant.
Built-ins: name, ordinal, valueOf(String) and entries (Kotlin 1.9), which replaced values(): entries is a cached read-only list, while values() copies an array on every call.
**valueOf throws** IllegalArgumentException on an unknown name. For values that come from a server, look up with a fallback (entries.firstOrNull { it.name == raw } ?: UNKNOWN), or a new backend value crashes every older app version.
Pick a sealed type when cases need different data (Error(message) next to Loading), when a case needs several instances, or when an unknown value must keep its raw string for logging (Unknown(raw)).
Rule of thumb: an enum for a closed list of constants of the same shape, a sealed hierarchy for a closed list of different shapes.
An abstract class can hold state and has a constructor, but you extend only one; an interface holds no state, but a class can implement many, and both may contain implemented methods.
Final by default: a plain class and its members cannot be extended or overridden. You opt in with open, and every override needs the override keyword. This heads off the fragile-base-class problem: nobody subclasses code that was not designed for it.
**open vs public**: public controls who can see and call a declaration, open controls who can subclass or override it. They are independent.
**abstract class**: cannot be instantiated; can have a constructor, backing fields and init blocks, and mixes abstract with concrete members. abstract implies open.
**interface: abstract members, default method bodies and properties without backing fields (abstract or with a getter). No constructor.
Same default method in two interfaces**: the class must override it and choose with super<A>.greet().
One colon covers both extends and implements: class Service : BaseRepo(url), Logger. Choose an interface for a contract or capability, an abstract class when subtypes truly share state and setup.
The four principles are encapsulation, abstraction, inheritance and polymorphism; overloading is one name with different parameters, chosen at compile time, overriding is a subclass replacing a method, chosen at runtime by the real type.
Encapsulation: hide state behind behaviour: private set, private fields, exposing a StateFlow instead of the MutableStateFlow.
Abstraction: depend on what something does, not how: a UserRepository interface instead of Retrofit plus Room directly.
Inheritance: reuse and specialize a base type; in Kotlin only from an open or abstract class.
Polymorphism: one call, many behaviours: shape.area() runs the Circle or the Square version depending on the object.
Overloading: fun show(id: Long) and fun show(name: String), resolved by the declared argument types. In Kotlin default arguments replace most overloads; @JvmOverloads generates them for Java callers.
Overriding: override fun area() in a subclass, only for an open or abstract member; dynamic dispatch picks it at runtime.
Kotlin has public (the default), private, protected and internal; internal means visible anywhere in the same module (one set of files compiled together, such as a Gradle module's main source set).
**public: the default. There is no Java-style package-private level.
private: inside a class, visible only in that class; at top level, only in that file.
protected: the class and its subclasses. Unlike Java, not the rest of the package.
internal: the whole module. In a multi-module Android app it keeps implementation details (DTOs, mappers, DAOs) out of other feature modules, and the module's own tests can still see them.
What it compiles to**: the JVM has no internal, so such a class is public in bytecode and internal members get mangled names like load$data_release. Kotlin callers are blocked; Java callers can still reach them.
Modifiers apply to constructors too: class Config private constructor(...) plus a companion factory controls how instances are created.
An extension adds a function or property to an existing type without changing its source; it compiles to a static method that takes the receiver as its first parameter, so it sees only the receiver's public and internal members, never private ones.
Syntax: fun String.initials(): String; inside it, this is the receiver.
Extension properties have no backing field, only a getter (and a setter for var) computed from the receiver.
On the JVM: a top-level extension in StringExt.kt becomes StringExtKt.initials(String), which is also how Java calls it.
Resolved statically: the declared type of the variable picks the extension, not the runtime type, and a member with the same signature always wins.
Nullable receivers: fun String?.orDash() can be called on null and handles it inside.
Use them for helpers on types you do not own (Context.dp(), a Flow operator) and to keep classes small; do not hide important behaviour in an extension where readers will not look for it.
An infix function is a member or extension with exactly one parameter that can be called without the dot and parentheses: a to b instead of a.to(b).
Rules: marked infix, a member or an extension, exactly one parameter, and that parameter cannot be vararg or have a default value.
In the standard library: to (builds a Pair), until, downTo, step, and/or/xor on Boolean, and shl/shr on Int.
Precedence: an infix call binds weaker than arithmetic but stronger than comparisons: 1 + 2 to 3 is (1 + 2) to 3.
When to write one: DSLs and test assertions (result shouldBe 5), where the call reads like a sentence. For ordinary APIs a normal call is clearer.
They all run a block on an object and differ in two things: whether the object is this or it inside the block, and whether the call returns the object itself or the block's result.
let uses it and returns the block result. Null-safe transforms: user?.let { show(it) }.
run uses this and returns the block result. Computing something from an object's members.
with(obj) uses this and returns the block result. Same as run, but a plain function, for a non-null object.
apply uses this and returns the object. Configuring an object right after creating it.
also uses it and returns the object. Side effects inside a chain: logging, validation.
Rule of thumb: "give me the object back" means apply/also, "give me a result" means let/run/with. Avoid nesting them; two levels of it/this already hide which object a line works on. takeIf/takeUnless complete the family by returning the object or null.
Equal objects must return equal hashCode values; override only equals and a HashSet or HashMap usually looks in the wrong bucket and fails to find an object equal to one already stored.
Default behaviour: Any.equals is reference identity, so two separately created objects with the same fields are not equal until you override it (a data class does it for you).
The rule runs one way: equal objects must have equal hash codes; unequal objects may collide.
Choose the fields: an entity compares by id only, a value type by all its fields. Use stable fields: if a field used in hashCode changes while the object sits in a HashSet, contains returns false for it.
Symmetry: for a final class other is Point is enough; for an open class compare javaClass, so that a == b and b == a agree across subclasses.
**== vs ===**: == calls equals and is null-safe; === compares references.
It is not only about tests: distinct(), map keys, DiffUtil, Compose skipping and StateFlow (which drops a value equal to the current one) all rely on equals.
A nested class has no reference to an outer instance (Java's static nested class), while an inner class stores a hidden reference to it, so it can use outer members but also keeps the outer object alive.
Nested: class Outer { class Nested }, created as Outer.Nested(), cannot touch an outer instance. Kotlin made this the default; in Java the default is the inner form.
**inner**: created as outer.Inner(), reaches outer members, and this@Outer names the outer instance.
The leak: hand an inner instance from a Fragment to anything that lives longer (a singleton event bus, a Handler with delayed messages, a background thread), and the Fragment with its whole view tree stays in memory. LeakCanary reports it as a retained Fragment.
Same capture without the keyword: anonymous objects, lambdas that touch members and bound references like ::onItem also hold the outer instance.
Frameworks need nested: Room entities, serializable DTOs and Parcelable classes cannot be inner, and neither can a data class.
The fix: keep the class nested, pass in exactly what it needs (a callback, a StateFlow), and unregister listeners in the matching lifecycle callback.
It prints [1, 2, 3, 4, 5] and then 4 true 5: apply and also return the receiver, while let and run return the last expression of the block.
a: apply returns list itself, not a copy. By the time println(a) runs the later calls have added 4 and 5, so all five numbers are printed.
b: also returns the list after adding 4, and .size is 4.
c: let returns the block's last expression, and MutableList.add returns Boolean, so c is true, not the list.
d: run returns size, which is 5 after all the adds.
The c line is the bug people actually ship: let written where also was meant yields a Boolean or Unit instead of the object, and type inference hides it until the value is used.
A class is a blueprint you instantiate as often as you like, an object is a singleton declared and created in one step, and a companion object is a singleton attached to a class that holds what Java would make static.
**object on the JVM**: compiles to a final class with a private constructor and a static final INSTANCE field set in the static initializer. The JVM runs that initializer once, on first access, under the class-initialization lock, so creation is thread-safe with no double-checked locking.
Only creation is thread-safe: a var or a MutableMap inside the object is shared mutable state and needs its own synchronization.
Lifetime: a static field holds the instance, so it is never garbage-collected while the app process lives. A Context, Activity or View stored in an object is a leak.
**companion object**: compiles to a nested class Companion plus a static field on the outer class, and is initialized when the outer class is loaded. Its members are not real JVM statics: Java callers need @JvmStatic or @JvmField, while const val becomes a static constant.
Object expression: object : TextWatcher { ... } creates a new anonymous instance every time, the Kotlin form of a Java anonymous class.
The companion object is initialized once, when the class is first loaded; for each instance the superclass is built first, then property initializers and init blocks run in the order they appear in the class body, and a secondary constructor's own body runs last.
The companion object (a static initializer), once per class load.
A secondary constructor first delegates with this(...) to the primary constructor (it may call super(...) itself only in a class without a primary constructor).
The primary constructor calls the superclass constructor, which is fully initialized first.
Primary-constructor parameters are bound; property initializers and init blocks run top to bottom, so an init block cannot read a property declared below it.
The secondary constructor's body runs on a fully built object.
The trap interviewers push on: calling an open method from a base-class init runs the subclass override before the subclass's properties are initialized, so they are still null or 0. Never call overridable members during construction.
It prints [tea, milk], false, true: copy() is shallow, so both carts point to the same MutableList, and adding to the copy's list changes the original.
**copy() copies references: it calls the constructor with the same property values; a list, an array or any other mutable object inside is shared, not cloned.
original == copy is false**: only because owner differs. With equal owners it would be true, since equals compares the shared list by content.
**=== on the lists is true: it is literally one object.
The fix**: keep data classes deeply immutable: val items: List<String> and cart.copy(items = cart.items + "milk"), which builds a new list. If a mutable field is unavoidable, copy it explicitly: copy(items = items.toMutableList()).
This matters most for UI state: mutate a shared list inside a StateFlow value and the "new" state equals the old one, so the screen does not update.
It prints Circle, shape, Circle: members are dispatched by the runtime type, extensions by the declared type, and a member always beats an extension with the same signature.
s.name(): name is an open member, so virtual dispatch calls Circle.name() even though s is declared as Shape.
s.label(): extensions are static functions chosen at compile time from the declared type Shape, so Shape.label() runs; the runtime Circle does not matter.
Circle().name() (a member and an extension with the same signature): the member wins, and the compiler warns that the extension is shadowed, so fun Circle.name() can never be called this way.
Consequences: an extension cannot be overridden, cannot change what a class already does, and is the wrong tool where you need polymorphism; make it an open member or an interface method instead.
progress is a constructor var of a data class, so it is part of hashCode; once onProgress changes it, remove(d) looks under a different hash, finds nothing, and every finished download stays in active forever.
Why it happens: HashSet stores the element in the bucket for its hash at insertion time; remove recomputes the hash from the current fields and looks in the wrong bucket, so even the same reference is not found.
The fix: move progress into the class body. The generated equals, hashCode, toString and copy use only primary-constructor properties, so the identity is now url, which never changes.
Alternatives: keep Download fully immutable and store a HashMap<String, Download> keyed by url, replacing the value with copy(progress = …); or use a plain class with equals/hashCode on a stable id.
Catch it next time: a unit test that calls onProgress and then finish exposes it immediately.
The list's iterator remembers a modification count when the loop starts and checks it on every next(); removing through the list itself changes that count, so the next step of the loop usually throws.
**downloads.removeAll { it.done }**: states the intent and removes in one pass with no loop of yours open; retainAll { } is the inverse.
**filterNot { it.done }: when you want a new read-only list, for example for UI state.
An explicit iterator with iterator.remove(): when the loop must do more than remove.
Iterate a copy**: for (d in downloads.toList()) when the body calls code that may change the list.
**forEach { } is the same loop**: it fails the same way, and so does changing a map while iterating its keys or entries.
The silent variant: removing the second-to-last element does not throw; the loop just ends and the last item is never visited. The name misleads too: the exception usually means one thread contradicting itself; for real multi-threaded access the fix is confinement or a concurrent collection, not a different loop.
:: turns an existing function, property or constructor into a function value; a bound reference (obj::method) captures that object when the reference is created, while an unbound one (Type::method) takes the receiver as its first parameter.
::save: a top-level function, typed (Order) -> Unit.
viewModel::onSubmit: bound, typed (String) -> Unit; the receiver is fixed right now.
User::name: an unbound property reference, (User) -> String; that is why map(User::name) and sortedBy(User::age) compile.
::UserRow is a constructor reference: users.map(::UserRow).
Since Kotlin 1.4 a reference adapts to the expected type: parameters with defaults may be omitted and a result can be coerced to Unit.
The trap
The bound receiver is captured, not re-read. val label = current::name keeps returning the first user's name after current is reassigned, and keeps that object alive. Inside a class, ::onItem captures this: the same leak as an inner class if the reference outlives a Fragment.
Use a reference when a lambda would only forward its arguments; use a lambda as soon as you reorder, drop or add one.
Each operator maps to a function with a fixed name, and marking that function operator is the whole opt-in: a + b calls a.plus(b), a[i] calls a.get(i), x in a calls a.contains(x).
Arithmetic: plus, minus, times, div, rem, unaryMinus.
Indexing: a[i] is get, a[i] = v is set, with any number of index parameters.
Others: a(x) is invoke, a..b is rangeTo, a < b is compareTo, for (x in a) needs iterator, destructuring needs componentN.
Compound assignment: a += b uses plusAssign if it exists, otherwise a = a.plus(b); having both available on a var is an ambiguity error.
Rules: the names are fixed, you cannot invent operators or change precedence, and an operator can be an extension on a type you do not own.
Use operators for types with real arithmetic or collection meaning (money, vectors, durations, grids), never to hide side effects, such as a + that writes to a database.
A Sequence runs each element through the whole chain lazily, only when a terminal operation like toList() or first() runs, while a List chain finishes every step for all elements and allocates an intermediate list per step.
Early exit: first { }, take(n) and generateSequence stop as soon as the answer is known; an infinite source is possible only with a sequence.
Small lists: asSequence() hurts there: every step is a wrapper object and every element goes through a lambda object, which costs more than a couple of tiny intermediate lists.
Stateful operations: sorted(), distinct() and groupBy() need all the input, so laziness is gone from that point on.
No index access: there is no get(i) or size without consuming the sequence.
Not a Java Stream: no parallelism; a sequence can usually be iterated again, but one built from a one-shot source throws on the second pass.
SAM conversion lets you pass a lambda where an interface with a single abstract method is expected; Java interfaces get it automatically, a Kotlin interface needs the fun interface modifier.
Java side: button.setOnClickListener { } works because OnClickListener has one abstract method.
Kotlin side: fun interface Validator { fun validate(s: String): Boolean } is used as Validator { it.isNotBlank() }; a plain interface does not accept a lambda.
**Versus a function type or typealias**: a fun interface is a named type, is friendlier to Java callers, may have default methods, and must have exactly one abstract member; a function type (String) -> Boolean is lighter but anonymous.
Identity trap: each lambda is a new object, so removeListener { } with a fresh lambda removes nothing; keep the instance in a variable.
Inheritance ties a subclass to its parent's implementation, so a change in the base class can break subclasses; composition keeps the collaborator behind an interface, and class LoggingRepo(inner: Repo) : Repo by inner makes the compiler write every forwarding method for you.
Why inheritance hurts
Fragile base class: the subclass depends on how the parent calls its own methods, not only on what it promises.
One parent only: you cannot combine two behaviours by extending two classes.
Everything is inherited: including state and methods the subclass never wanted.
How by helps
: Repo by inner generates every Repo method as a call to inner; methods you write yourself take precedence, so you override only what you change. That is a decorator in one line: logging, caching, retry, a test fake that fails one call.
The wrapped class does not have to be open.
The limit: inner does not know it is wrapped. If its refresh() calls its own load(), your overridden load() is not used: nothing dispatches back into the wrapper.
Inheritance still fits a genuine "is-a" relation with a base designed for extension, such as ViewModel or a sealed hierarchy. Property delegation (by lazy) is a separate feature that shares the keyword.
SOLID is five rules for classes that stay easy to change: single responsibility, open/closed, Liskov substitution, interface segregation and dependency inversion.
S (single responsibility): one reason to change. Violation: a ViewModel that calls Retrofit, parses JSON, writes to Room and formats dates. Fix: move data work into a repository and formatting into a mapper.
O (open/closed): add behaviour by adding code, not by editing working code. A new payment method is a new PaymentMethod implementation, not another branch in an ever-growing when (type).
L (Liskov substitution): a subtype must work wherever its parent is expected. Violation: ReadOnlyCache : Cache whose put() throws UnsupportedOperationException. Kotlin's split into List and MutableList avoids exactly that.
I (interface segregation): small, focused interfaces. TextWatcher forces three overrides when you need one; core-ktx's doAfterTextChanged { } is the segregated version.
D (dependency inversion): high-level code depends on abstractions. LoginViewModel takes an AuthRepository interface; Hilt binds the real implementation, tests pass a fake.
A nuance worth mentioning: a sealed type with when is closed to new types but open to new operations (the opposite trade-off to an interface), and fine when the set of cases is genuinely fixed.
Singleton is not an anti-pattern by itself, but a global object that code calls directly is hidden global state: callers do not declare it, tests cannot swap it, and it lives for the whole process.
Why it gets criticized: a global mutable object hides a dependency, cannot be replaced by a fake in a test, and leaks any Context, Activity or View it holds until the process dies.
Keep one instance, let DI own it: inject an interface scoped @Singleton (Hilt) instead of calling MySingleton.doWork() from everywhere; tests then bind a fake.
**Store only applicationContext**: never an Activity or View, which outlive their screen inside a singleton.
**object is thread-safe only at creation**: the JVM initializes it once under the class-initialization lock, but a var or a mutable collection inside still needs its own synchronization.
Other patterns: Strategy and Decorator (ContextWrapper, by delegation) are covered by the Android SDK pattern list in the architecture topic.