You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

487 lines
27 KiB
Markdown

This file contains invisible Unicode characters!

This file contains invisible Unicode characters that may be processed differently from what appears below. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to reveal hidden characters.

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# 03 | 档案类:怎么精简地表达不可变数据?
你好我是范学雷。今天我们聊一聊Java的档案类。
档案类这个特性首先在JDK 14中以[预览版](https://openjdk.java.net/jeps/359)的形式发布。在JDK 15中改进的档案类再次以[预览版](https://openjdk.java.net/jeps/384)的形式发布。最后档案类在JDK 16[正式发布](https://openjdk.java.net/jeps/395)。
那么什么是档案类呢档案类的英文使用的词汇是“record”。官方的说法Java档案类是用来表示不可变数据的透明载体。这样的表述有两个关键词一个是不可变的数据另一个是透明的载体。
该怎么理解“不可变的数据”和“透明的载体”呢?我们还是通过案例和代码,一步一步地来拆解、理解这些概念。
## 阅读案例
在面向对象的编程语言中,研究表示形状的类是一个常用的教学案例。今天的评审案例,我们从形状的子类圆形开始,来看一看面向对象编程实践中,这个类的设计和演化。
下面的这段代码,就是一个简单的、典型的圆形类的定义。这个抽象类的名字是**Circle**。它有一个私有的变量**radius**,用来表示圆的半径。有一个构造方法,用来生成圆形的实例。有一个设置半径的方法**setRadius**,一个读取半径的方法**getRadius**。还有一个重写的方法**getArea**,用来计算圆形的面积。
```java
package co.ivi.jus.record.former;
public final class Circle implements Shape {
private double radius;
public Circle(double radius) {
this.radius = radius;
}
@Override
public double getArea() {
return Math.PI * radius * radius;
}
public double getRadius() {
return radius;
}
public void setRadius(double radius) {
this.radius = radius;
}
}
```
这个圆形类之所以典型,是因为它交代了面向对象设计的关键思想,包括面向对象编程的三大支柱性原则:封装、继承和多态。
封装的原则是隐藏具体实现细节,实现的修改不会影响接口的使用。**Circle**类中,表示半径的变量被定义成私有的变量。我们可以改变半径这个变量的名字,或者不使用半径而是使用直径来表示圆形。这样的实现细节的变化,并不会影响公开方法的调用。
由于需要隐藏内部实现细节,所以需要设计公开接口来访问类的相关特征,比如例子中的圆形的半径。所以上面的例子中,设置半径的方法**setRadius**和读取半径的方法**getRadius**就显得显而易见并且顺理成章。在面向对象编程的教科书里以及Java的标准类库里我们可以看到很多类似的设计。
可是,这样的设计有哪些严重的缺陷呢?花点时间想想你能找到的问题,然后我们接下来再继续分析。
## 案例分析
上面这个例子,最重要的问题,就是它的接口不是多线程安全的。如果在一个多线程的环境中,有些线程调用了**setRadius**方法,有些线程调用**getRadius**方法,这些调用的最终结果是难以预料的。这也就是我们常说的多线程安全问题。
在现代计算机架构下,大多数的应用需要多线程的环境。所以,我们通常需要考虑多线程安全的问题。 该怎么解决上面例子中的多线程安全问题呢?如果上述例子的实现源代码不能更改,那么就需要在调用这些接口的程序中,增加线程同步的措施。
```java
synchronized (circleObject) {
double radius = circleObject.getRadius();
// do something with the radius.
}
```
遗憾的是,在调用层面解决线程同步问题的办法,并不总是显而易见的。不论多么资深的程序员,都有可能疏漏、忘记或者没有正确地解决好线程同步的问题。
所以,通常地,为了更皮实的接口设计,在接口规范设计的时候,就应该考虑解决掉线程同步的问题。比如说,我们可以把上面案例中的代码改成线程安全的代码。对于**Circle**类,只需要把它的公开方法都设置成同步方法,那么这个类就是多线程安全的了。具体的实现,请参考下面的代码。
```java
package co.ivi.jus.record.former;
public final class Circle implements Shape {
private double radius;
public Circle(double radius) {
this.radius = radius;
}
@Override
public synchronized double getArea() {
return Math.PI * radius * radius;
}
public synchronized double getRadius() {
return radius;
}
public synchronized void setRadius(double radius) {
this.radius = radius;
}
}
```
可是,线程同步并不是免费的午餐。代价有多大呢?我做了一个简单的性能基准测试,哪怕最简单的同步,比如上面代码里同步的**getRadius**方法,它的吞吐量损失也有十数倍。这相当于说,如果没有同步的应用需要一台机器支持的话,加了同步的应用就需要十多台机器来支撑相同的业务量。
这样的代价就有点大了,我们需要寻找更好的办法来解决多线程安全的问题。最有效的办法,就是在接口设计的时候,争取做到即使不使用线程同步,也能做到多线程安全。这说起来还是有点难以理解的,我们还是来看看代码吧。
下面的代码,是一个修改过的**Circle**类实现。在这个实现里,圆形的对象一旦实例化,就不能再修改它的半径了。相应地,我们删除了设置半径的方法。也就是说,这个对象是一个只读的对象,不支持修改。通常地,我们称这样的对象为不可变对象。
```java
package co.ivi.jus.record.immute;
public final class Circle implements Shape {
public final double radius;
public Circle(double radius) {
this.radius = radius;
}
@Override
public double area() {
return Math.PI * radius * radius;
}
}
```
对于只读的圆形类的设计,我们可以看到两个好处。
第一个好处,就是天生的多线程安全。因为这个类的对象,一旦实例化就不能再修改,所以即便在多线程环境下使用,也不需要同步。而不可变对象所承载的数据,比如上面例子中圆形的半径,就是我们前面所说的不可变的数据。这个不可变,是有一个界定范围的。这个界定范围,就是它所在对象的生命周期。如果跳出了对象的生命周期,我们可以重新生成新对象,从而实现数据的变化。
第二个好处,就是简化的代码。只读对象的设计,使得我们可以重新考虑代码的设计,这是代码简化的来源。你可能已经注意到了,在这个实现里,我们还删除了读取半径的方法。取而代之的,是公开的半径这个变量。这就是一个最直接的简化。
应用程序可以直接读取这个变量而不是通过一个类似于getRadius的方法。由于半径这个变量被声明为final变量所以它只可以被读取不能被修改。这并没有破坏对象的只读性。
不过乍看之下这样的设计似乎破坏了面向对象编程的封装原则。公开半径变量radius相当于公开的实现细节。如果我们改变主意想使用直径来表示一个圆形那么实现的修改就会显得很丑陋。
可是,如果我们认真思考一下几个简单的问题,对于封装的顾虑可能就降低很多了。比如说,使用直径来表示一个圆,这是一个真实的需求吗? 这是一个必需的表达方式吗?未来的圆,会不会变得没法使用半径来表达?其实不是的,未来的圆,还是可以用半径来表达的。使用其他的办法,比如直径,来表达一个圆,其实并没有必要。
所以,公开半径这个只读变量,并没有带来违反封装原则的实质性后果。而且,从另外一个角度来看,我们可以把读取这个只读变量的操作,看成是等价的读取方法的调用。不过,虽然很多人,包括我自己,倾向于这样解读,但是这总归是一个有争议的形式。
## 进一步的简化
还有没有进一步简化的空间呢?我们再来看看不可变的正方形**Square**类的设计。具体的实现,请参考下面的代码。
```java
package co.ivi.jus.record.immute;
public final class Square implements Shape {
public final double side;
public Square(double side) {
this.side = side;
}
@Override
public double area() {
return side * side;
}
}
```
如果比较一下不可变的圆形Circle类和正方形Square类的源代码你有没有发现这两个类的代码有惊人的相似点
第一个相似的地方就是使用公开的只读变量使用final修饰符来声明只读变量。Circle类的变量**radius**和Square类的变量**side**,都是公开的只读的变量。这样的声明,是为了公开变量的只读性。
第二个相似的地方就是公开的只读变量需要在构造方法中赋值而且只在构造方法中赋值且这样的构造方法还是公开的方法。Circle类的构造方法给**radius**变量赋值Square类的构造方法给**side**变量赋值。这样的构造方法,解决了对象的初始化问题。
第三个相似的地方,就是没有了读取的方法;公开的只读变量,替换了掉了公开的读取方法。这样的变化,使得代码量总体变少了。
这么多相似的地方,相似的代码,能不能进一步地简化呢?我知道,你可能已经开始思考这样的问题了。
对于这个问题Java的答案就是使用档案类。
## 怎么声明档案类
我们前面说过Java档案类是用来表示不可变数据的透明载体。那么怎么使用档案类来表示不可变数据呢
我们还是一起先来看看代码吧。咱们试着把上面不可变的圆形Circle普通的类改成档案类来感受下档案类到底是什么模样的。
```java
package co.ivi.jus.record.modern;
public record Circle(double radius) implements Shape {
@Override
public double area() {
return Math.PI * radius * radius;
}
}
```
看到这样的代码是不是有点出乎意料你可以对比一下不可变的Circle类的代码感受一下这两者之间的差异。
首先最常见的class关键字不见了取而代之的是record关键字。record关键字是class关键字的一种特殊表现形式用来标识档案类。record关键字可以使用和class关键字差不多一样的类修饰符比如public、static等但是也有一些例外我们后面再说
然后类标识符Circle后面有用小括号括起来的参数。类标识符和参数一起看就像是一个构造方法。事实上这样的表现方式的确可以看成是构造方法。而且这种形式还就是当作构造方法使用的。比如下面的代码就是使用构造方法的形式来生成Circle档案类实例的。
```java
Circle circle = new Circle(10.0);
```
最后,在大括号里,也就是档案类的实现代码里,变量的声明没有了,构造方法也没有了。前面我们已经知道怎么生成一个档案类实例了,但还有一个问题是,我们能读取这个圆形档案类的半径吗?
其实,类标识符声明后面的小括号里的参数,就是等价的不可变变量。在档案类里,这样的不可变变量是私有的变量,我们不可以直接使用它们。但是我们可以通过等价的方法来调用它们。变量的标识符就是等价方法的标识符。比如下面的代码,就是一个读取上面圆形档案类半径的代码。
```java
double radius = circle.radius();
```
是的,在档案类里,方法调用的形式又回来了。我们前面讨论过打破封装原则的顾虑,你可能还是没有足够的信心去接受不完整的封装形式。那么现在,档案类的调用形式依然保持着良好的封装形式。打破封装原则的顾虑也就不复存在了。
需要注意的是,由于档案类表示的是不可变数据,除了构造方法之外,并没有给不可变变量赋值的方法。
## 意料之外的改进
上面通过传统Circle类和档案Circle类代码的对比我们可以感受到档案类在简化代码、提高生产力方面的努力。如果说上面这些简化还在我的预料之内的话下面的简化我刚看到的时候是很惊喜的“哇这真是太奇妙了
我们还是通过代码来体验一下这种感受。如果我们生成两个半径为10厘米的圆形的实例这两个实例是相等的吗下面的代码就是用来验证我们猜想的。你可以试着运行一下看看和你猜想的结果是不是一样的。
```java
package co.ivi.jus.record;
import co.ivi.jus.record.immute.Circle;
public class ImmuteUseCases {
public static void main(String[] args) {
Circle c1 = new Circle(10.0);
Circle c2 = new Circle(10.0);
System.out.println("Equals? " + c1.equals(c2));
}
}
```
上面的代码里使用了我们开篇案例分析中的传统Circle类。运行结果告诉我们两个半径为10厘米的圆形的实例并不是相等的实例。我想这应该在你的预料之内。
如果需要比较两个实例是不是相等我们需要重写equals方法和hashCode方法。如果需要把实例转换成肉眼可以阅读的信息我们需要重写toString方法。我们上面案例分析的代码中这些方法都没有重写因此对应的操作结果也是不可预测的。
当然如果没有遗忘我们可以添加这三个方法的重写实现。然而这三个方法的重写尤其是equals方法和hashCode方法的重写实现一直是代码安全的重灾区。即便是经验丰富的程序员也可能忘记重写这三个方法就算没有遗忘equals方法和hashCode方法也可能没有正确实现从而带来各种各样的问题。这实在难以让人满意但是一直以来我们也没有更好的办法。
档案类会不一样吗?
我们再来看看使用档案类的代码,结果会不会不一样呢? 下面的这段代码Circle的实现使用的是档案类。这段代码运行的结果告诉我们两个半径为10厘米的圆形的档案类实例是相等的实例。
```java
package co.ivi.jus.record;
import co.ivi.jus.record.modern.Circle;
public class ModernUseCases {
public static void main(String[] args) {
Circle c1 = new Circle(10.0);
Circle c2 = new Circle(10.0);
System.out.println("Equals? " + c1.equals(c2));
}
}
```
看到这里,你是不是感觉到:哇! 这真的是太棒了!我们并没有重写这三个方法,它们居然可以使用。
为什么会这样呢?
这是因为档案类内置了缺省的equals方法、hashCode方法以及toString方法的实现。一般情况下我们就再也不用担心这三个方法的重写问题了。这不仅减少了代码数量提高了编码的效率还减少了编码错误提高了产品的质量。
## 不可变的数据
讨论到这里我们可以回头再看看Java档案类的定义了Java档案类是用来表示不可变数据的透明载体。“不可变的数据”和“透明的载体”是两个最重要的关键词。
我们前面讨论了不可变的数据。如果一个Java类一旦实例化就不能再修改那么用它表述的数据就是不可变数据。Java档案类就是表述不可变数据的。为了强化“不可变”这一原则避免面向对象设计的陷阱Java档案类还做了以下的限制
1. Java档案类不支持扩展子句用户不能定制它的父类。隐含的它的父类是java.lang.Record。父类不能定制也就意味着我们不能通过修改父类来影响Java档案的行为。
2. Java档案类是个终极final不支持子类也不能是抽象类。没有子类也就意味着我们不能通过修改子类来改变Java档案的行为。
3. Java档案类声明的变量是不可变的变量。这就是我们前面反复强调的一旦实例化就不能再修改的关键所在。
4. Java档案类不能声明可变的变量也不能支持实例初始化的方法。这就保证了我们只能使用档案类形式的构造方法避免额外的初始化对可变性的影响。
5. Java档案类不能声明本地native方法。如果允许了本地方法也就意味着打开了修改不可变变量的后门。
通常地我们把Java档案类看成是一种特殊形式的Java类。除了上述的限制Java档案类和普通类的用法是一样的。
## 透明的载体
好了,聊完“不可变的数据”,接下来该聊聊“透明的载体”了。
陆陆续续地,我们在前面提到过,档案类内置了下面的这些方法缺省实现:
* 构造方法
* equals方法
* hashCode方法
* toString方法
* 不可变数据的读取方法
如果你注意到的话,我们使用了“缺省”这样的字眼。换一种说法,我们可以使用缺省的实现,也可以替换掉缺省的实现。下面的代码,就是我们试图替换掉缺省实现的尝试。请注意,除了构造方法,其他的替换方法都可以使用**Override**注解来标注(如果你读过[《代码精进之路》](https://time.geekbang.org/column/intro/100019601),你就会倾向于总是使用**Override**注解的)。
```java
package co.ivi.jus.record.explicit;
import java.util.Objects;
public record Circle(double radius) implements Shape {
public Circle(double radius) {
this.radius = radius;
}
@Override
public double area() {
return Math.PI * radius * radius;
}
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (o instanceof Circle other) {
return other.radius == this.radius;
}
return false;
}
@Override
public int hashCode() {
return Objects.hash(radius);
}
@Override
public String toString() {
return String.format("Circle[radius=%f]", radius);
}
@Override
public double radius() {
return this.radius;
}
}
```
到这里,你应该明白了“透明的载体”的意思了。透明载体的意思,通俗地说,就是档案类承载有缺省实现的方法,这些方法可以直接使用,也可以替换掉。
不过,像上面这样的替换,除了徒增烦恼,是没有实际意义的。那我们什么时候需要替换掉缺省实现呢?
## 重写构造方法
最常见的替换,是要在构造方法里对档案类声明的变量添加必要的检查。比如说,我们现实生活中看到的各种各样的圆形,它的半径都不会是负数。如果在这样的场景里来讨论圆形,那么表示圆形的类的半径就不应该是负数。
你应该已经意识到了,我们上面的代码,在实例化的时候,都没有检查半径的数值,包括档案类缺省的构造方法。那么这时候,我们就要替换掉缺省的构造方法。下面的代码,就是一种替换的方法。如果,构造实例的时候,半径的数值为负,构造就会抛出运行时异常**IllegalArgumentException**。
```plain
package co.ivi.jus.record.improved;
public record Circle(double radius) implements Shape {
public Circle {
if (radius < 0) {
throw new IllegalArgumentException(
"The radius of a circle cannot be negative [" + radius + "]");
}
}
@Override
public double area() {
return Math.PI * radius * radius;
}
}
```
如果你阅读了上面的代码应该已经注意到了一点不太常规的形式。构造方法的声明没有参数也没有给实例变量赋值的语句。这并不是说构造方法就没有参数或者实例变量不需要赋值。实际上为了简化代码Java编译的时候已经替我们把这些东西加上去了。所以不论哪一种编码形式构造方法的调用都是没有区别的。
在上一个例子中,我们已经看到了构造方法的常规形式。在下面这张表里,我列出了两种构造方法形式上的差异,你可以看看它们的差异。
# ![图片](https://static001.geekbang.org/resource/image/87/48/8709d08e5545bd64b954e1b810603c48.jpg?wh=1920x914)
## 重写equals方法
还有一类常见的替换如果缺省的equals方法或者hashCode方法不能正常工作或者存在安全的问题就需要替换掉缺省的方法。
如果声明的不可变变量没有重写equals方法和hashCode方法那么这个档案类的equals方法和hashCode方法的行为就可能不是可以预测的。比如如果不可变的变量是一个数组通过下面的例子我们来看看它的equals方法能不能正常工作。
```java
jshell> record Password(byte[] password) {}
|  modified record Password
jshell> Password pA = new Password("123456".getBytes());
pA ==> Password[password=[B@2ef1e4fa]
jshell> Password pB = new Password("123456".getBytes());
pB ==> Password[password=[B@b81eda8]
jshell> pA.equals(pB);
$16 ==> false
```
这个例子里我们设计了一个口令的档案类其中的口令使用字节数组来存放。我们使用同样的口令生成了两个不同的实例。然后我们调用equals方法来比较这两个实例。
运算的结果显示这两个实例并不相等。这不是我们期望的结果。其中的原因就是因为数组这个变量的equals方法并不能正常工作或者换个说法数组变量没有重写equals方法
如果把变量的类型换成重写了equals方法的字符串String我们就能看到预期的结果了。
```java
jshell> record Password(String password) {};
|  created record Password
jshell> Password pA = new Password("123456");
pA ==> Password[password=123456]
jshell> Password pB = new Password("123456");
pB ==> Password[password=123456]
jshell> pA.equals(pB);
$5 ==> true
```
一般情况下equals方法和hashCode方法是成双成对的实现逻辑上需要匹配。所以当我们重写equals方法的时候一般也需要重写hashCode方法反之亦然。
## 不推荐的重写
为了更个性化的显示我们有时候也需要重写toString方法。但是我们通常不建议重写不可变数据的读取方法。因为这样的重写往往意味着需要变更缺省的不可变数值从而打破实例的状态进而造成许多无法预料的、让人费解的后果。
比如说,我们设想定义一个数,如果是负值的话,我们希望读取的是它的相反数。下面的例子,就是一个味道很坏的示范。
```java
jshell> record Number(int x) {
   ...>     public int x() {
   ...>         return x > 0 ? x : (-1) * x;
   ...>     }
   ...> }
|  created record Number
jshell> Number n = new Number(-1);
n ==> Number[x=-1]
jshell> n.x();
$9 ==> 1
jshell> Number m = new Number(n.x());
m ==> Number[x=1]
jshell> m.equals(n);
$11 ==> false
```
在这个例子里,我们重写了读取的方法。如果一个数是负数,重写的读取就返回它的相反数。读取出来的数据,并不是实例化的时候赋于的数据。这让代码变得难以理解,很容易出错。
更严重的问题是这样的重写不再能够支持实例的拷贝。比如说我们把实例n拷贝到另一个实例m。这两个实例按照道理来说应该相等。而由于重写了读取的方法实际的结果这两个实例是不相等的。这样的结果也可能会使代码容易出错而且难以调试。
## 总结
今天就到这里我来做个小结。从前面的讨论中我们了解到Java档案类是用来表示不可变数据的透明载体用来简化不可变数据的表达提高编码效率降低编码错误。同时我们也讨论了使用档案类的几个容易忽略的陷阱。
在我们日常的接口设计和编码实践中为了最大化的性能我们应该优先考虑使用不可变的对象数据如果一个类是用来表述不可变的对象数据我们应该优先使用Java档案类。
如果要丰富你的代码评审清单,有了封闭类后,你可以加入下面这一条:
> 一个类如果是用来表述不可变的数据能不能使用Java档案类
另外,通过今天的讨论,我拎出几个技术要点,这些都可能在你们面试中出现哦,通过学习,你应该能够:
* 知道Java支持档案类并且能够有意识地使用档案类提高编码效率降低编码错误
* 面试问题:你知道档案类吗?会不会使用它?
* 了解档案类的原理和它要解决的问题,知道使用不可变的对象优势;
* 面试问题:什么情况下可以使用档案类,什么情况下不能使用档案类?
* 了解档案类的缺省方法,掌握缺省方法的好处和不足,知道什么时候要重写这些方法。
* 面试问题:使用档案类应该注意什么问题?
如果你能够有意识地使用不可变的对象以及档案类,并且有能力规避掉其中的陷阱,你应该能够大幅度提高编码的效率和质量。毫无疑问,在面试的时候,这也是一个能够让你脱颖而出的知识点。
## 思考题
在重写equals方法这一小节里我们讨论了数组类型的不可变数据。我们已经知道了这样的数据类型需要重写equals方法和hashCode方法。其实toString()的方法也需要重写。今天的思考题,就是请你实现这些方法的重写。
方便起见,我们假设这个数组是字节数组,用来表示社会保障号。我们都知道,社会保障号是高度敏感的信息,不能被泄漏,也不能被盗取。你来想一想,有哪些方法需要重写?为什么?代码看起来是什么样子的?有难以克服的困难吗?
我开个头,写一个空白的档案类,你来把你想添加的代码补齐。
```java
record SocialSecurityNumber(byte[] ssn) {
// Here is your code.
}
```
欢迎你在留言区留言、讨论,分享你的阅读体验以及对这些问题的思考。
注:本文使用的完整的代码可以从[GitHub](https://github.com/XueleiFan/java-up/tree/main/src/main/java/co/ivi/jus/sealed)下载,你可以通过修改[GitHub](https://github.com/XueleiFan/java-up/tree/main/src/main/java/co/ivi/jus/sealed)上[review template](https://github.com/XueleiFan/java-up/blob/main/src/main/java/co/ivi/jus/record/review/xuelei/SocialSecurityNumber.java)代码,完成这次的思考题。如果你想要分享你的修改或者想听听评审的意见,请提交一个 GitHub的拉取请求Pull Request并把拉取请求的地址贴到留言里。这一小节的拉取请求代码请在[档案类专用的代码评审目录](https://github.com/XueleiFan/java-up/tree/main/src/main/java/co/ivi/jus/record/review)下建一个以你的名字命名的子目录代码放到你专有的子目录里。比如我的代码就放在record/review/xuelei的目录下面。