Function Types

These might look like magic, or at least like the sort of thing that can only be provided as part of a core language implementation. In reality, these higher-order functions are not that complicated, but they take advantage of a key feature of Kotlin: function types. A function type allows you to pass what looks like a function as a parameter to another function.

For example, the hasMatch() function in this code snippet is a higher-order function that mimics the functionality of any():

fun <T> hasMatch(list: List<T>, predicate: (T) -> Boolean): Boolean {
  list.forEach { item ->
    if (predicate.invoke(item)) return true
  }

  return false
}

data class Event(val id: Int)

fun main() {
  val events = listOf(Event(1), Event(5), Event(1337), Event(24601), Event(42), Event(-6))

  println(hasMatch(events) { it.id < 0 })
  println(hasMatch(events) { it.id > 100000 })
}

Functions and Generics

For an any()-style function, we need two things:

On the surface, the collection seems easy. We could just have a parameter that is a List or an Array or something. However, ideally, we would declare this function to be able to handle a list of any type of data. Our function does not care whether this is a list of String objects, a list of Event objects, or a list of Axolotl objects. All our function needs to do is to pass elements from the list to the lambda expression. So long as the list and the lambda are in agreement on types, what the type is does not matter.

hasMatch() uses generics not at the class level but at the function level. The <T> after the fun keyword says “hey, this function can operate on objects of some type T”. We can then use T as a placeholder for the “real” type elsewhere in the function declaration. In particular, our list parameter is a List<T>. We have no constraints on what T can be, so when we use hasMatch() a List<Event> works fine for a List<T>.

Declaring a Function Type Parameter

The second parameter to hasMatch() should be our lambda expression. From the standpoint of our function, though, the second parameter is a function type. foo: String declares a parameter named foo of type String, where the parameter name precedes the colon. Similarly, predicate: (T) -> Boolean declares a parameter named predicate. However, the type is a function type.

A function type has three elements:

In our case, we are saying that we want, as the second parameter, a function that takes in a T and returns a Boolean. hasMatch() does not care what the function is or does, so long as it accepts a T and returns a Boolean.

Passing a Function Type

Given that we have a higher-order function like hasMatch(), we need to supply a function satisfying the function parameter. There are a few ways in which we can do that.

Lambda Expressions

The most common approach, and the one used in the above example, is to pass a lambda expression.

Technically, the lambda expression ought to appear as an actual function parameter of hasMatch():

println(hasMatch(events, { it.id < 0 }))
println(hasMatch(events, { it.id > 100000 }))

Here, our lambda expression is part of the comma-delimited list of parameters passed to hasMatch().

However, the Kotlin compiler allows you to put the lambda expression after the closing parenthesis, if the function type parameter is the last parameter in the function’s parameter list. That is why hasMatch() takes the list first and then the function type parameter, so we can write this instead:

println(hasMatch(events) { it.id < 0 })
println(hasMatch(events) { it.id > 100000 })

If a function takes more than one function type parameter, or if the function type parameter is not last in the list, you have to pass the lambda expression as a regular parameter, inside the parentheses of the function call. For example, if we had a subscribe() function that took two function type parameters for onSuccess and onError, at most we could have the second of those be outside the parentheses.

Function References

While lambda expressions are popular, they are not the only option.

The next-most common solution is to use a function reference. This is a way to identify an existing function and pass it as a parameter. If the function’s signature matches what is required by the function type, then the compiler will be happy.

The most common syntax for this is to use a double-colon before the function name:

fun <T> hasMatch(list: List<T>, predicate: (T) -> Boolean): Boolean {
  list.forEach { item ->
    if (predicate.invoke(item)) return true
  }

  return false
}

data class Event(val id: Int)

fun lessThanZero(event: Event) = event.id < 0

fun main() {
  val events = listOf(Event(1), Event(5), Event(1337), Event(24601), Event(42), Event(-6))

  println(hasMatch(events, ::lessThanZero))
}

Here, we have a lessThanZero() function that takes an Event and returns a Boolean. We can use that to satisfy the function type for hasMatch() by using ::lessThanZero as the second parameter to the hasMatch() call, replacing our lambda expression. Kotlin’s type inference capability means that when we call hasMatch(events, ::lessThanZero), Kotlin sees that:

This satisfies both of the generics used in the hasMatch() function signature, so the compiler is happy.

This syntax works for top-level functions, such as this implementation of lessThanZero(). It also works for member functions, if the function reference is inside the same object:

data class Event(val id: Int)

class Thingy {
  fun <T> hasMatch(list: List<T>, predicate: (T) -> Boolean): Boolean {
    list.forEach { item ->
      if (predicate.invoke(item)) return true
    }

    return false
  }

  val events = listOf(Event(1), Event(5), Event(1337), Event(24601), Event(42), Event(-6))

  fun lessThanZero(event: Event) = event.id < 0

  fun matchify() {
    println(hasMatch(events, ::lessThanZero))
  }
}

fun main() {
  Thingy().matchify()
}

Here, everything is a part of a Thingy class, and so ::lessThanZero will resolve to the lessThanZero() member function of Thingy. The implication is that the receiver — the object on which lessThanZero() will be called — is this, or the current instance of Thingy where we are trying to use lessThanZero().

If you want to use a function on a specific receiver, put its name before the double-colon:

fun <T> hasMatch(list: List<T>, predicate: (T) -> Boolean): Boolean {
  list.forEach { item ->
    if (predicate.invoke(item)) return true
  }

  return false
}

data class Event(val id: Int)

class Thingy {
  fun lessThanZero(event: Event) = event.id < 0
}

fun main() {
  val events = listOf(Event(1), Event(5), Event(1337), Event(24601), Event(42), Event(-6))
  val thingy = Thingy()

  println(hasMatch(events, thingy::lessThanZero))
}

Here, lessThanZero() is a function on Thingy, and thingy is an instance of Thingy, so we can use thingy::lessThanZero to reference a lessThanZero() function in the context of thingy.

Anonymous Functions

The third approach — the anonymous function — does not seem to be that widely used, so we will explore it much later in the book.

Using a Function Type Parameter

You have two options for causing the function type parameter to execute and give you a result for some input: calling invoke() on it or using the function type as if it were an actual function.

Calling invoke()

Given that you have a parameter that is a function type, to call that function, call invoke() on the parameter. That is what we are doing in the hasMatch() higher-order function:

fun <T> hasMatch(list: List<T>, predicate: (T) -> Boolean): Boolean {
  list.forEach { item ->
    if (predicate.invoke(item)) return true
  }

  return false
}

invoke() will take the parameters that are declared for the function type. hasMatch() declares that predicate takes a T instance, so we must supply a T instance to invoke().

Similarly, invoke() returns whatever the function type says it should return. predicate is declared as returning a Boolean, so invoke() returns a Boolean.

Calling Directly

You can also skip the invoke() and treat the function type as an actual function:

fun <T> hasMatch(list: List<T>, predicate: (T) -> Boolean): Boolean {
  list.forEach { item ->
    if (predicate(item)) return true
  }

  return false
}

data class Event(val id: Int)

fun main() {
  val events = listOf(Event(1), Event(5), Event(1337), Event(24601), Event(42), Event(-6))

  println(hasMatch(events) { it.id < 0 })
  println(hasMatch(events) { it.id > 100000 })
}

Here, we treat predicate as if it were predicate(), calling it like a function, passing the parameter and getting the result. The effect is the same as with invoke() itself:

Multi-Parameter Function Types

Note that function types are not limited to a single parameter. They can support any number of parameters. For example, this collectify higher-order function uses a function type taking two parameters:

fun <K, V, T> collectify(input: Map<K, V>, transform: (K, V) -> T): List<T> {
  val result = mutableListOf<T>()

  for ((key, value) in input) { result.add(transform(key, value)) }

  return result.toList()
}

fun main() {
  val stuff = mapOf("foo" to "bar", "goo" to "baz")

  println(stuff)
  println(collectify(stuff) { key, value -> "${key.toUpperCase()}: ${value.toUpperCase()}" } )
}

The objective of collectify() is to convert the Map into a List of objects, using some supplied function type to take each key/value pair from the Map and create a corresponding object for the List.

For each entry in the Map, we invoke the transform on the key and value. We take the result of transform() and append it to a MutableList. We then return that list, converted to an immutable List via toList().

To use collectify(), we need to pass some implementation of the function type. The script shown above uses a lambda expression that returns a string made up of the uppercase renditions of the key and value.

If you run this script, you get both the original Map plus the collectify() result:

{foo=bar, goo=baz}
[FOO: BAR, GOO: BAZ]

In this case, though, collectify() is pointless, as Map already supports the unfortunately-named map() function, which does the same thing. However, map() uses Entry objects instead of the keys and values for the function type:

val stuff = mapOf("foo" to "bar", "goo" to "baz")

println(stuff)

val mappedMap = stuff.map { entry -> "${entry.key.toUpperCase()}: ${entry.value.toUpperCase()}" }

println(mappedMap)

So, our lambda expression gets an Entry and needs to retrieve the key and value from it.

Scope Functions and Function Types

Most places that you see something like a lambda expression, there is a function with a function type parameter that invokes that lambda expression.

For example, previously we examined Kotlin’s scope functions, such as let() and apply(). Those are implemented using function type parameters.

However, if you look at the Kotlin documentation for these, they will look a bit strange. For example, this is the declaration for let():

inline fun <T, R> T.let(block: (T) -> R): R

T and R are generic types, and block is a function type that accepts a T parameter and returns an R. However, there are two elements of this declaration that we have not covered yet.

First is inline. That is an optimization and is optional, so we will cover it much later in the book.

The other part is T.let. let is the function name, but what is the T.?

This is the way that you declare an “extension function”, where we can add functions to existing types, even for classes and stuff that we did not create. Extension functions are a powerful — and somewhat dangerous — capability in Kotlin, and we will explore them in an upcoming chapter.

Type Aliases

One problem with function types is that they are a bit long, particularly if you have a bunch of parameters to declare. Something like (K, V) -> T is not bad, because we tend to use single-letter identifiers for generics. But, this could easily become (String, Restaurant) -> CustomerOrder or something like that, which is a long data type.

Even then, it may not be bad, if you only use that function type in one place. However, if you use that function type in lots of places, the length gets magnified. Plus, it becomes a lot more work to change them, if you need to replace Restaurant with something else (e.g., FoodProvider).

One way to help manage this is with type aliases. As the name suggests, type aliases allow you to come up with your own “shorthand” identifier that maps to some other type. You can use type aliases for any sort of data type, but they are particularly useful with function types.

Declaring a type alias is very easy: use typealias, followed by your desired alias, =, and the type that the alias maps to:

typealias StringMap = Map<String, String>
typealias StringPair = Pair<String, String>
typealias Pairmonger = (String, String) -> StringPair

A type alias can even reference another type alias. Here, the Pairmonger type alias is defined in terms of StringPair, which itself is a type alias.

You can then use those aliases wherever you might use the actual type:

typealias StringMap = Map<String, String>
typealias StringPair = Pair<String, String>
typealias Pairmonger = (String, String) -> StringPair

fun collectify(input: StringMap, transform: Pairmonger): List<StringPair> {
  val result = mutableListOf<StringPair>()

  for ((key, value) in input) { result.add(transform(key, value)) }

  return result.toList()
}

fun main() {
  val stuff = mapOf("foo" to "bar", "goo" to "baz")

  println(stuff)
  println(collectify(stuff) { key, value -> key to value } )
}

Using too many aliases will make it difficult for newcomers to understand your code, as they require a level of “mental indirection” that will take time to learn. Try to limit your aliases to where the alias is significantly simpler than the type being aliased. In the example above, StringMap and StringPair would not be worth aliasing in most projects, while Pairmonger may be more reasonable.


Prev Table of Contents Next

This book is licensed under the Creative Commons Attribution-ShareAlike 4.0 International license.