0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

コード上のバグ対応について向き合う

Posted at

今回はあくまでも 「コードそのものに起因するエラー(要はバグ)についてです。」

ゲームでも、それ以外でも想定外のコードには 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がキャッチしてくれた場合は次フレームへと強制してくれますが、その先で新たなバグが発生した場合、それが元の例外が発生したことで関数達が中断されたことが起因なのか、本当に新たなバグなのか判断するのに途方もない時間を要すことでしょう。

結局はクラッシュを避け、プログラムを実行し続けたいという課題解決に向き合い続けることは忘れてはなりませんが、アプリケーションを停止してしまうことで「エラーハンドリング」のように派生しつづける対応コードを各必要ものく、「例外」のように予測できない事態に陥ることもなく、バグをすぐに表面化しプログラマが対応することに注力でき、最終的には割と理にかなっているのかもしれません。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?