3.2 编写Java代码

讨论Java所有相关的OOP功能后,就可以着手编写能够做些事情的类了。本节讨论一些可引导你完成这个过程的主题:

  • 运算符;
  • 普通的老式Java对象(POJO);
  • 数组;
  • 泛型和集合;
  • 循环;
  • 异常;
  • 线程;
  • lambda。

3.2.1 运算符

下表总结了Java语言中一些最重要的运算符。请注意,除这里列出的运算符外,Java还支持其他运算符。这里没有列出在其他所有流行的编程语言中都很常见的运算符,如+->>=<<=

运算符 描述
value++ value-- 返回value,再将value的值加1或减1
++value --value 将value的值加1或减1,再返回value的新值
! 逻辑NOT运算符
% 计算除法运算的余数
instanceof 返回一个布尔值,指出传入的对象是否是指定类或接口的实例
== != 相等和不等
&& || 逻辑AND和OR运算符
= 赋值
+= -= *= /= %= 这些运算符计算新值并将结果直接赋给变量

下面是一些运算符使用示例:

  1. class OperatorDemo {
  2. public static void main(String[] args) {
  3. int i = 0;
  4. System.out.println(i++);
  5. System.out.println(++i);
  6. System.out.println(i += 10);
  7. }
  8. }

运行时,这些代码在控制台中打印如下输出:

  1. 0
  2. 2
  3. 12

由于int变量i是在方法中声明的,因此必须显式地初始化(在这里,其初始值为0)方法中的第二行代码打印i的当前值(0),再默默地将其增加到1。接下来的一行代码将变量i的值加1,再打印它(因此打印的为2)。最后将i的值加10,并打印结果(12)。

3.2.2 条件检查

条件检查方式有两种:

  • if…else语句;
  • switch…case语句。

  • if…else语句

在Java中,ifif…else语句没有什么让人意外的地方。每个条件都必须返回一个布尔结果,而else部分是可选的:

  1. if (condition) {
  2. } else if (condition) {
  3. } else {
  4. }

可像下面这样使用逻辑AND(&&)和OR (||)运算符来指定条件:

  1. if (i > 25 || i == -1) {
  2. }

用于基本类型变量时,==运算符与预期一致,但用于对象时,它检查两个对象引用是否相同,而不是比较它们的内容(属性)。因此,要检查String变量的内容,必须使用来自java.lang.Object类并经过重写的方法equals(),而不能使用运算符==

  1. String foo = "hello";
  2. String bar = "world";
  3. if (!foo.equals(bar))
  4. System.out.println("Not equal!");

3.2 编写Java代码 - 图1 大多数IDE都能够识别将运算符==用于String可能是错误的做法,并使用String的方法equals来重写代码。

  • switch…case语句

与很多其他的编程语言一样,Java也支持switch语句,下面是一个简单的示例:

  1. int value = 3;
  2. String s = "";
  3. switch (value) {
  4. case 1:
  5. s = "One";
  6. break;
  7. case 2:
  8. case 3:
  9. s = "Two or three";
  10. break;
  11. default:
  12. s = "Something else";
  13. System.out.println(s);

对于switch语句的用法,有几点需要说明。

  • 指定的表达式的结果可以是整数,也可以是字符串。
  • case语句中指定的值必须是在编译阶段能够确定的,因此非final变量不能用来指定这种值。
  • 必须在case语句中添加一条break语句来跳到switch语句末尾,否则将接着执行下一条case语句。

3.2.3 POJO

最初,没有支持Java语言的框架,开发人员大多自己编写类。在Java的发展过程中,引入了很多框架,而其中很多都要求类实现特定的接口或扩展框架提供的类。这导致类与特定的框架紧密耦合,而编写的代码无法在使用其他框架的项目中重用。对于这样的情形,并非每个人都感到满意,因此不久后一种新趋势开始流行:重回普通的老式Java对象(Plain Old Java Objects,POJO)。当前,很多流行的框架都支持POJO:

3.2 编写Java代码 - 图2

一个类如果满足如下条件,就被视为真正的POJO:

  • 没有扩展类,也没有实现接口;
  • 是可修改的;
  • 包含一个不接受任何参数的公有构造函数;
  • 使用公有方法来存储和获取私有变量的值。

3.2 编写Java代码 - 图3 即便需要扩展类或实现接口,遵守POJO的其他设计规则也是不错的选择。POJO不是必须遵守的规则,而是一种约定。

下面就是一个POJO类:

  1. class POJO {
  2. private int value = 0;
  3. public POJO() {
  4. }
  5. public int getValue() {
  6. return value;
  7. }
  8. public void setValue(int value) {
  9. this.value = value;
  10. }
  11. }

在POJO类的实例中,可使用被称为属性的方法来存储和获取值。对于每个属性,都遵守如下约定:

  • 其值存储在私有变量中;
  • 有一个用于返回其值的公有获取方法;
  • 有一个用于存储其值的公有设置方法。

获取方法(getter)的名称以get打头,后面通常是变量名。如果变量是布尔类型,获取方法通常以is(而不是get)打头。设置方法(setter)的名称通常以set打头,而不管变量是哪种类型。

通常,再添加一个公有的重载构造函数,它将POJO类的所有属性(这里是value)作为参数:

  1. public POJO(int value) {
  2. this.value = value;
  3. }

3.2 编写Java代码 - 图4 在大多数IDE中,都只需单击一个按钮就能生成POJO或给既有POJO添加属性。

3.2.4 数组

Java提供了内置的数组支持。要声明数组,可在类型或变量名后添加[],并使用关键字new来创建数组并设置其长度:

  1. int[] intArray1 = new int[2];
  2. int intArray2[] = new int[2];

在Java中,必须在创建数组时显式地设置其长度。对于基本类型数组,其元素的默认初始值为0(数值类型)或false(布尔类型),而对于引用类型数组,其元素的默认初始值为null。

与很多其他流行的编程语言一样,索引是从零开始的。在前面的示例中,数组intArray1intArray2的索引可以是01。如果使用的索引超出了范围,将引发运行阶段异常:

  1. intArray1[0] = 10;
  2. intArray1[1] = 20;

要获取数组的长度,可使用数组提供的只读变量length

  1. System.out.println(intArray1.length);

上述代码将在控制台中打印2。数组缺少一些方便的功能,例如,它们没有重写方法toString(),因此使用方法System.out.println打印数组变量intArray1,得到的将是Object提供的默认输出,如[I@659e0bfd,这没有提供有关数组内容的任何信息。

java.util包中有一个实用的Arrays类,这个类包含很多便利的静态方法。如果你要将数组转换为集合类的实例、在数组中查找元素、对数组进行排序等,建议你参阅API文档。下面是一个使用java.util.Arrays类的示例:

  1. System.out.println(java.util.Arrays.toString(intArray1));

这将打印[10, 20]

可在声明数组的同时对其进行初始化:

  1. int[] intArray = { 10, 20, 30 };

在这个示例中,Java编译器将自动声明一个包含3个元素的int数组,并将每个元素都初始化为指定的值。

3.2.5 泛型和集合

前一章讨论了集合,因为它们对JVM来说非常重要。刚引入Java时,集合类只能存储java.lang.Object对象。鉴于JVM中的每个对象都可向上转换为java.lang.Object实例,因此集合一直能够存储任何类型的对象。这种灵活性也有缺点:要访问类成员,必须先向下转换对象。如果不小心添加了一个不同类型的对象,这可能导致运行阶段错误。下面的示例程序能够通过编译,但运行时将导致错误:

  1. import java.util.ArrayList;
  2. class ClassCastExceptionExample {
  3. public static void main(String[] args) {
  4. ArrayList list = new ArrayList();
  5. list.add(new Integer(123));
  6. list.add("This is not an integer");
  7. Integer i = (Integer)list.get(0);
  8. i = (Integer)list.get(1); // 运行阶段异常!!!
  9. }
  10. }

不能将java.lang.String实例转换为java.lang.Integer实例,因此将第二个元素(索引为1)转换为整数时,引发了ClassCastException异常。

为确保某些类只能用于存储固定的类型(由开发人员指定),在Java语言中添加了泛型。例如,可创建一个只能存储java.lang.Integer实例的ArrayList对象,这样如果代码试图在这个ArrayList对象中添加其他对象,编译器将拒绝编译。泛型是一个复杂的主题,这里只讨论其基本用法。鉴于后面的示例将使用ArrayList,因此先来看下面的类图,其中显示了ArrayList实现的一些接口。

3.2 编写Java代码 - 图5

<E>表示相应的接口(和实现它的类)支持泛型。可将E视为使用List时将指定的类型的别名。根据约定,使用单个字母(这里是E)来表示元素。要声明一个只能存储java.lang.Integer实例的java.util.ArrayList实例,可使用一个指向List接口的引用:

  1. import java.util.ArrayList;
  2. import java.util.List;
  3. class GenericsExample {
  4. public static void main(String[] args) {
  5. List<Integer> listWithIntegers = new ArrayList<>();
  6. listWithIntegers.add(new Integer(1));
  7. }
  8. }

为指定所需的类型(这里是Integer类),在接口类型List后面添加了。创建ArrayList的实例时,无需再指定Integer类,而只需添加<>即可。现在,如果你试图添加不能向上转换为Integer的类的实例,编译器将拒绝对代码进行编译。如果不使用泛型,这种错误只能到运行阶段才能检测到,因此程序员大都认为这是一种进步。

我们让引用类型变量listWithIntegers指向接口java.util.List,而不是ArrayList类,虽然并非必须这样做。java.util.List是一个泛型接口,ArrayList以及Collection API中的其他数据结构都实现了它。这样做是一种不错的约定,因为通过这样做,可将ArrayList替换为实现了接口java.util.List的其他数据结构,而无需对其他代码做任何修改。

3.2 编写Java代码 - 图6 在JVM领域,隐藏实现细节是一种非常好的设计选择,而接口和抽象类让这成为可能。

下面来看一个使用泛型HashMap的示例。HashMap是一个位于java.util包中的类,实现了更通用的接口java.util.Map。同样,这里也将把引用变量的类型声明为Map接口,以隐藏这样的设计信息,即我们使用的是HashMap类。我们先来看看接口Map

  1. public interface Map<K,V>

从中可知,Map支持泛型,且要求指定两种类型——KV(它们分别代表键和值)。下面来创建一个将String键映射到Integer值的HashMap实例:

  1. import java.util.HashMap;
  2. import java.util.Map;
  3. class GenericsExample {
  4. public static void main(String[] args) {
  5. Map<String, Integer> map = new HashMap<>();
  6. map.put("one", new Integer(1));
  7. map.put("ten", new Integer(10));
  8. System.out.println(map.get("one"));
  9. }
  10. }

这将向控制台打印Integer1

3.2 编写Java代码 - 图7 泛型只适用于对象,指定基本类型值将导致编译错误。处理泛型时,编译器不会将基本类型值自动装箱,也不会将对象自动拆箱。

3.2.6 循环

数组和集合很好,但仅当你能够遍历它们时才如此。Java提供了如下内置的循环结构:

  • for循环;
  • while循环。

for循环

Java有两种for循环:

  • 常规for循环(使用计数器);
  • 改进的for循环(用于对象)。

(1) 常规for循环

常规for循环的语法如下:

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

这将打印0-9,每个数字各占一行。

与其他语言中一样,for循环由三部分组成。

  • 第一部分初始化计数器。
  • 第二部分包含每次迭代开始前都将检查的表达式。如果这个表达式返回false,将停止迭代。
  • 最后一部分是每次迭代后都将调用的语句。

每部分都是可选的,但分隔各部分的分号必不可少。一种不同寻常的结构是无限循环,可通过将每部分都设置为空来创建:

  1. for (;;) {
  2. }

可使用关键字break来提前结束for循环。要结束当前迭代并进入下一次迭代,可使用关键字continue

  1. for (int i=0; i < 4; i++) {
  2. if (i == 1)
  3. continue;
  4. if (i == 3)
  5. break;
  6. System.out.println(i);
  7. }

上述for循环在i1时跳到下一次迭代,并在i3时结束,因此它只向控制台打印02

(2) 改进的for循环

改进的for循环只能用于数组和实现了泛型接口java.lang.Iterable的对象。大多数Collection API类都实现了这个接口。下面是一个将这种for循环用于数组的示例:

  1. String[] stringArray = { "One", "Two", "Three" };
  2. for (String s: stringArray) {
  3. System.out.println(s);
  4. }

这将打印OneTwoThree,每个字符串各占一行。推荐你尽可能使用这种for循环,因为这样可提高代码的可读性。

(3) while循环

在Java中,while循环类似于下面这样:

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

其中的表达式的结果必须为布尔值。这个示例什么都不会打印,因为int变量i的值为10,导致表达式的结果为false,所以根本就不会进入循环。

for循环一样,while循环也可使用关键字break来提前结束,还可使用关键字continue来跳到下一次迭代。

(4) do…while循环

do…while循环很像while循环,唯一的差别是它在迭代结束后再评估表达式。在任何情况下,都将进入这种循环:

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

这将打印10。在第一次迭代结束后对表达式进行评估;由于11大于10,因此表达式返回false,循环就此结束。

for循环和while循环一样,do…while循环也可使用关键字break来提前结束,还可使用关键字continue来跳到下一次迭代。

3.2.7 异常

异常在前一章讨论过。要处理异常,必须将代码放在try和catch块之间。可指定多种要处理的异常。下面的类图列出了几种异常:

3.2 编写Java代码 - 图8

下面的示例代码故意引发了除零错误:

  1. try {
  2. System.out.println(0/0);
  3. System.out.println("exit");
  4. } catch (NullPointerException e) {
  5. System.out.println("NULL POINTER EXCEPTION!");
  6. } catch (ArithmeticException e) {
  7. System.out.println(e.getMessage());
  8. e.printStackTrace();
  9. } catch (Exception e) {
  10. System.out.println("DIFFERENT ERROR");
  11. }

这将打印如下内容:

  1. / by zero
  2. java.lang.ArithmeticException: / by zero
  3. at JavaApplication8.main(JavaApplication8.java:4)

注意,没有打印文本exit,因为相应的语句还未执行就引发了异常。表达式0/0引发异常ArithmeticException后,JVM分析错误处理程序中的所有catch块。由于引发的异常ArithmeticException不是NullPointerException的子类,因此忽略第一个catch块。第二个catch块与这个异常完全匹配,因此执行这个catch块,打印这个异常的消息和栈跟踪。

如果引发的异常不是NullPointerExceptionArithmeticException,也不是它们的子类,JVM将指定第三个catch块,因为异常通常是java.lang.Exception的子类。

如果这些catch块都与引发的异常不匹配,JVM将查看方法的调用者。如果其中有try…catch块,就对其进行分析。如果找到匹配的catch块,就执行它。如果调用者没有try…catch块,就查看调用者的调用者,直到找遍调用栈。如果没有try…catch块能够处理错误,程序将崩溃。

必须按正确的顺序放置catch块:将捕获最具体的异常类的catch块放在最前面,然后是捕获Exception子类的catch块,最后是捕获Exception本身的catch块。下面的示例不能通过编译:

  1. try {
  2. Integer i = null;
  3. System.out.println(i.toString());
  4. } catch (Exception e) {
  5. // ...
  6. } catch (NullPointerException e) {
  7. // 不能通过编译!
  8. }

因为异常NullPointerExceptionException类的子类,Java编译器知道不可能到达捕获NullPointerException的catch块,所以拒绝编译这些代码。

可添加可选的finally块。在任何情况下,finally块中的语句都会执行,即便引发了异常:

  1. try {
  2. throw new Exception("oops");
  3. } catch (Exception e) {
  4. System.out.println("Exception!");
  5. } finally {
  6. System.out.println("FINALLY!");
  7. }

这将打印如下内容:

  1. Exception!
  2. FINALLY!

运行阶段异常

在方法可引发的异常方面,Java的要求不同寻常:方法可引发任何属于java.lang.RuntimeException的子类的异常。从本章前面的类图可知,NullPointerExceptionArithmeticException都是RuntimeException的子类。

要引发不是java.lang.RuntimeException的子类的异常,方法必须显式地列出它们。来看一下接口java.sql.ResultSet的一个方法的声明:

  1. public int getInt(String column) throws SQLException

这个方法将一个表示列名的String作为参数,并返回从数据库中检索到的int值。这个方法的签名告诉Java编译器,它可能引发SQLException异常,这个异常是java.lang.Exception的子类,但不是java.lang.RuntimeException的子类。现在,Java编译器知道,这个方法可能引发SQLException异常。

重写方法时,无论方法来自具体类、抽象类还是接口,均可:

  • 选择不引发任何异常,为此可根本不添加关键字throws
  • 使用关键字throws接受原始方法的全部或部分异常;
  • throws列表中的部分或全部异常替换为其子类。

除非原始方法可能引发的异常为RuntimeException,否则重写后的方法不能引发其他异常。

3.2.8 线程

这里只介绍最简单的并发编程:启动多个线程。

在支持线程方面,接口java.lang.Runnable扮演着至关重要的角色。这个接口很简单,只包含一个方法:

3.2 编写Java代码 - 图9

如果一个类实现了这个接口并提供了方法run()的实现,就可在独立的线程中启动。来看一个简单的示例。

首先,有一个实现了接口Runnable的类,能够在不同的线程中运行:

  1. class SleepyClass implements Runnable {
  2. private int number;
  3. public SleepyClass(int number) { this.number = number; }
  4. @Override
  5. public void run() {
  6. System.out.println("Thread " + number + " started!");
  7. try {
  8. Thread.sleep(3000);
  9. } catch (InterruptedException e) {
  10. e.printStackTrace();
  11. }
  12. System.out.println("Thread " + number + " ended!");
  13. }
  14. }

接下来,有另一个类,它在两个线程中运行前述代码:

  1. public class ThreadsDemo {
  2. public static void main(String[] args) {
  3. Thread thread1 = new Thread(new SleepyClass(1));
  4. Thread thread2 = new Thread(new SleepyClass(2));
  5. thread1.start();
  6. thread2.start();
  7. }
  8. }

这个程序运行时,输出可能是这样的:

  1. Thread 1 started!
  2. Thread 2 started!
  3. Thread 2 ended!
  4. Thread 1 ended!

对线程的启动顺序,JVM不做任何保证,即不保证线程1先开始、先结束。如果CPU内核足够多,可能每个线程都会在不同的CPU内核中运行。

SleepyClass类展示了Java开发人员经常会遇到的与异常处理相关的问题。Thread类的方法sleep可能引发异常InterruptedException,这种异常不是RuntimeException的子类。我们不能在方法run()的声明中添加throws InterruptedException,因为接口Runnable的方法run()不会引发这种异常或其子类。因此,我们选择在run()中处理这种异常。

并发编程很难。程序员必须确保多个线程不会同时修改同一个变量,否则可能引发竞态条件,因而导致数据受损和微妙的bug。之所以会出现这些问题,是因为对变量的操作大都不是原子性的,为完成一个操作必须执行多条CPU指令。在一个操作还在进行时,另一个线程将开始对同一个变量执行操作。

Java提供了一个用于方法的非访问限定符——synchronized,可确保方法不会被多个线程同时调用:其他线程等待当前线程调用完方法,再依次调用这个方法:

  1. public synchronized void synchronizedMethod() {
  2. }

线程调用完方法后,JVM将确保相应的锁得以释放,即便方法引发了异常。

3.2 编写Java代码 - 图10 不推荐大量地使用限定符synchronized,因为锁定线程是一种开销很大的操作,可能严重降低程序的性能。

如果你对并发编程感兴趣,请详细研究java.util.concurrent包中的类。

3.2.9 lambda

在Java 8新增的功能中,lambda可能是最受欢迎的,它们让你能够将函数像变量一样传递给方法。在本书介绍的很多语言中,都可使用lambda,因为很多其他的语言都提供了内置的lambda支持。

要向函数传递lambda,得先创建一个函数式接口(functional interface)。函数式接口是只包含一个抽象方法的普通接口。Java自带了大量支持lambda的接口,其中之一是接口java.lang.Runnable。由于这个接口只包含抽象方法run(),因此非常适合用来支持lambda。如果你只想运行一个线程,可直接向Thread实例传递一个匿名的lambda函数:

  1. public class LambdaDemo {
  2. public static void main(String[] args) {
  3. Thread thread1 = new Thread( () -> {
  4. try {
  5. Thread.sleep(3000);
  6. } catch (InterruptedException e) {
  7. }
  8. });
  9. thread1.start();
  10. }
  11. }

乍一看,其中的语法可能令人迷惑。考虑到接口java.lang.Runnable的方法run()不接受任何参数,我们首先指定一对空括号(()),再指定一个箭头(->),它告诉编译器接下来是一个lambda。

在这个代码块中,像通常那样编写将使用线程来运行的函数的代码。

在下一章,我们将使用更复杂的接受参数的lambda。