¿Las fugas de memoria son un problema de clase de "comportamiento indefinido" en C++?
memory-management memory-leaks (14)
Resulta que muchas cosas que parecen inocentes son un comportamiento indefinido en C ++. Por ejemplo, una vez que se ha delete
puntero que no es nulo, incluso la impresión de ese valor del puntero es un comportamiento indefinido .
Ahora las fugas de memoria son definitivamente malas. Pero, ¿qué situación de clase están definidas, indefinidas o qué otra clase de comportamiento?
Pérdidas de memoria.
No hay un comportamiento indefinido. Es perfectamente legal perder memoria.
Comportamiento indefinido: son las acciones que el estándar específicamente no desea definir y deja a la implementación para que sea flexible para realizar ciertos tipos de optimizaciones sin romper el estándar.
La gestión de la memoria está bien definida.
Si asigna memoria dinámicamente y no la libera. Entonces, la memoria sigue siendo propiedad de la aplicación para administrar como le parezca. El hecho de que haya perdido todas las referencias a esa parte de la memoria no está aquí ni allí.
Por supuesto, si continúa la filtración, se quedará sin memoria disponible y la aplicación comenzará a generar excepciones bad_alloc. Pero ese es otro tema.
(Comente a continuación "Heads-up: esta respuesta se ha movido aquí desde ¿Una pérdida de memoria causa un comportamiento indefinido? ", Es probable que tenga que leer esa pregunta para obtener el fondo adecuado para esta respuesta O_o).
Me parece que esta parte de la Norma permite explícitamente:
tener un grupo de memoria personalizado en el que coloca objetos
new
, luego libere / reutilice todo el proceso sin perder tiempo llamando a sus destructores, siempre que no dependa de los efectos secundarios de los destructores de objetos .bibliotecas que asignan un poco de memoria y nunca la liberan, probablemente debido a que sus funciones / objetos podrían ser utilizados por destructores de objetos estáticos y controladores de salida registrados, y no vale la pena comprar todo el orden de destrucción orquestado o transitorio "Phoenix", como el renacimiento cada vez que esos accesos suceden.
No puedo entender por qué el Estándar elige dejar el comportamiento indefinido cuando hay dependencias en los efectos secundarios, en lugar de simplemente decir que esos efectos secundarios no habrán ocurrido y dejar que el programa haya definido o no definido el comportamiento como normalmente esperaría. esa premisa
Aún podemos considerar que lo que dice la Norma es un comportamiento indefinido. La parte crucial es:
"Depende de los efectos secundarios producidos por el destructor que tiene un comportamiento indefinido".
La Norma §1.9 / 12 define explícitamente los efectos secundarios de la siguiente manera (las cursivas a continuación son las Normas, que indican la introducción de una definición formal):
Acceder a un objeto designado por un glvalue
volatile
(3.10), modificar un objeto, llamar a una función de E / S de la biblioteca o llamar a una función que realiza cualquiera de esas operaciones son todos efectos secundarios , que son cambios en el estado del entorno de ejecución.
En su programa, no hay dependencia, así que no hay comportamiento indefinido.
Un ejemplo de dependencia que podría decirse que coincide con el escenario en §3.8 p4, donde la necesidad o la causa de un comportamiento indefinido no es evidente, es:
struct X
{
~X() { std::cout << "bye!/n"; }
};
int main()
{
new X();
}
Un tema que la gente está debatiendo es si el objeto X
anterior se consideraría released
para los fines de 3.8 p4, dado que probablemente solo se haya liberado al sistema operativo después de la finalización del programa. No está claro si se lee la Norma si esa etapa de la vida útil de un proceso está dentro del alcance de los requisitos de comportamiento de la Norma (mi búsqueda rápida de la Norma no lo aclaró). Personalmente, me arriesgo a que 3.8p4 se aplique aquí, en parte porque siempre que sea lo suficientemente ambiguo como para argumentar, un compilador puede sentirse autorizado para permitir un comportamiento indefinido en este escenario, pero incluso si el código anterior no constituye una liberación, el escenario es fácil ala enmendada
int main()
{
X* p = new X();
*(char*)p = ''x''; // token memory reuse...
}
De todos modos, sin embargo, main implementado el destructor anterior tiene un efecto secundario : por "llamar a una función de E / S de la biblioteca"; Además, el comportamiento observable del programa podría decirse que "depende de él" en el sentido de que los búferes que se verían afectados por el destructor si se ejecutaran se vacían durante la finalización. Pero, ¿"depende de los efectos secundarios" solo significa aludir a situaciones en las que el programa tendría claramente un comportamiento indefinido si el destructor no se ejecutara? Me equivocaría por el lado anterior, particularmente porque el último caso no necesitaría un párrafo dedicado en la Norma para documentar que el comportamiento no está definido. Aquí hay un ejemplo con un comportamiento obviamente indefinido:
int* p_;
struct X
{
~X() { if (b_) p_ = 0; else delete p_; }
bool b_;
};
X x{true};
int main()
{
p_ = new int();
delete p_; // p_ now holds freed pointer
new (&x){false}; // reuse x without calling destructor
}
Cuando se llama al destructor de x
durante la terminación, b_
será false
y, por lo tanto, ~X()
delete p_
para un puntero ya liberado, creando un comportamiento indefinido. Si x.~X();
se había llamado antes de volver a utilizar, p_
se habría establecido en 0 y la eliminación habría sido segura. En ese sentido, se puede decir que el comportamiento correcto del programa depende del destructor, y el comportamiento es claramente indefinido, pero hemos creado un programa que coincide con el comportamiento descrito de 3.8p4 por sí mismo, en lugar de que el comportamiento sea una consecuencia de 3.8p4 ...?
Los escenarios más sofisticados con problemas, demasiado largos para proporcionar código, podrían incluir, por ejemplo, una biblioteca C ++ extraña con contadores de referencia dentro de los objetos de la secuencia de archivos que tuvieron que alcanzar 0 para desencadenar algún procesamiento como el vaciado de E / S o la unión de subprocesos de fondo, etc. donde no se pudieron hacer esas cosas, se arriesgaba no solo a realizar la salida solicitada explícitamente por el destructor, sino también a la salida de otra salida almacenada en búfer desde la corriente, o en algún sistema operativo con un sistema de archivos transaccional podría dar lugar a una reversión de la E / S anterior. tales problemas podrían cambiar el comportamiento observable del programa o incluso dejar el programa colgado.
Nota: no es necesario probar que hay algún código real que se comporte de forma extraña en cualquier compilador / sistema existente; El Estándar claramente se reserva el derecho de que los compiladores tengan un comportamiento indefinido ... eso es todo lo que importa. Esto no es algo por lo que pueda razonar y optar por ignorar el Estándar; puede ser que C ++ 14 o alguna otra revisión modifique esta estipulación, pero siempre que esté ahí, si existe una "dependencia" de los efectos secundarios , existe la posibilidad de un comportamiento indefinido (que, por supuesto, a un compilador / implementación particular lo permite, por lo que no significa automáticamente que cada compilador esté obligado a hacer algo extraño).
Agregando a todas las otras respuestas, algún enfoque completamente diferente. Mirando la asignación de memoria en el § 5.3.4-18 podemos ver:
Si cualquier parte de la inicialización del objeto descrita anteriormente 76 termina lanzando una excepción y se puede encontrar una función de desasignación adecuada, se llama a la función de desasignación para liberar la memoria en la que se estaba construyendo el objeto, después de lo cual la excepción continúa propagándose en el Contexto de la nueva-expresión. Si no se puede encontrar una función de desasignación coincidente no ambigua, la propagación de la excepción no hace que se libere la memoria del objeto. [Nota: Esto es apropiado cuando la función de asignación llamada no asigna memoria; de lo contrario, es probable que se produzca una pérdida de memoria. "Nota final"
Si causara UB aquí, sería mencionado, por lo que es "solo una pérdida de memoria".
En lugares como §20.6.4-10, se menciona un posible recolector de basura y un detector de fugas. Se ha pensado mucho en el concepto de punteros derivados de manera segura y otros. para poder usar C ++ con un recolector de basura (C.2.10 "Soporte mínimo para regiones recolectadas por basura").
Por lo tanto, si UB perdería el último puntero a algún objeto, todo el esfuerzo no tendría sentido.
Respecto al "cuando el destructor tiene efectos secundarios que no lo ejecutan nunca UB", diría que esto es incorrecto, de lo contrario las instalaciones como std::quick_exit()
serían inherentemente UB.
Esto obviamente no puede ser un comportamiento indefinido. Simplemente porque UB tiene que suceder en algún momento, y olvidarse de liberar memoria o llamar a un destructor no ocurre en ningún momento. Lo que sucede es que el programa termina sin haber liberado la memoria o llamado el destructor; esto no hace que el comportamiento del programa, o de su terminación, sea indefinido de ninguna manera.
Dicho esto, en mi opinión, la norma se contradice a sí misma en este pasaje. Por un lado, asegura que no se llamará al destructor en este escenario, y por otro lado dice que si el programa depende de los efectos secundarios producidos por el destructor, entonces tiene un comportamiento indefinido. Supongamos que el destructor llama a exit
, entonces ningún programa que haga nada puede pretender ser independiente de eso, porque el efecto secundario de llamar al destructor evitaría que hiciera lo que haría de otra manera; pero el texto también asegura que no se llamará al destructor para que el programa pueda continuar sin hacer nada. Creo que la única manera razonable de leer el final de este pasaje es que si el comportamiento correcto del programa requiere que se llame al destructor, entonces el comportamiento no está definido; Esto es un comentario superfluo, dado que se acaba de estipular que no se llamará al destructor.
La carga de la evidencia recae en aquellos que piensan que una pérdida de memoria podría ser C ++ UB.
Naturalmente no se han presentado pruebas.
En resumen, para cualquiera que tenga dudas, esta pregunta nunca se puede resolver claramente, excepto amenazando de manera muy creíble al comité con, por ejemplo, la música fuerte de Justin Bieber, de modo que agreguen una declaración de C ++ 14 que aclare que no es UB.
El problema es C ++ 11 §3.8 / 4:
" Para un objeto de un tipo de clase con un destructor no trivial, el programa no está obligado a llamar al destructor explícitamente antes de que el almacenamiento que ocupa el objeto se reutilice o libere; sin embargo, si no hay una llamada explícita al destructor o si no se usa una expresión de eliminación (5.3.5) para liberar el almacenamiento, el destructor no se llamará implícitamente y cualquier programa que dependa de los efectos secundarios producidos por el destructor Tiene un comportamiento indefinido.
Este pasaje tenía exactamente la misma redacción en C ++ 98 y C ++ 03. Qué significa eso?
no se requiere que el programa llame al destructor explícitamente antes de que el almacenamiento que ocupa el objeto se reutilice o libere
- significa que uno puede agarrar la memoria de una variable y reutilizar esa memoria, sin destruir primero el objeto existente.Si no hay una llamada explícita al destructor o si no se usa una expresión-eliminación (5.3.5) para liberar el almacenamiento, el destructor no se llamará implícitamente
- significa que si uno no destruye el objeto existente antes de la reutilización de la memoria, si el objeto es tal que se llama automáticamente a su destructor (por ejemplo, una variable local automática), entonces el programa tiene un comportamiento indefinido, ya que ese destructor operaría en un no Objeto más largo existente.y cualquier programa que dependa de los efectos secundarios producidos por el destructor tiene un comportamiento indefinido
- no puede significar literalmente lo que dice, porque un programa siempre depende de cualquier efecto secundario, según la definición de efecto secundario. O, en otras palabras, no hay forma de que el programa no dependa de los efectos secundarios, porque entonces no serían efectos secundarios.
Lo más probable es que lo que se pretendía no era lo que finalmente se abrió camino en C ++ 98, de modo que lo que tenemos a mano es un defecto .
Del contexto, se puede suponer que si un programa se basa en la destrucción automática de un objeto de tipo T
conocido estáticamente, donde la memoria se ha reutilizado para crear un objeto u objetos que no es un objeto T
, entonces ese es un comportamiento indefinido.
Quienes hayan seguido el comentario pueden notar que la explicación anterior de la palabra "deberá" no es el significado que asumí anteriormente. Como lo veo ahora, el "deber" no es un requisito en la implementación, lo que está permitido hacer. Es un requisito del programa, lo que el código puede hacer.
Por lo tanto, esto es formalmente UB:
auto main() -> int
{
string s( 666, ''#'' );
new( &s ) string( 42, ''-'' ); // <- Storage reuse.
cout << s << endl;
// <- Formal UB, because original destructor implicitly invoked.
}
Pero esto está bien con una interpretación literal:
auto main() -> int
{
string s( 666, ''#'' );
s.~string();
new( &s ) string( 42, ''-'' ); // <- Storage reuse.
cout << s << endl;
// OK, because of the explicit destruction of the original object.
}
Un problema principal es que con una interpretación literal del párrafo del estándar anterior aún estaría formalmente OK si la ubicación nueva creara un objeto de un tipo diferente allí, solo por la destrucción explícita del original. Pero no estaría muy bien en la práctica en ese caso. Tal vez esto esté cubierto por algún otro párrafo en el estándar, de modo que también sea formalmente UB.
Y esto también está bien, usando la ubicación new
de <new>
:
auto main() -> int
{
char* storage = new char[sizeof( string )];
new( storage ) string( 666, ''#'' );
string const& s = *(
new( storage ) string( 42, ''-'' ) // <- Storage reuse.
);
cout << s << endl;
// OK, because no implicit call of original object''s destructor.
}
Como yo lo veo - ahora.
La especificación del lenguaje no dice nada acerca de las "fugas de memoria". Desde el punto de vista del idioma, cuando crea un objeto en la memoria dinámica, está haciendo precisamente eso: está creando un objeto anónimo con una duración / almacenamiento ilimitados. "Ilimitado" en este caso significa que el objeto solo puede finalizar su vida útil / duración de almacenamiento cuando lo desasigna explícitamente, pero de lo contrario, continuará viviendo para siempre (siempre que el programa se ejecute).
Ahora, generalmente consideramos que un objeto asignado dinámicamente se convierte en una "pérdida de memoria" en el punto en la ejecución del programa cuando todas las referencias ("referencias genéricas", como los punteros) a ese objeto se pierden hasta el punto de ser irrecuperables. Tenga en cuenta que, incluso para un ser humano, la noción de que "todas las referencias se pierden" no está definida con mucha precisión. ¿Qué sucede si tenemos una referencia a alguna parte del objeto, que se puede "recalcular" teóricamente a una referencia a todo el objeto? ¿Es una pérdida de memoria o no? ¿Qué pasa si no tenemos ninguna referencia al objeto en absoluto, pero de alguna manera podemos calcular dicha referencia utilizando otra información disponible para el programa (como la secuencia precisa de asignaciones)?
La especificación del lenguaje no se ocupa de cuestiones como esa. Independientemente de lo que considere una apariencia de "pérdida de memoria" en su programa, desde el punto de vista del idioma, no es un evento en absoluto. Desde el punto de vista del lenguaje, un objeto "filtrado" asignado dinámicamente continúa viviendo felizmente hasta que finaliza el programa. Este es el único punto de preocupación restante: ¿qué sucede cuando el programa finaliza y todavía se asigna algo de memoria dinámica?
Si recuerdo correctamente, el idioma no especifica qué sucede con la memoria dinámica, a la que aún se le asigna el momento de finalización del programa. No se intentará destruir / desasignar automáticamente los objetos que creó en la memoria dinámica. Pero no hay un comportamiento formal indefinido en casos como ese.
Las fugas de memoria se definen definitivamente en C / C ++.
Si lo hago:
int *a = new int[10];
seguido por
a = new int[10];
Definitivamente estoy perdiendo memoria, ya que no hay forma de acceder a la primera matriz asignada y esta memoria no se libera automáticamente ya que no se admite GC.
Pero las consecuencias de esta fuga son impredecibles y varían de una aplicación a otra y de una máquina a otra para una misma aplicación. Supongamos que una aplicación que se bloquea debido a fugas en una máquina podría funcionar bien en otra máquina con más RAM. También para una aplicación determinada en una máquina determinada, el bloqueo debido a una fuga puede aparecer en diferentes momentos durante la ejecución.
Mi interpretación de esta declaración:
Para un objeto de un tipo de clase con un destructor no trivial, el programa no está obligado a llamar al destructor explícitamente antes de que el almacenamiento que ocupa el objeto se reutilice o libere; sin embargo, si no hay una llamada explícita al destructor o si no se usa una expresión de eliminación (5.3.5) para liberar el almacenamiento, el destructor no se llamará implícitamente y cualquier programa que dependa de los efectos secundarios producidos por el destructor Tiene un comportamiento indefinido.
es como sigue:
Si de alguna manera logra liberar el almacenamiento que ocupa el objeto sin llamar al destructor en el objeto que ocupó la memoria, la consecuencia es UB, si el destructor no es trivial y tiene efectos secundarios.
Si se asigna new
con malloc
, el almacenamiento sin formato podría liberarse con free()
, el destructor no se ejecutaría y se produciría UB. O si un puntero se convierte en un tipo no relacionado y se elimina, la memoria se libera, pero se ejecuta el destructor incorrecto, UB.
Esto no es lo mismo que un delete
omitido, donde la memoria subyacente no se libera. Omitir delete
no es UB.
Respuesta directa: el estándar no define lo que sucede cuando pierde memoria, por lo que es "indefinido". Sin embargo, está implícitamente indefinido, lo cual es menos interesante que las cosas explícitamente indefinidas en el estándar.
Si el transbordador espacial debe despegar en dos minutos, y tengo la opción de hacerlo con un código que pierde memoria y un código que tiene un comportamiento indefinido, estoy ingresando el código que pierde memoria.
Pero la mayoría de nosotros no solemos estar en una situación así, y si lo estamos, es probable que sea por una falla más allá de la línea. Tal vez me equivoque, pero leo esta pregunta como "¿Qué pecado me llevará al infierno más rápido?"
Probablemente el comportamiento indefinido, pero en realidad ambos.
Si pierde memoria, la ejecución continúa como si nada pasara. Este es el comportamiento definido.
Al final de la pista, es posible que una llamada a malloc
falle debido a que no hay suficiente memoria disponible. Pero este es un comportamiento definido de malloc
, y las consecuencias también están bien definidas: la llamada a malloc
devuelve NULL
.
Ahora esto puede hacer que un programa que no comprueba el resultado de malloc
falle con una violación de segmentación. Pero ese comportamiento indefinido es (a partir del POV de las especificaciones de idioma) debido a que el programa elimina la referencia a un puntero no válido, no a la pérdida de memoria anterior o la llamada malloc
fallida.
Su comportamiento definido definitivamente.
Considere un caso en el que el servidor se está ejecutando y siga asignando memoria de almacenamiento dinámico y no se libera memoria, incluso si no se usa. Por lo tanto, el resultado final sería que, finalmente, el servidor se quedará sin memoria y definitivamente se producirá una falla.
definido, ya que una pérdida de memoria se te olvida limpiar después de ti mismo.
por supuesto, una pérdida de memoria probablemente puede causar un comportamiento indefinido más adelante.
Undefined behavior means, what will happen has not been defined or is unknown. The behavior of memory leaks is definitly known in C/C++ to eat away at available memory. The resulting problems, however, can not always be defined and vary as described by gameover.