第14章:多态

通过继承,类可以从其他类(基类)派生。从前面的章节,我们已经见到,基类的指针可以指向派生类的对象。我们也见到,当基类的指针指向派生类的对象时,是由指针的类型决定哪些成员函数是可见的,而不是对象的类型决定。所以,当指针Vehicle *vp指向一个Car对象时,Car的speed和brandName函数是不能用的。

在之前的章节,讨论了两种基本的将类互相结合的方法:一个类可以在另一个类上实现,也可以是一个类派生基类。前者的关系通常是通过组合实现,后者通常是通过特殊的集成实现,叫做多态,即本章的主题。

类之间的is-a的关系使我们能够应用Liskov Substitution Principle(LSP),根据这个规则,在需要一个基类对象的指针或引用的地方,传递和使用派生类对象。到目前为止,我们在C++注解中已经多次应用到了LSP。我们每次都在需要ostream类型参数的函数中传递给它ostringstream,ofstream或者fstreawm。在本章中,我们将探索如何相应的设计自己的类。

LSP是通过多态实现的:尽管使用了基类的指针,却执行指针实际指向的对象(派生类)中定义的行为。所以Vehicle *vp指向Car时,行为却如同Car *。

多态是通过后期绑定的特性实现的。之所以称后期绑定,是因为要决定调用哪个函数(基类的函数或者派生类的函数)在编译期间是不能确定的,而是推迟到程序实际运行时:只有这是,才能决定要调用哪个函数。

在C++中,后期绑定不是函数调用的默认行为,默认是静态绑定(或叫早期绑定)。静态绑定是要调用的函数实在编译期间决定的。

后期绑定本质上是一种不同的(稍微慢一些)过程,因为它是在运行时决定的,而不是在编译时决定调用什么函数。由于早期绑定和后期绑定C++都支持,所以C++开法者可以自己选择使用哪种类型的绑定。可以根据手头上的不同情况做出最优的选择。许多其他的面向对象语言(如Java)只提供或者默认提供后期绑定。C++程序员应该明白这一点。期待早期绑定而得到了后期绑定很容易产生讨厌的bug。

我们通过一个简单的例子来分析一下早期绑定和后期绑定的区别。该例子仅仅是演示用。稍后会给出一个简短的解释为何是这样。

请看下面这个小程序:

#include <iostream>
using namespace std;

class Base
{
    protected:
        void hello()
        {
            cout << "base hello\n";
        }
    public:
        void process()
        {
            hello();
        }
};
class Derived: public Base
{
    protected:
        void hello()
        {
            cout << "derived hello\n";
        }
};
int main()
{
    Derived derived;

    derived.process();
}

这个程序中最重要的一点就是Base::process函数,调用hello函数。由于process是public接口中定义的唯一成员,它能被除了这两个类之外的其他代码调用。派生自Base的类Derived明显继承了Base的接口,因此process在Derived中是可用的。所以main中的Derived对象可以调用process,但是不能调用hello。

到目前为止,都很好理解。没有涉及新的东西,这些内容已经在之前的章节中讲过了。你可能知道为什么Derived是完全定义的。这大概是为了创建一个区别于Base::hello,适用于Drived的hello的实现。派生类的作者理由如下:Base的hello实现是不适用的;派生类对象可以通过提供适当的实现来弥补这一缺陷。此外我们的作者论证了:

由于对象的类型决定了要使用的接口,process肯定调用Derived::hello,因为hello是通过Derived对象的process方法调用的。

然而不幸的是,由于静态绑定,我们作者的推理是有缺陷的。当Base::process在编译的时候,静态绑定导致编译器将hello的调用绑定到了Base::hello()。

作者本来想要创建一个Base类的派生类。但只完成了一部分:Base接口被继承了,但Derived类放弃了对后面事情的控制。在process中,我们只能看见Base类的成员函数实现。多态则提供了一种方式,允许我们重新定义(在派生类中)基类中的成员函数,允许通过基类的接口调用这些重新定义的成员函数。

这就是LSP的本质:public inheritance should not be used to reuse the base class members (in derived classes) but to be reused (by the base class, polymorphically using derived class members reimplementing base class members).

花一点时间理解一下上面这个小程序的含义。hello和process函数并没有多大的意义,但这个程序是有意义的。process函在数可以实现层级遍历,hello可以定义当与到一个文件时要执行的操作。Base::hello可能只是简单显示文件名,但是Derived::hello可能会删除这个文件;也可能是判断是否文件在某个时间之后而决定是否列出;也可能是文件中含有特定的文本而列出其文件名等等。到现在,Derived要实现process的对自身的行为。多态云寻我们重新实现基类中的成员函数,然后通过基类的指针或引用使用我们重新实现的函数。使用多态,可以通过重新实现基类中相应的成员函数来复用已有代码。现在,是该揭示这是如何实现的了。

多态性,它不是C++的默认,解决问题和允许类的作者能够达到其目标。对于好奇的读者:请在Base类的void hello()前加上virtual关键字,并重新编译。运行修改后的程序,将会产生和预期一致的结果。接下来分析为什么是这样呢?

14.1 虚函数

默认情况下,通过指针或引用调用的成员函数的行为是由指针或引用的类中该函数的实现决定的。例如,Vehicle *激活Vehicle的成员函数,即使这个指针指向派生类对象。这就是早期或者静态绑定:要调用的函数是在编译阶段决定的。C++中,后期绑定或动态绑定通过虚函数实现。

当成员函数使用virtual关键字声明后,它就变成了虚函数。再次强调,和其他面向对象语言相比,这不是C++的默认情形。默认情况是使用静态绑定。

一旦基类中的函数声明为虚函数,在所有的派生类中它都是虚函数。在基类中声明为虚函数的成员,在派生类中就不应该再使用virtual关键字了。在派生类中,这些成员函数应该使用override,允许编译器确认你确实是要使用这个已经存在虚函数。

在交通工具(vehicle)类系统里(见13.1),我们着重讲解了mass和setMass成员。这两个成员定义了Vehicle类的接口。我们将要完成的是Vehicle和Vehicle的派生类都能使用这个接口,因为这些类对象本身都是属于交通工具。

如果我们定义了基类的接口(如交通工具),那么不管从交通工具派生出来的类是什么,这些接口都是可用的,那么我们的软件就实现了高度的可重用性:我们设计的或软件围绕交通工具的接口,我们的软件也能为派生类提供这样的功能。使用普通继承不能完成此任务。如果我们定义:

std::ostream &operator<<(std::ostream &out, Vehicle const &vehicle)
{
    return out << "Vehicle's mass is " << vehicle.mass() << " kg.";
}

交通工具的mass函数返回0,但是Car的mass函数返回1000,那么下面这个程序将会输出两次0:

int main()
{
    Vehicle vehicle;
    Car vw(1000);

    cout << vehicle << '\n' << vw << endl;
}

我们已经重载了插入运算符,但是它只知道Vehicle的接口,cout << vw使用的也是Vehicle的解耦,因此显示的是0。

如果我们在基类中加入一个可以冲定义的接口,可重用性就会增强。可被重新定义的接口允许派生类实现它们自己的行为,而不影响这个接口。同时,接口具有派生类所期望的行为,而不是接口的默认实现。

可重用的接口应该声明在类的私有部分:从概念上讲,它们仅仅属于它们自己的类(参见14.7)。在基类中,这些成员应该声明为虚函数(virtual)。派生类中,对这些虚函数重定义(而且应该使用override指示器)。

我们保留我们的接口不变,并加入可重定义的vmass成员函数到Vehicle的接口中:

class Vehicle
{
    public:
        size_t mass() const;
        size_t si_mass() const;    // see below

    private:
        virtual size_t vmass() const;
};

分离出用户接口和可重用的接口是一件明智的事。允许我们微调用户接口(可维护性),同时,允许我们标准化可重定义接口的期望的行为。例如,在许多国家使用国际化单位制,采用千克作为重量的单位,有些国家使用其他单位(例如lbs:1kg约为2.2046lbs)。通过分离用户接口和可重定义接口,我们可以对可重定义的接口使用一种标准,在用户接口中保持信息转换的灵活性。

只是为了维护一个清晰的用户和可重用接口的分离,我们考虑在Vehicle中加入另外一个访问器si_mass,实现如下:

size_t Vehicle::si_mass() const
{
    return vmass();
}

在Vehicle中加入一个d_massFactor成员,然后我们就可以这样计算重量:

size_t Vehicle::mass()
{
    return d_massFactor * si_mass();
}

Vehicle自己可以定义vmass使它返回一个象征值,例如:

size_t Vehicle::vmass()
{
    return 0;
}

现在,让我们来看下Car类。他派生自Vehicle,继承了Vehicle的接口。它也有size_t d_mass数据成员,它实现自己的可重用的接口:

class Car: public Vehicle
{
    ...
    private:
        size_t vmass() override;
}

如果Car的构造函数需要我们提供车的重量(存储在d_mass中),然后,Car只需要实现它的vmass成员函数如下:

size_t Car::vmass() const
{
    return d_mass;
}

派生自Car的Truck类,需要两个重量值:车头的重量和拖车的重量。车头的重量传递给基类Car,拖车的重量传递Vehicle类的d_trailor数据成员。Truck类也重写vmass,返回车头和拖车的总重量:

size_t Truck::vmass() const
{
    return Car::si_mass() + d_trailer.si_mass();
}

一旦类成员声明为虚函数,则在所有的派生类中,无论这些成员是否有override指示器,他们都是虚函数。但应该使用override指示器,允许编译器检查派生类的接口的拼写错误。

成员函数可以在类继承的任意层级声明为虚函数,但这可能会破坏底层多态类的设计,因为原来的基类不再能够完整的涵盖派生类中可重定义的接口。例如,mass在Car中声明为虚函数,而不是在Vehicle,那么相关的虚函数特性只在Car和Car的派生类可用。对于Vehicle的指针或引用,使用的仍然是静态绑定。

后期绑定(多态)的效果演示如下:

void showInfo(Vehicle &vehicle)
{
    cout << "Info: " << vehicle << '\n';
}

int main()
{
    Car car(1200);            // car with mass 1200
    Truck truck(6000, 115,      // truck with cabin mass 6000, 
          "Scania", 15000);     // speed 115, make Scania, 
                                // trailer mass 15000

    showInfo(car);             // see (1) below
    showInfo(truck);            // see (2) below

    Vehicle *vp = &truck;
    cout << vp->speed() << '\n';// see (3) below
}

现在mass定义为虚函数,采用了后期绑定:

  • 在(1)处,显示Car的重量;
  • 在(2)处,显示Truck的重量
  • 在(3)处,会产生语法错误。speed不适Vehicle的成员函数,所以不能通过Vehicle调用。

这个例子演示了当一个指针指向某个类时,只能调用该类的成员函数。virtua只影响绑定的类型(早期绑定或晚期绑定),而不适所能访问的成员函数。

通过虚函数,当用基类的指针或引用调用基类的函数时,派生类可以重新定义这些函数的行为。这种派生类对基类的成员函数的重新定义称为函数重载。

14.2:虚析构函数

当一个对象即将销毁时,会调用该对象的析构函数。考虑下面这段代码:

Vehicle *vp = new Land{ 1000, 120 };

delete vp;          // object destroyed

这里,delete应用到了一个指向基类的指针。由于基类定义了可用的delete接口,vp调用~Vehicle,但却没有调用~Land。假设Land分配了内存,那么就会产生内存泄漏。析构函数不仅仅用作释放内存。通常,会执行一些对象销毁之前的必要动作。但是~Land定义的任何行为都没有执行。坏消息…

在C++中,这种问题通过虚析构函数解决。虚构函数可以用virtual声明。若基类的的析构函数声明为虚函数,那么当对一个指向基类的指针bp执行delete操作时,则会执行该指针实际指向的类的析构函数。对于析构函数也实现了动态绑定,尽管基类和派生类析构函数有不同的名字。例如:

class Vehicle
{
    public:
        virtual ~Vehicle();     // all derived class destructors are
                                // now virtual as well.
};

通过声明虚析构函数,上面的delete操作能够正确的调用Land的析构函数,而不是Vehicle的析构函数。

一旦析构函数被调用,它就和平常一样,无论它是否是虚析构函数。所以,~Land首先执行自己的语句,然后调用~Vehicle。因此上面的delete vp语句通过动态绑定调用~Vehicle,从这点开始通常的对象销毁流程。

如果一个类设计为被其他类派生的基类,应该将其析构函数声明为虚函数。通常这些析构函数自身什么也不执行。这种情况下,给其一个空的函数体即可。例如,Vehicle::~Vehicle()的定义可能简单的为:

Vehicle::~Vehicle()
{}

不要图方便将虚析构函数定义为内联形式(即使是空的析构函数)。14.11节会讨论为什么。

14.3:纯虚函数

基类Vehicle提供了自己的虚函数实现(mass和setMass)。但是,基类中的虚函数可以不用实现。

若基类中的虚函数省略了实现,那么派生类中必须要进行实现。

这种方式在一些语言中(如C#,Delphi和Java)称为接口,定义一个协议。派生类必须遵循这个协议,并且实现这些未实现的成员函数。如果一个类至少包含一个未实现的成员函数,则不能创建这个类的对象。

这样不完全定义的类总是基类。他们通过声明一些函数名称,返回值和参数列表来定制一个协议。这些类叫做抽象类或抽象基类。派生类通过实现这些未实现的成员函数称为非抽象类。

抽象类是许多设计模式的基础(参考Gamma et al. (1995)),允许开发者创建高度可重用的软件。其中的一些设计模式会在本书中设计(如24.2中的模板方法),但是对于设计模式的彻底讨论请参考Gamma et al.这本书。

在基类中只有声明的成员函数叫做纯虚函数。在虚函数的声明后面加上= 0,这个函数就变成了纯虚函数。例如:

#include <iosfwd>
class Base
{
    public:
        virtual ~Base();
        virtual std::ostream &insertInto(std::ostream &out) const = 0;
};
inline std::ostream &operator<<(std::ostream &out, Base const &base)
{
    return base.insertInto(out);
}

所有派生自Base的类必须实现insertInto函数,否则不能创建它们的对象。这很巧妙:所有派生自Base的的类总是能插入到ostream。

虚析构函数是否可以是纯虚函数?答案是否定的。首先,派生类并不是强制要有析构函数,因为有默认的析构函数(除非析构函数声明时有= delete属性)。其次,如果它是纯虚函数,它的实现不存在。然而,派生类的析构函数最终会调用基类的析构函数。如果基类连实现都没有,那么该怎么调用呢?下一节会进一步讨论这个问题。

通常,但不是必需的,纯虚函数是const类型的成员函数。这允许创建派生类的常量对象。在其他情况下,这可能不是必要的(或不现实的),并且可能需要非const成员函数。对于const成员函数的通用规则同样适用于纯虚函数:如果成员函数更新对象的数据成员,就不能声明为const。

抽象类往往没有数据成员。然而,一旦基类声明了一个纯虚函数,那么在派生类中必须要有同样的声明。如果派生类中纯虚函数的实现更新了派生类对象的数据,则这个函数不能声明为const。因此,抽象类的作者应该仔细考虑一个纯虚函数是否应该声明为const。

14.3.1:实现纯虚函数

纯虚函数可以被实现。要实现一个纯虚函数,To implement a pure virtual member function, provide it with its normal = 0; specification, but implement it as well. Since the = 0; ends in a semicolon, the pure virtual member is always at most a declaration in its class, but an implementation may either be provided outside from its interface (maybe using inline).

纯虚函数可在它的类中或派生类中调用,也可以在派生类中通过类名和域解析运算符调用它。例如:

#include <iostream>

class Base
{
    public:
        virtual ~Base();
        virtual void pureimp() = 0;
};
Base::~Base()
{}
void Base::pureimp()
{
    std::cout << "Base::pureimp() called\n";
}
class Derived: public Base
{
    public:
        virtual void pureimp();
};
inline void Derived::pureimp()
{
    Base::pureimp();
    std::cout << "Derived::pureimp() called\n";
}
int main()
{
    Derived derived;

    derived.pureimp();
    derived.Base::pureimp();

    Derived *dp = &derived;

    dp->pureimp();
    dp->Base::pureimp();
}
// Output:
//      Base::pureimp() called
//      Derived::pureimp() called
//      Base::pureimp() called
//      Base::pureimp() called
//      Derived::pureimp() called
//      Base::pureimp() called

实现纯虚函数的用途有限。有人可能会争论纯虚函数的实现可以用来执行基类级别的任务。但是,不能保证基类的虚函数能够实际执行。因此基类的特殊的任务可能有其他的成员提供, without blurring the distinction between a member doing some work and a pure virtual member enforcing a protocol.

14.4:明确的虚函数重载

考虑系列情形:

  • 类Value是一个值类。提供一个拷贝构造函数,一个赋值运算符的重载函数,可能还有移动运算符shanghaiing函数,一个共有的非虚的构造函数。在14.7节,指出这样的类不适合作为基类,新的类不应该继承Value类。那么如何强制如此?

  • 多态类Base定义了一个虚函数v_process(int32_t)。它的一个派生类需要重载这个成员函数,但是作者拼写错误,协程了v_proces(int32_t)。如何避免这样的错误,破坏了派生类的多态行为。

  • 一个派生自多态类Base的类Derived重载了Base::v_process,但是派生自Derived的应该不要在重载v_process,但是可以重载qita的虚函数,如v_call和v_display。如强制限制Derived的派生类的多态特性?

采用两个特殊标识符final和override来实现这些需求。说他们特殊,因为他们只在特定的情形下才具有特殊的意义。其他情况下它们只是普通的标识符,比如,你可以定义一个bool final变量。

final标识符可以用在类声明时,表示这个类不可以作为基类,例如:

class Base1 final               // cannot be a base class
{};
class Derived1: public Base1    // ERR: Base1 is final
{};

class Base2                     // OK as base class
{};
class Derived2 final: public Base2  // OK, but Derived2 can't be
{};                                 //     used as a base class
class Derived: public Derived2      // ERR: Derived2 is final
{};

finnal标识符也可以用在虚函数的声明。表明这些虚函数不可以被派生类重载。由此来限制类的多态特性,如上面提到的,可以这样实现:

class Base
{
    virtual int v_process();    // define polymorphic behavior
    virtual int v_call();
    virtual int v_display();
};
class Derived: public Base      // Derived restricts polymorphism
{                               // to v_call and v_display
    virtual int v_process() final;
};
class Derived2: public Derived
{
    // int v_process();            No go: Derived:v_process is final
    virtual int v_display();    // OK to override
};

如果想让编译器检拼写错误,参数的不同,或者函数的修饰符不同(如const和非const),可以添加override到要重写的函数的后面,例如:

class Base
{
    virtual int v_process();
    virtual int v_call() const;
    virtual int v_display(std::ostream &out);
};
class Derived: public Base
{
    virtual int v_proces() override;    // ERR: v_proces != v_process
    virtual int v_call() override;      // ERR: not const
                                        // ERR: parameter types differ
    virtual int v_display(std::istream &out) override;
};

14.5:虚函数和多继承

在第6章,我们介绍了fstream类,该类提供了ifstream和ofstream。在第13章,我们学习到一个类可以继承多个基类。这样的派生类继承基类的所有属性。在多继承中同样可以使用多态。

思考如果从派生类到基类的“路径”多余一条会发生什么?下面这个例子演示了派生类两次继承基类Base:

class Base
{
    int d_field;
    public:
        void setfield(int val);
        int field() const;
};
inline void Base::setfield(int val)
{
    d_field = val;
}
inline int Base::field() const
{
    return d_field;
}
class Derived: public Base, public Base
{};

由于两次继承,基类的功能在派生类中出现了两次,产生了歧义:当用派生类对象调用setfield函数,将会执行两个函数中的哪一个呢?使用域解析操作符也无法处理这个问题,因此C++编译器不能编译上面的代码,并产生一个错误。

上面的代码明显是派生类多次继承了基类,当然,很容易避免这种情况,不要两次继承Base就好了(或者使用组合类)。但是,在嵌套的继承中,仍然有可能出现多次继承基类。如果有一种会飞的汽车(such as the one in James Bond vs. the Man with the Golden Gun…),定义它的类为AirCar,它派生自Car类和Air类(参考13.1)。那么AirCar最终会包含两个Vehicle,因此会有两个重量字段和两个setMass()函数和两个mass()函数。这是我们想要的吗?

14.5.1:多继承中的歧义

我们来进一步研究一下为什么派生自Car和Air的类AirCar会产生歧义。

  • AirCar是一辆车,因此是Land,所以是Vehicle
  • 然而,AirCar也是Air,所以也是Vehicle

重复的Vehicle如图14所示:

多继承中的基类重复
图14:多继承中的基类重复

AirCar的内部组织如图15所示:

AirCar的内部组织
图15:AirCar的内部组织

C++编译器会检查AirCar对象的歧义性,因此下面的代码不能编译:

AirCar jBond;
cout << jBond.mass() << '\n';

编译器不知道要调用哪个函数,但是我们有两种办法帮助编译器消除歧义:

  • 第一种,给产生歧义的函数添加修饰。使用域解析运算符消除歧义:
// let's hope that the mass is kept in the Car
// part of the object..
cout << jBond.Car::mass() << '\n';

将域解析运算符和类的名称和放在函数名前。

  • 第二种,为AirCar创建单独的函数:
int AirCar::mass() const
{
    return Car::mass();
}

更倾向使用第二种,因为它不需要编译器标记错误;我们使用AirCar时,也不需要特别的措施。

然而,还有更多好的办法,会在下面一节讨论到。

14.5.2:虚基类

如图15,AirCar代表了两个Vehicle。不仅产生了要调用哪个函数的歧义,还在AirCar中定义了两个mass字段。这有点多余,因为AirCar只有一个重量。

但是,在使用多继承的时候,我们是可以定义只有一个Vehicle的AirCar类的。在派生类的继承树中,我们可以把多次出现的基类定义为虚基类。

对于AirCar,只需作出一个小小的修改即可:

class Land: virtual public Vehicle
{
    // etc 
};
class Car: public Land
{
    // etc 
};
class Air: virtual public Vehicle
{
    // etc 
};
class AirCar: public Car, public Air 
{
};

虚继承确保对于一个派生类,只有一个Vehicle。意味着由AirCar到Vehicle的路径不再依赖它的直接基类;
we can only state that an AirCar is a Vehicle.现在,AirCar的内部组织如图16所示:

虚基类时AirCar的内部组织
图16:虚基类时AirCar的内部组织

若Third类继承自Second类,Second类又继承自First类,那么当创建Third类的对象时,调用Second类的构造函数,Second类的构造函数调用First类的构造函数,如:

class First
{
    public:
        First(int x); 
};
class Second: public First
{
    public:
        Second(int x)
        :   
            First(x)
        {}  
};
class Third: public Second
{
    public:
        Third(int x)
        :   
            Second(x)           // calls First(x)
        {}  
};

如果Second类使用了虚继承,就不再是上面所说的了。如果Second类使用了虚继承,并且它的构造函数由Third的构造函数调用,则它的基类的构造函数被忽略。替代的是,Second默认调用First的默认构造函数。看下面的例子:

class First
{
    public:
        First()
        {
            cout << "First()\n";
        }
        First(int x);
};
class Second: public virtual First      // note: virtual
{
    public:
        Second(int x)
        :
            First(x)
        {}
};
class Third: public Second
{
    public:
        Third(int x)
        :
            Second(x)
        {}
};
int main()
{
    Third third{ 3 };   // displays `First()'
}

当创建Third是,使用了First默认构造函数。但Third的构造函数通过明确制定要调用的构造函数,即可改变默认的行为。由于在创建Second对象时,First对象必须可用,所以要先调用First的构造函数。如果创建Third(int)对象时要调用First(int),那么Third的构造函数可以这样定义:

class Third: public Second
{
    public:
        Third(int x)
        :   
            First(x),           // now First(int) is called.
            Second(x)
        {}  
};

当使用简单的现行继承时,这种行为会让人感到困惑,但当在多继承时,集合虚基类是很有用的。思考AirCar:

发表回复

您的电子邮箱地址不会被公开。 必填项已用*标注