若无特殊说明, 以下所有操作均在 32 位环境下进行
本篇举例子用的类:
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) 就会大一点.
Animal* a = new Animal;
int* p = (int*)a;
cout << sizeof(*a) << endl;
cout << *p << endl;
p += 1;
cout << *p << endl;
输出的结果为
8
14588724
10可以看到, a 所指的对象大小为 8 个字节, 并且直接把 a 当成 int* 去访问所得到的不是成员变量 i, 而 p++ 后 (此处地址实际增加了 4), p 指向了成员变量 i
此时 a 所指的对象如下图:
i
age
大小为 int
大小为 intViewer does not support full SVG 1.1
上面的其实就是虚函数表指针 vptr, 下面才是成员变量 i
vptr 指向虚函数表(Vtable), 虚函数表中存储的是该类中所有的 virtual function 的指针, 也就是说, 每个类只有一张虚函数表, 可以验证一下这件事
Animal a, b;
cout << *(int*)(&a) << endl;// 打印出 vptr 所指向的地址
cout << *(int*)(&b) << endl;输出结果:
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()
Animal a;
Dog b;
Animal* p = &b;
p->eat();输出如下:
Dog::eat() tail=30cm这是一般用法. 对象没有发生任何的变化, 仅仅是让一个基类的指针指向了派生类的对象.
如果把派生类的对象赋值给基类的对象会发生什么?
Animal a;
Dog b;
a = b;
a.eat();输出如下:
Animal::eat()这叫做sliced off, 只有继承自基类的部分会被拷贝给 a, 其余的部分就被 “切掉了”.
只有通过指针或者引用调用才会是动态绑定, 此处当然在 a=b; 后, 即使通过指向 a 的指针调用也不会是动态绑定的, 这是因为, 在进行对象的赋值操作时, 虚函数表指针 vptr 并不会随着赋给 a, a 调用的还是 Animal 类内的函数.
是否可以做一些邪恶的事情呢 ?手动将 b 的 vptr 赋值给 a 会怎样?
千万不要在实际写代码中这样做! 这仅仅是为了研究 virtual function 实现的原理
// 不同的编译器可能不一样, 此处为 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()输出结果:
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, 否则会有麻烦, 因为如果不是 virtual, 在析构时发生的是静态绑定, 派生类的析构就被丢掉了.
C++ 中, Overidding 重定义了 virtual function 的函数体, 发生 overriding 之后, 若要调用基类中的同名的 virtual function, 需要用 Base::func(); 这样的语法
构成 overridding 的条件:
函数名一致
函数参数一致
函数返回值一致 (若返回类型具有协变的关系, 也是可以的, 如下面代码)
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 添加了多种签名
class Base {
public:
virtual void func();
virtual void func(int);
};若对基类中的重载函数 (overloaded function)进行重写 (override), 必须保证重写所有的重载
既然已经能够得到虚函数表的地址, 那么自然想要尝试用函数指针的方式来调用, 但是这并没有想象中的那么简单, 以下内容来自本人的尝试, 非常感谢 czg 同学的帮助.
测试平台的配置信息: 系统: Windows 10 编译器: cl (x86)/g++ (x64) 若在 64 位下编译, 需要将所有的 1 改为 2, 2 改为 4
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))();输出结果:
Animal::eat()
bark()通过函数指针确实成功地调用了函数, 接下来尝试验证动态绑定, 使指针 a 指向一个 Dog 类型的对象:
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))();输出结果:
Dog::eat() tail=-1779892224cm
bark()成功地调用了 Dog::eat() , 不过 Dog::eat() 并没有成功地获取到成员变量 tail 的值.
如何才能让虚函数绑定到具体的对象? 很自然的想法是将函数指针Fun 声明为 typedef void(*Fun)(Animal*);, 然后通过传参将 “this 指针” (实际上是指向对象的指针) 传给函数, 以期待函数将这个参数像 this 指针 那般使用.
然后问题出现了:
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 的值不正确输出结果(截图):

可是, *(int*)*p) 的值与 *q 是完全一致的, 问题到底出在哪里?
换到 g++ 编译器上, 再试试看:

尽管编译器给出了不少 waring ,但这确实是预期的结果. 在 czg 同学的帮助下, 我查看了汇编代码以及微软 Argument Passing and Naming Conventions (传参与命名公约)文档
Argument Passing and Naming Conventions
https://docs.microsoft.com/en-us/cpp/cpp/argument-passing-and-naming-conventions?view=msvc-160
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++ 编译却没有出现过. 这件事情和不同的平台, 不同的编译器都有关系, 因此只需了解虚函数实现多态的原理即可, 不必强求用代码实现.