十五、多態與虛函數
15.1 引言
- 面向對象編程的基本特征:數據抽象(封裝)、繼承、多態
- 基于對象:我們創建類和對象,并向這些對象發送消息
- 多態(Polymorphism):指的是相同的接口、不同的實現。簡單來說,多態允許不同的類對象 對 同一個函數調用 做出 不同的響應 。
- 動態多態性(Dynamical polymorphism) 是通過 虛函數 實現的。虛函數是邁向真正 面向對象編程(OOP) 的關鍵一步
多態的分類
-
編譯時多態(靜態多態/Static Polymorphism)
-
特點:在編譯階段就能確定調用哪個函數
-
實現方式:
- 函數重載(Function Overloading)
- 運算符重載(Operator Overloading)
-
示例:
#include <iostream> using namespace std; void print(int x){cout << x << endl;} void print(double x){cout << x << endl;}int main(){print(5); //在編譯時就知道調用int版本print(9.8);//在編譯時就知道調用double版本 }
-
-
運行時多態(動態多態/Dynamical polymorphism)
-
特點:在程序運行時決定調用哪個函數,是程序運行起來后根據實際對象決定的。
-
實現方式:虛函數(virtual function) + 基類指針或引用
-
示例:
#include <iostream> using namespace std; class Animal { public:virtual void speak() { cout << "Animal sound" << endl; } }; class Dog :public Animal { public:void speak() override { cout << "Woof!" << endl; } };void makeSound(Animal* a) {a->speak(); //在運行時才知道是哪個版本 } int main() {Animal a;Dog d;makeSound(&a);//輸出"Animal sound"makeSound(&d);//輸出 "Woof!",調用的是子類的函數 }
-
15.2 向上轉型(Upcasting)
- 當通過指針或引用(指向或引用基類)操作時,派生類的對象可以被當作其基類對象 來處理。
- 向上轉型(Upcasting): 獲取一個對象的地址(無論是指針 還是 引用),并將其當作 基類類型 使用,就叫做向上轉型(Upcasting)。
- 也就是說,” 新類是現有類的一種類型 “。
示例
class Instrument {
public:void play() const {}
};
//Wind是Instrument的派生類
class Wind :public Instrument {};
void tune(Instrument& i) { i.play(); }
void main() {Wind flute;tune(flute); //向上轉型(Upcasting)Instrument* p = &flute;//UpcastingInstrument& l = flute;//Upcasting
}
這里將一個 Wind 類型的引用或指針 轉換為 一個 Instument 類型的引用或指針的行為,就是向上轉型(Upcasting)。
下面給出一個有疑問的示例
#include <iostream>
using namespace std;
class Instrument {
public:void play() { cout << "Instrument::play" << endl; }
};
class Wind:public Instrument {
public://重新定義接口函數void play() const { cout << "Wind::play" << endl; }
};void tune(Instrument& i) { i.play(); }void main() {Wind flute;tune(flute);//向上轉型
}
輸出
Instrument::play
問題
- 此調用本應產生
Wind::play
,但實際調用了Instrument::play
。 - 為了解決這個問題,我們需要使用 虛函數(virtual function) 來解決這個問題。
15.3 虛函數(virtual functions)
什么是虛函數
-
格式:
virtual type function-name(arguments);
-
如果一個函數在其基類中被聲明為
virtual
, 那么它在所有派生類中也是virtual
的。 -
在派生類中重新定義一個
virtual
函數,通常稱之為重寫(Overriding)
。 -
多態(Polymorphism):
同名但不同實現的函數。虛函數(通過重寫)是動態決定 調用哪一個函數的。
-
函數重載(Function overloading):
是靜態決定調用哪個版本的函數。
示例 C15:Instrument2.cpp
//C15:Instrument2.cpp
#include <iostream>
using namespace std;class Instrument{
public:virtual void play() const{cout << "Instrument::play" << endl;}
};
class Wind:public Instrument{
public://重寫虛函數virtual void play() const //省略"virtual"是可以的{cout << "Wind::play" << endl;}
};void tune(Instrument& i){ i.play();}void main(){Wind flute;tune(flute);//向上轉型
}
輸出
Wind::play
這樣就達到我們的期望了——Wind::play
可擴展性
- 如果在基類中將
play()
定義為 虛函數(virtual) ,那么我們可以在不修改tune()
函數的前提下,添加任意多的新類型。 - 在一個設計良好的面向對象程序中,我們的大多數甚至全部函數,都會遵循
tune()
的模式,并只通過基類接口進行通信。這樣的程序是可擴展的,因為我們可以通過從共同的基類繼承新的數據類型 來添加新功能。
示例 Extensibility in OOP
//Extensibility in OOP
#include <iostream>
#include <string>
using namespace std;
class Instrument {
public:virtual void play() const { cout << "Instrument::play" << endl; }virtual string what() const { return "Instrument"; }//下面這個函數會修改對象virtual void adjust(int) {}
};class Wind :public Instrument {
public:void play() const { cout << "Wind::play" << endl; }string what() const { return "Wind"; }void adjust(int) {}
};class Stringed :public Instrument {
public:void play() const { cout << "Stringed::play" << endl; }string what() const { return "Stringed"; }
};class Brass :public Wind {
public:void play() const { cout << "Brass::play" << endl; }string what() const { return "Brass"; }
};void tune(Instrument& i) { i.play(); }void f(Instrument& i) { i.adjust(1); }int main() {Wind flute;Stringed violin;Brass horn;tune(flute);//Wind::play;tune(violin);//Stringed::play;tune(horn);//Brass::playf(horn);//Wind::adjustreturn 0;
}
- 我們可以看到,
virtual(虛函數)
機制 無論有多少層繼承都能正常運行。 adjust()
函數在Brass
類中沒有別重寫。當這種情況發生時,繼承層次結構中 ”最近的“‘定義會被自動使用(即:Wind::adjust
)。
注意
-
虛函數 是一個 非靜態成員函數 。
-
如果一個虛函數是在類體外定義的,那么關鍵字
virtual
只在聲明時需要寫明。class Instrument{ public:virtual void play() const; }; void Instrument::play() const {cout << "Instrument::play" endl; }
-
當使用 **作用域解析運算符
::
** 時,虛函數機制將不會被使用。……………… void tune(Instrumnet& i){//……i.Instrument::play(); //顯示調用基類版本,禁用虛機制 }void main(){Wind flute;tune(flute); }
輸出
Instrument::play
-
在派生類中,如果要重寫基類的虛函數,那么要重寫的函數的類型必須與基類中虛函數的類型完全相同,這樣才稱為 重寫(Overriding) 基類版本的虛函數。
-
如果類型不相同,那么這叫做重定義,是在派生類里面重定義了一個全新的函數,就不會重寫基類的虛函數,而是會名字隱藏基類的虛函數。
-
要實現多態行為:
- 派生類必須是 公有繼承(public) 自基類
- 被調用的成員函數必須是虛函數
- 必須通過指針或引用來操作對象。(如果是直接操作對象而不是通過指針或引用,編譯器在編譯時就已知對象的確切類型,因此不需要運行時多態機制)
示例
#include <iostream>
using namespace std;
class A{
public:virtual void f1(){cout << "A::f1" << endl;}virtual void f2(){cout << "A::f2" << endl;}void f3() {cout << "A::f3" << endl;}void f4() {cout << "A::f4" << endl;}
};class B:public A
{
public:virtual void f1() //虛函數的重寫{cout << "B::f1" << endl;}virtual void f2(int) //新定義了一個虛函數{cout << "B::f2" << endl;}virtual void f3() //在B中,f3是虛函數,但在A,f3并不是{cout << "B::f3" << endl;}void f4() //重定義{cout << "B::f4" << endl;}
};
下面是針對上面示例的不同main()
函數測試
main1
void main(){B b;A* p = &b;p->f1();p->f2();p->f3();p->f4();
}
輸出
B::f1
A::f2
A::f3
A::f4
main2
void main(){B b;A& a1 = b;a1.f1();a1.f2();a1.f3();a1.f4();
}
輸出
B::f1
A::f2
A::f3
A::f4
main3
void main(){B b;A a = b;a.f1();a.f2();a.f3();a.f4();
}
輸出
A::f1
A::f2
A::f3
A::f4
15.4 C++ 如何實現晚綁定(late binding)
綁定: 在C++中,”綁定“”就是指函數調用與實際執行代碼之間建立聯系的過程。
綁定有兩種:
類型 | 綁定時間 | 說明 |
---|---|---|
早綁定(Early Binding) | 編譯時決定 | 編譯器在編譯時就知道要調用哪個函數。適用于普通函數、非虛函數等。效率高,但不靈活 |
晚綁定(Late Binding) | 運行時決定 | 編譯時不確定,運行時根據對象的實際類型決定調用哪個函數。適用于虛函數。靈活,支持多態 |
C++晚綁定的機制
- 編譯器為每個包含 虛函數 的類創建一個 虛函數表(VTABLE)。
- 編譯器會將類的所有虛函數地址存放到它對應的 虛函數表 中。
- 在每個包含虛函數的類中,編譯器會 偷偷地添加一個 VPTR 指針 ,它指向該對象所屬類的 VTABLE。
- 為每個類設置 VTABLE ,初始化 VPTR ,插入虛函數調用代碼——這些操作都會自動完成。
- 當我們用基類指針(或引用)指向派生類對象時, 基類中的 虛指針(vptr)會被設置為指向派生類的虛函數表(vtable),
以便實現動態多態(運行時綁定)。
//C15:Early & Late Binding.cpp
#include <iostream>
#include <string>
using namespace std;
class Pet{
public:virtual string speak() const{return "Pet::speak";}
};class Dog:public Pet{
public:virtual string speak() const {return "Dog::speak";}
};void main(){Dog ralph;Pet* p1 = &ralph; //有類型歧義Pet& p2 = ralph; //有類型歧義Pet p3 = ralph; //無類型歧義//晚綁定cout << p1->speak() << endl;cout << p2.speak() << endl;//早綁定cout << p3.speak() << endl;
}
輸出
Dog::speak
Dog::speak
Pet::speak
15.5 為什么使用虛函數(virtual functions)
- 虛函數是一種選擇(并不是強制的)。
virtual
關鍵字的設計,是為了方便效率優化。當我們想提升代碼執行速度時,只需要查找哪些函數可以改為非虛函數即可。
15.6 抽象基類與純虛函數
-
有時候我們希望基類僅僅作為接口供其派生類使用,而不希望任何人實際創建這個基類的對象。
-
一個 抽象類(abstract class) 至少包含一個 純虛函數(pure virtual function)。并且抽象類不可以拿來創建對象。
-
純虛函數: 使用
virtual
關鍵字,并且以= 0
結尾。 -
純抽象類(pure abstract class): 是指其中只包含純虛函數,沒有其他函數實現。也不可以拿來創建對象。
-
不能創建抽象類的對象。
-
將一個類設計為抽象類,可以確保在向上轉型時只能通過指針或引用來使用這個類。
-
當一個抽象類別繼承時,所有純虛函數都必須在子列中實現(也就要定義),否則這個子類也會變成為一個抽象類。
#include <iostream>
using namespace std;
class Point //抽象類(不是純抽象類)
{
public:Point(int i = 0,int j = 0) { x0 = i; y0 = j; }virtual void Set() = 0;virtual void Draw() = 0;
protected:int x0, y0;
};class Line :public Point
{
public:Line(int i = 0, int j = 0, int m = 0, int n = 0) :Point(i, j){x1 = m; y1 = n;}virtual void Set(){cout << "Line::Set() called." << endl;}virtual void Draw(){cout << "Line::Draw()." << endl;}
protected:int x1, y1;
};
//抽象類
class Ellipse :public Point
{
public:Ellipse(int i = 0, int j = 0, int p = 0, int q = 0) :Point(i, j){x2 = p; y2 = q;}
protected:int x2, y2;
};void main() {Line line(0, 1);//Elipse elipse(0,1,2,3);//錯誤,因為Wllipse是抽象類Point& p = line;p.Set();p.Draw();
}
輸出
Line::Set() called.
Line::Draw().
15.7 繼承與虛函數表(VTABLE)
虛函數表
- 編譯器會為派生類自動創建一個新的 虛函數表(VTABLE) ,并將你新重寫的函數地址插入其中。對于那些沒有重寫的虛函數 ,則使用基類中的函數地址。
//C15:AddingVirtuals.cpp
#include <iostream>
#include <string>
using namespace std;
class Pet {string pname;
public:Pet(const string& petName) :pname(petName) {}virtual string name() const { return pname; }virtual string speak() const { return ""; }
};class Dog :public Pet {string name;
public:Dog(const string& petName) :Pet(petName) {}virtual string sit() const { return Pet::name() + "sits"; } //新的虛函數string speak () const { return Pet::name() + " says 'Bark!'"; }//重寫虛函數
};
int main() {Pet* p[] = { new Pet("generic"),new Dog("bob") };//創建一個Pet*數組cout << "p[0]->speak() = " << p[0]->speak() << endl;cout << "p[1]->speak() = " << p[1]->speak() << endl;//!cout << "p[1]->sit() = " << p[1]->sit() << endl;//非法,因為Pet型指針的Dog,會在Pet的虛函數表里面找sit(),但這是找不到的delete p[0];delete p[1];return 0;
}
輸出
p[0]->speak() =
p[1]->speak() = bob says 'Bark!'
對象切片(Object slicing)
- 通過地址進行向上轉型是自動且安全的,但通過值進行向上轉型是不安全的。(向下轉型同樣不安全)
- 對象切片: 對象切片會在復制對象到新對象時丟失原有對象的一部分信息(即派生類特有的部分會被“切掉”)。
- 如果你將對象向上轉型為另一個對象 (而不是指針或引用),就會發生對象切片。
//C15:ObjectSlicing.cpp
#include <iostream>
#include <string>
using namespace std;class Pet{string pname;
public:Pet(const string& name):pname(name){}virtual string name() const{return pname;}virtual string description() const{return "This is " + pname;}
};class Dog:public Pet{string favoriteActivity;
public:Dog(const string& name,const string& activity):Pet(name),favoriteActivity(activity){}virtual string description() const{return Pet::name() + "likes to " + favoriteActivity;}
};void describe(Pet a) //對象切片
{cout << a.description() << endl;
}
void main(){Pet p("Zhang");Dog d("Li","sleep");describe(p);describe(d);//Pet::pname::pname,name(),description()
}
輸出
This is Zhang
This is Li
這里就要對
This is Li
這段文字產生提出問題,我們并不希望這樣,因此需要將desctibe()
函數進行修改
void describe(Pet& a)
{cout << a.description() << endl;
}
輸出
This is Zhang
Lilikes to sleep
15.8 重載和重寫(Overloading & Overriding)
- 在派生類中,如果我們重寫或者重新定義了基類中某個重載的成員函數,那么基類里其他重載版本將會被隱藏。
- 編譯器不允許我們通過更改基類虛函數的返回類型來**“重新定義”**該函數。
//C15:NameHiding2.cpp
#include <iostream>
#include <string>
using namespace std;
class Base {
public:virtual int f() const { cout << "Base::f()" << endl; return 1; }virtual void f(string) const {}virtual void g() const {}
};class Derived1 :public Base
{
public:void g() const {}
};class Derived2 :public Base {
public://重寫int f() const { cout << "Derived2::f()" << endl; return 2; }
};class Derived3 :public Base {
public://不被允許,因為它在通過改變return類型來重新定義基類的虛函數f()//!void f() const { cout << "Derived3::f()" << endl; }
};class Derived4 :public Base {
public://重新定義,因為改變了參數列表int f(int) const { cout << "Derived4::f()" << endl; return 4; }
};int main() {string s("Hello");Derived1 d1;int x = d1.f(); //調用Base的int f()d1.f(s); //調用Base的void f(string)Derived2 d2;x = d2.f(); //調用Derived2的int f()//!d2.f(s);//Derived2的int f()將其他版本隱藏了Derived3 d3;d3.f();//調用Base的int f()Derived4 d4;x = d4.f(1);//!x = d4.f();//Base的f()版本都被隱藏//!d4.f(s);//void f(string)被隱藏Base& br = d4;//向上轉型//!!br.f(1);//因為轉型到了Base,所以派生類的非虛函數不能在使用br.f();//可以使用Base版本br.f(s);
}
輸出
Base::f()
Derived2::f()
Base::f()
Derived4::f()
Base::f()
15.9 虛函數和構造函數
-
當一個包含虛函數的對象被創建時,它的虛函數指針(VPTR)必須被初始化為指向正確的虛函數表(VTABLE)。
-
構造函數負責初始化這個虛函數指針(VPTR)。
-
構造函數不能是虛函數。
原因:構造函數負責設置 VPTR,但虛函數調用又依賴 VPTR,所以構造函數不能是 虛函數,否則邏輯自相矛盾。如果構造函數是虛函數,那調用構造函數需要去使用 VPTR,那使用VPTR又需要去調用構造函數,就像“先有雞還是先有蛋”一樣,會是悖論。
15.10 虛函數和析構函數
- 析構函數可以是虛函數,而且通常必須是虛函數(如果要通過“基類或指針”來刪除派生類對象)。
- 如果基類中的析構函數被聲明為
virtual
,那么即使派生類的析構函數沒有加virtual
關鍵字,它們也依然是virtual
的。 - 這是為了確保析構函數能夠被準確地調用。
下面給出原因
示例
#include <iostream>
using namespace std;class A {
public:A() { cout << "A::A()" << endl; }~A() { cout << "A::~A()" << endl; }
};class B :public A
{
public:B(int i) {buf = new char[i];cout << "B::B(int)" << endl;}~B() {delete[]buf;cout << "B::~B() called." << endl;}
private:char* buf;
};void main() {A* a = new B(15);delete a;
}
輸出
A::A()
B::B(int)
A::~A()
**問題:**我們通過基類指針刪除派生類對象,但 A::~A()
不是虛函數。
所以編譯器在執行 delete p;
時:
- 只知道
a
是個A*
類型 - 查的是
A
的析構函數 -->~A()
,不是虛函數 - 所以它不會進行“虛調用”跳轉到
~B()
- 最終只調用
A::~A()
,而B::~B()
根本沒被調用
后果: 導致buf
的內存沒有被釋放——內存泄漏。
那么為什么呢?原因就主要在于它不會進行“虛調轉”跳轉到~B()
虛調用 的跳轉
-
虛函數的跳轉:當通過基類指針或引用調用虛函數時,程序會根據對象的真實類型,通過虛函數表(vtable)**跳轉到**最派生類的函數版本。
-
正向跳轉:調用一個虛函數時,跳轉到最派生類重寫的版本來執行。
特點:只跳一次,執行最底層的版本,不會執行上層版本。
示例
class A { public:virtual void f() { cout << "A::f" << endl; } };class B : public A { public:void f() override { cout << "B::f" << endl; } };class C : public B { public:void f() override { cout << "C::f" << endl; } };
當我們寫:
A* p = new C(); p->f(); // 會執行誰?
這里會執行的是
C::f()
,不是A
的也不是B
的而是C
的,這就是因為虛函數的跳轉。 -
反向跳轉:析構對象時,虛函數表引導從最派生類開始,逐級調用所有析構函數(派生 → 基類)。
特點:逐級調用每一層的析構函數,不能省略。
示例
#include <iostream> using namespace std; class A { public:virtual void f() { cout << "A::f" << endl; }virtual ~A() { cout << "~A()" << endl; } };class B : public A { public:void f() override { cout << "B::f" << endl; }~B() { cout << "~B()" << endl; } };class C : public B { public:void f() override { cout << "C::f" << endl; }~C() { cout << "~C()" << endl; } }; void main() {A* a = new C();delete a; }
輸出
~C() ~B() ~A()
所以我們如果要解決上述的那個問題:就需要將 A
的析構函數改成虛函數
#include <iostream>
using namespace std;class A {
public:A() { cout << "A::A()" << endl; }virtual ~A() { cout << "A::~A()" << endl; }
};class B :public A
{
public:B(int i) {buf = new char[i];cout << "B::B(int)" << endl;}~B() {delete[]buf;cout << "B::~B() called." << endl;}
private:char* buf;
};void main() {A* a = new B(15);delete a;
}
輸出
A::A()
B::B(int)
B::~B() called.
A::~A()
注意:
- 最好避免在構造函數和析構函數中調用虛函數。
- 在構造函數或析構函數中調用虛函數時,不會啟用運行時多態性。
#include <iostream>
using namespace std;
class Base{
public:Base(){cout << "Bse constructor start" << endl;call();//調用虛函數cout << "Bse constructor end" << endl;}virtual void call(){cout << "Base::call()" << endl;}virtual ~Base(){cout << "Base destructor start" << endl;call();//析構再次調用虛函數cout << "Base destructor end" << endl;}
};class Derived : public Base {
public:Derived() {cout << "Derived constructor\n";}void call() override {cout << "Derived::call()\n";}~Derived() {cout << "Derived destructor\n";}
};
int main() {Derived d;return 0;
}
輸出
Base constructor start
Base::call()
Base constructor end
Derived constructor
Derived destructor
Base destructor start
Base::call()
Base destructor end
說明:
- 即使
Derived
中重寫了call()
函數,構造函數和析構函數中調用的都是Base::call()
。 - 這是因為:
- 在構造
Base
的時候,Derived
還沒構造完,因此不會使用它的虛函數表。 - 在析構
Base
時,Derived
已經開始析構,也不會再用它的虛函數表。
- 在構造
15.11 運算符重載
- 我們可以像其他成員函數一樣,把運算符函數聲明為虛函數。
15.12 向下轉型
- 向下轉型是不安全的
dynamic_cast
:會在運行時檢查類型安全(前提是基類中至少有一個虛函數),如果轉換失敗會返回nullptr
(指針)或拋出異常(引用),相對更安全。static_cast
:不做運行時檢查,速度快但風險大,只應在你確信類型匹配時使用。
15.13 總結
- 虛數調用的綁定方式:早期綁定、晚期綁定
- 虛函數、多態
- 向上轉型(Upcasting)
- 重寫(Overriding)、重載(Overloading)
- 純虛函數、抽象類、純抽象類