Higher order functions in detail
Starting with the Kotlin class in this chapter, we will leave the basics behind and move on to more advanced uses of Kotlin to further improve your Kotlin skills.
So let’s start with higher order functions.
Define higher-order functions
The relationship between higher-order functions and Lambda is inextricably linked. In Chapter 2, a quick introduction to Kotlin programming, we learned the basics of Lambda programming and grasped the use of some functional-type apis related to collections, such as map and filter functions. In addition, in the Kotlin class in chapter 3, we learned the standard functions of Kotlin, such as run and apply functions.
Do you notice that these functions have one thing in common: they all require us to pass a Lambda expression as an argument? Functions like this that take Lambda arguments are called functional programming style apis, and if you want to define your own functional API, you have to do it with higher-order functions, and that’s what we’ll focus on in this Kotlin lecture.
So let’s first look at the definition of higher order functions. A function is called a higher-order function if it takes another function as an argument, or if the return value is of a different type.
This definition may be a little confusing, but how can a function take another function as an argument? This brings us to another concept: function types. We know that programming languages have field types such as integers and Booleans, but Kotlin adds the concept of function types. If we add this type of function to a function’s argument declaration or return value declaration, then this is a higher-order function.
Let’s learn how to define a function type. Instead of defining a common field type, the syntax rules for function types are somewhat special. The basic rules are as follows:
(String, Int) - >Unit
Copy the code
You must be confused when you suddenly see such a grammar rule. But don’t worry, after listening to my explanation, you can easily understand.
Since we are defining a function type, the most important thing for ** is to declare what arguments the function takes and what values it returns. ** Therefore, the left part of -> is used to declare what arguments the function accepts. Use commas to separate multiple arguments. If no arguments are accepted, write a pair of empty parentheses. The right part of -> declares what type the return value of the function is. If there is no return value, use Unit, which is roughly equivalent to void in Java.
Now add the above function type to a function’s argument declaration or return value declaration, and the function is a higher-order function, as follows:
fun example(func: (String.Int) - >Unit) {
func("hello".123)}Copy the code
As you can see, the example() function here takes an argument of function type, so the example() function is a higher-order function. The syntax for calling an argument of a function type is similar to calling an ordinary function, except that the argument name is followed by a pair of parentheses and the necessary arguments are passed in the parentheses.
Now that we know how higher-order functions are defined, what exactly are they used for? Since higher-order functions are so versatile, if I had to summarize them briefly, it would be that ** higher-order functions allow the parameters of the function type to determine the logic of the function’s execution. ** Even if the same higher-order function is passed with different function types, the execution logic and the final return may be completely different. To illustrate the point, let’s take a specific example.
Here I’m going to define a higher-order function called num1AndNum2() and have it take two integers and one function parameter. We will perform some operation on the two integer arguments passed to num1AndNum2() and return the final operation, but the exact operation depends on the type of function arguments passed.
Create a new higherOrderFunction.kt file and write the following code in it:
fun num1AndNum2(num1: Int, num2: Int, operation: (Int.Int) - >Int): Int {
return operation(num1, num2)
}
Copy the code
This is a very simple higher-order function that probably doesn’t really mean much, but it’s a good example to learn from. The first two arguments to the num1AndNum2() function are nothing to explain, and the third argument is a function type argument that takes two integer arguments and returns an integer. In the num1AndNum2() function, we don’t do any specific operations. Instead, we pass the num1 and num2 arguments to the third function type argument, get its return value, and eventually return the resulting return value.
Now that the higher-order function is defined, how do we call it? Since the num1AndNum2() function takes an argument of a function type, we must first define a function that matches its function type. Add the following code to the higherOrderFunction.kt file:
fun plus(num1: Int, num2: Int): Int {
return num1 + num2
}
fun minus(num1: Int, num2: Int): Int {
return num1 - num2
}
Copy the code
Two functions are defined here, and the parameter declarations and return value declarations of both functions exactly match the function type arguments in the num1AndNum2() functions. Among them, plus() adds two parameters and returns, and minus() subtracted two parameters and returns, respectively corresponding to two different operations.
With these functions in place, we can call num1AndNum2() by writing the following code inside main() :
fun main(a) {
val num1 = 100
val num2 = 80
val result1 = num1AndNum2(num1, num2, ::plus)
val result2 = num1AndNum2(num1, num2, ::minus)
println("result1 is $result1")
println("result2 is $result2")}Copy the code
Note the way num1AndNum2() is called. The third argument uses the notation ::plus and ::minus. This is a way of writing a function reference, meaning that the plus() and minus() functions are passed as arguments to the num1AndNum2() functions. Since num1AndNum2() uses the function type argument passed in to determine the operation logic, plus() and minus() respectively are used to calculate two numbers.
This method of writing function references works fine, but isn’t it too complicated to define a function that matches its type arguments every time any higher-order function is called?
Yes, so Kotlin also supports many other ways to call higher-order functions, such as Lambda expressions, anonymous functions, member references, and so on. Lambda expressions are the most common and common way to call higher-order functions, and that’s what we’ll focus on next.
The above code, if written as a Lambda expression, would look like this:
fun main(a) {
val num1 = 100
val num2 = 80
val result1 = num1AndNum2(num1, num2) { n1, n2 ->
n1 + n2
}
val result2 = num1AndNum2(num1, num2) { n1, n2 ->
n1 - n2
}
println("result1 is $result1")
println("result2 is $result2")}Copy the code
The grammar rules for Lambda expressions were already learned in signatures, so this code should be easy for you to understand. You’ll find that Lambda expressions can also fully express the parameter declaration and return value declaration of a function (the last line of code in the Lambda expression is automatically the return value), but in a much simpler way.
Now you can delete the plus() and minus() functions you just defined, and run the code again. You will find that the results are exactly the same.
Let’s move on to higher-order functions. If you look back at the apply function, it can be used to give a specific context to a Lambda expression. When multiple methods on the same object need to be called in succession, the Apply function can be used to simplify the code, such as StringBuilder. Next, we implement a similar function using higher-order function emulation.
Modify the higherOrderFunction. kt file to include the following code:
fun StringBuilder.build(block: StringBuilder. () - >Unit): StringBuilder {
block()
return this
}
Copy the code
Here we define a build extension function for the StringBuilder class. This extension function takes a function type argument and returns a value of type StringBuilder.
Note that this function type argument is declared in a different way than the syntax we learned earlier: it precedes the function type with a StringBuilder. The grammatical structure of. What does that mean? This is the complete syntax for defining higher-order functions. The ClassName precedes the function type. The class in which the function type is defined.
So what’s the benefit here of defining the function type in the StringBuilder class? The benefit is that Lambda expressions passed in when we call the build function will automatically have the StringBuilder context, and this is how the apply function is implemented.
Now we can simplify the way StringBuilder builds strings by using the build function we created. Here’s another example of eating fruit:
fun main(a) {
val list = listOf("Apple"."Banana"."Orange"."Pear"."Grape")
val result = StringBuilder().build {
append("Start eating fruits.\n")
for (fruit in list) {
append(fruit).append("\n")
}
append("Ate all fruits.")
}
println(result.toString())
}
Copy the code
As you can see, the use of build is basically the same as that of apply, except that the build function we wrote currently works only on the StringBuilder class, whereas apply applies to all classes. If you want to implement this function for the Apply function, you’ll need to use Kotlin’s generics, which we’ll cover in Chapter 8.
Now that you have fully mastered the basics of higher order functions, let’s move on to some more advanced knowledge.
Inline functions
Higher-order functions are amazing and versatile, but do you know what’s behind them? Of course, this is not a topic that everyone needs to know, but in order to understand inline functions better, let’s take a quick look at how higher-order functions work.
Using the same num1AndNum2() functions we just wrote, the code looks like this:
fun num1AndNum2(num1: Int, num2: Int, operation: (Int.Int) - >Int): Int {
val result = operation(num1, num2)
return result
}
fun main(a) {
val num1 = 100
val num2 = 80
val result = num1AndNum2(num1, num2) { n1, n2 ->
n1 + n2
}
}
Copy the code
As you can see, the num1AndNum2() functions are called in the above code, and the Lambda expression specifies the sum of the two integer arguments passed in. This code is pretty easy to understand in Kotlin, because it’s the most basic use of higher-order functions. As we all know, Kotlin’s code will eventually compile to Java bytecode, but Java has no notion of higher-order functions.
So what kind of magic did Kotlin use to get Java to support this higher-order function syntax? This is thanks to Kotlin’s powerful compiler. Kotlin’s compiler converts the syntax of these higher-order functions into the syntax structures supported by Java. The Kotlin code above will be roughly translated into Java code as follows:
public static int num1AndNum2(int num1, int num2, Function operation) {
int result = (int) operation.invoke(num1, num2);
return result;
}
public static void main(a) {
int num1 = 100;
int num2 = 80;
int result = num1AndNum2(num1, num2, new Function() {
@Override
public Integer invoke(Integer n1, Integer n2) {
returnn1 + n2; }}); }Copy the code
For readability, I’ve tweaks this code a little bit, and it doesn’t exactly correspond to the Java code Kotlin converted to. As you can see, here the num1AndNum2() Function’s third argument becomes a Function interface, which is a built-in Kotlin interface with an invoke() Function to implement. The num1AndNum2() Function calls the invoke() Function on the Function interface and passes in the num1 and num2 arguments.
When the num1AndNum2() functions are called, the previous Lambda expression here becomes an anonymous class implementation of the Function interface, and then implements the logic of N1 + n2 in the invoke() Function and returns the result.
That’s the rationale behind Kotlin’s higher-order functions. You’ll notice that the Lambda expression we’ve been using has been transformed into an anonymous class implementation at the bottom. This means that every time we call a Lambda expression, we create a new anonymous class instance, which of course incurs additional memory and performance overhead.
To solve this problem, Kotlin provides the ability to inline functions, which eliminate the runtime overhead of using Lambda expressions entirely.
The use of inline functions is as simple as declaring a higher-order function with the inline keyword, as shown below:
inline fun num1AndNum2(num1: Int, num2: Int, operation: (Int.Int) - >Int): Int {
val result = operation(num1, num2)
return result
}
Copy the code
So how does inline function work? It’s not that complicated, but the Kotlin compiler automatically replaces the code in the inline function at compile time to the place where it was called, so there’s no runtime overhead.
Of course, a one-sentence description might not be easy to understand, so let’s use a legend to illustrate the code replacement process for inline functions.
First, the Kotlin compiler replaces the code in the Lambda expression where the function type argument is called, as shown in the figure below.
Next, replace all the code in the inline function with the function call, as shown in the figure below.
The final code is replaced with something like the figure below.
This is why inline functions completely eliminate the runtime overhead of Lambda expressions.
Noinline and crossinline
Now we’re going to talk about some more special cases. For example, if we add the inline keyword to a higher-order function that takes two or more arguments of function type, the Kotlin compiler automatically inlines all referenced Lambda expressions.
But what if we only want to inline one of the Lambda expressions? You can then use the noinline keyword, as shown below:
inline fun inlineTest(block1: () -> Unit.noinline block2: () -> Unit){}Copy the code
As you can see, the inlineTest() function is declared with the inline keyword, so that the Lambda expressions referenced by the block1 and block2 function type arguments are inlined. But we added the noinline keyword before the block2 parameter, so now only Lambda expressions referenced by the block1 parameter will be inlined. This is where the noinline keyword comes in.
We’ve already explained the benefits of inline functions, so why does Kotlin provide a noinline keyword to exclude inline functions? This is because ** inline function type arguments are replaced by code at compile time, so they have no real parameter properties. ** A non-inlined function type argument can be passed freely to any other function because it is a real argument, while an inlined function type argument can only be passed to one other inlined function, which is its biggest limitation.
In addition, an important difference between inlined and non-inlined functions is that the return keyword can be used in the Lambda expression referred to by an inlined function, whereas non-inlined functions can only return locally. To illustrate this, let’s look at the following example.
fun printString(str: String, block: (String) - >Unit) {
println("printString begin")
block(str)
println("printString end")}fun main(a) {
println("main start")
val str = ""
printString(str) { s ->
println("lambda start")
if (s.isEmpty()) {
return@printString
}
println(s)
println("lambda end")
}
println("main end")}Copy the code
A higher-order function called printString() is defined here to print the string arguments passed in a Lambda expression. But if the string argument is empty, then it is not printed. Note that direct use of the return keyword is not allowed in Lambda expressions. Instead, write return@printString to indicate a local return and no longer execute the rest of the Lambda expression.
Now we just pass in an empty string, run the program, and print something like the figure below.
As you can see, the log prints normally except for the code after the return@printString statement in the Lambda expression, indicating that return@printString is indeed only partially returned.
But if we declare the printString() function as an inline function, the situation is different, as follows:
inline fun printString(str: String, block: (String) - >Unit) {
println("printString begin")
block(str)
println("printString end")}fun main(a) {
println("main start")
val str = ""
printString(str) { s ->
println("lambda start")
if (s.isEmpty()) {
return
}
println(s)
println("lambda end")
}
println("main end")}Copy the code
Now that the printString() function is inline, we can use the return keyword in Lambda expressions. In this case, return means to return the outer calling function, which is main(). If you can’t figure out why, you can review the code replacement process for inline functions in the previous section.
Now run the program again and print something like the figure below.
As you can see, both main() and printString() do stop executing after the return keyword, as we would expect.
It is good programming practice to declare higher-order functions inline. In fact, most higher-order functions can be declared inline, but there are a few exceptions. Look at the following code example:
inline fun runRunnable(block: () -> Unit) {
val runnable = Runnable {
block()
}
runnable.run()
}
Copy the code
This code works perfectly without the inline keyword declaration, but it prompts an error like the one shown below.
The reason for this error can be a little complicated to explain. First, in the runRunnable() function, we create a Runnable object and call the function type parameter passed in the Runnable Lambda expression. Lambda expressions are converted to anonymous class implementations at compile time, which means that the code is actually calling the passed function type arguments in the anonymous class.
The return keyword is allowed to return the function. However, since we are calling the function type argument in an anonymous class, it is not possible to return the outer call function. At most, we can only return the function call in an anonymous class.
That is, if we create additional implementations of Lambda or anonymous classes in higher-order functions, and call function type arguments in those implementations, then declare the higher-order functions inline, we will get an error.
So is it really impossible to use inline functions in this case? No, the crossinline keyword is a great way to solve this problem:
inline fun runRunnable(crossinline block: () -> Unit) {
val runnable = Runnable {
block()
}
runnable.run()
}
Copy the code
So what is this crossinline keyword? As we have previously analyzed, the error shown in the figure above is caused by a conflict between the return keyword allowed in Lambda expressions of inline functions and the non-return keyword allowed in anonymous class implementations of higher-order functions. The crossinline keyword acts as a contract to ensure that the return keyword is never used in the Lambda expression of an inline function, so conflicts are eliminated and the problem is solved.
After crossinline is declared, we can no longer use the return keyword in the Lambda expression that calls runRunnable, but we can still use return@runRunnable for local returns. Overall, crossinline retains all the features of inline functions except for the use of the return keyword.
All right, so that’s pretty much everything that’s important about higher order functions, and hopefully you got a good handle on that, because a lot of the rest of what we’re going to learn about Lambda and higher order functions is based on this lecture.