阅读8:避免调试
6.031中的软件
防虫 | 容易明白 | 准备改变 |
---|---|---|
今天改正,在未知的未来改正。 | 与未来的程序员(包括未来的您)进行清晰的沟通。 | 旨在适应变化而无需重写。 |
目标
今天的课程的主题是调试–或更确切地说,如何避免完全调试,或者在必须进行调试时保持简单。
第一道防线:消除错误
最好的防御错误的方法是通过设计使它们成为不可能。
我们已经讨论的一种方法是静态检查。静态检查通过在编译时捕获它们来消除许多错误。
我们还在较早的课堂会议上看到了一些动态检查的示例。例如,Java通过动态捕获来使数组溢出错误成为不可能。如果您尝试使用数组或列表范围之外的索引,则Java会自动产生错误。诸如C和C ++之类的较早语言默默地允许错误访问,从而导致错误和全漏洞。
不变性(不受更改的影响)是防止错误的另一项设计原则。一个不可变的类型是一个类型,其价值一旦被创造永远无法改变。
字符串是不可变的类型。您无法在String上调用任何方法来改变其表示的字符顺序。字符串可以传递和共享,而不必担心会被其他代码修改。
Java还为我们提供了不可变的引用:用关键字声明的变量final
,只能分配一次,但不能重新分配。优良作法是final
用来声明方法的参数和尽可能多的局部变量。像变量的类型一样,这些声明也是重要的文档,对代码阅读者很有用,并由编译器进行静态检查。
考虑以下示例:
final char[] vowels = new char[] { 'a', 'e', 'i', 'o', 'u' };
该vowels
变量被声明为final,但是它真的不变吗?下列哪些语句是非法的(由编译器静态捕获),哪些将被允许?
vowels = new char[] { 'x', 'y', 'z' };
vowels[0] = 'z';
你可以在下面的练习中找到答案。要注意什么final
意思!它仅使引用不可变,而不必使引用指向的对象。
第二防御:本地化错误
如果我们不能防止错误,可以尝试将其本地化到程序的一小部分,这样我们就不必费劲查找错误的原因。当本地化为单个方法或小模块时,仅通过研究程序文本即可发现错误。
我们已经讨论过快速失败:越早发现问题(越接近问题的起因),就越容易解决。
让我们从一个简单的例子开始:
/**
* @param x requires x >= 0
* @return approximation to square root of x
*/
public double sqrt(double x) { ... }
现在,假设有人打电话sqrt
给我一个否定的论点。最佳行为是sqrt
什么?由于调用方未能满足x
应为非负数的要求,sqrt
不再受其合同条款的约束,因此从技术上讲,它可以自由执行任何所需的操作:返回任意值,或进入无限循环,或崩溃CPU。但是,由于错误调用指示调用者中存在错误,因此,最有用的行为将尽早指出该错误。为此,我们插入了一个测试前提条件的运行时断言。这是我们编写断言的一种方法:
/**
* @param x requires x >= 0
* @return approximation to square root of x
*/
public double sqrt(double x) {
if (! (x >= 0)) throw new AssertionError();
...
}
当不满足先决条件时,此代码通过引发AssertionError
异常来终止程序。阻止了调用者的bug的影响传播。
检查前提条件是防御性编程的一个示例。真正的程序很少没有错误。防御性编程提供了一种减轻错误影响的方法,即使您不知道它们在哪里。
断言
为这些类型的防御性检查定义一个过程是通常的做法,通常称为assert
:
assert (x >= 0);
这种方法从断言失败时的确切情况中抽象出来。失败的断言可能会退出;它可能在日志文件中记录一个事件;可能会将报告通过电子邮件发送给维护人员。
断言具有额外的好处,即可以记录当时有关程序状态的假设。对于阅读您的代码的人来说,assert (x >= 0)
“在这一点上,x> = 0应该总是正确的。” 但是,与注释不同,断言是在运行时强制执行假设的可执行代码。
在Java中,运行时断言是该语言的内置功能。assert语句的最简单形式采用一个布尔表达式,与上面的显示完全相同,AssertionError
如果布尔表达式的计算结果为false ,则抛出该布尔表达式:
assert x >= 0;
断言语句还可以包括描述表达式,该描述表达式通常是字符串,但也可以是原始类型或对对象的引用。断言失败时,该描述将打印在错误消息中,因此可用于向程序员提供有关失败原因的其他详细信息。该描述遵循声明的表达式,并用冒号分隔。例如:
assert (x >= 0) : "x is " + x;
如果x == -1,则此断言失败并显示错误消息
x is -1
以及一个堆栈跟踪,它告诉您在代码中的何处找到了assert语句以及将程序带到该点的调用顺序。这些信息通常足以开始查找错误。
Java断言的一个严重问题是断言默认情况下处于关闭状态。
如果您仅照常运行程序,则不会检查任何断言!Java的设计师之所以这样做,是因为检查断言有时会对性能造成高昂的代价。例如,使用二进制搜索搜索数组的过程要求对数组进行排序。声明此要求需要扫描整个阵列,但是,将应该以对数时间运行的操作转换为需要线性时间的操作。您应该(急于!)在测试期间支付此费用,因为它使调试变得容易得多,但在程序发布给用户之后却没有。但是,对于大多数应用程序而言,断言与其余代码相比并不昂贵,并且它们在错误检查中提供的好处值得付出这么低的性能成本。
因此,您必须通过将-ea
(代表enable asserts)传递给Java虚拟机来显式地启用断言。在Eclipse中,通过转至“运行”→“运行配置”→“自变量”,然后在“ VM自变量”框中放置-ea来启用断言。实际上,最好最好通过转到首选项→Java→已安装的JRE→编辑→默认VM参数来启用它们,就像您希望在《[入门指南》中所做的那样。
在运行JUnit测试时将断言打开总是一个好主意。您可以使用以下测试用例确保启用断言:
@Test(expected=AssertionError.class)
public void testAssertionsEnabled() {
assert false;
}
如果根据需要打开了断言,则assert false
抛出一个AssertionError
。(expected=AssertionError.class)
测试上的注解预期并要求抛出此错误,因此测试通过。但是,如果断言被关闭,则测试的主体将不执行任何操作,而不会引发预期的异常,并且JUnit会将测试标记为失败。
请注意,Javaassert
语句与JUnit方法assertTrue()
,assertEquals()
等是不同的机制。它们均断言有关您的代码的谓词,但设计用于不同的上下文。该assert
语句应在实现代码中使用,以便在实现内部进行防御性检查。JUnitassert...()
测试中应使用JUnit方法,以检查测试结果。该assert
语句不无运行-ea
,但JUnit的assert...()
方法始终运行。
断言是什么
你应该断言一些事情:
方法参数要求,如我们所见sqrt
。
方法返回值要求。 这种断言有时称为自我检查。例如,sqrt方法可能将其结果平方以检查它是否合理地接近x:
public double sqrt(double x) {
assert x >= 0;
double r;
... // compute result r
assert Math.abs(r*r - x) < .0001;
return r;
}
**涵盖所有情况。**如果条件语句或转换未涵盖所有可能的情况,则优良作法是使用断言阻止非法情况:
switch (vowel) {
case 'a':
case 'e':
case 'i':
case 'o':
case 'u': return "A";
default: assert false;
}
default子句中的断言具有断言vowel
必须是五个元音字母之一的作用。
什么时候应该编写运行时断言?在编写代码时,不要紧随其后。在编写代码时,请牢记不变性。如果推迟编写断言,则执行此操作的可能性较小,并且有可能忽略一些重要的不变量。
什么不断言
运行时断言不是免费的。它们可能会使代码混乱,因此必须谨慎使用。避免使用琐碎的断言,就像避免不必要的评论一样。例如:
// don't do this:
x = y + 1;
assert x == y+1;
此断言在您的代码中找不到错误。它会在编译器或Java虚拟机中找到错误,这些错误是您值得信赖的组件,直到有充分的理由怀疑它们为止。如果断言从其本地上下文显而易见,则将其忽略。
切勿使用断言来测试程序外部的条件,例如文件的存在,网络的可用性或人类用户键入的输入的正确性。断言测试程序的内部状态,以确保它在其规范的范围内。断言失败时,表明该程序在某种意义上已经脱离轨道,进入了无法正常运行的状态。因此,断言失败表明存在错误。外部故障不是bug,您无法对程序进行任何事先更改以防止它们发生。外部故障应该使用异常来处理。
设计了许多断言机制,以便仅在测试和调试期间执行断言,并在将程序发布给用户时将其关闭。Java的assert语句就是这种方式。由于可能会禁用断言,因此程序的正确性永远不应取决于是否执行了断言表达式。特别是,断言的表达式不应具有副作用。例如,如果要断言从列表中删除的元素实际上是在列表中找到的,则不要这样写:
// don't do this:
assert list.remove(x);
如果禁用了断言,则整个表达式将被跳过,并且x
永远不会从列表中删除。改为这样写:
boolean found = list.remove(x);
assert found;
增量发展
将错误本地化到程序的一小部分的一种好方法是增量开发。一次只构建程序的一部分,然后继续进行测试。这样,当您发现错误时,很可能是您刚刚编写的部分,而不是大量代码中的任何地方。
我们的测试课程讨论了两种有助于解决此问题的技术:
- 单元测试:当您单独测试模块时,您可以确信发现的任何错误都在该单元中–或可能在测试用例中。
- 回归测试:在大型系统中添加新功能时,请尽可能多地运行回归测试套件。如果测试失败,则该错误可能在您刚刚更改的代码中。
模块化和封装
你还可以通过更好的软件设计来本地化错误。
模块化。 模块化是指将系统分为多个组件或模块,每个组件或模块都可以与系统的其余部分分开设计,实施,测试,推理和重用。模块化系统的对立面是一个整体式系统-很大,其各个部分纠缠在一起并相互依赖。
由单个非常长的main()函数组成的程序是整体的-很难理解,也很难隔离错误。相比之下,分解为小函数和类的程序则更具模块化。
封装。 封装意味着在模块(硬壳或胶囊)周围建造墙,以使模块负责其自身的内部行为,并且系统其他部分中的错误不会破坏其完整性。
一种封装是访问控制,使用public
和private
控制变量和方法的可见性和可访问性。可以通过任何代码访问公共变量或方法(假设包含该变量或方法的类也是公共的)。私有变量或方法只能由同一类中的代码访问。尽可能地保持私有状态,尤其是对于变量,提供了封装,因为它限制了可能无意中导致错误的代码。
另一种封装来自可变范围。从表达式和语句可以引用该变量的意义上来说,变量的范围是程序文本中定义该变量的部分。方法参数的范围是方法的主体。局部变量的范围从其声明扩展到下一个右花括号。保持变量作用域尽可能小,可以更容易地推断出程序中可能存在错误的位置。例如,假设您有一个像这样的循环:
for (i = 0; i < 100; ++i) {
...
doSomeThings();
...
}
…并且您发现此循环永远持续运行-i
从未达到100。在某个地方,有人在变化i
。但是哪里?如果将ifi
声明为全局变量,如下所示:
public static int i;
...
for (i = 0; i < 100; ++i) {
...
doSomeThings();
...
}
…那么它的范围就是整个程序。它可以在程序中的任何地方更改:通过doSomeThings()
,通过其他doSomeThings()
调用方法,通过运行一些完全不同的代码的并发线程进行更改。但是将ifi
声明为范围较小的局部变量,如下所示:
for (int i = 0; i < 100; ++i) {
...
doSomeThings();
...
}
…然后i
可以更改的唯一位置是在for语句内–实际上,仅在…我们省略的部分内。您甚至不必考虑doSomeThings()
,因为doSomeThings()
没有访问此局部变量的权限。
最小化变量的范围是错误本地化的有力实践。以下是一些适用于Java的规则:
-
始终在for循环初始值设定项中声明一个循环变量。 因此,与其在循环之前声明它:
int i; for (i = 0; i < 100; ++i) {
这将使变量的范围成为包含此代码的外部花括号的其余部分,您应该执行以下操作:
for (int i = 0; i < 100; ++i) {
这使得范围
i
仅限于for循环。 -
仅在首次需要变量时才声明变量,并且可以在最里面的花括号中声明该变量。 Java中的变量作用域是花括号,因此请将变量声明放在最里面的变量声明中,该声明包含所有需要使用该变量的表达式。不要在函数开始时声明所有变量,这会使它们的作用域不必要地变大。但是请注意,在没有静态类型声明的语言(例如Python和Javascript)中,变量的范围通常通常是整个函数,因此您不能使用花括号来限制变量的范围,,。
-
避免使用全局变量。 这是一个非常糟糕的主意,尤其是当程序变大时。全局变量通常用作为程序的多个部分提供参数的快捷方式。最好只是将参数传递到需要它的代码中,而不是将其放在可能无意间重新分配的全局空间中。
概括
在本文中,我们研究了一些最小化调试成本的方法:
- 避免调试
- 使用静态类型,自动动态检查以及不可变的类型和引用之类的技术使错误变为不可能
- 限制错误
- 使用断言快速失败会阻止错误扩散
- 增量开发和单元测试将错误限制在您最近的代码中
- 范围最小化减少了您必须搜索的程序数量
考虑我们的三个主要代码质量度量:
- 安全的错误。 我们正在努力阻止并摆脱它们。
- **容易明白。**诸如静态类型,最终声明和断言之类的技术是代码中假设的其他文档。变量范围的最小化使读者更容易理解变量的使用方式,因为要查看的代码更少。
- 准备好进行更改。 断言和静态键入以一种可自动检查的方式记录这些假设,以便将来的程序员更改代码时,会发现偶然违反这些假设的情况。