今回はあくまでも 「コードそのものに起因するエラー(要はバグ)についてです。」
ゲームでも、それ以外でも想定外のコードには nullチェック
や Out-of-Bounds
をチェックするコードをサラッと書いています。
問題を発見したとき、こういった対応方法は本当に適切なのか?コレについて改めてみていきたいと思います。
今回は文章も多く、あまり答えに辿り着いていない記事となります
ここで得られるもの
- コーディングの質が少し良くなるかも
本日のレシピ
- 問題となるコードをみてみる
問題となるコード
以下の関数はエンティティのIDを受け取り、そのIDを持つエンティティを返すものです。
Entity GetEntity(int id)
{
return _entities.FirstOrDefault(entity => entity.Id == id);
}
エラーハンドリングしてみる
この関数で重要な点はIDに該当するエンティティが見つからなかったとき null
を返すところです。
今回は null
を返すシチュエーションが「バグ」であるとします。この場合、該当IDが含まれていないプログラマのエラーとして扱いたく、存在しなくても動作するような扱いにはしたくないことに注意が必要です。
Entity GetEntity(int id) {
var result = _entities.FirstOrDefault(entity => entity.Id == id);
if (result == null) {
Debug.LogError($"{id} entity not found");
}
return result;
}
エラーログを回避のオプションとして選択してみましたが、エラーが出力されることは嬉しいかもしれませんが、プログラムは実行され続けます。
アプローチを根本的に変えてくれるものではありません。
少し使い方をみてみましょう。
Vector3 GetEntityPosition(int id) {
var entity = GetEntity(id);
return entity.Position;
}
呼び出し元は GetEntity
で何が戻ったか気にする必要がありそうに見えます。
ここで重要なのは「GetEntityが戻った後に何が起こるのか」に視点を当てて設計する必要がありそうということです。
上記の例では該当IDではない場合 entity == null
となるため entity.Position
のところで NullReferenceException が投げられるでしょう。
この場合、前の行で無効なIDが渡されるというバグが発生した後、すぐに影響が現れてくれました。
しかし場合によっては null
がそのまま格納され続け、ライフサイクルが長くなってしまったとき、かなり後のコードベースの全く別の場所で発見される場合もあります。
リリースされたゲームやソフトウェアでは「プログラムを動かし続けること」が重要なアプローチの一つであると僕は考えているので、ここの課題として NullReferenceExeption をそういったアプローチで対応してみます
Vector3 GetEntityPosition(int id) {
var entity = GetEntity(id);
if (entity == null) {
return Vector3.zero;
}
return entity.Position;
}
このコードで重量なのは return Vector3.zero;
のところです。このバグは従来同様プログラムは実行され続けてくれます。
ここでまた使い方をみてみましょう。
float GetEntityDistance(int lId, int rId) {
var lPosition = GetEntityPosition(lId);
var rPosition = GetEntityPosition(rId);
return Vector3.Distance(lPosition, rPosition);
}
エンティティ間の距離を返すだけのものです。
このコードの怖いところは GetEntityPosition
は該当IDが見つからなかった場合 Vector3.zero
を返すことがあるので欠陥となります。
次に、これまでのアプローチ同様、エラーを回避し続ける対応を GetEntityDistance
でもしてみましょう。
float GetEntityDistance(int lId, int rId) {
var lPosition = GetEntityPosition(lId);
if (lPosition == Vector3.zero) {
Debug.LogError($"Entity L {lId} not found.");
}
var rPosition = GetEntityPosition(rId);
if (rPosition == Vector3.zero) {
Debug.LogError($"Entity R {rId} not found.");
}
return Vector3.Distance(lPosition, rPosition);
}
これでログ出力によって不正だったことがわかるようになります。が、そもそも Vector3.zero
が正しい値だった場合対応できません。おそらくこの対応方法では GetEntity
自身も、それを使う側も不毛なコードを描き続けることになりそうな予感しかしません。
エラーコードを返してみる
bool GetEntity(int id, out Entity entity) {
var result = _entities.FirstOrDefault(entity => entity.Id == id);
return result != null;
}
エラーコードといっても、今回はbooleanではありますが true == エンティティが見つかった
、 false == エンティティが見つからなかった
となります。
さっそく使う側をみてみましょう。
bool GetEntityPosition(int id, out Vector3 position) {
if (!GetEntity(id, out var entity)) {
position = Vector3.zero;
return false;
} else {
position = entity.Position;
return true;
}
}
エラーハンドリングによるログ出力以上に煩雑なコードになったように思えます。
このようにエラーコードを返すというパターンでは、バグ回避のためのコードが増え続けることが予想できます。
bool GetEntityDistance(int lId, int rId, out float distance) {
distance = 0.0f;
if (!GetEntityPosition(lId, out var lPosition)) {
return false;
}
if (!GetEntityPosition(rId, out var rPosition)) {
return false;
}
distance = Vector3.Distance(lPosition, rPosition);
return true;
}
エラーコードを返すことで何が発生しているのか、これも明確ではありますがコードがより複雑化し、余計な if
による負荷も増え続けることがよくわかりました。
例外で対応してみる
Entity GetEntity(int id) {
var result = _entities.FirstOrDefault(entity => entity.Id == id);
if (result == null) {
throw new ArgumentException($"{id} entity not found", nameof(id));
}
return result;
}
今回はバグが見つかったとき、例外を投げるコードを書いてみました。さっそくこれまで同様、使う側をみてみましょう。
Vector3 GetEntityPosition(int id) {
var entity = GetEntity(id);
return entity.Position;
}
この場合 GetEntity
で例外がスローされるため entity.Position
は実行されず値を返さないで終了します。
例外はUnityかアプリケーション側の実装によってキャッチされると思われます。Unityがキャッチした場合は、その例外をログに出力し、ゲームを次フレームへと続行させます。その後Unityは次フレームを例外が発生しないことを祈りながら進行します。
このアプローチは先程のログ出力が遅延しているにすぎないものであり、例外によってどれだけ部分的に実行された関数が発生したかわからない未知数の予測できない事象が起こることが予測できます。
これをアプリケーション側の実装でUnity以上のリカバリーが可能でしょうか?
アプリケーションを終了してみる
敢えて、これまで掲げた「プログラムを実行し続ける」というのと真逆のアプローチをしてみます。
Entity GetEntity(int id) {
var result = _entities.FirstOrDefault(entity => entity.Id == id);
if (result == null) {
Debug.LogError($"{id} entity not found");
Application.Quit(1);
}
return result;
}
次はバグが見つかったときにエラーを記録し、アプリケーションを終了させてみました。(アサーションでも構いません。)
これによって GetEntity
の呼び出し元は例外がコールスタックを通過することもなく、ゲームはバグが見つかった時点で停止します。
また Application.Quit は iOS なのではクラッシュするように見えるのと、アプリケーションの終了はユーザーの裁量に任せる必要があるため、この実装方法ではそもそも課題があります。
まとめ
コード上のバグを検出したとき、「エラーハンドリング」「例外」「アプリケーションの終了」と3つの選択肢があることに触れました。
「エラーハンドリング」と似たような手法で「エラーコードを返す」という手法もあったりします。これは利点もありますが、あらゆる関数で、そもそも発生しえないシナリオに対応したコードを書き続けないといけない、一つのバグのためにそれに関わる全ての関数でバグの対応をしなければならない状況に陥ります。
それに比べ「例外」はコードの可読性や、扱われるべきエラーコードが簡潔であるため、最善の解決方法のように思えますが、コードベースのどこでキャッチされるかわからないため、強制終了された関数立ち全てをトレースして、その状況に備えるのは困難であり深刻な状況へと陥ります。
Unityがキャッチしてくれた場合は次フレームへと強制してくれますが、その先で新たなバグが発生した場合、それが元の例外が発生したことで関数達が中断されたことが起因なのか、本当に新たなバグなのか判断するのに途方もない時間を要すことでしょう。
結局はクラッシュを避け、プログラムを実行し続けたいという課題解決に向き合い続けることは忘れてはなりませんが、アプリケーションを停止してしまうことで「エラーハンドリング」のように派生しつづける対応コードを各必要ものく、「例外」のように予測できない事態に陥ることもなく、バグをすぐに表面化しプログラマが対応することに注力でき、最終的には割と理にかなっているのかもしれません。