ゲームプログラマのための設計シリーズ:デザインパターン編の記事です。
概要
- 直和の表現には、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と同様な 関数が増える パターンはどうでしょう。
実はまたSOLID原則に違反してしまうことになります。
次回はこちらを取り上げてみます。