c++ memory struct

c++ - ¿Una variable miembro no utilizada ocupa memoria?



memory struct (6)

¿Inicializar una variable miembro y no hacer referencia / usarla ocupa más RAM en tiempo de ejecución, o el compilador simplemente ignora esa variable?

struct Foo { int var1; int var2; Foo() { var1 = 5; std::cout << var1; } };

En el ejemplo anterior, el miembro ''var1'' obtiene un valor que luego se muestra en la consola. ''Var2'', sin embargo, no se utiliza en absoluto. Por lo tanto, escribirlo en la memoria durante el tiempo de ejecución sería un desperdicio de recursos. ¿El compilador toma este tipo de situaciones en una cuenta y simplemente ignora las variables no utilizadas, o el objeto Foo es siempre del mismo tamaño, independientemente de si se usan sus miembros?


Depende de tu compilador y su nivel de optimización.

En gcc, si especifica -O , se activarán los siguientes indicadores de optimización :

-fauto-inc-dec -fbranch-count-reg -fcombine-stack-adjustments -fcompare-elim -fcprop-registers -fdce -fdefer-pop ...

-fdce significa Dead Code Elimination .

Puede usar __attribute__((used)) para evitar que gcc elimine una variable no utilizada con almacenamiento estático:

Este atributo, adjunto a una variable con almacenamiento estático, significa que la variable debe emitirse incluso si parece que la variable no está referenciada.

Cuando se aplica a un miembro de datos estáticos de una plantilla de clase C ++, el atributo también significa que se crea una instancia del miembro si se crea una instancia de la propia clase.


El compilador solo optimizará una variable miembro no utilizada (especialmente una pública) si puede probar que eliminar la variable no tiene efectos secundarios y que ninguna parte del programa depende de que el tamaño de Foo sea ​​el mismo.

No creo que ningún compilador actual realice tales optimizaciones a menos que la estructura no se use en absoluto. Algunos compiladores pueden al menos advertir sobre las variables privadas no utilizadas, pero no usualmente para las públicas.


En general, debe asumir que obtiene lo que solicitó, por ejemplo, las variables miembro "no utilizadas" están ahí.

Como en su ejemplo, ambos miembros son public , el compilador no puede saber si algún código (particularmente de otras unidades de traducción = otros archivos * .cpp, que se compilan por separado y luego se vinculan) accederían al miembro "no utilizado".

La respuesta de YSC da un ejemplo muy simple, donde el tipo de clase solo se usa como una variable de la duración del almacenamiento automático y donde no se toma ningún puntero a esa variable. Allí, el compilador puede alinear todo el código y luego puede eliminar todo el código muerto.

Si tiene interfaces entre funciones definidas en diferentes unidades de traducción, normalmente el compilador no sabe nada. Las interfaces siguen típicamente algunos ABI predefinidos (como that ) de tal manera que diferentes archivos de objetos se pueden vincular entre sí sin ningún problema. Normalmente, los ABI no marcan la diferencia si un miembro es usado o no. Entonces, en tales casos, el segundo miembro tiene que estar físicamente en la memoria (a menos que sea eliminado más tarde por el enlazador).

Y mientras esté dentro de los límites del lenguaje, no puede observar que se produzca ninguna eliminación. Si llama a sizeof(Foo) , obtendrá 2*sizeof(int) . Si crea una matriz de Foo s, la distancia entre los inicios de dos objetos consecutivos de Foo es siempre bytes sizeof(Foo) .

Su tipo es un tipo de diseño estándar , lo que significa que también puede acceder a los miembros en función de las compensaciones computadas en tiempo de compilación ( offsetof macro offsetof ). Además, puede inspeccionar la representación byte por byte del objeto copiando en una matriz de caracteres utilizando std::memcpy . En todos estos casos, se puede observar que el segundo miembro está allí.


Es importante darse cuenta de que el código que produce el compilador no tiene conocimiento real de sus estructuras de datos (porque tal cosa no existe en el nivel de ensamblaje), y tampoco el optimizador. El compilador solo produce código para cada función , no estructuras de datos .

Ok, también escribe secciones de datos constantes y tal.

Sobre esta base, ya podemos decir que el optimizador no "eliminará" ni "eliminará" a los miembros, ya que no genera estructuras de datos. Da salida al código , que puede o no usar a los miembros, y entre sus objetivos es guardar la memoria o los ciclos eliminando los usos sin sentido (es decir, las escrituras / lecturas) de los miembros.

La esencia de esto es que "si el compilador puede probar dentro del alcance de una función (incluidas las funciones que están integradas en ella) que el miembro no utilizado no hace ninguna diferencia en la forma en que opera la función (y lo que devuelve), entonces es muy probable que la presencia del miembro no provoca sobrecarga ".

A medida que hace que las interacciones de una función con el mundo exterior sean más complicadas / poco claras para el compilador (tomar / devolver estructuras de datos más complejas, por ejemplo, un std::vector<Foo> , oculta la definición de una función en una unidad de compilación diferente, prohibir / desincentivar la alineación, etc.), es cada vez más probable que el compilador no pueda probar que el miembro no utilizado no tiene efecto.

No hay reglas estrictas aquí porque todo depende de las optimizaciones que haga el compilador, pero mientras haga cosas triviales (como se muestra en la respuesta de YSC) es muy probable que no haya sobrecarga, mientras que hacer cosas complicadas (por ejemplo, regresar un std::vector<Foo> de una función demasiado grande para la alineación) probablemente incurrirá en la sobrecarga.

Para ilustrar el punto, considere este ejemplo :

struct Foo { int var1 = 3; int var2 = 4; int var3 = 5; }; int test() { Foo foo; std::array<char, sizeof(Foo)> arr; std::memcpy(&arr, &foo, sizeof(Foo)); return arr[0] + arr[4]; }

Aquí hacemos cosas no triviales (tomamos direcciones, inspeccionamos y añadimos bytes de la representación de bytes ) y, sin embargo, el optimizador puede descubrir que el resultado es siempre el mismo en esta plataforma:

test(): # @test() mov eax, 7 ret

No solo los miembros de Foo no ocuparon ningún recuerdo, ¡un Foo ni siquiera llegó a existir! Si hay otros usos que no se pueden optimizar, por ejemplo, sizeof(Foo) puede ser importante, ¡pero solo para ese segmento de código! Si todos los usos podrían optimizarse de esta manera, la existencia de, por ejemplo, var3 no influye en el código generado. Pero incluso si se usa en otro lugar, ¡ test() permanecerá optimizado!

En resumen: cada uso de Foo se optimiza de forma independiente. Algunos pueden usar más memoria debido a un miembro innecesario, otros pueden no. Consulte el manual de su compilador para más detalles.


La regla 1 "como-si" de C ++ dorada establece que, si el comportamiento observable de un programa no depende de la existencia de un miembro de datos no utilizado, el compilador puede optimizarlo .

¿Una variable miembro no utilizada ocupa memoria?

No (si está "realmente" sin usar).

Ahora viene dos preguntas en mente:

  1. ¿Cuándo el comportamiento observable no dependería de la existencia de un miembro?
  2. ¿Ese tipo de situaciones ocurren en programas de la vida real?

Vamos a empezar con un ejemplo.

Ejemplo

#include <iostream> struct Foo1 { int var1 = 5; Foo1() { std::cout << var1; } }; struct Foo2 { int var1 = 5; int var2; Foo2() { std::cout << var1; } }; void f1() { (void) Foo1{}; } void f2() { (void) Foo2{}; }

Si le pedimos a gcc que compile esta unidad de traducción , genera:

f1(): mov esi, 5 mov edi, OFFSET FLAT:_ZSt4cout jmp std::basic_ostream<char, std::char_traits<char> >::operator<<(int) f2(): jmp f1()

f2 es lo mismo que f1 , y nunca se utiliza ninguna memoria para contener un Foo2::var2 real. ( Clang hace algo similar ).

Discusión

Algunos pueden decir que esto es diferente por dos razones:

  1. este es un ejemplo demasiado trivial,
  2. La estructura está totalmente optimizada, no cuenta.

Bueno, un buen programa es un conjunto inteligente y complejo de cosas simples en lugar de una simple yuxtaposición de cosas complejas. En la vida real, escribe toneladas de funciones simples utilizando estructuras simples que el compilador optimiza. Por ejemplo:

bool insert(std::set<int>& set, int value) { return set.insert(value).second; }

Este es un ejemplo genuino de un miembro de datos (aquí, std::pair<std::set<int>::iterator, bool>::first ) sin usar. ¿Adivina qué? Se optimiza para alejar ( ejemplo más simple con un conjunto de prueba si ese conjunto te hace llorar).

Ahora sería el momento perfecto para leer la excelente respuesta de Max Langhof ( avíselo por favor, por favor). Explica por qué, al final, el concepto de estructura no tiene sentido en el nivel de ensamblaje de las salidas del compilador.

"Pero, si hago X, el hecho de que el miembro no utilizado se optimice lejos es un problema!"

Ha habido varios comentarios que argumentan que esta respuesta debe ser incorrecta porque alguna operación (como assert(sizeof(Foo2) == 2*sizeof(int)) ) rompería algo.

Si X es parte del comportamiento observable del programa 2 , el compilador no puede optimizar las cosas. Hay muchas operaciones en un objeto que contiene un miembro de datos "no utilizado" que tendría un efecto observable en el programa. Si se realiza una operación de este tipo o si el compilador no puede demostrar que no se ha realizado ninguna, ese miembro de datos "no utilizado" forma parte del comportamiento observable del programa y no se puede optimizar .

Las operaciones que afectan el comportamiento observable incluyen, pero no se limitan a:

  • tomando el tamaño de un tipo de objeto ( sizeof(Foo) ),
  • tomar la dirección de un miembro de datos declarado después del "no utilizado",
  • copiando el objeto con una función como memcpy ,
  • manipulando la representación del objeto (como con memcmp ),
  • calificar un objeto como volátil ,
  • etc.

1)

[intro.abstract]/1

Las descripciones semánticas en este documento definen una máquina abstracta no determinista parametrizada. Este documento no impone ningún requisito sobre la estructura de las implementaciones conformes. En particular, no es necesario que copien o emulen la estructura de la máquina abstracta. Más bien, se requiere que las implementaciones conformes emulen (solo) el comportamiento observable de la máquina abstracta como se explica a continuación.

2) Como un asertivo que pasa o falla es.


Los ejemplos proporcionados por otras respuestas a esta pregunta que elide var2 se basan en una única técnica de optimización: propagación constante y subsiguiente elisión de toda la estructura (no la elisión de solo var2 ). Este es el caso simple, y los compiladores optimizadores lo implementan.

Para los códigos C / C ++ no administrados, la respuesta es que el compilador, en general, no elide var2 . Por lo que sé, no hay soporte para tal transformación de estructura C / C ++ en la información de depuración, y si la estructura es accesible como una variable en un depurador, entonces var2 no puede eliminarse. Por lo que sé, ningún compilador actual de C / C ++ puede especializar las funciones de acuerdo con la elección de var2 , por lo que si la estructura pasa o se devuelve desde una función no integrada, no se puede eliminar var2 .

Para los lenguajes administrados, como C # / Java con un compilador JIT, el compilador podría elidir var2 manera segura porque puede rastrear con precisión si se está utilizando y si se escapa al código no administrado. El tamaño físico de la estructura en los idiomas administrados puede ser diferente del tamaño que se informa al programador.

Los compiladores C / C ++ del año 2019 no pueden eliminar var2 de la estructura a menos que se elimine la variable de estructura completa. Para casos interesantes de elision de var2 de la estructura, la respuesta es: No.

Algunos futuros compiladores de C / C ++ podrán elidir var2 de la estructura, y el ecosistema construido alrededor de los compiladores deberá adaptarse a la información de proceso de generación generada por los compiladores.