C++ 程序设计 第6章 多态与虚函数

发布时间 2023-03-22 21:32:30作者: 快乐在角落里

第6章 多态与虚函数

1 多态的基本概念

运用封装继承多态能够有效提高程序的可读性,可扩充性,可重用性。多态从字面上理解就是多种形态或多种形式。具体到C++这种面向对象程序设计的语言中,可以理解成 一种接口,多种实现。实现了多态机制的程序,可以使用同一个名字完成不同的功能。

多态

使用函数重载,在编译阶段就能建立函数代码与函数调用之间的对应关系就是静态多态,静态多态在编译期间就可以确定函数的调用地址,并产生代码。在编译阶段就能绑定调用语句与调用函数入口地址。所以静态多态也称为静态联编或静态绑定。静态联编使得程序的可读性好,但在控制程序运行和对象行为多样性方面存在局限性。

继承机制使得基类与派生类之间存在继承关系,派生类不但可以继承基类中的方法,还可以重写基类中的方法。

当派生类和基类都有相同函数名和相同参数列表时,希望根据指针在运行时所指的具体情况来选择要调用的函数,即选择调用函数在哪个类中的版本。但实际上,编译器是根据指针的类型来决定调用的函数版本的。

使用多态,在编译阶段并不能提前知晓指针指向的基类对象还是派生类对象,从而也不能确定调用的函数版本。

函数调用与代码入口地址的绑定需要在运行时刻才能确定,这称为动态联编或动态绑定。

程序编译阶段都早于程序运行阶段,所以静态绑定称为早绑定,动态绑定称为晚绑定。静态多态和动态多态的区别,只在于在什么时候将函数实现和函数调用关联起来,是在编译阶段还是在运行阶段,即函数地址是早绑定的还是晚绑定的。

C++中的封装,继承多态等特性都有用武之地。封装可以使得代码模块化,继承可以让程序在原有的代码基础上进行扩展,它们的目的都是为了代码复用。而多态是为了接口复用。也就是说,不论传递过来的是哪个类的对象,函数都能够通过同一个接口调用到适应各自对象的实现方法。

在类之间满足赋值兼容的前提下,实现动态绑定必须满足以下两个条件

  1. 必须声明虚函数
  2. 通过基类类型的指针或引用调用基类或派生类中都有的同名虚函数时,如果基类指针指向的是基类对象,执行的就是基类的虚函数,如果基类指针指向的是派生类对象,执行的就是派生类的虚函数,这叫做多态。

所谓多态性是指不同的对象调用相同名称的函数。

不论是静态还是动态,多态性肯定是调用同名的函数。

虚函数

编译器看到哪个类的指针,就会认为要通过它访问哪个类的成员,编译器不会分析基类指针指向的到底是基类对象还是派生类对象。

仅使用继承机制,不能做到动态多态。动态多态是通过继承加上虚函数共同实现的。

所谓虚函数,就是在函数声明时前面加了 virtual 关键字的成员函数。 virtual 关键字只在类定义中的成员函数声明处使用,不能在类外部写成员函数体时使用。静态成员函数不能是虚函数。包含虚函数的类为多态类。

声明虚函数的一般格式

virtual 函数返回值类型 函数名(形参表);

在类的定义中使用 virtual 关键字来限定的成员函数即是虚函数。不能在类外成员函数定义时使用 virtual

派生类可以继承基类的同名函数,并且可以在派生类中重写这个函数。如果不使用虚函数,当使用派生类对象调用这个函数,且派生类中重写了这个函数时,则调用派生类中的同名函数,即隐藏了基类中的函数。

仅当在派生类中重写了基类中的虚函数的前提下,当使用基类的引用或指针调用这个虚函数时,因为这个引用既可以指向基类对象,也可以指向派生类对象,所以编译阶段并不能确定要调用哪个类中的函数版本,需要等到运行时根据引用或指针实际指向的对象来确定调用的版本。这正是虚函数能起的作用,也是与调用非虚函数的区别所在。由此体现C++的多态性。

当然,如果还想调用基类中的函数,只需在调用函数时,在前面加上基类名及作用域限定符 :: 即可

关于虚函数,有以下几点需要注意

  1. 虽然将虚函数生命为内敛函数不会引起错误,但因为内联函数是在编译阶段进行静态处理的,而对虚函数的调用是动态绑定的,所以虚函数一般不声明为内联函数。
  2. 派生类重写基类的虚函数实现多态,要求函数名,参数列表及返回值类型要完全相同。
  3. 基类中定义了虚函数,在派生类中该函数始终保持虚函数的特性
  4. 只有类的非静态成员函数才能定义为虚函数,静态成员函数和友元函数不能定义为虚函数。
  5. 如果虚函数的定义是在类体外,则只需在声明函数时添加 virtual 关键字定义时不加 virtual 关键字
  6. 构造函数不能定义为虚函数,最好也不要将 operator= 定义为虚函数,因为使用时容易混淆。
  7. 不要在构造函数和析构函数中调用虚函数。在构造函数和析构函数中,对象是不完整的,可能会出现未定义的行为。
  8. 最好将基类的析构函数声明为虚函数。

简述题

简略叙述那些函数不能是虚函数,并解释原因

常见的不能声明为虚函数的有全局函数,静态成员函数,内联成员函数,构造函数和友元函数。

  1. 全局函数不能声明为虚函数。虚函数是为了与继承机制配合实现多态的,而全局函数不属于某个类,没有继承关系,只能被重载,不能被覆盖,声明为虚函数也起不到多态的作用。因此编译器会在编译时绑定全局函数。
  2. 构造函数不能声明为虚函数。构造函数一般用来初始化对象,只有在一个对象生成之后,才能发挥多态作用。如果将构造函数声明为虚函数,则表现为在对象还没有生成的时候来定义它的多态,这两点是不统一的。另外,构造函数不能被继承,因此不能声明为虚函数
  3. 静态成员函数不能声明为虚函数。静态成员函数对于每个类来说只有一份代码,所有的对象都共享这份代码,他不归某个对象所有,所以也没有动态绑定的必要性。
  4. 内联成员函数不能声明为虚函数。定义内联函数的目的是为了在代码中直接展开,减少函数调用的开销。定义虚函数的目的是为了在继承后对象能够准确地执行自己的动作,这两个目的南辕北辙,不是可能统一的。内联函数在编译时被展开,虚函数在运行时才能动态地绑定函数。
  5. 友元函数不能声明为虚函数,友元函数不属于类的成员函数,不能被继承,没有声明为虚函数的必要。

在基类声明为 virtual 的成员函数是虚函数,在派生类中只要有相同的成员函数,即使不实用 virtual 说明,也都是虚函数。

调用基类虚函数

p->A::func();

因为有类明显类名限定符,这种情况下,指针p指向基类或派生类对象,都不影响调用的函数。

实际上,通过基类指针或基类引用调用虚函数时,都会产生动态多态。

通过基类指针实现多态

通过基类指针调用虚函数可以实现多态,通过基类的引用调用虚函数的语句也是多态。

通过基类的引用调用基类和派生类中同名,同参数表的虚函数时,若其引用的是一个基类的对象,则调用的是基类的虚函数,若其引用的是一个派生类的对象,则调用的是派生类的虚函数

多态的实现原理

派生类包含基类的成员变量,派生类对象占用的存储空间大小,等于基类存储空间的大小加上派生类自身成员变量占用的存储空间大小。

当类中定义了虚函数时,类对象占用的空间变大了,实际上,这是编译系统为类对象自动添加的部分,是一块连续的内存,其中存储的是虚函数表的地址。

在64位环境下,指针占8个字节,基类有一个int和虚函数时,大小是8+4=12,字节对齐就是16,而派生类包含基类的int,还有自身的int,再加上重写的虚函数,则大小是4+4+8=16。

每一个有虚函数的类都有一个虚函数表,虚函数表是编译器生成的,程序运行时被载入内存。一个类的虚函数表中列出了该类的全部虚函数地址。虚函数表是类中所有对象共享的,该类的任何对象中都保存指向该虚函数表的指针,这就是导致含有虚函数类的对象占用空间增大的原因。

包含虚函数的类都有虚函数表,同一个类的所有对象共享虚函数表,但各对象有自己的指向虚函数的指针,而且各不相同。

当遇到程序中的函数调用语句是,系统根据基类指针所指向的实际对象中保存的虚函数表的地址,找到对应的虚函数的入口地址并进行绑定,从而实现了多态。

只要是基类中定义了虚函数,并且通过基类指针或基类引用调用函数,则一定是多态的,哪怕派生类中并没有定义虚函数,也没有重写基类中的虚函数。

2 多态实例

设计一个使用多态处理几何图形的程序。程序要求用户输入图形的个数,以及每个图形必需的参数,然后计算图形的面积,并输出相关信息。

输入数据时,使用 R 或 r 表示矩形,然后输入矩形的宽度和高度,使用 C 或 c 表示圆形,然后输入圆形的半径,使用 T 或 t表示三角形,然后输入三角形的3条边的边长。

各种图形都有各自的特点,所以定义一个基类 CShape 表示一般图形,然后派生3个子类分别表示矩形类,圆形类,三角形类。基类中定义了计算图形面积和输出信息的虚函数。

在程序中,定义了一个含 100 个元素的基类指针数组,用来保存输入的各个图形。这个数组是全局变量,可以在主函数中直接访问。

在主函数中,分别定义了3个派生类的指针。每当从键盘键入一个图形的信息,就创建对应的一个对象,并将指向对象的指针保存到全局数组中。因为数组元素是基类指针,所以这相当于使用基类指针指向派生类对象。

如果不使用多态机制,需要为每类图形分配一个数组,用来保存该类图形数据。使用多态后,由于可以使用基类指针指向派生类对象,所以定义全局变量,基类指针数组。每输入一个几何图形,就动态创建一个对应的图形变量,并将对象指针保存到数组中。同时,由于多态的使用,也可以使用相同的代码,分别处理不同的图形,并输出各自的结果。

3 多态的使用

实际上,因为类的成员函数之外是可以互相调用的,所以在普通成员函数(静态成员函数,构造函数和析构函数除外),中调用其他虚成员函数是允许的,并且是多态的。

基类对象调用成员函数,如果实际是派生类对象,成员函数里调用虚函数会调用派生类的虚函数,而当实际对象是基类时,则调用的是基类的虚函数。因为实际基类对象的虚函数表指向的是基类的虚函数,所以不会调用派生类的虚函数。

可以在构造函数和析构函数中调用虚函数,但这样调用的虚函数不是多态的。

在构造函数和析构函数中调用虚函数时不是多态的,因为编译时即可确定可调用的是哪个函数。如果本类中定义了该函数,则调用的就是本类中的函数,如果本类中没有定义相应的函数,则调用的就是直接基类中的函数。如果直接基类中还没有,则调用间接基类中定义的函数,以此类推。

执行基类构造函数时,而派生类对象尚未创建好,此时调用虚函数,会出现不安全的因素。

执行基类析构函数时,派生类对象已经消亡,此时再调用虚函数,同样是不安全的。

实现多态时,必须满足的条件是,使用基类指针或引用来调用基类中声明的虚函数。

派生类中继承自基类的虚函数,可以写 virtual 关键字,也可以省略这个关键字,这不影响派生类中的函数也是虚函数

4 虚析构函数

程序中可以让一个基类指针指向用 new 运算符动态生成的派生类对象。用 new 运算符动态生成的对象都需要通过 delete 来释放所占用的空间。如果一个基类指针指向的对象是用 new 运算符动态生成的派生类对象,那么释放该对象所占用的空间时,如果仅调用基类的析构函数,则只会完成该析构函数内的空间释放,不会涉及派生类析构函数内的空间释放,容易造成内存泄漏

为此,C++允许声明虚析构函数。格式如下

virtual ~类名();

如果一个类的析构函数是虚函数,则由它派生的所有子类的析构函数也是虚析构函数。使用虚析构函数的目的是为了在对象消亡时实现多态。具体来说,设置了虚析构函数后,使用指针或引用时可以动态绑定,实现运行时的多态,保证使用基类类型的指针能够调用适当的析构函数针对不同的对象进行清理工作。

只要基类的析构函数是虚函数,那么派生类的析构函数不论是否用 virtual 关键字声明,都自动成为虚析构函数

一般来说,一个类如果定义了虚函数,则最好将析构函数也定义成虚函数,不过,构造函数不能是虚函数。

5 纯虚函数和抽象类

纯虚函数

在有些情况下,基类中的某个虚函数给不出一个确切的定义,或者没必要给出详细的定义,那么可以将它声明为一个纯虚函数。在这种情况下,纯虚函数的作用相当于一个统一的接口形式,表明在基类的各派生类中应该有这样的一个操作,然后在各派生类中具体实现与本派生类相关的操作。

纯虚函数是声明在基类中的虚函数,没有具体的定义,而由各派生类根据实际需要给出各自的定义。纯虚函数只有函数的名字但不具备函数的功能,不能调用基类中的这个函数。

声明纯虚函数的一般格式如下:

要素是:使用 virtual 关键字修饰,函数定义中要有 =0 ,且没有函数体,大括号也没有。

virtual 函数类型 函数名(参数表) = 0;

纯虚函数没有函数体,参数表后要写 = 0 。派生类中必须重写这个函数。按照纯虚函数名调用时,执行的是派生类中重写的语句,即调用的是派生类中的版本。

抽象类

包含纯虚函数的类称为抽象类。因为抽象类中有尚未完成的函数定义,所以它不能实例化一个对象。抽象类的派生类中,如果没有给出全部纯虚函数的定义,则派生类继续是抽象类。直到派生类中给出全部纯虚函数定义后,他才不再是抽象类,也才能实例化一个对象。

虽然不能创建抽象类的对象,但可以定义抽象类的指针和引用。这样的指针和引用可以指向并访问派生类的成员,这种访问具有多态性。

纯虚函数与函数体为空的虚函数之间的异同。

纯虚函数没有函数体,而空的虚函数的函数体为空

纯虚函数所在的类是抽象类,不能直接进行实例化。而空的虚函数所在的类是可以实例化的。

他们共同的特点是,纯虚函数与函数体为空的虚函数都可以派生出新的类,然后在新类中给出虚函数的实现,而且这种新的实现具有多态特征。

虚基类

多重继承允许从多个基类共同派生一个派生类。而多重继承有天生的二义性,有些情况下可以在访问成员的前面添加类名及作用域符 :: 给出明确的指示,排除二义性。但有些情况下,即使添加类名及作用域符 :: 也没办法排除二义性。

image

若共同派生一个派生类的多个基类又有一个共同的基类,则底层的派生类会有从不同方式下得到的同一个间接基类。如上图,当在类 D 中访问类 A 的成员变量和成员函数时,前面冠以类名 A 仍不足以区分是哪个版本,从而引发二义性。

为了避免产生二义性,C++提供虚基类机制,使得在派生类中,继承同一个间接基类的成员仅保留一个版本。

定义虚基类一般格式如下

class 派生类名:virtual 派生方式 基类名{
  派生类体
};

上图的各类的继承关系如下:

class A
class B:virtual public A
class C:virtual public A
class D:public B,public C

image

不是虚基类访问共同基类会报错,如下采用虚基类则不会报错

image