从集合到范畴:图解范畴论核心概念与编程实践
范畴论在很多程序员眼里是“听过名字,翻过两页,然后放弃”的那类理论:到处是抽象定义,例子又少,符号还不友好。这次我们看的是一个以图解为主线、从集合到范畴的入门学习项目《Category Theory Illustrated: From Sets to Categories》。它最大的特点是不急着丢给你“范畴的公理化定义”,而是先让工程师从最熟悉的集合、函数、类型出发,用节点和箭头把范畴论的核心概念画清楚,再一步步收敛到严格定义。
这个主题最值得关注的地方有三个:第一,它把范畴论的抽象概念还原成“画得出来”的结构,降低理解门槛;第二,它沿着“集合 -> 函数 -> 箭头 -> 范畴”的路径推进,和程序员的类型直觉天然兼容;第三,范畴论中的对象、态射、复合、恒等、函子、自然变换都能在 TypeScript、Haskell、Python 里找到对应物,学完可以直接动手验证。
这篇文章会带你完成四件事:理清范畴论的四要素和两条公理;理解从集合视角切换到范畴视角的关键一步;用图解方法分析一个具体范畴;再通过代码示例把概念变成可运行的验证。如果你一直想搞清楚 Functor、Monad 到底在说什么,这篇文章就是一个比较务实的起点。
1. 核心概念速览
先把范畴论里最常出现的概念和它们对应的编程直觉列出来。下面的表格贯穿全文,后面所有章节都会反复引用这些名词。
| 概念 | 数学含义 | 编程直觉 |
|---|---|---|
| 范畴(Category) | 由对象和态射组成的数学结构 | 一个类型系统加上所有可定义函数 |
| 对象(Object) | 范畴中的“点”,可以没有内部结构 | 一个类型,例如string、number |
| 态射(Morphism) | 对象之间的箭头 | 一个函数,例如string => number |
| 复合(Composition) | 首尾相接的箭头组合 | 函数组合compose(f, g) |
| 恒等态射(Identity) | 每个对象上的“不动箭头” | 恒等函数id = x => x |
| 函子(Functor) | 范畴到范畴的映射,保持结构 | map/fmap,例如Array.map |
| 自然变换(Natural Transformation) | 函子到函子的转换 | 多态函数,例如maybeToList |
这里需要特别强调一点:范畴论里的“对象”不等于“集合”。对象在范畴中只是抽象的点,它内部可以有元素,也可以完全没有元素,范畴论不关心对象内部长什么样,只关心对象之间有哪些箭头。这一点和第 4 部分的“关键跃迁”直接相关。
2. 适用场景与学习边界
这个主题适合下面几类读者:
- 函数式编程学习者,想从根源理解
map、flatMap、compose这些操作到底在描述什么规律。 - 类型系统研究者或对 Haskell、Rust、TypeScript 类型体操感兴趣的工程师。
- 读过范畴论教材但觉得符号推演太多、直观例子太少的自学者。
- 需要和数学背景同事沟通架构设计,想建立一套通用抽象语言的后端工程师。
它不适合以下场景:
- 想快速上手某个 Web 框架、想立刻提升业务代码运行性能的读者,范畴论不会直接给你这类收益。
- 只想要“Monad 面试题速成”的人,范畴论可以解释 Monad 的来源,但不能替代 API 实战。
- 对纯数学证明不感兴趣、只想看代码效果的读者,可能觉得前几章推导偏多。
学习边界也要说清楚。范畴论是概念工具,不是运行框架。理解范畴论能改善你的抽象能力,能帮你设计更可组合的接口,但它不承诺代码变快,也不承诺自动写出高并发系统。它是“思维层”的工具,不是“执行层”的工具。
如果在学习过程中涉及代码示例,建议都在本地测试环境运行,不要在生产环境直接验证抽象理论代码。涉及开源库引用时,注意查看许可证。
3. 第一阶段:从集合与函数出发
范畴论图解项目通常选择“集合”作为起点,是因为集合论是大多数理工科背景都能接受的直觉:把一堆元素装进一个容器,就是集合;把每个输入对应到一个输出,就是函数。
在程序员看来,集合和类型几乎可以划等号:一种类型就是所有可能取值的集合。boolean是有两个元素的集合,string是所有字符串组成的集合,User是满足某个结构的对象集合。函数则对应类型签名:输入一个类型,输出另一个类型。
// 集合:类型 type User = { id: number name: string } // 函数:从一个集合到另一个集合的映射 const getName = (user: User): string => user.name这里getName接收一个User对象,返回一个string,本质上就是从“User 集合”到“string 集合”的映射。集合论视角下,我们会关心User里有哪些字段、string的编码方式是什么;但范畴论视角下,这些内部细节会被暂时放到一边,我们只关心一件事:存在一个从User到string的箭头。
所以第一阶段要建立的直觉就是:类型是集合,函数是集合之间的映射。这个直觉越稳固,后面理解范畴就越容易。
再补充一个集合与函数的关键性质:函数可以复合。如果有一个函数f: A -> B,另一个函数g: B -> C,那么一定能得到一个新函数h: A -> C,方式是先执行f再执行g。
const f = (x: number): string => String(x) const g = (s: string): boolean => s.length > 3 // 复合:number -> boolean const h = (x: number): boolean => g(f(x))这一条在集合论里是显而易见的,但它是整个范畴论的基石。后面定义范畴时,“态射可复合”会成为核心公理之一。
4. 第二阶段:关键跃迁——从函数到箭头
从集合到范畴,最关键的跃迁不是引入更复杂的对象,而是改变观察角度:把注意力从“集合内部的元素”转移到“集合之间的关系”。
传统集合论问的是:这个集合里有哪些元素?这两个集合的交集是什么?元素之间存在什么运算?
范畴论换了一个问法:这些对象之间有哪些箭头?箭头和箭头能否首尾相接?相接之后是否满足交换律?对象内部结构反而被刻意忽略了。
举一个例子。假设有两个对象A和B,A是string类型,B是number类型。集合论会问string里有多少字符串,但范畴论只关心一件事:从string到number有没有箭头?有,比如length、parseInt、charCodeAt,这些都算数。
这样一来,同一个对象可以有非常多种箭头,箭头本身成为研究对象。这个视角带来的直接好处是:当我们讨论“从 A 到 B 的箭头”时,不需要知道 A 里面到底是什么,只需要知道箭头可以在复合运算下连接起来。
这种抽象对工程师来说并不陌生。接口设计就是典型例子:一个接口只暴露方法签名,不暴露内部实现。调用方看到的不是对象内部的字段,而是对象能响应哪些操作,也就是“对象之间有什么箭头”。从集合到范畴,就是把这种工程直觉拔高成数学结构。
再进一步理解:函数是特殊的箭头,但箭头不只是函数。在某个具体范畴里,箭头可以是关系、逻辑蕴含、程序执行路径、数据库外键关系,只要它们满足复合和恒等规则,就能构成范畴。这也是范畴论抽象能力强的原因:它抓住的是“关系与复合”这一共同骨架。
5. 第三阶段:范畴的四要素与两条公理
当你接受“把函数当成箭头”之后,范畴的正式定义就顺理成章了。一个范畴由以下四个要素组成。
5.1 对象
对象是范畴中的基本节点。在集合范畴Set中,对象就是集合;在类型范畴中,对象就是类型。对象内部结构不重要,重要的是它作为箭头的起点和终点。
5.2 态射
态射是对象之间的箭头。每个态射都有一个源对象和一个目标对象。如果态射f的源对象是A、目标对象是B,写作f: A -> B。在Set范畴里,态射就是集合之间的函数。
5.3 恒等态射
每个对象都必须有一个恒等态射,从对象指向自身,并且和其他箭头复合时不起作用。在集合范畴中,恒等态射就是恒等函数id:输入什么就返回什么。
5.4 复合运算
态射必须支持复合。如果f: A -> B,g: B -> C,那么存在一个态射g ∘ f: A -> C。复合运算需要满足两条公理。
- 结合律:
h ∘ (g ∘ f) = (h ∘ g) ∘ f,只要箭头首尾能接上,复合顺序不影响结果。 - 单位律:对任意
f: A -> B,都有f ∘ idA = f,且idB ∘ f = f。
这两条公理不要求对象有任何内部结构,也不要求态射一定是函数。它们描述的是箭头之间最基础的组合规则。
用 TypeScript 可以直接把这些定义变成代码:
// 态射:从一个类型到另一个类型的函数 type Morphism<A, B> = (input: A) => B // 恒等态射:每个对象 A 都有一个 id const id = <A>(x: A): A => x // 复合运算:先执行 g,再执行 f const compose = <A, B, C>( f: Morphism<B, C>, g: Morphism<A, B> ): Morphism<A, C> => (x: A): C => f(g(x)) // 验证结合律:compose(h, compose(g, f)) === compose(compose(h, g), f) // 验证单位律:compose(id, f) === f这段代码基本就是集合范畴在类型系统里的缩影。对象对应类型,态射对应函数,恒等态射对应id,复合对应compose。如果读者能把这段代码跑通,再回头看书上的抽象定义,会轻松很多。
6. 图解形式:把范畴画出来
项目标题里的Illustrated提示我们,图解是这个主题的贯穿线索。范畴论的抽象概念一旦画出来,叙述会清晰很多。
6.1 节点与箭头
最基础的图就是节点加箭头。每个节点代表一个对象,每个箭头代表一个态射。比如两个对象A和B,存在态射f: A -> B,就画成:
A ── f ──▶ B看起来很简单,但很多推理都可以在图上完成。对象和态射一旦用图表示,复合就变成了“两条首尾相接的箭头可以合并成一条新箭头”。
6.2 复合的图
如果f: A -> B,g: B -> C,那么图上有两条路径可以从A到C:直接走g ∘ f,或者先走f再走g。范畴论要求这两条路径代表同一个态射,这就是复合的定义:
A ── f ──▶ B │ │ │ g │ ▼ └── g∘f ──▶ C一旦开始用路径思考,范畴论就从“符号游戏”变成了“箭头拼图”。
6.3 交换图
更复杂的范畴关系用交换图表示。比如下图:
A ── f ──▶ B │ │ g h ▼ ▼ C ── i ──▶ D如果从A到D的两条路径相等,即h ∘ f = i ∘ g,这个图就叫交换图。交换图的特点是:同一起点到同一终点的不同路径,结果一致。
交换图在范畴论中的地位很高,因为很多定义就是靠“某个图可交换”来描述的。函子保持复合结构、自然变换满足自然性条件,本质上都是在说某些图可以交换。
图解学习的好处是:每个抽象定义都能转成几何判断;不懂某个等式时,先画出所有箭头和路径,看哪些路径是等价的;图形结构可以嵌套,一个复杂交换图可以被拆成多个小方块分别验证。
7. 进阶:函子与自然变换
理解了范畴本身,下一步就是看范畴之间的映射,这就是函子(Functor)。函子可以理解成“保持结构的映射”:它不仅把范畴C里的每个对象映射到范畴D里的某个对象,还把C里的每个态射映射成D里的某个态射,并且必须保留复合和恒等关系。
程序员最熟悉的函子就是各种容器类型。Array、Promise、Maybe都是一等公民的函子。它们允许你在保持包装结构的前提下,把某个函数应用到内部值上。
Haskell 中的定义最有代表性:
class Functor f where fmap :: (a -> b) -> f a -> f bfmap接收一个普通函数a -> b,返回一个在函子上下文中运行的函数f a -> f b。对应到 TypeScript:
const arrayMap = <A, B>(f: (x: A) => B, arr: A[]): B[] => arr.map(f) const maybeMap = <A, B>(f: (x: A) => B, m: A | null): B | null => m === null ? null : f(m)arrayMap和maybeMap的共同点是:它们都把一个普通函数提升到某个上下文里执行,且不破坏上下文的形状。这正是函子的核心。
自然变换(Natural Transformation)则更进一步:它是函子之间的转换。一个自然变换能把一个函子的结果转换成另一个函子的结果,同时不依赖具体对象。函数式编程中常见的maybeToList就是自然变换:把Maybe a转成[a],而且这种转换对所有类型a都是一致的,不会针对某个具体类型特殊处理。
def maybe_to_list(maybe): return [maybe] if maybe is not None else [] print(maybe_to_list(42)) # [42] print(maybe_to_list(None)) # []Python 代码不够类型化,但能很直观地展示“上下文转换”的含义:不改变内部值的语义,只改变包装结构。这个例子在 Haskell 里写作:
maybeToList :: Maybe a -> [a] maybeToList Nothing = [] maybeToList (Just x) = [x]函子和自然变换是范畴论中两个高频出现的概念,也是理解 Monad、Lens、Profunctor 的前置知识。对多数工程师而言,先理解 Functor 就够了,自然变换可以作为进阶方向。
8. 学习过程中的常见误解与排查
学范畴论最容易栽跟头的地方不是计算,而是概念层面的误判。下面整理了几个高频误解和排查方向。
| 常见误解 | 正解 | 排查方向 |
|---|---|---|
| 把范畴论里的对象当成集合 | 对象是抽象节点,可以有内部结构也可以没有 | 当你试图“查看对象内部”时,停下来,改看箭头 |
| 把所有态射都当成函数 | 函数是态射的重要实例,但态射还可以是逻辑蕴含、数据库关系等 | 问自己:这里是否只依赖源、目标、复合?是的话就是态射 |
| 忽略恒等态射 | 每个对象必须有恒等态射,这是范畴公理的一部分 | 画图时检查每个节点是否有自环箭头 |
| 混淆函子和普通函数 | 函子是范畴之间的映射,而不是单个集合之间的映射 | 看到fmap时,注意f a这层包装不能丢失 |
| 认为交换图自动成立 | 交换图是等式约束,需要逐个路径验证 | 从同一起点分别走不同路径,对比终点是否一致 |
| 不理解“抽象”的意义 | 抽象是为了让不同领域的结构复用同一种推理方式 | 试着在关系数据库里找一组“对象 + 外键 + 路径”的对应关系 |
排查的具体做法也很简单:当一段范畴论推导看不懂时,先用纸笔把箭头画出来;如果图已经画出来了还是看不懂,就找一个具体范畴实例,比如Set范畴或者 TypeScript 类型范畴,把抽象箭替换成具体函数,再用代码跑一遍。多数卡住的情况都是因为缺少一个“具体落点”。
9. 最佳实践与后续学习路线
学习范畴论不需要一上来就啃大部头教材,更推荐按下面的路径推进。
第一,先建立“对象 + 箭头”的视觉直觉,不要急着记定义。遇到一个概念,先画图,再写定义。画图的时候,至少包含两个对象、两个箭头和一个复合箭头。
第二,每种定义都写至少一段代码。用 TypeScript 写状态映射和复合,用 Haskell 写类型类,用 Python 写可能函数。代码能把抽象概念固定到具体语法上,避免“看书看懂了,动手就忘”。
第三,从Set范畴入手,再扩展到列表范畴、Maybe范畴。Set范畴的优点是直观,但习惯之后要提醒自己:范畴不限于集合。可以尝试把编程里的状态迁移看成一个范畴:状态是对象,状态迁移是态射,连续迁移是复合。
第四,每学习一个新概念,都检查它是否保持了“复合”和“恒等”。函子必须满足fmap id = id、fmap (g . f) = fmap g . fmap f。用一个二维数组或Maybe实测这两个等式,概念会牢固很多。
第五,控制学习节奏。范畴论是一个可以无限深入的分支,第一次接触不要试图一天理解 Yoneda 引理。先掌握 Functor,再看 Applicative,之后才是 Monad。顺序反过来会非常痛苦。
后续可以继续了解的方向包括:Monad 及其在 IO、异步编程中的应用;Lens 与组合 lens 的数学基础;Profunctor 与双向映射;以及 Yoneda 引理与其在序列化、缓存设计中的直观解释。每个方向都能以本文的“集合到范畴”为基础往上叠加。
10. 总结
这个主题最值得尝试的地方,是把范畴论从“黑板上的符号”变成“可以画出来、可以用代码运行”的结构。从集合到范畴,本质上只做了一次视角切换:把注意力从元素转到箭头,把函数抽象成可复合的关系。
建议你最先验证的概念是复合与恒等。用 TypeScript 写一个compose,跑通单位律和结合律,再去看 Functor 的fmap,会发现后面的一切都是在这个基础上加包装层。
最容易踩的坑就是“把对象当成集合”。带上这个执念读后面的内容,会一直觉得范畴论在绕弯子;一旦接受对象只是箭头的端点,很多符号定义就通顺了。
下一步可以继续沿着“函子 -> 自然变换 -> Monad”的方向走,结合你正在使用的语言,找到对应的map、flatMap、compose实现,逐个验证它们是否满足范畴律。这套从集合到范畴的图解思路,可以作为长期使用的抽象思维脚手架。
