5.4 枚举的本质

本节探讨Java中的枚举类型。枚举是一种特殊的数据,它的取值是有限的,是可以枚举出来的,比如一年有四季、一周有七天。虽然使用类也可以处理这种数据,但枚举类型更为简洁、安全和方便。下面介绍枚举的使用和实现原理。先介绍基础用法和原理,再介绍典型场景。

5.4.1 基础

定义和使用基本的枚举是比较简单的,我们来看个例子。为表示衣服的尺寸,我们定义一个枚举类型Size,包括三个尺寸:小、中、大,代码如下:

1
2
3
public enum Size {
SMALL, MEDIUM, LARGE
}

枚举使用enum这个关键字来定义,Size包括三个值,分别表示小、中、大,值一般是大写的字母,多个值之间以逗号分隔。枚举类型可以定义为一个单独的文件,也可以定义在其他类内部。

可以这样使用Size:

1
Size size = Size.MEDIUM

Size size声明了一个变量size,它的类型是Size, size=Size.MEDIUM将枚举值MEDIUM赋值给size变量。枚举变量的toString方法返回其字面值,所有枚举类型也都有一个name()方法,返回值与toString()一样,例如:

1
2
3
Size size = Size.SMALL;
System.out.println(size.toString());
System.out.println(size.name());

输出都是SMALL。枚举变量可以使用equals和==进行比较,结果是一样的,例如:

1
2
3
4
Size size = Size.SMALL;
System.out.println(size==Size.SMALL);
System.out.println(size.equals(Size.SMALL));
System.out.println(size==Size.MEDIUM);

上面代码的输出结果为三行,分别是true、true、false。枚举值是有顺序的,可以比较大小。枚举类型都有一个方法int ordinal(),表示枚举值在声明时的顺序,从0开始,例如,如下代码输出为1:

1
2
Size size = Size.MEDIUM;
System.out.println(size.ordinal());

另外,枚举类型都实现了Java API中的Comparable接口,都可以通过方法compareTo与其他枚举值进行比较。比较其实就是比较ordinal的大小,例如,如下代码输出为-1,表示SMALL小于MEDIUM:

1
2
Size size = Size.SMALL;
System.out.println(size.compareTo(Size.MEDIUM));

枚举变量可以用于和其他类型变量一样的地方,如方法参数、类变量、实例变量等。枚举还可以用于switch语句,代码如下所示:

1
2
3
4
5
6
7
8
9
10
static void onChosen(Size size){
switch(size){
case SMALL:
System.out.println("chosen small"); break;
case MEDIUM:
System.out.println("chosen medium"); break;
case LARGE:
System.out.println("chosen large"); break;
}
}

在switch语句内部,枚举值不能带枚举类型前缀,例如,直接使用SMALL,不能使用Size.SMALL。枚举类型都有一个静态的valueOf(String)方法,可以返回字符串对应的枚举值,例如,以下代码输出为true:

1
System.out.println(Size.SMALL==Size.valueOf("SMALL"));

枚举类型也都有一个静态的values方法,返回一个包括所有枚举值的数组,顺序与声明时的顺序一致,例如:

1
2
3
for(Size size : Size.values()){
System.out.println(size);
}

屏幕输出为三行,分别是SMALL、MEDIUM、LARGE。

Java是从Java 5才开始支持枚举的,在此之前,一般是在类中定义静态整型变量来实现类似功能,代码如下所示:

1
2
3
4
5
class Size {
public static final int SMALL = 0;
public static final int MEDIUM = 1;
public static final int LARGE = 2;
}

枚举的好处体现在以下几方面。

  • 定义枚举的语法更为简洁。
  • 枚举更为安全。一个枚举类型的变量,它的值要么为null,要么为枚举值之一,不可能为其他值,但使用整型变量,它的值就没有办法强制,值可能就是无效的。
  • 枚举类型自带很多便利方法(如values、valueOf、toString等),易于使用。

枚举是怎么实现的呢?枚举类型实际上会被Java编译器转换为一个对应的类,这个类继承了Java API中的java.lang.Enum类。Enum类有name和ordinal两个实例变量,在构造方法中需要传递,name()、toString()、ordinal()、compareTo()、equals()方法都是由Enum类根据其实例变量name和ordinal实现的。values和valueOf方法是编译器给每个枚举类型自动添加的,上面的枚举类型Size转换成的普通类的代码大概如代码清单5-12所示。需要说明的是,这只是示意代码,不能直接运行。

代码清单5-12 枚举类Size对应的普通类示意代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public final class Size extends Enum<Size> {
public static final Size SMALL = new Size("SMALL",0);
public static final Size MEDIUM = new Size("MEDIUM",1);
public static final Size LARGE = new Size("LARGE",2);
private static Size[] VALUES = new Size[]{SMALL, MEDIUM, LARGE};
private Size(String name, int ordinal){
super(name, ordinal);
}
public static Size[] values(){
Size[] values = new Size[VALUES.length];
System.arraycopy(VALUES, 0, values, 0, VALUES.length);
return values;
}
public static Size valueOf(String name){
return Enum.valueOf(Size.class, name);
}
}

解释几点:
1)Size是final的,不能被继承,Enum<Size>表示父类,<Size>是泛型写法;
2)Size有一个私有的构造方法,接受name和ordinal,传递给父类,私有表示不能在外部创建新的实例;
3)三个枚举值实际上是三个静态变量,也是final的,不能被修改;
4)values方法是编译器添加的,内部有一个values数组保持所有枚举值;
5)valueOf方法调用的是父类的方法,额外传递了参数Size.class,表示类的类型信息,关于类型信息的详细介绍在第21章,父类实际上是回过头来调用values方法,根据name对比得到对应的枚举值的。

一般枚举变量会被转换为对应的类变量,在switch语句中,枚举值会被转换为其对应的ordinal值。可以看出,枚举类型本质上也是类,但由于编译器自动做了很多事情,因此它的使用更为简洁、安全和方便

5.4.2 典型场景

以上枚举用法是最简单的,实际中枚举经常会有关联的实例变量和方法。比如,上面的Size例子,每个枚举值可能有关联的缩写和中文名称,可能需要静态方法根据缩写返回对应的枚举值,修改后的Size代码如代码清单5-13所示。

代码清单5-13 带有实例变量和方法的枚举类Size
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public enum Size {
SMALL("S", "小号"),
MEDIUM("M", "中号"),
LARGE("L", "大号");
private String abbr;
private String title;
private Size(String abbr, String title){
this.abbr = abbr;
this.title = title;
}
public String getAbbr() {
return abbr;
}
public String getTitle() {
return title;
}
public static Size fromAbbr(String abbr){
for(Size size : Size.values()){
if(size.getAbbr().equals(abbr)){
return size;
}
}
return null;
}
}

上述代码定义了两个实例变量abbr和title,以及对应的get方法,分别表示缩写和中文名称;定义了一个私有构造方法,接受缩写和中文名称,每个枚举值在定义的时候都传递了对应的值;同时定义了一个静态方法fromAbbr,根据缩写返回对应的枚举值。需要说明的是,枚举值的定义需要放在最上面,枚举值写完之后,要以分号(; )结尾,然后才能写其他代码。

这个枚举定义的使用与其他类类似,比如:

1
2
3
4
Size s = Size.MEDIUM;
System.out.println(s.getAbbr()); //输出M
s = Size.fromAbbr("L");
System.out.println(s.getTitle()); //输出“大号”

加了实例变量和方法后,枚举转换后的类与代码清单5-12类似,只是增加了对应的变量和方法,修改了构造方法,代码不同之处大概如代码清单5-14所示。

代码清单5-14 增加了实例变量和方法后的枚举类Size对应的普通类示意代码
1
2
3
4
5
6
7
8
9
10
11
12
13
public final class Size extends Enum<Size> {
public static final Size SMALL = new Size("SMALL",0, "S", "小号");
public static final Size MEDIUM = new Size("MEDIUM",1, "M", "中号");
public static final Size LARGE = new Size("LARGE",2, "L", "大号");
private String abbr;
private String title;
private Size(String name, int ordinal, String abbr, String title){
super(name, ordinal);
this.abbr = abbr;
this.title = title;
}
//其他代码
}

每个枚举值经常有一个关联的标识符(id),通常用int整数表示,使用整数可以节约存储空间,减少网络传输。一个自然的想法是使用枚举中自带的ordinal值,但ordinal值并不是一个好的选择。为什么呢?因为ordinal值会随着枚举值在定义中的位置变化而变化,但一般来说,我们希望id值和枚举值的关系保持不变,尤其是表示枚举值的id已经保存在了很多地方的时候。比如,上面的Size例子,Size.SMALL的ordinal值为0,我们希望0表示的就是Size.SMALL,但如果增加一个表示超小的值XSMALL:

1
2
3
public enum Size {
XSMALL, SMALL, MEDIUM, LARGE
}

这时,0就表示XSMALL了。所以,一般是增加一个实例变量表示id。使用实例变量的另一个好处是,id可以自己定义。比如,Size例子可以写为:

1
2
3
4
5
6
7
8
9
10
public enum Size {
XSMALL(10), SMALL(20), MEDIUM(30), LARGE(40);
private int id;
private Size(int id){
this.id = id;
}
public int getId() {
return id;
}
}

枚举还有一些高级用法,比如,每个枚举值可以有关联的类定义体,枚举类型可以声明抽象方法,每个枚举值中可以实现该方法,也可以重写枚举类型的其他方法。此外,枚举可以实现接口,也可以在接口中定义枚举,其使用相对较少,我们就不介绍了。

至此,关于枚举,我们就介绍完了,对于枚举类型的数据,虽然直接使用类也可以处理,但枚举类型更为简洁、安全和方便。

本章介绍了类的一些扩展概念,包括接口、抽象类、内部类和枚举。我们之前提到过异常,但并未深入讨论,让我们下一章来探讨。

6.2 异常类

NullPointerException和NumberFormatException都是异常类,所有异常类都有一个共同的父类Throwable,我们先来介绍这个父类,然后介绍Java中的异常类体系,最后介绍怎么自定义异常。

6.2.1 Throwable

NullPointerException和NumberFormatException有一个共同的父类Throwable,它有4个public构造方法:

1
2
3
4
1.     public Throwable()
2. public Throwable(String message)
3. public Throwable(String message, Throwable cause)
4. public Throwable(Throwable cause)

Throwable类有两个主要参数:一个是message,表示异常消息;另一个是cause,表示触发该异常的其他异常。异常可以形成一个异常链,上层的异常由底层异常触发,cause表示底层异常。Throwable还有一个public方法用于设置cause:

1
Throwable initCause(Throwable cause)

Throwable的某些子类没有带cause参数的构造方法,就可以通过这个方法来设置,这个方法最多只能被调用一次。在所有构造方法的内部,都有一句重要的函数调用:

1
fillInStackTrace();

它会将异常栈信息保存下来,这是我们能看到异常栈的关键。Throwable有一些常用方法用于获取异常信息,比如:

1
2
3
4
5
6
7
8
void printStackTrace() //打印异常栈信息到标准错误输出流
//打印栈信息到指定的流,PrintStream和PrintWriter在第13章介绍
void printStackTrace(PrintStream s)
void printStackTrace(PrintWriter s)
String getMessage() //获取设置的异常message
Throwable getCause() //获取异常的cause
//获取异常栈每一层的信息, 每个StackTraceElement包括文件名、类名、函数名、行号等信息
StackTraceElement[] getStackTrace()

6.2.2 异常类体系

以Throwable为根,Java定义了非常多的异常类,表示各种类型的异常,部分类如图6-1所示。

epub_923038_47

图6-1 Java异常类体系

Throwable是所有异常的基类,它有两个子类:Error和Exception。

Error表示系统错误或资源耗尽,由Java系统自己使用,应用程序不应抛出和处理,比如图6-1中列出的虚拟机错误(VirtualMacheError)及其子类内存溢出错误(OutOfMemory-Error)和栈溢出错误(StackOverflowError)。

Exception表示应用程序错误,它有很多子类,应用程序也可以通过继承Exception或其子类创建自定义异常,图6-1中列出了三个直接子类:IOException(输入输出I/O异常)、RuntimeException(运行时异常)、SQLException(数据库SQL异常)。

RuntimeException比较特殊,它的名字有点误导,因为其他异常也是运行时产生的,它表示的实际含义是未受检异常(unchecked exception),相对而言,Exception的其他子类和Exception自身则是受检异常(checked exception),Error及其子类也是未受检异常。

受检(checked)和未受检(unchecked)的区别在于Java如何处理这两种异常。对于受检异常,Java会强制要求程序员进行处理,否则会有编译错误,而对于未受检异常则没有这个要求。下文我们会进一步解释。

RuntimeException也有很多子类,表6-1列出了其中常见的一些。

表6-1 常见的RuntimeException

epub_923038_48
如此多不同的异常类其实并没有比Throwable这个基类多多少属性和方法,大部分类在继承父类后只是定义了几个构造方法,这些构造方法也只是调用了父类的构造方法,并没有额外的操作。

那为什么定义这么多不同的类呢?主要是为了名字不同。异常类的名字本身就代表了异常的关键信息,无论是抛出还是捕获异常,使用合适的名字都有助于代码的可读性和可维护性。

6.2.3 自定义异常

除了Java API中定义的异常类,也可以自己定义异常类,一般是继承Exception或者它的某个子类。如果父类是RuntimeException或它的某个子类,则自定义异常也是未受检异常;如果是Exception或Exception的其他子类,则自定义异常是受检异常。

我们通过继承Exception来定义一个异常,如代码清单6-2所示。

代码清单6-2 自定义异常示例
1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class AppException extends Exception {
public AppException() {
super();
}
public AppException(String message, Throwable cause) {
super(message, cause);
}
public AppException(String message) {
super(message);
}
public AppException(Throwable cause) {
super(cause);
}
}

和很多其他异常类一样,我们没有定义额外的属性和代码,只是继承了Exception,定义了构造方法并调用了父类的构造方法。

第6章 异常

之前我们介绍的基本类型、类、接口、枚举都是在表示和操作数据,操作的过程中可能有很多出错的情况,出错的原因可能是多方面的,有的是不可控的内部原因,比如内存不够了、磁盘满了,有的是不可控的外部原因,比如网络连接有问题,更多的可能是程序的编写错误,比如引用变量未初始化就直接调用实例方法。

这些非正常情况在Java中统一被认为是异常,Java使用异常机制来统一处理。本章就来详细讨论Java中的异常机制,首先介绍异常的初步概念,以及异常类本身,然后主要介绍异常的处理。

6.1 初识异常

我们先来看两个具体的异常:NullPointerException和NumberFormatException。

6.1.1 NullPointerException(空指针异常)

我们来看段代码:

1
2
3
4
5
6
7
public class ExceptionTest {
public static void main(String[] args) {
String s = null;
s.indexOf("a");
System.out.println("end");
}
}

变量s没有初始化就调用其实例方法indexOf,运行,屏幕输出为:

1
2
Exception in thread "main" java.lang.NullPointerException
at ExceptionTest.main(ExceptionTest.java:5)

输出是告诉我们:在ExceptionTest类的main函数中,代码第5行,出现了空指针异常(java.lang.NullPointerException)。

但,具体发生了什么呢?当执行s.indexOf(“a”)的时候,Java虚拟机发现s的值为null,没有办法继续执行了,这时就启用异常处理机制,首先创建一个异常对象,这里是类NullPointerException的对象,然后查找看谁能处理这个异常,在示例代码中,没有代码能处理这个异常,因此Java启用默认处理机制,即打印异常栈信息到屏幕,并退出程序。

在介绍函数调用原理的时候,我们介绍过栈,异常栈信息就包括了从异常发生点到最上层调用者的轨迹,还包括行号,可以说,这个栈信息是分析异常最为重要的信息。

Java的默认异常处理机制是退出程序,异常发生点后的代码都不会执行,所以示例代码中的System.out.println(“end”)不会执行。

6.1.2 NumberFormatException(数字格式异常)

我们再来看一个例子,代码如下:

1
2
3
4
5
6
7
8
9
10
public class ExceptionTest {
public static void main(String[] args) {
if(args.length<1){
System.out.println("请输入数字");
return;
}
int num = Integer.parseInt(args[0]);
System.out.println(num);
}
}

args表示命令行参数,这段代码要求参数为一个数字,它通过Integer.parseInt将参数转换为一个整数,并输出这个整数。参数是用户输入的,我们没有办法强制用户输入什么,如果用户输入的是数字,比如123,屏幕会输出123,但如果用户输的不是数字而是字母,比如abc,屏幕会输出:

1
2
3
4
5
Exception in thread "main" java.lang.NumberFormatException: For input string: "abc"
at java.lang.NumberFormatException.forInputString(NumberFormatException.java:65)
at java.lang.Integer.parseInt(Integer.java:492)
at java.lang.Integer.parseInt(Integer.java:527)
at ExceptionTest.main(ExceptionTest.java:7)

出现了异常NumberFormatException。这个异常是怎么产生的呢?根据异常栈信息,我们看相关代码。NumberFormatException类65行附近代码如下:

1
2
3
64 static NumberFormatException forInputString(String s) {
65 return new NumberFormatException("For input string: \"" + s + "\"");
66 }

Integer类492行附近代码如下:

1
2
3
4
5
6
7
490 digit = Character.digit(s.charAt(i++), radix);
491 if (digit < 0) {
492 throw NumberFormatException.forInputString(s);
493 }
494 if (result < multmin) {
495 throw NumberFormatException.forInputString(s);
496 }

将这两处合为一行,主要代码就是:

1
throw new NumberFormatException(...)

new NumberFormatException是容易理解的,含义是创建了一个类的对象,只是这个类是一个异常类。throw是什么意思呢?就是抛出异常,它会触发Java的异常处理机制。在之前的空指针异常中,我们没有看到throw的代码,可以认为throw是由Java虚拟机自己实现的。

throw关键字可以与return关键字进行对比。return代表正常退出,throw代表异常退出;return的返回位置是确定的,就是上一级调用者,而throw后执行哪行代码则经常是不确定的,由异常处理机制动态确定。

异常处理机制会从当前函数开始查找看谁“捕获”了这个异常,当前函数没有就查看上一层,直到主函数,如果主函数也没有,就使用默认机制,即输出异常栈信息并退出,这正是我们在屏幕输出中看到的。

对于屏幕输出中的异常栈信息,程序员是可以理解的,但普通用户无法理解,也不知道该怎么办,我们需要给用户一个更为友好的信息,告诉用户,他应该输入的是数字,要做到这一点,需要自己“捕获”异常。“捕获”是指使用try/catch关键字,如代码清单6-1所示。

代码清单6-1 捕获异常示例代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class ExceptionTest {
public static void main(String[] args) {
if(args.length<1){
System.out.println("请输入数字");
return;
}
try{
int num = Integer.parseInt(args[0]);
System.out.println(num);
}catch(NumberFormatException e){
System.err.println("参数" + args[0] + "不是有效的数字,请输入数字");
}
}
}

上述代码使用try/catch捕获并处理了异常,try后面的花括号{}内包含可能抛出异常的代码,括号后的catch语句包含能捕获的异常和处理代码,catch后面括号内是异常信息,包括异常类型和变量名,这里是NumberFormatException e,通过它可以获取更多异常信息,花括号{}内是处理代码,这里输出了一个更为友好的提示信息。

捕获异常后,程序就不会异常退出了,但try语句内异常点之后的其他代码就不会执行了,执行完catch内的语句后,程序会继续执行catch花括号外的代码。

至此,我们就对异常有了一个初步的了解。异常是相对于return的一种退出机制,可以由系统触发,也可以由程序通过throw语句触发,异常可以通过try/catch语句进行捕获并处理,如果没有捕获,则会导致程序退出并输出异常栈信息。异常有不同的类型,接下来,我们来认识一下。

6.3 异常处理

在了解了异常的基本概念和异常类之后,我们来看Java语言对异常处理的支持,包括catch、throw、finally、try-with-resources和throws,最后对比受检和未受检异常。

6.3.1 catch匹配

在代码清单6-1中,我们简单演示了使用try/catch捕获异常,其中catch只有一条,其实,catch还可以有多条,每条对应一种异常类型。示例如下面代码所示:

1
2
3
4
5
6
7
8
9
try{
//可能触发异常的代码
}catch(NumberFormatException e){
System.out.println("not valid number");
}catch(RuntimeException e){
System.out.println("runtime exception "+e.getMessage());
}catch(Exception e){
e.printStackTrace();
}

异常处理机制将根据抛出的异常类型找第一个匹配的catch块,找到后,执行catch块内的代码,不再执行其他catch块,如果没有找到,会继续到上层方法中查找。需要注意的是,抛出的异常类型是catch中声明异常的子类也算匹配,所以需要将最具体的子类放在前面,如果基类Exception放在前面,则其他更具体的catch代码将得不到执行。

上述示例也演示了对异常信息的利用,e.getMessage()获取异常消息,e.printStackTrace()打印异常栈到标准错误输出流。这些信息有助于理解为什么会出现异常,这是解决编程错误的常用方法。示例是直接将信息输出到标准流上,实际系统中更常用的做法是输出到专门的日志中。

在示例中,每种异常类型都有单独的catch语句,如果多种异常处理的代码是类似的,这种写法比较烦琐。自Java 7开始支持一种新的语法,多个异常之间可以用“|”操作符,形如:

1
2
3
4
5
try {
//可能抛出 ExceptionA和ExceptionB
} catch (ExceptionA | ExceptionB e) {
e.printStackTrace();
}

6.3.2 重新抛出异常

在catch块内处理完后,可以重新抛出异常,异常可以是原来的,也可以是新建的,如下所示:

1
2
3
4
5
6
7
8
9
try{
//可能触发异常的代码
}catch(NumberFormatException e){
System.out.println("not valid number");
throw new AppException("输入格式不正确", e);
}catch(Exception e){
e.printStackTrace();
throw e;
}

对于Exception,在打印出异常栈后,就通过throw e重新抛出了。

而对于NumberFormatException,重新抛出了一个AppException,当前Exception作为cause传递给了AppException,这样就形成了一个异常链,捕获到AppException的代码可以通过getCause()得到NumberFormatException。

为什么要重新抛出呢?因为当前代码不能够完全处理该异常,需要调用者进一步处理。

为什么要抛出一个新的异常呢?当然是因为当前异常不太合适。不合适可能是信息不够,需要补充一些新信息;还可能是过于细节,不便于调用者理解和使用,如果调用者对细节感兴趣,还可以继续通过getCause()获取到原始异常。

6.3.3 finally

异常机制中还有一个重要的部分,就是finally。catch后面可以跟finally语句,语法如下所示:

1
2
3
4
5
6
7
try{
//可能抛出异常
} catch(Exception e) {
//捕获异常
} finally {
//不管有无异常都执行
}

finally内的代码不管有无异常发生,都会执行,具体来说:

  • 如果没有异常发生,在try内的代码执行结束后执行。
  • 如果有异常发生且被catch捕获,在catch内的代码执行结束后执行。
  • 如果有异常发生但没被捕获,则在异常被抛给上层之前执行。

由于finally的这个特点,它一般用于释放资源,如数据库连接、文件流等。

finally语句有一个执行细节,如果在try或者catch语句内有return语句,则return语句在finally语句执行结束后才执行,但finally并不能改变返回值,我们来看下面的代码:

1
2
3
4
5
6
7
8
public static int test(){
int ret = 0;
try{
return ret;
}finally{
ret = 2;
}
}

这个函数的返回值是0,而不是2。实际执行过程是:在执行到try内的return ret;语句前,会先将返回值ret保存在一个临时变量中,然后才执行finally语句,最后try再返回那个临时变量,finally中对ret的修改不会被返回。

如果在finally中也有return语句呢?try和catch内的return会丢失,实际会返回finally中的返回值。finally中有return不仅会覆盖try和catch内的返回值,还会掩盖try和catch内的异常,就像异常没有发生一样,比如:

1
2
3
4
5
6
7
8
9
public static int test(){
int ret = 0;
try{
int a = 5/0;
return ret;
}finally{
return 2;
}
}

以上代码中,5/0会触发ArithmeticException,但是finally中有return语句,这个方法就会返回2,而不再向上传递异常了。finally中,如果finally中抛出了异常,则原异常也会被掩盖,看下面的代码:

1
2
3
4
5
6
7
public static void test(){
try{
int a = 5/0;
}finally{
throw new RuntimeException("hello");
}
}

finally中抛出了RuntimeException,则原异常ArithmeticException就丢失了。所以,一般而言,为避免混淆,应该避免在finally中使用return语句或者抛出异常,如果调用的其他代码可能抛出异常,则应该捕获异常并进行处理。

6.3.4 try-with-resources

对于一些使用资源的场景,比如文件和数据库连接,典型的使用流程是首先打开资源,最后在finally语句中调用资源的关闭方法,针对这种场景,Java 7开始支持一种新的语法,称之为try-with-resources,这种语法针对实现了java.lang.AutoCloseable接口的对象,该接口的定义为:

1
2
3
public interface AutoCloseable {
void close() throws Exception;
}

没有try-with-resources时,使用形式如下:

1
2
3
4
5
6
7
8
public static void useResource() throws Exception {
AutoCloseable r = new FileInputStream("hello"); //创建资源
try {
//使用资源
} finally {
r.close();
}
}

使用try-with-resources语法,形式如下:

1
2
3
4
5
public static void useResource() throws Exception {
try(AutoCloseable r = new FileInputStream("hello")) { //创建资源
//使用资源
}
}

资源r的声明和初始化放在try语句内,不用再调用finally,在语句执行完try语句后,会自动调用资源的close()方法。

资源可以定义多个,以分号分隔。在Java 9之前,资源必须声明和初始化在try语句块内,Java 9去除了这个限制,资源可以在try语句外被声明和初始化,但必须是final的或者是事实上final的(即虽然没有声明为final但也没有被重新赋值)。

6.3.5 throws

异常机制中,还有一个和throw很像的关键字throws,用于声明一个方法可能抛出的异常,语法如下所示:

1
2
3
4
public void test() throws AppException,
SQLException, NumberFormatException {
//主体代码
}

throws跟在方法的括号后面,可以声明多个异常,以逗号分隔。这个声明的含义是,这个方法内可能抛出这些异常,且没有对这些异常进行处理,至少没有处理完,调用者必须进行处理。这个声明没有说明具体什么情况会抛出什么异常,作为一个良好的实践,应该将这些信息用注释的方式进行说明,这样调用者才能更好地处理异常。

对于未受检异常,是不要求使用throws进行声明的,但对于受检异常,则必须进行声明,换句话说,如果没有声明,则不能抛出。

对于受检异常,不可以抛出而不声明,但可以声明抛出但实际不抛出。这主要用于在父类方法中声明,父类方法内可能没有抛出,但子类重写方法后可能就抛出了,子类不能抛出父类方法中没有声明的受检异常,所以就将所有可能抛出的异常都写到父类上了。

如果一个方法内调用了另一个声明抛出受检异常的方法,则必须处理这些受检异常,处理的方式既可以是catch,也可以是继续使用throws,如下所示:

1
2
3
4
5
6
7
public void tester() throws AppException {
try {
test();
} catch(SQLException e) {
e.printStackTrace();
}
}

对于test抛出的SQLException,这里使用了catch,而对于AppException,则将其添加到了自己方法的throws语句中,表示当前方法处理不了,继续由上层处理。

6.3.6 对比受检和未受检异常

通过以上介绍可以看出,未受检异常和受检异常的区别如下:受检异常必须出现在throws语句中,调用者必须处理,Java编译器会强制这一点,而未受检异常则没有这个要求。

为什么要有这个区分呢?我们自己定义异常的时候应该使用受检还是未受检异常呢?对于这个问题,业界有各种各样的观点和争论,没有特别一致的结论。

一种普遍的说法是:未受检异常表示编程的逻辑错误,编程时应该检查以避免这些错误,比如空指针异常,如果真的出现了这些异常,程序退出也是正常的,程序员应该检查程序代码的bug而不是想办法处理这种异常。受检异常表示程序本身没问题,但由于I/O、网络、数据库等其他不可预测的错误导致的异常,调用者应该进行适当处理。

但其实编程错误也是应该进行处理的,尤其是Java被广泛应用于服务器程序中,不能因为一个逻辑错误就使程序退出。所以,目前一种更被认同的观点是:Java中对受检异常和未受检异常的区分是没有太大意义的,可以统一使用未受检异常来代替。

这种观点的基本理由是:无论是受检异常还是未受检异常,无论是否出现在throws声明中,都应该在合适的地方以适当的方式进行处理,而不只是为了满足编译器的要求盲目处理异常,既然都要进行处理异常,受检异常的强制声明和处理就显得烦琐,尤其是在调用层次比较深的情况下。

其实观点本身并不太重要,更重要的是一致性,一个项目中,应该对如何使用异常达成一致,并按照约定使用。

6.4 如何使用异常

针对异常,我们介绍了try/catch/finally、catch匹配、重新抛出、throws、受检/未受检异常,那到底该如何使用异常呢?下面从异常的适用情况、异常处理的目标和一般逻辑等多个角度进行介绍。

6.4.1 异常应该且仅用于异常情况

异常应该且仅用于异常情况,是指异常不能代替正常的条件判断。比如,循环处理数组元素的时候,应该先检查索引是否有效再进行处理,而不是等着抛出索引异常再结束循环。对于一个引用变量,如果正常情况下它的值也可能为null,那就应该先检查是不是null,不为null的情况下再进行调用。

另一方面,真正出现异常的时候,应该抛出异常,而不是返回特殊值。比如,String的substring()方法返回一个子字符串,如代码清单6-3所示。

代码清单6-3 String的substring()方法
1
2
3
4
5
6
7
8
9
10
public String substring(int beginIndex) {
if(beginIndex < 0) {
throw new StringIndexOutOfBoundsException(beginIndex);
}
int subLen = value.length - beginIndex;
if(subLen < 0) {
throw new StringIndexOutOfBoundsException(subLen);
}
return(beginIndex == 0) ? this : new String(value, beginIndex, subLen);
}

代码会检查beginIndex的有效性,如果无效,会抛出StringIndexOutOfBoundsExcep-tion异常。纯技术上一种可能的替代方法是不抛出异常而返回特殊值null,但beginIndex无效是异常情况,异常不能作为正常处理

6.4.2 异常处理的目标

异常大概可以分为三种来源:用户、程序员、第三方。用户是指用户的输入有问题;程序员是指编程错误;第三方泛指其他情况,如I/O错误、网络、数据库、第三方服务等。每种异常都应该进行适当的处理。

处理的目标可以分为恢复和报告。恢复是指通过程序自动解决问题。报告的最终对象可能是用户,即程序使用者,也可能是系统运维人员或程序员。报告的目的也是为了恢复,但这个恢复经常需要人的参与。

对用户,如果用户输入不对,可以提示用户具体哪里输入不对,如果是编程错误,可以提示用户系统错误、建议联系客服,如果是第三方连接问题,可以提示用户稍后重试。

对系统运维人员或程序员,他们一般不关心用户输入错误,而关注编程错误或第三方错误,对于这些错误,需要报告尽量完整的细节,包括异常链、异常栈等,以便尽快定位和解决问题。

用户输入或编程错误一般都是难以通过程序自动解决的,第三方错误则可能可以,甚至很多时候,程序都不应该假定第三方是可靠的,应该有容错机制。比如,某个第三方服务连接不上(比如发短信),可能的容错机制是换另一个提供同样功能的第三方试试,还可能是间隔一段时间进行重试,在多次失败之后再报告错误。

6.4.3 异常处理的一般逻辑

如果自己知道怎么处理异常,就进行处理;如果可以通过程序自动解决,就自动解决;如果异常可以被自己解决,就不需要再向上报告。

如果自己不能完全解决,就应该向上报告。如果自己有额外信息可以提供,有助于分析和解决问题,就应该提供,可以以原异常为cause重新抛出一个异常。

总有一层代码需要为异常负责,可能是知道如何处理该异常的代码,可能是面对用户的代码,也可能是主程序。如果异常不能自动解决,对于用户,应该根据异常信息提供用户能理解和对用户有帮助的信息;对运维和开发人员,则应该输出详细的异常链和异常栈到日志。

这个逻辑与在公司中处理问题的逻辑是类似的,每个级别都有自己应该解决的问题,自己能处理的自己处理,不能处理的就应该报告上级,把下级告诉他的和他自己知道的一并告诉上级,最终,公司老板必须要为所有问题负责。每个级别既不应该掩盖问题,也不应该逃避责任。

本章介绍了Java中的异常机制。在没有异常机制的情况下,唯一的退出机制是return,判断是否异常的方法就是返回值。方法根据是否异常返回不同的返回值,调用者根据不同返回值进行判断,并进行相应处理。每一层方法都需要对调用的方法的每个不同返回值进行检查和处理,程序的正常逻辑和异常逻辑混杂在一起,代码往往难以阅读理解和维护。另外,因为异常毕竟是少数情况,程序员经常偷懒,假装异常不会发生,而忽略对异常返回值的检查,降低了程序的可靠性。

在有了异常机制后,程序的正常逻辑与异常逻辑可以相分离,异常情况可以集中进行处理,异常还可以自动向上传递,不再需要每层方法都进行处理,异常也不再可能被自动忽略,从而,处理异常情况的代码可以大大减少,代码的可读性、可靠性、可维护性也都可以得到提高。

至此,关于Java语言本身的主要概念我们就介绍得差不多了,下一章,我们介绍一些常用的基础类。

4.2 继承的细节

本节探讨继续的一些细节,具体包括:

  • 构造方法;
  • 重名与静态绑定;
  • 重载和重写;
  • 父子类型转换;
  • 继承访问权限(protected);
  • 可见性重写;
  • 防止继承(final)。

下面我们逐个介绍。

4.2.1 构造方法

前面我们说过,子类可以通过super调用父类的构造方法,如果子类没有通过super调用,则会自动调动父类的默认构造方法,那如果父类没有默认构造方法呢?如下所示:

1
2
3
4
5
6
public class Base {
private String member;
public Base(String member){
this.member = member;
}
}

这个类只有一个带参数的构造方法,没有默认构造方法。这个时候,它的任何子类都必须在构造方法中通过super调用Base的带参数构造方法,如下所示,否则,Java会提示编译错误。

1
2
3
4
5
public class Child extends Base {
public Child(String member) {
super(member);
}
}

另外需要注意的是,如果在父类构造方法中调用了可被重写的方法,则可能会出现意想不到的结果。我们来看个例子,下面是基类代码:

1
2
3
4
5
6
7
public class Base {
public Base(){
test();
}
public void test(){
}
}

构造方法调用了test()方法。这是子类代码:

1
2
3
4
5
6
7
8
public class Child extends Base {
private int a = 123;
public Child(){
}
public void test(){
System.out.println(a);
}
}

子类有一个实例变量a,初始赋值为123,重写了test()方法,输出a的值。看下使用的代码:

1
2
3
4
public static void main(String[] args){
Child c = new Child();
c.test();
}

输出结果是:

1
2
0
123

第一次输出为0,第二次输出为123。第一行为什么是0呢?第一次输出是在new过程中输出的,在new过程中,首先是初始化父类,父类构造方法调用test()方法,test()方法被子类重写了,就会调用子类的test()方法,子类方法访问子类实例变量a,而这个时候子类的实例变量的赋值语句和构造方法还没有执行,所以输出的是其默认值0。

像这样,在父类构造方法中调用可被子类重写的方法,是一种不好的实践,容易引起混淆,应该只调用private的方法。

4.2.2 重名与静态绑定

4.1节我们提到,子类可以重写父类非private的方法,当调用的时候,会动态绑定,执行子类的方法。那实例变量、静态方法和静态变量呢?它们可以重名吗?如果重名,访问的是哪一个呢?

重名是可以的,重名后实际上有两个变量或方法。private变量和方法只能在类内访问,访问的也永远是当前类的,即:在子类中访问的是子类的;在父类中访问的是父类的,它们只是碰巧名字一样而已,没有任何关系。

public变量和方法,则要看如何访问它。在类内,访问的是当前类的,但子类可以通过super.明确指定访问父类的。在类外,则要看访问变量的静态类型:静态类型是父类,则访问父类的变量和方法;静态类型是子类,则访问的是子类的变量和方法。我们来看个例子,这是基类代码:

1
2
3
4
5
6
7
public class Base {
public static String s = "static_base";
public String m = "base";
public static void staticTest(){
System.out.println("base static: "+s);
}
}

定义了一个public静态变量s,一个public实例变量m,一个静态方法staticTest。这是子类代码:

1
2
3
4
5
6
7
public class Child extends Base {
public static String s = "child_base";
public String m = "child";
public static void staticTest(){
System.out.println("child static: "+s);
}
}

子类定义了和父类重名的变量和方法。对于一个子类对象,它就有了两份变量和方法,在子类内部访问的时候,访问的是子类的,或者说,子类变量和方法隐藏了父类对应的变量和方法,下面看一下外部访问的代码:

1
2
3
4
5
6
7
8
9
10
public static void main(String[] args) {
Child c = new Child();
Base b = c;
System.out.println(b.s);
System.out.println(b.m);
b.staticTest();
System.out.println(c.s);
System.out.println(c.m);
c.staticTest();
}

以上代码创建了一个子类对象,然后将对象分别赋值给了子类引用变量c和父类引用变量b,然后通过b和c分别引用变量和方法。这里需要说明的是,静态变量和静态方法一般通过类名直接访问,但也可以通过类的对象访问。程序输出为:

1
2
3
4
5
6
static_base
base
base static: static_base
child_base
child
child static: child_base

当通过b(静态类型Base)访问时,访问的是Base的变量和方法,当通过c(静态类型Child)访问时,访问的是Child的变量和方法,这称之为静态绑定,即访问绑定到变量的静态类型。静态绑定在程序编译阶段即可决定,而动态绑定则要等到程序运行时。实例变量静态变量静态方法private方法,都是静态绑定的

4.2.3 重载和重写

重载是指方法名称相同但参数签名不同(参数个数、类型或顺序不同),重写是指子类重写与父类相同参数签名的方法。对一个函数调用而言,可能有多个匹配的方法,有时候选择哪一个并不是那么明显。我们来看个例子,这是基类代码:

1
2
3
4
5
6
public class Base {
public int sum(int a, int b){
System.out.println("base_int_int");
return a+b;
}
}

它定义了方法sum,下面是子类代码:

1
2
3
4
5
6
public class Child extends Base {
public long sum(long a, long b){
System.out.println("child_long_long");
return a+b;
}
}

以下是调用的代码:

1
2
3
4
5
6
public static void main(String[] args){
Child c = new Child();
int a = 2;
int b = 3;
c.sum(a, b);
}

Child和Base都定义了sum方法,这里调用的是哪个sum方法呢?子类的sum方法参数类型虽然不完全匹配但是是兼容的,父类的sum方法参数类型是完全匹配的。程序输出为:

1
base_int_int

父类类型完全匹配的方法被调用了。如果父类代码改成下面这样呢?

1
2
3
4
5
6
public class Base {
public long sum(int a, long b){
System.out.println("base_int_long");
return a+b;
}
}

父类方法类型也不完全匹配了。程序输出为:

1
base_int_long

调用的还是父类的方法。父类和子类的两个方法的类型都不完全匹配,为什么调用父类的呢?因为父类的更匹配一些。现在修改一下子类代码,更改为:

1
2
3
4
5
6
public class Child extends Base {
public long sum(int a, long b){
System.out.println("child_int_long");
return a+b;
}
}

程序输出变为了:

1
child_int_long

终于调用了子类的方法。可以看出,当有多个重名函数的时候,在决定要调用哪个函数的过程中,首先是按照参数类型进行匹配的,换句话说,寻找在所有重载版本中最匹配的,然后才看变量的动态类型,进行动态绑定

4.2.4 父子类型转换

之前我们说过,子类型的对象可以赋值给父类型的引用变量,这叫向上转型,那父类型的变量可以赋值给子类型的变量吗?或者说可以向下转型吗?语法上可以进行强制类型转换,但不一定能转换成功。我们以前面的例子来看:

1
2
Base b = new Child();
Child c = (Child)b;

Child c = (Child)b就是将变量b的类型强制转换为Child并赋值为c,这是没有问题的,因为b的动态类型就是Child,但下面的代码是不行的:

1
2
Base b = new Base();
Child c = (Child)b;

语法上Java不会报错,但运行时会抛出错误,错误为类型转换异常。

一个父类的变量能不能转换为一个子类的变量,取决于这个父类变量的动态类型(即引用的对象类型)是不是这个子类或这个子类的子类。

给定一个父类的变量能不能知道它到底是不是某个子类的对象,从而安全地进行类型转换呢?答案是可以,通过instanceof关键字,看下面代码:

1
2
3
public boolean canCast(Base b){
return b instanceof Child;
}

这个函数返回Base类型变量是否可以转换为Child类型,instanceof前面是变量,后面是类,返回值是boolean值,表示变量引用的对象是不是该类或其子类的对象。

4.2.5 继承访问权限protected

变量和函数有public/private修饰符,public表示外部可以访问,private表示只能内部使用,还有一种可见性介于中间的修饰符protected,表示虽然不能被外部任意访问,但可被子类访问。另外,protected还表示可被同一个包中的其他类访问,不管其他类是不是该类的子类。我们来看个例子,这是基类代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
public class Base {
protected int currentStep;
protected void step1(){
}
protected void step2(){
}
public void action(){
this.currentStep = 1;
step1();
this.currentStep = 2;
step2();
}
}

action表示对外提供的行为,内部有两个步骤step1()和step2(),使用currentStep变量表示当前进行到了哪个步骤,step1()、step2()和currentStep是protected的,子类一般不重写action,而只重写step1和step2,同时,子类可以直接访问currentStep查看进行到了哪一步。子类的代码是:

1
2
3
4
5
6
7
8
public class Child extends Base {
protected void step1(){
System.out.println("child step " + this.currentStep);
}
protected void step2(){
System.out.println("child step " + this.currentStep);
}
}

使用Child的代码是:

1
2
3
4
public static void main(String[] args){
Child c = new Child();
c.action();
}

输出为:

1
2
child step 1
child step 2

基类定义了表示对外行为的方法action,并定义了可以被子类重写的两个步骤step1()和step2(),以及被子类查看的变量currentStep,子类通过重写protected方法step1()和step2()来修改对外的行为。

这种思路和设计是一种设计模式,称之为模板方法。action方法就是一个模板方法,它定义了实现的模板,而具体实现则由子类提供。模板方法在很多框架中有广泛的应用,这是使用protected的一种常见场景

4.2.6 可见性重写

重写方法时,一般并不会修改方法的可见性。但我们还是要说明一点,重写时,子类方法不能降低父类方法的可见性。不能降低是指,父类如果是public,则子类也必须是public,父类如果是protected,子类可以是protected,也可以是public,即子类可以升级父类方法的可见性但不能降低。看个例子,基类代码为:

1
2
3
4
5
6
public class Base {
protected void protect(){
}
public void open(){
}
}

子类代码为:

1
2
3
4
5
6
7
8
9
10
public class Child extends Base {
//以下是不允许的,会有编译错误
//private void protect(){
//}
//以下是不允许的,会有编译错误
//protected void open(){
//}
public void protect(){
}
}

为什么要这样规定呢?继承反映的是“is-a”的关系,即子类对象也属于父类,子类必须支持父类所有对外的行为,将可见性降低就会减少子类对外的行为,从而破坏“is-a”的关系,但子类可以增加父类的行为,所以提升可见性是没有问题的。

4.2.7 防止继承final

4.3节我们会提到,继承是把双刃剑,带来的影响就是,有的时候我们不希望父类方法被子类重写,有的时候甚至不希望类被继承,可以通过final关键字实现。final关键字可以修饰变量,而这是final的另一种用法。一个Java类,默认情况下都是可以被继承的,但加了final关键字之后就不能被继承了,如下所示:

1
2
3
public final class Base {
//主体代码
}

一个非final的类,其中的public/protected实例方法默认情况下都是可以被重写的,但加了final关键字后就不能被重写了,如下所示:

1
2
3
4
5
public class Base {
public final void test(){
System.out.println("不能被重写");
}
}

至此,关于Java继承概念一些细节就介绍完了。但还有些重要的地方我们没有讨论,比如,创建子类对象的具体过程?动态绑定是如何实现的?让我们下节来探讨继承实现的基本原理。

7.2 剖析String

字符串操作是计算机程序中最常见的操作之一。Java中处理字符串的主要类是String和StringBuilder,本节介绍String。先介绍基本用法,然后介绍实现原理,随后介绍编码转换,分析String的不可变性、常量字符串、hashCode和正则表达式。

7.2.1 基本用法

字符串的基本使用是比较简单直接的。可以通过常量定义String变量:

1
String name = "老马说编程";

也可以通过new创建String变量:

1
String name = new String("老马说编程");

String可以直接使用+和+=运算符,如:

1
2
3
4
String name = "老马";
name+= "说编程";
String descritpion = ",探索编程本质";
System.out.println(name+descritpion);

输出为:

1
老马说编程,探索编程本质

String类包括很多方法,以方便操作字符串,比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public boolean isEmpty() //判断字符串是否为空
public int length() //获取字符串长度
public String substring(int beginIndex) //取子字符串
public String substring(int beginIndex, int endIndex) //取子字符串
public int indexOf(int ch) //查找字符,返回第一个找到的索引位置,没找到返回-1
public int indexOf(String str) //查找子串,返回第一个找到的索引位置,没找到返回-1
public int lastIndexOf(int ch) //从后面查找字符
public int lastIndexOf(String str) //从后面查找子字符串
public boolean contains(CharSequence s) //判断字符串中是否包含指定的字符序列
public boolean startsWith(String prefix) //判断字符串是否以给定子字符串开头
public boolean endsWith(String suffix) //判断字符串是否以给定子字符串结尾
public boolean equals(Object anObject) //与其他字符串比较,看内容是否相同
public boolean equalsIgnoreCase(String anotherString) //忽略大小写比较是否相同
public int compareTo(String anotherString) //比较字符串大小
public int compareToIgnoreCase(String str) //忽略大小写比较
public String toUpperCase() //所有字符转换为大写字符,返回新字符串,原字符串不变
public String toLowerCase() //所有字符转换为小写字符,返回新字符串,原字符串不变
public String concat(String str) //字符串连接,返回当前字符串和参数字符串合并结果
public String replace(char oldChar, char newChar) //字符串替换,替换单个字符
//字符串替换,替换字符序列,返回新字符串,原字符串不变
public String replace(CharSequence target, CharSequence replacement)
public String trim() //删掉开头和结尾的空格,返回新字符串,原字符串不变
public String[] split(String regex) //分隔字符串,返回分隔后的子字符串数组

看个String的简单例子,按逗号分隔”hello, world”:

1
2
String str = "hello, world";
String[] arr = str.split(", ");

arr[0]为”hello”, arr[1]为”world”。

String的操作大多简单直接,不再赘述。从调用者的角度了解了String的基本用法,下面我们进一步来理解String的内部(代码基于Java 7)。

7.2.2 走进String内部

String类内部用一个字符数组表示字符串,实例变量定义为:

1
private final char value[];

String有两个构造方法,可以根据char数组创建String变量:

1
2
public String(char value[])
public String(char value[], int offset, int count)

需要说明的是,String会根据参数新创建一个数组,并复制内容,而不会直接用参数中的字符数组。String中的大部分方法内部也都是操作的这个字符数组。比如:
1)length()方法返回的是这个数组的长度。
2)substring()方法是根据参数,调用构造方法String(char value[], int offset, intcount)新建了一个字符串。
3)indexOf()方法查找字符或子字符串时是在这个数组中进行查找。

这些方法的实现大多比较直接,不再赘述。

String中还有一些方法,与这个char数组有关:

1
2
3
4
5
public char charAt(int index) //返回指定索引位置的char
//返回字符串对应的char数组, 注意,返回的是一个复制后的数组,而不是原数组
public char[] toCharArray()
//将char数组中指定范围的字符复制入目标数组指定位置
public void getChars(int srcBegin, int srcEnd, char dst[], int dstBegin)

与Character类似,String也提供了一些方法,按代码点对字符串进行处理,具体不再赘述。

1
2
3
4
public int codePointAt(int index)
public int codePointBefore(int index)
public int codePointCount(int beginIndex, int endIndex)
public int offsetByCodePoints(int index, int codePointOffset)

7.2.3 编码转换

String内部是按UTF-16BE处理字符的,对BMP字符,使用一个char,两个字节,对于增补字符,使用两个char,四个字节。我们在第2.3节介绍过各种编码,不同编码可能用于不同的字符集,使用不同的字节数目,以及不同的二进制表示。如何处理这些不同的编码呢?这些编码与Java内部表示之间如何相互转换呢?

Java使用Charset类表示各种编码,它有两个常用静态方法:

1
2
public static Charset defaultCharset()
public static Charset forName(String charsetName)

第一个方法返回系统的默认编码,比如,在笔者的计算机中,执行如下语句:

1
System.out.println(Charset.defaultCharset().name());

输出为UTF-8。

第二个方法返回给定编码名称的Charset对象,与我们在2.3节介绍的编码相对应,其charset名称可以是US-ASCII、ISO-8859-1、windows-1252、GB2312、GBK、GB18030、Big5、UTF-8等,比如:

1
Charset charset = Charset.forName("GB18030");

String类提供了如下方法,返回字符串按给定编码的字节表示:

1
2
3
public byte[] getBytes()
public byte[] getBytes(String charsetName)
public byte[] getBytes(Charset charset)

第一个方法没有编码参数,使用系统默认编码;第二个方法参数为编码名称;第三个方法参数为Charset。

String类有如下构造方法,可以根据字节和编码创建字符串,也就是说,根据给定编码的字节表示,创建Java的内部表示。

1
2
public String(byte bytes[], int offset, int length, String charsetName)
public String(byte bytes[], Charset charset)

除了通过String中的方法进行编码转换,Charset类中也有一些方法进行编码/解码,本书就不介绍了。重要的是认识到,Java的内部表示与各种编码是不同的,但可以相互转换。

7.2.4 不可变性

与包装类类似,String类也是不可变类,即对象一旦创建,就没有办法修改了。String类也声明为了final,不能被继承,内部char数组value也是final的,初始化后就不能再变了。

String类中提供了很多看似修改的方法,其实是通过创建新的String对象来实现的,原来的String对象不会被修改。比如,concat()方法的代码:

1
2
3
4
5
6
7
8
9
10
public String concat(String str) {
int otherLen = str.length();
if(otherLen == 0) {
return this;
}
int len = value.length;
char buf[] = Arrays.copyOf(value, len + otherLen);
str.getChars(buf, len);
return new String(buf, true);
}

通过Arrays.copyOf方法创建了一块新的字符数组,复制原内容,然后通过new创建了一个新的String,最后一行调用的是String的另一个构造方法,其定义为:

1
2
3
4
String(char[] value, boolean share) {
//assert share : "unshared not supported";
this.value = value;
}

这是一个非公开的构造方法,直接使用传递过来的数组作为内部数组。关于Arrays类,我们在7.4节介绍。

与包装类类似,定义为不可变类,程序可以更为简单、安全、容易理解。但如果频繁修改字符串,而每次修改都新建一个字符串,那么性能太低,这时,应该考虑Java中的另两个类StringBuilder和StringBuffer。

7.2.5 常量字符串

Java中的字符串常量是非常特殊的,除了可以直接赋值给String变量外,它自己就像一个String类型的对象,可以直接调用String的各种方法。我们来看代码:

1
2
3
System.out.println("老马说编程".length());
System.out.println("老马说编程".contains("老马"));
System.out.println("老马说编程".indexOf("编程"));

实际上,这些常量就是String类型的对象,在内存中,它们被放在一个共享的地方,这个地方称为字符串常量池,它保存所有的常量字符串,每个常量只会保存一份,被所有使用者共享。当通过常量的形式使用一个字符串的时候,使用的就是常量池中的那个对应的String类型的对象

比如以下代码:

1
2
3
String name1 = "老马说编程";
String name2 = "老马说编程";
System.out.println(name1==name2);

输出为true。为什么呢?可以认为,”老马说编程”在常量池中有一个对应的String类型的对象,我们假定名称为laoma,上面的代码实际上就类似于:

1
2
3
4
String laoma = new String(new char[]{'老', '马', '说', '编', '程'});
String name1 = laoma;
String name2 = laoma;
System.out.println(name1==name2);

实际上只有一个String对象,三个变量都指向这个对象,name1==name2也就不言而喻了。

需要注意的是,如果不是通过常量直接赋值,而是通过new创建,==就不会返回true了,看下面的代码:

1
2
3
String name1 = new String("老马说编程");
String name2 = new String("老马说编程");
System.out.println(name1==name2);

输出为false。为什么呢?上面代码类似于:

1
2
3
4
String laoma = new String(new char[]{'老', '马', '说', '编', '程'});
String name1 = new String(laoma);
String name2 = new String(laoma);
System.out.println(name1==name2);

String类中以String为参数的构造方法代码如下:

1
2
3
4
public String(String original) {
this.value = original.value;
this.hash = original.hash;
}

hash是String类中另一个实例变量,表示缓存的hashCode值。

可以看出,name1和name2指向两个不同的String对象,只是这两个对象内部的value值指向相同的char数组。其内存布局如图7-1所示。

epub_923038_53

图7-1 两个String对象的内存布局

所以,name1==name2不成立,但name1.equals(name2)是true。

7.2.6 hashCode

7.2.5节中提到hash这个实例变量,它的定义如下:

1
private int hash; //Default to 0

hash变量缓存了hashCode方法的值,也就是说,第一次调用hashCode方法的时候,会把结果保存在hash这个变量中,以后再调用就直接返回保存的值。

我们来看下String类的hashCode方法,代码如下:

1
2
3
4
5
6
7
8
9
10
11
public int hashCode() {
int h = hash;
if(h == 0 && value.length > 0) {
char val[] = value;
for(int i = 0; i < value.length; i++) {
h = 31 * h + val[i];
}
hash = h;
}
return h;
}

如果缓存的hash不为0,就直接返回了,否则根据字符数组中的内容计算hash,计算方法是:

1
s[0]*31^(n-1) + s[1]*31^(n-2) + ... + s[n-1]

s表示字符串,s[0]表示第一个字符,n表示字符串长度,s[0]*31^(n-1)表示31的(n-1)次方再乘以第一个字符的值。

为什么要用这个计算方法呢?使用这个式子,可以让hash值与每个字符的值有关,也与每个字符的位置有关,位置i(i>=1)的因素通过31的(n-i)次方表示。使用31大致是因为两个原因:一方面可以产生更分散的散列,即不同字符串hash值也一般不同;另一方面计算效率比较高,31h与32h-h即(h<<5)-h等价,可以用更高效率的移位和减法操作代替乘法操作。

在Java中,普遍采用以上思路来实现hashCode。

7.2.7 正则表达式

String类中,有一些方法接受的不是普通的字符串参数,而是正则表达式。什么是正则表达式呢?正则表达式可以理解为一个字符串,但表达的是一个规则,一般用于文本的匹配、查找、替换等。正则表达式具有丰富和强大的功能,是一个比较大的话题,我们在第25章单独介绍。

Java中有专门的类(如Pattern和Matcher)用于正则表达式,但对于简单的情况,String类提供了更为简洁的操作,String中接受正则表达式的方法有:

1
2
3
4
public String[] split(String regex)  //分隔字符串
public boolean matches(String regex) //检查是否匹配
public String replaceFirst(String regex, String replacement) //字符串替换
public String replaceAll(String regex, String replacement) //字符串替换

至此,关于String的用法、原理和特性等基本介绍完了。关于String的实现原理,值得了解的是,Java 9对String的实现进行了优化,它的内部不是char数组,而是byte数组,如果字符都是ASCII字符,它就可以使用一个字节表示一个字符,而不用UTF-16BE编码,节省内存。

7.3 剖析StringBuilder

7.2.4节提到,如果字符串修改操作比较频繁,应该采用StringBuilder和StringBuffer类,这两个类的方法基本是完全一样的,它们的实现代码也几乎一样,唯一的不同就在于StringBuffer类是线程安全的,而StringBuilder类不是。

关于线程的概念,我们到第15章再介绍。这里需要知道的就是,线程安全是有成本的,影响性能,而字符串对象及操作大部分情况下不存在线程安全问题,适合使用String-Builder类。所以,本节就只讨论StringBuilder类,包括基本用法和基本原理。

7.3.1 基本用法

StringBuilder的基本用法很简单。创建StringBuilder对象:

1
StringBuilder sb = new StringBuilder();

通过append方法添加字符串:

1
2
sb.append("老马说编程");
sb.append(",探索编程本质");

通过toString方法获取构建后的字符串:

1
System.out.println(sb.toString());

输出为:

1
老马说编程,探索编程本质

大部分情况,使用就这么简单,通过new新建StringBuilder对象,通过append方法添加字符串,然后通过toString方法获取构建完成的字符串。

7.3.2 基本实现原理

StringBuilder类是怎么实现的呢?我们来看下它的内部组成,以及一些主要方法的实现,代码基于Java 7。与String类似,StringBuilder类也封装了一个字符数组,定义如下:

1
char[] value;

与String不同,它不是final的,可以修改。另外,与String不同,字符数组中不一定所有位置都已经被使用,它有一个实例变量,表示数组中已经使用的字符个数,定义如下:

1
int count;

StringBuilder继承自AbstractStringBuilder,它的默认构造方法是:

1
2
3
public StringBuilder() {
super(16);
}

调用父类的构造方法,父类对应的构造方法是:

1
2
3
AbstractStringBuilder(int capacity) {
value = new char[capacity];
}

也就是说,new StringBuilder()代码内部会创建一个长度为16的字符数组,count的默认值为0。来看append方法的代码:

1
2
3
4
5
6
7
8
public AbstractStringBuilder append(String str) {
if(str == null) str = "null";
int len = str.length();
ensureCapacityInternal(count + len);
str.getChars(0, len, value, count);
count += len;
return this;
}

append会直接复制字符到内部的字符数组中,如果字符数组长度不够,会进行扩展,实际使用的长度用count体现。具体来说,ensureCapacityInternal(count+len)会确保数组的长度足以容纳新添加的字符,str.getChars会复制新添加的字符到字符数组中,count+=len会增加实际使用的长度。

ensureCapacityInternal的代码如下:

1
2
3
4
5
private void ensureCapacityInternal(int minimumCapacity) {
//overflow-conscious code
if(minimumCapacity - value.length > 0)
expandCapacity(minimumCapacity);
}

如果字符数组的长度小于需要的长度,则调用expandCapacity进行扩展,其代码为:

1
2
3
4
5
6
7
8
9
10
11
void expandCapacity(int minimumCapacity) {
int newCapacity = value.length * 2 + 2;
if(newCapacity - minimumCapacity < 0)
newCapacity = minimumCapacity;
if(newCapacity < 0) {
if (minimumCapacity < 0) //overflow
throw new OutOfMemoryError();
newCapacity = Integer.MAX_VALUE;
}
value = Arrays.copyOf(value, newCapacity);
}

扩展的逻辑是:分配一个足够长度的新数组,然后将原内容复制到这个新数组中,最后让内部的字符数组指向这个新数组,这个逻辑主要靠下面的代码实现:

1
value = Arrays.copyOf(value, newCapacity);

关于类Arrays,我们下一节介绍,这里主要看下newCapacity是怎么算出来的。参数minimumCapacity表示需要的最小长度,需要多少分配多少不就行了吗?不行,因为那就跟String一样了,每append一次,都会进行一次内存分配,效率低下。这里的扩展策略是跟当前长度相关的,当前长度乘以2,再加上2,如果这个长度不够最小需要的长度,才用minimumCapacity。

比如,默认长度为16,长度不够时,会先扩展到16*2+2即34,然后扩展到34*2+2即70,然后是70*2+2即142,这是一种指数扩展策略。为什么要加2?这样,在原长度为0时也可以一样工作。

为什么要这么扩展呢?这是一种折中策略,一方面要减少内存分配的次数,另一方面要避免空间浪费。在不知道最终需要多长的情况下,指数扩展是一种常见的策略,广泛应用于各种内存分配相关的计算机程序中。不过,如果预先就知道需要多长,那么可以调用StringBuilder的另外一个构造方法:

1
public StringBuilder(int capacity)

字符串构建完后,我们来看toString方法的代码:

1
2
3
4
public String toString() {
//Create a copy, don't share the array
return new String(value, 0, count);
}

基于内部数组新建了一个String。注意,这个String构造方法不会直接用value数组,而会新建一个,以保证String的不可变性。

除了append和toString方法, StringBuilder还有很多其他方法,包括更多构造方法、更多append方法、插入、删除、替换、翻转、长度有关的方法,限于篇幅,就不一一列举了。主要看下插入方法。在指定索引offset处插入字符串str:

1
public StringBuilder insert(int offset, String str)

原来的字符后移,offset为0表示在开头插,为length()表示在结尾插,比如:

1
2
3
4
5
6
StringBuilder sb = new StringBuilder();
sb.append("老马说编程");
sb.insert(0, "关注");
sb.insert(sb.length(), "老马和你一起探索编程本质");
sb.insert(7, ", ");
System.out.println(sb.toString());

输出为:

1
关注老马说编程,老马和你一起探索编程本质

了解了用法,下面来看insert的实现代码:

1
2
3
4
5
6
7
8
9
10
11
12
public AbstractStringBuilder insert(int offset, String str) {
if((offset < 0) || (offset > length()))
throw new StringIndexOutOfBoundsException(offset);
if(str == null)
str = "null";
int len = str.length();
ensureCapacityInternal(count + len);
System.arraycopy(value, offset, value, offset + len, count - offset);
str.getChars(value, offset);
count += len;
return this;
}

这个实现思路是:在确保有足够长度后,首先将原数组中offset开始的内容向后挪动n个位置,n为待插入字符串的长度,然后将待插入字符串复制进offset位置

挪动位置调用了System.arraycopy()方法,这是个比较常用的方法,它的声明如下:

1
public static native void arraycopy(Object src,int srcPos,Object dest, int destPos, int length);

将数组src中srcPos开始的length个元素复制到数组dest中destPos处。这个方法有个优点:即使src和dest是同一个数组,它也可以正确处理。比如下面的代码:

1
2
3
int[] arr = new int[]{1,2,3,4};
System.arraycopy(arr, 1, arr, 0, 3);
System.out.println(arr[0]+", "+arr[1]+", "+arr[2]);

这里,src和dest都是arr, srcPos为1, destPos为0, length为3,表示将第二个元素开始的三个元素移到开头,所以输出为:

1
2,3,4

arraycopy的声明有个修饰符native,表示它的实现是通过Java本地接口实现的。Java本地接口是Java提供的一种技术,用于在Java中调用非Java实现的代码,实际上,array-copy是用C++语言实现的。为什么要用C++语言实现呢?因为这个功能非常常用,而C++的实现效率要远高于Java。

7.3.3 String的+和+=运算符

Java中,String可以直接使用+和+=运算符,这是Java编译器提供的支持,背后,Java编译器一般会生成StringBuilder, +和+=操作会转换为append。比如,如下代码:

1
2
3
String hello = "hello";
hello+=", world";
System.out.println(hello);

背后,Java编译器一般会转换为:

1
2
3
StringBuilder hello = new StringBuilder("hello");
hello.append(", world");
System.out.println(hello.toString());

既然直接使用+和+=就相当于使用StringBuilder和append,那还有什么必要直接使用StringBuilder呢?在简单的情况下,确实没必要。不过,在稍微复杂的情况下,Java编译器可能没有那么智能,它可能会生成过多的StringBuilder,尤其是在有循环的情况下,比如,如下代码:

1
2
3
4
5
String hello = "hello";
for(int i=0; i<3; i++){
hello+=", world";
}
System.out.println(hello);

Java编译器转换后的代码大致如下所示:

1
2
3
4
5
6
7
String hello = "hello";
for(int i=0; i<3; i++){
StringBuilder sb = new StringBuilder(hello);
sb.append(", world");
hello = sb.toString();
}
System.out.println(hello);

在循环内部,每一次+=操作,都会生成一个StringBuilder。

所以,对于简单的情况,可以直接使用String的+和+=,对于复杂的情况,尤其是有循环的时候,应该直接使用StringBuilder。

第3章 类的基础

程序主要就是数据以及对数据的操作,为方便理解和操作,高级语言使用数据类型这个概念,不同的数据类型有不同的特征和操作,Java定义了8种基本数据类型:4种整型byte、short、int、long,两种浮点类型float、double,一种真假类型boolean,一种字符类型char。其他类型的数据都用类这个概念表达。

类比较复杂,本章主要介绍类的一些基础知识,具体分为3节:3.1节主要介绍类的基本概念;3.2节主要通过一些例子来演示如何将一些现实概念和问题通过类以及类的组合来表示和处理;3.3节介绍类代码的组织机制。

3.1 类的基本概念

在第1章,我们暂时将类看作函数的容器,在某些情况下,类也确实只是函数的容器,但类更多表示的是自定义数据类型。本节我们先从容器的角度,然后从自定义数据类型的角度介绍类。

3.1.1 函数容器

我们看个例子——Java API中的类Math,它里面主要包含了若干数学函数,表3-1列出了其中一些。

表3-1 Math类的常用函数

epub_923038_34
要使用这些函数,直接在前面加Math.即可,例如Math.abs(-1)返回1。这些函数都有相同的修饰符:public static。static表示类方法,也叫静态方法,与类方法相对的是实例方法。实例方法没有static修饰符,必须通过实例或者对象调用,而类方法可以直接通过类名进行调用,不需要创建实例。public表示这些函数是公开的,可以在任何地方被外部调用。

与public相对的是private。如果是private,则表示私有,这个函数只能在同一个类内被别的函数调用,而不能被外部的类调用。在Math类中,有一个函数Random initRNG()就是private的,这个函数被public的方法random()调用以生成随机数,但不能在Math类以外的地方被调用。

将函数声明为private可以避免该函数被外部类误用,调用者可以清楚地知道哪些函数是可以调用的,哪些是不可以调用的。类实现者通过private函数封装和隐藏内部实现细节,而调用者只需要关心public就可以了。可以说,通过private封装和隐藏内部实现细节,避免被误操作,是计算机程序的一种基本思维方式

除了Math类,我们再来看一个例子Arrays。Arrays里面包含很多与数组操作相关的函数,表3-2列出了其中一些。

表3-2 Arrays类的一些函数

epub_923038_35
这里将类看作函数的容器,更多的是从语言实现的角度看,从概念的角度看,Math和Arrays也可以看作自定义数据类型,分别表示数学和数组类型,其中的public static函数可以看作类型能进行的操作。接下来更为详细地讨论自定义数据类型。

3.1.2 自定义数据类型

我们将类看作自定义数据类型,所谓自定义数据类型就是除了8种基本类型以外的其他类型,用于表示和处理基本类型以外的其他数据。一个数据类型由其包含的属性以及该类型可以进行的操作组成,属性又可以分为是类型本身具有的属性,还是一个具体实例具有的属性,同样,操作也可以分为是类型本身可以进行的操作,还是一个具体实例可以进行的操作。

这样,一个数据类型就主要由4部分组成:

  • 类型本身具有的属性,通过类变量体现。
  • 类型本身可以进行的操作,通过类方法体现。
  • 类型实例具有的属性,通过实例变量体现。
  • 类型实例可以进行的操作,通过实例方法体现。

不过,对于一个具体类型,每一个部分不一定都有,Arrays类就只有类方法。

类变量和实例变量都叫成员变量,也就是类的成员,类变量也叫静态变量静态成员变量。类方法和实例方法都叫成员方法,也都是类的成员,类方法也叫静态方法

类方法我们上面已经看过了,Math和Arrays类中定义的方法就是类方法,这些方法的修饰符必须有static。下面解释类变量、实例变量和实例方法。

1.类变量

类型本身具有的属性通过类变量体现,经常用于表示一个类型中的常量。比如Math类,定义了两个数学中常用的常量,如下所示:

1
2
public static final double E = 2.7182818284590452354;
public static final double PI = 3.14159265358979323846;

E表示数学中自然对数的底数,自然对数在很多学科中有重要的意义;PI表示数学中的圆周率π。与类方法一样,类变量可以直接通过类名访问,如Math.PI。

这两个变量的修饰符也都有public static, public表示外部可以访问,static表示是类变量。与public相对的也是private,表示变量只能在类内被访问。与static相对的是实例变量,没有static修饰符。

这里多了一个修饰符final, final在修饰变量的时候表示常量,即变量赋值后就不能再修改了。使用final可以避免误操作,比如,如果有人不小心将Math.PI的值改了,那么很多相关的计算就会出错。另外,Java编译器可以对final变量进行一些特别的优化。所以,如果数据赋值后就不应该再变了,就加final修饰符。

表示类变量的时候,static修饰符是必需的,但public和final都不是必需的。

2.实例变量和实例方法

所谓实例,字面意思就是一个实际的例子。实例变量表示具体的实例所具有的属性,实例方法表示具体的实例可以进行的操作。如果将微信订阅号看作一个类型,那“老马说编程”订阅号就是一个实例,订阅号的头像、功能介绍、发布的文章可以看作实例变量,而修改头像、修改功能介绍、发布新文章可以看作实例方法。与基本类型对比,“int a; ”这个语句中,int就是类型,而a就是实例。

接下来,我们通过定义和使用类来进一步理解自定义数据类型。

3.1.3 定义第一个类

我们定义一个简单的类,表示在平面坐标轴中的一个点,代码如下:

1
2
3
4
5
6
7
class Point {
public int x;
public int y;
public double distance(){
return Math.sqrt(x*x+y*y);
}
}

我们来解释一下:

1
public class Point

表示类型的名字是Point,是可以被外部公开访问的。这个public修饰似乎是多余的,不能被外部访问还能有什么用?在这里,确实不能用private修饰Point。但修饰符可以没有(即留空),表示一种包级别的可见性,关于包,3.3节再介绍。另外,类可以定义在一个类的内部,这时可以使用private 修饰符,关于内部类我们在第5章介绍。

1
2
public int x;
public int y;

定义了两个实例变量x和y,分别表示x坐标和y坐标,与类变量类似,修饰符也有public或private修饰符,表示含义类似,public表示可被外部访问,而private表示私有,不能直接被外部访问,实例变量不能有static修饰符。

1
2
3
public double distance(){
return Math.sqrt(x*x+y*y);
}

定义了实例方法distance,表示该点到坐标原点的距离。该方法可以直接访问实例变量x和y,这是实例方法和类方法的最大区别。实例方法直接访问实例变量,到底是什么意思呢?其实,在实例方法中,有一个隐含的参数,这个参数就是当前操作的实例自己,直接操作实例变量,实际也需要通过参数进行。实例方法和类方法的更多区别如下所示。

  • 类方法只能访问类变量,不能访问实例变量,可以调用其他的类方法,不能调用实例方法。
  • 实例方法既能访问实例变量,也能访问类变量,既可以调用实例方法,也可以调用类方法。

如果这些让你感到困惑,没有关系,关于实例方法和类方法的更多细节,后续会进一步介绍。

3.1.4 使用第一个类

定义了类本身和定义了一个函数类似,本身不会做什么事情,不会分配内存,也不会执行代码。方法要执行需要被调用,而实例方法被调用,首先需要一个实例。实例也称为对象,我们可能会交替使用。下面的代码演示了如何使用:

1
2
3
4
5
6
public static void main(String[] args) {
Point p = new Point();
p.x = 2;
p.y = 3;
System.out.println(p.distance());
}

我们解释一下:

1
Point p = new Point();

这个语句包含了Point类型的变量声明和赋值,它可以分为两部分:

1
2
1 Point p;
2 p = new Point();

Point p声明了一个变量,这个变量叫p,是Point类型的。这个变量和数组变量是类似的,都有两块内存:一块存放实际内容,一块存放实际内容的位置。声明变量本身只会分配存放位置的内存空间,这块空间还没有指向任何实际内容。因为这种变量和数组变量本身不存储数据,而只是存储实际内容的位置,它们也都称为引用类型的变量。

p = new Point();创建了一个实例或对象,然后赋值给了Point类型的变量p,它至少做了两件事:

1)分配内存,以存储新对象的数据,对象数据包括这个对象的属性,具体包括其实例变量x和y。
2)给实例变量设置默认值,int类型默认值为0。

与方法内定义的局部变量不同,在创建对象的时候,所有的实例变量都会分配一个默认值,这与创建数组的时候是类似的,数值类型变量的默认值是0, boolean是false, char是“\u0000”,引用类型变量都是null。null是一个特殊的值,表示不指向任何对象。这些默认值可以修改,我们稍后介绍。

1
2
p.x = 2;
p.y = 3;

给对象的变量赋值,语法形式是:<对象变量名>.<成员名>。

1
System.out.println(p.distance());

调用实例方法distance,并输出结果,语法形式是:<对象变量名>.<方法名>。实例方法内对实例变量的操作,实际操作的就是p这个对象的数据。

我们在介绍基本类型的时候,先定义数据,然后赋值,最后是操作,自定义类型与此类似:

  • Point p = new Point();是定义数据并设置默认值。
  • p.x = 2; p.y = 3;是赋值。
  • p.distance()是数据的操作。

可以看出,对实例变量和实例方法的访问都通过对象进行,通过对象来访问和操作其内部的数据是一种基本的面向对象思维。本例中,我们通过对象直接操作了其内部数据x和y,这是一个不好的习惯,一般而言,不应该将实例变量声明为public,而只应该通过对象的方法对实例变量进行操作。这也是为了减少误操作,直接访问变量没有办法进行参数检查和控制,而通过方法修改,可以在方法中进行检查。

3.1.5 变量默认值

之前我们说实例变量都有一个默认值,如果希望修改这个默认值,可以在定义变量的同时就赋值,或者将代码放入初始化代码块中,代码块用{}包围,如下所示:

1
2
3
4
5
int x = 1;
int y;
{
y = 2;
}

x的默认值设为了1, y的默认值设为了2。在新建一个对象的时候,会先调用这个初始化,然后才会执行构造方法中的代码,关于构造方法,我们稍后介绍。

静态变量也可以这样初始化:

1
2
3
4
5
6
static int STATIC_ONE = 1;
static int STATIC_TWO;
static
{
STATIC_TWO = 2;
}

STATIC_TWO=2;语句外面包了一个static {},这叫静态初始化代码块。静态初始化代码块在类加载的时候执行,这是在任何对象创建之前,且只执行一次。

3.1.6 private变量

前面我们说一般不应该将实例变量声明为public,下面我们修改一下类的定义,将实例变量定义为private,通过实例方法来操作变量,如代码清单3-1所示。

代码清单3-1 Point类定义——实例变量定义为private
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Point {
private int x;
private int y;
public void setX(int x) {
this.x = x;
}
public void setY(int y) {
this.y = y;
}
public int getX() {
return x;
}
public int getY() {
return y;
}
public double distance() {
return Math.sqrt(x * x + y * y);
}
}

这个定义中,我们加了4个方法,setX/setY用于设置实例变量的值,getX/getY用于获取实例变量的值。

这里面需要介绍的是this这个关键字。this表示当前实例,在语句this.x=x;中,this.x表示实例变量x,而右边的x表示方法参数中的x。前面我们提到,在实例方法中,有一个隐含的参数,这个参数就是this,没有歧义的情况下,可以直接访问实例变量,在这个例子中,两个变量名都叫x,则需要通过加上this来消除歧义。

这4个方法看上去是非常多余的,直接访问变量不是更简洁吗?而且第1章我们也说过,函数调用是有成本的。在这个例子中,意义确实不太大,实际上,Java编译器一般也会将对这几个方法的调用转换为直接访问实例变量,而避免函数调用的开销。但在很多情况下,通过函数调用可以封装内部数据,避免误操作,我们一般还是不将成员变量定义为public。

使用这个类的代码如下:

1
2
3
4
5
6
public static void main(String[] args) {
Point p = new Point();
p.setX(2);
p.setY(3);
System.out.println(p.distance());
}

上述代码将对实例变量的直接访问改为了方法调用。

3.1.7 构造方法

在初始化对象的时候,前面我们都是直接对每个变量赋值,有一个更简单的方式对实例变量赋初值,就是构造方法,我们先看下代码。在Point类定义中增加如下代码:

1
2
3
4
5
6
7
public Point(){
this(0,0);
}
public Point(int x, int y){
this.x = x;
this.y = y;
}

这两个就是构造方法,构造方法可以有多个。不同于一般方法,构造方法有一些特殊的地方:

1)名称是固定的,与类名相同。这也容易理解,靠这个用户和Java系统就都能容易地知道哪些是构造方法。
2)没有返回值,也不能有返回值。构造方法隐含的返回值就是实例本身。

与普通方法一样,构造方法也可以重载。第二个构造方法是比较容易理解的,使用this对实例变量赋值。

我们解释下第一个构造方法,this(0,0)的意思是调用第二个构造方法,并传递参数“0,0”,我们前面解释说this表示当前实例,可以通过this访问实例变量,这是this的第二个用法,用于在构造方法中调用其他构造方法。

这个this调用必须放在第一行,这个规定也是为了避免误操作。构造方法是用于初始化对象的,如果要调用别的构造方法,先调别的,然后根据情况自己再做调整,而如果自己先初始化了一部分,再调别的,自己的修改可能就被覆盖了。

这个例子中,不带参数的构造方法通过this(0,0)又调用了第二个构造方法,这个调用是多余的,因为x和y的默认值就是0,不需要再单独赋值,我们这里主要是演示其语法。

我们来看下如何使用构造方法,代码如下:

1
Point p = new Point(2,3);

这个调用就可以将实例变量x和y的值设为2和3。前面我们介绍new Point()的时候说,它至少做了两件事,一件是分配内存,另一件是给实例变量设置默认值,这里我们需要加上一件事,就是调用构造方法。调用构造方法是new操作的一部分。

通过构造方法,可以更为简洁地对实例变量进行赋值。关于构造方法,下面我们讨论两个细节概念:一个是默认构造方法;另一个是私有构造方法。

1.默认构造方法

每个类都至少要有一个构造方法,在通过new创建对象的过程中会被调用。但构造方法如果没什么操作要做,可以省略。Java编译器会自动生成一个默认构造方法,也没有具体操作。但一旦定义了构造方法,Java就不会再自动生成默认的,具体什么意思呢?在这个例子中,如果我们只定义了第二个构造方法(带参数的),则下面语句:

1
Point p = new Point();

就会报错,因为找不到不带参数的构造方法。

为什么Java有时候自动生成,有时候不生成呢?在没有定义任何构造方法的时候,Java认为用户不需要,所以就生成一个空的以被new过程调用;定义了构造方法的时候,Java认为用户知道自己在干什么,认为用户是有意不想要不带参数的构造方法,所以不会自动生成。

2.私有构造方法

构造方法可以是私有方法,即修饰符可以为private,为什么需要私有构造方法呢?大致可能有这么几种场景:

1)不能创建类的实例,类只能被静态访问,如Math和Arrays类,它们的构造方法就是私有的。
2)能创建类的实例,但只能被类的静态方法调用。有一种常见的场景:类的对象有但是只能有一个,即单例(单个实例)。在这种场景中,对象是通过静态方法获取的,而静态方法调用私有构造方法创建一个对象,如果对象已经创建过了,就重用这个对象。
3)只是用来被其他多个构造方法调用,用于减少重复代码。

3.1.8 类和对象的生命周期

了解了类和对象的定义与使用,下面我们再从程序运行的角度理解下类和对象的生命周期。

在程序运行的时候,当第一次通过new创建一个类的对象时,或者直接通过类名访问类变量和类方法时,Java会将类加载进内存,为这个类分配一块空间,这个空间会包括类的定义、它的变量和方法信息,同时还有类的静态变量,并对静态变量赋初始值。下一章会进一步介绍有关细节。

类加载进内存后,一般不会释放,直到程序结束。一般情况下,类只会加载一次,所以静态变量在内存中只有一份。

当通过new创建一个对象的时候,对象产生,在内存中,会存储这个对象的实例变量值,每做new操作一次,就会产生一个对象,就会有一份独立的实例变量。

每个对象除了保存实例变量的值外,可以理解为还保存着对应类型即类的地址,这样,通过对象能知道它的类,访问到类的变量和方法代码。

实例方法可以理解为一个静态方法,只是多了一个参数this。通过对象调用方法,可以理解为就是调用这个静态方法,并将对象作为参数传给this。

对象的释放是被Java用垃圾回收机制管理的,大部分情况下,我们不用太操心,当对象不再被使用的时候会被自动释放。

具体来说,对象和数组一样,有两块内存,保存地址的部分分配在栈中,而保存实际内容的部分分配在堆中。栈中的内存是自动管理的,函数调用入栈就会分配,而出栈就会释放。

堆中的内存是被垃圾回收机制管理的,当没有活跃变量指向对象的时候,对应的堆空间就可能被释放,具体释放时间是Java虚拟机自己决定的。活跃变量就是已加载的类的类变量,以及栈中所有的变量。

3.1.9 小结

本节我们主要从自定义数据类型的角度介绍了类,谈了如何定义和使用类。自定义类型由类变量、类方法、实例变量和实例方法组成,为方便对实例变量赋值,介绍了构造方法,最后介绍了类和对象的生命周期。

通过类实现自定义数据类型,封装该类型的数据所具有的属性和操作,隐藏实现细节,从而在更高的层次(类和对象的层次,而非基本数据类型和函数的层次)上考虑和操作数据,是计算机程序解决复杂问题的一种重要的思维方式。

本节提到了多个关键字,这里汇总一下。
1)public:可以修饰类、类方法、类变量、实例变量、实例方法、构造方法,表示可被外部访问。
2)private:可以修饰类、类方法、类变量、实例变量、实例方法、构造方法,表示不可以被外部访问,只能在类内部被使用。
3)static:修饰类变量和类方法,它也可以修饰内部类(5.3节介绍)。
4)this:表示当前实例,可以用于调用其他构造方法,访问实例变量,访问实例方法。
5)final:修饰类变量、实例变量,表示只能被赋值一次,也可以修饰实例方法和局部变量(下章会进一步介绍)。

本节介绍的Point类,其属性只有基本数据类型,下节介绍类的组合,以表达更为复杂的概念。

5.3 内部类的本质

之前我们所说的类都对应于一个独立的Java源文件,但一个类还可以放在另一个类的内部,称之为内部类,相对而言,包含它的类称之为外部类

一般而言,内部类与包含它的外部类有比较密切的关系,而与其他类关系不大,定义在类内部,可以实现对外部完全隐藏,可以有更好的封装性,代码实现上也往往更为简洁

不过,内部类只是Java编译器的概念,对于Java虚拟机而言,它是不知道内部类这回事的,每个内部类最后都会被编译为一个独立的类,生成一个独立的字节码文件。

也就是说,每个内部类其实都可以被替换为一个独立的类。当然,这是单纯就技术实现而言。内部类可以方便地访问外部类的私有变量,可以声明为private从而实现对外完全隐藏,相关代码写在一起,写法也更为简洁,这些都是内部类的好处

在Java中,根据定义的位置和方式不同,主要有4种内部类。

  • 静态内部类。
  • 成员内部类。
  • 方法内部类。
  • 匿名内部类。

其中,方法内部类是在一个方法内定义和使用的;匿名内部类使用范围更小,它们都不能在外部使用;成员内部类和静态内部类可以被外部使用,不过它们都可以被声明为private,这样,外部就不能使用了。接下来,我们逐个介绍这些内部类的语法、实现原理以及使用场景。

5.3.1 静态内部类

静态内部类与静态变量和静态方法定义的位置一样,也带有static关键字,只是它定义的是类,下面我们介绍它的语法、实现原理和应用场景。我们看个静态内部类的例子,如代码清单5-3所示。

静态内部类与静态变量和静态方法定义的位置一样,也带有static关键字,只是它定义的是类,下面我们介绍它的语法、实现原理和应用场景。我们看个静态内部类的例子,如代码清单5-3所示。

代码清单5-3 静态内部类示例
1
2
3
4
5
6
7
8
9
10
11
12
public class Outer {
private static int shared = 100;
public static class StaticInner {
public void innerMethod(){
System.out.println("inner " + shared);
}
}
public void test(){
StaticInner si = new StaticInner();
si.innerMethod();
}
}

外部类为Outer,静态内部类为StaticInner,带有static修饰符。语法上,静态内部类除了位置放在其他类内部外,它与一个独立的类差别不大,可以有静态变量、静态方法、成员方法、成员变量、构造方法等。

静态内部类与外部类的联系也不大(与其他内部类相比)。它可以访问外部类的静态变量和方法,如innerMethod直接访问shared变量,但不可以访问实例变量和方法。在类内部,可以直接使用内部静态类,如test()方法所示。

public静态内部类可以被外部使用,只是需要通过“外部类.静态内部类”的方式使用,如下所示:

1
2
Outer.StaticInner si = new Outer.StaticInner();
si.innerMethod();

静态内部类是怎么实现的呢?代码清单5-3所示的代码实际上会生成两个类:一个是Outer,另一个是Outer$StaticInner,代码大概如代码清单5-4所示。

代码清单5-4 静态内部类示例的内部实现
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class Outer {
private static int shared = 100;
public void test(){
Outer$StaticInner si = new Outer$StaticInner();
si.innerMethod();
}
static int access$0(){
return shared;
}
}
public class Outer$StaticInner {
public void innerMethod() {
System.out.println("inner " + Outer.access$0());
}
}

内部类访问了外部类的一个私有静态变量shared,而我们知道私有变量是不能被类外部访问的,Java的解决方法是:自动为Outer生成一个非私有访问方法access$0,它返回这个私有静态变量shared。

静态内部类的使用场景是很多的,如果它与外部类关系密切,且不依赖于外部类实例,则可以考虑定义为静态内部类。比如,一个类内部,如果既要计算最大值,又要计算最小值,可以在一次遍历中将最大值和最小值都计算出来,但怎么返回呢?可以定义一个类Pair,包括最大值和最小值,但Pair这个名字太普遍,而且它主要是类内部使用的,就可以定义为一个静态内部类。

我们也可以看一些在Java API中使用静态内部类的例子:

  • Integer类内部有一个私有静态内部类IntegerCache,用于支持整数的自动装箱。
  • 表示链表的LinkedList类内部有一个私有静态内部类Node,表示链表中的每个节点。
  • Character类内部有一个public静态内部类UnicodeBlock,用于表示一个Unicode block。

以上一些类的细节我们在后续章节会再介绍。

5.3.2 成员内部类

与静态内部类相比,成员内部类没有static修饰符,少了一个static修饰符,含义有很大不同,下面我们详细讨论。我们看个成员内部类的例子,如代码清单5-5所示。

代码清单5-5 成员内部类示例
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class Outer {
private int a = 100;
public class Inner {
public void innerMethod(){
System.out.println("outer a " +a);
Outer.this.action();
}
}
private void action(){
System.out.println("action");
}
public void test(){
Inner inner = new Inner();
inner.innerMethod();
}
}

Inner就是成员内部类,与静态内部类不同,除了静态变量和方法,成员内部类还可以直接访问外部类的实例变量和方法,如innerMethod直接访问外部类私有实例变量a。成员内部类还可以通过“外部类.this.xxx”的方式引用外部类的实例变量和方法,如Outer.this. action(),这种写法一般在重名的情况下使用,如果没有重名,那么“外部类.this. ”是多余的。

在外部类内,使用成员内部类与静态内部类是一样的,直接使用即可,如test()方法所示。与静态内部类不同,成员内部类对象总是与一个外部类对象相连的,在外部使用时,它不能直接通过new Outer.Inner()的方式创建对象,而是要先将创建一个Outer类对象,代码如下所示:

1
2
3
Outer outer = new Outer();
Outer.Inner inner = outer.new Inner();
inner.innerMethod();

创建内部类对象的语法是“外部类对象.new 内部类()”,如outer.new Inner()。

与静态内部类不同,成员内部类中不可以定义静态变量和方法(final变量例外,它等同于常量),下面介绍的方法内部类和匿名内部类也都不可以。Java为什么要有这个规定呢?可以这么理解,这些内部类是与外部实例相连的,不应独立使用,而静态变量和方法作为类型的属性和方法,一般是独立使用的,在内部类中意义不大,而如果内部类确实需要静态变量和方法,那么也可以挪到外部类中。

成员内部类背后是怎么实现的呢?代码清单5-5也会生成两个类:一个是Outer,另一个是Outer$Inner,它们的代码大概如代码清单5-6所示。

代码清单5-6 成员内部类示例的内部实现
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public class Outer {
private int a = 100;
private void action() {
System.out.println("action");
}
public void test() {
Outer$Inner inner = new Outer$Inner(this);
inner.innerMethod();
}
static int access$0(Outer outer) {
return outer.a;
}
static void access$1(Outer outer) {
outer.action();
}
}
public class Outer$Inner {
final Outer outer;
public Outer$Inner(Outer outer){
ths.outer = outer;
}
public void innerMethod() {
System.out.println("outer a " + Outer.access$0(outer));
Outer.access$1(outer);
}
}

Outer$Inner类有个实例变量outer指向外部类的对象,它在构造方法中被初始化,Outer在新建Outer$Inner对象时给它传递当前对象,由于内部类访问了外部类的私有变量和方法,外部类Outer生成了两个非私有静态方法:access$0用于访问变量a, access$1用于访问方法action。

成员内部类有哪些应用场景呢?如果内部类与外部类关系密切,需要访问外部类的实例变量或方法,则可以考虑定义为成员内部类。外部类的一些方法的返回值可能是某个接口,为了返回这个接口,外部类方法可能使用内部类实现这个接口,这个内部类可以被设为private,对外完全隐藏。

比如,在Java API的类LinkedList中,它的两个方法listIterator和descendingIterator的返回值都是接口Iterator,调用者可以通过Iterator接口对链表遍历,listIterator和descend-ingIterator内部分别使用了成员内部类ListItr和DescendingIterator,这两个内部类都实现了接口Iterator。关于LinkedList,第9章会详细介绍。

5.3.3 方法内部类

内部类还可以定义在一个方法体中。我们看个例子,如代码清单5-7所示。

代码清单5-7 方法内部类示例
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class Outer {
private int a = 100;
public void test(final int param){
final String str = "hello";
class Inner {
public void innerMethod(){
System.out.println("outer a " +a);
System.out.println("param " +param);
System.out.println("local var " +str);
}
}
Inner inner = new Inner();
inner.innerMethod();
}
}

类Inner定义在外部类方法test中,方法内部类只能在定义的方法内被使用。如果方法是实例方法,则除了静态变量和方法,内部类还可以直接访问外部类的实例变量和方法,如innerMethod直接访问了外部私有实例变量a。如果方法是静态方法,则方法内部类只能访问外部类的静态变量和方法。方法内部类还可以直接访问方法的参数和方法中的局部变量,不过,这些变量必须被声明为final,如innerMethod直接访问了方法参数param和局部变量str。

方法内部类是怎么实现的呢?对于代码清单5-7,系统生成的两个类代码大概如代码清单5-8所示。

代码清单5-8 方法内部类示例的内部实现
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class Outer {
private int a = 100;
public void test(final int param) {
final String str = "hello";
OuterInner inner = new OuterInner(this, param);
inner.innerMethod();
}
static int access$0(Outer outer){
return outer.a;
}
}
public class OuterInner {
Outer outer;
int param;
OuterInner(Outer outer, int param){
this.outer = outer;
this.param = param;
}
public void innerMethod() {
System.out.println("outer a " + Outer.access$0(this.outer));
System.out.println("param " + param);
System.out.println("local var " + "hello");
}
}

与成员内部类类似,OuterInner类也有一个实例变量outer指向外部对象,在构造方法中被初始化,对外部私有实例变量的访问也是通过Outer添加的方法access$0来进行的。

方法内部类可以访问方法中的参数和局部变量,这是通过在构造方法中传递参数来实现的,如OuterInner构造方法中有参数int param,在新建OuterInner对象时,Outer类将方法中的参数传递给了内部类,如OuterInner inner = newOuterInner(this, param); 。在上面的代码中,String str并没有被作为参数传递,这是因为它被定义为了常量,在生成的代码中,可以直接使用它的值。

这也解释了为什么方法内部类访问外部方法中的参数和局部变量时,这些变量必须被声明为final,因为实际上,方法内部类操作的并不是外部的变量,而是它自己的实例变量,只是这些变量的值和外部一样,对这些变量赋值,并不会改变外部的值,为避免混淆,所以干脆强制规定必须声明为final。

如果的确需要修改外部的变量,那么可以将变量改为只含该变量的数组,修改数组中的值,如代码清单5-9所示。

代码清单5-9 方法内部类修改外部变量实例
1
2
3
4
5
6
7
8
9
10
11
12
13
public class Outer {
public void test(){
final String[] str = new String[]{"hello"};
class Inner {
public void innerMethod(){
str[0] = "hello world";
}
}
Inner inner = new Inner();
inner.innerMethod();
System.out.println(str[0]);
}
}

str是一个只含一个元素的数组,方法内部类不能修改str本身,但可以修改它的数组元素。

通过前面介绍的语法和原理可以看出,方法内部类可以用成员内部类代替,至于方法参数,也可以作为参数传递给成员内部类。不过,如果类只在某个方法内被使用,使用方法内部类,可以实现更好的封装。

5.3.4 匿名内部类

与前面介绍的内部类不同,匿名内部类没有单独的类定义,它在创建对象的同时定义类,语法如下:

1
2
3
new 父类(参数列表) {
//匿名内部类实现部分
}

或者

1
2
3
new 父接口() {
//匿名内部类实现部分
}

匿名内部类是与new关联的,在创建对象的时候定义类,new后面是父类或者父接口,然后是圆括号(),里面可以是传递给父类构造方法的参数,最后是大括号{},里面是类的定义。

看个具体的例子,如代码清单5-10所示。

代码清单5-10 匿名内部类示例
1
2
3
4
5
6
7
8
9
10
11
public class Outer {
public void test(final int x, final int y){
Point p = new Point(2,3){
@Override
public double distance() {
return distance(new Point(x, y));
}
};
System.out.println(p.distance());
}
}

创建Point对象的时候,定义了一个匿名内部类,这个类的父类是Point,创建对象的时候,给父类构造方法传递了参数2和3,重写了distance()方法,在方法中访问了外部方法final参数x和y。

匿名内部类只能被使用一次,用来创建一个对象。它没有名字,没有构造方法,但可以根据参数列表,调用对应的父类构造方法。它可以定义实例变量和方法,可以有初始化代码块,初始化代码块可以起到构造方法的作用,只是构造方法可以有多个,而初始化代码块只能有一份。因为没有构造方法,它自己无法接受参数,如果必须要参数,则应该使用其他内部类。与方法内部类一样,匿名内部类也可以访问外部类的所有变量和方法,可以访问方法中的final参数和局部变量。

匿名内部类是怎么实现的呢?每个匿名内部类也都被生成为一个独立的类,只是类的名字以外部类加数字编号,没有有意义的名字。代码清单5-10会产生两个类Outer和Outer$1,代码大概如代码清单5-11所示。

代码清单5-11 匿名内部类示例的内部实现
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class Outer {
public void test(final int x, final int y){
Point p = new Outer$1(this,2,3, x, y);
System.out.println(p.distance());
}
}
public class Outer$1 extends Point {
int x2;
int y2;
Outer outer;
Outer$1(Outer outer, int x1, int y1, int x2, int y2){
super(x1, y1);
this.outer = outer;
this.x2 = x2;
this.y2 = y2;
}
@Override
public double distance() {
return distance(new Point(this.x2, y2));
}
}

与方法内部类类似,外部实例this、方法参数x和y都作为参数传递给了内部类构造方法。此外,new时的参数2和3也传递给了构造方法,内部类构造方法又将它们传递给了父类构造方法。

在调用方法时,很多方法需要一个接口参数,比如Arrays.sort方法,它可以接受一个数组,以及一个Comparator接口参数,Comparator有一个方法compare用于比较两个对象。比如,要对一个字符串数组不区分大小写排序,可以使用Arrays.sort方法,但需要传递一个实现了Comparator接口的对象,这时就可以使用匿名内部类,代码如下所示:

1
2
3
4
5
6
7
8
public void sortIgnoreCase(String[] strs){
Arrays.sort(strs, new Comparator<String>() {
@Override
public int compare(String o1, String o2) {
return o1.compareToIgnoreCase(o2);
}
});
}

Comparator后面的<String>与泛型有关,表示比较的对象是字符串类型。匿名内部类还经常用于事件处理程序中,用于响应某个事件,比如一个Button,处理单击事件的代码可能类似如下:

1
2
3
4
5
6
7
Button bt = new Button();
bt.addActionListener(new ActionListener(){
@Override
public void actionPerformed(ActionEvent e) {
//处理事件
}
});

调用addActionListener将事件处理程序注册到了Button对象bt中,当事件发生时,会调用actionPerformed方法,并传递事件详情ActionEvent作为参数。

以上Arrays.sort和Button都是针对接口编程的例子,另外,它们也都是一种回调的例子。所谓回调是相对于一般的正向调用而言的,平时一般都是正向调用,但Arrays.sort中传递的Comparator对象,它的compare方法并不是在写代码的时候被调用的,而是在Arrays. sort的内部某个地方回过头来调用的。Button的addActionListener中传递的ActionListener对象,它的actionPerformed方法也一样,是在事件发生的时候回过头来调用的。

将程序分为保持不变的主体框架,和针对具体情况的可变逻辑,通过回调的方式进行协作,是计算机程序的一种常用实践。匿名内部类是实现回调接口的一种简便方式。

至此,关于各种内部类就介绍完了。内部类本质上都会被转换为独立的类,但一般而言,它们可以实现更好的封装,代码实现上也更为简洁。