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.hqwc.cn/news/415820.html

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

相关文章

Element中Upload组件上传(图片和文件的默认上传以及自定义上传)

目录 一、代码实现([具体配置文档](https://element.eleme.cn/#/zh-CN/component/upload))1. 默认图片上传2. 自定义图片上传3. 默认文件上传 二、效果图 一、代码实现(具体配置文档) 1. 默认图片上传 适用于&#xff1a;文件上传接口只要求file二进制文件&#xff0c;无需其…

【docker-compose】【nginx】内网环境https配置

目录 1、openssl生成自签名证书和私钥2、nginx.conf配置ssl3、docker-compose挂载 1、openssl生成自签名证书和私钥 在部署服务器上&#xff0c;新建cert目录&#xff0c;执行以下指令&#xff0c;然后生成.crt和.key文件 openssl req -newkey rsa:2048 -nodes -keyout rsa_pri…

图灵日记之java奇妙历险记--String类

目录 String常用方法字符串构造String对象的比较字符串查找char charAt(int index)int indexOf(int ch)int indexOf(int ch, int fromIndex)int indexOf(String str)int indexOf(String str, int fromIndex)int lastIndexOf(String str)int lastIndexOf(String str, int fromIn…

禅道(ZenTao)项目管理工具

认识禅道 禅道&#xff08;Zentao&#xff09;是一个面向敏捷开发的项目管理软件&#xff0c;它是一款开源的团队协作工具。禅道提供了项目管理、任务管理、缺陷管理、文档管理等功能&#xff0c;帮助团队高效地进行项目管理工作。禅道致力于提供简单易用、功能强大、界面美观的…

C++类相关oj题目分享(计算日期到天数转换、日期差值、打印日期、日期累加)

文章目录 1.计算日期到天数转换题目详情代码思路 2.KY111 日期差值题目详情代码思路 3.KY222 打印日期题目详情代码 4.KY258 日期累加题目详情代码思路 1.计算日期到天数转换 传送门 题目详情 代码 #include <iostream> using namespace std; int GetDay(int year,int…

Qt/C++中英输入法/嵌入式输入法/小数字面板/简繁切换/特殊字符/支持Qt456

一、前言 在嵌入式板子上由于没有系统层面的输入法支持&#xff0c;所以都绕不开一个问题&#xff0c;那就是在需要输入的UI软件中&#xff0c;必须提供一个输入法来进行输入&#xff0c;大概从Qt5.7开始官方提供了输入法的源码&#xff0c;作为插件的形式加入到Qt中&#xff…

【docker】安装 CentOS

查看可用的 CentOS版本 docker search centOS拉取 CentOS最新镜像 docker pull centos:latest 查看本地镜像 docker images运行容器 docker run -itd --name centos-demo centos查看进程 docker ps进入centos容器 docker exec -it centos-demo /bin/bash停止容器 docker …

Autosar信息安全入门系列01-SecOC基础介绍

本文框架 1. 概述2. SecOC基本概念2.1 SecOC是什么&#xff1f;2.2 新鲜度值与MAC值2.3 SecOC报文格式 3. SecOC报文发送及接收逻辑3.1 SecOC报文的发送3.2 SecOC报文的接收 1. 概述 本文为Autosar通信入门系列介绍&#xff0c;如您对AutosarMCAL配置&#xff0c;通信&#xf…

C++编写、生成、调用so库详解(二)

我们上篇中主要讲了怎么去打包so库 C编写、生成、调用so库详解(一) 这篇我们就来说一些怎么调用so库 目录 1.调用符合JNI标准的so库 2.调用不符合JNI标准的so库 上面说了两种不同类型的so库,我们分别来看一下怎么调用这两种,在调用so库之前,我们先说一下直接调用上面写的C…

解决Uniapp插件市场试用原生插件项目 没有MD5签名安卓无法自定基座打包的情况

Uniapp插件市场中&#xff0c;有些插件是原生插件&#xff0c;必须使用自定义基座才能打包。但是传统keytool命令&#xff0c;已经无法看到安卓证书的MD5签名。现采用Android Studio查询signingReport的办法获取证书的MD5签名&#xff0c;并对插件的示例项目进行打包运行。一、…

烟火检测/周界入侵/视频智能识别AI智能分析网关V4如何配置ONVIF摄像机接入

AI边缘计算智能分析网关V4性能高、功耗低、检测速度快&#xff0c;易安装、易维护&#xff0c;硬件内置了近40种AI算法模型&#xff0c;支持对接入的视频图像进行人、车、物、行为等实时检测分析&#xff0c;上报识别结果&#xff0c;并能进行语音告警播放。算法可按需组合、按…

响应式Web开发项目教程(HTML5+CSS3+Bootstrap)第2版 例4-5 select

代码 <!doctype html> <html> <head> <meta charset"utf-8"> <title>select</title> </head><body> <!--单选下拉菜单可设置默认选中项--> 所在城市&#xff08;单选&#xff09;:<br> <select>…