En C++17, el estándar introdujo la elisión de copia garantizada (elisión de copia obligatoria), que cambia fundamentalmente cómo se materializan los prvalues (valores rvalues puros). Cuando un prvalue de tipo de clase inicializa un objeto del mismo tipo —como cuando se devuelve de una función por valor o se pasa un temporal a una función—, el objeto se construye directamente en el almacenamiento de destino. En consecuencia, el constructor de copia o el constructor de movimiento no se invocan, y lo importante es que ni su accesibilidad (pública vs. privada) ni su mera existencia (siempre que la clase esté completa y sea destructible) son requeridas para que la operación esté bien formada. Esto contrasta marcadamente con estándares anteriores donde la elisión era únicamente una optimización opcional que todavía requería constructores accesibles y presentes para la compilación.
struct Immovable { Immovable() = default; Immovable(const Immovable&) = delete; Immovable(Immovable&&) = delete; }; Immovable factory() { return Immovable{}; // OK en C++17: no se invoca move/copy } void consume(Immovable x); // Parámetro inicializado directamente desde prvalue
Nuestro equipo estaba construyendo un controlador en modo núcleo donde los manejadores de recursos que envuelven contextos de hardware no podían ser duplicados ni reubicados en memoria debido a direcciones de núcleo registradas. Necesitábamos una función fábrica para producir estos manejadores por valor para la gestión de RAII, pero los manejadores eliminaban explícitamente tanto los constructores de copia como los de movimiento para evitar la invalidación accidental de los mapeos del núcleo. Antes de C++17, este diseño era incompatible con la devolución por valor porque incluso con NRVO, el compilador requería conceptualmente que el constructor de movimiento fuera accesible, lo que resultaba en errores de compilación.
Solución 1: Asignación en el montón mediante std::unique_ptr
Consideramos envolver el manejador en un std::unique_ptr, permitiendo que el puntero se moviera mientras que el objeto subyacente permanecía fijado. Este enfoque proporcionó seguridad y funcionó en C++14.
Pros: Administración estándar de memoria, previene fugas, ampliamente soportado en bases de código heredadas.
Contras: Introduce sobrecarga de asignación dinámica e indirección de punteros, lo que es prohibitivo en contextos de núcleo donde se requiere baja latencia determinista; también fragmenta la caché de la CPU y requiere consideraciones de manejo de excepciones para la falla de la asignación.
Solución 2: Inicialización de parámetros de salida
Pasar una referencia a un objeto asignado por el llamador a la fábrica para ser inicializado en su lugar.
Pros: Garantía de cero copias independientemente de la versión del estándar C++; sin asignación en el montón; compatible con tipos inmovilizados.
Contras: Destruye el estilo de API fluida (auto h = create(); se convierte en Handle h; create(h);); aumenta el riesgo de uso antes de la inicialización y no se compone bien con algoritmos estándar y bucles for basados en rango.
Solución 3: Aprovechar la elisión de copia garantizada en C++17
Reestructuramos la fábrica para devolver el tipo inmovilizado por valor, confiando en la elisión obligatoria para construir el prvalue directamente en el almacenamiento del llamador.
Pros: Elimina el uso del montón; preserva la semántica de valor; impone una abstracción de costo cero en tiempo de compilación; los constructores de movimiento/copia no necesitan existir o ser accesibles.
Contras: Se aplica estrictamente a rvalues puros (no se pueden devolver variables nombradas existentes); requiere un compilador con soporte para C++17; deben entenderse diferencias sutiles en el manejo de excepciones durante la construcción.
Seleccionamos Solución 3 porque la fábrica producía temporales frescos que eran prvalues puros, concordando perfectamente con el escenario de elisión garantizada. Esto permitió que los manejadores permanecieran estrictamente inmóviles mientras mantenían una semántica de valor ergonómica y compatibilidad con las declaraciones auto.
El controlador se envió con inicialización a escala de microsegundos para miles de conexiones concurrentes. La inspección a nivel de ensamblador confirmó que el manejador se construyó directamente en el marco de pila del llamador sin ningún código de reubicación o copia. El sistema de tipos impuso seguridad de recursos por construcción, y eliminamos completamente la contención del montón del camino más caliente.
¿Se aplica la elisión de copia garantizada a los valores de retorno nombrados (lvalues) dentro de la función, o está estrictamente limitada a los prvalues?
La elisión de copia garantizada se aplica exclusivamente a prvalues (rvalues puros), como los temporales creados en la declaración de retorno sin un nombre. La Optimización de Valor de Retorno Nombrado (NRVO) sigue siendo una optimización del compilador opcional; aunque se implementa ampliamente, no proporciona las mismas garantías con respecto a la accesibilidad del constructor o efectos secundarios. Si un candidato intenta devolver una variable local nombrada y presume que desencadenará una elisión garantizada incluso si el constructor de movimiento está eliminado, el programa estará mal formado porque las variables nombradas son lvalues y requieren operaciones de movimiento/copia a menos que el compilador aplique la opcional NRVO, que no está mandada.
¿Se puede devolver un objeto de una clase con constructores de copia y movimiento explícitamente eliminados por valor de una función bajo las reglas de elisión de copia garantizada?
Sí. En C++17, si la expresión devuelta es un prvalue (por ejemplo, return MyClass{};), los constructores de copia y movimiento nunca se consideran para la inicialización. Dado que el objeto se construye directamente en el almacenamiento del llamador, los constructores eliminados no se utilizan y no causan errores de compilación. Sin embargo, intentar devolver una variable nombrada de tal tipo fallará, ya que esa operación requiere conceptualmente mover el lvalue al espacio de retorno, lo que invocaría el constructor de movimiento eliminado y resultaría en un programa mal formado.
¿Cómo interactúa la elisión de copia garantizada con la seguridad de excepciones, específicamente en relación con la duración del prvalue temporal durante la descomposición de la pila?
Bajo la elisión de copia garantizada, no se crea un objeto temporal separado antes de que comience la duración del objeto objetivo. El prvalue se materializa directamente en su destino final. En consecuencia, si ocurre una excepción durante la construcción del prvalue, el mecanismo de descomposición de la pila no encuentra un temporal separado que requiera destrucción; en cambio, ve el objeto de destino parcialmente construido. Esto significa que desde la perspectiva del llamador, el objeto o bien existe completamente construido o no existe en absoluto, simplificando las garantías de seguridad de excepciones y asegurando que no ocurra una doble destrucción o fuga de recursos debido a un temporal abandonado durante el manejo de excepciones antes de que comience oficialmente la duración del objeto de destino.