LINEヤフー Tech Blog

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

コード品質向上のテクニック:第70回 制約は(データ)構造の母

こんにちは。コミュニケーションアプリ「LINE」のモバイルクライアント開発を担当している高瀬亮 (loloicci) です。

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

制約は (データ) 構造の母

コード内でエラーの種類とそのエラーレベルを扱うためのオブジェクト ErrorCode を考えます。また、同様にエラーを受け取ってそのエラーレベルによって様々なエラー処理を行う関数 handle を考えます。

以下の実装ではエラーコードを各エラーレベルの Set に追加することでエラーごとのレベルを管理しています。

object ErrorCode {
    const val FILE_SYSTEM_BROKEN = 1
    const val ARGUMENT_IS_INVALID = 2
    const val CACHE_IS_UNAVAILABLE = 3
    ...

    val fatalCodes = setOf(
        FILE_SYSTEM_BROKEN,
        ...
    )

    val errorCodes = setOf(
        ARGUMENT_IS_INVALID,
        ...
    )

    val warningCodes = setOf(
        CACHE_IS_UNAVAILABLE,
        ...
    )
}

fun handle(error: Error) {
    when (error.code) {
        in ErrorCode.fatalCodes -> throw generateException(
            error
        )
        in ErrorCode.errorCodes -> notifyErrorToUser(
            generateNotification(error)
        )
        in ErrorCode.warningCodes -> issueLog(
            generateLog(error)
        )
        else -> doSometing(error)
    }
}

このコードに何か問題はありますか?

制約足らずの構造

この ErrorCode は現状の動作には問題ありませんが、エラーレベルの管理方法に関して以下の問題を抱えています。

  1. エラーコード追加時のエラーレベル設定忘れ:

    この実装ではエラーレベルの設定されていないエラーコードが設定できてしまいます。また、エラーレベルが設定されているかどうかの確認が簡単ではないため、エラーレベルの設定忘れが起こり得ます。また、その場合は意図しない方法で doSomething が呼ばれる事になります。

  2. エラーレベルの二重追加:

    この実装では 1 つのエラーコードを 2 つ以上のエラーレベルのグループに追加できてしまいます。その場合、この実装の handle はより高い側のエラーレベルに対する処理を行うことになります。handle の実装を少し変えれば低い側のレベルの処理も行うようにもできますが、どちらにしても問題がありそうです。

データ構造で制約を行う

これらの問題は、1 つのエラーコードに対して 0 または 2 つ以上のエラーレベルを設定出来てしまうことから発生しています。言い換えれば、「1 つのエラーコードは 1 つのエラーレベルを持つ」という制約を実装で表現できればこの問題を防ぐことができそうです。この制約の下ではエラーの情報を Pair (2 要素 tuple) やそれに類する型・データ構造で表現するのが自然な実装になりそうです。特に kotlin では enum class を利用することで以下のように実装することができます。

enum class ErrorLevel {
    Fatal,
    Error,
    Warning
}

enum class Errors(val code: Int, val level: ErrorLevel) {
    FILE_SYSTEM_BROKEN(1, ErrorLevel.Fatal),
    ARGUMENT_IS_INVALID(2, ErrorLevel.Error),
    CACHE_IS_UNAVAILABLE(3, ErrorLevel.Warning)
}

fun handle(error: Errors) {
    when(error.level) {
        ErrorLevel.Fatal -> throw generateException(
            error
        )
        ErrorLevel.Error -> notifyErrorToUser(
            generateNotification(error)
        )
        ErrorLevel.Warning -> issueLog(
            generateLog(error)
        )
    }
}

奥の手: テストで制約を行う

プロトコルなどの関係でエラーコードのみから Error の情報を復元したり、handle を行う必要がある場合、それを補助する map が必要になるかもしれません。kotlin であれば以下のように補助を行う map を生成することが出来ます。

val codeToError = Errors.entries.associateBy {
    it.code
}

ただし、この map は 1 つのエラーコードについて 2 つ以上の Error が登録されている場合、意図した通りに機能しません。この問題は、上記のコードのみでは「登録されている全てのエラーコードがユニークである」という制限を表現できていないことに起因しています。

この制約は今回のケースでは以下のようなテストで保証をすることができます。

@Test
fun testEachCodeIsUnique() {
    assertEquals(
        Errors.entries.distinctBy { it.code }.size,
        Errors.entries.size,
    )
}

ただしテストで制約を行う場合、プログラムの読者が初見でその制約を知るのが難しくなる場合があることに注意をする必要があります。制約が自明でない場合、それをコメントやドキュメントで伝えるほうが親切でしょう。

制約は (データ) 構造の母

これで、型・データ構造とテストで、コードに「定義されているエラーコードはユニークである」「1 つのエラーコードに対しては単一のエラーレベルが設定されている」という制約を課すことができました。このように「制約」から考えて使用する型・構造を決定することで、おかしな状態のコードの実装を未然に防ぐことができます。

一言まとめ

モデルに関わる条件や制約を型・データ構造やテストで表現すると、おかしな状態のコードを実装してしまう事を防げる。

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

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