按值传递然后移动构造是一个糟糕的成语吗?

Is the pass-by-value-and-then-move construct a bad idiom?

本文关键字:一个 成语 移动 然后 按值传递      更新时间:2023-10-16

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

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

理由是,如果a是右值,则副本将被省略,并且只有一个移动。

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

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

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

将只有一个副本分配。如果我们要传递左值,我们能说这种方式比按值传递成语更可取吗?

移动成本高昂的类型在现代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) 复制和交换在一行代码中提供异常安全性和接近最佳性能。

但是

如果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 n 个重载,当你有n参数时,每个参数都可以不合格或const合格)。
    • 就像在转发模板中一样,按值传递的参数不仅与const兼容,而且与volatile兼容,从而减少了更普通的重载。
      • 结合上面的项目符号,你不需要4n个重载来用于n个参数的 {unqulified、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>>添加到模板参数列表中),以确保它不会与复制/移动构造函数冲突(即使后者是明确用户提供的)?
  • 由于参数是通过非引用类型的值传递的,因此它可以强制将参数作为prvalue传递。当参数是类文本类型时,这可能会有所不同。假设有这样一个类,在某个类中声明了一个静态constexpr数据成员,没有类外定义,当它用作左值引用类型的参数的参数时,它最终可能无法链接,因为它是odr使用的并且没有定义它。
    • 请注意,自 ISO C++ 17 以来,静态constexpr数据成员的规则已更改为隐式引入定义,因此在这种情况下差异并不显著。

缺点是:

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

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

结论

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

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

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

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

请注意,"可忽略"成本的准确限制有些主观,因为它最终取决于开发人员和/或用户可以容忍的成本,并且可能会因情况而异。

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

如果对性能有任何进一步的疑问,请进行剖析。

其他案例研究

还有其他一些众所周知的类型倾向于按值传递或不按值传递,具体取决于约定:

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

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

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

对于怀疑通过提供两者来提高速度的情况,首先三思而后行,如果这没有帮助,请测量。

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

最后,如果编程可以简化为不假思索的规则应用,我们可以把它留给机器人。所以恕我直言,过分关注规则不是一个好主意。对于不同情况,最好专注于优势和成本是什么。成本不仅包括速度,还包括代码大小和清晰度等。规则通常无法处理此类利益冲突。

按值传递,然后移动实际上是一个很好的成语,用于你知道是可移动的对象。

正如你提到的,如果传递了一个右值,它要么会忽略副本,要么被移动,然后在构造函数中它将被移动。

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

考虑这个例子,

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) 按值传递并移动赋值构造,传递 X 参数。如果 X 是...

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

左值:1 副本 1 移动

标准::移动(左值):2 移动

(B)通过引用传递和复制分配通常(C++11)之前)构造。如果 X 是...

临时:1份

左值:1份

标准::移动(左值): 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)应该快得多(除非我们主要传递左值)。

如果我错了,请纠正我。

声明中的可读性:

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() 来说,复制将始终完成。