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

jbg*_*bgs 63 c++ pass-by-value rvalue-reference move-semantics c++11

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

void set_a(A a) { _a = std::move(a); }
Run Code Online (Sandbox Code Playgroud)

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

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

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

void set_a(const A& a) { _a = a; }
Run Code Online (Sandbox Code Playgroud)

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

Cas*_*sey 43

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

void set_a(const A& a) { _a = a; }
void set_a(A&& a) { _a = std::move(a); }
Run Code Online (Sandbox Code Playgroud)

或完美转发的二传手:

template <typename T>
void set_a(T&& a) { _a = std::forward<T>(a); }
Run Code Online (Sandbox Code Playgroud)

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

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

  • 是的,但我不认为昂贵的移动类型是如此罕见.实际上,仅由POD组成的类与昂贵的复制一样昂贵.通过左值时,按值传递然后移动将与两个副本一样昂贵.这就是为什么它对我来说似乎是一个不好的习惯. (13认同)
  • 您可能要注意,如果`T`是可由`std :: initializer_list`构造的东西,则不允许您在调用中使用列表。set * a({1,2,3})可能会变成set_a(A {1,2,3})`,因为* braised-init-list *没有类型。 (3认同)
  • 我同意在正常情况下它不应该太昂贵.好吧,根据特定的C++ 11风格,至少它不会太昂贵.但我仍然对这种"动作便宜"感到不安(我不是说他们不是反正). (2认同)
  • @jbgs 完美转发也需要实现公开。 (2认同)

Ali*_*Ali 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)) { }  
};
Run Code Online (Sandbox Code Playgroud)

如果您有多个参数,则会出现排列问题.在这个非常简单的例子中,维护这4个构造函数可能仍然不是那么糟糕.但是,在这个简单的情况下,我会认真考虑使用单值函数的pass-by-value方法

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

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

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

  • @jbgs 我不会说固定大小的数组很少见,尤其是因为 [小字符串优化](http://john-ahlgren.blogspot.co.at/2012/03/small-string-optimization-and-move .html)。固定大小的数组非常有用:您可以节省动态内存分配,根据我的经验,它在 Windows 上非常慢。如果你在做低维的线性代数或一些 3D 动画,或者你使用一些特殊的小字符串,你的应用程序将充满固定大小的数组。 (3认同)
  • 我完全同意。这正是我的意思。POD(尤其是数组)并不罕见。 (2认同)

Che*_*Alf 8

对于存储价值的一般情况,按值传递只是一个很好的妥协 -

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

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

在不存储值的情况下,我更喜欢通过引用传递,因为这可以防止不必要的复制操作.

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


Fra*_*kHB 5

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

简短答案

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

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

详细分析

优点是:

  • 每个参数列表仅需要一个功能。
    • 实际上,它仅需要一个,而不是多个普通的重载(或者当您有n个参数且每个参数都可以不合格或不合格时,甚至不需要2 n个重载)。const
    • 就像在转发模板中一样,按值传递的参数不仅与兼容const,而且与兼容volatile,从而减少了更多的常规重载。
      • 与上面的子弹相结合,你不需要4个ň重载以服务{unqulified, ,,const }组合为ñ参数。constconst volatile
    • 与转发模板相比,只要不需要通用参数(通过模板类型参数进行参数化),它就可以是非模板函数。这允许使用离线定义,而不是需要为每个翻译单元中的每个实例实例化模板定义,这可以显着改善翻译时间性能(通常在编译和链接期间)。
    • 它还使其他重载(如果有)更易于实现。
      • 如果您有一个用于参数对象类型的转发模板T,则它可能仍会与参数const T&位于相同位置的重载发生冲突,因为参数可以是type的左值,T而用type实例化的模板T&(而不是const T&)则可能更多。如果没有其他方法可以区分哪个是最佳的重载候选,则按重载规则首选。这种不一致可能非常令人惊讶。
      • 特别要考虑的是,您有一个转发模板构造函数P&&,其类中有一个type类型的参数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 and 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. 对于任何类型的参数T,如果所有操作都需要每个参数的副本,请使用unification。
  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_list和的实例std::basic_string_view或多或少是两个指针或一个指针加一个大小。这个事实使它们便宜到足以直接使用而不使用引用。
  • 某些类型最好避免按值传递,除非您确实需要副本。有不同的原因。
    • 默认情况下,请避免使用复制,因为复制可能会非常昂贵,或者至少在不检查复制值的运行时属性的情况下,要保证复制便宜并不容易。容器就是这种典型的例子。
      • 如果不静态地知道一个容器中有多少个元素,通常就不安全(例如,在DoS攻击的意义上)进行复制。
      • (其他容器的)嵌套容器很容易使复制的性能问题变得更糟。
      • 即使是空的容器也不能保证便宜地被复制。(严格来说,这取决于容器的具体实现,例如,某些基于节点的容器是否存在“ sentinel”元素……但不,要保持简单,只需避免默认情况下进行复制即可。)
    • 即使对性能完全不感兴趣,也要避免默认情况下进行复制,因为这可能会产生一些意外的副作用。
  • 传统上还有其他几种类型。例如,参见GotW#91shared_ptr实例。(但是,并非所有智能指针都像那样;observer_ptr更像是原始指针。)