从集合到范畴:用图解轻松理解函子、Monad 与函数式编程抽象
如果你在学函数式编程时,看到Functor、Monad、Applicative这些名词一头雾水,那么最终的根源往往都指向同一个地方:范畴论(Category Theory)。很多资料喜欢直接抛定义,结果初学者被“对象”“态射”“自然变换”劝退。其实范畴论是一门高度图形化的学科,如果从集合论出发,一步一步看它如何被抽象出来,你会发现它并没有想象中那么难。
这篇文章围绕 “Category Theory Illustrated: From Sets to Categories” 这个主题,用图解、表格和代码示例,带你从熟悉的集合过渡到范畴。我们不讲证明,不堆定义,重点放在“为什么需要范畴论”“范畴长什么样”“它在编程中到底有什么用”这三个问题上。读完你应该能理解函子、自然变换和 Monad 的基本直觉,并能在 TypeScript 或 Python 里写出对应的最小实现。
目录:
- 范畴论到底在解决什么问题
- 从集合出发:构建熟悉的地基
- 范畴的三个基本构件:对象、态射、复合
- 用图解理解范畴:箭头与交换图
- 经典例子:从 Set 范畴到 Monoid、Preorder
- 函子:连接两个范畴的桥梁
- 自然变换与 Monad:把抽象推进到实用
- 常见理解误区与排查清单
- 学习路线与资料建议
- 最后说给初学者的话
1. 范畴论到底在解决什么问题
1.1 传统编程建模的底层视角是集合论
我们先回到熟悉的领域。作为一个程序员,你每天都在和“数据”打交道:
int是一个集合,包含所有 32 位整数;string是一个集合,包含所有可能的字符串;- 函数
f: A -> B是从集合 A 到集合 B 的一种映射; - 类型检查就是判断某个元素是否属于某个集合。
这种“把类型看成集合,把函数看成集合之间的映射”的视角非常直观,而且在大多数情况下够用。它来自数学里的集合论(Set Theory)。
集合论的一大利器是:它把世界拆成“元素”和“集合”。研究问题的时候,我们关心的是集合里有哪些元素,元素之间有什么运算。
1.2 集合论解释不了“结构保持”
但是当你进入函数式编程,会很快遇到一个尴尬的问题:
// 我们有一个 number[] const nums = [1, 2, 3]; // 用 map 得到 string[] const strs = nums.map(n => `数字${n}`);这里的Array不只是一个集合,它更像是一种“结构”:它把number包成一个容器,然后你可以在容器上继续做映射。
如果严格用集合论来说,Array<number>是一个集合,Array<string>也是一个集合,map是一个函数。可是Array本身是一个“从类型到类型的操作”,它可以把任意类型T变成Array<T>。这种操作在集合论里没有直接对应的位置。
你再想一想:集合论研究的是“元素属于某个集合”,但Array更关心的是“如何保持这个容器的结构,同时把内部元素换掉”。这就不是集合论擅长的事了。
1.3 范畴论的口号:不要盯着元素,要看箭头
范畴论换了一个更“粗粒度”的视角。它不关心集合内部有哪些元素,而是关心:
- 有哪些“对象”(Objects)
- 对象之间有哪些“箭头”(Arrows/Morphisms)
- 箭头如何复合(Compose)
你可以把范畴论理解为“研究箭头以及箭头之间关系的学科”。它把集合论里的“元素”隐去,只保留“对象”和“对象之间的变换”。
这个转换看起来有点反直觉,但它的威力在于:很多不同领域的结构,一旦忽略内部元素,只看对象和箭头,会呈现出高度相似的模式。你学到的抽象就可以跨领域复用。
2. 从集合出发:构建熟悉的地基
2.1 集合是什么
集合就是一堆东西放在一起。比如:
A = { 1, 2, 3 } B = { "apple", "banana" }集合关注的是“属于”关系:1 ∈ A,表示 1 属于集合 A。
2.2 集合之间的函数
函数f: A -> B做的事情是:把 A 中的每一个元素,对应到 B 中唯一的一个元素。
def to_even(a: int) -> int: return a * 2这里to_even就是把集合int映射到集合int。
在集合论里研究函数时,我们关心的性质包括:
- 单射(injective):不同的输入是否会产生不同的输出;
- 满射(surjective):输出是否覆盖了目标集合的所有元素;
- 双射(bijective):既单射又满射,两个集合可以“一一对应”。
这些性质在研究集合大小、计数问题时很有用。
2.3 集合论的局限逐渐暴露
假设现在有这样一个问题:如何描述“把任意一个集合变成以它为元素的列表”?
在集合论里,你可以定义List(A)为“以 A 中元素为元素的有限序列集合”。但你很快会发现,List不是一个普通集合,它更像一个“操作”。这种操作可以让任意集合 X 变成List(X)。
进一步地,你还需要描述:
- 如何把一个函数
f: A -> B变成List(f): List(A) -> List(B); - 这种变换是否保持了复合关系;
- 如果 A 和 B 之间有多条函数,
List操作完这些函数后,箭头之间的组合关系会不会乱。
到这里,集合论的框架已经不好用了。因为集合论擅长描述“静态的、一层的结构”,而编程中大量存在“容器”“上下文”“结构转换”这些偏高阶的东西。范畴论就是为此而生的。
3. 范畴的三个基本构件:对象、态射、复合
3.1 对象(Objects)
范畴里的“对象”可以是你喜欢的东西。它不需要是集合,只需要作为一个“端点”存在。
比如:
- 在 TypeScript 类型系统中,
string、number、boolean可以是对象; - 在数据库 schema 里,
User表、Order表可以是对象; - 在一个有向图里,节点可以是对象。
对象不负责描述内部结构。它只是一个抽象的“place”。
3.2 态射(Morphisms)
态射是对象之间的箭头,你可以理解为“从一个对象到另一个对象的变换”。
态射和集合论中的“函数”不同:函数必须关心元素怎么映射,但态射不需要。态射只需要知道:
- 从哪里来(source / domain);
- 到哪里去(target / codomain);
- 如何与其他箭头复合。
在编程里,最常见的态射就是“函数”。一个函数f: A -> B就是类型 A 到类型 B 的态射。
3.3 复合与恒等
范畴论要求每个范畴必须满足两个核心性质。
第一个性质:箭头可以复合。如果存在态射f: A -> B和态射g: B -> C,那么一定存在一个态射g ∘ f: A -> C,读作 “g 复合 f”。
这里要注意复合的顺序:g ∘ f表示先应用 f,再应用 g。很多刚接触范畴论的朋友会在这里搞混。
第二个性质:每个对象都有一个恒等态射id_A: A -> A。它和任何箭头复合后,都不会改变那个箭头:
f ∘ id_A = f id_B ∘ f = f除了这两条,复合还必须满足结合律:
(h ∘ g) ∘ f = h ∘ (g ∘ f)这三条规则看起来简单,但是它们是整个范畴论的地基。
3.4 一个可以立刻上手的例子:程序里的类型和函数
我们可以把编程语言中的“类型”看作对象,把“纯函数”看作态射。
number是一个对象;string是一个对象;function f(n: number): string是number -> string的态射;function g(s: string): boolean是string -> boolean的态射;- 那么
g ∘ f就是number -> boolean的态射。
这个“类型与函数”组成的范畴,在范畴论里非常重要。我们后面会看到,它其实就是 Set 范畴的一个编程版投影。
4. 用图解理解范畴:箭头与交换图
4.1 把范畴画成点和箭头
范畴论被称为“现代数学的通用语言”,很大程度上是因为它特别适合用图形表示。一个简单的范畴可以画成这样:
A ──f──▶ B │ │ idA idB这张图表示:对象 A 和对象 B,以及从 A 到 B 的态射 f。
如果 f 和 g 可以复合,再画出来:
A ──f──▶ B ──g──▶ C \ ▲ └──── g∘f ───────┘在这张图里,从 A 到 C 有两条路径:
- 先从 A 到 B,再走 g 到 C;
- 直接走复合箭头
g∘f从 A 到 C。
如果两条路径的最终结果一致,我们就说这个图“可交换”(commute)。
4.2 交换图为什么重要
交换图是范畴论里最常用的推理工具之一。当你看到一个箭头图,并且知道它交换时,你可以把图中任意两条首尾相同的路径看成“等价”。
f A ───────▶ B │ │ h│ │k ▼ ▼ C ───────▶ D j如果图中从 A 到 D 的两条路径k ∘ f和j ∘ h相等,则这个方块交换:
k ∘ f = j ∘ h这种“路径等价”的表达能力非常强。很多复杂的数学命题,写出来是一大串公式,画成交换图只是一张图,而且一目了然。
4.3 用代码实现箭头复合
在 TypeScript 里,我们可以用一个小小的compose函数模拟态射复合:
type Function<A, B> = (a: A) => B; function compose<A, B, C>(g: (b: B) => C, f: (a: A) => B): (a: A) => C { return (a) => g(f(a)); } // 两个普通函数 const toLength = (s: string): number => s.length; const isEven = (n: number): boolean => n % 2 === 0; // 复合出一个新函数 const isEvenLength = compose(isEven, toLength); console.log(isEvenLength("hello")); // false,长度 5 不是偶数 console.log(isEvenLength("world")); // true,长度 5 也不是偶数,这里换成 "test"换成 Python 也一样:
from typing import Callable, TypeVar A = TypeVar("A") B = TypeVar("B") C = TypeVar("C") def compose(g: Callable[[B], C], f: Callable[[A], B]) -> Callable[[A], C]: return lambda x: g(f(x)) def to_length(s: str) -> int: return len(s) def is_even(n: int) -> bool: return n % 2 == 0 is_even_length = compose(is_even, to_length) print(is_even_length("hello")) # False print(is_even_length("test")) # True这段代码看起来很简单,但它已经体现了范畴论的核心操作:把两个箭头复合起来,忽略中间如何实现,只关心从哪里来到哪里去。
5. 经典例子:从 Set 范畴到 Monoid、Preorder
5.1 Set 范畴:最直观的实例
范畴论里的经典起点是 Set 范畴:
- 对象:所有集合;
- 态射:集合之间的函数;
- 复合:函数的复合;
- 恒等:每个集合上的恒等函数。
这个范畴的意义在于:它把集合和函数重新组织成一个整体。当你站在 Set 范畴之上时,你不再讨论某个集合内部有哪些元素,而是讨论集合之间如何通过函数产生联系。
5.2 单元素集合与“元素”的另一种理解
用范畴论的眼光看“元素”,会有一种全新的理解方式。
考虑单元素集合{ * }。对于任何集合 X,从{ * }到 X 的一个函数,其实就是在 X 中选一个元素。
于是“元素 x ∈ X”可以被重新理解为“存在一个箭头pick_x: { * } -> X”。换句话说,范畴论不谈元素,但可以通过“从单元素集合出发的箭头”来模拟元素。
这个视角非常有用,因为它把“元素”这个概念推广了。在很多范畴里,可能没有“单元素对象”,但依然可以有类似的结构。
5.3 Monoid:一个对象与一群箭头
Monoid(幺半群)是数学中一个非常常见的结构。比如字符串拼接、列表合并、整数加法,都是 Monoid 的例子。
一个 Monoid 包含:
- 一个集合 M;
- 一个二元运算
·; - 一个单位元 e;
- 满足结合律:
(a · b) · c = a · (b · c); - 满足单位元定律:
e · a = a · e = a。
你可能会问:这和范畴有什么关系?
答案是:任意一个 Monoid 都可以看成一个只有一个对象的范畴。
- 对象:一个,记作
*; - 态射:M 里的每一个元素都是一个从
*到*的箭头; - 复合:就是 Monoid 的二元运算
·; - 恒等态射:就是单位元 e。
这是一个非常漂亮的对应关系。它说明范畴论比集合论更抽象,但抽象反而能揭示出结构之间深层的相似性。
5.4 Preorder:最多一条箭头的范畴
Preorder(预序)也是数学里的常见概念。一个集合上如果有一个满足自反性和传递性的关系,就构成一个 Preorder。
例如:
- 整数上的
<=关系; - 字符串上的“前缀”关系;
- 容器之间的“子集”关系。
Preorder 也可以看成一个范畴:
- 对象:集合中的元素;
- 态射:如果
a <= b,就存在一个箭头a -> b; - 由于关系至多只有一种,所以每对对象之间最多只有一个箭头;
- 自反性对应恒等态射;
- 传递性对应复合。
这个例子告诉我们:范畴不一定“内容丰富”,有时候它只是在表达一种结构性的偏序。
| 结构 | 对象 | 态射 | 要点 |
|---|---|---|---|
| Set 范畴 | 所有集合 | 集合间的函数 | 函数复合就是态射复合 |
| Monoid | 一个对象 | 集合元素 | 运算变成复合 |
| Preorder | 元素 | 关系箭头 | 关系保持自反与传递 |
6. 函子:连接两个范畴的桥梁
6.1 函子的定义
当我们有了两个范畴 C 和 D,就可以定义它们之间的映射,这个映射叫函子(Functor)。
一个函子F: C -> D要做两件事:
- 对象映射:把 C 里的每个对象 X,映射为 D 里的对象 F(X);
- 态射映射:把 C 里的每个态射 f: X -> Y,映射为 D 里的态射 F(f): F(X) -> F(Y)。
函子还必须保持结构:
- 保证复合:
F(g ∘ f) = F(g) ∘ F(f); - 保证恒等:
F(id_X) = id_{F(X)}。
简单说,函子不破坏目标范畴里的箭头关系。
6.2 编程中的 Functor:map 的本质
编程语言里的Array.map就是一个函子的经典例子。
const nums = [1, 2, 3]; const toStr = (n: number) => `数字${n}`; const strs = nums.map(toStr);这里:
Array是从类型系统范畴到类型系统范畴的函子;- 类型
number被映射成Array<number>; - 函数
toStr: number -> string被映射成Array.map(toStr): Array<number> -> Array<string>。
函子定律在 Array 上体现为:
// 恒等律:map(id) === id [1, 2, 3].map(x => x); // 等价于 [1, 2, 3] // 复合律:map(f).then(map(g)) === map(g ∘ f) [1, 2, 3].map(x => x + 1).map(x => x * 2); // 等价于 [1, 2, 3].map(x => (x + 1) * 2);6.3 自定义一个 Functor
在 TypeScript 中,我们可以用接口表示一个简单的 Functor 结构:
interface Functor<F> { map<A, B>(f: (a: A) => B, fa: F<A>): F<B>; }虽然 TypeScript 的类型构造器支持有限,但我们可以用更直观的类来演示:
class Box<T> { constructor(private value: T) {} map<U>(f: (value: T) => U): Box<U> { return new Box(f(this.value)); } getValue(): T { return this.value; } } const box = new Box(5); const doubled = box.map(n => n * 2); console.log(doubled.getValue()); // 10Box就是一个最简单的函子。它把内部值包起来,然后通过map在“上下文”中应用函数。
7. 自然变换与 Monad:把抽象推进到实用
7.1 自然变换:函子之间的箭头
函子连接了两个范畴。那么函子与函子之间是否也可以有箭头?答案是肯定的,这个箭头就叫自然变换(Natural Transformation)。
假设有两个函子 F 和 G,都从范畴 C 到范畴 D。一个自然变换α: F -> G对 C 中每个对象 X,给出一个 D 中的态射:
α_X : F(X) -> G(X)并且它必须满足“自然性条件”:对任意态射 f: X -> Y,下面这个方块交换:
F(f) F(X) ──────▶ F(Y) │ │ α_X│ │α_Y ▼ ▼ G(X) ──────▶ G(Y) G(f)自然变换的直观理解是:它能“批量地”把 F 结构转换成 G 结构,而且不依赖于具体对象。
在编程里,一个典型的例子可能是“把列表转换成数组”,或者“把 Optional 转换成列表”。只要这种转换对所有的类型都能一致地执行,它就是自然变换的雏形。
7.2 Monad:可以编程的上下文
Monad 是一个在函数式编程里被过度神化的概念。它本质上是一个自函子(从一个范畴到自身的函子)加上两个自然变换:
return/pure:把值放入上下文;flatMap/bind/>>=:在上下文中执行函数,并把结果展平。
用类型签名来理解最容易:
interface Monad<M> { // 把普通值放入容器 pure<A>(value: A): M<A>; // 在容器内应用返回容器的函数,并展平 flatMap<A, B>(ma: M<A>, f: (a: A) => M<B>): M<B>; }7.3 用 TypeScript 模拟 Maybe Monad
Maybe 可能是最实用的 Monad 入门案例。它用来表达“可能为空”的上下文。
type Maybe<T> = { tag: "just"; value: T } | { tag: "nothing" }; function just<T>(value: T): Maybe<T> { return { tag: "just", value }; } const nothing: Maybe<never> = { tag: "nothing" }; function pure<T>(value: T): Maybe<T> { return just(value); } function flatMap<T, U>(ma: Maybe<T>, f: (value: T) => Maybe<U>): Maybe<U> { if (ma.tag === "nothing") { return nothing; } return f(ma.value); } // 使用示例 function half(n: number): Maybe<number> { if (n % 2 === 0) { return just(n / 2); } return nothing; } const result = flatMap(flatMap(pure(10), half), half); console.log(result); // { tag: 'just', value: 2.5 } const result2 = flatMap(flatMap(pure(3), half), half); console.log(result2); // { tag: 'nothing' }这段代码完全可以用范畴论的术语解释:
Maybe是一个函子;pure是自然变换Id -> Maybe;flatMap的核心是把嵌套的Maybe<Maybe<T>>展平为Maybe<T>。
Monad 真正有用的地方在于,它让你可以把“副作用”“空值”“异步”等都封装在一个上下文里,然后通过统一的方式组合。
8. 常见理解误区与排查清单
8.1 “对象就是集合”的误区
在范畴论里,对象不一定是集合。它可以是类型、表、状态、甚至一个箭头本身。初学者如果一直用集合去套范畴论,会觉得很多定义不可理喻。
正确做法:把对象当成一个“不透明的节点”。先不要追究内部结构,先看箭头关系。
8.2 “态射就是函数”的误区
态射在常见的编程例子中往往是函数,但它也可以是关系、路径、甚至推导规则。只要满足复合规则和恒等规则,就可以视为态射。
8.3 复合方向容易搞混
g ∘ f表示先 f 后 g,这个书写顺序经常让人困惑。
建议在阅读时养成习惯:凡是看到g ∘ f,先在内心翻译成 “先执行右边的 f,再执行左边的 g”。
8.4 函子定律没有验证
编程中实现 Functor 时,容易忽略函子定律。如果map不保持恒等或复合,程序可能在组合时产生诡异行为。
排查清单:
| 问题现象 | 可能原因 | 解决思路 |
|---|---|---|
| map 之后结果和预期不一致 | 函子实现破坏了上下文 | 用简单的测试数据验证恒等律和复合律 |
| 无法理解自然变换 | 混淆了函子和自然变换的层级 | 记住:函子连接范畴,自然变换连接函子 |
| Monad 的 flatMap 嵌套难以阅读 | 不理解上下文含义 | 先用 Maybe 或 Promise 这类具体类型练习 |
| compose 顺序错了 | 写成先 g 后 f | 检查代码,明确compose(g, f)表示先 f 后 g |
9. 学习路线与资料建议
9.1 第一步:建立函数式编程基础
不需要直接啃范畴论教材。你可以先掌握:
- 纯函数、不可变数据;
map、filter、reduce;- 函数组合、柯里化;
- 常用容器类型:
Option、Either、List; - 类型类或接口的概念。
这些基础是理解范畴论的“感性素材”。没有这些素材,范畴论定义读起来会很空洞。
9.2 第二步:阅读面向程序员的范畴论入门
比较推荐的通俗资料:
- 《Category Theory for Programmers》(Bartosz Milewski)
- 《Functional Programming in Scala》
- 《Haskell 趣学指南》
- 各种关于 Functor / Monad 的图解文章
建议边看边画图。把书里的定义转换成箭头图,自己尝试补全交换图。
9.3 第三步:选一门强类型语言实践
Haskell 是最适合学习范畴论的语言,因为它的类型类和范畴论术语几乎一一对应。
如果不想学 Haskell,也可以选 TypeScript、Python 或 Kotlin,自己实现:
Functor接口;Monad接口;Maybe、Either、State这些常见类型。
9.4 第四步:回到经典计算机科学问题
学完基础后,你会看到范畴论在以下领域反复出现:
- 类型系统的设计;
- 编译优化中的变换;
- 领域驱动设计中的聚合与领域事件;
- 数据库查询语言的高阶抽象;
- 并发模型中的 monad 化封装。
10. 最后说给初学者的话
范畴论确实是一门深邃的数学分支,但作为程序员,你完全可以从“图形直觉”开始,不需要先学完一门数学课再动手。
我的建议是:
- 先画图,再定义;
- 先写代码,再记术语;
- 先理解
map,再理解 Functor; - 先理解
flatMap,再理解 Monad。
当你发现自己能用箭头图去描述Array.map、Promise.then、Optional.flatMap这些日常结构时,说明你已经真正跨过了“从集合到范畴”这个门槛。那个时候,再看那些看起来高深的理论定义,你会觉得它们只是给直觉补上了最后一块拼图。
如果这篇文章对你有帮助,欢迎收藏备用。下一篇文章里,我们可以继续深入函子与自然变换的代码实战。
