AulaML Python · C++
C++ intermedio · 46 min · Memoria y punteros

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.

Lección 3 de 3 del módulo
Al terminar esta lección podrás
  • 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
  1. ¿Qué es RAII?
  2. ¿Para qué sirven los punteros inteligentes?
  3. ¿Cómo funciona por dentro?
  4. Sintaxis
  5. Ejemplo básico: propiedad exclusiva
  6. Ejemplo intermedio: compartir y observar
  7. Caso práctico: un buffer que se libera solo
  8. Visualización: transferir la propiedad
  9. Errores comunes
  10. Buenas prácticas
  11. Ejercicios
  12. Soluciones explicadas
  13. Resumen
  14. 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_ptr llama a delete; el de shared_ptr libera 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 delete sobre el mismo bloque.
  • Documentar la intención: un parámetro const T& no posee; un std::unique_ptr<T> por valor transfiere; un std::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.

TipoPropiedadSe copiaCosteUso típico
T*Ninguna (observa)SíUn punteroParámetro que no posee
std::unique_ptr<T>ExclusivaNo, solo se mueveUn punteroDueño por defecto
std::shared_ptr<T>Compartida y contadaSí, incrementa el contadorDos punteros + bloque de controlRecursos realmente compartidos
std::weak_ptr<T>Ninguna, observaSíDos punterosRomper ciclos y cachés

Sintaxis

CPP sintaxis.cpp
#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 un unique_ptr; desde C++14 es la forma recomendada.
  • std::move(p) no mueve memoria: transfiere la propiedad y deja p en nullptr. Es una operación de coste constante.
  • use_count() consulta cuántos shared_ptr poseen 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 un shared_ptr vá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

CPP basico.cpp
#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

CPP intermedio.cpp
#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é.

CPP buffer.cpp
#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.

Mapa de memoria ·unique_ptr: crear, mover y liberar
1/6
STACK memoria automática
p 0x7ffc1000
0x5500
std::unique_ptr<int>
↕
HEAP memoria dinámica
*p 0x5500
42
int
p ──▶ 0x5500 dueño único
make_unique reserva 4 bytes en el heap y construye 42. p guarda la dirección; el bloque pertenece a p.
última acción: `make_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 liberar

Cada 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. Usa weak_ptr en 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 dos delete. Usa make_shared o copia un shared_ptr existente, nunca el puntero crudo.
  • Usar después de mover: tras auto q = std::move(p);, *p es 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 último shared_ptr muere, crudo queda colgante.
  • Devolver un unique_ptr por 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/delete solo 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_ptr como dueño por defecto; solo pasa a shared_ptr cuando la propiedad sea realmente compartida y desconocida en tiempo de compilación.
  • Usa std::make_unique y std::make_shared: evitan new explícito y, en el segundo caso, ahorran una reserva.
  • Recibe punteros crudos o referencias para recursos que no posees; reserva unique_ptr por valor para transferir propiedad.
  • Usa weak_ptr para 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/delete manual con punteros inteligentes sobre el mismo bloque.
Quién libera: recolector frente a destructor
Python
PYTHON recurso.py
with open("datos.csv") as f:
    texto = f.read()
# el archivo se cierra al salir del with
C++
CPP recurso.cpp
#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 archivo

Python 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

Ejercicio 1

Convertir new/delete en unique_ptr

El código de partida reserva un entero con 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.

Ejercicio 2

Romper un ciclo con weak_ptr

Define 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.

Completar código Completa la transferencia de propiedad
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_ptr expresa propiedad exclusiva, no se copia y se transfiere con std::move.
  • shared_ptr comparte propiedad con un contador atómico y un bloque de control; cuesta más que un puntero crudo.
  • weak_ptr observa sin poseer y es la herramienta para romper ciclos y evitar referencias colgantes.
  • make_unique y make_shared son la forma recomendada de crear los punteros; make_shared ahorra 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

Evaluación 1

¿Qué tipo expresa «este objeto tiene un único dueño que lo liberará»?

Evaluación 2

auto a = std::make_shared<int>(1); auto b = a; ¿qué ocurre con el contador de a?

Evaluación 3

Dos objetos se referencian con shared_ptr en ambos sentidos. ¿Cuál es la consecuencia?

Tus notas

¿Ya lo entendiste?

Márcala como completada para llevar tu progreso y desbloquear el siguiente paso de la ruta.