C++の多重継承について

AI実装検定のご案内

C++の多重継承とは、1つのクラスが複数の基底クラスを同時に継承できる仕組みです。

たとえば、あるクラスに「描画できる」「クリックできる」「保存できる」といった複数の役割を持たせたい場合、多重継承を使うことで、それぞれの役割を別々の基底クラスとして表現できます。

C++は、クラスそのものの多重継承をサポートしている言語です。

JavaやC#ではクラスの多重継承はできず、主にインターフェースを複数実装することで似た設計を行います。

一方、C++では複数のクラスを直接継承できるため、柔軟な設計が可能です。

ただし、その分だけ設計が複雑になりやすく、名前の衝突、ダイヤモンド継承、コンストラクタの順序、ポインタ変換、オブジェクトレイアウトなど、注意すべき点も多くあります。

目次

多重継承の基本的な考え方

複数の役割を1つのクラスに持たせる

多重継承は、単に「親クラスを複数持てる」という機能ではありません。

実務的には、1つのクラスに複数の役割や性質を持たせるために使われることが多いです。

たとえば、画面上のボタンを考えると、そのボタンは「描画できるもの」であり、「クリックできるもの」でもあります。

このような場合、「描画できる」という役割と「クリックできる」という役割を別々の基底クラスとして定義し、それらをボタンが継承するという設計ができます。

このように、多重継承は「複数の性質を合成する」ための仕組みとして使うと理解しやすいです。

インターフェースの合成として使うのが安全

C++で多重継承を使う場合、最も安全で分かりやすい使い方は、複数のインターフェースを実装する用途です。

C++にはJavaのような interface キーワードはありません。

その代わりに、純粋仮想関数だけを持つ抽象クラスをインターフェースのように使います。

たとえば、「読み込めるもの」と「書き込めるもの」という2つのインターフェースを用意し、ファイルクラスがその両方を実装する、という設計が考えられます。

このような多重継承は、実装の重複や状態の衝突が起きにくいため、比較的安全です。

C++で多重継承を使うなら、まずは「複数のインターフェースを実装するためのもの」と考えるのがよいです。

多重継承が向いている場面

複数の抽象インターフェースを実装する場合

多重継承が最も自然に使えるのは、複数の抽象インターフェースを1つのクラスが実装する場合です。

たとえば、ゲーム開発では、あるオブジェクトが「描画できる」「更新できる」「衝突判定できる」といった複数の役割を持つことがあります。

このような場合、それぞれの役割を抽象クラスとして分けておき、プレイヤーや敵キャラクターなどが必要な役割を継承する設計ができます。

この設計では、各インターフェースが明確な責務を持つため、コードの見通しがよくなります。

Mixin的に機能を追加する場合

多重継承は、Mixinのような使い方にも向いています。

Mixinとは、クラスに小さな機能を追加するための補助的なクラスです。

たとえば、「コピー禁止にする」「ログ出力機能を持たせる」「参照カウント機能を持たせる」といった用途で使われることがあります。

ただし、Mixinとして多重継承を使う場合は、各Mixinが独立していて、互いに状態や名前を衝突させないように設計する必要があります。

便利な反面、使いすぎるとクラスの性質が分かりにくくなるため注意が必要です。

フレームワークやライブラリ内部の設計

C++のフレームワークやライブラリでは、多重継承が内部設計に使われることがあります。

たとえば、GUIフレームワーク、COM、ゲームエンジン、組み込み系のライブラリなどでは、複数の役割を型として表現したい場面があります。

このような低レベルまたは大規模な設計では、多重継承の柔軟性が役立つことがあります。

ただし、一般的なアプリケーションコードでは、むやみに使うよりも、コンポジションや単一継承で済ませた方が分かりやすい場合も多いです。

多重継承で起きやすい問題

名前の衝突

多重継承では、複数の基底クラスに同じ名前のメンバ関数やメンバ変数があると、どちらを使うべきか分からなくなることがあります。

たとえば、2つの基底クラスがどちらも同じ名前の関数を持っている場合、派生クラスのオブジェクトからその関数を呼ぼうとすると、コンパイラはどちらの関数を呼べばよいか判断できません。

この場合は、どちらの基底クラスのメンバを使うのかを明示する必要があります。

または、派生クラス側で同じ名前の関数を定義し、そこでどちらを使うかを決める方法もあります。

名前の衝突は、多重継承で最も分かりやすく発生する問題の1つです。

状態の重複

実装を持つクラスを複数継承すると、それぞれの基底クラスがメンバ変数を持っている場合があります。

その場合、派生クラスの中には複数の基底クラス部分が含まれるため、状態が複雑になりやすいです。

特に、似たような意味のメンバ変数が複数の基底クラスにある場合、「どの状態が正しいのか」「どちらを更新すべきなのか」が分かりにくくなります。

そのため、実装と状態を持つクラスを複数継承する設計は、慎重に行う必要があります。

ダイヤモンド継承問題

多重継承で特に有名なのが、ダイヤモンド継承問題です。

これは、ある共通の基底クラスを、複数の中間クラスがそれぞれ継承し、さらにそれらの中間クラスを1つの派生クラスが継承する形です。

構造を図にすると、上に共通基底クラスがあり、そこから左右に中間クラスが分かれ、最後にそれらが1つの派生クラスに合流するため、ダイヤモンドのような形になります。

この場合、何も対策しないと、最終的な派生クラスの中に共通基底クラスの部分が2つ存在することがあります。

その結果、共通基底クラスのメンバにアクセスしようとしたとき、どちらの基底部分を指しているのかが曖昧になります。

仮想継承の必要性

ダイヤモンド継承問題を解決するために、C++には仮想継承という仕組みがあります。

仮想継承を使うと、複数の経路から同じ基底クラスを継承していても、最終的な派生クラスの中には共通基底クラスの部分が1つだけ存在するようになります。

これにより、共通基底クラスのメンバが重複せず、曖昧さを避けられます。

ただし、仮想継承を使うと、オブジェクトの内部構造やコンストラクタの呼び出し規則が複雑になります。

そのため、仮想継承は便利な機能ですが、必要な場合に限定して使うべきです。

コンストラクタとデストラクタの注意点

コンストラクタは継承リストの順に呼ばれる

多重継承では、基底クラスのコンストラクタが順番に呼ばれます。

ここで重要なのは、実際の呼び出し順序は、派生クラスの初期化リストに書いた順番ではなく、クラス定義で基底クラスを並べた順番によって決まるという点です。

つまり、ある派生クラスが最初に基底クラスAを継承し、次に基底クラスBを継承しているなら、Aのコンストラクタが先に呼ばれ、その後にBのコンストラクタが呼ばれます。

最後に派生クラス自身のコンストラクタが実行されます。

これは単一継承よりも順序を意識する場面が増えるため、多重継承では特に注意が必要です。

デストラクタは逆順に呼ばれる

デストラクタは、コンストラクタとは逆の順番で呼ばれます。

つまり、派生クラス自身のデストラクタが先に実行され、その後、基底クラスのデストラクタが継承リストの逆順に呼ばれます。

この順序は、オブジェクトが構築された順序と逆に破棄されるという考え方に基づいています。

多重継承では複数の基底クラスが関係するため、どの順番でリソースが解放されるのかを意識する必要があります。

仮想基底クラスは最派生クラスが初期化する

仮想継承を使っている場合、仮想基底クラスの初期化は、実際に生成される最も派生したクラスが担当します。

たとえば、共通基底クラスを仮想継承している中間クラスがあり、さらにその中間クラスを継承する最終的な派生クラスがある場合、共通基底クラスを初期化する責任は最終的な派生クラスにあります。

ただし、中間クラスを単体で生成する場合は、その中間クラス自身が最派生クラスになるため、中間クラスが仮想基底クラスを初期化します。

この点は混乱しやすいですが、基本的には「実際に作られるオブジェクトの最も派生したクラスが仮想基底クラスを初期化する」と覚えるとよいです。

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

基底クラス経由で削除するなら仮想デストラクタが必要

C++では、基底クラスのポインタや参照を通じて派生クラスを扱うことがよくあります。

このとき、基底クラスのポインタを通じて派生クラスのオブジェクトを削除する可能性があるなら、基底クラスのデストラクタは仮想デストラクタにする必要があります。

仮想デストラクタがない状態で、基底クラスのポインタから派生クラスのオブジェクトを削除すると、標準C++上は未定義動作になり得ます。

「危険」というよりも、正しく動く保証がないという意味で、かなり重大な問題です。

インターフェースにも仮想デストラクタを用意する

C++でインターフェース的な抽象クラスを作る場合でも、基本的には仮想デストラクタを用意します。

たとえインターフェースがデータメンバを持っていなくても、将来的に基底クラスのポインタ経由で削除される可能性があるなら、仮想デストラクタが必要です。

特に、多重継承で複数のインターフェースを実装する設計では、それぞれのインターフェースに仮想デストラクタを持たせるのが安全です。

ポインタ変換とオブジェクトレイアウト

基底クラスごとにオブジェクト内の位置が異なることがある

多重継承では、派生クラスのオブジェクトの中に、複数の基底クラス部分が含まれます。

そのため、派生クラスのポインタをある基底クラスのポインタに変換した場合、ポインタのアドレス値が調整されることがあります。

単一継承では、派生クラスの先頭に基底クラス部分があるように見えるケースが多いため、アドレスが同じに見えることもあります。

しかし、多重継承では、2番目以降の基底クラス部分がオブジェクトの途中に配置されることがあるため、基底クラスのポインタに変換したときに指す位置が変わる場合があります。

これはC++の多重継承を理解するうえで重要な点です。

メモリレイアウトは標準では固定されていない

多重継承の説明では、オブジェクトの中に基底クラス部分が並んでいるような図で説明されることがあります。

この図は理解のためには有効ですが、標準C++が具体的なメモリ配置を完全に保証しているわけではありません。

実際のレイアウトは、コンパイラ、ABI、仮想関数の有無、仮想継承の有無、アライメント、最適化などによって変わる可能性があります。

したがって、メモリレイアウトの図はあくまで概念的なものとして理解する必要があります。

仮想関数がある場合はさらに複雑になる

仮想関数を持つクラスでは、多くの実装で仮想関数テーブルにアクセスするための内部的なポインタが使われます。

これにより、オブジェクトの内部構造はさらに複雑になります。

ただし、仮想関数テーブルや内部ポインタの具体的な仕組みは、C++標準が直接規定しているものではありません。

多くの処理系で使われる一般的な実装方式ではありますが、あくまで実装依存の詳細です。

dynamic_castと多重継承

横方向キャストが必要になることがある

多重継承では、ある基底クラスのポインタから、同じ派生クラスを共有する別の基底クラスのポインタへ変換したい場合があります。

たとえば、あるオブジェクトを「描画できるもの」として受け取ったあと、そのオブジェクトが同時に「クリックできるもの」でもあるかを調べたい場合があります。

このような変換は、継承階層を横に移動するような形になるため、横方向キャストと呼ばれることがあります。

dynamic_castにはポリモーフィック型が必要

このような実行時の型変換には、dynamic_cast が使われます。

ただし、dynamic_cast を使って安全に実行時型判定を行うには、変換元の型がポリモーフィック型である必要があります。

ポリモーフィック型とは、少なくとも1つの仮想関数を持つ型のことです。

通常、インターフェースクラスには仮想デストラクタがあるため、この条件を満たします。

変換に成功すれば目的の基底クラスのポインタが得られ、失敗すればヌルポインタになります。

この仕組みにより、多重継承されたオブジェクトを安全に別の基底インターフェースとして扱うことができます。

多重継承とアクセス指定子

public継承

public継承は、「派生クラスは基底クラスの一種である」という関係を表すときに使います。

たとえば、あるクラスが「描画可能なもの」である場合、そのクラスは描画用インターフェースをpublic継承するのが自然です。

public継承では、外部からも派生クラスを基底クラスとして扱うことができます。

多重継承でインターフェースを実装する場合は、基本的にpublic継承を使います。

protected継承

protected継承では、基底クラスのpublicメンバやprotectedメンバが、派生クラス内ではprotectedとして扱われます。

外部からは基底クラスとして扱いにくくなるため、public継承ほど一般的ではありません。

主に、派生クラスやそのさらに派生クラスの内部実装として基底クラスの機能を使いたい場合に検討されます。

private継承

private継承では、基底クラスのpublicメンバやprotectedメンバが、派生クラス内ではprivateとして扱われます。

これは「派生クラスは基底クラスの一種である」というより、「派生クラスが基底クラスの実装を内部的に利用している」という意味に近くなります。

ただし、実務ではprivate継承よりも、基底クラスのオブジェクトをメンバとして持つコンポジションの方が分かりやすいことが多いです。

継承とコンポジションの使い分け

is-a関係なら継承

継承は、「AはBの一種である」と自然に言える場合に使うのが基本です。

たとえば、「ボタンは描画可能なもの」「ファイルストリームは読み込み可能なもの」と言える場合、継承による表現は自然です。

このような関係では、派生クラスを基底クラスとして扱うことに意味があります。

has-a関係ならコンポジション

一方で、「AはBを持っている」という関係なら、継承よりもコンポジションを使う方が自然です。

たとえば、車はエンジンを持っていますが、車はエンジンの一種ではありません。

したがって、車がエンジンを継承する設計は不自然です。

この場合は、車クラスの中にエンジンをメンバとして持たせる方がよい設計になります。

多重継承を使う前には、「これは本当に継承で表すべき関係なのか」を確認することが重要です。

多重継承のメリット

複数の役割を型として表現できる

多重継承を使うと、1つのクラスが複数の役割を持つことを型システム上で表現できます。

これにより、ある関数には「描画できるもの」だけを渡し、別の関数には「保存できるもの」だけを渡す、といった設計が可能になります。

このように、役割を分けて扱えることは、C++の多重継承の大きな利点です。

インターフェースを組み合わせやすい

複数の抽象インターフェースを組み合わせることで、柔軟な設計ができます。

クラスごとに必要な役割だけを実装できるため、巨大な基底クラスを作るよりも、責務を分離しやすくなります。

このような使い方では、多重継承は非常に有効です。

Mixinによる機能追加ができる

小さな補助機能をMixinとして分けておき、必要なクラスに継承させることで、機能を再利用できます。

コピー禁止、ログ機能、参照カウント、比較機能など、独立した性質を追加したい場合に役立ちます。

ただし、Mixinを多用するとクラスの責務が見えにくくなるため、設計上のバランスが重要です。

多重継承のデメリット

設計が複雑になりやすい

多重継承は強力ですが、継承関係が増えるほどクラスの構造が分かりにくくなります。

どの基底クラスからどの機能を受け継いでいるのか、どのメンバがどこで定義されているのかを追いにくくなることがあります。

特に大規模なコードベースでは、多重継承の使い方を誤ると保守性が下がります。

名前衝突が起きやすい

複数の基底クラスに同じ名前のメンバがあると、呼び出しが曖昧になります。

これは単一継承では比較的起きにくい問題ですが、多重継承では頻繁に注意が必要です。

派生クラス側で明示的に解決する、名前を衝突しにくく設計する、責務を分けるなどの工夫が必要です。

ダイヤモンド継承に注意が必要

共通基底クラスを複数経路で継承すると、基底クラス部分が重複する可能性があります。

これを避けるには仮想継承を使いますが、仮想継承は初期化やオブジェクト構造を複雑にします。

そのため、ダイヤモンド継承が出てくる設計では、本当にその継承構造が必要なのかを慎重に検討すべきです。

オブジェクトレイアウトやABIが複雑になる

多重継承では、オブジェクトの内部構造が単一継承よりも複雑になります。

基底クラスへのポインタ変換時にアドレス調整が必要になることもあります。

また、仮想関数や仮想継承が関わると、内部構造はさらに複雑になります。

ライブラリやバイナリ互換性を意識する設計では、この複雑さが問題になる場合があります。

実務での使い方の指針

純粋インターフェースの多重継承は比較的安全

C++で多重継承を使うなら、複数の純粋インターフェースを実装する用途が最も安全です。

この場合、基底クラスが状態を持たないため、状態の重複やダイヤモンド継承の問題が起きにくくなります。

複数の役割を型として表現したい場合には、有力な選択肢になります。

実装を持つクラスの多重継承は慎重に使う

実装や状態を持つクラスを複数継承すると、名前衝突や状態の重複が起きやすくなります。

特に、複数の基底クラスが似たような責務を持っている場合、派生クラスの意味が曖昧になりやすいです。

このような場合は、継承ではなくコンポジションで表現できないかを検討するべきです。

仮想継承は必要な場合だけ使う

仮想継承はダイヤモンド継承問題を解決するための重要な仕組みです。

しかし、仮想継承を使うと、コンストラクタの初期化規則やオブジェクトの内部構造が複雑になります。

したがって、共通基底クラスを本当に1つに共有する必要がある場合に限定して使うのがよいです。

コンポジションを優先する

多重継承で解決できる問題の多くは、コンポジションでも解決できます。

コンポジションは、「あるクラスが別のクラスを部品として持つ」という設計です。

継承よりも依存関係が明確になり、クラス同士の結合も弱くなりやすいです。

「AはBの一種である」と自然に言えない場合は、まずコンポジションを検討するのが安全です。

前回の説明に対する正確性の確認

大きな誤りはない

前回の説明は、C++の多重継承について大筋では正確です。

基本構文、複数インターフェースの実装、名前衝突、ダイヤモンド継承、仮想継承、コンストラクタ順序、ポインタ調整、仮想デストラクタの重要性など、主要なポイントは正しく説明できています。

そのため、入門から実務寄りの理解までを目的とする説明としては、十分に妥当です。

補足した方がよい点はある

ただし、C++として厳密に表現するなら、いくつか補足した方がよい点があります。

まず、基底クラスのポインタを通じて派生クラスのオブジェクトを削除する場合、基底クラスのデストラクタが仮想でないと、単に「危険」なのではなく、未定義動作になり得ます。

また、仮想継承における仮想基底クラスの初期化は、最派生クラスが行います。

ただし、中間クラスを単体で生成する場合は、その中間クラス自身が最派生クラスになるため、そのクラスが仮想基底を初期化します。

さらに、メモリレイアウトの図はあくまで概念図であり、実際の配置は処理系依存です。

標準C++が具体的なバイト配置や仮想関数テーブルの構造を固定しているわけではありません。

実務上の結論は妥当

前回の結論である「多重継承は強力だが、設計を間違えると複雑になる」「インターフェース合成として使うのが最も安全」「実装継承として使う場合は慎重に」という方針は、実務的にも妥当です。

特に、C++に慣れていない段階では、多重継承を「複数の抽象インターフェースを実装するための機能」として使うのが最も分かりやすく、安全です。

まとめ

C++の多重継承は、1つのクラスが複数の基底クラスを継承できる強力な機能です。

複数の役割を型として表現できるため、インターフェースの合成やMixin的な機能追加に役立ちます。

特に、状態を持たない抽象インターフェースを複数実装する用途では、比較的安全に使えます。

一方で、実装や状態を持つクラスを複数継承すると、名前衝突、状態の重複、ダイヤモンド継承、ポインタ調整、オブジェクトレイアウトの複雑化といった問題が起きやすくなります。

ダイヤモンド継承を解決するには仮想継承を使えますが、仮想継承自体もコンストラクタの初期化規則や内部構造を複雑にします。

そのため、多重継承を使うときは、次の方針を意識すると安全です。

  • 複数の純粋インターフェースを実装する用途なら、多重継承は有効です。
  • 実装を持つクラスを複数継承する場合は、慎重に設計する必要があります。
  • 共通基底クラスが重複する場合は、仮想継承を検討します。
  • 「has-a」の関係で表せるなら、継承よりもコンポジションを優先します。
  • 基底クラス経由で削除する可能性があるなら、仮想デストラクタを用意します。

最終的には、C++の多重継承は「使ってはいけない機能」ではありません。

ただし、非常に強力な機能であるため、設計意図が明確な場面で慎重に使うべき機能です。

以上、C++の多重継承についてでした。

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

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