I find myself completely at ease with them after discovering this gist. It all makes sense when you see it laid out
There is a fundamental lie in object oriented programming. The lie is that functions or methods are intrinsically properties of their enclosing objects. Say I have a class
data class Foo(val bar: Int, val baz: String) {
fun combine(other: Foo): Foo {
return Foo(this.bar + other.bar, this.baz + other.baz)
}
}
and I want to use the ‘combine’ method. I might use it like this
fun main(vararg args: String) {
val one = Foo(1, "one")
val two = Foo(2, "two")
// Common invocation
val three = one.combine(two)
println(three)
}
This should be fairly familiar. Foo owns a ‘combine’ operation, and so we use the dot operator on an instance of Foo in order to invoke it.
It’s not necessarily a ‘problem’, but the ‘thing’ is, there’s no reason to do it this way. Access modifiers on properties of data types, and subsequently intrinsic methods to operate on private properties, are just a superfluous idea from the OO world. If implementation or state needs to be hidden, that should be done using interfaces (or type classes in more functional languages).
With just as much validity, we could implement the above as the following:
data class Foo(val bar: Int, val baz: String)
fun combine(some: Foo, another: Foo): Foo {
return Foo(some.bar + another.bar, some.baz + another.baz)
}
fun main(vararg args: String) {
val one = Foo(1, "one")
val two = Foo(2, "two")
// Pure external function instead of instance method
val three = combine(one, two)
println(three)
}
And now we realize the first glimpse of the problem you are worried about. Why are there two different ways to implement and invoke a ‘combine’ function? How do I choose between the two? It is this seed of uncertainty that leads down the path to the helper functions we have today, and you mention in your post.
You can see from the above example that instance methods have questionable purpose. In OOP it is touted from private state management. For languages with weak support for function composition, it is (half as) useful for ‘fluent’ programming and chaining method calls. You can think of instance methods as a strange pattern of organizing functions for infix invocation.
this.combine(another)
compared to
combine(this, another)
There really isn’t a difference.
BUT. It’s the world we live in. We have this strange notion of ‘this’, the current instance of a class or object enclosing a method. So how do we cope in this world? How do we cope in a world where we really like ‘fluent programming’?
Sometimes (very very very frequently, actually), a class doesn’t define all the methods that would be useful for it to have. What if we could define our own methods on other people’s classes? Enter extension functions.
Why isn’t creating a normal function that just consumes the class as an argument good enough? No real virtuous reason, people just really like and are familiar with using dot operator aka infix invocation they are used to with OO methods.
So you’ll notice that whenever you define an extension function, you are actually just defining a normal function, with the only magic part being that the invocation target is an implicit first parameter. This implicit first parameter is named ‘this’.
data class Foo(val bar: Int, val baz: String)
fun Foo.reverse(): Foo {
return Foo(-this.bar, this.baz.reversed())
}
In the above sample right here, the function reverse()
is NOT a function with zero parameters… it is a function with one implicit parameter, that gets called ‘this’. The fact that reverse()
is an ‘extension function’ rather than a plain function is just sugar.
So now we have this notion of an implicit parameter ‘this’. But you will notice something interesting. Any time a block of code has a contextual ‘this’, you don’t have to actually ever say ‘this’. All the properties of the object are implicitly brought into scope as well.
data class Foo(val bar: Int, val baz: String)
fun Foo.reverse(): Foo {
return Foo(-bar, baz.reversed())
}
That detail is sugar as well. It is just pure sugar. If you like the taste of it, then it’s good as far as you like it. But that being said, none of it is really real. When you boil it down, what we have is still just a data type and a function with normal arguments.
Now, keep all that in mind, and we shift topics slightly.
Lambda functions are useful because it lets us introduce ad-hoc functions, which is useful for any language supporting higher order functions (functions are literally data, just like any other piece of data). Lambdas, similar to instance methods, are ‘just functions’ as well, no matter how much sugar we sprinkle on top. One particularly useful piece of sugar however, is an implicit first parameter. Sound familiar? Just like how instance methods have an implicit first parameter of ‘this’, lambdas (can) have an implicit single parameter of ‘it’. You can rename the parameter if you want, but the implicit parameter is always there for your convenience whenever you want it.
So all that being said, you’ll notice we have a few different ideas about how to dump sugar on our functions:
- There are plain functions with no implicit arguments
- There are ‘instance methods’ that are really just functions with an implicit first argument, called ‘this’. Every property of ‘this’ is also implicitly brought into scope.
- There are lambdas with single arguments, where we can have an implicit argument called ‘it’ if we so choose
- And lastly there are extension functions, which are just knockoffs of instance methods
So, finally, to address your concerns, we hone in on the extension functions.
At the very very simplist level, we would just have plain data and pure plain functions. However, we now have all this sugar at our disposal. You are writing a function for your project. Or perhaps you’re not writing a function directly, rather you want to take an object and do stuff to it. So you decide to use a function or lambda of some kind. With all these different types sugar for your function or lambda, how do you choose?
The choice is up to you. But by now, hopefully the usefulness of that gist I provided at the top of this post is starting to show. Here is how I walk through the choice:
Will I be feeding my object to a named function, or a lambda?
- With a named function, just use it, nevermind all these extension functions. These extension functions are at least 90% intended for use with lambdas.
Do I want implicitly to bring the properties of the target object into the scope of my lambda?
- If yes, then use one of the extension functions that passes the target as ‘this’ (
apply
or run
or with
).
- If no, then use one of the extension functions that passes the target as ‘it’ (
also
or let
).
- This is an important decision. I recommend heavily leaning towards ‘it’ instead of ‘this’ options, unless your lambda is specifically for initializing properties, or something else where implicitly bringing stuff into scope is useful. (you never actually have to type ‘this’, but still have to type ‘it’).
What do I want the return value of my lambda to be?
- The target (
also
or apply
)?
- or the lambda return value (
let
or run
or with
)?
How do I want to invoke my lambda?
- dot operator / fluent style? Use the extension functions (
also
apply
let
run
)
- normal function invocation? Use
with
to pass the target as ‘this’, or just use the invoke operator ()
directly on the labmda, feeding the object as an argument there (if you want the target passed as ‘it’)
And with all that in mind, you can actually see the method (pun unintended) in the madness. All these different choices are really just the different combinations of sugar. The more varieties of sugar we have for function definition, then combinatorially the more helpers we need for function invocation (to achieve equal helpfulness for all possible combinations of sugar). While there may be a lot of options, this isn’t a slippery slope. All the combinations are covered. The only way we’d add more is if we invented even more sugars for function definition (which to be fair, may happen, but still isn’t a huge slope even if it is a slippery one).
Hopefully that addresses most of your concern
You may notice the outlier takeIf
. The idea with takeIf
and takeUnless
are to evaluate predicate that determines an already fixed specified value. The the others have a slightly different intention, which is to perform operations on (or otherwise consume) the target. Without 100% pure functional and immutable guarantees, someone could still do something stupid and funky inside takeIf
(like mutate a property), but I think more experienced coders would cry foul and recommend the other helper functions in such a case.
At this rate, you may notice the semantics of the names of these functions starts to fit into place.
-
also
- return the target, but also do this thing to it
-
apply
- return the target, but apply some operations to it first - these operations borrowing the target’s internal scope (implicit this)
-
let
- let the target (it) be transformed by this lambda into the result of the lambda (this is kind of like the map
function, but on a single object instead of a collection)
-
run
- Take this object and borrowing its internal scope, run this lambda, returning the result of the lambda
-
with
- With a normal function argument, borrow its internal scope (implicit this) and return the result of the lambda
Granted, englishifying how they work is very contrived, and maybe some of the function names work better than others, but the semantics fit at least enough for me to tell them apart.
tl;dr:
These helper methods are an artifact of OOP and sugar to support OOP. That’s not to insult, Kotlin is actually my favorite OOP language I’ve ever used and I think they are super useful as far as OOP goes. It just inflates the need for a helper-function vocabulary a bit. If you want a more cohesive, trim, and refined experience, I recommend giving an ML family language a try (e.g. haskell, purescript, Standard ML)