アサーションの不安

ほとんどの本番 codebase は、二つの派閥のいずれかに属する。派閥Aは assert を装飾的な香辛料のように扱い、関数が被害妄想の弁護士が書いた法的契約書のように読めるまで、一行おきに振りまく。派閥Bはアサーションを開発時のみの補助輪として扱い、ビルド時にすべて取り外し、テストが一度通ったからといって本番環境でコードが動くことを願うだけだ。

両方の派閥が間違っている。問題はアサーションを使うかどうかではない。問題は、アサーションが実際には何を意味するのかということだ。

アサーションはエラーハンドリングではない。入力検証でもない。丁寧な提案でもない。アサーションは「何かが不可能である」という主張だ。 アサーションが発火したら、プログラムに対するあなたのメンタルモデルは破綻している。その区別が、アサーションが属すべき場所や、いくつ書くべきかのすべてを決定する。

アサーションは不変条件のためであり、エラーのためではない

ユーザーがAPIに負の年齢を渡したとき、それはエラーだ。エラーは想定される。エラーには本物のハンドリング、ロギング、ユーザー向けメッセージが必要だ。内部の計算が、成功したはずのクエリの後に負のデータベース行数を生み出したとき、それは invariant violation だ。そんなことは決して起こるべきでない。それこそが、アサーションが存在する理由だ。

これは明らかに聞こえるが、本番コードを読むまでの話だ。文字列が空でないことを assert し、三行後に if (!str) をチェックしてフォーマットされた例外を投げる関数を見たことがある。開発者は、どちらが本当の contract なのか決めかねたために、同じ条件に両方のツールを使っていた。

ここにルールがある。条件が外部入力によって引き起こされる可能性があるなら、それはアサーションではない。 自分のコードのバグによってのみ引き起こされる可能性があるなら、それがアサーションだ。

def process_payment(user_id: str, amount_cents: int) -> Receipt:
    # NOT an assertion. Users or upstream services can send bad data.
    if amount_cents <= 0:
        raise ValueError("amount_cents must be positive")

    # NOT an assertion. The user_id comes from the outside world.
    if not user_id:
        raise ValueError("user_id is required")

    receipt = _charge_card(user_id, amount_cents)

    # THIS is an assertion. If charge_card returned None after
    # succeeding, our understanding of the universe is wrong.
    assert receipt is not None, "charge_card succeeded but returned None"

    # THIS is an assertion. A receipt with zero items after a
    # successful charge means our internal logic is broken.
    assert len(receipt.items) > 0, "receipt has no items after successful charge"

    return receipt

最初の二つのチェックは boundary を守る。最後の二つはシステムの内部一貫性を守る。両方を混ぜると、何に誰が責任を持つのかについて混乱を生む。

三つのアサーションという上限

一つの関数に三つ以上のアサーションを書いていることに気づいたら、二つの問題のいずれかを抱えている。関数がやりすぎているか、invariants があいまいすぎて強制できないかのどちらかだ。

十二個のアサーションを持つ関数は defensive ではない。不確かなのだ。作者はそれを呼ぶコード、それが呼ぶコード、それらの間を流れるデータを信頼していない。その不確かさは、より多くの assert 文を追加するのではなく、refactor によって解決されるべきだ。

実用的な上限は、開発者が頭に入れておける量から生じる。関数は一つの明確な contract を持つべきだ。その contract は、少数の invariants を意味する。安全を感じるために十二個のアサーションが必要なら、あなたの関数はおそらく、他に属すべき責務を吸収しすぎている。

関数を分割せよ。データを変換する部分を抽出せよ。外部サービスを呼び出す部分を抽出せよ。抽出した各関数に、それ自身の小さな invariants のセットを与えよ。関数あたり三つのアサーションは警告灯だ。五つはパンクだ。

本番環境のアサーション:ONかOFFか

言語によって異なる選択をする。Python は -O フラグで実行すると assert 文を取り除く。CやC++のコンパイラは、release build で日常的にアサーションを削除する。JavaScript には組み込みの assert がまったくない。polyfill するか、本番環境で有効なままのライブラリを使うかのどちらかだ。

これは本物のジレンマを生む。アサーションを取り除けば、最も必要なときにまさに安全網を失うことになる。本番環境でのみ現れるバグは、fast fail するかわりに、静かにデータを破損させる。アサーションを残せば、理論上は不可能ながら実際には致命的ではない条件で、本番プロセスをクラッシュさせるリスクがある。

答えは継続のコストに依存する。invariant を違反したことが、次の操作でデータベースを破損させたり機密データを漏洩させたりすることを意味するなら、アサーションはプロセスをクラッシュさせるべきだ。 静かな侵害よりも、強制停止の方がましだ。invariant の違反が、わずかに間違ったログエントリや些細なUIの不具合を意味するなら、ログを残して継続せよ。

// This should probably crash. Continuing with a null user
// after auth succeeded is a security hole waiting to happen.
assert(user !== null, "auth middleware returned null user after success");

// This should probably not crash. A stale cache timestamp
// is annoying but not dangerous.
if (cache.timestamp > Date.now()) {
  logger.warn("cache timestamp is in the future, ignoring");
}

すべての invariant が同じ態度を受けるに値するわけではない。「これは停止しなければならない」と「これは奇妙だが生き延びられる」の違いを見分けることを学べ。

試したがうまくいかなかったこと

あるプロジェクトの初期に、すべての関数の precondition を assert しようと試みた。すべての引数が null、型、範囲、フォーマットについてチェックされた。結果は予想通りだった。テストは見事に通った。本番環境では、サードパーティAPIがフィールドを数値ではなく文字列として返した最初の瞬間にクラッシュした。

問題はアサーション自体ではなかった。問題は、自分たちの管理外のデータに対して assert し、そのまま本番環境でアサーションを有効にしてコンパイルしたことだった。異常な外部のレスポンスが、サニタイズされて処理されるかわりに、プロセスを殺した。私たちは、内部的には一貫しているが、外部的には脆いシステムを構築していた。

boundary と内部を分離することを学んだ。boundary では、積極的に parse と validate を行え。外部の混沌を内部の確実性に変換せよ。boundary の内側では、その確実性を定義する invariants を assert せよ。アサーションは残った。入力検証は、throw するかわりに Result 型を返す明示的な parsing 関数へと移された。

実用的なチェックリスト

アサーションを追加する前に、このリストを確認せよ:

  1. 外部入力がこれを引き起こす可能性があるか? もしそうなら、アサーションではなく validation を使え。
  2. 本番環境でこれが発火したら、プロセスを停止すべきか? もしそうでないなら、かわりに警告をログに残せ。
  3. この関数はすでに三つ以上のアサーションを持っているか? もしそうなら、もう一つ追加する前に refactor を検討せよ。
  4. このアサーションは、六か月後にコードを読む誰かにとって、依然として意味を成すか? 不明瞭なアサーションは refactor の際に削除される。明確なものは生き残る。

アサーションは安全ツールであると同時にコミュニケーションツールでもある。次の開発者に、「この条件は設計上不可能だ」と伝えるのだ。もしその条件が設計上実際に不可能でないなら、アサーションは嘘をついている。そして本番コードにおける嘘は高くつく。

FAQ

関数の引数に対して assert すべきか?

呼び出し元も自分のコードであり、引数が外部入力ではなく内部ロジックの産物である場合のみだ。Public API の関数は validate すべきだ。Private なヘルパー関数は、受け取る値に関する invariants を assert できる。

TypeScript はどうか?コンパイル時に null を捕捉してくれる。

TypeScript の型システムは強力なアサーション層だが、ランタイムでは消える。コンパイラが証明できるすべてのことにそれを使え。空白を埋めるためにランタイムアサーションを追加せよ:API レスポンス、deserialized データ、型チェッカーを迂回するあらゆる as キャストなどだ。

アサーションはパフォーマンスを損なうか?

ほとんどの言語で、適切に配置されたアサーションのコストはマイクロ秒単位だ。数百万のアイテムを処理するタイトなループの内側で assert しているなら、アサーションをループの外に移動せよ。各要素ではなく、バッチ単位で invariant をチェックせよ。

カスタム assert 関数を書くべきか?

組み込みのアサーションメッセージが役に立たない場合のみだ。実際の配列長を出力するカスタム assertNonEmpty は、コンテキストなしにクラッシュする一般的な assert len(items) > 0 よりも有用だ。小さく保て。アサーション framework を構築するな。