浅谈 Go 语言代码注释问题

每隔一段时间,网上总会突然出现一些令人讨厌的帖子,其观点是:不应该为代码写注释,它存在的唯一原因是因为代码本身不足够好。对于这些论点,我完全不能苟同。

烂代码

他们的观点也不完全是错误的。没有人能说自己的代码足够好。代码本身也会慢慢变坏。你知道什么时候代码腐烂得最厉害吗?当你六个月没有碰这些代码的时候!

当回过头再读的时候,你会非常好奇:“这个作者到底是怎么想的?”(于是,使用 Git blame 来查看历史记录,没想到代码竟然是自己写的,因为这是你的代码。)

反对注释者的论点是:需要注释的唯一原因是你的代码不够“清晰”。如果代码重构、命名和组织地更好,那就不需要这些注释了。

今天,当整个项目和问题空间都装在你的脑袋里的时候,你自然会觉得代码是干净、清晰和优雅的。但是,当六个月后,又或是 CTO 刚好在生产系统上突然发现一个非常严重的 bug,在主管紧盯的情况下,某个可怜的家伙不得不去调试你的代码的时候,这些代码可能对你已经有些模糊了。

你无比熟悉的一段代码,尝试去理解其他人在什么场景下不能理解,这是一种非常难以掌握的技能。不过,它具有无可估量的价值,几乎和一次就能把代码写到位的能力一样重要。在工业界中,基本上没有人是独行侠。即使真地在独自写代码,你也会遗忘代码这么写的缘由或者昨天深夜“工程代码”核心部分的确切目的。未来一旦你离职,接替你的人不得不去理解每一个仅藏在你的脑袋里的小偏好和习惯。

所以,写上一个即使在现在看来过于浅显的注释也不是一个坏事情。有时候,它甚至会带来巨大的帮助。

无注释经常导致代码更难以理解

某些人声称:移除注释将会使代码变得更好,因为这迫使你编写更清晰的代码。我对此亦不以为然,因为我不认为有人会实际写上一些次佳的代码,并且写上一些注释来解释这种行为(除了 // TODO: 这是一个临时的解决方法,我会稍后修正 之外)。我们都会写出在各种外部条件(通常是时间)下自认为最好的代码。

为去除注释而重构代码的问题在于,这种努力往往事与愿违,会产出更坏的代码。典型例子是重构一行复杂的代码,将之提取到一个独立的函数中,并取一个望文生义的名字。这个行为看上去很棒,但是,现在你为阅读代码的人带来了一个上下文切换点。替代真实代码的是一个函数调用,于是,他们不得不滚动到函数定义的地方,记住和对照函数声明和调用的参数,并且将函数返回值代入到调用的地方。

另外,清晰的函数名仅仅能够提供非常短小的注释。任何需要多余一小段短语的注释无法(或者不应该)概括到一个函数名中。因此,你最终会得到一个其上有注释的函数。

的确,一个非常短小的函数都可能导致困惑和更复杂的代码。如果看到这样的函数,我会去搜索这个函数在哪些地方被调用。如果只有一个地方,我就会去思考,这是一个确实封装了全局逻辑的通用代码块呢(譬如 NameToUserID),还是,这个函数严重依赖调用端的特定状态和实现,并且不能在其他地方正确工作。随着把这些代码提取一个函数里面,你本质上在其余的代码库中暴露了这些实现细节,这么草率的做决定是不合适的。即使你知道这个函数其他人不应该调用,其他人还会在某些地方调用它,即便这些地方不合适这么做。

小函数的相关问题在 Cindy Sridharan 在 medium 网站上的帖子[1]中有更加详细的阐述。

我们甚至可以深入讨论长短变量名的比较和权衡,但是就此打住吧,一般你不可能接受更长的变量名了。除非你的变量名就是你想写的完整的注释,否则你还是会丢失信息而不得不添加到注释中。我认为我们可以达成一致:usernameStrippedOfSpacesWithDotCSVExtension 是一个可怕的变量名称。

我不是说我们不应该提炼代码,让它们更加清晰和优雅。绝对要这么做!这是一个杰出开发人员的特征。但是,代码清晰性和有注释是正交的,撰写良好的注释也是杰出开发人员的特征。

没有坏注释

在这些讨论中给出的坏注释的例子都是些小错误,除了那些启蒙编程课程外,在实际工作中几乎不会碰到。

// 实例化一个错误对象var err error

不错,这个注释很清楚,但不是非常有用。不过同时,它实际上也没有什么坏处。

在浏览代码时,虽然有些不待见,但也很容易被忽略。如果开发者能够在其中包含一个有用的注释,能够节省我数小时键盘工作时间的话,我宁愿看成百这样的简单注释,而不是没注释。

我非常确信,不会有任何代码会说“伙计,这段代码非常容易理解,所以不需要提供任何注释。” 实际情况恰恰完全相反。

实际上,我找到了一些严重缺失注释的代码 – Go 标准库。它的代码非常精良,但在很多情况下,如果在读取代码前对其功能没有深刻理解,那么理解他们为什么这么设计将是个挑战。如果能加一些注释,用于解释代码的逻辑和设计意图,将使 Go 标准库更加容易阅读。在这篇文章中,我主要讨论实现代码里的注释,而不是通常的公开函数的文档注释(通常情况下,它们也是非常棒的)。

任何注释胜过无注释

另外一个反注释者喜欢拿出来的例子,可以用下面的简洁有力的图片来展示(证明其论点):

图片

哈,极好笑的,有人更换了瓶子里面的东西但是没有更新外面的标签。

但是,这是 20 年前的问题了,当时通常不进行代码审查。不过,现在代码审查已经非常普遍了。如果检查注释和实现是否匹配不是你们代码审核流程的一部分,那么最好检查一下你们的代码审核流程。

这不是说不会犯错误,实际上我昨天刚提交了一个“注释和实现不一致”的 bug。类似“无注释比错误注释好”的言论初听起来是正确的,然而,当你认识到如果没有注释,开发人员猜错代码的功能比错误注释的出现的概率高的多的时候,你会改变你的看法。

即使这种情况真的发生,代码被修改了,你依然可以获取有价值的信息:代码以前的用途。修改仅仅和原先有些许不同罢了,它依旧完成基本相同的功能。为了版本控制和向后兼容,同一个函数在不改变名称和签名的情况下,在功能上发生剧烈变化的频率有多少?基本上非常少。

就拿我昨天发现的 bug 来说,我们调用 client.SetKeepAlive(60)。而 SetKeepAlive 函数的注释是 “SetKeepAlive 在发送 PING 请求之前,客户端需要等待指定数量的时间(以秒为单位)”。看上去很棒,不是吗?知道我注意到 SetKeepAlive 的参数是 time.Duration。

如果没有其他指定的单位,60 这个整数将使用 Go 的 duration 的缺省单位纳秒。哎,某人更新了该函数,使用 Duration 类型来替换 Int。有趣的是,它仍然向下取整到了最接近的秒数,所以注释不是不正确,只是有些误导罢了。

为什么?

最重要的注释是为什么要注释。为什么代码是按照设计来执行的?为什么这个 ID 需要小于 24 个字符?为什么要在 Linux 下面隐藏这个选项?诸如此类。这些问题为什么重要的原因是你无法从代码中提炼出来。这些注释总结了开发者获得的经验教训,商业或系统层面的限制条件等,它们是价值无量的,并且几乎无法从其他途径获得(例如,函数取名应该反映函数做什么而不是为什么)。

那些用于说明代码功能的注释往往不是特别有用的,因为如果拥有足够的时间和努力,你总能够理解代码的功能。本质上,通过函数定义,代码往往会告诉你它的具体功能,但这不意味着你不应该写任何注释。确实应该力争写出最清晰简洁的代码,但是注释不需要任何额外的运行时开销,如果你觉得有人会错误理解一些代码或者理解上有困难,应该写上一些注释。至少,这个会节省他们半个小时来理解你的代码,这些注释也会在很大程度上帮助他们避免错误地修改或使用你的代码,从而导致 bug 的产生。

测试

一些人认为函数的功能测试案例就相当于文档。某种程度上说,确实是这样的。但是,在我的效率文档表中,它的优先级非常低。为什么呢?因为它们极其精确而且琐碎,仅仅覆盖了功能的很少一部分。每一个测试仅确切地测试一个特定的输入和与之相配的输出。任何超过一个简单函数的情况,你很可能需要一大串代码来构建输入和输出。

对于大多数编码而言,描述一个函数的主要功能比写代码去完整测试要容易的多。

很多时候我的测试代码行数倍于函数实现本身,然而文档注释仅仅需要寥寥几行而已。

此外,测试仅仅解释了函数的功能。函数的设计功能是什么?它们不能解释为什么,但是就像前面提到的,设计目的和意图总是更重要的。

你确实应当测试你的代码,通过一些边界测试案例,测试对于判定代码在边界条件下是否能够正常工作非常有用。但是一般而言,如果到了必须通过阅读测试案例来理解代码的地步的话,那么已经是一个危险信号,告诉我们需要去编写更多更好的注释了。

结论

除了一些非常简单的例子以外, 有用注释和无用注释的边界是非常难于去发现的。

所以,我宁愿人们站在多写注释的一方。你无法知道下一个可能阅读你代码的人是谁,所以能帮助他们的是尽你所能写上一大堆的注释。尽量写到你认为太多了,然后再多写一些,这个数量估计就正好了。

链接:https://npf.io/2017/11/comments/(版权归原作者所有,侵删)

【AD】炭云:768元/年/1GB内存/20GB SSD空间/2TB流量/500Mbps-1Gbps端口/独立IPv4/KVM/广州移动

【AD】美国洛杉矶CN2 VPS/香港CN2 VPS/日本CN2 VPS推荐,延迟低、稳定性高、免费备份_搬瓦工vps