传值后移的构造是一个糟糕的习惯吗?

86

由于C++中具有移动语义,现在通常这样做

void set_a(A a) { _a = std::move(a); }

如果a是rvalue,推断是复制将被省略,并且只会进行一次移动。

但是如果a是lvalue会发生什么?似乎会进行一个复制构造,然后再进行一个移动赋值(假设A拥有适当的移动赋值运算符)。如果对象具有太多成员变量,则移动赋值可能代价高昂。

另一方面,如果我们执行:

void set_a(const A& a) { _a = a; }

只会有一个拷贝赋值函数。如果我们将传递左值,那么是否可以说这种方式比按值传递的惯用法更好?


1
调用std::move在一个const&上会返回一个const&&,不能从中移动。 - Casey
你是对的,我已经编辑过了。 - jbgs
1
还有相关信息:https://dev59.com/lWUp5IYBdhLWcg3wAjtF#15600615。 - Andy Prowl
1
C++核心指南针对这种情况制定了规则F.15(高级),请参见http://isocpp.github.io/CppCoreGuidelines/CppCoreGuidelines#f15-prefer-simple-and-conventional-ways-of-passing-information。 - KindDragon
这里有一个由Nicolai Josuttis主讲的相关讨论,其中讨论了一些选项:https://www.youtube.com/watch?v=PNRju6_yn3o - johannes
显示剩余4条评论
7个回答

55

在现代C++中,需要昂贵移动的类型很少。如果你担心移动成本,那么请编写两个重载函数:

void set_a(const A& a) { _a = a; }
void set_a(A&& a) { _a = std::move(a); }

或者一个完美转发的setter:

template <typename T>
void set_a(T&& a) { _a = std::forward<T>(a); }

它将接受lvalue,rvalue和任何隐式可转换为decltype(_a)的内容,而不需要额外的拷贝或移动。

尽管从lvalue设置时需要额外移动,但这种习惯用法并不算不好,因为(a)绝大多数类型提供常数时间的移动(b)拷贝并交换提供了异常安全和近乎最优的性能,在一行代码中实现。


20
没错,但我认为昂贵的可移动类型并不罕见。实际上,仅由POD组成的类与昂贵的复制类型一样昂贵。通过值传递然后移动的代价,在传递左值时,就像进行两次复制一样昂贵。这就是为什么这对我来说似乎是一个糟糕的习惯用法。 - jbgs
3
我同意在正常情况下这不应该太花费。至少根据特定的C++11语法风格来看,它并不算太昂贵。但我仍然对这种“移动是廉价的”感到不安(我并不是说它们无论如何都不便宜)。 - jbgs
1
让我想知道为什么完美转发不总是优于这个习惯用法。 - jbgs
3
@jbgs 完美转发还需要暴露实现细节。 - Yakk - Adam Nevraumont
3
请注意,如果 T 是可以由 std::initializer_list 构造的东西,这将不允许您在调用中使用列表。例如,set_a({1,2,3}) 将需要变成 set_a(A{1,2,3}),因为大括号初始化列表没有类型。 - NathanOliver
显示剩余6条评论

31

但是,如果a是左值呢?这似乎会有一个复制构造和一个移动赋值(假设A有适当的移动赋值运算符)。如果对象具有太多成员变量,则移动赋值可能代价高昂。

问题被很好地发现了。我不想说传值后再移动的构造方式是一个糟糕的模式,但它绝对有潜在的问题。

如果您的类型移动代价高并且/或者移动本质上只是一次复制操作,那么传值方式就不是最优选择。这样的类型示例包括具有固定大小数组成员的类型:移动可能相对昂贵,并且移动仅仅是一次复制操作。请参见此处的Small String Optimization and Move Operations"Want speed? Measure." (by Howard Hinnant)

传值方式的优点是您只需要维护一个函数,但您需要牺牲性能。是否使用此方式取决于您的应用程序,即维护优势是否大于性能损失。

如果您有多个参数,传递左值和右值引用可能会很快导致维护问题。请考虑以下内容:

#include <vector>
using namespace std;

struct A { vector<int> v; };
struct B { vector<int> v; };

struct C {
  A a;
  B b;
  C(const A&  a, const B&  b) : a(a), b(b) { }
  C(const A&  a,       B&& b) : a(a), b(move(b)) { }
  C(      A&& a, const B&  b) : a(move(a)), b(b) { }
  C(      A&& a,       B&& b) : a(move(a)), b(move(b)) { }  
};

如果你有多个参数,就会出现排列问题。在这个非常简单的例子中,维护这4个构造函数可能还不错。但是,在这个简单的情况下,我会认真考虑使用单个函数的传值方法来替代上面的4个构造函数:

C(A a, B b) : a(move(a)), b(move(b)) { }

长话短说,两种方法都有缺点。根据实际的性能测试信息做出决策,而非过早优化。


1
这就是问题所在。假设固定大小的数组很少见,这种说法公平吗?我认为我们可以找到太多情况,其中按值传递和移动是次优的。当然,我们可以编写重载来改进它...但这意味着摆脱这种习惯用法。这就是为什么它是“不好”的原因 :) - jbgs
3
@jbgs 我不会说固定大小的数组很少见,特别是由于小字符串优化。固定大小的数组非常有用:您可以节省动态内存分配,在我的经验中,在Windows上速度相当慢。如果您在低维线性代数或某些3D动画中使用固定大小的数组,或者使用某些专门的小字符串,您的应用程序将充满固定大小的数组。 - Ali
2
我完全同意。那正是我的意思。POD(尤其是数组)并不罕见。 - jbgs
这里的度量在哪儿? - Potatoswatter
1
@Matthias 这取决于你的PODs或固定大小的数组,以及你的目标。如果不知道你的上下文,我无法给出简单的规则。至于我自己,我只要能够通过const ref传递就这么做,然后进行性能分析。到目前为止,我使用这种方法没有遇到任何问题。 - Ali
显示剩余9条评论

13

目前的答案相当不完整。相反,我将试图根据我发现的优缺点列表进行总结。

简短回答

简而言之,它可能是可以的,但有时很糟糕。

这个习语,即统一接口,与转发模板或不同的重载相比,在概念设计和实现方面具有更好的清晰度。它有时与复制并交换(实际上,在这种情况下也包括移动并交换)一起使用。

详细分析

优点如下:

  • 每个参数列表只需要一个函数。
    • 确实只需要一个函数,而不是多个普通重载 (甚至当您拥有n个参数时,每个参数可以是未限定的或const限定的时,就需要2的n次方个重载)。
    • 与转发模板中一样,通过值传递的参数不仅与const兼容,而且与volatile兼容,这进一步减少了普通重载。
      • 结合上面的子弹点,您不需要4的n次方个重载来服务于n个参数的{未限定、const、const volatile}组合。
    • 与转发模板相比,只要参数不需要是通用的(通过模板类型参数进行参数化),它可以成为非模板函数。这允许离线定义,而不是需要为每个实例在每个翻译单元中实例化的模板定义,这可以显著提高翻译时间性能(通常在编译和链接期间都会显著提高)。
    • 它也使得其他重载(如果有的话)更容易实现。
      • 如果您有一个针对参数对象类型T的转发模板,它仍然可能与在同一位置具有参数const T&的重载冲突,因为参数可以是类型T的左值,并且类型T&(而不是const T&)实例化的模板对于它可以更受过载规则的青睐,当没有其他方法可以区分哪一个是最好的重载候选时。这种不一致可能会相当令人惊讶。
        • 特别是考虑您在类C中有一个带有类型为P&&的参数的转发模板构造函数。你会忘记多少次通过SFINAE(例如通过向template-parameter-list添加typename = enable_if_t<!is_same<C, decay_t<P>>来排除该实例不被可能的cv限定符C 与拷贝/移动构造函数冲突),以确保它不与拷贝/移动构造函数冲突(即使后者明确地由用户提供)?
    • 由于参数作为非引用类型的值传递,因此它可以强制参数作为prvalue传递。当参数是类字面类型时,这可能会产生差异。考虑这样一个类,在某个没有外部定义的类中声明了静态constexpr数据成员,当它作为左值引用类型的参数被用作参数时,可能最终无法链接,因为它odr-used并且没有定义它。
      • 注意,自ISO C++ 17以来,静态constexpr数据成员的规则已经发生了变化,隐含引入定义,因此在这种情况下,差异不是很大。

    缺点是:

    当参数对象类型与类相同时,统一接口无法替代复制和移动构造函数。否则,参数的复制初始化将会无限递归,因为它将调用统一构造函数,而构造函数又会调用自身。
    正如其他答案所提到的,如果复制成本不可忽略(足够便宜且可预测),这意味着在不需要复制时,您几乎总是会出现性能退化,因为通过传值统一传递的参数的复制初始化无条件地引入了一个参数的副本(无论是复制还是移动),除非省略。
    即使自C++17以来有强制省略,参数对象的复制初始化仍然很难被删除 - 除非实现尝试根据as-if规则证明行为未发生变化,而不是适用于此处的专用复制省略规则,这有时可能是不可能的,除非进行整个程序分析。
    同样,销毁成本也可能不可忽略,特别是考虑到非平凡子对象的情况下(例如容器)。区别在于,它不仅适用于由复制构造引入的复制初始化,还适用于由移动构造引入的复制初始化。使移动比复制更便宜的构造函数无法改善情况。复制初始化的成本越高,您就必须承担更多销毁的成本。
    一个小缺点是没有办法以不同的方式调整接口,例如,为const&&&限定类型的参数指定不同的noexcept-说明符,作为复数重载。
    另一方面,在这个例子中,统一接口通常会为您提供noexcept(false)复制+ noexcept移动,如果您指定了noexcept,或者总是noexcept(false)当您没有指定任何内容(或显式noexcept(false))。 (请注意,在前一种情况下,noexcept不能防止在复制期间抛出,因为那只会发生在参数的求值期间,而这超出了函数体。)没有进一步的机会分别调整它们。
    这被认为是次要的,因为在现实中并不经常需要。
    即使使用这样的重载,它们可能本质上是令人困惑的:不同的说明符可能隐藏微妙但重要的行为差异,这些差异很难理解。为什么不使用不同的名称而不是重载?
    请注意,自C++17以来,noexcept说明符现在影响函数类型。 (一些意外的兼容性问题可以通过Clang++警告进行诊断。)
    有时无条件的复制实际上是有用的。因为具有强异常保证的操作组合在本质上不具备保证,当需要强异常保证而操作不能作为没有更严格(无异常或强)异常保证的操作序列时,可以使用复制作为事务状态持有者。(这包括复制和交换习语,尽管通常不建议统一赋值,原因见下文。)然而,这并不意味着复制是不可接受的。如果接口的意图总是创建某种类型 T 的对象,并且移动 T 的成本是可以忽略的,则可以将复制移动到目标位置,而不会产生不必要的开销。
    结论:
    对于一些给定的操作,以下是替换它们使用统一接口的建议:
    1. 如果不是所有参数类型都匹配于统一接口,或者除了操作的新副本成本之外还有行为差异,则不能有统一接口。
    2. 如果以下条件不能适用于所有参数,则不能有统一接口。(但仍可拆分为不同命名函数,将一个调用委托给另一个。)
    3. 对于任何类型为T的参数,如果所有操作都需要每个参数的一个副本,则使用统一接口。
    4. 如果T的复制和移动构造成本均可以忽略,则使用统一接口。
    5. 如果接口的意图始终是创建某个类型T的对象,并且T的移动构造成本可以忽略,则使用统一接口。
    6. 否则,请避免使用统一接口。

    以下是一些需要避免使用统一接口的示例:

    1. 对于没有可忽略的复制和移动构造代价的T类型的赋值操作(包括其子对象的赋值,通常采用复制并交换惯用法),不符合统一性的标准,因为赋值的意图不是创建对象而是替换对象内容。复制的对象最终将被销毁,这会产生不必要的开销。这在自我赋值的情况下更加明显。
    2. 容器中值的插入不符合标准,除非复制初始化和销毁都具有可忽略的成本。如果操作失败(由于分配失败、重复值等),则必须销毁参数,这会产生不必要的开销。
    3. 基于参数的条件创建对象将会产生开销,当它实际上没有创建对象时(例如,在出现上述失败时仍然像std::map::insert_or_assign一样插入容器)。

    请注意,“可忽略”成本的准确限制在某种程度上是主观的,因为它最终取决于开发人员和/或用户可以容忍多少成本,并且可能因情况而异。

    实际上,我(保守地)假设任何大小不超过一个机器字的平凡可复制和可销毁类型(如指针)通常都符合可忽略成本的标准——如果在这种情况下实际上产生了太多的代码成本,那么这表明可能使用了错误的构建工具配置,或者工具链尚未准备好生产。

    如果有任何关于性能的疑问,请进行分析。

    额外的案例研究

    还有一些其他的类型,根据惯例,更喜欢按值传递或不传递:

    • 按照惯例需要保留引用值的类型不应该通过值传递。
      • 一个经典的例子是ISO C ++中定义的参数转发调用包装器, 它需要转发引用。请注意,在调用者位置上,它也可能尊重 ref-qualifier 并保留引用。
      • 这个例子的一个实例是std::bind。另请参见LWG 817的解决方案。
    • 一些通用代码可能会直接复制一些参数。甚至可能没有std::move,因为假定复制的成本可以忽略不计,而移动并不一定会使其更好。
      • 这些参数包括迭代器和函数对象(除了上面讨论的参数转发调用包装器的情况)。
      • 请注意,std::function的构造函数模板(但不是赋值运算符模板)也使用传值方式的函数对象参数。
    • 具有与传递值参数类型成本相当的成本的类型也更喜欢通过传递值来传递。 (有时它们被用作专用替代品。)例如,std::initializer_liststd::basic_string_view的实例或多或少是两个指针或一个指针加上一个大小。这使它们足够便宜,可以直接传递而不使用引用。
    • 除非需要副本,否则应该更好地避免通过值传递某些类型。有不同的原因。
      • 默认情况下避免复制,因为复制可能相当昂贵,或者至少不容易保证复制是廉价的,而不需要检查被复制的值的运行时属性。容器是这种类型的典型示例。
        • 在不静态知道容器中有多少元素的情况下,通常不安全(例如在DoS攻击的意义上)进行复制。
        • 嵌套容器(其他容器)将很容易使复制的性能问题变得更糟。
        • 即使是空容器也不能保证便宜复制。(严格来说,这取决于容器的具体实现,例如某些基于节点的容器的“哨兵”元素的存在...但不,让它简单,只是默认情况下避免复制。)
      • 默认情况下避免复制,即使性能完全不感兴趣,因为可能会有一些意外的副作用。
        • 特别是,分配器感知容器和某些其他具有类似于分配器的处理方式的类型(在David Krauss' word中称为“容器语义”)不应该传递值 - 分配器传播只是另一个大的语义蠕虫罐头。
    • 还有一些传统依赖的类型。例如,请参见GotW#91shared_ptr实例。 (但是,并非所有智能指针都是这样的; observer_ptr更像原始指针。)

9

对于一般情况下将要保存的值,仅传递值是一个很好的折中方案-

对于只传递lvalue的情况(一些紧密耦合的代码),这是不合理的,不明智的。

对于怀疑提供两者可以加速的情况,请三思而后行,如果没有帮助,请进行测量。

对于不需要保存值的情况,我更喜欢通过引用传递,因为这可以避免无数不必要的复制操作。

最后,如果编程可以简化为机械应用规则,我们可以将其留给机器人。因此,在规则上花费太多精力并不是一个好主意。最好关注不同情况下的优缺点。成本不仅包括速度,还包括代码大小和清晰度等方面。规则通常无法处理此类利益冲突。


2

按值传递,然后移动,实际上是一个适用于可移动对象的好习惯。

正如您所提到的,如果传递了 rvalue,则它将省略复制或被移动,然后在构造函数内部它将被移动。

您可以显式重载复制构造函数和移动构造函数,但是如果有多个参数,则会变得更加复杂。

考虑以下示例:

class Obj {
  public:

  Obj(std::vector<int> x, std::vector<int> y)
      : X(std::move(x)), Y(std::move(y)) {}

  private:

  /* Our internal data. */
  std::vector<int> X, Y;

};  // Obj

假设你想要提供显式版本,那么你最终会得到4个构造函数,如下所示:
class Obj {
  public:

  Obj(std::vector<int> &&x, std::vector<int> &&y)
      : X(std::move(x)), Y(std::move(y)) {}

  Obj(std::vector<int> &&x, const std::vector<int> &y)
      : X(std::move(x)), Y(y) {}

  Obj(const std::vector<int> &x, std::vector<int> &&y)
      : X(x), Y(std::move(y)) {}

  Obj(const std::vector<int> &x, const std::vector<int> &y)
      : X(x), Y(y) {}

  private:

  /* Our internal data. */
  std::vector<int> X, Y;

};  // Obj

正如您所看到的,随着参数数量的增加,必要构造函数的排列组合也会增加。

如果您没有具体类型但有一个模板化的构造函数,您可以使用完美转发,如下所示:

class Obj {
  public:

  template <typename T, typename U>
  Obj(T &&x, U &&y)
      : X(std::forward<T>(x)), Y(std::forward<U>(y)) {}

  private:

  std::vector<int> X, Y;

};   // Obj

参考文献:

  1. 想要速度吗?传值吧
  2. C++调味品

2
我自问自答,因为我将尝试总结一些答案。每种情况下我们有多少次移动/复制操作?
(A) 值传递和移动赋值构造函数,传递X参数。如果X是一个...
临时变量:1次移动(复制被省略了)
左值:1次复制,1次移动
std :: move(左值):2次移动
(B) 引用传递和常规复制赋值(C++11之前的)构造。如果X是一个...
临时变量:1次复制
左值:1次复制
std :: move(左值):1次复制
我们可以假设这三种参数是同等概率的。因此,每3个调用中,我们有(A)4次移动和1次复制,或(B)3次复制。即平均每个调用(A)1.33次移动和0.33次复制,或(B)1次复制。
如果我们遇到大多由POD组成的类的情况,移动与复制的代价相当。因此,在情况(A)下,每次对setter的调用将有1.66次复制(或移动),而在情况(B)下则为1次复制。
我们可以说,在某些情况下(基于POD的类型),值传递然后移动的构造方法是一个非常糟糕的想法。它比较慢,慢了66%,并且依赖于C++11的特性。
另一方面,如果我们的类包括容器(这些容器使用动态内存),则(A)应该比较快(除非我们大多数情况下传递左值)。
请纠正我如果我错了。

2
您缺少(C)2个重载/完美转发(1个移动、1个拷贝、1个移动)。我也建议分别分析这3种情况(临时对象、左值、std::move(rvalue)),以避免对相对分布做出任何假设。 - Casey
我没有漏掉它。我没有包含它,因为显然它是最优解(就移动/复制而言,但在其他方面不是)。我只是想比较这个习语和通常的 C++11 之前的 setter。 - jbgs

0

声明中的可读性:

void foo1( A a ); // easy to read, but unless you see the implementation 
                  // you don't know for sure if a std::move() is used.

void foo2( const A & a ); // longer declaration, but the interface shows
                          // that no copy is required on calling foo().

性能:
A a;
foo1( a );  // copy + move
foo2( a );  // pass by reference + copy

职责:

A a;
foo1( a );  // caller copies, foo1 moves
foo2( a );  // foo2 copies

对于典型的内联代码,通常在优化时没有区别。 但是foo2()可能只在某些条件下进行复制(例如,如果键不存在,则插入到映射中),而对于foo1(),复制将始终执行。


除非你明确使用 std::move 信号表示要放弃所有权,这也是重点所在。 - Lightness Races in Orbit

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