(給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` 與程式碼不可變之間的區別,如果有任何疑問,歡迎在評論區留言。
朋友會在“發現-看一看”看到你“在看”的內容