文章目录
- [4.1 纯函数](#4.1 纯函数)
-
- [4.1.1. 引用透明 Referential transparency](#4.1.1. 引用透明 Referential transparency)
- [4.1.2. JS 函数中的副作用 Side effects](#4.1.2. JS 函数中的副作用 Side effects)
- [4.1.3. 纯函数的优势 Advantages of pure functions ✔️](#4.1.3. 纯函数的优势 Advantages of pure functions ✔️)
-
- [1 执行顺序 Order of execution](#1 执行顺序 Order of execution)
- [2 函数记忆 Memoization](#2 函数记忆 Memoization)
- [3 自带文档描述 Self-documentation](#3 自带文档描述 Self-documentation)
- [4 利于测试 Testing](#4 利于测试 Testing)
(接上篇内容)
4.1 纯函数
4.1.1. 引用透明 Referential transparency
(详见本专栏 【第 012 篇】 )
4.1.2. JS 函数中的副作用 Side effects
4.1.3. 纯函数的优势 Advantages of pure functions ✔️
纯函数的主要优点在于其没有副作用。调用纯函数时,除了对其传参外,无需担心任何事情。更重要的是,由于纯函数只对您提供的内容起作用,而与其他外部资源无关,从而可以确保函数不会产生任何问题或破坏任何原有逻辑。这还不是纯函数的唯一优势,本节将介绍纯函数的更多知识。
1 执行顺序 Order of execution
另一种看待本章所述内容的方式,是将纯函数视为 健壮性 的体现。无论纯函数以何种顺序执行,都不会对系统产生任何不良影响。这一点可以进一步扩展:并行执行纯函数也是可以的,不必担心并行运算下的结果与在单线程中得到的结果有所不同。
拓展
只可惜
JavaScript
极力限制开发者编写并行代码,仅能在极其有限的情况下通过 web workers 实现并行逻辑,仅此而已。对于Node
开发者,cluster
集群模块可能会有所助益,尽管它并不是线程的替代品,只不过可以生成多个进程、利用所有线程的CPU
内核罢了。总之,JavaScript
并未提供诸如Java
线程之类的功能特性,并行化并不能算作JavaScript
函数式编程的主要优势。
另一个要记住的点是,纯函数是无需明确指定调用顺序的。就像数学上的表达式 f(2) + f(5)
总是等效于 f(5) + f(2)
,这被称为可交换属性(commutative property)。
但是对非纯函数,结论可能就是错的,例如:
js
var mult = 1;
const f = x => {
mult = -mult;
return x * mult;
};
console.log(f(2) + f(5)); // 3
console.log(f(5) + f(2)); // -3
小贴士
对于上述非纯函数,不能假设
f(3) + f(3)
的结果与2 * f(3)
相同,或者假设f(4) - f(4)
的值为0
,不信试试看。本例中其他通用的数学性质也不成立。
为什么要关心这个问题?无论是否愿意,编写代码时或多或少都会记住诸如数学 交换律 这样的性质。如此一来,当您想当然地以为两个表达式会得到相同的结果、实现相应的逻辑时,一遇到这些由于非纯函数而引入的难搞的 Bug
就会惊讶不已。
2 函数记忆 Memoization
鉴于给定输入的纯函数得到的结果始终相同,利用这一特性,可以缓存函数的返回结果来避免性能开销可能相当高昂的重复运算。像这样,对某表达式求值只需运行一次,后续调用则返回该缓存结果的过程,称为 函数记忆 (memoization
)。
第六章《生成函数------高阶函数详解》中还会继续深入讨论,这里先手动实现一例。裴波拉契序列由于其简洁性和背后隐藏的运算开销问题,常被用作相关演示。该序列定义如下:
- 当 n = 0,fib (n) = 0;
- 当 n = 1,fib (n) = 1;
- 当 n > 1,fib (n ) = fib (n -2) + fib (n-1)。
知识拓展
斐波那契的名字实际上来自 filius Bonacci ,或 Bonacci 之子。他最出名的是引入了我们今天所熟知的数字 0-9 的用法,而不是繁琐的罗马数字。他从一个有关兔子的谜题中,推导出了以他名字命名的序列作为问题的答案。更多斐波那契的生平介绍,详见 维基百科:斐波那契数的历史 及 Plus maths 网站:斐波那契的数字人生。
该序列从 0、1 开始,之后每一项都是前两项的和:即 1,接着是 2、3、5、8、13、21,依此类推。利用递归编程实现该系列很简单;第九章《设计函数------递归思想》还将重新讨论这个示例。该序列的直译版代码实现如下:
js
const fib = (n) => {
if (n == 0) {
return 0;
} else if (n == 1) {
return 1;
} else {
return fib(n - 2) + fib(n - 1);
}
}
//
console.log(fib(10)); // 55, 略慢
提示
如果只用一行代码实现该序列,也可以写作
const fib = (n) => (n<=1) ? n : fib(n-2) + fib(n-1)
------看明白了吗?重要的是,牺牲代码的清晰逻辑换来的极简代码实现,是否值得?
如果增大该函数中 n 的值,则很快会暴露问题,函数求值变得越来越耗时。以毫秒为单位在笔者电脑上实测,最终得到如下所示的时间分布图(具体取值因设备而异)。
如果您尝试使用此函数来增加 n 的值,您很快就会意识到存在问题,并且计算开始花费太多时间。 例如,在我的机器上,我进行了一些计时,以毫秒为单位,并将它们绘制在下图中(当然,您的里程可能会有所不同)。 由于该函数运算速度很快,笔者只好对介于 0 和 40 之间的 n
值运行 100 次后再作图。即便如此,当 n 值较小时,耗时也非常短。只有从 n = 25 开始,才得到有代表性的数据。图 4.1 显示了指数级增长情况,预示情况不乐观:
【图 4.1:递归函数 fib() 的运算时间呈指数级增长】
如果绘制出计算 fib(6)
所需的所有函数调用情况,就会注意到问题所在。每个节点代表一次 fib(n)
调用:观察节点中 n
的值。每次调用,除了 n = 0 或 1,其他调用都需要进一步运算,如图 4.2 所示:
【图 4.2:fib(6) 的所有运算显示存在大量重复运算】
延迟增加的原因很明显:fib(2)
的计算在四个不同的地方重复;而 fib(3)
本身也计算了 3 次。鉴于 fib
函数是纯函数,我们可以缓存计算出的结果以避免反复运算某个节点值。利用缓存数组,一个可能的代码实现版本如下:
js
let cache = [];
const fib2 = (n) => {
if (cache[n] === undefined) {
if (n === 0) {
cache[0] = 0;
} else if (n === 1) {
cache[1] = 1;
} else {
cache[n] = fib2(n - 2) + fib2(n - 1);
}
}
return cache[n];
}
console.log(fib2(10)); // 55,同上,但更快了
最初,缓存数组为空数组。每当计算 fib2(n)
的值时,都会检查它是否已经预先计算过。若没算过,则执行计算,但有一个处理:先将结果存入缓存后,再返回函数值。这意味着不存在重复运算:一旦计算了 fib2(n)
后,后续调用将不会重复该过程,而只返回之前缓存的值即可。
此外还要注意以下几点:
- 手动实现的记忆函数也可以通过高阶函数来实现。第六章会详细介绍,实现在无需变更或重写原函数的情况下,记住原函数值的效果;
- 用全局变量替代缓存数组不是一个很好的做法;隐藏缓存可以使用
IIFE
和闭包------详见第三章myCounter()
函数示例相关内容; - 可用缓存空间的限制也要纳入考虑。应用程序可能最终会耗尽所有可用
RAM
而崩溃。借助外部存储设备(如数据库、文件系统或云解决方案)又可能会抵消掉缓存带来的所有性能优势。有一些标准的解决方案(如最终从缓存中删除数据),但这些知识超出了本书讲述的范围;
当然,程序中的每个纯函数并非都要都执行该操作。本例关注的是需要花费一定时间、且频繁调用的函数------若非如此,增加的缓存管理时间很可能会超过其节省下的时间开销。
3 自带文档描述 Self-documentation
纯函数还有另一个优点。由于函数需要处理的所有内容都是通过参数提供的,没有任何隐藏的依赖关系,因此阅读源码也就理解了函数想实现的功能。
一个额外的好处:明确一个函数不会访问它的参数之外的任何东西后,使用时就更有信心,不必担心意外的副作用;该函数唯一能完成的逻辑就是文档要求的东西。
单元测试(下一节介绍)也能充当文档,因为它们提供了在给定某些参数时函数返回结果的示例。大多数程序员都相信最好的文档是充满了大量示例的文档,并且每个单元测试都可视为文档示例。
4 利于测试 Testing
纯函数的另一个优势------也是最重要的优势之一------在于单元测试。纯函数只有一个职责:根据输入得到输出。因此,对纯函数编写测试用例时,由于无需考虑上下文,也无需模拟任何状态,工作量将大大简化。
纯函数只需要关注输入、检查输出即可。所有函数调用都是相互独立的。
至此,我们已经考察了纯函数的几个主要方面。接下来目光转到非纯函数,最后再看看二者的测试工作如何进行。