如何实际实施五条规则?

时间:2011-05-12 10:11:19

标签: c++ c++11 move-semantics rvalue-reference

底部的

更新

q1:如何为管理相当繁重的资源的类实现rule of five, 但是你希望它以值传递,因为这极大地简化并美化了它的用法?或者甚至不需要所有五项规则?

在实践中,我开始使用3D成像,其中图像通常是128 * 128 * 128倍。 能够写这样的东西会让数学变得更容易:

Data a = MakeData();
Data c = 5 * a + ( 1 + MakeMoreData() ) / 3;

q2:使用复制省略/ RVO /移动语义的组合,编译器应该能够以最少的复制完成此操作,不是吗?

我试图弄清楚如何做到这一点,所以我从基础开始;假设一个对象实现了实现复制和赋值的传统方式:

class AnObject
{
public:
  AnObject( size_t n = 0 ) :
    n( n ),
    a( new int[ n ] )
  {}
  AnObject( const AnObject& rh ) :
    n( rh.n ),
    a( new int[ rh.n ] )
  {
    std::copy( rh.a, rh.a + n, a );
  }
  AnObject& operator = ( AnObject rh )
  {
    swap( *this, rh );
    return *this;
  }
  friend void swap( AnObject& first, AnObject& second )
  {
    std::swap( first.n, second.n );
    std::swap( first.a, second.a );
  }
  ~AnObject()
  {
    delete [] a;
  }
private:
  size_t n;
  int* a;
};

现在输入rvalues并移动语义。据我所知,这将是一个有效的实施:

AnObject( AnObject&& rh ) :
  n( rh.n ),
  a( rh.a )
{
  rh.n = 0;
  rh.a = nullptr;
}

AnObject& operator = ( AnObject&& rh )
{
  n = rh.n;
  a = rh.a;
  rh.n = 0;
  rh.a = nullptr;
  return *this;
}

然而,编译器(VC ++ 2010 SP1)对此并不满意,编译器通常是正确的:

AnObject make()
{
  return AnObject();
}

int main()
{
  AnObject a;
  a = make(); //error C2593: 'operator =' is ambiguous
}

q3:如何解决这个问题?回到AnObject& operator =(const AnObject& rh)肯定会修复它,但是我们不会失去一个相当重要的优化机会吗?

除此之外,显然移动构造函数和赋值的代码充满了重复。 所以现在我们忘记了歧义,并尝试使用复制和交换来解决这个问题,但现在我们用于rvalues。 正如here所解释的那样,我们甚至不需要自定义交换,而是让std :: swap完成所有工作,听起来很有希望。 所以我写了以下内容,希望std :: swap会使用move构造函数复制构造一个临时构建器,然后用* this交换它:

AnObject& operator = ( AnObject&& rh )
{
  std::swap( *this, rh );
  return *this;
}

但由于std :: swap再次调用我们的operator =(AnObject&& rh),因此无效递归会导致堆栈溢出。 q4:有人可以举例说明示例中的含义吗?

我们可以通过提供第二个交换功能来解决这个问题:

AnObject( AnObject&& rh )
{
  swap( *this, std::move( rh ) );
}

AnObject& operator = ( AnObject&& rh )
{
  swap( *this, std::move( rh ) );
  return *this;
}

friend void swap( AnObject& first, AnObject&& second )
{
  first.n = second.n;
  first.a = second.a;
  second.n = 0;
  second.a = nullptr;
}

现在几乎是金额代码的两倍,但是它的移动部分是通过提供相当便宜的移动来支付的;但另一方面,正常的任务不再受益于复制省略。 在这一点上,我真的很困惑,并没有再看到什么是对错,所以我希望在这里得到一些意见..

更新所以似乎有两个阵营:

  • 有人说要跳过移动赋值运算符并继续执行C ++ 03教给我们的东西,即编写一个按值传递参数的单个赋值运算符。
  • 另一个人说实现移动赋值运算符(毕竟现在是C ++ 11)并让复制赋值运算符通过引用获取它的参数。

(好吧,第3阵营告诉我使用矢量,但这有点超出了这个假设类的范围。在现实生活中我会使用矢量,并且还会有其他成员,但是因为移动构造函数/赋值不会自动生成(但是?)问题仍然存在)

不幸的是,我无法在现实世界的场景中测试这两种实现,因为这个项目刚刚开始,数据实际流动的方式还不知道。所以我简单地实现了它们,添加了分配计数器等,并运行了大约几次迭代。这段代码,其中T是其中一个实现:

template< class T >
T make() { return T( narraySize ); }

template< class T >
void assign( T& r ) { r = make< T >(); }

template< class T >
void Test()
{
  T a;
  T b;
  for( size_t i = 0 ; i < numIter ; ++i )
  {
    assign( a );
    assign( b );
    T d( a );
    T e( b );
    T f( make< T >() );
    T g( make< T >() + make< T >() );
  }
}

要么这段代码不足以测试我所追求的东西,要么编译器太聪明了:无论我使用的是arraySize和numIter,两个阵营的结果几乎完全相同:相同的数字分配,时间上的微小变化,但没有可重现的显着差异。

因此,除非有人能够指出一种更好的方法来测试它(鉴于实际的使用情况还不知道),我必须得出结论,这无关紧要,因此留给开发者的味道。在这种情况下,我会选择#2。

6 个答案:

答案 0 :(得分:17)

您错过了复制赋值运算符的重要优化。随后情况变得混乱。

  AnObject& operator = ( const AnObject& rh )
  {
    if (this != &rh)
    {
      if (n != rh.n)
      {
         delete [] a;
         n = 0;
         a = new int [ rh.n ];
         n = rh.n;
      }
      std::copy(rh.a, rh.a+n, a);
    }
    return *this;
  }

除非你真的从不认为你将分配相同大小的AnObject,否则这要好得多。如果可以回收资源,切勿丢弃资源。

有些人可能会抱怨AnObject的复制赋值运算符现在只有基本的异常安全性,而不是强大的异常安全性。但请考虑一下:

  

您的客户总是可以快速行动   赋值运算符并赋予它强大   异常安全。但是他们不能接受   一个缓慢的赋值运算符并使它成功   更快。

template <class T>
T&
strong_assign(T& x, T y)
{
    swap(x, y);
    return x;
}

您的移动构造函数很好,但您的移动赋值运算符有内存泄漏。它应该是:

  AnObject& operator = ( AnObject&& rh )
  {
    delete [] a;
    n = rh.n;
    a = rh.a;
    rh.n = 0;
    rh.a = nullptr;
    return *this;
  }

...

Data a = MakeData();
Data c = 5 * a + ( 1 + MakeMoreData() ) / 3;
     

q2:使用copy elision / RVO / move语义的组合   编译器应该可以这个   最少复制,没有?

您可能需要重载运算符以利用rvalues中的资源:

Data operator+(Data&& x, const Data& y)
{
   // recycle resources in x!
   x += y;
   return std::move(x);
}

最终,对于您关心的每个Data,应该只创建一次资源。应该没有不必要的new/delete只是为了移动东西。

答案 1 :(得分:13)

如果您的对象资源很多,您可能希望完全避免复制,只需提供移动构造函数和移动赋值运算符。但是,如果您真的想要复制,则很容易提供所有操作。

您的复制操作看起来很合理,但您的移动操作却没有。首先,虽然右值参考参数绑定到右值,但在函数内它是左值,所以你的移动构造函数应该是:

AnObject( AnObject&& rh ) :
  n( std::move(rh.n) ),
  a( std::move(rh.a) )
{
  rh.n = 0;
  rh.a = nullptr;
}

当然,对于像你这样的基本类型来说它实际上并没有什么区别,但是养成习惯也是如此。

如果你提供了一个移动构造函数,那么在你定义复制赋值时就不需要一个移动赋值操作符了 - 因为你接受 value 的参数, rvalue将被移入参数而不是复制。

如您所见,您不能在移动赋值运算符内的整个对象上使用std::swap(),因为它将递归回移动赋值运算符。您链接到的帖子中的评论要点是,如果您提供移动操作,则无需实施自定义swap,因为std::swap将使用您的移动操作。不幸的是,如果你没有定义一个单独的移动赋值运算符,这不起作用,并且仍会递归。您当然可以使用std::swap来交换成员:

AnObject& operator=(AnObject other)
{
    std::swap(n,other.n);
    std::swap(a,other.a);
    return *this;
}

你的最后一堂课是:

class AnObject
{
public:
  AnObject( size_t n = 0 ) :
    n( n ),
    a( new int[ n ] )
  {}
  AnObject( const AnObject& rh ) :
    n( rh.n ),
    a( new int[ rh.n ] )
  {
    std::copy( rh.a, rh.a + n, a );
  }
  AnObject( AnObject&& rh ) :
    n( std::move(rh.n) ),
    a( std::move(rh.a) )
  {
    rh.n = 0;
    rh.a = nullptr;
  }
  AnObject& operator = ( AnObject rh )
  {
    std::swap(n,rh.n);
    std::swap(a,rh.a);
    return *this;
  }
  ~AnObject()
  {
    delete [] a;
  }
private:
  size_t n;
  int* a;
};

答案 2 :(得分:4)

让我帮助你:

#include <vector>

class AnObject
{
public:
  AnObject( size_t n = 0 ) : data(n) {}

private:
  std::vector<int> data;
};

从C ++ 0x FDIS, [class.copy] 注9:

  

如果类X的定义没有显式声明一个移动构造函数,那么当且仅当

时,才会隐式声明一个默认值。      
      
  • X没有用户声明的复制构造函数,

  •   
  • X没有用户声明的复制赋值运算符

  •   
  • X没有用户声明的移动赋值运算符

  •   
  • X没有用户声明的析构函数,

  •   
  • 移动构造函数不会被隐式定义为已删除。

  •   
     

[注意:当未隐式声明或显式提供移动构造函数时,否则将调用移动构造函数的表达式可能会调用复制构造函数。 - 尾注]

就个人而言,我对std::vector正确管理其资源并优化我可以编写的任何代码中的副本/移动更有信心。

答案 3 :(得分:1)

因为我还没有看到其他人明确指出这一点......

如果(并且如果)由于复制省略而传递了右值,则您的副本赋值运算符按值获取其参数是一个重要的优化机会。但是在具有赋值运算符的类中,显式采用rvalues(即具有移动赋值运算符的rvalues),这是一个荒谬的场景。因此,模拟已经在其他答案中指出的内存泄漏,我会说如果您只是更改复制赋值运算符以通过const引用获取其参数,那么您的类已经很理想了。

答案 4 :(得分:1)

原始海报的q3

我认为你(以及其他一些响应者)误解了编译器错误的含义,并因此得出了错误的结论。编译器认为(移动)赋值调用是不明确的,它是正确的!您有多种方法同样合格。

在原始版本的AnObject类中,复制构造函数通过const(左值)引用接受旧对象,而赋值运算符通过(非限定)值获取其参数。 value参数由适当的传递构造函数从运算符右侧的任何内容初始化。由于您只有一个传输构造函数,因此无论原始右侧表达式是左值还是右值,都始终使用该复制构造函数。这使赋值运算符充当复制赋值特殊成员函数。

添加移动构造函数后,情况会发生变化。每当调用赋值运算符时,传输构造函数有两种选择。复制构造函数仍将用于左值表达式,但只要给出右值表达式,就会使用移动构造函数!这使赋值运算符同时充当移动赋值特殊成员函数。

当您添加传统的移动赋值运算符时,您为该类提供了相同特殊成员函数的两个版本,这是一个错误。你已经拥有了你想要的东西,所以只需要摆脱传统的移动赋值算子,不需要进行任何其他改动。

在您的更新中列出的两个阵营中,我想我在技术上处于第一阵营,但原因完全不同。 (不要跳过(传统的)移动赋值运算符,因为它对于你的类来说是“破碎的”,但是因为它是多余的。)

顺便说一下,我刚开始阅读有关C ++ 11和StackOverflow的内容。我通过浏览另一个S.O.得出了这个答案。在看到这个之前的问题。 (更新:实际上,我仍然打开page。链接转到显示该技术的FredOverflow的特定响应。)

关于2011年5月12日Howard Hinnant的回复

(我是一个直接评论回复的新手。)

如果稍后的测试已经剔除它,则不需要显式检查自我分配。在这种情况下,n != rh.n已经完成了大部分工作。但是,std::copy调用超出了(当前)内部if,因此我们将获得n组件级别的自我分配。由你来决定这些作业是否过于反对,即使自我任命应该是罕见的。

答案 5 :(得分:1)

在委托构造函数的帮助下,您只需要实现一次概念;

  • 默认初始化
  • 资源删除
  • 交换
  • 复制

其他人只是使用那些。

也不要忘记进行移动分配(和交换)vector, 如果你把自己的课程放在一边,那么如果有很大帮助 在#include <utility> // header class T { public: T(); T(const T&); T(T&&); T& operator=(const T&); T& operator=(T&&) noexcept; ~T(); void swap(T&) noexcept; private: void copy_from(const T&); }; // implementation T::T() { // here (and only here) you implement default init } T::~T() { // here (and only here) you implement resource delete } void T::swap(T&) noexcept { using std::swap; // enable ADL // here (and only here) you implement swap, typically memberwise swap } void T::copy_from(const T& t) { if( this == &t ) return; // don't forget to protect against self assign // here (and only here) you implement copy } // the rest is generic: T::T(const T& t) : T() { copy_from(t); } T::T(T&& t) : T() { swap(t); } auto T::operator=(const T& t) -> T& { copy_from(t); return *this; } auto T::operator=(T&& t) noexcept -> T& { swap(t); return *this; }

DataGridView