Java 中的不可变数据结构

最近,在我主导的几场代码面试中,经常出现不可变数据结构(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,这样代码就安全了。感谢指正!


猜你喜欢

转载自blog.51cto.com/15082395/2590359