简明 JavaScript 函数式编程——实践篇
实践经验
在使用柯里化和函数组合的时候,有一些经验可以借鉴一下:
柯里化中把要操作的数据放到最后
因为我们的输出通常是需要操作的数据,这样当我们固定了之前的参数(我们可以称为配置)后,可以变成一个单元函数,直接被函数组合使用,这也是其他的函数式语言遵循的规范:
const split = curry((x, str) => str.split(x));
const join = curry((x, arr) => arr.join(x));
const replaceSpaceWithComma = compose(join(','), split(' '));
const replaceCommaWithDash = compose(join('-'), split(','));
但是如果有些函数没遵循这个约定,我们的函数该如何组合?当然也不是没办法,很多库都提供了占位符的概念,例如 Ramda 提供了一个占位符号(R.__)。假设我们的 split 把 str 放在首位
const split = curry((str, x) => str.split(x));
const replaceSpaceWithComma = compose(join(','), split(R.__, ' '));
函数组合中函数要求单输入
函数组合有个使用要点,就是中间的函数一定是单输入的,这个很好理解,之前也说过了,因为函数的输出都是单个的(数组也只是一个元素)。
函数组合的 Debug
当遇到函数出错的时候怎么办?我们想知道在哪个环节出错了,这时候,我们可以借助一个辅助函数 trace,它会临时输出当前阶段的结果。
const trace = curry((tip, x) => { console.log(tip, x); return x; });
const lastUppder = compose(toUpperCase, head, trace('after reverse'), reverse);
多参考 Ramda
现有的函数式编程工具库很多,Lodash/fp 也提供了,但是不是很推荐使用 Lodash/fp 的函数库,因为它的很多函数把需要处理的参数放在了首位( 例如 map )这不符合我们之前说的最佳实践。
这里推荐使用 Ramda,它应该是目前最符合函数式编程的工具库,它里面的所有函数都是 curry 的,而且需要操作的参数都是放在最后的。上述的 split,join,replace 这些基本的都在 Ramda 中可以直接使用,它一共提供了 200 多个超实用的函数,合理使用可以大大提高你的编程效率(目前我的个人经验来说,我需要的功能它 90%都提供了)。
实战一下
现在你已经基本学会了所有的基础概念,那让我们来实战一下吧!
假设我现在有一套数据:
const data = [
{
name: 'Peter',
sex: 'M',
age: 18,
grade: 99
},
……
]
实现以下几个常用功能:
- 获取所有年龄小于 18 岁的对象,并返回他们的名称和年龄。
- 查找所有男性用户。
- 更新一个指定名称用户的成绩(不影响原数组)。
- 取出成绩最高的 10 名,并返回他们的名称和分数。
我这边提供以下 Ramda 库中的参考函数:
// 对象操作(最后一个参数是对象),均会返回新的对象拷贝
R.prop('name') // 获取对象 name 字段的值
R.propEq('name', '123') // 判断对象 name 字段是否等于‘123’
R.assoc('name', '123') // 更新对象的'name'的值为'123'
R.pick(['a', 'd']); //=> {a: 1, d: 4} // 获取对象某些属性,如果对应属性不存在则不返回
R.pickAll(['a', 'd']); //=> {a: 1, d: 4} // 获取对象某些属性,如果对应属性不存在则返回`key : undefined`
// 数组操作
R.map(func) // 传统的 map 操作
R.filter(func) // 传统的 filter 操作
R.reject(func) // filter 的补集
R.take(n) // 取出数组前 n 个元素
// 比较操作
R.equals(a, b) // 判断 b 是否等于 a
R.gt(2, 1) => true // 判断第一个参数是否大于第二个参数
R.lt(2, 1) => false // 判断第一个参数是否小于第二个参数
// 排序操作
R.sort(func) // 根据某个排序函数排序
R.ascend(func) // 根据 func 转换后的值,生成一个升序比较函数
R.descend(func) // 根据 func 转换后的值,生成一个降序比较函数
// 例子:
R.sort(R.ascend(R.prop('age'))) // 根据 age 进行升序排序
// 必备函数
R.pipe() //compose 的反向,从前往后组合
R.compose() // 从后到前组合
R.curry() // 柯里化
可以想想看,如果是你会如何写这些函数,我这里提供了一个 codepen 的模板 ,可以在这里写你的答案,会自动测试
附录
Hindly Milner 类型签名
之前我们遇到了类似这样的说明:
:: String -> Object
这叫类型签名,最早是在 Hindley-Milner 类型系统中提出来的。
引入它的好处显而易见,短短一行,就能暴露函数的行为和目的,方便我们了解语义。有时候一个函数可能很长,光从代码上很难理解它到底做了什么:
const replace = reg => sub => str => str.replace(reg, sub);
而加上类型签名,我们至少能知道每一步它做了哪些转换,最后输出一个什么样的结果。
例如这个 replace ,通过类型签名我们知道它接受一个 正则表达 式和两个 String,最后会返回一个 String。
// replace :: Regex -> String -> String -> String
const replace = reg => sub => str => str.replace(reg, sub);
这样的连续箭头看起来可能很头疼,其实稍微组合一下可以发现,它就是柯里化的意思:先传一个 正则表达式 会返回一个函数,如果再传一个 String,也会返回函数……直到你输入了最后一个 String,就会返回一个 String 的结果。
// replace :: Regex -> (String -> (String -> String))
同时类型签名可以避免我们在合并函数的时候输入和输出的类型不一致。
例如 join 函数通过类型签名很明显是传入一个 String 的配置,然后就可以将一个 String 数组 转换成 String。
你也能在 Ramda 的官网上看到类似的类型签名:
// join :: String -> [String] -> String
const join = curry((sep, arr) => arr.join(sep));
同样,下面这个函数,它接受一个 String,然后经过 strLen 转换能返回一个 Number。
// strLen :: String -> Number
const strLen = str => str.length();
那我们很容易知道,以上两个函数完全可以组合,因为他们输入和输出类型一致,通过组合我们可以完成一个 String 数组 到 Number 的流水线。
const joinDash = join('-');
const lengthWithDash = compose(strLen, joinDash);
lengthWithDash(['abc', 'def']); // 7
当然还有时候你的函数可能不是接受特定的类型,而只是做一些通用的事情,此时我们可以用 a, b, c…… 这些来替代一些通用类型,例如 map ,它传入一个可以把 a 转换成 b 的函数,然后把a 数组 转换成b 数组。
// map :: (a -> b) -> [a] -> [b]
var map = curry(function(f, xs){
return xs.map(f);
});
// head :: [a] -> a
var head = function(xs){ return xs[0]; }
现在你就学会了类型签名的使用了,我们推荐你写的每个函数都加上类型签名,方便他人,方便自己。
Pointfree 编程风格
我之前提过一下 Pointfree 这种编程风格,它其实就是强调在整个函数编写过程中不出现参数(point),而只是通过函数的组合生成新的函数,实际数据只需要在最后使用函数的时候再传入即可。
// Pointfree 没有出现需要操作的参数
const upperLastItem = compose(toUpperCase, head, reverse);
// 非 Pointfree 出现了需要操作的参数
const upperLastItem = arr => {
const reverseArr = arr.reverse();
const head = reverseArr[0];
return head.toUpperCase();
}
我们在使用函数式编程的时候,其实自然就会形成这种风格,它有什么好处呢?
- 无需考虑参数命名:能减轻不少思维负担,毕竟参数命名也是个很费事的过程。
- 关注点集中:你无需考虑数据,只需要把所有的注意力集中在转换关系上。
- 代码精简:可以省去通过中间变量不断的去传递数据的过程。
- 可读性强:一眼就可以看出来数据的整个的转换关系。
刚开始使用这种编程风格肯定会有很多不适应,但是当你能合理运用这种编程风格后确实会让代码更加简洁和易于理解了。但是凡事无绝对,学了 Pointfree 这种风格并不意味着你要强迫自己做到一个参数都不能出现(比如很多基础函数,他们本身的编写就不是 Pointfree 的),函数式编程也不是所有场合都完全适用的,具体情况具体分析。
记住,你学习各种编程范式的最终目的都是为了让自己的编码更加高效,易懂,同时减少出错概率,不能因为学了一种编程范式,反而导致自己的编程成本大大增加,这就有点本末倒置了。
实战答案
当你写完函数,你可以看一下,你写的函数是不是足够的通用?如果我现在需求由获取男性用户变成获取所有的女性用户,如果我现在要取所有年龄前 10 名的用户,你的函数是否可以很好的复用呢?答案的 codepen 地址 ,我这里的答案也不一定是最优的,只是提供一个思路(就像 update,你可以不用 map,而用 R.update 直接更新数组元素)。
如果在不看答案前,你能写出所有这些操作,那说明你对函数的组合应用的很好了!
总结
前面介绍了很多函数式编程的概念可以总结出函数式编程的优点:
- 代码简洁,开发快速:函数式编程大量使用函数的组合,函数的复用率很高,减少了代码的重复,因此程序比较短,开发速度较快。Paul Graham 在《黑客与画家》一书中写道:同样功能的程序,极端情况下,Lisp 代码的长度可能是 C 代码的二十分之一。
- 接近自然语言,易于理解:函数式编程大量使用声明式代码,基本都是接近自然语言的,加上它没有乱七八糟的循环,判断的嵌套,因此特别易于理解。
- 易于"并发编程":函数式编程没有副作用,所以函数式编程不需要考虑“死锁”(Deadlock),所以根本不存在“锁”线程的问题。
- 更少的出错概率:因为每个函数都很小,而且相同输入永远可以得到相同的输出,因此测试很简单,同时函数式编程强调使用纯函数,没有副作用,因此也很少出现奇怪的 Bug。
因此,如果用一句话来形容函数式编程,应该是:Less code, fewer bugs 。因为写的代码越少,出错的概率就越小。人是最不可靠的,我们应该尽量把工作交给计算机。
一眼看下来好像函数式可以解决所有的问题,但是实际上,函数式编程也不是什么万能的灵丹妙药。正因为函数式编程有以上特点,所以它天生就有以下缺陷:
- 性能:函数式编程相对于指令式编程,性能绝对是一个短板,因为它往往会对一个方法进行过度包装,从而产生上下文切换的性能开销。同时,在 JS 这种非函数式语言中,函数式的方式必然会比直接写语句指令慢(引擎会针对很多指令做特别优化)。就拿原生方法 map 来说,它就要比纯循环语句实现迭代慢 8 倍。
- 资源占用:在 JS 中为了实现对象状态的不可变,往往会创建新的对象,因此,它对垃圾回收(Garbage Collection)所产生的压力远远超过其他编程方式。这在某些场合会产生十分严重的问题。
- 递归陷阱:在函数式编程中,为了实现迭代,通常会采用递归操作,为了减少递归的性能开销,我们往往会把递归写成尾递归形式,以便让解析器进行优化。但是众所周知,JS 是不支持尾递归优化的(虽然 ES6 中将尾递归优化作为了一个规范,但是真正实现的少之又少,传送门)
- ……
因此,在性能要求很严格的场合,函数式编程其实并不是太合适的选择。
但是换种思路想,软件工程界从来就没有停止过所谓的银弹之争,却也从来没诞生过什么真正的银弹,各种编程语言层出不穷,各种框架日新月异,各种编程范式推陈出新,结果谁也没有真正的替代谁。
学习函数式编程真正的意义在于:让你意识到在指令式编程,面向对象编程之外,还有一种全新的编程思路,一种用函数的角度去抽象问题的思路。学习函数式编程能大大丰富你的武器库,不然,当你手中只有一个锤子,你看什么都像钉子。
我们完全可以在日常工作中将函数式编程作为一种辅助手段,在条件允许的前提下,借鉴函数式编程中的思路,例如:
多使用纯函数减少副作用的影响。
使用柯里化增加函数适用率。
使用 Pointfree 编程风格,减少无意义的中间变量,让代码更且可读性。
……
最后,还是那句老生常谈的话:
没有最好的,只有最适合的
希望大家在实际项目中,能根据自己的需求选择最适合自己的编程范式,也希望通过学习这种新的编程范式,可以让我们在二进制的世界行走得更加游刃有余。