“代码要有充分注释”这句话害了不少项目。它没说清楚”充分”是多少,于是有人给每一行都配一句中文翻译,评审时谁也不敢说不对——毕竟注释多一点总没坏处。
有坏处。注释和代码一样是要维护的资产,而且它比代码更容易腐化:代码错了测试会挂,注释错了没人知道。
注释的唯一价值
注释能提供的价值只有一个:补充代码本身无法表达的信息。
代码能表达”做了什么”——读一遍就知道了。代码表达不了的是:
- 为什么这么做(业务背景、历史原因、取舍依据)
- 为什么不那么做(看起来更明显的方案为什么被排除)
- 隐含的约束前提(调用方必须保证什么、外部系统的怪癖)
除了这三类,其他内容都应该考虑用代码本身表达,或者直接不写。
不该写的三类
一、复述代码。
// 设置用户名为 Tom
user.setName("Tom");
// 循环遍历订单列表
for (Order order : orders) {
// 如果订单金额大于 100
if (order.getAmount() > 100) {
// 打印订单号
System.out.println(order.getId());
}
}
这类注释的信息量等于零,还带来维护成本——代码改成 setName("Jerry") 而注释没改,就变成了误导。更糟的是,它给读者一种”这段代码被解释过了”的错觉,反而降低了阅读时的警惕性。
二、过时的信息。 曾经正确但已经失效的描述,比没有注释更危险。
// TODO: 等 v2 接口上线后替换为 ApiV2Client
// 注意:此方法不支持并发调用,需要外部加锁
public List<Item> fetchItems() { ... }
第一行如果 v2 已经上线两年了,就是噪音。第二行如果方法内部已经加了锁,就是在误导。
三、显而易见的文档注释。 为了凑覆盖率而生成的注释,常见于 IDE 自动生成或 AI 补全:
/**
* 获取用户
* @param id 用户ID
* @return 用户
*/
public User getUser(Long id) { ... }
这三行里没有任何一行是读代码看不出来的。
好注释的四种类型
意图说明
解释”为什么是这段代码”,尤其是看起来有点怪的地方。
// 这里必须用 200 而不是 204:部分老版本客户端在收到 204 时会
// 认为响应体丢失并触发重试,导致重复下单。
// 详见 issue #4821。
w.WriteHeader(http.StatusOK)
# 先写 outbox 再提交事务,不能反过来。
# 反过来的话,进程在两步之间崩溃会产生"业务未生效但事件已发出"的幽灵消息。
outbox.insert(event)
db.commit()
约束警告
说明调用方或后续维护者必须遵守的前提。
/**
* 非线程安全。如果需要并发访问,调用方必须自行同步,
* 或者使用 {@link ConcurrentPriceCache}。
*/
public class PriceCache { ... }
// 调用方必须保证 key 不为 nil,否则会 panic。
// 历史原因:早期版本允许 nil 表示"全部",后来语义改了但保持 panic 以便快速暴露问题。
func Lookup(key string) ([]byte, error) { ... }
领域知识
代码里那些”魔法数字”和非常规流程,通常有业务来源。
// 90 天不是随便定的:该品类商品的最长退换货周期是 88 天,
// 留 2 天缓冲用于对账延迟。改这个值前先跟售后团队确认。
private static final int ARCHIVE_AFTER_DAYS = 90;
// 费率按"分"计算并向下取整,这是财务口径要求的,不能改成四舍五入。
// 差异部分在月度结算时统一处理。
func calcFee(amountCents int64, rate float64) int64 {
return int64(float64(amountCents) * rate)
}
TODO 的规范写法
TODO 不是”以后再说”的垃圾桶,它需要足够的信息让别人能接着做。
// TODO(zhang): 2026-11 之前替换为批量接口,当前 N+1 查询在大促时 P99 达到 800ms。
// 阻塞原因:依赖 inventory-svc v3.2 发布。
// 参考:RFC-0173
一条合格的 TODO 至少要说清三件事:做什么、为什么现在没做、谁负责。没有负责人和期限的 TODO,半年后没人敢删也没人敢做。团队里可以定个规矩:TODO 必须带工单号,超过半年自动转成技术债清单。
用命名与结构减少注释需求
很多注释的存在,是因为代码没写好。
用变量名替代注释:
// 如果账户处于冻结状态则拒绝提现
if (acct.getStatus() == 3 && acct.getBalance() > 0) { reject(); }
// 改为
if (acct.isFrozen() && acct.hasBalance()) { reject(); }
用提取方法替代注释:
// 计算可提现金额(余额减去冻结金额和手续费)
long a = balance - frozen - (balance - frozen) * feeRate;
// 改为
long withdrawable = calculateWithdrawable(balance, frozen, feeRate);
用常量替代注释:
if len(items) > 500 { batch = true } // 超过 500 走批量
// 改为
const batchThreshold = 500
if len(items) > batchThreshold { batch = true }
判断标准:如果你要给一段代码写注释,先问能不能用命名、提取、常量让它自己说清楚。 能,就改代码;不能,才写注释。
注释会腐化,怎么维护
注释腐化的根源是它和代码之间没有任何强制关联。代码改了,注释不会报错。
几个可行的约束:
一、评审时把注释当作代码的一部分检查。 修改函数逻辑时,diff 里如果只有代码没有注释变更,评审者要问一句”上面的注释还成立吗”。这是最有效也最依赖人的一条。
二、让注释靠近它描述的代码。 注释离代码越远,被遗忘的概率越高。文件头的”本文件包含 XX 功能”这种注释,几乎必然过时。
三、优先写”为什么”而不是”是什么”。 “为什么”变化慢,”是什么”变化快。上面那些例子里,90 天的业务原因、200 状态码的历史包袱,比”循环遍历列表”稳定得多。
四、定期清理 TODO。 把超过期限的 TODO 扫一遍,能做的做,不能做的删掉或者转成正式的技术债记录。
五、用测试固化约束。 有些注释描述的是约束(”必须按 ID 排序”),如果这种约束能用测试断言表达,就比注释可靠得多。
// 不需要写"返回的 items 必须按 createTime 升序"
// 直接写测试
func TestListItemsSortedByCreateTime(t *testing.T) { ... }
文档注释与实现注释的分工
两类注释的读者不同,内容也不同。
| 文档注释(API doc) | 实现注释 | |
|---|---|---|
| 读者 | 使用者(不读实现) | 维护者(正在读实现) |
| 内容 | 做什么、契约、边界、异常 | 为什么这么做、约束、背景 |
| 位置 | 公开接口上方 | 函数内部、关键语句旁 |
| 变化频率 | 低(接口变更才改) | 中(实现变更要同步) |
| 举例 | “金额单位:分;余额不足抛 InsufficientError” | “单位用分是为了避开浮点误差,财务口径要求” |
文档注释的检验标准:使用者只看这段注释,能不能正确调用? 参数单位、空值语义、时间复杂度、可能抛出的错误、是否线程安全——这些不说清楚,使用者就得去读源码。
实现注释的检验标准:半年后的自己看到这段注释,能不能理解当初的取舍? 如果注释只是”这里做了 XX”,那它没有存在的价值。
反面例子对照
| 反例 | 问题 | 改法 |
|---|---|---|
// 增加计数 配 count++ |
复述代码 | 删掉 |
// 修复了 bug |
无信息量 | 写清 bug 成因和工单号 |
// 这段代码很复杂,小心修改 |
恐吓但不解释 | 写清复杂在哪、约束是什么 |
/** 获取用户 */ getUser(Long id) |
显而易见 | 补充契约:不存在时返回 null 还是抛异常 |
| 注释描述 A,代码做 B | 过时误导 | 改代码时同步改注释 |
// 临时方案 无期限无人名 |
永不清理 | 补负责人、期限、工单号 |
| 大段注释掉的代码块 | 噪音 | 删掉,版本控制里有历史 |
那行”这段代码很复杂,小心修改”尤其典型。它传达的情绪是真实的,但信息是零——读者依然不知道雷在哪。改成”这里的排序依赖上游保证的 ID 单调性,上游改造前不能改成并发处理”,才是有效信息。
一条可执行的判断
写注释前问自己:这段信息,读代码的人能从代码里得到吗?
能,就别写,先想办法让代码更清楚。不能,才写——并且写的是”为什么”,不是”是什么”。
这条规则同时解释了好注释和坏注释:解释”为什么用分而不是元”的注释有价值,因为代码里只有一个 long;解释”循环遍历订单”的注释没价值,因为 for (Order o : orders) 已经说得很明白了。
评论(0)
还没有评论,来抢沙发。
请 登录 后参与评论。