Une partie du code "pratique" (façon amusante d'épeler "buggy") qui a été cassé ressemblait à ceci:
void foo(X* p) {
p->bar()->baz();
}
et il a oublié de tenir compte du fait que p->bar()
parfois renvoie un pointeur nul, ce qui signifie que le déréférencer pour appeler baz()
n'est pas défini.
Pas tout le code qui a été brisé contenu explicite if (this == nullptr)
ou les if (!p) return;
chèques. Certains cas étaient simplement des fonctions qui n'accédaient à aucune variable membre, et qui semblaient donc fonctionner correctement. Par exemple:
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);
}
Dans ce code, lorsque vous appelez func<DummyImpl*>(DummyImpl*)
avec un pointeur nul, il y a un déréférencement "conceptuel" du pointeur à appeler p->DummyImpl::valid()
, mais en fait, cette fonction membre retourne simplement false
sans accéder *this
. Cela return false
peut être intégré et donc, dans la pratique, le pointeur n'a pas du tout besoin d'être accédé. Donc, avec certains compilateurs, cela semble fonctionner correctement: il n'y a pas de segfault pour déréférencer null, p->valid()
est faux, donc le code appelle do_something_else(p)
, qui vérifie les pointeurs nuls, et ne fait donc rien. Aucun crash ou comportement inattendu n'est observé.
Avec GCC 6, vous obtenez toujours l'appel à p->valid()
, mais le compilateur déduit maintenant de cette expression qui p
doit être non-null (sinon ce p->valid()
serait un comportement non défini) et prend note de cette information. Ces informations déduites sont utilisées par l'optimiseur de sorte que si l'appel à do_something_else(p)
est incorporé, la if (p)
vérification est maintenant considérée comme redondante, car le compilateur se souvient qu'elle n'est pas nulle, et donc intègre le code à:
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);
}
}
Cela fait maintenant vraiment déréférencer un pointeur nul, et donc le code qui semblait auparavant fonctionner cesse de fonctionner.
Dans cet exemple, le bogue est présent func
, qui aurait dû d'abord vérifier null (ou les appelants n'auraient jamais dû l'appeler avec null):
template<typename T>
void func(T* p) {
if (p && p->valid())
do_something(p);
else
do_something_else(p);
}
Un point important à retenir est que la plupart des optimisations comme celle-ci ne sont pas un cas où le compilateur dit "ah, le programmeur a testé ce pointeur contre null, je vais le supprimer juste pour être ennuyeux". Ce qui se passe, c'est que diverses optimisations courantes telles que l'inlining et la propagation de la plage de valeurs se combinent pour rendre ces vérifications redondantes, car elles surviennent après une vérification antérieure ou une déréférence. Si le compilateur sait qu'un pointeur est non nul au point A dans une fonction et que le pointeur n'est pas modifié avant un point B ultérieur dans la même fonction, alors il sait qu'il est également non nul en B.Lorsque l'inlining se produit les points A et B peuvent en fait être des morceaux de code qui étaient à l'origine dans des fonctions séparées, mais qui sont maintenant combinés en un seul morceau de code, et le compilateur est capable d'appliquer sa connaissance que le pointeur est non nul à plusieurs endroits.