欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

Effective Modern C++: Item 12 -> 声明覆盖函数override

程序员文章站 2024-03-23 14:16:34
...

声明覆盖函数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),overridefinal。这些关键词只有在特定上下文中才有它们预留的特性。在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)区别对待