📘

動作未定義のロジックにどう対応するか

2024/11/24に公開

はじめに

プログラムを設計・実装する際、「動作未定義(Undefined Behavior)」に直面することがあります。このような状況では、開発者はどのように対応すべきでしょうか

本記事では、動作未定義のロジックを実装する際の考え方、アプローチ、そして最終的なコードへの落とし込みについて考えていきます。

動作未定義とは

ここでいう「動作未定義」とは、プログラムにおいて仕様や要件が明確に定義されていない部分、もしくはシステムがどのように振る舞うべきか確定していない状況を指します。これは、仕様の不備や意図的な設計上の抜けに起因する場合があります。

具体的には、以下のような状態が考えられます。

  • 仕様が曖昧または未定義

    • ある入力や条件に対して「どのように動作すべきか」が仕様書に記載されていない
    • 例: 「範囲外の入力を受けた場合どうなるか」といった取り決めがない
  • プログラムでカバーされていないケースが存在する

    • コード内での処理が、想定外の状況に対して未定義
    • 例: switch文でenumのすべての値をカバーしていない場合
  • 意図的に未定義とされている

    • 特定の状況が「実際に発生しない」と仮定されているため、明確な対応が省略されている
    • 例: 非対応のオプション値が入力された場合

動作未定義が生まれる理由

動作未定義のロジックが発生する原因はさまざまです

  • 要件が曖昧
    • 仕様書や要件定義で特定のケースが記述されていない
  • ビジネス上の不確定要素
    • レアケースや将来の変更を見越して意図的に未定義にされている
  • 異常な入力の可能性
    • 通常の使用では想定されない異常なデータや状態
  • 開発リソースの制約
    • すべてのケースに対応するコストが割けない

動作未定義のリスク

未定義な状態を放置すると、以下のようなリスクを引き起こします。

  • 予期しないシステム障害
    • 想定外のケースによりエラーや不安定な挙動が発生
  • 保守性の低下
    • 後続の開発者が意図を把握できず、バグを生む
  • セキュリティホール
    • 未定義の挙動が悪意ある攻撃者に利用される

特に想定外のケースによるシステム障害は、システムを不安定にするだけでなく、予測不能な結果を招くことがあります。

例えば、ストレージにデータを保存するシステムでは、予期しない値が書き込まれることで、データの整合性が崩れたり、意図しない状態での動作が発生します。これにより、データの復旧(正しいデータに戻す作業など)が非常に困難になる場合があります[1]

動作未定義がもたらす危険性を理解し、それを防ぐための適切な対策を講じることは、開発者にとって非常に重要な課題です。未定義な挙動を最小限に抑え、システムの安全性と信頼性を確保する設計が求められます。

動作未定義に対応する基本方針

ここでは、動作未定義な状況が発生した場合の対応について考えます。様々な方法が考えられますが、個人的に選ぶとしたら以下の内容になります。

  • 動作未定義をなくすよう、仕様を明確にする
  • 明示的にエラーを発生させる

動作未定義をなくすよう、仕様を明確にする

動作未定義の根本的な原因は、仕様が曖昧だったり、ケースによって未定義な状態が存在していることです。そのため、最も理想的な対応は「仕様を明確に定義し、動作未定義を取り除くこと」です。

この方法のメリットは

  • 予測可能性が向上
    • 未定義な状態がなくなることで、システムの挙動が常に予測可能になります
    • 予測可能な動作は、システムの安定性を向上させ、障害発生時の原因特定を容易にします
  • 保守性が向上
    • 後続の開発者がコードの意図を理解しやすくなり、機能追加や変更がスムーズになります
    • 明確な仕様があることで、コードの拡張や変更時にも一貫性を保ちやすく、メンテナンスの効率が上がります
  • テストが容易
    • すべてのケースが明確であるため、テストケースを設計しやすくなります
    • 未定義なケースに対する検証が不要になるため、テストのコストが削減されます

反面、無視できないデメリットもあります。

  • すべての仕様を明確に定義するのは現実的に困難
    • 例えば、ビジネス要件が頻繁に変わる環境では、すべてのケースを網羅する仕様を定義するのは非現実的です
    • 動作を明確化するためのリソース(時間・コスト)が不足する可能性があります
  • 設計の複雑化
    • すべてのケースを明確化しようとすると、仕様やコードが複雑になり、過剰設計に陥るリスクがあります
    • 特に、エッジケースやレアケースに対する詳細な仕様を追求しすぎると、本質的な機能の開発が遅延することがあります

つまり、動作未定義を仕様面の調整によって100%なくすことは不可能です。よって開発の段階で別の方法で対処する必要が出てきます。

明示的にエラーを発生させる

仕様を明確にするのが理想ですが、開発スケジュールやリソースの制約で、すべてのケースを仕様に落とし込むのが難しい場合もあります。そのような場合には、「動作未定義な状況を検知した時点で明示的にエラーを発生させる方法」が有効です。

具体的には

  • 例外をスローする
    • 未定義な入力や状態が発生した場合に、例外をスローして処理を中断します
if (input == null) {
    throw new IllegalArgumentException("Input cannot be null");
}
  • アサーションの利用
    • (本番環境に入れるかどうかはここでは一旦おいておいて)アサーションを利用する
assert input != null : "Input should never be null";

アサーションは、想定外の状態が発生したときにエラーメッセージを出力してプログラムを停止させます。これは特に開発中やデバッグ時に有用な手段です。

この方法のメリット

  • 早期発見が可能
    • 未定義な状態が発生した時点で明確にエラーが検出できるため、問題の原因をすぐに特定できます
  • 安全性が向上
    • 想定外の動作やデータ汚染を防ぎ、システムの信頼性が高まります
  • リスクの局所化
    • エラーを発生させることで、未定義な状態がシステム全体に波及するのを防ぎます

デメリットとしては

  • ユーザービリティの低下
    • 未定義な入力や状態が発生するとシステムがエラーで停止するため、ユーザーにとって「動作しない」という不便さを引き起こす可能性があります
  • 過剰な例外処理の複雑化
    • 未定義なケースに対するエラー処理を過剰に追加すると、コードが複雑になり、保守性が低下するリスクがあります
  • 本番環境での影響
    • 本番環境でエラーがスローされた場合、システムの停止がビジネスに直接的な影響を与える可能性があります

基本方針の使い分けに関する提案

それぞれの方針のメリット、デメリットの整理はできました。あとは、これらをどう使い分けていくかををここで考えます。

基本的には 「リソースが許す範囲で仕様の明確化を行い、対応しきれない部分は明示的にエラーで落とす」 の順番で適応するのが良いと考えます。

仕様を明確にすることは、動作未定義を排除する王道かつ根本的なアプローチです。この方法以外は、全て暫定対応でしかありません。まずはこの方法でやれるところまでやるべきです。

ここで大事なのは「より重要度の高いところから優先的に検討する」ことです。

システムにおける動作未定義な状況を全て検討し尽くすことは現実的に不可能だ、という話をしました。全てが無理ならば、よりインパクトが大きいところにリソースを集中すべきです。

発生頻度の高さ、システムへの影響度、セキュリティリスクなど、これらの要素が高いものから優先的に片付けることで、費用対効果の高い仕様策定ができます。

そして、対応しきれない部分は明示的にエラーで落とす方針は、もちろんリスクもありますが、異常発生における影響範囲を小さくできるという点において大きなメリットがあります。

暫定対応にはなりますが、その中でも動作未定義よる最も被害を抑えられる現実的な方法です。

おわりに

障害が発生した場合、最終的になんとかしないといけない立場にあるのが我々開発者です。動作未定義よる障害は対応が大変になる場合が往々にしてあります。

なるべく障害による致命傷は防ぎ、楽しい開発者生活を送りたいものですね。

脚注
  1. 過去、ログデータからデータベースの値を正常データへ復元をする、といった障害対応に関わったことがあるのですが、二次障害を引き起こす可能性が大いにあることもあり、生きた心地がしなかったです。 ↩︎

Discussion