前言
近期又開始折騰起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,用于保證運算的安全。在最后,我進行了兩個連續運算,結果為ret
和ret2
,可以看到,若運算過程中所有除數都不為0,則最終返回連續除法運算后的結果,若運算過程中某除數如果是0,那么返回的結果就會是nil。
我們可以發現,整個運算過程中我們只專注于運算的方法以及參與運算的數據,我們并沒有花其他的精力用于檢測除數是否為0,并且如果為零則終止運算,返回nil
,因為這部分關于上下文的考慮,bind
已經為我們打理好了。
Swift中實現Monad
Haskell
的類型系統強大,加上其對Monad的高度支持(如提供了do
語法糖),我們可以很容易地在里面創造和使用Monad。但是對于Swift
語言,由于其泛型系統以及語法的限制,我們不能夠像Haskell
那樣非常優雅地實現Monad,個人總結出有兩點原因:
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
舉例:
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
,我先拋出一個需求:
- 要連續完成一系列任務
- 在完成每項任務后,做相關的記錄存檔(如日志的記錄)
- 最終完成所有任務后,得到最終數據以及總體的記錄檔案
對于這個需求,傳統的做法可能是在全局中保存著檔案記錄,每當任務完成后,我們就響應地修改這個全局檔案,直到所有任務完成。
Writer monad
針對這種情況提供了更加優雅的解決方案,它的Context
中保存著檔案記錄,每次我們對數據進行運算時,我們不需要再分離一部分精力在檔案的組織和修改上,我們只需關注其中數據的運算。
Monoid
在繼續深入Writer monad
前,首先提及一個概念: Monoid(單位半群)
,它作為數學的概念有著一些特性,但由于我們只是利用它來完成工程項目上的一些邏輯,所以不深入探討它的數學概念。這里只是簡單提及一下它的需要滿足的特性:
對于一個集合,存在一個二元運算:
- 取這個集合中兩個元素進行運算,得到的結果任然是這個集合中的元素(封閉性)
- 這個運算符合結合律
- 存在一個元素(單位元),用二元運算將其與另一個元素進行運算,結果仍然是另外的那個元素。
舉個例子:
對于整數類型,它有一個加法運算,接收兩個整數,并且將兩個整數相加,得到的無疑也是一個整數,而且我們也都知道,加法是滿足結合律的。對于整數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)
}
}
上面這個單位半群的二元運算就是乘法運算,所以單位元為1
,1
與任何數相乘都為原本的數。
像布爾類型,可以引出兩種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)
}
}
當我們要判斷一組布爾值是否都為真
或者是否存在真
時,我們就可以利用All
或Any
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
概念中提到:Monad
的bind
操作是讓我們專注于數據的運算,對于上下文的處理,我們無需關心,這個是自動進行的。所以對于Writer monad
,bind
操作自動幫我們把記錄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
的單位半群,其二元運算為+
,表示將兩個字符串拼接起來,所以其單位元為一個空字符串。
這里我為Double
的Writer 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 Monad
在Haskell
的do
語法中能發揮強勁的作用,但是在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 Monad
的ret
函數接收一個任意類型的值,返回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
中操作函數做的事情有兩件:
- 調用服務器API,獲取到指定的用戶名字,并把用戶的名字添加到數組中
- 將原本的用戶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 Monad
、Writer Monad
、State Monad
,嘗試在Swift中去實現它們。雖然在平時的開發中我們一般都使用面向對象的編程范式,但是靈活地在你的代碼中融入一些函數式編程的概念及思想將會產生意想不到效果。
不過坑有點深??