《Mostly Adequate Guide》第五章深度解析:用 compose 函数组合编写优雅的函数式 JavaScript
【免费下载链接】mostly-adequate-guideMostly adequate guide to FP (in javascript)项目地址: https://gitcode.com/gh_mirrors/mo/mostly-adequate-guide
导读:本章是《Mostly Adequate Guide to FP (in javascript)》的核心章节,围绕compose(函数组合)展开:从一行变参实现到结合律、pointfree 风格、调试技巧,再到范畴论(Category Theory)的三大公理。本文以 ch05.md 为骨架,结合仓库中 support/index.js 的源码实现与 exercises/ch05 的练习/校验,完整还原每个代码示例,并给出可直接运行的演示与练习解答,帮助你在项目中真正用组合式思维组织纯函数。
为什么把组合当作第一设计原则
函数式编程中,纯函数是"输入到输出的管道"。单个管道能力有限,而compose就是把多条管道焊接起来:数据从右向左流过各函数,最终得到一个新函数。第五章开篇直言:"We hold composition as a design principle above all others"——组合是高于其他一切的设计原则,因为它让应用保持简单、可推理(ch05.md 的 In Summary 一节)。
这一原则直接服务于本书接下来的内容:第六章用它构建示例应用,第七章以后的范畴、函子、单子,全部建立在"组合 + 同一律"这两个范畴论公理之上。
组合的本质:从二元 compose 到变参 compose
先看一行"超级赛亚人"实现
仓库 support/index.js 中,本书的正式compose就是章节开头展示的那一行变参实现:
// compose :: ((a -> b), (b -> c), ..., (y -> z)) -> a -> z const compose = (...fns) => (...args) => fns.reduceRight((res, fn) => [fn.call(null, ...res)], args)[0];拆开看三步:
(...fns)收集要组合的所有函数;(...args)返回的新函数接收初始数据;reduceRight从右往左依次调用每个函数:每次用数组[fn.call(null, ...res)]包裹结果,是为了让fn可以接收上一个函数返回的多个值(即使res是数组,也会被展开成多个参数);最后[0]取出最终值。
用fn.call(null, ...res)而不是直接fn(...res),是刻意以null作为this,确保被组合的函数不依赖调用上下文。
二元版本更直观
章节先用一个简单的二元版本来建立直觉:
const compose2 = (f, g) => x => f(g(x));f和g是函数,x是被"管道输送"的值:先执行g(x),再把结果交给f。章节把组合比作"函数繁殖"(Functional Husbandry)——你像育种师一样挑选两个函数,把它们的特性揉合成一个全新函数:
const toUpperCase = x => x.toUpperCase(); const exclaim = x => `${x}!`; const shout = compose(exclaim, toUpperCase); shout('send in the clowns'); // "SEND IN THE CLOWNS!"注意执行顺序:toUpperCase先跑,exclaim后跑,形成从右到左的数据流。这也呼应了第四章"柯里化"中"数据放最后"的约定:柯里化函数 + 右到左组合,让每个中间函数都只接收一个参数。
右到左 vs 嵌套调用
同样逻辑若不用compose,就得写成"从内到外"的嵌套:
const shout = x => exclaim(toUpperCase(x));章节用last = compose(head, reverse)说明顺序的重要性:
const head = x => x[0]; const reverse = reduce((acc, x) => [x, ...acc], []); const last = compose(head, reverse); last(['jumpkick', 'roundhouse', 'uppercut']); // 'uppercut'reverse翻转列表,head取首个元素,组合出"取最后一个元素"的last。章节也坦承这是"有效但低效"的实现——教学上胜在清晰。仓库 support/index.js 实际提供的是直接索引的last = xs => xs[xs.length - 1],教学示例则展示了如何用组合从零构建语义。
结合律:组合可以任意分组
对任意组合都成立的核心性质是结合律(associativity):
compose(f, compose(g, h)) === compose(compose(f, g), h);意思是:组合内部怎么加括号都不影响结果。于是下面两种写法等价:
compose(toUpperCase, compose(head, reverse)); // 或 compose(compose(toUpperCase, head), reverse);正是有了结合律,才允许使用变参的compose一次组合任意多个函数,让库自己决定如何分组:
const arg = ['jumpkick', 'roundhouse', 'uppercut']; const lastUpper = compose(toUpperCase, head, reverse); const loudLastUpper = compose(exclaim, toUpperCase, head, reverse); lastUpper(arg); // 'UPPERCUT' loudLastUpper(arg); // 'UPPERCUT!'章节指出:这就是 lodash、underscore、ramda 等库中标准的compose形态(原文链接见 ch05.md 文末引用区)。
结合律的实战价值:自由重构
结合律带来一个实用福利:任意一组函数都可以被抽出来、捆成独立的组合,再参与更大的组合。章节给出了同一函数的三组等价写法:
// 写法一:一口气写全 const loudLastUpper = compose(exclaim, toUpperCase, head, reverse); // 写法二:抽出 last const last = compose(head, reverse); const loudLastUpper = compose(exclaim, toUpperCase, last); // 写法三:再抽出 angry const last = compose(head, reverse); const angry = compose(exclaim, toUpperCase); const loudLastUpper = compose(angry, last);"没有对错之分,只是按自己喜欢的方式拼乐高"。通常更推荐抽出last、angry这类可复用的组合——这相当于 Fowler《重构》中的Extract Function手法,只不过函数式版本不需要操心对象状态(原文引用见 ch05.md)。
Pointfree 风格:永远不提数据
Pointfree(无点式)指函数定义中从不提及它操作的数据。它依赖三件事协同:一等函数、柯里化、组合。章节给出经典对比:
// 不 pointfree:显式提到数据 word const snakeCase = word => word.toLowerCase().replace(/\s+/ig, '_'); // pointfree:只描述函数之间的管道 const snakeCase = compose(replace(/\s+/ig, '_'), toLowerCase);注意replace是柯里化的,先部分应用正则,得到"等待字符串数据"的函数。pointfree 版本在构造时根本不需要word存在,而 pointful 版本必须先拿到word才能组装逻辑。
章节特别提示:pointfree 版的replace与toLowerCase定义在 appendix_c.md(Pointfree Utilities),这些工具同时作为练习的全局上下文提供(见 exercises/support.js)。仓库实现如下(support/index.js、support/index.js):
// replace :: RegExp -> String -> String -> String const replace = curry((re, rpl, str) => str.replace(re, rpl)); // toLowerCase :: String -> String const toLowerCase = s => s.toLowerCase();再看出生名缩写示例:
// 不 pointfree const initials = name => name.split(' ').map(compose(toUpperCase, head)).join('. '); // pointfree(用附录 C 的 intercalate 代替 join,join 留给第 9 章的 Monad 语义) const initials = compose(intercalate('. '), map(compose(toUpperCase, head)), split(' ')); initials('hunter stockton thompson'); // 'H. S. T'仓库中intercalate = curry((str, xs) => xs.join(str))(support/index.js),map则是柯里化的函子映射curry((fn, f) => f.map(fn))(support/index.js),split = curry((sep, str) => str.split(sep))(support/index.js)。
章节对 pointfree 的态度相当务实:它是函数式代码的试金石——能用 pointfree 写出,说明你拥有"小函数输入到输出"的形态(while 循环这种东西就无法被组合);但它也是双刃剑,可能掩盖意图。并非所有函数式代码都必须 pointfree,能用的地方用,否则就用普通函数。
调试组合:先柯里化,再用 trace
常见错误:把二元函数直接塞进组合
最常见的坑是把map这种二元函数不经过部分应用就放进组合:
// 错:angry 只被部分应用,map 拿到了数组,语义全乱 const latin = compose(map, angry, reverse); latin(['frog', 'eyes']); // error // 对:每个函数都期望接收 1 个参数 const latin = compose(map(angry), reverse); latin(['frog', 'eyes']); // ['EYES!', 'FROG!']trace:在管道中窥探数据
调试组合时,可以用这个刻意"不纯"的辅助函数观察某一节点的数据(它打印后原样返回,方便插进任意位置):
const trace = curry((tag, x) => { console.log(tag, x); return x; });用dasherize复现经典报错:
const dasherize = compose( intercalate('-'), toLower, split(' '), replace(/\s{2,}/ig, ' '), ); dasherize('The world is a vampire'); // TypeError: Cannot read property 'apply' of undefined报错来自toLower直接作用在数组上(String.prototype.toLowerCase不存在),插一个trace验证:
const dasherize = compose( intercalate('-'), toLower, trace('after split'), split(' '), replace(/\s{2,}/ig, ' '), ); dasherize('The world is a vampire'); // after split [ 'The', 'world', 'is', 'a', 'vampire' ]确认split返回的是数组,因此toLower需要换成map(toLower):
const dasherize = compose( intercalate('-'), map(toLower), split(' '), replace(/\s{2,}/ig, ' '), ); dasherize('The world is a vampire'); // 'the-world-is-a-vampire'章节指出,Haskell、PureScript 等语言同样提供类似的调试工具。trace之所以"不纯",是因为它引入了console.log副作用——但它只存在于开发期,帮助你在组合管道任意节点观测数据流。
范畴论:组合背后的数学公理
组合之所以可靠,是因为它由**范畴论(Category Theory)**背书。范畴论是数学中抽象的抽象,能统一集合论、类型论、群论、逻辑学等领域的概念。下图是章节展示的跨领域概念对照表:
一个**范畴(category)**由四个要素组成:
- 对象的集合:这里的对象就是数据类型,如
String、Boolean、Number、Object。可以把类型看作所有可能值的集合,例如Boolean即{true, false},这样就能借用集合论来推理; - 态射的集合:即我们日常使用的纯函数;
- 态射上的组合运算:就是
compose。范畴论要求组合必须满足结合律——这正是上一节"结合律"不是巧合、而是公理的原因; - 一个特殊的态射:同一态射(identity):即
id函数。
组合过程的示意图如下(先经g得到中间类型,再经f得到最终类型):
对应的具体代码:
const g = x => x.length; // String -> Number const f = x => x === 4; // Number -> Boolean const isFourLetterWord = compose(f, g); // String -> Boolean同一态射 id
const id = x => x;id看似无用,但它是范畴定义的必要组成,也是 pointfree 代码中"值的替身"。它必须与compose和谐相处,对任意一元函数f恒有:
compose(id, f) === compose(f, id) === f;这与数字的"乘以 1 不变"如出一辙。仓库 support/index.js 提供了对应的identity = x => x,它也是后续章节里join、sequence等实现的基石(例如 support/index.js 的traverse(of, fn)就用identity作为默认映射)。
除了"类型 + 函数"这个范畴,章节还列举了其他范畴:有向图(节点为对象、边为态射、组合即路径拼接)、数字配>=偏序(任意偏序/全序都能构成范畴)等。但对本书而言,只需关心上述这一个范畴——它给出组合的两条公理:结合律与同一律。
章节小结
组合像一系列管道,数据必然从输入流向输出;纯函数本质就是"输入到输出",切断管道等于舍弃输出、让软件失去意义。因此组合被奉为最高设计原则:它让应用保持简单、可推理;范畴论则在后续章节承担应用架构、副作用建模与正确性保证的重任。下一站是第六章"示例应用",把这些组合技能投入实战(ch06.md)。
配套练习:用组合重构汽车数据处理
章节末尾有三道练习,围绕如下 Car 数据结构:
{ name: 'Aston Martin One-77', horsepower: 750, dollar_value: 1850000, in_stock: true, }练习 A:isLastInStock
用compose()重写(初始代码见 exercise_a.js):
// isLastInStock :: [Car] -> Boolean const isLastInStock = (cars) => { const lastCar = last(cars); return prop('in_stock', lastCar); };解答(solution_a.js):
const isLastInStock = compose(prop('in_stock'), last);右到左读:先last取最后一辆车,再prop('in_stock')取出库存字段。校验脚本 validation_a.js 专门断言了内部调用顺序:isLastInStock.callees必须依次为['last', 'prop'],且对cars.slice(0, 3)返回真、对cars.slice(3)返回假——若顺序写反会得到错误提示 "functions are composed from right to left!"。由此也可以确认:仓库通过函数包装记录callees调用链来做自动化判题。
练习 B:averageDollarValue
借助average辅助函数把求平均车价重构为组合(初始代码见 exercise_b.js):
const average = xs => reduce(add, 0, xs) / xs.length; const averageDollarValue = (cars) => { const dollarValues = map(c => c.dollar_value, cars); return average(dollarValues); };解答(solution_b.js):
const averageDollarValue = compose(average, map(prop('dollar_value')));左到右读:map(prop('dollar_value'))提取每辆车价格,average求平均。这里再次看到"柯里化 + 数据最后"的配合:map先接收映射函数,等数组数据流进来。
练习 C:fastestCar(点自由风格)
用compose()及其他函数重构,提示可用append(初始代码见 exercise_c.js):
const fastestCar = (cars) => { const sorted = sortBy(car => car.horsepower, cars); const fastest = last(sorted); return concat(fastest.name, ' is the fastest'); };解答(solution_c.js):
const fastestCar = compose( append(' is the fastest'), prop('name'), last, sortBy(prop('horsepower')), );右到左读:sortBy(prop('horsepower'))按马力排序 →last取最快 →prop('name')取名字 →append(' is the fastest')拼接描述。其中append = flip(concat)(support/index.js),即翻转参数后的concat,让"待拼接后缀"先被固定、数据流进来时直接接在后面,正好契合右到左的组合方向;sortBy的实现在 support/index.js。
如何运行这些练习
- 练习的初始代码、解答与校验一一对应,位于 exercises/ch05 目录(exercise_a/b/c.js、solution_a/b/c.js、validation_a/b/c.js);
- 全局可用的
compose、curry、last、prop、map、sortBy、append等工具来自 exercises/support.js,其实现与 support/index.js 一致,完整清单可对照 appendix_c.md 的 Pointfree Utilities; - 目录 exercises/test(对应根目录下 ch05 的测试)负责自动化验证解答是否符合"右到左组合 + 柯里化"的预期。
更进一步:组合在全书中的位置
- 前置知识:第四章的柯里化(ch04.md)——
curry让map、replace等函数"先收参数、后等数据",是 pointfree 组合的前提; - 后续章节:第六章示例应用(ch06.md)将组合投入实战;第七章以后的函子、应用函子、单子都会复用本章的
compose与id两条公理(可观察 support/index.js 中safeHead = compose(Maybe.of, head)这类组合的广泛使用); - 组合的数学保证:结合律 + 同一律,正是范畴论给函数式架构带来的"正确性保险"。
【免费下载链接】mostly-adequate-guideMostly adequate guide to FP (in javascript)项目地址: https://gitcode.com/gh_mirrors/mo/mostly-adequate-guide
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考