按值传递参数的复制省略

25

鉴于

struct Range{
    Range(double from, double to) : from(from), to(to) {}
    double from;
    double to;
};

struct Box{
    Box(Range x, Range y) : x(x), y(y) {}
    Range x;
    Range y;
};

假设我们运行 Box box(Range(0.0,1.0),Range(0.0,2.0))

启用优化的现代编译器是否可以在此构建过程中完全避免复制 Range 对象?(即,一开始就在 box 内部构造 Range 对象?)

4个回答

30
实际上,每个传递给构造函数的Range对象都执行了两次复制。第一次是将临时的Range对象复制到函数参数中。可以根据101010答案中给出的参考资料省略这一步骤。在特定情况下可以执行复制省略,详情请参见此处
第二次复制是将函数参数复制到成员变量中(在构造函数初始化列表中指定)。这不能被省略,这就解释了为什么您仍然会看到每个参数只有一个副本的情况。
当复制构造函数具有副作用时(例如YSC答案中的打印操作),第一次复制仍然可以进行复制省略,但第二次复制必须保留。
然而,编译器总是可以进行更改,如果这些更改不会改变程序的观察行为(这被称为“as-if”规则)。这意味着,如果复制构造函数没有副作用,并且删除构造函数调用不会改变结果,则编译器可以自由地删除甚至是第二个复制。
您可以通过分析生成的汇编代码来查看此情况。在这个例子中,编译器甚至优化了复制和Box对象本身的构造。
Box box(Range(a,b),Range(c,d));
std::cout << box.x.from;

生成与以下代码完全相同的汇编代码:

std::cout << a;

2
也许如果您更清晰地引用这里有两个因素(1)复制省略,它允许省略无论如何实现复制构造函数/析构函数,是C++特定的规则,和(2)As If规则,它允许省略任何不改变行为的代码,是通用优化器规则,那么这可能会得到改进。其他答案在(2)方面失败了,因为它们开始检测复制构造函数/析构函数调用以见证复制,从而抑制了“As If”规则,因此使优化器保留了复制行为。 - Matthieu M.
@MatthieuM。我已经进行了编辑,希望能更清楚地表达区别。 - interjay
2
@JordanMelo 如果你想知道为什么第一个副本可以省略,那是因为标准允许在其复制省略规则中进行,即使构造函数具有副作用也适用。这里相关的规则是从临时变量中省略复制。如果你想知道为什么第二个必须保留,那是因为它不符合复制省略的允许应用程序,并且由于它具有副作用,无法通过as-if规则删除。 - interjay
1
为什么第二种情况不符合复制省略的允许应用程序?是因为它不是从临时变量复制的吗? - Jordan Melo
2
@JordanMelo 正确。构造函数参数 fromto 是命名变量,因此不是临时变量。 - interjay
显示剩余6条评论

5
可以的,在特定情况下,这种复制省略上下文符合标准中 12.8/p31.3 复制和移动类对象[class.copy] 指定的复制省略标准:

(31.3) -- 当临时类对象未绑定到引用(12.2) 时,将会将它复制/移动到具有相同类型(忽略cv-qualification)的类对象,可以通过直接在省略的复制/移动目标中构造临时对象来省略复制/移动操作。

任何合适的编译器都可以在这个特定的情况下应用复制省略。然而,在 OP 示例中发生了两次复制。
  1. 传递给构造函数的临时对象(可以按照上述标准省略)。
  2. Box 构造函数初始化列表中的复制(不能省略)。
您可以在此演示中看到它,其中复制构造函数仅被调用了2次。
还要注意,由于标准允许在特定情况下进行复制省略优化,并不意味着编译器供应商必须这样做。复制省略是唯一允许改变可观察副作用的优化形式。因此,由于某些编译器不在每个允许的情况下执行复制省略优化(例如,在调试模式下),依赖于复制/移动构造函数和析构函数的副作用的程序是不可移植的。

3
我无法让它发生。给我一个例子会更有说服力。 - Museful
1
请问给我点踩的人可以解释一下他们点踩的原因吗?如果真的有的话... - 101010
我没有点踩,但是点踩的人可能来自于这个问题(https://dev59.com/ypHea4cB1Zd3GeqPoF7W),该问题链接到了你的回答。我想他们点踩是因为在你编辑之前,你的回答部分是不正确的(问题问的是这些副本是否可以完全省略,答案应该是否定的)。 - interjay
只是为了明确:这部分是指“在对象初始化时,当初始化表达式是与变量类型相同的同类类型(忽略cv限定符)的prvalue时”,因此自C++17以来已经得到保证。 - dragonxlwang
@dragonxlwang 是的,对于必须的情况是这样。 - 101010
显示剩余2条评论

5

这本应该可以实现,但是我无法使其正常工作(示例)。编译器可能会检测到构造函数的副作用并决定不使用复制省略。

#include <iostream>

struct Range{
    Range(double from, double to) : from(from), to(to) { std::cout << "Range(double,double)" << std::endl; }
    Range(const Range& other) : from(other.from), to(other.to) { std::cout << "Range(const Range&)" << std::endl; }
    double from;
    double to;
};

struct Box{
    Box(Range x, Range y) : x(x), y(y) { std::cout << "Box(Range,Range)" << std::endl; }
    Box(const Box& other) : x(other.x), y(other.y) { std::cout << "Box(const Box&)" << std::endl; }
    Range x;
    Range y;
};


int main(int argc, char** argv)
{
    (void) argv;
    const Box box(Range(argc, 1.0), Range(0.0, 2.0));
    std::cout << box.x.from << std::endl;
    return 0;
}

编译和运行:

clang++ -std=c++14 -O3 -Wall -Wextra -pedantic -Werror -pthread main.cpp && ./a.out

输出:

Range(double,double)
Range(double,double)
Range(const Range&)
Range(const Range&)
Box(Range,Range)
1

2
但你没有从拷贝构造函数中打印。 - Museful
3
这确实省略了两个拷贝构造函数,进入按值构造函数,只是没有省略从参数到 xy 成员的拷贝。一个常见的模式是先按值获取,然后使用 std::move 移动到数据成员中。 - TartanLlama
2
@YSC 复制省略不关心副作用。这就是整个重点。 - juanchopanza
无论如何,如果您使用 const Range &,则可以确保没有复制。除非这不是我的代码,我无法重构它,否则我不会依赖这些优化。 - Jean-François Fabre

1
虽然它有这个能力,但并不意味着它一定会这样做。在这个演示中可以看到,很明显你正在创建两份副本。提示:输出包含两次“copy made”。

我认为这不是一个公平的测试。编译器必须遵循 as-if 规则并发出调用代码中放置的 cout。因此,您引入了一个条件,防止副本被省略。 - Evan Teran
3
@EvanTeran 不正确。复制省略是编译器不必遵守“as-if”规则下其他任何情况下都是强制性的副作用,可以丢弃它们的唯一情况。 - underscore_d
@underscore_d,很有趣,感谢更正。 - Evan Teran

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接