Por que a detecção de código morto não pode ser totalmente resolvida por um compilador?

192

Os compiladores que tenho usado em C ou Java têm prevenção de código morto (aviso quando uma linha nunca será executada). Meu professor diz que esse problema nunca pode ser totalmente resolvido pelos compiladores. Fiquei me perguntando por que isso é. Eu não estou muito familiarizado com a codificação real dos compiladores, pois esta é uma classe baseada em teoria. Mas eu queria saber o que eles verificam (como possíveis seqüências de caracteres de entrada versus entradas aceitáveis ​​etc.) e por que isso é insuficiente.

Aprendiz
fonte
91
fazer um loop, o código put depois que, em seguida, aplicar en.wikipedia.org/wiki/Halting_problem
zapl
48
if (isPrime(1234234234332232323423)){callSomething();}esse código chamará alguma coisa ou não? Existem muitos outros exemplos em que decidir se uma função é chamada é de longe muito mais caro do que incluí-la no programa.
Idclev 463035818
33
public static void main(String[] args) {int counterexample = findCollatzConjectureCounterexample(); System.out.println(counterexample);}<- é o código morto da chamada println? Nem os humanos conseguem resolver esse problema!
usar o seguinte comando
15
@ tobi303 não é um ótimo exemplo, é realmente fácil fatorar números primos ... apenas não fatorá-los de forma relativamente eficiente. O problema da parada não está no NP, é insolúvel.
en_Knight
57
@alephzero e en_Knight - Vocês dois estão errados. isPrime é um ótimo exemplo. Você assumiu que a função está verificando um número primo. Talvez esse número fosse um número de série e faça uma pesquisa no banco de dados para ver se o usuário é um membro do Amazon Prime? O motivo é um ótimo exemplo, porque a única maneira de saber se a condição é constante ou não é realmente executar a função isPrime. Então agora isso exigiria que o compilador também fosse um intérprete. Mas isso ainda não resolveria os casos em que os dados são voláteis.
Dunk

Respostas:

275

O problema do código morto está relacionado ao problema da parada .

Alan Turing provou que é impossível escrever um algoritmo geral que receberá um programa e seja capaz de decidir se esse programa é interrompido para todas as entradas. Você pode escrever esse algoritmo para tipos específicos de programas, mas não para todos os programas.

Como isso se relaciona com o código morto?

O problema de parada é redutível ao problema de encontrar código morto. Ou seja, se você encontrar um algoritmo capaz de detectar código morto em qualquer programa, poderá usá-lo para testar se algum programa será interrompido. Desde que isso provou ser impossível, segue-se que escrever um algoritmo para código morto também é impossível.

Como você transfere um algoritmo para código morto para um algoritmo para o problema de parada?

Simples: você adiciona uma linha de código após o final do programa que deseja verificar se há interrupção. Se o seu detector de código morto detectar que esta linha está morta, você saberá que o programa não é interrompido. Caso contrário, você sabe que o seu programa é interrompido (chega à última linha e depois à sua linha de código adicionada).


Os compiladores geralmente verificam se há coisas que podem ser comprovadas em tempo de compilação como mortas. Por exemplo, blocos que dependem de condições que podem ser determinadas como falsas no tempo de compilação. Ou qualquer declaração após a return(dentro do mesmo escopo).

Esses são casos específicos e, portanto, é possível escrever um algoritmo para eles. Pode ser possível escrever algoritmos para casos mais complicados (como um algoritmo que verifica se uma condição é sintaticamente uma contradição e, portanto, sempre retornará falsa), mas ainda assim, isso não cobriria todos os casos possíveis.

RealSkeptic
fonte
8
Eu argumentaria que o problema da parada não é aplicável aqui, pois toda plataforma que é um destino de compilação de todo compilador no mundo real tem uma quantidade máxima de dados que pode acessar; portanto, ele terá um número máximo de estados, o que significa que é de fato, uma máquina de estado finito, não uma máquina de turing. O problema de parada não é insolúvel para FSMs, portanto, qualquer compilador no mundo real pode executar a detecção de código morto.
Validade 21/10
50
Os processadores @Vality de 64 bits podem endereçar 2 ^ 64 bytes. Divirta-se pesquisando todos os 256 ^ (2 ^ 64) estados!
Daniel Wagner
82
@DanielWagner Isso não deve ser um problema. Pesquisando 256^(2^64)estados O(1), é possível detectar o código morto em tempo polinomial.
Aebabis
13
@Leliel, isso foi sarcasmo.
Paul Draper
44
@ Qualidade: A maioria dos computadores modernos possui discos, dispositivos de entrada, comunicações em rede, etc. Qualquer análise completa teria que considerar todos esses dispositivos - incluindo, literalmente, a Internet e tudo o que está ligado a ela. Este não é um problema tratável.
Nat
77

Bem, vamos pegar a prova clássica da indecidibilidade do problema de parada e alterar o detector de parada para um detector de código morto!

Programa c #

using System;
using YourVendor.Compiler;

class Program
{
    static void Main(string[] args)
    {
        string quine_text = @"using System;
using YourVendor.Compiler;

class Program
{{
    static void Main(string[] args)
    {{
        string quine_text = @{0}{1}{0};
        quine_text = string.Format(quine_text, (char)34, quine_text);

        if (YourVendor.Compiler.HasDeadCode(quine_text))
        {{
            System.Console.WriteLine({0}Dead code!{0});
        }}
    }}
}}";
        quine_text = string.Format(quine_text, (char)34, quine_text);

        if (YourVendor.Compiler.HasDeadCode(quine_text))
        {
            System.Console.WriteLine("Dead code!");
        }
    }
}

Se YourVendor.Compiler.HasDeadCode(quine_text)retornos false, em seguida, a linha System.Console.WriteLn("Dead code!");não será sempre executada, de modo que este programa realmente não têm código morto, e o detector estava errado.

Porém, se retornar true, a linha System.Console.WriteLn("Dead code!");será executada e, como não há mais código no programa, não há código morto; então, novamente, o detector estava errado.

Portanto, um detector de código morto que retorna apenas "Existe código morto" ou "Não há código morto" às vezes deve gerar respostas erradas.

Joker_vD
fonte
1
Se eu entendi seu argumento corretamente, tecnicamente outra opção seria que não é possível escrever um código que seja um detector de código morto, mas é possível gravar um detector de código morto no caso geral. :-)
abligh 22/10/2015
1
incremento para a resposta godeliana.
22415 Jared Smith
@ Abigh Ugh, essa foi uma má escolha de palavras. Na verdade, não estou fornecendo o código-fonte do detector de código morto a si mesmo, mas o código-fonte do programa que o utiliza. Certamente, em algum momento, provavelmente teria que procurar seu próprio código, mas é o seu negócio.
Joker_vD 22/10
65

Se o problema da parada for muito obscuro, pense dessa maneira.

Pegue um problema matemático que se acredita verdadeiro para todo número inteiro positivo n , mas que não foi provado verdadeiro para todo n . Um bom exemplo seria a conjectura de Goldbach , de que qualquer número inteiro positivo maior que dois pode ser representado pela soma de dois números primos. Em seguida (com uma biblioteca bigint apropriada), execute este programa (segue o pseudocódigo):

 for (BigInt n = 4; ; n+=2) {
     if (!isGoldbachsConjectureTrueFor(n)) {
         print("Conjecture is false for at least one value of n\n");
         exit(0);
     }
 }

A implementação de isGoldbachsConjectureTrueFor()é deixada como um exercício para o leitor, mas para esse propósito pode ser uma iteração simples sobre todos os números primos menores quen

Agora, logicamente, o acima deve ser o equivalente a:

 for (; ;) {
 }

(ou seja, um loop infinito) ou

print("Conjecture is false for at least one value of n\n");

como a conjectura de Goldbach deve ser verdadeira ou não verdadeira. Se um compilador sempre pudesse eliminar o código morto, definitivamente haveria código morto a ser eliminado aqui em ambos os casos. No entanto, ao fazer isso, pelo menos, seu compilador precisaria resolver problemas arbitrariamente difíceis. Poderíamos fornecer problemas comprovadamente difíceis que precisariam ser resolvidos (por exemplo, problemas de NP-completos) para determinar qual parte do código eliminar. Por exemplo, se usarmos este programa:

 String target = "f3c5ac5a63d50099f3b5147cabbbd81e89211513a92e3dcd2565d8c7d302ba9c";
 for (BigInt n = 0; n < 2**2048; n++) {
     String s = n.toString();
     if (sha256(s).equals(target)) {
         print("Found SHA value\n");
         exit(0);
     }
 }
 print("Not found SHA value\n");

sabemos que o programa imprimirá "Valor SHA encontrado" ou "Valor SHA não encontrado" (pontos de bônus se você puder me dizer qual é o verdadeiro). No entanto, para um compilador ser capaz de otimizar razoavelmente isso levaria da ordem de 2 ^ 2048 iterações. Seria, de fato, uma ótima otimização, pois eu prevejo que o programa acima funcionaria (ou poderia) até a morte pelo calor do universo, em vez de imprimir qualquer coisa sem otimização.

abligh
fonte
4
É a melhor resposta, de longe, +1
jean
2
O que torna as coisas particularmente interessantes é a ambiguidade sobre o que o Padrão C permite ou não permite quando se assume que os loops serão encerrados. É importante permitir que um compilador adie cálculos lentos cujos resultados podem ou não ser usados ​​até o ponto em que seus resultados seriam realmente necessários; essa otimização pode, em alguns casos, ser útil mesmo que o compilador não consiga provar que os cálculos terminam.
Supercat
2
2 ^ 2048 iterações? Até o Pensamento Profundo desistiria.
Peter Mortensen
Ele imprimirá "Valor SHA encontrado" com probabilidade muito alta, mesmo se esse destino for uma sequência aleatória de 64 dígitos hexadecimais. A menos que sha256retorne uma matriz de bytes e as matrizes de bytes não sejam comparadas às seqüências de caracteres no seu idioma.
user253751
4
Implementation of isGoldbachsConjectureTrueFor() is left as an exercise for the readerIsso me fez rir.
biziclop
34

Não sei se C ++ ou Java têm uma Evalfunção de tipo, mas muitas linguagens permitem que você chame métodos por nome . Considere o seguinte exemplo de VBA (artificial).

Dim methodName As String

If foo Then
    methodName = "Bar"
Else
    methodName = "Qux"
End If

Application.Run(methodName)

O nome do método a ser chamado é impossível saber até o tempo de execução. Portanto, por definição, o compilador não pode saber com certeza absoluta que um método específico nunca é chamado.

Na verdade, dado o exemplo de chamar um método pelo nome, a lógica de ramificação nem é necessária. Simplesmente dizendo

Application.Run("Bar")

É mais do que o compilador pode determinar. Quando o código é compilado, tudo o que o compilador sabe é que um determinado valor de string está sendo passado para esse método. Ele não verifica se esse método existe até o tempo de execução. Se o método não for chamado em outro lugar, por métodos mais normais, uma tentativa de encontrar métodos mortos pode retornar falsos positivos. O mesmo problema existe em qualquer idioma que permita que o código seja chamado via reflexão.

Pato de borracha
fonte
2
Em Java (ou C #), isso pode ser feito com reflexão. Em C ++, você provavelmente poderia fazer alguma maldade usando macros para fazer isso. Não seria bonito, mas C ++ raramente é.
Darrel Hoffman
6
@DarrelHoffman - As macros são expandidas antes que o código seja fornecido ao compilador, portanto as macros definitivamente não são como você faria isso. Ponteiros para funções é como você faria isso. Não uso C ++ há anos, desculpe-me se meus nomes de tipo exatos estiverem incorretos, mas você pode simplesmente armazenar um mapa de strings para funcionar como ponteiro. Em seguida, peça algo que aceite uma sequência de caracteres da entrada do usuário, procure essa sequência no mapa e execute a função apontada.
ArtOfWarfare
1
@ArtOfWarfare não estamos falando sobre como isso poderia ser feito. Obviamente, a análise semântica do código pode ser feita para encontrar essa situação, o ponto era que o compilador não . Poderia, possivelmente, talvez, mas não.
precisa
3
@ ArtOfWarfare: Se você quiser escolher, com certeza. Considero o pré-processador como parte do compilador, embora eu saiba que tecnicamente não é. De qualquer forma, os ponteiros de função podem violar a regra de que as funções não são diretamente referenciadas em nenhum lugar - elas são, exatamente como um ponteiro em vez de uma chamada direta, como um delegado em C #. Em geral, o C ++ é muito mais difícil para um compilador prever, pois possui muitas maneiras de fazer as coisas indiretamente. Mesmo tarefas simples como "encontrar todas as referências" não são triviais, pois podem se esconder em typedefs, macros etc. Não é surpresa que não seja possível encontrar código morto com facilidade.
Darrel Hoffman
1
Você nem precisa de chamadas de método dinâmico para enfrentar esse problema. Qualquer método público pode ser chamado por uma função ainda não escrita que dependerá da classe já compilada em Java ou C # ou de qualquer outra linguagem compilada com algum mecanismo para vinculação dinâmica. Se os compiladores os eliminassem como "código morto", não poderíamos empacotar bibliotecas pré-compiladas para distribuição (NuGet, jars, Python wheels com componente binário).
jpmc26
12

Código morto incondicional pode ser detectado e removido por compiladores avançados.

Mas também há código morto condicional. Esse é um código que não pode ser conhecido no momento da compilação e só pode ser detectado durante o tempo de execução. Por exemplo, um software pode ser configurável para incluir ou excluir determinados recursos, dependendo da preferência do usuário, tornando determinadas seções de código aparentemente inoperantes em cenários específicos. Isso não é um código morto real.

Existem ferramentas específicas que podem fazer testes, resolver dependências, remover código morto condicional e recombinar o código útil em tempo de execução para obter eficiência. Isso é chamado eliminação dinâmica de código morto. Mas como você pode ver, está além do escopo dos compiladores.

dspfnder
fonte
5
"O código morto incondicional pode ser detectado e removido por compiladores avançados." Isso não parece provável. A falta de código pode depender do resultado de uma determinada função, e essa função pode resolver problemas arbitrários. Portanto, sua declaração afirma que compiladores avançados podem resolver problemas arbitrários.
Taemyr 23/10/2015
6
@ Taemyr Então não seria sabido que estava incondicionalmente morto, agora seria?
JAB
1
@ Taemyr Você parece entender mal a palavra "incondicional". Se a falta de código depende do resultado de uma função, é um código morto condicional. A "condição" é o resultado da função. Para ser "incondicional", teria que não depender de nenhum resultado.
Kyeotic
12

Um exemplo simples:

int readValueFromPort(const unsigned int portNum);

int x = readValueFromPort(0x100); // just an example, nothing meaningful
if (x < 2)
{
    std::cout << "Hey! X < 2" << std::endl;
}
else
{
    std::cout << "X is too big!" << std::endl;
}

Agora suponha que a porta 0x100 seja projetada para retornar apenas 0 ou 1. Nesse caso, o compilador não pode descobrir que o elsebloco nunca será executado.

No entanto, neste exemplo básico:

bool boolVal = /*anything boolean*/;

if (boolVal)
{
  // Do A
}
else if (!boolVal)
{
  // Do B
}
else
{
  // Do C
}

Aqui o compilador pode calcular que o elsebloco é um código morto. Portanto, o compilador pode avisar sobre o código morto apenas se tiver dados suficientes para descobrir o código morto e também deve saber como aplicar esses dados para descobrir se o bloco fornecido é um código morto.

EDITAR

Às vezes, os dados simplesmente não estão disponíveis no momento da compilação:

// File a.cpp
bool boolMethod();

bool boolVal = boolMethod();

if (boolVal)
{
  // Do A
}
else
{
  // Do B
}

//............
// File b.cpp
bool boolMethod()
{
    return true;
}

Ao compilar a.cpp, o compilador não pode saber que boolMethodsempre retorna true.

Alex Lop.
fonte
1
Embora seja estritamente verdade que o compilador não sabe, acho que faz parte do espírito da pergunta também perguntar se o vinculador pode saber.
Casey Kuball 22/10/2015
1
@Darthfett Não é de responsabilidade do vinculador . O Linker não analisa o conteúdo do código compilado. O vinculador (de modo geral) apenas vincula os métodos e os dados globais, não se importa com o conteúdo. No entanto, alguns compiladores têm a opção de concatenar os arquivos de origem (como ICC) e executar a otimização. Nesse caso, o caso em EDIT é coberto, mas esta opção afetará o tempo de compilação, especialmente quando o projeto for grande.
Alex Lop.
Essa resposta me parece enganadora; você está dando dois exemplos em que isso não é possível porque nem todas as informações estão disponíveis, mas você não deveria dizer que é impossível, mesmo que as informações estejam lá?
Anton Golov 22/10/2015
@AntonGolov Nem sempre é verdade. Em muitos casos, quando as informações estão disponíveis, os compiladores podem detectar o código morto e otimizá-lo.
Alex Lop.
@abforce apenas um bloco de código. Poderia ter sido qualquer outra coisa. :)
Alex Lop.
4

Sempre falta ao compilador algumas informações de contexto. Por exemplo, você deve saber que um valor duplo nunca excede 2, porque esse é um recurso da função matemática que você usa em uma biblioteca. O compilador nem vê o código na biblioteca e nunca pode conhecer todos os recursos de todas as funções matemáticas e detectar todas as maneiras complicadas e complicadas de implementá-las.

cdonat
fonte
4

O compilador não vê necessariamente o programa inteiro. Eu poderia ter um programa que chama uma biblioteca compartilhada, que chama de volta para uma função no meu programa que não é chamada diretamente.

Portanto, uma função que está morta em relação à biblioteca em que é compilada pode se tornar viva se essa biblioteca for alterada em tempo de execução.

Steve Sanbeg
fonte
3

Se um compilador pudesse eliminar todo o código morto com precisão, ele seria chamado de intérprete .

Considere este cenário simples:

if (my_func()) {
  am_i_dead();
}

my_func() pode conter código arbitrário e, para que o compilador determine se ele retorna verdadeiro ou falso, ele precisará executar o código ou fazer algo que seja funcionalmente equivalente à execução do código.

A idéia de um compilador é que ele execute apenas uma análise parcial do código, simplificando assim o trabalho de um ambiente de execução separado. Se você realizar uma análise completa, não será mais um compilador.


Se você considerar o compilador como uma função c(), onde c(source)=compiled codee o ambiente em execução como r(), onde r(compiled code)=program output, para determinar a saída de qualquer código-fonte do qual você deve calcular o valor r(c(source code)). Se cálculo c()requer o conhecimento do valor r(c())para qualquer entrada, não há necessidade de um separado r()e c(): você só pode derivar uma função i()de c()tal que i(source)=program output.

Biziclop
fonte
2

Outros comentaram sobre o problema da parada e assim por diante. Eles geralmente se aplicam a partes de funções. No entanto, pode ser difícil / impossível saber se um tipo inteiro (classe / etc) é usado ou não.

No .NET / Java / JavaScript e em outros ambientes orientados a tempo de execução, não há nada que impeça os tipos de serem carregados via reflexão. Isso é popular nas estruturas de injeção de dependência e é ainda mais difícil de ser considerado diante da desserialização ou do carregamento dinâmico do módulo.

O compilador não pode saber se esses tipos seriam carregados. Seus nomes podem vir de arquivos de configuração externos em tempo de execução.

Você pode procurar por trepidação de árvores, um termo comum para ferramentas que tentam remover com segurança subgráficos de código não utilizados.

Drew Noakes
fonte
Eu não sei sobre Java e javascript, mas o .NET realmente possui um plug-in de novo compartilhamento para esse tipo de detecção de DI (chamado Agent Mulder). Obviamente, ele não conseguirá detectar arquivos de configuração, mas poderá detectar confit no código (que é muito mais popular).
Laços
2

Tome uma função

void DoSomeAction(int actnumber) 
{
    switch(actnumber) 
    {
        case 1: Action1(); break;
        case 2: Action2(); break;
        case 3: Action3(); break;
    }
}

Você pode provar que actnumbernunca será 2assim Action2()e nunca será chamado ...?

CiaPan
fonte
7
Se você puder analisar os chamadores da função, poderá sim.
abligh
2
@abligh Mas o compilador geralmente não pode analisar todo o código de chamada. De qualquer forma, mesmo que pudesse, a análise completa pode exigir apenas uma simulação de todos os fluxos de controle possíveis, o que é quase sempre impossível devido aos recursos e tempo necessários. Portanto, mesmo que teoricamente exista uma prova de que ' Action2()nunca será chamada', é impossível provar a afirmação na prática - não pode ser totalmente resolvida por um compilador . A diferença é como 'existe um número X' vs. 'podemos escrever o número X em decimal'. Para alguns X, o último nunca acontecerá, embora o primeiro seja verdadeiro.
CiaPan
Esta é uma resposta ruim. as outras respostas provam que é impossível saber se actnumber==2. Essa resposta apenas afirma que é difícil sem ao menos declarar uma complexidade.
MSalters 26/10/2015
1

Discordo do problema da parada. Eu não chamaria esse código de morto, embora, na realidade, nunca seja alcançado.

Em vez disso, vamos considerar:

for (int N = 3;;N++)
  for (int A = 2; A < int.MaxValue; A++)
    for (int B = 2; B < int.MaxValue; B++)
    {
      int Square = Math.Pow(A, N) + Math.Pow(B, N);
      float Test = Math.Sqrt(Square);
      if (Test == Math.Trunc(Test))
        FermatWasWrong();
    }

private void FermatWasWrong()
{
  Press.Announce("Fermat was wrong!");
  Nobel.Claim();
}

(Ignore os erros de tipo e de estouro) Código morto?

Loren Pechtel
fonte
2
O último teorema de Fermat foi provado em 1994. Portanto, uma implementação correta do seu método nunca executaria o FermatWasWrong. Eu suspeito que sua implementação executará FermatWasWrong, porque você pode atingir o limite de precisão de flutuadores.
Taemyr 23/10/2015
@Taemyr Aha! Este programa não testa corretamente o último teorema de Fermat; um contra-exemplo para o que faz teste é N = 3, a = 65.536, B = 65536 (que produz Teste = 0)
user253751
@immibis Sim, eu perdi que ele transbordaria int antes que a precisão nos carros alegóricos se tornasse um problema.
Taemyr 23/10/2015
@immibis Observe a parte inferior da minha postagem: Ignore os erros de tipo e de estouro. Eu estava apenas tomando o que considerava um problema não resolvido como base de uma decisão - sei que o código não é perfeito. É um problema que não pode ser forçado com força bruta de qualquer maneira.
Loren Pechtel 23/10/2015
-1

Veja este exemplo:

public boolean isEven(int i){

    if(i % 2 == 0)
        return true;
    if(i % 2 == 1)
        return false;
    return false;
}

O compilador não pode saber que um int só pode ser par ou ímpar. Portanto, o compilador deve ser capaz de entender a semântica do seu código. Como isso deve ser implementado? O compilador não pode garantir que o menor retorno nunca seja executado. Portanto, o compilador não pode detectar o código morto.

do utilizador
fonte
1
Umm, sério? Se eu escrever isso em C # + ReSharper, recebo algumas dicas. Segui-los finalmente me dá o código return i%2==0;.
Thomas Weller
10
Seu exemplo é muito simples para ser convincente. O caso específico de i % 2 == 0e i % 2 != 0nem exige raciocínio sobre o valor de um módulo inteiro uma constante (o que ainda é fácil de fazer), requer apenas a eliminação de subexpressão comum e o princípio geral (canonicalização, mesmo) que if (cond) foo; if (!cond) bar;pode ser simplificado if (cond) foo; else bar;. É claro que "entender a semântica" é um problema muito difícil, mas este post não mostra que é, nem mostra que a solução desse problema difícil é necessária para a detecção de código morto.
5
No seu exemplo, um compilador de otimização identificará a subexpressão comum i % 2e a puxará para uma variável temporária. Ele reconhecerá que as duas ifinstruções são mutuamente exclusivas e pode ser gravado como if(a==0)...else..., e então identifica que todos os caminhos de execução possíveis passam pelas duas primeiras returninstruções e, portanto, a terceira returné um código morto. (Um bom compilador de otimização é ainda mais agressivo: o GCC transformou meu código de teste em um par de operações de manipulação de bits).
Mark
1
Este exemplo é bom para mim. Representa o caso em que um compilador não conhece algumas circunstâncias factuais. O mesmo vale para if (availableMemory()<0) then {dead code}.
Little Santi
1
@LittleSanti: Na verdade, o GCC detectará que tudo o que você escreveu lá é código morto! Não é apenas a {dead code}parte. O GCC descobre isso ao provar que há um estouro de número inteiro assinado inevitável. Todo o código nesse arco no gráfico de execução é, portanto, código morto. O GCC pode até remover o ramo condicional que leva a esse arco.
MSalters