【c++學習筆記】多型
多型到底是什麼呢?
字面意思就是同種事物在不同的場景下所表現出不同的形態。
在c++當中,多型分類如下:
在學習多型之前,我們必須得先了解虛擬函式的概念。
- 虛擬函式就是在類的成員函式(除建構函式、拷貝建構函式、靜態成員函式)前加virtual關鍵字。
class B
{
public:
virtual void TestFunc()
{
cout << "Base::TestFunc()" << endl;
}
int _b;
};
int main()
{
B b;
cout << sizeof (B) << endl;
return 0;
}
這裡列印結果為什麼是8不是4呢?
在記憶體視窗上&B之後,發現它的前4個位元組放的類似地址的東西,那麼這個地址指向的又是什麼呢?
將前四個位元組的類似地址的東西放到記憶體視窗上檢視後,發現這裡放的還是地址,那麼這裡的地址是什麼呢?
其實這裡的地址就是虛擬函式的地址,在帶有虛擬函式的類中,會多開闢四個位元組用來存放指向一張虛表的指標,虛表裡放的都是虛擬函式的地址。要注意的是,虛擬函式只有在繼承體系中才有意義,因為在非繼承體系用不到,還多開闢了4位元組的空間。帶有虛擬函式的類的物件模型如下:
靜態多型在這裡不過多介紹,主要學習動態多型
動態多型的條件:
- 基類中必須包含虛擬函式,並且派生類一定要對基類中的虛擬函式進行重寫
- 重寫:
- 要和基類中的虛擬函式原型相同(返回值、引數列表、函式名均相同)(協變和虛擬析構除外)
- 協變:返回值可以不同,但是基類的虛擬函式必須返回基類物件的指標(引用);派生類的虛擬函式必須返回派生類物件的指標(引用)–這裡不符合返回值相同,但是也是重寫。
- 解構函式:解構函式也可以作為虛擬函式,並且在繼承體系中建議作為虛擬函式(為什麼建議稍後解釋)
- 通過基類的指標(引用)呼叫虛擬函式。
多型的含義:
- 如果基類的指標/引用指向/引用基類的物件,那麼在呼叫虛擬函式時呼叫屬於基類的虛擬函式。
- 如果基類的指標/引用指向/引用派生類的物件(賦值相容規則),那麼在呼叫虛擬函式時呼叫屬於派生類的虛擬函式。
class B
{
public:
virtual void TestFunc()//加virtual關鍵字,成員函式將作為虛擬函式
{
cout << "Base::TestFunc()" << endl;
}
};
class D : public B
{
public:
virtual void TestFunc()//派生類中對虛擬函式進行重寫,函式名、返回值、引數列表必須一致,
//在重寫時,派生類中的訪問限定符不會對虛擬函式有什麼影響,且可以不加virtual關鍵字
{
cout << "Derived::TestFunc()" << endl;
}
};
void Test(B& b)//通過基類的引用呼叫虛擬函式
{
b.TestFunc();
}
int main()
{
D d;
B b;
Test(d);
Test(b);
return 0;
}
那麼,單繼承中派生類的物件模型是怎樣的呢?
帶有虛擬函式單繼承物件模型
class B
{
public:
virtual void TestFunc()//加virtual關鍵字,成員函式將作為虛擬函式
{
cout << "Base::TestFunc()" << endl;
}
int _b;
};
class D : public B
{
public:
virtual void TestFunc()
{
cout << "Derived::TestFunc()" << endl;
}
int _d;
};
int main()
{
D d;
d._b = 1;
d._d = 2;
cout << sizeof(d) << endl;
return 0;
}
在除錯的時候調出記憶體視窗,&d可以很容易得出帶有虛擬函式的單繼承物件模型:
普通成員函式和虛擬函式的區別:
最大的區別就是呼叫方式不同,普通成員函式直接呼叫,而虛擬函式的呼叫分為如下幾步:
- 從物件前4個位元組中取虛表的地址
- 傳遞this指標
- 從虛表中獲取虛擬函式的地址(虛表地址+虛擬函式在虛表中的偏移量)
- 呼叫虛擬函式
帶有虛擬函式的多繼承物件模型
class B1
{
public:
virtual void TestFunc1()
{
cout << "Base1::TestFunc1()" << endl;
}
int _b1;
};
class B2
{
public:
virtual void TestFunc2()
{
cout << "Base2::TestFunc2()" << endl;
}
int _b2;
};
class D: public B1, public B2
{
public:
virtual void TestFunc1()//對B1中的虛擬函式TestFunc1重寫
{
cout << "Derived::TestFunc1()" << endl;
}
virtual void TestFunc2()
{
cout << "Derived::TestFunc2()" << endl;//對B2中的虛擬函式TestFunc2重寫
}
virtual void TestFunc3()//派生類自己特有的虛擬函式
{
cout << "Derived::TestFunc3()" << endl;
}
int _d;
};
int main()
{
D d;
d._b1 = 1;
d._b2 = 2;
d._d = 3;
cout << sizeof(D) << endl;
return 0;
}
對&d得:
發現這裡有兩個類似地址得東西,檢視得
不難發現,這兩個地址分別指向兩張虛表,一張為繼承B1的、另一張為繼承B2的,第一張虛表還會存放派生類中特有的虛擬函式。所以,物件模型如下:
帶有虛擬函式的菱形繼承
#include <iostream>
using namespace std;
class B
{
public:
virtual void TestFunc1()
{
cout << "B::TestFunc1()" << endl;
}
virtual void TestFunc2()
{
cout << "B::TestFunc2()" << endl;
}
virtual void TestFunc3()
{
cout << "B::TestFunc3()" << endl;
}
int _b;
};
class C1 : public B
{
public:
virtual void TestFunc1()
{
cout << "C1::TestFunc1()" << endl;
}
int _c1;
};
class C2 : public B
{
public:
virtual void TestFunc2()
{
cout << "B::TestFunc2()" << endl;
}
int _c2;
};
class D : public C1, public C2
{
public:
virtual void TestFunc1()
{
cout << "D::TestFunc()" << endl;
}
int _d;
};
int main()
{
D d;
cout << sizeof(D) << endl;
return 0;
}
物件模型:
其實從派生類D的大小很容易就能推斷出物件的模型如下:
由於菱形繼承存在資料二意性的問題,所以就引出了帶有虛擬函式的菱形虛擬繼承
帶有虛擬函式的菱形虛擬繼承
首先,先看看在單繼承中,帶有虛擬函式的虛擬繼承的物件模型,這裡是為了方便理解帶有虛擬函式的菱形虛擬繼承的物件模型,因為在單繼承中,虛擬繼承是沒有什麼實際意義的。
class B
{
public:
virtual void TestFunc()
{
cout << "B::TestFunc()" << endl;
}
int _b;
};
class D : virtual public B
{
public:
virtual void TestFunc()
{
cout << "D::TestFunc()" << endl;
}
int _d;
};
int main()
{
D d;
cout << sizeof(D) << endl;
d._b = 1;
d._d = 2;
return 0;
}
在物件d中,除了有一張拷貝B的虛表外,因為是虛擬繼承,還有一張儲存偏移量的表格,調出記憶體視窗,取地址,如下:
發現,確實是有兩個指標,那麼,哪一個是指向虛表的,哪一個又是指向儲存偏移量的呢,我們可以再呼叫一個記憶體視窗進行檢視,因為虛表中儲存的是地址,而另一個儲存的是偏移量,是整數。
可以發現,上面的地址指向存放偏移量的表格,下面的指向虛表,所以再單繼承中,帶有虛擬函式的虛擬繼承物件模型如下:
上面的是派生類沒有新增自己特有的虛擬函式的模型,接下來我們用同樣的方法看看在派生類中新增虛擬函式後,物件模型又是什麼?
class B
{
public:
virtual void TestFunc1()
{
cout << "B::TestFunc1()" << endl;
}
int _b;
};
class D : virtual public B
{
public:
virtual void TestFunc1()
{
cout << "D::TestFunc1()" << endl;
}
virtual void TestFunc2()
{
cout << "D::TestFunc2()" << endl;
}
int _d;
};
int main()
{
D d;
cout << sizeof(D) << endl;
d._b = 1;
d._d = 2;
return 0;
}
算下來大小比上面的多了4位元組。其實就是如果派生類自己新增虛擬函式,就會多開闢四個位元組,指向另一張虛表,這張虛表中存放派生類自己特有的虛擬函式地址。
接下來,再來看看帶有虛擬函式的菱形虛擬繼承,這裡為了簡單起見,沒有在派生類中新增特有的虛擬函式。
class B
{
public:
virtual void TestFunc1()
{
cout << "B::TestFunc1()" << endl;
}
virtual void TestFunc2()
{
cout << "B::TestFunc2()" << endl;
}
virtual void TestFunc3()
{
cout << "B::TestFunc3()" << endl;
}
int _b;
};
class C1 : virtual public B
{
public:
virtual void TestFunc1()
{
cout << "C1::TestFunc1()" << endl;
}
int _c1;
};
class C2 : virtual public B
{
public:
virtual void TestFunc2()
{
cout << "C2::TestFunc2()" << endl;
}
int _c2;
};
class D : public C1, public C2
{
public:
virtual void TestFunc3()
{
cout << "D::TestFunc3()" << endl;
}
int _d;
};
int main()
{
D d;
cout << sizeof(C1) << endl;
cout << sizeof(D) << endl;
d._b = 1;
d._c1 = 2;
d._c2 = 3;
d._d = 4;
return 0;
}
物件模型如下:
講到這裡,現在我們來想想動態多型的實現原理
動態多型實現原理
- 編譯器在帶有虛擬函式的類的背後維護了一張虛表(虛擬函式的入口地址)
- 虛擬函式的呼叫原理(通過基類的指標/引用呼叫虛擬函式)
- 從指標所指物件(基類/派生類)前4個位元組中取虛表的地址
- 傳遞引數(this+當前虛擬函式的引數)
- 根據從物件4個位元組取到的虛表的地址取物件的虛擬函式地址
- 呼叫虛擬函式
現在我們再來談談為什麼靜態函式,建構函式,拷貝建構函式為什麼不能作為虛擬函式?
- 最主要的原因就是呼叫虛擬函式需要this指標,但是這幾個函式當中並沒有虛擬函式(或者還沒構造好物件)
那麼為什麼建議將解構函式作為虛擬函式呢?
答案是因為如果不作為虛擬函式,有可能會造成記憶體洩露的問題,如下:
class B
{
public:
B()
:_b(1)
{
cout << "B::B()" << endl;
}
~B()
{
cout << "B::~B()" << endl;
}
int _b;
};
class D: public B
{
public:
D()
:_ptr(new char[10])
{
cout << "D::D()" << endl;
}
~D()
{
if (_ptr)
{
delete[] _ptr;
}
cout << "D::~D()" << endl;
}
char* _ptr;
};
void TestFunc()
{
B* pb;
pb = new D;//由於賦值相容規則,基類指標可以指向派生類物件,這裡就會呼叫D的建構函式申請空間
delete pb;//由於是基類型別的指標,所以delete只會呼叫基類的析構,所以這裡就會造成記憶體洩漏
}
int main()
{
TestFunc();
return 0;
}
只要我們將解構函式作為虛擬函式,就不會出現上面的問題了,列印結果如下:
那麼,為什麼不直接將解構函式預設作為虛擬函式呢?
- C++不 把虛解構函式直接作為預設值的原因是虛擬函式表的開銷以及和C語言的型別的相容性。