Momento exacto de "retorno" en una función de C++
language-lawyer c++17 (3)
Parece una pregunta tonta, pero es el momento exacto en el que
return xxx;
¿Se "ejecuta" en una función definida inequívocamente?
Por favor vea el siguiente ejemplo para ver lo que quiero decir ( aquí en vivo ):
#include <iostream>
#include <string>
#include <utility>
//changes the value of the underlying buffer
//when destructed
class Writer{
public:
std::string &s;
Writer(std::string &s_):s(s_){}
~Writer(){
s+="B";
}
};
std::string make_string_ok(){
std::string res("A");
Writer w(res);
return res;
}
int main() {
std::cout<<make_string_ok()<<std::endl;
}
Lo que ingenuamente espero que suceda, mientras que
make_string_ok
se llama:
-
Se llama al constructor para
res
(el valor deres
es"A"
) -
Se llama constructor para
w
-
return res
se ejecuta. El valor actual de res debe devolverse (copiando el valor actual deres
), es decir,"A"
. -
Se llama Destructor para
w
, el valor deres
convierte en"AB"
. -
Se llama destructor para
res
.
Así que esperaría
"A"
como resultado, pero obtendría
"AB"
impreso en la consola.
Por otro lado, para una versión ligeramente diferente de
make_string
:
std::string make_string_fail(){
std::pair<std::string, int> res{"A",0};
Writer w(res.first);
return res.first;
}
El resultado es el esperado:
"A"
(
ver en vivo
).
¿El estándar prescribe qué valor debe devolverse en los ejemplos anteriores o no está especificado?
Debido a la
optimización de valor de retorno (RVO)
, no se puede llamar a un destructor para
std::string res
en
make_string_ok
.
El objeto de
string
se puede construir en el lado del que llama y la función solo puede inicializar el valor.
El código será equivalente a:
void make_string_ok(std::string& res){
Writer w(res);
}
int main() {
std::string res("A");
make_string_ok(res);
}
Por eso el valor devuelto será "AB".
En el segundo ejemplo, RVO no se aplica, y el valor se copiará al valor devuelto exactamente después de la llamada a la devolución, y el destructor del
Writer
se ejecutará en
res.first
después de que se haya producido la copia.
6.6 declaraciones de saltos
Al salir de un ámbito (aunque se haya realizado), se llama a los destructores (12.4) para todos los objetos construidos con duración de almacenamiento automático (3.7.2) (objetos nombrados o temporales) que se declaran en ese ámbito, en el orden inverso a su declaración. La transferencia fuera de un bucle, fuera de un bloque, o una copia de una variable inicializada con duración de almacenamiento automática implica la destrucción de variables con duración de almacenamiento automática que están dentro del alcance en el punto transferido desde ...
...
6.6.3 La declaración de devolución
La inicialización de la copia de la entidad devuelta se secuencia antes de la destrucción de los temporales al final de la expresión completa establecida por el operando de la declaración de devolución, que, a su vez, se secuencia antes de la destrucción de las variables locales (6.6) del Bloque que encierra la declaración de retorno.
...
12.8 Copiar y mover objetos de clase.
31 Cuando se cumplen ciertos criterios, una implementación puede omitir la construcción de copiar / mover de un objeto de clase, incluso si el constructor y / o destructor de copia / movimiento del objeto tiene efectos secundarios. En tales casos, la implementación trata el origen y el destino de la operación de copiar / mover omitida simplemente como dos formas diferentes de referirse al mismo objeto, y la destrucción de ese objeto ocurre en el último momento en que los dos objetos hubieran sido destruido sin la optimización. (123) Esta elección de operaciones de copia / movimiento, llamada elección de copia, se permite en las siguientes circunstancias (que pueden combinarse para eliminar múltiples copias):
- en una declaración de retorno en una función con un tipo de retorno de clase, cuando la expresión es el nombre de un objeto automático no volátil (que no sea un parámetro de función o cláusula catch) con el mismo tipo cvunqualified que el tipo de retorno de función, el la operación de copiar / mover se puede omitir construyendo el objeto automático directamente en el valor de retorno de la función
123) Debido a que solo se destruye un objeto en lugar de dos, y no se ejecuta un constructor de copia / movimiento, todavía hay un objeto destruido por cada uno construido.
Es RVO (+ devolución de copia como temporal lo que empaña la imagen), una de las optimizaciones que pueden cambiar el comportamiento visible:
10.9.5 Copiar / mover elision (los énfasis son míos) :
Cuando se cumplen ciertos criterios, se permite que una implementación omita la construcción de copia / movimiento de un objeto de clase , incluso si el constructor seleccionado para la operación de copia / movimiento y / o el destructor del objeto tienen efectos secundarios **. En tales casos, la implementación trata el origen y el destino de la operación de copiar / mover omitida simplemente como dos formas diferentes de referirse al mismo objeto .
Esta elision de las operaciones de copia / movimiento, llamada copia de la elision, se permite en las siguientes circunstancias (que se pueden combinar para eliminar varias copias):
- en una declaración de retorno en una función con un tipo de retorno de clase, cuando la expresión es el nombre de un objeto automático no volátil (que no sea un parámetro de función o una variable introducida por la declaración de excepción de un controlador) con el mismo tipo ( ignorando la calificación cv) como el tipo de retorno de función , la operación de copiar / mover se puede omitir construyendo el objeto automático directamente en el objeto de retorno de la llamada de función
- [...]
En función de si se aplica, toda su premisa se equivoca.
En 1. se llama al c''tor for
res
, pero el objeto puede vivir dentro de
make_string_ok
o afuera.
Caso 1.
Las balas 2. y 3. podrían no ocurrir en absoluto, pero este es un punto lateral.
Target tuvo los efectos secundarios del dtor de
Writer
afectados, estaba fuera de
make_string_ok
.
make_string_ok
que se trataba de una creación temporal utilizando
make_string_ok
en el contexto del
operator<<(ostream, std::string)
de evaluación
operator<<(ostream, std::string)
.
El compilador creó un valor temporal y luego ejecutó la función.
Esto es importante porque las vidas temporales están fuera de él, por lo que el objetivo de
Writer
no es local para
make_string_ok
sino para el
operator<<
.
Caso 2.
Mientras tanto, su segundo ejemplo no se ajusta al criterio (ni a los omitidos por brevedad) porque los tipos son diferentes.
Así muere el escritor.
Incluso moriría, si fuera parte de la
pair
.
Así que aquí, una copia de
res.first
se devuelve como un objeto temporal, y luego dtor de
Writer
afecta a la
res.first
original, que está a punto de morir.
Parece bastante obvio que la copia se realiza antes de llamar a los destructores, porque el objeto devuelto por copia también se destruye, por lo que no podría copiarlo de lo contrario.
Después de todo, se reduce a RVO, porque el d''tor de
Writer
trabaja en el objeto exterior o en el local, según si la optimización se aplica o no.
¿El estándar prescribe qué valor debe devolverse en los ejemplos anteriores o no está especificado?
No, la optimización es opcional, aunque puede cambiar el comportamiento observable. Es a discreción del compilador aplicarla o no. Es una excepción a la regla "general como si" que dice que el compilador tiene permitido realizar cualquier transformación que no cambie el comportamiento observable.
Un caso se convirtió en obligatorio en c ++ 17, pero no en el suyo. El obligatorio es donde el valor de retorno es un temporal sin nombre.
Hay un concepto en C ++ llamado elision.
Elision toma dos objetos aparentemente distintos y fusiona su identidad y su vida.
Antes de que c ++ 17 elision pudiera ocurrir:
-
Cuando tienes una variable no paramétrica
Foo f;
en una función que devolvióFoo
y la declaración de retorno fue unreturn f;
simplereturn f;
. -
Cuando tienes un objeto anónimo que se utiliza para construir casi cualquier otro objeto.
En c ++ 17 todos los casos (¿casi?) De # 2 son eliminados por las nuevas reglas de prvalue; Elision ya no ocurre, porque lo que solía crear un objeto temporal ya no lo hace. En cambio, la construcción del "temporal" está directamente vinculada a la ubicación del objeto permanente.
Ahora, elision no siempre es posible dado el ABI que compila un compilador. Dos casos comunes donde es posible se conocen como Optimización del valor de retorno y Optimización del valor de retorno con nombre.
RVO es el caso así:
Foo func() {
return Foo(7);
}
Foo foo = func();
donde tenemos un valor de retorno
Foo(7)
que se elimina en el valor devuelto, que luego se elimina en la variable externa
foo
.
Lo que parece ser 3 objetos (el valor de retorno de
foo()
, el valor en la línea de
return
y
Foo foo
) es en realidad 1 en tiempo de ejecución.
Antes de c ++ 17, los constructores de copiar / mover deben existir aquí, y el elision es opcional; en c ++ 17 debido a las nuevas reglas prvalue no es necesario que exista ningún constructor copy / move, y no hay opción para el compilador, debe haber 1 valor aquí.
El otro caso famoso se llama optimización de valor de retorno, NRVO. Este es el (1) caso de Elision arriba.
Foo func() {
Foo local;
return local;
}
Foo foo = func();
de nuevo, elision puede combinar la vida útil y la identidad de
Foo local
, el valor de retorno de
func
y
Foo foo
fuera de
func
.
Incluso
c ++ 17
, la segunda combinación (entre el valor de retorno de
func
y
Foo foo
) no es opcional (y técnicamente el prvalue devuelto por
func
nunca es un objeto, solo una expresión, que luego está vinculada para construir
Foo foo
) , pero el primero sigue siendo opcional, y requiere que exista un constructor de movimiento o copia.
Elision es una regla que puede ocurrir incluso si la eliminación de esas copias, destrucciones y construcciones tendría efectos secundarios observables; no es una optimización "como si". En cambio, es un cambio sutil lejos de lo que una persona ingenua podría pensar que significa el código C ++. Llamarlo una "optimización" es más que un nombre poco apropiado.
El hecho de que sea opcional, y que las cosas sutiles puedan romperlo, es un problema con él.
Foo func(bool b) {
Foo long_lived;
long_lived.futz();
if (b)
{
Foo short_lived;
return short_lived;
}
return long_lived;
}
en el caso anterior, si bien es legal que un compilador
Foo long_lived
y
Foo short_lived
, los problemas de implementación lo hacen prácticamente imposible, ya que ambos objetos no pueden fusionar sus vidas con el valor de retorno de
func
;
la elusión de
short_lived
y
long_lived
juntos no es legal, y sus vidas se superponen.
Aún puedes hacerlo como si, pero solo si puedes examinar y entender todos los efectos secundarios de los destructores, constructores y
.futz()
.