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:
- The collection of items to check, and
- The lambda expression that we use to check each item until we get a match
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:
- The types of the parameters to the function, enclosed in parentheses
- The arrow operator (
->
) - The type returned by the function
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:
-
events
is aList<Event>
-
lessThanZero()
takes anEvent
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:
- When called, the function type must be passed the same parameter(s) as the function type calls for
- When called, the function type returns an object of the type that the function type is declared to return
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.