文章目录
- 4.1 纯函数
- 4.1.1. 引用透明 Referential transparency
- 4.1.2. JS 函数中的副作用 Side effects
- 4.1.3. 纯函数的优势 Advantages of pure functions
- 4.2. 非纯函数 Impure functions ✔️
- 4.2.1. 避开非纯函数 Avoiding impure functions
- 1 避免使用状态 Avoiding the usage of state
- 2 注入非纯函数 Injecting impure functions
- 4.2.2. 纯函数的判定 Is your function pure?
(接上篇内容)
4.1 纯函数
4.1.1. 引用透明 Referential transparency
(详见本专栏 【第 012 篇】 )
4.1.2. JS 函数中的副作用 Side effects
(详见本专栏 【第 012 篇】 及 【第 013 篇】)
4.1.3. 纯函数的优势 Advantages of pure functions
(详见本专栏 【第 014 篇】)
4.2. 非纯函数 Impure functions ✔️
如果完全避开各种副作用,那么程序将只能使用硬编码输入,无法显示计算结果;同样,大多数网页都将黯然失色:既无法调用任何 Web
服务,也无法更新 DOM
——只有静态页面;而且 Node
代码在服务器端也将毫无意义——因为不执行任何 I/O
操作。
减少副作用是函数式编程一个很好的目标,但过犹不及!接下来,就让我们来考察一下如何避免非纯函数的使用;如果实在避无可避,又该怎样找到最佳方法来圈定或限制其作用范围。
4.2.1. 避开非纯函数 Avoiding impure functions
前面章节介绍了使用非纯函数的一些较常见的原因;本节就来看看,在完全消灭非纯函数不现实的情况下,怎样减少它们在代码中的数量。主要方法有两个:
- 避免使用状态;
- 使用一种更通用的注入模式(
injection
)将非纯函数的行为限制在可控范围内;
1 避免使用状态 Avoiding the usage of state
关于全局状态的使用——获取值也好、设置值也罢,解决方案都是众所周知的。其要点提炼如下:
- 将全局状态所需的任何内容作为参数传给函数;
- 如果函数需要更新状态,不宜直接更新,而应该生成一个新状态来作返回值;
- 应该由调用方接收返回的状态并更新该状态。
这些也是 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
,可以继续使用闭包,但这样会由于函数自身的调用而再次引入不纯的代码。
2 注入非纯函数 Injecting impure functions
如果一个函数因为调用了另一个不纯的函数而变得不纯,其中一种解题思路,是将所需函数直接注入原函数。该方案提供了更大的代码灵活性,应对后续变动更轻松,进行单元测试也更简单。
再来回顾一下用于生成随机文件名的示例函数。原函数的问题在于,使用了 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 操作的其他函数——只需引入一些中介函数即可。
4.2.2. 纯函数的判定 Is your function pure?
让我们通过考虑一个重要的问题来结束本小节的学习:您如何确保一个函数确实是纯函数?为演示该问题的难点,不妨再来看看第 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
)来有效防止上述情况的发生。
通过前面几节的介绍,想必您以及了解了纯函数和非纯函数各自的特点。在本章的最后一节,让我们再来看看如何测试所有这些函数。
(本节完)