std::move, ¡qué movida!
Publicado el 7 de junio de 2021
La definición de mover un objeto en C++ ha sido motivo de polémica desde el principio. En parte es comprensible, este es un campo en el que C++ fue pionero, y la solución que se diseñó fue una que resolvía de forma bastante elegante problemas importantes que el lenguaje arrastraba desde siempre, lo cual es meritorio cuando no hay ninguna referencia en la que basarse y de cuyos errores aprender. Diez años después, y con otros lenguajes como Rust habiéndose subido al carro, el sistema empieza a dejar ver sus costuras, y se vuelve a hablar sobre qué significa y qué debería significar mover un objeto. Es especialmente provocador el paper p2345
[1]
de un favorito de esta casa, Sean Parent, que plantea una redefinición del mover especialmente problemática por ser incompatible con la especificación actual y con la forma en la que el concepto se entiendo ahora mismo, pero que resuelve los problemas más acuciantes.
El estándar define que un objeto del que se ha movido su valor debe quedar en un estado válido pero no especificado. Esto lo explica muy bien Herb Sutter
[2]
. Mover es una operación sobre dos Ts mutables, aquel al que el valor es movido y aquel del que el valor se mueve, y debería funcionar igual que cualquier otra operación sobre Ts mutables. Es decir, para un tipo de dato abstracto, las invariantes del tipo deben mantenerse tras la operación. Eso es lo que significa válido pero no especificado. El estado exacto depende del tipo, pero debe ser un estado válido para el tipo. Es decir, los bytes de memoria ocupados por el objeto deben seguir representando un T sobre el que se puede operar y que se puede destruir. Esto es necesario para poder mover lo que C++ llama xvalues, es decir lvalues cuyo valor ha expirado, pero que siguen teniendo una dirección de memoria definida tras la operación de mover, que por lo tanto puede ser accedida.
C++
{
    // Ejemplo en el que se mueve un xvalue
    std::vector<int> v = {1, 2, 3, 4, 5};
    consume(std::move(v));
    v.push_back(5); // v es usado después de haberse movido
}
Sin embargo, el tener que mantener el objeto en un estado válido introduce un coste adicional al mover. En el caso de vector o de unique_ptr, este coste es trivial ya que consiste únicamente en dejar todos los miembros a cero, pero para clases como la implementación de
std::list
de Microsoft, que reserva siempre un nodo adicional para el iterador end, el coste adicional de mantener la clase en un estado válido no es trivial en absoluto. Estamos hablando de una reserva de memoria, entre otras cosas. Otro problema de “válido pero no especificado” es que su composición no es trivial. Se puede dar que para un tipo de dato abstracto compuesto por otros tipos de dato abstractos, pero que imponga precondiciones adicionales, el resultado de mover todos sus miembros no sea un estado válido, lo cual llevaría a tener que definir a mano una operación de mover y a no poder usar la regla de cero. También requiere, para todo tipo movible, la existencia de un estado válido para un objeto del que se ha movido, lo cual no es necesariamente deseable para todas las interfaces.
Además, es importante analizar, cómo nos recuerda Mike Acton, cuál es la operación más común. Cuál es estadísticamente el caso más común en el que un objeto se mueve. Porque es ese el caso para el que hay que optimizar el diseño. Y resulta que la situación más común en la que un objeto se mueve es la reubicación o movimiento destructivo (en inglés
relocation
o
destructive move
), es decir, cuando un objeto es trasladado a otra dirección de memoria, y el objeto anterior es destruido. Esto sucede al crecer un vector y tener que mover los objetos al nuevo bloque de memoria más grande, al intercambiar dos objetos mediante
std::swap
como parte de algoritmos de permutación como ordenar, rotar o hacer una partición, al devolver una local de una función o al mover una local en lo que Herb Sutter llama su último uso definitivo o
definite last use
[5]
. En todos estos casos la siguiente y única operación aplicada sobre el objeto tras moverlo es el destructor, por lo que hacer trabajo adicional para garantizar que cualquier otra operación sea válida en el objeto es tiempo desperdiciado. Usar un tipo después de ser movido es estadísticamente raro, y diseñar el sistema priorizando tener esto en cuenta quizá no la mejor idea.
Lo que propone Sean Parent es que un objeto movido quede en lo que llama un estado parcialmente formado, concepto acuñado por Alex Stepanov
[3]
y definido como un objeto que puede ser destruido o al que se puede asignar un valor. Ninguna otra operación sobre un objeto parcialmente construido es válida. Esto permitiría a aquellos tipos para los cuales mantener un estado válido tras ser movidos requiere un trabajo no trivial ser reubicados sin tener que hacer ese trabajo. También permitiría aprovechar la regla de cero, ya que la reubicación sí que es una operación que se compone mediante la simple concatenación de las reubicaciones de los miembros. Por último, lo que hoy llamamos mover podría definirse simplemente en términos de reubicar seguido de asignar un nuevo valor construido por defecto.
El aspecto en el que el paper de Parent es problemático es en que el estrechamiento de las precondiciones para objetos de los que se ha movido puede no ser retrocompatible con código que hoy dependa, de forma totalmente legal según el estándar, de la validez de objetos de los que se ha movido. En C++, la pérdida de retrocompatibilidad en la API es un factor suficiente para echar atrás una propuesta.
El otro paper relevante sobre el tema de mover que está en trámite ahora mismo es p1144 de Arthur O’Dwyer
[4]
, donde define la operación de reubicar como una función (
std::relocate_at
), además de el concepto de trivialmente reubicable (
trivially_relocatable
), que identifica que la reubicación de un tipo es equivalente a copiar sus bits con una operación equivalente a
memcpy
y saltarse su destructor. Este añadido permitiría optimizar, especialmente para tipos trivialmente reubicables, que son la mayoría, casos como el crecimiento de un vector o los algoritmos de permutaciones, y ataja perfectamente el caso patológico de la implementación de
std::list
de Microsoft antes mencionado. Además, es perfectamente retrocompatible con todo lo existente, y no requiere estrechar las precondiciones de nada.
Antes mencionábamos Rust como otro lenguaje de programación que también permite mover objetos. El caso de Rust es llamativo porque Rust obliga a todos sus tipos a ser trivialmente reubicables
[6]
. Esto simplifica varios aspectos del lenguaje y reduce la complejidad, al no tener que lidiar con tipos que no son reubicables, por ejemplo, cosa que sucede en C++. Que todos los tipos tengan que ser trivialmente reubicables no es un problema para la mayoría de los casos, aunque sí es cierto que inhibe algunas optimizaciones como la forma en la que GCC implementa la optimización de strings pequeñas o el iterador end de
std::list
como parte de la clase. Sin embargo, a pesar de inhibir optimizaciones, es un diseño simple y eficiente sobre el que es fácil y cómodo trabajar, en el que se puede ver que han aprendido de C++ y han intentado no caer en algunos de sus fallos.
Como comentario final al estado de mover en C++, a los papers de Parent y O’Dwyer, y un poco de conclusión, lo que la dirección de Rust y estos papers parecen mostrarnos es que mover no es quizá una buena operación fundamental para una base de operaciones eficiente. Puede que en lugar de entender la reubicación como mover + destruir, deberíamos entender mover como reubicar + construir un valor por defecto. La operación de reubicar podría también ser declarable como
=default
y deducible por el compilador, lo que permitiría tener tipos trivialmente reubicables sin tener que anotarlo. p1144 requiere anotar los tipos trivialmente reubicables con el atributo
[[trivially_relocatable]]
, que funciona pero es tedioso. De esta forma, podríamos definir la operación de mover por defecto como reubicar seguido de invocar el constructor por defecto. Esto aumentaría la granularidad de las operaciones que podemos tener sobre un tipo respecto a su propiedad sobre recursos, permitiría hacer la operación eficiente en cada caso, y para la mayoría de los tipos permitiría escribir menos código, ya que casi todos los tipos son trivialmente reubicables, por lo que no habría que escribir esta función, y el mover se puede definir en términos de la reubicación, lo que permitiría no escribir esta otra tampoco. También permitiría escribir tipos reubicables no movibles, permitiendo que no sea necesario que un tipo tenga un estado vacío con significado para poder ser devuelto de una función o estar contenido en un vector.
Evidentemente el problema con el que se encuentra lo descrito en el párrafo anterior es que C++ tiene demasiado código escrito ya considerando
std::move
parte de la base de operaciones, y cambiar esto ahora sería quizá demasiado trabajo. El paper de O’Dwyer es una buena solución para la mayoría de casos y es menos problemática con el legado existente, así que es seguramente la mejor dirección para C++. Ser pionero es difícil porque inevitablemente se van a cometer errores que sólo pueden verse a toro pasado, y eso no quita el enorme mérito de C++11 y su capacidad para ensanchar nuestra forma de ver la propiedad de recursos y de expresarla en un sistema de tipos. Pero tal vez otros lenguajes en el futuro puedan aprender de estos errores y diseñar una solución óptima desde el principio.

Referencias:

[1]
p2345: Relaxing
requirements
of moved-from objects (Sean Parent, 2021)

http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2021/p2345r0.pdf
[3]
Elements of programming (Alexander Stepanov y Paul Jones, 2009)

http://elementsofprogramming.com/eop.pdf
[4]
p1144: Object relocation in terms of move plus destroy (Arthur O’Dwyer, 2020)

http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2020/p1144r5.html
[5]
Empirically measuring and reducing C++’s accidental complexity (Herb Sutter, CppCon 2020)

https://www.youtube.com/watch?v=6lurOCdaj0Y
[6]
Documentación de Rust sobre la propiedad, copiar y mover objetos

https://doc.rust-lang.org/book/ch04-01-what-is-ownership.html
Logo of RSS.