为什么无法通过编译器完全解决死代码检测?


192

我在C或Java中使用的编译器具有防止死代码的功能(警告将永远不会执行任何行)。我的教授说,尽管如此,编译器永远无法完全解决这个问题。我想知道为什么会这样。我对编译器的实际编码不太熟悉,因为这是一个基于理论的类。但是我想知道他们检查什么(例如可能的输入字符串与可接受的输入等),以及为什么这样做不够。


91
进行循环,在其后放置代码,然后应用en.wikipedia.org/wiki/Halting_problem
zapl

48
if (isPrime(1234234234332232323423)){callSomething();}这段代码会调用什么吗?还有许多其他示例,其中决定是否调用一个函数比仅将其包括在程序中要昂贵得多。
idclev 463035818

33
public static void main(String[] args) {int counterexample = findCollatzConjectureCounterexample(); System.out.println(counterexample);}<-println调用无效代码吗?甚至人类都无法解决这个问题!
user253751

15
@ tobi303并不是一个很好的例子,分解质数确实很容易...只是不能相对有效地分解质数。停止问题不是NP所不能解决的。
en_Knight 2015年

57
@alephzero和en_Knight-你们都错了。isPrime是一个很好的例子。您假设该函数正在检查素数。也许那个号码是一个序列号,它是否在数据库中查找用户是否是Amazon Prime会员?这是一个很好的例子的原因是,知道条件是否恒定的唯一方法是实际执行isPrime函数。因此,现在这将要求编译器也必须是解释器。但这仍然无法解决数据不稳定的情况。
Dunk 2015年

Answers:


275

无效代码问题与停止问题有关

艾伦·图灵(Alan Turing)证明,不可能编写一种通用算法,该算法将被赋予程序并能够决定该程序是否停止所有输入。您可能可以为特定类型的程序编写这样的算法,但不能为所有程序编写。

这与无效代码有何关系?

停止问题可以简化为找到无效代码的问题。也就是说,如果找到一种可以检测任何程序中的无效代码的算法,则可以使用该算法测试任何程序是否将停止。既然已经证明这是不可能的,那么随之而来的是为死代码编写算法也是不可能的。

如何将死代码算法转换为停止问题算法?

简单:在要检查暂停的程序结束后添加一行代码。如果您的死代码检测器检测到该行已死,则说明程序不会停止。如果不是,则您知道程序停止了(进入最后一行,然后到达添加的代码行)。


编译器通常检查在编译时可以证明是死的东西。例如,取决于在编译时可以确定为假的条件的块。或之后的任何语句return(在相同范围内)。

这些是特定情况,因此可以为它们编写算法。可以为更复杂的情况编写算法(例如检查条件是否在语法上是矛盾的,因此总是返回false的算法),但是仍然不能涵盖所有可能的情况。


8
我认为停顿问题不适用于此处,因为作为现实世界中每个编译器的编译目标的每个平台都有可以访问的最大数据量,因此它将具有最大数量的状态,这意味着实际上是有限状态机,而不是图灵机。对于FSM而言,暂停问题并非无法解决,因此现实世界中的任何编译器都可以执行死代码检测。
价2015年

50
@Vality 64位处理器可以寻址2 ^ 64个字节。搜索所有256 ^(2 ^ 64)状态,玩得开心!
丹尼尔·瓦格纳

82
@DanielWagner这应该不是问题。搜索256^(2^64)状态为O(1),因此死代码检测可以在多项式时间内完成。
aebabis 2015年

13
@Leliel,这很讽刺。
保罗·德雷珀

44
@Vality:大多数现代计算机都具有磁盘,输入设备,网络通信等。任何完整的分析都必须考虑所有这些设备-实际上包括互联网以及与之相连的所有内容。这不是一个棘手的问题。
纳特

77

好吧,让我们以经典的证据证明停止问题的不确定性,然后将停止检测器更改为死代码检测器!

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

如果YourVendor.Compiler.HasDeadCode(quine_text)return false,则该行将System.Console.WriteLn("Dead code!");永远不会执行,因此该程序实际上确实具有无效代码,并且检测器错误。

但是,如果返回true,则将System.Console.WriteLn("Dead code!");执行该行,并且由于程序中没有更多代码,因此根本没有死代码,因此检测器还是错误的。

因此,有了死代码检测器,它仅返回“存在死代码”或“没有死代码”有时必须产生错误的答案。


1
如果我正确理解了您的论点,那么从技术上讲,另一种选择是不可能编写一个完全是死代码检测器的命令,但在一般情况下可以编写一个死代码检测器。:-)
abligh 2015年

1
戈德尔式答案的增量。
Jared Smith

@abligh gh,那是一个不好的选择。我实际上并不是将死代码检测器的源代码提供给自己,而是使用它的程序的源代码。当然,在某个时候它可能必须查看自己的代码,但这是它的事。
Joker_vD 2015年

65

如果停止问题太晦涩难懂,请以这种方式考虑。

采取一个数学问题,该问题被认为对所有正整数n都成立,但尚未证明对每一个n都成立哥德巴赫猜想就是一个很好的例子,任何大于2的正偶整数都可以用两个质数之和表示。然后(使用适当的bigint库)运行此程序(后接伪代码):

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

实施isGoldbachsConjectureTrueFor()就留给读者做练习,但为此目的可能是在所有的素数进行简单迭代小于n

现在,从逻辑上讲,以上内容必须等于:

 for (; ;) {
 }

(即无限循环)或

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

因为哥德巴赫的猜想必须是正确的或不正确的。如果编译器始终可以消除无效代码,则无论哪种情况,肯定都需要消除无效代码。但是,这样做至少会使您的编译器需要解决任意困难的问题。我们可以提供问题可证明的辛苦,那就要解决(如NP完全问题)来确定的码位以消除。例如,如果我们采用以下程序:

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

我们知道该程序将打印出“找到的SHA值”或“找不到SHA值”(如果您能告诉我哪个是正确的,请加分)。但是,为了使编译器能够合理地优化,需要进行2 ^ 2048次迭代。实际上,这将是一个巨大的优化,因为我预计上述程序将(或可能)运行到宇宙热死为止,而不是在没有优化的情况下打印任何内容。


4
到目前为止,这是最好的答案+1
让·吉恩

2
使事情变得特别有趣的是,当涉及到循环将终止时,C标准允许或不允许的歧义。允许编译器将缓慢的计算推迟到可能需要或可能不使用它们的结果,直到真正需要它们的结果时才有意义。即使编译器无法证明计算终止,该优化在某些情况下也可能有用。
2015年

2
2 ^ 2048次迭代?即使深思熟虑也会放弃。
彼得·莫滕森

即使目标是64个十六进制数字的随机字符串,它也会非常有可能打印“找到的SHA值”。除非sha256返回一个字节数组,否则字节数组不等于您语言中的字符串。
user253751

4
Implementation of isGoldbachsConjectureTrueFor() is left as an exercise for the reader这让我笑了。
biziclop 2015年

34

我不知道C ++或Java是否具有Eval类型函数,但是许多语言都允许您通过name调用方法。考虑下面的(人为)VBA示例。

Dim methodName As String

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

Application.Run(methodName)

直到运行时才知道要调用的方法的名称。因此,根据定义,编译器无法绝对确定地知道永远不会调用特定方法。

实际上,以通过名称调用方法的示例为例,甚至不需要分支逻辑。简单地说

Application.Run("Bar")

是编译器无法确定的。编译代码时,所有编译器都知道将某个字符串值传递给该方法。它直到运行时才检查该方法是否存在。如果未在其他地方调用该方法,则通过更常规的方法,尝试查找无效方法可能会返回误报。任何允许通过反射调用代码的语言都存在相同的问题。


2
在Java(或C#)中,可以通过反射来完成。C ++,您可能可以使用宏来完成一些工作。不会很漂亮,但是C ++很少。
Darrel Hoffman

6
@DarrelHoffman-在将代码提供给编译器之前会扩展宏,因此宏绝对不是您要执行的操作。函数的指针就是您将如何执行此操作。我已经好几年没有使用C ++了,如果我的确切类型名称错误,请原谅,但是您可以将字符串映射存储到函数指针。然后,从用户输入中接受一个字符串,然后在映射中查找该字符串,然后执行所指向的函数。
ArtOfWarfare 2015年

1
@ArtOfWarfare我们不是在谈论如何实现。显然,可以对代码进行语义分析以找到这种情况,关键是编译器没有这样做。它可以,也许可以,但是事实并非如此。
RubberDuck

3
@ArtOfWarfare:如果您想挑剔,请确保。我认为预处理器是编译器的一部分,尽管从技术上讲我不知道。无论如何,函数指针可能会违反以下规则:在任何地方都不能直接引用函数-它们就像指针一样,而不是直接调用,就像C#中的委托一样。通常,C ++对于编译器来说很难预测,因为它具有许多间接的处理方式。即使像“查找所有引用”这样简单的任务也不是一件容易的事,因为它们可以隐藏在typedef,宏等中。毫不奇怪,它无法轻松地找到无效代码。
Darrel Hoffman

1
您甚至不需要动态方法调用即可解决此问题。尚未编写的函数可以调用任何公共方法,该函数将依赖于Java或C#或任何其他已编译语言以及具有某种动态链接机制的已编译类。如果编译器将它们消除为“死代码”,那么我们将无法打包预编译的库以进行分发(NuGet,jar,带有二进制组件的Python轮子)。
2015年

12

高级编译器可以检测和删除无条件的死代码。

但是也有条件的死代码。那是在编译时无法识别的代码,只能在运行时检测到。例如,根据用户的喜好,可将软件配置为包括或排除某些功能,从而使某些代码段在特定情况下似乎已失效。那不是真正的死代码。

有一些特定的工具可以执行测试,解决依赖关系,删除条件无效代码并在运行时重新组合有用的代码以提高效率。这称为动态死代码消除。但是正如您所看到的,它超出了编译器的范围。


5
“高级编译器可以检测并删除无条件的死代码。” 这似乎不太可能。代码失效可能取决于给定功能的结果,并且给定功能可以解决任意问题。因此,您的声明断言高级编译器可以解决任意问题。
塔米尔(Taemyr),2015年

6
@Taemyr然后就不知道它是无条件地死了,是吗?
JAB 2015年

1
@Taemyr您似乎误解了“无条件”一词。如果代码无效性取决于函数的结果,那么它就是条件无效代码。“条件”是功能的结果。要成为“无条件”,就不必依赖任何结果。
Kyeotic

12

一个简单的例子:

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

现在假定端口0x100设计为仅返回0或1。在那种情况下,编译器无法确定该else块将永远不会执行。

但是,在此基本示例中:

bool boolVal = /*anything boolean*/;

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

在这里,编译器可以计算出该else块是无效代码。因此,仅当编译器具有足够的数据来找出该死代码时,编译器才可以警告该死代码,并且编译器应该知道如何应用该数据,以便确定给定的块是否为死代码。

编辑

有时数据在编译时不可用:

// File a.cpp
bool boolMethod();

bool boolVal = boolMethod();

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

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

在编译a.cpp时,编译器无法知道boolMethod总是返回true


1
尽管编译器不知道这是完全正确的,但我认为也要问链接器是否可以知道是该问题的实质。
Casey Kuball 2015年

1
@Darthfett这不是链接程序的责任。链接器不分析已编译代码的内容。链接程序(通常来说)只是链接方法和全局数据,而不关心内容。但是,某些编译器确实可以选择连接源文件(例如ICC),然后执行优化。在这种情况下,将覆盖EDIT下的情况,但是此选项将影响编译时间,尤其是在项目较大时。
罗伯

这个答案似乎误导了我。您给出了两个不可能的例子,因为并非所有信息都可用,但是您是否不应该说即使有信息也不可能吗?
安东·戈洛夫

@AntonGolovIt并非总是如此。在许多情况下,只要有信息,编译器就可以检测出无效代码并对其进行优化。
罗伯

@abforce只是一段代码。可能还有其他事情。:)
Alex Lop。

4

编译器将始终缺少一些上下文信息。例如,您可能知道,双精度值永远不会超过2,因为这是您从库中使用的数学函数的功能。编译器甚至看不到库中的代码,并且它永远无法知道所有数学函数的所有功能,也无法检测到所有奇怪而复杂的方法来实现它们。


4

编译器不一定会看到整个程序。我可以有一个调用共享库的程序,该库会回调我程序中未直接调用的函数。

因此,如果在运行时更改该库,则针对该库而死的函数可能会变为活动状态。


3

如果编译器可以准确地消除所有无效代码,则将其称为解释器

考虑以下简单情况:

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

my_func() 可以包含任意代码,并且为了使编译器确定返回的是true还是false,它必须运行代码或执行与运行代码等效的操作。

编译器的思想是仅执行部分代码分析,从而简化了单独运行环境的工作。如果执行完整的分析,则不再是编译器。


如果您将编译器视为函数c()所在的地方c(source)=compiled code,而将运行环境视为r()所在的地方r(compiled code)=program output,那么要确定任何源代码的输出,您必须计算的值r(c(source code))。如果计算c()需要的价值的知识r(c())对任何输入,就不需要单独的r()c():你可以得到一个函数i()c()这样i(source)=program output


2

其他人则对暂停问题等发表了评论。这些通常适用于部分功能。但是,很难甚至不可能知道是否使用了整个类型(类/等)。

在.NET / Java / JavaScript和其他运行时驱动的环境中,没有任何停止类型通过反射加载。这在依赖项注入框架中很流行,面对反序列化或动态模块加载时,甚至更难以推理。

编译器无法知道是否会加载此类类型。它们的名称可能在运行时来自外部配置文件。

您可能想四处寻找树震动,这是尝试安全删除未使用的代码子图的工具的常用术语。


我不了解Java和javascript,但.NET实际上有一个用于此类DI检测的Resharper插件(称为Agent Mulder)。当然,它不能检测配置文件,但是可以检测代码中的匹配(流行得多)。
领带

2

参加功能

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

您能证明actnumber永远不会2如此Action2()吗?


7
如果您可以分析函数的调用者,那么您可以。
2015年

2
@abligh但是编译器通常无法分析所有调用代码。无论如何,即使可能,完整的分析可能只需要模拟所有可能的控制流,由于所需的资源和时间,几乎总是不可能的。因此,即使理论上存在Action2()永不被调用”的证明,也无法在实践中证明这一主张- 编译器无法完全解决。区别是“存在数字X”与“我们可以将数字X用十进制写”。对于某些X,尽管前者是正确的,但后者永远不会发生。
CiaPan 2015年

这是一个很差的答案。其他答案证明不可能知道是否actnumber==2。这个答案只是声称即使没有说明复杂性也很难。
MSalters 2015年

1

我不同意暂停问题。我不会称此类代码为死,即使实际上它永远也不会到达。

相反,让我们考虑:

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

(忽略类型和溢出错误)死代码?


2
Fermat的最后一个定理在1994年得到证明。因此,正确实施您的方法永远不会运行FermatWasWrong。我怀疑您的实现将运行FermatWasWrong,因为您可能会达到浮动精度的极限。
塔米尔(Taemyr)

@Taemyr Aha!该程序无法正确测试费马的最后定理;其测试内容的反例是N = 3,A = 65536,B = 65536(得出Test = 0)
user253751

@immibis是的,我想念它会在浮点数的精度成为问题之前溢出int。
塔米尔(Taemyr),2015年

@immibis请注意我的帖子的底部:忽略类型和溢出错误。我只是以我认为尚未解决的问题为决策依据-我知道代码并不完美。无论如何,这是无法解决的问题。
罗伦·佩希特尔

-1

看这个例子:

public boolean isEven(int i){

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

编译器无法知道int只能是偶数或奇数。因此,编译器必须能够理解您代码的语义。应该如何实施?编译器无法确保永远不会执行最低的回报。因此,编译器无法检测到无效代码。


1
嗯,真的吗?如果我用C#+ ReSharper编写该代码,则会得到一些提示。跟随他们终于给了我代码return i%2==0;
Thomas Weller

10
您的示例太简单了,无法令人信服。的具体情况i % 2 == 0i % 2 != 0甚至不需要推理的整数值模常数(这仍然是很容易做到),它只需要公共子表达式消除和一般原则(标准化,甚至),其if (cond) foo; if (!cond) bar;可以简化为if (cond) foo; else bar;。当然,“理解语义”是一个非常棘手的问题,但是这篇文章既没有说明这一点,也没有表明解决此难题对于检测死代码非常必要。

5
在您的示例中,优化的编译器将发现公共子表达式i % 2并将其拉出到临时变量中。然后,它将认识到这两个if语句是互斥的,并且可以写为if(a==0)...else...,然后发现所有可能的执行路径都经过前两个return语句,因此第三个return语句是无效代码。(一个好的优化编译器更具攻击性:GCC将我的测试代码转换为一对位操作)。
2015年

1
这个例子对我有好处。它代表了编译器不了解某些实际情况的情况。同样的道理if (availableMemory()<0) then {dead code}
Little Santi 2015年

1
@LittleSanti:实际上,GCC会检测到您编写的所有内容都有死代码!这不只是{dead code}一部分。GCC通过证明不可避免的有符号整数溢出发现了这一点。因此,执行图中该弧上的所有代码均为无效代码。GCC甚至可以删除导致该弧的条件分支。
MSalters 2015年
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.