C++虛函數
C++虛函數是多態性實現的重要方式,當某個虛函數通過指針或者引用調用時,編譯器產生的代碼直到運行時才能確定到底調用哪個版本的函數。被調用的函數是與綁定到指針或者引用上的對象的動態類型相匹配的那個。因此,借助虛函數,我們可以實現多態性。這也是OOP
的核心思想之一。
引言
考慮下面一個繼承的例子,Dog類與Cat類都繼承自Animal類,但是它們擁有不同的speak()方法:
class Animal
{
public:
Animal(const string& name):
m_name{name}
{}
const string& getName() const
{
return m_name;
}
string speak() const
{
return "???";
}
private:
string m_name;
};
class Cat : public Animal
{
public:
Cat(const string& name):
Animal(name)
{}
string speak() const
{
return "Meow";
}
};
class Dog : public Animal
{
public:
Dog(const string& name):
Animal(name)
{}
string speak() const
{
return "Woof";
}
};
我們知道派生類對象可以賦值給基類的指針或者引用,但是我們希望調用這些指針或者引用時,能夠調用各個派生類自己的方法,比如下面的例子:
int main()
{
Cat cat{ "Fred" };
cout << "Cat is named " << cat.getName() << ", and it says " << cat.speak() << endl;
Dog dog{ "Carbo" };
cout << "Dog is named " << dog.getName() << ", and it says " << dog.speak() << endl;
Animal* catAnimal = &cat;
cout << "Cat is named " << catAnimal->getName() << ", and it says " << catAnimal->speak() << endl;
Animal& dogAnimal = dog;
cout << "Dog is named " << dogAnimal.getName() << ", and it says " << dogAnimal.speak() << endl;
return 0;
}
但是輸出并不是預期的那樣:
cat is named Fred, and it says Meow
dog is named Garbo, and it says Woof
pAnimal is named Fred, and it says ???
pAnimal is named Garbo, and it says ???
無論是指針還是引用,它們都沒有調用其派生對象所重寫的方法,而是基類原有的方法。大家可能會想,為什么我非要將派生類對象賦值給基類的指針或者引用來調用派生類的方法?直接利用派生類對象調用不就可以了嗎?這樣做有很多好處,比如你想使用一個函數,接收一個動物對象類,然后打印其名字以及叫聲。但是由于這樣的動物類有兩個,你必須利用重載的思想實現兩個版本:
void print(Cat& cat)
{
cout << cat.getName() << " says " << cat.speak() << endl;
}
void print(Dog& dog)
{
cout << dog.getName() << " says " << dog.speak() << endl;
}
兩個版本實現起來并沒有那么麻煩,但是如果動物類的種類更多呢?這個時候你就有點不樂意了,僅僅是對象類型不同,但是方法是相同的,為什么不能僅寫一個版本:
void print(Animal& animal)
{
cout << animal.getName() << " says " << animal.speak() << endl;
}
如果基類能夠動態確定其實際所指向的派生類對象,并調用合適版本的方法,那么一個函數就可以解決上面的問題。
看來盡管每個派生類都有自己實現的speak()方法,但是它們實際上并沒有真正的重寫基類方法,僅僅是隱藏。因為派生類對象傳遞給基類的指針或者引用并沒有調用派生類版本的方法,依然是基類方法。
所以,你需要虛函數!
虛函數與多態性
虛函數是類方法中的一種特殊函數,當你調用它時,它會匹配派生最遠的重寫版本。這種特性是多態性。匹配的規則是相同的函數簽名(函數名,參數個數與類型)以及返回類型(返回類型可以不相同,但必須存在派生關系)。虛函數僅需要再前面加上一個virtual
關鍵字即可,利用虛函數我們可以修改上面的代碼:
class Animal
{
public:
Animal(const string& name):
m_name{name}
{}
const string& getName() const
{
return m_name;
}
virtual string speak() const
{
return "???";
}
private:
string m_name;
};
class Cat : public Animal
{
public:
Cat(const string& name):
Animal(name)
{}
virtual string speak() const
{
return "Meow";
}
};
class Dog : public Animal
{
public:
Dog(const string& name):
Animal(name)
{}
virtual string speak() const
{
return "Woof";
}
};
此時,再測試一下下面的代碼,可以看到輸出實現了預期的效果:
int main()
{
Cat cat{ "Fred" };
cout << "Cat is named " << cat.getName() << ", and it says " << cat.speak() << endl;
Dog dog{ "Carbo" };
cout << "Dog is named " << dog.getName() << ", and it says " << dog.speak() << endl;
Animal* catAnimal = &cat;
cout << "Cat is named " << catAnimal->getName() << ", and it says " << catAnimal->speak() << endl;
Animal& dogAnimal = dog;
cout << "Dog is named " << dogAnimal.getName() << ", and it says " << dogAnimal.speak() << endl;
return 0;
}
output:
cat is named Fred, and it says Meow
dog is named Garbo, and it says Woof
cat is named Fred, and it says Meow
dog is named Garbo, and it says Woof
可以看到,不論是基類版本還是派生類版本,我們都在函數前面使用了virtual
關鍵字,事實上,派生類中的virtual
關鍵字并不是必要的。一旦基類中的方法打上了virtual
標簽,那么派生類中匹配的函數也是虛函數。但是,還是建議在后面的派生類中加上virtual
關鍵字,作為虛函數的一種提醒,以便后面可能還會有更遠的派生。
注意千萬不要在構造函數與析構函數中調用虛函數。我們知道派生類對象在創建時,首先基類部分先被創建,如果你在基類構造函數調用虛函數時,它此時將無法調用派生類版本的函數,因為派生類對象還未創建,此時派生類虛函數沒有作用的對象。那么,它只能調用基類版本的虛函數。對于析構函數,派生類對象中的派生部分先被析構,如果你在基類析構函數中調用了虛函數,它也只能調用基類版本的虛函數,因為派生類對象已經不存在了。
到底什么時候使用虛函數?大部分時候,我們希望派生類是真正的“重寫”基類函數,而不是“隱藏”。所以一般建議將所有方法都聲明為virtual
。既然如此,為什么編譯器不默認這樣做呢,其實對于Java語言來說,所有的方法默認是虛函數。但是使用虛函數是有代價的,相對于普通函數,虛函數的調用代價稍高,但是這種差別不會太大,所以還是建議所有方法都使用virtual
關鍵字。
override標識符
前面說到,派生類的重寫方法必須與基類方法要匹配,否則編譯器會認為派生類創建了一個新方法,而不是重寫基類的版本,看下面的例子:
class Super
{
public:
virtual string getName1(int x)
{
return "Super";
}
virtual string getName2(int x)
{
return "Super";
}
};
class Sub: public Super
{
public:
virtual string getName1(double x)
{
return "Sub";
}
virtual string getName2(int x) const
{
return "Sub";
}
};
int main()
{
Sub sub;
Super* super = ?
cout << super->getName1(1) << endl; // output: Super
cout << super->getName2(2) << endl; // output: Super
cin.ignore(10);
return 0;
}
可以看到,派生類的兩個虛方法并沒有重寫基類版本,這是由于兩個方法的函數簽名并不一樣。所以將派生類對象賦值給基類的指針只能是調用基類方法。但是,實際上我們希望派生類的兩個方法是重寫版本。有時候,我們很容易犯一些小錯誤導致重寫失敗,比如上面的例子。還有時候,我們修改了基類虛函數,但是沒有更新派生類的對應重載版本,也將有可能使重寫失效。為了避免這樣的錯誤,C++引入了override
標識符,使用這個標識符告訴編譯器這是重寫的方法,如果方法不匹配,那么將無法通過編譯。用override
修改代碼如下:
class Super
{
public:
virtual string getName1(int x)
{
return "Super";
}
virtual string getName2(int x)
{
return "Super";
}
};
class Sub: public Super
{
public:
virtual string getName1(double x) override
{
return "Sub";
}
virtual string getName2(int x) const override
{
return "Sub";
}
// 此時無法編譯
};
所以,只要重寫基類方法,建議使用override
標識符,避免無意的錯誤。
final標識符
有時候,你不想派生類重寫基類的虛方法,此時可以使用final
標識符,這個時候如果派生類重寫了基類虛方法,那么將無法編譯:
class A
{
public:
virtual void someMethod() { cout << "A" << endl; }
}
class B: public A
{
public:
// 基類A的someMethod方法沒有final標識符,那么B可以重寫該方法
// 但是此虛方法使用了final標識符,后面的派生類無法重寫
virtual void someMethod() override final { cout << "B" << endl; }
}
class C: public B
{
public:
// 無法編譯,因為不允許重寫
virtual void someMethod() override { cout << "C" << endl; }
}
而且final
標識符還可以直接用于類,此時該類將不能被繼承:
class A
{
public:
virtual void someMethod() { cout << "A" << endl; }
};
// B可以繼承A
class B final: public A
{
public:
virtual void someMethod() override { cout << "B" << endl; }
};
// B無法被繼承,此時無法編譯
class C: public B
{
public:
virtual void someMethod() override { cout << "C" << endl; }
};
協變返回類型
前面說過,要想成功重寫方法,基類虛方法與派生類虛方法必須匹配,其中返回類型也必須一致。但是有時候返回類型不相同,也能實現重寫,此時返回類型存在繼承關系:基類方法返回類型是一個指向某一類的指針或者引用,而派生類重寫版本的返回類型是指向派生類的指針或者引用。這種情況稱為協變返回類型。下面是一個例子:
class Super
{
public:
virtual Super* getThis() { return this; }
};
class Sub : public Super
{
virtual Sub* getThis() override { return this; }
};
析構函數要聲明為虛函數
對于析構函數,大部分時間我們只需要使用編譯器提供的默認版本就好,除非涉及到釋放動態分配的內存。但是如果存在繼承,虛函數最好聲明為虛函數。否則刪除一個實際指向派生類的基類指針,只會調用基類的析構函數,而不會調用派生類的析構函數以及派生類數據成員的析構函數。這樣就可能造成內存泄露,看下面的例子:
class Resource
{
public:
Resource() { cout << "Resource created!" << endl; }
~Resource() { cout << "Resource destoryed!" << endl; }
};
class Super
{
public:
Super() { cout << "Super constructor called!" << endl; }
~Super() { cout << "Super destructor called!" << endl; }
};
class Sub : public Super
{
public:
Sub() { cout << "Sub constructor called!" << endl;}
~Sub() { cout << "Sub destructor called!" << endl;}
private:
Resource res;
};
如果執行下面的代碼:
int main()
{
Sub* sub = new Sub;
Super* super = sub;
delete super;
cin.ignore(10);
return 0;
}
其輸出為:
Super constructor called!
Resource created!
Sub constructor called!
Super destructor called!
可以看到,派生類的析構函數沒有執行,其數據成員Resource也沒有被析構。但是如果你將析構函數都聲明為虛函數,上面的代碼將得到如下的結果:
Super constructor called!
Resource created!
Sub constructor called!
Resource destoryed!
Sub destructor called!
Super destructor called!
此時,程序按照預期輸出,所以,對于繼承問題,沒有理由不將析構函數聲明為虛函數!
函數調用捆綁
要想深刻理解虛函數機理,首先要了解函數調用捆綁機制。捆綁指的是將標識符(如變量名與函數名)轉化為地址。這里我們僅僅關注有關函數調用的捆綁。我們知道每個函數在編譯的過程中是存在一個唯一的地址的。如果我們在程序段里面直接調用某個函數,那么編譯器或者鏈接器會直接將函數標識符替換為一個機器地址。這種方式是早捆綁,或者說是靜態捆綁。因為捆綁是在程序運行之前完成的。看下面的簡單例子:
int add(int x, int y)
{
return x + y;
}
int subtract(int x, int y)
{
return x - y;
}
int multiply(int x, int y)
{
return x * y;
}
int main()
{
int x;
cout << "Enter a number: ";
cin >> x;
int y;
cout << "Enter another number: ";
cin >> y;
int op;
cout << "Enter an operation (0=add, 1=subtract, 2=multiply): ";
cin >> op;
int result;
switch (op)
{
// 使用早綁定來直接調用函數
case 0: result = add(x, y); break;
case 1: result = subtract(x, y); break;
case 2: result = multiply(x, y); break;
}
cout << "The answer is: " << result << endl;
return 0;
}
由于上面三個函數的調用都是直接使用函數名,采用早捆綁的方式。編譯器會將每個函數調用替換為一個跳轉指令,這個指令告訴CPU跳轉到函數的地址來執行。
但是有時候,我們在程序運行前并不知道調用哪個函數,此時必須使用晚捆綁或者動態捆綁。晚綁定的一個例子就是使用函數指針,修改上面的例子:
int main()
{
int x;
cout << "Enter a number: ";
cin >> x;
int y;
cout << "Enter another number: ";
cin >> y;
int op;
cout << "Enter an operation (0=add, 1=subtract, 2=multiply): ";
cin >> op;
// 定義一個函數指針
int(*opFun)(int, int) = nullptr;
switch (op)
{
// 使用早捆綁來直接調用函數
case 0: opFun = add; break;
case 1: opFun = subtract; break;
case 2: opFun = multiply; break;
}
// 通過函數指針來調用,只能是晚捆綁
cout << "The answer is: " << opFun(x, y) << endl;
return 0;
}
使用函數指針來間接調用函數,編譯器在編譯階段并不知道函數指針到底指向哪個函數,所以必須使用動態捆綁的方式。
動態綁定看起來更靈活,但是其是有代價的。靜態捆綁時,CUP可以直接跳轉到函數地址。但是動態捆綁,CPU必須先提取指針的地址,然后再跳轉到指向的函數地址。這多了一個步驟!
虛函數表(Vtable)
C++使用了一種稱為“虛表”的晚捆綁技術來實現虛函數。虛表是一個函數查詢表,以動態捆綁的方式解析函數調用。每個具有一個或者多個虛函數的類都有一張虛表,這個表是在編譯階段建立的靜態數組,其中包含了每個虛方法的函數指針,這些指針指向的是該類可見的派生最遠的函數實現。其次,編譯器會在基類對象都會添加一個隱含指針,這里我們稱為*__vptr
。這個指針當然能夠被派生類所繼承,這相當重要。當類的實例被創建時,這個指針指向該類所對應的虛表。這樣,當使用某個對象調用虛方法時,通過該指針查找虛表,然后根據實際的對象類型執行正確版本的方法調用。看下面的簡單例子:
class Base
{
public:
virtual void function1() { }
virtual void function2() { }
}
class D1: public Base
{
public:
virtual void function1() override { }
}
class D2: public Base
{
public:
virtual void function2() override { }
}
上面包含3個類,其中派生類D1與D2分別重寫了基類的function1()和function2()虛方法。編譯器會相應地創建3個不同的虛表,分別對應每個類。而且編譯器也會自動地為基類添加一個函數指針,如下所示:
class Base
{
public:
FunctionPointer *__vptr;
virtual void function1() { }
virtual void function2() { }
}
class D1: public Base
{
public:
virtual void function1() override { }
}
class D2: public Base
{
public:
virtual void function2() override { }
}
這樣,每個類實例創建時,*__vptr
將指向該類所對應的虛表,比如基類的一個實例創建時,這個指鎮就指向基類的虛表。
下面我們看看每個類的虛表是怎么建立的。因為僅有兩個虛方法,所以每個虛表僅包含兩個函數指針,分別對應function1()和function2()。但是每個函數指針實際指向的是那個類所可見的派生最遠的函數實現:
- Base的虛表:因為Base的實例僅可見自己的成員,所以它的虛表中的指針分別指向Base::function1()和Base::function2();
- D1的虛表:D1的實例可見Base的成員與自身的成員,但是D1僅重寫了function1(),所以虛表中的指針分別指向D1::function1()和Base::function2();
- D2的虛表:與D1類似,分別指向Base::function1()和D2::function2()。
下面是具體的示意圖(來源:learncpp):
所以,下面的代碼就有了很好的解釋:
int main()
{
D1 d1; // d1中的*__vptr指向類D1的虛表
Base *dPtr = &d1; // dPtr對*__vptr是可見的,但是實際上其指向的是D1的虛表;
dPtr->function1(); // 此時dPtr通過虛表查找,調用的是D1::function1()
}
使用虛表技術,虛函數得以正確實現!從而實現多態性!
純虛函數與抽象基類
有時候,基類的某個虛方法并不需要實現,但是希望派生類能夠提供重寫的版本。這個時候,你需要定義純虛函數。純虛函數在類的定義中顯示說明該方法不需要實現,其作用在于指明派生類必須要重寫它。純虛函數的定義很簡單:方法聲明后緊跟著=0。如果一個類中至少含有一個純虛函數,那么這個類是抽象基類,因為這個類無法實例化。當繼承一個抽象類時,必須重寫所有純虛函數,否則繼承出來的類也是一個抽象類。下面演示例子:
class Animal
{
public:
Animal(const string& name):
m_name{name}
{}
const string& getName() const
{
return m_name;
}
virtual string speak() const = 0; // 純虛函數
// 因為包含一個純虛方法,所以是抽象基類
private:
string m_name;
};
class Cat : public Animal
{
public:
Cat(const string& name):
Animal(name)
{}
// 重寫了純虛方法,所以Cat不是抽象類,可以實例化
virtual string speak() const
{
return "Meow";
}
};
// Dog沒有重寫基類的純虛方法,所以仍然無法實例化
class Dog : public Animal
{
public:
Dog(const string& name):
Animal(name)
{}
};
int main()
{
// Animal animal{"luly"}; // 無法編譯,因為抽象基類無法實例化
Cat cat{ "Sally" }; // 合法
// Dog dog{ "Betsy" }; // 非法,抽象類無法實例化
// 下面的代碼可以運行,因為可以指向可以實例化的派生類對象
Animal* aPtr = new Cat{ "Sally" };
cin.ignore(10);
return 0;
}
抽象類至少包含一個純虛方法,抽象類提供了一種禁止其他代碼直接實例化對象的方法,但是重寫純虛方法的派生類可以實例化。
接口類
接口是一個抽象的概念,使用者只關注功能而不要求了解實現。一個接口類可以看成一些純虛方法的集合,這意味著接口類僅有定義功能,而沒有具體的實現。C++ 其實沒有單獨的接口概念,而在Java和C#等語言中接口是與類相區別的。但是 C++ 仍然可以使用接口類實現類似的效果。有時候,我們也稱接口類為純抽象類,因為這個類中全是虛方法。下面是一個純抽象類的例子:
// 樂器純抽象類
class Instrument
{
public:
virtual void play() const = 0;
virtual string what() const = 0;
virtual void adjust(int) = 0;
};
class Wind: public Instrument
{
public:
virtual void play() const override
{
cout << "Wind: paly" << endl;
}
virtual string what() const override
{
return "Wind";
}
virtual void adjust(int i) override {}
};
class Brass : public Instrument
{
public:
virtual void play() const override
{
cout << "Brass: paly" << endl;
}
virtual string what() const override
{
return "Brass";
}
virtual void adjust(int i) override {}
};
void tune(Instrument& i)
{
// ...
i.play();
}
void f(Instrument& i)
{
i.adjust(1);
}
int main()
{
Wind wind;
Brass brass;
tune(wind);
tune(brass);
f(wind);
f(brass);
return 0;
}
可以看到Instrument是一個純抽象類,其只提供方法的聲明,具體卻沒有實現。但是它的兩個派生類分別重寫了這些純虛方法,因此可以實例化。并且兩個函數可以接收任意繼承了Instrument的類實例對象。進一步說,這兩個函數僅關注接收的對象是否提供了Instrument所要求的接口,但是不關注具體是怎么實現的。純抽象類提供了更高級的抽象!這符合OOP的思想。
虛基類
虛基類主要是用來解決菱形層次結構中的歧義基類問題。菱形層次結構是多重繼承中的一個典例,還是例子說話:
class PoweredDevice
{
public:
PoweredDevice(int power)
{
cout << "PoweredDevice: " << power << endl;
}
virtual void reportError() { cout << "Error" << endl; }
};
class Scanner : public PoweredDevice
{
public:
Scanner(int scanner, int power) :
PoweredDevice(power)
{
cout << "Scanner: " << scanner << endl;
}
};
class Printer : public PoweredDevice
{
public:
Printer(int printer, int power) :
PoweredDevice(power)
{
cout << "Printer: " << printer << endl;
}
};
class Copier : public Scanner, public Printer
{
public:
Copier(int scanner, int printer, int power):
Scanner(scanner, power), Printer(printer, power)
{}
};
int main()
{
Copier copier(1, 2, 3);
// output:
// PoweredDevice: 3
// Scanner : 1
// PoweredDevice : 3
// Printer : 2
// 可以看到PoweredDevice被繼承了兩次
// 無法編譯,有歧義,因為繼承了兩個版本的PoweredDevice
copier.reportError();
return 0;
}
上面的繼承關系有點復雜,但是畫出繼承圖譜(來源:learncpp)就很清晰了:
Scanner和Printer分別繼承了PoweredDevice類,然后Copier又同時繼承了Scanner和Printer類,我們實際希望Copier僅繼承一次PoweredDevice類,但是實際上Copier包含了兩個版本的PoweredDevice。所以可以看到,次PoweredDevice被構造了兩次。而且更嚴重的是,PoweredDevice中沒有被重寫的方法是無法調用的,因為編譯器會給出一個有歧義的錯誤!解決這個錯誤的方法很多,比如你可以在Copier類中明確聲明繼承的版本:using Scanner::PoweredDevice::reportError;
。但是這本質上沒有解決多版本的繼承問題。
此時,你可以用虛基類,使用虛基類,只需要在繼承列表中加上virtual
關鍵字:
class PoweredDevice
{
public:
PoweredDevice(int power)
{
cout << "PoweredDevice: " << power << endl;
}
virtual void reportError() { cout << "Error" << endl; }
};
class Scanner : virtual public PoweredDevice
{
public:
Scanner(int scanner, int power) :
PoweredDevice(power)
{
cout << "Scanner: " << scanner << endl;
}
};
class Printer : virtual public PoweredDevice
{
public:
Printer(int printer, int power) :
PoweredDevice(power)
{
cout << "Printer: " << printer << endl;
}
};
class Copier : public Scanner, public Printer
{
public:
// Note: 虛基類是由派生最遠的類負責創建,所以,
// 構造函數初始化列表中需要增加虛基類的構造函數調用
Copier(int scanner, int printer, int power):
Scanner(scanner, power), Printer(printer, power),
PoweredDevice(power)
{}
};
int main()
{
Copier copier(1, 2, 3);
// 合法
copier.reportError();
// output:
// PoweredDevice: 3
// Scanner : 1
// Printer : 2
// 可以看到PoweredDevice繼承了一次
return 0;
}
利用虛基類,可以解決上面多重繼承中歧義基類問題,基類僅被繼承一次。但是要注意的是此時的虛基類由派生最遠的類負責創建(可以看成該類的直接基類),因為PoweredDevice并沒有無參構造函數,所以在Copier構造函數初始化列表中必須加上PoweredDevice的有參構造函數調用!
說點題外話,盡管虛基類可以解決多重繼承中的菱形層次結構,但是看起來還是很抽象與復雜。實際上,多重繼承本來就是一個很有爭議的話題,因為使用多重繼承會使得繼承體系變得復雜,而且產生一系列問題,像Java和C#這類語言,是不允許多重繼承的,但是其單獨提供了接口,類可以繼承多個接口,這也相當于多重繼承了。而且好處是接口的繼承相當于組合,這也是比較推崇的!
對象切片
前面講過,實現虛函數及多態性必須要用傳地址的方式(引用或者指針)。一般,地址具有相同的長度,這意味著派生類對象的地址與基類對象的地址也是相同,盡管派生類對象所占的內存一般要高過基類對象。所以,傳地址的方式不會導致類型信息損失,進而可以實現多態性。看下面的例子:
class Base
{
public:
Base(int value):
m_value{value}
{}
virtual string getName() const { return "Base"; }
int getValue() const { return m_value; }
protected:
int m_value;
};
class Derived: public Base
{
public:
Derived(int value):
Base(value)
{}
virtual string getName() const override { return "Derived"; }
}
int main()
{
Derived derived{ 5 };
cout << "derived is a " << derived.getName() << " with value " << derived.getValue() << endl;
// output: derived is a Derived with value 5
Base& ref = derived;
cout << "ref is a " << ref.getName() << " with value " << ref.getValue() << endl;
// output: ref is a Derived with value 5
Base* ptr = &derived;
cout << "ptr is a " << ptr->getName() << " with value" << ptr->getValue() << endl;
// output: ptr is a Derived with value 5
Base base = derived;
cout << "base is a " << base.getName() << " with value " << base.getValue() << endl;
// output: base is a Base with value 5
return 0;
}
可以看到使用引用或者指針的方式,多態性都能夠實現,但是傳值的方式就存在問題。當我們將一個派生類對象直接賦值給基類對象時,僅僅基類的部分被復制,派生類的那部分信息將丟失。我們稱這種現象為“對象切片”:對象丟失了自己原有的部分信息。使用對象本身并沒有問題,但是處理不當,會造成很多問題,看下面的例子:
int main()
{
Derived d1{5};
Derived d2{2};
Base& b = d2;
b = d1; // 有隱患
return 0;
}
上面的例子很簡單,但是會有問題:首先d2引用給b時,b將指向d2,這沒有問題。但是將d1的值直接賦值給b時,會發生對象切片,只有d1的基類部分復制給b。此時,問題來了,你會發現現在d2擁有d1的基類部分與d2的派生部分,這顯得很混亂!所以,盡可能地別使用對象切片,否則你會麻煩不斷!
動態轉型
前面的例子,我們都是將派生類對象復制給基類對象,不管是通過傳地址的方式還是對象切片方式。這些都是向上轉型——在類層次中向上移動。我們不禁會想,肯定會存在可以向下移動的向下轉型。一般來說,派生類包含基類信息,所以向上轉型是容易的。但是,反過來可能會失敗!因為無法保證基類對象實際上存儲的是派生類對象。看下面的例子:
void process(Base* ptr)
{
Derived* derived = static_cast<Derived*>(ptr);
// 后序處理
// ...
}
process函數接收一個基類指針,但是在內部使用static_cast
向下轉型為派生類指針,然后進行后序處理。如果送入process函數的指針實際上就是指向派生類對象,那么上面的代碼是沒有問題的。但是,如果僅僅傳入就是指向基類對象的指針,或者指向其他派生類的指針,那么函數內部的轉型將存在問題:由于static_cast
在運行時是不檢查對象實際類型的,這將導致不可控行為!
為了解決這樣的隱患,C++引入了運行時的動態類型轉化操作符dynamic_cast
。dynamic_cast
在運行時檢測底層對象的類型信息。如果類型轉換沒有意義,那么它將返回一個空指針(對于指針類型)或者拋出一個std::bad_cast
異常(對于引用類型)。所以,可以修改上面的代碼如下:
void process(Base* ptr)
{
Derived* derived = dynamic_cast<Derived*>(ptr);
if (derived == nullptr)
{
// 后序處理
// ...
}
}
盡管如此,向下轉型還是不推薦的,除非必要!
Reference
[1] cpp leraning online(本文按照該教程書寫,作者人很nice,可以直接留言).
[2] Marc Gregoire. Professional C++, Third Edition, 2016.
[3] cppreference
[4] Bruce Eckel, Chuck Allison. Thinking in C++, Second Edition, 2011.