纯函数:JavaScript 函数式编程的确定性基石 —— mostly-adequate-guide 第三章精读
2026/9/20 13:38:17 网站建设 项目流程
  • 文档
  • 教程

【免费下载链接】mostly-adequate-guide

Mostly adequate guide to FP (in javascript)

项目地址:https://gitcode.com/gh_mirrors/mo/mostly-adequate-guide
点击查看免费下载

本文精读开源书籍《Mostly Adequate Guide to Functional Programming》(仓库 mostly-adequate-guide)第三章,围绕“纯函数(Pure Function)”这一函数式编程的奠基性概念展开:先给出严格定义,再用slice/splicecheckAge等真实代码对比纯与不纯的差异,随后从数学函数视角论证“纯函数就是数学函数”,最后系统拆解纯度带来的五大收益:可缓存、可移植、可测试、可推理、可并行。读完本文,你将掌握用“同输入必同输出、无可见副作用”的标尺审视每一段 JS 代码的能力,并为后续柯里化(ch04.md)、函子与 Monad(ch08.md、ch09.md)等章节打好概念地基。

纯函数:先讲清楚“纯净”的定义

在进入函数式编程的世界之前,首先要厘清一个概念——纯函数(pure function)。本书给出的定义是:

纯函数是这样一种函数:给定相同的输入,它总是返回相同的输出,并且不产生任何可观察的副作用。

关键词有两个:确定性(deterministic)无副作用(no observable side effect)。两者缺一不可,共同构成了纯函数的充分必要条件。

slice 与 splice:同一件事的纯与不纯

JavaScript 标准库中有一对绝佳的反例——slicesplice。它们做的事情几乎一样(截取数组片段),但方式截然不同:

  • slice纯的:它不修改原数组,每次给定相同输入都返回相同结果;
  • splice不纯的:它会“咀嚼”原数组(就地修改),并把它永久地改变,这种对调用方可见的改变就是一种“可观察效应”。
const xs = [1,2,3,4,5]; // pure xs.slice(0,3); // [1,2,3] xs.slice(0,3); // [1,2,3] xs.slice(0,3); // [1,2,3] // impure xs.splice(0,3); // [1,2,3] xs.splice(0,3); // [4,5] xs.splice(0,3); // []

注意上面这段输出:splice三次调用的返回值依次是[1,2,3][4,5][]——同样的调用,结果却一次比一次少,因为它每次都在破坏性修改同一个数组。函数式编程厌恶这种“留下烂摊子”的函数,我们追求的是每次都能稳定复现结果、值得信赖的函数,而不是像splice这样在身后留下一地狼藉的函数。

checkAge:可变状态的污染

再看另一个经典例子:

// impure let minimum = 21; const checkAge = age => age >= minimum; // pure const checkAge = (age) => { const minimum = 21; return age >= minimum; };

不纯版本中的checkAge依赖外层可变的minimum变量来决定结果。也就是说,它的输出取决于系统状态,而非仅取决于输入。这种对状态的依赖会引入外部环境,显著增加推理代码时的认知负担。

单看这个例子或许感觉影响不大,但“依赖状态”恰恰是系统复杂度的最大来源之一(参见 Moseley 与 Marks 关于软件系统复杂度的经典研究)。上面那个checkAge,其返回值可能取决于输入之外的各种因素——这不仅让它丧失了纯函数的资格,更让我们每次推理这段软件时都备受折磨。

纯函数版本则完全自给自足:minimum内嵌在函数体内,不再依赖任何外部环境。更进一步,我们可以把状态变量本身做成不可变的——状态永远不会变,纯度自然得以保持。要做到这一点,需要创建一个可冻结的对象:

const immutableState = Object.freeze({ minimum: 21 });

Object.freeze冻结对象后,任何对其属性的写入都会被忽略(严格模式下会抛错),配合局部变量使用,就能在需要“共享配置”时依然保持纯度。

副作用:不只是“功能”,更是“污染源”

要深化对纯函数的直觉,就得把“副作用”这个词拆开看。本书把effect(效应)定义为:计算过程中除了“算出结果”之外发生的一切事情。

效应本身并无原罪——在本书后面的章节里我们会大量使用各种效应。真正背负负面含义的是“side(侧、附带)”这个前缀。作者用了一个生动的比喻:水本身并不是滋生幼虫的温床,是“死水(stagnant)”才招来了蚊群;同理,“副作用”正是你程序里滋生 bug 的温床。

副作用(side effect):在计算结果的过程中,对系统状态产生的改变,或与外部世界发生的可观察交互。

常见的副作用包括但不限于:

  • 修改文件系统
  • 向数据库插入记录
  • 发起 HTTP 调用
  • 数据突变(mutation)
  • 打印到屏幕 / 写日志
  • 获取用户输入
  • 查询 DOM
  • 访问系统状态

这个清单可以无限延伸。任何与函数外部世界的交互都是副作用。这个事实可能让人怀疑:没有副作用还怎么写程序?函数式编程的哲学立场是:副作用是错误行为的主要成因

但这并不意味着我们被禁止使用副作用,而是要把副作用圈养起来,在受控的方式下运行。如何做到?本书会在函子(functor)与 Monad 的章节(ch08.md、ch09.md)中给出系统方案;在学会那些工具之前,现阶段的目标很简单:把“害群之马”般的不纯函数与我们的纯函数分离开来

为什么副作用会让函数失去“纯”的资格?因为纯函数要求“同输入必同输出”,而一旦函数要处理自身局部范围之外的事务,这个保证就不可能成立——外部世界时刻在变,输出自然无法稳定。

纯函数就是数学函数:回到八年级数学

为什么要执着于“同输入必同输出”?答案藏在数学里。数学对函数的定义是:

函数是值之间的一种特殊关系:每一个输入值恰好对应一个输出值。

换句话说,函数就是“输入与输出”两个值之间的映射关系。注意:每个输入恰好有一个输出,但输出并不要求对输入唯一(多个输入可以映射到同一个输出)。下面这张图展示了一个从xy的合法函数:每个输入点都指向唯一的输出点。

作为对比,下面这张图展示的不是函数:因为输入值5同时指向了多个输出,违背了“每个输入恰好一个输出”的规则。

函数可以表示为输入输出对构成的集合:[(1,2), (3,6), (5,10)](这个函数看起来是把输入翻倍);也可以用表格表示:

InputOutput
12
24
36

甚至可以用图像表示,横轴x是输入,纵轴y是输出:

既然“输入决定输出”,实现细节就不再重要了。函数不过是输入到输出的映射,那么理论上我们完全可以用对象字面量来充当函数——用[]取代()

const toLowerCase = { A: 'a', B: 'b', C: 'c', D: 'd', E: 'e', F: 'f', }; toLowerCase['C']; // 'c' const isPrime = { 1: false, 2: true, 3: true, 4: false, 5: true, 6: false, }; isPrime[3]; // true

当然,实际工程中你更想“计算”而不是“手工枚举”,但这个小例子展示了看待函数的另一种视角:函数就是一张查表

你可能会想:“那多参数函数怎么办?”确实,多参数在数学视角下有点麻烦。现阶段可以先把参数打包进数组,或者把arguments对象整体看作“一个输入”。等到本书第四章学习柯里化(currying)时(ch04.md),我们就能直接建模数学意义上的单参数函数了。

这里就是全章的“戏剧性揭晓时刻”:纯函数就是数学函数,而这正是函数式编程的全部要义。用这些“小天使”写程序能带来巨大的收益,下面逐一拆解。

纯度四加一大收益:为什么我们愿意大费周章保纯净

可缓存(Cacheable)

纯函数总能按输入做缓存,最典型的技术叫memoization(记忆化)

const squareNumber = memoize(x => x * x); squareNumber(4); // 16 squareNumber(4); // 16, returns cache for input 4 squareNumber(5); // 25 squareNumber(5); // 25, returns cache for input 5

下面是书中的一个简化实现(社区里有更多健壮的版本):

const memoize = (f) => { const cache = {}; return (...args) => { const argStr = JSON.stringify(args); cache[argStr] = cache[argStr] || f(...args); return cache[argStr]; }; };

实现要点:用JSON.stringify把参数序列化后作为缓存的键,命中即返回缓存值,否则计算结果并存入缓存。从源码角度可以指出两个使用前提:一是键依赖参数序列化结果,因此参数为对象时其键顺序会影响命中率;二是引用类型(如函数)无法被JSON.stringify正确序列化,此时缓存会退化。这些是在工程化使用memoize时需要留意的边界。

更进一步,我们甚至可以把一些不纯的函数通过延迟求值(deferring evaluation)改造成纯函数:

const pureHttpCall = memoize((url, params) => () => $.getJSON(url, params));

有意思的地方在于:我们并没有真的发起 HTTP 请求,而是返回一个“调用时才发请求”的函数。这个函数是纯的——给定相同输入(urlparams),它总是返回相同的输出:那个封装了特定 HTTP 调用的函数。这里memoize缓存的不是 HTTP 调用的结果,而是生成的函数本身。这个技巧眼下看起来用处不大,但后面章节会把它发扬光大。核心结论是:无论一个函数看起来多么“有破坏性”,我们都能缓存它

可移植 / 自文档化(Portable / Self-documenting)

纯函数完全自包含:它需要的一切都被“端到银盘上”递到手里。这意味着依赖是显式的,一眼就能看穿,没有幕后的暗箱操作:

// impure const signUp = (attrs) => { const user = saveUser(attrs); welcomeUser(user); }; // pure const signUp = (Db, Email, attrs) => () => { const user = saveUser(Db, attrs); welcomeUser(Email, user); };

不纯版本藏着掖着:你无从得知saveUserwelcomeUser从哪来、访问了什么。纯版本则必须诚实交代自己的依赖——单看签名就知道它会用到DbEmailattrs,信息量一目了然。

强制“注入依赖”(把依赖作为参数传入)还带来了巨大的灵活性:数据库或邮件客户端被参数化后,换一个Db只需换一个实参;把这段可靠函数复用到新应用时,只要把当时的DbEmail传进去即可。在 JavaScript 语境下,“可移植”还可以是:把函数序列化后经 socket 传输、把应用代码跑在 Web Worker 里——这是一种强大的特性。

反观命令式编程中“典型”的方法与过程,它们通过状态、依赖和可用效应深深扎根于环境之中;而纯函数可以在任何我们想去的地方运行。作者引用了 Erlang 之父 Joe Armstrong 的名言:

面向对象语言的问题在于,它们随身携带所有隐式环境。你想要一根香蕉,得到的却是拿着香蕉的大猩猩……以及整片丛林。

可测试(Testable)

纯函数让测试变得轻松得多:我们不必 mock 一个“真实的”支付网关,也不必在每个测试后断言“世界的状态”,只需要给函数输入、断言输出

事实上,函数式社区已经开创了新的测试工具——用生成的随机输入“轰炸”函数,并断言输出上的性质(property)恒成立。这超出了本书范围,但作者强烈建议读者搜索并尝试Quickcheck——一个专为纯函数环境设计的测试工具。(可参考本仓库 exercises 目录下的章节练习与 test 目录下的测试用例,体会“输入 → 断言输出”的测试风格。)

可推理(Reasonable)

许多人认为纯函数最大的红利是引用透明性(referential transparency):一段代码如果可以被它的求值结果替换而不改变程序行为,那么它就是引用透明的。

由于纯函数没有副作用,它们只能通过输出值影响程序行为;又因为输出值可以仅凭输入值可靠算出,纯函数总是保持引用透明。来看书中的例子(使用immutable库的Map):

const { Map } = require('immutable'); // Aliases: p = player, a = attacker, t = target const jobe = Map({ name: 'Jobe', hp: 20, team: 'red' }); const michael = Map({ name: 'Michael', hp: 20, team: 'green' }); const decrementHP = p => p.set('hp', p.get('hp') - 1); const isSameTeam = (p1, p2) => p1.get('team') === p2.get('team'); const punch = (a, t) => (isSameTeam(a, t) ? t : decrementHP(t)); punch(jobe, michael); // Map({name:'Michael', hp:19, team: 'green'})

decrementHPisSameTeampunch都是纯函数,因而引用透明。我们可以用等式推理(equational reasoning)——用“等量替换等量”的方式来推理代码,就像手工求值,但不受程序求值机制的怪癖干扰。逐步演练:

第一步:内联isSameTeam

const punch = (a, t) => (a.get('team') === t.get('team') ? t : decrementHP(t));

第二步:数据不可变,直接把 team 替换成实际值

const punch = (a, t) => ('red' === 'green' ? t : decrementHP(t));

第三步:'red' === 'green'为 false,删掉整个分支

const punch = (a, t) => decrementHP(t);

第四步:内联decrementHP,punch 变成了“把 hp 减 1”的调用

const punch = (a, t) => t.set('hp', t.get('hp') - 1);

这种推理能力对重构和理解代码极其宝贵。事实上,本书此前已经用等式推理重构过程序——利用加法和乘法的性质(可结合、可交换等)。在后续章节中,这套技巧会被反复使用。

可并行(Parallel Code)

最后是“杀手锏”:任何纯函数都可以并行运行。因为纯函数不需要访问共享内存,从定义上就不会因副作用而产生竞态条件(race condition)

这在服务端 JS 的多线程环境(worker threads)以及浏览器的 Web Worker 中完全可行——只是当前业界因为不纯函数带来的复杂度而普遍回避这种用法。纯度让我们重新拥有了并行的自由。

仓库佐证:本书配套 support 包与柯里化工具

本章末尾预告了下一个工具——柯里化(curry)。这个概念在纯函数的世界里几乎是刚需:为了让多参数函数也能保持“纯函数即数学函数”的形态,我们需要把多参函数转化为一串单参函数。

本仓库配套的 npm 包@mostly-adequate/support(源码见 support/index.js)提供了全书使用的全部工具函数,其实现与附录 A 一一对应。其中curry的源码实现如下(support/index.js):

// curry :: ((a, b, ...) -> c) -> a -> b -> ... -> c function curry(fn) { const arity = fn.length; return function $curry(...args) { if (args.length < arity) { return $curry.bind(null, ...args); } return fn.call(null, ...args); }; }

实现原理:读取原函数的形参数arity,若本次调用参数不足,则用bind固定已传入的参数并返回一个新函数,等待剩余参数;参数凑齐后才真正调用原函数。按 support/README.md 的说明,该包中所有顶层函数都是已经柯里化的,例如mapfilterconcatadd等(同样见附录 A),使用时无需再手动curry

在本书的阅读/练习体系中,你可以按 README.md 的指引安装支持包并运行各章节练习:

npm i @mostly-adequate/support

例如完成exercises/ch04下的练习文件后,运行:

npm run ch04

测试用例位于 test 目录(如test/ch04.js),可用来校验你对每章概念的掌握。这些练习正是把“纯函数”、“柯里化”等抽象概念落到手指尖的最佳途径。

小结:从此与“不纯”分道扬镳

本章我们看清了纯函数是什么,也明白了为什么函数式程序员视它为“猫的晚礼服”——整晚的焦点所在。从这一章起,我们的目标就是用纯函数编写所有代码。当然这需要一些额外的工具来帮忙;在获得那些工具之前,至少要把不纯的函数与其余纯代码分离开来。

单靠纯函数写程序略显费力:数据要在参数间来回传递,不能用状态,更不能碰副作用——这种“自虐”程序怎么写?答案正是下一章的主角:柯里化(Currying)

继续阅读:第四章:柯里化(Currying)

  • 文档
  • 教程

【免费下载链接】mostly-adequate-guide

Mostly adequate guide to FP (in javascript)

项目地址:https://gitcode.com/gh_mirrors/mo/mostly-adequate-guide
点击查看免费下载

相关推荐

上一篇:MathLive终极指南:如何快速为网站添加专业级数学编辑器
下一篇:Spring Boot多数据源下的PageHelper配置技巧与注意事项

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询