Lecture notes — Classes, Objects, and Interfaces
ver. 1.0.0
← Classes, Objects, and Interfaces
Where we are
The previous unit was about the calling side of functions. This one is about declaring the types you call things on, and it follows the same pattern: the same capabilities as Java, with markedly less ceremony, plus a few things Java cannot do at all.
Two defaults are reversed from Java, and both are deliberate safety choices worth flagging before we start:
- classes and methods are final unless marked
open - nested classes do not hold a reference to the outer instance unless marked
inner
In each case Kotlin picks the safer option as the default and requires a keyword to opt into the riskier one. That is unit 1’s “safe” commitment showing up in the declaration syntax, and it is the theme to watch through the first half of the unit.
What you will be able to do
declare-interfaces— Declare interfaces with abstract and default method implementations.explain-final-by-default— Explain why Kotlin classes are final by default and use open where inheritance is intended.use-visibility-modifiers— Use Kotlin’s visibility modifiers, including internal, and say how they map to Java.distinguish-nested-and-inner-classes— Distinguish a nested class from an inner class and choose the right one.use-sealed-classes— Use a sealed class to make a when exhaustive without an else branch.write-constructors— Write primary and secondary constructors and initializer blocks.implement-properties-and-backing-fields— Implement interface properties and access a property’s backing field.use-data-classes-and-delegation— Use data classes for value objects and by for class delegation.use-object-declarations— Use object declarations for singletons and object expressions for anonymous objects.use-companion-objects— Use companion objects in place of static members, and give them names and interfaces.
What we will cover
- Class hierarchies — interfaces,
open/final/abstract, visibility, nesting, sealed classes. - Constructors — primary, secondary, and
initblocks. - Properties — interface properties, backing fields, accessor visibility.
- What the compiler generates — data classes, and delegation with
by. - The
objectkeyword — singletons, companion objects, anonymous objects.
Interfaces
A Kotlin interface may contain abstract methods and methods with implementations, and no keyword like Java’s default is required:
interface Clickable {
fun click() // abstract
fun showOff() = println("I'm clickable!") // has a body
}Implementing one uses a single colon, which covers both extends and implements:
class Button : Clickable {
override fun click() = println("I was clicked")
}override is mandatory
Kotlin requires the override modifier where Java’s @Override is optional.
This is not pedantry. It prevents accidental override: you add a method to a class, and unknowingly override an inherited one whose existence you had forgotten. In Java that compiles silently and changes behaviour. In Kotlin it does not compile.
The diamond problem
If a class implements two interfaces that both supply a default for the same method, the compiler refuses to guess:
interface Clickable {
fun showOff() = println("I'm clickable!")
}
interface Focusable {
fun showOff() = println("I'm focusable!")
}
class Button : Clickable, Focusable {
override fun showOff() { // required — the compiler will not choose
super<Clickable>.showOff()
super<Focusable>.showOff()
}
}super<Type>.method() selects a specific inherited version, so you may call one, the other, or both.
Forcing the choice is the right call. Any arbitrary rule for picking a winner would silently select behaviour you did not intend.
Java 6 has no default methods, so Kotlin compiles an interface with bodies into an interface plus a static class holding the implementations. Java classes implementing that interface must supply their own bodies.
Another instance of the pattern from the previous unit: a cleaner Kotlin model, with whatever Java needs generated underneath.
Learning outcomes
- declare-interfaces: Declare interfaces with abstract and default method implementations.
Concepts
- interfaces-in-kotlin: default implementations with no keyword, mandatory
override, and explicit conflict resolution
Final by default
In Java every class is open to inheritance unless marked final. Kotlin reverses this:
open class RichButton : Clickable { // open — may be subclassed
fun disable() {} // final — cannot be overridden
open fun animate() {} // open — may be overridden
override fun click() {} // overriding member — open by default
}
open, which are final, and which are overrides.The open modifier is required on the class and again on each member you intend to be overridable. An overriden member is itself open unless you write final override.
Why
Worth understanding rather than accepting on authority. The fragile base class problem:
A subclass overrides methods in ways the base class author never anticipated, relying on details of how the base class calls its own methods. A later change to the base — entirely reasonable in itself — breaks the subclass.
The classic example is a HashSet subclass that counts additions by overriding add and addAll, and double-counts because addAll happens to call add internally. Nothing in the base class’s contract said it would.
Kotlin follows the advice from Effective Java: design and document for inheritance, or else prohibit it. Making final the default means you must consciously decide a class is meant to be extended — which is exactly the moment you would also document how.
abstract classes are open by definition, and abstract members are open without needing the keyword:
abstract class Animated {
abstract fun animate() // must be overridden
open fun stopAnimating() {} // may be overridden
fun animateTwice() {} // may not
}Learning outcomes
- explain-final-by-default: Explain why Kotlin classes are final by default and use open where inheritance is intended.
Concepts
- open-and-final-modifiers:
finalunlessopen, because of the fragile base class problem
Visibility modifiers
| Modifier | Class member | Top-level declaration |
|---|---|---|
public (default) |
visible everywhere | visible everywhere |
internal |
visible in the module | visible in the module |
protected |
visible in subclasses | not applicable |
private |
visible in the class | visible in the file |
Three differences from Java are worth naming:
public is the default, not package-private. Kotlin’s position is that if you did not restrict it, you did not intend to.
There is no package-private, and internal replaces it. This is a genuine improvement rather than a rename. Java’s package-private can be defeated by declaring a class with the same package name from another jar, so it never really guaranteed anything. internal means visible inside the module — a set of files compiled together — and the compiler enforces it at the module boundary.
protected means subclasses only, not the same package. Java’s version of protected quietly includes package access, which surprises people who expected it to mean what its name says.
And a top-level declaration may be private, meaning visible only within its own file — the right visibility for a helper that supports one file’s public API.
Learning outcomes
- use-visibility-modifiers: Use Kotlin’s visibility modifiers, including internal, and say how they map to Java.
Concepts
- visibility-modifiers:
internalas an enforceable replacement for package-private
Nested and inner classes
The second reversed default.

inner class does.class Outer {
class Nested { } // no reference to Outer — like Java's `static class`
inner class Inner { // holds a reference to Outer
fun outer(): Outer = this@Outer
}
}In Java, a nested class is inner by default and holds a hidden reference to the enclosing instance unless declared static. Kotlin makes the safe case the default and requires inner to opt in.
Why the reversal matters
The book’s example is serialization. A nested class that accidentally holds a reference to a non-serializable outer instance fails to serialize — with an error naming the outer class, which is not where you were looking.
The same hidden reference keeps the outer object alive as long as the nested one lives, which is a classic memory leak in long-lived callbacks and listeners.
Reaching the outer instance from an inner class needs the qualified this@Outer syntax — verbose enough that you notice you are relying on it.
Learning outcomes
- distinguish-nested-and-inner-classes: Distinguish a nested class from an inner class and choose the right one.
Concepts
- nested-and-inner-classes: no outer reference unless you ask for one, because the hidden one causes real bugs
Sealed classes
The problem comes from the expression evaluator of unit 2:
interface Expr
class Num(val value: Int) : Expr
class Sum(val left: Expr, val right: Expr) : Expr
fun eval(e: Expr): Int =
when (e) {
is Num -> e.value
is Sum -> eval(e.right) + eval(e.left)
else -> throw IllegalArgumentException("Unknown expression") // required
}The else is required because the compiler cannot know the hierarchy is complete — anyone can implement Expr.
And that else is where a bug hides. Add a Mul class, forget to handle it, and it silently falls into else and throws at runtime, in production, on an input nobody tested.
The fix

sealed class Expr {
class Num(val value: Int) : Expr()
class Sum(val left: Expr, val right: Expr) : Expr()
}
fun eval(e: Expr): Int =
when (e) {
is Expr.Num -> e.value
is Expr.Sum -> eval(e.right) + eval(e.left)
// no else — the compiler knows this is exhaustive
}A sealed class restricts subclassing so the compiler knows every subclass. Two things follow:
- a
whencovering all of them needs noelse - adding a new subclass makes every such
whenfail to compile
The compiler becomes a checklist of every place that must be updated when the hierarchy grows.
That is the same trade Kotlin makes with nullability: a runtime bug converted into a compile error. And it is why sealed plus when is the standard way to model a closed set of alternatives — results, states, parse nodes — rather than an enum with an attached payload.
Learning outcomes
- use-sealed-classes: Use a sealed class to make a when exhaustive without an else branch.
Concepts
- sealed-classes: a hierarchy the compiler knows completely, so it can check your
whenfor you
Constructors
The primary constructor goes in the class header, and this is where most of the boilerplate goes:
class User(val nickname: String)Writing val or var on a constructor parameter declares a property initialised from it. With default values, a class often needs nothing else:
class User(val nickname: String,
val isSubscribed: Boolean = true)
val alice = User("Alice")
val bob = User("Bob", false)
val carol = User("Carol", isSubscribed = false)init blocks
The primary constructor has no body of its own, so initialisation logic goes in an init block:
class User(_nickname: String) {
val nickname: String
init {
nickname = _nickname
}
}There may be several, running in declaration order, interleaved with property initialisers.
Calling a superclass constructor
Also in the header, by putting arguments after the superclass name:
open class View(val id: Int)
class Button(id: Int) : View(id)Note the syntactic detail that catches people: a superclass is written with parentheses, an interface without — because you construct the one and merely implement the other.
Making construction private is done with private constructor, which is how you force callers through a factory function.
Secondary constructors

Declared in the body with the constructor keyword, for classes that genuinely need several ways to be created — most often when extending a Java class with multiple constructors:
class MyButton : View {
constructor(ctx: Context) : super(ctx) { }
constructor(ctx: Context, attr: AttributeSet) : super(ctx, attr) { }
}They may also delegate to one another:

this(...).class MyButton : View {
constructor(ctx: Context) : this(ctx, MY_STYLE) { }
constructor(ctx: Context, attr: AttributeSet) : super(ctx, attr) { }
}Every path must eventually reach the primary constructor if there is one.
Most Java overload families collapse into a single primary constructor with default parameter values. Reach for secondary constructors only when the different ways of constructing genuinely do different work — typically at a Java boundary.
That is the previous unit’s lesson about overloads, applied to construction.
Learning outcomes
- write-constructors: Write primary and secondary constructors and initializer blocks.
Concepts
- primary-and-secondary-constructors: the header declares properties and runs
init; secondary constructors delegate inward
Properties and backing fields
Properties in interfaces
An interface may declare a property with no implementation, obliging implementers to provide one — and leaving how entirely to them:
interface User {
val nickname: String
}
class PrivateUser(override val nickname: String) : User // stored
class SubscribingUser(val email: String) : User {
override val nickname: String
get() = email.substringBefore('@') // computed
}
class FacebookUser(val accountId: Int) : User {
override val nickname = getFacebookName(accountId) // stored, initialised once
}All three satisfy the same declaration. That is the point: the interface specifies what is available, not how it is held. Note the third case computes once at construction, while the second recomputes on every access — the interface cannot tell, and does not need to.
An interface property may also carry its own custom getter, which implementers inherit.
The backing field
Inside an accessor, the special identifier field refers to the property’s backing field:
class User(val name: String) {
var address: String = "unspecified"
set(value: String) {
println("""
Address was changed for $name:
"$field" -> "$value".""".trimIndent())
field = value // the actual store
}
}This is what lets a setter do work and still store the value. Reading field in the getter and writing it in the setter are the only ways to reach it — there is no other name for it.
The rule is precise and worth remembering:
A backing field is generated only if at least one accessor references
field, or uses the default implementation.
A property whose getter computes from other state has no field at all. Nothing is stored, nothing is allocated.
Which explains something from the previous unit: an extension property must always be computed, because it is declared outside the class and there is nowhere for a field to go.
Accessor visibility
A getter and setter may have different visibility. The common case is publicly readable, privately writable:
class LengthCounter {
var counter: Int = 0
private set // only this class may change it
fun addWord(word: String) {
counter += word.length
}
}This replaces the Java pattern of a public getter, no setter, and mutation through methods — with the same guarantee and one line instead of five.
Learning outcomes
- implement-properties-and-backing-fields: Implement interface properties and access a property’s backing field.
- use-visibility-modifiers: Use Kotlin’s visibility modifiers, including internal, and say how they map to Java.
Concepts
- backing-fields-and-accessors:
fieldinside an accessor, and the rule that decides whether a field exists at all
Data classes
Every Java class inherits toString, equals and hashCode from Object, and a value class must override all three.
Override equals and forget hashCode, and the object vanishes inside a HashSet.
Two equal objects with different hash codes land in different buckets, so a lookup checks the wrong one and reports the object is not there. The class works perfectly until the day someone puts it in a set or uses it as a map key.
This is not a rare mistake. It is one of the most common bugs in Java code, and it is entirely mechanical to prevent.
Adding data generates all of it from the primary constructor properties:
data class Client(val name: String, val postalCode: Int)The compiler produces:
equalsandhashCode, consistent with each other by constructiontoStringin a readable form —Client(name=Alice, postalCode=342562)copy, which creates a modified duplicate- the
componentNfunctions that make destructuring work
Why copy matters more than it looks
Data classes are best used with val properties, making instances immutable — which brings the benefits unit 1 promised: safe as map keys, safe across threads, no defensive copying.
But immutability means change requires a new object, and that is tedious if you must repeat every unchanged field. copy names only what differs:
val bob = alice.copy(name = "Bob")A data class used as a map key must be immutable for a specific reason: if a key object is mutated after insertion, its hash code changes, and it is now in the wrong bucket. It cannot be found, and it cannot be removed.
val properties make this impossible rather than merely discouraged.
Concepts
- data-classes:
equals,hashCode,toString,copyandcomponentN, generated consistently
Class delegation
The decorator pattern in Java is painful. To modify one method of an interface you must implement the whole interface and forward every other method by hand, and the forwarding grows with the interface.
The by keyword removes it:
class CountingSet<T>(
val innerSet: MutableCollection<T> = HashSet<T>()
) : MutableCollection<T> by innerSet { // forward everything to innerSet
var objectsAdded = 0
override fun add(element: T): Boolean { // except this
objectsAdded++
return innerSet.add(element)
}
override fun addAll(c: Collection<T>): Boolean { // and this
objectsAdded += c.size
return innerSet.addAll(c)
}
}Declare that the class implements the interface by another object, and the compiler generates all the forwarding. You override only what you actually want to change.
The class now contains only what is genuinely new — two counters and two overrides — which is the whole idea of the decorator, finally expressed without the noise.
Overriding both add and addAll here is not redundant: CountingSet explicitly does not rely on addAll calling add, because it delegates rather than inherits.
That is the fragile base class problem from earlier in this unit, avoided by composition. Delegation gives you the decorator’s flexibility with none of the inheritance coupling — and now it costs one keyword instead of forty forwarding methods.
Learning outcomes
- use-data-classes-and-delegation: Use data classes for value objects and by for class delegation.
Concepts
- class-delegation:
bygenerates the forwarding, so a decorator contains only what is new
The object keyword
One keyword, three jobs. The unifying idea: each declares a class and creates an instance in a single step.
Object declarations: singletons
object Payroll {
val allEmployees = arrayListOf<Person>()
fun calculateSalary() {
for (person in allEmployees) { }
}
}
Payroll.allEmployees.add(Person("Alice"))An object declaration may have properties, methods, initialisers, and may implement interfaces — everything a class can have except a constructor, since it takes no arguments.
The good case is a stateless implementation of an interface:
object CaseInsensitiveFileComparator : Comparator<File> {
override fun compare(file1: File, file2: File): Int =
file1.path.compareTo(file2.path, ignoreCase = true)
}One instance suffices because there is nothing to distinguish two of them.
A singleton with mutable state is a global variable wearing a hat.
It brings every difficulty globals bring: tests that interfere with each other, ordering dependencies between unrelated code, and no way to substitute a different implementation. Payroll above is exactly this, and the book uses it to introduce the syntax rather than to recommend the design.
Stateless singletons are fine. Stateful ones deserve a hard look.
Companion objects: there is no static
Kotlin has no static keyword. Its replacement is a companion object — an object declaration inside a class, marked companion, whose members are called through the class name:
class A {
companion object {
fun bar() = println("Companion object called")
}
}
A.bar()
The crucial property: a companion object can access the class’s private members, including a private constructor. That makes it the right home for factory functions:
class User private constructor(val nickname: String) {
companion object {
fun newSubscribingUser(email: String) =
User(email.substringBefore('@'))
fun newFacebookUser(accountId: Int) =
User(getFacebookName(accountId))
}
}Two ways of constructing a user, each with a meaningful name — replacing the Java pattern of several constructors distinguished only by their parameter types.
Three things a Java static cannot do
- a companion object can be given a name:
companion object Loader { ... }, called asPerson.Loader.fromJSON(...) - it can implement an interface, so the class itself can be passed where that interface is expected
- it can be extended with extension functions, so another module can add factory methods to a class it does not own
That last one is worth pausing on. A static block is closed forever. A companion object is extensible from outside, which means a persistence layer can add Person.Companion.fromJSON without the Person class knowing anything about persistence.
Object expressions: anonymous objects
window.addMouseListener(
object : MouseAdapter() {
override fun mouseClicked(e: MouseEvent) { }
override fun mouseEntered(e: MouseEvent) { }
}
)Kotlin’s version of the Java anonymous inner class, with two improvements:
- it may implement several interfaces, where Java allows one
- it can access and modify local variables of the enclosing function, where Java requires them to be effectively final
Unlike an object declaration, an object expression creates a new instance each time the enclosing code runs — it is an expression, not a declaration.
For a single-method interface, a lambda is usually better — and the next unit is entirely about those.
Object expressions earn their place when the object must implement several methods, or hold state between calls.
Learning outcomes
- use-object-declarations: Use object declarations for singletons and object expressions for anonymous objects.
- use-companion-objects: Use companion objects in place of static members, and give them names and interfaces.
Concepts
- object-declarations: class and instance in one declaration
- companion-objects: a named, interface-implementing, extensible replacement for
static - object-expressions: anonymous objects that may implement several interfaces and capture mutable locals
What you can now declare
You can declare interfaces with defaults, control inheritance and visibility, write constructors that fit in a header, let the compiler generate the universal methods, delegate an interface, and use object in all three of its roles.
Learning outcomes
- declare-interfaces: Declare interfaces with abstract and default method implementations.
- explain-final-by-default: Explain why Kotlin classes are final by default and use open where inheritance is intended.
- use-visibility-modifiers: Use Kotlin’s visibility modifiers, including internal, and say how they map to Java.
- distinguish-nested-and-inner-classes: Distinguish a nested class from an inner class and choose the right one.
- use-sealed-classes: Use a sealed class to make a when exhaustive without an else branch.
- write-constructors: Write primary and secondary constructors and initializer blocks.
- implement-properties-and-backing-fields: Implement interface properties and access a property’s backing field.
- use-data-classes-and-delegation: Use data classes for value objects and by for class delegation.
- use-object-declarations: Use object declarations for singletons and object expressions for anonymous objects.
- use-companion-objects: Use companion objects in place of static members, and give them names and interfaces.
Conclusion
Kotlin reverses Java’s risky defaults and makes you ask for the risk.
finalunlessopen. Non-inner unlessinner.overridemandatory rather than optional. Each reverses a default that has produced real bugs, and each costs one keyword when you genuinely want the other behaviour.The fragile base class problem is the argument for final-by-default.
A subclass relying on how a base class calls its own methods breaks when the base changes reasonably. Effective Java’s advice — design for inheritance or prohibit it — is here enforced by the compiler.
Sealed classes turn a future runtime bug into a compile error today.
Without
sealed, theelsebranch swallows the subclass you add next year. With it, everywhenthat must change stops compiling until you change it.The compiler writes the code you would write badly.
datageneratesequalsandhashCodeconsistently, closing the most common correctness bug in Java value classes.bygenerates the forwarding a decorator needs, so the class contains only what is new.objectdoes three jobs and they share one idea.Declare a class and create the instance at once. Singleton, companion, anonymous object. The companion is the interesting one, because it can be named, implement interfaces and be extended — none of which
staticallows.Interfaces specify what, not how.
An interface property may be satisfied by a stored value, a computed getter, or a one-time initialisation. The implementer chooses, and the interface cannot tell.
Where next
The next unit, Programming with Lambdas, begins the functional half of the module: lambda syntax, the collection APIs built on it, lazy sequences, using Java’s functional interfaces from Kotlin, and lambdas with receivers — the feature the final DSL unit depends on entirely.