11.10.5 Copy/move elision

1

在满足特定条件时,即使所选拷贝/移动构造函数和/或对象析构函数存在副作用,实现也可以省略类对象的拷贝/移动构造。此种情况下,实现将省略的拷贝/移动操作的源对象与目标对象视为同一对象的两个引用方式。若所选构造函数的首参数为该对象类型的右值引用,则源对象的析构时机与目标对象原定析构时机相同;否则,源对象的析构将延迟至(在未优化情况下的)两个对象原定析构时间的较晚那个时刻(因为实际上只有一个对象被销毁(而不是两个),并且少执行了一次拷贝/移动构造,因此总体上仍保持构造一个对象然后销毁一个对象这种一一对应关系)。这种拷贝/移动的省略操作,被称为copy elision,在下列情形中允许使用(多种情形可组合使用,以消除多次拷贝):

  • (1.1) 在具有类返回类型的函数的返回语句中,当return后的表达式是①具有自动存储周期,②且类型与函数返回值类型相同,的non-volatile对象(但不是函数参数或catch子句中通过处理程序[except.handle]的异常声明引入的变量)的名字本身。那么可以通过将对象直接构造到函数调用的返回对象中,来省略拷贝/移动操作。
Paw5zx个人理解(针对1.1):点击查看更多

(1.1)描述的就是NRVO(命名返回值优化)这一类情况。核心就是在调用者的返回值存储区,直接构造被返回的局部对象。
对于结构体T:

1
2
3
4
5
6
7
8
struct T
{
int id;
T(int i) : id(i) { std::cout << "T(" << id << ") ctor" << std::endl; }
T(const T& other) : id(other.id) { std::cout << "T(" << id << ") copy ctor" << std::endl; }
T(T&& other) noexcept : id(other.id) { std::cout << "T(" << id << ") move ctor" << std::endl; }
~T() { std::cout << "T(" << id << ") dtor" << std::endl; }
};

执行

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
T make_nrvo() 
{
T local(1); // 自动存储周期,非volatile,与函数返回类型相同
std::cout << "return local" << std::endl;
return local; // 表达式是对象名称
}


int main()
{
std::cout << "Calling make_nrvo()" << std::endl;
T a = make_nrvo();
std::cout << "make_nrvo() done" << std::endl;

return 0;
}

先看关闭拷贝省略(添加编译选项-fno-elide-constructors)的输出:

1
2
3
4
5
6
7
Calling make_nrvo()
T(1) ctor <-- make_nrvo()内部构造局部变量local
return local
T(1) move ctor <-- 用local移动构造返回对象
T(1) dtor <-- 局部变量local析构
make_nrvo() done
T(1) dtor <-- a析构

此时的行为是:

  • ①调用者(main函数)为返回值a分配存储区(不构造)
  • ②被调函数(make_nrvo函数)内部栈帧构造局部对象local,调用T(int i)
  • ③因为拷贝省略被禁止,所以需要用local初始化返回对象,即在目标(a)地址上移动构造(优先移动,其次拷贝,原因:待验证?)
  • ④make_nrvo函数退出,析构local
  • ⑤main函数退出,析构a

现在开启拷贝省略,输出为:

1
2
3
4
5
Calling make_nrvo()
T(1) ctor <-- 直接在a的内存构造
return local
make_nrvo() done
T(1) dtor <-- a析构

编译器会识别到,返回语句中表达式符合1.1情况,所以不单独在函数栈帧中创建local,而是直接在调用者为返回值分配的存储区上构造。此时返回对象和local是同一个实体,不需要额外的拷贝/移动操作。

  • (1.2) 在throw表达式中,当操作数是①具有自动存储周期,②且类型与函数返回值类型相同,的non-volatile对象(但不是函数或catch子句的参数)的名字本身,这个对象作用域不会延伸超过最内层try块(若存在)的末尾,则可以通过将对象直接构造到异常对象中,来省略拷贝/移动操作。
Paw5zx个人理解(针对1.2):点击查看更多

(1.2)描述的核心是在异常对象存储区直接构造被抛出的局部对象。
对于结构体T(见1.1示例),执行

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
void make_throw()
{
try
{
T local(2);
std::cout << "throw exception local" << std::endl;
throw local; // 表达式是对象名称
}
catch(T& e)
{
std::cout << "caught exception T(" << e.id << ")" << std::endl;
}
}

int main()
{
std::cout << "Calling make_throw()" << std::endl;
make_throw();
std::cout << "make_throw() done" << std::endl;

return 0;
}

先看关闭拷贝省略(添加编译选项-fno-elide-constructors)的输出:

1
2
3
4
5
6
7
8
Calling make_throw()
T(2) ctor
throw exception local
T(2) move ctor
T(2) dtor
caught exception T(2)
T(2) dtor
make_throw() done

此时的行为是:

  • ①进入make_throw(),在内部栈帧构造局部对象local,调用T(int i)
  • ②为异常对象分配存储区(不构造)
  • ③因为拷贝省略被禁止,所以在throw表达式中使用local移动构造异常对象
  • ④local离开作用域,析构
  • ⑤按引用捕获异常对象,catch块结束后,异常对象声明周期结束,析构。

现在开启拷贝省略,输出仍是:

1
2
3
4
5
6
7
8
Calling make_throw()
T(2) ctor
throw exception local
T(2) move ctor
T(2) dtor
caught exception T(2)
T(2) dtor
make_throw() done

神奇,难道是因为这条规则是“允许”而不是“强制”?编译器采用的策略是构造异常对象+隐式移动?不明白。

  • (1.3) 在协程([dcl.fct.def.coroutine])中,可以省略协程参数的拷贝。(在这种情况下)凡是原本应当引用该副本的地方,都会改为直接引用对应的协程形参。前提是这种替换不会改变程序的语义(除去省略了参数副本对象的构造和析构这一点之外)
Paw5zx个人理解(针对1.3):点击查看更多

暂无,遇到再说

  • (1.4) 当异常处理器([except.pre])的异常声明所声明的对象类型(除去cv限定)与异常对象([except.throw])类型相同,
    则可以通过将该声明对象视为异常对象的别名(只要这种处理不会改变程序语义(除去省略了异常声明对象的构造和析构这一点之外)),来省略拷贝操作。【注释:由于异常对象总是一个左值,因此无法从它进行移动。 ——注释结束】
Paw5zx个人理解(针对1.4):点击查看更多

暂无,遇到再说

在以下情形中不允许执行拷贝省略:

【注释:如果同样的表达式在其他上下文中求值,仍可能进行拷贝省略。 ——注释结束】

Paw5zx个人理解(针对1.4):点击查看更多

在常量表达式语境和常量初始化中,不允许执行拷贝省略。(编译器在编译期需要可追踪到每一步构造/析构才能计算出最终值。拷贝省略属于一种运行时优化,不适用于这种“必须严格遵守构造序列”的编译期模拟执行。)

深入理解略,遇到再说

2

【示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
class Thing {
public:
Thing();
~Thing();
Thing(const Thing&);
};

Thing f() {
Thing t;
return t;
}

Thing t2 = f();

struct A {
void *p;
constexpr A(): p(this) {}
};

constexpr A g() {
A loc;
return loc;
}

constexpr A a; // well-formed, a.p points to a
constexpr A b = g(); // error: b.p would be dangling ([expr.const])

void h() {
A c = g(); // well-formed, c.p may point to c or to an ephemeral temporary
}

在此示例中,根据拷贝省略,可以消除将具有自动存储期的对象t拷贝到函数调用f()的结果对象t2的操作。实际上,局部对象t的构造可被视为直接初始化了全局对象t2,而该对象(t2)的析构将在程序退出时执行。若向Thing类添加移动构造函数会产生相同效果,但此时被消除的是从自动存储期对象tt2的移动构造。 ——注释结束】

2