什么是函数式编程

const users = [
  { name: 'tom', hobby: [] },
  { name: 'jerry', hobby: [] }
];

在一个程序中维护一个用户信息,假设我们现在有一个需求,是获取所有用户名称并格式化用户名称,将其首字母转换成大写。

  • 命令式编程

    const names = [];
    for(let i = 0, len = users.length; i < len; i++) {
      const user = users[i];
      const newName = user.name.toUpperCase();
      names.push(newName);
    }
    
  • 函数式编程

    function getName(user) {
      return user.name.toUpperCase();
    }
    const names = users.map(getName);
    

对比这两种方式,一个很明显的区别就是函数式编程的代码量少了,但这只是针对这个例子而言的,实际项目中代码量可能并不是这样。但是更重要的是函数式编程没有多余的变量声明,基本上都是函数层面的处理,通过函数的组合来实现,而命令式编程是一步一步的指引程序去处理需求,这也是它“命令式”的体现,更关注实现的过程。对比这两种方式,函数式编程更加的易读,如果函数命名语义化,直接看它的命名就可以知道代码的功能,但是命令式编程则需要一步步的观察才能了解具体功能。

映射是集合与集合之间的一种对应关系,数集到数集的映射即为函数

这是数学中的定义。在编程中需要处理的也是数据和关系,而关系就是函数,函数式编程就是在找数据之间的映射关系,让数据通过这种关系(也就是函数)转换成另一种数据。所以函数式编程就是强调在编程中把关注点放在如何构建关系,通过多个函数的组合,组成一个流水线一样加工数据最终生成所需要的数据。

函数式编程的特点

函数是”一等公民“ (First-Class Functions)

当我们说函数是“一等公民”的时候,并不是强调它的特殊,恰恰相反是表示函数是和其他对象一样的,处于平等地位,可以赋值给其他变量,可以作为参数传入另一个函数,也可以作为别的函数的返回值。因为都是在操作函数,所以这也是函数式编程得以实现的前提
不过在 Javascript 中函数就是如此的,这对于前端来说是没啥问题的。

声明式编程(Declarative Programming)

从上面的例子可以看出,函数式编程大多都是在声明需要做什么,而非怎么去做,这种编程风格就是声明式编程。这种编程的好处是代码的可读性特别高,因为声明式代码大多都是接近自然语言的。它不关心具体的实现,因此可以把某个能力交给一个具体的实现,方便进行分工协作。
React 也是声明式的,因为我们只关心数据,当数据变化时 React 会自动更新 UI 视图,也就是我们只声明数据和 UI ,数据改变后具体更新视图的操作我们并不关心交给 React 处理。

惰性执行(Lazy Evaluation)

惰性执行指的是函数只在需要的时候执行,不产生无意义的中间变量。在上面的例子中,函数式编程只定义了工作的函数和最终的结果数组,在函数定义完后直接调用函数返回最终结果。

无状态和数据不可变 (Statelessness and Immutable data)

函数式编程的核心概念:

  • 无状态
    对于一个函数,不管何时运行,都应该像第一次运行一样,相同的输入给出相同的输出,不依赖外部的状态。
  • 数据不可变
    要求你所有的数据都是不可变的,也就是想修改一个对象时应该先创建一个新的对象用来修改而不是直接修改原有的对象。

为了实现这两点,也就是要求函数必须没有副作用,还得是个纯函数

没有副作用(No Side Effects)

副作用的含义是:在完成函数主要功能之外完成的其他副要功能。函数中最主要的功能当然是根据输入返回结果,最常见的副作用就是随意操纵外部变量
用数组的 map 方法举例,方法的功能应该是输入的数组根据一个函数转换,生成一个新的数组。但是很常见的错误是将它作为一个循环来使用,直接去修改数组中的值。

const list = [
  { age: 0 },
  { age: 1 },
  { age: 2 }
];
list.map(item => {
  item.age++;
});

这样函数最主要的输出功能没有了,变成了直接修改了外部变量,这就是它的副作用。而没有副作用的写法应该是:

const newList = list.map(item => Object.assign({}, item, { age: item.age ++ }));

JS 中对象传递的是引用地址,保证函数没有副作用,不仅能保证数据的不可变性,还来能避免很多因为共享状态带来的问题。

纯函数 (pure functions)

纯函数的概念很简单,就是两点:

  • 不依赖外部状态(无状态)
  • 没有副作用(数据不变)

也就是上面说的函数式编程的两个特点,也就是它的这两个特点就决定了定义的函数应该是纯函数。
纯函数意味着相同的输入,永远会得到相同的输出

函数的合成与柯里化

函数式编程有两个最基本的运算:合成和柯里化。

合成(Compose)

如果一个值要经过多个函数,才能变成另外一个值,就可以把所有中间步骤合并成一个函数,这叫做"函数的合成"。
我们先实现一个简单的 compose 函数:

function compose (f, g) {
  return function(x) {
    return f(g(x));
  }
}

可以通过 compose 函数生成一个崭新的函数:

const f = x => x + 1;
const g = x => x + 2;
const fg = compose(f, g);
fg(1); // 4
const f = () => console.log('f');
const g = () => console.log('g');
const fg = compose(f, g);
fg();  // 执行函数先打印 g 再打印 f

可以看出 compose 的数据流是从右往左的,那么合成函数可以这样优化:
compose 函数有多种实现方式,利用 reduceRight 是其中一种,更多的实现不具体展开。)

function compose (...fns) {
  return function(...args) {
    return fns.reduceRight((val, fn, index) => {
      const last = index === 0;
      const ret = fn.apply(null, [].concat(val));
      return !last && Array.isArray(ret) ? [ret] : ret;
    }, args);
  }
}

函数的合成还必须满足结合律。

compose(f, compose(g, h))
// 等同于
compose(compose(f, g), h)
// 等同于
compose(f, g, h)

可以通过下面这个例子体现:

const f = x => x + 1;
const g = x => x + 2;
const h = x => x + 3;
compose(f, compose(g, h))(1);  // 7
compose(compose(f, g), h)(1);  // 7
compose(f, g, h)(1);  // 7

假设我们有这样一个需求,给一个数组,实现扁平化,并且去重。
通过合成的方式来做:

const arr = [1, [2, 2, 1, 10], 13, 3, [21, [34, [9, 8]]]];
const flatten = data => {
  let arr = JSON.parse(JSON.stringify(data));
  while(arr.some(Array.isArray)){
    arr = [].concat(...arr)
  }
  return Array.from(new Set(arr));
}
const unique = data => Array.from(new Set(data));
const flattenAndUnique = compose(unique, flatten);
flattenAndUnique(arr);  // [1, 2, 10, 13, 3, 21, 34, 9, 8]

当我们新增需求排序时,我们根本不需要修改之前封装过的函数:

// 新增一个数组排序方法
const sort = data => data.sort((a, b) => a - b);
const flattenAndUniqueAndSort = compose(sort, flattenAndUnique);
flattenAndUniqueAndSort(arr);  // [1, 2, 3, 8, 9, 10, 13, 21, 34]

这种方式遵循了设计模式中的开闭原则。

开闭原则:软件中的对象(类,模块,函数等等)应该对于扩展是开放的,但是对于修改是封闭的。

函数的合成,可以将需求拆分成一个个具体的工具函数,然后将函数像积木一样一个个组合搭建起来。

函数的柯里化(Currying)

在计算机科学中,柯里化(英语:Currying),又译为卡瑞化或加里化,是把接受多个参数的函数变换成接受一个单一参数(最初函数的第一个参数)的函数,并且返回接受余下的参数而且返回结果的新函数的技术。
—— 维基百科

柯里化是一种函数的转换,它是指将一个函数从可调用的 f(a, b, c) 转换为可调用的 f(a)(b)(c)
它不会调用函数,只是对函数进行转换。

我们实现一个 curry 版的 add 函数:

function add(x) {
  return function(y) {
    return x + y;
  }; 
}
const increment = add(1);
increment(2); // 3

观察这个例子,可以实现一个通用的 curry 函数:

function curry(func) {
  return function curried(...args) {
    if (args.length < func.length){
      return function() {
        return curried(...args.concat(Array.from(arguments)))
      }
    }
    return func(...args);
  }
}
function add(x, y) {
  return x + y;
}
const increment = curry(add);
increment(1)(2);  // 3

这里要区分一下柯里化和偏函数应用(Partial Application)。

偏函数的概念是指固定一个函数的一些参数,然后产生另一个更小元的函数

// 柯里化
f(a,b,c)  f(a)(b)(c)
// 偏函数
f(a,b,c)  f(a)(b,c) / f(a,b)(c)

柯里化强调的是生成单元函数,偏函数应用的强调的固定任意元参数,而平时开发中更常用的其实是偏函数应用,这样的好处是可以固定参数,降低函数通用性,提高函数的适合用性。

// 默认参数
function add(x, y, initial = 0) {
  return initial + x + y;
}
add(1, 2);  // 0

一般实际开发中并不会自己去实现一个 curry 函数,现成的库大多提供了一个 curry 函数的实现,例如 Lodash ,Ramda 这些。

总结

通过上面这些概念、特点可以总结出函数式编程的好处:

  • 可读性高、易理解
    使用声明式代码,接近自然语言,因此特别易于理解。
  • 方便测试、较少的错误率
    函数都是纯函数,没有副作用,因此测试简单。
  • 更好的协作开发
    遵循程序设计的开闭原则,可以更方便的扩展原有代码

当然没有一个完全完美的方案,函数式编程也是有它的一些不足的地方。
首先一个,生明了大量的函数,然后又通过合成、柯里化这些手段对函数进行包装,必然会使得 JS 引擎在上下文切换做更多的性能消耗,还有像是柯里化中的一些函数的递归问题,以及因为数据不可变,很常见的事重复创建新对象用于修改数据,占用更多的资源。因此,在性能要求很严格的场合,函数式编程其实并不是太合适的选择。
在日常开发中,可能整个应用层面并不是完全的使用这种方式去写代码,但是在写一些小功能或者插件这些,使用函数式编程的思想来写还是一个不错的选择。
至此,大致了解了函数式编程的一些概念和特点,还有函数合成和柯里化的应用。还有一些更深入的内容并未展开了,这里只是个人在复习这方面的内容时所作的简单的梳理函数式编程的一些知识。

参考

函数式编程指北
简明 JavaScript 函数式编程——入门篇
函数式编程入门教程- 阮一峰的网络日志