iOS App 程式開發

Swift Class vs Struct:設計 Model 時,該用 Struct 還是 Class 呢?

用 Swift 寫 app 的時候,我們通常一開始就會碰到一個問題:我們的 app model 宣告成 Struct 還是 Class 都符合我們的要求,哪麼應該宣告成哪一種好呢?讓我們來看看 Struct 和 Class 的差異,並從不同例子中看看哪一種更適合你的 app 吧!
Swift Class vs Struct:設計 Model 時,該用 Struct 還是 Class 呢?
Swift Class vs Struct:設計 Model 時,該用 Struct 還是 Class 呢?
In: iOS App 程式開發, Swift 程式語言

用 Swift 寫 app 的時候,我們通常會在一開始就碰到一個問題:我們的 App model 應該宣告成 struct 還是 class 好?

比如說,假設我們在開發一個通訊錄 app,而我們確定要建立一個叫做 Contact 的型別來代表每筆通訊資料,且它需要有 var name: Stringvar 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 裡面叫做 MimiCat 的話,我們必須將 theCat 覆蓋回去才行。雖然多了這個寫回去的步驟好像比較麻煩,但也確保了任何對文件做的更動都有被記錄下來,並讓程式碼的邏輯更清晰些。

總的來說,用 class 來定義資料物件的話,就好像是在用雲端共享文件一樣:每個人的螢幕上都會有一份文件可以編輯,但這個文件並沒有存在電腦裡,而是跟雲端的版本連線,所以所有的變動都是直接在雲端版本上更新的。好處是方便,壞處是誰修改了甚麼東西經理不會知道(class 本身沒有帳號功能!)。

用 struct 的話,則是像傳統的離線文件檔案一樣。一開始文件只有經理有,而如果他想要讓手下小美去修改文件的話,他就需要拷貝一份檔案給小美。小美修改完檔案後,必須把它交還給經理,然後經理再決定要不要用修改過的檔案取代原本的文件。

讓我們先整理一下上文的概念

Model 不是一個單一的物件,而是一整個層,裡面可能包含了文件(資料物件管理者)資料物件這兩種元件。其中,文件最好是用 class 來定義,因為它肩負了許多溝通的工作。資料物件則是資料的代表,要用 class 或 struct 來定義都可以,但兩種定義方式所延伸出的語法結構會很不一樣。Class 的辨識符就像是一個操作介面,用來操作一個遠端的實體,所以 class 的資料物件實際上並不在本地(某個函式或某個物件之內)。struct 的辨識符則直接保存了整個實體,所有的操作都是本地的。

用 class 定義資料物件的話,任何變動只要執行一次就可以了,因為它的實體只會有一個。然而,用 struct 定義的話,有多少個辨識符就會有多少個實體 ── 即使它們都指向同一筆資料。所以,我們需要將變動手動套用到文件所管理的那份實體,好讓整個 app 都能使用最新的資料。這雖然寫起來較為囉唆,卻讓閱讀與維護更為簡單。至於兩者孰優孰劣,就交由各位去針對不同的專案決定了。之後,我們會再討論 class 與 struct 的資料物件在不同的 controller 之間傳值的各種方法。

如果有多個 Controller 物件

我們已經了解到 MVC 架構中的 model 層裡,有文件資料物件兩種負不同責任的元件,而 controller 層就是透過文件在與 model 層互動的。然而,如果 controller 層裡不只有一個 controller 物件的話(官方的專案模板裡就已經有 AppDelegateViewController 兩個),它們分別又可以怎麼跟文件物件溝通呢?

假設我們在寫一個社交 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 又會造成架構上甚麼的不同呢?

讓我們先把整個更新的過程分解成個別的步驟來看:

  1. 通知大家「有個 Post 被更改了」。
  2. 確認是哪個 Post 被更改。
  3. 依據被更改的 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 正被選擇等)來決定,不然的話我們必須要透過 Postidentifier 來辨識它的身份。同時,因為它的變動是本地的,所以我們必須把整個 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 也是合理的做法。一切仍端看程式設計者 ── 也就是你自己 ── 認為哪一種資料物件更適合你的程式。

作者
Hsu Li-Heng
iOS 開發者、寫作者、filmmaker。現正負責開發 Storyboards by narrativesaw 此一故事板文件 app 中。深深認同 Swift 對於程式碼易讀性的重視。個人網站:lihenghsu.com。電郵:[email protected]
評論
很好! 你已成功註冊。
歡迎回來! 你已成功登入。
你已成功訂閱 AppCoda 中文版 電子報。
你的連結已失效。
成功! 請檢查你的電子郵件以獲取用於登入的連結。
好! 你的付費資料已更新。
你的付費方式並未更新。