C++深入之虚函数、虚继承与带虚函数的多基派生问题

基础

在讲解带虚函数的多基派生问题时,我们要先弄清楚不带虚函数的多基派生存在什么样的问题,这样才好弄明白带虚函数的多基派生问题。

多基派生的二义性问题

一般来说,在派生类中对基类成员的访问应当具有唯一性,但在多基继承时,如果多个基类中存在同名
成员的情况,造成编译器无从判断具体要访问的哪个基类中的成员,则称为对基类成员访问的二义性问
题。如下面的例子,我们先定义3个不同的类A、B、C,这3个类中都有一个同名成员函数print,然后让
类D继承自A、B、C,则当创建D的对象d,用d调用成员函数print时,发生编译错误。

示例代码:

class A{
public:void print(){cout << "A::print()" << endl;}
};class B{
public:void print(){cout << "B::print()" << endl;}
};class C{
public:void print(){cout << "C::print()" << endl;}
};class D
: public A
, public B
, public C
{};void test(){D d;d.print();//error,编译器无法判断其调用的是哪个基类中的print函数//解决办法:加作用域限定符d.A::print();//okd.B::print();//okd.C::print();//ok
}

有了这个前提之后再来看带上虚函数的多基派生问题的分析。

带虚函数的多基派生问题分析

先来看一段代码:

#include <iostream>using namespace std;//A类拥有三个虚函数:a、b、c
class A{
public:virtualvoid a(){cout << "A::a()" << endl;}virtualvoid b(){cout << "A::b()" << endl;}virtualvoid c(){cout << "A::c()" << endl;}
};//B类用有两个虚函数a和b,两个非虚函数c和d
class B{
public:virtualvoid a(){cout << "B::a()" << endl;}virtualvoid b(){cout << "B::b()" << endl;}void c(){cout << "B::c()" << endl;}void d(){cout << "B::d()" << endl;}
};//类C继承A和B,其有一个虚函数a,两个非虚函数c和d
class C: public A,public B{
public:virtualvoid a(){cout << "C::a()" << endl;}void c(){cout << "C::c()" << endl;}void d(){cout << "C::d()" << endl;}
};void test(){C c; //栈对象A *pa = &c;//问题出现了:下面这三句代码,执行的是类A中的函数还是类C中的函数呢?pa->a();//这个毫无疑问,是多态机制,因为C重写了虚函数a,所以是类C中的函数apa->b();//派生类没有重写虚函数b的话,那么调用的虚函数b就是属于基类型类A中的函数b/*对于函数c而言,比较难判断,因为A中的c函数是虚函数,而B中的c函数是非虚函数* 且类C同时继承了类A和类B,意思就是类C同时有一份虚函数c和一份非虚函数c* 这里其实调用的是类C中的c函数,因为它既没有重写A中的虚函数,又隐藏了B中的非虚函数* 那不就只能调用到C中的c函数了*/pa->c();cout << endl;B* pb = &c;pb->a();//调用的是类C中的函数a,因为重写了虚函数apb->b();//调用的是类B中的函数b,因为派生类C没有重写该虚函数pb->c();//因为函数c并非虚函数,所以调用的还是基类B中的函数bpb->d();//同上cout << endl;C* pc = &c;pc->a();//调用的是类C中的函数a//pc->b(); error/*这里的b函数会产生二义性,因为一份是类A中的b函数一份是类B中的b函数* 产生了二义性,说明带虚函数的多基继承依然存在二义性问题,所以报错* 正确写法依然还是像之前说的,使用作用域限定符即可,如下* */pc->A::b();pc->B::b();/*这里的函数c也很难判断,因为类A中的是虚函数,类B中的是却是非虚函数* 同时类C当中也有一个非虚函数c,这里直接无脑是类C中的非虚函数c就行了* 这里的情况和上面pa->c()是一样的,类C中的c函数既没有重写A中的虚函数* 又隐藏了类B中的非虚函数,那就只能调用到类C中的c函数本身了*/pc->c();pc->d();//类B中的d函数直接被隐藏了,所以这里是类C中的d函数
}int main(){test();return 0;
}

分析的情况都在代码的注释中了,请好好研读。

运行结果:
在这里插入图片描述
从图中可以看出,结果验证了我们的猜想。

从内存布局的层面进行分析

虚函数的底层实现

简单来说,就是通过一张虚函数表(Virtual Fucntion Table)实现的。具体地讲,当类中定义了一个虚函数后,会在该类创建的对象的存储布局的开始位置多一个虚函数指针(vfptr),该虚函数指针指向了一张虚函数表,而该虚函数表就像一个数组,表中存放的就是各虚函数的入口地址。如下图

在这里插入图片描述

当一个基类中设有虚函数,而一个派生类继承了该基类,并对虚函数进行了重定义,我们称之为覆盖(override). 这里的覆盖指的是派生类的虚函数表中相应虚函数的入口地址被覆盖。

那么虚函数机制是如何被激活的呢,或者说动态多态是怎么表现出来的呢?从上面的例子,可以得出结论:

  1. 基类定义虚函数
  2. 派生类重定义(覆盖、重写)虚函数
  3. 创建派生类对象
  4. 基类的指针指向派生类对象
  5. 基类指针调用虚函数

多基派生的底层实现

从代码可以知道,我们先讨论三个类各自的情况,先不谈多基继承的问题:

类A有三个虚函数a、b和c,对应上图,因为类A有虚函数,所以其会产生一张虚函数表,里面存放的是类A的三个虚函数的入口地址,而类A的内存地址空间的第一个位置存放虚函数表指针,其指向该表。

类B有三个两个虚函数a和b,因此类B也会具有一个虚函数表,其内存放的是类B的两个虚函数的入口地址,虚函数指针vfptr指向其虚表。

而类C也有一个虚函数a,因此其也会有一张虚表,存放其虚函数的入口地址,情况如下:
在这里插入图片描述

没问题吧?我们继续往后讨论,现在我们加入代码中继承的情况,即类C继承了类A和类B的情况:

在类C的内存地址空间中,因为继承关系,类C会继承得到类A中的三个虚函数和类B中的两个虚函数以及两个非虚函数(即继承得到类A和类B的两张虚表),因此地址开头的两块空间被用来存放了类A和类B的虚函数指针。所以从图中可以看到,因为类C重写了虚函数a,所以其覆盖了基类A中的虚函数a的地址,而C中并重未写函数b,所以在类A的虚表中函数b还是属于类A的(即调用的是类A中的函数b),函数c则因为没有被类C重写因此是属于类C的(调用都是类C的函数c)。

同理在类B的虚函数表中,类C重写了函数a,因此覆盖了类B中虚表里虚函数a的入口地址,所以调用的是类C的函数c,而b函数没被重写,因此依然属于类B。
在这里插入图片描述

这样的分析要比之前的代码中的注释应该要好理解一些吧。

这就是多基派生的底层实现原理。

番外:虚拟继承

两个概念:

虚拟继承是指在继承定义中包含了virtual关键字的继承关系。虚基类是指在虚继承体系中的通过virtual
继承而来的基类。

语法格式如下:

class Baseclass;
class Subclass
: public/private/protected virtual Baseclass{
public://...
private://...
protected://...
};
//其中Baseclass称之为Subclass的虚基类, 而不是说Baseclass就是虚基类

来举个例子加以说明,先来看一段代码,代码逻辑是类C继承了类B,类B继承了类A,三个类各自有一个成员变量,在main函数中初始化类C对象然后传入了三个值:

#include <ctime>
#include <iostream>using namespace std;class A{public:A(){cout << "A()" << endl;}A(int ia):_ia(ia){cout << "A(int)" << endl;}protected:int _ia;
};class B: public A{public:B(){cout << "B()" << endl;}B(int ia,int ib):A(ia),_ib(ib){cout <<  "B(int,int)" << endl;}protected:int _ib;
};class C:public B{public:C(){cout << "C()" << endl;}C(int ia,int ib,int ic):B(ia,ib),_ic(ic){cout << "C(int,int,int)" << endl;}void show() const{cout << " ia = " << _ia << endl<< "ib = " << _ib << endl<< "ic = " << _ic << endl;}protected:int _ic;
};int main(){C c(10,20,30);c.show();return 0;
}

其运行结果:
在这里插入图片描述
当我们将类B继承类A改成虚拟继承时:

class B: public virtual A{public:B(){cout << "B()" << endl;}B(int ia,int ib):A(ia),_ib(ib){cout <<  "B(int,int)" << endl;}protected:int _ib;
};

此时运行结果截然不同:
在这里插入图片描述
可以看到ia变成了一个随机值,为什么?

细心对比的话,我们可以发现第一次调用时构造函数调用的是有参构造函数A(int),而第二次调用的则是无参构造函数A();

我们明明显式调用了A(int),却在使用了虚拟继承之后就成调用无参构造函数了(相当于没调用到A(int)),从这一点可以看出,派生类B并不负责虚基类A的数据成员的初始化。

那么谁来初始化虚基类A的数据成员呢?我们来将类C进行改写:

class C:public B{public:C(){cout << "C()" << endl;}//改写位置C(int ia,int ib,int ic):A(ia),B(ia,ib),_ic(ic){cout << "C(int,int,int)" << endl;}void show() const{cout << " ia = " << _ia << endl<< "ib = " << _ib << endl<< "ic = " << _ic << endl;}protected:int _ic;
};

运行结果如下:
在这里插入图片描述
可以发现,虚基类A成员变量的初始化是由继承体系中的最后一个类来负责初始化的。

为什么?

在 C++ 中,如果继承链上存在虚继承的基类,则最底层的子类要负责完成该虚基类部分成员的构造。即我们需要显式调用虚基类的构造函数来完成初始化,如果不显式调用,则编译器会调用虚基类的缺省构造函数,不管初始化列表中次序如何,对虚基类构造函数的调用总是先于普通基类的构造函数。如果虚基类中没有定义的缺省构造函数,则会编译错误。因为如果不这样做,虚基类部分会在存在的多个继承链上被多次初始化。很多时候,对于继承链上的中间类,我们也会在其构造函数中显式调用虚基类的构造函数,因为一旦有人要创建这些中间类的对象,我们要保证它们能够得到正确的初始化。

菱形继承问题

在C++中,菱形继承是指一个类同时继承自两个不同的类,而这两个类又都继承自同一个基类。这种继承结构形成一个菱形的图形,导致了一些潜在的问题,其中最主要的问题是"菱形继承"问题(Diamond Inheritance Problem)。

问题的本质在于,如果不使用虚继承(virtual inheritance),最终派生类会包含两份相同的基类(共享的基类会被重复继承),导致数据冗余和访问冲突。

使用virtual关键字可以解决这个问题,即在派生类对共同的基类使用虚继承。虚继承的作用是确保只有一份共同的基类子对象,而不会出现重复。这样,菱形继承结构中的最终派生类只包含一份共同的基类,从而解决了数据冗余和访问冲突的问题。

代码示例:

class Base {
public:// ...
};class Derived1 : public virtual Base {
public:// ...
};class Derived2 : public virtual Base {
public:// ...
};class FinalDerived : public Derived1, public Derived2 {
public:// ...
};

在这个示例中,Derived1和Derived2都使用了virtual继承自Base类。这确保了FinalDerived最终只包含一份Base类的实例,从而解决了菱形继承问题。

菱形继承深入

虚继承主要解决的是共享基类时的数据冗余问题,而不是成员函数的冲突问题。

让我们更深入地理解为什么使用virtual关键字可以解决数据冗余的问题。

在C++中,当一个类使用虚继承时,基类的子对象在派生类中只会有一份实例,而不是像普通继承那样每次都有一份。这是通过在派生类对象中引入虚指针(vpointer)和虚表(vtable)的机制来实现的(参考前文的讲述内存布局的部分)。

让我们看一下使用虚继承的例子:

class Base {
public:int data;
};class Derived1 : public virtual Base {// ...
};class Derived2 : public virtual Base {// ...
};class FinalDerived : public Derived1, public Derived2 {// ...
};

在这个例子中,Derived1 和 Derived2 都使用了虚继承,因此它们共享一个虚表和虚指针,指向共同的 Base 子对象。当 FinalDerived 继承这两个虚基类时,由于它们共享相同的虚表和虚指针,最终 FinalDerived 中只包含一份 Base 类的子对象,而不是两份。

如果没有使用虚继承,FinalDerived 将分别继承 Derived1 和 Derived2 中的 Base 类子对象,导致 FinalDerived 中包含两份 Base 类的数据成员,造成了数据冗余。

虚继承的实现涉及到额外的内存结构,包括虚指针和虚表。这些机制确保了在派生类中只有一份共享的基类子对象,从而解决了数据冗余的问题。

关于菱形继承的一点疑惑

上面的深入部分我是问的GPT回答的,但我感觉不太对劲,因为在没有如果基类并不存在虚函数的话,那么虚函数表应该不会存在啊(学艺不精,等俺后面对这些概念更加清晰了再回来补这个坑)…

在有虚函数的基类中用上面的虚函数表理论比较好懂,但是在没有虚函数的基类中我觉得用虚基表的存在来解释这个更好理解:

在C++中,虚基表(Virtual Table,简称vtable)是用于支持多态性(polymorphism)和虚函数(virtual function)的一种机制。虚基表是针对包含虚函数的类层次结构而言的。

在C++中,当一个类包含至少一个虚函数时,编译器会为该类创建一个虚函数表(vtable)。虚函数表是一个数组,其中存储了指向每个虚函数的指针。当一个类派生自另一个类,而这两个类都包含虚函数时,派生类会继承基类的虚函数表,并在其自己的虚函数表中添加新的虚函数或覆盖基类的虚函数。

虚基表(virtual base table)是为了解决C++中的菱形继承问题而引入的。菱形继承指的是一个类同时继承自两个不同路径上的同一个基类,导致基类的实例在派生类中存在多份拷贝。为了解决这个问题,C++引入了虚基类(virtual base class)和虚基表。

虚基表的作用是为了跟踪虚基类的偏移量,确保在派生类中正确访问虚基类的成员。当一个类包含虚基类时,它的虚函数表中会包含一个指向虚基表的指针,虚基表中记录了虚基类的偏移量信息。这样,通过虚基表,派生类可以正确访问基类的成员,避免了菱形继承问题带来的二义性和数据冗余。

总的来说,虚基表是为了支持多继承和解决菱形继承问题而引入的,通过虚基表,C++能够正确地处理包含虚函数和虚基类的类层次结构。

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

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

相关文章

Docker(二)安装指南:主要介绍在 Linux 、Windows 10 和 macOS 上的安装

作者主页&#xff1a; 正函数的个人主页 文章收录专栏&#xff1a; Docker 欢迎大家点赞 &#x1f44d; 收藏 ⭐ 加关注哦&#xff01; 安装 Docker Docker 分为 stable test 和 nightly 三个更新频道。 官方网站上有各种环境下的 安装指南&#xff0c;这里主要介绍 Docker 在…

DAZ to maxon 实时面捕52个blendshapes 表情模板基本形中英文对照表

一、转自&#xff1a; DAZ to maxon 实时面捕52个blendshapes 表情模板基本形中英文对照表 - 哔哩哔哩 很多学员反映实时表情怎么就不同步呢&#xff1f;这个问题其实很常见。 第一&#xff1a;表情模板的顺序弄错&#xff0c;导致表情错乱。 第二&#xff1a;表情模板不标准…

AbstractHttpMessageConverter + easyexcell优雅下载附件

介绍 AbstractHttpMessageConverter 是 Spring 框架中用于处理 HTTP 消息转换的抽象基类。它用于处理来自 HTTP 请求的消息,并将其转换为特定的 Java 对象,或者将 Java 对象转换为 HTTP 响应消息。 这个抽象类允许开发人员创建自定义的 HTTP 消息转换器,以便在 Spring MVC…

职务岗位的概念澄清及应用

背景 现在的企业数字化平台中&#xff0c;有一些术语组织管理中的术语&#xff0c;理解上很有歧义&#xff0c;并且命名和应用上简直五花八门&#xff0c;洋相百出&#xff0c;比如&#xff0c;我们的大厂&#xff0c;就把角色这次&#xff0c;可以作为分类、分组的标签就能大…

2024-01-15(SpringMVCMybatis)

1.拦截器&#xff1a;如果我们想在多个handler方法(controller中的方法)执行之前或者之后都进行一些处理&#xff0c;甚至某些情况下需要拦截掉&#xff0c;不让handler方法执行&#xff0c;那么就可以使用SpringMVC为我们提供的拦截器。 拦截器和过滤器的区别&#xff1a;过滤…

基于内容的图像web检索系统

题目&#xff1a;基于内容的图像在线检索系统 简介&#xff1a;基于内容的图像在线检索系统&#xff08;Content Based Online Image Retrieval , 以下简称 CBOIR&#xff09;&#xff0c;是计算机视觉领域中关注大规模数字图像内容检索的研究分支。典型的CBOIR系统&#xff…

分布式事务Seata实战-AT模式(注册中心为Eureka)

大致记录Seata的AT模式下创建项目过程中需要注意的点和可能遇到的问题。 本项目是以官网的给的示例&#xff08;即下图&#xff09;进行创建的&#xff0c;以Eureka为注册中心。 官网&#xff1a;Seata AT 模式 | Apache Seata™ 官方代码示例&#xff1a; 快速启动 | Apac…

算法笔记(动态规划入门题)

1.找零钱 int coinChange(int* coins, int coinsSize, int amount) {int dp[amount 1];memset(dp,-1,sizeof(dp));dp[0] 0;for (int i 1; i < amount; i)for (int j 0; j < coinsSize; j)if (coins[j] < i && dp[i - coins[j]] ! -1)if (dp[i] -1 || dp[…

Doris配置外表以及多个Hive外表的配置

1.场景分析 以Clickhouse、Doris、Starrocks等为代表的mpp分析数据库正在快速的兴起&#xff0c;以其高效查询、跨库整合能力收到广大技术人员的喜爱。本文主要浅显介绍下作者在使用Doris时&#xff0c;通过建立catlog进行跨库查询。 废话不多少&#xff0c;直接上代码 2.相关…

力扣211. 添加与搜索单词 - 数据结构设计

字典树 思路&#xff1a; 设计一棵字典树&#xff0c;每个节点存放单词的一个字符&#xff0c;节点放一个标记位&#xff0c;如果是单词结束则标记&#xff1b;字典树插入&#xff1a; 字典树默认有 26 个 slot 槽代表 a - z&#xff1b;遍历单词&#xff0c;如果字符对应槽存…

Python自动化实战之接口请求的实现

在前文说过&#xff0c;如果想要更好的做接口测试&#xff0c;我们要利用自己的代码基础与代码优势&#xff0c;所以该章节不会再介绍商业化的、通用的接口测试工具&#xff0c;重点介绍如何通过 python 编码来实现我们的接口测试以及通过 Pycharm 的实际应用编写一个简单接口测…

『Open3D』1.10 Tensor数据处理

open3d中实现了自身的数据类型,用于open3d中内部算法的数值计算,但基础使用上与numpy类似。 目录 1、tensor创建 2、tensor数据属性 3、 Tensor数据在CPU与GPU上的转换

Linux/Traceback

Enumeration nmap 使用nmap初步扫描发现只开放了22和80端口&#xff0c;端口详细扫描情况如下 先看看web是什么样子的&#xff0c;打开网站发现有一条留言&#xff0c;显示该站点已经被黑了&#xff0c; 并且留下了后门 查看源代码&#xff0c;可以看到下面的注释 <!--So…

Docker中创建并配置MySQL、nginx、redis等容器

Docker中安装并配置MySQL、nginx、redis等 文章目录 Docker中安装并配置MySQL、nginx、redis等一、创建nginx容器①&#xff1a;拉取镜像②&#xff1a;运行nginx镜像③&#xff1a;从nginx容器中映射nginx配置文件到本地④&#xff1a;重启nginx并重新配置nginx的挂载 二、创建…

LabVIEW精确测量产品中按键力和行程

项目背景 传统的按键测试方法涉及手工操作&#xff0c;导致不一致和效率低下。在汽车行业中&#xff0c;带有实体按键的控制面板非常常见&#xff0c;确保一致的按键质量至关重要。制造商经常在这些组件的大规模、准确测试中遇到困难。显然&#xff0c;需要一个更自动化、精确…

Kubernetes (十四) 调度策略

一. 调度策略 二. 调度方法 nodeName 创建pod配置文件 vim nodename.yaml apiVersion: v1 kind: Pod metadata: name: nginx labels…

23 SEMC外扩SDRAM

文章目录 23.1 SDRAM 控制原理23.2 SEMC 简介 23.1 SDRAM 控制原理 RT1052 系列芯片扩展内存时可以选择 SRAM 和 SDRAM 由于 SDRAM 的“容量/价格”比较高&#xff0c;即使用 SDRAM 要比 SRAM 要划算得多。 给 RT1052 芯片扩展内存与给 PC 扩展内存的原理是一样的 PC 上一般…

【计算机网络】HTTP协议以及简单的HTTP服务器实现

文章目录 一、HTTP协议1.认识URL2.urlencode和urldecode3.HTTP协议格式4.HTTP的方法5.HTTP的状态码6.HTTP常见Header7.重定向8.长连接9.会话保持10.基本工具 二、简单的HTTP服务器实现1.err.hpp2.log.hpp3.procotol.hpp4.Sock.hpp5.Util.hpp6.httpServer.hpp7.httpServer.cc8.总…

网络编辑day4

思维导图 广播模型发送端-->类似于UDP客户端 #include<head.h> int main(int argc, const char *argv[]) {//1、创建套接字int sfdsocket(AF_INET,SOCK_DGRAM,0);if(sfd-1){perror("socket error ");return -1;}//2、将套接字设置成允许广播int broadcast1…

2023年山东省职业院校技能大赛高职组信息安全管理与评估 模块二(正式赛)

2023年山东省职业院校技能大赛高职组信息安全管理与评估 模块二 模块二竞赛项目试题 根据信息安全管理与评估技术文件要求&#xff0c;模块二为网络安全事件响应、数字取证调查和 应用程序安全。本文件为信息安全管理与评估项目竞赛-模块二试题。 介绍 竞赛有固定的开始和结…