函数式编程或称函数程序设计、泛函编程,是一种编程范式,它将电脑运算视为函数运算,并且避免使用程序状态以及易变对象。其中,λ演算为该语言最重要的基础。而且,λ演算的函数可以接受函数当作输入和输出。
上面是维基百科给出来函数式编程的解释,是不是感觉一头雾水?是的,你想了解什么是函数式编程,就算在网上搜索,可能连概念都很难理解。
然而函数式编程的概念在近几年越来越火,从去年的React Hook
,到今年的Vue3
,都大量的使用了函数式编程。
1. 什么是函数式编程
其实函数式编程并不是一个新鲜的概念,早在1958年就已经出现了函数式编程的概念。
现在业界最普遍使用的编程方式即命令式编程(Imperative),命令式编程最大的特点就是,你需要告诉计算机,先做什么,然后做什么,最后做什么。
而函数式编程属于声明式编程(Declarative)的一种,即你不需要告诉计算机具体怎么执行,你只需要告诉它你想要的结果,计算机自己就进行完成。
下面我们来看一个例子:比如你打车要去某个地方:
命令式编程(imperative):详细描述路径
1、下个路口左转
2、下个有红灯的路口右转
3、前进100米
4、在下个路口掉头
5、前进1500米
6、到达目的地出租车停车区
声明式编程(Declarative):只告诉目的地
1、带我到XXX街。
当然,上面的例子并非是说函数式编程就不需要写代码了,代码留给计算机写,其实函数式编程只是一个思想,你需要把实现的过程封装成一个一个的函数。
在函数式编程中有两个非常重要的概念:
- 柯里化(curry)
- 代码组合(compose)
清楚了这两个概念后,你对函数式编程就已经了解了一大半了,至于这两个名词是什么意思,我们后文会慢慢讲解。
2. 函数是一等公民
即函数可以被赋值给变量,并且可以像普通的数据类型一样被存入对象,存入数组,存入其它的数据类型中。
例如:
const add = (a, b) => a + b;
// 存入变量中
const compute = add; // 这里没有任何意义,在实际的开发中不推荐这么写
// 存入数组中
const list: any[] = [];
list.push(compute);
在近几年随着函数式编程逐渐火爆,很多命令式编程语言都开始支持函数式编程,即Java中的Lambda 表达式
,就提供了对函数式编程的支持。
而随着JavaScript的逐渐发展壮大,它已经能很好的支持函数式编程,而且它还拥有一个函数库Ramda
:专门为函数式编程风格而设计,更容易创建函数式 pipeline
、且从不改变用户已有数据。
如果你使用JavaScript,那么恭喜你,你可以在你的代码中使用函数式编程。
3. 纯函数
在函数式编程中,特别强调纯函数这个概念,即:相同的输入,永远会得到相同的输出,而且没有任何可观察的副作用。
const xs = [1, 2, 3, 4, 5];
// 纯的
xs.slice(0, 3);
//=> [1,2,3]
xs.slice(0, 3);
//=> [1,2,3]
xs.slice(0, 3);
//=> [1,2,3]
// 不纯的
xs.splice(0, 3);
//=> [1,2,3]
xs.splice(0, 3);
//=> [4,5]
xs.splice(0, 3);
//=> []
可以看到,splice
这个方法最终改变了数组中的值,而slice
这个方法,不管你调用多次次,原数组中的值不会发生任何变化。
那么这有什么意义呢?
试想一下,如果你的项目是多人同时进行开发,而别人在一个函数里面改变了公共变量的值,而你引入了这个公共变量,发现并不能得到正确的结果,经过很长时间的Debug,你终于发现问题所在:原来是别人在函数中修改了这个变量呀!
由于JavaScript的数组以及对象等都是引用传递,所以即使你使用了ES6
的const
进行声明变量,它们还是能被更改,所以使用纯函数,可以杜绝任何可以观察的副作用。无论你调用该函数多少次,都不会改变公共变量的值。
// 不纯的
const minimum = 21;
const checkAge = function (age) {
return age >= minimum;
};
// 纯的
const checkAge = function (age) {
const minimum = 21;
return age >= minimum;
};
在纯函数中,我们不应该直接使用任何该函数内没有进行声明的变量,因为引入了外部的环境,会增加认知负荷(cognitive load)。
如果这段代码不是你自己编写,而是别人编写,你可能很难找到引入的外部变量是什么,具体有什么用,所以在一个纯函数中,是没有this.xxx
这种调用变量的方式,因为this
指向会增大认知负荷,你可能并不知道这个this
到底指向的是哪儿。
关于this
这是一个令人头疼的话题,即使是资深的程序员,都有可能在this
指向上面翻跟头,虽然ES6引入了箭头函数后,this
指向问题已经被改善,但是还远远不够,this
指向问题,往往会产生一些很难被发觉的BUG。
而在Vue3
中,有一个令人兴奋的消息就是取消了this
,在Vue2
中,我们要使用组件中的状态或者方法,都需要通过this.xxx
进行调用,那么你真的清楚这个this
到底指向哪儿嘛?
而Vue3
中,为了解决这个问题,引入了组合式api,它彻底实现了去this
化。
当然,在函数式编程中,我们并不能做到编写的函数百分百全是纯函数,而且使用函数式编程的最大目的是为了提高效率,降低后期维护难度,如果你为了追求百分百的纯函数而丢失了效率,那这是得不偿失的。
4. 柯里化(curry)
终于来到了函数式编程的一个重要的概念之一:柯里化。
只传递给函数一部分参数来调用它,让它返回一个函数去处理剩下的参数。
const add = (x) => {
return (y) => {
return x + y;
};
};
const increment = add(1);
const addTen = add(10);
increment(2);
// 3
addTen(2);
// 12
这里我们定义了一个 add
函数,它接受一个参数并返回一个新的函数。调用 add
之后,返回的函数就通过闭包的方式记住了 add
的第一个参数。一次性地调用它实在是有点繁琐,好在我们可以使用一个特殊的 curry
帮助函数(helper function)使这类函数的定义和调用更加容易。
在ramda
函数库中就提供了curry
这样一个函数,你需要正常的书写函数,然后将函数传入curry
这个函数中,它就会自动帮你进行curry
化,是不是非常的方便?
所以上面的例子也可以写成这样:
import { curry } from "ramda"; // 引入ramda中的curry函数
const add = (x, y) => { // 正常编写函数
return x + y;
};
const newCurry = curry(add); // 进行柯里化
const increment = newCurry(1);
const addTen = newCurry(10);
increment(2);
// 3
addTen(2);
// 12
柯里化是一个比较难以理解的概念,至于为什么要进行柯里化,其实是为了后面的代码组合。
5. 代码组合(compose)
函数式编程的核心就是柯里化和代码组合,其中柯里化就相当于将函数变成一个个加工机器,而代码组合就相当于将这一个个加工机器组装起来,变成一条流水线。
即你可以将函数看做一个一个的管道,a
即为你需要进行处理的数据,它会进入f3
函数中进行处理后返回m
,然后进入f2
函数中返回n
,最后进入f1
函数中返回b
,而b
就是我们最后需要的值。
而f3,f2,f1
这几个管道的统称,就叫做代码组合(compose)。
比如说有一个数字a,你需要让它加上b,然后用得到的值除以c,再用得到的值减去d,求最后的结果。
用数学来表示即为:(a+b)/c-d
。
我们用我们平时写代码的习惯,即命令式编程:
function compute(a, b, c, d) {
return (a + b) / c - d;
}
接下来我们使用声明式编程来重写这个方法。
import { compose, curry } from "ramda";
// 两数相加
const add = curry((a: number, b: number): number => b + a);
// 两数相减
const divide = curry((a: number, b: number): number => b / a);
注意:在柯里化中,我们需要把要操作的数据放在函数的参数的最后。这也是为什么在函数式编程时,更推荐使用ramda
函数库而不推荐使用lodash
库。因为ramda
库总是将需要操作的值放在参数的最后面。
在上面的两个函数中,参数b才是需要操作的值。
下面,如果a=3,b=4,c=5,d=6
,我们来计算一下函数的结果。
命令式编程:
function compute(a, b, c, d) {
return (a + b) / c - d;
}
compute(3, 4, 5, 6); // -4.6
函数式编程:
import { compose, curry } from "ramda";
// 两数相加
const add = curry((a: number, b: number): number => b + a);
// 两数相除
const divide = curry((a: number, b: number): number => b / a);
const endValue = compose(add(-6), divide(5), add(4));
endValue(3); // -4.6
注意最后的endValue
常量的赋值,这个赋值要从右往左看。如果使用endValue(3)
进行调用该函数,那么含义就是,将3先加上4,然后得出来的结果传入divide
方法中进行相除运算,运算的结果再次传入add
方法中做相减运算,最后将得出来的结果进行返回。
可以看到,可能函数式编程的代码量要多不少,但是!如果说要让你在最后输出的结果中,再次+10
,然后计算出最后的结果呢?
命令式编程:
function compute(a, b, c, d, e) { // 改动
return (a + b) / c - d + e; // 改动
}
console.log(compute(3, 4, 5, 6, 10)); // 5.4
可以看到,为了实现这个需求,我们改变了compute
这个函数的参数和函数的内容。
下面来看看函数式编程:
import { compose, curry } from "ramda";
// 两数相加
const add = curry((a: number, b: number): number => b + a);
// 两数相除
const divide = curry((a: number, b: number): number => b / a);
const endValue = compose(add(10), add(-6), divide(5), add(4)); // 改动
endValue(3); // 5.4
咋一看,好像跟上面没改动的代码没有什么区别,如果我不告诉你改动了哪儿,你真的能找出改动的地方吗?
没错,我们只需要在最后将函数组合起来的时候,再调用一次两数相加的函数,就可以实现需求,改动量大大的减少了。
如果你觉得命令式编程好像也仅仅改了那么一点代码,无所谓改就改了,但是!!!
如果再叫你+10
或者*100
呢?你是不是想解决提出需求的人?而函数式编程实现起来就很轻松,根本不需要进行太多的改动,只需要更换一下组合方式,就能实现需求。
5.1 pointfree
即再定义函数的时候不使用所要处理的值,只合成运算过程。
即上面命令式编程的最后一部,将几个函数合起来的过程,就叫做pointfree
。
6. 最后
其实你搞清楚了上文的内容后,你就可以开始进行函数式编程了,同时函数式编程既然有优点,那么就有缺点,下面我们来看一下函数式编程的优缺点。
6.1 优点:
- 代码简洁,开发快速
- 接近自然语言,易于理解
- 更方便的代码管理
- 易于”并发编程”
- 代码的热升级
6.2 缺点
由于函数式编程会大量的使用闭包,通常性能会比命令式编程要低,但是两者的性能差距你可能根本感觉不到,所以除非你的项目要求极致的性能,不然的话你都可以放心的使用函数式编程。
7. 参考资料
函数式编程指北