函数式编程是什么?

函数式编程是一种通过组合纯函数来编写软件的编程范式,它是声明式而非命令式的,其原则是避免共享状态, 改变数据,以及副作用

函数式编程推荐:

  1. 使用纯函数
  2. 使用函数组合
  3. 使用声明式代码而非命令式代码

函数式编程应当避免:

  1. 副作用
  2. 改变数据
  3. 共享状态

术语解释

副作用(side effect)

函数副作用是指函数在执行过程中改变了函数之外的环境。具体地说,函数副作用是指函数被调用,完成了函数既定的计算任务,但同时因为访问了外部数据,尤其是因为对外部数据进行了写操作,从而一定程度地改变了系统环境。 例如下面的函数就有副作用,它改变了外部变量count。

1
2
3
4
5
let count = 0;

function add1() {
count += 1;
}

下面的情形都是有副作用的:

  • 函数在执行过程中改变了外部变量
  • 改变了函数的参数
  • 抛出了异常
  • 打印字符串到标准输出或者记录了日志
  • 启动了一个子进程
  • 调用了有副作用的函数

纯函数

如果一个函数满足下列条件则它就是一个纯函数:

  1. 有输入(参数)
  2. 没有状态 (如全局变量)
  3. 相同的输入,总是会的到相同的输出
  4. 没有任何副作用
  5. 函数只完成一个任务 (单一职责原则)

纯函数的优点在于:

  1. 可重用
  2. 可以组合使用
  3. 便于测试
  4. 便于缓存

改变数据

改变数据在这里指的是改变函数中通过参数传过来的数据,因为Javascript在传递对象参数时,传递的是引用,如果改变了这些参数的属性值时,函数外边的数据也被改变了。

例如:

1
2
3
4
let arr = [1, 3, 9, 8, 7];
let sortArr = (list) => { return list.sort() }
sortArr(arr) // [ 1, 3, 7, 8, 9 ]
console.log(arr) //[ 1, 3, 7, 8, 9 ]

可以看到 sortArr函数改变了其参数list,导致了外部数据arr的值也发生了变化。

我们可以通过如下的方法,在不改变参数数据的前提下来更新数组和对象。

更新数组

1
2
3
4
5
6
7
8
let arr = [1, 3, 2, 4, 5];
let arr1 = arr.map((item)=>{return item + 1});
let arr2 = arr.filter((item)=>{return item !== 2});
let sum = arr.reduce((x,y)=>x+y, 0)
console.log(arr); //[ 1, 3, 7, 8, 9 ]
console.log(arr1); //[ 2, 4, 8, 9, 10 ]
console.log(arr2); //[ 1, 3, 7, 8, 9 ]
console.log(sum); //28

更新对象

1
2
3
4
5
6
let stu = {name: 'xiaoming', age: 10};
let newStu = { ...stu, age: 11};
let { name, ...stuWithoutName} = stu;
console.log(stu); //{ name: 'xiaoming', age: 10 }
console.log(newStu); //{ name: 'xiaoming', age: 11 }
console.log(stuWithoutName); //{ age: 10 }

命令式 vs 声明式

命令式(imperative)和声明式(declarative)是两种不同的编程范式。命令式编程需要告诉计算机如何完成任务(如计算机熟悉的顺序、分支、循环等),而声明式编程只需要告诉计算机要做什么(如组合函数),它抽象掉了具体的控制流。

下面的代码是命令式的编程方式,它告诉计算机要通过循环来将数组的每一项加1,放到新的数组里。

1
2
3
4
5
6
let arr = [1, 2, 3, 4, 5];
let newArr = [];
for(let i = 0; i < arr.length; i++) {
newArr.push(arr[i] + 1)
}
console.log(newArr);

下面的代码则是声明式的代码,它只是通过函数add1告诉了计算机要做什么。

1
2
3
4
let arr = [1, 2, 3, 4, 5];
let add1 = (x) => x + 1;
let newArr = arr.map(add1);
console.log(newArr);

如何进行函数式编程(funcational programming)?

通过函数式编程的定义我们知道,函数式编程是将纯函数组合起来,从而完成复杂任务的编程方式。其哲学思想类似于Unix Do one thing, and do it well 的设计理念。一个纯函数只负责完成一个小的任务,而通过纯函数的组合,可以完成更为复杂的任务。

函数组合 (composition)

我们通过一个例子来看看如何将函数组合起来完成一个特定的任务。

这个例子给的任务是:统计一个句子中的单词数。我们可以把它拆成如下几个子任务,每个子任务都用一个纯函数来实现:

  • trimStr: 将句子两端的空白符去掉
  • splitStr: 将句子分隔为数组,数组项是拆分出来的单词
  • rmArticle: 去掉数组中的冠词
  • nrArr: 统计数组的长度

实现代码如下:

1
2
3
4
5
6
7
8
9
const sentence = ' Honesty and diligence should be your eternal mates. The proverb of Franklin. ';

const trimStr = (str) => str.trim();
const splitStr = (str) => str.split(' ');
const rmArticle = (arr) => arr.filter((w) => !['A', 'AN', 'THE'].includes(w.toUpperCase()));
const nrArr = (arr) => arr.length;

const count = nrArr(rmArticle(splitStr(trimStr(sentence))));
console.log(count); //11

上面的代码通过nrArr(rmArticle(splitStr(trimStr(sentence))))将函数组合起来调用,但是这样的方式可读性太差。假设我们有一个函数pipe,它可以把上述函数组合起来生成一个新的函数,然后再调用这个新的函数,可读性就会好很多。

1
2
3
const countWords = pipe(trimStr, splitStr, rmArticle, nrArr);
const count = countWords(sentence);
console.log(count);

那么pipe该如何实现呢?我们知道数组有一个方法叫reduce,它可以将数组项和上一次回调函数的执行结果作为参数放到回调函数中,于是,我们可以将需要组合的纯函数放到一个数组中,然后利用该数组的reduce逐个调用这些纯函数。

1
const pipe = (...funcs) => (x) => funcs.reduce((v, f) => f(v), x);

柯里化 (Currying)

组合函数要求函数的输入是上一个函数的输出,那么,如果函数的参数不止一个那该怎么办,这时候就需要用到柯里化。柯里化是指将一个需要多个参数的函数转化为只接受一个参数的函数,而这个函数的返回值是另外一个函数,它需要传入下一个参数。例如,把 add(a, b) 函数柯里化之后它就可以这样调用add(a)(b)

如果上述的例子中splitStr需要传入两个参数:

1
const splitStr = (s, str) => str.split(s);

那么在组合它之前必须将其进行柯里化。

1
2
const curriedSplitStr = curry(splitStr);
const countWords = pipe(trimStr, curriedSplitStr(' '), rmArticle, nrArr);

curry函数则是通过递归的方法来实现的:

1
2
3
4
5
6
7
8
9
const curry = (f) => (function nextFunc(prevArgs) {
return (arg) => {
const args = [...prevArgs, arg];
if (args.length >= f.length) {
return f(...args);
}
return nextFunc(args);
};
}([]));

工具库

当前有许多用于函数式编程的库,例如lodashRamda。这些库中的函数都是柯里化的,而且提供了函数式编程常用的工具函数如pipe, curry等等。我们以Ramda为例来看一看前面的例子如何通过这些库来实现。

1
2
3
4
5
6
7
8
9
10
11
import * as R from 'ramda';

const sentence = ' Honesty and diligence should be your eternal mates. The proverb of Franklin. ';
const trimStr = (str) => str.trim();
const splitStr = (s, str) => str.split(s);
const rmArticle = (arr) => arr.filter((w) => !['A', 'AN', 'THE'].includes(w.toUpperCase()));
const nrArr = (arr) => arr.length;

const countWords = R.pipe(trimStr, R.curry(splitStr)(' '), rmArticle, nrArr);
const count = countWords(sentence);
console.log(count);

小结

综上所述,函数式编程本质上是利用组合单一职责的纯函数来完成复杂任务的编程范式,就好像一条用函数组成的流水线,原材料经过这条流水线之后就变成了我们需要的产品。不论面向对象和函数式编程孰优孰劣,javascript因并不是天生的OOP语言而更加偏向于函数式编程,这点从React引入hooks就可见一斑。