エラーハンドリングのまとめ+個人メモ


tl:dr

IPAのエラーハンドリングの解説の内容が少々不足していると思っていて、
少し考えたことを整理してみたくなったので公開する。できるだけ実践的な内容にしたい。C++ベースで説明。後半は個人のメモ。適宜更新予定。

用語の定義

「エラー」とは、ファイルハンドラーオープンの失敗、メモリやリソース確保の失敗、不正なメモリアクセス、論理的計算の間違えなど。詳細は後述。

エラー発生の系は以下に分類される。

名称 説明
正常系 目的の出力や副作用を得ることができる系
異常系※ 目的の出力や副作用を得ることができない系
准正常系 異常系であるが、仕様どおりに正常系への回復動作が実行される、あるいは、仕様どおりにプロセスが終了する系
名称なし※2 異常系であり、エラー発生以後の動作が不定になる系

※エラー発生を未然に防ぐが目的を達しない系もあり、エラー発生がある場合を異常系というよりは、目的を達しない系を異常系と表現のほうが、正常系テスト、異常系テストと表現とも合致し、より適切と考える
※2ここでは、便宜的に制御不能系と表現する。偶然、正常系に復帰できる場合もあるが、実行環境を変えたり、再コンパイルした場合に、復帰できる保証はない。

「系」とは一連の動作、プログラムでいえば条件分岐を含む一連のステップ(計算や関数の呼び出し)。例えば、以下の場合


std::ifsream ifs(filename);//A 
char* buf = new char[100];//B
if(ifs.good()){
  //Doing somthing //C
}else{
  //Doing somthing //D
  delete [] buf; //E 
  return false; //F
}
//Doing somthing //G

buf[2000] = 10; //H

A->B->C->Gが正常系
A->B->D->E->Fが異常系であり准正常系
H以降が異常系(制御不能系)
となる。

エラー発生の状態の定義は、統一的なものがないが、ここでは、ステップの実行状態を以下のように定義する。アルファベットは上記の例のアルファベットに対応。

実行中の系 エラー発生 状態変数、リソースリーク 動作の制御 対応する系
1 正常 なし 正常、なし 可能 C
2 准正常 あり 異常、ありうる 可能 D
3 准正常 あり 正常、なし 可能 F
4 制御不能 あり 不定 不能 H

「エラーハンドリング」とは、エラー発生に対し准正常系を実装すること。あるいは、エラーが発生しても動作が継続可能で恒常的に正常な状態に復帰させるか、仕様どおりにプロセスを終了させることともいえる。

検知、伝達、対処から構成される。

四角は関数と考えてよい。関数が階層的に呼び出される。

「検知」とは、発生しうるエラーを事前に検知すること。発生したエラーを検知すること(=伝達されたエラーを受け取ること)。
「伝達」とは、エラーが発生したことを関数や計算式の呼び出しもとに通知すること。
「対処」とは、動作の継続が可能であれば継続可能な状態にすること(正常系に復帰させること)。動作の継続が不能なら適切にプロセスを終了すること。プロセスを終了して再始動することも含む。

→例えば関数コールによってエラーを検知し伝達するときは、状態2と状態3のどちらかであるは把握して対処すべき。
リスースの所有権が特定の関数にあるときは、関数を抜けるときに解放すべき。リソースが関数間で共有される場合は、どの階層でリソースを解放するかを把握もしくは設計する必要がある。伝達中のどこかの関数で状態2から状態3にする。
最終的な対処を実施する関数まで戻り対処したのち、正常系の動作に移る。つまり、状態を1にする。ソフトウェアが状態4になってはいけない。

エラー検知方法

0.エラーが発生する前に、エラーが発生する可能性があるか判定する。
これを検知と呼ぶかは議論の余地もあるが、一般的にエラーハンドリングの一手である。

//検知しない例
int a = b/c;   

//事前に検知し対処(回避)するのでゼロ割というエラーは発生しない
int a = c != 0 ? b/c : 0;   
  1. 例外をキャッチ
  2. 関数コール時に、戻り値で受け取る
  3. メンバー変数(関数の戻り値)を判定する
  4. グローバルな変数(関数)で判定する
  5. ライブラリー、フレームワークが提供する関数をコールし戻り値を判定する
  6. ライブラリー、フレームワークが提供するハンドラーを使う

エラー伝達方法

1or2が一般的。
1. 例外をthrow
2. 関数の戻り値でエラー状態を返す
3. メンバ変数を設定
4. グローバルな変数を設定
5. ライブラリー、フレームワークが提供する関数を使って状態を設定
6. ライブラリー、フレームワークが提供する関数をコール
 

エラー対処方法

プロセスを終了させない方法

  • スキップ(中断)
    例)ファイルオープンに失敗したので、以降の読み取り処理も中断し、呼び出しもとにエラーを返す。呼び出しもとは、ファイルがオープできなかったことを前提に准正常系の処理を実行する。

  • デフォルト値を返す

  • 値のクリップ

int i = std::rand();  
auto b = static_cast<std::byte>(std::clamp(i,0,255));   
  • 一定時間、または、一定回数分リトライ
  • 代替え手段を実施
  • ユーザーにデータの再入力を促す
  • ユーザーに通知(ログに残す、メッセージダイアログを表示) 「スキップ」や「ユーザーにデータの再入力を促す」などと併用

プロセスを終了させる方法

  • ユーザーへのメッセージやログを出力して終了
  • プロセスを再起動 例)Google Chromeのタブ(一つのタブがプロセス)
  • マシンとプロセスを再起動

エラーの種類

1.論理エラー

プログラムの論理的矛盾により発生。
→開発段階でエラーが発生しないよう設計にすべき。

静的要因により発生

  • 計算式の誤りなど
  • 配列インデックスの範囲外アクセス
int x = 1/0;
int a[10]; a[20]=1;

2.実行時エラー

実行時の状態により発生。
→エラーは発生を事前に抑止できないので、エラーハンドリングコードを実装する必要ある。

入力データに依存して発生

  • 想定外の入力データを使ったことにより計算
std::ifstream ifs("data.bin"); 
char buf[255];
ifs.read(buf,255);
1/buf[0]; buf[0]=0でエラー発生

実行時のOSやPCの状態によって起こりうるエラー

  • ヒープメモリの確保の失敗
  • リソース確保の失敗
  • メモリ確保した範囲外を参照 
  • ファイルオープンの失敗
  • ファイル書き込みの失敗(空きディスク不足など)
  • ウィルス・放射線などの外的要因でメモリ上のデータが破壊される
  • 文字列のパースに失敗
  • 通信処理で、通信経路、通信先のトラブルによる 

シーケンシャルな処理のエラーハンドリング例

エラーハンドリングなしのフロー

これにエラーハンドリングを実装するなら以下のようにする。

メイン処理にはtry~catchを設定してもよい。例外を使うことを推奨しない人もいるが、例外を使うライブラリが多数存在する以上、ライブラリ設計者の意図を組んで、素直に例外をハンドリングしたほうが実装が簡潔(条件分岐が減ることに)になるなら、作法にしたがうべき。例えば、std::vevctorにpush_backするとき、我々が目にするサンプルソースではtry~catchが設定されないが、一定の品質を確保する必要があるソフトウェアでは、本来設定すべい。メイン処理の最後で例外をcatchしたら、ファイナライズ処理に飛ぶ。逆に、リソース確保が多くエラーが発生しやすいいイニシャライズ部内は、関数の戻り値をチェックしたほうがエラーハンドリングという観点でいえば、利点が多い。例えば、検知と対処がコード上で対になり因果関係が明確。例外の場合、二箇所で同じ例外をthrowする場合、エラーの発生箇所の特定に特別な処理が必要。また、関数コールと対処コードが近いので、対処コードの中で関数の引数を参照しやすく、より具体的なエラーメッセージを作ることができる。イニシャライズ部で確保したリソースはファイナライズで解放するこが前提であるが、C++の場合は、RAIIを積極的に利用すべきで、そのときはファイナライズ部に解放コードは不要で、そのまま終了(サブルーチンならリターン)できる。

非同期処理のエラーハンドリング

非同期処理はそのモデルやライブラリが多いので、ライブラリごとに用意された機構を使うべき。ただ、例外は同期処理単位(スレッド)で補足し、ライブラリが提供する手段でエラーを通知する。

 参考

 その他

  • ソフトウェアのタイプに応じて、どこまで異常を検知すべきか決めるべき(開発コストをかけるべきか)
    -- OS/Driver
    -- 長時間稼働のアプリケーション
    -- 短期間稼働のアプリケーション
    -- ライブラリ
    -- 個人で使うツール
  • メモリーリーク、リソースリークは徹底的になくす
     intel inspector, valgrindを使う
  • 他人にリリースするコードは、メモリ確保、リソース確保の失敗を徹底的に検知する
  • 処理の中断は、ユーザーにとってどんな影響があるか考える  時間のかかる処理の場合、早い段階でエラーを検知し中断する。遅い段階の中断では、待ったユーザーに腹を立たせる
  • ソフトウェア内部で完結するエラー対処ができない場合、エラーログを出力したほうがよい
     C++のオススメのロギングライブラリ
     https://github.com/gabime/spdlog
  • stlコンテナのresize,reserve,push_xxxは、実質メモリ確保を伴うのでメモリ確保と同等のエラー検出を行うべき
  • 例外クラスを作るときは、Opencvの例外が参考になる。
    https://docs.opencv.org/4.1.0/d1/dee/classcv_1_1Exception.html#details 例外オブジェクトに、ファイル名、関数名、行番号を設定でき、catch側で情報を受け取れるのがポイント。
    ファイル名や行番号は_FILE_や_LINE_マクロで取得できる、詳細はは以下を参照。
    https://ja.cppreference.com/w/cpp/preprocessor/replace
    c++11では、現在実行中の関数名もマクロで取得できる。
    https://cpprefjp.github.io/lang/cpp11/func.html
  • 例外をcatchしたとき 例外には、以降の実行が不可でプロセスを終了させべきものもあるが、実行を継続できるものもあるので、例外発生の原因を理解した上でソフトウェア実装したほうがよい。
  • 例外の参考資料
    http://www.02.246.ne.jp/~torutk/cxx/exception/programming.html

 履歴

2019/6/4 初版
2019/6/5 フローが表示されない問題の訂正、誤字脱字の訂正、若干表現を変えた