若无特殊说明, 以下所有操作均在 32 位环境下进行

本篇举例子用的类:

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
class Animal
{
public:
Animal() {};
virtual void eat() { cout << "Animal::eat()" << endl; };
virtual void bark() { cout << "bark()" << endl; };
virtual ~Animal() {};
void growUp() { age += 1; }

protected:
int age = 10;
};

class Dog : public Animal
{
public:
Dog() { age = 20; }
void wag() {};
virtual void eat() {
cout << "Dog::eat()" << " tail=" << tail << "cm" << endl;
};

private:
int tail = 90;
};


工作原理

首先验证一件事, 任何一个类只要有了虚函数 (Virtual Function) 就会大一点.

1
2
3
4
5
6
7
8
9
Animal* a = new Animal;
int* p = (int*)a;

cout << sizeof(*a) << endl;

cout << *p << endl;
p += 1;
cout << *p << endl;

输出的结果为

1
2
3
8
14588724
10

可以看到, a 所指的对象大小为 8 个字节, 并且直接把 a 当成 int* 去访问所得到的不是成员变量 i, 而 p++ 后 (此处地址实际增加了 4), p 指向了成员变量 i

此时 a 所指的对象如下图:

i
age
大小为 int
大小为 int
Viewer does not support full SVG 1.1

上面的其实就是虚函数表指针 vptr, 下面才是成员变量 i

vptr 指向虚函数表(Vtable), 虚函数表中存储的是该类中所有的 virtual function 的指针, 也就是说, 每个类只有一张虚函数表, 可以验证一下这件事

1
2
3
Animal a, b;
cout << *(int*)(&a) << endl;// 打印出 vptr 所指向的地址
cout << *(int*)(&b) << endl;

输出结果:

1
2
4295476
4295476

两个对象的 vptr 指向了同一张虚函数表.

以本篇开头的 Animal 类为例, 若实例化一个 Animal 类的对象 , 则这个对象在内存中的组织形式为:

A AnimalvptrageAnimal VtableAnimal::dtor()Animal::bark()Animal::eat()

任何一个 Animal 的对象都会有一个指向 Animal Vtable 的虚函数指针

而派生类 Dog 的对象如下:

A DogvptragetailDog VtableDog::dtor()Animal::bark()Dog::eat()

这个对象也有一个 vptr, 但是它指向的不是 Animal 类的虚函数表, 而是自己类的.

需要注意的一点是, 派生类的虚函数表和基类的结构是一致的, 其中析构函数和 eat() 是自己的, bark() 沿用了 Animal 的 (析构函数编译器自动制造一个). 如果派生类中新增了虚函数, 虚函数表中会在原来的基础上新增.

如果做点邪恶的事情

将一个 Animal 的指针指向一个 Dog 的对象, 通过指针调用 eat() 函数, 会调用 Dog::eat()

1
2
3
4
Animal a;
Dog b;
Animal* p = &b;
p->eat();

输出如下:

1
Dog::eat() tail=30cm

这是一般用法. 对象没有发生任何的变化, 仅仅是让一个基类的指针指向了派生类的对象.

如果把派生类的对象赋值给基类的对象会发生什么?

1
2
3
4
Animal a;
Dog b;
a = b;
a.eat();

输出如下:

1
Animal::eat()

这叫做sliced off, 只有继承自基类的部分会被拷贝给 a, 其余的部分就被 “切掉了”.

只有通过指针或者引用调用才会是动态绑定, 此处当然在 a=b; 后, 即使通过指向 a 的指针调用也不会是动态绑定的, 这是因为, 在进行对象的赋值操作时, 虚函数表指针 vptr 并不会随着赋给 a, a 调用的还是 Animal 类内的函数.

是否可以做一些邪恶的事情呢 ?手动将 b 的 vptr 赋值给 a 会怎样?

千万不要在实际写代码中这样做! 这仅仅是为了研究 virtual function 实现的原理

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
// 不同的编译器可能不一样, 此处为 cl 编译器 32 位环境. 若需要在 64 位下查看, 应该把 1 均改为 2, 2 均改为 4. 
//我在 g++ 下编译需要将 *(p+1) 改为 *(p+2), 原来的 *(p+2) 改为 *(p+3) 我暂时先不去研究了
//若无法得到预期的结果, 将 Animal 和 Dog 的 protected 以及 private 设为 public 根据它们的地址调整偏移量
Animal a;
Dog b;
Animal* pa = &a;

pa->eat();//调用 Animal::eat(), 这是正常的用法

//cout << &b.age << " " << &b.tail << endl;

int* p = (int*)&a;
int* q = (int*)&b;

cout << "a vtable 的地址" << *p << endl;
cout << "age " << *(p + 1) << endl;
cout << "b vtable 的地址" << *q << endl;
cout << "age " << *(q + 1) << endl;
cout << "tail " << *(q +2) << endl;

*p = *q;//仅将 Dog 的vptr 赋给 a

cout << "a vtable 的地址" << *p << endl;//可以观察到a 的 vtable 的地址已经与 b 一致
cout << "age " << *(p + 1) << endl;//其他的没有变化
cout << "b vtable 的地址" << *q << endl;
cout << "age " << *(q + 1) << endl;
cout << "tail " << *(q + 2) << endl;

pa->eat();//调用 Dog::eat()

输出结果:

1
2
3
4
5
6
7
8
9
10
11
12
Animal::eat()
a vtable 的地址12229428
age 10
b vtable 的地址12229464
age 20
tail 90
a vtable 的地址12229464
age 10
b vtable 的地址12229464
age 20
tail 90
Dog::eat() tail=-858993460cm

手动将 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
    13
    class 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
    5
    class 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
2
3
4
5
6
7
8
9
10
11
typedef void(*Fun)();
Animal* a = new Animal();

int* p = (int*)a;//*p 是一个指针, 指向虚函数表
int* q = (int*)*p;//q 的值与 *p 相同, 指向虚函数表第一项 , *q 是函数指针

//调用 Animal::eat(), 等价于 ((void(*)())(*(int*)*(int*)a))();
((Fun)*q)();

//调用bark(), 等价于 ((void(*)())*((int*)*(int*)a+1))();
((Fun)*(q+1))();

输出结果:

1
2
Animal::eat()
bark()

通过函数指针确实成功地调用了函数, 接下来尝试验证动态绑定, 使指针 a 指向一个 Dog 类型的对象:

1
2
3
4
5
6
7
8
9
10
11
typedef void(*Fun)();
Animal* a = new Dog();

int* p = (int*)a;//*p 是一个指针, 指向虚函数表
int* q = (int*)*p;//q 的值与 *p 相同, 指向虚函数表第一项 , *q 是函数指针

//调用 Dog::eat(), 等价于 ((void(*)())(*(int*)*(int*)a))();
((Fun)*q)();

//调用bark(), 等价于 ((void(*)())*((int*)*(int*)a+1))();
((Fun)*(q+1))();

输出结果:

1
2
Dog::eat() tail=-1779892224cm
bark()

成功地调用了 Dog::eat() , 不过 Dog::eat() 并没有成功地获取到成员变量 tail 的值.

如何才能让虚函数绑定到具体的对象? 很自然的想法是将函数指针Fun 声明为 typedef void(*Fun)(Animal*);, 然后通过传参将 “this 指针” (实际上是指向对象的指针) 传给函数, 以期待函数将这个参数像 this 指针 那般使用.

然后问题出现了:
1
2
3
4
5
6
7
8
9
10
11
12
13
typedef void(*Fun)(Animal*);
Animal* a = new Dog();

int* p = (int*)a;//*p 是一个指针, 指向虚函数表
int* q = (int*)*p;//q 的值与 *p 相同, 指向虚函数表第一项 , *q 是函数指针

((void(*)(Animal*))(*(int*)*(int*)a))(a); //OK

((Fun)(*(int*)*(int*)a))(a); //OK

((Fun)(*(int*)*p))(a); //OK

((Fun)(*q))(a); //tail 的值不正确

输出结果(截图):

image-20210521000428670

可是, *(int*)*p) 的值与 *q 是完全一致的, 问题到底出在哪里?

换到 g++ 编译器上, 再试试看:

image-20210521000634740

尽管编译器给出了不少 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 寄存器.

可以看一下相应的汇编代码

image-20210521002412609

在 Visual Studio x86 编译下出现的这种情况是可以复现的, g++ 编译却没有出现过. 这件事情和不同的平台, 不同的编译器都有关系, 因此只需了解虚函数实现多态的原理即可, 不必强求用代码实现.