Kotlinのスコープ関数:let, run, apply, also, with
1. 概要
Kotlinにはよく似た5つの関数があります:let、run、with、apply、also。これらはすべてオブジェクトに対してコードブロックを実行しますが、そのオブジェクトへのアクセス方法と関数の戻り値が異なります。多くの人は適当に関数を選んでしまい、コードは動作しますが、読みにくくなります。
この記事では、スコープ関数を区別するための2つの基準について説明し、各関数を具体的な例で説明します。その後、テストを書いて動作を検証し、よくあるエラーを見ていきます。この記事を読み終わると、各状況に合った関数を選べるようになります。
2. 2つの区別基準
すべてのスコープ関数はlambdaを受け取り、そのcontext objectに対してlambdaを呼び出します。2つの質問に答えるだけです:
- lambda内で、オブジェクトに
this(receiver)経由でアクセスしますか、それともit(パラメータ)経由でアクセスしますか? - 関数は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 ?:の落とし穴、深すぎるネストに気を付ける必要があります。最後に、スコープ関数の目的はコードを読みやすくすることであり、あらゆる手段で短くすることではないことを忘れずに。