[譯] Swift 中強大的模式匹配

ALVIN君發表於2019-03-01

Swift 語言一個無可置疑的優點就是 switch 語句。在 switch 語句的背後是 Swift 的模式匹配,它使得程式碼更易讀,且安全。你可以利用 switch 語句的模式匹配的可讀性和優勢,將其應用於程式碼中的其他位置。

Swift 語言文件中指定了八種不同的模式。在模式匹配表示式中,我們很難知道其正確的語法。在實際情況中,你可能需要知道型別資訊,來解包取得變數的值,或者只是確認可選值是非空的。使用正確的模式,可以避免笨拙地解包和未使用的變數。

模式匹配中有兩個參與者:模式和值。值是緊跟 switch 關鍵字其後的表示式,或者,如果值在 switch 語句外測試的,則為 = 運算子。模式則是 case 後面的表示式。使用 Swift 語言的規則會對模式和值相互評估。截至 2018 年 7 月 15 日,參考文件中仍有一些關於如何在文章中以及在何處使用模式的一些錯誤,不過我們可以通過一些實驗來發現它們。[1]

接下來,我們先看看在 ifguard、和 while 語句中應用模式,但在此之前,讓我們用 switch 語句的一些非原生用法熱下身。

僅匹配非空變數

如果試圖匹配的值可能為空,我們可以使用可選值模式來匹配,如果不是非空的,就解包取值。在處理遺留下來的(以及一些不那麼遺留)的 Objective-C 方法和函式時,這一點尤其有用。對於 Swift 4.2,IUO 的重新實現使 !? 同義。而對於 Objective-C 方法,如果沒有 nullable 註解,你可能不得不處理此行為。

下面的例子是特別微不足道的,因為這個新的行為可能對於小於 Swift 4.2 的版本不太直觀。以下是 Objective-C 方法:

- (NSString *)aLegacyObjcFunction {
    return @"I haven`t been updated with modern obj-c annotations!";
}
複製程式碼

Swift 方法簽名是:func aLegacyObjcFunction() -> String!,並且在 Swift 4.1 中,這個方法可以通過編譯:

func switchExample() -> String {
    switch aLegacyObjcFunction() {
    case "okay":
       return "fine"
    case let output:
        return output  // implicitly unwrap the optional, producing a String
    }
}
複製程式碼

而在 Swift 4.2 中,你會收到如下報錯:“Value of optional type ‘String?’ not unwrapped; did you mean to use ‘!’ or ‘?’?”(可選型別 ‘String?’ 的值還沒有解包,你是否想要使用 ‘!’ 或 ‘?’ ?)。case let output 是一個簡單的變數賦值模式匹配。它會匹配 aLegacyObjcFunction 返回的 String? 型別而不會去解包取值。其中不直觀的部分是,return aLegacyObjcFunction() 是可以通過編譯的,因為它跳過了變數賦值(模式匹配),型別推斷因此返回的型別是一個 String! 的值,這由編譯器處理。我們應該更優雅地處理它,特別是如果存在有問題的 Objective-C 函式,實際上可以返回 nil

func switchExample2() -> String {
    switch aLegacyObjcFunction() {
    case "okay":
        return "fine"
    case let output?:
        return output 
    case nil:
        return "Phew, that could have been a segfault."
    }
}
複製程式碼

這一次,我們故意去處理可選性的問題。請注意,我們不必使用 if let 來解開 aLegacyObcFunction 的返回值。空模式匹配幫我們處理 case let output?:,其中 output 是一個 String 型別的值。

精確捕獲自定義錯誤型別

在捕獲自定義錯誤型別時,模式匹配非常有用,且富有表現力。一種常見的設計模式是,使用 enum 來定義自定義錯誤型別。這在 Swift 中尤其有效,因為可以容易地將關聯值增添到列舉用例中,用來提供更多有關錯誤的詳細資訊。

這裡我們使用兩種型別的型別轉換模式,以及兩種列舉用例模式來處理可能丟擲的任何錯誤:

enum Error: Swift.Error {
    case badError(code: Int)
    case closeShave(explanation: String)
    case fatal
    case unknown
}

enum OtherError: Swift.Error { case base }

func makeURLRequest() throws { ... }

func getUserDetails() {
    do {
        try makeURLRequest()
    }
    // Enumeration Case Pattern: where clause
    catch Error.badError(let code) where code == 50 {
         print("(code)") }
    // Enumeration Case Pattern: associated value
     catch Error.closeShave(let explanation) {
         print("There`s an explanation! (explanation)")
     }
     // Type Matching Pattern: variable binding
     catch let error as OtherError {
         print("This (error) is a base error")
     }
     // Type Matching Pattern: only type check
     catch is Error {
         print("We don`t want to know much more, it must be fatal or unknown")
     }
     // is Swift.Error. The compiler gives us the variable error for free here
     catch {
         print(error)
     }
}
複製程式碼

在每個 catch 上方,我們匹配並捕獲了我們需要的儘可能多的資訊。下面從 switch 開始,看看我們還能在哪裡使用模式匹配。

一次性匹配

很多時候你可能想要進行一次性模式匹配。你可能只需在給定單個列舉值的情況下應用更改,而且不關心其他值。此時,優雅可讀的 switch 語句突然變成了累贅的樣板檔案。

我們僅可以在非空的元組值中使用 if case 來解開它:

if case (_, let value?) = stringAndInt {
    print("The int value of the string is (value)")
}
複製程式碼

上面的例子在一條語句中使用了三種模式!頂部元組模式,其中包含了一個可選模式(與上面匹配非空變數的模式沒有什麼不同),還有一個鬼祟的萬用字元模式,_。 如果我們使用 switch stringAndInt {...},編譯器會強制我們顯式地處理所有可能的情況,或者執行 default 語句。

或者,如果 guard case 更能滿足你的需求,則無需更改:

guard case (_, let value?) = stringAndInt else {
    print("We have no value, exiting early.")
    exit(0)
}
複製程式碼

你可以使用模式來定義 while 迴圈和 for-in 迴圈的停止條件。這在範圍中非常有用。正規表示式模式允許我們避免傳統的variable >= 0 && variable <= 10 構造 [2]:

var guess: Int = 0

while case 0...10 = guess  {
    print("Guess a number")
    guess = Int(readLine()!)!
}
print("You guessed a number out of the range!")
複製程式碼

在所有這些例子中,模式緊跟在 case 之後,值則在 = 之後。語法與此不同的表示式中有 isasin 關鍵字。在這些情況下,如果將這些關鍵字視為 = 的替代品,那麼結構是相同的。記住這一點,並且通過編譯器的提示,你可以使用所有 8 種模式,而無需參考語言的文件。

到目前為止,我們在前面的例子中還沒有看到用 Range 來匹配表示式模式的一些獨特之處:它的模式匹配實現不是內建功能,至少不是內建於編譯器中的。表示式模式使用了 Swift 標準庫 ~= 操作符~= 操作符是一個自由的泛型函式,定義如下:

func ~= <T>(a: T, b: T) -> Bool where T : Equatable
複製程式碼

你可以看到 Swift 標準庫中的 Range 型別重寫了該運算子,提供了一個自定義行為,用來檢查特定值是否在給定的範圍內。

匹配正規表示式

下面讓我們建立一個實現 ~= 操作符的 Regex 型別。它將會是圍繞 NSRegularExpression 的一個輕量級的封裝器,它使用模式匹配來生成更具可讀性的正規表示式程式碼,在使用神祕的正規表示式時,應始終感興趣。

struct Regex: ExpressibleByStringLiteral, Equatable {

    fileprivate let expression: NSRegularExpression

    init(stringLiteral: String) {
        do {
            self.expression = try NSRegularExpression(pattern: stringLiteral, options: [])
        } catch {
            print("Failed to parse (stringLiteral) as a regular expression")
            self.expression = try! NSRegularExpression(pattern: ".*", options: [])
        }
    }

    fileprivate func match(_ input: String) -> Bool {
        let result = expression.rangeOfFirstMatch(in: input, options: [],
                                range NSRange(input.startIndex..., in: input))
        return !NSEqualRanges(result, NSMakeRange(NSNotFound, 0))
    }
}
複製程式碼

這就是我們的 Regex 結構體。它有一個 NSRegularExpression 屬性。它可以初始化為字串字面常量,其結果是,如果我們無法傳遞一個有效的正規表示式,那麼我們將得到失敗的訊息和一個匹配所有的正規表示式。接下來,我們實現模式匹配操作符,將其巢狀在擴充套件中,這樣就可以清楚地知道要在何處使用該操作符。

extension Regex {
    static func ~=(pattern: Regex, value: String) -> Bool {
        return pattern.match(value)
    }
}
複製程式碼

我們希望這個結構體是開箱即用的,所以我將定義兩個類常量,用來處理一些常見的正則驗證需求。匹配郵箱的正規表示式是從 Matt Gallagher 的 Cocoa with Love 文章裡面借用的,並檢查了 RFC 2822 中定義的電子郵件地址。

如果你在 Swift 中使用正規表示式,那麼你不能就簡單地從 Stack Overflow 關於 Regex 帖子中直接複製程式碼。Swift 字串定義轉義序列,如換行符(
),製表符( ),和 unicode 標量(u{1F4A9})。這與正規表示式的語法相沖突,因為正規表示式含有大量的反斜槓和所有型別的括號。像 Python,則有方便的原始字串語法。原始字串將按逐字逐句地獲取每個字元,並且不會解析轉義序列,因此可以以“純淨的”形式插入正規表示式。在 Swift 中,字串中任何單獨的反斜槓都表示轉義序列,因此對於編譯器來說,如果想要接受大多數的正規表示式,就需要轉義序列以及一些其他特殊字元。這裡有一個小嘗試,嘗試在 Swift 中使用原始字串,但最後失敗了。隨著 Swift 繼續成為一種多平臺,多用途的語言,人們可能會對這個功能重新產生興趣。在此之前,現有複雜的匹配郵件的正規表示式,變成了這個 ASCII 的藝術怪物:

static let email: Regex = """
^(?:[a-z0-9!#$%\&`*+/=?\^_`{|}~-]+(?:\.[a-z0-9!#$%\&`*+/=?\^_`{|}~-]+)*|"(?:[\x01-\x08
\x0b\x0c\x0e-\x1f\x21\x23-\x5b\x5d-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])*")@
(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?|\[(?:(?:25[0-5]|2[0
-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?|[a-z0-9-]*[a-z0-9]:(?
:[\x01-\x08\x0b\x0c\x0e-\x1f\x21-\x5a\x53-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7
f])+)\])$
"""
複製程式碼

我們可以使用一個更簡單的表示式來匹配電話號碼,借用 Stack Overflow 以及如前面所述的雙轉義:

static let phone: Regex = "^(\+\d{1,2}\s)?\(?\d{3}\)?[\s.-]?\d{3}[\s.-]?\d{4}$"
複製程式碼

現在,我們可以使用方便、易讀的模式語法來識別電話號碼或電子郵件:

let input = Bool.random() ? "nerd@bignerdranch.com" : "(770) 817-6373"
switch input {
    case Regex.email:
        print("Send (input) and email!")
    case Regex.phone:
        print("Give Big Nerd Ranch a call at (input)")
    default:
        print("An unknown format.")
}
複製程式碼

你可能想知道為什麼看不到上面的 ~= 操作符。因為它是 Expression Pattern 的一個實現細節,且是隱式使用的。

牢記這些基礎知識!

有了所有這些奇特的模式,我們不應該忘記使用經典 switch 語句的方法。當模式匹配 ~= 操作符未定義時,Swift 在 switch 語句中會使用 == 操作符。重申一下,我們現在不再處於模式匹配的範疇。

以下是一個例子。這裡的 switch 語句用來做一個給委託回撥的分離器。它對 NSObject 子類的 textField 變數執行了 switch 語句。因此,等式被定義為了標識比較,它會檢查兩個變數的指標值是否相等。舉個例子,以一個物件作為三個 UITextField 物件的委託。每個文字欄位都需要以不同的方式驗證其文字。當使用者編輯文字時,委託為每個文字欄位接收相同的回撥,

func textFieldShouldEndEditing(_ textField: UITextField) -> Bool {
    switch textField {
        case emailTextField:
            return validateEmail()
        case phoneTextField:
            return validatePhone()
        case passwordTextField:
            return validatePassword()
        default:
            preconditionFailure("Unaccounted for Text Field")
    }
}
複製程式碼

並且可以不同地驗證每個文字欄位。

結論

我們查閱了 Swift 中可用的一些模式,並檢查了模式匹配語法的結構。有了這些知識,所有 8 種模式都可供使用!模式具有許多優點,它是每個 Swift 開發者的工具箱中不可或缺的一部分。這篇文章還有未涵蓋到的內容,例如編譯器檢查窮舉邏輯的細節以及結合 where 語句的一些模式。

感謝 Erica Sadun 在她的部落格文章 Afternoon Whoa 中向我介紹了 guard case 語法,它是這篇文章的靈感來源。

這篇文章中的所有例子都可以在 gist 中找到。程式碼可以在 Playground 執行,也可以根據你的需要進行挑選。

[1] 該指南要求使用具有關聯值的列舉,“對應的列舉用例模式必須指定一個元組模式,其中包含每個關聯值的一個元素。”如果您不需要關聯的值,只需包含沒有任何關聯值的enum情況就可以編譯和匹配。

另一個小的更正是,自定義表示式操作符 ~= 可能 “僅出現在 switch 語句大小寫標籤中”。在上述例子中,我們也在一個 if 語句中使用到它。Swift 語法正確地說明了上述兩種用法,這個小錯誤只在本文中。

[2] readLine 方法不適用於 Playground。如果要執行此示例,請從 macOS 命令列應用中嘗試。

如果發現譯文存在錯誤或其他需要改進的地方,歡迎到 掘金翻譯計劃 對譯文進行修改並 PR,也可獲得相應獎勵積分。文章開頭的 本文永久連結 即為本文在 GitHub 上的 MarkDown 連結。


掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 AndroidiOS前端後端區塊鏈產品設計人工智慧等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章