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

148

O GCC 6 possui um novo recurso de otimizador : ele assume que thisnem sempre é nulo e otimiza com base nisso.

Agora, a propagação do intervalo de valores assume que o ponteiro this das funções de membro do C ++ é não nulo. Isso elimina verificações comuns de ponteiro nulo, mas também quebra algumas bases de código não conformes (como Qt-5, Chromium, KDevelop) . Como uma solução temporária, pode-se usar -fno-delete-null-pointer-checks. Código incorreto pode ser identificado usando -fsanitize = undefined.

O documento de mudança claramente chama isso de perigoso, pois quebra uma quantidade surpreendente de código usado com freqüência.

Por que essa nova suposição quebraria o código C ++ prático? Existem padrões específicos em que programadores descuidados ou desinformados dependem desse comportamento indefinido específico? Não consigo imaginar alguém escrevendo, if (this == NULL)porque isso é muito natural.

boot4life
fonte
21
@ Ben Espero que você esteja falando sério. O código com UB deve ser reescrito para não invocar o UB. É simples assim. Heck, muitas vezes existem perguntas frequentes que mostram como alcançá-lo. Então, não é um problema real IMHO. Tudo bom.
Reintegrar Monica
49
Estou surpreso ao ver pessoas defendendo a exclusão de ponteiros nulos no código. Simplesmente incrível.
precisa saber é o seguinte
19
@ Ben, explorar comportamentos indefinidos tem sido a tática de otimização muito eficaz há muito tempo. Adoro, porque adoro otimizações que tornam meu código mais rápido.
precisa saber é o seguinte
17
Eu concordo com SergeyA. O brouhaha inteiro começou porque as pessoas parecem insistir no fato de que thiselas são passadas como um parâmetro implícito, então elas começam a usá-lo como se fosse um parâmetro explícito. Não é. Quando você desreferencia um nulo disso, está invocando o UB como se tivesse desferenciado qualquer outro ponteiro nulo. É só isso. Se você deseja transmitir nullptrs, use um parâmetro explícito, DUH . Não será mais lento, nem mais desajeitado, e o código que possui essa API é profundo nos internos de qualquer maneira, portanto, tem escopo muito limitado. Fim da história, eu acho.
Reintegrar Monica
41
Kudos para GCC para quebrar o ciclo de código ruim -> compilador ineficiente para apoiar código ruim -> mais código ruim -> compilação mais ineficiente -> ...
MM

Respostas:

87

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

O caso mais comum é provavelmente se você tem uma classe 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 tornar isso uma função membro:

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

Nos primeiros dias do C ++ (antes da padronização), enfatizou-se que as funções-membro eram açúcar sintático para uma função em que o thisparâmetro está implícito. O código foi escrito em C ++, convertido em equivalente C e compilado. Havia até exemplos explícitos de que comparar thiscom nulo era significativo e o compilador Cfront original também se aproveitou disso. Portanto, vindo de um fundo C, a escolha óbvia para a verificação é:

if(this == nullptr) return;      

Nota: Bjarne Stroustrup até menciona que as regras para thisforam alteradas 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 tirar vantagem da chamada de uma função membro em thisque o nullptrcomportamento indefinido de being é, o que significa que essa condição é sempre false, e o compilador é livre para omitir isso.

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

  • Faça todas as verificações antes de ligar 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ê pode ter uma raiz nula.

  • Não use uma função de membro

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

  • Acredito que a maneira mais fácil / correta de corrigir esse exemplo específico de uma forma que seja compatível com os padrões é realmente usar um nó sentinela em vez de a 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 possa se safar, eles escreveram códigos ruins em this == nullptrvez de usar uma correção adequada.

Suponho que é assim que algumas dessas bases de código evoluíram para ter this == nullptrverificações nelas.

jtlim
fonte
6
Como pode 1 == 0ser um comportamento indefinido? É simplesmente false.
Johannes Schaub - litb 27/04
11
A verificação em si não é um comportamento indefinido. É sempre sempre falso e, portanto, eliminado pelo compilador.
precisa saber é o seguinte
15
Hmm .. o this == nullptridioma é um comportamento indefinido porque você chamou uma função de membro em um objeto nullptr antes disso, que é indefinido. E o compilador é livre para omitir a verificação
jtlim
6
@ Josué, o primeiro padrão foi publicado em 1998. O que aconteceu antes disso foi o que toda implementação queria. Idade das Trevas.
Sergeya
26
Uau, uau, não acredito que alguém já tenha escrito código que dependesse de chamar funções de instância ... sem uma instância . Instintivamente, eu teria usado o trecho marcado "Faça todas as verificações antes de chamar traverse_in_order", sem sequer pensar em thisser nulo. Eu acho que talvez esse seja o benefício de aprender C ++ em uma época em que o SO existe para entrincheirar os perigos do UB no meu cérebro e me dissuadir de fazer hacks bizarros como esse.
underscore_d
65

Isso ocorre 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 this, a não ser como uma micro-otimização, geralmente muito prematura.

É uma prática perigosa, pois o ajuste de ponteiros devido à passagem da hierarquia de classes pode transformar um nulo thisem um não nulo. Portanto, no mínimo, a classe cujos métodos devem trabalhar com um nulo thisdeve ser uma classe final sem classe base: não pode derivar de nada e não pode derivar. Estamos partindo rapidamente 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ê tinha uma árvore vazia (por exemplo, root é nullptr), esta solução ainda depende de um comportamento indefinido chamando traverse_in_order com um nullptr.

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

O argumento aqui parece se resumir à necessidade de escrever métodos não estáticos em objetos que poderiam ser chamados a partir de um ponteiro de instância nula. Não existe essa necessidade. A maneira C-com-objetos de escrever esse código ainda é muito melhor no mundo C ++, porque pode ser, no mínimo, do tipo seguro. Basicamente, o nulo thisé uma micro-otimização, com um campo de uso tão restrito, que não é perfeitamente adequado para IMHO. Nenhuma API pública deve depender de um valor nulo this.

Restabelecer Monica
fonte
18
@ Ben, quem escreveu esse código estava errado em primeiro lugar. É engraçado que você esteja nomeando projetos terrivelmente quebrados como MFC, Qt e Chromium. Boa viagem com eles.
precisa saber é o seguinte
19
@ Ben, estilos de codificação terríveis no Google são bem conhecidos por mim. O código do Google (pelo menos disponível ao público) geralmente é mal escrito, apesar de várias pessoas acreditarem que o código do Google é o exemplo brilhante. Pode ser que isso os faça revisitar seus estilos de codificação (e diretrizes enquanto estiverem nele).
precisa saber é o seguinte
18
@ Ben Ninguém substitui o Chromium retroativamente nesses dispositivos pelo Chromium compilado usando o gcc 6. Antes que o Chromium seja compilado usando o gcc 6, e outros compiladores modernos, ele precisará ser corrigido. Também não é uma tarefa enorme; as thisverificações são escolhidas por vários analisadores de código estático, portanto, não é como se alguém tivesse que procurá-los manualmente. O patch seria provavelmente algumas centenas de linhas de mudanças triviais.
Reintegrar Monica
8
@ Ben Em termos práticos, uma thisdesreferência nula é uma falha instantânea. Esses problemas serão descobertos rapidamente, mesmo que ninguém se preocupe em executar um analisador estático sobre o código. O C / C ++ segue o mantra "pague apenas pelos recursos que você usa". Se você deseja verificações, deve ser explícito sobre elas e isso significa não fazê-las this, quando for tarde demais, pois o compilador assume que thisnão é nulo. Caso contrário, teria que verificar thise, para 99,9999% do código existente, essas verificações são uma perda de tempo.
Reintegrar Monica
10
meu conselho para quem pensa que o padrão está quebrado: use um idioma diferente. Não faltam linguagens do tipo C ++ que não têm a possibilidade de comportamento indefinido.
28416 MM
35

O documento de mudança claramente chama isso de perigoso, pois quebra uma quantidade surpreendente de código usado com freqüência.

O documento não chama de perigoso. Também não afirma que quebra uma quantidade surpreendente de código . Ele simplesmente aponta algumas bases de código populares que afirma serem conhecidas por confiar nesse comportamento indefinido e que seriam interrompidas devido à alteração, a menos que a opção de solução alternativa seja usada.

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

Se o código c ++ prático se basear em um comportamento indefinido, as alterações nesse comportamento indefinido poderão quebrá-lo. É por isso que o UB deve ser evitado, mesmo quando um programa que depende dele parece funcionar como planejado.

Existem padrões específicos em que programadores descuidados ou desinformados dependem desse comportamento indefinido específico?

Não sei se o anti- padrão é amplamente difundido , mas um programador desinformado pode pensar que ele pode corrigir o problema de travar 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 não tiver informações suficientes, ele poderá criar padrões (anti) mais avançados que dependem desse UB.

Não consigo imaginar alguém escrevendo, if (this == NULL)porque isso é muito natural.

Eu posso.

eerorika
fonte
11
"Se o código c ++ prático depende de um comportamento indefinido, as alterações nesse comportamento indefinido podem quebrá-lo. É por isso que o UB deve ser evitado" this * 1000
underscore_d
if(this == null) PrintSomeHelpfulDebugInformationAboutHowWeGotHere(); Como um bom registro fácil de ler de uma sequência de eventos que um depurador não pode lhe contar com facilidade. Divirta-se depurando isso agora sem gastar horas colocando verificações em todos os lugares quando houver um nulo aleatório repentino em um grande conjunto de dados, no código que você não escreveu ... E a regra UB sobre isso foi feita mais tarde, depois que o C ++ foi criado. Costumava ser válido.
Stephane Hockenhull
@StephaneHockenhull É para isso que -fsanitize=nullserve.
eerorika
@ user2079303 Problemas: Isso desacelerará o código de produção até o ponto em que você não pode deixar o check-in enquanto estiver executando, custando muito dinheiro à empresa? Isso vai aumentar o tamanho e não caber no flash? Isso funciona em todas as plataformas de destino, incluindo a Atmel? Pode -fsanitize=nullregistrar os erros no cartão SD / MMC nos pinos # 5,6,10,11 usando SPI? Essa não é uma solução universal. Alguns argumentaram que é contrário aos princípios orientados a objetos acessar um objeto nulo, mas algumas linguagens OOP têm um objeto nulo que pode ser operado, portanto não é uma regra universal de OOP. 1/2
Stephane Hockenhull
1
... uma expressão regular que corresponde a esses arquivos? Dizendo que, por exemplo, se um lvalue for acessado duas vezes, um compilador poderá consolidar os acessos, a menos que o código entre eles faça alguma das várias coisas específicas, seria muito mais fácil do que tentar definir as situações precisas nas quais o código pode acessar o armazenamento.
precisa
25

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 falsesem acessar *this. Isso return falsepode 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 pdeve 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.

Jonathan Wakely
fonte
É possível instruir o GCC 6 a emitir avisos em tempo de compilação quando encontrar esses usos this?
jotik
3
@jotik, ^^^ o que o TC disse. Seria possível, mas você receberia esse aviso PARA TODO O CÓDIGO, O TEMPO TODO . A propagação do intervalo de valores é uma das otimizações mais comuns, que afeta quase todo o código, em qualquer lugar. Os otimizadores apenas veem o código, que pode ser simplificado. Eles não vêem "um pedaço de código escrito por um idiota que quer ser avisado se o seu idiota UB for otimizado". Não é fácil para o compilador diferenciar entre "verificação redundante que o programador deseja otimizar" e "verificação redundante que o programador acha que vai ajudar, mas é redundante".
Jonathan Wakely
1
Se você deseja instrumentar seu código para fornecer tempo de execução erros para vários tipos de UB, incluindo usos inválidos de this, em seguida, basta usar-fsanitize=undefined
Jonathan Wakely
3
@ jotik Já existe um aviso para isso.
TC
-25

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

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

Por que isso é prejudicial?

A simples recompilação do código seguro anteriormente em funcionamento com uma versão mais recente do compilador pode introduzir vulnerabilidades de segurança . Embora o novo comportamento possa ser desabilitado com um sinalizador, os makefiles existentes não têm esse sinalizador definido, obviamente. E como nenhum aviso é produzido, não é óbvio para o desenvolvedor que o comportamento anteriormente razoável mudou.

Neste exemplo, o desenvolvedor incluiu uma verificação de excesso de número inteiro, usando assert, que encerrará o programa se um comprimento inválido for fornecido. A equipe do GCC removeu a verificação com base em que o excesso de número inteiro é indefinido; portanto, a verificação pode ser removida. Isso resultou em instâncias reais in-the-wild dessa base de código sendo tornadas vulneráveis ​​após a correção do problema.

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

OK, mas e esse?

Na época, 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, o idioma é: se pObj não for nulo, use o identificador que ele contém, caso contrário, use um identificador padrão. Isso está encapsulado na GetHandlefunção

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

Eu ainda não entendi

Existe muito código que é escrito assim. Se alguém simplesmente recompilar, sem alterar uma linha, toda chamada paraDoThing(NULL) é um bug fatal - se você tiver sorte.

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

Isso pode ocorrer mesmo 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 diga a eles!

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

mas por que?

Quem pode dizer? Possivelmente:

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

Ou talvez outra coisa. Quem pode dizer?

Ben
fonte
32
Não concordo com todas as linhas da resposta. Os mesmos comentários foram feitos para otimizações estritas de alias e esses esperamos que sejam descartados agora. A solução é educar os desenvolvedores, não impedir otimizações baseadas em maus hábitos de desenvolvimento.
precisa saber é o seguinte
30
Eu fui e ler a coisa toda como você disse, e na verdade eu chorava, mas, principalmente, com a estupidez de Felix, que eu não acho que era o que você estava tentando passar ...
Mike Vine
33
Votado pelo discurso inútil. "Eles estão ... introduzindo erros de propósito. Talvez para um governo estrangeiro." Realmente? Isso não é / r / conspiração.
Isanae
31
Programadores decentes repetidamente repetem o mantra não invocam um comportamento indefinido , mas esses nonks foram adiante e fizeram isso de qualquer maneira. E veja o que aconteceu. Não tenho nenhuma simpatia. Isso é culpa dos desenvolvedores, simples assim. Eles precisam assumir a responsabilidade. Lembre-se disso? Responsabilidade pessoal? Pessoas confiando no seu mantra "mas e na prática !" é precisamente como essa situação surgiu em primeiro lugar. Evitar bobagens como essa é precisamente por que os padrões existem em primeiro lugar. Código para os padrões, e você não terá um problema. Período.
Lightness Races in Orbit
18
"Simplesmente recompilar o código seguro que funcionava anteriormente com uma versão mais recente do compilador pode introduzir vulnerabilidades de segurança" - isso sempre acontece . A menos que você queira determinar que uma versão de um compilador seja o único que será permitido pelo resto da eternidade. Você se lembra de quando o kernel do linux só pôde ser compilado com exatamente o gcc 2.7.2.1? O projeto do GCC chegou a ser bifurcado porque as pessoas estavam cansadas de lixo. Demorou muito tempo para superar isso.
MM)