第四章:不可变性
处理数据结构——专门用于存储和组织数据值的构造——是几乎所有程序的核心任务。在面向对象编程中,通常意味着处理可变的程序状态,通常封装在对象中。对于函数式方法,然而,不可变性是处理数据的首选方式,并且是许多概念的先决条件。
在像Haskell这样的函数式编程语言,甚至是更倾向于函数式的多范式语言如Scala中,不可变性被视为一种普遍的特性。在这些语言中,不可变性是必需的,并且通常严格执行,而不仅仅是设计的附带思考。与本书介绍的其他原则一样,不可变性不限于函数式编程,而且无论选择的范式如何,都能提供许多好处。
在本章中,您将学习有关JDK中已有的不可变类型以及如何使用JDK提供的工具或第三方库来使数据结构不可变,以避免副作用。
在OOP中的可变性和数据结构
作为一个面向对象的语言,典型的Java代码使用可变形式封装对象的状态。通常通过使用"setter"方法使其状态可变。这种方法使得程序状态是短暂的,意味着对现有数据结构进行的任何更改都会直接更新其当前状态,这也会影响到其他引用该对象的部分,并且之前的状态会丢失。
让我们来看一下在第2章中讨论的用于处理面向对象编程Java代码中可变状态的最常见形式:JavaBeans和普通的Java对象(POJO)。关于这两种数据结构及其不同的特性,存在着很多混淆。从某种意义上说,它们都是普通的Java对象,旨在通过封装所有相关状态来创建组件之间的可重用性。它们有着相似的目标,尽管它们的设计理念和规则有所不同。
POJO没有关于其设计的任何限制。它们被设计为"只是"封装业务逻辑状态的对象,甚至可以设计为不可变对象。如何实现它们取决于你和最适合你的环境。它们通常为其字段提供"getter"和"setter"方法,以在可变状态的面向对象上下文中更灵活地使用。
另一方面,JavaBeans是一种特殊类型的POJO,允许更容易地进行内省和重用,这要求它们遵循特定的规则。这些规则是必需的,因为JavaBeans最初被设计为在组件之间共享可标准化和可机读的状态,例如IDE中的UI小部件。POJO和JavaBeans之间的差异列在表4-1中。
JDK中许多可用的数据结构(如Collections框架)主要基于可变状态和原地修改的概念。以List为例,它的变异方法(如add(E value)或remove(E value))仅返回一个布尔值表示是否发生了更改,并且它们直接在集合中进行修改,因此之前的状态会丢失。在局部环境中,您可能不需要过多考虑这个问题,但是一旦一个数据结构离开您的直接影响范围,只要您持有对它的引用,就不能保证它会保持当前状态。
可变状态导致复杂性和不确定性。您必须始终在心中包含所有可能的状态变化,以理解和推理您的代码。这并不仅限于单个组件。共享可变状态会增加复杂性,涵盖了访问此类共享状态的任何组件的生命周期。并发编程尤其在共享状态的复杂性下受到影响,其中许多问题源于可变性,并需要复杂且经常被误用的解决方案,如访问同步和原子引用。
确保代码和共享状态的正确性变成了一项永无止境的任务,需要进行无数的单元测试和状态验证。并且,一旦可变状态与更多可变组件进行交互,所需的额外工作就会成倍增加,导致对它们的行为进行更多的验证。
这就是不可变性提供了另一种处理数据结构和恢复合理性的方法的地方。
不可变性(不仅仅是)在函数式编程中
不可变性的核心思想很简单:数据结构在创建后不能再改变。许多函数式编程语言在设计中都支持不可变性。这个概念不仅仅适用于函数式编程范式,并且在任何范式中都有许多优势。
不可变的数据结构是对数据的持久视图,没有直接的选项可以改变它。要"改变"这样的数据结构,您必须创建一个带有所需更改的新副本。一开始,在Java中不能"原地"改变数据可能会感到奇怪。与面向对象代码通常可变的性质相比,为什么您要采取额外的步骤来简单地更改一个值?通过复制数据创建新实例会产生特定的开销,对于不成熟的不可变性实现,这种开销会迅速累积。
尽管在原地无法改变数据和最初的奇怪感存在开销,但即使在没有更函数式的Java方法的情况下,不可变性的好处仍然可以使其具有价值:
可预测性
数据结构不会在您未注意到的情况下发生变化,因为它们根本无法改变。只要您引用一个数据结构,您就知道它与创建时的状态相同。即使您共享该引用或以并发方式使用它,没有人可以更改您的副本。
有效性
在初始化之后,数据结构是完整的。它只需要验证一次,并永久保持有效(或无效)。如果您需要在多个步骤中构建数据结构,稍后在"逐步创建"中展示的构建器模式将分离数据结构的构建和初始化。
没有隐藏的副作用
处理副作用是编程中一个非常棘手的问题,除了命名和缓存失效之外。不可变数据结构的副产品是消除了副作用;它们总是保持原样。即使通过代码的不同部分频繁移动或在您无法控制的第三方库中使用它们,它们也不会改变其值或给您带来意外的副作用。
线程安全
没有副作用,不可变数据结构可以自由地在线程边界之间移动。没有线程可以更改它们,因此由于没有意外的更改或竞态条件,对程序进行推理变得更加直观。
可缓存和优化
由于它们在创建后就是原样的,您可以轻松地缓存不可变的数据结构。优化技术,如记忆化(memoization),仅适用于不可变数据结构,如在第2章中讨论的。
更改跟踪
如果每次更改都会导致一个全新的数据结构,您可以通过存储先前的引用来跟踪它们的历史。您不再需要精心跟踪单个属性的更改以支持撤消功能。恢复先前的状态只需使用对数据结构的先前引用。
请记住,所有这些好处都与选择的编程范式无关。即使您决定函数式方法可能不适合您的代码库,您的数据处理仍然可以极大地受益于不可变性。
Java不可变性的状态
Java的初始设计并没有将不可变性作为深度集成的语言特性或提供多种不可变数据结构。虽然语言和其类型的某些方面始终是不可变的,但与其他更加函数式的语言相比,支持程度远远不够。然而,这一切在Java 14发布时发生了变化,引入了Records,这是一种内置的语言级不可变数据结构。
即使你可能还不知道,你已经在所有的Java程序中使用了不可变类型。它们的不可变性的原因可能不同,例如运行时优化或确保其正确使用,但无论其目的如何,它们都会使你的代码更安全、更少出错。 让我们来看一下目前在JDK中提供的所有不同的不可变部分。
java.lang.String
每个Java开发者都会了解的第一个类型之一就是String类型。字符串无处不在!这就是为什么它需要是一个高度优化和安全的类型。其中一种优化是它是不可变的。 String不是一个原始的基于值的类型,比如int或char。尽管如此,它支持使用+(加号)运算符将一个String与另一个值连接起来:
String first = "hello, ";
String second = "world!";
String result = first + second;
// => "hello, world!"
就像任何其他表达式一样,连接字符串会创建一个结果,而在这种情况下是一个新的String对象。这就是为什么Java开发者在早期就被教导不要过度使用手动字符串连接。每次使用+(加号)运算符连接字符串时,都会在堆上创建一个新的String实例,占用内存,如图4-1所示。这些新创建的实例可能会很快增加,特别是如果在循环语句(如for或while)中进行连接。
尽管JVM会对不再需要的实例进行垃圾回收,但无尽的字符串创建所带来的内存开销可能对运行时造成真正的负担。这就是为什么JVM在幕后使用多种优化技术来减少字符串的创建,例如将连接操作替换为java.lang.StringBuilder,甚至使用操作码invokedynamic来支持多种优化策略。因为String是如此基础的类型,因此将其设置为不可变的是合理的,有多种原因支持这样做。将这样的基本类型在设计上具有线程安全性可以在问题出现之前解决与并发性相关的问题,如同步。并发本身已经很困难,无需担心String会在不经意间发生变化。不可变性消除了竞态条件、副作用或简单的意外更改的风险。
JVM还对字符串字面量进行特殊处理。通过字符串池技术,相同的字面量只会存储一次,并被重复使用以节省宝贵的堆空间。如果一个String可以改变,那么使用引用指向它的所有人都会受到影响。可以通过显式调用String的构造函数来分配一个新的String实例,而不是创建一个字面量来避免使用字符串池。反过来也是可能的,可以调用任何实例上的intern方法,该方法返回与字符串池中具有相同内容的String实例。
然而,从技术角度来看,String类型并非完全不可变。由于性能考虑,它会延迟计算hashCode,因为需要读取整个字符串才能进行计算。尽管如此,它仍然是一个纯函数:相同的字符串始终会产生相同的hashCode。
使用惰性求值来隐藏昂贵的即时计算,以实现逻辑上的不可变性,在设计和实现类型时需要额外小心,以确保它保持线程安全和可预测性。
所有这些特性使得String在可用性上介于原始类型和对象类型之间。性能优化的可能性和安全性可能是其不可变性的主要原因,但不可变性的隐含优势仍然对于这种基本类型非常受欢迎。
不可变集合
另一个从不可变性中受益显著的基本且普遍存在的类型组是集合(Collections),如Set、List、Map等。 尽管Java的Collection框架并未将不可变性作为核心原则进行设计,但它仍提供了一定程度的不可变性,有三种选项:
- Unmodifiable Collections(不可修改的集合)
- Immutable Collection工厂方法(Java 9+)
- Immutable copies(Java 10+) 所有这些选项都不是通过new关键字直接实例化的公共类型。
相反,相关类型具有静态便利方法来创建必要的实例。此外,它们只是浅不可变的,意味着您不能添加或删除任何元素,但元素本身并没有保证是不可变的。持有元素引用的任何人都可以在不知道其当前所在的集合的情况下对其进行更改。
要拥有一个完全不可变的集合,你需要仅包含完全不可变的元素。然而,这三个选项仍然为你提供了有用的工具,用于防止意外修改。
不可修改的集合
第一种选项是通过调用java.util.Collections中以下泛型静态方法之一,从现有的集合创建不可修改的集合:
- Collection unmodifiableCollection(Collection<? extends T> c) Set
- unmodifiableSet(Set<? extends T> s) List unmodifiableList(List<? extends T>
- list) Map<K, V> unmodifiableMap(Map<? extends K, ? extends V> m) SortedSet
- unmodifiableSortedSet(SortedSet s) SortedMap<K, V>
- unmodifiableSortedMap(SortedMap<K, ? extends V> m) NavigableSet
- unmodifiableNavigableSet(NavigableSet s) NavigableMap<K, V>
- unmodifiableNavigableMap(NavigableMap<K, V> m)
每个方法返回与方法的单个参数相同的类型。原始实例和返回的实例之间的区别在于,任何试图修改返回的实例的尝试都会抛出UnsupportedOperationException,如下面的代码所示:
List<String> modifiable = new ArrayList<>();
modifiable.add("blue");
modifiable.add("red");
List<String> unmodifiable = Collections.unmodifiableList(modifiable);
unmodifiable.clear();
// throws UnsupportedOperationException
"不可修改视图"的明显缺点是它只是对现有集合的一个抽象。以下代码展示了底层集合仍然是可修改的,并且会影响不可修改的视图:
[`List<String> original = new ArrayList<>();
original.add("blue");
original.add("red");
List<String> unmodifiable = Collections.unmodifiableList(original);
original.add("green");
System.out.println(unmodifiable.size());
// OUTPUT:
// 3`]()
之所以可以通过原始引用进行修改,是因为数据结构在内存中的存储方式,如图4-2所示。不可修改的版本只是原始列表的一个视图,因此直接对原始列表进行的任何更改都会绕过视图的预期不可修改性质。
不可修改视图的常见用途是在将集合用作返回值之前,冻结集合以防止不必要的修改。
不可变集合的工厂方法
第二个选项——不可变集合的工厂方法——自Java 9起可用,不依赖于现有的集合。相反,元素必须直接提供给以下集合类型上的静态便利方法:
- List of(E e1, …)
- Set of(E e1, …)
- Map<K, V> of(K k1, V v1, …)
每个工厂方法都可以使用零个或多个元素,并且根据使用的元素数量使用优化的内部集合类型。
不可变副本
第三个选项是不可变副本,在Java 10及以上版本中可用,通过在以下三种类型上调用静态方法copyOf来提供更深层次的不可变性:
- Set copyOf(Collection<? extends E> coll)
- List copyOf(Collection<? extends E> coll)
- Map<K, V> copyOf(Map<? extends K, ? extends V> map)
copyOf不仅仅是一个视图,它创建一个新的容器,并持有对元素的自己的引用:
// SETUP ORIGINAL LIST
List<String> original = new ArrayList<>();
original.add("blue");
original.add("red");
// CREATE COPY
List<String> copiedList = List.copyOf(original);
// ADD NEW ITEM TO ORIGINAL LIST
original.add("green");
// CHECK CONTENT
System.out.println(original);
// [blue, red, green]
System.out.println(copiedList);
// [blue, red]
复制的集合阻止通过原始列表添加或删除任何元素,但实际元素仍然是共享的,如图4-3所示,并且可以进行更改。
选择哪种不可变集合选项取决于您的上下文和意图。如果无法在单个调用中创建集合,例如在for循环中,使用不可修改的视图或不可变副本是一种合理的方法。在本地使用可变集合,并在数据离开当前作用域时通过返回不可修改的视图或复制来"冻结"它。不可变集合的工厂方法不支持中间的集合可能被修改,但要求您事先知道所有元素的情况。
原始类型和原始类型包装器
到目前为止,你主要学习了不可变的对象类型,但在Java中并不是所有的东西都是对象。Java的原始类型(byte、char、short、int、long、float、double、boolean)与对象类型处理方式不同。它们是由字面值或表达式初始化的简单值。作为仅表示单个值的类型,它们在实际中是不可变的。
除了原始类型本身,Java还提供了对应的对象包装器类型,如Byte或Integer。它们将各自的原始类型封装在具体的对象类型中,使它们可以在不允许使用原始类型的场景中使用,比如泛型。否则,自动装箱(object wrapper类型与其对应的原始类型之间的自动转换)可能导致不一致的行为。
不可变的数学类型
Java中大多数简单的计算依赖于原始类型,如int或long用于整数,以及float或double用于浮点计算。然而,java.math包提供了两个不可变的替代方案,用于更安全和更精确的整数和小数计算,它们分别是java.math.BigInteger和java.math.BigDecimal。
就像使用String一样,为什么你要为你的代码增加不可变性的开销呢?因为它们允许在更大范围内进行无副作用的计算,并具有更高的精度。
然而,使用不可变的数学对象的一个陷阱是可能会简单地忘记使用计算的实际结果。尽管像add或subtract这样的方法名暗示了修改,至少在面向对象的上下文中,java.math类型返回一个带有结果的新对象,如下所示:
var theAnswer = new BigDecimal(42);
var result = theAnswer.add(BigDecimal.ONE);
// RESULT OF THE CALCULATION
System.out.println(result);
// OUTPUT:
// 43
// UNCHANGED ORIGINAL VALUE
System.out.println(theAnswer);
// OUTPUT:
// 42
不可变的数学类型仍然是带有通常开销的对象,并且使用更多内存来实现其精度。然而,如果计算速度不是限制因素,由于其任意精度,你应该始终优先选择BigDecimal类型进行浮点运算。
BigInteger类型是BigDecimal的整数等价物,也具有内置的不可变性。另一个优点是其扩展范围至少从到(均不包含),而相比之下,int类型的范围为到。
Java Time API (JSR-310)
Java 8引入了Java Time API(JSR-310),它以不可变性作为核心原则进行设计。在Java 8发布之前,你只能使用java.util包中的三种类型(Date、Calendar和TimeZone)来满足你的所有日期和时间相关需求。执行计算是繁琐且容易出错的。这就是为什么在Java 8之前,Joda Time库成为了日期和时间类的事实标准,并随后成为JSR-310的概念基础的原因。
与之前在java.util中的三种类型不同,现在在java.time包中有多种与日期和时间相关的类型,具有不同的精度,有些带有时区,有些没有。它们都是不可变的,具有相关的优点,例如没有副作用,在并发环境中可以安全使用。
Enums
Java枚举是由常量组成的特殊类型。常量是恒定的,因此是不可变的。除了常量值,枚举还可以包含其他字段,这些字段不是隐式常量。
通常情况下,这些字段会使用final的原始类型或字符串,但没有人阻止你使用可变对象类型或为原始类型使用setter。这很可能会导致问题,我强烈建议不要这样做。这也被认为是代码异味。
final关键字
自Java诞生以来,final关键字根据上下文提供了一种特定形式的不可变性,但它并不是一个可以使任何数据结构变为不可变的神奇关键字。那么,对于引用、方法或类来说,final到底意味着什么呢? final关键字类似于编程语言C中的const关键字。如果应用于类、方法、字段或引用,它会有几个影响:
- final类不能被子类化。
- final方法不能被覆盖。
- final字段必须被准确地赋值一次,可以通过构造函数或在声明时进行赋值,并且永远不能被重新赋值。
- final变量引用的行为类似于字段,只能在声明时被分配一次。关键字只影响引用本身,而不是引用的变量内容。
- final关键字为字段和变量提供了一种特定形式的不可变性。
然而,它们的不可变性可能不符合你的期望,因为引用本身变为不可变,但底层数据结构并没有变为不可变。这意味着你不能重新分配引用,但仍然可以更改数据结构,如示例4-1所示。
final List<String> fruits = new ArrayList<>();
System.out.println(fruits.isEmpty());
// OUTPUT:
// true
fruits.add("Apple");
System.out.println(fruits.isEmpty());
// OUTPUT:
// false
fruits = List.of("Mango", "Melon");
// => WON'T COMPILE
正如在“有效最终性”中讨论的那样,具有有效最终引用是Lambda表达式的必要条件。将代码中的每个引用都设置为final是一种选择,但我不推荐这样做。即使没有添加显式关键字,编译器也可以自动检测引用是否像最终引用一样。由缺乏不可变性引起的大多数问题实际上来自于底层数据结构本身,而不是重新分配的引用。为了确保只要数据结构处于活动使用状态就不会出现意外更改,你必须从一开始就选择一个不可变的数据结构。为了实现这个目标,Java最新的添加是Records。
Records
在2020年,Java 14引入了一种新类型的类,使用自己的关键字来补充或甚至替代特定情况下的POJO和JavaBeans:Records。 Records是“普通数据”聚合,比POJO或JavaBeans更简洁。它们的特性集被减少到绝对最低限度,以满足这个目的,使它们变得简洁:
public record Address(String name,
String street,
String state,
String zipCode,
Country country) {
// NO BODY
}
Records是浅不可变的数据载体,主要由其状态声明组成。在没有任何额外代码的情况下,Address记录会自动生成命名组件的getter方法、相等性比较、toString和hashCode方法等。 第5章将深入探讨Records,介绍如何在不同场景中创建和使用它们。
怎样实现不可变性
现在你已经了解了JVM提供的不可变部分,是时候看看如何将它们组合起来实现程序状态的不可变性了。 使类型不可变的最简单方法是一开始就不给它改变的机会。如果一个数据结构没有任何setter方法,具有final字段的数据结构在创建后就不会改变,因为它无法改变。
然而,对于真实的代码,解决方案可能并不像那么简单。 不可变性要求我们以一种新的方式思考数据的创建,因为共享数据结构很少一次性创建。与其随着时间的推移对单个数据结构进行变异,你应该尽可能地使用不可变的构造来进行工作,并最终组合成一个“最终”和不可变的数据结构。
图4-4描述了不同数据组件对“最终”不可变记录的贡献的一般思路。即使各个组件本身不是不可变的,你也应该努力将它们包装在一个不可变的外壳中,无论是使用Record还是其他方式。
在更复杂的数据结构中,跟踪所需的组件及其验证可能是具有挑战性的。在第5章中,我将讨论改进数据结构创建并减少所需认知复杂性的工具和技术。
常见做法
与一般的函数式方法一样,不可变性并不需要全盘采用。由于其优势,仅使用不可变数据结构听起来很有吸引力,你的主要目标应该是将其作为默认方法使用,并将不可变引用视为常规方法。然而,将现有的可变数据结构转换为不可变数据结构通常是一项相当复杂的任务,需要进行大量的重构或概念重设计。相反,你可以通过遵循以下常见做法逐步引入不可变性,并将数据视为已经是不可变的:
默认不可变性
任何新的数据结构,如数据传输对象、值对象或任何类型的状态,都应该被设计为不可变。如果JDK或其他你使用的框架或库提供了不可变的替代方案,你应该考虑使用它而不是可变类型。从一开始就处理不可变性的新类型将影响和塑造使用它的任何代码。
始终预期不可变性
假设所有的数据结构都是不可变的,除非你自己创建它们或明确指定了其他情况,特别是在处理类似集合的类型时。如果需要修改一个数据结构,最安全的方式是基于现有的结构创建一个新的数据结构。
修改现有类型
即使一个现有的类型不是不可变的,如果可能的话,新的添加应该是不可变的。可能有一些原因使它变为可变的,但不必要的可变性会增加错误的发生几率,并且不可变性的所有优势会立即消失。
在必要时打破不可变性
如果不适合,不要强行使用,特别是在遗留代码库中。不可变性的主要目标是提供更安全、更合理的数据结构,这要求它们的环境相应地支持。
将外部数据结构视为不可变
始终将任何不受你所控制的数据结构视为不可变。例如,作为方法参数接收到的类似集合的类型应被视为不可变。而不是直接操作它,为任何更改创建一个可变的包装视图,并返回一个不可修改的集合类型。这种方法使方法保持纯净,并防止调用方意外地进行任何未预期的更改。
遵循这些常见做法将使你更容易从一开始创建不可变的数据结构,或者在过程中逐步过渡到更不可变的程序状态。