c++ language-lawyer c++17 c++20 order-of-execution

Llamar a una función miembro no estática fuera del tiempo de vida del objeto en C++ 17



language-lawyer c++17 (5)

¿El siguiente programa tiene un comportamiento indefinido en C ++ 17 y posterior?

struct A { void f(int) { /* Assume there is no access to *this here */ } }; int main() { auto a = new A; a->f((a->~A(), 0)); }

C ++ 17 garantiza que a->f se evalúa según la función miembro del objeto A antes de evaluar el argumento de la llamada. Por lo tanto, la indirección de -> está bien definida. Pero antes de que se ingrese la llamada a la función, el argumento se evalúa y finaliza la vida útil del objeto A (sin embargo, vea las ediciones a continuación). ¿La llamada todavía tiene un comportamiento indefinido? ¿Es posible llamar a una función miembro de un objeto fuera de su vida útil de esta manera?

La categoría de valor de a->f es prvalue por [expr.ref]/6.3.2 y [basic.life]/7 solo no permite llamadas a funciones miembro no estáticas en valores que se refieren al objeto después de la vida útil. ¿Esto implica que la llamada es válida? (Editar: Como se discutió en los comentarios, es probable que esté malinterpretando [basic.life] / 7 y probablemente se aplique aquí).

¿Cambia la respuesta si reemplazo la llamada del destructor a->~A() con delete a o new(a) A (con #include<new> )?

Algunas ediciones y aclaraciones sobre mi pregunta:

Si tuviera que separar la llamada a la función miembro y el destructor / eliminar / colocación-nuevo en dos declaraciones, creo que las respuestas son claras:

  1. a->A(); a->f(0) a->A(); a->f(0) : UB, debido a a llamada de miembro no estático fuera de su vida útil. (Ver edición a continuación, sin embargo)
  2. delete a; a->f(0) delete a; a->f(0) : igual que el anterior
  3. new(a) A; a->f(0) new(a) A; a->f(0) : bien definido, llama al nuevo objeto

Sin embargo, en todos estos casos, a->f se secuencia después de la primera declaración respectiva, mientras que este orden se invierte en mi ejemplo inicial. Mi pregunta es si esta inversión permite que las respuestas cambien.

Para los estándares anteriores a C ++ 17 inicialmente pensé que los tres casos causan un comportamiento indefinido, ya que la evaluación de a->f depende del valor de a , pero no está secuenciada en relación con la evaluación del argumento que causa un efecto secundario en a . Sin embargo, este es un comportamiento indefinido solo si hay un efecto secundario real en un valor escalar, por ejemplo, escribir en un objeto escalar. Sin embargo, no se escribe ningún objeto escalar porque A es trivial y, por lo tanto, también me interesaría qué restricción se viola exactamente en el caso de los estándares anteriores a C ++ 17. En particular, el caso con la colocación de nuevas me parece poco claro ahora.

Me acabo de dar cuenta de que la redacción en relación con la vida útil de los objetos cambió entre C ++ 17 y el borrador actual. En n4659 (borrador C ++ 17) [basic.life] / 1 dice:

La vida útil de un objeto o de tipo T finaliza cuando:

  • si T es un tipo de clase con un destructor no trivial (15.4), se inicia la llamada al destructor

[...]

mientras que el borrador actual dice:

La vida útil de un objeto o de tipo T finaliza cuando:

[...]

  • si T es un tipo de clase, se inicia la llamada al destructor, o

[...]

Por lo tanto, supongo que mi ejemplo tiene un comportamiento bien definido en C ++ 17, pero no el borrador actual (C ++ 20), porque la llamada al destructor es trivial y el tiempo de vida del objeto A no está realmente terminado. . Agradecería aclaraciones sobre eso también. Mi pregunta original sigue siendo válida incluso para C ++ 17 para el caso de reemplazar la llamada del destructor con una expresión de eliminación o nueva ubicación.

Si f accede a *this en su cuerpo, entonces obviamente puede haber un comportamiento indefinido para los casos de llamada destructor y expresión de eliminación, sin embargo, en esta pregunta quiero centrarme en si la llamada en sí misma es válida o no. Sin embargo, tenga en cuenta que la variación de mi pregunta con ubicación-nueva potencialmente no tendría un problema con el acceso de miembros en f , dependiendo de si la llamada en sí es un comportamiento indefinido o no. Pero en ese caso, podría haber una pregunta de seguimiento, especialmente para el caso de colocación nueva, porque no me queda claro si this en la función siempre se referirá automáticamente al nuevo objeto o si podría necesitar ser potencialmente std::launder ed (según los miembros que tenga A ).

Si bien A tiene un destructor trivial, el caso más interesante es probablemente cuando tiene algún efecto secundario sobre el que el compilador puede hacer suposiciones para fines de optimización. (No sé si algún compilador realmente usa algo como esto). Por lo tanto, agradezco las respuestas para el caso en que A tenga un destructor no trivial, especialmente si la respuesta difiere entre los dos casos.

Además, desde una perspectiva práctica, una llamada trivial del destructor probablemente no tenga efecto sobre el código generado y (¿improbable?) Las optimizaciones basadas en suposiciones de comportamiento indefinidas a un lado, todos los ejemplos de código probablemente generarán código que se ejecuta como se espera en la mayoría de los compiladores. Estoy más interesado en la perspectiva teórica, más que en esta práctica.

La intención de esta pregunta es comprender mejor los detalles del idioma. No animo a nadie a escribir código como ese.


Además de lo que otros dijeron:

a-> ~ A (); eliminar a;

Este programa tiene una pérdida de memoria que técnicamente no es un comportamiento indefinido. Sin embargo, si llamó delete a; para evitarlo, eso debería haber sido un comportamiento indefinido porque delete llamaría a->~A() segunda vez [Sección 12.4 / 14].

a-> ~ A ()

De lo contrario, en realidad esto es como lo sugirieron otros: el compilador genera código de máquina en la línea de A* a = malloc(sizeof(A)); a->A(); a->~A(); a->f(0); A* a = malloc(sizeof(A)); a->A(); a->~A(); a->f(0); . Como no hay variables miembro o virtuales, las tres funciones miembro están vacías ( {return;} ) y no hacen nada. El apuntador a aún apunta a una memoria válida. Se ejecutará, pero el depurador puede quejarse de pérdida de memoria.

Sin embargo, el uso de cualquier variable miembro no estática dentro de f() podría haber sido un comportamiento indefinido porque está accediendo a ellas después de que son destruidas (implícitamente) por ~A() generado por el compilador. Eso probablemente resultaría en un error de tiempo de ejecución si fuera algo como std::string o std::vector .

eliminar un

Si reemplazó a->~A() con una expresión que invocó, delete a; en cambio, creo que esto habría sido un comportamiento indefinido porque el puntero a ya no es válido en ese punto.

A pesar de eso, el código aún debe ejecutarse sin errores porque la función f() está vacía. Si accedió a alguna variable miembro, es posible que se haya bloqueado o haya dado lugar a resultados aleatorios porque la memoria para a se desasigna.

nuevo (a) A

auto a = new A; new(a) A; es un comportamiento indefinido porque está llamando a A() por segunda vez para la misma memoria.

En ese caso, llamar a f () por sí solo sería válido porque existe pero construir a doble es UB.

Funcionará bien si A no contiene ningún objeto con constructores que asignen memoria y demás. De lo contrario, podría provocar pérdidas de memoria, etc., pero f () accedería a la "segunda" copia de ellos perfectamente.


Es cierto que los destructores triviales no hacen nada, ni siquiera terminan la vida útil del objeto, antes de (los planes para) C ++ 20. Entonces la pregunta es, er, trivial a menos que supongamos un destructor no trivial o algo más fuerte como delete .

En ese caso, el orden de C ++ 17 no ayuda: la llamada (no el acceso del miembro de la clase) usa un puntero al objeto ( para inicializar this ), en violación de las reglas para punteros fuera de tiempo de vida .

Nota al margen: si solo un orden estuviera indefinido, también lo sería el "orden no especificado" antes de C ++ 17: si alguna de las posibilidades de un comportamiento no especificado es un comportamiento indefinido, el comportamiento no está definido. (¿Cómo diría que se eligió la opción bien definida? La indefinida podría emularla y luego liberar a los demonios nasales).


La expresión de postfix a->f se secuencia antes de la evaluación de cualquier argumento (que se secuencian indeterminadamente entre sí). (Ver [llamada expr.])

La evaluación de los argumentos se secuencia antes del cuerpo de la función (incluso las funciones en línea, ver [intro.execution])

La implicación, entonces, es que llamar a la función en sí no es un comportamiento indefinido. Sin embargo, acceder a cualquier variable miembro o llamar a otras funciones miembro dentro sería UB por [basic.life].

Entonces, la conclusión es que esta instancia específica es segura según la redacción, pero es una técnica peligrosa en general.


No soy un abogado de idiomas, pero tomé su fragmento de código y lo modifiqué un poco. No usaría esto en el código de producción, pero parece producir resultados definidos válidos ...

#include <iostream> #include <exception> struct A { int x{5}; void f(int){} int g() { std::cout << x << ''/n''; return x; } }; int main() { try { auto a = new A; a->f((a->~A(), a->g())); catch(const std::exception& e) { std::cerr << e.what(); return EXIT_FAILURE; } return EXIT_SUCCESS; }

Estoy ejecutando Visual Studio 2017 CE con el indicador de idioma del compilador establecido en /std:c++latest y la versión de mi IDE es 15.9.16 y obtengo la siguiente salida de la consola y salgo del estado del programa:

salida de consola

5

Salida de estado de salida IDE

The program ''[4128] Test.exe'' has exited with code 0 (0x0).

Así que esto parece estar definido en el caso de Visual Studio, no estoy seguro de cómo otros compiladores tratarán esto. Se invoca el destructor, sin embargo, la variable a todavía está en la memoria dinámica de almacenamiento dinámico.

Probemos con otra ligera modificación:

#include <iostream> #include <exception> struct A { int x{5}; void f(int){} int g(int y) { x+=y; std::cout << x << ''/n''; return x; } }; int main() { try { auto a = new A; a->f((a->~A(), a->g(3))); catch(const std::exception& e) { std::cerr << e.what(); return EXIT_FAILURE; } return EXIT_SUCCESS; }

salida de consola

8

Salida de estado de salida IDE

The program ''[4128] Test.exe'' has exited with code 0 (0x0).

Esta vez no cambiemos más la clase, pero llamemos al miembro de a después ...

int main() { try { auto a = new A; a->f((a->~A(), a->g(3))); a->g(2); } catch( const std::exception& e ) { std::cerr << e.what(); return EXIT_FAILURE; } return EXIT_SUCCESS; }

salida de consola

8 10

Salida de estado de salida IDE

The program ''[4128] Test.exe'' has exited with code 0 (0x0).

Aquí parece que ax mantiene su valor después de que se llama a->~A() se llamó a new en A y aún no se ha llamado a delete .

Aún más si elimino el new y uso un puntero de pila en lugar de memoria dinámica dinámica asignada:

int main() { try { A b; A* a = &b; a->f((a->~A(), a->g(3))); a->g(2); } catch( const std::exception& e ) { std::cerr << e.what(); return EXIT_FAILURE; } return EXIT_SUCCESS; }

Todavía estoy recibiendo:

salida de consola

8 10

Salida de estado de salida IDE

Cuando cambio la configuración del indicador de idioma de mi compilador de /c:std:c++latest a /std:c++17 obtengo los mismos resultados exactos.

Lo que veo de Visual Studio parece estar bien definido sin producir ningún UB dentro de los contextos de lo que he mostrado. Sin embargo, desde una perspectiva de lenguaje cuando se trata del estándar, tampoco confiaría en este tipo de código. Lo anterior tampoco tiene en cuenta cuando la clase tiene punteros internos, tanto el almacenamiento automático de pila como la asignación dinámica de montón y si el constructor llama nuevo en esos objetos internos y las llamadas destructor eliminan en ellos.

También hay un montón de otros factores además de la configuración de idioma para el compilador, como optimizaciones, llamadas a convenciones y otros diversos indicadores del compilador. Es difícil de decir y no tengo una copia disponible del último estándar completo para investigar esto más a fondo. Tal vez esto pueda ayudarlo a usted, a otras personas que puedan responder su pregunta más a fondo y a otros lectores a visualizar este tipo de comportamiento en acción.


Parece suponer que a->f(0) tiene estos pasos (en ese orden para el estándar C ++ más reciente, en algún orden lógico para las versiones anteriores):

  • evaluando *a
  • evaluar a->f (una función llamada miembro enlazado)
  • evaluando 0
  • llamar a la función miembro a->f en la lista de argumentos (0)

Pero a->f no tiene un valor o tipo. Es esencialmente un no-cosa , un elemento de sintaxis sin sentido necesario solo porque la gramática descompone el acceso de los miembros y la llamada a la función, incluso en una llamada a la función del miembro que, por definición, combina el acceso al miembro y la llamada a la función .

Entonces, preguntar cuándo a->f es "evaluado" es una pregunta sin sentido: no existe un paso de evaluación distinto para la expresión a->f valor, sin tipo .

Por lo tanto, cualquier razonamiento basado en tales discusiones del orden de evaluación de la no entidad también es nulo y sin valor.

EDITAR:

En realidad, esto es peor que lo que escribí, la expresión a->f tiene un "tipo" falso:

E1.E2 es "función del parámetro tipo-lista cv que devuelve T".

"function of parameter-type-list cv" ni siquiera es algo que sería un declarador válido fuera de una clase: no se puede tener f() const como declarador como en una declaración global:

int ::f() const; // meaningless

Y dentro de una clase f() const no significa "función de parámetro-tipo-lista = () con cv = const”, significa función-miembro (de parámetro-tipo-lista = () con cv = const). No hay un declarador adecuado para la "función de cv de lista de tipos de parámetros" adecuada. Solo puede existir dentro de una clase; no existe una función de "cv de lista de tipos de parámetros que devuelva T" que pueda declararse o que sea realmente computable Las expresiones pueden tener.