什麼時候不應當使用虛擬函式--《C++沉思錄》
有人認為虛擬函式比非虛擬函式更根本,所有成員函式都應該預設為虛。更有甚者,有些人建議說根本沒有理由不使用虛擬函式,所有成員函式都必須自動地為虛擬函式。爭論背後的理論似乎非常吸引人,值得仔細研究以便理解問題之所在。
1. 適用的情況
首先,我們注意到,如果只是關注程式的行為,同時沒有繼承關係,那麼函式是否為虛擬函式根本無關緊要。因此,即使存在爭論,沒有使用繼承的程式設計師仍可以不假思索地把他們所有的函式設為虛擬函式。只有當涉及到繼承時,才有必要考慮一些問題。
可是在使用到繼承的程式中,爭論仍然繼續存在,說是把所有函式都設為虛擬函式可以獲得更大的靈活性。作為一個簡化了的例子,考慮一個表示整數陣列的 IntArray 類:
class IntArray{
public:
// ...
unsigned size() const;
int & operator[] (unsigned n);
};
我們可以寫一個函式來將陣列的所有元素設定為零:
void zero(IntArray & x)
{
for (int i = 0; i < x.size(); i++)
x[n] = 0;
}
在類似這樣的情況下,上述觀點認為 IntArray::operator[] 和 IntArray::size() 應該為虛擬函式。例如,有人希望從 IntArray 派生出一個類 IntFileArray,該類在檔案中而不是直接在記憶體中儲存這些函式。如果成員函式 IntArray::operator[] 和 IntArray::size() 是虛擬函式,那麼 zero 函式在 IntFileArray 物件中也能正常執行;如果不是虛擬函式,則 zero 函式將不能正常執行。
2. 不適用的情況
儘管這個觀點很吸引人,但是仍然有一些問題:
* 虛擬函式的代價並不是十分高昂,但也不是免費午餐;在使用它們之前,要認真考慮其開銷,這一點十分重要。
* 有些情況下非虛擬函式能夠正確執行,而虛擬函式卻不行。
* 不是所有類都是為了繼承而設計的。
2.1 效率
如果一個程式呼叫某個顯示提供的物件的虛擬成員函式,那麼優秀的編譯器不應該帶來任何額外的開銷;例如
T x;
x.f();
然而一旦通過指標或者引用進行呼叫,那就不是沒意義的了:
void call_f(T* tp) { tp->f(); }
這裡,tp 指向 T 類的一個物件,也可能是某個 T 類派生類的物件,所以,如果 T::f 是虛擬函式,則必須運用虛擬函式機制進行呼叫。虛擬函式的查詢開銷值得我們關注嗎?
這要視情況而定。想要知道某個程式在這方面的實際開銷,必須在不同機器上測量開銷,不過通過對記憶體引用(memory reference)進行計數來獲得一個大概值還是可能的。例如,讓我們回顧一下 IntArray::operator[] 成員函式。實現一個數組類的典型方法是令它的建構函式分配適當數量的記憶體,在這種情況下 operator[] 就類似於下面的程式碼:
int& IntArray::operator[] (unsigned n)
{
if(n >= arraysize)
throw "subscript out of range";
return data[n];
}
除了呼叫函式的開銷外,這個函式還需要 3 個記憶體引用,以便分別獲得 n、arraysize 和 data 的值。怎樣將這個開銷與呼叫虛擬函式的開銷進行比較?此外,怎樣將這個開銷與呼叫非虛成員函式的開銷進行比較?
因為我們假設開銷足夠大,所以將這個函式內聯。因此,一個好的實現在直接通過物件使用 operator[] 時根本不會引入新的開銷。通過指標或者引用呼叫 operator[] 的開銷可能與 3 個記憶體引用有關:一個是對指標本身的,另一個是為這個成員函式初始化 this 指標的,還有一個是用於呼叫返回序列的。因此,當我們通過指標或者引用來呼叫這個小成員函式時,所花的時間應該差不多是直接為某個物件呼叫這個函式所花時間的兩倍。呼叫一個虛擬函式通常由 3 個記憶體引用取出:一個從物件取出描述物件型別的表的地址值,一個取出虛擬函式的地址,第三個則在可能的較大外圍物件中,取出本物件的偏移量。在這樣的實現中,把一個函式變成虛擬函式需要 3 倍於執行時間,而非是兩倍的執行時間。
這個開銷值得關注嗎?這要取決於具體應用。顯然,成員函式越大,變為虛擬函式就越不會是問題。實際上,同樣的觀點也適用於邊界檢查:去掉它就會減掉函式本身的 3 個記憶體引用中的一個,所以我們有理由說邊界檢查使函式慢了 50%。而且一個好的優化的編譯器可能會使我們所有的估算落空。如果你關注到底要花多長時間,就應當進行測量。不過,這種粗略的分析還是說明了虛擬函式的開銷可能相當大。
有時候只需要稍加思索,我們就可以在不明顯增加任何開銷的情況下獲得類似虛擬函式的靈活性。例如,考慮一個表示輸入緩衝區的類。跟 C 有一個庫函式 getc 類似,我們希望自己的類有一個叫做 get 的成員函式,返回一個 int,返回值將包含一個字元或者 EOF。另外,我們還希望人們能夠從我們的類中派生出新的類來實現不同的緩衝策略。
一種明顯的方法如下編寫程式碼
class InputBuffer{
public:
// ...
virtual int get();
// ...
};
這樣凡是這個類的派生類只要需要都可以改寫 get,但是這個方法的潛在開銷很大。考慮下面這個計算緩衝區的行數的函式:
int countlines(InputBuffer& b)
{
int n = 0;
int c;
while ((c = b.get()) != EOF){
if (c == '\n')
n++;
}
return n;
}
從這個函式對於所有 InputBuffer 的派生類都有效這一點來說,它是很靈活的。但是,每個對 get 的呼叫都是虛擬函式呼叫,所以要消耗大約 6 個記憶體引用(3 個是函式固有的,多出的 3 個是虛擬函式開銷)。因此,函式呼叫開銷很可能是主導迴圈執行時間的主要因素。
如果我們認識到,使用緩衝的應用程式很可能是要一次性地訪問多個字元,那麼就可以把 InputBuffer 類的設計做得好很多。比如,假設我們編寫如下程式碼:
class InputBuffer{
public:
// ...
int get(){
if(next >= limit)
return refill();
return *next++;
}
protected:
virtual int refill();
private:
char* next;
char* limit;
};
我們還假設在緩衝區中有一定數量的字元處於等待狀態。資料成員 next 指向第一個這樣的字元;資料成員 limit 指向最後一個字元之後的首個記憶體位置。因此對
next >= limit 的測試判斷了是否已經沒有可用的字元了。如果沒有可用字元了,我們就呼叫 refill 函式,以獲得更多字元。如果呼叫成功,這個函式將重新適當地設定 next 和 limit,並返回第一個這樣的字元;如果失敗,這返回 EOF。
我們假設在大多數普通情況下都有字元存在;此時我們簡單地返回 *next++。這樣將獲得下一個可用的字元並轉到下一步操作。
關鍵在於 get 現在是內聯的,而不是虛擬函式的。如果存在一個可用字元,執行時就需要大約 4 個記憶體引用:兩個用於比較,一個取出字元,剩下的一個儲存 next 的新值。如果我們必須呼叫 refill,消耗當然也會更大,但是如果 refill 從它的輸入那兒獲得了一個足夠大的記憶體塊,那麼就沒有什麼需要擔心的了。
因此,在這個例子中,我們將 get 通常情況下的開銷從 6 個記憶體引用,加上虛擬 get 函式的程式碼儲存,減小到總共只比 4 個記憶體引用稍微多一點的開銷。如果我們假設 get 的函式版本和非虛擬函式版本所做的工作一樣多(很能想象如何能做得更少),那麼在決策上的改變就使 get 的開銷從 10 個記憶體引用減少到 4 個記憶體引用,速度增加兩倍多。
2.2 你想要什麼樣的行為
派生類總是嚴格地擴充套件其基類的行為。也就是說,通常派生類物件可以在不改變程式行為的情況下替代基類物件。但是,也有一些不屬於此類的情況;在這些情況下,虛擬函式可能導致非預期的行為。
我們可以在 IntArray 類的基礎上建立一個例子來說明這種情況。首先,我們稍微充實一下它的宣告:
class IntArray{
public:
IntArray(unsigned);
int& operator[] (unsigned);
unsigned size() const;
// ...
};
假設我們通過給定 IntArray 的大小來構造其物件,並且支援下標操作。
假設現在我們從這個類派生類一個 IntBlock 類,該類與 IntArray 類似,但是它的初始元素的下標不必為零:
class IntBlock: public IntArray{
public:
IntBlock(int l, int h): low(1), high(h);
IntArray(l > h ? 0: h - l + 1) {}
int & operator[] (int n){
return IntArray::operator[](n - low);
}
private:
int low, high;
};
這個類定義相當明確:要構造一個下邊界為 l、上邊界為 h 的 IntBlock,我們就要構造一個有 h - l + 1 個元素的陣列,如果元素個數為負數就令其為零。下標操作也很簡單:我們使用適當的索引值呼叫基類的下標操作符。
現在考慮一個將 IntArray 中的所有元素相加的函式:
int sum(IntArray & x)
{
int result = 0;
for (int i = 0; i < x.size(); i++)
result += x[i];
return result;
}
如果我們傳給這個函式的型別是 IntBlock 而不是 IntArray,情況又會怎樣?
答案是隻有 operator[] 是非虛擬函式,才可以正確地將 IntBlock 的元素相加!關鍵在於 sum 把它的引數當作一個 IntArray,這樣它就可以真正從引數中得到 IntArray 的行為了。它還特別期望第一個元素的下標為 0,而且還期望 size 函式要返回元素的個數。由於 IntBlock 不是 IntArray 的嚴格擴充套件,所以只有 operator[] 不是虛擬函式,這個行為才會出現。
2.3 不是所有的類都是通用的
不使用虛擬函式的第三個原因是有些函式只是為特定的有限制的用途而設計的。
我們常常認為,類的介面由公有成員組成,類的實現由其他東西組成。而介面是一種與使用者交流的方式,類可以有兩種使用者:使用該類物件的人和從這個類派生新類的人。
每個類都有第一種使用者,即使這個唯一的使用者是類設計者本人,但是有的類則絕對不允許有第二種使用者。換句話說,有的時候我們在設計一個類時,會故意不考慮其他人如何通過繼承改變它的行為。
寫到這裡,我可以想象人們指著我的鼻子,指責我鼓勵刻意的不良設計。儘管如此,我還是忍不住想起曾經聽說過的一個專案,這個專案要求它的開發者對他們寫的每個子例程配備說明文件,並且要使這些子例程對於專案的其他程式來說都是可以複用的。其基本思想是,如果子例程對某個開發者是有用的,那麼可能對其他人也有用。為什麼不讓整個專案從中受益呢?
顯然,隨後發生的事情不難預測:除非絕對必要,所有的程式設計師都極力避免編寫子例程。開發者用文字編輯器來複制程式碼塊,然後隨心所欲地進行區域性修改。結果產生一個難以維護、理解和修改的系統。
類似的,我們有時會為某些有限用途設計類。例如,我記得曾經寫過一個很小的類作為第一章介紹的 ASD 系統的一部分,這個類計算傳遞給它的資料的校驗和。它有一個建構函式,一個成員函式給它提供資料,另一個成員函式提取校驗和--差不多就是這些。如果我花時間考慮其他人將如何擴充套件這個類的話,就會佔用本來應該花在其他設計上的時間。我不知道提供這樣一個類會使誰的生活更輕鬆,沒有人向我詢問關於那個類的情況。
3. 解構函式很特殊
如果打算讓你的類支援繼承,那麼即使你不使用其他虛擬函式,可能還是需要一個虛解構函式。
記住虛擬函式和非虛擬函式之間的區別只有在下面這種特定的環境下才會體現出來:當使用一個基類指標或引用來指向或者引用一個派生來物件時。下面的情況便是其中一種:
class Base{
public:
void f();
virtual void g();
};
class Derived: public Base{
public:
void f();
virtual void g();
};
現在我們可以建立 Base 類的物件和 Derived 類的物件,還可以獲得指向它們的指標:
Base b;
Derived d;
Base* bp = &b;
Base* bq = &d;
Derived* dp = &d;
這裡,bp 指向一個 Base 物件,bq 和 dp 指向 Derived 物件。如果我們用這些指標呼叫成員函式 f 和 g 會出現什麼情況呢?
bp->f(); /* Base::f */ bp->g(); /* Base::g */
bq->f(); /* Base::f */ bq->g(); /* Derived::g */
dp->g(); /* Derived::g */ dp->f(); /* Derived::f */
你會發現只有指標的靜態型別與它所指向的實際物件的型別不同時,非虛擬函式 f 和虛擬函式 g 執行起來才會有所差別。當下面兩件事情同時發生時就需要虛析構函數了:
* 有需要解構函式的事情發生。
* 它發生在這樣一種上下文中:指向一個基類的指標或者引用都有一個靜態型別,並且實際上都指向一個派生類的物件。
解構函式只在銷燬物件時才需要。通過指標銷燬物件的唯一方法就是使用一個 delete 表示式。因此,只有當使用指向基類的指標來刪除派生類的物件時,虛解構函式才真正有意義。因此,例如:
Base* bp;
Derived* bp;
bp = new Derived;
dp = new Derived;
delete bp; // Base 必須有一個虛解構函式
delete dp; // 這裡虛解構函式可要可不要
我們在這裡用 bp,一個基類指標,來刪除一個派生類物件。因此,為了使這個例子能正常執行,Base 必須有一個虛解構函式。
有些實現可能會使這個例子正確--但是別做指望。注意,即使你的類根本沒有虛擬函式,可能也要用到虛解構函式。
如果需要一個虛解構函式,定義一個空的虛解構函式就行了:
class Base{
public:
// ...
virtual ~Base() {}
};
另外,如果一個類的基類有一個虛解構函式,那麼這個類本身也自動獲得一個虛解構函式,所以完整的類繼承層次結構中有一個虛解構函式就足夠了。
4. 小結
虛擬函式是 C++ 的基本組成部分,也是面向物件程式設計說必需的。然而,即使是一個有用的東西,我們也應該思考使用的合適時機。
關於虛擬函式為什麼不總是適用,我們已經知道了 3 個原因:其一是虛擬函式有時會帶來很大的消耗,其二是虛擬函式不總是提供所需的行為,其三是有時我們寫一個類時,可能不想考慮派生問題。
另一方面,我們還知道了一種必須使用虛擬函式的情況。當你想刪除一個表面上指向基類物件、實際卻是指向派生類物件的指標,就需要虛解構函式。
更為常見的是,寫程式時我們必須考慮自己正在做什麼。僅僅根據規則和習慣思維行事是不夠的。