c++主要有两个复杂的地方,一个是实现OO的对象模型,一个是一开始被设计为小刀但后来发现是屠龙刀的模板。
OK,模板元编程不搞也能用c++写东西(虽然我想用boost 和 STL 的人很难容忍在一点都不了解相关实现的情况下去使用那些对于传统c++来说就像魔法一样的代码)。
但是不学对象模型,用c++来写OO代码就没意义了。因为用c++是为了效率,而编译器在背后干了太多事情,如果不知道一点点c++的OO实现,代码跑起来可能会慢(例如重复构造临时对象)。Inside The c++ Object Model 这本书从出版到现在刚好二十年,其中不少内容可能会过时(特别是设计具体编译器的部分)。但是其中一些基本而且有效的模型估计还是不会变的,其中一些细节我也不写(虚拟继承转换父子类指针的时候怎么去算offset我真的懒得管了)。还有这篇东西主要作用还是整理思路,很多本来因该画图来表达的东西就不搞了,反正这篇东西主要还是给自己整理,方便回忆,老师大概也不怎么看(上一篇过了半年都还没看)。
这本书除了第一章和最后一章,标题都含semantic(语义)。之前一直不理解是啥意思。现在的理解是,semantic就是编程语言内置的逻辑功能,例如一个scope中的对象如果有析构函数的话,那么他一定会在scope结束的时候调用析构函数。这本书实际上就是讲c++中语言内置的逻辑功能究竟是如何在c语言的基础上实现的。
构造函数
并不是每一个对象都拥有构造函数。在程序员没有写构造函数的情况下,类只有在三种情况下才会为对象生成构造函数,而且这个类的对象被定义才会调用。
1:成员对象或基类有无参构造函数(或者说default constructor)。因为C++里面一个类的的基类和成员对象的构造函数必须由该类的构造函数调用,所以这个类必须生成一个构造函数来放置基类和成员对象的构造函数调用代码。
2:自己或者基类有虚函数。因为虚函数的实现方法是在每个对象的在内容中的区域里面插入一个vptr(指向虚函数表的指针)来实现虚函数,所以安置vptr的代码就被放在构造函数里面。因此如果一个类使用了虚函数,则编译器会保证这个类有构造函数。
3:有virtual base class。存取对象成员变量时,靠的是对象在内存中的起始地址和成员变量在整个对象内存区域中的位置来实现的。但由于virtual base class的作为subobject在子类对象中的区域是会改变的(如果这个使用了虚拟继承的子类的子类使用多重继承,而且该使用了虚拟继承的类的对象在子类的内存区域中不是排头),那么,当用被虚拟集成的基类指针来操作它自己的成员时,由于在编译期间不能这个指针究竟是指向子类还是子类的子类,这个指针就不知道基类数据成员的位置,所以存取操作必须延迟到运行时期才能确定具体找那一块内存。具体的行为是,使用了虚拟继承的子类会在自己的内存区域里面加一个指针来指向虚拟继承得来的基类(微软用的是vpbc,其它不清楚)。所以,如使用了虚函数一样,使用了虚拟继承的子类需要构造函数来调用安置这个指针的代码。
但是,使用了虚函数或者多重继承的子对象内存布局也和基类不一样啊?当vptr是放在对象开头(微软和g++都是这么干的),而原本基类没有使用虚函数,那基类的成员不再从开头开始排下来,而是要放在vptr之后。而使用了多重继承的子类,它只有一个基类的成员能放开头,其它基类的成员必须跟在后面。这两种情况都会使基类和子类的内存布局不一样。但是,这个不同实际上在子类指针upcast的时候就已经被解决了。
Base * pb = new Derived;
假设Base没有虚函数,而Derived有。这个语句其实在编译期干了很多事情。new出来的子类指针会先检查自己是不是0,不是0的话它会加上一个offset,让子类指针指向内存区域中基类成员的那部分,再传给pb。由于基类没有虚函数,只用这块区域的内存就能表现出基类完整的行为,因为它的行为只跟自己的成员变量有关,所以这样转换后pb使用起来是安全的。而多重继承也是这么干的,也是在编译期加一个offset。但是这样的话,这个不是位于内存开头的基类subobject的行为是能够完整吗?毕竟如果它有虚函数的话,基类指针就会访问子类其它区域的内存!没错!这里是会有问题的!所以在调用虚函数的时候,还要把作为虚函数参数的this指针转回去!具体怎么转已经没有不好玩了,它依赖编译器,而且知不知道也没啥关系。(展开了好多,现在回到正题)
如果不符合以上的情况,则类的构造函数是trivial的,实际上不会被合成出来。声明一个对象只会分配内存,不会调用构造函数。当类符合上述情况,则构造函数是nontrivial,构造函数会在对象声明的那个编译单元(vc里面的obj文件)里面被调用,为了防止被编译器生成的构造函数在多个编译单元中重复定义,这些构造函数都要是internal linkage,要么是inline,要么是explicit non-inline static。
如果程序员自己写了构造函数,上述三点所需要的代码都会被插入到程序员所定义的构造函数里面。
拷贝构造函数
当程序员没有定义拷贝构造函数。
如果对象的成员都是C++内置成员,那么它是Plain OI' Data,它的拷贝是bitwise copy semantics,拷贝构造函数是trivial的,实际上不会被合成。
会被合成的有一下三种情况(而且必须存在相关的拷贝对象的代码,因为既然没有拷贝代码那拷贝构造函数就没必要了):
1:如果对象的成员对象或者基类有拷贝构造函数(无论是程序员写的还是编译器合成的),那么这个对象就会被编译器合成一个拷贝构造函数,来调用成员对象或者基类的拷贝构造函数。
2:如果对象有虚函数,而且存在用子类给基类赋值的代码。因为如果基类有虚函数,要重置虚函数表指针,把子类的vptr换成基类的vptr。如果基类没有虚函数,要把虚函数那部分砍掉(这种情况貌似书没说)。
3:使用了虚拟继承。因为子类布局跟基类不一样,所以需要拷贝构造函数来维护。具体怎么维护也算了,没必要。
需要用到上面三点功能,而程序员又定义了拷贝构造函数,那么相关代码会插入到拷贝构造函数里面
析构函数
当程序员没有定义析构函数,只有在它的成员对象或者基类有析构函数,编译器才会自动生成析构函数。但是当对象有虚函数的时候,没有析构函数重置vptr没问题吗?没问题,因为既然基类没有析构函数,那自从子类对象析构开始,就没有人会去调用虚函数了,所以还管它干什么。但是如果基类有析构函数,那么子类的析构函数必须重置vptr(当然这是编译器干的)。
赋值操作符
下面三种情况,赋值操作符是non-trivial的:
1:成员对象或者基类重载了赋值操作符
2:使用了虚函数。道理跟拷贝构造函数一样。
3:使用了虚拟继承。道理跟拷贝构造函数一样。
其它情况都死bitwise copy。如果程序员定义了拷贝构造函数,由于赋值操作符没有初始化列表,所以成员对象和基类的赋值操作符必需由程序员调用。
初始化列表
只有初始化列表才能直接构造成员对象和基类。如果是在构造函数的代码里面初始化它们,就已经太迟了,因为这个时候编译器保证成员对象和基类已经调用了一次构造函数。所以如果是在构造函数的代码块里面用赋值操作符再设定它们,就低效。
函数中的转换
1.对象定义会变成对象声明加调用构造函数调用。
2.对象使用等好会变成拷贝构造函数或者重载的赋值操作符。
3.值传递对象会变成先拷贝临时对象,然后把临时对象的引用传给函数(thinking in c++里面说这个拷贝过程会比较特殊,会有个helper function帮助拷贝,而且这个临时对象是建在被调用函数的栈上面。可能是时代不同编译器改了吧)
- 以值返回对象的时候,实际上是把对象的引用(指针)作为一个外增参数传进函数,然后将返回的对象拷贝到这个外增参数。如果return 语句是直接return 一个构造函数(而且这个构造函数是nontrivial的,如果是trivial就要程序员定义它才能优化,虽然这个限制我有点怀疑),则执行NRV优化。直接用构造函数构造被被返回的对象。
对象布局与存取
对象的数据成员首先会按access section(public,private,protected)被分类,然后分别在内存中按声明顺序排列,静态成员之会被放在全局数据区。非virtual的成员函数由于跟对象的绑定可以在编译期完成,所以不会被放在对象内存中。virtual函数会被统一放在类的虚函数表里面,对象会被插入一个指向类的虚函数表的指针。
非虚拟继承得来的基类的成员会被放在子类成员的前面。虚拟继承的来的成员会放在后面,然后在子类内存区域的最前面安插一个指向虚拟基类的指针,或者像vc那样插入一个指向虚拟基类列表的vpbc。这是因为虚拟继承是用来应对在多重继承下,两个以上基类有公共基类的情况。这个时候多重继承的子类会重复拥有这个基类的成员。
如果对象是空的话,会被安插一个char,所以空对象的大小是1byte。如果对象没有对象,但有虚函数或者用了虚拟继承,vc和g++都会把那一个char省略掉,因为vptr已经可以占用内存了,即使这个类是继承于一个没有虚函数的空类也一样。
如果基类的数据成员加起来的大小不是4byte的倍数,那子类继承之后,会先把基类内存用空白补到4的倍数(alignment),然后在再后面放子类的成员。这是因为在拷贝的时候,拷贝是整个4byte一起拷贝过去的,那么当用一个基类对象拷贝给子类对象的时候,应该有的语义是只有基类的部分被修改,但如果子类占用了本来用空白占的地方,子类的部分也会被被用来拷贝的基类对象后面的未知数据给覆盖掉。
存取静态成员变量跟存取一个普通的静态成员没有区别,它跟类的绑定是在编译期完成的。
在成员函数存取非静态成员变量的方式是改写成员函数,把this指针传进成员函数,然后成员函数用this指针操作成员变量。在外部存取非静态变量的方式跟c中的struct一样,即使用了虚函数或多重继承,因为这个可以在编译期就决定好。
由于指针可以指向子类指针,而当用了虚拟继承的时候对象的内存布局可能会改变,所以用指针操作对象成员的指针要延迟到运行期并且要作一些判断,所以操作成员会变慢。其他情况都一样,即使用了虚函数和多重继承,因为它们都在upcast指针的时候已经把指针转换好了,这个在上面构造函数里面写了。而直接用点操作符存取虚基类成员不会出现类型不确定的情况,所以可以在编译期确定成员位置。
&Object::member 会得到成员在内存布局中的位置。内存中所有成员会在对象地址加1byte之后才开始排列,为了区分对象地址和对象成员地址(g++是这么干的)。
如果使用一个类的Pointer to Data Member,而且这个类用了多重继承,那么当把排列在后面的基类成员指针转换为子类成员指针(它们都指向相同的成员),就要对成员指针做offset调整。
成员函数指针
非虚函数无多重继承的成员函数指针只是简单的指向成员函数的指针,调用起来也不会变慢。
指向虚函数的成员函数指针不是地址,而是在虚函数表中的偏移量,用它来调用虚函数实际上是用vptr加上它自己存的偏移量来调用。
多重继承下的成员函数指针也不是指针,是struct,还是union,还是什么?我懒得管了。
异常
如果new操作符抛异常,那构造函数还没调用,所以不用去析构对象。如果构造函数抛异常,那么成员对象和基类的析构函数会被调用,也不用手动析构对象。
catch语句的参数如果是值语义,那就会像传函数一样调用一次拷贝构造函数。