Kotlin Advanced Features
1. Coroutines & Structured Concurrency
Q: What are Kotlin Coroutines and why are they important for Android?
Coroutines are lightweight, suspendable computations that allow you to write asynchronous code in a sequential, readable style. Unlike threads, coroutines are extremely cheap to create—you can launch thousands without significant overhead.
The key insight is that coroutines suspend rather than block. When a coroutine hits a suspension point (like a network call), it releases the thread to do other work, then resumes when the result is ready.
Q: Explain structured concurrency.
Structured concurrency means coroutines follow a parent-child hierarchy. When you launch a coroutine within a scope:
- The parent waits for all children to complete
- If the parent is cancelled, all children are cancelled
- If a child fails, the parent (and siblings) are cancelled by default
In Android, viewModelScope and lifecycleScope provide built-in structured concurrency—coroutines are automatically cancelled when the ViewModel clears or the lifecycle ends, preventing memory leaks and wasted work.
Q: What are the different Dispatchers and when do you use each?
| Dispatcher | Thread Pool | Use Case |
|---|---|---|
| Main | Android main thread | UI updates, light work |
| IO | Shared pool (64+ threads) | Network, database, file I/O |
| Default | CPU cores | Heavy computation, sorting, parsing |
| Unconfined | Inherits caller's thread | Testing only, avoid in production |
Q: How do Dispatchers work under the hood?
Dispatchers are CoroutineContext elements that determine which thread(s) execute the coroutine. When you call withContext(Dispatchers.IO), the coroutine suspends, moves to an IO thread, executes the block, then resumes on the original dispatcher.
- Main uses Android's main Looper—there's only one main thread
- IO and Default share threads but have different policies. IO allows many concurrent operations (blocking calls), while Default is sized to CPU cores (compute-bound work)
- IO can grow beyond 64 threads if needed, but reuses threads from Default when possible
Q: What is withContext vs switching dispatchers with launch?
withContext suspends the current coroutine and runs the block on another dispatcher, then returns the result. It's for sequential switching within a coroutine.
launch(Dispatchers.IO) creates a new child coroutine on that dispatcher. Use this for parallel/concurrent work.
Q: What's the difference between launch and async?
- launch: Fire-and-forget. Returns a
Job. Use when you don't need a result. - async: Returns a
Deferred<T>. Use when you need to compute a value, especially for parallel operations where youawait()multiple results.
Q: How does exception handling work in coroutines?
Regular launch propagates exceptions to the parent, which cancels siblings. Use SupervisorJob when you want child failures to be independent—one child's exception won't cancel others.
For async, exceptions are thrown when you call await(), so wrap that in try-catch.
2. Flow & Reactive Streams
Q: What is Kotlin Flow?
Flow is a cold asynchronous stream that emits multiple values over time. "Cold" means it doesn't produce values until someone collects it, and each collector gets its own independent stream.
Q: Compare Flow, StateFlow, and SharedFlow.
| Type | Hot/Cold | Initial Value | Replay | Use Case |
|---|---|---|---|---|
| Flow | Cold | No | No | One-shot data, transformations |
| StateFlow | Hot | Required | Latest value | UI state, always has current value |
| SharedFlow | Hot | Optional | Configurable | Events, can have multiple subscribers |
Q: What are the key Flow operators?
- map/filter: Transform or filter emissions
- flatMapLatest: Cancel previous work when new value arrives (great for search)
- combine: Merge multiple flows, emit when any changes
- debounce: Wait for pause in emissions (search input)
- catch: Handle upstream errors
- flowOn: Change dispatcher for upstream operations
- stateIn/shareIn: Convert cold Flow to hot StateFlow/SharedFlow
Q: How does backpressure work in Flow?
Backpressure occurs when the producer emits faster than the consumer can process. Flow handles this naturally because it's sequential by default—the producer suspends until the collector processes each emission.
For cases where you need different behavior:
- buffer(): Runs collector in separate coroutine, emissions don't wait for processing
- conflate(): Skip intermediate values, only process the latest when collector is ready
- collectLatest(): Cancel previous collection when new value arrives (similar to flatMapLatest)
Use buffer() when you want to decouple producer/consumer speeds. Use conflate() or collectLatest() when only the latest value matters (UI updates, search queries).
Q: How do you safely collect Flow in Android?
Use repeatOnLifecycle or flowWithLifecycle to collect only when the UI is visible. This prevents wasted work when the app is in the background and avoids crashes from updating views after the Activity is destroyed.
3. Generics & Variance
Q: Explain variance in Kotlin generics.
Variance defines how generic types with inheritance relate to each other.
Invariant (default): Box<Dog> is NOT a subtype of Box<Animal>, even though Dog extends Animal. This is safe because the box could accept writes.
Covariant (out): The type can only be produced/returned, never consumed. List<out T> means you can read items but not add them. A List<Dog> IS a subtype of List<Animal>.
Contravariant (in): The type can only be consumed, never produced. Comparable<in T> means you can pass items in but not get them out. A Comparator<Animal> IS a subtype of Comparator<Dog>.
Q: How do you remember in vs out?
- out = output position only (return types) → Producer
- in = input position only (parameter types) → Consumer
Think "PECS" from Java: Producer Extends, Consumer Super.
4. Sealed Classes vs Enums
Q: When should you use sealed classes instead of enums?
Enums are perfect for fixed sets of constants with no varying data—like days of the week or simple status codes.
Sealed classes are better when:
- Different states carry different data (Success has data, Error has message)
- You need inheritance hierarchies
- Subtypes are data classes or objects with properties
The compiler enforces exhaustive when expressions for both, but sealed classes let each subtype have its own structure.
Q: What about sealed interfaces?
Sealed interfaces (Kotlin 1.5+) allow a class to implement multiple sealed hierarchies, which sealed classes can't do. Use them when you need more flexible type modeling.
5. Inline Functions & Reified Types
Q: What does inline do?
The compiler copies the function body directly to every call site, eliminating the function call overhead and lambda object allocation. This matters for higher-order functions called frequently.
Q: What is reified and why is it useful?
Normally, generic type parameters are erased at runtime—you can't access T::class. The reified keyword (only works with inline functions) preserves the type information, letting you:
- Get the class:
T::class.java - Check types:
value is T - Create instances or pass to reflection APIs
Common uses: JSON parsing (gson.fromJson<User>(json)), ViewModel creation, intent extras.
Q: What are the downsides of inline?
- Increases bytecode size if overused
- Can't be used with recursive functions
- Public inline functions can't access private members
6. Extension Functions
Q: How do extension functions work internally?
They're compiled as static methods where the receiver becomes the first parameter. fun String.isEmail() becomes static boolean isEmail(String $this). They're resolved statically at compile time, not dynamically.
Q: What can't extension functions do?
- Access private/protected members of the class
- Override existing member functions (members always win)
- Be truly polymorphic (static dispatch, not virtual)
Q: Explain the scope functions (let, apply, run, also, with).
| Function | Returns | Receiver Access | Use Case |
|---|---|---|---|
| let | Lambda result | it |
Null checks, transformations |
| apply | Receiver object | this |
Object configuration |
| run | Lambda result | this |
Execute block, compute result |
| also | Receiver object | it |
Side effects, logging |
| with | Lambda result | this |
Multiple calls on same object |
7. Data Classes
Q: What does declaring a data class give you automatically?
equals()andhashCode()based on constructor propertiestoString()with property names and valuescopy()for creating modified copiescomponentN()functions for destructuring
Q: What are the limitations?
- Must have at least one primary constructor parameter
- All constructor parameters must be
valorvar - Cannot be abstract, open, sealed, or inner
- Only primary constructor properties are used in generated methods
Q: Best practices for data classes?
- Prefer
valfor immutability - Avoid mutable collections as properties
- Be careful with properties declared in the body—they're excluded from equals/hashCode
- Consider using
copy()instead of mutable properties
Quick Reference
| Topic | Key Points |
|---|---|
| Coroutines | Suspend don't block; structured concurrency with scopes; Main/IO/Default dispatchers |
| Flow | Cold streams; StateFlow for state; SharedFlow for events; flatMapLatest for search |
| Variance | out = producer (covariant); in = consumer (contravariant) |
| Sealed Classes | States with data; exhaustive when; better than enum for complex cases |
| Inline/Reified | Inline copies body; reified preserves generic type at runtime |
| Extensions | Static dispatch; can't access private; scope functions for fluent code |
| Data Classes | Auto equals/hashCode/copy/toString; immutable preferred |