Некоторые из «практических» (забавный способ записать «глючный») код, который был сломан, выглядели так:
void foo(X* p) {
p->bar()->baz();
}
и он забыл учитывать тот факт, что p->bar()
иногда возвращает нулевой указатель, что означает, что разыменование его для вызова baz()
не определено.
Не весь взломанный код содержал явные if (this == nullptr)
или if (!p) return;
проверки. Некоторые случаи были просто функциями , которые не получить доступ к любым переменным - членам, и так появились на работу OK. Например:
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
может быть встроено, и поэтому на практике указатель вообще не нужен. Таким образом, с некоторыми компиляторами все работает нормально: нет никакого segfault для разыменования null, значение p->valid()
false, поэтому код вызывает do_something_else(p)
, который проверяет нулевые указатели, и поэтому ничего не делает. Никаких сбоев или неожиданного поведения не наблюдается.
С GCC 6 вы по-прежнему получаете вызов p->valid()
, но компилятор теперь выводит из этого выражения, что оно p
должно быть ненулевым (в противном случае p->valid()
было бы неопределенное поведение), и запоминает эту информацию. Эта выведенная информация используется оптимизатором, так что если вызов to 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
, которая должна была сначала проверяться на ноль (или вызывающие никогда не должны вызывать ее с нулем):
template<typename T>
void func(T* p) {
if (p && p->valid())
do_something(p);
else
do_something_else(p);
}
Важно помнить, что большинство подобных оптимизаций не являются случаем того, что компилятор говорит: «ах, программист проверил этот указатель на ноль, я уберу его просто для раздражения». То, что происходит, - это то, что различные обычные оптимизации, такие как встраивание и распространение диапазона значений, объединяются, чтобы сделать эти проверки избыточными, потому что они идут после более ранней проверки или разыменования. Если компилятор знает, что указатель является ненулевым в точке A в функции, и указатель не изменяется до более поздней точки B в той же функции, то он знает, что он также ненулевой в B. Когда происходит встраивание точки A и B могут фактически быть фрагментами кода, которые изначально были в отдельных функциях, но теперь объединены в один фрагмент кода, и компилятор может применить свои знания о том, что указатель является ненулевым в большем количестве мест.