附录 C 调试-Java编程思维-万书网
首页

附录 C 调试

关灯 护眼    字体:

上一章 目录 下一章

    虽然有关调试的建议贯穿本书,但我们认为将它们放在一个附录中会很有帮助。每当你在调试过程中陷入困境时,都应该重温这个附录。

    哪种调试策略是最佳的呢?这取决于你面临的错误类型。

    编译时错误:表明程序存在语法错误,如语句末尾遗漏了分号。

    运行时错误:程序运行时出现的问题导致的错误,如无限递归导致 StackOverflowError 异常。

    逻辑错误:导致程序的行为不正确,如表达式的计算顺序与你预期的不一致。

    接下来的几节将介绍不同错误类型的相关调试技巧;有些技巧适用于特定类型的错误,对其他类型的错误可能不太管用。

    C.1 编译时错误

    最佳的调试方式是不用调试,因为你将错误扼杀在了摇篮中。为此,可采用 6.2 节中介绍的渐进开发,其中的关键是先编写一个可运行的简单程序,再每次添加少量的代码,这样发生错误时,你就会非常清楚它们出现在什么地方。

    尽管如此,你可能还是会遭遇下面的情形。对于每种情形,我们都提供了一些处理建议。

    C.1.1 编译器显示大量的错误消息

    编译器显示 100 条错误消息时,并不意味着程序存在 100 个错误。编译器遇到错误时,通常会暂时反应不过来。过了第一个错误后,它会尝试再次理清思路,但有时会错报错误。

    只有第一条错误消息是确实可靠的。因此我们建议你每次只修复一个错误,并立即再次编译程序。你可能发现,添加一个分号或大括号就消除了 100 个错误。

    C.1.2 编译器显示怪异的错误消息,怎么都消除不掉

    首先,仔细阅读错误消息。错误消息可能包含简洁的专业术语,但通常隐藏着重要的信息。

    即便没有提供其他信息,消息也至少指出了问题出现在程序的什么地方。实际上,它指出的是编译器阅读到什么地方时发现了问题,但错误并非一定就在那里。将编译器提供的信息作为参考,如果在编译器所说的地方没有发现错误,就扩大搜索范围。

    错误通常出现在错误消息指出的位置的前面,但也可能出现在其他地方。例如,如果错误消息指出方法调用有问题,实际的错误很可能出现在方法定义中。

    如果不能迅速找到错误,可先缓口气,再在整个程序中查找。为此,要先确保正确地缩进了程序代码,这样更容易发现语法错误。

    然后,开始查找常见的语法错误。

    (1) 检查所有的小括号和方括号都是成对的且嵌套正确。所有的方法定义都必须嵌套在类定义中。所有的程序语句必须位于方法定义中。

    (2) 别忘了,Java 区分大小写。

    (3) 检查语句末尾的分号。另外,大括号后面不需要分号。

    (4) 确保代码中的所有字符串的引号都是成对的;确保使用了双引号来括起字符串,并使用了单引号来括起字符。

    (5) 对于每条赋值语句而言,确保左边的类型与右边的类型相同。确保表达式的左边是变量名或其他可赋值的东西(如数组元素)。

    (6) 确保每个方法调用指定的实参的类型和排列顺序是正确的,且用来调用方法的对象的类型也是正确的。

    (7) 调用值方法时,确保使用了它返回的结果;调用 void 方法时,确保没有使用它返回的结果。

    (8) 调用实例方法时,确保调用它的对象的类型是正确的;在类外面调用其静态方法时,确保用句点表示法指定了类名。

    (9) 在实例方法中,可在没有指定对象的情况下引用实例变量。如果你在静态方法中这样做(无论是否使用 this),将出现类似于下面的错误消息:non-static variable x cannot be referenced from a static context(在静态方法中不能引用非静态变量 x)。

    如果还是没有找到错误,请进入下一节。

    C.1.3 怎么做都无法让程序通过编译

    如果编译器说存在错误,但你找不出来,可能是因为你和编译器看的代码不同。请检查开发环境,确保你编辑的程序就是编译器编译的程序。

    程序有多个版本时,经常会出现这样的情况。你编辑的是一个版本,而编译的是另一个版本。

    如果你不确定是不是这样的,可在程序开头故意添加一个显而易见的语法错误,再重新编译。如果编译器没有发现新添的错误,就很可能是开发环境的设置有问题。

    如果你仔细研究了代码,并确定编译器编译的是正确的源代码文件,就该拿出杀手锏了:二分调试(debugging by bisection)。

    备份当前调试的文件。如果调试的是 Bob.java,就创建副本并将其命名为 Bob.java.old。

    将 Bob.java 的代码删除约一半,再重新编译。

    如果程序能够通过编译,就说明错误发生在被删除的代码中。将删除的代码恢复一半,并再次进行编译。

    如果程序不能通过编译,那么错误肯定出在刚恢复的代码中。将刚恢复的代码删除一半,并再次进行编译。

    找到并修复错误后,再逐步恢复被删除的代码。

    这种做法一点都不优雅,但找到错误的速度可能比你想象得快,而且非常可靠。也适用于其他编程语言!

    C.1.4 按编译器说的做了,但还是不管用

    有些错误消息还包含修复建议,如“Golfer 类必须声明为抽象的,它没有定义接口 java.lang.Comparable 中的方法 int compareTo(java.lang.Object)”。这让你以为编译器要求你将 Golfer 声明为抽象类;如果你还处于阅读本书的水平,很可能不知道抽象类是什么,也不知道该如何声明为抽象类。

    好在编译器错了。对于这里的错误,解决方案是确保 Golfer 定义了将 Object 作为参数的方法 compareTo。

    可别让编译器牵着鼻子走。错误消息表明存在错误,但推荐的解决之道并不可靠。

    C.2 运行时错误

    导致运行时错误的原因并非总是那么明显,但在程序中添加打印语句通常能找出原因。

    C.2.1 程序挂起

    如果程序停止运行,看起来什么都不做,我们就说它“挂起”了。这通常意味着遭遇了无限循环或无限递归。

    如果你怀疑问题出在某个循环上,可在该循环前面添加一条显示“进入循环”的打印语句,并在它后面添加一条显示“退出循环”的打印语句。

    运行程序。如果你看到了第一条消息,但没有看到第二条,就知道程序在什么地方卡壳了。为解决这种问题,请参阅“无限循环”一节。

    在大多数情况下,无限递归会导致程序运行一段时间后出现 StackOverflowError 异常。如果出现这种异常,请参阅“无限递归”一节。

    如果没有出现 StackOverflowError 异常,但你怀疑问题出在某个递归方法上,可用“无限递归”一节介绍的技巧来解决问题。

    如果上述两个建议都不管用,可能是因为你没有搞明白程序的执行流程。在这种情况下,可参阅“执行流程”一节。

    1. 无限循环

    如果你认为程序包含无限循环并知道是哪一个,可在这个循环末尾添加打印语句,以显示条件变量的值以及条件的值。

    例如:

    while (x > 0 && y < 0) { // 修改x // 修改y System.out.println("x: " + x); System.out.println("y: " + y); System.out.println("condition: " + (x > 0 && y < 0)); }

    这样的话,当运行程序时,该循环每执行一次都将显示 3 行输出。最后一次执行循环时,添加应为 false。如果循环不断地执行,你将看到 x 和 y 的值,进而也许能够搞明白它们未能正确更新的原因。

    2. 无限递归

    在大多数情况下,无限递归将导致程序引发 StackOverflowError 异常。但如果程序的运行速度很慢,可能需要很长时间才能填满栈。

    如果你知道无限递归是哪个方法导致的,可检查它是否包含基线条件。必须存在某种条件,让方法不再进行递归调用而是返回。如果没有,就需要重新审视算法并找出基线条件。

    如果有基线条件,但程序好像满足不了这个条件,可在方法开头添加显示形参的打印语句。这样的话,在程序运行期间,每当这个方法被调用时,你都将看到几行输出并获悉形参的值。如果形参没有逐渐接近基线条件,你也许能够搞明白其中的原因。

    3. 执行流程

    如果不知道程序的执行流程,可在每个方法开头添加打印语句,以显示“进入方法 foo”这样的消息,其中 foo 为当前方法的名称。这样的话,程序运行时,每个被调用的方法都将留下痕迹。

    还可显示每个方法收到的实参。这样的话,你可以在程序运行时检查实参的值是否合理,还能发现最常见的错误之一——实参的指定顺序不正确。

    C.2.2 程序运行时出现异常

    出现异常时,Java 会显示一条消息,其中包含异常的名称、出现异常的代码的行号以及“栈跟踪”。栈跟踪包含当时运行的方法和方法调用链。

    你应该先检查错误发生的地方,并看看能不能找出其中的原因。

    NullPointerException

    试图通过值为 null 的对象变量访问实例变量或调用方法时,将引发这种异常。在这种情况下,你需要确定哪个变量为 null,再搞清楚它是怎么变成 null 的。

    别忘了,声明数组变量时,其元素在被赋值前默认为 null。例如,下面的代码将引发 NullPointerException 异常:

    int[] array = new Point[5]; System.out.println(array[0].x);

    ArrayIndexOutOfBoundsException

    访问数组时,如果使用的索引为负或大于 array.length - 1,将引发这种异常。如果你能够确定问题出在什么地方,可在它前面添加打印语句来显示索引的值和数组的长度。数组的长度对吗?索引的值对吗?

    然后,在程序中往后回溯,确定数组和索引来自何方。找到最近的赋值语句,看看其所作所为是否正确。如果数组或索引为形参,那么就跳转到调用方法的地方,看看这些值来自何方。

    StackOverflowError

    参见前面的“无限递归”一节。

    FileNotFoundException

    这意味着 Java 没有找到要查找的文件。如果你使用的是基于项目的开发环境,如 Eclipse,可能必须将这个文件导入项目。否则,请确保这个文件存在且路径正确。这种问题与文件系统相关,可能难以追查。

    ArithmeticException

    算术运算的执行出现了问题,如除以零。

    C.2.3 添加了很多打印语句,输出都泛滥成灾了

    用打印语句帮助调试带来的一个问题是,最终的输出可能泛滥成灾。解决之道有两个:要么简化输出,要么简化程序。

    要想简化输出,可将不再有帮助的打印语句删除或注释掉、合并打印语句或设置输出的格式使其易于理解。开发程序时,应编写代码来生成简洁而信息丰富的消息,对程序的所作所为进行跟踪。

    要想简化程序,可缩小程序处理的问题的规模。例如,对数组进行排序时,可使用较小的数组。如果程序从用户那里获取输入,可向它提供导致错误的最简单输入。

    另外,对代码进行清理:删除多余或实验性部分,并重新组织程序使其更易阅读。例如,如果你怀疑错误出在程序中的一个多层嵌套的部分,可用更简单的结构重新编写这部分;如果你怀疑错误出现在一个很大的方法中,可将这个方法分成多个小方法,再分别进行测试。

    在确定最简单的测试用例的过程中,常常能够发现导致 bug 的线索。例如,如果你发现程序在数组包含偶数个元素时没问题,但包含奇数个元素时出现问题,这可能就获得了找出原因的线索。

    重新组织程序可帮助你找出微妙的 bug。如果修改程序时发现,原本以为这样的修改不会有任何影响,但结果并非如此,这便透露出了蛛丝马迹。

    C.3 逻辑错误

    C.3.1 程序不管用

    逻辑错误难以发现,因为编译器和解释器不会提供有关这种错误的任何信息。只有知道程序该如何做时,你才能知道程序没有这样做。

    首先,你需要在代码和程序的实际行为之间建立联系。你需要就程序的实际行为作出假设。下面是你需要回答的一些问题。

    存在程序该做却没有做的事情吗?找出执行这项功能的代码片段,确定它在你认为该执行的时候执行了。参见前面的“执行流程”一节。

    出现了原本不该发生的事情吗?在程序中找到执行这项功能的代码,看看它是不是在不该执行的时候执行了。

    是否存在带来意外影响的代码片段?确保你搞明白了这些代码,尤其是调用了 Java 库中的方法时。阅读这些方法的相关文档,并用简单的测试用例来尝试使用它们。它们的功能可能不是你想的那样。

    要编写程序,你需要建立有关代码行为的心理模型。如果代码的行为不符合预期,有问题的可能不是程序,而是你的心理模型。

    要想校正你的心理模型,最佳的方式是将程序分成多个部分(通常是类和方法),再分别测试它们。一旦找出心理模型与实际情况的偏差,你就能把问题给解决了。

    下面是需要检查的一些常见逻辑错误。

    别忘了,整数除法的结果总是向下圆整的。如果你要获得小数结果,应用 double 执行除法运算。推而广之,用整数表示可数的东西;用浮点数表示不可数的东西。

    浮点数只是近似值,不要指望它们绝对精确。根本就不能用运算符 == 来比较浮点数。换句话说,不要编写 if(d == 1.23) 这样的代码,而应编写 if(Math.abs(d - 1.23)< .000001) 这样的代码。

    用于对象时,相等运算符(==)检查两个对象是否相同。如果你要比较两个对象是否相等,应使用方法 equals。

    对于用户定义的类型,默认的 equals 方法检查两个对象是否相同。如果你要赋予相等不同的含义,必须重写这个方法。

    继承可能带来微妙的逻辑错误,因为你可能在不知不觉间运行了继承而来的代码。请参阅前面的“执行流程”一节。

    C.3.2 冗长表达式的结果出乎意料

    你完全可以编写复杂的表达式,只要它们易于理解,但调试起来可能很麻烦。通常而言,最好将复杂表达式分解成一系列给临时变量赋值的赋值语句。

    rect.setLocation(rect.getLocation().translate( -rect.getWidth(), -rect.getHeight()));

    前面的示例可重写为下面这样:

    int dx = -rect.getWidth(); int dy = -rect.getHeight(); Point location = rect.getLocation(); Point newLocation = location.translate(dx, dy); rect.setLocation(newLocation);

    第二个版本更容易理解,这要部分归功于变量名提供了额外的说明。这个版本调试起来也更容易,因为你可以检查临时变量的类型并显示它们的值。

    冗长表达式可能存在的另一个问题是,运算的执行顺序可能并非你以为的那样。例如,为计算 x/(2π),你可能编写下面的代码:

    double y = x / 2 * Math.PI;

    这不对,因为乘法和除法运算的优先级相同,因此按从左到右的顺序执行。上述代码计算的是 x 除以 2 再乘 π。

    如果你对运算顺序没有把握,可查看相关文档,也可用括号来明确地指定。

    double y = x / (2 * Math.PI);

    这个版本是正确的,对不记得运算顺序的人来说也更容易理解。

    C.3.3 方法的返回值出乎意料

    如果你在返回语句中包含复杂的表达式,就根本没有机会在返回前显示这个表达式的值。

    public Rectangle intersection(Rectangle a, Rectangle b) { return new Rectangle( Math.min(a.x, b.x), Math.min(a.y, b.y), Math.max(a.x + a.width, b.x + b.width) - Math.min(a.x, b.x) Math.max(a.y + a.height, b.y + b.height) - Math.min(a.y, b.y)); }

    不应将整个表达式放在一条语句中,而应使用一系列临时变量:

    public Rectangle intersection(Rectangle a, Rectangle b) { int x1 = Math.min(a.x, b.x); int y2 = Math.min(a.y, b.y); int x2 = Math.max(a.x + a.width, b.x + b.width); int y2 = Math.max(a.y + a.height, b.y + b.height); Rectangle rect = new Rectangle(x1, y1, x2 - x1, y2 - y1); return rect; }

    这样就可以在返回前显示任何中间变量的值了。另外,通过重用 x1 和 y1,代码也更短了。

    C.3.4 打印语句什么都不显示

    如果你使用的是方法 println,输出将立即显示出来,但如果你使用的是 print,输出将被存储起来,直到出现换行符才显示出来(至少在有些环境中如此)。如果程序直到终止都没有显示换行符,你可能根本看不到存储的输出。如果你怀疑这就是罪魁祸首,可将部分或全部 print 语句改为 println 语句。

    C.3.5 陷入了绝境,无法自拔

    首先,离开计算机一会儿。计算机发射的电波会影响人的大脑,让人出现如下症状:

    气馁和愤怒;

    怪异的想法(“计算机讨厌我。”)和迷信(“这个程序只在我将帽子反戴时才能正确地运行。”);

    酸葡萄心理(“这个程序真不怎样。”)。

    如果你出现了上述任何症状,赶快起来走一走。冷静下来后再来研究程序。程序当前的行为是什么样的?导致这种行为的原因可能是什么?程序最后一次正确地运行之后,你都做了些什么?

    找出有些 bug 就是需要时间。人在放松时常常容易找出 bug,如坐公交车、洗澡和躺在床上时。

    C.3.6 必须得有人帮我

    每个人都会遇到这样的情况,即便是最优秀的程序员,也有陷入困境的时候。有时候,你需要别人的帮助。

    找人帮忙前,务必尝试本附录介绍的所有方法。

    你的程序应尽可能简单,并使用尽可能简单的输入来引发错误;你应在合适的地方添加打印语句,且这些语句的输出应易于理解;你对问题有足够的了解,能够简练地进行描述。

    帮忙的人来了后,向他们提供所需的信息。

    bug 是什么类型的?编译时错误、运行时错误还是逻辑错误?

    这种错误发生前,你做了什么?你最后编写的是哪些代码行?或者哪个测试用例未通过?

    如果错误发生在编译时或运行时,显示的是什么错误消息?它指出程序的什么地方有问题?

    你采取了哪些措施?得出了什么样的结论?

    等你向人说明完问题时,你可能已经找到了答案。鉴于这种现象非常普遍,有人推荐使用“橡皮鸭调试法”。这种调试法的步骤如下。

    (1) 买个标准款橡皮鸭。

    (2) 当你面对问题无计可施时,将橡皮鸭放在前面的桌子上,并对它说,“橡皮鸭,我深陷困境,情况是这样的……”。

    (3) 向橡皮鸭描述面临的问题。

    (4) 发现解决方案。

    (5) 向橡皮鸭致谢。

    没跟你开玩笑,这真的管用!详情见 https://en.wikipedia.org/wiki/Rubber-duck-debugging。

    C.3.7 终于找到bug了!

    bug 找到后,如何修复通常来说是显而易见的,但并非总是如此。有些看起来是 bug 的东西其实表明你没有理解程序或你使用的算法有问题。在这种情况下,你可能需要重新审视算法或调整心理模型。可暂时离开计算机,理清思路、手工执行测试用例或绘制计算图。

    修复 bug 后,不要立即投入到再造新 bug 的编程过程中。花点时间想想这是什么样的 bug、你为何会犯这样的错误、这种错误有何特征以及如何更快地找出它。这样的话,再遇到类似的情况时,你就能更快找到 bug,乃至再也不让这样的 bug 出现。

    看完

上一章 目录 下一章