¿Qué es la semántica del movimiento?

Mi primera respuesta fue una introducción extremadamente simplificada a la semántica del movimiento, y muchos detalles fueron omitidos a propósito para mantenerla simple.Sin embargo, hay mucho más en la semántica del movimiento, y pensé que era el momento de una segunda respuesta para llenar los vacíos.La primera respuesta ya es bastante vieja, y no me pareció correcto simplemente reemplazarla con un texto completamente diferente. Creo que sigue siendo útil como primera introducción. Pero si quieres profundizar, sigue leyendo 🙂

Stephan T. Lavavej se ha tomado el tiempo de aportar sus valiosos comentarios. Muchas gracias, Stephan!

Introducción

La semántica del movimiento permite que un objeto, bajo ciertas condiciones, se apropie de los recursos externos de algún otro objeto. Esto es importante de dos maneras:

  1. Convirtiendo copias caras en movimientos baratos. Ver mi primera respuesta para un ejemplo. Tenga en cuenta que si un objeto no maneja al menos un recurso externo (ya sea directamente, o indirectamente a través de sus objetos miembros), la semántica de movimiento no ofrecerá ninguna ventaja sobre la semántica de copia. En ese caso, copiar un objeto y moverlo significa exactamente lo mismo:

    class cannot_benefit_from_move_semantics{ int a; // moving an int means copying an int float b; // moving a float means copying a float double c; // moving a double means copying a double char d; // moving a char array means copying a char array // ...};
  2. Implementar tipos seguros «sólo para mover»; es decir, tipos para los que copiar no tiene sentido, pero mover sí. Algunos ejemplos son los bloqueos, los manejadores de archivos y los punteros inteligentes con semántica de propiedad única. Nota: Esta respuesta discute std::auto_ptr, una plantilla obsoleta de la biblioteca estándar C++98, que fue reemplazada por std::unique_ptr en C++11. Los programadores intermedios de C++ probablemente están al menos algo familiarizados con std::auto_ptr, y debido a la «semántica de movimiento» que muestra, parece un buen punto de partida para discutir la semántica de movimiento en C++11. YMMV.

¿Qué es un movimiento?

La biblioteca estándar de C++98 ofrece un puntero inteligente con una semántica de propiedad única llamada std::auto_ptr<T>. En caso de que no estés familiarizado con auto_ptr, su propósito es garantizar que un objeto asignado dinámicamente sea siempre liberado, incluso ante excepciones:

{ std::auto_ptr<Shape> a(new Triangle); // ... // arbitrary code, could throw exceptions // ...} // <--- when a goes out of scope, the triangle is deleted automatically

Lo inusual de auto_ptr es su comportamiento de «copia»:

auto_ptr<Shape> a(new Triangle); +---------------+ | triangle data | +---------------+ ^ | | | +-----|---+ | +-|-+ |a | p | | | | | +---+ | +---------+auto_ptr<Shape> b(a); +---------------+ | triangle data | +---------------+ ^ | +----------------------+ | +---------+ +-----|---+ | +---+ | | +-|-+ |a | p | | | b | p | | | | | +---+ | | +---+ | +---------+ +---------+

Nota cómo la inicialización de b con a no copia el triángulo, sino que transfiere la propiedad del triángulo de a a b. También decimos «a se traslada a b» o «el triángulo se traslada de a a b«. Esto puede sonar confuso porque el propio triángulo siempre permanece en el mismo lugar de la memoria.

Mover un objeto significa transferir la propiedad de algún recurso que gestiona a otro objeto.

El constructor de copia de auto_ptr probablemente se parece a esto (algo simplificado):

auto_ptr(auto_ptr& source) // note the missing const{ p = source.p; source.p = 0; // now the source no longer owns the object}

Movimientos peligrosos e inofensivos

Lo peligroso de auto_ptr es que lo que sintácticamente parece una copia es en realidad un movimiento. Intentar llamar a una función miembro en un auto_ptr movido invocará un comportamiento indefinido, por lo que hay que tener mucho cuidado de no utilizar un auto_ptr después de haber sido movido de:

auto_ptr<Shape> a(new Triangle); // create triangleauto_ptr<Shape> b(a); // move a into bdouble area = a->area(); // undefined behavior

Pero el auto_ptr no siempre es peligroso. Las funciones de fábrica son un caso de uso perfectamente correcto para auto_ptr:

auto_ptr<Shape> make_triangle(){ return auto_ptr<Shape>(new Triangle);}auto_ptr<Shape> c(make_triangle()); // move temporary into cdouble area = make_triangle()->area(); // perfectly safe

Nota cómo ambos ejemplos siguen el mismo patrón sintáctico:

auto_ptr<Shape> variable(expression);double area = expression->area();

Y sin embargo, uno de ellos invoca un comportamiento indefinido, mientras que el otro no. Entonces, ¿cuál es la diferencia entre las expresiones a y make_triangle()? ¿No son ambas del mismo tipo? Efectivamente lo son, pero tienen diferentes categorías de valor.

Categorías de valor

Obviamente, debe haber alguna diferencia profunda entre la expresión a que denota una variable auto_ptr, y la expresión make_triangle() que denota la llamada a una función que devuelve un auto_ptr por valor, creando así un objeto temporal auto_ptr fresco cada vez que se llama. a es un ejemplo de un lvalue, mientras que make_triangle() es un ejemplo de un rvalue.

Mover de lvalues como a es peligroso, porque podríamos más tarde tratar de llamar a una función miembro a través de a, invocando un comportamiento indefinido. Por otro lado, mover desde rvalues como make_triangle() es perfectamente seguro, porque después de que el constructor de la copia haya hecho su trabajo, no podemos usar el temporal de nuevo. No hay ninguna expresión que denote dicho temporal; si simplemente escribimos make_triangle() de nuevo, obtenemos un temporal diferente. De hecho, el temporal movido ya ha desaparecido en la siguiente línea:

auto_ptr<Shape> c(make_triangle()); ^ the moved-from temporary dies right here

Nótese que las letras l y r tienen un origen histórico en el lado izquierdo y derecho de una asignación. Esto ya no es cierto en C++, porque hay lvalues que no pueden aparecer en el lado izquierdo de una asignación (como los arrays o los tipos definidos por el usuario sin un operador de asignación), y hay rvalues que sí pueden (todos los rvalues de tipos de clase con un operador de asignación).

Un rvalue de tipo de clase es una expresión cuya evaluación crea un objeto temporal. En circunstancias normales, ninguna otra expresión dentro del mismo ámbito denota el mismo objeto temporal.

Referencias rvalue

Ahora entendemos que pasar de lvalues es potencialmente peligroso, pero pasar de rvalues es inofensivo. Si C++ tuviera soporte de lenguaje para distinguir los argumentos lvalue de los rvalue, podríamos prohibir completamente el movimiento desde lvalues, o al menos hacer explícito el movimiento desde lvalues en el lugar de la llamada, para que ya no nos movamos por accidente.

La respuesta de C++11 a este problema son las referencias rvalue. Una referencia rvalue es un nuevo tipo de referencia que sólo se une a rvalues, y la sintaxis es X&&. La vieja referencia X& se conoce ahora como referencia lvalue. (Nótese que X&& no es una referencia a una referencia; no existe tal cosa en C++.)

Si añadimos const a la mezcla, ya tenemos cuatro tipos diferentes de referencias. ¿A qué tipo de expresiones de tipo X se pueden unir?

 lvalue const lvalue rvalue const rvalue--------------------------------------------------------- X& yesconst X& yes yes yes yesX&& yesconst X&& yes yes

En la práctica, puedes olvidarte de const X&&. Estar restringido a leer de rvalues no es muy útil.

Una referencia rvalue X&& es un nuevo tipo de referencia que sólo se une a rvalues.

Conversiones implícitas

Las referencias rvalue pasaron por varias versiones. Desde la versión 2.1, una referencia rvalue X&& también se vincula a todas las categorías de valores de un tipo diferente Y, siempre que haya una conversión implícita de Y a X. En ese caso, se crea un temporal de tipo X, y la referencia rvalue se vincula a ese temporal:

void some_function(std::string&& r);some_function("hello world");

En el ejemplo anterior, "hello world" es un lvalue de tipo const char. Como hay una conversión implícita de const char a través de const char* a std::string, se crea un temporal de tipo std::string, y r se vincula a ese temporal. Este es uno de los casos en que la distinción entre rvalues (expresiones) y temporales (objetos) es un poco borrosa.

Constructores de movimiento

Un ejemplo útil de una función con un parámetro X&& es el constructor de movimiento X::X(X&& source). Su propósito es transferir la propiedad del recurso gestionado desde el origen al objeto actual.

En C++11, std::auto_ptr<T> ha sido sustituido por std::unique_ptr<T> que aprovecha las referencias rvalue. Voy a desarrollar y discutir una versión simplificada de unique_ptr. Primero, encapsulamos un puntero crudo y sobrecargamos los operadores -> y *, para que nuestra clase se sienta como un puntero:

template<typename T>class unique_ptr{ T* ptr;public: T* operator->() const { return ptr; } T& operator*() const { return *ptr; }

El constructor toma la propiedad del objeto, y el destructor lo borra:

 explicit unique_ptr(T* p = nullptr) { ptr = p; } ~unique_ptr() { delete ptr; }

Ahora viene la parte interesante, el constructor de movimiento:

 unique_ptr(unique_ptr&& source) // note the rvalue reference { ptr = source.ptr; source.ptr = nullptr; }

Este constructor de movimiento hace exactamente lo mismo que el constructor de copia auto_ptr, pero sólo puede ser suministrado con rvalues:

unique_ptr<Shape> a(new Triangle);unique_ptr<Shape> b(a); // errorunique_ptr<Shape> c(make_triangle()); // okay

La segunda línea falla al compilar, porque a es un lvalue, pero el parámetro unique_ptr&& source sólo puede estar ligado a rvalues. Esto es exactamente lo que queríamos; los movimientos peligrosos nunca deben ser implícitos. La tercera línea compila bien, porque make_triangle() es un rvalue. El constructor del movimiento transferirá la propiedad del temporal a c. De nuevo, esto es exactamente lo que queríamos.

El constructor move transfiere la propiedad de un recurso gestionado al objeto actual.

Operadores de asignación move

La última pieza que falta es el operador de asignación move. Su trabajo es liberar el recurso antiguo y adquirir el nuevo recurso a partir de su argumento:

 unique_ptr& operator=(unique_ptr&& source) // note the rvalue reference { if (this != &source) // beware of self-assignment { delete ptr; // release the old resource ptr = source.ptr; // acquire the new resource source.ptr = nullptr; } return *this; }};

Nota cómo esta implementación del operador de asignación de movimiento duplica la lógica tanto del destructor como del constructor de movimiento. ¿Estás familiarizado con el lenguaje de copiar e intercambiar? También se puede aplicar a la semántica de movimiento como el modismo move-and-swap:

 unique_ptr& operator=(unique_ptr source) // note the missing reference { std::swap(ptr, source.ptr); return *this; }};

Ahora que source es una variable de tipo unique_ptr, será inicializada por el constructor move; es decir, el argumento se moverá al parámetro. El argumento todavía se requiere que sea un rvalue, porque el propio constructor move tiene un parámetro de referencia rvalue. Cuando el flujo de control alcanza la llave de cierre de operator=, source sale del ámbito, liberando el antiguo recurso automáticamente.

El operador de asignación move transfiere la propiedad de un recurso gestionado al objeto actual, liberando el antiguo recurso. El modismo move-and-swap simplifica la implementación.

Mover desde lvalues

A veces, queremos mover desde lvalues. Es decir, a veces queremos que el compilador trate un lvalue como si fuera un rvalue, para que pueda invocar el constructor move, aunque pueda ser potencialmente inseguro.Para este propósito, C++11 ofrece una plantilla de función de biblioteca estándar llamada std::move dentro de la cabecera <utility>.Este nombre es un poco desafortunado, porque std::move simplemente convierte un lvalue en un rvalue; no mueve nada por sí mismo. Simplemente permite el movimiento. Tal vez debería haber sido llamado std::cast_to_rvalue o std::enable_move, pero estamos atascados con el nombre por ahora.

Aquí es cómo se mueve explícitamente de un lvalue:

unique_ptr<Shape> a(new Triangle);unique_ptr<Shape> b(a); // still an errorunique_ptr<Shape> c(std::move(a)); // okay

Note que después de la tercera línea, a ya no posee un triángulo. Eso está bien, porque al escribir explícitamente std::move(a), dejamos claras nuestras intenciones: «Querido constructor, haz lo que quieras con a para inicializar c; ya no me importa a. Siéntete libre de hacer lo que quieras con a

std::move(some_lvalue) convierte un lvalue en un rvalue, permitiendo así un movimiento posterior.

Xvalues

Nótese que aunque std::move(a) es un rvalue, su evaluación no crea un objeto temporal. Este enigma obligó al comité a introducir una tercera categoría de valores. Algo que puede estar ligado a una referencia rvalue, aunque no sea un rvalue en el sentido tradicional, se llama xvalue (valor eXpiring). Los rvalues tradicionales fueron renombrados a prvalues (Pure rvalues).

Tanto los prvalues como los xvalues son rvalues. Xvalues y lvalues son ambos glvalues (Generalized lvalues). Las relaciones son más fáciles de entender con un diagrama:

 expressions / \ / \ / \ glvalues rvalues / \ / \ / \ / \ / \ / \lvalues xvalues prvalues

Nótese que sólo los xvalues son realmente nuevos; el resto es sólo debido al cambio de nombre y la agrupación.

Los rvalues de C++98 se conocen como prvalues en C++11. Sustituya mentalmente todas las apariciones de «rvalue» en los párrafos anteriores por «prvalue».

Mover fuera de las funciones

Hasta ahora, hemos visto el movimiento hacia las variables locales, y hacia los parámetros de las funciones. Pero el movimiento también es posible en la dirección opuesta. Si una función devuelve por valor, algún objeto en el lugar de la llamada (probablemente una variable local o un temporal, pero podría ser cualquier tipo de objeto) se inicializa con la expresión tras la sentencia return como argumento del constructor de movimiento:

unique_ptr<Shape> make_triangle(){ return unique_ptr<Shape>(new Triangle);} \-----------------------------/ | | temporary is moved into c | vunique_ptr<Shape> c(make_triangle());

Tal vez sorprendentemente, los objetos automáticos (variables locales que no se declaran como static) también se pueden mover implícitamente fuera de las funciones:

unique_ptr<Shape> make_square(){ unique_ptr<Shape> result(new Square); return result; // note the missing std::move}

¿Cómo es que el constructor move acepta el lvalue result como argumento? El ámbito de result está a punto de terminar, y será destruido durante el desenrollado de la pila. Nadie podría quejarse después de que result ha cambiado de alguna manera; cuando el flujo de control vuelve al llamador, result ya no existe. Por esa razón, C++11 tiene una regla especial que permite devolver objetos automáticos desde las funciones sin tener que escribir std::move. De hecho, nunca debe usar std::move para mover objetos automáticos fuera de las funciones, ya que esto inhibe la «optimización del valor de retorno con nombre» (NRVO).

Nunca use std::move para mover objetos automáticos fuera de las funciones.

Note que en ambas funciones de fábrica, el tipo de retorno es un valor, no una referencia rvalue. Las referencias rvalue siguen siendo referencias, y como siempre, nunca debería devolver una referencia a un objeto automático; el llamador terminaría con una referencia colgante si engañara al compilador para que aceptara su código, así:

unique_ptr<Shape>&& flawed_attempt() // DO NOT DO THIS!{ unique_ptr<Shape> very_bad_idea(new Square); return std::move(very_bad_idea); // WRONG!}

Nunca devuelva objetos automáticos por referencia rvalue. El movimiento se realiza exclusivamente por el constructor move, no por std::move, y no por la mera vinculación de un rvalue a una referencia rvalue.

Moviendo en miembros

Tarde o temprano, vas a escribir código como este:

class Foo{ unique_ptr<Shape> member;public: Foo(unique_ptr<Shape>&& parameter) : member(parameter) // error {}};

Básicamente, el compilador se quejará de que parameter es un lvalue. Si miras su tipo, verás una referencia rvalue, pero una referencia rvalue simplemente significa «una referencia que está ligada a un rvalue»; ¡no significa que la propia referencia sea un rvalue! De hecho, parameter es sólo una variable ordinaria con un nombre. Puedes usar parameter tantas veces como quieras dentro del cuerpo del constructor, y siempre denota el mismo objeto. Mover implícitamente de ella sería peligroso, de ahí que el lenguaje lo prohíba.

Una referencia rvalue con nombre es un lvalue, como cualquier otra variable.

La solución es habilitar manualmente el movimiento:

class Foo{ unique_ptr<Shape> member;public: Foo(unique_ptr<Shape>&& parameter) : member(std::move(parameter)) // note the std::move {}};

Podrías argumentar que parameter ya no se usa después de la inicialización de member. ¿Por qué no hay una regla especial para insertar silenciosamente std::move al igual que con los valores de retorno? Probablemente porque sería una carga excesiva para los implementadores del compilador. Por ejemplo, ¿qué pasaría si el cuerpo del constructor estuviera en otra unidad de traducción? Por el contrario, la regla del valor de retorno simplemente tiene que comprobar las tablas de símbolos para determinar si el identificador después de la palabra clave return denota o no un objeto automático.

También puede pasar el parameter por valor. Para los tipos de sólo movimiento como unique_ptr, parece que no hay un modismo establecido todavía. Personalmente, prefiero pasar por valor, ya que causa menos desorden en la interfaz.

Funciones miembro especiales

C++98 declara implícitamente tres funciones miembro especiales bajo demanda, es decir, cuando se necesitan en alguna parte: el constructor de copia, el operador de asignación de copia y el destructor.

X::X(const X&); // copy constructorX& X::operator=(const X&); // copy assignment operatorX::~X(); // destructor

Las referencias Rvalue pasaron por varias versiones. Desde la versión 3.0, C++11 declara dos funciones miembro especiales adicionales bajo demanda: el constructor move y el operador de asignación move. Ten en cuenta que ni VC10 ni VC11 se ajustan todavía a la versión 3.0, por lo que tendrás que implementarlos tú mismo.

X::X(X&&); // move constructorX& X::operator=(X&&); // move assignment operator

Estas dos nuevas funciones miembro especiales sólo se declaran implícitamente si no se declara manualmente ninguna de las funciones miembro especiales. Además, si usted declara su propio constructor de movimiento o operador de asignación de movimiento, ni el constructor de copia ni el operador de asignación de copia serán declarados implícitamente.

¿Qué significan estas reglas en la práctica?

Si escribe una clase sin recursos no gestionados, no hay necesidad de declarar ninguna de las cinco funciones miembro especiales usted mismo, y obtendrá la semántica de copia correcta y la semántica de movimiento de forma gratuita. En caso contrario, tendrás que implementar las funciones miembro especiales tú mismo. Por supuesto, si su clase no se beneficia de la semántica de movimiento, no hay necesidad de implementar las operaciones especiales de movimiento.

Nótese que el operador de asignación de copia y el operador de asignación de movimiento pueden fusionarse en un único operador de asignación unificado, tomando su argumento por valor:

X& X::operator=(X source) // unified assignment operator{ swap(source); // see my first answer for an explanation return *this;}

De esta forma, el número de funciones miembro especiales a implementar se reduce de cinco a cuatro. Hay un compromiso entre la seguridad de las excepciones y la eficiencia aquí, pero no soy un experto en este tema.

Referencias de reenvío (anteriormente conocidas como referencias universales)

Considere la siguiente plantilla de función:

template<typename T>void foo(T&&);

Podría esperar que T&& sólo se vincule a rvalues, porque a primera vista, parece una referencia rvalue. Sin embargo, resulta que T&& también se une a lvalues:

foo(make_triangle()); // T is unique_ptr<Shape>, T&& is unique_ptr<Shape>&&unique_ptr<Shape> a(new Triangle);foo(a); // T is unique_ptr<Shape>&, T&& is unique_ptr<Shape>&

Si el argumento es un rvalue de tipo X, T se deduce que es X, por lo que T&& significa X&&. Pero si el argumento es un valor l de tipo X, debido a una regla especial, T se deduce que es X&, por lo que T&& significaría algo como X& &&. Pero como C++ todavía no tiene la noción de referencias a referencias, el tipo X& && se colapsa en X&. Esto puede sonar confuso e inútil al principio, pero el colapso de referencias es esencial para el reenvío perfecto (que no se discutirá aquí).

T&& no es una referencia rvalue, sino una referencia de reenvío. También se une a lvalues, en cuyo caso T y T&& son ambas referencias lvalue.

Si quieres restringir una plantilla de función a rvalues, puedes combinar SFINAE con type traits:

#include <type_traits>template<typename T>typename std::enable_if<std::is_rvalue_reference<T&&>::value, void>::typefoo(T&&);

Implementación de move

Ahora que entiendes el colapso de referencias, aquí tienes cómo se implementa std::move:

template<typename T>typename std::remove_reference<T>::type&&move(T&& t){ return static_cast<typename std::remove_reference<T>::type&&>(t);}

Como puedes ver, move acepta cualquier tipo de parámetro gracias a la referencia de reenvío T&&, y devuelve una referencia rvalue. La llamada a la metafunción std::remove_reference<T>::type es necesaria porque de lo contrario, para lvalues de tipo X, el tipo de retorno sería X& &&, que colapsaría en X&. Dado que t es siempre un lvalue (recuerde que una referencia rvalue con nombre es un lvalue), pero queremos vincular t a una referencia rvalue, tenemos que lanzar explícitamente t al tipo de retorno correcto.La llamada de una función que devuelve una referencia rvalue es en sí misma un xvalue. Ahora ya sabes de dónde vienen los xvalues 😉

Deja una respuesta

Tu dirección de correo electrónico no será publicada.