1. コンパイラの「約束」としての Smart-cast
Smart-cast は魔法でも読心術でもありません。とても実用的なコンパイラの判断です。ある地点のコードで値がより狭い型(例:String? ではなく String)であることにコンパイラが 確信できるなら、明示的な as や !! なしで、その狭い型として扱えるようにしてくれます。
重要なのは 確信 という点です。チェックと使用の間に値が変わる可能性が少しでもあるなら、コンパイラは「後で私のせいにされても困るので信用しない」モードになります。
まずは smart-cast が理想的に動き、「当然こうなるよね」と見えるケースから始めましょう。
fun printNameLength(name: String?) {
if (name != null) {
println(name.length) // ok: ブロック内では name を String として扱える
}
}
ここで name は関数パラメータ(本質的にローカル参照)です。if ブロックの範囲では、コンパイラは「ここに入ったなら name は null ではない」と保証できます。
2. smart-cast がときどき効かない理由
問題を一言で言うならこうです。smart-cast は、コンパイラがオブジェクトが 安定している(気づかないうちに変わらない)と確信できる場所でしか働きません。
Kotlin はいくつかの状況に対して「疑い深い」です。変更可能な変数(var)、オブジェクトのプロパティ(obj.prop)、特にカスタム getter を持ち得るもの、そして値がラムダの内部で使われ、別の場所で変更され得るケースです。
ときには腹立たしく見えます。人間の目では「何も変わらない」と分かっているのに、コンパイラはそれを証明できません。理屈は単純で、コンパイラはあなたの考えではなくコードを読み、リスクをモデル化しているからです。
var と「誰かが変えるかもしれない」問題
var は痛みの元になりがちです。文字どおり「変更できる」を意味します。コンパイラが変数が 原理的に 変更され得ると判断すると、長い区間にわたってそれを smart-cast することを拒否しがちです。
現実に近い例を見ましょう。例えば CLI アプリに「現在の入力行」があるとします(設計としては微妙ですが、学習プロジェクトではよくあります)。
var currentLine: String? = null
fun printCurrentLineLength() {
if (currentLine != null) {
// コンパイラが警告する可能性: currentLine は var なので null になり得る
println(currentLine.length)
}
}
なぜ Kotlin が警告し得るのでしょうか。currentLine が 2 回読まれているからです。条件で 1 回、println で 1 回。理論上、その間にどんなコードでも実行され得ます(大きなプロジェクトならイベントハンドラでも別関数でも、グローバル状態を書き換える何かでも)。今この瞬間に「スレッドがない」「そんなことは起きない」としても、コンパイラはプロジェクトの気分ではなく言語仕様の契約に従います。
解決は定番テクニックです。値をローカル val に スナップショットします。
var currentLine: String? = null
fun printCurrentLineLength() {
val snapshot = currentLine
if (snapshot != null) {
println(snapshot.length) // ok: snapshot は val で、このブロックでは安定
}
}
これは重要な習慣です。迷ったら、nullable の値を「管理された 1 回の読み取り」でローカルに取り込み、その後はローカル val だけを使って進めましょう。コンパイラにも、2 週間後の自分にも優しいコードになります。
obj.property は「変数」ではなくアクセサ呼び出し
とても多い落とし穴です。user.name が != null だと確認した後で、user.name.length を使いたくなります。理屈は通っているように見えますが、コンパイラは別の観点で見ます。
ポイントはこれです。user.name は プロパティであり、プロパティアクセスは「単なるフィールド読み取り」とは限りません。getter 呼び出し(カスタム getter を含む)かもしれず、呼ぶたびに別の値を返す可能性があります。そのためコンパイラは user.name を「長時間」smart-cast しないことがよくあります。
最小例:
class User(var name: String?)
fun printUserNameLength(user: User) {
if (user.name != null) {
// よくあるエラー: user.name に smart-cast が適用されない
println(user.name.length)
}
}
name が普通の var でもロジックは同じです。user.name を 2 回読んでいます。読み取りの間にそれが 変わり得ます(例えば誰かが user.name = null を実行するなど)。
正しいテクニックは、やはり スナップショットです。
class User(var name: String?)
fun printUserNameLength(user: User) {
val name = user.name
if (name != null) {
println(name.length) // ok
}
}
これは「その場しのぎ」ではなく、自然なスタイルです。「1 回読む → 確認する → 使う」。
カスタム getter の場合に特に慎重になる理由
なぜコンパイラがプロパティを信用する義務がないのか、より分かりやすくするために、getter が本当にトリッキーな例を想像してください。
class FlakyUser {
val name: String?
get() = if (System.currentTimeMillis() % 2L == 0L) "Neo" else null
}
fun printFlakyNameLen(u: FlakyUser) {
if (u.name != null) {
// 人間ですら 2 回目の u.name が null にならない保証はできない
println(u.name.length)
}
}
この例では「確認してから使う」が機能しないことが見て取れます。u.name は実質的に関数呼び出しだからです。コンパイラが厳密に振る舞うのは、単純なケースにもトリッキーなケースにも同じ安全性で対応するためです。
Nullable + ラムダ: 値が制御の外へ「漏れる」
次のクラスの状況です。x != null を確認した後で、x を ラムダの内部で使います。動くときもあれば動かないときもあり、初心者には Kotlin が意地悪しているように見えます。
なぜでしょうか。ラムダは独立したコード片で、想定どおりの場所・タイミングで実行されるとは限りません。コンパイラは、ラムダが実行される時点まで変数が変更されないことを確信する必要があります。
例: 出力を整形するために、アクションを受け取る関数を使うとします。
fun render(block: () -> Unit) {
block()
}
fun printLenInsideLambda(text: String?) {
if (text != null) {
render {
println(text.length) // ここで smart-cast の制約が出ることがある
}
}
}
実際には Kotlin 2.x(K2 コンパイラ)で、inline 関数内の smart-cast が一部シナリオで改善しました。inline 関数はラムダが「ここで直接呼ばれる」かのように振る舞うため、コンパイラが安全性を証明しやすくなるからです。ただし基本原則は同じです。ラムダがまさにこの場所で実行され、かつ変数が変わらないと保証できないなら、コンパイラは慎重になります。
ほぼ常に効く最も堅牢な手は、ラムダの前にスナップショットを作ることです。
fun render(block: () -> Unit) {
block()
}
fun printLenInsideLambda(text: String?) {
val snapshot = text
if (snapshot != null) {
render {
println(snapshot.length) // ok
}
}
}
Smart-cast と try/catch: 情報が「リセット」され得るとき
もう 1 つ、驚かれることがある点です。変数が null ではないと「分かっていた」つもりでも、try/catch の内側や、いくつかの操作の後では、コンパイラが nullable 型に戻してくることがあります。
ここで細部に踏み込みすぎる必要はありませんが、原理は理解しておくと役立ちます。try の中で同じ変数に別の値(null を含む)を代入した場合、コンパイラはその後に型情報を再評価しなければなりません。だからこそ「val へのスナップショット」は、値を安定なものとして扱いたいときの万能な保険になります。
3. smart-cast が効く形への書き換えテクニック
ローカル val へのスナップショット
ほとんどの「smart-cast impossible」状況に対する基本テクニックです。値をちょうど 1 回だけ読み取り、以降はローカル val として扱います。
式はシンプルです。
val snapshot = maybeNull
if (snapshot != null) {
// snapshot を non-null として使う
}
この習慣は 2 つの問題を同時に解決します。コードを分かりやすくし、再読み取り/getter の再呼び出しをなくします。
Guard clause: 早期リターンで、以降を non-null にする
ときにはコンパイラを「説得」する最良の方法は、コードをより直線的にすることです。値が null なら 即座に抜ける。これが guard clause(ガード節)です。このスタイルの利点は、ネストが減り、if の「階段」が減り、残りの部分に smart-cast を適用しやすくなることです。
2 つの書き方を比べます。
ネストする版:
fun printUpper(text: String?) {
if (text != null) {
println(text.uppercase())
}
}
早期リターン版:
fun printUpper(text: String?) {
if (text == null) return
println(text.uppercase()) // text は String として扱われる
}
心理的にも読みやすいことが多いです。先に「ダメなケース」を切り捨て、その後は ?. を連発せずに主シナリオを書けます。
requireNotNull / checkNotNull: T? -> T の明示的変換
null が「これはバグ/契約違反」を意味するなら、smart-cast を頼るのではなく、はっきり宣言できます。「null なら分かりやすいメッセージで落ちる」。そのための関数が requireNotNull と checkNotNull です。
意味上の違いはこうです。require* は通常、引数や入力データが不正なケース。check* は不正な状態(不変条件が壊れている)に対して使います。この講義で重要なのは別で、どちらも non-null 値を返すため、読みやすく T を得られる点です。
例:
fun tokenLength(token: String?): Int {
val t = requireNotNull(token) { "Token must be provided" }
return t.length
}
ここでは smart-cast に頼っていません。String? から String への「変換」を明示的に行っています。
ただし乱用は禁物です。token がユーザー入力で、通常のシナリオでも欠ける可能性があるなら、fail-fast は厳しすぎます。値をもう一度入力してもらう代わりに、プログラムを落としてしまうからです。前提条件(precondition)は、null が「プログラマのミス」または「内部契約の破綻」を意味する場所で有効です。
4. 実践: remove コマンドと、検索結果の二重読み取り
学習用の CLI 支出トラッカーを想定します。リポジトリが id で支出を検索できます。検索の契約上、結果が存在しない場合があるため、戻り値は Expense? です。
とても簡単なモデル:
data class Expense(val id: Int, val title: String, val amount: Int)
fun findExpenseById(expenses: List<Expense>, id: Int): Expense? {
return expenses.find { it.id == id }
}
次に、素朴なスタイルの remove コマンド処理:
fun handleRemove(expenses: MutableList<Expense>, id: Int) {
if (findExpenseById(expenses, id) != null) {
// 良くない: 検索を再度呼び出して、同じ結果だと期待している
val title = findExpenseById(expenses, id).title
println("Removed: $title")
}
}
ここでの問題は smart-cast だけではありません。ロジックとして 2 回検索しています。呼び出しの間にリストが変わるかもしれませんし、単純に無駄な処理です。
正しいスタイルは、1 回見つける → 保存する → 「結果のスナップショット」で処理する、です。
fun handleRemove(expenses: MutableList<Expense>, id: Int) {
val found = findExpenseById(expenses, id)
if (found == null) {
println("Nothing to remove: id=$id")
return
}
expenses.remove(found)
println("Removed: ${found.title}") // Removed: ...
}
注目点: これは「snapshot」と「guard clause」を同時に満たしています。こういうコードは、ほぼ常にコンパイラに優しく、実行も速く、読み手にも分かりやすいです。
5. 早見表: smart-cast が効く場所/効かない場所
規則を丸暗記するより、次の 1 点を頭に入れるほうが役立ちます。値が安定しているときに smart-cast は可能。ただ参照しやすいように、コンパクトな表にまとめます。
| コード上の状況 | コンパイラが拒否し得る理由 | どう書き換えるか |
|---|---|---|
| if (x != null) を確認し、その後 x を使う | 通常は ok(x がローカル val かパラメータなら) | 何もしない(「理想形」) |
| var x: T? を確認して、その後に使う | var は変更され得る(特に 2 回読まれている場合) | val snapshot = x を作り、以降は snapshot を使う |
| obj.prop != null を確認し、その後 obj.prop.something を使う | prop は 2 回読まれる/getter が別の値を返し得る | val p = obj.prop を作り、以降は p を使う |
| チェック後にラムダの内部で使用する | ラムダは後で、あるいは別の文脈で実行され得る | ラムダの前にスナップショットを取る、または ?.let { ... } |
| チェック後に同じ変数へ代入が起きるような複雑なコード | チェック結果が古くなり得る | Guard clause、または変更の前にスナップショットを取る |
なお Kotlin 2.x では、以前より「通る」シナリオがいくつか増えています(特に inline 関数と特定条件の組み合わせ)。それでも、「1 回読む → 確認 → 使用」という書き方にすると、基本的に得をします。
6. よくあるミス
ミス №1: 「user.name != null を確認したんだから、user.name.length は絶対に使える」。
これは最頻出の落とし穴の 1 つです。目で見ると明らかに見えますが、プロパティアクセスはローカル変数アクセスと同じではありません。getter かもしれないし、再読み取りかもしれないし、状態変更かもしれません。修正はほぼ常に簡単です: val name = user.name として、以降は name を使います。
ミス №2: 「今は確信しているから、とりあえず !! を付ける」。
!! は smart-cast 問題の解決ではなく、「後でより派手に落ちてください」という依頼です。契約上ここで null があり得ないなら、requireNotNull/checkNotNull に分かりやすいメッセージを付けるほうが良いです。null があり得るなら、そのシナリオを処理すべきです(早期 return、?:、?.let { ... })。地雷をコードに埋めないでください。
ミス №3: 結果を保存せず、同じ計算を繰り返す。
if (find(...) != null) println(find(...).title) のように書くと、smart-cast だけでなく、検索関数が決定的で状態が変わらないことまで期待してしまいます。今は正しくても、リファクタリングでバグの原因になり得ます。正しいスタイルは、1 回計算 → val found に入れる → それを使う、です。
ミス №4: スナップショットを作ったのに、結局元の変数を使う。
val snapshot = x と書いて snapshot != null を確認したのに、ブロック内でまた x にアクセスすることがあります。これでは元の問題に戻ります。x は依然 nullable で、かつ変更され得ます。スナップショットを作ったなら、一貫してそれを使いましょう。
ミス №5: ?.let { ... } を、let の中に let を入れる「入れ子人形」にする。
let は便利ですが、3 段の入れ子の「ピラミッド」を作り始めると、コードがまた重くなります。そういうときは、分かりやすい中間 val をいくつか作る、guard clause を使う、あるいは 2 つ程度の関数に分割して、それぞれがすでに non-null の値だけを扱うようにすると良いです。
GO TO FULL VERSION