在C++中,面向对象程序设计是以类、继承和虚函数为中心的。其中,最基本的理念是:在派生类中重写从基类继承过来的虚函数。令人遗憾的是,重写虚函数很容易出错。
因为重写(overriding)听起来有点像重载(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
为了能够成功达到重写,必须满足一些要求:
- 基类函数必须是virtual的。
- 基类函数和派生类函数的名字必须完全一样(除了析构函数)。
- 基类函数和派生类函数的参数类型必须完全一样。
- 基类函数和派生类函数的const属性必须完全一样。
- 基类函数和派生类函数的返回值类型以及异常规格(exception specification)必须是可兼容的。
这些是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 (returns 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 &&)
关于成员函数引用限定符的细节,随后再说,现在我们只需要记住,如果基类中的一个虚函数被引用限定符修饰,那么继承类中的重写函数也必须被同样的引用限定符修饰。如若不然,声明的函数还是存在于派生类中的,只是不会重写基类中的任何函数。
为了达到重写的目的,有这么多的要求和限制,这就意味着一个小小的错误,就可以导致很大偏差。包含错误重写的代码常常是有效的,但往往你不是你想要的。因此,你不能依赖编译器提醒你是否有错误。例如,下面的代码就完全是合法的,乍一看还很合理,但一个重写的虚函数都没有(没有一个派生类的函数是关联到基类的函数的)。你能找出每种情况的问题所在么,也就是,为什么每个同名的派生类函数没有重写基类函数?
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; // adding "virtual" is OK,
// but not necessary
};
注意,在这个例子中,为了让代码能工作,有一处是将Base中的mf4声明为虚函数。大多数重写相关的错误都是在派生类中,但在基类上出问题也是完全有可能的。
把所有的派生类中的重写函数都声明为override,这个准则不仅能让编译器告诉你什么地方声明了override却没有重写任何东西。而且当你考虑改变基类中虚函数的签名,它(这个准则)还能帮助你评估出波及面。如果派生类中每处都应用了override,你修改了函数签名,然后重新编译,就能看到造成了多大破坏(数一数多少派生类编译失败),然后决定是否值得去修改函数签名。没有override声明,你只能寄希望于有一个完备的单元测试了。
C++ 一直都有关键字,但c++ 11引入了两个上下文相关的关键字: override和final(将final应用于虚函数,会阻止它在派生类中被重写。final也可以修饰一个类,这种情况,该类会被禁止用作基类,也就是无法被继承)。这两个关键字的特点是,只在特定的上下文中它们是保留的(不能用作其他name)。以override为例,只有当它出现在成员函数声明的最后时,它才是保留的。这意味着对于历史遗留的代码,代码中已经使用了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要说的已经说完了,但是有关成员函数引用限定符的东西还没说完。我之前保证过我会在后面提供有关它们的信息,现在是时候了。
如果我们想写一个函数,这个函数只接受左值参数,我们可以声明一个非const左值引用的参数:
void doSomething(Widget& w); // accepts only lvalue Widgets
如果我们想写一个函数,这个函数只接受右值参数,我们可以声明一个右值引用的参数:
void doSomething(Widget&& w); // accepts only rvalue Widgets
成员函数引用限定符的作用就是针对发起成员函数调用的对象(即*this)加以区分。这和在成员函数后面加一个const的情形一样,即表明发起成员函数调用的对象应为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&),并且因为左值引用被定义为左值,我们用一个左值来初始化vals1。因此,就像注释说的那样,vals1是以w.values进行拷贝构造的。
现在假设我们有一个构建Widget对象的工厂函数,
Widget makeWidget();
并且我们想通过makeWidget返回的Widget,用这个Widget中的std::vector来初始化一个变量:
auto vals2 = makeWidget().data(); // copy values inside the Widget into vals2
同样地,Widget::data返回一个左值引用,并且,同样地,左值引用是一个左值,所以同样地,我们的新对象(vals2)通过拷贝构造函数拷贝了一份Widget中的值。这次Widget是一个从makeWidget返回的临时对象(一个左值,),拷贝它的std::vector浪费时间,我们最好的做法是move它,但是因为data返回一个左值引用,所以C++的规则要求编译器生成拷贝的代码。(若是通过所谓的“准规则”来优化的话,这里有一些回旋余地,但是如果你只能依赖你的编译器找到方法来优化它,那你就真是太蠢了)
我们需要一个方法来明确一点,那就是当data被一个右值Widget调用时,结果也应该是一个右值。使用引用限定符来重载data的左值和右值版本让之成为可能:
class Widget {
public:
using DataType = std::vector<double>;
…
DataType& data() & // for lvalue Widgets,
{ return values; } // return lvalue
DataType data() && // for rvalue Widgets,
{ return std::move(values); } // return rvalue
…
private:
DataType values;
};
注意两个重载函数的返回值类型不同。左值引用重载函数返回一个左值引用(也就是一个左值),然后右值引用重载函数返回一个临时对象(也就是一个右值)。这意味着现在,客户代码的表现是这样的:
auto vals1 = w.data(); // calls lvalue overload for
// Widget::data, copy-
// constructs vals1
auto vals2 = makeWidget().data(); // calls rvalue overload for
// Widget::data, move-
// constructs vals2
这确实表现得很好,但是不要让这happy ending的光辉分散了你的注意力,这章的重点是当你在派生类中声明一个函数,并打算用这个函数重写一个基类中的虚函数时,你要把这函数声明为override的。
Things to Remember
- 把重写函数声明为override的。
- 成员函数引用限定符能区别对待左值和右值对象(*this)。