《深度探索C++对象模型》第六章笔记 | Notes for Inside Cpp Object Model Chapter 06

本章中将会讨论在执行中程序员书写的表达式会发生的一些转换,讨论包括临时对象等问题。

6.1 对象的构建和析构

常规对象

常规对象(主要是代码段中声明并使用的对象)的构建发生在对象被声明出来的地方,而析构则发生在区段的退出位置前。如果一个区段有多个退出位置,则会发生在每个退出位置前。在这种情况下,通过将 object 尽可能的声明在使用它的区间附近可以节省不必要的对象构建和解构操作。

全局静态对象

对于全局静态对象,它们均储存在程序的 data segment 中,C++ 保证会在它们第一次被使用前被构造,并在程序结束前将其析构。一般称这种操作为静态的初始化和内存释放。对于使用表达式初始化的静态变量,一般的解决方案是:

  1. 对所有文件中的全局变量生成 __sti_filename_variablename() 函数用于使用表达式初始化它们
  2. 对所有文件中的全局变量生成 __std_filename_variablename() 函数用于析构它们
  3. 将所有 __sti, __std 函数分别放入两个集合函数中,并在 main 函数的开始和退出位置插入集合函数的调用

局部静态对象

这类对象包括类中的静态对象等。由于它们并不一定会在程序运行中被用到,一般只在它们被使用前才被构建,而其析构则发生在对应的 __std_xxx 函数中。

对象数组

对于这类对象的处理取决于这些对象的类型中的构造函数和析构函数的实现情况。如果该类型没有实现任何构造函数和析构函数,那么就直接申请内存即可,并不需要进行额外的操作。对于其他的情况,编译器通常会合成一个用于初始化的函数,形如:

1
2
3
4
5
6
7
void* vec_new (
void *array,
size_t elem_size,
int elem_cont,
void (*constructor)(void*),
void (*destructor)(void*, char)
);

特别地,当需要申请的是不记名数组的位置(如使用 new 运算符的情况),则传入的 array 地址就会设为 0 ,由函数在堆上动态分配内存。其它参数则指定了每个元素的大小、元素的数量、需要运行在每个元素上的构造函数以及出现错误时需要运行的析构函数。同样地会有相应的删除对象数组的函数被合成出来:

1
2
3
4
5
6
void* vec_delete (
void *array,
size_t elem_size,
int elem_cont,
void (*destructor)(void*, char)
);

不同编译器实现的方式不完全相同,有的编译器会使用不同的函数处理含 virtual base class 的类和不含的类,有的编译器会额外提供参数,以引导构造函数或析构函数的逻辑。

另外,当程序员显式的提供了初始化值时,被提供了初值的对象将会被单独初始化,只有剩余的对象会被传入形如上述 vec_new 的函数中。

Default Constructor 和数组

在上述使用的 vec_new 函数中的一大问题是,在运行期经由指针调用 constructor (事实上 C++ 并不支持由程序员使用指向构造函数的指针)初始化数组的一大问题是无法访问 default argument values(参数缺省值)。一种解决方案是构造一个只在数组初始化阶段使用的临时构造函数,并在该临时函数中调用使用了缺省值的构造函数。

6.2 new 和 delete 运算符

针对单一对象的语义

new 运算符实际上由两个步骤组成,包括了:

  1. 通过适当的函数实体配置需要的内存
  2. 为对象设置初始值

delete 运算符也类似,包括了:

  1. 检查是否是空指针
  2. 析构指向的对象
  3. 删除内存(如将内存标记为空闲)

特别地,语言要求每次 new 的调用均需要返回一个第一无二的指针,而且允许类似 new T[0] 的写法。在这种情况下,编译器一般会返回一个 byte 的区块。

针对数组的语义

这一部分和之前的比较类似,当需要使用构造函数初始化对象时,会使用和之前相同的接口 vec_new 申请内存并初始化,区别主要在于传入的目标指针是 NULL 值,由函数申请内存并返回地址。

一个需要注意的地方在于,当使用一个基类类型的指针传入 delete 函数以删除一个派生类时,如果没有使用 virtual 的析构函数,那么实际调用的是基类的析构函数。这个情况下很可能会造成内存的泄漏。

另一个对于数组而言的重要特性是,在使用 delete 运算符时,需要确认数组的大小以正确地释放对应的内存。这需要为指针赋予额外的信息。如配置一个额外的 word 或维护一个关联数组来将指针和数组对应。这种方式称为 cookie 。

Placement Operator new

这是一个预定义的重载的 new 运算符,通过传入一个指向内存中预分配的区块的指针,在需要的情况下将对应数量的规定类型的 constructor 应用于该段内存上。

这种操作可以将内存管理与内容初始化分离,但需要注意的是:当有重复利用内存块的需要时,应该使用对应的 Placement Operator delete 调用对应的析构函数,这会阻止系统将该块内存标记为空闲。又或者,当使用已经被同类型的object 占用的内存位置指针 new 新的内容时,编译器会自动调用对应的析构函数让出空间。

和上述针对数组的语义一样,使用已经包含其他类型的内容的内存区块初始化新内容的行为是未定义的。

6.3 临时对象

在运算中,常常会需要使用临时对象储存表达式的值等内容。一段语句是否会产生临时对象取决于编译器实现上的激进程度和该语句的上下文。C++ 标准允许编译器对于临时对象的和使用有完全的自由。

使用中,临时对象的生命周期的实现方法的不同可能带来灾难性的结果。如果一个临时对象被析构得过早,后续的调用可能会错误地引用到已被删除的地址上。下例展示了一种不合理的临时对象的使用方式:

1
2
3
4
5
6
7
8
9
// 原始的代码
String s1 = "a", s2 = "b";
printf("%s", s1 + s2);

// 合法但不正确的编译器拓展方式
String temp1 = String::operator+(s1, s2);
const char *temp2 = temp1.operator const char*();
temp1.~String(); // 过早的析构使得上述地址无效化
printf("%s", temp2); // 使用了无效的地址

因此,C++ 标准规定临时对象的析构必须是对完整表达式(包括了临时对象的最外围的表达式)求值过程中的最后一个步骤。但这无法避免形如下例的错误:

1
2
String s1 = "a", s2 = "b";
const char *p = s1 + s2; // 这里也指向了非法的地址

临时对象的生命规则有两个例外,包括了:

  1. 临时对象被用于作为 constructor 的参数初始化另一个对象时,在对象完成初始化前不能被析构(但这也无法避免上述无效指针的问题)
  2. 临时对象被一个 refernce 绑定时也不能在该 refernce 无效前析构对象,这一点是受 C++ 规则保护的