Hi,我是阿昌
,今天学习的是关于一个不可变数据的透明载体,档案类
。
档案类
这个特性,首先在 JDK 14 中以预览版的形式发布。在 JDK 15 中,改进的档案类再次以预览版的形式发布。最后,档案类在 JDK 16正式发布。
那么,什么是档案类呢?档案类的英文,使用的词汇是“record”
。
官方的说法,Java 档案类是用来表示不可变数据的透明载体
。
这样的表述,有两个关键词,一个是不可变
的数据,另一个是透明的载体
。
该怎么理解“不可变的数据”和“透明的载体”呢?
从形状的子类圆形开始,来看一看面向对象编程实践中,这个类的设计和演化。
下面的这段代码,就是一个简单的、典型的圆形类的定义。
这个抽象类的名字是 Circle。
它有一个私有的变量 radius,用来表示圆的半径。
有一个构造方法
,用来生成圆形的实例。
有一个设置半径的方法 setRadius,一个读取半径的方法 getRadius。
还有一个重写的方法 getArea,用来计算圆形的面积。
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 方法,这些调用的最终结果是难以预料的。
这也就是我们常说的多线程安全问题。
在现代计算机架构下,大多数的应用需要多线程的环境。
所以,我们通常需要考虑多线程安全的问题。 该怎么解决上面例子中的多线程安全问题呢?
如果上述例子的实现源代码不能更改,那么就需要在调用这些接口的程序中,增加线程同步的措施。
synchronized (circleObject) {
double radius = circleObject.getRadius();
// do something with the radius.
}
遗憾的是,在调用层面解决线程同步问题的办法,并不总是显而易见的。
不论多么资深的程序员,都有可能疏漏、忘记或者没有正确地解决好线程同步的问题。所以,通常地,为了更皮实的接口设计,在接口规范设计的时候,就应该考虑解决掉线程同步的问题。
比如说,我们可以把上面案例中的代码改成线程安全的代码。
对于 Circle 类,只需要把它的公开方法都设置成同步方法,那么这个类就是多线程安全的了。具体的实现,请参考下面的代码。
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 类实现。在这个实现里,圆形的对象一旦实例化,就不能再修改它的半径了。
相应地,我们删除了设置半径的方法。
也就是说,这个对象是一个只读的对象,不支持修改。
通常地,我们称这样的对象为不可变对象
。
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 类的设计。
具体的实现,请参考下面的代码。
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 普通的类改成档案类,来感受下档案类到底是什么模样的。
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 档案类实例的。
Circle circle = new Circle(10.0);
最后,在大括号里,也就是档案类的实现代码里,变量的声明没有了,构造方法也没有了。
前面我们已经知道怎么生成一个档案类实例了,但还有一个问题是,我们能读取这个圆形档案类的半径吗?其实,类标识符声明后面的小括号里的参数,就是等价的不可变变量。
在档案类里,这样的不可变变量是私有的变量,我们不可以直接使用它们。
但是我们可以通过等价的方法来调用它们。变量的标识符就是等价方法的标识符。
比如下面的代码,就是一个读取上面圆形档案类半径的代码。
double radius = circle.radius();
是的,在档案类里,方法调用的形式又回来了。
我们前面讨论过打破封装原则的顾虑,你可能还是没有足够的信心去接受不完整的封装形式。
那么现在,档案类的调用形式依然保持着良好的封装形式。
打破封装原则的顾虑也就不复存在了。需要注意的是,由于档案类表示的是不可变数据,除了构造方法之外,并没有给不可变变量赋值的方法。
上面,通过传统 Circle 类和档案 Circle 类代码的对比,我们可以感受到档案类在简化代码、提高生产力方面的努力。
如果说,上面这些简化,还在我的预料之内的话;下面的简化,我刚看到的时候,是很惊喜的:“哇,这真是太奇妙了!”我们还是通过代码来体验一下这种感受。
如果我们生成两个半径为 10 厘米的圆形的实例,这两个实例是相等的吗?
下面的代码,就是用来验证我们猜想的。你可以试着运行一下,看看和你猜想的结果是不是一样的。
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 厘米的圆形的档案类实例,是相等
的实例。
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 档案类还做了以下的限制:
不支持扩展子句
,用户不能定制它的父类。隐含的,它的父类是 java.lang.Record。父类不能定制,也就意味着我们不能通过修改父类来影响 Java 档案的行为。不支持子类,也不能是抽象类
。没有子类,也就意味着我们不能通过修改子类来改变 Java 档案的行为。变量是不可变的变量
。这就是前面反复强调的,一旦实例化就不能再修改的关键所在。不能支持实例初始化的方法
。这就保证了,我们只能使用档案类形式的构造方法,避免额外的初始化对可变性的影响。不能声明本地(native)方法
。如果允许了本地方法,也就意味着打开了修改不可变变量的后门。通常地,我们把 Java 档案类看成是一种特殊形式的 Java 类。除了上述的限制,Java 档案类和普通类的用法是一样的。
好了,聊完“不可变的数据”,接下来该聊聊“透明的载体”了。
陆陆续续地,我们在前面提到过,档案类内置了下面的这些方法缺省
实现:
如果你注意到的话,我们使用了“缺省”这样的字眼。
换一种说法,我们可以使用缺省的实现,也可以替换掉缺省的实现。
下面的代码,就是我们试图替换掉缺省实现的尝试。
请注意,除了构造方法,其他的替换方法都可以使用 Override 注解
来标注。
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。
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 编译的时候,已经替我们把这些东西加上去了。
所以,不论哪一种编码形式,构造方法的调用都是没有区别的。
在上一个例子中,我们已经看到了构造方法的常规形式。
在下面这张表里,我列出了两种构造方法形式上的差异,你可以看看它们的差异。
还有一类常见的替换,如果缺省的 equals 方法或者 hashCode 方法不能正常工作或者存在安全的问题,就需要替换掉缺省的方法。
如果声明的不可变变量没有重写 equals 方法和 hashCode 方法,那么这个档案类的 equals 方法和 hashCode 方法的行为就可能不是可以预测的。
比如,如果不可变的变量是一个数组
,通过下面的例子,我们来看看它的 equals 方法能不能正常工作。
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,我们就能看到预期的结果了。
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 方法。
但是,我们通常不建议重写不可变数据的读取方法。因为,这样的重写往往意味着需要变更缺省的不可变数值,从而打破实例的状态,进而造成许多无法预料的、让人费解的后果。
比如说,我们设想定义一个数,如果是负值的话,我们希望读取的是它的相反数。
下面的例子,就是一个味道很坏的示范。
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 档案类?
技术要点:
如果你能够有意识地使用不可变的对象以及档案类,并且有能力规避掉其中的陷阱,你应该能够大幅度提高编码的效率和质量。
毫无疑问,在面试的时候,这也是一个能够让你脱颖而出的知识点。