传值和后移是构建一个不好的习惯用法吗?

时间:2014-01-10 02:56:06

标签: c++ c++11 move-semantics pass-by-value rvalue-reference

由于我们已经在C ++中移动了语义,现在通常会这样做

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

原因是,如果a是左值,则副本将被删除,并且只有一次移动。

但如果a是左值,会发生什么?似乎将有一个复制结构,然后是一个移动赋值(假设A有一个适当的移动赋值运算符)。如果对象具有太多成员变量,则移动分配可能成本很高。

另一方面,如果我们这样做

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

只有一个副本分配。如果我们传递左值,我们可以说这种方式优于传值的习语吗?

7 个答案:

答案 0 :(得分:40)

在现代C ++中,昂贵的移动类型很少见。如果您担心移动的成本,请写两个重载:

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

或完美转发的二传手:

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

将接受左值,右值和其他任何可隐式转换为decltype(_a)而无需额外副本或移动的内容。

尽管从左值设置时需要额外的移动,但成语不是,因为(a)绝大多数类型提供恒定时间移动和(b)复制和交换提供单行代码中的异常安全性和接近最佳性能。

答案 1 :(得分:23)

  

但如果a是左值,会发生什么?似乎会有副本   施工,然后移动任务(假设A有适当的移动   赋值运算符)。如果对象具有移动分配可能是昂贵的   成员变量太多了。

问题很明显。我不会说,传递价值然后移动的结构是一个不好的习惯,但它肯定有其潜在的陷阱。

如果您的类型移动和/或移动的代价很高,它基本上只是一个副本,那么按值传递方法是次优的。此类类型的示例将包括具有固定大小数组作为成员的类型:移动可能相对昂贵并且移动仅仅是副本。另见

在这种情况下。

按值传递方法的优点是您只需要维护一个功能,但是您需要为此付出代价。这取决于您的应用程序,这种维护优势是否超过性能损失。

如果您有多个参数,通过左值和左值参考方法传递可能会导致维护问题。请考虑以下事项:

#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个构造函数可能仍然不是那么糟糕。但是,在这个简单的情况下,我会认真考虑使用单值函数的传值方法

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

而不是上面的4个构造函数。

故事很长,这两种方法都没有缺点。根据实际的分析信息做出决定,而不是过早优化。

答案 2 :(得分:8)

对于存储值的一般情况 ,传递值只是一个很好的折衷方案 -

对于你知道只会传递左值(一些紧密耦合的代码)的情况,这是不合理的,不智能的。

对于通过同时提供两者来怀疑速度提升的情况,首先考虑两次,如果这没有帮助,请测量。

如果不存储该值,我更喜欢通过引用传递,因为这可以防止不必要的复制操作。

最后,如果编程可以简化为不假思索的规则应用,我们可以将其留给机器人。所以恕我直言,如此关注规则并不是一个好主意。更好地关注不同情况下的优势和成本。成本不仅包括速度,还包括例如速度。代码大小和清晰度。规则通常不能处理此类利益冲突。

答案 3 :(得分:3)

当前答案还很不完整。取而代之的是,我将根据我发现的优缺点列表做出结论。

简短回答

简而言之,这可能还可以,但有时很糟糕。

与转发模板或不同的重载相比,该习惯用法即 unifying 界面(在概念设计和实现上)具有更好的清晰度。有时与 copy-and-swap 一起使用(实际上,在这种情况下也与 move-and-swap 一起使用)。

详细分析

优点是:

  • 每个参数列表仅需要一个功能。
    • 它确实只需要一个,而不是多个普通重载(甚至当您具有 n 个参数且每个参数都可以是 2 n 重载时)不合格或const合格)。
    • 就像转发模板中一样,按值传递的参数不仅与const兼容,而且与volatile兼容,从而减少了更多的普通重载。
      • 结合上面的项目符号,您不需要 4 n 重载即可{unqulified,constconst,{ n 个参数的{1}}}组合。
    • 与转发模板相比,只要不需要通用参数(通过模板类型参数进行参数化),它就可以是非模板函数。这样就可以为每个翻译单元中的每个实例实例化离线定义而不是实例化模板定义,这可以显着改善翻译时间性能(通常在编译和链接期间)。
    • 这也使其他重载(如果有)更易于实现。
      • 如果您有一个用于参数对象类型const volatile的转发模板,它可能仍与参数T位于同一位置的重载冲突,因为该参数可以是类型{{ 1}}和用类型const T&(而不是T实例化的模板),因为在没有其他方法可以区分哪个是最佳重载候选者的情况下,重载规则可能会更喜欢此模板。这种不一致可能非常令人惊讶。
      • 尤其要考虑在类T&中具有一个类型为const T&的参数的转发模板构造函数。您会忘记多少时间SFINAE将P&&的实例排除在可能具有简历资格的C之外(例如,将P&&添加到 template-parameter-list em>),以确保它不会与copy / move构造函数冲突(即使后者是由用户明确提供的)?
  • 由于参数是通过非引用类型的值传递的,因此可以强制将参数作为prvalue传递。当参数为class literal type时,这可能会有所不同。考虑一下这样的类,它在某个类中声明了静态C数据成员,但没有类外定义,当它用作左值引用类型的参数的参数时,最终可能无法链接,因为it is odr-used并没有定义。
    • 请注意,自ISO C ++ 17起,静态typename = enable_if_t<!is_same<C, decay_t<P>>数据成员的规则已更改to introduce a definition implicitly,因此在这种情况下,差异并不明显。

缺点是:

  • 在参数对象类型与类相同的情况下,统一接口无法替换copy and move构造函数。否则,参数的复制初始化将是无限递归的,因为它将调用统一的构造函数,然后再调用自身。
  • 如其他答案所述,如果复制成本不可忽略(便宜且可预测),这意味着您将几乎总是在通话中性能下降。必要,因为统一的按值传递参数无条件的复制初始化会引入参数的 copy (复制或移入),除非消失
    • 即使从C ++ 17开始使用mandatory elision,参数对象的复制初始化仍然很难被删除-除非实现非常努力地证明行为根据{{​​3}}进行了更改,而不是此处适用的as-if rules进行了更改,这有时可能不可能而没有整个程序的分析。
    • 同样,销毁的成本也可能无法忽略,尤其是在考虑了非平凡的子对象时(例如在容器的情况下)。不同之处在于,它不仅适用于复制构造引入的复制初始化,而且还适用于移动构造。在构造函数中使移动比复制便宜,无法改善这种情况。复制初始化的成本越高,销毁费用就越大。
  • 一个次要缺点是无法以多种方式对接口进行调整,例如复数重载,例如,为constexprconstexpr限定的参数指定不同的noexcept指定符类型。
    • OTOH,在此示例中,统一界面通常会为您提供const&副本+ &&移动(如果您指定noexcept(false),或者始终为noexcept,但您未指定(或显式noexcept)。 (请注意,在前一种情况下,noexcept(false)不会阻止在复制过程中抛出异常,因为这只会在函数参数之外的参数求值期间发生。)没有进一步的机会来单独调整它们。
    • 这被认为是次要的,因为在现实中并不经常需要它。
    • 即使使用了这样的重载,它们也可能在本质上造成混淆:不同的说明符可能隐藏了难以理解的细微但重要的行为差异。为什么不使用不同的名称而不是重载?
    • 请注意,noexcept(false)的示例自C ++ 17起可能尤其成问题,因为noexcept规范dedicated copy elision rules。 (now affect the function type可以诊断出一些意外的兼容性问题。)

有时候无条件复制实际上是有用的。由于具有强例外保证的业务构成本质上不具有担保,因此,在需要强例外保证且操作不能按严格的操作顺序分解的情况下,可以将副本用作跨国国家持有人(无例外或强烈)例外保证。 (这包括复制和交换的习惯用法,尽管一般出于其他原因,建议不要 将分配统一,请参见下文。)但是,这并不意味着复制是不可接受的。如果界面的意图是总是 创建noexcept类型的某个对象,并且移动noexcept的成本是可忽略的,则可以将副本无目的地移动到目标开销。

结论

因此,对于某些给定的操作,以下是有关是否使用统一界面来替换它们的建议:

  1. 如果并非所有参数类型都与统一接口都匹配,或者如果在统一的操作之间除了新副本的开销以外在行为上存在差异,那么就不会有统一接口。
  2. 如果以下条件不能满足 all 参数的要求,则无法使用统一界面。 (但是仍然可以将其分解为不同的命名函数,将一个调用委派给另一个。)
  3. 对于T类型的任何参数,如果所有操作都需要每个参数的副本,请使用统一。
  4. 如果T的复制结构和移动结构的成本都可忽略,则使用统一。
  5. 如果接口的意图始终是 创建类型T的对象,并且T的移动构造的成本是可忽略的,请使用统一。 / li>
  6. 否则,请避免统一。

这里有一些例子需要避免统一:

  1. T的分配操作(包括分配给其子对象,通常具有复制和交换习惯)在复制和移动构造中没有可忽略的成本,因此不符合统一标准,因为分配的意图是不是创建(而是替换内容)对象。复制的对象最终将被破坏,这将导致不必要的开销。对于自我分配的情况,这一点更加明显。
  2. 将值插入到容器中不符合条件,除非复制初始化和销毁​​的成本可忽略。如果复制初始化后操作失败(由于分配失败,重复值等),则必须销毁参数,这会产生不必要的开销。
  3. 有条件地基于参数创建对象时,实际上并不会创建对象(例如,即使发生上述故障,例如,T类的容器插入操作)也会产生开销。

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

实际上,我(保守地)假设任何琐碎可复制且琐碎的可破坏类型,其大小不超过一个机器字(如指针),通常符合可忽略成本的标准-如果结果代码实际上在这种情况下花费太多在这种情况下,则表明要么使用了错误的构建工具配置,要么工具链尚未准备好投入生产。

如果对性能还有任何疑问,请进行个人资料设置。

其他案例研究

还有其他一些众所周知的类型,通常可以通过值来传递它们:

  • STL样式的通用代码可以直接复制一些参数。甚至可能没有T,因为副本的成本被认为是可忽略的,而且移动并不一定会使它更好。这样的参数包括迭代器和函数对象(Clang++ warning除外)。
  • 假定具有与可忽略成本的按值传递参数类型可比的成本的类型也优选为按值传递。 (有时将它们用作专用的替代方法。)例如,std::map::insert_or_assignstd::move的实例或多或少是两个指针或一个指针加上一个大小。这个事实使它们便宜到足以直接传递而无需引用。

同样,除非需要复制,否则某些类型应避免通过值传递。容器就是这种典型的例子。   *特别是,(argument) forwarding caller wrappers和其他一些类似于分配器的类型(allocator-awared containers中的“容器语义”),即使性能完全不感兴趣,也不应按值传递-分配器传播为只是另一个大的语义蠕虫可以。

其他一些常规类型取决于。例如,请参见David Krauss' word中的std::initializer_list实例。 (但是,并非所有智能指针都像这样; std::basic_string_view更像是原始指针。)

答案 4 :(得分:1)

按值传递,然后移动实际上是您知道可以移动的对象的好习惯。

正如你所提到的,如果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. Want Speed? Pass by Value
  2. C++ Seasoning

答案 5 :(得分:1)

我正在回答自己,因为我会尝试总结一些答案。我们在每种情况下都有多少动作/副本?

(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),每次调用时,我们将有1.66个副本(或移动),对于案例(B),我将有1个副本。

我们可以说在某些情况下(基于POD的类型),传值和后移构造是一个非常糟糕的主意。它慢了66%,它取决于C ++ 11的功能。

另一方面,如果我们的类包含容器(使用动态内存),(A)应该快得多(除非我们主要传递左值)。

如果我错了,请纠正我。

答案 6 :(得分:0)

声明中的可读性:

kubectl apply -f Deployment.yaml  -o yaml

性能:

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

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