一、虚函数入门–“小明打猎”
1、什么是虚函数?
小明有一天去打猎,出门前正在思考带上什么装备去打猎。
假设我们在基类中定义了一种方法,那就是小明的“手”,随后在派生类中分别定义了三种打猎方法:石头、枪。
显然小明想带上枪去打猎,他调用了派生类中使用枪的方法(注意,此时他没有在基类中加Virtual关键字),于是乎,他调用后发现自己的手上并没有枪,小明非常的苦恼。最终他在基类调用方法前面加上了Virtual关键字,再次调用后,发现自己成功拿到了枪。
class Hunt {
public:
Hunt(int a = 0, int b = 0)
{
}
int measure() //这个函数前添加Virtual关键字,便可以实现子类的访问
{
cout << "I will use my hand" << endl;
return 0;
}
};
class Stick : public Hunt {
public:
Stick(int a = 0, int b = 0) :Hunt(a, b) { }
int measure()
{
cout << "I will use stick" << endl;
}
};
class Gun : public Hunt {
public:
Gun(int a = 0, int b = 0) :Hunt(a, b) { }
int measure()
{
cout << "I will use Gun" << endl;
}
};
int main()
{
Hunt* hunt;
Gun gun;
hunt = &gun;
hunt->measure();
return 0;
}
上述例子中,小明刚开始的失败是由于他并没有给出实现多态的方法,上述程序在没加Virtual之前之所以会报出“I will use my hand”的原因是,在调用打猎方法之前,基类中的方法函数已经被编译器准备好了。也就是所谓的静态链接。
由此我们引出了虚函数的概念,虚函数就是为了实现多态性(另一种方法是函数重载或者运算符重载),用Virtual关键字对成员函数进行修饰,这就是虚函数。
2、纯虚函数
接着上一个打猎的问题,小明准备好枪后,又在基类中写了一个打猎目标的函数,需要写出这些动物的所在地、出没时间等等,但是小明突然脑子短路,突然想不起来具体信息了,所以他想了一个好的办法。将基类中的虚函数后面直接赋值0,把具体的打猎信息在派生类中更好的实现。
class Hunt {
public:
Hunt(int a = 0, int b = 0){
}
virtual int Animal() = 0;//纯虚函数
};
这便是纯虚函数,在基类中定义虚函数,为了在派生类中重新定义该函数更好地适用于对象,但是在基类中又不能对虚函数给出有意义的实现,这个时候就会用到纯虚函数。
二、虚函数底层实现
1、虚表&虚表指针
了解了虚函数之后,我们不禁会想,虚函数的底层原理是怎么实现的呢,编译器处理虚函数的方法是,为每个类对象添加一个隐藏成员,隐藏成员中保存了一个指向函数地址数组的指针,称为虚表指针(vptr),这种数组成为虚函数表(Virtual Function Table, vtbl),每个类使用一个虚函数表,每个类对象用一个虚表指针。
我们对一个类写入虚函数可以发现,类的大小增加了4,并且无论增加几个虚函数,大小始终增加4,这个大小便是一个指针的大小,也就是所谓的虚表指针。虚表就是保存虚函数地址的一个数组。下面来看一个实验:
#include <iostream>
#include <windows.h>
using namespace std;
class A {
int k;
};
class B {
public:
int m;
virtual void print1()
{
cout << "bprint1\n";
}
virtual void print2()
{
cout << "bprint2\n";
}
};
class C : public B {
public:
int n;
virtual void print2()
{
cout << "cprint2\n";
}
};
int main()
{
cout << "类A的大小 = " << sizeof(A) << endl;
cout << "类B的大小 = " << sizeof(B) << endl;
cout << "类C的大小 = " << sizeof(C) << endl;
B* b = new C; //实例化
b->print2();
printf("C的虚表地址 0x%X\n", *(long*)b);
free(b);
b = new B;
b->print2();
printf("B的虚表地址 0x%X\n", *(long*)b);
while (1);
}
计算三个类的大小我们会发现,类A只有一个k变量,大小为4;B有一个m变量,还有一个虚表指针,大小为8;C是B的继承,所以有m和n变量,还有一个虚表指针,所以大小是12。
在后续的输出虚表地址实验中,
可以看出他们的虚函数地址是一样的。并且可以知道,虚表指针在类对象起始地址最开始的4字节或者8字节处!
2、虚函数表构造过程
从编译器的角度来说,B的虚函数表很好构造,C的虚函数表构造过程相对复杂。下面给出了构造C的虚函数表的一种方式:
1)如上图所示,首先拷贝基类B的虚表;
2)替换已重写的虚函数指针,也就是子类实现多态的虚函数指针;
3)追加子类自己的虚函数指针,完成构造。
3、虚函数的调用过程
我们在上述例子中调用了b->print2();能够很好的实现功能的原因是,我们声明了b是B类的指针,但是到目前为止,编译器只知道b是B类型的指针,并不知道它指向的具体对象类型。所以我们让他指向了C类,此时就可以完成C类中函数的调用。
要注意的是,在B类和C类的虚函数表中,print2函数在其表中的相对偏移是一致的,所以如果b指向B的对象,可以获取到B对象的vptr,加上偏移值8((char)vptr + 8),可以找到B::print2。如果b指向C的对象,可以获取到C对象的vptr,加上偏移值8((char*)vptr + 8) ,可以找到C::print2。
另外,虚函数指针虚函数成员指针ptr部分内容为虚函数对应的函数指针在虚函数表中的偏移地址加1(之所以加1是为了用0表示空指针),而adj部分为调节this指针的偏移字节数。所以C::print2是一个虚函数指针,他的ptr部分是9(8+1).
(图片来源于博客https://www.cnblogs.com/malecrab/p/5572119.html)
函数指针的size是普通指针的两倍,倘若在上述例子中调用C::print1;那么adj部分将会是4。具体参考上述链接博客中对指针的讲解。
三、参考文献
在撰写本文的过程中,参阅了不少网络资料和牛人博客,感激不尽。参考文章的链接:
https://www.cnblogs.com/Allen-rg/p/6927319.html