Skip to content

第05章:组合式编程

函数培育艺术

核心函数compose实现如下:

js
const compose = (...fns) => (...args) => fns.reduceRight((res, fn) => [fn.call(null, ...res)], args)[0];

...别被吓到!这是compose的究极进阶形态(灵感来自《龙珠》超级赛亚人设定)。为了便于理解,我们先忽略可变参数实现,分析仅能组合两个函数的简化版本。一旦掌握该逻辑,便能推广至任意数量函数的组合(甚至可数学证明)! 为方便读者,以下提供更友好的compose定义:

js
const compose2 = (f, g) => x => f(g(x));

fg为函数类型,x指代流经该组合的值

组合过程如同函数培育:作为函数繁育者,您挑选两个具备所需特性的函数,糅合演化出全新函数。具体用法示例如下:

js
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,等效代码为:

js
const shout = x => exclaim(toUpperCase(x));

执行顺序右→左代替了内→外,虽然看似逆向(嘘!),但序列重要性在下例中体现明显:

js
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功能。此时函数执行序列至关重要。虽然可定义左→右版本,但现有形式更贴近数学原义。事实上,组合概念直接源自数学教材。现在正是探讨组合属性的好时机。

js
// associativity
compose(f, compose(g, h)) === compose(compose(f, g), h);

组合满足结合律,即分组方式不影响最终结果。例如字符串大写转换可写作:

js
compose(toUpperCase, compose(head, reverse));
// or
compose(compose(toUpperCase, head), reverse);

由于组合调用分组不影响结果,我们可以安全地使用可变参数版compose:

js
// 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!'

结合律赋予代码灵活性与确定性。书中支持库已包含较复杂的可变参数实现,这与lodashunderscoreramda等库的标准实现一致。

结合律的妙处在于:任意函数组都可独立提取并打包为新组合。我们对前例作重构演示:

js
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...

重构无绝对对错,关键是灵活组装函数单元。通常建议按lastangry等可复用模式分组。熟悉Fowler《重构》的读者会发现,该过程类似“函数提取”模式,只不过无需关注对象状态。

无点风格

无点风格的精髓在于避免显式处理数据。一等函数、柯里化与组合的协同作用,共同铸就这一风格。

提示:replacetoLowerCase的无点风格版本详见附录C-实用工具,请随时查阅!

js
// 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变量,相比显式处理更加抽象。

再看另一示例:

js
// 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进行部分应用就尝试组合。

js
// 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!'])

调试组合函数时,可利用以下非纯追踪函数观察数据流(慎用):

js
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定位问题:

js
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

js
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)及变换刻画跨领域概念。不同理论中对应范畴论的概念映射如图所示:

category theory

请不必惊慌,无需精通所有领域知识。此图表旨在展示各领域的概念冗余,说明范畴论追求理论统一的理念。

范畴的定义包含以下要素:

  • 对象的集合
  • 态射的集合
  • 态射的组合规则
  • 特定的恒等态射

范畴论具有高度抽象性,但我们将聚焦类型与函数的应用场景。

对象的集合 对象对应数据类型,如StringBooleanNumber等。数据类型可视为所有可能值的集合(例如布尔类型是{true, false},数字类型是所有数值的集合)。类型即集合的观点便于应用集合论方法。

态射的集合 态射即日常使用的纯函数。

态射的组合规则 这正是我们的新武器——compose。如前所述,组合满足结合律,这恰恰体现范畴论对组合的基本要求。

组合过程示意图:

category composition 1category composition 2

代码实例演示:

js
const g = x => x.length;
const f = x => x === 4;
const isFourLetterWord = compose(f, g);

恒等态射 引入id函数:仅执行输入值的无损传递。定义如下:

js
const id = x => x;

可能疑惑:“这玩意儿有何用?”我们将在后续章节广泛使用它,目前可将其视为值的替身——一种伪装成数据的函数。

id必须与组合协同工作。任一元函数f满足以下特性:

js
// identity
compose(id, f) === compose(f, id) === f;
// true

这类同于数字的单位元性质!若理解困难请稍作思考——我们很快会高频使用id,目前只需将其视为值的函数替身,这在无点编程中极为实用。

至此我们建立了类型与函数的范畴体系。初次接触可能仍感抽象,本书将逐步深化理解。至少在本章,您应认识到其提供的组合智慧:结合律与单位元特性。

其他范畴示例:有向图(节点为对象,边为态射,组合即路径连接);以数字为对象、≥关系为态射的序范畴。本书主要关注上述基础范畴,其余暂时搁置。

本章总结

组合好似连接函数的管道网络,数据依纯函数特性(输入→输出)在应用中流动,切断该链条将导致功能失效。

组合被奉为核心设计原则,因其能保障程序的简洁性与合理性。范畴论将在架构设计、副作用处理与正确性验证中发挥关键作用。

目前我们已经为实践应用做好了理论准备,即将进入案例实战阶段。

第06章:应用案例

练习题

下列练习中,汽车对象(Car)的结构如下:

js
{
  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); };


给定函数如下:

js
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'); };