美文网首页C++
读《深度探索 C++ 对象模型》

读《深度探索 C++ 对象模型》

作者: ampire_dan | 来源:发表于2018-07-22 11:58 被阅读20次

    前言

    这本书是之前京东做活动买的很多本书中的一本(主要阅读时间是周末、每天早上起来吃早餐的时候,以及下班回来时候,前后花了一个月左右吧)。

    C++ 只是在大一下的时候大量使用过,之后就没怎么用过 C++ 了。起初以为看这本书可能比较吃力,因为前言说目标读者是 C++ 有经验的人。但是看的时候感觉没那么难懂,基本上写到的东西都能理解。

    此书应该是上世纪末成书的,书中的内容可能有些过时了(比如内存布局),但是仍然是值得一看的书。总体上感觉就是:为了程序员写代码,同时又为了保证程序语意等的正确和执行期的效率,编译器为我们做了很多事情。

    这里我对每一章内容都写一下我的一些总结和想法,由于是看完全书以后才写的,前面很多内容其实有些忘了(😂),尽量凭回忆和粗略的回看总结一下。。。

    关于对象

    本书第一章讲的是对象。

    c++ 的对象(类)使用其实是非常高效的,这里的高效指的是:对于 C,C++ 大部分简单类的属性存取操作其实是和 C 一样高效的。其他语言没有 C++ 高效有一部分原因是其存取可能涉及到很多间接内存操作,而 C/C++ 大部分存取操作可能只会涉及到一次内存读写。

    C++ 带来比 C 低效的大部分原因是要支持 virtual 机制:

    1. 虚函数
    2. 虚继承

    什么是对象模型?我的理解是语言本身是如何处理类的内存分布的策略,由于现在大部分语言支持 OOP 编程,该策略一个关注点就是如何实现继承,还有语言本身的其他涉及到内存分布的特性,比如虚函数、虚继承、多重继承、RTTI 的支持等。

    书中列举了几个可能的对象模型:

    1. 简单对象模型
    2. 表格驱动对象模型
    3. C++对象模型(被具体实现的模型)

    关于对象模型优劣的角度主要有:

    1. 间接性是否足够少(越少内存访问越快,带来的问题是灵活性不足)
    2. 对象的可拓展性(这是我自己加的,主要指的是,如果基类发生变化,子类是否需要重新编译)

    构造函数语意学

    这一章讲的是 C++ 的构造函数相关内容。

    默认构造函数。

    一个 C++ 类程序员可以不定义其构造函数,但是在有必要的情况下,编译器会自动帮助我们合成一个构造函数,虽然有时候这个构造函数可能并没有什么用。

    为什么默认构造函数是必须的(如果程序员不显式的定义一个构造函数)?

    1. 当当前类定义包含一个成员对象,该成员对象有一个默认构造函数,那么编译器会为当前类合成一个默认构造函数,然后在该函数内部调用成员对象的默认构造函数。
    2. 当当前定义的类是另一个类的子类,父类有一个默认构造函数,那么编译器也会生成一个默认构造函数,在该函数内部调用父类的默认构造函数。
    3. 当当前类带有虚函数的时候,编译器需要设定好虚函数表和虚函数表指针。
    4. 当当前类虚继承于另外一个类。

    如果不是上述这四种情况,是否合成默认构造函数是可有可无的。默认构造函数的合成是出于编译器的角度。而且默认构造函数中编译器关注的内容主要是成员对象的初始化,内建类型(也就是基本类型)的初始化是程序员的责任。

    拷贝构造函数

    有三种情况需要拷贝构造函数:

    1. 显式的将一个对象赋值给另一个对象
    2. 将对象作为参数传递
    3. 将对象作为函数返回值返回

    程序员可以不定义或者定义一个拷贝构造函数,当不定义的时候,默认的拷贝构造函数行为是按成员从当前对象拷贝到另一个对象上的。如果某个成员带有一个显式定义的拷贝构造函数的话,编译器就需要为我们合成一个拷贝构造函数,在函数内部调用该成员的显式拷贝构造函数。否则,编译器就不需要为我们合成拷贝构造函数。

    所以什么情况下编译器必须为我们合成一个拷贝构造函数?(如果程序员不显式的定义一个拷贝构造函数)

    1. 当前类中某个成员对象定义(或者编译器为它合成)了一个拷贝构造函数
    2. 当前类继承的父类定义了一个拷贝构造函数
    3. 当前类有虚函数
    4. 当前类虚继承与另外一个类

    1和2比较简单,编译器简单的将成员对象和父类的拷贝构造函数插入到合成的拷贝构造函数中即可。

    对于3,当将一个子类赋值给父类的时候,如果父类含有虚函数,那么不能简单的将子类的 vptr 赋值给父类对象,否则当父类对象虚函数调用的时候会执行子类的虚函数(这里没有多态)。所以编译器合成的拷贝构造函数要合理的设置 vptr 为父类的 vtable。(即使在程序员显式的定义了一个拷贝构造函数,vptr 的指定也是靠编译器来完成,因为 vptr 对于程序员是透明的)

    对于4,由于虚继承的特殊性,编译器要很仔细的帮助我们处理子类对象赋值给父类对象这种情况,以便合理的设置父类对象的某些值。

    程序转换语意学

    这一部分主要是为了告诉我们,在用到构造函数的地方,编译器为我们的代码做的转换,主要是确保默认构造函数、拷贝构造函数或者显式定义的构造函数们被正确的调用。主要从函数返回值、函数参数、初始化等几个角度讨论。

    接着讨论如何优化编译器的转换代码。比如 NRV 优化,主要是为了尽量避免多余的构造函数的调用。

    成员对象的初始化列表

    为了避免构造函数中多余的成员对象和临时对象的构造函数的调用,一种比较好的方法是使用初始化列表。但是要注意,初始化列表中的初始化顺序不是按照程序员写的顺序执行的,而是按照成员被声明的顺序初始化的。

    成员初始化列表最终会被编译器所转换,注入到用户定义的构造函数的代码内容的最前面。

    Data 语意学

    这一章讲解的是类中的数据。一个类的大小可以使用 sizeof 来取得。决定类的大小有三个因素:

    1. 语言本身的额外负担,比如为了支持虚函数调用机制,类的定义中会被安插一个 vptr 指针。
    2. 某些特殊情况下编译器的优化。比如空的虚继承基类的大小可能会反应到子类上。
    3. 内存对齐的需要

    类数据成员的绑定

    类数据成员在类的成员函数中使用似乎不应该存在太大问题。但是在早期的编译器中却是有问题的。比如类的成员函数在类数据成员定义之前用到了该数据成员,如果刚好在类定义外部有这么一个数据,那么编译器就要决议应该使用的是类内部的数据成员而不是外部的变量。

    数据成员的布局

    类的静态数据成员是被存放在类外部的,不会影响到对象的内存布局。非静态数据成员的内存布局依赖各个编译器具体的实现,C++ 标准并不加以限制。类的另外一些对程序员透明的成员,比如 vptr,它的位置是哪儿呢?编译器可以放在类定义的最前端或者最后面。编译器也可以合理的组织不同访问权限的(public、private、protect)成员在各自的区域。

    数据成员的访问

    首先是静态数据成员的访问。由于静态数据成员是存储在程序的数据段中,无论是通过指针还是对象来访问,这两种方式访问没有任何效率或者其他实质的区别。

    如果有两个类含有相同名字的静态数据成员,由于静态数据是存在数据段中的,为了防止名字冲突,编译器会进行名字处理,也就是 name-mangling。

    其次是非静态数据的访问。在成员函数中访问成员函数,编译器其实会做代码上的一些转换,将当前this 对象作为函数的第一个参数,函数中对成员数据的访问是通过改 this 指针来完成的。

    继承和数据成员

    一般来说,子类的数据成员会被定义在父类的下面(当然,这方面没有绝对的要求)
    在这一小节,作者分两个角度讨论:

    1. 一个是继承无多态
    2. 含多态的继承
    3. 多重继承
    4. 虚继承

    对于1,在编译器设计实现的时候需要特别注意一个问题,就是对于为了内存对齐所额外使用的空间的使用。父类存在为了内存对齐而多占用了几个字节大小,如果子类是紧随在父类之后的,并且为了节省空间考虑而将自己的数据成员填充到父类的内存对齐的几个字节中,那么,在将一个父类对象赋值给子类对象的时候,父类对象的内存对齐字节会覆盖了子类对象的起始数据成员,这显然是错误的。

    对于2,含多态的类的关注点是,将 vptr 放在类对象的哪儿呢?可以是开始,也可以是最后。

    对于3,多重继承要关注的是如何正确的处理派生类实例和其第二个父类的转换。答案是通过偏移量来处理。

    虚拟继承要解决的问题是多重继承中可能存在的菱形继承问题。虚拟继承的实现有两种方式:

    1. 指针策略
    2. 虚函数表偏移策略

    1是指,通过附加一个每个虚拟继承子类添加一个指向共享部分的一个指针。2是指,在虚函数表中放置虚拟继承基类的偏移量

    指向数据成员的指针

    当需要了解类中成员对象的底层内存布局的时候,使用这类指针会特别有用,因为这类指针取到的就是该数据成员在类模型中的偏移量。

    为了区分“没有指向任何数据成员” 和指向第一个数据成员这两种情况,所有的指针都会被加1,在具体使用的时候需要减1

    函数语意学

    非静态成员函数

    非静态成员函数是如何实现的呢?

    1. 编译器会改写该成员函数的签名,安插一个额外的参数到函数中,也就是 this 指针。
    2. 所有对非静态成员数据的访问都是通过 this 指针来完成的
    3. 该非静态成员函数的名字会被 name mangling,使得它的名字在程序中是独一无二的,又能反应函数的签名、所属类等的信息

    虚拟成员函数

    虚拟成员函数是如何实现的呢?

    1. 每一个类对象中会又一个 vptr 指针,用来指向存储虚函数列表。由于继承体系的复杂,vptr 指针很有可能被 mangling
    2. 对虚函数的调用是通过 vptr 指针,和其中虚函数列表中的索引值来的。
    3. 在虚函数第一个参数位置传递调用者指针,也就是 this 指针
    • 在单一继承体系中,每一个类对象如果有实现一个虚函数,则会重写其 vptr 相应索引值的来自直接基类的函数指针,使其指向自己的实现。
    • 在多重继承体系中,虚拟函数的实现比较复杂。一种是调整 this 指针,缺点是效率底下,解决方法是使用所谓的 thunk 函数。比较好的解决方法是用派生类中实现的虚函数指针重写基类们的 vptr 对于索引值的值。

    静态成员函数

    静态成员函数是如何实现的?

    一个非静态成员函数如果没有用到非静态成员变量的话,其实没有必要让它通过类实例来调用。但是如果类中存在一个 nonpublic 的静态成员函数,那么类必须提供成员函数来访问它。解决之道是引入静态成员函数。

    静态成员函数最大特点是没有 this 指针,所以它又如下特点:

    1. 不能直接访问类中的非静态成员
    2. 不能被声明为 const、 volatile 或者 virtual
    3. 不需要通过类实例来调用(虽然语法上可以,但是最终转换的代码并不需要类的实例)

    静态成员函数由于没有 this 指针,看起来很像是非成员函数。

    成员函数指针

    1. 非虚拟成员函数的指针实际上是函数在内存中的指针
    2. 虚函数指针实际上是虚函数在虚函数表中的索引值
    3. 多继承下的成员函数指针是一个结构体,index、 faddr 和 delta。如果是非虚函数,则 index 为 -1,否则 index 指的是虚函数表中的索引值。faddr 是非虚拟成员函数的内存地址,delta 是一个可能的 this 指针调整值。

    内敛函数

    处理内敛函数有两个步骤:

    1. 根据复杂度分析函数是否可以被内敛,若不能,则被转换成静态函数。
    2. 内敛函数的扩展是在调用的点上的。

    内敛函数的扩展会带来两个问题:

    1. 参数求值
    2. 临时对象管理

    临时对象管理一个可能的情况是内敛函数的参数是另一个函数调用返回的值(基本类型或者类类型)。编译器要正确的处理临时对象的释放问题。

    内敛函数是 C 中的 #define 的一个安全替代,特别是宏有它的负作用。但是内敛函数被使用太多的话,会产生大量的代码,使得程序代码增大。同时内敛函数要管理可能产生的临时对象,还有就是内敛嵌套内敛这种复杂的情况。所以内敛函数有其优点但是要小心使用。

    构造、析构、拷贝语意学

    未完待续。。。

    相关文章

      网友评论

        本文标题:读《深度探索 C++ 对象模型》

        本文链接:https://www.haomeiwen.com/subject/kcylmftx.html