simple is not small
Simple is not small / 简单不等于小
Table of contents
- Do we need simplicity?
- Unix pipelines are not simple
- What is simplicity?
- Large is not the same as coupled
- Decoupling
- When is it useful to be small?
- How do we make simple programs?
- What next?
目录
- 我们需要简单吗?
- Unix 管道并不简单
- 什么是简单?
- 大不等于耦合
- 解耦
- 什么时候“小”是有用的?
- 我们如何编写简单的程序?
- 接下来做什么?
@notjack.space: UIs with too many buttons confuse and alarm me @sixfold-origami.com: ah, this is the unix thing! @notjack.space: no, because I want cross device sync to actually work
@notjack.space:按钮太多的用户界面让我感到困惑和恐慌。 @sixfold-origami.com:啊,这就是 Unix 的那一套! @notjack.space:不,因为我希望跨设备同步功能真正好用。
Do we need simplicity? Recently, I gave a talk titled “Precise, consistent, and reliable code coverage”. It’s about a truly gnarly bug that took my company 9 months to debug. At the end, my friend Predrag asks: “How would you recommend that we think about building tools such that these epic debugging stories aren’t as necessary?” and I answer him: “We need to prioritize simplicity.” If you go back to my coverage pipeline, there are a lot of nodes in this diagram. […] The tooling’s complicated. We need to rethink how our computing works. I’m not satisfied with that answer.
我们需要简单吗? 最近,我做了一场题为《精确、一致且可靠的代码覆盖率》的演讲。它讲述了一个极其棘手的 Bug,我所在的公司花了 9 个月才调试完成。在演讲结束时,我的朋友 Predrag 问我:“你建议我们如何思考工具的构建,才能让这种史诗级的调试故事不再那么必要?”我回答他:“我们需要优先考虑简单性。”如果你回顾我的覆盖率流水线,会发现图中有很多节点。[…] 工具链太复杂了。我们需要重新思考我们的计算方式。我对这个答案并不满意。
Unix pipelines are not simple
Consider two programs to calculate the frequency of words of a file. First, a small unix pipeline:
cat README.md | tr --complement --squeeze-repeats '[:alpha:]' '\n' | tr A-Z a-z | sort | uniq --count | sort --reverse --numeric-sort
This says “read README.md, translate each word boundary into a newline, collapsing multiple newlines, convert uppercase to lowercase, count the number of occurrences of each word, then show them in frequency order”. I think this is what most people think of when they think of “simple”: each program is small, they’re designed to be joined together ad-hoc in this way, it’s concise and somewhat easy to read.
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,将每个单词边界转换为换行符并合并多个换行符,将大写转换为小写,统计每个单词出现的次数,然后按频率排序显示”。我认为这就是大多数人在想到“简单”时所想到的:每个程序都很小,它们被设计成可以这样临时组合在一起,简洁且易于阅读。
Next, consider a Clojure program:
(->> (slurp "README.md")
(re-seq #"[a-zA-Z]+")
(map str/lower-case)
frequencies
(sort-by val >)
(run! (fn [[word count]] (println count word))))
This does the same thing, with a few more names and higher-order functions thrown in. Now, let’s say we want to make a small change: show the output in the original file order. In Clojure, this is fairly straightforward: store an ordered sequence of the words in word_seq, store a map from each word to its frequency in freq_map, iterate over the sequence, and look up each word in the map.
接下来,考虑一个 Clojure 程序:
(->> (slurp "README.md")
(re-seq #"[a-zA-Z]+")
(map str/lower-case)
frequencies
(sort-by val >)
(run! (fn [[word count]] (println count word))))
它实现了相同的功能,只是多了一些命名和高阶函数。现在,假设我们要进行一个小改动:按原始文件顺序显示输出。在 Clojure 中,这相当直接:将单词的有序序列存储在 word_seq 中,将每个单词到其频率的映射存储在 freq_map 中,遍历序列并在映射中查找每个单词。
In Bash you need a bunch of temp files and ugly opaque regexes, sorts, and joins:
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
That’s because our original program was small but not simple.
而在 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
这是因为我们最初的程序虽然“小”,但并不“简单”。
What is simplicity? In Simple Made Easy, Rich Hickey defines “simple” from its root, “sim-plex”: having only one braid. He contrasts this to “com-plex”: braiding multiple things together. In this post I’ll use “coupled” as a synonym for “complex” to avoid ambiguity. And that gives us a language to talk about what’s going on with our first Unix pipeline: it’s small but it’s coupled.
什么是简单? 在《Simple Made Easy》一文中,Rich Hickey 从词源“sim-plex”定义了“简单”:即只有一股编织。他将其与“com-plex”(复杂)相对比,后者意为将多股事物编织在一起。在本文中,我将使用“耦合”作为“复杂”的同义词,以避免歧义。这为我们讨论第一个 Unix 管道的问题提供了一种语言:它很小,但它是耦合的。
Large is not the same as coupled Now, let’s consider the opposite end. Say you have Google Drive for Desktop running on your computer. This is a massively large program: it depends on platform-specific file watchers, “all of Google3”, a streaming and syncing network client, and conflict resolution logic. But to the user it feels quite simple: Install the program, tell it which folder you want it to watch, tell it whether to keep the files locally or primarily on Google’s infra. It does all the rest.
大不等于耦合 现在,让我们考虑相反的情况。假设你的电脑上运行着 Google Drive 桌面版。这是一个极其庞大的程序:它依赖于特定平台的监控程序、整个“Google3”代码库、流式同步网络客户端以及冲突解决逻辑。但对用户来说,它感觉非常简单:安装程序,告诉它你想监控哪个文件夹,告诉它文件是保存在本地还是主要保存在 Google 的基础设施上。剩下的工作它全都会自动完成。
Decoupling When I think about complex programs, I think about coupling. Programs are complex when different features are coupled to each other, even when they don’t have to be. Let’s take one small example. In Rust, you can associate names to values with a map, or with a 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);
It’s very clear from this that a struct gets you known present fields. For the map, we have to call unwrap(), because the type checker doesn’t know what keys are in a map. For the struct it does, so we can just directly access the value.
解耦 当我思考复杂程序时,我想到的是耦合。当不同的功能相互耦合(即使它们本不必如此)时,程序就会变得复杂。让我们举一个小例子。在 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,它知道,所以我们可以直接访问该值。