C++ 提高性能手段 —— 临时对象的产生与避免
一、临时对象的概念
二、临时对象的产生与避免
一、临时对象的概念
临时对象是 在源码中不可见的,是栈上的、没有名字的对象。与函数内定义的临时对象有根本差别。
- 临时对象在源码中不可见,函数内定义的临时对象并不是这里讨论的临时对象:
非临时对象情况:
int func()
{
int tmp = 1; // 这里的 tmp 并不是临时对象,其生命周期在退出函数后结束
return tmp;
}
产生临时对象的情况:
int main()
{
int i = 1;
int j = i++; // 这里的 i++ 会产生临时对象,这里的临时对象是在系统中产生,代码中看不见的
// 首先将 i 的值赋给临时对象,再把临时对象的值作为返回结果赋给 j,再对 i 进行自增操作。
return 0;
}
-
临时对象存放在 栈 上,产生与销毁都不需要在代码中操作。
-
临时对象的产生与销毁会产生额外的系统开销,所以在代码书写时,应该尽量避免临时对象的产生。
二、临时对象的产生与避免
(以下程序在 C++ 11 环境下编译,并且关闭了编译器的构造优化 -fno-elide-constructors,以分析临时对象的产生)
C++标准允许一种(编译器)实现省略创建一个只是为了初始化另一个同类型对象的临时对象。指定这个参数(-fno-elide-constructors)将关闭这种优化,强制G++在所有情况下调用拷贝构造函数。
并且从C++ 11 起,C++ 14 和 C++ 17 都有不同程度上的复制优化,具体可参考:
https://zh.cppreference.com/w/cpp/language/copy_elision
1、类型转换生成临时对象
例一:
class A
{
public:
int val;
public:
A(int v = 0) : val(v)
{
cout << "A()\t" << val << endl;
}
~A()
{
cout << "~A()\t" << val << endl;
}
A(const A& t) : val(t.val)
{
cout << "A(const A& t)" << val << endl;
}
A& operator=(const A& t)
{
val = t.val;
cout << "A& operator=(const A& t)" << endl;
}
};
int main()
{
A a;
a = 10; // 1. 将 10为参数调用了 A 的构造函数创建了一个 A 类型的临时对象
// 2. 通过拷贝赋值运算符,将临时对象的成员值赋给了 a
// 3. 临时对象被销毁,调用 A 的析构函数
return 0;
}
执行结果:
A() 0 // A a 调用构造函数
A() 10 // 临时对象调用构造函数,val = 10
A& operator=(const A& t) // 调用 拷贝赋值运算符,将临时对象的 tmp.val 赋值给 a.val
~A() 10 // 销毁临时对象
~A() 10 // 销毁 a
优化方法:将 A 的定义与初始化在一行代码中完成:
int main()
{
A a = 10;
return 0;
}
C++ 11 环境下执行结果:
A() 10 // 临时对象调用构造函数,val = 10
A(const A& t)10 // 调用拷贝构造函数,构造对象 a
~A() 10 // 销毁临时对象
~A() 10 // 销毁对象 a
在 C++ 11 的环境下,可以看到,系统通过将对象 a 通过临时对象调用拷贝构造函数,后置构造,省略了一步拷贝赋值的过程。
C++ 17 环境下执行结果:
A() 10 // 系统为 a 预留了空间,直接在预留空间中用 10 构造了对象 a
~A() 10 // 销毁对象 a
例二:
隐式类型转换时,产生临时对象
void PrintStr(const string& src)
{
cout << src << endl;
}
int main()
{
char mystr[100] = "hello world";
PrintStr(mystr); // char[] 类型隐式转换成 string 类型,产生临时对象
// src 绑定到了临时对象上
return 0;
}
将 char[] 类型强制转换成 string 类型,产生 string 类型的临时对象,并通过 mystr 进行赋值。此时调用 PrintStr,传入的实际上是这个 临时对象。也就是说,src 绑定到了临时对象上。
临时对象是右值,可以通过 const 左值引用绑定,但是不允许修改。
如果将 PrintStr 修改成:
void PrintStr(string& src)
{
cout << src << endl;
}
那么将会编译报错,因为左值引用是不能够绑定右值的。
如果将 PrintStr 修改成:
void PrintStr(string&& src)
{
cout << src << endl;
}
那么对于本 case,是可以编译通过的,因为可以通过右值引用绑定右值。但是如果此时未经过隐式类型转换,直接传入 string 类型左值,就会编译报错,因为不能通过右值引用绑定左值。
这里要避免产生临时对象,实际上将传入的实参和函数的形参类型保持一致即可:
void PrintStr(const string& src)
{
cout << src << endl;
}
int main()
{
string mystr("hello world") ;
PrintStr(mystr);
return 0;
}
2、类型转换生成临时对象
例一:
A Double(A& src)
{
A tmp(src.val * 2);
return tmp;
}
int main()
{
A a1(10);
A a2 = Double(a1);
return 0;
}
执行结果:
A() 10 // 产生对象 a1,调用构造函数
A() 20 // 产生对象 tmp,调用构造函数
A(const A& t)20 // 产生临时对象,调用拷贝构造函数,拷贝对象 tmp
~A() 20 // 对象 tmp 被销毁
A(const A& t)20 // 产生对象 a2,调用 拷贝构造函数,拷贝临时对象
~A() 20 // 临时对象被销毁
~A() 20 // 对象 a2 被销毁
~A() 10 // 对象 a1 被销毁
通过执行结果,可以看出,函数返回对象,会导致临时对象的产生,多了一次拷贝构造函数的产生和一次析构函数的产生。
同时,在此编译环境下,会产生 “从临时对象到对象 a2 的拷贝”,我看了其他人的操作,都没有多出来的这一步,可能是C++版本不同导致(本环境为 Windows C++ 11),待更多尝试验证。
例二:
通过右值引用绑定函数返回的临时对象:
A Double(A& src)
{
A tmp(src.val * 2);
return tmp;
}
int main()
{
A a1(10);
A&& a2 = Double(a1);
return 0;
}
执行结果:
A() 10
A() 20
A(const A& t)20
~A() 20
~A() 20
~A() 10
通过右值引用绑定 函数返回对象 产生的 临时对象,不会产生从 临时对象到 a2 的拷贝。因为此时是通过 引用 去绑定,不会产生新的对象。
例三:
函数返回对象的引用:
A& Double(A& src)
{
A tmp(src.val * 2);
A& tmplr = tmp;
return tmplr;
}
int main()
{
A a1(10);
A& a2 = Double(a1);
cout << "a2.val\t" << a2.val << endl;
A& a3 = Double(a1);
cout << "a3.val\t" << a3.val << endl;
cout << "a3.val\t" << a3.val << endl;
cout << "a2.val\t" << a2.val << endl;
return 0;
}
执行结果:
A() 10
A() 20
~A() 20
a2.val 20
A() 20
~A() 20
&a2 0x71fdd0
&a3 0x71fdd0
a3.val 632089152
a3.val 632089152
a2.val 632089152
~A() 10
在这种情况下,函数返回对象的引用,在 Double 函数中,tmplr 所绑定的 tmp 退出函数时已被销毁,但是引用仍然保持可访问的状态。这种引用被称为 悬垂引用。
从执行结果可以看出, 引用 a2 和 a3 的地址相同,都是 Double(a1) 返回的引用。但是当访问这个引用的数据时,会发现返回的数据是 不稳定 的,因为实际上此时它们所绑定的对象已经被销毁。
这种 悬垂引用 在代码的书写中,应该尽量避免。
优化方法:
不建议使用 【引用】来减少 构造和析构的开销。建议使用 返回值优化 (RVO)
A Double(A& src)
{
return A(src.val * 2);
}
int main()
{
A a1(10);
A a2 = Double(a1);
return 0;
}
理想执行结果:
A() 10
A() 20
~A() 20
~A() 10
但是。。 可能因为编译环境的问题…… 本人实操时的执行结果却是:
A() 10
A() 20
A(const A& t)20
~A() 20
A(const A& t)20
~A() 20
~A() 20
~A() 10
待验证……待验证……
不过幸运的是,现在编译器已经对 消除复制 有了很好的优化,当编译时去掉参数 -fno-elide-constructors,就能够得到最优的优化效果:
A() 10
A() 20
~A() 20
~A() 10