最近,在我主导的几场代码面试中,经常出现不可变数据结构(Immutable Data Structure)相关内容。关于这个主题我个人并不过分教条,不变性通常体现在数据结构中,"除非必要"否则不会要求代码一定具备不变性。然而,我发现大家对不变性(Immutability)这个概念似乎有一些误解。开发者通常认为加上 `final`,或者在 Kotlin、Scala 中加上 `val` 就足以实现不可变对象。这篇文章会深入讨论不可变引用和不可变数据结构。
1. 不可变数据结构的优点
不可变数据结构有下列显著优点:
没有无效状态(Invalid State)
线程安全
代码易于理解
易于测试
可用作值类型
译注:在计算机编程中包含两种类型,值类型 value type 与引用类型 reference type。值类型表示实际值,引用类型表示对其他值或对象的引用。
2. 没有无效状态
不可变对象只能通过构造函数初始化,并且通过参数限制了输入的有效性,从而确保对象不会包含无效值。例如下面这段代码示例:
```java Address address = new Address(); address.setCity("Sydney"); // 由于没有设置 country,address 现在处于无效状态. Address address = new Address("Sydney", "Australia"); // Address 对象有效并且不提供 setter 方法,因此 address 对象会一直保持有效. ```
3. 线程安全
由于对象值不可修改,在线程间共享时不会产生竞态条件或者数据突变问题。
4. 代码易于理解
在上面的示例代码中,使用构造函数比初始化方法更易于理解。构造函数会强制检查输入参数,而 setter 或初始化方法不会在编译时进行检查。
5. 易于测试
使用初始化方法,必须测试调用顺序对对象的影响。而使用构造函数,对象的值要么有效要么无效,无需进行排列组合测试。代码执行结果的可靠性更强,出现 `NullPointerExceptions` 的机率更小。下面是一个传递对象过程中改变了对象状态的示例:
```java public boolean isOverseas(Address address) { if(address.getCountry().equals("Australia") == false) { address.setOverseas(true); // address 的值发生了改变! return true; } else { return false; } } ```
上面的代码是一种错误示范,在返回 `boolean` 结果的同时改变了对象状态。这样的代码可读性和可测性都很差。一种更好的方法是从 `Address` 类中移除 setter 方法,为 `country` 属性提供 `boolean` 类型的测试方法;更进一步,可以把 `address.isOverseas()` 的逻辑移到 `Address` 类中。需要设置状态时,拷贝原来的对象而非修改输入对象的值。
6. 可作为值类型使用
如何做到使用 `Money` 对象表示10美金,使用的时候一直是10美金?比如这段代码,`public Money(final BigInteger amount, final Currency currency)` 确保了一旦声明10美金后接下来不会改变。这样对象的值可以安全地作为值类型使用。
7. final 并不能让对象变成不可变对象
文章开头提到过,我经常遇到开发者不能完全理解 `final` 引用和不可变对象的区别。最常见误区是,只要在变量前加上 `final` 就会成为不可变数据结构。不幸的是,实际并没有这么简单。接下来会为大家消除这个误解:
在变量前加 `final` 不会产生不可变对象。
换句话说,下面这段代码生成的对象是可变对象:
```java final Person person = new Person("John"); ```
尽管 `person` 是一个 final 字段不能重新赋值,但 `Person` 类可能提供了 setter 方法或者其他修改方法,比如像下面这个方法:
```java person.setName("Cindy"); ```
无论是否加 `final` 修饰符,轻易就可以修改对象。不仅如此,`Person` 类可能还提供了许多修改 address 属性的类似方法,调用它们可以向对象添加地址,同样会修改 `person` 对象。
```java person.getAddresses().add(new Address("Sydney")); ```
`final` 引用并没能阻止修改对象。
现在我们已经澄清了这个误解,接下来讨论如何让类具有不可变的特性。在设计时需要考虑以下事项:
不要把内部状态暴露出来
不要在内部修改状态
确保子类不会破坏上面的行为
按照上面这些建议,让我们重新设计 `Person` 类:
```java public final class Person { // final 类, 不支持重载 private final String name; // 加 final 修饰, 支持多线程 private final List<Address> addresses; public Person(String name, List<Address> addresses) { this.name = name; this.addresses = List.copyOf(addresses); // 拷贝列表, 避免从外面修改对象 (Java 10+). // 也可以使用 Collections.unmodifiableList(new ArrayList<>(addresses)); } public String getName() { return this.name; // String 是不可变对象, 可以暴露 } public List<Address> getAddresses() { return addresses; // Address list 可以修改 } } public final class Address { // final 类, 不支持重载 private final String city; // 只使用不可变类 private final String country; public Address(String city, String country) { this.city = city; this.country = country; } public String getCity() { return city; } public String getCountry() { return country; } } ```
现在,代码变成下面这样:
```java import java.util.List; final Person person = new Person("John", List.of(new Address(“Sydney”, "Australia")); ```
更新后的 `Person` 和 `Address` 让上面的代码成为不可变代码。不仅如此,`final` 引用让 `person` 变量无法再次赋值。
更新:正如评论中[指出的][1],上面的代码还是可以修改的,因为并没有在构造函数中执行列表拷贝。如果不在构造函数中调用 `new ArrayList()` 还可以像下面这样做:
```java final List<Address> addresses = new ArrayList<>(); addresses.add(new Address("Sydney", "Australia")); final Person person = new Person("John", addressList); addresses.clear(); ```
[1]:https://www.reddit.com/r/java/comments/azryu6/final_vs_immutable_data_structures_in_java/?st=jt74o32w&sh=40d418d3
由于不在构造函数中执行 `copy`,上面的代码无法修改 `Person` 类中拷贝后的 address list,这样代码就安全了。感谢指正!
希望本文能够有助于理解 `final` 与代码不可变之间的区别,如果有任何疑问,欢迎在评论区留言。