Занимает ли неиспользуемая переменная-член память?

Занимает ли инициализация переменной-члена и не ссылается на нее/не использует ее дополнительно ОЗУ во время выполнения, или компилятор просто игнорирует эту переменную?

struct Foo {
    int var1;
    int var2;

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

В приведенном выше примере член 'var1' получает значение, которое затем отображается в консоли. "Var2", однако, не используется вообще. Поэтому запись его в память во время выполнения будет пустой тратой ресурсов. Принимает ли компилятор такие ситуации на учет и просто игнорирует неиспользуемые переменные, или объект Foo всегда одинакового размера, независимо от того, используются ли его члены?

Ответы

Ответ 1

Золотое C++ "как если бы" правило 1 гласит, что, если наблюдаемое поведение программы не зависит от неиспользуемого существования данных членов, компилятор имеет право оптимизирован его.

Занимает ли неиспользуемая переменная-член память?

Нет (если оно "действительно" не используется).


Теперь возникает два вопроса:

  1. Когда наблюдаемое поведение не зависит от существования члена?
  2. Встречаются ли такие ситуации в реальных программах?

Давайте начнем с примера.

пример

#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{}; }

Если мы попросим gcc скомпилировать этот модуль перевода, он выдаст:

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

f2 совпадает с f1, и никакая память никогда не используется для хранения фактического Foo2::var2. (Clang делает что-то подобное).

обсуждение

Некоторые могут сказать, что это отличается по двум причинам:

  1. это слишком тривиальный пример,
  2. структура полностью оптимизирована, это не считается.

Что ж, хорошая программа - это умная и сложная сборка простых вещей, а не простое сопоставление сложных вещей. В реальной жизни вы пишете тонны простых функций, используя простые структуры, которые компилятор не оптимизирует. Например:

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

Это подлинный пример того, что элемент данных (здесь std::pair<std::set<int>::iterator, bool>::first) не используется. Угадай, что? Он оптимизирован (более простой пример с фиктивным набором, если эта сборка заставляет вас плакать).

Сейчас самое подходящее время, чтобы прочитать отличный ответ Макса Ланхофа (пожалуйста, передайте его мне). Это объясняет, почему, в конце концов, концепция структуры не имеет смысла на уровне сборки, которую выводит компилятор.

"Но если я сделаю X, то тот факт, что неиспользованный член оптимизирован, является проблемой!"

Было несколько комментариев, утверждающих, что этот ответ должен быть неправильным, потому что некоторая операция (например, assert(sizeof(Foo2) == 2*sizeof(int))) что-то сломает.

Если X является частью наблюдаемого поведения программы 2 компилятору не разрешается оптимизировать вещи. Существует много операций над объектом, содержащим "неиспользуемый" элемент данных, который может оказать заметное влияние на программу. Если такая операция выполняется или если компилятор не может доказать, что ни одна из них не выполнена, этот "неиспользуемый" элемент данных является частью наблюдаемого поведения программы и не может быть оптимизирован.

Операции, которые влияют на наблюдаемое поведение, включают, но не ограничиваются:

  • взять размер типа объекта (sizeof(Foo)),
  • взятие адреса члена данных, объявленного после "неиспользованного",
  • копирование объекта с помощью функции, такой как memcpy,
  • манипулирование представлением объекта (как с memcmp),
  • квалифицировать объект как изменчивый,
  • и т.п.

1)

[intro.abstract]/1

Семантические описания в этом документе определяют параметризованную недетерминированную абстрактную машину. Этот документ не предъявляет никаких требований к структуре соответствующих реализаций. В частности, им не нужно копировать или эмулировать структуру абстрактной машины. Скорее соответствующие реализации должны эмулировать (только) наблюдаемое поведение абстрактной машины, как объяснено ниже.

2) Как утверждают прохождение или провал это.

Ответ 2

Важно понимать, что код, который создает компилятор, не имеет реальных знаний о ваших структурах данных (потому что такого не существует на уровне сборки), равно как и оптимизатор. Компилятор создает код только для каждой функции, а не структуры данных.

Хорошо, он также пишет постоянные разделы данных и тому подобное.

Исходя из этого, мы уже можем сказать, что оптимизатор не будет "удалять" или "исключать" элементы, потому что он не выводит структуры данных. Он выводит код, который может использовать или не использовать элементы, и одной из его целей является экономия памяти или циклов за счет исключения бессмысленного использования (то есть записи/чтения) элементов.


Суть этого в том, что "если компилятор может доказать в рамках функции (включая функции, которые были встроены в него), что неиспользуемый элемент не имеет никакого значения для того, как функция работает (и что она возвращает), тогда велики шансы, что присутствие члена не вызывает никаких накладных расходов ".

Когда вы сделаете взаимодействие функции с внешним миром более сложным/неясным для компилятора (возьмите/верните более сложные структуры данных, например, std::vector<Foo>, скройте определение функции в другом модуле компиляции, запрещать/отменять включение и т.д.), становится все более вероятным, что компилятор не сможет доказать, что неиспользуемый элемент не имеет никакого эффекта.

Здесь нет жестких правил, потому что все зависит от оптимизаций, которые выполняет компилятор, но, пока вы делаете тривиальные вещи (например, как показано в ответе YSC), очень вероятно, что никаких накладных расходов не будет, а делать сложные вещи (например, возвращать std::vector<Foo> из функции, слишком большой для встраивания), вероятно, потребует дополнительных затрат.


Чтобы проиллюстрировать это, рассмотрим этот пример:

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];
}

Здесь мы делаем нетривиальные вещи (берём адреса, проверяем и добавляем байты из представления байтов), и все же оптимизатор может выяснить, что результат всегда одинаков на этой платформе:

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

Мало того, что члены Foo не занимают никакой памяти, Foo даже не появился! Если есть другие варианты использования, которые нельзя оптимизировать, то, например, sizeof(Foo) может иметь значение, но только для этого сегмента кода! Если все способы использования могут быть оптимизированы таким образом, то существование, например, var3 не влияет на сгенерированный код. Но даже если он используется где-то еще, test() останется оптимизированным!

Вкратце: каждое использование Foo оптимизируется независимо. Некоторые могут использовать больше памяти из-за ненужного члена, некоторые - нет. Обратитесь к руководству вашего компилятора для более подробной информации.

Ответ 3

Компилятор оптимизирует только неиспользуемую переменную-член (особенно общедоступную), только если он может доказать, что удаление переменной не имеет побочных эффектов и что ни одна часть программы не зависит от размера Foo.

Я не думаю, что какой-либо текущий компилятор выполняет такие оптимизации, если структура на самом деле не используется вообще. Некоторые компиляторы могут, по крайней мере, предупреждать о неиспользуемых закрытых переменных, но обычно не об открытых.

Ответ 4

В общем, вы должны предположить, что вы получите то, что просили, например, есть "неиспользуемые" переменные-члены.

Поскольку в вашем примере оба члена являются public, компилятор не может знать, будет ли какой-либо код (особенно из других модулей перевода = другие файлы *.cpp, которые скомпилированы отдельно и затем связаны) получит доступ к "неиспользуемому" члену.

Ответ YSC дает очень простой пример, где тип класса используется только в качестве переменной продолжительности автоматического хранения и где указатель на эту переменную не берется. Там компилятор может встроить весь код и затем удалить весь мертвый код.

Если у вас есть интерфейсы между функциями, определенными в разных единицах перевода, обычно компилятор ничего не знает. Интерфейсы обычно следуют некоторому предопределенному ABI (как этот), так что различные объектные файлы могут быть связаны без каких-либо проблем. Как правило, ABI не имеют значения, если член используется или нет. Таким образом, в таких случаях второй член должен быть физически в памяти (если он не будет удален компоновщиком позже).

И пока вы находитесь в пределах границ языка, вы не можете наблюдать, как происходит какое-либо устранение. Если вы позвоните sizeof(Foo), вы получите 2*sizeof(int). Если вы создаете массив Foo, расстояние между началами двух последовательных объектов Foo всегда равно sizeof(Foo) байтов.

Ваш тип является стандартным типом макета, что означает, что вы также можете получить доступ к элементам на основе вычисленных смещений во время компиляции (см. offsetof смещения). Более того, вы можете проверить побайтовое представление объекта, скопировав его в массив char с помощью std::memcpy. Во всех этих случаях можно наблюдать присутствие второго члена.

Ответ 5

Примеры, представленные другими ответами на этот вопрос, которые elide var2, основаны на единственном методе оптимизации: постоянное распространение и последующее исключение всей структуры (не исключение только var2). Это простой случай, и оптимизирующие компиляторы его реализуют.

Для неуправляемых кодов C/C++ ответ заключается в том, что компилятор в общем случае не будет elide var2. Насколько я знаю, в отладочной информации нет поддержки такого преобразования структуры C/C++, и если структура доступна как переменная в отладчике, то var2 не может быть исключен. Насколько я знаю, текущий компилятор C/C++ не может специализировать функции в соответствии с разрешением var2, поэтому, если структура передается или возвращается из не встроенной функции, то var2 не может быть исключен.

Для управляемых языков, таких как С#/Java с JIT-компилятором, компилятор может безопасно исключить var2 потому что он может точно отслеживать, используется ли он и экранируется ли он в неуправляемый код. Физический размер структуры в управляемых языках может отличаться от ее размера, сообщаемого программисту.

Компиляторы C/C++ года 2019 не могут исключить var2 из структуры, если не исключена вся переменная структуры. Для интересных случаев исключения var2 из структуры ответ: Нет.

Некоторые будущие компиляторы C/C++ смогут исключить var2 из структуры, а экосистема, построенная вокруг компиляторов, должна будет адаптироваться к обработке информации об исключениях, генерируемой компиляторами.

Ответ 6

Это зависит от вашего компилятора и уровня его оптимизации.

В gcc, если вы укажете -O, он включит следующие флаги оптимизации:

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

-fdce обозначает " Устранение мертвого кода".

Вы можете использовать __attribute__((used)) для предотвращения gcc исключения неиспользуемой переменной со статическим хранилищем:

Этот атрибут, прикрепленный к переменной со статическим хранилищем, означает, что переменная должна быть выдана, даже если кажется, что на переменную нет ссылки.

При применении к статическому члену данных шаблона класса C++ атрибут также означает, что экземпляр создается, если создается экземпляр самого класса.