熟悉几个概念
在之前的文章中讲过通过基类的指针或者引用指向子类的对象,去调用虚函数的时候,实际上调用的是子类重写的虚函数。而本文就是帮助大家搞清楚背后的原理。
虚函数指针
虚函数指针本质上是一个指向申明的虚函数的指针。 注意:类中的每个虚函数都有一个虚函数指针。虚函数的调用是通过虚函数表中的函数指针来实现的,这样可以实现多态性,即在运行时根据对象的实际类型来调用相应的虚函数。
虚函数表
当一个类中声明了虚函数时,编译器会为该类创建一个虚函数表(vtable)。虚函数表本质上是个指针数组,其中每个指针指向相应虚函数的地址。 如果存在继承关系,派生类重写了基类的虚函数,派生类也会有自己的一张虚函数表。
虚函数表指针
每个对象都有一个指向虚函数表的虚函数指针(vptr)。这个虚函数指针指向类的虚函数表,使得在运行时可以动态地确定调用哪个虚函数。
案例分析虚函数表与虚指针
cpp
class A{
public:
virtual void vFunc1() {}
virtual void vFunc2() {}
private:
int x;
int y;
};
当我们通过类对象调用上面的虚函数时,内存中的情况大致如下:

从上图可以看出,对象中有个虚函数指针vptr
指向虚函数表vtable
,虚函数表中存放的就是指向虚函数地址的指针。当我们调用一个虚函数时,就是去虚函数表查找对应的虚函数指针来找到对应的虚函数的。 注意:虚函数指针vptr
和虚函数表vtable
都是在编译时产生的。普通函数调用也不需要查表操作。
上面的类A创建一个对象,内存是多少个字节?
答案:16 byte。因为2个int占了8 byte,vptr
这个指针占了8 byte,所以是16 byte。这一点,大家可以通过sizeof
去验证。
存在继承关系的虚函数表
cpp
class B : public A {
public:
virtual void vFunc3() {}
void vFunc1() override {}
};
大家先猜一下虚函数表有几个指针? 答案是3个
内存模型大致如下:

上图可知,类B也有一个自己的虚函数表,由于继承关系,vFunc2
函数的指针没有变,但是vfunc1
函数被子类重写了,所以这个虚函数的指针是改变了的,类B中又新增了一个虚函数vfunc3
,所以又多了一个函数指针。所以B的虚函数表是有3个函数指针。
注意:子类重写了虚函数之后,虚函数的地址也发生了变化,子类虚函数表中对应的指针也发生了变化。如果子类没有重写虚函数,也没有任何其他新增的虚函数,那和基类共用一张虚函数表。但是这种情况是没有实际意义的,因为把某个函数设置成虚函数时,也就表明我们希望子类在继承的时候能够重写该函数,做自己特有的实现;如果我们明确这个类不会被继承,那么就不应该有虚函数的出现。
现在回过头来想文章开头的问题,在多态中,通过基类的指针或者引用指向子类的对象,去调用虚函数的时候,调用的是子类的函数了吧。
cpp
int main() {
B b;
A *a = &b;
a->vFunc1();
return 0;
}
原因很简单,因为子类重写了虚函数之后,子类的虚函数表中该虚函数的地址变化了,所以运行时调用能在子类虚函数表中查找到调用的其实是子类重写的虚函数。
小结
虚函数表存放的就是实际调用的虚函数指针。通过虚函数表,解决了继承、覆盖的问题,保证了多态时能在运行时查找到真正要调用的函数。
动态绑定与静态绑定
上面已经讲解了虚函数表和虚函数指针的原理,接下来在这个基础上带大家搞懂多态背后动态绑定的原理。
看一个例子,思考结果
cpp
class A {
public:
virtual void vFunc1() {
cout << "A: vFunc1" << endl;
}
virtual void vFunc2() {
cout << "A: vFunc2" << endl;
}
void func1(){
cout << "A: func1" << endl;
}
void func2(){
cout << "A: func2" << endl;
}
};
class B : public A {
public:
void vFunc1() override {
cout << "B: vFunc1" << endl;
}
void func1(){
cout << "B: func1" << endl;
}
};
cpp
int main() {
B b;
b.vFunc1(); //输出 B: vFunc1
b.vFunc2(); //输出 A: vFunc2
b.func1(); //输出 B: func1
b.func2(); //输出 A: func2
return 0;
}
上面代码很简单,类A有2个虚函数,2个非虚函数;类B重写了vFunc1
,隐藏了func1
。对于输出结果,大家想必没有什么疑问。
现在对main
方法改造下
cpp
int main() {
B b;
A *a = &b;
a->vFunc1(); //注意这里 动态联编
a->vFunc2(); // 动态联编
a->func1(); //注意这里 静态联编
a->func2(); //静态联编
return 0;
}
输出结果:
B: vFunc1
A: vFunc2
A: func1
A: func2
这里是通过基类指针指向派生类对象的方式来调用函数的。先来看虚函数的调用,类B重写了vFunc1
,那调用的结果也是B的vFunc1
,vFunc2
没有重写,调用的还是基类A的vFunc2
;再来看非虚函数,B中的func1
是个隐藏函数,按理说是隐藏了基类的func1
函数,但是此时并没有如我们所想调用派生类B的func1
,实际上调用的是基类A中的func1
。 func2
是直接继承了的,调用的还是基类A的func2
。
想要彻底搞懂上面指针对象调用函数的结果为啥是这样?就需要搞懂当基类指针指向派生类对象时,通过基类指针调用函数,编译器处理方式是怎样的?\
1.如果调用的是非虚函数,则采用静态编译,直接调用基类中的函数,不用管派生类是否隐藏了该函数;
2.如果调用的是虚函数,会通过指针指向的派生类的虚函数表来查找对应的虚函顺方法
动态绑定
动态绑定是指在运行时确定函数调用的。在动态绑定中,编译器通过虚函数表和虚函数指针来确定要调用的函数,根据对象的实际类型来选择正确的函数实现。
只有通过基类指针或引用指向派生类对象,然后调用虚函数,才会执行动态绑定。对于非虚函数是不会执行动态绑定的。 其实也很好理解,非虚函数不会加入到虚函数表,而是在编译时确定的。
动态绑定允许通过基类指针或引用来调用派生类虚函数,实现多态性。这样可以在运行时根据对象的实际类型,动态地选择正确的函数实现。
虚函数,虚函数表是动态绑定的基础;动态绑定是实现运行时多态的基础。
静态绑定
静态绑定是指在编译时确定函数调用的具体实现。它基于对象的静态类型来确定调用哪个函数。静态绑定适用于非虚函数和静态成员函数。在静态绑定中,编译器根据变量的静态类型来选择调用的函数,无论实际运行时变量的类型是什么。
总结
1、静态绑定在编译时确定函数调用;
2、动态绑定在运行时确定函数调用;
3、动态绑定通过虚函数表和虚函数指针实现,可以实现多态性。