objetos - ¿Por qué exactamente llamar al destructor por segunda vez un comportamiento indefinido en C++?
ejemplos de poo en c++ (16)
Básicamente, como ya se señaló, llamar al destructor por segunda vez fallará para cualquier destructor de clase que realice el trabajo.
Como se mencionó en esta respuesta, simplemente llamar al destructor por segunda vez ya es un comportamiento indefinido 12.4 / 14 (3.8).
Por ejemplo:
class Class {
public:
~Class() {}
};
// somewhere in code:
{
Class* object = new Class();
object->~Class();
delete object; // UB because at this point the destructor call is attempted again
}
En este ejemplo, la clase está diseñada de tal manera que el destructor se puede llamar varias veces, no pueden suceder cosas como la doble eliminación. La memoria aún está asignada en el punto donde se llama a delete
: la primera llamada del destructor no llama a ::operator delete()
para liberar memoria.
Por ejemplo, en Visual C ++ 9 el código anterior parece funcionar. Incluso la definición de C ++ de UB no prohíbe directamente que las cosas calificadas como UB funcionen. Por lo tanto, para que el código anterior rompa algunos requisitos de implementación y / o plataforma se requieren.
¿Por qué exactamente se rompería el código anterior y bajo qué condiciones?
Creo que su pregunta apunta a la razón detrás de la norma. Piénsalo al revés:
- Definir el comportamiento de llamar a un destructor dos veces crea trabajo, posiblemente mucho trabajo.
- Su ejemplo solo muestra que en algunos casos triviales no sería un problema llamar al destructor dos veces. Eso es cierto pero no muy interesante.
- No dio un caso de uso convincente (y dudo que pueda) cuando llamar al destructor dos veces es una buena idea / facilita el código / hace que el lenguaje sea más poderoso / limpia la semántica / o cualquier otra cosa.
Entonces, ¿por qué una vez más esto no debería causar un comportamiento indefinido?
Cuando utiliza las facilidades de C ++ para crear y destruir sus objetos, acepta utilizar su modelo de objetos, sin embargo, está implementado.
Algunas implementaciones pueden ser más sensibles que otras. Por ejemplo, un entorno interpretado interactivo o un depurador podrían esforzarse más para ser introspectivos. Eso podría incluir incluso alertarlo específicamente sobre la doble destrucción.
Algunos objetos son más complicados que otros. Por ejemplo, los destructores virtuales con clases de base virtual pueden ser un poco peludos. El tipo dinámico de un objeto cambia sobre la ejecución de una secuencia de destructores virtuales, si recuerdo correctamente. Eso podría fácilmente llevar a un estado inválido al final.
Es bastante fácil declarar funciones con nombres apropiados para usar en lugar de abusar del constructor y el destructor. La C directa directa orientada a objetos aún es posible en C ++, y puede ser la herramienta correcta para algún trabajo ... en cualquier caso, el destructor no es el constructo correcto para cada tarea relacionada con la destrucción.
El objeto ya no existe después de llamar al destructor.
Entonces, si lo vuelves a llamar, estás llamando a un método en un objeto que no existe .
¿Por qué este comportamiento sería definido alguna vez? El compilador puede optar por poner a cero la memoria de un objeto que ha sido destruido, por depuración / seguridad / alguna razón, o reciclar su memoria con otro objeto como una optimización, o lo que sea. La implementación puede hacer lo que le plazca. Volver a llamar al destructor es esencialmente llamar a un método en una memoria en bruto arbitraria: una mala idea (tm).
Es un comportamiento indefinido porque el estándar dejó en claro para qué se usa un destructor, y no decidió qué sucedería si lo usas incorrectamente. Un comportamiento indefinido no significa necesariamente "aplastoso", solo significa que el estándar no lo definió, por lo que se deja a la implementación.
Si bien no soy demasiado fluido en C ++, mi instinto me dice que la implementación es bienvenida ya sea para tratar al destructor como simplemente otra función miembro, o para destruir el objeto cuando se llama al destructor. Por lo tanto, podría romperse en algunas implementaciones, pero tal vez no en otras. Quién sabe, está indefinido (presta atención a los demonios que vuelan por tu nariz si lo intentas).
Estándar 12.4 / 14
Una vez que se invoca un destructor para un objeto, el objeto ya no existe; el comportamiento no está definido si se invoca el destructor para un objeto cuya vida útil ha finalizado (3.8).
Creo que esta sección se refiere a invocar el destructor a través de eliminar. En otras palabras: la esencia de este párrafo es que "eliminar un objeto dos veces es un comportamiento indefinido". Es por eso que tu código de ejemplo funciona bien.
Sin embargo, esta cuestión es bastante académica. Los destructores están destinados a ser invocados a través de la eliminación (aparte de la excepción de los objetos asignados a través de la ubicación, ya que se ha observado correctamente como diente puntiagudo). Si desea compartir el código entre un destructor y la segunda función, simplemente extraiga el código a una función separada y llámelo desde su destructor.
La razón es porque su clase podría ser, por ejemplo, un puntero inteligente de referencia contado. Entonces el destructor decrementa el contador de referencia. Una vez que ese contador llega a 0, el objeto real debe ser limpiado.
Pero si llamas al destructor dos veces, el conteo se desordenará.
La misma idea para otras situaciones también. Tal vez el destructor escribe 0s en una parte de la memoria y luego la desasigna (para no dejar accidentalmente la contraseña de un usuario en la memoria). Si intenta volver a escribir en esa memoria, después de que se haya desasignado, se producirá una infracción de acceso.
Simplemente tiene sentido que los objetos se construyan una vez y se destruyan una vez.
La razón es que, en ausencia de esa regla, sus programas serían menos estrictos. Ser más estricto, incluso cuando no se aplica en tiempo de compilación, es bueno porque, a cambio, obtienes una mayor previsibilidad de cómo se comportará el programa. Esto es especialmente importante cuando el código fuente de las clases no está bajo su control.
Muchos conceptos: RAII, punteros inteligentes y solo la asignación / liberación genérica de memoria dependen de esta regla. La cantidad de veces que se llamará al destructor (una) es esencial para ellos. Entonces, la documentación para tales cosas generalmente promete: "¡ Use nuestras clases de acuerdo con las reglas del lenguaje C ++, y funcionarán correctamente! "
Si no existiera tal regla, se establecería como " Use nuestras clases de acuerdo con las reglas de lenguaje C ++, y sí, no llame a su destructor dos veces, entonces funcionarán correctamente " . Muchas especificaciones sonarían de esa manera. El concepto es demasiado importante para el idioma para omitirlo en el documento estándar.
Esta es la razón. No es nada relacionado con los internos binarios (que se describen en la respuesta de Potatoswatter ).
La razón para la formulación en el estándar es muy probablemente que todo lo demás sería mucho más complicado : tendría que definir cuándo es posible exactamente la eliminación doble (o al revés), es decir, con un destructor trivial o con un destructor cuyos efectos secundarios pueden ser descartados.
Por otro lado, no hay beneficio para este comportamiento. En la práctica, no puede beneficiarse de él porque no puede saber en general si un destructor de clase cumple con los criterios anteriores o no. Ningún código de propósito general podría confiar en esto. Sería muy fácil introducir errores de esa manera. Y finalmente, ¿cómo ayuda? Simplemente hace posible escribir código descuidado que no rastrea la vida útil de sus objetos, es decir, código subespecificado. ¿Por qué debería el estándar apoyar esto?
¿Los compiladores / tiempos de ejecución existentes romperán su código particular? Probablemente no, a menos que tengan controles especiales en tiempo de ejecución para evitar el acceso ilegal (para evitar lo que parece ser un código malicioso o simplemente protección contra fugas).
La siguiente Class
se bloqueará en Windows en mi máquina si llamas a destructor dos veces:
class Class {
public:
Class()
{
x = new int;
}
~Class()
{
delete x;
x = (int*)0xbaadf00d;
}
int* x;
};
Puedo imaginar una implementación cuando se estrellará con un destructor trivial. Por ejemplo, tal implementación podría eliminar los objetos destruidos de la memoria física y cualquier acceso a ellos podría llevar a algún fallo de hardware. Parece que Visual C ++ no es una de esas implementaciones, pero quién sabe.
Los destructores no son funciones regulares. Llamar a uno no llama a una función, llama a muchas funciones. Es la magia de los destructores. Si bien ha proporcionado un destructor trivial con la única intención de hacer que sea difícil mostrar cómo podría romperse, no ha podido demostrar qué hacen las otras funciones a las que se llama. Y tampoco lo hace la norma. Es en esas funciones que las cosas potencialmente pueden desmoronarse.
Como ejemplo trivial, digamos que el compilador inserta código para rastrear la vida útil de los objetos con fines de depuración. El constructor [que también es una función mágica que hace todo tipo de cosas que no le pediste] almacena algunos datos en algún lugar que dicen "Aquí estoy". Antes de llamar al destructor, cambia los datos para que digan "Ahí voy". Una vez que se llama al destructor, se deshace de la información que utilizó para encontrar esos datos. Así que la próxima vez que llame al destructor, terminará con una infracción de acceso.
Probablemente también puedas dar ejemplos que involucren tablas virtuales, pero tu código de muestra no incluyó ninguna función virtual, por lo que sería trampa.
No está definido porque, de no ser así, cada implementación tendría que marcarse a través de algunos metadatos ya sea que un objeto aún esté vivo o no. Tendría que pagar ese costo por cada objeto que va en contra de las reglas básicas de diseño de C ++.
Por definición, el destructor ''destruye'' el objeto y destruye un objeto dos veces no tiene sentido.
Tu ejemplo funciona pero es difícil que funcione en general.
Supongo que se ha clasificado como no definido porque la mayoría de las eliminaciones dobles son peligrosas y el comité de estándares no quiso agregar una excepción al estándar para los pocos casos en los que no es necesario.
En cuanto a donde podría romper su código; es posible que encuentre los saltos de código en compilaciones de depuración en algunos compiladores; muchos compiladores tratan a UB como "hacer lo que no afectaría al rendimiento para un comportamiento bien definido" en el modo de lanzamiento e "insertar comprobaciones para detectar un mal comportamiento" en las versiones de depuración.
Un ejemplo importante de una implementación que podría romperse:
Una implementación de C ++ conforme puede soportar la recolección de basura. Este ha sido un objetivo de diseño de larga data. Un GC puede asumir que un objeto puede ser GC''ed inmediatamente cuando se ejecuta su dtor. Así, cada llamada de dtor actualizará su contabilidad interna de GC. La segunda vez que se llama al dtor para el mismo puntero, las estructuras de datos del GC podrían corromperse.
Ya que lo que realmente está solicitando es una implementación plausible en la que su código fallaría, suponga que su implementación proporciona un modo de depuración útil, en el que rastrea todas las asignaciones de memoria y todas las llamadas a constructores y destructores. Entonces, después de la llamada explícita del destructor, establece un indicador para decir que el objeto ha sido destruido. delete
comprueba esta bandera y detiene el programa cuando detecta la evidencia de un error en su código.
Para hacer que su código "funcione" como usted quería, esta implementación de depuración tendría que hacer especial hincapié en su destructor de no hacer nada, y omitir la configuración de esa bandera. Es decir, tendría que asumir que estás destruyendo deliberadamente dos veces porque (piensas) el destructor no hace nada, en lugar de suponer que estás destruyendo accidentalmente dos veces, pero no pudo detectar el error porque el destructor no hace nada. . O eres descuidado o eres un rebelde, y hay más kilometraje en las implementaciones de depuración que ayudan a las personas que son descuidadas de lo que hay en complacer a los rebeldes ;-)