Author profile picture

Choosing between with() and run()

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:

  1. The context of the code block
  2. The value that the function returns

If you just consider these characteristics, with() is identical to the receiver version of run():

  1. They both set the receiver to this inside the code block.
  2. They both return the result of the block.

Here’s a comparison:

val size = "Hello".run {
    println(this)
    length
}
val size = with("Hello") {
    println(this)
    length
}

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 to size, just like in the earlier example.
  • On the other hand, if the receiver is null (and in our case, it is!), then run() will never get invoked here, and size will be set to null.

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:

  1. If you’ve got a nullable receiver, use run() to take advantage of the safe-call operator.
  2. 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.

Share this article: