ゲームプログラマのための設計シリーズ:デザインパターン編の記事です。
概要
- 継承の危険な力
- 継承の避け方
- 結局、Interface継承ならOKという結論
本文
継承は危険である、避けるべきという認識が広まって久しいです。GoやRustなどC++より後発の言語でありながら、継承の機能を持つクラスをあえて持たせないようにしているものもあります。
そんな中、C++ではどのように継承に付き合うべきなのか議論してみたいと思います。
継承の強すぎる危険な力
特に問題とされるのがpublic継承です。
// 基底型
class Base
{
public:
virtual ~Base() = default;
void publicFunc();
protected:
void protectedFunc();
private:
virtual void virtualFunc();
};
// 派生型
class Derived : public Base
{
private:
virtual void virtualFunc() override; // こちらのvirtualはなくても同じ
};
- Baseクラスのすべてのメンバーを、Derivedクラスも持つ(危険度★)
- Baseクラスのprotectedメンバは、Derivedクラスのメンバ関数からアクセス可能(危険度★★★)
- Derivedクラスへの参照は、Baseクラスへの参照にキャストできる(危険度★)
- Baseクラスの仮想関数の処理を、オーバーライドで上書きできる(危険度★★)
これらの動作が簡単かつすべて同時に起こせてしまうのが、継承の強力であり危険である点でしょう。
さらに、
- デストラクタにvirtualを付けなければならない
- コンストラクタやデストラクタで仮想関数を呼んではいけない
- 仮想関数のオーバーライドではリスコフの置換原則を守らなければならない
- 非参照な基底型にキャストしてはならない(スライシングを避ける)
といった追加のお約束をきちんと守らないと、意図しないバグや未定義動作に突っ込んだりするのも困った点です。自分が間違いなく作法を守れる人間であっても、そうではないかもしれない他人が触る可能性があるならば継承を避けるに十分な理由となるでしょう。
避け方
継承の機能のうち、危険なものから順番に取り除いていきましょう。
無事最後まで到達できたら、そのコードは無事に継承から卒業できることになります!
①protectedメンバ変数をやめる(絶対)
protected変数は、すべての派生先のメンバ関数からアクセスすることができる都合ほとんどpublicと変わらない公開範囲を持つといえます。
public変数を避けるのと同じ要領で、せめてprotected関数から操作するようにカプセル化しましょう。
②実装を持つ仮想関数をやめる(絶対)
仮想関数の中身は、派生型の実装次第で上書きされたりされなかったりします。コードを読むうえで「この処理は呼ばれないかもしれない」と思って見ていくのはかなりキツいです。また、実装を上書きしてしまうとリスコフの置換原則も守るのが困難になります。
基底側の仮想関数の実装は空にして、派生先でオーバーライドした場合にはこれ以上上書きされないようfinalをつけましょう。
// 基底型
class Base
{
public:
void publicFunc()
{
// 基底型のvirtualFuncを空にできるように前後に処理を移動
// 前処理
virtualFunc();
// 後処理
}
private:
// 仮想関数
virtual void virtualFunc() {}
};
// 派生型
class Derived : public Base
{
private:
void virtualFunc() override final
{
// 具体的な処理を書いたらfinalをつけて上書きを防ぐ
}
};
※純粋仮想関数をやめる、と書いていないのはC++では純粋仮想関数であっても実装を書くことができるからです。
// 基底型
class Base
{
virtual void virtualFunc() = 0
{
// 純粋仮想関数でも一応デフォルト実装を書くことができる
}
};
※余談も余談ですが、昔に純粋仮想関数の定義は宣言と同時に書くことができないと見たことがあるのですが今手元のVS2022ではコンパイルが通りました。流石にこのために規格を調べに行く気にはならないのですが、これが規格が変わったためなのかコンパイラ依存なのかご存じの方いらっしゃればコメントいただけると嬉しいです。
③実装を持たない仮想関数も避ける(できれば)
仮想関数が一つなら、高階関数で代替可能です。
// もはや派生は不要
class Base
{
public:
explicit Base(std::function<void()> func) : mFunc(func) { /* mFuncが空でないかチェック */}
void publicFunc()
{
mFunc();
}
private:
std::function<void()> mFunc;
};
別に仮想関数が複数あったら複数std::functionなどを持てばいい話なのですが、抽象化の観点からひとまとめにしておきたいというニーズはあり得ます。また、std::functionに持たせる状態が多い場合にどうしようという観点もあります。
この部分の判断についてはC++における抽象化の手段比較/高階関数をご参照ください。
仮想関数のオーバーライド先の処理で基底クラスの処理を呼ぶようにしたかったんだ!という場合でも、std::functionなどの引数から渡してやるようにすれば対応可能です。
④基底型ポインタでの寿命管理をやめる(もしやってたら)
基底型へのポインタ経由で、異なる型の寿命管理を行うことが稀にあります。
// Base派生のクラスの寿命を管理してくれるクラス
class Store
{
using BasePtr = std::shared_ptr<Base>;
public:
void add(BasePtr base) { mBases.push_back(base); }
private:
std::vector<BasePtr> mBases;
};
int func()
{
Store store;
// 異なる型の寿命管理をStoreに任せる
store.add(std::make_shared<Derived>());
store.add(std::make_shared<Derived2>());
}
そもそもこういうことをやっているケースは珍しいと思いますが、std::shared_ptr<void>やstd::anyで継承を使わない形への代替が可能です。
⑤移譲へ
ここまでくると、継承を使う必要はもはやありません。
もともと継承していたクラスを、privateメンバに移動させましょう。
// 元:派生型
class Derived
{
private:
Base mBase; // 元:基底型
};
元:基底型にあったprotected関数は、すべてpublic関数に変更してOKです。
結局のところ、Interface継承ならOK
実質的にほとんどのケースで③が継承を使うべきか否かの判断が分かれるポイントになると思います。①②を守ると、基底クラスは実装を持たない(他言語でいうInterface)か、Template Methodパターンになるでしょう。
// 処理の流れを基底クラスで作り、具体的な処理を派生先で実装してもらう = Template Methodパターン
class TemplateMethodPattern
{
public:
virtual ~TemplateMethodPattern() = default;
void process()
{
preProcess();
// 何か処理
mainProcess();
// 何か処理
postProcess();
}
private:
virtual void preProcess() {}
virtual void mainProcess() {}
virtual void postProcess() {}
};
そして、Template Methodパターンは仮想関数のみ別のクラスに切り出すことで基底クラスをInterface化することができます。
// 実装を一切持たないInterface
class IProcess
{
public:
virtual ~IProcess() = default;
virtual void preProcess() {}
virtual void mainProcess() {}
virtual void postProcess() {}
};
// Processの処理はコンストラクタで外から受け取る = Strategyパターン
class StrategyPattern
{
public:
explicit StrategyPattern(IProcess& proc) : mProcess(proc) {}
void process()
{
mProcess.preProcess();
// 何か処理
mProcess.mainProcess();
// 何か処理
mProcess.postProcess();
}
private:
IProcess& mProcess;
};
ということで結局、継承の利用が許されるケースはInterfaceの継承に限る、という一般的な結論にたどり着くのでした。