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

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

可用虚函数

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

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

如若内容造成侵权/违法违规/事实不符,请联系编程知识网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

Jmeter接口自动化03-JMeter的常用核心组件

p03 高清B站视频链接 由于JMeter涉及的组件数目很多&#xff0c;据不完全统计至少有110个&#xff0c;而其实只需要掌握20%的组件就可以完成80%甚至更多的日常工作了&#xff0c;所以接下来我们重点剖析使用最频繁的核心组件&#xff0c;如下图所示。只需要优先掌握这10个左右…

双指针问题——求只包含两个元素的最长连续子序列(子数组)

一&#xff0c;题目描述 你正在探访一家农场&#xff0c;农场从左到右种植了一排果树。这些树用一个整数数组 fruits 表示&#xff0c;其中 fruits[i] 是第 i 棵树上的水果 种类 。 你想要尽可能多地收集水果。然而&#xff0c;农场的主人设定了一些严格的规矩&#xff0c;你必…

展望2024:9大要点把握PLM软件趋势,云PLM领导者Arena

2023年《质量强国建设纲要》&#xff08;以下简称《纲要》&#xff09;的推出&#xff0c;再次确定了中国要走上制造业高质量发展之路的决心。《纲要》指出要深入实施质量强国战略&#xff0c;加快传统制造业技术迭代和质量升级&#xff0c;推动工业品质量迈向中高端。当前&…

基于 IDEA 创建 Maven 的 Java SE 工程和 Java Web 工程

一、概念简介 Maven 工程相对之前的项目&#xff0c;多出一组 gavp 属性&#xff0c;gav 需要我们在创建项目的时候指定&#xff0c;p 有默认值&#xff0c;我们先行了解下这组属性的含义。 Maven 中的 GAVP 是指 GroupId、ArtifactId、Version、Packaging 等四个属性的缩写&am…

SpringSecurity入门demo(一)集成与默认认证

一、集成与默认认证&#xff1a; 1、说明&#xff1a;在引入 Spring Security 项目之后&#xff0c;没有进行任何相关的配置或编码的情况下&#xff0c;Spring Security 有一个默认的运行状态&#xff0c;要求在经过 HTTP 基本认证后才能访问对应的 URL 资源&#xff0c;其默认…

Swin Transformer 学习笔记(附代码)

论文地址&#xff1a;https://arxiv.org/pdf/2103.14030.pdf 代码地址&#xff1a; GitHub - microsoft/Swin-Transformer: This is an official implementation for "Swin Transformer: Hierarchical Vision Transformer using Shifted Windows". 1.是什么&#x…

Mariadb和mysql数据库的区别和相同之处

目 录 一、maridb 和mysql在linux系统中广泛应用 二、MySQL数据库 三、MariaDB数据库 四、MariaDB和MySQL有哪些相同点 五、MariaDB和MySQL的不同点 一、mariadb 和mysql在linux系统中广泛应用 用linux&#xff08;包括centos和Ubuntu&#xff09;的都知道&a…

打通商城与ERP系统,实现物料自动同步

【客户介绍】 某文化传播有限公司是一家专注于为企业提供品牌营销、公关传播、活动策划、数字营销等服务的综合性文化传播公司。该公司拥有一支经验丰富的专业团队&#xff0c;具有丰富的品牌营销、公关传播、活动策划、数字营销经验。该公司自成立以来&#xff0c;已经为数百…

【服务器】服务器管理 - cockpit开启

开启cockpit #!/bin/bashsed -i s/is():where()/is(*):where(*)/ /usr/share/cockpit/static/login.jssystemctl enable --now cockpit.socket #开启cockpit服务systemctl start cockpit.socket 登录 https://ip:9090

2. Presto应用

该笔记来源于网络&#xff0c;仅用于搜索学习&#xff0c;不保证所有内容正确。文章目录 1、Presto安装使用2、事件分析3、漏斗分析4、漏斗分析UDAF开发开发UDF插件开发UDAF插件 5、漏斗测试 1、Presto安装使用 参考官方文档&#xff1a;https://prestodb.io/docs/current/ P…

苍穹外卖学习----出错记录

1.微信开发者工具遇到的问题&#xff1a; 1.1appid消失报错&#xff1a; {errMsg: login:fail 系统错误,错误码:41002,appid missing [20240112 16:44:02][undefined]} 1.2解决方式&#xff1a; appid可在微信开发者官网 登录账号后在开发栏 找到 复制后按以下步骤粘贴即…

H264码流进行RTP包封装

一.H264基本概念 H.264从框架结构上分为视频编码层&#xff08;VCL&#xff09;和网络抽象层&#xff08;NAL&#xff09;&#xff0c;VCL功能是进行视频编解码&#xff0c;包括运动补偿预测&#xff0c;变换编码和熵编码等功能&#xff1b;NAL用于采用适当的格式对VCL视频数据…