Function Types with Receivers in Kotlin - The Power Behind Kotlin's apply, with, and run Scope Functions

nfragiskatos

Nicholas Fragiskatos

Posted on October 18, 2023

Function Types with Receivers in Kotlin - The Power Behind Kotlin's apply, with, and run Scope Functions

When we invoke one of Kotlin's Scope functions (apply, run, let, also, or with) a temporary scope/context is created for the object, and you can access the object without its name. Depending on the function you can reference the context object using either it, or implicitly with this.

The apply, run, and with functions provide the context object with this, and most interestingly, as a result, they let you define your function block as if it were a native member of the object's type.

For example, consider the following:

val myList = mutableListOf("Ron", "Leslie", "Ben", "Ann")  

myList.apply {  
    add("Chris")  
    remove("Ron")  
}

println("[${myList.joinToString(", ")}]")

// Output: ["Leslie", "Ben", "Ann", "Chris"]

Enter fullscreen mode Exit fullscreen mode

add and remove are both functions as defined by the MutableList interface, but because of the lambda's implicit this context, we can reference them without any qualifiers. Unlike if using let, for example. However, even with let, we can achieve the same outcome:

val myList = mutableListOf("Ron", "Leslie", "Ben", "Ann") 

myList.let {  
    it.add("Chris")  
    it.remove("Ron")  
}

println("[${myList.joinToString(", ")}]")

// Output: ["Leslie", "Ben", "Ann", "Chris"]

Enter fullscreen mode Exit fullscreen mode

We can take a look at the definition of the apply function and see it's very short. The real functionality that we are interested in lies in the function's argument, and the last two lines.

@kotlin.internal.InlineOnly  
public inline fun <T> T.apply(block: T.() -> Unit): T {  
    contract {  
        callsInPlace(block, InvocationKind.EXACTLY_ONCE)  
    }  
    block()  
    return this  
}

Enter fullscreen mode Exit fullscreen mode

💡

We can disregard thecontract{}block. That's a Kotlin feature that allows the developer to share some extra metadata with the compiler.Here is a good blog post about it. For this discussion about Function Types with Receivers, it's not necessary.

From just naively using the apply function, we know that it runs some block of code that we pass to it and that block of code is within the this context, and lastly the apply function returns the context object itself as a result.

That's exactly what we see happening in the last two lines of the function. There's an invocation of block, which is the functional parameter passed by the caller, and then finally it just returns this.

However, block doesn't seem to be a regular functional parameter. We notice that its type is a little different. It looks more like an Extension Function

block: T.() -> Unit

Enter fullscreen mode Exit fullscreen mode

The parameter is similar but not quite an extension function. It is instead a Function Type with a Receiver. Let's dive deeper into what that means by first starting with breaking this feature down into its two key parts, function types and receivers.

Function Types

Just like variables, functions in Kotlin can also have a type. A function type uses special notation that defines the function's arguments and return type. Function types have the following general template:

(A, B, C,...) -> Z

This is a type that represents a function with arguments of types A, B, C, etc. and returns a value of type Z.

Some concrete examples:

  • () -> Unit, a function that takes no arguments, and returns nothing.

  • (String) -> Int, a function that takes a String argument and returns an Int.

  • ((Char) -> Unit) -> Unit, a higher-order function that takes a function as an argument and returns nothing.

Kotlin, like many other programming languages, treats functions as first-class citizens. This means you can use a function in the same ways you would use any other type: assigning functions to variables, passing them as arguments to other functions (higher-order functions), and even returning a function as a result of another function.

Kotlin provides us with a good bit of freedom with how we can define an implementation of a function type. No matter which way it is defined it can still be used in a higher-order function and it will provide the same results.

As an example, let's take a look at the filter function that we have access to when working with a Collection. When we call filter all we must do is make sure to provide it with a function type that accepts an argument of type T and returns a boolean.

public inline fun <T> Iterable<T>.filter(predicate: (T) -> Boolean): List<T> {  
    return filterTo(ArrayList<T>(), predicate)  
}

Enter fullscreen mode Exit fullscreen mode

Here are the different ways we can satisfy this functional argument when invoking filter.

Lambda Functions

Lambda functions allow the developer to quickly and conveniently define an implementation for a functional argument when invoking a higher-order function without first having to define it elsewhere. This is probably the most common way of invoking functions with a functional argument.

val myList = mutableListOf("Ron", "Leslie", "Ben", "Anne", "Chris")

val filterByLambda: List<String> = myList.filter { str: String ->  
    str.isNotBlank() && str.length > 4  
} 

println("filterByLambda: [${filterByLambda.joinToString(", ")}]")

// Output: 
// filterByLambda: ["Leslie", "Chris"]

Enter fullscreen mode Exit fullscreen mode

Anonymous Functions

Anonymous functions are similar to lambda functions but with different syntax.

val myList = mutableListOf("Ron", "Leslie", "Ben", "Anne", "Chris")

val filterByAnonymous: List<String> = myList.filter(fun(str): Boolean {  
    return str.isNotBlank() && str.length > 4  
})  

println("filterByAnonymous: [${filterByAnonymous.joinToString(", ")}]")

// Output: 
// filterByAnonymous: ["Leslie", "Chris"]

Enter fullscreen mode Exit fullscreen mode

Function Variables

We can also store a function literal inside a variable with a matching function type. Then you just pass that variable to the function like you would any other argument.

val myList = mutableListOf("Ron", "Leslie", "Ben", "Anne", "Chris")

val myFilterFunctionVariable: (String) -> Boolean = { str: String ->  
    str.isNotBlank() && str.length > 4  
}  

val filterByFunctionVariable: List<String> = myList.filter(myFilterFunctionVariable)  

println("filterByFunctionVariable: [${filterByFunctionVariable.joinToString(", ")}]")

// Output: 
// filterByFunctionVariable: ["Leslie", "Chris"]

Enter fullscreen mode Exit fullscreen mode

Function Declarations to Function Types

Lastly, we can take an existing, traditional declaration of a function and use the member reference operator (::) to create an instance of a function type from the declaration.

val myList = mutableListOf("Ron", "Leslie", "Ben", "Anne", "Chris")

fun myFilterPredicate(str: String) : Boolean {  
    return str.isNotBlank() && str.length > 4  
}

val filterByNamedFunction: List<String> = myList.filter(::myFilterPredicate)  

println("filterByNamedFunction: [${filterByNamedFunction.joinToString(", ")}]")

// Output: 
// filterByNamedFunction: ["Leslie", "Chris"]

Enter fullscreen mode Exit fullscreen mode

Receivers

Receivers are something we use all the time, but we just don't think about it. Essentially, any time you are using dot notation to reference some member function for a specific instance of an object, you are using a receiver. For example, if we have myObj.someFunction(), myObj is considered the receiver of the function call someFunction.

Each receiver has its own context and within that context this references that specific object instance. Furthermore, inside a class, any reference to a member property or function always has an implicit this attached to it. Although, for syntax convenience, we never have to type it.

Now consider the following:

class MyCustomObject(val id: String) {

    fun getFormattedId() : String {
        return "MyCustomObject-${id}" // (implicit this reference for id)
        // or equivalently
        // return "MyCustomObject-${this.id}" (explicit this reference)
    }
}

val pawnee = MyCustomObject("Pawnee")
val eagleton = MyCustomObject("Eagleton")

println(pawnee.getFormattedId()) // prints MyCustomObject-Pawnee
println(eagleton.getFormattedId()) // prints MyCustomObject-Eagleton

Enter fullscreen mode Exit fullscreen mode

When we have pawnee.getFormattedId(), the pawnee instance variable is considered the receiver of the function call getFormattedId and this.id points to "Pawnee". Likewise, when we have eagleton.getFormattedId(), the eagleton instance variable is now the receiver of that function call and this.id points to "Eagleton". This is how the getFormattedId function knows which id to get.

Function Types with Receivers

As we discussed earlier, (A) -> B denotes a function type for a function that has one argument of type A and has a return value of type B. Optionally, a function type can also have a receiver type, which is defined with the following form:

R.(A) -> B

This represents a function that has one argument of type A and has a return value of type B, as before, but additionally is called on a receiver type R.

Here is an example of a function that has a receiver with type String, an argument with type String, and that returns a type Boolean:

val isLonger : String.(String) -> Boolean = { other: String ->  
    this.length > other.length  
}

Enter fullscreen mode Exit fullscreen mode

We can then invoke this function by prepending a receiver to the function, similarly to how we invoke any other object's function:

val result1_a: Boolean = "Leslie".isLonger("Anne")
val ben = "Ben"
val result2_a: Boolean = ben.isLonger("Chris")

println("Is Longer, Result1: $result1_a") // true  
println("Is Longer, Result2: $result2_a") // false

Enter fullscreen mode Exit fullscreen mode

We can also invoke a function type with a receiver without prepending the receiver, but instead, pass the receiver as the first argument to the function:

val ben = "Ben"
val result1_b = isLonger("Leslie", "Anne")  
val result2_b: Boolean = isLonger(ben, "Chris")  

println("Is Longer, Result1_b: $result1_b") // true
println("Is Longer, Result2_b: $result2_b") // false

val result1_c = isLonger.invoke("Leslie", "Anne")  
val result2_c: Boolean = isLonger.invoke(ben, "Chris")  

println("Is Longer, Result1_c: $result1_c") // true
println("Is Longer, Result2_c: $result2_c") // false

Enter fullscreen mode Exit fullscreen mode

The Comparison to Extension Functions

Extension functions are full static function declarations with one implementation. When we create an extension function, the class isn't modified but instead the actual code compiles to a static function declaration. For example, when we define an extension function like:

fun String.countNumberOfVowels() : Int {  
    val vowels: Set<Char> = setOf('a', 'e', 'i', 'o', 'u', 'A', 'E', 'I', 'O', 'U')  
    var count = 0  

    for (char in this) {  
        if (char in vowels) {  
            ++count  
        }  
    } return count  
}

val vowels = "Hello, World".countNumberOfVowels()

Enter fullscreen mode Exit fullscreen mode

The extension function definition and usage are syntactic sugar, and what the extension function ultimately gets resolved to is:

fun countNumberOfVowels(word: String) : Int {  
    val vowels: Set<Char> = setOf('a', 'e', 'i', 'o', 'u', 'A', 'E', 'I', 'O', 'U')  
    var count = 0  

    for (char in word) {  
        if (char in vowels) {  
            ++count  
        }  
    } return count  
}

val vowels = countNumberOfVowels("Hello, World")

Enter fullscreen mode Exit fullscreen mode

In fact, if you try and define both of these functions in the same package you will receive a build error:

Kotlin: Platform declaration clash: The following declarations have the same JVM signature (countNumberOfVowels(Ljava/lang/String;)I):

fun countNumberOfVowels(word: String): Int defined in root package

fun String.countNumberOfVowels(): Int defined in root package

Similarities

Extension functions and function types with receivers are the same in that they are largely just syntactic sugar, and they both achieve the same thing:

  1. Extending the functionality of a class without actually modifying the class.

  2. Providing a this scope within the function literal so the user can implement the function literal as if it were a native member of the class.

Differences

  • Extension functions are function declarations, and as the name implies, function types with receivers are just types, like any other type. In the case of higher-order functions, the type's implementation only gets defined when called.

  • We saw earlier that a function type with a receiver can be invoked with and without specifying the receiver.

  • Extension functions are a way to avoid having to resort to either inheritance, the decorator design pattern, or defining and using a bunch of util classes when extending a class' functionality. Function types with receivers are more concerned about making life easier when working with higher-order functions.

Conclusion

Function types with receivers are a powerful feature and a key part of how Kotlin's apply, with, and run scope functions work. It's a feature that allows for greater convenience and code clarity when developing in Kotlin.

To fully understand this feature, we dove deeper into smaller concepts like function types (learning how to define and use them), as well as receivers (what they are and how they fit together with function types). Lastly, we looked at how a function type with a receiver compares to and contrasts with an extension function.


If you noticed anything in the article that is incorrect or isn't clear, please let me know. I always appreciate the feedback.

💖 💪 🙅 🚩
nfragiskatos
Nicholas Fragiskatos

Posted on October 18, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related