Thursday 15 September 2022

Kotlin: scope functions (mostly)

G'day:

More Kotlin learning/investigation, following on from these other Kotlin articles. This time I didn't even get to start the next koans exercise before being sidetracked.


Scope functions

There's a bunch of functions - let, with, run, apply, and also - that one calls on an object; they each take a lambda, and within the lambda expressions act on the object either via it (a parameter of the lambda), or this (a reference to the object), or simply implicitly (no reference to the object at all!). They all seem to do similar stuff - the docs even say so - they're all just variants that are possibly better-suited to one use case or another. I have written a test for each of them, keeping the "feature" being tested similar across all of them to show the differences.

let

it("provides an object to run the statements in the lambda with") {
    val result = listOf(
        Colour("whero"),
        Colour("karaka"),
        Colour("kōwhai")
    ).first()
    .let {
        it.english = "red"
        "${it.maori}: ${it.english}"
    }
    result shouldBe "whero: red"
}

The intent of let is to be able to chuck it in a chain of other method calls. In this contrived example I have a list of colours, I get the first one, and I call let on that. The let lambda just fills in the English value, and returns a string of "[Maori]: [English]" for that colour.


with

it("calls the statements in the lambda with the given object") {
    val green = Colour()

    with(green) {
        maori = "kākāriki"
        english = "green"
    }
    "${green.maori}: ${green.english}" shouldBe "kākāriki: green"
}

with is for when one wants to go "with this object, do some stuff to it". Here the statements are applied to this (the argument of with), implicitly. I could explicitly say this.maori = "kākāriki". I also found the term for those functions that are just functions (doSomething(someObject)), rather than being used like how I'd expect a method to be used: someObject.doSomething(). The term is "non-extension function".

It can also return a value (as per usual with lambdas in Kotlin: the last expression is returned):

it("will return something") {
    val purple = with(Colour()) {
        maori = "papura"
        english = "purple"

        this
    }
    "${purple.maori}: ${purple.english}" shouldBe "papura: purple"
}

For the return, I need to be explicit about this otherwise Kotlin won't know I mean to return it.

run

This is like a combo of with and let: it's an extension function, so can be called in a chain, but it receives the value of the chain as its (implicit) this:

it("applies the statements in the lambda to the object") {
    val blue = listOf(
        Colour("whero"),
        Colour("kākāriki"),
        Colour("kikorangi")
    ).last()
    .run {
        english = "blue"

        this
    }
    "${blue.maori}: ${blue.english}" shouldBe "kikorangi: blue"
}

This is similar to the let example except no need to use "it" when referencing the object.


apply

describe("Tests of apply function") {
    it("applies the assignments in the lambda to the object") {
        val yellow = Colour().apply {
            maori = "kōwhai"
            english = "yellow"
        }
        "${yellow.maori}: ${yellow.english}" shouldBe "kōwhai: yellow"
    }
}

apply seems like an extension function (look at me with my new jargon) version of with. It implicitly returns the original object though. So - and I'm just paraphrasing the docs here - it's idiomatic usage seems to be for configuring an object. One is applying the assignemnts in the lambda to the object.


also

it("also runs the statements in the block with the object") {
    val indigo = Colour().also {
        it.maori = "tūāuri"
        it.english = "indigo"
    }
    "${indigo.maori}: ${indigo.english}" shouldBe "tūāuri: indigo"
}

I'm just gonna quote the docs for this one, as it describes it kinda like how it occurred to me to do so:

When you see also in the code, you can read it as "and also do the following with the object."

Cos it's an extension function, it too could be slung in the middle of some other chain of calls. NB: it implicitly returns the original object.


Callable references

Something cool that the docs used which I will repeat here is passing the object to a callable reference to a function:

it("can use a callable reference to use a function as the callback") {
    val orange = Colour("karaka", "orange")

    val output = SystemLambda.tapSystemOut {
        "${orange.maori}: ${orange.english}".also(::print)
    }

    output shouldBe "karaka: orange"
}

See how I'm passing my string to also, and the lambda is passing it to a reference to print. One would not do this in real life, but it demonstrates the point. If one prefixes a function with the :: operator, one gets a callable reference to that function.


takeIf and takeUnless

The "Scope functions" page of the docs has these two tacked onto the end of it, so I'll do the same.

takeIf

it("returns the object if the predicate is true, otherwise null") {
    var number = 210

    var checked = number.takeIf { it % 2 == 0 }
    checked shouldBe number

    checked = checked?.takeIf { it % 3 == 0 }
    checked shouldBe number

    checked = checked?.takeIf { it % 5 == 0 }
    checked shouldBe number

    checked = checked?.takeIf { it % 7 == 0 }
    checked shouldBe number

    checked = checked?.takeIf { it % 11 == 0 }
    checked.shouldBeNull()
}

The test case kinda explains what this one does. And I admit the actual test is a bit daft.

Note how I need to use the null-safe operator after the first takeIf expression, as the object could be null after that. The code won't compile unless I do. Indeed IntelliJ tells me when I've got it wrong:

(I am really loving the static type-checking in Kotlin, btw).


takeUnless

takeUnless works the other way around: it returns the object if the predicate is false (otherwise null). Why have both? Sometimes the code just reads better with different wording.

it("returns the object if the predicate is false, otherwise null") {
    val number = 210

    number.asClue {
        var checked = it.takeUnless { it % 2 != 0 }
        withClue("should be a multiple of 2") {
            checked shouldBe it
        }

        checked = checked?.takeUnless { it % 3 != 0 }
        withClue("should be a multiple of 3") {
            checked shouldBe it
        }

        checked = checked?.takeUnless { it % 5 != 0 }
        withClue("should be a multiple of 5") {
            checked shouldBe it
        }

        checked = checked?.takeUnless { it % 7 != 0 }
        withClue("should be a multiple of 7") {
            checked shouldBe it
        }

        checked = checked?.takeUnless { it % 11 != 0 }
        withClue("should NOT be a multiple of 11") {
            checked.shouldBeNull()
        }
    }
}

For a change here I'm using those asClue and withClue constructs that Kotest has, as mentioned in the previous article. To recap: this just adds context and hinting to test failures. Let me break a condition there to show you. I've slung this in as the last condition in that previous test:

checked = checked?.takeUnless { it % 13 != 0 }
withClue("should be a multiple of 13") {
    checked shouldBe it
}

And this yields:

210
java.lang.AssertionError: 210
should be a multiple of 13
Expected 210 but actual was null

And that was the bottom of that page of docs, and this is long enough, so I'll leave it here. I suspect I will be back tomorrow with more…

All the code shown here is on Github: /src/test/kotlin/language/ScopeFunctionsTest.kt

Righto.

--
Adam