Einige der "praktischen" (lustige Art, "Buggy" zu buchstabieren) Codes, die kaputt waren, sahen folgendermaßen aus:
void foo(X* p) {
p->bar()->baz();
}
und es wurde vergessen, die Tatsache zu berücksichtigen, dass p->bar()
manchmal ein Nullzeiger zurückgegeben wird, was bedeutet, dass die Dereferenzierung zum Aufruf baz()
undefiniert ist.
Nicht der gesamte fehlerhafte Code enthielt explizite if (this == nullptr)
oder if (!p) return;
Überprüfungen. Einige Fälle waren einfach Funktionen, die nicht auf Mitgliedsvariablen zugegriffen haben und daher in Ordnung zu sein schienen . Beispielsweise:
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);
}
In diesem Code gibt es beim Aufrufen func<DummyImpl*>(DummyImpl*)
mit einem Nullzeiger eine "konzeptionelle" Dereferenzierung des aufzurufenden Zeigers p->DummyImpl::valid()
, aber tatsächlich kehrt die false
Elementfunktion nur ohne Zugriff zurück *this
. Das return false
kann inline sein und so muss in der Praxis überhaupt nicht auf den Zeiger zugegriffen werden. Bei einigen Compilern scheint es also in Ordnung zu sein: Es gibt keinen Segfault für die Dereferenzierung von Null, p->valid()
ist falsch, daher ruft der Code auf do_something_else(p)
, der nach Nullzeigern sucht, und tut nichts. Es wird kein Absturz oder unerwartetes Verhalten beobachtet.
Mit GCC 6 erhalten Sie immer noch den Aufruf von p->valid()
, aber der Compiler leitet jetzt aus diesem Ausdruck ab, p
der nicht null sein darf (andernfalls p->valid()
wäre dies ein undefiniertes Verhalten), und notiert diese Informationen. Diese abgeleiteten Informationen werden vom Optimierer verwendet, sodass do_something_else(p)
die if (p)
Prüfung jetzt als redundant betrachtet wird , wenn der Aufruf von inline ausgeführt wird , da der Compiler sich daran erinnert, dass sie nicht null ist, und den Code daher wie folgt einfügt :
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);
}
}
Dies dereferenziert jetzt wirklich einen Nullzeiger, und so funktioniert Code, der zuvor zu funktionieren schien, nicht mehr.
In diesem Beispiel befindet sich der Fehler func
, der zuerst auf null hätte prüfen sollen (oder die Anrufer hätten ihn niemals mit null aufrufen sollen):
template<typename T>
void func(T* p) {
if (p && p->valid())
do_something(p);
else
do_something_else(p);
}
Ein wichtiger Punkt, an den Sie sich erinnern sollten, ist, dass die meisten Optimierungen wie diese nicht vom Compiler stammen, der sagt: "Ah, der Programmierer hat diesen Zeiger gegen Null getestet, ich werde ihn entfernen, nur um ärgerlich zu sein." Was passiert, ist, dass verschiedene Standardoptimierungen wie Inlining und Wertebereichsausbreitung zusammen diese Überprüfungen überflüssig machen, da sie nach einer früheren Überprüfung oder einer Dereferenzierung erfolgen. Wenn der Compiler weiß, dass ein Zeiger an Punkt A in einer Funktion nicht null ist und der Zeiger nicht vor einem späteren Punkt B in derselben Funktion geändert wird, weiß er, dass er auch an B nicht null ist. Wenn Inlining auftritt Die Punkte A und B können tatsächlich Codeteile sein, die sich ursprünglich in separaten Funktionen befanden, jetzt aber zu einem Codeteil zusammengefasst sind, und der Compiler kann sein Wissen anwenden, dass der Zeiger an mehreren Stellen nicht null ist.