第5章 类的扩展

之前我们一直在说,程序主要就是数据以及对数据的操作,而为了方便操作数据,高级语言引入了数据类型的概念。Java定义了8种基本数据类型,而类相当于是自定义数据类型,通过类的组合和继承可以表示和操作各种事物或者说对象。

除了基本的数据类型和类概念,还有一些扩展概念,包括接口、抽象类、内部类和枚举。上一章我们提到,继承有其两面性,替代继承的一种方式是使用接口,接口到底是什么呢?此外,介于接口和类之间,还有一个概念:抽象类,它又是什么呢?一个类可以定义在另一个类内部,称为内部类,为什么要有内部类,它到底是什么呢?枚举是一种特殊的数据类型,它有什么用呢?本章就来探讨这些概念,先来看接口。

5.1 接口的本质

在之前的章节中,我们一直在强调数据类型的概念,但只是将对象看作属于某种数据类型,并按该类型进行操作,在一些情况下,并不能反映对象以及对对象操作的本质

为什么这么说呢?很多时候,我们实际上关心的,并不是对象的类型,而是对象的能力,只要能提供这个能力,类型并不重要。我们来看一些生活中的例子。

比如要拍照,很多时候,只要能拍出符合需求的照片就行,至于是用手机拍,还是用Pad拍,或者是用单反相机拍,并不重要,即关心的是对象是否有拍出照片的能力,而并不关心对象到底是什么类型,手机、Pad或单反相机都可以。

又如要计算一组数字,只要能计算出正确结果即可,至于是由人心算,用算盘算,用计算器算,用计算机软件算,并不重要,即关心的是对象是否有计算的能力,而并不关心对象到底是算盘还是计算器。

再如要将冷水加热,只要能得到热水即可,至于是用电磁炉加热,用燃气灶加热,还是用电热水壶加热,并不重要,即重要的是对象是否有加热水的能力,而并不关心对象到底是什么类型。

在这些情况中,类型并不重要,重要的是能力。那如何表示能力呢?接口。下面就来详细介绍接口,包括其概念、用法、一些细节,以及如何用接口替代继承。

5.1.1 接口的概念

接口这个概念在生活中并不陌生,电子世界中一个常见的接口就是USB接口。计算机往往有多个USB接口,可以插各种USB设备,如键盘、鼠标、U盘、摄像头、手机等。

接口声明了一组能力,但它自己并没有实现这个能力,它只是一个约定。接口涉及交互两方对象,一方需要实现这个接口,另一方使用这个接口,但双方对象并不直接互相依赖,它们只是通过接口间接交互,如图5-1所示。

拿上面的USB接口来说,USB协议约定了USB设备需要实现的能力,每个USB设备都需要实现这些能力,计算机使用USB协议与USB设备交互,计算机和USB设备互不依赖,但可以通过USB接口相互交互。下面我们来看Java中的接口。

epub_923038_45

图5-1 接口的概念

5.1.2 定义接口

我们通过一个例子来说明Java中接口的概念。这个例子是“比较”,很多对象都可以比较,对于求最大值、求最小值、排序的程序而言,它们其实并不关心对象的类型是什么,只要对象可以比较就可以了,或者说,它们关心的是对象有没有可比较的能力。Java API中提供了Comparable接口,以表示可比较的能力,但它使用了泛型,而我们还没有介绍泛型,所以本节先自己定义一个Comparable接口,叫MyComparable。

首先来定义这个接口,代码如下:

1
2
3
public interface MyComparable {
int compareTo(Object other);
}

定义接口的代码解释如下:
1)Java使用interface这个关键字来声明接口,修饰符一般都是public。
2)interface后面就是接口的名字MyComparable。
3)接口定义里面,声明了一个方法compareTo,但没有定义方法体,Java 8之前,接口内不能实现方法。接口方法不需要加修饰符,加与不加相当于都是publicabstract。

再来解释compareTo方法:
1)方法的参数是一个Object类型的变量other,表示另一个参与比较的对象。
2)第一个参与比较的对象是自己。
3)返回结果是int类型,-1表示自己小于参数对象,0表示相同,1表示大于参数对象。

接口与类不同,它的方法没有实现代码。定义一个接口本身并没有做什么,也没有太大的用处,它还需要至少两个参与者:一个需要实现接口,另一个使用接口。我们先来实现接口。

5.1.3 实现接口

类可以实现接口,表示类的对象具有接口所表示的能力。在此以上一章介绍过的Point类来说明。我们让Point具备可以比较的能力,Point之间怎么比较呢?我们假设按照与原点的距离进行比较,Point类代码如代码清单5-1所示。

代码清单5-1 Point类代码:实现了MyComparable
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
27
28
29
30
public class Point implements MyComparable {
private int x;
private int y;
public Point(int x, int y) {
this.x = x;
this.y = y;
}
public double distance(){
return Math.sqrt(x*x+y*y);
}
@Override
public int compareTo(Object other) {
if(! (other instanceof Point)){
throw new IllegalArgumentException();
}
Point otherPoint = (Point)other;
double delta = distance() - otherPoint.distance();
if(delta<0){
return -1;
}else if(delta>0){
return 1;
}else{
return 0;
}
}
@Override
public String toString() {
return "("+x+", "+y+")";
}
}

代码解释如下:
1)Java使用implements这个关键字表示实现接口,前面是类名,后面是接口名。
2)实现接口必须要实现接口中声明的方法,Point实现了compareTo方法。

再来解释Point的compareTo实现。
1)Point不能与其他类型的对象进行比较,它首先检查要比较的对象是否是Point类型,如果不是,使用throw抛出一个异常,异常将在下一章介绍,此处可以忽略。
2)如果是Point类型,则使用强制类型转换将Object类型的参数other转换为Point类型的参数otherPoint。
3)这种显式的类型检查和强制转换是可以使用泛型机制避免的,第8章我们再介绍泛型。

一个类可以实现多个接口,表明类的对象具备多种能力,各个接口之间以逗号分隔,语法如下所示:

1
2
3
public class Test implements Interface1, Interface2 {
// 主体代码
}

定义和实现了接口,接下来我们来看怎么使用接口。

5.1.4 使用接口

与类不同,接口不能new,不能直接创建一个接口对象,对象只能通过类来创建。但可以声明接口类型的变量,引用实现了接口的类对象。比如,可以这样:

1
2
3
MyComparable p1 = new Point(2,3);
MyComparable p2 = new Point(1,2);
System.out.println(p1.compareTo(p2));

p1和p2是MyComparable类型的变量,但引用了Point类型的对象,之所以能赋值是因为Point实现了MyComparable接口。如果一个类型实现了多个接口,那么这种类型的对象就可以被赋值给任一接口类型的变量。p1和p2可以调用MyComparable接口的方法,也只能调用MyComparable接口的方法,实际执行时,执行的是具体实现类的代码。

为什么Point类型的对象非要赋值给MyComparable类型的变量呢?在以上代码中,确实没必要。但在一些程序中,代码并不知道具体的类型,这才是接口发挥威力的地方。我们来看下面使用MyComparable接口的例子,如代码清单5-2所示。

代码清单5-2 使用MyComparable的示例:CompUtil
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
27
28
29
public class CompUtil {
public static Object max(MyComparable[] objs){
if(objs==null||objs.length==0){
return null;
}
MyComparable max = objs[0];
for(int i=1; i<objs.length; i++){
if(max.compareTo(objs[i])<0){
max = objs[i];
}
}
return max;
}
public static void sort(Comparable[] objs){
for(int i=0; i<objs.length; i++){
int min = i;
for(int j=i+1; j<objs.length; j++){
if(objs[j].compareTo(objs[min])<0){
min = j;
}
}
if(min!=i){
Comparable temp = objs[i];
objs[i] = objs[min];
objs[min] = temp;
}
}
}
}

类CompUtil提供了两个方法,max获取传入数组中的最大值,sort对数组升序排序,参数都是MyComparable类型的数组,sort使用的是简单选择排序,具体算法我们就不介绍了。

可以看出,这个类是针对MyComparable接口编程,它并不知道具体的类型是什么,也并不关心,但却可以对任意实现了MyComparable接口的类型进行操作。我们来看如何对Point类型进行操作,代码如下:

1
2
3
4
5
6
Point[] points = new Point[]{
new Point(2,3), new Point(3,4), new Point(1,2)
};
System.out.println("max: " + CompUtil.max(points));
CompUtil.sort(points);
System.out.println("sort: "+ Arrays.toString(points));

以上代码创建了一个Point类型的数组points,然后使用CompUtil的max方法获取最大值,使用sort排序,并输出结果,输出如下:

1
2
max: (3,4)
sort: [(1,2), (2,3), (3,4)]

这里演示的是对Point数组操作,实际上可以针对任何实现了MyComparable接口的类型数组进行操作。这就是接口的威力,可以说,针对接口而非具体类型进行编程,是计算机程序的一种重要思维方式。接口很多时候反映了对象以及对对象操作的本质。它的优点有很多,首先是代码复用,同一套代码可以处理多种不同类型的对象,只要这些对象都有相同的能力,如CompUtil。

接口更重要的是降低了耦合,提高了灵活性。使用接口的代码依赖的是接口本身,而非实现接口的具体类型,程序可以根据情况替换接口的实现,而不影响接口使用者。解决复杂问题的关键是分而治之,将复杂的大问题分解为小问题,但小问题之间不可能一点关系没有,分解的核心就是要降低耦合,提高灵活性,接口为恰当分解提供了有力的工具。

5.1.5 接口的细节

前面介绍了接口的基本内容,接口还有一些细节,包括:

  • 接口中的变量。
  • 接口的继承。
  • 类的继承与接口。
  • instanceof。

下面具体介绍。

(1)接口中的变量

接口中可以定义变量,语法如下所示:

1
2
3
public interface Interface1 {
public static final int a = 0;
}

这里定义了一个变量int a,修饰符是public static final,但这个修饰符是可选的,即使不写,也是public static final。这个变量可以通过“接口名.变量名”的方式使用,如Interface1.a。

(2)接口的继承

接口也可以继承,一个接口可以继承其他接口,继承的基本概念与类一样,但与类不同的是,接口可以有多个父接口,代码如下所示:

1
2
3
4
5
6
7
8
public interface IBase1 {
void method1();
}
public interface IBase2 {
void method2();
}
public interface IChild extends IBase1, IBase2 {
}

IChild有IBase1和IBase2两个父类,接口的继承同样使用extends关键字,多个父接口之间以逗号分隔。

(3)类的继承与接口

类的继承与接口可以共存,换句话说,类可以在继承基类的情况下,同时实现一个或多个接口,语法如下所示:

1
2
3
public class Child extends Base implements IChild {
//主体代码
}

关键字extends要放在implements之前。

(4)instanceof

与类一样,接口也可以使用instanceof关键字,用来判断一个对象是否实现了某接口,例如:

1
2
3
4
Point p = new Point(2,3);
if(p instanceof MyComparable){
System.out.println("comparable");
}

5.1.6 使用接口替代继承

上一章我们提到,可以使用组合和接口替代继承。怎么替代呢?

继承至少有两个好处:一个是复用代码;另一个是利用多态和动态绑定统一处理多种不同子类的对象。使用组合替代继承,可以复用代码,但不能统一处理。使用接口替代继承,针对接口编程,可以实现统一处理不同类型的对象,但接口没有代码实现,无法复用代码。将组合和接口结合起来替代继承,就既可以统一处理,又可以复用代码了。

我们还是以4.4节的例子来说明,先增加一个接口IAdd,代码如下:

1
2
3
4
public interface IAdd {
void add(int number);
void addAll(int[] numbers);
}

修改Base代码,让它实现IAdd接口,代码基本不变:

1
2
3
public class Base implements IAdd {
//主体代码,与代码清单4-10一样
}

修改Child代码,也是实现IAdd接口,代码基本不变:

1
2
3
public class Child implements IAdd {
//主体代码,组合使用Base,与代码清单4-12一样
}

Child复用了Base的代码,又都实现了IAdd接口,这样,既复用代码,又可以统一处理,还不用担心破坏封装。

5.1.7 Java 8和Java 9对接口的增强

需要说明的是,前面介绍的都是Java 8之前的接口概念,Java 8和Java 9对接口做了一些增强。在Java 8之前,接口中的方法都是抽象方法,都没有实现体,Java 8允许在接口中定义两类新方法:静态方法和默认方法,它们有实现体,比如:

1
2
3
4
5
6
7
8
9
public interface IDemo {
void hello();
public static void test() {
System.out.println("hello");
}
default void hi() {
System.out.println("hi");
}
}

test()就是一个静态方法,可以通过IDemo.test()调用。在接口不能定义静态方法之前,相关的静态方法往往定义在单独的类中,比如,Java API中,Collection接口有一个对应的单独的类Collections,在Java 8中,就可以直接写在接口中了,比如Comparator接口就定义了多个静态方法。

hi()是一个默认方法,用关键字default表示。默认方法与抽象方法都是接口的方法,不同在于,默认方法有默认的实现,实现类可以改变它的实现,也可以不改变。引入默认方法主要是函数式数据处理的需求,是为了便于给接口增加功能。关于函数式数据处理,会在第26章介绍。

在没有默认方法之前,Java是很难给接口增加功能的,比如List接口(第9章介绍),因为有太多非Java JDK控制的代码实现了该接口,如果给接口增加一个方法,则那些接口的实现就无法在新版Java上运行,必须改写代码,实现新的方法,这显然是无法接受的。函数式数据处理需要给一些接口增加一些新的方法,所以就有了默认方法的概念,接口增加了新方法,而接口现有的实现类也不需要必须实现。看一些例子,List接口增加了sort方法,其定义为:

1
2
3
4
5
6
7
8
9
default void sort(Comparator<? super E> c) {
Object[] a = this.toArray();
Arrays.sort(a, (Comparator) c);
ListIterator<E> i = this.listIterator();
for(Object e : a) {
i.next();
i.set((E) e);
}
}

Collection接口增加了stream方法,其定义为:

1
2
3
default Stream<E> stream() {
return StreamSupport.stream(spliterator(), false);
}

在Java 8中,静态方法和默认方法都必须是public的,Java 9去除了这个限制,它们都可以是private的,引入private方法主要是为了方便多个静态或默认方法复用代码,比如:

1
2
3
4
5
6
7
8
9
10
11
public interface IDemoPrivate {
private void common() {
System.out.println("common");
}
default void actionA() {
common();
}
default void actionB() {
common();
}
}

这里,actionA和actionB两个默认方法共享了相同的common()方法的代码。

5.1.8 小结

本节我们谈了数据类型思维的局限,提到了很多时候关心的是能力,而非类型,所以引入了接口,介绍了Java中接口的概念和细节。针对接口编程是一种重要的程序思维方式,这种方式不仅可以复用代码,还可以降低耦合,提高灵活性,是分解复杂问题的一种重要工具

接口不能创建对象,没有任何实现代码(Java 8之前),而之前介绍的类都有完整的实现,都可以创建对象。Java中还有一个介于接口和类之间的概念:抽象类,它有什么用呢?

7.6 随机

本节,我们来讨论随机,随机是计算机程序中一个非常常见的需求,比如:

  • 各种游戏中有大量的随机,比如扑克游戏中的洗牌。
  • 微信抢红包,抢的红包金额是随机的。
  • 北京购车摇号,谁能摇到是随机的。
  • 给用户生成随机密码。

我们首先来介绍Java对随机的支持,同时介绍其实现原理,然后针对一些实际场景,包括洗牌、抢红包、摇号、随机高强度密码、带权重的随机选择等,讨论如何应用随机。先来看如何使用最基本的随机。

7.6.1 Math.random

Java中,对随机最基本的支持是Math类中的静态方法random(),它生成一个0~1的随机数,类型为double,包括0但不包括1。比如,随机生成并输出3个数:

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

笔者的计算机中的一次运行,输出为:

1
2
3
0.4784896133823269
0.03012515628333423
0.7921024363953197

每次运行,输出都不一样。Math.random()是如何实现的呢?我们来看相关代码(Java
7):

1
2
3
4
5
6
7
8
9
10
private static Random randomNumberGenerator;
private static synchronized Random initRNG() {
Random rnd = randomNumberGenerator;
return (rnd == null) ? (randomNumberGenerator = new Random()) : rnd;
}
public static double random() {
Random rnd = randomNumberGenerator;
if (rnd == null) rnd = initRNG();
return rnd.nextDouble();
}

内部它使用了一个Random类型的静态变量randomNumberGenerator,调用random()就是调用该变量的nextDouble()方法,这个Random变量只有在第一次使用的时候才创建。

下面我们来看这个Random类,它位于包java.util下。

7.6.2 Random

Random类提供了更为丰富的随机方法,它的方法不是静态方法,使用Random,先要创建一个Random实例,看个例子:

1
2
3
Random rnd = new Random();
System.out.println(rnd.nextInt());
System.out.println(rnd.nextInt(100));

笔者计算机中的一次运行,输出为:

1
2
-1516612608
23

nextInt()产生一个随机的int,可能为正数,也可能为负数,nextInt(100)产生一个随机int,范围是0~100,包括0不包括100。除了nextInt,还有一些别的方法:

1
2
3
4
5
public long nextLong() //随机生成一个long
public boolean nextBoolean() //随机生成一个boolean
public void nextBytes(byte[] bytes) //产生随机字节, 字节个数就是bytes的长度
public float nextFloat() //随机浮点数,从0到1,包括0不包括1
public double nextDouble() //随机浮点数,从0到1,包括0不包括1

除了默认构造方法,Random类还有一个构造方法,可以接受一个long类型的种子参数:

1
public Random(long seed)

种子决定了随机产生的序列,种子相同,产生的随机数序列就是相同的。看个例子:

Random rnd = new Random(20160824);

1
2
3
for(int i=0; i<5; i++){
System.out.print(rnd.nextInt(100)+" ");
}

种子为20160824,产生5个0~100的随机数,输出为:

1
69 13 13 94 50

这个程序无论执行多少遍,在哪执行,输出结果都是相同的。

除了在构造方法中指定种子,Random类还有一个setter实例方法:

1
synchronized public void setSeed(long seed)

其效果与在构造方法中指定种子是一样的。

为什么要指定种子呢?指定种子还是真正的随机吗?指定种子是为了实现可重复的随机。比如用于模拟测试程序中,模拟要求随机,但测试要求可重复。在北京购车摇号程序中,种子也是指定的,后面我们还会介绍。种子到底扮演了什么角色呢?随机到底是如何产生的呢?让我们看下随机的基本原理。

7.6.3 随机的基本原理

Random产生的随机数不是真正的随机数,相反,它产生的随机数一般称为伪随机数。真正的随机数比较难以产生,计算机程序中的随机数一般都是伪随机数。

伪随机数都是基于一个种子数的,然后每需要一个随机数,都是对当前种子进行一些数学运算,得到一个数,基于这个数得到需要的随机数和新的种子。

数学运算是固定的,所以种子确定后,产生的随机数序列就是确定的,确定的数字序列当然不是真正的随机数,但种子不同,序列就不同,每个序列中数字的分布也都是比较随机和均匀的,所以称之为伪随机数。

Random的默认构造方法中没有传递种子,它会自动生成一个种子,这个种子数是一个真正的随机数,如下所示(Java
7):

1
2
3
4
5
6
7
8
9
10
11
12
13
private static final AtomicLong seedUniquifier
= new AtomicLong(8682522807148012L);
public Random() {
this(seedUniquifier() ^ System.nanoTime());
}
private static long seedUniquifier() {
for(; ; ) {
long current = seedUniquifier.get();
long next = current * 181783497276652981L;
if(seedUniquifier.compareAndSet(current, next))
return next;
}
}

种子是seedUniquifier()与System.nanoTime()按位异或的结果,System.nanoTime()返回一个更高精度(纳秒)的当前时间,seedUniquifier()里面的代码涉及一些多线程相关的知识,我们后续章节再介绍,简单地说,就是返回当前seedUniquifier(current变量)与一个常数181783497276652981L相乘的结果(next变量),然后,设置seedUniquifier的值为next,使用循环和compareAndSet都是为了确保在多线程的环境下不会有两次调用返回相同的值,保证随机性。

有了种子数之后,其他数是怎么生成的呢?我们来看一些代码:

1
2
3
4
5
6
7
8
9
10
11
12
public int nextInt() {
return next(32);
}
public long nextLong() {
return ((long)(next(32)) << 32) + next(32);
}
public float nextFloat() {
return next(24) / ((float)(1 << 24));
}
public boolean nextBoolean() {
return next(1) ! = 0;
}

它们都调用了next(int bits),生成指定位数的随机数,我们来看下它的代码:

1
2
3
4
5
6
7
8
9
10
11
12
private static final long multiplier = 0x5DEECE66DL;
private static final long addend = 0xBL;
private static final long mask = (1L << 48) - 1;
protected int next(int bits) {
long oldseed, nextseed;
AtomicLong seed = this.seed;
do {
oldseed = seed.get();
nextseed = (oldseed * multiplier + addend) & mask;
} while (! seed.compareAndSet(oldseed, nextseed));
return (int)(nextseed >>> (48 - bits));
}

简单地说,就是使用了如下公式:

1
nextseed = (oldseed * multiplier + addend) & mask;

旧的种子(oldseed)乘以一个数(multiplier),加上一个数addend,然后取低48位作为结果(mask相与)。

为什么采用这个方法?这个方法为什么可以产生随机数?这个方法的名称叫线性同余随机数生成器(linear congruential pseudorandom number generator),描述在《计算机程序设计艺术》一书中。随机的理论是一个比较复杂的话题,超出了本书的范畴,我们就不讨论了。

我们需要知道的基本原理是:随机数基于一个种子,种子固定,随机数序列就固定,默认构造方法中,种子是一个真正的随机数。

理解了随机的基本概念和原理,我们来看一些应用场景,包括随机密码、洗牌、带权重的随机选择、微信抢红包算法,以及北京购车摇号算法。

7.6.4 随机密码

在给用户生成账号时,经常需要给用户生成一个默认随机密码,然后通过邮件或短信发给用户,作为初次登录使用。我们假定密码是6位数字,代码很简单,如代码清单7-4所示。

代码清单7-4 生成随机密码:6位数字
1
2
3
4
5
6
7
8
public static String randomPassword(){
char[] chars = new char[6];
Random rnd = new Random();
for(int i=0; i<6; i++){
chars[i] = (char)('0'+rnd.nextInt(10));
}
return new String(chars);
}

代码很简单,就不解释了。如果要求是8位密码,字符可能由大写字母、小写字母、数字和特殊符号组成,如代码清单7-5所示。

代码清单7-5 生成随机密码:简单8位
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
private static final String SPECIAL_CHARS = "! @#$%^&*_=+-/";
private static char nextChar(Random rnd){
switch(rnd.nextInt(4)){
case 0:
return (char)('a'+rnd.nextInt(26));
case 1:
return (char)('A'+rnd.nextInt(26));
case 2:
return (char)('0'+rnd.nextInt(10));
default:
return SPECIAL_CHARS.charAt(rnd.nextInt(SPECIAL_CHARS.length()));
}
}
public static String randomPassword(){
char[] chars = new char[8];
Random rnd = new Random();
for(int i=0; i<8; i++){
chars[i] = nextChar(rnd);
}
return new String(chars);
}

这段代码,对每个字符,先随机选类型,然后在给定类型中随机选字符。在笔者的计算机中,一次的随机运行结果是:

1
8Ctp2S4H

这个结果不含特殊字符。很多环境对密码复杂度有要求,比如,至少要含一个大写字母、一个小写字母、一个特殊符号、一个数字。以上的代码满足不了这个要求,怎么满足呢?一种可能的代码如代码清单7-6所示。

代码清单7-6 生成随机密码:复杂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
25
26
27
28
29
30
31
32
33
private static int nextIndex(char[] chars, Random rnd){
int index = rnd.nextInt(chars.length);
while(chars[index]! =0){
index = rnd.nextInt(chars.length);
}
return index;
}
private static char nextSpecialChar(Random rnd){
return SPECIAL_CHARS.charAt(rnd.nextInt(SPECIAL_CHARS.length()));
}
private static char nextUpperlLetter(Random rnd){
return (char)('A'+rnd.nextInt(26));
}
private static char nextLowerLetter(Random rnd){
return (char)('a'+rnd.nextInt(26));
}
private static char nextNumLetter(Random rnd){
return (char)('0'+rnd.nextInt(10));
}
public static String randomPassword(){
char[] chars = new char[8];
Random rnd = new Random();
chars[nextIndex(chars, rnd)] = nextSpecialChar(rnd);
chars[nextIndex(chars, rnd)] = nextUpperlLetter(rnd);
chars[nextIndex(chars, rnd)] = nextLowerLetter(rnd);
chars[nextIndex(chars, rnd)] = nextNumLetter(rnd);
for(int i=0; i<8; i++){
if(chars[i]==0){
chars[i] = nextChar(rnd);
}
}
return new String(chars);
}

nextIndex随机生成一个未赋值的位置,程序先随机生成4个不同类型的字符,放到随机位置上,然后给未赋值的其他位置随机生成字符。

7.6.5 洗牌

一种常见的随机场景是洗牌,就是将一个数组或序列随机重新排列。我们以一个整数数组为例来介绍如何随机重排,如代码清单7-7所示。

代码清单7-7 随机重排
1
2
3
4
5
6
7
8
9
10
11
private static void swap(int[] arr, int i, int j){
int tmp = arr[i];
arr[i] = arr[j];
arr[j] = tmp;
}
public static void shuffle(int[] arr){
Random rnd = new Random();
for(int i=arr.length; i>1; i--) {
swap(arr, i-1, rnd.nextInt(i));
}
}

shuffle方法能将参数数组arr随机重排,来看使用它的代码:

1
2
3
4
5
6
int[] arr = new int[13];
for(int i=0; i<arr.length; i++){
arr[i] = i;
}
shuffle(arr);
System.out.println(Arrays.toString(arr));

调用shuffle方法前,arr是排好序的,调用后,一次调用的输出为:

1
[3, 8, 11, 10, 7, 9, 4, 1, 6, 12, 5, 0, 2]

已经随机重新排序了。shuffle的基本思路是什么呢?从后往前,逐个给每个数组位置重新赋值,值是从剩下的元素中随机挑选的。在如下关键语句中:

1
swap(arr, i-1, rnd.nextInt(i));

i-1表示当前要赋值的位置,rnd.nextInt(i)表示从剩下的元素中随机挑选。

7.6.6 带权重的随机选择

实际场景中,经常要从多个选项中随机选择一个,不过,不同选项经常有不同的权重。比如,给用户随机奖励,三种面额:1元、5元和10元,权重分别为70、20和10。这个怎么实现呢?实现的基本思路是,使用概率中的累计概率分布。

以上面的例子来说,计算每个选项的累计概率值,首先计算总的权重,这里正好是100,每个选项的概率是70%、20%和10%,累计概率则分别是70%、90%和100%。

有了累计概率,则随机选择的过程是:使用nextDouble()生成一个0~1的随机数,然后使用二分查找,看其落入哪个区间,如果小于等于70%则选择第一个选项,70%和90%之间选第二个,90%以上选第三个,如图7-2所示。

epub_923038_54

图7-2 选项的累计概率值

下面来看代码,我们使用一个类Pair表示选项和权重,如代码清单7-8所示。

代码清单7-8 表示选项和权重的类Pair
1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Pair {
Object item;
int weight;
public Pair(Object item, int weight){
this.item = item;
this.weight = weight;
}
public Object getItem() {
return item;
}
public int getWeight() {
return weight;
}
}

我们使用一个类WeightRandom表示带权重的选择,如代码清单7-9所示。

代码清单7-9 带权重的选择WeightRandom
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
27
28
29
30
public class WeightRandom {
private Pair[] options;
private double[] cumulativeProbabilities;
private Random rnd;
public WeightRandom(Pair[] options){
this.options = options;
this.rnd = new Random();
prepare();
}
private void prepare(){
int weights = 0;
for(Pair pair : options){
weights += pair.getWeight();
}
cumulativeProbabilities = new double[options.length];
int sum = 0;
for(int i = 0; i<options.length; i++) {
sum += options[i].getWeight();
cumulativeProbabilities[i] = sum / (double)weights;
}
}
public Object nextItem(){
double randomValue = rnd.nextDouble();
int index = Arrays.binarySearch(cumulativeProbabilities, randomValue);
if(index < 0) {
index = -index-1;
}
return options[index].getItem();
}
}

其中,prepare()方法计算每个选项的累计概率,保存在数组cumulativeProbabilities中, nextItem()方法根据权重随机选择一个,具体就是,首先生成一个0~1的数,然后使用二分查找,如果没找到,返回结果是-(插入点)-1,所以-index-1就是插入点,插入点的位置就对应选项的索引。

回到上面的例子,随机选择10次,代码为:

1
2
3
4
5
6
7
Pair[] options = new Pair[]{
new Pair("1元",7), new Pair("2元", 2), new Pair("10元", 1)
};
WeightRandom rnd = new WeightRandom(options);
for(int i=0; i<10; i++){
System.out.print(rnd.nextItem()+" ");
}

在一次运行中,输出正好符合预期,具体为:

1
1元 1元 1元 2元 1元 10元 1元 2元 1元 1元

不过,需要说明的是,由于随机,每次执行结果比例不一定正好相等。

7.6.7 抢红包算法

我们都知道,微信可以抢红包,红包有一个总金额和总数量,领的时候随机分配金额。金额是怎么随机分配的呢?微信具体是怎么做的,我们并不能确切地知道,但如下思路可以达到该效果。

维护一个剩余总金额和总数量,分配时,如果数量等于1,直接返回总金额,如果大于1,则计算平均值,并设定随机最大值为平均值的两倍,然后取一个随机值,如果随机值小于0.01,则为0.01,这个随机值就是下一个的红包金额。

我们来看代码,如代码清单7-10所示,为计算方便,金额用整数表示,以分为单位。

代码清单7-10 抢红包算法
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 RandomRedPacket {
private int leftMoney;
private int leftNum;
private Random rnd;
public RandomRedPacket(int total, int num){
this.leftMoney = total;
this.leftNum = num;
this.rnd = new Random();
}
public synchronized int nextMoney(){
if(this.leftNum<=0){
throw new IllegalStateException("抢光了");
}
if(this.leftNum==1){
return this.leftMoney;
}
double max = this.leftMoney/this.leftNum*2d;
int money = (int)(rnd.nextDouble()*max);
money = Math.max(1, money);
this.leftMoney -= money;
this.leftNum --;
return money;
}
}

代码比较简单,就不解释了。关于synchronized修饰符,此处可以忽略,留待第15章介绍。看一个使用的例子,总金额为10元,10个红包,代码如下:

1
2
3
4
RandomRedPacket redPacket = new RandomRedPacket(1000, 10);
for(int i=0; i<10; i++){
System.out.print(redPacket.nextMoney()+" ");
}

一次输出为:

1
136 48 90 151 36 178 92 18 122 129

如果是这个算法,那先抢好,还是后抢好呢?先抢肯定抢不到特别大的,不过,后抢也不一定会,这要看前面抢的金额,剩下的多就有可能抢到大的,剩下的少就不可能有大的。

7.6.8 北京购车摇号算法

我们来看下影响很多人的北京购车摇号,它的算法是怎样的呢?思路大概是这样的:
1)每期摇号前,将每个符合摇号资格的人,分配一个从0到总数的编号,这个编号是公开的,比如总人数为2 304 567,则编号为0~2 304 566。
2)摇号第一步是生成一个随机种子数,这个随机种子数在摇号当天通过一定流程生成,整个过程由公证员公证,就是生成一个真正的随机数。
3)种子数生成后,然后就是循环调用类似Random.nextInt(int n)方法,生成中签的编号。

编号是事先确定的,种子数是当场公证随机生成的,是公开的,随机算法是公开透明的,任何人都可以根据公开的种子数和编号验证中签的编号。

7.6.9 小结

本节介绍了随机,介绍了Java中对随机的支持Math.random()以及Random类,介绍了其使用和实现原理,同时,介绍了随机的一些应用场景,包括随机密码、洗牌、带权重的随机选择、微信抢红包和北京购车摇号,完整的代码在github上,地址为https://github.com/swiftma/program-logic ,位于包shuo.laoma.commoncls.c34下。

需要说明的是,Random类是线程安全的,也就是说,多个线程可以同时使用一个Random实例对象,不过,如果并发性很高,会产生竞争,这时,可以考虑使用多线程库中的ThreadLocalRandom类。另外,Java类库中还有一个随机类SecureRandom,可以产生安全性更高、随机性更强的随机数,用于安全加密等领域。

至此,关于常用基础类就介绍完了。我们深入分析了各种包装类、String、String-Builder、Arrays、日期和时间、以及随机,这些都是日常程序中经常用到的功能。还有一些基础类,限于篇幅,就不介绍了,比如UUID、Math和Objects,UUID用于随机生成需要确保唯一性的标识符,Math用于进行数学运算,Objects包含一些操作对象、检查条件的方法,具体可参看API文档。

之前章节中,我们经常提到泛型这一概念,它到底是什么呢?让我们下一章详细探讨。

7.4 剖析Arrays

数组是存储多个同类型元素的基本数据结构,数组中的元素在内存连续存放,可以通过数组下标直接定位任意元素,相比在后续章节介绍的其他容器而言效率非常高。

数组操作是计算机程序中的常见基本操作。Java中有一个类Arrays,包含一些对数组操作的静态方法,本节主要就来讨论这些方法。首先介绍怎么用,然后介绍它们的实现原理。学习Arrays的用法,就可以“避免重新发明轮子”,直接使用,学习它的实现原理,就可以在需要的时候自己实现它不具备的功能。

7.4.1 用法

Arrays类中有很多方法,我们主要介绍toString、排序、查找,对于一些其他方法,如复制、比较、批量设置值和计算哈希值等,我们也进行简单介绍。

1. toString

Arrays的toString()方法可以方便地输出一个数组的字符串形式,以便查看。它有9个重载的方法,包括8个基本类型数组和1个对象类型数组,下面列举两个:

1
2
public static String toString(int[] a)
public static String toString(Object[] a)

例如:

1
2
3
4
int[] arr = {9,8,3,4};
System.out.println(Arrays.toString(arr));
String[] strArr = {"hello", "world"};
System.out.println(Arrays.toString(strArr));

输出为:

1
2
[9, 8, 3, 4]
[hello, world]

如果不使用Arrays.toString方法,直接输出数组自身,即代码改为:

1
2
3
4
5
6
7
8
9
10
public static String toString(int[] a)
public static String toString(Object[] a)
int[] arr = {9,8,3,4};
System.out.println(Arrays.toString(arr));
String[] strArr = {"hello", "world"};
System.out.println(Arrays.toString(strArr));
int[] arr = {9,8,3,4};
System.out.println(arr);
String[] strArr = {"hello", "world"};
System.out.println(strArr);

则输出会变为如下所示:

1
2
[I@1224b90
[Ljava.lang.String; @728edb84

这个输出就难以阅读了,@后面的数字表示的是内存的地址。

2.排序

排序是一种非常常见的操作。同toString一样,对每种基本类型的数组,Arrays都有sort方法(boolean除外),例如:

1
2
public static void sort(int[] a)
public static void sort(double[] a)

排序按照从小到大升序排列,例如:

1
2
3
int[] arr = {4, 9, 3, 6, 10};
Arrays.sort(arr);
System.out.println(Arrays.toString(arr));

输出为:

1
[3, 4, 6, 9, 10]

数组已经排好序了。

除了基本类型,sort还可以直接接受对象类型,但对象需要实现Comparable接口。

1
2
public static void sort(Object[] a)
public static void sort(Object[] a, int fromIndex, int toIndex)

我们看个String数组的例子:

1
2
3
String[] arr = {"hello", "world", "Break", "abc"};
Arrays.sort(arr);
System.out.println(Arrays.toString(arr));

输出为:

1
[Break, abc, hello, world]

“Break”之所以排在最前面,是因为大写字母的ASCII码比小写字母都小。那如果排序的时候希望忽略大小写呢?sort还有另外两个重载方法,可以接受一个比较器作为参数:

1
2
public static <T> void sort(T[] a, Comparator<? super T> c)
public static <T> void sort(T[] a, int fromIndex, int toIndex,Comparator<? super T> c)

方法声明中的T表示泛型,泛型我们在第8章介绍,这里表示的是,这个方法可以支持所有对象类型,只要传递这个类型对应的比较器就可以了。Comparator就是比较器,它是一个接口,Java 7中的定义是:

1
2
3
4
public interface Comparator<T> {
int compare(T o1, T o2);
boolean equals(Object obj);
}

最主要的是compare这个方法,它比较两个对象,返回一个表示比较结果的值,-1表示o1小于o2,0表示o1等于o2,1表示o1大于o2。排序是通过比较来实现的,sort方法在排序的过程中需要对对象进行比较的时候,就调用比较器的compare方法。Java 8中Comparator增加了多个静态和默认方法,具体可参看API文档。

String类有一个public静态成员,表示忽略大小写的比较器:

1
public static final Comparator<String> CASE_INSENSITIVE_ORDER = new CaseInsensitiveComparator();

我们通过这个比较器再来对上面的String数组排序:

1
2
3
String[] arr = {"hello", "world", "Break", "abc"};
Arrays.sort(arr, String.CASE_INSENSITIVE_ORDER);
System.out.println(Arrays.toString(arr));

这样,大小写就忽略了,输出变为:

1
[abc, Break, hello, world]

为进一步理解Comparator,我们来看下String的这个比较器的主要实现代码,如代码清单7-2所示。

代码清单7-2 String的CaseInsensitiveComparator实现
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
private static class CaseInsensitiveComparator
implements Comparator<String> {
public int compare(String s1, String s2) {
int n1 = s1.length();
int n2 = s2.length();
int min = Math.min(n1, n2);
for(int i = 0; i < min; i++) {
char c1 = s1.charAt(i);
char c2 = s2.charAt(i);
if(c1 ! = c2) {
c1 = Character.toUpperCase(c1);
c2 = Character.toUpperCase(c2);
if(c1 ! = c2) {
c1 = Character.toLowerCase(c1);
c2 = Character.toLowerCase(c2);
if(c1 ! = c2) {
//No overflow because of numeric promotion
return c1 - c2;
}
}
}
}
return n1 - n2;
}
}

代码比较简单直接,就不解释了。

sort方法默认是从小到大排序,如果希望按照从大到小排序呢?对于对象类型,可以指定一个不同的Comparator,可以用匿名内部类来实现Comparator,比如:

1
2
3
4
5
6
7
8
String[] arr = {"hello", "world", "Break", "abc"};
Arrays.sort(arr, new Comparator<String>() {
@Override
public int compare(String o1, String o2) {
return o2.compareToIgnoreCase(o1);
}
});
System.out.println(Arrays.toString(arr));

程序输出为:

1
[world, hello, Break, abc]

以上代码使用一个匿名内部类实现Comparator接口,返回o2与o1进行忽略大小写比较的结果,这样就能实现忽略大小写且按从大到小排序。

Collections类中有两个静态方法,可以返回逆序的Comparator,例如:

1
2
public static <T> Comparator<T> reverseOrder()
public static <T> Comparator<T> reverseOrder(Comparator<T> cmp)

关于Collections类,我们在12.2节介绍。

这样,上面字符串忽略大小写逆序排序的代码可以改为:

1
2
3
String[] arr = {"hello", "world", "Break", "abc"};
Arrays.sort(arr, Collections.reverseOrder(String.CASE_INSENSITIVE_ORDER));
System.out.println(Arrays.toString(arr));

传递比较器Comparator给sort方法,体现了程序设计中一种重要的思维方式。将不变和变化相分离,排序的基本步骤和算法是不变的,但按什么排序是变化的,sort方法将不变的算法设计为主体逻辑,而将变化的排序方式设计为参数,允许调用者动态指定,这也是一种常见的设计模式,称为策略模式,不同的排序方式就是不同的策略

3.查找

Arrays包含很多与sort对应的查找方法,可以在已排序的数组中进行二分查找。所谓二分查找就是从中间开始查找,如果小于中间元素,则在前半部分查找,否则在后半部分查找,每比较一次,要么找到,要么将查找范围缩小一半,所以查找效率非常高。

二分查找既可以针对基本类型数组,也可以针对对象数组,对对象数组,也可以传递Comparator,也可以指定查找范围。比如,针对int数组:

1
2
public static int binarySearch(int[] a, int key)
public static int binarySearch(int[] a, int fromIndex, int toIndex, int key)

针对对象数组:

1
public static int binarySearch(Object[] a, Object key)

指定自定义比较器:

1
public static <T> int binarySearch(T[] a, T key, Comparator<? super T> c)

如果能找到,binarySearch返回找到的元素索引,比如:

1
2
int[] arr = {3,5,7,13,21};
System.out.println(Arrays.binarySearch(arr, 13));

输出为3。如果没找到,返回一个负数,这个负数等于-(插入点+
1)。插入点表示,如果在这个位置插入没找到的元素,可以保持原数组有序,比如:

1
2
int[] arr = {3,5,7,13,21};
System.out.println(Arrays.binarySearch(arr, 11));

输出为-4,表示插入点为3,如果在3这个索引位置处插入11,可以保持数组有序,即数组会变为{3,5,7,11,13,21}。

需要注意的是,binarySearch针对的必须是已排序数组,如果指定了Comparator,需要和排序时指定的Comparator保持一致。另外,如果数组中有多个匹配的元素,则返回哪一个是不确定的。

4.更多方法

除了常用的toString、排序和查找,Arrays中还有复制、比较、批量设置值和计算哈希值等方法。

基于原数组,复制一个新数组,与toString一样,也有多种重载形式,例如:

1
2
public static long[] copyOf(long[] original, int newLength)
public static <T> T[] copyOf(T[] original, int newLength)

判断两个数组是否相同,支持基本类型和对象类型,如下所示:

1
2
public static boolean equals(boolean[] a, boolean[] a2)
public static boolean equals(Object[] a, Object[] a2)

只有数组长度相同,且每个元素都相同,才返回true,否则返回false。对于对象,相同是指equals返回true。

Arrays包含很多fill方法,可以给数组中的每个元素设置一个相同的值:

1
public static void fill(int[] a, int val)

也可以给数组中一个给定范围的每个元素设置一个相同的值:

1
public static void fill(int[] a, int fromIndex, int toIndex, int val)

针对数组,计算一个数组的哈希值:

1
public static int hashCode(int a[])

计算hashCode的算法和String是类似的,我们看下代码:

1
2
3
4
5
6
7
8
public static int hashCode(int a[]) {
if(a == null)
return 0;
int result = 1;
for(int element : a)
result = 31 * result + element;
return result;
}

回顾一下,String计算hashCode的算法也是类似的,数组中的每个元素都影响hash值,位置不同,影响也不同,使用31一方面产生的哈希值更分散,另一方面计算效率也比较高。

Java 8和9对Arrays类又增加了一些方法,比如将数组转换为流、并行排序、数组比较等,具体可参看API文档。

7.4.2 多维数组

之前介绍的数组都是一维的,数组还可以是多维的。先来看二维数组,比如:

1
2
3
4
5
6
int[][] arr = new int[2][3];
for(int i=0; i<arr.length; i++){
for(int j=0; j<arr[i].length; j++){
arr[i][j] = i+j;
}
}

arr就是一个二维数组,第一维长度为2,第二维长度为3,类似于一个矩阵,或者类似于一个表格,第一维表示行,第二维表示列。arr[i]表示第i行,它本身还是一个数组, arr[i][j]表示第i行中的第j个元素。

除了二维,数组还可以是三维、四维等,但一般而言,很少用到三维以上的数组,有几维,就有几个[]。比如,一个三维数组的声明为:

1
int[][][] arr = new int[10][10][10];

在创建数组时,除了第一维的长度需要指定外,其他维的长度不需要指定,甚至第一维中每个元素的第二维的长度可以不一样,看个例子:

1
2
3
int[][] arr = new int[2][];
arr[0] = new int[3];
arr[1] = new int[5];

arr是一个二维数组,第一维的长度为2,第一个元素的第二维长度为3,而第二个元素的第二维长度为5。

多维数组到底是什么呢?其实,可以认为,多维数组只是一个假象,只有一维数组,只是数组中的每个元素还可以是一个数组,这样就形成二维数组;如果其中每个元素还都是一个数组,那就是三维数组。

Arrays中的toString、equals、hashCode都有对应的针对多维数组的方法:

1
2
3
public static String deepToString(Object[] a)
public static boolean deepEquals(Object[] a1, Object[] a2)
public static int deepHashCode(Object a[])

这些deepXXX方法,都会判断参数中的元素是否也为数组,如果是,会递归进行操作。

看个例子:

1
2
int[][] arr = new int[][]{{0,1}, {2,3,4}, {5,6,7,8}};
System.out.println(Arrays.deepToString(arr));

输出为:

1
[[0, 1], [2, 3, 4], [5, 6, 7, 8]]

7.4.3 实现原理

下面介绍Arrays的方法的实现原理。hashCode()的实现我们已经介绍了;fill和equals等的实现都很简单,循环操作即可,不再赘述;下面主要介绍二分查找和排序的实现代码。

1.二分查找

二分查找(binarySearch)的代码比较直接,如代码清单7-3所示。

代码清单7-3 Arrays的二分查找实现
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
private static <T> int binarySearch0(T[] a, int fromIndex, int toIndex,
T key, Comparator<? super T> c) {
int low = fromIndex;
int high = toIndex - 1;
while(low <= high) {
int mid = (low + high) >>> 1;
T midVal = a[mid];
int cmp = c.compare(midVal, key);
if(cmp < 0)
low = mid + 1;
else if(cmp > 0)
high = mid - 1;
else
return mid; //key found
}
return -(low + 1); //key not found
}

上述代码中有两个标志:low和high,表示查找范围,在while循环中,与中间值进行对比,大于则在后半部分查找(提高low),否则在前半部分查找(降低high)。

2.排序

与Arrays中的其他方法相比,sort要复杂得多。排序是计算机程序中一个非常重要的方面,几十年来,计算机科学家和工程师们对此进行了大量的研究,设计实现了各种各样的算法,进行了大量的优化。一般而言,没有一个最好的算法,不同算法往往有不同的适用场合。

那Arrays的sort是如何实现的呢?具体实现非常复杂,我们简单了解下。

对于基本类型的数组,Java采用的算法是双枢轴快速排序(Dual-PivotQuicksort)。这个算法是Java 7引入的,在此之前,Java采用的算法是普通的快速排序。双枢轴快速排序是对快速排序的优化,新算法的实现代码位于类java.util.DualPivotQuicksort中。

对于对象类型,Java采用的算法是TimSort。TimSort也是在Java 7引入的,在此之前, Java采用的是归并排序。TimSort实际上是对归并排序的一系列优化,TimSort的实现代码位于类java.util.TimSort中。

在这些排序算法中,如果数组长度比较小,它们还会采用效率更高的插入排序。

为什么基本类型和对象类型的算法不一样呢?排序算法有一个稳定性的概念,所谓稳定性就是对值相同的元素,如果排序前和排序后,算法可以保证它们的相对顺序不变,那算法就是稳定的,否则就是不稳定的。

快速排序更快,但不稳定,而归并排序是稳定的。对于基本类型,值相同就是完全相同,所以稳定不稳定没有关系。但对于对象类型,相同只是比较结果一样,它们还是不同的对象,其他实例变量也不见得一样,稳定不稳定可能就很有关系了,所以采用归并排序。

这些算法的实现是比较复杂的,所幸的是,Java提供了很好的封装,绝大多数情况下,我们会用就可以了。

7.4.4 小结

其实,Arrays中包含的数组方法是比较少的,很多常用的操作没有,比如,Arrays的binarySearch只能针对已排序数组进行查找,那没有排序的数组怎么方便查找呢?

Apache有一个开源包(http://commons.apache.org/proper/commons-lang/),里面有一个类ArrayUtils(位于包org.apache.commons.lang3),包含了更多的常用数组操作,这里就不列举了。

数组是计算机程序中的基本数据结构,Arrays类以及ArrayUtils类封装了关于数组的常见操作,使用这些方法,避免“重新发明轮子”吧。

7.5 剖析日期和时间

本节,我们讨论Java 中日期和时间处理相关的API。日期和时间是一个比较复杂的概念,Java 8之前的设计有一些不足,业界有一个广泛使用的第三方类库Joda-Time, Java 8受Joda-Time影响,重新设计了日期和时间API,新增了一个包java.time。虽然Java 8之前的API有一些不足,但依然是被大量使用的,本节只介绍Java 8之前的API。关于Java 8的API,它使用了Lambda表达式,我们还没介绍,所以留待到第26章介绍。

下面,我们先来看一些基本概念,然后再介绍Java的日期和时间API。

7.5.1 基本概念

关于日期和时间,有一些基本概念,包括时区、时刻、纪元时、年历等。

1.时区

我们都知道,同一时刻,世界上各个地区的时间可能是不一样的,具体时间与时区有关。全球一共有24个时区,英国格林尼治是0时区,北京是东八区,也就是说格林尼治凌晨1点,北京是早上9点。0时区的时间也称为GMT+0时间,GMT是格林尼治标准时间,北京的时间就是GMT+8:00。

2.时刻和纪元时

所有计算机系统内部都用一个整数表示时刻,这个整数是距离格林尼治标准时间1970年1月1日0时0分0秒的毫秒数。为什么要用这个时间呢?更多的是历史原因,本书就不介绍了。

格林尼治标准时间1970年1月1日0时0分0秒也被称为Epoch Time(纪元时)。

这个整数表示的是一个时刻,与时区无关,世界上各个地方都是同一个时刻,但各个地区对这个时刻的解读(如年月日时分秒)可能是不一样的。

对于1970年以前的时间,使用负数表示。

3.年历

我们都知道,中国有公历和农历之分,公历和农历都是年历,不同的年历,一年有多少月,每月有多少天,甚至一天有多少小时,这些可能都是不一样的。

比如,公历有闰年,闰年2月是29天,而其他年份则是28天,其他月份,有的是30天,有的是31天。农历有闰月,比如闰7月,一年就会有两个7月,一共13个月。

公历是世界上广泛采用的年历,除了公历,还有其他一些年历,比如日本也有自己的年历。Java API的设计思想是支持国际化的,支持多种年历,但没有直接支持中国的农历,本书主要讨论公历。

简单总结下,时刻是一个绝对时间,对时刻的解读,则是相对的,与年历和时区相关。

7.5.2 日期和时间API

Java API中关于日期和时间,有三个主要的类。

  • Date:表示时刻,即绝对时间,与年月日无关。
  • Calendar:表示年历,Calendar是一个抽象类,其中表示公历的子类是Gregorian-Calendar。
  • DateFormat:表示格式化,能够将日期和时间与字符串进行相互转换,DateFormat也是一个抽象类,其中最常用的子类是SimpleDateFormat。

还有两个相关的类:

  • TimeZone:表示时区。
  • Locale:表示国家(或地区)和语言。

下面,我们来看这些类。

1. Date

Date是Java API中最早引入的关于日期的类,一开始,Date也承载了关于年历的角色,但由于不能支持国际化,其中的很多方法都已经过时了,被标记为了@Deprecated,不再建议使用。

Date表示时刻,内部主要是一个long类型的值,如下所示:

1
private transient long fastTime;

fastTime表示距离纪元时的毫秒数,此处,关于transient关键字,我们暂时忽略。

Date有两个构造方法:

1
2
3
4
5
6
public Date(long date) {
fastTime = date;
}
public Date() {
this(System.currentTimeMillis());
}

第一个构造方法是根据传入的毫秒数进行初始化;第二个构造方法是默认构造方法,它根据System.currentTimeMillis()的返回值进行初始化。System.currentTimeMillis()是一个常用的方法,它返回当前时刻距离纪元时的毫秒数。

Date中的大部分方法都已经过时了,其中没有过时的主要方法有下面这些:

1
2
3
4
5
6
7
public long getTime() //返回毫秒数
public boolean equals(Object obj) //主要就是比较内部的毫秒数是否相同
//与其他Date进行比较,如果当前Date的毫秒数小于参数中的返回-1,相同返回0,否则返回1
public int compareTo(Date anotherDate)
public boolean before(Date when) //判断是否在给定日期之前
public boolean after(Date when) //判断是否在给定日期之后
public int hashCode() //哈希值算法与Long类似

2. TimeZone

TimeZone表示时区,它是一个抽象类,有静态方法用于获取其实例。获取当前的默认时区,代码为:

1
2
TimeZone tz = TimeZone.getDefault();
System.out.println(tz.getID());

获取默认时区,并输出其ID,在笔者的计算机中,输出为:

1
Asia/Shanghai

默认时区是在哪里设置的呢?可以更改吗?Java中有一个系统属性user.timezone,保存的就是默认时区。系统属性可以通过System.getProperty获得,如下所示:

1
System.out.println(System.getProperty("user.timezone"));

在笔者的计算机中,输出为:

1
Asia/Shanghai

系统属性可以在Java启动的时候传入参数进行更改,如:

1
java -Duser.timezone=Asia/Shanghai xxxx

TimeZone也有静态方法,可以获得任意给定时区的实例。比如,获取美国东部时区:

1
TimeZone tz = TimeZone.getTimeZone("US/Eastern");

ID除了可以是名称外,还可以是GMT形式表示的时区,如:

1
TimeZone tz = TimeZone.getTimeZone("GMT+08:00");

3. Locale

Locale表示国家(或地区)和语言,它有两个主要参数:一个是国家(或地区);另一个是语言,每个参数都有一个代码,不过国家(或地区)并不是必需的。比如,中国内地的代码是CN,中国台湾地区的代码是TW,美国的代码是US,中文语言的代码是zh,英文语言的代码是en。

Locale类中定义了一些静态变量,表示常见的Locale,比如:

  • Locale.US:表示美国英语。
  • Locale.ENGLISH:表示所有英语。
  • Locale.TAIWAN:表示中国台湾地区所用的中文。
  • Locale.CHINESE:表示所有中文。
  • Locale.SIMPLIFIED_CHINESE:表示中国内地所用的中文。

与TimeZone类似,Locale也有静态方法获取默认值,如:

1
2
Locale locale = Locale.getDefault();
System.out.println(locale.toString());

在笔者的计算机中,输出为:

1
zh_CN

4. Calendar

Calendar类是日期和时间操作中的主要类,它表示与TimeZone和Locale相关的日历信息,可以进行各种相关的运算。我们先来看下它的内部组成,与Date类似,Calendar内部也有一个表示时刻的毫秒数,定义为:

1
protected long time;

除此之外,Calendar内部还有一个数组,表示日历中各个字段的值,定义为:

1
protected int fields[];

这个数组的长度为17,保存一个日期中各个字段的值,都有哪些字段呢?Calendar类中定义了一些静态变量,表示这些字段,主要有:

  • Calendar.YEAR:表示年。
  • Calendar.MONTH:表示月,1月是0, Calendar同样定义了表示各个月份的静态变量,如Calendar.JULY表示7月。
  • Calendar.DAY_OF_MONTH:表示日,每月的第一天是1。
  • Calendar.HOUR_OF_DAY:表示小时,为0~23。
  • Calendar.MINUTE:表示分钟,为0~59。
  • Calendar.SECOND:表示秒,为0~59。
  • Calendar.MILLISECOND:表示毫秒,为0~999。
  • Calendar.DAY_OF_WEEK:表示星期几,周日是1,周一是2,周六是7,Calenar同样定义了表示各个星期的静态变量,如Calendar.SUNDAY表示周日。

Calendar是抽象类,不能直接创建对象,它提供了多个静态方法,可以获取Calendar实例,比如:

1
2
public static Calendar getInstance()
public static Calendar getInstance(TimeZone zone, Locale aLocale)

最终调用的方法都是需要TimeZone和Locale的,如果没有,则会使用上面介绍的默认值。getInstance方法会根据TimeZone和Locale创建对应的Calendar子类对象,在中文系统中,子类一般是表示公历的GregorianCalendar。

getInstance方法封装了Calendar对象创建的细节。TimeZone和Locale不同,具体的子类可能不同,但都是Calendar。这种隐藏对象创建细节的方式,是计算机程序中一种常见的设计模式,它有一个名字,叫工厂方法,getInstance就是一个工厂方法,它生产对象。

与new Date()类似,新创建的Calendar对象表示的也是当前时间,与Date不同的是, Calendar对象可以方便地获取年月日等日历信息。来看代码,输出当前时间的各种信息:

1
2
3
4
5
6
7
8
9
Calendar calendar = Calendar.getInstance();
System.out.println("year: "+calendar.get(Calendar.YEAR));
System.out.println("month: "+calendar.get(Calendar.MONTH));
System.out.println("day: "+calendar.get(Calendar.DAY_OF_MONTH));
System.out.println("hour: "+calendar.get(Calendar.HOUR_OF_DAY));
System.out.println("minute: "+calendar.get(Calendar.MINUTE));
System.out.println("second: "+calendar.get(Calendar.SECOND));
System.out.println("millisecond: " +calendar.get(Calendar.MILLISECOND));
System.out.println("day_of_week: " + calendar.get(Calendar.DAY_OF_WEEK));

具体输出与执行时的时间和默认的TimeZone以及Locale有关,比如,在笔者的计算机中的一次输出为:

1
2
3
4
5
6
7
8
year: 2016
month: 7
day: 14
hour: 13
minute: 55
second: 51
millisecond: 564
day_of_week: 2

内部,Calendar会将表示时刻的毫秒数,按照TimeZone和Locale对应的年历,计算各个日历字段的值,存放在fields数组中,Calendar.get方法获取的就是fields数组中对应字段的值。

Calendar支持根据Date或毫秒数设置时间:

1
2
public final void setTime(Date date)
public void setTimeInMillis(long millis)

也支持根据年月日等日历字段设置时间,比如:

1
2
3
4
public final void set(int year, int month, int date)
public final void set(int year, int month, int date,
int hourOfDay, int minute, int second)
public void set(int field, int value)

除了直接设置,Calendar支持根据字段增加和减少时间:

1
public void add(int field, int amount)

amount为正数表示增加,负数表示减少。

比如,如果想设置Calendar为第二天的下午2点15,代码可以为:

1
2
3
4
5
6
Calendar calendar = Calendar.getInstance();
calendar.add(Calendar.DAY_OF_MONTH, 1);
calendar.set(Calendar.HOUR_OF_DAY, 14);
calendar.set(Calendar.MINUTE, 15);
calendar.set(Calendar.SECOND, 0);
calendar.set(Calendar.MILLISECOND, 0);

Calendar的这些方法中一个比较方便和强大的地方在于,它能够自动调整相关的字段。比如,我们知道2月最多有29天,如果当前时间为1月30号,对Calendar.MONTH字段加1,即增加一月,Calendar不是简单的只对月字段加1,那样日期是2月30号,是无效的,Calendar会自动调整为2月最后一天,即2月28日或29日。

再如,设置的值可以超出其字段最大范围,Calendar会自动更新其他字段,如:

1
2
3
Calendar calendar = Calendar.getInstance();
calendar.add(Calendar.HOUR_OF_DAY, 48);
calendar.add(Calendar.MINUTE, -120);

相当于增加了46小时。

内部,根据字段设置或修改时间时,Calendar会更新fields数组对应字段的值,但一般不会立即更新其他相关字段或内部的毫秒数的值,不过在获取时间或字段值的时候, Calendar会重新计算并更新相关字段。

简单总结下,Calenar做了一项非常烦琐的工作,根据TimeZone和Locale,在绝对时间毫秒数和日历字段之间自动进行转换,且对不同日历字段的修改进行自动同步更新。

除了add方法,Calendar还有一个类似的方法:

1
public void roll(int field, int amount)

与add方法的区别是,roll方法不影响时间范围更大的字段值。比如:

1
2
3
4
Calendar calendar = Calendar.getInstance();
calendar.set(Calendar.HOUR_OF_DAY, 13);
calendar.set(Calendar.MINUTE, 59);
calendar.add(Calendar.MINUTE, 3);

calendar首先设置为13:59,然后分钟字段加3,执行后的calendar时间为14:02。如果add改为roll,即:

1
calendar.roll(Calendar.MINUTE, 3);

则执行后的calendar时间会变为13:02,在分钟字段上执行roll方法不会改变小时的值。

Calendar可以方便地转换为Date或毫秒数,方法是:

1
2
public final Date getTime()
public long getTimeInMillis()

与Date类似,Calendar之间也可以进行比较,也实现了Comparable接口,相关方法有:

1
2
3
4
public boolean equals(Object obj)
public int compareTo(Calendar anotherCalendar)
public boolean after(Object when)
public boolean before(Object when)

5. DateFormat

DateFormat类主要在Date和字符串表示之间进行相互转换,它有两个主要的方法:

1
2
public final String format(Date date)
public Date parse(String source)

format将Date转换为字符串,parse将字符串转换为Date。

Date的字符串表示与TimeZone和Locale都是相关的,除此之外,还与两个格式化风格有关,一个是日期的格式化风格,另一个是时间的格式化风格。DateFormat定义了4个静态变量,表示4种风格:SHORT、MEDIUM、LONG和FULL;还定义了一个静态变量DEFAULT,表示默认风格,值为MEDIUM,不同风格输出的信息详细程度不同。

与Calendar类似,DateFormat也是抽象类,也用工厂方法创建对象,提供了多个静态方法创建DateFormat对象,有三类方法:

1
2
public final static DateFormat getDateTimeInstance()
public final static DateFormat getDateInstance()

public final static DateFormat getTimeInstance()

getDateTimeInstance方法既处理日期也处理时间,getDateInstance方法只处理日期,get-TimeInstance方法只处理时间。看下面的代码:

1
2
3
4
5
6
7
8
9
Calendar calendar = Calendar.getInstance();
//2016-08-15 14:15:20
calendar.set(2016, 07, 15, 14, 15, 20);
System.out.println(DateFormat.getDateTimeInstance()
.format(calendar.getTime()));
System.out.println(DateFormat.getDateInstance()
.format(calendar.getTime()));
System.out.println(DateFormat.getTimeInstance()
.format(calendar.getTime()));

输出为:

1
2
3
2016-8-15 14:15:20
2016-8-15
14:15:20

每类工厂方法都有两个重载的方法,接受日期和时间风格以及Locale作为参数:

1
2
DateFormat getDateTimeInstance(int dateStyle, int timeStyle)
DateFormat getDateTimeInstance(int dateStyle, int timeStyle, Locale aLocale)

比如,看下面的代码:

1
2
3
4
5
Calendar calendar = Calendar.getInstance();
//2016-08-15 14:15:20
calendar.set(2016, 07, 15, 14, 15, 20);
System.out.println(DateFormat.getDateTimeInstance(DateFormat.LONG,
DateFormat.SHORT, Locale.CHINESE).format(calendar.getTime()));

输出为:

1
2016年8月15日 下午2:15

DateFormat的工厂方法里,我们没看到TimeZone参数,不过,DateFormat提供了一个setter方法,可以设置TimeZone:

1
public void setTimeZone(TimeZone zone)

DateFormat虽然比较方便,但如果我们要对字符串格式有更精确的控制,则应该使用SimpleDateFormat这个类。

6. SimpleDateFormat

SimpleDateFormat是DateFormat的子类,相比DateFormat,它的一个主要不同是,它可以接受一个自定义的模式(pattern)作为参数,这个模式规定了Date的字符串形式。先看个例子:

1
2
3
4
5
6
Calendar calendar = Calendar.getInstance();
//2016-08-15 14:15:20
calendar.set(2016, 07, 15, 14, 15, 20);
SimpleDateFormat sdf = new SimpleDateFormat(
"yyyy年MM月dd日 E HH时mm分ss秒");
System.out.println(sdf.format(calendar.getTime()));

输出为:

1
2016年08月15日 星期一 14时15分20秒

SimpleDateFormat有个构造方法,可以接受一个pattern作为参数,这里pattern是:

1
yyyy年MM月dd日 E HH时mm分ss秒

pattern中的英文字符a~z和A~Z表示特殊含义,其他字符原样输出,这里:

  • yyyy:表示4位的年。
  • MM:表示月,用两位数表示。
  • dd:表示日,用两位数表示。
  • HH:表示24小时制的小时数,用两位数表示。
  • mm:表示分钟,用两位数表示。
  • ss:表示秒,用两位数表示。
  • E:表示星期几。

这里需要特意提醒一下,hh也表示小时数,但表示的是12小时制的小时数,而a表示的是上午还是下午,看代码:

1
2
3
4
5
Calendar calendar = Calendar.getInstance();
//2016-08-15 14:15:20
calendar.set(2016, 07, 15, 14, 15, 20);
SimpleDateFormat sdf = new SimpleDateFormat("yyyy/MM/dd hh:mm:ss a");
System.out.println(sdf.format(calendar.getTime()));

输出为:

1
2016/08/15 02:15:20 下午

更多的特殊含义可以参看SimpleDateFormat的API文档。如果想原样输出英文字符,可以将其用单引号括起来。

除了将Date转换为字符串,SimpleDateFormat也可以方便地将字符串转化为Date,看代码:

1
2
3
4
5
6
7
8
9
String str = "2016-08-15 14:15:20.456";
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS");
try {
Date date = sdf.parse(str);
SimpleDateFormat sdf2 = new SimpleDateFormat("yyyy年M月d h:m:s.S a");
System.out.println(sdf2.format(date));
} catch (ParseException e) {
e.printStackTrace();
}

输出为:

1
2016年8月15 2:15:20.456 下午

代码将字符串解析为了一个Date对象,然后使用另外一个格式进行了输出,这里SSS表示三位的毫秒数。需要注意的是,parse会抛出一个受检异常,异常类型为ParseException,调用者必须进行处理。

7.5.3 局限性

至此,关于Java 8之前的日期和时间相关API的主要内容基本就介绍完了。Date表示时刻,与年月日无关,Calendar表示日历,与时区和Locale相关,可进行各种运算,是日期时间操作的主要类,DateFormat/SimpleDateFormat在Date和字符串之间进行相互转换。这些API存在着一些局限性,下面强调一下。

1. Date中的过时方法

Date中的方法参数与常识不符合,过时方法标记容易被人忽略,产生误用。比如,看如下代码:

1
2
Date date = new Date(2016,8,15);
System.out.println(DateFormat.getDateInstance().format(date));

想当然的输出为2016-08-15,但其实输出为:

1
3916-9-15

之所以产生这个输出,是因为Date构造方法中的year表示的是与1900年的差,month是从0开始的。

2. Calendar操作比较烦琐

Calendar API的设计不是很成功,一些简单的操作都需要多次方法调用,写很多代码,比较臃肿。

另外,Calendar难以进行比较复杂的日期操作,比如,计算两个日期之间有多少个月,根据生日计算年龄,计算下个月的第一个周一等。

3. DateFormat的线程安全性

DateFormat/SimpleDateFormat不是线程安全的。关于线程概念,第15章会详细介绍,这里简单说明一下。多个线程同时使用一个DateFormat实例的时候,会有问题,因为DateFormat内部使用了一个Calendar实例对象,多线程同时调用的时候,这个Calendar实例的状态可能就会紊乱。

解决这个问题大概有以下方案:

  • 每次使用DateFormat都新建一个对象。
  • 使用线程同步(第15章介绍)。
  • 使用ThreadLocal(第19章介绍)。
  • 使用Joda-Time或Java 8的API,它们是线程安全的。

第7章 常用基础类

本章介绍Java编程中一些常用的基础类,探讨它们的用法、应用和实现原理,这些类有:

  • 各种包装类;
  • 文本处理的类String和StringBuilder;
  • 数组操作的类Arrays;
  • 日期和时间处理;
  • 随机。

7.1 包装类

Java有8种基本类型,每种基本类型都有一个对应的包装类。包装类是什么呢?它是一个类,内部有一个实例变量,保存对应的基本类型的值,这个类一般还有一些静态方法、静态变量和实例方法,以方便对数据进行操作。Java中,基本类型和对应的包装类如表7-1所示。

表7-1 基本类型和对应的包装类

epub_923038_50
包装类也都很好记,除了Integer和Character外,其他类名称与基本类型基本一样,只是首字母大写。包装类有什么用呢?Java中很多代码(比如后续章节介绍的容器类)只能操作对象,为了能操作基本类型,需要使用其对应的包装类。另外,包装类提供了很多有用的方法,可以方便对数据的操作。下面先介绍各个包装类的基本用法及其共同点,然后重点介绍Integer和Character。

7.1.1 基本用法

各个包装类都可以与其对应的基本类型相互转换,方法也是类似的,部分类型如表7-2所示。

表7-2 包装类与基本类型的转换

epub_923038_51
包装类与基本类型的转换代码结构是类似的,每种包装类都有一个静态方法valueOf(),接受基本类型,返回引用类型,也都有一个实例方法xxxValue()返回对应的基本类型。

将基本类型转换为包装类的过程,一般称为“装箱”,而将包装类型转换为基本类型的过程,则称为“拆箱”。装箱/拆箱写起来比较烦琐,Java 5以后引入了自动装箱和拆箱技术,可以直接将基本类型赋值给引用类型,反之亦可,比如:

1
2
Integer a = 100;
int b = a;

自动装箱/拆箱是Java编译器提供的能力,背后,它会替换为调用对应的valueOf/xxx-Value方法,比如,上面的代码会被Java编译器替换为:

1
2
Integer a = Integer.valueOf(100);
int b = a.intValue();

每种包装类也都有构造方法,可以通过new创建,比如:

1
2
3
4
Integer a = new Integer(100);
Boolean b = new Boolean(true);
Double d = new Double(12.345);
Character c = new Character('马');

那到底应该用静态的valueOf方法,还是使用new呢?一般建议使用valueOf方法。new每次都会创建一个新对象,而除了Float和Double外的其他包装类,都会缓存包装类对象,减少需要创建对象的次数,节省空间,提升性能。实际上,从Java 9开始,这些构造方法已经被标记为过时了,推荐使用静态的valueOf方法。

7.1.2 共同点

各个包装类有很多共同点,比如,都重写了Object中的一些方法,都实现了Comparable接口,都有一些与String有关的方法,大部分都定义了一些静态常量,都是不可变的。下面具体介绍。

1.重写Object方法

所有包装类都重写了Object类的如下方法:

1
2
3
boolean equals(Object obj)
int hashCode()
String toString()

我们分别介绍。

(1)equals

equals用于判断当前对象和参数传入的对象是否相同,Object类的默认实现是比较地址,对于两个变量,只有这两个变量指向同一个对象时,equals才返回true,它和比较运算符(==)的结果是一样的。

equals应该反映的是对象间的逻辑相等关系,所以这个默认实现一般是不合适的,子类需要重写该实现。所有包装类都重写了该实现,实际比较用的是其包装的基本类型值,比如,对于Long类,其equals方法代码如下:

1
2
3
4
5
6
public boolean equals(Object obj) {
if(obj instanceof Long) {
return value == ((Long)obj).longValue();
}
return false;
}

对于Float,其实现代码如下:

1
2
3
4
public boolean equals(Object obj) {
return(obj instanceof Float)
&& (floatToIntBits(((Float)obj).value) == floatToIntBits(value));
}

Float有一个静态方法floatToIntBits(),将float的二进制表示看作int。需要注意的是,只有两个float的二进制表示完全一样的时候,equals才会返回true。在2.2节的时候,我们提到小数计算是不精确的,数学概念上运算结果一样,但计算机运算结果可能不同,比如下面的代码:

1
2
3
4
5
Float f1 = 0.01f;
Float f2 = 0.1f0.1f;
System.out.println(f1.equals(f2));
System.out.println(Float.floatToIntBits(f1));
System.out.println(Float.floatToIntBits(f2));

输出为:

1
2
3
false
1008981770
1008981771

也就是,两个浮点数不一样,将二进制看作整数也不一样,相差为1。

Double的equals方法与Float类似,它有一个静态方法doubleToLongBits,将double的二进制表示看作long,然后再按long比较。

(2)hashCode

hashCode返回一个对象的哈希值。哈希值是一个int类型的数,由对象中一般不变的属性映射得来,用于快速对对象进行区分、分组等。一个对象的哈希值不能改变,相同对象的哈希值必须一样。不同对象的哈希值一般应不同,但这不是必需的,可以有对象不同但哈希值相同的情况。

比如,对于一个班的学生对象,hashCode可以是学生的出生日期,出生日期是不变的,不同学生生日一般不同,分布比较均匀,个别生日相同的也没关系。

hashCode和equals方法联系密切,对两个对象,如果equals方法返回true,则hashCode也必须一样。反之不要求,equal方法返回false时,hashCode可以一样,也可以不一样,但应该尽量不一样。hashCode的默认实现一般是将对象的内存地址转换为整数,子类如果重写了equals方法,也必须重写hashCode。之所以有这个规定,是因为Java API中很多类依赖于这个行为,尤其是容器中的一些类。

包装类都重写了hashCode,根据包装的基本类型值计算hashCode,对于Byte、Short、Integer、Character, hashCode就是其内部值,代码为:

1
2
3
public int hashCode() {
return (int)value;
}

对于Boolean, hashCode代码为:

1
2
3
public int hashCode() {
return value ? 1231 : 1237;
}

根据基类类型值返回了两个不同的数,为什么选这两个值呢?它们是质数(即只能被1和自己整除的数),质数用于哈希时比较好,不容易冲突。

对于Long, hashCode代码为:

1
2
3
public int hashCode() {
return(int)(value ^ (value >>> 32));
}

是高32位与低32位进行位异或操作。

对于Double, hashCode代码为:

1
2
3
public int hashCode() {
return floatToIntBits(value);
}

与equals方法类似,将double的二进制表示看作long,然后再按long计算hashCode。

每个包装类也都重写了toString方法,返回对象的字符串表示,这个一般比较自然,不再赘述。

2. Comparable

每个包装类都实现了Java API中的Comparable接口。Comparable接口代码如下:

1
2
3
4
public int hashCode() {
long bits = doubleToLongBits(value);
return(int)(bits ^ (bits >>> 32));
}

<T>是泛型语法,我们在第8章介绍,T表示比较的类型,由实现接口的类传入。接口只有一个方法compareTo,当前对象与参数对象进行比较,在小于、等于、大于参数时,应分别返回-1、0、1。

各个包装类的实现基本都是根据基本类型值进行比较,不再赘述。对于Boolean,false小于true。对于Float和Double,存在和equals方法一样的问题,0.01和0.1*0.1相比的结果并不为0。

3.包装类和String

除了toString方法外,包装类还有一些其他与String相关的方法。除了Character外,每个包装类都有一个静态的valueOf(String)方法,根据字符串表示返回包装类对象,如:

1
2
3
public interface Comparable<T> {
public int compareTo(T o);
}

也都有一个静态的parseXXX(String)方法,根据字符串表示返回基本类型值,如:

1
2
Boolean b = Boolean.valueOf("true");
Float f = Float.valueOf("123.45f");

都有一个静态的toString方法,根据基本类型值返回字符串表示,如:

1
2
boolean b = Boolean.parseBoolean("true");
double d = Double.parseDouble("123.45");

输出:

1
2
System.out.println(Boolean.toString(true));
System.out.println(Double.toString(123.45));

对于整数类型,字符串表示除了默认的十进制外,还可以表示为其他进制,如二进制、八进制和十六进制,包装类有静态方法进行相互转换,比如:

1
2
true
123.45

输出:

1
2
3
System.out.println(Integer.toBinaryString(12345));       //输出二进制
System.out.println(Integer.toHexString(12345)); //输出十六进制
System.out.println(Integer.parseInt("3039", 16)); //按十六进制解析

4.常用常量

包装类中除了定义静态方法和实例方法外,还定义了一些静态变量。对于Boolean类型,有:

1
2
3
11000000111001
3039
12345

所有数值类型都定义了MAX_VALUE和MIN_VALUE,表示能表示的最大/最小值,比如,对Integer:

1
2
public static final Boolean TRUE = new Boolean(true);
public static final Boolean FALSE = new Boolean(false);

Float和Double还定义了一些特殊数值,比如正无穷、负无穷、非数值,如Double类:

1
2
3
public static final double POSITIVE_INFINITY = 1.0 / 0.0; //正无穷
public static final double NEGATIVE_INFINITY = -1.0 / 0.0; //负无穷
public static final double NaN = 0.0d / 0.0; //非数值

5. Number

6种数值类型包装类有一个共同的父类Number。Number是一个抽象类,它定义了如下方法:

1
2
3
4
5
6
byte byteValue()
short shortValue()
int intValue()
long longValue()
float floatValue()
double doubleValue()

通过这些方法,包装类实例可以返回任意的基本数值类型。

6.不可变性

包装类都是不可变类。所谓不可变是指实例对象一旦创建,就没有办法修改了。这是通过如下方式强制实现的:

  • 所有包装类都声明为了final,不能被继承。
  • 内部基本类型值是私有的,且声明为了final。
  • 没有定义setter方法。

为什么要定义为不可变类呢?不可变使得程序更为简单安全,因为不用操心数据被意外改写的可能,可以安全地共享数据,尤其是在多线程的环境下。关于线程,我们在第15章介绍。

7.1.3 剖析Integer与二进制算法

本小节主要介绍Integer类, Long与Integer类似,就不再单独介绍了。一个简单的Integer还有什么要介绍的呢?它有一些二进制操作,包括位翻转和循环移位等,另外,我们也分析一下它的valueOf实现。为什么要关心实现代码呢?大部分情况下,确实不用关心,会用它就可以了,我们主要是学习其中的二进制操作。二进制是计算机的基础,但代码往往晦涩难懂,我们希望对其有一个更为清晰深刻的理解。

1.位翻转

Integer有两个静态方法,可以按位进行翻转:

1
2
public static int reverse(int i)
public static int reverseBytes(int i)

位翻转就是将int当作二进制,左边的位与右边的位进行互换,reverse是按位进行互换, reverseBytes是按byte进行互换,我们来看个例子:

1
2
3
4
5
6
int a = 0x12345678;
System.out.println(Integer.toBinaryString(a));
int r = Integer.reverse(a);
System.out.println(Integer.toBinaryString(r));
int rb = Integer.reverseBytes(a);
System.out.println(Integer.toHexString(rb));

a是整数,用十六进制赋值,首先输出其二进制字符串,接着输出reverse后的二进制,最后输出reverseBytes后的十六进制,输出为:

1
2
3
10010001101000101011001111000
11110011010100010110001001000
78563412

reverseBytes是按字节翻转,78是十六进制表示的一个字节,12也是,所以结果78563412是比较容易理解的。二进制翻转初看是不对的,这是因为输出不是32位,输出时忽略了前面的0,我们补齐32位再看:

1
2
00010010001101000101011001111000
00011110011010100010110001001000

这次结果就对了。这两个方法是怎么实现的呢?

先来看reverseBytes的代码:

1
2
3
4
5
6
public static int reverseBytes(int i) {
return ((i >>> 24) ) |
((i >> 8) & 0xFF00) |
((i << 8) & 0xFF0000) |
((i << 24));
}

代码比较晦涩,以参数i等于0x12345678为例,我们来分析执行过程:
1)i>>>24无符号右移,最高字节挪到最低位,结果是0x00000012;
2)(i>>8) & 0xFF00,左边第二个字节挪到右边第二个,i>>8结果是0x00123456,再进行& 0xFF00,保留的是右边第二个字节,结果是0x00003400;
3)(i << 8) & 0xFF0000,右边第二个字节挪到左边第二个,i<<8结果是0x34567800,再进行& 0xFF0000,保留的是右边第三个字节,结果是0x00560000;
4)i<<24,结果是0x78000000,最右字节挪到最左边。

这4个结果再进行或操作|,结果就是0x78563412,这样,通过左移、右移、与和或操作,就达到了字节翻转的目的。

我们再来看reverse的代码:

1
2
3
4
5
6
7
8
9
public static int reverse(int i) {
//HD, Figure 7-1
i = (i & 0x55555555) << 1 | (i >>> 1) & 0x55555555;
i = (i & 0x33333333) << 2 | (i >>> 2) & 0x33333333;
i = (i & 0x0f0f0f0f) << 4 | (i >>> 4) & 0x0f0f0f0f;
i = (i << 24) | ((i & 0xff00) << 8) |
((i >>> 8) & 0xff00) | (i >>> 24);
return i;
}

这段代码虽然很短,但非常晦涩,到底是什么意思呢?代码第一行是一个注释,HD表示的是一本书,书名为Hacker’s Delight,中文版为《算法心得:高效算法的奥秘》, HD是它的缩写,Figure 7-1是书中的图7-1, reverse的代码就是复制了这本书中图7-1的代码,书中也说明了代码的思路,我们简要说明。

高效实现位翻转的基本思路是:首先交换相邻的单一位,然后以两位为一组,再交换相邻的位,接着是4位一组交换、然后是8位、16位,16位之后就完成了。这个思路不仅适用于二进制,而且适用于十进制,为便于理解,我们看个十进制的例子。比如对数字12345678进行翻转。

第一轮,相邻单一数字进行互换,结果为:

1
21 43 65 87

第二轮,以两个数字为一组交换相邻的,结果为:

1
43 21 87 65

第三轮,以4个数字为一组交换相邻的,结果为:

1
8765 4321

翻转完成。

对十进制而言,这个效率并不高,但对于二进制而言,却是高效的,因为二进制可以在一条指令中交换多个相邻位。下面代码就是对相邻单一位进行互换:

1
x = (x & 0x55555555) <<   1 | (x & 0xAAAAAAAA) >>>   1;

5的二进制表示是0101,0x55555555的二进制表示是:

1
01010101010101010101010101010101

x & 0x55555555就是取x的奇数位。

A的二进制表示是1010,0xAAAAAAAA的二进制表示是:

1
10101010101010101010101010101010

x & 0xAAAAAAAA就是取x的偶数位。

1
(x & 0x55555555) <<   1 | (x & 0xAAAAAAAA) >>>   1;

表示的就是x的奇数位向左移,偶数位向右移,然后通过|合并,达到相邻位互换的目的。这段代码可以有个小的优化,只使用一个常量0x55555555,后半部分先移位再进行与操作,变为:

1
(i & 0x55555555) << 1 | (i >>> 1) & 0x55555555;

同理,如下代码就是以两位为一组,对相邻位进行互换:

1
i = (i & 0x33333333) << 2 | (i & 0xCCCCCCCC)>>>2;

3的二进制表示是0011,0x33333333的二进制表示是:

1
00110011001100110011001100110011

x & 0x33333333就是取x以两位为一组的低半部分。

C的二进制表示是1100,0xCCCCCCCC的二进制表示是:

1
11001100110011001100110011001100

x & 0xCCCCCCCC就是取x以两位为一组的高半部分。

1
(i & 0x33333333) << 2 | (i & 0xCCCCCCCC)>>>2;

表示的就是x以两位为一组,低半部分向高位移,高半部分向低位移,然后通过|合并,达到交换的目的。同样,可以去掉常量0xCCCCCCCC,代码可以优化为:

1
(i & 0x33333333) << 2 | (i >>> 2) & 0x33333333;

同理,下面代码就是以4位为一组进行交换。

1
i = (i & 0x0f0f0f0f) << 4 | (i >>> 4) & 0x0f0f0f0f;

到以8位为单位交换时,就是字节翻转了,可以写为如下更直接的形式,代码和reverse-Bytes基本完全一样。

1
2
i = (i << 24) | ((i & 0xff00) << 8) |
((i >>> 8) & 0xff00) | (i >>> 24);

reverse代码为什么要写得这么晦涩呢?或者说不能用更容易理解的方式写吗?比如,实现翻转,一种常见的思路是:第一个和最后一个交换,第二个和倒数第二个交换,直到中间两个交换完成。如果数据不是二进制位,这个思路是好的,但对于二进制位,这个思路的效率比较低。

CPU指令并不能高效地操作单个位,它操作的最小数据单位一般是32位(32位机器),另外,CPU可以高效地实现移位和逻辑运算,但实现加、减、乘、除运算则比较慢。

reverse是在充分利用CPU的这些特性,并行高效地进行相邻位的交换,也可以通过其他更容易理解的方式实现相同功能,但很难比这个代码更高效。

2.循环移位

Integer有两个静态方法可以进行循环移位:

1
2
public static int rotateLeft(int i, int distance)
public static int rotateRight(int i, int distance)

rotateLeft方法是循环左移,rotateRight方法是循环右移,distance是移动的位数。所谓循环移位,是相对于普通的移位而言的,普通移位,比如左移2位,原来的最高两位就没有了,右边会补0,而如果是循环左移两位,则原来的最高两位会移到最右边,就像一个左右相接的环一样。看个例子:

1
2
3
4
5
int a = 0x12345678;
int b = Integer.rotateLeft(a, 8);
System.out.println(Integer.toHexString(b));
int c = Integer.rotateRight(a, 8);
System.out.println(Integer.toHexString(c))

b是a循环左移8位的结果,c是a循环右移8位的结果,所以输出为:

1
2
34567812
78123456

这两个函数的实现代码为:

1
2
3
4
5
6
public static int rotateLeft(int i, int distance) {
return (i << distance) | (i >>> -distance);
}
public static int rotateRight(int i, int distance) {
return (i >>> distance) | (i << -distance);
}

这两个函数中令人费解的是负数,如果distance是8,那i>>>-8是什么意思呢?其实,实际的移位个数不是后面的直接数字,而是直接数字的最低5位的值,或者说是直接数字&0x1f的结果。之所以这样,是因为5位最大表示31,移位超过31位对int整数是无效的。

理解了移动负数位的含义,就比较容易理解上面这段代码了,比如,-8的二进制表示是:

1
11111111111111111111111111111000

其最低5位是11000,十进制表示就是24,所以i>>>-8就是i>>>24, i<<8 |i>>>24就是循环左移8位。上面代码中,i>>>-distance就是i>>>(32-distance),i<<-distance就是i<<(32-distance)。

Integer中还有一些其他的位操作,具体可参看API文档。关于其实现代码,都有注释指向Hacker’s Delight这本书的相关章节,不再赘述。

3. valueOf的实现

在前面,我们提到,创建包装类对象时,可以使用静态的valueOf方法,也可以直接使用new,但建议使用valueOf方法,为什么呢?我们来看Integer的valueOf的代码(基于Java
7):

1
2
3
4
5
6
public static Integer valueOf(int i) {
assert IntegerCache.high >= 127;
if (i >= IntegerCache.low && i <= IntegerCache.high)
return IntegerCache.cache[i + (-IntegerCache.low)];
return new Integer(i);
}

它使用了IntegerCache,这是一个私有静态内部类,如代码清单7-1所示。

代码清单7-1 IntegerCache
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
private static class IntegerCache {
static final int low = -128;
static final int high;
static final Integer cache[];
static {
//high value may be configured by property
int h = 127;
String integerCacheHighPropValue = sun.misc.VM.getSavedProperty("java.lang.Integer.IntegerCache.high");
if(integerCacheHighPropValue ! = null) {
int i = parseInt(integerCacheHighPropValue);
i = Math.max(i, 127);
//Maximum array size is Integer.MAX_VALUE
h = Math.min(i, Integer.MAX_VALUE - (-low) -1);
}
high = h;
cache = new Integer[(high - low) + 1];
int j = low;
for(int k = 0; k < cache.length; k++)
cache[k] = new Integer(j++);
}
private IntegerCache() {}
}

IntegerCache表示Integer缓存,其中的cache变量是一个静态Integer数组,在静态初始化代码块中被初始化,默认情况下,保存了-128~127共256个整数对应的Integer对象。

在valueOf代码中,如果数值位于被缓存的范围,即默认-128~127,则直接从Integer-Cache中获取已预先创建的Integer对象,只有不在缓存范围时,才通过new创建对象。

通过共享常用对象,可以节省内存空间,由于Integer是不可变的,所以缓存的对象可以安全地被共享。Boolean、Byte、Short、Long、Character都有类似的实现。这种共享常用对象的思路,是一种常见的设计思路,它有一个名字,叫享元模式,英文叫Flyweight,即共享的轻量级元素。

7.1.4 剖析Character

本节探讨Character类。Character类除了封装了一个char外,还有什么可介绍的呢?它有很多静态方法,封装了Unicode字符级别的各种操作,是Java文本处理的基础,注意不是char级别,Unicode字符并不等同于char,本节详细介绍这些方法。在此之前,先来回顾一下Unicode知识。

1. Unicode基础

Unicode给世界上每个字符分配了一个编号,编号范围为0x000000~0x10FFFF。编号范围在0x0000~0xFFFF的字符为常用字符集,称BMP(Basic MultilingualPlane)字符。编号范围在0x10000~0x10FFFF的字符叫做增补字符(supplementary character)。

Unicode主要规定了编号,但没有规定如何把编号映射为二进制。UTF-16是一种编码方式,或者叫映射方式,它将编号映射为两个或4个字节,对BMP字符,它直接用两个字节表示,对于增补字符,使用4个字节表示,前两个字节叫高代理项(high surrogate),范围为0xD800~0xDBFF,后两个字节叫低代理项(lowsurrogate),范围为0xDC00~0xDFFF。UTF-16定义了一个公式,可以将编号与4字节表示进行相互转换。

Java内部采用UTF-16编码,char表示一个字符,但只能表示BMP中的字符,对于增补字符,需要使用两个char表示,一个表示高代理项,一个表示低代理项。

使用int可以表示任意一个Unicode字符,低21位表示Unicode编号,高11位设为0。整数编号在Unicode中一般称为代码点(code point),表示一个Unicode字符,与之相对,还有一个词代码单元(code unit)表示一个char。

Character类中有很多相关静态方法,下面分别介绍。

2.检查code point和char

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
//判断一个int是不是一个有效的代码点,小于等于0x10FFFF的为有效,大于的为无效
public static boolean isValidCodePoint(int codePoint)
//判断一个int是不是BMP字符,小于等于0xFFFF的为BMP字符,大于的不是
public static boolean isBmpCodePoint(int codePoint)
//判断一个int是不是增补字符,0x010000~0X10FFFF为增补字符
public static boolean isSupplementaryCodePoint(int codePoint)
//判断char是否是高代理项,0xD800~0xDBFF为高代理项
public static boolean isHighSurrogate(char ch)
//判断char是否为低代理项,0xDC00~0xDFFF为低代理项
public static boolean isLowSurrogate(char ch)
//判断char是否为代理项, char为低代理项或高代理项,则返回true
public static boolean isSurrogate(char ch)
//判断两个字符high和low是否分别为高代理项和低代理项
public static boolean isSurrogatePair(char high, char low)
//判断一个代码点由几个char组成,增补字符返回2, BMP字符返回1
public static int charCount(int codePoint)

3. code point与char的转换

除了简单的检查外,Character类中还有很多方法,进行code point与char的相互转换。

1
2
3
4
5
6
7
8
9
10
11
//根据高代理项high和低代理项low生成代码点,这个转换有个公式,这个方法封装了这个公式
public static int toCodePoint(char high, char low)
//根据代码点生成char数组,即UTF-16表示,如果code point为BMP字符,则返回的char
//数组长度为1,如果为增补字符,长度为2, char[0]为高代理项,char[1]为低代理项
public static char[] toChars(int codePoint)
//将代码点转换为char数组,与上面方法类似,只是结果存入指定数组dst的指定位置index
public static int toChars(int codePoint, char[] dst, int dstIndex)
//对增补字符code point,生成低代理项
public static char lowSurrogate(int codePoint)
//对增补字符code point,生成高代理项
public static char highSurrogate(int codePoint)

4.按code point处理char数组或序列

Character包含若干方法,以方便按照code point处理char数组或序列。

返回char数组a中从offset开始count个char包含的code point个数:

1
public static int codePointCount(char[] a, int offset, int count)

比如,如下代码输出为2, char个数为3,但code point为2。

1
2
3
4
char[] chs = new char[3];
chs[0] = '马';
Character.toChars(0x1FFFF, chs, 1);
System.out.println(Character.codePointCount(chs, 0, 3));

除了接受char数组,还有一个重载的方法接受字符序列CharSequence:

1
public static int codePointCount(CharSequence seq, int beginIndex,int endIndex)

CharSequence是一个接口,它的定义如下所示:

1
2
3
4
5
6
public interface CharSequence {
int length();
char charAt(int index);
CharSequence subSequence(int start, int end);
public String toString();
}

它与一个char数组是类似的,有length方法,有charAt方法根据索引获取字符,String类就实现了该接口。

返回char数组或序列中指定索引位置的code point:

1
2
3
public static int codePointAt(char[] a, int index)
public static int codePointAt(char[] a, int index, int limit)
public static int codePointAt(CharSequence seq, int index)

如果指定索引位置为高代理项,下一个位置为低代理项,则返回两项组成的codepoint,检查下一个位置时,下一个位置要小于limit,没传limit时,默认为a.length。

返回char数组或序列中指定索引位置之前的code point:

1
2
3
public static int codePointBefore(char[] a, int index)
public static int codePointBefore(char[] a, int index, int start)
public static int codePointBefore(CharSequence seq, int index)

codePointAt是往后找,codePointBefore是往前找,如果指定位置为低代理项,且前一个位置为高代理项,则返回两项组成的code point,检查前一个位置时,前一个位置要大于等于start,没传start时,默认为0。

根据code point偏移数计算char索引:

1
2
public static int offsetByCodePoints(char[] a, int start, int count,int index, int codePointOffset)
public static int offsetByCodePoints(CharSequence seq, int index,int codePointOffset)

如果字符数组或序列中没有增补字符,返回值为index+codePointOffset,如果有增补字符,则会将codePointOffset看作code point偏移,转换为字符偏移,start和count取字符数组的子数组。

比如,如下代码:

1
2
3
char[] chs = new char[3];
Character.toChars(0x1FFFF, chs, 1);
System.out.println(Character.offsetByCodePoints(chs, 0, 3, 1, 1));//输出结果为3, index和codePointOffset都为1,但第二个字符为增补字符,一个code point偏移是两个char偏移,所以结果为3。

5.字符属性

Unicode在给每个字符分配一个编号之外,还分配了一些属性,Character类封装了对Unicode字符属性的检查和操作,下面介绍一些主要的属性。

获取字符类型(general category):

1
2
public static int getType(int codePoint)
public static int getType(char ch)

Unicode给每个字符分配了一个类型,这个类型是非常重要的,很多其他检查和操作都是基于这个类型的。getType方法的参数可以是int类型的code point,也可以是char类型。char类型只能处理BMP字符,而int类型可以处理所有字符。Character类中很多方法都是既可以接受int类型,也可以接受char类型,后续只列出int类型的方法。返回值是int,表示类型,Character类中定义了很多静态常量表示这些类型,表7-3列出了一些字符、type值,以及Character类中常量的名称。

表7-3 常见字符类型值

epub_923038_52
检查字符是否在Unicode中被定义:

1
public static boolean isDefined(int codePoint)

每个被定义的字符,其getType()返回值都不为0,如果返回值为0,表示无定义。注意与isValidCodePoint的区别,后者只要数字不大于0x10FFFF都返回true。

检查字符是否为数字:

1
public static boolean isDigit(int codePoint)

getType()返回值为DECIMAL_DIGIT_NUMBER的字符为数字。需要注意的是,不光字符’0’、’1’、……、’9’是数字,中文全角字符的0~9也是数字。比如:

1
2
char ch = '9'; //中文全角数字
System.out.println((int)ch+", "+Character.isDigit(ch));

输出为:

1
65305, true

全角字符的9, Unicode编号为65305,它也是数字。

检查是否为字母(Letter):

1
public static boolean isLetter(int codePoint)

如果getType()的返回值为下列之一,则为Letter:

1
2
3
4
5
UPPERCASE_LETTER
LOWERCASE_LETTER
TITLECASE_LETTER
MODIFIER_LETTER
OTHER_LETTER

除了TITLECASE_LETTER和MODIFIER_LETTER,其他在表7-3中有示例,而这两个平时碰到的也比较少,就不介绍了。

检查是否为字母或数字:

1
public static boolean isLetterOrDigit(int codePoint)

只要其中之一返回true就返回true。

检查是否为字母(Alphabetic):

1
public static boolean isAlphabetic(int codePoint)

这也是检查是否为字母,与isLetter的区别是:isLetter返回true时,isAlphabetic也必然返回true;此外,getType()值为LETTER_NUMBER时,isAlphabetic也返回true,而isLetter返回false。LETTER_NUMBER中常见的字符有罗马数字字符,如’I’、’Ⅱ’、’Ⅲ’、’Ⅳ’。

检查是否为空格字符:

1
public static boolean isSpaceChar(int codePoint)

getType()值为SPACE_SEPARATOR,LINE_SEPARATORPARAGRAPH_SEPARATOR时,返回true。这个方法其实并不常用,因为它只能严格匹配空格字符本身,不能匹配实际产生空格效果的字符,如Tab控制键’\t‘。

更常用的检查空格的方法:

1
public static boolean isWhitespace(int codePoint)

\t‘、’\n‘、全角空格’ ‘和半角空格’ ‘的返回值都为true。

检查是否为小写字符:

1
public static boolean isLowerCase(int codePoint)

常见的小写字符主要是小写英文字母a~z。

检查是否为大写字符:

1
public static boolean isUpperCase(int codePoint)

常见的大写字符主要是大写英文字母A~Z。

检查是否为表意象形文字:

1
public static boolean isIdeographic(int codePoint)

大部分中文都返回为true

检查是否为ISO 8859-1编码中的控制字符:

1
public static boolean isISOControl(int codePoint)

我们在第2章介绍过,0~31、127~159表示控制字符。

检查是否可作为Java标识符的第一个字符:

1
public static boolean isJavaIdentifierStart(int codePoint)

Java标识符是Java中的变量名、函数名、类名等,字母(Alphabetic)、美元符号($)、下画线(_)可作为Java标识符的第一个字符,但数字字符不可以。

检查是否可作为Java标识符的中间字符:

1
public static boolean isJavaIdentifierPart(int codePoint)

相比isJavaIdentifierStart,主要多了数字字符,Java标识符的中间字符可以包含数字。

检查是否为镜像(mirrowed)字符:

1
public static boolean isMirrored(int codePoint)

常见镜像字符有( ){ }< >[ ],都有对应的镜像。

6.字符转换

Unicode除了规定字符属性外,对有大小写对应的字符,还规定了其对应的大小写,对有数值含义的字符,也规定了其数值。

我们先来看大小写,Character有两个静态方法,对字符进行大小写转换:

1
2
public static int toLowerCase(int codePoint)
public static int toUpperCase(int codePoint)

这两个方法主要针对英文字符a~z和A~Z,例如:toLowerCase(‘A’)返回’a’,toUpper-Case(‘z’)返回’Z’。

返回一个字符表示的数值:

1
public static int getNumericValue(int codePoint)

字符’0’~’9’返回数值0~9,对于字符a~z,无论是小写字符还是大写字符,无论是普通英文还是中文全角,数值结果都是10~35。例如,如下代码的输出结果是一样的,都是10。

1
2
3
4
System.out.println(Character.getNumericValue('A')); //全角大写A
System.out.println(Character.getNumericValue('A'));
System.out.println(Character.getNumericValue('a')); //全角小写a
System.out.println(Character.getNumericValue('a'));

返回按给定进制表示的数值:

1
public static int digit(int codePoint, int radix)

radix表示进制,常见的有二进制、八进制、十进制、十六进制,计算方式与get-NumericValue类似,只是会检查有效性,数值需要小于radix,如果无效,返回-1。例如:digit(‘F’,16)返回15,是有效的;但digit(‘G’,16)就无效,返回-1。

返回给定数值的字符形式:

1
public static char forDigit(int digit, int radix)

digit(int codePoint, int radix)相比,进行相反转换,如果数字无效,返回’\0‘。例如, Character.forDigit(15, 16)返回’F‘。

与Integer类似,Character也有按字节翻转:

1
public static char reverseBytes(char ch)

例如,翻转字符0x1234:

1
System.out.println(Integer.toHexString(Character.reverseBytes((char)0x1234)));

输出为3412。

至此,Characer类就介绍完了,它在Unicode字符级别(而非char级别)封装了字符的各种操作,通过将字符处理的细节交给Character类,其他类就可以在更高的层次上处理文本了。

1.7 函数调用的基本原理

在介绍递归函数的时候,我们看到了一个系统错误:java.lang.StackOverflowError,理解这个错误,需要理解函数调用的实现机制。下面,我们先来了解一个重要的概念:栈,然后再通过一些例子来仔细分析函数调用的过程。

1.7.1 栈的概念

我们之前谈过程序执行的基本原理:CPU有一个指令指示器,指向下一条要执行的指令,要么顺序执行,要么进行跳转(条件跳转或无条件跳转)。

基本上,这依然是成立的,程序从main函数开始顺序执行,函数调用可以看作一个无条件跳转,跳转到对应函数的指令处开始执行,碰到return语句或者函数结尾的时候,再执行一次无条件跳转,跳转回调用方,执行调用函数后的下一条指令。

但这里面有几个问题。
1)参数如何传递?
2)函数如何知道返回到什么地方?在if/else、for中,跳转的地址都是确定的,但函数自己并不知道会被谁调用,而且可能会被很多地方调用,它并不能提前知道执行结束后返回哪里。
3)函数结果如何传给调用方?

解决思路是使用内存来存放这些数据,函数调用方和函数自己就如何存放和使用这些数据达成一个一致的协议或约定。这个约定在各种计算机系统中都是类似的,存放这些数据的内存有一个相同的名字,叫

栈是一块内存,但它的使用有特别的约定,一般是先进后出,类似于一个桶,往栈里放数据称为入栈,最下面的称为栈底,最上面的称为栈顶,从栈顶拿出数据通常称为出栈。栈一般是从高位地址向低位地址扩展,换句话说,栈底的内存地址是最高的,栈顶的是最低的。

计算机系统主要使用栈来存放函数调用过程中需要的数据,包括参数、返回地址,以及函数内定义的局部变量。计算机系统就如何在栈中存放这些数据,调用者和函数如何协作做了约定。返回值不太一样,它可能放在栈中,但它使用的栈和局部变量不完全一样,有的系统使用CPU内的一个存储器存储返回值,我们可以简单认为存在一个专门的返回值存储器。main函数的相关数据放在栈的最下面,每调用一次函数,都会将相关函数的数据入栈,调用结束会出栈。

1.7.2 函数执行的基本原理

以上描述可能有点抽象,我们通过一个例子来具体说明函数执行的过程,看个简单例子:

1
2
3
4
5
6
7
8
9
10
11
12
1 public class Sum {
2
3 public static int sum(int a, int b) {
4 int c = a + b;
5 return c;
6 }
7
8 public static void main(String[] args) {
9 int d = Sum.sum(1, 2);
10 System.out.println(d);
11 }
12 }

这是一个简单的例子,main函数调用了sum函数,计算1和2的和,然后输出计算结果,从概念上,这是容易理解的,让我们从栈的角度来讨论下。

当程序在main函数调用Sum.sum之前,栈的情况大概如图1-1所示。

epub_923038_12

图1-1 调用Sum.sum之前的栈示意图

栈中主要存放了两个变量args和d。在程序执行到Sum.sum的函数内部,准备返回之前,即第5行,栈的情况大概如图1-2所示。

epub_923038_13

图1-2 在Sum.sum内部,准备返回之前的栈示意图

我们解释下,在main函数调用Sum.sum时,首先将参数1和2入栈,然后将返回地址(也就是调用函数结束后要执行的指令地址)入栈,接着跳转到sum函数,在sum函数内部,需要为局部变量c分配一个空间,而参数变量a和b则直接对应于入栈的数据1和2,在返回之前,返回值保存到了专门的返回值存储器中。

在调用return后,程序会跳转到栈中保存的返回地址,即main的下一条指令地址,而sum函数相关的数据会出栈,从而又变回图1-1的样子。

main的下一条指令是根据函数返回值给变量d赋值,返回值从专门的返回值存储器中获得。

函数执行的基本原理,简单来说就是这样。但有一些需要介绍的点,我们讨论一下。

我们在1.1节的时候说过,定义一个变量就会分配一块内存,但我们并没有具体谈什么时候分配内存,具体分配在哪里,什么时候释放内存。

从以上关于栈的描述我们可以看出,函数中的参数和函数内定义的变量,都分配在栈中,这些变量只有在函数被调用的时候才分配,而且在调用结束后就被释放了。但这个说法主要针对基本数据类型,接下来我们介绍数组和对象。

1.7.3 数组和对象的内存分配

对于数组和对象类型,我们介绍过,它们都有两块内存,一块存放实际的内容,一块存放实际内容的地址,实际的内容空间一般不是分配在栈上的,而是分配在堆(也是内存的一部分,后续章节会进一步介绍)中,但存放地址的空间是分配在栈上的。我们来看个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class ArrayMax {
public static int max(int min, int[] arr) {
int max = min;
for(int a : arr){
if(a>max){
max = a;
}
}
return max;
}
public static void main(String[] args) {
int[] arr = new int[]{2,3,4};
int ret = max(0, arr);
System.out.println(ret);
}
}

这个程序也很简单,main函数新建了一个数组,然后调用函数max计算0和数组中元素的最大值,在程序执行到max函数的return语句之前的时候,内存中栈和堆的情况如图1-3所示。

epub_923038_14

图1-3 参数有数组的内存栈和堆示意图

对于数组arr,在栈中存放的是实际内容的地址0x1000,存放地址的栈空间会随着入栈分配,出栈释放,但存放实际内容的堆空间不受影响。

但说堆空间完全不受影响是不正确的,在这个例子中,当main函数执行结束,栈空间没有变量指向它的时候,Java系统会自动进行垃圾回收,从而释放这块空间。

1.7.4 递归调用的原理

我们再通过栈的角度来理解一下递归函数的调用过程,代码如下:

1
2
3
4
5
6
7
8
9
10
11
public static int factorial(int n){
if(n==0){
return 1;
}else{
return n*factorial(n-1);
}
}
public static void main(String[] args) {
int ret = factorial(4);
System.out.println(ret);
}

在factorial第一次被调用的时候,n是4,在执行到nfactorial(n-1),即4factorial(3)之前的时候,栈的情况大概如图1-4所示。

epub_923038_15

图1-4 递归调用栈示意图,n为4

注意,返回值存储器是没有值的,在调用factorial(3)后,栈的情况如图1-5所示。

epub_923038_16

图1-5 递归调用栈示意图,n为3

栈的深度增加了,返回值存储器依然为空,就这样,每递归调用一次,栈的深度就增加一层,每次调用都会分配对应的参数和局部变量,也都会保存调用的返回地址,在调用到n等于0的时候,栈的情况如图1-6所示。

epub_923038_17

图1-6 递归调用栈示意图,n为0

这个时候,终于有返回值了,我们将factorial简写为f。f(0)的返回值为1;f(0)返回到f(1), f(1)执行1*f(0),结果也是1;然后返回到f(2), f(2)执行2*f(1),结果是2;接着返回到f(3), f(3)执行3*f(2),结果是6;然后返回到f(4),执行4*f(3),结果是24。

以上就是递归函数的执行过程,函数代码虽然只有一份,但在执行的过程中,每调用一次,就会有一次入栈,生成一份不同的参数、局部变量和返回地址。

1.7.5 小结

本节介绍了函数调用的基本原理,函数调用主要是通过栈来存储相关的数据,系统就函数调用者和函数如何使用栈做了约定,返回值可以简单认为是通过一个专门的返回值存储器存储的

从函数调用的过程可以看出,调用是有成本的,每一次调用都需要分配额外的栈空间用于存储参数、局部变量以及返回地址,需要进行额外的入栈和出栈操作。在递归调用的情况下,如果递归的次数比较多,这个成本是比较可观的,所以,如果程序可以比较容易地改为其他方式,应该考虑其他方式。另外,栈的空间不是无限的,一般正常调用都是没有问题的,但如果栈空间过深,系统就会抛出错误java.lang.StackOverflowError,即栈溢出。

至此,关于编程的基础知识,包括数据类型和变量、赋值、基本运算、流程控制中的条件执行和循环,以及函数的概念和基本原理,就介绍完了。我们谈到,在Java中,函数必须放在类中,目前我们简单认为类只是函数的容器,但类在Java中远不止有这个功能,它还承载了很多概念和思维方式,在探讨类的概念之前,在下一章,我们先来进一步理解下各种基本数据类型和文本背后的二进制表示。

1.6 函数的用法

如果需要经常做某一种操作,则类似的代码需要重复写很多遍。比如在一个数组中查找某个数,第一次查找一个数,第二次可能查找另一个数,每查一个数,类似的代码都需要重写一遍,很罗唆。另外,有一些复杂的操作,可能分为很多个步骤,如果都放在一起,则代码难以理解和维护。

计算机程序使用函数这个概念来解决这个问题,即使用函数来减少重复代码和分解复杂操作。本节我们就来谈谈Java中的函数,包括函数的基本概念和一些细节,下节我们讨论函数的基本实现原理。

1.6.1 基本概念

函数这个概念,我们学数学的时候都接触过,其基本格式是y=f(x),表示的是x到y的对应关系,给定输入x,经过函数变换f,输出y。程序中的函数概念与其类似,也由输入、操作和输出组成,但它表示的是一段子程序,这个子程序有一个名字,表示它的目的(类比f),有零个或多个参数(类比x),有可能返回一个结果(类比y)。我们来看两个简单的例子:

1
2
3
4
5
6
7
8
9
public static int sum(int a, int b){
int sum = a + b;
return sum;
}
public static void print3Lines(){
for(int i=0; i<3; i++){
System.out.println();
}
}

第一个函数的名字叫做sum,它的目的是对输入的两个数求和,有两个输入参数,分别是int整数a和b,它的操作是对两个数求和,求和结果放在变量sum中(这个sum和函数名字的sum没有任何关系),然后使用return语句将结果返回,最开始的public static是函数的修饰符,我们后续介绍。

第二个函数的名字叫做print3Lines,它的目的是在屏幕上输出三个空行,它没有输入参数,操作是使用一个循环输出三个空行,它没有返回值。

以上代码都比较简单,主要是演示函数的基本语法结构,即:

1
2
3
4
修饰符 返回值类型  函数名字(参数类型 参数名字,…) {
操作
return返回值;
}

函数的主要组成部分有以下几种。

1)函数名字:名字是不可或缺的,表示函数的功能。
2)参数:参数有0个到多个,每个参数由参数的数据类型和参数名字组成。
3)操作:函数的具体操作代码。
4)返回值:函数可以没有返回值,如果没有返回值则类型写成void,如果有则在函数代码中必须使用return语句返回一个值,这个值的类型需要和声明的返回值类型一致。
5)修饰符:Java中函数有很多修饰符,分别表示不同的目的,本节假定修饰符为public static,且暂不讨论这些修饰符的目的。

以上就是定义函数的语法。定义函数就是定义了一段有着明确功能的子程序,但定义函数本身不会执行任何代码,函数要被执行,需要被调用

Java中,任何函数都需要放在一个类中。类还没有介绍,我们暂时可以把类看作函数的一个容器,即函数放在类中,类中包括多个函数,Java中的函数一般叫做方法,我们不特别区分函数方法,可能会交替使用。一个类里面可以定义多个函数,类里面可以定义一个叫做main的函数,形式如:

1
2
3
public static void main(String[] args) {

}

这个函数有特殊的含义,表示程序的入口,String[] args表示从控制台接收到的参数,我们暂时可以忽略它。Java中运行一个程序的时候,需要指定一个定义了main函数的类,Java会寻找main函数,并从main函数开始执行。

刚开始学编程的人可能会误以为程序从代码的第一行开始执行,这是错误的,不管main函数定义在哪里,Java函数都会先找到它,然后从它的第一行开始执行。

main函数中除了可以定义变量,操作数据,还可以调用其他函数,如下所示:

1
2
3
4
5
6
7
8
public static void main(String[] args) {
int a = 2;
int b = 3;
int sum = sum(a, b);
System.out.println(sum);
print3Lines();
System.out.println(sum(3,4));
}

调用函数需要传递参数并处理返回值。main函数首先定义了两个变量a和b,接着调用了函数sum,并将a和b传递给了sum函数,然后将sum的结果赋值给了变量sum。

调用函数需要传递参数并处理返回值。main函数首先定义了两个变量a和b,接着调用了函数sum,并将a和b传递给了sum函数,然后将sum的结果赋值给了变量sum。

这里初学者需要注意的是,参数和返回值的名字是没有特别含义的。调用者main中的参数名字a和b,和函数定义sum中的参数名字a和b只是碰巧一样而已,它们完全可以不一样,而且名字之间没有关系,sum函数中不能使用main函数中的名字,反之也一样。调用者main中的sum变量和sum函数中的sum变量的名字也是碰巧一样而已,完全可以不一样。另外,变量和函数可以取一样的名字,但一样不代表有特别的含义。

调用函数如果没有参数要传递,也要加括号(),如print3Lines()。

传递的参数不一定是个变量,可以是常量,也可以是某个运算表达式,可以是某个函数的返回结果。比如:System.out.println(sum(3,4));,第一个函数调用sum(3,4),传递的参数是常量3和4,第二个函数调用System.out.println传递的参数是sum(3,4)的返回结果。

关于参数传递,简单总结一下,定义函数时声明参数,实际上就是定义变量,只是这些变量的值是未知的,调用函数时传递参数,实际上就是给函数中的变量赋值。

函数可以调用同一个类中的其他函数,也可以调用其他类中的函数,比如:

1
2
int a = 23;
System.out.println(Integer.toBinaryString(a));

调用Integer类中的toBinaryString函数,toBinaryString是Integer类中修饰符为public static的函数,表示输出一个整数的二进制表示。

对于需要重复执行的代码,可以定义函数,然后在需要的地方调用,这样可以减少重复代码。对于复杂的操作,可以将操作分为多个函数,会使得代码更加易读。

我们知道,程序执行基本上只有顺序执行、条件执行和循环执行,但更完整的描述应该包括函数的调用过程。程序从main函数开始执行,碰到函数调用的时候,会跳转进函数内部,函数调用了其他函数,会接着进入其他函数,函数返回后会继续执行调用后面的语句,返回到main函数并且main函数没有要执行的语句后程序结束。1.7节会更深入地介绍执行过程细节。在Java中,函数在程序代码中的位置和实际执行的顺序是没有关系的。

1.6.2 进一步理解函数

函数的定义和基本调用应该是比较容易理解的,但有很多细节可能令初学者困惑,包括参数传递、返回、函数命名、调用过程等,我们逐个介绍。

1.参数传递

有两类特殊类型的参数:数组和可变长度的参数。

(1)数组

数组作为参数与基本类型是不一样的,基本类型不会对调用者中的变量造成任何影响,但数组不是,在函数内修改数组中的元素会修改调用者中的数组内容。我们看个例子:

1
2
3
4
5
6
7
8
9
10
11
12
public static void reset(int[] arr){
for(int i=0; i<arr.length; i++){
arr[i] = i;
}
}
public static void main(String[] args) {
int[] arr = {10,20,30,40};
reset(arr);
for(int i=0; i<arr.length; i++){
System.out.println(arr[i]);
}
}

在reset函数内给参数数组元素赋值,在main函数中数组arr的值也会变。

这个其实也容易理解,我们在1.2节介绍过,一个数组变量有两块空间,一块用于存储数组内容本身,另一块用于存储内容的位置,给数组变量赋值不会影响原有的数组内容本身,而只会让数组变量指向一个不同的数组内容空间。

在上例中,函数参数中的数组变量arr和main函数中的数组变量arr存储的都是相同的位置,而数组内容本身只有一份数据,所以,在reset中修改数组元素内容和在main中修改是完全一样的。

(2)可变长度的参数

前面介绍的函数,参数个数都是固定的,但有时候可能希望参数个数不是固定的,比如求若干个数的最大值,可能是两个,也可能是多个。Java支持可变长度的参数,如下例所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public static int max(int min, int ... a){
int max = min;
for(int i=0; i<a.length; i++){
if(max<a[i]){
max = a[i];
}
}
return max;
}
public static void main(String[] args) {
System.out.println(max(0));
System.out.println(max(0,2));
System.out.println(max(0,2,4));
System.out.println(max(0,2,4,5));
}

这个max函数接受一个最小值,以及可变长度的若干参数,返回其中的最大值。可变长度参数的语法是在数据类型后面加三个点“… ”,在函数内,可变长度参数可以看作是数组。可变长度参数必须是参数列表中的最后一个,一个函数也只能有一个可变长度的参数。

可变长度参数实际上会转换为数组参数,也就是说,函数声明max(int min, int…a)实际上会转换为max(int min, int[] a),在main函数调用max(0,2,4,5)的时候,实际上会转换为调用max(0, new int[]{2,4,5}),使用可变长度参数主要是简化了代码书写。

2.理解返回

对初学者,我们强调下return的含义。函数返回值类型为void时,return不是必需的,在没有return的情况下,会执行到函数结尾自动返回。return用于显式结束函数执行,返回调用方。

return可以用于函数内的任意地方,可以在函数结尾,也可以在中间,可以在if语句内,可以在for循环内,用于提前结束函数执行,返回调用方。

函数返回值类型为void也可以使用return,即“return; ”,不用带值,含义是返回调用方,只是没有返回值而已。

函数的返回值最多只能有一个,那如果实际情况需要多个返回值呢?比如,计算一个整数数组中的最大的前三个数,需要返回三个结果。这个可以用数组作为返回值,在函数内创建一个包含三个元素的数组,然后将前三个结果赋给对应的数组元素。

如果实际情况需要的返回值是一种复合结果呢?比如,查找一个字符数组中所有重复出现的字符以及重复出现的次数。这个可以用对象作为返回值,我们在第3章介绍类和对象。虽然返回值最多只能有一个,但其实一个也够了。

3.重复的命名

每个函数都有一个名字,这个名字表示这个函数的意义,名字可以重复吗?在不同的类里,答案是肯定的,在同一个类里,要看情况。

同一个类里,函数可以重名,但是参数不能完全一样,即要么参数个数不同,要么参数个数相同但至少有一个参数类型不一样。

同一个类中函数名相同但参数不同的现象,一般称为函数重载。为什么需要函数重载呢?一般是因为函数想表达的意义是一样的,但参数个数或类型不一样。比如,求两个数的最大值,在Java的Math库中就定义了4个函数,如下所示:

1
2
3
4
public static double max(double a, double b)
public static float max(float a, float b)
public static int max(int a, int b)
public static long max(long a, long b)

4.调用的匹配过程

在之前介绍函数调用的时候,我们没有特别说明参数的类型。这里说明一下,参数传递实际上是给参数赋值,调用者传递的数据需要与函数声明的参数类型是匹配的,但不要求完全一样。什么意思呢?Java编译器会自动进行类型转换,并寻找最匹配的函数,比如:

1
2
3
char a = 'a';
char b = 'b';
System.out.println(Math.max(a, b));

参数是字符类型的,但Math并没有定义针对字符类型的max函数,这是因为char其实是一个整数(我们在2.4节会说明), Java会自动将char转换为int,然后调用Math. max(int a, int b),屏幕会输出整数结果98。

如果Math中没有定义针对int类型的max函数呢?调用也会成功,会调用long类型的max函数。如果long也没有呢?会调用float型的max函数。如果float也没有,会调用double型的。Java编译器会自动寻找最匹配的。

在只有一个函数的情况下,即没有重载,只要可以进行类型转换,就会调用该函数,在有函数重载的情况下,会调用最匹配的函数。

5.递归函数

函数大部分情况下都是被别的函数调用的,但其实函数也可以调用它自己,调用自己的函数就叫递归函数。为什么需要自己调用自己呢?我们来看一个例子,求一个数的阶乘,数学中一个数n的阶乘,表示为n!,它的值定义是这样的:

epub_923038_11
0的阶乘是1, n的阶乘的值是n-1的阶乘的值乘以n,这个定义是一个递归的定义,为求n的值,需先求n-1的值,直到0,然后依次往回退。用递归表达的计算用递归函数容易实现,代码如下:

1
2
3
4
5
6
7
public static long factorial(int n){
if(n==0){
return 1;
}else{
return n*factorial(n-1);
}
}

看上去应该是比较容易理解的,和数学定义类似。递归函数形式上往往比较简单,但递归其实是有开销的,而且使用不当,可能会出现意外的结果,比如说这个调用:

1
System.out.println(factorial(100000));

系统并不会给出任何结果,而会抛出异常。异常我们在第6章介绍,此处理解为系统错误就可以了。异常类型为java.lang.StackOverflowError,这是什么意思呢?这表示栈溢出错误,要理解这个错误,我们需要理解函数调用的实现原理,我们1.7节介绍。

那递归不可行的情况下怎么办呢?递归函数经常可以转换为非递归的形式,通过循环实现。比如,求阶乘的例子,其非递归形式的定义是:

1
n! =1× 2× 3× …× n

这个可以用循环来实现,代码如下:

1
2
3
4
5
6
7
public static long factorial(int n){
long result = 1;
for(int i=1; i<=n; i++){
result=result*i;
}
return result;
}

1.6.3 小结

函数是计算机程序的一种重要结构,通过函数来减少重复代码、分解复杂操作是计算机程序的一种重要思维方式。本节我们介绍了函数的基础概念,以及关于参数传递、返回值、重载、递归方面的一些细节。

在Java中,函数还有大量的修饰符,如public、private、static、final、synchronized、abstract等,本节假定函数的修饰符都是public static,在后续章节中,我们再介绍这些修饰符。函数中还可以声明异常,我们也到第6章再介绍。

1.5 循环

所谓循环,就是多次重复执行某些类似的操作,这个操作一般不是完全一样的操作,而是类似的操作。都有哪些操作呢?这种例子太多了,比如:
1)展示照片,我们查看手机上的照片,背后的程序需要将照片一张张展示给我们。
2)播放音乐,我们听音乐,背后程序按照播放列表一首首给我们放。
3)查看消息,我们浏览朋友圈消息,背后程序将消息一条条展示给我们。

循环除了用于重复读取或展示某个列表中的内容,日常中的很多操作也要靠循环完成,比如:
1)在文件中,查找某个词,程序需要和文件中的词逐个比较(当然可能有更高效的方式,但也离不开循环);
2)使用Excel对数据进行汇总,比如求和或平均值,需要循环处理每个单元的数据;
3)群发祝福消息给好友,程序需要循环给每个好友发。

当然,以上这些例子只是冰山一角。计算机程序运行时大致只能顺序执行、条件执行和循环执行。顺序和条件其实没什么特别的,而循环大概才是程序强大的地方。凭借循环,计算机能够非常高效地完成人很难或无法完成的事情。比如,在大量文件中查找包含某个搜索词的文档,对几十万条销售数据进行统计汇总等。下面,我们先来介绍循环的4种形式,然后介绍循环控制,最后讨论循环的实现原理并进行总结。

1.5.1 循环的4种形式

在Java中,循环有4种形式,分别是while、do/while、for和foreach,下面我们分别介绍。

1. while

while的语法为:

1
2
3
while(条件语句){
代码块
}

或:

1
while(条件语句) 代码;

while和if的语法很像,只是把if换成了while,它表达的含义也非常简单,只要条件语句为真,就一直执行后面的代码,为假就停止不做了。比如:

1
2
3
4
5
6
7
8
9
10
Scanner reader = new Scanner(System.in);
System.out.println("please input password");
int num = reader.nextInt();
int password = 6789;
while(num! =password){
System.out.println("please input password");
num = reader.nextInt();
}
System.out.println("correct");
reader.close();

以上代码中,我们使用类型为Scanner的reader变量从屏幕控制台接收数字,reader. nextInt()从屏幕接收一个数字,如果数字不是6789,就一直提示输入,否则跳出循环。以上代码中的Scanner我们会在13.3节介绍,目前可以忽略其细节。

while循环中,代码块中会有影响循环中断或退出的条件,但经常不知道什么时候循环会中断或退出。比如,上例中在匹配的时候会退出,但什么时候能匹配取决于用户的输入。

2. do/while

如果不管条件语句是什么,代码块都会至少执行一次,则可以使用do/while循环,其语法为:

1
2
3
do{
代码块;
}while(条件语句)

这个也很容易理解,先执行代码块,然后再判断条件语句,如果成立,则继续循环,否则退出循环。也就是说,不管条件语句是什么,代码块都会至少执行一次。上面的例子,改为do/while循环,代码为:

1
2
3
4
5
6
7
8
9
Scanner reader = new Scanner(System.in);
int password = 6789;
int num = 0;
do{
System.out.println("please input password");
num = reader.nextInt();
}while(num! =password);
System.out.println("correct");
reader.close();

3. for

实际中应用最为广泛的循环语法可能是for了,尤其是在循环次数已知的情况。其语法为:

1
2
3
for(初始化语句;循环条件;步进操作){
循环体
}

for后面的括号中有两个分号;,分隔了三条语句。除了循环条件必须返回一个boolean类型外,其他语句没有什么要求,但通常情况下第一条语句用于初始化,尤其是循环的索引变量,第三条语句修改循环变量,一般是步进,即递增或递减索引变量,循环体是在循环中执行的语句。

for循环简化了书写,但执行过程对初学者而言不是那么明显,实际上,它执行的流程如下:
1)执行初始化指令;
2)检查循环条件是否为true,如果为false,则跳转到第6步;
3)循环条件为真,执行循环体;
4)执行步进操作;
5)步进操作执行完后,跳转到第2步,即继续检查循环条件;
6)for循环后面的语句。

下面是一个简单的for循环:

1
2
3
4
int[] arr = {1,2,3,4};
for(int i=0; i<arr.length; i++){
System.out.println(arr[i]);
}

顺序打印数组中的每个元素,初始化语句初始化索引i为0,循环条件为索引小于数组长度,步进操作为递增索引i,循环体打印数组元素。

在for中,每条语句都是可以为空的,也就是说:

1
for(; ; ){}

是有效的,这是个死循环,一直在空转,和while(true){}的效果是一样的。可以省略某些语句,但分号;不能省。如:

1
2
3
4
5
int[] arr = {1,2,3,4};
int i=0;
for(; i<arr.length; i++){
System.out.println(arr[i]);
}

索引变量在外面初始化了,所以初始化语句可以为空。

4. foreach

foreach的语法如下所示:

1
2
3
4
int[] arr = {1,2,3,4};
for(int element : arr){
System.out.println(element);
}

foreach不是一个关键字,它使用冒号:,冒号前面是循环中的每个元素,包括数据类型和变量名称,冒号后面是要遍历的数组或集合(第9章介绍),每次循环element都会自动更新。对于不需要使用索引变量,只是简单遍历的情况,foreach语法上更为简洁。

1.5.2 循环控制

在循环的时候,会以循环条件作为是否结束的依据,但有时可能会需要根据别的条件提前结束循环或跳过一些代码,这时可以使用break或continue关键字对循环进行控制。

1. break

break用于提前结束循环。比如,在一个数组中查找某个元素的时候,循环条件可能是到数组结束,但如果找到了元素,可能就会想提前结束循环,这时就可以使用break。

我们在介绍switch的时候提到过break,它用于跳转到switch外面。在循环的循环体中也可以使用break,它的含义和switch中的类似,用于跳出循环,开始执行循环后面的语句。以在数组中查找元素作为例子,代码可能是:

1
2
3
4
5
6
7
8
9
10
11
12
13
int[] arr = … ; //在该数组中查找元素
int toSearch = 100; //要查找的元素
int i = 0;
for(; i<arr.length; i++){
if(arr[i]==toSearch){
break;
}
}
if(i! =arr.length){
System.out.println("found");
}else{
System.out.println("not found");
}

如果找到了,会调用break, break执行后会跳转到循环外面,不会再执行i++语句,所以即使是最后一个元素匹配,i也小于arr.length,而如果没有找到,i最后会变为arr. length,所以可根据i是否等于arr.length来判断是否找到了。以上代码中,也可以将判断是否找到的检查放到循环条件中,但通常情况下,使用break会使代码更清楚一些。

2. continue

在循环的过程中,有的代码可能不需要每次循环都执行,这时候,可以使用continue语句,continue语句会跳过循环体中剩下的代码,然后执行步进操作。我们看个例子,以下代码统计一个数组中某个元素的个数:

1
2
3
4
5
6
7
8
9
10
int[] arr = …     //在该数组中查找元素
int toSearch = 2; //要查找的元素
int count = 0;
for(int i=0; i<arr.length; i++){
if(arr[i]! =toSearch){
continue;
}
count++;
}
System.out.println("found count "+count);

上面的代码统计数组中值等于toSearch的元素个数,如果值不等于toSearch,则跳过剩下的循环代码,执行i++。以上代码也可以不用continue,使用相反的if判断也可以得到相同的结果。这只是个人偏好的问题,如果类似要跳过的情况比较多,使用continue可能会更易读。

1.5.3 实现原理

和if一样,循环内部也是靠条件转移和无条件转移指令实现的,比如下面的代码:

1
2
3
4
int[] arr = {1,2,3,4};
for(int i=0; i<arr.length; i++){
System.out.println(arr[i]);
}

其对应的跳转过程可能为:

1
2
3
4
5
6
7
1 int[] arr = {1,2,3,4};
2 int i=0;
3 条件跳转:如果i>=arr.length,跳转到第7行
4 System.out.println(arr[i]);
5 i++
6 无条件跳转,跳转到第3行
7 其他代码

在if中,跳转只会往后面跳,而for会往前面跳,第6行就是无条件跳转指令,跳转到了前面的第3行。break/continue语句也都会转换为跳转指令,具体就不赘述了。

1.5.4 小结

循环的语法总体上也是比较简单的,初学者需要注意的是for的执行过程,以及break和continue的含义。虽然循环看起来只是重复执行一些类似的操作而已,但它其实是计算机程序解决问题的一种基本思维方式,凭借循环(当然还有别的),计算机程序可以发挥出强大的威力,比如批量转换数据、查找过滤数据、统计汇总等。

使用基本数据类型、数组、基本运算,加上条件和循环,其实已经可以写很多程序了,但这样写出来的程序往往难以理解,尤其是程序逻辑比较复杂的时候。

解决复杂问题的基本策略是分而治之,将复杂问题分解为若干相对简单的子问题,然后子问题再分解为更小的子问题……程序由数据和指令组成,大程序可以分解为小程序,小程序接着分解为更小的程序。那如何表示子程序,以及子程序之间如何协调呢?我们下节介绍。

Java编程的逻辑 版权信息

  • 书名:Java编程的逻辑作者:
  • 马俊昌出版社:机械工业出版社出版
  • 时间:2018-01
  • ISBN:9787111587729
  • 本书由北京华章图文信息有限公司授权上海阅文信息技术有限公司进行制作与发行

Java编程的逻辑 读者评论

我觉得你的文章跟一般Java教程最大的不同在于,你把各个知识点的“为什么”都解释得很清楚,非常对味,非常感谢。很多网上教程,都是直接教如何做的,主要是动手能力。可是做完了还是云里雾里。结合你的文章,一下子就通透了。——Hannah

老马说编程,太好了。把神秘的编程,通俗地讲解,使编程者认识了本质。每个专题的介绍都是深入浅出,有分析,有总结,有详细例子,真是爱不释手的宝书。——张工荣成

其实老马写的东西网上都有大把的类似文章,但是老马总是能把复杂的东西讲得深入浅出,把看似简单的东西分析得细致深入!——VitaminChen

文章比其他文章的亮点:有情景带入,重点突出,让人耳目一新,读起来很方便。感谢辛苦付出。——hellojd

虽然我使用Java多年,可是阅读作者的文章仍然觉得受益匪浅。并发总结得很好,对前面讲的并发知识作了很好的总结和梳理。——彭越

我不是初学者,依然能从这里学到很多东西。对不了解原理的非初学者来说,像回头捡落下的宝贝似的。关于编码,之前一直云里雾里的,找了几篇文章都没读进去。你的讲解浅显易懂!——Keyirei

用平实的语言把计算机科学的思维方法由浅入深,娓娓道来,让人如沐春风,醍醐灌顶。这里面没有复制、粘贴的拼凑,更没有生硬古怪的翻译腔,文章中句句都能感觉到老马理解、实践、贯通后表达出来的逻辑严密周全和通透流畅。——杜鹏

最近从PHP转Java,从您的文章学到了很多知识,很系统地重构了对计算机以及程序语言的认知,很感谢。——房飞

多线程一直连概念也模糊,阅读后真的受益匪浅!异常处理,看着简单,刚开始学习时,自己也是胡乱try和throw,不过到开发时,才体会到正确处理的重要性。感谢这篇文章。比起学习使用庞大的框架,我觉得基础知识是更重要的,对于一个知识点的理解,细细琢磨,知道实现原理,也是一种收获。——Chain