C++ 多态的实现机制
若无特殊说明, 以下所有操作均在 32 位环境下进行
本篇举例子用的类:
1 | class Animal |
工作原理
首先验证一件事, 任何一个类只要有了虚函数 (Virtual Function) 就会大一点.
1 | Animal* a = new Animal; |
输出的结果为
1 | 8 |
可以看到, a 所指的对象大小为 8 个字节, 并且直接把 a 当成 int*
去访问所得到的不是成员变量 i, 而 p++
后 (此处地址实际增加了 4), p 指向了成员变量 i
此时 a 所指的对象如下图:
上面的其实就是虚函数表指针 vptr, 下面才是成员变量 i
vptr 指向虚函数表(Vtable), 虚函数表中存储的是该类中所有的 virtual function 的指针, 也就是说, 每个类只有一张虚函数表, 可以验证一下这件事
1 | Animal a, b; |
输出结果:
1 | 4295476 |
两个对象的 vptr 指向了同一张虚函数表.
以本篇开头的 Animal 类为例, 若实例化一个 Animal 类的对象 , 则这个对象在内存中的组织形式为:
任何一个 Animal 的对象都会有一个指向 Animal Vtable 的虚函数指针
而派生类 Dog 的对象如下:
这个对象也有一个 vptr, 但是它指向的不是 Animal 类的虚函数表, 而是自己类的.
需要注意的一点是, 派生类的虚函数表和基类的结构是一致的, 其中析构函数和 eat() 是自己的, bark() 沿用了 Animal 的 (析构函数编译器自动制造一个). 如果派生类中新增了虚函数, 虚函数表中会在原来的基础上新增.
如果做点邪恶的事情
将一个 Animal 的指针指向一个 Dog 的对象, 通过指针调用 eat()
函数, 会调用 Dog::eat()
1 | Animal a; |
输出如下:
1 | Dog::eat() tail=30cm |
这是一般用法. 对象没有发生任何的变化, 仅仅是让一个基类的指针指向了派生类的对象.
如果把派生类的对象赋值给基类的对象会发生什么?
1 | Animal a; |
输出如下:
1 | Animal::eat() |
这叫做sliced off, 只有继承自基类的部分会被拷贝给 a, 其余的部分就被 “切掉了”.
只有通过指针或者引用调用才会是动态绑定, 此处当然在 a=b;
后, 即使通过指向 a 的指针调用也不会是动态绑定的, 这是因为, 在进行对象的赋值操作时, 虚函数表指针 vptr 并不会随着赋给 a, a 调用的还是 Animal 类内的函数.
是否可以做一些邪恶的事情呢 ?手动将 b 的 vptr 赋值给 a 会怎样?
千万不要在实际写代码中这样做! 这仅仅是为了研究 virtual function 实现的原理
1 | // 不同的编译器可能不一样, 此处为 cl 编译器 32 位环境. 若需要在 64 位下查看, 应该把 1 均改为 2, 2 均改为 4. |
输出结果:
1 | Animal::eat() |
手动将 vptr 赋值后, a 的 vptr 不再指向 Animal 的虚函数表, 而是指向 Dog 的虚函数表, 所以调用 eat()
的时候会调用 Dog::eat()
. 同时可以看到, 最后打印了一个奇怪的值, 因为 Dog 类中新增了一个成员变量 tail (可以看到尽管 tail 是private 也并非没有办法去访问甚至修改), 而在基类 Animal 中是不存在的. 所以 Dog::eat()
会把 a.age
下面的那块内存当成 a.tail
来打印.
虚拟析构函数 (Virtual destructors)
关于析构函数, 若类中存在虚函数, 则必须将该类的析构函数也设为 virtual, 否则会有麻烦, 因为如果不是 virtual, 在析构时发生的是静态绑定, 派生类的析构就被丢掉了.
重写 (Overridding)
C++ 中, Overidding 重定义了 virtual function 的函数体, 发生 overriding 之后, 若要调用基类中的同名的 virtual function, 需要用 Base::func();
这样的语法
构成 overridding 的条件:
函数名一致
函数参数一致
函数返回值一致 (若返回类型具有协变的关系, 也是可以的, 如下面代码)
1
2
3
4
5
6
7
8
9
10
11
12
13class Expr{
public:
virtual Expr* newExpr();
virtual Expr& clone();
virtual Expr self();
};
class BinaryExpr : public Expr{
public:
virtual BinaryExpr* newExpr(); //OK
virtual BinaryExpr& clone(); //OK
virtual BinaryExpr self(); //ERROR
};
重载与虚函数 (Overloading and virtuals)
Overloading 添加了多种签名
1
2
3
4
5class Base {
public:
virtual void func();
virtual void func(int);
};若对基类中的重载函数 (overloaded function)进行重写 (override), 必须保证重写所有的重载
- 若只重写一部分, 其余的基类中的同名函数将会发生 name hiding.
通过函数指针调用 virtual function 的尝试
既然已经能够得到虚函数表的地址, 那么自然想要尝试用函数指针的方式来调用, 但是这并没有想象中的那么简单, 以下内容来自本人的尝试, 非常感谢 czg 同学的帮助.
测试平台的配置信息:
系统: Windows 10
编译器: cl (x86)/g++ (x64)
若在 64 位下编译, 需要将所有的 1 改为 2, 2 改为 4
1 | typedef void(*Fun)(); |
输出结果:
1 | Animal::eat() |
通过函数指针确实成功地调用了函数, 接下来尝试验证动态绑定, 使指针 a 指向一个 Dog 类型的对象:
1 | typedef void(*Fun)(); |
输出结果:
1 | Dog::eat() tail=-1779892224cm |
成功地调用了 Dog::eat()
, 不过 Dog::eat()
并没有成功地获取到成员变量 tail 的值.
如何才能让虚函数绑定到具体的对象? 很自然的想法是将函数指针Fun
声明为 typedef void(*Fun)(Animal*);
, 然后通过传参将 “this 指针” (实际上是指向对象的指针) 传给函数, 以期待函数将这个参数像 this 指针 那般使用.
1 | typedef void(*Fun)(Animal*); |
输出结果(截图):
可是, *(int*)*p)
的值与 *q
是完全一致的, 问题到底出在哪里?
换到 g++ 编译器上, 再试试看:
尽管编译器给出了不少 waring ,但这确实是预期的结果. 在 czg 同学的帮助下, 我查看了汇编代码以及微软 Argument Passing and Naming Conventions (传参与命名公约)文档
The following calling conventions are supported by the Visual C/C++ compiler.
Keyword | Stack cleanup | Parameter passing |
---|---|---|
__cdecl | Caller | Pushes parameters on the stack, in reverse order (right to left) |
__clrcall | n/a | Load parameters onto CLR expression stack in order (left to right). |
__stdcall | Callee | Pushes parameters on the stack, in reverse order (right to left) |
__fastcall | Callee | Stored in registers, then pushed on stack |
__thiscall | Callee | Pushed on stack; this pointer stored in ECX |
__vectorcall | Callee | Stored in registers, then pushed on stack in reverse order (right to left) |
在调用成员函数时是 __thiscall , 将 this
指针存入 ECX 寄存器, 而通过传参的方式 __cdecl 是将参数压入栈中, 因此, 此处出问题是成员函数 Dog::eat()
想从 ECX 寄存器得到 this
指针, 但是 this
并不在哪里, 所以得到的 tail 值就是错误的.
至于为什么前几个看似工作正常, 是由于函数执行期间恰好将 a 的值 move 进了 ECX 寄存器.
可以看一下相应的汇编代码
在 Visual Studio x86 编译下出现的这种情况是可以复现的, g++ 编译却没有出现过. 这件事情和不同的平台, 不同的编译器都有关系, 因此只需了解虚函数实现多态的原理即可, 不必强求用代码实现.