Деякі з "практичних" (смішний спосіб написання "баггі") коду, який було порушено, виглядали приблизно так:
void foo(X* p) {
p->bar()->baz();
}
і він забув врахувати той факт, що p->bar()
іноді повертає нульовий покажчик, а це означає, що перенаправлення його на виклик baz()
не визначене.
Не весь код, який було порушено, містив явні if (this == nullptr)
чи if (!p) return;
перевірки. Деякі випадки були просто функціями, які не мали доступу до змінних будь-яких членів, і тому, здається, працювали нормально. Наприклад:
struct DummyImpl {
bool valid() const { return false; }
int m_data;
};
struct RealImpl {
bool valid() const { return m_valid; }
bool m_valid;
int m_data;
};
template<typename T>
void do_something_else(T* p) {
if (p) {
use(p->m_data);
}
}
template<typename T>
void func(T* p) {
if (p->valid())
do_something(p);
else
do_something_else(p);
}
У цьому коді при виклику func<DummyImpl*>(DummyImpl*)
з нульовим вказівником є "концептуальна" відмова вказівника на виклик p->DummyImpl::valid()
, але насправді функція члена просто повертається false
без доступу *this
. Це return false
може бути накреслено, тому на практиці вказівник зовсім не потребує доступу. Так що, з деякими компіляторами, здається, це працює добре: немає ніякого сегмента за відміною нуля, p->valid()
помилково, тому код викликає do_something_else(p)
, який перевіряє нульові покажчики, і так нічого не робить. Жодних збоїв чи несподіваної поведінки не спостерігається.
За допомогою GCC 6 ви все ще отримуєте дзвінок p->valid()
, але компілятор тепер випливає з цього виразу, який p
повинен бути ненульовим (інакше p->valid()
було б невизначене поведінка) і робить помітку цієї інформації. Отримана інформація використовується оптимізатором, так що якщо виклик do_something_else(p)
стає вбудованим, if (p)
перевірка тепер вважається зайвою, оскільки компілятор пам'ятає, що вона не є нульовою, і таким чином вводить код на:
template<typename T>
void func(T* p) {
if (p->valid())
do_something(p);
else {
// inlined body of do_something_else(p) with value propagation
// optimization performed to remove null check.
use(p->m_data);
}
}
Це тепер дійсно робить перенаправлення нульового вказівника, і тому код, який раніше з'явився на роботі, перестає працювати.
У цьому прикладі виявлена помилка func
, яка повинна була спочатку перевірити нуль (або абоненти ніколи не повинні називати це null):
template<typename T>
void func(T* p) {
if (p && p->valid())
do_something(p);
else
do_something_else(p);
}
Важливим моментом, який слід пам’ятати, є те, що більшість подібних оптимізацій не стосуються компілятора, який сказав: «Ах, програміст перевірив цей покажчик на нуль, я видалю його просто, щоб роздратувати». Що трапляється, це те, що різні оптимізації, що виконуються на млині, такі як розширення діапазону та поширення діапазону значень, поєднуються, щоб зробити ці чеки зайвими, оскільки вони приходять після попередньої перевірки або відміни. Якщо компілятор знає, що покажчик не є нульовим у точці A у функції, а вказівник не змінюється перед пізнішою точкою B у тій же функції, то він знає, що він також є недійсним у B. Коли вбудоване відбувається точки A і B можуть бути фактично фрагментами коду, які спочатку були в окремих функціях, але тепер об'єднуються в один фрагмент коду, і компілятор може застосувати свої знання про те, що вказівник не є нульовим у більшості місць.