34938 字
175 分钟
获取中...
获取中...
第3章 面向对象编程

第3章 面向对象编程#

Java是一种面向对象的编程语言。面向对象编程,英文是Object-Oriented Programming,简称OOP。

那什么是面向对象编程?

和面向对象编程不同的,是面向过程编程。面向过程编程,是把模型分解成一步一步的过程。比如,老板告诉你,要编写一个TODO任务,必须按照以下步骤一步一步来:

  1. 读取文件;
  2. 编写TODO;
  3. 保存文件。

procedure-oriented

而面向对象编程,顾名思义,你得首先有个对象:

object-oriented

有了对象后,就可以和对象进行互动:

GirlFriend gf = new GirlFriend();
gf.name = "Alice";
gf.send("flowers");

因此,面向对象编程,是一种通过对象的方式,把现实世界映射到计算机模型的一种编程方法。

在本章中,我们将讨论:

面向对象的基本概念,包括:

  • 实例
  • 方法

面向对象的实现方式,包括:

  • 继承
  • 多态

Java语言本身提供的机制,包括:

  • package
  • classpath
  • jar

以及Java标准库提供的核心类,包括:

  • 字符串
  • 包装类型
  • JavaBean
  • 枚举
  • 常用工具类

通过本章的学习,完全可以理解并掌握面向对象的基本思想,但不保证能找到对象。

oop

3.1 面向对象基础#

面向对象编程,是一种通过对象的方式,把现实世界映射到计算机模型的一种编程方法。

现实世界中,我们定义了“人”这种抽象概念,而具体的人则是“小明”、“小红”、“小军”等一个个具体的人。所以,“人”可以定义为一个类(class),而具体的人则是实例(instance):

现实世界计算机模型Java代码
类 / classclass Person { }
小明实例 / mingPerson ming = new Person()
小红实例 / hongPerson hong = new Person()
小军实例 / junPerson jun = new Person()

同样的,“书”也是一种抽象的概念,所以它是类,而《Java核心技术》、《Java编程思想》、《Java学习笔记》则是实例:

现实世界计算机模型Java代码
类 / classclass Book { }
Java核心技术实例 / book1Book book1 = new Book()
Java编程思想实例 / book2Book book2 = new Book()
Java学习笔记实例 / book3Book book3 = new Book()

class和instance#

所以,只要理解了class和instance的概念,基本上就明白了什么是面向对象编程。

class是一种对象模版,它定义了如何创建实例,因此,class本身就是一种数据类型:

class

而instance是对象实例,instance是根据class创建的实例,可以创建多个instance,每个instance类型相同,但各自属性可能不相同:

instances

定义class#

在Java中,创建一个类,例如,给这个类命名为Person,就是定义一个class

class Person {
public String name;
public int age;
}

一个class可以包含多个字段(field),字段用来描述一个类的特征。上面的Person类,我们定义了两个字段,一个是String类型的字段,命名为name,一个是int类型的字段,命名为age。因此,通过class,把一组数据汇集到一个对象上,实现了数据封装。

public是用来修饰字段的,它表示这个字段可以被外部访问。

我们再看另一个Book类的定义:

class Book {
public String name;
public String author;
public String isbn;
public double price;
}

请指出Book类的各个字段。

创建实例#

定义了class,只是定义了对象模版,而要根据对象模版创建出真正的对象实例,必须用new操作符。

new操作符可以创建一个实例,然后,我们需要定义一个引用类型的变量来指向这个实例:

Person ming = new Person();

上述代码创建了一个Person类型的实例,并通过变量ming指向它。

注意区分Person ming是定义Person类型的变量ming,而new Person()是创建Person实例。

有了指向这个实例的变量,我们就可以通过这个变量来操作实例。访问实例变量可以用变量.字段,例如:

ming.name = "Xiao Ming"; // 对字段name赋值
ming.age = 12; // 对字段age赋值
System.out.println(ming.name); // 访问字段name
Person hong = new Person();
hong.name = "Xiao Hong";
hong.age = 15;

上述两个变量分别指向两个不同的实例,它们在内存中的结构如下:

┌──────────────────┐
ming ──────▶│Person instance │
├──────────────────┤
│name = "Xiao Ming"│
│age = 12 │
└──────────────────┘
┌──────────────────┐
hong ──────▶│Person instance │
├──────────────────┤
│name = "Xiao Hong"│
│age = 15 │
└──────────────────┘

两个instance拥有class定义的nameage字段,且各自都有一份独立的数据,互不干扰。

注意
一个Java源文件可以包含多个类的定义,但只能定义一个public类,且public类名必须与文件名一致。如果要定义多个public类,必须拆到多个Java源文件中。

练习#

请定义一个City类,该class具有如下字段:

  • name: 名称,String类型
  • latitude: 纬度,double类型
  • longitude: 经度,double类型

实例化几个City并赋值,然后打印。

// City
public class Main {
public static void main(String[] args) {
City bj = new City();
bj.name = "Beijing";
bj.latitude = 39.903;
bj.longitude = 116.401;
System.out.println(bj.name);
System.out.println("location: " + bj.latitude + ", " + bj.longitude);
}
}
class City {
???
}

小结#

在OOP中,classinstance是“模版”和“实例”的关系;

定义class就是定义了一种数据类型,对应的instance是这种数据类型的实例;

class定义的field,在每个instance都会拥有各自的field,且互不干扰;

通过new操作符创建新的instance,然后用变量指向它,即可通过变量来引用这个instance

访问实例字段的方法是变量名.字段名

指向instance的变量都是引用变量。

3.1.1 方法#

一个class可以包含多个field,例如,我们给Person类就定义了两个field

class Person {
public String name;
public int age;
}

但是,直接把fieldpublic暴露给外部可能会破坏封装性。比如,代码可以这样写:

Person ming = new Person();
ming.name = "Xiao Ming";
ming.age = -99; // age设置为负数

显然,直接操作field,容易造成逻辑混乱。为了避免外部代码直接去访问field,我们可以用private修饰field,拒绝外部访问:

class Person {
private String name;
private int age;
}

试试private修饰的field有什么效果:

// private field
public class Main {
public static void main(String[] args) {
Person ming = new Person();
ming.name = "Xiao Ming"; // 对字段name赋值
ming.age = 12; // 对字段age赋值
}
}
class Person {
private String name;
private int age;
}

是不是编译报错?把访问field的赋值语句去了就可以正常编译了。

compile-error

fieldpublic改成private,外部代码不能访问这些field,那我们定义这些field有什么用?怎么才能给它赋值?怎么才能读取它的值?

所以我们需要使用方法(method)来让外部代码可以间接修改field

// private field
public class Main {
public static void main(String[] args) {
Person ming = new Person();
ming.setName("Xiao Ming"); // 设置name
ming.setAge(12); // 设置age
System.out.println(ming.getName() + ", " + ming.getAge());
}
}
class Person {
private String name;
private int age;
public String getName() {
return this.name;
}
public void setName(String name) {
this.name = name;
}
public int getAge() {
return this.age;
}
public void setAge(int age) {
if (age < 0 || age > 100) {
throw new IllegalArgumentException("invalid age value");
}
this.age = age;
}
}

虽然外部代码不能直接修改private字段,但是,外部代码可以调用方法setName()setAge()来间接修改private字段。在方法内部,我们就有机会检查参数对不对。比如,setAge()就会检查传入的参数,参数超出了范围,直接报错。这样,外部代码就没有任何机会把age设置成不合理的值。

setName()方法同样可以做检查,例如,不允许传入null和空字符串:

public void setName(String name) {
if (name == null || name.isBlank()) {
throw new IllegalArgumentException("invalid name");
}
this.name = name.strip(); // 去掉首尾空格
}

同样,外部代码不能直接读取private字段,但可以通过getName()getAge()间接获取private字段的值。

所以,一个类通过定义方法,就可以给外部代码暴露一些操作的接口,同时,内部自己保证逻辑一致性。

调用方法的语法是实例变量.方法名(参数);。一个方法调用就是一个语句,所以不要忘了在末尾加;。例如:ming.setName("Xiao Ming");

定义方法#

从上面的代码可以看出,定义方法的语法是:

修饰符 方法返回类型 方法名(方法参数列表) {
若干方法语句;
return 方法返回值;
}

方法返回值通过return语句实现,如果没有返回值,返回类型设置为void,可以省略return

private方法#

public方法,自然就有private方法。和private字段一样,private方法不允许外部调用,那我们定义private方法有什么用?

定义private方法的理由是内部方法是可以调用private方法的。例如:

// private method
public class Main {
public static void main(String[] args) {
Person ming = new Person();
ming.setBirth(2008);
System.out.println(ming.getAge());
}
}
class Person {
private String name;
private int birth;
public void setBirth(int birth) {
this.birth = birth;
}
public int getAge() {
return calcAge(2019); // 调用private方法
}
// private方法:
private int calcAge(int currentYear) {
return currentYear - this.birth;
}
}

观察上述代码,calcAge()是一个private方法,外部代码无法调用,但是,内部方法getAge()可以调用它。

此外,我们还注意到,这个Person类只定义了birth字段,没有定义age字段,获取age时,通过方法getAge()返回的是一个实时计算的值,并非存储在某个字段的值。这说明方法可以封装一个类的对外接口,调用方不需要知道也不关心Person实例在内部到底有没有age字段。

this变量#

在方法内部,可以使用一个隐含的变量this,它始终指向当前实例。因此,通过this.field就可以访问当前实例的字段。

如果没有命名冲突,可以省略this。例如:

class Person {
private String name;
public String getName() {
return name; // 相当于this.name
}
}

但是,如果有局部变量和字段重名,那么局部变量优先级更高,就必须加上this

class Person {
private String name;
public void setName(String name) {
this.name = name; // 前面的this不可少,少了就变成局部变量name了
}
}
方法参数#

方法可以包含0个或任意个参数。方法参数用于接收传递给方法的变量值。调用方法时,必须严格按照参数的定义一一传递。例如:

class Person {
...
public void setNameAndAge(String name, int age) {
...
}
}

调用这个setNameAndAge()方法时,必须有两个参数,且第一个参数必须为String,第二个参数必须为int

Person ming = new Person();
ming.setNameAndAge("Xiao Ming"); // 编译错误:参数个数不对
ming.setNameAndAge(12, "Xiao Ming"); // 编译错误:参数类型不对
可变参数#

可变参数用类型...定义,可变参数相当于数组类型:

class Group {
private String[] names;
public void setNames(String... names) {
this.names = names;
}
}

上面的setNames()就定义了一个可变参数。调用时,可以这么写:

Group g = new Group();
g.setNames("Xiao Ming", "Xiao Hong", "Xiao Jun"); // 传入3个String
g.setNames("Xiao Ming", "Xiao Hong"); // 传入2个String
g.setNames("Xiao Ming"); // 传入1个String
g.setNames(); // 传入0个String

完全可以把可变参数改写为String[]类型:

class Group {
private String[] names;
public void setNames(String[] names) {
this.names = names;
}
}

但是,调用方需要自己先构造String[],比较麻烦。例如:

Group g = new Group();
g.setNames(new String[] {"Xiao Ming", "Xiao Hong", "Xiao Jun"}); // 传入1个String[]

另一个问题是,调用方可以传入null

Group g = new Group();
g.setNames(null);

而可变参数可以保证无法传入null,因为传入0个参数时,接收到的实际值是一个空数组而不是null

参数绑定#

调用方把参数传递给实例方法时,调用时传递的值会按参数位置一一绑定。

那什么是参数绑定?

我们先观察一个基本类型参数的传递:

// 基本类型参数绑定
public class Main {
public static void main(String[] args) {
Person p = new Person();
int n = 15; // n的值为15
p.setAge(n); // 传入n的值
System.out.println(p.getAge()); // 15
n = 20; // n的值改为20
System.out.println(p.getAge()); // 15还是20?
}
}
class Person {
private int age;
public int getAge() {
return this.age;
}
public void setAge(int age) {
this.age = age;
}
}

运行代码,从结果可知,修改外部的局部变量n,不影响实例page字段,原因是setAge()方法获得的参数,复制了n的值,因此,p.age和局部变量n互不影响。

结论:基本类型参数的传递,是调用方值的复制。双方各自的后续修改,互不影响。

我们再看一个传递引用参数的例子:

// 引用类型参数绑定
public class Main {
public static void main(String[] args) {
Person p = new Person();
String[] fullname = new String[] { "Homer", "Simpson" };
p.setName(fullname); // 传入fullname数组
System.out.println(p.getName()); // "Homer Simpson"
fullname[0] = "Bart"; // fullname数组的第一个元素修改为"Bart"
System.out.println(p.getName()); // "Homer Simpson"还是"Bart Simpson"?
}
}
class Person {
private String[] name;
public String getName() {
return this.name[0] + " " + this.name[1];
}
public void setName(String[] name) {
this.name = name;
}
}

注意到setName()的参数现在是一个数组。一开始,把fullname数组传进去,然后,修改fullname数组的内容,结果发现,实例p的字段p.name也被修改了!

结论
引用类型参数的传递,调用方的变量,和接收方的参数变量,指向的是同一个对象。双方任意一方对这个对象的修改,都会影响对方(因为指向同一个对象嘛)。

有了上面的结论,我们再看一个例子:

// 引用类型参数绑定
public class Main {
public static void main(String[] args) {
Person p = new Person();
String bob = "Bob";
p.setName(bob); // 传入bob变量
System.out.println(p.getName()); // "Bob"
bob = "Alice"; // bob改名为Alice
System.out.println(p.getName()); // "Bob"还是"Alice"?
}
}
class Person {
private String name;
public String getName() {
return this.name;
}
public void setName(String name) {
this.name = name;
}
}

不要怀疑引用参数绑定的机制,试解释为什么上面的代码两次输出都是"Bob"

练习#

Person类增加getAge/setAge方法:

public class Main {
public static void main(String[] args) {
Person ming = new Person();
ming.setName("小明");
ming.setAge(12);
System.out.println(ming.getAge());
}
}
class Person {
private String name;
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
}

下载练习

小结#
  • 方法可以让外部代码安全地访问实例字段;

  • 方法是一组执行语句,并且可以执行任意逻辑;

  • 方法内部遇到return时返回,void表示不返回任何值(注意和返回null不同);

  • 外部代码通过public方法操作实例,内部代码可以调用private方法;

  • 理解方法的参数绑定。

3.1.2 构造方法#

创建实例的时候,我们经常需要同时初始化这个实例的字段,例如:

Person ming = new Person();
ming.setName("小明");
ming.setAge(12);

初始化对象实例需要3行代码,而且,如果忘了调用setName()或者setAge(),这个实例内部的状态就是不正确的。

能否在创建对象实例时就把内部字段全部初始化为合适的值?

完全可以。

这时,我们就需要构造方法。

创建实例的时候,实际上是通过构造方法来初始化实例的。我们先来定义一个构造方法,能在创建Person实例的时候,一次性传入nameage,完成初始化:

// 构造方法
public class Main {
public static void main(String[] args) {
Person p = new Person("Xiao Ming", 15);
System.out.println(p.getName());
System.out.println(p.getAge());
}
}
class Person {
private String name;
private int age;
public Person(String name, int age) {
this.name = name;
this.age = age;
}
public String getName() {
return this.name;
}
public int getAge() {
return this.age;
}
}

由于构造方法是如此特殊,所以构造方法的名称就是类名。构造方法的参数没有限制,在方法内部,也可以编写任意语句。但是,和普通方法相比,构造方法没有返回值(也没有void),调用构造方法,必须用new操作符。

默认构造方法#

是不是任何class都有构造方法?是的。

那前面我们并没有为Person类编写构造方法,为什么可以调用new Person()

原因是如果一个类没有定义构造方法,编译器会自动为我们生成一个默认构造方法,它没有参数,也没有执行语句,类似这样:

class Person {
public Person() {
}
}

要特别注意的是,如果我们自定义了一个构造方法,那么,编译器就不再自动创建默认构造方法:

// 构造方法
public class Main {
public static void main(String[] args) {
Person p = new Person(); // 编译错误:找不到这个构造方法
}
}
class Person {
private String name;
private int age;
public Person(String name, int age) {
this.name = name;
this.age = age;
}
public String getName() {
return this.name;
}
public int getAge() {
return this.age;
}
}

如果既要能使用带参数的构造方法,又想保留不带参数的构造方法,那么只能把两个构造方法都定义出来:

// 构造方法
public class Main {
public static void main(String[] args) {
Person p1 = new Person("Xiao Ming", 15); // 既可以调用带参数的构造方法
Person p2 = new Person(); // 也可以调用无参数构造方法
}
}
class Person {
private String name;
private int age;
public Person() {
}
public Person(String name, int age) {
this.name = name;
this.age = age;
}
public String getName() {
return this.name;
}
public int getAge() {
return this.age;
}
}

没有在构造方法中初始化字段时,引用类型的字段默认是null,数值类型的字段用默认值,int类型默认值是0,布尔类型默认值是false

class Person {
private String name; // 默认初始化为null
private int age; // 默认初始化为0
public Person() {
}
}

也可以对字段直接进行初始化:

class Person {
private String name = "Unamed";
private int age = 10;
}

那么问题来了:既对字段进行初始化,又在构造方法中对字段进行初始化:

class Person {
private String name = "Unamed";
private int age = 10;
public Person(String name, int age) {
this.name = name;
this.age = age;
}
}

当我们创建对象的时候,new Person("Xiao Ming", 12)得到的对象实例,字段的初始值是啥?

在Java中,创建对象实例的时候,按照如下顺序进行初始化:

  1. 先初始化字段,例如,int age = 10;表示字段初始化为10double salary;表示字段默认初始化为0String s;表示引用类型字段默认初始化为null
  2. 执行构造方法的代码进行初始化。

因此,构造方法的代码由于后运行,所以,new Person("Xiao Ming", 12)的字段值最终由构造方法的代码确定。

多个构造方法#

可以定义多个构造方法,在通过new操作符调用的时候,编译器通过构造方法的参数数量、位置和类型自动区分:

class Person {
private String name;
private int age;
public Person(String name, int age) {
this.name = name;
this.age = age;
}
public Person(String name) {
this.name = name;
this.age = 12;
}
public Person() {
}
}

如果调用new Person("Xiao Ming", 20);,会自动匹配到构造方法public Person(String, int)

如果调用new Person("Xiao Ming");,会自动匹配到构造方法public Person(String)

如果调用new Person();,会自动匹配到构造方法public Person()

一个构造方法可以调用其他构造方法,这样做的目的是便于代码复用。调用其他构造方法的语法是this(…)

class Person {
private String name;
private int age;
public Person(String name, int age) {
this.name = name;
this.age = age;
}
public Person(String name) {
this(name, 18); // 调用另一个构造方法Person(String, int)
}
public Person() {
this("Unnamed"); // 调用另一个构造方法Person(String)
}
}
练习#

请给Person类增加(String, int)的构造方法:

public class Main {
public static void main(String[] args) {
// TODO: 给Person增加构造方法:
Person ming = new Person("小明", 12);
System.out.println(ming.getName());
System.out.println(ming.getAge());
}
}
class Person {
private String name;
private int age;
public String getName() {
return name;
}
public int getAge() {
return age;
}
}

下载练习

小结#

实例在创建时通过new操作符会调用其对应的构造方法,构造方法用于初始化实例;

没有定义构造方法时,编译器会自动创建一个默认的无参数构造方法;

可以定义多个构造方法,编译器根据参数自动判断;

可以在一个构造方法内部调用另一个构造方法,便于代码复用。

3.1.3 方法重载#

在一个类中,我们可以定义多个方法。如果有一系列方法,它们的功能都是类似的,只有参数有所不同,那么,可以把这一组方法名做成同名方法。例如,在Hello类中,定义多个hello()方法:

class Hello {
public void hello() {
System.out.println("Hello, world!");
}
public void hello(String name) {
System.out.println("Hello, " + name + "!");
}
public void hello(String name, int age) {
if (age < 18) {
System.out.println("Hi, " + name + "!");
} else {
System.out.println("Hello, " + name + "!");
}
}
}

这种方法名相同,但各自的参数不同,称为方法重载(Overload)。

注意:方法重载的返回值类型通常都是相同的。

方法重载的目的是,功能类似的方法使用同一名字,更容易记住,因此,调用起来更简单。

举个例子,String类提供了多个重载方法indexOf(),可以查找子串:

  • int indexOf(int ch):根据字符的Unicode码查找;
  • int indexOf(String str):根据字符串查找;
  • int indexOf(int ch, int fromIndex):根据字符查找,但指定起始位置;
  • int indexOf(String str, int fromIndex)根据字符串查找,但指定起始位置。

试一试:

// String.indexOf()
public class Main {
public static void main(String[] args) {
String s = "Test string";
int n1 = s.indexOf('t');
int n2 = s.indexOf("st");
int n3 = s.indexOf("st", 4);
System.out.println(n1);
System.out.println(n2);
System.out.println(n3);
}
}
练习#

Person增加重载方法setName(String, String)

public class Main {
public static void main(String[] args) {
Person ming = new Person();
Person hong = new Person();
ming.setName("Xiao Ming");
// TODO: 给Person增加重载方法setName(String, String):
hong.setName("Xiao", "Hong");
System.out.println(ming.getName());
System.out.println(hong.getName());
}
}
class Person {
private String name;
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
}

下载练习

小结#

方法重载是指多个方法的方法名相同,但各自的参数不同;

重载方法应该完成类似的功能,参考StringindexOf()

重载方法返回值类型应该相同。

3.1.4 继承#

在前面的章节中,我们已经定义了Person类:

class Person {
private String name;
private int age;
public String getName() {...}
public void setName(String name) {...}
public int getAge() {...}
public void setAge(int age) {...}
}

现在,假设需要定义一个Student类,字段如下:

class Student {
private String name;
private int age;
private int score;
public String getName() {...}
public void setName(String name) {...}
public int getAge() {...}
public void setAge(int age) {...}
public int getScore() { … }
public void setScore(int score) { … }
}

仔细观察,发现Student类包含了Person类已有的字段和方法,只是多出了一个score字段和相应的getScore()setScore()方法。

能不能在Student中不要写重复的代码?

这个时候,继承就派上用场了。

继承是面向对象编程中非常强大的一种机制,它首先可以复用代码。当我们让StudentPerson继承时,Student就获得了Person的所有功能,我们只需要为Student编写新增的功能。

Java使用extends关键字来实现继承:

class Person {
private String name;
private int age;
public String getName() {...}
public void setName(String name) {...}
public int getAge() {...}
public void setAge(int age) {...}
}
class Student extends Person {
// 不要重复name和age字段/方法,
// 只需要定义新增score字段/方法:
private int score;
public int getScore() { … }
public void setScore(int score) { … }
}

可见,通过继承,Student只需要编写额外的功能,不再需要重复代码。

注意
子类自动获得了父类的所有字段,严禁定义与父类重名的字段!

在OOP的术语中,我们把Person称为超类(super class),父类(parent class),基类(base class),把Student称为子类(subclass),扩展类(extended class)。

继承树#

注意到我们在定义Person的时候,没有写extends。在Java中,没有明确写extends的类,编译器会自动加上extends Object。所以,任何类,除了Object,都会继承自某个类。下图是PersonStudent的继承树:

┌───────────┐
│ Object │
└───────────┘
┌───────────┐
│ Person │
└───────────┘
┌───────────┐
│ Student │
└───────────┘

Java只允许一个class继承自一个类,因此,一个类有且仅有一个父类。只有Object特殊,它没有父类。

类似的,如果我们定义一个继承自PersonTeacher,它们的继承树关系如下:

┌───────────┐
│ Object │
└───────────┘
┌───────────┐
│ Person │
└───────────┘
▲ ▲
│ │
│ │
┌───────────┐ ┌───────────┐
│ Student │ │ Teacher │
└───────────┘ └───────────┘
protected#

继承有个特点,就是子类无法访问父类的private字段或者private方法。例如,Student类就无法访问Person类的nameage字段:

class Person {
private String name;
private int age;
}
class Student extends Person {
public String hello() {
return "Hello, " + name; // 编译错误:无法访问name字段
}
}

这使得继承的作用被削弱了。为了让子类可以访问父类的字段,我们需要把private改为protected。用protected修饰的字段可以被子类访问:

class Person {
protected String name;
protected int age;
}
class Student extends Person {
public String hello() {
return "Hello, " + name; // OK!
}
}

因此,protected关键字可以把字段和方法的访问权限控制在继承树内部,一个protected字段和方法可以被其子类,以及子类的子类所访问,后面我们还会详细讲解。

super#

super关键字表示父类(超类)。子类引用父类的字段时,可以用super.fieldName。例如:

class Student extends Person {
public String hello() {
return "Hello, " + super.name;
}
}

实际上,这里使用super.name,或者this.name,或者name,效果都是一样的。编译器会自动定位到父类的name字段。

但是,在某些时候,就必须使用super。我们来看一个例子:

// super
public class Main {
public static void main(String[] args) {
Student s = new Student("Xiao Ming", 12, 89);
}
}
class Person {
protected String name;
protected int age;
public Person(String name, int age) {
this.name = name;
this.age = age;
}
}
class Student extends Person {
protected int score;
public Student(String name, int age, int score) {
this.score = score;
}
}

运行上面的代码,会得到一个编译错误,大意是在Student的构造方法中,无法调用Person的构造方法。

这是因为在Java中,任何class的构造方法,第一行语句必须是调用父类的构造方法。如果没有明确地调用父类的构造方法,编译器会帮我们自动加一句super();,所以,Student类的构造方法实际上是这样:

class Student extends Person {
protected int score;
public Student(String name, int age, int score) {
super(); // 自动调用父类的构造方法
this.score = score;
}
}

但是,Person类并没有无参数的构造方法,因此,编译失败。

解决方法是调用Person类存在的某个构造方法。例如:

class Student extends Person {
protected int score;
public Student(String name, int age, int score) {
super(name, age); // 调用父类的构造方法Person(String, int)
this.score = score;
}
}

这样就可以正常编译了!

因此我们得出结论:如果父类没有默认的构造方法,子类就必须显式调用super()并给出参数以便让编译器定位到父类的一个合适的构造方法。

这里还顺带引出了另一个问题:即子类不会继承任何父类的构造方法。子类默认的构造方法是编译器自动生成的,不是继承的。

阻止继承#

正常情况下,只要某个class没有final修饰符,那么任何类都可以从该class继承。

从Java 15开始,允许使用sealed修饰class,并通过permits明确写出能够从该class继承的子类名称。

例如,定义一个Shape类:

public sealed class Shape permits Rect, Circle, Triangle {
...
}

上述Shape类就是一个sealed类,它只允许指定的3个类继承它。如果写:

public final class Rect extends Shape {...}

是没问题的,因为Rect出现在Shapepermits列表中。但是,如果定义一个Ellipse就会报错:

public final class Ellipse extends Shape {...}
// Compile error: class is not allowed to extend sealed class: Shape

原因是Ellipse并未出现在Shapepermits列表中。这种sealed类主要用于一些框架,防止继承被滥用。

sealed类在Java 15中目前是预览状态,要启用它,必须使用参数--enable-preview--source 15

向上转型#

如果一个引用变量的类型是Student,那么它可以指向一个Student类型的实例:

Student s = new Student();

如果一个引用类型的变量是Person,那么它可以指向一个Person类型的实例:

Person p = new Person();

现在问题来了:如果Student是从Person继承下来的,那么,一个引用类型为Person的变量,能否指向Student类型的实例?

Person p = new Student(); // ???

测试一下就可以发现,这种指向是允许的!

这是因为Student继承自Person,因此,它拥有Person的全部功能。Person类型的变量,如果指向Student类型的实例,对它进行操作,是没有问题的!

这种把一个子类类型安全地变为父类类型的赋值,被称为向上转型(upcasting)。

向上转型实际上是把一个子类型安全地变为更加抽象的父类型:

Student s = new Student();
Person p = s; // upcasting, ok
Object o1 = p; // upcasting, ok
Object o2 = s; // upcasting, ok

注意到继承树是Student > Person > Object,所以,可以把Student类型转型为Person,或者更高层次的Object

向下转型#

和向上转型相反,如果把一个父类类型强制转型为子类类型,就是向下转型(downcasting)。例如:

Person p1 = new Student(); // upcasting, ok
Person p2 = new Person();
Student s1 = (Student) p1; // ok
Student s2 = (Student) p2; // runtime error! ClassCastException!

如果测试上面的代码,可以发现:

Person类型p1实际指向Student实例,Person类型变量p2实际指向Person实例。在向下转型的时候,把p1转型为Student会成功,因为p1确实指向Student实例,把p2转型为Student会失败,因为p2的实际类型是Person,不能把父类变为子类,因为子类功能比父类多,多的功能无法凭空变出来。

因此,向下转型很可能会失败。失败的时候,Java虚拟机会报ClassCastException

为了避免向下转型出错,Java提供了instanceof操作符,可以先判断一个实例究竟是不是某种类型:

Person p = new Person();
System.out.println(p instanceof Person); // true
System.out.println(p instanceof Student); // false
Student s = new Student();
System.out.println(s instanceof Person); // true
System.out.println(s instanceof Student); // true
Student n = null;
System.out.println(n instanceof Student); // false

instanceof实际上判断一个变量所指向的实例是否是指定类型,或者这个类型的子类。如果一个引用变量为null,那么对任何instanceof的判断都为false

利用instanceof,在向下转型前可以先判断:

Person p = new Student();
if (p instanceof Student) {
// 只有判断成功才会向下转型:
Student s = (Student) p; // 一定会成功
}

从Java 14开始,判断instanceof后,可以直接转型为指定变量,避免再次强制转型。例如,对于以下代码:

Object obj = "hello";
if (obj instanceof String) {
String s = (String) obj;
System.out.println(s.toUpperCase());
}

可以改写如下:

// instanceof variable:
public class Main {
public static void main(String[] args) {
Object obj = "hello";
if (obj instanceof String s) {
// 可以直接使用变量s:
System.out.println(s.toUpperCase());
}
}
}

这种使用instanceof的写法更加简洁。

区分继承和组合#

在使用继承时,我们要注意逻辑一致性。

考察下面的Book类:

class Book {
protected String name;
public String getName() {...}
public void setName(String name) {...}
}

这个Book类也有name字段,那么,我们能不能让Student继承自Book呢?

class Student extends Book {
protected int score;
}

显然,从逻辑上讲,这是不合理的,Student不应该从Book继承,而应该从Person继承。

究其原因,是因为StudentPerson的一种,它们是is关系,而Student并不是Book。实际上StudentBook的关系是has关系。

具有has关系不应该使用继承,而是使用组合,即Student可以持有一个Book实例:

class Student extends Person {
protected Book book;
protected int score;
}

因此,继承是is关系,组合是has关系。

练习#

定义PrimaryStudent,从Student继承,并新增一个grade字段:

public class Main {
public static void main(String[] args) {
Person p = new Person("小明", 12);
Student s = new Student("小红", 20, 99);
// TODO: 定义PrimaryStudent,从Student继承,新增grade字段:
Student ps = new PrimaryStudent("小军", 9, 100, 5);
System.out.println(ps.getScore());
}
}
class Person {
protected String name;
protected int age;
public Person(String name, int age) {
this.name = name;
this.age = age;
}
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public int getAge() { return age; }
public void setAge(int age) { this.age = age; }
}
class Student extends Person {
protected int score;
public Student(String name, int age, int score) {
super(name, age);
this.score = score;
}
public int getScore() { return score; }
}
class PrimaryStudent {
// TODO
}

下载练习

小结#

继承是面向对象编程的一种强大的代码复用方式;

Java只允许单继承,所有类最终的根类是Object

protected允许子类访问父类的字段和方法;

子类的构造方法可以通过super()调用父类的构造方法;

可以安全地向上转型为更抽象的类型;

可以强制向下转型,最好借助instanceof判断;

子类和父类的关系是is,has关系不能用继承。

3.1.5 多态#

在继承关系中,子类如果定义了一个与父类方法签名完全相同的方法,被称为覆写(Override)。

例如,在Person类中,我们定义了run()方法:

class Person {
public void run() {
System.out.println("Person.run");
}
}

在子类Student中,覆写这个run()方法:

class Student extends Person {
@Override
public void run() {
System.out.println("Student.run");
}
}

Override和Overload不同的是,如果方法签名不同,就是Overload,Overload方法是一个新方法;如果方法签名相同,并且返回值也相同,就是Override

注意
方法名相同,方法参数相同,但方法返回值不同,也是不同的方法。在Java程序中,出现这种情况,编译器会报错。
class Person {
public void run() { … }
}
class Student extends Person {
// 不是Override,因为参数不同:
public void run(String s) { … }
// 不是Override,因为返回值不同:
public int run() { … }
}

加上@Override可以让编译器帮助检查是否进行了正确的覆写。希望进行覆写,但是不小心写错了方法签名,编译器会报错。

// override
public class Main {
public static void main(String[] args) {
}
}
class Person {
public void run() {}
}
public class Student extends Person {
@Override // Compile error!
public void run(String s) {}
}

但是@Override不是必需的。

在上一节中,我们已经知道,引用变量的声明类型可能与其实际类型不符,例如:

Person p = new Student();

现在,我们考虑一种情况,如果子类覆写了父类的方法:

// override
public class Main {
public static void main(String[] args) {
Person p = new Student();
p.run(); // 应该打印Person.run还是Student.run?
}
}
class Person {
public void run() {
System.out.println("Person.run");
}
}
class Student extends Person {
@Override
public void run() {
System.out.println("Student.run");
}
}

那么,一个实际类型为Student,引用类型为Person的变量,调用其run()方法,调用的是Person还是Studentrun()方法?

运行一下上面的代码就可以知道,实际上调用的方法是Studentrun()方法。因此可得出结论:

Java的实例方法调用是基于运行时的实际类型的动态调用,而非变量的声明类型。

这个非常重要的特性在面向对象编程中称之为多态。它的英文拼写非常复杂:Polymorphic。

多态#

多态是指,针对某个类型的方法调用,其真正执行的方法取决于运行时期实际类型的方法。例如:

Person p = new Student();
p.run(); // 无法确定运行时究竟调用哪个run()方法

有同学会说,从上面的代码一看就明白,肯定调用的是Studentrun()方法啊。

但是,假设我们编写这样一个方法:

public void runTwice(Person p) {
p.run();
p.run();
}

它传入的参数类型是Person,我们是无法知道传入的参数实际类型究竟是Person,还是Student,还是Person的其他子类例如Teacher,因此,也无法确定调用的是不是Person类定义的run()方法。

所以,多态的特性就是,运行期才能动态决定调用的子类方法。对某个类型调用某个方法,执行的实际方法可能是某个子类的覆写方法。这种不确定性的方法调用,究竟有什么作用?

我们还是来举例子。

假设我们定义一种收入,需要给它报税,那么先定义一个Income类:

class Income {
protected double income;
public double getTax() {
return income * 0.1; // 税率10%
}
}

对于工资收入,可以减去一个基数,那么我们可以从Income派生出SalaryIncome,并覆写getTax()

class Salary extends Income {
@Override
public double getTax() {
if (income <= 5000) {
return 0;
}
return (income - 5000) * 0.2;
}
}

如果你享受国务院特殊津贴,那么按照规定,可以全部免税:

class StateCouncilSpecialAllowance extends Income {
@Override
public double getTax() {
return 0;
}
}

现在,我们要编写一个报税的财务软件,对于一个人的所有收入进行报税,可以这么写:

public double totalTax(Income... incomes) {
double total = 0;
for (Income income: incomes) {
total = total + income.getTax();
}
return total;
}

来试一下:

// Polymorphic
public class Main {
public static void main(String[] args) {
// 给一个有普通收入、工资收入和享受国务院特殊津贴的小伙伴算税:
Income[] incomes = new Income[] {
new Income(3000),
new Salary(7500),
new StateCouncilSpecialAllowance(15000)
};
System.out.println(totalTax(incomes));
}
public static double totalTax(Income... incomes) {
double total = 0;
for (Income income: incomes) {
total = total + income.getTax();
}
return total;
}
}
class Income {
protected double income;
public Income(double income) {
this.income = income;
}
public double getTax() {
return income * 0.1; // 税率10%
}
}
class Salary extends Income {
public Salary(double income) {
super(income);
}
@Override
public double getTax() {
if (income <= 5000) {
return 0;
}
return (income - 5000) * 0.2;
}
}
class StateCouncilSpecialAllowance extends Income {
public StateCouncilSpecialAllowance(double income) {
super(income);
}
@Override
public double getTax() {
return 0;
}
}

观察totalTax()方法:利用多态,totalTax()方法只需要和Income打交道,它完全不需要知道SalaryStateCouncilSpecialAllowance的存在,就可以正确计算出总的税。如果我们要新增一种稿费收入,只需要从Income派生,然后正确覆写getTax()方法就可以。把新的类型传入totalTax(),不需要修改任何代码。

可见,多态具有一个非常强大的功能,就是允许添加更多类型的子类实现功能扩展,却不需要修改基于父类的代码。

覆写Object方法#

因为所有的class最终都继承自Object,而Object定义了几个重要的方法:

  • toString():把instance输出为String
  • equals():判断两个instance是否逻辑相等;
  • hashCode():计算一个instance的哈希值。

在必要的情况下,我们可以覆写Object的这几个方法。例如:

class Person {
...
// 显示更有意义的字符串:
@Override
public String toString() {
return "Person:name=" + name;
}
// 比较是否相等:
@Override
public boolean equals(Object o) {
// 当且仅当o为Person类型:
if (o instanceof Person) {
Person p = (Person) o;
// 并且name字段相同时,返回true:
return this.name.equals(p.name);
}
return false;
}
// 计算hash:
@Override
public int hashCode() {
return this.name.hashCode();
}
}
调用super#

在子类的覆写方法中,如果要调用父类的被覆写的方法,可以通过super来调用。例如:

class Person {
protected String name;
public String hello() {
return "Hello, " + name;
}
}
class Student extends Person {
@Override
public String hello() {
// 调用父类的hello()方法:
return super.hello() + "!";
}
}
final#

继承可以允许子类覆写父类的方法。如果一个父类不允许子类对它的某个方法进行覆写,可以把该方法标记为final。用final修饰的方法不能被Override

class Person {
protected String name;
public final String hello() {
return "Hello, " + name;
}
}
class Student extends Person {
// compile error: 不允许覆写
@Override
public String hello() {
}
}

如果一个类不希望任何其他类继承自它,那么可以把这个类本身标记为final。用final修饰的类不能被继承:

final class Person {
protected String name;
}
// compile error: 不允许继承自Person
class Student extends Person {
}

对于一个类的实例字段,同样可以用final修饰。用final修饰的字段在初始化后不能被修改。例如:

class Person {
public final String name = "Unamed";
}

final字段重新赋值会报错:

Person p = new Person();
p.name = "New Name"; // compile error!

可以在构造方法中初始化final字段:

class Person {
public final String name;
public Person(String name) {
this.name = name;
}
}

这种方法更为常用,因为可以保证实例一旦创建,其final字段就不可修改。

练习#

给一个有工资收入和稿费收入的小伙伴算税。

下载练习

小结#

子类可以覆写父类的方法(Override),覆写在子类中改变了父类方法的行为;

Java的方法调用总是作用于运行期对象的实际类型,这种行为称为多态;

final修饰符有多种作用:

  • final修饰的方法可以阻止被覆写;
  • final修饰的class可以阻止被继承;
  • final修饰的field必须在创建对象时初始化,随后不可修改。

3.1.6 抽象类#

由于多态的存在,每个子类都可以覆写父类的方法,例如:

class Person {
public void run() { … }
}
class Student extends Person {
@Override
public void run() { … }
}
class Teacher extends Person {
@Override
public void run() { … }
}

Person类派生的StudentTeacher都可以覆写run()方法。

如果父类Personrun()方法没有实际意义,能否去掉方法的执行语句?

class Person {
public void run(); // Compile Error!
}

答案是不行,会导致编译错误,因为定义方法的时候,必须实现方法的语句。

能不能去掉父类的run()方法?

答案还是不行,因为去掉父类的run()方法,就失去了多态的特性。例如,runTwice()就无法编译:

public void runTwice(Person p) {
p.run(); // Person没有run()方法,会导致编译错误
p.run();
}

如果父类的方法本身不需要实现任何功能,仅仅是为了定义方法签名,目的是让子类去覆写它,那么,可以把父类的方法声明为抽象方法:

class Person {
public abstract void run();
}

把一个方法声明为abstract,表示它是一个抽象方法,本身没有实现任何方法语句。因为这个抽象方法本身是无法执行的,所以,Person类也无法被实例化。编译器会告诉我们,无法编译Person类,因为它包含抽象方法。

必须把Person类本身也声明为abstract,才能正确编译它:

abstract class Person {
public abstract void run();
}
抽象类#

如果一个class定义了方法,但没有具体执行代码,这个方法就是抽象方法,抽象方法用abstract修饰。

因为无法执行抽象方法,因此这个类也必须申明为抽象类(abstract class)。

使用abstract修饰的类就是抽象类。我们无法实例化一个抽象类:

Person p = new Person(); // 编译错误

无法实例化的抽象类有什么用?

因为抽象类本身被设计成只能用于被继承,因此,抽象类可以强迫子类实现其定义的抽象方法,否则编译会报错。因此,抽象方法实际上相当于定义了“规范”。

例如,Person类定义了抽象方法run(),那么,在实现子类Student的时候,就必须覆写run()方法:

// abstract class
public class Main {
public static void main(String[] args) {
Person p = new Student();
p.run();
}
}
abstract class Person {
public abstract void run();
}
class Student extends Person {
@Override
public void run() {
System.out.println("Student.run");
}
}
面向抽象编程#

当我们定义了抽象类Person,以及具体的StudentTeacher子类的时候,我们可以通过抽象类Person类型去引用具体的子类的实例:

Person s = new Student();
Person t = new Teacher();

这种引用抽象类的好处在于,我们对其进行方法调用,并不关心Person类型变量的具体子类型:

// 不关心Person变量的具体子类型:
s.run();
t.run();

同样的代码,如果引用的是一个新的子类,我们仍然不关心具体类型:

// 同样不关心新的子类是如何实现run()方法的:
Person e = new Employee();
e.run();

这种尽量引用高层类型,避免引用实际子类型的方式,称之为面向抽象编程。

面向抽象编程的本质就是:

  • 上层代码只定义规范(例如:abstract class Person);
  • 不需要子类就可以实现业务逻辑(正常编译);
  • 具体的业务逻辑由不同的子类实现,调用者并不关心。
练习#

用抽象类给一个有工资收入和稿费收入的小伙伴算税。

下载练习

小结#

通过abstract定义的方法是抽象方法,它只有定义,没有实现。抽象方法定义了子类必须实现的接口规范;

定义了抽象方法的class必须被定义为抽象类,从抽象类继承的子类必须实现抽象方法;

如果不实现抽象方法,则该子类仍是一个抽象类;

面向抽象编程使得调用者只关心抽象方法的定义,不关心子类的具体实现。

3.1.7 接口#

在抽象类中,抽象方法本质上是定义接口规范:即规定高层类的接口,从而保证所有子类都有相同的接口实现,这样,多态就能发挥出威力。

如果一个抽象类没有字段,所有方法全部都是抽象方法:

abstract class Person {
public abstract void run();
public abstract String getName();
}

就可以把该抽象类改写为接口:interface

在Java中,使用interface可以声明一个接口:

interface Person {
void run();
String getName();
}

所谓interface,就是比抽象类还要抽象的纯抽象接口,因为它连字段都不能有。因为接口定义的所有方法默认都是public abstract的,所以这两个修饰符不需要写出来(写不写效果都一样)。

当一个具体的class去实现一个interface时,需要使用implements关键字。举个例子:

class Student implements Person {
private String name;
public Student(String name) {
this.name = name;
}
@Override
public void run() {
System.out.println(this.name + " run");
}
@Override
public String getName() {
return this.name;
}
}

我们知道,在Java中,一个类只能继承自另一个类,不能从多个类继承。但是,一个类可以实现多个interface,例如:

class Student implements Person, Hello { // 实现了两个interface
...
}
术语#

注意区分术语:

Java的接口特指interface的定义,表示一个接口类型和一组方法签名,而编程接口泛指接口规范,如方法签名,数据格式,网络协议等。

抽象类和接口的对比如下:

abstract classinterface
继承只能extends一个class可以implements多个interface
字段可以定义实例字段不能定义实例字段
抽象方法可以定义抽象方法可以定义抽象方法
非抽象方法可以定义非抽象方法可以定义default方法
接口继承#

一个interface可以继承自另一个interfaceinterface继承自interface使用extends,它相当于扩展了接口的方法。例如:

interface Hello {
void hello();
}
interface Person extends Hello {
void run();
String getName();
}

此时,Person接口继承自Hello接口,因此,Person接口现在实际上有3个抽象方法签名,其中一个来自继承的Hello接口。

继承关系#

合理设计interfaceabstract class的继承关系,可以充分复用代码。一般来说,公共逻辑适合放在abstract class中,具体逻辑放到各个子类,而接口层次代表抽象程度。可以参考Java的集合类定义的一组接口、抽象类以及具体子类的继承关系:

┌───────────────┐
│ Iterable │
└───────────────┘
▲ ┌───────────────────┐
│ │ Object │
┌───────────────┐ └───────────────────┘
│ Collection │ ▲
└───────────────┘ │
▲ ▲ ┌───────────────────┐
│ └──────────│AbstractCollection │
┌───────────────┐ └───────────────────┘
│ List │ ▲
└───────────────┘ │
▲ ┌───────────────────┐
└──────────│ AbstractList │
└───────────────────┘
▲ ▲
│ │
│ │
┌────────────┐ ┌────────────┐
│ ArrayList │ │ LinkedList │
└────────────┘ └────────────┘

在使用的时候,实例化的对象永远只能是某个具体的子类,但总是通过接口去引用它,因为接口比抽象类更抽象:

List list = new ArrayList(); // 用List接口引用具体子类的实例
Collection coll = list; // 向上转型为Collection接口
Iterable it = coll; // 向上转型为Iterable接口
default方法#

在接口中,可以定义default方法。例如,把Person接口的run()方法改为default方法:

// interface
public class Main {
public static void main(String[] args) {
Person p = new Student("Xiao Ming");
p.run();
}
}
interface Person {
String getName();
default void run() {
System.out.println(getName() + " run");
}
}
class Student implements Person {
private String name;
public Student(String name) {
this.name = name;
}
public String getName() {
return this.name;
}
}

实现类可以不必覆写default方法。default方法的目的是,当我们需要给接口新增一个方法时,会涉及到修改全部子类。如果新增的是default方法,那么子类就不必全部修改,只需要在需要覆写的地方去覆写新增方法。

default方法和抽象类的普通方法是有所不同的。因为interface没有字段,default方法无法访问字段,而抽象类的普通方法可以访问实例字段。

练习#

用接口给一个有工资收入和稿费收入的小伙伴算税。

下载练习

小结#

Java的接口(interface)定义了纯抽象规范,一个类可以实现多个接口;

接口也是数据类型,适用于向上转型和向下转型;

接口的所有方法都是抽象方法,接口不能定义实例字段;

接口可以定义default方法(JDK>=1.8)。

3.1.8 静态字段和静态方法#

在一个class中定义的字段,我们称之为实例字段。实例字段的特点是,每个实例都有独立的字段,各个实例的同名字段互不影响。

还有一种字段,是用static修饰的字段,称为静态字段:static field

实例字段在每个实例中都有自己的一个独立“空间”,但是静态字段只有一个共享“空间”,所有实例都会共享该字段。举个例子:

class Person {
public String name;
public int age;
// 定义静态字段number:
public static int number;
}

我们来看看下面的代码:

// static field
public class Main {
public static void main(String[] args) {
Person ming = new Person("Xiao Ming", 12);
Person hong = new Person("Xiao Hong", 15);
ming.number = 88;
System.out.println(hong.number);
hong.number = 99;
System.out.println(ming.number);
}
}
class Person {
public String name;
public int age;
public static int number;
public Person(String name, int age) {
this.name = name;
this.age = age;
}
}

对于静态字段,无论修改哪个实例的静态字段,效果都是一样的:所有实例的静态字段都被修改了,原因是静态字段并不属于实例:

┌──────────────────┐
ming ──▶│Person instance │
├──────────────────┤
│name = "Xiao Ming"│
│age = 12 │
│number ───────────┼──┐ ┌─────────────┐
└──────────────────┘ │ │Person class │
│ ├─────────────┤
├───▶│number = 99 │
┌──────────────────┐ │ └─────────────┘
hong ──▶│Person instance │ │
├──────────────────┤ │
│name = "Xiao Hong"│ │
│age = 15 │ │
│number ───────────┼──┘
└──────────────────┘

虽然实例可以访问静态字段,但是它们指向的其实都是Person class的静态字段。所以,所有实例共享一个静态字段。

因此,不推荐用实例变量.静态字段去访问静态字段,因为在Java程序中,实例对象并没有静态字段。在代码中,实例对象能访问静态字段只是因为编译器可以根据实例类型自动转换为类名.静态字段来访问静态对象。

推荐用类名来访问静态字段。可以把静态字段理解为描述class本身的字段。对于上面的代码,更好的写法是:

Person.number = 99;
System.out.println(Person.number);
静态方法#

有静态字段,就有静态方法。用static修饰的方法称为静态方法。

调用实例方法必须通过一个实例变量,而调用静态方法则不需要实例变量,通过类名就可以调用。静态方法类似其它编程语言的函数。例如:

// static method
public class Main {
public static void main(String[] args) {
Person.setNumber(99);
System.out.println(Person.number);
}
}
class Person {
public static int number;
public static void setNumber(int value) {
number = value;
}
}

因为静态方法属于class而不属于实例,因此,静态方法内部,无法访问this变量,也无法访问实例字段,它只能访问静态字段。

通过实例变量也可以调用静态方法,但这只是编译器自动帮我们把实例改写成类名而已。

通常情况下,通过实例变量访问静态字段和静态方法,会得到一个编译警告。

静态方法经常用于工具类。例如:

  • Arrays.sort()
  • Math.random()

静态方法也经常用于辅助方法。注意到Java程序的入口main()也是静态方法。

接口的静态字段#

因为interface是一个纯抽象类,所以它不能定义实例字段。但是,interface是可以有静态字段的,并且静态字段必须为final类型:

public interface Person {
public static final int MALE = 1;
public static final int FEMALE = 2;
}

实际上,因为interface的字段只能是public static final类型,所以我们可以把这些修饰符都去掉,上述代码可以简写为:

public interface Person {
// 编译器会自动加上public static final:
int MALE = 1;
int FEMALE = 2;
}

编译器会自动把该字段变为public static final类型。

练习#

Person类增加一个静态字段count和静态方法getCount(),统计实例创建的个数。

下载练习

小结#

静态字段属于所有实例“共享”的字段,实际上是属于class的字段;

调用静态方法不需要实例,无法访问this,但可以访问静态字段和其他静态方法;

静态方法常用于工具类和辅助方法。

3.1.9 包#

在前面的代码中,我们把类和接口命名为PersonStudentHello等简单名字。

在现实中,如果小明写了一个Person类,小红也写了一个Person类,现在,小白既想用小明的Person,也想用小红的Person,怎么办?

如果小军写了一个Arrays类,恰好JDK也自带了一个Arrays类,如何解决类名冲突?

在Java中,我们使用package来解决名字冲突。

Java定义了一种名字空间,称之为包:package。一个类总是属于某个包,类名(比如Person)只是一个简写,真正的完整类名是包名.类名

例如:

小明的Person类存放在包ming下面,因此,完整类名是ming.Person

小红的Person类存放在包hong下面,因此,完整类名是hong.Person

小军的Arrays类存放在包mr.jun下面,因此,完整类名是mr.jun.Arrays

JDK的Arrays类存放在包java.util下面,因此,完整类名是java.util.Arrays

在定义class的时候,我们需要在第一行声明这个class属于哪个包。

小明的Person.java文件:

package ming; // 申明包名ming
public class Person {
}

小军的Arrays.java文件:

package mr.jun; // 申明包名mr.jun
public class Arrays {
}

在Java虚拟机执行的时候,JVM只看完整类名,因此,只要包名不同,类就不同。

包可以是多层结构,用.隔开。例如:java.util

特别注意
包没有父子关系。java.util和java.util.zip是不同的包,两者没有任何继承关系。

没有定义包名的class,它使用的是默认包,非常容易引起名字冲突,因此,不推荐不写包名的做法。

我们还需要按照包结构把上面的Java文件组织起来。假设以package_sample作为根目录,src作为源码目录,那么所有文件结构就是:

package_sample
└─ src
├─ hong
│ └─ Person.java
│ ming
│ └─ Person.java
└─ mr
└─ jun
└─ Arrays.java

即所有Java文件对应的目录层次要和包的层次一致。

编译后的.class文件也需要按照包结构存放。如果使用IDE,把编译后的.class文件放到bin目录下,那么,编译的文件结构就是:

package_sample
└─ bin
├─ hong
│ └─ Person.class
│ ming
│ └─ Person.class
└─ mr
└─ jun
└─ Arrays.class
包作用域#

位于同一个包的类,可以访问包作用域的字段和方法。不用publicprotectedprivate修饰的字段和方法就是包作用域。例如,Person类定义在hello包下面:

package hello;
public class Person {
// 包作用域:
void hello() {
System.out.println("Hello!");
}
}

Main类也定义在hello包下面:

package hello;
public class Main {
public static void main(String[] args) {
Person p = new Person();
p.hello(); // 可以调用,因为Main和Person在同一个包
}
}
import#

在一个class中,我们总会引用其他的class。例如,小明的ming.Person类,如果要引用小军的mr.jun.Arrays类,他有三种写法:

第一种,直接写出完整类名,例如:

Person.java
package ming;
public class Person {
public void run() {
// 写完整类名: mr.jun.Arrays
mr.jun.Arrays arrays = new mr.jun.Arrays();
}
}

很显然,每次写完整类名比较痛苦。

因此,第二种写法是用import语句,导入小军的Arrays,然后写简单类名:

Person.java
package ming;
// 导入完整类名:
import mr.jun.Arrays;
public class Person {
public void run() {
// 写简单类名: Arrays
Arrays arrays = new Arrays();
}
}

在写import的时候,可以使用*,表示把这个包下面的所有class都导入进来(但不包括子包的class):

Person.java
package ming;
// 导入mr.jun包的所有class:
import mr.jun.*;
public class Person {
public void run() {
Arrays arrays = new Arrays();
}
}

我们一般不推荐这种写法,因为在导入了多个包后,很难看出Arrays类属于哪个包。

还有一种import static的语法,它可以导入一个类的静态字段和静态方法:

package main;
// 导入System类的所有静态字段和静态方法:
import static java.lang.System.*;
public class Main {
public static void main(String[] args) {
// 相当于调用System.out.println(…)
out.println("Hello, world!");
}
}

import static很少使用。

Java编译器最终编译出的.class文件只使用完整类名,因此,在代码中,当编译器遇到一个class名称时:

  • 如果是完整类名,就直接根据完整类名查找这个class
  • 如果是简单类名,按下面的顺序依次查找:
    • 查找当前package是否存在这个class
    • 查找import的包是否包含这个class
    • 查找java.lang包是否包含这个class

如果按照上面的规则还无法确定类名,则编译报错。

我们来看一个例子:

Main.java
package test;
import java.text.Format;
public class Main {
public static void main(String[] args) {
java.util.List list; // ok,使用完整类名 -> java.util.List
Format format = null; // ok,使用import的类 -> java.text.Format
String s = "hi"; // ok,使用java.lang包的String -> java.lang.String
System.out.println(s); // ok,使用java.lang包的System -> java.lang.System
MessageFormat mf = null; // 编译错误:无法找到MessageFormat: MessageFormat cannot be resolved to a type
}
}

因此,编写class的时候,编译器会自动帮我们做两个import动作:

  • 默认自动import当前package的其他class
  • 默认自动import java.lang.*
注意
自动导入的是java.lang包,但类似java.lang.reflect这些包仍需要手动导入。

如果有两个class名称相同,例如,mr.jun.Arraysjava.util.Arrays,那么只能import其中一个,另一个必须写完整类名。

最佳实践#

为了避免名字冲突,我们需要确定唯一的包名。推荐的做法是使用倒置的域名来确保唯一性。例如:

  • org.apache
  • org.apache.commons.log
  • com.liaoxuefeng.sample

子包就可以根据功能自行命名。

要注意不要和java.lang包的类重名,即自己的类不要使用这些名字:

  • String
  • System
  • Runtime

要注意也不要和JDK常用类重名:

  • java.util.List
  • java.text.Format
  • java.math.BigInteger
编译和运行#

假设我们创建了如下的目录结构:

work
├── bin
└── src
└── com
└── itranswarp
├── sample
│   └── Main.java
└── world
└── Person.java

其中,bin目录用于存放编译后的class文件,src目录按包结构存放Java源码,我们怎么一次性编译这些Java源码呢?

首先,确保当前目录是work目录,即存放srcbin的父目录:

$ ls
bin src

然后,编译src目录下的所有Java文件:

$ javac -d ./bin src/**/*.java

命令行-d指定输出的class文件存放bin目录,后面的参数src/**/*.java表示src目录下的所有.java文件,包括任意深度的子目录。

注意:Windows不支持**这种搜索全部子目录的做法,所以在Windows下编译必须依次列出所有.java文件:

C:\work> javac -d bin src\com\itranswarp\sample\Main.java src\com\itranswarp\world\Persion.java

使用Windows的PowerShell可以利用Get-ChildItem来列出指定目录下的所有.java文件:

PS C:\work> (Get-ChildItem -Path .\src -Recurse -Filter *.java).FullName
C:\work\src\com\itranswarp\sample\Main.java
C:\work\src\com\itranswarp\world\Person.java

因此,编译命令可写为:

PS C:\work> javac -d .\bin (Get-ChildItem -Path .\src -Recurse -Filter *.java).FullName

如果编译无误,则javac命令没有任何输出。可以在bin目录下看到如下class文件:

bin
└── com
└── itranswarp
├── sample
│   └── Main.class
└── world
└── Person.class

现在,我们就可以直接运行class文件了。根据当前目录的位置确定classpath,例如,当前目录仍为work,则classpath为bin或者./bin

$ java -cp bin com.itranswarp.sample.Main
Hello, world!
练习#

请按如下包结构创建工程项目:

oop-package
└── src
└── com
└── itranswarp
├── sample
│   └── Main.java
└── world
└── Person.java

下载练习

小结#

Java内建的package机制是为了避免class命名冲突;

JDK的核心类使用java.lang包,编译器会自动导入;

JDK的其它常用类定义在java.util.*java.math.*java.text.*,……;

包名推荐使用倒置的域名,例如org.apache

3.1.10 作用域#

在Java中,我们经常看到publicprotectedprivate这些修饰符。在Java中,这些修饰符可以用来限定访问作用域。

public#

定义为publicclassinterface可以被其他任何类访问:

package abc;
public class Hello {
public void hi() {
}
}

上面的Hellopublic,因此,可以被其他包的类访问:

package xyz;
class Main {
void foo() {
// Main可以访问Hello
Hello h = new Hello();
}
}

定义为publicfieldmethod可以被其他类访问,前提是首先有访问class的权限:

package abc;
public class Hello {
public void hi() {
}
}

上面的hi()方法是public,可以被其他类调用,前提是首先要能访问Hello类:

package xyz;
class Main {
void foo() {
Hello h = new Hello();
h.hi();
}
}
private#

定义为privatefieldmethod无法被其他类访问:

package abc;
public class Hello {
// 不能被其他类调用:
private void hi() {
}
public void hello() {
this.hi();
}
}

实际上,确切地说,private访问权限被限定在class的内部,而且与方法声明顺序无关。推荐把private方法放到后面,因为public方法定义了类对外提供的功能,阅读代码的时候,应该先关注public方法:

package abc;
public class Hello {
public void hello() {
this.hi();
}
private void hi() {
}
}

由于Java支持嵌套类,如果一个类内部还定义了嵌套类,那么,嵌套类拥有访问private的权限:

// private
public class Main {
public static void main(String[] args) {
Inner i = new Inner();
i.hi();
}
// private方法:
private static void hello() {
System.out.println("private hello!");
}
// 静态内部类:
static class Inner {
public void hi() {
Main.hello();
}
}
}

定义在一个class内部的class称为嵌套类(nested class),Java支持好几种嵌套类。

protected#

protected作用于继承关系。定义为protected的字段和方法可以被子类访问,以及子类的子类:

package abc;
public class Hello {
// protected方法:
protected void hi() {
}
}

上面的protected方法可以被继承的类访问:

package xyz;
class Main extends Hello {
void foo() {
// 可以访问protected方法:
hi();
}
}
package#

最后,包作用域是指一个类允许访问同一个package的没有publicprivate修饰的class,以及没有publicprotectedprivate修饰的字段和方法。

package abc;
// package权限的类:
class Hello {
// package权限的方法:
void hi() {
}
}

只要在同一个包,就可以访问package权限的classfieldmethod

package abc;
class Main {
void foo() {
// 可以访问package权限的类:
Hello h = new Hello();
// 可以调用package权限的方法:
h.hi();
}
}

注意,包名必须完全一致,包没有父子关系,com.apachecom.apache.abc是不同的包。

局部变量#

在方法内部定义的变量称为局部变量,局部变量作用域从变量声明处开始到对应的块结束。方法参数也是局部变量。

package abc;
public class Hello {
void hi(String name) { // 1
String s = name.toLowerCase(); // 2
int len = s.length(); // 3
if (len < 10) { // 4
int p = 10 - len; // 5
for (int i=0; i<10; i++) { // 6
System.out.println(); // 7
} // 8
} // 9
} // 10
}

我们观察上面的hi()方法代码:

  • 方法参数name是局部变量,它的作用域是整个方法,即1 ~ 10;
  • 变量s的作用域是定义处到方法结束,即2 ~ 10;
  • 变量len的作用域是定义处到方法结束,即3 ~ 10;
  • 变量p的作用域是定义处到if块结束,即5 ~ 9;
  • 变量i的作用域是for循环,即6 ~ 8。

使用局部变量时,应该尽可能把局部变量的作用域缩小,尽可能延后声明局部变量。

final#

Java还提供了一个final修饰符。final与访问权限不冲突,它有很多作用。

final修饰class可以阻止被继承:

package abc;
// 无法被继承:
public final class Hello {
private int n = 0;
protected void hi(int t) {
long i = t;
}
}

final修饰method可以阻止被子类覆写:

package abc;
public class Hello {
// 无法被覆写:
protected final void hi() {
}
}

final修饰field可以阻止被重新赋值:

package abc;
public class Hello {
private final int n = 0;
protected void hi() {
this.n = 1; // error!
}
}

final修饰局部变量可以阻止被重新赋值:

package abc;
public class Hello {
protected void hi(final int t) {
t = 1; // error!
}
}
最佳实践#

如果不确定是否需要public,就不声明为public,即尽可能少地暴露对外的字段和方法。

把方法定义为package权限有助于测试,因为测试类和被测试类只要位于同一个package,测试代码就可以访问被测试类的package权限方法。

一个.java文件只能包含一个public类,但可以包含多个非public类。如果有public类,文件名必须和public类的名字相同。

小结#

Java内建的访问权限包括publicprotectedprivatepackage权限;

Java在方法内部定义的变量是局部变量,局部变量的作用域从变量声明开始,到一个块结束;

final修饰符不是访问权限,它可以修饰classfieldmethod

一个.java文件只能包含一个public类,但可以包含多个非public类。

3.1.11 内部类#

在Java程序中,通常情况下,我们把不同的类组织在不同的包下面,对于一个包下面的类来说,它们是在同一层次,没有父子关系:

java.lang
├── Math
├── Runnable
├── String
└── ...

还有一种类,它被定义在另一个类的内部,所以称为内部类(Nested Class)。Java的内部类分为好几种,通常情况用得不多,但也需要了解它们是如何使用的。

Inner Class#

如果一个类定义在另一个类的内部,这个类就是Inner Class:

class Outer {
class Inner {
// 定义了一个Inner Class
}
}

上述定义的Outer是一个普通类,而Inner是一个Inner Class,它与普通类有个最大的不同,就是Inner Class的实例不能单独存在,必须依附于一个Outer Class的实例。示例代码如下:

// inner class
public class Main {
public static void main(String[] args) {
Outer outer = new Outer("Nested"); // 实例化一个Outer
Outer.Inner inner = outer.new Inner(); // 实例化一个Inner
inner.hello();
}
}
class Outer {
private String name;
Outer(String name) {
this.name = name;
}
class Inner {
void hello() {
System.out.println("Hello, " + Outer.this.name);
}
}
}

观察上述代码,要实例化一个Inner,我们必须首先创建一个Outer的实例,然后,调用Outer实例的new来创建Inner实例:

Outer.Inner inner = outer.new Inner();

这是因为Inner Class除了有一个this指向它自己,还隐含地持有一个Outer Class实例,可以用Outer.this访问这个实例。所以,实例化一个Inner Class不能脱离Outer实例。

Inner Class和普通Class相比,除了能引用Outer实例外,还有一个额外的“特权”,就是可以修改Outer Class的private字段,因为Inner Class的作用域在Outer Class内部,所以能访问Outer Class的private字段和方法。

观察Java编译器编译后的.class文件可以发现,Outer类被编译为Outer.class,而Inner类被编译为Outer$Inner.class

Anonymous Class#

还有一种定义Inner Class的方法,它不需要在Outer Class中明确地定义这个Class,而是在方法内部,通过匿名类(Anonymous Class)来定义。示例代码如下:

// Anonymous Class
public class Main {
public static void main(String[] args) {
Outer outer = new Outer("Nested");
outer.asyncHello();
}
}
class Outer {
private String name;
Outer(String name) {
this.name = name;
}
void asyncHello() {
Runnable r = new Runnable() {
@Override
public void run() {
System.out.println("Hello, " + Outer.this.name);
}
};
new Thread(r).start();
}
}

观察asyncHello()方法,我们在方法内部实例化了一个RunnableRunnable本身是接口,接口是不能实例化的,所以这里实际上是定义了一个实现了Runnable接口的匿名类,并且通过new实例化该匿名类,然后转型为Runnable。在定义匿名类的时候就必须实例化它,定义匿名类的写法如下:

Runnable r = new Runnable() {
// 实现必要的抽象方法...
};

匿名类和Inner Class一样,可以访问Outer Class的private字段和方法。之所以我们要定义匿名类,是因为在这里我们通常不关心类名,比直接定义Inner Class可以少写很多代码。

观察Java编译器编译后的.class文件可以发现,Outer类被编译为Outer.class,而匿名类被编译为Outer$1.class。如果有多个匿名类,Java编译器会将每个匿名类依次命名为Outer$1Outer$2Outer$3……

除了接口外,匿名类也完全可以继承自普通类。观察以下代码:

// Anonymous Class
import java.util.HashMap;
public class Main {
public static void main(String[] args) {
HashMap<String, String> map1 = new HashMap<>();
HashMap<String, String> map2 = new HashMap<>() {}; // 匿名类!
HashMap<String, String> map3 = new HashMap<>() {
{
put("A", "1");
put("B", "2");
}
};
System.out.println(map3.get("A"));
}
}

map1是一个普通的HashMap实例,但map2是一个匿名类实例,只是该匿名类继承自HashMapmap3也是一个继承自HashMap的匿名类实例,并且添加了static代码块来初始化数据。观察编译输出可发现Main$1.classMain$2.class两个匿名类文件。

Static Nested Class#

最后一种内部类和Inner Class类似,但是使用static修饰,称为静态内部类(Static Nested Class):

// Static Nested Class
public class Main {
public static void main(String[] args) {
Outer.StaticNested sn = new Outer.StaticNested();
sn.hello();
}
}
class Outer {
private static String NAME = "OUTER";
private String name;
Outer(String name) {
this.name = name;
}
static class StaticNested {
void hello() {
System.out.println("Hello, " + Outer.NAME);
}
}
}

static修饰的内部类和Inner Class有很大的不同,它不再依附于Outer的实例,而是一个完全独立的类,因此无法引用Outer.this,但它可以访问Outerprivate静态字段和静态方法。如果把StaticNested移到Outer之外,就失去了访问private的权限。

小结#

Java的内部类可分为Inner Class、Anonymous Class和Static Nested Class三种;

Inner Class和Anonymous Class本质上是相同的,都必须依附于Outer Class的实例,即隐含地持有Outer.this实例,并拥有Outer Class的private访问权限;

Static Nested Class是独立类,但拥有Outer Class的private访问权限。

3.1.12 classpath和jar#

在Java中,我们经常听到classpath这个东西。网上有很多关于“如何设置classpath”的文章,但大部分设置都不靠谱。

到底什么是classpath

classpath是JVM用到的一个环境变量,它用来指示JVM如何搜索class

因为Java是编译型语言,源码文件是.java,而编译后的.class文件才是真正可以被JVM执行的字节码。因此,JVM需要知道,如果要加载一个abc.xyz.Hello的类,应该去哪搜索对应的Hello.class文件。

所以,classpath就是一组目录的集合,它设置的搜索路径与操作系统相关。例如,在Windows系统上,用;分隔,带空格的目录用""括起来,可能长这样:

C:\work\project1\bin;C:\shared;"D:\My Documents\project1\bin"

在Linux系统上,用:分隔,可能长这样:

/usr/shared:/usr/local/bin:/home/liaoxuefeng/bin

现在我们假设classpath.;C:\work\project1\bin;C:\shared,当JVM在加载abc.xyz.Hello这个类时,会依次查找:

  • <当前目录>\abc\xyz\Hello.class
  • C:\work\project1\bin\abc\xyz\Hello.class
  • C:\shared\abc\xyz\Hello.class

注意到.代表当前目录。如果JVM在某个路径下找到了对应的class文件,就不再往后继续搜索。如果所有路径下都没有找到,就报错。

classpath的设定方法有两种:

在系统环境变量中设置classpath环境变量,不推荐;

在启动JVM时设置classpath变量,推荐。

我们强烈不推荐在系统环境变量中设置classpath,那样会污染整个系统环境。在启动JVM时设置classpath才是推荐的做法。实际上就是给java命令传入-classpath参数:

java -classpath .;C:\work\project1\bin;C:\shared abc.xyz.Hello

或者使用-cp的简写:

java -cp .;C:\work\project1\bin;C:\shared abc.xyz.Hello

没有设置系统环境变量,也没有传入-cp参数,那么JVM默认的classpath.,即当前目录:

java abc.xyz.Hello

上述命令告诉JVM只在当前目录搜索Hello.class

在IDE中运行Java程序,IDE自动传入的-cp参数是当前工程的bin目录和引入的jar包。

通常,我们在自己编写的class中,会引用Java核心库的class,例如,StringArrayList等。这些class应该上哪去找?

有很多“如何设置classpath”的文章会告诉你把JVM自带的rt.jar放入classpath,但事实上,根本不需要告诉JVM如何去Java核心库查找class,JVM怎么可能笨到连自己的核心库在哪都不知道!

注意
不要把任何Java核心库添加到classpath中!JVM根本不依赖classpath加载核心库!

更好的做法是,不要设置classpath!默认的当前目录.对于绝大多数情况都够用了。

假设我们有一个编译后的Hello.class,它的包名是com.example,当前目录是C:\work,那么,目录结构必须如下:

C:\work
└─ com
└─ example
└─ Hello.class

运行这个Hello.class必须在当前目录下使用如下命令:

C:\work> java -cp . com.example.Hello

JVM根据classpath设置的.在当前目录下查找com.example.Hello,即实际搜索文件必须位于com/example/Hello.class。如果指定的.class文件不存在,或者目录结构和包名对不上,均会报错。

jar包#

如果有很多.class文件,散落在各层目录中,肯定不便于管理。如果能把目录打一个包,变成一个文件,就方便多了。

jar包就是用来干这个事的,它可以把package组织的目录层级,以及各个目录下的所有文件(包括.class文件和其他文件)都打成一个jar文件,这样一来,无论是备份,还是发给客户,就简单多了。

jar包实际上就是一个zip格式的压缩文件,而jar包相当于目录。如果我们要执行一个jar包的class,就可以把jar包放到classpath中:

java -cp ./hello.jar abc.xyz.Hello

这样JVM会自动在hello.jar文件里去搜索某个类。

那么问题来了:如何创建jar包?

因为jar包就是zip包,所以,直接在资源管理器中,找到正确的目录,点击右键,在弹出的快捷菜单中选择“发送到”,“压缩(zipped)文件夹”,就制作了一个zip文件。然后,把后缀从.zip改为.jar,一个jar包就创建成功。

假设编译输出的目录结构是这样:

package_sample
└─ bin
├─ hong
│ └─ Person.class
│ ming
│ └─ Person.class
└─ mr
└─ jun
└─ Arrays.class

这里需要特别注意的是,jar包里的第一层目录,不能是bin,而应该是hongmingmr。如果在Windows的资源管理器中看,应该长这样:

hello.zip.ok

如果长这样:

hello.zip.invalid

上面的hello.zip包含有bin目录,说明打包打得有问题,JVM仍然无法从jar包中查找正确的class,原因是hong.Person必须按hong/Person.class存放,而不是bin/hong/Person.class

jar包还可以包含一个特殊的/META-INF/MANIFEST.MF文件,MANIFEST.MF是纯文本,可以指定Main-Class和其它信息。JVM会自动读取这个MANIFEST.MF文件,如果存在Main-Class,我们就不必在命令行指定启动的类名,而是用更方便的命令:

java -jar hello.jar

在大型项目中,不可能手动编写MANIFEST.MF文件,再手动创建jar包。Java社区提供了大量的开源构建工具,例如Maven,可以非常方便地创建jar包。

小结#

JVM通过环境变量classpath决定搜索class的路径和顺序;

强烈建议不要设置系统环境变量classpath,建议始终通过-cp命令传入;

jar包本质上是zip格式,相当于目录,可以包含很多.class文件,方便下载和使用;

MANIFEST.MF文件可以提供jar包的信息,如Main-Class,这样可以直接运行jar包。

3.1.13 class版本#

在Java开发中,许多童鞋经常被各种版本的JDK搞得晕头转向,本节我们就来详细讲解Java程序编译后的class文件版本问题。

我们通常说的Java 8,Java 11,Java 17,是指JDK的版本,也就是JVM的版本,更确切地说,就是java.exe这个程序的版本:

$ java -version
java version "17" 2021-09-14 LTS

而每个版本的JVM,它能执行的class文件版本也不同。例如,Java 11对应的class文件版本是55,而Java 17对应的class文件版本是61。

如果用Java 11编译一个Java程序,输出的class文件版本默认就是55,这个class既可以在Java 11上运行,也可以在Java 17上运行,因为Java 17支持的class文件版本是61,表示“最多支持到版本61”。

如果用Java 17编译一个Java程序,输出的class文件版本默认就是61,它可以在Java 17、Java 18上运行,但不可能在Java 11上运行,因为Java 11支持的class版本最多到55。如果使用低于Java 17的JVM运行,会得到一个UnsupportedClassVersionError,错误信息类似:

java.lang.UnsupportedClassVersionError: Xxx has been compiled by a more recent version of the Java Runtime...

只要看到UnsupportedClassVersionError就表示当前要加载的class文件版本超过了JVM的能力,必须使用更高版本的JVM才能运行。

打个比方,用Word 2013保存一个Word文件,这个文件也可以在Word 2016上打开。但反过来,用Word 2016保存一个Word文件,就无法使用Word 2013打开。

但是,且慢,用Word 2016也可以保存一个格式为Word 2013的文件,这样保存的Word文件就可以用低版本的Word 2013打开,但前提是保存时必须明确指定文件格式兼容Word 2013。

类似的,对应到JVM的class文件,我们也可以用Java 17编译一个Java程序,指定输出的class版本要兼容Java 11(即class版本55),这样编译生成的class文件就可以在Java >=11的环境中运行。

指定编译输出有两种方式,一种是在javac命令行中用参数--release设置:

$ javac --release 11 Main.java

参数--release 11表示源码兼容Java 11,编译的class输出版本为Java 11兼容,即class版本55。

第二种方式是用参数--source指定源码版本,用参数--target指定输出class版本:

$ javac --source 9 --target 11 Main.java

上述命令如果使用Java 17的JDK编译,它会把源码视为Java 9兼容版本,并输出class为Java 11兼容版本。注意--release参数和--source --target参数只能二选一,不能同时设置。

然而,指定版本如果低于当前的JDK版本,会有一些潜在的问题。例如,我们用Java 17编译Hello.java,参数设置--source 9--target 11

public class Hello {
public static void hello(String name) {
System.out.println("hello".indent(4));
}
}

用低于Java 11的JVM运行Hello会得到一个LinkageError,因为无法加载Hello.class文件,而用Java 11运行Hello会得到一个NoSuchMethodError,因为String.indent()方法是从Java 12才添加进来的,Java 11的String版本根本没有indent()方法。

注意
如果使用--release 11则会在编译时检查该方法是否在Java 11中存在。

因此,如果运行时的JVM版本是Java 11,则编译时也最好使用Java 11,而不是用高版本的JDK编译输出低版本的class。

如果使用javac编译时不指定任何版本参数,那么相当于使用--release 当前版本编译,即源码版本和输出版本均为当前版本。

在开发阶段,多个版本的JDK可以同时安装,当前使用的JDK版本可由JAVA_HOME环境变量切换。

源码版本#

在编写源代码的时候,我们通常会预设一个源码的版本。在编译的时候,如果用--source--release指定源码版本,则使用指定的源码版本检查语法。

例如,使用了lambda表达式的源码版本至少要为8才能编译,使用了var关键字的源码版本至少要为10才能编译,使用switch表达式的源码版本至少要为12才能编译,且12和13版本需要启用--enable-preview参数。

小结#

高版本的JDK可编译输出低版本兼容的class文件,但需注意,低版本的JDK可能不存在高版本JDK添加的类和方法,导致运行时报错。

运行时使用哪个JDK版本,编译时就尽量使用同一版本的JDK编译源码。

3.1.14 模块#

从Java 9开始,JDK又引入了模块(Module)。

什么是模块?这要从Java 9之前的版本说起。

我们知道,.class文件是JVM看到的最小可执行文件,而一个大型程序需要编写很多Class,并生成一堆.class文件,很不便于管理,所以,jar文件就是class文件的容器。

在Java 9之前,一个大型Java程序会生成自己的jar文件,同时引用依赖的第三方jar文件,而JVM自带的Java标准库,实际上也是以jar文件形式存放的,这个文件叫rt.jar,一共有60多M。

如果是自己开发的程序,除了一个自己的app.jar以外,还需要一堆第三方的jar包,运行一个Java程序,一般来说,命令行写这样:

java -cp app.jar:a.jar:b.jar:c.jar com.liaoxuefeng.sample.Main
注意
JVM自带的标准库rt.jar不要写到classpath中,写了反而会干扰JVM的正常运行。

如果漏写了某个运行时需要用到的jar,那么在运行期极有可能抛出ClassNotFoundException

所以,jar只是用于存放class的容器,它并不关心class之间的依赖。

从Java 9开始引入的模块,主要是为了解决“依赖”这个问题。如果a.jar必须依赖另一个b.jar才能运行,那我们应该给a.jar加点说明啥的,让程序在编译和运行的时候能自动定位到b.jar,这种自带“依赖关系”的class容器就是模块。

为了表明Java模块化的决心,从Java 9开始,原有的Java标准库已经由一个单一巨大的rt.jar分拆成了几十个模块,这些模块以.jmod扩展名标识,可以在$JAVA_HOME/jmods目录下找到它们:

  • java.base.jmod
  • java.compiler.jmod
  • java.datatransfer.jmod
  • java.desktop.jmod

这些.jmod文件每一个都是一个模块,模块名就是文件名。例如:模块java.base对应的文件就是java.base.jmod。模块之间的依赖关系已经被写入到模块内的module-info.class文件了。所有的模块都直接或间接地依赖java.base模块,只有java.base模块不依赖任何模块,它可以被看作是“根模块”,好比所有的类都是从Object直接或间接继承而来。

把一堆class封装为jar仅仅是一个打包的过程,而把一堆class封装为模块则不但需要打包,还需要写入依赖关系,并且还可以包含二进制代码(通常是JNI扩展)。此外,模块支持多版本,即在同一个模块中可以为不同的JVM提供不同的版本。

编写模块#

那么,我们应该如何编写模块呢?还是以具体的例子来说。首先,创建模块和原有的创建Java项目是完全一样的,以oop-module工程为例,它的目录结构如下:

oop-module
├── bin
├── build.sh
└── src
├── com
│   └── itranswarp
│   └── sample
│   ├── Greeting.java
│   └── Main.java
└── module-info.java

其中,bin目录存放编译后的class文件,src目录存放源码,按包名的目录结构存放,仅仅在src目录下多了一个module-info.java这个文件,这就是模块的描述文件。在这个模块中,它长这样:

module hello.world {
requires java.base; // 可不写,任何模块都会自动引入java.base
requires java.xml;
}

其中,module是关键字,后面的hello.world是模块的名称,它的命名规范与包一致。花括号的requires xxx;表示这个模块需要引用的其他模块名。除了java.base可以被自动引入外,这里我们引入了一个java.xml的模块。

当我们使用模块声明了依赖关系后,才能使用引入的模块。例如,Main.java代码如下:

package com.itranswarp.sample;
// 必须引入java.xml模块后才能使用其中的类:
import javax.xml.XMLConstants;
public class Main {
public static void main(String[] args) {
Greeting g = new Greeting();
System.out.println(g.hello(XMLConstants.XML_NS_PREFIX));
}
}

如果把requires java.xml;module-info.java中去掉,编译将报错。可见,模块的重要作用就是声明依赖关系。

下面,我们用JDK提供的命令行工具来编译并创建模块。

首先,我们把工作目录切换到oop-module,在当前目录下编译所有的.java文件,并存放到bin目录下,命令如下:

$ javac -d bin src/module-info.java src/com/itranswarp/sample/*.java

如果编译成功,现在项目结构如下:

oop-module
├── bin
│   ├── com
│   │   └── itranswarp
│   │   └── sample
│   │   ├── Greeting.class
│   │   └── Main.class
│   └── module-info.class
└── src
├── com
│   └── itranswarp
│   └── sample
│   ├── Greeting.java
│   └── Main.java
└── module-info.java

注意到src目录下的module-info.java被编译到bin目录下的module-info.class

下一步,我们需要把bin目录下的所有class文件先打包成jar,在打包的时候,注意传入--main-class参数,让这个jar包能自己定位main方法所在的类:

$ jar --create --file hello.jar --main-class com.itranswarp.sample.Main -C bin .

现在我们就在当前目录下得到了hello.jar这个jar包,它和普通jar包并无区别,可以直接使用命令java -jar hello.jar来运行它。但是我们的目标是创建模块,所以,继续使用JDK自带的jmod命令把一个jar包转换成模块:

$ jmod create --class-path hello.jar hello.jmod

于是,在当前目录下我们又得到了hello.jmod这个模块文件,这就是最后打包出来的传说中的模块!

运行模块#

要运行一个jar,我们使用java -jar xxx.jar命令。要运行一个模块,我们只需要指定模块名。试试:

$ java --module-path hello.jmod --module hello.world

结果是一个错误:

Error occurred during initialization of boot layer
java.lang.module.FindException: JMOD format not supported at execution time: hello.jmod

原因是.jmod不能被放入--module-path中。换成.jar就没问题了:

$ java --module-path hello.jar --module hello.world
Hello, xml!

那我们辛辛苦苦创建的hello.jmod有什么用?答案是我们可以用它来打包JRE。

打包JRE#

前面讲了,为了支持模块化,Java 9首先带头把自己的一个巨大无比的rt.jar拆成了几十个.jmod模块,原因就是,运行Java程序的时候,实际上我们用到的JDK模块,并没有那么多。不需要的模块,完全可以删除。

过去发布一个Java应用程序,要运行它,必须下载一个完整的JRE,再运行jar包。而完整的JRE块头很大,有100多M。怎么给JRE瘦身呢?

现在,JRE自身的标准库已经分拆成了模块,只需要带上程序用到的模块,其他的模块就可以被裁剪掉。怎么裁剪JRE呢?并不是说把系统安装的JRE给删掉部分模块,而是“复制”一份JRE,但只带上用到的模块。为此,JDK提供了jlink命令来干这件事。命令如下:

$ jlink --module-path hello.jmod --add-modules java.base,java.xml,hello.world --output jre/

我们在--module-path参数指定了我们自己的模块hello.jmod,然后,在--add-modules参数中指定了我们用到的3个模块java.basejava.xmlhello.world,用,分隔。最后,在--output参数指定输出目录。

现在,在当前目录下,我们可以找到jre目录,这是一个完整的并且带有我们自己hello.jmod模块的JRE。试试直接运行这个JRE:

$ jre/bin/java --module hello.world
Hello, xml!

要分发我们自己的Java应用程序,只需要把这个jre目录打个包给对方发过去,对方直接运行上述命令即可,既不用下载安装JDK,也不用知道如何配置我们自己的模块,极大地方便了分发和部署。

访问权限#

前面我们讲过,Java的class访问权限分为public、protected、private和默认的包访问权限。引入模块后,这些访问权限的规则就要稍微做些调整。

确切地说,class的这些访问权限只在一个模块内有效,模块和模块之间,例如,a模块要访问b模块的某个class,必要条件是b模块明确地导出了可以访问的包。

举个例子:我们编写的模块hello.world用到了模块java.xml的一个类javax.xml.XMLConstants,我们之所以能直接使用这个类,是因为模块java.xmlmodule-info.java中声明了若干导出:

module java.xml {
exports java.xml;
exports javax.xml.catalog;
exports javax.xml.datatype;
...
}

只有它声明的导出的包,外部代码才被允许访问。换句话说,如果外部代码想要访问我们的hello.world模块中的com.itranswarp.sample.Greeting类,我们必须将其导出:

module hello.world {
exports com.itranswarp.sample;
requires java.base;
requires java.xml;
}

因此,模块进一步隔离了代码的访问权限。

练习#

请下载并练习如何打包模块和JRE。

下载练习

小结#

Java 9引入的模块目的是为了管理依赖;

使用模块可以按需打包JRE;

使用模块对类的访问权限有了进一步限制。

3.2 Java核心类#

本节我们将介绍Java的核心类,包括:

  • 字符串
  • StringBuilder
  • StringJoiner
  • 包装类型
  • JavaBean
  • 枚举
  • 常用工具类

java-lang

3.2.1 字符串和编码#

String#

在Java中,String是一个引用类型,它本身也是一个class。但是,Java编译器对String有特殊处理,即可以直接用"..."来表示一个字符串:

String s1 = "Hello!";

实际上字符串在String内部是通过一个char[]数组表示的,因此,按下面的写法也是可以的:

String s2 = new String(new char[] {'H', 'e', 'l', 'l', 'o', '!'});

因为String太常用了,所以Java提供了"..."这种字符串字面量表示方法。

Java字符串的一个重要特点就是字符串不可变。这种不可变性是通过内部的private final char[]字段,以及没有任何修改char[]的方法实现的。

我们来看一个例子:

// String
public class Main {
public static void main(String[] args) {
String s = "Hello";
System.out.println(s);
s = s.toUpperCase();
System.out.println(s);
}
}

根据上面代码的输出,试解释字符串内容是否改变。

字符串比较#

当我们想要比较两个字符串是否相同时,要特别注意,我们实际上是想比较字符串的内容是否相同。必须使用equals()方法而不能用==

我们看下面的例子:

// String
public class Main {
public static void main(String[] args) {
String s1 = "hello";
String s2 = "hello";
System.out.println(s1 == s2);
System.out.println(s1.equals(s2));
}
}

从表面上看,两个字符串用==equals()比较都为true,但实际上那只是Java编译器在编译期,会自动把所有相同的字符串当作一个对象放入常量池,自然s1s2的引用就是相同的。

所以,这种==比较返回true纯属巧合。换一种写法,==比较就会失败:

// String
public class Main {
public static void main(String[] args) {
String s1 = "hello";
String s2 = "HELLO".toLowerCase();
System.out.println(s1 == s2);
System.out.println(s1.equals(s2));
}
}

结论:两个字符串比较,必须总是使用equals()方法。

要忽略大小写比较,使用equalsIgnoreCase()方法。

String类还提供了多种方法来搜索子串、提取子串。常用的方法有:

// 是否包含子串:
"Hello".contains("ll"); // true

注意到contains()方法的参数是CharSequence而不是String,因为CharSequenceString实现的一个接口。

搜索子串的更多的例子:

"Hello".indexOf("l"); // 2
"Hello".lastIndexOf("l"); // 3
"Hello".startsWith("He"); // true
"Hello".endsWith("lo"); // true

提取子串的例子:

"Hello".substring(2); // "llo"
"Hello".substring(2, 4); "ll"

注意索引号是从0开始的。

去除首尾空白字符#

使用trim()方法可以移除字符串首尾空白字符。空白字符包括空格,\t\r\n

" \tHello\r\n ".trim(); // "Hello"

注意:trim()并没有改变字符串的内容,而是返回了一个新字符串。

另一个strip()方法也可以移除字符串首尾空白字符。它和trim()不同的是,类似中文的空格字符\u3000也会被移除:

"\u3000Hello\u3000".strip(); // "Hello"
" Hello ".stripLeading(); // "Hello "
" Hello ".stripTrailing(); // " Hello"

String还提供了isEmpty()isBlank()来判断字符串是否为空和空白字符串:

"".isEmpty(); // true,因为字符串长度为0
" ".isEmpty(); // false,因为字符串长度不为0
" \n".isBlank(); // true,因为只包含空白字符
" Hello ".isBlank(); // false,因为包含非空白字符
替换子串#

要在字符串中替换子串,有两种方法。一种是根据字符或字符串替换:

String s = "hello";
s.replace('l', 'w'); // "hewwo",所有字符'l'被替换为'w'
s.replace("ll", "~~"); // "he~~o",所有子串"ll"被替换为"~~"

另一种是通过正则表达式替换:

String s = "A,,B;C ,D";
s.replaceAll("[\\,\\;\\s]+", ","); // "A,B,C,D"

上面的代码通过正则表达式,把匹配的子串统一替换为","。关于正则表达式的用法我们会在后面详细讲解。

分割字符串#

要分割字符串,使用split()方法,并且传入的也是正则表达式:

String s = "A,B,C,D";
String[] ss = s.split("\\,"); // {"A", "B", "C", "D"}
拼接字符串#

拼接字符串使用静态方法join(),它用指定的字符串连接字符串数组:

String[] arr = {"A", "B", "C"};
String s = String.join("***", arr); // "A***B***C"
格式化字符串#

字符串提供了formatted()方法和format()静态方法,可以传入其他参数,替换占位符,然后生成新的字符串:

// String
public class Main {
public static void main(String[] args) {
String s = "Hi %s, your score is %d!";
System.out.println(s.formatted("Alice", 80));
System.out.println(String.format("Hi %s, your score is %.2f!", "Bob", 59.5));
}
}

有几个占位符,后面就传入几个参数。参数类型要和占位符一致。我们经常用这个方法来格式化信息。常用的占位符有:

  • %s:显示字符串;
  • %d:显示整数;
  • %x:显示十六进制整数;
  • %f:显示浮点数。

占位符还可以带格式,例如%.2f表示显示两位小数。如果你不确定用啥占位符,那就始终用%s,因为%s可以显示任何数据类型。要查看完整的格式化语法,请参考JDK文档

类型转换#

要把任意基本类型或引用类型转换为字符串,可以使用静态方法valueOf()。这是一个重载方法,编译器会根据参数自动选择合适的方法:

String.valueOf(123); // "123"
String.valueOf(45.67); // "45.67"
String.valueOf(true); // "true"
String.valueOf(new Object()); // 类似java.lang.Object@636be97c

要把字符串转换为其他类型,就需要根据情况。例如,把字符串转换为int类型:

int n1 = Integer.parseInt("123"); // 123
int n2 = Integer.parseInt("ff", 16); // 按十六进制转换,255

把字符串转换为boolean类型:

boolean b1 = Boolean.parseBoolean("true"); // true
boolean b2 = Boolean.parseBoolean("FALSE"); // false

要特别注意,Integer有个getInteger(String)方法,它不是将字符串转换为int,而是把该字符串对应的系统变量转换为Integer

Integer.getInteger("java.version"); // 版本号,11
转换为char[]#

Stringchar[]类型可以互相转换,方法是:

char[] cs = "Hello".toCharArray(); // String -> char[]
String s = new String(cs); // char[] -> String

如果修改了char[]数组,String并不会改变:

// String <-> char[]
public class Main {
public static void main(String[] args) {
char[] cs = "Hello".toCharArray();
String s = new String(cs);
System.out.println(s);
cs[0] = 'X';
System.out.println(s);
}
}

这是因为通过new String(char[])创建新的String实例时,它并不会直接引用传入的char[]数组,而是会复制一份,所以,修改外部的char[]数组不会影响String实例内部的char[]数组,因为这是两个不同的数组。

String的不变性设计可以看出,如果传入的对象有可能改变,我们需要复制而不是直接引用。

例如,下面的代码设计了一个Score类保存一组学生的成绩:

// int[]
import java.util.Arrays;
public class Main {
public static void main(String[] args) {
int[] scores = new int[] { 88, 77, 51, 66 };
Score s = new Score(scores);
s.printScores();
scores[2] = 99;
s.printScores();
}
}
class Score {
private int[] scores;
public Score(int[] scores) {
this.scores = scores;
}
public void printScores() {
System.out.println(Arrays.toString(scores));
}
}

观察两次输出,由于Score内部直接引用了外部传入的int[]数组,这会造成外部代码对int[]数组的修改,影响到Score类的字段。如果外部代码不可信,这就会造成安全隐患。

请修复Score的构造方法,使得外部代码对数组的修改不影响Score实例的int[]字段。

字符编码#

在早期的计算机系统中,为了给字符编码,美国国家标准学会(American National Standard Institute:ANSI)制定了一套英文字母、数字和常用符号的编码,它占用一个字节,编码范围从0127,最高位始终为0,称为ASCII编码。例如,字符'A'的编码是0x41,字符'1'的编码是0x31

如果要把汉字也纳入计算机编码,很显然一个字节是不够的。GB2312标准使用两个字节表示一个汉字,其中第一个字节的最高位始终为1,以便和ASCII编码区分开。例如,汉字'中'GB2312编码是0xd6d0

类似的,日文有Shift_JIS编码,韩文有EUC-KR编码,这些编码因为标准不统一,同时使用,就会产生冲突。

为了统一全球所有语言的编码,全球统一码联盟发布了Unicode编码,它把世界上主要语言都纳入同一个编码,这样,中文、日文、韩文和其他语言就不会冲突。

Unicode编码需要两个或者更多字节表示,我们可以比较中英文字符在ASCIIGB2312Unicode的编码:

英文字符'A'ASCII编码和Unicode编码:

┌────┐
ASCII: │ 41 │
└────┘
┌────┬────┐
Unicode: │ 00 │ 41 │
└────┴────┘

英文字符的Unicode编码就是简单地在前面添加一个00字节。

中文字符'中'GB2312编码和Unicode编码:

┌────┬────┐
GB2312: │ d6 │ d0 │
└────┴────┘
┌────┬────┐
Unicode: │ 4e │ 2d │
└────┴────┘

那我们经常使用的UTF-8又是什么编码呢?因为英文字符的Unicode编码高字节总是00,包含大量英文的文本会浪费空间,所以,出现了UTF-8编码,它是一种变长编码,用来把固定长度的Unicode编码变成1~4字节的变长编码。通过UTF-8编码,英文字符'A'UTF-8编码变为0x41,正好和ASCII码一致,而中文'中'UTF-8编码为3字节0xe4b8ad

UTF-8编码的另一个好处是容错能力强。如果传输过程中某些字符出错,不会影响后续字符,因为UTF-8编码依靠高字节位来确定一个字符究竟是几个字节,它经常用来作为传输编码。

在Java中,char类型实际上就是两个字节的Unicode编码。如果我们要手动把字符串转换成其他编码,可以这样做:

byte[] b1 = "Hello".getBytes(); // 按系统默认编码转换,不推荐
byte[] b2 = "Hello".getBytes("UTF-8"); // 按UTF-8编码转换
byte[] b2 = "Hello".getBytes("GBK"); // 按GBK编码转换
byte[] b3 = "Hello".getBytes(StandardCharsets.UTF_8); // 按UTF-8编码转换

注意:转换编码后,就不再是char类型,而是byte类型表示的数组。

如果要把已知编码的byte[]转换为String,可以这样做:

byte[] b = ...
String s1 = new String(b, "GBK"); // 按GBK转换
String s2 = new String(b, StandardCharsets.UTF_8); // 按UTF-8转换

始终牢记:Java的Stringchar在内存中总是以Unicode编码表示。

延伸阅读#

对于不同版本的JDK,String类在内存中有不同的优化方式。具体来说,早期JDK版本的String总是以char[]存储,它的定义如下:

public final class String {
private final char[] value;
private final int offset;
private final int count;
}

而较新的JDK版本的String则以byte[]存储:如果String仅包含ASCII字符,则每个byte存储一个字符,否则,每两个byte存储一个字符,这样做的目的是为了节省内存,因为大量的长度较短的String通常仅包含ASCII字符:

public final class String {
private final byte[] value;
private final byte coder; // 0 = LATIN1, 1 = UTF16

对于使用者来说,String内部的优化不影响任何已有代码,因为它的public方法签名是不变的。

小结#

Java字符串String是不可变对象;

字符串操作不改变原字符串内容,而是返回新字符串;

常用的字符串操作:提取子串、查找、替换、大小写转换等;

Java使用Unicode编码表示Stringchar

转换编码就是将Stringbyte[]转换,需要指定编码;

转换为byte[]时,始终优先考虑UTF-8编码。

3.2.2 StringBuilder#

Java编译器对String做了特殊处理,使得我们可以直接用+拼接字符串。

考察下面的循环代码:

String s = "";
for (int i = 0; i < 1000; i++) {
s = s + "," + i;
}

虽然可以直接拼接字符串,但是,在循环中,每次循环都会创建新的字符串对象,然后扔掉旧的字符串。这样,绝大部分字符串都是临时对象,不但浪费内存,还会影响GC效率。

为了能高效拼接字符串,Java标准库提供了StringBuilder,它是一个可变对象,可以预分配缓冲区,这样,往StringBuilder中新增字符时,不会创建新的临时对象:

StringBuilder sb = new StringBuilder(1024);
for (int i = 0; i < 1000; i++) {
sb.append(',');
sb.append(i);
}
String s = sb.toString();

StringBuilder还可以进行链式操作:

// 链式操作
public class Main {
public static void main(String[] args) {
var sb = new StringBuilder(1024);
sb.append("Mr ")
.append("Bob")
.append("!")
.insert(0, "Hello, ");
System.out.println(sb.toString());
}
}

如果我们查看StringBuilder的源码,可以发现,进行链式操作的关键是,定义的append()方法会返回this,这样,就可以不断调用自身的其他方法。

仿照StringBuilder,我们也可以设计支持链式操作的类。例如,一个可以不断增加的计数器:

// 链式操作
public class Main {
public static void main(String[] args) {
Adder adder = new Adder();
adder.add(3)
.add(5)
.inc()
.add(10);
System.out.println(adder.value());
}
}
class Adder {
private int sum = 0;
public Adder add(int n) {
sum += n;
return this;
}
public Adder inc() {
sum ++;
return this;
}
public int value() {
return sum;
}
}

注意:对于普通的字符串+操作,并不需要我们将其改写为StringBuilder,因为Java编译器在编译时就自动把多个连续的+操作编码为StringConcatFactory的操作。在运行期,StringConcatFactory会自动把字符串连接操作优化为数组复制或者StringBuilder操作。

你可能还听说过StringBuffer,这是Java早期的一个StringBuilder的线程安全版本,它通过同步来保证多个线程操作StringBuffer也是安全的,但是同步会带来执行速度的下降。

StringBuilderStringBuffer接口完全相同,现在完全没有必要使用StringBuffer

练习#

请使用StringBuilder构造一个INSERT语句:

public class Main {
public static void main(String[] args) {
String[] fields = { "name", "position", "salary" };
String table = "employee";
String insert = buildInsertSql(table, fields);
System.out.println(insert);
String s = "INSERT INTO employee (name, position, salary) VALUES (?, ?, ?)";
System.out.println(s.equals(insert) ? "测试成功" : "测试失败");
}
static String buildInsertSql(String table, String[] fields) {
// TODO:
return "";
}
}

下载练习

小结#

StringBuilder是可变对象,用来高效拼接字符串;

StringBuilder可以支持链式操作,实现链式操作的关键是返回实例本身;

StringBufferStringBuilder的线程安全版本,现在很少使用。

3.2.3 StringJoiner#

要高效拼接字符串,应该使用StringBuilder

很多时候,我们拼接的字符串像这样:

// 输出: Hello Bob, Alice, Grace!
public class Main {
public static void main(String[] args) {
String[] names = {"Bob", "Alice", "Grace"};
var sb = new StringBuilder();
sb.append("Hello ");
for (String name : names) {
sb.append(name).append(", ");
}
// 注意去掉最后的", ":
sb.delete(sb.length() - 2, sb.length());
sb.append("!");
System.out.println(sb.toString());
}
}

类似用分隔符拼接数组的需求很常见,所以Java标准库还提供了一个StringJoiner来干这个事:

import java.util.StringJoiner;
public class Main {
public static void main(String[] args) {
String[] names = {"Bob", "Alice", "Grace"};
var sj = new StringJoiner(", ");
for (String name : names) {
sj.add(name);
}
System.out.println(sj.toString());
}
}

慢着!用StringJoiner的结果少了前面的"Hello "和结尾的"!"!遇到这种情况,需要给StringJoiner指定“开头”和“结尾”:

import java.util.StringJoiner;
public class Main {
public static void main(String[] args) {
String[] names = {"Bob", "Alice", "Grace"};
var sj = new StringJoiner(", ", "Hello ", "!");
for (String name : names) {
sj.add(name);
}
System.out.println(sj.toString());
}
}
String.join()#

String还提供了一个静态方法join(),这个方法在内部使用了StringJoiner来拼接字符串,在不需要指定“开头”和“结尾”的时候,用String.join()更方便:

String[] names = {"Bob", "Alice", "Grace"};
var s = String.join(", ", names);
练习#

请使用StringJoiner构造一个SELECT语句:

import java.util.StringJoiner;
public class Main {
public static void main(String[] args) {
String[] fields = { "name", "position", "salary" };
String table = "employee";
String select = buildSelectSql(table, fields);
System.out.println(select);
System.out.println("SELECT name, position, salary FROM employee".equals(select) ? "测试成功" : "测试失败");
}
static String buildSelectSql(String table, String[] fields) {
// TODO:
return "";
}
}

下载练习

小结#

用指定分隔符拼接字符串数组时,使用StringJoiner或者String.join()更方便;

StringJoiner拼接字符串时,还可以额外附加一个“开头”和“结尾”。

3.2.4 包装类型#

我们已经知道,Java的数据类型分两种:

  • 基本类型:byteshortintlongbooleanfloatdoublechar
  • 引用类型:所有classinterface类型。

引用类型可以赋值为null,表示空,但基本类型不能赋值为null

String s = null;
int n = null; // compile error!

那么,如何把一个基本类型视为对象(引用类型)?

比如,想要把int基本类型变成一个引用类型,我们可以定义一个Integer类,它只包含一个实例字段int,这样,Integer类就可以视为int的包装类(Wrapper Class):

public class Integer {
private int value;
public Integer(int value) {
this.value = value;
}
public int intValue() {
return this.value;
}
}

定义好了Integer类,我们就可以把intInteger互相转换:

Integer n = null;
Integer n2 = new Integer(99);
int n3 = n2.intValue();

实际上,因为包装类型非常有用,Java核心库为每种基本类型都提供了对应的包装类型:

基本类型对应的引用类型
booleanjava.lang.Boolean
bytejava.lang.Byte
shortjava.lang.Short
intjava.lang.Integer
longjava.lang.Long
floatjava.lang.Float
doublejava.lang.Double
charjava.lang.Character

我们可以直接使用,并不需要自己去定义:

// Integer:
public class Main {
public static void main(String[] args) {
int i = 100;
// 通过new操作符创建Integer实例(不推荐使用,会有编译警告):
Integer n1 = new Integer(i);
// 通过静态方法valueOf(int)创建Integer实例:
Integer n2 = Integer.valueOf(i);
// 通过静态方法valueOf(String)创建Integer实例:
Integer n3 = Integer.valueOf("100");
System.out.println(n3.intValue());
}
}
Auto Boxing#

因为intInteger可以互相转换:

int i = 100;
Integer n = Integer.valueOf(i);
int x = n.intValue();

所以,Java编译器可以帮助我们自动在intInteger之间转型:

Integer n = 100; // 编译器自动使用Integer.valueOf(int)
int x = n; // 编译器自动使用Integer.intValue()

这种直接把int变为Integer的赋值写法,称为自动装箱(Auto Boxing),反过来,把Integer变为int的赋值写法,称为自动拆箱(Auto Unboxing)。

注意
自动装箱和自动拆箱只发生在编译阶段,目的是为了少写代码。

装箱和拆箱会影响代码的执行效率,因为编译后的class代码是严格区分基本类型和引用类型的。并且,自动拆箱执行时可能会报NullPointerException

// NullPointerException
public class Main {
public static void main(String[] args) {
Integer n = null;
int i = n;
}
}
不变类#

所有的包装类型都是不变类。我们查看Integer的源码可知,它的核心代码如下:

public final class Integer {
private final int value;
}

因此,一旦创建了Integer对象,该对象就是不变的。

对两个Integer实例进行比较要特别注意:绝对不能用==比较,因为Integer是引用类型,必须使用equals()比较:

// == or equals?
public class Main {
public static void main(String[] args) {
Integer x = 127;
Integer y = 127;
Integer m = 99999;
Integer n = 99999;
System.out.println("x == y: " + (x==y)); // true
System.out.println("m == n: " + (m==n)); // false
System.out.println("x.equals(y): " + x.equals(y)); // true
System.out.println("m.equals(n): " + m.equals(n)); // true
}
}

仔细观察结果的童鞋可以发现,==比较,较小的两个相同的Integer返回true,较大的两个相同的Integer返回false,这是因为Integer是不变类,编译器把Integer x = 127;自动变为Integer x = Integer.valueOf(127);,为了节省内存,Integer.valueOf()对于较小的数,始终返回相同的实例,因此,==比较“恰好”为true,但我们绝不能因为Java标准库的Integer内部有缓存优化就用==比较,必须用equals()方法比较两个Integer

最佳实践
按照语义编程,而不是针对特定的底层实现去“优化”。

因为Integer.valueOf()可能始终返回同一个Integer实例,因此,在我们自己创建Integer的时候,以下两种方法:

  • 方法1:Integer n = new Integer(100);
  • 方法2:Integer n = Integer.valueOf(100);

方法2更好,因为方法1总是创建新的Integer实例,方法2把内部优化留给Integer的实现者去做,即使在当前版本没有优化,也有可能在下一个版本进行优化。

我们把能创建“新”对象的静态方法称为静态工厂方法。Integer.valueOf()就是静态工厂方法,它尽可能地返回缓存的实例以节省内存。

最佳实践
创建新对象时,优先选用静态工厂方法而不是new操作符。

如果我们考察Byte.valueOf()方法的源码,可以看到,标准库返回的Byte实例全部是缓存实例,但调用者并不关心静态工厂方法以何种方式创建新实例还是直接返回缓存的实例。

进制转换#

Integer类本身还提供了大量方法,例如,最常用的静态方法parseInt()可以把字符串解析成一个整数:

int x1 = Integer.parseInt("100"); // 100
int x2 = Integer.parseInt("100", 16); // 256,因为按16进制解析

Integer还可以把整数格式化为指定进制的字符串:

// Integer:
public class Main {
public static void main(String[] args) {
System.out.println(Integer.toString(100)); // "100",表示为10进制
System.out.println(Integer.toString(100, 36)); // "2s",表示为36进制
System.out.println(Integer.toHexString(100)); // "64",表示为16进制
System.out.println(Integer.toOctalString(100)); // "144",表示为8进制
System.out.println(Integer.toBinaryString(100)); // "1100100",表示为2进制
}
}

注意:上述方法的输出都是String,在计算机内存中,只用二进制表示,不存在十进制或十六进制的表示方法。int n = 100在内存中总是以4字节的二进制表示:

┌────────┬────────┬────────┬────────┐
│00000000│00000000│00000000│01100100│
└────────┴────────┴────────┴────────┘

我们经常使用的System.out.println(n);是依靠核心库自动把整数格式化为10进制输出并显示在屏幕上,使用Integer.toHexString(n)则通过核心库自动把整数格式化为16进制。

这里我们注意到程序设计的一个重要原则:数据的存储和显示要分离。

Java的包装类型还定义了一些有用的静态变量

// boolean只有两个值true/false,其包装类型只需要引用Boolean提供的静态字段:
Boolean t = Boolean.TRUE;
Boolean f = Boolean.FALSE;
// int可表示的最大/最小值:
int max = Integer.MAX_VALUE; // 2147483647
int min = Integer.MIN_VALUE; // -2147483648
// long类型占用的bit和byte数量:
int sizeOfLong = Long.SIZE; // 64 (bits)
int bytesOfLong = Long.BYTES; // 8 (bytes)

最后,所有的整数和浮点数的包装类型都继承自Number,因此,可以非常方便地直接通过包装类型获取各种基本类型:

// 向上转型为Number:
Number num = new Integer(999);
// 获取byte, int, long, float, double:
byte b = num.byteValue();
int n = num.intValue();
long ln = num.longValue();
float f = num.floatValue();
double d = num.doubleValue();
处理无符号整型#

在Java中,并没有无符号整型(Unsigned)的基本数据类型。byteshortintlong都是带符号整型,最高位是符号位。而C语言则提供了CPU支持的全部数据类型,包括无符号整型。无符号整型和有符号整型的转换在Java中就需要借助包装类型的静态方法完成。

例如,byte是有符号整型,范围是-128+127,但如果把byte看作无符号整型,它的范围就是0255。我们把一个负的byte按无符号整型转换为int

// Byte
public class Main {
public static void main(String[] args) {
byte x = -1;
byte y = 127;
System.out.println(Byte.toUnsignedInt(x)); // 255
System.out.println(Byte.toUnsignedInt(y)); // 127
}
}

因为byte-1的二进制表示是11111111,以无符号整型转换后的int就是255

类似的,可以把一个short按unsigned转换为int,把一个int按unsigned转换为long

小结#

Java核心库提供的包装类型可以把基本类型包装为class

自动装箱和自动拆箱都是在编译期完成的(JDK>=1.5);

装箱和拆箱会影响执行效率,且拆箱时可能发生NullPointerException

包装类型的比较必须使用equals()

整数和浮点数的包装类型都继承自Number

包装类型提供了大量实用方法。

3.2.5 JavaBean#

在Java中,有很多class的定义都符合这样的规范:

  • 若干private实例字段;
  • 通过public方法来读写实例字段。

例如:

public class Person {
private String name;
private int age;
public String getName() { return this.name; }
public void setName(String name) { this.name = name; }
public int getAge() { return this.age; }
public void setAge(int age) { this.age = age; }
}

如果读写方法符合以下这种命名规范:

// 读方法:
public Type getXyz()
// 写方法:
public void setXyz(Type value)

那么这种class被称为JavaBean

java-bean

上面的字段是xyz,那么读写方法名分别以getset开头,并且后接大写字母开头的字段名Xyz,因此两个读写方法名分别是getXyz()setXyz()

boolean字段比较特殊,它的读方法一般命名为isXyz()

// 读方法:
public boolean isChild()
// 写方法:
public void setChild(boolean value)

我们通常把一组对应的读方法(getter)和写方法(setter)称为属性(property)。例如,name属性:

  • 对应的读方法是String getName()
  • 对应的写方法是setName(String)

只有getter的属性称为只读属性(read-only),例如,定义一个age只读属性:

  • 对应的读方法是int getAge()
  • 无对应的写方法setAge(int)

类似的,只有setter的属性称为只写属性(write-only)。

很明显,只读属性很常见,只写属性不常见。

属性只需要定义gettersetter方法,不一定需要对应的字段。例如,child只读属性定义如下:

public class Person {
private String name;
private int age;
public String getName() { return this.name; }
public void setName(String name) { this.name = name; }
public int getAge() { return this.age; }
public void setAge(int age) { this.age = age; }
public boolean isChild() {
return age <= 6;
}
}

可以看出,gettersetter也是一种数据封装的方法。

JavaBean的作用#

JavaBean主要用来传递数据,即把一组数据组合成一个JavaBean便于传输。此外,JavaBean可以方便地被IDE工具分析,生成读写属性的代码,主要用在图形界面的可视化设计中。

通过IDE,可以快速生成gettersetter。例如,在Eclipse中,先输入以下代码:

public class Person {
private String name;
private int age;
}

然后,点击右键,在弹出的菜单中选择“Source”,“Generate Getters and Setters”,在弹出的对话框中选中需要生成gettersetter方法的字段,点击确定即可由IDE自动完成所有方法代码。

枚举JavaBean属性#

要枚举一个JavaBean的所有属性,可以直接使用Java核心库提供的Introspector

import java.beans.*;
public class Main {
public static void main(String[] args) throws Exception {
BeanInfo info = Introspector.getBeanInfo(Person.class);
for (PropertyDescriptor pd : info.getPropertyDescriptors()) {
System.out.println(pd.getName());
System.out.println(" " + pd.getReadMethod());
System.out.println(" " + pd.getWriteMethod());
}
}
}
class Person {
private String name;
private int age;
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public int getAge() {
return age;
}
public void setAge(int age) {
this.age = age;
}
}

运行上述代码,可以列出所有的属性,以及对应的读写方法。注意class属性是从Object继承的getClass()方法带来的。

小结#

JavaBean是一种符合命名规范的class,它通过gettersetter来定义属性;

属性是一种通用的叫法,并非Java语法规定;

可以利用IDE快速生成gettersetter

使用Introspector.getBeanInfo()可以获取属性列表。

3.2.6 枚举类#

在Java中,我们可以通过static final来定义常量。例如,我们希望定义周一到周日这7个常量,可以用7个不同的int表示:

public class Weekday {
public static final int SUN = 0;
public static final int MON = 1;
public static final int TUE = 2;
public static final int WED = 3;
public static final int THU = 4;
public static final int FRI = 5;
public static final int SAT = 6;
}

使用常量的时候,可以这么引用:

if (day == Weekday.SAT || day == Weekday.SUN) {
// TODO: work at home
}

也可以把常量定义为字符串类型,例如,定义3种颜色的常量:

public class Color {
public static final String RED = "r";
public static final String GREEN = "g";
public static final String BLUE = "b";
}

使用常量的时候,可以这么引用:

String color = ...
if (Color.RED.equals(color)) {
// TODO:
}

无论是int常量还是String常量,使用这些常量来表示一组枚举值的时候,有一个严重的问题就是,编译器无法检查每个值的合理性。例如:

if (weekday == 6 || weekday == 7) {
if (tasks == Weekday.MON) {
// TODO:
}
}

上述代码编译和运行均不会报错,但存在两个问题:

  • 注意到Weekday定义的常量范围是0~6,并不包含7,编译器无法检查不在枚举中的int值;
  • 定义的常量仍可与其他变量比较,但其用途并非是枚举星期值。
enum#

为了让编译器能自动检查某个值在枚举的集合内,并且,不同用途的枚举需要不同的类型来标记,不能混用,我们可以使用enum来定义枚举类:

// enum
public class Main {
public static void main(String[] args) {
Weekday day = Weekday.SUN;
if (day == Weekday.SAT || day == Weekday.SUN) {
System.out.println("Work at home!");
} else {
System.out.println("Work at office!");
}
}
}
enum Weekday {
SUN, MON, TUE, WED, THU, FRI, SAT;
}

注意到定义枚举类是通过关键字enum实现的,我们只需依次列出枚举的常量名。

int定义的常量相比,使用enum定义枚举有如下好处:

首先,enum常量本身带有类型信息,即Weekday.SUN类型是Weekday,编译器会自动检查出类型错误。例如,下面的语句不可能编译通过:

int day = 1;
if (day == Weekday.SUN) { // Compile error: bad operand types for binary operator '=='
}

其次,不可能引用到非枚举的值,因为无法通过编译。

最后,不同类型的枚举不能互相比较或者赋值,因为类型不符。例如,不能给一个Weekday枚举类型的变量赋值为Color枚举类型的值:

Weekday x = Weekday.SUN; // ok!
Weekday y = Color.RED; // Compile error: incompatible types

这就使得编译器可以在编译期自动检查出所有可能的潜在错误。

enum的比较#

使用enum定义的枚举类是一种引用类型。前面我们讲到,引用类型比较,要使用equals()方法,如果使用==比较,它比较的是两个引用类型的变量是否是同一个对象。因此,引用类型比较,要始终使用equals()方法,但enum类型可以例外。

这是因为enum类型的每个常量在JVM中只有一个唯一实例,所以可以直接用==比较:

if (day == Weekday.FRI) { // ok!
}
if (day.equals(Weekday.SUN)) { // ok, but more code!
}
enum类型#

通过enum定义的枚举类,和其他的class有什么区别?

答案是没有任何区别。enum定义的类型就是class,只不过它有以下几个特点:

  • 定义的enum类型总是继承自java.lang.Enum,且无法被继承;
  • 只能定义出enum的实例,而无法通过new操作符创建enum的实例;
  • 定义的每个实例都是引用类型的唯一实例;
  • 可以将enum类型用于switch语句。

例如,我们定义的Color枚举类:

public enum Color {
RED, GREEN, BLUE;
}

编译器编译出的class大概就像这样:

public final class Color extends Enum { // 继承自Enum,标记为final class
// 每个实例均为全局唯一:
public static final Color RED = new Color();
public static final Color GREEN = new Color();
public static final Color BLUE = new Color();
// private构造方法,确保外部无法调用new操作符:
private Color() {}
}

所以,编译后的enum类和普通class并没有任何区别。但是我们自己无法按定义普通class那样来定义enum,必须使用enum关键字,这是Java语法规定的。

因为enum是一个class,每个枚举的值都是class实例,因此,这些实例有一些方法:

name()#

返回常量名,例如:

String s = Weekday.SUN.name(); // "SUN"
ordinal()#

返回定义的常量的顺序,从0开始计数,例如:

int n = Weekday.MON.ordinal(); // 1

改变枚举常量定义的顺序就会导致ordinal()返回值发生变化。例如:

public enum Weekday {
SUN, MON, TUE, WED, THU, FRI, SAT;
}

public enum Weekday {
MON, TUE, WED, THU, FRI, SAT, SUN;
}

ordinal就是不同的。如果在代码中编写了类似if(x.ordinal()==1)这样的语句,就要保证enum的枚举顺序不能变。新增的常量必须放在最后。

有些童鞋会想,Weekday的枚举常量如果要和int转换,使用ordinal()不是非常方便?比如这样写:

String task = Weekday.MON.ordinal() + "/ppt";
saveToFile(task);

但是,如果不小心修改了枚举的顺序,编译器是无法检查出这种逻辑错误的。要编写健壮的代码,就不要依靠ordinal()的返回值。因为enum本身是class,所以我们可以定义private的构造方法,并且,给每个枚举常量添加字段:

// enum
public class Main {
public static void main(String[] args) {
Weekday day = Weekday.SUN;
if (day.dayValue == 6 || day.dayValue == 0) {
System.out.println("Work at home!");
} else {
System.out.println("Work at office!");
}
}
}
enum Weekday {
MON(1), TUE(2), WED(3), THU(4), FRI(5), SAT(6), SUN(0);
public final int dayValue;
private Weekday(int dayValue) {
this.dayValue = dayValue;
}
}

这样就无需担心顺序的变化,新增枚举常量时,也需要指定一个int值。

注意
枚举类的字段也可以是非final类型,即可以在运行期修改,但是不推荐这样做!

默认情况下,对枚举常量调用toString()会返回和name()一样的字符串。但是,toString()可以被覆写,而name()则不行。我们可以给Weekday添加toString()方法:

// enum
public class Main {
public static void main(String[] args) {
Weekday day = Weekday.SUN;
if (day.dayValue == 6 || day.dayValue == 0) {
System.out.println("Today is " + day + ". Work at home!");
} else {
System.out.println("Today is " + day + ". Work at office!");
}
}
}
enum Weekday {
MON(1, "星期一"), TUE(2, "星期二"), WED(3, "星期三"), THU(4, "星期四"), FRI(5, "星期五"), SAT(6, "星期六"), SUN(0, "星期日");
public final int dayValue;
private final String chinese;
private Weekday(int dayValue, String chinese) {
this.dayValue = dayValue;
this.chinese = chinese;
}
@Override
public String toString() {
return this.chinese;
}
}

覆写toString()的目的是在输出时更有可读性。

注意
判断枚举常量的名字,要始终使用name()方法,绝不能调用toString()!
switch#

最后,枚举类可以应用在switch语句中。因为枚举类天生具有类型信息和有限个枚举常量,所以比intString类型更适合用在switch语句中:

// switch
public class Main {
public static void main(String[] args) {
Weekday day = Weekday.SUN;
switch(day) {
case MON:
case TUE:
case WED:
case THU:
case FRI:
System.out.println("Today is " + day + ". Work at office!");
break;
case SAT:
case SUN:
System.out.println("Today is " + day + ". Work at home!");
break;
default:
throw new RuntimeException("cannot process " + day);
}
}
}
enum Weekday {
MON, TUE, WED, THU, FRI, SAT, SUN;
}

加上default语句,可以在漏写某个枚举常量时自动报错,从而及时发现错误。

小结#

Java使用enum定义枚举类型,它被编译器编译为final class Xxx extends Enum { … }

通过name()获取常量定义的字符串,注意不要使用toString()

通过ordinal()返回常量定义的顺序(无实质意义);

可以为enum编写构造方法、字段和方法

enum的构造方法要声明为private,字段强烈建议声明为final

enum适合用在switch语句中。

3.2.7 记录类#

使用StringInteger等类型的时候,这些类型都是不变类,一个不变类具有以下特点:

  1. 定义class时使用final,无法派生子类;
  2. 每个字段使用final,保证创建实例后无法修改任何字段。

假设我们希望定义一个Point类,有xy两个变量,同时它是一个不变类,可以这么写:

public final class Point {
private final int x;
private final int y;
public Point(int x, int y) {
this.x = x;
this.y = y;
}
public int x() {
return this.x;
}
public int y() {
return this.y;
}
}

为了保证不变类的比较,还需要正确覆写equals()hashCode()方法,这样才能在集合类中正常使用。后续我们会详细讲解正确覆写equals()hashCode(),这里演示Point不变类的写法目的是,这些代码写起来都非常简单,但是很繁琐。

record#

从Java 14开始,引入了新的Record类。我们定义Record类时,使用关键字record。把上述Point类改写为Record类,代码如下:

// Record
public class Main {
public static void main(String[] args) {
Point p = new Point(123, 456);
System.out.println(p.x());
System.out.println(p.y());
System.out.println(p);
}
}
record Point(int x, int y) {}

仔细观察Point的定义:

record Point(int x, int y) {}

把上述定义改写为class,相当于以下代码:

final class Point extends Record {
private final int x;
private final int y;
public Point(int x, int y) {
this.x = x;
this.y = y;
}
public int x() {
return this.x;
}
public int y() {
return this.y;
}
public String toString() {
return String.format("Point[x=%s, y=%s]", x, y);
}
public boolean equals(Object o) {
...
}
public int hashCode() {
...
}
}

除了用final修饰class以及每个字段外,编译器还自动为我们创建了构造方法,和字段名同名的方法,以及覆写toString()equals()hashCode()方法。

换句话说,使用record关键字,可以一行写出一个不变类。

enum类似,我们自己不能直接从Record派生,只能通过record关键字由编译器实现继承。

构造方法#

编译器默认按照record声明的变量顺序自动创建一个构造方法,并在方法内给字段赋值。那么问题来了,如果我们要检查参数,应该怎么办?

假设Point类的xy不允许负数,我们就得给Point的构造方法加上检查逻辑:

public record Point(int x, int y) {
public Point {
if (x < 0 || y < 0) {
throw new IllegalArgumentException();
}
}
}

注意到方法public Point {...}被称为Compact Constructor,它的目的是让我们编写检查逻辑,编译器最终生成的构造方法如下:

public final class Point extends Record {
public Point(int x, int y) {
// 这是我们编写的Compact Constructor:
if (x < 0 || y < 0) {
throw new IllegalArgumentException();
}
// 这是编译器继续生成的赋值代码:
this.x = x;
this.y = y;
}
...
}

作为recordPoint仍然可以添加静态方法。一种常用的静态方法是of()方法,用来创建Point

public record Point(int x, int y) {
public static Point of() {
return new Point(0, 0);
}
public static Point of(int x, int y) {
return new Point(x, y);
}
}

这样我们可以写出更简洁的代码:

var z = Point.of();
var p = Point.of(123, 456);
小结#

从Java 14开始,提供新的record关键字,可以非常方便地定义Data Class:

  • 使用record定义的是不变类;
  • 可以编写Compact Constructor对参数进行验证;
  • 可以定义静态方法。

3.2.8 BigInteger#

在Java中,由CPU原生提供的整型最大范围是64位long型整数。使用long型整数可以直接通过CPU指令进行计算,速度非常快。

如果我们使用的整数范围超过了long型怎么办?这个时候,就只能用软件来模拟一个大整数。java.math.BigInteger就是用来表示任意大小的整数。BigInteger内部用一个int[]数组来模拟一个非常大的整数:

BigInteger bi = new BigInteger("1234567890");
System.out.println(bi.pow(5)); // 2867971860299718107233761438093672048294900000

BigInteger做运算的时候,只能使用实例方法,例如,加法运算:

BigInteger i1 = new BigInteger("1234567890");
BigInteger i2 = new BigInteger("12345678901234567890");
BigInteger sum = i1.add(i2); // 12345678902469135780

long型整数运算比,BigInteger不会有范围限制,但缺点是速度比较慢。

也可以把BigInteger转换成long型:

BigInteger i = new BigInteger("123456789000");
System.out.println(i.longValue()); // 123456789000
System.out.println(i.multiply(i).longValueExact()); // java.lang.ArithmeticException: BigInteger out of long range

使用longValueExact()方法时,如果超出了long型的范围,会抛出ArithmeticException

BigIntegerIntegerLong一样,也是不可变类,并且也继承自Number类。因为Number定义了转换为基本类型的几个方法:

  • 转换为bytebyteValue()
  • 转换为shortshortValue()
  • 转换为intintValue()
  • 转换为longlongValue()
  • 转换为floatfloatValue()
  • 转换为doubledoubleValue()

因此,通过上述方法,可以把BigInteger转换成基本类型。如果BigInteger表示的范围超过了基本类型的范围,转换时将丢失高位信息,即结果不一定是准确的。如果需要准确地转换成基本类型,可以使用intValueExact()longValueExact()等方法,在转换时如果超出范围,将直接抛出ArithmeticException异常。

如果BigInteger的值甚至超过了float的最大范围(3.4x1038),那么返回的float是什么呢?

// BigInteger to float
import java.math.BigInteger;
public class Main {
public static void main(String[] args) {
BigInteger n = new BigInteger("999999").pow(99);
float f = n.floatValue();
System.out.println(f); // Infinity
}
}
小结#

BigInteger用于表示任意大小的整数;

BigInteger是不变类,并且继承自Number

BigInteger转换成基本类型时可使用longValueExact()等方法保证结果准确。

3.2.9 BigDecimal#

BigInteger类似,BigDecimal可以表示一个任意大小且精度完全准确的浮点数。

BigDecimal bd = new BigDecimal("123.4567");
System.out.println(bd.multiply(bd)); // 15241.55677489

BigDecimalscale()表示小数位数,例如:

BigDecimal d1 = new BigDecimal("123.45");
BigDecimal d2 = new BigDecimal("123.4500");
BigDecimal d3 = new BigDecimal("1234500");
System.out.println(d1.scale()); // 2,两位小数
System.out.println(d2.scale()); // 4
System.out.println(d3.scale()); // 0

通过BigDecimalstripTrailingZeros()方法,可以将一个BigDecimal格式化为一个相等的,但去掉了末尾0的BigDecimal

BigDecimal d1 = new BigDecimal("123.4500");
BigDecimal d2 = d1.stripTrailingZeros();
System.out.println(d1.scale()); // 4
System.out.println(d2.scale()); // 2,因为去掉了00
BigDecimal d3 = new BigDecimal("1234500");
BigDecimal d4 = d3.stripTrailingZeros();
System.out.println(d3.scale()); // 0
System.out.println(d4.scale()); // -2

如果一个BigDecimalscale()返回负数,例如,-2,表示这个数是个整数,并且末尾有2个0。

可以对一个BigDecimal设置它的scale,如果精度比原始值低,那么按照指定的方法进行四舍五入或者直接截断:

import java.math.BigDecimal;
import java.math.RoundingMode;
----
public class Main {
public static void main(String[] args) {
BigDecimal d1 = new BigDecimal("123.456789");
BigDecimal d2 = d1.setScale(4, RoundingMode.HALF_UP); // 四舍五入,123.4568
BigDecimal d3 = d1.setScale(4, RoundingMode.DOWN); // 直接截断,123.4567
System.out.println(d2);
System.out.println(d3);
}
}

BigDecimal做加、减、乘时,精度不会丢失,但是做除法时,存在无法除尽的情况,这时,就必须指定精度以及如何进行截断:

BigDecimal d1 = new BigDecimal("123.456");
BigDecimal d2 = new BigDecimal("23.456789");
BigDecimal d3 = d1.divide(d2, 10, RoundingMode.HALF_UP); // 保留10位小数并四舍五入
BigDecimal d4 = d1.divide(d2); // 报错:ArithmeticException,因为除不尽

还可以对BigDecimal做除法的同时求余数:

import java.math.BigDecimal;
----
public class Main {
public static void main(String[] args) {
BigDecimal n = new BigDecimal("12.345");
BigDecimal m = new BigDecimal("0.12");
BigDecimal[] dr = n.divideAndRemainder(m);
System.out.println(dr[0]); // 102
System.out.println(dr[1]); // 0.105
}
}

调用divideAndRemainder()方法时,返回的数组包含两个BigDecimal,分别是商和余数,其中商总是整数,余数不会大于除数。我们可以利用这个方法判断两个BigDecimal是否是整数倍数:

BigDecimal n = new BigDecimal("12.75");
BigDecimal m = new BigDecimal("0.15");
BigDecimal[] dr = n.divideAndRemainder(m);
if (dr[1].signum() == 0) {
// n是m的整数倍
}
比较BigDecimal#

在比较两个BigDecimal的值是否相等时,要特别注意,使用equals()方法不但要求两个BigDecimal的值相等,还要求它们的scale()相等:

BigDecimal d1 = new BigDecimal("123.456");
BigDecimal d2 = new BigDecimal("123.45600");
System.out.println(d1.equals(d2)); // false,因为scale不同
System.out.println(d1.equals(d2.stripTrailingZeros())); // true,因为d2去除尾部0后scale变为3
System.out.println(d1.compareTo(d2)); // 0 = 相等, -1 = d1 < d2, 1 = d1 > d2

必须使用compareTo()方法来比较,它根据两个值的大小分别返回负数、正数和0,分别表示小于、大于和等于。

注意
总是使用compareTo()比较两个BigDecimal的值,不要使用equals()!

如果查看BigDecimal的源码,可以发现,实际上一个BigDecimal是通过一个BigInteger和一个scale来表示的,即BigInteger表示一个完整的整数,而scale表示小数位数:

public class BigDecimal extends Number implements Comparable<BigDecimal> {
private final BigInteger intVal;
private final int scale;
}

BigDecimal也是从Number继承的,也是不可变对象。

小结#

BigDecimal用于表示精确的小数,常用于财务计算;

比较BigDecimal的值是否相等,必须使用compareTo()而不能使用equals()

3.2.10 常用工具类#

Java的核心库提供了大量的现成的类供我们使用。本节我们介绍几个常用的工具类。

Math#

顾名思义,Math类就是用来进行数学计算的,它提供了大量的静态方法来便于我们实现数学计算:

求绝对值:

Math.abs(-100); // 100
Math.abs(-7.8); // 7.8

取最大或最小值:

Math.max(100, 99); // 100
Math.min(1.2, 2.3); // 1.2

计算xy次方:

Math.pow(2, 10); // 2的10次方=1024

计算 x\sqrt x

Math.sqrt(2); // 1.414...

计算ex次方:

Math.exp(2); // 7.389...

计算以e为底的对数:

Math.log(4); // 1.386...

计算以10为底的对数:

Math.log10(100); // 2

三角函数:

Math.sin(3.14); // 0.00159...
Math.cos(3.14); // -0.9999...
Math.tan(3.14); // -0.0015...
Math.asin(1.0); // 1.57079...
Math.acos(1.0); // 0.0

Math还提供了几个数学常量:

double pi = Math.PI; // 3.14159...
double e = Math.E; // 2.7182818...
Math.sin(Math.PI / 6); // sin(π/6) = 0.5

生成一个随机数x,x的范围是0 <= x < 1

Math.random(); // 0.53907... 每次都不一样

如果我们要生成一个区间在[MIN, MAX)的随机数,可以借助Math.random()实现,计算如下:

// 区间在[MIN, MAX)的随机数
public class Main {
public static void main(String[] args) {
double x = Math.random(); // x的范围是[0,1)
double min = 10;
double max = 50;
double y = x * (max - min) + min; // y的范围是[10,50)
long n = (long) y; // n的范围是[10,50)的整数
System.out.println(y);
System.out.println(n);
}
}

有些同学可能注意到Java标准库还提供了一个StrictMath,它提供了和Math几乎一模一样的方法。这两个类的区别在于,由于浮点数计算存在误差,不同的平台(例如x86和ARM)计算的结果可能不一致(指误差不同),因此,StrictMath保证所有平台计算结果都是完全相同的,而Math会尽量针对平台优化计算速度,所以,绝大多数情况下,使用Math就足够了。

HexFormat#

在处理byte[]数组时,我们经常需要与十六进制字符串转换,自己写起来比较麻烦,用Java标准库提供的HexFormat则可以方便地帮我们转换。

要将byte[]数组转换为十六进制字符串,可以用formatHex()方法:

import java.util.HexFormat;
public class Main {
public static void main(String[] args) throws InterruptedException {
byte[] data = "Hello".getBytes();
HexFormat hf = HexFormat.of();
String hexData = hf.formatHex(data); // 48656c6c6f
}
}

如果要定制转换格式,则使用定制的HexFormat实例:

// 分隔符为空格,添加前缀0x,大写字母:
HexFormat hf = HexFormat.ofDelimiter(" ").withPrefix("0x").withUpperCase();
hf.formatHex("Hello".getBytes())); // 0x48 0x65 0x6C 0x6C 0x6F

从十六进制字符串到byte[]数组转换,使用parseHex()方法:

byte[] bs = HexFormat.of().parseHex("48656c6c6f");
Random#

Random用来创建伪随机数。所谓伪随机数,是指只要给定一个初始的种子,产生的随机数序列是完全一样的。

要生成一个随机数,可以使用nextInt()nextLong()nextFloat()nextDouble()

Random r = new Random();
r.nextInt(); // 2071575453,每次都不一样
r.nextInt(10); // 5,生成一个[0,10)之间的int
r.nextLong(); // 8811649292570369305,每次都不一样
r.nextFloat(); // 0.54335...生成一个[0,1)之间的float
r.nextDouble(); // 0.3716...生成一个[0,1)之间的double

有童鞋问,每次运行程序,生成的随机数都是不同的,没看出伪随机数的特性来。

这是因为我们创建Random实例时,如果不给定种子,就使用系统当前时间戳作为种子,因此每次运行时,种子不同,得到的伪随机数序列就不同。

如果我们在创建Random实例时指定一个种子,就会得到完全确定的随机数序列:

import java.util.Random;
public class Main {
public static void main(String[] args) {
Random r = new Random(12345);
for (int i = 0; i < 10; i++) {
System.out.println(r.nextInt(100));
}
// 51, 80, 41, 28, 55...
}
}

前面我们使用的Math.random()实际上内部调用了Random类,所以它也是伪随机数,只是我们无法指定种子。

SecureRandom#

有伪随机数,就有真随机数。实际上真正的真随机数只能通过量子力学原理来获取,而我们想要的是一个不可预测的安全的随机数,SecureRandom就是用来创建安全的随机数的:

SecureRandom sr = new SecureRandom();
System.out.println(sr.nextInt(100));

SecureRandom无法指定种子,它使用RNG(random number generator)算法。JDK的SecureRandom实际上有多种不同的底层实现,有的使用安全随机种子加上伪随机数算法来产生安全的随机数,有的使用真正的随机数生成器。实际使用的时候,可以优先获取高强度的安全随机数生成器,如果没有提供,再使用普通等级的安全随机数生成器:

import java.util.Arrays;
import java.security.SecureRandom;
import java.security.NoSuchAlgorithmException;
public class Main {
public static void main(String[] args) {
SecureRandom sr = null;
try {
sr = SecureRandom.getInstanceStrong(); // 获取高强度安全随机数生成器
} catch (NoSuchAlgorithmException e) {
sr = new SecureRandom(); // 获取普通的安全随机数生成器
}
byte[] buffer = new byte[16];
sr.nextBytes(buffer); // 用安全随机数填充buffer
System.out.println(Arrays.toString(buffer));
}
}

SecureRandom的安全性是通过操作系统提供的安全的随机种子来生成随机数。这个种子是通过CPU的热噪声、读写磁盘的字节、网络流量等各种随机事件产生的“熵”。

在密码学中,安全的随机数非常重要。如果使用不安全的伪随机数,所有加密体系都将被攻破。因此,时刻牢记必须使用SecureRandom来产生安全的随机数。

注意
需要使用安全随机数的时候,必须使用SecureRandom,绝不能使用Random!
小结#

Java提供的常用工具类有:

  • Math:数学计算
  • HexFormat:格式化十六进制数
  • Random:生成伪随机数
  • SecureRandom:生成安全的随机数
第3章 面向对象编程
https://drtk.cn/posts/books/java/30-oop/
作者
Dr.TK
发布于
2025-07-24
许可协议
CC BY-NC-SA 4.0