El placement new es una variante del operador new que no reserva memoria, sino que simplemente inicializa un objeto en una dirección de memoria ya existente. Mientras que un new convencional realiza dos pasos atómicos: 1) llamar a operator new para alocar memoria en el heap y 2) ejecutar el constructor, el placement new salta el primer paso. Esto te permite separar la gestión de la memoria (cuándo reservar el bloque) de la gestión del ciclo de vida (cuándo construir el objeto).
Para que esto funcione, la dirección de memoria proporcionada debe cumplir dos requisitos críticos: debe tener el tamaño suficiente para el tipo T y debe estar correctamente alineada para T. Si intentas construir un objeto double en una dirección que no es múltiplo de 8, provocarás comportamiento indefinido (UB), lo que en arquitecturas como ARM puede resultar en un error de alineación (bus error) o en una degradación masiva de rendimiento en x86.
Esta técnica es fundamental cuando implementas estructuras de datos de alto rendimiento, como memory pools o contenedores personalizados (como un std::vector que gestiona su propio buffer), donde quieres evitar las llamadas costosas al sistema operativo durante el tiempo de ejecución. También es vital en sistemas de serialización que reconstruyen objetos directamente sobre un búfer de red.
El riesgo es alto: al delegar la responsabilidad de la memoria al programador, el compilador ya no puede ayudarte. Si olvidas llamar manualmente al destructor del objeto (ptr->~T()), los recursos (memoria heap interna, descriptores de archivos, mutexes) no se liberarán, aunque la memoria del búfer se reutilice. Además, si reconstruyes un objeto sobre una dirección donde ya existía otro, el compilador podría optimizar mal el código basándose en la suposición de que el objeto en esa dirección no ha cambiado; para evitar esto, en C++17 [C++17] se introdujo std::launder para indicarle al optimizador que debe “olvidar” la identidad del objeto anterior y reconocer el nuevo objeto en esa dirección.
#include <iostream>
#include <cstddef> // std::byte
#include <new> // placement new, std::launder
#include <type_traits>
struct Monitorizado {
int id;
Monitorizado(int i) : id(i) {
std::cout << " [Constructor] Objeto " << id << " creado\n";
}
~Monitorizado() {
std::cout << " [Destructor] Objeto " << id << " destruido\n";
}
void saludar() const {
std::cout << " [Método] Hola desde " << id << "\n";
}
};
// Un pool de memoria extremadamente simple para un solo objeto
template <typename T>
class SimplePool {
// Usamos alignas para asegurar que el buffer tenga la alineación correcta para T.
// std::aligned_storage está deprecated en C++23; el estándar actual
// recomienda usar un array de std::byte con alignas.
alignas(T) std::byte buffer[sizeof(T)];
T* ptr = nullptr;
public:
~SimplePool() {
if (ptr) {
// El destructor debe llamarse manualmente si el objeto fue construido
ptr->~T();
}
}
template <typename... Args>
T* construct(Args&&... args) {
// Placement new: construye T en el espacio reservado en buffer
ptr = new (buffer) T(std::forward<Args>(args)...);
return ptr;
}
T* get_ptr() {
// std::launder es necesario para informar al compilador que el objeto
// en 'buffer' ha cambiado, evitando optimizaciones basadas en la
// dirección de memoria original.
return std::launder(reinterpret_cast<T*>(buffer));
}
void reset() {
if (ptr) {
ptr->~T();
ptr = nullptr;
}
}
};
int main() {
SimplePool<Monitorizado> pool;
std::cout << "--- Primera construcción ---\n";
Monitorizado* m1 = pool.construct(101);
m1->saludar();
std::cout << "--- Reutilización (Re-construcción) ---\n";
// Reutilizamos la misma memoria para un nuevo objeto
// Primero destruimos el anterior manualmente
m1->~Monitorizado();
// Placement new sobre la misma dirección
m1 = pool.construct(202);
// Usamos el puntero obtenido mediante la lógica de la clase (que usa std::launder)
pool.get_ptr()->saludar();
std::cout << "--- Fin del programa (Destrucción automática) ---\n";
return 0;
}
Análisis del código
En SimplePool, el uso de alignas(T) std::byte buffer[sizeof(T)] es la forma moderna y segura de reservar espacio. std::byte es preferible a char porque no tiene propiedades de tipo carácter o entero, comunicando que estamos tratando con memoria cruda. La cláusula alignas(T) garantiza que el compilador alinee buffer siguiendo las restricciones de la arquitectura para el tipo T, evitando el UB por mala alineación.
La línea ptr = new (buffer) T(std::forward<Args>(args)...); es el núcleo del placement new. Aquí, el compilador no pide memoria al heap, sino que simplemente ejecuta el constructor de T usando la dirección de buffer como this.
En main, cuando realizamos la segunda construcción, es vital llamar a m1->~Monitorizado(). Si no lo hiciéramos, y Monitorizado tuviera, por ejemplo, un std::string como miembro, ese string sufriría una fuga de memoria (memory leak) porque su destructor nunca se ejecutaría.
Finalmente, std::launder en get_ptr() resuelve un problema de provenance. Cuando reconstruyes un objeto en la misma dirección de memoria, el puntero original podría ser considerado “inválido” por el optimizador si este asume que el objeto en esa dirección es inmutable tras su construcción inicial. std::launder garantiza que el compilador trate la dirección como un nuevo objeto, permitiendo un acceso seguro y correcto.
El error frecuente
El error más sutil y peligroso con placement new ocurre cuando se intenta reconstruir un objeto en un búfer sin llamar al destructor del objeto anterior, especialmente si el objeto posee recursos gestionados por RAII.
// ERROR: UB / Fuga de recursos
char buffer[sizeof(std::string) + 10]; // Mal: falta alineación y tamaño incierto
std::string* s = new (buffer) std::string("Hola mundo");
// ...
s = new (buffer) std::string("Nuevo string"); // ¡ERROR! El primer string perdió su memoria
En este caso, el std::string original tenía una asignación en el heap para su contenido. Al sobrescribir la memoria con un segundo placement new sin llamar a ~std::string(), la dirección del puntero interno del primer string se pierde sin que se llame a delete, provocando una fuga de memoria. Además, el uso de char buffer[] sin alignas puede causar fallos de segmentación en arquitecturas estrictas. Para detectar esto, es indispensable compilar con AddressSanitizer (-fsanitize=address) en Clang/GCC, que detectará la fuga de memoria inmediatamente.
N° 114