¿Por qué un compilador no puede resolver completamente la detección de código muerto?


192

Los compiladores que he estado usando en C o Java tienen prevención de código muerto (advertencia cuando una línea nunca se ejecutará). Mi profesor dice que este problema nunca puede ser resuelto completamente por los compiladores. Me preguntaba por qué es eso. No estoy muy familiarizado con la codificación real de los compiladores, ya que esta es una clase basada en la teoría. Pero me preguntaba qué verifican (como posibles cadenas de entrada frente a entradas aceptables, etc.) y por qué eso es insuficiente.


9191
hacer un bucle, poner código después de él, luego aplicar en.wikipedia.org/wiki/Halting_problem
zapl

48
if (isPrime(1234234234332232323423)){callSomething();}¿Este código alguna vez llamará algo o no? Hay muchos otros ejemplos, donde decidir si alguna función se llama alguna vez es mucho más costoso que simplemente incluirlo en el programa.
idclev 463035818

33
public static void main(String[] args) {int counterexample = findCollatzConjectureCounterexample(); System.out.println(counterexample);}<- ¿Está el código muerto de la llamada println? ¡Ni siquiera los humanos pueden resolver eso!
user253751

15
@ tobi303 no es un gran ejemplo, es realmente fácil factorizar números primos ... simplemente no factorizarlos de manera relativamente eficiente. El problema de detención no está en NP, es insoluble.
en_Knight

57
@alephzero y en_Knight: ambos están equivocados. isPrime es un gran ejemplo. Supuso que la función está buscando un número primo. ¿Tal vez ese número era un número de serie y hace una búsqueda en la base de datos para ver si el usuario es miembro de Amazon Prime? La razón por la que es un gran ejemplo es porque la única forma de saber si la condición es constante o no es ejecutar realmente la función isPrime. Entonces, eso requeriría que el compilador también sea un intérprete. Pero eso aún no resolvería aquellos casos en los que los datos son volátiles.
Dunk

Respuestas:


275

El problema del código muerto está relacionado con el problema de detención .

Alan Turing demostró que es imposible escribir un algoritmo general que recibirá un programa y podrá decidir si ese programa se detiene para todas las entradas. Es posible que pueda escribir dicho algoritmo para tipos específicos de programas, pero no para todos los programas.

¿Cómo se relaciona esto con el código muerto?

El problema de detención se puede reducir al problema de encontrar código muerto. Es decir, si encuentra un algoritmo que puede detectar código muerto en cualquier programa, puede usar ese algoritmo para probar si algún programa se detendrá. Como se ha demostrado que eso es imposible, se deduce que escribir un algoritmo para código muerto también es imposible.

¿Cómo transfieres un algoritmo para código muerto a un algoritmo para el problema de detención?

Simple: agrega una línea de código después del final del programa que desea verificar para detener. Si su detector de código muerto detecta que esta línea está muerta, entonces sabe que el programa no se detiene. Si no es así, entonces sabe que su programa se detiene (llega a la última línea y luego a la línea de código agregada).


Los compiladores usualmente verifican que las cosas que pueden ser probadas en tiempo de compilación estén muertas. Por ejemplo, bloques que dependen de condiciones que se pueden determinar como falsas en tiempo de compilación. O cualquier declaración después de un return(dentro del mismo alcance).

Estos son casos específicos y, por lo tanto, es posible escribir un algoritmo para ellos. Es posible escribir algoritmos para casos más complicados (como un algoritmo que verifica si una condición es sintácticamente una contradicción y, por lo tanto, siempre será falsa), pero aún así, eso no cubriría todos los casos posibles.


8
Yo diría que el problema de detención no es aplicable aquí, ya que cada plataforma que es un objetivo de compilación de cada compilador en el mundo real tiene una cantidad máxima de datos a los que puede acceder, por lo tanto, tendrá un número máximo de estados, lo que significa que es de hecho, una máquina de estados finitos, no una máquina de turing. El problema de detención no es insoluble para los FSM, por lo que cualquier compilador en el mundo real puede realizar la detección de código muerto.
Vality

50
Los procesadores @Vality de 64 bits pueden direccionar 2 ^ 64 bytes. ¡Diviértete buscando en los 256 ^ (2 ^ 64) estados!
Daniel Wagner

82
@DanielWagner Esto no debería ser un problema. La búsqueda de 256^(2^64)estados es O(1), por lo que la detección de código muerto se puede hacer en tiempo polinómico.
aebabis

13
@Leliel, eso fue sarcasmo.
Paul Draper

44
@Vality: la mayoría de las computadoras modernas tienen discos, dispositivos de entrada, comunicaciones de red, etc. Cualquier análisis completo tendría que considerar todos esos dispositivos, incluido, literalmente, Internet y todo lo que está conectado a él. Este no es un problema manejable.
Nat

77

¡Bien, tomemos la prueba clásica de la indecidibilidad del problema de detención y cambiemos el detector de detención a un detector de código muerto!

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!");
        }
    }
}

Si YourVendor.Compiler.HasDeadCode(quine_text)vuelvefalse , entonces la línea System.Console.WriteLn("Dead code!");no será nunca ejecutada, por lo que este programa realmente no tiene código muerto, y el detector estaba mal.

Pero si regresa true, entonces la línea System.Console.WriteLn("Dead code!");se ejecutará, y como no hay más código en el programa, no hay ningún código muerto, por lo que nuevamente, el detector estaba equivocado.

Entonces, ahí lo tiene, un detector de código muerto que devuelve solo "Hay código muerto" o "No hay código muerto" a veces debe dar respuestas incorrectas.


1
Si he entendido su argumento correctamente, técnicamente otra opción sería que no es posible escribir un detector de código muerto, pero es posible escribir un detector de código muerto en el caso general. :-)
abligh

1
incremento para la respuesta de Godelian.
Jared Smith

@abligh Ugh, esa fue una mala elección de palabras. En realidad, no estoy entregando el código fuente del detector de código muerto, sino el código fuente del programa que lo usa. Seguramente, en algún momento probablemente tendría que mirar su propio código, pero es su negocio.
Joker_vD

65

Si el problema de detención es demasiado oscuro, piénselo de esta manera.

Tome un problema matemático que se cree que es verdadero para todos los enteros positivos n , pero no se ha demostrado que sea cierto para todos los n . Un buen ejemplo sería la conjetura de Goldbach , de que cualquier entero positivo incluso mayor que dos puede representarse por la suma de dos números primos. Luego (con una biblioteca bigint apropiada) ejecute este programa (sigue el 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);
     }
 }

La implementación de isGoldbachsConjectureTrueFor()se deja como un ejercicio para el lector, pero para este propósito podría ser una simple iteración sobre todos los números primos menores quen

Ahora, lógicamente, lo anterior debe ser el equivalente de:

 for (; ;) {
 }

(es decir, un bucle infinito) o

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

como la conjetura de Goldbach debe ser verdadera o no verdadera. Si un compilador siempre pudiera eliminar el código muerto, definitivamente habría un código muerto para eliminar aquí en cualquier caso. Sin embargo, al hacerlo al menos su compilador necesitaría resolver problemas arbitrariamente difíciles. Podríamos proporcionar problemas demostrablemente difíciles que tendría que resolver (por ejemplo, problemas de NP completo) para determinar qué bit de código eliminar. Por ejemplo, si tomamos 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 el programa imprimirá "Valor SHA encontrado" o "Valor SHA no encontrado" (puntos de bonificación si puede decirme cuál es el verdadero). Sin embargo, para que un compilador pueda optimizar razonablemente eso tomaría del orden de 2 ^ 2048 iteraciones. De hecho, sería una gran optimización, ya que predigo que el programa anterior se ejecutará (o podría) hasta la muerte por calor del universo en lugar de imprimir cualquier cosa sin optimización.


44
Es la mejor respuesta con diferencia +1
jean

2
Lo que hace que las cosas sean particularmente interesantes es la ambigüedad sobre lo que el estándar C permite o no permite cuando se trata de asumir que los bucles terminarán. Es valioso permitir que un compilador difiera los cálculos lentos cuyos resultados pueden o no usarse hasta el punto en el que sus resultados serían realmente necesarios; Esta optimización podría ser útil en algunos casos incluso si el compilador no puede probar que los cálculos terminan.
supercat

2
2 ^ 2048 iteraciones? Incluso el Pensamiento Profundo se rendiría.
Peter Mortensen

Imprimirá el "valor SHA encontrado" con una probabilidad muy alta, incluso si ese objetivo era una cadena aleatoria de 64 dígitos hexadecimales. A menos que sha256devuelva una matriz de bytes y las matrices de bytes no se comparan con cadenas iguales en su idioma.
user253751

44
Implementation of isGoldbachsConjectureTrueFor() is left as an exercise for the readerEsto me hizo reír.
biziclop

34

No sé si C ++ o Java tienen una Evalfunción de tipo, pero muchos lenguajes le permiten llamar a los métodos por su nombre . Considere el siguiente (inventado) ejemplo de VBA.

Dim methodName As String

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

Application.Run(methodName)

El nombre del método a llamar es imposible de saber hasta el tiempo de ejecución. Por lo tanto, por definición, el compilador no puede saber con absoluta certeza que nunca se llama a un método en particular.

En realidad, dado el ejemplo de llamar a un método por su nombre, la lógica de ramificación ni siquiera es necesaria. Simplemente diciendo

Application.Run("Bar")

Es más de lo que el compilador puede determinar. Cuando se compila el código, todo lo que el compilador sabe es que cierto valor de cadena se pasa a ese método. No verifica si ese método existe hasta el tiempo de ejecución. Si el método no se llama a otra parte, a través de métodos más normales, un intento de encontrar métodos muertos puede devolver falsos positivos. El mismo problema existe en cualquier lenguaje que permita que se invoque el código mediante reflexión.


2
En Java (o C #), esto podría hacerse con reflexión. C ++ probablemente podría lograr algo de desagradable usando macros para hacerlo. No sería bonito, pero C ++ rara vez lo es.
Darrel Hoffman

66
@DarrelHoffman: las macros se expanden antes de que el código se entregue al compilador, por lo que las macros definitivamente no son cómo haría esto. Punteros a las funciones es cómo haría esto. No he usado C ++ en años, así que discúlpeme si mis nombres de tipo exactos son incorrectos, pero puede almacenar un mapa de cadenas para punteros de función. Luego, tenga algo que acepte una cadena de entrada del usuario, busque esa cadena en el mapa y luego ejecute la función a la que apunta.
ArtOfWarfare

1
@ArtOfWarfare no estamos hablando de cómo se podría hacer. Obviamente, se puede hacer un análisis semántico del código para encontrar esta situación, el punto era que el compilador no . Podría, posiblemente, tal vez, pero no lo hace.
RubberDuck

3
@ArtOfWarfare: Si quieres hacer trampas, seguro. Considero que el preprocesador es parte del compilador, aunque sé que técnicamente no lo es. De todos modos, los punteros de función pueden romper la regla de que no se hace referencia directa a las funciones en ninguna parte: son, como un puntero en lugar de una llamada directa, muy parecidas a un delegado en C #. En general, C ++ es mucho más difícil de predecir para un compilador, ya que tiene muchas formas de hacer las cosas indirectamente. Incluso tareas tan simples como "buscar todas las referencias" no son triviales, ya que pueden esconderse en typedefs, macros, etc. No sorprende que no pueda encontrar código muerto fácilmente.
Darrel Hoffman

1
Ni siquiera necesita llamadas a métodos dinámicos para enfrentar este problema. Se puede llamar a cualquier método público mediante una función aún no escrita que dependerá de la clase ya compilada en Java o C # o cualquier otro lenguaje compilado con algún mecanismo para la vinculación dinámica. Si los compiladores los eliminaran como "código muerto", entonces no podríamos empaquetar bibliotecas precompiladas para su distribución (NuGet, tarros, ruedas de Python con componente binario).
jpmc26

12

Los compiladores avanzados pueden detectar y eliminar el código muerto incondicional.

Pero también hay un código muerto condicional. Es un código que no se puede conocer en el momento de la compilación y solo se puede detectar durante el tiempo de ejecución. Por ejemplo, un software puede ser configurable para incluir o excluir ciertas características dependiendo de la preferencia del usuario, haciendo que ciertas secciones de código parezcan inactivas en escenarios particulares. Eso no es ser un verdadero código muerto.

Existen herramientas específicas que pueden hacer pruebas, resolver dependencias, eliminar el código muerto condicional y recombinar el código útil en tiempo de ejecución para mayor eficiencia. Esto se llama eliminación dinámica de código muerto. Pero como puede ver, está más allá del alcance de los compiladores.


55
"Los compiladores avanzados pueden detectar y eliminar el código muerto incondicional". Esto no parece probable. El código muerto puede depender del resultado de una función dada, y esa función dada puede resolver problemas arbitrarios. Por lo tanto, su declaración afirma que los compiladores avanzados pueden resolver problemas arbitrarios.
Taemyr

66
@Taemyr Entonces no se sabría que está incondicionalmente muerto, ¿verdad?
JAB

1
@Taemyr Parece que no entiendes la palabra "incondicional". Si el código muerto depende del resultado de una función, entonces es un código muerto condicional. La "condición" es el resultado de la función. Para ser "incondicional" tendría que no depende de ningún resultado.
Kyeotic

12

Un simple ejemplo:

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;
}

Ahora suponga que el puerto 0x100 está diseñado para devolver solo 0 o 1. En ese caso, el compilador no puede darse cuenta de que el elsebloque nunca se ejecutará.

Sin embargo, en este ejemplo básico:

bool boolVal = /*anything boolean*/;

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

Aquí el compilador puede calcular el else bloque es un código muerto. Por lo tanto, el compilador puede advertir sobre el código muerto solo si tiene suficientes datos para descubrir el código muerto y también debe saber cómo aplicar esos datos para averiguar si el bloque dado es un código muerto.

EDITAR

A veces los datos simplemente no están disponibles en el momento de la compilación:

// File a.cpp
bool boolMethod();

bool boolVal = boolMethod();

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

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

Mientras compila a.cpp, el compilador no puede saber que boolMethodsiempre regresa true.


1
Si bien es estrictamente cierto que el compilador no lo sabe, creo que está en el espíritu de la pregunta también preguntar si el enlazador puede saberlo.
Casey Kuball

1
@Darthfett No es responsabilidad del vinculador . Linker no analiza el contenido del código compilado. El vinculador (en términos generales) solo vincula los métodos y los datos globales, no le importa el contenido. Sin embargo, algunos compiladores tienen la opción de concatenar los archivos de origen (como ICC) y luego realizar la optimización. En tal caso, el caso bajo EDIT está cubierto, pero esta opción afectará el tiempo de compilación, especialmente cuando el proyecto es grande.
Alex Lop.

Esta respuesta me parece engañosa; está dando dos ejemplos donde no es posible porque no toda la información está disponible, pero ¿no debería decir que es imposible incluso si la información está ahí?
Anton Golov

@AntonGolov No siempre es cierto. En muchos casos, cuando la información está allí, los compiladores pueden detectar el código muerto y optimizarlo.
Alex Lop.

@abforce solo un bloque de código. Podría haber sido cualquier otra cosa. :)
Alex Lop.

4

El compilador siempre carecerá de información contextual. Por ejemplo, es posible que sepa que un valor doble nunca es 2, porque esa es una característica de la función matemática que utiliza desde una biblioteca. El compilador ni siquiera ve el código en la biblioteca, y nunca puede conocer todas las características de todas las funciones matemáticas y detectar todas las formas complicadas y complicadas de implementarlas.


4

El compilador no necesariamente ve todo el programa. Podría tener un programa que llame a una biblioteca compartida, que vuelve a llamar a una función en mi programa que no se llama directamente.

Por lo tanto, una función que está muerta con respecto a la biblioteca con la que se compila podría cobrar vida si esa biblioteca se cambiara en tiempo de ejecución.


3

Si un compilador pudiera eliminar todo el código muerto con precisión, se lo llamaría intérprete .

Considere este escenario simple:

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

my_func() puede contener código arbitrario y para que el compilador determine si devuelve verdadero o falso, tendrá que ejecutar el código o hacer algo que sea funcionalmente equivalente a ejecutar el código.

La idea de un compilador es que solo realiza un análisis parcial del código, simplificando así el trabajo de un entorno de ejecución separado. Si realiza un análisis completo, ya no es un compilador.


Si considera el compilador como una función c(), where c(source)=compiled code, y el entorno de ejecución como r(), where r(compiled code)=program output, para determinar la salida de cualquier código fuente, debe calcular el valor r(c(source code)). Si el cálculo c()requiere el conocimiento del valor de r(c())cualquier entrada, no hay necesidad de un separado r()y c(): puede derivar una función i()de c()tal manera i(source)=program output.


2

Otros han comentado sobre el problema de detención y demás. Estos generalmente se aplican a porciones de funciones. Sin embargo, puede ser difícil / imposible saber si se usa o no un tipo completo (clase / etc.).

En .NET / Java / JavaScript y otros entornos en tiempo de ejecución no hay nada que impida que los tipos se carguen a través de la reflexión. Esto es popular con los marcos de inyección de dependencia, y es aún más difícil de razonar frente a la deserialización o la carga dinámica del módulo.

El compilador no puede saber si esos tipos se cargarían. Sus nombres podrían provenir de archivos de configuración externos en tiempo de ejecución.

Es posible que desee buscar sacudidas de árboles, que es un término común para las herramientas que intentan eliminar de forma segura subgrafías de código no utilizadas.


No sé acerca de Java y JavaScript, pero .NET en realidad tiene un complemento de intercambio para ese tipo de detección de DI (llamado Agente Mulder). Por supuesto, no podrá detectar archivos de configuración, pero sí puede detectar confits en el código (que es mucho más popular).
Corbatas

2

Tomar una función

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

¿Puedes demostrar que actnumbernunca será 2así que Action2()nunca se llama ...?


77
Si puede analizar los llamadores de la función, entonces puede, sí.
Abligh

2
@abligh Pero el compilador generalmente no puede analizar todo el código de llamada. De todos modos, incluso si pudiera, el análisis completo podría requerir solo una simulación de todos los flujos de control posibles, lo que casi siempre es imposible debido a los recursos y el tiempo necesarios. Entonces, incluso si en teoría existe una prueba de que " Action2()nunca se llamará", es imposible probar la afirmación en la práctica: un compilador no puede resolverla por completo . La diferencia es como 'existe un número X' vs. 'podemos escribir el número X en decimal'. Para algunas X, lo último nunca sucederá aunque lo primero sea cierto.
CiaPan

Esta es una respuesta pobre. Las otras respuestas demuestran que es imposible saber si actnumber==2. Esta respuesta simplemente afirma que es difícil sin siquiera indicar una complejidad.
MSalters

1

No estoy de acuerdo con el problema de detención. No llamaría muerto a ese código, aunque en realidad nunca se alcanzará.

En cambio, consideremos:

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 el tipo y los errores de desbordamiento) ¿Código muerto?


2
El último teorema de Fermat se probó en 1994. Por lo tanto, una implementación correcta de su método nunca ejecutaría FermatWasWrong. Sospecho que su implementación ejecutará FermatWasWrong, porque puede alcanzar el límite de precisión de los flotadores.
Taemyr

@Taemyr Aha! Este programa no prueba correctamente el último teorema de Fermat; un contraejemplo para lo que prueba es N = 3, A = 65536, B = 65536 (lo que arroja Test = 0)
user253751

@immibis Sí, perdí que se desbordará int antes de que la precisión en los flotadores se convierta en un problema.
Taemyr

@immibis Tenga en cuenta la parte inferior de mi publicación: ignore el tipo y los errores de desbordamiento. Estaba tomando lo que pensé que era un problema sin resolver como base de una decisión: sé que el código no es perfecto. Es un problema que no puede ser forzado de ninguna manera.
Loren Pechtel el

-1

Mira este ejemplo:

public boolean isEven(int i){

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

El compilador no puede saber que un int solo puede ser par o impar. Por lo tanto, el compilador debe poder comprender la semántica de su código. ¿Cómo se debe implementar esto? El compilador no puede garantizar que el rendimiento más bajo nunca se ejecutará. Por lo tanto, el compilador no puede detectar el código muerto.


1
Umm, enserio? Si escribo eso en C # + ReSharper obtengo un par de pistas. Seguirlos finalmente me da el código return i%2==0;.
Thomas Weller

10
Su ejemplo es demasiado simple para ser convincente. El caso específico de i % 2 == 0y i % 2 != 0ni siquiera requiere razonamiento sobre el valor de un módulo entero una constante (que todavía es fácil de hacer), solo requiere la eliminación de la subexpresión común y el principio general (canonicalización, incluso) que if (cond) foo; if (!cond) bar;se puede simplificar if (cond) foo; else bar;. Por supuesto, "comprender la semántica" es un problema muy difícil, pero esta publicación no muestra que lo sea, ni muestra que resolver este problema es necesario para la detección de código muerto.

55
En su ejemplo, un compilador optimizador detectará la subexpresión común i % 2y la extraerá en una variable temporal. Luego reconocerá que las dos ifdeclaraciones son mutuamente excluyentes y pueden escribirse como if(a==0)...else..., y luego detectará que todas las rutas de ejecución posibles pasan por las dos primeras returndeclaraciones y, por lo tanto, la tercera returndeclaración es código muerto. (Un buen compilador de optimización es aún más agresivo: GCC convirtió mi código de prueba en un par de operaciones de manipulación de bits).
Mark

1
Este ejemplo es bueno para mi. Representa el caso cuando un compilador no conoce algunas circunstancias de hecho. Lo mismo vale para if (availableMemory()<0) then {dead code}.
Little Santi

1
@LittleSanti: ¡En realidad, GCC detectará que todo lo que escribiste allí es un código muerto! No es solo la {dead code}parte. GCC descubre esto al demostrar que hay un desbordamiento de entero con signo inevitable. Todo el código en ese arco en el gráfico de ejecución es, por lo tanto, código muerto. GCC puede incluso eliminar la rama condicional que conduce a ese arco.
MSalters
Al usar nuestro sitio, usted reconoce que ha leído y comprende nuestra Política de Cookies y Política de Privacidad.
Licensed under cc by-sa 3.0 with attribution required.