目录
所谓临时对象就是当编译器需要一个空间来暂存表达式的求值结果时临时创建的一个未命名的对象。编译器根据程序的需要可能会安插一些临时变量来支持程序的运行,这些动作是在程序员不可感知的背后默默进行,所以我们有必要了解编译器在背后的所作所为。这些动作有时是为了转换原来的代码的语义以保证代码能顺利地编译通过,有的是为了程序运行的正确性而暂存的对象。有两种情形一般会产生临时对象,一种是暂存函数调用的返回结果,一种是计算表达式的过程中暂存运算结果。下面将根据这两种情况来展开分析。
暂存函数返回结果的临时对象
先看一个例子:
#include <cstdio>
class Object {
public:
Object() { printf("%s, this = %p\n", __PRETTY_FUNCTION__, this); }
Object(const Object& rhs) : i(rhs.i) {
printf("%s, this = %p, rhs = %p\n", __PRETTY_FUNCTION__, this, &rhs);
}
~Object() { printf("%s, this = %p\n", __PRETTY_FUNCTION__, this); }
Object& operator=(const Object& rhs) {
printf("%s, this = %p, rhs = %p\n", __PRETTY_FUNCTION__, this, &rhs);
i = rhs.i;
return *this;
}
int i = 0;
};
Object operator+(const Object& obj1, const Object& obj2) {
printf("%s, obj1 = %p, obj2 = %p\n", __PRETTY_FUNCTION__, &obj1, &obj2);
Object result;
result.i = obj1.i + obj2.i;
return result;
}
int main() {
Object a;
Object b;
Object c = a + b;
printf("c.i = %d\n", c.i);
return 0;
}
编译时暂时先关闭掉优化选项,加上编译选项“-fno-elide-constructors”,程序的输出结果:
Object::Object(), this = 0x16ba673f8 // 构造a
Object::Object(), this = 0x16ba673f4 // 构造b
Object operator+(const Object &, const Object &), obj1 = 0x16ba673f8, obj2 = 0x16ba673f4
Object::Object(), this = 0x16ba673a4 // 构造result
// 以下调用拷贝构造临时对象0x16ba673dc
Object::Object(const Object &), this = 0x16ba673dc, rhs = 0x16ba673a4
Object::~Object(), this = 0x16ba673a4 // 析构result
// 以下调用拷贝构造对象c
Object::Object(const Object &), this = 0x16ba673e0, rhs = 0x16ba673dc
Object::~Object(), this = 0x16ba673dc // 析构临时对象
c.i = 0
Object::~Object(), this = 0x16ba673e0 // 析构c
Object::~Object(), this = 0x16ba673f4 // 析构b
Object::~Object(), this = 0x16ba673f8 // 析构a
可以看到,上面的程序中编译器产生了一个临时对象,即地址为0x16ba673dc(第6行打印)。首先有一点要注意的是在operator+函数里的result变量不是临时对象,它是一个局部变量,这是我们自己定义的具名对象,而临时对象是编译器产生的。在这个程序里,编译器用了一个临时对象来保存函数的运行结果,它是以局部对象result为初值调用拷贝构造函数构造出来的,然后编译器再次调用拷贝构造函数将它拷贝给对象c(第9行打印),随后这个临时对象就被释放掉了(第10行打印)。
是否会产生临时对象跟编译器的实现有关,不同的编译器可能采取不同的策略,上面的情形只是编译器可能采用的策略之一,另外编译器也有可能采用另外的优化策略,比如将临时对象构造到局部对象result中,这样即可以减少调用一次构造函数和一次析构函数,也有可能采用更激进的优化手法,直接将c的地址传递给operator+函数,直接在函数里构造对象c,优化掉局部对象result和临时对象。第三种情形即是之前文章“深度解读《深度探索C++对象模型》之返回值优化”里讲过的,当类中有定义了拷贝构造函数时会触发编译器启用NRV优化。我们把优化选项打开,即把编译选项“-fno-elide-constructors”去掉,重新编译后输出:
Object::Object(), this = 0x16ee6f3f8 // 构造a
Object::Object(), this = 0x16ee6f3f4 // 构造b
Object operator+(const Object &, const Object &), obj1 = 0x16ee6f3f8, obj2 = 0x16ee6f3f4
Object::Object(), this = 0x16ee6f3e0 // 构造c
c.i = 0
Object::~Object(), this = 0x16ee6f3e0 // 析构c
Object::~Object(), this = 0x16ee6f3f4 // 析构b
Object::~Object(), this = 0x16ee6f3f8 // 析构a
从输出看到对象c直接在operator+函数里构造了,这比之前少调用了两次拷贝构造函数和两次析构函数,因为不需要构造局部对象result和临时对象了。
上面的代码在启用NRV优化后,临时对象就被编译器优化掉了,但在另外的一种情形下,临时对象却不能够被省略掉,将上面代码main函数做如下的修改:
// 将 Object c = a + b; 修改为:
Object c;
c = a + b;
当然上面的代码只是为了举例,实际的代码中可能是对象c在这里定义,然后在另外的地方重新给它赋值。将上面的代码修改后重新编译,在同样启用优化选项的情况下,程序的输出结果:
Object::Object(), this = 0x16f5a73f8 // 构造a
Object::Object(), this = 0x16f5a73f4 // 构造b
Object::Object(), this = 0x16f5a73e0 // 构造c
Object operator+(const Object &, const Object &), obj1 = 0x16f5a73f8, obj2 = 0x16f5a73f4
Object::Object(), this = 0x16f5a73dc // 构造临时对象
Object &Object::operator=(const Object &), this = 0x16f5a73e0, rhs = 0x16f5a73dc
Object::~Object(), this = 0x16f5a73dc // 析构临时对象
c.i = 0
Object::~Object(), this = 0x16f5a73e0 // 析构c
Object::~Object(), this = 0x16f5a73f4 // 析构b
Object::~Object(), this = 0x16f5a73f8 // 析构a
为什么说上面第5行的打印是构造临时对象而不是构造局部对象result?因为如果构造的是result,那么它在operator+函数调用结束时就会被析构掉了,而这里却是等到operator=函数调用完成后才析构它,说明它确实是一个临时对象,关于临时对象的存活周期下面再讲。这里编译器实际上也是做了一个优化,就是将临时对象构造在result对象上了,这样就省略了一次拷贝构造函数和一次析构的调用。上面的代码实际上是被转换成:
Object c;
c = a + b;
// 将被转换为:
Object tmp = a + b; // 即 operator+(tmp, a, b); 函数内直接构造tmp对象
c = tmp; // 即 c.operator=(tmp);
tmp.Object::~Object();
那么这里编译器为什么不再进一步优化,一定要保留临时对象呢?因为此时不能像采用NRV优化那样将对象c的地址传递给operator+函数,然后在operator+函数内直接构造对象c,因为这样做的前提是对象的空间是一块崭新的内存空间,但是此时对象c已经被构造过了,那就需要先调用它的析构函数,因为有可能在对象c的构造函数里有申请了系统资源,如果没有先释放掉这些系统资源就重新构造它就会造成系统资源的泄漏或者其他的运行错误。那么编译器是否可以将赋值语句(调用赋值运算符函数operator=)转换为一系列的调用析构、然后再构造的语句呢?如下面这样:
c.Object::~Object(); // 先析构对象c
c.Object::Object(a + b) // 重新构造对象c
答案是这种转换所得的结果并不一定是等同的,因为上述的拷贝构造函数、析构函数和拷贝赋值运算符函数operator=都可以是程序员定义的,编译器不能理解程序员的意图,比如程序员的预期是上面的赋值语句会调用到operator=函数,假如他需要在operator=函数里做一些事情,而此时如果把它转换成调用析构加拷贝构造函数了,这就违背了程序员本来的意图,所以这可能是一个错误的优化行为。
表达式运算过程产生的临时对象
在表达式的运算过程中有可能也会产生临时变量,比如当运算需要进行类型转换时,或者是暂存子表达式的运算结果。
- 类型转换产生的临时变量
int main() {
double d = 3.14;
const int &ri = d;
return 0;
}
上面的代码将会产生一个临时变量,double类型的变量d会先转换成int类型的值然后暂存在一个临时变量,引用ri绑定的是这一个临时变量,可以来看看编译器产生的汇编代码:
.LCPI0_0:
.quad 0x40091eb851eb851f # double 3.1400000000000001
main: # @main
# 略...
movsd xmm0, qword ptr [rip + .LCPI0_0] # xmm0 = mem[0],zero
movsd qword ptr [rbp - 16], xmm0
cvttsd2si eax, qword ptr [rbp - 16]
mov dword ptr [rbp - 28], eax
lea rax, [rbp - 28]
mov qword ptr [rbp - 24], rax
# 略...
cvttsd2si是一个SSE扩展指令,它的作用是取出一个64位的浮点值,并截断为一个64位的整型。上面第7行代码就是将d截断为整型并保存在eax寄存器中,然后第8行再截断为32位(dword类型)的int类型并保存在栈空间[rbp - 28]中,这个即是编译器自动产生的临时变量,第9、10行是取得它的地址并赋值给[rbp - 24],即ri变量的位置。为什么需要产生一个临时变量?因为此处ri引用的是一个int类型的数,对ri的操作应该是整型的运算,但d却是一个double类型的浮点数,因此为了确保ri绑定到一个整数,编译器就产生了一个整型的临时变量,让ri绑定到它。顺带提一下,临时对象实际上是一个右值,它不允许被修改,所以这里的ri引用必须是const引用,如果这里去掉const,编译则会通不过。
- 暂存运算结果产生的临时对象
假如我们给上小节中例子的Object类增加一个类型转换函数:
class Object {
public:
// 其它不变,新增如下函数
operator int() { return i; }
};
再假设有这样一段代码:
if ( a + b > 10) {
// do something
}
a + b将会产生一个临时对象,然后再在此临时对象之上实施int()类型转换,最后再与10比较大小。看一下它对应的汇编代码:
# 省略掉其它无关的代码
lea rdi, [rbp - 48]
lea rsi, [rbp - 8]
lea rdx, [rbp - 16]
call operator+(Object const&, Object const&)
lea rdi, [rbp - 48]
call Object::operator int()
mov dword ptr [rbp - 52], eax # 4-byte Spill
lea rdi, [rbp - 48]
call Object::~Object() [base object destructor]
mov eax, dword ptr [rbp - 52] # 4-byte Reload
cmp eax, 10
从省略掉的代码里知道[rbp - 8]存放的是对象a,[rbp - 16]存放的是对象b,[rbp - 48]其实存放的就是临时对象。上面代码的第2到第5行,相当于下面的伪代码:
operator+(&tmp, &a, &b);
相当于operator+函数的返回结果直接构造在临时对象tmp上。之后的第6到第8行是调用类型转换函数int()并将返回值eax暂存在栈空间[rbp - 52]中,然后这个临时对象就销毁了。