代码审查是为数不多的几种软件开发技术之一,它被一致认为可以降低缺陷的发生率。它为什么有效?本文对这一话题提出了一些大胆的推测,并提供了关于如何充分利用代码审查的实用建议。
理解程序为何能工作
作为软件开发人员,我们经常需要推断软件的行为。例如,要修复一个 bug,我们从一个展示该行为的测试用例开始,然后阅读源代码以了解此行为是如何产生的。通常我们会发现自己无法理解任何内容,不得不求助于诸如使用调试器或询问代码作者等取证技术。这种情况远非理想。毕竟,如果我们难以理解自己的软件,我们如何能确定它确实能工作呢?不出所料,它确实无法工作。
在修改和扩展软件时,正确的理解也至关重要。程序员必须始终对程序中发生的事情、它如何准确地映射到领域等有一个精确的心理模型。如果这个模型存在缺陷,他们编写的代码将不符合领域,并且无法正确解决问题。错误的理解直接导致 bug。
我们如何使软件更容易理解?人们常说,要看你是否真正理解某件事,你需要尝试向他人解释它。例如,作为一名参加考试的理科学生,你可能会被要求对某些众所周知的观察到的效应进行解释,并从该领域的基准定律推导出它。类似地,如果我们在软件中对某个问题进行建模,我们可以从领域知识和通用编程知识出发,构建一个论点,说明我们的模型为何适用于该问题,为何它是正确的,具有最佳性能等等。这种解释以代码注释的形式出现,或者在更高级别上以设计文档的形式出现。
如果你有彻底注释代码的习惯,你可能已经注意到,编写注释通常比编写代码本身要困难得多。它还具有一个令人不快的副作用——有时,在编写注释时,你会越来越清楚地意识到代码难以理解,需要花费很长时间才能解释,或者可能是完全错误的,你必须重写它。这正是编写注释的主要积极影响。它可以帮助你发现 bug 并使代码更易于理解,并且除非你尝试解释代码,否则你不会注意到这些问题。
理解你的程序为何能工作与理解它为何失败密不可分,因此,对于后者也存在类似的过程,称为“橡皮鸭调试”。为了调试一个特别棘手的 bug,你开始一步一步地向一个想象中的伙伴甚至一个无生命的物体(例如一只黄色的橡皮鸭)解释程序逻辑。这个过程通常非常有效,远远超出了人们根据橡皮鸭有限的对话能力所预期的效果。其潜在机制可能与注释相同——你仅仅通过尝试解释程序就开始更好地理解它,这让你能够发现 bug。
在团队合作时,你甚至可以向同一个项目的其他开发人员解释你的代码。这可能比跟鸭子说话更有趣。更重要的是,他们将维护你编写的代码,因此最好确保 *他们* 也能理解它。解释代码如何工作的一个很好的正式场合是代码审查流程。让我们看看你如何才能从中获得最大收益,使你的代码更易于理解。
审查他人的代码
代码审查通常被视为一个把关流程,维护人员会审查每一次贡献,以确保它符合项目方向、具有可接受的质量、符合编码指南等等。在处理外部贡献时,这种观点似乎很自然,但如果将其应用于内部贡献,则意义不大。毕竟,我们的同事维护人员完全了解项目目标和指南,他们可能比我们更有才华和经验,并且可以信任他们能够产生最佳的解决方案。额外的审查如何才能有所帮助呢?
审查代码一个不太明显但非常重要的部分是,看看其他人是否能够理解它。无论参与者的管理角色和编程熟练程度如何,这都是有帮助的。如果易于理解是你的首要任务,那么作为审查者,你应该做什么呢?
你可能不需要关心诸如代码风格之类的琐事。有自动化的工具可以处理这些。你可能会发现一些 bug,但这可能是一个副作用。你的主要任务是理解代码。
首先检查拉取请求试图解决的问题的高级描述。阅读它修复的 bug 的描述,或它添加的功能的文档。对于较大的功能,通常会有一个设计文档,描述整体实现,而不会深入到代码细节中。在你理解了问题之后,开始阅读代码。它对你来说有意义吗?你不应该太努力地去理解它。想象一下,你很累并且时间紧迫。如果你觉得必须花费很大力气才能理解代码,请向作者寻求澄清。在交谈的过程中,你可能会发现代码不正确,或者它可以用更直接的方式重写,或者它需要更多注释。
在你得到答案后,不要忘记更新代码和注释以反映它们。不要仅仅在你亲自得到解释后就停止。如果你作为审查者有一个问题,那么其他人以后也可能会有同样的问题,但可能没有人可以问。他们将不得不求助于 `git blame` 并重新阅读整个拉取请求或其中的几个。代码考古有时很有趣,但当你正在调查一个紧急的 bug 时,这是你最不想做的事情。所有答案都应该一目了然。
与作者一起,你应该确保代码对于任何具有基本领域和编程知识的人来说都基本显而易见,并且所有不显而易见的部分都已清楚地解释。
为代码审查做好准备
作为作者,你也可以做一些事情来使你的代码更容易被审查者理解。
首先,如果您正在实现一个主要功能,那么在您开始编写代码之前,可能需要进行一轮设计评审。跳过设计评审直接进入代码评审可能会成为一个主要的挫折来源,因为最终可能会发现您要解决的问题本身的表述都是错误的,并且您所做的所有工作都必须被丢弃。当然,设计评审也无法完全避免这种情况。编程是一项迭代的、探索性的活动,在复杂的情况下,您只有在实现第一个解决方案后才能开始理解问题,然后您会意识到它是不正确的,并且必须被丢弃。
在准备您的代码以供评审时,您的主要目标是使您的问题及其解决方案对评审者清晰明了。一个好的工具是代码注释。任何较大的逻辑片段都应该有一个介绍性注释来描述其总体目的并概述实现方式。此描述可以参考类似的功能,解释与它们的差异,解释它如何与其他子系统交互。放置此一般描述的一个好地方是充当功能主要入口点的函数,或其其他形式的公共接口,或最重要的类,或包含实现的文件,等等。
深入到每个代码块,您应该能够解释它做什么,为什么这样做,为什么选择这种方式而不是其他方式。如果有多种方法可以做这件事,为什么您选择了这种方法?当然,对于某些代码,这些内容是从更一般的注释中得出的,不需要重复说明。数据操作的机制应该从代码本身就可以看出来。如果您发现自己在解释语言的特定特性,最好不要使用它。
特别注意使代码中的数据结构清晰可见,并对其含义和不变性进行良好的注释。数据结构的选择最终决定了您可以应用哪些算法,并设定了性能的限制,这也是我们作为 ClickHouse 开发人员应该关注它的另一个原因。
在解释代码时,务必为读者提供足够的上下文,以便他们无需深入研究周围的系统和模糊的测试用例即可理解您的意思。提供指向可能与任务相关的所有事物的指针。如果您知道您的代码必须处理的一些特殊情况,请详细描述它们,以便能够重现它们。如果有相关的标准或设计文档,请引用它,甚至内联引用它。如果您依赖于其他系统中的某些不变性,请提及它。当容易做到这一点时,添加反映您注释的程序检查是一个好习惯。关于不变性的注释应伴随一个断言,并且一个重要的场景应由一个测试用例来重现。
不要担心过于冗长。注释往往不够,但几乎从来没有太多。
关于代码注释的常见问题
人们经常听到对注释代码的想法提出异议,所以让我们讨论几个常见的问题。
自文档化代码
您经常会看到一个令人费解的想法,即源代码可以以某种方式“自文档化”,或者注释是“代码异味”,它们的存在表明代码编写得很糟糕。我很难想象这种信念如何能够与多年来在与他人合作的情况下维护足够复杂和大型软件的任何经验相兼容。代码和注释描述了解决方案的不同部分。代码描述了数据结构及其转换,但它无法传达含义。代码中的名称充当指针,将数据及其转换映射到领域概念,但它们是示意性的,缺乏细微差别。编写使人们易于理解数据操作方面正在发生什么的代码并不困难。这主要需要适度,也就是说,阻止自己过于聪明。对于大多数代码来说,很容易看出它在做什么,但为什么?为什么选择这种方式而不是那种方式?为什么它是正确的?为什么这里的快速路径有帮助?为什么您选择此数据布局?如何保证此不变性?等等。对于独自在一个短期项目上工作的开发人员来说,这可能并不那么明显,因为他们头脑中包含了所有必要的上下文。但是,当他们必须与其他人(甚至与过去的和未来的自己)合作,或在不熟悉的领域工作时,非代码、更高层次的上下文的重要性就变得非常明显。我们应该或甚至可以以某种方式将诸如此注释编码到名称或控制流中的想法简直是荒谬的。
过时的注释
注释无法由编译器或测试检查,因此没有自动的方法来确保它们与其他注释和代码保持最新。注释逐渐变得不正确的可能性有时被用作反对任何注释的论据。
此问题并非注释所独有——代码也会并且确实会变得过时。诸如死代码之类的简单案例可以通过静态分析或研究代码的测试覆盖率来检测。更复杂的案例只能通过校对才能找到,例如维护不再重要的不变性,或准备不需要的一些数据。
虽然过时的注释可能导致错误,但同样的事情也适用于(也许更强烈地适用于)缺乏注释。当您需要一些关于代码的高级知识,但它没有写下来时,您被迫从第一原理开始进行整个调查以了解发生了什么,而这是容易出错的。即使是过时的注释也可能比没有注释提供更好的起点。此外,在积极使用注释的代码库中,它们往往是正确的。这是因为开发人员依赖注释,阅读和编写它们,并在代码评审期间注意它们。注释会随着代码的变化而定期更改,过时的注释很快就会被注意到并修复。这确实需要一些习惯。在广阔的、难以理解的自文档化代码的沙漠中,孤立的注释将无法很好地发挥作用。
结论
代码评审使您的软件变得更好,并且这其中很大一部分可能来自尝试理解您的软件实际上在做什么。通过特别关注代码评审的这一方面,您可以使其效率更高。您将拥有更少的错误,并且您的代码将更容易维护——作为软件开发人员,我们还能要求什么呢?
2021-04-13 Alexander Kuzmenkov。标题照片由 Nikita Mikhaylov 提供。
附言:本文包含作者的个人观点,并非 ClickHouse 维护人员的权威手册。