Utopia
·Writings

幺半群与罗马下水道

周末看到一则推文:"Haskell 世界里有一句著名的话:"单子是自函子范畴上的一个幺半群"(A monad is a monoid in the category of endofunctors)。这是范畴论中最著名也最深入人心的描述之一。我曾经遇到的程序员里只有一个能在第一眼看见这句话就知道它说的是什么意思。"

十年前我一次看到这句话的反应:哎呦,装死了,最烦装逼的人。它用三个极度抽象的数学概念来解释第四个抽象概念,就像用"量子纠缠"来解释"为什么你的代码报空指针异常"一样。那些拗口的中文翻译——自函子、幺半群、范畴——不过是在给自己添麻烦,假装自己是数学家或逻辑学家。这些抽象词汇在中文语境中缺乏直观性,把本该"解释性的描述"变成了"名词的堆砌"。

而多年之后,我看到这个翻译的英文就一下子对它的意思豁然开朗了。Mon- (Mono) 前缀,后缀 -oid 表示“像……一样的”,Endo-: 这是一个非常通用的前缀,表示“内部的”,functor 也就是函子。“映射到自身的函子”(Map to itself),幺半群 (Monoid)这个翻译虽然在数学定义上极其精准(带单位元半群),但在字面上和“单子”毫无关系,这种描述我认为割裂了概念之间的联系。

聪明的陷阱

剥离数学术语,所谓的“自函子”(Endofunctor)就是一个容器,比如一个列表或者一个盒子。它有一个特质:你能对它里面的东西进行映射(map)

# 一个简单的容器 box = [1, 2, 3] # 我们可以把里面的东西映射成别的,但它依然是个列表 new_box = [x + 1 for x in box]

这很好理解。那么“幺半群”(Monoid)又是干什么的?在数学上它代表“组合”,但在编程的这个语境下,它其实就是一种把多层容器“拍扁”的能力。

试想一下,如果你对一个列表里的每个元素都做一个操作,而这个操作本身也会返回一个列表,会发生什么?

# 假设我们有一个函数,把一个数变成两个数 def split(x): return [x, x] # 如果直接 map,我们会得到“列表的列表” nested = [split(x) for x in [1, 2]] # 结果是 [[1, 1], [2, 2]] —— 这就是“两层洋葱”

这时候,你需要一种机制把这两层东西合并成一层,变成 [1, 1, 2, 2]。在范畴论里这叫 flatten 或 join,在工程里我们通常把“映射”和“拍扁”结合起来,叫做 flatMap

这就是那个听起来高深莫测的 Monad 的全部秘密:它是一个允许你把产生嵌套容器的操作(比如异步的 Promise,或者可能为空的 Optional)像做加法一样顺滑地串联起来,而不需要你去手动剥洋葱。

问题在于,当你遇到可能失败的操作,Monad 会自动判断:如果上游失败了,就自动切断下游,不再执行。这看起来很方便——你不用每行代码都写 if 判断了。但在实际工程中,中间遇到的不同错误需要不同的处理、不同的通知。把所有错误都变成一个统一的"失败"状态,然后让它在管道里静默传递,是在逻辑上的自嗨和在现实应用里的自杀。

这让我想起了很多大语言模型写的代码——很多都不会报错,但逻辑各种各样乱七八糟【甚至完全错误!】但很多情况下却能通过自动化测试【逆天了!】,直到有一天大厦崩塌【迟早的事】。

Monad 和 Rust 的类型系统设计本质上是同一种东西:想用机制来替代思考。现在人们太懒了,不想看全部的代码去理解错误在哪,而用这些机制来帮助自己。但类型安全不等于逻辑正确。Monad 保证了类型安全,但它不能保证你的业务逻辑不是一坨屎。

核电站悖论

想象一个核电站的控制系统。业务逻辑错误:控制棒应该插进去,结果业务逻辑出错拔出来了。但程序的内存安全、类型安全都完美无缺——没有内存泄露,没有空指针异常,变量类型完美匹配。结果核电站炸了。讽刺的是,因为程序没有崩溃,它反而更高效、更稳定地执行了那个毁灭性的错误指令。如果这时候程序因为内存泄露崩了,说不定还能救核电站一命。

业务逻辑都错了,内存安全还有用吗?最关键的是业务。类型错误和内存报错只是业务错误的一个副产品罢了。

为什么现在的编程语言都在疯狂卷"类型安全"和"内存安全"?因为那是编译器能做到的极限,也是程序员能偷懒的极限。检查内存越界是语法题,机器能自动做。检查业务逻辑——利息算没算对,阀门开没开反——这是阅读理解题,必须人脑去思考核对。现在的风气是:程序员懒得去思考复杂的业务逻辑,于是躲进"技术本身"的避风港里。他们会沾沾自喜地说:"看,我的代码通过了 Rust 的借用检查,我用了高级的 Monad,我是个牛逼的工程师。"实际上,他只是把一道数学题做对了,但把应用题做错了。

很多时候,我们遇到空指针异常或类型错误,本质上是没想清楚业务状态。为什么这里会是 Null?技术视角会说:忘了加判断,或者忘了用 Maybe Monad。业务视角会说:是因为这个用户是游客模式,游客模式下根本就不该调用"获取钱包"这个逻辑!如果你用 Monad 强行把 Null 处理掉了,给个默认值 0,你表面上消灭了报错,实际上掩盖了业务逻辑的漏洞。你让一个本该报错的地方悄无声息地滑过去了。

脑力守恒定律

Rust 的问题更加致命。人的大脑内存是有限的。在写 C/C++ 时,人们拿出 30% 的脑力管内存,70% 的脑力去思考订单系统怎么设计才不会死锁。写 Python 或 Go 时,拿出 5% 的脑力管语法,95% 的脑力去思考业务。写 Rust 时——对于普通程序员来说——需要拿出 90% 的脑力去跟编译器吵架:为什么这里生命周期不够?为什么那里不能借用?只剩下 10% 的脑力去写业务。

结果就是:好不容易把红色的报错消灭了,程序能跑了,人已经累瘫了。他根本没有余力去想:"我是不是少写了一个事务回滚?"或者"这个状态机是不是漏了一个状态?"这就导致了一个诡异的现象:看似内存安全,实则错得千奇百怪。因为人的心力和脑力是有限的,光搞这些内存体操,已经把能力给榨干了。又不是能跑起来,这程序就没错了。

在 Rust 项目里,经常看到一种斯德哥尔摩综合征:程序员被编译器折磨了一整天,终于编译通过的那一刻,获得了虚假的成就感。他觉得"编译器没报错,我的代码一定很完美"。大错特错!编译器只是告诉你"这代码不会发生段错误",它没告诉你"这代码算出来的钱是对的"。

糟糕的 C 代码是简单直接的烂——空指针会直接崩给你看。糟糕的 Rust 代码是扭曲的烂:为了避开生命周期,满屏的 .clone(),性能崩塌;为了避开所有权,把本该聚合的数据拆得支离破碎;为了通过编译,写了无数层嵌套,最后全部 .unwrap() 暴力破解。这种代码既没有 C 的性能,也没有宣称的安全,还失去了可读性。

Rust 太难了。打工仔根本就不会用,也写不好,只能让原本写得已经糟糕的 C 代码变得更糟糕——如果能写出来的话。Rust 本来是给写 Firefox 引擎、写操作系统内核的高手用的。但现在很多业务跟风,让写增删改查的普通打工仔去写 Rust。这就是灾难的开始。

大师的刻刀与防呆的安全带

在我开来,真正优雅的还是 C/C++,既可以简单,又可以复杂,支撑了十几亿人的现代基建。Linux 内核、Windows 操作系统、Nginx、Redis,乃至现在的 AI 框架底层。

这些代码之所以伟大,是因为它们是编程大师用人脑一行一行雕琢出来的。大师知道这里如果不释放内存会怎样,所以他精确地在 100 行之后释放了。这种代码充满了人类智慧的光辉。它是艺术品,是在刀尖上跳舞的优雅。

而 Rust 的逻辑是:"我不相信你是大师,我觉得你就是个会犯错的傻X,所以我把刀收走,给你发一把钝的剪刀,还要把你绑在安全座椅上。"

一旦系统膨胀到千万行级别,决定生死的永远是架构设计和业务逻辑的梳理,而不是你那个变量有没有被借用。真正的 Bug 都是逻辑炸弹:死锁、资源竞争导致的逻辑错误、分布式系统的一致性问题、缓存击穿。这些问题,Rust 的编译器一个都查不出来。你费尽心机过了编译,上线后该死锁还是死锁,该逻辑错误还是逻辑错误。类型安全一点用没有,最后还是爆炸。

失传的知识

新一代程序员越来越浮躁,特别是有了 AI 以后,什么都用 Python 胡乱堆一堆垃圾贴上去能跑就算了。原本的 C 和 C++ 的庞大基础设施未来会不会因为无人维护和维修逐渐腐烂,最后造成整个社会系统的轰然倒塌呢?

这不是杞人忧天。这种现象在历史上发生过无数次——古罗马的下水道和高架引水渠还在,但修缮它们的技师去世后,后人只知道用,坏了就只能看着它一步步崩塌,文明因此倒退了几百年。在数字时代,C/C++ 构建的底层设施就是我们的"高架引水渠"。

你可能会想:古罗马水道不就是堆石头吗?只要有图纸,谁都能修吧?但事实是,即使有图纸,后人也修不了。到了中世纪,人们看着这些宏伟的建筑,还以为是巨人或者魔鬼造的,就和我们现在看吉萨金字塔一样【只有外星人能造吧!】

后罗马时代丢失的不是图纸,而是工匠口口相传的"隐性知识"。古罗马混凝土泡在海里两千年不腐,甚至能"自愈"裂缝。古罗马工程师留下了建筑,留下了书,但配方失传了近两千年。直到最近几年,科学家才通过显微镜分析发现,原来是因为他们用了特殊的火山灰,并且用了"热混合"技术。书上只写了"加灰",没写"要趁热加",也没写"哪座山的灰"。这些是当时工匠的肌肉记忆和口耳相传的经验,没写在说明书里。工匠断代,技术就断了。

C/C++ 的代码文档可能写着:"这里加个锁"。但它没写的是:"为什么要在这里加锁?而不是在下一行?"也许是因为 20 年前的某个硬件架构有个 Bug,不加锁会乱序执行。也许是因为这个变量会被另一个不起眼的线程修改。现在的程序员看着文档,觉得"这锁没用啊,影响性能",顺手删了。结果系统在特定并发下挂了。代码是显性的,但"为什么要这么写"的决策过程是隐性的,这才是最容易失传的。

更可怕的是工具链的消亡。假设你要维护一个 15 年前的银行核心系统。它依赖 GCC 3.4 编译器,它依赖一个叫 lib-magic-v1 的库——这个库的公司 10 年前倒闭了,源码找不到了,只有二进制文件。它只能跑在 RedHat 4.0 上,现在的服务器硬件根本装不上这个系统。你有代码,但你造不出能运行它的环境。这就是为什么很多老系统只能跑在虚拟机里,永远不敢动,因为一旦重新编译,就再也跑不起来了。

没有捷径

99% 的新一代程序员变成了你之前最讨厌的人"啥也不懂的傻X产品经理":他们用 Python、JavaScript 快速拼接 API,写出能跑的业务。他们不懂内存布局,不懂指针,不懂系统调用。而那 1% 真正懂 C/C++、懂硬件交互、懂极致优化的"老法师",正在老去、退休。

危险在于知识的断代。代码本身不会腐烂,但理解代码的"隐性知识"会消失。当 Linux 内核里某段 20 年前写的、极其精妙但晦涩的调度算法出了 Bug,现在的年轻人看着 AI 解释的一知半解的注释,根本不敢动,只能在外面打补丁。久而久之,底层代码就变成了"不可触碰的神之遗迹",大家只能盲目崇拜、小心翼翼地调用,一旦内部崩溃,就是灾难。

全球金融系统的底层至今还跑着上世纪 70 年代的 COBOL 代码。当年的 COBOL 程序员都七八十岁了,现在的年轻人根本不学。结果就是:银行不得不返聘那些爷爷辈的程序员,开出天价时薪请他们回来修 Bug。C/C++ 的基建未来很可能也会面临这种局面:它是数字世界的承重墙,但没人知道怎么修补墙里的裂缝。

Haskell、Rust 这些语言的设计者们以为找到了捷径——用类型系统、用 Monad、用编译器的强制检查来解决复杂性。但这世界没有捷径。复杂度是守恒的,你不在这里付出代价,就会在那里付出代价。 你用 Monad 掩盖了错误处理的复杂度,代价是你必须理解范畴论。你用 Rust 的借用检查避免了内存错误,代价是你必须把 90% 的脑力花在类型体操上。最终,本来就大的心智负担变得更大了。

当我们把希望寄托在编译器、类型系统、设计模式这些"智能接头"上时,我们实际上是在放弃思考。我们以为自己变聪明了,实际上是聪明反被聪明误。