C++11新特性——final/override关键字

1.“重写”的概念

在了解C++11中的final/override关键字之前,我们先回顾一下C++关于"重写"的概念。简单地说,一个类A中声明的虚函数fun在其派生类B中再次被定义,且B中的函数fun跟A中fun的原型一样(函数名、参数列表等一样),那么我们就称B重写了A的fun函数。对于任何B类型的变量,调用成员函数fun都是调用了B重写的版本。而如果同时有A的派生类C,却并没有重写A的fun函数,那么调用成员函数fun则会调用A中的版本。

2.final关键字

在通常情况下,一旦在基类A中的成员函数fun被声明为virtual的,那么对于其派生类B而言,fun会有一个缺省的默认实现(从A类中继承过来的)。有的时候我们并不想fun在B类型派生类中被重写,那么,C++98没有方法对此进行限制。我们看看下面这个具体的例子,如下面代码所示:

#include 
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;
};

在上面的代码中,我们的基础类MathObject定义了两个接口:Arith和 Print。类Printable则继承于MathObject并实现了Print接口。接下来,Add2和Mul3为了使用MathObject的接口和Printable的Print的实现,于是都继承了Printable。这样的类派生结构,在面向对象的编程中非常典型。不过倘若这里的Printable和Add2是由两个程序员完成的,Printable的编写者不禁会有一些忧虑,如果Add2的编写者重载了Print函数,那么他所期望的统一风格的打印方式将不复存在。

对于Java这种所有类型派生于单一元类型( Object)的语言来说,这种问题早就出现了。因此Java语言使用了final 关键字来阻止函数继续重写。final关键字的作用是使派生类不可覆盖它所修饰的虚函数。C++11也采用了类似的做法,如下面代码所示:

struct Object{
virtual void fun( ) = 0 ;
} ;

struct Base : public object {
    void fun() final ;//声明为final
};

struct Derived : public Base {
    void fun ( ) ;//无法通过编译
};

在上面的代码中,派生于Object的Base类重载了Object的fun接口,并将本类中的fun函数声明为final的。那么派生于Base的 Derived类对接口fun的重载则会导致编译时的错误。同理,在之前的代码示例中,Printable的编写者如果要阻止派生类重载Print函数,只需要在定义时使用final进行修饰就可以了。

读者可能注意到了,在上面的示例当中,final关键字都是用于描述一个派生类的。那么基类中的虚函数是否可以使用final关键字呢?答案是肯定的,不过这样将使该虚函数无法被重写,也就失去了虚函数的意义。如果不想成员函数被重写,程序员可以直接将该成员函数定义为非虚的。而final通常只在继承关系的“中途”终止派生类的重载中有意义。从接口派生的角度而言,final可以在派生过程中任意地阻止一个接口的可重写性,这就给面向对象的程序员带来了更大的控制力。


3.override关键字

在C++中重写还有一个特点,就是对于基类声明为virtual的函数,之后的重写版本都不需要再声明该重写函数为virtual。即使在派生类中声明了virtual,该关键字也是编译器可以忽略的。这带来了一些书写上的便利,却带来了一些阅读上的困难。比如上面代码中的Print函数,程序员无法从Printable的定义中看出Print是一个虚函数还是非虚函数。另外一点就是,在C+中有的虚函数会“跨层”,没有在父类中声明的接口有可能是祖先的虚函数接口。比如在前面的代码中,如果Printable不声明Arith函数,其接口在Add2和 Mul3中依然是可重写的,这同样是在父类中无法读到的信息。这样一来,如果类的继承结构比较长(不断地派生)或者比较复杂(比如偶尔多重继承),派生类的编写者会遇到信息分散、难以阅读的问题(虽然有时候编辑器会进行提示,不过编辑器不是总是那么有效)。而自己是否在重写一个接口,以及自己重载的接口的名字是否有拼写错误等,都非常不容易检查。

在C++11中为了帮助程序员写继承结构复杂的类型,引入了虚函数描述符override,如果派生类在虚函数声明时使用了override描述符,那么该函数必须重写其基类中的同名函数,否则代码将无法通过编译。我们来看一下下面的例子:

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 DeriveaMid {
    void Turing() override ;
    void Dikjstra() override;//无法通过编译,拼写错误,并非重写
    void vNeumann(double g) override;//无法通过编译,参数不一致,并非重写
    void DKnuth() override ;//无法通过编译,常量性不一致,并非重写
    void Print() override;//无法通过编译,非虚函数重写
};

在上面的代码中,我们在基类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修饰符则可以保证编译器辅助地做一些检查。我们可以看到,在这段代码中,DerivedTop作者的4处错误都无法通过编译。

此外,值得指出的是,在C++中,如果一个派生类的编写者自认为新写了一个接口,而实际上却重写了一个底层的接口(一些简单的名字如get、set、print就容易出现这样的状况),出现这种情况编译器还是爱莫能助的。不过这样无意中的重载一般不会带来太大的问题,因为派生类的变量如果调用了该接口,除了可能存在的一些虚函数开销外,仍然会执行派生类的版本。因此编译器也就没有必要提供检查“非重写”的状况。而检查“一定重写”的override关键字,对程序员的实际应用则会更有意义。

还有值得注意的是,final/override也可以定义为正常变量名,只有在其出现在函数后时才是能够控制继承/派生的关键字。通过这样的设计,很多含有final/override变量或者函数名的C++98代码就能够被C++编译器编译通过了。但出于安全考虑,建议读者在C++11代码中应该尽可能地避免这样的变量名称或将其定义在宏中,以防发生不必要的错误。

你可能感兴趣的:(C++,c++,C++11特性,final,override)