為什么對象應該是不可變的

jopen 10年前發布 | 11K 次閱讀 對象 Java開發

在面向對象的編程領域中,一個對象如果在創建后,它的狀態不能改變,那么我們就認為這個對象是不可變的(Immutable)。

在Java中,String這個不可變對象就是個很好的例子。一旦創建String對象后,我們不能對它的狀態進行改變。我們可以創建新的String對象,但是不能改變原有的String對象。

然而,在JDK中有不可變對象只是很少的一部分。類似Date這樣的類,我們能夠通過調用setTime()方法改變它的狀態。

我不清楚為什么JDK的設計者把如此相似的兩個對象采取截然相反的實現方式。然而,我認為Date作為一個可變對象有很多缺陷。與此同時,不可變的String更能體現面向對象編程的本質。

更進一步,我認為在一個純面向對象的世界里,所有的類都應該是不可變的。然而,有時會因為JVM的限制很難實現這一點。但不管怎么說,我們都應該盡全力做到最好。

下面幾點是支持對象不可變性的一些理由:

  • 不可變對象更容易構造、測試與使用。
  • 真正的不可變對象都是線程安全的。
  • 不可變對象可以避免耦合。
  • 不可變對象的使用沒有副作用(沒有保護性拷貝)。
  • 對象變化的問題得到了避免。
  • 不可變對象的失敗都是原子性的。
  • 不可變對象更容易緩存。
  • 不可變對象可以避免空值(NULL)引用,這通常是很糟糕的

線程安全

不可變對象最重要的特征是線程安全。這意味著多個線程能夠在同時訪問同一個對象,而且不需要擔心與其他線程產生沖突。

如果對象的方法都不能改變對象的狀態,那么不管有多少個對象,不管它們被并行調用的頻率——不可變對象運行在自己的堆棧中。

Goetz等人在他們一本非常有名的書Java Concurrency in Pratice中更加細致的討論了不可變對象的優勢,強烈推薦大家去看。

避免時間上的耦合

下面給出一個時間上耦合的例子(下面的代碼發送兩個連續的 HTTP POST請求,第二個有HTTP body):

Request request = new Request("http://example.com");
request.method("POST");
String first = request.fetch();
request.body("text=hello");
String second = request.fetch();

這段代碼可以工作。但是,第一個方法必須在第二個方法之前調用,如果我們把第一個方法注釋掉(也就是去掉第二行與第三行),編譯器不會報任何錯誤:

Request request = new Request("http://example.com");
// request.method("POST");
// String first = request.fetch();
request.body("text=hello");
String second = request.fetch();

現在,這段代碼雖然沒有編譯錯誤,但仍然失效了。這就是所謂的時間上的耦合——總是有些隱藏信息需要程序員去記住。在這個例子中,我們必須記著在使用第二個方法前,需要調用第一個方法。

我們必須記住第二個方法必須與第一個方法一起使用,并且是在第一個方法之后使用。

如果Request對象不可變,第一個代碼片段也是不對的,很有可能是下面這個樣子:

final Request request = new Request("");
String first = request.method("POST").fetch();
String second = request.method("POST").body("text=hello").fetch();

這下這兩個方法就沒有耦合了,我們可以很放心的去掉第一個方法。你也許會說上面的代碼有重復,確實是有。但是我們可以改成這樣:

final Request request = new Request("");
final Request post = request.method("POST");
String first = post.fetch();
String second = post.body("text=hello").fetch();

這樣一來,我們重構后的代碼也是正確的,而且沒有了時間上的耦合。第一個請求可以在不影響第一個請求的情況下取消掉。

我希望這個例子能夠向你展示操作不可變對象是更可讀且可維護的,因為它沒有時間上的耦合。

避免副作用

讓我們在一個新方法中使用Request對象現在它是可變的了):

public String post(Request request) {
  request.method("POST");
  return request.fetch();
}

下面讓我們發送兩個請求——第一個用GET方法,第二個用POST方法:

Request request = new Request("http://example.com");
request.method("GET");
String first = this.post(request);
String second = request.fetch();

這樣代碼就安全了,而且沒有副作用。

避免身份可變性(Identity Mutability)

通常而言,對于內部狀態相同的對象,我們認為它們是相同的。Date 類就是這方面一個很好的例子:

Date first = new Date(1L);
Date second = new Date(1L);
assert first.equals(second); // true

這里有兩個對象,但是由于它們的內部狀態是一樣的,所以我們認為它們是相同的。可以通過重寫它們的equals()與hashCode()方法實現。

這種便捷的方式的后果是:當我們在處理可變對象時,一旦我們改變了它們的內部狀態,那么也就改變了它們的身份。

Date first = new Date(1L);
Date second = new Date(1L);
first.setTime(2L);
assert first.equals(second); // false

這也許看起來很自然,但是如果我們把可變對象作為Map的key時,情況就不一樣了:

Map<Date, String> map = new HashMap<>();
Date date = new Date();
map.put(date, "hello, world!");
date.setTime(12345L);
assert map.containsKey(date); // false

當我們改變date的狀態時,我們不希望改變它的身份。我們不想僅僅因為改變了key的狀態就失去了這個條目。但是上面的例子確實會發生丟失條目的問題。

當我們向map中添加一個對象時,這個對象的hashCode()會返回一個值。HashMap根據這個值來決定當前條目在內部哈希表的位置。當我們調用containsKey()方法時,由于對象的hashcode不一樣了(因為 hashcode 依賴于內部狀態),所以HashMap在內部的哈希表中找不到相應條目了。

這是個非常煩人的問題,而且很難去調試可變對象的副作用而產生的問題。不可變對象就能從根本上避免這個問題了。

原子性失敗

下面是個簡單的例子:

public class Stack {
  private int size;
  private String[] items;
  public void push(String item) {
    size++;
    if (size > items.length) {
      throw new RuntimeException("stack overflow");
    }
    items[size] = item;
  }
}

很明顯,如果程序因為溢出而導致拋出異常時,Stack 對象就會處于一種不健康的狀態。它的size屬性會增加,但是items中并不包含新元素。

不可變性可以避免這個問題,因為一個不可變對象只能在構造時改變狀態。構造函數要么失敗,這樣就不會初始化這個對象;要么成功,這時才會構造一個合法可靠且的對象。因為這時對象的內部屬性不會再發生改變了。

如果想了解更多關于這方面的內容,可以參考Joshua Bloch寫的Effective Java, 2nd Edition

反對不可變性的論據

下面是一些反對不可變性的爭論:

  1. “不可變性不適合企業級項目”。通常,我會聽人說到不可變性是個假想的特征,在真正的企業級項目中并不適用。作為一個反對這個爭論的人,我可以僅僅列舉出下面一些例子,它們都是真實的應用,并且使用到了不可變的Java對象:jcabi-http,jcabi-xml,jcabi-github,jcabi-s3,jcabi-dynamo,jcabi-simpledb。 上面的這些Java庫都使用了不可變對象。netbout.comstateful.co是兩個使用了不可變對象實現的Web 應用程序。
  2. “更新一個已有對象的狀態比創建一個新對象的成本要低”。Oracle認為“對象創建的成本往往被高估了,而且,不可變對象帶來的便利可以抵消掉創建對象時的開銷,因為垃圾回收機制能夠減少開銷,同時,我們可以不用去寫專門防止可變對象出錯的代碼了。”我同意這種說法。

如果你有其他的想法,請在下面貼出來,我將盡量回復。

原文鏈接: javacodegeeks 翻譯: ImportNew.com - 劉 家財
譯文鏈接: http://www.importnew.com/14027.html

 本文由用戶 jopen 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!