Unityのアサーション機能の使い方とそれに関連する契約プログラミングについての知識をまとめました。
Unity2019.4.0
はじめに
この記事ではUnityのアサーション機能(Assertクラス)の説明をします。
またその前提知識として契約プログラミングについてもまとめます。
さらに例外との使い分けの方針についてもまとめます。
ただしこのあたりについては思想がいくつかあるため、一つの考え方として受け取っていただければ幸いです。
エラーの必要性
さて、アサーションはプログラムにおけるエラーに関する処理の一種となります。
そこでまずそもそもエラーを発生させる必要性から考えます。
いま、以下のようなダメージ計算メソッドを考えます。
「攻撃力」「防御力」「攻撃力のバフ値」「防御力のバフ値」を入力としてダメージを計算します。
/// <summary> /// ダメージを計算する。 /// </summary> /// <param name="atk">攻撃力</param> /// <param name="def">防御力</param> /// <param name="atkBuff">攻撃力のバフ値</param> /// <param name="defBuff">防御力のバフ値</param> /// <returns>ダメージ</returns> private int CalcDamage(int atk, int def, int atkBuff, int defBuff) { int damage; // いろんな計算 return damage; }
想定外の入力値とヤバいバグ
このコードは、想定外の入力値を与えられても結果を返せてしまうという問題があります。
たとえばこのメソッドの実装者が、攻撃力や防御力はマイナスにならないという前提で実装をしたとします。
しかしエラーの判定をしていないため、攻撃力や防御力がマイナスとして与えられても戻り値として何かしらの結果が得られてしまいます。
// 不正な値を入力しても計算できてしまう var damage = CalcDamage(-100, -100, 0, 0);
もし何かのミスでこのような不正な計算が行われてしまった場合、
「ダメージを与えるはずが何故か回復させてしまう」などかなり深刻な不具合をもたらす可能性があります。
こういうバグは非常に追いづらく、いろんな作業者の工数を奪い去ります。
プロジェクトに与えるインパクトは多大です。
例外によるエラー判定
さてそれではこれを防ぐために、エラーになった時に例外が発生するように書き換えてみます。
仕様としては攻撃力・防御力は0以上1000未満、バフは0以上100未満に収まるものとします。
private int CalcDamage(int atk, int def, int atkBuff, int defBuff) { if (atk < 0 || atk >= 1000) { throw new ArgumentException($"{nameof(atk)}は0以上1000未満である必要があります。"); } if (def < 0 || def >= 1000) { throw new ArgumentException($"{nameof(def)}は0以上1000未満である必要があります。"); } if (atkBuff < 0 || atkBuff >= 100) { throw new ArgumentException($"{nameof(atkBuff)}は0以上100未満である必要があります。"); } if (defBuff < 0 || defBuff >= 100) { throw new ArgumentException($"{nameof(defBuff)}は0以上100未満である必要があります。"); } int damage; // いろんな計算 return damage; }
これで不正な値が入力されたときにエラーが検知できるようになりました。
例外によるエラー判定でいいのか?
さて前節では例外を使うことによりエラーがしっかり検知できるようになりました。
ここで、以下の点を考えると本当にエラー判定は例外で行うのが適切なのかという疑問が浮かびます。
そもそもハンドリングしなくね?
一点目は、そもそもハンドリングしないなら例外にする必要がないのではないかという点です。
例外はハンドリングができるように作られています。
例えばネットワーク通信を行うためのクラスで通信不安定なときに例外を発生させれば、
それをハンドリングしてリトライなどの処理を実装することができます。
しかしながら今回のダメージ計算式のような処理では例外をハンドリングするイメージは沸きません。
例外が発生したら不正な値を渡している側のソースコードを修正するだけです。
コードの見通しが悪くなる
二点目はコードの見通しが悪くなる点です。
前述の判定処理を見てわかる通り、4つの変数を判定しただけでこれだけの行数を追加する必要があります。
判定文や例外のスロー処理、メッセージが多数書かれるためどうしても乱雑な見た目となります。
さらに変数が増えればそれだけ判定処理も増えていきます。
直感的じゃない
例外を発生させるためにはif文の中に「満たすべき条件」ではなく「満たしてはいけない条件」を書かなければいけません。
仕様としては「0以上1000未満である必要がある」などのように「満たすべき条件」として定義されることが多いのでこれは直感的な記述とは言えません。
直感的じゃない手法はミスを誘発しがちです。
面倒くさい
直感的じゃないことに加えて、記述量の多さや、スローするべき例外クラスを考えなければいけない手間、
メッセージを書く手間なども相まって、例外は書くのが面倒です。
面倒なことは浸透しません。
次第にメッセージが適当に書かれたり、条件自体が書かれなくなることが考えられます。
契約プログラミング
そこでアサーションが登場するわけですが、アサーションの前にまずは契約プログラミングについて理解する必要があります。
契約プログラミングとは、メソッドやクラスのが満たすべき仕様をソースコードとして記述して安全性を高めようという考え方です。
事前条件・事後条件・クラス不変条件
具体的には、事前条件・事後条件・クラス不変条件を記述します。
事前条件とは、メソッド開始時に引数が満たすべき条件です。
これに対して事後条件とは、メソッドの終了時に満たされるべき条件です。
またクラス不変条件とは、クラス内の全てのメソッドやプロパティの操作前後で常に満たされているべき条件です。
言葉だけだとわかりづらいので次節で実際に実装していきます。
アサーション
アサーションは契約プログラミングにおける条件を記述して条件を満たさないときにエラーとして処理するための機能です。
UnityではAssert.IsTrue(/*ここに満たすべき条件を書く*/)
のように記述することで使うことができます。
事前条件を書く
例えば前述のダメージ計算式例は以下のように記述できます。
private int CalcDamage(int atk, int def, int atkBuff, int defBuff) { Assert.IsTrue(atk >= 0 && atk < 1000); // atkの事前条件 Assert.IsTrue(def >= 0 && def < 1000); // defの事前条件 Assert.IsTrue(atkBuff >= 0 && atkBuff < 100); // atkBuffの事前条件 Assert.IsTrue(defBuff >= 0 && defBuff < 100); // defBuffの事前条件 int damage; // いろんな計算 return damage; }
例外のときにはif文に満たしてはいけない条件を書いていたのに対して、
アサーションは満たすべき条件を書いているため直感的です。
ソースコードもかなり見やすくなりました。
事後条件を書く
次に「ダメージ計算結果は常に99999以下である」という仕様があるとして、これを事後条件として記述します。
private int CalcDamage(int atk, int def, int atkBuff, int defBuff) { Assert.IsTrue(atk >= 0 && atk < 1000); Assert.IsTrue(def >= 0 && def < 1000); Assert.IsTrue(atkBuff >= 0 && atkBuff < 100); Assert.IsTrue(defBuff >= 0 && defBuff < 100); int damage; // いろんな計算 Assert.IsTrue(damage <= 99999); // 事後条件 return damage; }
こうしておけば、他の実装者が計算式に変更を加えた際にも仕様が満たされることが保証されます。
このようにアサーションには「仕様をコードの形で表す」という側面もあります。
クラス不変条件を書く
さらにクラス不変条件を書いてみます。
いまキャラクターの情報を表すクラスを考えます。
キャラクターの仕様として、HPは常に0以上99999以下の値を取らなければいけないとします。
すなわち_hp
というフィールドの不変条件をチェックするメソッドを記述し、
それをこのクラスのpublicメソッド・プロパティの処理前後に呼びます。
public class Character { private int _hp; private void CheckInvariant() { Assert.IsTrue(_hp >= 0 && _hp <= 99999); } public void SomeOperation() { CheckInvariant(); // 不変条件をチェック // 何らかの処理 CheckInvariant(); // 不変条件をチェック } }
これでキャラクターの仕様が常に満たされることが保証されました。
Unityのアサーションの仕様
一通りアサーションについて理解できたところで、Unityのアサーションの仕様について理解を深めます。
アサーションメソッドの種類
アサーションメソッドの種類についてはすでに紹介したAssert.IsTrue()
の他に以下の種類が存在します。
メソッド名 | 用途 |
---|---|
IsTrue | 条件が真であることをチェックする |
IsFalse | 条件が偽であることをチェックする |
AreEqual | 二つの値が同じであることをチェックする |
AreNotEqual | 二つの値が同じでないことをチェックする |
AreApproximatelyEqual | 二つの値が誤差を考慮した上で同じであることをチェックする |
AreNotApproximatelyEqual | 二つの値が誤差を考慮した上で同じでないことをチェックする |
IsNull | nullであることをチェックする |
IsNotNull | nullでないことをチェックする |
Releaseビルドで判定するかどうか
アサーションメソッドはいわゆるバグを発見するための機能です。
つまりデバッグ用の機能であり、ビルド後は通常、Development Buildのときにしか条件は判定されません。
あまりいいことではありませんが、リリースビルドでアサーションを有効にしたい場合には、
ビルド時にBuildOptions.ForceEnableAssertions
を設定することで実現できます。
エラー時にはログか例外か
アサーションで条件を満たさない時に例外をスローするかエラーログだけを出力するかは特に決まりがあるわけではありません。
UnityではAssert.raiseExceptions
をtrueにすると条件を満たさない時に例外がスローされます。
falseにすると例外はスローされず、Debug.LogAssertion()
した時のようにLogType.Assert
なログが出力されます。
ただしこのあたりの挙動には最近変更が加わっており、
Unity2019.1以前はAssert.raiseExceptions
のデフォルト値がfalseだったのに対しUnity2019.2以降はtrueになっています。
また将来的には常に例外を出力する方針であるとのコメント共にAssert.raiseExceptions
がObsoleteになっているため、Assertは例外を出力するものとして考えたほうがよさそうです。
テストとか考えるとたしかにこの方がよさそうです。
例外とアサーションの使い分け
さてこのようにアサーションを使うと処理の前後で満たすべき仕様を明示的に記述でき、バグが発見しやすくなります。
ではアサーションを使う場合に例外は不要になのかというと、そういうわけではありません。
アサーションはバグを発見のための機能であるのに対して、例外はバグではなく仕様としてのエラーを表します。
例えば通信状況によるエラーが発生した時には、例外をスローすることでそのハンドリングを使用者に委ねるべきです。
このようなハンドリングさせたいハンドリングさせたいエラーについては例外を使うべきだといえます。