通过调用移动赋值运算符来实现移动构造函数

30

MSDN文章《如何编写移动构造函数》提供了以下建议。

如果为类提供了移动构造函数和移动分配运算符,则可以通过编写移动构造函数来调用移动分配运算符以消除冗余代码。以下示例展示了一个修订后的移动构造函数版本,该版本调用移动分配运算符:

// Move constructor.
MemoryBlock(MemoryBlock&& other)
   : _data(NULL)
   , _length(0)
{
   *this = std::move(other);
}

这段代码会不会浪费效率,因为它对MemoryBlock的值进行了两次初始化?还是编译器可以优化掉额外的初始化?我的移动构造函数是否应该总是调用移动赋值运算符来编写?


只是一点提示。如果你的一些数据成员无法默认构造,那么这种方法就行不通。 - Dmytro Ovdiienko
6个回答

16

[...]编译器能够消除额外的初始化吗?

几乎在所有情况下都可以。

我应该总是通过调用移动赋值运算符来编写移动构造函数吗?

是的,除非您测量了它会导致次优性能的情况。


今天的优化器在优化代码方面做得非常出色。您的示例代码尤其易于优化。首先:移动构造函数在几乎所有情况下都将被内联。如果您通过移动赋值运算符实现它,那么该函数也将被内联。

让我们看看一些汇编代码!这里展示了具有手动和通过移动赋值操作符两种版本的移动构造函数的Microsoft网站上的精确代码。这是使用-O的GCC的汇编输出(-O1具有相同的输出;clang的输出导致相同的结论):

; ===== manual version =====           |   ; ===== via move-assig =====
MemoryBlock(MemoryBlock&&):            |   MemoryBlock(MemoryBlock&&):
    mov     QWORD PTR [rdi], 0         |       mov     QWORD PTR [rdi], 0
    mov     QWORD PTR [rdi+8], 0       |       mov     QWORD PTR [rdi+8], 0
                                       |       cmp     rdi, rsi
                                       |       je      .L1
    mov     rax, QWORD PTR [rsi+8]     |       mov     rax, QWORD PTR [rsi+8]
    mov     QWORD PTR [rdi+8], rax     |       mov     QWORD PTR [rdi+8], rax
    mov     rax, QWORD PTR [rsi]       |       mov     rax, QWORD PTR [rsi]
    mov     QWORD PTR [rdi], rax       |       mov     QWORD PTR [rdi], rax
    mov     QWORD PTR [rsi+8], 0       |       mov     QWORD PTR [rsi+8], 0
    mov     QWORD PTR [rsi], 0         |       mov     QWORD PTR [rsi], 0
                                       |   .L1:
    ret                                |       rep ret

除了为右版本添加的分支外,代码完全相同。意思是:删除了重复的赋值

为什么需要额外的分支?如微软页面所定义的移动赋值运算符比移动构造函数做更多的工作:它可以防止自我赋值。移动构造函数没有这个保护。但是:正如我已经说过的,构造函数在几乎所有情况下都会被内联。在这些情况下,优化器可以看到它不是自我赋值,因此这个分支也将被优化掉。


这一点被反复强调,但很重要:不要进行过早的微观优化!

别误会我,我也讨厌由于懒惰或草率的开发人员或管理决策而浪费大量资源的软件。节约能源不仅关乎电池,也是一个环境问题,我非常热衷于此。但是,过早地进行微观优化并不有助于这方面!当然,将大型数据的算法复杂度和缓存友好性记在脑后。但在进行任何特定的优化之前,请先进行度量!

在这种情况下,我甚至猜想您永远不必手动优化,因为编译器总是能够在移动构造函数周围生成最佳代码。现在进行无用的微观优化将会在以后需要更改两个位置的代码或需要调试仅因更改一个位置的代码而发生的奇怪错误时浪费您的开发时间。这是可以用于做有用的优化的开发时间浪费。


对于一个可移动构造但不可移动赋值的类,这是不可行的。当移动构造函数和移动赋值运算符具有不同的异常规范时,会出现问题。还要注意,对于具有不同分配器传播策略的容器,它将失败得非常可怕。但根本问题是逻辑问题。也就是说,通常情况下,移动赋值应该依赖于移动构造函数,而不是反过来。这里需要冗余成员初始化的必要性也表明一些非惯用语法出了问题。 - FrankHB

14

我不会这样做。移动成员存在的原因是为了性能。对于你的移动构造函数来说,这样做就像买了一辆超跑花费巨资,然后试图通过购买普通汽油来节省钱。

如果您想减少编写的代码量,只需不编写移动成员即可。在移动上下文中,您的类将仍然可以正常复制。

如果你想让你的代码具有高性能,那么请尽可能使你的移动构造函数和移动赋值函数快速执行。良好的移动成员将非常快速,您可以通过计算加载、存储和分支次数来评估它们的速度。如果您可以用4个load/store代替8个来编写某些内容,请这样做!如果您可以编写无分支的东西而不是1个,请这样做!

当您(或您的客户)将您的类放入std::vector时,可能会生成很多移动操作。即使您的移动速度非常快,也要考虑使用仅需4个或6个load/store就能使其速度提高两倍甚至提高50%。

个人认为,我已经厌倦了看到等待光标,愿意再多花5分钟编写代码并知道它的速度尽可能快。

如果您仍然不确定是否值得这样做,请同时编写两种方式,然后在完全优化后检查生成的汇编代码。谁知道?你的编译器可能已经足够聪明了,可以为您优化额外的加载和存储。但到那个时候,您已经投入了比一开始编写优化的移动构造函数更多的时间。


26
这里有一些好建议,但我认为这个答案忽略了一个事实——重复代码的成本不仅仅是再次编写它所花费的5分钟时间,而是在可读性、可维护性和健壮性方面更具有重大的代价。特别是当存在重复代码时,它会引发一种非常常见的错误:也就是在代码的一个实例中修复或更改了某些内容,而在另一个实例中却没有。在我的生活中,我被这种错误咬了很多次,除非有非常强烈的证明理由,否则我不会复制代码。 - Don Hatch
9
性能是原因之一,但并不是唯一或最重要的原因。大多数情况下,当我编写移动构造函数和移动赋值运算符时,是因为我的类对资源具有独特的所有权。通常性能不是问题,因此在这些情况下,避免重复是有意义的。 - opetroch
@user779446:感谢分享您的优先事项。这是我的:http://howardhinnant.github.io/coding_guidelines.html - Howard Hinnant
6
唐纳德·库努斯曾说:“程序员会在非关键部分的程序速度上消耗大量时间去思考或担忧,而这些对效率的追求实际上会在调试和维护时产生极其负面的影响。我们应该忘记小细节,比如说97%的时间:过早地优化是万恶之源。然而,在关键的3%机会出现时,我们也不应该错过它们。” - Jamie
我不理解这个回应的地方在于,编译器很可能会内联调用移动赋值运算符,并生成与手动编写两者相同的汇编代码。在绝大多数情况下...不应该有性能惩罚。 - Darinth
显示剩余4条评论

4

这是我用C++11编写的MemoryBlock类。

#include <algorithm>
#include <vector>
// #include <stdio.h>

class MemoryBlock
{
 public:
  explicit MemoryBlock(size_t length)
    : length_(length),
      data_(new int[length])
  {
    // printf("allocating %zd\n", length);
  }

  ~MemoryBlock() noexcept
  {
    delete[] data_;
  }

  // copy constructor
  MemoryBlock(const MemoryBlock& rhs)
    : MemoryBlock(rhs.length_) // delegating to another ctor
  {
    std::copy(rhs.data_, rhs.data_ + length_, data_);
  }

  // move constructor
  MemoryBlock(MemoryBlock&& rhs) noexcept
    : length_(rhs.length_),
      data_(rhs.data_)
  {
    rhs.length_ = 0;
    rhs.data_ = nullptr;
  }

  // unifying assignment operator.
  // move assignment is not needed.
  MemoryBlock& operator=(MemoryBlock rhs) // yes, pass-by-value
  {
    swap(rhs);
    return *this;
  }

  size_t Length() const
  {
    return length_;
  }

  void swap(MemoryBlock& rhs)
  {
    std::swap(length_, rhs.length_);
    std::swap(data_, rhs.data_);
  }

 private:
  size_t length_;  // note, the prefix underscore is reserved.
  int*   data_;
};

int main()
{
   std::vector<MemoryBlock> v;
   // v.reserve(10);
   v.push_back(MemoryBlock(25));
   v.push_back(MemoryBlock(75));

   v.insert(v.begin() + 1, MemoryBlock(50));
}

使用正确的C++11编译器,在测试程序中应仅调用MemoryBlock::MemoryBlock(size_t)三次。


2
你应该使用std::unique_ptr<int[]>来代替手动删除数组;这样做更符合C++11的规范。 - TiMoch
我喜欢在移动赋值运算符中使用swap(),因为这可以确保析构函数将在对象实例上正确执行,从而释放其所有资源。 - Julien-L
当数据成员在移动时不会出现冗余复制的开销时,统一赋值是可以接受的。根据此分析,这里总是存在开销。虽然在这种情况下这种开销可以说是足够小以忽略,但是一个次优的实现很难成为惯用法。由于在某些其他情况下开销可能是不可忽略的,因此很难确定标准(和可移植性)。这可能是API和ABI兼容性方面的一个大问题。因此,我建议在特殊成员函数中完全避免统一赋值。 - FrankHB
在您的情况下,程序员仍然需要编写两个大部分相似的方法:swap()和移动构造函数(基本上是使用空/未初始化的lhs对象对swap()进行特化)。因此,如果需要swap()方法,则您的版本很好。如果不需要它,则无法避免代码重复!请注意,一些建议甚至进一步建议对移动构造函数也使用swap(),首先通过默认构造函数创建一个空对象,然后“交换”移入的对象。 - Kai Petzke

1
我认为您不会注意到明显的性能差异。我认为使用移动构造函数中的移动赋值运算符是一个很好的做法。
但是,我更愿意使用std::forward而不是std::move,因为它更加合乎逻辑:
*this = std::forward<MemoryBlock>(other);

4
std::forward 用于模板中,当你不知道是否可以移动对象时使用。它的作用是使代码更通用和高效。 - aschepler
“other”变量不已经是rvalue了吗?所以没有必要使用std::move吗? - Mike Fisher
4
不,一个被命名的右值是一个左值。 - TiMoch
这对我很有效,解决了关于在移动后使用某些内容的clang-tidy警告。谢谢! - Tim Sylvester

0

这取决于你的移动赋值运算符做了什么。如果你看一下你链接到的那篇文章中的代码,你会发现其中的一部分是:

  // Free the existing resource.
  delete[] _data;

所以在这种情况下,如果你从移动构造函数中调用了移动赋值运算符而没有先初始化_data,那么你最终会尝试删除一个未初始化的指针。因此,在这个例子中,无论是否高效,实际上都至关重要的是你确实初始化这些值。

如果您使用初始化列表 :_data(other._data),则无需初始化两次该值。 - Elliot Hatch
不过这样你就要重新实现移动代码,而不是调用赋值运算符,而这正是你的问题所在。 - Jonathan Potter

-1

我会简单地取消成员初始化并写成:

MemoryBlock(MemoryBlock&& other)
{
   *this = std::move(other);
}

这将始终有效,除非移动赋值引发异常,而通常不会!

这种风格的优点:

  1. 您不需要担心编译器是否会重复初始化成员,因为在不同的环境中可能会有所不同。
  2. 您编写的代码更少。
  3. 即使将来向类中添加额外成员,也不需要更新它。
  4. 编译器通常可以内联移动赋值,因此复制构造函数的开销将是最小的。

我认为@Howard的帖子并没有完全回答这个问题。实际上,类经常不喜欢复制,许多类只是禁用复制构造函数和复制赋值。但是,即使不能复制,大多数类也可以移动。


2
移动赋值运算符可能会在从源对象中窃取资源之前释放此对象的资源。如果您不在移动构造函数中初始化成员,则它们将包含一些垃圾值,并且移动赋值运算符将尝试释放它们。 - 4LegsDrivenCat

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接