結局オブジェクト指向とはいったいなんなのかとか、ゲームの設計に関して最近思うこと

2兆年ぶりにブログ記事を書いている気がする。レンダリングエンジンの開発に飽きたからこのブログはほぼ用なしになってしまった。ただ、最近プライベートでプログラムを書いてなさすぎるので、リハビリに再開するのもいいかもしれない。プログラムを書くのは好きだが、致命的に目的意識や目標となるエンジニア像が存在しないため、具体的なマイルストーンを設置しないと絶望的に怠惰になりがちだ。

しかし、最近転職してから「設計」というものを考える機会が莫大に増え、それに関しての自身のナレッジ、あるいはモヤモヤなどを文章化することで、自分の中で情報を整理しようと思い立った。

私はゲームプログラマーだ。作っているゲームはリリースされてから数年が経ち、ソースコードの量は膨大で、そしてありがたいことに収益が安定しているため、数年後もちゃんと保守できることを前提として設計しなくてはならない。

私のチームのクライアントの開発の要素として大きく上げられるものとしては、オブジェクト指向設計アジャイル開発の2つがある。巷ではどちらもある種のバズワードのような扱われ方をしている。確かにすべてが上手くいく魔法のような開発手法というわけではないことは確かだが、果たして数年続き、大人数で行われる私のチームにとっての開発スタイルとして正しいのだろうか、そういつも考えている。といっても、アジャイル開発に関してはマネジメントの領域なので、その是非については1エンジニアに過ぎない私には判断できない。

オブジェクト指向設計に話をフォーカスしよう。

で、実際のところ、「オブジェクト指向」を言語化して説明することは無理なんじゃないかと思っている。多態性とかカプセル化とかそういう言葉の定義を説明することは可能だが、いざプログラムを見せられた時に、「これはオブジェクト指向で設計されているか?」という質問に対して回答することはできない。オブジェクト指向プログラミング言語で書かれてますね、とは言えるかもしれないけれど。

この時点で、少なくともプログラムを評価したり、あるいは指導する際に「オブジェクト指向設計」という単語を使うことは不適切に感じる。定量化ができず、共通認識も取れない単語なんて地雷にしかならない。

結局、適切なケースにおいて適切なデザインパターンを適用する、適切な抽象化をする、適切なデータ隠蔽をする、そういった「ある局所的なケースにおいて常に適切な」選択を続けていく結果、再利用性や可読性が最強になる桃源郷のようなプログラムが生まれるという仮定のもと、それをオブジェクト指向設計と定義しているんだろう。だから、オブジェクト指向設計を目指すというのは見えてないゴールに向けて適当に走り出すようなもので、実際に私たちがやるべきなのは、もっと「良い設計」とやらを個々の問題に分解して、見えているゴールを目指すことだと思う。その結果、きっと私たちのプログラムはいつかオブジェクト指向設計にたどり着くんじゃないんかな。知らんけど。

設計に関する個々の問題についてなら、私もそれなりに思想がある。
以下に、私が経験則的に上手くいった設計方針の備忘録みたいなものをまとめる。もちろん個人的な思想なのでその辺は留意してほしい。


...思いついたら書き足す。

クラスは常にミニマムに設計する

モジュールは細かく要素分解されているべきだ。もちろん、一連の手続きをラップする上位的なインターフェースはあってもいいけれど、内部の構造としてはミニマムなクラスや関数の組み合わせによって実現されていてほしい。

例えば、AssetManagerというクラスを考えてみる。もうだめだ。作った時はいいけれど、担当者が変わるたびにインターフェイスが増えて、メンバ変数が増えて、そしていつか神にへと昇華するだろう。最初に設計した人はこのManagerクラスは美しいほどによくできたクラスだったのかもしれない。でも担当者が変わった瞬間に、この「Manager」という単語を都合よくとらえて、好き勝手に機能を継ぎ足ししてしまうに違いない。

「このクラスはこの仕事をします!」という設計者の意図を正しく伝えるためには、クラス名を適切にするしかない。このAssetManagerも適切な名前にすべきだ。といっても、リネームでは解決しない。クラスがミニマムではないから。

このManagerクラスがゲームで頻繁に要求される要件をすべて含んでいるとして、それらはAssetDownloder, AssetDependencyResolver, AssetCachePoolといった個々のクラスに分離できるはずだ。もちろん無理にクラスにしなくてもいい。ただの手続きであるのならば、関数でもいい。大事なのは、全ての部分問題を独立させて、適切な名前を割り当てることだ。そうすれば、後任者も機能を追加するとき、「え!? そこにコード加えるの!?」みたいなトンデモ改修をすることはなくなることが期待できる。

できるだけ副作用のないconst属性のインターフェイスを採用する

処理を記述するときは、結果を返り値で返して、内部の状態には変更を加えないようなメンバ関数を中心に定義すべきだ。
どうしてか? それは、ユニットテストが書きやすいから。これに尽きる。特に、処理に必要な情報を引数で受け取るように記述されていれば、もうテストが書くのが楽すぎて最高になる。テストの書きやすさは品質に直結する。そして、テストフレンドリーに設計されたクラスは、自然と「命名が適切で」「個々の関数の役割が明確な」クラス/関数になる。信じてもらえないかもしれないけど、経験則的にそうなると思っている。

class Hoge {
  void setData(Data data);
  void calculate();
  int getResult() const;
};

よりも、

class Hoge {
  int calculate(Data data) const;
};

のほうがいいよねって話だ。もちろん、こんな分かりやすいヘンテコなコードはめったに見ないが、本質的に同じコードはよく見かける。大事なのは「ユニットテスト」の「ユニット」とは、「一つの関数」であるべきということ。前者のコードの場合、このcalculate関数のテストを記述するためには3つの関数を実行する必要がある。それはユニットではない。

これは愚痴に近い雑談だが、テスト駆動開発はある種の理想だけど、ゲームではそれが難しいことも多い。ビューが根深く絡むし、この分野は仕様変更がかなり多い傾向にあって、仕様変更の影響で膨大な量のユニットテストを修正していると本当に虚無の感情となる。

セッター関数を減らす

セッター関数はあればあるほど設計者も使用者も混乱する。複数のプロパティの整合性、あるいは単なるデータのセットし忘れなど、落とし穴を生みがちだ。オブジェクトとして十分に振る舞うために必要なデータは全てコンストラクターや生成関数で受け取って、それ以降は変更を加えないべき。もしくは、本質的にはSetterでも、setという命名ではなく、そのデータの格納によってどのようにオブジェクトが振舞うのかを明示した関数名を定義する。簡単な例だと、setStateよりもchangeStateの方がインターフェイスとして明瞭ということ。

1つのクラスでフラグ変数を2つ以上定義しない

フラグ複数持つのは典型的なアンチパターンだ。(2^フラグの数)だけ状態が生まれるから。そして、必ずそれは設計者の想定の範疇を超えてバグを生み出す。
例を挙げてみよう。isTouchable、isVisibleのような二つのフラグを持つとする。この時、「タッチ可能で見える状態」、「タッチ可能だが見えない状態」、「タッチ不可能だが見える状態」、「タッチ不可能でかつ見えない状態」の4つの状態をこのオブジェクトは持ちうる。だが、多くの場合、この4つの状態全ての要件を満たす必要性があることはとても少ない。設計者も、暗黙的に「isVisibleがfalseの時は必ずisTouchableもfalseになるようにしよう」という思想で設計しているだろう。だが、「想定しない状態になりうる」それだけでバグを生み出すオブジェクトの脆弱性になる。見えないけどタッチできてしまうとかありそーなバグだ。設計者は完全に想定された状態を明示的に定義しよう。この場合、enumなどで Active(タッチもできるし見える), Deactive(タッチはできないが見える), Invisible(タッチもできないし見えない) のようなステートを定義するのが懸命だ。すると、件の起こりそうなバグは未然に防がれる。

if文はテスト可能な関数に集約し、ビュー側では書かない

これも突き詰めるとテストカバレッジの問題になってくるが、提言しておく。前提として、ビューのコードはテスト困難だ。というか、ビューのテストを実現できているゲームプロジェクトがあるのならばそのナレッジを共有して欲しい。まじでなんでもします。靴も舐めます。
さて、if文があるということは、その関数は毎回同じ動作をしないということだ。それは、つまりバグを生み出す。同じことばっかり言ってる気がするが、プログラマは複雑なロジックを書いた時必ずバグを出す。そしてそれを後任者が改修した時もっと顕著に現れる。それをどう防ぐか? ユニットテストだ。

幸い、ビューが絡まないロジックの場合はテストを書くことは困難ではない。if文があっても、カバレッジが100%になるようにテストを書こう。(もちろんカバレッジが100%だからといってテストケースが十分である保証は全くない)

例えばこんなコードがあるとする。

void initView() {
  /* ... */
  int64_t remainTimeSec = _model->getRemainTimeSec();
  if (remainTimeSec >= SECOND_PER_DAY) {
    _text->setString(format("残り%d日", remainTimeSec / SECOND_PER_DAY));
  } else if (remainTimeSec >= SECOND_PER_DAY) {
    _text->setString(format("残り%d時間", remainTimeSec / SECOND_PER_HOUR));
  } else {
    _text->setString(format("残り%d時間", remainTimeSec / SECOND_PER_MINUTE));
  }
 /* .. */
}

別に何が悪いというわけではない。ただ、ロジックがあるのに、この関数はビューにアクセスしているためテストできない。それは、不健全だ。

std::string formatRemainTimeText(int64_t remainTimeSec) {
  if (remainTimeSec >= SECOND_PER_DAY) {
    return format("残り%d日", remainTimeSec / SECOND_PER_DAY);
  } else if (remainTimeSec >= SECOND_PER_DAY) {
    return format("残り%d時間", remainTimeSec / SECOND_PER_HOUR);
  } else {
    return format("残り%d時間", remainTimeSec / SECOND_PER_MINUTE);
  }
}


void initView() {
  /* .. */

  int64_t remainTimeSec = _model->getRemainTimeSec();
  _test->setString(formatRemainTimeText(remainTimeSec));

 /* .. */
}


これならば、ビューの初期化コードは常に一貫性のある動作をし、そしてロジックはテスト可能なビューが絡まない関数に集約されているため、品質が保証できる。

protectedメンバー変数は邪悪

クラスというものは、インターフェイスを通じてやり取りするもの。だからこそ、オブジェクトはブラックボックスとして振舞うことができる。公開しているインターフェイスは、「想定された操作」だからだ。さて、ここで継承クラスについて考えよう。これはあんまり浸透している感じがしないのだが、継承クラスと基底クラスは 赤の他人 なのだ。もう少し具体的に言うと、基底クラスは、継承クラスが作られ、どのように拡張されるかを想定して設計することは困難ということ。たいていの場合、overrideやprotectedメンバー変数を悪用すると、基底クラスが「想定された操作」の範疇を超えて継承クラスは設計されてしまう。その状態になると、基底クラスの内部実装をリファクタリングしたりするだけで継承クラスの振る舞いが変わってしまい、不具合を引き起こす。他にも、ある基底クラスから派生したAとBという2つの継承クラスがあるときに、基底クラスはAとBの共通部分であることが期待できるが、本質的にはそうなっておらず、共通機能を実装するときに基底クラスを弄るだけで解決しないことがある。

これらを回避するために一番大事なことは、protectedメンバー変数を定義しないこと。継承先で自由にアクセスできる変数を基底クラスに置くことは、基底クラスが一切カプセル化されていないのと同義だ。たとえ継承先のクラスであっても、全てメンバー関数を通してならばある程度制御が効く。(ここで継承クラスのためだけのGetterとSetterを無理やり定義してしまうと、本質的な問題が解決しないので注意すること)

共通の要件を持つ複数のクラスを設計するとき、継承ではなく内含で設計する

これは前の項の「protectedメンバー変数は邪悪」の話の延長戦上にある。そもそも、継承で「共通の要件」を括りだすことは、保守的な面で非常に困難だ。特に、仮想関数を持ったりしている時点で、基底クラスがそれらの共通要件であり続けることはとても困難である。何度も言うが、将来的に第三者の手で改修されることを常に想定すべきだ。基底クラスで解決するという選択も、作りきりのプログラムではうまくいくかもしれないけれど、保守や改修が必ず困難になる。

内含は、共通要件である機能を完全なカプセルオブジェクトとして設計できる。設計者は、publicインターフェイスがどのように呼ばれるかだけ考慮すればよい。これは、仮想関数を持つ基底クラスを設計することよりも非常に簡単だ。

私は、動的ポリモーフィズムを用いて多態的に振舞わせたいという要件においてのみ、継承を使うべきだと思っている。