这本书毕业时就有耳闻,买来之后发现以当时的水平根本看不下去,现在陆续把基本c++有关的书都读了之后,是时候补一下这本书了。 购于2019.2.28
与C的关系
第一章提到c++为了与c兼容费了不少劲,如class和struct,二者在底层被视为同类,除了访问级别的区别,更多的是我们想要一个什么样的数据结构。
C中一些对struct的操作到了c++可能会失效。如在struct的末尾声明一个单元素的数组,在分配内存的时候可以按需多分配任意那个数组类型大小的内存,达到“柔性数组”的目的。 其原理是利用了c允许访问超出数组大小的元素,而在struct末尾多分配的内存保证是连续的,因此我们可以按自己的需要去分配和访问。 但到了c++情况就不同了,因为class内存的布局和public、protect、private的位置有关,只有在同一个acess section内的成员,才保证其在内存中按顺序分布,不同的section中的成员是不确定的。
默认构造
没有任何参数,或所有参数都有默认值的构造称为默认构造。 第二章提到编译器只在自己需要的时候为class生成默认构造,但并不会帮用户初始化成员变量,除非是class类型且也存在默认构造的成员。 一共有4中情况编译器会生成,具体的就不列出了,重点是是否为编译器所需,比如自身或基类包含虚函数的类,需要一个vptr来指向vtbl,那么这个vptr的初始化就需要有地方去完成(在所有的构造函数中)。 如果用户未定义任何构造函数,编译器就会生成一个默认构造来做这些事。 除此之外编译器并不会为我们合成,因为并不需要。
拷贝构造
2.2一直在讨论编译器什么时候不会执行bitwise copy,换句话说只要条件允许,编译器便会去执行类似memcpy的操作,因为这样效率最高。
与默认构造类似,拷贝构造会在编译器需要的时候生成,如需在子类对象赋值给基类对象时,需要保证基类的vptr仍然指向基类的vtbl而不是子类的。
类似这样的情况便不能进行bitwise copy。
需要注意的是,要进行bitwise copy,条件是拷贝构造必须为trivial,也就是说必须由编译器判断当前没有额外的事情需要处理,且用户也没有定义拷贝构造时。
只要用户定义了拷贝构造,就会被视为nontrivial,除非让它 = default。
返回值优化
NRV(named return value)优化,指的是函数内部返回一个有名字的局部变量所作的优化。 原因是局部变量要经过构造、拷贝、销毁三个过程,而如果可以直接在需要的地方构造返回结果,便可以省掉额外的拷贝和销毁操作。 经查早期的编译器以用户是否定义了拷贝构造为依据,判断是否需要使用NRV优化,如果是,则为函数生成一个隐式的返回值类型参数,直接进行构造和操作。
这可能引起另一个问题,用户希望在拷贝构造中做一些事情,以相应的方式写代码,而编译器为了优化将这个过程跳过了。
RVO(return value optimization)
新c++标准下的返回值优化,在这本书写的时候还没有这个说法,通常只会使用NRV优化。
第三章——Data
一个什么都没有的空类,随其sizeof的大小是多少?好像之前有个面试问到了这个问题,当时我以为是0,其实不然。
答案是1,因为地址是以字节为单位,为了区分每个对象的地址,至少要有1个字节,对空的类,编译器会在其中加入一个char成员占用1字节。
通常来说继承一个空类的子类也会继承到这1个字节,然而有一种情况编译器不会为子类增加这1字节,称为empty virtual base class处理,
即一个类作为基类被virtual继承时,子类中会存在一个指针指向基类的subobject或一个表,占用4个字节(据操作系统),此时并不需要额外再增加一个字节来占位,如果没有这个处理,4+1再加上对齐就需要占用8字节。
继承下的内存布局
基类和子类的成员在类中会分开进行内存对齐,即子类的成员并不会去填补基类中因为对齐而多占用的内存空间。 例如基类有一个char,子类有另一个char,那么子类将占用8字节,因为分别发生了对齐。
这样做的原因是在发生拷贝的时候,由于基类指针可以指向任意子类对象,拷贝的时候要保证基类的数据被正确覆盖。 如果子类的成员占用了基类的内存,那么在一个基类对象向子类对象拷贝时,其子类的成员数据会被未定义的内存覆盖(参阅书中第88页3.4节)。
多继承
若一个类继承两个不相干的基类,那么这两个基类的成员会按先后顺序在这个子类的内存中存储。 当把子类对象的地址赋值给后面那个基类的指针时,需要进行偏移才能得到正确的地址,因为地址的前端已经被另一个基类占据。 谁能想到还要处理这种事,只能说编译器承担了太多。
而当指针赋值时情况又不一样,因为指针存在为空的情况,如果为空则不需要进行偏移直接设为0就行,否则会从0进行另一个基类大小的偏移得到未知的地址。
虚继承
虚继承是为了解决在多继承时多个基类间有一样基类的情况,即菱形继承。 与使用虚函数表类似,发生虚继承使用一个指针指向基类的地址,这样在多继承时,如果存在相同类型的基类,它们的指针就会指向同一块内存。 为了优化访问的效率,可以像虚函数表那样建立一个virtual base class表,再进一步,可以直接使用虚函数表的指针,只需在反方向存储基类地址的offset即可,然后在需要获取的时候使用负索引。 offset是相对于当前类型的地址偏移,基类和子类占用的内存是连续分布的,因此可以直接把指针移过去。
指向成员变量的指针
这东西我之前都不知道,声明一个指针指向一个类的成员变量(int ClassName::*mptr),它本质上是这个成员在类中的偏移量,因此使用的时候必须带上对象,否则只靠偏移量无法获取数据(obj.*mptr)。
为了区分不指向任何成员的指针和指向第一个成员的指针(都是0),成员的偏移都会加上1(现代编译器似乎会像我们隐藏这个处理,不需要额外关心这个1了。
多继承发生时(真是麻烦的特性),多个基类中的数据会影响成员在内存中的偏移,因此编译器需要对每个占用了前面内存位置的基类做一个额外偏移,才能得到最终那个成员最终的正确偏移,同时还要防范空指针。
第四章——函数
static成员函数由于没有也不需要this指针,但它依然是该类的成员,为了和外界的函数所区分,同时保证调用的效率,编译器会为其生成一段特殊的名字,就像其他成员一样。
单继承情况下,要实现多态,只需在编译期为每个虚函数在虚函数表中指定一个索引,运行时通过vptr和索引就可以获取到正确的函数地址进行调用。
多继承发生时,成员虚函数的调用除了查表之外,还要将this指针进行偏移,以让其指向正确的类型,偏移量的大小delta可能作为数据的一部分存储在虚函数表中
成员函数的指针,如果指向的是一个普通函数,则是一串真实地址,但同样需要对象的信息才能正常调用,因为需要this指针。 而如果指向的是一个虚函数,则是一个索引,用来在虚函数表中找到正确的函数地址。 为了区分上面两种情况,可以用一个结构包含索引(index)、函数地址(faddr)和this的偏移(delta),代价就是每次调用都要进行一次判断是否索引有效,且会创建一个结构体对象。
第五章——构造、析构、拷贝
编译器会自动对构造进行扩充,进行初始化initialization list中的成员变量(以声明顺序)、调用成员的默认构造(如果存在)、初始化vptr、调用基类的构造(以声明顺序)等。 同理,析构时会对成员对象调用析构函数。
虚继承发生时,通常意味着会发生多继承,且存在共享基类的情况,此时构造中就不能无脑对基类进行构造,可能就会出现重复构造。 书中(5.2)给出的做法是在最深层(most derived)的派生类中进行虚基类的构造,因此构造中需要进行是否是most derived的判断,如果判断通过才进行虚基类的构造。 除此之外,拷贝赋值操作符也存在类似的问题,编译器可能为我们生成操作符实现,调用基类的版本,但虚继承发生时,就可能出现重复调用的情况。 果然多继承不是一个好特性,要考虑的事情太多了。能不用尽量别用。
vptr初始化
在构造中调用的虚函数,最多只能是这个类的实现,而调用不到子类的实现。 这是因为构造的顺序是由内而外、由根源到末端,只有当一个构造函数执行之后,这个对象才被构造完毕。 这是个老生常谈的话题了,面试的时候也作为八股经常会问。
为了达到控制构造中可能的虚函数调用,编译器必须严格控制vptr的初始化时机,即在基类构造调用之后,成员初始化列表和用户代码之前。
析构语义学
只有在class内存在成员变量或基类中定义了析构函数的情况下,编译器才会合成析构函数,否则会被视为没有意义,即使有虚函数(5.5)。 在一个对象被删除前,并没有像构造函数那样有必须为成员变量提供初值的必要,如在delet调用前,我们并不需要将对象内的数据恢复成默认值。 只有在需要用户进行一些形如释放内存的操作时,析构函数才有意义。 这就是为何当基类中有析构函数时编译器就会为我们定义,因为要调用基类的析构函数(在用户代码之后)。 如果我们已经定义了一个析构函数,编译器会帮我们调用基类的析构。 这还有点反直觉,我一直以为析构和构造必须是对称存在的。
5.5最后的译注说析构的函数体在vptr被重置之前调用,和原文中的顺序不一致,查了几个AI回答和原文中顺序的一致,解释也比较合理。 即vptr必须在析构函数一开始或开始前就被重置成指向当前类的虚函数表,确保没有指向已经销毁的子类的虚表,因为此时子类应该已经被析构完毕了。
第六章——Runtime语义学
局部对象需要在每个函数可能结束之处之前进行,如每个return语句之前,因此我们应尽可能晚的声明局部变量,而不是全在函数开头声明,以降低可能的析构开销。 这倒是符合我一直以来的习惯。
这一章提到一些概念如静态初始化、data segment、常量表达式,都是名字很熟悉但让我讲好像又不是很能说的上来具体是什么,也许后面读csapp的时候能有比较清晰的认识,这里先不去深究了。
关于new和delete
new做两件事,分配内存和设置初值(或调用构造)。这个在more effective c++里面有讲。
在delete执行结束之后,目标指针并不会被置空。
下面是书中的一个new的实现
extern 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;
}
}
while的作用是在内存分配失败时,让用户可以有机会使用_new_handler做一些事情,可能是释放内存,可能是使用新的_new_handler。
new一个数组时会发生什么?这个问题一直没有特别清楚,查过很多次,每次查完感觉懂了但是过段时间又忘了。
6.1节提到,当声明一个数组时,会调用一个vec_new函数,它需要起始地址、元素大小、数组大小、默认构造和析构函数指针,用来为每个元素调用构造。
当使用new声明一个数组时,同样如此,除非该类型没有默认构造,那样的话只需要单纯分配内存。
使用delete时情况类似,但由于是显示的传入析构函数地址,虚函数机制并不会生效,因此不可以用一个基类指针去存储一个子类数组,不然很可能会出错,毕竟类的大小可能都不一样。
placement new
在已经分配好的内存上构造对象。它的实现很简单,就是直接返回了传入的指针而已
void* operator new(size_t, void* p)
{
return p;
}
在代码中使用new时,如果new后面有括号,编译器就会寻找对应的new版本去调用,如果是一串地址或指针,就会触发placement new。 它直接返回指针,是因为我们不能主动的调用构造,需要让编译器为我们调用,它的实现就是说不用分配内存,在现在这个地址上构造就行。 我感觉可以这么理解,placement new就是c++官方提供的一种operator new的重载版本,用来在指定的地址构造对象。 妙啊。
这也是用户想直接调用构造的唯一方法。
然而编译器并不知道那个地址上之前有没有对象,如果有还需要手动调用析构(注意不是delete,因为内存还需要)。
显而易见,placement new并不支持多态,因为内存的大小已经固定了,如果在上面构造子类,很可能会超出范围导致未知的问题。
第七章
Template
实例化(instantiation)是模板中一个非常重要的概念,意思是“进程(process)将真正的类型和表达式绑定到template相关形式参数”的操作。 我的理解就是真正使用这个模板的时候,就是实例化的时候,声明只是声明了一个类的蓝图,还不是一个真正的类。 它和类的实例化是两个概念。
只有在真正的实例化操作发生时,一些错误才会显现,如使用了未声明的操作符,或试图用不支持的值进行初始化。 在此之前,只会进行有限的错误检查,如明显的语法错误。 书中提到对于定义不存在的成员函数,或访问不存在的成员变量,都可能可以通过编译,这随编辑器的设计而定,不知道现在的编译器还会不会这样。
编译器必须保持两个scope contexts:
- scope of the template decalration,模板声明时的情景
- scope of the template instantiation,模板实例化时的情景
书中的例子是两个名称相同参数类型不同的全局函数,在模板类中调用,如果传入的参数类型不受模板实例化的类型影响,那么这个调用就会使用第一种决议,反之如果这次调用需要知道使用时的类型,也就是在实例化时,那么就会使用第二种决议类型。
关于7.1最后讲的成员模板函数的实例化,很多不是特别理解,除了有关编译过程的知识不够,也不确定现代编译器是否与cfront这些老编译器有所差别。 这部分内容准备到时候看csapp的时候再研究。
RTTI
c++的类型信息体现在需要把基类指针转换成子类指针,也就是downcast,为此就必须要编译器为类型保存必要的信息。
我们大多时候这么转换都是为了多态,而c++的多态体现在虚函数,因此将类的信息存入vptr就显得理所当然了。
在vptr指向虚表的第一个slot中,存放一个type_info类型的对象(没错这是个类),它包含所在类的信息,如class的原始名称或编码名称,被称为类型描述器(type descriptor)。
typeid运算符传回一个type_info的const引用。
注意引用的向下转型和指针不同,指针如果转换失败会返回一个空指针,而引用却没有空引用的说法,因此如果转换失败会抛出bad_cast exception异常。 这个应该以前做c++总结的时候看过,但已经没什么印象了,再记一下,虽然平时几乎没看见过需要对引用执行dynamic_cast的情况。
从上也可以看出,RTTI在c++中只适用于多态类和内建类型,不同的是内建类型可以静态取得,而多态类需要运行时且需要时。
(2026.3.29)终于读完了最后一章,可以开始下一本了。