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?

ゲームプログラマのための設計:Adapterパターンと、その先のExternal Polymorphismパターン

Last updated at Posted at 2024-11-20

ゲームプログラマのための設計シリーズ:デザインパターン編の記事です。

概要

  • 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パターンを用います。

Adapterパターン改
// 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の型しかありません。テンプレートにしてしまいましょう。

External Polymorphismパターン
// 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++における抽象化の手段比較もご参照ください。

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?