Действительно ли копирование и обмен является лучшим решением?

Я видел copy-and-swap идиому, рекомендованную в различных помещает как рекомендуемый/лучший/единственный способ реализовать сильную защиту исключений для оператора присваивания. Мне кажется, что этот подход также имеет недостаток.

Рассмотрим следующий упрощенный векторно-подобный класс, который использует copy-and-swap:

class IntVec {
  size_t size;
  int* vec;
public:
  IntVec()
    : size(0),
      vec(0)
  {}
  IntVec(IntVec const& other)
    : size(other.size),
      vec(size? new int[size] : 0)
  {
    std::copy(other.vec, other.vec + size, vec);
  }

  void swap(IntVec& other) {
    using std::swap;
    swap(size, other.size);
    swap(vec, other.vec);
  }

  IntVec& operator=(IntVec that) {
    swap(that);
    return *this;
  }

  //~IntVec() and other functions ...
}

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

Рассмотрим случай назначения 700 МБ IntVec 1GB IntVec на машине с лимитом кучи < 2GB. Оптимальное назначение поймет, что у него уже достаточно выделенной памяти и только скопировать данные в уже выделенный буфер. Реализация copy-and-swap приведет к распределению другого 700-мегабайтного буфера перед выпуском 1 ГБ, в результате чего все 3 попытаются сосуществовать в памяти сразу, что вызовет ненужную ошибку.

Эта реализация решит проблему:

IntVec& operator=(IntVec const& that) {
  if(that.size <= size) {
    size = that.size;
    std::copy(that.vec, that.vec + that.size, vec);
  } else
    swap(IntVec(that));
  return *this;
}

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

Ответ 1

Есть две проблемы с повторным использованием пространства

  • Если вы назначаете very_huge_vect = very_small_vect;, дополнительная память не будет выпущена. Это может быть то, что вы хотите или не можете.

  • В случае целых чисел все в порядке, но как насчет объектов, для которых операция копирования может вызывать исключение? В итоге у вас будет испорченный массив, в котором часть копии выполнена, и она была усечена. Гораздо лучше было бы оставить цель нетронутой, если операция копирования завершится неудачно (что делает идиома обмена).

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

Ответ 2

Чтобы исправить вашу конкретную проблему, измените режим "свопинг-копия", чтобы очистить копию.

Это можно сделать несколько общим с помощью:

Foo& operator=( Foo const& o ) {
  using std::swap;
  if (this == &o) return *this; // efficient self assign does nothing
  swap( *this, Foo{} ); // generic clear
  Foo tmp = o; // copy to a temporary
  swap( *this, tmp ); // swap temporary state into us
  return *this;
}
Foo& operator=( Foo && o ) {
  using std::swap;
  if (this == &o) return *this; // efficient self assign does nothing
  swap( *this, Foo{} ); // generic clear
  Foo tmp = std::move(o); // move to a temporary
  swap( *this, tmp ); // swap temporary state into us
  return *this;
}

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

Ключевая часть копирования-замены заключается в том, что она выполняет правильную реализацию, а получение права на исключение с безопасностью является сложным.

Вы заметите, что вышеописанное, если генерируется исключение, приводит к тому, что lhs может находиться в пустом состоянии. Для сравнения, стандартная копия-копия приведет к действительной копии, или lhs не изменится.

Теперь у нас есть один последний трюк, который мы можем сделать. Предположим, что наше состояние захвачено в vector подтестов, и эти подстанции имеют безопасные исключения swap или move. Тогда мы можем:

Foo& move_substates( Foo && o ) {
  if (this == &o) return *this; // efficient self assign does nothing
  substates.resize( o.substates.size() );
  for ( unsigned i = 0; i < substates.size(); ++i) {
    substates[i] = std::move( o.substates[i] );
  }
  return *this;
}

который эмулирует ваше содержимое-копию, но делает его move вместо copy. Затем мы можем использовать это в нашем operator=:

Foo& operator=( Foo && o ) {
  using std::swap;
  if (this == &o) return *this; // efficient self assign does nothing
  if (substates.capacity() >= o.substates.size() && substates.capacity() <= o.substates.size()*2) {
    return move_substates(std::move(o));
  } else {
    swap( *this, Foo{} ); // generic clear
    Foo tmp = std::move(o); // move to a temporary
    swap( *this, tmp ); // swap temporary state into us
    return *this;
  }
}

и теперь мы повторно используем нашу внутреннюю память и избегаем выделения, если мы переходим от источника, и мы не слишком много, чем источник, если вы боитесь выделения памяти.

Ответ 3

Да, нехватка памяти является потенциальной проблемой.

Вы уже знаете, какие проблемы копируются и своп решают. Это то, как вы "отменили" неудавшееся задание.

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

Ответ 4

Я прав, что это проблема с идиомой copy-and-swap

Это зависит; если у вас есть такие большие векторы, то да вы правы.

Я не замечаю некоторую проблему с моей "лучшей" версией, которую копирует и заменяет, решает

  • Оптимизация для редкого случая. Зачем выполнять дополнительные проверки и добавлять цикличность к вашему коду? (если, разумеется, наличие больших векторов soo является нормой в вашем приложении)

  • Так как С++ 11, r-valuness of temporaries может быть собрана С++. Таким образом, передача вашего аргумента const& отменяет оптимизацию.

Нижняя строка: слово всегда легко опровергнуть. Если бы было универсальное решение, всегда лучше, чем любая альтернатива, я полагаю, что компилятор может принять его и неявно создать оператор присваивания по умолчанию на основе этого

В предыдущей заметке неявно объявленный оператор присваивания копии имеет форму

T& T::operator=(const T&);

принимает свой аргумент const&, а не по значению (как в идиоме копирования и подкачки)

Ответ 5

Два оговорки с вашим подходом.

  • Рассмотрим случай назначения 1KB IntVec для 1GB IntVec. Вы получите массивный кусок выделенной, но неиспользуемой (потерянной) памяти.
  • Что делать, если во время копирования возникает исключение? Если вы переписываете существующее местоположение, вы получаете поврежденные частично скопированные данные.

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

Ответ 6

Вы можете видеть, как STL реализует оператор = вектор.

template <class _Tp, class _Alloc>
vector<_Tp,_Alloc>& 
vector<_Tp,_Alloc>::operator=(const vector<_Tp, _Alloc>& __x)
{
  if (&__x != this) {
    const size_type __xlen = __x.size();
    if (__xlen > capacity()) {
      iterator __tmp = _M_allocate_and_copy(__xlen, __x.begin(), __x.end());
      destroy(_M_start, _M_finish);
      _M_deallocate(_M_start, _M_end_of_storage - _M_start);
      _M_start = __tmp;
      _M_end_of_storage = _M_start + __xlen;
    }
    else if (size() >= __xlen) {
      iterator __i = copy(__x.begin(), __x.end(), begin());
      destroy(__i, _M_finish);
    }
    else {
      copy(__x.begin(), __x.begin() + size(), _M_start);
      uninitialized_copy(__x.begin() + size(), __x.end(), _M_finish);
    }
    _M_finish = _M_start + __xlen;
  }
  return *this;
}