详解 C++ 对象模型

2023-05-16

文章目录

    • 何为 C++ 对象模型?
    • 基本 C++ 对象模型
    • C++对象模型中加入单继承
      • 1. 无重写的单继承
      • 2. 有重写的单继承
    • C++ 对象模型中加入多继承
    • C++ 对象模型中加入虚继承
      • 1. 简单虚继承(无重复继承情况)
      • 2. 菱形继承(含重复继承、多继承情况)
    • 如何访问成员?
    • 多态如何实现?

何为 C++ 对象模型?

C++ 对象模型可以概括为以下两个部分

  1. 语言中直接支持面向对象程序设计的部分
  2. 对于各种支持的底层实现机制

语言中直接支持面向程序设计的部分,如构造函数、析构函数、虚函数、继承(单继承,多继承,虚继承)、多态等。重点在底层实现机制。

在 C 语言中,“数据” 和 “处理数据的操作(函数)”是分开来声明的。也就是说,语言本身并没有支持 “数据和函数” 之间的关联性。在 C++ 中,通过抽象数据类型(abstract data type,ADT),在类中定义数据和函数,来实现数据和函数直接的绑定。

概括来说,在 C++ 类中有两种成员数据:static、nonstatic;三种成员函数:static、nonstatic、virtual。
在这里插入图片描述
如下面的 Base 类的定义:

#include <iostream>
using namespace std;
class Base{
private:
	int iBase;
	static int count;
public:
	Base(int);
	virtual ~Base(void);
	
	int getIBase() const;
	static int instanceCount();
	virtual void print() const;
};

那么,Base 类在机器中如何构建出各种成员数据和成员函数的呢?


基本 C++ 对象模型

在介绍 C++ 使用的对象模型之前,介绍两种对象模型:简单对象模型(a simple object model)、表格驱动对象模型(a table-driven object model)。
在这里插入图片描述

所有的成员占用相同的空间(跟成员类型无关),对象只是维护了一个包含成员指针的一个表。表中放的是成员的地址,无论是成员变量还是函数,都是这样处理。对象并没有直接保存成员而是保存了成员的指针


在这里插入图片描述

这个模型在简单对象的基础上又添加了一个间接层。将成员分为函数和数据,并且用两个表格保存,然后是对象只保存了两个指向表格的指针。这个模型可以保证所有的对象具有相同的大小,比如简单对象模型中每个对象的大小于其成员的个数有关。其中数据成员表中包含实际数据;函数成员表中包含的实际函数的地址(与数据成员相比,多一次寻址)。


在这里插入图片描述

这个模型结合上面两种模型的特点,并对内存存取空间进行了优化。在此模型中,nonstatic 数据成员被放在对象内部;static 数据成员,static 和 nonstatic 成员函数均被放置在对象之外。

对于虚函数的支持分两步完成:

  1. 每一个 class 产生一堆指向虚函数的指针,放在一个表格之中。这个表格称之为虚函数表(virtual table,vtbl)。
  2. 每一个对象被添加了一个指针,指向相关的虚函数表 vtbl。通常这个指针被称为 vptr。vptr 的设定(setting)和 重置(resetting)都由每一个 class 的构造函数,析构函数和拷贝赋值运算符自动完成。

另外,虚函数表地址的前面设定了一个指向 type_info 的指针,RTTI(Run Time Type Identification)运行时类型识别是在编译器生成的特殊类型信息,包括对象继承关系,对象本身的描述,RTTI 是为多态而生成的信息,所以只有具有虚函数的对象才会生成。

这个模型的优点在于它的空间和存取时间的效率;但是也有缺点:如果应用程序本身未改变,但当所使用的类的 nonstatic 数据成员添加删除或修改时,需要重新编译。


模型验证测试
为了验证上述 C++ 对象模型,我们编写如下代码

#include <iostream>
#include <string>
using namespace std;
//获取普通成员函数的地址
template<typename dst_type,typename src_type>
dst_type pointer_cast(src_type src)
{
    return *static_cast<dst_type*>(static_cast<void*>(&src));
}
class Base{
private:
	int iBase;
	static int count;
public:
	Base(int i) : iBase(i){}
	virtual ~Base(){}

	int getIBase() const{ return iBase; }
	static int instanceCount(){ count++; return count; }
	virtual void print() const{cout << "printf" << endl; }
};
int Base::count = 0;
void test_base_model()
{
    Base b1(1000);
    cout << "对象b1 的其实内存地址:" << &b1 << endl;
    cout << "type_info信息:" << (int*)*(int*)(&b1) - 1 << endl;

    cout << "虚函数表地址:" << (int*)(&b1) << endl;
    cout << "虚函数表——第一个函数地址:" << (int *)(*(int*)(&b1)) << endl;
    cout << "虚函数表——第二个函数地址:" << (int *)(*(int*)(&b1))  + 1<< endl;

    cout << endl;
    cout << "推测数据成员 iBase 地址:" << (int*)(&b1) + 1 << ",值为:" << *((int*)(&b1) + 1) << endl;
    cout << "普通函数 getIBase 的地址为:" << pointer_cast<void *>(&Base::getIBase) << endl;
    cout << "静态函数instanceCount地址:" << pointer_cast<void *>(&Base::instanceCount) << endl;
}
int main()
{
    test_base_model();
    return 0;
}



【运行结果】
在这里插入图片描述

根据 C++ 对象模型,实例化对象 b1 的起始内存地址,即虚函数表地址

  • 虚函数表的第一个函数地址是虚析构函数地址;
  • 虚函数表的第二个函数地址就是虚函数 print() 的地址,通过函数指针可以调用,进行验证;
  • 推测数据成员 iBase 的地址为虚函数表的地址 + 1,(int *)(&b1) + 1;
  • 静态数据成员和静态函数所在内存地址,与对象数据成员和函数成员位段不一样;

上面介绍了基本的 C++ 对象模型,引入继承之后,C++ 模型又是怎样的?

C++对象模型中加入单继承

不管是单继承、多继承、还是虚继承,如果基于 “简单对象模型”,每一个基类都可以被派生类中的一个 slot 指出,该 slot 内包含基类对象的地址。这个机制的主要缺点是,因为间接性而导致空间和存取时间上的额外负担;优点是派生类对象的大小不会因其基类的改变而受影响。

如果基于 “表格驱动模型”,派生类中有一个 slot 指向基类表,表格中的每一个 slot 含有一个相关的基类地址(这个很像虚函数的地址)。这样每个派生类对象含一个 bptr,它会被初始化,指向其基类表。这种策略的主要缺点是由于间接性而导致的空间和存取时间上的额外负担;优点则是在每一个派生类对象中对继承都有一致的表现方式,每一个派生类对象都应该在某个固定位置上放置一个基类表指针,与基类的大小或数量无关。第二个优点是,不需要改变派生类对象本身,就可以放大,缩小或更改基类表

不管上述哪一种机制,“间接性”的级数都将因为集成的深度而增加。C++ 实际模型是,对于一般继承是扩充已存在的虚函数表;对于虚继承添加一个虚函数表指针。

1. 无重写的单继承

无重写,即派生类中没有于基类同名的虚函数

class Derived : public Base
{
public:
    Derived(int d) : Base(d), iDerived(888){}
    virtual ~Derived(){}
    virtual void derived_print(){}

protected:
    int iDerived;
};

Base、Derived的类图如下所示:

在这里插入图片描述
Base 的模型跟上面一样,不受继承影响。Derived 不是虚继承,所以是扩充已存在的虚函数表,所以结构如下图所示:
在这里插入图片描述
为了验证上述C++对象模型,我们编写如下测试代码。

void test_single_inherit_norewrite()
{
    Derived d(999);
    cout << "对象d'的起始位置为:" << &d << endl;
    cout << "type_info信息:" << (int *)*(int *)(&d) - 1 << endl;
    cout << "虚函数表的地址:" << (int*)(&d) << endl;
    cout << "第一个虚函数地址:" << (int*)*(int*)(&d) << endl;
    cout << "第二个虚函数地址:" << (int*)*(int*)(&d) + 1<< endl;
    cout << "第三个虚函数地址:" << (int*)*(int*)(&d) + 2<< endl;

   cout << "推测数据成员iBase地址:\t\t" << ((int*)(&d) +1) << "\t通过地址取得的值:" << *((int*)(&d) +1) << endl;

    cout << "推测数据成员iDerived地址:\t" << ((int*)(&d) +2) << "\t通过地址取得的值:" << *((int*)(&d) +2) << endl;
}

【运行结果】
在这里插入图片描述

2. 有重写的单继承

派生类中重写了基类的 print() 函数

class Derived_Overrite : public Base
{
public:
    Derived_Overrite(int);
    virtual ~Derived_Overrite(void);
    virtual void print(void) const;
protected:
    int iDerived;
};

Base、Derived_Overwrite的类图如下所示:
在这里插入图片描述
重写print()函数在虚函数表中表现如下:
在这里插入图片描述


C++ 对象模型中加入多继承

从单继承可以知道,派生类只是扩充了基类的虚函数表。如果是多继承的话,又是如何扩充的?

  1. 每个基类都有自己的虚表
  2. 子类的成员函数被放到了第一个基类的表中
  3. 内存布局中,其父类布局依次按声明顺序排列
  4. 每个基类的虚表中的 print() 函数都被 overwrite 成了子类的 print()。这样做是为了解决不同的基类类型的指针指向同一个子类实例,而能够调用到实际的函数。

在这里插入图片描述
上面3个类,Derived_Mutlip_Inherit继承自Base、Base_1两个类,Derived_Mutlip_Inherit的结构如下所示:
在这里插入图片描述


C++ 对象模型中加入虚继承

虚继承是为了解决重复继承中多个间接父类的问题的,所以不能使用上面简单的扩充并为虚基类提供一个虚函数指针(这样会导致重复继承的基类会有多个虚函数表)形式。

虚继承的派生类的内存结构,和普通继承完全不同。虚继承的子类,有单独的虚函数表,另外也单独保存一份父类的虚函数表,两部分之间用一个四个字节的 0x00000000 来作为分界。派生类的内存中,首先是自己的虚函数表,然后是派生类的数据成员,然后是 0x0,之后就是基类的虚函数表,之后就是基类的数据成员。

如果派生类没有自己的虚函数,那么派生类也会有有一个指向虚函数表的 vptr,而后是派生类的变量,然后再是基类。虚继承中,即时派生类和基类都没有虚函数,派生类也会有 vptr,其指向的内存地址所存储的是 0x00。

因此,在虚继承中,派生类和基类(虚基类)的数据,是完全间隔的,先存放派生类自己的虚函数表和数据,中间以 0x 分界,最后保存基类的虚函数和数据。如果派生类重载了父类的虚函数,那么则将派生类内存中基类虚函数表的响应函数替换。


1. 简单虚继承(无重复继承情况)

简单虚继承的2个类 Base、Derived_Virtual_Inherit1 的关系如下所示:
在这里插入图片描述
Derived_Virtual_Inherit1的对象模型如下图:
【标注】:Xxxx => -4 是分隔符的意思
在这里插入图片描述

2. 菱形继承(含重复继承、多继承情况)

菱形继承关系如下图:

在这里插入图片描述
至此,C++对象模型介绍的差不多了,清楚了C++对象模型之后,很多疑问就能迎刃而解了。下面结合模型介绍一些典型问题。


如何访问成员?

前面介绍了 C++ 对象模型,下面介绍 C++ 对象模型对访问成员的影响。其实清楚了 C++ 对象模型,就清楚了成员访问机制,给出一个大致的介绍。

对象大小问题
在这里插入图片描述
其中3个类中的函数都是虚函数

  • Derived 继承 Base
  • Derived_Virtual 虚继承 Base
class Base{
private:
public:
	Base(){}
	virtual ~Base(){}
	virtual void print(){}
	virtual void print_virtual(){}
};
class Derived : public Base
{
public:
    Derived(){}
    virtual ~Derived(){}
    virtual void print(){}
    virtual void print_virtual(){}

};
class Derived_Virtual : virtual public Base
{
    public:
        Derived_Virtual() {}
        virtual ~Derived_Virtual() {}

        virtual void print_derived_virtual(){}
    protected:

    private:
};

测试对象大小:

void test_size()
{
    Base b;
    Derived d;
    Derived_Virtual dv;
    cout << "sizeof(b) = " << sizeof(b) << endl;
    cout << "sizeof(d) = " << sizeof(d) << endl;
    cout << "sizeof(dv) = " << sizeof(dv) << endl;
    	 
}

【运行结果】
在这里插入图片描述

  • Base 中包含虚函数指针,所以 size 为 4字节
  • Derived 单继承 Base,只是扩充了基类的虚函数表,不会新增虚函数表指针,所以 size 也为 4
  • Derived_Virtual 虚继承 Base,根据前面的模型可知,派生类有自己的虚函数表及指针,并且有分隔符(0x00000000)4字节,然后是虚基类的虚函数表指针,所以 size = 4+4+4 = 12

那么一个空类(只有构造函数和析构函数(析构函数不是虚函数))的大小是否为0呢?
【举个栗子】

class Empty{
public:
	Empty(){}
	~Empty(){}
};

【运行结果】
在这里插入图片描述

结果如上,并不是空的,它有一个隐晦的 1字节,那是被编译器安插进去的一个 char。这将使得这个class 的两个函数在类中有独一无二的地址。如果不给空类分配一定的空间,那么将无法使用该类的实例。


数据成员如何访问(直接取址)

跟实际对象模型相关联,根据 对象起始地址 + 偏移量取得

静态绑定和动态绑定

程序调用函数时,将使用哪个可执行代码块呢?编译器负责回答这个问题。将源代码中的函数调用解析为执行特定的函数代码块被称为函数名绑定(binding, 又称联编)。在 C 语言中,这非常简单,因为每个函数名都对应一个不同的函数。在 C++ 中,由于函数重载的缘故,这项任务更复杂。编译器必须查看函数参数以及函数名才能确定使用哪个函数。然而编译器可以在编译过程中完成这种绑定,这称为静态绑定(static binding),又称为早期绑定(early binding)

然而虚函数使这项工作变得更加困难。使用哪一个函数不是能在编译阶段确定的,因为编译器不知道用户选择哪种类型所以,编译器必须能够在程序运行时选择正确的虚函数的代码,这被称为动态绑定(dynamic binding),又称为晚期绑定(late binding)

使用虚函数是有代价的,在内存和执行速度等方面是有一定成本的,包括:

  • 每个对象都将增大,增大量为存储虚函数表指针的大小
  • 对于每个类,编译器都将创建一个虚函数地址表
  • 对于每个函数调用,都需要执行一项额外的操作,即到虚函数表中查找地址。虽然非虚函数比虚函数效率稍高,但不具备动态联编能力。

函数成员如何访问(间接取址)

跟实际模型相关联,普通函数(static、nonstatic)根据编译、链接的结果直接获取函数地址;如果是虚函数根据对象模型,取出对于虚函数地址,然后在虚函数表中查找函数地址。


多态如何实现?

多态的实现
多态(Polymorphisn)在 C++ 中是通过虚函数实现的。通过上面的模型【“有重写的单继承”】知道,如果类中有虚函数,编译器就会自动生成一个虚函数表,对象中包含一个指向虚函数表的指针。能够实现多态的关键在于:虚函数是允许被派生类重写的,在虚函数表中,派生类函数覆盖基类函数。除此之外,还必须通过指针或引用调用方法才行,将派生类对象赋给基类对象。
在这里插入图片描述
上面2个类,基类 Base、派生类 Derived 都包含下面2个方法

void print() const;
virtual void print_virtual() const;

这两个方法的区别就在于一个是普通函数,一个是虚函数。
编写测试代码如下:

void test_polmorphisn()
{
	Base b;
	Derived d;
	b = d;
	b.print();
	b.print_virtual();

	Base *p;
	p = &d;
	p->print();
	p->print_virtual();
}

【运行结果】
blog.csdnimg.cn/20200809085631403.png)

根据模型推测只有 p->print_virtual() 才实现了多态,其他三个调用都是调用基类的方法

  • b.print(); b.print_virtual();不能实现多态是因为通过基类对象调用,而非指针或者引用,所以不能实现多态
  • p->print();不能实现多态是因为,print() 函数没有声明为虚函数(virtual),派生类中也定义了print 函数只是隐藏了基类的 print 函数。

为什么析构函数设成虚函数是有必要的?

析构函数应当都是虚函数,除非明确该类不做基类(不被其他类继承)。基类的析构函数声明为虚函数,这样做是为了确保释放派生类对象时,按照正确的顺序调用析构函数。
从前面介绍的 C++ 对象模型可知,如果析构函数不定义成虚函数,那么派生类就不会重写基类的析构函数,再有多态行为的时候,派生类的析构函数不会被调用到(有内存泄漏的风险

【举个栗子】

void test_vitual_destructor()
{
    Base *p = new Derived();
    delete p;
}

如果基类不是析构函数
在这里插入图片描述
注意,缺少了派生类的析构函数调用。把析构函数声明为虚函数,调用就正常了:
在这里插入图片描述

本文内容由网友自发贡献,版权归原作者所有,本站不承担相应法律责任。如您发现有涉嫌抄袭侵权的内容,请联系:hwhale#tublm.com(使用前将#替换为@)

详解 C++ 对象模型 的相关文章

随机推荐

  • Python监控目录文件夹,并使用SFTP上传目录及文件到linux服务器

    Python 扫描监控本地文件夹并进行超大文件上传 方案1 WebUploader大文件分块多线程并发上传 方案2 watchdog目录文件夹监控 paramiko STFP上传服务器 方案3 优化2 压缩后上传 服务器解压 1 监控本地文
  • 本地组策略编辑器关闭windows10的自动更新

    具体操作 xff1a win 43 R输入 gpedit msc 打开组策略 xff0c 在 计算机配置 管理模板 windows组件 Windows更新 里找到 指定Intranet microsoft 更新服务位置 xff0c 双击打开
  • PVE虚拟机篇-PVE虚拟机安装

    安装包下载 前往Proxmox Virtual Environment下载ISO镜像文件 xff0c 可以直接选择最新版本 但是如果没有海外线路一般下载十分缓慢 xff0c 甚至下不动 xff0c 所以可以去中科大镜像源 xff1a 中科大
  • PVE虚拟机篇-pve软件换源

    起因 由于安装pve的第二天突然发现后台显示执行错误 xff0c 更新软件源失败 xff1a 更新软件源方法 注释企业源 echo 34 deb https enterprise proxmox com debian pve bullsey
  • 解决android opengl glReadPixels 慢的问题 二

    解决android opengl glReadPixels 慢的问题 二 上篇讲到使用pbo解决glreadpix慢的问题 xff0c 但是效果不太理想 xff0c 后来参考链接 xff1a OpenGL Pixel Buffer Obje
  • Tesseract 3.02中文字库训练

    下载chi sim traindata字库 下载tesseract ocr setup 3 02 02 exe 下载地址 xff1a http code google com p tesseract ocr downloads list 下
  • Windows下使用pip安装包 出错 TLS/SSL

    Windows下使用pip安装包的时候出现如下问题 xff1a WARNING pip is configured with locations that require TLS SSL however the ssl module in
  • C++进阶(七)-模板与群体数据8

    选择排序 选择排序的基本思想 每次从待排序序列中选择一个关键字最小的元素 xff0c xff08 当需要按关键字升序排列时 xff09 xff0c 顺序排在已排序序列的最后 xff0c 直至全部排完 例9 12 简单选择排序函数模板 tem
  • Ubuntu 20.04 WARNING笔记(长期更新,欢迎交流)

    记录使用Ubuntu 20 04过程的报错 以及解决 61 61 61 61 61 61 61 61 61 61 61 61 61 61 61 61 61 61 61 61 61 61 61 61 61 61 61 61 61 61 61
  • 循环嵌套例题

    循环嵌套例题 1 例题1 span class token comment 代码 span span class token keyword for span span class token punctuation span span c
  • java实现学生信息管理(对象数组实现)

    java实现学生信息管理 xff08 对象数组实现 xff09 1 例题 实体类 学生类 id 姓名 xff0c 年龄 xff0c 性别 xff0c 成绩 需要使用数组保存学生信息 Student allStu 需要完成的方法 1 根据学生
  • java基础语法思维导图

    java从入门到放弃 简单总结之前的
  • 学生管理系统2.0 (可对学生数组扩容)

    学生管理系统2 0 可对学生数组扩容 1 用户可初始化数组长度 xff0c 不够用时可以扩充数组容量 尝试完成以下功能 实体类 学生类 id 姓名 xff0c 年龄 xff0c 性别 xff0c 成绩 需要使用数组保存学生信息 Studen
  • LinkedList和Set

    LinkedList和Set 1 LinkedList 1 1 LinkedList概述 底层存储数据是一个双向链表结构 自行车链子 就是一个生活中链表结构 xff0c 环环相扣 xff0c 替换 xff0c 拆除非常方便 1 2 Link
  • shiro与springboot整合

    Shiro 与 SpringBoot 的整合 1 创建SpringBoot工程 xff0c 导入依赖 span class token generics function span class token punctuation lt sp
  • Vue

    Author Thor Version 9 0 1 文章目录 一 Vue简介1 1 简介1 2 MVVM 模式的实现者 双向数据绑定模式1 3 其它 MVVM 实现者1 4 为什么要使用 Vue js1 5 Vue js 的两大核心要素1
  • android AudioRecord 音频录制 噪音消除

    android AudioRecord 音频录制 噪音消除 因为公司APP做适配 xff0c 一些低端机的噪音比较严重 xff0c 所以再一些低端机上做了简单除噪音功能 xff0c 1 xff0c 由于APP使用场景的限制 xff0c 所以
  • springboot 常用注解

    springboot 常用注解 在spring boot中 xff0c 摒弃了spring以往项目中大量繁琐的配置 xff0c 通过自身默认配置 xff0c 极大的降低了项目搭建的复杂度 在spring boot中 xff0c 大量注解的使
  • X86架构基本汇编指令详解

    文章目录 汇编指令伪指令1 MODEL2 STACK3 ENDP4 END 汇编指令1 MOV xff1a 将源操作数复制到目的操作数2 MOVZX 和 MOVSX3 XCHG 交换两个操作数内容4 INC 和 DEC5 ADD 和 SUB
  • 详解 C++ 对象模型

    文章目录 何为 C 43 43 对象模型 xff1f 基本 C 43 43 对象模型C 43 43 对象模型中加入单继承1 无重写的单继承2 有重写的单继承 C 43 43 对象模型中加入多继承C 43 43 对象模型中加入虚继承1 简单虚