传递值然后移动构成一个坏习惯吗?


70

由于我们在C ++中具有移动语义,因此如今很常见

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

理由是,如果a是右值,则副本将被删除,并且只有一招。

但是,如果a是左值会怎样?似乎将有一个副本构造,然后是一个移动分配(假设A具有正确的移动分配运算符)。如果对象具有太多的成员变量,则移动分配的成本可能很高。

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

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

将只有一份副本分配。如果我们要传递左值,是否可以说这种方法比按值传递惯用法更可取?


1
呼叫std::movea会const&传回const&&无法移走的a。
Casey 2014年

您说得对,我编辑了它。
jbgs 2014年


对于这种情况,C ++核心准则具有规则F.15(高级)isocpp.github.io/CppCoreGuidelines/…–
KindDragon

与此相关的是Nicolai Josuttis的演讲,其中讨论了一些选择:youtube.com/watch?
v

Answers:


48

昂贵的移动类型在现代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)复制和交换提供了异常安全性,并且在单个操作中性能接近最佳代码行。


14
是的,但我不认为昂贵的移动类型如此罕见。实际上,仅包含POD的类的迁移成本和复制成本一样高。传递左值时,按值传递然后再移动的成本将高达两个副本。这就是为什么对我来说这是一个坏习惯。
jbgs 2014年

1
正是由于这个原因,具有现代C ++ 11风格的@jbgs程序员避免创建主要由POD组成的类。实际上,恒定时间可移动类型的盛行实际上至少在接口中阻止了非恒定时间可移动类型的创建。
Casey 2014年

3
我同意,在正常情况下,费用不应该太高。好吧,至少根据特定的C ++ 11样式,它并不太昂贵。但是对于这种“便宜的举动”,我仍然感到不安(我并不是说它们也不便宜)。
jbgs 2014年

2
@jbgs完美的转发也需要实现。
Yakk-Adam Nevraumont 2014年

3
您可能需要注意的是,如果T可以由构造std::initializer_list,则不允许您在通话中使用列表。 set_a({1,2,3})将海成为set_a(A{1,2,3})支撑,初始化列表的没有一个类型。
NathanOliver '19

25

但是,如果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个构造函数。

长话短说,这两种方法都没有缺点。根据实际的分析信息来做出决策,而不是过早地进行优化。


这就是问题。假设固定大小的数组是“稀有的”是否公平?我认为我们发现太多情况下按值传递和移动不理想。当然,我们可以编写重载来改善它……但这意味着要摆脱这种习惯用法。这就是为什么它“不好”的原因:)
jbgs

2
@jbgs我不会说固定大小的数组很少见,特别是由于小的字符串优化。固定大小的数组可能非常有用:根据我的经验,保存动态内存分配在Windows上非常慢。如果您正在做低维的线性代数或某些3D动画,或者使用一些专用的小字符串,则应用程序将充满固定大小的数组。
阿里

1
我完全同意。这就是我的意思。POD(尤其是阵列)并不罕见。
jbgs 2014年

这里的尺寸在哪里?
Potatoswatter 2014年

1
@Matthias这取决于(1)您的POD或固定大小的数组,以及(2)您的目标。在不了解您的情况的情况下,我不能给您一个简单的规则。对于我来说,只要可以就可以通过const ref,然后进行概要分析。到目前为止,我对这种方法还没有一个单一的问题。
阿里

9

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

对于仅传递左值的情况(某些紧密耦合的代码),这是不合理,不明智的。

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

在不存储值的位置,我更喜欢按引用传递,因为这样可以防止大量不必要的复制操作。

最后,如果可以将编程简化为没有规则的规则应用,我们可以将其留给机器人。因此,恕我直言,集中精力于规则不是一个好主意。更好地关注针对不同情况的优势和成本。成本不仅包括速度,还包括代码大小和清晰度。规则通常无法处理此类利益冲突。


7

当前的答案还很不完整。相反,我将尝试根据我发现的优缺点列表做出结论。

简短答案

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

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

详细分析

优点是:

  • 每个参数列表仅需要一个功能。
    • 实际上,它仅需要一个,而不是多个普通重载(甚至当您有n个参数且每个参数可以不合格或不合格时,甚至不需要2 n个重载)。const
    • 就像在转发模板中一样,按值传递的参数不仅与兼容const,而且与兼容volatile,从而减少了更多的常规重载。
      • 与上面的子弹相结合,你不需要4个ň重载以服务{unqulified, ,,const }组合为ñ参数。constconst volatile
    • 与转发模板相比,只要不需要通用参数(通过模板类型参数进行参数化),它就可以是非模板函数。这允许使用离线定义,而不是需要为每个翻译单元中的每个实例实例化模板定义,这可以显着改善翻译时间性能(通常在编译和链接期间)。
    • 它还使其他重载(如果有)更易于实现。
      • 如果您有一个用于参数对象类型的转发模板T,则它可能仍会与参数const T&位于相同位置的重载发生冲突,因为参数可以是type的左值,T而用type实例化的模板T&(而不是const T&)则可能更多如果没有其他方法可以区分哪个是最佳的重载候选,则应由重载规则首选。这种不一致可能非常令人惊讶。
        • 特别要考虑的是,您P&&在类中具有一个带有一个类型参数的转发模板构造函数C。您会忘记多少时间P&&CSFINAE可能不具备cv资格的实例中排除实例(例如,通过添加typename = enable_if_t<!is_same<C, decay_t<P>>template-parameter-list),以确保它不会与copy / move构造函数冲突(即使后者显式)用户提供)?
  • 由于参数是通过非引用类型的值传递的,因此可以强制将参数作为prvalue传递。当参数为类文字类型时,这可能会有所不同。请考虑存在这样的类,该类具有constexpr在某个类中声明的静态数据成员而没有类外定义,当将其用作左值引用类型的参数的参数时,由于它是odr,因此最终可能无法链接-使用,没有定义。
    • 请注意,自从ISO C ++ 17开始,静态constexpr数据成员的规则已更改为隐式引入定义,因此在这种情况下,差异并不明显。

缺点是:

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

有时无条件复制实际上是有用的。由于具有强例外保证的操作的构成本质上不具有保证,因此,在需要强例外保证且操作不能按严格的操作顺序分解时,可以将副本用作事务状态持有者(无例外或强烈)例外保证。(这包括复制和交换的习惯用法,尽管通常建议出于其他原因将分配统一,请参见下文。)但是,这并不意味着复制是不可接受的。如果接口的意图总是创建某种类型的对象T,并且移动的成本T是可忽略的,则可以将副本移动到目标而不会产生不必要的开销。

结论

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

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

以下是一些需要避免统一的示例:

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

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

实际上,我(保守地)假定其大小不超过一个机器字(如指针)的琐碎可复制且琐碎的可破坏类型通常符合可忽略成本的标准-如果在这种情况下所产生的代码实际成本过高,建议使用错误的构建工具配置,或者工具链尚未准备好进行生产。

如果对性能还有任何疑问,请进行分析。

其他案例研究

根据约定,还有一些其他众所周知的类型优选按值传递或不按值传递:

  • 需要按惯例保留引用值的类型不应按值传递。
    • 一个典型的例子是ISO C ++中定义参数转发调用包装器,它需要转发引用。请注意,在调用方位置,它可能还会保留有关ref限定符参考
    • 此示例的一个实例是std::bind。另请参阅LWG 817的分辨率。
  • 一些通用代码可以直接复制一些参数。甚至可能没有这种情况std::move,因为假定副本的成本是可忽略的,并且此举不一定会使它更好。
    • 这样的参数包括迭代器和函数对象(上面讨论的参数转发调用方包装器的情况除外)。
    • 请注意,的构造函数模板std::function(但不是赋值运算符模板)也使用传递值函子参数。
  • 假定其成本可与成本可忽略的传递值参数类型相比较的类型也优选为传递值。(有时将它们用作专用的替代方法。)例如,std::initializer_list和的实例std::basic_string_view或多或少是两个指针或一个指针加一个大小。这个事实使它们便宜到足以直接使用而不使用引用。
  • 最好避免某些类型通过值传递,除非您确实需要副本。有不同的原因。
    • 默认情况下,避免使用复制,因为复制可能非常昂贵,或者至少在不检查复制值的运行时属性的情况下,要保证复制便宜并不容易。容器就是这种典型的例子。
      • 如果不静态地知道一个容器中有多少个元素,通常就不安全(例如从DoS攻击的意义上来说)进行复制。
      • (其他容器的)嵌套容器很容易使复制的性能问题变得更糟。
      • 即使是空容器也不能保证便宜地被复制。(严格来说,这取决于容器的具体实现,例如,某些基于节点的容器是否存在“ sentinel”元素……但不,要保持简单,只是避免默认情况下进行复制。)
    • 即使对性能完全不感兴趣,也要避免默认情况下进行复制,因为这可能会产生一些意外的副作用。
      • 特别是,不应将通过分配器唤醒的容器和其他与分配器具有类似处理方式的类型(“容器语义”,用David Krauss的话说)通过值传递-分配器传播只是另一种大型语义蠕虫可以。
  • 传统上还有其他几种类型。例如,参见GotW#91shared_ptr实例。(但是,并非所有智能指针都是这样;observer_ptr更像是原始指针。)

2

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

如您所提到的,如果传递了一个右值,它将取消副本或将其移动,然后在构造函数中将其移动。

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

考虑这个例子,

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(lvalue):2次移动

(B)通过引用传递和复制赋值常规(C ++ 11之前的版本)构造。如果X是...

临时:1份

左值:1个副本

std :: move(lvalue):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)应该快得多(除非我们大多传递左值)。

如果我错了,请纠正我。


2
您缺少(C)2个超载/完美转发(1个动作,1个副本,1个动作)。我还将分别分析这3种情况(临时,左值,std :: move(rvalue)),以避免对相对分布做出任何假设。
Casey

我没有错过。我没有包括它,因为它显然是最佳的解决方案(就移动/副本而言,但不是其他方面)。我只是想比较一下这个习惯用法和通常的C ++ 11之前的设置器。
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()可能仅在某些条件下执行复制(例如,如果键不存在,则插入map中),而对于foo1(),复制将始终完成。


除非您明确表示要使用产生所有权std::move,否则就是这样。
Lightness Races in Orbit,
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.