函数式编程 - 一篇文章概述Functor(函子)、Monad(单子)、Applicative

前言

初步深入函数式编程是在寒假的时候,搞了一本Haskell的书,啃了没多久就因为我突然的项目任务被搁置了,不过在学习的时候也是各种看不懂,里面的概念略微抽象,再加上当时没有适当地实战敲Demo,导致没过多久脑袋就全空了。庆幸的是,Swift是一门高度兼容函数式编程范式的语言,而我又是一只喜欢敲Swift的程序Dog,在后来我使用Swift编码时,有意识或无意识地套用函数式编程范式的一些概念,也渐渐加深我对函数式编程的理解。这篇文章是我对自己所掌握的函数式编程的一个小总结,主要探讨的是函数式编程中的几个概念: FunctorApplicativeMonad以及它们在Swift中的表现形式。由于本人能力有限,一些概念上的不严谨、编码上的不全面希望大家多包涵,欢迎留下各位宝贵的意见或问题。

本文为纯概念讲述,后期或许会有函数式编程实战的文章推出(我有空写再说吧)

概念

Context

在编码时,我们会遇到各种数据类型,基础的数据类型我们称作,当然这并不是指编程语言中的基本数据类型,比如说整形1它可以称作一个值,一个结构体struct Person { let name: String; let age: Int }的实例也可以成为一个值,那么何为Context(上下文)呢,我们可以将它理解为对值的一个包装,通过这层包装,我们可以得知值此时所处在的一个状态。在Haskell中,这个包装就是typeclass(类型类),而在Swift中,魔性的enum(枚举)可以充当这个角色,一个例子,就是Swift中的Optional(可选类型),它的定义如下(相关继承或协议关系在这里不标出):

Optional<Wrapped> {
    case none
    case some(Wrapped)

Optional有两种状态,一种是空状态none,也就是和平时我们传入的nil相等价,一种是存在值的状态,泛型Wrapped指代被包入这层上下文的值的类型。通过这个例子,我们可以很直观地理解Context:描述值在某一阶段的状态。当然,在平时开发中,我们会见到各种Context,比如Either:

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

它代表在某个阶段值可能在left或者right中存在。
在一些函数式响应式编程框架如ReactiveCocoaRxSwift中,Context无处不在:RACSignalObservable,甚至是Swift的基本类型Array(数组)它本身也可以看作是一个Context。可见,只要你接触了函数式编程,Context即会接触。

这里,我特别说下这个Context:Result,因为在后面对其他概念以及实战的讲述中我都会以它为基础:

enum Result<T> {
    case success(T)
    case failure(MyError)
}

Result上下文存在两种状态,一种是成功的状态,当处于这个状态,Result就会持有一个特定类型的值,另外一种状态是失败状态,在这个状态中,你可以获取到一个错误的实例(这个实例可以是你自己拟定的)。这么这个Context有什么用呢?想象一下,你正在进行一项网络操作,获取到的数据是无法确定的,你或许能如你所愿,从服务器中获取到你期望的值,但是也有可能此时服务器发生一些未知的错误,或者网络延时,又或是一些不可抗力的影响,那么,此时你得到的将会是一个错误的表示,如HTTP Code 500...而Result可以在这种情况下引入来表示你在网络操作中获取到的最终结果,是成功还是失败。除了网络请求,诸如数据库操作、数据解析等等,Result都可以引入来进行更明确的标示。

何为Functor、Applicative、Monad?

你可以把FunctorApplicativeMonad想象成Swift中的Protocol(协议),它们可以为某种数据结构的抽象,而这种数据接口正是刚刚我在上面提到的Context,要将某个Context实现成FunctorApplicativeMonad,你必须实现其中特定的函数,所以,要了解什么是FunctorApplicativeMonad,你需要知道它们定义了那些协议函数。接下来我会一一讲解。

Functor

我们对一个值的运算操作使用的是函数,比如我要对一个整形的值进行翻倍操作,我们可以定义一个函数:

func double(_ value: Int) -> Int {
    return 2 * value
}

然后就可以拿这个函数对特定的值进行操作:

let a = 2
let b = double(a)

好,问题来了,如果此时这个值被包在一个Context中呢?
一个函数只能作用于它声明好的特定类型的值,运算整形的函数不能用来运算一个非整形的Context,所以这时,我们引入了Functor。它要做的,就是使一个只能运算值的函数用来运算一个包有这个值类型的Context,最后返回的一个包有运算结果的Context,为此,我们要实现map这个函数(在Haskell中为fmap),它的伪代码是这样的:
Context(结果值) = map(Context(初始值), 运算函数)

现在我们拿Result来实现一下:

extension Result {
    func map<O>(_ mapper: (T) -> O) -> Result<O> {
        switch self {
        case .failure(let error):
            return .failure(error)
        case .success(let value):
            return .success(mapper(value))
        }
    }
}

我们可以看到,首先我们对Result进行模式匹配,当此时状态是失败的话,我们也直接返回失败,并把错误的实例传递下去,如果状态是成功的,我们就对初始的值进行运算,最后返回包有结果值的成功状态。
为了后面表达式简便,我在这里定义了map的运算符<^>

precedencegroup ChainingPrecedence {
    associativity: left
    higherThan: TernaryPrecedence
}

// Functor
infix operator <^> : ChainingPrecedence

// For Result
func <^><T, O>(lhs: (T) -> O, rhs: Result<T>) -> Result<O> {
    return rhs.map(lhs)
}

我们现在就可以测试一下:

let a: Result<Int> = .success(2)
let b = double <^> a

在上面我提到,Swift的数组也可以当成是Context,它是作为一个包有多个值的状态存在。想必在日常开发中我们经常也用到了Swift数组中的map函数吧:

let arrA = [1, 2, 3, 4, 5]
let arrB = arrA.map(double)

RxSwift中我们也经常使用map

let ob = Observable.just(1).map(double)

Applicative

Applicative其实就是高级的Functor,我们可以调出上面Functormap伪代码:
Context(结果值) = map(Context(初始值), 运算函数)
在函数式编程中,函数也可以作为一个值来看待,若此时这个函数也是被一个Context包裹的,单纯的map是不能接受包裹着函数的Context,所以我们引入了Applicative
Context(结果值) = apply(Context(初始值), Context(运算函数))

我们将Result实现Applicative

extension Result {
    func apply<O>(_ mapper: Result<(T) -> O>) -> Result<O> {
        switch mapper {
        case .failure(let error):
            return .failure(error)
        case .success(let function):
            return self.map(function)
        }
    }
}

// Applicative
infix operator <*> : ChainingPrecedence

// For Result
func <*><T, O>(lhs: Result<(T) -> O>, rhs: Result<T>) -> Result<O> {
    return rhs.apply(lhs)
}

使用:

let function: Result<(Int) -> Int> = .success(double)
let a: Result<Int> = .success(2)
let b = function <*> a

Applicative在日常开发中其实用的不多,很多时候我们并不会将一个函数塞进一个Context上,但是如果你用了一些略为高阶的函数时,它强劲的能力就能在此时表现出来,这里举一个略为晦涩的例子,你可以花点时间搞懂它:
这个例子的思路是来自源Swift的函数式JSON解析库Argo的基本用法,若大家有兴趣可以阅读下Argo的源码: thoughtbot/Argo

假设现在我定义了一个函数,它能够接受一个Any的JSON Object,以及一个值在JSON中对应的Key(键)作为参数,返回一个从JSON数据中解析出来的结果,由于这个结果是不确定的,可能JSON中不存在此键对应的值,所以我们用Result来包装它,这个函数的签名为:

func parse<T>(jsonObject: Any, key: String) -> Result<T>

当解析成功时,返回的Result处于成功状态,当解析失败时,返回的Result处于失败状态并携带错误的实体,我们能够通过错误实体得知解析失败的原因。

现在我们有一个结构体,它里面有多个成员,它实现了默认的构造器:

struct Person {
    let name: String
    let age: Int
    let from: String
}

我们自己可以编写一套函数柯里化的库,这个库能够对多参数的函数进行柯里化,你也可以从Github中下载: thoughtbot/Curry
比如,我们有一个函数,它的基本签名是: func haha(a: Int, b: Int, c: Int) -> Int,通过函数柯里化我们可以将其转化为(Int) -> (Int) -> (Int) -> Int类型的函数。
我们此时将Person的构造器进行函数柯里化:curry(Person.init),此时我们得到的是类型为(String) -> (Int) -> (String) -> Person的值。
现在奇幻的魔法来了,我定义一个将JSON解析成Person的函数:

func parseJSONToPerson(json: Any) -> Result<Person> {
    return curry(Person.init)
        <^> parse(jsonObject: json, key: "name")
        <*> parse(jsonObject: json, key: "age")
        <*> parse(jsonObject: json, key: "from")
}

通过这个函数,我能够将一个JSON数据解析成Person的实例,以一个Result的包装返回,如果解析失败,Result处理失败状态会携带一个错误的实例。

这个函数为什么可以这么写呢,我们来分解一下:
首先通过函数的柯里化我们得到了类型为(String) -> (Int) -> (String) -> Person的值,它也是一个函数,然后经过了<^>map的操作,map的右边是一个解析了name返回的Result,它的类型为Result<String>,map将函数(String) -> (Int) -> (String) -> Person应用于Result<String>,此时我们得到的是返回的结果(Int) -> (String) -> Person的Result包装:Result<(Int) -> (String) -> Person>(因为已经消费掉了一个参数),此时,这个函数就被一个Context包裹住了,后面我们不能再用map去将这个函数应用在接下来解析出来的数据了,所以这是我们就借助于Applicative<*>,接下来看第二个参数,parse函数将JSON解析返回了类型为Result<Int>的结果,我们通过<*>Result<(Int) -> (String) -> Person>的函数取出来,应用于Result<Int>,就得到了类型为Result<(String) -> Person>的结果。以此类推,最终我们就获取到了经JSON解析后的结果Result<Person>
Applicative强大的能力能够让代码变得如此优雅,这就是函数式编程的魅力之所在。

Monad

Monad中文称为单子,网上看到挺多人被Monad的概念所搞晕,其实它也是基于上面所讲述的概念而来的。对于使用过函数式响应式编程框架(Rx系列[RxSwift、RxJava]、ReactiveCocoa)的人来说,可能不知道Monad是什么,但是在实战中肯定用过,它所要求实现的函数说白了就是flatMap

let ob = Observable.just(1).flatMap { num in
    Observable.just("The number is \(num)")
}

有很多人喜欢用降维来形容flatMap的能力,但是,它能做的,不止如此。
Monad需要实现的函数我们可以称为bind,在Haskell中它使用符号>>=,在Swift中我们可以定义运算符>>-来表示bind函数,或者直接叫做flatMap。我们先来看看他的伪代码:
首先我们定义一个函数,他的作用是将一个值进行包装,这里标示出这个函数的签名:
function :: 值A -> Context(值B)(值A与值B的类型可相同亦可不同)
我们的bind函数就可以这么写了:
Context(结果值) = Context(初始值) >>- function
这里我们实现一下ResultMonad

extension Result {
    func flatMap<O>(_ mapper: (T) -> Result<O>) -> Result<O> {
        switch self {
        case .failure(let error):
            return .failure(error)
        case .success(let value):
            return mapper(value)
        }
    }
}

// Monad
infix operator >>- : ChainingPrecedence

// For Result
func >>-<T, O>(lhs: Result<T>, rhs: (T) -> Result<O>) -> Result<O> {
    return lhs.flatMap(rhs)
}

Monad的定义很简单,但是Monad究竟能帮我们解决什么问题呢?它要怎么使用呢?别急,通过以下这个例子,你就能对Monad有更深一层的理解:
假设现在我有一系列的操作:

  1. 通过特定条件进行本地数据库的查询,找出相关的数据
  2. 利用上面从数据库得到的数据作为参数,向服务器发起请求,获取响应数据
  3. 将从网络获取到的原始数据转换成JSON数据
  4. 将JSON数据进行解析,返回最终解析完成的有特定类型的实体

对以上操作的分析,我们能得知以上每一个操作它的最终结果都具有不确定性,意思就是说我们无法保证操作百分百完成,能成功返回我们想要的数据,所以我们很容易就会想到利用上面已经定义的Context:Reuslt将获取到的结果进行包裹,若获取结果成功,Result将携带结果值处于成功状态,若获取结果失败,Result将携带错误的信息处于失败状态。
现在,我们针对以上每种操作进行函数定义:

// A代表从数据库查找数据的条件的类型
// B代表期望数据库返回结果的类型
func fetchFromDatabase(conditions: A) -> Result<B> { ... }

// B类型作为网络请求的参数类型发起网络请求
// 获取到的数据为C类型,可能是原始字符串或者是二进制
func requestNetwork(parameters: B) -> Result<C> { ... }

// 将获取到的原始数据类型转换成JSON数据
func dataToJSON(data: C) -> Result<JSON> { ... }

// 将JSON进行解析输出实体
func parse(json: JSON) -> Result<Entity> { ... }

现在我们假设所有的操作都是在同一条线程中进行的(非UI线程),如果我们只是纯粹地用基本的方法去调用这些函数,我们可能要这么来:

var entityResult: Entity?
if case .success(let b) = fetchFromDatabase(conditions: XXX) {
    if case .success(let c) = requestNetwork(parameters: b) {
        if case .success(let json) = dataToJSON(data: c) {
            if case .success(let entity) = parse(json: json) {
                entityResult = entity
            }
        }
    }
}

这代码写起来也好看起来也好真的是一把辛酸泪啊,而且,这里还有一个缺陷,就是我们无法从中获取到错误的信息,如果我们还想要获取到错误的信息,必须再编写多一大串代码了。

此时,Monad出场了:

let entityResult = fetchFromDatabase(conditions: XXX) >>- requestNetwork >>- dataToJSON >>- parse

吓到了吧,只需一行代码,即可将所有要做的事情连串起来了,并且,最终我们获取到的是经Result包装的数据,若在操作的过程中发生错误,错误的信息也记录在里面了。
这就是Monad的威力

当然,我们可以继续对上面的操作进行优化,比如说现在我需要在网络请求的函数中加多一个参数,表示请求的URL,我们可以这样来定义这个网络请求函数:

// B类型作为网络请求的参数类型发起网络请求
// 获取到的数据为C类型,可能是原始字符串或者是二进制
func requestNetwork(urlString: String) -> (B) -> Result<C> {
    return { parameters in
        return { ... }
    }
}

调用的时候我们只需要这样调用:

let entityResult = fetchFromDatabase(conditions: XXX) >>- requestNetwork(urlString: "XXX.com/XXX/XXX") >>- dataToJSON >>- parse

这主要是高阶函数的使用技巧。

个人对Monad作用的总结有两部分:

  1. 对一系列针对值与Context的操作进行链式结合,代码极其优雅,清晰明了。
  2. 将值与Context之间的转换、Context内部进行的操作对外屏蔽,像上面我用原始的方式进行操作,我们需要手动地分析Context的情况,手动地针对不同的Context状态进行相应的操作,而如果我们使用Monad,整一流程下来我们什么都不需要做,坐享其成,取得最终的结果。

总结

Swift是一门高度适配函数式编程范式的语言,你可以在里面到处都能找到函数式编程思想的身影,通过上面对FunctorAppliactiveMonad相关概念的讲述,在巩固我对函数式编程的知识外,希望也能让你对函数式编程的理解有帮助,若文章有概念不严谨的地方或者错误,望见谅,也希望能够向我提出。
谢谢阅读。

参考链接

阮一峰的网络日志 - 图解 Monad

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,013评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,205评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,370评论 0 342
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,168评论 1 278
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,153评论 5 371
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,954评论 1 283
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,271评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,916评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,382评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,877评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,989评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,624评论 4 322
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,209评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,199评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,418评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,401评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,700评论 2 345

推荐阅读更多精彩内容