Kotlinのスコープ関数:let, run, apply, also, with

1. 概要

Kotlinにはよく似た5つの関数があります:let、run、with、apply、also。これらはすべてオブジェクトに対してコードブロックを実行しますが、そのオブジェクトへのアクセス方法と関数の戻り値が異なります。多くの人は適当に関数を選んでしまい、コードは動作しますが、読みにくくなります。

この記事では、スコープ関数を区別するための2つの基準について説明し、各関数を具体的な例で説明します。その後、テストを書いて動作を検証し、よくあるエラーを見ていきます。この記事を読み終わると、各状況に合った関数を選べるようになります。

2. 2つの区別基準

すべてのスコープ関数はlambdaを受け取り、そのcontext objectに対してlambdaを呼び出します。2つの質問に答えるだけです:

  1. lambda内で、オブジェクトにthis(receiver)経由でアクセスしますか、それともit(パラメータ)経由でアクセスしますか?
  2. 関数はlambdaの結果を返しますか、それともオブジェクト自体を返しますか?
関数 Context object 戻り値 拡張関数?
let it lambdaの結果 はい
run this lambdaの結果 はい(拡張なしの版もあり)
with this lambdaの結果 いいえ、オブジェクトは引数
apply this オブジェクト自体 はい
also it オブジェクト自体 はい

ペアで覚えると速いです:

  • applyとalsoはオブジェクトを返すので、オブジェクトの設定や関数チェーンの途中に副作用を挿入するのに適しています。
  • let、run、withは結果を返すので、オブジェクトを別の値に変換するのに適しています。

以下の例では、いくつかのシンプルなクラスを共通して使用します:

data class Customer(val name: String, val email: String?)

class Order(val id: String) {
    val items = mutableListOf<Double>()
    var discountPercent = 0
}

class Invoice {
    var number: String = ""
    var amount: Double = 0.0
    var paid: Boolean = false
}

3. let:nullable値の変換と処理

letはオブジェクトをitという名前でlambdaに渡し、lambdaの結果を返します。最も一般的な用途は、safe call ?.と組み合わせて、値がnullでない場合のみコードを実行することです:

fun sendWelcome(customer: Customer) {
    customer.email?.let { email ->
        println("ウェルカムメールを $email に送信します")
    }
}

fun main() {
    sendWelcome(Customer("An", "an@example.com"))
    sendWelcome(Customer("Bình", null))
}
ウェルカムメールを an@example.com に送信します

顧客Bìnhの場合、emailはnullです。式?.がそこで止まり、lambdaは実行されません。lambdaの中で、emailはString?ではなくString型なので、追加のチェックなしに使用できます。ここではパラメータをitではなくemailと名付けて、コードをより読みやすくしています。

letは、中間変数を作成する必要なく値を変換したい場合にも便利です:

val domain = Customer("An", "an@example.com").email
    ?.let { it.substringAfter("@") }
    ?: "不明"

println(domain) // example.com

4. run:オブジェクトに基づいた計算

runはletに似ていますが、オブジェクトがthis経由でアクセスされる点が異なります。thisは省略できるので、オブジェクトのプロパティとメソッドを直接呼び出します:

val order = Order("ORD-42").apply {
    items += listOf(100_000.0, 50_000.0)
    discountPercent = 10
}

val total = order.run {
    val subtotal = items.sum()
    subtotal - subtotal * discountPercent / 100
}

println(total) // 135000.0

runブロック内では、itemsとdiscountPercentはorderのプロパティです。戻り値は最後の式、つまり割引後の金額です。

runには拡張関数ではないバージョンもあります。いくつかのステートメントで値を生成し、一時変数を外側のscopeに漏らしたくない場合に使用します:

val connectionUrl = run {
    val host = System.getenv("DB_HOST") ?: "localhost"
    val port = System.getenv("DB_PORT") ?: "5432"
    "jdbc:postgresql://$host:$port/shop"
}

このブロック後、hostとportは存在しません。connectionUrlだけが保持されます。

5. with:同じオブジェクト上の複数の呼び出しをまとめる

withはrunと同じように動作しますが、拡張関数ではありません。オブジェクトは最初のパラメータとして渡されます:

val summary = with(order) {
    "注文 $id:${items.size} 商品、割引 $discountPercent%"
}

println(summary)
注文 ORD-42:2 商品、割引 10%

意味的には、with(order) { ... }は「このorderについて…を実行する」と読みます。オブジェクトが確実にnullでなく、複数のメンバーを呼び出す必要がある場合に適しています。オブジェクトがnullである可能性がある場合は、order?.run { ... }を使用する必要があります。withは?.と組み合わせられないからです。

6. apply:オブジェクトの設定

applyはthis経由でオブジェクトにアクセスし、オブジェクト自体を返します。これは同じ式内でオブジェクトを初期化・設定するための慣用的な方法です:

val invoice = Invoice().apply {
    number = "INV-001"
    amount = 250_000.0
    paid = false
}

println("${invoice.number} - ${invoice.amount}") // INV-001 - 250000.0

lambdaの結果は無視され、applyは常に作成されたばかりのInvoiceを返します。Springでbean設定する際など、setterを使用するJavaクラスを扱う場合にapplyがよく使われます:

@Bean
fun restTemplate(): RestTemplate = RestTemplate().apply {
    requestFactory = SimpleClientHttpRequestFactory().apply {
        setConnectTimeout(2_000)
        setReadTimeout(5_000)
    }
}

7. also:関数チェーンを壊さずに副作用を追加

alsoはit経由でオブジェクトにアクセスし、またオブジェクト自体を返します。この関数は「ついでに」ログ出力、検証、メトリクス呼び出しなどを行う場合に適しており、これらが主要な流れを変更しません:

fun createOrder(id: String): Order =
    Order(id).also { println("注文 ${it.id} を作成しました") }

fun main() {
    val order = createOrder("ORD-7")
        .also { it.items += 99_000.0 }
        .also { println("商品数:${it.items.size}") }
}
注文 ORD-7 を作成しました
商品数:1

オブジェクトがit経由でアクセスされ、this経由ではないため、外側のクラスのthisを混乱することなく使用できます。これは、オブジェクトを設定するのではなく使用するがlambdaの主眼である場合、alsoの方がapplyより読みやすい理由でもあります。

2つの変数をスワップするためのちょっとしたコツもあります:

var a = 1
var b = 2
a = b.also { b = a }
println("$a $b") // 2 1

式b.also { ... }はbの古い値(2)を返し、lambdaはその割り当て前にb = a(1)を割り当てます。

8. どの関数を選ぶか?

以下のルールを使用できます:

  • 値がnullでない場合にコードを実行したい、または値を変換したい場合:?.letを使用します。
  • オブジェクトの複数のメンバーから値を計算したい場合:runを使用するか、オブジェクトが確実にnullでない場合はwithを使用します。
  • 複数のステートメントを1つの式にまとめたい場合:receiverなしのrun { }を使用します。
  • オブジェクトを初期化または設定したい場合:applyを使用します。
  • ログ出力、検証、副作用を追加したいが、オブジェクトは保持したい場合:alsoを使用します。

9. 動作の検証

戻り値を正しく理解していることを確認するために、いくつかのテストを書いてみましょう。まず、pom.xmlに依存関係を追加します:

<dependencies>
    <dependency>
        <groupId>org.jetbrains.kotlin</groupId>
        <artifactId>kotlin-stdlib</artifactId>
        <version>2.0.21</version>
    </dependency>
    <dependency>
        <groupId>org.junit.jupiter</groupId>
        <artifactId>junit-jupiter</artifactId>
        <version>5.11.3</version>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.assertj</groupId>
        <artifactId>assertj-core</artifactId>
        <version>3.26.3</version>
        <scope>test</scope>
    </dependency>
</dependencies>

プロジェクトがSpring Boot 3.xの場合、spring-boot-starter-testにはJUnit 5とAssertJがすでに含まれているため、これら2つのライブラリを個別に宣言する必要はありません。

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

class ScopeFunctionsTest {

    @Test
    fun `applyとalsoはオブジェクト自体を返す`() {
        val invoice = Invoice()

        val fromApply = invoice.apply { amount = 100.0 }
        val fromAlso = invoice.also { it.paid = true }

        assertThat(fromApply).isSameAs(invoice)
        assertThat(fromAlso).isSameAs(invoice)
        assertThat(invoice.amount).isEqualTo(100.0)
        assertThat(invoice.paid).isTrue()
    }

    @Test
    fun `let run withはlambdaの結果を返す`() {
        val order = Order("ORD-1").apply { items += listOf(10.0, 20.0) }

        val count: Int = order.let { it.items.size }
        val sum: Double = order.run { items.sum() }
        val label: String = with(order) { "Order $id" }

        assertThat(count).isEqualTo(2)
        assertThat(sum).isEqualTo(30.0)
        assertThat(label).isEqualTo("Order ORD-1")
    }

    @Test
    fun `letは値がnullの場合は実行されない`() {
        var called = false
        Customer("Bình", null).email?.let { called = true }

        assertThat(called).isFalse()
    }
}

最初のテストはisSameAsを使用して、applyとalsoが元の参照を返し、コピーではないことを確認します。2番目のテストは型を明示的に宣言(Int、Double、String)して、各関数がlambdaの結果を返すことを明確に示します。3番目のテストは、?.letがnull値をスキップすることを確認します。

10. よくあるエラー

10.1. apply内でthisが隠蔽される

apply内では、最も近いreceiverが優先されます。したがって、外側のクラスと同じ名前のプロパティは隠蔽されます:

class InvoiceFactory(private val amount: Double) {
    fun create() = Invoice().apply {
        amount = amount // Invoice.amountを自分自身に割り当て、結果は0.0のまま
    }
}

2つの方法で修正できます。1つ目はthis@InvoiceFactory.amountでreceiverを明示的に指定する方法です。2つ目はalsoに切り替える方法です:

fun create() = Invoice().also { it.amount = amount }

10.2. ?.let { } ?: ...の落とし穴

多くの人がx?.let { ... } ?: fallbackをif/elseと同じ意味で書きます。この書き方は同等ではありません。lambdaがnullを返すと、?:ブランチも実行されます:

val cache = mapOf<String, String>()
val email: String? = "an@example.com"

email?.let { cache[it] } ?: println("メールアドレスがありません")
メールアドレスがありません

ここでemailはnullではありませんが、cache[it]はnullを返すので、メッセージが出力されてしまいます。これは正しくありません。明確に分岐する必要がある場合は、if (email != null) ... else ...を使用してください。

10.3. 複数階層のネストが深すぎる

スコープ関数を相互にネストすると、itとthisが異なるオブジェクトを指すようになり、読み手はそれぞれが何であるかを追跡する必要があります:

// 読みにくい
order.let { o -> o.items.firstOrNull()?.let { price -> price.run { this * 2 } } }

注意: 1階層以上ネストすべきではありません。ネストが必須の場合は、itではなく明確なパラメータ名を使用してください。さらに良いのは、ローカル変数または別の関数に分割することです。

10.4. できるという理由だけでスコープ関数を使用する

val name = customer.let { it.name }という一節は、val name = customer.nameと比べて何のメリットもありません。スコープ関数は一時変数を削除したり、nullを処理したり、設定コードをまとめたりするのに役立ちます。それが達成できない場合は、使用しないでください。

11. 結論

Kotlinの5つのスコープ関数は、2つの点でのみ異なります:オブジェクトがthisまたはit経由でアクセスされるか、そして関数がオブジェクトまたはlambdaの結果を返すかどうか。そこから対応する用途が得られます:設定にはapply、副作用追加にはalso、nullable値処理と変換にはlet、そしてオブジェクトに基づいた計算にはrunとwith。また、thisの隠蔽、?.let ?:の落とし穴、深すぎるネストに気を付ける必要があります。最後に、スコープ関数の目的はコードを読みやすくすることであり、あらゆる手段で短くすることではないことを忘れずに。

kotlinscope-functionsletapplyclean-code