ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

从集合到范畴:用代码理解函子与Monad的编程抽象

从集合到范畴:用代码理解函子与Monad的编程抽象 范畴论Category Theory听起来像是远离工程的理论数学但过去十年它已经频繁出现在编程语言设计和库的文档里Haskell 的 Monad、Rust 的 trait、Scala 的 typeclass甚至前端的状态管理库都在用同一套抽象语言。这次我们不从最抽象的公理讲起而是沿着一条最自然的路径走从你已经熟悉的集合Sets出发重新审视它擅长什么、忽略什么然后一步步构造出范畴Categories的定义并用 Python、Haskell 风格的代码把抽象概念变成可以运行、可以验证的小例子。这篇文章会更像一次“概念实测”第 1 节是核心概念速览让你先知道整张地图长什么样第 2 节到第 7 节建立从集合到范畴、函子、自然变换的完整推导第 8 节把范畴论和编程中的 Functor、Monad 对上号第 9 节给出学习路线、常见误区和自查清单。如果你读过一些函数式编程文章但被“对象、态射、自然变换”劝退过这篇文章应该能帮你把缺口补上。1. 核心概念速览先给出一张总表后面所有内容都在解释这张表。概念一句话理解数学记法编程对应物集合 Set由元素组成的整体强调内部元素A {1,2,3}Python set / list态射 Morphism对象之间的联系f: A → B函数 f: A - B范畴 Category对象 态射 复合 恒等C, D类型系统、接口规范函子 Functor范畴之间保持结构的映射F: C → Dfmap / map自然变换 Natural Transformation函子之间的映射η: F ⇒ G多态函数、泛化操作幺半群 Monoid单个对象范畴的特殊形态(M, ⊗, e)字符串拼接、加法Monad自函子范畴上的幺半群(T, η, μ)可组合的计算上下文这里最关键的转变是集合论把“元素”当作第一等概念范畴论把“态射”也就是对象之间的关系当作第一等概念。这个转变听起来很小但它的影响非常大。后面会反复看到同一个定义在“元素视角”和“态射视角”下会呈现完全不同的复杂度。阅读建议不必一次记住所有术语。你可以把第 1 节当作索引学到后面忘记某个词时再翻回来对照。2. 从集合到范畴为什么需要新语言集合论在现代数学里的地位是奠基性的。ZFC 公理体系用“元素属于集合”这一条关系定义了自然数、实数、函数、关系几乎所有数学对象都可以归约为集合。程序员也天然容易理解集合一个 list 就是一堆元素一个 dict 就是键值对的集合一个函数就是输入集合到输出集合的映射。这种“万物皆集合”的视角优点是具体、可计算、可操作。但集合论在处理“结构”的时候有两个不可忽视的局限。第一个局限是内部元素太具体。两个结构一样的群如果元素名字不同在集合论里就是两个不同的集合需要额外定义“同构”才能说它们本质上一样。也就是说集合论总是先给你元素的内部细节再让你通过同构关系把这些细节抹掉。范畴论反过来它默认你关心的不是元素长什么样而是对象之间有哪些联系、这些联系如何复合。第二个局限是关系本身没有被一视同仁。集合论里函数是一种特殊的关系群的同态、拓扑空间的连续映射、程序的类型转换都需要各自定义各自证明性质。范畴论则把所有“对象之间的结构保持映射”统一称为态射然后只保留这些态射之间的复合规则。这样一抽象群范畴、拓扑范畴、类型范畴就有了完全相同的语法骨架。所以“从集合到范畴”并不是说集合论错了而是说集合论适合描述“一个结构的内部构造”范畴论适合描述“一类结构之间的共同行为”。后者更接近编程里接口和抽象的思考方式。这也是为什么范畴论被大量用在程序设计语言理论中当你想描述“所有类型的盒子都能被 map”、 “所有可失败的计算都能被组合”这类跨类型的统一性质时集合论的语言显得太具体范畴论的语言反而更自然。3. 范畴的定义四要素与两条公理范畴的定义并不复杂它总共只有四个组成部分和两条公理。一个范畴 C 由以下数据构成对象类 ob(C)包含这个范畴里的所有对象。对任意两个对象 A、B都有一个态射集合 Hom_C(A, B)其中的元素写作 f: A → B。复合规则对于 f: A → B 和 g: B → C存在复合态射 g ∘ f: A → C。恒等态射对每个对象 A存在态射 1_A: A → A。这些数据还要满足两条公理结合律对于 h: C → Dg: B → Cf: A → B有 h ∘ (g ∘ f) (h ∘ g) ∘ f。单位元律对于 f: A → B有 1_B ∘ f f 且 f ∘ 1_A f。注意这里的对象没有要求有元素态射也没有要求是函数。只要你找到一类“东西”当对象一类“关系”当态射并且复合满足两条公理你就得到了一个范畴。用代码来表达这个定义可以写一个最小的接口骨架from typing import Callable, Any # 范畴定义的最小接口骨架 # 实际使用时对象、态射都由具体范畴提供 # 恒等态射对每个对象 A提供一个 id_A def identity(x: Any) - Any: return x # 复合规则任意 f: A - B, g: B - C得到 g ∘ f: A - C def compose(f: Callable[[Any], Any], g: Callable[[Any], Any]) - Callable[[Any], Any]: return lambda x: g(f(x))这段代码很抽象但它严格对应上面的定义identity 对应恒等态射compose 对应复合规则。后面验证具体范畴时只需要检查这两个函数行为是否满足两条公理。4. Set 范畴拆解用代码验证抽象定义理解了抽象定义后最关键的一步是把它套到最熟悉的例子上。集合与函数构成一个范畴通常记作 Set对象是所有集合。态射 f: A → B 是集合之间的函数。复合就是函数复合先执行 f再执行 g。恒等态射是恒等函数 id(x) x。Set 范畴是最直观的例子但它有个特点它的对象类太大了包含了所有的集合所以数学上叫“大范畴”。集合本身是对象而“所有集合的集合”会引发罗素悖论因此集合论里不允许这样直接构造。范畴论用“类”而不是“集合”来容纳对象绕开了这个悖论。这个概念对程序员来说类似于你不需要真的有“包含所有列表的列表”只需要有一个概念上的总称。现在用 Python 构造一个有限的小例子验证 Set 范畴的公理。取三个集合A {1, 2, 3} B {a, b} C {x, y} def f(x): A - B 的函数 if x 1 or x 2: return a return b def g(y): B - C 的函数 if y a: return x return y def h(z): C - C 的函数 return z def identity(x): return x def compose(f, g): 返回 g ∘ f即先执行 f再执行 g return lambda x: g(f(x)) # 复合封闭性g ∘ f 是从 A 到 C 的函数 gf compose(f, g) print(g(f(1)) , gf(1)) print(g(f(3)) , gf(3)) # 结合律h ∘ (g ∘ f) (h ∘ g) ∘ f left compose(compose(f, g), h) right compose(f, compose(g, h)) assert all(left(x) right(x) for x in A) print(associativity holds) # 单位元律1_B ∘ f f f ∘ 1_A left_id compose(f, identity) right_id compose(identity, f) assert all(left_id(x) f(x) for x in A) assert all(right_id(x) f(x) for x in A) print(identity law holds)运行这段代码你会得到 g(f(1)) x、g(f(3)) y以及两行断言通过。这看起来平淡无奇但它验证了一个范畴需要满足的全部条件复合封闭、结合律、单位元律。以后接触任何新范畴你都可以用同样的方式写一组断言来检查它是不是真的范畴。这种“用代码验证数学公理”的方法是学习范畴论非常高效的方式。因为范畴论的公理本质上就是一组类型签名加上等式约束它们非常适合翻译成函数和断言。5. 三个基准例子偏序集、幺半群、类型范畴只有 Set 一个例子不够。范畴论的价值在于同一个定义能覆盖完全不同的对象。下面三个例子建议当作“基准例子”长期保留遇到抽象概念想不通时就拿这三个例子去套。第一个是偏序集范畴。给定一个偏序集 (P, ≤)可以构造一个范畴对象是 P 中的元素对任意 a, b ∈ P当 a ≤ b 时存在唯一的态射 a → b否则没有态射。复合关系来自偏序的传递性如果 a ≤ b 且 b ≤ c那么 a ≤ c这就是 g ∘ f。恒等态射来自自反性a ≤ a 就是 1_a。这个例子很关键因为它说明了态射不一定是“函数”它可以是“小于等于关系”。两个元素之间最多只有一个态射这类范畴也叫“瘦范畴”。第二个是幺半群范畴。幺半群是带有结合运算和单位元的代数结构比如整数加法配上 0字符串拼接配上空字符串。任意一个幺半群 (M, ⊗, e) 都可以看成只有一个对象 * 的范畴对象只有一个所有态射就是 M 中的元素复合就是幺半群的运算 a ⊗ b恒等态射就是单位元 e。反过来任何一个只有一个对象的范畴也自动给出一个幺半群。这个例子揭示了“单对象范畴”和“幺半群”是同一种东西的两种视角。第三个是类型范畴在 Haskell 社区常被称为 Hask。对象是类型Int、String、Bool 等态射是纯函数复合就是函数复合恒等态射就是 id。这个例子对程序员最友好因为它几乎不需要新知识。注意这里的对象是类型不是值。Int 是一个对象Int - String 是一个从 Int 到 String 的态射。你平常写的绝大多数纯函数都是这个范畴里的态射。这三个例子覆盖了三种完全不同的“味道”偏序集范畴里态射是关系幺半群范畴里对象只有一个类型范畴里对象是类型。共同点是它们都满足同一条复合规则和同两条公理。这就是范畴论的抽象能力不去关心对象内部有什么只关心对象之间如何关联。6. 函子范畴之间的保结构映射有了多个范畴自然要问范畴之间能不能建立映射这就是函子Functor。一个函子 F: C → D 包含两部分对象层把 C 的每个对象 A 映射到 D 的某个对象 F(A)。态射层把 C 的每个态射 f: A → B 映射到 D 的态射 F(f): F(A) → F(B)。函子还要保持范畴结构也就是满足两条条件F(1_A) 1_F(A)恒等态射映射到恒等态射。F(g ∘ f) F(g) ∘ F(f)复合被保到复合。这里的“保结构”是函子的灵魂。它保证了映射不是随便乱投而是尊重两边范畴的复合规则。程序员最熟悉的函子例子是 List。在 Haskell 里列表的 map 操作就是一个函子class Functor f where fmap :: (a - b) - f a - f bfmap 就是态射层的映射当你有一个函数 f :: a - bfmap f 会把一个 f a 变成 f b。fmap 保结构的条件对应两条几乎人人都在用但没人会怀疑的性质fmap id idfmap (g . f) fmap g . fmap f用 Python 也能直观表达def fmap_list(fn, xs): return [fn(x) for x in xs] # 恒等律 assert fmap_list(lambda x: x, [1, 2, 3]) [1, 2, 3] # 复合律 def add1(x): return x 1 def mul2(x): return x * 2 assert fmap_list(add1, fmap_list(mul2, [1, 2, 3])) \ fmap_list(lambda x: add1(mul2(x)), [1, 2, 3]) print(list functor laws hold)除了 ListMaybe、Either、Promise、Future 都可以定义成函子。你可以做一个练习给 Python 里的 dict 定义 fmap让它只作用于值而不动键然后验证上面的两条定律。能通过你实现的就是一个合法函子。函子还有很多类型遗忘函子把群结构忘掉只看底层集合自由函子反过来给集合加上一层结构还有嵌入函子、同构函子等等。理解函子的关键不是记住这些名字而是记住“函子是尊重复合规则的范畴间映射”这一句话。7. 自然变换函子之间的态射有了范畴、函子下一步很自然函子之间的映射叫什么叫自然变换Natural Transformation。函子把范畴映射到范畴自然变换则把一个函子变成另一个函子。给定两个函子 F: C → D 和 G: C → D一个自然变换 η: F ⇒ G 由一组态射组成对 C 中每个对象 A都有一个 D 中的态射 η_A: F(A) → G(A)。并且这组态射必须满足自然性条件对任意态射 f: A → B下面的等式成立G(f) ∘ η_A η_B ∘ F(f)这个等式画成图就是一个交换正方形。它说明先做 F 的映射再做自然变换和先做自然变换再做 G 的映射结果一致。用文字描述就是“两种路径殊途同归”。编程里最常见的自然变换例子是 reverse 函数。对于任意类型 areverse :: [a] - [a] 可以看作 List 函子到 List 函子自身的自然变换。自然性条件说的是先把列表里的每个元素做一次 f 变换再反转等于先反转再对每个元素做 f 变换。写成等式就是reverse (map f xs) map f (reverse xs)这个性质太常见了以至于你有意无意都在用它。简单验证一下def reverse_nat(xs): return xs[::-1] # 自然性map f ∘ reverse reverse ∘ map f def map_list(fn, xs): return [fn(x) for x in xs] xs [1, 2, 3] f lambda x: x ** 2 assert map_list(f, reverse_nat(xs)) reverse_nat(map_list(f, xs)) print(naturality holds)另一个例子是 length。length :: [a] - Int 可以看作从 List 函子到常量函子的自然变换前提是 Int 部分不随类型变化而变化。自然性条件对应 length (map f xs) length xs也就是 map 不改变列表长度。你看这些你早就知道的性质在范畴论里都来自同一个统一原理自然性。自然变换的意义在于它是范畴论第一次出现“高维”的地方。对象之间是态射范畴之间是函子函子之间是自然变换。这个往上再走一层的模式让范畴论能统一描述很多“规则之间的规则”也为后面理解 Monad 铺好了路。8. 从范畴论到编程Functor、Applicative 与 Monad现在可以把前面所有概念串起来落到程序员最关心的几个抽象上。Functor 已经在第 6 节讲过了它对应的是一个类型构造器加上一个合法的 fmap。在代码里大多数容器类型都可以实现 Functor这是最基础的一层抽象。Applicative 是 Functor 的增强。它除了 fmap还提供两个操作pure 把单个值放进效果上下文里* 把上下文里的函数应用到上下文里的参数。如果把 Functor 理解成“允许对盒子里的值做普通函数变换”Applicative 就是“允许在盒子里做多参数函数应用”。Monad 则是“可组合的计算上下文”。它建立在自函子之上提供了两个核心操作return也叫 pure 或 unit把值装进上下文对应自然变换 η: 1 → T。join或者用 展开把嵌套上下文压平对应自然变换 μ: T ∘ T → T。Monad 本质上就是“自函子范畴上的幺半群”这句话在 Haskell 社区流传很广。它听起来很绕但拆开来看并不复杂自函子从一个范畴映射到自身的函子比如 List - List。幺半群有一个结合运算和一个单位元。合起来Monad 就是给自函子 T 配备两个自然变换 η 和 μ使得它们满足结合律和单位元律。用 Haskell 的 Monad 定律来看更清晰return a f f a m return m (m f) g m (\x - f x g)这三条定律分别对应范畴公理里的单位元律和结合律。也就是说Monad 不是某种特例它只是“把范畴的结构复制到了自函子的世界里”。Python 里也能写出一个简化版的 Maybe Monadclass Just: def __init__(self, value): self.value value def bind(self, fn): return fn(self.value) def __repr__(self): return fJust({self.value}) class Nothing: def bind(self, fn): return self def __repr__(self): return Nothing def safe_div(x, y): if y 0: return Nothing() return Just(x / y) # 链式调用成功则继续失败则短路 result Just(10).bind(lambda x: safe_div(x, 2)).bind(lambda x: safe_div(x, 5)) print(result) # Just(1.0) failed Just(10).bind(lambda x: safe_div(x, 0)).bind(lambda x: safe_div(x, 5)) print(failed) # Nothing这里的 bind 就是 它把“可能失败的计算”串成一条链任何一步失败都会自动短路。这种能力并不是范畴论发明的但范畴论解释了它为什么是正确的、为什么能和其他抽象组合。学习建议是分层掌握先确保 Functor 的 fmap 定律能写能验证再看 Applicative 的 pure 和 *最后看 Monad 的 return 和 bind。不要一上来就背 Monad 定律而是回到第 6 节的函子定律从那里一层层往上推。9. 学习路径、常见误区与建议范畴论的门槛不在定义本身而在抽象层级跳跃。给几条经过验证的学习路径和自查建议。入门可以读 Lawvere 与 Schanuel 的《Conceptual Mathematics》概念数学这本书面向完全没接触过范畴论的读者几乎没有前置要求。进阶可以选择 Bartosz Milewski 的《Category Theory for Programmers》它在网上免费发布例子全是代码非常贴合程序员的认知方式。再往上Emily Riehl 的《Category Theory in Context》是更系统的研究生教材适合已经建立大量例子之后再去读不建议作为第一本。工具方面nLab 是范畴论社区维护的百科全书式资料适合查概念不适合系统阅读。常见误区正确理解范畴论要取代集合论它不取代任何理论只是换个角度描述结构对象必须有内部元素对象可以没有元素甚至只有一个对象态射必须是函数态射可以是关系、序关系、程序、证明学会术语就学会了范畴论术语只是地图必须用例子验证公理上来就读最严格的教材先从概念型教材或代码型教材入手Monad 是函数式编程的专属它是范畴论中的一般结构编程只是应用之一自查时可以对照这张表问题现象可能原因排查方式解决方案范畴定义记不住没有例子支撑回到 Set 范畴逐条对照固定使用 Set、偏序集、幺半群三个基准例子复合方向总是搞反记号不熟先写出 g ∘ f 的类型A → C反复口算 g(f(x))函子保结构理解困难跳过具体例子对 List 验证 fmap id id用代码写断言自己试一遍自然变换交换图看不懂不熟悉交换图把图翻译成等式用 reverse 和 length 练习自然性等式Monad 抽象完全晕前面积累了太多疑问回看函子和自然变换两章先只掌握 return 和 bind 的类型工程实践上还有几条通用建议。第一每个新定义都拿三个基准例子去套一遍写代码验证比读十遍定义更有效。第二维护一个自己的“例子库”把 Set、偏序集、幺半群、List、Maybe 的实现放在同一个文件里遇到新概念就加一个验证函数。第三遇到抽象概念时先退回到它依赖的上一层概念Functor 不懂就查自然变换自然变换不懂就查函子函子不懂就回到 Set 范畴。如果你的目标只是读懂函数式编程相关文章优先级应该是对象、态射、复合、恒等然后是函子和 fmap再是自然变换最后才是 Monad。如果目标是读范畴论教材上述顺序不变但每个概念需要更严格的证明和更多例子支撑。从今天开始可以做的验证这篇文章的最终输出其实是一个可执行的检查清单你可以在自己的电脑上把这套小例子跑一遍然后试着给偏序集范畴写一个验证脚本。偏序集范畴的复合就是传递性恒等就是自反性这两条验证顺了你对范畴公理的理解就很牢固了。下一步有两个方向一个是往深走研究极限、余极限、伴随函子这些是范畴论的核心对象也是阅读 Riehl 教材的必备基础另一个是往应用走用 Functor 和 Monad 重构你手头的错误处理、异步流程和数据转换代码看看统一抽象能带来什么实际收益。对大多数程序员来说后者更能直接改变写代码的方式。
返回列表