0

I'm trying to build a good mental model for lambdas with receivers in Kotlin, and how DSLs work. The simples ones are easy, but my mental model falls apart for the complex ones.

Part 1

Say we have a function changeVolume that looks like this:

fun changeVolume(operation: Int.() -> Int): Unit {
    val volume = 10.operation()
}

The way I would describe this function out loud would be the following:

A function changeVolume takes a lambda that must be applicable to an Int (the receiver). This lambda takes no parameters and must return an Int. The lambda passed to changeVolume will be applied to the Int 10, as per the 10.lambdaPassedToFunction() expression.

I'd then invoke this function using something like the following, and all of a sudden we have the beginning of a small DSL:

changeVolume {
    plus(100)
}

changeVolume {
    times(2)
}

This makes a lot of sense because the lambda passed is directly applicable to any Int, and our function simply makes use of that internally (say 10.plus(100), or 10.times(2))

Part 2

But take a more complex example:

data class UserConfig(var age: Int = 0, var hasDog: Boolean = true)
val user1: UserConfig = UserConfig()

fun config(lambda: UserConfig.() -> Unit): Unit {
    user1.lambda()
}

Here again we have what appears to be a simple function, which I'd be tempted to describe to a friend as "pass it a lambda that can have a UserConfig type as a receiver and it will simply apply that lambda to user1".

But note that we can pass seemingly very strange lambdas to that function, and they will work just fine:

config {
    age = 42
    hasDog = false
}

The call to config above works fine, and will change both the age and the hasDog properties. Yet it's not a lambda that can be applied the way the function implies it (user1.lambda(), i.e. there is no looping over the 2 lines in the lambda).

The official docs define those lambdas with receivers the following way: "The type A.(B) -> C represents functions that can be called on a receiver object of A with a parameter of B and return a value of C."

I understand that the age and the hasDog can be applied to the user1 individually, as in user1.age = 42, and also that the syntactic sugar allows us to omit the this.age and this.hasDog in the lambda declaration. But how can I reconcile the syntax and the fact that both of those will be run, sequentially nonetheless! Nothing in the function declaration of config() would lead me to believe that events on the user1 will be applied one by one.

Is that just "how it is", and sort of syntactic sugar and I should learn to read them as such (I mean I can see what it's doing, I just don't quite get it from the syntax), or is there more to it, as I imagine, and this all comes together in a beautiful way through some other magic I'm not quite seeing?

  • Documentation explains perfectly. Your question is actually understanding the ````apply```` extension function in the kotlin. https://kotlinlang.org/docs/scope-functions.html#this Your lambda expects UserConfig as this. So basically you are doing this.age this.hasDog – Y.Kakdas Apr 06 '21 at 12:59
  • They are also aware that confusion and therefore put that note: On the other hand, if this is omitted, it can be hard to distinguish between the receiver members and external objects or functions. So, having the context object as a receiver (this) is recommended for lambdas that mainly operate on the object members: call its functions or assign properties. – Y.Kakdas Apr 06 '21 at 13:00
  • You say "Yet it's not a lambda that can be applied the way the function implies it". Why not? It can be called like that and it is. Now this example is a bit odd, you are taking a `UserConfig` and you're doing nothing with it, you're just overwriting its fields with other values. I would say this is not a nice API. If I see something like that I imagine that the input user is going to be used somehow. If your example lambda did `age = age * 2` that would make more sense. – al3c Apr 06 '21 at 13:01
  • @Y.Kakdas yes this part I'm clear on, and agree that using the this.age makes it explicit. – thunderbiscuit Apr 06 '21 at 16:38
  • @al3c I guess my question is more about the syntax of how user1.lambda() translates into the lambda being applied to the user1 object (agreed the API is ugly, I was trying to build a small self-contained example). Note from the answer below from Tenfour that you can have an extensive lambda with tons of lines, plenty of which would not be applicable as user1.lambda() (say user1.println("I am $age years old"). That's sort of what I'm trying to reconcile with my intuitive sense of what the code means to do when you write the config() function, which has one line only, user1.lambda(). – thunderbiscuit Apr 06 '21 at 16:41

1 Answers1

3

The lambda is like any other function. You aren't looping through it. You call it and it runs through its logic sequentially from the first line to a return statement (although a bare return keyword is not allowed). The last expression of the lambda is treated as a return statement. If you had not defined your parameter as receiver, but instead as a standard parameter like this:

fun config(lambda: (UserConfig) -> Unit): Unit {
    user1.lambda()
}

Then the equivalent of your above code would be

config { userConfig ->
    userConfig.age = 42
    userConfig.hasDog = false
}

You can also pass a function written with traditional syntax to this higher order function. Lambdas are only a different syntax for it.

fun changeAgeAndRemoveDog(userConfig: UserConfig): Unit {
    userConfig.age = 42
    userConfig.hasDog = false
}

config(::changeAgeAndRemoveDog) // equivalent to your lambda code

or

config(
    fun (userConfig: UserConfig): Unit {
        userConfig.age = 42
        userConfig.hasDog = false
    }
)

Or going back to your original example Part B, you can put any logic you want in the lambda because it's like any other function. You don't have to do anything with the receiver, or you can do all kinds of stuff with it, and unrelated stuff, too.

config {
    age = 42
    println(this) // prints the toString of the UserConfig receiver instance
    repeat(3) { iteration ->
        println(copy(age = iteration * 4)) // prints copies of receiver
    }
    (1..10).forEach {
        println(it)
        if (it == 5) {
            println("5 is great!")
        }
    }
    hasDog = false
    println("I return Unit.")
}
Tenfour04
  • 83,111
  • 11
  • 94
  • 154
  • Thank you for your answer! I'm starting to see it more. My problem was really with the `user1.lambda()` syntax, which feels like it would warrant the passing of something that can be directly applicable to `user1` (say a method). I see now is that it's not really the case, but rather that the syntax is saying "using `user1` as a receiver, run this arbitrary function", which then can have all sorts of funky stuff in it, including things that would not make sense to call on the receiver (say `user1.println("I return")`, but which also allows making use of that receiver if one wishes. – thunderbiscuit Apr 06 '21 at 16:46
  • 1
    If you're familiar with extension functions in Kotlin, it's kind of like those. It makes it look like a function you can call on the object, but is effectively treating the object as a parameter of that function. – Tenfour04 Apr 06 '21 at 17:13
  • Also note, you can also pass functions using `::` syntax like I showed above, and it's considered a matching signature if the first parameter of the passed function is the same as the receiver parameter. For example, you could have a function defined `fun doSomethingToUserConfig(userConfig: UserConfig)` and pass it to your `config` function with `config(::doSomethingToUserConfig)` and the compiler won't complain even though the function is not defined with the UserConfig as the receiver. – Tenfour04 Apr 06 '21 at 17:17
  • Aaaaah dude that is _exactly_ the image I needed (regarding your first comment). If you think of the lambda as a function to which you pass the receiver as the first argument (just like extension functions), then it aaalllll makes sense, and the syntax becomes just a case of "it oddly looks like something else but once you're used to it you can easily tell them apart" type of thing. Thank you! Stellar answer. – thunderbiscuit Apr 06 '21 at 17:27