01 · OOP Deep Dive¶
Level 1 covered classes, objects, and data classes at a basic level. Real
Kotlin codebases lean much harder on a handful of OOP idioms that don't exist
in the same form in Java: interfaces with real implementations, sealed
hierarchies that the compiler can exhaustively check, and object for
singletons. This module goes deep on those, plus the inheritance rules and
traps that show up when combining them with data classes.
Interfaces with implementation¶
Kotlin interfaces aren't just method signatures — they can have method bodies and even properties (as long as the property has no backing field).
interface Greeter {
val greeting: String // abstract property -- must be overridden
fun greet(name: String): String = "$greeting, $name!" // default implementation
}
class EnglishGreeter : Greeter {
override val greeting = "Hello"
// greet() is inherited as-is -- no need to override it
}
class PirateGreeter : Greeter {
override val greeting = "Ahoy"
override fun greet(name: String) = "${greeting.uppercase()}, $name, ye scallywag!"
}
fun main() {
println(EnglishGreeter().greet("Alice")) // Hello, Alice!
println(PirateGreeter().greet("Bob")) // AHOY, Bob, ye scallywag!
}
A class can implement multiple interfaces. If two of them provide the same
default method, Kotlin forces you to resolve the ambiguity explicitly with
super<InterfaceName> — it won't silently pick one for you.
interface A { fun greet() = println("Hello from A") }
interface B { fun greet() = println("Hello from B") }
class C : A, B {
override fun greet() {
super<A>.greet() // explicitly call A's version
super<B>.greet() // and B's version
println("...and hello from C")
}
}
fun main() = C().greet()
Abstract classes vs. interfaces¶
Both let you define a contract with optional shared implementation, so when do you reach for which?
interface |
abstract class |
|
|---|---|---|
| Multiple inheritance | A class can implement many | A class can extend only one |
| Constructor / state | No constructor, no backing-field state | Full constructor, can hold real state |
| Default methods | Yes | Yes |
| Use when | Defining a capability/role (Comparable, Greeter) |
Modeling a shared base with real fields and invariants |
Inheritance: open, override, and visibility¶
Classes and members are final (non-inheritable/non-overridable) by
default in Kotlin — the opposite of Java. You must opt in with open.
open class Animal(val name: String) {
open fun sound(): String = "..."
fun describe(): String = "$name says ${sound()}" // not open -- can't be overridden
}
class Dog(name: String) : Animal(name) {
override fun sound(): String = "Woof"
}
class Cat(name: String) : Animal(name) {
override fun sound(): String = "Meow"
}
fun main() {
val animals: List<Animal> = listOf(Dog("Rex"), Cat("Whiskers"))
for (animal in animals) {
println(animal.describe()) // dynamic dispatch -- calls the right sound()
}
}
protected members are visible to subclasses but not to outside code —
useful for internal hooks a subclass needs but callers shouldn't touch:
open class Account(private val owner: String) {
protected var balance: Double = 0.0
fun deposit(amount: Double) {
balance += amount
}
}
class LoggingAccount(owner: String) : Account(owner) {
fun depositAndReport(amount: Double) {
deposit(amount)
println("Balance is now $balance") // OK -- protected is visible to subclasses
}
}
Sealed classes and interfaces¶
A sealed type restricts its subtypes to those declared in the same
package/module — the compiler knows the complete set of possibilities,
so a when over a sealed type doesn't need an else branch to be
exhaustive. This is Kotlin's answer to modeling "one of a fixed set of
cases" more safely than an enum (each case can carry different data).
sealed class NetworkResult
data class Success(val body: String) : NetworkResult()
data class Failure(val code: Int, val message: String) : NetworkResult()
object Loading : NetworkResult()
fun describe(result: NetworkResult): String = when (result) {
is Success -> "Got: ${result.body}"
is Failure -> "Error ${result.code}: ${result.message}"
Loading -> "Still loading..."
// no `else` needed -- the compiler knows these are the only three subtypes
}
fun main() {
println(describe(Success("hello")))
println(describe(Failure(404, "Not Found")))
println(describe(Loading))
}
Exhaustiveness is a feature, not a formality
If you later add a fourth subtype of NetworkResult, every when that
matches on it without an else will fail to compile until you handle
the new case. That's deliberate — it turns "I forgot to handle a new
state" from a runtime bug into a compile error. Adding a stray else ->
"just in case" throws this safety net away.
A sealed interface works the same way and is handy when your cases don't
share a common base implementation:
sealed interface Shape
data class Circle(val radius: Double) : Shape
data class Rectangle(val width: Double, val height: Double) : Shape
fun area(shape: Shape): Double = when (shape) {
is Circle -> Math.PI * shape.radius * shape.radius
is Rectangle -> shape.width * shape.height
}
object declarations: singletons for free¶
object declares a class and its single instance at the same time — no
getInstance() boilerplate, no double-checked locking.
object AppConfig {
var environment: String = "development"
val version: String = "1.0.0"
}
fun main() {
AppConfig.environment = "production"
println("${AppConfig.version} running in ${AppConfig.environment}")
// There is only ever one AppConfig -- accessing it anywhere gives the same instance
}
companion object attaches object-like (static-ish) members to a class,
commonly used for factory functions:
class User private constructor(val name: String, val email: String) {
companion object {
fun fromEmail(email: String): User {
val name = email.substringBefore("@").replaceFirstChar { it.uppercase() }
return User(name, email)
}
}
}
fun main() {
val user = User.fromEmail("alice@example.com")
println("${user.name} <${user.email}>") // Alice <alice@example.com>
}
Trap: data classes and inheritance don't mix well¶
A data class cannot extend another data class, and if it extends a
regular open class, the generated equals()/hashCode()/toString() only
consider properties declared in the data class's own primary constructor
— parent-class properties are silently excluded.
open class Entity(val id: Int)
data class Product(val name: String) : Entity(1)
fun main() {
val p1 = Product("Widget")
val p2 = Product("Widget")
println(p1 == p2) // true -- only `name` is compared, `id` from Entity is ignored!
}
This surprises people who expect id to matter. If identity fields live in
a base class, prefer composition (a regular property) over inheritance for
data classes, or override equals()/hashCode() yourself.
How It Actually Works¶
Interfaces with default method bodies compile using the exact same JVM
mechanism Java 8+ interfaces use: default methods in the interface's
bytecode. interface Greeter { fun greet(...) = ... } produces a real
.class file with greet marked default in its bytecode; a class that
doesn't override it simply inherits that default method the way any JVM
class inherits a default interface method — no synthetic delegation classes
involved. Abstract properties in interfaces, though, are a Kotlin-only
concept the JVM doesn't have: val greeting: String in the interface
compiles to an abstract getGreeting() method declaration, and each
implementing class is required to supply a concrete getter (here, backed by
a real field since EnglishGreeter and PirateGreeter each declare
override val greeting = "...", which does allocate a backing field on the
implementing class, not the interface — interfaces themselves can never
hold instance fields on the JVM).
The super<A>.greet() diamond-resolution syntax is a compile-time-only
requirement — the compiler detects that C inherits two default
implementations of the same signature and refuses to compile unless C
overrides greet() itself, calling out which parent's version it means via
invokespecial (the same bytecode instruction Java's A.super.greet()
syntax compiles to). Without an explicit override, the JVM's own default-method
conflict-resolution rules would apply, which Kotlin considers too implicit
to allow silently.
open-by-default's inversion of Java matters because it's not just a style
choice — it's checked and enforced by the bytecode verifier. A non-open
Kotlin class compiles with the ACC_FINAL flag set on the class file, and a
non-open method compiles as a final method — meaning the JVM itself
would refuse to load a subclass that tried to override it, not just the
Kotlin compiler. describe() calling sound() on Animal compiles to a
regular invokevirtual, which is what makes it dispatch to Dog's override
at runtime through the normal JVM vtable lookup — the same polymorphism
mechanism as Java, just gated behind an opt-in keyword instead of being the
default.
Cheat sheet¶
| Concept | Keyword | Key trait |
|---|---|---|
| Capability contract | interface |
Multiple per class, default methods, no state |
| Shared base with state | abstract class |
Single per class, can hold fields |
| Fixed set of subtypes | sealed class / sealed interface |
Exhaustive when, no else needed |
| Singleton | object |
Exactly one instance, lazily created |
| Static-like members | companion object |
Attached to the class, not an instance |
| Opt-in overriding | open / override |
Classes/members are final unless marked open |
Exercise¶
Model a sealed class PaymentMethod with three subtypes: data class
CreditCard(val last4: String), data class PayPal(val email: String), and
object Cash. Write a function processPayment(method: PaymentMethod):
String that uses an exhaustive when (no else) to return a description
for each case. Then add a fourth subtype and watch every when that
handled PaymentMethod exhaustively fail to compile until you add a branch
for it.