函數語言程式設計 - 酷炫Applicative(應用函子) [Swift描述]

TangentW發表於2018-12-24

Applicative

引言

Applicative functor(應用函子),簡稱Applicative,作為函數語言程式設計裡面一個比較重要的概念,其具有十分酷炫的特性,在工程上的實用性也非常高。這篇文章將會以工程的角度不斷深入、層層剖析Applicative,在闡述其概念的同時也會結合小Demo進行實戰演示。

很多函數語言程式設計的概念在我之前寫的文章中已經介紹過,一些相關的也會將在這篇文章中被重複提及,以加深認識。

Functor(函子)

Applicative本身也是Functor,其基於Functor有一套自己額外的概念,用物件導向的角度去理解,可以認為Applicative繼承自Functor。在進一步介紹Applicative之前,我們先來認識一下Functor

概念及引入

什麼是Functor?為了說明,這裡引入Swift的一個結構:Optional:

Optional代表資料可空,其存在兩種狀態,要麼其中具有資料,要麼其為空(nil)

var name: String? = nil
name = "Tangent"
複製程式碼

讓我們來模擬一個場景,假設現在有一個函式,會在傳入的字串前拼接"Hello",返回一個新的字串,它會是這樣:

func sayHello(_ str: String) -> String {
    return "Hello " + str
}
複製程式碼

對於裝有String的Optional型別String?(Optional<String>),它的值將不能直接傳入這個函式中,因為String?並不等同於String,而且編譯器並不會在我們將Optional值傳入非Optional型別變數的時候做自動型別轉換的優化(反過來就可以)。要得到結果值,傳統的做法我們可能會這麼寫:

let name: String? = "Tangent"

// 使用變數
var result: String?
if let str = name {
    result = sayHello(str)
}

// 直接閉包呼叫
let result: String? = {
    guard let str = name else { return nil }
    return sayHello(str)
}()
複製程式碼

上面的兩種寫法都是分情況考慮:假如原來的字串為空,那麼結果值也理所當然是空,否則將字串解包後傳入函式中返回結果。比起純粹地處理非Optional資料,這裡我們需要多做判空這一步。

讓我們來換個角度思考,對於Optional型別,它不僅要儲存資料本身,還要去記錄資料是否為空的標誌,所以我們對Optional值進行處理時,我們除了要處理其中的資料,還要考慮它所攜帶的是否為空的標誌。它就像一個盒子,盒子裡裝有值,本身還具備一些額外的元資訊(我們也可以稱其為副作用)。

當我們操作盒子的時候,我們需要把盒子裡的資料拿出來,並且要考慮到盒子其中所攜帶的額外資訊,像上面的程式碼所示,我們做的不僅要處理Optional中的資料,還需要對Optional進行判空處理。這裡還有一個非常重要的點:我們在上面程式碼所做的處理中,並沒有更改到盒子裡的額外資訊,若原來資料是空的,那麼結果值也會是空,同理若原來資料非空,結果值也不可能為空。

現在,我們可以把上面的操作進行抽象:

  • 存在一種像盒子一樣的資料型別,除了包含內部的資料本身外,可能還攜帶一些額外的元資訊
  • 需要對這個盒子資料型別內部的資料進行一些處理轉換
  • 在處理轉換的過程中並不會改變其中額外的元資訊

為了表示上面的抽象,我們可以引入Functor(函子)

實現

為了方便描述,對於這種盒子資料型別,我們可以叫做Context<A>,其中,A代表內部所裝載資料的型別。Functor中存在一種運算,名字可以叫做mapmap的型別用類似Swift語法的描述可以理解為:map: ((A) -> B) -> Context<A> -> Context<B>,你可以理解為它將一個作用於盒子內部資料的函式提升為一個能作用於盒子的函式,也可以認為它接收一個作用於盒子內部的函式和一個盒子,先將盒子開啟,將函式扔進去作用於盒子內部,然後得到一個具有新資料的盒子。若這個盒子實現了此運算,我們可以認為這個盒子實現了Functor,就像Swift中的協議實現一樣(對於Functor的實現其實還有一些約定,本篇文章不在此詳述,如果你有興趣可以去查閱Functor相關概念進行深入瞭解)。

當然Swift作為一門支援物件導向的語言,我們也可以從物件導向的角度去實現Functor,這裡拿Optional舉個例子:

// Optional在Swift中的定義
enum Optional<Wrapped> {
	case some(Wrapped)
	case none
}

// 為Optional實現Functor
extension Optional {
    // 使用傳統的模式匹配來實現
    func map<U>(_ f: (Wrapped) -> U) -> U? {
        guard case .some(let value) = self else { return nil }
        return f(value)
    }
    
    // 使用Swift語法糖來實現
    func map2<U>(_ f: (Wrapped) -> U) -> U? {
        guard let value = self else { return nil }
        return f(value)
    }
}
複製程式碼

這樣,我們就可以使用map運算來重寫之前的小例子了:

func sayHello(_ str: String) -> String {
    return "Hello " + str
}

let name: String? = "Tangent"

let result = name.map(sayHello)
複製程式碼

Swift其實預設已經為Optional定義了map操作,我們在開發中也可以直接拿來使用。

得益於Functor,當我們在遇到類似的情況時,可以只關注於資料處理本身,而不需要花精力於額外的元資訊上,程式碼的實現更簡潔優雅。

Swift預設實現了Functor的還有Sequence

let arr = [1, 2, 3, 4, 5]
let result = arr.map { 2 * $0 }
複製程式碼

運算子

我們可以為map運算定義運算子<^>,以便在後續使用:

precedencegroup FunctorApplicativePrecedence {
    higherThan: AdditionPrecedence
    associativity: left
}

infix operator <^> : FunctorApplicativePrecedence

func <^> <A, B>(lhs: (A) -> B, rhs: A?) -> B? {
    return rhs.map(lhs)
}
複製程式碼

這樣,我們就可以從更函式式的角度來使用Functor

func sayHello(_ str: String) -> String {
    return "Hello " + str
}

let name: String? = "Tangent"
let result = sayHello <^> name
複製程式碼

值得注意的是,這裡<^>運算子左邊的型別為函式,右邊為盒子型別,看起來跟物件導向的習慣性寫法有點相反。

雖說Swift應儘量避免定義一堆奇奇怪怪的運算子,以免導致程式碼的可讀性降低、增加理解成本,但是<^>運算子其實跟Haskell語言中的<$>十分相似,而且它們功能都是相同的,同理,即將在文章後面定義的<*>運算子在Haskell中你也能找到相同功能的<*>,這些運算子所表達的邏輯可以說是約定俗成的。

Applicative

Applicative基於Functor。比起FunctorApplicative更為抽象複雜,為了能容易理解,本篇接下來將先介紹它的概念以及實現,在最後我們才去結合函數語言程式設計的其他概念來分析它的使用場景,進行專案實戰。

概念

用回在上文提到的盒子模型,Context<A>是一個內部包含A型別資料的盒子,Functormap操作將傳入(A) -> B函式,將盒子開啟,作用於裡面的資料,返回新的的盒子Context<B>。在這期間,改變的只是盒子內部的資料,而盒子中具有的額外元資訊將不受影響。而對於Applicative而言,其具有apply操作,用Swift語法描述其型別可以是:apply: Context<(A) -> B> -> Context<A> -> Context<B>,你可以將它的運算邏輯理解為以下幾個步驟:

  1. 傳入a盒子Context<A>以及b盒子Context<(A) -> B>,a盒子裡面裝著單純的資料,而b盒子裡面裝有一個處理函式
  2. 將a盒子中的資料取出,將b盒子中的函式取出,然後將函式作用於資料,得到型別為B的新值
  3. 將a盒子和b盒子所具有的額外元資訊取出,相互作用得到新的元資訊
  4. 把新的值和元資訊裝入盒子,得到結果Context<B>

由上我們可以發現,FunctormapApplicativeapply其實十分相似,比起mapapply需要接收的是一個包裝著函式的盒子,而不是純粹的函式型別。另外,map在運作的過程中不會對額外的元資訊產生影響,apply因為其接收的引數都是盒子,它們都具有各自的元資訊,所以這裡需要取出這些元資訊,讓它們相互作用,以產生新的元資訊

Applicative還具有一個操作pure,其接收一個普通值作為引數,返回一個盒子。我們可以理解為它將一個原始的資料裝在一個盒子裡面。它的型別用Swift語法可描述為:pure: (A) -> Context<A>。對於通過pure產生的新盒子,其中的元資訊應該處於最初始的狀態。

實現

接下來我們以物件導向的角度來為Optional實現Applicative

extension Optional {
    static func pure(_ value: Wrapped) -> Wrapped? {
        return value
    }
    
    func apply<U>(_ f: ((Wrapped) -> U)?) -> U? {
        switch (self, f) {
        case let (value?, fun?):
            return fun(value)
        default:
            return nil
        }
    }
}
複製程式碼

對於pure,我們定義了一個static方法,直接將接收到的值返回,Swift編譯器會自動幫我們用Optional包裝起來。

對於apply,我們首先看元資訊部分,因為Optional所包含的元資訊是一個判斷資料是否為空的標誌,這裡將Optional例項本身與傳入的包含處理函式的Optional引數雙方的元資訊進行相互作用,作用的邏輯為:假如任意一方的元資訊表示為空,那麼apply所返回Optional結果的元資訊也一樣是空。再來看資料部分,這裡所做的就是把雙方盒子裡的資料取出來,分別是一個函式以及一個普通的值,再將函式作用於值,得到新的結果裝入盒子。

請不要疑惑:“為什麼Optionalnil時明明已經沒有值了為什麼還要從值的角度去考慮?”,因為上面盒子模型中對於元資訊和值的描述是基於抽象的角度來進行思考的。

我們下面就可以來把玩一下:

typealias Function = (String) -> String
let sayHello: Function? = {
    return "Hello " + $0
}

let name: String? = "Tangent"

let result = name.apply(sayHello)
複製程式碼

運算子

我們使用<*>來作為apply的運算子,讓程式碼編寫起來更函式式:

infix operator <*> : FunctorApplicativePrecedence

func <*> <A, B>(lhs: ((A) -> B)?, rhs: A?) -> B? {
    return rhs.apply(lhs)
}
複製程式碼

這裡需要注意的是:FunctorApplicativePrecedence已在文章前面定義,它規定了運算子的結合性是左結合的,所以<^><*>都具有左結合的特性。

下面就來使用一下:

typealias Function = (String) -> String
let sayHello: Function? = {
    return "Hello " + $0
}

let name: String? = "Tangent"

let result = sayHello <*> name
複製程式碼

Curry(柯里化)

Applicative的使用場景離不開函數語言程式設計中另一個重要的概念:Curry(函式柯里化)Curry就是將一個接收多個引數的函式轉變為只接收單一引數的高階函式。像型別為(A, B) -> C的函式,經過Curry後,它的型別就變成了(A) -> (B) -> C。舉個例子,我們有函式add,能夠接收兩個Int型別的引數,並返回兩個引數相加的結果:

func add(_ a: Int, _ b: Int) -> Int {
    return a + b
}

let three = add(1, 2)
複製程式碼

Curry後的add只需接收一個引數,返回的是一個閉包,這裡閉包也需要接收一個引數,最終返回結果值:

func add(_ a: Int) -> (Int) -> Int {
    return { b in a + b }
}

// 連續呼叫
let three = add(1)(2)

// 將返回的閉包儲存起來,後續再呼叫
let inc = add2(1)
let three2 = inc(2)
複製程式碼

為了方便,我們可以構造若干個幫助我們進行Curry的函式,這些函式也叫做curry

func curry<A, B, C>(_ fun: @escaping (A, B) -> C) -> (A) -> (B) -> C {
    return { a in { b in fun(a, b) } }
}

func curry<A, B, C, D>(_ fun: @escaping (A, B, C) -> D) -> (A) -> (B) -> (C) -> D {
    return { a in { b in { c in fun(a, b, c) } } }
}

func curry<A, B, C, D, E>(_ fun: @escaping (A, B, C, D) -> E) -> (A) -> (B) -> (C) -> (D) -> E {
    return { a in { b in { c in { d in fun(a, b, c, d) } } } }
}

// 更多引數的情況 ...
複製程式碼

現在我們可以用一個例子來使用這些curry函式:

struct User {
    let name: String
    let age: Int
    let bio: String
}

let createUser = curry(User.init)
let tangent = createUser("Tangent")(22)("I'm Tangent!")
複製程式碼

上面我們定義了一個結構體User,其具有三個成員。這裡Swift編譯器預設已經幫我們建立了一個User的構造方法:User.init,方法的型別為(String, Int, String) -> User。通過把這個構造方法傳入curry函式,我們得到一個高價的函式(閉包)(String) -> (Int) -> (String) -> User

通過結合CurryApplicative將能發揮強大的作用。

使用場景

大家可能從上面的概念中還摸不清Applicative到底能用來做什麼,下面就來揭露Applicative的實用範圍:

假設現在有一個Dictionary,裡面可能裝有與User相關的資訊,我們想在裡面找尋能構造User的欄位資訊,從而構造出例項:

struct User {
    let name: String
    let age: Int
    let bio: String
}

let dic: [String: Any] = [
    "name": "Tangent",
    "age": 22,
    "bio": "Hello, I'm Tangent!"
]
複製程式碼

在執行時中,dic裡面是否具備構造User的全部欄位資訊我們是不知道的,所以最終的結果為一個被Optional包起來的User,也就是User?,傳統的做法可以這樣寫:

// 使用變數
var tangent: User?
if let name = dic["name"] as? String,
    let age = dic["age"] as? Int,
    let bio = dic["bio"] as? String {
    tangent = User(name: name, age: age, bio: bio)
}

// 直接閉包呼叫
let tangent: User? = {
    guard
        let name = dic["name"] as? String,
        let age = dic["age"] as? Int,
        let bio = dic["bio"] as? String
    else { return nil }
    return User(name: name, age: age, bio: bio)
}()
複製程式碼

在日常的開發中我們是不是也經常會寫出跟上面相似的程式碼呢?這樣寫沒毛病,但是總感覺有點繁雜了...

這時候Applicative粉墨登場了:

let tangent = curry(User.init)
    <^> (dic["name"] as? String)
    <*> (dic["age"] as? Int)
    <*> (dic["bio"] as? String)
複製程式碼

等等,這上面發生了什麼?讓我們來一步步分析:

  1. curry(User.init)生成了一個型別為(String) -> (Int) -> (String) -> User的高階函式(閉包)

    let createUser = curry(User.init)
    複製程式碼
  2. 我們將這個閉包與dic["name"] as? String通過<^>運算子連線:

    let step1 = createUser <^> (dic["name"] as? String)
    複製程式碼

    step1的型別是什麼?回憶一下<^>,它來源於Functormap操作,左邊接收一個函式(A) -> B,右邊則是一個盒子Context<A>,返回盒子Context<B>。現在我們把實際的型別代入:盒子是OptionalAString,因為<^>左邊傳入的函式型別為(String) -> (Int) -> (String) -> User,我們可以理解為(String) -> ((Int) -> (String) -> User),所以這裡B就是(Int) -> (String) -> User,於是,<^>運算結果step1的型別就是Optional<(Int) -> (String) -> User>step1: Optional<(Int) -> (String) -> User>

  3. <*>運算應用於step1dic["age"] as? Int

    let step2 = step1 <*> (dic["age"] as? Int)
    複製程式碼

    <*>來源於Applicativeapply操作,左邊接收一個裝有函式的盒子Context<(A) -> B>,右邊接收一個盒子Context<A>,返回盒子Context<B>。把實際的型別代入:盒子是OptionalAInt,因為我們把step1應用於<*>的左邊,step1是一個裝有(Int) -> (String) -> User函式的Optional盒子,(Int) -> (String) -> User可以理解為(Int) -> ((String) -> User),其作用於A(Int),所以B就是(String) -> User。於是,step2的型別就是Optional<(String) -> User>step2: Optional<(String) -> User>

  4. <*>運算應用於step2dic["String"] as? String,得到結果:

    let tangent = step2 <*> (dic["bio"] as? String)
    複製程式碼

    和上面同理,<*>左邊接收的型別為Context<(A) -> B>,右邊為Context<A>,返回Context<B>,代入實際型別:盒子是OptionalAStringstep2作為一個Optional盒子,裝有型別為(String) -> User的函式,所以B就是User。於是tangent的型別就是Optional<User>tangent: Optional

這就是上方Applicative例子運作的整個過程。比起傳統的寫法,使用Applicative能讓程式碼更加簡潔優雅。

我們也可以在其中使用Applicativepure

let tangent = .pure(curry(User.init))
    <*> (dic["name"] as? String)
    <*> (dic["age"] as? Int)
    <*> (dic["bio"] as? String)
複製程式碼

若使用了pure,我們就不需要Functor<^>了,因為pure已經將函式用盒子裝了起來,後面就需要全部用<*>運算進行操作。不過這樣寫就需要多呼叫了一個函式。

可能有人會疑惑:“使用Applicative的程式碼其實也就是比起傳統的寫法優雅一點點而已,差別不大,為什麼這裡還要大費周章去引入一個新的概念去完成這一件小事?”

因為這裡的例子只是為了方便理解而從Optional的角度去講解,Swift已經為Optional定義了一套語法糖,所以以傳統的寫法來使用Optional已足夠簡潔。但是Applicative並不只侷限於Optional,它足夠強大,能完成更多的事情。

下面將引入其他功能更加強大的Applicative,它們的實用性也非常高。

Result

Result這個概念對於Swifter們來說應該不會陌生,Swift也計劃將它納入到標準庫中了。Result表示了一個可能失敗的操作結果:若操作成功,Result中將裝有結果資料,若失敗,Result中也會裝有表示失敗原因的錯誤資訊。

enum Result<Value, Err> {
    case success(Value)
    case failure(Err)
}
複製程式碼

得益於Swift對代數資料型別的支援,這裡Result將作為一個列舉,包含兩種狀態(成功和失敗),每個狀態都具有一個關聯資料,對於成功的狀態,其關聯著一個結果值,對於失敗,其關聯了一個錯誤資訊。這裡對Result的實現中,我們也為錯誤資訊配有泛型引數,而不單純是一個實現了Error協議的任意型別。Result以一種非錯誤丟擲的形式來向操作呼叫方反饋錯誤資訊,在一些不能使用錯誤丟擲的地方(非同步回撥)中起到非常重要的作用。

引入

讓我們來編寫一個小型的JSON解析函式,它通過一個特定的key將資料從JSON Object(以Dictionary的形式呈現)中取出來,並將其轉換成一個特定的型別:

enum JSONError {
    case keyNotFound(key: String)
    case valueNotFound(key: String)
    case typeMismatch(type: Any.Type, value: Any)
}

extension Dictionary where Key == String, Value == Any {
    func parse<T>(_ key: String) -> Result<T, JSONError> {
        guard let value = self[key] else {
            return .failure(.keyNotFound(key: key))
        }
        guard !(value is NSNull) else {
            return .failure(.valueNotFound(key: key))
        }
        guard let result = value as? T else {
            return .failure(.typeMismatch(type: T.self, value: value))
        }
        return .success(result)
    }
}
複製程式碼

parse方法返回一個Result作為解析的結果,若解析失敗,Result處於failure狀態幷包含JSONError型別的錯誤資訊。

下面來使用看看:

let jsonObj: [String: Any] = [
    "name": NSNull(),
    "age": "error value",
    "bio": "Hello",
]

typealias JSONResult<T> = Result<T, JSONError>
// valueNotFound
let name: JSONResult<String> = jsonObj.parse("name")
// typeMismatch
let age: JSONResult<Int> = jsonObj.parse("age")
// keyNotFound
let gender: JSONResult<String> = jsonObj.parse("gender")
// success!
let bio: JSONResult<String> = jsonObj.parse("bio")
複製程式碼

假設我們要通過一個JSON Object來構造User例項,按照User中宣告的順序來依次解析每個欄位,當解析到某個欄位發生錯誤的時候,我們返回裝有錯誤資訊的Result,如果全部欄位解析成功,我們得到一個包含User例項的Result。按照傳統的做法,我們需要這樣編寫程式碼:

typealias JSONResult<T> = Result<T, JSONError>

func createUser(jsonObj: [String: Any]) -> JSONResult<User> {
    // name
    let nameResult: JSONResult<String> = jsonObj.parse("name")
    switch nameResult {
    case .success(let name):
        
        // age
        let ageResult: JSONResult<Int> = jsonObj.parse("age")
        switch ageResult {
        case .success(let age):
            
            // bio
            let bioResult: JSONResult<String> = jsonObj.parse("bio")
            switch bioResult {
            case .success(let bio):
                return .success(User(name: name, age: age, bio: bio))
                
            case .failure(let error):
                return .failure(error)
            }
            
        case .failure(let error):
            return .failure(error)
        }
        
    case .failure(let error):
        return .failure(error)
    }
}
複製程式碼

上面的程式碼層層巢狀、非常繁雜,每一個欄位解析完畢後我們還要分情況做考慮:當解析成功,繼續解析下一個欄位,當解析失敗,返回失敗值。如果後期User需要新增或修改欄位,這裡的程式碼改起來就非常麻煩。

使用Applicative就能夠更加優雅地實現上面的需求。

實現

現在為Result實現Applicative。因為Applicative基於Functor,這裡首先讓Result成為一個Functor

// Functor
extension Result {
    func map<U>(_ f: (Value) -> U) -> Result<U, Err> {
        switch self {
        case .success(let value):
            return .success(f(value))
        case .failure(let error):
            return .failure(error)
        }
    }
}

func <^> <T, U, E>(lhs: (T) -> U, rhs: Result<T, E>) -> Result<U, E> {
    return rhs.map(lhs)
}

func testFunctor() {
    let value: Result<String, Never> = .success("Hello")
    let result = value.map { $0 + " World" }
}
複製程式碼

Result盒子的元資訊表明了操作過程中可能產生的錯誤資訊,因為map不會影響到盒子的元資訊,所以如果原來的Result是失敗的,那麼得到的結果也處於失敗的狀態。整個過程就如文章之前所述,將盒子內的資料拿出來應用於函式中,再將得到的結果裝回盒子。

接著就可以讓Result成為一個Applicative,首先我們先來看下面的程式碼,下面的程式碼是完全按照Applicative的規定來編寫的,但是存在一個非常有趣的問題

// Applicative
extension Result {
    static func pure(_ value: Value) -> Result {
        return .success(value)
    }
    
    func apply<U>(_ f: Result<(Value) -> U, Err>) -> Result<U, Err> {
        switch f {
        case .success(let fun):
            switch self {
            case .success(let value):
                return .success(fun(value))
            case .failure(let error):
                return .failure(error)
            }
        case .failure(let error):
            return .failure(error)
        }
    }
}

func <*> <T, U, E>(lhs: Result<(T) -> U, E>, rhs: Result<T, E>) -> Result<U, E> {
    return rhs.apply(lhs)
}

func testApplicative() {
    let function: Result<(String) -> String, Never> = .success { $0 + " World!" }
    let value: Result<String, Never> = .success("Hello")
    let result = value.apply(function)
}
複製程式碼

apply方法中,我們依次判斷裝有函式和裝有值的Result是否處於失敗狀態,如果是,那麼直接返回失敗結果,否則繼續進行。

上面的程式碼問題在哪裡呢?試想我們設計Result的初衷:我們希望能夠依次按照User中每個欄位的順序去解析JSON,當遇到其中一個欄位解析失敗時,直接把錯誤資訊封裝在Result返回,並停止後續的解析操作。可以說,這是一種“短路”的邏輯,但是因為Swift並不是一門原生支援惰性求值的語言,而如果我們按照上面的寫法來為Result實現Applicative,程式將會把所有的解析邏輯都執行一遍,這樣就違背了我們的初衷。所以這裡我們就需要對其進行修改:

// Applicative
extension Result {
    static func pure(_ value: Value) -> Result {
        return .success(value)
    }
}

func <*> <T, U, E>(lhs: Result<(T) -> U, E>, rhs: @autoclosure () -> Result<T, E>) -> Result<U, E> {
    switch lhs {
    case .success(let fun):
        switch rhs() {
        case .success(let value):
            return .success(fun(value))
        case .failure(let error):
            return .failure(error)
        }
    case .failure(let error):
        return .failure(error)
    }
}
複製程式碼

為了實現惰性求值,我們把Applicative基於物件導向角度編寫的apply方法去掉,把全部實現都放在了<*>運算子的定義中,然後把運算子右邊原本接收的型別Result<T, E>改成了以Autoclosure形式存在的閉包型別() -> Result<T, E>。這樣,當前一個解析操作失敗時,下面的操作將不會進行,實現了短路的效果。

使用

現在,我們來使用已經實現ApplicativeResult來重寫上面的JSON解析程式碼:

typealias JSONResult<T> = Result<T, JSONError>

func createUser(jsonObj: [String: Any]) -> JSONResult<User> {
    return curry(User.init)
        <^> jsonObj.parse("name")
        <*> jsonObj.parse("age")
        <*> jsonObj.parse("bio")
}
複製程式碼

怎麼樣,現在是不是瞬間感覺程式碼優雅了很多!這裡也多虧了Swift的型別自動推導機制,讓我們少寫了型別的宣告程式碼。

Validation

引入

Validation用於表示某種驗證操作的結果,跟上面提到的Result非常相似,它也是擁有兩種狀態,分別代表驗證成功和驗證失敗。當結果驗證成功,則包含結果資料,當驗證失敗,則包含錯誤資訊。ValidationResult不同的地方在於對錯誤的處理上。

對於Result,當我們進行一系列可能產生錯誤的操作時,若前一個操作產生了錯誤,那麼接下來後面所有的操作將不能夠被執行,程式直接將錯誤再向上返回,這是一種“短路”的邏輯。但是有些時候我們想讓全部操作都能夠被執行,最終再將各個操作中產生的全部錯誤資訊彙總。Validation就是用於解決這種問題。

實現

enum Validation<T, Err: Monoid> {
    case valid(T)
    case invalid(Err)
}
複製程式碼

仔細看Validation的定義,我們會發現其中表示錯誤資訊的泛型Err具有Monoid協議的約束,這就說明Validation中的錯誤資訊是Monoid(單位半群)Monoid在我的上一篇文章《函數語言程式設計 - 有趣的Monoid(單位半群)》中已進行非常詳細的說明,若大家對Monoid的認識比較模糊,可以檢視此文章或者翻閱其他資料,Monoid的概念在這裡就不再展開說明。

下面是Monoid的定義:

infix operator <> : AdditionPrecedence

protocol Semigroup {
    static func <> (lhs: Self, rhs: Self) -> Self
}

protocol Monoid: Semigroup {
    static var empty: Self { get }
}

// 為String實現Monoid
extension String: Semigroup {
    static func <> (lhs: String, rhs: String) -> String {
        return lhs + rhs
    }
}

extension String: Monoid {
    static var empty: String {
        return ""
    }
}

// 為Array實現Monoid
extension Array: Semigroup {
    static func <> (lhs: [Element], rhs: [Element]) -> [Element] {
        return lhs + rhs
    }
}

extension Array: Monoid {
    static var empty: Array<Element> {
        return []
    }
}
複製程式碼

Functor的實現上,ValidationResult並無太大區別,我們可以以Result的角度去理解:

// Functor
extension Validation {
    func map<U>(_ f: (T) -> U) -> Validation<U, Err> {
        switch self {
        case .valid(let value):
            return .valid(f(value))
        case .invalid(let error):
            return .invalid(error)
        }
    }
}

func <^> <T, U, E: Monoid>(lhs: (T) -> U, rhs: Validation<T, E>) -> Validation<U, E> {
    return rhs.map(lhs)
}
複製程式碼

ValidationApplicative的實現上則比起Result大有不同。文章上面提到:Functormap不會對盒子元資訊產生影響,而Applicativeapply需要將雙方盒子的元資訊進行相互作用,以產生新的元資訊。而ValidationResult的區別是在於錯誤資訊的處理,這屬於的元資訊範疇,所以對於map操作ResultValidation無區別,但是apply操作則有所不同。

// Applicative
extension Validation {
    static func pure(_ value: T) -> Validation<T, Err> {
        return .valid(value)
    }
    
    func apply<U>(_ f: Validation<(T) -> U, Err>) -> Validation<U, Err> {
        switch (self, f) {
        case (.valid(let value), .valid(let fun)):
            return .valid(fun(value))
        case (.invalid(let errorA), .invalid(let errorB)):
            return .invalid(errorA <> errorB)
        case (.invalid(let error), _), (_, .invalid(let error)):
            return .invalid(error)
        }
    }
}

func <*> <T, U, E: Monoid>(lhs: Validation<(T) -> U, E>, rhs: Validation<T, E>) -> Validation<U, E> {
    return rhs.apply(lhs)
}
複製程式碼

上面對於apply的實現分了三種情況:

  • 若裝有函式和裝有值的Validation盒子都處於成功狀態,那麼將函式應用於值後的結果封裝到一個成功狀態的Validation中。
  • 若兩個Validation其中有一個處於成功狀態,一個處於失敗狀態,那麼將錯誤資訊封裝到一個失敗狀態的Validation中。
  • 若兩個Validation都處於失敗狀態,因為Validation中的錯誤資訊是Monoid,所以此時將它們的錯誤資訊通過<>組合,再將組合結果封裝到一個失敗狀態的Validation中。

使用

假設現在我們需要完成一個使用者註冊介面的邏輯,使用者需要輸入的內容以及對應的規則限制為:

  • 使用者名稱 | 不能為空
  • 電話號碼 | 長度為11的數字
  • 密碼 | 長度大於6

如果使用者輸入的內容全部合規,點選註冊按鈕則可以向伺服器發起提交請求,若使用者輸入的內容存在不合規,則需要把全部不合規的原因彙總起來並提醒使用者。

首先編寫模型類和按鈕點選的觸發方法:

struct Info {
    let name: String
    let phone: Int
    let password: String
}

func signIn(name: String?, phone: String?, password: String?) {
    // TODO ...
}
複製程式碼

Info模型用於儲存合規的使用者輸入內容,最終作為伺服器請求的引數。

當按鈕點選後,signIn方法將會被呼叫,我們從UITextField中分別取出使用者輸入的內容name、phone、password,傳入,它們的型別都是String?。這個方法剩下的邏輯將會在後面補上。

此時我們就要針對不同的內容編寫規則判斷以及轉換邏輯,這裡我們就可以用到Validation

func validate(name: String?) -> Validation<String, String> {
    guard let name = name, !name.isEmpty else {
        return .invalid(" 使用者名稱不能為空 ")
    }
    return .valid(name)
}

func validate(phone: String?) -> Validation<Int, String> {
    guard let phone = phone, !phone.isEmpty else {
        return .invalid(" 電話號碼不能為空 ")
    }
    guard phone.count == 11, let num = Int(phone) else {
        return .invalid(" 電話號碼格式有誤 ")
    }
    return .valid(num)
}

func validate(password: String?) -> Validation<String, String> {
    guard let password = password, !password.isEmpty else {
        return .invalid(" 密碼不能為空 ")
    }
    guard password.count >= 6 else {
        return .invalid(" 密碼長度需大於6 ")
    }
    return .valid(password)
}

複製程式碼

在這裡,我們用String型別來表示Validation中的錯誤資訊,文章上面已經為String實現了Monoid,它的append操作就是將兩個字串相連線,empty則是一個空字串。

對於每種輸入內容,我們會進行不同的合規判斷,如果輸入不合規,那麼將返回裝有錯誤資訊的失敗Validation,否則將返回裝有結果的成功Validation

現在,我們就可以通過Validation來將使用者輸入的內容進行合規檢查和資料換行了:

let info = curry(Info.init)
    <^> validate(name: name)
    <*> validate(phone: phone)
    <*> validate(password: password)
複製程式碼

info的型別為Validation<Info>,我們將通過它來判斷究竟需要提醒使用者輸入不合規還是直接發起伺服器請求。

最終signIn方法的程式碼為:

func signIn(name: String?, phone: String?, password: String?) {
    let info = curry(Info.init)
        <^> validate(name: name)
        <*> validate(phone: phone)
        <*> validate(password: password)

    switch info {
    case .invalid(let error):
        print("Error: \(error)")
        // TODO: 向使用者展示錯誤資訊(可通過UILabel)
    case .valid(let info):
        print(info)
        // TODO: 發起網路請求
    }
}
複製程式碼

下面就來測試一下這個方法:

signIn(name: "Tangent", phone: "123", password: "123")
複製程式碼

上面的執行最終會在控制檯列印出結果:Error: 密碼長度需大於6 電話號碼格式有誤

除了上文談到的ResultValidationApplicative還有其他很多實現,我們甚至可以將它用於構造響應式的小型工具上。由於篇幅問題,文章在此就不再細講,有興趣的小夥伴可以查閱相關資料進行了解,而說不定未來我也會再出一篇文章進行介紹。

若大家對文章有疑惑,歡迎在評論區留言。

相關文章