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?

ゲームプログラマのための設計:NVI

Posted at

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

概要

  • NVI(Non-Virtual Interface)は、publicメンバ関数を仮想関数にしないというスタイル
  • 後から事前・事後処理を挟みたくなったり、引数を変えたくなった時に利用
  • 多vs多の状況を、中間層を立てて整理するパターンといえる

本文

とある基底クラスを用意しました。

class Base
{
public:
	virtual ~Base() = default;
	virtual void func() = 0;
};

チームメンバーが活用してくれて、派生クラスをどんどん生み出しています。

class DerivedA final : public Base
{
public:
	void func() override { /* いろいろ */ }
};

class DerivedB final : public Base
{
public:
	void func() override { /* あれこれ */ }
};

// 以下たくさん...

Baseを使ったコードも増えてきました。

void userA(Base& b)
{
    b.func();
}

void userB(Base& b)
{
    b.func();
}

// 以下たくさん...

さて、時がたったある日、Base::funcに事前・事後処理を追加したくなりました。

class Base
{
public:
	virtual ~Base() = default;
	virtual void func() = 0;

+	void preProcess();   // funcの「前」に呼び出したい
+	void postProcess();  // funcの「後」に呼び出したい
};

各派生クラスに一つ一つ呼び出しを追加するのはやってられませんね。
下のような関数を用意することも考えられますが、これから新たにInterfaceを利用する人が気づいてくれるかわからないのでイマイチです。。。preProcess/postProcessもできればprivateに隠したいところです。

void callFunc(Base& i)
{
	i.preProcess();  // 事前処理
	i.func();        // func呼び出し
	i.postProcess(); // 事後処理
}

NVI

そこで、Baseクラスを以下のように修正します。

class Base
{
public:
	virtual ~Base() = default;
	void func()
	{
+		preProcess_();
+		doFunc_();
+		postProcess_();
	}

private:
+	virtual void doFunc_() = 0;  // 派生クラスはfuncではなく、この関数をoverrideする

	void preProcess_();
	void postProcess_();
};

publicメンバ関数であるBase::funcが仮想関数ではなくなりました。これがNVI(Non-Virtual Interface)イディオムです。

これで、

  • Baseの利用者が、preProcess/postProcessを呼び忘れるといったことはない
  • 後から事前処理・事後処理を追加しても、派生クラスやBaseの利用者に影響がない(少なくとも文法上は。リスコフの置換原則に反した処理を勝手に追加したら壊れます)

という良い性質が達成できました。

publicな仮想関数は多vs多の状況を生み出す

それにしても、なぜpublicメンバ関数を仮想関数にするとこのような面倒なことになるのでしょうか?

NVIにする前のコード
class Base
{
public:
	virtual ~Base() = default;
	virtual void func() = 0;
};

何をいまさらという感じですが、利用側としてはBase&を介したfuncの呼び出しはDerivedA::funcかもしれないし、DerivedB::funcかもしれないわけです。

void userA(Base& b)
{
	b.func();

	/* これは、
	* bがDerivedAだったら、DerivedA::funcを呼ぶ
	* bがDerivedBだったら、DerivedB::funcを呼ぶ
	* ...
	* と書いてあるということ
	*/
}

依存関係としては派生クラスDerivedAたちの存在は隠蔽されていますが

  • 関数のシグネチャ
  • 実際の処理の流れ

の2点においては派生クラスのfuncすべてと結合していることになります。

既存コードの修正
派生クラスの追加 不要
関数のシグネチャ変更 必要
共通処理の追加 必要

このような多vs多の関係は、設計の観点では避けたいものです。一か所の変更が非常に多くの部位に波及するからです。
NVIを導入することによって、図のように1vs多の関係2つに分断することができました。(矢印の数が減っていることに注目。ここでは処理の流れのみ図示)

多vs多の構造に対して、中間層を設けることで解消することができます。例えばGoFのデザインパターンでは、MediatorやFacadeパターンが同様に中間層を立てて多vs多の状況を解消していますね。

いつでもNVIにしておけばいいの?

筆者の意見としては、すべてのインターフェースをNVI形式で書いておくのはやりすぎかな。。。という感覚です。

// すべての関数をNVI方式に
// C++erにとってはこれをインターフェースと呼ぶのかどうかについても議論がありそう。少なくともJavaやC#におけるInterfaceではない
class Interface
{
public:
    virtual Interface() = default;
    void funcA(){ doFuncA_(); }
    void funcB(){ doFuncB_(); }

private:
    virtual void doFuncA_() = 0;
    virtual void doFuncB_() = 0;
}

もともとインターフェースとして設計したクラスには事前・事後処理を追加したいケースがあまりなかった経験からそう考えているのだと思います。

一方、基底クラスがライブラリ側にあり容易には派生先のコードを変更できないようなケースでは、あらかじめNVIにしておくのが無難でしょう。

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?