编译器构造/处理错误
即使是有经验的程序员也会犯错误,因此他们会感谢编译器在识别错误方面提供的任何帮助。新手程序员可能会犯很多错误,并且可能不太了解编程语言,因此他们需要清晰、准确、无术语的错误报告。特别是在学习环境中,编译器的主要功能是报告源程序中的错误;作为偶尔的副作用,您实际上可能会得到翻译和运行的程序。
作为一般规则,编译器编写者应该尝试用比较简单的英语表达错误消息,而不是参考官方编程语言定义(一些语言定义使用了一些模糊或专门的术语)。
例如,消息“无法将字符串转换为整数”可能比“未找到强制转换”更清晰。
在 1960 年代和 1970 年代的大部分时间里,批处理是使用(大型)大型机计算机的正常方式(个人计算机直到 1980 年代初才开始成为家庭用品)。从您将一叠穿孔卡片交给接待员到您收集卡片以及打印的程序列表(伴随着错误消息或一些有用的结果)可能需要几个小时,甚至一天的时间。
在这种情况下,编译器报告尽可能多的错误非常重要,因此编写编译器的一部分工作是“从错误中恢复”并继续检查(但不翻译),以期发现更多错误。不幸的是,一旦发生错误(特别是如果错误影响了声明),编译器很可能会变得混乱并产生大量虚假的错误报告。
程序员然后需要决定哪些错误要尝试修复,哪些错误要忽略,希望它们在修复了早期错误后会消失。一些编译器特别容易产生虚假的错误报告。帮助台工作人员唯一有用的建议是:修复第一个错误,因为编译器在那时还没有机会让自己混乱。
大量的编译器开发工作通常都投入到错误恢复的尝试中。您可以尝试猜测程序员可能想做什么,或者插入一些令牌以至少允许继续解析,或者干脆放弃该语句并跳到下一个分号。后一项操作可能会跳过一个 **end** 或其他重要的程序结构令牌,从而使编译器更加混乱。
现在有快速的个人计算机,因此 IDE 越来越受欢迎,编辑器和编译器紧密耦合,可以从一个图形界面使用。许多 IDE 还包含一个调试器。在某些情况下,编辑器是语言敏感的,因此它可以提供匹配的括号和/或语句模式以帮助减少微不足道的错误数量。IDE 还可以使用不同的颜色表示源语言中的不同概念,例如将保留字用 **粗体** 表示,将注释用绿色表示,将常量用蓝色表示,等等。
这种速度和紧密耦合允许编译器编写者采用一种更简单的方法来处理错误:编译器只要发现错误就停止,然后编辑器将光标放在源文本中检测到错误的位置并显示一些具体的错误消息。请注意,检测到错误的位置很可能是在实际发生错误的位置之后的一段距离。
早在 1964 年就出现了行模式 IDE,许多 BASIC 系统就是这类系统的例子;我们将在本书的 案例研究 - 一个简单的解释器 部分实现类似的东西。
在编译期间,始终可以给出检测到错误的确切位置。可以通过将编辑器光标放在精确的位置来显示此位置,或者(批处理模式)通过列出有问题的行,后面跟着包含某种标记(例如“|”)的行,该标记位于错误点的下方,或者(不太方便)通过提供该点的行号和列号。
请记住,错误的实际位置(区别于检测到的位置)很可能是在程序中更早的某个点;在某些情况下(例如括号不匹配),编译器可能能够指示早期错误的性质。
错误消息必须清晰、正确且相关。
- Murray Langton 遇到的最糟糕的反例是一个编译器,它在报告“缺少分号”时,实际错误是在错误位置的额外空格。为了进一步混淆,没有给出程序中错误位置的任何指示。更令人恼火的是,源语言甚至没有使用分号!
在词法分析阶段,可以检测到的错误相对较少。
奇怪的字符
- 一些编程语言不使用所有可能的字符,因此出现的任何奇怪字符都可以被报告。但是请注意,几乎任何字符都可以在引号字符串中使用。
长引号字符串 (1)
- 许多编程语言不允许引号字符串跨越多行;在这种情况下,可以检测到缺少的引号。这类语言通常有一些方法可以自动将连续的引号字符串连接在一起,以允许非常长的字符串。
长引号字符串 (2)
- 如果引号字符串可以跨越多行,那么缺少的引号会导致大量文本在检测到错误之前被“吞掉”。错误很可能在下一个引号字符串的文本中被报告,这在作为程序的一部分时不太可能产生意义。
无效的数字
- 像 123.45.67 这样的数字可以在词法分析期间被检测为无效(前提是语言不允许句点出现在数字的后面)。一些编译器编写者更愿意将此视为词法分析的两个连续数字 123.45 和 .67,并将其留给语法分析器报告错误。一些语言不允许数字以句点/小数点开头,在这种情况下,词法分析器可以轻松检测到这种情况。
在语法分析期间,编译器通常试图根据对少量令牌之一的期望来决定下一步要做什么。因此,在大多数情况下,可以通过简单地列出此时可接受的令牌来自动生成有用的错误消息。
Source: A + * B Error: | Found '*', expect one of: Identifier, Constant, '('
在括号不匹配的情况下可能需要更具体的定制错误消息。
Source: C := ( A + B * 3 ; Error: | Missing ')' or earlier surplus '('
在语义分析期间报告的最常见错误之一是“标识符未声明”;要么是您省略了声明,要么是您拼错了标识符。
在语义分析期间检测到的其他常见错误与类型的使用不兼容有关,例如尝试将逻辑值(例如 **true**)分配给字符字符串。其中一些错误可能非常微妙,但同样容易自动生成相当精确的错误消息。
Source: SomeString := true; Error: Can't assign logical value to character string
这种类型检查的程度在很大程度上取决于源语言。
- PL/1 允许惊人的各种自动类型转换,因此几乎没有检查可能进行。
- Pascal 更加挑剔;您甚至不能将实数值分配给整型变量,而无需明确指定您是否要对该值进行舍入或截断。
- 一些编写者认为,应该扩展类型检查以涵盖适当的单位,以进行更多检查,例如,将距离乘以温度是没有意义的。
语义错误的其他可能来源是参数计数错误和下标计数错误。 通常,将子程序声明为具有 4 个参数,然后用 5 个参数调用该子程序是错误的(但某些语言确实允许子程序具有可变数量的参数)。 通常,将数组声明为具有 2 个下标,然后尝试使用 3 个下标访问数组元素也是错误的(但某些语言可能允许使用比声明中更少的下标来选择数组的“切片”)。
人们普遍认为,应该检测和报告诸如除以 0 之类的运行时错误。 但是,关于如何报告错误位置,存在很大差异。
- 某些系统仅提供导致错误的指令的十六进制地址。 如果您的编译器/链接器生成了加载映射,那么您可能能够执行一些十六进制运算来识别它是哪个例程。
- 某些系统会告诉您发生错误的例程的名称,以及可能当时所有处于活动状态的例程的名称。
- 少数友好的系统会提供源代码行号,这非常有用。 但是请注意,广泛的程序优化可能会移动代码并混合语句,在这种情况下,行号可能只是近似的。 从实现者的角度来看,有几种方法可以提供行号细节或等效内容。
- 编译后的程序可以包含将当前行号放在某个固定位置的指令;这会使程序更长、更慢。 当然,编译器只需为可能实际导致错误的语句添加这些指令。
- 编译后的程序可以包含一个表,指示每个源代码行在编译后的代码中开始的位置。 发生错误时,特殊代码可以查询此表并确定涉及的源代码行。 这会使编译后的代码更长,但不会降低其速度。
- 在一些未经优化的系统中,可能能够从编译后的代码中推断出一些源信息,例如,Elliott 503 Algol 60 编译器可以报告:“在例程'xyz'的第三个begin之后的第二个除法处除以 0”。 这不会影响代码大小或速度,但可能并不总是可行。
本节中的某些内容可能存在争议。
存在某些潜在的运行时错误,许多系统甚至没有尝试检测。 语言定义可能只是说违反某些语言规则的结果是未定义的,即您可能会收到错误消息,或者您可能会在没有任何警告的情况下得到错误的答案,或者您可能在某些情况下得到正确的答案,或者您可能每次运行程序都会得到不同的答案,或者您可能会触发第三次世界大战(“未定义”确实意味着任何事情都可能发生)。
过去,有一些计算机(Burroughs 5000+、Elliott 4130)具有硬件支持,可以快速检测到其中一些错误。 许多当前的 IDE 确实具有调试选项,可以帮助检测其中一些运行时错误
- 尝试除以 0。
- 算术运算期间的溢出(以及可能的下溢)。
- 尝试在变量被设置为某个合理值(未定义变量)之前使用它。
- 尝试引用不存在的数组元素(无效下标)。
- 尝试将变量(定义为具有有限范围)设置为此范围之外的某个值。
- 与指针相关的各种错误
- 尝试在指针被设置为指向某个有用位置之前使用它。
- 尝试使用nil指针,该指针明确地没有指向任何有用位置。
- 尝试使用指向它应该指向的数组之外的指针。
- 尝试在它指向的内存被释放后使用指针。
历史上,不做这些检查的主要原因是性能的影响。 当 FORTRAN 首次开发(大约在 1957 年)时,它必须与用汇编语言编写的代码竞争;事实上,现代编译器中使用的许多优化技术最初是在那个时候开发和使用的。 C 语言(大约在 1971 年)最初被开发为汇编语言的替代品,供经验丰富的系统程序员在编写操作系统时使用。
在这两种情况下,都有充分的理由不做这些检查。 如今,计算机硬件比 1957 年或 1971 年快得多,并且有更多经验不足的程序员编写代码,因此避免检查的理由要弱得多。 实际上,在看似正常的程序上添加检查会很有启发性/令人惊讶/令人尴尬;即使是那些“工作”了多年的程序也可能发现存在令人惊讶数量的错误。
Hoare(快速排序的发明者)在 1960 年代初期负责一个 Algol 60 编译器;下标检查总是完成的。 事实上,Hoare 在“关于编程语言设计的提示”中说:“在测试过程中进行检查,然后在生产中抑制它们,就像一个水手在陆地上训练时穿着救生衣,然后在出海时脱掉救生衣一样。”
在“计算机编程心理学”一书中,Weinberg 回忆了以下轶事
- 经过几个月的努力,一个特定的应用程序仍然无法正常工作,因此公司另一部门的顾问被叫来。 他得出结论,现有的方法永远无法可靠地实现。 在回家的路上,他意识到如何完成这项工作。 经过几天的工作,他有一个演示程序可以正常工作,并将其展示给最初的编程团队。
- 团队领导:您的程序在处理时需要多长时间?
- 顾问:每个案例大约需要 10 秒。
- 团队领导:但我们的程序只需要 1 秒。 {团队此时沾沾自喜}
- 顾问:但您的程序不工作。 如果程序不必工作,我可以让它变得像您想要的一样快。
Wirth 将 Pascal 设计为一种教学语言(大约在 1972 年);对于许多 Pascal 编译器来说,默认情况下是执行所有安全检查。 一些 Pascal 系统有一个选项可以抑制对程序某些有限部分的检查。
当一种编程语言允许使用指针和指针运算来访问数组元素时,对访问不存在的数组元素进行检查的成本可能很高。 请注意,它确实可以完成:每个指针都足够大,可以包含三个地址,第一个是程序员直接操作和使用的地址,另外两个地址是第一个的上下限。 当语言允许整数和指针之间的相互转换时,这种方法可能会遇到问题。
在“未定义变量”的情况下,请注意,将所有变量最初设置为 0 实际上是一个坏主意(除非语言当然要求这样做)。 这样的初始设置会降低程序的可移植性,也可能会掩盖严重的逻辑错误。
Murray Langton(本维基教科书的主要作者)在检查一个 140,000 行安全关键的遗留 Fortran 程序中的“未定义”方面取得了一些成功。 基本思路是将所有全局变量设置为可以识别的奇怪值,这些值在使用时极有可能产生明显奇怪的结果。
对于 IBM 大型机,奇怪的值是
- REAL 设置为 -9.87654E70
- INTEGER 设置为 -123456789
- CHAR 设置为 '?'
请注意,使用的特定值取决于您的系统,尤其是用于 REAL 的大数绝对依赖于硬件。 对于具有 IEEE 浮点算术的机器(大多数 PC),REAL 的最佳选择是 NaN(非数字),可能的替代选择是 -9.87654E37
选择大的负数值的原因是,它们在打印或显示为输出时往往非常明显,并且在用于算术运算时往往会导致数值错误(溢出)。 此外,在 Fortran 中,所有输出都在固定宽度字段中,任何无法容纳在字段中的输出都将显示为一个充满星号的字段,这很容易发现。
在上面引用的安全关键示例中,编写了一个程序来识别所有全局变量(通过分析 COMMON 块),排除那些(在 BLOCK DATA 中)显式初始化的变量,然后编写一个 Fortran 例程来设置所有这些奇怪的值。 如果对 COMMON 块进行了任何更改,只需重新运行此分析程序即可。
在执行过程中,设置无效值的例程使用的 CPU 时间不到总 CPU 时间的 0.1%。当这些无效值首次使用时,花费了几个月的时间才追踪并消除在输出中出现的星号和问号泛滥,尽管该程序已经“运行”了 20 多年。
如何检查“未定义”
[edit | edit source]基本思路是确保在声明时将所有变量标记为“未定义”。某些语言允许同时声明和初始化,在这种情况下,变量被标记为“已定义”。每当将值赋给变量时,该标记将更改为“已定义”。每当使用变量时,都会检查该标记,如果该标记为“未定义”,则会报告错误。
过去,一些幸运的实现者在硬件方面获得了帮助,即每个内存字都附加了一个额外的位(Burroughs 5000+)。在现代字节寻址机器上,可以为每个变量附加一个额外的字节来保存标记。不幸的是,由于对齐要求,这将导致数据所需的内存量翻倍(许多系统要求 4 字节项(如数字)的地址为 4 的倍数;即使允许不对齐,使用它也可能会使程序速度明显降低)。
提供标记的最简单方法是使用一些在实践中不太可能出现的特定值。具体的值取决于所涉及变量的类型。
布尔值
- 此类变量最有可能分配一个字节的存储空间,其中 0 代表假,1 代表真。255 或 128 之类的值是合适的标记。
字符
- 在用于二进制输入/输出时,任何值都可能出现,因此无法进行检查。因此,在这种情况下必须能够关闭检查。
- 用作字符时,存在许多可能的非打印字符。127 或 128 或 255 可能是合适的选择。
整数
- 大多数计算机系统使用二进制补码表示负数,这会产生非对称范围(对于 16 位,范围为 -32768 到 +32767)。我们可以使用最大的负数作为“未定义”标记来恢复对称性。
实数
- 如果您的硬件符合 IEEE 标准(大多数 PC 都符合),则可以使用 NaN(非数字)。
如何在编译时检查
[edit | edit source]您可能认为所有这些检查(未定义、下标错误、超出范围等)会使程序速度明显降低。情况并没有您想象的那么糟糕,因为实际上很多检查可以在编译时完成,如下所述。
首先,一些统计数据来向您展示可以做什么
- 仅将检查添加到现有编译器中会导致为一个 6000 行的程序生成 1800 个检查。
- 在编译器中添加几百行代码使其能够在编译时完成许多检查,并将运行时检查的数量减少到只有 70 个。然后该程序的运行速度比包含所有检查的版本快 20% 以上。
我们已经提到,在声明时被赋予初始值的变量永远不需要检查是否未定义。
接下来的几个测试需要一些简单的流程控制分析,例如,仅在一个分支中设置的变量在 **if** 语句之后再次变为未定义,除非您可以确定变量在所有可能的分支上都已定义。
- 一旦变量被设置(通过赋值或从文件读取),它就被认为已定义,以后就不需要再测试了。
- 一旦变量被测试为“未定义”,就可以假定它以后已定义。
如果您的编程语言允许您区分例程的输入参数和输出参数,则可以在调用之前检查所有输入参数是否已定义。在例程中,您可以假定所有输入参数都已定义。
对于离散变量(如整数和枚举),您通常可以在编译时跟踪该变量在程序中的任何时刻可以具有的最大值和最小值。如果您的源语言允许变量被声明为具有某些有限范围(例如 Pascal),这将特别容易。当然,对这种有界变量的任何赋值都必须检查以确保该值在指定的范围内。
对于作为下标的有界变量的许多用途,它通常表明该变量的已知限制在该下标范围内,因此不需要检查。
在计数控制循环中,您通常可以通过在进入循环之前检查循环边界来检查控制变量的范围,这可能会减少循环内需要的下标检查。
词汇表
[edit | edit source]本 词汇表旨在提供与编译相关的词语或短语的定义。它并非旨在提供通用计算术语的定义,对此,参考维基百科可能更合适。