Эффективное использование семантики перемещения вместе с (N)RVO

Допустим, я хочу реализовать функцию, которая должна обрабатывать объект и возвращать новый (возможно, измененный) объект. Я хотел бы сделать это максимально эффективно в C+11. Среда выглядит следующим образом:

class Object {
    /* Implementation of Object */
    Object & makeChanges();
};

Альтернативы, которые приходят мне в голову:

// First alternative:
Object process1(Object arg) { return arg.makeChanges(); }
// Second alternative:
Object process2(Object const & arg) { return Object(arg).makeChanges(); }
Object process2(Object && arg) { return std::move(arg.makeChanges()); }
// Third alternative:
Object process3(Object const & arg) { 
    Object retObj = arg; retObj.makeChanges(); return retObj; 
}
Object process3(Object && arg) { std::move(return arg.makeChanges()); }

Примечание: я хотел бы использовать функцию обтекания, как process() потому что он будет выполнять какую-то другую работу, и я хотел бы иметь как можно больше повторного использования кода.

Обновления:

Я использовал makeChanges() с данной подписью, потому что объекты, с которыми я имею дело, предоставляют методы с этим типом подписи. Я думаю, они использовали это для цепочки методов. Я также исправил две упомянутые синтаксические ошибки. Спасибо за указание на это. Я также добавил третий вариант, и я отвечу на вопрос ниже.

Опробовать их с помощью Clang [т.е. Object obj2 = process(obj);] приводит к следующему:

Первый вариант делает два вызова конструктора копирования; один для передачи аргумента и один для возврата. Можно вместо этого сказать return std::move(..) и один вызов конструктора копирования и один вызов конструктора перемещения. Я понимаю, что RVO не может избавиться от одного из этих вызовов, потому что мы имеем дело с параметром функции.

Во втором варианте у нас еще есть два вызова конструктора копирования. Здесь мы делаем один явный вызов и один выполняется при возврате. Я ожидал, что RVO включится и избавится от последнего, поскольку объект, который мы возвращаем, - это другой объект, чем аргумент. Однако этого не произошло.

В третьем варианте у нас есть только один вызов конструктора копирования, и это явный вызов. (N)RVO устраняет вызов конструктора копирования, который мы сделали бы для возврата.

Мои вопросы следующие:

  1. (ответил) Почему RVO пинает последний вариант, а не второй?
  2. Есть лучший способ сделать это?
  3. Если бы мы передали временные параметры, 2-й и 3-й варианты вызвали бы конструктор перемещения при возврате. Можно ли устранить это с помощью (N)RVO?

Спасибо!

3 ответа

Решение

Мне нравится измерять, поэтому я настроил это Object:

#include <iostream>

struct Object
{
    Object() {}
    Object(const Object&) {std::cout << "Object(const Object&)\n";}
    Object(Object&&) {std::cout << "Object(Object&&)\n";}

    Object& makeChanges() {return *this;}
};

И я предположил, что некоторые решения могут давать разные ответы для xvalues ​​и prvalues ​​(оба из которых являются rvalues). И поэтому я решил проверить их оба (в дополнение к lvalues):

Object source() {return Object();}

int main()
{
    std::cout << "process lvalue:\n\n";
    Object x;
    Object t = process(x);
    std::cout << "\nprocess xvalue:\n\n";
    Object u = process(std::move(x));
    std::cout << "\nprocess prvalue:\n\n";
    Object v = process(source());
}

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

#if PROCESS == 1

Object
process(Object arg)
{
    return arg.makeChanges();
}

#elif PROCESS == 2

Object
process(const Object& arg)
{
    return Object(arg).makeChanges();
}

Object
process(Object&& arg)
{
    return std::move(arg.makeChanges());
}

#elif PROCESS == 3

Object
process(const Object& arg)
{
    Object retObj = arg;
    retObj.makeChanges();
    return retObj; 
}

Object
process(Object&& arg)
{
    return std::move(arg.makeChanges());
}

#elif PROCESS == 4

Object
process(Object arg)
{
    return std::move(arg.makeChanges());
}

#elif PROCESS == 5

Object
process(Object arg)
{
    arg.makeChanges();
    return arg;
}

#endif

В таблице ниже приведены мои результаты (используется clang -std= C++11). Первое число - это число конструкций копирования, а второе - количество конструкций перемещения:

+----+--------+--------+---------+
|    | lvalue | xvalue | prvalue |    legend: copies/moves
+----+--------+--------+---------+
| p1 |  2/0   |  1/1   |   1/0   |
+----+--------+--------+---------+
| p2 |  2/0   |  0/1   |   0/1   |
+----+--------+--------+---------+
| p3 |  1/0   |  0/1   |   0/1   |
+----+--------+--------+---------+
| p4 |  1/1   |  0/2   |   0/1   |
+----+--------+--------+---------+
| p5 |  1/1   |  0/2   |   0/1   |
+----+--------+--------+---------+

process3 выглядит лучшим решением для меня. Однако это требует двух перегрузок. Один для обработки lvalues ​​и один для обработки rvalues. Если по какой-либо причине это проблематично, решения 4 и 5 выполняют работу только с одной перегрузкой, затрачивая на 1 дополнительное построение хода для glvalues ​​(lvalues ​​и xvalues). Это суждение о том, хочет ли кто-то платить дополнительную конструкцию хода, чтобы избежать перегрузки (и нет единственно правильного ответа).

(ответил) Почему RVO пинает последний вариант, а не второй?

Чтобы RVO включился, оператор return должен выглядеть следующим образом:

return arg;

Если вы усложните это с:

return std::move(arg);

или же:

return arg.makeChanges();

тогда RVO блокируется.

Есть лучший способ сделать это?

Мои любимые р3 и р5. Я предпочитаю p5, а не p4, это просто стилистика. Я уклоняюсь от сдачи move на return заявление, когда я знаю, что оно будет применено автоматически из-за страха случайного блокирования RVO. Однако в p5 RVO в любом случае не является опцией, хотя оператор return действительно получает неявный ход. Таким образом, p5 и p4 действительно эквивалентны. Выбери свой стиль.

Если бы мы передали временные параметры, 2-й и 3-й варианты вызвали бы конструктор перемещения при возврате. Можно ли устранить это с помощью (N)RVO?

Столбец "prvalue" против столбца "xvalue" решает этот вопрос. Некоторые решения добавляют дополнительную конструкцию move для xvalues, а некоторые - нет.

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

makeChanges возвращает Object&, Следовательно, оно должно быть скопировано в значение, поскольку вы его возвращаете. Таким образом, первые два всегда делают копию возвращаемого значения. С точки зрения количества копий, первая делает две копии (одна для параметра, одна для возвращаемого значения). Второй делает две копии (одна явно в функции, одна для возвращаемого значения.

Третий не должен даже компилироваться, так как вы не можете неявно преобразовать ссылку на l-значение в ссылку на r-значение.

Так что на самом деле, не делай этого. Если вы хотите передать объект и изменить его на месте, просто сделайте это:

Object &process1(Object &arg) { return arg.makeChanges(); }

Это изменяет предоставленный объект. Нет копирования или что-нибудь. Конечно, можно задаться вопросом, почему process1 не функция-член или что-то, но это не имеет значения.

Самый быстрый способ сделать это - если аргумент равен lvalue, затем скопировать его и вернуть это значение, если rvalue, а затем переместить его. Возврат всегда можно переместить или применить RVO/NRVO. Это легко сделать.

Object process1(Object arg) {
    return std::move(arg.makeChanges());
}

Это очень похоже на канонические формы C++11 многих видов перегрузок операторов.

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