通过价值传递与通过右值参考传递

Mar*_*ark 48 c++ c++11 c++14

我什么时候应该声明我的功能:

void foo(Widget w);

而不是

void foo(Widget&& w);

假设这是唯一的重载(例如,我选择一个或另一个,不是两个,也没有其他重载).没有涉及模板.假设该函数foo需要所有权Widget(例如const Widget&,不是本讨论的一部分).我对这些情况范围之外的任何答案都不感兴趣.请参阅帖子末尾的附录,了解为什么这些约束是问题的一部分.

我和我的同事可以提出的主要区别是rvalue参考参数强制您明确复制副本.调用者负责制作一个显式副本,然后在std::move需要副本时将其传入.在按值传递的情况下,隐藏了副本的成本:

    //If foo is a pass by value function, calling + making a copy:
    Widget x{};
    foo(x); //Implicit copy
    //Not shown: continues to use x locally

    //If foo is a pass by rvalue reference function, calling + making a copy:
    Widget x{};
    //foo(x); //This would be a compiler error
    auto copy = x; //Explicit copy
    foo(std::move(copy));
    //Not shown: continues to use x locally
Run Code Online (Sandbox Code Playgroud)

除了那个差异.除了强迫人们明确复制和改变调用函数时获得的语法糖量,这些还有什么不同?他们对界面有何不同看法?它们的效率是否高于或低于其他?

我和我的同事已经想过的其他事情:

  • rvalue引用参数意味着您可以移动参数,但不强制它.您在呼叫站点传递的参数可能会在之后处于其原始状态.函数也可能会在没有调用移动构造函数的情况下使用/更改参数,但是假设因为它是一个右值引用,调用者放弃了控制权.通过价值,如果你进入它,你必须假设一个移动发生; 没有选择.
  • 假设没有elisions,则通过rvalue消除单个移动构造函数调用.
  • 编译器有更好的机会通过值传递复制/移动.有人可以证实这种说法吗?最好有一个链接到gcc.godbolt.org,显示来自gcc/clang的优化生成代码,而不是标准中的一行.我试图表明这一点可能无法成功地隔离行为:https://godbolt.org/g/4yomtt

附录: 为什么我如此限制这个问题?

  • 没有重载 - 如果有其他重载,这将转变为关于传递值与包含const引用和rvalue引用的一组重载的讨论,此时重载集显然更有效并获胜.这是众所周知的,因此并不有趣.
  • 没有模板 - 我对转发引用如何适应图片不感兴趣.如果您有转发引用,则无论如何都要调用std :: forward.转发引用的目标是在收到它们时传递内容.副本不相关,因为您只是传递左值.这是众所周知的,而且并不有趣.
  • foo需要所有权Widget(又名否const Widget&) - 我们不是在谈论只读函数.如果函数是只读的或者不需要拥有或延长它的生命周期Widget,那么答案就会变得简单const Widget&,这也是众所周知的,而且并不有趣.我还提到你为什么我们不想谈论重载.

Alt*_*nia 14

关于界面与复制,rvalue用法有什么用?rvalue向调用者建议该函数都想拥有该值,并且无意让调用者知道它所做的任何更改.考虑以下(我知道你在你的例子中说没有左值引用,但请耐心等待):

//Hello. I want my own local copy of your Widget that I will manipulate,
//but I don't want my changes to affect the one you have. I may or may not
//hold onto it for later, but that's none of your business.
void foo(Widget w);

//Hello. I want to take your Widget and play with it. It may be in a
//different state than when you gave it to me, but it'll still be yours
//when I'm finished. Trust me!
void foo(Widget& w);

//Hello. Can I see that Widget of yours? I don't want to mess with it;
//I just want to check something out on it. Read that one value from it,
//or observe what state it's in. I won't touch it and I won't keep it.
void foo(const Widget& w);

//Hello. Ooh, I like that Widget you have. You're not going to use it
//anymore, are you? Please just give it to me. Thank you! It's my
//responsibility now, so don't worry about it anymore, m'kay?
void foo(Widget&& w);
Run Code Online (Sandbox Code Playgroud)

另一种看待它的方式:

//Here, let me buy you a new car just like mine. I don't care if you wreck
//it or give it a new paint job; you have yours and I have mine.
void foo(Car c);

//Here are the keys to my car. I understand that it may come back...
//not quite the same... as I lent it to you, but I'm okay with that.
void foo(Car& c);

//Here are the keys to my car as long as you promise to not give it a
//paint job or anything like that
void foo(const Car& c);

//I don't need my car anymore, so I'm signing the title over to you now.
//Happy birthday!
void foo(Car&& c);
Run Code Online (Sandbox Code Playgroud)

现在,如果Widgets必须保持唯一(例如,GTK中的实际小部件),那么第一个选项不起作用.第二,第三和第四选项是有意义的,因为仍然只有一个真实的数据表示.无论如何,当我在代码中看到它们时,这就是那些语义对我说的.

现在,至于效率:它取决于.如果Widget具有指向数据成员的指针,则rvalue引用可以节省大量时间,该数据成员的指向内容可能相当大(想想一个数组).由于呼叫者使用右值,他们说他们不关心他们给你的东西了.因此,如果您想将调用者的Widget内容移动到您的Widget中,只需使用他们的指针即可.无需精心复制其指针指向的数据结构中的每个元素.这可以带来相当好的速度提升(再次,思考数组).但是如果Widget类没有任何这样的东西,那么这个好处就无处可见了.

希望能得到你所要求的; 如果没有,我可以扩展/澄清一些事情.

  • @Mark 你是对的;最后,将汽车移入 `void foo(Car c)` 会导致同样的结果;这只是一种人为的方式。简想要一辆车,鲍勃不再需要他的了。但简不想强加于鲍勃,所以她拒绝乘坐鲍勃的私人汽车;只想要一个喜欢的。鲍勃想摆脱他的车,然后假装给她买了一辆新的,但实际上给了她他的。这个用例是两个人并没有真正同意接口应该是什么。另外,为了完整性,我提到了其他情况:) (5认同)
  • 这是我读过的关于按值传递、左值引用、const 左值引用和右值引用的最佳解释之一。真的吗! (3认同)
  • @Altainia 你在代码中的评论是最好的。你不仅仅是一个好的程序员,你还是一个好的老师。谢谢 :) (3认同)
  • 这些答案错过了某人将Widget或Car移动到第一种情况的可能性,从而仍然避免了副本.`void foo(Car c)`的情况并不是说我们每个人都有车,它说"我需要一辆车的所有权",它可能是一个新的(副本),它可能是你已经决定你不再需要你了,并把它给了我.此时,案例1和案例4之间的区别在于副本的显式性(就接口而言).然后,只有这样,性能问题才会变得有趣.其他案件仍然超出范围. (2认同)

Chr*_*rew 10

除非类型是一个只移动类型,你通常有一个选项来传递引用到const,似乎任意使它"不是讨论的一部分",但我会尝试.

我认为选择部分取决于foo参数的作用.

该功能需要本地副本

假设Widget是一个迭代器,你想实现自己的std::next函数.next需要自己的副本才能提前然后返回.在这种情况下,您的选择是这样的:

Widget next(Widget it, int n = 1){
    std::advance(it, n);
    return it;
}
Run Code Online (Sandbox Code Playgroud)

VS

Widget next(Widget&& it, int n = 1){
    std::advance(it, n);
    return std::move(it);
}
Run Code Online (Sandbox Code Playgroud)

我认为按价值在这里更好.从签名中你可以看到它正在复制.如果调用者想要避免副本,他们可以做一个std::move并保证变量被移出但是如果他们愿意,他们仍然可以传递左值.通过rvalue-reference传递,调用者无法保证变量已被移动.

移动分配到副本

假设你有一个班级WidgetHolder:

class WidgetHolder {
    Widget widget;
   //...
};
Run Code Online (Sandbox Code Playgroud)

你需要实现一个setWidget成员函数.我假设你已经有一个带引用const的重载:

WidgetHolder::setWidget(const Widget& w) {
    widget = w;
}
Run Code Online (Sandbox Code Playgroud)

但在测量性能后,您决定需要针对r值进行优化.您可以选择将其替换为:

WidgetHolder::setWidget(Widget w) {
    widget = std::move(w);
}
Run Code Online (Sandbox Code Playgroud)

或者超载:

WidgetHolder::setWidget(Widget&& widget) {
    widget = std::move(w);
}
Run Code Online (Sandbox Code Playgroud)

这个有点棘手.它很诱人选择pass-by-value,因为它接受rvalues和lvalues,所以你不需要两个重载.但是,它无条件地复制,因此您无法利用成员变量中的任何现有容量.通过引用传递给const并传递r值引用重载使用赋值而不需要复制可能更快的副本

移动构建副本

现在假设你正在编写构造函数,WidgetHolder并且像以前一样已经实现了一个带有引用的构造函数:

WidgetHolder::WidgetHolder(const Widget& w) : widget(w) {
}
Run Code Online (Sandbox Code Playgroud)

和之前一样,你已经测量了性能,并决定你需要优化rvalues.您可以选择将其替换为:

WidgetHolder::WidgetHolder(Widget w) : widget(std::move(w)) {
}
Run Code Online (Sandbox Code Playgroud)

或者超载:

WidgetHolder::WidgetHolder(Widget&& w) : widget(std:move(w)) {
}
Run Code Online (Sandbox Code Playgroud)

在这种情况下,成员变量不能具有任何现有容量,因为这是构造函数.你正在移动构建一个副本.此外,构造函数通常采用许多参数,因此编写所有不同的重载排列以优化r值引用可能会非常痛苦.所以在这种情况下,使用pass-by-value是个好主意,特别是如果构造函数需要很多这样的参数.

通过 unique_ptr

随着unique_ptr效率的担忧不太重要,因为一个举动是如此便宜,它没有任何能力.更重要的是表现力和正确性.关于如何通过unique_ptr 这里有一个很好的讨论.


Jar*_*d42 9

右值参考参数强制您明确表示副本.

是的,通过右值参考获得了一个观点.

rvalue引用参数意味着您可以移动参数,但不强制它.

是的,按值传递得到了一分.但是,这也为r-pass-by-rvalue提供了处理异常保证的机会:如果foo抛出,widget则不需要消耗价值.

对于仅移动类型(as std::unique_ptr),pass-by-value似乎是常态(主要针对您的第二点,并且第一点不适用).

编辑:标准库与我之前的句子相矛盾,其中一个shared_ptr构造函数需要std::unique_ptr<T, D>&&.

对于同时具有复制/移动(as std::shared_ptr)的类型,我们可以选择与先前类型的一致性或强制在复制时使用.

除非你想保证没有不需要的副本,否则我会使用按值传递来实现一致性. 除非你想要保证和/或立即接收,否则我会使用右移传递.

对于现有的代码库,我会保持一致性.

  • 传递rvalue-ref**并不表示与传递值相同的接口**.调用代码不能做同样的事情.因此,当您根据类型的类型推荐一个而不是另一个时,它没有意义. (2认同)

rua*_*akh 7

其他答案中没有提到的一个问题是异常安全的想法。

一般来说,如果函数抛出异常,我们理想情况下希望有强异常保证,这意味着调用除了引发异常之外没有任何影响。如果传值使用了移动构造函数,那么这种效果本质上是不可避免的。因此,在某些情况下,右值引用参数可能更好。(当然,在各种情况下,无论哪种方式都无法实现强异常保证,以及各种情况下都可以使用不抛出保证。所以这在 100% 的情况下并不相关。但它是相关的有时。)


Nic*_*ick 5

当您通过右值引用对象生命周期变得复杂。如果被调用者没有移出参数,则延迟销毁参数。我认为这在两种情况下都很有趣。

首先,你有一个 RAII 类

void fn(RAII &&);

RAII x{underlying_resource};
fn(std::move(x));
// later in the code
RAII y{underlying_resource};
Run Code Online (Sandbox Code Playgroud)

初始化时yx如果fn不移出右值引用,则资源仍可被持有。在传值代码中,我们知道x被移出并fn释放x。这可能是您想要按值传递的情况,并且复制构造函数可能会被删除,因此您不必担心意外复制。

其次,如果参数是一个大对象并且函数没有移出,则向量数据的生命周期比按值传递的情况要长。

vector<B> fn1(vector<A> &&x);
vector<C> fn2(vector<B> &&x);

vector<A> va;  // large vector
vector<B> vb = fn1(std::move(va));
vector<C> vc = fn2(std::move(vb));
Run Code Online (Sandbox Code Playgroud)

在上面的示例中,如果fn1并且fn2不移出x,那么您最终将得到所有向量中的所有数据仍然存在。如果改为按值传递,则只有最后一个向量的数据仍然有效(假设向量移动构造函数清除了源向量)。