Hopp til hovedinnhold

A type class is a construct that defines a set of behaviours that can be adapted to different types. Unlike inheritance, the implementation of the behaviour for a type is decoupled from the definition of the type. In this post, we'll walk through an example that demonstrates this in Kotlin.

Lets use the example of finding the sum of something, like integers. From the standard library we have the sum function that can be applied to lists, listOf(1, 2, 3).sum(), resulting in 6. The sum function is implemented in the standard library as an extension function on Iterable:

public fun Iterable<Int>.sum(): Int {
    var sum: Int = 0
    for (element in this) {
        sum += element
    }
    return sum
}

Say we wanted to sum a list of java.math.BigDecimal, listOf(BigDecimal(1), BigDecimal(2), BigDecimal(3)).sum(). This does not compile because the function sum does not exist for BigDecimal. We could define it like the following code snippet, which is exactly the same as the implementation for Int except from swapping Int with BigDecimal:

public fun Iterable<BigDecimal>.sum(): BigDecimal {
    var sum: BigDecimal = BigDecimal(0)
    for (element in this) {
        sum += element
    }
    return sum
}

To avoid repeating this implementation for all the types we want to sum, let's try creating an interface Addable so that we can make the sum function generic for subtypes of Addable:

interface Addable<T> {
    fun plus(other: T): T
}

The Addable interface has one function plus, adding two instances of T and returning the result.

Using the Addable interface, our sum function on Iterable would look like this:

fun <T : Addable<T>> Iterable<T>.sum(): T? {
    var sum: T? = null
    for (element in this) {
        sum = sum?.plus(element) ?: element
    }
    return sum
}

One problem with this implementation is that we do not know how to represent the initial value for sum, namely zero. This is a problem when the iterable is empty, so we'll have to change the return type from T to T? and return null if the iterable is empty.

A second problem is that we cannot make BigDecimal extend our new Addable interface - or any other types that we use from other libraries. So to make this work for BigDecimal, we'll have to make a wrapper that can extend Addable:

inline class BigDecimal(val value: java.math.BigDecimal) : Addable<BigDecimal> {
    override fun plus(other: BigDecimal): BigDecimal = BigDecimal(value + other.value)
}

This does not seem to be an ideal solution, whereas duplication of the function for different types will make it more convenient to use. In fact, the function sum is duplicated for Byte, Short, Int, Long, Float and Double in the standard library. However, let's try to redefine our Addable interface to a type class:

interface Addable<T> {
    fun T.plus(t: T): T
    fun zero(): T
}

It is still an interface, but we have defined the plus function as an extension function on T since we do not intend for the interface to be inherited by T as in the previous version of the Addable interface. We want to decouple the behaviour of Addable for the type T, from the implementation of T.

Since the implementation of Addable for a type T will be decoupled from the implementation of T, we may add singleton behaviour to the type class, i.e. behaviour that is not related to a specific instance of T. That makes it possible to deal with the null issue from our previous implementation of sum with inheritance, by adding the function zero to the type class.

This is how the implementation of sum could be, using the Addable type class:

fun <T> Iterable<T>.sum(addable: Addable<T>): T {
    var sum: T = addable.zero()
    for (element in this) {
        addable.run {
            sum = sum.plus(element)
        }
    }
    return sum
}

In this version, we provide an instance addable of Addable<T>. The variable sum is initialized to addable.zero() so that we do not need to worry about null when the list is empty. In the for-loop, we wrap the sum operation inside a block of addable.run, making Addable<T> the receiver type which makes the extension function plus available for instances of T.

To make it more convenient to create instances of the Addable type class, we can add a function instance to its companion object, taking the two functions zero and plus as parameters, and returning a new instance of Addable:

interface Addable<T> {
    fun T.plus(t: T): T
    fun zero(): T

    companion object {
        fun <T> instance(plus: (T,T) -> T, zero: () -> T): Addable<T> {
            return object : Addable<T> {
                override fun T.plus(t: T) = plus(this, t)
                override fun zero(): T = zero()
            }
        }
    }
}

Now, it is easy to create an instance of Addable for BigDecimal:

fun bigDecimalAddable(): Addable<BigDecimal> =
    Addable.instance({ a, b -> a + b },  { BigDecimal(0) })

Which can be used like this:

listOf(BigDecimal(1), BigDecimal(2), BigDecimal(3)).sum(bigDecimalAddable())

There is a proposal to enable compile-time dependency resolution with a new extension keyword to define instances of interfaces, and mechanisms to retrieve those instances by the with keyword. These changes could make it more convenient to use type classes in Kotlin in the future. For the above example, by enabling invocation of sum() without providing the type class instance of bigDecimalAddable().

If you found this short introduction to type classes in Kotlin interesting, you should definitely check out the Arrow library, which implements a number of type classes.

Did you like the post?

Feel free to share it with friends and colleagues