Punteros modernos con C++
Por Arrecio
Lo primero que tenemos que saber es que para los punteros inteligentes (la versión de la librería estándar) es necesario incluir la librería <memory>:
#include <memory>
En lenguajes donde la gestión de la memoria está prácticamente en manos del programado es tremenda importante seguir adecuadamente el patrón de diseño RAII con objeto de evitar los siempre temidos memory leaks. Lenguajes más modernos como Rust implementan nativamente este patrón mediante su sistema de ownership. Con un poco de cuidado y de experiencia con este lenguaje podemos implementar algo bastante similar.
Hablando de estos punteros inteligentes se consideran de tres tipos bien diferenciados: std::unique_ptr, std::shared_ptr, std::weak_ptr. Son los mismos tipos introducidos inicialmente en las librerías boost pero que finalmente fueron incorporados en la std, manteniéndose en cualquier caso en boost entre otras cosas por cuestiones de compatibilidad con proyectos antiguos.
Vamos a introducir sus diferencias con un dibujo y a partir de ahí vamos comentando:
La figura anterior es auto explicativa pero mejor empezar hablando de la gestión de punteros clásicos en C. Para gestionar dinámicamente la memoria con C se usan diversas funciones para reservar memoria: malloc, calloc, realloc, y una función para liberar memoria: free. El mismo tutorial del enlace anterior contiene otro enlace a una descripción de cómo C gestiona la memoria. Si hablamos de C++ tenemos los operadores new y delete cuyas funciones son alojar y desalojar objetos de manera dinámica.
En C, y en C++, tendremos dos tipos de datos alojados en memoria, los alojados estáticamente (en el stack) y los dinámicos (que van al heap). Que un dato sea alojado estáticamente no implica que siempre esté alojado en memoria ya que el stack va creciendo y decreciendo en tanto que la ejecución va progresando, entrando y saliendo de las distintas funciones que se van llamando. En la cima del stack probablemente estarán los datos globales alojados estáticamente y a medida de que avanza la ejecución y se entran en distintos entornos locales la pila crece para alojarlos, cuando se sale de esos entornos locales en los que están definidos, al volver la pila al mismo punto, aunque los datos sigan "físicamente" en memoria ya no están disponibles.
Por otro lado los alojados dinámicamente van al heap, y su ciclo de vida no depende del punto en el que se encuentre la ejecución del programa. Una vez que se reserva memoria para ellos esa memoria permanecera ocupada hasta que deliberadamente se libere. Esta situación obliga a los programadores a estar verdaderamente pendientes de liberar la memoria cuando no se tiene intención de utilizar el dato en el futuro de lo contrario incurriremos en los ya citados memory leaks. Si olvidamos liberar la memoria de datos que reservamos recursiva y masivamente, como por ejemplo desde un función que llamamos en gran cantidad de ocasiones, el daño puede terminar en un colapso del proceso, cuando menos.
Los punteros inteligentes están pensados para liberar al programador de la responsabilidad de liberar la memoria. Cuando se declara un puntero inteligente, este de manera interna ejecuta cierta lógica por la que se determinará el momento en el que la memoria apuntada debe ser liberada.
Empecemos por unique_ptr. Este es un objeto que se declara como cualquier otro objeto alojado estáticamente dentro de un contexto, entorno o ámbito de ejecución (usaré el término conexto en lo sucesivo) normalmente local, por decirlo de alguna manera siempre encerrado entre dos llaves. Ese objeto contiene entre sus miembros la dirección que apunta a los datos alojados dinámicamente. Cuando se sale del contexto del unique_ptr entonces necesariamente se lleva a su destructor, que se encargará de librera la memoria.
Esta propiedad de los unique_ptr puede evitarnos muchos problemas pero a priori puede parecer muy poco versatil. Si el ciclo de vida del dato dinámico es un contexto dado entonces ¿qué utilidad puede tener crear un objeto alojado dinámicamente respecto a una declaración alojada estáticamente?. La respuesta probablmente sería ninguna y es donde aparece el sentido de la transferencia de propiedad, algo que forma parte del paradigma de otros lenguajes como Rust.
Un unique_ptr puede moverse se un lugar a otro, siendo habitual moverlo por ejemplo dentro de contenedores. Cuando instanciamos un unique_ptr dentro de una función, al salir de la misma el destructor del unique_ptr liberará la memoria que estuviera siendo apuntada por el puntero interno, en la cantidad correspondient al tipo del dato contenido. Pero si antes realizamos un move hacia otro unique_ptr el primero de ellos quedará vacío de contenido y al destruirse no realizará ninguna operación sobre la memoria. El segundo unique_ptr, al que hemos transferido el puntero, tendrá igualmente otro contexto de ejecución y cuando se salga del mismo será cuando se realicen las operaciones de liberación, a no ser que previamente se haya movido nuevamente el puntero.
Podemos echar un vistazo a este artículo para ver algunos ejemplos. Decir ahora simplemente que se recomienda que la reserva de memoria se realice a través de las distintas utilidades de la librería std en lugar de pasar directamente el puntero. Por ejemplo:
#include <memory>
int main()
{
// uso permitido
std::unique_ptr<int> int_ptr_1(new int(5));
// uso recomendado
std::unique_ptr<int> int_ptr_2 = std::make_unique<int>(5);
// salimos del conexto, se liberan ambos datos dinámicos
}
El siguiente puntero inteligente a examinar es shared_ptr. Se trata de un contenedor de un puntero al que asocia un contador de referencias. Si se utiliza adecuadamente el objeto apuntado vivirá mientras existan objetos shared_ptr que contengan el puntero al mismo. Resulta clara la diferencia con unique_ptr ya que mientras este únicamente puede tener un dueño, en el caso de shared_ptr el ownership puede recaer sobre sobre más de un lugar. Esto se refleja en que un objeto unique_ptr no puede ser copiado y si se utiliza una asignación directa (con el símbolo =) se producirá un error en tiempo de compilación. Lo que si puede el unique_ptr es ser transferido mediante el uso de move tal y como indicamos antes y como además se observa en el boceto inicial.
Una buena práctica en el uso de shared_ptr es utilizar make_shared para instanciarlo, de la misma manera que hemos visto en el ejemplo anterior para unique_ptr con make_unique. Existe esta gran explicación en Stack Overflow acerca del porqué es tan recomendado.
Le pedí a Gemini un ejemplo de uso de shared_ptr que he modificado un poco. La clave está en como se interpreta el copiado del objeto y en concreto en la parte en la que se hace ptr2 = ptr1 que en este caso si está permitida y en esencia es donde se encuentra la utilidad de este tipo de punteros inteligentes:
#include <iostream>
#include <memory>
class Objeto {
public:
Objeto() { std::cout << "Objeto creado en memoria\n"; }
~Objeto() { std::cout << "Objeto destruido y memoria liberada\n"; }
void saludar() { std::cout << "¡Hola desde Objeto!\n"; }
};
int main() {
std::cout << "--- Inicio del programa ---\n";
// Creamos el shared_ptr usando std::make_shared
std::shared_ptr<Objeto> ptr1 = std::make_shared<Objeto>();
// .use_count() nos dice cuántos punteros están compartiendo este objeto
std::cout << "Contador de referencias: " << ptr1.use_count() << "\n"; // Imprime: 1
{
// Forzamos un nuevo ámbito
std::cout << "\n--- Entrando a un nuevo bloque ---\n";
// ptr2 comparte la propiedad del mismo objeto que ptr1
std::shared_ptr<Objeto> ptr2 = ptr1;
// Comprobamos como se aumenta el contador
std::cout << "Contador de referencias: " << ptr1.use_count() << "\n";
// Ambos pueden usar el objeto sin problema
ptr2->saludar();
std::cout << "--- Saliendo del bloque ---\n";
} // Aquí ptr2 se destruye al salir de su ámbito. El contador baja a 1.
// Comprobamos que el contador de referencia está en 1 (esa debe ser la salida en este punto)
std::cout << "\nContador de referencias tras salir del bloque: " << ptr1.use_count() << "\n";
std::cout << "--- Fin del programa ---\n";
return 0;
} // Aquí ptr1 se destruye al terminar main(). El contador llega a 0 y el objeto Objeto se destruye automáticamente.
No deja de ser un programa sencillo y compilable sin grandes dificultades incluso en un copilador online como https://onecompiler.com/cpp#draft-cs3n. Si el lector no conocía el sitio es una buena oportunidad para probarlo y observar la salida del programa.
Para alguien más curioso le animo a echar un vistazo al código fuente de la versión de boost, y ya de paso al resto de punteros inteligentes y utilidades.
Antes de ver weak_ptr vamos a señalar cual es el problema que puede llevarnos a la necesidad de usarlo. Lo expreso así porque este debería ser el tipo de puntero inteligente que sólo deberíamos usar cuando no encajan ninguno de los anteriores. Por lo general deberemos intentar usar siempre unique_ptr y cuando necesariamente necesitemos varias instancias apuntando a un mismo objeto entonces pasaremos a shared_ptr, dejando weak_ptr únicamente cuando el anterior puede ser causante de problemas como es el caso de las referencias cruzadas.
Un ejemplo de referencia cruzada sería el siguiente. Imaginemos que estamos desarrollando una aplicación para un taller. En nuestra base de datos tendremos coches y mecánicos. A un coche le asignamos un mecánico trabajando en el mismo, y el mecánico tiene a su vez una referencia al coche con el que está trabajando. Si ambos se almacenan dinámicamente la solución del shared_ptr y el siguiente código explica porqué:
#include <iostream>
#include <memory>
struct Mecanico;
struct Coche {
std::shared_ptr<Mecanico> mecanico_asignado;
~Coche() { std::cout << "Coche destruido\n"; }
};
struct Mecanico {
std::shared_ptr<Coche> coche_en_reparacion;
~Mecanico() { std::cout << "Mecanico destruido\n"; }
};
int main() {
auto coche = std::make_shared<Coche>(); // El contador de coche es 1
auto mecanico = std::make_shared<Mecanico>(); // El contador de mecanico es 1
mecanico->coche_en_reparacion = coche; // El contador de coche es 2
coche->mecanico_asignado = mecanico; // El contador de mecanico es 2
// Comprobamos contadores
std::cout << "Contador de referencias del coche: " << coche.use_count() << "\n";
std::cout << "Contador de referencias del mecanico: " << mecanico.use_count() << "\n";
return 0;
// Al salir, los contadores bajan a 1.
// Ningún nodo se destruye. ¡Memory Leak!
}
Como se observa en el dibujo, la referencia cruzada existe cuando dos objetos contienen campos apuntándose entre ellos. Para evitar la fuga de memoria lo que podemos hacer es trabajar con weak_ptr, tipo de puntero inteligente al que se puede asignar un shared_ptr sin que se incremente el contador. Esta sería la manera de usarlo como solución de nuestro problema:
#include <iostream>
#include <memory>
struct Mecanico;
struct Coche {
std::shared_ptr<Mecanico> mecanico_asignado;
~Coche() { std::cout << "Coche destruido\n"; }
};
struct Mecanico {
std::weak_ptr<Coche> coche_en_reparacion;
~Mecanico() { std::cout << "Mecanico destruido\n"; }
};
int main() {
auto coche = std::make_shared<Coche>(); // El contador de coche es 1
auto mecanico = std::make_shared<Mecanico>(); // El contador de mecanico es 1
// La asignación a un weak_ptr no aumenta el contador
mecanico->coche_en_reparacion = coche; // El contador de coche es 1
coche->mecanico_asignado = mecanico; // El contador de mecanico si aumenta y es 2
// Comprobamos contadores
std::cout << "Contador de referencias del coche: " << coche.use_count() << "\n";
std::cout << "Contador de referencias del mecanico: " << mecanico.use_count() << "\n";
return 0;
// Al salir, primero los contadores bajan una unidad por lo que se destruye el coche.
// al destruirse el coche el mecanico baja una nueva unidad por lo que se queda a 0.
// Todos los objetos quedan destruidos.
}
Respecto a que objeto debería contener el weak_ptr esto es indiferente y dependerá del diseño, algo que se termina decidiendo a base de experiencia. Y es que la ventaja del uso de weak_ptr para evitar referencias cruzadas añade cierta complejidad a su uso ya que los objetos apuntados no pueden ser utilizados sin antes llamar a su método .lock().
Además como weak_ptr no incrementa el contador puede darse el caso de que el weak_ptr aún exista mientras que la memoria que ocupaba el objeto apuntado ya fue liberada al llegar a 0 el contador del shared_ptr a partir del cual se creó. Para evitar un uso indeseado del objeto, que probablemente no acabaría bien, se debe comprobar que el objeto apuntado aún no haya sido destruido lo cual se puede hacer mediante el uso de la función .expired().
Y hasta aquí la introducción en este tipo de gestores de objetos dinámicos que provee la librería std. Todo esto es algo que cualquier programador de C++ debe controlar a la perfección a día de hoy en el que el uso de raw pointers se entiende como una completa mala práctica salvo casos debidamente justificados.