Почему некоторые люди используют своп для заданий на перемещение?

Например, 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;
    }

В общем случае оператор назначения перемещения должен:

  1. Уничтожьте видимые ресурсы (хотя, возможно, сохраните ресурсы деталей реализации).
  2. Переместить назначить все базы и члены.
  3. Если назначение перемещения баз и членов не сделало ресурс менее правым, сделайте это так.

Вот так:

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 Смотрите также обсуждение здесь в комментариях

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