Чи займає невикористана змінна члена пам'ять?


91

Чи ініціалізація змінної-члена, а не посилання / використання її, додатково займає оперативну пам’ять під час виконання, чи компілятор просто ігнорує цю змінну?

struct Foo {
    int var1;
    int var2;

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

У наведеному вище прикладі член 'var1' отримує значення, яке потім відображається в консолі. Однак 'Var2' взагалі не використовується. Тому запис його в пам'ять під час виконання буде марною тратою ресурсів. Чи бере компілятор до обліку подібні ситуації та просто ігнорує невикористані змінні, або об’єкт Foo завжди однакового розміру, незалежно від того, чи використовуються його члени?


25
Це залежить від компілятора, архітектури, операційної системи та використовуваної оптимізації.
Сова

16
Існує метрична тонна коду драйвера низького рівня, який спеціально додає бездіяльні члени структури для заповнення відповідно до розмірів апаратних даних кадру та як хак для отримання бажаного вирівнювання пам'яті. Якби компілятор почав їх оптимізувати, було б багато поломок.
Andy Brown

2
@Andy, вони насправді нічого не роблять, оскільки обчислюється адреса наступних членів даних. Це означає, що існування цих членів оббивки дійсно спостерігається у програмі. Тут var2немає.
YSC

4
Я був би здивований, якби компілятор зміг би його оптимізувати, враховуючи, що будь-який модуль компіляції, що звертається до такої структури, може бути пов'язаний з іншим блоком компіляції, використовуючи ту саму структуру, і компілятор не може знати, звертається окремий блок компіляції до члена чи ні.
Галик

2
@geza sizeof(Foo)не може зменшуватися за визначенням - якщо ви друкуєте, sizeof(Foo)він повинен дати 8(на загальних платформах). Компілятори можуть оптимізувати простір, який використовується var2(незалежно від того, через newстек, у стеку чи у викликах функцій ...) у будь-якому контексті, який вони вважають розумним, навіть без оптимізації LTO або цілої програми. Там, де це неможливо, вони цього не роблять, як і майже будь-яка інша оптимізація. Я вважаю, що редагування прийнятої відповіді робить значно меншою ймовірність введення в оману.
Макс Ланггоф,

Відповіді:


106

Золотий 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. ( Кленг робить щось подібне ).

Обговорення

Деякі можуть сказати, що це інакше з двох причин:

  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) Як твердження, що проходить або не відповідає.


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

1
Навіть assert(sizeof(…)…)насправді не обмежує компілятор - він повинен забезпечити sizeofкод, що дозволяє коду, memcpyякий працює, але це не означає, що компілятору якось потрібно використовувати стільки байт, якщо він не може бути підданий такому, memcpyщо він може все одно не перепишіть, щоб отримати правильне значення.
Девіс Оселедець

@Davis Абсолютно.
YSC

63

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

Гаразд, він також пише постійні розділи даних тощо.

Виходячи з цього, ми вже можемо сказати, що оптимізатор не буде "видаляти" або "виключати" члени, оскільки він не виводить структури даних. Він виводить код , який може або не повинен використовувати члени, і серед його цілей є економія пам'яті або циклів шляхом усунення безглуздого використання (тобто запису / читання) членів.


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

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

Тут немає жорстких правил, оскільки все залежить від оптимізацій, які робить компілятор, але поки ви робите тривіальні речі (як показано у відповіді YSC), дуже ймовірно, що ніяких накладних витрат не буде, тоді як робити складні речі (наприклад, повернення a 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оптимізується незалежно. Хтось може використовувати більше пам'яті через непотрібного члена, хтось може ні. Зверніться до посібника компілятора для отримання детальної інформації.


6
Падіння мікрофона "Докладніше див. У посібнику компілятора." : D
YSC

22

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

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


1
І все-таки це відбувається: godbolt.org/z/UJKguS + жоден компілятор не попереджав би про невикористаний елемент даних.
YSC

@YSC clang ++ попереджає про невикористані члени даних та змінні.
Максим

3
@YSC Я думаю, що це трохи інша ситуація, її оптимізована структура повністю і просто друкується 5 безпосередньо
Алан

4
@AlanBirtles Я не бачу, як це інакше. Компілятор оптимізував все від об'єкта, що не впливає на спостережувану поведінку програми. Отже, ваше перше речення "компілятор навряд чи оптимізує невикористовувану змінну члена" помилкове.
YSC

2
@YSC у реальному коді, де структура фактично використовується, а не просто побудована для її побічних ефектів, ймовірно, більш малоймовірно, що вона буде оптимізована для подальшого використання
Алан

7

Загалом, ви повинні припустити, що отримуєте те, про що ви просили, наприклад, там є “невикористані” змінні-члени.

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

Відповідь YSC дає дуже простий приклад, де тип класу використовується лише як змінна автоматичної тривалості зберігання, і коли вказівник на цю змінну не береться. Там компілятор може вбудувати весь код, а потім може усунути весь мертвий код.

Якщо у вас є інтерфейси між функціями, визначеними в різних одиницях перекладу, зазвичай компілятор нічого не знає. Інтерфейси, як правило, відповідають деяким заздалегідь визначеним ABI (наприклад , таким), що різні об'єктні файли можуть бути пов'язані між собою без будь-яких проблем. Зазвичай ABI не роблять різниці в тому, використовується член чи ні. Отже, у таких випадках другий член повинен бути фізично в пам’яті (якщо його не усуне пізніше лінкер).

І поки ви знаходитесь у межах мови, ви не можете спостерігати, що відбувається будь-яка ліквідація. Якщо зателефонувати sizeof(Foo), то отримаєте 2*sizeof(int). Якщо ви створюєте масив Foos, відстань між початками двох послідовних об'єктів Fooзавжди дорівнює sizeof(Foo)байтам.

Ваш тип є стандартним типом макета , що означає, що ви також можете отримати доступ до членів на основі компенсованих зсувів під час компіляції (пор. offsetofМакрос). Більше того, ви можете перевірити байтове представлення об'єкта, скопіювавши в масив charuse std::memcpy. У всіх цих випадках можна спостерігати, що там знаходиться другий член.


Коментарі не призначені для розширеного обговорення; цю розмову переміщено до чату .
Коді Грей

2
+1: лише агресивна оптимізація цілої програми могла б скоригувати компонування даних (включаючи розміри та зміщення часу компіляції) для випадків, коли локальний об'єкт структури не повністю оптимізований,. gcc -fwhole-program -O3 *.cтеоретично міг би це зробити, але на практиці, мабуть, не буде. (наприклад, у випадку, якщо програма робить деякі припущення щодо того, яке саме значення sizeof()має ця ціль, і тому що це дійсно складна оптимізація, яку програмісти повинні робити вручну, якщо вони цього хочуть.)
Пітер Кордес,

6

Приклади, наведені в інших відповідях на це питання, які вислизають var2, базуються на одній техніці оптимізації: постійне розповсюдження та подальше елізіювання всієї структури (а не елізія простоvar2 ). Це простий випадок, і оптимізаційні компілятори це реалізують.

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

Для керованих мов, таких як C # / Java із компілятором JIT, компілятор може мати можливість безпечного виходу var2 оскільки він може точно відстежувати, чи використовується, і чи він до некерованого коду. Фізичний розмір структури в керованих мовах може відрізнятися від її розміру, повідомленого програмісту.

Компілятори C / C ++ 2019 року не можуть вийти var2зі структури, якщо не вилучена вся змінна struct. Для цікавих випадків вилучення var2з структури відповідь: Ні.

Деякі майбутні компілятори C / C ++ зможуть відійти var2від структури, а екосистема, побудована навколо компіляторів, повинна адаптуватися до обробки інформації про елісію, що генерується компіляторами.


1
Ваш параграф про інформацію про налагодження зводиться до "ми не можемо оптимізувати її, якщо це ускладнить налагодження", що є просто неправильним. Або я неправильно читаю. Не могли б ви пояснити?
Макс Ланггоф,

Якщо компілятор видає інформацію про налагодження про структуру, він не може видалити var2. Варіанти: (1) Не видавати інформацію про налагодження, якщо вона не відповідає фізичному
поданню

Можливо, більш загальним є посилання на скалярну заміну агрегатів (а потім видалення мертвих сховищ тощо ).
Девіс Оселедець

4

Це залежить від вашого компілятора та рівня його оптимізації.

Якщо вказати gcc, -Oвін увімкне наступні прапори оптимізації :

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

-fdceрозшифровується як Усунення мертвого коду .

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

Цей атрибут, приєднаний до змінної зі статичним сховищем, означає, що змінна повинна бути випущена, навіть якщо здається, що на змінну немає посилання.

Застосовуючись до статичного члена даних шаблону класу C ++, атрибут також означає, що член інстанціюється, якщо інстанціюється сам клас.


Це стосується статичних членів даних, а не невикористаних членів кожного екземпляра (які не оптимізуються, якщо це не робить весь об’єкт). Але так, я гадаю, це має значення. До речі, усунення невикористаних статичних змінних не є усуненням мертвого коду , якщо GCC не згине термін.
Пітер Кордес,
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.