Java と Kotlin で assertion の挙動が違った話

assertion とは

assertion とはプログラムの中の様々な前提が満たされていることを確認するための仕組みで、日本語だと「表明」という訳が当てられることが多いです。

Java や Kotlin に限らず様々な言語で実装されており細かい仕様は言語ごとに異なりますが、多くの場合「前提が満たされていない場合は例外を発生させる」「本番環境では無効化できる」という点で共通しています。

例えば Kotlin の場合は以下の形で記述され、仮に条件が満たされなかった場合は AssertionError が指定されたエラーメッセージ付きで投げられます。

val result: Int = someFunction()
assert(result > 0) {
    "result of someFunction() is always expected to be positive but is $result"
}

assertion を使えば、プログラムが想定通りに動作していることの確認をプロダクトコード中に含めることができるので、適切な箇所で利用すれば必要なテストコードを大きく削減することができます。

起きた出来事

サーバーサイドKotlinで開発されているドワンゴ教育授業の新教材基盤システムでは主に、「集約のルート」に相当する大きなデータクラスの init ブロックの中で、内部整合性を確認する目的で assert() 関数を利用していました。

例えば以下の例ではチャプターとその親となるコースで参照しているIDに矛盾が無いことを確認しています。

data class ChapterRoot(
    val chapter: Chapter,
    val parentCourse: Course,
) {
    init {
        assert(chapter.courseId == parentCourse.courseId) { "courseIds are conflicting in $this" }
    }
}

これは最もシンプルで軽い例ですが、中には全ての配列の要素を1つずつ調べたり、HTMLテキストのパースを行ったり、比較的重めの処理も存在します。 ただ、「本番では無効になる」という漠然と理解していたのでパフォーマンスへの影響は特に気にせず、テストコードの代用になりそうなところはなるべく assert() によるチェックを仕込むようにしていました。

しかし、リリース前の負荷テストでレスポンスが遅いエンドポイントの調査を行っていたときに、assertのチェック処理が本番サーバー上でも実行されており、 それが原因でレイテンシーが悪化していたことが判明しました。

原因

これは、JavaとKotlinのassertionの間にある、以下のような違いを認識していないことが原因でした。

Java

Javaのassertion機能は 言語機能レベルで実装され、 以下のような専用の assert 文が用意されています。1

int result = someFunction();
    
assert result > 0 : "result of someFunction() is always expected to be positive but is" + result;

そして、assertionを無効にした状態では判定処理を含む文全体がスキップされます。

これによって、本番でのパフォーマンスを気にすることなくプログラムの事前条件の表明を細かく仕込むことができます。

Kotlin

一方、Kotlinの assert() 関数は、以下のように実装された ただの関数 です。2

internal object _Assertions {
    internal val ENABLED: Boolean = javaClass.desiredAssertionStatus()
}

public inline fun assert(value: Boolean, lazyMessage: () -> Any) {
    if (_Assertions.ENABLED) {
        if (!value) {
            val message = lazyMessage()
            throw AssertionError(message)
        }
    }
}

assertion が有効かどうかを判定するif節は例外を投げる部分にしかかかっておらず、条件判定の部分は Boolean 型の引数なので、assertionが無効になっているときも必ず判定処理が実行されるようになっています。

Kotlinのassertionがこのような設計になっているのは、条件判定に副作用が存在することが原因で、assertionの有効/無効でプログラムの挙動が変化してしまうことを防ぐという目的があるそうです。3

対策

上記のKotlinの assert() 関数の実装を参考に、以下のような debugAssert() の関数と判定用オブジェクトを定義し、それを assert() の代わりとして使用するようにしました。

inline fun debugAssert(condition: () -> Boolean, message: () -> String) {
    if (AssertionFlag.enabled) {
        if (!condition()) {
            throw AssertionError(message())
        }
    }
}

object AssertionFlag {
    val enabled: Boolean = javaClass.desiredAssertionStatus()
}

condition として値ではなくラムダ式を渡すようになっているので、アサーションが無効の時には判定処理の実行コストが発生しないようになっています。 これによって、コードの堅牢性を保ったまま、前述のパフォーマンスの問題を大きく改善することができました。

まとめ

KotlinはJavaをベースに開発され、JavaはKotlinをはじめとする多言語の機能を積極的に取り入れていっているため、両言語の類似点は非常に多いです。 しかし両者はあくまで異なる設計者による異なる言語であるため、似たような機能でも詳細な仕様や背後の設計思想には当然異なる部分が存在します。

JavaとKotlinの両方に存在する類似機能を使う場合には、他方の機能からの類推でわかった気にならずにちゃんと使いたい言語自身の仕様を調べないと、思わぬバグの温床になってしまうということを今回の経験で実感しました。

We are hiring!

株式会社ドワンゴの教育事業では、一緒に未来の当たり前の教育をつくるメンバーを募集しています。

サーバーサイドKotlinのチームでは、全体的な設計を主導できる開発者を探しています。

カジュアル面談応募フォームも受付中です。

また、開発チームの取り組み、教育事業の今後については、他の記事や採用資料をご覧ください。

www.nnn.ed.nico

speakerdeck.com