Scope functions are, as the name says, functions that run some code in a separated context. Anything declared in the given scope will be accessible only inside this code block. The keywords for these scope functions are let, also, apply, run and with. This article will cover the differences between them, and how and when to use them.
Scope functions
As stated in the introduction, scope functions execute code in a very limited context. In that context, the variables that serves as the context can be referenced as it. This makes the code very simple to read.
Scope functions do not necessarily provide a new functionality. Readability is a major topic, however, in development. This is the main goal of the scope functions. They clearly define where the scope starts, as well as ends, and the actions that are performed upon the context.
Any object instance can serve as such a context. An extremely simple example would be the following snippet:
"Hello".let { println(it) }
The snippet above may be useless, but it shows how anything can become the subject of the scope!
The biggest difference between the scope functions are the following:
- The object reference (it or this)
- Return value (context object or rescoped object)
Let
Let is probably the most widely used scoped function, especially after a null check. As shown in the example above, however, it can also simply scope a non-nullable value as well.
Here is some notable information about let:
- The scoped context can be referenced using it
- When used in null checks, smart casting makes the object non-nullable
- Returns the value from the last line
A sample usage of let
would be:
data class Person(val username: String, val nickname: String? = null, val birthdate: LocalDate = LocalDate.now())
@Test
fun let(){
val voidReturn = Person("Joe").let {
println("The person's birthdate is ${it.birthdate}")
println(it)
}
println("voidReturn = $voidReturn")
val stringReturn = Person("Maria").let {
println("The person's birthdate is ${it.birthdate}")
it.username
}
println("stringReturn = $stringReturn")
}
The code snippet above would return the following:
As you can see, let
returns the value from the last line.
Now, a more useful example would be to perform some code snippet that only runs on a condition. An example would be something like:
enum class Channel{EMAIL, NOTIFICATION, SMS}
data class Evaluation(val distribution: Distribution? = null, val outcome: String)
data class Distribution(val channel: Channel = Channel.EMAIL, val message: String = "Welcome")
class NotificationService(){
fun notify(distribution: Distribution){
// doSomething
}
}
@Test
fun let_conditional(){
val notificationService = NotificationService()
Evaluation(outcome = "Accepted").distribution?.let { notificationService.notify(it) }
}
Now, here we have some code that would conditionally apply some additional operations on the object if that object is not null, and otherwise simply continue. This is very frequently used.
Run
Run is very similar to let
and can be used almost interchangeably. The biggest difference is that it works
with the keyword this instead of it
.
In fact, it is so similar that you could simply replace the code above like so:
@Test
fun run_conditional(){
val notificationService = NotificationService()
Evaluation(outcome = "Accepted").distribution?.run { notificationService.notify(this) }
}
run
also returns the last line of the block, same as let
. If we create the exact same test as before, we’d have
the following:
@Test
fun run(){
val voidReturn = Person("Joe").run {
println("The person's birthdate is ${this.birthdate}")
println(this)
}
println("voidReturn = $voidReturn")
val stringReturn = Person("Maria").run {
println("The person's birthdate is ${this.birthdate}")
this.username
}
println("stringReturn = $stringReturn")
}
which would produce the exact same result:
Now, what is the difference exactly? Well, there really isn’t one. However, run can also be used as a standalone block without an object as an extension.
@Test
fun run_standalone(){
run{
val myString = "Hi"
println(myString) // prints "Hi"
}
println(myString) //wouldn't compile
}
This is where the main difference is, in my opinion. If I ever need a conditional block, I always use let
. If I ever need
to quickly declare some values, perform some operations, and then throw away those variables, then I’d use run
.
Also
Now, when looking at also, the main difference is that it returns the context object. Here, we could chain multiple
lambdas on the same object if we wanted to, since every also
returns the scoped object itself (albeit with changes, if those are
performed inside the lamdba). also
uses the it
keyword again in its scope.
data class Person(val username: String, var nickname: String? = null, val birthdate: LocalDate = LocalDate.now())
@Test
fun also() {
val person = Person("Joe").also {
println("The person's birthdate is ${it.birthdate}")
}.also {
println(it)
}.also {
it.nickname = "Now it's got a nickname"
}
println("Also return = $person") // The nickname from the last also will be in here
}
Now, an actual usecase in my code is from the following snippet:
val coinList: MutableList<CryptoAsset>? = coinGeckoGateway.loadGreatestCoins(page).collectList().block()
coinList?.map {
cryptoAssetRepository.findById(it.id).toNullable()?.also { coin -> coin.price = it.price } ?: it
}
?.map { it.addNewSnapshot() }
?.let { cryptoAssetRepository.saveAll(it) }
- A service call is made to retrieve coin values
-
Each coin is looked up
- If present, the price value is set (but other values, such as the ID, are form the DB)
- If not present, the coin is integrally taken from the service call
- Either way, in the end, the coin contains the updated price
- Mapping of an additional snapshot takes place the same for all coins, no matter whether they went through the
also
block - The coins are saved. New coins are now assigned an id, the others are simply updated
Apply
The difference between apply and also
is the same as it was earlier between let
and run
. The test from before could
be rewritten as such:
@Test
fun apply() {
val person = Person("Joe").apply {
println("The person's birthdate is ${this.birthdate}")
}.apply {
println(this)
}.apply {
this.nickname = "Now it's got a nickname"
}
println("Apply return = $person") // The nickname from the last also will be in here
}
It would have the exact same output.
Again, the difference is mostly a user preference. For example, apply would be used in cases where additional instantiation is required.
@Test
fun actual_apply() {
val person = Person("Joe").apply {
this.nickname = this.nickname?: this.username
}
println("The first person is = $person") // nickname is set for him
val person2 = Person("Joe", nickname = "The baws").apply {
this.nickname = this.nickname?: this.username
}
println("The second person is = $person2") // no nickname override
}
The output would be the following:
Again, this would have worked just fine with also
. It just applies some additional operation during the construction, and may
be just a little more readable.
With
Finally, there’s the last scope function, with. The usage is a little bit different here, as it is actually not an extension
function on an object. with
takes an object as a variable for a small scope. With returns the rescoped object, meaning the
last item of the list. Here’s a small snippet and its evaluation:
@Test
fun with() {
val people = listOf(Person("Joe"), Person("Maria", nickname = "The baws"))
val whatAmI = with(people){
println("There are ${this.size} people in the list")
println("The last person is ${this.last().username}")
}
println("And I am... drumroll... $whatAmI") // what do you think?
}
Well, I said before that it would return the last operation from inside the scope, so it should be Unit. Let’s see…
So, yes, it indeed returns Unit. Kind of anti-climactic, I’m sorry about that… :)
Sooo, there you have it. When I started out with learning Kotlin, I thought that scope functions were nice, but also kind of intimidating, as it was hard to see which one to use at what point. However, as you can see, there really aren’t many differences!! It mostly is a personal preference, and yeah, there may be some small guidelines, but really, it’s up to you.