その2 _ASSERTを賢く使うべし!
プログラムを動かしていた時に突然表れるこの警告:
出ると凄いびっくりと共にがっくりくるのですが、これは不正なポインタによるアクセス違反によって引き起こされます。不正なポインタアクセスで一番多いのは「NULLによるアクセス」です。例えば、
int *p = 0;
int val = *p;
のようなコードです。Visual Studioはポインタによる不正アクセスが行われると上のような警告ウィンドウを表示します。ここで「中断」をクリックすると、止まった箇所にジャンプしてくれます。大変にありがたいわけです。
Visual Studioが感知する不正に対しては上のウィンドウが自動で出ますが、例えば次のようなコードだとどうでしょう:
仕様上のバグ void Clear() {
// クリア条件を満たしていない時にこの関数を呼ばないで下さい!
delete pValue;
}
このClear関数はコメントにもあるように、クリアして良い条件が揃っていない時に呼ばれると致命的であるとします。コメントに書いてあるからと言って、この関数がいつも正しい条件の時に呼ばれるという保証は何もありません。しかも、この関数の呼び出し自体には問題が無いので、Visual Studioも感知してくれません。
このようにゲームの仕様上の不正な操作を未然に防ぐために存在するのが_ASSERTマクロです。この章では_ASSERTマクロをなぜ使う必要があるのか、いつどこで使うのかについて見ていきます。
@ _ASSERTマクロ群
Cのランタイムライブラリの中には_ASSERTマクロが定義されています。このマクロを使うにはcrtdbg.hをインクルードして下さい。
_ASSERTマクロの定義はこんな感じです:
_ASSERTマクロの定義 #define _ASSERT_EXPR(expr, msg) \
(void) ((!!(expr)) || \
(1 != _CrtDbgReportW(_CRT_ASSERT, _CRT_WIDE(__FILE__), __LINE__, NULL, msg)) || \
(_CrtDbgBreak(), 0))
#ifndef _ASSERT
#define _ASSERT(expr) _ASSERT_EXPR((expr), NULL)
#endif
#ifndef _ASSERTE
#define _ASSERTE(expr) _ASSERT_EXPR((expr), _CRT_WIDE(#expr))
#endif
_ASSERT_EXPRというマクロを呼び出しているのがわかります。_ASSERT_EXPRマクロはすぐ上で定義されています。何が何だか…という気がしますが、_CrtDbgReport関数は各種デバッグレポート用のウィンドウを表示する関数です(詳しくはマニュアルをご参照下さい)。
_ASSERT_EXPRマクロは、第1引数のexprの条件が満たされなかったら、第2引数に渡したメッセージをデバッグレポートウィンドウに表記するというマクロです。使い方の例はこちら:
_ASERTの使い方 void Clear() {
_ASSERT_EXPR( isClear == true, _T("クリア条件を満たしていない時にこの関数を呼ばないで下さい!") );
delete pValue;
}
こうすると不正な状態でこの関数を呼んだ時には次のようなレポートウィンドウが表示されます:
「アサートによって止まった」という警告の後に、どのファイルのどの行なのか、そしてプログラマからのコメント(Expression)が記載されたデバッグレポートウィンドウが表示されます。ここで中止を押すとデバッグが終わってしまうのですが、「再試行」を押すと止まった箇所にジャンプしてくれます。
コメントは別にいらないという人は単に_ASSERTマクロを使うだけで十分です。
A _ASSERTをなぜ使うのか?
_ASSERTマクロを使う理由は「致命的である事を知らせる」そして「パフォーマンスの犠牲を避ける」ためです。
_ASSERTマクロは、デバッグモードの時にしか働きません。具体的には_DEBUGマクロ定数が有効ならばアサートが働きます。デバッグモードの時にはプリプロセッサが_DEBUGマクロ定数を自動的に有効にするため、自動的に_ASSERTマクロも有効になります。逆に言えば「リリースモードの時は_ASSERTマクロは『無いもの』とされます」。ここが大切です。
無いものということは、そこは空の行になるわけです。つまりパフォーマンスに全く影響を与えなくなります。先のClear関数は、if文で制御しても良いのですが、そうするとリリース時にも判定文が入ります。この関数が1フレームに何万回も呼ばれるとするならば、その条件文はパフォーマンスに影響を与え出します。そこを_ASSERTに置きかえれば、リリース時に最高のパフォーマンスを叩き出せる事になります。
_ASSERTを使う別の意義として、「致命的なバグが起こる箇所を明示的に示す」というのもあります。Clear関数が不正なタイミングで呼ばれると、ゲーム自体が壊れるとしましょう。実際にプログラムを組んでデバッグ実行した段階で、アサートで止まったとします。これはClear関数が悪いのではなくてその外側のアルゴリズムがそもそも致命的である事を表しています。ですから、外側を徹底的にバグチェックして「アルゴリズム的に不正に呼ばれない」ようにします。これをデバッグ時に明示的に忠告させるために_ASSERTを使うわけです。
もし_ASSERTが無かったら、他の人があっさりと条件を破ってClear関数を呼んで、とんでもない場所でエラーが出るでしょう。まさかエラー原因がClear関数の呼び出しにあるとは思わないので、バグの理由を必死に探すハメになるわけです。このバグ取りは熾烈を極めます。そういう「自分及び他者への明示的な告知・忠告」と言う意味でも_ASSERTマクロの存在は大きいんです。
B _ASSERTを入れるべき所
_ASSERTは明示的な忠告を与えてくれるものですが、リリース版では消えます。ですから、リリース版ではアサート級のエラーが起こる値がそこに来ても忠告なしに通り抜けます。その先にあるのは致命的なエラーです。
となると、_ASSERTを入れた所にはそれと同じ条件文を書いてプログラムが止まらないようにする…のかというと、必ずしもそうでない部分もあります。それは「パフォーマンスが必要な所」や「そこにバグ値がアルゴリズム的に来ない部分」です。
パフォーマンスが必要な所の代表格が描画部分です。1フレームで何千回も呼ばれる描画の内部に例えばIDirect3DDevice9(描画デバイス)のポインタが入ってきたかチェックする部分を入れると、その文だけパフォーマンスの犠牲になります。描画デバイスのチェックは大外で1回やれば十分で、各描画メソッドでは「あるはずだ」の前提で書いて構わないのですが、万が一の間違いがあるかもしれないので_ASSERTを入れてデバッグ時にチェックするようにします:
_ASERTの使用前 void Object::Draw( IDirect3DDevice9 *pDev ) {
if ( !pDev )
return;
}
_ASERTの使用後 void Object::Draw( IDirect3DDevice9 *pDev ) {
_ASSERT( pDev );
}
これは「バグ値がアルゴリズム的に来ない部分」にも該当しています。
もちろん、パフォーマンス等を考慮して条件文を挟んでも問題ない、また条件文で処理をキックしてもゲームとして問題ない部分はちゃんとそれを入れて下さい。何でもかんでも_ASSERTで最適化というのは危険です。あくまでも根底にあるのは「明示的に致命的なバグを報告して欲しい」という精神です。例えば、私は有効なポインタを引数に取る関数でそのポインタに対して_ASSERTチェックを入れる事が多いです。
_ASSERTを入れるのはちょっとした心掛けですが、その恩恵はかなりなもので、大概はデバッグがはるかに容易になります。現場の恐ろしいコード量に達したプログラムで、_ASSERTを入れていないと時に諸先輩に激しく怒られます(笑)。数百万行にもなったゲームのコードで、誰もその全容を知らないという状態で、良く分からずに止まってしまった理由を突き止めて直すのは並大抵ではないんです。それが_ASSERTで止まっているだけでも、その理由がかなりしぼられるため、修正時間が大幅に縮小します。_ASSERTを積極的に使用していきたいものです。