蓝色:概念、讨论主题
下划线:值得注意的内容
紫红色:比较重要的地方
红色:必须理解的地方
第一章 关于对象
介绍了C++ 如何在背后实现一个对象,内存中的布局以及空间上的关系
- C++ 中以类对数据进行封装后,布局成本是否会增加?
- 一般来说,并不会增加成本。每个data member直接内含在每一个class object中,就像C struct的情况一样。而member funcitons虽然含在class声明中,却不出现在object中。所以类的封装并不会增加布局成本
- C++在布局以及存取时间上主要的额外负担是由virtual引起的,包括:
- virtual funcitons机制:每一个class有一个virtual table内含class之中有作用的virtual funcitons地址,每个object有一个vptr指向virtual table所在
- virtual base class: 用以实现 “多次出现在继承体系中的base class有一个单一而被共享的实例”
- C++ 布局成本的主要来源就是virtual机制
- C++对象模型(The C++ Object Model)
- non-static data members被配置于每一个class object之内
- static data members则被存放在所有class object之外
- static/non-static funciton members也被放在所有class object之外
- virtual funciton 机制由以下两个步骤来支持:
- 每一个class产生出一系列virtual funciton的指针,放在一个被称为virtual table(vtbl,vtable的表格中;
- 每一个class object被添加一个vptr指向相应的vtable,vptr的设置由编译器全权负责,程序员无须关心
- RTTI : 一般来说,每一个class相关联的type_info object通常也保存在vtable的第一个slot中
- 需要清楚明白的一个概念: 一个vtbl对应一个class,一个vptr对应一个class object
- 引入继承后对象模型成本:
- 普通继承 : 父对象被直接包含在子对象中,这样对父对象的存取也是直接的,没有额外成本;
- 虚拟继承 : 父对象会由一个指针指出来,这样对于父对象的存取就多了一层间接性,必须由一个指针来访问,这样就添加了一次间接的额外成本
- C++优先判断一个语句为声明 : 当语言无法区分一个语句是声明还是表达式时,就需要用一个超越语言范围的规则——C++优先判断为声明
- struct和class关键字的意义 :
- 它们之间并无本质区别,更多的是概念和编程思想上的区别。
- struct 用来表现那些只有数据的集合体POD(Plain OI’ Data)、而class则希望表达的是ADT(abstract data type)的思想;
- 两个关键字本质是无区别的,所以class并没有必须引入,但是引入它的确非常令人满意,因为这个语言所引入的不止是这个关键字,还有它所支持的封装和继承的哲学;
- struct方便C程序员迁移到C++
- C++只保证处于同一个access section 的数据,一定以声明的次序出现在内存布局当中。
- 与C兼容的内存布局 : 组合,而非继承,才是把C的C++结合在一起的唯一可行的方法
- 只有使用组合时,才能保证C拥有相同的内存布局,使用继承的内存布局是不受C++ standard 所保证的
- C++ 支持三种形式的编译风格(programming paradigm) :
- 面向过程的风格 : 就像C一样, 一条语句接一条语句的执行或者函数跳转;
- 基于对象的风格(object-based) (或称ADT) : 仅仅使用了class封装,很多人都是在用基于对象的风格却误以为自己在使用面向对象的风格;
- 面向对象的风格(object-oriented) : 使用了class的封装和多态的编程思维(多态才是真正的面向对象的特征)。
- 纯粹以一种paradigm写程序,有助于整体行为的良好稳固。
- C++ 支持多态的方法 :
- 经由一组隐式的转化操作,例如把derived class 指针转化为一个指向其public base type 的指针:
shape *ps = new circle()
- 经由 virtual function 机制 : ps->test();
- 经由 dynamic_cast 和 typeid 运算符 : if (circle *pc = dynamic_cast<circle *> (ps)) ….
- 一个reference通常是以一个指针实现的,所以point和reference并没有本质的区别。
- 一个对象的内存布局大小(通常由3部分组成) :
- 其nonstatic data member 的总和大小;
- 任何由于位对齐所需要的填补上云的空间;
- 加上为了支持virtual 机制而引起的额外负担。
- 这也印证了前面的一个结论 : C++中的额外成本都是由于virtual 机制所引起的。
- 指针的类型 :
- 对于内存来说,不同类型的指针并没有什么不同。它们都是占用一个word的大小,包含一个数字,这个数字代表内存中的整体上地址;
- 感觉上,指针的类型是编译器的概念,对于硬件来,并没有什么指针类型的概念;
- 转型操作也只是一种编译器的指令,它改变的内是编译器对被内存的解释方式而已!
- 多态只能由”指针”或”引用”来实现,根本原因在于 :
- 指针的引用(通常以指针来实现)的大小是固定的(一个word),而对象的大小却是可变的。其类的指针和引用可以指向(或引用)子类,但是基类的对象也只能是基类,没有变化不可能引发多态。
- 一个point或reference绝不会引发任何”与类型有关的内存委托操作”,在指针类型转换时会受到的改变的只有它们所指向内存的解释方式而已。(例如指针绝不会引发slice,因为它们大小相同)
- 在初始化、assignment等操作,编译器会保证对象的vptrs得到正确的设置。这是编译器的职责。一般都是通过在各种操作中插入编译器的代码来实现的。
第二章 构造函数语意学
详细的讨论了constructor如何工作,讨论构造一个对象的过程以及构造一个对象给程序带来的影响。
- C++中对于默认构造函数的解释为: 默认的构造函数会在需要的时候被编译器产生出来。
- 这里非常重要的一点是: 谁需要? 是程序的需要还是编译器的需要 ?
- 如果是程序的需要,那是程序员的责任;只有在是编译器的需要时,默认构造函数才会被编译器产生出来,而且被产生出来的默认构造函数只会执行编译器所需要的行动,而且这个产生操作只有在默认构造函数真正被调用时才会
- 例如成员变量初始化为0操作,这个操作是程序和需要,而不是编译器的需要。
- 区分trivial 和 notrivial :
- 只有编译器需要的时候,合成操作才是notrivial的, 这样的函数才会被真正的合成出来;
- 如果编译器不需要,而程序员又没有提供,这时的默认构造函数就是trivial的。虽然它在概念上存在,但是编译器实际上根本不会去合成出来,因为他不做任何有意义的事情,所以当然可以忽略它不去合成。trivial的函数只存在于概念上,实际上不存在这个函数。
- 总结变量的初始化 : 只有全局变量和静态变量才会保证初始化,其中静态变量可以视为全局变量的一种,因它静态变量也是保存在优变量的存储空间上的。
- Golbal objects 的内存保证会在程序激活的时候被清0;Local objects 配置于程序的堆栈中, Heap objects 配置于自由空间中,都不一定会被清0,它们的内容将是内存上次被使用后的痕迹
- 类声明头文件可以被许多源文件所包含,如何避免合成默认构造函数、拷贝构造函数、析构函数、赋值拷贝操作符(4大成员函数)时不引起函数的重定义?
- 解决方法是以inline的方式完成,如果函数太复杂不适合inline,就会合成一个excplicit non-inline static实体(static 函数独立于编译单元)
- 如果class A内含一个或以上的member objects,那么A的constructor必须调用每一个member class的默认构造函数。
- 具体方法是 : 编译器会扩张constructor,在其中安插代码使得在user code被调用之前先调用member objects 的默认构造函数(当然如果需要调用基类的默认构造函数,则放在基类的默认构造函数调用之后:基类构造函数->成员构造函数->user code)。
- C++ 要求以”member objects 在 class 中的声明次序”来调用各个constructors。这就是声明的次序决定了初始化次序(构造函数初始化列表一直要求以声明顺序来初始化)的根本原因!
- 带有virtual funcitons 的类的默认构造函数毫无疑问是nontrivial的,需要编译器安插额外的成员vptr并在构造函数中正确设置vptr,这是编译器的重要职责之一。
- 带有virtual base class 的类的默认构造函数同样也是毫无疑问的nonstatic的,编译器需要正确设置相关的信息以僵持这些virtual base class 的信息能够在执行时准妥当,这些设置取决于实现虚基类的手法。
- 编译器有4种情况会使得编译器真正为class生成nontrivial的默认构造函数,这个nontrivial的默认构造函数只满足编译器的需要(调用member objects 或base class 的默认构造函数、初始化virtual funciton 或 virtual base class 机制)。其它情况时,类在概念上拥有默认构造函数,但是实际根本不会被产生出来(前面的区分)
- C++新手常见的两个误区
- ERROR : 如果class 没有定义default constructor 就会合成一个;
- 首先定义了其它constructor就不会合成默认构造函数,其次即使没有定义任何函数也不一定会合成default constructor,可能仅仅是概念上有, 但实际上不合成出来。
- ERROR : 编译器合成出来的默认构造函数会明确设定一个data member 的默认值;
- 明显不会,区分了Golbal objects,stack objects,Heap objects 就非常明白了只有在Golbal 上的objects会被清0,其它的情况不会保证被清0。
- Copy constructor 和默认构造函数来说,只有在必须的时候才会被产生出来,对于大部分的class 来说,拷贝构造函数仅仅需要按位拷贝就可以。满足bitwise copy semantics的拷贝构造函数是trivial的,就不会真正被合成出来(与默认构造函数,只有nontrivial的拷贝构造函数才会被真正合成出来)。
- 对于大多数类按位拷贝就够了,什么时候一个class 不展现bitwise copy semantics呢? 有以下四种情况
- class内含一个member object而后者声明了(或者是由于nontrivial而被合成出来的)一个copy constructor时;
- 当class 继承自一个base class 而后者存在一个copy constructor时(不论显示定义或是合成而得的)
- 当class 声明了一个或多个virtual funcitons时; (vf 影响了位语意,进而影响了效率)
- 当class 派生自一个继承串链,其中一个或多个virtual base classes时。
- NVR优化 : 编译器会把返回值作为一个参数传到函数内,比如:
X foo() {...}
会被更改(也可以手动做这个优化)为 :void foo(X & result) {...}
;- 从使用者角度,用
return X(...)
代替X x;return x;
能够辅助这个优化操作。
- 不要随意提供copy constructor,对于满足bitwise copy constructor的类来说,编译器自动生成的拷贝构造函数自动地使用了位拷贝(这是效率最高的),如果你自己随意提供copy constructor就会压抑掉编译器的这个行为,这还会影响效率。
- 成员初始化列表 : 在成员初始化列表背后发现的事情是什么?
- 编译器会一一操作初始化列表,把其中初始化操作以member声明的次序在constructor内安插初始化,并且在任何excplicit user code 之前。
- “以member声明的次序来决定初始化次序”和”初始化列表中的排列次序”之间的外观错乱,可能会导致一些不明显的Bug
- 不过GCC已经强制要求使用声明次序来进行初始化以避免这个陷阱。
- 理解了初始化列表中的实际执行顺序中”以member声明的次序”来决定的,就可以理解一些很微妙的错误。比如:
A() : i(99),j(66),value(foo()) {...}
- int i, int j;
- 这会不会产生错误取决于成员函数foo()是依赖i还是j;
- 如果foo依赖于i,由于i声明在value之前,所以不会产生错误;
- 如果foo依赖于j,由于j声明在value之后,就产生了使用未初始化成员的错误。
第三章 Data语意学
C++ 对象模型的细节,讨论data member的处理。
- 空类也有1Byte的大小,因为这样才能使得这个class 的2个objects在内存中有独一无二的地址。
- 一个对象内存布局大小(通常由3外部分组成)
- 其nonstatic data member的总和大小;
- 任何由于位对齐所需要的填补上去的大小;
- 加上为了支持virtual机制而引起的额外负担;
- 对member functions本身的分析会直到整个class 的声明都出现才开始。所以class的member funcitons可以引用声明在后面的成员,C语言就做不到。
- 和第3条对比,需要十分注意的一点是 : class 中的typedef并不具备这个性质。
- 因此,类中的typedef影响会受到函数与typedef的先后顺序的影响。
1
2
3
4
5
6
7typedef int length;
class Point3d {
public:
void f1(length l) {cout << l << endl; }
typedef string length;
void f2(length l) { cout << l << endl; }
}; - 这样f1绑定的length类型是int;而f2绑定的length类型才是string。
- 所以,对于typedef需要防御性的风格: 始终把nested type声明(即typedef)放在class起始处!
- 传统上,vptr被安放在所有被明确声明的member的最后,不过也有些编译器把vptr放在最前面(MSVC++ 就是把vptr放在最前面,而GCC是把vptr放在最后面).
- 在C++中,直观上来说,由一个对象存取一个member会比由一个指针存取一个member更快捷。但是对于经由一个对象来存取和一个指针来存取一个静态的member来说,是完全一样的,都会被编译器所扩展。
- 经由一个函数调用的结果来存取静态成员,C++标准要求必须对这个函数进行求值,虽然这个求值的结果并无用处,例如:
1
foobar().static_data = 10;
- foo()返回一个类型为X的对象,含有一个static_data,foobar()其实可以不用求值而直接访问这个静态成员,但是C++标准保证了foobar()会被求值,可能的代码扩展为:
1
2(void) foobar();
X::static_data = 10;
- 对一个nonstatic data member 进行存取操作,编译器会进行如下扩展:
1
2
3
4
5origin._y = 0.0;
那么地址&origin._y将等于:
&origin + (&X::_y - 1);
- 注意其中的-1的操作,指向data member 的指针,其offset 值总是被加上1,这桩可以2使编译器系统区分”一个指针data member 的指针,用以指向class的第一个member”和”一个指向data member的指针,但是没有指向任何member”两种情况(成员指针也需要有个表示NULL的方式,0相当于用来表示NULL了,其它的就都要加上1了)。
- 以下这两种写法有什么区别?
1
2
3X x; x.x = 0.0;
*px = &x; px->x = 0.0;
- 答案是:X是一个派生类,而其继承结构有一个虚基类,且x成员又是蜡烛基类中的成员,这两种写法在编译器眼中就有巨大区别了因为我们不能明确的说pt必然指向哪一个class type,故我们也就不知道编译时期member 真正的offset位置,存取操作只能延时到执行期。对于x来说类型固定,所以member的offset位置也在编译时期就确定了。
- 派生类的成员和基类的排序并未在C++ standard中强制指定;理论止编译器可以自由安排,但是对于大部分编译器实现来说,都是把基类成员放在前面,但是virtual base class 除外。(一般而言,任何一条规则一旦碰到virtual base class 就没辙了)。
- C++ standard中保证: 出现在派生类中的base class subobject有其完整原样性!
- 子类会被放在父类的对齐空白字节之后,因为父类的完整性必须得以保证,父类的对齐空白字节也是父类的一部分,也是不可分割的。
- 支持多态所带来的4个负担:
- 导入virtual table 用来存放每一个virtual funcitons的地址。这个Table 的元素数目一般而言是被声明的virtual funcitons数目再加上一个或两个slots(用心支持RTTI);
- 在每一个class object 中安插一个vptr指向相应vtable;
- 在constructor 中安插代码以正确设置vptr,让它指定class 所对应的virtual table;
- 在deconstructor 中安插代码以正确设置vptr,使它能够抹消”指向class 之相关virtual table”的vptr;
- 单一继承并含有虚拟函数时的内存布局(考虑把vptr放在尾部的情况)
- 单一继承时vptr被放在第一个子类的末尾,产生这样的布局的原因在于”基类的完整性必须在子类中得以保存“。
- 对于第一个__vptr__Point2d的vptr可以这样理解,由Point2d而引发的vptr,在Point2d的对象中,这个__vptr__Point2d所指向的是与Point2d所指向的是与Point2d所对应的point2d_vtable,而在Point3d的对象中,这个__vptr__Point2d所指向的却是与Point3d所对应的point3d_vtable.
- 多重集成时的布局(多重继承时的主要问题在于派生类与非第一基类之间的转换):
- 在多重继承的派生体系中,将派生类的地址转换为第一基类是成本与单继承是相同的,只需要修改地址的解释方式而已;而对于转换为第一基类的情况,则需要对地址进行一定的offset操作才可以。
- C++ standard 并未明确base classes 的特定排列次序,但是目前的编译器都是按照声明的次序来安放他们的。(有一个优化:如果第一基类没有vtable而后继基类有,则可能把它们调换位置).
- 多重继承中,可能会有多个vptr指针,视其继承体系而定:派生类中的ptr的数目等于所有基类的vptr数目的总和。
- 虚拟继承:虚拟继承就是把一个类切割成两个部分:一个不变局域和一个共享局部。
- 这个共享局部必须通过编译器安插的一些指针指向virtual base class object 来间接的存取,这样才能够实现共享。对于这个安插指针来实现共享的技术,有两种主流的做法:
- 一种做法就是直接使用一个指针指向虚基类:
- 另一种做法就是在vtable 中放置virtual base class的offset:
- 这种使用偏移地址的方式好处在于:vptr是已经存在的成本,而vtable是class的所有objects所共享的成本。对于每一个class object没有引入任何的额外成本,仅仅在vtable多存储了一个slot布局,而前一种方式却对每一个object都引入了两个指针的巨大成本。
- 这两种方式教师把虚基类放在内存模型中的最后面,然后借由一层间接性(指针或offset)来访问。
- 一种做法就是直接使用一个指针指向虚基类:
- 一般而言:virtual base class 最有效的一种运用形式就是:一个抽象virtual base class ,没有任何data members。
- 普通封装不会带来任何执行期的成本,编译器可以轻松优化掉普通封装带来的任何成本。
- 但是一旦涉及到虚拟继承,效率就会大幅降低,在有n层的虚拟继承体系中,普通的访问就要经过n次间接,普通访问的成本就变为了n倍。
- 再次表示,C++中的额外成本基本都是由于virtual 机制引起的。
- 指向Data Members 的指针内部实际保存的是这个data member 相对于对象起始地址的偏移地址(offset)(但需要另外加1以区分空指针,前面有讲过了)
- 使用指向Data Members的指针时也不会损失效率,成本与直接存取相同。就像第17条所说的,普通访问没有额外成本,但是遇到虚拟继承效率就大幅降低。
第四章 Function语意学
C++对象模型的细节,讨论了member funcitons,尤其是virtual funciton。
- C++的设计准则之一就是:nonstatic member funciton 至少必须一般的nonmember funciton 有相同的效率。
- 实际上,nonstatic member funciton 会被编译器进行如下的转换,变成一个普通函数
1
2// non-const nonstatic member
Type1 X::foo(Type2 arg1) {....} - 会被转换为如下普通函数(可能的内部转换结果):
1
2//C++伪码
void foo(X* const this,Type1& _result,Type2 arg1) {....}
- 如何确定函数是否为non-static?
- 它是否可以直接存取nonstatic数据。
- 看这个函数是否被声明为const。
- 实际上,普通函数,普通成员函数,静态成员函数到最后才会变成与C语言类似的普通函数,只是编译器在这些不同类型的函数身上做了不同的扩展,并放在不同的scope里面而已。
- 虚拟成员函数:ptr->normalize();
- 会被内部转化为: ( * ptr->vptr[1]) (ptr);
- 事实上vptr的名称也会被”mangled”(名称切割重组),因为对于一个复杂的派生体系,可能会有多个vptr。前面总结过了,一个派生类中的vptr数目等于其基类的总和。
- static member funciton 主要特性:
- 不能直接存取其class 中的nonstatic members.
- 不能被声明为const、volatile或virtual.
- 不需要经由class object 才被调用,虽然大部分时候都是这样使用的。
- 静态成员函数其实就是带有类scope的普通函数,它也没有this指针,所以它的地址类型并还是一个指向成员的指针而仅仅是一个普通的函数指针而已。
- 静态成员函数是作为一个callback的理想对象,要类的scope内,又是普通的函数指针。
- 识别一个classs是否支持多态,唯一的适当的方法就是看它是否有任何的virtual funciton。只有class声明有任意一个virtual funciton,那么它就需要额外的执行期信息vtable.
- 对于单一继承的情况,每个类最多只会有一个vptr指针,并放在第一个拥有virtual function 的类的后面(父类必须保证对象的完整性)。
- 在单一继承的内存布局下,virtual function是如何工作的呢?因为在调用一个成员函数ptr->z()时;
- 虽然不能确定ptr直接指向的类型,但是可以经由ptr找到它的vtable,而vtable记录了所指对象的真正类型(一般对象的type_info 放在vtable的第一个slot中)
- 虽然不能确定应该调用的z函数的真正地址,但是可以知道vtable中被放在哪一个slot,于是就直接去vtalbe中相应的slot中取出真正的函数地址加以调用。
- 多重继承下的内存布局:
- 要多重继承中比单一继承更复杂的地方在于对大是大非第一基类的指针和引用进行操作时,必须进行一些执行期的调整this指针的操作。
- 比如对于简单的delete 操作:delete base2;由于base2可能没有指向对象的起始地址,这样简单的删除操作都会引发巨大灾难,所以需要对base2做执行期的调整才能正确的delete对象。
- 在多重继承下,一个derived class 可能同时含有多个vptrs指针,这取决于它的所有基类的情况(基类完整性定义)。也可能有对应的多个vtables(如cfront),但也可能无论如何只有一个vtable(把所有的vtables合成一个,并使得所有的vptrs都指向这一个合成的vtable+offset,如Sum的编译器),这些都取决于编译器的策略。
- 有三种情况,非第一基类会影响对虚函数的支持:
- 通过一个指向非”第一base class”的指针,调用derived class virtual function。最后为了能够正确执行,ptr必须调整指向derived对象起始处。
1
2
3
4
5Base2 *ptr=new Derived;
//调用Derived::~Derived()
//ptr必须向后调整sizeof(Base1)个byte
//因为非第一base class的地址指定操作,需要加上或减去介于中间的base class subobject(s)的大小
delete ptr; - 这种情况是第一种情况的变形,通过一个”指向derived class“的指针,调用第二个base class中的一个继承而来的virtual function。最终derived class 指针必须再次调整,经指向第二个base subobjec最终derived class 指针必须再次调整,经指向第二个base subobject。
1
2
3
4Derived *pder=new Derived;
//调用Base2::mumble()
//pder必须向前调整sizeof(Base1)个byte
pder->mumble(); - 第三种情况发生在一个语言扩充性质下,允许一个虚函数的返回值类型有所变化,可能是base type,也可能是publicly derived type。
1
2
3
4Base2 *pb1=new Derived;
//调用Derived * Derived::clone()
//返回值必须调整。以指向Base2 对象
Base2 *pb2=pb1->clone;
- 通过一个指向非”第一base class”的指针,调用derived class virtual function。最后为了能够正确执行,ptr必须调整指向derived对象起始处。
- 虚拟继承下的内存布局:
- Lippman建议:不要在一个virtual base class中声明nonstatic data members。如果一定要这么做,那么你会距离复杂的深渊愈来愈近,终不可拔。
- 这里的函数性能测试表明,inline函数的性能如此之高,比其它类型的函数高的不是一个等级。因为inline函数不只能够节省一般函数调用所带来的额外负担,也给编译器提供了程序优化的额外机会。
- 取一个nonstatic member function 的地址,如果该函数是nontrivial,则得到的结果是它在内存中的真正地址。然而这个值是不完全的,它需要被绑定于某个class object的地址上,才能够通过它调用该函数。
- 指向virtual member funcitons的指针:
- 对于一个virtual member function取其地址,所能获得的只是一个vtable的索引值
- inline函数扩展时的实际参数取代形式参数的过程,会聪明地引入临时变量来避免重复求值。
1
2
3
4
5
6
7
8
9
10
11
12
13假设函数:
inline int min(int i,int j)
{
return i < j ? i : j;
}
三种调用方式:
int minval;
int val1 = 1024;
int val2 = 2048;
minval = min(val1,val2); //方式一
minval = min(1024,2048); //方式二
minval = min(foo(),bar()+1) //方式三
- 方式一,由于是参数,所以可以直接代换
- 方式二,由于是常数,所以可能直接拥抱常量
- 方式三,会引发参数副作用,所以需要引入一个临时变量,用以避免重复求值。
- inline 中再调用inline函数,可能使得表面上一个看起来很平凡的inline却因连锁的复杂性而没有办法扩展开来。
- 对于既要安全又要效率的程序,inline函数提供了一个强而有力的工具,然后与non-inline函数比起来,它们需要更加小心处理。
第五章 构造、解构、拷贝语意学
C++对象模型的细节,讨论了class 的整个模型,一个对象的完整生命周期
- 纯虚函数志可以被调用,方式如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23class A
{
public:
virtual ~A() {}
virtual void f() = 0;
};
//纯虚函数必须定义在类声明之外
void A::f() {std::cout << "pure virtual" << std::endl;}
class D : public A
{
public:
//纯虚函数必须经由派生类显式的要求调用
virtual void f() {A::f(); }
};
int main()
{
D d;
d.f();
return 0;
}
- 输出结果为”pure virtual”,这里需要注意几点:
- 纯虚函数不能在类的声明中提供实现,只能在类声明的外部来提供默认的实现;
- 基类的纯函数的默认实现必须由派生类显式的要求调用;
- 派生类不会自动继承这个纯虚函数的定义,如果派生类D未定义f(),那么A依然是一个抽象类型;
- 这种pure virtual function 实现的方案比较好的应用场景为:基类提供了一个默认的实现,但是不希望自动的继承给派生类作用,除非派生类明确的要求。
- 仍需要注意这个纯函数为析构函数的情况。C++语言保证继承体系中的每一个class object 的 deconstructor 都会被调用。所以编译器一定会扩展派生类的析构函数去显式地调用基类的原构函数。
- 另外一个重要的应用场景:有些情况下会把析构函数声明为纯虚。这时,必须为纯虚析构函数提供一个默认的实现。否则,派生类的析构函数由于编译器的扩展而显式的调用基类的原构函数时会找不到定义。同时编译器也无法为已经声明为纯虚的析构函数生成一个默认的实现。
- 虚函数中的const 哲学: 一个虚函数该不该被定义为const呢?
- 一个虚函数在基类中不需要修改data member 并不意味着派生类改写它时一定不会修改data member.
- 所以除非有十足的把握,一般就不声明为const。
- Lippman 认为把所有的函数都声明为virtual function,然后再靠编译器的优化操作把大是大非必须的virtual invocation 去除,并还是好的设计观念。不过,Java和.NET很可能都是这么干的。
- 对象能从三个地方产生出来:Global内存、Local内存、Heap内存。
- 观念上,编译器会为每一个类产生4个函数
- trivial default constructor,trivial deconstructor,trivial copy constructor,trivial copy assignment operator
- 但是这仅仅是观念上的,trivial的函数不会被真正的产生出来。
- C和C++的又一个不同点,就是C语言的临时性定义
- 就像书上的Point global;这样的定义:
- 在C中会被视为一个”临时性定义”,可以在程序中出现多次,这些实例最后会被链接器折叠起来,最终留下一个实体
- 在C++中会被视为一个”完全定义”,所以只能出现一次,要想实现C一样的临时性语意,C++中必须把它声明为extern,即:exern Point global;
- 写法差异:
1
2A *pa1 = new A;
A *pa2 = new A();
- 这两种写法是存在一定差异
- 对于内置类型:加括号会初始化,不加括号不初始化
- 对于自定义类型:都会调用默认构造函数,所以加不加括号没什么区别。
- 对于可以视为POD的class(没有声明构造函数、没有virtual等等),就可以使用POD结构特有的initialization list进行初始化。
- Point p={2,3} ,在C++11中的initialization list 被大量使用。
- 引入virtual function 会给对象的构造、拷贝和析构等过程带来的负担如下:
- constructor 必须被安插一些代码以便将vptr正确的初始化,这些代码需要被安插在任何base class constructor的调用之后,但必须在任何user code 的代码之前;
- 合成copy constructor 和copy assignment operator ,因为它们不再是trivial的了,它们必须安插代码以正确的设置vptr;
- C++ standard 要求尽量延迟nontrivial members 的实际合成操作,直到真正遇到其使用场合为止。
- constructor 会被编译器安插大量的代码,一般而言编译器所做的扩充操作大约如下:
- 初始化成员:使用member initialization list 或者调用默认构造函数;
- 在那之前,如果class object 有vptr,它们必须被正确的设置;
- 在那之前,所有的上一层的base class constructors 必须被调用,以base classes声明的顺序。使用member initialization list 晓雾调用默认构造函数,同时如果base class是多重继承下的大是大非第1基类,还需要调整this指针;
- 在那之前,所有的virtual base class constructor 必须被调用,从左到右,从深到浅。并同时设置好virtual base class 所需要使用的各种机制;
- 处理顺序为:virtual base classes -> base class -> vptr -> member 。
- 赋值运算符中切记要记得进行自我检查。
- 虚拟继承时,共享基类必须由最底层的class 负责初始化操作 :
- 这是虚拟继承时非常重要的一点,共享基类的初始化操作必须由最底层的类来负责,中间层次的类调用这个共享类初始化的操作会被编译器所压抑掉。
- 考虑对于如下继承体系的类:编译器如何压抑非底层对共享基类的初始化操作呢? 是通过对Point3d和Vertex的构造安插一个安插一个额外的参数( _most_derived )来解决的。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
Point3d* Point3d::Point3d (Point3d *this, bool _most_derived,float x,
float y,float z)
{
if (_most_derived != false)
this->Point::Point(x,y);
this->_vptr_Point3d = _vtbl_Point3d;
this->_vptr_Point3d_Point = _vtbl_Point3d_Point;
this->_z = rhs._z;
return this;
}
Vertex3d* Vertex3d::Vertex3d(Vertex3d *this,bool _most_derived, float x,
float y,float z)
{
if (_most_derived != false)
this->Point::Point(x,y);
this->Point3d::Point3d(false,x,y,z);
this->Vertex::Vertex(false,x,y);
return this;
} - 当Point3d是作为最底层来构造时,_most_derived参数针被设置为true,于是Point的构造函数就会被调用;当Point3d的构造函数是被Vertex3d间接调用时,_most_derived参数会被设置为flase,于是调用Point构造函数的操作就被压抑掉了。
- 这种由最底层类来负责初始脂共享基类的手法貌似有一点不优雅,但是这却是共享基类唯一可能正确的确定初始化的地方。
- 在构造函数中调用virtual function 是没有多态性的,因为在构造函数中,对象还不完整,派生类的部分还没有开始构造,当然不能调用它们的成员函数,否则在它们的成员函数中可能会访问还不存在的成员变量。
- 由于在构造函数中没有多态性,所以侧重了一种在构造函数中清0,再提供一个init()进香真正的初始化的保护性手法。
- 要保证在构造函数中没有多态性,虚拟机制就必须知道一个调用操作是否来源于构造函数之中,变量如何实现的呢?
- 编译器在构造函数中安插代码时会保证:行调用所有基类的构造函数,再设置vptr,然后再调用member initializaiton操作。变量构造函数中没有多态性的根本原因。
- 在任何User code 和 member initializaiton被调用之前,vptr被正确的设置为了当前类的类型,于是在调用virtual function时从vtable 中取出来的函数地址就是正确的当前类的成员方法地址。
- 一个热爱容易犯的错误:
1
2
3
4
5struct A : public Base {
A() : Base(foo()) , valueA(10) {}
int foo() {return valueA;}
int valueA;
}
- 使用派生类的成员方法去初始化基类,注意在这个时候派生类还没有开始构造,调用它的成员方法的行为当然是未定义的!
- 一个class 的默认copy assignment operator ,以下情况不会表现出bitwise copy 语意:
- 当含有一个或以上的成员有copy assignment operator 时;
- 当基类有copy assignment operator时;
- 当class 中有virtual function 时(需要正确设置vptr);
- 当class 的继承体系中有virtual base class 时。
- C++语言中的虚拟继承时copy assignment operator弱点:
- C++ 标准没有规定在虚继承时copy assignment operator 中是否会多次调用共享基类的copy assignment operator 。这样就有可能造成共享基类被赋值多次,造成一些错误,所以程序员应该在使用了virtual base class 时小心检验copy assignment operator 里的代码(经确保这样的多次赋值没有问题或者查看编译器是否已经提供了解决方案)。
- 因此,飞翔可能不要允许一个virtual base class 的拷贝操作,甚至根本就不要在任何virtual base class 中声明数据。
- C++隐式生成的4大成员函数,在不是真正需要的情况下都不要自己去声明。
- 因为如果是trivial的,这些函数不会被真正的合成出来(只存在于概念上),当然也就没有调用的成本了,去提供一个trivial的成员反而是不符合效率的。
- 析构函数的执行顺序
- 如果object 内带有vptr,那么首先重设相关的vtable;
- deconstructor 函数本身现在会被执行,也就是说vptr会在程序员的代码执行之前被重设;
- 以声明顺序的相反顺序调用members的析构函数;
- 如果有任何直接的(上一层)nontrivial base classed 拥有deconstructor,那么会以其声明顺序的相反顺序被调用;
- 如果有任何virtual base classes 拥有 deconstructor ,而当前讨论的这个class是最尾端,那么它们会以原来的构造顺序的相反顺序被调用。
- 由于析构函数中的重设vptr会在任何代码之前被执行这样就保证了要析构函数中也不具有多态性,从而不会调用子类的函数。因为此时子类已经不完整了,子类的成员已经不存在了,而子类的函数有可能需要使用这些成员。
- 构造函数和析构函数中都不具有多态性:这并不是语言的弱点,布是正确的语意所要求的(因为那个时候的对象不完整)。
第六章 执行期语意学
讨论执行期的对象模型的行为,包括临时对象的生命周期和new、delete运算符的行为。
- C++中过多的隐式变换有时候不太容易从程序代码中看出来表达式的复杂度。
- C++保证:全局变量会在第一次用到之前构造好,在main()结束之前原构掉。
- C++程序中所有的Global object 都旋转在程序的data segment 中并清0,但是它的constructor要程序激活时才会被调用。
- Lippman建议不要使用那些需要使用静态初始化的global object (Google C++编程规范也是如此建议的) 。
- 现在的C++ standard 已经强制要求局部静态在第一次使用时才被构造出来。
- 这也是Effiective C++中Singleton 手法所利用的。而且在程序结束时会被以构造的相反次序被摧毁。
- 对象的数组是通过编译器安插一个函数调用的代码来实现的:
1
2
3
4
5
6
7
8
9
10
11
12
13
14void* vec_new(void *array, //address of start of array
size_t elem_size, //size of each class object
int elem_count, //number of elements in array
void (*constructor) (void*), //构造函数的指针
void (*destructor) (void* , char) //析构函数的指针
)
{}
void * vec_delete (void *array // address of start of array
size_t elem_size, //size of each class object
int elem_count, //number of elements in array
void (*destructor) (void* , char)
)
{}
- 由于把数组的声明转换为vec_new的函数调用,产生的问题是构造函数是通过指针调用的,因此无法使用任何参数,默认参数也不行。
- 对于那些声明了默认参数从而实际上拥有无参构造函数的类,编译器会产生整体上绝对无参的构造函数,再从这个构造函数里调用这个默认参数的构造函数。(这样,编译器实际上违反了语言的函数,拥有了2个没有参数的构造函数,但是这样的特例只能由编译器自己来违反)
- 这里之所以传入了析构函数的指针,是为了在构造函数抛出异常时,把已经构造好的对象给原构掉,这是vec_new义不容辞的任务。
- new 的两步曲
- 分配内存;
- 调用构造函数;
- delete 的两步曲
- 调用析构函数
- 释放内存
- 一般的library 对new 运算符的实现:
1
2
3
4
5
6
7
8
9
10
11
12extern void* operator new (size_t size) {
if (size == 0)
size = 1;
void *last_alloc;
while (!(last_alloc = malloc(size))) {
if (_new_handler)
(*_new_handler) ();
else
return 0;
}
return last_alloc;
}
- 有2个精巧之处。第一:new 操作符至少会返回1个字节的内存;第二:_new_handler会给予内存分配不足时以补救的机会。
- 虽然C++ standard并没有规定,但是实际上的new运算符都是以C malloc()完成;同样delete 运算符也都是以C free()完成的。
- trivial的vec_new():
- 如果要分配的数组的类型并没有默认构造函数,那么这个vec_new()的调用就是trivial的,完全可以仅仅分配内存就可以了,new操作符(注意区分new操作符和new运算符)足以胜任这个任务。只有在定义了默认构造函数时,vec_new才需要被调用起来。
- delete 和delete[]
- 寻找数组维度给delete运算符带来了效率上的影响,所以出现了这个妥协。只有在”[]”出现时,编译器才会寻找数组的维度,否则它就假设只有一个object需要被删除。
- delete 数组时,只有第1个元素会被删除;
- delete[] 单个对象时,1个元素都不会被删除,没有任何析梦函数被调用。
- 数组的大小会被编译器记录在某个地方,所以编译器能够直接查询出来某个数组的大小。
- 数组和多态行为和天生不兼容性:
- 永远不要把数组和多态扯到一起,他们天生是不兼容。当你对一个指向派生类的基类指针进行delete[] pabse ; 操作时,它是不会有正确的语意的。
- 这是由于delete[]实际上会使用vec_delete()类似的函数调用代替,而在vec_delete()的参数中已经传递了元素的大小,在vec_delete()中的迭代删除时,会在删除一个指针之后将指针向后移动item_size个位置,如果DerivedClass 的size比BaseClass要大的话(通常都是如此),指针就已经指向一个未知区域国(如果Derived与Base大小相同,那碰巧不会发生错误,delete[]可以正确的执行)。
- placement operator new 应该与 placement operator delete 搭配使用,也可以在placement operator new出来的对象上显式的调用它的析构函数使得原来的内存又可以被再次使用。
- 一般而言,placement operator new并不支持多态,因为Derived Class往往比Base class 要大,已经存在的类型为内存并不一定能够容纳Derived类型的对象。
- 一段比较晦涩隐暗的代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19class Base {
public:
virtual ~Base() {}
virtual void f() {cout << "f in Base" << endl;}
int value;
};
class Derived : public Base {
public:
virtual void f() {cout << "f in Derived" << endl;}
};
int main()
{
Base b;
b.f(); //这个调用很明显
b.~Base();
new (&b) Derived;
b.f(); //这个调用就不太明显
return 0;
}
- 大部分人认为这里应该输出”f in Derived”,但实际上GCC输出的是”f in Base “。如果理解了前面的编译器如何扩展函数调用,就会明白”f in Base”才是正确的。因为b是一个对象而还是指针或者引用不具有多态性,所以编译器会:把b.f()直接扩展为Base::f(&b);
- 因此,可以想象,如果把b换成是Base * 类型,则由于指针会引发多态,所以才调用Derived的f()函数:
1
2
3
4
5
6
7
8
9int main()
{
Base b = new Base();
b->f();
b->~Base();
new (b) Derived;
b->f();
return 0;
} - 这次,GCC输出了” in Derived”.
- C++ standard允许编译器对临时性对象的产生胡完全的自由度。
- 临时对象的摧毁时机:
- 摧毁临时对象应该在产生它的完整的表达式的最后一个步骤。切记是完整的表达式,比如一连串的逗号或一堆的括号,只有在完整的表达式最后才能保证这个临时对象在后面不会再被引用到。
- 如果一个临时性对象被绑定于一个reference,对象将残留,直到被初始化之reference 的生命结束,或者直到临时对象的生命范畴(scope)结束——视哪一种情况先到达而定。
- 总结:临时性对象的确在一些场合、一定程度上影响了C++的效率。但是这些影响完全可以通过良好的编码和编译器的积极优化而解决掉临时性对象带来的问题(至少在很大的程度上),所以对临时性对象的影响不能大意但也不必太放在心上。
第七章 站在对象模型的尖端
讨论了C++的三个著名扩展:template,exception handing,RTTI。
- 编译器在看到一个模板的声明时会做出什么反映呢?
- 实际上编译器没有任何反映!编译器的反映只有在真正具现化时才会发生。
- 明白了这个,就明白了为什么在模板内部有明显的语法错误,编译器也不会报错,除非你要具现化出这个模板的——实体时编译器才会发出抱怨。
- 在这点上,似乎GCC做的比MSCV++要好的多。GCC好像会做完的解析,但是除了类型的检验;而MSVC++似乎就是放任不管,只有在具现化的时候才去检查。
- 在学习了C++ template 就明白了,编译器实际上会做二阶段查找而且这种延迟到实例化时的具体行为是:延迟定义,而不是声明。
- 声明一个模板类型的指针是不会引起模板的具现化操作的,因为仅仅声明指针不需要知道class的实际内存布局。
- 只有在某个member function 真正被使用时,它才会被真正的具现化出来,这样的延迟具现化至少有2个好处:
- 空间和时间上的效率;
- 如果使用的类型并不完全支持所有的函数,但是只需要不去用那些不支持函数,这样的部分具现化就能得以通过编译。
- int 和 long 的一致性:int 和 long 在大多数的机器上都是相同的,但是如果编译器看到如下声明:
1
2Point<int> p1;
Point<long> p2;
- 目前的所有编译器都会具现化2个实体。
- 可以想象,编译器用一些mangling的手法把具现出来的2个实体分别叫做:_Point_int,_Point_Long之类的东西。
- 涉及Template时的错误检查太弱了,template中那些与语法无关的错误,程序员可能认为十分明显,编译器却放它通过了,只有特定的实体具现化时,编译器才发出抱怨,这是目前实现技术上的一个大问题(二阶段查找的必然结果)。
- Template中的名称决议方式:scope of the template definition(定义模板的地方)和scope of the tempalate instantiation (具现出模板实体的地方)。示例如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20//scope of the template definition
extern double foo (double);
template <class type>
class ScopeRules
{
public:
void invariant() { //情况1
_member = foo(_val);
}
type type_dependent() { //情况2
return foo (_member);
}
private:
int _val;
type _member;
};
//scope of the template instantiation
extern int foo(int);
ScopeRules<int> sr0;
- Template中,对于一个nonmember name的决议结果是根据这个name的使用是否”用以具现出该template的参数类型”有关而决定的。
- 当时觉得这样的规则很诡异,然后在学习了C++ Template 之后就很清晰的明白了。这是因为前面一个是非依赖名称;而后面的使用是依赖名称,所以会在不同的时机进行查找(二阶段查找)。
- 情况1:如果其使用互不相关,那就以scope of template declaration 来决议name;
- 情况2:如果其使用互有关系,那就以scope of tmeplate declaration 来决议name;
- 这个看似很诡异的规则,实际上是非常必要的!这给予了一个调用者可以进行自定义的机会。模板的使用者往往可以在使用时,根据具体的调用类型来提供一个更好的函数给模板(就像示例中,提供一个完全符合int类型的函数,可以视为一个更好的函数)。
- 与参数无关的调用,就是站在模板设计者的角度来看,所以然就使用scope of Template declaration;而与参数相关的调用,就是站在模板使用者的角度来看,当然也就使用scope of the template instantiation.
- 还需语意一点的就是:这里依据是否与类型相关而决定使用哪一个scope,然后其中搜寻适当的name.示例中的代码,在调用sr0.type_dependent();时,由于使用了scope of the Template instantiation,使得2个foo()函数同时成为备选函数,但是由于foo(int)更加的符合,所以最后才决议使用foo(int)这个版本。如果sr0是ScopeRules
类型的话,最后调用的依然是foo(double)那个版本。
- 编译器维持了2个scope contexts:
- scope of Template declaration : 用以专注一般的template class;
- socpe of template instantiation : 用以专注于特定的实体;
- 这种关联性不能简单的作用一个宏扩展来重现,是一种很新奇的关联。
- 一种具现化的策略:先不具现任何的member function ,链接器会登记缺少哪些函数的定义,然后再重新调用编译器把编译器把登记重写编译出来,最后在把这些缺乏的定义重写编译出来,最后在把这些缺乏的定义和以前的链接结果链接起来堪最后的可执行文件或者库。
- 如果vtalbe被具现出来,那么每一个virtual function 也都必须被具现。
- 这就是为什么C++ standard 中有如下的描述:”如果一个虚函数被具现出来,其具现点紧跟在其class 的具现点之后”。(也就是说,virtual function是一口气被具现出来的)
- 一般而言,exception handing 机制需要与编译器所产生的数据结构以及执行期的一个excplicit library紧密合作而实现。
- 编译器为了支持异常的机制,又需要把程序员的代码进行大量的扩展才能保证异常机制的正确执行。但是处理这些问题是编译器的责任,不过程序员应该明白这里把做的事情以及有可能付出的代价。
- 以值类型抛出,以引用类型进行捕获:
- 被抛出的异常类型,一定会被先复制一份,真正被抛弃的实际上是这份复制器;
- 即使是以值类型来进行捕获异常也可以捕获该值类型和其派生类的异常,但是在catch语句中会引发切割。
- 对于每一个被丢出的exception,编译器必须产生一个类型描述器,对exception类型进行编码。如果那是一个derived type,则编码内容还必须包括所有base class 类型信息。
- 当一个exception被丢出时,exception object 会被产生出来并通常旋转在相同形式的exception数据堆栈中。从throw 端传染给catch子句的是exception object 的地址、类型描述器(或是一个函数指针,该函数会返回该exception type 有关的类型描述器对象),以及有可能还有的exception object 的析构函数的地址(如果有的话)
- 只有在一个catch子句评估完毕并且知道它不会丢出exception之后,真正的exception object 才会被摧毁。
- 支持异常机制的代价:与其它语言特征相比较,C++编译器支持EH机制所付出的代价最大。
- C++对异常机制所付出的代价大概为:空间10%、时间5%。不算小,但也不是不可以接受吧。有一个问题:如果编译器开启了异常支持,但是在某一段未使用异常的代码中,也会为编译器开启了异常支持而付出代价吗?
- 在C++中,一个具备多态性质的class,就是指賖virtual functions 的类(直接声明或者继承而来的)。
- 由于具备多态性质的class都已经含有一个vptr指向vtable了,C++把类型信息放在vtable 的第1个slot中(一个type_info的指针指向一个表示当前类型的type_info对象),从而几乎没有付出代价的支持RTTI(1byte per class,not 1byte per class object)(中文书有误)
- 由于RTTI所需要的信息放在vtable中,自然的:只有含有vptr的类才支持RTTI.
- 有了RTTI机制的支持,就可以实施保证安全的动态转型操作dynamic_cast<>();
- 在dynamic_cast中使用指针和引用的区别在于当转型失败时:
- 指针版本会返回0,使用者需要进行检查;
- 引用的版本会抛出一个bad_exception(因为没有空引用啊);
- 这两个机制各有用处吧,视需而用。
- type_info类型copy构造函数和operator=操作符都被声明为私有,禁止了赋值和拷贝操作。而且只提供了一个受保护的带有一个const char * 参数的构造函数,因为不能直接得到type_info 对象,只能通过typeid()运算符来得到这类对象。
- RTTI只适用于多态类型(RTTI信息在于vtable的原因),事实上type_info object 也适用于非多态类型。typeid()作用于多态类型时的差异在于,这时候type_info object 是静态取得的(编译器直接给扩展了),而非像多态类型一样在执行期通过vtable 动态取得。可以通过这个例子来理解:
1
2
3
4
5
6
7
8
9
10
11struct A{}
struct B : public A{};
int main()
{
A *pa = new B;
cout << typeid(pa).name() << endl;
cout << typeid(*pa).name() << endl;
}
将输出:
struct A *
struct A
- 这没有检测出pa所指的真正类型,原因就在于typeid运算符用在非多态类型上时,会被编译器在编译器静态的扩展了。也许是类似的扩展:
1
2cout << typeid(pa).name() << endl; => typeid(A*).name()
cout << typeid(*pa).name() << endl; => typeid(A).name() - 如果给struct A添加一个虚拟函数,从而使得类型A和B都变成多态类型,于是typeid运算符就会在运行期间动态的去获取它们的真正类型了
1
2
3
4
5
6
7
8
9
10
11
12
13
14struct A
{
virtual ~A() {} //A包含了一个虚函数,从而把A变成了多态类型
};
struct B : public A{}; //B从A继承一个虚函数,所以也是多态类型
int main()
{
A *pa = new B;
cout << typeid(pa).name() << endl;
cout << typeid(*pa).name() << endl;
}
将输出:
struct A*
struct A
- 效率和弹性始终是矛盾体!