行走於 Swift 的世界中

發表於2014-06-09

walk-in-swift

 

從週一 Swift 正式公佈,到現在週五,這幾天其實基本一直在關注和摸索 Swift 了。對於一門新語言來說,開荒階段的探索自然是激動人心的,但是很多時候資料的缺失和細節的隱藏也讓人著實苦惱。這一週,特別是最近幾天的感受是,Swift 並不像我上一篇表達自己初步看法的文章裡所說的那樣,相對於 objc 來說有更好的學習曲線。甚至可以說 objc 在除了語法上比較特別以外,其概念還是比較容易的。而 Swift 在漂亮的語法之後其實隱藏了很多細節和實現,而如果無法理解這些細節和實現,就很難明白這門新語言在設計上的考慮。在實際編碼中,也會有各種各樣的為什麼編譯不通過,為什麼執行時出錯這樣的問題。本文意在總結一下這幾天看 Swift 時候遇到的自己覺得重要的一些概念,並重新整理一些對這門語言的想法。可能有些內容是需要您瞭解 Swift 的基本概念的,所以這並不是一篇教你怎麼寫 Swift 或者入門的文章,建議您先讀讀 Apple 官方給出的 Swift 的電子書,至少將第一章的 Tour 部分讀完(這裡也有質量很不錯的但是暫時還沒有完全翻譯完成的中文版本)。不過因為自己也才接觸一週不到,肯定說不上深入,還希望大家一起探討。

型別?什麼是型別?

這是一個基礎的問題,型別 (Types) 在 Swift 中是非常重要的概念,在 Swift 中型別是用來描述和定義一組資料的有效值,以及指導它們如何進行操作的一個藍圖。這個概念和其他程式語言中“類”的概念很相似。Swift 的型別分為命名型別和複合型別兩種;命名型別比較簡單,就是我們日常用的 類 (class)結構體 (struct)列舉 (enum) 以及介面 (protocol)。在 Swift 中,這四種命名型別為我們定義了所有的基本結構,它們都可以有自己的成員變數和方法,這和其他一般的語言是不太一樣的(比如很少有語言的enum可以有方法,protocol可以有變數)。另外一種型別是複合型別,包括函式 (func) 和 多元組 (tuple)。它們在使用的時候不會被命名,而是由 Swift 內部自己定義。

我們在實際做開發時,一般會接觸很多的命名型別。在 Swift 的世界中,一切看得到的東西,都一定屬於某一種型別。在 PlayGround 或者是專案中,通過在某個實際的被命名的型別上 Cmd + 單擊,我們就能看到它的定義。比如在 Swift 世界中的所有基本型IntStringArrayDictionay 等等,其實它們都是結構體。而這些基本型別通過定義本身,以及眾多的 extension,實現了很多介面,共同提供了基本功能。這也正式 Swift 的型別的一種很常見的組織方式。

而相對的,Cocoa 框架中的類,基本都被對映為了 Swift 的 class。如果你有比較深厚的 objc 功底的話,應該會聽說過 objc 的類其實是一組包含了後設資料 (metadata) 的結構體,而在 objc 中我們可以使用 +class 來拿到某個 Class 的 isa,從而確定類的組成和描述。而在 Swift 的 native 層面上,在 type safe 的基礎上,不再需要 isa 來指導物件如何構建,而這個過程會通過確定的命名型別完成。正因為這個原因,Swift 中乾脆把 NSObject 的 class 方法都拿掉了,因為 Swift 和 ObjC 在這個根本問題上的分歧,最終導致了在使用 Swift 呼叫 Cocoa 框架時的各種麻煩和問題。

參照和值,Array 和 Dictionary 背後的一些故事

如果你堅持看到了這裡,那麼恭喜你…本文最無趣和枯燥的部分已經結束了(同時也應該嚇走了不少抱著玩玩看的心態來看待 Swift 的讀者吧..笑),那麼開始說一些細節的東西吧。

首先要明白的概念是,參照和值。在 C 系語言裡摸爬滾打過的同學都知道,我們在呼叫一個函式的時候,往裡傳的引數有兩種可能。一種是傳遞類似一個數字或者結構體這樣的基本元素,這時候這個整數的值會被在記憶體中複製一份然後傳到函式內部;另一種情況是傳遞一個物件,為了效能和記憶體上的考慮,這時候一般不會去將物件的內容複製一遍,而是會傳遞的一個指向同一塊記憶體的指標。

在 Swift 中一個與其他語言都不太一樣的地方是,它的 Collection 型別,也就是 Array 和 Dictionary,並不是 class 型別,而是 struct 結構體。那麼按照我們以往的經驗,在傳值或者賦值的時候應該是會複製一份。我們來試試看是不是這樣的~

Dictionary 的值沒有問題,我們改變了 dic 中的值,但是 newDic 保持了原來的值,說明 newDic 確實被複制了一份。而當我們檢查到Array 的時候,發生了一點神奇的事情。雖然 Array 是 struct,但是當我們改變 arr 時,新的 newArr 也發生了改變,也就是說,arr 和newArr 其實是同一個參照。這裡的原因其實在 Apple 的官方文件中有一些說明。Swift 考慮到實際使用的情景,對 Array 做了特殊的處理。除非需要(比如 Array 的大小發生改變,或者顯式地要求進行復制),否則 Array 在傳遞的時候會使用參照。

在這裡如果你想要只改變 arr 的值,而保持新賦予的 newArr 不變的話,你需要顯式地對 arr 進行 copy(),像下面這樣。

這時候 arr 和 copiedArr 將指向不同的記憶體地址,對原來的陣列重新賦值的時候,就不會再影響新的陣列了。另一種等效的做法是通過 Array的初始化方法建立一個新的 Array

值得一提的是,對於 Array 這個 struct 的這種特殊行為,Apple 還準備了另一個函式 unshare() 給我們使用。unshare() 的作用是如果物件陣列不是唯一參照,則複製一份,並將作用的參照指向新的地址(這樣它就變成唯一參照,不會意外改變原來的別的同樣的參照了);而如果這個參照已經是唯一參照了的話,就什麼都不做。

這個設計的意圖是為了更安全地使用這個優化過的行為奇怪的陣列結構體。關於 unshare() 的行為,我們也可以通過使用 LLDB 斷點來觀察記憶體地址的變化。參見下圖:swift_unshare_array

另外一個要加以注意的是,Array 在 copy 時執行的不是深拷貝,所以 Array 中的參照型別在拷貝之後仍然會是參照。Array 中巢狀 Array 的情況亦是如此:對一個 Array 進行的 copy 只會將被拷貝的 Array 指向新的地址,而保持其中所有其他 Array 的引用。當然你可以為 Array (或者準確說是 Array)寫一個遞迴的深拷貝擴充套件,但這是另外一個故事了。

Array vs Slice

因為 Array 型別實在太重要了,因此不得不再多說兩句。檢視 Array 在 Swift 中的定義,我們可以發現其實 Array 實現了兩個很重要的介面MutableCollection 和 Sliceable。第一個介面比較簡單,為 Array 實現了下標等特性,通過 Collection 通用的一些概念,可以從資料結構中獲取元素,比較簡單。而第二個介面 Sliceable 實現了通過 Range 來取出部分陣列,這裡稍微有點特殊。

Swift 引入了在其他很多語言中很流行的用 .. 和 ... 來表示 Range 的概念。從一個陣列裡面取出一個子陣列其實是蠻普遍的一個需求,但是如果你足夠細心的話,可能會發現我們無法寫這樣的程式碼:

你會得到一個編譯錯誤,告訴你沒有過載下標。在我們去掉我們強制加上的 : Array 型別設定之後,編譯能通過了。這就告訴我們,我們使用 Rang 從 Array 中取出來的東西,並不是 Array 型別。那它到底是個什麼東西?使用 REPL 可以很容易看到,在使用 Range 從 Array 裡取出來的其實是一個 Slice,而不是一個 Array

So, what is a slice?檢視 Slice 的定義,可以看到它幾乎和 Array 一模一樣,實現了同樣的介面,擁有同樣的成員,那麼為什麼不直接乾脆給個爽快,而要新弄一個 Slice 呢?Apple gets crazy?當然不是..Slice的存在當然有其自己的價值和含義,而這和我們剛才提到的值和引用有一些關係。

So, why is a slice?讓我們先嚐試 play with it。接著上面的情況,執行下面的程式碼試試看:

我想你已經明白一些什麼了吧?這裡的 slice 和 arr 當然不可能是同一個引用(它們的型別都不一樣),但是很有趣的是,通過 Range 拿到的Slice 中的元素,是指向原來的 Array 的。這個特性就非常有趣了,我們可以對感興趣的陣列片段進行觀察或者操作,並且它們的值和原來的陣列是對應的同步的。

理所當然的,在對應著的 Array 或者 Slice 其中任意一個的記憶體指向發生變化時(比如新增或移除了元素,重新賦值等等),這種關係就會被打破。

對於 Slice 和 Array,其實是可以比較簡單地轉換的。因為 Collection 介面是實現了 + 過載的,於是我們可以簡單地通過相加來生成一個Array (如果我們願意的話)。不過,要是真的有需要的話,使用 Array 的初始化方法會是比較好的選擇:

使用 Range 下標的方式,不僅可以取到這個 Range 內的 Slice,還可以對原來的陣列進行批量”賦值”:

細心的同學可能注意到了,這裡我把“賦值”打上了雙引號。實際上這裡做的是替換,陣列的記憶體已經發生了變化。因為 Swift 沒有強制要求替換的時候 Range 的範圍要和用來替換的 Collection 的元素個數一致,所以其實這裡一定會涉及記憶體的分配和新的陣列生成。我們可以看看下面的例子:

給一個陣列進行 Range 賦值,背後其實呼叫了陣列的 replaceRange 方法,將取到的 Slice,替換成了賦給它的 Array 或者 Slice。而只要 Range 有效,我們就可以很靈活地寫出類似這樣的所謂的插入方法:

這裡的 1..1 是一個起點為 1,長度為 0 的Range,於是它取到的是原來 [0, 0, 0] 中 index 為 1 的位置的一個空 Slice,將其替換為 [1, 1]。清楚明白。

既然都提到了這麼多次 Range,還是需要說明一下這個 Swift 裡很重要的概念(其實在 objc 裡 NSRange 也很重要,只不過沒有像 Swift 裡這麼普遍)。Range 結構體中有兩個非常重要的值,startIndex 和 endIndex,它表示了這個 Range 的範圍。而這個值永遠是右開的,也就是說,它們會和 x..y 這樣的表示中 x 和 y 分別相等。對於 x < y 的情況下的 Range,是存在數學上的表達意義的,比如 2..1 這樣的 Range 表示從 2 開始往前數 1。但是在實際從 Array 或者 Slice 中取值時這種表達是沒有意義,並且會丟擲一個執行時的 EXC_BAD_INSTRUCTION 的,在使用的時候還要加以注意。

顏文字很好,但是…

有了上面的一些基礎,我們可以來談談 String 了。當說到我們可以在原生的 String 中使用 UniCode 字元時,全場一片歡呼。沒錯,以後我們可以把程式碼寫成這樣了!

相關文章