重載和重寫的區別參見:
C++繼承中重載、重寫、重定義的區別:
在了解C++11中的final/override關鍵字之前,我們先回顧一下C++關於重載的概念。簡單地說,一個類A中聲明的虛函數fun在其派生類B中再次被定義,且B中的函數fun跟A中fun的原型一樣(函數名、參數列表等一樣),那么我們就稱B重寫(override)了A的fun函數。對於任何B類型的變量,調用成員函數fun都是調用了B重寫的版本。而如果同時有A的派生類C,卻並沒有重寫A的fun函數,那么調用成員函數fun則會調用A中的版本。這在C++中就實現多態。
在通常情況下,一旦在基類A中的成員函數fun被聲明為virtual的,那么對於其派生類B而言,fun總是能夠被重寫的。有的時候我們並 不想fun在B類型派生類中被重寫 ,那么,C++98沒有方法對此進行限制。我們看看下面這個具體的例子,如代碼清單2-23所示。
代碼清單2-23
#include <iostream> using namespace std; class MathObject{ public: virtual double Arith() = 0; virtual void Print() = 0; }; class Printable : public MathObject{ public: double Arith() = 0; void Print() // 在C++98中我們無法阻止該接口被重寫 { cout << "Output is: " << Arith() << endl; } }; class Add2 : public Printable { public: Add2(double a, double b): x(a), y(b) {} double Arith() { return x + y; } private: double x, y; }; class Mul3 : public Printable { public: Mul3(double a, double b, double c): x(a), y(b), z(c) {} double Arith() { return x * y * z; } private: double x, y, z; }; // 編譯選項:g++ 2-10-1.cpp
在代碼清單2-23中,我們的基礎類MathObject定義了兩個接口:Arith和Print。類Printable則繼承於MathObject並實現了Print接口。接下來,Add2和Mul3為了使用MathObject的接口和Printable的Print的實現,於是都繼承了Printable。這樣的類派生結構,在面向對象的編程中非常典型。不過倘若這里的Printable和Add2是由兩個程序員完成的,Printable的編寫者不禁會有一些憂慮,如果Add2的編寫者重寫了Print函數,那么他所期望的統一風格的打印方式將不復存在。
對於Java這種所有類型派生於單一元類型(Object)的語言來說,這種問題早就出現了。因此Java語言使用了final關鍵字來阻止函數繼續重寫。final關鍵字的作用是使派生類不可覆蓋它所修飾的虛函數。C++11也采用了類似的做法,如代碼清單2-24所示的例子。
代碼清單2-24
struct Object{ virtual void fun() = 0; }; struct Base : public Object { void fun() final; // 聲明為final }; struct Derived : public Base { void fun(); // 無法通過編譯 }; // 編譯選項:g++ -c -std=c++11 2-10-2.cpp
在代碼清單2-24中,派生於Object的Base類重載了Object的fun接口,並將本類中的fun函數聲明為final的。那么派生於Base的Derived類對接口fun的重載則會導致編譯時的錯誤。同理,在代碼清單2-23中,Printable的編寫者如果要阻止派生類重載Print函數,只需要在定義時使用final進行修飾就可以了。
讀者可能注意到了,在代碼清單2-23及代碼清單2-24兩個例子當中,final關鍵字都是用於描述一個派生類的。那么基類中的虛函數是否可以使用final關鍵字呢?答案是肯定的,不過這樣將使用該虛函數無法被重載,也就失去了虛函數的意義。如果不想成員函數被重載,程序員可以直接將該成員函數定義為非虛的。而final通常只在繼承關系的“中途”終止派生類的重載中有意義。從接口派生的角度而言,final可以在派生過程中任意地阻止一個接口的可重載性,這就給面向對象的程序員帶來了更大的控制力。
在C++中重載還有一個特點,就是對於基類聲明為virtual的函數,之后的重載版本都不需要再聲明該重載函數為virtual。即使在派生類中聲明了virtual,該關鍵字也是編譯器可以忽略的。這帶來了一些書寫上的便利,卻帶來了一些閱讀上的困難。比如代碼清單2-23中的Printable的Print函數,程序員無法從Printable的定義中看出Print是一個虛函數還是非虛函數。另外一點就是,在C++中有的虛函數會“跨層”,沒有在父類中聲明的接口有可能是祖先的虛函數接口。比如在代碼清單2-23中,如果Printable不聲明Arith函數,其接口在Add2和Mul3中依然是可重載的,這同樣是在父類中無法讀到的信息。這樣一來,如果類的繼承結構比較長(不斷地派生)或者比較復雜(比如偶爾多重繼承),派生類的編寫者會遇到信息分散、難以閱讀的問題(雖然有時候編輯器會進行提示,不過編輯器不是總是那么有效)。而自己是否在重載一個接口,以及自己重載的接口的名字是否有拼寫錯誤等,都非常不容易檢查。
在C++11中為了幫助程序員寫繼承結構復雜的類型,引入了虛函數描述符override,如果派生類在虛函數聲明時使用了override描述符,那么該函數必須重載其基類中的同名函數,否則代碼將無法通過編譯。我們來看一下如代碼清單2-25所示的這個簡單的例子。
代碼清單2-25
struct Base { virtual void Turing() = 0; virtual void Dijkstra() = 0; virtual void VNeumann(int g) = 0; virtual void DKnuth() const; void Print(); }; struct DerivedMid: public Base { // void VNeumann(double g); // 接口被隔離了,曾想多一個版本的VNeumann函數 }; struct DerivedTop : public DerivedMid { void Turing() override; void Dikjstra() override; // 無法通過編譯,拼寫錯誤,並非重載 void VNeumann(double g) override; // 無法通過編譯,參數不一致,並非重載 void DKnuth() override; // 無法通過編譯,常量性不一致,並非重載 void Print() override; // 無法通過編譯,非虛函數重載 }; // 編譯選項:g++ -c -std=c++11 2-10-3.cpp
在代碼清單2-25中,我們在基類Base中定義了一些virtual的函數(接口)以及一個非virtual的函數Print。其派生類DerivedMid中,基類的Base的接口都沒有重載,不過通過注釋可以發現,DerivedMid的作者曾經想要重載出一個“void VNeumann(double g)”的版本。這行注釋顯然迷惑了編寫DerivedTop的程序員,所以DerivedTop的作者在重載所有Base類的接口的時候,犯下了3種不同的錯誤:
函數名拼寫錯,Dijkstra誤寫作了Dikjstra。
函數原型不匹配,VNeumann函數的參數類型誤做了double類型,而DKnuth的常量性在派生類中被取消了。
重寫了非虛函數Print。
如果沒有override修飾符,DerivedTop的作者可能在編譯后都沒有意識到自己犯了這么多錯誤。因為編譯器對以上3種錯誤不會有任何的警示。這里override修飾符則可以保證編譯器輔助地做一些檢查。我們可以看到,在代碼清單2-25中,DerivedTop作者的4處錯誤都無法通過編譯。
此外,值得指出的是,在C++中,如果一個派生類的編寫者自認為新寫了一個接口,而實際上卻重載了一個底層的接口(一些簡單的名字如get、set、print就容易出現這樣的狀況),出現這種情況編譯器還是愛莫能助的。不過這樣無意中的重載一般不會帶來太大的問題,因為派生類的變量如果調用了該接口,除了可能存在的一些虛函數開銷外,仍然會執行派生類的版本。因此編譯器也就沒有必要提供檢查“非重載”的狀況。而檢查“一定重載”的override關鍵字,對程序員的實際應用則會更有意義。
還有值得注意的是,如我們在第1章中提到的,final/override也可以定義為正常變量名,只有在其出現在函數后時才是能夠控制繼承/派生的關鍵字。通過這樣的設計,很多含有final/override變量或者函數名的C++98代碼就能夠被C++編譯器編譯通過了。但出於安全考慮,建議讀者在C++11代碼中應該盡可能地避免這樣的變量名稱或將其定義在宏中,以防發生不必要的錯誤。