C++の純粋仮想関数とは、基底クラスで関数の存在だけを定義し、具体的な処理を派生クラスに任せるための仕組みです。
基底クラスでは「この機能を持つべき」というルールだけを示し、実際の動作は派生クラスごとに決めます。
たとえば、「図形」という大きな分類には「面積を求める」という共通の操作があります。
しかし、円、長方形、三角形では面積の求め方が異なります。
このように、共通する操作はあるものの、具体的な処理が種類ごとに変わる場合に、純粋仮想関数が使われます。
純粋仮想関数の基本的な考え方
基底クラスで共通のルールを定義する
純粋仮想関数は、基底クラスに共通のルールを定義するために使います。
基底クラスは、派生クラスに対して「この関数を持っている必要がある」という約束を与えます。
ただし、その関数の中身までは基底クラスでは決めません。
具体的な処理は、派生クラス側で実装します。
この仕組みにより、異なるクラスでも同じ操作名で扱えるようになります。
具体的な処理は派生クラスで決める
純粋仮想関数を使うと、基底クラスでは処理の詳細を持たず、派生クラスごとに適切な処理を定義できます。
たとえば、「動物」という基底クラスに「鳴く」という機能を用意した場合、犬、猫、鳥では鳴き方が異なります。
このとき、「動物」側では「鳴く」という操作だけを定義し、犬や猫などの派生クラスで具体的な鳴き方を決めます。
純粋仮想関数を持つクラスは抽象クラスになる
抽象クラスは直接オブジェクトを作れない
純粋仮想関数を1つでも持つクラスは、抽象クラスになります。
抽象クラスとは、直接オブジェクトを作ることができないクラスです。
未完成の関数を含んでいるため、そのままでは具体的な動作を持つオブジェクトとして扱えません。
抽象クラスは、単体で使うためのクラスではなく、派生クラスに共通の設計を与えるために使います。
抽象クラスは設計図として使う
抽象クラスは、共通の機能やルールをまとめる設計図のような役割を持ちます。
たとえば、「図形」という抽象クラスでは、「面積を求める」という操作を定義できます。
ただし、「図形」そのものには具体的な形がないため、面積の計算方法を決められません。
そこで、円や長方形などの派生クラスで、それぞれの計算方法を実装します。
このように、抽象クラスは具体的な処理を完成させるためではなく、派生クラスに共通のインターフェースを与えるために使われます。
純粋仮想関数は必ずすぐに実装する必要はない
実装しない派生クラスも抽象クラスになる
純粋仮想関数は、派生クラスで必ずすぐに実装しなければならないわけではありません。
派生クラスが純粋仮想関数を実装しない場合、その派生クラスも抽象クラスになります。
つまり、中間的な派生クラスでは未実装のままにしておき、さらに下位の派生クラスで具体的な処理を実装することもできます。
具象クラスとして使うには実装が必要
実際にオブジェクトを作れるクラスにするには、継承している純粋仮想関数を最終的に実装する必要があります。
必要な純粋仮想関数をすべて実装したクラスは、具象クラスとして扱えます。
具象クラスとは、実際にオブジェクトを作ることができるクラスです。
抽象クラスが設計図であるのに対し、具象クラスは実際に利用できる完成したクラスと考えると分かりやすいです。
純粋仮想関数を使う目的
共通インターフェースを作る
純粋仮想関数の大きな目的は、共通インターフェースを作ることです。
インターフェースとは、外部から見たときに利用できる操作の約束です。
たとえば、円、長方形、三角形はそれぞれ異なる図形ですが、どれも「面積を求める」という操作を持つことができます。
このような共通操作を抽象クラスに定義しておくことで、異なる種類のオブジェクトを同じ方法で扱えるようになります。
ポリモーフィズムを実現する
純粋仮想関数は、C++のポリモーフィズムを実現するために重要です。
ポリモーフィズムとは、同じ操作を呼び出しても、実際のオブジェクトの種類に応じて異なる処理が実行される仕組みです。
たとえば、同じ「鳴く」という操作でも、実体が犬であれば犬の鳴き方、猫であれば猫の鳴き方が実行されます。
呼び出し側は、具体的なクラスを細かく意識する必要がありません。
共通の操作だけを呼び出せば、実際の型に応じた処理が実行されます。
処理を差し替えやすくする
純粋仮想関数を使うと、処理の差し替えがしやすくなります。
たとえば、ログ出力の仕組みを考えると、出力先にはコンソール、ファイル、データベース、外部サービスなどがあります。
呼び出し側が具体的な出力先に依存していると、出力方法を変更するたびに多くの修正が必要になります。
しかし、共通のログ出力インターフェースを用意しておけば、呼び出し側は具体的な出力先を意識せずに処理できます。
これにより、実装の変更や追加がしやすくなります。
通常の仮想関数との違い
通常の仮想関数は基底クラス側に処理を持てる
通常の仮想関数は、基底クラス側に具体的な処理を持つことができます。
派生クラスは、その処理を上書きしてもよく、上書きしなくても構いません。
つまり、通常の仮想関数は「標準の処理は用意しておき、必要に応じて派生クラスで変更する」ための仕組みです。
純粋仮想関数は派生クラスでの実装を前提にする
純粋仮想関数は、基底クラス側では具体的な処理を持たない関数として扱われます。
そのため、そのクラスを実際に使える具象クラスにするには、派生クラス側で実装する必要があります。
通常の仮想関数が「上書きしてもよい関数」であるのに対し、純粋仮想関数は「具象クラスとして使うなら実装が必要な関数」です。
抽象クラスと具象クラスの違い
抽象クラスとは
抽象クラスとは、純粋仮想関数を持つため、直接オブジェクトを作れないクラスです。
抽象クラスは、共通の性質や操作を定義するために使います。
具体的な処理をすべて完成させるのではなく、派生クラスに必要な機能を示す役割を持ちます。
抽象クラスは、クラス設計の土台として使われます。
具象クラスとは
具象クラスとは、必要な関数がすべて実装されており、実際にオブジェクトを作ることができるクラスです。
抽象クラスを継承し、未実装の純粋仮想関数を実装することで、具象クラスになります。
たとえば、「図形」は抽象的な概念ですが、「円」や「長方形」は具体的な図形です。
この場合、「図形」は抽象クラス、「円」や「長方形」は具象クラスとして設計できます。
overrideを付ける重要性
オーバーライドのミスを防げる
派生クラスで純粋仮想関数を実装するときは、override を付けるのが一般的です。
override を付けることで、その関数が基底クラスの仮想関数を正しく上書きしているかをコンパイラが確認できます。
関数名の間違い、引数の違い、constの付け忘れなどがあると、正しくオーバーライドできません。
override を付けていれば、このようなミスをコンパイル時に検出できます。
意図しない別関数の作成を防げる
override を付けていない場合、オーバーライドしたつもりの関数が、実際には別の新しい関数として扱われることがあります。
この場合、プログラムはコンパイルできても、意図した処理が呼ばれない可能性があります。
特に、関数名のタイプミスや引数の型違いは見落としやすいため、派生クラスで仮想関数を上書きする場合は override を付けることが推奨されます。
constの有無に注意する
constもオーバーライド判定に関係する
オーバーライドでは、関数名や引数だけでなく、constの有無も重要です。
基底クラス側の関数がconst付きで宣言されている場合、派生クラス側でも同じようにconstを付ける必要があります。
constを付け忘れると、同じ名前の関数であっても、基底クラスの関数を正しく上書きしたことにはなりません。
overrideでconstのミスを検出できる
派生クラス側に override を付けておけば、constの不一致もコンパイラが検出できます。
そのため、仮想関数を上書きする場合は、override を付けることで安全性が高まります。
特に抽象クラスを使った設計では、関数のシグネチャが正しく一致していることが重要です。
戻り値と共変戻り値
戻り値は原則として一致が必要
仮想関数をオーバーライドする場合、戻り値の型は基本的に基底クラス側と一致している必要があります。
戻り値の型が異なると、正しくオーバーライドできない場合があります。
ただし、C++には一部例外があります。
共変戻り値が認められる場合がある
基底クラスの仮想関数がポインタや参照を返す場合、派生クラス側でより具体的な型のポインタや参照を返せることがあります。
これを共変戻り値と呼びます。
たとえば、基底クラス側では基底クラス型のポインタを返し、派生クラス側では派生クラス型のポインタを返すような設計が可能です。
共変戻り値を使うと、派生クラス側でより具体的な型を扱いやすくなります。
仮想デストラクタが重要な理由
基底クラス経由で削除する場合に必要
抽象クラスを基底クラスとして使う場合、仮想デストラクタを用意することが重要です。
基底クラスのポインタを通じて派生クラスのオブジェクトを削除する場面では、基底クラスのデストラクタが仮想である必要があります。
仮想デストラクタがない状態で基底クラス経由の削除を行うと、未定義動作になる可能性があります。
未定義動作とは、C++の仕様上、プログラムの結果が保証されない状態です。
抽象基底クラスでは仮想デストラクタを用意する
純粋仮想関数を持つクラスは、基底クラスとして使われることが多くあります。
そのため、抽象基底クラスには仮想デストラクタを用意するのが基本です。
派生クラスがリソースを持っている場合、デストラクタが正しく呼ばれないと、メモリ解放漏れやリソース解放漏れにつながる可能性があります。
安全な設計にするためには、ポリモーフィズムで使う基底クラスに仮想デストラクタを持たせることが重要です。
純粋仮想関数にも実装を持たせられる
実装があっても抽象クラスのまま
C++では、純粋仮想関数にも実装を持たせることができます。
ただし、実装があっても、純粋仮想関数として宣言されている限り、そのクラスは抽象クラスのままです。
抽象クラスかどうかは、実装の有無ではなく、純粋仮想関数として宣言されているかどうかで決まります。
共通処理として使える
純粋仮想関数に実装を持たせると、派生クラスから共通処理として利用できます。
この使い方は頻繁ではありませんが、基底クラス側に一部の共通処理を置きつつ、派生クラスに最終的な実装を求めたい場合に使われます。
ただし、純粋仮想関数の実装は、通常クラス外で定義します。
純粋仮想デストラクタ
デストラクタも純粋仮想にできる
C++では、デストラクタを純粋仮想にすることもできます。
純粋仮想デストラクタを持つクラスも抽象クラスになります。
この仕組みは、そのクラスを基底クラスとしてのみ使わせたい場合などに利用できます。
純粋仮想デストラクタには定義が必要
純粋仮想デストラクタは、純粋仮想であっても定義が必要です。
派生クラスのオブジェクトが破棄されるとき、基底クラスのデストラクタも必ず呼ばれるためです。
つまり、純粋仮想デストラクタは「呼ばれない関数」ではありません。
宣言だけで定義がない場合、リンクエラーなどの原因になります。
コンストラクタは仮想関数にできない
純粋仮想コンストラクタは作れない
C++では、コンストラクタを仮想関数にすることはできません。
そのため、純粋仮想コンストラクタも作れません。
コンストラクタはオブジェクトを生成するための特別な関数であり、仮想関数として扱う仕組みとは役割が異なります。
仮想コンストラクタという設計パターンはある
コンストラクタ自体は仮想にできませんが、似た目的を実現するために、オブジェクトを生成または複製する仮想関数を用意することがあります。
このような設計は「仮想コンストラクタ」と呼ばれることがあります。
ただし、実際にコンストラクタが仮想になっているわけではありません。
派生クラスごとに適切なオブジェクトを作るための設計パターンです。
C++の抽象クラスとインターフェース
インターフェースのように使える
C++には、JavaやC#のような専用のinterfaceキーワードはありません。
その代わりに、純粋仮想関数を持つ抽象クラスを使うことで、インターフェースのような設計を実現できます。
たとえば、「描画できるもの」「保存できるもの」「ログ出力できるもの」などの共通操作を抽象クラスとして定義できます。
呼び出し側は具体的なクラスではなく、共通インターフェースに依存して処理できます。
JavaやC#のinterfaceと完全に同じではない
C++の抽象クラスは、JavaやC#のinterfaceと似た役割を持ちますが、完全に同じではありません。
C++の抽象クラスは、純粋仮想関数だけでなく、通常のメンバ関数やデータメンバを持つこともできます。
また、コンストラクタやprotectedメンバを持つこともできます。
純粋なインターフェースとして使いたい場合は、データメンバを持たせず、純粋仮想関数と仮想デストラクタを中心に構成することが一般的です。
純粋仮想関数を使うメリット
設計意図が明確になる
純粋仮想関数を使うと、派生クラスに必要な機能を明確にできます。
基底クラスを見るだけで、派生クラスがどのような操作を提供すべきか分かります。
複数人で開発する場合や、長期的に保守する場合にも、設計意図が伝わりやすくなります。
共通の型として扱える
純粋仮想関数を持つ基底クラスを使うと、異なる派生クラスを共通の型として扱えます。
円、長方形、三角形をすべて図形として扱うことができます。
コンソール出力、ファイル出力、データベース出力をすべてログ出力として扱うこともできます。
このように、具体的な種類が異なっていても、共通の操作に基づいて処理できます。
拡張しやすい設計になる
純粋仮想関数を使うと、新しい派生クラスを追加しやすくなります。
既存の共通インターフェースに従って新しいクラスを作れば、呼び出し側の処理を大きく変更せずに機能を追加できます。
このような設計は、保守性や拡張性の高いプログラムにつながります。
テストしやすくなる
純粋仮想関数を使ってインターフェースを分離しておくと、テスト用の実装を作りやすくなります。
本番用の実装とは別に、テスト用の簡易的な実装を用意できます。
外部サービス、ファイル操作、データベース接続などに依存する処理でも、テスト時には代替実装に差し替えることができます。
これにより、単体テストがしやすくなります。
純粋仮想関数を使う際の注意点
何でも抽象化すればよいわけではない
純粋仮想関数は便利ですが、必要以上に使うと設計が複雑になります。
派生クラスが1つしかなく、今後も増える予定がない場合は、抽象クラスを作る必要がないこともあります。
抽象化は、複数の実装を共通に扱いたい場合や、処理を差し替える必要がある場合に効果を発揮します。
実行時コストがある
仮想関数は、実行時に呼び出し先を決定します。
そのため、通常の関数呼び出しよりもわずかなコストがあります。
多くのプログラムでは大きな問題になりませんが、ゲームエンジン、組込みシステム、リアルタイム処理など、高い性能が求められる場面では考慮が必要です。
所有権と寿命管理に注意する
抽象クラスは、ポインタや参照を通じて扱われることが多くあります。
そのため、オブジェクトの所有権や寿命管理に注意が必要です。
現代C++では、手動でメモリを管理するよりも、スマートポインタを使うことが一般的です。
ただし、スマートポインタを使う場合でも、基底クラス経由で派生クラスを削除する可能性があるなら、基底クラスのデストラクタは仮想にしておく必要があります。
よくある間違い
仮想デストラクタを用意していない
抽象クラスを基底クラスとして使う場合、仮想デストラクタを用意していないと危険です。
基底クラスのポインタ経由で派生クラスのオブジェクトを削除する場合、仮想デストラクタがないと未定義動作になる可能性があります。
ポリモーフィズムで使う基底クラスには、仮想デストラクタを用意するのが基本です。
overrideを付けていない
派生クラスで仮想関数を上書きするときに override を付けないと、ミスに気づきにくくなります。
関数名のタイプミス、引数の違い、constの不一致などによって、オーバーライドしたつもりの関数が別の関数として扱われることがあります。
override を付けることで、このようなミスをコンパイル時に検出できます。
constの不一致
基底クラス側でconst付きの関数として宣言されている場合、派生クラス側でもconstを付ける必要があります。
constを忘れると、正しくオーバーライドされません。
このミスは見た目では分かりにくいため、override を使って検出することが重要です。
引数の型が違う
基底クラスの関数と派生クラスの関数で引数の型が異なる場合、オーバーライドにはなりません。
同じ関数名であっても、引数の型が違えば別の関数として扱われます。
このようなミスも、override を付けていればコンパイル時に確認できます。
純粋仮想関数を使うべき場面
複数の実装を同じ方法で扱いたい場合
複数のクラスに共通する操作があり、それぞれの具体的な処理が異なる場合、純粋仮想関数が有効です。
図形、決済方法、ログ出力、ファイル形式、描画処理など、共通の操作を持つ複数の実装を扱う場面に向いています。
実装を後から差し替えたい場合
本番用、テスト用、開発用など、状況に応じて実装を差し替えたい場合にも有効です。
共通インターフェースを用意しておけば、呼び出し側を変更せずに実装だけを入れ替えられます。
呼び出し側を具体的な実装から切り離したい場合
呼び出し側が具体的なクラスに強く依存していると、実装変更の影響が大きくなります。
純粋仮想関数を使ってインターフェースに依存する設計にすると、具体的な実装との結びつきを弱められます。
その結果、変更に強く、保守しやすい設計になります。
純粋仮想関数を使わなくてもよい場面
派生クラスが増える予定がない場合
派生クラスが1つしかなく、今後も増える予定がない場合は、純粋仮想関数を使う必要がないことがあります。
不要な抽象化は、コードの見通しを悪くする原因になります。
単純なデータ構造を扱う場合
データを保持するだけの単純なクラスでは、純粋仮想関数は必要ない場合が多いです。
純粋仮想関数は、動作を抽象化したい場合に使うものです。
単なるデータのまとまりに対して使うと、設計が不自然になることがあります。
性能を極限まで重視する場合
仮想関数には実行時ディスパッチのコストがあります。
通常は小さなコストですが、非常に高頻度で呼び出される処理では影響する可能性があります。
性能が極めて重要な処理では、テンプレートや静的ポリモーフィズムなど、別の設計を検討することもあります。
まとめ
C++の純粋仮想関数は、基底クラスで共通のインターフェースを定義し、具体的な処理を派生クラスに任せるための仕組みです。
純粋仮想関数を1つでも持つクラスは抽象クラスになり、直接オブジェクトを作ることはできません。
抽象クラスは、派生クラスに共通のルールを与える設計図として使われます。
純粋仮想関数を使うことで、異なるクラスを共通の型として扱えるようになります。
また、ポリモーフィズムによって、同じ操作でも実際のオブジェクトに応じた処理を実行できます。
実務では、抽象基底クラスに仮想デストラクタを用意し、派生クラスで仮想関数を上書きするときは override を付けることが重要です。
純粋仮想関数は、複数の実装を共通に扱いたい場合、処理を差し替えたい場合、呼び出し側を具体的な実装から切り離したい場合に有効です。
一方で、不要な抽象化は設計を複雑にします。
使う場面を見極めることで、柔軟で拡張しやすいC++プログラムを設計しやすくなります。
以上、C++の純粋仮想関数についてでした。
最後までお読みいただき、ありがとうございました。
