【问题标题】:Implementing Move Constructor by Calling Move Assignment Operator通过调用移动赋值运算符实现移动构造函数
【发布时间】:2013-06-11 16:18:33
【问题描述】:

MSDN 文章How to: Write a Move Constuctor 有以下建议。

如果你为你的类同时提供了一个移动构造函数和一个移动赋值运算符,你可以通过编写来消除冗余代码 移动构造函数调用移动赋值运算符。这 以下示例显示了移动构造函数的修订版本, 调用移动赋值运算符:

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

通过双重初始化MemoryBlock 的值,这段代码是否效率低下,或者编译器是否能够优化掉额外的初始化?我应该总是通过调用移动赋值运算符来编写移动构造函数吗?

【问题讨论】:

  • 只是一个注释。如果您的某些数据成员不是默认可构造的,则该方法不起作用。

标签: c++ c++11 move-semantics move-constructor


【解决方案1】:

[...] 编译器能否优化掉额外的初始化?

在几乎所有情况下:是的。

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

是的,只需通过移动赋值运算符实现它,除非您测量它会导致性能欠佳。


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

让我们看看一些组装! This 显示了来自 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

除了正确版本的附加分支外,代码完全相同。含义:重复的分配已被删除

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


这个 get 重复了很多次,但很重要:不要过早进行微优化!

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

在这种特定情况下,我什至猜想您永远不必手动优化,因为编译器将始终能够围绕您的移动构造函数生成最佳代码。当您需要在两个地方更改代码时,或者当您需要调试一个奇怪的错误时,现在进行无用的微优化会花费您的开发时间,而这种错误只是因为您只在一个地方更改了代码而发生。这是浪费的开发时间,本可以花在有用的优化上。

【讨论】:

  • 这对于 MoveConstructible 类而不是 MoveAssignable 类根本不可行。当移动构造函数和移动赋值运算符具有不同的异常规范时,就会产生麻烦。另请注意,对于在不同操作上具有不同分配器传播策略的分配器感知容器,它会严重失败。但根本问题是逻辑问题。也就是说,一般来说,移动赋值应该依赖移动构造函数,而不是相反。这里冗余成员初始化的必要性也表明一些非惯用的事情出错了。
【解决方案2】:

我不会这样做。移动成员存在的原因首先是性能。为您的移动构造器执行此操作就像花巨资购买一辆超级汽车,然后尝试通过购买普通汽油来省钱。

如果您想减少编写的代码量,请不要编写移动成员。您的班级将在移动上下文中很好地复制。

如果您希望您的代码具有高性能,那么请尽可能快地调整您的移动构造函数和移动赋值。好的移动成员会非常快,你应该通过计算负载、商店和分支来估计他们的速度。如果您可以使用 4 个加载/存储而不是 8 个来编写一些东西,那就去做吧!如果你可以写一些没有分支而不是 1 的东西,那就去做吧!

当您(或您的客户)将您的班级放入 std::vector 时,您的类型会生成 很多 动作。即使您的移动速度在 8 次加载/存储时闪电般快速,如果您只需 4 或 6 次加载/存储即可使其速度提高一倍,甚至快 50%,恕我直言,这是值得的时间。

就我个人而言,我厌倦了看到等待光标,并愿意多花 5 分钟来编写我的代码,并且知道它是尽可能快的。

如果您仍然不相信这是值得的,请以两种方式编写它,然后在完全优化的情况下检查生成的程序集。谁知道呢,你的编译器可能足够聪明,可以为你优化掉额外的负载和存储。但此时您已经投入了比一开始编写优化移动构造函数更多的时间。

【讨论】:

  • 这里有一些很好的建议,但我认为这个答案忽略了考虑到重复代码的成本不仅仅是第二次编写它所花费的 5 分钟——重复代码的意义要大得多成本高于可读性、可维护性和健壮性。特别是当存在重复代码时,它会引发一种非常常见的错误类型:即,当某个代码实例中的某些内容被修复或更改时,而另一个实例中没有。在我的一生中,我被它所困扰的次数超过了我所关心的次数,除非有一个非常强有力的证明理由,否则我不会重复代码。
  • 性能是原因之一,但不是唯一或最重要的原因。大多数情况下,我编写移动构造函数和移动赋值运算符是因为我的类拥有资源的唯一所有权。通常性能不是问题,因此在这些情况下避免重复是有意义的。
  • @user779446:感谢您分享您的优先事项。这是我的:howardhinnant.github.io/coding_guidelines.html
  • Donald Knuth 写道:“程序员浪费了大量时间来思考或担心程序中非关键部分的速度,而这些效率上的尝试实际上对调试和维护产生了强烈的负面影响考虑到。我们应该忘记小的效率,比如说大约 97% 的时间:过早的优化是万恶之源。但我们不应该放弃关键的 3% 的机会。”
  • 我对此响应的不理解是,对移动赋值的调用可能会被编译器内联并最终生成相同的程序集,就像您手动编写两者一样。在绝大多数情况下......不应该有性能损失。
【解决方案3】:

我的 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) 在测试程序中应该只被调用 3 次。

【讨论】:

  • 你应该使用 std::unique_ptr 而不是手动删除你的数组;这将使它更像 c++11ish
  • 我喜欢移动赋值运算符中的 swap(),因为这样可以确保析构函数在对象的实例上正确执行,从而释放其所有资源。
  • 当数据成员在移动时不会出现冗余副本的开销时,可以接受统一分配。根据this analysis,开销始终存在于此。尽管在这种情况下这样的开销可以说是小到可以忽略,但次优的实现很难是惯用的。由于在某些其他情况下开销可能不可忽略,因此也很难获得正确的标准(并且可移植)。这可能是 API 和 ABI 兼容性的一个大问题。因此,我建议完全避免在特殊成员函数中进行统一。
  • 在您的情况下,程序员仍然必须编写两个最相似的方法:swap() 和移动构造函数(这基本上是 swap() 的特化,带有空/未初始化的 lhs 对象)。所以,如果需要swap() 方法,无论如何,你的版本很好。如果不需要,也不能避免代码重复!请注意,一些建议更进一步,将swap() 也用于移动构造函数,首先通过默认构造函数创建一个空对象,然后“交换”移动的对象。
【解决方案4】:

我认为您不会注意到显着的性能差异。我认为使用移动构造函数中的移动赋值运算符是一种好习惯。

但是我宁愿使用 std::forward 而不是 std::move 因为它更合乎逻辑:

*this = std::forward<MemoryBlock>(other);

【讨论】:

  • std::forward 用于模板中,当您不知道是否可以移动对象时。
  • “其他”变量不是已经是右值了吗,所以不需要std::move?
  • @MikeFisher 不,命名的右值是左值
  • 这对我来说效果很好,并解决了移动后使用某物的叮当声警告。谢谢!
【解决方案5】:

这取决于你的移动赋值运算符做什么。如果您查看链接到的文章中的那个,您会看到部分内容:

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

因此,在这种情况下,如果您从移动构造函数中调用移动赋值运算符没有首先初始化_data,您最终会尝试删除未初始化的指针。所以在这个例子中,不管是否低效,初始化值实际上是至关重要的。

【讨论】:

  • 如果你使用了初始化列表:_data(other._data),你就不需要初始化两次
  • 不,但是您正在重新实现移动代码,而不是调用您的问题所在的赋值运算符。
【解决方案6】:

我会简单地消除成员初始化并编写,

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

除非移动分配抛出异常,否则这将始终有效,而且通常不会!

这种风格的优点:

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

我认为@Howard 的帖子并没有完全回答这个问题。在实践中,类通常不喜欢复制,很多类只是禁用了复制构造函数和复制赋值。但是大多数类可以移动,即使它们不可复制。

【讨论】:

  • 移动赋值运算符可能会在从源对象窃取资源之前释放该对象的资源。如果你不在移动构造函数中初始化成员,那么它们将包含一些垃圾值,移动赋值运算符将尝试释放它们。
猜你喜欢
  • 2016-05-19
  • 2017-05-15
  • 2017-01-16
  • 2020-09-06
  • 1970-01-01
  • 2015-03-03
  • 2015-07-10
  • 2019-09-29
  • 2021-08-18
相关资源
最近更新 更多