🚨

エラーとアサーションについて

2022/11/18に公開

概要

デバッグに使われるアサーションについてのお話。なぜアサーションとはなんぞや?なぜアサーションはマクロで実装するのか?など、アサーションに関するあれこれをまとめました。

環境

Visual Studio 2022
C++17

アサーションとは?

アサーションはそれが呼ばれた時にアサーションの引数に指定された条件式を満たしているべきだと明示することです。条件を満たしていれば何も起きず、そうでなければエラー処理を行う(プログラム終了等...)つまりifチェックをしているのと同じです。

assert(1 + 1 == 2); // 1 + 1 = 2 〇
assert(1 + 1 == 3); // 1 + 1 = 3 ✕

Windowsの場合は assert マクロでアサーションを行います。条件式を満たさない時、エラー箇所の出力を行って実行を停止させます。[1]

これまでのエラーハンドリング

printf デバッグ

私がいままデバックで使用していたのは、ゆわゆる printfデバッグ という方法です。ここでのprintfデバッグは 「文字列を出力する関数を使ってデバッグをする」 ということを意味しており、標準ライブラリのprintf関数に限定した話ではありません。

Object* object = CreateObject();
if (!object)
{
    printf("Faital Error: object is null.");
}

printfデバッグの問題点は、エラーが起きた場所がすぐにわからないという点です。初期化・終了処理など、1度しか行わない処理の場合はコンソールを見れば一目瞭然です。しかし、ループ中のデバッグ出力はコンソールに大量に文字出力がされるので、エラーが起きた場所を探りたい場合には向いていません。

Faital Error: object is null.
Faital Error: object is null.
Faital Error: object is null.
Faital Error: object is null.
...

つまり、不正な値であった場合に実行を一時停止させて欲しいわけですよ。

assert マクロ

Object* object = CreateObject();
assert(object != nullptr);

assertマクロは不正な値であった場合に実行を一時停止させるので、エラー箇所が即座に判定できそうです。ifチェックよりスッキリしていますね。ただし、エラー発生後の処理がこちら側から操作することが出来ません。assertは、失敗した判定式とその式のファイル名、行番号しか出力せず、出力文字列を独自にカスタマイズできません。デバッグ時にメッセージボックスが出るのは個人的に鬱陶しい感じがしますし、エラー内容も出力できた方が良い気もします。

debugbreak と アサーション

__debugbreak はその名の通り、呼び出した場所にブレークポイントを置いたのと同じ挙動をします。ヒットすれば実行は一時停止して呼び出した場所にフォーカスされます。これを使えばエラー後の処理を自由にカスタマイズできそうです。

Object* object = CreateObject();
if (!object)
{   
    // ここから
    printf("Faital Error: object is null.");
    
    // ここまで処理を書く
    __debugbreak();
}

エラーの種類

アサーションだけを使えばよいという訳ではないというお話。ゲームエンジンで開発する際に起こるエラーを大まかに分けると、EngineエラーとEditor(Application)エラーに分けられます。例えばエディターはエンジンに乗っかって実行され、エンジンが落ちるとエディターも落ちると仮定します。エンジン側のヌルポ(エンジン側で管理するリソースの場合)はどうしようもないので、素直にエラーを吐いて停止するしかありません。対してエディター側(エディターの操作に対するエンジンの対応)はすべてがそうであるとは限りません。エディターから無効な値が渡された時に、エンジンは仮の値を割り当てることで、可能な限りエンジンが停止するのを防ぎます。ユーザー(エディターを使う人)がアセットパスを間違えただけでエディターが落ちるようでは話になりません。また、停止するのを防ぐだけでなくユーザーに無効な値が渡されたという事を伝えることも必要で、視覚的にエラーが起きていることを伝えることが大切です。Unityの場合、マテリアルにエラーがあるメッシュはマゼンダのマテリアルが割り当てられるので、視覚的にマテリアルにミスがあることが分かります。

ビルド構成

アサーションはデバッグ中に仕込んで置き、製品版では取り除かれるものです。なので、デバッグ中であれば Debug / Release 関係なくアサーションを有効にしておき、製品版で無効にするという考え方もありますし、Debugのみアサーションを有効にしてReleaseビルドを製品版としてアサーションを無効化するという考え方もアリだと思います。また、アサーションの種類を増やすという手もあります。一般的にDebug中はDebug/Relaseどちらのマクロも有効、Release中はRelease用のマクロが有効になることが多いです。

UnrealEngineでは

UnrealEngineの場合は、check, verify, ensure の3つのマクロが用意されています。エラーに応じて、実行を停止するべきかどうかを判断してアサーションを使い分ける必要がある。
https://docs.unrealengine.com/4.27/ja/ProgrammingAndScripting/ProgrammingWithCPP/Assertions/

なぜマクロにするのか

アサーションというのはマクロで定義されるのが一般的です。VisualC++のアサーションも、UnrealEngineのアサーションもマクロで定義されています。その一番の理由はビルド構成の切り替えが便利だからです。Debugではアサートを有効にして、Releaseではアサートを無効にする場合、関数で同じことをするにはアサート毎に、前後に#ifdefのようなプリプロセッサ分岐を書く必要があります。[2]

マクロ

// 任意の引数を受け取るようにすれば、文字列がコメントとして機能する
#ifdef _DEBUG 
    #define ASSERT(...) __debugbreak();
#else
    #define ASSERT(...)
#endif

Object* object = CreateObject();
if (!object)
{
    // リリースでは無効化される
    ASSERT("Faital Error: object is null.");
}

関数

void Assert(const char* message) { __debugbreak(); }

Object* object = CreateObject();
if (!object)
{
#ifdef _DEBUG
    Assert("Faital Error: object is null.");
#else
}

サンプルコード

最後に、__debugbreak を使ったアサートマクロの一例です。
第一引数に条件式を、第二引数には任意で文字列を指定します。引数の数に応じてオーバーロードされ、第二引数を指定した場合は、その文字列がデバッグ出力されます。

// 複数定義を避けるために、適宜プレフィックスをつけると良し!
#define EX(x) x
#define ARG3(_1, _2, _3, ...) _3
#define BREAK(expr, ...) { if(!(expr)) {                      __debugbreak(); } }
#define PRINT(expr, ...) { if(!(expr)) { printf(__VA_ARGS__); __debugbreak(); } }

#if _DEBUG
#define ASSERT(...) EX(ARG3(__VA_ARGS__, PRINT(__VA_ARGS__), BREAK(__VA_ARGS__)))
#else 
#define ASSERT(...)
#endif

BREAKがメッセージを出力せず、PRINTがメッセージを出力するマクロです。ASSERTに渡された引数がARG3に渡され、3番目の引数となったマクロが選ばれることによってオーバーロードをしています。ASSERTに引数が2つ渡された場合は、ARG3の引数は4個になるのでPRINTが選択され、引数が1つ渡された場合は、ARG3の引数は3個になるのでBREAKが選択されます。

Object* object = CreateObject();

// 一時停止
ASSERT(object);

// 一時停止 + デバッグ出力
ASSERT(object, "Faital Error: object is null.");

参考

https://in-neuro.hatenablog.com/entry/2020/10/21/155651
https://qiita.com/tyanmahou/items/bb45c0ad63b9df4abaf1


脚注
  1. 正確には中止、再試行、無視の選択肢が[MessageBox]
    (https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-messagebox)系関数によって提示されます。 ↩︎

  2. 関数内部でプリプロセッサ分岐を行ってもよいですが、個人的に空の関数呼び出しをしたくありません。最適化がかかるでしょうし、些細な問題かもしれませんね... ↩︎

Discussion