欢迎大家来到IT世界,在知识的湖畔探索吧!
经常在编译错误中看到的vTable究竟是什么?
为什么要有虚函数
C++的设计理念是:用不到的功能就不要在运行时花费时间。正因如此,C++中会有静态绑定、动态绑定、虚函数这些概念。
对比其他一些面向对象的语言,可以认为它们所有成员函数都是虚函数,都是动态绑定,而C++则为了性能考虑,只有实际用到时,即成员函数有virtual修饰,才开启动态绑定。
静态绑定与动态绑定
所谓静态绑定,是指成员函数的地址在编译期就可以确定,运行时则直接跳转到对应地址执行;而动态绑定则是指编译期不确定,只有到运行时才能找到函数地址,这需要两次额外的寻址指令:第1次找到虚表,第2次从虚表中找到函数地址。
哪些情况会出现动态绑定?答案是只有使用指针或引用调用虚成员函数时才会出现。
例如:
class VirtualClass { public: virtual void f() {} }; class SubClass : public VirtualClass { virtual void f() override {} }; int main() { auto* p = new SubClass(); p->f(); // 动态绑定 }
欢迎大家来到IT世界,在知识的湖畔探索吧!
之所以不能用静态绑定,是因为p不仅可以指向VirtualClass对象,也可以指向它的子类SubClass ,而我们在编译期并不能确定它具体指向哪个类。
内存布局与虚表指针
在分析虚表之前,先讨论一下类的内存布局。
一个类实例需要占据一定的内存空间,而空间的大小以及其中内容的排布,对同一个类的所有实例都是相同的。例如下面这个类:
欢迎大家来到IT世界,在知识的湖畔探索吧!class Layout { public: short s; int i; long long int l; void f(); };
在Visual Studio的工具中可以看到它的内存排布:
欢迎大家来到IT世界,在知识的湖畔探索吧!
而当我们把成员函数f改成虚函数时:
class Layout { public: short s; int i; long long int l; virtual void f(); };
我们发现,前8个字节增加了一个指针 vfptr 即虚表指针,它指向一个虚表。从另一个角度,我们也可以理解为,Layout类的前8个字节用来标识它的实际类型(Layout或其某个子类)。
虚表
前面说到了虚表,那么虚表到底是什么呢?
虚表就是一个数组,它存储了一系列函数指针。只有包含虚函数的类才会有虚表,一个类的所有实例公用一个虚表。虚表中的每个指针则是指向这个类的所有虚函数。
下面的代码和对应的示意图可以看得很清楚:
欢迎大家来到IT世界,在知识的湖畔探索吧!class Instrument { public: virtual void play() {}; virtual void adjust() {}; }; class Wind : public Instrument { public: virtual void play() override { printf("Wind play"); } virtual void adjust() override { printf("Wind adjust"); } int score = 1; }; class Brass : public Wind { public: virtual void play() override { printf("Brass play"); } virtual void what() { printf("Brass what"); } int score = 2; }; int main() { Instrument* list[4]; list[0] = new Instrument(); list[1] = new Wind(); list[2] = new Brass(); list[3] = new Brass(); return 0; }
从这个例子中可以看到,当子类重写(override)父类的虚函数时,虚表中的对应指针也会修改,但顺序不变。当子类新增虚函数时,则会在虚表末尾新增。
按照这样的规则,当我们把子类的指针或引用向上类型转换时,它的虚表完全可以当做时父类的虚表来使用,无需关心实际类型。
多继承
上面说到,子类的内存布局和虚表都兼容父类,但这时又出现一个新的问题,如果有多继承怎么办?如何同时兼容两个父类呢?
其实,多继承的情况,子类会的内存布局会将两个父类依次排布,也就是会有两个虚表指针。
例如:
class Flyable { public: virtual void fly() {} int hight = 0; }; class Runnable { public: virtual void run() {} int speed = 0; }; class Bird : public Flyable, public Runnable { public: virtual void fly() override {} virtual void run() override {} virtual void eat() {} int weight = 0; };
Bird的内存布局如下:
从图上可以清楚看到,0x00 ~ 0x0f 这部分内存布局兼容Flyable类,0x10 ~ 0x1f 兼容,0x20之后的地址是Bird类自己的成员变量。
而Bird类自己的虚成员函数 eat() 会加在哪个虚表里呢?答案是加到第一个虚表中,和单继承的情况类似。
指针偏移
这时你可能又要问了,当Bird*类型向上转换成Runnable*之后,再调用虚函数时,又怎么知道此时应该去0x10的位置,而非0x00找虚表指针呢?
答案是,不需要。因为在做类型转换的时候,会直接将指针偏移到0x10的位置,我们来验证一下:
欢迎大家来到IT世界,在知识的湖畔探索吧!int main() { Bird* b = new Bird(); Runnable* r = b; printf("b: %x\nr: %x\nb == r: %d", b, r, b == r); } output: b: ee617fb0 r: ee617fc0 b == r: 1
可以看到,b和r的地址确实不同,但当我们做比较运算时,结果却是相等。所以大多数时候,我们不需要关注这里的指针偏移。
但这样一来,也存在一个坑,就是我们不能将Bird*类型先转成void*之后,再强转成Runnable*类型,因为这样的转换不会做指针偏移。
对于包含虚表的类,做类型转换时一般用dynamic_cast,但不支持void*。
还是以上面的继承关系为例:
int main() { Bird* b = new Bird(); Flyable* f = b; void* v = b; printf("sc: %x\ndc: %x", static_cast<Runnable*>(v), dynamic_cast<Runnable*>(f)); } output: sc: dc:
可以看到, v和 f实际指向的时同一个 Bird对象,但两种类型转换后指针却不同,就是因为 static_cast将 void*转换到 Runnable 时没有做指针偏移。而 dynamic_cast会动态检查对象的实际类型,所以总能做出正确的指针偏移。
更多思考
就到此为止了吗?其实还有其他更复杂的情况,例如多继承时,两个父类包含相同签名的虚函数;例如有菱形继承、虚继承的情况。这些复杂情况在实际应用中较少碰到,就不做详细讨论了。
另外再提一下,C++中没有“虚成员变量”,当我们做向上类型转换后,就无法直接获取到子类的成员变量了,只能通过虚函数来获取。
免责声明:本站所有文章内容,图片,视频等均是来源于用户投稿和互联网及文摘转载整编而成,不代表本站观点,不承担相关法律责任。其著作权各归其原作者或其出版社所有。如发现本站有涉嫌抄袭侵权/违法违规的内容,侵犯到您的权益,请在线联系站长,一经查实,本站将立刻删除。 本文来自网络,若有侵权,请联系删除,如若转载,请注明出处:https://itzsg.com/134653.html