Java 值對象探討與實踐

值類型與值對象

我們都知道,Java 語言中的類型分爲兩種:基本類型(primitive type)和引用類型(reference type),這不僅是語言層面的特性,也由 JVM 內在實現支持 [1]。

其中,基本類型指是的 8 種基本的數值類型:boolean、byte、char、int、short、long、float、double;而引用類型,指的是對程序中創建的對象的引用,可以理解爲指向對象的指針或句柄。Java 號稱一切皆是對象,很可惜,這並不是事實,基本類型就不是對象。

那麼,值類型又是什麼呢?

在你編寫程序時,是否經常會遇到一些需要表達數值或其它類型值的場景?比如複數、向量、顏色值、座標點、時間、日期等。這些值通常無法用基本類型來表達,一則它可能是多個屬性構成,二則針對值的一些操作或邏輯我們希望跟數據封裝在一起,比如向量的點乘、叉乘、取模等。但如果使用對象來表達同樣也會產生很多問題:

▐****相等性比較

對這些對象的比較是有意義的,但是默認情況下 Java 對象比較的是地址,因此直接比較的結果通常不是我們期待的行爲:

▐****可變性

對引用類型的賦值、方法傳參等會生成多個引用,這些引用都指向同一個對象。這在一些情況下是沒有問題的,但在某些場景下可能導致對象發生預期之外的變化。如:

上面的 case 比較簡單,只要對 Date 的特性有些瞭解就不會犯這樣的錯誤。但如果對象經過多次傳遞,使用的位置離創建的位置很遠的話,我們就未必能這麼謹慎了。這種問題,Martin Flower 稱之爲 aliasing bug[2]。

▐****性能

上面兩點其實都容易解決,只是每個實現需要寫很多樣板代碼。需要比較的對象只要重寫 equals() 和 hashCode 方法即可;對於可變性問題,可以將對象設計爲不可變對象,在修改時返回一個深拷貝副本來供客戶端操作。滿足上述兩種條件的對象,我們可以稱之爲值對象。

那麼,通過 “對象” 來實現我們對這種數據結構的訴求,是否是最好的方式呢?

我們知道,Java 中的對象通常是分配在堆上,通過引用來進行操作,不過這不是必然的。JVM 有一項技術叫逃逸分析 [3],可以在運行時分析出一個方法中創建的對象是否會逃逸到方法或線程外部,如果沒有逃逸,可以進而執行一些編譯優化,比如棧上分配、同步消除、標量替換等。如果一個對象被分配到棧上,就意味着當方法結束後就會自動銷燬,省去了 GC 的開銷,這對於優化應用內存佔用和 GC 停頓時間來說,無疑是個好消息;而標量替換意味着壓根就不會創建對象,相關數據被替換成基本類型數據直接分配到棧上,不僅省去了對象操作相關開銷,也更利於 CPU 高速緩存或寄存器進行優化。

對於值對象來說,一般極少有共享的需求,假如能直接在棧上進行分配,那麼將省去對象的存儲、訪問和 GC 的成本,對程序性能非常有利。不過進行逃逸分析也是有成本的,如果在語言層面直接支持的話,就可以進一步減少編譯時分析的開銷。不過,目前 Java 語言還做不到這一點。

當一門編程語言爲上述類型的數據結構提供內在支持時,該類型可稱之爲值類型。而對於滿足上述訴求的實例,無論是基於值類型實現還是普通對象類型實現,我們都可以稱之爲值對象。

不同編程語言對值類型的支持

▐****Java

上面已經說過,Java 語言層面原生並不支持值類型。不過,它提供了許多具有值類型特點的類,比如:8 個基本類型對應的封裝類、String、BigDecimal 等,這些類的共同特點之一就是不可變性,同時也都對比較操作做了實現,因此都可看作值對象。另外一個應該設計爲不可變、但實際可變的類是 java.util.Date 類,也因爲如此,Date 類飽受詬病。在 Java 8 中官方正式推出新的 時間 / 日期 API,試圖取代 Date 相關接口,這些新的類全部被設計成了不可變類。

對於 Java 是否應該從語言層面支持值類型的討論由來已久,比如這篇 JEP 提案 [4] 早在 2012 時就提議支持值對象;oracle 論壇上的這篇博客 [5] 也對如何實現值對象做了探討。最近有兩篇提案,一個提出了 Primitive Object[6] 的概念,可算是值類型的一種實現;另外一篇提議基於 Primitive Object 統一基本類型與對象類型 [7]。不過,這兩個提案仍處於 Submitted 階段(JEP 提案從提出到發佈的流程有幾個階段,可以看 這裏 [8]Process states 一節),能否被採納、實現乃至發佈到正式版本,還是未知之數。

▐****C++

C++ 中沒有值對象這一概念,不過在創建對象時,允許開發者選擇在堆上還是在棧上創建。比如下面的示例代碼,直接通過 A a; 的方式創建的對象是分配在棧上的,而通過 new A(); 的方式創建的對象分配在堆上,並且返回一個指向該對象的指針。在棧上創建的對象在函數執行結束時會自動銷燬。

更進一步,對 A 類型的對象進行賦值(34 行)或方法傳參(38 行)時,會產生一次拷貝操作,生成一個新的對象,新對象的作用域分別爲當前函數和被調函數,相應函數執行結束時也會被銷燬。而對指針類型的對象進行賦值(43 行)和方法傳參(45 行)時,儘管創建了新的指針對象,新的指針仍然指向相同的對象。

可見 C++ 中對類類型和指針類型的使用,分別具有值類型和引用類型的一些特點。

▐****C#

C# 語言中是明確的提出了值類型 [9] 這一概念的,struct 就是一種值類型。MSDN 文檔中說明:“默認情況下,在分配中,通過將實參傳遞給方法並返回方法結果來複制變量值。” 在賦值操作時,也同樣會對對象進行拷貝。如下面的代碼所示,我們可以看到將 p1 賦值給 p2,p2 修改狀態後,p1 中的數據仍然保持不變。

另外,在 C# 中值類型是分配在棧上的,值類型與引用類型之間可以進行轉化,稱之爲裝箱和拆箱,上面的 Java Primitive Object 提案似乎也借鑑了 C# 的設計思想。

▐****其它語言

其它編程語言對值類型的支持不盡相同。以函數式編程爲例,大多數函數式編程語言中變量都是不可變的,因此在函數式語言中定義的數據結構都可看作是值類型。

DDD 中的值對象

儘管 Java 並沒有對值對象提供語言層面的類型支持,但這並不妨礙我們在自己的代碼中創建事實上的值對象。實際上值對象 [10] 的定義可以並不僅限於類似向量、顏色值、座標點這樣一些使用範圍。Martin Flower 認爲,值對象在編程中的作用被極大的忽視了,善於值對象可以非常有效的簡化你的系統代碼;Vaughn Vernon 在《實現領域驅動設計》一書中甚至說,我們應該儘量使用值對象建模而不是實體對象。實際上,當提到 “值對象” 這個概念時,最常見的就是在 DDD(領域驅動設計)這個上下文中。

Eric Evans 在《領域驅動設計 軟件核心複雜性應對之道》一書中提出了實體(Enity)與值對象(Value Object)的概念。Vaughn Vernon 在《實現領域驅動設計》中做了進一步闡述。

在 DDD 中,實體代表具有個性特徵或需要區分不同個體的對象,它具有唯一標識和可變性。對於實體對象,我們首要考慮的並不是其屬性,而是能代表其本質特徵的唯一標識,無論對象屬性如何變化,它都是同一個對象,它的生命週期具有連續性,甚至對對象進行持久化存儲然後基於存儲來重建對象,它仍然是同一個對象的延續。

而值對象,它通常是一些屬性的集合,是**對對象的度量和描述。**值對象應該是不可變的,當度量和描述改變時,可以用另外一個值對象替換。值可以跟其它值對象進行相等性比較。

可以看到,在 DDD 中的值對象的定義跟我們上面的描述非常相似。《實現領域驅動設計》對於值對象的闡述非常詳盡,想要進一步瞭解的可以閱讀該書第 6 章內容。

使用值對象的好處

因爲值對象通常設計爲不可變對象,因此值對象的好處首先就是不可變對象的好處。另外在支持值類型的語言中,值對象的創建、操作、銷燬會有更好的性能。

▐****線程安全

在 Java 編程語言中,出現線程安全問題的必要條件有兩個:對象狀態被多個線程共享;對象狀態可變。因此解決線程安全問題的思路也主要從幾個方向出發:無狀態;狀態不可變;不共享狀態;通過同步機制來序列化對象狀態的訪問。

而不可變對象狀態是不變的,因此是線程安全的,可以放心應用到併發環境中,無需額外的同步機制在多個線程中共享。

▐****避免 Alias Bug

Aliasing bug 的概念上文已經講過,主要是指多個對象的引用被分享到多個環境中後,在某個環境的改動會導致從另外一個環境中看到預期之外的變化。

最近我們的項目中就遇到這樣一個 bug,某個對象會被緩存到本地內存中,取出對象後,返回給 UI 層的某個屬性值需要根據請求環境做一些判斷與變更,由於未做防禦性拷貝,導致變化污染了緩存對象,後面的請求出現錯誤的結果。

而不可變對象不允許修改屬性值,任何狀態的變化必須通過創建副本來實現,因此可以有效的避免該類 bug。

▐****簡化邏輯複雜程度

▐****使你的設計更清晰

值對象與基礎類型數據相比,富含業務語義,在任何使用到它的地方,其含義一看便知。它還可以封裝跟數據相關的業務邏輯,避免爲了複用代碼而創建 util 類,更符合面向對象的思想。

▐****可比較、可以被集合類使用

相信這一點不需要再說明了。

值對象 Java 實踐

那麼,如何在我們的代碼中創建不可變對象呢?我們分爲部分內容來講,第一部分是指導思想,第二部分是如何進行實踐。

▐****值對象創建指南

在 《Effective Java 第三版》 第 17 條 最小化可變性一節中,將不可變類的設計歸納爲五條原則:

第 2、3、4 點很容易理解。對第 1 點,也就是說對任何涉及狀態變更的操作,都不能直接修改原始對象的狀態,而是通過創建對象的副本,比如下面對複數對象的 “加” 操作:

對於第 2 點,確保類不能被繼承,除了將類設爲 final,還有一種方式是將構造方法設爲 private,並向外提供靜態工廠方法來創建實例。

而第 5 點的意思是,“如果你的類有任何引用可變對象的屬性,請確保該類的客戶端無法獲得 對這些對象的引用”。舉例而言,下面的 Period 類,儘管滿足上面的 1~4 點,但由於其狀態變量中包含了引用對象,引用對象通過構造方法與訪問方法與外界共享,導致它的狀態也會發生變化(第 7 行、第 10 行):

一個解決方案是,不使用 Date 對象,而是使用 Java 8 中提供的 LocalDate 對象,該對象是不可變的。另一種方案,在引用共享的位置對對象進行拷貝。

由此可以延伸出:

這裏還要注意幾點:

這一點可參照《Effective Java 第三版》 第 2 條。這裏不展開了。

由於不變對象在修改數據時會進行拷貝,因此它的一個主要問題就是可能會創建過多的對象,這會帶來性能問題。一個方案是,對可能會經常用到的對象提供公共的靜態 final 常量。這一點,既可以通過公共的常量字段來實現,也可以通過靜態工廠方法來實現。

需要重寫 equals() 和 hashCode() 方法。至於爲什麼以及如何實現,相信大家都知道了,就不展開講了。

這一點也很好理解,既然值對象是不可變的,那麼創建完成之後沒有任何方法可以改變的狀態,因此必須在構造時進行必要的合法性校驗,使創建出來的對象滿足其所有的不變性條件(Invariants)。

▐****如何實現

有了指導思想,如何實現其實就一目瞭然了。只不過,要實現不可變對象,需要創建大量的樣板代碼,比如 equals() 和 hashCode() 方法的重寫、builder 模式的創建等等。這些重複代碼不僅寫起來費力,而且會使類的核心業務邏輯隱藏在大量的樣板代碼中,降低了類的可讀性。因此,最好實現方式還是借且代碼生成工具。

(i) lombok @value 註解

lombok 庫的 @value 註解可以很方便的幫我們生成一個不可變的值對象類型。如:

如果我們使用 Intellij IDEA 工具,並且安裝了 lombok 插件,可以在源代碼處 右鍵 -> Refactor -> Delombok -> All lombok annotations,來查看 lombok 註解處理器處理過後生成的字節碼對應的源代碼大概是什麼樣子。

這裏有一點需要注意,lombok 工具對於引用類型不會幫我們做防禦性拷貝,因此假如我們的構成組件包含可變對象,需要我們自己去做防禦性拷貝。做法很簡單,只要提供我們自己的構造方法和 get 方法,lombok 就不會再幫我們生成對應的方法。

如果我們要對參數進行合法性校驗,也同樣需要提供自定義的構造方法,在構造方法中添加校驗邏輯。

(ii) lombok @Builder 註解

lombok 的 @Builder 註解非常強大,可以應用在類上、構造方法上,也可以應用在靜態工廠方法上。在構建時未傳入的參數爲該類型的默認值。同樣的,如果你需要校驗,可提供自定義的全參數構造方法。

上面我們提到過,對值對象的實例儘可能的重用。如果我們使用靜態工廠方法,就可以實現這一點:

注意我們把 @Builder 註解放在了 of() 靜態工廠方法上面,同時將構造方法設爲 private。通過查看生成的代碼,發現 builder 的 build() 方法直接調用了該工廠方法。

(iii) lombok @With 註解

@Value 註解會將生成的類設爲不可變,如果我們需要修改對象的狀態,怎麼辦?上面說過,修改狀態需要創建拷貝。使用 @With 註解可以很方便的做到這一點。

(iv) 與 mapstruct 配合使用

在進行領域驅動設計時,我們經常會在不同的層或者模塊之間使用不同的對象,比如持久化層使用跟數據庫紀錄進行映射的 DO 對象,而在領域層使用更具有業務意義的領域對象。如何在對象之間進行屬性的拷貝呢?可以有很多種選擇,我最常用的是 mapstruct 工具,該工具非常強大,不僅支持不同名稱、不同類型字段的映射,還可以使用表達式、方法調用等。

對於它我們不做過多介紹,有興趣可以看這裏 [11]。

在進行屬性拷貝時,通常基於無參構造函數創建對象,然後設置對應屬性。但是上面的類,我們在實現不可變特性時,不再提供無參構造函數。如何讓 mapstruct 支持這種類呢?恭喜你,只要加了 @Builder 註解,什麼都不需要做,mapstruct 已經內置提供了對 lombok @Builder 註解的支持。

至於使用其它手段的屬性拷貝,我暫時沒有去了解,熟悉的同學可以參與討論。

(v) json 反序列化

我們知道,當使用 json 反序列化工具生成自定義類型的實例時,通常也是使用該類型的默認無參構造方法。假如沒有該構造方法,運行時就會拋出異常。但是,我們不希望提供該構造方法來破壞對象的不可變性。怎麼辦呢?

這裏又要祭出 lombok 的另一法寶,@Jacksonized 註解。加上這一註解後,我們的不可變對象就可以被 jackson json 庫順利的創建出來了(需要跟 @Builder 一起使用)。其實這個註解沒什麼複雜之處,能實現這點得益於 jackson json 庫本身對 builder 模式的支持,@Jacksonized 註解只是按照 jackson json 的相關要求生成相關的 builder 類和方法而已。目前 fastjson 庫似乎不支持使用 builder 模式來創建對象,不知道後面有沒有相關的計劃。

總結

本文通過一些簡單的案例討論了值類型與值對象的概念,並且探討了不同語言對值類型的支持情況。然後對於在 Java 語言中如何創建值對象給出了一些指導原則,並介紹了一些可用於快速實現值對象的工具。值對象的使用是一種非常有用的編程技巧,可以使我們的業務語義更加清晰,並有效的簡化代碼邏輯的複雜程度。因此,建議大家在自己的代碼中多嘗試使用值對象,相信在這個過程中必然更有更深刻的認識和感受。

相關鏈接

[1].https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-2.html#jvms-2.2

[2]. https://martinfowler.com/bliki/AliasingBug.html?spm=ata.21736010.0.0.1bf49431yt4uP0

[3]. https://zhuanlan.zhihu.com/p/94568794?spm=ata.21736010.0.0.1bf49431yt4uP0

[4]. http://openjdk.java.net/jeps/169?spm=ata.21736010.0.0.1bf49431yt4uP0[5]. 

[5].https://blogs.oracle.com/jrose/value-types-in-the-vm?spm=ata.21736010.0.0.1bf49431yt4uP0

[6].https://bugs.openjdk.java.net/browse/JDK-8251554?spm=ata.21736010.0.0.1bf49431yt4uP0

[7].https://bugs.openjdk.java.net/browse/JDK-8259731?spm=ata.21736010.0.0.1bf49431yt4uP0

[8].https://openjdk.java.net/jeps/1?spm=ata.21736010.0.0.1bf49431yt4uP0

[9].https://docs.microsoft.com/zh-cn/dotnet/csharp/language-reference/builtin-types/value-types?spm=ata.21736010.0.0.1bf49431yt4uP0

[10].https://martinfowler.com/bliki/ValueObject.html?spm=ata.21736010.0.0.1bf49431yt4uP0

[11].https://mapstruct.org/?spm=ata.21736010.0.0.1bf49431yt4uP0

作者 | 少琛

編輯 | 橙子君

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/QDh-eE868_xh7NX1XPCCyQ