Function Types with Receivers in Kotlin - The Power Behind Kotlin's apply, with, and run Scope Functions
Nicholas Fragiskatos
Posted on October 18, 2023
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"]
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"]
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
}
💡
We can disregard the
contract{}
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
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 aString
argument and returns anInt
.((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)
}
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"]
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"]
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"]
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"]
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
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
}
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
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
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()
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")
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:
Extending the functionality of a class without actually modifying the class.
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.
Posted on October 18, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.