C++ Moderno y Gestión de Memoria: Smart Pointers (unique_ptr, shared_ptr)
Lo Que Dominarás en Este Tutorial
- Understand unique ownership with std::unique_ptr and zero-cost abstraction.
1. The Power of std::unique_ptr and Move Semantics
std::unique_ptr automatically deallocates memory when it goes out of scope.
#include <iostream>
#include <memory>
void runWorker() {
auto conn = std::make_unique<int>(42);
// Destructor called automatically when out of scope
}
Evaluación Rápida: Pon a Prueba tus Conocimientos
Preguntas Frecuentes
2. Paso a paso: refactoriza una clase con punteros crudos a punteros inteligentes
La teoría se vuelve hábito la primera vez que refactorizas una clase que posee a mano un objeto en el heap. Esta es la forma clásica, anterior al C++ moderno: un Car que crea un Engine con new y lo borra en el destructor:
class Engine {
public:
void start() {}
};
class Car {
Engine* engine; // owning raw pointer
public:
Car() : engine(new Engine) {}
~Car() { delete engine; } // manual cleanup
// Compiler-generated copy operations copy the POINTER,
// so two Cars delete the same Engine. Double free.
};
El problema no es el delete; es que la propiedad es invisible. Nada en el tipo dice que Car es dueño del Engine, así que el compilador genera sin problema operaciones de copia que duplican esa obligación. La refactorización convierte la propiedad en el propio tipo:
#include <memory>
#include <utility>
class Engine {
public:
void start() {}
};
class Car {
std::unique_ptr<Engine> engine; // ownership is now visible
public:
Car() : engine(std::make_unique<Engine>()) {}
// unique_ptr is move-only, so Car is move-only too:
// copies are deleted by the compiler, moves transfer ownership.
Car(Car&&) noexcept = default;
Car& operator=(Car&&) noexcept = default;
Engine& getEngine() { return *engine; } // observer access, no ownership
};
Tres reglas completan la refactorización dondequiera que la apliques. Las funciones factoría devuelven std::unique_ptr, para que la transferencia de propiedad quede escrita en la firma. Las funciones que solo observan un objeto reciben una referencia o un puntero crudo obtenido con .get(): observar es gratis y no reclama nada. Y una clase que guarda un unique_ptr casi nunca vuelve a necesitar un destructor escrito a mano: la Regla del Cero sustituye a la Regla de los Cinco.
3. Un ejemplo completo
Una caché de texturas para un juego es el problema canónico de propiedad compartida: muchos sprites comparten una misma textura cargada, la textura debe descargarse cuando el último usuario la suelta y la propia caché no debe mantenerlo todo vivo para siempre. Esa combinación —shared_ptr para quienes usan la textura y weak_ptr para la caché— es todo el diseño:
#include <iostream>
#include <memory>
#include <string>
#include <unordered_map>
struct Texture {
std::string path;
explicit Texture(std::string p) : path(std::move(p)) {
std::cout << "Loaded " << path << "\n";
}
~Texture() { std::cout << "Unloaded " << path << "\n"; }
};
class TextureCache {
public:
std::shared_ptr<Texture> load(const std::string& path) {
if (auto it = cache_.find(path); it != cache_.end()) {
if (auto tex = it->second.lock()) { // still alive? reuse it
return tex;
}
}
auto tex = std::make_shared<Texture>(path);
cache_[path] = tex; // cache observes via weak_ptr
return tex;
}
private:
std::unordered_map<std::string, std::weak_ptr<Texture>> cache_;
};
int main() {
TextureCache cache;
{
auto hero = cache.load("hero.png"); // Loaded hero.png
auto heroAgain = cache.load("hero.png"); // reused, no reload
std::cout << "Same object: " << (hero == heroAgain) << "\n";
} // last shared_ptr dies -> Unloaded hero.png
auto bg = cache.load("bg.png"); // weak_ptr had expired -> fresh load
return 0;
}
Ejecútalo y la salida enseña la semántica: hero.png se carga una sola vez aunque load() se llamó dos veces, se descarga justo cuando se cierra el ámbito interno y la caché no prolonga la vida de ninguna textura ni una sola instrucción, porque un weak_ptr observa sin poseer. Cambia la caché a shared_ptr y cada textura vivirá hasta que muera la caché; esa diferencia de una palabra es el error de diseño con shared_ptr más común en el mundo real.
Paso 3: entrega la propiedad desde las funciones factoría y gestiona recursos que no son memoria
Las factorías completan el patrón: una función que crea un objeto lo devuelve como unique_ptr, de modo que quien llama recibe la propiedad sin ver nunca un new crudo. El mismo mecanismo gestiona recursos que no son memoria en absoluto. Un handle de archivo en C no tiene destructor, así que le aportas uno —un deleter personalizado— y RAII se encarga del resto:
#include <cstdio>
#include <memory>
struct FileCloser {
void operator()(std::FILE* f) const {
if (f) std::fclose(f);
}
};
// A unique_ptr whose "delete" is fclose
using UniqueFile = std::unique_ptr<std::FILE, FileCloser>;
UniqueFile openLogFile(const char* path) {
return UniqueFile(std::fopen(path, "a")); // ownership moves to caller
}
void appendLine(const char* path, const char* line) {
auto file = openLogFile(path); // closes on every exit path,
if (!file) return; // including exceptions
std::fputs(line, file.get()); // .get(): observe, don't own
}
Esta es la razón de fondo por la que la sección en profundidad te pide diseñar primero la propiedad: cuando la propiedad es un tipo, los archivos, los sockets y los handles de bases de datos obtienen la misma garantía de limpieza en una línea que los objetos del heap, sin bloques finally y sin cierres olvidados en el camino de error.
4. Solución de errores
- Error de compilación: «use of deleted function» al copiar. Intentaste copiar un unique_ptr —o una clase que contiene uno, como el Car de arriba—. El compilador está haciendo cumplir la propiedad única. Pasa el objeto por referencia para compartir el acceso, muévelo con std::move para transferir la propiedad o, solo si existe un segundo dueño genuino, cambia ese miembro a shared_ptr de forma deliberada.
- Caída al salir del programa, en un destructor, con «double free or corruption». Se construyeron dos dueños desde el mismo puntero crudo en algún sitio: dos punteros inteligentes creados a partir de un mismo new, o un puntero this envuelto por dos gestores distintos. Localiza cada lugar donde un puntero crudo entra en un puntero inteligente; en código sano ocurre exactamente una vez por asignación, en la llamada a make_unique o make_shared.
- LeakSanitizer reporta memoria «still reachable» al salir. Hay un ciclo de shared_ptr: A guarda a B, B guarda a A y ningún contador llega nunca a cero. Recompila con -fsanitize=address,leak, lee el ciclo en el informe y degrada la referencia de vuelta a weak_ptr: la corrección que prescribe la sección en profundidad, ahora aplicada a una fuga que puedes reproducir.
- Segfault cuando un observador sobrevive a su dueño. Algo guardó un puntero crudo o una referencia —un registro, un callback, un widget de interfaz— después de que el dueño unique_ptr se reiniciara o saliera de ámbito. El observador no tenía forma de saber que el objeto había muerto. Guarda un weak_ptr en el observador y haz lock() en cada uso, para que la expiración se pueda comprobar en lugar de resultar fatal.
Profundizando: punteros inteligentes en la práctica
Conocer la sintaxis de std::unique_ptr y std::shared_ptr es solo la mitad de la habilidad; la otra mitad es desarrollar instinto para diseñar la propiedad. En código de producción, los punteros inteligentes brillan cuando la propiedad se decide en la fase de diseño: las funciones fábrica devuelven std::unique_ptr para entregar la propiedad sin ambigüedad, los gestores de larga vida guardan std::shared_ptr solo para recursos genuinamente compartidos, y todo lo demás se pasa como un puntero crudo o una referencia que simplemente observa. Los equipos que adoptan esta disciplina comprueban que clases enteras de errores —fugas, dobles liberaciones, uso tras la liberación— dejan de aparecer en las revisiones de código, porque las propias firmas de los tipos documentan quién es responsable de cada objeto, y el compilador hace cumplir lo que antes la documentación solo prometía. El resultado es un código más corto y más seguro a la vez, porque asignar y liberar dejan de ser decisiones separadas y repartidas por todo un archivo.
Errores comunes
- Tirar de std::shared_ptr por defecto. La propiedad compartida oculta decisiones de diseño y añade a cada copia el costo de un conteo de referencias atómico. Empieza con std::unique_ptr y asciende solo cuando exista de verdad un segundo propietario.
- Envolver dos veces el mismo puntero crudo. Construir dos punteros inteligentes independientes desde un mismo puntero crudo crea dos propietarios que no saben nada el uno del otro, y el objeto termina destruido dos veces, con comportamiento indefinido como recompensa.
- Ciclos de propiedad que nunca liberan. Dos objetos que se guardan std::shared_ptr el uno al otro se mantienen vivos para siempre. Rompe el ciclo haciendo que la referencia de vuelta sea un std::weak_ptr.
- Recibir punteros inteligentes solo para mirar. Las funciones que se limitan a leer un objeto deberían aceptar un puntero crudo o una referencia. Recibir un std::shared_ptr por valor aumenta el conteo de referencias y anuncia una reclamación de propiedad que nunca quisiste hacer.
Preguntas frecuentes
¿Debería usar new y delete directamente alguna vez en C++ moderno?
Casi nunca en código de aplicación. std::make_unique y std::make_shared construyen el puntero inteligente sin un new desnudo en tu código, lo que mantiene la propiedad explícita y segura ante excepciones desde la primera línea.
¿Para qué sirve realmente std::weak_ptr?
Un weak_ptr observa un objeto gestionado por un shared_ptr sin prolongar su vida. Es la solución estándar para los ciclos de propiedad, y la herramienta adecuada para cachés y registros que deben comprobar si un objeto sigue existiendo sin mantenerlo vivo.
¿Los punteros inteligentes reemplazan a RAII, o son RAII?
Son RAII aplicado a la memoria: el destructor del objeto puntero libera el recurso automáticamente al salir del ámbito, exactamente igual que un guarda de bloqueo libera un mutex o un envoltorio de archivo cierra su descriptor.