なかなか気付かないバグ

少々ハマったので、ここにメモしておく。恥を晒しておけば同じミスは繰り返さずに済むかもしれないし。

まず、次のインターフェイスを持つ「状態」オブジェクトを考える。

interface IState
{
  void Enter();
  void Exit();
}

この状態に遷移したときには Enter() が呼ばれ、この状態から抜けるときには Exit() が呼び出されなくてはならない。また、常にアクティブな状態は最大でも1つだけとし、1つもアクティブな状態がない場合もあるとする。

そこで次のような管理クラスを作った。

class StateManager
{
  static IState _current;

  public static IState Current
  {
    get { return _current; }
    set
    {
      if ( _current != value ) {
        if ( _current != null ) _current.Exit();
        _current = value;
        if ( _current != null ) _current.Enter();
      }
    }
  }
}

Current プロパティに状態を代入すると、まず現在の状態を Exit し、次に新たな状態に Enter するコードとなっている。シンプルなコードだけれど、今回このコードがバグを生んだ。現在の状態が Exit されないまま新たな状態に Enter してしまうケースが発生したのだ。

何が原因か、予想できます?デキる人にはすぐに分かるのかな?

まず、マルチスレッドが関わったらアウトだ。何も排他制御をしていないので、複数スレッドからこのプロパティを同時にセットしたら整合性が狂ってしまうだろう。しかし、今回のバグはスレッド絡みではない。

次に、例外が思い当たる。もし Enter() や Exit() が例外をスローした場合は状態遷移に失敗したということになるので、厳密には元の状態にロールバックするような処理が必要になるだろう。しかし、今回のバグは例外絡みでもない。

答えは、再帰呼び出し

IState を実装したとある具象クラスにおいて、Exit() 関数から StateManager.Current.set を再帰呼び出ししてしまった。もちろん Exit() 関数内で直接再帰呼び出ししたわけじゃなくて、何か別の関数を呼び出して、その関数呼び出しがイベントを発生させて、そのイベントを拾ったハンドラが StateManager.Current に値を代入、みたいな感じ。

以上、お粗末な話でした。