关于虚函数
一、多态与重载
1、多态的概念
面向对象的语言有三大特性:继承、封装、多态。虚函数作为多态的实现方式,重要性毋庸置疑。
多态意指相同的消息给予不同的对象会引发不同的动作(一个接口,多种方法)。其实更简单地来说,就是“在用父类指针调用函数时,实际调用的是指针指向的实际类型(子类)的成员函数”。多态性使得程序调用的函数是在运行时动态确定的,而不是在编译时静态确定的。
2、重载—编译期多态的体现
重载,是指在一个类中的同名不同参数的函数调用,这样的方法调用是在编译期间确定的。
3、虚函数—运行期多态的体现
运行期多态发生的三个条件:继承关系、虚函数覆盖、父类指针或引用指向子类对象。
二、虚函数实例
#include <iostream> int main() { class base{ public: virtual void vir_func(){std::cout<<"virtual fun : base \n";} void func(){std::cout<<"normal fun : base \n";} }; class A: public base{ public: virtual void vir_func(){std::cout<<"virtual fun : A \n";} void func(){std::cout<<"normal fun : A \n";} }; class B: public base{ public: virtual void vir_func(){std::cout<<"virtual fun : B \n";} void func(){std::cout<<"normal fun : B \n";} }; base * Base = new(base); base * a = new(A); base * b = new(B); std::cout << ">>>>>>>>>> normal fun: " << std::endl; Base->func(); a->func(); b->func(); std::cout << ">>>>>>>>>> virtual fun: " << std::endl; Base->vir_func(); a->vir_func(); b->vir_func(); std::cout << ">>>>>>>>>> convert type: " << std::endl; ((A *) b)->vir_func(); ((A *) b)->func(); delete Base; delete a; delete b; }
>>>>>>>>>> normal fun: normal fun : base normal fun : base normal fun : base >>>>>>>>>> virtual fun: virtual fun : base virtual fun : A virtual fun : B >>>>>>>>>> convert type: virtual fun : B normal fun : A
在上述例子中,我们首先定义了一个基类base,基类有一个名为vir_func的虚函数,和一个名为func的普通成员函数。而类A,B都是由类base派生的子类,并且都对成员函数进行了重载。然后我们定义三个base类型的指针Base、a、b分别指向类base、A、B。
可以看到,当使用这三个指针调用func函数时,调用的都是基类base的函数。而使用这三个指针调用虚函数vir_func时,调用的是指针指向的实际类型的函数。最后,我们将指针b做强制类型转换,转换为A类型,然后分别调用func和vir_func函数,发现普通函数调用的是类A的函数,而虚函数调用的是类B的函数。
以上,我们可以得出结论“当使用类的指针调用成员函数时,普通函数由指针类型决定,而虚函数由指针指向的实际类型决定”。那么,虚函数又是怎么实现的呢?
虚函数的实现过程:通过对象内存中的vptr找到虚函数表vtbl,接着通过vtbl找到对应虚函数的实现区域并进行调用。
三、类的虚表
每个包含了虚函数的类都包含一个虚表。
我们知道,当一个类(A)继承另一个类(B)时,类A会继承类B的函数的调用权。所以如果一个基类包含了虚函数,那么其继承类也可调用这些虚函数,换句话说,一个类继承了包含虚函数的基类,那么这个类也拥有自己的虚表。
我们来看以下的代码。类A包含虚函数vfunc1,vfunc2,由于类A包含虚函数,故类A拥有一个虚表。
class A { public: virtual void vfunc1(); virtual void vfunc2(); void func1(); void func2(); private: int m_data1, m_data2; };
类A的虚表如图1所示。
虚表是一个指针数组,其元素是虚函数的指针,每个元素对应一个虚函数的函数指针。需要指出的是,普通的函数即非虚函数,其调用并不需要经过虚表,所以虚表的元素并不包括普通函数的函数指针。
虚表内的条目,即虚函数指针的赋值发生在编译器的编译阶段,也就是说在代码的编译阶段,虚表就可以构造出来了。
四、虚表指针
虚表是属于类的,而不是属于某个具体的对象,一个类只需要一个虚表即可。同一个类的所有对象都使用同一个虚表。
为了指定对象的虚表,对象内部包含一个虚表的指针,来指向自己所使用的虚表。为了让每个包含虚表的类的对象都拥有一个虚表指针,编译器在类中添加了一个指针,*__vptr
,用来指向虚表。这样,当类的对象在创建时便拥有了这个指针,且这个指针的值会自动被设置为指向类的虚表。
上面指出,一个继承类的基类如果包含虚函数,那个这个继承类也有拥有自己的虚表,故这个继承类的对象也包含一个虚表指针,用来指向它的虚表。
五、动态绑定
说到这里,大家一定会好奇C++是如何利用虚表和虚表指针来实现动态绑定的。我们先看下面的代码。
class A { public: virtual void vfunc1(); virtual void vfunc2(); void func1(); void func2(); private: int m_data1, m_data2; }; class B : public A { public: virtual void vfunc1(); void func1(); private: int m_data3; }; class C: public B { public: virtual void vfunc2(); void func2(); private: int m_data1, m_data4; };
类A是基类,类B继承类A,类C又继承类B。类A,类B,类C,其对象模型如下图3所示。
由于这三个类都有虚函数,故编译器为每个类都创建了一个虚表,即类A
的虚表(A vtbl)
,类B
的虚表(B vtbl)
,类C
的虚表(C vtbl)
。类A
,类B
,类C
的对象都拥有一个虚表指针,***__vptr**
,用来指向自己所属类的虚表。
- 类
A
包括两个虚函数,故A vtbl
包含两个指针,分别指向A::vfunc1()
和A::vfunc2()
。 - 类
B
继承于类A
,故类B
可以调用类A
的函数,但由于类B
重写了B::vfunc1()
函数,故B vtbl
的两个指针分别指向B::vfunc1()
和A::vfunc2()
。 - 类
C
继承于类B
,故类C
可以调用类B
的函数,但由于类C
重写了C::vfunc2()
函数,故C vtbl
的两个指针分别指向**B::vfunc1()**
(指向继承的最近的一个类的函数)和C::vfunc2()
。
虽然图3看起来有点复杂,但是只要抓住“对象的虚表指针用来指向自己所属类的虚表,虚表中的指针会指向其继承的最近的一个类的虚函数”这个特点,便可以快速将这几个类的对象模型在自己的脑海中描绘出来。
非虚函数的调用不用经过虚表,故不需要虚表中的指针指向这些函数。
假设我们定义一个类B的对象bObject。由于bObject是类B的一个对象,故bObject包含一个虚表指针,指向类B的虚表。
int main() { B bObject; }
现在,我们声明一个类A的指针p来指向对象bObject。虽然p是基类的指针只能指向基类的部分,但是虚表指针亦属于基类部分,所以 p 可以访问到对象bObject 的虚表指针。bObject的虚表指针指向类B的虚表,所以p可以访问到B vtbl。如图3所示。
int main() { B bObject; A *p = & bObject; }
当我们使用p来调用vfunc1()函数时,会发生什么现象?
int main() { B bObject; A *p = & bObject; p->vfunc1(); }
程序在执行p->vfunc1()
时,会发现p
是个指针,且调用的函数是虚函数,接下来便会进行以下的步骤。
首先,根据虚表指针p->__vptr
来访问对象bObject
对应的虚表。虽然指针p
是基类A
类型,但是**__vptr
也是基类的一部分,所以可以通过p->__vptr**
可以访问到对象对应的虚表。
然后,在虚表中查找所调用的函数对应的条目。由于虚表在编译阶段就可以构造出来了,所以可以根据所调用的函数定位到虚表中的对应条目。对于p->vfunc1()
的调用,B vtbl
的第一项即是vfunc1
对应的条目。
最后,根据虚表中找到的函数指针,调用函数。从图3可以看到,B vtbl
的第一项指向B::vfunc1()
,所以**p->vfunc1()
实质会调用B::vfunc1()**
函数。
如果p指向类A的对象,情况又是怎么样?
int main() { A aObject; A *p = &aObject; p->vfunc1(); }
当aObject
在创建时,它的虚表指针__****vptr**
已设置为指向**A vtbl**
,这样p->__vptr
就指向A vtbl
。vfunc1
在A vtbl
对应在条目指向了A::vfunc1()
函数,所以p->vfunc1()
实质会调用A::vfunc1()
函数。
可以把以上三个调用函数的步骤用以下表达式来表示:
(*(p->__vptr)[n])(p)
可以看到,通过使用这些虚函数表,即使使用的是基类的指针来调用函数,也可以达到正确调用运行中实际对象的虚函数。
我们把经过虚表调用虚函数的过程称为动态绑定,其表现出来的现象称为运行时多态。动态绑定区别于传统的函数调用,传统的函数调用我们称之为静态绑定,即函数的调用在编译阶段就可以确定下来了。
那么,什么时候会执行函数的动态绑定?这需要符合以下三个条件。
- 通过指针来调用函数
- 指针upcast向上转型(继承类向基类的转换称为upcast,关于什么是upcast,可以参考本文的参考资料)
- 调用的是虚函数
如果一个函数调用符合以上三个条件,编译器就会把该函数调用编译成动态绑定,其函数的调用过程走的是上述通过虚表的机制。
六、总结
封装,继承,多态是面向对象设计的三个特征,而多态可以说是面向对象设计的关键。C++通过虚函数表,实现了虚函数与对象的动态绑定,从而构建了C++面向对象程序设计的基石。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 终于写完轮子一部分:tcp代理 了,记录一下
· 震惊!C++程序真的从main开始吗?99%的程序员都答错了
· 别再用vector<bool>了!Google高级工程师:这可能是STL最大的设计失误
· 单元测试从入门到精通
· 【硬核科普】Trae如何「偷看」你的代码?零基础破解AI编程运行原理