函数式编程-将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,用于保证运算的安全。在最后,我进行了两个连续运算,结果为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中去实现它们。虽然在平时的开发中我们一般都使用面向对象的编程范式,但是灵活地在你的代码中融入一些函数式编程的概念及思想将会产生意想不到效果。
不过坑有点深😐
作者:Tangentw
链接:https://www.jianshu.com/p/b35e6e634df4
來源:简书
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。