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.
— — — — — — — — — — — — — — —
THE CURRENT VERSION OF THIS ARTICLE IS PUBLISHED HERE.
This article is part of the Kotlin Primer, an opinionated guide to the Kotlin language, which is indented to help facilitate Kotlin adoption inside Java-centric organizations. It was originally written as an organizational learning resource for Etnetera a.s. and I would like to express my sincere gratitude for their support.
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>:
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:
Using delegates, operators and extension functions, we can actually create an implementation that can be used in place of any List<T>:
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:
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.
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.