C++で共通の操作を型ごとに切り替えたいとき、抽象クラスと純粋仮想関数が強力な選択肢になります。
基底クラスが「何をできるか」を宣言し、派生クラスが「どうやって実現するか」を定義することで、拡張しやすく保守しやすい設計を実現できます。
本稿では、純粋仮想関数の意味から実装例、設計ベストプラクティス、よくあるエラーまで体系的に解説します。
C++の抽象クラスとは:純粋仮想関数の基礎と目的
抽象クラスの定義と特徴(インスタンス化不可・インターフェースの表現)
抽象クラスとは、1つ以上の純粋仮想関数(pure virtual function)を持つクラスです。
純粋仮想関数を含むクラスは直接インスタンス化できず、派生クラスで実装(オーバーライド)してはじめて具体的な型として使えます。
これにより「共通のインターフェース(操作の約束)」を型階層に与え、利用側は基底型の参照やポインタ経由で多態的(ポリモーフィック)に扱えます。
純粋仮想関数(=0)の意味と役割
関数宣言の末尾に= 0
を付けると、その関数は純粋仮想関数になります。
これは「実装を持たず、派生クラスでの実装を強制する」という意味です。
たとえばvirtual double area() const = 0;
は、派生クラスに面積の計算ロジックを必ず定義させる契約になります。
ポリモーフィズムのための抽象化と利点
抽象クラスは実装から利用を分離します。
利用側は抽象クラスのポインタ/参照を受け取り、派生型が何であるかに依存せずに同じ操作(例:area()
)を呼べます。
これにより、次の利点があります。
設計の拡張性、依存関係の低減、テスト容易性、交換可能性(オープン/クローズド原則の実践)。
純粋仮想関数で派生クラスに実装を強制する方法
宣言シンタックス:virtual 戻り値 型名(…) = 0;
純粋仮想関数は次の構文で宣言します。
返り値、関数名、引数、const
やnoexcept
などの修飾子も契約に含まれるため、派生側は完全一致でオーバーライドする必要があります。
- 例:
virtual double area() const = 0;
- 例:
virtual void draw(int scale) noexcept = 0;
override/final の使い方とオーバーライドの判定
override
は、その関数が基底クラスの仮想関数を正しくオーバーライドしていることをコンパイラに検証させます。シグネチャの不一致をコンパイル時に捕捉できます。final
は、さらに派生したクラスでその仮想関数やクラス自体のオーバーライド/継承を禁止します。
便利な目安:
- 派生クラスで仮想関数を定義するたびに
override
を必ず付ける - もう変更させたくない段階で
final
を付ける
仮想デストラクタ/純粋仮想デストラクタの必要性
基底クラスをポインタ経由で破棄する可能性があるなら、デストラクタは仮想にすべきです。
そうしないと、派生クラスのデストラクタが呼ばれず、リソースリークにつながります。
- 一般形:
virtual ~Base() = default;
- ときに「純粋仮想デストラクタ」を使う場合もあります(基底をインスタンス化させない意図を明確化)。
ただし純粋仮想デストラクタにも本体定義が必要です。例えば:
struct IFoo {
virtual ~IFoo() = 0; // 純粋仮想デストラクタ
};
inline IFoo::~IFoo() {} // 本体定義が必須(未定義参照を防ぐ)
サンプルコード:抽象クラスと派生クラスの実装例
形状クラス(Shape)で面積を計算する基本例
以下はShape
抽象クラスと、Circle
・Rectangle
・RightTriangle
の派生クラスの例です。
area()
は純粋仮想関数で、派生側に実装を強制しています。
// g++ -std=c++17 -O2 sample.cpp && ./a.out
#include <iostream>
#include <iomanip>
#include <memory>
#include <vector>
#include <string>
#include <utility>
// 抽象クラス: 形状の共通インターフェース
class Shape {
public:
virtual ~Shape() = default; // 仮想デストラクタ(必須)
virtual double area() const = 0; // 純粋仮想関数:面積
virtual const char* name() const noexcept = 0; // 純粋仮想関数:名称
};
// Circle: 追加の継承を禁止したい場合は final を付ける
class Circle final : public Shape {
public:
explicit Circle(double r) : r_(r) {}
double area() const override { // override でオーバーライドを明示
constexpr double pi = 3.141592653589793;
return pi * r_ * r_;
}
const char* name() const noexcept override {
return "Circle";
}
private:
double r_;
};
class Rectangle : public Shape {
public:
Rectangle(double w, double h) : w_(w), h_(h) {}
double area() const override {
return w_ * h_;
}
const char* name() const noexcept override {
return "Rectangle";
}
private:
double w_, h_;
};
class RightTriangle : public Shape {
public:
RightTriangle(double base, double height) : b_(base), h_(height) {}
double area() const override {
return 0.5 * b_ * h_;
}
const char* name() const noexcept override {
return "RightTriangle";
}
private:
double b_, h_;
};
// 多態的に呼び出すユーティリティ
double print_and_get_area(const Shape& s) {
const double a = s.area(); // 動的束縛で各派生の実装が呼ばれる
std::cout << s.name() << " area = " << a << "\n";
return a;
}
int main() {
using std::make_unique;
std::vector<std::unique_ptr<Shape>> shapes;
shapes.emplace_back(make_unique<Circle>(2.0));
shapes.emplace_back(make_unique<Rectangle>(4.0, 5.0));
shapes.emplace_back(make_unique<RightTriangle>(3.0, 4.0));
double total = 0.0;
std::cout << std::fixed << std::setprecision(2);
for (const auto& p : shapes) {
total += print_and_get_area(*p); // 参照経由で多態を活用
}
std::cout << "Total area = " << total << "\n";
}
Circle area = 12.57
Rectangle area = 20.00
RightTriangle area = 6.00
Total area = 38.57
スマートポインタ(std::unique_ptr)と多態的呼び出し
上記ではstd::unique_ptr<Shape>
の配列に異なる派生型を格納し、Shape&
としてarea()
を呼んでいます。
delete
を明示せずに済み、仮想デストラクタにより各派生の破棄が正しく行われます。
所有権の移動も安全で、例外が発生しても自動的に破棄されるので、リソースリークを防げます。
const・参照・引数一致で正しくオーバーライドするコツ
オーバーライドはシグネチャ(戻り値型、関数名、引数、const
/&
/&&
修飾、例外指定など)が完全一致している必要があります。
よくある落とし穴を例で示します。
// 1) const の付け忘れ
struct B1 {
virtual void f(int) const = 0;
};
struct D1_bad : B1 {
void f(int) override { /* ... */ } // エラー: const が欠落し一致しない
};
struct D1_ok : B1 {
void f(int) const override { /* ... */ } // 正しい
};
// 2) 例外指定の不一致(基底がnoexceptなら派生もnoexceptが必要)
struct B2 {
virtual void g() noexcept = 0;
};
struct D2_bad : B2 {
void g() /* noexceptなし */ override { /* ... */ } // エラー: 仕様が弱くなる
};
struct D2_ok : B2 {
void g() noexcept override { /* ... */ } // 正しい
};
// 3) 参照修飾子(& / &&)の不一致
struct B3 {
virtual void h() & = 0; // 左辺値でのみ呼べる
};
struct D3_bad : B3 {
void h() && override { /* ... */ } // エラー: & と && は別シグネチャ
};
struct D3_ok : B3 {
void h() & override { /* ... */ } // 正しい
};
override
を常に付けると、これらの不一致はコンパイル時に分かるため強く推奨されます。
設計ベストプラクティス:C++抽象クラスの作り方
非仮想インターフェース(NVI)パターンの適用
NVIは「公開関数は非仮想、内部で仮想フックを呼ぶ」方針です。
前後処理や例外安全の統一を基底で担保できます。
class Algorithm {
public:
// 外部APIは非仮想。手順の枠組みを固定化できる
void run() {
pre();
try {
step(); // 派生側のコア処理(仮想フック)
} catch (...) {
post();
throw;
}
post();
}
virtual ~Algorithm() = default;
protected:
virtual void step() = 0; // 純粋仮想フック
virtual void pre() {} // 省略可のフック
virtual void post() noexcept {} // 省略可のフック(失敗しない契約)
};
公開APIの安定性と再利用性が向上し、派生側はコアロジックのみに集中できます。
コピー/ムーブの方針を明示(=delete / =default)
抽象基底を多態的に扱う場合、通常はコピーを禁止し、必要ならclone()
を定義します。
class BasePoly {
public:
BasePoly() = default;
BasePoly(const BasePoly&) = delete; // 多態基底はコピー禁止が無難
BasePoly& operator=(const BasePoly&) = delete;
BasePoly(BasePoly&&) noexcept = default; // ムーブは許可(方針次第)
BasePoly& operator=(BasePoly&&) noexcept = default;
virtual ~BasePoly() = default;
virtual std::unique_ptr<BasePoly> clone() const = 0; // 複製用インターフェース
};
コピーの必要があれば、各派生でclone()
を実装し、unique_ptr
経由で安全に多態コピーを実現します。
例外安全・noexceptと契約(pre/post条件)の整理
- 破棄は失敗すべきでないため、デストラクタは暗黙に
noexcept(true)
です。明示的に副作用のない実装を心がけます。 - 仮想関数の例外仕様は契約です。基底が
noexcept
なら派生もnoexcept
にし、仕様を弱めないようにします。 - 事前条件(pre)と事後条件(post)をドキュメントまたは
assert
で明示し、NVIで一元的にチェックすると堅牢になります。
小さな指針:
- 例外を投げうる関数は強い保証/基本保証を意識する
- ムーブ演算子は可能なら
noexcept
にする - リソース所有はスマートポインタで明確化する
よくあるエラーと対処法:抽象クラス/純粋仮想関数
cannot declare variable of abstract type の原因と対応
エラーメッセージ例:
- error: cannot declare variable ‘x’ to be of abstract type ‘Derived’ 原因は、
Derived
がまだ純粋仮想関数を実装していない、またはシグネチャ不一致により実装されていないと見なされていることです。override
を付けて一致を確認し、未実装をすべて埋めてください。
オーバーライド失敗(シグネチャ不一致・const/参照・例外指定)
const
や参照修飾子(&
/&&
)、noexcept
の差異、デフォルト引数の違いが原因になります。- 対策は、基底の宣言を正確に写しつつ、派生で
override
を付与することです。IDEの「実装の生成」機能も有効です。
vtable関連のリンクエラー(未定義参照)のチェックポイント
- 純粋仮想デストラクタを宣言したが本体を定義していない
- 仮想関数をクラス外で定義すると宣言したのに、翻訳単位に実体がない
- ODR(One Definition Rule)違反や、別翻訳単位の実装ファイルをビルド対象に含め忘れた
まずは「純粋仮想デストラクタに本体を与えたか」「全翻訳単位がリンクされているか」を確認します。
FAQ:C++抽象クラスとインターフェースの違い・他機能との比較
Java/C#のinterface相当との違い(データメンバ・既定実装)
C++には専用のinterface
キーワードはありません。抽象クラスで代替します。
C++の抽象クラスはデータメンバやコンストラクタ、非仮想関数、既定実装を持てます(= Java/C#のinterfaceより表現力が広い)。その分、多重継承時の設計や責務分担は慎重に行いましょう。
CRTP・コンセプトとの使い分け
CRTP(Curiously Recurring Template Pattern)は静的多相(コンパイル時ポリモーフィズム)に有効で、仮想呼び出しのオーバーヘッドを回避できます。実行時に型が増減しない場面やインライン化したい場面に適します。
C++20のコンセプトは「要件(インターフェース)を型に課す」ための仕組みで、テンプレート実体化時にチェックされます。
実行時の多態が不要なら、抽象クラスよりコンセプトで静的に表現する方が軽量・高最適化になることがあります。
実行時の型消去・異種コンテナ・プラグイン拡張などが要件なら、抽象クラス+仮想関数が適しています。
いつ抽象クラスを使うべきか(設計判断の指針)
ライブラリの公開APIとして、拡張可能な「契約」を提供したい
実行時に異種オブジェクトを同列に扱い、切り替えたい
実装の差異はあるが操作は共通で、利用側を実装詳細から独立させたい
一方で、実行時多態が不要な場合は、コンセプトやCRTP、std::variant
+std::visit
なども検討すると良いです。
まとめ
純粋仮想関数は、派生クラスに実装を強制するC++の正統的な手段であり、抽象クラスは拡張性・交換可能性・保守性を高める強力な道具です。
実装ではoverride
/final
で契約を明確化し、仮想デストラクタを忘れず、安全な所有権管理にスマートポインタを用いると堅牢になります。
設計面ではNVIで枠組みを固定し、コピー/ムーブ方針や例外仕様、事前/事後条件を明示して、利用者と実装者の合意をコードに刻み込みましょう。
用途に応じてCRTPやコンセプトと使い分けられるようになると、抽象化の選択肢が大幅に広がります。