虚表指针在C++类的继承/多态与重载中的基本逻辑

news2025/1/15 2:05:44

文章目录

  • 前言
  • 重载
  • 继承
    • 虚函数
    • 虚函数表
    • 动态绑定的实现
    • 析构函数
    • 构造函数
  • 多态
    • 子类直接继承父类的方法,不覆盖
    • 多重继承
    • 纯虚函数

前言

C++在C语言的基础上增加了类的概念,而类的最关键的特性就是三个:

  • 继承
  • 多态
  • 重载

这篇文章想接着上两篇C++相关的文章,从内存分布,符号表等更底层的角度来看一下C++语言的实现逻辑,以便更好的理解C++程序设计的底层逻辑。

重载

重载在了解了符号表,特别是函数签名之后是特别好理解的,同样的函数名,但是返回值,行参类型不同的情况下,在符号表中的函数签名是不一样的。
通过编译和链接后,加载在不同的内存区域,就实现了函数的重载逻辑。

继承

继承的概念就是,子类可以沿用父类的成员变量与函数。但是什么时候会用到,什么时候有不会用到,特别是在通过指针在不同类之间进行转换的时候,是特别容易出错的。

虚函数

类之间的继承是需要通过虚函数来实现的,也就是C++中的virtual关键字。
在说虚函数之前,先来看两段代码:
demo1:


class base{
public:
    int x=1;

    int addx(){return x+1;}
};

class childA: public base{
public:
    int addx(){return x+2;}
};

int main()
{
    base* ptr = new childA();
    base* ptr_base = new base();

    printf("hello world: %d\n", ptr->addx());
    printf("hello world: %d\n", ptr_base->addx());
    return 0;
}

demo2:


class base{
public:
    int x=1;

    virtual int addx(){return x+1;}
};

class childA: public base{
public:
    virtual int addx(){return x+2;}
};

int main()
{
    base* ptr = new childA();
    base* ptr_base = new base();

    printf("hello world: %d\n", ptr->addx());
    printf("hello world: %d\n", ptr_base->addx());
    return 0;
}

写这段代码的逻辑是需要使用一个基类指针,可以在运行过程中,根据需要指向不同的子类,来执行不同的业务逻辑。

从上面两端代码中,唯一的区别是成员函数前有没有virtual关键字。

这里注意,使用到继承和多态的时候,最好不要用gcc编译器,会出现链接失败的情况,最好使用g++编译器进行编译链接。

编译链接后执行:
demo1的输出结果:
hello world: 2
hello world: 2

demo2的输出结果是:
hello world: 3
hello world: 2

很明显,demo2才是我们想要的结果。那为什么demo1没有达到我们想要的效果呢,我们可以看一下符号表来找一下这里面的逻辑。

使用nm来观察其符号表:
demo1的符号表:

0000000100003f10 T __ZN4base4addxEv
0000000100003ef0 t __ZN4baseC1Ev
0000000100003f50 t __ZN4baseC2Ev
0000000100003ed0 t __ZN6childAC1Ev
0000000100003f30 t __ZN6childAC2Ev
                 U __Znwm
0000000100008020 d __dyld_private
0000000100000000 T __mh_execute_header
0000000100003e10 T _main
                 U _memset
                 U _printf
                 U dyld_stub_binder

demo2的符号表:

0000000100003f40 t __ZN4base4addxEv
0000000100003e90 t __ZN4baseC1Ev
0000000100003ef0 t __ZN4baseC2Ev
0000000100003f20 t __ZN6childA4addxEv
0000000100003e70 t __ZN6childAC1Ev
0000000100003eb0 t __ZN6childAC2Ev
0000000100004030 S __ZTI4base
0000000100004040 S __ZTI6childA
0000000100003fa5 S __ZTS4base
0000000100003f9d S __ZTS6childA
0000000100004058 s __ZTV4base
0000000100004018 s __ZTV6childA
                 U __ZTVN10__cxxabiv117__class_type_infoE
                 U __ZTVN10__cxxabiv120__si_class_type_infoE
                 U __Znwm
0000000100008018 d __dyld_private
0000000100000000 T __mh_execute_header
0000000100003db0 T _main
                 U _memset
                 U _printf
                 U dyld_stub_binder

从demo1的符号表中可以看到,在符号表中只有base类的add函数签名,也就是编译器分析代码后发现只用到了base类的add函数。实际上,如果不带virtual关键字的话,编译器会认为这是完全没关系的两个函数,只是说childA和base之间有个继承关系而已。
base的add是base的成员函数,childA的add是childA的成员函数,着两个函数之间没有任何的关系。在代码base* ptr = new childA()执行的时候,编译器做完类型转换后会判断就是要执行base类的成员函数add,所以符号表中就只有base类的add函数签名,最终输出也就是上面的结果了。

而如果是加了virtual关键字的话,就需要把childA和父类的base相关符号全部链接到符号表中。

虚函数表

virtual关键字实际上是告诉编译器,我们需要在父类和子类的两个成员函数见建立继承的关系。这种关系实际上是比较复杂的,这个复杂的关系,编译器是通过维护一个叫做“虚函数表”的指针来实现的。

  • 每个类对象都维护了一个虚函数指针,实际上就是一个链表结构,这个链表按顺序保存了每个对象中虚函数的地址。
  • 虚函数指针位于符号表的最前面
  • 虚函数按照其声明顺序放于表中
  • 父类的虚函数在子类的虚函数前面
    我们先看上面代码中这种比较简单的情况,也就是子类简单的覆盖了父类的虚函数。
    比如我们有两个对象:
base b;
childA c;

两个对象都会有一个虚表指针vptr,都在对象内存的最前面。vptr就是一个链表头,后面接着的就是一个一个的虚函数地址:
base的内存分布:
在这里插入图片描述

child的内存分布:
在这里插入图片描述

我们可以通过一段代码来验证这个过程:

class base{
public:
    int x=1;

    virtual int addx(){printf("first func\n");return x+1;}
};

int main()
{
    base b;
    Fun pFun = NULL;
    // &b为类对象的首地址,把这个地址转换成int64位地址,记得在64位机器上一定是int64,因为大部分int只是一个32位地址,那样地址的取值就会不对了。
    // 上面提到,虚表指针就是放在类对象的最前面,所以这个地址也是虚表指针的地址。
    printf("虚函数表地址:%x\n", (int64_t*)(&b));
    // 上面的虚表指针地址就是vptr的地址,对这个地址进行取值计算,也就是*运算符,得到的就是vptr指向的链表的第一个元素的地址。
    printf("a:%10x\n", *(int64_t*)(&b));
    // 和上面的地址是一样的,只是做了一个int64的转换
    printf("b:%10x\n", (int64_t*)*(int64_t*)(&b));
    // 对链表的第一个地址再进行取值运算,就是这个函数的地址了
    printf("c:%x\n", *(int64_t*)*(int64_t*)(&b));
    // 用一个函数指针指向这个地址,直接执行,就相当于执行了这个函数指针
    pFun = (Fun)*(int64_t*)*(int64_t*)(&b);
    pFun();

    return 0;
}

输出结果就是:

虚函数表地址:d7bd868
a:   4b23068
b:   4b23068
c:4b22ee0
first func

动态绑定的实现

根据上面的代码,我们在程序的运行过程中,可以用父类的对象指针指向不同的子类的对象地址,从而实现不同业务逻辑的执行,这个过程称之为动态绑定。
参考代码:

class base{
public:
    int x=1;

    virtual int addx(){printf("base func\n");return x+1;}
};

class childA: public base{
public:
    virtual int addx(){printf("child func\n");return x+2;}
};

typedef int(*Fun)();

int main()
{
    base* ptr_base = new base();

    Fun pFun = NULL;
    printf("虚函数表地址:%x\n", (int64_t*)(ptr_base));
    // 再次取址就可以得到第一个虚函数的地址了
    printf("a:%10x\n", *(int64_t*)(ptr_base));
    printf("b:%10x\n", (int64_t*)*(int64_t*)(ptr_base));
    printf("c:%x\n", *(int64_t*)*(int64_t*)(ptr_base));

    pFun = (Fun)*(int64_t*)*(int64_t*)(ptr_base);
    pFun();

    childA c;
    ptr_base = &c;

    printf("虚函数表地址:%x\n", (int64_t*)(ptr_base));
    // 再次取址就可以得到第一个虚函数的地址了
    printf("a:%10x\n", *(int64_t*)(ptr_base));
    printf("b:%10x\n", (int64_t*)*(int64_t*)(ptr_base));
    printf("c:%x\n", *(int64_t*)*(int64_t*)(ptr_base));

    pFun = (Fun)*(int64_t*)*(int64_t*)(ptr_base);
    pFun();

    return 0;
}

输出结果是:

虚函数表地址:7ac05990
a:   4947028
b:   4947028
c:4946e60
base func
虚函数表地址:d6b2868
a:   4947050
b:   4947050
c:4946ee0
child func

实际上,我们应该理解,c++的指针都是一个64位的地址,指针类型只是标记它指向的地址类型,或者说移动的大小之类。在上面这个例子中,从父类对象指向了一个子类的对象。相应的vptr,虚函数地址都发生了变化。
在这里插入图片描述

析构函数

在有虚函数和继承关系的情况下,一般建议父类和每个子类都需要在析构函数上标记为virtual。
原因在于:如果没有标记为虚函数,在使用动态绑定来实现不同的逻辑的时候,父类对象在被释放的时候,调用的都是父类的析构函数,造成的后果就是该释放的没释放(子类中的内存),不该释放的释放了,就会造成内存泄漏和访问越界。

构造函数

构造函数不能是虚函数,因为构造函数就是为内存中的各种值赋值的,此时还没有初始化vptr这个虚指针。每个子类自己定义一下就可以了。

多态

父类和子类之间有继承关系之后,子类和父类可以表现出不同的逻辑,实际上上面已经提到了一种情况的多态,也就是子类直接覆盖了父类的虚函数,再这一节我们一起看看其他几种情况。

子类直接继承父类的方法,不覆盖

把上面的例子中子类的方法去掉:

class base{
public:
    int x=1;

    virtual int addx(){printf("base func\n");return x+1;}
};

class childA: public base{
public:
    // virtual int addx(){printf("child func\n");return x+2;}
};

还是使用上面的输出,我们会发现,链表第一个指针的内容是一样的,也就是说子类的虚表指针直接指向了父类的函数地址,调用的也就是调用的父类的函数了。

虚函数表地址:51405990
a:   42fe028
b:   42fe028
c:42fdeb0
base func
虚函数表地址:ca83868
a:   42fe050
b:   42fe050
c:42fdeb0
base func

无继承情况

多重继承

在C++中,子类是可以继承多个类的。那么子类继承自两个类以上的情况,实际上就是上面几种情况的扩展,有覆盖的就是指向自己的函数地址,无覆盖的就指向相应的父类的地址。
和单一继承的情况不一样的是, 多继承的子类中有多个虚表指针,每个指针指向不同的继承线上的虚表。
如代码:

class base{
public:
    int x=1;

    virtual int addx(){printf("base func\n");return x+1;}
};

class baseB
{
public:
    int y=100;
    virtual int minus(){printf("base minus func\n"); return y-1;}
};

class childA: public baseB, public base{
public:
    // virtual int addx(){printf("child func\n");return x+2;}
    virtual int minus(){printf("child minus func\n");return y-2;}
};

typedef int(*Fun)();

int main()
{
    base* ptr_base = new base();

    Fun pFun = NULL;
    printf("虚函数表地址:%x\n", (int64_t*)(ptr_base));
    // 再次取址就可以得到第一个虚函数的地址了
    printf("a:%10x\n", *(int64_t*)(ptr_base));
    printf("b:%10x\n", (int64_t*)*(int64_t*)(ptr_base));
    printf("c:%x\n", *(int64_t*)*(int64_t*)(ptr_base));

    pFun = (Fun)*(int64_t*)*(int64_t*)(ptr_base);
    pFun();

    childA c;
    baseB* ptr_baseB = &c;

    printf("虚函数表地址:%x\n", (int64_t*)(ptr_baseB));
    // 再次取址就可以得到第一个虚函数的地址了
    printf("a:%10x\n", *(int64_t*)(ptr_baseB));
    printf("b:%10x\n", (int64_t*)*(int64_t*)(ptr_baseB));
    printf("c:%x\n", *(int64_t*)*(int64_t*)(ptr_baseB));

    pFun = (Fun)*(int64_t*)*(int64_t*)(ptr_baseB);
    pFun();

    return 0;
}

继承了两个类,内存中虚指针和虚表的情况就是:
在这里插入图片描述

在使用代码baseB* ptr_baseB = &c;时, 编译器就会使用baseB的虚表指针。

最终的输出结果就是:

虚函数表地址:8ac05990
a:   4ae5030
b:   4ae5030
c:4ae4dc0
base func
虚函数表地址:d856858
a:   4ae5058
b:   4ae5058
c:4ae4e90
child minus func

纯虚函数

纯虚函数就类似于java里面的接口类了,定义了纯虚函数的类是不能被实例化的,只能通过被子类继承之后实例化。
在虚表里面的话,就是父类的虚表里面没有这样一项而已。

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

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

相关文章

[附源码]Python计算机毕业设计SSM基于推荐算法的汽车租赁系统(程序+LW)

项目运行 环境配置: Jdk1.8 Tomcat7.0 Mysql HBuilderX(Webstorm也行) Eclispe(IntelliJ IDEA,Eclispe,MyEclispe,Sts都支持)。 项目技术: SSM mybatis Maven Vue 等等组成,B/S模式 M…

2023最新SSM计算机毕业设计选题大全(附源码+LW)之java动物爱心救助平台s9dza

首先选择计算机题目的时候先看定什么主题,一般的话都选择当年最热门的话题进行组题,就比如说,今年的热门话题有奥运会,全运会,残运会,或者疫情相关的,这些都是热门话题,所以你就可以…

HTML做一个抗疫逆行者感动人物页面(纯html代码)

🎉精彩专栏推荐 💭文末获取联系 ✍️ 作者简介: 一个热爱把逻辑思维转变为代码的技术博主 💂 作者主页: 【主页——🚀获取更多优质源码】 🎓 web前端期末大作业: 【📚毕设项目精品实战案例 (10…

AI公平性研究进展(2022)

最近AI公平性方面出了不少新的研究成果,如有遗漏,欢迎补充↓↓↓↓ 公平性提升 MAAT: A Novel Ensemble Approach to Addressing Fairness and Performance Bugs for Machine Learning Software, FSE, 2022. 利用模型集成的方式获得公平的预测结果&am…

Windows下如何查看某个端口被谁占用

开发时经常遇到端口被占用的情况,这个时候我们就需要找出被占用端口的程序,然后结束它,本文为大家介绍如何查找被占用的端口。 1、打开命令窗口(以管理员身份运行) 开始—->运行—->cmd,或者是 windowR 组合键&#xff0c…

含冰蓄冷空调的冷热电联供型微网多时间尺度优化调度(Matlab代码实现)

💥💥💞💞欢迎来到本博客❤️❤️💥💥 🏆博主优势:🌞🌞🌞博客内容尽量做到思维缜密,逻辑清晰,为了方便读者。 ⛳️座右铭&a…

开关电源 - LLC电路的谐振工作模态浅析

LLC电路的谐振工作模态浅析 LLC谐振电路是常用的拓扑,广泛应用在目前的热门应用中,本文主要从典型谐振状态来分析其基本工作过程,后续我们将逐步扩展到其它工作状态。 一、电路工作基本条件及电路组成 图1 电路主要组成部分 如上图1所示&a…

c#入门-运算符

可用运算符 值的运算也是自定义的,但是这个就看不出了。 要么翻源文件,要么翻说明书才知道一个类型能怎么运算。 但是一个默认情况下的类型,是没有定义任何运算符的。 也就是说你适用的运算一定是由人先写出来的,没有系统默认的…

MYSQL的索引事务

文章目录1:索引1.1:概念:1.2 作用:1.3 使用场景:1.4 使用:补充:**2:了解一下B树:****3:了解一下B树**4:为什么使用B树/B树来实现索引2&#xff1a…

通过IP地址如何查到用户真实个人信息

当人们使用服务时,互联网上所有科技巨头公司都会收集大量的数据,他们就是这样发财的。微博知道您的朋友是谁、您 “喜欢” 什么,以及您在新闻源上阅读的内容种类;百度知道您的想法、您想知道什么、以及任何时候的去向;…

[附源码]JAVA毕业设计小区物业管理系统(系统+LW)

[附源码]JAVA毕业设计小区物业管理系统(系统LW) 项目运行 环境项配置: Jdk1.8 Tomcat8.5 Mysql HBuilderX(Webstorm也行) Eclispe(IntelliJ IDEA,Eclispe,MyEclispe,Sts都支持)。 项目技术…

Spring cloud Eureka服务注册中心实战

Spring cloud Eureka服务注册中心一、注册中心二、存在意义三、角色四、运行流程五、入门案例1、创建Spring Cloud 工程2、创建 Eureka 服务pom.xml3、配置 application.yml4、服务间调用方式一:RestTemplate DiscoveryClient方式二:RestTemplate Load…

【Golang】关于slice调用append函数后分配新数组的问题(slice的底层实现)

问题描述 今天在写代码的时候遇到一个很奇怪的现象,先看下面两段代码 func push(a []int, v int) {a[1] 2a append(a, v) } func main() {a : []int{0, 1, 2}push(a, 3)fmt.Println(a) }结果:[0 2 2] func push(a []int, v int) {a append(a, v)a[…

如何看股票l2接口的最佳买卖价位委托明细?

股票l2接口的买卖价位委托明细即买一卖一位置的挂挡情况,挂单可以让用户及时了解交易队列,从而了解完整的开盘情况。用户可以通过观察挂单来判断档位是否有主力资金介入,从而判断开盘时的主力动态,进而把握投资机会。 1.股票l2接…

数据库之insert的使用讲解

1单独插入一条数据: INSERT INTO t1(col1,col2) VALUE(v01,v02); 2插入多条数据 INSERT INTO t1(col1,col2) VALUE(v01,v02),(v03,v04),(v05,v06)...; 3插入查询的数据(查询N条数据插入N条数据,广泛应用&#xf…

微信小程序|使用小程序制作一个足球拼图小游戏

世界杯的意义永远不止是冠军,它是每个精彩的瞬间,是呐喊,是青春。此文用小程序制作一个足球的拼图小游戏,一起为世界杯疯狂吧! 开发步骤一、工具安装二、功能实现1 . 提示图展示2 . 绘制画布内容3 . 打乱方块顺序4 . 移…

模型和视图框架(概念)

MVC(Model-View-Controller)是一种设计模式。 Model(模型)是应用对象,用来显示模型View (视图)是用户界面,用来显示数据Controller(控制)定义用户界面对用户输出的反映方式模型/视图类可以分为上述三组:模型、视图和委…

CPU100%,怎么快速定位?

一台机器,CPU100%,如何找到相关服务,如何定位问题代码,今天简单分享下思路。假设,服务器上部署了若干Java站点服务,以及若干Java微服务,突然收到运维的CPU异常告警。如何定位是哪个服务进程导致…

是时候给钉钉和腾讯会议算算账了

杨净 萧箫 发自 凹非寺量子位 | 公众号 QbitAI这几天,工作和上课等事情开始有回归线下的迹象,腾讯会议、钉钉似乎也可以松口气了。毕竟云会议的这两大APP,前段时间一直在被网友找平替。一来,它们要收费了;二来&#xf…

【有营养的算法笔记】整数二分和浮点二分的全面分析

👑作者主页:进击的安度因 🏠学习社区:进击的安度因(个人社区) 📖专栏链接:有营养的算法笔记 文章目录一、铺垫二、整数二分模板分析三、模板应用 —— 数的范围四、浮点二分模板分析…