函数式编程-入门篇

函数式编程或称函数程序设计、泛函编程,是一种编程范式,它将电脑运算视为函数运算,并且避免使用程序状态以及易变对象。其中,λ演算为该语言最重要的基础。而且,λ演算的函数可以接受函数当作输入和输出。

上面是维基百科给出来函数式编程的解释,是不是感觉一头雾水?是的,你想了解什么是函数式编程,就算在网上搜索,可能连概念都很难理解。

然而函数式编程的概念在近几年越来越火,从去年的React Hook,到今年的Vue3,都大量的使用了函数式编程。

1. 什么是函数式编程

其实函数式编程并不是一个新鲜的概念,早在1958年就已经出现了函数式编程的概念。

现在业界最普遍使用的编程方式即命令式编程(Imperative),命令式编程最大的特点就是,你需要告诉计算机,先做什么,然后做什么,最后做什么。

而函数式编程属于声明式编程(Declarative)的一种,即你不需要告诉计算机具体怎么执行,你只需要告诉它你想要的结果,计算机自己就进行完成。

下面我们来看一个例子:比如你打车要去某个地方:

命令式编程(imperative):详细描述路径

1、下个路口左转

2、下个有红灯的路口右转

3、前进100米

4、在下个路口掉头

5、前进1500米

6、到达目的地出租车停车区

声明式编程(Declarative):只告诉目的地

1、带我到XXX街。

当然,上面的例子并非是说函数式编程就不需要写代码了,代码留给计算机写,其实函数式编程只是一个思想,你需要把实现的过程封装成一个一个的函数。

在函数式编程中有两个非常重要的概念:

  1. 柯里化(curry)
  2. 代码组合(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的数组以及对象等都是引用传递,所以即使你使用了ES6const进行声明变量,它们还是能被更改,所以使用纯函数,可以杜绝任何可以观察的副作用。无论你调用该函数多少次,都不会改变公共变量的值。

// 不纯的
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)

函数式编程的核心就是柯里化代码组合,其中柯里化就相当于将函数变成一个个加工机器,而代码组合就相当于将这一个个加工机器组装起来,变成一条流水线。

函数式编程核心概念-组合(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 优点:

  1. 代码简洁,开发快速
  2. 接近自然语言,易于理解
  3. 更方便的代码管理
  4. 易于”并发编程”
  5. 代码的热升级

6.2 缺点

由于函数式编程会大量的使用闭包,通常性能会比命令式编程要低,但是两者的性能差距你可能根本感觉不到,所以除非你的项目要求极致的性能,不然的话你都可以放心的使用函数式编程。

7. 参考资料

函数式编程指北