先理解一件事,在intel汇编层面来说,直接调用和间接调用的区别。
直接调用语法: call 地址 硬编码为 :e8
间接调用语法: call [ ...] 硬编码为: FF
那么在C++语法中,实现多态的前提是父类需要实现多态的成员方法前面加入virtual。我们先来看一个例子,这次试验用的是windows平台下的VS编译器不同编译器的细节是不一样的原理大差不差。
#include<iostream>
using namespace std;
class Base {
public:
void func1() {
printf("这是func1");
}
virtual void func2() {
printf("这是func2");
}
};
int main() {
Base b1;
b1.func1();
b1.func2();
return 0;
}
这个代码示例中我们只写了一个类,并且用对象去调用成员方法。运行结果如下:
我们观察下汇编代码:
此时均为e8-call,属于直接调用。接下来我们换一种调用方式,我们通过指针的方式去调用对象方法:
#include<iostream>
using namespace std;
class Base {
public:
void func1() {
printf("这是func1");
}
virtual void func2() {
printf("这是func2");
}
};
int main() {
Base b1;
Base* p;
p = &b1;
p->func1();
p->func2();
return 0;
}
运行结果没有变化,但是此时我们观察反汇编我们就发现了不一样的地方:
fun1就是普通的成员函数,反汇编依然是那样。但是我们看加入关键字的virtual变成虚函数后,汇编代码变化了。真正调用的其实是call eax这句汇编代码,我们看到他的硬编码是FF D0。说明他是个间接调用。我们可以理解这段反汇编代码,p是指向对象的首地址的也就是this指针。将this指针的第一项放入eax,又将eax的第一项复制给edx,此时将this指针给ecx(如果是linux平台g++编译this指针是赋值给rdi的 _x64情况下)最后将edx的第一项赋值给eax。最后去执行。这段翻译可能有点绕。可以自己去理解下。
现在我们知道虚函数,如果是通过对象去调用,和普通成员方法没什么区别,但是如果通过指针或者引用去调用。那么他就是个间接调用。一些细节后面再讲。
下面我们再分析一个问题,这个类到底有多大。
#include<iostream>
using namespace std;
class Base {
public:
int a;
int b;
void func1() {
printf("这是func1");
}
void func2() {
printf("这是func2");
}
};
int main() {
//Base b1;
//Base* p;
//p = &b1;
//p->func1();
//p->func2();
printf("Base结构体大小为=%d", sizeof(Base));
return 0;
}
这个问题应该很简单,只算数据大小,int类型是占4个字节因此这个Base对象的大小应该是8,因为成员方法是在代码区的,这里其实跟C语言的结构体没什么不一样的:
那如果现在我们将其中一个成员方法改成虚函数呢?
神奇的一幕发生了,变成了12。
那是不是说类中每多一个虚函数,就会多4字节大小呢?(32位而言,64位就是8)
我们将fun1也变成虚函数:
我们看到没有变化。也就是说,跟你在类中定义多少个虚函数木的关系。那这多出来的4字节是个什么鬼东西呢?这就是我们接下来要探究的东西。
我们观察。现在没有任何函数的情况下,我们的类是这样布局的。 0x00cffb80也就是b1对象的首地址。
下面我们加入一个虚函数:
我们观察此时对象的内存布局:
什么都没有,接着往下走:
再往下走:
再往下走:
我们发现对象首地址存的不再是1。而是一个地址。那这个地址是什么呢?这就是我们接下来要探究的东西。写一个demo:
#include<iostream>
using namespace std;
class Base {
public:
int a;
int b;
virtual void func1() {
printf("这是func1");
}
};
int main() {
Base b1;
b1.a = 1;
b1.b = 2;
Base* p;
p = &b1;
p->func1();
return 0;
}
我们跟过去反汇编:
我们现在看这两行汇编的意思就很明朗了。
1.mov eax [p] //将对象的this指针放入eax
2.mov edx [eax] //将this指针首地址里面存的虚函数表放入edx
3.mov ecx [p] //将this指针传给ecx
3,mov eax ,[edx] //将虚函数表里的第一项放入eax
4.call eax //调用fun1函数。
相比看懂了上面的流程就明白了。总结图如下:
那我们能验证虚函数表中的函数就是我们的想要调用的函数嘛?demo如下:
#include<iostream>
using namespace std;
class Base {
public:
int a;
int b;
virtual void func1() {
printf("这是func1\n");
}
};
int main() {
Base b1;
b1.a = 1;
b1.b = 2;
Base* p;
p = &b1;
p->func1();
printf("b1对象的地址=%p\n", &b1);
printf("虚函数表地址=%p\n", *((int*)&b1));
printf("func1函数地址=%p\n", *((int*)*((int*)&b1)));
int p2 = *((int*)*((int*)&b1));
_asm {
call p2;
}
return 0;
}
这里因为我是用32位写的demo因此我们用内联汇编测试下。代码逻辑就是取出虚函数表中的第一项,然后用汇编调用,看是否和我们用指针调用的是同一个函数运行结果如下:
通过这个实验我们确实验证了虚函数表中存的就是我们的虚函数的地址。这也是C++编译器实现多态的一个先提条件。