1. 程式人生 > >C++三大特性----"多型"

C++三大特性----"多型"

    多型,顧名思義多種狀態,在C++中大致可分為靜態多型與動態多型,其中靜態多型(早繫結)是編譯器在編譯期間通過實參的型別推斷要呼叫哪個函式,若有對應的函式,則呼叫,否則報錯,最能體現靜態多型的就是函式過載以及泛型程式設計;而對於動態多型(也稱晚繫結,動態繫結),則是在執行期間根據實參決定函式的呼叫的。

    下面我們重點來討論動態繫結:

    一般來說,動態繫結有兩個先決條件:首先在基類中必須存在以virtual關鍵字修飾的虛擬函式,而且這個虛擬函式在派生類當中必須被重寫;其次,對於虛擬函式的呼叫必須使用基類型別的指標或對應的引用來進行呼叫。

    這裡我們先分析一下這兩個條件,首先什麼是重寫?

    在C++中,基類中可以被繼承的成員函式大致可以分成兩種,一種希望被直接繼承而不要做任何改變的函式,另一種則是希望派生類進行覆蓋的函式,也就是我們定義的虛擬函式,而重寫則是在派生類中對原本基類中的虛擬函式進行重新定義並覆蓋。

    這裡要穿插一點東西,那就是在繼承體系下,同名函式的三種關係過載,重寫(覆蓋)以及同名隱藏的區別。

1.過載:函式過載必須是在同一作用域(基類與派生類屬於不同作用域),函式名相同,引數列表不同(引數的個數不同,或引數的型別不同,或引數的順序不同),與返回值無關。

2.重寫(覆蓋):構成重寫必須是在不同的作用域下(基類和派生類的作用域),函式名相同,引數相同,返回值相同(協變除外),基類中的函式必須以virtual關鍵字修飾,訪問限定符可以不同。

3.同名隱藏:構成同名隱藏必須在不同作用域下(基類和派生類作用域),函式名相同,在基類和派生類中不構成重寫。

    分析了過載,重寫(覆蓋)以及同名隱藏的區別,我們來回歸主題,講了這麼多,我們一直在說一個東西,那就是虛擬函式,我們只知道虛擬函式是被virtual關鍵字修飾的成員函式,那麼究竟有哪些函式可以作為虛擬函式呢?

    要明白這點,我們得深入的來分析一下動態繫結下的物件模型以及函式的呼叫過程。

測試程式碼1:

#include <iostream>
using namespace std;


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

	virtual void funtest2()
	{
		cout<<"Base::funtest2()"<<endl;
	}
public:
	int _b;
};

class Dirvate:public Base
{
public:
	void funtest1()override
	{
		cout<<"Dirvate::funtest1()"<<endl;
	}

	void funtest2()override
	{
		cout<<"Dirvate::funtest2()"<<endl;
	}
public:
	int _d;
};

void Fun(Base& b)
{
	b.funtest1();
	b.funtest2();
}

int main()
{
	Base b;
	b._b=1;
	Dirvate d;
	d._b=2;
	d._d=3;
	Fun(b);
	Fun(d);
	return 0;
}

首先,我們來檢視基類物件b的記憶體空間:


我們很明顯發現,物件b的記憶體空間的前4個位元組中存放了一個地址,而通過記憶體檢視這個地址我們發現在這個地址附近存放了上圖所示的類似於地址的內容。



通過檢視彙編程式碼,我們發現在以基類物件b為實參時,對虛擬函式的呼叫底層實現如上圖所示,都分別執行了一次call指令,而跳轉的位置正式基類物件b的記憶體空間的前4個位元組儲存的指標所指向空間中儲存的兩個地址。

同樣,我們也用同樣的方法來檢視派生類物件d的記憶體空間:


彙編程式碼底層實現:



我們發現和基類物件b的情況完全相同,也就是說對於有虛擬函式的類的物件在前4個位元組會存放一個虛表指標,而這個指標指向一張虛表,而這張虛表當中存放著虛擬函式的入口地址。

為此我們進行進一步的驗證:

測試程式碼2:

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

	virtual void funtest2()
	{
		cout<<"Base::funtest2()"<<endl;
	}
public:
	int _b;
};

class Dirvate:public Base
{
public:
	void funtest1()override
	{
		cout<<"Dirvate::funtest1()"<<endl;
	}

	void funtest2()override
	{
		cout<<"Dirvate::funtest2()"<<endl;
	}
public:
	int _d;
};

typedef void (*fun)();

void Fun()
{
	Base b;
	
	fun* pfun=(fun*)(*(int*)(&b));
	while(*pfun)
	{
		(*pfun)();
		pfun=(fun*)((int*)pfun+1);
	}
}


int main()
{
	Fun();
	return 0;
}

測試結果:


很明顯,上述測試用例並未對函式進行呼叫,而是通過對前4個位元組中儲存的地址進行強轉,解引用來實現的,而結果表明確確實實進行了虛擬函式的呼叫,因此對於虛表中存放虛擬函式的入口地址的說法是正確的。

    因此,我們可以總結一點:當類中存在虛擬函式的時候,編譯器一般都會維護一張虛表,裡面存放著虛擬函式的入口地址,並在物件的前4個位元組中儲存一個虛表指標指向這張虛表。而對於虛擬函式的呼叫,則是通過虛表指標找到虛表,並進行相應的偏移找到相應的虛擬函式入口地址進行呼叫。

   由此,我們回過頭來討論一下,那些函式可以作為虛擬函式。

①建構函式:不能作為虛擬函式

        通過上面的討論我們知道,呼叫虛擬函式必定得通過虛表指標和虛表,而能找到虛表指標的前提是必須得有物件,因為虛表指標就存放在物件的前4個位元組中,而當我們還未進行建構函式的呼叫時,物件顯然是未被建立的,因此根本無法找到虛表指標和虛表。

②解構函式:建議用作虛擬函式

        舉個例子:

測試程式碼:

class A
{
public:
	A()
		:_a(1)
	{
		cout<<"A()"<<endl;
	}
	~A()
	{
		cout<<"~A()"<<endl;
	}
private:
	int _a;
};

class B:public A
{
public:
	B()
		:_b(2)
	{
		cout<<"B()"<<endl;
	}
	~B()
	{
		cout<<"~B()"<<endl;
	}
private:
	int _b;
};

int main()
{
	A* pa=new B;
	delete pa;
	return 0;
}
測試結果:


我們來分析這段程式碼:首先我們通過A類型別的指標pa構建了一個B類的物件,並用pa指向,但是在釋放這塊空間的時候,由於pa是A*型別的,所以只調用了A類的解構函式如上圖所示,但是這樣的話,我們根據上面的結果可以很明確的知道,程式將A類的那部分空間釋放了,但B類的那部分空間呢?並沒有釋放,因此這個程式會出現記憶體洩漏的問題,而問題所在則是在於它構建了一個派生類的物件,但卻只釋放了基類物件對應的空間,而如果將解構函式宣告為虛擬函式,就可以很好的規避這類的問題(虛擬函式的呼叫是取決於物件的型別的)。

    ③賦值運算子過載:可以用作虛擬函式,但不建議

        賦值運算子的過載成員函式滿足作為虛擬函式的前提條件,但是它有一個問題:

class A
{
public:
	virtual A& operator=(A& a)
	{}
private:
	int _a;
};

class B:public A
{
public:
	virtual B& operator=(B& b)
	{}
private:
	int _b;
};

int main()
{
	A a;
	B b;
	A& ra=a;
	ra=b;//派生類物件給基類物件賦值(沒問題)
	A& raa=b;
	raa=a;//基類物件給派生類物件賦值(不符合賦值相容性規則,但在這裡由於賦值運算子過載是虛擬函式,這裡會呼叫派生類當中的賦值運算子過載函式)
	return 0;
}
    ④靜態成員函式與友元函式

        首先,靜態成員函式不存在this指標,它是整個類共享的,因此無法找到物件,更不要說找到虛表指標和虛表了。

        其次,對於虛擬函式,前提必須得是成員函式,而友元函式不是成員函式

綜上,對於虛擬函式,我們可以總結出一條:任何建構函式之外的非靜態成員函式均可作為虛擬函式,但實際用的時候,要不要作為虛擬函式,那得視情況而定,像賦值運算子過載雖然可以,但不建議作虛擬函式使用。

    既然我們已經得知對於帶有虛擬函式的類的物件會有一個虛表指標指向一張虛表,那麼下面我們來分析一下在不同的繼承體系下,基類與派生類的虛表變化。

1.單繼承

測試程式碼3:

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

	virtual void Fun2()
	{
		cout<<"Base::Fun2()"<<endl;
	}
	int _b;
};

class Derive:public Base
{
public:
	virtual void Fun0()
	{
		cout<<"Derive::Fun0()"<<endl;
	}
	virtual void Fun2()
	{
		cout<<"Derive::Fun2()"<<endl;
	}

	int _d;
};

typedef void (*Fun)();
void FunTest()
{
	Base b;
	Fun* pFun=(Fun*)(*((int*)(&b)));
	while(*pFun)
	{
		(*pFun)();
		pFun=(Fun*)((int*)pFun+1);
	}

	Derive d;
	pFun=(Fun*)(*((int*)(&d)));
	while(*pFun)
	{
		(*pFun)();
		pFun=(Fun*)((int*)pFun+1);
	}
}

int main()
{
	FunTest();
	return 0;
}
測試結果:


由此,我們可以發現只要是虛擬函式就會將它的入口地址儲存在虛表當中,並且是按照宣告順序存放的,而在單繼承下,首先派生類將基類的虛表進行一份拷貝,若在派生類中有進行重寫,則以派生類中的對應虛擬函式入口地址將原本基類中相對應的進行覆蓋,否則不做修改,而對於派生類中新增的虛擬函式,把它們的地址放在末尾。

2.多繼承

測試程式碼4:

class Base1
{
public:
	virtual void Fun1()
	{
		cout<<"Base1::Fun1()"<<endl;
	}

	virtual void Fun2()
	{
		cout<<"Base1::Fun2()"<<endl;
	}
	int _b1;
};

class Base2
{
public:
	virtual void Fun3()
	{
		cout<<"Base2::Fun3()"<<endl;
	}
	
	virtual void Fun4()
	{
		cout<<"Base2::Fun4()"<<endl;
	}

	int _b2;
};
class Derive:public Base1,public Base2
{
public:
	virtual void Fun0()
	{
		cout<<"Derive::Fun0()"<<endl;
	}
	virtual void Fun2()
	{
		cout<<"Derive::Fun2()"<<endl;
	}
	virtual void Fun3()
	{
		cout<<"Derive::Fun3()"<<endl;
	}

	int _d;
};

int main()
{
	Derive d;
	d._b1=1;
	d._b2=2;
	d._d=3;
	return 0;
}
檢視派生類物件d的記憶體:

由此,我們發現物件b的記憶體中多出了兩個虛表指標,而根據多繼承的物件模型以及上面討論過的單繼承的虛表變化,我們可以想到這兩個虛表指標分別指向從Base1和Base2的拷貝下來的虛表的。
所以,我們做了如下測試:

測試程式碼5:

class Base1
{
public:
	virtual void Fun1()
	{
		cout<<"Base1::Fun1()"<<endl;
	}

	virtual void Fun2()
	{
		cout<<"Base1::Fun2()"<<endl;
	}
	int _b1;
};

class Base2
{
public:
	virtual void Fun3()
	{
		cout<<"Base2::Fun3()"<<endl;
	}
	
	virtual void Fun4()
	{
		cout<<"Base2::Fun4()"<<endl;
	}

	int _b2;
};
class Derive:public Base1,public Base2
{
public:
	virtual void Fun0()
	{
		cout<<"Derive::Fun0()"<<endl;
	}
	virtual void Fun2()
	{
		cout<<"Derive::Fun2()"<<endl;
	}
	virtual void Fun3()
	{
		cout<<"Derive::Fun3()"<<endl;
	}

	int _d;
};

typedef void (*Fun)();
void FunTest()
{
	Derive d;

	cout<<"Base1:"<<endl;
	Base1& b1=d;
	Fun* pFun=(Fun*)(*((int*)(&b1)));
	while(*pFun)
	{
		(*pFun)();
		pFun=(Fun*)((int*)pFun+1);
	}

	cout<<"Base2:"<<endl;
	Base2& b2=d;
	pFun=(Fun*)(*((int*)(&b2)));
	while(*pFun)
	{
		(*pFun)();
		pFun=(Fun*)((int*)pFun+1);
	}

}


int main()
{
	FunTest();
	return 0;
}
測試結果:


由此,我們可以總結出多繼承的虛表變化規則:

跟單繼承類似,只要是虛擬函式就會將它的入口地址儲存在虛表當中,並且是按照宣告順序存放的,而在多繼承下,由於有多個基類,因此會拷貝多份虛表,並將地址存放在對應的前4個位元組中,而對於拷貝下來的虛表,若當中虛擬函式在派生類中發生重寫,則以派生類中的虛擬函式入口地址覆蓋,否則不做改動,而對於自己新增的虛擬函式則將它的入口地址放在第一個繼承的基類拷貝下來的虛表的末尾。

3.菱形繼承

測試程式碼6:

class A
{
public:
	virtual void fun1()
	{
		cout<<"A::fun1()"<<endl;
	}

	int _a;
};

class B1:public A
{
public:
	virtual void fun1()
	{
		cout<<"B1::fun1()"<<endl;
	}
	virtual void fun2()
	{
		cout<<"B1::fun2()"<<endl;
	}

	int _b1;
};

class B2:public A
{
public:
	virtual void fun1()
	{
		cout<<"B2::fun1()"<<endl;
	}
	virtual void fun3()
	{
		cout<<"B2::fun3()"<<endl;
	}

	int _b2;
};

class C:public B1,public B2
{
public:
	virtual void fun0()
	{
		cout<<"C::fun0()"<<endl;
	}
	virtual void fun1()
	{
		cout<<"C::fun1()"<<endl;
	}
	virtual void fun2()
	{
		cout<<"C::fun2()"<<endl;
	}
	
	int _c;
};



int main()
{
	C c;
	c.B1::_a=1;
	c._b1=2;
	c.B2::_a=3;
	c._b2=4;
	c._c=5;

	return 0;
}
檢視c物件的記憶體空間:

由此,我們發現在菱形繼承中,與多繼承一樣,在物件中加入了兩個虛表指標,並指向兩個從基類B1,B2拷貝下來的虛表,至於虛表的變化,我們進行進一步測試:

測試程式碼7:

class A
{
public:
	virtual void fun1()
	{
		cout<<"A::fun1()"<<endl;
	}

	int _a;
};

class B1:public A
{
public:
	virtual void fun1()
	{
		cout<<"B1::fun1()"<<endl;
	}
	virtual void fun2()
	{
		cout<<"B1::fun2()"<<endl;
	}

	int _b1;
};

class B2:public A
{
public:
	virtual void fun1()
	{
		cout<<"B2::fun1()"<<endl;
	}
	virtual void fun3()
	{
		cout<<"B2::fun3()"<<endl;
	}
	virtual void fun4()
	{
		cout<<"B2::fun4()"<<endl;
	}

	int _b2;
};

class C:public B1,public B2
{
public:
	virtual void fun0()
	{
		cout<<"C::fun0()"<<endl;
	}
	virtual void fun1()
	{
		cout<<"C::fun1()"<<endl;
	}
	virtual void fun2()
	{
		cout<<"C::fun2()"<<endl;
	}
	virtual void fun4()
	{
		cout<<"C::fun4()"<<endl;
	}
	
	int _c;
};

typedef void (*Fun)();
void funtest()
{
	C c;

	cout<<"B1:"<<endl;
	B1& b1=c;
	Fun* pFun=(Fun*)(*((int*)(&b1)));
	while(*pFun)
	{
		(*pFun)();
		pFun=(Fun*)((int*)pFun+1);
	}

	cout<<"B2:"<<endl;
	B2& b2=c;
	pFun=(Fun*)(*((int*)(&b2)));
	while(*pFun)
	{
		(*pFun)();
		pFun=(Fun*)((int*)pFun+1);
	}
}

int main()
{
	funtest();
	return 0;
}
測試結果:


由此,我們可以發現菱形繼承的虛表變化的規則基本和多繼承一致,只不過,在菱形繼承中對於起始的基類A中的虛擬函式,基類B1,B2都會繼承一份,由此在派生類C的兩個虛表中都會有fun1出現,對於兩個虛表剛開始拷貝下來都並沒有的虛擬函式(即新增的虛擬函式),則放在第一個虛表的末尾。

4.虛擬繼承

測試程式碼8:

class A
{
public:
	virtual void fun1()
	{
		cout<<"A::fun1()"<<endl;
	}

	int _a;
};

class B:virtual public A
{
public:
	virtual void fun1()
	{
		cout<<"B::fun1()"<<endl;
	}
	virtual void fun2()
	{
		cout<<"B::fun2()"<<endl;
	}

	int _b;
};

int main()
{
	B b;
	b._a=1;
	b._b=2;
	return 0;
}

檢視派生類物件b的記憶體空間:


由此,我們可以發現,在派生類物件的派生類自帶部分,前4個位元組存放虛表指標,後4個位元組存放偏移量表指標,最後才是存放物件的成員變數,而基類的虛表指標與成員變數則是與虛擬繼承規則相同,放在最後。最重要的一點,那就是原本單繼承只有一個虛表指標,但在這裡我們很清楚的發現,多了一個A類的虛表指標,因此,我們進行進一步測試。

測試程式碼9:

class A
{
public:
	virtual void fun1()
	{
		cout<<"A::fun1()"<<endl;
	}
	virtual void fun2()
	{
		cout<<"A::fun2()"<<endl;
	}

	int _a;
};

class B:virtual public A
{
public:
	virtual void fun1()
	{
		cout<<"B::fun1()"<<endl;
	}
	virtual void fun3()
	{
		cout<<"B::fun3()"<<endl;
	}

	int _b;
};

typedef void (*Fun)();
void funtest()
{
	B b;

	cout<<"A:"<<endl;
	A& a=b;
	Fun* pFun=(Fun*)(*((int*)(&a)));
	while(*pFun)
	{
		(*pFun)();
		pFun=(Fun*)((int*)pFun+1);
	}

	cout<<"B:"<<endl;
	pFun=(Fun*)(*((int*)(&b)));
	while(*pFun)
	{
		(*pFun)();
		pFun=(Fun*)((int*)pFun+1);
	}
}

int main()
{
	funtest();
	return 0;
}
測試結果:


由此我們可以得知,在虛擬繼承的物件模型中,增加了一個A類的虛表指標,而指向的虛表中用來儲存基類A類的虛擬函式,而正因為如此,A類的虛擬函式便不再出現在其他虛表當中了。

5.菱形虛擬繼承

測試程式碼10:

class A
{
public:
	virtual void fun1()
	{
		cout<<"A::fun1()"<<endl;
	}

	int _a;
};

class B1:virtual public A
{
public:
	virtual void fun1()
	{
		cout<<"B1::fun1()"<<endl;
	}
	virtual void fun2()
	{
		cout<<"B1::fun2()"<<endl;
	}

	int _b1;
};

class B2:virtual public A
{
public:
	virtual void fun1()
	{
		cout<<"B2::fun1()"<<endl;
	}
	virtual void fun3()
	{
		cout<<"B2::fun3()"<<endl;
	}
	virtual void fun4()
	{
		cout<<"B2::fun4()"<<endl;
	}

	int _b2;
};

class C:public B1,public B2
{
public:
	virtual void fun0()
	{
		cout<<"C::fun0()"<<endl;
	}
	virtual void fun1()
	{
		cout<<"C::fun1()"<<endl;
	}
	virtual void fun2()
	{
		cout<<"C::fun2()"<<endl;
	}
	virtual void fun4()
	{
		cout<<"C::fun4()"<<endl;
	}
	
	int _c;
};
int main()
{
	C c;
	c._a=1;
	c._b1=2;
	c._b2=3;
	c._c=4;
	return 0;
}
檢視物件c的記憶體空間:



鑑於上面對虛擬繼承的分析,我們進行類似的測試:

測試程式碼11:

class A
{
public:
	virtual void fun1()
	{
		cout<<"A::fun1()"<<endl;
	}

	int _a;
};

class B1:virtual public A
{
public:
	virtual void fun1()
	{
		cout<<"B1::fun1()"<<endl;
	}
	virtual void fun2()
	{
		cout<<"B1::fun2()"<<endl;
	}

	int _b1;
};

class B2:virtual public A
{
public:
	virtual void fun1()
	{
		cout<<"B2::fun1()"<<endl;
	}
	virtual void fun3()
	{
		cout<<"B2::fun3()"<<endl;
	}
	virtual void fun4()
	{
		cout<<"B2::fun4()"<<endl;
	}

	int _b2;
};

class C:public B1,public B2
{
public:
	virtual void fun0()
	{
		cout<<"C::fun0()"<<endl;
	}
	virtual void fun1()
	{
		cout<<"C::fun1()"<<endl;
	}
	virtual void fun2()
	{
		cout<<"C::fun2()"<<endl;
	}
	virtual void fun4()
	{
		cout<<"C::fun4()"<<endl;
	}
	
	int _c;
};

typedef void (*Fun)();
void funtest()
{
	C c;

	cout<<"B1:"<<endl;
	B1& b1=c;
	Fun* pFun=(Fun*)(*((int*)(&b1)));
	while(*pFun)
	{
		(*pFun)();
		pFun=(Fun*)((int*)pFun+1);
	}

	cout<<"B2:"<<endl;
	B2& b2=c;
	pFun=(Fun*)(*((int*)(&b2)));
	while(*pFun)
	{
		(*pFun)();
		pFun=(Fun*)((int*)pFun+1);
	}

	cout<<"A"<<endl;
	A& a=c;
	pFun=(Fun*)(*((int*)(&a)));
	while(*pFun)
	{
		(*pFun)();
		pFun=(Fun*)((int*)pFun+1);
	}
}

int main()
{
	funtest();
	return 0;
}
測試結果:


由此,我們可以發現,菱形虛擬繼承在菱形繼承的基礎上,增加了一個A類的虛表指標,並指向一張虛表,用於儲存A類的虛擬函式的入口地址,以防止在繼承過程中出現基類虛擬函式被B1繼承的同時,被B2也繼承了,導致呼叫函式時不明確的問題(二義性)。