2.5 语句

语句是 Java 语言中可执行代码的基本单位,表达程序员的某个意图。和表达式不同,Java 语句没有返回值。语句一般包含表达式和运算符(尤其是赋值运算符),执行的目的往往是为了它们的副作用。

Java 定义的很多语句是流程控制语句,例如条件语句和循环语句,它们通过合理的方式改变默认的线性执行顺序。表 2-5 总结了 Java 定义的语句。

表2-5:Java语句

语句 作用 句法
表达式 副作用 var = expr; expr++; method(); new Type();
复合语句 语句组 { statements }
空语句 无作用 ;
标注 为语句命名 label: statement
变量 声明变量 [final] type name[=value][,name[=value]] …;
if 条件判断 if (expr) statement[ else statement]
switch 条件判断 switch (expr) { [ case expr : statements ] … [ default: statements ] }
while 循环 while (expr) statement
do 循环 do statement while (expr);
for 简单循环 for (init; test; increment) statement
遍历 迭代集合 for (variable : iterable) statement
break `退出块 break [label];
continue 重新开始循环 continue [label];
return 结束方法 return [expr];
synchronized 临界区 synchronized (expr) {statements}
throw 抛出异常 throw expr;
try 处理异常 try {statements}[ catch (type name) { statements } ] … [ finally { statements } ]
assert 验证不变式 assert invariant[ :error];

2.5.1 表达式语句

本章前面已经说过,某些 Java 表达式有副作用。也就是说,这些表达式不仅能计算得到的一个值,还能以某种方式改变程序的状态。只要表达式有副作用,在表达式后面加上分号就能作为语句使用。合法的表达式语句有赋值、递增和递减、方法调用以及对象创建。例如:

  1. a = 1; // 赋值
  2. x *= 2; // 带运算的赋值
  3. i++; // 后递增
  4. --c; // 前递减
  5. System.out.println("statement"); // 方法调用

2.5.2 复合语句

复合语句是一些放在花括号里的语句,语句的数量和类型不限。Java 句法规定可以使用语句的地方都可以使用复合语句:

  1. for(int i = 0; i < 10; i++) {
  2. a[i]++; // 这个循环体是一个复合语句
  3. b[i]--; // 包括两个表达式语句
  4. } // 放在花括号里

2.5.3 空语句

在 Java 中,空语句使用一个分号表示。空语句什么也不做,不过这种句法偶尔有用。例如,在 for 循环中可以使用空语句表明循环体为空:

  1. for(int i = 0; i < 10; a[i++]++) // 递增数组元素
  2. /* empty */; // 循环体是空语句

2.5.4 标注语句

标注语句就是有名称的语句。命名方法是,在语句前加上一个标识符和一个冒号。breakcontinue 语句会用到标注。例如:

  1. rowLoop: for(int r = 0; r < rows.length; r++) { // 一个标注循环
  2. colLoop: for(int c = 0; c < columns.length; c++) { // 另一个
  3. break rowLoop; // 使用标注
  4. }
  5. }

2.5.5 局部变量声明语句

局部变量经常直接称为变量,是值存储位置的符号名称,在方法和复合语句中定义。所有变量在使用之前必须先声明,声明变量的方法是使用声明语句。Java 是静态类型语言,声明变量时要指定变量的类型,而且只有这种类型的值才能存储在这个变量中。

变量声明语句最简单的形式只需指定变量的类型和名称:

  1. int counter;
  2. String s;

声明变量时还可以包含一个初始化表达式,用于指定变量的初始值。例如:

  1. int i = 0;
  2. String s = readLine();
  3. int[] data = {x+1, x+2, x+3}; // 稍后会介绍数组初始化表达式

Java 编译器不允许使用未初始化的局部变量,所以,方便起见,通常会在一个语句中同时声明和初始化变量。初始化表达式不必是编译器能计算得到结果的字面量或常量表达式,也可以是程序运行时能计算出结果的任意复杂表达式。

一个变量声明语句可以声明和初始化多个变量,但是所有变量必须是同一类型。变量名称和可选的初始化表达式使用逗号分隔:

  1. int i, j, k;
  2. float x = 1.0, y = 1.0;
  3. String question = "Really Quit?", response;

变量声明语句可以以 final 关键字开头。这个修饰符表明,为变量指定初始值之后,其值就不能改变了:

  1. final String greeting = getLocalLanguageGreeting();

后文,尤其是讨论不可变编程风格时,还会说明 final 关键字。

C 语言程序员要注意,在 Java 代码的任何地方都能使用变量声明语句,而不局限于只能在方法和代码块的开头使用。稍后会介绍,局部变量声明还可以集成到 for 循环的初始化部分里。

局部变量只能在其定义所在的方法和代码块中使用,这叫变量的作用域词法作用域

  1. void method() { // 定义一个方法
  2. int i = 0; // 声明变量i
  3. while (i < 10) { // 在这个作用域里可以使用i
  4. int j = 0; // 声明变量j;j的作用域从这里开始
  5. i++; // 在这个作用域里可以使用i;递增i
  6. } // 在这个作用域里不能使用j了
  7. System.out.println(i); // 在这个作用域里仍能使用i
  8. } // i的作用域在这结束

2.5.6 if/else语句

if 语句是基本的控制语句,允许 Java 作出判断,或者更准确地说,根据条件决定执行哪些语句。if 语句有关联的表达式和语句,如果表达式的计算结果为 true,解释器会执行关联的语句;如果表达式的计算结果为 false,解释器会跳过关联的语句。

2.5 语句 - 图1 Java 允许在关联的表达式中使用包装类型 Boolean 代替基本类型 boolean。 此时,包装对象会自动拆包。

下面是一个 if 语句示例:

  1. if (username == null) // 如果username的值是null
  2. username = "John Doe"; // 使用默认值

虽然括号看起来不重要,但却是 if 语句句法不可缺少的一部分。前面说过,花括号中的语句块本身也是语句,所以 if 语句还可以写成这样:

  1. if ((address == null) || (address.equals(""))) {
  2. address = "[undefined]";
  3. System.out.println("WARNING: no address specified.");
  4. }

if 语句可以包含一个可选的 else 关键字,并在后面跟着另一个语句。在这种形式中,如果表达式的计算结果为 true,会执行第一个语句,否则执行第二个语句。例如:

  1. if (username != null)
  2. System.out.println("Hello " + username);
  3. else {
  4. username = askQuestion("What is your name?");
  5. System.out.println("Hello " + username + ". Welcome!");
  6. }

嵌套使用 if/else 语句时要注意,必须确保 else 子句和正确的 if 语句匹配。例如下面的代码:

  1. if (i == j)
  2. if (j == k)
  3. System.out.println("i equals k");
  4. else
  5. System.out.println("i doesn't equal j"); // 错误!!

在这个例子中,根据句法,内层 if 语句是外层 if 语句的单个语句。但是,(除了缩进给出的提示)else 子句和哪个 if 语句匹配并不明确。而且,这个例子的缩进提示也是错的。规则是这样的,else 子句和最近的 if 语句关联。正确缩进后的代码如下:

  1. if (i == j)
  2. if (j == k)
  3. System.out.println("i equals k");
  4. else
  5. System.out.println("i doesn't equal j"); // 错误!!

这是合法的代码,但显然没有清楚表明程序员的意图。使用嵌套 if 语句时,应该使用花括号,让代码更易读。下面是这个示例更好的编写方式:

  1. if (i == j) {
  2. if (j == k)
  3. System.out.println("i equals k");
  4. }
  5. else {
  6. System.out.println("i doesn't equal j");
  7. }

else if 子句

if/else 语句适用于测试一个条件,并在两个语句或代码块中选择一个执行。那么需要在多个代码块中选择时怎么办呢?这种情况一般使用 else if 子句。这其实不是新句法,而是标准 if/else 语句的惯用句法。用法如下:

  1. if (n == 1) {
  2. // 执行代码块#1
  3. }
  4. else if (n == 2) {
  5. // 执行代码块#2
  6. }
  7. else if ( n== 3) {
  8. // 执行代码块#3
  9. }
  10. else {
  11. // 如果前面的条件判断都失败,执行代码块#4
  12. }

这段代码没什么特别,只是一系列 if 语句,其中各 if 语句是前一个语句 else 子句的一部 分。较之完全使用嵌套的形式(如下所示),更推荐使用 else if

  1. if (n == 1) {
  2. // 执行代码块#1
  3. }
  4. else {
  5. if (n== 2) {
  6. // 执行代码块#2
  7. }
  8. else {
  9. if (n == 3) {
  10. // 执行代码块#3
  11. }
  12. else {
  13. // 如果前面的条件判断都失败,执行代码块#4
  14. }
  15. }
  16. }

2.5.7 switch语句

if 语句在程序的执行过程中创建一个分支。如前一节所述,可以使用多个 if 语句创建多个分支。但这么做并不总是最好的方式,尤其是所有分支都判断同一个变量的值时,在多个 if 语句中重复检查这个变量的值效率不高。

更好的方式是使用从 C 语言继承而来的 switch 语句。虽然这种语句的句法没有 Java 中其他语句优雅,但是鉴于它的实用性,还是值得使用。

2.5 语句 - 图2 switch 语句以一个表达式开始,这个表达式的返回值是 intshortcharbyte(或这四个类型的包装类型)、String 或枚举类型(详细介绍参见第 4 章)。

这个表达式后面跟着一段放在花括号里的代码,这段代码中有多个入口点,对应于表达式各个可能的返回值。例如,下面的 switch 语句等效于前一节的多个 ifelse/if 语句:

  1. switch(n) {
  2. case 1: // 如果n == 1,从这开始
  3. // 执行代码块#1
  4. break; // 在这停止
  5. case 2: // 如果n == 2,从这开始
  6. // 执行代码块#2
  7. break; // 在这停止
  8. case 3: // 如果n == 3,从这开始
  9. // 执行代码块#3
  10. break; // 在这停止
  11. default: // 如果前面的条件判断都失败了……
  12. // 执行代码块#4
  13. break; // 在这停止
  14. }

从这个示例可以看出,switch 语句中的各入口点有两种形式:一种使用关键字 case 标注,后面跟着一个整数和一个冒号;另一种使用特殊的关键字 default 标注,后面跟着一个冒号。解释器执行 switch 语句时,先计算括号中表达式的值,然后查找有没有匹配这个值的 case 标注。如果有,解释器就从这个 case 标注后的代码块中第一个语句开始执行;如果没有,解释器从特殊的 default 标注后的代码块中第一个语句开始执行;如果没有 default 标注,解释器会跳过整个 switch 语句主体。

注意,在前面的代码中每个 case 子句末尾都有 break 关键字。本章后面会介绍 break 语句,这里,它的作用是让解释器退出 switch 语句的主体。switch 语句中的 case 子句只用来指定需要执行的代码起始点,各 case 子句后的代码块不是相互独立的,没有任何隐式的结束点。因此,必须使用 break 或相关的语句明确指定各 case 子句在哪里结束。如果没有 break 语句,switch 语句会从匹配的 case 标注后第一个语句开始执行,一直到代码块结束为止。极少数的情况下会这样编写代码,从一个 case 标注执行到下一个 case 标注;99% 的情况下都要在每个 casedefault 子句中加上一个语句,结束执行 switch 语句。一般情况下使用 break 语句,不过 returnthrow 语句也行。

switch 语句可以使用多个 case 子句标注同一个希望执行的语句。例如下面这个方法中的 switch 语句:

  1. boolean parseYesOrNoResponse(char response) {
  2. switch(response) {
  3. case 'y':
  4. case 'Y': return true;
  5. case 'n':
  6. case 'N': return false;
  7. default:
  8. throw new IllegalArgumentException("Response must be Y or N");
  9. }
  10. }

switch 语句和 case 标注有些重要的限制。首先,switch 语句关联的表达式必须是适当的类型,可以是 bytecharshortint(及这四种类型的包装类型)、枚举类型或 String 类型,不支持浮点数和布尔类型,虽然 long 也是整数类型,但也不能使用。其次,各 case 标注关联的值必须是编译器能计算的常量或常量表达式。case 标注不能包含运行时表达式,例如变量或方法调用。再者,case 标注中的值必须在 switch 表达式返回值对应数据类型的取值范围内。最后,不能有两个或多个 case 标注使用同一个值,而且 default 标注不能超过一个。

2.5.8 while语句

while 语句是一种基本语句,目的是让 Java 执行重复的操作。换言之,while 语句是 Java 的主要循环结构之一。句法如下:

  1. while (expression)
  2. statement

while 语句先计算 expression 的值,计算结果必须是布尔值。如果计算结果为 false,解释器跳过循环中的 statement,执行程序中的下一个语句。如果计算结果为 true,解释器执行组成循环主体的 statement,然后再次计算 expression 的值。如果计算结果为 false,解释器执行程序中的下一个语句;否则,再次执行 statement。只要 expression 的计算结果为 true,就会一直循环下去,while 语句结束后(即 expression 的计算结果为 false)解释器才会执行下一个语句。

下面是一个 while 循环示例,打印数字 09

  1. int count = 0;
  2. while (count < 10) {
  3. System.out.println(count);
  4. count++;
  5. }

可以看出,在这个示例中,变量 count 的起始值是 0,循环主体每执行一次,count 的值就会增加 1。循环执行 10 次后,表达式的计算结果变成 false(即 count 的值不再小于 10),此时 while 语句结束,Java 解释器继续执行程序中的下一个语句。大多数循环都有一个计数器变量,例如这个例子中的 count。循环计数器变量的名称经常使用 ijk,不过你应该使用意义更明确的名字,以便代码更易理解。

2.5.9 do语句

do 循环和 while 循环很像,不过循环表达式不在循环开头,而在循环末尾测试。也就是说,循环主体至少会执行一次。do 循环的句法如下:

  1. do
  2. statement
  3. while (expression);

注意,do 循环和更普通的 while 循环有几个不同点。首先,do 循环既需要使用关键字 do 标记循环的开头,也要使用关键字 while 标记循环的结尾,以及引入循环条件。其次,与 while 循环不同的是,do 循环的结尾要使用分号。这是因为 do 循环以循环条件结尾,而不是标记循环主体结束的花括号。下面的 do 循环和前面的 while 循环打印相同的结果:

  1. int count = 0;
  2. do {
  3. System.out.println(count);
  4. count++;
  5. } while(count < 10);

do 循环比类似的 while 循环少见得多,因为在实际使用中很少遇到一定会至少先执行一次循环的情况。

2.5.10 for语句

for 语句提供的循环结构往往比 whiledo 循环更便利。for 语句利用了一般循环的执行模式。大多数循环都有一个计数器,或者某种形式的状态变量,在循环开始前初始化,然后测试这个变量的值,决定是否执行循环主体,再次计算表达式的值之前,在循环主体末尾递增或者以某种方式更新这个变量的值。初始化、测试和更新,这三步是循环变量的重要操作,for 语句把这三步作为循环句法的明确组成部分:

  1. for(initialize; test; update) {
  2. statement
  3. }

for 循环基本等同于下面的 while 循环:

  1. initialize;
  2. while (test) {
  3. statement;
  4. update;
  5. }

initializetestupdate 三个表达式放在 for 循环的开头,特别有助于理解循环的作用,还能避免一些错误,例如忘记初始化或更新循环变量。解释器会丢掉 initializeupdate 两个表达式的返回值,所以它们必须有副作用。initialize 一般是赋值表达式,update 一般是递增、递减或其他赋值表达式。

下面的 for 循环与前面的 whiledo 循环一样,打印数字 0 到 9:

  1. int count;
  2. for(count = 0 ; count < 10 ; count++)
  3. System.out.println(count);

注意,这种句法把循环变量的重要信息都放在同一行,更能看清循环的执行方式。而且,把更新循环变量的表达式放在 for 语句中,还简化了循环主体,只剩一个语句,甚至不需要使用花括号组成语句块。

for 循环还支持一种句法,可以让循环更便于使用。很多循环都只在循环内部使用循环变量,因此 for 循环允许 initialize 是一个完整的变量声明表达式,这样循环变量的作用域是循环主体,在循环外部不可见。例如:

  1. for(int count = 0 ; count < 10 ; count++)
  2. System.out.println(count);

而且,for 循环的句法不限制只能使用一个变量,initializeupdate 表达式都能使用逗号分隔多个初始化和更新表达式。例如:

  1. for(int i = 0, j = 10 ; i < 10 ; i++, j--)
  2. sum += i * j;

在目前所举的例子中,计数器都是数字,但 for 循环并不限制计数器只能使用数字。例如,可以使用 for 循环迭代链表中的元素:

  1. for(Node n = listHead; n != null; n = n.nextNode())
  2. process(n);

for 循环中的 initializetestupdate 表达式都是可选的,只有分隔这些表达式的分号是必须的。如果没有 test 表达式,其值假定为 true。因此,可以使用 for(;;) 编写一个无限循环。

2.5.11 遍历语句

Java 的 for 循环能很好地处理基本类型,但处理对象集合时没什么用,而且笨拙。不过,有种叫作“遍历循环”(foreach loop)的句法可以处理需要循环的对象集合。

遍历循环以关键字 for 开头,后面跟着一对括号,括号里是变量声明(不初始化)、冒号和表达式,括号后面是组成循环主体的语句(或语句块):

  1. for( declaration : expression )
  2. statement

别被遍历循环这个名字迷惑了,它并不使用关键字 foreach。冒号一般读作“……中”,例如“studentNames 中的各个名字”。

介绍 whiledofor 循环时,都举了一个例子,打印 10 个数字。遍历循环也能做到,但需要迭代一个集合。为了循环 10 次(打印 10 个数字),我们需要一个有 10 个元素的数组或其他集合。我们可以使用下面的代码:

  1. // 这些是我们想打印的数字
  2. int[] primes = new int[] { 2, 3, 5, 7, 11, 13, 17, 19, 23, 29 };
  3. // 这是打印这些数字的循环
  4. for(int n : primes)
  5. System.out.println(n);

遍历不能做的事

遍历和 whilefordo 循环不同,因为它隐藏了循环计数器或 Iterator 对象。介绍 lambda 表达式时会看出来,这种想法很好,但有些算法不能使用遍历循环自然地表达出来。

假如你想把数组中的元素打印出来,各元素使用逗号分隔。为此,要在数组的每个元素后面打印一个逗号,但最后一个元素后面没有逗号;或者说,数组的每个元素前面都要打印一个逗号,但第一个元素前面没有逗号。使用传统的 for 循环,代码可以这样写:

  1. for(int i = 0; i < words.length; i++) {
  2. if (i > 0) System.out.print(", ");
  3. System.out.print(words[i]);
  4. }

这是很简单的任务,但遍历做不到,因为遍历循环没有循环计数器,也没有其他能识别第一次、最后一次或中间某次迭代的方式。

2.5 语句 - 图3 使用遍历循环迭代集合中的元素也有类似的问题。使用遍历循环迭代数组时无法获取当前元素的索引,同样,使用遍历循环迭代集合也无法获取列举集合元素的 Iterator 对象。

还有一些事情遍历循环做不到:

  • 反向迭代数组或 List 对象中的元素;

  • 使用同一个循环计数器获取两个不同数组同一索引位的元素;

  • 通过调用 Listget() 方法遍历元素,而不是使用它的迭代器。

2.5.12 break语句

break 语句让 Java 解释器立即跳出所在的语句块。我们已经见过 break 语句在 switch 语句中的用法。break 语句最常写成关键字 break 后跟一个分号:

  1. break;

这种形式让 Java 解释器立即退出所在的最内层 whiledoforswitch 语句。例如:

  1. for(int i = 0; i < data.length; i++) {
  2. if (data[i] == target) { // 找到需要的数据时
  3. index = i; // 记住数据所在的位置
  4. break; // 然后停止查找
  5. }
  6. } // 执行break语句后,Java解释器来到这里

break 语句后面也可以跟着标注语句的名称。此时,break 语句让 Java 解释器立即退出指定的语句块。退出的语句块可以是任何类型,不只局限于循环或 switch 语句。例如:

  1. TESTFORNULL: if (data != null) {
  2. for(int row = 0; row < numrows; row++) {
  3. for(int col = 0; col < numcols; col++) {
  4. if (data[row][col] == null)
  5. break TESTFORNULL; // 把数组当成未定义
  6. }
  7. }
  8. } // 执行break TESTFORNULL语句后,Java解释器来到这里

2.5.13 continue语句

break 语句的作用是退出循环,而 continue 语句的作用是中止本次循环,开始下一次循环。continue 语句,不管是无标注还是有标注形式,只能在 whiledofor 循环中使用。如果没指定标注,continue 语句让最内层循环开始下一次循环;如果指定了标注,continue 语句让对应的循环开始下一次循环。例如:

  1. for(int i = 0; i < data.length; i++) { // 循环处理数据
  2. if (data[i] == -1) // 如果缺失某个数据
  3. continue; // 跳到下一次循环
  4. process(data[i]); // 处理数据
  5. }

whiledofor 循环中,continue 语句开始下一次循环的方式稍有不同。

  • while 循环中,Java 解释器直接返回循环开头,再次测试循环条件,如果计算结果为 true,再次执行循环主体。

  • do 循环中,解释器跳到循环的末尾,测试循环条件,决定是否要执行下一次循环。

  • for 循环中,解释器跳到循环的开头,先计算 update 表达式,然后计算 test 表达式,以此决定是否继续循环。由示例可以看出,有 continue 语句的 for 循环和基本等效的 while 循环行为不同:在 for 循环中会计算 update 表达式,而在等效的 while 循环中不会计算。

2.5.14 return语句

return 语句告诉 Java 解释器,终止执行当前方法。如果声明方法时指明了有返回值,return 语句后面必须跟着一个表达式。这个表达式的返回值就是这个方法的返回值。例如,下述方法计算并返回一个数字的平方:

  1. double square(double x) { // 计算x平方的方法
  2. return x * x; // 计算并返回一个值
  3. }

有些方法声明时使用了 void,指明不返回任何值。Java 解释器运行这种方法时,会依次执行其中的语句,直到方法结束为止。执行完最后一个语句时,解释器隐式返回。然而,有时没有返回值的方法要在到达最后一个语句之前显式返回。此时,可以使用后面没有任何

表达式的 return 语句。例如,下述方法只打印不返回参数的平方根。如果参数是负数,直接返回,不打印任何内容:

  1. // 打印x平方根的方法
  2. void printSquareRoot(double x) {
  3. if (x < 0) return; // 如果x是负数,返回
  4. System.out.println(Math.sqrt(x)); // 打印x的平方根
  5. } // 方法结束,隐式返回

2.5.15 synchronized语句

Java 一直支持多线程编程,后文会详细介绍这个话题(尤其是 6.5 节)。不过读者要注意,并发并不容易掌控,并且有很多细节需要了解。

具体而言,处理多线程时,经常必须避免多个线程同时修改同一个对象,以防对象的状态有冲突。Java 提供的 synchronized 语句可以帮助程序员,避免发生冲突。synchronized 语句的句法为:

  1. synchronized ( expression ) {
  2. statements
  3. }

expression 代表一个对象或者一个数组。statements 是能导致破坏的代码块,必须放在花括号里。

执行语句块之前,Java 解释器先为 expression 所定义的对象或数组获取一个排它锁(exclusive lock),直到语句块执行完毕后再释放。只要某个线程拥有对象的排它锁,其他线程就不能再获取这个锁。

在 Java 中,synchronized 关键字还可以作为方法的修饰符。应用于方法时,synchronized 关键字指明整个方法都被锁定。如果 synchronized 关键字应用于类方法(静态方法),执行方法前,Java 会先为这个类获取一个排它锁。如果 synchronized 关键字应用于实例方法,Java 为类的实例获取一个排它锁。(类和实例在第 3 章介绍。)

2.5.16 throw语句

异常是一种信号,表明发生了某种异常状况或错误。抛出异常时表明有未预料的状况发生。捕获异常的目的是处理异常,使用必要的操作修复。在 Java 中,throw 语句用于抛出异常:

  1. throw expression;

expression 必须是一个异常对象,说明发生了什么异常或错误。稍后会详细介绍异常的种类,现在你只需知道,异常通过有点特殊的对象表示。下面是抛出异常的示例代码:

  1. public static double factorial(int x) {
  2. if (x < 0)
  3. throw new IllegalArgumentException("x must be >= 0");
  4. double fact;
  5. for(fact=1.0; x > 1; fact *= x, x--)
  6. /* empty */ ; // 注意,使用的是空语句
  7. return fact;
  8. }

Java 解释器执行 throw 语句时,会立即停止常规的程序执行,开始寻找能捕获或处理异常的异常处理程序。异常处理程序使用 try/catch/finally 语句编写,下一节会介绍。Java 解释器先在当前代码块中查找异常处理程序,如果有,解释器会退出这个代码块,开始执行异常处理代码。异常处理程序执行完毕后,解释器会继续执行处理程序后的语句。

如果当前代码块中没有适当的异常处理程序,解释器会在外层代码块中寻找,直到找到为止。如果方法中没有能处理 throw 语句抛出的异常的异常处理程序,解释器会停止运行当前方法,返回调用这个方法的地方,开始在调用方法的代码块中寻找异常处理程序。Java 通过这种方式,通过方法的词法结构不断向上冒泡,顺着解释器的调用堆栈一直向上寻找。如果一直没有捕获异常,就会冒泡到程序的 main() 方法。如果在 main() 方法中也没有处理异常,Java 解释器会打印一个错误消息,还会打印一个堆栈跟踪,指明这个异常在哪里发生,然后退出。

2.5.17 try/catch/finally语句

Java 有两种稍微不同的异常处理机制。经典形式是使用 try/catch/finally 语句。这个语句的 try 子句是可能抛出异常的代码块。try 代码块后面是零个或多个 catch 子句,每个子句用于处理特定类型的异常,而且能处理多个不同类型的异常。如果 catch 块要处理多个异常,使用 | 符号分隔各个不同的异常。catch 子句后面是一个可选的 finally 块,包含清理代码,不管 try 块中发生了什么,始终都会执行。

try 块的句法

catchfinally 子句都是可选的,但每个 try 块都必须有这两个子句中的一个。trycatchfinally 块都放在花括号里。花括号是句法必须的一部分,即使子句只包含一个语句也不能省略。

下述代码演示了 try/catch/finally 语句的句法和作用:

  1. try {
  2. // 正常情况下,这里的代码从上到下运行,没有问题
  3. // 但是,有时可能抛出异常
  4. // 可能是throw语句直接抛出
  5. // 也可能是调用的方法间接抛出
  6. }
  7. catch (SomeException e1) {
  8. // 这段代码中的语句用于处理SomeException或其子类类型的异常对象
  9. // 在这段代码中,可以使用名称e1引用那个异常对象
  10. }
  11. catch (AnotherException | YetAnotherException e2) {
  12. // 这段代码中的语句用于处理AnotherException、YetAnotherException
  13. // 或二者的子类类型的异常。在这段代码中,使用名称e2引用传入的异常对象
  14. }
  15. finally {
  16. // 不管try子句的结束方式如何,这段代码中的语句都会执行:
  17. // 1)正常结束:到达块的末尾
  18. // 2)由break、continue或return语句导致
  19. // 3)抛出异常,由上述catch子句处理
  20. // 4)抛出异常,未被捕获处理
  21. // 但是,如果在try子句中调用了System.exit(),解释器会立即退出
  22. // 不执行finally子句
  23. }

1. try 子句

try 子句的作用很简单,组建一段代码,其中有异常需要处理,或者因某种原因终止执行后需要使用特殊的代码清理。try 子句本身没什么用,异常处理和清理操作在 catchfinally 子句中进行。

2. catch 子句

try 块后面可以跟着零个或多个 catch 子句,指定处理各种异常的代码。每个 catch 子句只有一个参数(可以使用特殊的 | 句法指明 catch 块能处理多种异常类型),指定这个子句能处理的异常类型,以及一个名称,用来引用当前处理的异常对象。catch 块能处理的类型必须是 Throwable 的子类。

有异常抛出时,Java 解释器会寻找一个 catch 子句,它的参数要和异常对象的类型相同,或者是这个类型的子类。解释器会调用它找到的第一个这种 catch 子句。catch 块中的代码应该执行处理异常状况所需的任何操作。假如异常是 java.io.FileNotFoundException,此时或许要请求用户检查拼写,然后重试。

不是所有可能抛出的异常都要有一个 catch 子句处理,有些情况下,正确的处理方式是让异常向上冒泡,由调用方法捕获。还有些情况,例如表示程序错误的 NullPointerException 异常,正确的处理方式或许是完全不捕获,随它冒泡,让 Java 解释器退出,打印堆栈跟踪和错误消息。

3. finally 子句

finaly 子句放在 try 子句后面,一般用来执行清理操作(例如关闭文件和网络连接)。finally 子句很有用,因为不管 try 块中的代码以何种方式结束执行,只要有代码执行,finally 子句中的代码就会执行。事实上,只有一种方法能让 try 子句退出而不执行 finally 子句——调用 System.exit() 方法,让 Java 解释器停止运行。

正常情况下,执行到 try 块的末尾后会继续执行 finally 块,做必要的清理工作。如果因为 returncontinuebreak 语句而离开 try 块,会先执行 finally 块,然后再转向新的目标代码。

如果 try 块抛出了异常,而且有处理该异常的 catch 块,那么先执行 catch 块,然后在执行 finally 块。如果本地没有能处理该异常的 catch 块,先执行 finally 块,然后再向上冒泡到能处理该异常最近的 catch 子句。

如果 finally 块使用 returncontinuebreakthrow 语句,或者调用的方法抛出了异常,从而转移了控制权,那么待转移的控制权中止,改为执行新的控制权转移。例如,如果 finally 子句抛出了异常,这个异常会取代任何正在抛出的异常。如果 finally 子句使用了 return 语句,就算抛出的异常还没处理,方法也会正常返回。

tryfinally 子句可以放在一起使用,不处理异常,也没有 catch 子句。此时,finally 块只是负责清理的代码,不管 try 子句中有没有 breakcontinuereturn 语句,都会执行。

2.5.18 处理资源的try语句

try 块的标准形式很通用,但有些常见的情况需要开发者小心编写 catchfinally 块。这些情况是清理或关闭不再需要使用的资源。

Java(从第 7 版起)提供了一种很有用的机制,能自动关闭需要清理的资源——处理资源的 try 语句(try-with-resources,TWR)。10.1 节会详细介绍 TWR,但为了本节的完整,先介绍它的句法。下面的示例展示了如何使用 FileInputStream 类打开文件(得到的对象需要清理):

  1. try (InputStream is = new FileInputStream("/Users/ben/details.txt")) {
  2. // ……处理这个文件
  3. }

这种新型 try 语句的参数都是需要清理的对象。2 这些对象的作用域在 try 块中,不管 try 块以何种方式退出,都会自动清理。开发者无需编写任何 catchfinally 块,Java 编译器会自动插入正确的清理代码。

2严格来说,这些对象必须实现 AutoCloseable 接口。

所有处理资源的新代码都应该使用 TWR 形式编写,因为这种形式比自己动手编写 catch 块更少出错,而且不会遇到麻烦的技术问题,例如终结(详情参见 6.4 节)。

2.5.19 assert语句

assert 语句用来验证 Java 代码的设计假想。断言(assertion)由 assert 关键字和布尔表达式组成,程序员认为布尔表达式的计算结果始终应该为 true。默认情况下断言未启用,assert 语句什么作用也没有。

不过,可以启用断言,作为一种调试工具。启用后,assert 语句会计算表达式。如果表达式的计算结果确是 trueassert 语句什么也不做;如果计算结果是 false,断言失败,assert 语句抛出 java.lang.AssertionError 异常。

2.5 语句 - 图4 在 JDK 库之外,极少使用 assert 语句。用它测试大多数应用都不灵便,一般的开发者很少使用,不过有时用来现场调试复杂的多线程应用。

assert 语句可以包含可选的第二个表达式,使用冒号和第一个表达式分开。如果启用了断言,而且第一个表达式的计算结果为 false,那么第二个表达式的值会作为错误代码或错误消息传给 AssertionError() 构造方法。assert 语句的完整句法如下:

  1. assert assertion;

或者:

  1. assert assertion : errorcode;

为了有效使用断言,必须注意几处细节。首先,要记住,一般情况下程序没有启用断言,只有少数情况才会启用。这意味着,编写断言表达式时要小心,不能有副作用。

2.5 语句 - 图5 绝不要在自己编写的代码中抛出 AssertionError 异常,如果这么做,可能会在 Java 平台未来的版本中得到意料之外的结果。

如果抛出了 AssertionError 异常,表明程序员的假想之一没有实现。这意味着,在设计的使用范围之外使用了代码,无法正常运行。简单来说,没有看似合理的方式能从 AssertionError 异常中恢复,因此不要尝试捕获这个异常(除非在顶层简单捕获,以对用户更友好的方式显示错误)。

启用断言

为了效率,不应该在每次执行代码时都测试断言,因为 assert 语句认为假想始终为真。因此,默认情况下禁用了断言,assert 语句没有作用。不过,断言代码还是会编译到类文件中,所以需要诊断或调试时可以启用断言。断言可以全局启用,也可以把命令行参数传给 Java 解释器,有选择性地启用。

如果想为系统类之外的所有类启用断言,使用 -ea 参数。如果想为系统类启用断言,使用 -esa 参数。如果想为某个具体的类启用断言,使用 -ea 参数,后跟一个冒号和类名:

  1. java -ea:com.example.sorters.MergeSort com.example.sorters.Test

如果想为包中所有的类和子包启用断言,在 -ea 参数后面加上冒号、包名和三个点号:

  1. java -ea:com.example.sorters... com.example.sorters.Test

使用 -da 参数,通过相同的方式可以禁用断言。例如,为整个包启用断言,但在某个类或子包中禁用,可以这么做:

  1. java -ea:com.example.sorters... -da:com.example.sorters.QuickSort
  2. java -ea:com.example.sorters... -da:com.example.sorters.plugins...

最后,类加载时可以控制是否启用断言。如果在程序中使用自定义的类加载程序(第 11 章会详细介绍自定义类加载),而且想启用断言,可能会对这些方法感兴趣。