Por que o otimizador aprimorado do GCC 6 quebra o código C ++ prático?

O GCC 6 tem um novo recurso de otimizador : ele pressupõe que this não é sempre nulo e otimiza com base nisso.

Propagação de intervalo de valor agora assume que o ponteiro this de funções de membro do C ++ é não-nulo. Isso elimina checagens de pointers nulos comuns, mas também quebra algumas bases de código que não estão em conformidade (como Qt-5, Chromium, KDevelop) . Como um trabalho temporário em torno de -fno-delete-null-pointer-checks pode ser usado. Código errado pode ser identificado usando -fsanitize = undefined.

O documento de alteração chama isso claramente como perigoso porque quebra uma quantidade surpreendente de código usado com frequência.

Por que essa nova suposição iria quebrar o código C ++ prático? Existem padrões particulares em que programadores descuidados ou desinformados confiam nesse comportamento indefinido em particular? Eu não posso imaginar alguém escrevendo if (this == NULL) porque isso é tão pouco natural.

Eu acho que a pergunta que precisa ser respondida é por que pessoas bem-intencionadas escrevem os cheques em primeiro lugar.

O caso mais comum é provavelmente se você tem uma class que faz parte de uma chamada recursiva que ocorre naturalmente.

Se você tinha:

 struct Node { Node* left; Node* right; }; 

em C, você pode escrever:

 void traverse_in_order(Node* n) { if(!n) return; traverse_in_order(n->left); process(n); traverse_in_order(n->right); } 

Em C ++, é bom fazer disso uma function de membro:

 void Node::traverse_in_order() { // <--- What check should be put here? left->traverse_in_order(); process(); right->traverse_in_order(); } 

Nos primórdios do C ++ (antes da padronização), foi enfatizado que as funções de membro eram açúcar sintático para uma function onde o parâmetro this é implícito. Código foi escrito em C ++, convertido em equivalente C e compilado. Havia até mesmo exemplos explícitos que comparar this com null era significativo e o compilador Cfront original também se aproveitava disso. Então, vindo de um background em C, a escolha óbvia para o cheque é:

 if(this == nullptr) return; 

Nota: Bjarne Stroustrup até menciona que as regras para this mudaram ao longo dos anos aqui

E isso funcionou em muitos compiladores por muitos anos. Quando a padronização aconteceu, isso mudou. E, mais recentemente, os compiladores começaram a aproveitar a chamada de uma function de membro, em que this sendo nullptr é o comportamento indefinido, o que significa que essa condição é sempre false e o compilador está livre para omiti-la.

Isso significa que, para fazer qualquer travessia dessa tree, você precisa:

  • Faça todas as verificações antes de chamar traverse_in_order

     void Node::traverse_in_order() { if(left) left->traverse_in_order(); process(); if(right) right->traverse_in_order(); } 

    Isso significa também verificar em TODOS os sites de chamadas se você puder ter uma raiz nula.

  • Não use uma function de membro

    Isso significa que você está escrevendo o código de estilo C antigo (talvez como um método estático) e chamando-o explicitamente com o object como um parâmetro. por exemplo. você está de volta a escrever Node::traverse_in_order(node); em vez de node->traverse_in_order(); no site de chamadas.

  • Acredito que a maneira mais fácil / correta de corrigir esse exemplo específico de maneira compatível com os padrões é realmente usar um nó sentinela em vez de um nullptr .

     // static class, or global variable Node sentinel; void Node::traverse_in_order() { if(this == &sentinel) return; ... } 

Nenhuma das duas primeiras opções parece tão atraente, e embora o código pudesse escaping, elas escreveram código ruim com this == nullptr invés de usar uma correção apropriada.

Eu estou supondo que é como algumas dessas bases de código evoluíram para ter this == nullptr verifica nelas.

Isso acontece porque o código “prático” foi quebrado e envolveu um comportamento indefinido para começar. Não há razão para usar um nulo, a não ser como uma micro-otimização, geralmente muito prematura.

É uma prática perigosa, já que o ajuste de pointers devido à travessia da hierarquia de classs pode transformar um valor nulo em um não-nulo. Então, no mínimo, a class cujos methods devem funcionar com um nulo, deve ser uma class final sem class base: ela não pode derivar de nada, e não pode ser derivada dela. Estamos rapidamente nos afastando da prática para a terra feia .

Em termos práticos, o código não precisa ser feio:

 struct Node { Node* left; Node* right; void process(); void traverse_in_order() { traverse_in_order_impl(this); } private: static void traverse_in_order_impl(Node * n) if (!n) return; traverse_in_order_impl(n->left); n->process(); traverse_in_order_impl(n->right); } }; 

Se você tivesse uma tree vazia (por exemplo, root é nullptr), esta solução ainda está contando com comportamento indefinido chamando traverse_in_order com um nullptr.

Se a tree estiver vazia, também conhecida como Node* root nula, você não deve chamar nenhum método não estático nela. Período. É perfeitamente correto ter um código de tree semelhante a C que use um ponteiro de instância por um parâmetro explícito.

O argumento aqui parece resumir-se à necessidade de escrever methods não-estáticos em objects que poderiam ser chamados de um ponteiro de instância nula. Não há essa necessidade. A maneira C-with-objects de escrever tal código ainda é muito mais agradável no mundo C ++, porque pode ser do tipo seguro no mínimo. Basicamente, o nulo this é uma micro-otimização, com tal campo estreito de uso, que não permitindo que seja IMHO perfeitamente bem. Nenhuma API pública deve depender de um nulo this .

O documento de alteração chama isso claramente como perigoso porque quebra uma quantidade surpreendente de código usado com frequência.

O documento não chama isso de perigoso. Nem afirma que quebra uma quantidade surpreendente de código . Ele simplesmente aponta algumas bases de código populares que ele afirma ser conhecido por confiar neste comportamento indefinido e quebras devido à mudança, a menos que a opção alternativa seja usada.

Por que essa nova suposição iria quebrar o código C ++ prático?

Se o código c ++ prático depender de um comportamento indefinido, as mudanças nesse comportamento indefinido podem quebrá-lo. É por isso que o UB deve ser evitado, mesmo quando um programa baseado nele parece funcionar como pretendido.

Existem padrões particulares em que programadores descuidados ou desinformados confiam nesse comportamento indefinido em particular?

Eu não sei se é anti -pattern, mas um programador desinformado pode pensar que eles podem consertar o programa fazendo:

 if (this) member_variable = 42; 

Quando o bug real está desreferenciando um ponteiro nulo em outro lugar.

Tenho certeza de que, se o programador estiver desinformado o suficiente, eles poderão criar padrões (anti) mais avançados que dependem desse UB.

Eu não posso imaginar alguém escrevendo if (this == NULL) porque isso é tão pouco natural.

Eu posso.

Parte do código “prático” (maneira engraçada de soletrar “bugs”) que foi quebrado ficou assim:

 void foo(X* p) { p->bar()->baz(); } 

e ele esqueceu de levar em conta o fato de que p->bar() às vezes retorna um ponteiro nulo, o que significa que a desreferência para chamar baz() é indefinida.

Nem todo o código que foi quebrado continha explícito if (this == nullptr) ou if (!p) return; Verificações. Alguns casos eram simplesmente funções que não acessavam nenhuma variável de membro e, portanto, pareciam funcionar OK. 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 void do_something_else(T* p) { if (p) { use(p->m_data); } } template void func(T* p) { if (p->valid()) do_something(p); else do_something_else(p); } 

Neste código quando você chama func(DummyImpl*) com um ponteiro nulo, há uma referência “conceitual” do ponteiro para chamar p->DummyImpl::valid() , mas na verdade essa function de membro apenas retorna false sem acessar *this . Esse return false pode ser embutido e, assim, na prática, o ponteiro não precisa ser acessado. Então, com alguns compiladores, parece funcionar OK: não há nenhum segfault para desreferenciar null, p->valid() é false, então o código chama do_something_else(p) , que verifica se há pointers nulos, e não faz nada. Nenhum acidente ou comportamento inesperado é observado.

Com o GCC 6, você ainda recebe a chamada p->valid() , mas o compilador agora infere dessa expressão que p deve ser não nulo (caso contrário, p->valid() seria um comportamento indefinido) e faz uma anotação disso. em formação. Essa informação inferida é usada pelo otimizador para que, se a chamada para do_something_else(p) for embutida, a verificação if (p) seja agora considerada redundante, porque o compilador lembra que ela não é nula e, portanto, insere o código em:

 template 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 realmente desreferencia um ponteiro nulo e, assim, o código que anteriormente parecia funcionar para de funcionar.

Neste exemplo, o bug está em func , que deveria ter verificado primeiro null (ou os chamadores nunca deveriam ter chamado-o com null):

 template 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 é um caso do compilador dizendo “ah, o programador testou esse ponteiro contra null, eu o removerei apenas para ser chato”. O que acontece é que várias otimizações comuns, como inlining e propagação de faixa de valor, se combinam para tornar essas verificações redundantes, porque elas vêm após uma verificação anterior ou uma desreferenciação. Se o compilador souber que um ponteiro não é nulo no ponto A em uma function, e o ponteiro não é alterado antes de um ponto posterior B na mesma function, então ele sabe que também é não nulo em B. Quando inlining acontece Os pontos A e B podem, na verdade, ser pedaços de código que estavam originalmente em funções separadas, mas agora estão combinados em um pedaço de código, e o compilador é capaz de aplicar seu conhecimento de que o ponteiro não é nulo em mais lugares. Essa é uma otimização básica, mas muito importante, e se os compiladores não fizessem isso, o código do dia-a-dia seria consideravelmente mais lento e as pessoas se queixariam de ramificações desnecessárias para testar novamente as mesmas condições repetidamente.

O padrão C ++ é quebrado de maneiras importantes. Infelizmente, em vez de proteger os usuários desses problemas, os desenvolvedores do GCC optaram por usar o comportamento indefinido como uma desculpa para implementar otimizações marginais, mesmo quando lhes foi claramente explicado como isso é prejudicial.

Aqui uma pessoa muito mais esperta do que eu explico em detalhes. (Ele está falando sobre C, mas a situação é a mesma lá).

Por que é prejudicial?

Basta recompilar o código seguro anteriormente em funcionamento com uma versão mais recente do compilador, que pode introduzir vulnerabilidades de segurança . Enquanto o novo comportamento pode ser desativado com um sinalizador, os makefiles existentes não possuem esse sinalizador definido, obviamente. E como nenhum aviso é produzido, não é óbvio para o desenvolvedor que o comportamento anteriormente razoável foi alterado.

Neste exemplo, o desenvolvedor incluiu uma verificação de estouro de inteiro, usando assert , que terminará o programa se um comprimento inválido for fornecido. A equipe do GCC removeu a verificação com base no fato de que o estouro de número inteiro é indefinido, portanto, a verificação pode ser removida. Isso resultou em instâncias reais e in-the-wild desta base de código sendo refazidas após o problema ter sido corrigido.

Leia a coisa toda. É o suficiente para fazer você chorar.

OK, mas e esse aqui?

Lá atrás, quando havia um idioma bastante comum que era algo assim:

  OPAQUEHANDLE ObjectType::GetHandle(){ if(this==NULL)return DEFAULTHANDLE; return mHandle; } void DoThing(ObjectType* pObj){ osfunction(pObj->GetHandle(), "BLAH"); } 

Portanto, a expressão idiomática é: Se pObj não for nulo, você usará o identificador que ele contém, caso contrário, você usará um identificador padrão. Isso é encapsulado na function GetHandle .

O truque é que chamar uma function não virtual realmente não faz uso do ponteiro, portanto, não há violação de access.

Eu ainda não entendi

Existe muito código escrito assim. Se alguém simplesmente a recompilar, sem alterar uma linha, todas as chamadas para o DoThing(NULL) são um erro DoThing(NULL) – se você tiver sorte.

Se você não tiver sorte, as chamadas para bugs falhos se tornam vulnerabilidades de execução remota.

Isso pode ocorrer até automaticamente. Você tem um sistema de construção automatizado, certo? Atualizá-lo para o compilador mais recente é inofensivo, certo? Mas agora não é – não se o seu compilador for o GCC.

OK, então conte para eles!

Eles foram informados. Eles estão fazendo isso com pleno conhecimento das conseqüências.

mas por que?

Quem pode dizer? Possivelmente:

  • Eles valorizam a pureza ideal da linguagem C ++ em relação ao código real
  • Eles acreditam que as pessoas devem ser punidas por não seguirem o padrão
  • Eles não têm compreensão da realidade do mundo
  • Eles estão … introduzindo erros de propósito. Talvez por um governo estrangeiro. Onde você mora? Todos os governos são estrangeiros na maior parte do mundo, e a maioria é hostil a algumas partes do mundo.

Ou talvez algo mais. Quem pode dizer?