必读网 - 人生必读的书

TXT下载此书 | 书籍信息


(双击鼠标开启屏幕滚动,鼠标上下控制速度) 返回首页
选择背景色:
浏览字体:[ ]  
字体颜色: 双击鼠标滚屏: (1最慢,10最快)

程序员修炼之道

_9 Andrew(美)
}
else {
processName(name);
if (ad(address) != OK) {
retcode = BAD_READ;
}
else {
processAddress(address);
if (ad(telNo) != OK) {
retcode = BAD_READ;
}
else {
// etc, etc...
}
}
}
return retcode;
  幸运的是,如果编程语言支持异常,你可以通过更为简洁的方式重写这段代码:
retcode = OK;
try {
ad(name);
process(name);
ad(address);
processAddress(address);
ad(telNo);
// etc, etc...
}
catch (IOException e) {
retcode = BAD_READ;
Lg("Error reading individual: " + tMessage());
}
return retcode;
  现在正常的控制流很清晰,所有的错误处理都移到了一处。
什么是异常情况
  关于异常的问题之一是知道何时使用它们。我们相信,异常很少应作为程序的正常流程的一部分使用;异常应保留给意外事件。假定某个未被抓住的异常会终止你的程序,问问你自己:“如果我移走所有的异常处理器,这些代码是否仍然能运行?”如果答案是“否”,那么异常也许就正在被用在非异常的情形中。
  例如,如果你的代码试图打开一个文件进行读取,而该文件并不存在,应该引发异常吗?
  我们的回答是:“这取决于实际情况。”如果文件应该在那里,那么引发异常就有正当理由。某件意外之事发生了——你期望其存在的文件好像消失了。另一方面,如果你不清楚该文件是否应该存在,那么你找不到它看来就不是异常情况,错误返回就是合适的。
  让我们看一看第一种情况的一个例子。下面的代码打开文件/etc/passwd,这个文件在所有的UNIX系统上都应该存在。如果它失败了,它会把FileNotFoundException传给它的调用者。
public void open_passwd() throws FileNotFoundException {
// This may throw FileNotFoundException...
ipstream = new FileInputStream("/etc/passwd");
// ...
}
  但是,第二种情况可能涉及打开用户在命令行上指定的文件。这里引发异常没有正当理由,代码看起来也不同:
public boolean open_user_file(String name)
throws FileNotFoundException {
File f = new File(name);
if (!ists()) {
return false;
}
ipstream = new FileInputStream(f);
return true;
}
  注意FileInputStream调用仍有可能生成异常,这个例程会把它传递出去。但是,这个异常只在真正异常的情形下才生成;只是试图打开不存在的文件将生成传统的错误返回。
提示34
Use Exceptions for Exceptional Problems
将异常用于异常的问题
  我们为何要提出这种使用异常的途径?嗯,异常表示即时的、非局部的控制转移——这是一种级联的(cascading)goto。那些把异常用作其正常处理的一部分的程序,将遭受到经典的意大利面条式代码的所有可读性和可维护性问题的折磨。这些程序破坏了封装:通过异常处理,例程和它们的调用者被更紧密地耦合在一起。
错误处理器是另一种选择
  错误处理器是检测到错误时调用的例程。你可以登记一个例程处理特定范畴的错误。处理器会在其中一种错误发生时被调用。
  有时你可能想要使用错误处理器,或者用于替代异常,或者与异常一起使用。显然,如果你使用像C这样不支持异常的语言,这是你的很少几个选择之一(参见下一页的“挑战”)。但是,有时错误处理器甚至也可用于拥有良好的内建异常处理方案的语言(比如Java)。
  考虑一个客户-服务器应用的实现,它使用了Java的Remote Method Invocation(RMI)设施。因为RMI的实现方式,每个对远地例程的调用都必须准备处理RemoteException。增加代码处理这些异常可能会变得让人厌烦,并且意味着我们难以编写既能与本地例程、也能与远地例程一起工作的代码。一种绕开这一问题的可能方法是把你的远地对象包装在非远地的类中。这个类随即实现一个错误处理器接口,允许客户代码登记一个在检测到远地异常时调用的例程。
相关内容:
l 死程序不说谎,120页
挑战
l 不支持异常的语言常常拥有一些其他的非局部控制转移机制(例如,C拥有longjmp/setjmp)。考虑一下怎样使用这些设施实现某种仿造的异常机制。其好处和危险是什么?你需要采取什么特殊步骤确保资源不被遗弃?在你编写的所有C代码中使用这种解决方案有意义吗?
练习
21. 在设计一个新的容器类时,你确定可能有以下错误情况:  (解答在292页)
(1) add例程中的新元素没有内存可用
(2) 在fetch例程中找不到所请求的数据项
(3) 传给add例程的是null指针
应怎样处理每种情况?应该生成错误、引发异常、还是忽略该情况?
25怎样配平资源
“我把你带进这个世界,”我的父亲会说:“我也可以把你赶出去。那没有我影响。我要再造另一个你。”
  ——Bill Cosby,Fatherhood
  只要在编程,我们都要管理资源:内存、事务、线程、文件、定时器——所有数量有限的事物。大多数时候,资源使用遵循一种可预测的模式:你分配资源、使用它,然后解除其分配。
  但是,对于资源分配和解除分配的处理,许多开发者没有始终如一的计划。所以让我们提出一个简单的提示:
提示35
Finish What You Start
要有始有终
  在大多数情况下这条提示都很容易应用。它只是意味着,分配某项资源的例程或对象应该负责解除该资源的分配。让我们通过一个糟糕的代码例子来看一看该提示的应用方式——这是一个打开文件、从中读取消费者信息、更新某个字段、然后写回结果的应用。我们除去了其中的错误处理代码,以让例子更清晰:
void readCustomer(const char *fName, Customer *cRec) {
cFile = fopen(fName, "r+");
fread(cRec, sizeof(*cRec), 1, cFile);
}
void writeCustomer(Customer *cRec) {
rewind(cFile);
fwrite (cRec, sizeof(*cRec), 1, cFile);
fclose(cFile);
}
void updateCustomer(const char *fName, double newBalance) {
Customer cRec;
readCustomer(fName, &cRec);
cRlance = newBalance;
writeCustomer(&cRec);
}
  初看上去,例程updateCustomer相当好。它似乎实现了我们所需的逻辑——读取记录,更新余额,写回记录。但是,这样的整洁掩盖了一个重大的问题。例程readCustomer和writeCustomer紧密地耦合在一起[27]——它们共享全局变量cFile。readCustomer打开文件,并把文件指针存储在cFile中,而writeCustomer使用所存储的指针在其结束时关闭文件。这个全局变量甚至没有出现在updateCustomer例程中。
  这为什么不好?让我们考虑一下,不走运的维护程序员被告知规范发生了变化——余额只应在新的值不为负时更新。她进入源码,改动updateCustomer:
void updateCustomer(const char *fName, double newBalance) {
Customer cRec;
readCustomer(fName, &cRec);
if (newBalance >= 0.0) {
cRlance = newBalance;
writeCustomer(&cRec);
}
}
  在测试时一切似乎都很好。但是,当代码投入实际工作,若干小时后它就崩溃了,抱怨说打开的文件太多。因为writeCustomer在有些情形下不会被调用,文件也就不会被关闭。
  这个问题的一个非常糟糕的解决方案是在updateCustomer中对该特殊情况进行处理:
void updateCustomer(const char *fName, double newBalance) {
Customer cRec;
readCustomer(fName, &cRec);
if (newBalance >= 0.0) {
cRlance = newBalance;
writeCustomer(&cRec);
}
else
fclose(cFile);
}
  这可以修正问题——不管新的余额是多少,文件现在都会被关闭——但这样的修正意味着三个例程通过全局的cFile耦合在一起。我们在掉进陷阱,如果我们继续沿着这一方向前进,事情就会开始迅速变糟。
  要有始有终这一提示告诉我们,分配资源的例程也应该释放它。通过稍稍重构代码,我们可以在此应用该提示:
void readCustomer(FILE *cFile, Customer *cRec) {
fread(cRec, sizeof(*cRec), 1, cFile);
}
void writeCustomer(FILE *cFile, Customer *cRec) {
rewind(cFile);
fwrite(cRec, sizeof(*cRec), 1, cFile);
}
void updateCustomer(const char *fName, double newBalance) {
FILE *cFile;
Customer cRec;
cFile = fopen(fName, "r+"); // >---
readCustomer(cFile, &cRec); // /
if (newBalance >= 0.0) { // /
cRlance = newBalance; // /
writeCustomer(cFile, &cRec); // /
} // /
fclose(cFile); // <---
}
  现在updateCustomer例程承担了关于该文件的所有责任。它打开文件并(有始有终地)在退出前关闭它。例程配平了对文件的使用:打开和关闭在同一个地方,而且显然每一次打开都有对应的关闭。重构还移除了丑陋的全局变量。
嵌套的分配
  对于一次需要不只一个资源的例程,可以对资源分配的基本模式进行扩展。有两个另外的建议:
1. 以与资源分配的次序相反的次序解除资源的分配。这样,如果一个资源含有对另一个资源的引用,你就不会造成资源被遗弃。
2. 在代码的不同地方分配同一组资源时,总是以相同的次序分配它们。这将降低发生死锁的可能性。(如果进程A申请了resource1,并正要申请resource2,而进程B申请了resource2,并试图获得resource1,这两个进程就会永远等待下去。)
不管我们在使用的是何种资源——事务、内存、文件、线程、窗口——基本的模式都适用:
无论是谁分配的资源,它都应该负责解除该资源的分配。但是,在有些语言中,我们可以进一步发展这个概念。
对象与异常
  分配与解除分配的对称让人想起类的构造器与析构器。类代表某个资源,构造器给予你该资源类型的特定对象,而析构器将其从你的作用域中移除。
  如果你是在用面向对象语言编程,你可能会发现把资源封装在类中很有用。每次你需要特定的资源类型时,你就实例化这个类的一个对象。当对象出作用域或是被垃圾收集器回收时,对象的析构器就会解除所包装资源的分配。
配平与异常
  支持异常的语言可能会使解除资源的分配很棘手。如果有异常被抛出,你怎样保证在发生异常之前分配的所有资源都得到清理?答案在一定程度上取决于语言。
在C++异常机制下配平资源
  C++支持try…catch异常机制。遗憾的是,这意味着在退出某个捕捉异常、并随即将其重新抛出的例程时,总是至少有两条可能的路径:
void doSomething(void) {
Node *n = new Node;
try {
// do something
}
catch (...) {
delete n;
throw;
}
delete n;
}
  注意我们创建的节点是在两个地方释放的——一次是在例程正常的退出路径上,一次是在异常处理器中。这显然违反了DRY原则,可能会发生维护问题。
  但是,我们可以对C++的语义加以利用。局部对象在从包含它们的块中退出时会被自动销毁。这给了我们一些选择。如果情况允许,我们可以把“n”从指针改变为栈上实际的Node对象:
void doSomething1(void) {
Node n;
try {
// do something
}
catch (...) {
throw;
}
}
  在这里,不管是否抛出异常,我们都依靠C++自动处理Node对象的析构。
  如果不可能不使用指针,可以通过在另一个类中包装资源(在这个例子中,资源是一个Node指针)获得同样的效果。
// Wrapper class for Node resources
class NodeResource {
Node *n;
public:
NodeResource() { n = new Node; }
~NodeResource() { delete n; }
Node *operator->() { return n; }
};
void doSomething2(void) {
NodeResource n;
try {
// do something
}
catch (...) {
throw;
}
}
  现在包装类NodeResource确保了在其对象被销毁时,相应的节点也会被销毁。为了方便起见,包装提供了解除引用操作符->,这样它的使用者可以直接访问所包含的Node对象中的字段。
返回书籍页