RAII y punteros inteligentes
Deja que el destructor libere por ti: unique_ptr, shared_ptr y weak_ptr
RAII ata la vida de un recurso a la vida de un objeto: cuando el objeto muere, el recurso se libera, incluso si hay una excepción. Con unique_ptr, shared_ptr y weak_ptr esa idea se vuelve automática y elimina casi todas las fugas.
- Explicar RAII y el orden de destrucción al salir de un ámbito
- Usar unique_ptr para propiedad exclusiva y transferirla con std::move
- Compartir un objeto con shared_ptr y observar su contador de referencias
- Romper ciclos de propiedad con weak_ptr
- Aplicar la regla de cero y decidir cuándo un puntero crudo es aceptable
Antes necesitas: Referencias, new/delete y fugas de memoria · Memoria, stack, heap y punteros
En esta lección
- ¿Qué es RAII?
- ¿Para qué sirven los punteros inteligentes?
- ¿Cómo funciona por dentro?
- Sintaxis
- Ejemplo básico: propiedad exclusiva
- Ejemplo intermedio: compartir y observar
- Caso práctico: un buffer que se libera solo
- Visualización: transferir la propiedad
- Errores comunes
- Buenas prácticas
- Ejercicios
- Soluciones explicadas
- Resumen
- Evaluación
En la lección anterior liberabas memoria a mano y confiabas en no equivocarte. RAII propone lo contrario: no liberes tú. Ata el recurso a un objeto del stack y deja que su destructor se encargue. Como el compilador destruye ese objeto siempre que el ámbito termina —por retorno normal, por break o por una excepción—, la liberación deja de depender de tu memoria.
RAII son las siglas de Resource Acquisition Is Initialization: la adquisición del recurso ocurre en la inicialización del objeto. El nombre describe solo la mitad; la otra mitad es que la liberación ocurre en la destrucción. El patrón no sirve únicamente para memoria: también para archivos, sockets, mutex y conexiones de base de datos.
¿Qué es RAII?
RAII es una técnica de diseño en la que un objeto posee un recurso durante toda su vida. Su constructor lo adquiere y su destructor lo libera. No hay llamadas de limpieza repartidas por el código: el recurso tiene un dueño con una vida bien definida, y el lenguaje garantiza que ese dueño se destruye.
La analogía útil es una tarjeta de acceso a una sala. Al entrar la recibes (constructor), al salir la devuelves (destructor) aunque te vayas corriendo o te expulsen. Si la devolución dependiera de que te acordaras, tarde o temprano alguien se iría con la tarjeta y la sala quedaría bloqueada. Una fuga de memoria es exactamente eso: una tarjeta que nadie devolvió.
¿Para qué sirven los punteros inteligentes?
- Eliminar fugas: el destructor de
std::unique_ptrllama adelete; el deshared_ptrlibera cuando el último dueño desaparece. - Expresar propiedad en el tipo:
std::unique_ptr<T>dice «esto es mío y de nadie más»;T*no dice nada sobre quién debe liberar. - Sobrevivir a excepciones: si una función falla a mitad, los objetos locales ya construidos se destruyen igualmente.
- Evitar dobles liberaciones: la propiedad es única o está contada; no hay dos dueños que llamen a
deletesobre el mismo bloque. - Documentar la intención: un parámetro
const T&no posee; unstd::unique_ptr<T>por valor transfiere; unstd::shared_ptr<T>comparte. El código se lee sin comentarios.
¿Cómo funciona por dentro?
std::unique_ptr<T> es un envoltorio de un puntero crudo con dos operaciones clave: mover y destruir. No se puede copiar porque dos dueños exclusivos serían una contradicción; sí se puede mover, y al mover el puntero original queda en nullptr. Su tamaño es el de un puntero, igual que manejarlo a mano, así que no cuesta más que la alternativa manual.
std::shared_ptr<T> es más caro: guarda dos punteros, uno al objeto y otro a un bloque de control que contiene el contador de dueños (strong count), el contador de observadores (weak count) y el deleter. Cuando el strong count llega a cero, se destruye el objeto; cuando además el weak count llega a cero, se libera el bloque de control. Ese diseño permite que un weak_ptr sepa si el objeto sigue vivo sin impedir que se destruya.
La copia de un shared_ptr incrementa el contador de forma atómica, lo que lo hace seguro entre hilos pero también más lento que una copia de unique_ptr. std::make_shared<T> reserva el objeto y el bloque de control en una sola operación, mientras que std::shared_ptr<T>(new T) hace dos reservas. Esa diferencia importa en bucles ajustados y en proyectos grandes.
| Tipo | Propiedad | Se copia | Coste | Uso típico |
|---|---|---|---|---|
T* | Ninguna (observa) | Sí | Un puntero | Parámetro que no posee |
std::unique_ptr<T> | Exclusiva | No, solo se mueve | Un puntero | Dueño por defecto |
std::shared_ptr<T> | Compartida y contada | Sí, incrementa el contador | Dos punteros + bloque de control | Recursos realmente compartidos |
std::weak_ptr<T> | Ninguna, observa | Sí | Dos punteros | Romper ciclos y cachés |
Sintaxis
#include <iostream>
#include <memory>
int main() {
auto p = std::make_unique<int>(42); // dueño único
std::cout << *p << '\n'; // 42
auto q = std::move(p); // transfiere la propiedad
std::cout << (p == nullptr) << '\n'; // 1
std::cout << *q << '\n'; // 42
auto s = std::make_shared<int>(7);
std::cout << s.use_count() << '\n'; // 1
{
auto copia = s; // comparte propiedad
std::cout << s.use_count() << '\n'; // 2
}
std::cout << s.use_count() << '\n'; // 1
std::weak_ptr<int> observador = s; // no incrementa el contador
std::cout << s.use_count() << '\n'; // 1
if (auto fijado = observador.lock()) {
std::cout << *fijado << '\n'; // 7
}
return 0;
}std::make_unique<T>(args)construye el objeto en el heap y devuelve ununique_ptr; desde C++14 es la forma recomendada.std::move(p)no mueve memoria: transfiere la propiedad y dejapennullptr. Es una operación de coste constante.use_count()consulta cuántosshared_ptrposeen el objeto. No lo uses para lógica de negocio: en programas con hilos el valor puede cambiar entre la consulta y el uso.weak_ptr::lock()devuelve unshared_ptrválido si el objeto sigue vivo, o uno vacío si ya fue destruido. Siempre comprueba el resultado antes de desreferenciar.
Ejemplo básico: propiedad exclusiva
#include <iostream>
#include <memory>
void mostrar(const std::unique_ptr<int>& p) {
std::cout << *p << '\n';
}
int main() {
std::unique_ptr<int> p = std::make_unique<int>(10);
mostrar(p); // 10
std::unique_ptr<int> q = std::move(p);
if (!p) {
std::cout << "p ya no posee nada\n";
}
std::cout << *q << '\n'; // 10
return 0;
}mostrar recibe const std::unique_ptr<int>& porque solo necesita leer el valor y no participa en la propiedad. Si recibiera el unique_ptr por valor, la llamada tendría que moverlo y main se quedaría sin dueño. Después de std::move(p), p es comparable con nullptr y no debe desreferenciarse; q es el único dueño y libera el entero al final del ámbito.
Ejemplo intermedio: compartir y observar
#include <iostream>
#include <memory>
#include <string>
#include <vector>
struct Nodo {
std::string nombre;
std::vector<std::shared_ptr<Nodo>> hijos;
std::weak_ptr<Nodo> padre; // referencia sin propiedad
};
int main() {
auto raiz = std::make_shared<Nodo>(Nodo{"raiz", {}, {}});
auto hoja = std::make_shared<Nodo>(Nodo{"hoja", {}, raiz});
raiz->hijos.push_back(hoja);
std::cout << raiz->hijos.size() << '\n'; // 1
std::cout << raiz.use_count() << '\n'; // 1
std::cout << hoja.use_count() << '\n'; // 2
std::cout << hoja->padre.expired() << '\n'; // 0
if (auto p = hoja->padre.lock()) {
std::cout << p->nombre << '\n'; // raiz
}
return 0;
}La raíz posee a la hoja mediante hijos, así que la hoja tiene dos dueños: la variable hoja y el vector. La raíz solo tiene uno, porque padre es un weak_ptr y no cuenta. Si padre hubiera sido shared_ptr, raíz y hoja se poseerían mutuamente y ninguna de las dos se liberaría nunca: ese es el ciclo que debes evitar. expired() devuelve 0 porque el objeto sigue vivo; lock() obtiene un dueño temporal mientras lo usas.
Caso práctico: un buffer que se libera solo
Antes de los punteros inteligentes, la forma de aplicar RAII era escribir una clase con constructor y destructor. Este ejemplo muestra el mecanismo sin magia: el destructor llama a delete[] y el compilador se encarga de invocarlo en todas las salidas de la función. Fíjate también en las operaciones de copia borradas: un buffer que posee un bloque no debe copiarse sin decidir quién libera qué.
#include <iostream>
class Buffer {
public:
explicit Buffer(int n) : datos_(new int[n]{}), n_(n) {
std::cout << "reserva " << n_ << '\n';
}
~Buffer() {
delete[] datos_;
std::cout << "libera " << n_ << '\n';
}
Buffer(const Buffer&) = delete;
Buffer& operator=(const Buffer&) = delete;
int& operator[](int i) { return datos_[i]; }
const int& operator[](int i) const { return datos_[i]; }
int tam() const { return n_; }
private:
int* datos_;
int n_;
};
int main() {
Buffer b(3);
b[0] = 5;
b[2] = 9;
std::cout << b[0] + b[2] << '\n'; // 14
return 0;
}La salida es reserva 3, luego 14 y por último libera 3. El destructor se ejecuta al salir de main aunque no lo llames. Si dentro de main hubiera un return anticipado o una excepción, el orden sería el mismo. En código real escribirías std::vector<int> o std::unique_ptr<int[]>, pero entender esta clase es lo que te permite reconocer RAII en cualquier biblioteca.
Visualización: transferir la propiedad
Sigue los pasos y observa que el bloque del heap nunca se copia: solo cambia quién lo posee. En el paso de std::move, p queda vacío y q recibe la dirección. En reset(), el objeto se destruye y el heap vuelve a estar libre sin que hayas escrito delete.
p ──▶ 0x5500 dueño únicomake_unique reserva 4 bytes en el heap y construye 42. p guarda la dirección; el bloque pertenece a p.Ver código de esta simulación
std::unique_ptr<int> p = std::make_unique<int>(42);
std::cout << *p;
std::unique_ptr<int> q = std::move(p);
std::cout << *q;
q.reset();
// fin del ambito: los destructores no tienen nada que liberarCada paso muestra qué vive en el stack, qué se reserva en el heap y cómo apunta cada variable.
Errores comunes
- Ciclo de
shared_ptr: dos objetos se poseen mutuamente y sus contadores nunca llegan a cero. La memoria se libera solo al terminar el proceso. Usaweak_ptren la dirección de vuelta (hijo → padre) o rompe el ciclo explícitamente. - Construir dos dueños del mismo puntero:
std::shared_ptr<int> a(p); std::shared_ptr<int> b(p);crea dos bloques de control y dosdelete. Usamake_sharedo copia unshared_ptrexistente, nunca el puntero crudo. - Usar después de mover: tras
auto q = std::move(p);,*pes comportamiento indefinido. El moved-from es válido para destruir o reasignar, no para leer. - Guardar
get():T* crudo = sp.get();no prolonga la vida del objeto. Si el últimoshared_ptrmuere,crudoqueda colgante. - Devolver un
unique_ptrpor referencia: la referencia no transfiere propiedad y el objeto puede morir al salir de la función. Devuélvelo por valor; el movimiento es barato y el RVO lo optimiza. - Liberar memoria del stack:
int x; delete &x;es comportamiento indefinido.new/deletesolo se emparejan con memoria del heap.
Buenas prácticas
- Regla de cero: si tus clases no declaran destructor, copia ni movimiento, el compilador genera versiones correctas y tus miembros RAII hacen el resto. Diseña para no necesitar la regla de cinco.
- Usa
std::unique_ptrcomo dueño por defecto; solo pasa ashared_ptrcuando la propiedad sea realmente compartida y desconocida en tiempo de compilación. - Usa
std::make_uniqueystd::make_shared: evitannewexplícito y, en el segundo caso, ahorran una reserva. - Recibe punteros crudos o referencias para recursos que no posees; reserva
unique_ptrpor valor para transferir propiedad. - Usa
weak_ptrpara referencias hacia atrás, cachés y observadores que no deben mantener vivo al objeto. - Pasa
const std::shared_ptr<T>&solo si necesitas el contador; si solo usas el objeto,const T&es más claro y más rápido. - No mezcles
new/deletemanual con punteros inteligentes sobre el mismo bloque.
with open("datos.csv") as f:
texto = f.read()
# el archivo se cierra al salir del with#include <fstream>
#include <iterator>
#include <string>
void leer() {
std::ifstream f("datos.csv");
std::string texto((std::istreambuf_iterator<char>(f)),
std::istreambuf_iterator<char>());
} // el destructor cierra el archivoPython usa `with` para delimitar la vida del recurso; C++ usa el ámbito y el destructor. La idea es la misma: la limpieza se ejecuta sin que la escribas en cada salida. Los punteros inteligentes aplican ese patrón a la memoria.
Ejercicios
Convertir new/delete en unique_ptr
new int(7), lo imprime, le suma uno y lo libera con delete. Reescríbelo usando std::make_unique, sin ningún new ni delete, y comprueba que imprime lo mismo: 7 y luego 8.Pista
Sustituye int* p = new int(7); por auto p = std::make_unique<int>(7);. El acceso sigue siendo *p y no necesitas liberar nada.
Romper un ciclo con weak_ptr
struct Nodo { std::string nombre; std::shared_ptr<Nodo> otro; };. Crea dos nodos que se apunten mutuamente con shared_ptr, imprime a.use_count() y explica por qué vale 2. Después cambia otro por std::weak_ptr<Nodo> en uno de los dos lados, imprime de nuevo los contadores y comprueba que el ciclo desaparece.Pista
Un weak_ptr no incrementa el strong count. Para usarlo necesitas lock(), que devuelve un shared_ptr temporal.
auto p = std::(5); std::cout << *p << '\n'; auto q = std::(p); std::cout << (p == ) << '\n'; // 1
Crear un dueño único usa `make_unique`; transferirlo, `std::move`; y el original queda vacío.
Soluciones explicadas
El primer ejercicio es una sustitución mecánica, y ese es justamente el punto: la mayor parte del código moderno no necesita new. El segundo muestra el único caso en el que shared_ptr no basta. Los contadores valen 2 porque cada nodo es poseído por su variable y por el otro nodo; con el ciclo, el programa termina con dos bloques que nunca se liberan. Cambiar una de las dos flechas a weak_ptr rompe la simetría: el nodo observado puede morir, el observador sigue vivo y el contador vuelve a cero. La regla práctica es que la propiedad fluya en una sola dirección y las referencias de vuelta sean débiles.
Resumen
- RAII ata la vida de un recurso a un objeto: constructor adquiere, destructor libera.
- El destructor se ejecuta también durante el desenrollado de la pila, así que una excepción no produce fugas.
unique_ptrexpresa propiedad exclusiva, no se copia y se transfiere constd::move.shared_ptrcomparte propiedad con un contador atómico y un bloque de control; cuesta más que un puntero crudo.weak_ptrobserva sin poseer y es la herramienta para romper ciclos y evitar referencias colgantes.make_uniqueymake_sharedson la forma recomendada de crear los punteros;make_sharedahorra una reserva.- La regla de cero: si no gestionas recursos a mano, no escribas destructor, copia ni movimiento.
- Los punteros crudos siguen siendo válidos como parámetros que no poseen; lo que no deben ser es dueños olvidados.
Evaluación
¿Qué tipo expresa «este objeto tiene un único dueño que lo liberará»?
auto a = std::make_shared<int>(1); auto b = a; ¿qué ocurre con el contador de a?
Dos objetos se referencian con shared_ptr en ambos sentidos. ¿Cuál es la consecuencia?
¿Ya lo entendiste?
Márcala como completada para llevar tu progreso y desbloquear el siguiente paso de la ruta.