ゲームプログラマのための設計シリーズ:デザインパターン編の記事です。
概要
- Adapterパターンを汎用化していくと、External Polymorphismパターンにたどり着く
- それなりに複雑なので、型の追加が頻繁なときのみ使おう
本文
チームメイトが、IFighterインターフェースを使った戦闘システムを作ってくれました!
class IFighter
{
public:
virtual ~IFighter() = default;
virtual void attack() = 0; // 攻撃
virtual void defence() = 0; // 防御
};
void fight(IFighter& fighter)
{
fighter.attack();
fighter.defence();
}
ここに、IFighterとは関係ないPlayer/Enemyクラスがあります。
class Player {};
class Enemy {};
// 攻撃時の処理
void PlayerAttack(Player&) { Print("Player's Attack"); }
void EnemyAttack(Enemy&) { Print("Enemy's Attack"); }
// 防御時の処理
void PlayerDefence(Player&){ Print("Player's Defence"); }
void EnemyDefence(Enemy&) { Print("Enemy's Defence"); }
これらをfight関数に適用するにはどうしたらよいでしょうか。
Adapterパターン
まずはGofのAdapterパターンです。
実装方法はいくつか考えられますが、今回はコンポジションを使います。
class PlayerAdapter final : public IFighter
{
public:
// Playerと同様の引数で構築できるようにする
template<typename... TArgs>
explicit PlayerAdapter(TArgs&&... args)
: mPlayer(std::forward<TArgs>(args)...)
{}
void attack() override { PlayerAttack(mPlayer); }
void defence() override { PlayerDefence(mPlayer); }
private:
Player mPlayer;
};
class EnemyAdapter final : public IFighter
{
public:
template<typename... TArgs>
explicit EnemyAdapter(TArgs&&... args)
: mEnemy(std::forward<TArgs>(args)...)
{}
void attack() override { EnemyAttack(mEnemy); }
void defence() override { EnemyDefence(mEnemy); }
private:
Enemy mEnemy;
};
void test()
{
PlayerAdapter player{};
EnemyAdapter enemy{};
fight(player); // Player's Attackなどと出力される
fight(enemy); // Enemy's Attackなどと出力される
}
さてここで第三のFighter、Animal君を追加しようとどうなるでしょうか?
// 型と処理が追加された
class Animal {};
void AnimalAttack(Animal&) { Print("Animal's Attack"); }
void AnimalDefence(Animal&){ Print("Animal's Defence"); }
+ // Adapterクラスも用意
+ class AnimalAdapter final : public IFighter
+ {
+ public:
+ template<typename... TArgs>
+ explicit AnimalAdapter(TArgs&&... args)
+ : mAnimal(std::forward<TArgs>(args)...)
+ {}
+
+ void attack() override { AnimalAttack(mAnimal); }
+ void defence() override { AnimalDefence(mAnimal); }
+ private:
+ Animal mAnimal;
+ };
このくらいのコードが追加されます。
本質的には、
- attackをAnimalAttackにバイパス
- defenceをAnimalDefenceにバイパス
したいだけなのですが、それ以外の定型コードが多いです。
今度は、Playerに異なる攻撃方法を使わせたい場合はどうなるでしょう?
// 新たな処理を追加
void PlayerAttack2(Player&) { Print("Player's Attack2"); }
+ class PlayerAdapter2 final : public IFighter
+ {
+ public:
+ template<typename... TArgs>
+ explicit PlayerAdapter2(TArgs&&... args)
+ : mPlayer(std::forward<TArgs>(args)...)
+ {}
+
+ void attack() override { PlayerAttack2(mPlayer); }
+ void defence() override { PlayerDefence(mPlayer); }
+ private:
+ Player mPlayer;
+ };
やっぱり、やりたいことに関係ないコードが多いですね。
PlayerAdapterが、
- Playerという具象クラスを、IFighterインターフェースに適合させる
- attackにはPlayerAttack関数を、defenceにはPlayerDefence関数を用いる
という風にいろんな知識を抱え込んでいるため、拡張が大変なのでした。
ということで、知識を分割して外に出してみましょう。
Adapterパターン改
まずは、「attackにはPlayerAttack関数を、defenceにはPlayerDefence関数を用いる」という知識をPlayerAdapterから追い出しましょう。
ここでは静的Strategyパターンを用います。
// attack/defenceに何の処理を用いるかという知識を、TFightStrategyに委譲
template<typename TFightStrategy>
class PlayerAdapterRevised final : public IFighter
{
public:
template<typename... TArgs>
explicit PlayerAdapterRevised( TFightStrategy&& s, TArgs&&... args)
: mPlayer(std::forward<TArgs>(args)...)
, mStrategy(std::forward<TFightStrategy>(s))
{}
void attack() override { mStrategy.attack(mPlayer); }
void defence() override { mStrategy.defence(mPlayer); }
private:
Player mPlayer;
TFightStrategy mStrategy;
};
// TFightStrategyに与えるクラス
// Playerについて、attackにはPlayerAttack関数を、defenceにはPlayerDefence関数を用いるという知識を司る
class PlayerFightStrategy
{
public:
void attack(Player& p) { PlayerAttack(p); }
void defence(Player& p) { PlayerDefence(p); }
};
void test()
{
// 推論されてPlayerAdapterRevised<PlayerFightStrategy>型になる
PlayerAdapterRevised playerrev{ PlayerFightStrategy{} };
fight(playerrev);
}
※PlayerAdapterRevisedの初期化ではコンストラクタ引数の推論を用いています。
これで、PlayerAttack2を使いたい場合はPlayerAttackStrategyと同様のクラスを増やすだけになりました。
External Polymorphismパターン
さて、PlayerAdapterRevisedと同様にEnemyAdapterRevisedも作ろうと思いますが、よく見ると変えるべき部分はPlayer->Enemyの型しかありません。テンプレートにしてしまいましょう。
// PlayerやEnemyが入る部分をTObjテンプレート引数に
// アダプタとして最低限の責務だけが残された
template<typename TObj, typename TFightStrategy>
class FighterAdapter final : public IFighter
{
public:
explicit FighterAdapter(TObj&& obj, TFightStrategy&& strategy)
: mObj(std::forward<TObj>(obj))
, mStrategy(std::forward<TFightStrategy>(strategy))
{}
void attack() override { mStrategy.attack(mObj); }
void defence() override { mStrategy.defence(mObj); }
private:
TObj mObj;
TFightStrategy mStrategy;
};
// TFightStrategyに与えるクラス
// Playerの分とEnemyの分をまとめたが、別に他のクラスに分けても問題ない
class FightStrategy
{
public:
void attack(Player& p) { PlayerAttack(p); }
void defence(Player& p) { PlayerDefence(p); }
void attack(Enemy& e) { EnemyAttack(e); }
void defence(Enemy& e){ EnemyDefence(e); }
};
void test()
{
FighterAdapter player{ Player{}, FightStrategy{} }; // FighterAdapter<Player,FightStrategy>に推論
FighterAdapter enemy { Enemy{}, FightStrategy{} }; // FighterAdapter<Enemy,FightStrategy>に推論
fight(player);
fight(enemy);
}
もはや、AdapterクラスはPlayer/Enemyといった具象クラスの知識も、attack/defenceに対してどの関数を適用するかという知識も持たなくなりました。つまり、これらの拡張に対して全く無縁でいられる=これ以上手をいれる必要がなくなりました!
Adapterパターンの時にやったように、新たな型Animalを追加してみます。
// 型と処理が追加された
class Animal {};
void AnimalAttack(Animal&) { Print("Animal's Attack"); }
void AnimalDefence(Animal&){ Print("Animal's Defence"); }
// Playerの分とEnemyの分とAnimalの分をまとめたが、別に他のクラスに分けても問題ない
class FightStrategy
{
public:
void attack(Player& p) { PlayerAttack(p); }
void defence(Player& p) { PlayerDefence(p); }
void attack(Enemy& e) { EnemyAttack(e); }
void defence(Enemy& e){ EnemyDefence(e); }
+ void attack(Animal& a) { AnimalAttack(a); }
+ void defence(Animal& a){ AnimalDefence(a); }
};
コードの修正はこれだけです。さらに言えばAnimal向けの処理はFightStrategyではないクラスに書いてもいいので、元のFightStrategyを触らずに追加することもできます。
void test()
{
FighterAdapter animal{ Animal{}, FightStrategy{} };
fight(animal);
}
Playerに異なる攻撃方法を使わせたい場合もやってみましょう。
// 新たな処理を追加
void PlayerAttack2(Player&) { Print("Player's Attack2"); }
+ // 新たなStrategyクラスを追加
+ class FightStrategy2
+ {
+ public:
+ void attack(Player& p) { PlayerAttack2(p); }
+ void defence(Player& p) { PlayerDefence(p); }
+ };
こちらも、もともとのAdapterパターンと比較して本質に関係ないコードの追加量がずいぶんと減りました。
void test()
{
FighterAdapter player2{ Player{}, FightStrategy2{} };
fight(player2); // Player's Attack2などと出力される
}
型・処理の追加それぞれに対してオープンな実装となりましたが、「Adapterパターン」「(静的)Strategyパターン」「テンプレート」といろいろなデザインパターンの集合体なので、なじみのない人には把握に時間がかかるコードでしょう。行き過ぎたDRYであるといわれるかもしれません。型・処理の追加が激しく行われて、通常のAdapterパターンではAdapterクラスのボイラープレートコードが爆増してしまう、というくらいのケースでのみ使用するのが良いかと思います。
※このパターンはC++ソフトウェア設計 ―高品質設計の原則とデザインパターンにて「External Polymorphismパターン」として紹介されています。ただ、解説の導入の流れがしっくりこなかったため、個人的に納得いく導入を書き起こしてみた次第です。書籍によると、
- Adapterパターンは、既存のインターフェースに適合させるためのもの
- External Polymorphismパターンは、既存の一連の型を多態的に扱う目的で抽象化している
という目的の違いがある故別のパターンなのだと主張していますが、あんまり納得いきませんでした。(どっちも結局同じことでは・・・)
余談①:Concept
C++20以上が使えるなら、FighterAdapterのテンプレート引数TFightStrategyにConceptによる制約を付けてあげると少し親切でしょう。
// TStrageyは、TObj&に適用できるattack関数とdefence関数を持つべしという制約
template<typename TStrategy,typename TObj>
concept FightStrategyConcept = requires(TStrategy strategy, TObj& obj)
{
strategy.attack(obj);
strategy.defence(obj);
};
template<typename TObj, FightStrategyConcept<TObj> TFightStrategy>
class FighterAdapter final : public IFighter
{
// 中身は一緒
};
余談②:TFightStrategyの粒度
今回はTFightStrategyがattack/defence関数を持つものとして抽象化しましたが、attack/defenceをそれぞれバラバラに受けつける設計も考えられます。
template<
typename TObj,
std::invocable<TObj&> TAttack,
std::invocable<TObj&> TDefence
>
class FighterAdapter2 final : public IFighter
{
public:
explicit FighterAdapter2(TObj&& obj,TAttack&& attack, TDefence&& defence )
: mObj(std::forward<TObj>(obj))
, mAttack(std::forward<TAttack>(attack))
, mDefence(std::forward<TDefence>(defence))
{}
void attack() override { mAttack(mObj); }
void defence() override { mDefence(mObj); }
private:
TObj mObj;
TAttack mAttack;
TDefence mDefence;
};
void test()
{
// 利用者側で、Attack/Defenceの処理を好きなように組み立てられる
FighterAdapter2 player { Player{},&PlayerAttack, &PlayerDefence };
FighterAdapter2 player2{ Player{},&PlayerAttack2, &PlayerDefence }; // 攻撃だけ違う処理に変えた
fight(player);
fight(player2);
}
柔軟性が増した一方で、TAttack/TDefenceが同じ変数を参照する場合にメモリが無駄になったり、その変数の寿命管理が複雑化したりします。
void test()
{
struct State {} state;
FighterAdapter2 stateful{
Player{},
[&state](Player&) { /* stateとplayer両方使った処理 */ },
[&state](Player&) { /* stateとplayer両方使った処理 */ }
};
// このstatefulが外に持ち出されたとき、state変数の寿命の管理をTAttackとTDefenceどちらがしたらいいかわからないので、shared_ptrなどの機構が必要
// TFightStrategyにまとめていれば、そのクラスだけが管理すればよかった
}
このあたりの議論はC++における抽象化の手段比較もご参照ください。