JavaScript学习(九) —— 函数式编程
百科定义: 函数式编程(Functional Programming)是种编程范式,它将电脑运算视为函数的计算。函数编程语言最重要的基础是λ演算(lambda calculus),而且λ演算的函数可以接受函数当作输入(参数)和输出(返回值)。

写在前头:本人之前并没有过多了解过函数式编程,也很少发散思维和总结。直到最近开始写技术文章,写到函数式编程这个主题时,阅览了大量大神写的文章后才恍然,发现实战项目中很多模块用到了函数式编程。写这篇博文的过程也是自己系统学习这种编程范式的过程,用自己所学的知识尽力把函数式编程涉及到的知识点都说明白。
一、纯函数
要弄清楚函数式编程的具体实现和实际使用方法,需要先弄清楚纯函数的概念。我们一直说的函数式编程中的函数指的是数学中的函数,类似我们高中学过的关于自变量x的函数f(x)、g(x)还有复合函数f(gx)等概念。这样的数学函数可算是纯函数的一种。
纯函数的特性:
- 对于相同的输入,永远会得到相同的输出
- 没有任何可观察的副作用
- 不依赖外部环境的状态。
JS中某些对数组的一些方法(函数)就有纯和非纯的分别:
纯函数:
var arr = [1,2,3,4];
console.log(arr.slice(0,2)); // [1, 2]
console.log(arr); // [1, 2, 3, 4]
slice
是纯函数,它没有改变原来的数组arr
。
非纯函数:
var arr = [1,2,3,4];
console.log(arr.splice(0,2)); // [1, 2]
console.log(arr); // [3, 4]
splice
改变了原来的数组arr
,是非纯的函数。
以上面两个例子对比:
- 我们的目的是想通过调用一个函数后,获得一个截取原数组
arr
的结果,但并不想改变原数组arr。 - 非纯函数
splice
随便就把外部变量或状态(原数组arr
)修改,会导致很多预期之外的结果,这样的副作用不是我们所期望的。 - 在函数式编程范式中,我们当然希望使用纯函数
slice
,它不会修改原数组,没有副作用。
看一个函数会依赖外部环境的状态的示例:
例1:
var price = 69.99;
function discount(){
return price * .8;
}
console.log(discount()); // 55.992
console.log(price); // 69.99
例1的discount
方法引用了外部状态price
,如果修改了外部状态price
,会轻松影响discount
方法返回的值,对于大型应用程序会增强系统复杂性和维护的难度。
解决这个问题的方法是把价格当作参数传入函数:
例2:
var price = 69.99;
function discount(p){
return p * .8;
}
console.log(discount(price)); // 55.992
console.log(price); // 69.99
例1例2虽然结果都一样,区别在于是否把价格当作参数传入,函数内部是否有形参接收。形参是按值传递,发生了一次复制行为,返回的结果不影响函数外的状态。例1属于非纯函数,例2是纯函数。
使用纯函数的目的:
本人认为纯函数可以理解为最小功能的单元,可比喻为拼插玩具(乐高)的基本单位。我们所实现的一些复杂需求就由这些纯函数组合而成。后面的函数组合将详细说明。
二、函数柯里化(Currying)
之前《闭包》中提到过柯里化。假设有两个函数A和B,当函数A作为函数B的返回值被缓存在一个变量中,函数A引用了函数B作用域中的变量,其展现形态就是一个闭包。这种特性可作为函数式编程的一种体现。
举例: 下面是一个计算折扣价格的例子:
// 缓存8折优惠后的价格
var discount80 = discount(.8);
// 缓存9折优惠后的价格
var discount90 = discount(.9);
// 优惠价格Currying
function discount(percent){
return function(price){
return price * percent;
}
}
console.log(discount80(69.99)); // 55.992
console.log(discount90(69.99)); // 62.991
柯里化对于函数式编程的意义在于,它可以缓存调用函数先传一部分参数的结果,然后再调用时传入另一部分参数,第一次调用缓存可以理解为对某个最终结果的预加载。第二次调用缓存的函数引用得到最终结果。
三、函数组合
函数式编程的另一种体现是函数中的某个参数也是一个函数。写过很多年代码的人肯定听过函数是第一等公民这句话。它是指函数可以赋值给一个变量(函数表达式),也可以当成一个参数传递给另一个函数。还有在另一个函数体内被当作结果返回,就是刚才说的函数柯里化。
当一个函数被当成参数传递给另一个函数时,就如文章开头提到的高中数学的 f(g(x))
这种形态。
用JS代码表示:
function fn(func){
return func() + 20;
}
console.log(fn(function(){ // 30
return 10;
}));
这个例子无非是在一个函数体内调用了传进来的参数函数,没有太多意义。我们想介绍的是函数组合。
下面是一个简单的函数组合实现,只接收两个参数的函数,从右到左顺序组合函数。
function compose(f, g) {
return function(x) {
return f(g(x));
};
};
它是怎么用的:
function add10(num){
return num + 10;
}
function add20(num){
return num + 20;
}
var res = compose(add10, add20);
console.log(res(5)); // 35
将 add10
、add20
两个纯函数组合,并将返回函数缓存起来。
四、一个实际例子
介绍完纯函数、函数柯里化 和 函数组合,如果把这些结合起来能做什么?
现在有个这样的需求:有个折扣柯里化方法,有个保留价格小数位数的柯里化方法,将这两个方法组合计算出一个打了9折后保留2位小数的函数。随便代入一个价格,计算出最终结果。
// 缓存9折优惠后的价格
var discount90 = discount(.9);
// 缓存保留2位小数后的价格
var toFixed2 = toFixed(2);
// 优惠价格Currying
function discount(percent){
return function(price){
return price * percent;
}
}
// 保留小数Currying
function toFixed(num){
return function(price){
return price.toFixed(num);
}
}
// 函数组合
function compose(f, g) {
return function(x) {
return f(g(x));
};
};
// 缓存一个打了9折且保留2位小数的组合函数
var final_price = compose(toFixed2,discount90);
// 调用得到最后结果
console.log(final_price(69.99)); // 62.99
- 优惠价格函数
discount
和 保留小数位数toFixed
两个方法用到了函数柯里化 - 缓存两个柯里化的返回结果的方法
discount90
和toFixed2
是两个 纯函数 compose
组合了discount90
和toFixed2
函数
五、一些通用函数库
underscore、ramda、lodash 等JS库都支持了函数式编程的范式,它们提供的API其实都大同小异,我们可以在项目中引入这些第三方库,写某些场景的业务代码时可适当使用函数式编程范式。
JS给数组实例提供了 reverse
和 sort
方法,但是这两个方法会改变原数组,它们是非纯函数,所以如果想先后调用这两个方法,需要先实现一个复制原数组的方法。
// 定义数组
var arr = [1,3,2];
// 定义升序排序函数
var diffAsc = function(a, b) { return a - b; };
// 缓存升序排序方法
var sortAsc = sort(diffAsc);
// 函数组合
function compose(f,g,h) {
return function() {
return function(x) {
return f(g(h(x)));
};
};
};
// 定义复制数组方法
function copy(arr){
return [].concat(arr);
}
// 定义数组排序柯里化方法
function sort(type){
return function(arr){
return arr.sort(type);
}
}
// 定义数组翻转方法
function reverse(arr){
return arr.reverse();
}
// 缓存组合函数,注意后面还有个()
var res = compose(reverse,sortAsc,copy)();
console.log(res(arr)); // [3, 2, 1]
console.log(arr); // [1, 3, 2] 没有改变原数组
这里 compose
方法只是一种简单实现,它的扩张性不好,只固定三层嵌套函数。
好吧,我们直接看 ramda.js
是怎么实现的:
// 引入 ramda 依赖
var R = require('ramda');
// 定义数组
var arr = [1, 3, 2];
// 定义升序排序函数
var diffAsc = function(a, b) { return a - b; };
// 缓存组合了 R.reverse 和 R.sort 的函数
var res = R.compose(R.reverse,R.sort(diffAsc));
console.log(res(arr)); // [3, 2, 1]
console.log(arr); // [1, 3, 2] 没有改变原数组
注意:
R.compose
是从右往左执行函数组合。R.reverse
和R.sort
是纯函数,它们复制了原数组,返回了一个新数组。- ramdajs 中很多方法类似
R.sort
这样。它们本身都是支持柯里化的,即R.sort( diffAsc, arr )
和R.sort(diffAsc)(arr)
是等效的,所以在使用组合函数R.compose
时,里面的参数可以这样传R.sort(diffAsc)
最后总结:
函数式编程诞生的年头已经不短了,我在写业务代码实现一些比较复杂的功能时,不知不觉地使用了这种范式,但没有完全严格遵守规范(实现过程中还有很多不足),通过写这篇文章也能总结出自己的不足。
对于前端开发来说,JS能很强地支持函数式编程范式,我们无比幸运。充分掌握这个技能并把它用在对的场景是我们应该努力追寻的。