【问题标题】:Pass by value vs pass by rvalue reference按值传递与按右​​值引用传递
【发布时间】:2016-10-22 11:15:12
【问题描述】:

我什么时候应该将我的函数声明为:

void foo(Widget w);

相对

void foo(Widget&& w);?

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

我和我的同事能想出的主要区别是右值引用参数迫使您明确说明副本。调用者负责制作显式副本,然后在您需要副本时将其与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

除此之外。除了在调用函数时强迫人们明确复制和更改你得到多少语法糖之外,这些还有什么不同?他们对界面有什么不同的看法?它们之间的效率更高还是更低?

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

  • 右值引用参数意味着您可以移动参数,但不强制要求它。您在调用站点传递的参数可能会在之后处于其原始状态。也有可能该函数会吃掉/更改参数而不调用移动构造函数,但假设因为它是一个右值引用,调用者放弃了控制。按值传递,如果你进入它,你必须假设发生了移动;别无选择。
  • 假设没有省略,通过右值传递消除单个移动构造函数调用。
  • 编译器有更好的机会通过值传递来省略复制/移动。任何人都可以证实这一说法吗?最好带有指向 gcc.godbolt.org 的链接,显示从 gcc/clang 生成的优化代码,而不是标准中的一行。我试图展示这一点可能无法成功隔离该行为:https://godbolt.org/g/4yomtt

附录: 为什么我要限制这个问题这么多?

  • 没有重载 - 如果有其他重载,这将转为讨论按值传递与包含 const 引用和 rvalue 引用的重载集,此时重载集显然更有效并且更胜一筹。这是众所周知的,因此并不有趣。
  • 没有模板 - 我对转发引用如何融入图片不感兴趣。如果您有转发引用,则无论如何都调用 std::forward 。转发参考的目标是在您收到东西时传递它们。副本不相关,因为您只需传递一个左值。众所周知,但并不有趣。
  • foo 需要 Widget 的所有权(又名const Widget&) - 我们不是在谈论只读函数。如果该函数是只读的,或者不需要拥有或延长Widget 的生命周期,那么答案就变成了const Widget&,这又是众所周知的,并不有趣。我还向您介绍了为什么我们不想谈论重载。

【问题讨论】:

  • 为什么不直接使用std::move 而不是制作中间副本?
  • @VermillionAzure - 如果我以后不打算使用该变量,我可以这样做。关键是如果我确实需要一个副本,它现在是明确的。该示例假设出于某种原因需要副本。
  • 这取决于foo 对参数所做的事情。像这样的非成员函数需要获取参数的所有权是不寻常的。
  • 这两个接口不可互换,因为传递值也采用左值。所以我不确定是否可以在不指定进一步限制使用的情况下进行有意义的比较。
  • 这个问题相当广泛。函数是否会修改对象会产生很大的不同,如果您对其进行约束,以便函数始终修改对象以获得更多的主题答案。

标签: c++ c++11 c++14


【解决方案1】:

关于接口与复制的右值用法说明了什么? 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);

换个角度看:

//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);

现在,如果小部件必须保持唯一性(就像 GTK 中的实际小部件一样),那么第一个选项就行不通了。第二、第三和第四个选项是有意义的,因为仍然只有一个真实的数据表示。不管怎样,当我在代码中看到这些语义时,这就是我所说的。

现在,至于效率:这取决于。如果 Widget 有一个指向数据成员的指针,其指向的内容可能相当大(想想一个数组),则右值引用可以节省大量时间。由于调用者使用了右值,他们说他们不再关心他们给你的东西了。因此,如果您想将调用者的 Widget 的内容移动到您的 Widget 中,只需获取他们的指针即可。无需仔细复制指针指向的数据结构中的每个元素。这可以很好地提高速度(再次,想想数组)。但是如果 Widget 类没有这样的东西,这个好处就看不到了。

希望这能满足您的要求;如果没有,我也许可以扩展/澄清一些事情。

【讨论】:

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

右值引用参数强制您明确说明副本。

是的,pass-by-rvalue-reference 得到了一个点。

右值引用参数意味着您可以移动参数,但不强制。

是的,按值传递得到了一点。

但这也为 pass-by-rvalue 提供了处理异常保证的机会:如果foo 抛出,widget 值就不需要消耗。

对于仅移动类型(如std::unique_ptr),按值传递似乎是常态(主要用于您的第二点,而第一点无论如何都不适用)。

编辑:标准库与我上一句相矛盾,shared_ptr 的构造函数之一采用std::unique_ptr<T, D>&&

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

除非你想保证没有不需要的副本,否则我会使用 pass-by-value 来实现一致性。

除非您想要保证和/或立即接收器,否则我会使用 pass-by-rvalue。

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

【讨论】:

  • 右值引用传递不表达与传值相同的接口。调用代码不能做同样的事情。所以当你根据类型推荐一个而不是另一个时,它是没有意义的。
  • @Cheersandhth.-Alf:在问题的上下文中,两者都表示所有权转移,一个允许隐式复制。
  • @Jarod42:显然我当时回复的内容已在清理中删除。现在我对自己的论点有点困惑。我仍然认为最好的选择是通过右值引用传递,但我认为这是最好的,因为它最清楚地传达所有权获取,给代码的读者。在答案中,我看不到我写了任何关于源代码作为交流的东西。嗯!
【解决方案3】:

除非该类型是只移动类型,否则您通常可以选择通过对 const 的引用传递,并且将其设为“不属于讨论的一部分”似乎是任意的,但我会尝试。

我认为选择部分取决于foo 将如何处理参数。

函数需要本地副本

假设Widget 是一个迭代器,您想实现自己的std::next 函数。 next 需要自己的副本前进然后返回。在这种情况下,您的选择类似于:

Widget next(Widget it, int n = 1){
    std::advance(it, n);
    return it;
}

Widget next(Widget&& it, int n = 1){
    std::advance(it, n);
    return std::move(it);
}

我认为这里按价值计算更好。从签名中您可以看到它正在复制。如果调用者想要避免复制,他们可以执行std::move 并保证变量被移出,但如果他们愿意,他们仍然可以传递左值。 使用 pass-by-rvalue-reference,调用者不能保证变量已经被移出。

将分配移动到副本

假设你有一个班级WidgetHolder

class WidgetHolder {
    Widget widget;
   //...
};

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

WidgetHolder::setWidget(const Widget& w) {
    widget = w;
}

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

WidgetHolder::setWidget(Widget w) {
    widget = std::move(w);
}

或重载:

WidgetHolder::setWidget(Widget&& widget) {
    widget = std::move(w);
}

这个有点棘手。选择按值传递很诱人,因为它同时接受右值和左值,因此您不需要两个重载。但是,它无条件地复制,因此您不能利用成员变量中的任何现有容量。 const 引用传递和 r 值传递引用重载使用 assignment 而不获取可能更快的副本

移动构建副本

现在假设您正在为 WidgetHolder 编写构造函数,并且和之前一样,您已经实现了一个接受 const 引用的构造函数:

WidgetHolder::WidgetHolder(const Widget& w) : widget(w) {
}

和之前一样,您已经测量了性能并决定需要针对右值进行优化。您可以选择将其替换为:

WidgetHolder::WidgetHolder(Widget w) : widget(std::move(w)) {
}

或重载:

WidgetHolder::WidgetHolder(Widget&& w) : widget(std:move(w)) {
}

在这种情况下,成员变量不能有任何现有容量,因为这是构造函数。您正在移动构建一个副本。此外,构造函数通常需要许多参数,因此编写所有不同的重载排列以优化 r 值引用可能会非常痛苦。因此,在这种情况下,最好使用按值传递,尤其是在构造函数采用许多此类参数的情况下。

传递unique_ptr

对于unique_ptr,效率问题并不那么重要,因为移动是如此便宜且没有任何容量。更重要的是表现力和正确性。关于如何通过unique_ptrhere 有很好的讨论。

【讨论】:

  • next 的右值参数版本做的工作比它需要的要多。它的界面说“我拥有你给我的迭代器”;无需在inside 函数中进行复制。当且仅当有必要时,才会在呼叫站点发生这种情况。因此,关于下一步的讨论没有实际意义。本次讨论的其余部分完全是关于重载,其中 const& 是重载之一。在这种情况下,众所周知右值参数更好。对这些情况不感兴趣。
  • 更正我的最后一条评论:无需在函数内进行复制移动。除非另有提示,否则调用者必须假定它已被移动。还有代码行!= 汇编行。
  • @Mark 你说得对,我不需要在next 中明确复制,尽管它只是将复制推迟到返回。关于“......众所周知,右值参数更好”,我试图提供一个例子(构造函数),其中按值更好。
  • @dragonxlwang 注意这不是WidgetHolder 的移动构造函数,那就是:WidgetHolder::WidgetHolder(WidgetHolder&&)。这是一个普通的构造函数,恰好采用Widget。在这种情况下通过 r 值引用传递并没有什么特别的错误,但是您必须有两个重载 WidgetHolder::WidgetHolder(Widget&& w)WidgetHolder::WidgetHolder(const Widget& w) 而不是一个没有任何特别好处的重载。如果您有一个带有许多参数的构造函数,这尤其糟糕,因为您需要许多重载排列。
  • @Siddu 一个右值引用本身就是一个左值,所以如果你不做std::move 它会尝试做一个复制分配而不是一个移动分配。
【解决方案4】:

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

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

【讨论】:

    【解决方案5】:

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

    首先,你有一个 RAII 类

    void fn(RAII &&);
    
    RAII x{underlying_resource};
    fn(std::move(x));
    // later in the code
    RAII y{underlying_resource};
    

    在初始化y 时,如果fn 没有移出右值引用,则资源仍可以由x 持有。在值传递代码中,我们知道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));
    

    在上面的例子中,如果fn1fn2 没有移出x,那么你最终会得到所有向量中的所有数据仍然存在。如果改为按值传递,则只有最后一个向量的数据仍然有效(假设向量移动构造函数清除源向量)。

    【讨论】:

    • 这是一个可靠的、合理的区别,其他人没有提到。这是微妙而令人敬畏的。可以通过template&lt;typename T&gt; typename std::remove_reference&lt;T&gt;::type move_fo_realzies(T&amp;&amp; x) { return std::move(x); } 解决。只有当你使用 std::move 而不是 move_fo_realzies 时,这个问题才会发挥作用......但是让人们使用后者需要一些额外的再教育。
    【解决方案6】:

    在按值和按右值引用之间进行选择,没有其他重载,没有意义。

    通过值传递,实际参数可以是左值表达式。

    通过右值引用传递,实际参数必须是右值。


    如果函数正在存储参数的副本,那么明智的选择是在按值传递和一组具有传递引用到常量和传递右值引用的重载之间。对于作为实际参数的右值表达式,一组重载可以避免一步。微优化是否值得增加复杂性和打字,这是一个工程直觉决定。

    【讨论】:

    • 这个答案避免了这个问题。除了第一句话,答案实际上是正确的,但没有相关性。我根据 OP 中列举的差异列表拒绝第一句话。
    • @Mark:如果我知道有什么方法可以让你摆脱这种愚蠢,我不会。
    • 需要明确的是,问题不在于在给定的限定情况下在两个列举的备选方案之间做出明智的选择。那将是一个有点愚蠢的问题。问题实际上是关于两个列举的替代方案之间的技术差异和代码与人类通信的差异,并提供了划定的环境以隔离正在考虑的案例并将其简化为最小形式。
    【解决方案7】:

    一个显着的区别是,如果你转向传值函数:

    void foo(Widget w);
    foo(std::move(copy));
    

    编译器必须生成一个移动构造函数调用Widget(Widget&amp;&amp;) 来创建值对象。在传递右值引用的情况下,不需要这样的调用,因为右值引用直接传递给方法。通常这并不重要,因为移动构造函数是微不足道的(或默认的)并且大部分时间都是内联的。 (您可以在 gcc.godbolt.org 上查看它——在您的示例中声明移动构造函数Widget(Widget&amp;&amp;);,它将显示在程序集中)

    所以我的经验法则是这样的:

    • 如果对象表示唯一资源(没有复制语义),我更喜欢使用 pass-by-rvalue-reference,
    • 否则,如果在逻辑上移动或复制对象是有意义的,我会使用按值传递。

    【讨论】:

      猜你喜欢
      • 1970-01-01
      • 2023-03-02
      • 2012-07-03
      • 1970-01-01
      • 2013-04-12
      • 1970-01-01
      • 2017-01-19
      相关资源
      最近更新 更多