登录后台

页面导航

本文编写于 2242 天前,最后修改于 1259 天前,其中某些信息可能已经过时。

流程控制是所有编程语言都会提供的基本功能。它来自于结构化程序设计的成功,但实际上 Java 语言的方法内部一样需要进行流程控制,因此 Java 也提供了顺序结构、分支结构和循环结构 3 种流程。

上面 3 种最基本的流程里,顺序结构是最简单的,基本上出错的概率不大;但对于 Java 提供的两种分支语句:if 语句和 switch 语句,如果开发者不小心就很容易导致程序出现错误。而且,有些错误还比较隐蔽,初次遇到时可能难以发现;还有像 switch 语句中忘记 break 语句所导致的错误,也是很难发现的。Java 总共提供了 4 种循环语句:while 循环、do……while 循环、for 循环和 foreach 循环。实际开发中,for 循环、foreach 循环的使用频率是最高的,但恰恰是这两种循环最容易引起错误。

switch 语句陷阱

switch 语句是 Java 提供的一种重要的分支语句,它用于判断某个表达式的值,根据不同的值执行不同的分支语言。需要支出的是,Java 的 switch 语句限制较多,而且还有一个非常容易出错的陷阱,使用时要无比小心。

default 分支永远会执行吗?

switch 语句之后可以包含一个 default 分支。从字面意义上来看,这个分支是默认分支,似乎是无条件执行的分支,实际上不是。default 分支的潜在条件是:表达式的值与前面分支的值都相等。也就是说,正常情况下,只有当 switch 语句的前面分支没有获得执行时,default 分支才会获得执行的机会。

break 的重要性

在 case 分支后的某个代码块后都有一条 break; 语句,这个 break; 语句有极其重要的意义:用于终止当前分支的执行体。如果某个 case 分支之后没有使用 break; 来终止这个分支的执行体,即使用花括号来包围该分支的执行体也是无效的。Java 一旦找到匹配的 case 分支(表达式的值与 case 后的值相等),程序开始执行这个 case 分支的执行体,不再判断与后面 case、default 标签的条件是否匹配,除非遇到 break; 才会结束该执行体。

从逻辑意义上看,Java 的语法根本不应该允许省略 break; 的情形发生,因为省略 break 给实际编程并没有带来多大的实质好处,只是增加了引入陷阱的机会。

从 JDK1.5 开始,Java 编译器增加了更严格的检查,只要在 javac 命令后增加 -Xlint:fallthrough 选项,Java 编译器就会提示缺少 break; 的警告。

由于 switch 分支语句中绝大部分都不应该省略 break; 语句,因此建议使用 javac 命令时应该总是增加 -Xlint:fallthrough 选项。

如果需要了解 javac 命令具有哪些扩展选项,可以输入 javac -X 命令进行查看,执行该命令即可看到 javac 命令支持的全部扩展选项。

switch 表达式的类型

switch 语句后可以指定一个表达式,系统根据该表达式的值来决定执行哪个 case 分支的执行体。对于 switch 语句的表达式而言,它只能是如下 5 种数据类型:

  • byte:字节类型
  • short:短整型
  • int:整型
  • char:字符型
  • enum:枚举型

需要指出的是,switch 表达式的类型绝对不是 String 类型,也不能是 long、float、double 等其它基本类型。

如果在表达式中含有以下代码:

//...
int a = 5;
switch (a + 1.2 + 0.8) {
    //...
}
//...

编译该程序时,就会提示“可能损失精度”的错误提示,这是因为 a + 1.2 + 0.8 表达式的类型自动提升为 double 类型,而 switch 表达式不允许使用 double 类型,为了让该程序输出希望的结果,可以将表达式修改为 switch ((int)(a + 1.2 + 0.8)) {

从 JDK1.5 开始,switch 的表达式的表达式可以是 enum 类型,值得指出的是,程序在其它地方使用 enum 值时,通常应该使用枚举类名作为限定,例如 Season.FALLSeason.SPRING 等;但在 case 分支中访问枚举值时不能使用枚举类型作为限定,例如 case SPRING:case SUMMER: 等。

标签引起的陷阱

Java 语句的标签是一个怪胎:它主要是为了 C 语言的 goto 语句而创建,但 Java 程序中根本没有 goto 语句。虽然 Java 一直将 goto 作为关键字,但估计 Java 也没有引入 goto 语句的打算。因此,Java 语句中的标签通常都没有太大的作用。

不过,Java 语句的标签可以与循环中的 break、continue 结合使用,让 break 直接终止标签所标识的循环,让 continue 语句忽略标签所标识的循环的剩下语句。从这个意义上来,Java 程序中的标签只有放在循环之前才具有实际意义,但问题是,Java 的标签可以放在程序的任何位置,即使它没有任何实际意义。

public class URLTest {
    public static void main(String[] args) {
        String book = "疯狂 Java 讲义";
        double price = 99.0;
        if (price > 90) {
            http://blog.yeskery.com
            System.out.println(book + "的价格大于90!");
        }
    }
}

尝试编译并运行上面的程序,可以发现程序可以正常编译且可以正常运行。对于 Java 而言,它并不认识这个网址,Java 会把这个字符串分解成以下两个部分:

  • http::合法的标识符后紧跟英文分号,这是一个标签
  • //blog.yeskery.com:双斜线后的内容是注释

对于 Java 来说,它允许 http: 放在任意位置————它是一个标签,虽然它没有任何实际意义。而网址,则只是一行简单的单行注释。

if 语句的陷阱

if 语句也是 Java 程序广泛使用的分支语句,即使初学编程的人也会经常使用 if 分支语句。但实际上,if 语句也存在一些需要小心回避的陷阱。

else 隐含的条件

else 的字面意义是“否则”,隐含的条件是前面条件都不复合,也就是 else 有一个隐含的条件,else if 的条件是 if 显式条件和 else 隐式条件的交集。

public class IfErrorTest {
    public static void main(String[] args) {
        int age = 45;
        if (age > 20) {
            System.out.println("青年人");
        } else if (age > 40) {
            System.out.println("中老人");
        } else if (age > 60) {
            System.out.println("老年人");
        }
    }
}

运行上面的程序,发现打印的结果实青年人,而预期的45岁应该被判断为中年人————这显然出现了一个问题。造成这个问题的原因就是 else 后的隐含条件,else 的隐含条件就是不满足 else 之前的条件,也就是上面程序的实质等于如下代码:

public class IfErrorTest {
    public static void main(String[] args) {
        int age = 45;
        if (age > 20) {
            System.out.println("青年人");
        }
        if (age > 40 && !(age > 20)) {
            System.out.println("中老人");
        }
        if (age > 60 && !(age > 20) && !(age > 40 && !(age > 20))) {
            System.out.println("老年人");
        }
    }
}

此时,就比较容易看出为什么发生了上面的错误。对于 age > 40 && !(age > 20) 这个条件可以改写成 age > 40 && age <= 20,这样的情况永远不会发生。对于 age > 60 && !(age > 20) && !(age > 40 && !(age > 20)) 这个条件,则更不可能会发生了。因此,无论如何,程序永远都不可能打印出中年人和老年人了。

为了让程序可以正确地根据年龄来判断“青年人”、“中年人”和“老年人”,可以把程序改写成如形式:

public class IfRightTest {
    public static void main(String[] args) {
        int age = 45;
        if (age > 60) {
            System.out.println("老年人");
        } else if (age > 40) {
            System.out.println("中年人");
        } else if (age > 20) {
            System.out.println("青年人");
        }
    }
}

这个程序就能输出正确的结果了,上面程序的实质如下:

public class IfRightTest {
    public static void main(String[] args) {
        int age = 45;
        if (age > 60) {
            System.out.println("老年人");
        }
        if (age > 40 && !(age > 60)) {
            System.out.println("中年人");
        }
        if (age > 20 && !(age > 60) && !(age > 40 && !(age > 60))) {
            System.out.println("青年人");
        }
    }
}

上面程序的判断逻辑即转为如下 3 种情形:

  • age 大于 60 岁,判断为“老年人”;
  • age 大于 40 岁,且 age 小于等于 60 岁,判断为“中年人”;
  • age 大于 20 岁,且 age 小于等于 40 岁,判断为“青年人”。

上面的程序逻辑才是实际希望的判断逻辑。因此,当使用 if……else 语句进行流程控制时,一定不要忽略了 else 所带的隐含条件。

如果每次都去计算 if 条件和 else 条件的交集也是一件非常繁琐的事情,为了避免出现上面的错误,使用 if……else 语句有一条基本规则:总是优先把包含范围小条件放在前面处理。例如 age > 60age > 20 两个条件,明显 age > 60 的范围更小,所以应该先处理 age > 60 的情况。

这实际上是一个逻辑问题,如果使用 if……else 语句时先处理范围大的条件,会有下面的情况:

if 处理范围大的情况
    ...
    ...
后处理小范围 && else 隐含条件:剩下小范围
    ...
    ...

从上面的说明可以看出,如果先处理范围大的条件,接下来的情况是拿“后处理的小范围”和“else 隐含条件:刨除大范围的小范围”计算交集,两个小范围求交就很难有交集了,这将导致后处理的分支永远都不会获得执行的机会。

换一种方式,如果先处理范围小的条件,会有下面的情况:

if 处理范围小的情况
    ...
    ...
后处理大范围 && else 隐含条件:剩下的大范围

从上面的说明可以看出,如果先处理范围小的条件,接下来的情况是拿“后处理的大范围”和“else 隐含条件:刨除小范围的大范围”计算交集,两个大范围求交才会产生交集,这样后处理的分支才有可能获得执行的机会。

小心空语句

Java 允许使用单独一个分号作为空语句,空语句往往在“不经意”间产生。需要指出的是:如果 if 语句后没有花括号括起来的条件执行体,那么这个 if 语句仅仅控制到该语句后的第一个分号处,后面部分将不再受该 if 语句控制。

对于 if 语句而言,如果紧跟该语句的是花括号括起来的语句块,那么该 if 语句将控制花括号括起来的语句快;如果省略了 if 语句后条件执行体的花括号,那它仅仅控制到紧跟该语句的第一个分号为止。

循环体的花括号

什么时候可以省略花括号

Java 对于 if 语句、while 语句、for 语句的处理策略完全一样:如果紧跟该语句的是花括号括起来的语句块,那么该 if 语句、while 语句、for 语句将控制花括号括起来的语句块;如果 if 语句、while 语句、for 语句之后没有紧跟 花括号,那么 if 语句、while 语句、for 语句的作用范围到该语句之后的第一个分号结束。

只有当循环体内只包含一条语句时才可以省略循环体的花括号,此时循环本身不会受到太大影响。当循环体有多条语句时,不可省略循环体的花括号,否则循环体将变成只有紧跟循环条件的那条语句。

在最极端的情况下,即使循环体只有一条语句,依然不能省略循环体的花括号,关于这种情况请看下面的介绍。

省略花括号的危险

class Cat {
    //使用一个变量记录一共创建了多少个实例
    private static long instanceCount = 0;
    public Cat() {
        System.out.println("执行无参数的构造器");
        instanceCount++;
    }
    public static long getInstanceCount() {
        return instanceCount;
    }
}
public class CatTest {
    public static void main(String[] args) {
        //使用循环创建10个Cat对象
        for (int i = 0;i < 10;i++) 
            Cat cat = new Cat();
        System.out.println(Cat.getInstanceCount());
    }
}

上面程序定义了一个 Cat 类,在该 Cat 类定义一个 instanceCount 类变量来记录该 Cat 类一同创建了多少个实例。每当程序调用构造器创建 Cat 对象时,将让 instanceCount 类变量的值加 1。

程序的 main 方法使用 for 循环创建了 10 个 Cat 对象————因为循环体只有一条语句,因此省略了循环体的花括号;接着调用 Cat 类的 getInstanceCount() 方法来输出 Cat 类创建的实例个数。

这个程序看上去一切正常,没有任何问题,但如果尝试编译该程序,将看到编译器提示编译错误。

为什么会发生这样的情况?这是因为 Java 语言规定:for、while 或 do……while 循环中的重复执行语句不能是一条单独的局部变量定义语句;如果程序要使用循环来重复定义局部变量,这条局部变量定义语句必须放在花括号内才有效。因此将上面的 CatTest 类改为如下形式即可:

public class CatTest {
    public static void main(String[] args) {
        //使用循环创建10个Cat对象
        for (int i = 0;i < 10;i++){
            Cat cat = new Cat();
        }
        System.out.println(Cat.getInstanceCount());
    }
}

由上面程序可知,当循环体只有一条局部变量定义语句时,仍然不可以省略循环体的花括号。

大部分时候,如果循环体只包含一条语句,那么就可以省略循环体的花括号;但如果循环体只包含一条局部变量定义语句的,那依然不可以省略循环体的花括号。

上面程序给出的教训非常明显:尽量保留循环体的花括号,这样写出来的程序会比较健壮。虽然省略循环体的花括号看上去比较简洁,但凭空增添了许多出错的可能。

for 循环的陷阱

for 循环是所有循环中最简洁、功能最丰富的循环,因此大部分时候 for 循环完全可以取代其它循环。在使用 for 循环时,一样可能存在一些危险。

分号惹的祸

与前面介绍的 if、while 语句十分相似的是:如果 for 语句后没有紧跟花括号,那么 for 语句的控制范围到紧跟该语句的第一个分号为止。也就是说,如果 for 语句后面直接跟分号,分号后面的花括号就只是一个普通的代码块,并不属于 for 循环的控制之内,因此在这个代码块中找不到在循环中定义的循环变量。

public class SemicolonError2 {
    public static void main(String[] args) {
        String[] books = {"疯狂 Java 讲义", "轻量级 Java EE 企业应用实战", "疯狂 XML 讲义"};
        int i = 0;
        //遍历 books 数组
        for (;i < books.length;i++); {
            System.out.println("第" + i +"个元素的值是:" + books[i]);
        }
    }
}

尝试运行上面的程序,则看到程序引发了 ArrayIndexOutOfBoundsException 异常,这是什么原因呢?

程序开始执行 for 循环, for 循环的初始化语句为空,因此什么都不做;for 循环的循环体也为空,为此执行 for 循环时也是什么都不做;for 循环每循环一次,它的循环计数器 i 将增加 1————直到最后一次 i 的值等于 3,此时 i < books.length 为假,循环结束。此时 i 的值为 3,接着程序开始执行 for 语句之后的代码块,也就是执行 System.out.println("第" + i +"个元素的值是:" + books[i]); 语句,books 数组的长度为 3,程序试图访问它的第四个元素(books[3]),当然就会引起编译错误了。

for 循环的初始化语句可以定义多个初始化变量,示例如下:

public class SemicolonError3 {
    public static void main(String[] args) {
        for (int[] intArr = {5, 6, -10};int i = 0;i < intArr.length;i++) {
            System.out.println("intArr数组的元素为:" + intArr[i]);
        }
    }
}

上面程序为 for 循环的初始化条件定义了 2 条语句:

  • 定义了一个 int[] 数组;
  • 定义了一个 int 类型的循环计数器。

编译这个程序,会提示编译错误。

根据 Java 语言规范,for 循环里有且只能有 2 个分号作为分隔符。第一个分号之前的是初始化条件,两个分号中间的部分是一个返回 boolean 的逻辑表达式,当它返回 true 时 for 循环才会执行下一次循环;第二个分号之后的是循环迭代部分,每次循环结束后会执行循环迭代部分。

上面程序中的 for 循环中包含了 3 个分号,这显然让 Java 编译器无所适从,因此程序会提示编译错误,由此可见,虽然 for 循环允许初始化条件定义多个变量,但初始化条件不能包括分号,因此只能拥有一条语句。如下程序中的 for 循环是正确的:

public class SemicolonRight {
    public static void main(String[] args) {
        for (int j = 1, i = 0;i < 5 && j < 20;i++, j *= 2) {
            System.out.println(i + "-->" + j);
        }
    }
}

上面程序中 for 循环的初始化条件定义了 2 个变量 i 和 j。因为 i 和 j 的数据类型都是 int 型,所以可以使用一条语句定义 2 个初始化变量。

for 循环的初始化条件可以同时定义多个变量,但由于它只能接受一条语句,因此这两个变量的数据类型应该相同。

上面 for 循环的循环条件是一个用 && 符号连接的逻辑表达式,这没有任何问题,只要这个逻辑表达式能返回 boolean 值。

上面 for 循环的迭代部分包含了 2 条语句:i++j*= 2。需要指出的时,虽然迭代部分可以包含多条语句,但这多条语句不能用分号作为分隔符,只能用逗号作为分隔符。

小心循环计数器的值

对于 for 循环而言,已经习惯了使用 for(int i = 0;i < 10;i++) 这样的结构来控制循环。看到这样的结构,往往会很主观地断定:这个循环将会循环 10 次。是这样的吗?看下面的示例程序:

public class CareForCount {
    public static void main(String[] args) {
        //简单的循环,试图循环10次
        for (int i = 0;i < 10;i++) {
            System.out.println("i的值为:" + i);
            i *= 0.1;
        }
    }
}

运行上面的程序将发现这是一个死循环,输出 i 时一直看到的是 1。

其实不难发现这个循环的问题,程序开始 i = 0,程序输出 i 的值为 0,接着程序执行 i *= 0.1。这行代码相当于 i = (int)i * 0.1; 得到的结果,i 依然是 0。接着for 循环执行迭代条件,执行完迭代条件后 i = 1,因此程序输出 i 的值可以看到 1。接着,程序执行 i *= 0.1,这行代码导致 i 再次变为 0。这样,每次执行完循环体之后 i 值总是 0,执行完循环迭代部分之后 i 的值总是 1,因此这个 for 循环就变成了一个死循环。

这个程序给出的教训是,不要仅根据习惯来判断一个循环会执行多少次,必须仔细对待循环体执行过程中每个可能改变循环计数器的语句,才能正确掌握循环的执行次数。当然,最安全的做法就是避免改变循环计数器的值。如果循环体内需要根据访问、修改循环计数器的值,可以考虑额外地定义个新变量来保存修改过的值。

浮点数作循环计算

public class FloatCount {
    public static void main(String[] args) {
        final int START = 999999999;
        //尝试循环50次
        for (float i = START;i < START + 50;i++) {
            System.out.println("i的值:" + i + new Date());
        }
    }
}

尝试运行上面的程序,看到这个程序直接生成一个不断执行的死循环,程序每次输出 i 的值都是 1.0E9

导致这个程序产生这个奇怪的结果主要是因为,程序定义的 START 是一个 int 型的整数,而且这个整数还比较大,当程序把这个 int 型的数赋给 float 型变量时,float 型的变量无法精确记录这个值,会导致精度丢失。也就是说,float 型变量无法精确记录 999999999 的值,示例如下:

public class FloatCount {
    public static void main(String[] args) {
        final float f1 = 999999999;
        System.out.println(f1);
        System.out.println(f1 + 1);
        System.out.println(f1 == f1 + 1);
    }
}

上面程序直接将 999999999 赋值给 float 变量,接着程序输出这个 float 变量的值,将看到输出 1.0E9。程序输出 f1 + 1 也将得到 1.0E9,甚至程序判断 f1 + 1 == f1 时也会返回 true。

对于一个 float 型变量而言,它很容易丢失部分数据,因此对于 999999999 这个值而言,float 会以 1.0E9 保存它,它每次加 1 之后,它的值依然是 1.0E9。因此,上面程序看上去会循环 50 次,但实际上确实一个死循环————因为循环计数器 i 从来不曾改变。

将上面的循环稍作改变,作为如下形式:

public class FloatCount {
    public static void main(String[] args) {
        final int START = 999999999;
        //尝试循环20次
        for (float i = START;i < START + 20;i++) {
            System.out.println("i的值:" + i + new Date());
        }
    }
}

尝试编译运行上面的程序,看到这个循环一次都不循环,程序直接结束了,这又是为什么?很显然,这个程序与前一个循环的区别仅仅在于 START + 50START + 20,那么使用如下程序来看看 START + 50START + 20 的区别。

public class FloatCount {
    public static void main(String[] args) {
        final float f1 = 999999999;
        System.out.println(f1);
        System.out.println(f1 + 20);
        //下面语句输出true
        System.out.println(f1 == f1 + 20);
        System.out.println(f1 + 50);
        //下面语句输出false
        System.out.println(f1 == f1 + 50);
    }
}

运行上面的程序,看以看到如下所示的结果:

1.0E9
1.0E9
true
1.00000006E9
false

从上面的结果可以看出,999999999 + 20 的结果依然是 1.0E9,也就是 f1 + 20 == f1 会输出 true。因此,上面循环中循环条件为 i < START + 20 时,这使得循环根本不能获得执行的机会。但对于 999999999 + 50 的结果就不同了,因为加 50 的幅度较大,已经引起了本质改变,所以看到 f1 + 50 == f1 输出 false,也就是 f1 + 50 > f1,从而使得第一个循环变成死循环。

foreach 循环的循环计数器

从 JDK1.5 之后,Java 增加了 foreach 循环用于遍历数组、集合的每个元素。使用 foreach 循环遍历数组和集合元素时,无需获得数组和集合的长度,也无需根据索引来访问数组元素和集合元素,foreach 循环自动遍历数组和集合的每个元素。

foreach 循环的语法格式如下:

for (type variableName : array | collection) {
    //variableName 自动迭代访问每个元素
}

上面语法格式中,type 是数组元素或集合元素的类型,variableName 是一个形参名,foreach 循环将自动将数组元素、集合元素依次赋给该变量。下面程序示范了如何使用 foreach 循环来遍历数组元素。

当使用 foreach 循环来迭代输出数组元素或集合元素时,系统将数组元素、集合元素的副本传给循环计数器————即 foreach 循环中的循环计数器不是数组元素、集合元素本身。

由于 foreach 循环中的循环计数器本身并不是数组元素、集合元素,它只是一个中间变量,临时保存了正在遍历的数组元素、集合元素,因此通常不要对循环变量进行赋值,虽然这种赋值在语法上是允许的,但没有太大的实际意义,而且极容易引起错误。

public class ForEachErrorTest {
    public static void main(String[] args) {
        List<String> books = new ArrayList<String>();
        books.add("疯狂 Java 讲义");
        books.add("轻量级 Java EE 企业应用实战");
        books.add("疯狂 Ajax 讲义");
        books.add("疯狂 XML 讲义");
        //使用 foeach 循环来遍历数组元素,其中book作为循环计数器
        //book的值等于当前正在遍历的集合元素的值
        //但book并不是集合元素本身
        for (String book : books) {
            //对循环计数器赋值
            book = "Ruby On Rails 敏捷开发最佳实践";
            System.out.println(book);
        }
        System.out.println(books);
    }
}

上面程序在 foreach 循环内对循环计数器赋值,但由于这个循环计数器只是一个中间变量,它仅仅保存了当前正在遍历的集合元素,因此对其赋值并不会改变集合元素本身。尝试编译、运行这个程序,会看到如下的结果:

Ruby On Rails 敏捷开发最佳实践
Ruby On Rails 敏捷开发最佳实践
Ruby On Rails 敏捷开发最佳实践
Ruby On Rails 敏捷开发最佳实践
[疯狂 Java 讲义, 轻量级 Java EE 企业应用实战, 疯狂 Ajax 讲义, 疯狂 XML 讲义]

从上面的结果可以看出,在 foreach 循环中对循环计数器赋值导致不能正确遍历集合,不能准确取出每个集合元素的值。而且,当再次访问集合本身时,会发现集合本身依然没有任何改变。

使用 foreach 循环迭代数组、集合时,循环计数器只是保存了当前正在遍历的数组元素、集合元素的值,并不是数组元素、集合元素本身,因此不要对 foreach 循环的循环计数器进行赋值,在很多支持 foreach 循环的变成语言中,编译器往往禁止在循环体内对循环计数器赋值,因为这种做法除了增加出错的可能之外,实在很难想出太多的实用价值。但 Java 编译器偏偏不精致。在一些个别的地方,Java 编译器设计有点“故弄玄虚”,比如,switch 分支语句中允许 case 分支省略 break 语句也没有太大的实际用途,只是增加了出错的可能。

本文转载自:《疯狂Java 突破程序员基本功的16课》第六章 流程控制的陷阱