Objective-C 轉 Swift 的第一道坎——論如何正確的處理可選型別

小橘爺發表於2018-04-13

從 Objective-C 轉 Swift 開發已經有一段時間了,這兩門語言在整體的理念上差異還是蠻大的。在這之中,可選型別的處理是每一個使用 Swift 的開發者每天都要面臨的問題,理解並正確處理好可選型別對於寫出高質量的 Swift 程式碼和保證 iOS 專案的健壯性都是至關重要的。

可選型別

要想處理好可選型別,就要先理解可選型別。

一個可選型別代表有兩種可能性:有一個值,你可以解包可選型別來訪問該值;或者根本沒有值。

Objective-C 中不存在可選型別的概念,Objective-C 中最接近的東西就是 nil, nil 的意思是“沒有有效的物件”。但是,這隻適用於物件——它不適用於結構、基本資料型別或列舉值。對於這些型別,Objective-C 方法通常會返回一個特殊值(如 NSNotFound)來指示缺少值。這種方法假設方法的呼叫者知道有一個特殊的值來測試,並記得檢查它。Swift 的可選值可以讓你指出可能為 nil 的任何型別的值,而不需要特殊的常量。

例如,SwiftInt 型別有一個初始化方法,它試圖將一個 String 值轉換成一個 Int 值。但是,並不是每個字串都可以轉換成一個整數。字串 "123" 可以轉換為數字值 123,但字串 "Hello, world" 沒有一個明顯的數值要轉換。

下面的例子使用初始化方法來嘗試將一個字串轉換為一個 Int

let possibleNumber = "123"
let convertedNumber = Int(possibleNumber)
// convertedNumber 被推斷為 "Int?" 型別或 “可選的 Int”
複製程式碼

因為初始化方法可能會失敗,所以它返回一個可選的 Int,而不是一個 Int。可選的 Int 被寫為 Int?,而不是 Int。問號表示它所包含的值是可選的,這意味著它可能包含一個 Int 值,或者它可能根本不包含任何值。

nil

通過賦值給它一個特殊的值 nil 來設定一個可選變數為無值狀態:

var serverResponseCode: Int? = 404
// serverResponseCode 包含一個實際的 Int 值為 404
serverResponseCode = nil
// serverResponseCode 現在不包含任何值 
複製程式碼

如果你定義了一個可選變數而不提供預設值,則該變數會自動設定為 nil

var surveyAnswer: String?
// surveyAnswer 自動設定為 nil
複製程式碼

SwiftnilObjective-C 中的 nil 不相同。在 Objective-C 中,nil 是一個指向不存在物件的指標。在 Swift 中,nil 不是一個指標,它是缺少某種型別的值。任何型別的可選值都可以被設定為 nil,而不僅僅是物件型別。

處理可選型別

Swift 中,處理可選型別總體而言有五種方式:強制解包、可選繫結、隱式解包、Nil-Coalescing 運算子和可選鏈。接下來我們將簡要介紹一下這五種方式:

強制解包

一旦確定可選值包含值,可以通過在可選值名稱的末尾新增感嘆號(!)來訪問其內部值。這被稱為強制解包一個可選的值。

print("convertedNumber has an integer value of \(convertedNumber!).")
複製程式碼

試著用 ! 訪問不存在的可選值會觸發執行時錯誤。在使用強制解包之前,一定要確保一個可選值不為 nil

if convertedNumber != nil {
    print("convertedNumber has an integer value of \(convertedNumber!).")
}
// 列印 "convertedNumber has an integer value of 123."
複製程式碼

可選繫結

你可以使用可選繫結來發現可選值是否包含值,如果有,則使用該值用作臨時常量或變數。可選繫結可以與 ifwhile 語句一起使用,以檢查可選值內部的值,並將該值提取為常量或變數,作為單次操作的一部分。

使用 if 語句編寫一個可選繫結,如下所示:

if let actualNumber = Int(possibleNumber) {
    print("\"\(possibleNumber)\" has an integer value of \(actualNumber)")
} else {
    print("\"\(possibleNumber)\" could not be converted to an integer")
}
// 列印 ""123" has an integer value of 123"
複製程式碼

如果轉換成功,那麼 actualNumber 常量可以在 if 語句的第一個分支中使用。它已經被初始化為包含在非可選的值中,所以沒有必要使用 ! 字尾來訪問它的值。

你可以使用可選繫結的常量和變數。如果你想在 if 語句的第一個分支內操作 actualNumber 的值,你可以寫 if var actualNumber,使得可選值作為一個變數而非常量。

你可以根據需要在單個 if 語句中包含儘可能多的可選繫結和布林條件,並用逗號分隔。如果可選繫結中的任何值為 nil,或者任何布林條件的計算結果為 false,則整個 if 語句的條件被認為是錯誤的。以下 if 語句是等價的:

if let firstNumber = Int("4"), let secondNumber = Int("42"), firstNumber < secondNumber && secondNumber < 100 {
    print("\(firstNumber) < \(secondNumber) < 100")
}
// 列印 "4 < 42 < 100"
 
if let firstNumber = Int("4") {
    if let secondNumber = Int("42") {
        if firstNumber < secondNumber && secondNumber < 100 {
            print("\(firstNumber) < \(secondNumber) < 100")
        }
    }
}
// 列印 "4 < 42 < 100"
複製程式碼

隱式解包可選型別

有時從程式的結構中可以清楚的看到,在第一次設定值之後,可選值將始終有一個值。在這些情況下,每次訪問時都不需要檢查和解包可選值,因為可以安全地假定所有的時間都有一個值。

這些可選值被定義為隱式解包可選值。你寫一個隱式解包的可選值,在你想要的可選型別之後放置一個感嘆號(String!)而不是一個問號(String?

隱式解包可選值的背後是普通可選值,但也可以像非可選值一樣使用,而不必在每次訪問時解包可選值。

let possibleString: String? = "An optional string."
let forcedString: String = possibleString! // 需要感嘆號
 
let assumedString: String! = "An implicitly unwrapped optional string."
let implicitString: String = assumedString // 不需要感嘆號
複製程式碼

如果隱式解包可選值為 nil,並且你嘗試訪問其包裝的值,則會觸發執行時錯誤。

你仍然可以對隱式解包可選值使用強制解包和可選繫結。

Nil-Coalescing 運算子

Nil-Coalescing 運算子(a ?? b)如果 a 包含一個值則解包它,或者返回一個預設值 b(如果 anil)。表示式 a 始終是可選的型別,表示式 b 必須匹配儲存在 a 中的型別。

Nil-Coalescing 運算子是以下程式碼的簡寫:

a != nil ? a! : b
複製程式碼

上面的程式碼使用三元條件運算子,並強制解包(a!)來訪問 a 來訪問 a 不為 nil 時包裝的值,否則返回 b。Nil-Coalescing 運算子提供了一種更簡潔的方式來以簡潔易懂的形式封裝這個條件檢查和解包。

如果 a 的值不是 nil,則不計算 b 的值。這就是所謂的短路計算。

可選鏈

可選鏈是查詢和呼叫可能當前為 nil 的可選屬性,方法和下標的過程。如果可選值包含一個值,那麼屬性,方法和下標呼叫將會成功;如果可選值為 nil,則屬性,方法和下標呼叫返回 nil。多個查詢可以連結在一起,如果鏈中的任何連結有一個為 nil,則整個連結將優雅的失敗。

可選鏈可以作為強制解包的替代。

定義兩個名為 PersonResidence 的類:

class Person {
    var residence: Residence?
}
 
class Residence {
    var numberOfRooms = 1
}
複製程式碼

建立一個新的 Person 示例,由於是它是可選型別,所以它的 residence 屬性預設初始化為 nil

let john = Person()
複製程式碼

如果採用強制解包的方式訪問 johnnumberOfRooms 屬性,則會觸發執行時錯誤:

let roomCount = john.residence!.numberOfRooms
// 這會觸發執行時錯誤
複製程式碼

可選鏈提供了另一種訪問 numberOfRooms 值的方法。要使用可選鏈,請使用問號代替感嘆號:

if let roomCount = john.residence?.numberOfRooms {
    print("John's residence has \(roomCount) room(s).")
} else {
    print("Unable to retrieve the number of rooms.")
}
// 列印 "Unable to retrieve the number of rooms."
複製程式碼

可選鏈可以訪問屬性:

if let roomCount = john.residence?.numberOfRooms {
    print("John's residence has \(roomCount) room(s).")
} else {
    print("Unable to retrieve the number of rooms.")
}

john.residence?.numberOfRooms = 2
複製程式碼

可選鏈可以呼叫方法:

class Person {
    var residence: Residence?
}
 
class Residence {
    var numberOfRooms = 1
    func printNumberOfRooms () {
        print("John's residence has \(numberOfRooms) room(s).")
    }
}
...
john.residence?.printNumberOfRooms()
複製程式碼

可選鏈可以訪問下標:

var testScores = ["Dave": [86, 82, 84], "Bev": [79, 94, 81]]
testScores["Dave"]?[0] = 91
testScores["Bev"]?[0] += 1
testScores["Brian"]?[0] = 72
// the "Dave" array is now [91, 82, 84] and the "Bev" array is now [80, 94, 81]
複製程式碼

就是這樣。

Swift 中處理可選型別的建議

因為 Objective-C 中的 nil 對於開發者來說是相對安全的,雖然向集合型別中新增 nil 會造成異常,但是對 nil 傳送訊息並不會有任何的問題(當然業務上可能會有問題)。但在 Swift 中,就像大多數其他語言一樣,向 nil 傳送訊息會造成 crash。而且作為典型的現代強型別語言,可選型別的加入更是給之前長期使用 Objective-C 這種算是弱型別語言的 iOS 開發者帶來了困擾。再此給開發者們一些處理可選型別的建議:

儘可能避免宣告可選型別的例項

除非一些必要的場景(例如代理模式,過程中物件可能為 nil),儘可能的使用非可選型別,包括但不限於屬性宣告和方法引數。

多使用可選繫結、Nil-Coalescing 運算子和可選鏈處理可選型別,避免使用強制解包和隱式解包

Swift 選用 “!” 作為強制解包和隱式解包的標誌是有原因的,這是在提醒我們這是一種很危險的操作,往往會在意想不到的時候給我們的應用帶來額外的 crash

不僅要處理可選繫結和可選鏈的命中分支,對於 else 的情況也要進行額外情況的處理

在進行可選繫結時,可選型別不為 nil 的場景我們都會進行處理,但往往會忽視 else 的情況,儘可能也進行處理,那怕只是一句 log

進行可選繫結時,儘量使用同名的區域性變數

最佳實踐是用同名的區域性變數來可選繫結可選值,這樣可以保證上下文清晰,不會因為出現了新的區域性變數導致閱讀程式碼的人反覆對照。

if let serverResponseCode = serverResponseCode {
    print("serverResponseCode is (\serverResponseCode)")
} else {
    print("serverResponseCode is nil")
}
複製程式碼

至此,關於 Swift 中可選型別的處理就告一段落了,由於 Swift 是一門強型別的語言,如果有哪些場景是我們處理的不正確的,編譯器也會給出相應的提示,但是真正的危險可能不僅止於此……

Objective-C 和 Swift 混編時如何正確的處理可選型別

除了一些在最近一段時間剛剛從零啟動的專案,絕大多數的專案都是處於從 Objective-CSwift 程式碼過渡的階段,這裡面涉及到了對原有 Objective-C 程式碼進行可選非可選區分的問題。

Objective-C 中,你使用可能為 NULL 的原始指標(在 Objective-C 中稱為 nil)來處理物件的引用。 在 Swift 中,所有值(包括結構和物件引用)都保證為非 nil 值。 相反,你表示可以通過將值的型別包裝為可選型別表示其可能缺失。 當你需要表示缺少某個值時,可以使用值 nil

如果讀過一些進行過適配 SwiftObjective-C 寫的三方庫的原始碼之後會發現,很多都用到了這樣的一對巨集:

NS_ASSUME_NONNULL_BEGIN

...

NS_ASSUME_NONNULL_END
複製程式碼

這對巨集的意思是,在這對巨集之間宣告的屬性和方法,其中涉及到的型別都是非可選型別的。很多開發的同學發現這樣一種簡單而又粗暴的將 Objective-C 一鍵適配到 Swift 的方法之後,果斷的在所有的標頭檔案中的開始和結尾處加上這對巨集。然後悲劇就發生了,比如:

NS_ASSUME_NONNULL_BEGIN

// 如果裝置的記憶體處於極小的情況下,會返回 nil
@property (nonatomic, strong) DataBase *dataBase;

NS_ASSUME_NONNULL_END
複製程式碼

在尋常的情況下呼叫資料庫屬性並不會有任何的問題,如果裝置的記憶體處於極小的情況下,會返回 nil,這在純 Objective-C 的程式碼中也不會有什麼問題,但是當混編時:

let x = object.dataBase().fetchUserInfo() // 當 dataBase 返回 nil 時,會 crash。
複製程式碼

因為你已經通過巨集宣告瞭 dataBase 屬性是非可選的,所以編譯器就會認為這個屬性是非可選的,不會給出任何處理可選的提示。

看到這裡你可能會說,對於這類情況,可以通過判斷是否為 nil 來進行處理,比如:

if object.dataBase() != nil {
    ...
}
複製程式碼

這在 debug 模式下是行的通的,但是在 release 模式下,iOS 系統為了優化效能,會對所有標記了非可選型別的物件的與 nil 的比較直接認為是 true,直接落入了括號中,造成更不可查的 crash。所以最好的處理方式是對任何可能出現 nil 可能的屬性或方法引數都加上 nullable

@property (nonatomic, strong, nullable) DataBase *dataBase;
複製程式碼

這樣就可以通知編譯器這是一個可選型別屬性,該有的一些提示和處理也會由編譯器來提供。從而避免了 release 之後出現線上 crash 的悲劇。

原文地址:Objective-C 轉 Swift 的第一道坎——論如何正確的處理可選型別

如果覺得我寫的還不錯,請關注我的微博@小橘爺,最新文章即時推送~

相關文章