C++回顾——多态性和虚函数

版权声明:本文为博主原创文章,未经博主允许不得转载。 https://blog.csdn.net/ZLANBL085321/article/details/82020086

多态性(在C++中通过虚函数来实现)是面向对象程序设计语言中数据抽象和继承之外的第三个基本特征。多态性提供了接口与具体实现之间的另一层隔离,改善了代码的组织性和可读性,同时也使创建的程序具有可扩展性,程序不仅在项目的最初创建期可以“扩展”,而且当在项目需要有新的功能时也能“扩展”。

一、函数调用捆绑
把函数体与函数调用相联系称为捆绑(binding)。当捆绑在程序运行之前(由编译器和连接器)完成时,这称为早捆绑。捆绑根据对象的类型,发生在运行时,称为晚捆绑(又称为动态捆绑、运行时捆绑)。当一个语言实现晚捆绑时,必须有某种机制来确定运行时对象的类型并调用合适的成员函数。对于一种编译语言,编译器并不知道实际的对象类型,但它插入能找到和调用正确函数体的代码。晚捆绑机制因语言而异。

1、C++如何实现晚捆绑
所有的工作都由编译器在幕后完成。当告诉编译器要晚捆绑时(通过创建虚函数来告诉),编译器安装必要的晚捆绑机制。为了达到这个目的,通常,编译器对每个包含虚函数的类创建一个表(称为VTABLE)。在VTABLE中,编译器放置特定类的虚函数的地址。在每个带有虚函数的类中,编译器放置一个指针(称为vpointer,缩写为VPTR),指向这个对象的VTABLE。当通过基类指针做虚函数调用时(即多态调用),编译器静态地插入能取得这个VPTR并在VTABLE表中查找函数地址的代码,这样就能调用正确的函数并引起晚捆绑的发生。为每个类设置VTABLE、初始化VPTR、为虚函数调用插入代码,所有这些都是自动发生的。
每当创建一个包含有虚函数的类或从包含有虚函数的类派生一个类时,编译器就为这个类创建一个唯一的VTABLE,在这个表中,编译器放置了在这个类中或它的基类中所有已声明为virtual的函数的地址。如果在这个派生类中没有对在基类中声明为virtual的函数进行重新定义,编译器就使用基类的虚函数地址。然后编译器在这个类中放置VPTR,VPTR必须被初始化为指向响应的VTABLE的起始地址(这在构造函数中发生)。

如果编译器有一个它知道确切类型的对象,在C++中,那么对任何函数的调用将不再使用晚捆绑(因为编译知道对象的确切类型,为了提高效率,当调用这些对象的虚函数时,很多编译器使用早捆绑)。

二、虚函数
对于特定的函数,为了引起晚捆绑,C++要求在基类中声明这个函数时使用virtual关键字。晚捆绑只对virtual函数起作用,而且只在使用含有virtual函数的基类的地址时发生。
创建virtual函数时,仅仅在声明的时候需要使用关键字virtual,定义时并不需要。如果一个函数在基类中被声明为virtual,那么在所有的派生类中它都是virtual的。在派生类中virtual函数的重定义通常称为重写。注意,仅需要在基类中声明一个函数为virtual。调用所有匹配基类声明行为的派生类函数都将使用虚机制(虽然可以在派生类声明前使用virtual,但这会使程序冗余混乱)。

1、为什么需要虚函数
虚函数不是相当高效的(它并不是对于绝对地址的一个简单的CALL,而是为设置虚函数调用需要两条以上的复杂的汇编指令,这既需要代码空间,也需要执行时间)。因此,virtual可以改变程序的效率,然而,当设计类时,我们不应当为效率问题担心。如果想使用多态,就在每处使用虚函数。当试图加速代码时,只需要寻找可用不使用虚函数的函数。

三、抽象基类和纯虚函数
如果仅想对基类进行向上类型转换,使用它的接口,而不希望用户实际创建一个基类的对象,就可以在基类中加入至少一个纯虚函数,来使基类成为抽象类(如果类中全是纯虚函数,称为纯抽象类)。纯虚函数使用关键字virtual,并且在其后面加上=0。
当继承一个抽象类时,必须实现所有的纯虚函数,否则继承出的类也将是一个抽象类。同时,纯虚函数要求继承出的类对它提供一个定义。
纯虚函数等于告诉编译器在VTABLE中为函数保留一个位置,但在这个特定位置中不放地址。只有有一个函数在类中被声明为纯虚函数,则VTABLE就是不完全的。
纯虚函数禁止对抽象类的函数以传值方式调用,这也是防止对象切片(编译器只拷贝对象对应于基类的部分,切除这个对象的派生部分,派生类对象被强制变成基类对象。对象切片实际上是当它拷贝到一个新的对象时,去掉原来对象的一部分,而不是像使用指针或引用那样简单地改变地址的内容。因此,不常使用对象向上类型转换。)的一种方法。通过抽象类,可以保证在向上类型转换期间总是使用指针或引用。

在基类中,对纯虚函数提供定义是可能的(我们可能希望一段公共代码,使一些货所有派生类定义都能调用,而不必在每个函数中重复这段代码)。例如:

class Pet {
public:
    virtual void speak() const = 0;
};

void Pet::speak() const {
    cout << "Pet::speak()" << endl;
}

class Dog : public Pet {
public:
    void speak() const { Pet::speak(); }
};

Pet的VTABLE表仍然空着,但在派生类中刚好有一个函数,可以通过名字调用它。这个特点的好处是,它允许我们实现从常规虚函数到纯虚函数的改变。

四、继承和VTABLE
对于可被创建的每个对象,在VTABLE中总有一个函数地址的全集,所以绝对不能调用不在其中的地址。
在派生类中继承或增加新的虚函数时,编译器在派生类的VTABLE中把函数的地址准确地映射到和基类的VTABLE中同样的位置。这是因为编译器产生的代码在VTABLE中使用一个简单的偏移来选择虚函数。不论对象属于哪个特殊的类,它的VTABLE都是以同样的方法设置,所以对虚函数的调用将总是使用同样的方法。

五、重载和重新定义
1、编译器不允许我们改变重新定义过的函数的返回值(如果不是虚函数,则是允许的),但也有特例,如果返回一个指向基类的指针或引用,则该函数的重新定义版本将会从基类返回的内容中返回一个指向派生类的指针或引用。
2、如果重新定义了基类中的一个重载成员函数(虚函数或非虚函数),则在派生类中其他的重载函数将会被隐藏。
3、可以改变重载成员虚函数的参数列表。

六、虚函数和构造函数
当创建一个包含虚函数的对象时,必须初始化它的VPTR以指向相应的VTABLE,这一工作是构造函数完成的。编译器在构造函数的开头部分秘密地插入能初始化VPTR的代码。如果我们没有为一个类显式地创建构造函数,则编译器会为我们生成构造函数。如果该类含有虚拟函数,则生成的构造函数将会包含相应的VPTR初始化代码。
所有基类构造函数总是在继承类构造函数中被调用。因为构造函数要确保对象被正确地建立,而派生类只访问它自己的成员,不访问基类的成员,只有基类构造函数能正确地初始化它自己的成员。因此,确保所有的构造函数被调用是很关键的,否则整个对象不会适当地被构造。如果不在构造函数初始化表达式表中显示地调用基类的构造函数,编译器就调用默认构造函数。如果没有默认构造函数,编译器将报告出错。
对于在构造函数中调用虚函数的情况,被调用的只是这个函数的本地版本,也就是说,虚机制在构造函数中不工作。理由如下:
1)构造函数的工作是生成一个对象,在任何构造函数中,可能只是部分形成对象。我们只能知道基类已被初始化,但并不知道哪个类是从这个基类继承来的。然而,虚函数在继承层次上是“向前”和“向外”进行调用。它可以调用在派生类中的函数。如果我们在构造函数中也这样做,那么调用的函数可能操作还没有被初始化的成员,后果将是灾难性的。
2)当一个构造函数被调用时,它做的首要事情之一是初始化它的VPTR,然而,它只知道它属于“当前”类(构造函数所在的类)。当编译器为这个构造函数产生代码时,它是为这个类的构造函数产生代码。所以它使用的VPTR必须是对于这个类的VTABLE。而且,只要它是最后的构造函数调用,那么在这个对象的生命期内,VPTR将保持被初始化为这个VTABLE。但如果接着还有一个更晚派生的构造函数被调用,那么这个构造函数又将设置VPTR指向它的VTABLE,以此类推,直到最后的构造函数结束。VPTR的状态是由被最后调用的构造函数确定的。这就是为什么构造函数调用是按照从基类到最晚派生的类的顺序的另一个理由。

七、析构函数和虚拟析构函数
构造函数是不能为虚函数的,但析构函数能够且常常必须是虚的。析构函数按照与构造函数调用相反的顺序:析构函数自最晚派生的类开始,并向上到基类。这是安全且合理的:当前的析构函数一直知道基类成员仍是有效的。如果需要在析构函数中调用某一些类的成员函数,进行这样的操作是安全的。因此,析构函数能够对其自身进行清除,然后它调用下一个析构函数,该析构函数又将执行它的清除工作。以此类推,每个析构函数知道它所在类从哪一个类派生而来,但不知道从它派生出哪些类(构造函数和析构函数是类层次进行调用的唯一地方)。
注意,不把析构函数设为虚函数是一个隐匿的错误(它常常不会对程序有直接的影响),它不知不觉地引入存储器泄漏(关闭程序时内存未释放),同样,这样的析构操作还有可能掩盖发生的问题。故最好把析构函数设为虚的。
尽管纯虚析构函数在标准C++中是合法的,但在使用时有一个额外的限制:必须为纯虚析构函数提供一个函数体。
纯虚析构函数和非纯虚析构函数之间唯一的不同之处在于纯虚析构函数使得基类是抽象类,所以不能创建一个基类的对象(虽然如果基类的任何其他函数是纯虚函数,也是具有同样的效果)。当从某个含有纯虚析构函数的类中继承出一个类,不要求在派生类中提供纯虚函数的定义。
如果在一个普通的成员函数中,调用一个虚函数,则会使用晚捆绑机制来调用这个函数。而对于析构函数,不论是虚的还是非虚的,只有成员函数的“本地”版本被调用,虚机制被忽略。为什么是这样的呢?假设在析构函数中使用虚机制,那么调用下面这样的虚函数是可能的:这个函数是在继承层次中比当前的析构函数“更靠外的”(更晚派生)。析构函数从“外层”(从最晚派生的析构函数向基类析构函数)被调用,所以,实际上被调用的函数就可能操作在已被删除的对象上。因此,编译器决定在编译时只调用这个函数的“本地”版本。

八、virtual运算符
因为我们可能对两个不知道类型的对象进行操作,所以实现virtual运算符通常会很复杂,这通常用于处理数学部分。

没有更多推荐了,返回首页