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?

ゲームプログラマのための設計:直和の表現にはVariantか継承か

Last updated at Posted at 2024-10-25

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

概要

  • 直和の表現には、std::variantか継承が用いられる
  • 合わせ技でいいとこどりをすることが可能

本文

std::variant

状態が(そう多くない数の)複数の値のうち一つをとる直和である、という状況ではenumを使いますね。

// 装備タイプ
enum class EquipmentType
{
	Sword,  // 剣
	Gun,    // 銃
	Clothes // 服
};

それぞれの状態に応じて追加で変数を持ちたいときはどうしましょうか。

  • 剣には、「攻撃力」と「耐久度」がある
  • 銃には、「残弾数」がある
  • 服には、「色」と「防御力」がある

一つの手としてすべての状態を一つの構造体にぶち込むことはできますが・・・

struct Equipment
{
	EquipmentType mType;
	int mAttack;     // 攻撃力
	int mDurability; // 耐久度
	int mBulletNum;  // 残弾数
	Color mColor;    // 色
	int mDefence;    // 防御力
};
  • EquipmentTypeと対応した状態以外にアクセスしないよう、人間が気を付ける必要がある
  • EquipmentTypeが増える度にメンバがどんどん増えて上の状況がますます悪化する

といった点が気になります。
C++17より導入されたstd::variantを用いると、この状況を素直に表現できます。(この用途では現在unionのメリットがほぼないので割愛)

#include <variant>

struct Sword
{
	int mAttack;     // 攻撃力
	int mDurability; // 耐久度
};
struct Gun
{
	int mBulletNum;  // 残弾数
};
struct Clothes
{
	Color mColor;    // 色
	int mDefence;    // 防御力
};

// これが肝。EquipmentはSword,Gun,Clothesのどれかになる
using Equipment = std::variant<Sword, Gun, Clothes>;

読み書きは以下のような感じです。

void write()
{
	// equipmentの中身にコンストラクタでSwordを格納
	Equipment equipment{Sword{}};

	// 代入で中身をGunに切り替える
	equipment = Gun{};

	// 代入で中身をClothesに切り替える
	equipment = Clothes{};
}

void read(const Equipment& equipment)
{
	// std::get_ifで格納された値を取り出せる
	// 現在格納されている型以外を指定すると、nullptrが帰る
	if (auto* sword = std::get_if<Sword>(&equipment))
	{
		sword->mAttack;
		sword->mDurability;
	}
	else if (auto* gun = std::get_if<Gun>(&equipment))
	{
		gun->mBulletNum;
	}
	else if (auto* clothes = std::get_if<Clothes>(&equipment))
	{
		clothes->mColor;
		clothes->mDefence;
	}

	// これは嫌な予感・・・
}

variantは格納したい最大の型に合わせたサイズを持つので、異なるサイズの型に切り替えてもメモリ確保or開放の必要がないのがゲームプログラミング的にはうれしいところです。(メモリに無駄が生じうるということでもありますが)

しかし、ちょっとread関数から嫌な雰囲気が漂ってきますね。ある程度の経験をお持ちなら、switch地獄、またはdynamic_cast地獄を思い出すでしょう。
これらの地獄たる所以は、SOLID原則の「オープン・クローズドの原則」に違反しているからなのでした。Equipmentに格納される 型が増える度、read関数の中身に処理を付け加えないとならず、これが原則が主張するところの「拡張に対して開いていない」状態です。

継承

「オープン・クローズドの原則」に対するオブジェクト指向言語の回答は、以下のような感じです。

class EquipmentBase
{
public:
	virtual ~EquipmentBase() = default;
	virtual void read() const = 0;
};

class Sword : public EquipmentBase
{
public:
	virtual void read() const override
	{
		mAttack;
		mDurability;
	}
private:
	int mAttack;     // 攻撃力
	int mDurability; // 耐久度
};
class Gun : public EquipmentBase
{
public:
	virtual void read() const override
	{
		mBulletNum;
	}
private:
	int mBulletNum;  // 残弾数
};
class Clothes : public EquipmentBase
{
public:
	virtual void read() const override
	{
		mColor;
		mDefence;
	}
private:
	Color mColor;    // 色
	int mDefence;    // 防御力
};

読み書きは以下のような感じです。

void write()
{
    // 基底のポインタで取りまわす
	std::unique_ptr<EquipmentBase> equipment = std::make_unique<Sword>();

	equipment = std::make_unique<Gun>();
	equipment = std::make_unique<Clothes>();
}

void read(const EquipmentBase& equipment)
{
    // 分岐を動的ディスパッチに任せた
	equipment.read();
}

型を追加する度に、read関数の中身をいじる必要がなくなりました。
一方、equipmentの切り替えにメモリ確保が必要になってしまった点はゲームプログラミング的には残念です。

合わせ技

  • variantの、メモリ確保不要な性質
  • 継承の、型の追加に対して開いている性質
    のいいとこどりをしてみましょう。
// EquipmentBaseやSwordクラスなどは継承版と同じ

class Equipment
{
public:
	// コンストラクタと代入演算子はvariantに完全転送
	explicit Equipment() = default;
	template<typename T>
	explicit Equipment(T&& t) : mVariant(std::forward<T>(t)) {}
	template<typename T>
	Equipment& operator=(T&& t) { mVariant = std::forward<T>(t); return *this; }

	void read() const
	{
        // mVariantの現在の値が、ラムダの引数に渡される
		std::visit(
			[](const EquipmentBase& equipment) { equipment.read(); },
			mVariant
		);
	}

private:
	std::variant<Sword, Gun, Clothes> mVariant;
};

void write()
{
	// 型の切り替えはvariantと同様
	Equipment equipment{ Sword{} };
	equipment = Gun{};
	equipment = Clothes{};
}

void read(const Equipment& equipment)
{
	// SwordやGunなど具体的な型にはアクセスさせない
	equipment.read();
}

※実行効率も気になるところです。コンパイラによってはstd::visitが高コストになる場合があるそう(C++ソフトウェア設計より)なので、頻度高く呼び出される場合は注意です。

※今回は各装備品クラスはread関数を持つべしという制約をインターフェースで表現するしましたが、ダックタイピングでも実装上は問題なくパフォーマンスも良いです。

さて、今度はreadと同様な 関数が増える パターンはどうでしょう。その場合はVisitorパターンの出番です。
こちらの記事をご参照ください。

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?