何時用 struct?何時用 class?

SwiftGG翻譯組發表於2018-07-15

翻譯:muhlenXi
校對:YousanflicsnumbbbbbCee
定稿:CMB

在 Swift 的世界中,有一個熱議很久的主題,何時使用 class 和何時使用 struct ,今天,我想發表一下我自己的觀點。

值型別 VS 引用型別

事實上,這個問題的答案很簡單:當你需要值語義的時候用 struct,當你需要引用語義的時候就用 class。

好了,下週同一時間請再次訪問我的部落格……

等等

怎麼了?

這沒有回答上述中的問題

你什麼意思?答案就在那兒。

是的,但是……

但是什麼?

那什麼是值語義,什麼是引用語義呢?

昂,你提醒了我。我確實應該講解一下。

還有它們和 struct、class 的關係

好吧。

這些問題的核心就是資料和資料的儲存位置。我們用區域性變數、引數、屬性和全域性變數來儲存資料。儲存資料有兩種最基本的方式。

對於值語義,資料是直接儲存在變數中。對於引用語義,資料儲存在其他地方,變數儲存的是該資料的引用地址。當我們訪問資料時,這種差異不一定很明顯。但是拷貝資料時就完全不一樣了。對於值語義,你得到的是該資料的拷貝。對於引用語義,你得到的是該資料的引用地址拷貝。

這有些抽象,我們通過一個示例來了解一下。先暫時跳過 Swift 的示例,一起來看一個 Objective-C 的示例:

    @interface SomeClass : NSObject 
    @property int number;
    @end
    @implementation SomeClass
    @end
    
    struct SomeStruct {
        int number;
    };
    
    SomeClass *reference = [[SomeClass alloc] init];
    reference.number = 42;
    SomeClass *reference2 = reference;
    reference.number = 43;
    NSLog(@"The number in reference2 is %d", reference2.number);
    
    struct SomeStruct value = {};
    value.number = 42;
    struct SomeStruct value2 = value;
    value.number = 43;
    NSLog(@"The number in value2 is %d", value2.number);
複製程式碼

列印的結果如下:

    The number in reference2 is 43
    The number in value2 is 42
複製程式碼

為什麼列印結果會不一樣?

程式碼 SomeClass *reference = [[SomeClass alloc] init] 在記憶體中建立了 SomeClass 類的一個新例項,然後將該例項的引用放到 reference 變數中。程式碼 reference2 = reference 將 reference 變數的值(例項的引用)賦值給新的 reference2 變數。然後 reference.number = 43 將兩個變數指向的物件(同一個物件)的 number 屬性修改為 43。 這就導致列印的 reference2 的值也是 43。

程式碼 struct SomeStruct value = {} 建立 SomeStruct 結構體的一個新例項並賦值給變數 value。程式碼 value2 = value 拷貝 value 的值到 變數 value2 中。每個變數包含各自的資料塊。而程式碼 value.number = 43 僅僅修改 value 變數的值。所以,value2 變數的值仍然是 42。

用 Swift 實現這個例子:

    class SomeClass {
        var number: Int = 0
    }
    
    struct SomeStruct {
        var number: Int = 0
    }
    
    var reference = SomeClass()
    reference.number = 42
    var reference2 = reference
    reference.number = 43
    print("The number in reference2 is \(reference2.number)")
    
    var value = SomeStruct()
    value.number = 42
    var value2 = value
    value.number = 43
    print("The number in value2 is \(value2.number)")
複製程式碼

和之前一樣,列印如下:

    The number in reference2 is 43
    The number in value2 is 42
複製程式碼

使用值型別的經驗

值型別不是新出的型別。但是對於很多人來說,他們感覺上很新。這是怎麼回事?

大部分 Objective-C 程式碼不會用到 struct。我們通常操作的是 CGRect 、 CGPoint ,很少自己定義結構體。一方面,結構體不實用,無法做函式式的引用賦值。在 Objective-C 中,正確儲存物件的引用到 struct 中是很困難的,尤其是使用 ARC 的時候。

大部分語言沒有類似 struct 結構體的東西。像 Python 和 JavaScript 這樣“一切皆物件”的語言都只有引用型別。如果你是從這樣的語言轉到 Swift,值型別這個概念可能對你來說更加陌生。

不過等一下!有一個地方几乎所有的語言都會使用值型別:數值(number)!只要你寫過一段時間程式碼,無論是什麼語言,肯定能理解下面這段程式碼的行為:

    var x = 42
    var x2 = x
    x++
    print("x=\(x) x2=\(x2)")
    // prints: x=43 x2=42
複製程式碼

這對我們來說是非常明顯和自然的,我們甚至沒有意識到它的行為與眾不同。但是它確確實實是值型別。從你程式設計的第一天開始就一直在使用值型別,即使你沒有意識到這一點。

由於許多語言的核心是“一切皆物件”,number 其實是用引用型別來實現的。然而,它們是不可變引用型別,不可變引用型別和值型別的差異是很難察覺的。它們的行為和值型別一樣,即使它們不是以這種方式實現。

這是理解值型別和引用型別的重要部分。就語言語義方面,區別是很重要的。當修改資料時,如果你的資料是不可變的,那麼值型別/引用型別之間的區別就消失了,或者至少變成純粹的效能問題而不是語義問題。

Objective-C 中也有類似的東西,就是標記指標(tagged pointers)。標記指標把物件直接儲存在指標值中,因此它實際上是值型別,拷貝指標相當於拷貝物件。Objective-C 的庫只會把不可變型別儲存到標記指標中,所以使用的時候感受不到區別。有些 NSNumber 是引用型別,有些是值型別,但是使用上沒有區別。

做出選擇

既然我們已經知道值型別是如何工作的,那麼你自己的資料型別該用什麼呢?

這兩者之間的根本區別在於,當你使用 = 時會發生什麼。值型別會得到該物件的副本,引用型別僅僅得到該物件的引用。

因此,決定使用哪一個的基本問題是:是否需要拷貝?是否需要經常拷貝?

首先來看一些毫無爭議的例子。Integer 顯然是可拷貝的,它應該是值型別。網路套接字(Network sockets)明顯是不可拷貝的,它應該是引用型別。再比如使用 (x, y) 實數對錶示的座標(Points)是可拷貝的,它應該是值型別。代表磁碟的控制器是明顯不可拷貝的,它應該是引用型別。

有些型別理論上可以拷貝,但是這種拷貝可能不是你想要的。這種情況下,它們應該是引用型別。舉個例子,螢幕上的按鈕在程式碼層面可以拷貝,但是拷貝的按鈕和原始按鈕並不一樣。點選拷貝的按鈕並不會觸發原始按鈕,拷貝的按鈕在螢幕上的位置也和原始按鈕不一樣。如果你需要把按鈕當成引數傳遞,或者將它賦值給一個新變數,那你需要的是原始按鈕的引用,只有明確宣告的時候才進行拷貝。因此,按鈕應該是引用型別。

檢視和視窗控制器也類似。它們可以支援拷貝,但一般來說這不是你期望的行為,它們應該是引用型別。

接著談談模型(model)型別。假設你有一個 User 型別,用來表示系統中的使用者,然後用 Crime 型別來表示 User 的操作。這兩個型別看起來都可以拷貝,可以設定成值型別。但是,如果你的程式需要更新 User 的 Crime 並且能把改動同步到其他程式碼,那最好用一個使用者控制器(User Controller)來管理 User,顯然這個使用者控制器應該是引用型別。

集合是個有趣的例子。集合包括陣列、字典、字串等型別。它們是可拷貝的嗎?顯然是。是否需要經常拷貝?這就不好說了

大部分語言的回答是“No”,它們的集合是引用型別。比如 Objective-C、Java、Python、JavaScript 以及一些我能想到的語言。(一個例外是 C++ 的 STL 集合,但是 C++ 是語言中的瘋子,它做的每件事都很奇怪。)

Swift 是可拷貝的。這意味著 Array、Dictionary 和 String 是結構體而不是類。可以將他們的拷貝作為引數來使用。如果拷貝付出的代價很小,這麼做就完全合理。Swift 為了實現這個功能花了很大功夫。。

巢狀型別

巢狀值型別和引用型別有四種方式。哪怕只用到了其中一種,你的生活都會變得更加有趣。

  1. 包含其他引用型別的引用型別,這沒什麼特別的。如果持有內部或外部值的引用,就可以修改這個值。改動會同步到所有持有者。
  2. 包含其他值型別的值型別,這樣做的結果是一個更龐大的值型別。當內部值是外部值的一部分時,如果你將外部值儲存到某個新地方,整個值型別都會被拷貝,包括內部值。如果你將內部值儲存到新地方,那就只拷貝內部值。
  3. 包含值型別的引用型別,被引用的值會變大。外部值的引用可以操作整個物件,包括內部值。修改內部值時,外部值引用的持有者都會同步改動。如果你將內部值儲存到新地方,它會被拷貝。
  4. 包含引用型別的值型別,這就有點複雜了。你可能會遇到意料之外的行為。這有利有弊,取決於你的使用方式。如果你將一個引用型別放到值型別中,然後拷貝這個值型別到一個新地方,拷貝中的內部物件的引用值是相同的,它們都指向相同的地方。下面是一個示例:
        class Inner {
            var value = 42
        }
    
        struct Outer {
            var value = 42
            var inner = Inner()
        }
    
        var outer = Outer()
        var outer2 = outer
        outer.value = 43
        outer.inner.value = 43
        print("outer2.value=\(outer2.value)     outer2.inner.value=\(outer2.inner.value)")
複製程式碼

列印如下:

     outer2.value=42 outer2.inner.value=43
複製程式碼

outer2outer 的拷貝,它僅僅拷貝了 inner 的引用,因此兩個結構體的 inner 共享一個儲存空間。因此更新 outer.inner.value 的值會影響 outer2.inner.value 的值。神奇!

如果使用得當,上面的這種行為使程式設計變得很方便,它允許你建立一個支援寫時複製的 struct,允許你不需要拷貝大量的資料就可以實現值語義。這就是 Swift 的集合工作機制,你也可以建立自己的集合。如果想了解更多,可以閱讀 一起來構建 Swift Array

這種行為也相當危險。舉個例子,你有一個可拷貝的 Person 類,所以它可以是 struct 型別,為了懷舊,你決定用 NSString 型別來儲存姓名:

    struct Person {
         var name: NSString
    }
複製程式碼

然後生成一對夫婦的例項,分別給每個例項的姓名賦值:

    let name = NSMutableString()
    name.appendString("Bob")
    name.appendString(" ")
    name.appendString("Josephsonson")
    let bob = Person(name: name)
    
    name.appendString(", Jr.")
    let bobjr = Person(name: name)
複製程式碼

列印他們的姓名:

    print(bob.name)
    print(bobjr.name)
複製程式碼

結果如下:

    Bob Josephsonson, Jr.
    Bob Josephsonson, Jr.
複製程式碼

喔!

發生了什麼?與 Swift 中的 String 型別不同,NSString 是一個引用型別,是不可變的,但是它有一個可變的子類 NSMutableString。構建 bob 時,生成了一個被 name 中字串所持有的引用。隨後改變 這個字串時,改動被同步到了 bob 中。雖然 bob 是用 let 宣告值型別,但是此處的賦值操作顯然改變了 bob。事實上,這沒有覆寫 bob,只不過是改變了 bob 持有的引用的資料。因為 name 是 bob 的一部分資料,從語義上看,就好像覆寫了 bob。

這種行為在 Objective-C 中一直存在。每個有經驗的 Objective-C 開發者都能避免這種行為。因為一個 NSString 實際上可能是一個 NSMutableString。為了防止這種行為,可以宣告一個 copy 的屬性或者在初始化的時候顯式的呼叫 copy 方法。在許多 Cocoa 的集合中可以發現這種做法。

Swift 的解決方法很簡單:用值型別而不是引用型別。在這種情況下,宣告 name 為 String 型別即可。這樣就不用擔心無意中出現儲存共享的問題。

有些情況下,解決方法可能沒有這麼簡單。舉個例子,你可能會建立一個 包含引用型別變數 view 的 struct,並且它不能改變為值型別。這也許表示你的型別不應該是 struct,因為你無論如何也不能實現值語義。

結論

移動值語義型別的資料時,新資料是原資料的拷貝。然而,引用語義型別的資料得到的是原資料的引用拷貝。這意味著你可以在任何地方通過引用覆寫原資料。而值語義只能通過改變原資料來改變原資料的值。選擇型別時,要考慮該型別是否適合拷貝和傾向於拷貝的固有型別。最後,注意值型別中巢狀的引用型別,如果你不留心將會發生一些糟糕的事情。

今天的內容到此結束,這次是真的結束了,下次再見。你們的建議對 Friday Q&A 是最好的鼓勵,所以如果你關於這個主題有什麼好的想法,請發郵件到這裡

你喜歡這篇文章麼?我的書裡還有更多有意思的內容!第二卷 和 第三卷正在出售中!包括 ePub,PDF,紙質版,iBooks 和 Kindle,點選檢視更多資訊

相關文章