Historia: En C++98, la gestión de recursos seguía la Regla de Tres: si una clase necesitaba un destructor, un constructor de copia o un operador de asignación de copia personalizados, probablemente necesitaba los tres. Cuando C++11 introdujo las semánticas de movimiento, esto se convirtió en la Regla de Cinco, añadiendo constructor de movimiento y operador de asignación de movimiento. El comité estándar tomó un enfoque conservador: declarar cualquier destructor (incluso los triviales) inhibe la generación implícita de operaciones de movimiento para prevenir movimientos superficiales accidentales de recursos gestionados por destructores.
Problema: Cuando escribes ~MyClass() = default; dentro de la definición de la clase, creas un destructor "declarado por el usuario". Según el estándar de C++ ([class.copy.ctor]/3), esta presencia suprime la declaración implícita tanto del constructor de movimiento como del operador de asignación de movimiento. Como resultado, el compilador trata la clase como solo copia, volviendo silenciosamente a las semánticas de copia costosas durante las realocaciones de std::vector o optimizaciones de retorno por valor, a pesar de que el destructor no realiza ningún trabajo real.
Solución: Para mantener la generación implícita de movimiento, declara el destructor solo dentro de la clase y proporciona la definición por defecto fuera:
class Optimized { public: ~Optimized(); // Solo declarado aquí std::array<char, 4096> buffer; }; Optimized::~Optimized() = default; // Definido fuera
Esto hace que el destructor sea "proporcionado por el usuario" pero no "declarado por el usuario" en el punto donde el compilador decide generar movimientos. Alternativamente, declara explícitamente los cinco miembros especiales, o preferiblemente sigue la Regla de Cero reemplazando recursos crudos por std::unique_ptr o contenedores.
Nos encontramos con esto en un motor de comercio de alta frecuencia procesando objetos MarketDataPacket. La clase contenía un búfer fijo de 4KB para datos de red:
class MarketDataPacket { public: ~MarketDataPacket() = default; // Escrito en el encabezado para "claridad" char buffer[4096]; };
Después de la migración a C++11, el perfilado de latencia reveló que el 40% de los ciclos de CPU se gastaron en memcpy a pesar de devolver paquetes por valor. El culpable fue el destructor por defecto en la clase, que eliminó inadvertidamente los movimientos implícitos y forzó copias durante el crecimiento de std::vector y los retornos de funciones.
Solución 1: Declara explícitamente noexcept el constructor de movimiento y la asignación. Esto soluciona inmediatamente el problema de rendimiento al habilitar movimientos. Sin embargo, requiere mantener manualmente estas funciones al agregar miembros, arriesga discrepancias en la especificación de excepciones si se involucran punteros crudos y añade código adicional que viola la Regla de Cero.
Solución 2: Mueve la definición del destructor al archivo .cpp con MarketDataPacket::~MarketDataPacket() = default;. Esto restaura los movimientos generados por el compilador mientras mantiene el destructor trivial. Mantiene la abstracción sin costo adicional y permite optimizaciones del compilador como omitir llamadas al destructor para objetos no utilizados. La única desventaja es que requiere una unidad de compilación separada, lo cual fue aceptable.
Solución 3: Reemplaza el búfer crudo con std::vector<uint8_t> o std::unique_ptrstd::byte[]. Esto logra un cumplimiento perfecto de la Regla de Cero. Sin embargo, esto introduce indireccionamiento o sobrecarga de asignación en el montón que no es aceptable en caminos comerciales sensibles a microsegundos donde la localidad de caché es crítica.
Seleccionamos Solución 2. Al mover la definición por defecto fuera de la clase, restauramos los movimientos implícitos, redujimos la latencia de procesamiento de paquetes de 12μs a 3μs y mantenemos la destrucibilidad trivial permitiendo optimizaciones agresivas del compilador.
¿Por qué el compilador distingue entre la definición por defecto en la clase y fuera de la clase cuando las semánticas son las mismas?
La diferencia es sintáctica, no semántica. C++ utiliza un modelo de análisis de una sola pasada para las definiciones de clase. Cuando el compilador llega a la llave de cierre de la clase, debe decidir si generar operaciones de movimiento implícitas. Si ve = default dentro, el destructor es "declarado por el usuario" en ese punto, activando las reglas de supresión según [class.copy]/7. El compilador no puede "mirar adelante" a la definición externa para cambiar esta decisión. Esta es una restricción fundamental del modelo de compilación de C++.
¿Marcar el destructor como noexcept restaura los movimientos implícitos?
No. La supresión de la generación de movimientos implícitos depende únicamente de si el destructor es declarado por el usuario, no de su especificación de excepción. Si bien marcar los movimientos como noexcept es crucial para que se utilicen en realocaciones de std::vector, simplemente agregar noexcept a un destructor por defecto dentro de la clase no recupera las operaciones de movimiento eliminadas. Debes mover la definición fuera o declarar explícitamente los movimientos por defecto.
¿Cómo afecta un destructor declarado por el usuario a la inicialización agregada?
Una clase con cualquier destructor declarado por el usuario deja de ser un agregado. Esto suele ser más disruptivo que perder movimientos. Significa perder inicializadores designados (C++20) y la capacidad de usar listas de inicialización encerradas en llaves sin constructores explícitos. Muchos desarrolladores esperan que la inicialización agregada funcione y se sorprenden cuando falla:
struct Config { ~Config() = default; // Rompe la agregación int value; }; // Config c{42}; // Error: no hay constructor coincidente
Esto ocurre porque la presencia de un destructor declarado por el usuario obliga a la clase a tener semánticas de destrucción no triviales en el sistema de tipos, descalificándola del estatus de agregado independientemente de la complejidad real.