深入探讨约翰·奥斯特豪特关于实用软件设计的观点、Tcl 的遗产、与 Brooks 的讨论,以及复杂性如何让产品沉没。

约翰·奥斯特豪特(John Ousterhout)是一位计算机科学家和工程师,既有研究也有真实系统的实践经验。他创造了 Tcl 编程语言,参与了现代文件系统的设计,并在多年经验基础上提出了一个简单但有点刺耳的断言:复杂性是软件的主要敌人。
这个观点至今仍然适用,因为大多数团队并不是因为缺少功能或努力而失败——他们失败是因为系统(和组织)变得难以理解、难以修改且容易被破坏。复杂性不仅会拖慢工程师速度。它还会渗透到产品决策、路线图信心、客户信任、事故频率,甚至招聘上——因为入职变成了一个需时数月的过程。
Ousterhout 的表述很务实:当系统积累了特例、例外、隐藏依赖和“就这一次”的修补时,代价并不限于代码库。整个产品变得更昂贵以致难以演进。功能需要更久,QA 更难,发布风险更高,团队开始回避改进,因为触碰任何东西都感觉危险。
这不是呼吁学术上的纯粹主义。这是在提醒:每个捷径都有利息支付——而复杂性是利率最高的债务。
为了把想法具体化(而不是仅仅激励人心),我们将从三个角度来看 Ousterhout 的观点:
这篇文章不仅写给语言爱好者。如果你构建产品、领导团队或在做路线图权衡,你会找到可操作的方法来及早发现复杂性,阻止它成为制度化,并把简洁作为一项一等约束而非上线后的附加品。
复杂性并不是“代码多”或“数学难”。它是你对系统在改动后会做什么的预期与实际行为之间的差距。当小改动显得冒险——因为你无法预测波及范围时,系统就是复杂的。
在健康的代码中,你可以回答:“如果我们改了这个,还可能有什么坏掉?”复杂性让这个问题变得昂贵。
它常常藏在:
团队感受到的复杂性表现为发布更慢(更多时间用于调查)、更多 bug(行为令人惊讶)、以及脆弱的系统(改动需要多方和多服务协调)。它也会拖累入职:新人无法建立心智模型,因此会回避触碰核心流程。
有些复杂性是必要的:业务规则、合规要求、现实世界的边缘情况。这些你无法删除。
但很多是偶然的:令人困惑的 API、重复逻辑、变成常设的“临时”标志以及泄露内部细节的模块。这些是设计选择造成的复杂性——也是你可持续地偿还的那种债务。
Tcl 的初衷很务实:让自动化软件、扩展已有应用而无需重写变得容易。约翰·奥斯特豪特把它设计为团队可以向工具中添加“恰到好处的可编程性”,并把这种能力交给用户、运维、QA 或任何需要脚本化工作流的人。
Tcl 推广了胶水语言的概念:一个小而灵活的脚本层,用来连接用更快速、低级语言写成的组件。与其把每个功能都加进单体,不如暴露一组命令,然后把它们组合出新行为。
这一模式之所以有影响,是因为它符合实际工作的方式。人们不仅构建产品;他们还构建构建系统、测试夹具、管理工具、数据转换器和一次性自动化工具。轻量脚本层把“提交工单”变成“写一段脚本”。
Tcl 把嵌入解释器当作一等关注点。你可以把解释器嵌入应用、导出一套干净的命令接口,从而立刻获得可配置性和快速迭代能力。
今天相同的模式出现在插件系统、配置语言、扩展 API 和嵌入式脚本运行时中——无论脚本语法是否像 Tcl。
它还强化了一个重要的设计习惯:把稳定的原语(宿主应用的核心能力)与可变的组合(脚本)分开。当运作良好时,工具能更快演进而不会频繁动摇核心。
Tcl 的语法和“万物皆字符串”的模型有时会让人感觉不直观,大型 Tcl 代码库若没有强约定也会变得难以推理。随着生态系统提供更丰富的标准库、更好的工具链和更大的社区,许多团队自然迁移到别处。
但这些并不抹杀 Tcl 的遗产:它让可扩展性与自动化不再是附加项——它们是能显著降低使用和维护系统人员复杂性的产品特性。
Tcl 建立在一个看似严格的理念上:保持核心小巧,使组合强大,并让脚本可读,以便多人协作而不需不断翻译。
Tcl 没有提供一大堆专门特性,而是依赖一组紧凑的原语(字符串、命令、简单的求值规则),期望用户组合它们。
这一哲学促使设计者倾向于更少的概念,在多个场景下重用。对产品与 API 设计的教训很直白:如果用两三个一致的构建块能解决十个需求,你就缩小了人们必须学习的面。
软件设计的一个陷阱是为构建者的方便而优化。一个特性可能易于实现(复制已有选项、添一个特殊标志、打补丁),但会让产品变得更难用。
Tcl 强调的是相反方向:保持心智模型紧凑,即便实现端需要在后台做更多工作。
在评审一个提案时,问自己:这能减少用户必须记住的概念,还是在加入一个例外?
极简只有在原语一致时才有帮助。如果两个看似类似的命令在边缘行为上不一样,用户就会记住琐碎规则。一组小工具在规则微妙变化时会变成“锋利边缘”。
把它想成厨房:一把好刀、一口平底锅和一台烤箱可以通过组合技术做出很多菜。一个只切牛油果的工具是一次性特性——容易卖,但会让抽屉凌乱。
Tcl 的哲学主张选择刀和锅:可组合的通用工具,而不是为每道菜都准备新小玩意。
1986 年,弗雷德·布鲁克斯写了一篇文章,结论故意挑衅:没有单一突破——没有“银弹”——能在一跃之间让软件开发快一个数量级、便宜或更可靠。
他的观点并不是进步不可能,而是软件作为一种媒介几乎能做任何事,而这种自由带来了独特的负担:我们在构建的同时不断定义这个事物。更好的工具能帮忙,但它们不会抹去最难的部分。
布鲁克斯把复杂性分成两类:
工具可以压碎偶然复杂性。想想高级语言、版本控制、CI、容器、托管数据库和优秀 IDE 带来的收获。但布鲁克斯认为必要复杂性占主导地位,而它不会因为工具改进就消失。
即便有现代平台,团队仍把大部分精力花在协商需求、整合系统、处理例外并保持行为一致上。表面可能变化(云 API 取代设备驱动),但核心挑战仍在:把人类需求翻译成精确且可维护的行为。
这就形成了 Ousterhout 倾向的张力:如果必要复杂性无法被消除,是否通过严谨的设计可以显著减少其“泄露”到代码中——以及每天泄露到开发者头脑中的程度?
人们有时把“奥斯特豪特 vs 布鲁克斯”看作乐观与现实的对立。更有用的方式是把它当成两位有经验的工程师在描述同一问题的不同部分。
布鲁克斯说没有银弹,Ousterhout 并不是真的反对这一点。
他的反驳更狭窄也更务实:团队常把复杂性视为必然,而很多复杂性其实是自招的。
在 Ousterhout 看来,良好的设计可以显著减少复杂性——不是让软件“变简单”,而是让它改动时不那么令人困惑。这是一个重要的主张,因为困惑会把日常工作变成缓慢的工作。
布鲁克斯强调必要难度:软件必须建模混乱的现实、不断变化的需求和存在于代码之外的边缘情况。即便有优秀的工具和聪明的人,你也无法删除这些复杂性——你只能管理它们。
二者的重叠比争论显得的更多:
与其问“谁对”,不如问:本季度我们能控制哪些复杂性?
团队无法控制市场变化或领域的核心难度。但他们可以控制新功能是否引入特例、API 是否强迫调用方记住隐藏规则,以及模块是否隐藏复杂性或把它泄露出去。
这是可操作的中间地带:接受必要复杂性,并对偶然复杂性进行无情筛选。
深模块是能做很多事同时只暴露少量、易于理解接口的组件。模块的“深度”即其承担的复杂性:调用者不需要知道混乱细节,接口也不会把这些强加给他们。
浅模块则相反:它可能封装少量逻辑,但把复杂性向外推——通过大量参数、特殊标志、必需的调用顺序或“你必须记住……”的规则。
想象一家餐厅。深模块是厨房:你从简单菜单点“意面”,不关心供应商选择、煮面时间或装盘。
浅模块是一个把原材料和 12 步说明交给你的“厨房”,还要你自备锅具。工作仍然完成——但移到了客户那边。
增加层次有益当它把许多决策折叠成一个明显的选择。
例如,暴露 save(order) 的存储层并在内部处理重试、序列化和索引就是深的。
层次有害当它主要是在重命名或增加选项。如果新抽象引入的配置比它移除的还多——比如 save(order, format, retries, timeout, mode, legacyMode)——它很可能是浅的。代码看起来“有组织”,但认知负担出现在每个调用点。
useCache、skipValidation、force、legacy。\n- 调用者必须遵循特定顺序(“先 A 后 B”)以避免微妙的 bug。\n- 模块把内部概念(文件路径、表名、线程规则)泄露到接口里。\n- 大多数更改需要触及许多调用点,因为抽象没有稳定行为。\n- 文档读起来像警告标签而不是承诺(“当 Y 时别用 X,除非 Z”)。深模块不仅仅是“封装代码”。它们封装决策。
“好”的 API 不仅能做很多事,更重要的是它能被人类在工作时掌握。
Ousterhout 的设计视角促使你根据 API 要求的心理努力来评判它:你必须记住多少规则、预测多少例外、以及多容易犯错。
对人友好的 API 往往是小、始终如一且难以误用的。
“小”并不意味着无能——它意味着表面面积集中在少数可组合的概念上。始终如一意味着相同模式在整个系统中适用(参数、错误处理、命名、返回类型)。难以误用意味着 API 引导你走向安全路径:清晰的不变量、边界校验以及早期失败的类型或运行时检查。
每多一个标志、模式或“以防万一”的配置都会成为所有用户的税。即使只有 5% 的调用者需要它,100% 的调用者现在也必须知道它存在、怀疑是否需要它,并在它与其他选项交互时解释行为。
这就是 API 通过组合爆炸积累隐藏复杂性的方式:不是在单次调用里,而是在组合学上。
默认值是一种善意:它们让大多数调用者可以省略决策仍能得到合理行为。约定(一个明显的做法)减少用户心中的分叉。命名也在做实事:选择与用户意图相符的动词和名词,并保持相似操作命名的一致性。
再提醒一点:内部 API 与公共 API 同等重要。产品的大部分复杂性存在于幕后——服务边界、共享库和“帮助”模块。把这些接口当作产品来对待,进行评审与版本管理(另见 /blog/deep-modules)。
复杂性很少以一次“坏决策”出现。它通过小而合理的补丁逐步积累——尤其是团队在期限压力下,短期目标是上线时。
一个陷阱是到处都是功能标志。标志对于安全发布有用,但当它们滞留时,每个标志都会乘以可能行为的数量。工程师不再推理“系统”,而是推理“系统,在标志 A 打开且用户属于分段 B 的情况下除外”。
另一个是特例逻辑:“企业客户需要 X”、“除非在 Y 区域”、“除非账户超过 90 天”。这些例外常会散布到代码库中,几个月后没人知道哪些仍然必要。
第三是泄露的抽象。一个强迫调用者理解内部细节(时序、存储格式、缓存规则)的 API,会把复杂性推向外部。结果不是一个模块承担负担,而是每个调用者都学会这些古怪规则。
战术编程为本周优化:快速修复、最小改动、“先打补丁”。
战略编程为来年优化:小幅重设计以防止同类错误,减少将来工作。
危险在于“维护利息”。一个看似便宜的临时方案会用利息偿还:更慢的入职、脆弱的发布、以及因惧怕触碰旧代码而产生的恐惧型开发。
在代码评审中加入轻量提示:“这是不是引入了新的特例?”“API 能否隐藏这一细节?”“我们会留下什么复杂性?”
对非琐碎权衡保留短小的决策记录(几条要点即可)。并在每个 sprint 中保留一个小的重构预算,这样战略性修复就不会被视为课外工作。
复杂性不会困在工程内部。它渗透到进度、可靠性和客户体验中。
当系统难以理解时,每次改动都需要更久。上市时间延后,因为每次发布需要更多协调、更多回归测试和更多“以防万一”的审查周期。
可靠性也会受损。复杂系统会产生无人能完全预测的相互作用,bug 会以边缘案例出现:只有当优惠券、已保存购物车和某地区税率在特定组合下才会导致结账失败。这类事件最难复现、修复最慢。
入职成为隐性拖累。新人无法建立有用的心智模型,于是回避高风险区域,拷贝他们不理解的模式,且无意中加入更多复杂性。
客户不在意某个行为是否由代码里的“特例”导致。他们体验到的是不一致:设置并非处处生效、流程取决于你的到达路径、功能“大多数时候”可用。
信任下降,流失率上升,采用停滞。
支持团队通过更长的工单和更多的来回交流为复杂性买单。运营通过更多告警、更多运行手册和更小心的部署支付代价。每个例外都需要监控、记录与解释。
假设有人请求“再加一个通知规则”。添加它看上去很快,但它引入了行为分支、更多的 UI 文案、更多测试用例,以及更多用户误配置的方式。
再把它和简化现有通知流程比较:更少的规则类型、清晰的默认值、以及跨 Web 与移动的一致行为。你可能上线更少的旋钮,但你减少了惊喜——使产品更易用、更易支持、更快进化。
把复杂性像性能或安全那样计划、衡量并保护。如果你只在交付变慢时才注意到复杂性,那你已经在付利息了。
在功能范围之外,定义一个发布可以引入的新复杂性的量。预算可以很简单:“不引入净新增概念,除非我们移除一个”,或“任何新集成都必须替代旧路径”。
在规划时把权衡显式化:如果某个功能需要三个新配置模式和两个例外情况,那它应该比适配现有概念的功能“花费”更多预算。
你不需要完美的数据——只要有朝正确方向的信号:
按发布跟踪这些指标,并把它们与决策联系起来:“我们新增了两个公共选项;我们移除了或简化了什么来补偿?”
原型通常以“能否构建?”来评判。相反,请用它们来回答:“这是否感觉易用且难以误用?”
让不熟悉该功能的人用原型完成真实任务。衡量成功所需时间、提出的问题和他们犯的错误假设。这些都是复杂性热点。
这也是现代构建流程可以减少偶然复杂性的地方——前提是它们保持快速迭代并易于回滚。例如,当团队使用像 Koder.ai 这样的平台通过聊天草拟内部工具或新流程时,诸如 planning mode(在生成前澄清意图)和 snapshots/rollback(快速撤销风险性改动)等功能能让早期试验更安全——而无需提交一堆半成品抽象。如果原型通过评审,你仍可导出源代码并应用上文描述的“深模块”和 API 纪律。
把“复杂性清理”工作定期化(每季度或每次大版本),并定义“完成”意味着什么:
目标不是抽象意义上的更干净代码,而是更少的概念、更少的例外和更安全的变更。
下面是把 Ousterhout 的“复杂性是敌人”理念转化为每周团队习惯的几条举措。
挑一个经常造成困惑的子系统(入职痛点、重复 bug、频繁的“这如何工作?”问题)。
你可以在内部运行的后续:在规划中做一次“复杂性审查”(/blog/complexity-review)以及快速检查你的工具链是在减少偶然复杂性还是在增加抽象层(/pricing)。
如果本周你只能删除一个特例,你会先移除哪一条复杂性?
复杂性是你对系统在修改后会发生什么的“预期”与实际行为之间的差距。
当小改动看起来很冒险、因为你无法预测波及范围(测试、服务、配置、客户或可能被破坏的边缘情况)时,你就能感受到复杂性。
寻找使推理变贵的信号:
本质复杂性来自领域本身(法规、现实世界的边缘情况、核心业务规则),你不能消除它——只能把它建模好。
偶然复杂性是自我造成的(泄露的抽象、重复逻辑、过多模式/标志、不清晰的 API)。这是团队可以通过设计与简化持续减少的部分。
一个深模块在内部承担大量工作,同时暴露出一个小且稳定的接口。它把脏活(重试、序列化、顺序、不变量)吸收掉,让调用者无需了解细节。
实用测试:如果大多数调用者不需要知道内部规则就能正确使用模块,它就是深的;如果调用者必须记住规则和顺序,那就是浅的。
常见症状:
legacy、skipValidation、force、mode)。浅模块往往看起来“有条理”,但把复杂性推到了每个调用者身上。
偏好这样的 API:
当你想“再加一个选项”时,先问是否能把大多数调用者从这个决策中解放出来。
把功能标志用于受控发布,然后把它们当作债务来管理:
长期存在的标志会成倍增加工程师需要推理的“系统”数量。
在规划中把复杂性显式化,而不是只在代码评审时发现:
目标是把权衡在复杂性制度化之前摊到桌面上来讨论。
战术编程为本周优化:快速修补、最小改动、“先上线”。
战略编程为来年优化:小范围重设计,去除反复出现的错误类别,减少未来工作量。
一个有用的启发式:如果修复需要调用者知识(“先调用 X”或“仅在生产设置此标志”),你很可能需要把复杂性藏到模块内部,做更战略性的改变。
Tcl 的持久教训是:一组小而一致的原语加上强组合能力的力量——通常作为嵌入式“胶水”层。
现代等价体包括:
设计目标相同:让核心简单稳定,通过干净的接口来承载变化。