ゲームプログラマのための設計シリーズ:デザインパターン編の記事です。
概要
- 継承を用いたVisitorパターンのデメリット
- variantによるVisitorパターンを検討する
本文
Visitorパターン
Gofのデザインパターンの一つVisitorパターンについての話題です。
継承を用いたVisitorパターンの実装例は以下の通りです。
// 訪問する側の基底
class IVisitor
{
public:
virtual ~IVisitor() = default;
virtual void visit(class Player&) = 0;
virtual void visit(class Enemy&) = 0;
};
// 訪問する側の処理
class ConcreteVisitor : public IVisitor
{
public:
virtual void visit(Player&) override { /* Player訪問時の処理*/ }
virtual void visit(Enemy&) override { /* Enemy訪問時の処理 */ }
};
#include "Visitor.h"
// 訪問される側の基底
class IVisitee
{
public:
virtual ~IVisitee() = default;
virtual void accept(IVisitor&) = 0;
};
// 訪問される側の派生
class Player : public IVisitee
{
public:
virtual void accept(IVisitor& visitor) override final
{ visitor.visit(*this); } // IVisitor::visit(Player&)を呼ぶ
};
class Enemy : public IVisitee
{
public:
virtual void accept(IVisitor& visitor) override final
{ visitor.visit(*this); } // IVisitor::visit(Enemy&)を呼ぶ
};
使い方は以下です。
#include "visitor.h"
#include "visitee.h"
void main()
{
// 配列にPlayerとEnemyを詰める
std::vector<std::unique_ptr<IVisitee>> objs;
objs.emplace_back(new Player{});
objs.emplace_back(new Enemy{});
// 訪問
ConcreteVisitor visitor;
for(auto& obj : objs)
{
obj->accept(visitor); // 対応するConcreteVisitor::visitが呼ばれる
}
}
IVisiteeの派生型に応じて動的に処理が切り替わります。
IVisiteeBaseに仮想関数を追加する(Strategyパターン)ことでも同様のことはできますが、Visitorパターンを採用するメリットとして
- 処理(関数)の追加にIVisiteeとその派生クラスを変更する必要がない。つまり処理の追加に対して、「オープン・クローズドの原則」を満たしている。
- それゆえ、処理の増加に対するIVisiteeやその派生クラスの肥大化や依存関係の増加を防ぐことができる。
という点があります。
逆に、IVisiteeの派生型を増やす場合にはIVisitorとその派生をすべて修正する必要があるので、型の増加に対する「オープン・クローズドの原則」は満たされていません。
型を増やしていきたいのか、処理を増やしていきたいのかでVisitorパターンを採用するかどうか検討することになります。
型の種類が増えそう | Strategyパターン |
処理の種類が増えそう | Visitorパターン |
継承による実装の問題点
まず、二回仮想関数呼び出しが挟まる処理負荷オーバーヘッドの懸念が挙げられます。これが気になるかは使い所次第ではありますが、それより問題なのがクラスの依存関係に循環があることです。
- Playerの他にもEnemyのようなIVisitee継承を追加したら、IVisitorも変更が必要
- 違う種類のIVisitor(visit関数の戻り値が異なるなど)を追加したら、IVisiteeとその派生の変更が必要
という具合に、循環内のどのクラスを変更してもそれに他のクラスが引っ張られてしまいます。
また、そもそもPlayerやEnemyクラスに手を加えられない状況ではこの実装は採用できません。
欠点まとめ:
- 仮想関数二回分の処理負荷
- 循環したクラス間依存
- 訪問される側のクラスに実装を追加する必要がある
variantによる実装
std::variantを用いることでもVisitorパターンを実装できます。
// 訪問する側 こちらも継承が不要に
class Visitor
{
public:
void operator()(class Player&) {/* Player訪問時の処理*/ }
void operator()(class Enemy&) {/* Enemy訪問時の処理*/ }
};
// 訪問される側 継承やVisitorへの依存が不要になった
class Player {};
class Enemy {};
使い方は以下です。
#include "visitor.h"
#include "visitee.h"
void main()
{
// 訪問される型すべてを含むvariant
using Visitee = std::variant<Player, Enemy>;
// 配列にPlayerとEnemyを詰める
std::vector<Visitee> objs;
objs.emplace_back(Player{});
objs.emplace_back(Enemy{});
// 訪問
Visitor visitor;
for (auto& obj : objs)
{
std::visit(visitor, obj); // 対応するVisitor::operator()が呼ばれる
}
}
コード、UMLともにずいぶんシンプルになりました!
循環する依存関係が解消されたので、新しいVisitor(返り値が違う)を追加してもPlayer側が影響されることはないですし、そもそもPlayerクラスに触らずともVisitorパターンを実現することができるようになりました。
variant版にも弱点が無いわけではないですが、ほとんど回避可能なので利用可能ならこちらがオススメです。
弱点と回避策の例:
- variantのインスタンスサイズは格納できる最大サイズの要素に引っ張られるためメモリが無駄になる→ポインタ型にして対策
- variantを持ちまわるすべての経路でPlayer/Enemyなどのincludeが必要→上記同様ポインタ型にしてもいいが前方宣言が煩雑なので、ラッパーを用意する
// variantのラッパークラス visit不要のコードではこれの前方宣言で済ませられる
using Base = std::variant<Player, Enemy>; // エイリアスに対しては前方宣言できないので、クラスでくるむ必要がある
class Visitee : public Base
{
public:
using Base::Base; // コンストラクタを基底から引き継ぐ
};
ちなみに継承版で発生する仮想関数2回分の呼び出しと、std::visitの呼び出しどちらが重いかという点については、ご利用の環境で測定してください。(手元のVS2022では、Debugビルドでは継承版の方が軽量でしたが、Releaseビルドではvariant版が逆転するという結果になりました。)