閉じる

【C++】仮想関数とポリモーフィズムをわかりやすく解説

オブジェクト指向の中核となる機能の1つが、C++の「仮想関数」と「ポリモーフィズム」です。

これらを理解すると、拡張しやすく保守しやすい設計ができるようになります。

本記事では、なぜ仮想関数が必要なのかポリモーフィズムがどのように動くのかを、図解と具体的なコード例を交えながら丁寧に解説します。

仮想関数とは何か

仮想関数のイメージ

仮想関数とは、基底クラスの関数を派生クラスで上書きし、実行時に実体に応じて適切な関数を呼び分けるための仕組みです。

通常のメンバ関数は「型」に基づいて呼び出し先が決まりますが、仮想関数は実際のオブジェクトの中身(実体)に基づいて呼び出し先が決まる点が大きく異なります。

これにより、基底クラスのポインタや参照を使っていても、派生クラス側の処理を自然に呼び出すことができます。

なぜ仮想関数が必要なのか

オブジェクト指向では、共通のインターフェースを持ちつつ、クラスごとに振る舞いを変えたい場面が多くあります。

例えば「図形」を表すクラス群では、共通してdraw()という関数を用意しつつ、円なら円の描画、四角形なら四角形の描画を行わせたいといった場面です。

このとき、仮想関数を使うと、図形の種類を意識せず「とにかくdrawを呼ぶ」だけで、適切な描画処理が実行されます

これこそが、後述するポリモーフィズム(多態性)を実現する土台となります。

仮想関数の基本構文

virtual キーワードの使い方

仮想関数は、基底クラスのメンバ関数宣言にvirtualキーワードを付けることで定義します。

C++
class Shape {
public:
    // 仮想関数の宣言
    virtual void draw() {
        std::cout << "Shape::draw()" << std::endl;
    }
};

このvirtualが付いた関数を、派生クラスで同じシグネチャ(戻り値、関数名、引数)で定義すると、その関数は「オーバーライド」されます。

override を付けるべき理由

C++11以降では、overrideキーワードを付けることで、「この関数は基底クラスの仮想関数を上書きしている」とコンパイラに明示します。

C++
class Circle : public Shape {
public:
    // 基底クラスのdrawをオーバーライド
    void draw() override {
        std::cout << "Circle::draw()" << std::endl;
    }
};

overrideを付けておくと、関数名や引数のミスでオーバーライドになっていない場合にコンパイルエラーで気づけるため、実務では必ず付けることが推奨されます

ポリモーフィズム(多態性)とは

ポリモーフィズムの概念図

ポリモーフィズム(polymorphism)とは、同じインターフェース(型)で扱える複数のオブジェクトが、それぞれ異なる振る舞いをする性質のことです。

C++では、virtualを使った仮想関数と、基底クラスのポインタまたは参照を組み合わせることで、実行時ポリモーフィズム(動的ポリモーフィズム)を実現します。

ポリモーフィズムの典型的なコード例

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

// 共通のインターフェースを持つ基底クラス
class Shape {
public:
    virtual ~Shape() = default; // 仮想デストラクタ(後述)

    // 仮想関数: 派生クラスでそれぞれの描画処理を実装する
    virtual void draw() const {
        std::cout << "Shape::draw()" << std::endl;
    }
};

// 円を表すクラス
class Circle : public Shape {
public:
    void draw() const override { // Shape::drawをオーバーライド
        std::cout << "Drawing Circle" << std::endl;
    }
};

// 四角形を表すクラス
class Rectangle : public Shape {
public:
    void draw() const override { // Shape::drawをオーバーライド
        std::cout << "Drawing Rectangle" << std::endl;
    }
};

int main() {
    // Shape型のスマートポインタのコンテナに、異なる派生クラスを格納
    std::vector<std::unique_ptr<Shape>> shapes;
    shapes.emplace_back(std::make_unique<Circle>());     // 実体はCircle
    shapes.emplace_back(std::make_unique<Rectangle>());  // 実体はRectangle

    // 共通のインターフェース(Shape)だけを意識して処理を書く
    for (const auto &s : shapes) {
        s->draw(); // 実体に応じてCircle::draw, Rectangle::drawが呼ばれる
    }

    return 0;
}
実行結果
Drawing Circle
Drawing Rectangle

ポイントは、shapesの要素型はどれもShapeであるにもかかわらず、実際には異なるクラスのdraw()が呼ばれていることです。

この「1つの型で複数の振る舞いを使い分ける」ことがポリモーフィズムです。

静的バインディングと動的バインディング

静的バインディングとの違いを比較

通常のメンバ関数は、コンパイル時にどの関数を呼び出すかが決まるため「静的バインディング」と呼ばれます。

一方、仮想関数では実行時に実体に応じて関数が決まるため「動的バインディング」と呼ばれます。

次のコードで違いを確認してみます。

C++
#include <iostream>

class Base {
public:
    void func() const { // 仮想ではない
        std::cout << "Base::func()" << std::endl;
    }

    virtual void vfunc() const { // 仮想関数
        std::cout << "Base::vfunc()" << std::endl;
    }

    virtual ~Base() = default;
};

class Derived : public Base {
public:
    void func() const { // Base::funcとは別物(隠蔽)
        std::cout << "Derived::func()" << std::endl;
    }

    void vfunc() const override { // Base::vfuncをオーバーライド
        std::cout << "Derived::vfunc()" << std::endl;
    }
};

int main() {
    Derived d;
    Base* p = &d; // 基底クラスのポインタで派生クラスを指す

    p->func();   // 仮想ではない → Base::func()が呼ばれる
    p->vfunc();  // 仮想関数 → Derived::vfunc()が呼ばれる

    return 0;
}
実行結果
Base::func()
Derived::vfunc()

ここではpBase*型ですが、vfunc()実体がDerivedであることに応じてDerived::vfunc()が呼ばれています。

一方、仮想でないfunc()型に基づいてBase::func()が呼び出されます。

仮想デストラクタの重要性

なぜデストラクタもvirtualにすべきか

基底クラスのポインタ経由で派生クラスのオブジェクトをdeleteする場合、デストラクタが仮想関数でないと派生クラス側のデストラクタが呼ばれず、リソースリークの原因になります。

C++
#include <iostream>

class Base {
public:
    Base() { std::cout << "Base ctor\n"; }
    ~Base() { std::cout << "Base dtor\n"; } // 仮想ではない
};

class Derived : public Base {
public:
    Derived() { std::cout << "Derived ctor\n"; }
    ~Derived() { std::cout << "Derived dtor\n"; }
};

int main() {
    Base* p = new Derived();
    delete p; // Derivedのデストラクタは呼ばれない

    return 0;
}
実行結果
Base ctor
Derived ctor
Base dtor

この問題を避けるため、ポリモーフィズムのための基底クラスは必ず仮想デストラクタを持たせるのが定石です。

C++
class Base {
public:
    Base() { std::cout << "Base ctor\n"; }
    virtual ~Base() { std::cout << "Base dtor\n"; } // 仮想デストラクタ
};

class Derived : public Base {
public:
    Derived() { std::cout << "Derived ctor\n"; }
    ~Derived() override { std::cout << "Derived dtor\n"; }
};
実行結果
Base ctor
Derived ctor
Derived dtor
Base dtor

このように仮想デストラクタを用意しておくと、基底クラスのポインタ経由でも安全にオブジェクトを破棄できます

抽象クラスと純粋仮想関数

純粋仮想関数の役割

抽象クラスとは、少なくとも1つ以上の純粋仮想関数を持つクラスのことです。

純粋仮想関数は、= 0を付けて宣言します。

C++
class Shape {
public:
    virtual ~Shape() = default;

    // 純粋仮想関数: 実装を持たない
    virtual void draw() const = 0;
};

純粋仮想関数を持つクラスはインスタンス化できません

その代わり、「こういう関数を持つべきだ」という契約(インターフェース)を表現する役割を持ちます。

抽象クラスを使った設計例

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

class Shape {
public:
    virtual ~Shape() = default;

    // すべての派生クラスが実装しなければならないインターフェース
    virtual void draw() const = 0;
};

class Circle : public Shape {
public:
    void draw() const override {
        std::cout << "Circle is drawn." << std::endl;
    }
};

class Rectangle : public Shape {
public:
    void draw() const override {
        std::cout << "Rectangle is drawn." << std::endl;
    }
};

int main() {
    // Shapeは抽象クラスなので直接は生成できない
    // Shape s;          // コンパイルエラー

    std::vector<std::unique_ptr<Shape>> shapes;
    shapes.emplace_back(std::make_unique<Circle>());
    shapes.emplace_back(std::make_unique<Rectangle>());

    for (const auto &s : shapes) {
        s->draw(); // 実体に応じて適切なdrawが呼ばれる
    }

    return 0;
}
実行結果
Circle is drawn.
Rectangle is drawn.

このように抽象クラスは「共通のインターフェースを定義する型」として設計に大きな役割を果たします。

仮想関数テーブル(vtable)のざっくりイメージ

vtable の図解と仕組み(概念レベル)

実務でvtableを直接触ることはありませんが、仕組みをイメージしておくと理解が深まります。

コンパイラは、仮想関数を持つクラスごとに「仮想関数テーブル(vtable)」を用意し、各オブジェクトにはそのテーブルを指すポインタ(vptr)を埋め込むことが多いです。

関数呼び出し時には、このテーブルを参照して実際に呼び出す関数を決めます。

このため、仮想関数にはわずかな実行時オーバーヘッドがありますが、多くのアプリケーションでは十分に許容できるレベルです。

ポリモーフィズムを使う際の注意点

値渡しではポリモーフィズムが効かない

ポリモーフィズムは、必ずポインタまたは参照で扱う必要があります。

値渡しをすると「オブジェクトの切り詰め(slicing)」が起こり、派生クラスとしての情報が失われてしまいます。

C++
#include <iostream>

class Base {
public:
    virtual void who() const {
        std::cout << "Base" << std::endl;
    }

    virtual ~Base() = default;
};

class Derived : public Base {
public:
    void who() const override {
        std::cout << "Derived" << std::endl;
    }
};

// 値渡し(コピー)している
void print_by_value(Base b) {
    b.who(); // Baseとして扱われてしまう
}

// 参照渡し
void print_by_ref(const Base& b) {
    b.who(); // 実体に応じて呼び分けられる
}

int main() {
    Derived d;

    print_by_value(d); // Derived → Baseに切り詰められる
    print_by_ref(d);   // Derivedとして呼ばれる

    return 0;
}
実行結果
Base
Derived

ポリモーフィズムを使う関数の引数は、必ず基底クラスの参照かポインタにすることが重要です。

パフォーマンスと設計のバランス

仮想関数は便利ですが、以下のような特徴があります。

  • 実行時にvtableを経由するため、通常の関数呼び出しよりわずかに遅い
  • インライン展開されにくい場合がある
  • 継承階層が深くなると、設計が複雑化しやすい

そのため、「常に仮想関数にする」のではなく、「拡張可能性が必要な箇所」に絞って使うことが重要です。

小さなユーティリティクラスなどでは、テンプレートや関数オブジェクトなど、別の手段を使うことも検討します。

まとめ

仮想関数とポリモーフィズムは、C++のオブジェクト指向で最も重要な機能の1つです。

基底クラスにvirtualを付けてインターフェースを定義し、派生クラスでoverrideして振る舞いを変えることで、拡張しやすく柔軟な設計が可能になります。

実装時には、仮想デストラクタの追加や、参照・ポインタでの扱い、抽象クラスによるインターフェース定義などのポイントを意識すると、安全かつわかりやすいコードになります。

日常的なクラス設計の中で少しずつ仮想関数を使い、ポリモーフィズムの感覚を身につけていってください。

継承・ポリモーフィズム・抽象化

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

URLをコピーしました!