C++ Section2 面向对象(2) 多态与虚函数

多态性

多态指当不同的对象收到相同的消息时,产生不同的动作

  • 编译时多态(静态绑定),函数重载,运算符重载,模板。
  • 运行时多态(动态绑定),虚函数机制。

运行时多态(动态绑定)

  • 定义:“一个接口,多种方法”,程序在运行时才决定调用的函数。
  • 实现:C++多态性主要是通过虚函数实现的,虚函数允许子类重写override(注意和overload的区别,overload是重载,是允许同名函数的表现,这些函数参数列表/类型不同)。
  • 目的:接口重用。封装可以使得代码模块化,继承可以扩展已存在的代码,他们的目的都是为了代码重用。而多态的目的则是为了接口重用。
  • 用法:声明基类的指针,利用该指针指向任意一个子类对象,调用相应的虚函数,可以根据指向的子类的不同而实现不同的方法。

重载、覆盖、重写的区别

  • Overload(重载):

    在C++程序中,可以将语义、功能相似的几个函数用同一个名字表示,但参数或返回值不同(包括类型、顺序不同),即函数重载。

    (1)相同的范围(在同一个类中);
    (2)函数名字相同;
    (3)参数不同;
    (4)virtual 关键字可有可无。

  • Override(覆盖):

    是指派生类函数覆盖基类函数,特征是:

    (1)不同的范围(分别位于派生类与基类);
    (2)函数名字相同;
    (3)参数相同;
    (4)基类函数必须有virtual 关键字。

  • Overwrite(重写):

    即隐藏,是指派生类的函数屏蔽了与其同名的基类函数,规则如下:

    (1)如果派生类的函数与基类的函数同名,但是参数不同。此时,不论有无virtual关键字,基类的函数将被隐藏(注意别与重载混淆)。
    (2)如果派生类的函数与基类的函数同名,并且参数也相同,但是基类函数没有virtual关键字。此时,基类的函数被重写(隐藏)(注意别与覆盖混淆)。

注:重写基类虚函数的时候,会自动转换这个函数为virtual函数,不管有没有加virtual,因此重写的时候不加virtual也是可以的,不过为了易读性,还是加上比较好。

虚函数与虚继承

转自:虚函数与虚继承寻踪

基本对象模型

首先,我们定义一个简单的类,它含有一个数据成员和一个虚函数。

1
2
3
4
5
6
class MyClass {
int var;
public:
virtual void fun()
{}
};

编译出的MyClass对象结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
1> class MyClass size(8):
1> +---
1> 0 | {vfptr}
1> 4 | var
1> +---
1>
1> MyClass::$vftable@:
1> | &MyClass_meta
1> | 0
1> 0 | &MyClass::fun
1>
1> MyClass::fun this adjustor: 0

从这段信息中我们看出,MyClass对象大小是8个字节。前四个字节存储的是虚函数表的指针vfptr,后四个字节存储对象成员var的值。虚函数表的大小为4字节,就一条函数地址,即虚函数fun的地址,它在虚函数表vftable的偏移是0。因此,MyClass对象模型的结果如图1所示。

图1 Myclass对象模型
图1 MyClass 对象模型

MyClass的虚函数表虽然只有一条函数记录,但是它的结尾处是由4字节的0作为结束标记的。
adjust表示虚函数机制执行时,this指针的调整量,假如fun被多态调用的话,那么它的形式如下:

*(this+0)[0]()

总结虚函数调用形式,应该是:

*(this指针+调整量)[虚函数在vftable内的偏移]()

单重继承对象模型

我们定义一个继承于MyClass类的子类MyClassA,它重写了fun函数,并且提供了一个新的虚函数funA

1
2
3
4
5
6
7
8
class MyClassA : public MyClass {
int varA;
public:
virtual void fun()
{}
virtual void funA()
{}
};

它的对象模型为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
1> class MyClassA size(12):
1> +---
1> | +--- (base class MyClass)
1> 0 | | {vfptr}
1> 4 | | var
1> | +---
1> 8 | varA
1> +---
1>
1> MyClassA::$vftable@:
1> | &MyClassA_meta
1> | 0
1> 0 | &MyClassA::fun
1> 1 | &MyClassA::funA
1>
1> MyClassA::fun this adjustor: 0
1> MyClassA::funA this adjustor: 0

可以看出,MyClassA将基类MyClass完全包含在自己内部,包括vfptrvar。并且虚函数表内的记录多了一条——MyClassA自己定义的虚函数funA。它的对象模型如图2所示。

图2 MyclassA对象模型
图2 MyClassA 对象模型

我们可以得出结论:

  • 在单继承形式下,子类完全获得父类的虚函数表和数据。
  • 子类如果重写了父类的虚函数(如fun),就会把虚函数表原本fun对应的记录(内容MyClass::fun)覆盖为新的函数地址(内容MyClassA::fun),否则继续保持原本的函数地址记录。
  • 如果子类定义了新的虚函数,虚函数表内会追加一条记录,记录该函数的地址(如MyClassA::funA)。

另外类的非虚成员排列顺序是由基类到派生类,先varvarA

使用这种方式,就可以实现多态的特性。假设我们使用如下语句:

1
2
MyClass*pc = new MyClassA;
pc->fun();

编译器在处理第二条语句时,发现这是一个多态的调用,那么就会按照上边我们对虚函数的多态访问机制调用函数fun

*(pc+0)[0]()

因为虚函数表内的函数地址已经被子类重写的fun函数地址覆盖了,因此该处调用的函数正是MyClassA::fun,而不是基类的MyClass::fun

如果使用MyClassA对象直接访问fun,则不会出发多态机制,因为这个函数调用在编译时期是可以确定的,编译器只需要直接调用MyClassA::fun即可。

多重继承对象模型

和前边MyClassA类似,我们也定义一个类MyClassB

1
2
3
4
5
6
7
8
class MyClassB : public MyClass {
int varB;
public:
virtual void fun()
{}
virtual void funB()
{}
};

它的对象模型和MyClassA完全类似,这里就不再赘述了。

为了实现多重继承,我们再定义一个类MyClassC

1
2
3
4
5
6
7
8
class MyClassC : public MyClassA, public MyClassB {
int varC;
public:
virtual void funB()
{}
virtual void funC()
{}
};

为了简化,我们让MyClassC只重写父类MyClassB的虚函数funB,它的对象模型如下:

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
28
29
30
31
32
33
1> class MyClassC size(28):
1> +---
1> | +--- (base class MyClassA)
1> | | +--- (base class MyClass)
1> 0 | | | {vfptr}
1> 4 | | | var
1> | | +---
1> 8 | | varA
1> | +---
1> | +--- (base class MyClassB)
1> | | +--- (base class MyClass)
1> 12 | | | {vfptr}
1> 16 | | | var
1> | | +---
1> 20 | | varB
1> | +---
1> 24 | varC
1> +---
1>
1> MyClassC::$vftable@MyClassA@:
1> | &MyClassC_meta
1> | 0
1> 0 | &MyClassA::fun
1> 1 | &MyClassA::funA
1> 2 | &MyClassC::funC
1>
1> MyClassC::$vftable@MyClassB@:
1> | -12
1> 0 | &MyClassB::fun
1> 1 | &MyClassC::funB
1>
1> MyClassC::funB this adjustor: 12
1> MyClassC::funC this adjustor: 0

和单重继承类似,多重继承时MyClassC会把所有的父类全部按序包含在自身内部。而且每一个父类都对应一个单独的虚函数表。MyClassC的对象模型如图3所示。

图3 MyclassC对象模型
图3 MyClassC 对象模型

多重继承下,子类不再具有自身的虚函数表,它的虚函数表与第一个父类的虚函数表合并了。

同样的,如果子类重写了任意父类的虚函数,都会覆盖对应的函数地址记录。如果MyClassC重写了fun函数(两个父类都有该函数),那么两个虚函数表的记录都需要被覆盖!

在这里我们发现MyClassC::funB的函数对应的adjust值是12,按照我们前边的规则,可以发现该函数的多态调用形式为:

*(this+12)[1]()

此处的调整量12正好是MyClassBvfptrMyClassC对象内的偏移量

虚拟继承对象模型

虚拟继承是为了解决多重继承下公共基类的多份拷贝问题。比如上边的例子中MyClassC的对象内包含MyClassAMyClassB子对象,但是MyClassAMyClassB内含有共同的基类MyClass。为了消除MyClass子对象的多份存在,我们需要让MyClassAMyClassB都虚拟继承于MyClass,然后再让MyClassC多重继承于这两个父类。相对于上边的例子,类内的设计不做任何改动,先修改MyClassA和MyClassB的继承方式:

1
2
3
class MyClassA : virtual public MyClass
class MyClassB : virtual public MyClass
class MyClassC : public MyClassA, public MyClassB

由于虚继承的本身语义,MyClassC内必须重写fun函数,否则由于同时存在MyClassA::funMyClassB::fun,会有二义性。因此我们需要再重写fun函数。这种情况下,MyClassC的对象模型如下:

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
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
1> class MyClassC size(36):
1> +---
1> | +--- (base class MyClassA)
1> 0 | | {vfptr}
1> 4 | | {vbptr}
1> 8 | | varA
1> | +---
1> | +--- (base class MyClassB)
1> 12 | | {vfptr}
1> 16 | | {vbptr}
1> 20 | | varB
1> | +---
1> 24 | varC
1> +---
1> +--- (virtual base MyClass)
1> 28 | {vfptr}
1> 32 | var
1> +---
1>
1> MyClassC::$vftable@MyClassA@:
1> | &MyClassC_meta
1> | 0
1> 0 | &MyClassA::funA
1> 1 | &MyClassC::funC
1>
1> MyClassC::$vftable@MyClassB@:
1> | -12
1> 0 | &MyClassC::funB
1>
1> MyClassC::$vbtable@MyClassA@:
1> 0 | -4
1> 1 | 24 (MyClassCd(MyClassA+4)MyClass)
1>
1> MyClassC::$vbtable@MyClassB@:
1> 0 | -4
1> 1 | 12 (MyClassCd(MyClassB+4)MyClass)
1>
1> MyClassC::$vftable@MyClass@:
1> | -28
1> 0 | &MyClassC::fun
1>
1> MyClassC::fun this adjustor: 28
1> MyClassC::funB this adjustor: 12
1> MyClassC::funC this adjustor: 0
1>
1> vbi: class offset o.vbptr o.vbte fVtorDisp
1> MyClass 28 4 4 0

虚继承的引入把对象的模型变得十分复杂,除了每个基类(MyClassAMyClassB)和公共基类(MyClass)的虚函数表指针需要记录外,每个虚拟继承了MyClass的父类还需要记录一个虚基类表vbtable的指针vbptrMyClassC的对象模型如图4所示。

图4 MyclassC虚继承对象模型
图4 MyClassC 虚继承对象模型]

虚基类表的第一项记录着当前子对象(当前虚表指针,vfptr_A或者vfptr_B)相对与当前虚基类表指针(vbptr_A或者vbptr_B)的偏移。

MyClassAMyClassB子对象内的虚表指针都是存储在相对于自身的4字节偏移处,因此该值是-4。假定MyClassAMyClassC或者MyClassB内没有定义新的虚函数,即不会产生虚函数表,那么虚基类表第一项字段的值应该是0。

虚基类表的第二项记录着公共基类虚表指针vfptr相对于当前虚基类表指针(vbptr_A或者vbptr_B)的偏移量。

比如MyClassA的虚基类表第二项记录值为24,正是MyClass::vfptr相对于MyClassA::vbptr的偏移量,同理MyClassB的虚基类表第二项记录值12也正是MyClass::vfptr相对于MyClassA::vbptr的偏移量。

通过以上的对象组织形式,编译器解决了公共虚基类的多份拷贝的问题。通过每个父类的虚基类表指针,都能找到被公共使用的虚基类的子对象的位置,并依次访问虚基类子对象的数据。至于虚基类定义的虚函数,它和其他的虚函数的访问形式相同,本例中,如果使用虚基类指针MyClass*pc访问MyClassC对象的fun,将会被转化为如下形式:

*(pc+28)[0]()

总结

虚函数机制涉及的指针和表有:

  • 虚函数表指针vfptr和虚函数表vftable
  • 虚继承下还涉及 虚基类表指针vbptr和虚基类表vbtable

虚函数的实现过程:

  1. 编译器为每个含有虚函数的类或者从此类派生的类创建一个虚函数表vftable, 保存此类所有虚函数的地址,并增加一个隐藏成员虚函数表指针vfptr放在所有数据成员之前。在创建类的对象时,在构造函数内部对虚函数表指针进行初始化,指向之前创建的虚函数表。
  2. 单继承情况下,派生类会继承基类所有的数据成员和虚函数表指针,并由编译器生成虚函数表,在创建派生类实例时,将虚函数表指针指向新的,属于派生类的虚函数表。
  3. 多重继承情况下,会有多个虚函数表,几重继承,就会有几个虚函数表。这些表按照派生的顺序依次排列,如果派生类改写了基类的虚函数,那么就会用派生类自己的虚函数覆盖虚函数表的相应的位置,如果派生类有新的虚函数,那么就添加到第一个虚函数表的末尾。
  4. 虚继承情况下,会再创建一个虚基类表和一个虚基类表指针,也就是说,编译器会增加两个指针,
    • 一个是虚基类表指针,指向虚基类表,保存了所有继承过来的虚基类在内存中的地址(偏移量);
    • 另一个是从公共基类(MyClass)继承过来的虚函数表指针,保存了公共基类虚函数的地址。
  5. 虚基类部分会在C++继承层次中只有一份。所有由虚基类派生的类都持有一个虚基类表指针,指向一个虚基类表,表里面保存了所有它继承的虚基类部分的地址。虚基类部分有一个虚函数表指针,指向虚函数表。

基类的析构函数为什么要声明为虚函数

为了能在多态情况下准确调用派生类的析构函数。

如果基类的析构函数非虚函数,则用基类指针或引用引用派生类进行析构时,只会调用基类的析构函数;如果是虚析构函数,则会依次调用派生类的析构和基类的析构。(基类的析构是一定会调用的,无论是否为虚)。

构造函数为什么不可以是虚函数

虚函数在运行期决定函数调用,而在构造一个对象时,由于对象还未构造成功,编译器无法确定对象的实际类型,继而无法决定调用哪一个构造函数。

虚函数的执行依赖于虚函数表,而虚函数表在构造函数中进行初始化工作,即初始化 vptr,让它指向正确的虚函数表,而在构造期间,虚函数表还没有初始化,所以无法决定调用哪个构造函数。

所以,非纯虚的虚方法也就是普通的虚方法必须写定义,哪怕是空的,因为要生成虚函数表,没有方法定义就没有方法地址。纯虚方法和非虚方法可以不用写定义。

不能声明为虚函数的成员函数

构造函数

首先明确一点,在编译期间编译器完成了虚表的创建,而虚指针在构造函数期间被初始化。
如果构造函数是虚函数,那必然需要通过虚指针来找到虚构造函数的入口地址,但是这个时候我们还没有把虚指针初始化。因此,构造函数不能是虚函数。

內联函数

编译期內联函数在调用处被展开,而虚函数在运行时才能被确定具体调用哪个类的虚函数。內联函数体现的是编译期机制,而虚函数体现的是运行期机制。

静态成员函数:

静态成员函数和类有关,即使没有生成一个实例对象,也可以调用类的静态成员函数。而虚函数的调用和虚指针有关,虚指针存在于一个类的实例对象中,如果静态成员函数被声明成虚函数,那么调用成员静态函数时又如何访问虚指针呢。总之可以这么理解,静态成员函数与类有关,而虚函数与类的实例对象有关。

非成员函数:

虚函数的目的是为了实现多态,多态和继承有关。所以声明一个非成员函数为虚函数没有任何意义。