欢迎您访问 最编程 本站为您分享编程语言代码,编程技术文章!
您现在的位置是: 首页

玩转JS函数式编程_013] 4.1 JavaScript纯函数相关概念(中4.1 JavaScript 纯函数相关概念(中):函数副作用的几种具体表现

最编程 2024-10-15 06:58:20
...

文章目录

  • 4.1.2. 副作用 Side effects
    • 1 常见副作用 Usual side effects(详见上篇)
    • 2 全局状态下的副作用 Global state
    • 3 内部状态下的副作用 Inner state
    • 4 改变参数导致的副作用 Argument mutation
    • 5 一些棘手的函数 Troublesome functions

(接上篇内容)

4.1.2. 副作用 Side effects

1 常见副作用 Usual side effects(详见上篇)

(详见本专栏 第 012 篇《【玩转 JS 函数式编程_012】第四章 行为得体的纯函数 + 4.1 纯函数的相关概念(上)》


2 全局状态下的副作用 Global state

在上述所有要点中,产生副作用的最常见原因,是使用了与程序其他模块共享了全局状态的非局部变量。根据定义,纯函数总是在给定相同入参的情况下返回相同的出参值。如果一个函数引用了其内部状态之外的任何东西,则会自动变为不纯函数;这也为后续调试制造了障碍:要了解一个函数实现了什么功能,必须理解该状态如何获取到当前最新的值——这意味着必须理解在这之前的所有历史代码逻辑:这可不是个轻松活。

让我们编写一个函数,通过检查一个人是否至少出生于 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 是更好的解决方案),参数仍然是相同的;常量不会改变,所以仍然是纯函数。

了解了全局状态引起的副作用,再来看看函数内部状态的问题。

3 内部状态下的副作用 Inner state

副作用的概念还可以推广到保存了本地状态、以备后续调用的内部变量。此时外部状态没有变化,但由于函数的返回值所隐含的后续状态差异,仍有可能引入副作用。不妨假设一个舍入函数 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 的正负来决定调用 ceilfloor,然后用 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 被收至 3accum 变为负数;
第三轮,accum 为负,相同的值 2.71828 被舍入为 2——相同的输入,却得到了不同的值。

可见,由于函数的结果取决于其 内部状态,同样的参数在累计误差的作用下,可以向上或向下舍入而得到不同的结果。

提示

像这样使用内部状态,也是许多 FP 开发者认为使用对象可能不太好的原因。在面向对象编程中,开发者习惯于存储信息到某个属性,以备后续调用;然而,这种做法被认为是不纯的(impure),因为重复的方法调用也可能返回不同的值,尽管传入了相同的参数。

除了全局及内部状态下的副作用,还有其他情况也可能存在副作用,比如改变传入的参数值的情况。一起来看看吧。

4 改变参数导致的副作用 Argument mutation

不纯函数会修改参数值——这一情况也要引起重视。在 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"]

至此,我们讨论了修改函数自身参数引起的副作用,再来考察最后一种情况:函数*为不纯函数。

5 一些棘手的函数 Troublesome functions

最后,一些函数本身也会带来问题。例如,随机函数 Math.random() 本身就是不纯的:它并不总是返回相同的值,否则就与设计初衷相违背。该函数每调用一次,就会修改一个全局 seed 值,以便计算下一个随机数。

拓展

随机数实际上是由内部函数计算得到的,因此根本不是随机的(如果提前知道使用的公式和种子的初值的话),更名为 pseudorandom 可能更准确。

例如,考虑如下函数,用于生成随机字母(AZ):

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"

记住这个函数,后续章节我们还将围绕它谈谈单元测试的问题,并在此基础上做一些改动来解决这个问题。

非纯特性也可以推广至当前时间或日期的访问,因为它们的结果取决于外部条件(即一天里的时间)——也是应用程序全局状态的一部分。我们也可以重写函数 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 操作、并且还能同时使用纯函数呢?答案是否定的——但这确实意味着一些函数是不纯的,使用时必须考虑它们存在的不足;稍后会继续讨论。