閒談軟體設計:Immutable Interface

2023/08/25閱讀時間約 11 分鐘
這是很久前的舊文, 不過,回頭再看一遍,還是挺有意思的。順便更新一下,Java 9 針對 library 開發有更好的存取限制 (模組),可將某些類別和函式限定成同模組就可以存取,這很適合將實際實作的類別隱藏起來,但外部是看不到的。Swift 也有 privatefileprivateinternal 和 public 等存取限制,確實方便很多,但還是會有需要 Immutable Interface 的時候。

引子

先前和剛開始寫 iOS 的同事 pair programming,同事對於我習慣將 private member data 宣告成 read-only property 感到有點疑惑,老實說,這習慣是只有寫 Objective C 時才有的,而且不是全部的 private member data 都是如此,主因是 Objective C 對於存取限制的設計不像 C++ / Java / C# 那樣顯著,但這確實讓我時常思考:存取限制的目的是什麼?

像是 JavaScript 存取限制的設計也不明顯,最後還是回到 OO 的基本特性之一:封裝。封裝有時會被誤以為是 information hiding,我認為封裝最大的用意是讓使用者以既定規範使用被封裝的元件,避免元件被破壞。因此,在不違反這原則下,我能接受部分資訊以唯讀的方式暴露出來,有時唯讀資訊還能讓測試變容易。

另外還有一個要思考的是,除了語言本身提供對存取限制的支援外,沒有其他方法能達到同樣的效果嗎?事實上,我見過很多 Java 工程師宣告 private member data 後就立即撰寫對應的 public getter 和 setter,甚至還因為覺得寫 getter/setter 很麻煩有了像 lombok 這樣的 third-party library,如果是這樣,一開始就把該 member data 設計成 public 不是更簡單?

可是有時候確實會面臨一個兩難,某個 member data 確實是可以改的,但又不是任何人都可以改,所以若不開放 setter 會變成所有人都無法修改,但開放後又變成所有人都能改,因此像 Java 提供了 protected 和 package 層級的限制,前者讓繼承的物件能夠修改,後者讓屬於同個 package 的物件能夠修改,但有時候 A 物件和 B 物件既非繼承關係也非同 package 關係,此時這些限制都無法滿足一個需求:讓物件暫時變成 immutable (Java官方有建議的方式設計永久性immutable object,但不是我要的)。

Immutable interface

一個簡單的例子,在一個行動裝置的 App 上,可能會使用一個 User 類別記錄使用者的暱稱、ID 以及照片,除了 ID 是一創建後無法修改外,其他像暱稱或是照片都是可以修改的,因此,通常會替類別加上對應的 setter,但這會有個問題,修改暱稱或是照片可能需要和 server 同步及儲存,但持有 User 物件的對象 (例如:UI),可能誤以為呼叫對應的 setter 即可完成任務,確實可以在 setter 做完這些事情,但這會讓 User 的responsibility 變繁重,或是透過 observer 的方式,當任何屬性有變更時,通知 observer 做對應的動作 (和 server 同步及儲存),但若 observer 發生錯誤,處理錯誤變得很不乾脆,所以這兩種方式我都沒使用過。

為了不讓不合適的物件持有者修改物件狀態,可以將 getter 和 setter 分離,將 getter 全部集中到介面上,一個只有 getter 的 interface,稱為 Immutable interface,setter 則保留在實作的類別上,如Figure 1,UserInfo 僅宣告getter,而實作的 User 依舊提供 setter。當 UI 需要呈現使用者的暱稱和照片時,可以只傳遞 UserInfo 給 UI,雖然 UI 取得的是只有 getter 的物件,但這些 getter 提供充足的資訊完成顯示的任務,但如果要修改暱稱,由於沒有setter,是無法擅自修改暱稱,只能透過 UserManager 進行修改。

Figure 1 UserInfo, User, and UserManager

Figure 1 UserInfo, User, and UserManager

這只是 program to interface 一個小把戲,事實上,傳遞給 UI 和UserManager 所持有的物件可以是同一個物件,只是彼此看到的介面不同罷了。例如下面範例程式中的 DefaultUserManager 實際上所持有的是 User 實體。像這樣的技巧在 iOS 開發時也很常用,內部持有的是 NSMutableArray 但傳出去時,卻是以 NSArray 往外傳。

當 UI 需要取得 UserInfo 時,呼叫 UserManager 的 getUser(String),實作中先從記憶體當中先找有沒有符合的實體,若沒有則呼叫 Web service 嘗試向 server 取得指定的使用者資訊,若沒有發生任何錯誤,Web service 一樣會回傳 immutable 的 UserInfo 實體,接著呼叫 DAO 將該使用者的資訊儲存到資料庫中,若沒有錯誤,用該資訊建立一個 User 實體放入users 中,下次使用時不需再呼叫 Web service,接著回傳 User 實體,但對 UI 而言取得的是 immutable object。若擔心強制轉型,可複製一份再回傳。

其他妙用

由於資料物件常常有多個屬性要初始化,以 User 為例,有三個屬性要初始化,若都透過 constructor 建立,constructor 的參數會變得相當多,這變成 long parameter list 的壞味道,此時,UserInfo 正好扮演 Parameter Object 的規範,而且還是 immutable 的 parameter object (雖然 Java 有 final 關鍵字可以用,但只限制該變數無法改變所指向的物件,無法阻止呼叫 setter 改變物件內的狀態)。這樣也可以避免常常寫「用預設建構子建立物件,然後呼叫很多的 setter 完成初始化」這類重複的程式碼。

以剛剛的 getUser 為例,如果是一個回傳 JSON 的 Web service,只需替 JSON parser 分析出來的結果寫一個 wrapper (如下),就能當做是建立 User 實體所需要的 parameter object 了,雖然很多 JSON parser 都有 data binding 的功能,能直接將 JSON 轉成對應的 POJO 物件,可是如果 JSON 內的屬性名稱和 POJO 的屬性名稱不相同時,在 model object 中加入與 domain無關的 annotation (例如 @SerializedName)就讓我有點討厭。

此外,Java 不像 C++ 支援 copy constructor,雖然提供一個 clone() 函式用來複製既有的物件,但使用上,須在想支援複製的類別上加上 Cloneable 的實作,否則呼叫 clone() 時會拋出CloneNotSupportedException 例外,而且 clone() 的回傳值是 Object 型別,每次都要轉型確實有點討厭。但有類似 Code List 3 的建構子後 (其實有點像copy constructor),複製一個物件,只需將要被複製當成建構子的參數即可。使用的方式就像 Code List 5 一樣,由於 DAO 的 save(UserInfo) 函式需要一個帶有最新狀態的物件好將狀態寫入資料庫,但在沒有成功之前,又不想改變既有的物件狀態,此時只需複製一份新的物件,將新的暱稱設定到複製出來的物件內,當成save(UserInfo) 的參數,若儲存成功,在將新的暱稱設回原先的物件,如此一來,若儲存失敗,既有的物件也不會受影響。

複雜的結構

Java 的 interface 可以繼承另一個 interface,所以 Immutable interface 也可以用在原有的繼承架構上,只是會變成像 Figure 2 那樣是個平行的繼承架構。最近 IM (Instant Messaging) 很熱門,有各式各樣的 IM App,而且傳遞的訊息也不再僅僅是文字訊息,以程式來說,要表現出不同的訊息類型,可能會有 Figure 2 右半邊的繼承架構,一個抽象類別 Message 代表一則訊息,實際的訊息種類則用 TextMessageImageMessage 分別代表文字訊息或是圖片訊息。在導入 Immutable interface 時,為了符合右邊的繼承架構,可以看到 Figure 2 左半邊有一個相似的繼承關係,在這繼承關係中都有一個對應的 Immutable interface,MessageInfo 對應 MessageTextMessageInfo 對應TextMessage,而 ImageMessageInfo 對應ImageMessage

Figure 2 immutable interface 繼承架構

Figure 2 immutable interface 繼承架構

在這樣的平行繼承關係中,先前提到的建構子一樣能適用,如 Code List 6 中,TextMessage 的建構子直接把參數傳給 Message 的建構子,實際上,一則文字訊息一旦送出,內容不會在修改 (好吧,最近有些 IM 主打閱過即焚,就好像 Mission: Impossible 中的任務錄影帶一樣),所以 TextMessage 除了透過建構子將訊息內容傳入外,並沒有 setter 能夠修改訊息內容。

總結

從上面的例子,可以看出 Immutable interface 讓封裝更有彈性,不用擔心 setter 的過度開放。當不希望物件被不允許的對象修改時,只需讓對方取得 getter 的介面即可,反之,讓能夠允許修改的對象取得有 setter 的物件即可。雖然沒有提到 functional programming,但 immutable object 是 functional programming 中很重要的一環,也是常用的一種 thread-safe 技巧,Immutable interface 某種程度上 (若不理會強制轉型),不需複製物件就能達成 immutable object 的目標了。


後記

雖然 C++ 已經有相當久的歷史,但若善用 const 關鍵字(使用 const point 或 const reference 搭配 const member functions),確實不需要 Immutable interface 就能輕鬆達成 immutable object 的目的。

進階參考


51會員
100內容數
這是從 Medium 開始的一個專題,主要是想用輕鬆閒談的方式,分享這幾年軟體開發的心得,原本比較侷限於軟體架構,但這幾年的文章不僅限於架構,也聊不少流程相關的心得,所以趁換平台,順勢換成閒談軟體設計。
留言0
查看全部
發表第一個留言支持創作者!