Swift 中的值型別與引用型別使用指北

DeepMissea發表於2018-01-15

Swift 中的值型別與引用型別使用指北

在本文中,我們將探索值型別與引用型別語義的不同之處,在 Swift 中使用值型別的一些鮮明特徵和關鍵的好處。然後我們會關注在設計程式時,何時使用值型別或者引用型別。

Swift 中的值型別和引用型別

Swift 是一種多正規化的程式語言。它有類,這是構成物件導向程式設計的基石。類在 Swift 中可以定義屬性和方法,指定構造器,符合協議,支援整合和多型。Swift 也是一種面向協議的程式語言,通過功能豐富的協議和結構體,可以在沒有繼承的情況下實現抽象和多型。在 Swift 中,函式是第一型別,它可以賦給變數,作為引數和返回值在多個函式之間傳遞。因此 Swift 也適用於函數語言程式設計。

對於多數面嚮物件語言的開發者來說,Swift 中最大的不同就是結構體的豐富功能。除了繼承以外,你在一個類裡可以做什麼,在結構體中同樣可以做到。這就引發了問題 —— 何時並如何使用結構體和類。更通俗的說,問題是在 Swift 中何時並如何使用值型別和引用型別。

為了完整需要提醒一下,Swift 中的值型別並不僅僅只有結構體。列舉和元組也是值型別。同樣地,引用型別並不只有類,函式也是引用型別。不過函式、列舉和元組在使用時更加特定化。Swift 在值型別和引用型別的爭論中心都集中在結構體和類上。這是本文中的主要重點,所以在本文中術語值型別和引用型別可以和術語結構體和類相互轉換。

現在讓我們從一些基本原理開始,即值和引用語義的區別。

值與引用

使用值語義,變數和分配給變數的資料在邏輯上是統一的。由於變數存在於棧上,值型別在 Swift 中被稱為棧分配。確切地說,所有的值型別例項並不一直在棧上。一些可能只存在於 CPU 暫存器中,另一些可能實際在堆上分配。從邏輯上講,值型別的例項可以被認為是包含在被賦值的變數之中。在變數和值之間存在一對一的關係。變數所包有的值不能獨立於變數進行操作。

另一方面,在使用引用語義時,變數和資料是不同的。引用型別的例項在堆中分配,變數只包含一個對儲存資料的記憶體位置的引用。一個例項引用多個變數是可以的也是很常見的。任何這些引用都可以用來操作例項。

這會對將值或引用型別例項分配給新變數或傳遞給函式時發生一些影響。由於值型別例項只能擁有一個所有者,例項被複制,並將副本分配給新變數或傳入某函式。每個副本都可以修改而互不影響。對於引用型別,只有引用被複制,並且新變數或函式獲得對同一例項的新引用。如果使用任何引用修改引用型別例項,則會影響所有其他引用持有者,因為它們持有的都是對同一例項的引用。

我們來看看程式碼。

struct CatStruct {
    var name: String
}

let a = CatStruct(name: "Whiskers")
var b = a
b.name = "Fluffy"

print(a.name)   // Whiskers
print(b.name)   // Fluffy
複製程式碼

我們定義了一個結構體表示一隻貓,有一個 name 屬性。我們建立一個 CatStruct 例項,把它賦給一個變數,然後把這個變數賦給一個新的變數,並用新變數改變 name 屬性。由於結構體是值語義,賦值給新變數的行為會導致例項被複制,然後我們得到了兩個不同名字的 CatStruct

現在,我們用類做同樣的事:

class CatClass {
    init(name: String) {
        self.name = name
    }

    var name: String
}

let x = CatClass(name: "Whiskers")
let y = x
y.name = "Fluffy"

print(x.name)   // Fluffy
print(y.name)   // Fluffy
複製程式碼

在這種情況下,用新變數改變 name 屬性也會修改第一個變數的 name 屬性。這是因為類是引用語義,賦值給新變數的行為不會建立一個新的例項,兩個變數持有對同一個例項的引用,這導致隱式資料共享,這可能會對你如何並何時使用引用型別產生影響。

可變性的不同概念

為了理解可變性在值型別和引用型別之間的差異,我們必須要分清楚變數可變性例項可變性

我們上面已經知道,值型別例項和被賦值的變數在邏輯上是一致的。因此,如果變數是不可變的,那無論該例項是否有可變屬性或者可變方法,變數都會忽略讓例項不可變。只有當值型別的例項賦給一個可變變數時,例項的可變性才可以起作用。

對於引用型別,例項和被賦值的變數是不同的,因此他們的可變性也是不同的。當我們宣告一個不可變的變數引用一個例項,我們能確定的是,這個變數的引用永遠不會改變。即它總會指向同一個例項。例項的可變屬性還是可以通過這個或者其他的引用改變。如果要讓類例項不可變,必須保證它的所有儲存屬性都是不可變的。

在剛才的程式碼中,我們看到,可以宣告 a 將第一個 CatStruct 例項作為 let 常量,因為它不會被修改。而 b 必須被宣告為一個 var,因為我們修改了它的 name 屬性和值。對於 CatClassxy 都被宣告為 let 常量,然而我們能修改 name 屬性。

定義為值型別的特徵

為了能更好的理解什麼時候以及如何使用值型別,我們需要看一下定義為值型別的一些特徵:

  1. **基於屬性的相等:**任何兩個同型別值,其屬性相等,都可以認為他們是相等的。考慮一個貨幣型別,它表示貨幣具有貨幣和金額屬性。如果我們建立一個 5 美元的例項,它與任何其他 5 美元例項都相等。
  2. **淡化的標識及生明週期:**值型別沒有固定的身份。它僅由其屬性而定義。對於數字 2 或者 “Swift” 這種簡單的值就是這種情況。對於複雜的值來說也是如此。值也沒有需要儲存狀態變化的生命週期。它可以隨時被建立、銷燬或重建。代表 5 美元的貨幣例項,等於代表 5 美元的任何其他例項,無論這兩個例項是何時或如何建立。
  3. **可替代性:**沒有明確的標識和生命週期給了值型別可替代性,這意味著,如果兩個例項相等,即它們通過了基於屬性的相等測試,那麼任何例項都可以被自由地替代。回到我們的貨幣型別例子,一旦我們建立了一個代表 5 美元的例項,程式可以根據情況自由的建立或放棄這個例項的副本。無論何時我們需要遞交一個 5 美元的例項,這個 5 美元的例項是否是先前建立的那個已經無關緊要,我們要關心的是值的屬性。

使用值型別的優點

1. 效率

引用型別在堆上分配,這比在棧上分配要昂貴的多。為了確保在引用型別不需要時記憶體被釋放,需要保持一個對每個引用型別的所有活動的引用計數,並在沒有引用時銷燬例項。值型別沒有這種開銷,所以在建立和複製上很高效。值型別的複製是廉價的,因為值型別的例項在不變(constant)的時間被複制。

Swift 實現了內建的可擴充套件的資料結構,比如 StringArrayDictionary 等等。然而,這些並不能在棧上分配,因為他們的大小在編譯時是不知道的。為了能有效地使用堆分配並且保有值語義,Swift 使用一種名為寫時複製的優化技術。這意味著每個複製的例項都是邏輯意義上的副本,只有當複製的例項發生變化時才會在堆上建立實際的副本,在此之前,所有的邏輯副本都會指向相同的底層例項。因為更少的副本被建立,並且在建立的時候,涉及了固定數量的引用計數操作,所以提供了更好的效能。如果需要,這種效能優化還可以對自定義值型別使用。

2. 可預測的程式碼

使用引用型別時,持有對例項的引用的程式碼的任何部分都不能確定該例項包含的內容,因為可以使用任何其他引用來修改該例項包含的內容。由於值型別例項在複製時沒有隱式資料共享,所以我們不需要考慮程式碼的某部分的行為會影響其他部分行為所造成的意外後果。而且,當我們看到一個變數宣告為 let 常量並持有一個值型別的例項時,我們可以肯定,無論如何定義值型別,該值都不能被修改。這為程式碼的行為提供了強有力的守護以及細粒度的控制,讓程式碼變的易於推理和預測。

有人可能會爭辯說,可以編寫程式碼,使得每次將引用型別例項交給新所有者時,都會建立一個副本。 但是這會導致很多防禦性複製,這樣效率會非常低,因為複製一個引用型別會帶來很大的開銷。如果正在複製的引用型別例項具有也是引用型別例項的屬性,並且我們希望避免任何隱式資料共享,則每次都必須建立深度拷貝,這會使讓效能更糟。我們也可以嘗試通過使所有引用型別不可變來解決共享狀態和可變性的問題。但是這仍然會涉及到很多低效率的複製,而且無法改變引用型別的狀態會失去引用型別的用意。

3. 執行緒安全

值型別例項可以在多執行緒環境中使用,而不用擔心一個執行緒正在改變另一個執行緒例項的狀態。由於沒有競態條件和死鎖,所以沒有必要實現同步機制。使用值型別編寫多執行緒的程式碼變得更簡單、更安全、更高效。

4. 無記憶體洩漏

Swift 使用自動引用計數,並在沒有引用的情況下,釋放引用型別例項。這解決了正常事件過程中的記憶體洩漏問題。不過,通過強迴圈引用仍會記憶體洩漏,即當兩個類例項彼此強引用互相阻止彼此的釋放。當一個類與一個閉包(在 Swift 中也是引用型別)彼此強引用也會發生相同的情況。由於值型別沒有引用,所以記憶體洩漏的問題也就不存在。

5. 易於測試

因為引用型別的生命週期會保有狀態,所以在對引用型別進行單元測試時,經常使用模擬框架來觀察各種方法被呼叫時對測試物件的狀態和行為的影響。而且由於引用型別例項的行為會隨狀態的變化而改變,通常需要設定程式碼來保證測試物件處於正確的狀態。對值型別而言,要關心的全部是值型別的屬性。所以我們需要做的,就是建立一個新的值,這個值的屬性和期望的值屬性相同。

用值型別和引用型別設計程式

值型別和引用型別不應該被看作是相互競爭的。他們不同的語義和行為,讓他們適用於不同的情景。我們的目的是理解並運用值和引用語義,讓他們以最能滿足應用目標的方式結合起來。

1. 使用引用型別模擬具有標識的實體

幾乎所有現實世界領域都有在生命週期裡保持著標識和狀態的實體。這些實體應該使用類來建模。

考慮有一個使用員工型別來代表員工的薪酬應用。簡單地,假設只儲存員工的姓和名。可能有兩個或者更多的員工例項的姓名相同,但是這並不能讓他們相等,因為在現實世界中,這些例項代表著不同的員工。

如果把一個員工類例項賦給一個新的變數或者把它傳到一個函式裡,新的引用會指向相同的例項。這是我們可以確定的。例如,如果我們在應用的某個模組中使用一個引用來記錄員工的工時,那麼當應用另一個模組計算每月工資時,它使用的都是具有正確工時的同一個例項。同樣,如果在某個位置更新員工的地址,那麼我們對員工的所有引用都會更新為正確的地址,因為他們是對同一例項的引用。

如果嘗試使用結構體來模擬員工的話會導致錯誤並且前後矛盾,因為每次把員工例項賦給一個變數或者傳給一個函式時,它會被複制。程式中不同的部分會以它們各自的例項結束,並且其中某部分狀態改變並不會在其他部分體現出來。

2. 用值型別來封裝狀態和暴露行為

雖然有標識和生命週期的實體需要用類來建模,但是需要用值型別來封裝它們的狀態,表示相關的業務並且暴露行為。

繼續以員工型別為例。假設要保留每個員工的個人資料,工資績效資訊。我們可以建立個人資訊工資績效值型別,將狀態、業務規則和行為這些元素聯絡在一起。這可以讓類不那麼臃腫,因為它只負責維護標識,而它包含的值型別例項會處理該狀態的各種元素和相關行為。

這也非常符合單一原則。例如,相比於員工型別不得不實現一些方法來暴露各種層面的行為,客戶程式碼只對員工的績效感興趣,所以交給績效例項來處理。因為處理的是值型別,我們無需擔心隱式資料共享與客戶端背後變化,而對員工例項的狀態產生影響。

這種方式也更加適用於多執行緒。表示引用型別例項狀態的各種元素的值型別例項副本,可以自由地切換到不同執行緒上的程式,而不需要同步。這可以提高效能,並提高應用互動的響應。

3. 上下文的重要性

要注意的是,有時值型別和引用型別的選擇是由上下文驅動的。應用開發不是絕對意義上的對現實世界的建模練習,而是建模問題的具體方面,以滿足給定的用例。因此,要判斷在應用程式的上下文中使用值語義還是引用語義,具體取決於實體在相關領域問題中扮演的角色。

想一想前面介紹的 CatStructCatClass 型別。我們更願意使用哪一種模型來模擬寵物貓呢?由於例項將代表一隻真正的貓,所以應該使用一個類。例如,當我們把貓交給獸醫來打疫苗時,我們不希望獸醫給一隻貓的副本打疫苗,如果使用一個結構體,就會發生這樣的事情。但是,如果我們正在設計一個處理寵物貓的飲食習慣的應用,那麼就應該使用結構體來處理一般意義上的貓,而不是尋找一隻特定標識的貓。對於這樣的應用,我們的 CatStruct 不會擁有 name 屬性,但可能有消耗食物型別,每天的服務數量等的屬性。

不久前,我們使用貨幣型別作為一個值為模型的概念的絕佳例子。在銀行,金融或其他應用的情況下,我們只關心貨幣的屬性,即貨幣的多少和種類。但是,如果我們正在建立一個實物貨幣的印刷,分配和最終處理的應用,我們就需要將每個紙幣視為具有唯一標識和生命週期的實體。

相同地,對於為輪胎製造商開發的應用程式來說,每個輪胎都可能是一個具有唯一標識和生命週期的實體,用於銷售點以追蹤退貨,保修索賠等。但是,對製造汽車的公司而言,他們也許不想看輪胎的屬性來跟蹤哪輛車使用哪個輪胎,儘管他們可以看到他們製造的汽車具有獨特的標識和生命週期。

4. 基於屬性相等的測試

值型別沒有固定的標識來區分它是否是那個型別例項。唯一比較它們的方式就是比較它們的屬性。事實上,基於屬性相等性的概念在值型別中是非常基本的,所以決定一個特定的型別是值型別還是引用型別,它可以作為一個指引。如果一個型別的兩個例項不能僅使用基於屬性的相等來比較的話,那我們就要處理一些元素的標識,這通常意味著他們是引用型別,或者它們可以用值和引用語義區分。

實際上,這意味著要比較任何兩個例項是否相等都要使用 == 運算子。因此,所有的值型別都必須符合 Equatable 協議。

5. 結合值型別和引用型別

如上面提到過的,把引用型別的屬性封裝為值型別的例項,以達到封裝狀態,表示業務規則並且暴露行為的目的是非常可取的。這些值型別可以高效傳遞,而不用擔心意外後果,如執行緒安全性等。但是,值型別應該儲存引用型別的例項嗎?這通常應該避免,因為在值型別上使用引用型別屬性會引入堆分配,引用計數和隱式資料共享,影響值型別的效能和其他優點。事實上,它會導致值型別失去其基於屬性的平等,淡化標識和可替代性的特點。因此,重要的是要遵守規則,不能以損害兩者完整性的方式來結合值與引用語義。

有很多方式描述了值型別和引用型別是如何在實際應用中工作的。如 Andy Matuschak這篇文章中所說的:把物件看作是可預測的純淨的值層之上的一個輕薄的必要的層。在 Andy 的文章的參考文獻部分是 Gary Bernhardt這次演講,一種使用他稱之為的函式性核心和命令式外殼來構建系統的方法。函式核心由純粹的值,特定領域邏輯和業務規則組成。很容易得出,這套系統有利於併發並且易於測試,因為它通過命令式外殼與外部依賴隔離,因此保留了狀態並連線到使用者介面,持久化機制,網路等等。

Swift 標準庫與 Cocoa 框架

Swift 的標準庫主要由值型別組成。所有的內建基本型別和集合都是用結構體實現的。構成 Cocoa 框架的部分主要由類構成。有些地方需要類的原因是,類對於 MVC,使用者介面元素,網路連線,檔案處理等等是很恰當的方式。

但是 Cocoa 在 Foundation 框架裡也有很多類是值型別的,不過作為引用型別而存在,因為他們是用 Objective-C 來編寫的。這就是 Swift 標準覆蓋的地方,為越來越多的 Objective-C 引用型別提供了值型別的橋接。更多橋接型別和 Swift 與 Cocoa 框架之間互動的細節,可以看看蘋果開發者網站上的這一頁

結論

Swift 提供了強大而高效的值型別,讓我們的程式碼更加高效,可預測而且執行緒安全。這就需要理解值和引用語義之間的差異,才能以最能滿足應用程式目標的方式來結合值型別和引用型別。


掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 AndroidiOS前端後端區塊鏈產品設計人工智慧等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章