weak_ptr considered harmful
Publicado el 16 de julio de 2021
C++11 incluyó
std::shared_ptr
, un tipo de "smart pointer" que permite a varios puntos del programa compartir la propiedad de un objeto.
shared_ptr
es una clase bastante útil para resolver algunos problemas. Además de esto, C++11 incluye
std::weak_ptr
, un tipo de smart pointer que puede apuntar a un objeto gestionado por un
shared_ptr
sin ser dueño, de forma que no tiene poder sobre cuándo el objeto va a ser destruido pero puede acceder a él mientras siga vivo.
weak_ptr
tiene dos usos principalmente. Por un lado, sirve para romper ciclos de referencias en grafos donde los nodos están conectados por
shared_ptr
s. Por otro, sirve para modelar una situación en la que un objeto necesita saber cuándo otro es destruido, sin tener ningún control sobre cuándo sucede esta destrucción. La existencia de
weak_ptr
no es gratuita. Para poder funcionar, es necesario que el bloque de control del objeto gestionado lleve, además de la cuenta del número de
shared_ptr
s que apuntan al objeto, la cuenta del número de
weak_ptr
s, ya que cuando el último
shared_ptr
es destruido, hay que destruir el objeto, pero no el bloque de control. Sin embargo, este coste es más bien nimio y en absoluto la razón de ser de este texto. Los casos de uso de
weak_ptr
, los problemas que resuelve, en realidad son, fuera de un diseño extremadamente orientado a objetos, antipatrones donde
weak_ptr
es un parche bonito que evita tener que razonar sobre cómo refactorizar el problema. Los sitios donde un programa usa
weak_ptr
, incluso en sus usos más canónicos, aquellos para los que fue diseñado, nos pueden apuntar a partes del programa donde la estructura de datos, la posesión y los patrones de acceso están vagamente definidos y crean un exceso artificial de complejidad.
Comencemos por el problema de los ciclos. El problema es simple. Imaginemos el siguiente grafo, donde el número de cada nodo es el número de referencias al nodo.
Grafo con leak potencial
Cuando se destruyen todas las referencias externas al grafo, cada elemento sigue estando apuntado por al menos una referencia, evitando que el grafo se destruya, aunque ahora ya es inaccesible para el resto del programa.
Grafo con leak
Substituir uno de esos punteros por un
weak_ptr
permitiría que, cuando la última referencia externa al grafo desaparezca, exista un objeto no apuntado por ningún
shared_ptr
, que sería destruido e iría destruyendo recursivamente el resto del grafo.
Grafo con weak_ptr
Grafo con weak_ptr destruido
Aunque
weak_ptr
puede parecer la solución ideal para este problema, en realidad es un parche que oculta el verdadero problema. Este ejemplo muestra lo que Sean Parent llama una estructura de datos emergente, es decir una estructura de datos para la que no existe un único punto en el programa que gestione las relaciones entre sus partes. En vez de eso, las relaciones entre los tres nodos están dispersas por las clases de los nodos, y el modelo de propiedad es el resultado accidental del uso de
shared_ptr
s. Este código podría ser refactorizado a una clase de grafo que contenga los tres objetos siendo su único propietario y gestione las relaciones entre ellos. Este objeto de grafo puede ser luego compartido o gestionado por un único objeto, según las necesidades del programa, pero no es necesario tener un contador de referencias para cada uno de los nodos. El origen de este tipo de diseños está en lenguajes como Java o C#, donde todos los objetos tienen un contador de referencias, así que esta es la forma en la que se hace un grafo en este tipo de lenguajes. Sin embargo, por suerte C++ nos brinda las herramientas para construir otros modelos de propiedad, que pueden ser menos complejos y mejores soluciones para situaciones donde las ventajas obtenidas por ese exceso de complejidad no son necesarias.
Es importante, a la hora de analizar los componentes de un programa, entender las relaciones entre ellos. La programación orientada a objetos incita a programar en un modelo en el que cada objeto es un ente aislado del resto del programa. Sin embargo, la realidad es que un objeto solo es un caso rarísimo. Lo más común es tener colecciones de objetos en estructuras de datos, y prestar atención a estas estructuras, las relaciones entre sus componentes y los patrones de acceso en los algoritmos sobre ellas nos puede llevar a código mejor organizado, más legible, más eficiente y más paralelizable.
Prosigamos con el segundo problema: saber cuándo un objeto ha sido destruido. Esta situación se da con frecuencia en un diseño en el que un sistema que gestiona sus propios objetos devuelve también un puntero al objeto creado que puede ser usado mientras que el objeto siga vivo. Supongamos por ejemplo un sistema para crear emisores de partículas, que emiten partículas durante un tiempo y luego son destruidos, pero además de una configuración inicial para el emisor devuelve un puntero que permite al usuario manipular las partículas para hacer efectos como atraerlas hacia un punto. Esto lleva a un modelo de propiedad extraño, en el que el emisor de partículas pertenece únicamente al sistema que lo crea (cabría preguntarse si
shared_ptr
es la abstracción correcta para esto), mientras que quien solicita crear el emisor de partículas tiene también acceso a él, pero ningún poder sobre la propiedad. Usar shared y weak ptr para esta situación conlleva toda una serie de problemas. Por un lado fuerza a que el emisor tenga que estar en memoria dinámica en su propio bloque y con un bloque de control bastante pesado, que además tiene que ser liberado cuando el efecto termine. También hace más difícil reusar la memoria. Se puede escribir un allocator para esto pero es una cantidad no trivial de trabajo. Por otro lado, nada impide al usuario guardar el
shared_ptr
devuelto por el
.lock()
de
weak_ptr
y violar completamente las reglas de gestión de la vida del emisor de partículas.
Hay dos diseños posibles que pueden facilitar este tipo de situaciones bastante. Por un lado, podemos hacer que el sistema que gestiona emisores de partículas devuelva un identificador que luego puede ser usado para acceder al emisor dentro del sistema. Esto permite al sistema organizar sus emisores de la forma que más le convenga, sin estar atado por las necesidades bastante restrictivas de
shared_ptr
. También facilita saber dónde se lee o escribe la memoria de los emisores de partículas, ya que es necesario tener visible al sistema para poder acceder a los emisores. Esto facilita razonar sobre dónde cambian distintas partes de la memoria del programa, y hace más fácil paralelizar.
La otra forma posible es haciendo que el sistema, al crear un nuevo emisor de partículas, coja una función que guarde junto al emisor y modifique el comportamiento del mismo. De esta forma, el código que crea un sistema de partículas puede olvidarse de él y no tener que gestionar parte manualmente y aún así tener la posibilidad de implementar este tipo de efectos.
Los usos de
weak_ptr
son en realidad una consecuencia de un problema de fondo, el ver las clases no como tipos de dato sino como agentes con capacidad de ejecución, y los punteros como relaciones entre estos agentes. Aunque este modelo de pensamiento es cómodo y bastante intuitivo, se parece más bien poco a cómo funciona un ordenador, y lleva a programas en los que las estructuras de datos están poco definidas y los patrones de acceso son un caos. La consecuencia es código menos legible, menos eficiente, menos paralelizable y más difícil de debugear. Personalmente, me fui dando cuenta de la obsolescencia de
weak_ptr
conforme fui interiorizando y aplicando la regla de Sean Parent de “no raw pointers”. Cuando se organiza el código en estructuras que se comportan como valores, y se intenta que la mayor parte de relaciones en el programa sean de la parte con el todo, es decir que para cada dos objetos A y B, A es parte de B, B es parte de A, o no están relacionados, los usos de
weak_ptr
desaparecen y las situaciones donde un
weak_ptr
podría tener sentido pasan a ser lugares sospechosos donde la estructura está poco definida. Es importante entender también que
shared_ptr
debería ser entendido como una herramienta útil para la implementación interna de tipos que abstraigan relaciones más complejas, como por ejemplo la optimización de copias en objetos inmutables o el canal de comunicación compartido entre
promise
y
future
, pero nunca o muy rara vez como parte de la interfaz o como un miembro más en tipos que no tienen como objetivo abstraer ese pointer.
shared_ptr
tiene unas implicaciones semánticas muy complejas ya que, aunque es copiable, es improbable que su copia mantenga un significado con sentido bajo la regla de 0 cuando está contenido en otras clases, y por lo tanto debería ser abstraído con cuidado para el código de usuario.
Logo of RSS.