再读JS函数式编程指南

JS函数式编程指南在线阅读https://llh911001.gitbooks.io/mostly-adequate-guide-chinese/content/ch1.html

之前看这篇教程时,半懂不懂,尤其是后面第7章Haskell的签名类型,第8、10章Functor,各种Applicative Functors、第9章Monad,以至于后面没有继续阅读下去。于是乎学习了下Scheme以及看了下Haskell趣学指南,有些概念也就不再那么陌生,于是重新拾起本教程,记录下一些自己觉得有用的知识。如果你在学习过程中遇到和我一样的问题,即便是看网上关于Monad,Applicative图解还是不太明白时,强烈建议你看一下Haskell里关于FunctorApplicativeMonad定义的一些法则(law)以及对应的常见的实例(instance)。

几个经典数学定律

1
2
3
4
5
6
7
8
// 结合律(assosiative)
add(add(x, y), z) == add(x, add(y, z))
// 交换律(commutative)
add(x, y) == add(y, x)
// 同一律
add(x, 0) == x
// 分配律(distributive)
multiply(x, add(y, z)) == add(multiply(x, y), multiply(x, z))

这几个经典数学定律是第一章列举的一个小例子提到的,有趣的是这几个定律在Haskell里关于FunctorApplicative FunctorMonad的法则里反复出现,可能背后涉及的范畴论里关于这几个概念的定义确实如此。

pointfree风格

第二章列举了一段垃圾代码,参看上一篇文章由一段垃圾代码想到的λ演算应用。类似f(x) = g(x)写成pointfree风格就是f = g,这样写的好处是节省代码量、参数不固定,增加可维护、可扩展性,缺点可阅读性稍差。

纯函数及其作用

第三章提到了关于纯函数的概念。没有副作用的函数称为纯函数(相当于引用透明,即一代代吗可以替换成它执行的结果,而且是在不改变整个程序的行为前提下替换的)。所谓没有副作用指输出只与输入有关,而且没有任何可观察的副作用。注意很多书上可能不会明确指明后一点。
本章中提到一个关于保留纯粹性而不受外部环境影响的一个办法:使用Object.freeze来冻结变量。当然在ES6中已经可以使用const来定义那些不可变变量。
纯函数的作用:
1.可缓存性(Cacheable)
由于输出只与输入有关,因此可以把结果缓存起来加速计算。在设计模式里对应的是单例模式。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
var memoize = function (f) {
var cache = {};
return function () {
var arg_str = JSON.stringify(arguments); // 可能有函数作为参数等
// 如果cache[arg_str]是包含!!x返回false之类的,比如0,false,undefined等,将会重复计算
cache[arg_str] = cache[arg_str] || f.apply(f, arguments);
return cache[arg_str];
}
}
squareNum = memoize(x => x*x);
squareNum(2); // 第一次执行完毕后会缓存一份在cache中
squareNum(2); // 第二次执行会优先从cache中提取

2.可移植性/自动文档化(Portable/Self-Documenting)
黑人问号??
3.可测试性(Testable)
还是根据纯函数的特性,输出只与输入有关,因此只需要给一个输入,断言一个输出即可。
4.合理性
讲的就是纯函数的引用透明性(referential transparency)带来的好处以及如何利用等式推导(equational reasoning)来分析代码。这让我联想到前几天看到的Facebook的最新开源工具Prepack,基本上就是这种等式推导思想,在编译过程中执行原本需要在运行时计算的过程,并重写js代码来提高运行效率。
并行性
纯函数不需要访问共享内存,不会有副作用而进入竞争态(race condition)。并行代码在服务端 js 环境以及使用了 web worker 的浏览器那里是非常容易实现的,因为它们使用了线程(thread)。不过出于对非纯函数复杂度的考虑,当前主流观点还是避免使用这种并行。(黑人问号??)

柯里化curry和函数组合compose

柯里化
λ演算里是不支持“多个参数”的,如果要支持多参数,可以采用分部调用如λx.λy.(f x y)。在Haskell里参数都是自动柯里化的,例如:

1
2
3
4
5
6
7
>> (/) 2 3
-- 等价于 (2 /) 3 --
-- 等价于 (2 / 3) --
-- 查看(/)类型可以知道(/)函数接受2个参数返回一个值,实际执行时会分部单参数调用执行 --
>> :t (/)
(/) :: Fractional a => a -> a -> a

配合使用loadash的curry实现柯里化,将要操作的数据放在最后一个参数。

1
2
3
4
5
6
var curry = require('loadash').curry;
var match = curry(function (what, str) {
return str.match(what);
});
match(/\d+/, "abcd123") == match(/\d+/)("abcd123")

柯里化所做的工作就是输入一个参数返回一个新函数来处理剩余参数,通过简单传递几个参数便能动态创建实用的新函数。

1
2
3
4
5
6
7
var _ = require('loadash');
// 包裹数组的slice函数使之成为curry函数
var slice = _.curry(function (a, b, list) {
return list.slice(a,b);
});
// 借助slice定义一个`take` curry函数,使的获取list的前n个字符
var take = slice(0);

函数组合

1
2
3
4
5
6
7
var compose = (f, g) => x => f(g(x))
// 在Haskell提供了一个函数组合的语法糖“.”,等同于(f.g)
var add1 = x => x+1;
var div2 = x => x/2;
compose(div2, add1)(3) = 2
// 等价Haskell代码`((/2).(+1)) 3`

组合的一个重要特性:结合律。

1
compose(f, compose(g, h)) == compose(compose(f, g), h)

这使得我们可以任意将一个函数分组拆分开来,然后像搭积木形式组合在一块

Hindley-Milner类型签名

在Haskell里用于编译时检测(compile time checks),当然类型是可以推断的,所以类型签名也不是必须的。另一方面,通过类型签名暴露函数的行为和目的,方便代码阅读。

1
2
3
4
--函数名 :: 接口类型 => 参数... => 输出
(add) :: Num a => a -> a -> a
add a b = (+) a b
--说明函数(+)接收2个Num类型参数,结果返回Num类型值

Functor

本章只说明了几个Functor实例的js实现,但是为什么要这这样实现呢?下面结合Haskell里关于Functor定义加以说明。
1. 什么是Functor?

2. Functor’s law

1
2
3
4
5
// identity
map(id) = id
// composition
compose(map(f), map(g)) == map(compose(f, g))

Applicative Functor

加强版Functor
Applicative’s law

Monad

加强版Applicative
Monad’s law