Skip to content
ExponaBLOG
MODERN ANDROID & KOTLIN · DAY 086 / 100

Higher-order functions and lambdas: a step-by-step guide

Functions as values.

Expona Admin8 min read

Higher-order functions and lambdas: a step-by-step guide

When you need to inject behavior into your code without creating new classes, higher-order functions and lambdas are your best friends. In Kotlin, functions are first-class citizens, meaning you can pass them around like data. This article walks you through the mechanics of higher-order functions and lambdas, from basic parameter passing to production-grade optimizations, so you can write cleaner, more flexible code.

Understanding higher-order functions

A higher-order function is simply a function that accepts another function as a parameter, returns a function, or both. This might sound abstract until you see it in action. Think about a logging decorator in a production system—you want to log every network request without duplicating code across endpoints. Rather than writing separate logging logic for each endpoint, you pass the logging behavior as a parameter.

Here's a basic example:

kotlin
fun executeWithLogging(action: (String) -> Unit) {
    println("Action started")
    action("Processing data")
    println("Action completed")
}

fun main() {
    executeWithLogging({ message -> println(message) })
}

In this code, executeWithLogging is a higher-order function because it takes a lambda as a parameter. The lambda type is (String) -> Unit, which means it accepts a String and returns nothing. When we call executeWithLogging, we pass the behavior (printing) as an argument. The function controls when and how that behavior executes.

This pattern is powerful because different callers can provide different behaviors without modifying the function itself. One caller might log to console, another to a file, another to a remote service. The higher-order function doesn't care—it just invokes whatever behavior was passed in.

Pass behavior as parameters

Passing behavior as a parameter is the core idea that makes higher-order functions useful. Let's look at a real-world scenario from an e-commerce platform: you have an order processing system that needs to validate orders, then apply different transformations based on order type.

kotlin
data class Order(val id: String, val amount: Double, val isPremium: Boolean)

fun processOrder(order: Order, validation: (Order) -> Boolean, transform: (Order) -> Order) {
    if (!validation(order)) {
        println("Order validation failed")
        return
    }
    
    val transformedOrder = transform(order)
    println("Order processed: ${transformedOrder.id}")
}

fun main() {
    val order = Order("ORD-001", 99.99, true)
    
    // Pass validation behavior
    val validateAmount = { o: Order -> o.amount > 0 }
    
    // Pass transformation behavior
    val applyPremiumDiscount = { o: Order ->
        o.copy(amount = o.amount * 0.9)
    }
    
    processOrder(order, validateAmount, applyPremiumDiscount)
}

Notice how processOrder doesn't know or care about the specifics of validation or transformation. It receives functions and calls them at the right time. This is inversion of control—the caller decides what logic runs inside the function.

In a production system, you might have multiple validation strategies (fraud detection, inventory check, rate limiting) and multiple transformations (apply discount, add insurance, set delivery preferences). By accepting these as parameters, your core processing logic stays stable while behavior varies.

Trailing lambda syntax

Kotlin has syntactic sugar that makes passing lambdas cleaner. When the last parameter of a function is a lambda, you can move it outside the parentheses. This is called trailing lambda syntax, and it dramatically improves readability.

kotlin
// Without trailing lambda
processOrder(order, validateAmount, { o -> o.copy(amount = o.amount * 0.9) })

// With trailing lambda
processOrder(order, validateAmount) { o -> o.copy(amount = o.amount * 0.9) }

The trailing lambda version reads more naturally. It feels less like passing an argument and more like extending the function with custom logic.

When a function has only a lambda parameter, you can omit the parentheses entirely:

kotlin
fun retry(times: Int, action: () -> Unit) {
    repeat(times) {
        try {
            action()
            return
        } catch (e: Exception) {
            // retry
        }
    }
}

// Called without parentheses
retry(3) {
    println("Attempting operation")
}

This syntax is so common in Kotlin that you see it everywhere—in scope functions like apply, let, and with, in collection operations like map and filter, and in testing frameworks like Kotest.

The it parameter shorthand

When a lambda has exactly one parameter, Kotlin lets you refer to it as it implicitly, without declaring it. This reduces boilerplate and makes code more concise, especially with collection operations.

kotlin
val numbers = listOf(1, 2, 3, 4, 5)

// Explicit parameter name
numbers.filter { number -> number > 2 }

// Using it
numbers.filter { it > 2 }

The it shorthand works because Kotlin can infer the parameter from context. When you're filtering a list of integers, Kotlin knows it is an Int.

In more complex scenarios with nested lambdas, though, it can become confusing. Stick with explicit parameter names when multiple lambdas are involved:

kotlin
// Confusing with it
orders.map { it.items.filter { it.price > 10 } }

// Clear with explicit names
orders.map { order -> order.items.filter { item -> item.price > 10 } }

The rule of thumb: use it for simple, single-line operations. Use explicit names when the lambda body is longer or when there are nested lambdas. In a production codebase, readability trumps brevity.

The inline modifier and lambda overhead

Every time you create a lambda, the Kotlin compiler generates an object—specifically, an anonymous inner class or a function object. For high-frequency operations, this can create garbage collection pressure. The inline modifier tells the compiler to copy the function body directly into the call site, eliminating the lambda object allocation.

kotlin
// Without inline - creates a lambda object
fun forEach(action: (Int) -> Unit) {
    for (i in 1..10) {
        action(i)
    }
}

// With inline - no lambda object
inline fun forEachInline(action: (Int) -> Unit) {
    for (i in 1..10) {
        action(i)
    }
}

When you call forEachInline { i -> println(i) }, the compiler inlines the entire function body at the call site, replacing action(i) with the actual lambda code. This is faster and generates less garbage.

However, inlining has trade-offs. Inlined functions increase bytecode size—if you inline a large function used in many places, you're duplicating code. Also, inlined functions can't be passed to variables or stored in collections; the compiler needs to know the lambda at compile time.

kotlin
// This works
forEachInline { i -> println(i) }

// This doesn't work with inline—compilation error
val callback: (Int) -> Unit = { i -> println(i) }
forEachInline(callback)

In the Kotlin standard library, heavily-used functions like map, filter, forEach, and let are inlined. When you write your own higher-order functions, mark them inline only if they're called frequently and the function body is small. For a wrapper around a single lambda parameter with no other logic, inlining is often worth it. For complex functions or those used infrequently, skip it.

There's also noinline, which you use when an inline function takes multiple lambda parameters but you want to prevent one from being inlined:

kotlin
inline fun processAsync(crossinline onSuccess: (String) -> Unit, noinline onError: (Exception) -> Unit) {
    // onSuccess is crossinline (can be called from nested lambda)
    // onError is noinline (can be stored or passed around)
    Thread {
        try {
            onSuccess("Done")
        } catch (e: Exception) {
            onError(e)
        }
    }.start()
}

Common mistakes

Forgetting lambda context: When you pass a lambda, it executes in the context of the higher-order function, not the caller. If the lambda references this or other context-specific variables, you might get unexpected behavior.

kotlin
class RequestHandler {
    fun processRequest(transform: (String) -> String) {
        val result = transform("data")
        println(result)
    }
    
    fun handle() {
        // this inside the lambda refers to RequestHandler, not a receiver
        processRequest { it.uppercase() }
    }
}

Over-inlining: Inlining a large, complex function used in ten places generates ten copies of that code. This bloats the APK and can hurt performance. Be selective.

Nesting lambdas without clarity: Multiple levels of lambdas are hard to read. Break them into named functions or use explicit variable names instead of relying on it.

Ignoring thread safety: If your lambda captures mutable state and runs on a different thread (common in coroutines), you need synchronization. Lambdas don't create thread safety automatically.

Key takeaways

  • Higher-order functions accept or return functions, enabling behavior injection without creating new types.
  • Trailing lambda syntax moves the last lambda parameter outside parentheses for readable code.
  • The it shorthand works for single-parameter lambdas but sacrifices clarity in complex scenarios—use explicit names instead.
  • The inline modifier eliminates lambda object allocation for frequent, small functions, but increases bytecode size and prevents lambda storage.
  • Higher-order functions are the foundation of Kotlin's functional programming style; you'll see them in scope functions, collection operations, and coroutine builders.
  • Test your higher-order functions thoroughly—behavior passed as a parameter is easy to get wrong under concurrent execution or complex state capture.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles