Как на самом деле реализовать правило пяти?

ОБНОВЛЕНИЕ внизу

q1: Как бы вы реализовали правило пяти для класса, который управляет довольно большими ресурсами, но вы хотите, чтобы оно передавалось по значению, потому что это значительно упрощает и украшает его использование? Или не все пять пунктов правила даже нужны?

На практике я начинаю что-то с 3D-изображения, где изображение обычно 128*128*128, удваивается. Хотя умение писать такие вещи намного облегчило бы математику:

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

q2: Используя комбинацию семантики копирования elision / RVO / move, компилятор должен быть способен сделать это с минимумом копирования, нет?

Я попытался выяснить, как это сделать, поэтому я начал с основ; предположим, что объект реализует традиционный способ реализации копирования и присваивания:

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), безусловно, исправляет это, но не теряем ли мы довольно важную возможность оптимизации?

Кроме того, ясно, что код для конструктора перемещения и назначения полон дублирования. Итак, пока мы забываем о неоднозначности и пытаемся решить эту проблему, используя copy и swap, но теперь для rvalues. Как объяснялось здесь, нам даже не понадобился бы пользовательский своп, но вместо этого мы должны были бы выполнить всю работу std::swap, что звучит многообещающе. Поэтому я написал следующее, надеясь, что std::swap скопирует временную конструкцию, используя конструктор перемещения, а затем поменяет ее на *this:

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

Но это не работает и вместо этого приводит к переполнению стека из-за бесконечной рекурсии, поскольку std::swap снова вызывает наш оператор = ( 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, результаты для обоих лагерей в значительной степени идентичны: одинаковое количество выделений, очень незначительные изменения во времени, но не воспроизводимые существенные различия.

Поэтому, если кто-то не может указать на лучший способ проверить это (учитывая, что фактическое использование scnearios еще не известно), я должен буду заключить, что это не имеет значения и, следовательно, остается на вкус разработчика. В этом случае я бы выбрал #2.

6 ответов

Решение

Вы пропустили значительную оптимизацию в своем операторе назначения копий. И впоследствии ситуация запуталась.

  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: Используя комбинацию семантики копирования elision / RVO / move, компилятор должен иметь возможность сделать это с минимумом копирования, нет?

Возможно, вам придется перегружать операторов, чтобы использовать ресурсы в значениях:

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

В конечном итоге ресурсы должны создаваться ровно один раз для каждого Data Вы заботитесь о. Там не должно быть ненужных new/delete просто с целью перемещения вещей вокруг.

Если ваш объект требует значительных ресурсов, вы можете вообще отказаться от копирования и просто предоставить конструктор перемещения и оператор присваивания перемещения. Однако, если вы действительно хотите копировать, легко выполнить все операции.

Ваши операции копирования выглядят разумными, а операции перемещения - нет. Во-первых, хотя ссылочный параметр rvalue будет привязан к rvalue, внутри функции это lvalue, поэтому ваш конструктор перемещения должен быть:

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

Конечно, для фундаментальных типов, таких как у вас здесь, это на самом деле не имеет значения, но это также входит в привычку.

Если вы предоставляете конструктор перемещения, то вам не нужен оператор перемещения-назначения, когда вы определяете назначение копирования, как у вас - потому что вы принимаете параметр по значению, значение r будет перемещено в параметр, а не скопировано,

Как вы обнаружили, вы не можете использовать 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;
};

Позвольте мне помочь вам:

#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 правильное управление его ресурсами и оптимизация копий / перемещений, которые можно написать в любом коде.

С помощью делегирующего конструктора вам нужно реализовать каждую концепцию только один раз;

  • инициализация по умолчанию
  • удаление ресурса
  • своп
  • копия

остальные просто используют их.

Также не забудьте сделать перемещение-назначение (и обмен) 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;
}

Так как я не видел, чтобы кто-то еще прямо указывал на это...

Ваш оператор присваивания копии, принимающий свой аргумент по значению, является важной возможностью оптимизации, если (и только если) ему передано значение r, из-за исключения копирования. Но в классе с оператором присваивания, который явно принимает только значения (т. Е. Класс с оператором присваивания перемещения), это бессмысленный сценарий. Таким образом, по модулю утечек памяти, которые уже были указаны в других ответах, я бы сказал, что ваш класс уже идеален, если вы просто измените оператор присваивания копии, чтобы получить его аргумент по ссылке const.

q3 оригинального плаката

Я думаю, что вы (и некоторые другие респонденты) неправильно поняли, что означала ошибка компилятора, и из-за этого пришли к неверным выводам. Компилятор считает, что вызов присваивания (перемещение) неоднозначен, и это правильно! У вас есть несколько методов, которые одинаково квалифицированы.

В вашей оригинальной версии AnObject класс, ваш конструктор копирования принимает старый объект const (lvalue) ссылка, в то время как оператор присваивания принимает свой аргумент по (неквалифицированному) значению. Аргумент value инициализируется соответствующим конструктором переноса из того, что было справа от оператора. Поскольку у вас есть только один конструктор переноса, этот конструктор копирования всегда используется, независимо от того, было ли исходное выражение правой части lvalue или rvalue. Это заставляет оператор присваивания действовать как функция-член копирования-присваивания.

Ситуация меняется после добавления конструктора перемещения. Всякий раз, когда вызывается оператор присваивания, существует два варианта для конструктора переноса. Конструктор копирования по-прежнему будет использоваться для выражений lvalue, но конструктор перемещения будет использоваться всякий раз, когда вместо него дается выражение rvalue! Это заставляет оператор присваивания одновременно действовать как функция-член перемещения-присваивания.

Когда вы добавили традиционный оператор присваивания перемещения, вы дали классу две версии одной и той же специальной функции-члена, что является ошибкой. У вас уже есть то, что вы хотели, так что просто избавьтесь от традиционного оператора присваивания перемещения, и никаких других изменений не требуется.

Я полагаю, что в двух лагерях, перечисленных в вашем обновлении, технически я нахожусь в первом лагере, но по совершенно другим причинам. (Не пропускайте (традиционный) оператор присваивания перемещения, потому что он "сломан" для вашего класса, а потому что он излишний.)

Кстати, я новичок в чтении о C++11 и Stackru. Я пришел с этим ответом, просматривая еще один вопрос, прежде чем увидеть этот. (Обновление: на самом деле, у меня все еще была открытая страница. Ссылка идет на конкретный ответ fredoverflow, который показывает технику.)

Об ответе Говарда Хиннанта 2011-мая-12

(Я слишком новичок, чтобы напрямую комментировать ответы.)

Вам не нужно явно проверять самостоятельное назначение, если более поздний тест уже отбросит его. В этом случае, n != rh.n уже позаботится о большей части этого. Тем не менее std::copy вызов находится за пределами этого (в настоящее время) внутреннего ifтак что мы бы получили n самостоятельные задания на уровне компонентов. Вам решать, будут ли эти назначения слишком антиоптимальными, даже если самостоятельное назначение должно быть редким.

Другие вопросы по тегам