C++
Copia elision
Buscar..
Propósito de la copia elision
Hay lugares en el estándar donde un objeto se copia o mueve para inicializar un objeto. Elección de copia (a veces llamada optimización de valor de retorno) es una optimización por la cual, bajo ciertas circunstancias específicas, se permite que un compilador evite la copia o el movimiento aunque la norma diga que debe suceder.
Considera la siguiente función:
std::string get_string()
{
return std::string("I am a string.");
}
De acuerdo con la estricta redacción de la norma, esta función inicializará una std::string
temporal, luego la copiará / moverá al objeto de valor de retorno y luego destruirá la temporal. El estándar es muy claro que así se interpreta el código.
Copy elision es una regla que permite a un compilador de C ++ ignorar la creación del temporal y su posterior copia / destrucción. Es decir, el compilador puede tomar la expresión de inicialización para el temporal e inicializar directamente el valor de retorno de la función. Esto obviamente guarda el rendimiento.
Sin embargo, tiene dos efectos visibles en el usuario:
El tipo debe tener el constructor de copiar / mover que se habría llamado. Incluso si el compilador evita la copia / movimiento, el tipo aún debe poder copiarse / moverse.
Los efectos secundarios de los constructores de copia / movimiento no están garantizados en circunstancias en las que puede producirse la elección. Considera lo siguiente:
struct my_type
{
my_type() = default;
my_type(const my_type &) {std::cout <<"Copying\n";}
my_type(my_type &&) {std::cout <<"Moving\n";}
};
my_type func()
{
return my_type();
}
¿Qué hará la func
llamada? Bueno, nunca se imprimirá "Copiando", ya que el temporal es un rvalue y my_type
es un tipo movible. Entonces, ¿se imprimirá "Moving"?
Sin la regla de elision de copia, esto se requeriría para imprimir siempre "Mover". Pero como la regla de elision de copia existe, el constructor de movimientos puede o no ser llamado; es dependiente de la implementación
Y, por lo tanto, no puede depender de la llamada de los constructores de copiar / mover en contextos donde es posible la elección.
Debido a que elision es una optimización, su compilador puede no ser compatible con elision en todos los casos. Y sin importar si el compilador elude un caso particular o no, el tipo todavía debe soportar la operación que se está elidiando. Por lo tanto, si se elimina una construcción de copia, el tipo aún debe tener un constructor de copia, aunque no se llamará.
Copia garantizada elision
Normalmente, elision es una optimización. Si bien prácticamente todos los compiladores son compatibles con la copia de la elision en los casos más simples, tenerla aún representa una carga particular para los usuarios. A saber, el tipo de quién se está eliminando la copia / movimiento debe seguir teniendo la operación de copia / movimiento que fue eliminada.
Por ejemplo:
std::mutex a_mutex;
std::lock_guard<std::mutex> get_lock()
{
return std::lock_guard<std::mutex>(a_mutex);
}
Esto puede ser útil en los casos en que a_mutex
es un mutex que es privado en algún sistema, pero un usuario externo puede querer tener un bloqueo de ámbito en él.
Esto tampoco es legal, porque std::lock_guard
no se puede copiar o mover. A pesar de que virtualmente cada compilador de C ++ ocultará la copia / movimiento, el estándar aún requiere que el tipo tenga esa operación disponible.
Hasta C ++ 17.
C ++ 17 ordena la elisión al redefinir efectivamente el significado mismo de ciertas expresiones para que no se realicen copias / movimientos. Considere el código anterior.
Bajo la redacción anterior a C ++ 17, ese código dice crear un temporal y luego usar el temporal para copiar / mover al valor de retorno, pero la copia temporal puede eliminarse. Bajo la redacción de C ++ 17, eso no crea un temporal en absoluto.
En C ++ 17, cualquier expresión prvalue , cuando se usa para inicializar un objeto del mismo tipo que la expresión, no genera un temporal. La expresión inicializa directamente ese objeto. Si devuelve un prvalue del mismo tipo que el valor de retorno, entonces el tipo no necesita tener un constructor de copiar / mover. Y por lo tanto, bajo las reglas de C ++ 17, el código anterior puede funcionar.
La redacción de C ++ 17 funciona en los casos en que el tipo del prvalue coincide con el tipo que se está inicializando. Por lo tanto, dado get_lock
arriba, esto tampoco requerirá una copia / movimiento:
std::lock_guard the_lock = get_lock();
Dado que el resultado de get_lock
es una expresión prvalue que se utiliza para inicializar un objeto del mismo tipo, no se realizará ninguna copia o movimiento. Esa expresión nunca crea un temporal; se utiliza para inicializar directamente the_lock
. No hay elision porque no hay copia / movimiento para ser elide elide.
El término "elision de copia garantizada" es, por lo tanto, un nombre poco apropiado, pero ese es el nombre de la función tal como se propone para la estandarización de C ++ 17 . No garantiza en absoluto la elisión; elimina la copia / movimiento por completo, redefiniendo C ++ para que nunca haya una copia / movimiento que deba ser borrado.
Esta característica solo funciona en casos que involucran una expresión prvalue. Como tal, esto usa las reglas habituales de elision:
std::mutex a_mutex;
std::lock_guard<std::mutex> get_lock()
{
std::lock_guard<std::mutex> my_lock(a_mutex);
//Do stuff
return my_lock;
}
Si bien este es un caso válido para la elección de copia, las reglas de C ++ 17 no eliminan la copia / movimiento en este caso. Como tal, el tipo aún debe tener un constructor de copiar / mover para usar para inicializar el valor de retorno. Y como lock_guard
no lo hace, esto sigue siendo un error de compilación. Se permite que las implementaciones se nieguen a rechazar copias al pasar o devolver un objeto de tipo de copia trivial. Esto es para permitir el desplazamiento de tales objetos en registros, que algunas ABI podrían imponer en sus convenciones de llamada.
struct trivially_copyable {
int a;
};
void foo (trivially_copyable a) {}
foo(trivially_copyable{}); //copy elision not mandated
Valor de retorno elision
Si devuelve una expresión prvalue de una función, y la expresión prvalue tiene el mismo tipo que el tipo de retorno de la función, entonces se puede eliminar la copia del prvalue temporal:
std::string func()
{
return std::string("foo");
}
Casi todos los compiladores eludirán la construcción temporal en este caso.
Parámetro elision
Cuando pasa un argumento a una función, y el argumento es una expresión prvalue del tipo de parámetro de la función, y este tipo no es una referencia, entonces la construcción del prvalue se puede eliminar.
void func(std::string str) { ... }
func(std::string("foo"));
Esto dice que para crear una string
temporal, luego muévala en el parámetro de función str
. Copy elision permite que esta expresión cree directamente el objeto en str
, en lugar de usar un movimiento + temporal.
Esta es una optimización útil para los casos en que un constructor se declara explicit
. Por ejemplo, podríamos haber escrito lo anterior como func("foo")
, pero solo porque la string
tiene un constructor implícito que convierte de const char*
a una string
. Si ese constructor fuera explicit
, nos veríamos obligados a usar un temporal para llamar al constructor explicit
. Copiar elision nos evita tener que hacer una copia / movimiento innecesario.
Valor de retorno con nombre elision
Si devuelve una expresión lvalue desde una función, y este valor lvalue:
- representa una variable automática local para esa función, que se destruirá después de la
return
- La variable automática no es un parámetro de función.
- y el tipo de la variable es el mismo tipo que el tipo de retorno de la función
Si todos estos son el caso, entonces la copia / movimiento desde el lvalue se puede eliminar:
std::string func()
{
std::string str("foo");
//Do stuff
return str;
}
Los casos más complejos son elegibles para la elección, pero cuanto más complejo sea el caso, menos probable será que el compilador realmente lo evite:
std::string func()
{
std::string ret("foo");
if(some_condition)
{
return "bar";
}
return ret;
}
El compilador todavía podría elidir a ret
, pero las posibilidades de que lo hagan disminuyen.
Como se señaló anteriormente, elision no está permitido para los parámetros de valor.
std::string func(std::string str)
{
str.assign("foo");
//Do stuff
return str; //No elision possible
}
Copia inicializacion elision
Si utiliza una expresión prvalue para copiar, inicialice una variable, y esa variable tiene el mismo tipo que la expresión prvalue, entonces la copia puede ser eliminada.
std::string str = std::string("foo");
La inicialización de la copia efectivamente transforma esto en std::string str("foo");
(hay diferencias menores).
Esto también funciona con valores de retorno:
std::string func()
{
return std::string("foo");
}
std::string str = func();
Sin la elección de copia, esto provocaría 2 llamadas al constructor de movimientos de std::string
. Copy elision permite que esto llame al constructor de movimientos 1 o cero veces, y la mayoría de los compiladores optarán por este último.