Java的基本理念是 结构不佳的代码不能运行
概念
异常 这个词有 我对此感到意外 的意思 问题出现了 你也许不清楚该如何处理 但你的确知道不应该置之不理 你要停下来 看看是不是有别人或在别的地方 能够处理这个问题 只是在当前的环境中还没有足够的信息来解决这个问题 所以就把这个问题提交到一个更高级别的环境中 在这里将作出正确的决定
使用异常所带来的另一个相当明显的好处是 它往往能够降低错误处理代码的复杂度 如果不使用异常 那么就必须检查特定的错误 并在程序中的许多地方去处理它 而如果使用异常 那就不必在方法调用处进行检查 因为异常机制将保证能够捕获这个错误 并且 只需在一个地方处理错误 即所谓的异常处理程序中 这种方式不仅节省代码 而且把 描述在正常执行过程中做什么事 的代码和 出了问题怎么办 的代码相分离 总之 与以前的错误处理方法相比 异常机制使代码的阅读 编写和调试工作更加井井有条
基本异常
当抛出异常后 有几件事会随之发生 首先 同Java中其他对象的创建一样 将使用new在堆上创建异常对象 然后 当前的执行路径(它不能继续下去了)被终止 并且从当前环境中弹出对异常对象的引用 此时 异常处理机制接管程序 并开始寻找一个恰当的地方来继续执行程序 这个恰当的地方就是异常处理程序 它的任务是将程序从错误状态中恢复 以使程序能要么换一种方式运行 要么继续运行下去
举一个抛出异常的简单例子 对于对象引用t 传给你的时候可能尚未被初始化 所以在使用这个对象引用调用其方法之前 会先对引用进行检查 可以创建一个代表错误信息的对象 并且将它从当前环境中 抛出 这样就把错误信息传播到了 更大 的环境中 这被称为抛出一个异常 看起来像这样:
这就抛出了异常 于是在当前环境下就不必再为这个问题操心了 它将在别的地方得到处理
异常使得我们可以将每件事都当作一个事务来考虑 而异常可以看护着这些事务的底线 事务的基本保障是我们所需的在分布式计算中的异常处理 事务是计算机中的合同法 如果出了什么问题 我们只需要放弃整个计算 我们还可以将异常看作是一种内建的恢复(undo)系统 因为(在细心使用的情况下)我们在程序中可以拥有各种不同的恢复点 如果程序的某部分失败了 异常将 恢复 到程序中某个已知的稳定点上
异常最重要的方面之一就是如果发生问题 它们将不允许程序沿着其正常的路径继续走下去 在C和C++这样的语言中 这可真是个问题 尤其是C 它没有任何办法可以强制程序在出现问题时停止在某条路径上运行下去 因此我们有可能会较长时间地忽略了问题 从而陷入了完全不恰当的状态中 异常允许我们(如果没有其他手段)强制程序停止运行 并告诉我们出现了什么问题 或者(理想状态下)强制程序处理问题 并返回到稳定状态
异常参数
与使用Java中的其他对象一样 我们总是用new在堆上创建异常对象 这也伴随着存储空间的分配和构造器的调用 所有标准异常类都有两个构造器:一个是默认构造器 另一个是接受字符串作为参数 以便能把相关信息放入异常对象的构造器:
捕获异常
要明白异常是如何被捕获的 必须首先理解监控区域(guarded region)的概念 它是一段可能产生异常的代码 并且后面跟着处理这些异常的代码
try块
如果在方法内部抛出了异常(或者在方法内部调用的其他方法抛出了异常) 这个方法将在抛出异常的过程中结束 要是不希望方法就此结束 可以在方法内设置一个特殊的块来捕获异常 因为在这个块里 尝试 各种(可能产生异常的)方法调用 所以称为try块 它是跟在try关键字之后的普通程序块:
异常处理程序
当然 抛出的异常必须在某处得到处理 这个 地点 就是异常处理程序 而且针对每个要捕获的异常 得准备相应的处理程序 异常处理程序紧跟在try块之后 以关键字catch表示:
每个catch子句(异常处理程序)看起来就像是接收一个且仅接收一个特殊类型的参数的方法 可以在处理程序的内部使用表示符(id1 id2等等) 这与方法参数的使用很相似 有时可能用不到标识符 因为异常的类型已经给了你足够的信息来对异常进行处理 但标识符并不可以省略
异常处理程序必须紧跟在try块之后 当异常被抛出时 异常处理机制将负责搜寻参数与异常类型相匹配的第一个处理程序 然后进入catch子句执行 此时认为异常得到了处理 一旦catch子句结束 则处理程序的查找过程结束 注意 只有匹配的catch子句才能得到执行 这与switch语句不同 switch语句需要在每一个case后面跟一个break 以避免执行后续的case子句
注意在try块的内部 许多不同的方法调用可能会产生类型相同的异常 而你只需要提供一个针对此类型的异常处理程序
终止与恢复
异常处理理论上有两种基本模型 Java支持终止模型(它是Java和C++所支持的模型) 在这种模型中 将假设错误非常关键 以至于程序无法返回到异常发生的地方继续执行 一旦异常被抛出 就表明错误已无法挽回 也不能回来继续执行
另一种称为恢复模型 意思是异常处理程序的工作是修正错误 然后重新尝试调用出问题的方法 并认为第二次能成功 对于恢复模型 通常希望异常被处理之后能继续执行程序 如果想要用Java实现类似恢复的行为 那么在遇见错误时就不能抛出异常 而是调用方法来修正该错误 或者 把try块放在while循环里 这样就不断地进入try块 直到得到满意的结果
创建自定义异常
要自己定义异常类 必须从已有的异常类继承 最好是选择意思相近的异常类继承(不过这样的异常并不容易找) 建立新的异常类型最简单的方法就是让编译器为你产生默认构造器 所以这几乎不用写多少代码
你也许想通过写入System.err而将错误发送给标准错误流 通常这比把错误信息输出到System.out要好 因为System.out也许会被重定向 如果把结果送到System.err 它就不会随System.out一起被重定向 这样更容易被用户注意
也可以为异常类定义一个接受字符串参数的构造器
在异常处理程序中 调用了在Throwable类声明(Exception即从此类继承)的printStackTrace()方法 就像从输出中看到的 它将打印 从方法调用处直到异常抛出处 的方法调用序列 这里 信息被发送到了System.out 并自动地被捕获和显示在输出中 但是 如果调用默认版本
则信息将被输出到标准错误流
尽管由于LoggingException将所有记录日志的基础设施都构建在异常自身中 使得它所使用的方式非常方便 并因此不需要客户端程序员的干预就可以自动运行 但是更常见的情形是我们需要捕获和记录其他人编写的异常 因此我们必须在异常处理程序中生成日志消息
还可以更进一步自定义异常 比如加入额外的构造器和成员
新的异常添加了字段x以及设定x值的构造器和读取数据的方法 此外 还覆盖了Throwable.getMessage()方法 以产生更详细的信息 对于异常类来说 getMessage()方法有点类似于toString()方法
异常说明
异常说明使用了附加的关键字throws 后面接一个所有潜在异常类型的列表 所以方法定义可能看起来像这样
但是 要是这样写
就表示此方法不会抛出任何异常(除了从RuntimeException继承的异常 它们可以在没有异常说明的情况下被抛出 这些将在后面进行讨论)
代码必须与异常说明保持一致 如果方法里的代码产生了异常却没有进行处理 编译器会发现这个问题并提醒你 要么处理这个异常 要么就在异常说明中表明此方法将产生异常 通过这种自顶向下强制执行的异常说明机制 Java在编译时就可以保证一定水平的异常正确性
不过还是有个能 作弊 的地方 可以声明方法将抛出异常 实际上却不抛出 编译器相信了这个声明 并强制此方法的用户像真的抛出异常那样使用这个方法 这样做的好处是 为异常先占个位子 以后就可以抛出这种异常而不用修改已有的代码 在定义抽象基类和接口时这种能力很重要 这样派生类或接口实现就能够抛出这些预先声明的异常
这种在编译时被强制检查的异常称为被检查的异常
捕获所有异常
可以只写一个异常处理程序来捕获所有类型的异常 通过捕获异常类型的基类Exception 就可以做到这一点(事实上还有其他的基类 但Exception是同编程活动相关的基类)
这将捕获所有异常 所以最好把它放在处理程序列表的末尾 以防它抢在其他处理程序之前先把异常捕获了
下面的例子演示了如何使用Exception类型的方法
可以发现每个方法都比前一个提供了更多的信息——实际上它们每一个都是前一个的超集
栈轨迹
printStackTrace()方法所提供的信息可以通过getStackTrace()方法来直接访问 这个方法将返回一个由栈轨迹中的元素所构成的数组 其中每一个元素都表示栈中的一帧 元素0是栈顶元素 并且是调用序列中的最后一个方法调用(这个Throwable被创建和抛出之处) 数组中的最后一个元素和栈底是调用序列中的第一个方法调用 下面的程序是一个简单的演示示例
重新抛出异常
有时希望把刚捕获的异常重新抛出 尤其是在使用Exception捕获所有异常的时候 既然已经得到了对当前异常对象的引用 可以直接把它重新抛出
重抛异常会把异常抛给上一级环境中的异常处理程序 同一个try块的后续catch子句将被忽略 此外 异常对象的所有信息都得以保持 所以高一级环境中捕获此异常的处理程序可以从这个异常对象中得到所有信息
如果只是把当前异常对象重新抛出 那么printStackTrace()方法显示的将是原来异常抛出点的调用栈信息 而并非重新抛出点的信息 要想更新这个信息 可以调用fillInStackTrace()方法 这将返回一个Throwable对象 它是通过把当前调用栈信息填入原来那个异常对象而建立的 就像这样
调用fillInStackTrace()的那一行就成了异常的新发生地了
有可能在捕获异常之后抛出另一种异常 这么做的话 得到的效果类似于使用fillInStackTrace() 有关原来异常发生点的信息会丢失 剩下的是与新的抛出点有关的信息
最后那个异常仅知道自己来自main() 而对f()一无所知
异常链
常常会想要在捕获一个异常后抛出另一个异常 并且希望把原始异常的信息保存下来 这被称为异常链 在JDK1.4以前 程序员必须自己编写代码来保存原始异常的信息 现在所有Throwable的子类在构造器中都可以接受一个cause(因由)对象作为参数 这个cause就用来表示原始异常 这样通过把原始异常传递给新的异常 使得即使在当前位置创建并抛出了新的异常 也能通过这个异常链追踪到异常最初发生的位置
在Throwable的子类中 只有三种基本的异常类提供了带cause参数的构造器 它们是Error(用于Java虚拟机报告系统错误) Exception以及RuntimeException 如果要把其他类型的异常链接起来 应该使用initCause()方法而不是构造器
下面的例子能让你在运行时动态地向DynamicFields对象添加字段
Java标准异常
Throwable这个Java类被用来表示任何可以作为异常被抛出的类 Throwable对象可分为两种类型(指从Throwable继承而得到的类型) Error用来表示编译时和系统错误(除特殊情况外 一般不用你关心) Exception是可以被抛出的基本类型 在Java类库 用户方法以及运行时故障中都可能抛出Exception型异常 所以Java程序员关心的基类型通常是Exception
特例:RuntimeException
属于运行时异常的类型有很多 它们会自动被Java虚拟机抛出 所以不必在异常说明中把它们列出来 这些异常都是从RuntimeException类继承而来 所以既体现了继承的优点 使用起来也很方便 这构成了一组具有相同特征和行为的异常类型 并且 也不再需要在异常说明中声明方法将抛出RuntimeException类型的异常(或者任何从RuntimeException继承的异常) 它们也被称为 不受检查异常 这种异常属于错误 将被自动捕获 就不用你亲自动手了 要是自己去检查RuntimeException的话 代码就显得太混乱了 不过尽管通常不用捕获RuntimeException异常 但还是可以在代码中抛出RuntimeException类型的异常
如果不捕获这种类型的异常会发生什么事呢 因为编译器没有在这个问题上对异常说明进行强制检查 RuntimeException类型的异常也许会穿越所有的执行路径直达main()方法 而不会被捕获 要明白到底发生了什么 可以试试下面的例子
RuntimeException(或任何从它继承的异常)是一个特例 对于这种异常类型 编译器不需要异常说明 其输出被报告给了System.err
如果RuntimeException没有被捕获而直达main() 那么在程序退出前将调用异常的printStackTrace()方法
请务必记住:只能在代码中忽略RuntimeException(及其子类)类型的异常 其他类型异常的处理都是由编译器强制实施的 究其原因 RuntimeException代表的是编程错误
使用finally进行清理
对于一些代码 可能会希望无论try块中的异常是否抛出 它们都能得到执行 这通常适用于内存回收之外的情况(因为回收由垃圾回收器完成) 为了达到这个效果 可以在异常处理程序后面加上finally子句 完整的异常处理程序看起来像这样
为了证明finally子句总能运行 可以试试下面这个程序
这个程序也给了我们一些思路 当Java中的异常不允许我们回到异常抛出的地点时 那么该如何应对呢? 如果把try块放在循环里 就建立了一个 程序继续执行之前必须要达到 的条件 还可以加入一个static类型的计数器或者别的装置 使循环在放弃以前能尝试一定的次数 这将使程序的健壮性更上一个台阶
finally用来做什么
当要把除内存之外的资源恢复到它们的初始状态时 就要用到finally子句 这种需要清理的资源包括:已经打开的文件或网络连接 在屏幕上面的图形 甚至可以是外部世界的某个开关 如下面例子所示
程序的目的是要确保main()结束的时候开关必须是关闭的 所以在每个try块和异常处理程序的末尾都加入了对sw.off()方法的调用 但也可能有这种情况:异常被抛出 但没被处理程序捕获 这时sw.off()就得不到调用 但是有了finally 只要把try块中的清理代码移放在一块即可
甚至在异常没有被当前的异常处理程序捕获的情况下 异常处理机制也会在跳到更高一层的异常处理程序之前 执行finally子句
当涉及break和continue语句的时候 finally子句也会得到执行 请注意 如果把finally子句和带标签的break及continue配合使用 在Java里就没必要使用goto语句了
在return中使用finally
因为finally子句总是会执行的 所以在一个方法中 可以从多个点返回 并且可以保证重要的清理工作仍旧会执行
缺憾:异常丢失
遗憾的是 Java的异常实现也有瑕疵 异常作为程序出错的标志 决不应该被忽略 但它还是有可能被轻易地忽略 用某些特殊的方式使用finally子句 就会发生这种情况
从输出中可以看到 VeryImportantException不见了 它被finally子句里的HoHumException所取代 这是相当严重的缺陷 因为异常可能会以一种比前面例子所示更微妙和难以察觉的方式完全丢失 相比之下 C++把 前一个异常还没处理就抛出下一个异常 的情形看成是糟糕的编程错误 也许在Java的未来版本中会修正这个问题(另一方面 要把所有抛出异常的方法 如上例中的dispose()方法 全部打包放到try-catch子句里面)
一种更加简单的丢失异常的方式是从finally子句中返回
异常的限制
当覆盖方法的时候 只能抛出在基类方法的异常说明里列出的那些异常 这个限制很有用 因为这意味着 当基类使用的代码应用到其派生类对象的时候 一样能够工作(当然 这是面向对象的基本概念) 异常也不例外
下面例子演示了这种(在编译时)施加在异常上面的限制
异常限制对构造器不起作用 你会发现StormyInning的构造器可以抛出任何异常 而不必理会基类构造器所抛出的异常 然而 因为基类构造器必须以这样或那样的方式被调用(这里默认构造器将自动被调用) 派生类构造器的异常说明必须包含基类构造器的异常说明
派生类构造器不能捕获基类构造器抛出的异常
尽管在继承过程中 编译器会对异常说明做强制要求 但异常说明本身并不属于方法类型的一部分 方法类型是由方法的名字与参数的类型组成的 因此 不能基于异常说明来重载方法 此外 一个出现在基类方法的异常说明中的异常 不一定会出现在派生类方法的异常说明里 这点同继承的规则明显不同 在继承中 基类的方法必须出现在派生类里 换句话说 在继承和覆盖的过程中 某个特定方法的 异常说明的接口 不是变大了而是变小了——这恰好和类接口在继承时的情形相反
构造器
有一点很重要 即你要时刻询问自己 如果异常发生了 所有东西能被正确的清理吗 尽管大多数情况下是非常安全的 但涉及构造器时 问题就出现了 构造器会把对象设置成安全的初始状态 但还会有别的动作 比如打开一个文件 这样的动作只有在对象使用完毕并且用户调用了特殊的清理方法之后才能得以清理 如果在构造器内抛出了异常 这些清理行为也许就不能正常工作了 这意味着在编写构造器时要格外细心
在下面的例子中 建立了一个InputFile类 它能打开一个文件并且每次读取其中的一行
如果FileReader的构造器失败了 将抛出FileNotFoundException异常 对于这个异常 并不需要关闭文件 因为这个文件还没有被打开 而任何其他捕获异常的catch子句必须关闭文件 因为在它们捕获到异常之时 文件已经打开了(当然 如果还有其他方法能抛出FileNotFoundException 这个方法就显得有些投机取巧了 这时 通常必须把这些方法分别放到各自的try块里) close()方法也可能会抛出异常 所以尽管它已经在另一个Catch子句块里了 还是要再用一层try-catch——对Java编译器而言 这只不过是又多了一对花括号 在本地做完处理之后 异常被重新抛出 对于构造器而言这么做是很合适的 因为你总不希望去误导调用方 让他认为 这个对象已经创建完毕 可以使用了
在本例中 由于finally会在每次完成构造器之后都执行一遍 因此它实在不该是调用close()关闭文件的地方 我们希望文件在InputFile对象的整个生命周期内都处于打开状态
getLine()方法会返回表示文件下一行内容的字符串 它调用了能抛出异常的readLine() 但是这个异常已经在方法内得到处理 因此getLine()不会抛出任何异常 在设计异常时有一个问题:应该把异常全部放在这一层处理 还是先处理一部分 然后再向上层抛出相同的(或新的)异常 又或者是不做任何处理直接向上层抛出 如果用法恰当的话 直接向上层抛出的确能简化编程 在这里 getLine()方法将异常转换为RuntimeException 表示一个编程错误
用户在不再需要InputFile对象时 就必须调用dispose()方法 这将释放BufferedReader和/或FileReader对象所占用的系统资源(比如文件句柄) 在使用完InputFile对象之前是不会调用它的 可能你会考虑把上述功能放到finalize()里面 但你不知道finalize()会不会被调用(即使能确定它将被调用 也不知道在什么时候调用) 这也是Java的缺陷:除了内存的清理之外 所有的清理都不会自动发生 所以必须告诉客户端程序员 这是他们的责任
对于在构造阶段可能会抛出异常 并且要求清理的类 最安全的使用方式是使用嵌套的try子句
请仔细观察这里的逻辑:对InputFile对象的构造在其自己的try语句块中有效 如果构造失败 将进入外部的catch子句 而dispose()方法不会被调用 但是 如果构造成功 我们肯定想确保对象能够被清理 因此在构造之后立即创建了一个新的try语句块 执行清理的finally与内部的try语句块相关联 在这种方式中 finally子句在构造失败时是不会执行的 而在构造成功时将总是执行
这种通用的清理惯用法在构造器不抛出任何异常时也应该运用 其基本规则是:在创建需要清理的对象之后 立即进入一个try-finally语句块
本例中的异常处理的棘手程度 对于应该创建不能失败的构造器是一个有力的论据 尽管这么做并非总是可行
注意 如果dispose()可以抛出异常 那么你可能需要额外的try语句块 基本上 你应该仔细考虑所有的可能性 并确保正确处理每一种情况
异常匹配
抛出异常的时候 异常处理系统会按照代码的书写顺序找出 最近 的处理程序 找到匹配的处理程序之后 它就认为异常将得到处理 然后就不再继续查找
查找的时候并不要求抛出的异常同处理程序所声明的异常完全匹配 派生类的对象也可以匹配其基类的处理程序 就像这样
如果把捕获基类的catch子句放在最前面 以此想把派生类的异常全给 屏蔽 掉 就像这样
这样编译器就会发现Sneeze的catch子句永远也得不到执行 因此它会向你报告错误
其他可选方式
异常处理的一个重要原则是 只有在你知道如何处理的情况下才捕获异常 实际上 异常处理的一个重要目标就是把错误处理的代码同错误发生的地点相分离 这使你能在一段代码中专注于要完成的事情 至于如何处理错误 则放在另一段代码中完成 这样以来 主干代码就不会与错误处理逻辑混在一起 也更容易理解和维护 通过允许一个处理程序去处理多个出错点 异常处理还使得错误处理代码的数量趋向于减少
被检查的异常 使这个问题变得有些复杂 因为它们强制你在可能还没准备好处理错误的时候被迫加上catch子句 这就导致了吞食则有害(harmful if swallowed)的问题
程序员们只做最简单的事情 常常是无意中 吞食 了异常 然而一旦这么做 虽然能通过编译 但除非你记得复查并改正代码 否则异常将会丢失 异常确实发生了 但 吞食 后它却完全消失了 因为编译器强迫你立刻写代码来处理异常 所以这种看起来最简单的方法 却可能是最糟糕的做法
把异常传递给控制台
对于简单的程序 最简单而又不用写多少代码就能保护异常信息的方法 就是把它们从main()传递到控制台 例如 为了读取信息而打开一个文件 必须对FileInputStream进行打开和关闭操作 这就可能会产生异常 对于简单的程序 可以像这样做
把 被检查的异常 转换为 不检查的异常
在编写你自己使用的简单程序时 从main()中抛出异常是很方便的 但这不是通用的方法 问题的实质是 当在一个普通方法里调用别的方法时 要考虑到 我不知道该这样处理这个异常 但是也不想把它 吞 了 或者打印一些无用的消息 JDK1.4的异常链提供了一种新的思路来解决这个问题 可以直接把 被检查的异常 包装进RuntimeException里面 就像这样
如果想把 被检查的异常 这种功能 屏蔽 掉的话 这看上去像是一个好办法 不用 吞下 异常 也不必把它放到方法的异常说明里面 而异常链还能保证你不会丢失任何原始异常的信息
这种技巧给了你一种选择 你可以不写try-catch子句和/或异常说明 直接忽略异常 让它自己沿着调用栈往上 冒泡 同时 还可以用getCause()捕获并处理特定的异常 就像这样
异常使用指南
应该在下列情况下使用异常: