compiler-theory - programacion - interpretes
¿Por qué un compilador no puede resolver completamente la detección de código muerto? (13)
¡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)
devuelve
false
, entonces la línea
System.Console.WriteLn("Dead code!");
nunca se ejecutará, por lo que este programa realmente tiene un código muerto y el detector estaba equivocado.
Pero si devuelve
true
, entonces la línea
System.Console.WriteLn("Dead code!");
se ejecutará, y dado que 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.
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.
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.
Entonces, 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.
El compilador siempre carecerá de información contextual. Por ejemplo, puede saber 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.
El problema del código muerto está relacionado con el en.wikipedia.org/wiki/Halting_problem .
Alan Turing demostró que es imposible escribir un algoritmo general que se le dará un programa y poder 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 generalmente verifican que las cosas que se pueden probar 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 una
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.
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.
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.
No estoy de acuerdo con el problema de detención. No llamaría a ese código muerto, 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?
No sé si C ++ o Java tienen una función de tipo
Eval
, 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 el código se llame a través de la reflexión.
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 incluso un tipo completo (clase / etc.) se usa o no.
En .NET / Java / JavaScript y otros entornos en tiempo de ejecución no hay nada que impida que los tipos se carguen mediante reflexión. Esto es popular entre los marcos de inyección de dependencia y es aún más difícil de razonar ante 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.
Si el problema de detención es demasiado oscuro, piénselo de esta manera.
Tomemos 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 que
n
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 probablemente 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.
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()
, donde
c(source)=compiled code
, y el entorno de ejecución como
r()
, donde
r(compiled code)=program output
, entonces para determinar la salida de cualquier código fuente que tiene que calcular el valor de
r(c(source code))
.
Si el cálculo de
c()
requiere el conocimiento del valor de
r(c())
para cualquier entrada, no hay necesidad de un
r()
y
c()
por separado: puede derivar una función
i()
de
c()
tal que
i(source)=program output
.
Tomar una función
void DoSomeAction(int actnumber)
{
switch(actnumber)
{
case 1: Action1(); break;
case 2: Action2(); break;
case 3: Action3(); break;
}
}
¿Puedes probar que
actnumber
nunca será
2
para que
Action2()
nunca se llame ...?
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 bloque
else
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 que el bloque
else
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
boolMethod
siempre devuelve
true
.