用 Swift 寫 app 的時候,我們通常會在一開始就碰到一個問題:我們的 App model 應該宣告成 struct 還是 class 好?
比如說,假設我們在開發一個通訊錄 app,而我們確定要建立一個叫做 Contact
的型別來代表每筆通訊資料,且它需要有 var name: String
跟 var phoneNumber: String
兩項屬性。問題來了:不管是 struct 還是 class 都符合我們的要求,因為它們都同樣可以擁有方法跟儲存屬性 (stored property)。那麼,我們該如何選擇好呢?
Struct 與 Class 的不同性質
讓我們先來回顧一下 struct 與 class 的基本差別是甚麼。首先,當我們指派 (assign) 一個實體給一個辨識符(identifier,也就是變數/常數名)的時候,如果該實體是 struct 的話,該辨識符所容納的會是該實體的所有內容;但如果它是 class 的話,這個辨識符就只會容納存放該實體的位址:
// 用 struct 定義 Dog。
struct Dog {
var name = "Bart"
}
// 整個 Dog 實體都會被存到 myDog 裡。
var myDog = Dog()
// 用 class 定義 Cat。
class Cat {
var name = "Mimi"
}
// myCat 只會儲存 Cat 實體的位址。Cat 實體本身會被存到別的地方。
var myCat = Cat()
也就是說,當我們使用辨識符的時候,如果它的型別是 struct 的話,我們在操作的實體都會是本地的。但是當我們在操作 class 型別的辨識符的話,那麼我們實際上是透過辨識符在操作一個遠端的實體。所以,當我們更改這些實體的屬性的時候,它們的行為就不太一樣了:
// 使用 struct。
var herDog = Dog() {
// 如果 herDog 有變動的話就顯示訊息。
didSet {
print("Her dog is changed!")
}
}
herDog.name = "Starlord"
// Her dog is changed!
// 使用 class。
var herCat = Cat() {
didSet {
print("Her cat is changed!")
}
}
herCat.name = "Mumu"
// 沒有訊息。
怎麼會有這樣的差別呢?因為 herDog
儲存了所有的 Dog
實體內容,所以任何 Dog
實體的屬性的變動,就等於說 herDog
本身有變動。然而,herCat
並沒有儲存 Cat
實體的內容,所以 Cat
實體屬性的變動是在別的地方發生的,且 herCat
本身所儲存的 Cat
實體位址並沒有任何的改變。
這樣的性質還造成很多行為上的差異,但現在我們只要著重於 struct 的本地性與 class 的遠端性就可以了。接著,讓我們來看看 model 又是甚麼東西。
MVC 中的 Model
其實,這篇文章的題目本身是有問題的。為甚麼呢?讓我們看看 Apple 的 MVC 架構在官方文件裡長甚麼樣子:
是的,model 其實並不只是一個物件,而是一整個層 (layer)。在 model 層裡面,根據這張圖來說,至少有兩種不同的物件:資料物件與文件。當中,資料物件就是原初本文所指的「model」,反映了實際上的資料。而文件則較難從名字猜到意思,但其實它就是資料的管理者,負責跟 controller 層溝通。溝通甚麼呢?
依照這張圖,我們可以知道,文件負責接收 controller 層的更新指令,並且在資料有變動的時候,回頭通知 controller 層。圖中沒畫到的是,它還負擔了與磁碟、資料庫或網路等外部儲存空間溝通的責任,才能儲存資料或偵測資料變動。順帶一提,在這篇文章裡,「文件」所指的包含了所有的資料管理者元件,像是 Core Data 的 NSManagedObjectContext
,或是任何負責下載、上傳資料物件的網路層元件,如自訂的 NetworkManager
等。
有了以上這些資訊,我們可以推導出,文件最好用 class 來寫。怎麼說呢?首先,文件必須要有一個持續存在的實體,如此才能夠隨時通知 controller 資料的變動,並且進行非同步或甚至自動的資料更新。這點 struct 與 class 都辦得到:
class ViewController: UIViewController {
// 不管 Document 是 struct 或 class 都可以持續存在,除非 ViewController 本身被消滅。
var document = Document()
}
然而雖然程式碼長得一樣,概念上它們卻是全然不同的。還記得 struct 的本地性與 class 的遠端性嗎?如果我們的 Document
是 class 的話,就等於我們在某個地方新增了一個 Document
實體,而 ViewController.document
就只是用來存取該實體的一個管道而已:
但如果 Document
是 struct 的話,它的實體就會直接活在 ViewController.document
裡面,像這樣:
這樣已經稍微打破 MVC 的區隔了,但還不算甚麼大問題。真正的大問題出現在當我們將 document
指派給新辨識符,或者傳給函式當參數的時候。如果文件是 class 的話,所有這些動作都只會複製 Document
實體的位址,不會複製實體本身:
但如果文件是 struct 的話 ⋯⋯
每次指派都會複製一個新的實體出來。這對於文件的職責來說,不只不必要,還會造成混淆。文件必須要持續存在才能發揮功用,所以當我們用了某一個實體,我們就必須確保接下來這個實體會持續存在,而且我們只能繼續用這個實體。Class 的遠端性設計確保了不管怎麼換辨識符,它的實體都只會有一個;但 struct 的本地性設計就讓實體的複製變得非常簡單,因此我們很容易不小心就複製了實體而不自知。所以,文件定義成 class 會是比較合理的做法。
關於資料物件
然而,資料物件的部分就自由很多了。由於不需要像文件一樣只保持一個實體並持續運行,所以 class 的遠端性在這裡並沒有直接的優勢。事實上,連 Apple 自己都同時在推行兩種不同的做法:一方面推廣所謂的值語義 (value semantics) ── 也就是 struct 的本地性,一方面卻也持續更新以 class 的遠端性為基礎寫成的 Core Data。
我們先來看看另一個以 class 來定義資料物件的框架 Realm 怎樣運作:
// 資料物件是用 class 寫成的。
class Dog: Object {
@objc dynamic var name = ""
}
// Realm 是由框架提供的一個文件元件。
let realm = try! Realm()
// 由於 Dog 是 class,所以 theDog 只是一個用來操作遠端 Dog 實體的辨識符。
let theDog = realm.objects(Dog.self).filter("name == 'Bart'").first
// 儲存資料。
try! realm.write {
// theDog 的實體跟 realm.objects(Dog.self).filter("name == 'Bart'").first 的實體是同一個,所以我們不需要再將 theDog 寫回 realm 裡面,直接操作就好。
// 雖然 theDog 是常數,但它的屬性並沒有被存在它裡面,所以只要屬性本身是變數,該屬性就可以被更動。
theDog!.name = "Janet"
}
可以發現,以 class 寫成的資料物件 Dog
,用起來就好像在操控一個介面一樣,因為我們是透過 theDog
這個辨識符,來操縱一個存在於某個地方的實體,而該實體又正被 realm
管理著。事實上,Core Data 與 Realm 都是資料庫的一種包裝,而 class 的遠端性正好適合拿來做成操縱資料庫的介面。
那麼,把資料物件寫成 struct 又會是甚麼樣子呢?
// 用 struct 定義資料物件型別。
struct Cat {
var name = ""
}
// 假設我們有一個文件類型叫做 Document。
let document = Document()
// theCat 儲存了 document 裡面叫做 Mimi 的 Cat 實體的拷貝。
var theCat = document.cats.first(where: { $0.name == "Mimi" })!
// 更改 theCat 的時候並不會影響到 document 裡的叫做 Mimi 的 Cat。
theCat.name = "Jason"
// 所以,我們需要手動把 theCat 存回原本的位置,覆蓋掉原本的 Cat 實體。
if let index = document.cats.firstIndex(where: { $0.name == "Mimi" }) {
document.cats[index] = theCat
}
// 儲存資料。
document.write()
我們可以看到,現在當我們更動 theCat
的屬性時,唯一影響到的就只有 theCat
自己而已。如果我們要更新 document
裡面叫做 Mimi
的 Cat
的話,我們必須將 theCat
覆蓋回去才行。雖然多了這個寫回去的步驟好像比較麻煩,但也確保了任何對文件做的更動都有被記錄下來,並讓程式碼的邏輯更清晰些。
總的來說,用 class 來定義資料物件的話,就好像是在用雲端共享文件一樣:每個人的螢幕上都會有一份文件可以編輯,但這個文件並沒有存在電腦裡,而是跟雲端的版本連線,所以所有的變動都是直接在雲端版本上更新的。好處是方便,壞處是誰修改了甚麼東西經理不會知道(class 本身沒有帳號功能!)。
用 struct 的話,則是像傳統的離線文件檔案一樣。一開始文件只有經理有,而如果他想要讓手下小美去修改文件的話,他就需要拷貝一份檔案給小美。小美修改完檔案後,必須把它交還給經理,然後經理再決定要不要用修改過的檔案取代原本的文件。
讓我們先整理一下上文的概念
Model 不是一個單一的物件,而是一整個層,裡面可能包含了文件(資料物件管理者)與資料物件這兩種元件。其中,文件最好是用 class 來定義,因為它肩負了許多溝通的工作。資料物件則是資料的代表,要用 class 或 struct 來定義都可以,但兩種定義方式所延伸出的語法結構會很不一樣。Class 的辨識符就像是一個操作介面,用來操作一個遠端的實體,所以 class 的資料物件實際上並不在本地(某個函式或某個物件之內)。struct 的辨識符則直接保存了整個實體,所有的操作都是本地的。
用 class 定義資料物件的話,任何變動只要執行一次就可以了,因為它的實體只會有一個。然而,用 struct 定義的話,有多少個辨識符就會有多少個實體 ── 即使它們都指向同一筆資料。所以,我們需要將變動手動套用到文件所管理的那份實體,好讓整個 app 都能使用最新的資料。這雖然寫起來較為囉唆,卻讓閱讀與維護更為簡單。至於兩者孰優孰劣,就交由各位去針對不同的專案決定了。之後,我們會再討論 class 與 struct 的資料物件在不同的 controller 之間傳值的各種方法。
如果有多個 Controller 物件
我們已經了解到 MVC 架構中的 model 層裡,有文件與資料物件兩種負不同責任的元件,而 controller 層就是透過文件在與 model 層互動的。然而,如果 controller 層裡不只有一個 controller 物件的話(官方的專案模板裡就已經有 AppDelegate
跟 ViewController
兩個),它們分別又可以怎麼跟文件物件溝通呢?
假設我們在寫一個社交 app。這個 app 裡有兩個場景,一個是動態時報,由 PostTableViewController
所代表。另一個是貼文頁面,以 PostViewController
為代表。而這裡的文件,是一個叫做 PostManager
的物件,而且具有以下幾個方法:
class PostManager {
// ...
// 跟伺服器要 [Post]。
func fetchPosts(handler: @escaping ([Post]) -> Void) {
// ...
}
// 跟伺服器用 identifier 要 Post。
func fetchPost(identifier: String, handler: @escaping (Post) -> Void) {
// ...
}
}
那麼,我們可以怎麼樣讓兩個 view controller 跟 PostManager
互動呢?
所有的 Controller 物件都直接存取文件
首先,我們可以讓它們都直接跟 PostManager
溝通:
要怎麼實作呢?當我們從動態時報點了一個貼文,需要呈現貼文頁面的時候,我們可以直接把 PostManager
傳給 PostViewController
去用:
class PostTableViewController: UITableViewController {
// 持有一個 PostManager 實體。
var postManager = PostManager()
func presentPostViewController(identifier: String) {
let postVC = PostViewController()
// 直接把自己的 postManager 傳給 postVC 用。
postVC.postManager = self.postManager
// 給 postVC 一個可以用來取得 post 的辨識符。
postVC.identifier = identifier
present(postVC, animated: true)
}
}
class PostViewController: UIViewController {
// 僅持有 PostManager 的參照而不持有。
weak var postManager: PostManager?
var identifier = ""
override func viewDidAppear(animated: Bool) {
super.viewDidAppear(animated: animated)
// 用辨識符去取得 post 之後顯示它。
postManager?.fetchPost(identifier: self.identifier) { post in
// 顯示 post。
}
}
}
或者,直接把 PostManager
設計成一個單例 (singleton):
class PostManager {
static let shared = PostManager()
// ...
}
class PostTableViewController: UITableViewController {
// ...
func presentPostViewController(identifier: String) {
let postVC = PostViewController()
// 傳值時不再需要傳遞 PostManager,因為 postVC 也可以直接存取 PostManager 的單例。
postVC.identifier = identifier
present(postVC, animated: true)
}
}
這種讓所有 controller 物件直接存取文件物件的方式,其實並沒有真的在 controller 之間傳遞資料物件,所以資料物件是 struct 還是 class 差別並不大。不過,這種做法是遠端性的,因為 controller 物件們所操作的是一個不屬於自己的文件物件,也無法防止該文件的內容被其他 controller 改變。
要注意的是,如果是 class 資料物件的話,可以試著使同一個辨識符所傳回的實體也是同一個,以減少記憶體的消耗,並避免身份的混淆:
postManager.fetchPost(identifier: identifier) { post1 in
postManager.fetchPost(identifier: identifier) { post2 in
post1 === post2 // 這段敘述如果為否,會很容易造成身份的混淆,失去了用 class 的好處。
}
}
只有一個 Controller 物件能存取文件
如果不想要在每個 controller 物件的定義裡都去處理對文件的溝通的話,可以只讓一個主要的 controller 物件去管理文件就好,比方說 AppDelegate
或者是某個根 view controller。讓這個主要的 controller 物件拿到文件裡的資料物件之後,再把資料物件直接傳給別的 controller 物件來用:
class PostTableViewController: UITableViewController {
// 只有這裡才能存取 postManager。
var postManager = PostManager()
func presentPostViewController(post: Post) {
let postVC = PostViewController()
// 只需傳遞資料物件過去。
postVC.post = post
present(postVC, animated: true)
}
}
class PostViewController: UIViewController {
// 只需管理一個資料物件。
var post: Post?
override func viewDidAppear(animated: Bool) {
super.viewDidAppear(animated: animated)
// 直接拿資料物件來用。
if let post = self.post {
// 顯示 post。
}
}
}
如此一來,每個 controller 物件要管理的東西就更簡單了,對文件物件有依賴的 controller 物件也只剩下一個。如果 Post
是 class 的話,整個架構是長這樣的:
如果 Post
是 struct 的話:
可以看到,不管是哪一個,PostViewController
要管理的東西就只有一個 Post
而已,而且一開始它要拿到 Post
的路徑都是透過 PostTableViewController
。兩種方式的差別,是 PostViewController
是不是直接存取 PostManager
所管理的 Post
,以及我們是否需要維持 class 版本 Post
的單一性(同樣的辨識符只對應到一個資料物件的實體)。
資料改變的同步
以上都是講呈現 PostViewController
時初始傳值的方式。那麼,當 PostViewController
更改了它的 Post
時,class 跟 struct 的 Post
又會造成架構上甚麼的不同呢?
讓我們先把整個更新的過程分解成個別的步驟來看:
- 通知大家「有個
Post
被更改了」。 - 確認是哪個
Post
被更改。 - 依據被更改的
Post
內容去執行對應工作。
先假設我們是使 PostViewController
直接管理 Post
,且用 notification 來實作通知。如果我們不想每次編輯 postViewController.post
的時候都手動去發通知的話,我們可以透過觀察它來讓它自動發通知。如果 Post
是 struct 的話,我們可以直接使用屬性監聽器,因為它任何的變動都會觸發 didSet
:
class PostViewController: UIViewController {
var post: Post? {
didSet {
// 發出通知。
}
}
}
然而如果 Post
是 class 的話,就不那麼適合用 didSet
了,因為只有當 post
被重新指派的時候才會觸發它。也就是說,我們必須這樣做:
// ...
func didEditPostAuthor(_ author: String)
let post = self.post
post.author = author
// 必須要重新指派,才能觸發 didSet。
self.post = post
}
// ...
還好,我們還可以用 KVO 來達到類似 didSet
的效果:
class PostViewController: UIViewController {
@objc var post: Post? {
didSet {
observation = observe(\.post.propertyA, options: [.old, .new]) { object, change in
// 發出通知。
}
observation = observe(\.post.propertyB, options: [.old, .new]) { object, change in
// 發出通知。
}
}
var observation: NSKeyValueObservation?
}
發出通知之後,我們需要在 PostTableViewController
確認是哪個 Post
被更新。如果 Post
是 struct 的話,除非我們用 view 的狀態(哪個 cell 正被選擇等)來決定,不然的話我們必須要透過 Post
的 identifier
來辨識它的身份。同時,因為它的變動是本地的,所以我們必須把整個 Post
都傳回給 PostTableViewController
才行:
class PostTableViewController: UITableViewController {
// ...
@objc func postManagerPostsDidChange(notification: Notification) {
// 從 notification 裡取得變更的 post。
guard let changedPost = notification.userInfo?["changedPost"] as? Post else { return }
// 取得該 Post 在 posts 的位置。
guard let index = posts.firstIndex(where: { $0.identifier == changedPost.identifier }) else { return }
// 更新 view。
tableView.reloadRows(at: [IndexPath(row: index, section: 0], with: .automatic)
}
}
而如果 Post
是 class 的話,所有的 controller 物件其實都會存取同一個 Post
實體,所以不需要再去傳遞變更。不過,由於 class 內建了一個身份比較運算子——三等號,所以我們還是可以把整個 Post
放在通知裡,拿來做身份的比對:
class PostTableViewController: UITableViewController {
// ...
@objc func postManagerPostsDidChange(notification: Notification) {
guard let changedPost = notification.userInfo?["changedPost"] as? Post else { return }
// 改用 "==="。
guard let index = posts.firstIndex(where: { $0 === changedPost }) else { return }
tableView.reloadRows(at: [IndexPath(row: index, section: 0], with: .automatic)
}
}
不過,我們還有另一個作法。我們可以利用 class 的遠端性,在更動的時候給它一個 isUpdated
的標示,這樣的話要找到它的位置就不必透過 notification
物件了:
// ...
@objc func postManagerPostsDidChange() {
// 找出更新過後的 post 在 posts 的位置。
guard let index = posts.firstIndex(where: { $0.isUpdated }) else { return }
// 更新 view。
tableView.reloadRows(at: [IndexPath(row: index, section: 0], with: .automatic)
// 拿掉標示。
posts[index].isUpdated = false
}
// ...
事實上,Core Data 的 NSManagedObjectContext
就是用這種方法實作對資料的監控的。這種做法最大限度地利用了 class 的遠端性去減少傳遞資料物件的次數。
結論
看完整篇文章,好像用 class 來寫資料物件好處多很多,但其實不然。Class 的遠端性可以讓同一個資料物件在不同的地方被存取,而這不只會增加管理的複雜度,在不同的執行緒同時存取時,更會造成競爭狀況 (race condition),增加程式的不穩定性。相較之下,struct 的本地性讓所有的變動都是顯性 (explicit)的,且每個 controller 物件都可以真正的控制所擁有的資料物件,還可以防止競爭狀況。簡而言之,struct 是比 class 更不容易出現 bug 的,也更好偵錯。
即使如此,以 class 作為資料物件仍然是相當普遍的情況。比如說,iOS 開發中最熱門的兩套資料庫框架 Core Data 與 Realm,就都把資料物件用 class 來定義。Class 同時也讓資料物件可以做更多事,甚至負擔一些文件的職責,像是發出通知、監聽變動等;這些都是 struct 的資料物件是做不來的,因為代表同一份資料的 struct 可以有好幾個,那如果有變動的時候,豈不是也會有好幾個 struct 資料物件一起發送好幾個重複的通知嗎?
總的來說,如果希望資料物件是單純的資料而已,也就是所有的變更偵測、通知發送與值的同步等具遠端性的功能全部交給別的物件去做的話,那麼把它寫成 struct 是可以幫助你將職責劃分清楚的,而這也會讓偵錯更方便;然而,如果希望它能夠不受限於本地,自己就有以上這些功能的話,那麼把它寫成 class 也是合理的做法。一切仍端看程式設計者 ── 也就是你自己 ── 認為哪一種資料物件更適合你的程式。