写在最前:本文全程参考《Java核心技术卷I》,添加了一些个人的思考和整理。如有错误欢迎指出!
目录
异常和断言
用户在遇到意外错误时会感觉不爽,为了尽量避免这种事情的发送,应该做到:
- 向用户通知错误
- 保存用户的操作
- 允许用户妥善地退出程序
在测试期间,需要允许大量的检查以确保程序操作的正确性,而这些检查可能会非常耗时,在测试完成后也没有保留的必要。可以选择将这些检查一句句删除,需要另做测试时再粘贴回来,但是这样子会很繁琐。我们可以使用断言机制来处理这种难题。
在程序出错时,为了记录错误信息,以便日后分析,这时我们可以使用日志来记录。
1、处理错误
如果由于出错而使得某些操作没有完成,程序应该:
- 返回到一直安全的状态,并且能够让用户执行其他的命令
- 或者,允许用户保存所有工作的结果,并以妥善的方式终止程序
为了能够处理程序中的异常情况,必须考虑到程序中可能会会出现的意外错误和问题:
- 用户输入错误
- 设备错误(执行打印程序时,打印机没连接上;没有网络等等)
- 物理限制(磁盘或内存已满)
- 代码错误(数组下标越界、使用空指针)
如果某个方法不能够采用正常的途径完成它的任务,可以通过另一个路径退出方法。在这种情况下没法发并不返回任何值,而是抛出(throw)一个封装了错误信息的对象,且这个方法会立刻退出,不返回任何值,也不会继续执行代码的后续代码。此外,也不会从调用这个方法的代码继续执行,异常处理机制将开始搜索能够处理这种异常状况的异常处理器(exception handler)
1.1 异常分类
所以的异常都是由Throwable
类继承来的,而这个类在下一层被封为两大分支:Error
和Exception
-
Error
层描述了Java运行时系统的内部错误和资源耗尽错误。自己的应用程序不应该抛出这种类型的错误,程序员无法处理此错误,只能避免产生Error
。发生了这样的错误,应该通知用户后尽可能妥善地终止程序。 -
Exception
层又被分解为两个分支:RuntimeException
和IOException
RuntimeException
异常:由编程错误产生的异常。如:- 错误的强制类型转换
- 数组访问越界
- 访问空指针
IOException
异常:不属于RuntimeException
的异常,如:- 试图超越文件末尾继续读取文件
- 试图打开一个不存在的文件
- 试图根据给定的字符串查找不存在的类
java将派生于Error
类或RuntimeException
类的所有异常称为非检查型(unchecked)异常,而其他异常为检查型(checked)异常。
1.2 声明检查型异常
声明异常的场景
java的方法可以抛出异常。一个方法不仅要告诉编译器要返回什么值,还要告诉编译器可能发生什么错误。
需要抛出异常的情况:
- 调用了一个抛出检查型异常的方法
- 检查到一个错误,并利用
throw
语句抛出一个检查型异常 - 程序出现错误会自动抛出一个非检查型异常。(如除以零的错误)
- java虚拟机或运行时库出现的错误
对于前面两种情况(检查型异常),必须告诉(声明)调用这个方法的程序员可能抛出的异常。
一个方法必须声明(throws)所有可能抛出的检查型异常,而非检查型异常要么在控制范围之外(Error),要么从一开始就应该尽可能避免(RuntimeException)
声明异常与throws关键字
方法抛出异常应该在方法首部的异常规范处声明可能抛出的所有的检查型异常:
public Image loadImage(String s) throws FileNotFoundException, EOFException {
// ...
}
不需要声明(抛出)java内部的错误,即从Error
继承的异常。因为任何程序的代码都可能抛出这些异常,我们无法控制。同理,也不应该声明(throws
)从RuntimeException
继承的那些非检查型异常。
如果在子类中覆盖了父类的一个方法,子类方法中声明的检查型异常不能比父类的异常更通用(甚至允许不抛出异常)。如果父类没有抛出异常,那么子类也不行。
1.3 创建异常类
自定义异常类应该有两个构造器,一个是无参构造,一个是带字符串参数的构造器
public class MyException extends Exception {
public MyException(){}
public MyException(String msg) {
super(msg);
}
}
2、捕获异常
如果出现了一个异常,要么向上抛出异常,要么对其进行捕获(catch)
2.1 try-catch语句块
如果产生了某个异常但是并没有在任何一个地方部落,程序就会终止,并在控制台上打印栈轨迹信息(printStackTrace
方法)以供debug
想要捕获异常,就需要使用try-catch
语句块:
- 如果
try-catch
语句块之间出现了catch
子句指定的异常类,那么程序就会跳过try
语句块中剩余的代码,并执行catch
语句块中的异常处理器代码。 - 如果没有产生异常,那么程序会跳过
catch
语句。 - 如果方法中抛出了
catch
子句中没有声明的一个异常类型,那么这个方法就会立即退出
2.2 捕获多个异常
try
与居住可以捕获多个类型,并对不同类型第一次做出不同的处理,也就是可以为每一个异常提供一个catch
子句:
try {
// ...
} catch (FileNotFoundException e) {
e.printStackTrace();
} catch (UnknowHostException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
}
异常对象可能包含有关的异常信息,可以使用e.getMessage()
方法获取更多错误信息(如果有的话,这个就是自定义异常中,用带字符串的构造器构造出来的异常对象),或者使用e.getClass().getName()
获取异常的实际类型
注意:异常变量为隐式的final
变量,不允许修改
2.3 再次抛出异常和异常链
程序抛出的异常通常是没有文本信息的,我们可以在捕获异常时再抛出一个带文本信息的同类型异常:
try {} catch (SQLException e) {
throw new ServletException("database error: " + e.getMessage());
}
实际上还有更好的方法:
try {} catch (SQLException e) {
// 更好的方法:
ServletException newE = new ServletException("database error");
newE.initCause(e);
throw newE;
}
捕获到这个异常时,可以使用e.getCause()
获取原始异常。如此一来就可以多次包装异常,且不会丢失原始异常的细节。(这个方法在转换异常类型时也很有用)
2.4 finally子句
代码抛出一个异常时,就会停止处理这个方法的剩余代码,并退出这个方法,如果此时还有代码需要执行,比如资源关闭的代码,那就需要使用finally
语句。
try
语句可以没有catch
而有finally
程序可能在以下三种情况下执行finally
语句:
- 代码没有抛出异常。此时,程序首先执行完
try
语句块中的代码,然后执行finally
语句块的代码。随后,继续执行finally
语句块之后的代码。 - 代码抛出一个异常,并在一个
catch
子句中捕获。此时,程序将执行try
语句块中的代码,直到抛出异常,之后,进入对应的catch
子句执行其代码,最后执行finally
语句块的代码- 如果
catch
中没有继续抛出异常,那么在finally
之后会继续执行后续代码 - 如果
catch
中抛出异常了,那么finally
之后方法终止,回到方法的调用者。
- 如果
- 代码抛出一个异常,并且没有被
catch
捕获。此时,程序将执行try
语句块中的代码,直到抛出异常,之后,直接直接finally
语句,之后方法终止,返回到方法的调用者。
2.5 try-with-Resource语句
try-with-Resource
语句可以自动关闭资源。
AutoCloseable
接口有一个close
方法,用于关闭资源。如果在try-catch
中打开了一个资源,这个资源是实例化了一个AutoCloseable
接口的子类,可以使用try-with-Resource
语句省去资源的关闭操作。
try-with-Resource
语句的最简形式为:
try (Resource res = ...) {
// 资源操作
}
try
语句块结束或因出现异常而终止时,会自动调用res.close();
(前提是这个res实现了AutoCloseable
接口)
使用示例:
try (Scanner in = new Scanner(new FileInputStream("/users/words"))) {
while (in.next()) {
System.out.println(in.next());
}
}
// 多个资源用分号分隔
try (
Scanner in = new Scanner(new FileInputStream("/users/words"));
PrintWriter out = new PrintWriter("out.txt")
) {
while (in.next()) {
System.out.println(in.next());
}
// in和out都会在结束时调用close()方法
}
在java9中,可以在try
的首部(圆括号)中使用之前声明的事实最终变量:
public static void printAll(String[] lines; PrintWriter out) {
try (out) { // 事实最终变量
for (String line : lines) {
out.println(line);
}
} // out会在结束时调用close()方法
}
try-with-Resources
语句自身也可以使用catch-finally
语句,这两个语句会在资源关闭后执行
在try-with-Resources
语句中,如果try代码块出现异常,且资源的close
方法执行时也出现异常,那么前者会被重新抛出,而close
方法抛出的异常会“被抑制”。这些异常将被自动捕获,并由addSuppressed
方法添加到原来的异常。如果对这些异常感兴趣,可以使用getSuppressed
方法,它将生产从close
方法抛出并被抑制的异常的数组。
2.6 分析堆栈轨迹元素
堆栈轨迹(stack trace)是程序执行过程中,某个特定点上所有挂起的方法调用的一个列表。java因为一个未捕获的异常而终止时,就会显示堆栈轨迹。
可以调用Throwable
类的printStackTrace
方法访问堆栈轨迹的文本描述信息。
StackWalker与栈帧
一种更灵活的方式是使用StackWalker
类,它会生成一个StackWalker.StackFrame
实例流,其中每个实例分别描述一个栈帧(stack frame)
可以利用以下调用迭代处理栈帧:
StacjWalker = StackWalker.getInstance();
walker.forEach(frame -> 分析栈帧)
如果想用懒方式处理Stream<StackWalker.StackFrame>
,可以调用:
walker.walk(stream -> 进程流process stream)
利用StackWalker.StackFrame
类的一些方法可以得到所执行代码行的文件名和行号,以及类对象和方法名。toString
方法会生成一个字符串,包含所有这些信息。
3、异常的使用技巧
- 异常处理不能代替简单的条件判断和测试,前者效率远低于后者。
- 不要过分地细化异常,可以将一个代码逻辑内的代码包裹在一个
try-catch
中 - 充分利用异常层次结构。不要只抛出
RuntimeException
异常,应该寻找一个适合的子类或创建自己的异常类。不要只捕获Throwable
异常,否则会降低代码可读性,提高维护成本。 - 不要压制异常
- 检查错误时,苛刻比放任好。比如栈为空时,返回null还是异常好?建议是返回一个
EmptyStackException
异常,好过以后出现空指针异常 - 适当传递异常(而不是直接捕获)【早抛出,晚捕获】
4、断言
4.1 断言的概念
断言机制运行在测试期间插入一些检查,而在生产代码中会自动删除这些检查。
java引入了关键字assert
来声明断言:
assert codition;
assert codition : expression;
这两个语句都会计算条件,如果为false,则抛出一个AssertionError
异常
expression表达式部分的目的是产生一个消息字符串,储存在异常对象中。AssertionError
异常并不储存具体的表达式的值,以后也将无法获得这个表达式值。
如果是用表达式的值,久违鼓励程序员尝试从断言失败中恢复程序运行,这有违断言机制的初衷
assert x >= 0; // 断言x是非负数
assert x >= 0 : x; // 如果断言为false,异常对象会储存x的取值,以便之后显示
assert x >= 0 : "x >= 0"; // 如果断言为false,异常对象会储存判断条件"x >= 0",以便之后显示
4.2 启动和禁用断言
默认情况下,断言是禁用的,可以在运行程序是用-enableassertions
或者-ea
启动断言:
java -ea MyApp // 项目
java -ea:MyClass // 类
java -ea:com.mycompany.mypackage // 包
也可以使用-disableassertions
或-da
禁用断言
不必重新编译程序来启动或禁用断言,启动或禁用断言是类加载器的功能。禁用断言时,类加载器会取出断言代码。因此不会影响程序实际运行的效率
不过,对于那些没有类加载器的“系统类”,不能使用-ea
和-da
开关断言,而需要使用-enablesystemassertions
或者-esa
开关断言。
4.3 什么时候使用断言
断言检查只是在开发和测试阶段打开。
断言是用来捕获程序员自己敲代码时的失误,用来确定程序内部错误的位置。断言不应该成为程序的一部分。
断言失败是致命的,不可修复的错误。不应该使用断言向程序的其他部门通知发了可恢复性的错误,也不应该利用断言与程序用户进行沟通。
使用场景:例如有一个方法要求传入一个非空参数,就可以断言要传入的参数不为空。(计算机科学家将这种约定称为前置条件(precondition)
4.4使用断言提供假设文档
很多程序员使用注释来提供底层假设的文档:
if (i % 3 == 0) {
// ...
} else if (i % 3 == 1) {
// ...
} else {
// i % 3 ==2
// ...
}
此时在最后一个else
处使用断言会更好:
if (i % 3 == 0) {
// ...
} else if (i % 3 == 1) {
// ...
} else {
assert i % 3 ==2;
// ...
}
我们发现,i%3
的值可以是1,2,3,但是如果i
是负数,那么余数可以是-1,-2,因此,我们应该假设i
是非负数,即在进入if
语句之前使用断言:assert i >= 0