c++ - compilar - ¿Puede un compilador optimizado agregar std:: move?
mingw (2)
¿Puede un compilador realizar una conversión automática de valor a valor si puede probar que el valor no se volverá a utilizar? Aquí hay un ejemplo para aclarar lo que quiero decir:
void Foo(vector<int> values) { ...}
void Bar() {
vector<int> my_values {1, 2, 3};
Foo(my_values); // may the compiler pretend I used std::move here?
}
Si se agrega un std::move
a la línea comentada, entonces el vector se puede mover al parámetro de Foo
, en lugar de copiarlo. Sin embargo, como está escrito, no std::move
.
Es bastante fácil demostrar estáticamente que mis valores_valores no se utilizarán después de la línea comentada. Entonces, ¿se le permite al compilador mover el vector, o se requiere que lo copie?
El compilador debe comportarse como si la copia ocurriera desde el vector
a la llamada de Foo
.
Si el compilador puede probar que existe un comportamiento de máquina abstracto válido sin efectos secundarios observables (dentro del comportamiento de máquina abstracta, ¡no en una computadora real!) Que involucra mover el std::vector
a Foo
, puede hacerlo.
En su caso anterior, esto (el movimiento no tiene efectos secundarios visibles de la máquina abstracta) es cierto; El compilador puede no ser capaz de probarlo, sin embargo.
El posible comportamiento observable al copiar un std::vector<T>
es:
- Invocando a los constructores de copia sobre los elementos. No se puede observar hacerlo con
int
- Invocando el
std::allocator<>
predeterminadostd::allocator<>
en diferentes momentos. Esto invoca::new
y::delete
(quizás 1 ). En cualquier caso,::new
y::delete
no se han reemplazado en el programa anterior, por lo que no puede observarlo en la norma. - Llamando al destructor de
T
más veces sobre diferentes objetos. No observable conint
. - El
vector
no está vacío después de la llamada aFoo
. Nadie lo examina, por lo que estar vacío es como si no lo fuera. - Las referencias, los punteros o los iteradores a los elementos del vector exterior son diferentes a los del interior. No se llevan referencias, vectores o punteros a los elementos del vector fuera de
Foo
.
Si bien puedes decir "pero, ¿qué pasa si el sistema no tiene memoria y el vector es grande, no es eso observable?":
La máquina abstracta no tiene una condición de "memoria std::bad_alloc
", simplemente tiene una asignación que falla a veces (lanzar std::bad_alloc
) por razones no restringidas. No fallar es un comportamiento válido de la máquina abstracta, y no fallar al no asignar memoria (real) (en la computadora real) también es válido, siempre y cuando la no existencia de la memoria no tenga efectos secundarios observables.
Una caja de juguetes un poco más:
int main() {
int* x = new int[std::size_t(-1)];
delete[] x;
}
Si bien este programa asigna claramente demasiada memoria, el compilador es libre de no asignar nada.
Podemos ir más lejos. Incluso:
int main() {
int* x = new int[std::size_t(-1)];
x[std::size_t(-2)] = 2;
std::cout << x[std::size_t(-2)] << ''/n'';
delete[] x;
}
se puede convertir en std::cout << 2 << ''/n'';
. Ese búfer grande debe existir de manera abstracta , pero mientras su programa "real" se comporte como-si la máquina abstracta lo hiciera, no tiene que asignarlo.
Desafortunadamente, hacerlo a cualquier escala razonable es difícil. Hay muchas y muchas formas en que la información puede filtrarse desde un programa C ++. Por lo tanto, confiar en tales optimizaciones (incluso si ocurren) no va a terminar bien.
1 Hubo algunas cosas acerca de las llamadas unidas a new
que podrían confundir el problema, no estoy seguro de si sería legal omitir llamadas incluso si hubiera un ::new
reemplazo.
Un hecho importante es que hay situaciones en las que no se requiere que el compilador se comporte como si hubiera una copia, incluso si no se llamó a std::move
.
Cuando return
una variable local desde una función en una línea que se parece al return X;
y X
es el identificador, y esa variable local tiene una duración de almacenamiento automático (en la pila), la operación es implícitamente un movimiento, y el compilador (si puede) puede ocultar la existencia del valor de retorno y la variable local en uno Objeto (e incluso omitir el move
).
Lo mismo ocurre cuando construyes un objeto a partir de un temporal: la operación es implícitamente un movimiento (ya que se vincula a un valor) y puede eliminar el movimiento por completo.
En ambos casos, se requiere que el compilador lo trate como un movimiento (no una copia), y puede evitar el movimiento.
std::vector<int> foo() {
std::vector<int> x = {1,2,3,4};
return x;
}
ese x
no tiene std::move
, pero se mueve al valor de retorno, y esa operación puede eliminarse ( x
y el valor de retorno se puede convertir en un objeto).
Esta:
std::vector<int> foo() {
std::vector<int> x = {1,2,3,4};
return std::move(x);
}
Bloquea a Elision, como hace esto:
std::vector<int> foo(std::vector<int> x) {
return x;
}
e incluso podemos bloquear el movimiento:
std::vector<int> foo() {
std::vector<int> x = {1,2,3,4};
return (std::vector<int> const&)x;
}
o incluso:
std::vector<int> foo() {
std::vector<int> x = {1,2,3,4};
return 0,x;
}
como las reglas para el movimiento implícito son intencionalmente frágiles. ( 0,x
es un uso del operador muy difamado ,
).
Ahora, no se recomienda confiar en que el movimiento implícito no se produce en casos como este último ,
basado en uno: el comité estándar ya ha cambiado un caso de copia implícita a un movimiento implícito desde que se agregó movimiento implícito al idioma porque lo consideraron inofensivo (donde la función devuelve un tipo A
con un A(B&&)
ctor, y la declaración de retorno es return b;
donde b
es de tipo B
; en la versión C ++ 11 que hizo una copia, ahora hace un movimiento). No se puede descartar una mayor expansión del movimiento implícito: lanzar explícitamente a una const&
es probablemente la forma más confiable de evitarlo ahora y en el futuro.
En este caso, el compilador podría salir de my_values
. Esto es porque eso no causa diferencia en el comportamiento observable .
Citando la definición de comportamiento observable del estándar C ++:
Los requisitos mínimos para una implementación conforme son:
- El acceso a objetos volátiles se evalúa estrictamente de acuerdo con las reglas de la máquina abstracta.
- Al finalizar el programa, todos los datos escritos en archivos serán idénticos a uno de los posibles resultados que la ejecución del programa de acuerdo con la semántica abstracta hubiera producido.
- La dinámica de entrada y salida de los dispositivos interactivos se llevará a cabo de tal manera que la salida de solicitud se envíe realmente antes de que un programa espere la entrada. Lo que constituye un dispositivo interactivo está definido por la implementación.
Interpretando esto ligeramente: "archivos" aquí incluye el flujo de salida estándar, y para las llamadas de funciones que no están definidas por el Estándar de C ++ (por ejemplo, las llamadas del sistema operativo, o las llamadas a bibliotecas de terceros), se debe asumir que esas funciones pueden escribir a un archivo, por lo que un corolario de esto es que las llamadas a funciones no estándar también deben considerarse un comportamiento observable.
Sin embargo, su código (como lo ha mostrado) no tiene variables volatile
ni llamadas a funciones no estándar. Por lo tanto, las dos versiones (mover o no mover) deben tener un comportamiento observable idéntico y, por lo tanto, el compilador podría hacer (o incluso optimizar la función completamente, etc.)
En la práctica, por supuesto, generalmente no es tan fácil para un compilador probar que no se producen llamadas de función no estándar, por lo que se pierden muchas oportunidades de optimización como esta. Por ejemplo, en este caso, es posible que el compilador aún no sepa si el ::operator new
predeterminado se ha reemplazado con una función que genera resultados.