In Kotlin’s standard library, the Standard.kt
file contains a number of scope functions that put a receiver object into a new scope as either this
or as an argument (e.g., it
).
The with()
function tends to be a bit of an outlier. The others are all extension functions, so they’re invoked on an object. with()
, on the other hand, takes its receiver (the object that it’s operating upon) as an argument.
Similarities
As explained in the guide Understanding Kotlin’s let(), also(), run(), and apply(), the scope functions vary on two main characteristics:
- The context of the code block
- The value that the function returns
If you just consider these characteristics, with()
is identical to the receiver version of run()
:
- They both set the receiver to
this
inside the code block. - They both return the result of the block.
Here’s a comparison:
|
|
As you can see, the only difference in syntax is on the outside of the code block - that is, where "Hello"
is located in each example.
So they’re the same, right?
In terms of how they compile, they’re almost identical, not counting a few NOP
and LINENUMBER
instructions in the bytecode. In fact, if you run IntelliJ’s decompiler on that bytecode, you’ll get the same thing in both cases. But there are still some trade-offs you make when you choose one over the other.
The benefit of extension functions
Whenever you can choose between an extension function and a function that takes the same object as an argument, the extension function always has an edge, because it gives you an interception point for nulls.
Consider the following:
fun getReceiver(): String? = null
val size = getReceiver()?.run {
println(this)
length
}
The function getReceiver()
declares that it returns a null
or a String
, but of course, we have it hard-coded to return a null
for demonstration purposes.
The important part to notice is the ?.
, which is the safe-call operator. Here’s how it plays out:
- If the receiver is a real
String
, the code block executes, and the length is assigned tosize
, just like in the earlier example. - On the other hand, if the receiver is
null
(and in our case, it is!), thenrun()
will never get invoked here, andsize
will be set tonull
.
There’s no equivalent when using with()
. Sure, you can pass it a null
, but the result is different:
fun getReceiver(): String? = null
val size = with(getReceiver()) {
println(this)
this?.length
}
Similarly to the previous listing, size
will be set to null
, but most importantly, the code block will execute, so the word null
gets printed out at the println(this)
statement.
The takeaway is that because extension functions are invoked with a dot, they give you a hook for a safe-call operator.
The benefit of with()
Readability is an important quality of your code. Although there are some elements of readability that are probably universal (like, really… nobody would appreciate it if you were to scatter the words of your sentence all around a page in no particular order!), there’s also a lot of subjectivity involved.
Personally, I find with(someObject) { ... }
to read better than someObject.run { ... }
. It just fits English syntax better - the word “with” is followed by an object of the preposition.
Recommendation
You’ve seen the main trade-offs above, so you can make a judgement call for yourself. My recommendation is:
- If you’ve got a nullable receiver, use
run()
to take advantage of the safe-call operator. - In all other cases, use
with()
for readability.
Of course, this assumes that run()
and with()
have the characteristics you need. You can also consider other scope functions to see if they fit your particular use case better.