Чи "завжди ініціалізація змінних" не призводить до того, що важливі помилки приховуються?


35

Основні вказівки C ++ мають правило ES.20: Завжди ініціалізувати об'єкт .

Уникайте використаних раніше встановлених помилок та пов'язаних з ними невизначених поведінок. Уникайте проблем із розумінням складної ініціалізації. Спростіть рефакторинг.

Але це правило не допомагає знаходити помилок, воно лише їх приховує.
Припустимо, що програма має шлях виконання, де вона використовує неініціалізовану змінну. Це клоп. Не визначена поведінка в стороні, це також означає, що щось пішло не так, і програма, ймовірно, не відповідає його вимогам до продукту. Коли він буде розгорнутий на виробництво, може статися грошова втрата або навіть гірше.

Як ми перевіряємо помилки? Ми пишемо тести. Але тести не охоплюють 100% шляхів виконання, а тести ніколи не охоплюють 100% програмних даних. Більше того, навіть тест охоплює несправний шлях виконання - він все ще може пройти. Адже це не визначена поведінка, неініціалізована змінна може мати дещо дійсне значення.

Але крім наших тестів, у нас є компілятори, які можуть написати щось на зразок 0xCDCDCDCD до неініціалізованих змінних. Це трохи покращує швидкість виявлення тестів.
Ще краще - існують такі інструменти, як Address Sanitizer, які будуть вловлювати всі прочитані неініціалізовані байти пам'яті.

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

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

int bytes_read = 0;
my_read(buffer, &bytes_read); // err_t my_read(buffer_t, int*);
// bytes_read is not changed on read error.
// It's a bug of "my_read", but detection is suppressed by initialization.
buffer.shrink(bytes_read); // Uninitialized bytes_read could be detected here.

// Another bug: use empty buffer after read error.
use(buffer);

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


10
Хоча я думаю, що це гарне питання, я не розумію вашого прикладу. Якщо виникає помилка читання, іbytes_read не змінюється (так дорівнює нулю), чому це має бути помилка? Програма все ще може продовжуватись розумно, доки вона не передбачає неявного bytes_read!=0згодом. Тож добре, дезінфікуючі засоби не скаржаться. З іншого боку, якщо bytes_readпрограма не буде ініціалізована заздалегідь, програма не зможе продовжуватись здоровим чином, тому не ініціалізація bytes_readфактично вводить помилку, якої раніше не було.
Док Браун

2
@Abyx: навіть якщо це третя сторона, якщо вона не має справу з буфером, починаючи з \0неї, це баггі. Якщо задокументовано, щоб не боротися з цим, ваш код виклику баггі. Якщо ви зафіксували код виклику, щоб перевірити його bytes_read==0перед використанням, то ви повернетесь до того, з чого почали: ваш код є помилковим, якщо ви не ініціалізуєтесь bytes_read, безпечний, якщо це зробити. ( Зазвичай функції повинні заповнювати свої вихідні параметри навіть у випадку помилки : не дуже. Дуже часто виходи залишаються в спокої або невизначені.)
Мат.

1
Чи є якась причина, що цей код ігнорує err_tповернене my_read()? Якщо в прикладі є помилка, це все.
Blrfl

1
Це легко: ініціалізуйте змінні лише у тому випадку, коли це має значення. Якщо це не так, не робіть. Я можу погодитися, хоча використання даних "фіктивних" це погано, оскільки це приховує помилки.
Пітер Б

1
"Є ще одне правило - якщо при виконанні програми зустрічається помилка, програма повинна загинути якнайшвидше. Не потрібно підтримувати її в живих, просто вийти з ладу, написати аварійний дамб, віддати інженерам для розслідування".: Спробуйте це під час польоту програмне забезпечення для управління. Удачі в оздоровленні аварійного сміття з уламків літака.
Джорджіо

Відповіді:


44

Ваші міркування помиляються в кількох записах:

  1. Порушення сегментації далеко не певні. Використання неініціалізованої змінної призводить до невизначеної поведінки . Недоліки сегментації - це один із способів прояву такої поведінки, але здається нормальним.
  2. Компілятори ніколи не заповнюють неініціалізовану пам'ять визначеним малюнком (наприклад, 0xCD). Це те, що деякі налагоджувачі роблять, щоб допомогти вам знайти місця, де неініціалізовані змінні звикають. Якщо ви запускаєте таку програму поза налагоджувачем, то змінна міститиме абсолютно випадковий сміття. Не менш ймовірно, що лічильник на зразок значення bytes_readмає таке значення, 10як і значення 0xcdcdcdcd.
  3. Навіть якщо ви працюєте в відладчику, який встановлює неініціалізовану пам'ять на фіксовану схему, вони роблять це лише при запуску. Це означає, що цей механізм надійно працює лише для статичних (і, можливо, виділених в купу) змінних. Для автоматичних змінних, які виділяються на стеку або живуть лише в регістрі, велика ймовірність, що змінна зберігається в місці, яке раніше використовувалося, тому шаблон пам'яті розповідачів уже був перезаписаний.

Ідея вказівок завжди ініціалізувати змінні полягає у включенні цих двох ситуацій

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

  2. Змінна містить визначене значення, яке ви можете протестувати пізніше, щоб визначити, чи функція на зразок my_readоновила значення. Без ініціалізації ви не можете визначити, чи bytes_readдійсно має дійсне значення, оскільки ви не можете знати, з якого значення воно почалося.


8
1) мова йде про ймовірності, наприклад, 1% проти 99%. 2 і 3) VC ++ генерує такий код ініціалізації, також для локальних змінних. 3) статичні (глобальні) змінні завжди ініціалізуються з 0.
Abyx

5
@Abyx: 1) На мій досвід, ймовірність становить ~ 80% "немає негайно очевидної поведінкової різниці", 10% "робить неправильну справу", 10% "segfault". Що стосується (2) та (3): VC ++ робить це лише у налагодженнях. Покладатися на це дуже страшна ідея, оскільки вона вибірково розбиває версії версій і не з’являється у багатьох тестах.
Крістіан Айхінгер

8
Я думаю, що «ідея, яка стоїть за керівництвом», є найважливішою частиною цієї відповіді. Ці вказівки абсолютно не говорять про те, щоб слідкувати за кожною змінною декларацією = 0;. Метою поради є оголосити змінну в тому місці, де ви будете мати корисне значення для неї, і негайно призначити це значення. Це чітко стає зрозумілим у наступних правилах ES21 та ES22. Ці три слід розуміти як спільну роботу; не як окремі непов'язані правила.
GrandOpener

1
@GrandOpener Саме так. Якщо в точці, де оголошена змінна, немає значущого значення, сфера змінної, ймовірно, помилкова.
Кевін Крумвіде

5
"Компілятори ніколи не заповнюють", чи не завжди це має бути ?
CodesInChaos

25

Ви писали: "це правило не допомагає знаходити помилок, воно лише їх приховує" - ну, мета правила - не допомогти у пошуку помилок, а уникнути їх. А коли уникнути помилок, нічого прихованого немає.

Давайте вирішуватимете питання на прикладі: припустимо, my_readфункція має письмовий договір про ініціалізацію bytes_readза будь-яких обставин, але це не відбувається у випадку помилки, тому вона несправна, принаймні, для цього випадку. Ваш намір полягає в тому, щоб використовувати середовище часу виконання, щоб показати цю помилку, не ініціалізуючи bytes_readпараметр спочатку. Поки ви точно знаєте, що на місці є дезінфікуюча адреса, це дійсно можливий спосіб виявити таку помилку. Щоб виправити помилку, потрібно змінитиmy_read внутрішньо функцію.

Але існує інша точка зору, яка, принаймні, однаково справедлива: несправна поведінка виникає лише з комбінації не ініціалізації bytes_readзаздалегідь, а виклику my_readпісля цього (з очікуванням bytes_readініціалізується після цього). Це ситуація, яка часто трапляється в компонентах реального світу, коли письмова специфікація для функції на зразок my_readне є на 100% зрозумілою, або навіть неправильною щодо поведінки у випадку помилки. Однак, поки bytes_readперед викликом ініціалізується до нуля, програма поводиться так само, як якщо б ініціалізація була зроблена всередині my_read, тому вона веде себе правильно, в цій комбінації помилок у програмі немає.

Тож моя рекомендація, яка випливає з цього, полягає в тому, щоб використовувати підхід, який не ініціалізується, лише якщо

  • ви хочете перевірити, чи функція або блок коду ініціалізує певний параметр
  • ви на 100% впевнені, що функція в пакеті має контракт, коли напевно неправильно не призначати значення цьому параметру
  • ви на 100% впевнені, що навколишнє середовище може це зловити

Це умови, які типово можна домовитись у тестовому коді для конкретного інструментального середовища.

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


4
Це саме те, що я думав, коли читав це. Це не підмітання речей під килим, це підмітання їх у смітник!
corsiKa

22

Завжди ініціалізуйте свої змінні

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

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

Гаразд, я зупинюсь на словах ін’єкції. Я думаю, ти зрозумієш справу ;-)

У цьому моделюванні було сотні неініціалізованих змінних. Один розробник запустив valgrind на моделювання і помітив, що існує кілька помилок "гілки на неініціалізовані значення". "Гм, це виглядає так, що може спричинити недетермінізм, що ускладнить повторення тестових прогонів, коли це нам найбільше потрібно". Розробник перейшов до менеджменту, але менеджмент був дуже щільним, і не міг заощадити ресурси, щоб знайти цю проблему. "Ми закінчуємо ініціалізацію всіх наших змінних, перш ніж їх використовувати. У нас є хороші методи кодування."

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

Весь колектив, можливо, був зупинений і витратив більшу частину 2 місяця, розчісуючи всю кодову базу імітації, виправляючи неініціалізовані помилки значення замість реалізації та тестування функцій. Потрібно сказати, що працівник пропустив "Я сказав вам так" і пішов прямо допомагати іншим розробникам зрозуміти, що таке неініціалізовані значення. Як не дивно, стандарти кодування були змінені незабаром після цього інциденту, спонукаючи розробників завжди ініціалізувати свої змінні.

І це попереджувальний постріл. Це куля, яка пасеться через ніс. Справжнє питання є набагато куди більш підступним, ніж ви навіть уявляєте.

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

Або може взагалі нічого не робити

Моя канарка у вугільній шахті для UB - це випадок із двигуна SQL, про який я читав. Пробачте, що не пов’язував це, я не зміг знайти статтю знову. Виникла проблема з перевищенням буфера в SQL-двигуні, коли ви передали більший розмір буфера функції, але тільки для певної версії Debian. Клоп старанно ввійшов у систему та дослідив. Найсмішніша частина була: перевірено перевиконання буфера . Був код для обробки перекриття буфера на місці. Це виглядало приблизно так:

// move the pointers properly to copy data into a ring buffer.
char* putIntoRingBuffer(char* begin, char* end, char* get, char*put, char* newData, unsigned int dataLength)
{
    // If dataLength is very large, we might overflow the pointer
    // arithmetic, and end up with some very small pointer number,
    // causing us to fail to realize we were trying to write past the
    // end.  Check this before we continue
    if (put + dataLength < put)
    {
        RaiseError("Buffer overflow risk detected");
        return 0;
    }
    ...
    // typical ring-buffer pointer manipulation followed...
}

Я додав більше коментарів у моїй передачі, але ідея така ж. Якщо put + dataLengthобернути навколо, він буде меншим, ніж putпокажчик (вони мали перевірити час складання, щоб переконатися, що неподписаний int був розміром вказівника, для допитливих). Якщо це трапиться, ми знаємо, що стандартні алгоритми буферного дзвінка можуть заплутатися в результаті цього переповнення, тому повертаємо 0. Або ми?

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

У разі цієї помилки, Debian була вибрати , щоб використовувати нову версію GCC , що жоден з інших основних смаків Linux не оновив в своїх виробничих випусках. Ця нова версія gcc мала більш агресивний оптимізатор мертвого коду. Компілятор побачив невизначену поведінку і вирішив, що результатом ifзаяви буде "все, що робить оптимізацію коду найкращою", що було абсолютно законним перекладом UB. Відповідно, було зроблено припущення, що оскільки ptr+dataLengthніколи не може бути нижче ptrбез переповнення вказівника UB, ifоператор ніколи не запустить, і оптимізував перевірку перевиконання буфера.

Використання "розумного" UB фактично призвело до того, що у великого продукту SQL було застосовано функцію перевиконання буфера, щоб уникнути написаного коду!

Ніколи не покладайтеся на невизначену поведінку. Колись.


Для дуже забавного читання про недефіційну поведінку, software.intel.com/en-us/blogs/2013/01/06/… - це дивовижно добре написаний пост про те, як погано це може піти. Однак саме ця посада стосується атомних операцій, які для більшості є дуже заплутаними, тому я уникаю того, щоб рекомендувати її як грунтовку для UB і як вона може піти не так.
Корт Аммон - Відновіть Моніку

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

@supercat Це було б непогано, якщо припустити, що ви орієнтуєтесь на платформи, де це правильне рішення. Одним із прикладів відомих питань є можливість створювати шаблони пам'яті, які не тільки недійсні для типу пам'яті, але неможливо досягти звичайними засобами. boolє прекрасним прикладом, коли є очевидні проблеми, але вони з’являються в іншому місці, якщо ви не припускаєте, що працюєте на дуже корисній платформі, як x86 або ARM або MIPS, де всі ці проблеми, як правило, вирішуються в час кодування.
Корт Аммон - Відновіть Моніку

Розглянемо випадок, коли оптимізатор може довести, що значення, яке використовується для a switch, менше 8, через розміри цілочисельної арифметики, щоб вони могли використовувати швидкі інструкції, які припускали, що немає ризику введення "великого" значення. Раптом з'являється не визначене значення (яке ніколи не можна побудувати за допомогою правил компілятора), що робить щось несподіване, і раптом у вас є масивний стрибок з кінця таблиці стрибків. Дозвіл невказаних результатів означає, що кожен вимикач програми повинен мати додаткові пастки для підтримки цих випадків, які "ніколи не трапляються".
Корт Аммон - Відновіть Моніку

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

5

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

Ці звички також можна перенести на загальнообов'язкові мови. Практично завжди можна перефактурувати свій код, щоб уникнути ініціалізації змінної з фіктивним значенням. Ось, що ці настанови говорять вам робити. Вони хочуть, щоб ви помістили щось значиме, а не те, що просто зробить автоматизовані інструменти щасливими.

Ваш приклад з API в стилі C трохи більш складний. У тих випадках, коли я використовую функцію, я ініціалізуюсь до нуля, щоб утримувач не скаржився, але один раз в my_readодиничних тестах я ініціалізую щось інше, щоб переконатися, що стан помилки працює належним чином. Вам не потрібно перевіряти кожен можливий стан помилок при кожному використанні.


5

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


1
І ініціалізація з -1 може бути насправді значущою. Там, де "int bytes_read = 0" погано, тому що ви насправді можете прочитати 0 байт, ініціалізація його з -1 дає зрозуміти, що спроба читання байтів не вдається, і ви можете перевірити це.
Пітер Б

4

TL; DR: Є два способи зробити цю програму правильною, ініціалізувати свої змінні та молитися. Лише один дає результати послідовно.


Перш ніж я зможу відповісти на ваше запитання, мені потрібно спочатку пояснити, що означає Невизначена поведінка . Насправді я дозволю автору-компілятору виконати основну частину роботи:

Якщо ви не бажаєте читати ці статті, TL; DR:

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

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

Однак правда полягає в тому, що Undefined Behavior впливає на саму компіляцію , задовго до того, як ви навіть спробуєте скористатися програмою (інструментально чи ні, у відладчику чи ні) і зможете повністю змінити її поведінку.

Я вважаю приклад у частині 2 вище яскравим:

void contains_null_check(int *P) {
  int dead = *P;
  if (P == 0)
    return;
  *P = 4;
}

перетворюється на:

void contains_null_check(int *P) {
  *P = 4;
}

тому що це очевидно, що цього Pне може бути, 0оскільки його відміняють перед тим, як перевірити.


Як це стосується вашого прикладу?

int bytes_read = 0;
my_read(buffer, &bytes_read); // err_t my_read(buffer_t, int*);
// bytes_read is not changed on read error.
// It's a bug of "my_read", but detection is suppressed by initialization.
buffer.shrink(bytes_read); // Uninitialized bytes_read could be detected here.

Що ж, ви зробили загальну помилку, вважаючи, що невизначена поведінка спричинить помилку під час виконання. Це не може.

Уявімо собі, що визначення my_read:

err_t my_read(buffer_t buffer, int* bytes_read) {
    err_t result = {};
    int blocks_read = 0;
    if (!(result = low_level_read(buffer, &blocks_read))) { return result; }
    *bytes_read = blocks_read * BLOCK_SIZE;
    return result;
}

і продовжуйте, як очікувалося, від хорошого компілятора з вбудованим:

int bytes_read; // UNINITIALIZED

// start inlining my_read

err_t result = {};
int blocks_read = 0;
if (!(result = low_level_read(buffer, &blocks_read))) {
    // nothing
} else {
    bytes_read = blocks_reads * BLOCK_SIZE;
}

// end of inlining my_read

buffer.shrink(bytes_read);

Тоді, як і очікувалося від хорошого компілятора, ми оптимізуємо непотрібні гілки:

  1. Жодна змінна не повинна використовуватися неініціалізованою
  2. bytes_readвикористовувались би неініціалізованими, якби resultне0
  3. Розробник обіцяє, що resultніколи не буде 0!

Так resultніколи 0:

int bytes_read; // UNINITIALIZED
err_t result = {};
int blocks_read = 0;
result = low_level_read(buffer, &blocks_read);

bytes_read = blocks_reads * BLOCK_SIZE;
buffer.shrink(bytes_read);

О, resultніколи не використовується:

int bytes_read; // UNINITIALIZED
int blocks_read = 0;
low_level_read(buffer, &blocks_read);

bytes_read = blocks_reads * BLOCK_SIZE;
buffer.shrink(bytes_read);

О, ми можемо відкласти декларацію про bytes_read:

int blocks_read = 0;
low_level_read(buffer, &blocks_read);

int bytes_read = blocks_reads * BLOCK_SIZE;
buffer.shrink(bytes_read);

І ось ми - суворо підтверджуюча трансформація оригіналу, і жоден налагоджувач не захопить неініціалізовану змінну, оскільки її немає.

Я пішов по цій дорозі, розуміючи проблему, коли очікувана поведінка та збори не відповідають, насправді не цікаво.


Іноді я думаю, що компілятори повинні отримати програму для видалення вихідних файлів, коли вони виконують шлях до UB. Потім програмісти дізнаються, що означає UB для свого кінцевого споживача ....
mattnz

1

Давайте докладніше розглянемо ваш приклад код:

int bytes_read = 0;
my_read(buffer, &bytes_read); // err_t my_read(buffer_t, int*);
// bytes_read is not changed on read error.
// It's a bug of "my_read", but detection is suppressed by initialization.
buffer.shrink(bytes_read); // Uninitialized bytes_read could be detected here.

// Another bug: use empty buffer after read error.
use(buffer);

Це хороший приклад. Якщо ми передбачаємо таку помилку, як ця, ми можемо вставити рядок assert(bytes_read > 0);і зафіксувати цю помилку під час виконання, що неможливо з неініціалізованою змінною.

Але припустимо, що ми цього не робимо, і ми виявляємо помилку всередині функції use(buffer). Ми завантажуємо програму в відладчик, перевіряємо зворотній слід і з'ясовуємо, що вона викликана з цього коду. Тому ми ставимо точку перерви у верхній частині цього фрагмента, запускаємо ще раз і відтворюємо помилку. Ми робимо один крок, намагаючись зловити його.

Якщо ми не ініціалізували bytes_read, він містить сміття. Це не обов'язково містить одне і те ж сміття кожного разу. Ми проходимо повз лінію my_read(buffer, &bytes_read);. Тепер, якщо це значення має інше значення, ніж раніше, ми можемо не вдасться відтворити нашу помилку! Це може спрацювати наступного разу, за тим же самим входом, при повній аварії. Якщо вона постійно дорівнює нулю, ми отримуємо послідовну поведінку.

Ми перевіряємо значення, можливо, навіть на відстані у тому ж циклі. Якщо він дорівнює нулю, ми можемо побачити, що щось не так; bytes_readне повинні бути нульовими на успіх. (Або, якщо це може бути, ми можемо захотіти ініціалізувати його до -1.) Тут ми, мабуть, можемо зловити помилку. Якщо bytes_readправдоподібне значення, однак, що це просто трапиться неправильно, ми би помітили це з першого погляду?

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


1

ОП не покладається на невизначену поведінку чи, принаймні, не зовсім. Дійсно, покладатися на невизначену поведінку погано. У той же час поведінка програми в несподіваному випадку також не визначена, але іншого виду не визначена. Якщо ви встановите змінну до нуля, але ви не мали наміру мати шлях виконання, який використовує цей початковий нуль, чи буде ваша програма поводитися нормально, коли у вас є помилка і чи є такий шлях? Ти зараз у бур'яні; ви не планували використовувати це значення, але все одно ви його використовуєте. Можливо, це буде нешкідливим, а може призведе до збою програми, а може призведе до того, що програма мовчки пошкодить дані. Ви не знаєте.

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

Єдине місце, з яким я не погоджуюсь з ОП, - це саме той кінець, де він каже: "коли б інакше була б помилка сегментації в іншому випадку". Дійсно, неініціалізована змінна не призведе до надійної помилки сегментації. Натомість я б сказав, що вам слід використовувати засоби статичного аналізу, які не дозволять вам дійти навіть до спроби виконати програму.


0

Відповідь на ваше запитання потрібно розділити на різні типи змінних, що з’являються всередині програми:


Локальні змінні

Зазвичай декларація повинна бути прямо на місці, де змінна вперше отримує своє значення. Не визначайте змінні, як у старому стилі C:

//Bad: predeclared variables
int foo = 0;
double bar = 0.0;
long* baz = NULL;

bar = getBar();
foo = (int)bar;
baz = malloc(foo);


//Correct: declaration and initialization at the same place
double bar = getBar();
int foo = (int)bar;
long* baz = malloc(foo);

Це знімає 99% потреби в ініціалізації, змінні мають остаточне значення прямо з вимкнення. Кілька винятків - ініціалізація залежить від певної умови:

Base* ptr;
if(foo()) {
    ptr = new Derived1();
} else {
    ptr = new Derived2();
}

Я вважаю, що добре написати такі випадки так:

Base* ptr = nullptr;
if(foo()) {
    ptr = new Derived1();
} else {
    ptr = new Derived2();
}
assert(ptr);

І. е. явно стверджують, що виконується деяка розумна ініціалізація вашої змінної.


Змінні учасника

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


Буфери

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

char buffer[30];
memset(buffer, 0, sizeof(buffer));

char* buffer2 = calloc(30);

Я вважаю, що це майже завжди є корисним: Єдиний ефект цих ініціалізацій полягає в тому, що вони роблять інструменти на зразок valgrindбезсилими. Будь-який код, який читає більше з ініціалізованих буферів, ніж слід, дуже ймовірно помилка. Але при ініціалізації цей помилка не може бути виявлена valgrind. Тому не використовуйте їх, якщо ви по-справжньому не покладаєтесь на те, що пам'ять заповнена нулями (і в такому випадку залиште коментар, в якому сказано, для чого потрібні нулі).

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


Глобальні змінні

У вас не може бути глобальних змінних, які не ініціалізовані (принаймні, на C / C ++ тощо), тому переконайтеся, що ця ініціалізація потрібна.


Зауважте, що ви можете писати умовні ініціалізації з Base& b = foo() ? new Derived1 : new Derived2;
термінальним

@Lorehead Це може працювати для простих випадків, але це не буде працювати і для більш складних: Ви не хочете робити цього, якщо у вас є три і більше випадків, а ваші конструктори беруть три або більше аргументів, просто для читабельності причини. І це навіть не враховує жодних обчислень, які можуть знадобитися зробити, як пошук аргументу для однієї гілки ініціалізації в циклі.
cmaster

Для більш складних випадків, можна обернути код ініціалізації функції фабричної: Base &b = base_factory(which);. Це найкорисніше, якщо вам потрібно дзвонити код не раз або якщо він дозволяє зробити результат постійним.
Девіслор

@Lorehead Це правда, і, звичайно, шлях, якщо потрібна логіка не проста. Тим не менш, я вважаю, що між ними є невелика сіра зона, де ініціалізація через ?:- PITA, а фабрична функція все ще є надмірною. Таких випадків мало і далеко між ними, але вони існують.
cmaster

-2

Пристойний компілятор C, C ++ або Objective-C з набором правильних параметрів компілятора підкаже вам під час компіляції, якщо використовується змінна до встановлення її значення. Оскільки в цих мовах використання значення неініціалізованої змінної є невизначеною поведінкою, "встановити значення перед використанням" не є підказом, не керівництвом, ні належною практикою, це 100% вимога; інакше ваша програма абсолютно зламана. В інших мовах, таких як Java та Swift, компілятор ніколи не дозволить вам використовувати змінну до її ініціалізації.

Існує логічна різниця між "ініціалізацією" та "встановленням значення". Якщо я хочу знайти коефіцієнт конверсії між доларами та євро, і написати "подвійний курс = 0,0;" то змінна має набір значень, але вона не ініціалізується. Збережений тут 0,0 не має нічого спільного з правильним результатом. У цій ситуації, якщо через помилку ви ніколи не зберігаєте правильний коефіцієнт конверсії, у компілятора немає шансу сказати вам. Якщо ви тільки що написали "подвійну ставку"; і ніколи не зберігав значущий коефіцієнт конверсії, скаже вам компілятор.

Отже: Не ініціалізуйте змінну лише тому, що компілятор каже вам, що вона використовується без ініціалізації. Це приховує помилку. Справжня проблема полягає в тому, що ви використовуєте змінну, яку ви не повинні використовувати, або що для одного кодового шляху ви не встановили значення. Виправте проблему, не приховуйте її.

Не ініціалізуйте змінну лише тому, що компілятор може сказати вам, що вона використовується без ініціалізації. Знову ж таки, ви приховуєте проблеми.

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

Уникайте повторного використання змінних. Коли ви повторно використовуєте змінну, вона, швидше за все, ініціалізується на марне значення, коли ви використовуєте її з другою метою.

Було зауважено, що деякі компілятори мають помилкові негативи та перевірка на ініціалізацію еквівалентна проблемі зупинки. Обидва на практиці не мають значення. Якщо компілятор, як цитується, не може знайти використання неініціалізованої змінної через десять років після повідомлення про помилку, настав час шукати альтернативного компілятора. Java реалізує це двічі; один раз у компіляторі, один раз у верифікаторі, без проблем. Найпростіший спосіб вирішити проблему зупинки - це не вимагати, щоб змінна ініціалізувалася перед використанням, а щоб вона була ініціалізована перед використанням способом, який можна перевірити простим і швидким алгоритмом.


Це звучить поверхово добре, але занадто багато покладається на точність попереджень неініціалізованих значень. Отримати цілком правильні цілі еквівалентно проблемі зупинки, і компілятори виробництва можуть і зазнавати помилкових негативів (тобто вони не діагностують неініціалізовану змінну, коли вони повинні мати); див., наприклад, помилку GCC 18501 , яка не виправлена вже більше десяти років.
zwol

Що ви говорите про gcc - це просто сказане. Решта не має значення.
gnasher729

Це сумно з приводу gcc, але якщо ви не розумієте, чому відпочинок доречний, тоді вам потрібно навчитись.
zwol
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.