虚函数是C++中实现动态绑定的重要方法,而今天笔者将从虚函数表和this指针切入,去深度解析虚函数。
在讲虚函数之前先提一提静态关联,在同一类族中,派生类对象有着一块与基类对象相似的空间。
class A {
int a;
int b;
};
class B :public A {
int c;
};
使用VScode下的可视化工具查看类的内存分部
1>class A size(8):
1> +---
1> 0 | a
1> 4 | b
1> +---
1>class B size(12):
1> +---
1> 0 | +--- (base class A)
1> 0 | | a
1> 4 | | b
1> | +---
1> 8 | c
1> +---
不难看出,类B
中有一部分完全与类A
相同。
在C++中,基类指针是可以合法访问派生类对象,因为当把派生类对象的地址赋值给基类指针时,会自动进行指针类型转换,先将所提取出的派生类对象的地址转换为基类指针,然后基类指针指向派生类对象中的基类部分,故此方法只能访问派生类中由基类继承下来的成员。
上图中,若定义B类对象B b_obj
和A类指针A* p=&b_obj
(A类引用同理,引用本质上就是指针),那么p只能访问对象b_obj
中的成员a
和b
。因为A能访问的成员和函数在编译时就已经确定。若要通过派生类指针访问基类,则需手动进行指针类型的强制转换。
同样,对于函数而言,静态关联使得通过基类指针或者基类引用所访问的函数在编译时就被确定,如果一个类不会作为基类或者在其派生类中不会重新定义基类中的任何方法,那么使用静态关联会更合理,空间开销更小,效率更高,这也是C++默认使用静态关联的原因。
在基类和派生类中,定义同名,同类型,同参函数fun1()
(若有一个不同会被当做是函数重载),派生类对象的函数会覆盖基类函数的功能,这就是所谓的同名覆盖。
class A {
public:
void fun1();
};
class B :public A {
public:
void fun1();
};
void A::fun1() {
printf("fun1 in Class A\n");
}
void B::fun1() {
printf("fun1 in Class B\n");
}
上图中基类A
和派生类B
有相同的函数func1()
,当调用B类对象的func1()
时,会默认调用B类下的B::func1()
,若需要调用A类下A::func1()
,则需要使用作用域运算符来确定。
#include
int main(){
B b;
b.fun1();
b.A::fun1();
return 0;
}
//输出结果
fun1 in Class B
fun1 in Class A
在实际开发中,时常遇到这样的问题,调用对象的方法不是直接通过对象调用,而是通过对象的指针或者引用去间接调用,当你在派生类定义一个同名函数去覆盖基类中的函数,而用基类指针去访问派生类中同名函数func1()
的时候,由于基类指针任然静态关联到基类中的A::func1()
,故没有发生同名覆盖,任然调用的是基类中的同名函数。
int main(){
B b;
A* p = &b;
A &ab = b;
p->fun1();
ab.fun1();
return 0;
}
//输出结果
fun1 in Class A
fun1 in Class A
上述代码中,无论是基类指针A* p = &b;
,还是基类引用A &ab = b;
,虽然都指向派生类,但在编译是关联到了基类中的函数A::func1()
,如果程序设计者想实现通过一个指针,访问不同类中的同名函数,而不受指针类型的限制,那就需要引入了虚函数来实现这个功能。虚函数则通过取消这种静态关联来实现函数和对象的动态绑定。
虚函数声明方法:在基类的函数声明前加上关键字virtual
,出于工程开发规范,在派生类同名函数声明后加上关键字override
,告诉计算机这是虚函数而不是函数重载。
class A {
public:
virtual void fun1();
};
class B :public A {
public:
void fun1() override;
};
//其余部分与上图代码相同
//输出结果
fun1 in Class B
fun1 in Class B
不难看出,当把func1()
设为虚函数时,指针访问的函数由指向对象决定,于是就实现了函数和对象的动态绑定。由于编译器不确定会使用哪个虚函数,故所有虚函数都必须被定义。
但虚函数实现动态绑定的原理,很多教材一笔带过,清华大学出版社的《C++程序设计》中这样解释:
“所谓虚函数,就是在基类声明函数是虚拟的,并不是实际存在的函数,然后在派生类中才正式定义的函数。”——《C++程序设计》谭浩强
这种玄乎的解释完全没有解释清楚虚函数的原理,而要理解虚函数实现动态绑定的本质,就需要介绍类中一个重要的部分,虚函数表。
继续通过VScode下的可视化工具查看声明了虚函数和没有声明虚函数的类的内存分布。
非虚函数:
class A {
public:
void fun1();
};
class B :public A {
public:
void fun1();
};
//下为对象的内存分布
1>class A size(1):
1> +---
1> +---
1>class B size(1):
1> +---
1> 0 | +--- (base class A)
1> | +---
1> +---
由于类A,B都不含数据成员,由于非虚函数并不占用空间,而一个对象至少占用1单位空间,故A,B生成的对象空间都为1,当我们把函数fun1()设为虚函数,对象的内存分布完全不同。
虚函数类:
class A {
public:
void fun1();
};
class B :public A {
public:
void fun1();
};
//下为对象的内存分布
1>class A size(8):
1> +---
1> 0 | {
vfptr}
1> +---
1>class B size(8):
1> +---
1> 0 | +--- (base class A)
1> 0 | | {
vfptr}
1> | +---
1> +---
可以看出,当func1()
被设为虚函数后,对象大小变成了8个字节,这正好是64位操作系统指针的大小,因为含有虚函数的对象中有一个指针__vfptr
指向该类的虚函数表
每个包含虚函数的类都有虚函数表。虚函数表存放一系列的函数指针,指向虚函数本身,包括了基类的虚函数,派生类对基类虚函数的重写和派生类自己的虚函数。
在单继承中,派生类只含有一个虚函数表,包括基类虚函数,派生类对基类虚函数的重写,派生类虚函数。
下图为例:
class A {
public:
virtual void fun1();
virtual void fun2();
};
class B :public A {
public:
void fun2() override;
virtual void fun3();
};
//下为对象的内存分布
1>class A size(8):
1> +---
1> 0 | {
vfptr}
1> +---
1>class B size(8):
1> +---
1> 0 | +--- (base class A)
1> 0 | | {
vfptr}
1> | +---
1> +---
位于对象0位置的指针指向一个由vfptr
指针构成的虚函数地址表(位于对象内),该表所包含的每个指针均指向一个虚函数表(位于类内)
虚函数表具体存在形式:
//下为单继承中虚函数表在内存中的存在形式
//类A的虚函数表
1>A::$vftable@:
1> | &A_meta
1> | 0
1> 0 | &A::fun1
1> 1 | &A::fun2
//类B的虚函数表
1>B::$vftable@:
1> | &B_meta
1> | 0
1> 0 | &A::fun1//基类的虚函数
1> 1 | &B::fun2//派生类对基类虚函数的重写
1> 2 | &B::fun3//派生类中的虚函数
可见在单继承中,在派生类的虚函数表内,派生类的虚函数会将基类中的虚函数覆盖,派生类自身的虚函数会添加在虚函数表之后。非虚函数不会进入虚函数表。
在多重继承中,虚函数表不止一个,每一个基类都可能带有一个或多个虚函数表,故虚函数地址表的指针指向每一个虚函数表
下图为例:
class A1 {
public:
virtual void fun1();
virtual void fun2();
};
class A2 {
public:
virtual void fun3();
};
class B :public A1, public A2 {
public:
void fun2() override;
void fun3() override;
virtual void fun4();
};
1>class A1 size(8):
1> +---
1> 0 | {
vfptr}
1> +---
1>class A2 size(8):
1> +---
1> 0 | {
vfptr}
1> +---
1>class B size(16):
1> +---
1> 0 | +--- (base class A1)
1> 0 | | {
vfptr}
1> | +---
1> 8 | +--- (base class A2)
1> 8 | | {
vfptr}
1> | +---
1> +---
由图可见,类B
同时继承类A1
和类A2
,由于类A1
和类A2
中都包含有虚函数,故类B
中有两个vfptr
指针指向两个虚函数表。
虚函数表在内存中的存在形式:
//下为多继承中虚函数表在内存中的存在形式
//类A1的虚函数表
1>A1::$vftable@:
1> | &A1_meta
1> | 0
1> 0 | &A1::fun1
1> 1 | &A1::fun2
//类A1的虚函数表
1>A2::$vftable@:
1> | &A2_meta
1> | 0
1> 0 | &A2::fun3
//类B的虚函数表
1>B::$vftable@A1@: //类B从类A1继承的虚函数表
1> | &B_meta
1> | 0
1> 0 | &A1::fun1//基类A1中的虚函数
1> 1 | &B::fun2//派生类重写基类A1中的虚函数
1> 2 | &B::fun4//派生类自身的虚函数
1>B::$vftable@A2@: //类B从类A2继承的虚函数表
1> | -8
1> 0 | &B::fun3//派生类重写基类A2中的虚函数
与单继承中不同,多继承中派生类所重写的虚函数会存入对应的虚函数表,而自身的虚函数则存放在第一个虚函数表后面。
由于我们已知,对象中指向虚函数表的指针位于对象0地址处,可以通过修改指针类型访问虚函数表。在类方法中,传入的第一个参数为隐藏的this指针,在类外调用类方法时要将这个隐藏参数显式传入。(具体在下一节会详谈)
typedef void (*func)(void * );
//定义函数指针func,参数为不定类型指针(void*)
已知,虚函数表中的指针指向虚函数,vfptr
指针指向虚函数表。则func
为虚函数的地址,func
的指针(func*
)为虚函数表的首地址,func
指针的指针(func**
)为虚函数地址表的首地址。则将对象地址强制转换为func**
类型便可访问虚函数表。
以上文多继承为例
class A1 {
public:
virtual void fun1();
virtual void fun2();
};
class A2 {
public:
virtual void fun3();
};
class B :public A1, public A2 {
public:
void fun2() override;
void fun3() override;
virtual void fun4();
};
typedef void (*func)(void * );
//函数定义与前文相同
int main(){
B b;
A1 a1;
//输出a1.fun1()地址
printf("ptr of A1::fun1 %p\n", ((func**)(&a1))[0][0]);
//输出b.fun1()地址
printf("ptr of B::fun1 %p\n", ((func**)(&b))[0][0]);
//输出a1.fun2()地址
printf("ptr of A1::fun2 %p\n", ((func**)(&a1))[0][1]);
//输出b.fun2()地址
printf("ptr of B::fun2% p\n", ((func**)(&b))[0][1]);
((func**)(&b))[0][1](NULL);//调用类B第一个虚函数表的第二个虚函数:
((func**)(&b))[1][0](NULL);//调用类B第二个虚函数表的第一个虚函数
}
//输出结果
ptr of A1::fun1 00007FF6131F13E8
ptr of B::fun1 00007FF6131F13E8
// 由于类B中未对方法fun1()重写,故类A1和B的虚函数表中的fun1都指向同一地址
ptr of A1::fun2 00007FF6131F13E3
ptr of B::fun2 00007FF6131F1325
// 由于类B中对方法fun2()重写,故类A1和B的虚函数表中的fun2指向不同地址
fun2 in Class B
fun3 in Class B
对于未被重写的虚函数,基类和派生类虚函数表中的该函数地址相同
对于已被重写的虚函数,基类和派生类虚函数表中的该函数地址不同
(这部分一定程度上是研究IDE的输出所做的猜测,编译器具体是如何实现的还望dalao指教)
通过函数指针调用虚函数的方法,与通过基类指针(引用)调用虚函数的方法类似,这也揭示了虚函数实现动态关联的本质,通过指针间接访问一个类的虚函数必须通过该类的虚函数表,而访问虚函数表必须通过所指对象的vfptr
指针,所以定义基类指针(引用)的类决定其寻址范围(可调用的方法),而其所指向对象的虚函数表决定方法的具体实现。
虽然虚函数表实现了基类指针和虚函数的动态关联,但是在派生类中仍然能通过**作用域运算符(::
)**访问基类中的虚函数
//类定义与上文相同
int main(){
A1* ap = &b;
ap->fun2();
ap->A1::fun2();
}
//输出结果
fun2 in Class B
fun2 in Class A1
作用域运算符使得基类指针可以绕过所指对象中的vfptr
指针直接访问类的虚函数表,该调用在编译时完成解析。
通常情况下,只有成员(友元)函数中需要通过作用域运算符来回避虚函数机制。————《C++Primer (第五版)》
虚函数所应用的场景,除了在基类引用和基类指针指向派生类的时候,还有在类成员函数中调用其他成员函数时,也会涉及到需要动态绑定的情况。
解释类中的成员函数如何执行,必须提到C++中的一个重要关键字this
。在最早的C++编译器中,C++是通过转换成C语言来实现的,成员函数会转换成普通函数,而如何标记是哪个对象在调用这个成员函数,是通过隐式传入一个指向调用对象自身的指针来实现的。例如存在函数List1.append("123")
会转换成List::append(&List1,"123")
,而第一个参数就是编写中被隐藏的this指针。
对于非虚函数,this指针的寻址范围和方法实现为函数所定义层级的对象;对于虚函数,this指针的寻址范围为函数定义所在对象,而实现方法为this所指向的对象。
#include
class B {
public:
virtual void FunA() {
std::cout << "Base FunA" << std::endl;
FunB();//FunB()为虚函数,由指向派生类对象的this指针调用,故实现的是派生类中D::FunB()
FunC();//FunC()为非虚函数,编译时在B::FunA()中被类B静态关联,故实现的是B::FunC()
}
virtual void FunB() {
std::cout << "Base FunB" << std::endl;}
void FunC() {
std::cout << "Base FunC" << std::endl;}
};
class D : public B {
public:
void FunA() {
std::cout << "Derived FunA " << std::endl;
B::FunA();//必须使用作用域运算符回避虚函数,否则无限递归
}
void FunB() {
std::cout << "Derived FunB" << std::endl;}
void FunC() {
std::cout << "Derived FunC" << std::endl;}
virtual void FunD() {
std::cout << "Derived FunD" << std::endl;}
};
int main(){
B* b = new D;
b->FunA();
}
//输出结果
Derived FunA
Base FunA
Derived FunB
Base FunC
上述代码中,继承并重写的函数包括虚函数FunA()
,FunB()
和非虚函数FunC()
,FunD()
为派生类自身虚函数。基类指针B* b
指向派生类对象后,调用虚函数D::FunA()
,其中又中调用基类虚函数B::FunA()
。在B::FunA()
,由于FunB()
为虚函数,由指向派生类对象的this指针调用,故实现的是派生类中D::FunB()
;而FunC()
为非虚函数,编译时在B::FunA()
中被类B
静态关联,故实现的是B::FunC()
。
(以下部分为个人根据IDE输出的推测)
若将this指针显示表达出来,便可以解释上述动态关联的原理:
B::FunA()
可以这样理解
virtual void FunA(B *thisptr){
std::cout<<"Base FunA"<<std::endl;
thisptr->FunB();//
FunC();
}
thisptr
为this
指针所传入的地址,为了与关键字this
区别,故使用thisptr
在D::FunA()
virtual void FunA(B *thisptr){
std::cout<<"Derived FunA"<<std::endl;
B::FunA(thisptr);
}
在B::FunA(thisptr);
中,this相当于D* thisptr
类指针被强制转换为B* thisptr
,但仍然指向派生类对象。故在函数B::FunA()
,是一个基类指针B* thisptr
指向了派生类对象,故调用虚函数FunB()
时实现的是D::FunB()
。
《C++ Primer Plus(第6版)中文版》人民邮电出版社
《C++ Primer(第5版)中文版》电子工业出版社
《C++程序设计(第三版)》清华大学出版社
C++虚函数表剖析
浅谈C++中的this指针
C++中派生类通过作用域运算符调用基类虚函数?