← 文章 / 编程开发
Hacker News 5小时前 · 2026-09-08 03:46:15 · 2 阅读

简单并不等于小巧

@notjack.space: UI上按钮太多的程序让我困惑甚至害怕
@sixfold-origami.com: 啊,这就是 Unix 风格!
@notjack.space: 不对,因为我只想要跨设备同步能真正正常工作

我们需要简单吗?

最近我做了个演讲,题目是《精准、一致且可靠的代码覆盖率》。 内容讲述了一个让我所在公司折腾了 9 个月才调试完成的棘手 Bug。 最后,我的朋友Predrag问我:

你们会如何建议构建工具,才能让这种史诗级的调试故事不再那么必要?

我当时是这样回答的:

我们需要优先考虑简洁性。 回到我的覆盖率流水线来看,这个架构图里有非常多的节点。[...] 这套工具太复杂了。 我们需要重新思考计算的运作方式。

但我不满意这个回答。


Unix 管道并不简单

来看两个用于统计文件词频的程序。先是一个简洁的 Unix 管道:

cat README.md \
  | tr --complement --squeeze-repeats '[:alpha:]' '\n' \
  | tr A-Z a-z \
  | sort \
  | uniq --count \
  | sort --reverse --numeric-sort

这行命令的意思是:“读取 README.md,将每个单词边界替换为换行符并合并连续的换行,将大写转为小写,统计每个词的出现次数,然后按频率降序排列输出”。

我想,这大多就是人们心中“简单”的样子: 每个程序都很小巧,设计上允许这样即兴组合,简洁且大体可读。

接下来看看一个 Clojure 程序的写法:

(->> (slurp "README.md")
     (re-seq #"[a-zA-Z]+")
     (map str/lower-case)
     frequencies
     (sort-by val >)
     ; for every (word, count) pair in the sequence, call an anonymous function that prints it.
     (run! (fn [[word count]] (println count word))))

它实现了相同的功能,只是多了些函数名和高阶函数的组合。

现在,假设我们要做一个小改动:按原始文件的顺序显示输出。 在 Clojure 中,这相当直接:将单词有序序列存入 word_seq,将每个单词到其频率的映射存入 freq_map,然后遍历序列并在映射中查找每个单词:

(let [word_seq (->> (slurp "README.md")
                 (re-seq #"[a-zA-Z]+")
                 (map str/lower-case))
      freq_map (frequencies word_seq)]
  (->> (distinct word_seq)
       ; for every distinct word, in original order, print its frequency (from our `freq` map) and the word itself
       (run! (fn [w] (println (freq_map w) w)))))

而在 Bash 中,你需要一堆临时文件和丑陋晦涩的正则表达式、排序以及连接操作:

tr < README.md --complement --squeeze-repeats '[:alpha:]' '\n' \
  | grep . > words
sort words \
  | uniq --count \
  | sed --regexp-extended 's/^ *([0-9]+) (.*)/\2 \1/' \
  | sort > counts
nl --body-numbering=a words \
  | sort --key=2,2 --key=1,1n \
  | uniq --skip-fields=1 \
  | sort --key=2,2 > firstseen
join -1 2 -2 1 -o 1.1,2.2,1.2 firstseen counts \
  | sort --numeric-sort \
  | cut --delimiter=' ' --field=2,3

这是因为我们的原始程序是的,但并不简单


什么是简单性?

Simple Made Easy讲稿)中, Rich Hickey 从词源"sim-plex"出发定义了"简单":只有一股编织。 他将其与"com-plex"(多股交织)相对照。 在本文中,我将使用"耦合"作为"复杂"的同义词以避免歧义。

Braided rope, uncurling into straight fibers

这就给了我们要描述第一个 Unix 管道现象的语言:它是的,但它是耦合的。 让我们仔细看看是什么让它变成这样。

tr < README.md --complement --squeeze-repeats '[:alpha:]' '\n' \
  | tr A-Z a-z \
  | sort \
  | uniq --count \
  | sort --reverse --numeric-sort

这里有很多小地方我可以吹毛求疵,但真正耦合在一起(缠绕在一起)的是 sort | uniq --count 这部分。 查看 uniq 的手册页,它是这么说的:

输入中不相邻的重复行不会被识别,因此可能需要先对文件排序。

Unix 里没有原生对应 frequencies 的东西,sort | uniq -c 已经是最接近的做法了。 它不仅性能更差(必须先把全部输入收进内存才能继续), 还把聚合和排序绑在了一起。 这正是"把排序和聚合分离"如此困难的原因; 我们最终不得不搞出这种通过文本文件做表连接的古怪操作。

你可能听过那句"写程序只做一件事,并把它做好",说的是 Unix 系统。 也许你听过它的另一个名字:Unix 哲学。 我认为"只做一件事"通常被理解为简单性,但实际上它说的是规模。 Unix 工具很,但并不简单

大不等于耦合

现在来看另一个极端。 假设你电脑上运行着 Google Drive for Desktop。 这是一个极其庞大的程序: 它依赖平台特定的文件监视器、"整个 Google3"、流式同步网络客户端,以及冲突解决逻辑。 但对用户来说,它相当简单: 装上程序,告诉它要监视哪个文件夹,再告诉它文件是保留在本地还是主要放在 Google 的基础设施上。 剩下的它全包了。

Google's official marketing for Google Drive

解耦

当我想到复杂的程序时,我想的是耦合。 当不同的功能彼此耦合、而实际上又不必如此时,程序就变复杂了。

举个小例子。 在 Rust 中,你可以用 map 或 struct 把名字关联到值:

struct HttpResponse {
  status: u16,
}
let strukt = HttpResponse { status: 200 };

let mut map = HashMap::new();
map.insert("status", 200);

println!("map: {}", map.get("status").unwrap());
println!("struct: {}", strukt.status);

从这一点可以很清楚地看出,struct 能为你提供 已知的、确定的字段。而对于 map,我们必须调用 unwrap(),因为类型检查器并不知晓 map 中有哪些键。结构体则不同,它知道这些字段,因此我们可以直接访问值。

但这里可能没那么明显的一点是,struct 会 丢失运行时信息。如果你想遍历一个 map,那很简单:调用 for (key, val) in map { ... 即可。但如果你想遍历一个 struct……祝你好运?还是得写个过程宏?

原因在于,在 Rust 中,struct 将类型检查固定的数据表示耦合在一起。二者不可兼得,必须同进同退。

这与 Clojure 形成了对比,在 Clojure 中你可以将它们解耦。在 Clojure 里,struct 就是 map:你不是去定义一个新类型,而是给 map 注解其允许存在的字段。如果要把我们之前的 struct HttpResponse 翻译成 Clojure,可以这样写:

; 将名称 `http-response` 绑定到一组关键词(即 interned 字符串)列表。
; 这是一个在运行时创建和操作的普通列表,并无任何特殊之处。
(def http-response [:map [:status :int]])
; 将名称 `print-resp` 绑定到一个函数。
; `^{}` 是一个“元数据”map,会与上述名称关联。
; 绑定的元数据可以在运行时被检索。
(defn ^{:malli/schema [:=> [:cat http-response] :nil]}
  print-resp [map]
  (println "status:" (:status map)))

在这里,我们创建了一个类型注解,并通过函数 (malli/instrument!) 在运行时进行检查。值得注意的是,这是借助(Malli)进行的检查,而非由编译器直接检查;并且该注解是可被反射检视的。例如,你可以编写一个 schema->md 函数,充当你自己的迷你版 Rustdoc,而无需集成任何编译器 API。而且这一切都在不牺牲类型安全、反射或 map 值遍历能力的前提下完成。

之所以能如此,是因为 Clojure 解耦了数据表示与类型检查。Typed Racket 也做了类似的 trick,不过它是利用宏让类型检查发生在编译期而非运行期。

什么时候“小巧”是有用的?

作为维护者,如果你的资源有限,精简代码是有意义的。 也许你是布莱恩·克尼汉,你的程序运行在一台真正的 PDP-11 上。 也许你是一个开源维护者,每月只有几个小时可以投入项目。 也许你身处的环境中,能完成任何功能都算胜利,而你只能获得支持实现你实际想构建的功能子集的支持。 这些都是让程序保持精简的好理由。

但精简不等同于简单。 「程序何时应该简单?」的答案是:始终如此。 引入与程序其他部分的耦合几乎没有任何优势; 这让你作为开发者更难维护程序,也让用户的使用灵活性降低。

我们如何编写简单的程序?

啊,这才是最难的部分。 要编写简单的程序,你需要对程序有一个良好的心智模型。 你还需要有好品味,而我还不知道该如何传授这一点。

有时候,你也需要硬着头皮去解决那些困难的问题。 CSS 和 SQL 高度解耦(基本上): 你只需编写一段声明式规范,描述程序应该做什么,然后浏览器引擎或数据库运行时来决定如何实现它。 这非常非常难! 仅 SQLite 就历经数代人年的时间才能可靠运行。 Blink(Chrome 的渲染引擎)可能也投入了成千上万的人年。 在某些领域,这就是让你编写解耦程序所必需的代价。

在任何程序上花费这么多时间,并不总是合理的。 Crunchy 式的技术工作可能是一个蛾灯问题: 它会吸引某类热爱梦想代码可能应该能够如何运作的人。 有时候,放下工具去阳光下小睡一会儿反而更好。 但当它奏效时——

回到本文开头:我修复了那个覆盖率流水线之后,它其实变了,而不是变小。但与此同时它也变简单了,因为数据流图各部分之间那些隐藏的依赖更少了。

接下来?

我希望这篇文章能鼓励你写出简单而非短小的程序,也去审视你手头的工具里有哪些不必要的耦合。

在后续文章中,我打算进一步展开这些想法:如何培养自己的品味;程序如何在保持垂直整合的同时实现解耦;以及如何构建大型系统而不让它变得复杂。

原始来源: Hacker News

评论 (0)