Semánticas de copia y movimiento (V)

Artículos de la serie:

Semántica de movimiento


Consideremos una función read_staff() que reciba como único argumento un número entero de elementos n escogido, por regla general, en tiempo de ejecución. La función inicializará un contenedor Dynarray<Employee> de dicha longitud, siendo Employee una clase cuyos objetos consisten en registros de empleados. A continuación, la función realizará una serie de operaciones sobre el array y, finalmente, lo retornará como resultado. Por ejemplo, la función podría solicitar al usuario que introdujese por la terminal los nombres y los números identificativos de los n empleados a almacenar en el array.

Se plantea aquí el problema no trivial de retornar el array Dynarray<Employee> de manera eficiente fuera de la función. Tengamos en cuenta, en primer lugar, que el número de elementos contenidos en el array (y, por tanto, el tamaño del bloque de memoria) puede ser arbitrariamente grande, siempre que no superemos los límites impuestos por la implementación o los recursos de memoria en el momento de ejecución de la función.

Supongamos, en primer lugar, el empleo del lenguaje C++98/03, con el fin de realizar un análisis histórico de las mejoras proporcionadas por estándares posteriores. En una primera y poco acertada aproximación, y con nuestra implementación actual del contenedor Dynarray<>, podríamos estar tentados a escribir:

   // código ineficiente en C++98 (ignorando optimizaciones del compilador)     Dynarray<Employee> read_staff(std::size_t n)    {       Dynarray<Employee> res(n);       // solicitamos los datos al usuario...       return res; // retorno por valor    }    // ... Dynarray<Employee> data = read_staff(n);

Es importante remarcar que, por el momento, ignoraremos las optimizaciones que el compilador pueda realizar en una situación como ésta, si bien retomaremos este tema más adelante en este mismo post.

La solución anterior (retornar por valor), aunque natural, resulta altamente ineficiente por cuanto involucra la copia del array res al finalizar la ejecución de la función (el contenedor data es inicializado mediante una llamada al constructor copia). Esta copia puede resultar muy costosa, tanto más costosa cuanto más elementos contenga el contenedor, pues implica la asignación de un nuevo bloque de memoria y la inicialización de las copias de los n objetos de tipo Employee. Una de las soluciones tradicionales a este problema antes de la introducción del estándar C++11 consistía en la devolución de un puntero a un objeto Dynarray<Employee> con duración de almacenamiento dinámica:

   Dynarray<Employee>* read_staff(std::size_t n)    {       Dynarray<Employee>* ptr = new Dynarray<Employee>(n);       // ...       return ptr;    }

Sin embargo, este código obliga al programador a destruir el objeto y liberar la memoria manualmente mediante una expresión delete cuando el array no sea ya necesario. Todo ello debería quedar convenientemente indicado en las especificaciones de la función, claro está, pero es fácil imaginar las numerosas lagunas de memoria que podría provocar un programador olvidadizo. Incluso el uso de algún tipo de puntero inteligente con la semántica de propiedad adecuada no aliviaría el hecho de que estemos introduciendo un grado de indirección adicional innecesario que afecta al rendimiento general del código. Esta antigua problemática del lenguaje, a saber, la incapacidad de las funciones de retornar objetos de gran tamaño en memoria sin afectar a la sintaxis, el rendimiento y/o la seguridad del código, quedó resuelta con la llegada del estándar C++11 mediante la inclusión de la denominada semántica de movimiento.

Observemos, en el primer bloque de código de ejemplo, que el objeto local Dynarray<Employee> res va a ser destruido al finalizar la ejecución de la función. Como explicamos anteriormente, debemos evitar una copia costosa del contenedor. ¿Por qué no robar entonces la representación del objeto res antes de su destrucción y proporcionársela al objeto data inicializado mediante la llamada a la función? Los estándares C++11 y posteriores permiten tal operación de transferencia de recursos mediante la definición de un constructor de movimiento para la plantilla Dynarray<>:

   template<typename T> inline    Dynarray<T>::Dynarray(Dynarray&& d) noexcept // constructor de movimiento       : Base{0} // (A)    {       swap(d);  // (B)    }

El doble signo && denota una referencia rvalue [1]. Este constructor crea primeramente un array vacío (A) para, a continuación, intercambiar la representación con el objeto d pasado como argumento (B). El array d queda, así, vacío, listo para ser destruido o reasignado, mientras que el objeto *this adquiere la representación de d (es decir, se hace cargo de los datos originalmente referenciados por d). Tomemos nuevamente el bloque de código en el que se retorna res por valor:

   // código eficiente en C++11 una vez que Dynarray<> contempla semántica de movimiento    Dynarray<Employee> read_staff(std::size_t n)    {       Dynarray<Employee> res{n};       // ...       return res;    }    // ... Dynarray<Employee> data = read_staff(n);

El estándar C++11 garantiza que, de haber un constructor de movimiento disponible, será éste el invocado al construir el objeto data, prefiriéndose dicha operación a la copia tradicional. Así, data se hará cargo de la propiedad de los datos almacenados en res antes de la destrucción de este último. Ni siquiera es necesario explicitar que deseamos realizar una transferencia de recursos con una expresión de la forma return std::move(res): ésta se produce automáticamente por el hecho de que nuestra plantilla Dynarray<> soporte ahora la semántica de movimiento.


Ha llegado el momento de analizar en más detalles las optimizaciones habituales realizadas por el compilador en códigos como el aquí considerado.

Tanto las operaciones de copia como de movimiento pueden ser omitidas por el compilador bajo ciertas condiciones, construyéndose el objeto directamente en la ubicación de la variable de destino, en un procedimiento clave denominado genéricamente omisión de copia (copy-elision [2]). Consideremos el código siguiente:

   Dynarray<Employee> read_staff(std::size_t n)    {       Dynarray<Employee> res{n};       // ...       return res;    }    //...    std::size_t n;    std::cin >> n;    Dynarray<Employee> data = read_staff(n);    // usamos d...

Aquí, un compilador optará normalmente por pasar la dirección en memoria del array de destino como un argumento oculto de la función read_staff(), con el fin de construir el objeto retornado en dicha dirección (más detalles en [3]):

   void read_staff(std::size_t n, void* __p)    {       Dynarray<Employee>& res = *(::new(__p) Dynarray<Employee>{n}); // placement new       // cargamos datos en res...    }    // ...    std::size_t n;    std::cin >> n;        char __mem[sizeof(Dynarray<Employee>)]; // buffer oculto para almacenar el contenedor    read_staff(n, __mem);    Dynarray<Employee>& data = *reinterpret_cast<Dynarray<Employee>*>(__mem);    // usamos d...

¡Ni el constructor copia ni el constructor de movimiento son invocados en este caso! Esta optimización (denominada named return value optimization, NRVO [2]) no sería posible en general, sin embargo, si existiera más de una cláusula de retorno en la función read_staff(). Curiosamente, esta optimización quedaría también invalidada de emplearse una expresión de la forma return std::move(res) (nótese el uso explícito de std::move) en el retorno de la función read_staff.

Mención aparte merecen las asignaciones de la forma

   Dynarray<Employee> d;     d = read_staff();

Con la implementación actual de Dynarray<>, el compilador no podría evitar en este caso una llamada al operador de asignación copia, aun cuando pueda evitar la realización de una copia innecesaria en la construcción del temporal retornado por la función read_staff(). Por fortuna, podemos también reducir la operación a una transferencia de recursos poco costosa gracias a la definición de un operador de asignación de movimiento tal y como discutiremos a continuación.


De manera similar a como definimos el constructor de movimiento, puede introducirse el operador de asignación de movimiento como:

   template<typename T> inline // operador de asignación de movimiento    Dynarray<T>& Dynarray<T>::operator=(Dynarray<T>&& a) noexcept    {       Dynarray<value_type> tmp; // (A)       swap(tmp);                // (B)       swap(a);                  // (C)       return *this;     } // tmp se destruye en este punto

Este operador crea primeramente un array vacío tmp (A) para, a continuación, intercambiar su representación con el objeto *this (esté éste vacío o no) en (B). *this intercambia entonces su nueva representación (la de un array vacío) con el objeto pasado como argumento a (C), que queda entonces listo para su posterior reasignación o destrucción. Al destruirse el temporal tmp, se destruye el array referenciado originalmente por *this. El operador de asignación se declara noexcept dado que ni la función miembro swap() ni el destructor de la clase value_type pueden emitir excepciones de ningún tipo. El uso de estas funciones miembro especiales puede ilustrarse con el código siguiente:

   auto u = Dynarray<int>{0,1,2,3},        v = Dynarray<int>{4,5,6};    v = std::move(u); // asignación de movimiento:                      // v se hace cargo del array [0,1,2,3] apuntado por u                     // el array [4,5,6] originalmente referenciado por v es liberado                     // u se queda vacío

Aquí, nos servimos de la función estándar std::move() proporcionada en el fichero de cabecera <utility> para indicar nuestra intención de realizar una operación de movimiento. Decimos intención pues, si la plantilla Dynarray<> no implementase la semántica de movimiento, se llamarían a los constructores y operadores de asignación copia habituales correspondientes.


La función std::move() convierte un valor dado en una referencia rvalue, posibilitando su movimiento. Ello da pie a invocar a constructores u operadores de asignación de movimiento, que toman tales referencias como argumentos para efectuar, realmente, la transferencia del recurso. Si tales operaciones de movimiento no estuviesen disponibles, pero sí las usuales de copia, la referencia rvalue decaería a una referencia lvalue tradicional, siendo estas últimas las invocadas.

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


Referencias bibliográficas:
  1. Value categories - https://en.cppreference.com/w/cpp/language/value_category
  2. Copy elision - https://en.cppreference.com/w/cpp/language/copy_elision
  3. Alexandrescu A., 'Move Constructors', Dr. Dobbs Journal (2003) - https://www.drdobbs.com/move-constructors/184403855
  4. Stroustrup B., 'The C++ Programming Language'. Addison-Wesley, 4th Edition (2013).

No hay comentarios:

Publicar un comentario