Classes, Objects, and Interfaces

Kotlin

2026-08-21 09:00

Where we are

From calling to declaring

The previous unit was about the calling side.

This one is about declaring the types you call things on.

Same capabilities as Java, less ceremony, plus a few things Java cannot do.

Two defaults are reversed

Classes and methods are final unless marked open.

Nested classes hold no outer reference unless marked inner.

Kotlin picks the safer option, and makes you ask for the risk.

What you will be able to do

  1. Declare interfaces with default implementations.
  2. Explain why classes are final by default.
  3. Use the visibility modifiers, including internal.
  4. Distinguish nested from inner classes.
  5. Use sealed classes for exhaustive when.
  6. Write primary and secondary constructors.
  7. Implement interface properties and use field.
  8. Use data classes and by delegation.
  9. Use object declarations and expressions.
  10. Use companion objects instead of static.

Interfaces

Bodies with no keyword

interface Clickable {
    fun click()                                   // abstract
    fun showOff() = println("I'm clickable!")     // has a body
}
class Button : Clickable {
    override fun click() = println("I was clicked")
}

One colon covers extends and implements.

override is mandatory

Where Java’s @Override is optional.

Not pedantry: it prevents accidental override.

You add a method, unknowingly override an inherited one you had forgotten. Java compiles that silently.

The diamond problem

class Button : Clickable, Focusable {
    override fun showOff() {          // required
        super<Clickable>.showOff()
        super<Focusable>.showOff()
    }
}

The compiler refuses to guess.

Any arbitrary tie-break would silently pick behaviour you did not intend.

Final by default

Java’s default

Every class is open to inheritance unless marked final.

Kotlin reverses it.

open, and again per member

open class RichButton : Clickable {
    fun disable() {}          // final
    open fun animate() {}     // may be overridden
    override fun click() {}   // open by default
}

Which is which

The fragile base class problem

A subclass overrides methods in ways the base author never anticipated,

relying on how the base calls its own methods.

A later, entirely reasonable change to the base breaks the subclass.

The classic example

A HashSet subclass counting additions.

Overrides add and addAll. Double-counts.

Because addAll happens to call add. Nothing in the contract said so.

Effective Java’s advice, enforced

Design and document for inheritance, or else prohibit it.

Making final the default means you must decide

which is exactly the moment you would also document how.

Visibility

Four modifiers

member top-level
public (default) everywhere everywhere
internal the module the module
protected subclasses
private the class the file

Three differences from Java

public is the default. If you did not restrict it, you did not intend to.

No package-private. internal replaces it — and Java’s could be defeated by declaring the same package name from another jar.

protected means subclasses only. Not the same package.

Nested and inner classes

The second reversal

class Outer {
    class Nested { }            // no outer reference
    inner class Inner {
        fun outer(): Outer = this@Outer
    }
}

Kotlin makes you ask

Why the reversal matters

Serialization fails — and names the outer class, not where you were looking.

Memory leaks — the outer object stays alive as long as the nested one does.

Classic in long-lived callbacks and listeners.

Sealed classes

The problem, from unit 2

fun eval(e: Expr): Int =
    when (e) {
        is Num -> e.value
        is Sum -> eval(e.right) + eval(e.left)
        else -> throw IllegalArgumentException(...)   // required
    }

Anyone can implement Expr, so the compiler demands an else.

And that else hides a bug

Add a Mul class.

Forget to handle it.

It falls silently into else and throws at runtime, in production.

sealed

sealed class Expr {
    class Num(val value: Int) : Expr()
    class Sum(val left: Expr, val right: Expr) : Expr()
}

The hierarchy is closed

Two things follow

A when covering all of them needs no else.

Adding a subclass makes every such when fail to compile.

The compiler becomes a checklist of what must be updated.

Constructors

The header does the work

class User(val nickname: String,
           val isSubscribed: Boolean = true)

val alice = User("Alice")
val carol = User("Carol", isSubscribed = false)

val or var on a parameter declares a property.

init blocks

class User(_nickname: String) {
    val nickname: String
    init { nickname = _nickname }
}

The primary constructor has no body of its own.

Superclass, in the header

open class View(val id: Int)
class Button(id: Int) : View(id)

A superclass has parentheses. An interface does not.

You construct one and merely implement the other.

Secondary constructors

Delegating with this(…)

But prefer defaults

Most Java overload families collapse into

one primary constructor with default values.

Reach for secondary constructors mainly at a Java boundary.

Properties and backing fields

Interfaces specify what, not how

interface User { val nickname: String }

class PrivateUser(override val nickname: String) : User          // stored
class SubscribingUser(val email: String) : User {
    override val nickname get() = email.substringBefore('@')     // computed
}
class FacebookUser(val id: Int) : User {
    override val nickname = getFacebookName(id)                  // once
}

The interface cannot tell, and does not need to.

The field identifier

var address: String = "unspecified"
    set(value: String) {
        println("Address changed: \"$field\" -> \"$value\"")
        field = value                     // the actual store
    }

The only way to reach the backing field. There is no other name for it.

When does a field exist?

Only if an accessor references field, or uses the default.

A computed property stores nothing.

Which is why an extension property must always be computed — nowhere to put one.

Accessor visibility

var counter: Int = 0
    private set

Public getter, private setter. Replaces five lines of Java pattern.

Data classes

The bug this prevents

Override equals, forget hashCode.

The object vanishes inside a HashSet.

Works perfectly until someone uses it as a map key.

One keyword

data class Client(val name: String, val postalCode: Int)

equals · hashCode · toString · copy · componentN

And equals and hashCode are consistent by construction.

Why copy matters

Data classes want val properties — immutable instances.

Change then means a new object.

val bob = alice.copy(name = "Bob")

Class delegation

The Java decorator

To modify one method of an interface,

implement the whole interface and forward everything else by hand.

by

class CountingSet<T>(
        val innerSet: MutableCollection<T> = HashSet<T>()
) : MutableCollection<T> by innerSet {

    var objectsAdded = 0

    override fun add(element: T): Boolean {
        objectsAdded++
        return innerSet.add(element)
    }
}

Notice what this also fixes

Overriding both add and addAll here is not redundant.

CountingSet does not rely on addAll calling add — it delegates.

The fragile base class problem, avoided by composition. One keyword.

The object keyword

Three jobs, one idea

Declare a class and create an instance in a single step.

object declaration · companion object · object expression

Singletons

object CaseInsensitiveFileComparator : Comparator<File> {
    override fun compare(f1: File, f2: File): Int =
        f1.path.compareTo(f2.path, ignoreCase = true)
}

Everything a class can have, except a constructor.

The caution

A singleton with mutable state is a global variable wearing a hat.

Interfering tests, ordering dependencies, no substitution.

Stateless singletons are fine. Stateful ones deserve a hard look.

There is no static

Companion objects

class User private constructor(val nickname: String) {
    companion object {
        fun newSubscribingUser(email: String) =
            User(email.substringBefore('@'))
        fun newFacebookUser(accountId: Int) =
            User(getFacebookName(accountId))
    }
}

It can reach the class’s private members — including a private constructor.

Three things static cannot do

  • be given a name
  • implement an interface, so the class can be passed as one
  • be extended with extension functions

That last one: a persistence layer can add Person.Companion.fromJSON

without Person knowing anything about persistence.

Object expressions

window.addMouseListener(
    object : MouseAdapter() {
        override fun mouseClicked(e: MouseEvent) { }
        override fun mouseEntered(e: MouseEvent) { }
    }
)

May implement several interfaces. May modify enclosing locals.

New instance each time — it is an expression, not a declaration.

But for one method

Use a lambda.

Which is the next unit.

Summary

The six things to carry away

  • Kotlin reverses Java’s risky defaults and makes you ask for the risk.
  • The fragile base class problem is the argument for final-by-default.
  • Sealed classes turn a future runtime bug into a compile error today.
  • data and by generate the code you would otherwise write badly.
  • object does three jobs; the companion is the interesting one.
  • Interfaces specify what, not how.

Where next

Programming with Lambdas begins the functional half.

Lambda syntax, the collection APIs, lazy sequences, Java interop.

And lambdas with receivers — the feature the final DSL unit depends on entirely.