Algunos de los códigos "prácticos" (forma divertida de deletrear "errores") que se rompieron se veían así:
void foo(X* p) {
p->bar()->baz();
}
y se olvidó de tener en cuenta el hecho de que a p->bar()
veces devuelve un puntero nulo, lo que significa que desreferenciarlo para llamar baz()
no está definido.
No todo el código que se rompió contenía explícitos if (this == nullptr)
o if (!p) return;
verificaciones. Algunos casos eran simplemente funciones que no tenían acceso a ninguna variable miembro y, por lo tanto, parecían funcionar bien. Por ejemplo:
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);
}
En este código, cuando llama func<DummyImpl*>(DummyImpl*)
con un puntero nulo, existe una desreferencia "conceptual" del puntero a llamar p->DummyImpl::valid()
, pero de hecho esa función miembro simplemente regresa false
sin acceder *this
. Eso return false
puede estar en línea y, en la práctica, no es necesario acceder al puntero. Entonces, con algunos compiladores parece funcionar bien: no hay segfault para desreferenciar nulo, p->valid()
es falso, por lo que el código llama do_something_else(p)
, que comprueba los punteros nulos, y no hace nada. No se observan accidentes ni comportamientos inesperados.
Con GCC 6 todavía recibe la llamada p->valid()
, pero el compilador ahora deduce de esa expresión que no p
debe ser nula (de lo contrario p->valid()
sería un comportamiento indefinido) y toma nota de esa información. El optimizador utiliza esa información inferida, de modo que si la llamada a do_something_else(p)
se inserta, la if (p)
verificación ahora se considera redundante, porque el compilador recuerda que no es nula, y así alinea el código para:
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);
}
}
Esto ahora realmente hace referencia a un puntero nulo, por lo que el código que antes parecía funcionar deja de funcionar.
En este ejemplo, el error está en func
, que debería haber verificado primero nulo (o los llamadores nunca deberían haberlo llamado con nulo):
template<typename T>
void func(T* p) {
if (p && p->valid())
do_something(p);
else
do_something_else(p);
}
Un punto importante para recordar es que la mayoría de las optimizaciones como esta no son el caso del compilador que dice "ah, el programador probó este puntero contra nulo, lo eliminaré solo para ser molesto". Lo que sucede es que varias optimizaciones de rutina como la línea y la propagación del rango de valores se combinan para hacer que esas verificaciones sean redundantes, ya que vienen después de una verificación anterior, o una desreferencia. Si el compilador sabe que un puntero no es nulo en el punto A en una función, y el puntero no se cambia antes de un punto B posterior en la misma función, entonces sabe que también es no nulo en B. Cuando ocurre la alineación los puntos A y B en realidad podrían ser piezas de código que originalmente estaban en funciones separadas, pero ahora se combinan en una sola pieza de código, y el compilador puede aplicar su conocimiento de que el puntero no es nulo en más lugares.