读书笔记

写出高质量代码

高质量不是优雅,是将来改它的时候不容易出错。代码的成本大头在读和改,不在写——一切都从这句话推出来。

约 26 分钟专题技术工作

一段代码写完之后,会被读几十次、改几次、在半夜被人对着排查一次。所以"高质量"的唯一有用的定义是:将来改它的时候,不容易出错。

不是优雅,不是设计模式用得多,不是行数少。凡是让未来的修改更安全的,就是高质量;凡是让未来的修改更危险的,写得再漂亮也不是。 这一页所有的条目都是从这一句推出来的,按杠杆从高到低排。同样先声明:这是我的判断,不是某本书的转述,详见页末灰框。

一、先把"高质量"定义清楚

优化写起来爽,是优化错了对象

成本大头在读和改,不在写这是整页的地基

一段业务代码,写它可能花两小时,之后三年里会被读几十遍(排查、接需求、新人上手)、改几次、在事故中被紧急阅读一次。

所以真正该优化的是阅读成本和修改风险,而不是写的时候顺不顺手。很多看起来"高级"的写法——把三步压成一行、用一个特别巧妙的技巧——优化的是写的那两个小时,代价是之后三年的每一次阅读。

工作里的样子

那行特别聪明的三元嵌套。 写的时候很爽,半年后你自己都要读三遍。

"这个我知道,当时是为了少写几行。" 少写的几行,换来了每次读都要重新理解一次。

新人上手一周。 这一周的成本,直接反映的就是代码的阅读成本。

半夜排查线上问题。 这是阅读成本最贵的时刻——人困、有压力、没时间。代码在这个时刻好不好读,决定故障多长时间恢复。

接手别人的模块。 你的第一感受就是那个人的代码质量,不需要任何指标。

写完一段代码,问一句:半年后的我,或者一个刚来的同事,看这段要多久? 这个问题比任何规范都能提高质量。

一次性脚本和支付链路不该同一个标准

质量是分级的,别平均用力有限的预算花在刀刃上

把所有代码都按最高标准写,是一种浪费,而且做不到。质量预算要按"改动频率 × 出错代价"来分配。

- 两头都高(核心业务、支付、鉴权、数据迁移):值得下最大功夫,测试、审查、防御全都上。 - 两头都低(一次性脚本、临时分析、内部小工具):能跑就行,别过度设计。 - 改动频繁但出错便宜(内部管理页面):重点是好改,不必追求滴水不漏。 - 很少改但出错极贵(加密、结算、权限):重点是正确性和可审查性,宁可写得笨一点、直白一点。

工作里的样子

跑一次的数据修复脚本。 别给它做配置化和插件机制,它明天就没用了。

支付回调。 这里多花三倍时间是值得的,出一次错的代价远超三倍。

内部后台的列表页。 好改比完美重要,需求每周都在变。

鉴权逻辑。 宁可写得又长又直白,让每个人一眼看懂——这里的"聪明"是负资产。

日志和监控代码。 常被当成二等公民,但它决定了出事时你能不能看见。

动手前花十秒判断:这段代码会被改多少次?错了有多贵? 两个问题的答案决定你该投入多少。这一步能省下大量在无关紧要处的纠结。

二、杠杆最高的几条

起不出名字是设计有问题的信号

命名:名字不对,通常说明理解不对最高杠杆的一件事

命名之所以是最高杠杆的事,不是因为"可读性"这种笼统的好处,而是因为:你能不能给一个东西起出准确的名字,直接反映了你有没有真正理解它。

起名困难通常不是词汇量问题,是这个东西本身职责不清。 一个函数叫 handleData、processInfo、doStuff,往往说明它干了不止一件事。这时候正确的动作不是想个更好的名字,是把它拆开。

说清楚是什么,不是怎么做
getUserById 好过 queryUserFromMysqlByPrimaryKey——实现会变,意图不会。
布尔量用肯定式
isEnabled 好过 isNotDisabled。双重否定是理解成本的重灾区。
带上单位和量纲
timeoutMs、sizeInBytes。省下的是一整类低级事故。
别用会骗人的名字
叫 save 却顺手发了通知,这比名字难听危险得多。
统一同一概念的叫法
同一个东西在三个模块里叫三个名字,是理解成本的主要来源之一。
名字长度配得上作用域
循环里的 i 没问题,跨越两百行的变量不能叫 d。
工作里的样子

utils.js 和 common 包。 名字没有含义,于是什么都往里塞,最后成为垃圾场。名字模糊会招来更多混乱。

data、info、result、temp。 这几个词几乎不携带信息,看到它们通常可以想想更准的说法。

flag、status = 3。 三个月后没人记得 3 是什么。用枚举。

给函数起名时卡住了。 这是个好信号——卡住往往意味着它做了太多事。

改名字改出一堆冲突。 说明这个概念渗透太广,这本身就是设计信息。

每次起名卡壳,别硬凑,先问自己"这东西到底负责什么"。答不上来就是设计没想清楚——这时候改设计比改名字有用。

靠纪律不如靠结构

让非法状态无法表示防错的最优解

最有效的防错不是"记得检查",而是让错误的写法根本写不出来。靠人记得,总有忘的时候;靠结构约束,一次做对永远有效。

防止"未支付的订单被发货"
靠纪律

每个发货的地方都记得判断状态

靠结构

未支付的订单类型上就没有 ship() 方法

靠纪律

文档里写清楚"必须先检查"

靠结构

状态流转集中在一个地方,非法流转直接抛错

靠纪律

code review 时提醒

靠结构

用不同的类型表示不同状态的订单

靠纪律

出事后加一条判断

靠结构

构造的时候就保证不可能处于非法状态

工作里的样子

必填参数用类型强制。 而不是进函数后一堆 if 判空。

用枚举代替字符串。 拼错字符串编译器不管,拼错枚举它当场就说。

不可变对象。 一个东西创建后不能改,就永远不用担心它在别处被悄悄改掉——这消灭的是一整类最难查的 bug。

把校验放在入口。 数据一进系统就规整成合法形态,里面的代码就不用到处防御。

别用同一个类型表达两种含义。 比如金额有时候是元有时候是分,这类事故的根源是类型上分不出来。

出了一个 bug,别只修它。问一句:能不能改一下结构,让这类错误以后写都写不出来? 能做到的话,你消灭的是一整类 bug,不是一个。

能推理的代码才可能正确

bug 密度正比于"这段代码能被多少东西影响"减少状态和依赖

一段代码为什么难改?因为你不知道改了会影响谁。而"能影响它的东西"越多——全局变量、共享状态、隐藏的副作用、跨层调用——你能确定的事情就越少。

一个函数如果只依赖参数、只返回结果、不碰外面的任何东西,那它的正确性是可以被完整推理和测试的。反过来,一个读三个全局变量、写两张表、发一条消息的函数,没有人能真正说清它对不对。

工作里的样子

全局配置到处读。 改一个配置,影响范围没人说得清。

函数里偷偷改了传进来的对象。 调用方完全预料不到,这类 bug 极难查。

一个方法既算数又存库还发通知。 想单独用它的计算逻辑?不行,一用就发通知。

把计算和副作用分开。 纯计算的部分抽出来,好测、好复用、好推理。剩下的读写外部的部分薄薄一层。

单例和静态状态。 测试时互相污染的主要来源——测试难写,常常是设计耦合的症状,不是测试工具的问题。

下次觉得一段代码"不好测",别急着找测试技巧,先怀疑是设计问题。难测几乎总是意味着依赖太多或职责不清。"好测"和"好改"高度重合,所以可测性是很好的质量代理指标。

暴露得越少,未来越自由

模块的价值在于藏住了什么边界

划分模块的目的不是"整理得好看",是让一部分决定可以在不惊动其他人的情况下被改掉。

所以衡量一个模块好不好,不看它提供了多少功能,看它藏住了多少东西:藏住了数据库表结构、藏住了第三方接口的怪癖、藏住了具体算法。藏得越多,将来能自由改动的空间越大。

反过来,一个把内部细节全暴露出去的模块,等于把自己焊死了——任何改动都会波及所有调用方。

工作里的样子

接口返回了数据库实体。 于是表结构变成了对外契约,以后想改表就得改所有调用方。

把第三方 SDK 的对象传得到处都是。 换供应商时才发现它渗透进了几十个文件。

一个"薄薄的一层"包装。 看起来多余,但换实现的时候救命。

跨模块直接读对方的数据库表。 短期最快,长期是最难拆的耦合。

内部方法设成 public"方便调用"。 一旦被人用了,它就变成了不能改的契约。

写一个模块时问:如果哪天要换掉里面的实现,有多少人会被影响? 答案越接近"没人",这个边界划得越好。

顺利的情况人人都试过

事故大多发生在错误路径上,而错误路径最少被测试被系统性忽视的一块

正常流程每天被跑几万次,有问题早就发现了。真正出事的是异常分支:超时、重试、部分失败、并发冲突、依赖挂了。而这些路径恰恰是最少被测试、最少被 review、写得最随意的。

别吞异常
catch 里什么都不做,是在把问题藏到更远的地方爆炸。
错误信息要能定位
"操作失败"帮不了任何人。要带上是什么操作、哪个对象、什么原因。
想清楚重试的后果
网络超时了但对方其实成功了,重试一次就是重复扣款。幂等不是可选项。
部分失败怎么办
三步操作做完两步挂了,系统处在什么状态?这个问题必须有答案。
超时必须设
没有超时的调用,会在下游变慢时拖垮整个上游。
降级路径也要测
从没被执行过的兜底代码,出事时大概率也是坏的。
工作里的样子

catch (e) {}。 这一行制造的排查难度,可能是整个项目里最高的。

重复下单、重复扣款。 几乎都来自重试路径没做幂等。

下游变慢导致自己也挂了。 没设超时 + 线程池被占满,是典型的雪崩链条。

日志里只有"error"没有上下文。 半夜排查时,这等于没有日志。

兜底逻辑写完从没跑过。 真到用的时候才发现它自己有 bug。

写完一个功能,专门花五分钟只看异常分支:每个可能失败的地方失败了会怎样、能不能重试、重试安全吗、失败信息够不够定位。这五分钟的性价比高于其他任何质量动作。

三、几个常被做反的

等到第三次再抽

过早抽象,比重复更糟错误的抽象比重复难拆得多

"不要重复自己"被念得太多,以至于很多人看到两处相似就急着抽象。但相似不等于相同——两段代码今天长得像,可能是巧合,明天就会因为不同的原因分头演化。

而一旦你把它们合并成一个带三个开关的通用函数,拆开的成本远高于当初重复的成本。经验做法是等到第三次:出现三次,你才真正看清哪部分是共性、哪部分是差异。

工作里的样子

带五个布尔参数的"通用"函数。 每次新需求就加一个开关,最后没人敢动。

为了复用而复用。 两个业务今天逻辑一样,合并之后其中一个改需求,于是加 if——从此开始腐烂。

"以后可能会用到"的配置项。 大部分永远没用到,但一直在增加理解成本。

过早拆微服务。 边界还没稳定就拆,结果是分布式的单体,同时承担两边的缺点。

复制粘贴其实常常是对的。 尤其在你还不确定两者会不会分头走的时候。保留重复,是在保留选择权。

看到重复先忍住,记一笔就好。等第三次出现时再抽象——那时候你对"什么是真正的共性"的判断,会准得多。

要问它搬到哪儿去了

复杂度守恒:它不会消失,只会转移警惕"这个方案消除了复杂度"

业务本身有多复杂,是需求决定的,代码消灭不了它。所有的框架、模式、工具,做的都是把复杂度从一个地方搬到另一个地方。

所以听到"用了这个方案就简单了",正确的反应不是高兴,是问一句:那些复杂度现在在哪? 通常在:配置文件里、运维那里、调试的时候、或者新人的学习曲线上。

工作里的样子

引入一个框架,业务代码变清爽了。 但出问题时你得读框架源码——复杂度搬到了排查环节。

"零配置"工具。 约定优于配置的意思是,复杂度搬进了你必须记住的约定里。

微服务拆分。 单个服务简单了,但你多了服务发现、分布式事务、链路追踪——复杂度搬到了运维和调试。

ORM。 写起来简单,代价是生成的 SQL 你不知道长什么样,性能问题排查更难。

加一层缓存。 读变快了,但你多了一致性问题——这是最常被低估的一次搬运。

评估任何方案时问:它把复杂度搬到哪儿了?搬过去的地方,我们扛得住吗? 有时候答案是"搬得好"(搬到我们擅长的地方),那就该做;但"消除了复杂度"这种说法,基本可以直接怀疑。

风格之争几乎不值得吵

团队里,一致性大于个人品味统一的次优 > 各自最优

一个项目里有三种错误处理方式、四种命名风格、五种目录组织,即使每一种单独看都不差,合起来的理解成本也远高于统一用一种平庸的做法。

因为读代码的人需要不断切换心智模型。这个成本是隐形的、持续的、且随人数增长。

而且风格之争(tab 还是空格、大括号换不换行)本身就消耗团队精力,而收益接近零——交给工具自动格式化,把争论时间省下来。

工作里的样子

格式化工具入 CI。 一次配置,永久消灭这类争论。

"我习惯这么写。" 在个人项目里没问题,在团队项目里是成本。

新人带来了新框架的写法。 不是不能引入,但要么统一切,要么明确划出边界,别两种风格长期共存。

同一个项目里三种日期处理方式。 这类不一致最容易导致真实 bug。

把能自动化的全部自动化(格式化、lint、import 排序),剩下的写成一页约定文档。争论只发生一次,之后按文档执行。这一页文档同时也是给 AI 用的——它和新同事一样需要显式的规矩。

四、几个工程习惯

没测试的代码会腐烂

测试真正的价值,是让你敢改不是为了抓 bug

测试常被理解成"防止 bug",这个理解太窄了。测试最大的价值是给你修改的勇气。

一个没有测试的模块,所有人都不敢碰。不敢碰就会用打补丁的方式绕开它,补丁越堆越多,最后彻底不可维护。代码腐烂的机制,本质是恐惧。

先测关键路径
覆盖率数字没意义,重要的是最贵的那条路径有没有被测到。
测行为不测实现
测"输入这个得到那个",别测内部调了哪个私有方法——否则重构就会误报。
一个测试测一件事
失败时才能立刻知道哪里坏了。
改老代码前先补测试
哪怕当前行为是错的。你要的是别改坏,不是证明它对。
bug 修复配一个测试
这是最划算的一类测试——它防的是已经证明会发生的事。
别追求 100%
边际收益递减很快,而维护成本一直在。
工作里的样子

"这块代码没人敢动。" 几乎总是等价于"这块没有测试"。

重构时心里没底。 有测试的重构是工程,没测试的重构是赌博。

一改就崩的测试。 通常是测了实现细节,而不是行为。

测试跑五分钟。 太慢就没人跑,等于没有。速度本身是测试的质量属性。

判断一段代码质量,有个很准的问法:"你敢不敢现在就重构它?" 敢,说明质量不错;不敢,原因通常就是缺测试或依赖太乱。

大改动等于没审

小步提交,可回滚性是最被低估的质量属性出事时唯一真正管用的东西

线上出问题时,最有效的手段永远是回滚,不是现场调试。而能不能快速回滚,取决于你的改动是不是足够小、足够独立。

同时,大 diff 等于没有 review:一次改二十个文件,人是审不动的,审查会退化成"看着还行"。

工作里的样子

一个 PR 改了三个不相干的事。 想回滚其中一个,只能整个回。

重构和功能改动混在一起。 这是最难审的一类 PR——看不出哪些是行为变化,哪些只是搬家。 分开提。

上线前一天合并的大改动。 风险叠加在最不该叠加的时间点。

"顺手把格式也调了。" 于是真正的逻辑改动淹没在两千行格式变化里。

一条规矩:一个提交只做一件事,重构和功能改动永远分开提。 这一条同时改善了 review 质量、回滚能力和排查效率——性价比极高。

留着"以后可能用"的成本是持续的

删代码是被低估的技能最好的代码是没有代码

每一行代码都是负债:要被读、要被理解、要在升级依赖时被考虑、会在搜索时干扰你。没有代码是最没有 bug 的状态。

而大多数团队只会加不会删:功能下线了代码还留着,开关早就固定了分支还在,注释掉的代码放了三年——因为删除有风险而收益不明显,没人愿意做。

工作里的样子

注释掉的代码块。 版本控制就是干这个的,直接删。留着只会让人猜"是不是还要用"。

下线功能的残骸。 常常还在被定时任务调用,成为幽灵逻辑。

永远为 true 的开关。 每个读代码的人都要多想一次。

"以后可能用得上"。 大部分永远用不上,而理解成本天天在付。

依赖里没用的包。 增加构建时间、增加攻击面。

每次做需求时顺手删掉一点死代码。删除比新增更需要勇气,但它是少数能让系统真正变简单的动作。

五、现实里:工期紧的时候怎么办

借债是杠杆,不记账才是灾难

技术债不是罪,无意识的技术债才是关键是记账

现实中一定会有"先上线再说"的时候,这本身没错——赶上市场窗口的价值可能远超代码整洁。技术债和金融杠杆一样,是工具。

真正的问题在于很多团队借了债但不记账:没人知道欠了什么、欠在哪、什么时候必须还。于是利息一直在滚(每次改动都更慢),直到某天系统彻底动不了。

赶工期的两种做法
无意识欠债

"先这样吧",然后忘了

有意识欠债

代码里留 TODO 注明:为什么这么写、正确做法是什么、什么条件下必须改

无意识欠债

下次谁碰谁倒霉

有意识欠债

建一个技术债清单,写清影响范围和利息

无意识欠债

月底突然发现动不了了

有意识欠债

每个迭代固定留一点时间还债

无意识欠债

"这块是历史原因"

有意识欠债

至少让下一个人知道这是有意为之,不是没想到

工作里的样子

TODO 写"这里以后优化"。 等于没写。要写清楚为什么、正确做法是什么。

"等有空再重构"。 永远不会有空。要么排进计划,要么承认不改。

新人问"这里为什么这么写"。 如果答案是"不知道,历史原因",说明当初的债没记账。

每个迭代留 10%~20%。 比"专门搞一次重构月"现实得多——后者通常排不上。

赶工期时照样可以写下三句话:为什么这么做、正确做法是什么、什么情况下必须改。 三句话花不了两分钟,但它把"隐患"变成了"已知的、可管理的债"——这两者的差别是巨大的。

它解释不了什么

这一页给不了"什么时候该妥协"的答案。 所有条目在现实里都会撞上工期、人手、历史包袱。判断什么时候该守、什么时候该让,靠的是经验,这一页替代不了。

不同领域的权重差别很大。 嵌入式、游戏、数据分析脚本、金融系统,对性能、正确性、可读性的取舍完全不同。这一页偏向的是长期演进的业务系统。

代码质量解决不了产品方向的问题。 把一个没人要的东西写得很漂亮,收益是零。"该不该做"永远排在"做得好不好"前面。

团队问题不能靠个人代码质量解决。 如果 review 流于形式、没人写测试、上线全靠人肉,个人再努力也扛不住。这些是流程和文化问题。

它也不解决"祖传屎山"。 面对一个几十万行的老系统,上面这些原则大部分用不上,你需要的是另一套东西:先加测试保护、小步改、别大重构。这一页没展开讲。

  1. 高质量的唯一有用定义:将来改它的时候,不容易出错。
  2. 起不出名字,通常不是词穷,是这个东西职责不清。
  3. 最好的防错不是记得检查,是让错的写法根本写不出来。
  4. 复杂度不会消失,只会转移——听到"这个方案更简单了",先问它搬到哪儿去了。
  5. 代码腐烂的机制本质是恐惧:没有测试 → 没人敢改 → 只能打补丁 → 更没人敢改。

这一页和《AI 写代码》是配套的:那一页说"你的收益取决于验证能力",而这一页讲的很多东西——小 diff、好测试、显式约定、清晰边界——恰好就是验证能力的组成部分。AI 写得越快,这些基本功越值钱,不是越不值钱。