【玩转 JS 函数式编程_014】4.1 JavaScript 纯函数的相关概念(下):纯函数的优势

文章目录

  • [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

(详见本专栏 【第 012 篇】【第 013 篇】

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 = 01,其他调用都需要进一步运算,如图 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

纯函数的另一个优势------也是最重要的优势之一------在于单元测试。纯函数只有一个职责:根据输入得到输出。因此,对纯函数编写测试用例时,由于无需考虑上下文,也无需模拟任何状态,工作量将大大简化。

纯函数只需要关注输入、检查输出即可。所有函数调用都是相互独立的。

至此,我们已经考察了纯函数的几个主要方面。接下来目光转到非纯函数,最后再看看二者的测试工作如何进行。

相关推荐
转世成为计算机大神3 分钟前
易考八股文之Java中的设计模式?
java·开发语言·设计模式
宅小海22 分钟前
scala String
大数据·开发语言·scala
qq_3273427324 分钟前
Java实现离线身份证号码OCR识别
java·开发语言
锅包肉的九珍25 分钟前
Scala的Array数组
开发语言·后端·scala
心仪悦悦29 分钟前
Scala的Array(2)
开发语言·后端·scala
yqcoder1 小时前
reactflow 中 useNodesState 模块作用
开发语言·前端·javascript
baivfhpwxf20231 小时前
C# 5000 转16进制 字节(激光器串口通讯生成指定格式命令)
开发语言·c#
许嵩661 小时前
IC脚本之perl
开发语言·perl
长亭外的少年1 小时前
Kotlin 编译失败问题及解决方案:从守护进程到 Gradle 配置
android·开发语言·kotlin
直裾1 小时前
Scala全文单词统计
开发语言·c#·scala