C++でクラスを継承して振る舞いを切り替える仕組みを、初心者向けに丁寧に解説します。
キーワードはポリモーフィズム(多態性)とvirtual関数です。
同じインターフェースで異なる動作を実行できるようになると、コードの再利用性や保守性が大きく向上します。
基礎から具体例まで、動作と理由を確認しながら身につけましょう。
C++のポリモーフィズムとは?基本の考え方
同じインターフェースで動作を切り替える
ポリモーフィズムとは、同じメンバ関数の呼び出しが、実体となるオブジェクトの型に応じて適切な実装に切り替わる性質のことです。
C++ではvirtual
関数を使うことで実現します。
呼び出し元は共通のインターフェース(基底クラスの型)だけを意識すればよく、具体的な動作は派生クラス側に任せられます。
簡単な例(動物の鳴き声)
#include <iostream>
// 基底クラス。speakをvirtualにすることで動作を切り替え可能にする
class Animal {
public:
virtual void speak() const {
std::cout << "Animal..." << std::endl;
}
};
class Dog : public Animal {
public:
void speak() const /* override 可(今回は基礎のため省略) */ {
std::cout << "Woof!" << std::endl;
}
};
class Cat : public Animal {
public:
void speak() const {
std::cout << "Meow!" << std::endl;
}
};
// 共通のインターフェース(Animal)で受け取り、動作は実体(Dog/Cat)が決める
void makeSpeak(const Animal& a) {
a.speak(); // virtual呼び出し
}
int main() {
Dog d;
Cat c;
makeSpeak(d);
makeSpeak(c);
}
Woof!
Meow!
継承で共通の型をそろえる
ポリモーフィズムは継承関係があることが前提です。
基底クラス(例: Animal
)を共通の型として整え、その上に派生クラス(Dog
やCat
)を積み重ねます。
呼び出し側はAnimal&
やAnimal*
という統一された型で扱えるため、拡張しやすくなります。
C++のポリモーフィズムのメリット(再利用性/保守性)
新しい派生クラスを追加しても既存の呼び出し側を変えずに動作を増やせる点が最大の利点です。
さらに、if/switchの分岐を増やさなくて済むため、分岐の漏れやミスを減らせます。
実装の詳細は派生クラスに閉じ込められ、基底のインターフェースは安定します。
結果としてテストが容易になり、コードの見通しがよくなります。
virtual関数の基礎と使い方
virtual関数の書き方と意味
virtual
をメンバ関数に付けると、動的束縛が有効になります。
これは実行時に実体の型に応じて関数実装を選ぶという意味です。
#include <iostream>
class Base {
public:
virtual void run() const { // virtual: 派生の実装を呼べる
std::cout << "Base::run" << std::endl;
}
};
派生クラスでの上書き(オーバーライド)
派生クラスでは同じシグネチャの関数を定義して上書きできます。
初心者のうちはシグネチャ(戻り値、関数名、引数、constなど)が完全一致しないと別関数扱いになる点に注意してください。
#include <iostream>
class Base {
public:
virtual void run() const {
std::cout << "Base::run" << std::endl;
}
};
class Derived : public Base {
public:
void run() const { // 上書き(オーバーライド)
std::cout << "Derived::run" << std::endl;
}
};
void exec(const Base& b) {
b.run(); // 実体がDerivedならDerived::runが呼ばれる
}
int main() {
Base b;
Derived d;
exec(b);
exec(d);
}
Base::run
Derived::run
参照とポインタで呼び出す理由
ポリモーフィズムは参照(T&
)またはポインタ(T*
)で機能します。
値渡しはオブジェクトをコピーしてしまい、基底部分だけに切り取られる(スライス)ことがあるため、実体を保ったまま呼び出すには参照/ポインタが必要です。
呼び出し形態の違い(概要)
表:
受け取り方 | 例 | ポリモーフィズム | 注意点 |
---|---|---|---|
参照 | void f(Base&) | 有効 | 実体に直接アクセス。安全で推奨 |
ポインタ | void f(Base*) | 有効 | ヌルチェックが必要 |
値 | void f(Base) | 無効 | スライスが起きて基底部分だけになる |
値渡しではポリモーフィズムが効かない
値渡しはスライスが起きるため、派生の実装は呼ばれません。
#include <iostream>
class Base {
public:
virtual void who() const {
std::cout << "Base" << std::endl;
}
};
class Derived : public Base {
public:
void who() const {
std::cout << "Derived" << std::endl;
}
};
// 値渡し(スライスが発生)
void printByValue(Base b) {
b.who(); // Baseとして呼ばれる
}
// 参照渡し(ポリモーフィズムが効く)
void printByRef(const Base& b) {
b.who(); // 実体に応じて呼ばれる
}
int main() {
Derived d;
printByValue(d); // スライス
printByRef(d); // 期待通りDerived
}
Base
Derived
virtualを付ける関数の選び方
派生クラスごとに振る舞いを変えたい関数にだけvirtualを付けます。
コンストラクタには付けられません。
デストラクタは「基底クラスをポインタ経由でdeleteする可能性がある」なら必ずvirtualにします。
APIとして公開する関数のうち、実装差し替えの余地があるものを絞ってvirtualにし、内部実装だけの関数は非virtualにしておくと誤用を減らせます。
抽象クラスと純粋仮想関数
実装を必須にする純粋仮想関数(pure virtual)
= 0
を付けたvirtual関数は純粋仮想関数で、派生クラスに実装を強制します。
基底側で共通のインターフェースを定め、実装は各派生に任せられます。
#include <iostream>
#include <memory>
#include <vector>
class Shape {
public:
virtual ~Shape() = default; // 仮想デストラクタ(概要は後述)
virtual void draw() const = 0; // 純粋仮想関数: 実装必須
};
class Circle : public Shape {
public:
void draw() const {
std::cout << "Draw Circle" << std::endl;
}
};
class Rect : public Shape {
public:
void draw() const {
std::cout << "Draw Rect" << std::endl;
}
};
int main() {
// Shape s; // エラー: 抽象クラスはインスタンス化できない
std::vector<std::unique_ptr<Shape>> shapes;
shapes.emplace_back(std::make_unique<Circle>());
shapes.emplace_back(std::make_unique<Rect>());
for (const auto& sp : shapes) {
sp->draw(); // 実体に応じて描画
}
}
Draw Circle
Draw Rect
インスタンス化できない抽象クラス
純粋仮想関数を1つでも持つクラスは抽象クラスとなり、直接インスタンス化できません。
あくまで共通インターフェースの定義として使い、具体的な動作は派生クラスに実装します。
インターフェース設計の基本パターン
- インターフェース専用基底(純粋仮想のみ): 呼び出し約束だけ定義し、実装はすべて派生へ。
- NVI(Non-Virtual Interface)パターン: 基底に非virtualの公開関数を置き、その中から保護されたvirtual関数を呼ぶことで手順を固定し、拡張点だけ差し替える。
- 最小インターフェース: 必要最小限のvirtualだけ定義し、拡張性と保守性のバランスを取る。
活用例と初心者が押さえる注意点
具体例(Shapeの描画/Playerの再生)で見る使い方
図形描画の例は先ほど示しました。
もう1つ、メディア再生の例を簡潔に示します。
#include <iostream>
#include <memory>
#include <vector>
class Player {
public:
virtual ~Player() = default;
virtual void play(const std::string& file) = 0;
};
class Mp3Player : public Player {
public:
void play(const std::string& file) {
std::cout << "Play MP3: " << file << std::endl;
}
};
class OggPlayer : public Player {
public:
void play(const std::string& file) {
std::cout << "Play OGG: " << file << std::endl;
}
};
void playAll(std::vector<std::unique_ptr<Player>>& ps, const std::string& f) {
for (auto& p : ps) p->play(f);
}
int main() {
std::vector<std::unique_ptr<Player>> ps;
ps.emplace_back(std::make_unique<Mp3Player>());
ps.emplace_back(std::make_unique<OggPlayer>());
playAll(ps, "music.sample");
}
Play MP3: music.sample
Play OGG: music.sample
新しい形式(AACなど)を追加しても、呼び出し側のplayAll
は変更不要です。
if/switchを減らす設計の考え方
ファイル拡張子でif
やswitch
を増やす代わりに、型に振る舞いを持たせて仮想関数に委ねると、分岐の集中によるメンテナンス負担が減ります。
条件分岐が必要なのは生成時の1回(ファクトリなど)にとどめ、play
やdraw
の呼び出し自体は仮想関数で分岐させるのが基本発想です。
基底クラスに仮想デストラクタ(概要)
基底クラスをポインタ/参照で扱い、派生をdeleteする可能性があるなら、基底のデストラクタはvirtualにする必要があります。
そうしないと派生のデストラクタが呼ばれず、リソースリークにつながります。
#include <iostream>
class BadBase {
public:
// virtualがないデストラクタ
~BadBase() { std::cout << "~BadBase\n"; }
};
class BadDerived : public BadBase {
public:
~BadDerived() { std::cout << "~BadDerived\n"; }
};
class GoodBase {
public:
virtual ~GoodBase() { std::cout << "~GoodBase\n"; } // 仮想デストラクタ
};
class GoodDerived : public GoodBase {
public:
~GoodDerived() { std::cout << "~GoodDerived\n"; }
};
int main() {
BadBase* p1 = new BadDerived();
delete p1; // ~BadDerivedは呼ばれない(未定義動作を引き起こす可能性)
GoodBase* p2 = new GoodDerived();
delete p2; // 両方呼ばれる
}
出力例(典型的な出力の一例):
~BadBase
~GoodDerived
~GoodBase
仮想デストラクタがない場合の挙動は未定義動作になり得ます。
安全のため、ポリモーフィズム前提の基底クラスには必ずvirtualデストラクタを付けましょう。
virtualの付け忘れで意図通りに動かない例
基底にvirtualがないと、派生で同名関数を定義しても静的束縛になり、期待通りに切り替わりません。
#include <iostream>
class Base {
public:
void say() const { // virtualを付け忘れ
std::cout << "Base::say" << std::endl;
}
};
class Derived : public Base {
public:
void say() const { // これは隠蔽(shadowing)であって仮想上書きではない
std::cout << "Derived::say" << std::endl;
}
};
void call(const Base& b) {
b.say(); // 常にBase::sayが呼ばれる
}
int main() {
Derived d;
call(d); // 期待はDerivedだが、実際はBase
}
Base::say
関数の切り替えを意図するなら基底にvirtual
を付けることを忘れないようにしましょう。
加えて、派生側での意図的な上書きであることを示すoverride
指定(今回は詳細説明を割愛)を使うと、ミスに気づきやすくなります。
まとめ
本記事では、ポリモーフィズムとvirtual関数の基礎を、参照/ポインタによる呼び出し、値渡しで効かない理由、純粋仮想関数による抽象化、そして仮想デストラクタの重要性まで含めて解説しました。
要点は次の通りです。
同じインターフェースで動作を切り替えることで、拡張に強く保守しやすい設計になります。
値渡しはスライスするため避ける、ポリモーフィズム前提の基底には仮想デストラクタを必須、派生での上書きはvirtual
が前提という原則を押さえてください。
これらを身につければ、if/switchに頼らない、拡張しやすいオブジェクト指向設計へと一歩進めます。