Почему некоторые люди используют своп для заданий на перемещение?
Например, stdlibC++ имеет следующее:
unique_lock& operator=(unique_lock&& __u)
{
if(_M_owns)
unlock();
unique_lock(std::move(__u)).swap(*this);
__u._M_device = 0;
__u._M_owns = false;
return *this;
}
Почему бы просто не назначить двух членов __u * этому непосредственно? Разве своп не подразумевает, что __u назначен * этот элемент, только для того, чтобы позже присвоили 0 и false... в этом случае своп выполняет ненужную работу. Что мне не хватает? (unique_lock::swap просто выполняет std:: swap для каждого члена)
4 ответа
Это моя вина. (полушутя, наполовину нет).
Когда я впервые показал примеры реализации операторов присваивания перемещения, я просто использовал swap. Затем какой-то умный парень (я не помню, кто) указал мне, что побочные эффекты от разрушения lhs до назначения могут быть важными (например, unlock() в вашем примере). Так что я перестал использовать своп для назначения ходов. Но история использования свопа все еще существует и продолжается.
В этом примере нет причин использовать swap. Это менее эффективно, чем вы предлагаете. Действительно, в libC++ я делаю именно то, что вы предлагаете:
unique_lock& operator=(unique_lock&& __u)
{
if (__owns_)
__m_->unlock();
__m_ = __u.__m_;
__owns_ = __u.__owns_;
__u.__m_ = nullptr;
__u.__owns_ = false;
return *this;
}
В общем случае оператор назначения перемещения должен:
- Уничтожьте видимые ресурсы (хотя, возможно, сохраните ресурсы деталей реализации).
- Переместить назначить все базы и члены.
- Если назначение перемещения баз и членов не сделало ресурс менее правым, сделайте это так.
Вот так:
unique_lock& operator=(unique_lock&& __u)
{
// 1. Destroy visible resources
if (__owns_)
__m_->unlock();
// 2. Move assign all bases and members.
__m_ = __u.__m_;
__owns_ = __u.__owns_;
// 3. If the move assignment of bases and members didn't,
// make the rhs resource-less, then make it so.
__u.__m_ = nullptr;
__u.__owns_ = false;
return *this;
}
Обновить
В комментариях есть дополнительный вопрос о том, как обрабатывать конструкторы перемещения. Я начал отвечать там (в комментариях), но ограничения форматирования и длины затрудняют создание четкого ответа. Таким образом, я помещаю свой ответ здесь.
Вопрос в том, что лучше всего подходит для создания конструктора перемещения. Делегировать в конструктор по умолчанию, а затем поменять местами? Это имеет преимущество в уменьшении дублирования кода.
Мой ответ таков: я думаю, что самый важный вывод заключается в том, что программисты должны опасаться следования шаблонам без размышлений. Могут быть некоторые классы, в которых реализация конструктора перемещения по умолчанию +swap - это правильный ответ. Класс может быть большим и сложным. A(A&&) = default;
может поступить неправильно Я думаю, что важно рассмотреть все ваши выборы для каждого класса.
Давайте посмотрим на пример ОП подробно: std::unique_lock(unique_lock&&)
,
Замечания:
О. Этот класс довольно прост. Он имеет два элемента данных:
mutex_type* __m_; bool __owns_;
B. Этот класс находится в библиотеке общего назначения, которая будет использоваться неизвестным числом клиентов. В такой ситуации проблемы с производительностью являются высоким приоритетом. Мы не знаем, будут ли наши клиенты использовать этот класс в критичном для производительности коде или нет. Таким образом, мы должны предположить, что они есть.
C. Конструктор перемещения для этого класса будет состоять из небольшого количества загрузок и хранилищ, несмотря ни на что. Таким образом, хороший способ взглянуть на производительность - это посчитать нагрузки и запасы. Например, если вы делаете что-то с 4 магазинами, а кто-то другой делает то же самое только с двумя магазинами, обе ваши реализации очень быстрые. Но их в два раза быстрее, чем у вас! Эта разница может иметь решающее значение в тесной петле некоторых клиентов.
Сначала давайте посчитаем количество загрузок и сохранений в конструкторе по умолчанию и в функции обмена членами:
// 2 stores
unique_lock()
: __m_(nullptr),
__owns_(false)
{
}
// 4 stores, 4 loads
void swap(unique_lock& __u)
{
std::swap(__m_, __u.__m_);
std::swap(__owns_, __u.__owns_);
}
Теперь давайте реализуем конструктор перемещения двумя способами:
// 4 stores, 2 loads
unique_lock(unique_lock&& __u)
: __m_(__u.__m_),
__owns_(__u.__owns_)
{
__u.__m_ = nullptr;
__u.__owns_ = false;
}
// 6 stores, 4 loads
unique_lock(unique_lock&& __u)
: unique_lock()
{
swap(__u);
}
Первый способ выглядит намного сложнее, чем второй. И исходный код больше и несколько дублирует код, который мы, возможно, уже написали в другом месте (скажем, в операторе присваивания перемещения). Это означает, что есть больше шансов для ошибок.
Второй способ проще и использует код, который мы уже написали. Таким образом меньше вероятность ошибок.
Первый способ быстрее. Если стоимость грузов и магазинов примерно одинакова, возможно, на 66% быстрее!
Это классический инженерный компромисс. Там нет бесплатного обеда. И инженеры никогда не освобождаются от необходимости принимать решения о компромиссах. В одну минуту самолеты начинают выпадать из воздуха, а атомные станции начинают таять.
Для libC++ я выбрал более быстрое решение. Мое обоснование состоит в том, что для этого класса я лучше пойму это правильно, несмотря ни на что; класс достаточно прост, поэтому мои шансы сделать все правильно; и мои клиенты будут ценить производительность. Я мог бы прийти к другому выводу для другого класса в другом контексте.
Речь идет о безопасности исключений. поскольку __u
уже создан, когда вызывается оператор, мы знаем, что нет никаких исключений, и swap
не бросает.
Если бы вы выполняли назначения членов вручную, вы рискуете, что каждое из них может вызвать исключение, и тогда вам придется иметь дело с тем, что что-то было частично назначено для перемещения, но нужно было спасти.
Может быть, в этом тривиальном примере это не показано, но это общий принцип разработки:
- Копирование-назначение путем копирования-конструирования и обмена.
- Move-назначить с помощью move-construct и swap.
- Написать
+
с точки зрения конструкции и+=
, так далее.
По сути, вы пытаетесь минимизировать количество "реального" кода и пытаетесь выразить как можно больше других функций в терминах основных функций.
(The unique_ptr
принимает явную ссылку на rvalue в присваивании, потому что оно не разрешает создание / присваивание копии, поэтому это не лучший пример этого принципа проектирования.)
Еще одна вещь, чтобы рассмотреть относительно компромисса:
Реализация default-construct + swap может показаться медленнее, но иногда анализ потока данных в компиляторе может устранить некоторые бессмысленные назначения и в конечном итоге будет очень похож на рукописный код. Это работает только для типов без "умной" семантики значения. В качестве примера,
struct Dummy
{
Dummy(): x(0), y(0) {} // suppose we require default 0 on these
Dummy(Dummy&& other): x(0), y(0)
{
swap(other);
}
void swap(Dummy& other)
{
std::swap(x, other.x);
std::swap(y, other.y);
text.swap(other.text);
}
int x, y;
std::string text;
}
сгенерированный код в Move Ctor без оптимизации:
<inline std::string() default ctor>
x = 0;
y = 0;
temp = x;
x = other.x;
other.x = temp;
temp = y;
y = other.y;
other.y = temp;
<inline impl of text.swap(other.text)>
Это выглядит ужасно, но анализ потока данных может определить, что он эквивалентен коду:
x = other.x;
other.x = 0;
y = other.y;
other.y = 0;
<overwrite this->text with other.text, set other.text to default>
Возможно, на практике компиляторы не всегда выдают оптимальную версию. Возможно, захочется поэкспериментировать с ним и взглянуть на сборку.
Существуют также случаи, когда подкачка лучше, чем присваивание, из-за "умной" семантики значения, например, если один из членов в классе является std::shared_ptr. Нет причин, по которым конструктор перемещения должен связываться с атомарным рефконтером.
Я отвечу на вопрос из заголовка: "Почему некоторые люди используют своп для заданий на перемещение?".
Основная причина использования swap
не предоставляет ничего кроме перемещения.
Из комментария Говарда Хиннанта:
В общем случае оператор назначения перемещения должен:
1. Уничтожьте видимые ресурсы (хотя, возможно, сохраните ресурсы деталей реализации).
Но в целом функция уничтожения / освобождения может дать сбой и вызвать исключение!
Вот пример:
class unix_fd
{
int fd;
public:
explicit unix_fd(int f = -1) : fd(f) {}
~unix_fd()
{
if(fd == -1) return;
if(::close(fd)) /* !!! call is failed! But we can't throw from destructor so just silently ignore....*/;
}
void close() // Our release-function
{
if(::close(fd)) throw system_error_with_errno_code;
}
};
Теперь давайте сравним две реализации перемещения-назначения:
// #1
void unix_fd::operator=(unix_fd &&o) // Can't be noexcept
{
if(&o != this)
{
close(); // !!! Can throw here
fd = o.fd;
o.fd = -1;
}
return *this;
}
а также
// #2
void unix_fd::operator=(unix_fd &&o) noexcept
{
std::swap(fd, o.fd);
return *this;
}
#2
совершенно нет исключений!
Да, close()
звонок может быть отложен в случае #2
, Но! Если мы хотим строгой проверки ошибок, мы должны использовать явный close()
вызов, а не деструктор. Деструктор освобождает ресурс только в "чрезвычайных" ситуациях, когда исключение не может быть выброшено.
PS Смотрите также обсуждение здесь в комментариях