C++のポリモーフィズムについて

AI実装検定のご案内

C++のポリモーフィズムとは、同じインターフェースを使っていても、実際のオブジェクトの種類に応じて異なる処理を実行できる仕組みのことです。

日本語では「多態性」と呼ばれます。

たとえば、「動物が鳴く」という共通の操作があるとします。

犬であれば犬の鳴き声、猫であれば猫の鳴き声、鳥であれば鳥の鳴き声になります。

呼び出す側は「鳴く」という操作だけを知っていればよく、具体的に犬なのか猫なのかを細かく判定しなくても、それぞれに合った処理を実行できます。

このように、共通の型や共通の操作を通じて、実体ごとに異なる動作をさせる考え方がポリモーフィズムです。

目次

C++におけるポリモーフィズムの種類

C++のポリモーフィズムは、大きく分けると次の2種類があります。

静的ポリモーフィズム

静的ポリモーフィズムは、コンパイル時に呼び出される処理が決まるポリモーフィズムです。

代表的なものには、関数オーバーロード、演算子オーバーロード、テンプレートなどがあります。

同じ名前の関数でも、引数の型や数によって呼び出される処理が変わる場合があります。

また、テンプレートを使うと、型に依存しない汎用的な処理を書きながら、コンパイル時には具体的な型に応じたコードが生成されます。

静的ポリモーフィズムは実行時ではなくコンパイル時に解決されるため、一般的に実行時のオーバーヘッドが少ないという特徴があります。

動的ポリモーフィズム

動的ポリモーフィズムは、実行時に実際のオブジェクトの型に応じて呼び出される処理が決まるポリモーフィズムです。

C++では、主に仮想関数、継承、基底クラスのポインタまたは参照を使って実現します。

一般的にC++で「ポリモーフィズム」と言う場合、特にこの動的ポリモーフィズムを指すことが多いです。

ただし、C++ではテンプレートによる静的ポリモーフィズムも非常に重要です。

そのため、文脈によっては「ポリモーフィズム」という言葉が、仮想関数によるものだけでなく、テンプレートによる多態性も含む場合があります。

動的ポリモーフィズムの基本

動的ポリモーフィズムでは、基底クラスを通じて派生クラスの処理を呼び出します。

重要なのは、呼び出し側が具体的なクラスを意識しなくてよいという点です。

たとえば、呼び出し側は「動物」という共通の型として扱います。

しかし、実体が犬であれば犬の処理、猫であれば猫の処理が実行されます。

これにより、呼び出し側のコードを共通化でき、新しい種類のオブジェクトを追加しやすくなります。

動的ポリモーフィズムが働く条件

C++で動的ポリモーフィズムを働かせるには、主に次の条件が必要です。

まず、基底クラス側の関数が仮想関数である必要があります。

仮想関数にすることで、派生クラスで上書きされた関数を実行時に呼び分けられるようになります。

次に、基底クラスのポインタまたは参照を通じて関数を呼び出す必要があります。

この点は非常に重要です。

単に派生クラスのオブジェクトを基底クラス型の変数にコピーしてしまうと、派生クラス固有の部分が失われる可能性があります。これをオブジェクトスライシングと呼びます。

つまり、動的ポリモーフィズムを正しく使うには、仮想関数を用意し、基底クラスのポインタまたは参照を通じて呼び出すことが基本になります。

virtualの役割

C++で動的ポリモーフィズムを実現するうえで中心になるのが、virtual です。

基底クラスの関数に virtual を付けることで、その関数は仮想関数になります。

仮想関数は、基底クラスのポインタや参照を通じて呼び出されたときに、実際のオブジェクトの型に応じて呼び出す関数を切り替えます。

virtualがある場合

virtual がある場合、基底クラスのポインタや参照で扱っていても、実体が派生クラスであれば、その派生クラス側の処理が呼ばれます。

つまり、変数の見かけ上の型ではなく、実際に参照しているオブジェクトの型に応じて処理が決まります。

これが動的ポリモーフィズムの基本的な動きです。

virtualがない場合

一方、virtual がない場合は、基底クラスのポインタや参照から呼び出しても、基本的にはその型に応じた関数が呼ばれます。

つまり、実体が派生クラスであっても、基底クラス型として扱っているなら、基底クラス側の関数が呼ばれてしまいます。

そのため、派生クラスごとに処理を切り替えたい場合は、基底クラス側の関数を仮想関数にする必要があります。

overrideの役割

派生クラスで基底クラスの仮想関数を上書きするときは、override を使うのが一般的です。

override は、派生クラス側の関数が「基底クラスの仮想関数を正しく上書きしている」ことをコンパイラに確認させるためのものです。

overrideを使うメリット

override を付けると、関数名、引数、戻り値、const の有無などが基底クラス側と一致しているかをコンパイラがチェックしてくれます。

もし上書きしたつもりの関数が、実際には基底クラスの関数と一致していなければ、コンパイルエラーになります。

これにより、意図しないミスを早い段階で発見できます。

overrideは実務では基本的に付けるべき

override は必須ではありませんが、C++11以降では、仮想関数を上書きするときに付けるのが一般的です。

特に実務では、関数名の打ち間違い、引数の違い、const の付け忘れなどが原因で、意図したオーバーライドになっていないケースがあります。

override を付けておけば、そうしたミスをコンパイラが検出してくれるため、安全性が高まります。

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

ポリモーフィズムでは、基底クラスに具体的な処理を書かず、「派生クラスが必ず実装すべき操作」だけを定義することがあります。

このとき使うのが純粋仮想関数です。

純粋仮想関数とは

純粋仮想関数とは、基底クラス側で「この関数は派生クラスで実装されるべきもの」と示すための仮想関数です。

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

つまり、そのクラスは具体的なオブジェクトを作るためのクラスではなく、共通のインターフェースを定義するためのクラスになります。

抽象クラスとは

純粋仮想関数を1つでも持つクラスは、抽象クラスになります。

抽象クラスは、具体的なオブジェクトを直接作ることはできません。

派生クラスが純粋仮想関数を実装することで、初めて具体的なクラスとして使えるようになります。

たとえば、「動物」という概念だけでは具体的な鳴き声を決められません。

しかし、「犬」や「猫」であれば具体的な鳴き声を決められます。

このような場合、「動物」は抽象クラス、「犬」や「猫」は具体クラスとして設計できます。

純粋仮想関数にも実装を持たせることはできる

少し発展的な話ですが、C++では純粋仮想関数にも実装を持たせることができます。

ただし、実装を持っていても、純粋仮想関数である限り、そのクラスは抽象クラスです。

そのため、初心者の段階では、純粋仮想関数は「派生クラスで実装を強制するための仕組み」と理解しておくとよいです。

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

C++のポリモーフィズムでは、基底クラスのデストラクタを仮想デストラクタにすることが非常に重要です。

特に、基底クラスのポインタを通じて派生クラスのオブジェクトを削除する可能性がある場合、基底クラスのデストラクタは必ず仮想にするべきです。

仮想デストラクタが必要な理由

基底クラスのポインタで派生クラスのオブジェクトを扱う場合、そのポインタを通じて削除することがあります。

このとき、基底クラスのデストラクタが仮想でないと、C++では未定義動作になります。

初心者向けには「派生クラスのデストラクタが正しく呼ばれない可能性がある」と説明されることもありますが、厳密にはそれ以上に問題で、規格上は未定義動作です。

ポリモーフィックな基底クラスでは仮想デストラクタを用意する

仮想関数を持つ基底クラス、つまりポリモーフィックに使うことを想定している基底クラスでは、基本的に仮想デストラクタを用意します。

これにより、基底クラスのポインタやスマートポインタを通じてオブジェクトを破棄しても、派生クラス側の後始末が正しく行われます。

スマートポインタを使う場合も同じ

現代C++では、手動でメモリを管理するよりも、スマートポインタを使うことが多いです。

しかし、スマートポインタを使う場合でも、基底クラスのデストラクタが仮想であることは重要です。

たとえば、基底クラス型のスマートポインタに派生クラスのオブジェクトを持たせる場合、スマートポインタが破棄されるときには基底クラス型を通じて削除が行われます。

そのため、基底クラスのデストラクタが仮想でないと問題になります。

オブジェクトスライシングに注意

C++のポリモーフィズムでよくある落とし穴が、オブジェクトスライシングです。

オブジェクトスライシングとは、派生クラスのオブジェクトを基底クラス型の変数に値として代入したときに、派生クラス固有の部分が切り落とされてしまう現象です。

値として扱うと派生クラス部分が失われる

派生クラスのオブジェクトを基底クラス型の変数にコピーすると、基底クラスに含まれる部分だけがコピーされます。

その結果、派生クラスの情報は失われます。

この状態で仮想関数を呼び出しても、すでに実体は基底クラス部分だけになっているため、期待した派生クラスの処理は呼ばれません。

ポリモーフィズムでは参照かポインタを使う

動的ポリモーフィズムを使いたい場合は、値渡しではなく、基底クラスの参照またはポインタを使うのが基本です。

関数の引数でも同じです。

基底クラス型の値として受け取るとスライシングが起きる可能性があります。

ポリモーフィズムを維持したいなら、基底クラスの参照、またはポインタで受け取るべきです。

特に、所有権を持たない場合は参照を使うのが自然です。

対象が存在しない可能性がある場合や、所有権を扱う場合はポインタやスマートポインタを検討します。

ポリモーフィズムのメリット

ポリモーフィズムを使うことで、コードの柔軟性や拡張性を高めることができます。

特に、複数の種類のオブジェクトを共通の方法で扱いたい場合に有効です。

呼び出し側のコードを共通化できる

ポリモーフィズムを使うと、呼び出し側は具体的なクラスを意識せずに、共通のインターフェースだけを使って処理できます。

たとえば、犬、猫、鳥などを個別に判定するのではなく、「動物として鳴かせる」という共通の操作だけを呼び出せます。

これにより、呼び出し側のコードがシンプルになります。

新しいクラスを追加しやすい

ポリモーフィズムを使うと、新しい派生クラスを追加しやすくなります。

たとえば、既存の設計に新しい種類の動物を追加したい場合でも、共通のインターフェースに従って新しいクラスを作れば、既存の呼び出し側コードを大きく変更せずに済みます。

この性質は、変更に強い設計を作るうえで重要です。

if文やswitch文を減らせる

ポリモーフィズムを使うと、型ごとの条件分岐を減らせます。

具体的な種類ごとに条件分岐して処理を切り替えるのではなく、それぞれのクラスに処理を持たせることで、呼び出し側は共通の操作を呼ぶだけで済みます。

ただし、すべての条件分岐をポリモーフィズムに置き換えるべきという意味ではありません。

種類が少なく、今後増える予定もない場合は、単純な条件分岐の方が分かりやすいこともあります。

インターフェース中心の設計ができる

ポリモーフィズムを使うと、具体的な実装ではなく、抽象的なインターフェースに依存する設計ができます。

たとえば、支払い処理であれば、クレジットカード決済、銀行振込、QR決済などの具体的な方法に直接依存するのではなく、「支払う」という共通のインターフェースに依存できます。

これにより、後から支払い方法を追加したり、テスト用の実装に差し替えたりしやすくなります。

ポリモーフィズムのデメリット

ポリモーフィズムは便利ですが、常に使えばよいわけではありません。

設計を間違えると、かえってコードが複雑になることもあります。

実行時コストがある

動的ポリモーフィズムでは、仮想関数呼び出しによる実行時コストが発生する場合があります。

通常の関数呼び出しと比べると、実際に呼び出す関数を実行時に決める必要があるためです。

ただし、多くのアプリケーションでは、このコストが問題になることは少ないです。

一方で、ゲームエンジン、組み込み開発、高頻度で呼ばれる処理などでは、仮想関数呼び出しのコストやキャッシュ効率を意識する場合があります。

また、コンパイラの最適化によって、実体の型が明確な場合には仮想関数呼び出しが通常の呼び出しのように最適化されることもあります。

処理の流れが追いにくくなる場合がある

ポリモーフィズムでは、同じ呼び出しでも実体の型によって実行される処理が変わります。

そのため、コードを読むときに「実際にはどのクラスの処理が呼ばれるのか」を追いにくくなることがあります。

小規模なコードでは問題になりにくいですが、大規模な継承階層では、処理の流れを把握しづらくなる場合があります。

継承設計が複雑になりやすい

ポリモーフィズムは継承と組み合わせて使われることが多いですが、継承は慎重に使う必要があります。

本当に「派生クラスは基底クラスの一種である」と言える関係なのかを考える必要があります。

継承が自然でない場合は、無理にポリモーフィズムを使うよりも、コンポジション、関数オブジェクト、テンプレート、std::variant などを使った方がよい場合もあります。

静的ポリモーフィズムとの違い

C++では、動的ポリモーフィズムだけでなく、静的ポリモーフィズムも重要です。

両者の違いを理解すると、どの場面でどちらを使うべきか判断しやすくなります。

動的ポリモーフィズムの特徴

動的ポリモーフィズムは、実行時に呼び出す処理が決まります。

基底クラスのポインタや参照を通じて、実体の型に応じた処理を呼び出します。

柔軟性が高く、実行時に異なる種類のオブジェクトを同じように扱いたい場合に向いています。

一方で、仮想関数呼び出しによる実行時コストや、継承設計の複雑さには注意が必要です。

静的ポリモーフィズムの特徴

静的ポリモーフィズムは、コンパイル時に呼び出す処理が決まります。

C++では、特にテンプレートによるポリモーフィズムが代表的です。

テンプレートを使うと、さまざまな型に対応する汎用的な処理を書けます。

実行時に仮想関数を呼び分けるのではなく、コンパイル時に型ごとのコードが生成されます。

そのため、実行時のオーバーヘッドを抑えやすいという特徴があります。

使い分けの考え方

実行時に異なる種類のオブジェクトをまとめて扱いたい場合は、動的ポリモーフィズムが向いています。

一方、型がコンパイル時に分かっており、性能や汎用性を重視したい場合は、静的ポリモーフィズムが向いています。

ただし、どちらが常に優れているというものではありません。

設計の目的、変更の頻度、性能要件、所有権の扱いなどを考えて選ぶ必要があります。

std::variantとの違い

C++17以降では、std::variant を使って複数の型を扱う設計もあります。

これは仮想関数によるポリモーフィズムとは異なるアプローチです。

仮想関数は型の追加に強い

仮想関数を使った設計では、新しい派生クラスを追加しやすいという特徴があります。

共通の基底クラスに従って新しいクラスを作れば、既存の呼び出し側コードを大きく変更せずに済むことが多いです。

そのため、「今後、種類が増える可能性がある」設計では、仮想関数によるポリモーフィズムが向いている場合があります。

std::variantは処理の追加に強い傾向がある

std::variant は、扱う型の候補があらかじめ決まっている場合に向いています。

型の種類が固定されている代わりに、それらに対する処理を追加しやすいという特徴があります。

ただし、新しい型を追加する場合は、std::variant の定義や関連する処理を修正する必要が出ることがあります。

どちらを選ぶべきか

仮想関数は型の追加に強く、std::variant は処理の追加に強い傾向があります。

ただし、これはあくまで目安です。

実際には、変更されやすいのが「型」なのか「処理」なのか、性能要件はどの程度か、所有権をどう扱うか、設計をどこまで単純に保ちたいかによって、適切な選択は変わります。

vtableと内部実装について

C++の仮想関数は、多くの処理系でvtableと呼ばれる仕組みによって実装されています。

vtableは、仮想関数の呼び出し先を管理するためのテーブルのようなものです。

vtableは一般的な実装方法

仮想関数を呼び出すとき、プログラムは実際のオブジェクトに対応する関数を探し、その関数を呼び出します。

多くのC++コンパイラでは、この仕組みをvtableやvptrのような構造で実現しています。

これにより、基底クラスのポインタや参照から呼び出しても、実体の型に応じた関数を実行できます。

C++標準がvtableを義務付けているわけではない

ただし、厳密には、C++標準は「仮想関数は必ずvtableで実装しなければならない」と定めているわけではありません。

C++標準が定めているのは、仮想関数呼び出しの振る舞いです。

つまり、実体の型に応じて適切な関数が呼ばれることが重要であり、その内部実装方法は処理系に任されています。

そのため、vtableの説明は「多くの処理系で使われている一般的な仕組み」として理解すると正確です。

ポリモーフィズムを使うべき場面

ポリモーフィズムは、次のような場面で特に有効です。

共通の操作を持つ複数の型を扱いたい場合

複数の型に共通の操作があり、それぞれの型で処理内容が異なる場合、ポリモーフィズムが有効です。

たとえば、図形、動物、支払い方法、ログ出力先、通知方法、ファイル形式などは、ポリモーフィズムと相性が良い例です。

呼び出し側を具体クラスから切り離したい場合

呼び出し側が具体的なクラスに依存していると、新しい種類を追加したときに呼び出し側も変更しなければならないことがあります。

ポリモーフィズムを使えば、呼び出し側は抽象的なインターフェースに依存できるため、具体的な実装を差し替えやすくなります。

テストしやすい設計にしたい場合

ポリモーフィズムを使うと、本番用の実装とテスト用の実装を差し替えやすくなります。

たとえば、本番環境では実際のログ出力を使い、テスト環境ではテスト用のログ出力を使う、といった設計がしやすくなります。

このような柔軟性は、保守性の高いコードを書くうえで重要です。

ポリモーフィズムを使わない方がよい場面

ポリモーフィズムは便利ですが、使いどころを間違えると設計が複雑になります。

種類がほとんど増えない場合

扱う種類が少なく、今後も増える可能性が低い場合は、単純な条件分岐の方が分かりやすいことがあります。

無理に抽象クラスや継承階層を作ると、かえって読みづらくなる可能性があります。

継承関係が自然でない場合

ポリモーフィズムは継承と組み合わせて使うことが多いため、継承関係が自然かどうかを考える必要があります。

「AはBの一種である」と言えない場合、継承よりもコンポジションを使った方がよい場合があります。

性能が非常に重要な場合

仮想関数呼び出しには、わずかながら実行時コストが発生する可能性があります。

通常のアプリケーションでは大きな問題になりませんが、極めて高頻度に呼び出される処理では、テンプレートやデータ指向設計など、別の方法を検討することがあります。

よくあるミス

C++のポリモーフィズムでは、いくつか典型的なミスがあります。

virtualを付け忘れる

基底クラス側の関数に virtual を付け忘れると、基底クラスのポインタや参照を通じて呼び出しても、実体に応じた処理が呼ばれません。

動的ポリモーフィズムを使うつもりなら、基底クラス側の関数を仮想関数にする必要があります。

仮想デストラクタを用意しない

ポリモーフィックに使う基底クラスで、デストラクタを仮想にしないのは危険です。

基底クラスのポインタを通じて派生クラスのオブジェクトを削除すると、未定義動作になります。

仮想関数を持つ基底クラスでは、原則として仮想デストラクタを用意するべきです。

値渡ししてしまう

基底クラス型の値として派生クラスのオブジェクトを渡すと、オブジェクトスライシングが起きる可能性があります。

ポリモーフィズムを維持したい場合は、基底クラスの参照またはポインタを使う必要があります。

overrideを付けない

派生クラスで仮想関数を上書きするときに override を付けないと、意図したオーバーライドになっていない場合でも気づきにくくなります。

特に、関数名の打ち間違い、引数の違い、const の付け忘れなどはよくあるミスです。

override を付けることで、コンパイラがミスを検出してくれます。

public継承を忘れる

C++では、class を使った継承はデフォルトで private 継承になります。

通常の「派生クラスは基底クラスの一種である」という関係を表したい場合は、public 継承にする必要があります。

一方、struct の継承はデフォルトで public です。

この違いは初心者が間違えやすいポイントです。

実務での考え方

C++でポリモーフィズムを使うときは、単に文法を知っているだけでなく、設計上の意図を考えることが重要です。

まずはインターフェースを意識する

ポリモーフィズムでは、「具体的に何であるか」よりも、「どのような操作ができるか」を重視します。

呼び出し側が必要としている操作を抽象化し、それを基底クラスとして表現します。

このとき、基底クラスは共通の契約のような役割を持ちます。

所有権を明確にする

ポリモーフィズムでは、ポインタや参照を使うことが多いため、所有権の扱いが重要になります。

所有しない場合は参照や生ポインタを使うことがあります。

単独で所有する場合は std::unique_ptr を使うのが基本です。

複数箇所で所有を共有する必要がある場合だけ、std::shared_ptr を検討します。

不用意に共有所有にすると、オブジェクトの寿命が分かりにくくなるため注意が必要です。

継承よりコンポジションがよい場合もある

すべてを継承で表現する必要はありません。

オブジェクト同士の関係が「AはBの一種である」ではなく、「AはBを持っている」なら、継承よりもコンポジションの方が自然です。

ポリモーフィズムは強力ですが、使いすぎると設計が複雑になります。

そのため、必要な場面で適切に使うことが大切です。

まとめ

C++のポリモーフィズムは、同じインターフェースを通じて、実際のオブジェクトの種類に応じた処理を実行する仕組みです。

特に、仮想関数を使った動的ポリモーフィズムでは、基底クラスのポインタや参照を通じて派生クラスの処理を呼び出せます。

重要なポイントは次の通りです。

動的ポリモーフィズムを使うには、基底クラス側の関数を仮想関数にする必要があります。

実際の型に応じた処理を呼び出すには、基底クラスのポインタまたは参照を通じて呼び出す必要があります。

派生クラス側では、オーバーライドのミスを防ぐために override を使うのが基本です。

純粋仮想関数を使うと、共通インターフェースを表す抽象クラスを作れます。

ポリモーフィックに使う基底クラスでは、仮想デストラクタを用意する必要があります。

値渡しをするとオブジェクトスライシングが起きるため、ポリモーフィズムを使う場合は参照やポインタを使います。

仮想関数によるポリモーフィズムは型の追加に強く、テンプレートや std::variant などとは異なる特徴を持ちます。

ポリモーフィズムは、柔軟で拡張しやすい設計を作るための強力な仕組みです。

ただし、何でもポリモーフィズムで設計すればよいわけではありません。

継承関係が自然か、変更に強い設計になっているか、所有権が明確か、処理が追いやすいかを考えながら使うことが重要です。

以上、C++のポリモーフィズムについてでした。

最後までお読みいただき、ありがとうございました。

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!
目次