前言
最近在学习《深度探索C++对象模型》,真心觉得这是一本很不错的书籍,看了之后觉得自己对对象的内存布局、构造、析构操作以及虚函数的调用等有了进一步的了解,对于一些C++规则存在的内在原因也算是知其所以然了。比如说C++2.0之前要求inline函数“类内声明,类外定义”,原因是在C++早期的编译器上,如果inline函数中存取的data member在该函数之后声明,则data member的绑定会出错。因此,C++2.0规定一个内联函数实体,在整个类声明未被完全看见之前,是不会被评估求值的,也就是说inline函数体中的数据绑定放到类声明之后执行。
此篇记录原书第一章的学习笔记。第一章主要介绍了C++与C之间因不同的设计理念而带来的封装上的差异以及C++支持的三种不同的程序设计范式,并简要介绍了C++的对象模型。
C这种程序性的语言将“数据”和“处理数据的操作(函数)”分开来声明,而C++将数据和函数封装为抽象数据类型(ADT)。
一、加上封装后,布局成本增加了多少?
封装本身并不会增加任何成本。data members直接内含于一个class object中,就像C中的struct一样。而member functions虽然含在class的声明中,却不出现在object中(更像是类命名空间中的普通函数),每个non-inline member function只会诞生一个函数实例。
C++在布局以及存取时间上主要的额外负担是由virtual引起的,包括:
- virtual function机制:需要保存vtbl和透过vtbl找到函数地址;
- virtual base class机制:需要透过指针(vbptr)指向的vbtbl来找到基类的成员,vbtbl中存放本类与虚基类之间的offset。
另外还有一些多重继承下的额外负担,发生在“一个derived class和其第二或后继之base class的转换”,这个过程中需要指针的偏移。
二、C++的对象模型
(1)首先,
- Nonstatic data members被配置于每一个class object之内;
- Static data members则被存放在所有的class object之外;
- Static和Nonstatic function members 也被放在所有的class object之外。
(2)其次,Virtual function机制则由以下的2个的两个步骤来支持:
- 每一个class产生出一系列指向Virtual functions的指针,放在一个被称为virtual table(vtbl, vtable)的表格中;
- 每一个class object被添加了一个指针vptr,指向相对应的vtable。vptr的设置由编译器全权负责(编译器通过向class的constructor、destructor和copy assignment运算符中添加代码实现vptr的设定和重置),程序员无需关心。
(3)虚机制的实行仰仗于C++的RTTI(运行时类别识别),每个class相关联的type_info object的指针也保存在vtbl的第一个slot中。type_info是C++ Standard所定义的类型描述器的class名称,它重载了operator=()、operator!=()、name()等成员函数。运行时,两个类型描述器被交给一个runtime library函数,若两个类型之间是相等或is-a关系,则返回真正的地址,否则返回0。
(4)需要清楚的明白一点是:
一个 vtable 对应 一个 class , 一个 vptr 才对应 一个 class object,必须区分开这2个概念。
(5)引入继承后的对象模型成本:
- 如果是普通的继承,父对象被直接包含在子对象里面,这样对父对象的存取也是直接进行的,没有额外的成本;
- 如果是虚拟继承,则父对象会由一个指针被指出来,这样的话对父对象的存取就添加了一层间接性,必须经由一个指针(vbptr)来访问,添加了一次间接的额外成本。
三、struct关键字
(1)C++优先判断一个语句为声明:当语言无法区分一个语句是声明还是表达式时,就需要用一个超越语言范围的规则 —— C++优先判断为声明。
(2)struct和class关键字的意义:
- 它们之间在语言层面并无本质的区别,更多的是概念和编程思想上的区别。
- struct用来表现那些只有数据的集合体POD(Plain OI’ Data)、而class则希望表达的是ADT(abstract data type)的思想;
- 由于这2个关键字在本质是无区别,所以class并没有必须要引入,但是引入它的确非常令人满意,因为这个语言所引入的不止是这个关键字,还有它所支持的封装和继承的哲学;
- 可以这样想象:struct只剩下方便C程序员迁徙到C++的用途了。
(3)因为C++只保证处于同一个access section的数据,必定以其声明顺序出现在内存布局当中。所以C的一些伎俩在C++中不一定有效,比如把单一元素的数组放在一个struct的尾端,于是每个struct objects可以拥有可变大小的数组。
(4)与C兼容的内存布局: 组合,而非继承,才是把 C 和 C++ 结合在一起的唯一可行的方法。组合就是在一个类中以另一个类的对象作为数据成员。C struct在C++中的一个合理用途就是,当你要传递”一个很复杂的C++ class object的全部或部分“到某个C函数去时,struct声明可以将数据封装起来,并保证拥有与C兼容的空间布局。
四、对象的差异
(1)C++程序设计模型直接支持三种程序设计范式:
程序模型(面向过程的风格):就像C一样,一条语句接一条语句的执行或者函数跳转;
抽象数据类型模型(基于对象的风格):仅仅使用了 class 的封装,很多人都是在用基于对象的风格却误以为自己在使用面向对象的风格;
面向对象模型(面向对象的风格):使用了 class 的封装和多态的编程思维(多态才是真正的面向对象的特征)。
不同范式的混用,可能会导致一些不好的后果。比如说,虽然你可以直接或间接处理继承体系中的一个base class object(抽象数据类型模型的良好行为),但只有通过pointer或reference的间接处理,才支持OO程序设计所需的多态性质(面向对象模型的行为)。
注:编译器层面,一个reference通常是以一个指针来实现的。
(2)多态的用途:一个接口,多种实现
(3)C++支持多态的方式:
经由一组隐式的转化操作
shape* ps = new circle(); //将派生类指针转化为指向基类的指针
经由virtual function机制
ps->rotate(); //rotate()为虚函数
(*ps).rotate();
经由dynamic_cast和typeid运算符
if(circle *pc = dynamic_cast<circle*>(ps))...
(4)一个对象的内存布局大小
- 其nonstatic data member的总和大小;
- 任何由于位对齐所需要的填补上去的空间,字节对齐一方面是为了不同硬件平台间的兼容,因为各个硬件平台对存储空间的处理不尽相同;另一方面是为了使bus的”运输量“达到最高效率,因为bus是以字节块为单位运送数据的,字节对齐可以避免一个数据被放到2个字节块中,从而需要多执行一次内存访问。
- 加上了为了支持virtual机制而引起的额外负担。
(5)指针的类型
”指向不同类型的指针“间的差异,实际上在于它所寻址出来的对象的类型不同。而指针本身占据的内存空间是一定的,在32位机器上占用4个字节,64位机器上为8个字节。
”指针类型“会教导编译器如何解释某个特定地址中的内存内容和大小。换句话说就是,虽然基类指针和派生类指针指向同一个位置,但是类型的不同使得它们所涵盖的范围不同。在单继承的情况下,使用基类指针指向派生类对象,在执行期间,我们明确了指针指向的对象类型,即指针所涵盖的地址范围,所以我们可以利用该指针操作派生类的成员。如果需要调用virtual function,则需要通过该对象对应的vptr找到该类的vtbl,然后调用virtual function。
多态只能由”指针“或”引用“来实现,根本原因在于:
它们并不引发内存中任何”与类型有关的内存委托操作“。会受到改变的,只有它们所指向的内存的”大小和内容解释方式“而已。