上一章我们重点介绍了函数式编程中的关键要素——函数,并详细介绍了箭头函数及相关概念,如注入(injection
)、回调(callback
)、填充(polyfilling
)、打桩(stubbing
)等。本章将重新审视并应用其中一些观点,并主要介绍以下内容:
- 引入“纯度”的概念,以及为何要关注纯函数、非纯函数;
- 考察“引用透明度”的概念;
- 认识到副作用隐含的问题;
- 介绍纯函数的一些优点;
- 阐述非纯函数背后的逻辑;
- 设法最小化非纯函数的数量;
- 探讨纯函数和非纯函数的测试方法。
纯函数的行为方式与数学上的函数相同,并具备诸多好处。当一个函数满足以下两个条件,即为 纯函数:
- 给定相同的参数,该函数总是计算并返回相同的结果:无论调用多少次或在什么条件下调用,该命题都成立;其结果不依赖于任何 外部 的信息或状态,否则这些信息或状态可能会在程序执行期间发生变化并导致返回值的改变。函数结果也不能依赖于
I/O
结果、随机数、以及其他一些外部变量或不能直接控制的值; - 在计算其结果时,该函数不会引起任何可观察到的“副作用”:这包括输出到
I/O
终端、对象的改变、函数外的程序状态更改等等;
简单来说,纯函数不依赖(也不修改)其作用域之外的任何内容,并且始终为相同的输入参数返回相同的结果。
在该语境下常用的另一个术语概念是 幂等性,但二者并不完全相同。幂等函数可以根据需要多次调用,并且总是产生相同的结果;但这并不意味着该函数没有副作用。幂等性通常出现在 RESTful
风格的服务上下文中。这里举个简单的例子来区分纯度(purity
)和幂等性(idempotency
)。调用 PUT
请求会导致数据库记录被更新(副作用),但如果反复调用,元素将不会被进一步修改,因此数据库的全局状态也不会进一步改变。
借用一个软件设计原则(单一职责)来提醒自己:一个函数应该 做一件事就只做这件事,除此之外什么都不做。如果一个函数实现了其他逻辑或具备一些隐藏功能,那么该函数对其状态的依赖将无法正确预测结果,加大开发者的处理难度。
下面就来深入研究一下这些控制因素。
数学里的 引用透明 是一种特性(property
),可以用表达式的值替换表达式,而不改变原来的任何结果。
知识拓展
引用透明的对立面,是 引用不透明。这样的函数在调用时,即便使用相同的参数也无法保证总是产生相同的结果。
例如,考虑一个做优化处理的编译器在执行 常量折叠 时发生的变化。原始代码如下:
const x = 1 + 2 * 3;
编译器可能会将 2 * 3
视为常量,优化为:
const x = 1 + 6;
更理想的情况,甚至可以完全避免求和:
const x = 7;
编译器正是利用了所有数学表达式和函数按照定义都具备的引用透明的特性来节省执行时间的。此外,如果编译器无法预测给定表达式的结果,则无法完成任何形式的代码优化,只能在运行时进行计算。
拓展
在 λ 演算中,如果将目标函数表达式的值替换为函数的计算值,则该操作称为 β 化简。请注意,这里只能使用引用透明的函数。
所有的算术表达式(包含数学运算符和函数)都是引用透明的:22 * 9
总是可以被 198
替换;涉及 I/O
的表达式是不透明的,因为在具体执行之前无法得知它们的结果。同理,涉及日期和时间相关的函数或随机数表达式也是不透明的。
至于自定义的函数,很容易写出一些不满足 引用透明 条件的来。事实上,一个函数甚至都不需要返回一个值,尽管 JavaScript
解释器会默认返回一个 undefined
的值。
拓展
一些语言还区分了函数(
function
)和过程(procedure
)。函数要返回一个值,过程不返回任何东西;但JavaScript
不是。还有一些编程语言提供了确保函数引用透明的方法。
如果要分类的话,函数可以分为以下三类:
- 纯函数:返回值至取决于函数参数、没有任何副作用的函数;
- 副作用函数:不返回任何值、但会产生某些副作用的函数(实际上也会返回一个
undefined
值,但不是讨论重点); - 带副作用的函数:返回值不仅仅取决于参数,还含有一些副作用。
在函数式编程中,尤为强调第一类——引用透明的纯函数。此时编译器不仅可以推断程序行为(从而能够优化生成的代码),程序员也可以更轻松地推断程序及其组件之间的联系。这反过来又可以帮助证明算法的正确性,或通过函数等效替换来进一步优化代码。
何为 副作用?我们可以将其定义为在执行某些计算或过程期间,程序所发生的状态变化;或者与外部元素所发生的交互,如用户、Web 服务、另一台计算机等等。
对于这个定义的适用范围可能存在误解。在日常谈话中一谈到副作用,更像是在谈论 附带伤害——某个特定行为的一些意料之外的结果;然而,计算机领域的副作用,则包含了函数外部的所有可能的影响或变化。如果一个函数要执行 console.log()
来显示结果,则会被视为副作用,即便它正是该函数本应实现的首要功能。
本节主要介绍以下内容:
JavaScript
中常见的副作用;- 全局及内部状态引发的问题;
- 函数参数不固定时的情况;
- 一些总是很棘手的函数;
编程中被视为副作用的东西可太多了。在 JavaScript
中,无论前端后端,您可能会看到以下常见的副作用:
- 改变全局变量;
- 改变作为参数传入的对象;
- 任何类型的输入输出操作,例如显示
alert
消息或将一些文本写入日志; - 操作、更改文件系统;
- 更新数据库;
- 调用
Web
服务; - 查询或修改
DOM
; - 触发任何外部进程;
- 仅仅是调用了另一个碰巧产生副作用的函数。这可以理解为不纯函数具有 传染性:调用不纯函数的函数会自动变为不纯函数!
有了这个定义,让我们来看看哪些因素会导致函数不纯(或引用不透明)。
在上述所有要点中,产生副作用的最常见原因,是使用了与程序其他模块共享了全局状态的非局部变量。根据定义,纯函数总是在给定相同入参的情况下返回相同的出参值。如果一个函数引用了其内部状态之外的任何东西,则会自动变为不纯函数;这也为后续调试制造了障碍:要了解一个函数实现了什么功能,必须理解该状态如何获取到当前最新的值——这意味着必须理解在这之前的所有历史代码逻辑:这可不是个轻松活。
让我们编写一个函数,通过检查一个人是否至少出生于 18 年前,来判定他们是否是合法成年人。(诚然这不够精确,因为没有考虑出生日期和月份;但请多担待,这不是讨论的重点)。满足需求的函数 isOldEnough()
代码实现如下:
let limitYear = 1999;
const isOldEnough = birthYear => birthYear <= limitYear;
console.log(isOldEnough(1960)); // true
console.log(isOldEnough(2001)); // false
函数 isOldEnough()
可以正确检测某人是否至少 18 岁,但这取决于一个外部变量(该变量仅适用于 2017 年)。除非您了解外部变量的含义、并知晓它是如何获取到值的,否则将无从了解该函数的作用。而且测试也会很困难:必须记得创建全局变量 limitYear
,否则测试将无法进行。即使该函数有效,但代码实现并不是最佳的。
这种情况也有例外:考察以下函数 circleArea()
,用于计算给定半径的圆面积。该函数是否为纯函数呢?
const PI = 3.14159265358979;
const circleArea = r => PI * Math.pow(r, 2); // 或 PI * r ** 2
即便函数访问了外部状态,但 PI
是一个常量(因此无法修改)的事实,允许我们在没有功能性修改的前提下,在 circleArea
内部将其替换为一个值,因此可被视为一个纯函数。该函数将始终为相同的参数返回相同的值,从而符合纯度定义。
提示
即使换用
Math.PI
而非代码中定义的常量(顺便说一下,用Math.PI
是更好的解决方案),参数仍然是相同的;常量不会改变,所以仍然是纯函数。
了解了全局状态引起的副作用,再来看看函数内部状态的问题。
副作用的概念还可以推广到保存了本地状态、以备后续调用的内部变量。此时外部状态没有变化,但由于函数的返回值所隐含的后续状态差异,仍有可能引入副作用。不妨假设一个舍入函数 roundFix()
,为了让上下舍入的累计误差趋近于零,函数会在下一次运算时执行与本次相反的舍入操作。该函数将不得不对先前舍入的总效应做一个汇总来决定下一步操作,可能的代码实现如下:
const roundFix = (function() {
let accum = 0;
return n => {
// 实际上下或向下舍入取决于 accum 的符号
let nRounded = accum > 0 ? Math.ceil(n) : Math.floor(n);
console.log("accum", accum.toFixed(5), " result", nRounded);
accum += n - nRounded;
return nRounded;
};
})();
其中——
- 本例中的
console.log()
只是便于罗列当前的累计误差及函数的返回值,看看是执行了向上还是向下舍入,实际不会包含在函数中; - 为了获取一个隐藏的内部变量,这里用到了
IIFE
模式; - 第 5 行求
nRounded
的值也可以写作Math[accum > 0 ? "ceil": "floor"](n)
——考察accum
的正负来决定调用ceil
或floor
,然后用Object["method"]
的写法调用Object.method()
。本例的写法其实更清楚,这里只是给出另一种写法,仅供参考。
只用两个值测试该函数(发现了吗)结果表明:对于给定的输入,最终结果并不总是相同。控制台打印部分展示了某个值是如何上下舍入的:
roundFix(3.14159); // accum 0.00000 result 3
roundFix(2.71828); // accum 0.14159 result 3
roundFix(2.71828); // accum -0.14013 result 2
roundFix(3.14159); // accum 0.57815 result 4
roundFix(2.71828); // accum -0.28026 result 2
roundFix(2.71828); // accum 0.43802 result 3
roundFix(2.71828); // accum 0.15630 result 3
第一轮,accum
为零,3.14159
向下舍入,accum
变为 0.14159
,符合预期;
第二轮,accum
为正,向上收入,故 2.71828
被收至 3
,accum
变为负数;
第三轮,accum
为负,相同的值 2.71828
被舍入为 2
——相同的输入,却得到了不同的值。
可见,由于函数的结果取决于其 内部状态,同样的参数在累计误差的作用下,可以向上或向下舍入而得到不同的结果。
提示
像这样使用内部状态,也是许多
FP
开发者认为使用对象可能不太好的原因。在面向对象编程中,开发者习惯于存储信息到某个属性,以备后续调用;然而,这种做法被认为是不纯的(impure
),因为重复的方法调用也可能返回不同的值,尽管传入了相同的参数。
除了全局及内部状态下的副作用,还有其他情况也可能存在副作用,比如改变传入的参数值的情况。一起来看看吧。
不纯函数会修改参数值——这一情况也要引起重视。在 JavaScript
中,参数是按 值 传递的,但数组和对象除外,它们是按 引用 传递的。这意味着对函数形参的任何修改都会引起实参中原对象或数组的实际修改。JavaScript
中的几个突变方法(mutator methods)可以根据定义修改目标对象,进一步掩盖了这一副作用。例如,想要一个可以找出字符串数组中最大元素的函数(若为数字数组,可以简单地使用 Math.max()
),假设代码实现如下:
const maxStrings = a => a.sort().pop();
let countries = ["Argentina", "Uruguay", "Brasil", "Paraguay"];
console.log(maxStrings(countries)); // "Uruguay"
该函数确实能得出正确结果(若考虑外语对排序的影响,请参考上一章的相关章节),但它存在一个缺陷。考察原始数组:
console.log(countries); // ["Argentina", "Brasil", "Paraguay"]
糟糕——原数组被修改了;这就是副作用。如果您要再次调用 maxStrings(countries)
,那么它不会返回与之前相同的结果,而是得到另一个值;该函数显然不是纯函数。面对这种情况,一个快速解决方案是使用数组的副本(如借助扩展运算符,更多处理手法将在第十章重点论述):
const maxStrings2 = a => [...a].sort().pop();
let countries = ["Argentina", "Uruguay", "Brasil", "Paraguay"];
console.log(maxStrings2(countries)); // "Uruguay"
console.log(countries); // ["Argentina", "Uruguay", "Brasil", "Paraguay"]
至此,我们讨论了修改函数自身参数引起的副作用,再来考察最后一种情况:函数被迫为不纯函数。
最后,一些函数本身也会带来问题。例如,随机函数 Math.random()
本身就是不纯的:它并不总是返回相同的值,否则就与设计初衷相违背。该函数每调用一次,就会修改一个全局 seed
值,以便计算下一个随机数。
拓展
随机数实际上是由内部函数计算得到的,因此根本不是随机的(如果提前知道使用的公式和种子的初值的话),更名为
pseudorandom
可能更准确。
例如,考虑如下函数,用于生成随机字母(A
到 Z
):
const getRandomLetter = () => {
const min = "A".charCodeAt();
const max = "Z".charCodeAt();
return String.fromCharCode(
Math.floor(Math.random() * (1 + max - min)) + min
);
};
该函数不接收任何参数,但却能在每次调用时会产生不同的结果,这一事实清楚地表明,该函数是不纯的。
提示
关于
getRandomLetter()
函数的详细解释,参考 MDN 文档之 random;更多.charCodeAt()
介绍,详见 MDN 文档之 String。
非纯特性可以通过调用函数来传播。如果一个函数用到了一个不纯的函数,它自身就会立即变得不纯。例如,想用函数 getRandomLetter()
来生成随机文件名,并带有可选的给定扩展名。假设代码实现如下:
const getRandomFileName = (fileExtension = "") => {
const NAME_LENGTH = 12;
let namePart = new Array(NAME_LENGTH);
for (let i = 0; i < NAME_LENGTH; i++) {
namePart[i] = getRandomLetter();
}
return namePart.join("") + fileExtension;
};
提示
第五章《声明式编程——一种更好的风格》中,还会利用
map()
函数的特性,介绍一种更偏函数式风格的方法来初始化数组namePart
。
由于用到了非纯函数 getRandomLetter()
,原函数 getRandomFileName()
也变得不纯了,尽管函数运行符合预期,能正确生成完全随机的文件名:
console.log(getRandomFileName(".pdf")); // "SVHSSKHXPQKG.pdf"
console.log(getRandomFileName(".pdf")); // "DCHKTMNWFHYZ.pdf"
console.log(getRandomFileName(".pdf")); // "GBTEFTVVHADO.pdf"
console.log(getRandomFileName(".pdf")); // "ATCBVUOSXLXW.pdf"
console.log(getRandomFileName(".pdf")); // "OIFADZKKNVAH.pdf"
记住这个函数,后续章节我们还将围绕它谈谈单元测试的问题,并在此基础上做一些改动来解决这个问题。
The concern about impurity also extends to functions that access the current time or date, because their results will depend on an outside condition (namely the time of day) that is part of the global state of the application. We could rewrite our isOldEnough() function to remove the dependency upon a global variable, but it wouldn't help much. An attempt could be as follows:
对杂质的关注也延伸到访问当前时间或日期的函数,因为它们的结果将取决于作为应用程序全局状态一部分的外部条件(即一天中的时间)。 我们可以重写我们的 isOldEnough() 函数来消除对全局变量的依赖,但这并没有多大帮助。 尝试如下
非纯特性也可以推广至当前时间或日期的访问,因为它们的结果取决于外部条件(即一天里的时间)——也是应用程序全局状态的一部分。我们也可以重写函数 isOldEnough()
来消除原函数对全局变量的依赖,但这并没有多大帮助。尝试改动代码如下:
const isOldEnough2 = birthYear =>
birthYear <= new Date().getFullYear() - 18;
console.log(isOldEnough2(1960)); // true
console.log(isOldEnough2(2001)); // false
此时解决了一个问题——新的 isOldEnough2()
函数现在更安全了。此外,只要不在元旦午夜前后使用它,函数就会始终返回相同的结果,因此,套用 19 世纪的 Ivory Soap
的口号,可以说该函数 大约 99.44% 是纯的;但是仍有不便:如何测试?万一编写的函数今天运行良好,明年偏就就会测试失败呢?所以还需要做一些工作来解决这个问题,后续会详讲。
还有其他几个同样不纯的函数,例如那些导致 I/O
的函数。如果函数从某个数据源(Web
服务、用户自己、文件或其他源)获取输入,显然返回的结果会有所不同。同时还应该考虑 I/O
搞错的可能,因此调用相同服务或读取相同文件的相同函数可能在某些时候由于不可抗力的因素而调用失败(也可以假设文件系统、数据库、套接字这些可能统统都不可用,此时调用指定的函数可能会产生某个错误,而不是意料中的常量、或不变的结果)。
即使是单纯输出结果、或通常不会在内部(至少以可见方式)更改任何内容的一般安全语句(如 console.log()
),也会导致一些副作用。因为用户确实看到了更改:根据生成的输出结果。
凡此种种,是否意味着永远无法编写出一个程序,既能得到随机数、又能处理日期、还能允许 I/O
操作、并且还能同时使用纯函数呢?答案是否定的——但这确实意味着一些函数是不纯的,使用时必须考虑它们存在的不足;稍后会继续讨论。
纯函数的主要优点在于其没有副作用。调用纯函数时,除了对其传参外,无需担心任何事情。更重要的是,由于纯函数只对您提供的内容起作用,而与其他外部资源无关,从而可以确保函数不会产生任何问题或破坏任何原有逻辑。这还不是纯函数的唯一优势,本节将介绍纯函数的更多知识。
另一种看待本章所述内容的方式,是将纯函数视为 健壮性 的体现。无论纯函数以何种顺序执行,都不会对系统产生任何不良影响。这一点可以进一步扩展:并行执行纯函数也是可以的,不必担心并行运算下的结果与在单线程中得到的结果有所不同。
拓展
只可惜
JavaScript
极力限制开发者编写并行代码,仅能在极其有限的情况下通过 web workers 实现并行逻辑,仅此而已。对于Node
开发者,cluster
集群模块可能会有所助益,尽管它并不是线程的替代品,只不过可以生成多个进程、利用所有线程的CPU
内核罢了。总之,JavaScript
并未提供诸如Java
线程之类的功能特性,并行化并不能算作JavaScript
函数式编程的主要优势。
另一个要记住的点是,纯函数是无需明确指定调用顺序的。就像数学上的表达式 f(2) + f(5)
总是等效于 f(5) + f(2)
,这被称为可交换属性(commutative property)。
但是对非纯函数,结论可能就是错的,例如:
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
就会惊讶不已。
鉴于给定输入的纯函数得到的结果始终相同,利用这一特性,可以缓存函数的返回结果来避免性能开销可能相当高昂的重复运算。像这样,对某表达式求值只需运行一次,后续调用则返回该缓存结果的过程,称为 函数记忆(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 的用法,而不是繁琐的罗马数字。他从一个有关兔子的谜题中,推导出了以他名字命名的序列作为问题的答案。更多斐波那契的生平介绍,详见 [Wiki](https://en.wikipedia.org/wiki/Fibonacci_number#History or https://plus.maths.org/content/life-and-numbers-fibonacci)。
该序列从 0、1 开始,之后每一项都是前两项的和:即 1,接着是 2、3、5、8、13、21,依此类推。利用递归编程实现该系列很简单;第九章《设计函数——递归思想》还将重新讨论这个示例。该序列的直译版代码实现如下:
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
函数是纯函数,我们可以缓存计算出的结果以避免反复运算某个节点值。利用缓存数组,一个可能的代码实现版本如下:
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
而崩溃。借助外部存储设备(如数据库、文件系统或云解决方案)又可能会抵消掉缓存带来的所有性能优势。有一些标准的解决方案(如最终从缓存中删除数据),但这些知识超出了本书讲述的范围;
当然,程序中的每个纯函数并非都要都执行该操作。本例关注的是需要花费一定时间、且频繁调用的函数——若非如此,增加的缓存管理时间很可能会超过其节省下的时间开销。
纯函数还有另一个优点。由于函数需要处理的所有内容都是通过参数提供的,没有任何隐藏的依赖关系,因此阅读源码也就理解了函数想实现的功能。
一个额外的好处:明确一个函数不会访问它的参数之外的任何东西后,使用时就更有信心,不必担心意外的副作用;该函数唯一能完成的逻辑就是文档要求的东西。
单元测试(下一节介绍)也能充当文档,因为它们提供了在给定某些参数时函数返回结果的示例。大多数程序员都相信最好的文档是充满了大量示例的文档,并且每个单元测试都可视为文档示例。
纯函数的另一个优势——也是最重要的优势之一——在于单元测试。纯函数只有一个职责:根据输入得到输出。因此,对纯函数编写测试用例时,由于无需考虑上下文,也无需模拟任何状态,工作量将大大简化。
纯函数只需要关注输入、检查输出即可。所有函数调用都是相互独立的。
至此,我们已经考察了纯函数的几个主要方面。接下来目光转到非纯函数,最后再看看二者的测试工作如何进行。
如果完全避开各种副作用,那么程序将只能使用硬编码输入,无法显示计算结果;同样,大多数网页都将黯然失色:既无法调用任何 Web
服务,也无法更新 DOM
——只有静态页面;而且 Node
代码在服务器端也将毫无意义——因为不执行任何 I/O
操作。
减少副作用是函数式编程一个很好的目标,但过犹不及!接下来,就让我们来考察一下如何避免非纯函数的使用;如果实在避无可避,又该怎样找到最佳方法来圈定或限制其作用范围。
前面章节介绍了使用非纯函数的一些较常见的原因;本节就来看看,在完全消灭非纯函数不现实的情况下,怎样减少它们在代码中的数量。主要方法有两个:
- 避免使用状态;
- 使用一种更通用的注入模式(
injection
)将非纯函数的行为限制在可控范围内;
关于全局状态的使用——获取值也好、设置值也罢,解决方案都是众所周知的。其要点提炼如下:
- 将全局状态所需的任何内容作为参数传给函数;
- 如果函数需要更新状态,不宜直接更新,而应该生成一个新状态来作返回值;
- 应该由调用方接收返回的状态并更新该状态。
这些也是 Redux
中的 reducer
的设计理念。reducer
的签名为 (previousState, action) => newState
,表示接收一个状态值 state
和一个 action
作参数,并返回一个新的状态值 newState
。更具体地说,reducer
不应该简单地更改 previousState
参数;它必须保持不变(第十章《确保纯度——不变性》将详细介绍)。
最早版本的 isOldEnough
函数用到了一个全局变量 limitYear
,改造起来也很简单:将其视为函数参数即可。这样原函数就变成了纯函数,其结果只取决于传入的参数。再进一步,可以将当前年份传入,让函数来处理具体计算,而不是交由调用者亲自计算,代码如下:
const isOldEnough3 = (currentYear, birthYear) => birthYear <= currentYear-18;
很明显,该方案必须更改所有的函数调用,将 limitYear
作为参数传入(也可以使用科里化来解决,第七章《函数转换——科里化与部分应用》会详述)。给 limitYear
赋初值后,和之前一样,其职责依然游离于函数之外,但这样处理已经成功避开了一个陷阱。
不妨在 roundFix
函数上小试牛刀。回忆一下,该函数根据上一次计算的舍入误差的正负,来对累计舍入误差进行修正。虽然无法绕开中间状态,但却可以将当前舍入计算与累计修正计算剥离开。原代码如下:
const roundFix1 = (function() {
let accum = 0;
return n => {
let nRounded = accum > 0 ? Math.ceil(n) : Math.floor(n);
accum += n - nRounded;
return nRounded;
};
})();
新版实现将传入两个参数:
const roundFix2 = (a, n) => {
let r = a > 0 ? Math.ceil(n) : Math.floor(n);
a += n - r;
return {a, r};
};
该函数具体怎么使用呢?其用法是:先初始化累计误差,然后传入该函数,再由调用方完成状态更新:
let accum = 0;
// ...some other code...
let {a, r} = roundFix2(accum, 3.1415);
accum = a;
console.log(accum, r); // 0.1415 3
注意以下几点:
accum
现在是全局状态的一部分;- 根据
roundFix2()
的定义,每次调用该函数都会用到当前accum
的最新的值; - 负责更新全局状态的是调用者自己,而不是
roundFix2()
函数本身。
提示
注意第 5 行解构赋值的用法,可以将函数返回一个以上的结果并轻松赋给不同的变量。更多详情,参考 MDN 官方文档。
改造后的新 roundFix2()
函数就是百分百的纯函数了,测试起来也会很轻松。若要对程序其他部分隐藏 accum
,可以继续使用闭包,但这样会由于函数自身的调用而再次引入不纯的代码。
如果一个函数因为调用了另一个不纯的函数而变得不纯,其中一种解题思路,是将所需函数直接注入原函数。该方案提供了更大的代码灵活性,应对后续变动更轻松,进行单元测试也更简单。
再来回顾一下用于生成随机文件名的示例函数。原函数的问题在于,使用了 getRandomLetter()
函数来生成文件名(第 5 行):
const getRandomFileName = (fileExtension = "") => {
const NAME_LENGTH = 12;
let namePart = new Array(NAME_LENGTH);
for (let i = 0; i < NAME_LENGTH; i++) {
namePart[i] = getRandomLetter();
}
return namePart.join("") + fileExtension;
};
解决该问题的一个思路是用一个外部注入的函数来替换非纯函数;此时需要为之前的 getRandomFileName
函数引入一个新的参数 randomLetterFunc
:
const getRandomFileName2 = (fileExtension = "", randomLetterFunc) => {
const NAME_LENGTH = 12;
let namePart = new Array(NAME_LENGTH);
for (let i = 0; i < NAME_LENGTH; i++) {
namePart[i] = randomLetterFunc();
}
return namePart.join("") + fileExtension;
};
这样就从原函数中移除了原来的非纯函数行为。虽然引入的是一个给定的伪随机函数,但如果返回的一个是固定的、已知的值,那么后续就可以轻松对该函数进行单元测试;后续的示例还将进一步演示具体做法。函数的用法已然改变,此时需要这么写:
let fn = getRandomFileName2(".pdf", getRandomLetter);
如果看上去不太习惯,也可以处理成给 randomLetterFunc
参数提供一个默认值,如下所示(注意第 3 行):
const getRandomFileName2 = (
fileExtension = "",
randomLetterFunc = getRandomLetter
) => {
...
};
当然,这个问题也可以通过部分传参(partial application)来解决,后续第 7 章《函数变换——柯里化及部分传参技术》会进一步介绍。
其实这样做并没有完全避免使用非纯函数。正常情况下,我们还是会通过传入的随机字母生成逻辑来调用 getRandomFileName()
,因此改造后的函数本质上仍然是非纯函数。但是,出于测试方面的考虑,如果传入的是一个返回给定值而非随机值的函数,就可以假定它是个纯函数来进行测试,操作起来就会方便很多。
而对于这一切的始作俑者、非纯函数 getRandomLetter()
又该如何处置呢?可以使用同样的技巧,引入一个参数来生成随机数,从而得到下面的函数新版本(留意第 1 行):
const getRandomLetter = (getRandomInt = Math.random) => {
const min = "A".charCodeAt();
const max = "Z".charCodeAt();
return String.fromCharCode(
Math.floor(getRandomInt() * (1 + max - min)) + min
);
};
对于常规调用,getRandomFileName()
中的 getRandomLetter()
在调用时不带任何参数,也就是说 getRandomLetter()
将按照默认的随机逻辑进行计算;但若要测试该函数是否按预期运行,则可以人为注入一个伪随机函数来进行测试,让该函数返回测试预设的结果,从而充分测试原函数。
这一思路至关重要,在解决其他类似的问题时也有相当广泛的应用。例如,可以提供某个注入函数(injected functions),避免在原函数中直接访问 DOM。这样在后续测试时,要验证被测试函数是否确实完成了它要实现的功能,将会变得非常简单,无需真正与 DOM 进行交互(当然,测试那些与 DOM 相关的注入函数还需要其他的一些方法,但不是此处的重点)。类似的操作也适用于需要更新 DOM、生成新元素以及进行各种 DOM 操作的其他函数——只需引入一些中介函数即可。
让我们通过考虑一个重要的问题来结束本小节的学习:您如何确保一个函数确实是纯函数?为演示该问题的难点,不妨再来看看第 1 章中介绍过了一个简单求和的函数 sum3()
。简便起见,这里改写为箭头函数的形式。问题来了:该函数是纯函数吗?看起来确实像:
const sum3 = (x, y, z) => x + y + z;
让我们考察考察:这个 sum3
函数除了访问其自身的参数外,不涉及外部任何内容,也没有中途修改参数的迹象(暂且不论能不能改(要是能改,情况又如何?));既不执行任何 I/O
操作,也不与之前提到的任何非纯函数或非纯方法打交道。这么一来,判定为纯函数会有什么问题吗?
问题的关键在于你的前提条件。举个例子,谁说 sum3
的参数就一定是数字?您可能会做出让步:好吧,就当它们也可以是字符串吧;可即便如此,函数也仍然是纯函数啊,不是吗?但是(用这种语气说明肯定不对),如果是下面这种情况,答案还会是纯函数吗:
let x = {};
x.valueOf = Math.random;
let y = 1;
let z = 2;
console.log(sum3(x, y, z)); // 3.2034400919849431
console.log(sum3(x, y, z)); // 3.8537045249277906
console.log(sum3(x, y, z)); // 3.0833258308458734
高手支招
注意,上述代码将一个新函数赋给了
x.valueOf
方法。这是对“函数是一等对象”这一知识点的灵活应用。更多相关介绍,详见本书第三章第 3.1.3 节《不必要的错误(An unnecessary mistake)》。
由此可见,sum3()
是不是纯函数,其实取决于传给它的参数是什么;在 JavaScript
中,是完全可以让一个纯函数以非纯函数的方式来运行的!您可能会自我安慰说,肯定不会遇到这样传参的情况,但这些边缘情况(edge cases)往往就是 Bug 的藏身之所;但也不必放弃使用纯函数的想法。尽管 JavaScript
无法让您完全确信写出的代码 始终是纯函数形式 的,但至少只要通过添加一些类型检查(比如使用第 1 章介绍转译工具时提过的 TypeScript
)来有效防止上述情况的发生。
通过前面几节的介绍,想必您以及了解了纯函数和非纯函数各自的特点。在本章的最后一节,让我们再来看看如何测试所有这些函数。