クラスの「継承」より「合成」がよい理由とは?ゲーム開発におけるコードのフレキシビリティと可読性の向上

コード構造における重要な問題として、複数のクラスを共有する場合に合成と継承のどちらを用いるかという点があります。“has a”の関係と、“is a”の関係と言われる2つの対比です。例えば、“ソファには綿が入っている”と、“ソファは家具である”という違いのようなものです。この例では2つの違いは非常に明白ですが、実際には、“has a”の関係でも“is a”の関係でも意味を成すケースがたくさんあります。ゲームのキャラクターについて、これはコリジョンボックスを持っているかと聞くのと、これは衝突可能なオブジェクトかと聞くような場合です。この2つは全く同じことではありませんが、それぞれが(または両方一緒に)衝突を処理する主構造として用いられ、どちらの方がよいかは必ずしも明白ではありません。私の経験では、直感的には継承の方がよいと思うことも多いのですが、それだと問題がたくさんあって結局は合成の方がよかったというケースが多々あります。

同じ問題が2つのどちらの方法でも解決できるという例を見てみましょう。Awesomenautsにはキャラクターの“物理”を扱う個別のクラスがあります。このクラスは、重力やノックバック、スライディングやジャンプの処理を行います。キャラクターは物理的なオブジェクトですから、CharacterがPhysicsObjectを継承するのは筋が通っていると考えられます。また、Characterが衝突を扱うPhysicsObjectを持っているという言い方もできます。なお、“has a”の代わりに、“uses a”という表現をしてもよいでしょう。

この関係がコードの中ではどのようになるか見てみましょう。下記は非常に簡略化されたものですが、基本的な概念はうまく表されています。

class PhysicsObject
{
 Vector2D position;

 void updatePhysics();
 void applyKnockback(Vector2D force);
 Vector2D getPosition() const;
};

//Using PhysicsObject through inheritance
class CharacterInheritance: PhysicsObject
{
 void update()
 {
  updatePhysics();
 }
};

//Using PhysicsObject through composition
class CharacterComposition
{
 PhysicsObject physicsObject;

 void update()
 {
  physicsObject.updatePhysics();
 }

 void applyKnockback(Vector2D force)
 {
  physicsObject.applyKnockback(force);
 }

 void getPosition() const
 {
  return physicsObject.getPosition();
 }
};

このコードを見て分かるように、CharacterInheritanceの方が短いですね。それにapplyKnockbackやgetPositionのアクセサ関数を追加で書く必要がないので、継承を用いる方が自然なように感じられます。しかし、こういった構造を作成してきた長年の経験から、このような場合は合成を用いる方がより柔軟性があり、バグの心配も減り、継承を用いるよりも理解しやすいということを、私は学びました。

フレキシビリティ

まずフレキシビリティという点から議論しましょう。例えば敵キャラクターとして、電気が通った鎖でつながっている2つのブロックを作るとします。この敵は、鎖に触れた者にダメージを与えます。2つのブロックは別々に動かすことが可能で、この敵と戦う時は位置を考えて遊ぶというプレイの楽しさを生み出します。このオブジェクトを構成する2つのブロックは完全に個別に動き、それぞれ異なる物理的条件を持っています。そのため1つのブロックをノックバックしても、もう片方のブロックには影響ありません。しかしこれは2つのブロックで1つのキャラクターとなっており、体力ゲージもAIも1つ、ミニマップ上の表示も1つですし、2つ一緒にしか存在しません。

合成構造でこれを作る場合、複数のPhysicsObjectを持つキャラクターを作るだけですから、この敵を作るのは非常に簡単でしょう。しかし継承の場合、PhysicsObjectから2回継承を行うことはできませんから、1つのキャラクターとして作成することができません。おそらく何かしらの処理方法で継承を用いて作ることもできるとは思いますが、合成ほど簡単で直感的にはできなくなるでしょう。

もしあなたがこれを読んで、この例がこじつけっぽくてあまり適切ではないと思われたとしたら、あなたはきっとこれまでゲームプレイのよさが全てとされる大きなプロジェクトに参加したことがないのでしょう。ゲームデザイナーは、あなたがこれまでプログラムしてきたようなものに相反するゲームメカニクスを常に追及しています。コード構造でゲームメカニクスを処理できないからといって諦めてしまえば、ゲームの品質に深刻な打撃を与えることになります。というのも、結局のところ、いきつくところは、プレイして面白いかどうかですから(もちろんプロジェクトの予算と期限内に達成でききれば、ですが)。

「Awesomenauts」におけるアップグレードの多様性を見てみてください。私たちのコードではどれくらいが例外処理だったか分かるはずです。デザイナーはこういったアップグレードに対処し、何とかしてコードを変更し、プログラムを動作させなければなりません。ゲームプログラミングで最も重要なゴールは柔軟であることです。柔軟に、今どきのゲームデザイナーが思いついたとっぴな考えも、比較的簡単に付け加えられるような方法でコードを作ることです。ほとんどのケースで、合成は継承よりもずっと柔軟性があります。

可読性

次の議論は継承に対する可読性です。“可読性”のよさがバグ発見の精度につながります。プログラムがどう動作するかをプログラマが実際に理解できていなかったとしたら、起動時に壊してしまう恐れがあります。

Ronimoでは、コーディング基準において見過ごせない規則の1つが、全てのクラスのサイズを500行以下にしておくということです。常に基準値を満たしているかどうかチェックする方法はありませんが、達成ラインは明白です。つまり比較的短いクラスを保っていれば、理解するのも簡単ですし、プログラマは思い描いているクラスの動作と一致させることが可能です。

ゲームにますます多くの特徴が加わったので、時間の経過とともに、私たちのコードは進歩していると言えるでしょう。そしてCharacterやPhysicsObjectのコードは両方とも500行まで増えてきています。さらにPhysicsObjectを利用した2つのクラス、Pickup とProjectileを追加しました。これらもまた500行のコードです。

こういった状況では、継承は、大抵、工程が複雑化します。可能な限りprivateを保たなければならないでしょう。しかし、最終的には、継承はほとんどの場合、virtual 関数とprotected関数を導入します。言い換えれば、PhysicsObjectは下位クラスのCharacter、Pickup、Projectileと複雑に絡み合うことになります。継承されたクラスでは複雑な動きを作り出すために同時に動作し、時間をかけるほど、ますます絡み合うことになるのです。私の経験では、ほぼ毎回といっていいほどこのようになってしまいます。

この現象自体は、それほど大きな問題にはならないかもしれませんが、これらのクラスの任意の1つをきちんと理解するためにも、私たちはこの4つ全てを知っておく必要があります。Projectileに新たな特徴が必要になってPhysicsObjectで何かを再構築しようとすると、CharacterとPickupが巻き込まれることになります。全体の状況を理解するために、プログラマは4つの異なったクラスを考える必要があり、頭の中に2000行のコードを入れてなければいけません。私の経験上、すぐにできないと分かります。コードが多すぎて、いっしょくたになることなく、一度に全てを把握することができません。そうすると可読性が損なわれ、プログラマは見落としをしやすくなり、その結果バグが増えてしまいます。

もちろん合成の構造をとったからといって、このような問題がたちどころに解決されるわけではありません。それでも構造をシンプルで理解しやすく保っておくには役立ちます。合成を用いるとvirtualやprotectedはありませんから、一方のPhysicsObjectとCharacter、他方のProjectileとPickupの間の分離がとても明確になります。これにより、時間が経過してもクラスが絡み合うのを回避し、これらをきちんと分離しておくのが楽になります。理論上は継承を用いても分離できるはずなのですが、私の経験からいって、これを維持するのは相当に難しいことです。でも、合成を用いるとやりやすいのです。継承のクラス構造を用いたコードでは、大きくなりすぎ、理解するのが困難になってしまうケースが多くみられます。

ひし形継承問題

継承と合成の比較論をするとき、よく聞く「ひし形継承問題」を避けては通れません。クラスAがクラスBとCから継承していて、このBとCがクラスDという1つの親から継承しているとき、何が起こるのでしょうか。AはDを多重継承することになり、結果として混乱を招きます。

C++では、このひし形継承問題に対していくつかの解決方法があります。例えば、AがDを2度継承することをただ受け入れる、または、仮想継承を用いて解決する方法もあります。しかしどちらの解決法もいろいろな他の問題を引き起こしたり、バグを増やしたりしますから、ひし形継承問題自体を避けるのが一番いいでしょう。

通常この問題は、最初のゲームプレイのコード構造の設計段階では起きませんが、機能が追加されるに従い、時として現れてくる可能性があります。やっかいなのは、この問題が起きてしまうと多くのリファクタリング作業をしなくてはこれを取り除くのが非常に困難になってしまうということです。

とはいえ、ひし形継承問題は私の12年間のオブジェクト指向プログラミング経験の中でほんの数回しか発生していません。ですから、ひし形継承問題を継承への反論の理由にするのはやや無理があるかもしれません。

(そういえば、ひし形継承問題がなくても、多重継承構造はやや格好が悪くなってしまう傾向があります。この点については私の以前のブログ記事なぜthisは必ずしもthisではないのかで説明しています)

継承を使用するのはいいけれど、多用しない

このように書いていると私が継承を目の敵にしているように思われるかもしれませんが、決してそんなことはありません。継承が有効に使える場面もたくさんあります。例えば、ポリモーフィズムや、リスナやファクトリなどのデザインパターンにおいてはとても便利です。私が言いたいのは、継承が“見かけほどには”便利ではないという点なのです。継承が最もすっきりした解決法だと思っても、実際にやってみると合成のほうがよかったというケースが多々あります。継承を使用してもいいのです。ただ使いすぎないようにしましょう。