Действительно ли std::move необходим в списке инициализации конструктора для тяжелых членов, передаваемых по значению?

Недавно я прочитал пример из cppreference.../vector/emplace_back:

struct President
{
    std::string name;
    std::string country;
    int year;

    President(std::string p_name, std::string p_country, int p_year)
        : name(std::move(p_name)), country(std::move(p_country)), year(p_year)
    {
        std::cout << "I am being constructed.\n";
    }

Мой вопрос: это std::move действительно нужно? Я хочу сказать, что этот p_name не используется в теле конструктора, поэтому, может быть, в языке есть какое-то правило использовать семантику перемещения для него по умолчанию?

Было бы очень неприятно добавлять std::move в список инициализации для каждого тяжелого члена (например, std::string, std::vector). Представьте себе сотни проектов KLOC, написанных на C++03 — будем везде добавлять этот std::move?

Этот вопрос: move-constructor-and-initialization-list ответ говорит:

Согласно золотому правилу, всякий раз, когда вы берете что-то по ссылке rvalue, вам нужно использовать это внутри std::move, а всякий раз, когда вы берете что-то по универсальной ссылке (т. е. выведенный шаблонный тип с помощью &&), вам нужно использовать это внутри std:: вперед

Но я не уверен: передача по значению - это скорее не универсальная ссылка?

[ОБНОВЛЕНИЕ]

Чтобы мой вопрос был более понятен. Можно ли рассматривать аргументы конструктора как XValue - я имею в виду значения с истекающим сроком действия?

В этом примере, насколько я знаю, мы не используем std::move:

std::string getName()
{
   std::string local = "Hello SO!";
   return local; // std::move(local) is not needed nor probably correct
}

Итак, нужно ли здесь:

void President::setDefaultName()
{
   std::string local = "SO";
   name = local; // std::move OR not std::move?
}

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


person PiotrNycz    schedule 29.05.2014    source источник
comment
Компилятор не может просто что-то придумывать (несмотря на то, что многие сообщения Stack OVerflow говорят о компиляторе). Он должен играть по правилам языка, которые очень специфичны для разрешения перегрузки.   -  person Kerrek SB    schedule 29.05.2014
comment
Это необходимо, потому что это lvalue. Вам нужно std::move, чтобы превратить их в rvalue и выбрать правильные перегрузки конструктора. См. это ">похожая запись. Компилятор может оптимизировать по правилу «как будто». Маловероятно, что он сделает это, изменив lvalue на rvalue!   -  person juanchopanza    schedule 29.05.2014
comment
@KerrekSB - смотрите мое обновление. Может ли компилятор принять переменные, использованные в последний раз, как xvalue?   -  person PiotrNycz    schedule 29.05.2014
comment
@PiotrNycz: Повторюсь: компилятор не может делать предположения. Он должен реализовать язык, который очень четко определяет правила.   -  person Kerrek SB    schedule 29.05.2014
comment
@PiotrNycz, образцы, которые вы упомянули в своем посте, определенно распознаются современными компиляторами. Первый случай является традиционным RVO, второй устранит избыточную локальную переменную и назначит ее непосредственно имени (в то же время литерал SO по-прежнему будет размещаться в разделе константных объектов).   -  person Yury Schkatula    schedule 29.05.2014
comment
@KerrekSB Хорошо, я понял твою точку зрения. Однако для меня гипотетическое правило Если переменная используется в качестве источника для копирования, и это последний раз, когда переменная используется - то вместо копирования используется перемещение, было бы вполне понятным для компиляторов. Мое текущее понимание состоит в том, что такого правила нет, и оно должно быть предложено, если оно вообще имеет смысл...   -  person PiotrNycz    schedule 29.05.2014
comment
Чтобы с этим чего-то добиться, вам нужно перестать говорить о компиляторе и вместо этого подумать, какое языковое правило поможет с этой оптимизацией...   -  person Kerrek SB    schedule 29.05.2014
comment
Представьте сотни проектов KLOC, написанных на C++03. Конечно, они все равно будут написаны с использованием pass-by-const-ref? Если вы переходите на передачу по значению, вы можете добавить ходы одновременно.   -  person Useless    schedule 29.05.2014


Ответы (4)


Мой вопрос: действительно ли нужен этот std::move? Я хочу сказать, что компилятор видит, что это p_name не используется в теле конструктора, так что, может быть, есть какое-то правило использовать для него семантику перемещения по умолчанию?

В общем, если вы хотите преобразовать lvalue в rvalue, тогда да, вам нужен std::move(). См. также превращают ли компиляторы C++11 локальные переменные в rvalue, когда это возможно, во время оптимизации кода?

void President::setDefaultName()
{
   std::string local = "SO";
   name = local; // std::move OR not std::move?
}

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

Здесь я бы хотел, чтобы оптимизатор удалил лишнее local ВСЕГДА; к сожалению, на практике это не так. Оптимизация компилятора усложняется, когда в игру вступает куча памяти, см. Основной доклад BoostCon 2013: Чендлер Каррут: Оптимизация возникающих структур C++. Один из моих выводов из выступления Чандлера заключается в том, что оптимизаторы просто склонны сдаваться, когда дело доходит до памяти, выделенной кучей.

См. код ниже для разочаровывающего примера. Я не использую std::string в этом примере, потому что это сильно оптимизированный класс со встроенным ассемблерным кодом, который часто приводит к нелогичному сгенерированному коду. Чтобы добавить оскорбления к оскорблению, std::string грубо говоря, общий указатель с подсчетом ссылок в gcc 4.7.2, по крайней мере (оптимизация копирования при записи, теперь запрещенная стандартом 2011 года для std::string). Итак, пример кода без std::string:

#include <algorithm>
#include <cstdio>

int main() {
   char literal[] = { "string literal" };
   int len = sizeof literal;
   char* buffer = new char[len];
   std::copy(literal, literal+len, buffer);
   std::printf("%s\n", buffer);
   delete[] buffer;
}

Очевидно, согласно правилу "как если бы" сгенерированный код может быть оптимизирован для этого:

int main() {
   std::printf("string literal\n");
}

Я пробовал это с GCC 4.9.0 и Clang 3.5 с включенной оптимизацией времени компоновки (LTO), и ни один из них не смог оптимизировать код до этого уровня. Я посмотрел на сгенерированный ассемблерный код: они оба выделили память в куче и сделали копию. Ну да, это разочаровывает.

Однако стек выделенной памяти отличается:

#include <algorithm>
#include <cstdio>

int main() {
   char literal[] = { "string literal" };
   const int len = sizeof literal;
   char buffer[len];
   std::copy(literal, literal+len, buffer);
   std::printf("%s\n", buffer);
}

Я проверил ассемблерный код: здесь компилятор может сократить код до основного просто std::printf("string literal\n");.

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

Представьте себе сотни KLOC-проектов, написанных на C++03 — везде ли мы будем добавлять этот std::move?
[...]
Но я не уверен: передача по значению — это не универсальная ссылка?

"Хотите скорость? Измерьте." (от Говард Хиннант)

Вы можете легко оказаться в ситуации, когда вы делаете свои оптимизации только для того, чтобы узнать, что ваши оптимизации сделали код медленнее. :( Мой совет такой же, как и у Говарда Хиннанта: Измеряйте.

std::string getName()
{
   std::string local = "Hello SO!";
   return local; // std::move(local) is not needed nor probably correct
}

Да, но у нас есть правила для этого особого случая: это называется оптимизацией именованного возвращаемого значения (NRVO).

person Ali    schedule 29.05.2014
comment
Может быть, может быть, в конкретном случае, который вы показываете, такую ​​оптимизацию можно было бы реализовать по правилу «как если бы». -- Не может, если существует какой-либо способ, с помощью которого программа может обнаружить разницу, в том числе незаметно, например, определить собственный глобальный operator new и подсчитать, сколько раз он вызывается. И я думаю, что программа может сделать это. - person ; 29.05.2014
comment
@hvd Интересно. Требует ли стандарт вызова operator new? Я смотрю сейчас на более простые примеры и вижу, что std::string невероятно оптимизирован и я часто не понимаю, почему я получаю ту сборку, которую получаю. Часто контринтуитивно. - person Ali; 29.05.2014
comment
Вы правы в том, что это явно не предписывается, но я не вижу, как в противном случае можно выполнить требования стандарта (во всяком случае, для строк длиннее sizeof(std::string)). Кстати, насчет вашего последнего раза, когда я проверял, реализация GCC была методом копирования при записи: вы правы, и разработчики GCC рассматривают это как ошибку. Ведется работа над новой реализацией, которая не использует COW, и эта новая реализация будет использоваться как std::string в будущем. - person ; 29.05.2014
comment
@hvd Не могли бы вы дать отзыв о правильности моего обновленного ответа, пожалуйста? - person Ali; 29.05.2014
comment
Интересный пример, но у него тоже есть проблема, заключающаяся в том, что operator new[] и operator delete[] могут иметь пользовательские реализации (редактировать: вы упоминаете LTO, а с LTO должно быть возможно, чтобы компилятор знал что это не так, но я не знаю, насколько это было бы тяжело). Кроме того, хотя printf оптимизирован для puts, компилятор не знает о внутреннем устройстве puts и не может исключить возможность того, что puts освободит указанную память и выйдет из программы без возврата, тогда как освобождение указанной памяти не удастся. Так что… оптимизировать сложно. - person ; 29.05.2014
comment
@hvd Мой вывод из выступления Чендлера заключается в том, что оптимизаторы просто склонны сдаваться, когда дело доходит до памяти, выделенной в куче. Не совсем уверен, почему, но я не автор компилятора. Что касается puts: Действительно ли Clang такой умный? хотя это для памяти, выделенной стеком. Я думаю, что компилятор мог бы знать немного больше о функциях в стандартной библиотеке (например, могут быть аннотации, специфичные для реализации, чтобы сообщить компилятору, что puts не будет освобождать указанную память и т. д.). В любом случае, спасибо за проверку! - person Ali; 29.05.2014
comment
Да, это правильно. Когда я написал, что компилятор не знает puts, я имел в виду именно это. Действительно, компилятор можно модифицировать, чтобы получить такие знания. Что касается вашей другой ссылки, я думаю, что был очень недавний вопрос, который показал, что на самом деле clang не очень умный: он неправильно применяет эту оптимизацию и генерирует неверный код в некоторых случаи. Я вижу, что это связано с вопросом, который вы нашли. - person ; 29.05.2014
comment
@hvd Хм, вы имеете в виду этот вопрос: Почему Clang оптимизирует этот код? Я думаю, что нет, потому что это более ранний вопрос, чем связанный. В любом случае, дайте мне знать, если найдете. - person Ali; 29.05.2014
comment
Да, именно его я и имел в виду. Это показывает, что та же самая оптимизация, которая действительно иногда работает, в том числе в Clang, действительно ли это умно? вопрос, терпит неудачу в других случаях. - person ; 29.05.2014
comment
@hvd Да, но это ошибка оптимизатора; это было подтверждено разработчиками clang. - person Ali; 29.05.2014
comment
@Ali: Моя интерпретация заключалась в том, что operator new считается имеющим побочные эффекты (а именно, выделение виртуальной и, возможно, физической памяти), и в этом случае компиляторам запрещено оптимизировать его. Это, безусловно, объясняет, почему они все сдаются. - person Mooing Duck; 29.05.2014
comment
@MooingDuck Да, это объясняет, почему buffer не устранено в моем примере. Однако, как я вижу, в целом оптимизатор имеет тенденцию сдаваться и не отслеживать, что происходит, когда речь идет о памяти в куче. Таким образом, он не пытается устранить хотя бы лишнюю копию. Не могли бы вы дать мне какую-нибудь ссылку, пожалуйста: где в стандарте указано, что компилятору не разрешено оптимизировать вызовы operator new / delete? - person Ali; 29.05.2014
comment
@MooingDuck Я должен обдумать это: если компилятору не разрешено оптимизировать вызовы operator new, то невозможно молча превратить копии в перемещения, поскольку это неявно означало бы оптимизацию вызовов operator new. - person Ali; 29.05.2014
comment
это будет неявно означать оптимизацию operator new вызовов, например, в случае std::vector. - person Ali; 29.05.2014
comment
@Ali: в спецификации не говорится, что компилятор не может оптимизировать operator new специально, но: в 1.9 есть примечание: фактическая реализация не должна оценивать часть выражения, если она может сделать вывод, что его значение не используется и что нет стороны создаются эффекты, влияющие на наблюдаемое поведение программы. Вопрос в том, есть ли у new побочные эффекты или нет. Я думаю, что да, но спецификация не говорит ни так, ни иначе. Это может быть даже определено реализацией. - person Mooing Duck; 29.05.2014
comment
Этот вопрос говорит, что реализация определена, если operator new имеет побочные эффекты. - person Mooing Duck; 29.05.2014
comment
@MooingDuck Интересно. Clang успешно оптимизирует строку delete[] new char[10];; gcc нет. Спасибо, сегодня узнал что-то новое! - person Ali; 29.05.2014
comment
Компиляторы обычно рассматривают функции без доступного определения как барьеры времени компиляции из-за неизвестных побочных эффектов. Вот почему он не может оптимизировать вызовы непрозрачных функций. - person Maxim Egorushkin; 03.06.2014

Текущее правило с поправками, внесенными DR1579, заключается в том, что преобразование xvalue происходит, когда локальная переменная или параметр с возможностью NRVO или id-выражение, ссылающееся на локальную переменную или параметр, является аргументом оператора return.

Это работает, потому что ясно, что после оператора return переменную нельзя использовать снова.

За исключением того, что это не так:

struct S {
    std::string s;
    S(std::string &&s) : s(std::move(s)) { throw std::runtime_error("oops"); }
};

S foo() {
   std::string local = "Hello SO!";
   try {
       return local;
   } catch(std::exception &) {
       assert(local.empty());
       throw;
   }
}

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

Не исключено, что стандарт можно изменить, указав, что «последнее» использование локальной переменной подлежит преобразованию xvalue; проблема заключается в том, чтобы определить, какое "последнее" использование является. И еще одна проблема заключается в том, что это имеет нелокальные эффекты внутри функции; добавление напр. оператор отладки ниже может означать, что преобразование xvalue, на которое вы полагались, больше не выполняется. Даже однократное правило не сработает, поскольку один оператор может выполняться несколько раз.

Возможно, вам будет интересно представить предложение для обсуждения в списке рассылки std-proposals?

person ecatmur    schedule 29.05.2014
comment
Пример основан на том факте, что конструктор S предлагает только базовую гарантию исключения. Если бы оператор return (включая преобразование в S) предлагал надежную гарантию, то local нельзя было бы выбросить и использовать позже. Вы знаете, это была позиция комитета относительно того, почему они считали правило безвредным, или я просто что-то заметил? - person Steve Jessop; 29.05.2014
comment
Боюсь, @SteveJessop намного раньше меня, но глядя на open-std.org/jtc1/sc22/wg21/docs/papers/2002/n1377.htm#Moving от локальных значений это, похоже, не рассматривалось (The auto-local в любом случае будет концептуально уничтожен). - person ecatmur; 29.05.2014

Мой вопрос: действительно ли нужен этот std::move? Я хочу сказать, что это p_name не используется в теле конструктора, так что, может быть, в языке есть какое-то правило использовать семантику перемещения для него по умолчанию?

Конечно нужен. p_name — это lvalue, поэтому std::move необходимо, чтобы превратить его в rvalue и выбрать конструктор перемещения.

Это не только то, что говорит язык — что, если тип будет таким:

struct Foo {
    Foo() { cout << "ctor"; }
    Foo(const Foo &) { cout << "copy ctor"; }
    Foo(Foo &&) { cout << "move ctor"; }
};

Язык предписывает, что copy ctor должно быть напечатано, если вы пропустите ход. Здесь нет вариантов. Компилятор не может сделать это по-другому.

Да, исключение копирования по-прежнему применяется. Но не в вашем случае (список инициализации), см. комментарии.


Или ваш вопрос касается почему мы используем этот шаблон?

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

Рассмотрим этот класс, который содержит две строки (т.е. два тяжелых объекта для копирования).

struct Foo {
     Foo(string s1, string s2)
         : m_s1{s1}, m_s2{s2} {}
private:
     string m_s1, m_s2;
};

Итак, давайте посмотрим, что происходит в различных сценариях.

Возьмите 1

string s1, s2; 
Foo f{s1, s2}; // 2 copies for passing by value + 2 copies in the ctor

Аргх, это плохо. Здесь происходит 4 копии, когда действительно нужны только 2. В C++03 мы бы немедленно превратили аргументы Foo() в константные ссылки.

Возьмите 2

Foo(const string &s1, const string &s2) : m_s1{s1}, m_s2{s2} {}

Теперь у нас есть

Foo f{s1, s2}; // 2 copies in the ctor

Это намного лучше!

Но как насчет ходов? Например, из временных:

string function();
Foo f{function(), function()}; // still 2 copies in the ctor

Или при явном перемещении lvalue в ctor:

Foo f{std::move(s1), std::move(s2)}; // still 2 copies in the ctor

Это не очень хорошо. Мы могли бы использовать команду перемещения string для непосредственной инициализации членов Foo.

Возьмите 3

Итак, мы могли бы ввести некоторые перегрузки для конструктора Foo:

Foo(const string &s1, const string &s2) : m_s1{s1}, m_s2{s2} {}
Foo(string &&s1, const string &s2) : m_s1{std::move(s1)}, m_s2{s2} {}
Foo(const string &s1, string &s2) : m_s1{s1}, m_s2{std::move(s2)} {}
Foo(string &&s1, string &&s2) : m_s1{std::move(s1)}, m_s2{std::move(s2)} {}

Итак, хорошо, теперь у нас есть

Foo f{function(), function()}; // 2 moves
Foo f2{s1, function()}; // 1 copy + 1 move

Хорошо. Но, черт возьми, мы получаем комбинаторный взрыв: каждый аргумент теперь должен появляться в своих вариантах const-ref + rvalue. Что, если мы получим 4 строки? Мы собираемся написать 16 ктор?

Возьми 4 (хороший)

Вместо этого давайте посмотрим на:

Foo(string s1, string s2) : m_s1{std::move(s1)}, m_s2{std::move(s2)} {}

С этой версией:

Foo foo{s1, s2}; // 2 copies + 2 moves
Foo foo2{function(), function()}; // 2 moves in the arguments + 2 moves in the ctor
Foo foo3{std::move(s1), s2}; // 1 copy, 1 move, 2 moves

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

И я даже не коснулся поверхности безопасности исключений.


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

{
// some code ...
std::string s = "123";

AClass obj {s};
OtherClass obj2 {s};
Anotherclass obj3 {s};

// s won't be touched any more from here on
}

Если я вас правильно понял, вам бы очень хотелось, чтобы компилятор фактически сдвинулся на s при последнем использовании:

{
// some code ...
std::string s = "123";

AClass obj {s};
OtherClass obj2 {s};
Anotherclass obj3 {std::move(s)}; // bye bye s

// s won't be touched any more from here on. 
// hence nobody will notice s is effectively in a "dead" state!
}

Я сказал вам, почему компилятор не может этого сделать, но я понял вашу точку зрения. С определенной точки зрения это имело бы смысл — бессмысленно заставлять s жить дольше, чем его последнее использование. Думаю, пища для размышлений о C++2x.

person peppe    schedule 29.05.2014
comment
Вопрос к: Язык предписывает, что copy ctor должен быть напечатан, если вы пропустите ход. Здесь нет вариантов. Компилятор не может сделать это по-другому. Что насчет копирования-исключения: en.wikipedia.org/wiki /Copy_elision? - person PiotrNycz; 29.05.2014
comment
Позвольте мне отметить мою точку зрения: способ анализа выражения никогда не вызовет перемещение-ctor (потому что передается lvalue). Перечитывая N3337 §12.8.31 (копия elision), это не применимо здесь: 1) это не return ; 2) это не throw ; 3) это не временное копирование/перемещение; 4) это не catch. - person peppe; 29.05.2014
comment
Разве в вашем примере с Take 2 нет нулевых ходов? std::move() в константную ссылку ничего не должен делать. - person Dan Olson; 30.11.2017
comment
Я верю в Take 3, еще есть над чем поработать для версий &&. См. stackoverflow.com/questions/31213539/ - person Teloze; 02.04.2021
comment
Вы абсолютно правы, моя вина за то, что я не исправила это раньше. Надеюсь, теперь это нормально. - person peppe; 03.04.2021

Я провел дополнительное расследование и запросил другие форумы в сети.

К сожалению, кажется, что этот std::move необходим не только потому, что так говорит стандарт C++, но и в противном случае это было бы опасно:

((кредит Kalle Olavi Niemitalo из comp.std.c++ - его ответ здесь))

#include <memory>
#include <mutex>
std::mutex m;
int i;
void f1(std::shared_ptr<std::lock_guard<std::mutex> > p);
void f2()
{
    auto p = std::make_shared<std::lock_guard<std::mutex> >(m);
    ++i;
    f1(p);
    ++i;
}

Если бы f1(p) автоматически сменилось на f1(std::move(p)), то мьютекс был бы разблокирован уже до второго ++i; утверждение.

Следующий пример кажется более реалистичным:

#include <cstdio>
#include <string>
void f1(std::string s) {}
int main()
{
    std::string s("hello");
    const char *p = s.c_str();
    f1(s);
    std::puts(p);
}

Если f1(s) автоматически изменится на f1(std::move(s)), то указатель p больше не будет действительным после возврата f1.

person PiotrNycz    schedule 16.06.2014