C++ におけるポリモーフィズムの原理

序文

前回の記事ではポリモーフィズムの原理を説明しましたが、今回はポリモーフィズムの原理について詳しく説明します。

よくある筆記試験の質問は次のとおりです: sizeof(Base) とは何ですか?
ここに画像の説明を挿入します

ここに画像の説明を挿入します
なぜ 8 ではないのでしょうか?
デバッグして確認することができます。
よく見ると、オブジェクトの先頭に余分なポインタがあります。
ここに画像の説明を挿入します
このポインタを仮想関数テーブルポインタと呼びます。

上記は重要ではなく、重要なのは次のポリモーフィズムの原理です。
このポインタが指すテーブルには正確には何が含まれているのでしょうか?

ポリモーフィズムの原理

以下を見てください、ここには 2 つのオブジェクトがあり、1 つは mike で、もう 1 つは johnson であり、どちらのオブジェクトもテーブル ポインターを持っています。

class Person {
    
    
public:
	virtual void BuyTicket() {
    
     cout << "买票-全价" << endl; }
};
class Student : public Person {
    
    
public:
	virtual void BuyTicket() {
    
     cout << "买票-半价" << endl; }
};
void Func(Person& p)
{
    
    
	p.BuyTicket();
}
int main()
{
    
    
	Person mike;
	Func(mike);
	Student johnson;
	Func(johnson);
	return 0;
}

ポリモーフィズムの構成要素については以前に説明しました。
これは、この参照が指すポインターまたはオブジェクトに関係します。

なぜ?それはどのように達成されるのでしょうか?
ここに画像の説明を挿入します
このポインタは、親クラスの仮想関数を呼び出す場合は親クラスを指し、サブクラスの仮想関数を呼び出す場合はサブクラスを指します。
どうやって?

親クラス オブジェクトの仮想テーブルには親クラスの仮想関数が格納され、サブクラス オブジェクトの仮想テーブルにはサブクラスの仮想関数が格納されていることがわかります。
コンパイラはどのようにそれを行うのでしょうか?
コンパイラは、その構造が多態性を構成するかどうかも判断し、多態性を構成しない場合は、コンパイル中に呼び出しアドレスを決定します。

どうすれば確実ですか?
それがどのようなタイプの人であるかにもよります。次に、この機能を直接見つけて、この場所の住所を特定します。

ポリモーフィックな場合は、
指定されたオブジェクトの仮想テーブル内でそれを検索します。
コンパイラも非常にシンプルで、ポリモーフィック条件が満たされているかどうかを厳密にチェックするだけです。

デバッグ方法を説明しましょう
ここに画像の説明を挿入します
ここに画像の説明を挿入します

ポリモーフィズムを構成する状況:
p.BuyTicket(); この命令の実行では、誰が呼び出しているかがわかりません。なぜ?
この人物オブジェクトには 2 つの状況があります

上記のアセンブリ コードの本質は、呼び出し元のポインタ オブジェクトや参照オブジェクトの型とは関係がないことです。指されたオブジェクトを
見ると、指された親クラスは親クラスを呼び出し、指された子クラスはサブクラスを呼び出します。

ポリモーフィズムはアセンブリに変換することですが、
ポリモーフィズムにはならず、アドレスを直接決定しますが、ポリモーフィズムに該当する場合は、対応するアセンブリ命令に変換されます。
この指示は何のためにあるのでしょうか? アドレスは特定できず、誰が呼び出しているかわかりません。参照は親クラスを指します。親クラス
の最初の 4 バイトが見つかり、仮想テーブルのポインタが見つかり、仮想テーブルが見つかり、仮想関数を見つけます。この仮想関数に依存します。
ここに画像の説明を挿入します

サブクラスを指すとカットまたはスライスされます。
ここに画像の説明を挿入します

p.BuyTicket(); 命令を見ただけでは、それがサブクラスを参照しているのか親クラスを参照しているのかはわかりません。
アセンブリ命令は同じなのに、呼び出し結果が異なるのはなぜですか?
異なるオブジェクトが渡されるため、異なるオブジェクトの仮想テーブルも異なります。

仮想関数の別名がオーバーライドと呼ばれるのはなぜですか?
それがサブクラス内にある場合、仮想関数を書き換えた後、サブクラス内の対応する仮想テーブルの場所がコピーされ、
仮想関数と同じになるように上書きされます。

このように考えることができます。書き換えは構文レベルの概念であり、上書きは原理レベルの概念です。

多態性の条件要件

ポリモーフィズムの条件を逆に考えることができるようになりました
1. ポリモーフィック条件が書き換えられるのはなぜですか?
仮想テーブル内の仮想関数の場所を上書きする必要があるためです。

2. なぜポインタや参照を使うのでしょうか?
ポインターと参照は親クラス オブジェクトとサブクラス オブジェクトの両方を指すことができるためです。

なぜ仮想関数をオブジェクトの先頭に直接格納しないのでしょうか?
複数の仮想関数を持つ可能性があるため、それらをすべてオブジェクトに格納するのは不適切です。
次に、同じタイプの仮想テーブルについても同様です。

仮想関数テーブル: 基本的には仮想関数ポインターの配列

仮想関数が複数ある場合
ここに画像の説明を挿入します

保障とは何かを体験してみましょう。
ここに画像の説明を挿入します
最初の仮想関数は書き換えられており、サブクラスオブジェクトはまず親クラスオブジェクトのテーブルをコピーすると考えられます。
次に、そのオーバーレイを自分のものに書き換えます。書き換えなくても上書きはできません。

仮想関数テーブルは実際にはコンパイル時に決定されますが、書き換えをしない場合と
書き換えが完了した後では異なります。

仮想関数テーブルには複数のアドレスが存在する可能性がありますが、具体的にはどれを呼び出す必要がありますか?
関数の宣言順序を確認してください。

3. オブジェクトが親クラスの場合、ポリモーフィズムを実現できますか?
親クラスへのポインターまたは参照はここでスライスできます。親クラスのオブジェクトもスライスできます。
なぜオブジェクトは多態性を達成できないのでしょうか?原理的な観点から見ると?
コンパイル時に命令に変換されるので、オブジェクトの人であれば直接人を調整するだけです。

スライスを実装することもできます。ポリモーフィックに実装してみてはいかがでしょうか?
ポインター、参照、オブジェクトの違いは何かというと、そのスライスが少し異なります。

それがポインタと参照のスライスの場合はどうなるでしょうか?
ポインタがこの親クラスを指しているか、この親クラスを参照しているかどうか。
サブクラスについてはどうですか? サブクラスオブジェクトの親クラス部分を切り取ります。次に、切り取った部分を指すか参照します。
サブクラスのこの部分の仮想テーブルは依然としてサブクラス用です。

それが物体だったらどうなるでしょうか?
親クラスであれば問題ありませんが、サブクラスの場合はどうなるでしょうか?
サブクラスが親クラスにスライスを渡すと、メンバーがコピーされ、コピー コンストラクターが呼び出されます。
ここに何か問題があるのでしょうか?仮想テーブルはコピーされますか?
コピーされない場合、親クラスのオブジェクトの仮想テーブルには常に親クラスの仮想関数が含まれます。
仮想テーブルのコピーには大きな問題があるため、あえてコピーしません。
コピーすると汚くなるからです。仮想テーブルのディープコピーを想定すると、
親クラスオブジェクトの仮想テーブルがサブクラスの仮想関数なのか親クラスの仮想関数なのかは全く不明です。

したがって、オブジェクトをスライスするときは、メンバーのみがコピーされ、仮想テーブルはコピーされません。

感じてください、仮想の外観は変わっていません
ここに画像の説明を挿入します

仮想関数のアドレスのみが仮想テーブルに保存されます。

もう一つの質問は、
仮想関数には仮想テーブルがあるということで正しいですか?
いいえ、仮想関数は通常の関数と同じようにコード セグメントに配置されます。
ただし、仮想関数のアドレスは仮想関数テーブルには入れられません。

これには、Linux オペレーティング システムの知識が必要です。さらに詳しく知ることができます。
ここに画像の説明を挿入します

同じタイプのオブジェクトが同じ仮想テーブルを形成しているかどうかを確認できます。
ここに画像の説明を挿入します
ここに画像の説明を挿入します
書き換えが必要な場合、サブクラスには独立した仮想テーブルが必要であるため、親クラスとサブクラスの仮想テーブルは異なります。

監視ウィンドウに表示される内容は変更されており、監視ウィンドウに表示される内容は最も現実的ではない可能性があります。

class Base
{
    
    
public:
	virtual void Func1()
	{
    
    
		cout << "Base::Func1()" << endl;
	}

	virtual void Func2()
	{
    
    
		cout << "Base::Func2()" << endl;
	}

	void Func3()
	{
    
    
		cout << "Base::Func3()" << endl;
	}

private:
	int _b = 1;
};

class Derive : public Base
{
    
    
public:
	virtual void Func1()
	{
    
    
		cout << "Derive::Func1()" << endl;
	}
private:
	int _d = 2;
};

int main()
{
    
    
	Base b1;
	Base b2;
	Base b3;

	Derive d;

	b1.Func1();
	b1.Func3();
	return 0;
}

仮想関数テーブル

仮想テーブルに入るのは仮想関数ではなく、仮想テーブルに入る仮想関数のアドレスであることに注意してください。
仮想テーブルの正式名は、仮想関数テーブルです。
仮想テーブルの本質はポインタの配列です。

ここに画像の説明を挿入します

基本クラスの仮想テーブル
ここに画像の説明を挿入します

派生クラスの仮想テーブル
派生クラスの仮想テーブルにも 2 つの仮想関数のアドレスがありますが
、異なる点は、サブクラスの仮想テーブルが親クラスの仮想テーブルのコピーであると考えることができることです
。コピーした後に行うのでしょうか?
仮想関数を書き換えると、書き換えた位置が書き換えた仮想関数に上書きされます。
ここに画像の説明を挿入します

ポリモーフィズムの本質は、仮想テーブルに依存することで実現されます。
たとえば、親クラスのポインタまたは参照がある場合、それは親クラス オブジェクトまたは
サブクラス オブジェクトを指すことができますが、サブクラス オブジェクトを指すということは、サブクラス オブジェクトの親クラス部分を切り出すことを意味します。
このポインタでは、親クラスのオブジェクトだけが表示されると考えることができます。
1 つは親クラス オブジェクトそのものであり、もう 1 つはサブクラス オブジェクトから切り出された親クラス オブジェクトであるだけです。

ptr->Func1(); 基礎となるアセンブリは同じであり、コードの本質はそれをアセンブリに変換することです。
あなたが何であるかは関係なく、仮想関数のアドレスを見つけるために仮想テーブルに移動します。
したがって、親クラスを呼び出すには親クラスをポイントし、サブクラスを呼び出すにはサブクラスをポイントします。

Func4() が派生クラスに追加されたとします。
ここに画像の説明を挿入します

現在、Func1(); は書き換えを完了していますが、Func4(); は書き換えを完了していません。
Func4(); は仮想テーブルにありますか?
ここに画像の説明を挿入します
分かりませんでした。Func4(); はどこに行ったのでしょうか?
Func4(); は仮想関数ですが、なぜ仮想テーブルにないのですか?

メモリウィンドウを見てみましょう。
ここに画像の説明を挿入します
Func1() と Func2() の両方があるので、これは Func4(); ですか?
それを確認するにはどうすればよいですか?
比較のために Func4(); のアドレスを出力してもらえますか? それは可能ですが、後でさらに複雑な状況が発生します。
今は単一相続ですが、多重相続、ダイヤモンド相続はどうでしょうか。

次に、仮想テーブルを印刷するプログラムを使用した新しい遊び方について説明します。

プログラムを使用して仮想テーブルを印刷します

印刷方法は?
仮想テーブルのアドレス、関数ポインターの配列のアドレス、
それを今すぐ出力する方法がすでにわかっているとします。

これは関数ポインタですが、扱いがさらに面倒です。
ここに画像の説明を挿入します
それはどういう意味ですか。ここで、typedef は関数ポインタです。
関数ポインタ自体は非常に特殊なので、このようにする必要があります。
ここに画像の説明を挿入します
ただし、関数ポインタ typedef の前に型を付け、その後に名前を変更した名前を付けることはできません。
関数ポインタ定義変数または typedef は中央に移動する必要があります。
ここに画像の説明を挿入します

配列の出力は非常に簡単ですが、オブジェクトごとに仮想テーブルが異なり、g++ でのみハードコーディングできるため、配列がどのくらいの大きさになるかわかりません。たとえば、配列が 3 つあることがわかっている
場合は、
3枚までしか印刷できません。しかし、vsシリーズでは横断が可能です。

vs シリーズでは仮想テーブルを格納する際、nullptt が配列の末尾に配置されますが、
g++ では配置されません。

VS コンパイラーが nullptr を認識しない場合は、ソリューションをクリーンアップしてから、ソリューションを再生成します

ここに画像の説明を挿入します

ここに画像の説明を挿入します

引き続き下を見て、今度は仮想テーブルのアドレスを取り出したいと思います。
ここに画像の説明を挿入します
仮想テーブルのアドレスを取得するにはどうすればよいですか?
このポインタは、オブジェクトの最初の 4 バイトまたは最初の 8 バイトにあります。
オブジェクトの最初の 4 バイトを取得するにはどうすればよいですか?

ビッグエンディアンを学習していて、下位の値を取得したいと思ったときのことを振り返ることができます。
整数が与えられ、この整数の最初のバイトを取得したいとします。
1. 共用体を定義します(ここで別の共用体を定義すると追加できません)
2. int のアドレスを強制的にchar に変換して逆参照します。

ここでは 2 番目の遊び方を使用します。
ここに画像の説明を挿入します
ただし、これは int であるため、関数のパラメータを渡すことはできません。int は対応する型に強制されます。
ここに画像の説明を挿入します
ここに画像の説明を挿入します

パラメータ転送時に直接転送されないのでしょうか?
いいえ、直接変換は暗黙的な型変換です。C++ では、同様の型の暗黙的な型変換のみをサポートしています。
int、double、char など。

ポインタはアドレスですが、ポインタの型によって、参照されるときのポインタの大きさが決まります。

sizeof() は配列の計算には使用できないことに注意してください。パラメーターが渡されている限り問題が発生します。
また、これは私たちが通常使用する種類の配列ではなく、配列 0 のサイズをカウントできるのは定義した静的配列のみです。
他のどこにもありません。

もっと直接的な方法もありますが、
ここに画像の説明を挿入します
これはまだ簡略化してありますが、関数ポインタを直接挿入するとバイブルになります。

理解できるようお手伝いさせてください。
ここに画像の説明を挿入します

ここに画像の説明を挿入します

なぜこのようにしてはいけないのでしょうか?
ここに画像の説明を挿入します
結論から先に言いますが、これはうまくいきません。
まず、送りたいアドレスはどこですか?オブジェクトの最初の 4 バイトまたは 8 バイト。
逆参照は、オブジェクトの最初の 4 バイトまたは 8 バイトを取得するために必要です。
&b はオブジェクトへのポインタです。ポインタを位置 1 または位置 2 に渡しますか? 2番。
今渡しているのは番号 1 です。位置 2 のポインタはオブジェクトの最初の 4 バイトにあります。これを取り出すにはどうすればよいでしょうか?

VF_PTR** への強制変換、ポインター逆参照は 32 ビットで 4 バイト、64 ビットで 8 バイトを調べます。
ここに画像の説明を挿入します

これら 2 つの書き方の違いは何ですか?
最初の記述方法には特定の制限があり、32 ビットでのみ実行でき、
64 ビットでは実行できないという制限があります。
2 番目の書き方が適用可能で、VF_PTR** の逆参照は、VF_PTR* を参照することです。VF_PTR* は、
32 ビットで 4 バイト、64 ビットで 8 バイトです。

これで、仮想テーブル内の仮想関数のアドレスを出力できるようになりましたが、それがこれであることを確認するにはどうすればよいでしょうか?
裏技を教えましょう。

ここに画像の説明を挿入します
ここに画像の説明を挿入します

質問がありますが、親クラスには Func4() がありません。どうすれば仮想テーブルに入ることができますか?
この仮想テーブルは親クラスに属するだけでなく、継承されます。成長ポイントが子クラス オブジェクトの親クラスの一部であるというだけです。
Func4(); はサブクラスであり、第二に、この仮想テーブルは厳密に言えばサブクラスに属します。

親クラスの仮想テーブルと子クラスの仮想テーブルは同じではありません。子クラスが継承した後、子クラスが仮想テーブルのコピーを作成し、
子クラスがそれを書き換えて、独自の仮想関数を作成します。もこの仮想テーブルに入ります。

仮想テーブルはどの段階で生成されますか?
これらの関数のアドレスはコンパイル中に利用可能であり、親クラスの仮想テーブルとサブクラスの仮想テーブルを形成できるため、コンパイル中に生成されます。

オブジェクト内の仮想テーブルはいつ初期化されますか?
コンストラクターの初期化リストで初期化されます。デバッグを通じて自分で確認することができます。

仮想テーブルはどこに存在しますか?
まず第一に、仮想テーブルはオブジェクト内に存在せず、オブジェクト内にあるのは仮想テーブル ポインタです。
スタック上にある可能性はありますか?
複数のオブジェクトが同じ仮想テーブルを指しているため、絶対に不可能です。スタックにはスタック フレームしかなく、関数呼び出しが終了して破棄されるため、これは不可能です。
ここに画像の説明を挿入します
ヒープ上にある可能性はありますか?
それは可能ですが、不合理です。ヒープは通常、動的に割り当てられます。不可能。

ここに画像の説明を挿入します
次に、それが静的領域にあるか、定数領域にあるかを確認できます。
いくつかの住所を印刷して比較してください。
ここに画像の説明を挿入します
アドレスの距離を比較すると、仮想テーブルのアドレスが定数領域に最も近い。

実際、仮想テーブルがコンパイル後に変更されるかどうかを慎重に検討できますか?
仮想テーブル、特にサブクラスのテーブルはコンパイル中に変更される可能性があります。
動作中に変化することはないので、一定の領域に配置するのが適切です。

実際には、以下を見れば分かります
ここに画像の説明を挿入します
。コンパイルされた関数は命令列です。この命令列のアドレスが関数のアドレスです。関数のアドレスは、 の
定数領域に配置されます。コードセグメント。

多重継承仮想関数テーブル

ここに画像の説明を挿入します

Base1 と Base2 に問題はありません。重要なのは、多重継承の派生を確認することです。
最初に監視ウィンドウを確認します。

Deriv は 2 つの仮想テーブル Base1 と Base2 を継承し
ここに画像の説明を挿入します
、func1(); func2(); を書き換えて変更されないため、Derive には 2 つの仮想テーブルが必要です。

さて、サブクラスの func3(); をどこに置くかという質問があります。
ここでは仮想テーブル印刷を使って見てみましょう。
ここに画像の説明を挿入します
ここで質問ですが、最初の仮想テーブルが最初の位置にありますが、2 番目の仮想テーブルを印刷したときの大きさはどれくらいですか?
ここに画像の説明を挿入します

2 つの仮想テーブルは 2 つのオブジェクト内に配置されており、それらが連続しているかどうかを判断することはできません。
Base1 にはこの仮想テーブル以外にも他のメンバー変数があるためです。

1. Base1 をスキップし、sizeof(Base1) を追加します;
2. スライスを使用し、ポインターのオフセットを使用します。(Base2 のポインタは自動的にオフセットされます)
ここに画像の説明を挿入します
しかし、これは間違っています。&d は Derive*、Derive*+1 は Derive をスキップし、強制的に char*、char*+1 は 1 バイトスキップします。

最初のテーブルに置かれました
ここに画像の説明を挿入します

ポインタのオフセットを理解します。
まずは次の質問を見てみましょう。
ここに画像の説明を挿入します
この問題は、スライスを理解していれば解決できます。
p1とp3のアドレスは同じですが、意味が異なります。
ここに画像の説明を挿入します

先に継承した人が最初に宣言し、最初に宣言した人が先頭になります。

ここに画像の説明を挿入します
func1 で書き換えが完了します。2 つの場所をカバーする 2 回の書き換えが行われ、Base1 の仮想テーブルは Base2 の仮想テーブルもカバーします。
しかし、ここで非常に奇妙な現象が起こります。これが本当の大きな問題であり、C++ を学習する 10 人中 9 人は失敗します。

書き換えたfunc1のアドレスが違うことに気づきましたか?
まず質問ですが、この関数は func1 ですか?fuc1のアドレスが書き換えられているのでしょうか?
はい、この関数を呼び出すことで後ろの文字列が出力されるからです。
ここに画像の説明を挿入します

しかし、なぜこのアドレスは違うのでしょうか?
この問題は非常に奥深く、理解するのが難しく、編纂を読むことでしか理解できません。

ここに画像の説明を挿入します

これらは両方ともアセンブリ コードに変換されますが、これら 2 つのアセンブリ コードは異なります。これら 2 つの場所で同じ関数が呼び出されていますか?
これは通話と同じアドレスですか?
ここではポリモーフィズムの条件が満たされているため、同じだと思う人も多いでしょう。
**2 番目のアドレスはカプセル化されていると考えることができます。**カプセル化なしでは呼び出しを完了できないため、一部の条件についての理解には多少の差異があります。
これには深い理由があります。

ここに画像の説明を挿入します
再実行してもアドレスは変更されませんが、プロセスのロードに何らかの理由が関係するため、比較には役立ちません。移転する必要があります。

ptr1 は通常の呼び出しです。
みんな ptr2 jmp を何回か続けて見ましたが、なぜですか?
jmp はカプセル化に相当します。
右側に非常に特殊な命令があり、
ここに画像の説明を挿入します
ecx には this ポインタが格納されていますが、この図を見れば誰でも理解できます。
ここに画像の説明を挿入します

サブクラスのこの関数を呼び出すとき。
ptr1 が処理されない理由は、ptr1 がたまたまサブクラス ペアの先頭を指しているためです。
サブクラス関数を呼び出す場合、this ポインタはサブクラス オブジェクトを指す必要があります。

ptr2 がサブクラスのこの関数を呼び出すとき、this ポインタが正しくありません。

この命令の機能は、このポインタの位置を修正することです。
これは必ずしも 8 を減らすわけではなく、Base1 のサイズを減らします。

ここにも問題があり、Base2を先に継承した場合、base1を修正する必要があります。

静的多態性と動的多態性

場所によっては、静的ポリモーフィズムと動的ポリモーフィズムが区別されます。

では、静的ポリモーフィズムとは何でしょうか?
関数のオーバーロード。

一般的な言語レベルでは、静的とはコンパイル時間を指します。

関数のオーバーロードはコンパイル時に実装されます。

動的ポリモーフィズムとは何ですか?
ランタイムに対応します。

これら 2 つのエッセンスは死ぬほど書かれています。

ダイヤモンドの仮想継承

ここに画像の説明を挿入します
A には仮想関数 func があり、B には仮想関数 func があり、C には仮想関数 func があり、
D には仮想関数がありません。これは不可能です。
D が書き換えられない場合、エラーが報告されます。理由は明らかではありませんか?
ここに画像の説明を挿入します

B が書き換えられ、C が書き換えられました。ここで、A の仮想テーブルには誰の仮想関数が配置されているのかという疑問が生じます。
この質問に興味があれば、自分で調べてください。ここでは答えません。

おすすめ

転載: blog.csdn.net/weixin_68359117/article/details/135113344