C++中的多态

虚函数

虚函数的定义:在一个成员函数的前面加上 virtual 关键字,该函数就成为虚函数。

用基类指针指向派生类对象后,通过这个基类对象竟然可以调用派生类的成员函数。而且,基类和派生类对象所占空间的大小都改变了,说明其内存结构发生了变化。

多态01.png

实现原理

虚函数指针

有虚函数的类会有一张虚函数表(本质是一个数组),那么此类(基类)及其派生类的对象会有一个虚函数指针,这个指针指向自己归属的类拥有的那张虚函数表(简称虚表),其中存放的是虚函数的入口地址。

至于派生类会有多少张虚表,可以提前预告,即当下我们讨论的是单继承,那派生类肯定就是只有一张虚表,但到后面讲到派生类继承多个含有虚函数的基类的时候,派生类可就不止一张虚表了。

Derived 继承了 Base 类,那么创建一个 Derived 对象,依然会创建出一个 Base 类的基类子对象。

如果说 Derived(派生类)没有自己定义 display (重名的函数,且基类中为虚函数),那么 Derived 对象的虚函数指针指向的虚函数表(这张虚函数表和基类完全一致,但是属于派生类自己的),虚表中指向的就是基类的 display,就是 Derived 从基类那里继承来的。

多态02.png

如果在 Derived 类中定义了 display 函数,就会发生覆盖机制(override),覆盖的是虚函数表中虚函数的入口地址。也就是用自己的 display 函数覆盖掉之前从 基类那里继承来的 display 函数。

多态03.png

Base* p 去指向 Derived 对象,用指针 p 去调用 display 函数,发现是一个虚函数,那么会通过 vfptr 找到虚表,此时虚表中存放的是 Derived::display 的入口地址,所以调用到 Derived 的 display 函数。

至此,我们可以明确以下观点:单继承情况下

  1. 含有虚函数的类会有一张虚函数表,此类的实例化对象共享这张虚函数表,这些实例化对象会有一个虚函数指针,实例化对象就是通过虚函数指针找到这张虚函数表。
  2. 派生类继承基类(含有虚函数)之后,不管有没有重写虚函数,派生类都会拥有自己的一张虚函数表。
  3. 如果派生类不重写基类的虚函数,那么自己的虚函数表就和基类的虚函数表别无二致。如果派生类重写基类的虚函数,那么重写的部分就会覆盖基类的虚函数(覆盖的是虚函数表中的入口地址,并不是覆盖函数本身),没有重写的依旧和基类的别无二致。
  4. 类(含有虚函数)的实例化对象的虚函数指针永远是指向自己的类的虚表。

虚函数的覆盖

如果一个基类的成员函数定义为虚函数,那么它在所有派生类中也保持为虚函数,即使在派生类中省略了 virtual 关键字,也仍然是虚函数。虚函数一般用于灵活拓展,所以需要派生类中对此虚函数进行覆盖。覆盖的格式有一定的要求:

  • 与基类的虚函数有相同的函数名
  • 与基类的虚函数有相同的参数个数
  • 与基类的虚函数有相同的参数类型
  • 与基类的虚函数有相同的返回类型

总结一句话,要和基类的虚函数完全一致(但不需要加 virtual 关键字,前面刚刚讲编译器会自动处理这件事情)。

正因为这个强要求,为了避免程序员犯错,C++提供一个关键字 override,能辅助我们。

在虚函数的函数参数列表之后,函数体的大括号之前,加上 override 关键字,告诉编译器此处定义的函数是要对基类的虚函数进行覆盖。

1
2
3
4
5
class Derived : public Base
{
public:
void display() override
};

这个时候你在派生类的源文件中去定义,如果和基类的虚函数不匹配就会报错,这就是 C++ 提供给我们的检测手段。

重载、隐藏、覆盖的区分

我们要和重载进行区分。重载发生在一个类中,覆盖发生在继承关系且还有 virtual 关键字的类中。重载是同名函数且参数列表不同,覆盖是同名函数+参数列表+返回类型相同

那么隐藏呢?隐藏是发生在继承关系的类中,只要函数名相同就会发生隐藏行为,把基类的同名函数隐藏,进而调用自己的。

动态多态(虚函数机制)被激活的条件

虚函数机制是如何被激活的呢,或者说动态多态是怎么表现出来的呢?其实激活条件还是比较严格的,需要满足以下全部要求:

  1. 基类定义虚函数(这是最基本的)
  2. 派生类中要覆盖虚函数 (覆盖的是虚函数表中的地址信息,不然你只是全部调用基类中继承过来的虚函数,而没有覆盖行为,那不就只是调用基类的虚函数吗?没有同一个接口调用而呈现不同的行为的现象,谈何动态多态?)
  3. 创建派生类对象(没有派生类,我还玩个鸡毛)
  4. 基类的指针指向派生类对象(或基类引用绑定派生类对象)
  5. 通过基类指针(引用)调用虚函数(如果你是通过派生类指针,那你必然是调用自己的成员函数,没有动态多态可言)

最终的效果:基类指针调用到了派生类实现的虚函数。(如果没有虚函数机制,基类指针只能调用到基类的成员函数)

1
2
3
4
5
6
7
8
Base* basePtr = new Derived();          /* 基类指针指向派生类对象 */
basePtr->display(); /* 调用的是派生类的 display() */

Base* basePtr = new Base(); /* 基类指针指向基类对象 */
basePtr->display(); /* 调用基类的方法 */

Derived* derivedPtr = new Derived(); /* 派生类指针指向派生类对象 */
derivedPtr->display(); /* 调用派生类的方法 */

虚函数表

在虚函数机制中 virtual 关键字的含义

1、虚函数是存在

2、通过间接的方式去访问

3、通过基类的指针访问到派生类的函数,基类的指针共享了派生类的方法

如果没有虚函数,当通过 pbase 指针去调用一个普通的成员函数,那么就不会通过虚函数指针和虚表,直接到程序代码区中找到该函数。

有了虚函数,去找这个虚函数的方式就成了间接的方式。

常见问题

1、虚表存放在哪里?

编译完成时,虚表应该已经存在;在使用的过程中,虚函数表不应该被修改掉(如果能修改,将会找不到对应的虚函数)——应该存在只读段——具体位置不同厂家有不同实现

2、一个类中虚函数表有几张?

虚函数表(虚表)可以理解为是一个数组,存放的是一个个虚函数的地址

  • 一个类可以没有虚函数表(没有虚函数就没有虚函数表)

  • 可以有一张虚函数表(即使这个类有多个虚函数,将这些虚函数的地址都存在虚函数表中

  • 也可以有多张虚函数表(继承多个 有虚函数的 基类,后面我们会讲到的虚函数的多继承)

3、虚函数机制的底层实现是怎样的?

虚函数机制的底层是通过虚函数表实现的。当类中定义了虚函数之后,就会在对象的存储开始位置多一个虚函数指针,该虚函数指针指向一张虚函数表,虚函数表中存储的是虚函数入口地址。

虚函数的限制

虚函数机制给C++提供了灵活的用法,但仍然受到了一些约束,以下几种函数不能设为虚函数:

1.构造函数不能设为虚函数

构造函数的作用是创建对象时完成数据的初始化,而虚函数机制被激活的条件之一就是要先创建对象,有了对象才能表现出动态多态。如果将构造函数设为虚函数,那此时构造未执行完,对象还没完成初始化,存在矛盾。

2.静态成员函数不能设为虚函数

虚函数的实际调用:this -> vfptr -> vtable -> virtual function,但是静态成员函数没有 this 指针,所以无法访问到 vfptr。vfptr 是属于一个特定对象的部分,虚函数机制起作用必然需要通过 vfptr 去间接调用虚函数。静态成员函数找不到这样特定的对象。

3.Inline 函数不能设为虚函数

因为 inline 函数在编译期间完成替换,而在编译期间无法展现动态多态机制,所以起作用的时机是冲突的。如果同时存在,inline 失效。

4.普通函数不能设为虚函数

虚函数要解决的是对象多态的问题,与普通函数无关。

虚函数各种访问情况

抽象类

纯虚函数

纯虚函数是一种特殊的虚函数,在许多情况下,在基类中不能对虚函数给出有意义的实现,而把它声明为纯虚函数,它的实现留给该基类的派生类去做。这就是纯虚函数的作用。纯虚函数的格式如下:

1
2
3
4
class 类名 {
public:
virtual 返回类型 函数名(参数 ...) = 0;
};

在基类中声明纯虚函数就是在告诉派生类的设计者 —— 你必须提供一个纯虚函数的实现,但我不知道你会怎样实现它。多个派生类可以对纯虚函数进行多种不同的实现,但是都需要遵循基类给出的接口(纯虚函数的声明)。

声明了纯虚函数的类成为抽象类,抽象类不能实例化对象。

基类(声明纯虚函数的类)虽然无法创建对象,但是可以定义此类型的指针,指向派生类对象,去调用派生类实现好的纯虚函数。这种使用方式也归类为动态多态,尽管不符合第一个条件(基类中声明纯虚函数,而非定义),最终的效果仍然是基类指针调用到了派生类实现的虚函数,属于动态多态的特殊情况。

只定义了protected型构造函数的类

如果一个类只定义了 protected 型的构造函数而没有提供 public 构造函数,无论是在外部还是在派生类中作为其对象成员都不能创建该类的对象,但可以由其派生出新的类,这种能派生新类,却不能创建自己对象的类是另一种形式的抽象类。

Base 类只定义了 protected 属性的构造函数,不能创建 Base 类的对象,但是可以定义 Base 类的指针—— Base 类是抽象类。

如果 Derived 类也只定义了 protected 属性的构造函数,Derived 类也是抽象类,无法创建对象,但是可以定义指针指向派生类对象。那么还需要再往下派生,一直到某一层提供了 public 的构造函数,才能创建对象。

析构函数定义为虚函数

如果不把基类的析构函数设置为虚函数,那么派生类的析构函数将不会得到自动调用(基类指针指向派生类对象)。尤其是类内有申请的动态内存,需要清理和释放的时候,这点务必做好。

1
2
3
4
5
6
void test(){
Base * pbase = new Derived();
pbase->display();

delete pbase;
}

当基类的析构函数为非虚函数时,删除一个基类指针指向的派生类实例时,只清理了派生类从基类继承过来的资源,而派生类自己独有的资源却没有被清理,这显然不是我们希望的。

把基类的析构函数为虚函数时,在派生类析构函数执行完毕后,会自动调用基类析构函数(毕竟我们调用派生类构造的时候,先完成基类的构造,再完成派生类的构造,但要注意我们是先调用的派生类的构造哦)。这是由编译器在析构函数调用序列中隐式安排的,这个过程不依赖于虚函数表,属于C++的语言规则。

带虚函数的多继承

内存布局

先是 Base1、Base2、Base3 都拥有虚函数 f、g、h,Derived 公有继承以上三个类,在 Derived 中覆盖了虚函数 f,还有一个普通的成员函数 g1,四个类各有一个 double 成员。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Derived 
: public Base1
, public Base2
, public Base3
{
public:Derived()
: _iDerived(10000)
{ cout << "Derived()" << endl; }

void f() { cout << "Derived::f()" << endl; }

void g1() { cout << "Derived::g1()" << endl; }
private:
double _iDerived;
};

通过 VS 平台展示类对象内存布局的功能,我们可以总结出以下规则:

  1. 每个基类都有自己的虚函数表(前提是基类定义了虚函数),继承这三个基类的派生类会拥有三张虚表。(这很容易理解,不同的基类(基类定义了虚函数)有独属于自己的虚函数,不管名字是否相同,派生类在继承这些基类的时候,肯定会各自拷贝(继承)一份过来,等到派生类对象被不同的基类指针指向的时候,调用相同的函数(我们暂且假设这个时候派生类还没有去覆盖),会因为基类指针的不同而去调用对应的基类指针的虚函数)
1
2
3
4
Derived d;
Base1* pBase1 = &d;
Base2* pBase2 = &d;
Base3* pBase3 = &d;
  1. 派生类如果有自己的虚函数(即已经覆盖基类的虚函数),会被加入到第一个虚函数表之中。(希望尽快访问到虚函数)
  2. 内存布局中,其基类的布局按照基类被声明时的顺序进行排列,但是有虚函数的基类会往上放,希望尽快访问到虚函数,没有虚函数的基类往下放。(如果继承顺序为 Base1/Base2/Base3,在 Derived 对象的内存布局中就会先是 Base1 类的基类子对象,然后是 Base2、Base3 基类子对象。此时,如果 Base1 中没有定义虚函数,那么内存排布上会将 Base1 基类子对象排在 Base2、Base3 基类子对象之后)
  3. 派生类会覆盖基类的虚函数,只有第一个虚函数表中存放的是真实的被覆盖的函数的地址;其它的虚函数表中对应位置存放的并不是真实的对应的虚函数的地址,而是一条跳转指令 —— 指示到哪里去寻找被覆盖的虚函数的地址。(我们前面不是讲,派生类会有三张虚表嘛,那么如果这三个基类带有相同的虚函数,派生类在覆盖的时候,实际只会覆盖第一张虚表的那个虚函数的地址,其他两张虚表的这个虚函数并不是真实的对应的虚函数地址(没有被覆盖),而是一条跳转指令,跳转到派生类的第一张虚表的那个被派生类覆盖的虚函数地址)
虚函数多继承.png

带虚函数的多重继承的二义性

假设我们有两个基类 AB,它们都定义了同名的虚函数 foo(),并且 C 类继承了这两个类。如果我们在 C 类中没有明确指定要调用哪个父类的 foo(),就会产生二义性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
#include <iostream>
using namespace std;

class A {
public:
virtual void foo() {
cout << "A's foo" << endl;
}
};

class B {
public:
virtual void foo() {
cout << "B's foo" << endl;
}
};

class C : public A, public B {
public:
// 这里如果没有重写foo(),会导致二义性
};

int main() {
C c;
// c.foo(); // 会产生编译错误:二义性
return 0;
}

在上面的代码中,C 继承了 AB,而这两个类都有一个虚函数 foo()。当你尝试调用 c.foo() 时,编译器无法判断应该调用 Afoo() 还是 Bfoo(),从而导致二义性。

我们来看看具体的示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class A {
public:
virtual void a() { cout << "A::a()" << endl; }
virtual void b() { cout << "A::b()" << endl; }
virtual void c() { cout << "A::c()" << endl; }
};

class B {
public:
virtual void a() { cout << "B::a()" << endl; }
virtual void b() { cout << "B::b()" << endl; }
void c() { cout << "B::c()" << endl; }
void d() { cout << "B::d()" << endl; }
};

class C
: public A, public B {
public:
virtual void a() { cout << "C::a()" << endl; }
void c() { cout << "C::c()" << endl; } // 覆
void d() { cout << "C::d()" << endl; }
};

内存结构示意图:

多态04.png

调用情况

对象

多态05.png

基类指针指向派生类对象

多态06.png

基类指针指向派生类对象

多态07.png

派生类指针指向派生类对象

多态08.png

总结

  • 如果通过对象来调用虚函数,那么不会通过虚表来找虚函数,因为编译器从一开始就确定调用函数的对象是什么类型,直接到程序代码区中找到对应函数的实现。
  • 如果基类指针指向派生类对象,通过基类指针调用虚函数,若派生类中对这个虚函数进行了覆盖(重写-override),那么符合动态多态的触发机制,最终的效果是基类指针调用到了派生类定义的虚函数;如果派生类对这个虚函数没有进行覆盖,也会通过虚表访问,访问到的是基类自己定义的虚函数的入口地址。(但是要切记,不可以用另一个基类的指针企图去访问另一个基类的成员函数)
  • 如果是派生类指针指向本类对象,调用虚函数时,也会通过虚表去访问虚函数。若本类中对基类的虚函数进行覆盖,那么调用到的就是本类的虚函数实现,如果没有覆盖,那么会调用到基类实现的虚函数。

通过派生类对象或者派生类指针指向本类对象,如果调用的函数没有被覆盖且两个基类拥有相同的成员函数(返回类型+函数名+参数列表),就会出现二义性冲突。但是这两种的区别是派生类对象不走虚表,派生类指针指向本类对象走虚表(尽管如此,却算不上是运行时多态行为)。

而对于不同的基类指针指向派生类的情况,切记不可以用另一个基类的指针企图去访问另一个基类的成员函数。

虚继承

虚函数 与 虚继承

虚函数指针指向虚函数表。(只要成员函数有 virtual 关键字)

虚基类指针指向虚基类表。(只要继承的时候有 virtual 关键字)

注意:虚基类的说法,如果 B 类虚拟继承了 A 类,那么说 A 类是 B 类虚基类,而不要说 A 类是虚基类,因为 A 类还可以以非虚拟的方式派生其他类。

(1)虚继承的内存结构--单继承

基类和派生类没有虚函数,自然没有虚函数表的存在。但是有虚继承(单继承),那么就会有一张虚基类表。

因此,派生类实例对象 B 中有一个虚基类指针。

虚拟继承1.png

(2)如果虚基类中包含了虚函数--单继承

基类有虚函数,派生类没有写明有虚函数,派生类将会有一张虚函数表。有虚继承(单继承),那么还会有一张虚基类表。

因此,派生类实例对象 B 有一个虚函数指针和一个虚基类指针。

虚拟继承2.png

(3)如果派生类中又定义了新的虚函数,会在内存中多出一个属于派生类的虚函数指针,指向一张新的虚表(VS 的实现)--单继承

基类有虚函数,派生也有虚函数,派生类将会有两张虚函数表。有虚继承,那么还会有一张虚基类表。

因此,派生类实例对象 B 有两个虚函数指针和一个虚基类指针。

虚拟继承3.png

(4)带虚函数的菱形继承——虚拟继承方式--多继承

虚拟继承4.png

真实情况:

虚拟继承5.png

虚拟继承时派生类对象的构造和析构

在虚拟继承的结构中,最底层的派生类不仅需要显式调用中间层基类的构造函数,还要在初始化列表最开始调用顶层基类的构造函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class D
: public B
, public C
{
public:
D(double a,double b,double c,double d)
: A(a) // 顶层基类
, B(a,b) // 中间基类
, C(a,c) // 中间基类
, _d(d)
{
cout << "D(double * 4)" << endl;
}

~D(){ cout << "~D()" << endl; }
private:
double _d;
};

——那么 A 类构造岂不是会调用 3 次?

并不会,有了 A 类的构造之后会压抑 B、C 构造时调用 A 类构造,A 类构造只会调用一次。可以对照菱形继承的内存模型理解,D 类对象中只有一份 A 类对象的内容。

对于析构函数,同样存在这样的压抑效果,D 类析构执行完后,根据继承声明顺序的反序调用 C 类的析构函数,C 的析构函数执行完后并没有自动调用 A 的析构函数,而是接下来调用 B 的析构函数,最后调用 A 的析构函数。

效率分析

多重继承和虚拟继承对象模型较单一继承复杂的对象模型,造成了成员访问低效率,表现在两个方面:对象构造时 vptr 的多次设定,以及 this 指针的调整。

性能分析.png