Class 還是 Struct?論 Model 層的設計概念
最近在用 Swift 重寫一個筆記 app 的時候,又碰到了要怎麼設計整個 model 層的問題。這是由於 model 層不像 controller 層與 view 層在 Cocoa 架構裡都已經定義清楚 — 一定要繼承 UIViewController
與 UIView
— 所以你要怎麼定義 model 層都可以。當然,如果要寫文件基礎(document-based)的 app,像是 Pages 這種會把文件存成檔案的,那你可以直接繼承既有的 UIDocument
。但因為我的筆記 app 是以資料庫為基礎的(像 Photos 一樣),所以我就得面對一個完全空白的畫布,重新去設計整個 MVC 架構。
所以到底什麼是 MVC 架構?
等等⋯⋯這個問題需要先從物件導向語言開始定義,所以:
我對物件導向語言(Object-oriented Language)的有限認識
基本上我不太是一個擅長抽象思考的人,所以任何的概念我都會去找現實生活中的比喻對象。而對我來說,所謂物件導向語言就是要你去定義一個個的物件(object),而整個 app 就是由這些物件所組成的。比如說,螢幕上的一個按鈕是一個物件,而當使用者按下這個按鈕,它就會跟一個控制器物件講說「嘿,我被按了」。接著,這個控制器物件就會去執行它的工作。
這在現實生活中的喻體是什麼呢?我是覺得這根本就是一間公司,整個 app 就是一個公司。而公司是由人所組成的。比如說當你要跟一間手機廠商報修你螢幕裂開的手機的時候,你可以先去找客服人員,要求預約,然後服務人員會幫你轉接到地方的維修站,並且可能去中央部門調看你的保固期限之類的。在物件導向語言中,每個物件就像一個人員,所以你要做的事情基本上就是在決定你的公司要多少人,每個人的職責是什麼,以及職位跟職位之間要怎麼溝通等事情。
回到 MVC 架構
MVC 架構其實就是一個公司(app)架構的骨架。M 代表的是 Model,是資料的模型,比如說每個客戶的資料。V 是 View,代表的是整個 app 對使用者的顯示,是公司的門面,像是去銀行的時候的招牌或抽號機。而 C 則是 Controller,control 什麼呢?它們是實際上在操控 View 跟 Model 並處理它們傳來的資料的單位。再拿銀行來做例子好了:你要去銀行存錢。你到了銀行,拿了一張存單並在上面寫下你的帳戶跟要存的金額之類的訊息,然後把這張存單跟錢一起拿給櫃檯行員,讓他去把錢存到你的帳戶裡面。在這個過程中,存單就是屬於 View 的物件,你作為銀行的使用者透過存單去告訴行員說你要存錢。行員就比較屬於 Controller,因為是他負責處理你傳過來的存單跟錢,把錢存到行庫裡。而存單上面的資訊本身就屬於 Model,包括你這個客戶的姓名、資產等等都是。
如果拿 Apple 的定義來說的話,MVC 是長上面這樣。所以你透過存單去對行員執行動作,而行員則會透過存簿來提供你更新的資料。這是 View 跟 Controller 之間的互動,蠻好理解的。
照理說,Model 跟 Controller 應該也是要照這個方式互動。Controller 會以從使用者那邊拿到的新資料去更新 Model,而 Model 在接收到新資料的時候也會主動通知 Controller,讓 Controller 去更新 View。要注意的是,Model 的更新不一定是從這個 app 的 View 或 Controller 來的,也可能是從網路上或者資料庫本身來的。比如說一個時鐘 app,他的資料是時間,而時間就不是由使用者輸入的,而是由系統決定的,而系統的時間又是自己更新的。另一個例子是 iCloud 同步。你的照片 app 裡的照片不一定是從這個 app 輸入的,也可能是從別的裝置或別的 app 輸入的。而當有新的照片從別的地方輸入的時候,Model 應該要能通知 Controller,好讓最新的照片能自動出現在所有同步的 app 裡,不需要使用者去主動要求更新。
這個架構聽起來很不錯,把職責劃分得很清楚。然而,Apple 卻似乎總是在推廣一些違背 MVC 分責概念的做法。但在講到這件事之前,我們還必須建立一下關於 Value Type 跟 Reference Type 的概念。
Value Type versus Reference Type
在 Swift 裡,當你在定義物件的時候你會有三種選擇:Class,Struct,跟 Enum。Enum 比較特殊,也少用在 Model 物件上面,而比較常用於代表「狀態」上,所以這裡先不談。Class 跟 Struct 表面上看起來超像:兩個都可以擁有「屬性」跟「方法」,也都可以套用 Protocol。在設計的時候,它們兩個幾乎長得一模一樣,只差在 Class 可以繼承自另一個 superclass — 比如說,HondaCar
class 可以繼承自 Car
class — 而 Struct 不行。舉例來說,這是一個叫做 Car
的 class 物件設計圖:
class Car {
var color: Color
...
}
而這是繼承了 Car
class 的 HondaCar
class 設計圖:
class HondaCar: Car {
func drive() {
// Move forward!
...
}
}
在這個例子中,HondaCar
也會擁有 color 這個屬性,因為他的 superclass 有。這個是 Struct 沒有的功能,叫做「繼承」。
雖然繼承是 Class 跟 Struct 之間最主要的一個分別,但那其實只是在設計這些物件的時候才有差。實際在運用這些物件的時候,Class 跟 Struct 最大的分別我覺得不是繼承(畢竟 Struct 也可以用 Protocol 做到類似的事情),而是她們分屬的 reference 語義學與 value 語義學。要說明這個,我們還是得回到「變數」的概念上:
var car: Car = HondaCar()
這個句子實際上做了三件事。第一,它創造了一個叫做 car
的容器。第二,它從我們剛剛寫的 HondaCar
藍圖創造了一個 HondaCar
的實體(Instance)。第三,它把這個新創造的 HondaCar
實體的參照位址儲存到 car
容器裡面。或者換另一種白話一點的方式說,就是我們現在把這台新的 HondaCar
叫做 car
了,之後我們就能夠用 car
去指稱這台車。比如說我們要叫這台 HondaCar
移動的時候,就可以這樣做:
car.drive()
而我們也可以給它加上別的名字,比如說 Johnny:
let johnny = car
然後我們就可以叫
johnny.drive()
而不管是 car
還是 johnny
,指的都是同一台車,因為 car
跟 johnny
都只是名字而已,不是車本身。這就是所謂的 reference 語義學:這些變數跟常數只是用來參照實體的名字而已,不是實體本身。
然而,如果是 Struct 的話,事情就完全不同了。比如說,假設我們把一台腳踏車定義成一個 Struct 的話:
struct Bike {
func ride() {
...
}
}
我們一樣可以創造一個新的實體,並把它指派給一個變數:
var bike: Bike = Bike()
也可以用同樣的句子叫它工作:
bike.ride()
但是,但是!這個 bike
變數並不只是名字而已。在 value 語義學來說,bike
實際上是一個裝了一個 Bike
實體的容器,也就是說它是直接持有 Bike
本身的。所以,當我們使用這個句子的時候:
let hank = bike
我們實際上是創造了另一個叫做 hank
的容器,然後把 bike
的內容物複製一份丟到 hank
容器裡面。結果就是,我們現在有了兩台一模一樣的腳踏車,但是一台叫 bike
一台叫 hank
。
「等一下,這也太神奇或不合邏輯了吧!?現實生活中哪有這樣的事情?」其實是有的,只是我這比喻太差。比如說,當你要計算你的銀行利息會有多少的時候,你是直接把你的帳戶餘額本身拿出來加減乘除嗎?除非你可以駭進銀行的資料庫,不然你大概只能把餘額的數字抄到紙上去做計算。這個「抄」的過程,其實就是屬於 value 語義學的,因為你在更動你抄到紙上的餘額的時候,你銀行裡的餘額並不會跟著改變。
另一個更顯然的例子是拷貝。比如說,當一本書被印了兩千本的時候,這兩千本拷貝並不只是「原版的參照」,而是個別的實體。所以,當原版更動的時候,這兩千本並不會跟著更動。與之相反的是電視廣播,當新聞台送出的訊號有任何變動的時候,觀眾在家裡面的電視機就會播出最新的畫面。此時,家裡的電視機上的畫面就是新聞台送出的訊號的參照,並不是拷貝。電視台一更動畫面,全台灣在播放同一頻道的電視機都會一起變動畫面。可以說畫面就只有一個,而不同的螢幕就是對這個畫面的參照。在這兩個例子中,紙本書就是 value 語義學,而電視機是 reference 語義學;一個儲存 value (文字)本身,一個儲存的只是 reference(取得頻道畫面的管道)。
終於可以開始討論 Model 層的設計⋯⋯嗎?
我會想做這個研究的動機,從一開始是需要在 Class 跟 Struct 之間選一個來設計我的筆記 Model,後來變成是因為看了一個 WWDC 上 Apple 工程師提出的 value 語義學概念講座。在講座中,該團隊大力推崇使用 value 語義學去作為設計整個 MVC 架構的基礎。最核心的理由,是一個叫做「Local Reasoning」的概念。他們說:
Local reasoning means that when you look at the code, right in front of you, you don’t have to think about how the rest of your code interacts with that one function.
Local reasoning 的意思就是說當你在看你的 code,你面前的 code,你不用去想你其他的 code 是怎樣跟這個 function 互動。
這是因為在 reference 語義學中,容易會有一個實體被不曉得哪來的物件更改的問題。比如說,在公司裡,你大概不會想要把一份重要的簡報檔開放給所有人編輯,因為可能會有人把整個簡報檔的圖片弄不見,然後你還找不到是誰做的。如果是在 value 語義學中,這件事不會發生,因為其他人持有的都只是原版簡報檔的拷貝,最後還是要交回給你做參考,並由你去對原版簡報檔做編輯,或者可能你覺得某份做得不錯就直接把它當作原版也可以。
於是,在講座中所提到的示範專案裡,所有的 model 就都被設計成 struct:
然後當你要另一個 controller — 我們叫它 Doug
好了 — 去處理一個老闆 controller — Boss
— 持有的簡報檔 model 的時候,你會直接要 Boss
把簡報檔交給 Doug
,並要 Doug
在更動他的簡報檔之後回報給 Boss
。就跟前面提到的簡報檔的比喻一樣,Boss
手上的簡報檔不會因為 Doug
亂改就跟著亂掉,因為他們拿的是各自的簡報檔。這樣一來,每個人的職責就變得更清楚,誰對簡報檔亂搞也更容易抓出來了。這就是 local reasoning 的好處。
更棒的是,如果 Boss
對 Doug
做的簡報檔不滿意的話,他只要不把 Doug
版的簡報檔拿來用就好了,因為他自己也有一份沒有改過的簡報檔。在程式設計中,這代表當使用者要復原上一步的時候,只要把舊的 model 拿出來就好了,而這是 class 比較難做到的事情 — 因為全部人都共享一份簡報檔,而該份簡報檔已經被修改過了。沒有特別拷貝一份的話,那要怎麼回到舊版的狀態?這是 value 語義學中,狀態保存容易的優點。
Value 語義學對 MVC 架構的衝擊
一切聽起來都很美好,那我還等什麼,趕快用 value 語義學來設計我的 app 啊!但等一下,讓我們看一下一開始的 MVC 架構圖:
在這圖中,Model 是具備主動通知 Controller 說自己被修改的能力的。也就是說,他並不只是一個被動的簡報檔,而還要有一個機制去告訴所有持有他的 controller 説「嘿,我被修改了」。然而,在該示範專案中,實際上通知 Controller 說 Model 被修改了的是另一個 Controller。在我們的例子裡,就是 Doug
去通知 Boss
說簡報檔被修改了,而不是簡報檔本身。這代表說這個示範專案實際上是打破了 MVC 的分責,讓 Controller 也兼具 Model 的職責。
簡單來說,value 語義學在讓 Controller 之間的分責明確之時,卻讓 Controller 跟 Model 之間的分責模糊了。
你可能會說,這事好解決,給簡報檔加上通知的功能就好。就好像在 Google Drive 上面工作一樣,有什麼變動都會通知所有共享人員,不是很方便嗎?但首先,Google Drive 的共享文件是 reference 語義學的,因為它只有一份所有人一起編輯的原版。第二,你還是需要有 Google Drive 這個平台來監控 Model 任何的變動。放到 MVC 架構圖來說的話,就會變成這樣:
也就是說,你也必須通過 Google Drive 來編輯這個簡報檔才行。如果你把簡報檔下載到電腦裡自己編輯,也不放在 Google Drive 資料夾裡的話,那一樣不會有人知道你的簡報檔已經被編輯了。而由於 Google Drive 是負責通知 Controller 說簡報檔被修改了的物件,所以它在圖中應該是屬於 Model 層的。
結果,我們又回到 reference 語義學架構來了,因為持有簡報檔 model 的不是 Controller,而是 Model 那邊的一個單例(Singleton)。這樣一來,又一樣可能會發生不知道誰去跟 Google Drive 溝通的問題。
這並不是說去想其他通知手段,比如說通過一個 NotificationCenter
去發送通知廣播就能解決的問題。用 NotificationCenter
的做法,雖然可以實踐 local reasoning,但實際上整個架構會變成這樣:
Controller 直接持有簡報檔,然後有任何更動的時候把更動過的簡報檔推送給 NotificationCenter,再讓它去通知其他的 controller。於是,NotificationCenter is the new Model,而簡報檔則變成一個本地的快取,就好像手動把 Google Drive 上的文件下載下來、編輯完之後再上載一樣。其實這個方法還不錯,同時保有一些 value 語義學的特性與 MVC 架構。然而,這還是沒有真正達到 value 語義學,因為 value 語義學真正的敵人就是對 Singleton 與 class 的依賴。換句話說,value 語義學真正想達到的就是 Controller 與 Controller 之間完全分責,而那會讓 MVC 架構變成這樣:
Well,也就是說 MVC 架構會變成 VC 架構。那個講座裡面的示範專案就真的是這樣寫的。因為完全的 value 語義學就是要讓 model 除了透過 controller 去傳遞給別的 controller 之外 沒有任何其他方法可以用。任何其他會牽扯到 reference type 或者 Singleton 的架構都會造成 model 變得 global 而不是 local,因而違反了整個 value 語義學的理念。
總得來說,value 語義學跟 MVC 架構本身是相沖的。只要你想要保有 MVC 架構,那不管怎樣你都一定會放一個 class 或者 Singleton 在 Model 層裡面,如此一來才能夠達到主動通知 Controller 層的功能。
好吧⋯⋯如果變成 VC 架構又怎樣?我還是會得到許多好處啊!
首先,雖然 controller 之間可以分責明確,但 Controller 層跟 Model 層之間的分責怎麼辦?現在你的 Controller 層就變成也要負責整個 Model 層的操控與通知,等於說原本 Model 層的工作也都交給它做,那原本已經有近千行的 Controller 程式碼不就要變得更肥大?也許你可以把 ViewController 跟 ModelController 分開來,但實際上那只是單純的把檔案分開而已,因為兩者之間的耦合程度仍然會高到不正常,而且又再度可能會變回反 value 語義學的 MVC 架構去。
然而,如果不堅持 Model 層是全 value type 的話,那其實還是可以透過單例如 NotificationCenter 來達到 local reasoning 的效果,保有容易測試等的好處。
另一種可能性:以 Model Class 本身作為 API
這是我從 Realm 那裡得到的靈感。由於如果要用 NotificationCenter 或者 ModelManager 之類的單例或物件來跟 Model 層溝通的話,還是會增加 Controller 層跟 Model 層之間的依賴,因為 Controller 必須要知道要怎麼跟它們溝通才行,沒辦法說當作整個 Model 層不存在。換句話說,你必須要知道怎麼操作通知系統才行,沒辦法只懂要怎麼用 PowerPoint 也可以達到通知所有人的效果。然而,reference-type model 卻可以讓你在只懂怎麼操作 Google Slides 的時候就在後台自動通知所有人有更新。
Realm 資料庫系統就是採用這樣的設計概念。你並不是設計一個特殊的 Model 層介面去給 Controller 使用,比如說
class ModelManager {
func image(for indexPath: IndexPath) -> Image {
// Return an image from the database.
...
}
}
然後這樣呼叫:
let image = modelManager.model(for: indexPath).image
而是直接用 Swift 既有的語法,像是直接定義
class Model {
var image: Image {
get {
// Return an image from the database.
...
}
}
}
所以,呼叫的時候就只需要這樣:
let image = models[indexPath.row].image
如此一來,即可大大的減少 Controller 對 Model 層的依賴程度。它唯一需要知道的,就只是 Model 的介面而已,而在 Swift 中,物件的介面的自訂程度是很高的,包括 subscript (像 models[subscript]
這樣的語法)都可以自訂。於是,Controller 就完全不需要知道任何特殊的 ModelManager type 或者 Notification type 也可以完美的運作,因為它只需要知道一個型別,那就是 Model 本身。也就是說,Controller 本身應該要只對 Model 做這些事:
// Updating an existing model.
models[indexPath.row] = updatedModel // Not necessary since it might have been updated already.
tableView.reloadRows(at: [indexPath], with: .automatic)
// Deleting a model.
models.remove(at: indexPath.row)
tableView.deleteRows(at: [indexPath], with: .automatic)
// Inserting a model.
models.insert(newModel, at: index)
tableView.insertRows(at: [indexPath], with: .automatic)
所有其他的商業邏輯或資料庫存取之類的事情應該要完全寫在 Model class 裡面,包括發送更新訊息也是。也就是說,要做到不管 Model 是本地還是雲端,存在資料庫還是存成檔案,都不用去改到 Controller 的程度。所以,雖然對 Controller 來說這樣的寫法看似方便,但實際上要花更多的時間去設計 Model class,因為它會在承擔許多存取邏輯的同時還要把自己封裝成跟一個 dumb model 長得一樣的東西。
另一個 reference-type model 的特點,同時也是 value 語義學極力避免的事情,就是全局同步。這雖然可能會造成難以測試、過度耦合等後果,但是如果說你有一個複雜的資料結構,像是有連續三層的父子關係,並分別對應到四個層級的 ViewController(就是我的狀況)的話,那要怎麼保持每一層的 ViewController 都有拿到最新版的 Model 就需要花更多功夫去維護。雖然這可能有益於 local reasoning,但也可能會造成整個 Model 層的邏輯混亂的問題。比如說,你可能只是在最底層的 GrandchildModel
動一下他的名字,但這就會造成整個家族上到 GrandparentModel
都被複製一輪。
再者,有些 Model 直覺來想就應該是 reference type,尤其當這個 Model 是有身份的時候。比如說我的筆記 app 中,我很常會有兩個 value 一模一樣的 NoteModel
,但他們仍然是不同的個體。雖然我可以靠 UUID 之類的方法去給 struct 也加上身份識別的功能,但在程式邏輯上就會變得很不直覺。
直覺 Coding
直覺是我認為不輸 local reasoning 的一個重要概念。簡單來說,我認為程式碼應該是不需要寫任何 comment 就可以輕鬆看懂的。每個物件、屬性與方法的命名都應該要可以簡單地描述他們所做的事情,換句話說,他們做的事應該要可以被簡單的描述。當直覺與 local reasoning 相沖的時候,我會選擇直覺,因為雖然你可能得要跳到別的地方去了解你的程式碼,但總比留在原地想半天這個方法到底是在幹嘛來得好。當然,好的 local reasoning 應該要是直覺的,反之亦然。
這也是我偏好基本 Swift 跟 MVC 架構的原因:它們架構清楚,容易理解。雖然你可能要花比較多時間在 UIViewController
裡面一一描述要對每一個使用者事件做出什麼反應,然後每次更新 Model 都要自己手動去更新 View,但這至少讓誰在哪裡負責什麼是清楚的。比如說,如果我按了一個按鈕卻沒有反應,我可以到 ViewController
裡面去檢查我的 didPressButton(_:)
是不是出了什麼問題。
同時,我也認為 Delegate 是最好的設計模式之一。雖然我實在對 Value-oriented programming 很頭痛,但 Protocol-oriented programming 我卻是完全贊成。雖然我大多時候都選擇用 Class 而非 Struct,但並不代表我就喜歡用它的繼承功能。絕大多數時候,我能用 Protocol 來解決的事情就用 Protocol,因為它可以大幅減少耦合。其中一個例子是 Delegate 模式。Delegate 的限制很明顯:一個物件一次只能有一個 delegate。但這也代表說如果我從頭到尾都只用 Delegate 來實作通知的話,那我一樣可以增強 local reasoning — 因為所有的事情就只會在某個 controller 跟他的 delegate 之間發生。
其實 Protocol 本身除了是一個很好的抽象方法之外,更是一個很方便的封裝工具。比如說前面提到的 Model class,它完全可以被定義成一個 protocol,然後由一個 ModelManager 來套用這個 protocol。雖然這個 ModelManager 原本可能有一堆特殊的方法,但當 Controller 去用 Model protocol 跟它溝通的時候,Controller 就會完全不知道這些特殊方法,而只看得到 protocol 有定義的東西。甚至,Controller 連他在溝通的是跟原本的 Model class 不一樣的 ModelManager 都不知道。這也讓我們不用多花時間去把 ModelManager 的所有方法重新命名。當然,這算是治標不治本,但理論上 Protocol 仍然是一個減少耦合的好工具,所以多用無妨。
結論
我最後還是選擇用 Class 作為我筆記 app 的 Model type。最主要的原因,是因為自從我試著轉移到 value 語義學之後,整個程式的邏輯都變得複雜起來,我也覺得越來越難找到 bug 的位置。我想這並不是因為 value 語義學本身的問題,而是因為我的資料 Model 本來就比較適合 reference type。畢竟我不會去比較兩個筆記的數值,但我會去比較兩個筆記是不是同一個筆記。
我想,了解每一個案子的性質,尤其是資料的性質,然後去了解 value 語義學跟 reference 語義學之間的差異,最後才做出決定,應該才是最好的做法。某些情況下你會覺得 class 很讓人頭痛、無法預測,但我這次的情況則是 struct 讓我很頭痛、難以掌控。其實,適當的混合兩者也是不錯,比如說我的筆記 model class 裡面的 Array 屬性就是屬於 value type,這讓我在想要複製 Array 的時候不必擔心造成原本 model 裡面的 Array 屬性也被變更。我也在思考說把 model 裡面的所有 value 屬性都包起來變成一個 value type,這樣子在實作 undoManager
的時候應該會方便很多。
參考資料
Value and Reference Types — Swift Blog — Apple Developer
Should I use a Swift struct or a class? · faq.sa
mikeash.com: Friday Q&A 2015–07–17: When to Use Swift Structs and Classes
Swift models as structs or classes?
Protocol and Value Oriented Programming in UIKit Apps — WWDC 2016 — Videos — Apple Developer