2.3 关键编程问题
如上所述,优秀的解决方案在生命周期中所花费的成本最低。下一个问题是,优秀的解决方案有哪些特性?如何构建优秀的解决方案?本节将回答这些问题。
本节讨论的编程问题为人熟知,但编程新手往往对此重视不够。在上完第一节编程课后,许多同学只是想“使程序运行起来”。以下的分析有助于树立正确的编程观念。
在编程新手中间,有一个普遍存在的错误观念:计算机程序仅供计算机“读取”。他们倾向于只考虑计算机能否“理解”程序,换言之,程序将编译、执行和生成正确的输出吗?但实际上,经常有其他人读取和修改程序。在典型的编程环境中,有许多人共享一个程序。某人编写的程序可能为另一人所用,与第三人编写的程序合并,一年以后,还可能有人修改程序。因此,必须认真设计程序,使其易于读取,易于理解。
关键的编程问题有以下6点:
(1) 模块化
(2) 可修改
(3) 易用
(4) 防故障编程
(5) 风格
(6) 调试
2.3.1 模块化
使用面向对象的设计方式进行软件开发,就是模块化设计。本书将继续强调模块化。建议从解决方案的初始设计开始,在问题求解过程的所有阶段尽量使用模块化方法。前面讨论面向对象的设计方式时曾提到,随着程序规模和复杂性的提高,很多编程任务变得越来越难。模块化减缓了难度级别的增长速度。具体地讲,模块化可对以下编程环节产生有利的影响。
● 构建程序。小型模块化程序和大型模块化程序的主要区别仅在于它们包含的模块数不同。模块是独立的,因此编写一个大型模块化程序与编写很多小型、独立的程序区别不大。另一方面,处理大型非模块化程序更类似于同时处理很多相互关联的程序。模块化还支持团队编程,几个编程人员可独自处理各自的模块,之后,再将这些模块组合为一个程序。
● 调试程序。调试大型程序令人望而生畏。假设输入一个包含10 000行的程序,并最终编译之。这些任务都不轻松。假设在执行程序的过程中,在输出几百行结果后,发现了一个错误数字。您也许要费一天的时间来跟踪这个复杂的程序,找出一个诸如错误算术表达式的问题。
模块化的一个巨大优势在于:它可将调试大型程序的任务变成调试很多小型程序。在开始编写模块代码时,总是可以肯定,以前编写的其他所有模块都正确。换言之,在结束一个模块前,必须对这个模块进行全面测试:包括单独测试,以及在与其他模块共存的环境中进行测试,选择包含模块所有可能行为的实参调用之。若测试得很彻底,就可以确保任何问题都是最近添加的模块的错误引发的。总而言之,模块化隔离了错误。
前面曾提及,从理论上讲,可以用正规方法来确立程序的正确性。模块化程序服从这个验证过程。
● 读取程序。在读取大型程序时,您可能会有只见树木不见森林之感。模块化设计可帮助编程人员处理问题求解过程的复杂性,模块化程序亦如此,可帮助用户理解程序的工作方式。模块化程序易于跟踪,因为用户可在不读取代码的情况下,很好地理解下一步做什么。如果一个方法编写得当,则可以通过其名称、初始注释及调用的其他方法名,来理解这个方法。仅当程序的用户需要详细了解程序的工作方式时,才有必要研究具体的代码。后面讲述编程风格时,将进一步讨论程序的可读性。
● 修改程序。下一节将详述可修改性。不过,程序的模块化与可修改性直接相关,所以这里简单提一下。如果程序要求有一处小改动,则只需要对代码作小的修改。否则,说明程序的质量不高,特别是没有实现模块化。要处理要求的改动,模块化程序一般只需更改相应的模块;如果模块彼此独立(即松散耦合),而且每个模块执行一个意义明确的任务(即强聚合),尤其如此。
在更改程序时,最好每次只改一点儿。采用模块化设计方式,可将重大的修改变成对程序隔离部分的一组 (相对简单的)的小改动。总而言之,模块化降低了修改的复杂性。
● 消除冗余代码。模块化设计的另一个优点是,可以找出在程序多个不同部分出现的一个计算过程,并将其实现为一个方法。结果,该计算过程的代码只出现一次,从而提升了可读性和可修改性。从下一节的例子可以看出这一点。
2.3.2 可修改性
一段时间后,程序的规范可能有变化。人们常常要求程序执行一些不同于原先指定的操作,或提出更多的请求。本节举两个例子,说明如何通过方法和命名常量来提高程序的可修改性。
1. 方法
假设图书馆有一个编写书目的大型程序。程序的一些代码显示被请求图书的相关信息。在这些代码中,程序可包含一个System.out.println语句,以显示图书的调用号、作者和书名。displayBook方法也显示图书的这些信息,可以用displayBook方法调用来替换System.out.println语句。另外,可以在book类中提供toString方法的实现代码,book类包含要显示的图书的相关信息。当book对象出现在System.out.println语句中时,将调用toString方法。
使用displayBook方法,不仅能去掉多余的代码,还能提高程序的可修改性。例如,在修改输出格式时,只需更改displayBook的实现代码,而不必更改各个System.out.println语句。如果没有使用displayBook方法,那么在修改时,必须在程序显示信息的各个位置进行更改;查找这些代码很困难,还可能出现疏漏。由这个简单例子,可以看到使用方法的显著优势。
前面提到一个解决方案,它的一项任务是给一些数据排序。若将排序算法开发为一个独立的模块,并最终将它实现为一个方法,则程序将易于修改。例如,若发现排序算法速度过慢,那么,可在完全不考虑程序其余部分的情况下替换排序方法。只需“剪切”旧方法,并“粘贴”新方法。若将排序算法集成到程序中,则修改难度将大大增加。
一般应考虑是否要重编程序来处理小的改动。通常很容易对结构完好的程序进行轻微的修改:由于各个模块只解决总体问题的一小部分,因此,若问题描述出现小的改动,通常只影响几个模块。
2. 命名常量
使用命名常量是另一种增强程序可修改性的方法。例如,数组必须预定义,大小必须固定,这个限制将带来一些困难。假设程序使用数组来处理某大学计算机学科专业的SAT分数。最初在编写程序时,有202个计算机学科专业,故将数组声明为:
int[] scores = new int[202];
程序按几种方式处理数组。例如,读取分数,写入分数,求平均分。各个任务的伪码包含如下结构:
for( index = 0 through 201)
Process the score
如果专业数目发生变化,则不仅要修改scores的声明,还必须更改处理数组的各个循环,以反映新数组的大小。另外,程序中可能有其他语句依赖于数组大小。这儿是202,那儿是201,究竟要改哪一个?
如果换一种方法,使用命名常量,如
final int NUMBER_OF_MAJORS = 202;
则可用下列形式声明数组:
int[] scores = new int[NUMBER_OF_MAJORS];
按下列形式编写处理循环的伪码:
for(index = 0 through NUMBER_OF_MAJORS-1)
Process the score
如果编写的表达式依赖于由常量NUMBER_OF_MAJORS(如NUMBER_ OF_MAJORS–1)指定的数组大小,那么,只需更改常量的定义,并重新编译程序,即可更改数组的大小。
2.3.3 易用性
另一个需要考虑他人的地方是用户界面的设计。程序的输入和输出一般由用户处理。要点如下:
● 在交互环境中,程序应总以意图明确的方式提示用户输入数据。例如,使用提示符“?”不如提示“请输入存款账号”直观。决不要无端地假设程序的用户知道程序需要什么响应信息。
● 程序一定要显示输入。每当程序读取数据时,不管是由用户输入,还是从文件中读取,程序的输出中应包含其读入的值。这么做有两个目的:首先,使用户能够检查输入的数据,从而防止输入错误和数据传输错误。在交互输入的情况下,这个检查显得更重要。第二,如果包含哪些输入生成输出的记录,输出会更有意义,更清晰。
● 输出应有明显的标志,并易于读取。例如,有两段输出:
1800 6 1
Jones, Q. 223 2234.00 1088.19 N, J Smith, T. 111
110.23 I, Harris, V. 44 44000.00 22222.22
CUSTOMER ACCOUNTS AS OF 1800 HOURS ON JUNE 1
Account status codes: N=new, J=joint, I=inactive
NAME ACC# CHECKING SAVINGS STATUS
Jones, Q. 223 $ 2234.00 $ 1088.19 N, J
Smith, T. 111 $ 110.23 ----------- I
Harris, V. 44 $44000.00 $22222.22 ----------
很明显,第1段不如第2段清晰,容易被曲解。
要编写友好的用户界面,这些特性只是一些基本要求。有些程序只是勉强能用,而有些程序则非常友好,它们之间有一些微妙的区别。初学者容易忽略友好的用户界面,而实际上,只要多投入一些时间,就会发现,优秀的程序和只解决问题的程序大不相同。例如,假如程序要求用户以固定的格式输入一行数据,输入项之间只留一个空格。若采用自由输入格式,允许在输入项之间添加任意数量的空格,无疑将使用户倍感方便。编写忽略空格的代码只需要很少的时间,为什么要求用户固守精确的格式呢?只要稍加一把力,就可以提高程序的易用性,提升自己的技术。程序的用户将不再需要考虑输入格式。
2.3.4 防故障编程
防故障程序是指无论以什么方式使用,它都能合理执行的程序。遗憾的是,这个目标通常无法实现。更现实的目标是预计人们可能误用程序的方式,并谨防滥用。
此处讨论两类错误。第一类是数据输入错误。例如,程序要求输入非负整数,但读入了–12。在遇到此类问题时,程序不应生成错误的结果,也不应用表述不清的错误消息异常终止。相反,防故障程序提供以下消息:
-12 is not a valid number of children.
Please enter this number again.
第二种错误类型是程序逻辑错误。后面介绍调试时,将讨论这种错误类型;不过,检测程序逻辑中的错误也是一个防故障编程问题。一个看似正确运行的程序,即使读入的数据有效,也可能在某些位置出现异常行为。例如,虽然尽了最大努力来测试程序的逻辑,但可能没有考虑到导致异常结果的某些数据。或者修改了程序,而这个修改使程序其他部分做的假设变得无效。无论出现什么问题,程序都应该有内置保护,来防止此类错误。程序应该自我监视,并能提供错误信息,您不能相信结果。
1. 防止数据输入错误
假设要计算收入在$10 000和$100 000之间的人员的统计数据,将收入四舍五入到最近似的千美元,如$10 000、$11 000和$100 000等。原始数据在包含一行或多行的文件中,其形式如下:
G N
其中,N是收入在G千美元组的人数。如果几个人已经编辑过数据,则可能出现同一G值的多个项。当用户输入数据时,程序必须总计和记录各个G值的人数。在这个问题中,G是 [10,100]范围内的一个整数,N是一个非负整数。
为了演示如何防止输入错误,可考虑为该问题编写一个输入方法。在第一次尝试编写这个方法时,列出了程序缺少防故障概念的几种常见情况。最后得到一个改进的输入方法,与前一方案相比,它更接近防故障目标。
第一次编写的类和方法如下:
import java.util.Scanner;
public class IncomeStatistics {
final static int LOW_END = 10; // low end of incomes
final static int HIGH_END = 100; // high end of incomes
final static iht TABLE_SIZE = HIGH_END - LOW_END + 1;
int[] incomeData; // used to store the income data,
// incomeData[G] stores the total number of
// people that fall into the G-thousand-dollar group
public IncomeStatistics () {
incomeData = new int[TABLE_SIZE];
} // end constructor
public void readData(){
// ---------------------------------------
// Reads and organizes income statistics.
// Precondition: The calling module gives directions and
// prompts the user. Input data is error-free, and each
// input line is in the form G N, where N is the number of
// people with an income in the G-thousand-dollar group
// and LOW_END <= G <= HIGH_END. An input line with values
// of zero for both G and N terminates the input.
// Postcondition: incomeData[G-LOW_END] = total number of
// people with an income in the G-thousand-dollar group
// for each G read. The values read are displayed.
// ----------------------------------------------
int group, number; // input values
Scanner input = new Scanner(System.in);
for (group = LOW_END; group <= HIGH_END; ++group) {
// clear array
incomeData[index(group)] = 0;
} // end for
group = input.nextInt();
number = input.nextInt();
while ((group i= 0) || (number != 0)) {
System.out.println("Income group "+group+" contains "+
number + "people.");
incomeData[index(group)] += number;
group = input.nextInt();
number = input.nextInt();
} // end while
} // end readData
Private int index(int group) {
// Returns the array index corresponding to group number.
Return group – LOW_END;
} // end index
// other methods for class IncomeStatistics would follow
} // end IncomeStatistics
readData方法有一些问题。若输入行包含异常数据,程序将出错。考虑下面两种情况:
● 输入行的第一个整数(方法将它指派给group)不在LOW_END到HIGH_END范围内。引用incomeData[index(group)]将抛出IndexOutOfBoundsException异常。
● 输入行的第二个数(方法将它指派给number)是负数。因为收入组的人数不能为负,所以number的负值无效,尽管如此,该方法仍将number加到组的数组项上。这样,数组incomeData将出错。
在方法读取group和number值后,必须检查:group是否在LOW_END到HIGH_END之间,number是否为正。若超出允许范围,必须处理输入错误。
有人不检查number,而在加上number后检查incomeData[index(group)]的值,分析incomeData[index(group)]是否为正。该方法存在纰漏。首先注意,给incomeData的一项加上一个负值,但该项并未因此而变负。例如,若number为–4 000,而incomeData中的相应项为10 000,求和的结果为6 000。这样,还是检测不到number的负值,致使程序其余部分的结果变得无效。
若方法检测到无效数据,一种可能的操作是抛出异常。在这种情况下,程序在调用方法的位置抛出异常,不再接受用户以后输入的数据。另一种可能是让方法设置错误标志,忽略错误的输入行,程序继续读取用户的输入。用户输入完数据后,方法可能会给调用代码抛出一个异常,或者返回布尔值false,仅说明发生了一个输入错误。如果输入错误很少发生,最好用异常来处理这种情况。但如果输入错误比较常见,就可以从结果中删除错误的输入,返回布尔值false。
下面的readData方法尽量编写得比较通用,并尽可能提高使用该方法的程序的可修改性。当方法遇到输入错误时,它设置标志,忽略数据行,并继续执行。当出现输入错误时,方法通过设置标志,然后返回标志的值,让调用模块来确定执行什么操作(如异常终止或继续)。这样,可将同一输入方法用于多种情形,并轻松地修改在遇到错误时采取的操作。
public boolean readData() {
// ------------------------------------------
// Reads and organizes income statistics.
// Precondition: The calling code gives directions and
// prompts the user. Each input line contains exactly two
// integers in the form G N, where N is the number of people
// with an income in the G-thousand-dollar group and
// LOW END <= G <= HIGH END. An input line with values of zero
// for both G and N terminates the input.
// Postcondition: incomeData[G-LOW_END] = total number of
// people with an income in the G-thousand-dollar group. The
// values read are displayed. If either G or N is erroneous (G
// and N are not both 0, and either G < LOW_END, G > HIGH_END,
// or N < 0), the method prints a message indicating the line
// will be ignored, sets the return value to false, and
// continues. In this case, the calling code should take
// action. The return value is true if the data is error free.
// --------------------------------------------------
int group, number; // input values
boolean dataCorrect = true; // no data error found as yet
Scanner input = new Scanner(System.in);
for (group = LOW_END; group <= HIGH_END; ++group) {
// clear array
incomeData[index(group)] = 0;
} // end for
group = input.nextInt();
number = input.nextInt();
while ((group != 0) || (number != 0)) {
// Invariant: group and number are not both 0
System.out.println("Income group" + group +
"contains "+ number + "people.");
if ((group >= LOW_END) && (group <= HIGH_END) &&
(number >= 0)) {
incomeData[index(group)] += number;
System.out.println();
}
else {
System.out.println("Data not valid – ignored.");
dataCorrect = false;
} // end if
group = input.nextInt();
number = input.nextInt();
} // end while
return dataCorrect;
} // end readData
在遇到大多数常见的输入错误时,这个输入方法可以正常运行,尽管如此,它仍然不是一个令人满意的防故障方法。若输入行仅含一个整数,或包含非整数,应如何处理?如果该方法一次读取一行输入,再用Scanner类的hasnextInt()方法验证该行的确包含两个整数,则它的防故障性能将更好。在一些情况下,这有小题大做之嫌。若数据输入者常因输入非整数而出错,则可以修改这个输入方法,因为该方法是一个独立的模块。在任何情况下,方法的初始注释都应包含对数据的任何假设,并指出哪些情况可使程序异常终止。
2. 防止程序逻辑错误
现在考虑程序的第二种错误类型,即程序的逻辑错误。这是调试程序时未捕获的错误,或因程序修改而引入的错误。
遗憾的是,当程序发生错误时,并不一定能报告出错,原因很简单:如果程序中报告出错的机制出现了错误,程序一定无法报告错误。不过,可以在程序中内置检查功能,从而确保当程序正确地实现其算法时,某些条件总是成立。由前述可知,这种条件称为不变式。
现在分析上例的不变式:数组incomeData的所有整数必须大于等于0。由前述可知,方法readData不应只检查incomeData项的有效性,而不检查number。不过,可以既检查number,也检查incomeData项。例如,若方法发现数组incomeData中的元素超出了某个范围,则向用户发出潜在问题警告。
为了防止故障,另一种常用的方式是使各方法检查其初始条件。例如,分析返回整数阶乘的方法factorial。
public static int factorial(int n) {
// ------------------------------------------
// Computes the factorial of an integer.
// Precondition: n >= 0.
// Postcondition: Returns n * (n-1)*...*l, if n > O;
// returns i if n = 0.
// ----------------------------------------------
int result = 1;
for (int i = n; i > 1; --i) {
result *= i;
} // end for
return result;
} // end factorial
该方法的初始注释包含初始条件,即关于做出哪些假设的信息。只有满足初始条件,方法返回的值才有效。如果n<0,则方法返回错误值1。
在使用该方法的程序中,假设n一定不为负是合理的。换言之,如果程序的其余部分正确运行,则将只用正确的n值调用factorial。正因为如此,最好让factorial方法检查n值:若n小于0,检查结果的警告指示:程序的其他位置可能出错。
factorial方法之所以检查n值是否小于0,还有一个原因:在程序的外部,factorial方法也必须正确。换言之,如果另一程序使用factorial方法,而为其传入一个负数n,则factorial方法将发出警告。最好编写一个更健壮的检查方法,而不是只在注释的初始条件中描述。因此,方法应声明其假设条件,若可能,要检查实参是否符合这些假设条件。
在本例中,factorial可以检查n的值,若n为负,则返回0,因为阶乘结果决不为0。这样,使用factorial的程序就可以检查这个异常值。
另外,若实参值为负,则可使factorial终止执行。许多编程语言(包括Java)支持一种错误处理机制,称为异常(exception)。如第1章所述,一个模块抛出异常,指示发生错误。另一模块捕获异常,对抛出的异常作出响应,并执行处理错误条件的代码。下一节介绍编程风格时将进一步讨论错误处理。
2.3.5 风格
本节讨论编程中有关个人风格的如下5个问题:
(1) 广泛使用方法
(2) 使用私有数据字段
(3) 错误处理
(4) 可读性
(5) 文档记录
无疑,下面的讨论掺杂了作者本人的喜好。当然也有其他的编程风格。
1. 广泛使用方法
尽量多用方法。如果一组语句执行的任务需要多次重复执行,则应将其编写为一个方法。另外,即使任务不需要重复执行,也应使用方法。
若程序将所有代码逐行排列,则运行速度要比调用方法的程序快。但是,不包含方法的程序的使用成本并不低。若将人员时间看作成本的重要组分,那么,使用方法具有成本效益。前面已经介绍了模块化程序的优点。
2. 使用私有数据字段
每个对象都有一组方法,表示可对该对象执行的操作。对象还包含数据字段,来存储对象中的信息。将所有的数据字段设置为私有,可对使用该对象的模块隐藏这些数据字段的准确表示形式。这符合信息隐藏原理。对象的实现细节不可见,方法仅提供从对象中获取信息以及向对象传达信息的机制。即使与某个数据字段相关的操作仅有读写数据,对象也应提供一个简单的访问(accessor)方法,来返回数据字段的值,再提供一个可变(mutator)方法来设置数据字段的值。例如,Person对象通过getname()方法返回人员姓名,提供对数据字段theName的访问;并通过setName()方法更改人员姓名。
3. 错误处理
防故障程序在其输入和逻辑中检查错误,并试图在遇到这些错误时正确运行。方法应检查几种错误类型,如无效的输入或无效的实参值。在遇到错误时,方法应采取什么操作?根据具体的情况,应对错误的操作包括:忽略错误数据并继续执行,以及终止程序。本章前面讨论的收入统计数据程序中的readData方法给调用模块返回一个布尔值,指示它遇到了无效的数据行。这样,方法让调用模块决定执行什么操作。当出现错误时,方法一般应返回值,或抛出异常,而不是显示消息。
有些情况下,当发生错误时,最好让方法本身采取措施。在出现需要终止程序的致命错误时,可使用Java提供的java.lang.Error类。若程序遇到的错误十分严重,不能保证程序的继续执行,则程序将抛出java.lang.Error类型的对象。用整数除以0就属于这种情况,这将导致程序按上述方式异常终止。
4. 可读性
要使程序易于理解,必须有正确的结构和设计,有正确选择的标识符,适当使用缩进和空行,并做好文档记录。忌用表面上灵巧的编程技术,以很多人力时间的代价去节省一点计算机时间。本书将通过这方面的例子。
选择具有描述作用的标识符,即标识符要具有自我描述性。区分诸如int的关键词和用户自定义的标识符。本书使用下列约定:
● 关键词是小写,显示为粗体。
● 用户自定义的标识符使用大写和小写字母,如下:
• 类名是名词,标识符各个单词的首字母大写。
• 方法名是动词,第一个字母小写,后续内部单词的第一个字母大写。变量以小写字母开头。多单词标识符中的其余各个单词以大写字母开头。
• 命名常量全部为大写,并用下划线来分隔单词。
通过适当的缩进样式来增强程序的可读性。程序的布局应便于用户识别程序的模块。用空行分隔各个方法。此外,在方法中,应明显缩进各个代码块,并用空行将它们分开。这些块通常是一个控制结构内执行的操作,如while循环或if语句。
可从几种缩进样式中选择。下面列出缩进样式的5个最重要的要求:
● 块应充分缩进,以醒目显示。
● 缩进应一致:总按相同方式缩进相同类型的结构。
● 缩进样式应提供合理的方式,来处理右向漂流问题,即嵌套块碰到页面右边距时的处理方法。
● 在复合语句中,左大括号应在复合语句第一行的结尾处,而右大括号应与复合语句第一行的开始处对齐。如下:
while(i > 0) {
statement(s)
} // end while
● 只要语句是控制结构(如if-else或for语句)的一部分,就可以用大括号将它们包围起来。大括号也可用于包围单条语句。这便于添加语句,不会因为忘记添加大括号而引入错误。
在这些指导原则内,也可以添加个人偏好。下面是本书使用的风格。
● 为简单或复合操作编写的for或while语句。
while(expression) {
statement(s)
} // end while
● 为简单或复合操作编写的do语句。
do {
statement(s)
} // while (expression);
● 为简单或复合操作编写的if语句。
if(expression){
statement(s)
}
else{
statement(s)
} //end if
从3个或多个不同操作过程中选择的嵌套if语句:
if(condition1){
action1
}
else if (condition2){
action2
}
else if (condition3){
action3
}//end if
这种缩进样式较好地反映了该结构的特征,它类似于一个泛化的switch语句:
switch(expression){
case constant1 : action1; break;
case constant1 : action1; break;
case constant1 : action1; break;
} // end switch
5. 文档记录
应为程序编写良好的文档,以便他人读取、使用和修改。现在有多种可接受的文档风格,具体采用哪一种,一般取决于具体的程序或个人的偏好。下面列出程序的文档记录的基本功能:
(1) 程序的初始注释包括
a. 作用描述
b. 作者和日期
c. 程序输入和输出的描述
d. 程序用法描述
e. 诸如所需数据类型的假设
f. 异常描述,换言之,哪些会出错
g. 简述主要类
(2) 各个类的初始注释,声明类的作用,并描述类包含的数据(常量或变量)
(3) 各个方法的初始注释,声明方法的作用、初始条件、结束条件和调用的方法
(4) 各个方法体的注释,解释重要功能或微妙的逻辑
编程新手容易忽略文档记录的重要性,他们认为,计算机并不读取注释,所以这些记录可有可无。但是,其他人也要读取程序,所以注释必须清晰易懂,以便他人使用程序中的方法或进行修改。一些注释供要使用方法的人使用,其他一些则供修改其实现代码的人使用。要区别不同的注释种类。
新手们还倾向于在最后一步才编写程序的文档记录。正确的做法是在开发程序时编写文档记录。大型程序的编写任务可能持续数周,所以,编写时似乎清晰的方法可能在一周后修改时变得模糊难懂。为何不现在编写文档记录,以备后用呢?
2.3.6 调试
无论编程时多么小心,程序也会包含需要跟踪的错误。幸运的是,模块化、结构清晰、文档完善的程序一般很容易调试。防故障技术可防止某些错误,并在遇到错误时予以报告。防故障技术对调试也有重要作用。
许多学生在面对程序中的错误时一筹莫展,不知如何下手。这是因为他们没有掌握跟踪错误的系统方法。离开了系统方法,即使要在大型程序中查找一个小错误,也令人望而生畏。
很多人之所以在调试程序时遇到困难,部分原因是认为自己的程序确实按预计完成任务。例如,在接到第1098行代码执行错误的消息时,一位学生可能说:“这不可能,第1098行的语句根本没有执行,它在else子句中,我能确定。” 正确的做法是主动解决问题:用现有的调试工具跟踪程序的执行,或添加System.out.println语句,以显示执行了if语句的哪些部分。这样,可以验证if语句中表达式的值。若预计为1,而表达式为0,则下一步确定它是如何变成0的。
如何查找未按预计执行的程序代码呢?编程环境一般允许跟踪程序的执行:单步调试程序中的语句,或在执行暂停的位置设置断点。还可建立监视(watch)窗口,或插入临时的System.out.println语句,分析具体变量的内容。调试的关键是用这些技术来报告正在进行的活动。这听起来毫无新意,不过,真正的技术体现在如何有效地使用这些调试工具。不能只将断点、监视窗口和System.out.println语句放在程序中的随机位置,让它们报告随机信息。
关键在于系统地定位程序中引发问题的位置。程序的逻辑指示,一些条件在程序的各个位置必须为true(这些条件称为不变式)。若编写了不变式,那么,一旦程序的结果与不变式相悖,则出现了错误。要更正错误,必须确定程序从哪一点开始出现这个区别。在程序的关键位置(例如循环和方法的入口点和退出点)插入断点、监视窗口或System.out.println语句,可以系统地隔离错误。
通过这些诊断技术可判断出,错误出现在程序的指定位置之前还是之后。用初始诊断技术运行程序后,应能捕获两点间的错误。例如,在调用方法M1之前,程序正常运行,但在调用方法M2之后,程序出现错误。利用此类信息,可将注意力集中在两点之间。继续此过程,直至最终将查找范围限制为几个语句。这样,程序的错误无藏身之处。
为了在适当的位置放置断点、监视窗口和System.out.println语句,并使它们报告正确的信息,需要缜密地分析问题,也需要实际经验。一般原则如下:
1. 调试方法
应使用监视窗口或System.out.println语句,在方法的开始和结束处检查实参的值。在将各个主要方法用于程序前,应分别进行调试。
2. 调试循环
应在循环的开始和结束处分析关键变量的值,如下例注释所示:
// check values of start and stop before entering loop
for (index = start; index <= stop; ++index) {
// check values of index and key variables
// at beginning of iteration
∙
∙
∙
// check values of index and key variables
// at end of iteration
} // end for
// check values of start and stop after exiting loop
3. 调试if语句
在if语句之前,应分析表达式中变量的值。可以用断点或System.out.println语句来确定if语句执行的分支,如下例所示:
// check variables within expression before executing if
if (expression) {
System.out.println("Value of expression is true");
. . .
}
else {
System.out.println("Value of expression is false");
} // end if
4. 使用System.out.println语句
有时,System.out.println语句比监视窗口简便得多。此类语句报告关键变量的值,以及变量具有这些值的位置。可以用注释来标记这些位置,如下:
//This is point A
system.out. println("At point A method compute:\n"+
"x="+x+", y="+y));
记住,在程序最终开始工作时,将这些语句注释掉或删除。
5. 使用特殊转储方法
经常需要分析数组或其他更复杂数据结构的值。为此,应编写转储方法,以高度可读的方式显示数据结构。在跟踪错误时,很容易将调用各转储方法的单个语句从程序的一点移至另一点。事实证明,在这些方法上花些时间是值得的,原因是:在调试程序的不同部分时,可重复地调用它们。
衷心希望,通过学习上面的内容,您能充分认识在调试中有效使用诊断工具的重要性。无论编程人员的水平如何,都难免要花一些时间进行调试。所以,要成为一个出色的编程人员,必须是一个合格的调试人员。







