C++中的虚函数

2024-01-08 19:01:05

前言

本篇文章讲述C++的虚函数

定义

在C++语言中,基类将类型相关的函数和派生类不做改变直接继承的函数区分开来。对于有些函数,基类希望派生类各自定义适合自身的版本。那么基类就会将这些函数标记为virtual,这些被标记的函数就是虚函数。
下面这就是一个虚函数在代码中的定义,和普通的函数一样,只不过前面添加了关键字virtual

class A_CLASS
{
public:
    virtual void print() {std::cout << "invoke A_CLASS virtual function::printf" << std::endl;}
};

**如果派生类想要重新定义虚函数,派生类需要在自己的类中重新声明虚函数。**声明的时候需要注意两点:

  • 可以在前面添加virtual关键字,也可以不添加,建议添加
  • 可以在函数声明的结尾添加override关键字,也可以添加,建议添加
  • virtual只能出现在类内部的函数声明之前而不能用于类外部的函数定义
  • 如果一个基类把函数声明为虚函数,则在派生类中该函数默认也是虚函数

先看第一条,为什么建议添加,在我们阅读代码的时候,明确一个函数是不是虚函数对我们理解代码结构很有帮助,尤其是类层级变多以后,这条只是从提高代码的可读性角度来看。

对于第二条,我们先看下面的代码:

#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++语言中,当我们使用基类的引用或者指针调用一个虚函数时将发生动态绑定。动态绑定是多态得以实现的基础

动态绑定的原理

知道了动态绑定和静态绑定的定义,现在我们来研究一下动态绑定的实现原理
我们知道,一个函数在内存中其实是一系列的字节数据,用汇编表示就是一系列的汇编指令,我们执行一个函数的步骤如下:

  • 将函数需要的参数传递给寄存器或者栈空间
  • 然后使用call指令跳转到函数的内存地址
  • 然后开始执行函数
  • 执行函数后使用ret指令返回执行前的位置

知道函数的执行步骤,我们看一下一个类的虚函数的特点

  • 首先,虚函数的实现代码在内存是已知的,这点跟普通的函数是一样的
  • 然后,虚函数的参数是已知的,这样编译器可以提前传递参数数据
  • 最后,就剩下函数的跳转了,这也是多态实现的最重要的地方

一个类,如果有虚函数存在的话,编译器会为这个类分配一块内存,专门用来放虚函数实现代码在内存的位置,你可以把这块内存理解为指针的数组。这块内存被称为虚函数表,简称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]

我们分析一下这四行代码:

  1. 将c指针的值传递给rax,c指针的值就是B_CLASS类实例在内存的位置,我们从监视窗口看到了,值为0x00000171286a23f0,这块内存目前保存了虚函数表的位置,我们可以在内存窗口输入0x00000171286a23f0查询一下,结果如图,跟监视窗口的虚函数表指针是一样的:
    在这里插入图片描述

  2. 将rax地址中保存的值赋值给rax,也就是经过这一步,rax保存的值变成了虚函数表在内存的位置,经过这一步rax的值由0x00000171286a23f0变为00007FF71973BC80

  3. 将c指针的值传递给rcx,这一步是因为我们调用虚函数的时候需要传递默认参数this,这个默认参数是第一个参数,保存在寄存器rcx中,因为我们的虚函数没有别的参数了,所以这里就传递这一个值。

  4. 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)

可以看到,没有取地址的操作,就两步:

  • 获取this给rcx
  • 调用

静态的函数调用确实比动态调用效率高,但是失去了动态调用的多样性。

虚函数表

这一小节,我们讲一下虚函数表中虚函数的排列,其实从上一节已经看到了,虚函数调用时,在虚函数表中的偏移是个常量,也就是说在编译阶段,编译器已经确定了虚函数在虚函数表中的偏移位置
既然虚函数的位置在虚函数表中是静态的,那么在类继承的关系层次中,虚函数的布局就是明确的。看下面的例子:

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* b = new C_CLASS();编译器会对b的指针进行调整,使其指向
    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,那么这个虚函数会被定义为纯虚函数,纯虚函数有以下特点:

  • 纯虚函数所在的类被称为抽象类,抽象类不能实例化
  • 如果继承抽象类的派生类没有重新实现虚函数并且取消定义为纯虚函数,该派生类还是抽象类。

可用虚函数

一个对象,引用或者指针的静态类型决定了该对象哪些成员是可见的,当然也包括哪些虚函数是可调用的

文章来源:https://blog.csdn.net/b1049112625/article/details/135462167
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。