resueltos resolucion programacion poo orientada operador objetos ejercicios ejemplos ejemplo constructores codigo clases ambito c++ language-lawyer move-semantics perfect-forwarding copy-elision

c++ - resolucion - ¿Por qué no se mueve el constructor de movimiento siempre que sea posible con las funciones `make_x()`?



operador de resolucion de ambito c++ (3)

Debido a que en la expresión X(std::forward<T>(arg)) , incluso si, en el último caso, arg es una referencia vinculada a un temporal, aún no es un temporal. Dentro del cuerpo de la función, el compilador no puede garantizar que arg no esté enlazado a un valor l. Considere lo que sucedería si el constructor de movimientos se eliminara y usted realizaría esta llamada:

auto x4 = make_X(std::move(x2));

x4 se convertiría en un alias para x2 .

Las reglas para la elección de movimiento del valor de retorno se describen en [class.copy]/32 :

[...] Esta elision de operaciones de copia / movimiento, llamada elision de copia, se permite en las siguientes circunstancias (que pueden combinarse para eliminar copias múltiples):

  • en una declaración de retorno en una función con un tipo de retorno de clase, cuando la expresión es el nombre de un objeto automático no volátil (que no sea una función o un parámetro de cláusula catch) con el mismo tipo no calificado cv que el tipo de retorno de función, la operación de copiar / mover se puede omitir construyendo el objeto automático directamente en el valor de retorno de la función

  • cuando un objeto de clase temporal que no se ha vinculado a una referencia ([class.temporary]) se copiaría / movería a un objeto de clase con el mismo tipo no calificado de CV, la operación de copiar / mover se puede omitir construyendo el objeto temporal directamente en el objetivo de la copia / movimiento omitido

En la llamada make_X(X(1)) elision de copia ocurre realmente, pero solo una vez:

  1. La primera X (1) crea un temporal que está vinculado a arg .
  2. Entonces X(std::forward<T>(arg)) invoca el constructor de movimientos. arg no es temporal, por lo que la segunda regla anterior no se aplica.
  3. Luego, el resultado, la expresión X(std::forward<T>(arg)) también se debe mover para construir el valor de retorno, pero este movimiento se elimina.

Acerca de su ACTUALIZACIÓN, std::forward causa la materialización de la X(1) temporal que está vinculada a un xvalue: el retorno de std::forward . Este xvalue devuelto no es temporal, por lo que copiar / elision ya no es aplicable.

De nuevo, ¿qué pasaría en este caso si se produjera el movimiento? (La gramática c ++ no es contextual):

auto x7 = std::forward<X>(std::move(x2));

Nota: Después de haber visto una nueva respuesta sobre C ++ 17, quería agregar confusión.

En C ++ 17, la definición de prvalue es la que cambió que ya no hay ningún constructor de movimientos para elegir dentro de su código de ejemplo. Aquí el ejemplo del código de resultado de GCC con la opción fno-elide-constructors en C ++ 14 y luego en C ++ 17:

#c++ -std=c++14 -fno-elide-constructors | #c++ -std=c++17 -fno-elide-constructors main: | main: sub rsp, 24 | sub rsp, 24 mov esi, 1 | mov esi, 1 lea rdi, [rsp+15] | lea rdi, [rsp+12] call X::X(int) | call X::X(int) lea rsi, [rsp+15] | lea rdi, [rsp+13] lea rdi, [rsp+14] | mov esi, 1 call X::X(X&&) | call X::X(int) lea rsi, [rsp+14] | lea rdi, [rsp+15] lea rdi, [rsp+11] | mov esi, 1 call X::X(X&&) | call X::X(int) lea rdi, [rsp+14] | lea rsi, [rsp+15] mov esi, 1 | lea rdi, [rsp+14] call X::X(int) | call X::X(X&&) lea rsi, [rsp+14] | xor eax, eax lea rdi, [rsp+15] | add rsp, 24 call X::X(X&&) | ret lea rsi, [rsp+15] lea rdi, [rsp+12] call X::X(X&&) lea rdi, [rsp+13] mov esi, 1 call X::X(int) lea rsi, [rsp+13] lea rdi, [rsp+15] call X::X(X&&) lea rsi, [rsp+15] lea rdi, [rsp+14] call X::X(X&&) lea rsi, [rsp+14] lea rdi, [rsp+15] call X::X(X&&) xor eax, eax add rsp, 24 ret

No puedo entender por qué en el último caso se llama al constructor de movimientos cuando está habilitada la opción de copia (o incluso es obligatoria, como en C ++ 17):

class X { public: X(int i) { std::clog << "converting/n"; } X(const X &) { std::clog << "copy/n"; } X(X &&) { std::clog << "move/n"; } }; template <typename T> X make_X(T&& arg) { return X(std::forward<T>(arg)); } int main() { auto x1 = make_X(1); // 1x converting ctor invoked auto x2 = X(X(1)); // 1x converting ctor invoked auto x3 = make_X(X(1)); // 1x converting and 1x move ctor invoked }

¿Qué reglas obstaculizan el movimiento del constructor para ser elidido en este caso?

ACTUALIZAR

Tal vez casos más sencillos cuando se llaman constructores de movimiento:

X x4 = std::forward<X>(X(1)); X x5 = static_cast<X&&>(X(1));


Los dos casos son sutilmente diferentes, y es importante entender por qué. Con la nueva semántica de valores en C ++ 17, la idea básica es que demoramos el proceso de convertir los valores en objetos el mayor tiempo posible.

template <typename T> X make_X(T&& arg) { return X(std::forward<T>(arg)); } int main() { auto x1 = make_X(1); auto x2 = X(X(1)); auto x3 = make_X(X(1)); }

Para x1 , la primera expresión que tenemos del tipo X es la que está en el cuerpo de make_X , que básicamente es el return X(1) . Eso es un prvalue de tipo X Estamos inicializando el objeto de retorno de make_X con ese prvalue, y luego make_X(1) es en sí mismo un prvalue de tipo X , por lo que estamos retrasando la materialización. Inicializar un objeto de tipo T partir de un prvalor de tipo T significa inicializar directamente desde el inicializador , por lo que auto x1 = make_X(1) reduce a solo X x1(1) .

Para x2 , la reducción es aún más simple, solo aplicamos directamente la regla.

Para x3 , el escenario es diferente. Tenemos un prvalue de tipo X anterior (el argumento X(1) ) y ese prvalue se une a una referencia. En el momento de la vinculación, aplicamos la conversión de materialización temporal , lo que significa que en realidad creamos un objeto temporal . Ese objeto luego se mueve al objeto de retorno, y podemos hacer una reducción de valor en la expresión subsiguiente hasta el final. Así que esto se reduce básicamente a:

X __tmp(1); X x3(std::move(__tmp));

Todavía tenemos un movimiento, pero solo uno (podemos evitar movimientos encadenados). Es el enlace a una referencia que requiere la existencia de un objeto X separado. El argumento arg y el objeto de retorno de make_X deben ser objetos diferentes, lo que significa que debe ocurrir un movimiento.

Para los dos últimos casos:

X x4 = std::forward<X>(X(1)); X x5 = static_cast<X&&>(X(1));

En ambos casos, estamos vinculando una referencia a un prvalue, que nuevamente requiere la conversión temporal de materialización. Y luego, en ambos casos, el inicializador es un xvalor, por lo que no obtenemos la reducción del prvalor, solo tenemos que mover la construcción del xvalor que era un objeto temporal materializado de un prvalor.


Para simplificar tu ejemplo:

auto x1 = make_X(1); // converting auto x2 = X(X(1)); // converting auto x4 = X(std::forward<X>(X(1))); // converting + move

De la documentación de elision de copia de cppreference (énfasis mío):

Antes de c ++ 17:

Bajo las siguientes circunstancias, los compiladores están permitidos, pero no están obligados a omitir la construcción de objetos de clase (copiar y mover) (desde C ++ 11) ...

  • Si una función devuelve un tipo de clase por valor , y la expresión de la declaración de retorno es el nombre de un objeto no volátil con duración de almacenamiento automático, que no es un parámetro de función o un parámetro de cláusula catch, y que tiene el mismo tipo ( ignorando la calificación cv de nivel superior) como el tipo de retorno de la función, luego se omite copiar / mover (desde C ++ 11). Cuando ese objeto local se construye, se construye directamente en el almacenamiento donde, de lo contrario, el valor de retorno de la función se movería o copiaría. Esta variante de elision de copia se conoce como NRVO, "optimización de valor de retorno denominada".

Desde c ++ 17:

En las siguientes circunstancias, los compiladores deben omitir la construcción de copiar y mover ...

a) En la inicialización, si la expresión de inicialización es un prvalue y la versión no calificada de cv del tipo de origen es la misma clase que la clase de destino, la expresión de inicializador se usa para inicializar el objeto de destino:

T x = T(T(T())); // only one call to default constructor of T, to initialize x

b) En una llamada de función, si el operando de una instrucción de retorno es un prvalue y el tipo de retorno de la función es el mismo que el tipo de ese prvalue.

T f() { return T{}; } T x = f(); // only one call to default constructor of T, to initialize x T* p = new T(f()); // only one call to default constructor of T, to initialize *p

En cualquier caso, std::forward no cumple con los requisitos, ya que su resultado es un xvalue , no un prvalue : no devuelve el tipo de clase por valor. Por lo tanto, no ocurre la elisión.