Alguns dos códigos "práticos" (maneira engraçada de soletrar "buggy") que foram quebrados eram assim:
void foo(X* p) {
p->bar()->baz();
}
e esqueceu de explicar o fato de que p->bar()
algumas vezes retorna um ponteiro nulo, o que significa que a desreferenciação para chamar baz()
é indefinida.
Nem todo o código que foi quebrado continha explicações if (this == nullptr)
ou if (!p) return;
verificações. Alguns casos eram simplesmente funções que não acessavam nenhuma variável membro e, portanto, pareciam funcionar bem. Por exemplo:
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);
}
Nesse código, quando você chama func<DummyImpl*>(DummyImpl*)
com um ponteiro nulo, há uma desreferência "conceitual" do ponteiro para chamar p->DummyImpl::valid()
, mas, na verdade, essa função de membro retorna false
sem acessar *this
. Isso return false
pode ser incorporado e, portanto, na prática, o ponteiro não precisa ser acessado. Portanto, com alguns compiladores, parece funcionar bem: não há segfault para remover a referência nula, p->valid()
é falso, então o código chama do_something_else(p)
, que verifica se há ponteiros nulos, e não faz nada. Nenhuma falha ou comportamento inesperado é observado.
Com o GCC 6, você ainda recebe a chamada p->valid()
, mas o compilador deduz agora a expressão que p
deve ser não nula (caso contrário, p->valid()
seria um comportamento indefinido) e anota essas informações. Essas informações inferidas são usadas pelo otimizador para que, se a chamada do_something_else(p)
for inline, a if (p)
verificação agora for considerada redundante, porque o compilador lembra que não é nulo e, portanto, alinha o 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);
}
}
Isso agora desreferencia um ponteiro nulo e, portanto, o código que parecia funcionar anteriormente para de funcionar.
Neste exemplo, está o erro func
, que deveria ter verificado primeiro como nulo (ou os chamadores nunca deveriam tê-lo chamado com nulo):
template<typename T>
void func(T* p) {
if (p && p->valid())
do_something(p);
else
do_something_else(p);
}
Um ponto importante a ser lembrado é que a maioria das otimizações como essa não é o caso do compilador dizendo "ah, o programador testou esse ponteiro contra nulo, vou removê-lo apenas por ser irritante". O que acontece é que várias otimizações comuns, como inlining e propagação da faixa de valores, se combinam para tornar essas verificações redundantes, porque elas vêm após uma verificação anterior ou uma desreferência. Se o compilador souber que um ponteiro não é nulo no ponto A de uma função, e o ponteiro não é alterado antes de um ponto posterior B na mesma função, ele sabe que também é não nulo em B. Quando ocorre o inlining os pontos A e B podem realmente ser trechos de código que estavam originalmente em funções separadas, mas agora são combinados em um trecho de código, e o compilador pode aplicar seu conhecimento de que o ponteiro não é nulo em mais lugares.