本篇文章讲述C++的虚函数
在C++语言中,基类将类型相关的函数和派生类不做改变直接继承的函数区分开来。对于有些函数,基类希望派生类各自定义适合自身的版本
。那么基类就会将这些函数标记为virtual,这些被标记的函数就是虚函数。
下面这就是一个虚函数在代码中的定义,和普通的函数一样,只不过前面添加了关键字virtual
class A_CLASS
{
public:
virtual void print() {std::cout << "invoke A_CLASS virtual function::printf" << std::endl;}
};
**如果派生类想要重新定义虚函数,派生类需要在自己的类中重新声明虚函数。**声明的时候需要注意两点:
先看第一条,为什么建议添加,在我们阅读代码的时候,明确一个函数是不是虚函数对我们理解代码结构很有帮助,尤其是类层级变多以后,这条只是从提高代码的可读性角度来看。
对于第二条,我们先看下面的代码:
#include <iostream>
class A_CLASS
{
public:
virtual void print() {std::cout << "invoke A_CLASS virtual function::printf" << std::endl;}
};
class B_CLASS:public A_CLASS
{
public:
virtual void prnit() {std::cout << "invoke B_CLASS virtual function::printf" << std::endl;}
};
对于上面代码,编译是没有问题,但是使用下面的调用
int main(int argc, const char* argv[])
{
A_CLASS* c = new B_CLASS();
c->print();
return 0;
}
打印的结果却是
invoke A_CLASS virtual function::printf
根据多态的特性,我们应该是想调用B_CLASS的print方法,但是在B_CLASS中,我们不小心把print方法写成了prnit。编译没有问题,但是不是我们期望的结果,如果我们在后边添加关键字override。
override关键字强调我们的方法要重新实现基类的虚函数,如果基类没找到该函数,编译器会报错
。override关键字能让我们预防上面出现的漏洞
对于C++的函数调用,有两种方式:
在C++语言中,当我们使用基类的引用或者指针调用一个虚函数时
将发生动态绑定。动态绑定是多态得以实现的基础
知道了动态绑定和静态绑定的定义,现在我们来研究一下动态绑定的实现原理
我们知道,一个函数在内存中其实是一系列的字节数据,用汇编表示就是一系列的汇编指令,我们执行一个函数的步骤如下:
知道函数的执行步骤,我们看一下一个类的虚函数的特点
一个类,如果有虚函数存在的话,编译器会为这个类分配一块内存,专门用来放虚函数实现代码在内存的位置,你可以把这块内存理解为指针的数组。这块内存被称为虚函数表
,简称vtbl,全称virtual table
每个类都会有一块这样的内存,基类和派生类分别有自己的虚函数表
对于一个类创建的实例,所有的实例都会包含一个指针,这个指针指向上面说的那块内存。这个指针叫做虚函数表指针
,简称vptr,全称virtual pointer。一般来说,虚函数表指针在类实例的最前面。
我们看一个实例:
#include <iostream>
class A_CLASS
{
public:
virtual void print1() {}
virtual void print() {std::cout << "invoke A_CLASS virtual function::printf" << std::endl;}
};
class B_CLASS:public A_CLASS
{
public:
virtual void print() override {std::cout << "invoke B_CLASS virtual function::printf" << std::endl;}
};
int main(int argc, const char* argv[])
{
A_CLASS* c = new B_CLASS();
std::cout << "c.size::" << sizeof(*c) << std::endl;
c->print();
return 0;
}
打印B_CLASS实例的大小,发现有8个字节,我们猜测这个8字节的值正是虚函数表指针的大小,我们在这里加个断点,运行一下,鼠标停在c变量上,在出现的提示区域右键,选择添加监视,可以看到类实例的内容如下:
这印证了我们的猜测。
在c->print();这一行打个断点,继续执行到这里,然后打开反汇编窗口
,我们可以看到关键的四行代码:
00007FF719731E7A mov rax,qword ptr [c]
00007FF719731E7E mov rax,qword ptr [rax]
00007FF719731E81 mov rcx,qword ptr [c]
00007FF719731E85 call qword ptr [rax+8]
我们分析一下这四行代码:
将c指针的值传递给rax,c指针的值就是B_CLASS类实例在内存的位置,我们从监视窗口看到了,值为0x00000171286a23f0,这块内存目前保存了虚函数表的位置,我们可以在内存窗口输入0x00000171286a23f0查询一下,结果如图,跟监视窗口的虚函数表指针是一样的:
将rax地址中保存的值赋值给rax,也就是经过这一步,rax保存的值变成了虚函数表在内存的位置,经过这一步rax的值由0x00000171286a23f0变为00007FF71973BC80
将c指针的值传递给rcx,这一步是因为我们调用虚函数的时候需要传递默认参数this,这个默认参数是第一个参数,保存在寄存器rcx中,因为我们的虚函数没有别的参数了,所以这里就传递这一个值。
call [rax+8]中的值,为什么是rax+8呢,因为B_CLASS的虚函数表有两个虚函数,一个是在A_CLASS中定义的print1,另一个是自己重定义的print。我们调用的是print,所以要往后移动8个字节才能定位到保存print函数的指针位置。
经过上面的分析和查看汇编代码我们知道了动态绑定发生的地方:
动态绑定就是发生在虚函数表指针那里。不同的类实例这个虚函数表指针指向的位置不一样,所以才能调用不同的虚函数,这,就是多态
我们上面看到了动态绑定的执行过程,现在看一下静态绑定的执行过程,将上面main中的代码修改一下:
int main(int argc, const char* argv[])
{
B_CLASS b;
b.print();
return 0;
}
还是在b.print();打一个断点,执行到断点之后,查看反汇编界面,显示如下:
00007FF711EA1E25 lea rcx,[b]
00007FF711EA1E29 call B_CLASS::print (07FF711EA115Eh)
可以看到,没有取地址的操作,就两步:
静态的函数调用确实比动态调用效率高,但是失去了动态调用的多样性。
这一小节,我们讲一下虚函数表中虚函数的排列,其实从上一节已经看到了,虚函数调用时,在虚函数表中的偏移是个常量,也就是说在编译阶段,编译器已经确定了虚函数在虚函数表中的偏移位置
。
既然虚函数的位置在虚函数表中是静态的,那么在类继承的关系层次中,虚函数的布局就是明确的。看下面的例子:
class A_CLASS
{
public:
virtual void print1() {}
virtual void print2() {std::cout << "invoke A_CLASS virtual function::printf" << std::endl;}
virtual void print3() {}
};
class B_CLASS:public A_CLASS
{
public:
virtual void print2() override {std::cout << "invoke B_CLASS virtual function::printf" << std::endl;}
virtual void print4() {};
};
对于B_CLASS的实例,虚函数表的虚函数布局方式应该是这样的:
print1--A_CLASS::print1
print2--B_CLASS::print1
print3--A_CLASS::print1
print4--B_CLASS::print1
对于A_CLASS的实例,虚函数表的虚函数布局方式应该是这样的:
print1--A_CLASS::print1
print2--A_CLASS::print1
print3--A_CLASS::print1
这样,每个虚函数在表中的偏移就是固定的了。
上面我们的例子是单继承的情况,C++支持多继承,对于多继承,就比较复杂了,我们修改一下上面的例子,这次我们给每个类加了一个int变量:
class A_CLASS
{
public:
int a;
virtual void print1() {}
virtual void print2() {std::cout << "invoke A_CLASS virtual function::printf" << std::endl;}
virtual void print3() {}
};
class B_CLASS
{
public:
int b;
virtual void print2() {std::cout << "invoke B_CLASS virtual function::printf" << std::endl;}
virtual void print4() {};
};
class C_CLASS :public A_CLASS,public B_CLASS
{
public:
int c;
virtual void print1() override { std::cout << "invoke B_CLASS virtual function::printf" << std::endl; }
virtual void print4() {};
virtual void print5() {};
};
C_CLASS同时继承了A_CLASS和B_CLASS,那虚函数表是怎么的呢?
对于多继承的类,虚函数表的规则如下:
继承了几个类,就有几个虚函数表
A_CLASS虚函数表指针--8字节
int a--4字节,对齐到8字节
B_CLASS虚函数表指针--8字节
int b--4字节,对齐到8字节
int c--4字节,对齐到8字节
B_CLASS虚函数表指针
这个位置。编译器通过这样的调整,让所有状态的类实例具有统一的调用方式。
基类如果包含虚函数通常都应该定义一个虚析构函数,即使该函数不执行任何操作也是如此
比如C_CLASS继承的A_CLASS,如果我们没有将A_CLASS的析构函数设置为虚函数的话,我们现在在派生类C_CLASS的某个方法分配了一块内存,在C_CLASS的析构函数中进行的释放,这没有问题。但是我们接着进行下面的操作:
A_CLASS* a = new C_CLASS();
a->b();//分配了一块内存
delete a;
那么我们通过delete a的方式释放内存的时候,不会调用派生类C_CLASS的析构函数,因为不是虚函数,也就不会动态绑定,执行静态绑定会调用A_CLASS的析构函数,这样,之前分配的内存就泄漏了。
如果一个基类定义的虚函数返回值是自身的引用或者指针,派生类重写虚函数的时候返回值可以是派生类自身的引用或指针。
如果某次虚函数的调用使用了默认实参,则该实参的值由对象的静态类型决定。一般对于这种情况,基类和派生类的默认值设置成一样。
为什么会这样呢?
其实通过前面的分析,这一点已经很明确了,还记得前面的虚函数调用代码吗?
00007FF719731E7A mov rax,qword ptr [c]
00007FF719731E7E mov rax,qword ptr [rax]
00007FF719731E81 mov rcx,qword ptr [c]
00007FF719731E85 call qword ptr [rax+8]
我们说过,动态绑定只发生在虚函数表指针那里。编译器在编译的时候,已经准备好传递参数了,如果是默认实参,会把该实参的默认值传递到寄存器或者栈空间。但是这个时候是不知道具体的实际类型的,只能把当前的静态类型的值传递过来。
如果我们想要在派生类的虚函数中调用基类的虚函数,可以使用作用域运算符实现
,否则将变成无限递归
如果我们在一个虚函数的声明结尾添加=0,那么这个虚函数会被定义为纯虚函数,纯虚函数有以下特点:
一个对象,引用或者指针的静态类型决定了该对象哪些成员是可见的,当然也包括哪些虚函数是可调用的