Higher-order function và lambda trong Kotlin

1. Tổng quan

Trong Kotlin, function là “công dân hạng nhất”. Bạn có thể gán function vào biến, truyền nó làm tham số, hoặc trả nó về từ một function khác. Function nhận hoặc trả về function khác được gọi là higher-order function. Cách viết ngắn gọn nhất để tạo một function như vậy là lambda.

Bài này đi qua function type, cú pháp lambda, function reference, closure, lambda with receiver và từ khóa inline. Đọc xong, bạn sẽ tự viết được higher-order function của riêng mình. Bạn cũng sẽ biết tránh vài lỗi hay gặp.

2. Function type

Mỗi function trong Kotlin có một kiểu, gọi là function type. Cú pháp gồm danh sách kiểu tham số trong ngoặc, mũi tên, rồi kiểu trả về:

fun main() {
    val add: (Int, Int) -> Int = { a, b -> a + b }
    val greet: (String) -> Unit = { name -> println("Xin chào, $name") }
    val now: () -> Long = { System.currentTimeMillis() }

    println(add(2, 3))
    greet("Lan")
    println(now() > 0)
}

Giải thích:

  • (Int, Int) -> Int là function nhận hai Int và trả về Int.
  • (String) -> Unit là function không trả giá trị có ý nghĩa, tương tự void trong Java.
  • () -> Long là function không có tham số.
  • Bạn gọi biến function như gọi function thường: add(2, 3). Cách này tương đương add.invoke(2, 3).
5
Xin chào, Lan
true

Function type cũng có thể nullable. Khi đó bạn phải bọc cả kiểu trong ngoặc: ((Order) -> Unit)?. Để gọi an toàn, bạn dùng callback?.invoke(order).

3. Viết higher-order function đầu tiên

Ta dùng một model đơn giản xuyên suốt bài. Số tiền dùng kiểu Long (đơn vị đồng) để tránh sai số dấu phẩy động:

data class Order(val id: String, val customer: String, val amount: Long)

fun calculateTotal(orders: List<Order>, priceOf: (Order) -> Long): Long {
    var total = 0L
    for (order in orders) {
        total += priceOf(order)
    }
    return total
}

fun main() {
    val orders = listOf(
        Order("A1", "Lan", 100_000),
        Order("A2", "Minh", 250_000),
        Order("A3", "Lan", 50_000),
    )

    val full = calculateTotal(orders) { it.amount }
    val discounted = calculateTotal(orders) { it.amount * 90 / 100 }

    println(full)
    println(discounted)
}

calculateTotal là một higher-order function vì tham số priceOf có kiểu (Order) -> Long. Function này không biết cách tính giá của từng đơn. Nó giao việc đó cho người gọi. Nhờ vậy, cùng một vòng lặp có thể dùng cho giá gốc lẫn giá đã giảm.

400000
360000

4. Cú pháp lambda

4.1. Các dạng viết

Lambda luôn nằm trong cặp {}. Tham số đứng trước ->, phần thân đứng sau. Biểu thức cuối cùng chính là giá trị trả về, bạn không cần viết return:

val isLarge: (Order) -> Boolean = { order ->
    val threshold = 200_000
    order.amount > threshold   // giá trị trả về
}

val isLargeShort: (Order) -> Boolean = { it.amount > 200_000 }

val ignoreSecond: (Order, Int) -> String = { order, _ -> order.id }
  • Khi lambda chỉ có một tham số, bạn có thể bỏ khai báo và dùng tên ngầm định it.
  • Tham số không dùng tới nên đặt là _ để code rõ ý và tránh cảnh báo của compiler.

4.2. Trailing lambda

Nếu tham số cuối cùng của function là function type, bạn được đặt lambda ra ngoài dấu ngoặc tròn. Nếu đó là tham số duy nhất, bạn bỏ luôn cặp ():

val orders = listOf(
    Order("A1", "Lan", 100_000),
    Order("A2", "Minh", 250_000),
)

val large = orders.filter { it.amount > 200_000 }
val total = calculateTotal(orders) { it.amount }
val sorted = orders.sortedByDescending { it.amount }.map { it.id }

println(large.map { it.id })
println(sorted)
[A2]
[A2, A1]

Đây là lý do code Kotlin dùng collection API đọc gần như ngôn ngữ tự nhiên. Toàn bộ filter, map, sortedByDescending trong thư viện chuẩn đều là higher-order function.

5. Function reference

Khi đã có sẵn một function đúng chữ ký, bạn không cần bọc nó trong lambda. Hãy dùng toán tử :::

fun isVip(order: Order): Boolean = order.amount >= 200_000

class TaxService(private val ratePercent: Long) {
    fun withTax(order: Order): Long = order.amount * (100 + ratePercent) / 100
}

fun main() {
    val orders = listOf(Order("A1", "Lan", 100_000), Order("A2", "Minh", 250_000))
    val taxService = TaxService(10)

    println(orders.filter(::isVip).map { it.id })   // top-level function
    println(orders.map(Order::amount))              // property reference
    println(calculateTotal(orders, taxService::withTax)) // bound reference
}
  • ::isVip tham chiếu tới một function top-level.
  • Order::amount tham chiếu tới property. Nó có kiểu (Order) -> Long.
  • taxService::withTax là bound reference: function đã gắn sẵn với đối tượng taxService.
[A2]
[100000, 250000]
385000

Function reference thường dễ đọc hơn lambda khi tên function đã nói rõ ý định.

6. Trả về function và closure

6.1. Function trả về function

Higher-order function cũng có thể trả về một function. Điều này hữu ích khi bạn muốn tạo ra hành vi dựa trên cấu hình:

fun discountFor(level: String): (Long) -> Long {
    val percent = when (level) {
        "GOLD" -> 80
        "SILVER" -> 90
        else -> 100
    }
    return { amount -> amount * percent / 100 }
}

fun main() {
    val goldPrice = discountFor("GOLD")
    val normalPrice = discountFor("NONE")

    println(goldPrice(500_000))
    println(normalPrice(500_000))
}
400000
500000

Lambda được trả về vẫn “nhớ” biến percent, dù discountFor đã chạy xong. Cơ chế này gọi là closure.

Lưu ý: Đừng viết "GOLD" -> { amount -> amount * 80 / 100 } bên trong when. Ở vị trí đó, cặp {} được hiểu là một block, không phải lambda, nên code sẽ không compile. Hãy tính giá trị trước rồi trả về lambda như ví dụ trên. Bạn cũng có thể bọc lambda trong ngoặc tròn: ({ amount -> ... }).

6.2. Closure có thể sửa biến

Khác với lambda trong Java, lambda Kotlin được phép thay đổi biến var mà nó capture:

fun orderIdGenerator(prefix: String): () -> String {
    var next = 1
    return { "$prefix-${next++}" }
}

fun main() {
    val nextId = orderIdGenerator("ORD")
    println(nextId())
    println(nextId())
    println(nextId())
}
ORD-1
ORD-2
ORD-3

Compiler bọc next trong một đối tượng tham chiếu, gọi là Ref, để lambda và function ngoài cùng chia sẻ một giá trị.

Lưu ý: Closure sửa biến dùng chung không thread-safe. Nếu nhiều thread cùng gọi nextId(), bạn có thể nhận ID trùng nhau. Khi đó hãy dùng AtomicInteger.

7. Lambda with receiver

Kotlin có một dạng function type đặc biệt: T.() -> R. Bên trong lambda này, this chính là đối tượng kiểu T. Bạn truy cập trực tiếp member của nó mà không cần tiền tố. Đây là nền tảng của các DSL như buildString, apply, hay Gradle Kotlin DSL:

class OrderBuilder {
    var id: String = ""
    var customer: String = ""
    var amount: Long = 0

    fun build(): Order = Order(id, customer, amount)
}

fun order(block: OrderBuilder.() -> Unit): Order =
    OrderBuilder().apply(block).build()

fun main() {
    val o = order {
        id = "B1"
        customer = "Hoa"
        amount = 120_000
    }
    println(o)
}
Order(id=B1, customer=Hoa, amount=120000)

block có kiểu OrderBuilder.() -> Unit. Vì vậy trong lambda, id = "B1" thực chất là this.id = "B1". apply(block) gọi lambda với receiver là builder vừa tạo, rồi trả về chính builder đó.

8. inline và return trong lambda

8.1. Chi phí của lambda

Trên JVM, mỗi lambda thường được biên dịch thành một đối tượng. Nếu lambda capture biến, mỗi lần gọi có thể sinh thêm một object mới. Với function được gọi rất nhiều lần, chi phí này cộng dồn lại đáng kể.

Đánh dấu function là inline sẽ giải quyết vấn đề đó. Compiler chép thân function và thân lambda thẳng vào chỗ gọi, nên không tạo object nào:

inline fun <T> measure(label: String, block: () -> T): T {
    val start = System.nanoTime()
    val result = block()
    println("$label: ${(System.nanoTime() - start) / 1_000} µs")
    return result
}

fun main() {
    val orders = (1..1_000).map { Order("O$it", "C$it", it * 1_000L) }
    val total = measure("Tính tổng") { calculateTotal(orders) { it.amount } }
    println(total)
}
Tính tổng: 412 µs
500500000

Thời gian đo được sẽ khác nhau trên mỗi máy. filter, map, forEach trong thư viện chuẩn đều là inline.

8.2. Non-local return và label

Trong lambda truyền vào function inline, lệnh return trơn sẽ thoát khỏi function bao ngoài, không chỉ khỏi lambda:

fun findFirstLarge(orders: List<Order>, threshold: Long): Order? {
    orders.forEach {
        if (it.amount > threshold) return it   // thoát khỏi findFirstLarge
    }
    return null
}

fun printPaidOrders(orders: List<Order>) {
    orders.forEach {
        if (it.amount == 0L) return@forEach    // chỉ bỏ qua phần tử này
        println(it.id)
    }
    println("Xong")
}
  • return it là non-local return. Nó kết thúc luôn findFirstLarge.
  • return@forEach là return có label. Nó chỉ kết thúc lần chạy hiện tại của lambda, giống continue.

Với function không inline, return trơn trong lambda sẽ bị compiler báo lỗi. Bạn bắt buộc phải dùng label.

Lưu ý: Nếu function inline truyền lambda sang một ngữ cảnh khác, ví dụ chạy nó trong thread khác, hãy đánh dấu tham số là crossinline để cấm non-local return. Nếu muốn lưu lambda vào biến hoặc field, hãy dùng noinline.

9. Kiểm thử higher-order function

Thêm dependency test vào pom.xml. Dự án cần cấu hình sẵn kotlin-maven-plugin:

<dependencies>
    <dependency>
        <groupId>org.junit.jupiter</groupId>
        <artifactId>junit-jupiter</artifactId>
        <version>5.10.2</version>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.assertj</groupId>
        <artifactId>assertj-core</artifactId>
        <version>3.25.3</version>
        <scope>test</scope>
    </dependency>
</dependencies>

Higher-order function rất dễ test. Bạn chỉ cần truyền vào một lambda đơn giản và kiểm tra kết quả:

import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test

class PricingTest {

    private val orders = listOf(
        Order("A1", "Lan", 100_000),
        Order("A2", "Minh", 250_000),
        Order("A3", "Lan", 50_000),
    )

    @Test
    fun `calculateTotal applies the given price function`() {
        assertThat(calculateTotal(orders) { it.amount }).isEqualTo(400_000L)
        assertThat(calculateTotal(orders) { 1L }).isEqualTo(3L)
    }

    @Test
    fun `discountFor returns function matching customer level`() {
        assertThat(discountFor("GOLD")(500_000)).isEqualTo(400_000L)
        assertThat(discountFor("SILVER")(500_000)).isEqualTo(450_000L)
        assertThat(discountFor("UNKNOWN")(500_000)).isEqualTo(500_000L)
    }

    @Test
    fun `generator keeps its own state per closure`() {
        val first = orderIdGenerator("ORD")
        val second = orderIdGenerator("INV")

        assertThat(listOf(first(), first(), second()))
            .containsExactly("ORD-1", "ORD-2", "INV-1")
    }
}

Test thứ ba xác nhận một điểm quan trọng: mỗi lần gọi orderIdGenerator tạo ra một closure riêng, với biến next riêng.

10. Lỗi hay gặp và khi nào không nên dùng

  • Lambda quá dài. Nếu thân lambda vượt quá vài dòng, hãy tách nó thành function có tên rồi truyền bằng ::. Code sẽ dễ đọc và dễ test hơn.
  • Lạm dụng it lồng nhau. Nếu bạn viết orders.map { it.items.filter { it.price > 0 } }, it bên trong che mất it bên ngoài. Hãy đặt tên tham số rõ ràng.
  • inline mọi thứ. Chỉ nên inline function nhỏ và có tham số là lambda. Inline function lớn làm phình bytecode. Inline function không có lambda gần như không đem lại lợi ích, và compiler sẽ cảnh báo bạn.
  • Nhầm return. Một return trơn trong forEach thoát cả function bao ngoài. Đây là nguồn gốc của nhiều bug khó thấy. Khi chỉ muốn bỏ qua phần tử, hãy dùng return@forEach.
  • Capture đối tượng nặng. Lambda được lưu lâu dài, ví dụ làm listener, sẽ giữ tham chiếu tới mọi biến nó capture. Điều này có thể gây rò rỉ bộ nhớ, ví dụ khi giữ cả Activity trên Android.
  • Không cần trừu tượng hóa. Nếu chỉ có một cách xử lý duy nhất và nó không đổi, một function thường là đủ. Đừng thêm tham số lambda chỉ để “phòng khi cần”.

11. Kết luận

Trong bài này, bạn đã thấy function type mô tả chữ ký của function. Lambda và function reference là hai cách tạo giá trị function. Higher-order function nhận hoặc trả về function để tách phần “khung” khỏi phần “hành vi”. Bạn cũng đã làm quen với closure, lambda with receiver để xây DSL, và inline để loại bỏ chi phí object cùng cách non-local return hoạt động.

Khi nắm vững các khái niệm này, bạn sẽ đọc thư viện chuẩn Kotlin dễ dàng hơn nhiều. Bạn cũng sẽ biết lúc nào nên viết API dạng higher-order cho code của mình.

kotlinlambdahigher-order functionfunctional programming