值類型與值對象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] 。
json
上面兩點其實都容易解決,只是每一個實現須要寫不少樣板代碼。須要比較的對象只要重寫 equals()
和 hashCode
方法便可;對於可變性問題,能夠將對象設計爲不可變對象,在修改時返回一個深拷貝副原本供客戶端操做。知足上述兩種條件的對象,咱們能夠稱之爲值對象。
那麼,經過「對象」來實現咱們對這種數據結構的訴求,是不是最好的方式呢?
咱們知道,Java 中的對象一般是分配在堆上,經過引用來進行操做,不過這不是必然的。JVM 有一項技術叫 逃逸分析[3] ,能夠在運行時分析出一個方法中建立的對象是否會逃逸到方法或線程外部,若是沒有逃逸,能夠進而執行一些編譯優化,好比棧上分配、同步消除、標量替換等。若是一個對象被分配到棧上,就意味着當方法結束後就會自動銷燬,省去了 GC 的開銷,這對於優化應用內存佔用和 GC 停頓時間來講,無疑是個好消息;而標量替換意味着壓根就不會建立對象,相關數據被替換成基本類型數據直接分配到棧上,不只省去了對象操做相關開銷,也更利於 CPU 高速緩存或寄存器進行優化。
對於值對象來講,通常極少有共享的需求,假如能直接在棧上進行分配,那麼將省去對象的存儲、訪問和 GC 的成本,對程序性能很是有利。不過進行逃逸分析也是有成本的,若是在語言層面直接支持的話,就能夠進一步減小編譯時分析的開銷。不過,目前 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++ 中沒有值對象這一律念,不過在建立對象時,容許開發者選擇在堆上仍是在棧上建立。好比下面的示例代碼,直接經過 A a;
的方式建立的對象是分配在棧上的,而經過 new A();
的方式建立的對象分配在堆上,而且返回一個指向該對象的指針。在棧上建立的對象在函數執行結束時會自動銷燬。
更進一步,對 A 類型的對象進行賦值(34行)或方法傳參(38行)時,會產生一次拷貝操做,生成一個新的對象,新對象的做用域分別爲當前函數和被調函數,相應函數執行結束時也會被銷燬。而對指針類型的對象進行賦值(43行)和方法傳參(45行)時,儘管建立了新的指針對象,新的指針仍然指向相同的對象。
可見 C++ 中對類類型和指針類型的使用,分別具備值類型和引用類型的一些特色。
數據結構
C# 語言中是明確的提出了 值類型[9] 這一律唸的,www.tlbb10.com,struct 就是一種值類型。MSDN文檔中說明:「默認狀況下,在分配中,經過將實參傳遞給方法並返回方法結果來複制變量值。」 在賦值操做時,也一樣會對對象進行拷貝。以下面的代碼所示,咱們能夠看到將 p1 賦值給 p2,p2 修改狀態後,p1 中的數據仍然保持不變。
另外,在 C# 中值類型是分配在棧上的,值類型與引用類型之間能夠進行轉化,稱之爲裝箱和拆箱,上面的 Java Primitive Object 提案彷佛也借鑑了 C# 的設計思想。
併發
其它編程語言對值類型的支持不盡相同。以函數式編程爲例,大多數函數式編程語言中變量都是不可變的,所以在函數式語言中定義的數據結構均可看做是值類型。oracle
DDD 中的值對象
儘管 Java 並無對值對象提供語言層面的類型支持,但這並不妨礙咱們在本身的代碼中建立事實上的值對象。實際上值對象[10]的定義能夠並不只限於相似向量、顏色值、座標點這樣一些使用範圍。Martin Flower 認爲, 值對象 在編程中的做用被極大的忽視了,善於值對象能夠很是有效的簡化你的系統代碼;Vaughn Vernon 在《實現領域驅動設計》一書中甚至說,咱們應該儘可能使用值對象建模而不是實體對象。實際上,當提到「值對象」這個概念時,最多見的就是在 DDD(領域驅動設計)這個上下文中。
Eric Evans 在《領域驅動設計 軟件核心複雜性應對之道》一書中提出了實體(Enity)與值對象(Value Object)的概念。Vaughn Vernon 在《實現領域驅動設計》中作了進一步闡述。
在 DDD 中,實體表明具備個性特徵或須要區分不一樣個體的對象,它具備惟一標識和可變性。對於實體對象,咱們首要考慮的並非其屬性,而是能表明其本質特徵的惟一標識,不管對象屬性如何變化,它都是同一個對象,它的生命週期具備連續性,甚至對對象進行持久化存儲而後基於存儲來重建對象,它仍然是同一個對象的延續。
而值對象,它一般是一些屬性的集合,是對對象的度量和描述。值對象應該是不可變的,當度量和描述改變時,能夠用另一個值對象替換。值能夠跟其它值對象進行相等性比較。
能夠看到,在 DDD 中的值對象的定義跟咱們上面的描述很是類似。《實現領域驅動設計》對於值對象的闡述很是詳盡,想要進一步瞭解的能夠閱讀該書第 6 章內容。
使用值對象的好處
由於值對象一般設計爲不可變對象,所以值對象的好處首先就是不可變對象的好處。另外在支持值類型的語言中,值對象的建立、操做、銷燬會有更好的性能。
在 Java 編程語言中,出現線程安全問題的必要條件有兩個:對象狀態被多個線程共享;對象狀態可變。所以解決線程安全問題的思路也主要從幾個方向出發:無狀態;狀態不可變;不共享狀態;經過同步機制來序列化對象狀態的訪問。
而不可變對象狀態是不變的,所以是線程安全的,能夠放心應用到併發環境中,無需額外的同步機制在多個線程中共享。
Aliasing bug 的概念上文已經講過,主要是指多個對象的引用被分享到多個環境中後,在某個環境的改動會致使從另一個環境中看到預期以外的變化。
最近咱們的項目中就遇到這樣一個 bug,某個對象會被緩存到本地內存中,取出對象後,返回給 UI 層的某個屬性值須要根據請求環境作一些判斷與變動,因爲未作防護性拷貝,致使變化污染了緩存對象,後面的請求出現錯誤的結果。
而不可變對象不容許修改屬性值,任何狀態的變化必須經過建立副原本實現,所以能夠有效的避免該類 bug。
任何使用到值對象的地方,它的狀態始終是合法的。一般不可變對象會在建立時進行自校驗,所以一旦建立完成,它始終處於合法有效的狀態之中,沒有任何行爲能使破壞它的一致性狀態。
能夠安全的共享給其它對象、其它線程,而不用擔憂狀態發生變化,簡化了代碼維護者對流程、邏輯的理解。
值對象與基礎類型數據相比,富含業務語義,在任何使用到它的地方,其含義一看便知。它還能夠封裝跟數據相關的業務邏輯,避免爲了複用代碼而建立 util 類,更符合面向對象的思想。
相信這一點不須要再說明了。
值對象 Java 實踐
在 《Effective Java 第三版》 第 17 條 最小化可變性一節中,將不可變類的設計概括爲五條原則:
第 二、三、4 點很容易理解。對第 1 點,也就是說對任何涉及狀態變動的操做,都不能直接修改原始對象的狀態,而是經過建立對象的副本,好比下面對複數對象的「加」操做:
對於第 2 點,確保類不能被繼承,除了將類設爲 final,還有一種方式是將構造方法設爲 private,並向外提供靜態工廠方法來建立實例。
而第 5 點的意思是,「若是你的類有任何引用可變對象的屬性,請確保該類的客戶端沒法得到 對這些對象的引用」。舉例而言,下面的www.tlbb10.com Period 類,儘管知足上面的 1~4 點,但因爲其狀態變量中包含了引用對象,引用對象經過構造方法與訪問方法與外界共享,致使它的狀態也會發生變化(第 7 行、第 10 行):
一個解決方案是,不使用 Date 對象,而是使用 Java 8 中提供的 LocalDate 對象,該對象是不可變的。另外一種方案,在引用共享的位置對對象進行拷貝。
由此能夠延伸出:
儘量使用不可變對象做爲構建對象的組件;
必要時對構造方法參數和方法返回值進行防護性拷貝:(第 六、七、1四、18 行)
這裏還要注意幾點:
進行防護性拷貝應在參數檢查以前執行,以免參數檢查可拷貝期間受其它線程對參數更改的影響。
必要時,對實現 serializable 接口的類進行反序列化重寫 readObject 方法,以免字節碼***。對於這一點,簡單來說就是因爲 Java 對象的反序列默認經過 readObject 方法重建對象,而不會調用咱們提供的構造方法,這使得***者能夠經過修改字節碼數據,從而繞開構造方法中的參數校驗的防護性拷貝。具體能夠看 《Effective Java 第三版》 第 88 條 保護性的編寫 readObject 方法。
這一點可參照《Effective Java 第三版》 第 2 條。這裏不展開了。
因爲不變對象在修改數據時會進行拷貝,所以它的一個主要問題就是可能會建立過多的對象,這會帶來性能問題。一個方案是,對可能會常常用到的對象提供公共的靜態 final 常量。這一點,既能夠經過公共的常量字段來實現,也能夠經過靜態工廠方法來實現。
須要重寫 equals() 和 hashCode() 方法。至於爲何以及如何實現,相信你們都知道了,就不展開講了。
這一點也很好理解,既然值對象是不可變的,那麼建立完成以後沒有任何方法能夠改變的狀態,所以必須在構造時進行必要的合法性校驗,使建立出來的對象知足其全部的不變性條件(Invariants)。
有了指導思想,如何實現其實就一目瞭然了。只不過,要實現不可變對象,須要建立大量的樣板代碼,好比 equals(www.tlbb10.com) 和 hashCode() 方法的重寫、builder 模式的建立等等。這些重複代碼不只寫起來費力,並且會使類的核心業務邏輯隱藏在大量的樣板代碼中,下降了類的可讀性。所以,最好實現方式仍是借且代碼生成工具。
lombok 庫的 @value 註解能夠很方便的幫咱們生成一個不可變的值對象類型。如:
若是咱們使用 Intellij IDEA 工具,而且安裝了 lombok 插件,能夠在源代碼處 右鍵 -> Refactor -> Delombok -> All lombok annotations,來查看 lombok 註解處理器處理事後生成的字節碼對應的源代碼大概是什麼樣子。
這裏有一點須要注意,lombok 工具對於引用類型不會幫咱們作防護性拷貝,所以假如咱們的構成組件包含可變對象,須要咱們本身去作防護性拷貝。作法很簡單,只要提供咱們本身的構造方法和 get 方法,lombok 就不會再幫咱們生成對應的方法。
若是咱們要對參數進行合法性校驗,也一樣須要提供自定義的構造方法,在構造方法中添加校驗邏輯。
lombok 的 @Builder 註解很是強大,能夠應用在類上、構造方法上,也能夠應用在靜態工廠方法上。在構建時未傳入的參數爲該類型的默認值。一樣的,若是你須要校驗,可提供自定義的全參數構造方法。
上面咱們提到過,對值對象的實例儘量的重用。若是咱們使用靜態工廠方法,就能夠實現這一點:
注意咱們把 @Builder 註解放在了 of()
靜態工廠方法上面,同時將構造方法設爲 private。經過查看生成的代碼,發現 builder 的 build()
方法直接調用了該工廠方法。
@Value 註解會將生成的類設爲不可變,若是咱們須要修改對象的狀態,怎麼辦?上面說過,修改狀態須要建立拷貝。使用 @With 註解能夠很方便的作到這一點。
在進行領域驅動設計時,咱們常常會在不一樣的層或者模塊之間使用不一樣的對象,好比持久化層使用跟數據庫紀錄進行映射的 DO 對象,而在領域層使用更具備業務意義的領域對象。如何在對象之間進行屬性的拷貝呢?能夠有不少種選擇,我最經常使用的是 mapstruct 工具,該工具很是強大,不只支持不一樣名稱、不一樣類型字段的映射,還可使用表達式、方法調用等。
對於它咱們不作過多介紹,有興趣能夠看 這裏[11] 。
在進行屬性拷貝時,一般基於無參構造函數建立對象,而後設置對應屬性。可是上面的類,咱們在實現不可變特性時,再也不提供無參構造函數。如何讓 mapstruct 支持這種類呢?恭喜你,只要加了 @Builder 註解,什麼都不須要作,mapstruct 已經內置提供了對 lombok @Builder 註解的支持。
至於使用其它手段的屬性拷貝,我暫時沒有去了解,熟悉的同窗能夠參與討論。
咱們知道,當使用 json 反序列化工具生成自定義類型的實例時,一般也是使用該類型的默認無參構造方法。假如沒有該構造方法,運行時就會拋出異常。可是,咱們不但願提供該構造方法來破壞對象的不可變性。怎麼辦呢?
這裏又要祭出 lombok 的另外一法寶,@Jacksonized 註解。加上這一註解後,咱們的不可變對象就能夠被 jackson json 庫順利的建立出來了(須要跟 @Builder 一塊兒使用)。其實這個註解沒什麼複雜之處,能實現這點得益於 jackson json 庫自己對 builder 模式的支持,@Jacksonized 註解只是按照 jackson json 的相關要求生成相關的 builder 類和方法而已。目前 fastjson 庫彷佛不支持使用 builder 模式來建立對象,不知道後面有沒有相關的計劃。