Skip to content

Latest commit

 

History

History
1050 lines (748 loc) · 51.3 KB

File metadata and controls

1050 lines (748 loc) · 51.3 KB

Chapter 2: Object-Oriented Programming

Continuing with the "like Java, but better" angle, we'll now take a look at how Kotlin approaches object-oriented code. While Java is clearly and strictly an object-oriented language, the same can not be said for Kotlin. We'll see later on that it supports more than just this one paradigm. For now though, let's see how Kotlin does OOP.

Classes 101

It makes sense to start with the simplest possible class:

class Person

This is what an empty class looks like in Kotlin. Classes usually have bodies bounded by curly braces {}, but these can be omitted if the body of the class is empty - which will happen in Kotlin more than you might think.

Classes have two kinds of constructors - we'll deal with primary constructors first. These are declared right in the header of the class, and simply list the parameters you need to construct a class:

class Person(name: String, age: Int)

These parameters don't do anything useful so far, we'd at the very least want to store them somewhere so that we can work with them later. In Java, we'd use fields for this. In Kotlin, we'll use properties instead, which is a higher-level concept. We can create a property with either the val or var keyword, just like a local variable. Similarly to local variables, a val is a read-only property, while a var is a read-write property.

class Person(name: String, age: Int) {
    val name: String
    var age: Int
}

This code won't compile yet, as it produces the following error for both uninitialized properties:

e: Property must be initialized or be abstract

Kotlin does not allow properties to go uninitialized, as constructing a Person with no name or age set would cause problems when someone wants to access these values.

This behaviour forces you to explicitly initialize every value in one way or another, and guarantees that your properties won't have implicit values stored in them. Whenever you read a property, you'll get a value out of it that you have put there, intentionally.

Note how Java would allow you to create a Person class with uninitialized fields without a problem.

   public class Person {
       String name;
       int age;
   }

This Java class has an implicit constructor with no parameters. When you call its constructor using new Person(), its fields will be initialized to implicit default values: null and 0, respectively. In general, primitive types are initialized to some resemblance of 0, while reference types are initialized to null.

Working with an object in your program that contains implicit default values can lead to all sorts of trouble. If you're lucky, it's a reference type storing null, and you'll get a crash when calling something on this null value. The problem is significantly worse for numerical values that are set to 0 by default: you can perform all sorts of invalid calculations based on this value, and it's very hard to detect when that's happening.

There are two ways of initializing this property within the body of the class. It can either be done at its declaration, or inside an initializer block, which is executed when the class is constructed:

class Person(name: String, age: Int) {
    val name: String = name
    var age: Int

    init {
        this.age = age
    }
}

Although rare, a Kotlin class may have more than one init block.

Since taking a parameter in the constructor and saving its value to a property with the same name is such a common pattern, we can simply add val or var directly in the primary constructor:

class Person(val name: String, var age: Int)

This class takes two parameters in its constructor, declares two properties with the same name as those parameters, and initializes the properties to the values passed in for the parameters. This is used extensively in Kotlin classes.

Properties

We've seen that properties can be declared either in the primary constructor or in the body of the class.

Let's take the following example of using the class that we've created:

fun main() {
    val person = Person("Mandy", 41)
    println(person.name)
    person.age = 42
}

It would seem like this class doesn't follow the encapsulation rules of OOP - from this syntax, it seems like we're accessing the data stored in the class directly. That would be the case if this was a Java class and these were fields, however, we have properties here.

A property represents a field, a getter, and a setter (in the case of vars) in a single concept. This becomes very apparent when we use the same class from Java:

public class Main {
    public static void main(String[] args) {
        Person person = new Person("Mandy", 41);
        System.out.println(person.getName());
        person.setAge(42);
    }
}

The properties that we use in Kotlin are exposed to Java clients as getters and setters, which is the usual way of accessing values stored in a class there. Kotlin tries to blend in to the platform it's targeting and interoperate with it as seamlessly as possible.

If you pay attention to some details, client code written in Java will never have to know that it's calling into your Kotlin code.

We can use the Kotlin decompiler to view a decompiled version of the class, which shows us an approximation of what the one-liner Person class above would look like in Java:

public final class Person {
   private final String name;
   private int age;

   public final String getName() {
      return this.name;
   }

   public final int getAge() {
      return this.age;
   }

   public final void setAge(int age) {
      this.age = age;
   }

   public Person(String name, int age) {
      this.name = name;
      this.age = age;
   }
}

While the getters and setters we use here are public, the fields that actually hold the data still remain private. We still have proper encapsulation!

This comparison of one-liner model classes vs the lengthy syntax of Java is often shown off when showcasing the strengths of Kotlin.

Custom getters and setters

As you can see in the decompiled code above, the getters and setters generated for a property by default simply read or write the property's backing field, the field created behind-the-scenes by the compiler to store a value for the property.

We can change this behaviour by adding a custom getter or setter implementation. To do this, we'll have to move our property from the primary constructor to the body of the class - the primary constructor is only for simple, straightforward properties.

class Person(val name: String, age: Int) {
    var age: Int = age
        get() {
          return field
        }
        set(value) {
          field = value
        }
}

To start, we've reimplemented the default functionality of reading and writing the backing field, which can be accessed by the field keyword in both the getter and the setter. The getter has no parameter, while the setter takes a single parameter (conventionally named value).

The compiler marks these implementations as redundant, as we get the same getter/setter by default.

Be careful not to write down something like age = value inside the setter. This would set the value of the property instead of the field, which would invoke its setter again, resulting in an infinite loop.

We can customize these functions to our liking now. For example, we might want to lie about our age when asked, or make sure that a person never gets any younger than their current age:

var age = age
    get() = field - 5
    set(value) {
        if (value > field) {
            field = value
        }
    }

Note the usage of an expression body with the getter, just like with any other function.

Kotlin's type inference is hard at work here. The type of the property is not specified anywhere in its now lengthy declaration, instead, it's inferred from the type of the constructor parameter that the property is initialized with.

The full syntax of the property with very explicit typing would look like this:

var age: Int = age
    get(): Int = field - 5
    set(value: Int) {
        if (value > field) {
            field = value
        }
    }

This contains a lot of unnecessary typing (pun intended), but specifying the type of at least the property itself on the first line may be a good idea - doing so prevents it from accidentally changing its type if the constructor parameter's type changes.

Even though we're now writing a custom getter and setter in the implementation of our class, the external usage of the property remains the same as before. We can read and write it by just referencing its name directly:

val person = Person("Dave", 38)
println(person.age) // 33
person.age = 20
println(person.age) // 33

However we implement a property - whether we rely on the default implementation or do it ourselves - they're always encapsulated, and use accessor functions under the hood. This helps us with maintaining the APIs of classes, while allowing them to change their internals in a broad variety of ways.

Some Java libraries work by reading and writing the values of Java fields. These fields often have to be publicly accessible as well. As Kotlin's properties always use private backing fields, these libraries can have trouble operating on Kotlin classes. The solution is these cases is using the @JvmField annotation, which turns a Kotlin property into a plain Java field in the bytecode.

class Person {
    @JvmField
    val name: String = "Anonymous"
}

Instead of calling a getter for this property, we can now directly access it in Java code:

System.out.println(new Person().name);
Property delegation

Logic written in custom getters and setters tends to follow the same patterns over and over again. One the most common patterns is lazy initialization, computing a value only when it's first needed (saving resources until then), and then storing it for later use (saving resources on subsequent accesses).

Here's an example of a pi property which is computed only if it hasn't been accessed yet, and otherwise it returns an already stored value when the getter is invoked:

private var _pi: Double? = null
val pi: Double
    get() {
        if (_pi == null) {
            // Some expensive computation
            val sum = (1..50_000).sumOf { 1.0 / it / it }
            _pi = sqrt(sum * 6.0)
        }
        return _pi!!
    }

This property uses another property to store its data instead of its own backing field - a backing property - because the types of the property and the data it needs to store are different. This is due to nullability concerns, which we'll cover in the next chapter.

The computation itself also uses some advanced features that we didn't cover yet, but you can attempt to figure out how it works!

If we were to now write code that lazily computes e (Euler's number), we'd end up writing a lot of the same code as before. Two properties, one of them null initially, and a custom getter that performs a null check, executes the initialization code if needed, and finally a return statement.

private var _e: Double? = null
val e: Double
    get() {
        if (_e == null) {
            // Again, complex, expensive computation
            val sum = (0..20).sumOf { 1.0 / (1..it).fold(1, { a, x -> a * x }) }
            _e = sum
        }
        return _e!!
  }

A feature called property delegation comes to the rescue here, which allows us to extract our getter (and setter) logic into a class to make it reusable. We'll look at how to do this in a later chapter.

For now, let's see what delegates the Standard Library has built-in, starting with lazy, which initializes a property only when it's first accessed. You can delegate a property using the by keyword, and create a lazy property with lazy {}:

val pi: Double by lazy {
    val sum = (1..50_000).sumOf { 1.0 / it / it }
    sqrt(sum * 6.0)
}

The logic performing the lazy initialization is now gone from our own code, and all we have to focus on is the initialization logic itself! This is placed within the braces {} - the last expression in here will be assigned as the value of the property.

lazy is also thread-safe by default, which you can disable with an additional parameter if you don't need the safety and want slightly better performance:

lazy(mode = LazyThreadSafetyMode.NONE) { ... }

Another common pattern that's covered by built-in delegates is running code whenever the value of a property changes. Delegates.observable and Delegates.vetoable from the Standard Library cover these use cases - see their documentation to learn more.

Constructors

This section has been adapted from a blog post that originally appeared on zsmb.co.

Primary constructors play a fundamental role in Kotlin classes. Let's take a closer look at them, understand what exactly is part of a primary constructor, what makes this constructor special, and what the alternatives are.

The primary constructor

Let's create a Car class that takes two parameters in its primary constructor, and uses them to initialize properties. For the sake of the example, we'll use both inline initialization and an init block. We'll also add a property that's initialized to a constant value.

class Car(model: String, year: Int) {
    val model: String = model
    val year: Int
    var miles: Double = 0.0

    init {
        this.year = year
    }
}

When the constructor is called, these different types of initializations are performed from top to bottom, in order. In the example, model and miles would be initialized first, and then finally year would get its value. Any parameters that the primary constructor takes may be used for these initializations.

Let's simplify the code by moving both model and year into the primary constructor. Properties in the primary constructor will be initialized before anything in the body of the class, and again, they'll be initialized in the order that they're declared in.

class Car(val model: String, val year: Int) {
    var miles: Double = 0.0
}

In addition to constructor parameters, any previously initialized properties within the class body will also be in scope during initialization, and you can use their values to initialize other properties:

class Car(val model: String, val year: Int) {
    var miles: Double = 0.0

    val age: Int

    init {
        age = getCurrentYear() - year
    }

    val description: String = "$model ($age years, $miles miles)"
}

We can only initialize description this way after age has been initialized. If we placed it above the init block, we'd again see an error:

e: Variable 'age' must be initialized

A look under the hood

If we decompile the bytecode produced for this class using the Kotlin decompiler, we'll see this corresponding Java source (comments added):

public final class Car {
    private double miles;
    private final int age;
    private final String description;
    private final String model;
    private final int year;

    // Getters & setters ...

    public Car(String model, int year) {
        // Properties in the primary constructor
        this.model = model;
        this.year = year;

        // Initialization at the declaration
        // (This is actually optimized away if we init to 0)
        this.miles = 0.0D;

        // init block
        this.age = Utils.getCurrentYear() - this.year;

        // Initialization at the declaration
        this.description = this.model + " (" + this.age + " years, " + this.miles + " miles)";
    }
}

This shows us how all the different kinds of initializations end up in the body of a single constructor together, in order.

Initialization rules, recap

To review, the initialization order:

  • Properties in the primary constructor, in declaration order.
  • Initializations at property declarations and in initializer blocks, interleaved, in the order that they appear in the class body.

You can read the initialization statements in the class top to bottom, and that's what you'll get in the "body" of the primary constructor.

In each of these initializations, you can use the values of:

  • Constructor parameters, even if they're not stored in properties.
  • Previously initialized (not just declared!) properties.

Thanks to these restrictions and safety guarantees, classes created using the primary constructor will always be in a valid state.

A representation of our single, primary constructor, which is in a valid state.

Secondary constructors

There are cases where you want to create instances of a class with different sets of parameters, which normally requires multiple constructors. Kotlin's default arguments make this possible to some extent while still keeping just a primary constructor. However, if you need a constructor that has entirely new parameters or parameters with different types, you'll need a secondary constructor.

For our example, let's say we need to be able to create cars with a model, year, and mileage, all provided as strings. Our primary constructor can't accommodate these parameters, so it's time to write a new one. Secondary constructors are declared in the body of the class, using the constructor keyword.

Here's a first attempt at writing one:

constructor(
    model: String,
    year: String,
    mileage: String,
) {
    this.model = model
    this.year = year.toInt()
    this.miles = mileage.toDouble()
}

This code would fail at constructing a valid Car instance, and so it doesn't compile. For example, it doesn't set the age and description properties of the instance, which we expect to be initialized by every constructor.

We also get an error for trying to set year and miles here: e: Val cannot be reassigned. As a val can only be initialized once, that one initialization will always have to happen in the primary constructor (if there is one).

A secondary constructor that doesn't call the primary constructor is invalid.

The fix, and the rule for secondary constructors is simple: it has to first call the primary constructor, and only after that can it perform further initialization on the instance that was created. After the primary constructor is called by the secondary constructor, the instance is already in a well-constructed, known valid state, so it's safe to operate on it further in the body of the secondary constructor.

Let's change our secondary constructor to invoke the primary constructor first:

constructor(
    model: String,
    year: String,
    mileage: String,
): this(model, year.toInt()) {
    miles = mileage.toDouble()
}

A secondary constructor that calls the primary constructor directly.

The call to the primary constructor doesn't have to be direct, it can also happen indirectly through calling another secondary constructor, but this chain eventually has to end in a call to a primary constructor.

Here's an example of yet another new constructor, which calls the previous secondary constructor:

constructor(data: Array<String>) : this(
    model = data[1],
    year = data[3],
    mileage = data[7],
)

A secondary constructor that calls the primary constructor indirectly.

What we really have here in our class is a graph of the various constructors calling each other.

  • A primary constructor is valid if it initializes all properties.
  • A secondary constructor is valid if it eventually calls the primary constructor, i.e. if there's a directed path to the node representing the primary constructor from the node of the secondary constructor.
    • This also means that there can be no cycles within secondary constructor nodes, and no disconnected nodes.

A graph of constructors.

Without primaries [Extra content]

While Kotlin classes are usually designed around their primary constructor (thanks to the conveniences it offers for initializing properties in the body), designing classes to have no primary constructor at all is also an option. See the extras for this chapter to learn more.

Data classes

Data classes are one of the frequently advertised features of Kotlin. Their purpose is make data holder classes more convenient to use by automatically generating some methods for them. All of this generated functionality relies on the properties that a data class has in its primary constructor.

Data classes may have additional properties in their bodies, but these will not be taken into account by the generated methods.

To create a data class, add the data keyword to a class. Getting back to our favourite Person example:

data class Person(val name: String, var age: Int)

The first three methods generated are the equals, hashCode, and toString methods, which you may already know from Java.

In Java, these methods are present on the java.lang.Object type, which every class implicitly has as a supertype. In Kotlin, the type in the same role is called Any, and maps exactly to Object when you're running Kotlin on the JVM.

The generated equals and hashCode methods will consider all properties that are in the primary constructor (and only those) to compare instances of the class for equality. If you need different behaviour, you can still override these implementations yourself. (Though in this case, you might be better off without a data class.)

The generated toString method will give you a nicely formatted string that contains the name of the class, as well as the names and values of its primary constructor properties:

val emma = Person("Emma", 19)
println(emma) // Person(name=Emma, age=19)

You might argue that you're not writing these methods anyway, even in Java, but generating them with IDE shortcuts. However, those generated methods are part of the codebase and have to be maintained. With data classes, these are generated every time the class is compiled, so they're guaranteed to follow any changes to the class, such as a new property being added.

There are also methods generated that are specifically for Kotlin use. Methods named component1, component2, and so on are added to data classes so that they can be used with destructuring declarations. This feature allows you to declare and initialize multiple variables at the same time, with an assignment like this:

val (name, age) = emma
println(name) // Emma

Note that destructuring is positional, and that these variables can have arbitrary names, they don't necessarily have to match the names of the properties. For example, swapping the names of these two variables will lead to very unexpected results:

val (age, name) = emma
println(name) // 19

The last generated method for data classes is copy, which allows you to... make a copy of your current instance.

val clone = emma.copy()
println(clone) // Person(name=Emma, age=19)

Its real power lies in the fact that it actually has the sane parameters as the class does in its primary constructor, and they all default to taking the value of the original instance. This means that you can choose to change them one by one, using named parameters, for example:

val olderEmma = emma.copy(age = 26)
println(olderEmma) // Person(name=Emma, age=26)

We've provided only the age parameter, so the other properties of the class will take their values from the original instance.

The copy method comes in especially handy when using it on data classes that only have val properties. Since you can't change these, you have to create new instances whenever you need to represent slightly changed data, and this can get very tedious when you have to copy the old values for several properties. The copy method does this for you, allowing you to only change what you want to change for the new instance.

This is part of Kotlin's general push to prefer immutability. The less mutable state a class holds, the easier it is to reason about how it will behave at any given point in time. Immutability also has huge advantages in multithreaded environments. You don't have to synchronize access to immutable objects, as they never change! So whether it's a local variable or a property, remember to always go with a val first.

The copy method performs a shallow copy. Any references to other objects will therefore be the same in the original and the copy created.

Data classes do have some requirements:

  • Their primary constructor can't be empty, it needs to contain at least one property.
  • All primary constructor parameters need to be properties, either val or var.
  • Inheritance is also restricted: data classes can not be inherited from.

Finally, remember that regular Kotlin classes are already very concise and powerful if you just need them to hold a couple values as properties. Not everything has to be a data class.

Hint: The generated code for data classes is a very interesting thing to look at with a decompiler!

Java 16 introduced records, which achieve something similar to data classes, automatically generating convenience methods for simple data holders. Kotlin has support for records as well, allowing you to use Java-declared records, as well as to declare records in Kotlin code.

Objects

An object is a construct very similar to a class in Kotlin, with one difference: while you create instances of classes, an object is an instance on its own. What's more, it's the only instance of its type. This is essentially a very concise way to declare a singleton:

object Logger {
    var isEnabled = true
    
    fun log(message: String) {
        if (isEnabled) {
            println(message)
        }
    }
}

An object can not have a constructor, and its single instance can be accessed simply by its name:

Logger.log("Hello world") // Hello world
Logger.isEnabled = false
Logger.log("Oh no, where's my log") //

You can make an object a data object to give it a nicer toString implementation, and improved equals/hashCode behaviour.

Nested objects

Objects may be declared inside classes. In this case, they can access the internals of the class that they're nested in, and they can be used with the following syntax:

class Document {
    object Counter {
        var count: Int = 0
    }

    val id = Counter.count++
}

fun main() {
    repeat(5) {
        Document()
    }
    println(Document.Counter.count) // 5
}

repeat is a simple way to execute a piece of code multiple times.

Companion objects

How would you add a counter like in the snippet above in Java? You would use a static variable inside the class. Kotlin doesn't have static properties or functions. However, you can mark a nested object inside the class as the companion object of the class to get the following familiar syntax for accessing anything inside that object:

class Document {
    companion object Counter {
        var count: Int = 0
    }

    val id = Counter.count++
}

println(Document.count)

What makes this work is that writing down the name of the class - Document in this example - will actually give you the reference to the companion object. You can confirm this easily, with this slightly odd line of code:

val counter: Document.Counter = Document

Naturally, classes can only have one companion object. This object is special, as it doesn't have to have an explicit name, and it usually isn't named. Under the hood, its type will be named Companion implicitly:

class Document {
    companion object {
        var count: Int = 0
    }

    val id = count++
}

val counter: Document.Companion = Document
println(Document.count) // 5

As far as Kotlin syntax is concerned, this is as good as having static variables and functions - you simply place these inside a companion object. However, these are still members in an inner class of Document, which we're then using just a single instance of. They're not really static members of the Document class.

This can cause issues with certain Java-based frameworks that work with static fields and methods, and the syntax for Java clients isn't completely smooth either:

Document.Companion.getCount();

If you need real static declarations for Java compatibility, you can use the @JvmStatic annotation on methods or properties inside a companion object:

class Document {
    companion object {
        @JvmStatic
        var count: Int = 0
    }
}

This will expose them as real static declarations:

Document.getCount();

You can also use @JvmField to turn properties in the companion into static fields. To read more about the various possibilities for static interop, see the table in this blog post.

Nested classes

A quick word about nested classes. They work similarly to the ones in Java, with one significant difference: they don't hold a reference to the outer class by default. This helps avoid accidentally capturing references to outer classes, which can lead to memory leaks.

class Outer {
    var outerValue = 0
    
    class Inner {
        init {
            println(outerValue)
                 // ^ Unresolved reference: outerValue
        }
    }
}

In Java, this behaviour would be achieved by adding the static keyword in front of the nested class.

If you do want an implicit reference to the outer class stored in the nested class, make it an inner class by using the inner keyword - this gives you the behaviour that would be the default in Java:

class Outer {
    var outerValue = 0

    inner class Inner {
        init {
            println(outerValue) // 0
        }
    }
}

Inheritance

Let's create a simple game to learn about how inheritance works in Kotlin. For a start, we'll create an Entity base class, which will store the current position of an entity on screen, as two coordinates:

class Entity(var x: Double, var y: Double)

We can extend this Entity class with a concrete implementation using the following syntax:

class UFO(x: Double, y: Double) : Entity(x, y)

This UFO class has a primary constructor that takes two parameters, and this primary constructor calls into the superclass' constructor, passing along both parameters.

Note that even if Entity didn't take any parameters, you'd have to inherit from it by calling its constructor using the : Entity() syntax.

The code above, perhaps surprisingly, doesn't compile. This is because classes in Kotlin are final by default, meaning that they can't be inherited from unless that's explicitly allowed, by making them open:

open class Entity(var x: Double, var y: Double)

This design choice falls in line with one of the often cited items of the Effective Java book - Item 19: Design and document for inheritance or else prohibit it. Extending a class that was not designed with inheritance in mind can lead to a wide variety of problems, and final by default serves as a safeguard against this.

Items of this book will be referenced by these materials every now and again, as Kotlin promotes many Java best practices naturally, through its language design.

In our specific case, it also doesn't make sense to let clients create Entity instances directly, which we can prevent by making this base class abstract. This works the same way as Java's abstract classes: it prevents creating instances of this class, while still allowing inheritance from it. Abstract classes, of course, are always open.

abstract class Entity(var x: Double, var y: Double)

Next, we'll add a progress method to the base class, which will be invoked by our "game engine" to indicate that time has passed.

Methods are also final by default, meaning they can't be overridden. Any method that we want to allow overrides for must be marked open. In the case of an abstract class, a method may also be marked abstract, if there's no default implementation provided for it - this will force concrete subclasses to override it.

progress can be an open method, with an empty body:

abstract class Entity(var x: Double, var y: Double) {
    open fun progress() {
        /* Empty */
    }
}

In Java, @Override is an optional annotation. In Kotlin, it's a required keyword. Let's add some random movement to our UFO class in its progress method:

class UFO(x: Double, y: Double) : Entity(x, y) {
    override fun progress() {
        x += Random.nextDouble(from = -5.0, until = 5.0)
        y += Random.nextDouble(from = -10.0, until = 10.0)
    }
}

java.util.Random is still available to use in Kotlin when you're on the JVM, but the kotlin.random.Random class from the Standard Library provides a simple, platform independent random API, which you should use in most cases. This is what's used in the previous code snippet.

We'll keep track of our entities in a list within a Game class:

class Game {
    val entities = mutableListOf<Entity>()

    fun tick() {
        for (entity in entities) {
            entity.progress()
        }
    }
}

Interfaces and type checks

It's time to draw our game on the screen, and learn about interfaces.

Kotlin's interfaces are fairly straightforward. They can not hold state, i.e. they can't declare concrete properties, but they can declare properties and methods that classes implementing the interface will have to override. They can also contain default implementations for methods.

Let's introduce a Renderable interface, which will be implemented by entities that can be displayed on the screen:

interface Renderable {
    val isVisible: Boolean
    fun render(canvas: Canvas)
}

This interface requires implementations to be able to tell whether they're currently visible, and to render themselves onto a Canvas when asked to do so.

Our UFO class will implement this interface:

class UFO(x: Double, y: Double) : Entity(x, y), Renderable {
    companion object {
        const val WIDTH = 40.0
        const val HEIGHT = 40.0
    }

    override var isVisible: Boolean = true

    override fun render(canvas: Canvas) {
        // Drawing logic using JavaFX
        val context = canvas.graphicsContext2D
        context.fill = Color.DIMGRAY
        context.fillOval(x, y, WIDTH, HEIGHT)
        context.fill = Color.DODGERBLUE
        context.fillOval(x + WIDTH / 4, y + HEIGHT / 4, WIDTH / 2, HEIGHT / 2)
    }

    /* ... */
}

A couple of things to note here:

  • Implementing interfaces uses almost the same syntax as extending classes, except there are no parentheses indicating a constructor call.
  • Constants which would be static in Java can't reside directly in classes - in that case, they would just be properties, being present as a value in each instance. However, they may be placed inside an object, which can then be nested in the class. This is almost always the companion object. By marking these properties with the const keyword, we get to use them in annotations, and their values will be inlined to any use sites as a performance optimization.

The implementation of isVisible is especially interesting. The Renderable interface declares it as a val of Boolean type. This lets implementations of the interface choose from a wide variety of implementations, as long as a getter exists for this property.

In the UFO class above, we've implemented this as a var, which will create a field, a getter, and a setter inside the class. We can also implement this property with a custom getter, using a delegate, or as a computed property:

override val isVisible: Boolean
    get() {
        return x > y
    }

Computed properties are properties where the getter (and setter, if it's a var) doesn't reference its backing field. In this case, a backing field won't be generated inside the class for the property at all. The accessors of these properties can rely on other methods or properties that are in scope.

This means that computed properties can even be present in interfaces, as they store no state - they're just getters/setters with default implementations.

Let's update our Game class to add support for rendering Renderable entities:

class Game {
    val entities = mutableListOf<Entity>()

    fun renderScene(canvas: Canvas) {
        canvas.graphicsContext2D.fill = Color.BLACK
        canvas.graphicsContext2D.fillRect(0.0, 0.0, canvas.width, canvas.height)

        for (entity in entities) {
            if (entity is Renderable && entity.isVisible) {
                entity.render(canvas)
            }
        }
    }
    
    /* ... */
}

The renderScene method fills the background of the game, and then renders each Renderable entity in a loop. Within the loop, we check whether each entity implements this interface with the is Renderable syntax (equivalent to a Java instanceof check).

The surprising part of this code is that there's no casting to a Renderable after this check passes. We just use the isVisible property and the render method of the entity that passed the type check directly.

This is thanks to a feature called smart casts. Whenever the compiler can reason about the type of a variable based on type checks and control flow, it will automatically make the variable available as the known type, performing the cast for us.

This would even work if we checked for non-conformance to the Renderable interface, with the !is operator:

for (entity in entities) {
    if (entity !is Renderable) {
        continue
    }
    if (entity.isVisible) {
        entity.render(canvas)
    }
}

continue terminates the current iteration of a loop, and skips to the next one.

Smart casts replace most manual casting in Kotlin, but casting explicitly is still possible with the as keyword. entity as Renderable will throw an exception if entity is not a Renderable, and return it with the Renderable type if it is.

This completes the interesting bits of implementation for our UFO game - find the full code of the game in this project.

Class delegation (implementation by delegation) [Extra content]

Classes can also implement interfaces by delegation. Instead of implementing the members declared in the interface in the class itself, the class may delegate to another instance that already implements that interface.

This looks something like this:

class RocketShip(delegate: Renderable) : Renderable by delegate

Whenever a member of Renderable is invoked on the RocketShip instance, it will forward that call to the same member of delegate.

Learn more about why this is useful in the extras for this chapter.

Sealed classes

Sometimes it's handy to restrict inheritance from a class. Sealed classes prevent unknown subclasses of a base class by only allowing subclasses within the same module and package. This ensures that these classes are always compiled together, and all possible subclasses are known at compilation time.

Long ago, before Kotlin 1.1, subclasses had to be nested in the sealed class. You can still see this pattern of nesting in many usages of sealed classes today.

For example, we may represent the response from a network call with a sealed class:

sealed class Response
data class Success(val data: String) : Response()
data class Error(val exception: IOException) : Response()

These classes don't strictly need to be data classes, but data classes are often used in sealed hierarchies.

Then, instead of the unfriendly APIs that throw exceptions at us when something goes wrong, we can provide an API that will always return a Response instance as the result of a network call. Returning a Response object like this makes the caller of the function check for errors before using the results.

fun getDataFromAPI(): Response {
    return try {
        val data = URL("https://www.kotlinlang.org/").readText()
        Success(data)
    } catch (e: IOException) {
        Error(e)
    }
}

Remember, try-catch is an expression!

Because Response is a sealed type, we know for certain that it will either be an instance of Success or Error. This small set of possible values can only change if someone has access to the current module, and can recompile it.

This brings us to a frequently used capability of when, its ability to perform type checks:

when (val response = getDataFromAPI()) {
    is Success -> {
        println("Network success")
        println(response.data)
    }
    is Error -> {
        System.err.println("Network error")
        response.exception.printStackTrace()
    }
}

Notice that in each branch, the value of response is smart cast to its concrete type, making properties like data and exception accessible on them!

If we use when as an expression (we return a value from it), we can do so with sealed classes without having to provide an else branch. The compiler can guarantee that the statement is already exhaustive with just these two branches, since we know all existing implementations of Response:

val message = when (getDataFromAPI()) {
    is Success -> "Network success"
    is Error -> "Network error"
}
println(message)

Using sealed classes for error handling is a neat way of avoiding having to deal with exceptions in Kotlin. For more error handling strategies, watch this KotlinConf 2019 talk by Nat Pryce and Duncan McGregor. Spoiler alert: contains quite a few advanced Kotlin features that we haven't covered yet.

Java 17 also introduced sealed classes with slightly different syntax.

Sealed interfaces

Sealed interfaces work the exact same way as sealed classes. If your sealed class doesn't need to store any data in properties, consider using a sealed interface instead to simplify the code.

We can do this in the example above:

sealed interface Response
data class Success(val data: String) : Response
data class Error(val exception: IOException) : Response

A prominent use case for sealed interfaces is using them in libraries to ensure that users of the library can never implement a given interface.

Using a sealed interface also lets members of the sealed hierarchy subclass something else if necessary.

Objects in the sealed hierarchy

If you have a member in your sealed hierarchy that doesn't have any properties, you can make it an object instead of a class to avoid creating new instances of the type every time you need to use it.

We can apply this if we don't want to include the exception as part of our Error instances:

sealed interface Response
data class Success(val data: String) : Response
data object Error : Response

If you're using data classes for your class implementations in your sealed hierarchy, you can use a data object to match.

Enums

Enums are classes in Kotlin, and a basic enum declaration looks like this:

enum class Sizes {
    S, M, L
} 

Enums are classes with a fixed number of instances. In the example above, S, M, and L are the only three instances of Sizes that will ever exist.

This is the logical equivalent of creating a sealed class with all of its subclasses being objects. If that makes sense to you, you're getting a good grasp of OOP in Kotlin! Note that this is just a comparison, and not how enums in Kotlin are actually implemented.

Enums may also have properties and methods, just like any regular class. These can be implemented once for each of them, in the "base class", or be "abstract" and implemented by each value separately:

enum class MenuItem(val price: Double) {
    Hamburger(4.65),
    Fries(3.50),
    Coke(2.50) {
        override fun purchase() {
            super.purchase()
            println("It's a coke!")
        }
    }; // !
    
    open fun purchase() {
        println("Spending $price")
    }
}

The semicolon separating the list of instances from the enum's methods is one of the very very few required semicolons in the Kotlin language.

It's natural to compare enums to sealed classes. While enums declare a class with a fixed number of instances, sealed classes declare a type with a fixed number of subclasses. Those subclasses can normally be instantiated any number of times.

Putting it another way, with enum classes, every single instance of the class is known in advance. With sealed classes, it's only the types of possible subclasses that are known.

Visibility

To wrap up our discussion of object-oriented programming, let's discuss visibility. Kotlin has similar visibility modifiers to Java on first sight, but they come with a few significant changes. These modifiers can be applied to declarations of various types: classes, objects, properties, functions, and so on.

  • public declarations are accessible from anywhere. This is the default visibility in Kotlin, and it's implicit. The "package" visibility that was the default in Java is not available in Kotlin.
  • private declarations in classes are only accessible within the same class, while top-level* private declarations are only accessible within the same file.
  • protected declarations are only accessible in the containing class or its subclasses. They are not accessible from code in the same package, like they are in Java.
  • internal declarations are accessible within the same compilation unit, for example, the same Gradle module. This is a visibility unique to Kotlin, and it's especially useful for keeping library internals private from clients.

*top-level: Declarations that are declared directly in a file, and not nested in classes. Really, these are package-level declarations, it's their package that contains them lexically.

There are certain use cases for visibility modifiers where it's not obvious where to place them, such as applying them to primary constructors, or auto-generated getters/setters:

class SecretValue internal constructor(initialValue: Int) {
    var state: Int = initialValue
        private set
}
  • This class cannot be instantiated from another module due to the limited visibility of its constructor. Note that in this case, the constructor keyword must be added to the primary constructor.
  • While the state property is a var, its setter will not be visible externally, essentially making it a val to the outside world.

Summary

The elements of object-oriented programming in Kotlin are very similar to mainstream object-oriented languages such as Java, with a few notable exceptions.

Instead of fields, getters, and setters, Kotlin operates on the abstraction level of properties. These properties can have auto-implemented accessors or custom ones. They may also be delegated, or be computed (have no backing field).

The primary constructor is the main way of initializing instances, and it comes with special safety guarantees. Secondary constructors have to rely on the primary constructor to perform the basic initialization of the class in most cases.

Data classes come with auto-generated utility methods in exchange for a few restrictions. object declarations are a concise way to create singletons in Kotlin. Nested objects and companion objects can act as the "static" parts of classes. Nested classes are "static" by default, and have to be marked with inner to get a reference to the outer class.

Classes are final by default, and have to be marked with open or abstract to be inherited from. Interfaces can contain property and function declarations, as well as default implementations for functions. Casting in Kotlin is mostly done with smart casts which happen automatically after a successful type check. Sealed classes are a way to restrict an inheritance hierarchy, and play really nicely with when statements.

Visibility modifiers in Kotlin are slightly different from Java. Declarations are public by default, and Kotlin offers an internal modifier.

Sources