¿Una variable miembro no utilizada ocupa memoria?

91

¿Inicializar una variable miembro y no referenciarla / usarla consume más RAM durante el 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. Sin embargo, 'Var2' no se utiliza en absoluto. Por lo tanto, escribirlo en la memoria durante el tiempo de ejecución sería una pérdida de recursos. ¿El compilador toma en cuenta este tipo de situaciones y simplemente ignora las variables no utilizadas, o el objeto Foo siempre tiene el mismo tamaño, independientemente de si se utilizan sus miembros?

Chriss555888
fuente
25
Esto depende del compilador, la arquitectura, el sistema operativo y la optimización utilizada.
Búho
16
Existe una tonelada métrica de código de controlador de bajo nivel que agrega específicamente miembros de estructura que no hacen nada para que el relleno coincida con los tamaños de los marcos de datos del hardware y como un truco para obtener la alineación de memoria deseada. Si un compilador comenzara a optimizarlos, habría muchas fallas.
Andy Brown
2
@Andy, en realidad no hacen nada, ya que se evalúa la dirección de los siguientes miembros de datos. Esto significa que la existencia de esos miembros de relleno tiene un comportamiento observable en el programa. Aquí var2no lo hace.
YSC
4
Me sorprendería si el compilador pudiera optimizarlo dado que cualquier unidad de compilación que aborde tal estructura podría vincularse a otra unidad de compilación usando la misma estructura y el compilador no puede saber si la unidad de compilación separada se dirige al miembro o no.
Galik
2
@geza sizeof(Foo)no puede disminuir por definición; si imprime sizeof(Foo), debe ceder 8(en plataformas comunes). Los compiladores pueden optimizar el espacio utilizado por var2(sin importar si es a través newo en la pila o en llamadas a funciones ...) en cualquier contexto que les parezca razonable, incluso sin LTO o optimización del programa completo. Donde eso no sea posible, no lo harán, como con casi cualquier otra optimización. Creo que la edición de la respuesta aceptada hace que sea mucho menos probable que se engañe.
Max Langhof

Respuestas:

106

La regla dorada de C ++ "como si" 1 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 no se utiliza "realmente").


Ahora vienen dos preguntas en mente:

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

Comencemos 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 , da como resultado:

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

f2es lo mismo que f1, y nunca se utiliza ninguna memoria para mantener un archivo real Foo2::var2. ( 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á completamente optimizada, no cuenta.

Bueno, un buen programa es un ensamblaje 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 usando 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) que no se usa. ¿Adivina qué? Está optimizado ( ejemplo más simple con un conjunto ficticio si ese ensamblaje te hace llorar).

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

"Pero, si hago X, ¡el hecho de que el miembro no utilizado esté optimizado es un problema!"

Ha habido una serie de 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 probar que no se realiza ninguna, ese miembro de datos "no utilizado" es 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) ),
  • tomando la dirección de un miembro de datos declarado después del "no utilizado",
  • copiando el objeto con una función como memcpy,
  • manipular 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 necesitan copiar ni emular la estructura de la máquina abstracta. Más bien, se requieren implementaciones conformes para emular (solo) el comportamiento observable de la máquina abstracta como se explica a continuación.

2) Como una afirmación que pasa o falla.

YSC
fuente
Los comentarios que sugieren mejoras a la respuesta se han archivado en el chat .
Cody Gray
1
Incluso assert(sizeof(…)…), en realidad, no limita al compilador; tiene que proporcionar un sizeofcódigo que permita el uso de cosas como memcpypara funcionar, pero eso no significa que el compilador de alguna manera deba usar tantos bytes a menos que puedan estar expuestos a un código tal memcpyque pueda No reescriba para producir el valor correcto de todos modos.
Davis Herring
@Davis Absolutamente.
YSC
63

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 demás.

En base a eso, ya podemos decir que el optimizador no "eliminará" ni "eliminará" miembros, porque no genera estructuras de datos. Genera código , que puede o no usar a los miembros, y entre sus objetivos está el ahorro de memoria o ciclos al eliminar usos sin sentido (es decir, 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 estaban integradas en ella) que el miembro no utilizado no hace ninguna diferencia en cómo opera la función (y lo que devuelve), entonces es muy probable que la presencia del miembro no provoca gastos generales ".

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

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


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 (tomar direcciones, inspeccionar y agregar bytes de la representación de bytes ) y, sin embargo, el optimizador puede darse cuenta de que el resultado es siempre el mismo en esta plataforma:

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

No solo los miembros de Foono ocuparon ningún recuerdo, ¡ Fooni siquiera llegaron a existir! Si hay otros usos que no se pueden optimizar, por ejemplo, sizeof(Foo)podría ser importante, ¡pero solo para ese segmento de código! Si todos los usos pudieran optimizarse así, entonces 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 Foose optimiza de forma independiente. Algunos pueden usar más memoria debido a un miembro innecesario, otros pueden no. Consulte el manual del compilador para obtener más detalles.

Max Langhof
fuente
6
Mic drop "Consulte el manual del compilador para obtener más detalles". : D
YSC
22

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 del tamaño de Foo la misma.

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

Alan Birtles
fuente
1
Y, sin embargo, lo hace: godbolt.org/z/UJKguS + ningún compilador advertiría sobre un miembro de datos no utilizado.
YSC
@YSC clang ++ advierte sobre variables y miembros de datos no utilizados.
Maxim Egorushkin
3
@YSC Creo que es una situación ligeramente diferente, optimizó la estructura por completo y solo imprime 5 directamente
Alan Birtles
4
@AlanBirtles No veo cómo es diferente. El compilador optimizó todo desde el objeto que no tiene ningún efecto sobre el comportamiento observable del programa. Entonces, su primera oración "es muy poco probable que el compilador optimice una variable miembro no utilizada" es incorrecta.
YSC
2
@YSC en código real donde la estructura se está utilizando en realidad en lugar de simplemente construida por sus efectos secundarios, es probablemente más improbable que se optimice
Alan Birtles
7

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

Dado que en su ejemplo ambos miembros lo 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 duración de almacenamiento automático y donde no se toma ningún puntero a esa variable. Allí, el compilador puede incorporar todo el código y luego 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 una ABI predefinida (como esa ) de modo que diferentes archivos de objetos se pueden vincular entre sí sin ningún problema. Por lo general, las ABI no marcan la diferencia si un miembro se usa o no. Entonces, en tales casos, el segundo miembro tiene que estar físicamente en la memoria (a menos que el enlazador lo elimine más tarde).

Y mientras esté dentro de los límites del idioma, no puede observar que ocurre ninguna eliminación. Si llama sizeof(Foo), obtendrá 2*sizeof(int). Si crea una matriz de Foos, la distancia entre los comienzos de dos objetos consecutivos de Fooes siempre sizeof(Foo)bytes.

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

Handy999
fuente
Los comentarios no son para una discusión extensa; esta conversación se ha movido al chat .
Cody Gray
2
+1: solo una optimización agresiva de todo el programa podría ajustar el diseño de datos (incluidos los tamaños y compensaciones en tiempo de compilación) para los casos en que un objeto de estructura local no se optimiza por completo,. gcc -fwhole-program -O3 *.cen teoría podría hacerlo, pero en la práctica probablemente no lo hará. (por ejemplo, en caso de que el programa haga algunas suposiciones sobre qué valor exacto sizeof()tiene en este objetivo, y porque es una optimización realmente complicada que los programadores deben hacer a mano si lo desean)
Peter Cordes
6

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

Para los códigos C / C ++ no administrados, la respuesta es que el compilador, en general, no elide var2. Hasta donde yo 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 var2no se puede elidir. Hasta donde yo sé, ningún compilador actual de C / C ++ puede especializar funciones de acuerdo con la elisión de var2, por lo que si la estructura se pasa o se devuelve desde una función no incorporada, var2no se puede elidir.

Para lenguajes administrados como C # / Java con un compilador JIT, el compilador podría eludirlo de manera segura var2porque 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 lenguajes administrados puede ser diferente de su tamaño informado al programador.

Los compiladores de C / C ++ del año 2019 no pueden elidir var2de la estructura a menos que se elimine toda la variable de estructura. Para casos interesantes de elisión de var2de la estructura, la respuesta es: No.

Algunos compiladores futuros de C / C ++ podrán eludir var2la estructura, y el ecosistema construido alrededor de los compiladores deberá adaptarse para procesar la información de elisión generada por los compiladores.

símbolo del átomo
fuente
1
Su párrafo sobre la información de depuración se reduce a "no podemos optimizarlo si eso dificultaría la depuración", lo cual es simplemente incorrecto. O estoy leyendo mal. ¿Podrías aclarar?
Max Langhof
Si el compilador emite información de depuración sobre la estructura, no puede eludir var2. Las opciones son: (1) No emitir la información de depuración si no corresponde a la representación física de la estructura, (2) Apoyar la elisión del miembro de la estructura en la información de depuración y emitir la información de depuración
atomsymbol
Quizás más general sea referirse al Reemplazo escalar de agregados (y luego la elisión de almacenes muertos, etc. ).
Davis Herring
4

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

En gcc, si lo especifica -O, activará las siguientes marcas de optimización :

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

-fdcesignifica Eliminación de código muerto .

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 ser emitida 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.

maravilla
fuente
Eso es para miembros de datos estáticos , no miembros por instancia no utilizados (que no se optimizan a menos que todo el objeto lo haga). Pero sí, supongo que eso cuenta. Por cierto, eliminar las variables estáticas no utilizadas no es una eliminación de código muerto , a menos que GCC doble el término.
Peter Cordes