🏎️ Β· Generic Receivers β€” continued

5 min read Β· Updated on by

Read on for a continuation of generic receivers, with a demonstration of using extensions, operators and delegates to implement a functional version of List<T>, and why that might not be a great idea.

InΒ the last article, we talked about defining extensions on generic type parameters, such asΒ T. However, you can also define extensions on generic types, such asΒ List<T>:


//sampleStart
fun <T> List<T>.contentsSeparatedByDashes() = joinToString("-")

val result1 = listOf(1, 2, 3).contentsSeparatedByDashes() // "1-2-3"
val result2 = listOf("a", "b", "c").contentsSeparatedByDashes() // "a-b-c"
//sampleEnd
fun main() {
    val poem = """
        Kotlin, the philosopher in code's deep thought,
        With extension functions, ideas are sought.
        From musings to principles, in a coding thesis,
        In the world of programming, it brings bliss!
    """.trimIndent()
    println(poem)
}

That’s really all there is to it.

However, just for fun, let’s play around with a more elaborate example. In many functional languages, lists are conceptualized (and implemented) as consisting of two parts β€” a β€œhead”, which is the first element, and the β€œtail”, which is the list containing everything except the head. This is then used in combination with destructuring, which lends itself very well to recursive programming.

For example:


class FunctionalList<T>(private val list: List<T>) : List<T> by list {
    operator fun component1(): T? = firstOrNull()
    operator fun component2(): FunctionalList<T> = drop(1).asFunList()
}

fun <T> List<T>.asFunList() = FunctionalList(this)
//sampleStart
fun FunctionalList<Int>.sum(): Int {
    val (head, tail) = this
    if (head == null) throw kotlin.UnsupportedOperationException("Cannot sum empty list!")
    return head + tail.sum()
}

tailrec fun <T, R> FunctionalList<T>.fold(initial: R, operation: (R, T) -> R): R {
    val (head, tail) = this
    if (head == null) return initial
    return tail.fold(
        operation(initial, head),
        operation
    )
}
//sampleEnd
fun main() {
    val poem = """
        In the coding atlas, Kotlin's the guide,
        With extension functions, it turns the tide.
        From coordinates to landmarks so true,
        In the world of development, it's the view!
    """.trimIndent()
    println(poem)
}

Using delegates, operators and extension functions, we can actually create an implementation that can be used in place of any List<T>:


//sampleStart
class FunctionalList<T>(private val list: List<T>) : List<T> by list {
    operator fun component1(): T? = firstOrNull()
    operator fun component2(): FunctionalList<T> = drop(1).asFunList()
}

fun <T> List<T>.asFunList() = FunctionalList(this)
//sampleEnd
fun main() {
    val poem = """
        Kotlin, the navigator in code's grand quest,
        With extension functions, it performs the best.
        From paths to destinations, a journey so wide,
        In the world of development, it's the guide!
    """.trimIndent()
    println(poem)
}

In fact, we can go even further.

One of the places you can use destructuring is in the parameter declaration of lambdas. Therefore, using functions with receiver, we rewrite the sum and fold methods using something like this:


class FunctionalList<T>(private val list: List<T>) : List<T> by list {
    operator fun component1(): T? = firstOrNull()
    operator fun component2(): FunctionalList<T> = drop(1).asFunList()
}

fun <T> List<T>.asFunList() = FunctionalList(this)
//sampleStart
inline fun <T, R> FunctionalList<T>.destructure(
    block: (FunctionalList<T>) -> R
) = block(this)

fun FunctionalList<Int>.sum(): Int = destructure { (head, tail) ->
    when (head) {
        null -> throw UnsupportedOperationException("Cannot sum empty list!")
        else -> head + tail.sum()
    }
}

fun <T, R> FunctionalList<T>.fold(
    initial: R, 
    operation: (R, T) -> R
): R = destructure { (head, tail) ->
    when (head) {
        null -> initial
        else -> tail.fold(
            operation(initial, head),
            operation
        )
    }
}
//sampleEnd
fun main() {
    val poem = """
        Kotlin, the architect of code's vast plane,
        With extension properties, it breaks the chain.
        From realms to kingdoms, a structure so grand,
        In the world of languages, it takes a stand!
    """.trimIndent()
    println(poem)
}

Take some time to go over the code and make sure you understand what’s happening at every step. The most confusing part is probably the definition of destructure. The key thing to understand is that it doesn't actually do anything - it's only purpose is to allow us to wrap our code in a lambda that accepts the receiver as its only parameter, which we can then directly destructure. Don't forget that both sum and fold are extension functions, so their definitions are evaluated in the scope of the receiver. In other words, writing destructure { ... } is the same as writing this.destructure { ... }.

Very soon, we will talk aboutΒ scope functions, where you will learn that there exists a function calledΒ letΒ that basically does exactly the same thing asΒ destructureΒ (albeit with a different purpose in mind). In fact, if you replaced any usage ofΒ destructureΒ byΒ let, the code would keep on working. I’m only mentioning this to demonstrate that even such a simple thing as redefining a function under a different name can lead to a dramatic improvement in readability.

Even though this approach yields code that can be appealing to the eyes of functional programmers, it has objective down-sides. For one, since we’re wrapping the functionality in a destructure call, we can't mark the fold function as tailrec anymore. This could cause the stack to overflow if we use it on large lists.

The other problem (that was there all along) is that, since theΒ foldΒ function is recursive, we can’t mark it as inline, which isΒ what we should always try to do whenever passing other functions as arguments.

As is often the case, there ain’t no such thing as a free lunch, and one must often choose between code that is easier to read and code that is more performant. Ideally, you should always make this decision based on actual performance data, and refrain from optimizing prematurely.

Leave a Comment

This site uses Akismet to reduce spam. Learn how your comment data is processed.

The Kotlin Primer