在C++中順利使用虛函數需知道的細節
- 如函數在派生類中的定義有別於基類中的定義,而且你希望它成為虛函數,就要為基類的函數聲明添加保留字
virtual。在派生類的函數聲明中,則可以不添加virtual。函數在基類中virtual,在派生類中自動virtual(但為了澄清,最好派生類中也將函數聲明標記為virtual,盡管這非必須)。 - 保留字
virtual在函數聲明中添加,不要再函數定義中添加。 - 除非使用保留字
virtual,否則不能獲得虛函數,也不能獲得虛函數的任何好處。 - 既然虛函數如此好用,為何不將所有成員函數都設為
virtual?這似乎只有一個理由——效率。編譯器和“運行時”環境要為虛函數做多得多的工作。所以,無謂地將成員函數為virtual會影響程序執行效率。
重寫
虛函數定義在派生類中發生改變時我們說函數定義被重寫。一些C++書籍區分了重定義(redefine)和重寫(override)。兩者都是在派生類更改函數定義。函數是虛函數,就稱為重寫。如果不是,就稱為重定義。對於我們程序員而言,這種區分似乎有點無聊,因為程序員在兩種情況下做的事情是一樣的。不過,編譯器對於這兩種情況確定是區別對待的。
多態
多態性是指借助晚期綁定技術,為一個函數名關聯多種含義的能力。因此,多態性、晚期綁定和虛函數其實是同一個主題。
虛函數和擴展類型兼容性、切割問題
#include <iostream>
#include <string>
using std::cout;
using std::endl;
using std::string;
class Pet
{
public:
virtual void print();
string name;
};
class Dog : public Pet
{
public:
virtual void print();
string breed; // 品種
};
void Pet::print()
{
cout << "Pet name: " << name << endl;
}
void Dog::print()
{
cout << "Dog name: " << name << ", breed: " << breed << endl;
}
int main()
{
Pet vPet;
Dog vDog;
vDog.name = "Tiny";
vDog.breed = "Great Dane";
vPet = vDog;
// cout << vPet.breed;
return 0;
}
上述代碼vPet = vDog;的賦值是允許的,但賦給變量vPet的值會丟失其breed字段。這稱為切割問題(slicing problem)。例如,cout << vPet.breed會報錯。
切割問題:在將派生類對象賦給基類變量時,派生類對象有、基類沒有的數據成員會在賦值過程中丟失,基類沒有的成員函數也會丟失。在最終的基類對象中,將無法使用這些丟失的成員。
切割測試:
#include <iostream>
#include <string>
using std::cout;
using std::endl;
using std::string;
class Demo
{
public:
Demo(const string& s): str(s)
{
cout << "Demo constructor called (" + str + ").\n";
}
~Demo()
{
cout << "Demo deconstructor called (" + str + ").\n";
}
Demo(const Demo& other)
{
str = other.str;
cout << "Demo copy constructor called (" + str + ").\n";
}
Demo& operator=(const Demo& other)
{
str = other.str;
cout << "Demo operator= called (" + str + ").\n";
return *this;
}
private:
string str;
};
class Base
{
public:
Demo member1 = Demo("member1");
};
class Derived : public Base
{
public:
Demo member2 = Demo("member2");
};
int main()
{
Derived derived;
Base base;
base = derived;
}
/* Output
Demo constructor called (member1).
Demo constructor called (member2).
Demo constructor called (member1).
Demo operator= called (member1).
Demo deconstructor called (member1).
Demo deconstructor called (member2).
Demo deconstructor called (member1).
*/
幸好,C++提供了一種方式,允許在將一個Dog視為Pet的同時不丟失品種名稱:
Pet *pPet;
Dog *pDog;
pDog = new Dog;
pDog->name = "Tiny";
pDog->breed = "Great Dane";
pPet = pDog;
pPet->print(); // prints "Dog name: Tiny, breed: Great Dane"
基類Pet把print()聲明為virtual。所以一旦編譯器看到pPet->print();就會檢查Pet和Dog的virtual表,判斷pPet指向的是Dog類型的對象。因此,它會使用Dog::print(),而不是Pet::print()。
配合動態變量進行OOP是一種全然不同的編程方式。只要記住以下兩條簡單的規則,理解起來就容易得多。
- 如果指針
pAncestor的域類型是指針pDescendant的域類型的基類,則以下指針賦值操作允許:pAncestor = pDescendant;。此外,pDescendant指向的動態變量的任何數據成員或成員函數都不會丟失。 - 雖然動態變量所有附加字段(成員)都沒有丟,但要用
virtual成員函數訪問。
視圖對虛成員函數定義不齊全的類進行編譯
編譯前,如果還有任何尚未實現的virtual成員函數,編譯就會失敗,並產生形如undefined reference to Class_Name virtual table的錯誤信息。即使沒有派生類,只有一個virtual成員,並且沒有調用該虛函數,只要函數沒有定義,就會產生這種形式的消息。此外,可能還會產生進一步的錯誤消息,聲稱程序對默認構造函數進行了未定義的引用,即使確實已定義了這些構造函數。
始終/盡量使析構函數成為虛函數(主要講述把析構函數聲明為虛函數的優點)
這里主要闡述讓析構函數稱為虛函數的好處,但實際上也有壞處。在《Effective C++》條款07中有提到具體內容,見本文后記。
析構函數最好都是虛函數。但在解釋它為什么好之前,首先解釋一下析構函數和指針如何交互,以及虛析構函數的具體含義。如以下代碼,其中SomeClass是含有非虛析構函數的類:
SomeClass *p = new SomeClass;
// ...
delete p;
為p調用delete,會自動調用SomeClass類的析構函數,現在看看將析構函數標記為virtual之后會發生什么。為了描述析構函數與虛函數機制的交互,最簡單的方式是將所有析構函數都視為同名(即使它們並非真的同名)。如假定Derived類是Base類的派生類,並假定Base類的析構函數標記為virtual,現在分析以下代碼:
Base *pBase = new Derived;
// ...
delete pBase;
為pBase調用delete時,會調用一個析構函數。由於Base類中的析構函數標記為virtual,且指向的對象是Derived類型,故會調用Derived的析構函數(它進而調用Base類的析構函數)。若Base類的析構函數沒有標記為virtual,則只調用Base類的析構函數。
還要注意一點,將析構函數標記為virtual后,派生類的所有析構函數都自動成為virtual的(不管是否用virtual標記)。同樣,這種行為就好比所有析構函數具有相同的名稱(即使事實上不同名)。
現在,已准備好解釋為什么所有析構函數都應該是虛函數。假定Base類有一個指針類型的成員變量pB,Base類的構造函數會創建由pB指向的一個動態變量,而Base類的析構函數會刪除之;另外,假定Base類的析構函數沒有標記為virtual,並假定Derived類(從Base派生)有一個指針類型的成員變量pD,Derived類的構造函數會創建由pD指向的一個動態變量,而Derived類的析構函數會刪除之。則以下代碼
Base *pBase = new Derived;
// ...
delete pBase;
由於基類析構函數未標記為virtual,所以只會調用Base類的析構函數。這會將pB指向的動態變量的內存返還給自由存儲;但pD指向的動態變量占用的內存永遠不會返還給自由存儲直到程序終止。
另一方面,將基類Base析構函數標記為virtual,delete pBase;時會調用Derived類的析構函數(因為指向的對象是Derived類型)。Derived類的析構函數會刪除pD指向的動態變量,再自動調用基類Base的析構函數刪除pB指向的動態變量。
測試代碼:
#include <iostream>
class Base
{
public:
Base()
{
baseData = new int;
std::cout << "baseData allocated.\n";
}
~Base()
{
delete baseData;
std::cout << "baseData deleted.\n";
}
private:
int *baseData;
};
class Derived : public Base
{
public:
Derived()
{
derivedData = new int;
std::cout << "derivedData allocated.\n";
}
~Derived()
{
delete derivedData;
std::cout << "derivedData deleted.\n";
}
private:
int *derivedData;
};
int main()
{
Base *base = new Derived;
delete base;
}
/* Output
baseData allocated.
derivedData allocated.
baseData deleted.
*/
將第11行的~Base()改為virtual ~Base(),程序輸出為
/* Output
baseData allocated.
derivedData allocated.
derivedData deleted.
baseData deleted.
*/
后記
參考:Walter Savitch《Problem Solving with C++, Tenth Edition》《Effective C++》。
《Effective C++》條款07:“為多態基類聲明virtual析構函數”中提到:
- 帶多態性質的基類應該聲明一個virtual析構函數;如果類帶有任何virtual函數,則它就應該擁有一個virtual析構函數。
- 類的設計目的如果不是作為基類使用,或不是為了具備多態性,就不該聲明virtual析構函數。(如標准庫
input_iterator_tag等)
