Niektóre z „praktycznych” (zabawnych zapisów „błędny”) kod, który został uszkodzony, wyglądały następująco:
void foo(X* p) {
p->bar()->baz();
}
i zapomniał wziąć pod uwagę fakt, że p->bar()
czasami zwraca pusty wskaźnik, co oznacza, że wyłuskiwanie odwołania do wywołania baz()
jest niezdefiniowane.
Nie cały uszkodzony kod zawierał jawne if (this == nullptr)
lub if (!p) return;
kontrole. Niektóre przypadki były po prostu funkcjami, które nie miały dostępu do żadnych zmiennych składowych, więc wydawało się, że działają poprawnie. Na przykład:
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);
}
W tym kodzie, gdy wywołujesz func<DummyImpl*>(DummyImpl*)
ze wskaźnikiem zerowym, występuje „koncepcyjne” wyłuskiwanie wskaźnika do wywołania p->DummyImpl::valid()
, ale w rzeczywistości funkcja składowa po prostu zwraca false
bez dostępu *this
. To return false
może być wbudowane, więc w praktyce nie ma potrzeby uzyskiwania dostępu do wskaźnika. Tak więc z niektórymi kompilatorami wydaje się, że działa OK: nie ma segfaulta dla wyłuskiwania null, p->valid()
jest fałszem, więc kod wywołuje do_something_else(p)
, który sprawdza puste wskaźniki, więc nic nie robi. Nie zaobserwowano awarii ani nieoczekiwanego zachowania.
W GCC 6 nadal otrzymujesz wywołanie p->valid()
, ale teraz kompilator wnioskuje z tego wyrażenia, które p
musi być niezerowe (w przeciwnym razie p->valid()
byłoby niezdefiniowane zachowanie) i odnotowuje te informacje. Wywnioskować, że informacje te są wykorzystywane przez optymalizator tak, że jeśli wywołanie do_something_else(p)
zostanie inlined The if (p)
wyboru jest obecnie uważany za zbędne, ponieważ kompilator pamięta, że nie jest zerowa, a więc inlines kod do:
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);
}
}
To teraz naprawdę wyłuskuje pusty wskaźnik, więc kod, który wcześniej wydawał się działać, przestaje działać.
W tym przykładzie występuje błąd func
, który powinien był najpierw sprawdzić, czy nie ma null (lub wywołujący nie powinni byli nigdy wywołać go z null):
template<typename T>
void func(T* p) {
if (p && p->valid())
do_something(p);
else
do_something_else(p);
}
Ważną kwestią do zapamiętania jest to, że większość optymalizacji tego typu nie dotyczy kompilatora mówiącego „ach, programista przetestował ten wskaźnik pod kątem wartości null, usunę go tylko po to, aby był irytujący”. Dzieje się tak, że różne typowe optymalizacje, takie jak inlining i propagacja zakresu wartości, łączą się, aby te sprawdzenia były zbędne, ponieważ pojawiają się po wcześniejszym sprawdzeniu lub dereferencji. Jeśli kompilator wie, że wskaźnik jest różny od null w punkcie A w funkcji, a wskaźnik nie jest zmieniany przed późniejszym punktem B w tej samej funkcji, to wie, że w punkcie B również nie jest zerowy. punkty A i B mogą w rzeczywistości być fragmentami kodu, które pierwotnie znajdowały się w osobnych funkcjach, ale teraz są połączone w jeden fragment kodu, a kompilator może zastosować swoją wiedzę, że wskaźnik nie jest zerowy w wielu miejscach.