起因
之前一直寫C#,因為有GC,所以不用關心物件的複製問題,預設的淺複製就夠了,也就沒去深究struct、class、值、引用間的區別,前段時間寫了點C++,C++需要手動管理記憶體,如果類包含有指標或引用成員時就要遵循rule of three,實現複製、賦值複製和析構以正確管理資源,這種生命週期管理函式寫多了感覺挺枯燥的,而且我發現許多domain object沒有必要實現複製,因為大多數domain object並不適合用值語義來表達,首先它們本質不是一種“值”,並且我希望傳遞時傳遞同一個物件,而不是一個副本。
C#中同時保留了值型別和引用型別,而我幾乎從未寫過自定義的值型別。於是我就開始思考值物件和引用物件到底有什麼區別,隨即寫下此文分享本人的見解。
值物件(value object)
什麼是值物件
值物件是標識取決於狀態的物件
你現在拿出一張紙幣,它就是典型的值物件,雖然每張紙幣都有一個唯一的編號,但實際使用中這個編號是沒有意義的,我們關心的是它們的面值。
這裡紙幣的編號就對應值物件的標識(比如記憶體地址),紙幣的面值就對應值物件表示的值。
其它值物件的例子:IP地址、RGB顏色、GUID、地理座標、日期時間。
我的理解:值物件是用來表達一個資訊的物件,它的狀態是靜態的,且比較“透明”;我們使用值物件時,關心的是它所表達的資訊,而不是這個物件本身
值物件與不可變性(immutability)
值物件應該被設計成不可變的(immutable),因為
- 從理論上講值物件存在的意義就在於表示一個值,它的狀態決定了它的標識,如果狀態修改了,那就是一個新的值,新的物件。
- 簡化程式設計、避免bug
如果值物件可變,當它被共享時,一處修改可能會影響另一處,修改操作就會產生“副作用(side effect)”,如java中的java.util.Date就是可變的引用型別,使用不當就會產生問題:
1234567891011121314151617//表示一個定時任務class Task {//設定執行時間public Date setStartDate(Date date){ this.startDate = date; }//獲取執行時間public Date getStartDate(){ return this.startDate; } //FIXME: 改成 return this.startDate.clone(); 相當於“手動實現值語義”//延遲執行時間void delay(int delayDays) {this.startDate.setDate(startDate.getDate() + delayDays);}private Date startDate;//執行時間}task1.setStartDate(new Date("2016/01/01");task2.setStartDate(task1.getTaskDate());task2.delay(5); //這裡本想修改task2的日期,但無意中影響到了task1
這裡的問題在於我們關心的其實只是一個“日期值”而不是一個java.util.Date物件,所以在傳遞時應該是傳遞表示的日期值,而不是直接傳遞java.util.Date物件。註釋中的FIXME標註是一種解決辦法。
值物件必須要實現為不可變嗎?
但是如果把一個類實現為不可變的話,意味著修改一個成員就要建立一個新的物件,如果成員非常多的話,程式碼寫起來會比較繁瑣。
有些語言支援值語義(value semantics),物件傳遞時是傳遞的它所表示的值(傳值),而不是傳遞物件本身(傳引用),這就意味著傳遞過程就是“複製”,比如C++預設就是值語義、C#中的struct等,因為是複製,所以值物件不會被共享,也就沒有了上面提到的那個問題,這種情況下,值物件可變的話也是完全可以的,但是值物件仍可能會被“按引用”傳遞,所以把值物件實現為不可變是最保險的手段。
值物件的實現
- C++
C++預設就是值語義,如果類狀態較簡單,則可以不做任何特殊處理。如果比較複雜,比如成員包含指標或引用,則就要實現生命週期管理函式 - C#
- 如果類狀態較簡單,可直接用struct,因為struct正好就是值語義
- 如果狀態較複雜則用class,類的設計者應該從介面上把它設計成不可變,如果沒有這樣設計,則使用者最好在使用時“手動實現值語義”(見上java.util.Date示例)。
- java
同C#中的class
與值物件相關的概念
struct與class
不管是C++和C#中的struct關鍵字,還是ruby中的Struct::new都是趨向用於定義簡單的複合型別,所以struct適合用來定義沒有複雜行為和狀態的值物件。而class更趨向用於定義具有豐富邏輯、複雜狀態的物件型別,struct、class和值物件、引用物件並不是一一對應的關係,但一般而言,值物件都不會太複雜。
字串
拋開具體的實現,字串是一個靜態的字元序列,它的狀態決定了它的相等性,是一種值。
問題1,為什麼在C#、java中字串都是引用型別呢?
“引用型別”是具體語言/平臺實現中的概念,“值物件”,“引用物件”是語言無關的,引用型別的物件也可能是值物件,只是引用型別的物件不具有原生的值語義。
將字串實現為”引用型別“更多是出於效能考慮。
- 避免複製。傳引用,可以避免發生內容的拷貝。
- 可以實現String interning(字串扣留)。扣留操作會檢查一個全域性字串扣留池,看有沒有與給定字串內容相同的已被扣留的字串物件,如果有則返回,沒有則進行扣留。如果程式中有很多內容相同的字串物件,這樣能節約記憶體,這也是Flyweight模式的一個案例。
java示例:
12345String str1 = "string interning"; // string literal會被自動”扣留“String str2 = new String(str1); // 這時產生了兩個內容相同的String物件System.out.println(str1 == str2); // 輸出falsestr2 = str2.intern(); // 這時 str1 和 str2 指向的是同一個String物件System.out.println(str1 == str2); // 輸出true
C#示例:
12345var str1 = "string interning";var str2 = new String(str1.ToCharArray());Console.WriteLine(Object.ReferenceEquals(str1, str2)); // falsestr2 = String.Intern(str2);Console.WriteLine(Object.ReferenceEquals(str1, str2)); // true
問題2,為什麼C++中的std::basic_string和Ruby中的String都是可變的?
很簡單,這樣用起來更方便。對於C++,可以用const實現不可變性,而Ruby是通過Symbol來表示唯一的、不可變的字串。
C#中的值型別
C#語言中的型別分為兩類“值型別”和“引用型別”,strut和enum屬值型別,class屬引用型別。C#編譯器處理struct時讓該型別繼承了System.ValueType這個抽象類,而enum則繼承自System.Enum,System.Enum還是繼承自System.ValueObject。
值型別 和 引用型別的區別只有一個:一個是值語義(傳值,複製),一個是引用語義(傳引用),至於什麼“一個分配於棧,一個分配於堆”,這是具體實現的問題,而且值型別不一定分配於棧,比如作為引用型別的成員(被捕捉到閉包中同屬該情況)。
引用物件(reference object)
引用物件是相等性取決於它的標識的一種物件。
為什麼“相等性取決於標識”?因為在使用引用物件時,我們關心的是這個物件本身,在傳遞過程中,需要傳遞同一個物件,所以需要傳遞物件的“引用(即標識、一般是記憶體地址)”,這也就是“引用語義(reference semantics)”。
我們實際寫程式中,使用物件的目的在於對映問題域中的事物,基本關心的是物件本身,所以物件大多都屬於引用物件。
實現
- 在C#和java中用class定義的型別叫“引用型別”,具有引用語義,建立的物件天生就是引用物件
- 在C/C++中通常通過分配堆記憶體和傳遞指標實現
參考
打賞支援我寫出更多好文章,謝謝!
打賞作者
打賞支援我寫出更多好文章,謝謝!
任選一種支付方式