有保证的复制消除(Guaranteed Copy Elision)

作者:Jonas Devlieghere

原文地址:https://jonasdevlieghere.com/guaranteed-copy-elision/

新的 C++ 17 标准带来了很多令人兴奋的新特性,其中一个微小的,不易觉察的改进就是“有保证的复制消除(guaranteed copy elision)”。注意关键字是“有保证”,因为“复制消除(copy elision)”很早就已经成为 C++ 标准的一部分了。尽管它不是像结构化绑定这样显著的语言特性改变,但是我还是很高兴它成为 C++ 标准的新内容。

复制消除(copy elision)

开始讨论这个新标准带来的特性改变之前,不妨先回顾一下 C++ 14 标准中关于复制消除的基本定义,如果你已经了解相关的内容,也知道它是如何工作的,请跳过这一部分。

请看一下下面这个小的类,它的构造函数和析构函数会在每次调用的时候打印一些信息,我将在本文的所有例子中使用这个类的代码。

struct Foo {
  Foo() { std::cout << "Constructed" << std::endl; }
  Foo(const Foo &) { std::cout << "Copy-constructed" << std::endl; }
  Foo(Foo &&) { std::cout << "Move-constructed" << std::endl; }
  ~Foo() { std::cout << "Destructed" << std::endl; }
};

理论上讲,即使存在产生副作用的可能,编译器仍然会在 3 种情况下省略对象的复制/移动构造。

返回值优化(Return Value Optimization,RVO)

最常用的复制消除技术就是返回值优化。如果以传值的方式返回一个对象,复制消除“允许”编译器避免相应的(对象)复制操作。

[…] 在一个返回类型是对象的函数中的 return 语句中,当表达式本身就是 non-volatile 自动(auto)对象(不是函数参数或 catch 块的参数)的名字,并且它与返回值类型一致,且有相同的 cv 限定(cv-unqualified)时,可以通过将这个自动对象直接构造到函数的返回值中来省略一次复制(移动)操作。

命名返回值优化(Named Return Value Optimization,NRVO)

有时,尽管返回值优化适用于任何一种方式,但是常规 RVO 和命名的 RVO 之间还是会有一些差别。下面就是一个使用命名返回值的例子。

Foo f() {
  Foo foo;
  return foo;
}

int main() { Foo foo = f(); }
返回值优化(Return Value Optimization,RVO)

至于常规的 RVO,如下所示,返回的仅仅是临时对象的值。

Foo f() {
  return Foo();
}

使用 -fno-elide-constructors 编译选项可以告诉编译器不要执行复制消除的动作,这是 clang 演示这个例子,不过 gcc 也接受一样的编译选项。下面的例子展示了执行复制消除与不执行复制消除的差别(对于 RVO 和 NRVO 的效果是一样的):

$ clang++ foo.cpp -std=c++11 -fno-elide-constructors && ./a.out
Constructed
Move-constructed
Destructed
Move-constructed
Destructed
Destructed

$ clang++ foo.cpp -std=c++11 && ./a.out
Constructed
Destructed

这个优化节省了两次(移动)构造函数的调用,第一次复制动作是将局部对象 foo 复制到函数 f() 返回值的临时对象中,第二次复制动作是将函数返回的临时对象复制到 main() 函数中的 foo 对象中。

临时对象传值(Passing a Temporary by Value)

第二种常用复制消除技术的情况是传递一个临时对象的值的时候。

[…] 当一个没有绑定到引用的临时类对象将被复制(移动)到一个具有相同类型的类对象时,可以通过将临时对象直接构造到目标对象的方式省略一次复制(移动)操作。

void f(Foo f) { std::cout << "Fn" << std::endl; }

int main() {
  f(Foo());
}
$ clang++ foo.cpp -fno-elide-constructors -std=c++11 && ./a.out
Constructed
Move-constructed
Fn
Destructed
Destructed

$ clang++ foo.cpp -std=c++11 && ./a.out
Constructed
Fn
Destructed

传值的方式抛出或捕获异常(Throwing and Catching Exceptions by Value)

从 C++ 11 开始,抛出或捕捉异常这两种情况也开始支持复制消除。

[…] 在 throw 表达式中,如果操作数是一个 non-volatile 自动对象(不是函数参数或 catch 块参数)的名字,并且这个对象的作用域不超过最内层封闭 try 块(如果有的话)的结尾,则将操作数复制(移动)到异常对象上的操作可以通过直接在异常对象上构造这个自动对象的方式省略掉。

[…] 当一个异常处理 handler 中的异常声明是一个对象,并且这个对象与异常对象类型相同(不考虑 cv 限定)的情况下,如果程序的意图除了对异常声明的对象执行构造函数和析构函数外,不做其他改变,则可以通过将异常声明中的对象看作是异常对象的别名(alias)的方式省略一次复制(移动)动作。

void f() {
  Foo foo;
  throw foo;
}

int main() {
  try {
    f();
  } catch (Foo foo) {
    std::cout << "Catch" << std::endl;
  }
}

让我惊讶的是,无论是 gcc 还是 clang,对这种情况都没有履行复制消除动作,到目前为止我还不知道为什么…

有保证的复制消除(Guaranteed Copy Elision)

有保证的复制消除提案强调了对于当前这种状况(译者注:就是非强制的复制消除策略)的几个问题。如果对复制消除没有保证(仅仅是允许),就无法摆脱拷贝构造函数和移动构造函数被调用的事实,因为消除动作没有发生,这是主要问题。这也阻止了一些不可移动(non-movable)的类型被当作函数返回值传递,比如工厂模式。

尽管在上一节我们已经看到,使用复制消除已经不需要调用拷贝或移动构造函数,但是下面的例子代码还是无法编译(译者注:因为 Foo 的拷贝构造函数和移动构造函数被标记为 delete):

struct Foo {
  Foo() { std::cout << "Constructed" << std::endl; }
  Foo(const Foo &) = delete;
  Foo(const Foo &&) = delete;
  ~Foo() { std::cout << "Destructed" << std::endl; }
};

Foo f() {
  return Foo();
}

int main() {
  Foo foo = f();
}

如果是 C++ 17,上面的代码可以通过编译,并且有相同的输出,就好像拷贝构造函数和移动构造函数还存在一样。这一点是如何做到的?下面就来解释一下。

值的分类(Value Categories)

这个提案的全称是“通过简化的值的分类保证复制消除”。为了实现有保证的复制消除,提案建议区分 prvalue(纯右值)表达式和通过它们初始化的临时对象。更具体地说,一个广义上的左值(generalized lvalue) glvalue 被定义为对象的位置,而一个纯右值 prvalue 被定义为对象的初始值设置者。

如果一个纯右值 prvalue 被用作另一个同一类型对象的初始值设置器(initializer),则直接对这个对象进行初始化,相应的结果就是通过函数返回值(临时对象)的初始化变成了直接初始化(译者注:函数调用表达式左边的对象原本要通过函数返回产生的临时对象初始化,现在变成直接对这个对象进行初始化),避免了拷贝或移动,这就意味着不需要访问对象的拷贝构造函数或移动构造函数。

第二个结果就是即使有了“有保证的复制消除”, C++ 17 中的 NVRO 也不会发生任何变化,正如前面所述,这个更改(译者注:指增加“有保证的复制消除”)只涉及纯右值,对 NVRO 来说,那个命名的值是个广义上的左值(glvalue),所以不受影响。提案的作者也承认了这一点,但是选择将其排除在提案之外。

虽然我们相信可靠的 NRVO 是影响性能的一个重要特征,但是 NRVO 的情况比较微妙,很难简单地给于任何“保证”。

附录:翻译单元(Translation Units)

在 reddit 上的这篇帖子的评论中,有人问复制省略是否会被限制在同一个翻译单元中。很快,用户 flitterio 就通过一个例子说明无论翻译单元的边界在哪里,复制省略都会发生。即使调用者所在的翻译单元不存在被调用的函数,返回值也能被(直接)复制到调用方的堆栈内存中。

附录:拷贝列表初始化(Copy-List-Initialization)

Evgeny Panasyuk 指出,实际上从 C++ 11 开始就可以通过函数返回不可移动(non-movable)的值,如果对象提供了非显式(non-explicit)的构造函数,拷贝列表初始化(Copy-List-Initialization)可以保证不会发生复制或移动动作。

Foo f() {  
    return {};
}

int main() {  
    auto &&foo = f();
}

这个机制是独立工作的,与复制消除没有任何关系。

clang++ foo.cpp -fno-elide-constructors -std=c++11 && ./a.out
Constructed
Destructed

【广告】更多与现代 C++ 有关的内容,请关注这个公众号
在这里插入图片描述

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值