Когда на самом деле вызывается конструктор перемещения, если у нас есть (N)RVO?

Я понял из нескольких вопросов здесь о SO, что (N)RVO предотвращает вызов конструктора перемещения, когда объект возвращается по значению. Классический пример:

struct Foo {
  Foo()            { std::cout << "Constructed\n"; }
  Foo(const Foo &) { std::cout << "Copy-constructed\n"; }
  Foo(Foo &&)      { std::cout << "Move-constructed\n"; }
  ~Foo()           { std::cout << "Destructed\n"; }
};

Foo makeFoo() {
  return Foo();
}

int main() { 
  Foo foo = makeFoo(); // Move-constructor would be called here without (N)RVO
}

Выход с (N)RVO включен:

Constructed
Destructed

Так в каких случаях будет вызываться конструктор перемещения, независимо от наличия (N)RVO? Не могли бы вы привести несколько примеров? Другими словами: почему я должен заботиться о реализации конструктора перемещения, если (N)RVO по умолчанию выполняет свою работу по оптимизации?

1 ответ

Решение

Во-первых, вы должны убедиться, что Foo следует правилу три / пять и имеет операторы перемещения / копирования. И хорошей практикой для оператора перемещения и назначения перемещения является noexcept:

struct Foo {
  Foo()                           { std::cout << "Constructed\n"; }
  Foo(const Foo &)                { std::cout << "Copy-constructed\n"; }
  Foo& operator=(const Foo&)      { std::cout << "Copy-assigned\n"; return *this; }
  Foo(Foo &&)            noexcept { std::cout << "Move-constructed\n"; }
  Foo& operator=(Foo &&) noexcept { std::cout << "Move-assigned\n"; return *this; }

  ~Foo()                    { std::cout << "Destructed\n"; }
};

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

(N) RVO только для возвращаемых значений функции. Это не относится, например, к параметрам функции. Конечно, компилятор может применять любые оптимизации, которые ему нравятся, в соответствии с правилом "как будто", поэтому мы должны быть осторожны при создании тривиальных примеров.

Параметры функции

Есть много случаев, когда будет вызываться конструктор перемещения или оператор присваивания. Но простой случай, если вы используете std::move передать владение функции, которая принимает параметр по значению или по rvalue-reference:

void takeFoo(Foo foo) {
  // use foo...
}

int main() { 
  Foo foo = makeFoo();

  // set data on foo...

  takeFoo(std::move(foo));
}

Выход:

Constructed
Move-constructed
Destructed
Destructed

Для использования в стандартных контейнерах библиотеки

Очень полезный случай для Move-конструктора, если у вас есть std::vector<Foo>, Как и ты push_back объекты в контейнер, которые ему иногда приходится перераспределять и перемещать все существующие объекты в новую память. Если есть допустимый Move-конструктор, доступный на Foo он будет использовать его вместо копирования:

int main() { 
  std::vector<Foo> v;
  std::cout << "-- push_back 1 --\n";
  v.push_back(makeFoo());
  std::cout << "-- push_back 2 --\n";
  v.push_back(makeFoo());
}

Выход:

-- push_back 1 --
Constructed
Move-constructed  <-- move new foo into container
Destructed        
-- push_back 2 --
Constructed
Move-constructed  <-- move existing foo to new memory
Move-constructed  <-- move new foo into container
Destructed
Destructed
Destructed
Destructed

Списки инициализатора элементов конструктора

Я считаю, что конструкторы перемещения полезны в списках инициализаторов членов конструктора. Скажи, у тебя есть класс FooHolder который содержит Foo, Затем вы можете определить конструктор, который принимает Foo by-value и перемещает его в переменную-член:

class FooHolder {
  Foo foo_;
public:
  FooHolder(Foo foo) : foo_(std::move(foo)) {} 
};

int main() { 
  FooHolder fooHolder(makeFoo());
}

Выход:

Constructed
Move-constructed
Destructed
Destructed

Это хорошо, потому что позволяет мне определить конструктор, который принимает lvalue или rvalues ​​без ненужных копий.

Случаи поражения NVRO

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

Foo makeFoo(double value) {
  Foo f1;
  Foo f2;
  if (value > 0.5)
    return f1;
  return f2;
}

Foo foo = makeFoo(value);

Выход:

Constructed
Constructed
Move-constructed
Destructed
Destructed
Destructed

Или, если возвращаемая переменная также является параметром функции:

Foo appendToFoo(Foo foo) {

  // append to foo...

  return foo;
}

int main() { 
  Foo f1;
  Foo f2 = appendToFoo(f1);
}

Выход:

Constructed
Copy-constructed
Move-constructed
Destructed
Destructed
Destructed

Оптимизация сеттеров для значений

Один случай для оператора присваивания перемещения - это если вы хотите оптимизировать установщик для значений r. Скажи, что у тебя есть FooHolder который содержит Foo и ты хочешь setFoo функция-член. Тогда, если вы хотите оптимизировать как lvalues, так и rvalues, у вас должно быть две перегрузки. Один, который принимает ссылку на const, а другой - на rvalue-ссылку:

class FooHolder {
  Foo foo_;
public:
  void setFoo(const Foo& foo) { foo_ = foo; }
  void setFoo(Foo&& foo) { foo_ = std::move(foo); }
};

int main() { 
  FooHolder fooHolder;  
  Foo f;
  fooHolder.setFoo(f);  // lvalue
  fooHolder.setFoo(makeFoo()); // rvalue
}

Выход:

Constructed
Constructed
Copy-assigned  <-- setFoo with lvalue
Constructed
Move-assigned  <-- setFoo with rvalue
Destructed
Destructed
Destructed
Другие вопросы по тегам