读书笔记《深度探索c++对象模型》(6) - 执行期语意
一、对象的构造与析构
- 因声明定义一个类对象,若需要构造或析构函数时,其构造函数和析构函数将被编译器插入到代码中的合适位置,然而因为需要对析构函数的正确且合理的位置调用,可能会出现多个可能的位置插入析构函数的调用代码,如不同处的return,goto语句、{}代码段结束位置等复杂场景中,故建议定义一个类对象时最好放置在需要使用的地方且防止可能在多处插入析构函数的情况,如可用{}早点儿结束析构操作,即最好不要将类对象声明定义在最开始处。
- 全局对象,C++全局对象均放置在数据段中,且被初始化为给定的初值或默认值为0;故而一般情况下全局对象在进入main函数前已被初始化,而main函数结束时把其析构释放掉;故而需要编译器提供必要的静态初始化的支持,即全局对象的静态初始化以及对应的析构释放。
- 全局对象应需要被静态初始化,其不能被放置在try区段内,对静态调用的构造函数可能会抛异常时将引发terminate函数调用;另外便是需要控制跨模块做静态初始化的对象的依赖顺序产生的问题和复杂度,建议不要用需要做静态初始化的全局对象。
- 局部静态对象,需要保证其所在函数被调用多次时,始终仅调用一次构造函数和析构函数,需要编译器来支持实现,插入必要的初始化一次和析构一次的临时保护代码,不同编译器有不同的实现方式(可能通过插入另外一个全局静态的一个对象,由该对象的赋值有效值与否来判断局部静态对象的构造与否操作,另外在静态的内存释放函数中析构它)(局部静态对象本质上同全局对象差不多,只是多了类似隐藏的可见属性)。
- 对象数组,若该类定义了构造函数或析构函数,则对象数组初始化或析构时均对每个元素执行构造和析构。而编译器的实现可能通过类似vec_new或vec_vnew等函数分别来处理没有虚基类的对象数组初始化和有虚基类的对象数组初始化,另外vec_delete以支持对数组对象的每个元素执行析构操作;不同的编译器实现方式可能不同;此外,对于对象数组提供了部分初始化时,编译器可能需要显式地初始化给了初值的元素,而对于剩下的元素仍然通过nec_new之类的函数来遍历初始化。
二、new和delete操作符
- new操作符申请对象时,分为两个步骤:为该对象在堆上申请分配内存、初始化该对象(若给了初值的话)。对于类对象来说,申请对象的占用内存空间后,再施加于其调用构造函数,其中的调用构造函数块由try区段包含,若抛出异常,则释放该申请的内存,并继续上抛异常。
- delete操作符释放对象时,若该对象为0或nullptr时,则编译器会插入保护检测代码,不为空时才调用库的delete函数;被释放的对象的指针虽然可继续用,但其指向的对象已经不可用,即已被释放可能被程序的其他代码重新利用。对于类对象的delete操作时,将先调用其析构函数,再释放内存资源。同样的,若析构函数有抛异常,则捕获异常并释放内存资源,并继续上抛异常。
- 部分库的实现,在申请内存资源时,请求0大小的内存仍然会然后一个1字节的地址,此外可以设置_new_handler来实现申请失败时的hook调用,另外申请可能在一个循环里被调用,直到找到可用的指定大小的空间为止才返回该地址或者没有设置该_new_handler则失败时直接返回0地址。
- 基本上大多数的new和delete操作符的实现,申请内存时采用标准C库的malloc和free来处理的。
- 针对数组执行new操作时,若该数组对象不需要或没有提供构造函数,则将仅仅申请对应大小的内存空间而已,若有提供,则将按照之前说的那个vec_new相关的函数来遍历构造初始化,另外若某个数组元素构造失败抛异常,则将被捕获释放申请的所有资源。释放new出来的数组时,调用delete []arryptr而不是delete arryptr(此将仅释放第一个元素,其他元素被泄露了),另外因为delete时的[]没有指定个数,故当编译器发现delete []时将确定元素个数(编译器的一般做法是有个全局的申请到的地址和个数对应表,当delete []时从该表中传入地址得到对应个数,即可确定实际的个数;另外由vec_new函数来加入该申请的地址和元素个数至该全局表中)。
- 析构new出来的数组类对象时,不可由基类对象来释放数组,即 delete []baseptr,此操作将导致仅仅调用到基类的析构函数;不仅如此,当调用到delete_vec时传入的大小也是不正确的,可能引发异常。故在释放时,要么用子类的对象来释放数组delete []derivedptr,要么将基类对象依次强制转为子类来遍历调用delete也可。
- placement new 操作符语意,其形如:someType *ptr = new(someAddr) someType;基于已有的内存地址空间(someAddr),在此地址空间实施该对象的某个类型(someType)构造函数(若是有构造函数的话)。其不同于operator new,不申请分配新的内存空间,主要执行两步操作:一是类型强制转换,二是执行对应类型的构造函数调用;另外someAddr一般由其他地方申请的足够的空间,若是该空间已为某个类对象且需要被调用析构函数,则在再次调用placement new复用该空间时,可先显式地调用该地址对象的析构函数(不能调用delete,因其还会释放内存空间),最后该someAddr的大小不能小于请求的类型大小,否则当调用构造函数时很可能出现异常。
三、临时性对象
- 是否产生临时对象,是由编译器来决定的,一些优化比较好的或是支持NRV的编译器可能会更为友好的处理临时对象。
- 通过用拷贝构造的方式来初始化类对象来替换构造临时对象和拷贝赋值,可以提高效率。
- 有的表达式决定了一定会有临时对象的产生,如print(a + b)。
- 临时性对象由程序的表达式语意有条件地产生,故其生命周期将变得复杂:
1) 一般认为,临时性对象被摧毁应是在对完整的表达式在求值过程中的最后一个步骤时;
2) 若临时对象被绑定在一个引用上,则其生命周期被延长,直到该引用的生命周期结束或直到临时对象的生命作用域范围结束,此时视哪一种情况先到达而定。
5. 临时对象的确可能引起执行效率的降低,如产生临时对象的构造及析构,另外就是可能产生较多的中间临时对象。但是另外一部分主要的效率降低主要为读取类成员对象的大量堆栈存取操作,将部分成员放入缓存器可以提高效率,故需要编译器来实现一些优化以提高效率。