ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

代码注释是债不是资产:用自文档化重写可维护代码

代码注释是债不是资产:用自文档化重写可维护代码 先摆个立场那标题确实是我写的但我不是让你真的把项目里所有注释一夜之间全删干净。我真正想说的是绝大多数注释根本不该存在——它们不是帮助是债是坑是你未来某天半夜调bug时突然背上的刺。我前阵子接手一个老项目一个方法大概两百行里面密密麻麻三十多条注释。你以为这是好事结果其中十一条跟代码实际行为对不上五条描述的是已经被删掉的逻辑。我照着注释去理解理解出来一个根本不存在的业务最后定位问题多花了整整一下午。那一下午我脑子里只有一句话注释这东西写的时候是天使读的时候全是恶魔。为什么会有“kegg注释”这种词挂在热搜上因为“注释”这个词在不同领域含义差得很远生物学里的序列注释和程序员代码里的注释完全不是一回事。但核心逻辑其实是通用的注释这个过程意味着给一段东西附加解释信息而这个解释一旦产生就开始面临一个永恒的问题——它会不会和原始内容脱节。代码注释尤其如此。代码是活的会跑会被改注释是死的只会躺着。代码更新了注释还停在十年前别人踩过你的坑改了逻辑注释还在描述那个已经不存在的旧流程。所以这篇文章我不谈道德只谈成本和收益。我会把“什么时候别写注释”“什么时候必须写注释”“怎么用代码本身说话”这三件事讲透配合真实代码示例和我在项目里踩出来的坑。不管你是刚入行的新手还是带团队的老兵这篇文章都值得看完——尤其如果你习惯写完代码顺手补一堆“解释性”注释那很可能会改变你之后写代码的习惯。1. 为什么注释会成为“恶魔”三个真实代价1.1 注释和代码脱节形成虚假的安全感先说最要命的注释和代码之间没有编译期约束没有类型检查没有任何机制能保证注释描述的和代码做的一致。代码错了会报错、会崩溃、会报警注释错了什么都不发生它就那么安安静静躺着等你某天真信了它然后掉进坑里。我见过一个真实的例子。一个支付相关的函数注释写着“此处已处理优惠券抵扣金额为折扣后价格”结果后面做活动产品把折扣逻辑整个移到了上层这个函数早就变成只算原价了。注释没人改代码也没人管直到财务对账发现金额老是对不上。排查两天真相大白的时候所有人看那行注释的眼神都不对劲了。这不是个例是常态。只要注释存在它就会给你一种“代码应该没错”的心理暗示——因为注释清清楚楚写着这里做了什么事情。这种虚假的安全感比没有注释更可怕因为它让你不敢深挖、懒得验证最终让问题在眼皮底下发酵。注意注释不是代码的一部分它只是代码的“旁白”。旁白说错了剧本还是照样演。1.2 维护成本翻倍一个改动两处维护如果你负责过一个活得比较久的项目你肯定有这种经历一个需求改动逻辑代码只要五分钟但同步修改相关注释得再来十分钟而且还总是忘了改干净哪条。这就是注释最直接的财务账本来维护一份表达就够了现在要维护两份。而且这两份表达之间没有稳定性可言——改了代码忘改注释是不小心改了代码故意不改注释是心累最后注释和代码的偏差积累到一定程度整个文件的可读性就会断崖式下降。更隐蔽的是注释还会占用你的大脑缓存。你读一个方法的时候每遇到一条注释就要切换一次思维模式先读注释在说什么再读代码在做什么再对比两者是否一致。这比直接读代码本身消耗的算力高得多。一个本可以用五十行清晰代码讲明白的逻辑套上二十条注释以后反而更难看懂了。1.3 注释是代码坏味道的“遮羞布”我后来看代码有个习惯哪个方法注释越多越可疑。因为真正清爽的代码本身就容易读懂你根本不需要在它旁边絮絮叨叨。而当一个人写了一堆注释来解释他的代码往往意味着他的代码还不够好——变量名叫a、b、c函数两百行一坨逻辑埋在三个if嵌套里。注释在这里起的作用不是帮助而是掩饰。它把“这段代码很烂”这件事包装成了“这段代码很复杂所以需要解释”。你读完注释觉得写的人挺认真于是对背后的乱逻辑多了一份容忍。而这份容忍就是劣质代码存活的土壤。我见过最夸张的一个例子一个十几行的函数写了八条注释来介绍每一步在干嘛但函数名本身叫processData入参是个object返回也是个object全程没人知道data是什么、processed成什么样。那些注释翻译成人话就是“我处理了一下数据具体处理成什么样你们看代码吧。”但代码比注释还难看懂。这种注释就是典型的遮羞布——它制造了一个“这段逻辑被解释过”的假象实际上什么都没说清楚。2. 哪些注释最坑人四种常见的坏注释类型2.1 直译型注释纯粹噪音先看最简单的类型。这类注释就是把代码翻译成人话再说一遍不提供任何增量信息// 将i增加1 i; // 将用户列表按创建时间排序 users.sort((a, b) - a.createTime.compareTo(b.createTime)); // 设置用户的邮箱 user.setEmail(email);这些注释错了吗没有。有用吗一点用都没有。i谁看不懂sort按什么排序看下参数不就知道了你写这种注释的唯一效果就是让阅读者多扫一眼浪费一点点脑力然后把注意力从代码本身挪到一个无用信息上。如果全项目都是这种注释它们还会形成一种“噪音污染”。就像你在看一幅画画上每个颜色旁边都贴了个标签写着“红色”“蓝色”“绿色”不但不帮助你欣赏反而让你烦。删掉这类注释是成本最低、收益最明显的事情直接删就行不用有任何心理负担。2.2 过时型注释和误导型注释最危险这类型上面已经提到过。过时型注释的可怕之处在于它错得很安静。代码不会因为你改了逻辑就自动更新注释注释也不会因为你忘了更新就变红报警。它就在那儿躺着和代码并存直到你把它当真理。举一个我在真实项目里遇到的例子# 这里过滤掉停用用户因为停用用户不能再登录 if user.is_deleted: return None看着是不是挺正常结果后来业务变化允许“已删除用户”在 30 天冷静期内重新激活账号于是代码改成if user.is_deleted and not user.can_reactivate(): return None但注释没人动。半年后新同事接手看到那行注释振振有词地跟产品说这个接口不可能返回已删除用户——因为他“读过了代码”。这件事最后上升到客诉。你说这行注释算不算恶魔它太算了。2.3 注释掉的代码版本库里的“僵尸”“先注释掉不删万一以后用得上”是我最不理解的程序员习惯之一。代码是干吗用的是跑起来产生价值的。你注释掉一段代码它既不跑也没价值还占着地方吓人。而且版本管理工具就是干这个的——真要用得上Git 历史里翻得出来没必要在源码里留僵尸。注释掉的代码比普通注释坑的地方在于它们往往会被后来的维护者当成“可选逻辑”而非“死代码”。有人重构时看到一段被注释掉的排序逻辑以为这是处理某些边界场景的备选方案就照着这个“备选方案”改了活代码的行为结果搞出线上bug。这种case我见过不止一次。我的建议很粗暴只要确定一段代码当前没在跑就直接删。担心以后用得上提交信息里写清楚“删除了XX逻辑”以后通过git log -S或者git blame都能找回来。2.4 情绪型和占位型注释团队协作的隐藏成本比僵尸代码更隐蔽的是情绪注释和占位注释。情绪注释长这样// 不要动这里动一次炸一次 // FIXME: 暂时这么写后面一定重构 // HACK: 这代码我自己都看不懂但是能用这些注释背后往往藏着一段没人敢动的脆弱逻辑。它的存在不是在帮助后来者理解而是在传递焦虑。写注释的人早已跑了留下后面每个人面对那行代码既不敢改、又看不透、还找不到文档。这种情绪价值是负的。占位注释常见于匆忙交付的代码// TODO: 这快后面要加权限校验 // 这里应该记录审计日志主任说下周做注意我的意思不是否定TODO本身——合理使用下它有提醒价值。但现实是绝大多数的TODO注释会变成永不兑现的承诺。你在代码里放了五条TODO三个月以后回来一看一条都没做它们只是变成了一种心理安慰“我知道有问题我已经记录过了。”可这份记录既不会催你也没人帮你跟踪最终就是躺在代码里永不见天日。实操建议TODO 可以写但必须配合任务系统。要么立刻建 task 并关联编号比如// TODO(TICKET-2333): 增加重试机制要么就别写。没有编号的 TODO 等于没写。3. 让代码自己说话自文档化的三个落地手法3.1 命名即文档把解释塞进名字里要减少注释第一件事就是把名字起好。变量名、函数名、类名都是不会被代码逻辑篡改的“活注释”——代码怎么改名字就跟到哪不会像旧注释一样停留在上一个版本。打个比方。下面这段代码有注释// 计算用户实际需要支付的金额会减掉折扣也可能会加运费 double x calc(price, discount, shipping);再怎么优化你还是要靠那条注释去理解calc在算什么。但如果函数本身就叫calculateFinalPrice入参再清楚一点double finalPrice calculateFinalPrice( originalPrice, applyDiscount(discountRule, originalPrice), estimatedShippingFee(region, weight) );你还需要注释吗不需要。函数名说清楚了要算什么参数名说清楚了它拿什么算每个小函数又解释了折扣和运费各自怎么来。整段逻辑变成了一条可以顺畅读下来的句子。我自己写代码有个衡量标准如果一段代码需要注释才能看懂那优先想办法改名和拆分而不是直接补注释。命名是比注释便宜得多的文档——它不需要额外维护因为代码每次编译运行都是在验证这些名字是否还在正确描述行为。3.2 把注释的需求“吸”进函数设计还有一种常见情况你写注释是因为一个方法里塞了太多逻辑不解释根本绕不清楚。这种问题的根治办法不是注释而是拆函数。看一个经典反例def process(orders): # 先过滤已完成订单 valid [o for o in orders if o.status ! done] # 再按客户优先级排序 valid.sort(keylambda o: o.customer.priority, reverseTrue) # 前10个走加急 urgent valid[:10] # 剩下的走普通 normal valid[10:] # 加急订单要发短信通知 for o in urgent: notify(o.customer, urgent) # 普通订单发邮件 for o in normal: notify(o.customer, email) return urgent, normal这里每条注释都在解释一个“本可以叫出名字”的操作。与其用注释解释不如把操作变成函数让名字来当注释def split_orders(orders): valid [o for o in orders if o.status ! done] valid.sort(keyorder_priority) return valid[:10], valid[10:] def notify_urgent(orders): for o in urgent: notify(o.customer, urgent) def notify_normal(orders): for o in normal: notify(o.customer, email) def process(orders): urgent, normal split_orders(orders) notify_urgent(urgent) notify_normal(normal) return urgent, normal哪个好维护一目了然。拆出来的每个函数名本身就是注释的替代品。而且这些“注释替代品”有一个注释永远做不到的优点它们可以被单独测试、单独复用出问题时也能单独定位。我在实践里常用的拆法叫“注释作为拆分清单”如果一段代码里需要写三条以上注释来描述步骤我就把这几个步骤分别抽成函数注释的内容变成函数名。代码重构完注释几乎会自动消失。3.3 用类型和数据结构表达意图而不是解释第三个利器是类型系统和数据结构设计。很多时候我们想用注释弥补的是代码本身在表达上的含糊——而这些含糊恰恰可以用类型来解决。看一个例子// 第一个参数是超时秒数第二个是重试次数 retry(task, 30, 3);这种调用谁看谁懵。但改成对象后RetryPolicy policy RetryPolicy.builder() .timeoutSeconds(30) .maxAttempts(3) .build(); retry(task, policy);不用注释调用点自己就把话说干净了。再比如如果你用一个裸布尔值表示“是否是服务端”读代码的人还得记着这个布尔值的含义。但如果定义一个枚举NodeRole.SERVER、NodeRole.CLIENT那赋值和比较的时候语义就长在代码上。我常说一句话注释解释的是“代码想干什么”类型和数据结构限制的是“代码能干什么”。后者显然比前者更可靠——因为违反类型会报编译错误违反注释什么都不会发生。能用语言本身表达清楚的事情就不要额外负担一层解释。4. 必要注释的边界哪些地方真的该写光说“别写注释”很容易走火入魔。作为一个认真搞过几年代码库的人我必须诚实告诉你确实有一些注释值得写而且不写才是灾难。关键在于你要能分辨“什么注释有用”。4.1 解释“为什么”而不是“是什么”写注释唯一不可替代的场景是代码本身无法表达“为什么这么做”的时候。“是什么”永远可以用代码自己表达但“为什么”常常藏在业务背景、历史包袱、外部约束里这些信息放不进任何函数名或变量名。举例这类注释是金子// 这里不能用浮点运算因为金额计算中浮点误差会触发风控误判 const amountInCents Math.round(price * 100);# 客户端依赖这个响应体字段顺序不能重排否则老版本APP会解析崩溃 return {code: 0, message: ok, data: ...}-- 不用JOIN是因为该表数据量过亿JOIN会导致查询计划退化详见性能报告 #4021这些注释解释的都是“代码为什么长成这样”它背后是大量上下文、失败经验、外部约束。未来的维护者只凭代码绝对猜不到这里藏着一个雷。所以这条注释不是解释而是警告是路标它值得存在。4.2 领域知识和业务规则的来源标注还有一种注释让我感激涕零把一段看着很奇怪的逻辑关联到需求的来源。我处理过一个会员积分规则代码非常简洁几行就算完了。但这些规则本身极其反直觉为什么这个场景积分×1.5为什么那个场景积分直接清零代码里看不出任何原因。直到我翻到一条注释# 见需求文档 PRD-201 第三页连续30天未登录的用户积分按50%折算 # 该规则来自运营活动“沉睡唤醒”2022年上线后一直延续那天我差点给写这条注释的人磕一个。这种注释的价值在于它连接了“代码世界”和“业务世界”。代码只是实现业务规则才是那个需要被理解的源头。没有注释你面对的就是一串无法解释的数字和判断条件只能靠猜。所以我的经验是当你发现的代码行为明显有“非显而易见性”时——即一个正常编程思维的人绝不会这么写但这背后是有意为之的业务决策——请务必留下注释把决策的来源、原因、时间或关联文档编号写清楚。4.3 公共API的接口契约注释该写就写对那些会被其他团队、其他项目甚至外部用户调用的公共 API我坚定支持写规范的注释。这里不是写给自己看的随笔而是接口契约——它是给所有对接方看的使用说明书。用 Doxygen 或 Javadoc 格式去描述参数范围、返回值语义、异常条件、线程安全性在我看来完全合理。Python 项目里用 docstring 配 Sphinx 生成在线文档也是同样道理。工具本身没有错错的是把工具用在不该用的地方。接口注释模板我一般长这样/** * brief 提交订单并触发支付流程 * param orderId 订单ID必须是已创建且未支付状态 * param paymentMethod 支付渠道枚举支持 ALIPAY / WECHAT / CARD * return 支付流水号可用于后续对账 * throws InvalidOrderError 订单不存在或状态非法 * throws PaymentGatewayError 支付渠道调用失败可重试 */ PaymentReceipt submitOrder(string orderId, PaymentMethod paymentMethod);这种注释是真正有价值的“注释”因为它描述的是“外部契约”不是“内部实现”。实现细节你随便换契约不能变。它帮调用方理解边界条件、避免误用同时保证了接口演进的稳定性。这跟我前面骂的“i加1”完全是两码事。关键区分给“调用者”看的信息契约、范围、约束值得写给“维护者”看的信息尽量融入代码本身命名、结构、类型给“作者自己看”的信息我暂时这么写、以后改基本不值得写。5. 工具链和配套实践把注释从代码里“请出去”和“拦下来”5.1 用 Git 提交信息承载“变更理由”而不是污染源码很多人写不出代码注释时喜欢把变更原因写在源码里# 2023-05-11 修改这里加了缓存因为DB太慢问题在于源码不是变更日志一行行历史文本堆在文件里只会让阅读者崩溃。变更理由本来就该写在 Git 提交信息里那里有作者、时间、完整 diff还不占源码空间。我之前团队的做法很简单每次提交尽量写清“为什么”和“怎么影响”fix(order): 修复并发支付导致超卖 原因扣减库存的 SQL 少了库存条件判断两个请求同时进入时 后一个请求覆盖前一个的扣减结果。 影响加库存校验条件重试时不会重复扣减。这个提交信息在未来git blame任何一个相关行时都会出现。后来的维护者不用去源码里翻注释就能完整了解这段代码的来龙去脉。这比源码注释高级太多——因为它跟具体改动绑定不会像注释一样长期留在文件里失真。5.2 用评审和自动化检查“拦截”无效注释既然注释是有害的最好的策略就是别让它进场。我在 code review 环节会重点关注新增注释看到那种直译型、过时型、凑数型注释我一律打回。同时也有一些自动化手段可以用。比如 ESLint 有no-warning-comments可以设置告警级别处理TODO、FIXME残留问题。有些团队还会用自定义脚本扫描大段的注释行比例超过阈值就报警。这里给一个简单的思路示例用脚本统计每个文件的注释行占比#!/bin/bash # 统计某个Python文件中的注释行占比粗略行级判断 total0 comments0 while IFS read -r line; do total$((total 1)) trimmed$(echo $line | sed s/^[[:space:]]*//) case $trimmed in \#*) comments$((comments 1)) ;; esac done $1 echo comment ratio: $((comments * 100 / total))%这类脚本重点不是算出一个绝对精确的数字而是让团队有个客观抓手当某个文件的注释占比异常高时去重构代码而不是继续堆注释。5.3 注释工具链的几个常见坑模板、乱码与清理工具说句题外话工具层面有几个和注释相关的高频痛点我顺手一并聊了。第一个是 IDE 注释模板。很多人用 IDEA 配了一套包含作者、日期、描述的文件头模板觉得挺规范。但这类模板往往只生成“当初写文件的人”和“当初创建的时间”文件一旦被多人维护这些信息就变成过时的历史牌匾。我见过一个文件文件头写的是 2012 年某前同事的名字最后修改的人却是 2023 年的新同事。这种信息对阅读者一点忙都帮不上反而误导。所以我的建议是文件头模板可以保留简单的版权声明或包说明但“作者/日期”这种一定会过期的信息尽量别放。作者和日期问 Git别问注释。第二个是 vscode 注释乱码。很多老项目源码是 GBK 编码新编辑器默认 UTF-8打开后注释部分直接成乱码看得人头皮发麻。解决办法是让编辑器按文件编码自动检测或者统一转码// .vscode/settings.json { files.encoding: utf8, files.autoGuessEncoding: true }第三种场景是“去除源码注释的软件”。如果你接手一个注释污染严重的项目想批量清理可以试用开源工具比如stripcommentsPython或者直接正则扫描。但要非常谨慎因为字符串字面量里的#、//、/*不该被当注释误删。我建议用成熟工具而不是自己写正则。更重要的是删之前先走一遍 Git diff确认没有误删文档字符串、LICENSE 头、公共类说明等关键信息。经验提醒批量删注释前先git stash留一条退路。我就干过一次批量删完发现某个文件里一条注释其实是给外部对接方看的协议说明删了之后对接方看代码看懵了。后来靠 Git 恢复才救回来。6. 在真实项目里推进“去注释化”的实践建议6.1 增量治理别搞“大扫除”如果你认同我的判断决定在团队里推进这件事切忌一夜之间发动“删注释运动”。代码库里数十条注释有些是金有些是屎你一把火烧了金也烧没了。我当时的做法是定一条新规新代码一律不写直译型注释用命名和拆函数让代码自己说话对旧代码只在需要修改的模块里做“路过清理”——你改这个函数的时候就顺手把过时注释清掉把该留的“为什么”补上。三个月下来整个核心模块的注释质量好了一大截而且没有因为大规模重构产生额外风险。同时我会在团队的编码规范里明确写清楚三类注释的处置方式直译型和废话型直接删过时型和误导型修正或者删注释掉的代码一律删需要时从 Git 找回只有真正的“为什么”注释和 API 契约注释才允许留下。6.2 当别人坚持删你注释时怎么办这个标题发出去之后我收到最多的反问是我注释写得好好的凭什么删我的回答是删注释不是因为“注释没有价值”而是因为代码库整体才是真正需要维护的产品。注释是代码的旁白而旁白一多正剧就容易被人忽略。当年纪越大、项目越久你会越来越感激那些“一眼看到底”的文件而不是那种每段逻辑旁边都要读一段散文才能继续的文件。如果你实在舍不得可以做一个实验找自己三个月前写的十个含有大量注释的文件把注释全部遮住只看代码看自己能理解多少。大多数情况下你会惊讶地发现——代码本身已经足够说明了注释根本可有可无。少数真正需要在注释里解释的“为什么”你自然会有刻骨铭心的印象。6.3 常见问题速查表场景应不应该写注释替代方案变量/函数命名不清不该写改命名提取函数代码逻辑很复杂自己都绕不清不该写拆函数简化结构记录“为什么这么写”的背景约束应该写保持简短关联需求编号公共API的入参/返回/异常契约应该写Doxygen/Javadoc/docstring记录TODO和待办事项最好别写用任务系统跟踪代码中引用编号注释掉的废弃代码坚决不写删除靠Git找回文件头作者/创建日期别写靠Git blame说明业务规则来源应该写写需求文档名和关键决策原因个人体会是敢不敢删注释其实是一个程序员对“代码质量”要求的分水岭。写注释确实很舒服——它让写的人觉得“我表达清楚了”让读的人觉得“有人解释过”大家一起安心。但这种安心是虚幻的。真正能让项目长期活下来的是代码本身的清晰度、结构的可读性、变更历史的可追踪性而不是那些写在边上、慢慢变质的旁白。这个思路后续还能继续往深走怎么通过领域建模减少注释需求、怎么让 API 文档直接从代码生成、怎么设计团队评审清单来拦截劣质注释。不过那些话题得单独写一篇了把这次的底线先亮明白代码不说谎时注释才有资格开口说话。
返回列表