七個Swift中的陷阱以及避免方法

bestswifter發表於2017-12-28

文章總結翻譯自:Seven Swift Snares & How to Avoid Them

Swift正在完成一個驚人的壯舉,它正在改變我們在蘋果裝置上程式設計的方式,引入了很多現代範例,例如:函數語言程式設計和相比於OC這種純面嚮物件語言更豐富的型別檢查。

Swift語言希望通過採用安全的程式設計模式去幫助開發者避免bug。然而這也會不可避免的產生一些人造的陷阱,他們會在編譯器不報錯的情況下引入一些Bug。這些陷阱有的已經在Swift book中提到,有一些還沒有。這裡有七個我在去年遇到的陷阱,它們涉及Swift協議擴充套件、可選鏈和函數語言程式設計。

協議擴充套件:強大但是需要謹慎使用

一個Swift類可以去繼承另一個類,這種能力是強大的。繼承將使類之間的特定關係更加清晰,並且支援細粒度程式碼分享。但是,Swift中如果不是引用型別的話(如:結構體、列舉),就不能具有繼承關係。然而,一個值型別可以繼承協議,同時協議可以繼承另一個協議。雖然協議除了型別資訊外不能包含其他程式碼,但是協議擴充套件(protocol extension)可以包含程式碼。照這種方式,我們可以用繼承樹來實現程式碼的分享共用,樹的葉子是值型別(結構體或列舉類),樹的內部和根是協議和與他們對應的擴充套件。

但是Swift協議擴充套件的實現依然是一片新的、未開發的領域,尚存在一些問題。程式碼並不總是按照我們期望的那樣執行。因為這些問題出現在值型別(結構體與列舉)與協議組合使用的場景下,我們將使用類與協議組合使用的例子去說明這種場景下不存在陷阱。當我們重新改為使用值型別和協議的時候將會發生令人驚奇的事。

開始介紹我們的例子:classy 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 }

我們可以通過crustGrain屬性取得披薩所對應的原料

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通過顯式的標註override避免這種錯誤。比如在這個例子中,我們用到了override,但是拼寫錯誤的"crustGain"其實並沒有重寫任何屬性,下面是修改後的程式碼:

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

現在它可以通過編譯併成功執行:

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

同時Pizza超類允許我們的程式碼在不知道Pizza具體型別的時候去操作pizzas。我們可以宣告一個Pizza型別的變數。

var pie: Pizza 但是通用型別Pizza仍然可以去得到特定型別的資訊。

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

Swift的引用型別在這個Demo中工作的很好。但是如果這個程式涉及到併發性、競爭條件,我們可以使用值型別來避免這些。讓我們來試一下值型別的Pizza吧!

這裡和上面一樣簡單,只需要把class修改為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來達到目的。但是對於值型別將要求一個協議和一個協議擴充套件來合作完成。

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?!

對於執行結果,我們想說cornmeal pizza並不是Wheat製作的,返回結果出現錯誤!哎呀!我把 struct CornmealPizza: Pizza { let crustGain: Grain = .Corn } 中的 crustGrain寫成了crustGain,再一次忘記了r,但是對於值型別這裡沒有override關鍵字去幫助編譯器去發現我們的錯誤。沒有編譯器的幫助,我們不得不更加小心的編寫程式碼。

⚠️ 在協議擴充套件中重寫協議中的屬性時要仔細核對

ok,我們把這個拼寫錯誤改正過來:

struct CornmealPizza: Pizza { let crustGrain: Grain = .Corn }

重新執行

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

為了在討論Pizza的時候不需要擔心到底是New York, Chicago, 還是 cornmeal,我們可以使用Pizza協議作為變數的型別。

var pie: Pizza

這個變數能夠在不同種類的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編譯程式碼的時候忽略了變數的目前實際值。程式碼只能夠使用編譯時期的知道的資訊,並不知道執行時期的具體資訊。程式中可以在編譯時期得到的資訊是piepizza型別,pizza協議擴充套件返回wheat,所以在結構體CornmealPizza中的重寫起不到任何作用。雖然編譯器本能夠在使用靜態排程替換動態排程時,為潛在的錯誤提出警告,但它實際上並沒有這麼做。這裡的粗心將帶來巨大的陷阱。

在這種情況下,Swift提供一種解決方案,除了在協議擴充套件中(extension)定義crustGrain屬性之外,還可以在協議中宣告。

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

在協議內宣告變數並在協議擴充中定義,這樣會告訴編譯器關注變數pie執行時的值。

在協議中一個屬性的宣告有兩種不同的含義,靜態還是動態排程,取決於是否這個屬性在協議擴充套件中定義。

補充了協議中變數的宣告後,程式碼可以正常執行了:

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

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

然而這個設法避免陷阱的方式並不總是有效的。

匯入的協議不能夠完全擴充套件。

框架(庫)可以使一個程式匯入介面去使用,而不必包含相關實現。例如蘋果提供給我們提供了需要框架,實現了使用者體驗、系統設施和其他功能。Swift的擴充套件允許程式嚮匯入的類、結構體、列舉和協議中新增自己的屬性(這裡的屬性並不是儲存屬性)。通過協議擴充新增的屬性,就好像它原來就在協議中一樣。但實際上定義在協議擴充中的屬性並非一等公民,因為通過協議擴充無法新增屬性的宣告。

我們首先實現一個框架,這個框架定義了Pizza協議和具體的型別

// PizzaFramework:

public protocol Pizza { }

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

匯入框架並且擴充套件Pizza

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屬性並沒有在協議中宣告,而是隻是在擴充套件中定義。然而,我們沒有辦法對框架的程式碼進行修改,因此也就不能解決這個問題。因此,想要通過擴充套件增加其他框架的協議屬性是不安全的。

⚠️ 不要對匯入的協議進行擴充套件,新增可能需要動態排程的屬性

正像剛才描述的那樣,框架與協議擴充套件之間的互動,限制了協議擴充套件的效用,但是框架並不是唯一的限制因素,同樣,型別約束也不利於協議擴充套件。

Attributes in restricted protocol extensions: declaration is no longer enough

回顧一下此前Pizza的例子:

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,所以我們使用一個通用的Meal結構體來適應各種情況。我們只需要傳入一個引數就可以確定進餐的具體型別。

struct Meal: MealProtocol { let mainDish: MainDishOfMeal }

結構體Meal繼承自MealProtocol協議,它可以測試meal是否包含谷蛋白。

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

為了避免中毒,程式碼中使用了預設值(不含有谷蛋白)

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

Swift中的 Where提供了一種方式去表達約束性協議擴充套件。當主菜是pizza的時候,我們知道pizzascrustGrain屬性,我們就可以訪問這個屬性。如果沒where這裡的限制,我們在不是Pizza的情況下訪問scrustGrain是不安全的。

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

一個帶有Where的擴充套件叫做約束性擴充套件。

讓我們做一份美味的cornmeal Pizza

let meal: Meal = Meal(mainDish: CornmealPizza())

結果:

meal.isGlutenFree // returns false // 根據協議擴充,理論上應該返回true

正像我們在前面小節演示的那樣,當發生動態排程的時候,我們應該在協議中宣告,並且在協議擴充套件中進行定義。但是約束性擴充套件的定義總是靜態排程的。為了防止由於意外的靜態排程而引起的bug:

⚠️ 如果一個新的屬性需要動態排程,避免使用約束性協議擴充套件

使用可選鏈賦值和副作用

Swift可以通過靜態地檢查變數是否為nil來避免錯誤,並使用一種方便的縮略表示式,可選鏈,用於忽略可能出現的nil。這一點也正是Objective-C的預設行為。

不幸的是,如果可選鏈中被賦值的引用有可能為空,就可能導致錯誤,考慮下面這段程式碼,Holder中存放一個整數:

class Holder  {
var x = 0
}

var n = 1
var h: Holder? = nil
h?.x = n++
複製程式碼

在這段程式碼的最後一行中,我們把n++賦值給h的屬性。除了賦值以外,變數n還會自增,我們稱此為副作用

變數n最終的值會取決於h是否為nil。如果h不為nil,那麼賦值語句執行,n++也會執行。但如果h為nil,不僅賦值語句不會執行,n++也不會執行。為了避免沒有發生副作用導致的令人驚訝的結果,我們應該:

⚠️ 避免把一個有副作用的表示式的結果通過可選鏈賦值給等號左邊的變數

函式程式設計陷阱

由於Swift的支援,函數語言程式設計的優點得以被帶入蘋果的生態圈中。Swift中的函式和閉包都是一等公民,不僅方便易用而且功能強大。不幸的是,其中也有一些我們需要小心避免的陷阱。

比如,inout引數會在閉包中默默的失效。

Swift的inout引數允許函式接受一個引數並直接對引數賦值,Swift的閉包支援在執行過程中引用被捕獲的函式。這些特性有助於我們寫出優雅易讀的程式碼,所以你也許會把它們結合起來使用,但這種結合有可能會導致問題。

我們重寫crustGrain屬性來說明inout引數的使用,為簡單起見,開始時先不使用閉包:

enum Grain {
case Wheat, Corn
}

struct CornmealPizza {
func setCrustGrain(inout grain: Grain)  {
grain = .Corn
}
}
複製程式碼

為了測試這個函式,我們給它傳一個變數作為引數。函式返回後,這個變數的值應該從Wheat變成了Corn:

let pizza = CornmealPizza()
var grain: Grain = .Wheat
pizza.setCrustGrain(&grain)
grain		// returns Corn
複製程式碼

現在我們嘗試在函式中返回閉包,然後在閉包中設定引數的值:

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
複製程式碼

到目前為止一切正常,但如果我們直接把引數傳進getCrustGrainSetter函式而不是閉包呢?

struct CornmealPizza {
func getCrustGrainSetter(inout grain: Grain)  ->  () -> Void {
return { grain = .Corn }
}
}
複製程式碼

然後再試一次:

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

inout引數在傳入閉包的作用域外時會失效,所以:

⚠️ 避免在閉包中使用inout引數

這個問題在Swift文件中提到過,但還有一個與之相關的問題值得注意,這與建立的閉包的等價方法:柯里化有關。

在使用柯里化技術時,inout引數顯得前後矛盾。

在一個建立並返回閉包的函式中,Swift為函式的型別和主體提供了一種簡潔的語法。儘管這種柯里化看上去僅是一種縮略表示式,但它與inout引數結合使用時卻會給人們帶來一些驚訝。為了說明這一點,我們用柯里化語法實現上面那個例子。函式沒有宣告為返回一個閉包,而是在第一個引數列表後加上了第二個引數列表,然後在函式體內省略了顯式的閉包建立:

struct CornmealPizza {
func getCrustGrainSetterWithCurry(inout grain: Grain)() -> Void {
grain = .Corn
}
}
複製程式碼

和顯式建立閉包時一樣,我們呼叫這個函式然後返回一個閉包:

var grain: Grain = .Wheat
let pizza = CornmealPizza()
let aClosure = pizza.getCrustGrainSetterWithCurry(&grain)
複製程式碼

在上面的例子中,閉包被顯式建立但沒能成功為inout引數賦值,但這次就成功了:

aClosure()
grain				// returns Corn
複製程式碼

這說明在柯里化函式中,inout引數可以正常使用,但是顯式的建立閉包時就不行了。

⚠️ 避免在柯里化函式中使用inout引數,因為如果你後來將柯里化改為顯式的建立閉包,這段程式碼就會產生錯誤

總結:七個避免

  • 在協議擴充套件中重寫協議中的屬性時要仔細核對
  • 在協議擴充套件中定義的每一個屬性,需要在協議中進行宣告
  • 不要對匯入的第三方協議進行屬性擴充套件,那樣可能需要動態排程
  • 如果一個新的屬性需要動態排程,避免使用約束性協議擴充套件
  • 避免把一個有副作用的表示式的結果通過可選鏈賦值給等號左邊的變數
  • 避免在閉包中使用inout引數
  • 避免在柯里化函式中使用inout引數,因為如果你後來將柯里化改為顯式的建立閉包,這段程式碼就會產生錯誤

One More Thing

更多好文敬請關注我和@Martin_wjl共同維護的專題——每週學點Swift,每週五更新幾篇精選教程,輕鬆學習Swift。

相關文章