ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

纯函数:JavaScript 函数式编程的确定性基石 —— mostly-adequate-guide 第三章精读

纯函数:JavaScript 函数式编程的确定性基石 —— mostly-adequate-guide 第三章精读 文档教程【免费下载链接】mostly-adequate-guideMostly 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/splice、checkAge等真实代码对比纯与不纯的差异随后从数学函数视角论证“纯函数就是数学函数”最后系统拆解纯度带来的五大收益可缓存、可移植、可测试、可推理、可并行。读完本文你将掌握用“同输入必同输出、无可见副作用”的标尺审视每一段 JS 代码的能力并为后续柯里化ch04.md、函子与 Monadch08.md、ch09.md等章节打好概念地基。纯函数先讲清楚“纯净”的定义在进入函数式编程的世界之前首先要厘清一个概念——纯函数pure function。本书给出的定义是纯函数是这样一种函数给定相同的输入它总是返回相同的输出并且不产生任何可观察的副作用。关键词有两个确定性deterministic与无副作用no observable side effect。两者缺一不可共同构成了纯函数的充分必要条件。slice 与 splice同一件事的纯与不纯JavaScript 标准库中有一对绝佳的反例——slice与splice。它们做的事情几乎一样截取数组片段但方式截然不同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中给出系统方案在学会那些工具之前现阶段的目标很简单把“害群之马”般的不纯函数与我们的纯函数分离开来。为什么副作用会让函数失去“纯”的资格因为纯函数要求“同输入必同输出”而一旦函数要处理自身局部范围之外的事务这个保证就不可能成立——外部世界时刻在变输出自然无法稳定。纯函数就是数学函数回到八年级数学为什么要执着于“同输入必同输出”答案藏在数学里。数学对函数的定义是函数是值之间的一种特殊关系每一个输入值恰好对应一个输出值。换句话说函数就是“输入与输出”两个值之间的映射关系。注意每个输入恰好有一个输出但输出并不要求对输入唯一多个输入可以映射到同一个输出。下面这张图展示了一个从x到y的合法函数每个输入点都指向唯一的输出点。作为对比下面这张图展示的不是函数因为输入值5同时指向了多个输出违背了“每个输入恰好一个输出”的规则。函数可以表示为输入输出对构成的集合[(1,2), (3,6), (5,10)]这个函数看起来是把输入翻倍也可以用表格表示InputOutput122436甚至可以用图像表示横轴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 请求而是返回一个“调用时才发请求”的函数。这个函数是纯的——给定相同输入url和params它总是返回相同的输出那个封装了特定 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); };不纯版本藏着掖着你无从得知saveUser、welcomeUser从哪来、访问了什么。纯版本则必须诚实交代自己的依赖——单看签名就知道它会用到Db、Email和attrs信息量一目了然。强制“注入依赖”把依赖作为参数传入还带来了巨大的灵活性数据库或邮件客户端被参数化后换一个Db只需换一个实参把这段可靠函数复用到新应用时只要把当时的Db和Email传进去即可。在 JavaScript 语境下“可移植”还可以是把函数序列化后经 socket 传输、把应用代码跑在 Web Worker 里——这是一种强大的特性。反观命令式编程中“典型”的方法与过程它们通过状态、依赖和可用效应深深扎根于环境之中而纯函数可以在任何我们想去的地方运行。作者引用了 Erlang 之父 Joe Armstrong 的名言面向对象语言的问题在于它们随身携带所有隐式环境。你想要一根香蕉得到的却是拿着香蕉的大猩猩……以及整片丛林。可测试Testable纯函数让测试变得轻松得多我们不必 mock 一个“真实的”支付网关也不必在每个测试后断言“世界的状态”只需要给函数输入、断言输出。事实上函数式社区已经开创了新的测试工具——用生成的随机输入“轰炸”函数并断言输出上的性质property恒成立。这超出了本书范围但作者强烈建议读者搜索并尝试Quickcheck——一个专为纯函数环境设计的测试工具。可参考本仓库 exercises 目录下的章节练习与 test 目录下的测试用例体会“输入 → 断言输出”的测试风格。可推理Reasonable许多人认为纯函数最大的红利是引用透明性referential transparency一段代码如果可以被它的求值结果替换而不改变程序行为那么它就是引用透明的。由于纯函数没有副作用它们只能通过输出值影响程序行为又因为输出值可以仅凭输入值可靠算出纯函数总是保持引用透明。来看书中的例子使用immutable库的Mapconst { 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})decrementHP、isSameTeam、punch都是纯函数因而引用透明。我们可以用等式推理equational reasoning——用“等量替换等量”的方式来推理代码就像手工求值但不受程序求值机制的怪癖干扰。逐步演练第一步内联isSameTeamconst 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);第四步内联decrementHPpunch 变成了“把 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 的说明该包中所有顶层函数都是已经柯里化的例如map、filter、concat、add等同样见附录 A使用时无需再手动curry。在本书的阅读/练习体系中你可以按 README.md 的指引安装支持包并运行各章节练习npm i mostly-adequate/support例如完成exercises/ch04下的练习文件后运行npm run ch04测试用例位于 test 目录如test/ch04.js可用来校验你对每章概念的掌握。这些练习正是把“纯函数”、“柯里化”等抽象概念落到手指尖的最佳途径。小结从此与“不纯”分道扬镳本章我们看清了纯函数是什么也明白了为什么函数式程序员视它为“猫的晚礼服”——整晚的焦点所在。从这一章起我们的目标就是用纯函数编写所有代码。当然这需要一些额外的工具来帮忙在获得那些工具之前至少要把不纯的函数与其余纯代码分离开来。单靠纯函数写程序略显费力数据要在参数间来回传递不能用状态更不能碰副作用——这种“自虐”程序怎么写答案正是下一章的主角柯里化Currying。继续阅读第四章柯里化Currying赞分享文档教程【免费下载链接】mostly-adequate-guideMostly adequate guide to FP (in javascript)项目地址https://gitcode.com/gh_mirrors/mo/mostly-adequate-guide点击查看免费下载相关推荐JavaScript函数式编程完全指南mostly-adequate-guide深度解析JavaScript函数式编程完全指南mostly adequate guide深度解析 JavaScript函数式编程是一个强大而优雅的编程范式它通过数学人工智能AI AgentAI 安全治理策略引擎Agent 沙箱认证鉴权5个关键技巧从mostly-adequate-guide学习纯函数编程5个关键技巧从mostly adequate guide学习纯函数编程 纯函数编程是函数式编程的核心概念它能显著提升代码的可维护性、可测试性和可靠性。通过m文档教程终极Superfish下拉菜单插件教程从零开始构建专业导航系统终极Superfish下拉菜单插件教程从零开始构建专业导航系统 Superfish是一款功能强大的jQuery下拉菜单插件专为现代网站导航设计而生能够显著前端UI库/组件上一篇MathLive终极指南如何快速为网站添加专业级数学编辑器下一篇Spring Boot多数据源下的PageHelper配置技巧与注意事项创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表