pstack 拆解 01 · 少即是多(上)
AI coding 之后,代码的产出量第一次超过了人能读完的速度。于是"多写"不再是美德,而是一种负债:每多一个概念、一层抽象、一个必须保持同步的状态,都是以后每一次阅读、以及让 agent 继续改的时候,要重新付出的成本。
这一篇讲三条关于"少"的原则:默认能删就删,加之前先减,以及用读者的负担而不是行数来衡量。
laziness-protocol
懒惰协议的背后是一个判断:代码是负债,不是资产。 每一行你写下的代码,不只是一个当下的解决方案,还是未来每一次阅读、修改、迁移都要重新付一次的成本。所以"写出来"和"有价值"是两件事:代码是达成结果的手段,它本身就是负债。懒惰协议的整个立场都建立在这个交换上:不追求少写,追求在结果相同的前提下,引入的复杂度最小。它衡量的单位是复杂度,不是行数。
它有一句判据:用最小的代码和复杂度,拿到最大的结果。
协议防的是后者。
反直觉的地方在这。agent 被要求"加个功能"时,默认动作是加。加一个类,加一层抽象,加一个参数穿过所有调用点,加完看起来就完整了。回到开头那个 --json,正确的第一步是先问一句:现在有几个格式化目标?一个。那就先不要接口。
它有六条具体的做法。
优先删除。 当你被要求重构或"改进"一段代码,第一个动作不是想加什么,是想能删什么。改进的默认方向是减法,因为每删掉一个概念,以后所有人都少记一件事。
保持调用层级扁平。 避免很深的调用链。这里有个容易混的地方:一个接口很厚、内部做了很多事,不叫深调用链。深调用链是 A 调 B 调 C 调 D,每一层都很薄,你为了追一个值要跳四次。判据很具体,回答一个问题如果穿过三个以上的文件或层级,就拍平它。
集中决定。 同一个选择不要在多个地方重复。把它放到唯一的事实来源,只把结果当一个简单的标志传出去。比如"要不要显示时间戳"这个决定,不要在每个消费者里各判一次,在边界判一次,传一个布尔下去。
最小化 diff。 用能解决问题的最小改动,更少的行胜过"优雅"的样板。这里的敌人常常是那种看起来更专业的写法:一个接口、一个工厂、一个基类,写出来很像回事,可问题只需要一个函数。
质疑穿透。 如果任务让你把一个新信号穿进类型、schema、管线这一层层结构里,先停下来,找一个更直接的路。穿透本身就是信号,它说明这个信号要穿过很多并不需要它的中间层,而这些中间层以后每次都要跟着改。
处理小泄漏。 纯转发的包装、把内部实现漏到外面的地方、重复的选择,在扩散之前清掉。小泄漏会复利成永久的协调成本。今天一个纯转发的包装,明天它长出一个参数,后天所有人都要绕着它走。
检验它只需要一句朴素的话:如果一个人维护这段代码会觉得累,它就是坏方案。
subtract-before-you-add
先减后加的背后是一个观察:系统不是一次设计出来的,而是一层层长出来的。 每加一个能力,都会留下一点当时有用的结构,而其中一部分后来变成了没人敢动的噪声。等到你要再加东西时,你面对的不是最初的设计,而是设计加上所有历史残留。先减,就是先把这些残留清掉,让最初的结构重新露出来;在干净的表面上,下一步最好的设计常常会自己浮现。所以它是一个顺序问题:先减,后加。
举个具体的。你要给一段慢逻辑加缓存,先别急着加。去看它为什么慢,可能会发现半年前加的一个校验早就没人用了。删掉它,缓存根本不用加。
它的做法是这么几条。
先把移除排进顺序。 不要把清理留到最后当成收尾,它是前置动作。先让它变简单,再在简单的基础上盖东西。
先砍,再打磨。 先到最小可用,再往质量里投入。反过来做,你一边精修一边还在砍,精修的那部分很可能整个被砍掉。
对着观察到的用法设计。 为真实的调用方式建,不为猜出来的边界情况建。规格没要求的校验、解析器、守卫,先不动手。
简化指令本身。 重复的说明和过度的模板也是复杂度,删掉它们,读的人少记一层。
没有新内容的引用就删掉。 不要留一个空壳说"以后再补",空壳会被下一个人当成真的存在。
它背后是一个态度:把简化当成持续投资。你交出去的代码,表面应该不变或更小,能力不降。
minimize-reader-load
降低读者负担的背后是一个事实:一条代码写下一次,可能被读上百次。 所以它真正的成本不在写的时刻,而在之后每一次有人要理解它的时候。可维护性不是一种风格,它是一笔可以被计量的工作量:读者要追上几层、要在脑子里同时记住多少东西。人的工作记忆是有限的,这和模型的上下文窗口是同一类约束,只不过一个在脑子里,一个在窗口里。把"下一个读者的代价"当成一等成本,是这条原则的全部出发点。
它有一句定义:可维护性,就是读者理解代码要付出的工作。
它只数两个维度。一个是层级,从问题到答案隔着几层。一个是状态,读者要在脑子里记住多少东西。这两个维度互相独立:一个 50 个全局变量的平铺文件,和一个六层适配器栈,一样难推理。
它的做法集中在两件事上,折层和缩状态。
折叠得不偿失的层。 只有一个调用者的包装、没有第二个实现的适配器、没被证明需要的中间层,这些都是纯成本。层只要它省下的理解成本少于它带来的,就该拍平。
相邻两层必须改变抽象。 如果一层只是把下一层的方法和参数原样再写一遍,它没有压掉任何东西,读者却要学两遍。这种只做转发的层直接删。
接口要能压住复杂度。 一个很宽、却藏不住复杂度的接口,会逼读者同时学表面和实现。好接口是藏住了一堆决定的那层薄壳。
缩小状态范围。 优先级是:纯函数好过变更,局部变量好过字段,字段好过模块状态,模块状态好过全局。能从一个值推导出来的,就不要存两份去同步,同步迟早会不同步。这是函数式的状态纪律:副作用越小、可变状态越少,读者要追踪的东西就越少。能推导就别存第二份,比如 fullName 能从 firstName 和 lastName 算出来,就别再存一个字段;存了就要保证它和两个来源一致,而它迟早会不一致。
把不变式命名在边界。 让读者在一个地方学会这条规则,而不是在每个消费者里重新推一遍。
加一层、加一份状态之前,先问一句:它在别处减少的读者负担,至少和它带来的一样多吗。
检验很干净:一个新读者能不能在半分钟里回答两个问题,X 从哪来,什么能改 X。
参考
原文都在 pstack 上游仓库(cursor/plugins):