La elisión de copias es el mecanismo por el cual el compilador evita la duplicación innecesaria de objetos en memoria, permitiendo que un objeto se construya directamente en su destino final. Esto ocurre mediante dos técnicas: RVO (Return Value Optimization), que actúa sobre objetos temporales sin nombre (prvalue), y NRVO (Named RVO), que es una optimización aplicada a variables locales con nombre dentro de una función.
Aunque antes de C++17 la mayoría de los compiladores implementaban estas técnicas como una optimización opcional, C++17 transformó el comportamiento de los prvalue en algo fundamental: la elisión obligatoria. Esto significa que, si retornas un objeto temporal, el compilador no está “intentando” evitar la copia; es que, por definición del lenguaje, el objeto se construye directamente en la ubicación del receptor, incluso si los constructores de copia o movimiento están marcados como delete. Sin embargo, si retornas un objeto con nombre (NRVO), el compilador sigue teniendo la libertad de decidir si aplica la optimización o no, ya que el objeto tiene una identidad dentro de su ámbito.
Debes usar estas mecánicas cuando devuelvas objetos pesados (como std::vector o buffers de datos) desde funciones fábricas o de procesamiento. Si intentas “ayudar” al compilador usando std::move en una sentencia return de una variable local, lo que realmente estás haciendo es deshabilitar la optimización, forzando una transferencia de propiedad (move semantics) en lugar de una construcción directa. Si ignoras estas reglas o bloqueas la elisión erróneamente, el impacto se traduce en ciclos de CPU desperdiciados en llamadas a constructores y un aumento en la presión sobre la pila (stack).
#include <iostream>
#include <string>
#include <utility>
// Clase para rastrear qué sucede con el objeto en tiempo de ejecución
struct Tracker {
std::string name;
Tracker(std::string n) : name(std::move(n)) {
std::cout << " [Construcción: " << name << "]\n";
}
Tracker(const Tracker& other) : name(other.name + " (copia)") {
std::cout << " [Copia: " << name << "]\n";
}
Tracker(Tracker&& other) noexcept : name(std::move(other.name)) {
std::cout << " [Movimiento: " << name << "]\n";
}
};
// Clase que no permite copias ni movimientos (test de elisión obligatoria)
struct NonMovable {
NonMovable() = default;
NonMovable(const NonMovable&) = delete;
NonMovable(NonMovable&&) = delete;
NonMovable(int) {} // Constructor para facilitar la creación
};
// 1. RVO: Retornamos un prvalue (objeto temporal sin nombre)
// C++17 garantiza que no haya ni copia ni movimiento.
Tracker make_rvo() {
return Tracker("RVO_Object");
}
// 2. NRVO: Retornamos un objeto local con nombre
// El compilador optimiza esto, pero es una optimización, no una garantía de lenguaje.
Tracker make_nrvo() {
Tracker local("NRVO_Object");
return local;
}
// 3. El error frecuente: Forzar std::move en el return
// Esto destruye la capacidad del compilador para aplicar NRVO.
Tracker make_move_error() {
Tracker local("Mistake_Object");
return std::move(local); // Error conceptual: obligas a un move en lugar de elisión
}
// 4. Elisión obligatoria C++17: Retornar un tipo no movible
// Gracias a que es un prvalue, esto compila en C++17 a pesar de ser non-movable.
NonMovable make_mandatory() {
return NonMovable(42);
}
int main() {
std::cout << "--- Test RVO (prvalue) ---\n";
Tracker t1 = make_rvo();
std::cout << "\n--- Test NRVO (named local) ---\n";
Tracker t2 = make_nrvo();
std::cout << "\n--- Test Error (std::move) ---\n";
Tracker t3 = make_move_error();
std::cout << "\n--- Test Mandatory Elision (Non-movable) ---\n";
NonMovable t4 = make_mandatory();
return 0;
}
Para compilar este ejemplo:
g++ -std=c++20 -Wall -Wextra -Wpedantic -o elision_test elision_test.cpp
Análisis del comportamiento
En make_rvo, al retornar Tracker("RVO_Object"), estamos entregando un prvalue. El compilador identifica el destino (t1 en main) y construye el objeto directamente en ese espacio de memoria. Por eso, en la salida, solo verás el mensaje de “Construcción” sin rastro de “Copia” o “Movimiento”.
En make_nrvo, la variable local tiene un nombre. El compilador utiliza NRVO para mapear la dirección de memoria de local directamente a la dirección de t2. Es una optimización de gestión de espacio en la pila que evita que local sea una entidad separada que deba ser movida al llamador.
El caso de make_move_error es el error más común en código de alto rendimiento. Al escribir return std::move(local);, transformas un lvalue en un xvalue (un lvalue que está a punto de morir). Esto le dice explícitamente al compilador: “No intentes optimizar esto, aplica el constructor de movimiento”. Como resultado, ves en la consola el mensaje de “Movimiento”, lo que significa que se ha realizado una operación de transferencia de recursos que la elisión habría evitado por completo.
Finalmente, make_mandatory demuestra la potencia de C++17. Antes de este estándar, retornar un tipo como NonMovable que no tiene constructor de movimiento era problemático si el compilador decidía que necesitaba realizar una copia/movimiento para cumplir con la semántica de retorno. Ahora, la elisión obligatoria garantiza que el objeto se construya directamente en el espacio de t4, haciendo que el código sea más seguro y permite el uso de tipos que no son ni copiables ni movibles en contextos de retorno.
El error frecuente
El error más sutil es el uso de std::move en la sentencia return de una variable local.
// MAL: Bloquea la optimización NRVO
Tracker bad_return(std::string name) {
Tracker local(std::move(name));
return std::move(local); // <--- ¡ERROR! Obliga a un move innecesario
}
Aunque el desarrollador cree que está siendo “explícito” y ayudando al compilador, en realidad está destruyendo la posibilidad de NRVO. Al aplicar std::move, el objeto local ya no puede ser tratado como un objeto que el compilador puede “reubicar” mediante el paso de un puntero interno; se ve obligado a tratarlo como un objeto con valor de categoría de movimiento, disparando el constructor de movimiento. Este bug es silencioso: el código compila perfectamente y funciona, pero penaliza el rendimiento de forma sistemática. Si necesitas mover un objeto local, simplemente confía en el mecanismo de retorno estándar o en la elisión de C++17; el compilador es más inteligente que tú en este aspecto.
N° 60