虛擬函式

虛擬函式

虛擬函式是C++語言引入的一個很重要的特性,它提供了“動態綁定”機制,正是這一機制使得繼承的語義變得相對明晰。對繼承體系的使用者而言,此繼承體系內部的多樣性是“透明的”。它不必關心其繼承細節,處理的就是一組對它而言整體行為一致的“對象”。

基本信息

虛擬函式的特性

虛擬函式是C++語言引入的一個很重要的特性,它提供了“動態綁定”機制,正是這一機制使得繼承的語義變得相對明晰。
(1)基類抽象了通用的數據及操作,就數據而言,如果該數據成員在各派生類中都需要用到,那么就需要將其聲明在基類中;就操作而言,如果該操作對各派生類都有意義,無論其語義是否會被修改或擴展,那么就需要將其聲明在基類中。
(2)有些操作,如果對於各個派生類而言,語義保持完全一致,而無需修改或擴展,那么這些操作聲明為基類的非虛擬成員函式。各派生類在聲明為基類的派生類時,默認繼承了這些非虛擬成員函式的聲明/實現,如同默認繼承基類的數據成員一樣,而不必另外做任何聲明,這就是繼承帶來的代碼重用的優點。
(3)另外還有一些操作,雖然對於各派生類而言都有意義,但是其語義並不相同。這時,這些操作應該聲明為基類的虛擬成員函式。各派生類雖然也默認繼承了這些虛擬成員函式的聲明/實現,但是語義上它們應該對這些虛擬成員函式的實現進行修改或者擴展。另外在實現這些修改或擴展過程中,需要用到額外的該派生類獨有的數據時,將這些數據聲明為此派生類自己的數據成員。
再考慮更大背景下的繼承體系,當更高層次的程式框架(繼承體系的使用者)使用此繼承體系時,它處理的是一個抽象層次的對象集合(即基類)。雖然這個對象集合的成員實質上可能是各種派生類對象,但在處理這個對象集合中的對象時,它用的是抽象層次的操作。並不區分在這些操作中,哪些操作對各派生類來說是保持不變的,而哪些操作對各派生類來說有所不同。這是因為,當運行時實際執行到各操作時,運行時系統能夠識別哪些操作需要用到“動態綁定”,從而找到對應此派生類的修改或擴展的該操作版本。
也就是說,即只需關心它自己問題域的業務邏輯,只要保證正確,其任務就算完成了。即使繼承體系內部增加了某種派生類,或者刪除了某種派生類,或者某某派生類的某個虛擬函式的實現發生了改變,它的代碼不必任何修改。這也意味著,程式的模組化程度得到了極大的提高。而模組化的提高也就意味著可擴展性、可維護性,以及代碼的可讀性的提高,這也是“面向對象”編程的一個很大的優點。

實例展示

下面通過一個簡單的實例來展示這一優點。
假設有一個繪圖程式允許用戶在一個畫布上繪製各種圖形,如三角形、矩形和圓等,很自然地抽象圖形的繼承體系,如圖2-2所示。
這個圖形繼承體系的設計大致如下:
class Shape
{
public:
Shape();
virtual ~Shape();
virtual void Draw();
virtual void Rotate();
private:
...
};
class Triangle : Shape
{
public:
Triangle();
~Triangle();
void Draw();
void Rotate(int angle);
...
};
class Circle : Shape
{
public:
Circle();
~ Circle();
void Draw();
void Rotate(int angle);
...
};
class Rectangle : Shape
{
public:
Rectangle();
~ Rectangle();
void Draw();
void Rotate(int angle);
...
};
為簡單起見,讓每個Shape對象都支持“繪製”和“旋轉”操作,每個Shape的派生類對這兩個操作都有自己的實現:
void Triangle::Draw()
{
...
}
void Circle::Draw()
{
...
}
void Rectangle::Draw()
{
...
}
void Triangle::Rotate(int angle)
{
...
}
void Circle::Rotate(int angle)
{
...
}
void Rectangle::Rotate(int angle)
{
...
}
再來考慮這個圖形繼承體系的使用,這裡很自然的一個使用者是畫布,設計其類名為“Canvas”:
public Canvas
{
public:
Canvas();
~Canvas();
void Paint();
void RotateSelected(int angle);
...
private:
ShapeList shapes;
};
...
void Canvas::Paint()
{
while(shapes.GetNext())
{
Shape* sh = shapes.GetNext();
sh->Draw(); ①
shapes.Next();
}
...
}
void RotateSelected(int angle)
{
Shape* select_shape = GetCurrentSelected();
if(select_shape)
select_shape->Rotate(angle); ②
...
}
Canvas類中維護一個包含所有圖形的shapes,Canvas類在處理自己的業務邏輯時並不關心shapes實際上都是哪些具體的圖形;相反,如①處和②處所示,它只將這些圖形作為一個抽象,即Shape。在處理每個Shape時,調用每個Shape的某個操作即可。
這樣做的一個好處是當圖形繼承體系發生變化時,作為圖形繼承體系的使用者Canvas而言,它的改變幾乎沒有,或者很小。
比如說,在程式的演變過程中發現需要支持多邊型(Polygon)和貝塞爾曲線(Bezier)類型,只需要在圖形繼承體系中增加這兩個新類型即可:
class Polygon : class Shape
{
public:
Polygon();
~Polygon();
void Draw();
void Rotate(int angle);
...
};
void Polygon::Draw()
{
...
}
void Polygon::Rotate(int angle)
{
...
}
class Bezier : class Shape
{
public:
Bezier();
~Bezier();
void Draw();
void Rotate(int angle);
...
};
void Bezier::Draw()
{
...
}
void Bezier::Rotate(int angle)
{
...
}
而不必修改Canvas的任何代碼,程式即可像以前那樣正常運行。同理,如果以後發現不再支持某種類型,也只需要將其從圖形繼承體系中刪除,而不必修改Canvas的任何代碼。可以看到,從對象繼承體系的使用者(Canvas)的角度來看,它只看到Shape對象,而不必關心到底是哪一種特定的Shape,這是面向對象設計的一個重要特點和優點。

虛擬函式的“動態綁定”

虛擬函式的“動態綁定”特性雖然很好,但也有其內在的空間以及時間開銷,每個支持虛擬函式的類(基類或派生類)都會有一個包含其所有支持的虛擬函式指針的“虛擬函式表”(virtual table)。另外每個該類生成的對象都會隱含一個“虛擬函式指針”(virtual pointer),此指針指向其所屬類的“虛擬函式表”。當通過基類的指針或者引用調用某個虛擬函式時,系統需要首先定位這個指針或引用真正對應的“對象”所隱含的虛擬函式指針。“虛擬函式指針”,然後根據這個虛擬函式的名稱,對這個虛擬函式指針所指向的虛擬函式表進行一個偏移定位,再調用這個偏移定位處的函式指針對應的虛擬函式,這就是“動態綁定”的解析過程(當然C++規範只需要編譯器能夠保證動態綁定的語義即可,但是目前絕大多數的C++編譯器都是用這種方式實現虛擬函式的),通過分析,不難發現虛擬函式的開銷:
― 空間:每個支持虛擬函式的類,都有一個虛擬函式表,這個虛擬函式表的大小跟該類擁有的虛擬函式的多少成正比,此虛擬函式表對一個類來說,整個程式只有一個,而無論該類生成的對象在程式運行時會生成多少個。
― 空間:通過支持虛擬函式的類生成的每個對象都有一個指向該類對應的虛擬函式表的虛擬函式指針,無論該類的虛擬函式有多少個,都只有一個函式指針,但是因為與對象綁定,因此程式運行時因為虛擬函式指針引起空間開銷跟生成的對象個數成正比。
― 時間:通過支持虛擬函式的類生成的每個對象,當其生成時,在構造函式中會調用編譯器在構造函式內部插入的初始化代碼,來初始化其虛擬函式指針,使其指向正確的虛擬函式表。
― 時間:當通過指針或者引用調用虛擬函式時,跟普通函式調用相比,會多一個根據虛擬函式指針找到虛擬函式表的操作。

內聯函式

因為內聯函式常常可以提高代碼執行的速度,因此很多普通函式會根據情況進行內聯化,但是虛擬函式無法利用內聯化的優勢,這是因為內聯函式是在“編譯期”編譯器將調用內聯函式的地方用內聯函式體的代碼代替(內聯展開),但是虛擬函式本質上是“運行期”行為,本質上在“編譯期”編譯器無法知道某處的虛擬函式調用在真正執行的時候會調用到那個具體的實現(即在“編譯期”無法確定其綁定),因此在“編譯期”編譯器不會對通過指針或者引用調用的虛擬函式進行內聯化。也就是說,如果想利用虛擬函式的“動態綁定”帶來的設計優勢,那么必須放棄“內聯函式”帶來的速度優勢。
根據上面的分析,似乎在採用虛擬函式時帶來和很多的負面影響,但是這些負面影響是否一定是虛擬函式所必須帶來的?或者說,如果不採用虛擬函式,是否一定能避免這些缺陷?
還是分析以上圖形繼承體系的例子,假設不採用虛擬函式,但同時還要實現與上面一樣的功能(維持程式的設計語義不變),那么對於基類Shape必須增加一個類型標識成員變數用來在運行時識別到底是哪一個具體的派生類對象:
class Shape
{
public:
Shape();
virtual ~Shape();
int gettype() { return type; } ①
void Draw(); ③
void Rotate(); ④
private:
int type; ②
...
};
如①處和②處所示,增加type用來標識派生類對象的具體類型。另外注意這時③處和④處此時已經不再使用virtual聲明。
其各派生類在構造時,必須設定具體類型,以Circle派生類為例:
class Circle : class Shape
{
public:
Circle() : type(CIRCLE) {...} ①
~Circle();
void Draw();
void Rotate(int angle);
...
};
對圖形繼承體系的使用者(這裡是Canvas)而言,其Paint和RotateSelected也需要修改:
void Canvas::Paint()
{
while(shapes.GetNext())
{
Shape* sh = shapes.GetNext();
//sh->Draw();
switch(sh->GetType())
{
case(TRIANGLE)
((Triangle*)sh)->Draw();
case(CIRCLE)
((Circle*)sh)->Draw();
case(RECTANGLE)
((Rectangle*)sh)->Draw();
...
}
shapes.Next();
}
...
}
void RotateSelected(int angle)
{
Shape* select_shape = GetCurrentSelected();
if(select_shape)
{
//select_shape->Rotate(angle);
switch(select_shape->GetType())
{
case(TRIANGLE)
((Triangle*)select_shape)->Rotate(angle);
case(CIRCLE)
((Circle*)select_shape)->Rotate(angle);
case(RECTANGLE)
((Rectangle*)select_shape)->Rotate(angle);
...
}
}
...
}

程式功能

因為要實現相同的程式功能(語義),已經看到,每個對象雖然沒有編譯器生成的虛擬函式指針析構函式往往被設計為virtual,如果如此,仍然免不了會隱含增加一個虛擬函式指針,這裡假設不是這樣),但是還是需要另外增加一個type變數用來標識派生類的類型。構造對象時,雖然不必初始化虛擬函式指針,但是仍然需要初始化type。另外,圖形繼承體系的使用者調用函式時雖然不再需要一次間接的根據虛擬函式表找尋虛擬函式指針的操作,但是再調用之前,仍然需要一個switch語句對其類型進行識別。
綜上所述,這裡列舉的5條虛擬函式帶來的缺陷只剩下兩條,即虛擬函式表的空間開銷及無法利用“內聯函式”的速度優勢。再考慮虛擬函式表,每一個含有虛擬函式的類在整個程式中只會有一個虛擬函式表。可以想像到虛擬函式表引起的空間開銷實際上是非常小的,幾乎可以忽略不計。

性能缺陷

這樣可以得出結論,即虛擬函式引入的性能缺陷只是無法利用內聯函式。
可以進一步構想,非虛擬函式的常規設計假如需要增加一種新的圖形類型,或者刪除一種不再支持的圖形類型,都必須修改該圖形系統所有使用者的所有與類型相關的函式調用的代碼。這裡使用者只有Canvas一個,與類型相關的函式調用代碼也只有Paint和RotateSelected兩處。但是在一個複雜的程式中,其使用者很多。並且類型相關的函式調用很多時,每次對圖形系統的修改都會波及到這些使用者。可以看出不使用虛擬函式的常規設計增加了代碼的耦合度,模組化不強,因此帶來的可擴展性、可維護性,以及代碼的可讀性方面都極大降低。面向對象編程的一個重要目的就是增加程式的可擴展性和可維護性,即當程式的業務邏輯發生變化時,對原有程式的修改非常方便。而不至於對原有代碼大動干戈,從而降低因為業務邏輯的改變而增加出錯的可能性。根據這點分析,虛擬函式可以大大提升程式的可擴展性及可維護性。
因此在性能和其他方面特性的選擇方面,需要開發人員根據實際情況進行權衡和取捨。當然在權衡之前,需要通過性能檢測確認性能的瓶頸是由於虛擬函式沒有利用到內聯函式的優勢這一缺陷引起;否則可以不必考慮虛擬函式的影響。

相關詞條

相關搜尋

熱門詞條

聯絡我們