歡迎光臨
每天分享高質量文章

Java 中的不可變資料結構

(給ImportNew加星標,提高Java技能)

 

編譯:ImportNew/唐尤華

 

最近,在我主導的幾場程式碼面試中,經常出現不可變資料結構(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
addresses;

public Person(String name, List

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

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
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` 與程式碼不可變之間的區別,如果有任何疑問,歡迎在評論區留言。

    已同步到看一看
    贊(0)

    分享創造快樂