深度解析塑造未来的技术文章。

Rob Pike 的五条编程法则,至今依然有效

Rob Pike 在 1989 年提出了五条编程法则。如今看来它们比当年更有价值,尤其是那些我们总在忽视的条目。

一张旧索引卡片贴在杂乱现代书桌旁,灯光下格外醒目

1989 年,Rob Pike 写下了五条编程法则。他后来还参与创造了 Go、UTF-8 和 Plan 9。这几条法则简短到能写在一张索引卡上,却深刻到让编程界争论了 37 年。大多数开发者至少在博客或技术大会上见过其中一些。但真正内化的人并不多,这很可惜,因为它们能省下大量白费的力气。

这些法则看似简单,却不谈语法,也不谈架构模式。它们讲的是程序员在哪些地方持续浪费时间,以及如何停止这样做。

五条法则

在逐条分析之前,我先把它们原文列出来:

  1. 你无法预知程序会把时间花在哪里。瓶颈往往出现在意想不到的地方,所以在证明瓶颈确实在那里之前,别急着凭猜测去加速。
  2. 测量。在测量之前不要为速度调优;即便测量了,除非代码中某一部分明显压过其余部分,否则也不要动手。
  3. 当 n 很小时,精巧的算法反而更慢,而 n 通常很小。精巧的算法常数因子很大。在你确知 n 经常会很大之前,别把事情搞得花哨。
  4. 精巧的算法比简单的算法更容易出 bug,实现起来也困难得多。简单算法和简单数据结构优先。
  5. 数据主导一切。如果你选对了数据结构,并且组织得当,算法几乎会不言自明。真正核心的是数据结构,而不是算法。

第 1 和第 2 条关乎优化,第 3 和第 4 条关乎复杂度,第 5 条关乎设计。合在一起,它们构成了一种本质上关于谦逊的哲学:承认我们对性能的直觉常常是错的,承认我们低估了复杂度的代价,也承认好的数据结构比聪明的代码更重要。

第 1 条:你不知道瓶颈在哪里

这是开发者最理直气壮违反的一条。“我知道这个函数慢,因为它有个嵌套循环。”“这里应该用哈希表,因为查找是 O(1)。”“我要预分配这个数组,因为分配很昂贵。”听上去都很合理,但往往是错的。

我分析过足够多的生产系统,积累了不少这类例子:直觉上的瓶颈并非真正的瓶颈。某个系统里所有人都以为是数据库拖慢了速度,剖析后却发现 JSON 序列化占了 60% 的请求耗时。某个数据管道里被视为“昂贵”的矩阵乘法只占运行时间的 5%,而 CSV 解析占了 70%。某个 Web 应用团队花了几个月优化数据库查询,真正的瓶颈却是每个出站 HTTP 请求都要做的 DNS 解析。

人脑是个糟糕的性能分析器。我们会高估那些概念上看起来昂贵的操作(数据库查询、网络调用),低估那些看起来廉价的操作(字符串拼接、JSON 解析、内存分配)。现代硬件让情况更糟:CPU 缓存、分支预测和乱序执行意味着代码复杂度与执行时间之间的关系极其违反直觉。

第 2 条:先测量,再优化

这是第 1 条的实践推论。不要凭直觉优化。先做剖析,找到真正的热点,然后只优化它。

这条规则的后半句——“除非代码中某一部分明显压过其余部分”——同样重要,但被引用得少。如果剖析结果显示运行时间平均分布在 20 个函数上,每个占 5%,那就根本没有单一的瓶颈可以优化。把其中一个函数提速 2 倍,也只能节省总运行时间的 2.5%,这通常不值得增加的复杂度。这时你需要的是一种根本不同的思路,而不是逐个优化函数。

# Profile first. Always.
# Python
python -m cProfile -s cumulative my_script.py
# Node.js
node --prof app.js
node --prof-process isolate-*.log > profile.txt
# Go
go test -bench=. -cpuprofile=cpu.prof
go tool pprof cpu.prof
# Rust
cargo flamegraph
# General (Linux)
perf record ./my_program && perf report
# The flamegraph (brendangregg.com/flamegraphs) is the most
# informative visualization. It shows you exactly where time
# is spent, in a format that makes bottlenecks visually obvious.

第 3 条:n 小时,精巧的算法反而慢

这是计算机科学教育里最容易讲反的一条。我们教的是 O(n log n) 优于 O(n²),从渐近意义上讲没错。但当 n = 20 时,一个实现良好的 O(n²) 插入排序,由于常数因子、缓存行为和额外开销,往往比 O(n log n) 的归并排序更快。

现实中的例子比比皆是。在一个 50 个元素的有序数组中,线性查找比二分查找更快,因为线性查找的缓存局部性极佳,也不会产生分支预测失败。对于 100 个元素以下的集合,简单的链表胜过平衡二叉树,因为在树上沿指针跳转会破坏缓存局部性。哈希表的摊还查找复杂度是 O(1),但常数足够大,因此在 30 到 50 个元素以下的集合里,数组上的线性查找反而更快。

标准库早就明白这一点。Python 的 sorted() 使用 Timsort,在小的子序列上会退回到插入排序。C++ 的 std::sort 在低于某个阈值(通常是 16 到 32 个元素)时切换到插入排序。Rust 的 sort_unstable 则结合了快速排序与插入排序。所谓“精巧”的算法,只在 n 确实大到足以让它获胜时才会启用。

更广泛的教训是:心里要有数,知道你的 n 大概是多少。如果你在一个简单的 O(n²) 算法和一个复杂的 O(n log n) 算法之间犹豫,先问问自己实际运行时 n 会有多大。如果它只有几百,那简单算法几乎肯定够用,而且更容易编写、调试和维护。

第 4 条:简单胜过聪明

第 4 条是第 3 条在性能之外的延伸。精巧的算法不只是在小 n 时更慢,它们还更容易出 bug。红黑树的边界情况比有序数组多得多;无锁并发数据结构的隐性失败模式比互斥锁保护的版本更多;自定义内存分配器破坏内存的方式也比系统分配器多。

我见过一些团队花了好几周去实现和调试一个 O(1) 操作的自定义 LRU 缓存。而如果他们用一个带线性淘汰的简单有界数组,一个下午就能写完,第一次就正确,而且对他们的工作负载(最多只有几百个缓存条目)来说已经足够快。

复杂度的代价不只体现在最初的实现上,更体现在之后每一个要理解、修改和调试它的开发者身上。一个团队里每个人都能看懂的简单算法,远比只有原作者才能维护的聪明算法更有价值。而六个月后的原作者,本质上已经是另一个人了,而且也忘了它是怎么工作的。

调试代码的难度是写代码的两倍。因此,如果你把代码写得尽可能聪明,那按定义你就不够聪明,无法调试它。——Brian Kernighan

第 5 条:数据主导一切

这是 Pike 最重要的一条,也是最容易被那些专注于算法和设计模式的开发者忽视的一条。核心观点是:如果数据结构选对了,算法自然水到渠成;如果数据结构错了,再多的算法技巧也救不了你。

Fred Brooks 说过类似的话:“把你的流程图拿给我看,却藏起你的表格,我依然会一头雾水。把你的表格拿给我看,我通常就不需要你的流程图了。”Linus Torvalds 也呼应道:“差的程序员担心代码,好的程序员担心数据结构及其关系。”

这一原则在实践中随处可见。一个把用户权限存成扁平权限字符串列表的代码库,最终会在各处积累复杂且容易出错的校验逻辑。如果把数据改成角色层级结构,校验逻辑就会变得轻而易举。一个把事件存为 JSON 块的系统,则需要在每个消费者那里做复杂的解析和校验。把事件结构化为带明确 schema 的类型化记录,消费者的代码就会大幅简化。

# Bad data structure → complex algorithm
class OrderSystem:
def __init__(self):
self.orders = []  # flat list of all orders
def get_user_orders(self, user_id):
return [o for o in self.orders if o.user_id == user_id]  # O(n)
def get_pending_orders(self):
return [o for o in self.orders if o.status == 'pending']  # O(n)
def get_user_pending_orders(self, user_id):
return [o for o in self.orders
if o.user_id == user_id and o.status == 'pending']  # O(n)
# Better data structure → algorithms become obvious
class OrderSystem:
def __init__(self):
self.orders_by_user = {}      # user_id → [orders]
self.orders_by_status = {}    # status → [orders]
def get_user_orders(self, user_id):
return self.orders_by_user.get(user_id, [])  # O(1)
def get_pending_orders(self):
return self.orders_by_status.get('pending', [])  # O(1)
# The right data structure makes the code self-evident.
# You don't need to think about algorithms at all.

Go 如何体现这些法则

很难看着 Pike 的法则而不联想到 Go 的设计哲学,它几乎是后者的雏形。Pike 在写下这些法则 20 年后与人共同创造了 Go,这门语言系统性地偏爱简单而非聪明。

  • 初期没有泛型——迫使你使用简单的数据结构。(泛型在 Go 1.18 才加入,但那是在多年抵制之后,直到找到足够简单的设计方案。)
  • 不支持运算符重载——代码的含义一目了然。
  • 没有隐式类型转换——明确优于聪明。
  • 没有异常——在错误发生的地方处理。
  • 标准库中的算法极少——用 slice 和 map 即可,无需花哨的数据结构。
  • 内置性能分析工具(pprof)——用测量代替猜测。

很多偏爱更具表现力语言的开发者批评 Go“无聊”。这恰恰是重点。Pike 的法则本质上就是一份写出“无聊”代码的配方:代码简单、可测量,建立在良好的数据结构之上,而非精巧的算法。Go 就是把这份配方变成一门语言之后的样子。

法则不适用的地方

没有任何一套规则是放之四海而皆准的,Pike 的法则也有其合理的例外。性能关键型系统(游戏引擎、数据库内核、编译器)有时确实需要精巧的算法,因为它们的 n 真的很大。每秒运行数百万次的基础设施代码,也能为应用代码不值得做的优化提供理由。还有些时候,“简单”算法的复杂度是 O(n³),即便 n 不大也确实无法接受。

这些法则是启发式经验,而非定律。它们的价值在于纠正常见的偏差:开发者往往过早优化,使用过于复杂的算法,并且过多关注代码、过少关注数据结构。Pike 的法则正是用来对抗这些倾向的。如果你恰好身处相反偏差适用的少见情境——确实需要更多复杂度而非更少——那就放手去“花哨”吧。但在此之前,先测量。

Pike 写下这些法则已经过去 37 年,它们依然是有史以来最好的编程建议之一。并非因为它们多么出人意料,大多数有经验的开发者读到时都会想“没错,显而易见”。它们的价值在于:把道理讲得足够清楚,才能被持续地应用。下一次当你想用红黑树、自定义分配器,或者一个还没做过剖析的“优化”时,请记住:先测量,保持简单,并把数据结构做对。