Kotlinのnull safety: ?, !!, let と Elvis Operator

1. 概要

NullPointerException(NPE) はすべての Java 開発者の悪夢です。Kotlin は type system に統合されたnull safetyシステムにより、この問題を compile-time で即座に解決します。この記事は 4 つの主要なツールを理解するのに役立ちます: nullable type を宣言するための ? 記号、safe call ?.、elvis operator ?:、および let 関数。この記事を読み終わると、どのツールをいつ使用するかを理解でき、!! を乱用しても NPE に遭遇するという典型的な落とし穴を避けることができます。

2. Nullable Type と ? 記号

Kotlin では、すべてのタイプがデフォルトで non-null です。変数が null を含むことができるようにするには、タイプの後に ? 記号を明示的に宣言する必要があります。

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

fun main() {
    val customer = Customer("An", null)
    println(customer.email) // null。email が String? なので問題ありません
}
  • name: String — 決して null にはなりません。compiler がこれを保証します。
  • email: String? — null の可能性があります。compiler はそれが使用されているすべての場所で対処することを強制します。

customer.email.length のように email に対して直接 method を呼び出そうとすると、compiler は即座にエラーを報告します:

error: only safe (?.) or non-null asserted (!!.) calls are allowed on a nullable receiver of type String?

これが最大の利点です: エラーは compile-time で検出され、app が本番環境で実行されているときではありません。

3. Safe Call ?.

?. は receiver が null でない場合にのみ method を呼び出すか property にアクセスします。receiver が null の場合、式全体が null を返し、exception は throw されません。

class Order(val id: Long, val customer: Customer?)

fun printCustomerName(order: Order) {
    val length = order.customer?.name?.length
    println("名前の長さ: $length")
}
  • order.customer?.name — customer が null の場合、式全体は短絡 (short-circuit) し、.length を実行することなく null を返します。
  • 結果の length のタイプは Int? です。?. チェーンの各ステップが null を返す可能性があるためです。
名前の長さ: null   // customer == null のとき
名前の長さ: 2       // customer.name = "An" のとき

注意: ?. チェーンが 3〜4 ステップを超える場合は、通常、domain model の設計がまだ十分ではないことを示しています。関数の分割または sealed class の使用を検討して、nullable が蔓延するのを避けてください。

3.1. Safe Call と関数呼び出しの組み合わせ

?. は property だけでなく関数でも機能します:

fun notifyCustomer(order: Order) {
    order.customer?.let { sendEmail(it) }
}

fun sendEmail(customer: Customer) {
    println("メールを ${customer.email} に送信しています")
}

order.customer?.let { ... } 行は、customer が null でない場合にのみ let ブロックを実行します。この部分は、セクション 5 の let に直接関連しています。

4. Elvis Operator ?:

Elvis operator は、左側の式が null の場合にデフォルト値を提供します。名前は ?: の形がエルヴィス・プレスリーの髪を横から見たように見えることに由来しています。

fun getDisplayEmail(customer: Customer): String {
    return customer.email ?: "メールが提供されていません"
}
  • customer.email != null の場合、その値そのものを返します。
  • null の場合、フォールバック文字列 "メールが提供されていません" を返します。

Elvis operator は return や throw と組み合わせたときさらに強力になり、簡潔な早期リターンコードを実現します:

fun validateOrder(order: Order): Customer {
    val customer = order.customer ?: throw IllegalStateException("Order ${order.id} にはカスタマーが必要です")
    return customer
}

?: throw IllegalStateException(...) 行により、その後の customer は compiler によって non-null Customer タイプと推論され、Customer? ではなくなります。これは関数の早い段階で nullable を “unwrap” する非常に一般的なテクニックです。

4.1. Elvis とデフォルト安全関数

fun calculateDiscount(order: Order): Double {
    val tier = order.customer?.name?.length ?: 0
    return if (tier > 5) 0.1 else 0.0
}

ここでは、safe call チェーンと elvis が組み合わされています: どのステップが null を返す場合でも、最終結果は 0 にフォールバックします。

5. let 関数を使用して null でない値を処理する

let はスコープ関数です: receiver を受け取り、ブロックを実行し、ブロックの結果を返します。?. と組み合わせると、値が null でない場合にのみコードを実行する古典的なパターンになります。

fun processPayment(amount: Double?) {
    amount?.let {
        println("支払い処理中: $it")
        chargeCard(it)
    }
}

fun chargeCard(value: Double) {
    println("カードから $value を請求しました")
}
  • amount?.let { ... } — ブロック内は amount != null の場合にのみ実行されます。
  • ブロック内では、it のタイプは Double(non-null)であり、Double? ではないため、chargeCard(it) を自由に呼び出すことができます。!! や追加チェックは不要です。

if (amount != null) { ... } と書く代わりに、let を使用することで、一時変数のスコープを制限し、ブロック外への変数の漏洩を防ぎ、コードを “パイプライン” のような方法で読ませることができます。

5.1. let と Elvis を組み合わせて値を返す

fun formatAmount(amount: Double?): String {
    return amount?.let { "金額: %.2f".format(it) } ?: "金額がありません"
}

この行は自然に読めます: “金額があればフォーマットし、なければフォールバックを返す”。これは実際の Kotlin コードで非常に一般的なイディオムであり、特に DTO からドメインモデルへのデータマッピングの際に見かけます。

6. Non-null Assertion !! — いつ使用するか?

!! は compiler に nullable タイプを non-null として扱うよう強制します。実際の値が null の場合、すぐに NullPointerException が throw されます。

fun getCustomerNameUnsafe(order: Order): String {
    return order.customer!!.name
}
  • order.customer!! — customer が null の場合、この行は NPE を throw します:
Exception in thread "main" java.lang.NullPointerException
    at MainKt.getCustomerNameUnsafe(Main.kt:2)

注意: 本質的に、!! は NPE を Java から Kotlin に持ち込んでいます。値がそのポイントで 100% null ではない ことが確実な場合(前のステップで既に検証されている場合など)にのみ使用し、理由をコメントで説明することが最善です。

!! の使用を受け入れるケース:

  • 古い Java コードとの相互運用。API が @Nullable を返しますが、ビジネスロジックが null を保証しないことを知っています。
  • テストコード。セットアップが間違っている場合に素早く失敗したいとき。

!! を使用すべきでないケース:

  • ユーザー入力、HTTP リクエスト、データベースの処理 — 常に予期せず null になる可能性があります。
  • 検証ロジックの代わり。requireNotNull() または elvis + 意味のある例外を使用してください。
fun getCustomerName(order: Order): String {
    val customer = requireNotNull(order.customer) { "Order ${order.id} にはカスタマーが必要です" }
    return customer.name
}

requireNotNull は nullable を unwrap しながら、!! よりはるかに明確なエラーメッセージを提供します — これは !! よりも優先すべき選択肢です。

7. 4 つのツールの簡単な比較

ツール 使用時期 null に遭遇したときの動作
? null の可能性があるタイプを宣言する N/A、タイプの注釈に過ぎません
?. 安全にアクセスし、null の可能性がある null を返し、throw しません
?: フォールバック値または早期リターンが必要 右側の式を実行
let 値がある場合にのみロジックを実行 ブロックを実行しません
!! 100% null でないことが確実 NullPointerException を throw

8. Null を処理する関数のテスト作成

null ロジックが正しく機能することを確認するために、両方のブランチ(値がある場合と null の場合)に対するテストを作成してください。

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

class OrderServiceTest {

    @Test
    fun `getDisplayEmail はメールが null の場合フォールバックを返す`() {
        val customer = Customer(name = "An", email = null)
        val result = getDisplayEmail(customer)
        assertThat(result).isEqualTo("メールが提供されていません")
    }

    @Test
    fun `getDisplayEmail は値がある場合メールを返す`() {
        val customer = Customer(name = "An", email = "an@example.com")
        val result = getDisplayEmail(customer)
        assertThat(result).isEqualTo("an@example.com")
    }

    @Test
    fun `validateOrder はカスタマーが null の場合 throw する`() {
        val order = Order(id = 1L, customer = null)
        assertThatThrownBy { validateOrder(order) }
            .isInstanceOf(IllegalStateException::class.java)
            .hasMessageContaining("カスタマーが必要です")
    }
}
  • 最初のテストは、email == null の場合に elvis がフォールバックを正しく返すかをチェックします。
  • 2 番目のテストはその逆のブランチをチェック — 実際の値が失われないことを確認。
  • 最後のテストは ?: throw が正しい例外を throw し、期待されるメッセージを持つかをチェックします。AssertJ の assertThatThrownBy を使用。

テストに必要な依存関係 (Gradle Kotlin DSL の場合):

<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>

9. よくある間違い

!! を “compiler を沈黙させるために” 乱用する。 compiler が nullable のエラーを報告し、反射的にすぐに !! を入力したら、止まってください — それはあなたがデータフローを十分に理解していないことの兆候です。自問してください: この値は本当に null になる可能性がありますか? もしそうなら、?: または let で処理してください。

複雑なロジックで機械的に ?.let を if-else の代わりに使用する。 多くのブランチを持つ複雑なロジックの場合、if (x != null) { } else { } の方が let をネストするより明確な場合があります。コードが読みにくくなる場合は、すべてを elvis/let に無理矢理入れないでください。

Java からの platform type に null safety がないことを忘れる。 Java メソッドが @Nullable/@NonNull アノテーション なしのオブジェクトを返す場合、Kotlin はそれを platform type(String!) と見なし、compiler はあなたに null をチェックするよう強制しません。このような Java interop の場合、実行時に NPE が発生する可能性があります — Java とやり取りするときは ?. を自分で追加するか、手動でチェックしてください。

// Java: public String getLegacyName() { return null; } // アノテーションなし
val name = legacyService.getLegacyName() // 推論タイプは String!、Kotlin は警告しません
println(name.length) // name が null の場合、実行時に NPE が発生する可能性があります

10. まとめ

Kotlin の null safety は NPE を完全に消去する “魔法” ではなく、エラーをより早く検出するツールです — 実行時ではなく compile-time で。? は null の可能性を宣言し、?. は安全にアクセスし、?: はフォールバックまたは早期リターンを提供し、let は値がある場合のロジックのスコープを制限します。!! は緊急時の出口であり、あなたが確実で null を throw する危険を冒す準備ができている場合にのみ使用すべきです。この 4 つのツールをマスターすると、古い Java スタイルの null チェックと比べて、Kotlin コードをより安全に書くことができます。

kotlinnull-safetybest-practicesjava-interop