Item 12: 把重寫函數聲明爲“override”的

本文翻譯自《effective modern C++》,由於水平有限,故無法保證翻譯完全正確,歡迎指出錯誤。謝謝!

博客已經遷移到這裏啦

C++中的面向對象編程總是圍繞着類,繼承,以及虛函數。這個世界中,最基礎的概念就是,對於一個虛函數,用派生類中的實現來重寫在基類中的實現。但是,這是令人沮喪的,你要認識到重寫虛函數有多麼容易出錯。這就好像這部分語言,是用這樣的概念(墨菲定律不僅僅要被遵守,更需要被尊敬)來設計的。(it’s almost as if this part of the language were designed with the idea that Murphy’s Law wasn’t just to be obeyed, it was to be honored)

因爲“重寫”聽起來有點像“重載”,但是他們完全沒有關係,讓我們來弄清楚,重寫虛函數是爲了通過基類的接口來調用派生類的函數。

class Base {
public:
    virtual void doWork();          //基類虛函數
    ...
};

class Derived: public Base{
public:
    virtual void doWork();          //重寫Base::doWork
    ...                             //(“virtual” 是可選的)
};

std::unique_ptr<Base> upb =         //創建基類指針,來指向
    std::make_unique<Derived>();    //派生類對象;有關
                                    //std::make_unique的信息
                                    //請看Item 21
...

upb->doWork();                      //通過基類指針調用doWork;
                                    //派生類的函數被調用了

爲了能夠成功重寫,必須要符合一些要求:

  • 基類函數必須是virtual的。
  • 基類函數和派生類函數的名字必須完全一樣(除了析構函數)。
  • 基類函數和派生類函數的參數類型必須完全一樣。
  • 基類函數和派生類函數的const屬性必須完全一樣。
  • 基類函數和派生類函數的返回值類型以及異常規格(exception specification)必須是可兼容的。

這些限制是C++98要求的,C++11還增加了一條:

  • 函數的引用限定符必須完全一樣

“成員函數引用限定符”是C++11中不太被知道的特性,所以即使你從來沒有聽過,也不需要吃驚。它們的出現是爲了限制成員函數只能被左值或右值中的一個使用。使用它們時,不需要一定是virtual成員函數:

class Widget {
public:
    ...
    void doWork() &;            //只有*this是左值時,纔會調用
                                //這個版本的doWork

    void doWork() &&;           //只有*this是右值時,纔會調用
                                //這個版本的doWork
};

...

Widget makeWidget();            //工廠函數(返回一個右值)

Widget w;                       //正常的對象(一個左值)

...

w.doWork();                     //調用左值版本的Widget::doWork
                                //也就是Widget::doWork &

makeWidget().doWork();          //調用右值版本的Widget::doWork
                                //也就是Widget::doWork &&

更多關於帶引用限定符的成員函數的信息,我會在後面討論,現在,我們只需要知道,如果一個基類中的虛函數有引用限定符,那麼派生類的重寫函數中,也必須有完全一樣的引用限定符。如果它們沒有一樣的限定符,聲明的函數在派生類中還是存在的,但是它們不會重寫任何基類函數。

重寫需要這麼多的的要求,就意味着一個小的差錯就會有很大影響。含有錯誤重寫的代碼常常是有效的,但是這些代碼會產生你不想要的結果。因此,你不能依賴編譯器來通知你:你是否做錯了。舉個例子,下面的代碼完全沒有問題,並且乍一看也很合理,但是它們沒有包含虛函數重寫(派生類的函數沒有綁定基類的函數)。你能找出每種情況的問題所在麼,也就是,爲什麼每個同名的派生類函數沒有重寫基類函數?

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
  • mf3在基類中是左值限定的,但是在派生類中是右值限定的。
  • mf4在基類中沒聲明爲virtual的

你可能在想,“喂,在練習中,這些東西編譯器都會發出警告,所以我不需要去關心它”。這可能是對的,但是也可能是錯的。我測試過兩個編譯器,代碼成功被編譯器接受,並且編譯器沒有發出警告,並且這是在警告選項全部打開的情況下測試的。(其他編譯器會對其中幾條問題(不是全部)產生警告。)

在派生類中,聲明出正確的重寫函數很重要,但是它們總是很容易出錯,所以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;              //增加“virtual”也可以,但不是必須的
};

記住,在這個例子中,做的一部分事情是在Base中聲明mf4爲virtual的。大部分和重寫有關的錯誤發生在派生類,但是也有可能是基類中有不對的地方。

把所有的派生類中的重寫函數都聲明爲override,這個準則不僅能讓編譯器告訴你什麼地方聲明瞭override卻沒有重寫任何東西。而且當你考慮改變基類中虛函數的簽名,它(這個準則)還能幫助你評估出影響大不大。如果派生類所有的地方都使用了override,你只需要改變函數簽名,然後再編譯一次你的系統,看看你造成了多大的損害(也就是,各個派生類中有多少函數不能編譯),然後再決定這些問題是否值得你去改變函數簽名。如果沒有override,你就只能祈禱你有一個全面的單元測試了。因爲,就像我們看到的那樣,一個派生類的虛函數需要重寫基類的函數,但是它如果沒有“成功重寫”,那編譯器也不會發出警告。

C++有一些關鍵字一直是關鍵字,但是C++11介紹了兩個和上下文相關的關鍵字,override和final。這兩個關鍵字的特點是,只在特定的上下文中它們是保留的(不能用作其他name)。比如override的情況,只有當它出現在成員函數聲明的最後時,它纔是保留的。這意味着如果你有歷史遺留的代碼,代碼中已經使用了override作爲name,你不需要因爲你使用了C++11而改變它:

class Warning {
public:
    ...
    void override();            //在C++98和C++11中都合法
    ...                         //也擁有同樣的意義
};

關於override要說的已經說完了,但是有關成員函數引用限定符的東西還沒說完。我之前保證過我會在後面提供有關它們的信息,然後現在就是“後面”了。

如果我們想寫一個函數,這個函數只接受左值參數,我們可以聲明一個非const左值引用的參數:

void doSomething(Widget& w);        //只接受屬於左值的Widget

如果我們想寫一個函數,這個函數只接受右值參數,我們可以聲明一個右值引用的參數:

void foSomething(Widget&& w);       //只接受屬於右值的Widget

成員函數引用限定符也能做出這樣的區分,讓不同的對象(*this屬於左值還是右值)調用不同的成員函數(加不加override)。這和在成員函數的聲明後面加上const(這表示const對象要調用的成員函數)幾乎是完全一樣的。

需要引用限定功能的成員函數不常見,但是它是存在的。舉個例子,假設我們的Widget類有一個std::vector數據成員,並且我們提供一個訪問函數來讓客戶直接訪問這個變量:

class Widget {
public:
    using DataType = std::vector<double>;       //using的詳細信息請看Item 9
    ...

    DataType& data() { return values; }
    ...

private:
    DataType values;
};

這幾乎不符合大多數封裝設計的標準,但是把它放在一邊,並且考慮下在下面的客戶代碼中發生了什麼

Widget w;
...

auto vals1 = w.data();                  //把w.values拷貝到vals1中

Widget::data的返回類型是一個左值引用(準確地說是std::vector&),並且因爲左值引用被定義爲左值,vals1的初始化來自一個左值。因此,就像註釋說的那樣,用w.values 拷貝構造了一個vals1。

現在假設我們有一個工廠函數,這個函數能創建Widget,

Widget makeWidget();

並且我們想通過makeWidget返回的Widget,用這個Widget中的std::vector來初始化一個變量:

auto vals2 = makeWidget().data();       //把Widget中的值拷貝到vals2中

同樣地,Widget::data返回一個左值引用,並且,同樣地,左值引用是一個左值,所以同樣地,我們的新對象(vals2)通過拷貝構造函數拷貝了一份Widget中的值。這次Widget是一個從makeWidget返回的臨時對象(一個左值,),拷貝它的std::vector浪費時間,我們最好的做法是move它,但是因爲data返回一個左值引用,所以C++的規則要求編譯器生成拷貝的代碼。(若是通過所謂的“as if rule”來優化的話,這裏有一些迴旋餘地,但是如果你只能依賴你的編譯器找到方法來優化它,那你就真是太蠢了)

我們需要一個方法來明確一點,那就是當data被一個右值Widget調用時,結果也應該是一個右值。使用引用限定符來重載data的左值和右值版本讓之成爲可能:

class Widget {
public:
    using DataType = std::vector<double>;
    ...

    DataType& data()&           //左值Widget返回左值
    { return values;}

    DataType data() &&          //右值Widget返回右值
    { return std::move(values); }
    ...

private:
    DataType values;
};

注意兩個重載函數的返回值類型不同。左值引用重載函數返回一個左值引用(也就是一個左值),然後右值引用重載函數返回一個臨時對象(也就是一個右值)。這意味着現在,客戶代碼的表現是這樣的:

auto vals1 = w.data();              //調用Widget::data的左值
                                    //重載函數,拷貝構造一個vals1

auto vals2 = makeWidget().data();   //調用Widget::data的右值
                                    //重載函數,移動構造一個vals2

這確實表現得很好,但是不要讓這happy ending的光輝分散了你的注意力,這章的重點是當你在派生類中聲明一個函數,並打算用這個函數重寫一個基類中的虛函數時,你要把這函數聲明爲override的。

            你要記住的事
  • 把重寫函數聲明爲override的。
  • 成員函數引用限定符能區別對待左值和右值對象(*this)。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章