C++中的虚函数

前言

本篇文章讲述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,那么这个虚函数会被定义为纯虚函数,纯虚函数有以下特点:

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

可用虚函数

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

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:/a/303978.html

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

[计算机提升] 创建和管理一般共享文件夹

4.6 创建和管理一般共享文件夹 4.6.1 创建一般共享文件夹 通过创建共享文件夹&#xff0c;可以让多台计算机在同一网络上共享文件和文件夹。这对于团队协作、家庭用户共享文件和资源非常方便。方式如下&#xff1a; 1、选中要共享的文件夹&#xff0c;然后右键属性&#xff0…

软件测试大作业||测试计划+测试用例+性能用例+自动化用例+测试报告

xxx学院 2023—2024 学年度第二学期期末考试 《软件测试》&#xff08;A&#xff09;试题&#xff08;开卷&#xff09; 题目&#xff1a;以某一 web 系统为测试对象&#xff0c;完成以下文档的编写&#xff1a; &#xff08;满分 100 分&#xff09; &#xff08;1&am…

接雨水(Leetcode42)

例题&#xff1a; 题目说了&#xff0c;给定n个宽度为1的柱子&#xff0c;height数组中的每个元素表示每个柱子的高度。 只要柱子之间存在凹槽&#xff0c;就能接住雨水。 在解决这道题目之前&#xff0c;我们先了解一下单调递减栈。&#xff08;由栈底到栈顶逐渐递减&#xff…

项目知识—SSM及之后02

1、resultMap写的Base内容必须保证select都使用上 2、VALUE单个 &#xff0c;VALUES多个 3、一对多&#xff0c;两张表&#xff0c;多的表加外键 比如班级和学生就是一对多&#xff0c;查就是按照学生表去查询 多对多&#xff0c;三张表&#xff0c;关系表加外键 4、数据库…

yolov8实战第五天——yolov8+ffmpeg实时视频流检测并进行实时推流——(推流,保姆教学)

yolov8实战第一天——yolov8部署并训练自己的数据集&#xff08;保姆式教程&#xff09;_yolov8训练自己的数据集-CSDN博客 yolov8实战第三天——yolov8TensorRT部署&#xff08;python推理&#xff09;&#xff08;保姆教学&#xff09;-CSDN博客 今天&#xff0c;我们继续y…

深度解析 Compose 的 Modifier 原理 -- DrawModifier

其实原理性分析的文章&#xff0c;真的很难讲的通俗易懂&#xff0c;讲的简单了就没必要写了&#xff0c;讲的繁琐难懂往往大家也不乐意看&#xff0c;所以只能尽量想办法&#xff0c;找个好的角度&#xff08;比如从 Demo 代码示例出发&#xff09;慢慢带着大家去钻源码&#…

Linux查看物理CPU个数、核数、逻辑CPU个数

文章目录 总核数总逻辑CPU数查看物理CPU个数查看每个物理CPU中core的个数(即核数)查看逻辑CPU的个数 总核数 总核数 物理CPU个数 X 每颗物理CPU的核数 总逻辑CPU数 总逻辑CPU数 物理CPU个数 X 每颗物理CPU的核数 X 超线程数 查看物理CPU个数 cat /proc/cpuinfo| grep “…

湖南大学-数据库系统-2015期末考试解析

【写在前面】 这是2015年的卷子&#xff0c;应该是我能找到最老的一张了&#xff0c;遂做了并与同学校对了答案。答案仅供参考。这张难度不大&#xff0c;都是基础题。 一.单选题&#xff08;每题 2 分&#xff0c;共 20 分&#xff09; 1、在数据库中&#xff0c;下列说法&a…

企业工商基本信息API:一站式掌握企业核心数据

引言 在当今快速发展的商业环境中&#xff0c;了解企业的基本信息是每个业务决策者的基本需求。然而&#xff0c;手动收集和处理这些信息既耗时又容易出错。企业工商基本信息查询API的出现&#xff0c;为企业提供了一个高效、准确的一站式解决方案。 企业工商基本信息API 企…

win10录音功能大盘点,帮你轻松搞定录音

“有人知道win10系统怎么录音吗&#xff1f;在网上找到了一段英语听力&#xff0c;本来打算保存下来&#xff0c;但是发现不能下载&#xff0c;我也不会使用电脑录音&#xff0c;真的很头疼&#xff0c;有人能帮帮我吗。” 在Windows 10系统中&#xff0c;录音是一项常见但往往…

PPT插件-布局参考-增加便携尺寸功能

PPT自带的尺寸为很久的尺寸&#xff0c;很多尺寸不常用&#xff0c;这里增加一些画册尺寸&#xff0c;用于PPT排版设计。 软件介绍 PPT大珩助手是一款全新设计的Office PPT插件&#xff0c;它是一款功能强大且实用的PPT辅助工具&#xff0c;支持Wps Word和Office Word&#x…

Python——字符串的拼接

print("某某程序员" "月薪过万") name "吱昂张程序员" address "**大学" tel 19819208830 print("我是:"name"我的地址在&#xff1a;"address)#通过占位的形式完成字符串换的拼接 name"吱昂张" me…

【项目实战】Cadence工具的使用2

代码覆盖率的收集 双击total&#xff0c;打开imc工具。total 下的文件是代码覆盖率文件 找到DUT模块&#xff01;从图中可以看到代码的覆盖率已经是94.43% 添加exclude文件&#xff0c;注意和Synopsys的后缀不同。 导入.vRefine文件 代码覆盖率为100%。 原因是我们添加了exclu…

VS2022 | 调整适配虚幻5的设置

VS2022 | 调整适配虚幻5的设置

Spring学习 Spring事务控制

7.1.事务介绍 7.1.1.什么是事务&#xff1f; 当你需要一次执行多条SQL语句时&#xff0c;可以使用事务。通俗一点说&#xff0c;如果这几条SQL语句全部执行成功&#xff0c;则才对数据库进行一次更新&#xff0c;如果有一条SQL语句执行失败&#xff0c;则这几条SQL语句全部不…

工业自动化中RFID标签的应用案例

RFID标签是实现RFID数据采集的重要载体&#xff0c;利用RFID标签&#xff0c;可以将所有产品的信息写入标签中&#xff0c;大部分的RFID标签都以不干胶标签的形式使用&#xff0c;只需要在物品包装上贴RFID标签就可以。下面我们就一起来了解一下&#xff0c;工业自动化中RFID标…

编程代码设计GUI界面

前情提要 GUI界面有元件拖动和编程代码两种设计方式&#xff0c;元件拖动比较直观&#xff0c;编程代码更加细致。本来搞了一个包含各种元件的项目&#xff0c;最后发现代码比较长&#xff0c;一下子扔出来对初学者非常不友好&#xff0c;所以我们分开一段一段来添加&#xff…

Eureka注册中心Eureka提供者与消费者,Eureka原理分析,创建EurekaServer和注册user-service

提示&#xff1a;文章写完后&#xff0c;目录可以自动生成&#xff0c;如何生成可参考右边的帮助文档 文章目录 前言一、Eureka提供者与消费者二、Eureka原理分析eurekaeureka的作用eureka总结 三、创建EurekaServer和注册user-service创建EurekaServer总结 服务的拉取总结-Eur…

Adding Conditional Control to Text-to-Image Diffusion Models——【论文笔记】

本文发表于ICCV2023 论文地址&#xff1a;ICCV 2023 Open Access Repository (thecvf.com) 官方实现代码&#xff1a;lllyasviel/ControlNet: Let us control diffusion models! (github.com) Abstract 论文提出了一种神经网络架构ControlNet,可以将空间条件控制添加到大型…

Spark---RDD(双值类型转换算子)

文章目录 1.RDD双值类型算子1.1 intersection1.2 union1.3 subtract1.4 zip 1.RDD双值类型算子 RDD双Value算子就是对两个RDD进行操作或行动&#xff0c;生成一个新的RDD。 1.1 intersection 对源 RDD 和参数 RDD 求交集后返回一个新的 RDD 函数定义&#xff1a; def inters…