函数式编程在 JavaScript 领域着实已经成为一个热门话题。就在几年前,很多 JavaScript 程序员甚至都不知道啥是函数式编程,但是就在近三年里我看到过的每一个大型应用的代码库中都包含了函数式编程思想的大规模使用。
函数式编程(缩写为 FP)是一种通过组合纯函数来构建软件的过程,避免状态共享、可变数据及副作用的产生。函数式编程是一种声明式编程而不是指令式编程,应用的状态全部流经的是纯函数。与面向对象编程思想形成对比的是,其应用程序的状态通常都是与对象中的方法共享的。
函数式编程是一种编程范式,意指它是一种基于一些基本的、限定原则的软件架构的思维方式,其他编程范式的例子还包括面向对象编程和面向过程编程。
相比指令式编程或面向对象,函数式编程的代码倾向于更为简洁、可预测且更容易测试。但如果你不熟悉这种方式或与其常见的几种相关模式的话,函数式编程的代码同样可以看起来很紧凑,相关文档对于新手来说可能也较为难以理解。
如果你开始去搜索函数式编程的相关术语,你可能很快就会碰壁,大量专业术语完全可以唬住一个新手。单纯的讨论其学习曲线有点儿过于轻描淡写了,但是如果你已经从事 JavaScript 编程工作有一段时间了,那么你应该已经在你的项目中使用过很多函数式编程的思想或工具了。
别让新词汇把你吓跑。它们会比听起来更容易。
这其中最难的部分可以说就是让一堆陌生词汇充斥你的脑袋了。各种术语一脸无辜,因为在掌握它们之前你还需要了解下面这些术语的含义:
- 纯函数
- 函数组合
- 避免状态共享
- 避免状态改变
- 避免副作用
一个纯函数定义如下:
- 每次给定相同的输入,其输出结果总是相同的
- 无任何副作用
纯函数中的很多特性在函数式编程中都很重要,包括引用透明度(如果表达式可以替换为其相应的值而不更改程序的行为,则该表达式称为引用透明)。
引用透明度说白了就是相同的输入总是得到相同的输出,也就是说函数中未使用任何外部状态:
function plusOne(x) {
return x + 1;
}
复制代码
上面的例子即为引用透明度函数,我们可以用 6 来代替 plusOne(5)
的函数调用。详细解释可参考 stack overflow - What is referential transparency?
函数组合是指将两个或多个函数进行组合以便产生一个新的函数或执行某些计算的过程。比如组合函数f.g
(.
的意识是指由...组成)在 JavaScript 中等价于 f(g(x))
。理解函数组合对于理解使用函数式编程编写软件来说是个十分重要的步骤。
状态共享
状态共享是指任何变量、对象或内存空间在一个共享的作用域中存在,或者是被用来作为对象的属性在作用域之间传递。一个共享的作用域可以包括全局作用域或者闭包作用域。在面向对象编程中,对象通常都是通过添加一个属性到其他对象中来在作用域间共享的。
状态共享的问题在于为了了解一个函数的作用,你不得不去了解函数中使用的或影响的每一个共享的变量的过往。
假定你有一个用户对象需要保存,你的saveUser()
函数会向服务器上的接口发起请求。与此同时,用户又进行了更换头像的操作,调用updateAvatar()
来更换头像的同时也会触发另一次saveUser()
请求。在保存时,服务器返回一个规范的用户对象,该对象应该替换内存中的任何内容以便与服务器上的更改或响应其他 API 调用同步。
但是问题来了,第二次响应比第一次返回要早。所以当第一个响应(已过期)返回时,新头像被从内存中抹去了,替换回了旧头像。这就是一个争用条件的例子 —— 是与状态共享有关的一个很常见的 bug。
另一个跟状态共享有关的常见问题是更改调用函数的顺序可能会导致级联失败,因为作用于状态共享的函数与时序有关:
// 在状态共享的函数中,函数调用的顺序会导致函数调用的结果的变化
const x = {
val: 2
};
const x1 = () => x.val += 1;
const x2 = () => x.val *= 2;
x1();
x2();
console.log(x.val); // 6
// 同样的例子,改变调用顺序
const y = {
val: 2
};
const y1 = () => y.val += 1;
const y2 = () => y.val *= 2;
y2();
y1();
// 改变了结果
console.log(y.val); // 5
复制代码
如果我们避免状态共享,函数调用的时间和顺序就不会改变函数调用的结果。使用纯函数,给定相同的输入总是能得到相同的输出。这就使得函数调用完全独立,就可以从根本上简化改变与重构。函数中的某处改变,或是函数调用的顺序不会影响或破坏程序的其他部分。
const x = {
val: 2
};
const x1 = x => Object.assign({}, x, { val: x.val + 1});
const x2 = x => Object.assign({}, x, { val: x.val * 2});
console.log(x1(x2(x)).val); // 5
const y = {
val: 2
};
// 由于不存在对外部变量的依赖
// 所以我们不需要不同的函数来操作不同的变量
// 此处故意留白
// 因为函数不变,所以我们可以以任意顺序调用这些函数任意次
// 而且还不改变其他函数调用的结果
x2(y);
x1(y);
console.log(x1(x2(y)).val); // 5
复制代码
在上面的例子中,我们使用了Object.assign()
方法,然后传入了一个空对象作为第一个参数来复制x
的属性,而不是在原处改变x
。该例中,不使用Object.assign()
的话,它相当于简单的从头开始创建一个新对象,但这是 JavaScript 中创建现有状态副本而不是使用变换的常见模式,我们在第一个示例中演示了这一点。
如果你仔细的看了本例中的console.log()
语句,你应该会注意到我已经提到过的一些东西:函数组合。回忆一下之前说过的知识点,函数组合看起来像这样:f(g(x))
。在本例中为x1(x2())
,也即x1.x2
。
当然了,要是你改变了组合顺序,输出也会跟着改变。执行顺序仍然很重要。f(g(x))
不总是等价于g(f(x))
,但是再也不用担心的一件事就是函数外部的变量,这可是件大事。在非纯函数中,不可能完全知道一个函数都做了什么,除非你知道函数使用或影响的每一个变量的整个历史。
去掉了函数调用的时序依赖,你也就完全排除了这一类 bug。
不可变性
不可变对象是指一个对象一旦被创建就不能再被修改。反之可变对象就是说对象被创建后可以修改。不可变性是函数式编程中的一个核心概念,因为如果没有这个特性,程序中的数据流就会流失、状态历史丢失,然后你的程序中就总会冒出奇怪的 bug。
在 JavaScript 中,千万不要把 const
和不变性搞混。const
绑定了一个创建后就无法再被分配的变量名。const
不创建不可变对象。使用 const
创建的变量无法再被赋值但是可以修改对象的属性。
不可变对象是完全不能被修改的。你可以深度冻结一个对象使其变成真·不可变的值。JavaScript 中有一个方法可以冻结对象的第一层:
const a = Object.freeze({
foo: 'Hello',
bar: 'world',
baz: '!'
});
a.foo = 'Goodbye';// Error: Cannot assign to read only property 'foo' of object Object
复制代码
这种冻结方式仅仅是浅层的不可变,例如:
const a = Object.freeze({
foo: { greeting: 'Hello' },
bar: 'world',
baz: '!'
});
a.foo.greeting = 'Goodbye';
console.log(`${ a.foo.greeting }, ${ a.bar }${a.baz}`);// Goodbye world!
复制代码
可以看到,一个被冻结的顶层的原始属性是不可变的。但如果属性值为对象的话,该对象依然可变(包括数组等)。除非你遍历整个对象树,将其层层冻结。
在很多函数式编程语言中都又比较特殊的不可变数据结构,称之为查找树数据结构,这种数据结构是可以有效的进行深度冻结的。
查找树通过结构共享来共享内存空间的引用,其在对象被复制后依然是不变的,从而节省了内存,使得某类操作的性能有显著的提升。
例如,你可以在一个对象树的根节点使用身份对照来进行比较。如果身份相同,如果身份相同,那你就不用去遍历整颗树来对比差异了。
在 JavaScript 中有一些比较优秀的利用树的类库,比如 Immutable.js
和 Mori
。
这俩库我都用过,我更倾向于在需要很多不可变状态的大型项目中使用 Immutable.js
。
副作用
副作用就是指当调用函数时,除了返回函数值之外,还对主调用函数产生附加的影响。副作用的函数不仅仅只是返回了一个值,而且还做了其他的事情:
- 改变了外部对象或变量属性(全局变量或父函数作用域链中的变量)
- 在控制台中有输出打印
- 向屏幕中写了东西
- 向文件中写了东西
- 向网络中写了东西
- 触发了外部过程
- 调用了其他有副作用的函数
副作用在函数式编程中大多数时候都是要避免的,这样才能使得程序的作用一目了然,也更容易被测试。
Haskell
等其他编程语言总是从纯函数中使用 Monads 将副作用独立并封装。有关 Monads 内容太多了,大家可以去了解一下。
但你现在就需要了解的是,副作用行为需要从你的软件中独立出来,这样你的软件就更易扩展、重构、debug、测试和维护。
这也是大多数前端框架鼓励用户单独的管理状态和组件渲染、解耦模块。
通过高阶函数提高复用性
函数式编程倾向于复用一系列函数工具来处理数据。面向对象编程则倾向于将方法和数据放在对象中,这些合并起来的方法只能用来操作那些被设计好的数据,经常还是包含在特定组件实例中的。
在函数式编程中,任何类型的数据都是一样的地位,同一个 map()
函数可以遍历对象、字符串、数字或任何类型的数据,因为它接收一个函数作为参数,而这个函数参数可以恰当的处理给定的数据类型。函数式编程通过高阶函数来实现这种特性。
JavaScript 秉承函数是一等公民的观点,允许我们把函数当数据对待 —— 把函数赋值给变量、将函数传给其他函数、让函数返回函数等...
高阶函数就是指任何可以接收函数作为参数的函数、或者返回一个函数的函数,或者两者同时。高阶函数经常被用于:
- 抽象或独立的动作、回调函数的异步流控制、promises,、monads 等等...
- 创建可以处理各种数据类型的实用工具函数
- 使用函数的部分参数或以复用目的或函数组合创建的柯里化函数
- 接收一组函数作为参数然后返回其中的一些作为组合
容器、函子、列表、流
函子就是一种可以被映射的东西。换句话说,它就是一个有接口的容器,该接口可以被用来apply
到函数内部的一个值(这句翻译太奇怪了,功力不够。原文 it’s a container which has an interface which can be used to apply a function to the values inside it.)。
前面我们知道了相同的 map()
函数可以在多种数据类型上执行。它通过提升映射操作以使用函子 API 来实现。关键的流控制操作可以通过 map()
函数利用该接口使用。如果是 Array.prototype.map()
的话,容器就是个数组,其他数据结构可以作为函子,只要它们提供了 map()
API。
让我们来看一下 Array.prototype.map()
是如何允许从映射函数中抽象数据类型使得 map()
函数在任何数据类型上可用的。我们创建一个 double()
函数来映射传入的参数乘 2 的操作:
const double = n => n * 2;
const doubleMap = numbers => numbers.map(double);
console.log(doubleMap([2, 3, 4])); // [ 4, 6, 8 ]
复制代码
要是我们想对一个游戏中的目标进行操作,让其得分数翻倍呢?只需要在 double()
函数中传入 map()
的值上稍作改动即可:
const double = n => n.points * 2;
const doubleMap = numbers => numbers.map(double);
console.log(doubleMap([
{ name: 'ball', points: 2 },
{ name: 'coin', points: 3 },
{ name: 'candy', points: 4}
])); // [ 4, 6, 8 ]
复制代码
使用如函子/高阶函数的概念来使用原生工具函数来操作不同的数据类型在函数式编程中很重要。类似的概念被应用在 all sorts of different ways。
列表在时间上的延续即为流。
你现在只需要知道数组和函数不是容器和值在容器中应用的唯一方式。比如说,一个数组就是一组数据。列表在时间上的延续即为流 -- 因此你可以使用同类工具函数来处理进来的事件流 —— 在日后实践函数式编程中你会对此有所体会。
声明式编程 & 指令式编程
函数式编程是一种声明式编程范式,程序的逻辑在表达时没有明确的描述流控制。
指令式编程用一行行代码来描述特定步骤来达到预期结果。而根本不在乎流控制是啥?
声明式编程抽象了流控制过程,用代码来描述数据流该怎么做,如何去获得抽象的方式。
下面的例子中给出了指令式编程映射数组中数字并返回将值乘 2 返回新数组:
const doubleMap = numbers => {
const doubled = [];
for (let i = 0; i < numbers.length; i++) {
doubled.push(numbers[i] * 2);
}
return doubled;
};
console.log(doubleMap([2, 3, 4])); // [4, 6, 8]
复制代码
声明式编程做同样的事,但是使用函数工具 Array.prototype.map()
抽象了流控制的方式,允许你对数据流做更清晰的表达:
const doubleMap = numbers => numbers.map(n => n * 2);
console.log(doubleMap([2, 3, 4])); // [4, 6, 8]
复制代码
指令式编程常使用语句,语句即一段执行某个动作的代码,包括for
、if
、switch
、throw
等等。
声明式编程的代码更多依赖的是表达式,表达式是一段有返回值的代码。表达式的例子如下:
2 * 2
doubleMap([2, 3, 4])
Math.max(4, 3, 2)
复制代码
你会在代码中经常看见一个表达式被赋给一个变量、从函数中返回一个表达式或是被传入一个函数。
结论
本文要点:
- 使用纯函数而不是共享状态或者有副作用的函数
- 发扬不可变性而不是可变数据
- 使用函数组合而不是指令式的流控制
- 很多原生、可复用的工具函数可以通过高阶函数应用到很多数据类型上,而不是只能处理指定数据
- 声明式编程而不是指令式编程(要知道做什么,而不是如何做)
- 表达式和语句
- 容器 & 高阶函数对比 特设多态