按值传递然后移动构造是一个不好的习语吗?

因为我们在 C + + 中有 move 语义,所以现在通常要做

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

其原因是,如果 a是一个右值,那么副本将被省略,并且只有一次移动。

但是如果 a是左值会发生什么呢?似乎会有一个拷贝构造,然后是一个移动赋值(假设 A 有一个合适的移动赋值操作符)。如果对象具有太多的成员变量,则移动分配的开销可能很大。

另一方面,如果我们这么做了

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

只有一份作业。如果要传递 lvalue,我们是否可以说这种方式优于按值传递习惯用法?

20154 次浏览

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

通过值传递,则 move 实际上是一个很好的习惯用法,用于您知道是可移动的对象。

正如您提到的,如果传递了一个右值,它要么删除副本,要么被移动,然后在构造函数中移动它。

你可以重载复制建构子并显式地移动构造函数,但是如果你有多个参数,就会变得更复杂。

考虑一下这个例子,

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 + + 调味料

对于一般情况下的 该值将存储在哪里,仅按值传递是一个很好的折衷方案-

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

如果你怀疑两者都能提高速度,那么先想两次,如果没有帮助,那么就测量一下。

在不存储值的地方,我更喜欢通过引用传递,因为这样可以防止无数不必要的复制操作。

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

但是如果 a是左值会发生什么? 似乎会有一个副本 构造,然后移动分配(假设 A 有一个适当的移动 如果对象具有 太多的成员变量。

问题很明显。我不会说 pass-by-value-and-then-move 结构是一个糟糕的习惯用法,但它肯定有其潜在的缺陷。

如果类型的移动代价很高,并且/或移动它实际上只是一个副本,那么按值传递方法就不是最佳方法。这类类型的示例包括具有固定大小数组作为成员的类型: 移动可能相对昂贵,而移动只是一个副本。参见

在这种情况下。

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

如果您有多个参数,那么通过左值和右值引用的方法可能会很快导致维护方面的麻烦。想想这个:

#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个构造函数。

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

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

(A)传递值和移动赋值构造,传递 X 参数。如果 X 是..。

临时: 1移动(副本省略)

左值: 1复制1移动

Move (lvalue) : 2 move

(B)通过引用和复制作业传递通常(前 C + + 11)构造。如果 X 是一个..。

临时: 1份

Lvalue: 1拷贝

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 < sup > n 重载,如果您有 N参数,而每个参数都可以是不合格的或者 const合格的)。
    • 像在转发模板中一样,通过值传递的参数不仅与 const兼容,而且与 volatile兼容,这减少了更普通的重载。
      • 与上面的项目符号相结合,您不需要 4 < sup > n 重载来为 N参数提供{非限定的,constconstconst volatile}组合。
    • 与转发模板相比,它可以是非模板化函数,只要参数不需要是泛型的(通过模板类型参数参数化)。这允许为每个翻译单元中的每个实例实例化离线定义而不是模板定义,这可以显著提高翻译时间性能(通常在编译和链接期间)。
    • 它还使其他重载(如果有的话)更容易实现。
      • 如果你有一个参数对象类型 T的转发模板,它仍然可能与在同一位置有一个参数 const T&的重载冲突,因为参数可以是 T类型的左值,并且用 T&类型(而不是 const T&)实例化的模板,因为当没有其他方法来区分哪个是最佳重载候选对象时,重载规则可能更喜欢它。这种不一致性可能相当令人惊讶。
        • 特别是,假设您在类 C中有一个带有一个 P&&类型参数的转发模板构造函数。有多少次你会忘记将 P&&的实例从 SFINAE 可能的 cv 限定的 C中排除(例如通过将 typename = enable_if_t<!is_same<C, decay_t<P>>添加到 模板-参数-列表中) ,以确保它不会与拷贝/移动构造函数冲突(即使后者是明确的用户提供的) ?
  • 因为参数是通过非引用类型的值传递的,所以它可以强制将参数作为 < em > prvalue 传递。当参数为 类文字类型时,这可能会有所不同。假设有这样一个类,在没有类外定义的类中声明了一个静态 constexpr数据成员,当它被用作左值引用类型的参数的参数时,它可能最终无法链接,因为 是用过的并没有它的定义。
    • 注意,自从 ISO C + + 17以来,静态 constexpr数据成员的规则已经更改了 隐含地引入一个定义,因此在这种情况下,差异并不显著。

缺点是:

  • 统一接口不能替换参数对象类型与类相同的复制和移动构造函数。否则,参数的复制初始化将是无限递归,因为它将调用统一构造函数,然后构造函数调用自身。
  • 正如其他答案所提到的,如果复制的成本不可忽略(廉价且可预测) ,这意味着在不需要复制的情况下,几乎总是会导致调用中的性能退化,因为复制初始化通过值传递的统一参数 无条件的会引入参数的 收到(复制到或移动到) ,除非 省略
    • 即使从 C + + 17开始使用 强制性省略,参数对象的拷贝初始化仍然很难被移除——除非实现尝试 非常努力来证明行为没有根据 好像规则一样改变,而不是适用于这里的 专用文本省略规则,有时可能是没有完整的程序分析的 不可能
    • 同样,销毁的成本也不可忽视,特别是当考虑到非平凡的子对象时(例如在容器的情况下)。不同之处在于,它不仅适用于由 copy 构造引入的复制初始化,而且也适用于 move 构造。在构造函数中使移动比复制更便宜并不能改善这种情况。复制初始化的成本越高,您必须支付的销毁成本就越高。
  • 一个小的缺点是,没有办法以不同的方式作为复数重载来调整接口,例如,为 const&&&限定类型的参数指定不同的 noexcept说明符。
    • OTOH,在这个例子中,如果指定了 noexcept,统一接口通常会提供 noexcept(false)拷贝 + noexcept move,如果没有指定任何内容(或显式 noexcept(false)) ,则始终提供 noexcept(false)。(注意,在前一种情况下,noexcept并不阻止在复制过程中抛出,因为这只会在计算函数体之外的参数时发生。)没有进一步的机会单独调整它们。
    • 这被认为是次要的,因为在现实中并不经常需要它。
    • 即使使用了这样的重载,它们也可能因为性质而混淆: 不同的说明符可能隐藏了难以推断的细微但重要的行为差异。为什么不用不同的名字代替重载呢?
    • 注意,noexcept的例子可能特别有问题,因为 C + + 17是 noexcept规范的 现在影响函数类型。(一些意想不到的兼容性问题可以通过 叮当声警告进行诊断。)

有时候无条件的拷贝实际上是有用的。由于具有强异常保证的操作组合本质上不具有保证,因此当需要具有强异常保证的操作时,可以将副本用作事务状态持有者,并且不能将操作拆分为具有同样严格(无异常或强异常)保证的操作序列。(这包括复制和交换的习惯用法,尽管由于其他原因,没有建议统一作业,见下文。)但是,这并不意味着副本在其他方面是不可接受的。如果接口的意图是 一直都是创建某个类型为 T的对象,并且移动 T的成本是可忽略的,那么可以将副本移动到目标,而不会产生不必要的开销。

结论

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

  1. 如果不是所有的参数类型都匹配统一接口,或者除了统一操作之间的新副本的成本之外,还存在行为差异,则不可能存在统一接口。
  2. 如果下列条件不适合 所有参数,则不能有统一接口。(但是它仍然可以分解为不同的命名函数,将一个调用委托给另一个。)
  3. 对于 T类型的任何参数,如果所有操作都需要每个参数的副本,则使用统一。
  4. 如果复制和移动 T的建设都有可忽略的成本,使用统一。
  5. 如果接口的意图是 一直都是创建某个类型为 T的对象,并且 T的移动构造的成本是可以忽略的,则使用统一。
  6. 否则,避免统一。

下面是一些需要避免统一的例子:

  1. T的赋值操作(包括对其子对象的赋值,通常使用复制和交换的习惯用法)没有可忽略的复制和移动构造成本,不符合统一的标准,因为赋值的意图不是对 创造(而是对 更换的内容)的对象。复制的对象最终将被销毁,这将产生不必要的开销。这在自我分配的情况下更为明显。
  2. 向容器中插入值不符合标准,除非复制初始化和销毁都具有可忽略的成本。如果在拷贝初始化之后操作失败(由于分配失败、重复值等原因) ,则必须销毁参数,这会带来不必要的开销。
  3. 基于参数有条件地创建对象会在实际上没有创建对象时产生开销(例如,尽管上面提到过失败,但仍会有类似 std::map::insert_or_assign的容器插入)。

请注意,“可忽略”成本的精确限制有些主观,因为它最终取决于开发人员和/或用户能够容忍多少成本,而且它可能因情况而异。

实际上,我(保守地)假设任何微不足道的可复制和可破坏的类型,其大小不超过一个机器词(如一个指针) ,通常符合可忽略成本的标准-如果在这种情况下产生的代码实际上成本太高,这表明要么是错误的构建工具配置使用,或工具链尚未准备好生产。

如果对性能有任何进一步的怀疑,请进行侧写。

附加案例研究

还有其他一些众所周知的类型,它们更倾向于通过值或不通过值传递,这取决于惯例:

  • 需要通过约定保留引用值的类型不应该通过值传递。
  • 某些泛型代码可能直接复制某些参数。它甚至可能没有 std::move,因为 收到的成本被认为是可忽略的,一个移动并不一定使它更好。
    • 这些参数包括迭代器和函数对象(上面讨论的 转发调用方包装器的参数除外)。
    • 注意,std::function(但是 不是赋值运算符模板)的构造函数模板也使用 pass-by-value 函数参数。
  • 可能具有与按值传递参数类型相当的成本且具有可忽略成本的类型也优先选择按值传递。(有时它们被用作专门的替代品。)例如,std::initializer_liststd::basic_string_view的实例或多或少是两个指针或一个指针加上一个大小。这个事实使得它们足够便宜,可以不使用引用直接传递。
  • 有些类型应该是更好的 避免值传递,除非你确实需要一个副本。有不同的原因。
    • 默认情况下避免复制,因为复制可能非常昂贵,或者至少在不检查所复制的值的运行时属性的情况下,不容易保证复制的成本低廉。容器是这种类型的典型例子。
      • 如果不静态地知道一个容器中有多少个元素,那么通常不会复制 安全(例如 拒绝服务攻击)。
      • (其他容器的)嵌套容器很容易使复制的性能问题恶化。
      • 即使是空的容器也不能保证廉价复制。(严格来说,这取决于容器的具体实现,例如某些基于节点的容器的“哨兵”元素的存在... ... 但是,不,保持简单,只是在默认情况下避免复制。)
    • 默认情况下避免复制,即使性能完全不感兴趣,因为可能会有一些意想不到的副作用。
  • 其他一些类型通常依赖于。例如,有关 shared_ptr实例,请参见 GotW # 91。(然而,并非所有智能指针都是这样的; observer_ptr更像是原始指针。)

声明中的可读性:

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() ,复制将始终进行。