KotlinのHigher-order functionとlambda

1. 概要

KotlinではfunctionはFirst Classです。functionを変数に代入したり、引数として渡したり、別のfunctionから返すことができます。別のfunctionを受け取るか返すfunctionはhigher-order functionと呼ばれます。このようなfunctionを作成する最も簡潔な方法はlambdaです。

この記事では、function type、lambda構文、function reference、closure、lambda with receiver、そしてinlineキーワードについて解説します。読み終わったら、自分でhigher-order functionを書けるようになります。また、よくある間違いをいくつか回避する方法も学べます。

2. Function type

Kotlinの各functionには型があり、これはfunction typeと呼ばれます。構文は括弧内のパラメータ型のリスト、矢印、その後の戻り値の型で構成されます:

fun main() {
    val add: (Int, Int) -> Int = { a, b -> a + b }
    val greet: (String) -> Unit = { name -> println("こんにちは、$name") }
    val now: () -> Long = { System.currentTimeMillis() }

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

説明:

  • (Int, Int) -> Intは2つのIntを受け取りIntを返すfunctionです。
  • (String) -> Unitはfunctionで、意味のある値を返しません。Javaのvoidに相当します。
  • () -> Longはパラメータを持たないfunctionです。
  • function変数を通常のfunctionのように呼び出します:add(2, 3)。これはadd.invoke(2, 3)と同等です。
5
こんにちは、Lan
true

Function typeはnullableにすることもできます。その場合、型全体を括弧で囲む必要があります:((Order) -> Unit)?。安全に呼び出すには、callback?.invoke(order)を使用します。

3. 最初のhigher-order functionを書く

この記事全体で使用するシンプルなmodelを定義します。金額は浮動小数点誤差を避けるためLong型を使用します:

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はhigher-order functionです。なぜなら、パラメータpriceOfの型が(Order) -> Longだからです。このfunctionは各注文の価格を計算する方法を知りません。それを呼び出し元に委ねます。そのおかげで、同じループを元の価格と割引価格の両方に使用できます。

400000
360000

4. Lambda構文

4.1. 様々な記述方法

Lambdaは常に{}で囲まれます。パラメータは->の前、本体は後に配置されます。最後の式が戻り値です。returnを書く必要がありません:

val isLarge: (Order) -> Boolean = { order ->
    val threshold = 200_000
    order.amount > threshold   // 戻り値
}

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

val ignoreSecond: (Order, Int) -> String = { order, _ -> order.id }
  • lambdaが1つのパラメータのみを持つ場合、宣言を省略して暗黙的なitを使用できます。
  • 使用されないパラメータは_として設定して、コードの意図を明確にし、コンパイラの警告を回避します。

4.2. Trailing lambda

Functionの最後のパラメータがfunction typeの場合、lambdaを括弧の外に置くことができます。それが唯一のパラメータの場合、()を完全に省略できます:

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]

これがKotlinのコレクションAPIが自然言語のように読める理由です。標準ライブラリのすべてのfilter、map、sortedByDescendingはhigher-order functionです。

5. Function reference

既に適切なシグネチャを持つfunctionが存在する場合、それをlambdaで囲む必要がなく、:: 演算子を使用します:

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 })   // トップレベルのfunction
    println(orders.map(Order::amount))              // プロパティ参照
    println(calculateTotal(orders, taxService::withTax)) // バインド済み参照
}
  • ::isVipはtop-levelのfunctionへの参照です。
  • Order::amountはpropertyへの参照です。型は(Order) -> Longです。
  • taxService::withTaxはbound referenceです。functionは既にtaxServiceオブジェクトにバインドされています。
[A2]
[100000, 250000]
385000

Function referenceはfunctionの名前が意図を明確に表しているときは、lambdaよりも読みやすいことが多いです。

6. Functionを返す、そしてclosure

6.1. Functionを返すFunction

Higher-order functionは別のfunctionを返すこともできます。これは設定に基づいて動作を作成したいときに便利です:

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は変数percentを「記憶」します。discountFor実行後でも。このメカニズムをclosureと呼びます。

注意: when内で"GOLD" -> { amount -> amount * 80 / 100 }と書かないでください。その位置では、{}はブロックと解釈され、lambdaではないため、コードはコンパイルされません。最初に値を計算してからlambdaを返してください。上の例のように。または、lambdaを括弧で囲むこともできます:({ amount -> ... })。

6.2. Closureは変数を変更可能

Javaのlambdaとは異なり、Kotlin lambdaはそれがcaptureするvar変数を変更することができます:

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はnextをRefと呼ばれる参照オブジェクトでラップし、lambdaと外側のfunctionが値を共有できるようにします。

注意: 共有変数を変更するclosureはthread-safeではありません。複数のthreadが同じnextId()を呼び出すと、重複したIDを受け取る可能性があります。その場合はAtomicIntegerを使用してください。

7. Lambda with receiver

Kotlinには特別な形式のfunction typeがあります:T.() -> R。このlambda内では、thisはtypeTのオブジェクトそのものです。プレフィックスなしでそのメンバーに直接アクセスできます。これはbuildString、apply、またはGradle Kotlin DSLなどの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の型はOrderBuilder.() -> Unitです。したがって、lambda内ではid = "B1"は実質的にthis.id = "B1"です。apply(block)はbuilderに対してlambdaを呼び出してから、builderそのものを返します。

8. inlineとlambda内のreturn

8.1. Lambdaのコスト

JVM上では、各lambdaは通常オブジェクトにコンパイルされます。lambdaが変数をcaptureする場合、呼び出すたびに新しいオブジェクトが生成される可能性があります。非常に多く呼ばれるfunctionでは、このコストが積み重なります。

Functionをinlineとしてマークすると、この問題が解決されます。Compilerはfunctionの本体とlambdaの本体を呼び出し元に直接コピーするため、オブジェクトは作成されません:

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("合計を計算") { calculateTotal(orders) { it.amount } }
    println(total)
}
合計を計算: 412 µs
500500000

測定時間はマシンごとに異なります。標準ライブラリのfilter、map、forEachはすべてinlineです。

8.2. Non-local returnとラベル

inline functionに渡されたlambda内のplain returnステートメントは、lambdaだけでなく外側のfunctionから抜けます:

fun findFirstLarge(orders: List<Order>, threshold: Long): Order? {
    orders.forEach {
        if (it.amount > threshold) return it   // findFirstLargeから抜ける
    }
    return null
}

fun printPaidOrders(orders: List<Order>) {
    orders.forEach {
        if (it.amount == 0L) return@forEach    // この反復をスキップするだけ
        println(it.id)
    }
    println("完了")
}
  • return itはnon-local returnです。findFirstLargeを終了します。
  • return@forEachはラベル付きreturnです。現在のlambda反復のみを終了し、continueと同様です。

non-inline functionでは、lambda内のplain returnはコンパイラエラーになります。ラベルの使用が必須です。

注意: inline functionがlambdaを別のコンテキストに渡す場合(例えば、別のthreadで実行する場合)、パラメータをcrossinlineとしてマークしてnon-local returnを禁止してください。lambdaを変数またはフィールドに保存したい場合は、noinlineを使用してください。

9. Higher-order functionのテスト

pom.xmlにテスト依存関係を追加します。プロジェクトには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は非常にテストしやすいです。シンプルなlambdaを渡して結果を確認するだけです:

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は与えられたprice functionを適用する`() {
        assertThat(calculateTotal(orders) { it.amount }).isEqualTo(400_000L)
        assertThat(calculateTotal(orders) { 1L }).isEqualTo(3L)
    }

    @Test
    fun `discountForは顧客レベルに合致したfunctionを返す`() {
        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は各closureで独自の状態を保つ`() {
        val first = orderIdGenerator("ORD")
        val second = orderIdGenerator("INV")

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

3番目のテストは重要なポイントを確認します:orderIdGeneratorへの各呼び出しは独立したclosureを作成し、独自のnext変数を持ちます。

10. よくある間違いと非推奨な用法

  • Lambdaが長すぎる。 Lambda本体が数行を超える場合、それを名前付きfunctionに分割してから::で渡してください。コードはより読みやすくテストしやすくなります。
  • ネストされたitの乱用。 orders.map { it.items.filter { it.price > 0 } }と書くと、内側のitが外側のitを隠します。パラメータに明示的な名前を付けてください。
  • すべてにinlineを付ける。 小さいfunctionとlambdaパラメータを持つfunctionのみをinlineにしてください。大きなinline functionはbytecodeを膨らませます。Lambdaを持たないinline functionはほぼ利益がなく、コンパイラが警告します。
  • returnを混同する。 forEach内のplain returnは外側のfunction全体を抜けます。これは多くの見つけられないバグの原因です。要素をスキップしたいだけならreturn@forEachを使用してください。
  • 重いオブジェクトをcaptureする。 Lambdaが長期間保存される場合(例えばlistener)、それがcaptureするすべての変数への参照を保持します。これはメモリリークを引き起こす可能性があります。例えばAndroidでActivity全体を保持する場合。
  • 抽象化が不要。 単一の処理方法があり、それが変わらない場合、通常のfunctionで十分です。将来の「万が一」のためだけにlambdaパラメータを追加しないでください。

11. 結論

この記事では、function typeはfunctionのシグネチャを記述します。LambdaとfunctionReferenceは2つのfunctionを値として作成する方法です。Higher-order functionは別のfunctionを受け取るか返し、「枠組み」を「振る舞い」から分離します。また、closure、DSL構築のためのlambda with receiver、そしてobjectコストを削除し、non-local returnの仕組みを理解するためのinlineについても学びました。

これらの概念を習得したら、Kotlin標準ライブラリを非常に簡単に読むことができます。また、自分のコードに対してhigher-orderなAPIを書くべき時期も判断できるようになります。

kotlinlambdahigher-order functionfunctional programming