LINEヤフー Tech Blog

LINEヤフー株式会社のサービスを支える、技術・開発文化を発信しています。

コード品質向上のテクニック:第66回 アサートあっても憂いあり

こんにちは。コミュニケーションアプリ「LINE」のモバイルクライアントを開発している石川です。

この記事は、毎週木曜の定期連載 "Weekly Report" 共有の第 66 回です。 LINEヤフー社内には、高い開発生産性を維持するための Review Committee という活動があります。ここで集まった知見を、Weekly Report と称して毎週社内に共有しており、その一部を本ブログ上でも公開しています。(Weekly Report の詳細については、過去の記事一覧を参照してください)

アサートあっても憂いあり

以下の Rational クラスは分数を表現するクラスで、符号付き整数の分子と符号なし整数の分母の組として定義されています。分数自体の符号は、分子の符号によって決定されます。

class Rational(val numerator: Int, val denominator: UInt)

ここで、既約分数を返すような関数 toIrreducible を実装したいとしましょう。以下の実装ではまず、ユークリッドの互除法によって最大公約数を返す関数 calculateGcd を定義し、分母と分子を最大公約数で割ることで既約分数を計算しています。ここで、Kotlin の check 関数は、引数が false のときに IllegalStateException を投げます。つまり、denominator が 0 のときに toIrreducible を呼ぶと例外が投げられます。 (0U は unsigned な 0 という意味です。)

class Rational(val numerator: Int, val denominator: UInt) {

    fun toIrreducible(): Rational {
        check(denominator != 0U) { "Denominator must not be zero." }

        val gcd = calculateGcd(numerator.absoluteValue.toUInt(), denominator)
        return Rational(numerator / gcd.toInt(), denominator / gcd)
    }

    private tailrec fun calculateGcd(a: UInt, b: UInt): UInt =
        if (b == 0U) a else calculateGcd(b, a % b)
}

このクラスに改善点はありますか?

後顧の憂いを払う

この実装では、分母が有効か、つまり 0 でないかの検証を toIrreducible 内で行っています。しかし将来、新たな関数 (plus, minus, etc.) を追加しようとしたとき、その全ての関数で検証コードが必要になってしまうという問題があります。以下のコードでは plus という新たな関数を追加しているのですが、レシーバと引数の両方の分母を検証しています。(require は、引数が false のときに IllegalArgumentException を投げる関数です。)

class Rational(val numerator: Int, val denominator: UInt) {

    ...

    infix fun plus(other: Rational): Rational {
        check(this.denominator != 0U) { "Left denominator must not be zero." }
        require(other.denominator != 0U) { "Right denominator must not be zero." }

        ...
    }
}

このような検証コードで実装漏れがあると、バグの原因になってしまいます。これを改善するためには、値の検証のタイミングを、オブジェクトの作成時や状態の更新時にする と良いでしょう。作成時や更新時に無効な状態 (この例では denominator = 0U) でないことを保証できれば、存在するオブジェクトは全て有効であることを保証できます。以下のコードでは、分母が 0 でないことをファクトリ関数 of で検証し、分母が 0 ならばインスタンスを作らず、null を返しています。

class Rational private constructor(val numerator: Int, val denominator: UInt) {

    fun toIrreducible(): Rational {
        val gcd = calculateGcd(numerator.absoluteValue.toUInt(), denominator)
        return Rational(numerator / gcd.toInt(), denominator / gcd)
    }

    private tailrec fun calculateGcd(a: UInt, b: UInt): UInt =
        if (b == 0U) a else calculateGcd(b, a % b)

    companion object {
        fun of(numerator: Int, denominator: UInt): Rational? =
            if (denominator != 0U) Rational(numerator, denominator) else null
    }
}

オブジェクト作成時と同様、状態を変更する (プロパティへの再代入やコレクションの要素の追加など) 場合も、変更時点で新しい状態の有効性をチェックすべきです。そうすることで、状態が不正になるような操作が行われようとしたとき、戻り値などですぐに呼び出し元へ通知できます。

型は憂いの玉箒

先述の例では、不正な値に対して null を返すというファクトリ関数を定義しました。他に考えられる実装方法として、以下のように null を返す代わりに非検査例外を使う方法がありえます。しかし、この実装は先ほどの例ほど頑健ではありません。

class Rational(val numerator: Int, val denominator: UInt) {
    init {
        require(denominator != 0U) { "Denominator must not be zero." }
    }

    ...
}

確かに、この方法でも「インスタンスが存在する以上、それは有効な値であることを示している」ことは保証できます。しかし先ほどの例とは異なり、コンストラクタの呼び出し元の全てで denominator != 0U であることを保証しなくてはなりません。ここで、Rational 自身はどのように使われるかを定義しておらず、ユーザ入力といった信頼できないデータソースのことを考慮しなければなりません。よって、そこかしこの呼び出し元で if (denominator != 0U) によるチェックが必要になるのですが、チェックの実装忘れがあったとしてもコンパイルは通ってしまいます。Kotlin の nullable 型などの「インスタンスを使うにはチェックが強制される型」を使う方がより安全でしょう。

もちろん、動的型付けの言語で型ヒントも利用できない場合は、次善策として非検査例外を使う必要もあります。ただし、静的型付けの言語ならば、たとえ nullable 型がなかったとしても Optional/Option/Maybe といった型を定義、利用することで、失敗時のハンドリングを呼び出し元に強制でき、より安全な実装を実現できます。

「値はオブジェクト作成時にチェックしましょう」ということを説明する文脈では、このような非検査例外を使ったコードが提示されることもある点に留意してください。

一言まとめ

値の検証はオブジェクトの作成時や状態の更新時に行い、可能ならば、失敗時のハンドリングを呼び出し元に強制する形にするのが望ましい。

キーワード: validation, data model, type checking

コード品質向上のテクニックの他の記事を読む

コード品質向上のテクニックの記事一覧