Don't Repeat Yourself

Don't Repeat Yourself (DRY) is a principle of software development aimed at reducing repetition of all kinds. -- wikipedia

Kotlinのスマートキャスト

最近はKotlinエンジニアをしています。Scalaを5年ほど書いていたので、Kotlinの文法それ自体はScalaの知識でだいたい追いつくことができました。一方で、使えたら便利だと思う機能としてスマートキャストという機能に出会いました。Kotlinには言語仕様書があり、それを読み解くといくつかおもしろいことがわかったので、仕様書を読んだ内容を簡単にまとめておきたいと思います。

スマートキャストとは何か

Kotlinを書いていると、ときどき次のようなコードに出会します。たとえばNullableな型に対してnullチェックを挟むと、その処理以降の型はnullを挟まない型として解釈されるというものです。下記のサンプルコードに示すように、nullであるチェックを走らせる前のコード中で推論される型と、nullであるチェック後に推論される型が変わる(実質的に型の「キャスト」が起こっている)ことがわかります。

fun nonnull() {
    var a: Any? = 42
    
    // a = Any?型

    if (a == null) {
        return
    }

    // a = Any型に変わる
    
    println("${a::class.simpleName}")
}

fun main() {
    nonnull()
}

スマートキャストは、「Flow-sensitive Typing」と呼ばれる型付けの手法を限定的に採用したものです。Flow-sensitive Typingというのは要するに、型付けの際に制御フローの情報を得て型付けの際のヒントにする手法のことを指します。たとえば先ほど説明したifをはじめとする制御構文の情報を追加で型付けの情報として利用するということだと思います。最初に導入されたのはSchemeで、似たような仕組みを取り入れているものとしては他にTypeScriptがそうかな、と思います。

Flow-sensitive Typingのメリットは、不要な実行時の型のキャストを減らせるという点にあるようです[*1]。Kotlinで思いつくケースだと、たとえばAny型で一度型付けをしておいたものの、その後の処理でそれがInt型であることがわかった場合、実行時に型をキャストする必要が出てきます。ところが、スマートキャストをはじめとするFlow-sensitive Typingが採り入れられた型システムにおいては、制御構文等でInt型であることを同定できる情報を付与しておきさえすれば、そのキャストをコンパイル時に行い、実行時に回さないようにすることができる、ということです。あるいはとくにダックタイピング等を導入する動的型付き言語では大きな威力を発揮する手法であるという予想が成り立つように思います。

スマートキャストはデータフロー解析の一種であると捉えられます。したがって、スマートキャストを定義するためには、そもそもデータフロー解析のフレームワークを定義する必要がある、と仕様書は述べています。データフローの領域は、いわゆる束(lattice)の構造になっており、SmartCastData = Expression -> SmartCastTypeという構造を持つようです。…が、この辺りは真面目に読んでないというかあんまりわからないので、詳しくは仕様書をご覧ください

(若干ここからの記述には解釈の誤りを含む可能性がありますが)コンパイラでは、スマートキャストそれ自体はそもそもきちんとひとつ型(というかExpression)が用意されています。そのデータの中では、元のExpressiondelegateする形でスマートキャストの結果を格納する型情報などが格納されています。加えてスマートキャスト時には、そのキャストの結果が「安定的か」どうかを知っておく必要があるのですが、それもプロパティをとして持っているようです。要するに普通のExpressionの解釈とは別に、それを(おそらく)上書きする形で特別視してコンパイラがスマートキャスト情報を保有しているということです。タイミングとしてはFIRを生成するところで保持するようです。

package org.jetbrains.kotlin.fir.expressions

// 筆者が省略

/**
 * Generated from: [org.jetbrains.kotlin.fir.tree.generator.FirTreeBuilder.smartCastExpression]
 */
abstract class FirSmartCastExpression : FirExpression() {
    abstract override val source: KtSourceElement?
    @UnresolvedExpressionTypeAccess
    abstract override val coneTypeOrNull: ConeKotlinType?
    abstract override val annotations: List<FirAnnotation>
    abstract val originalExpression: FirExpression
    abstract val typesFromSmartCast: Collection<ConeKotlinType>
    abstract val smartcastType: FirTypeRef
    abstract val smartcastTypeWithoutNullableNothing: FirTypeRef?
    abstract val isStable: Boolean
    abstract val smartcastStability: SmartcastStability

// 以下、関数定義が続く

スマートキャストが適用される条件

大事な点は、スマートキャストがいつ発動するかを知ることです。スマートキャストは少々暗黙的な機能です。コード上には直接は表現されない、コンパイラが付与する追加情報のようなものなので、コードの字面からそれが起きていることを追うのはなかなか困難なように思います。したがって、発生条件を知っていると幾分かコードリーディングに役立ちます。仕様書によると、スマートキャストは下記の条件で発生するようです。

  • if
  • when
  • エルビス演算子?:
  • セーフナビゲーション演算子?.
  • &&
  • ||
  • Non-nullアサーション!!
  • as
  • is
  • 単純な代入(simple assignments)
  • あとは、プラットフォーム依存のケース。何があるかは知らない。

その他

スマートキャストシンクの安定性

まず、このタイトルを理解するためには言葉の定義が必要になります。スマートキャストをさせる際に発生記述する条件のことを「ソース(source)」と呼び、スマートキャストされた結果導出された型情報を使って何かしらのメソッドの呼び出しなどをかけることをシンク(sink)と呼んでいるようです[*2]

var x: Int? = 42 // 定義
if (x != null) { // スマートキャストソース(smart cast source)
    x.inc() // スマートキャストシンク(smart cast sink)
} else {
    x = null // ネストされた再定義
}

そして、シンクが「安定的(stable)」であるというのは、制御フローグラフの外側からシンクの状況がいじられないことを指します。その結果、先のコード例のようにx.inc()xの型付けが、今回のケースでは「確実に」non-nullなものであると同定することができ、その結果が変わることはないというわけです。

逆にシンクの安定性を壊すことができるケースは、たとえば

  • ミュータブルな値のキャプチャ
  • 並行の書き込み
  • モジュールを跨ぐコンパイル
  • カスタマイズされたgetter
  • delegation

が仕様書には書かれています。実はコンパイラの実装を探してみたところ次のような実装があり、こちらを読むとさらに詳細な条件がわかると思います。SmartcastStabilityenumですが、まさにスマートキャストの安定性を示すためにコンパイラ内部で使用されているようです。enumのうちSmartcastStability.STABLE_VALUEのみが「安定的」である状況を示しており、それ以外のenumはすべて、「安定的でない」ケースを示しています。

package org.jetbrains.kotlin.types

/**
 * See https://kotlinlang.org/spec/type-inference.html#smart-cast-sink-stability for explanation on smartcast stability. The "mutable value
 * capturing" and "concurrent writes" categories in the spec are covered by [CAPTURED_VARIABLE] and [MUTABLE_PROPERTY] together.
 * Specifically, we only do capture analysis on local mutable properties. Non-local ones are considered to be always unstable (assuming
 * some concurrent writes are always present).
 */
enum class SmartcastStability(private val str: String, val description: String = str) {
    // Local value, or parameter, or private / internal member value without open / custom getter,
    // or protected / public member value from the same module without open / custom getter
    // Smart casts are completely safe
    STABLE_VALUE("stable val"),

    // Smart casts are not safe
    EXPECT_PROPERTY("expect property"),

    // Member value with open / custom getter
    // Smart casts are not safe
    PROPERTY_WITH_GETTER("custom getter", "property that has an open or custom getter"),

    // Protected / public member value from another module
    // Smart casts are not safe
    ALIEN_PUBLIC_PROPERTY("alien public", "public API property declared in different module"),

    // Local variable already captured by a changing closure
    // Smart casts are not safe
    CAPTURED_VARIABLE("captured var", "local variable that is mutated in a capturing closure"),

    // Member variable regardless of its visibility
    // Smart casts are not safe
    MUTABLE_PROPERTY("member", "mutable property that could be mutated concurrently"),

    // A delegated property.
    // Smart casts are not safe
    DELEGATED_PROPERTY("delegate", "delegated property"),
}

ところで、スマートキャストが安定的でなくなるケースはどのようなものがあり得るでしょうか?一例を示すと、次のコードはうまくいきませんでした。これはミュータブルな値xに対するキャプチャがrunブロック内で引き起こされるため、ブロック終了後に再登場するシンクは安定的でないという判定になるためです。スマートキャストそれ自体は制御フローの解析によるものであるため、スコープが確定しないとどこまでの制御フローを見ればよいかという情報がなくなってしまうからだと思われます。先ほどのenumでは、CAPTURED_VARIABLEに該当するでしょうか。

fun unstable() {
    var x: Int? = 42
    run {
        if (x != null) {
            // `Int`にスマートキャストされる
            x.inc()
        }
    }
    // 相変わらず`Int?`
    x.inc()
}

Kotlin Playground: Edit, Run, Share Kotlin Code Online

逆に安定的であるケースはどのようなものがあるでしょうか?仕様書によれば、シンクの安定性は次のような条件で「安定的である」とみなされるようです。

  1. delegationないしはカスタマイズされたgetterのない、イミュータブルなローカルプロパティないしはクラスのプロパティ。
  2. 「実質的にイミュータブルである (effectively immutable)」とコンパイラが判定できるかつ、delegationないしはカスタマイズされたgetterのない、ミュータブルなローカルプロパティ。
  3. delegationないしはカスタマイズされたgetterがなく、かつ、現在のモジュール内で宣言されている安定的でイミュータブルなローカルプロパティのイミュータブルなローカルプロパティ。

イミュータブルなものは基本的に、一度宣言された場所以降で値が書き換わることはまずないため、フロー解析の結果を安定化させやすく、ゆえにシンクの安定性を立証しやすいというところでしょうか。いわゆるミュータブルな操作はあとで書き換わる可能性があるという性質から、あるスコープで絞った範囲のフローの解析を「そのスコープ内で」完結させるのが難しくなります。逆にイミュータブルであれば、スコープ関係なくそもそも書き換えが起こることはありえないため、フローの解析の結果を完結させやすいと言えます。[*3]もちろん、ミュータブルだった場合であっても、「実質的にイミュータブル」であるという条件を満たせる限りであれば、それはやはり安定的であるとみなしやすいというのは、想像に難くないと思います。

「実質的にイミュータブルである(effectively immutable)」なスマートキャストシンクは条件がありますが、それは仕様書のこの部分に記述されています。(あとで気が向いたら追記するかもしれない)

ループ中の扱い

ループ中にデータフロー解析が発散してしまった場合には、killDataFlowという命令が走ることがあるようです。この命令は要するにデータフロー解析を止めるために発せられるものです。データフロー解析が、たとえばループなどに差し掛かって解釈に手間取ってコンパイル時間が極端に伸びることを防止するために設けられています。

データフロー解析が発散してしまうケースとしては、たとえば一度も評価されずに終わるボディを持つループが挙げられます。一度も評価されずに終わるボディを持つは、フローのグラフを構築するのが困難になります。一度も評価が行われないため、そもそもグラフを構築できないからです。こうしたケースではスマートキャストの適用は伝播させず、型付けを諦めることになります。

が、一部のループにおいてはきちんとスマートキャストが走るように実装されているようです。具体的には、下記2つはスマートキャストがきちんと走り、型付けが正しく行われます。必ず1回以上は評価が走るボディを持つと考えられるためです。

  • while (true) { ... }
  • do { ... } while (condition)

スマートキャストはフローに依存します。したがって、一度以上ループが走ってくれて制御フローを構築できさえすれば、スマートキャストを走らせられるということです。逆に一度も処理が実行されず、フローを構築できないものはそもそもスマートキャストを起こすのが無理、ということを言っているだけだと思います。

スマートキャストの結果の結びつけ

たとえばaという変数に値を代入後、abという別の変数に代入したとします。abはKotlinでは同じ値を指し示しています。そして、b側にnon-nullチェックを加えます。bに対するnon-nullチェックが入っただけですが、aIntとして判定されています。これを、「abのスマートキャストの結果が結び付けられて(bounded)いる」といいます。

fun bounded() {
    val a: Int? = 42
    val b = a
    if (b != null) {
        a.inc()
    }
}

Kotlin Playground: Edit, Run, Share Kotlin Code Online

まとめ

Kotlinにおけるスマートキャストの概要と型システム上のメリット、発生条件などをまとめました。

*1:Kotlinの仕様書ではそう説明されています→Kotlin language specification。ちなみにですが、Kotlinも「限定的な」flow-sensitive typingの採用になっているようです。

*2:仕様書では語の定義がなく、突然登場する印象を持ちましたが。

*3:余談ですがこのあたりの議論はRustのライフタイム解析でもやはり同様のことが当てはまり、RustはShared XOR Mutabilityという戦略をとることにより、この解析の正確性を担保したのでした。