函數式編程-將Monad(單子)融入Swift

前言

近期又開始折騰起Haskell,掉進這個深坑恐怕很難再爬上來了。在不斷深刻了解Haskell的各類概念以及使用它們去解決實際問題的時候,我會試想着將這些概念移植到Swift中。函數式編程範式的不少概念在Swift等主打面向對象範式的語言中就像各類設計模式同樣,優雅地幫助咱們構建好整個項目,促使咱們的代碼更加的美觀優雅、安全可靠。
本篇文章爲"函數式編程"系列中的第二篇,我主要說下Monad的一些小概念,以及試圖將Monad融入Swift中來讓其爲咱們的實際工程項目做出貢獻。數據庫

關於Monad、在Swift中實現Monad的一些看法

Monad回顧

在上一篇文章《函數式編程-一篇文章概述Functor(函子)、Monad(單子)、Applicative》中提到過,咱們能夠將一個值用Context(上下文)包裹起來,使得它不只能夠純粹地表示本身,還含有一些額外的信息,Monad我理解爲參與某種計算過程的、被上下文包含起來的值,說到計算過程,就須要說起Monad中一個重要的函數bind(>>=),它的做用,就是進行Monad的計算過程,而且,它讓咱們在計算過程當中只需專一於值的運算,而不須要花另外的精力去處理計算過程當中Context(上下文)的變化轉換。說白了,就是咱們只管值的運算,Context(上下文)就放心交給bind的內部實現去處理吧。
這裏列舉一個Swift中的Optional monad:編程

// 擴展Optional,實現bind方法
extension Optional {
    func bind<O>(_ f: (Wrapped) -> Optional<O>) -> Optional<O> {
        switch self {
        case .none:
            return .none
        case .some(let v):
            return f(v)
        }
    }
}

// 定義bind運算符`>>-`
precedencegroup Bind {
    associativity: left
    higherThan: DefaultPrecedence
}

infix operator >>- : Bind

func >>- <L, R>(lhs: L?, rhs: (L) -> R?) -> R? {
    return lhs.bind(rhs)
}

// 除法,若除數爲0,返回nil
// 方法類型:
// A B C
// (Double) -> (Double) -> Double?
// 用B除以A
func divide(_ num: Double) -> (Double) -> Double? {
    return {
        guard num != 0 else { return nil }
        return $0 / num
    }
}

let ret = divide(2)(16) >>- divide(3) >>- divide(2) // 1.33333333...
// 能夠寫成
// let ret = Optional.some(16) >>- divide(2) >>- divide(3) >>- divide(2)

let ret2 = Optional.some(16) >>- divide(2) >>- divide(0) >>- divide(2) // nil複製代碼

如上,我將Swift中的Optional類型實現爲Monad,因此對於一個可選的數據類型,它的上下文爲數據是否爲空。定義的除法方法divide將兩個數相除,若是除數爲0,則返回nil,用於保證運算的安全。在最後,我進行了兩個連續運算,結果爲retret2,能夠看到,若運算過程當中全部除數都不爲0,則最終返回連續除法運算後的結果,若運算過程當中某除數若是是0,那麼返回的結果就會是nil。
咱們能夠發現,整個運算過程當中咱們只專一於運算的方法以及參與運算的數據,咱們並無花其餘的精力用於檢測除數是否爲0,而且若是爲零則終止運算,返回nil,由於這部分關於上下文的考慮,bind已經爲咱們打理好了。設計模式

Swift中實現Monad

Haskell的類型系統強大,加上其對Monad的高度支持(如提供了do語法糖),咱們能夠很容易地在裏面創造和使用Monad。可是對於Swift語言,因爲其泛型系統以及語法的限制,咱們不可以像Haskell那樣很是優雅地實現Monad,我的總結出有兩點緣由:api

Swift中的協議沒法定義出Monad

Haskell中,Monad的定義爲:數組

class Applicative m => Monad (m :: * -> *) where
  (>>=) :: m a -> (a -> m b) -> m b
  (>>) :: m a -> m b -> m b
  return :: a -> m a
  fail :: String -> m a複製代碼

Haskell的類型類與Swift中的協議相似,咱們能夠看到第一行聲明瞭Monad,而m能夠看作是須要實現Monad的類型,下面就是一些須要實現的函數。事實上,m在上面實際上是一個類型構造器,它的類型爲(* -> *),咱們能夠直接把它當作是Swift中具備一個泛型參數的泛型,相應的,若是是(* -> * -> *)類型的Haskell類型構造器,就類型於Swift中具備兩個泛型參數的泛型,而(*)類型的類型構造器其實就是一個具體的類型。
如今問題來了,對於Haskell,咱們可讓一個非具體的類型(具備一個或多個類型參數的類型構造器)去實現某些類型類,可是對於Swift,若要實現一個協議,咱們必須得提供一個具體的類型。因此在Swift中Monad沒法用協議來實現。安全

protocol Monad {
    associatedtype MT
    func bind(_ f: (MT) -> Self) -> Self
}複製代碼

像上面定義的Monad協議,泛型參數爲MT。這個Monad協議的bind函數是存在問題的,由於它接收一個返回Self類型的函數,而且返回一個Self類型,Self指待如今實現了這個協議的類型,它的泛型參數依舊是保持不變,這並不知足Monad的要求。
(以上爲我的觀點,我的嘗試過是寫不出來,若各位能使用Swift的協議實現了Monad,還望教授)
要在Swift實現Monad,只能由咱們本身保證每一個Monad的實現類中實現了指定的Monad函數。服務器

Swift中沒法優雅地解決Monad中的lambda嵌套

Haskell的do語法可以避免多重的lambda嵌套,從而使得Monad的語法更加優雅可觀:閉包

main = do
  first <- getLine
  second <- getLine
  putStr $ first ++ second複製代碼

對於Swift來講,若咱們在使用Monad的時候涉及到了lambda的嵌套,可能寫起來就會有點憂傷,這裏拿上面提到的Optional monad舉例:app

let one: Int? = 4
let two: Int? = nil
let three: Int? = 7

let result1 = one >>- { o in two >>- { t in o + t } }
let result2 = one >>- { o in two >>- { t in three >>- { th in o * t * th } } }複製代碼

若是Swift支持do語法(不是指異常處理的do語法),那麼這樣子就會簡潔不少:編程語言

let result1 = do {
  o <- one
  t <- two
  th <- three
  return o * t * th
}複製代碼

上面的語法純屬腦補。

因此通常來講應該不會用Swift去實現某些須要多重嵌套lambda的Monad。

Either Monad

在上一篇函數式編程的文章中有提到Result Monad,它表示某個運算可能會存在成功與失敗的狀況,若運算成功,則能獲取到結果值,若運算失敗,則能夠獲取到失敗的緣由(錯誤信息)。使用Either Monad也能夠作這件事。

enum Either<L, R> {
    case left(L)
    case right(R)
}

extension Either {
    static func ret(_ data: R) -> Either<L, R> {
        return .right(data)
    }

    func bind<O>(_ f: (R) -> Either<L, O>) -> Either<L, O> {
        switch self {
        case .left(let l):
            return .left(l)
        case .right(let r):
            return f(r)
        }
    }
}

func >>- <L, R, O> (lhs: Either<L, R>, f: (R) -> Either<L, O>) -> Either<L, O> {
    return lhs.bind(f)
}複製代碼

Either爲枚舉類型,接收兩個泛型參數,它表示在某個狀態時,數據要麼是在left中,要麼是在right中。
因爲Monad要求所實現的類型須要具有一個泛型參數,由於在進行bind操做時可能會對數據類型進行轉換,可是上下文所包含的數據類型是不會改變的,因此這裏咱們將泛型參數L用於上下文所包含的數據類型,R則做爲值的類型。

什麼是上下文所包含的數據類型,什麼是值的類型?
Result monad中有一個數據泛型,表明裏面的數據類型。某次運算成功是,則返回這個類型的數據,若運算失敗,則會返回一個Error類型。咱們能夠把Error類型當作是上下文中包含的數據類型,它在一系列運算中是不可變的,由於Result須要靠它來記錄失敗的信息,若某次運算這個類型忽然變成Int,那麼整個上下文將失去本來的意義。因此,若Either monad做爲Result monad般地工做,咱們必須固定好一個上下文包含的類型,這個類型在一系列的運算中都不會改變,而值的類型是能夠改變的。
運算符>>-的簽名能夠很清晰地看到這種類型約束:接收的Either參數跟後面返回的Either它們的左邊泛型參數都爲L,而右邊泛型參數能夠隨着接收的函數而相應進行改變(R -> O)。

Either monad來做爲Result monad般工做,能夠細化錯誤信息的類型。在Result monad中,錯誤信息都是用Error類型的實例來攜帶,而咱們使用Either monad,能夠根據咱們的須要擬定不一樣的錯誤類型。如咱們有兩個模塊,模塊一表示錯誤的類型爲ErrorOne,模塊二則爲ErrorTwo,咱們就能夠定義兩個Either monad來分別做用於兩個模塊:

typealias EitherOne<T> = Either<ErrorOne, T>
typealias EitherTwo<T> = Either<ErrorTwo, T>複製代碼

從上面的代碼咱們也能夠看出,Swift也能像Haskell同樣對類型構造器(泛型類)進行柯里化操做,意思是咱們在實現一個泛型的時候無需把它須要的全部泛型參數都填滿,能夠只填入其中的若干個。

Writer monad

爲了引入Writer monad,我先拋出一個需求:

  1. 要連續完成一系列任務
  2. 在完成每項任務後,作相關的記錄存檔(如日誌的記錄)
  3. 最終完成全部任務後,獲得最終數據以及整體的記錄檔案

對於這個需求,傳統的作法多是在全局中保存着檔案記錄,每當任務完成後,咱們就響應地修改這個全局檔案,直到全部任務完成。

Writer monad針對這種狀況提供了更加優雅的解決方案,它的Context中保存着檔案記錄,每次咱們對數據進行運算時,咱們不須要再分離一部分精力在檔案的組織和修改上,咱們只需關注其中數據的運算。

Monoid

在繼續深刻Writer monad前,首先說起一個概念: Monoid(單位半羣),它做爲數學的概念有着一些特性,但因爲咱們只是利用它來完成工程項目上的一些邏輯,因此不深刻探討它的數學概念。這裏只是簡單說起一下它的須要知足的特性:

對於一個集合,存在一個二元運算:

  1. 取這個集合中兩個元素進行運算,獲得的結果任然是這個集合中的元素(封閉性)
  2. 這個運算符合結合律
  3. 存在一個元素(單位元),用二元運算將其與另外一個元素進行運算,結果仍然是另外的那個元素。

舉個例子:
對於整數類型,它有一個加法運算,接收兩個整數,而且將兩個整數相加,獲得的無疑也是一個整數,並且咱們也都知道,加法是知足結合律的。對於整數0,任何數與它相加,都是等於原來的數,因此0是這個單位半羣的單位元。

咱們能夠在Swift中定義Monoid的協議:

// 單位半羣
protocol Monoid {
    typealias T = Self
    static var mEmpty: T { get }
    func mAppend(_ next: T) -> T
}複製代碼

其中,mEmpty表示此單位半羣的單位元,mAppend表示相應的二元運算。

上面的例子就能夠在Swift中這樣實現:

struct Sum {
    let num: Int
}

extension Sum: Monoid {
    static var mEmpty: Sum {
        return Sum(num: 0)
    }

    func mAppend(_ next: Sum) -> Sum {
        return Sum(num: num + next.num)
    }
}複製代碼

咱們使用Sum來表示上面例子中的單位半羣。爲何不直接使用Int來實現Monoid,非要對其再包裝多一層呢?由於Int還能夠實現其餘的單位半羣,好比:

struct Product {
    let num: Int
}

extension Product: Monoid {
    static var mEmpty: Product {
        return Product(num: 1)
    }

    func mAppend(_ next: Product) -> Product {
        return Product(num: num * next.num)
    }
}複製代碼

上面這個單位半羣的二元運算就是乘法運算,因此單位元爲11與任何數相乘都爲本來的數。

像布爾類型,能夠引出兩種Monoid:

struct All {
    let bool: Bool
}

extension All: Monoid {
    static var mEmpty: All {
        return All(bool: true)
    }

    func mAppend(_ next: All) -> All {
        return All(bool: bool && next.bool)
    }
}

struct `Any` {
    let bool: Bool
}

extension `Any`: Monoid {
    static var mEmpty: `Any` {
        return `Any`(bool: true)
    }

    func mAppend(_ next: `Any`) -> `Any` {
        return `Any`(bool: bool || next.bool)
    }
}複製代碼

當咱們要判斷一組布爾值是否都爲或者是否存在時,咱們就能夠利用AllAny monoid的特性:

let values = [true, false, true, false]

let result1 = values.map(`Any`.init)
    .reduce(`Any`.mEmpty) { $0.mAppend($1) }.bool // true

let result2 = values.map(All.init)
    .reduce(All.mEmpty) { $0.mAppend($1) }.bool // false複製代碼

實現Writer monad

下面繼續來深刻Writer monad,首先給出它在Swift中的實現:

// Writer
struct Writer<W, T> where W: Monoid {
    let data: T
    let record: W
}

extension Writer{
    static func ret(_ data: T) -> Writer<W, T> {
        return Writer(data: data, record: W.mEmpty)
    }

    func bind<O>(_ f: (T) -> Writer<W, O>) -> Writer<W, O> {
        let newM = f(data)
        let newData = newM.data
        let newW = newM.record
        return Writer<W, O>(data: newData, record: record.mAppend(newW))
    }
}

func >>- <L, R, W>(lhs: Writer<W, L>, rhs: (L) -> Writer<W, R>) -> Writer<W, R> where W: Monoid {
    return lhs.bind(rhs)
}複製代碼

分析下實現的源碼:

  • 泛型參數M要求爲一個Monoid,它就是表示一系列操做用所記錄的檔案的類型;泛型參數T表示被包裹在Writer monad上下文中數據的類型。
  • ret方法做用跟Haskell中的return函數同樣,將一個值包裹在某個Monad的最小上下文中。對於Writer monad,咱們在ret函數中返回一個Writer,其中數據爲傳入的參數,記錄檔案則爲指定Monoid的單位元,這樣就能將一個數據包裹進Writer monad的最小上下文中。
  • bind的實現中,咱們能夠看到,裏面會自動將兩個Writer monad的記錄進行mAppend操做,返回一個包裹着新數據和新記錄的Writer monad。前面關於Monad概念中提到:Monadbind操做是讓咱們專一於數據的運算,對於上下文的處理,咱們無需關心,這個是自動進行的。因此對於Writer monadbind操做自動幫咱們把記錄mAppend起來,咱們也無需把其餘的精力花在對記錄的操做中。
  • 爲了讓代碼更加美觀優雅,我定義了運算符>>-,它在Haskell中的樣子是>>=

Demo

接下來咱們用Writer monad作一個小Demo。
就像前面引入的需求同樣,這裏我打算作一個關於Double的一系列簡單運算,包括加、減、乘、除,每次運算後,咱們須要用字符串來對運算的過程進行記錄,好比x * 3會記錄成乘以3,並將以前的記錄與新運算建立的記錄進行合併,最終一系列運算完成後,咱們會獲得運算結果以及整個運算過程的記錄。

首先咱們先讓String實現Monoid

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

    func mAppend(_ next: String) -> String {
        return self + next
    }
}複製代碼

這個針對String的單位半羣,其二元運算爲+,表示將兩個字符串拼接起來,因此其單位元爲一個空字符串。

這裏我爲DoubleWriter monad類型擬一個別名,記錄類型爲String,數據類型爲Double

typealias MWriter = Writer<String, Double>複製代碼

而後定義加、減、乘、除運算:

func add(_ num: Double) -> (Double) -> MWriter {
    return { MWriter(data: $0 + num, record: "加上\(num) ") }
}

func subtract(_ num: Double) -> (Double) -> MWriter {
    return { MWriter(data: $0 - num, record: "減去\(num) ") }
}

func multiply(_ num: Double) -> (Double) -> MWriter {
    return { MWriter(data: $0 * num, record: "乘以\(num) ") }
}

func divide(_ num: Double) -> (Double) -> MWriter {
    return { MWriter(data: $0 / num, record: "除以\(num) ") }
}複製代碼

注意,這些函數都是高階函數,若他們的形參跟返回值當作是(a) -> (b) -> c,則這些函數的做用是進行運算b X a (X爲加、減、乘、除運算),而後把結果c返回。
每次運算後都會記錄這次運算的相關信息,好比加上X除以X

如今咱們來測試一下:

let resultW = MWriter.ret(1) >>- add(3) >>- multiply(5) >>- subtract(6) >>- divide(7)

let resultD = resultW.data // 2.0

let resultRecord = resultW.record // "加上3.0 乘以5.0 減去6.0 除以7.0"複製代碼

可見,咱們獲得了屢次連續運算後的結果2.0,還有被自動拼接起來的記錄"加上3.0 乘以5.0 減去6.0 除以7.0"


固然,Writer monad的玩法還有不少種,好比如今再出一個需求:
規定成績分數爲整數,分數大於等於60分能拿到及格,現須要統計一個班同窗的成績,而且判斷:整個班的同窗是否都及格/是否存在至少一個同窗及格。
咱們能夠利用上面已經介紹的All monoid以及Any monoid來建立分數的Writer monad

typealias ScoreWriter = Writer<All, Int>

func append(_ score: Int) -> (Int) -> ScoreWriter {
    return { ScoreWriter(data: $0 + score, record: All(bool: score >= 60)) }
}

let allScores = [45, 60, 98, 77, 65, 59, 60, 86, 93]

let result = allScores.reduce(ScoreWriter.ret(0)) { $0 >>- append($1) }
let resultBool = result.record.bool // false
let resultScore = result.data // 643複製代碼

append爲一個高階函數,咱們能夠把它當作是一個接收兩個參數的函數的柯里化形式,咱們會判斷傳入的第一個參數是否知足合格的要求,而且將兩個參數相加,建立一個ScoreWriter
在這個ScoreWriter monad中,我將記錄類型設爲All,因此返回的結果中,布爾類型代表整個班同窗們的成績是否都及格了。傳入的數據中顯然有低於60的,因此最終的布爾結果爲false

若是你把All改爲Any,最終的布爾結果就爲true,代表整個班至少有一位同窗是及格的:

// 這裏我用反單引號(`)將Any包裹住,由於Any爲Swift中的關鍵字
typealias ScoreWriter = Writer<`Any`, Int>

func append(_ score: Int) -> (Int) -> ScoreWriter {
    return { ScoreWriter(data: $0 + score, record: `Any`(bool: score >= 60)) }
}

let allScores = [45, 60, 98, 77, 65, 59, 60, 86, 93]

let result = allScores.reduce(ScoreWriter.ret(0)) { $0 >>- append($1) }
let resultBool = result.record.bool // true複製代碼

State Monad

對於Swift來講,因爲其不是純函數式編程語言,因此也不會存在數據不可變的狀況,咱們能夠隨時用var建立變量。而Haskell因爲其特性規定了全部數據都是不可變的,因此對於某些涉及狀態的運算而言,須要另闢蹊徑。State monad(狀態Monad)能夠用來解決這種需求。不過在Swift中,若是你不喜歡老是定義一些變量,或者說出現變量混雜的狀況,你也可使用這種方法。

State MonadHaskelldo語法中能發揮強勁的做用,可是在Swift中如要實現這種效果,咱們須要編寫多重的lambda嵌套(閉包嵌套),這樣寫既麻煩,可觀性又不高,與函數式編程簡潔的特色相違背。因此,這裏只探討用>>- (bind)鏈式調用State monad的相關狀況。
State Monad有必定的難度,而且它可能不多會在平常的工程項目中被須要到,可是經過對它的學習把玩,能夠很好地提升咱們對函數式編程的熟悉掌握。如下對Stata Monad的講解較爲粗略,以供瞭解,如有興趣,可查閱有關State Monad的更多信息。

首先咱們來實現State Monad:

struct State<S, T> {
    let f: (S) -> (T, S)
}

extension State {
    static func ret(_ data: T) -> State<S, T> {
        return State { s in (data, s) }
    }

    func bind<O>(_ function: @escaping (T) -> State<S, O>) -> State<S, O> {
        let funct = f
        return State<S, O> { s in
            let (oldData, oldState) = funct(s)
            return function(oldData).f(oldState)
        }
    }
}

func >>- <S, T, O>(lhs: State<S, T>, f: @escaping (T) -> State<S, O>) -> State<S, O> {
    return lhs.bind(f)
}複製代碼

若是某項操做須要狀態,咱們不想在做用域中建立一個新的變量來記錄某些臨時的狀態,並隨着操做的進行而改變,能夠在每次進行操做完後把新的狀態返回,這樣,咱們下一次操做就能夠利用新的狀態進行,以此類推。
State具備一個成員,它的類型爲一個函數,這個函數能夠看做是一種操做,接受某個狀態做爲參數,返回操做後的結果數據以及一個新的狀態組成的元組。State Monadret函數接收一個任意類型的值,返回State自己。由於ret函數是將數據包裹在Monad的最小上下文中,因此此時State中的成員函數不對數據和狀態作任何的處理。
對於bind函數,它的做用就是自動幫咱們將上一個操做返回的新狀態傳入到下一個操做中,因此咱們調用bind函數進行一系列操做的時候,咱們無需花精力於狀態的傳遞。

下面我舉一個使用State Monad的小例子,這個例子可能比較牽強,若是之後我想到更好的可能會從新修改下這部分。

現假設如今服務器提供API,經過用戶的ID能夠獲取到用戶的名字,咱們想要獲取連續ID的n個用戶的名字,並將這些名字包裹在一個數組中。
咱們首先來模擬服務器數據庫的數據以及API函數:

struct Person {
    let id: Int
    let name: String
}

let data = ["Hello", "My", "Name", "Is", "Tangent", "Haha"].enumerated().map(Person.init)

func fetchNameWith(id: Int) -> String? {
    return data.filter { $0.id == id }.first?.name
}複製代碼

服務器提供fetchNameWith方法用於經過ID獲取到指定用戶的名字,若不存在此ID的用戶,則返回nil

咱們定義用於解決此問題的State Monad類型,並建立請求函數:

typealias MState = State<Int, [String]>

func fetch(names: [String]) -> MState {
    return MState { id in
        guard let name = fetchNameWith(id: id) else { return (names, id) }
        return (names + [name], id + 1)
    }
}複製代碼

fetch函數的類型爲([String]) -> MState,參數爲前面所請求到的全部用戶名字所組成的數組,返回的MState中操做函數作的事情有兩件:

  1. 調用服務器API,獲取到指定的用戶名字,並把用戶的名字添加到數組中
  2. 將本來的用戶ID加一,以便在後面的操做中可以獲取到下一個用戶的名字

這裏需考慮一個邊界狀況,當服務器找不到指定的用戶時,返回nil,咱們的操做函數就不作任何的事情了,返回原來的數據,代表後面咱們再怎麼繼續調用請求函數,結果都不會改變。

下面來測試一下:

let fetchFunc = MState.ret([]) >>- fetch >>- fetch >>- fetch >>- fetch
let namesAndNextID = fetchFunc.f(1)
let names = namesAndNextID.0 // ["My", "Name", "Is", "Tangent"]
let nextID = namesAndNextID.1 // 5複製代碼

咱們一開始把一個空的數組包裹到State Monad的最小上下文中,而後進行了四次請求,bind自動完成有關狀態的操做,最後返回結果State Monad,這個結果State Monad中的操做函數已是將前面全部的操做合併了,因此咱們能夠直接調用此操做函數,最中獲取咱們想要的數據。

總結

本文概述了有關Monad(單子)的概念,探討了在Swift中實現Monad的一些缺陷點,並引入了Either MonadWriter MonadState Monad,嘗試在Swift中去實現它們。雖然在平時的開發中咱們通常都使用面向對象的編程範式,可是靈活地在你的代碼中融入一些函數式編程的概念及思想將會產生意想不到效果。不過坑有點深😐

相關文章
相關標籤/搜索