C++でインターフェースを明確にし、実装を必ず書かせたいときに使うのが抽象クラスと純粋仮想関数です。
本記事では、抽象クラスの意味と制約、純粋仮想関数(=0)の書き方を、初心者でも手を動かして理解できるように、コード例と実行結果を交えて丁寧に解説します。
C++の抽象クラスと純粋仮想関数の基本
抽象クラスとは インスタンス化できない基底クラス
抽象クラスとは、少なくとも1つの純粋仮想関数を持つため、そのままではインスタンス化できないクラスです。
役割は「共通の約束事(インターフェース)を定義し、派生クラスに実装させること」にあります。
設計の意図としては、ベースに振る舞いの型だけを定め、具体的な処理は派生側へ委ねます。
純粋仮想関数とは 実装を強制する関数
純粋仮想関数は、派生クラスに実装を義務づけるための仮想関数です。
実装を提供しない宣言だけを持ち、ベース側では呼び出せません。
派生クラスが該当関数を実装しない場合、その派生クラスも抽象クラスのままで、インスタンス化できません。
純粋仮想関数の書き方(=0)
書式はとても簡単で、仮想関数宣言の末尾に= 0
を付けます。
戻り値、引数、const
やnoexcept
などの修飾も通常どおり書けます。
// 純粋仮想関数の基本形
// ポイント: "= 0" を付けると「実装なし・派生クラスに実装を強制」の意味になる
struct Interface {
virtual ~Interface() = default; // 破壊時の多態性のために仮想デストラクタを用意
virtual int compute(int x) const = 0; // 純粋仮想関数
virtual void reset() noexcept = 0; // noexcept も付けられる
};
なお、デストラクタも純粋仮想にできますが、その場合でも定義自体は必要です(後述)。
また、純粋仮想関数に「ベース側の実装本体」を与えることは可能ですが、クラスは依然として抽象のままです。
抽象クラスと具体クラスの違い
抽象クラスは「作り方の約束」を定義するクラス、具体クラスは「実際の動作」を提供するクラスです。
違いを整理します。
観点 | 抽象クラス | 具体クラス |
---|---|---|
インスタンス化 | できない | できる |
純粋仮想関数 | 少なくとも1つ以上を持つ | 持たなくてもよい(持っていれば実装が必要) |
目的 | インターフェースの統一、実装の強制 | 実際の処理の提供 |
依存の向き | 利用側は抽象に依存 | 具体は抽象を実装 |
使う側は抽象(インターフェース)に依存し、提供側が具体(実装)を差し替えます。
これにより、変更に強く、テストしやすい設計になります。
抽象クラスはいつ使うか
共通のインターフェースを統一したいとき
複数の型に対して、同じメソッド名や同じ引数・戻り値の形で呼び出したい場合に有効です。
例えばShape
という抽象クラスにarea()
を定義すると、どの具体図形でもarea()
で面積を取得できます。
ポリモーフィズムで動作を切り替えたいとき
実行時に実体の型に応じて適切な関数が呼ばれる(動的ポリモーフィズム)を使いたいときに抽象クラスが役立ちます。
呼び出し側はShape&
やShape*
経由で扱うだけで、内部が円でも長方形でも正しく切り替わります。
複数の実装を差し替えたいとき
本番用実装とテスト用ダミー実装の切り替え、OSやライブラリ差の吸収、将来の拡張など、差し替え可能性が必要なときに抽象クラスは強力です。
呼び出し側のコードを変えずに、具体クラスだけを差し替えられます。
使い方と実装例
基底クラスの宣言例 純粋仮想関数を1つ持つ
図形の面積を返すShape
を抽象クラスとして定義します。
破棄を正しく行うため、デストラクタは必ずvirtual
にします。
// 例: 面積を返す抽象クラス
#include <iostream>
class Shape {
public:
virtual ~Shape() = default; // 仮想デストラクタ(重要)
virtual double area() const = 0; // 純粋仮想関数: 派生クラスに実装を強制
};
ポイントの解説
抽象クラスShape
はarea()
を= 0
で宣言しており、この時点ではインスタンス化できません。
使うには後述の派生クラス側で実装が必要です。
派生クラスでの実装例
円と長方形の2つの具体クラスを定義します。
関数シグネチャを完全一致させるため、間違いを防ぐ目的でoverride
を付けています(なくても動作しますが、初心者のうちは付けることを推奨です)。
#define _USE_MATH_DEFINES
#include <cmath>
// 円: 半径 r の面積は πr^2
class Circle : public Shape {
double r_;
public:
explicit Circle(double r) : r_(r) {}
double area() const override { // override を付けて間違いを検出
return M_PI * r_ * r_;
}
};
// 長方形: 幅 w, 高さ h の面積は w*h
class Rectangle : public Shape {
double w_, h_;
public:
Rectangle(double w, double h) : w_(w), h_(h) {}
double area() const override {
return w_ * h_;
}
};
実装のコツ
引数やconst
修飾が1つでも異なるとオーバーライドではなく別関数になってしまいます。
override
を付けると、シグネチャ不一致をコンパイル時に検出できます。
ポインタや参照で呼び出す基本
動的ポリモーフィズムは、基底クラスへの参照やポインタ経由で呼び出すことで機能します。
以下は参照を使う安全でシンプルな例です。
#define _USE_MATH_DEFINES
#include <cmath>
#include <iostream>
class Shape {
public:
virtual ~Shape() = default; // 仮想デストラクタ(重要)
virtual double area() const = 0; // 純粋仮想関数: 派生クラスに実装を強制
};
// 円: 半径 r の面積は πr^2
class Circle : public Shape {
double r_;
public:
explicit Circle(double r) : r_(r) {}
double area() const override { // override を付けて間違いを検出
return M_PI * r_ * r_;
}
};
// 長方形: 幅 w, 高さ h の面積は w*h
class Rectangle : public Shape {
double w_, h_;
public:
Rectangle(double w, double h) : w_(w), h_(h) {}
double area() const override {
return w_ * h_;
}
};
// Shape& 経由で面積を表示するユーティリティ関数
void printArea(const Shape& s, const char* name) {
std::cout << name << " area = " << s.area() << '\n';
}
int main() {
Circle c(2.0); // 半径2
Rectangle r(3.0, 4.0); // 幅3, 高さ4
// 参照で呼び出すと、実体の型に応じた area() が呼ばれる
printArea(c, "Circle");
printArea(r, "Rectangle");
}
Circle area = 12.5664
Rectangle area = 12
参照を使う理由
参照であれば所有権や解放を気にせず、安全に多態呼び出しができます。
ポインタを使う場合は指す先の寿命に注意します。
具体クラスを選んで使う小さな例
実行時に条件で具体クラスを選び、Shape*
経由で使う例です。
int main() {
// Circle c(1.5);
// Rectangle r(2.0, 5.0);
Circle* c = new Circle(1.5);
Rectangle* r = new Rectangle(2.0, 5.0);
bool useCircle = true; // 条件: 実行時に選択すると仮定
Shape* s = useCircle ? static_cast<Shape*>(c) : static_cast<Shape*>(r);
// ポインタ経由でも、実体の型に応じて適切な area() が呼ばれる
std::cout << "Selected area = " << s->area() << '\n';
delete c;
delete r;
}
Selected area = 7.06858
選択ロジックの拡張
文字列や設定値に基づいて分岐する「簡易ファクトリ」に発展させると、差し替えがより柔軟になります。
本格的な生成ロジックは別記事(ファクトリパターン)で扱うと良いでしょう。
よくあるエラーと注意点
抽象クラスは生成できない インスタンス化不可
抽象クラスをそのままnewしたり、ローカル変数として生成したりはできません。
少なくとも1つの純粋仮想関数が未実装であるためです。
// コンパイルエラーの例:
// Shape s; // NG: 抽象クラスはインスタンス化できない
// auto p = new Shape; // NG: 同上
多くのコンパイラは「abstract class のインスタンス化はできない」「pure virtual function が未実装」といったメッセージを出します。
純粋仮想関数の未実装でリンクエラー
典型例は純粋仮想デストラクタです。
純粋でも「定義」自体は必要です。
定義がないとリンクエラーになります。
// 純粋仮想デストラクタの正しい定義方法
class Base {
public:
virtual ~Base() = 0; // 純粋仮想デストラクタ
};
Base::~Base() {} // 本体定義が必須(空でよい)
// Base は依然として抽象クラスのまま
一方、純粋仮想関数を派生で実装し忘れると、その派生クラスも抽象のままになり、インスタンス化でコンパイルエラーになります。
名前や引数が違うとオーバーライドにならない
次のようにconst
が抜けたり引数型が違うと、ベースの関数とは別物になり、多態呼び出しが効きません。
struct I {
virtual int f(int) const = 0;
};
struct ImplBad : I {
// int f(int) { ... } // const が抜けている → オーバーライドではない
int f(int) { return 42; } // 実は別関数(警告にも気づきにくい)
};
struct ImplGood : I {
int f(int) const override { return 7; } // 正しく一致
};
常にoverride
を付けることで、シグネチャ不一致をコンパイル時に検出できます。
基底クラスのデストラクタはvirtualにする
基底をvirtual
でないデストラクタにすると、基底ポインタで派生オブジェクトをdelete
したときに未定義動作になります。
// 悪い例: 仮想でないデストラクタ
struct BadBase {
~BadBase() { std::cout << "~BadBase\n"; }
virtual void f() = 0;
};
struct BadDerived : BadBase {
~BadDerived() { std::cout << "~BadDerived\n"; }
void f() override {}
};
int main() {
BadBase* p = new BadDerived();
delete p; // ~BadDerived が呼ばれない可能性(未定義動作)
}
修正は簡単で、基底のデストラクタをvirtual
にします。
struct GoodBase {
virtual ~GoodBase() { std::cout << "~GoodBase\n"; }
virtual void f() = 0;
};
struct GoodDerived : GoodBase {
~GoodDerived() override { std::cout << "~GoodDerived\n"; }
void f() override {}
};
int main() {
GoodBase* p = new GoodDerived();
delete p; // ~GoodDerived → ~GoodBase の順で安全に破壊
}
コンストラクタから純粋仮想関数を呼ばない
コンストラクタやデストラクタの最中に仮想関数を呼ぶと、動的束縛されません。
ベースのコンストラクタ中に純粋仮想を呼ぶと実行時エラー(環境によってはクラッシュ)になります。
#include <iostream>
struct A {
A() {
// 純粋仮想呼び出しは危険。A の構築中は派生の vtable が有効でない
// g(); // ← 実行時に "pure virtual method called" になることがある
}
virtual ~A() = default;
virtual void g() = 0;
};
struct B : A {
void g() override { std::cout << "B::g\n"; }
};
回避策として、コンストラクタでは非仮想の初期化関数を呼ぶ、あるいは構築後に明示的なinit()
を呼ぶ二段階初期化などを検討します。
まとめ
抽象クラスと純粋仮想関数は、インターフェースを明確にして実装を強制し、動的ポリモーフィズムを安全に運用するための核となる仕組みです。
書き方はvirtual 関数宣言の末尾に = 0
を付けるだけとシンプルですが、基底デストラクタを仮想にする、オーバーライドのシグネチャを完全一致させる、コンストラクタから純粋仮想を呼ばないといった注意点を守ることが重要です。
実装例のように参照やポインタ経由で呼び出すことで、同じ呼び出しコードで複数の実装を自然に切り替えられます。
まずは小さな抽象クラスから導入し、設計の見通しと変更耐性を高めていきましょう。