Effective Modern C++: Item 12 -> 宣告覆蓋函式override
宣告覆蓋函式override
C++中面向物件程式設計的世界中主要涉及類,繼承和虛擬函式。在這個世界中最基礎的思想之一就是繼承類中的虛擬函式實現會覆蓋掉基類中的對應實現。然而,意識到虛擬函式覆蓋多麼容易出錯將令人沮喪。該語言的這個部分幾乎就是按照這個想法來設計的:墨菲定律不只是被用來遵守的,還是被膜拜的。
因為覆蓋(“overridding”)聽起來很像過載(“overloading”,即使它倆毫無關聯,所以讓我解釋清楚一點,虛擬函式讓通過基類介面呼叫繼承類函式成為可能:
class Base{
public:
virtual void doWork();//base class virtual function
...
};
class Derived:public Base{
public:
virtual void doWork();//overrides Base::doWork
//("virtual is optional here")
...
};
std::unique_ptr<Base> upb = //create base class pointer
std::make_unique<Derived>(); // to derived class object;
//see Item 21 for info on
//std::make_unique
...
upb->doWork(); //call doWork through base
//class ptr;derived class
//function is invoked
為了讓覆蓋能夠發生,一些要求必須達到:
- 基類函式必須是虛擬函式
- 基類和繼承類函式的名字必須一樣(除了解構函式是個例外)
- 基類和繼承類函式的引數型別必須一樣
- 基類和繼承類函式的常量性(constness)必須一樣
- 基類和繼承類函式的返回型別和異常指定符必須相容
除了這些限制,這也是C++98中的一部分,C++11還額外加了一個:
- 函式的引用修飾符必須一樣。成員函式的引用修飾符是C++11宣傳力度比較小的特性之一,所以如果你從未聽說過它們也不必驚訝。它們讓限定成員函式只能左值使用還是隻能右值使用成為可能。成員函式不許成為虛擬函式就可以使用:
class Widget{
public:
...
void doWork() &; //this version of doWork applies
// only when *this is an lvalue
void doWork() &&; //this version of doWork applies
//only when *this is an rvalue
};
...
Widget makeWidget(); //factory function (return rvalue)
Widget w; //normal object (an lvalue)
...
w.doWork(); //calls Widget::doWork for lvalues
//(i.e.,Widget::doWork &)
makeWidget().doWork(); //calls Widget::doWork for rvalues
//(i.e.,Widget::doWork &&)
我後面會說更多有關帶有引用修飾符的成員函式的東西,但是現在,只需要簡單記住如果一個基類虛擬函式帶有引用修飾符,那麼覆蓋它的繼承類函式也必須帶有一模一樣的引用修飾符。如果沒有,那麼宣告的函式依舊存在於繼承類中,但是它們不會覆蓋基類中的任何東西。
對於覆蓋的所有要求意味著一個小錯誤就會導致大不同。程式碼中包含覆蓋錯誤一般是合法的,但是它的含義卻不是你想要的。所以你不能指望編譯器提示你是否做錯了什麼。例如,下面的程式碼是完全合法的,第一眼看起來很合理,但是它並沒有包含虛擬函式覆蓋—就連一個與基類函式相關聯的繼承類函式都沒有。你能識別出每一個case裡面的問題嗎,也就是說,為什麼繼承類中的函式沒有覆蓋基類中相同名字的函式?
class Base {
public:
virtual void mf1() const;
virtual void mf2(int x);
virtual void mf3() &;
void mf4() const;
};
class Derived: public Base {
public:
virtual void mf1();
virtual void mf2(unsigned int x);
virtual void mf3() &&;
void mf4() const;
};
需要一些幫助?
- mf1在基類中宣告為const,但在繼承類中就沒有
- mf2在基類中接受一個int型別,但在繼承類中接受一個unsigned int
- mf3在基類中是左值限定的,而在繼承類中則是右值限定的
- mf4在基類中沒有宣告成虛擬函式
你也許會認為,“嘿,在實踐中,這些情況會有編譯警告的,所以不必擔心。”也許那是真的,也許是假的。在我檢查過的兩個編譯器裡,程式碼通過,沒有任何警告,而且所有的警告設定都是開著的(其他一些編譯器會提示其中的一些問題,但不是全部)
因為宣告繼承類函式覆蓋很重要一點就是讓它正確,但是這很容易就出錯,C++11給你提供一種方法,顯式表示繼承類函式應該要覆蓋基類中的對應版本:用override去宣告它。將這個應用到上面的例子中會得到這樣的繼承類:
class Derived: public Base {
public:
virtual void mf1() override;
virtual void mf2(unsigned int x) override;
virtual void mf3() && override;
virtual void mf4() const override;
};
當然,這編譯不通過,因為當這樣寫時,編譯器會抱怨所有覆蓋相關的問題。而這正是你所想要的,這也是為什麼我們應該對所有的覆蓋函式用override宣告。
能夠編譯的使用override的程式碼看起來像下面這樣(假設繼承類的所有函式的目標就是覆蓋基類中的虛擬函式):
class Base {
public:
virtual void mf1() const;
virtual void mf2(int x);
virtual void mf3() &;
virtual void mf4() const;
};
class Derived: public Base {
public:
virtual void mf1() const override;
virtual void mf2(int x) override;
virtual void mf3() & override;
void mf4() const override; // adding "virtual" is OK,
}; // but not necessary
這個例子中有一點要注意,上面還將基類中的mf4宣告成了虛擬函式。絕大多數覆蓋相關的問題都出在繼承類上,但是基類也可能會出現錯誤。
對所有的繼承類覆蓋函式使用override這一方針能做的不僅僅是讓編譯器告訴你什麼時候你希望覆蓋的函式並沒有覆蓋任何東西,它還可以幫助你估計一下分支變化,如果你決定要修改基類中某個虛擬函式的函式簽名。如果繼承類到處都用到了override,你可以修改一下函式簽名,重新編譯一下,看看這到底造成了多大損害(也就是說有多少繼承類會編譯失敗),然後再決定對這個函式簽名的修改是否值得。沒有override,你就不得不希望自己進行了充足的單元測試,因為正如我們已經看到的,本應該覆蓋基類虛擬函式的繼承類函式如果沒有覆蓋,不會導致編譯器報警。
C++一直都有關鍵詞,但是C++11引入了兩個新的上下文關鍵詞(contextual keywords),override和final。這些關鍵詞只有在特定上下文中才有它們預留的特性。在override的case裡,它只有在成員函式宣告的最後面才有保留語義。那就是說如果你有一份合法程式碼已經使用了override這個名字,那麼你不需要為C++11來對其進行修改:
class Warning { // potential legacy class from C++98
public:
…
void override(); // legal in both C++98 and C++11
… // (with the same meaning)
};
這就是所有我要說的關於override的東西,但是這並不是所有關於成員函式引用修飾符要說的。我承諾過我待會會說更多有關成員函式修飾符的資訊,現在就是我說的待會。
如果我們想要寫一個函式,只接受左值引數,我們宣告一個non-const的左值引用引數:
void doSomething(Widget& w); //accepts only lvalue Widgets
如果我們想要寫一個只接受右值引數的函式,我們宣告一個右值引用引數:
void doSomething(Widget&& w); //accept only rvalue Widgets
成員函式引用修飾符則讓成員函式應該被什麼型別的物件(也就是*this)呼叫成為可能。這個和成員函式聲明後面的const很相似,那是呼叫該成員函式的物件(*this)必須是const的。
對於帶有引用修飾符的函式需求並不常見,但是也會出現。例如,假設我們的Widget類有一個std::vector資料成員,並且我們提供一個訪問函式讓使用者可以訪問它:
class Widget{
public:
using DataType = std::vector<double>; //see Item 9 for info on "using"
...
DataType& data() {return values;}
...
private:
DataType values;
};
這很難說是有史以來封裝程度最深的設計,但是先不管這個,考慮一下客戶程式碼裡會發生什麼:
Widget w;
...
auto vals1 = w.data(); //copy w.values into vals1
Widget::data的返回型別是一個左值引用(嚴格來說是std::vector<double>&),並且因為左值引用是被定義成左值的,我們正在用一個左值來初始化vals1。vals1也因此就像評論所說的那樣,是從w.values裡面拷貝構造的。
現在假設我們有一個工廠函式生產Widgets,
Widget makeWidget();
並且我們想用makeWidget返回的Widget內部的std::vector來初始化一個變數:
auto vals2 = makeWidget().data(); //copy values inside the Widget int vals2
再一次,Widget::data返回一個左值引用,並且再一次,該左值引用是一個左值,所以,再一次,我們新的物件(vals2)也是從Widget裡的values複製構造出來的。這一次,儘管Widget是一個makeWidget返回的臨時物件(就是一個右值),所以對它內部的std::vector進行復制簡直是浪費時間。我更傾向於去移動它,但是因為data函式返回的是一個左值引用,C++的規則會要求編譯器生成複製的程式碼。(儘管還有一些通過”as if rule”來優化的空間,但是你不要傻乎乎的依賴編譯器去幫你找到一種利用它的方法)
現在需要一種方法去指定當data被一個右值Widget呼叫時,結果應該是一個右值。使用引用修飾符去分別為左值和右值Widget過載data函式就可以辦到:
class Widget{
public:
using DataType = std::vector<double>;
...
DataType& data() & //for lvalue Widget
{return values;} //return lvalue
DataType data() && //for rvalue Widgets,
{return std::move(values);} // return rvalue
...
private:
DataType values;
};
注意data過載函式的不同返回型別。左值引用過載返回一個左值引用(也就是一個左值),而右值引用過載返回一個臨時物件(也就是一個右值)。這意味著現在客戶程式碼可以像我們預期的那樣工作了:
auto vals1 = w.data(); //calls lvalue overload for
// Widget::data,copy-constructs vals1
auto vals2 = makeWidget().data(); //calls rvalue overload for
//Widget::data,move-construct vals2
這真的很nice,但是不要讓這個happy ending打擾到你認識該Item真正的要點。要點就是每當你在一個繼承類中宣告一個要覆蓋基類中某個虛擬函式的函式,確保將那個函式宣告成override。
要點記憶
- 使用override宣告覆蓋函式
- 成員函式引用修飾符能夠對左值和右值物件(*this)區別對待