第05章:组合式编程
函数培育艺术
核心函数compose实现如下:
const compose = (...fns) => (...args) => fns.reduceRight((res, fn) => [fn.call(null, ...res)], args)[0];...别被吓到!这是compose的究极进阶形态(灵感来自《龙珠》超级赛亚人设定)。为了便于理解,我们先忽略可变参数实现,分析仅能组合两个函数的简化版本。一旦掌握该逻辑,便能推广至任意数量函数的组合(甚至可数学证明)! 为方便读者,以下提供更友好的compose定义:
const compose2 = (f, g) => x => f(g(x));f和g为函数类型,x指代流经该组合的值
组合过程如同函数培育:作为函数繁育者,您挑选两个具备所需特性的函数,糅合演化出全新函数。具体用法示例如下:
const toUpperCase = x => x.toUpperCase();
const exclaim = x => `${x}!`;
const shout = compose(exclaim, toUpperCase);
shout('send in the clowns'); // "SEND IN THE CLOWNS!"两个函数组合后生成新函数,这符合类型单元的组合规律:您不会把两个乐高积木拼接后得到一个林肯积木。这种现象背后存在深刻的数学定律,我们将在后续探索。
在compose定义中,g先于f执行,形成数据从右向左流动。这种写法比多层嵌套调用更易读。若不使用compose,等效代码为:
const shout = x => exclaim(toUpperCase(x));执行顺序右→左代替了内→外,虽然看似逆向(嘘!),但序列重要性在下例中体现明显:
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功能。此时函数执行序列至关重要。虽然可定义左→右版本,但现有形式更贴近数学原义。事实上,组合概念直接源自数学教材。现在正是探讨组合属性的好时机。
// associativity
compose(f, compose(g, h)) === compose(compose(f, g), h);组合满足结合律,即分组方式不影响最终结果。例如字符串大写转换可写作:
compose(toUpperCase, compose(head, reverse));
// or
compose(compose(toUpperCase, head), reverse);由于组合调用分组不影响结果,我们可以安全地使用可变参数版compose:
// previously we'd have to write two composes, but since it's associative,
// we can give compose as many fn's as we like and let it decide how to group them.
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等库的标准实现一致。
结合律的妙处在于:任意函数组都可独立提取并打包为新组合。我们对前例作重构演示:
const loudLastUpper = compose(exclaim, toUpperCase, head, reverse);
// -- or ---------------------------------------------------------------
const last = compose(head, reverse);
const loudLastUpper = compose(exclaim, toUpperCase, last);
// -- or ---------------------------------------------------------------
const last = compose(head, reverse);
const angry = compose(exclaim, toUpperCase);
const loudLastUpper = compose(angry, last);
// more variations...重构无绝对对错,关键是灵活组装函数单元。通常建议按last、angry等可复用模式分组。熟悉Fowler《重构》的读者会发现,该过程类似“函数提取”模式,只不过无需关注对象状态。
无点风格
无点风格的精髓在于避免显式处理数据。一等函数、柯里化与组合的协同作用,共同铸就这一风格。
提示:
replace与toLowerCase的无点风格版本详见附录C-实用工具,请随时查阅!
// not pointfree because we mention the data: word
const snakeCase = word => word.toLowerCase().replace(/\s+/ig, '_');
// pointfree
const snakeCase = compose(replace(/\s+/ig, '_'), toLowerCase);注意我们对replace进行部分应用的方式:每个单参数函数依次处理数据流。柯里化技术让每个函数准备好接收数据→处理→传递的过程。无点版本无需提前绑定word变量,相比显式处理更加抽象。
再看另一示例:
// not pointfree because we mention the data: name
const initials = name => name.split(' ').map(compose(toUpperCase, head)).join('. ');
// pointfree
// NOTE: we use 'intercalate' from the appendix instead of 'join' introduced in Chapter 09!
const initials = compose(intercalate('. '), map(compose(toUpperCase, head)), split(' '));
initials('hunter stockton thompson'); // 'H. S. T'无点风格通过消除冗余命名提升代码简洁性与通用性。这种风格是函数式代码的试金石,可验证函数是否符合输入→输出的简洁形式(例如无法组合while循环)。但需警惕:无点风格可能影响可读性,并非所有场景都适用。我们的原则是:可用则用,但不必强求。
调试技巧
常见错误示例:未对双参数函数map进行部分应用就尝试组合。
// wrong - we end up giving angry an array and we partially applied map with who knows what.
const latin = compose(map, angry, reverse);
latin(['frog', 'eyes']); // error
// right - each function expects 1 argument.
const latin = compose(map(angry), reverse);
latin(['frog', 'eyes']); // ['EYES!', 'FROG!'])调试组合函数时,可利用以下非纯追踪函数观察数据流(慎用):
const trace = curry((tag, x) => {
console.log(tag, x);
return x;
});
const dasherize = compose(
intercalate('-'),
toLower,
split(' '),
replace(/\s{2,}/ig, ' '),
);
dasherize('The world is a vampire');
// TypeError: Cannot read property 'apply' of undefined当组合异常时,使用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' ]症结显见:应对数组使用map调用toLower。
const dasherize = compose(
intercalate('-'),
map(toLower),
split(' '),
replace(/\s{2,}/ig, ' '),
);
dasherize('The world is a vampire'); // 'the-world-is-a-vampire'trace函数帮助在调试时查看特定节点数据。Haskell、PureScript等语言也提供类似开发辅助工具。
组合将作为程序构建的核心工具,其背后的强大数理保障为我们的开发护航。现在让我们深入理论层面。
范畴论基础
范畴论是从集合论、类型论、群论、逻辑学等领域抽象出的数学分支,通过对象(Object)、态射(Morphism)及变换刻画跨领域概念。不同理论中对应范畴论的概念映射如图所示:

请不必惊慌,无需精通所有领域知识。此图表旨在展示各领域的概念冗余,说明范畴论追求理论统一的理念。
范畴的定义包含以下要素:
- 对象的集合
- 态射的集合
- 态射的组合规则
- 特定的恒等态射
范畴论具有高度抽象性,但我们将聚焦类型与函数的应用场景。
对象的集合 对象对应数据类型,如String、Boolean、Number等。数据类型可视为所有可能值的集合(例如布尔类型是{true, false},数字类型是所有数值的集合)。类型即集合的观点便于应用集合论方法。
态射的集合 态射即日常使用的纯函数。
态射的组合规则 这正是我们的新武器——compose。如前所述,组合满足结合律,这恰恰体现范畴论对组合的基本要求。
组合过程示意图:


代码实例演示:
const g = x => x.length;
const f = x => x === 4;
const isFourLetterWord = compose(f, g);恒等态射 引入id函数:仅执行输入值的无损传递。定义如下:
const id = x => x;可能疑惑:“这玩意儿有何用?”我们将在后续章节广泛使用它,目前可将其视为值的替身——一种伪装成数据的函数。
id必须与组合协同工作。任一元函数f满足以下特性:
// identity
compose(id, f) === compose(f, id) === f;
// true这类同于数字的单位元性质!若理解困难请稍作思考——我们很快会高频使用id,目前只需将其视为值的函数替身,这在无点编程中极为实用。
至此我们建立了类型与函数的范畴体系。初次接触可能仍感抽象,本书将逐步深化理解。至少在本章,您应认识到其提供的组合智慧:结合律与单位元特性。
其他范畴示例:有向图(节点为对象,边为态射,组合即路径连接);以数字为对象、≥关系为态射的序范畴。本书主要关注上述基础范畴,其余暂时搁置。
本章总结
组合好似连接函数的管道网络,数据依纯函数特性(输入→输出)在应用中流动,切断该链条将导致功能失效。
组合被奉为核心设计原则,因其能保障程序的简洁性与合理性。范畴论将在架构设计、副作用处理与正确性验证中发挥关键作用。
目前我们已经为实践应用做好了理论准备,即将进入案例实战阶段。
练习题
下列练习中,汽车对象(Car)的结构如下:
{
name: 'Aston Martin One-77',
horsepower: 750,
dollar_value: 1850000,
in_stock: true,
}开始练习!
使用compose()重构下方函数
const isLastInStock = (cars) => {
const lastCar = last(cars);
return prop('in_stock', lastCar);
};
给定函数如下:
const average = xs => reduce(add, 0, xs) / xs.length;开始练习!
使用辅助函数average以组合方式重构averageDollarValue
const averageDollarValue = (cars) => {
const dollarValues = map(c => c.dollar_value, cars);
return average(dollarValues);
};
开始练习!
使用compose()与其他函数以无点风格重构fastestCar。提示:可借助append函数。
const fastestCar = (cars) => {
const sorted = sortBy(car => car.horsepower, cars);
const fastest = last(sorted);
return concat(fastest.name, ' is the fastest');
};