Swift 中的 7 個陷阱以及如何避免

BigNerdCoding發表於2016-02-04

作者: David Ungar,時間:2016/1/27
翻譯:BigNerdCoding, 如有錯誤歡迎指出。原文連結

前言

伴隨著Swift語言的快速發展,我們對於蘋果裝置程式設計的認識也發生著變化。與原來的Objective-C語言相比,Swift語言帶來的更多現代化的特徵,例如函數語言程式設計和更多的型別檢查。

Swift語言採用一些安全的程式設計模式來幫助開發者避免一些bug。然而不可避免的是,這種雄心勃勃的做法也會讓我們的程式中引入一些陷阱(至少目前是這樣),並且在編譯的時候編譯器無法檢查出來並給出任何警告提示。這其中的一些陷阱在官方的Swift書裡面,但是還有一些書中並沒有提及。下面會介紹7個陷阱,其中的大部分坑我都進過。它們涉及到Swift的協議擴充套件(protocol extensions),可選鏈(optional chaining),以及函數語言程式設計(functional programming)

協議擴充套件:強大,但是小心使用

對於程式設計師來說,Swift中類的繼承特性是自己武器庫裡的一件有力武器,因為它能讓類之間的特殊關係變的明確,而且讓程式碼的分享和複用變的可行。但是Swift中的值型別(value types)並不能和引用型別(reference types)一樣能相互之間進行繼承。然而,一個值型別卻可以繼承自一個協議,反過來協議還能繼承自另一個協議。雖然協議裡面不能包含程式碼只能含有型別資訊,但是型別的擴充套件卻可以包含程式碼。通過這種方式,程式碼可以以樹形層級結構來說實現繼承共享:值型別作為葉子節點,而根節點和中間節點則是協議於相應的協議擴充套件。

但是協議擴充套件的實現,作為一個新的處女地,還存在幾個問題。程式碼可能不會總是如我們所期待的那樣執行。因為Swift中結構和列舉都是值型別於協議在一起的使用的時候會有坑,我們在示例中先使用類來避免這個坑,然後再對比看看前者的一下神奇的坑。

簡單例項:類型別的pizza

我們首先假設,這裡有三種pizza,是由兩種穀物製作的:

enum Grain  { case Wheat, Corn }
class  NewYorkPizza  { let crustGrain: Grain = .Wheat }
class  ChicagoPizza  { let crustGrain: Grain = .Wheat }
class CornmealPizza  { let crustGrain: Grain = .Corn  }   

每一種pizza都能返回製作的原料:

NewYorkPizza().crustGrain     // returns Wheat
ChicagoPizza().crustGrain     // returns Wheat
CornmealPizza().crustGrain     // returns Corn  

因為大部分的pizza都是由Wheat製作的,我們可以將通用的程式碼分解出來放在一個超類的預設的實現裡面:

enum Grain { case Wheat, Corn }
class Pizza {
    var crustGrain: Grain { return .Wheat }
    // other common pizza behavior
}
class NewYorkPizza: Pizza {}
class ChicagoPizza: Pizza {}

其它情況可以使用過載來解決:

class CornmealPizza: Pizza {
    override var crustGain: Grain { return .Corn }
}

哎呀!這裡程式碼是錯的,幸好編譯器檢查出來了。我們在寫變化crustGain遺漏了一個字元`r`。Swift通過強制類中過載的程式碼必須明確對應來避免這種錯誤。因此,這裡程式碼宣告為過載,拼寫錯誤就會被檢查出來。修改後:


class CornmealPizza: Pizza {
    override var crustGrain: Grain { return .Corn }
}  

現在,編譯器會通過:


NewYorkPizza().crustGrain         // returns Wheat
ChicagoPizza().crustGrain         // returns Wheat
CornmealPizza().crustGrain         // returns Corn  

我們可以更進一步分解出通用程式碼,父項Pizza允許我們不必知道具體的pizza型別就可以進行操作,因為我們可以宣告一個通用的pizza變數。

var pie: Pizza  

通用型的pizza變數依然可以如下獲得具體的資訊:

pie =  NewYorkPizza();        pie.crustGrain     // returns Wheat
pie =  ChicagoPizza();      pie.crustGrain     // returns Wheat
pie = CornmealPizza();      pie.crustGrain     // returns Corn  
  

上面的引用型別是個很好的例子。但是當程式涉及到併發的時候,就會面臨一些條件競爭,而值型別則由於不可變的語言特性支援而不會出現這些情況。接下來看看值型別下的pizza。

簡單的值型別的例子

pizza的三種種類和原料可以使用struct之類的值型別表示,就像引用型別一樣簡單。

enum Grain { case Wheat, Corn }
struct  NewYorkPizza     { let crustGrain: Grain = .Wheat }
struct  ChicagoPizza     { let crustGrain: Grain = .Wheat }
struct CornmealPizza     { let crustGrain: Grain = .Corn  }  

如下呼叫:


NewYorkPizza()    .crustGrain     // returns Wheat
ChicagoPizza()    .crustGrain     // returns Wheat
CornmealPizza()    .crustGrain     // returns Corn     

包含所有pizza的協議和一個無法檢測到的錯誤

使用引用型別,我們可以宣告一個公共的父類來表示一個通用的“pizza”概念。在值型別中要實現相同的功能,我們需要兩個部分而不是一個:一個宣告通用型別的協議和定義新型別屬性的協議擴充套件。


protocol Pizza {}
extension Pizza {  var crustGrain: Grain { return .Wheat }  }

struct  NewYorkPizza: Pizza { }
struct  ChicagoPizza: Pizza { }
struct  CornmealPizza: Pizza {  let crustGain: Grain = .Corn }

編譯如下程式碼做測試:

NewYorkPizza().crustGrain         // returns Wheat
ChicagoPizza().crustGrain         // returns Wheat
CornmealPizza().crustGrain         // returns Wheat  What?!

這裡發生了錯誤,與上面提到的錯誤一樣也是忘記了字元`r`。但是在值型別,這裡沒有override關鍵字去幫助編譯器發現錯誤。在語言中出現這樣的遺漏是很不合適的,否則你需要提供足夠的冗餘去發現這個錯誤。沒有了編譯器的幫助,我們只能自己更加小心一點。第一個坑的準則:

⚠️ 在過載協議擴充套件的屬性時候移動要複查,屬性名稱。

好了,讓我們修復這個問題並再次測試:

struct CornmealPizza: Pizza {  let crustGrain: Grain = .Corn }
NewYorkPizza().crustGrain         // returns Wheat
ChicagoPizza().crustGrain         // returns Wheat
CornmealPizza().crustGrain         // returns Corn  Hooray!

Pizza變數,但是錯誤的答案

為了討論一個通用的pizza而不關心具體的型別,我們可以使用Pizza協議作為一個變數的型別。然後我們使用變數來獲得不同pizza的原料:

var pie: Pizza

pie =  NewYorkPizza(); pie.crustGrain  // returns Wheat
pie =  ChicagoPizza(); pie.crustGrain  // returns Wheat
pie = CornmealPizza(); pie.crustGrain  // returns Wheat    Not again?!

為什麼對於cornmeal pizza程式返回給我們的是wheat?Swift編譯後的程式碼忽略了其真實的值。編譯器能提供給編譯後的程式碼資訊就是程式編譯時的資訊,而不是程式碼執行時的資訊。這裡,我們在編譯時(compile-time)能知道的就是pie是一個pizza,並且在pizza的擴充套件裡面宣告瞭是Wheat,所以CornmealPizza裡面的宣告並不會起到任何作用,呼叫的時候自然無法返回我們希望的結果。儘管便一起可會對這個使用靜態而不是動態呼叫的潛在錯誤提出警告,但是這裡沒有。我相信一不小心你就會掉進去,我稱之為大圈套。

這裡提供了一個方案可以修復這個問題。除了在協議擴充套件裡面定義屬性外:

protocol  Pizza {}
extension Pizza {  var crustGrain: Grain { return .Wheat }  }

我們還在協議裡進行宣告:

protocol  Pizza {  var crustGrain: Grain { get }  }
extension Pizza {  var crustGrain: Grain { return Wheat }  }

使用既提供一個宣告並且加上定義這種做法給Swift,會讓它通知編譯器變數的執行時的型別和值。(但並不全是這樣,當我們沒有在協議擴充套件裡面定義crustGrain的話,協議裡的crustGrain宣告必須在每一個繼承Pizza型別裡面[structure, class, or enumeration]實現。)

協議裡面宣告的屬性意味這兩件不同的事,靜態和動態分發,而這取決於屬性有沒有在協議擴充套件裡面進行定義。

協議裡面新增宣告後,程式碼正常工作了:


pie =  NewYorkPizza();  pie.crustGrain     // returns Wheat
pie =  ChicagoPizza();  pie.crustGrain     // returns Wheat
pie = CornmealPizza();  pie.crustGrain     // returns Corn    Whew!  

這是一個很嚴重的坑;即使我們已經弄清它了,但是這依然可能給我們程式帶來bug。這裡要感謝一些這篇文章作者Alexandros Salazar。就像文章中提到的一樣這裡沒有任何編譯時檢查,為了避免這個坑:

⚠️ 每一個協議擴充套件中定義的屬性,請在協議中進行宣告。

但是這種規避並不是總是可能的。

匯入的協議並不能完全被擴充套件

框架和類庫為程式程式碼提供了介面以供使用,而且不需要知道框架程式碼實現的細節。例如蘋果提供了實現的使用者體驗,系統工具等功能很多框架。Swift語言的擴充套件功能允許程式新增自己的屬性到匯入的類、結構、列舉和協議中。對於具體的型別(類、結構、列舉),能夠很好的工作,這些屬性就像是匯入框架中自己原有的定義一樣。但是對於協儀擴充套件來說,她定義的屬性並沒有一等公民的待遇。因為在協議擴充套件裡面新增一個屬性並不能在協議裡面進行宣告。

下面我們導定義了pizzas的協議框架。框架裡面定義了協議和具體型別:

// PizzaFramework:

public protocol Pizza { }

public struct  NewYorkPizza: Pizza  { public init() {} }
public struct  ChicagoPizza: Pizza  { public init() {} }
public struct CornmealPizza: Pizza  { public init() {} }  

接下來,我們匯入框架並且進行擴充套件:


import PizzaFramework

public enum Grain { case Wheat, Corn }

extension Pizza         { var crustGrain: Grain { return .Wheat    } }
extension CornmealPizza { var crustGrain: Grain { return .Corn    } }  

與前面一樣,靜態分發導致了一個錯誤的答案:


var pie: Pizza = CornmealPizza()
pie.crustGrain                            // returns Wheat   Wrong!

與前面解釋的原因一樣,crustGrain屬性只進行了定義而沒有在協議中宣告。然而,我們不能修改框架裡面的原始碼,所以我們無法修復這裡的問題。因此無法安全的從其它框架中擴充套件一個協議(除非你確信它永遠都不會需要動態分發)。為了避免這個問題:

⚠️ 永遠不要從匯入的框架中擴充套件一個包含可能需要動態分發的屬性的協議

正如在任何大型系統一樣,Swift中的特性數量會導致一個與之數量匹配的潛在不良後果。如剛剛描述的,框架與協議擴充套件相互作用限制了後者的作用。但框架是不是唯一的問題,型別限制也會對協議擴充套件產生不利影響。

在受限的協議擴充中:宣告變數已經不夠

當我們擴充一個通用的協議,而該協議裡面的某些屬性只在某些型別裡面使用時,我們可以在一個受限的協議裡面。但是語言可能並不是我們所期待的那樣。回顧一下我們前面的例項程式碼:

enum Grain { case Wheat, Corn }
protocol  Pizza { var crustGrain: Grain { get }  }
extension Pizza { var crustGrain: Grain { return .Wheat }  }
struct  NewYorkPizza: Pizza  { }
struct  ChicagoPizza: Pizza  { }
struct CornmealPizza: Pizza  { let crustGrain: Grain = .Corn }  

我們可能做了一頓飯主食時pizza。但是並不是每次飯食裡面都有pizza,所以我們將不同種類的食物定義為一個通用的膳食結構型別的變數型別:

struct Meal<MainDishOfMeal>: MealProtocol {
    let mainDish: MainDishOfMeal
}

Meal繼承了MealProtocol協議,該協議可以檢查食物是否有谷蛋白。為了是無谷蛋白的程式碼能過在不同的meal中分享(如在沒有主食的meal),我們使用如下協議:

protocol MealProtocol {
    typealias MainDish_OfMealProtocol
    var mainDish: MainDish_OfMealProtocol {get}
    var isGlutenFree: Bool {get}
}

為了防止有人中毒,做到有備無患,程式碼需要設定一個安全保守的預設值:

extension MealProtocol {
    var isGlutenFree: Bool  { return false }
}  

很高興,有一道菜是沒問題的:用corn而不是wheat做成的pizza。Swift中的where結構提供了一個方法將這個情況表示為一個受限的協議擴充套件。如果主食是pizza的話,我們知道它有一個crust,我們可以很安全的獲取該屬性。如果不使用where的話程式碼是不安全的:

extension MealProtocol  where  MainDish_OfMealProtocol: Pizza {
    var isGlutenFree: Bool  { return mainDish.crustGrain == .Corn }
}

這個帶where的擴充套件被稱為受限擴充套件(restricted extension)。

接下來,我們看一下cornmeal pizza!

let meal: Meal<Pizza> = Meal(mainDish: CornmealPizza())

meal.isGlutenFree // returns false
// But there is no gluten! Why can’t I have that pizza?

就像前面提到的一樣,在協議中進行屬性宣告,並在協議擴充套件裡面進行相應定義會導致動態分發。但是在受限的協議擴充套件裡面的定義永遠都是靜態分發的。為了避免這個坑帶來的bug:

⚠️ 避免對一個協議進行受限擴充套件,特別是當擴充套件裡面有個新的屬性需要動態分發的時候。

即使你避免了上面於雨協議相關的坑,Swift中還有一些其它的坑。其中大部分都在官方的書籍裡面提到了,但是當我們將它單獨拿出來分析的時候會更加的突出、明顯,這其中就包括接下來要討論的。

可選鏈賦值以及相應的一些副作用

注:這裡的副作用side-effects是這樣理解的,就是發生了一些使用者意料意外的事。總之,“side effects”指的就是那些本不應有或者使用者意料之外的作用。

Swift中的可選型別使用對可能是nil值靜態檢查,避免了可能存在的錯誤。它提供了一個方便速記、可選鏈,來處理什麼時候nil值需要忽略操作,就像Objective-C中預設的一昂。不幸的是,Swift可選鏈的一些細節可能導致錯誤的發生,那就是當我們對潛在的空應用變數進行賦值的時候。考慮如下情況,一個物件包含一個整型變數,有一個可能是空指標指向該物件,並且進行賦值:

class Holder  { var x = 0 }
var n = 1
var h: Holder? = ...
h?.x = n++
n  // 1 or 2?

上面程式碼中n的值,取決於h是不是空值。如果不是空值,那麼賦值語句執行,然後n會自增,結束的時候n就為2。反之,賦值語句不執行,自增語句可回跳過,結束的時候n就為1。為了避免這個坑照成的困惑:

⚠️ 不要將一個可能帶有副作用的語句表示式賦值給左側的可選鏈。

Swift函數語言程式設計的坑

Swift對函數語言程式設計的支援,讓這一程式設計模式的有點更夠應用於整個蘋果生態系統中。Swift中的函式和閉包是語言中的第一等實體,它們容易使用且功能強大。但是,這裡面也有坑在等你。

閉包中輸入輸出引數會失效

在Swift中輸入輸出引數,允許在呼叫函式時接收一個變數的值,並且設定該變數的值。而閉包則可以捕獲和抓取上下文中的常量和變數的引用。這兩個特性會讓程式碼變得更加的優雅和容易理解。所有你可能同時使用這兩個特性,但是當他們一起使用的時候會導致一些問題。

首先讓我們來重寫crustGrain屬性來理解輸入輸出引數。我們以簡單的例子開始,不包含閉包:

enum Grain { case Wheat, Corn }
struct CornmealPizza {
    func setCrustGrain(inout grain: Grain)  { grain = .Corn }
}  

下面我們來簡單的測試一下上面的函式,我們傳遞一個變數過去。當程式返回的時候,該變數的值會從Whwat變成了Corn

let pizza = CornmealPizza()
var grain: Grain = .Wheat
pizza.setCrustGrain(&grain)
grain      // returns Corn  

現在,我們寫一個函式,該函式會返回一個閉包,而這個閉包可以設定grain變數的值:

struct CornmealPizza {
    func getCrustGrainSetter()   ->   (inout grain: Grain) -> Void {
        return { (inout grain: Grain) in grain = .Corn }
    }
}   

使用這個閉包的話會需要更多的呼叫步驟:

var grain: Grain = .Wheat
let pizza = CornmealPizza()
let aClosure = pizza.getCrustGrainSetter()
grain   // returns Wheat (We have not run the closure yet)
aClosure(grain: &grain)
grain   // returns Corn  

到目前為止,程式碼執行良好沒有出現問題。但是如果我們把輸入輸出引數grain傳遞給返回閉包的函式而不是閉包本身的時候會發生什麼呢?

struct CornmealPizza {
    func getCrustGrainSetter(inout grain: Grain)  ->  () -> Void {
        return { grain = .Corn }
        }
}  

我們試著測試一下程式碼:

var grain: Grain = .Wheat
let pizza = CornmealPizza()
let aClosure = pizza.getCrustGrainSetter(&grain)
grain    // returns Wheat (We have not run the closure yet)
aClosure()
grain    // returns Wheat  What?!?

輸入輸出引數傳遞到閉包外部的時候,沒有起到任何作用,因此:

⚠️ 儘量在閉包裡面避免使用輸入輸出引數

這個問題在官方書籍中有提到,但是這裡存在一個與之相關的問題。那就是使用Currring建立閉包的時候。

Currying 裡面使用輸入輸出引數會導致與上面不一致的問題

對於建立和返回一個閉包的函式,Swift提供了一個緊湊的語法和結構。雖然這個Currying的語法很簡短緊湊,但是當在裡面使用輸出輸入引數時會有一個隱藏的錯誤。為了揭示這個問題,我們使用一個帶有Curring語法的相同例子。不同於宣告一個返回函式型別的函式,這裡在第一個引數列表後面還有另一個引數列表,而這也隱藏了閉包的建立:

struct CornmealPizza {
    func getCrustGrainSetterWithCurry(inout grain: Grain)() -> Void {
        grain = .Corn
    }
}

與顯式的建立閉包一樣,呼叫該函式也會返回一個閉包:

var grain: Grain = .Wheat
let pizza = CornmealPizza()
let aClosure = pizza.getCrustGrainSetterWithCurry(&grain) 

但是與前面的設定失敗不同,這裡成功了:

aClosure()
grain    // returns Corn 

顯示構造閉包失敗的地方,Curring能夠成功起到作用

⚠️ 不要在Curring裡面使用輸入輸出引數,因為如果你以後將程式碼改為顯示建立閉包的話,程式碼會不起作用而失效

總結

針對蘋果裝置上的軟體程式設計,Swift語言進行了進行了精心的設計。就像任何雄心勃勃的承諾一樣,總有一些邊緣問題會導致程式不按我們的意願執行。為了避免這些坑:

  • ⚠️ 在過載協議擴充套件的屬性時候移動要複查,屬性名稱。

  • ⚠️ 每一個協議擴充套件中定義的屬性,請在協議中進行宣告。

  • ⚠️ 永遠不要從匯入的框架中擴充套件一個包含可能需要動態分發的屬性的協議

  • ⚠️ 避免對一個協議進行受限擴充套件,特別是當擴充套件裡面有個新的屬性需要動態分發的時候。

  • ⚠️ 不要將一個可能帶有副作用的語句表示式賦值給左側的可選鏈。

  • ⚠️ 儘量在閉包裡面避免使用輸入輸出引數

  • ⚠️ 不要在Curring裡面使用輸入輸出引數,因為如果你以後將程式碼改為顯示建立閉包的話,程式碼會不起作用而失效

相關文章