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.