Kotlin defines a few of extension functions like with() and apply() in its Standard.kt file. You probably have seen some of them in various tutorials or even used them already. Sometimes you may also wonder which one to use. In this post, I will walk through those confusing functions and see if we can understand the differences.

There are four (plus one added in 1.1) functions that look similar, and they are defined as follow:

/**
 * Calls the specified function [block] with `this` value as its receiver and returns its result.
 */
public inline fun <T, R> T.run(block: T.() -> R): R = block()

/**
 * Calls the specified function [block] with the given [receiver] as its receiver and returns its result.
 */
public inline fun <T, R> with(receiver: T, block: T.() -> R): R = receiver.block()

/**
 * Calls the specified function [block] with `this` value as its receiver and returns `this` value.
 */
public inline fun <T> T.apply(block: T.() -> Unit): T { block(); return this }

/**
 * Calls the specified function [block] with `this` value as its argument and returns `this` value.
 */
@SinceKotlin("1.1")
public inline fun <T> T.also(block: (T) -> Unit): T { block(this); return this }

/**
 * Calls the specified function [block] with `this` value as its argument and returns its result.
 */
public inline fun <T, R> T.let(block: (T) -> R): R = block(this)

If you try to read the comments, it’s very difficult to understand the differences immediately. Instead let’s read through the method signatures “literally” and look for things that are the same and ones that are different.

Things that are the same:

  1. T is the type of instance you want to operate with, and in Kotlin’s terminology it is called receiver type. Even though with() is not an extension method, the instance of T is still called receiver in method definition.
  2. The last argument is a function that you can supply to operate the receiver object. Since it’s the last argument, so Kotlin allows you move the function declaration out of the method parenthesis.

Things that are different:

  1. apply() and also() return T, but others return R.
  2. block in also() and let() is is a function that takes in T as the argument, but in others it is an extension function on type T.

Show me some code

Nothing is better than real code snippets, right?

Let’s start with Java version, so we can better understand how those Kotlin functions can help us write more concise and readable code at the same time as well.

StringBuilder builder = new StringBuilder();
builder.append("content: ");
builder.append(builder.getClass().getCanonicalName());

out.println(builder.toString()); // content: java.lang.StringBuilder

See the equivalent implementations and compare the functions

also() vs apply()

Let’s compare these two functions first:

  1. their return value is always this, which is the receiver instance with type T.
  2. the block returns Unit in both functions.

The same implementation:

StringBuilder().also {
    it.append("content: ")
    it.append(it.javaClass.canonicalName)
}.print() // content: java.lang.StringBuilder

StringBuilder().apply {
    append("content:")
    append(javaClass.canonicalName)
}.print() // content: java.lang.StringBuilder

the print() method is defined as for brevity:

fun Any.print() = println(this)

Both functions work exactly the same way, but with one subtle difference that in also() we have to use an explicit it variable to append content. In apply() we can directly call append() as if block was part of the instance.

Why? Because block is defined as (T) -> Unit, but it is defined as T.() -> Unit in apply().

So you can simply see apply() as a “simpler” version of also() that it has an implicit this defined to be used in the function body. also() requires you to use it, but it can be more readable in some cases, or you can even name the instance to fit your context better.

let() vs run()

Comparison:

  1. block is defined as (T) -> R in let(), but it is defined as T.() -> R in run()
  2. their return value is R from block function.

Implementation:

StringBuilder().let {
    it.append("content: ")
    it.append(it.javaClass.canonicalName)
}.print()

StringBuilder().run {
    append("content: ")
    append(javaClass.canonicalName)
}.print()

Similar to the previous comparison, let() requires an explicit it and run() has an implicit this in their block body.

However, this pair of functions has another major difference than also() and apply(). They return the value returned by the block body. In other words, both let() and run() return whatever block returns.

See the following sample:

StringBuilder().run {
    append("run (append):")
    append(" ")
    append(javaClass.canonicalName)
}.print() // run (append): java.lang.StringBuilder

StringBuilder().run {
    append("run (length):")
    append(" ")
    append(javaClass.canonicalName)
    length
}.print() // 37

The first one returns the enclosing string builder since append() returns the string builder itself, but the second one returns 37 since length() returns an integer.

So both let() and run() can be used when you want to operate an instance of T but you also want to have a different return value R.

with()

Implementation:

with(StringBuilder()) {
    append("content: ")
    append(javaClass.canonicalName)
}.print()

with() is not an extension function on type T, but it takes a instance of T as the first argument. Also Kotlin allows us to move the last function argument out of the parenthesis.

block is defined as T.() -> R so you don’t have to use it, and you can change the return value in block body.

Comparison Chart

I made a simple comparison chart to include the characteristics and hopefully we can see the differences more easily:

Name Is Extension Function? Return Type Argument in block block definition
also() yes T (this) explicit it (T) -> Unit
apply() yes T (this) implicit this T.() -> Unit
let() yes R (from block body) explicit it (T) -> R
run() yes R (from block body) implicit this T.() -> R
with() no R (from block body) implicit this T.() -> R

Thoughts:

These functions looks very similar and can be interchangable in some use cases. For better consistency and readability for Kotlin beginners, I would suggest that maybe start with also() and let() since they require developers to name the argument or use the explicit it in the block body. It may be slightly verbose than other functions, but it is always good to have some common ground among your team members when starting using Kotlin.