函数式入门笔记1
引用
- JavaScript 函数式编程(一)
- JavaScript 函数式编程(二)
- JavaScript 函数式编程(三)
- 怎么学习函数式编程?
- 学习 Computational Trinitarianism 应按照怎样的学习路径
- Learn You a Haskell for Great Good!
- Composing Software: The Book
- 类型论驿站写作计划
纯函数
纯函数的定义是,对于相同的输入,永远会得到相同的输出,而且没有任何可观察的副作用,也不依赖外部环境的状态
函数的柯里化
传递给函数一部分参数来调用它,让它返回一个函数去处理剩下的参数。
事实上柯里化是一种“预加载”函数的方法,通过传递较少的参数,得到一个已经记住了这些参数的新函数,某种意义上讲,这是一种对参数的“缓存”,是一种非常高效的编写函数的方法:
1 | import { curry } from "lodash"; |
函数组合
1 | //两个函数的组合 |
Point Free
细心的话你可能会注意到,之前的代码中我们总是喜欢把一些对象自带的方法转化成纯函数:
1 | var map = (f, arr) => arr.map(f); |
Point Free
这种模式现在还暂且没有中文的翻译,有兴趣的话可以看看这里的英文解释:
Tacit_programming
阮一峰 Pointfree 编程风格指南
比如:
1 | //这不Point free |
1 | var toUpperCase = (word) => word.toUpperCase(); |
这种风格能够帮助我们减少不必要的命名,让代码保持简洁和通用。当然,为了在一些函数中写出 Point Free 的风格,在代码的其它地方必然是不那么 Point Free 的,这个地方需要自己取舍。
例一
比如,读取对象的 role 属性,不要直接写成 obj.role,而是要把这个操作封装成函数。
1 | var prop = (p, obj) => obj[p]; |
上面代码中,data
是传入的值,getWorkers
是处理这个值的函数。定义getWorkers
的时候,完全没有提到data
,这就是 Pointfree
。
简单说,Pointfree
就是运算过程抽象化,处理一个值,但是不提到这个值。这样做有很多好处,它能够让代码更清晰和简练,更符合语义,更容易复用,测试也变得轻而易举。
例二
1 | var str = "Lorem ipsum dolor sit amet consectetur adipiscing elit"; |
上面是一个字符串,请问其中最长的单词有多少个字符?
先定义一些基本运算。
1 | // 以空格分割单词 |
然后,把基本运算合成为一个函数
1 |
|
可以看到,整个运算由三个步骤构成,每个步骤都有语义化的名称,非常的清晰。这就是 Pointfree
风格的优势。
Ramda
提供了很多现成的方法,可以直接使用这些方法,省得自己定义一些常用函数
1 | // 上面代码的另一种写法 |
声明式与命令式代码
命令式代码的意思就是,我们通过编写一条又一条指令去让计算机执行一些动作,这其中一般都会涉及到很多繁杂的细节。
而声明式就要优雅很多了,我们通过写表达式的方式来声明我们想干什么,而不是通过一步一步的指示。
1 | //命令式 |
容器、Functor
容器为函数式编程里普通的变量、对象、函数提供了一层极其强大的外衣,赋予了它们一些很惊艳的特性,就好像 Tony Stark 的钢铁外衣,Dva 的机甲,明日香的 2 号机一样。
1 | var Container = function (x) { |
我们调用 Container.of
把东西装进容器里之后,由于这一层外壳的阻挡,普通的函数就对他们不再起作用了,所以我们需要加一个接口来让外部的函数也能作用到容器里面的值:
1 | Container.prototype.map = function (f) { |
我们可以这样使用它:
1 | Container.of(3) |
没错!我们仅花了 7 行代码就实现了很炫的 『链式调用』 ,这也是我们的第一个 Functor
。
Functor
(函子)是实现了 map
并遵守一些特定规则的容器类型。
也就是说,如果我们要将普通函数应用到一个被容器包裹的值,那么我们首先需要定义一个叫 Functor
的数据类型,在这个数据类型中需要定义如何使用 map
来应用这个普通函数。
把东西装进一个容器,只留出一个接口 map
给容器外的函数,这么做有什么好处呢?
本质上,Functor
是一个对于函数调用的抽象,我们赋予容器自己去调用函数的能力。当 map
一个函数时,我们让容器自己来运行这个函数,这样容器就可以自由地选择何时何地如何操作这个函数,以致于拥有惰性求值、错误处理、异步调用等等非常牛掰的特性。
Maybe
举个例子,我们现在为 map
函数添加一个检查空值的特性,这个新的容器我们称之为 Maybe
(原型来自于 Haskell):
1 | var Maybe = function (x) { |
有了柯里化这个强大的工具,我们可以这样写:
1 | import _ from "lodash"; |
错误处理、Either
如果你对 Promise 熟悉的话应该还记得,Promise 是可以调用 catch 来集中处理错误的:
1 | doSomething() |
对于函数式编程我们也可以做同样的操作,如果运行正确,那么就返回正确的结果;如果错误,就返回一个用于描述错误的结果。这个概念在 Haskell
中称之为 Either
类,Left
和 Right
是它的两个子类。我们用 JS 来实现一下:
1 | // 这里是一样的=。= |
Left
和 Right
唯一的区别就在于 map
方法的实现,Right.map
的行为和我们之前提到的 map
函数一样。但是 Left.map
就很不同了:它不会对容器做任何事情,只是很简单地把这个容器拿进来又扔出去。这个特性意味着,Left
可以用来传递一个错误消息。
1 | var getAge = (user) => (user.age ? Right.of(user.age) : Left.of("ERROR!")); |
是的,Left
可以让调用链中任意一环的错误立刻返回到调用链的尾部,这给我们错误处理带来了很大的方便,再也不用一层又一层的 try/catch
。
Left
和 Right
是 Either
类的两个子类,事实上 Either
并不只是用来做错误处理的,它表示了逻辑或,范畴学里的 Coproduct
。但这些超出了我们的讨论范围。
惰性求值 IO, URL
下面我们的程序要走出象牙塔,去接触外面“肮脏”的世界了,在这个世界里,很多事情都是有副作用的或者依赖于外部环境的,比如下面这样:
1 | function readLocalStorage() { |
额……好吧……好像确实没什么卵用……我们只是(像大多数拖延症晚期患者那样)把讨厌做的事情暂时搁置了而已。为了能彻底解决这些讨厌的事情,我们需要一个叫 IO
的新的 Functor
:
IO
1 | import _ from "lodash"; |
IO
跟前面那几个 Functor
不同的地方在于,它的 __value
是一个函数。它把不纯的操作(比如 IO、网络请求、DOM)包裹到一个函数内,从而延迟这个操作的执行。所以我们认为,IO
包含的是被包裹的操作的返回值。
1 | var io_document = new IO((_) => window.document); |
注意我们这里虽然感觉上返回了一个实际的值 IO(document.title)
,但事实上只是一个对象:{ __value: [Function] }
,它并没有执行,而是简单地把我们想要的操作存了起来,只有当我们在真的需要这个值得时候,IO
才会真的开始求值,这个特性我们称之为『惰性求值』。
URL
1 | import _ from "lodash"; |
Monad
如果你对 Promise
这种规范有了解的话,应该记得 Promise
里一个很惊艳的特性:
1 | doSomething() |
对于 Promise
的一个回调函数来说,它既可以直接返回一个值,也可以返回一个新的 Promise
,但对于他们后续的回调函数来说,这二者都是等价的,这就很巧妙地解决了 nodejs
里被诟病已久的嵌套地狱。
事实上,Promise
就是一种 Monad
,是的,可能你天天要写一大堆 Promise
,可直到现在才知道天天用的这个东西竟然是个听起来很高大上的函数式概念。
下面我们来实际实现一个 Monad
,如果你不想看的话,只要记住 『Promise
就是一种 Monad
』 这句话然后直接跳过这一章就好了。
我们来写一个函数 cat
,这个函数的作用和 Linux
命令行下的 cat
一样,读取一个文件,然后打出这个文件的内容,这里 IO
的实现请参考上一篇文章:
1 | import fs from "fs"; |
由于这里涉及到两个 IO
:读取文件和打印,所以最后结果就是我们得到了两层 IO
,想要运行它,只能调用:
cat("file").__value().__value();
很尴尬对吧,如果我们涉及到 100 个 IO
操作,那么难道要连续写 100 个 __value()
吗?
当然不能这样不优雅,我们来实现一个 join
方法,它的作用就是剥开一层 Functor
,把里面的东西暴露给我们:
1 | var join = (x) => x.join(); |
有了 join
方法之后,就稍微优雅那么一点儿了:
1 | var cat = compose(join, map(print), readFile); |
join
方法可以把 Functor
拍平(flatten),我们一般把具有这种能力的 Functor
称之为 Monad
。
这里只是非常简单地移除了一层 Functor
的包装,但作为优雅的程序员,我们不可能总是在 map
之后手动调用 join
来剥离多余的包装,否则代码会长得像这样:
var doSomething = compose(join, map(f), join, map(g), join, map(h));
所以我们需要一个叫 chain
的方法来实现我们期望的链式调用,它会在调用 map
之后自动调用 join
来去除多余的包装,这也是 Monad
的一大特性:
1 | var chain = _.curry((f, functor) => functor.chain(f)); |
你可能看出来了,chain
不就类似 Promise
中的 then
吗?是的,它们行为上确实是一致的(then
会稍微多一些逻辑,它会记录嵌套的层数以及区别 Promise
和普通返回值),Promise
也确实是一种函数式的思想。