函式程式设计 (functional programming) — 函式组合 (Composition)
2025年5月8日
在先前的文章中,我们从函式的宣告式特点谈到高阶函式。在有了这些基础后,在这期的主题文,我们会进一步来谈函式组合 (composition)。
函式好在哪?
在谈函式组合是什么之前,想与读者们先思考一个问题,当今天提到函式的好处时,大家会想到什么呢? 相信多数人会想到模组化、可复用、好测试等好处。
让我们用面包工厂来做比喻,假如今天有一间工厂想用全自动化的方式来做奶油面包。要实现这件事,通常会把流程拆分成多个不同的部分,然后以类似流水线的方式串在一起。
举例来说,这个流水线可以切分成以下的不同部分:
- 制作面团:把面粉原料搭配水揉成做面包的面团
- 发酵:在完成面团后,加入发酵粉静置发酵
- 成形:把发酵完的面团,转成一个个面包原型
- 烘烤:有了一个个面包的原型后,进一步放入烤箱烘烤
- 加入奶油:在烤完面包后,把美味的鲜奶油挤入面包当中
(下图为用 ChatGPT 帮忙生成示意图,虽然图中的中文字有点混乱,但图片概念很精准传达)

以程式的角度来看,上面提到的每个步骤,都可以用函式的概念来表达。举例来说,制作面团可以是一个函式,像下面这样:
function makeDough(ingredients: Ingredients): Dough {
// 细节忽略
}
透过函式的形式来切分,我们可以轻易地做到模组化,把不同的步骤拆成不同的模组;这样的好处是能够重复使用,假如之后想扩厂,同样的 makeDough
函式就可以拿来用。同时因为各个模组切分地很清楚,所以要测试或者除错也会变容易,因为函式的边界很清楚,很容易找出哪边出问题,然后针对出问题的函式来除错,就不用在茫茫大海中找问题。
除了上面谈的好处,我们也可以从「抽象化」这个角度来看函式。所谓的抽象化,是指把复杂的执行细节隐藏起来,只看做核心的概念。以上面的面包工厂为例,在上面 makeDough
的函式内,我们用注解写了 // 细节忽略
,这就是一种抽象化。
就像要制作面团,我们需要有不同的机器零件、需要有不同的原料,需要有制作的工法 (例如要怎么揉、不同阶段的温度要怎么调整),但今天在用面团制作机时,完全不用去考虑那些细节,只用把原料倒进去就好。在使用makeDough
函式时,也不用关注执行细节,只要把输入丢入,就会得到相对应的输出。
什么是函式组合 (function composition)?
上个段落我们谈了函式的好处、抽象化让我们能关注核心而非细节。但在上面的面包工厂案例中,有一个很关键的点,是我们还没谈到的,那就是制作面包的不同阶段,可以组合在一起,让面包从开始制作到出炉,都能全自动完成。
就如要以函式来表达,会是像这样:
const dough = makeDough(rawIngredients);
const loaves = shapeDough(dough);
const bread = bakeLoaves(loaves);
const butterBread = addButter(bread);
如果要用工厂流水线的角度来看,则可以变成这样:
const butterBread = addButter(
bakeLoaves(shapeDough(makeDough(rawIngredients)))
);
抽象一点来看,就会是:
输入 -> 函式 1 -> 函式 2 -> 函式 3 -> 输出
但是上面 addButter(bakeLoaves(shapeDough(makeDough(rawIngredients))))
这样的写法,可读性不是太好,所以在函式程式设计中,多半会使用 compose
这个高阶函式,来把多个不同的函式组合起来。
具体来说,compose
的用法会是这样,把要组合的函式,传入到 compose
之中,而 compose
会回传组合出来的函式。接着如果要获得最终的成果,直接呼叫组合出的函式即可。
// 把 addButter, bakeLoaves, shapeDough, makeDough 组合成 createButterBread
const createButterBread = compose(addButter, bakeLoaves, shapeDough, makeDough);
// 直接用 createButterBread 来做奶油面包。
const butterBread = createButterBread(rawIngredients);
而今天如果想要做巧可力面包,只需用把 addButter
换成 addChocolate
,就能够组合成 createChocolateBread
这个函式,因为流程中的其他部分都一样,所以只需要替换掉不同的部分,就能组合出新的函式,其他都能重复利用,非常方便。
在看完上面的例子后,相信读者可能会觉得,能够理解用制作面包例子,但是在实际工作上,可以如何运用函式组合呢? 以 ExplainThis 的网站本身来说,我们就有用上函式组合。因为 ExplainThis 网站基本是个文章为主的静态网站,对这类网站 SEO 是重要的面相。为了做到 SEO 最佳化,需要有固定对搜寻引擎友善的格式,这时候就能把不同的函式组合起来,用在不同地方。
具体来说会是这样:
// 把英文标题转换成惯用标题的函式
// 例如把 what is functional programming 转为 What Is Functional Programming
const formatTitle = compose(
capitalizeWords, // 把每个字的字首转成大写
removeExtraSpaces, // 去除掉前后多余的空格
toLowerCase // 先把所有字都转成小写
);
// 产生 slug 的函式,好的 slug 对 SEO 帮助很大
// 例如 What Is Functional Programming 转为 what-is-functional-programming
const createSlug = compose(
replaceSpacesWithDashes, // 把空格都用 - 替换掉
removeSpecialCharacters, // 把特殊字移除掉,避免网址难呈现
removeExtraSpaces, // 去除掉前后多余的空格
toLowerCase // 先把所有字都转成小写
);
// 产生文章描述的函式,让搜寻引擎可以读到并呈现文章的描述
const createDescription = compose(
truncateToLength(150), // 截取一篇文章的前 150 个字作为描述
removeExtraSpaces // 去除掉前后多余的空格
);
可以看到,因为基本上不同的函式组合,都会需要用到 removeExtraSpaces
或 toLowerCase
这种函式,这时候先把这类函式抽出来,然后在不同地方组起来,就能很轻易地重复利用。
函式组合的本质是什么?
在看完上面的讨论,接着让我们来想一下,函式组合的本质是什么?
从上面的例子可以看到,函式组合在做的事情,就是把不同的函式组成一个功能更完整的函式。把一个函式的输出,变为下一个函式的输入,让资料透过这些相连的函式,逐步转化成最终的结果。
因为把每一个部分都拆成一个范围小的函式,所以可以获得拆成函式的好处,包含上面提到的模组化、可复用、好测试。除此之外,所以可以很轻易地搭配出不同的组合,而不用当有新的组合时就要重写。
阅读更多
在了解完函式组合后,接着我们会进一步谈如何实作函式组合,以及函式组合跟 reduce
这个高阶函式有什么关联。这些点我们在 E+ 成长计划的主题文都有更详细谈到,推荐感兴趣的读者阅读。
本文为 E+ 成长计划的深度内容,截取段落开放免费阅读。欢迎加入 E+ 成长计划阅读完整版本 (点此了解 E+ 的详细介绍)。