43

更新在底部

q1:对于管理相当大量资源的类,您如何实现五规则,但您希望它通过值传递,因为这大大简化和美化了它的使用?或者甚至不需要规则的所有五项?

在实践中,我开始使用 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;
};

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

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 ) 肯定会修复它,但我们不会失去一个相当重要的优化机会吗?

除此之外,很明显移动构造函数和赋值的代码充满了重复。所以现在我们忘记了歧义并尝试使用复制和交换来解决这个问题,但现在是右值。正如这里所解释的,我们甚至不需要自定义交换,而是让 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)并让复制赋值运算符通过引用获取其参数。

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

不幸的是,我无法在现实世界的场景中测试这两种实现,因为这个项目刚刚开始,数据实际流动的方式尚不清楚。所以我简单地实现了它们,为分配等添加了计数器,并运行了几次迭代。此代码,其中 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。

4

6 回答 6

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相同大小的 s,否则这会好得多。如果可以回收利用,切勿丢弃资源。

有些人可能会抱怨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:使用复制省略/RVO/移动语义的组合,编译器应该能够以最少的复制来实现这一点,不是吗?

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

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

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

于 2011-05-12T12:00:38.337 回答
13

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

您的复制操作看起来很明智,但您的移动操作却不明智。首先,虽然右值引用参数将绑定到一个右值,但在函数中它是一个左值,所以你的移动构造函数应该是:

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

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

如果您提供移动构造函数,那么当您定义复制分配时,您不需要移动分配运算符 --- 因为您按接受参数,所以右值将被移动到参数中而不是复制.

如您所见,您不能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;
};
于 2011-05-12T10:46:12.433 回答
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就个人而言,我对正确管理其资源和优化我可以编写的任何代码中的副本/移动更有信心。

于 2011-05-12T12:28:32.027 回答
1

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

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

于 2011-05-12T18:50:37.017 回答
1

原海报第三季

我认为您(和其他一些响应者)误解了编译器错误的含义,并因此得出了错误的结论。编译器认为(移动)赋值调用是模棱两可的,没错!您有多种同样合格的方法。

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

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

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

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

顺便说一句,我不熟悉 C++11 和 StackOverflow。在看到这个问题之前,我通过浏览另一个 SO 问题得到了这个答案。(更新:实际上,我仍然打开了页面。链接转到显示该技术的FredOverflow的特定响应。)

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

(我太新手了,无法直接评论回复。)

如果以后的测试已经将其剔除,则无需显式检查自分配。在这种情况下,n != rh.n已经处理了大部分。但是,该std::copy调用在该(当前) inner 之外if,因此我们将获得n组件级别的自分配。由您决定这些分配是否过于反最优,即使自我分配应该很少见。

于 2011-10-31T13:09:04.323 回答
1

借助委托构造函数,您只需实现每个概念一次;

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

其余的只是使用那些。

另外不要忘记进行移动分配(和交换)noexcept,例如,如果您将班级放在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;
}
于 2017-02-10T13:29:11.773 回答