这本书毕业时就有耳闻,买来之后发现以当时的水平根本看不下去,现在陆续把基本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必须在析构函数一开始或开始前就被重置成指向当前类的虚函数表,确保没有指向已经销毁的子类的虚表,因为此时子类应该已经被析构完毕了。