读书笔记-函数式编程指北
《函数式编程指北》读书笔记
中文版
函数是一等公民
和其他对象一样,可以像对待任何其他数据类型一样对待它们——把它们存在数组里,当作参数传递,赋值给变量…等等
非常小心 this
值,特别是以一等公民的方式调用 this…
纯函数
相同的输入,永远会得到相同的输出,而且没有任何可观察的副作用
纯函数不依赖外部环境,从而降低了认知负荷;函数能够做到自给自足。更可以用 Object.freeze
把对象变成不可变的,从而保留纯粹性,因为状态不会有变化
副作用是计算结果的过程中,系统状态的一种变化,或者与外部世界进行的可观察的交互
函数式编程的哲学就是假定副作用是造成不正当行为的主要原因
纯函数的优点:
- 可缓存性(Cacheable):总能够根据输入来做缓存,一种典型方式是 memoize 技术
- 可移植性/自文档化(Portable/Self-Documenting):依赖明确(所有的依赖都需用函数的参数传递),纯函数对其依赖必须要诚实,并且参数化能够使应用更加灵活,与环境无关
- 可测试性(Testable):只需要简单地给函数一个输入,然后断言输出就好了
- 合理性(Reasonable):引用透明性(referential transparency,即如果一段代码可以替换成它执行所得的结果,而且是在不改变整个程序行为的前提下替换的)
- 并行代码:因为纯函数根本不需要访问共享的内存,也不会因副作用而进入竞争态,所以我们可以并行运行任意纯函数
可以通过延迟执行的方式把不纯的函数转换为纯函数
柯里化
只传递给函数一部分参数来调用它,让它返回一个函数去处理剩下的参数
lodash 的 curry 方法
1 | _.curry(func, [arity=func.length]) |
对于传进去的 func
的参数,需要把要操作的数据放到最后一个参数
表明了一种“预加载”函数的能力,通过传递一到两个参数调用函数,就能得到一个记住了这些参数的新函数
只传给函数一部分参数通常也叫作局部调用,能够大量减少样板文件代码
curry 函数是纯函数
代码组合
compose:将两个函数结合起来,得到一个新函数
1 | var compose = function(f, g) { |
g
先于 f
执行,从右向左的数据流
结合律
如何为 compose
的调用分组不重要,结果是一样的
1 | var associative = compose(f, compose(g, h)) == compose(compose(f, g), h); // true |
因此,可以给 compose
传递很多参数而不必自己手动分组,它自己会决定如何分组(这个时候 compose
就不是上面写的那个只接受两个参数的了,而要写个多个的)
因为有了结合律,所以任何一个函数分组都可以被拆开来,然后再以它们自己的组合方式打包在一起
如何组合的最佳实践:让组合可重用
pointfree
函数无须提及将要操作的数据是什么样的
1 | // 非 pointfree,因为提到了数据:word |
一等公民的函数+柯里化+组合协作起来,通过管道把数据在接受单个参数的函数间传递
pointfree 模式能够帮助我们减少不必要的命名,让代码保持简洁和通用
在组合时注意如果想组合类似 map
这种接受两个参数的函数,需要先对 map
局部调用使得其返回接受一个参数的函数
在 debug 过程中可以用 trace
函数来追踪代码执行情况,在想要观察的特定点(管道的某一环)观察数据
范畴学
把很多数学分支(集合论、类型论、群论、逻辑学等…)中相同的概念加以形式化
…范畴:有以下这些组件(component)的搜集(collection)就构成了一个范畴
- 对象的搜集
- 态射的搜集
- 态射的组合
- indentity 这个独特的态射
对应到编程上:
对象的搜集:数据类型,例如 String Boolean Number Object 等。通常我们把数据类型视作所有可能的值得一个集合,这样可以用集合论处理类型
态射的搜集:态射是标准的、普通的纯函数
态射的组合:本章介绍的组合
compose
。在范畴学中任何组合都适用结合律indentity 这个独特的态射
1
var id = function(x) { return x; };
有了
id
函数,就可以有单位律(和实数的单位元一样)1
compose(id, f) = compose(f, id) == f;
示例应用
声明式代码:指明“做什么“,而不是”怎么做“
命令式 | 声明式 |
---|---|
for 循环,硬编码处理迭代累加blabla… | map |
硬编码顺序调用函数 | compose 组合函数,不指定执行顺序(天然地适合并行运算),为潜在的代码更新提供支持 |
map 的组合律
1 | var law = compose(map(f), map(g)) == map(compose(f, g)); |
可以用来重构代码(就像数学推导公式一样)
类型签名
Hindley-Milner 系统
函数写成 a -> b
的样子,表示接受值和返回值
连续指向
1 | // map :: (a -> b) -> [a] -> [b] |
最简单的理解:map 接受两个参数,第一个是 f
,类型为 a -> b
;第二个是 xs
,类型为 [a]
;返回一个数组 [b]
深入理解:从右向左加括号 (a -> b) -> ([a] -> [b])
,外层是输入一个 a -> b
返回 [a] -> [b]
,输入一个函数返回一个函数;内层是 [a] -> [b]
,输入数组返回数组
仅仅根据类型签名,就可以理解函数做了什么事情,比如 map 其实就是在每个 a
上调用一次 f
(a -> b
),从而得到一个 [b]
另一个例子
1 | // reduce :: (b -> a -> b) -> b -> [a] -> b |
理解:
- 第一个参数
f
:(b -> a -> b)
,接受一个b
一个a
,返回b
- 那么这里的 a b 来源于哪里呢,其实就在第二三个参数
b
和[a]
,这里的 b a 会传给 f 作为参数 - reduce 的最终返回结果是一个
b
,其实就是f :: b -> a -> b
的这个输出b
- 对照 reduce 的真实定义 来理解:
f
即要对数组中每个值执行的函数,它的参数实际上是:b
为accumulator
即累计回调的值,a
为currentValue
即正在处理的当前数组元素x
是第一次调用f
时的初始值xs
是数组,不用讲了…
Parametricity
wiki (暂时还没理解这个概念啥意思)
但反正这个特性表明,函数将会以一种统一的行为作用于所有的类型
也就是说,在看着类型签名进行函数功能的推断时,要保证函数对每一个可能的类型操作必须保持统一,这样就能缩小可能的功能的范围
例如,// head :: [a] -> a
,a 可能是任意类型,所以函数在对 a
一无所知的情况下,可能对 [a]
进行的操作,只可能是返回数组中的某个元素
自由定理
根据(多态)类型签名,可以无需知道函数具体实现的情况就对函数表达式进行推导得到一些普适的重写规则,从而可以重构代码,提高代码效率
类型约束
也可以把类型约束为一个特定的接口(interface)
容器
Indentity
1 | var Container = function(x) { |
functor:实现了
map
函数并遵守一些特定规则的容器类型
把值装进一个容器,而且只能使用 map
来处理它——是抽象,对函数运用的抽象(当 map
一个函数的时候,我们请求容器来运行这个函数)
Maybe
另一种 functor:处理空值
1 | var Maybe = function(x) { |
这样我们在使用 map
时代码就不会报错了,而是会得到 Maybe(null)
Maybe
最常用在那些可能会无法成功返回结果的函数中,通过(人工或自动)抛出 Maybe(null)
可以实现失败时立即切断执行
more pointfree…
any_functor_at_all
即为我们上面创建的 Contianer
、Maybe
等任意 functor,这样就把 functor 的点记式 map 代理成了 pointfree 的 map 了
1 | // map :: Functor f => (a -> b) -> f a -> f b |
一个帮助函数 maybe
,用于返回一个自定义的值然后还能继续执行后面的代码
1 | // maybe :: b -> (a -> b) -> Maybe a -> b |
Either
1 | var Left = function(x) { |
1 | Right.of('rain').map(function(str){ return 'b'+str; }); |
Right
就像个 Container
,而 Left
无视了我们的 map 请求
Left
可以用来返回错误,让程序短路,比 Maybe(null)
好的是,我们可以在可能错误的地方给 Left
传错误信息了
Left
和 Right
是 Either
抽象类型的两个子类,一个(伪)类型签名可以这样写:
1 | // Either(String, Number) |
lift:一个函数在调用的时候,如果被 map
包裹了,那么它就会从一个非 functor 函数转换为一个 functor 函数,我们把这个过程叫做 lift。一个比较好的实践是,仍按照普通方式去写(操作普通数据类型的)函数,在必要的时候再通过 lift 变为合适的容器去操作容器类型,这样能得到更简单、重用性更高的函数,它们能够随需求而变,兼容任意 functor
帮助函数 either
1 | // either :: (a -> c) -> (b -> c) -> Either a b -> c |
IO
1 | var IO = function(f) { |
IO
把非纯动作捕获到包裹函数里,目的是延迟这个非纯动作。我们可以认为 IO
包含的是被包裹的执行动作的返回值,而不是包裹函数本身(在 of 函数中可以看出,IO(function() { return x; })
仅仅是为了延迟执行,其实我们得到的是 IO(x)
通过链式调用 map
,其实是把传给 map
的函数们压入一个“运行栈”,直到最终调用者调用时才会运行这些(不纯的)函数们
异步任务
Task
一点理论
1 | // identity 同一律 |
在范畴学中,functor 接受一个范畴的对象和态射(morphism),然后把它们映射(map)到另一个范畴里去
可以把范畴想象成一个有着多个对象的网络,对象之间靠态射连接。那么 functor 可以把一个范畴映射到另外一个,而且不会破坏原有的网络
Monad
pointed functor
pointed functor 是实现了
of
方法的 functor
关键是用 of
把任意值丢到容器里然后就可以开始到处使用 map
的能力,像这样:
1 | any_pointed_functor.of(any_value).map(f) |
of
方法不是用来避免 new
关键字的,而是用来把值放到默认最小化上下文(default minimal context)中的(我们希望容器类型的任意值都能发生 lift
,然后像所有的 functor 那样再 map
出去)
monad
monad 是可以变扁(flatten)的 pointed functor
因为在连续的 functor 传递中,值会被包成 functor(value) -> 传给下一个函数 -> 而下一个函数是普通函数需要用 map 包一下以处理 functor -> 处理完就变成了 functor(functor(value)) -> 那么再下一个函数就需要包两层 map…
例如,
1 | // safeProp :: Key -> {Key: a} -> Maybe a |
为了解决这个一层一层洋葱一样的嵌套,可以在 functor 上定义一个 join
方法,这个 functor 就成为了一个 monad
1 | Maybe.prototype.join = function() { |
用 monad 魔法改造上面的 firstAddressStreet 例子,在每次得到嵌套 Maybe
后加一个 join
把它变平
1 | // join :: Monad m => m (m a) -> m a |
chain
上面的例子中包含一种模式:我们总是在紧跟着 map
的后面调用 join
。我们可以把这个行为抽象到一个叫做 chain
的函数中
1 | // chain :: Monad m => (a -> m b) -> m a -> m b |
chain
也叫作 >>=
(读作 bind)或者 flatMap
,重构一下上面的例子
1 | // map/join |
因为 chain
可以轻松嵌套多个作用,因此我们就能以一种纯函数式的方式来表示 序列(sequence)和 变量赋值(variable assignment)。像这样:
1 | Maybe.of(null).chain(safeProp('address')).chain(safeProp('street')); |
使用插入式的 chain
,避免了 compose
时还需要几个帮助函数
记住:返回的如果是“普通”值就用 map
,如果是 functor
就用 chain
理论
1 | // 结合律 |
1 | // 三角同一律 |
Applicative Functor
ap
就是这样一种函数,能够把一个 functor 的函数值应用到另一个 functor 的值上。
比如我们想让两个 Container
的值相加,
1 | // 这样是行不通的, 因为 2 和 3 都藏在 Container 里 |
ap 版本
1 | Container.prototype.ap = function(other_container) { |
applicative functor 是实现了
ap
方法的 pointed functor
一个特性:map
一个 f
等价于 ap
一个值为 f
的 functor
1 | F.of(x).map(f) == F.of(f).ap(F.of(x)) |
applicative functor 可以使几个(独立的不需要依赖顺序的)函数同时执行
1 | // Http.get :: String -> Task Error HTML |
lift
1 | var liftA2 = curry(function(f, functor1, functor2) { |
让那些小代码块发生 lift,成为 applicative functor 中的一员
一个实际用例:
1 | // checkEmail :: User -> Either String Email |
衍生函数
of/ap
= map
1 | X.prototype.map = function(f) { |
chain
-> functor applicative(但会失去 ap
并行的能力)
1 | X.prototype.map = function(f) { |
定律
applicative functor 是组合关闭(closed under composition)的,意味着
ap
永远不会改变容器类型同一律(identity)
1
2A.of(id).ap(v) == v
map(id) == id同态(homomorphism)
1
A.of(f).ap(A.of(x)) == A.of(f(x))
同态就是一个能够保持结构的映射,实际上,functor 就是一个在不同范畴间的同态,因为 functor 在经过映射之后保持了原始范畴的结构
互换(interchange)
1
v.ap(A.of(x)) == A.of(function(f) { return f(x) }).ap(v)
组合(composition)
1
A.of(compose).ap(u).ap(v).ap(w) == u.ap(v.ap(w));