简单不等于小

查看原文 HN 讨论

文章摘要

作者 jyn 写于 2026 年 8 月 27 日的一篇软件架构随笔。起因是她做过一场题为《精确、一致、可靠的代码覆盖率》的演讲,讲的是一个让她所在公司花了九个月才调通的极其棘手的 bug。演讲结尾,她的朋友 Predrag 问:我们该怎么思考工具的构建方式,才能让这类史诗级的调试故事不再必要?她当时的回答是”我们需要优先考虑简单性……那条覆盖率流水线的图里有太多节点了,工具链太复杂了,我们需要重新思考计算方式”。她说自己对这个答案并不满意——于是有了这篇文章。

破题的例子是一段 Unix 管道。 她给出一条统计文件词频的经典管道:tr 把非字母字符换成换行并压缩重复、tr 转小写、sort、uniq –count、再按数值逆序 sort。她说这大概就是多数人想到”简单”时脑中的画面:每个程序都很小,被设计成能这样临时拼接,简洁而且还算易读。随后她给出一段等价的 Clojure 程序:slurp 读文件、re-seq 正则切词、map 转小写、frequencies 计数、sort-by 排序、run! 打印——名字更多,用了高阶函数。

然后她提出一个微小的改动需求:按原文出现顺序输出。 Clojure 版本的改法相当直白——把词序列存进一个变量,用 frequencies 得到频率映射,再对 distinct 后的词序列遍历查表。Bash 版本则彻底崩坏:需要一堆临时文件、nl 编号、多重 sort 按不同键排序、uniq –skip-fields、join 做表连接、最后 cut 取字段。她的结论是:”这是因为我们最初那个程序小,但不简单。”

核心定义来自 Rich Hickey 的《Simple Made Easy》。 Hickey 从词根出发定义 simple——”sim-plex”,只有一股辫子;与之相对的是 “com-plex”,把多股东西编在一起。作者在文中用”耦合”(coupled)作为”复杂”的同义词以避免歧义。在这套语言下,原来那条管道的问题就清楚了:它小,但它耦合。具体耦合在 sort | uniq --count 这一段——uniq 的手册明确写着重复行如果不相邻就检测不到,所以可能需要先排序。Unix 里没有原生的 frequencies 等价物,这个组合是最接近的;它不仅性能更差(必须把全部输入收进内存才能继续),而且把聚合和排序绑死了。这正是让”把排序和聚合分开”变得如此困难的原因,最终只能通过文本文件做表连接这种别扭的舞蹈。

她对”Unix 哲学”提出了一个精确的批评:”写只做一件事并做好的程序”——她认为”做一件事”通常被理解为关于简单性,但实践中它其实是关于大小。”Unix 工具很小,但它们不简单。”

反方向的例子是 Google Drive for Desktop。 这是一个体量庞大的程序:依赖平台特定的文件监视器、”整个 Google3”、一个流式同步网络客户端,以及冲突解决逻辑。但对用户来说它感觉相当简单:安装、告诉它监视哪个文件夹、告诉它文件主要放本地还是放 Google 的基础设施,剩下的它全包。大不等于耦合。

解耦的第二个例子来自 Rust 与 Clojure 的对比。 在 Rust 里,你可以用 map 也可以用 struct 把名字关联到值。struct 给你的是”已知存在的字段”——map 取值必须 unwrap(),因为类型检查器不知道 map 里有哪些键,而 struct 可以直接访问。但不那么显然的是,struct 丢失了运行时信息:遍历 map 很容易,遍历 struct 就得写过程宏。原因在于”Rust 里 struct 把类型检查和一种固定的数据表示耦合在了一起,你没法只要其中一个”。Clojure 则可以:那里 struct 就是 map,你不是定义一个类型,而是标注一个 map 被允许拥有哪些字段。她展示了用 Malli 库在运行时做类型检查的写法,并强调这个标注是可检视的——你可以写个 schema->md 函数做出自己的迷你 Rustdoc,不需要对接编译器 API,同时还不放弃类型安全、反射和遍历 map 值的能力。Typed Racket 用宏做了类似的事,只是把检查放在编译期。

“什么时候小才有用?” 她的回答很务实:当你作为维护者没有太多资源投入时。也许你是 Brian Kernighan,程序跑在一台真的 PDP-11 上;也许你是每月只有几小时的开源维护者;也许你所处的环境里做成任何事都算胜利,只能为你真正想建的功能争取到一小部分支持。这些都是保持程序小的好理由。但小不等于简单,而”程序什么时候该简单”的答案是:永远。引入耦合几乎没有好处,它让你更难维护,对用户也更不灵活。

“怎么写出简单的程序?” 她坦承这是难的部分:你需要对程序有好的心智模型,还需要品味,而品味是她还不知道该怎么教的东西。有时候你还得”忍下来把那件难的事写出来”。CSS 和 SQL 是高度解耦的(大体上):你写一份关于你想让程序做什么的声明式规格,浏览器引擎或数据库运行时去搞清楚怎么做。这非常非常难——光是 SQLite 就投入了数百人年才让它可靠工作,Blink 可能投入了数万人年。在某些领域,这就是让你能写出解耦程序所需要的代价。但也不是所有程序都值得花那么多时间:”硬核的技术工作可能是个扑火灯问题——它会吸引某一类热爱幻想代码可能、应该、能够如何运作的人。有时候还不如放下工具,去阳光下睡一觉。”

文章最后回到开头:她修好之后那条覆盖率流水线其实变得更大了,不是更小;但它同时变得更简单了,因为数据流图各部分之间隐藏的依赖变少了。她预告了后续要写的题目:如何培养品味;程序如何在垂直整合的同时保持解耦;以及如何构建大型系统而不让它变复杂。

HN 评论精华

99 条评论,讨论质量相当高,主线是对作者那条 Unix 管道例子的集体反击,以及一场关于”简单”这个词到底能不能被定义的哲学辩论。

最强的反驳是:作者根本没用对 shell。 JoachimSchipper 给出了一段优雅得多的实现,思路是回到经典 shell 工具被设计时的”排序—累加”范式(这种范式使用 O(1) 内存,靠磁盘排序):tr 切词转小写后用 nl 编号,按词和行号两个键排序,uniq 跳过第一字段并计数,再按首次出现的行号排序,最后用 awk 整理输出格式。他后来详细解释了原理:对输入 “foo bar FOO qux FOO foo”,先变成带行号的序列,按词(再按行号)排序后 uniq 就能给出”计数 首次行号 词”,最后按首次行号排一次序即可。他强调关键在于这个范式设计于另一个时代——你的数据绝对装不进内存,所以你用 sort(1) 在磁盘上排序,让自己的软件只用常量内存。aozgaa 补充说这非常符合著名的 McIlroy/Knuth “对决”的精神:牺牲一些效率(通过额外的簿记或排序)来换取可组合性。

其他人则指出禁用 awk 是不公平的。stianhoiland 说”这个例子比看上去还要刻意,因为他本可以直接用 awk”,并补充”awk 就是一堆哈希表”。atiedebee 和 aozgaa、sgarland 各自给出了 awk 一行/一段的解法。somat 把这个批评推到了最狠的地方:”坚持’只用’ shell 是很奇怪的,shell 本质上是一个进程编排器,不让它用进程就是在否定它大部分的功能。这等价于说’用 Python 做这个,但你不许用任何模块’。”

Snarwin 提出了更根本的一点:”Unix 命令行的核心特性之一就是它可被用户扩展。如果没有一个原生的 frequencies 命令,你可以自己写一个,它会和你 PATH 里的其他任何二进制文件享有完全同等的待遇。这完全符合 Unix 那种实现保持简单的哲学。”diegocg 说得更直白:”论证从’Unix 管道不简单’滑到了’基于 Unix 的操作系统默认没有 Clojure 的 frequency 函数所以更差’。”

但也有人站在作者这边,而且理由相当扎实。 fwlr 的表述最精炼:”Unix 管道是 easy 的,而且是个非常好的抽象,它的抽象边界选得极其出色,但它们不 simple。(我不止一次见过同事为了往 Unix 管道里喂数据而实现一个环形缓冲区!)”ElectricalUnion 列了一张清单来质问那些说”管道就是简单”的人:你记得检查另一个被 spawn 的进程的退出码吗?对进程链里所有进程 waitpid 了吗?双向传播和处理 SIGINT/SIGTSTP/SIGPIPE/SIGHUP 了吗?切换 stdio 缓冲模式了吗?记得在写满 64KB 管道缓冲区之前统计 write 实际写了多少字节并在未写完时阻塞了吗?结束时 flush 并关闭管道留下的所有文件描述符了吗?”正因为这类原因,我不信任任何非平凡的、非 shell 的东西能正确使用管道。”

unscaled 给出了一个更精准的切分:”文章在这一点上有点弱,因为这不是关于 Unix 管道,而是关于 POSIX shell 工具太简陋。但 Unix 管道也确实不简单。”他列了两条:管道只能流原始字节,所有处理列表的程序都必须用分隔符(通常是换行)分项,如果你要处理含有该分隔符的数据就有得受;以及 shell 默认隐藏错误。他的判词是:”这又是 New Jersey 路线:我让我的代码(操作系统、shell)更容易写,作为回报让我的用户(工具作者)的日子更难过。”

Barrin92 从系统论角度给出了本帖最有启发性的一条:”复杂性在于部件之间的交互,而不在于部件本身的属性……Unix 哲学对系统思维有一种蓄意的漠视。Russ Ackhoff 有个房子的类比:你怎么设计房子里的房间?永远只能把整栋房子放在心里去设计。你不能指望房间自己就行,你必须始终检查一个新房间是否改进了整栋房子;如果哪里不对,你修房间,不是修房子。Unix 工具对房子是漠视的。Clojure 之所以好用,是因为它的每一部分被设计出来不只是为了改进它自己,而是为了改进 Clojure。Emacs 在这方面也类似。”

getnormality 把这篇文章接上了一条著名的血脉:《The Rise of Worse is Better》。 那篇经典文章对比了 MIT/斯坦福风格与 New Jersey 路线,两边对”简单性”的定义只差一句话——MIT 认为接口简单比实现简单更重要,New Jersey 认为实现简单比接口简单更重要。getnormality 的映射是:本文的”简单”对应 MIT 式简单(对用户的简单),”小”对应 New Jersey 式简单(对开发者的简单)。他随即提出了一个耐人寻味的问题:”我怀疑两派的张力根源在于用户/开发者这个区分本身的模糊性。开发者也是用户。”ux266478 补了一个绝妙的反讽:Lisp 是符号 AI 领域的 New Jersey 路线——美国的知识工程师被视为薪水过高、毫无数学优雅性、可信度极低的过程式黑客,而这恰恰和这些 Lisp 机用户看待 Unix 和 C 程序员的眼光一模一样。mitxela 给了个一句话版本:”所以只有 nerd 用 Linux(对开发者简单),而所有人用 Office(对用户简单)。抱歉,我是说 Microsoft Copilot 365。”

关于”简单”能否被定义的辩论也很精彩。 jerf 的立场是彻底的:”看着人们争论什么是简单大约看了十年,我基本上已经断定它不是一个定义良好的术语。我们确实能认出复杂,至少当它大量出现时;但简单不只是复杂的缺席。我们都在试图往同一个词里塞进至少三个概念,它们不仅不正交,还经常互相冲突。”在被追问后他拆出了三个:词元少(最字面的简单,但正如文章指出的那样不一定吻合)、易于复用(函数式编程追求的那种,Haskell 每个毛孔都在渗出这种简单,但人们普遍认为它是一门非常复杂难学的语言)、易于理解(认知上不复杂——想想你第一次看到快排的感受,词元不多,但它扭曲而递归)。他还提出了一条元警告:”当某样东西是’好’的,人们会去挪用这个词来挪用它的好,仿佛好是从词流向它所依附的事物,而不是反过来。”

jonahx 给出了最漂亮的反例:”因为熟悉度是复杂性直觉的一个混杂因素,即使’我们能认出复杂’这一点也并非总是成立。麦克斯韦方程组在外行看来是复杂的,而对已经理解它们的人来说可以代表简单性的巅峰。”voidhorse 则认为只有在某个形式系统内、基于共享的初始公理才能给”简单”精确含义,并注意到这恰好呼应了 Hickey 的词源推导——那些词根本质上就和关系的数量有关。Gehinnn 提出了一个可操作的代理指标:”用’正确性陈述的长度 + 其证明的长度’作为组件复杂度的代理效果相当好。”fnord123 立刻反驳:”听起来是个合理的概念,但《数学原理》花了 300 页证明 1+1=2。”

关于 Rust/Clojure 那个例子,批评几乎是一边倒的。 Twey 从对面看这件事:”这对我来说很好笑,因为从另一侧看,(这种)Clojure 把运行时类型信息耦合到了数据结构上:你不再被允许定义一个不附带某种运行时类型信息的数据结构。固定的静态结构只是不添加动态类型信息的后果而已。与此同时在 Rust 里你可以用 trait object 得到’没有固定结构’的类型检查。”stephenlf 直接说”那个例子相当站不住脚而且刻意”。boomlinde 给出了一个更中肯的修正:问题不在 Rust 的耦合,而在于 Rust 里访问那些编译期就存在的信息实在太麻烦;在 Zig 里你可以用写运行时代码的同一套语言遍历字段——std.meta.fields 加 inline for 就够了。weavejester 则为作者辩护:”不管类型信息是静态的还是动态的,你都仍然在把某个类型耦合到某份数据上。”Twey 回应说他很难拼凑出”把一个值与它的类型概念性地解耦”到底意味着什么——你可以彻底忘掉一个值的类型把它当作不透明字节,但那样就没有任何有效操作了。

hankbond 的开场评论收获了最多共鸣,它把文章的观点接到了 AI 编程上。 “我正在构建一个非常模块化的软件,这是我整个职业生涯中最难设计的项目。我在任何待过的岗位上都不会被分配到这么多时间精力,去做出一个这么健壮、这么清晰界定的东西。这个项目的很多方面都花了三到五轮尝试和推倒重来,才得到一个不纠缠的抽象。这正是为什么 vibe coding 在构建微小的孤立脚本时如此成功,而在其他任何事情上如此灾难性。造一个又大又简单的东西,真的是非常非常难。”Someone 用《人月神话》里 Brooks 的那段话接住了它:人们偶尔会读到两个程序员在改装过的车库里做出了超越大团队最佳成果的重要程序,每个程序员都愿意相信这种传说——但必须看看被生产出来的到底是什么;车库里产出的是一个完整自足、可以由作者在开发它的系统上运行的程序,而工业团队产出的是别的东西。

pianopatrick 提出了一个全新的、AI 时代特有的维度: 如果你的程序被拆成较小的解耦”模块”,那么每个模块的全部代码都能装进 AI 的上下文窗口,AI 只需加载整个模块的代码就拥有了编辑它所需的全部上下文,你就不那么需要向量搜索了。他按每行 20 token、上下文 10 万到 100 万来估算,认为模块应该在 5000 到 5 万行之间。embedding-shape 的实战经验给出了严厉得多的数字:”我对任何 AI 相关项目的每一个源文件都设了 200 行的硬上限……我认为单文件超过 5000 行,所有现有模型就已经频繁迷失了,即使上下文上限理论上远高于此。除非你想要大量误解,不如把上限定在 1000 行左右。”

几条其他角度。 drbig 从实践出发点了两下:”你需要有品味(也就是经验)才能把 DRY 做对”——同一段代码出现不止一次,自然反应是抽成 helper,而由此引入的耦合太常被完全无视;以及”有时候就该让它保持原样”——当”同一段代码”只有 95% 到 98% 相同时,那”数值上仅仅 2 到 5 个百分点”会让抽出来的 helper 变成一场各种体操表演,唯一正确的答案是:别开始。MathMonkeyMan 指出了一个技术上的不对等:Clojure 版本把整个文件读进内存再操作,而管道是分块处理输入的,GNU sort 对大输入会创建临时文件做归并排序;你可以让 Clojure 也这么干,但那就没那么”简单”了。stianhoiland 的比喻很到位:”这有点像批评把钢琴搬上楼梯间的效率,说你为什么不直接用吊车从窗户吊上去?用吊车并不是一种更好的走楼梯间搬钢琴的方式。”adityaathalye 则指出了一个范畴错误:文章把系统设计(管道)和语言设计(Clojure 与 Bash)混在了一起——管道是进程抽象,而那个长得很像的 Clojure 线程宏根本没有在流水线化任何东西(既不是 CPU 也不是进程也不是内存),它只是把深度嵌套的调用链写成”扁平”列表的语法糖。

最后,多位评论者(embedding-shape、andai、fwlr)不约而同地推荐去看 Rich Hickey 的原始演讲《Simple Made Easy》。embedding-shape 说这是他每半年就重看一次的少数几场演讲之一,”很少有东西、概念和想法像 Hickey 那场演讲以及最终 Clojure 那样改变了我的编程头脑”,并感慨希望还能有 Hickey 的新演讲可以链接。simongray 带来了好消息:他会在即将到来的 Clojure Conj 上演讲,所以很快就会有新的了。