C++のポリモーフィズム入門:virtual関数とoverrideで同じ関数名でもオブジェクトごとに処理を切り替える方法【サンプルコード付き】

C++では同じ関数名でも、オブジェクトの実際の型に応じて処理を切り替える「ポリモーフィズム」が使えます。

本記事では、virtual関数とoverride/finalの正しい使い方を、落とし穴や設計の観点も含めて体系的に解説します。

サンプルコードと実行結果を通して、動的バインディングの仕組みを具体的に理解できるように進めます。

目次
  1. C++のポリモーフィズムとは?同じ関数名でオブジェクトごとに処理を変える基礎
  2. virtual関数とoverrideの基本—書き方・使い方・注意点
  3. 参照・ポインタでの動的ディスパッチとオブジェクトスライシング
  4. サンプルコード:virtual/overrideで処理を切り替える実装例
  5. よくある落とし穴とベストプラクティス
  6. まとめ

C++のポリモーフィズムとは?同じ関数名でオブジェクトごとに処理を変える基礎

ポリモーフィズム(多態性)の意味とメリット

ポリモーフィズムとは、共通のインターフェースを通じて、実体(オブジェクト)の型ごとに異なる実装を動的に選択する仕組みです。

メリットは、呼び出し側が型の詳細を知らなくても「同じ関数名」を呼ぶだけで適切な処理が実行される点にあります。

これにより、拡張が容易で、依存を低減した保守性の高い設計が可能になります。

同じ関数名で異なる実装を選ぶ仕組み(動的バインディング)

動的バインディングは、プログラム実行時に仮想関数テーブル(vtable)を参照して、実体の型に対応する関数を選ぶ仕組みです。

これにより、基底クラスの参照やポインタを通じて呼び出しても、実際には派生クラスの関数が選ばれます。

C++で実現する理由とユースケース

C++は低レベル制御と高レベル抽象の両立を目指す言語であり、仮想関数による動的ディスパッチはその中核機能です。

ユースケースとしては、描画オブジェクトの共通API、戦略パターン(アルゴリズムの差し替え)、プラグイン構造、入出力処理の共通化などが挙げられます。

virtual関数とoverrideの基本—書き方・使い方・注意点

仮想関数(virtual)の宣言と定義のルール

仮想関数は基底クラスでvirtualとして宣言します。

派生クラスでは同名・同シグネチャの関数を定義すると「オーバーライド」できます。

  • constや参照修飾(&/&&)、noexcept、戻り値の型(共変戻り値)までがシグネチャ整合に関わります。1つでも異なると別関数扱いになり、意図しないオーバーロードになることがあります。
  • 純粋仮想関数(= 0)にすると、そのクラスは抽象クラスとなりインスタンス化できません。インターフェース定義に有用です。
  • デストラクタを仮想にするのは重要です。基底ポインタからdeleteする際に派生のデストラクタが確実に呼ばれます。

例(純粋仮想関数の宣言)

C++
// 純粋仮想関数を持つ抽象クラス
struct Interface {
    virtual ~Interface() = default;
    virtual void doWork() = 0; // 純粋仮想関数
};

override指定子で安全にオーバーライドする

overrideは派生クラスの関数が「本当に仮想関数をオーバーライドしているか」をコンパイラにチェックさせます。

シグネチャの些細な違いによるミスを早期に検出できます。

C++
struct Base {
    virtual void f() const; // constがシグネチャの一部
};

struct Derived : Base {
    void f() /* constを落とすと別物 */ override; // コンパイルエラーで教えてくれる
};

finalでオーバーライドや継承を禁止する

finalは保護の度合いを明確にします。

  • クラスに付けると、そのクラスを継承できません。
  • 仮想関数に付けると、それ以上の派生でその関数をオーバーライドできません。
C++
struct A {
    virtual void g();
};

struct B final : A {            // これ以上継承できない
    void g() override final;    // これ以上オーバーライドできない
};

参照・ポインタでの動的ディスパッチとオブジェクトスライシング

基底クラス参照/ポインタ経由でvirtual関数を呼ぶ

動的バインディングは「基底クラスの参照またはポインタ」を通じた仮想関数呼び出しで発動します。

値渡しでは派生部分が切り落とされるため避けるべきです。

値渡しで起きるオブジェクトスライシングの回避

オブジェクトスライシングとは、派生オブジェクトを基底型の値として受け取る際、派生部分が切り落とされる現象です。

仮想関数を持つ型を関数引数やコンテナで扱う場合は、const Base&std::unique_ptr<Base>などを用いて回避します。

関数オーバーロードやテンプレートとの違い

オーバーロードはコンパイル時に引数型で関数を選ぶ「静的ディスパッチ」です。

テンプレート(静的ポリモーフィズム)も実体化時に実装が決まります。

仮想関数は実行時に選ばれる点が異なります。

手法目的・特性決定タイミング典型ユースケース
仮想関数(動的ポリモーフィズム)実行時に実体の型で実装を選択実行時プラグイン、UIウィジェット、I/O抽象化
関数オーバーロード引数型により別の関数を選択コンパイル時型ごとに最適化したAPIの提供
テンプレート(静的ポリモーフィズム)型に依存する汎用コードの生成コンパイル時数値計算、コンパイル時最適化
std::variant + std::visit列挙的な型分岐を網羅的に扱う実行時/静的混在限られた型集合の分岐、合併型の表現

サンプルコード:virtual/overrideで処理を切り替える実装例

基底クラスと派生クラスの設計(純粋仮想関数と抽象クラス)

以下では、図形を例に多態的な呼び出しを実装します。

Shapeは抽象クラスで、area()は純粋仮想関数です。

Circleはクラス自体をfinalに、Rectanglearea()finalにして拡張境界を明示します。

C++
#include <iostream>
#include <vector>
#include <memory>
#include <string>

// 依存ライブラリに左右されない円周率定数
constexpr double kPi = 3.14159265358979323846;

// 多態のための抽象基底クラス
class Shape {
public:
    virtual ~Shape() = default;                        // 仮想デストラクタは重要
    virtual std::string name() const { return "Shape"; }
    virtual double area() const = 0;                   // 純粋仮想関数(面積)
    virtual void draw(std::ostream& os) const {        // デフォルトの描画
        os << "Drawing a generic shape\n";
    }
};

// 継承を禁止した円
class Circle final : public Shape {
    double r_;
public:
    explicit Circle(double r) : r_(r) {}
    std::string name() const override { return "Circle"; }
    double area() const override { return kPi * r_ * r_; }
    void draw(std::ostream& os) const override {
        os << "Drawing a circle of radius " << r_ << "\n";
    }
};

// 面積の再オーバーライドを禁止した長方形
class Rectangle : public Shape {
    double w_, h_;
public:
    Rectangle(double w, double h) : w_(w), h_(h) {}
    std::string name() const override { return "Rectangle"; }
    double area() const override final { return w_ * h_; } // これ以上の派生でarea()を変えられない
    void draw(std::ostream& os) const override {
        os << "Drawing a rectangle " << w_ << " x " << h_ << "\n";
    }
};

// 基底参照で多態的に処理する関数
void print_report(const Shape& s) {
    std::cout << s.name() << ": area = " << s.area() << "\n";
    s.draw(std::cout);
}

int main() {
    // ヒープ上の多態オブジェクトをunique_ptrで安全に管理
    std::vector<std::unique_ptr<Shape>> shapes;
    shapes.emplace_back(std::make_unique<Circle>(2.0));
    shapes.emplace_back(std::make_unique<Rectangle>(3.0, 4.0));

    for (const auto& shp : shapes) {
        print_report(*shp); // 参照経由で仮想関数呼び出し -> 動的ディスパッチ
    }
}
実行結果
Circle: area = 12.5664
Drawing a circle of radius 2
Rectangle: area = 12
Drawing a rectangle 3 x 4

多態的に動作する呼び出しコード(配列・コンテナでの活用)

上記のようにstd::vector<std::unique_ptr<Shape>>を使うと、異なる型(CircleRectangle)を同じコンテナで扱えます。

取り出すときは基底への参照を使って仮想関数を呼ぶだけで、正しい実装が選ばれます。

所有権はunique_ptrが表現するため、明確で安全なリソース管理が可能です。

実行結果と動的バインディングの確認ポイント

出力の1行目ではCirclearea()が呼ばれ、2行目の描画もCircle::draw()が選ばれています。

続く行ではRectangle側の実装が選ばれており、同じprint_report呼び出しでもオブジェクトごとに処理が切り替わっていることが確認できます。

よくある落とし穴とベストプラクティス

仮想デストラクタとRAII(unique_ptrでの安全な管理)

基底クラスに仮想デストラクタがないと、delete base_ptr;で派生デストラクタが呼ばれずリソースリークや未定義動作の原因になります。

RAIIの原則に従い、所有権はstd::unique_ptr<Base>で表現し、例外や早期returnでも確実に破棄されるようにします。

オブジェクトスライシングの実例(値渡しは避ける)

値渡しは派生部分を切り落とします。

次の短いプログラムで確認できます。

C++
#include <iostream>
#include <string>

struct Animal {
    virtual ~Animal() = default;
    virtual std::string name() const { return "Animal"; }
};

struct Dog : Animal {
    std::string name() const override { return "Dog"; }
};

void by_value(Animal a) { // ここでスライシングが起きる
    std::cout << "by_value: " << a.name() << "\n";
}

void by_ref(const Animal& a) { // 参照なら動的ディスパッチされる
    std::cout << "by_ref: " << a.name() << "\n";
}

int main() {
    Dog d;
    by_value(d); // Animalに切り詰められる
    by_ref(d);   // Dogとして振る舞う
}
実行結果
by_value: Animal
by_ref: Dog

デフォルト引数は静的束縛—宣言位置と設計のコツ

デフォルト引数は「呼び出し時点の静的型」に束縛されます。

仮想関数本体は動的に選ばれても、デフォルト引数の値は静的に決まるため、混乱の原因になりがちです。

C++
#include <iostream>
#include <string>

struct Base {
    virtual ~Base() = default;
    virtual void hello(const std::string& who = "Base") const {
        std::cout << "Hello from " << who << "\n";
    }
};

struct Derived : Base {
    void hello(const std::string& who = "Derived") const override {
        std::cout << "Hello from " << who << "\n";
    }
};

int main() {
    Derived d;
    Base& b = d;

    d.hello(); // 呼び出し式の静的型はDerived -> "Derived"
    b.hello(); // 静的型はBase -> "Base"(ただし本体はDerived::hello)
}
実行結果
Hello from Derived
Hello from Base

したがって、仮想関数にデフォルト引数を多用するのは避け、必要なら明示的に引数を渡すか、呼び出しラッパー関数を用意するのが安全です。

宣言・定義の分離時は、デフォルト引数をヘッダ側の宣言にのみ書くのが原則です。

パフォーマンスとインライン化:virtualの使いどころ

仮想関数呼び出しは間接呼び出しになるため、分岐予測・インライン化の観点でわずかなオーバーヘッドがあります。

とはいえ現代の最適化では多くの場合無視できる程度です。

ホットパスで極限の性能が必要な箇所は、テンプレート(静的ポリモーフィズム)やCRTP、ポリモーフィックな分岐の排除(データ指向設計)を検討するとよいでしょう。

逆に、拡張性・テスト容易性・疎結合が重要な箇所ではvirtualが適しています。

インターフェース分離とテスト容易性の向上

抽象基底クラスで最小限の責務を定義し、用途ごとに小さなインターフェースへ分割すると、結合度を下げて差し替えやすい設計になります。

依存側は抽象型に依存し、実装は注入(DI)することで、単体テストではモック/スタブの実装に置き換えられます。

これによりテスト容易性と変更耐性が高まります。

まとめ

いつvirtualを使うべきかの判断基準

  • 実行時に実装を差し替える必要があり、呼び出し側が型詳細を知らないほうがよい場合はvirtualを選びます。プラグイン・戦略・描画・I/O抽象などが該当します。
  • コンパイル時に型が確定し、性能が最優先ならテンプレートやオーバーロードを検討します。
  • どちらも必要なら、境界で抽象化(virtual)し、内部は静的ポリモーフィズムで最適化する折衷案も有効です。

設計指針:抽象クラスの設計とoverride運用ルール

  • 抽象基底クラスは純粋仮想関数で「できること」だけを示し、デストラクタは必ず仮想にします。
  • 派生側はoverrideを必ず明示し、必要に応じてfinalで拡張境界を固定します。
  • 値渡しを避け、const Base&std::unique_ptr<Base>で多態オブジェクトを扱います。
  • 仮想関数のデフォルト引数は避け、静的束縛の落とし穴を回避します。

追加学習:多態性の応用・参考資料(動的バインディング/純粋仮想関数)

本記事のサンプルと注意点を踏まえ、virtual/overrideを正しく使い分ければ、拡張性・保守性・テスト容易性の高いC++設計を実現できます。

クラウドSSLサイトシールは安心の証です。

URLをコピーしました!