CPP03 1.0
|
仮想継承の仕組みもですが、その前にクラスの継承の仕組みについても理解度が不十分だった。
基底クラスのオブジェクトを作り、基底オブジェクトのメンバーの全てを派生クラスを持つことができる。(クラスの継承で基底オブジェクトの全てのメンバーをコピーするイメージ) さらに、仮想関数の仕組みをを使うことによって、どの仮想関数を呼び出すかを実行時に決定することができる。
先の課題にはなりますが、 仮想関数と仮想継承を組み合わせることで 、複雑な継承階層におけるポリモーフィズムを正しく機能することができる。継承の仕組みには仮想関数によって、継承したメンバー関数をオーバーすることができる。 仮想関数を持つクラスは
vtable
という特殊なテーブルを持ち、そのオブジェクトはvtable
へのポインター(vtpr
- virtual pointer )を内部に持ちます。 仮想関数を呼び出すと、オブジェクトのvptr
を通じて、実行時に適切な関数のアドレスを特定されて、ポリモーフィズムが実現される。
仮想継承の仕組みと、仮想継承でどんな課題を解決できるのか?について。
仕組み
/ \\ /Represents a DiamondTrap robot, inheriting from both FragTrap and ScavTrap.Definition DiamondTrap.hpp:39Represents a FragTrap robot, a specialized type derived from ClapTrap.Definition FragTrap.hpp:38Represents a ScavTrap robot, a specialized type derived from ClapTrap.Definition ScavTrap.hpp:38
コンストラクタの動きについて
DiamondTrap クラスが FragTrap と ScavTrap をどのように継承するかによって、オブジェクトの構造とコンストラクタの動作が大きく異なります。
* オブジェクト構造:
図を書く(未完)
DiamondTrap
オブジェクトは、FragTrap
のすべてのメンバーとScavTrap
のすべてのメンバーを持ちます。FragTrap
とScavTrap
はそれぞれClapTrap
を継承しているため、DiamondTrap
オブジェクト内にはClapTrap
のサブオブジェクトが 2つ 存在します(FragTrap
経由とScavTrap
経由)。
- コンストラクタの動き:
1.
DiamondTrap
のコンストラクタが呼び出されます。
- 初期化リストに従い、まず FragTrap のコンストラクタが呼び出されます。
- 次に
ScavTrap
のコンストラクタが呼び出されます。- それぞれのコンストラクタ内で、さらに
ClapTrap
のコンストラクタが呼び出されます。つまり、ClapTrap
のコンストラクタが 2回 実行されます。DiamondTrap
自身のメンバー変数が初期化されます。
- 問題点:
ClapTrap
のメンバー(例えばname
,hitPoints
など)がDiamondTrap
オブジェクト内に2つ存在するため、どのメンバーにアクセスしたいのかが曖昧になる可能性があります(ダイヤモンド継承問題)。
DiamondTrap オブジェクトは、FragTrap と ScavTrap のすべてのメンバーを持ちますが、ClapTrap のサブオブジェクトは 1つだけ 共有されます。
1. DiamondTrap のコンストラクタが呼び出されます。
- 仮想基底クラス (ClapTrap) のコンストラクタは、最も深い派生クラス (DiamondTrap) のコンストラクタが直接呼び出す責任を持ちます。初期化リストで ClapTrap(...) を呼び出すことで、これが実現されます。
- 初期化リストに従い、FragTrap のコンストラクタが呼び出されます。このとき、FragTrap のコンストラクタリストで ClapTrap のコンストラクタが呼び出されていても、それは無視されます。
- 同様に、ScavTrap のコンストラクタが呼び出され、そのコンストラクタリストの ClapTrap の呼び出しも無視されます。
- DiamondTrap 自身のメンバー変数が初期化されます。
ClapTrap のサブオブジェクトが1つだけなので、メンバーへのアクセスが曖昧になる問題を回避できます。
いいえ、通常継承でも仮想継承でも、FragTrap と ScavTrap のオブジェクトのサブオブジェクトは DiamondTrap オブジェクト内に存在します。仮想継承の主な効果は、共有された共通の祖先クラス (ClapTrap) のサブオブジェクトが1つになる ことです。FragTrap と ScavTrap 自身の固有のメンバーは、DiamondTrap オブジェクト内にそれぞれ存在します。
上記で説明したように、仮想継承は、多重継承において共通の基底クラスのサブオブジェクトが重複して生成されるのを防ぎ、共有された単一のサブオブジェクトを持つようにするための仕組みです。
仮想継承は主に以下の課題を解決します。
共通の基底クラスのメンバーが派生クラスに複数の経路で継承され、データの重複やアクセス時の曖昧さを引き起こす問題を解決します。
複数の派生クラスが共通の概念や状態を共有すべき場合に、その共有を言語レベルで表現し、一貫性を保ちやすくします。
共通の基底クラスのサブオブジェクトが1つになるため、通常継承と比較して、DiamondTrap オブジェクトのサイズが小さくなる可能性があります。特に、基底クラスが大きい場合にメモリ節約の効果が大きくなります。
仮想継承を使用することで、継承の意図がより明確になります。共通の祖先クラスが共有されるべきであることがコード上で明示的に示されます。これにより、クラス間の関係性が理解しやすくなります。
仮想継承には、わずかな実行時オーバーヘッドが発生する可能性があります。これは、共有された仮想基底クラスのサブオブジェクトへのアクセスが、通常の間接参照(ポインタなど)を介して行われるためです。また、コンパイラが仮想継承を管理するための追加の情報をオブジェクト内に保持する場合があります(例えば、仮想ベースポインタ)。しかし、通常はこのオーバーヘッドは軽微であり、得られるメリットと比較して無視できることが多いです。
仮想関数テーブル (vtable) は、仮想関数を持つクラスのオブジェクトが、どの仮想関数を呼び出すべきかを実行時に決定するために使用される仕組みです。
仮想関数を持つクラスは、コンパイラによって vtable と呼ばれる特別なテーブルを持ちます。このテーブルには、そのクラスの仮想関数のアドレスが格納されています。
各オブジェクトは、vtable へのポインタ(vptr - virtual pointer)を内部に持ちます。vptr は、オブジェクトの型に応じて適切な vtable を指します。
仮想関数が呼び出される際、オブジェクトの vptr を通じて vtable が参照され、実行時に適切な関数アドレスが特定されて呼び出されて、ポリモーフィズムが実現されます。
仮想継承自体は vtable と直接的な関係はありませんが、仮想関数と組み合わせて使用されることで、より複雑な継承階層におけるポリモーフィズムを正しく機能させることができます。
table
仮想継承と通常の継承との違い:
通常の継承 (public, protected, private キーワードを使用)
共通の基底クラスの扱い: 派生クラスが複数の経路で共通の基底クラスを継承した場合、派生クラスのオブジェクト内にはその基底クラスのサブオブジェクトが、継承経路の数だけ複数存在します。 コンストラクタの呼び出し: 各継承経路において、派生クラスのコンストラクタから基底クラスのコンストラクタがそれぞれ独立して呼び出されます。 メンバーへのアクセス: 共通の基底クラスから継承されたメンバーへのアクセスが曖昧になる可能性があります(ダイヤモンド継承問題)。曖昧さを解消するには、スコープ解決演算子 (::) を使用して、どの基底クラスのメンバーかを明示的に指定する必要があります。 オブジェクトサイズ: 共通の基底クラスのサブオブジェクトが複数存在するため、オブジェクトサイズが大きくなる可能性があります。 仮想継承 (virtual public, virtual protected キーワードを使用)
共通の基底クラスの扱い: 派生クラスが複数の経路で共通の基底クラスを仮想継承した場合、派生クラスのオブジェクト内にはその基底クラスのサブオブジェクトが 1つだけ 共有されます。 コンストラクタの呼び出し: 仮想基底クラスのコンストラクタは、最も深い派生クラスのコンストラクタが直接呼び出す責任を持ちます。中間の派生クラスのコンストラクタからの呼び出しは無視されます。 メンバーへのアクセス: 共通の基底クラスのメンバーは、曖昧さなしに直接アクセスできます。共有された単一のサブオブジェクトが存在するためです。 オブジェクトサイズ: 共通の基底クラスのサブオブジェクトが1つになるため、通常継承よりもオブジェクトサイズが小さくなる可能性があります(ただし、仮想継承を管理するためのオーバーヘッドが発生する場合もあります)。 目的: ダイヤモンド継承問題を解決し、共通の祖先クラスのメンバーの重複を避けるために使用されます。 まとめ:
通常継承は、各派生クラスが基底クラスの独立したコピーを持つ場合に適しています。一方、仮想継承は、複数の派生クラスが共通の基底クラスの状態や振る舞いを共有する必要がある場合に有効です。仮想継承は、特に多重継承の複雑さを軽減し、より明確で効率的な継承階層を構築するために重要な機能です。
virtual デストラクタとは? 仮想デストラクタ (virtual ~ClassName()) は、基底クラスのポインタを通じて派生クラスのオブジェクトを削除する際に、派生クラスのデストラクタが確実に呼び出されるようにするための仕組みです。
問題点: 基底クラスのポインタが派生クラスのオブジェクトを指している場合、delete 演算子でそのポインタを削除すると、通常は基底クラスのデストラクタのみが呼び出されます。もし派生クラスが独自のメモリ管理(例えば、new で確保したメモリを delete する)を行っている場合、派生クラスのデストラクタが呼び出されないと、メモリリークが発生する可能性があります。 解決策: 基底クラスのデストラクタを virtual として宣言することで、delete 演算子は実行時にオブジェクトの実際の型を調べ、適切なデストラクタ(最も派生したクラスのデストラクタから順に、基底クラスのデストラクタまで)を呼び出すようになります。 原則: 継承される可能性のある基底クラスは、必ず仮想デストラクタを持つべきです。これにより、ポリモーフィズムを利用したオブジェクトの削除が安全に行えるようになります。 ダイナミックキャストとは? ダイナミックキャスト (dynamic_cast) は、実行時にオブジェクトの実際の型をチェックし、安全なダウンキャスト(基底クラスのポインタまたは参照から派生クラスのポインタまたは参照への変換)を行うための C++ のキャスト演算子です。
目的: 静的キャスト (static_cast) はコンパイル時に型の安全性をチェックしますが、ダウンキャストの安全性は保証しません。ダイナミックキャストは、ダウンキャストが安全かどうかを実行時に判断し、安全な場合にのみ変換を行います。 使い方: ポインタの場合: Derived* derivedPtr = dynamic_cast<Derived*>(basePtr); 参照の場合: Derived& derivedRef = dynamic_cast<Derived&>(baseRef); 戻り値: ポインタの場合: 変換が成功した場合は派生クラスのポインタを返し、失敗した場合は nullptr を返します。 参照の場合: 変換が失敗した場合は std::bad_cast 例外をスローします。 条件: ダイナミックキャストが成功するためには、基底クラスが少なくとも一つの仮想関数を持っている必要があります(ポリモーフィックなクラス)。これは、実行時の型情報を利用するために必要です。 protected 継承とは? protected 継承 (class Derived : protected Base) は、派生クラスが基底クラスを継承する際のアクセス指定子の一つです。
public メンバー: 基底クラスの public メンバーは、派生クラス内では protected メンバーとして扱われます。派生クラスのオブジェクトの外部からはアクセスできませんが、派生クラスのメンバー関数や、そのさらに派生したクラスからはアクセスできます。 protected メンバー: 基底クラスの protected メンバーは、派生クラス内ではそのまま protected メンバーとして扱われます。 private メンバー: 基底クラスの private メンバーは、派生クラスからはアクセスできません(public 継承の場合と同様です)。 protected 継承の主な用途:
実装の継承: 基底クラスの実装を派生クラスで利用したいが、基底クラスのインターフェースを派生クラスの外部に公開したくない場合に使用されます。 インターフェースの制限: 派生クラスが基底クラスのインターフェースの一部を隠蔽し、より特化したインターフェースを提供したい場合に利用されます。 protected 継承は public 継承ほど一般的ではありません。public 継承は「is-a」の関係(派生クラスは基底クラスの一種である)を表すのに対し、protected 継承は「is-implemented-using-a」の関係に近いと言えます。
これらの説明で、EX03 の DiamondTrap クラスに関連する継承の概念について、より深くご理解いただけたでしょうか?もし他に疑問点があれば、遠慮なくご質問ください。