kotlinlambdakotlin-dsl

Kotlin lambdas with receivers: seeking clarification on my mental model


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?


Solution

  • 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.")
    }