Skip to content

Kotlin Programming

170 companion flashcards · AI-assisted study content · Open the deck →

deck dives into some of Kotlin's most distinctive features, the building blocks that make the language feel modern and expressive. You'll work through the null safety system, including nullable types, the safe call operator, the Elvis operator, and the non-null assertion. Beyond that, the cards cover data classes and what they generate for you, extension functions and how they interact with private members, sealed classes and how they pair with when expressions, and companion objects including whether they can implement interfaces.

It's well suited to learners who already know basic Kotlin syntax, or developers coming from Java who want to get comfortable with the idiomatic side of the language. If you've written a few small programs and are ready to understand the design decisions behind Kotlin's type system and class model, this is a good next step.

Because many of these features are interconnected, try writing short code examples as you review each card. For instance, pairing a sealed class with a when expression really clicks once you've seen it in action. Small experiments like this turn the definitions on the cards into working intuition.

Spacing your review across a few short sessions tends to work better than cramming, especially for subtle distinctions like the difference between sealed and enum classes. Coming back to the cards a day later often reveals gaps you didn't notice the first time around.

Foundations — Types, Variables, and Expressions

Kotlin's type system starts with two declarations that differ only in mutability: val creates a read-only reference that cannot be reassigned, while var creates a mutable one. Beyond this, the basic types are numbers (Int, Long, Short, Byte, Float, Double), Boolean, Char, and String — every Kotlin type is an object at the language level, even when the JVM represents primitives more efficiently under the hood. String interpolation lets you embed expressions directly with a $ symbol, using curly braces for multi-token expressions. For more advanced formatting, you can compose with multi-line raw strings enclosed in triple quotes, which preserve newlines and most content verbatim while still supporting $ interpolation.

Two universal types anchor the entire hierarchy. Any is the root of all non-nullable types, much like Object in Java, while Any? extends it to include nullable types. Unit is Kotlin's equivalent of void for functions with no meaningful return value, but unlike Java's void, it is a real type with a single value and can be used in generics. At the opposite extreme, Nothing represents computations that never produce a value at all — functions that always throw, infinite loops, or the operand of an Elvis branch that throws. Because Nothing is a subtype of every other type, expressions that throw can appear in any context expecting a value.

For control flow, when is Kotlin's replacement for Java's switch and is considerably more powerful. It can match direct values, ranges (in 1..10), types (is String), and arbitrary predicates, either as a statement or as an expression that returns a value. As an expression, it must be exhaustive — covering every branch or providing an explicit else. Ranges are constructed with .. for inclusive bounds, until for exclusive upper bounds, and downTo for descending iteration, all of which accept an optional step for custom increments. for loops iterate over an iterator over a range, array, collection, or sequence, while while and do-while suit condition-driven loops. To exit a lambda early without breaking out of the surrounding function, you can use a labeled return like return@forEach, which terminates only the current iteration; a custom label like @lit@{ return@lit } handles nested or shadowed cases.

Equality in Kotlin distinguishes structural from referential comparison. The == operator checks structural equality by calling equals(), while === checks referential equality — that is, whether two references point to the very same object in memory. For data classes, the compiler-generated equals implements structural comparison by content, which is why two distinct instances of User("Alice", 30) compare as equal under ==. Smart casting complements this by tracking type checks: after if (obj is String), the compiler treats obj as a non-nullable String inside the branch, removing the need for explicit casts and simplifying chained &&/|| conditions. Taken together, these building blocks give Kotlin a coherent foundation for declaring and reasoning about values.

Null Safety

Null safety is Kotlin's most celebrated feature, designed to eliminate NullPointerException at compile time. By default, types are non-nullable — declaring var name: String means the compiler will reject any attempt to assign null. To permit it, you append a question mark to declare a nullable type, as in String?. Once a type is marked nullable, the compiler tracks every place it is used and forces you to handle the null case explicitly, transforming what was a runtime hazard in languages like Java into a compile-time verification.

Three operators form the everyday vocabulary of null handling. The safe call operator ?. accesses a property or calls a method on a nullable receiver, but if the receiver is null, the entire expression short-circuits to null instead of throwing. For example, user?.address?.city returns null if user or address is null, and you can chain as many ?.'s together as needed — each short-circuits independently. To provide a default value when the left side is null, the Elvis operator ?: supplies an alternative: name?.length ?: 0 falls back to 0 whenever name is null. The right-hand side of ?: is evaluated only when needed, which makes the operator ideal for lazy fallbacks, and because it is right-associative with low precedence, idioms like a ?: b ?: c cleanly find the first non-null value. For situations where you are certain a value is non-null, !! casts it to non-null but throws a KotlinNullPointerException if that certainty proves wrong — used sparingly because it is a runtime escape hatch whose stack trace points to the !! site.

You can also check for null directly with == or != against null, and the compiler will smart cast the variable inside the conditional block, so it can be used as non-null without any operator at all. Combined with let, this yields an idiomatic null check: name?.let { println(it) } executes the block only when name is non-null, with the lambda's last expression as the result; if name is null, the whole let call returns null. Because let returns the lambda result while also returns the original object, the two are distinct tools for different needs — choose also when you want the receiver to flow through a chain unchanged, and let when you want a computed transformation.

When Kotlin calls Java, it encounters nullable types whose nullability is not annotated. These become platform types (sometimes shown as String! in tooling) and are treated as both nullable and non-nullable by the compiler — effectively shifting null-safety responsibility to the caller. To restore compile-time guarantees, you can annotate Java declarations with @NotNull and @Nullable (from JetBrains, JSR-305, or AndroidX), after which Kotlin enforces them like any native nullable type. Finally, throwing an exception produces a Nothing, which by being a subtype of every type lets you write val name = input ?: throw IllegalArgumentException("name required") without further typing noise.

Classes, Objects, and Inheritance

Kotlin classes are final by default, meaning they cannot be inherited unless explicitly marked open. An open class can be extended, and its members can be overridden with the override keyword — which is itself open unless combined with final, allowing subclasses to further override. Abstract classes go further, forbidding instantiation and supporting abstract members that subclasses must fill in. Sealed classes take the restriction in a different direction: they limit direct subclasses to the same file (or package, in newer Kotlin versions), letting the compiler verify exhaustiveness in when expressions so you can omit the else branch safely. Unlike enum classes, where each value is a single stateless instance, sealed class subclasses can each carry their own properties and instantiate freely — making them well-suited for result hierarchies or variant types with attached data.

Several class variants offer specialized semantics. A data class — declared with the data keyword — automatically generates equals(), hashCode(), toString(), copy(), and componentN() functions. The generated copy() lets you derive a modified version of an instance, supporting functional patterns with immutable records: val older = user.copy(age = 31). The componentN() functions enable destructuring declarations like val (name, age) = User("Alice", 30), and they work the same way in for loops over maps, where for ((k, v) in map) destructures each entry. Companion objects, declared with companion object { } inside a class, hold static-style members accessible via the class name and can implement interfaces, providing a clean factory method pattern. A nested class is Java's static nested equivalent with no reference to the outer instance, while an inner class (declared with the inner keyword) keeps an implicit reference accessible as this@Outer. For a single shared instance, an object declaration creates a lazily initialized, thread-safe singleton; an object expression, by contrast, produces an anonymous class instance immediately for one-off use.

Construction in Kotlin comes in two flavors. The primary constructor is declared in the class header (class User(val name: String, val age: Int)) and can carry default values, visibility modifiers, and annotations, but contains no body. For arbitrary logic during construction, you use init { } blocks, which run in declaration order alongside property initializers; each class can have multiple init blocks, all sharing the primary constructor's parameter scope. Secondary constructors declared with the constructor keyword inside the body must delegate to the primary via this(...), and they run after the init chain completes. The overall construction order is property initializers and init blocks first (in declaration order), followed by secondary constructor bodies.

Functions and properties can be customized without inheritance. Extension functions add new functions to existing classes without modifying their sources, called with normal dot syntax; however, they are resolved statically and cannot access the receiver's private or protected members. Interfaces in Kotlin can declare abstract methods, abstract properties, and default implementations, and a single class may implement several — replacing the trait-style composition of multiple behaviors. Type aliases provide an alternative name for an existing type, which is particularly useful for simplifying complex generics or function signatures. To call an overridden member from a subclass, use super.method(), or super<InterfaceName>.method() to disambiguate when multiple parents provide the same method. Finally, several annotations smooth Java interop: @JvmStatic generates real static methods on objects and companions; @JvmField exposes a property as a public field; @JvmOverloads emits overloads for default arguments; @JvmName renames JVM methods to avoid clashes; @Throws re-enables Java-style checked-exception declarations; and @SerialName together with the @Serializable annotation integrates with kotlinx.serialization for JSON or other formats.

Functions, Lambdas, and Scope Functions

Functions are first-class citizens in Kotlin. A higher-order function is one that takes a function as a parameter or returns one, opening up powerful composition patterns. The function-typed parameters are typically supplied as lambdas — anonymous functions written as literals. With a single parameter, you can use the implicit name it; for several or explicit names, declare them in the parameter list, as in { x, y -> x + y }. Several modifiers refine higher-order functions. The inline keyword tells the compiler to copy the function body and its lambdas to the call site, avoiding the allocation of function objects and enabling the reified modifier for runtime type access. noinline opts a specific lambda out of inlining when it needs to be stored or passed further, and crossinline prevents non-local returns when the lambda will be invoked from a different execution context.

Reified type parameters unlock uses that generics normally forbid — most notably is T and T::class.java at runtime. Because type information is erased by the JVM, this trick works only inside inline functions where the type is preserved at the call site. The infix modifier lets a function be called without a dot or parentheses, as in 3 times "Hi ", giving DSL-like fluency to member or extension functions with a single argument (and no varargs or default values). Operator overloading works similarly: by marking a function with the operator keyword and using predefined names like plus, minus, times, divide, compareTo, get, set, contains, and invoke, you can use +, *, <, [], in, and other natural syntax. The invoke operator is particularly interesting — it lets an instance be called like a function, as in val g = Greeter(); g("Alice"), making objects suitable as callbacks and DSL builders.

Top-level functions are declared outside any class, package, or file scope. They default to public and compile to static methods on a synthetic class named after the file, providing a convenient way to expose utility functions without polluting the global namespace. Kotlin's class builder idiom relies on returning this from each setter-like method, ideally using apply so the chain composes naturally: Query().limit(5).select("*"). Labeled returns let you break out of a lambda early without exiting the enclosing function — return@forEach terminates the current iteration, and a custom label handles nested or shadowed cases.

The scope functions are a small family of standard-library helpers that wrap a block of code on an object, each with subtly different behavior. let references the receiver as it and returns the lambda result, ideal for null checks with ?.let { ... } and transformations. run references the receiver as this and returns the lambda result, useful for object configuration followed by computing a value. with takes the object as an argument (not as a receiver), so it works as a non-extension function — perfect for calling multiple methods on an object. apply references the receiver as this but returns the object itself, making it the idiomatic choice for initialization and builder chains. also references the receiver as it while returning the object, designed for side effects like logging or validation that keep the original flowing through a chain. Two related utilities complement the scope family: takeIf returns the object when its predicate is true and null otherwise; takeUnless does the opposite. The two main questions when choosing between them are whether you need the object back (apply, also) or a computed result (let, run, with), and whether you prefer referencing it as it or as this.

Properties and Delegation

Kotlin's property model unifies simple field access with getter and setter logic. A declaration like val name: String creates a property that the compiler backs with a private field and a synthetic accessor method. You can override these accessors when logic is needed, as in val fullName: String get() = "$first $last" for a computed read-only property, or var password: String set(value) { if (value.length >= 8) field = value } for guarded mutation. Inside a custom accessor, the identifier field refers to the backing storage, and a backing field is generated only when you use the default accessors or reference field — otherwise the property is just a function with property syntax.

Initialization strategies vary by use case. lateinit applies only to var properties of non-nullable reference types (not primitives like Int or Boolean) and defers initialization until first use, throwing UninitializedPropertyAccessException if read too early. It shines for dependency injection and Android view binding where you cannot initialize in the constructor, and ::adapter.isInitialized lets you test whether it has been assigned. The lazy { } delegate, by contrast, applies to read-only properties: it computes the value on first access, caches it, and is thread-safe by default (LazyThreadSafetyMode.SYNCHRONIZED) — ideal for expensive or rare computations. Together, they cover the two lazy patterns: lateinit for late-initialized mutable references and lazy for expensive immutable computations.

The by keyword enables two distinct delegation patterns. In class headers, by delegates interface implementation to another object: class Derived(b: Base) : Base by b forwards every method of Base to b without manual forwarding code. For properties, by delegates the get and set logic to a separate object — the receiver just provides getValue() and setValue() operator functions. Built-in delegates cover the most common cases: lazy for deferred initialization, Delegates.observable(initial) { prop, old, new -> ... } for change-notification, and Delegates.vetoable for changes that may be rejected. Custom delegates can implement getValue and setValue themselves, enabling patterns like map-backed properties, validated setters, or even shared remote state.

Top-level properties sit at the file level outside any class and can be val or var — with const val allowed at top level and for primitives or Strings. They make natural global constants and can be restricted to module-internal use with the internal modifier. Kotlin's visibility modifiers extend beyond classes: internal members are visible throughout the same Gradle or IntelliJ module but hidden from other modules, striking a balance between Java's package-private and public. protected limits visibility to the declaring class and its subclasses — unlike Java, it does not extend to the same package. There is no package-private keyword; for package-local visibility, you use internal or a private file-level declaration. The const keyword declares compile-time constants: const val must be a primitive type or String, must be initialized with a literal, and is restricted to the top level or inside an object or companion object. Crucially, const val is inlined at every call site, which is required for use in annotations and for when expression cases that must compile to efficient conditional branches. Plain val can hold any type, may use a custom getter, and can sit anywhere in the class hierarchy, but its value is evaluated at runtime and cannot appear in compile-time contexts.

Collections, Sequences, and Strings

Kotlin's collection library distinguishes read-only interfaces from their mutable counterparts. List, Set, and Map describe immutable collections, while MutableList, MutableSet, and MutableMap extend them with mutation methods like add, remove, and clear. The standard factories — listOf(), mutableListOf(), setOf(), mutableSetOf(), mapOf(), mutableMapOf(), emptyList(), and emptyList() — return instances of these interfaces. The runtime implementations are usually ArrayList and HashMap, but your code should rely only on the interface contract. arrayListOf(...) returns a Java ArrayList directly, while mutableListOf(...) returns the most general mutable type, allowing the JVM platform to choose an optimized implementation. Use type-specific factories like intArrayOf, longArrayOf, doubleArrayOf, charArrayOf, and booleanArrayOf when working with primitives to avoid the boxing overhead of Array<Int>. The Array(size) { index -> ... } constructor calls the initializer for every index, producing an array of the given size.

Functional operations are a hallmark of the library. map transforms each element, filter keeps elements matching a predicate, and reduce aggregates elements starting with the first one — though it throws on empty collections. fold is the safer variant, accepting an explicit initial accumulator that allows empty inputs. mapNotNull applies a transform and discards any null results, while flatMap maps each element to an Iterable and concatenates them into one flat list. groupBy returns a Map keyed by the selector result, and partition splits the collection into a Pair of two lists based on a predicate. For finding elements, firstOrNull returns the first match or null on empty input, while singleOrNull asserts uniqueness by returning null unless exactly one element matches. To convert between collection shapes, list.toSet() or .toHashSet() dedupes into a Set, while keys.zip(values).toMap() builds a map from two parallel lists, and associate or associateWith covers transform-based construction with last-duplicate-key-wins semantics.

Lazy sequences are the eager collection's counterpart. Each operation on a sequence is fused with the next, so elements flow through the chain one at a time without producing intermediate lists — a major advantage for chained operations over large or infinite data. sequenceOf(...), sequence { yield(...); yieldAll(...) }, and asSequence() create a sequence; the sequence builder lets you hand-craft a generator. chunked(size) splits a list into fixed-size batches, useful for pagination or batched network calls, while windowed(size, step, partialWindows) produces overlapping or stepped slices. zip pairs elements from two collections into a List<Pair>, stopping at the shorter one; zipWithNext pairs adjacent elements within a single collection. distinct removes duplicates while preserving first-occurrence order, distinctBy deduplicates by a selected key, sorted and sortedDescending return new lists in ascending or descending natural order, while sortedWith combined with compareBy and thenBy sorts by multiple criteria. reversed() allocates a new list, whereas asReversed() returns a live view that reflects later mutations on a MutableList.

Strings come with rich manipulation tools. String interpolation uses $variable and ${expr}. The substring variants — substring, substringBefore, substringAfter — support both raw indices and delimiter-based extraction. replace supports literal, regex, and range-based replacement, and split accepts an optional limit that caps the number of parts returned. For efficient construction in loops, buildString { append(...) } creates a StringBuilder internally and returns the toString() result, which is preferable to repeated + concatenation; StringBuilder is the underlying type and only worth reaching for directly when reusing instances for performance. isLetterOrDigit, isDigit, isLetter, isUpperCase, and isWhitespace give readable character classification. Multi-line raw strings delimited by triple quotes span multiple lines and skip escape processing (escape a literal $ with \$); trimIndent() and trimMargin() handle indentation. someNullable?.toString() yields null safely for null receivers, and CharSequence is the interface implemented by String, StringBuilder, and StringBuffer — accept it in library functions for flexibility. Regex is constructed with Regex("pattern") or "pattern".toRegex(), used via containsMatchIn, find, findAll, and replace; the find function returns a MatchResult? whose groups you can access via groupValues or destructure with val (user, domain) = match.destructured. Error handling has no checked exceptions: try, catch, and even if are expressions, val n = try { s.toInt() } catch (e: Exception) { -1 } returns an Int. The runCatching { ... } helper wraps a block in try/catch and returns a Result<T>, on which you can chain onSuccess, onFailure, getOrNull, getOrElse, or getOrThrow — but note that runCatching catches Throwable including Error, so prefer typed try/catch for specific exceptions. toIntOrNull() safely converts strings to Int by returning null on parse failure.

Coroutines and Flow

Coroutines are Kotlin's framework for writing asynchronous code that looks synchronous. A coroutine is a lightweight, cooperatively scheduled unit of work that can be paused and resumed at suspension points without blocking the underlying thread — so thousands of coroutines can share a small thread pool efficiently. Functions that participate in this model are declared with the suspend keyword and can only be called from another suspend function or from a coroutine builder. dispatchers determine which thread or thread pool a coroutine runs on: Dispatchers.Main for UI work, Dispatchers.IO for blocking I/O, Dispatchers.Default for CPU-bound computation, and Dispatchers.Unconfined when the dispatcher should be inherited from the calling context. The withName property on the standard dispatchers adds a label that surfaces in coroutine stack traces and thread dumps, making debugging easier.

Two builder functions cover most needs. launch starts a coroutine that returns a Job — useful for fire-and-forget work without a result. async starts a coroutine that returns a Deferred<T>, on which you call await() to retrieve the value, and is the natural choice when you need a return value. withContext(dispatcher) { ... } switches the dispatcher for the duration of the block and returns to the original context on completion — the idiomatic way to move work off the main thread. A Job is the handle to a launched coroutine, exposing cancel(), join(), isActive, and isCompleted, while a Deferred<T> extends Job with the await() method.

Structured concurrency means coroutines are scoped to a parent and cannot outlive it. The coroutineScope { ... } builder creates a child scope in which all launched children must complete before the block returns; if any child fails, its siblings are cancelled and the failure is propagated. supervisorScope { ... } differs in that child failures are isolated — siblings continue, and the parent only fails if you explicitly await the failed child. A SupervisorJob as the parent (used by default in viewModelScope and GlobalScope in many setups) prevents sibling cancellation on failure, ideal for fire-and-forget tasks where one failure should not bring down the rest. viewModelScope ties coroutines to the ViewModel's lifecycle, automatically cancelling them on onCleared() to prevent leaks.

Cancellation in coroutines is cooperative. Job.cancel() sends a signal that propagates to the next suspension point, where the coroutine throws a CancellationException that the coroutine machinery treats as normal completion. To honor cancellation properly, suspending functions should call ensureActive() or other cancellable APIs. withContext(NonCancellable) { ... } runs work that ignores cancellation signals, useful for cleanup that must finish before the coroutine truly ends — but should be used sparingly. Finally, Flow is Kotlin's cold streaming counterpart to suspend functions. A suspend fun returns a single value, whereas a Flow emits many values over time, supports operators like map, filter, collect, combine, and debounce, and runs lazily — emitting only when a collector subscribes. Cold flows built with flow { ... } run a fresh producer for each subscriber, giving every collector its own stream. Hot flows like StateFlow, SharedFlow, and Channel emit whether or not anyone is collecting, sharing values among subscribers and behaving like event buses. Together, suspend functions, builders, and Flow form a complete model for handling asynchronous work — from single network calls to reactive UI pipelines — while keeping the calling code readable.

Frequently asked questions

What is null safety in Kotlin?

Null safety is a Kotlin feature that eliminates NullPointerException at compile time.
By default, variables cannot hold null. You must explicitly declare a type as nullable using ?, e.g. var name: String? = null.

What does the inline keyword do for higher-order functions?

The inline keyword tells the compiler to copy the function body and the lambda body at the call site, avoiding the overhead of creating a function object:
inline fun execute(action: () -> Unit) { action() }
This improves performance for small lambdas.

How does the lazy delegate work in Kotlin?

lazy initializes a property on first access and caches the result:
val heavy: String by lazy { expensiveComputation() }
By default it is thread-safe (LazyThreadSafetyMode.SYNCHRONIZED).

When does the right-hand side of the Elvis operator get evaluated?

Only when the left-hand side expression is null. If the left side is non-null, the right side is not evaluated, which makes ?: useful for default values and lazy fallbacks.

How does operator overloading for comparison work?

Override compareTo:
operator fun compareTo(other: Version) = this.major.compareTo(other.major)
Then <, <=, >, >=, and sort/sorted use it automatically.

What is a companion object's relationship to its class?

A companion object is initialized when the outer class is loaded, and its members can be accessed as if they were static. Each class can have at most one companion object.

What does the windowed function do?

windowed(size, step, partialWindows) produces overlapping or stepped slices of a collection:
(1..5).toList().windowed(3)[[1,2,3], [2,3,4], [3,4,5]]
Add step to skip elements, partialWindows = true to keep short tails.

How do you make a Kotlin class act as a builder?

Return this (or apply) from setter-like methods:
class Query { var limit = 10; fun limit(v: Int) = apply { limit = v } }
Callers write Query().limit(5).select("*").

How does Kotlin handle checked exceptions?

Kotlin has no checked exceptions. You can throw and catch any exception without declaring it. The compiler will not force callers of a function to catch or rethrow IOException, SQLException, etc.

What is the difference between CharSequence and String?

String is a concrete, immutable sequence of Char. CharSequence is the interface implemented by String, StringBuilder, and StringBuffer. Library functions that only need to read chars should accept CharSequence for flexibility.

Drill this topic

170 flashcards on Kotlin Programming — free, no signup needed to start.

Study Kotlin Programming flashcards

LearnWiki pages are generated with AI assistance from LearnCoachAssist's reviewed study catalog and may contain errors — verify anything critical against your course materials.