程序员的注释之道范德成
2014年10月25日
写这篇文章之前,我所思考的前一个问题是代码的质量。而在编写了好的代码的前提下,代码的注释就成了代码质量的另一部分——它的作用初看时显得并不那么大,但是越到后面越显得重要。当一名勤奋的程序员为了一个大项目,洋洋洒洒地写了数千行代码之后,他转而去做该项目的另一个模块。等到一年后,他回头再来看他之前写的这几千行代码时,如果没有详细有意义的注释,那就得挠头了——因为当初没有写注释。
为什么会出现这种情况呢?我们当今所使用的编程语言,比如Java、C#、python等,不是早就是高级语言了嘛?已经不只是给机器看的代码,这些代码也能让人读懂了嘛?的确,它们是高级语言。但问题却在于,代码本身只写了怎么做一件事,做了这件事的结果如何。至于它做的是什么事,为什么要做这件事,在什么情况下可以用它来做这件事,代码本身是体现不出来的。
因此,注释有其独特的用处。代码本身的质量,来自正确性、安全性、可用性、可读性、可维护性、效率等好几个方面。好的注释,则能够帮助代码提升其可读性和可维护性,并最终为正确性等其他几个方带来正面影响。
那么,在保证代码本身的高质量的前提下,注释应该怎么写,才能有效呢?以下是我个人在多年工作中总结出来的经验教训。它适用于绝大多数命令式(imperative)编程语言:
第一点,要为复杂的函数接口写清晰的注释。这里的函数接口指的是函数名及其参数、返回值、异常等的规范。更严格地来说,关于函数接口的注释定义了一个函数的契约。虽然我们所使用的编程语言不一定支持面向契约的编程,或者我们不选择这样一种编程模式,但我们仍可以在概念上用注释来表示一个函数的契约。
函数接口的注释该怎么写呢?以C#为例,它的一个函数会有函数名,参数和返回值。同时,参数和返回值又分别有其类型,还会有潜在的异常抛出。如下,是一个假想的做归并排序的函数的接口(该接口明显过于复杂,但其目的是为了演示如何写注释):
bool MergeSort<T>(T[] array, int begin, int len, IComparer<T> comparer)
{
}
C#语言支持XML注释,其功能类似于Javadoc。而且,它的XML注释正好支持我们要注释的契约的所有内容。因此,我们可以用XML注释来写。对于这个函数,我们的注释如下:
/// <summary>
/// Performs merge sort on a part of an array with a comparer.
/// </summary>
/// <typeparam name="T">the type of members in the array to be sorted</param>
/// <param name="array">the array to sort</param>
/// <param name="begin">the beginning index of the part in the array to be sorted</param>
/// <param name="len">the length of the part in the array to be sorted</param>
/// <param name="comparer">the comparer used to compare elements in the array; see documentation on <see cref="System.Collections.Generic.IComparer<T>">IComparer</see> for more information</param>
/// <returns>
/// Whether the part of the array is already sorted.
/// </returns>
/// <remarks>
/// <para>This method checks whether the specified part of the source array is already sorted. If it is already sorted, the method returns true directly without changing the array. Otherwise, it sorts the part and returns false.</para>
/// <para>This method performs stable sort on the specified part of the array.</para>
/// <para>After calling this method, the range of the array from position <paramref name="begin" /> and of length <paramref name="len" /> is sorted.</para>
/// <para>The time complexity of this method is O(n log n), where n is the length of the part being sorted.</para>
/// </remarks>
/// <exception cref="System.IndexOutOfRangeException">
/// An IndexOutOfRangeException exception is thrown if the indices <paramref name="begin" /> and <paramref name="len" /> are out of range.
/// </exception>
bool MergeSort<T>(T[] array, int begin, int len, IComparer<T> comparer)
中文翻译如下:
/// <summary>
/// 利用一个比较器,对一个数组中的一段内容执行归并排序。
/// </summary>
/// <typeparam name="T">被排序数组的元素类型</param>
/// <param name="array">要排序的数组</param>
/// <param name="begin">数组中要排序的段的起始下标</param>
/// <param name="len">数组中要排序的段的长度</param>
/// <param name="comparer">用于比较数组中元素大小的比较器;详细信息请参见<see cref="System.Collections.Generic.IComparer<T>">IComparer</see>的文档。</param>
/// <returns>
/// 该数组段是否本来就是有序的。
/// </returns>
/// <remarks>
/// <para>本方法将检查源数组的指定段是否已经处于有序状态。如果是这样,本方法将不修改数组内容,直接返回true。否则,本方法对指定段进行排序并返回false。</para>
/// <para>本方法对数组的指定段执行的排序是稳定排序。</para>
/// <para>在调用本方法之后,数组中从<paramref name="begin" />开始,长度为<paramref name="len" />的段将被排序。</para>
/// <para>本函数的时间复杂度为O(n log n),其中n是被排序部分的长度。</para>
/// </remarks>
/// <exception cref="System.IndexOutOfRangeException">
/// 若下标<paramref name="begin" />和<paramref name="len" />的范围溢出了,则抛出一个IndexOutOfRangeException异常。
/// </exception>
bool MergeSort<T>(T[] array, int begin, int len, IComparer<T> comparer)
C#的XML注释还支持更多标签,比如example(示例)等。但是在我们日常的编程过程中,这些标签只是根据需要,偶尔用到。而上面我讲的这些则是经常要用到的。我们来看一下。首先,对于该函数,我们有一个简介(见summary部分)。然后,对于每个参数,以及函数的返回值,我们都要作一下说明。典型的异常要做说明,但并非每一个都有必要。对于复杂的函数,在简介里面没有办法用一句话概括所有意思的,需要写一段注解(remarks部分)。
其中函数的简介,力求用一句话(最多两句)把该函数该做什么事情给讲清楚。参数和返回值的注释,要把它们的含义讲一下,把它们的特殊值讲一下。特殊值的例子就是和平时传的值有所区别的值。比如,对于某些可选参数,传入null表示忽略该参数,那么这样的值就是特殊值,需要得到说明。注解部分则要加入一些函数契约的细节。比如,前置条件(函数调用之前需要满足的条件)、后置条件(函数调用后,数据会变成什么样,比如这里的已排序状态就是后置条件)、时空复杂度(如果有需求的话)、典型的应用场合、特殊的应用场合(这对于一些需要在特定上下文中执行的业务API来说很重要)等等。
这里,我想特别说明一下对于异常的注释。各个语言中,对于异常在语法上有着不同要求。C#不支持checked exception,它的设计者Anders Hejlsberg也不建议我们使用checked exception;Java则要求除了程序bug以外的异常都作为checked exception。所谓checked exception,是这样的一些异常类型,当它们被当前函数抛出时,当前函数必须在原型(即函数的接口)中声明这些异常。这样做的好处是,调用方知道将会收到哪些异常。缺点则是,应用程序扩展起来很不方便:当需要从底层增加一个新的异常类时,要么就得在应用程序函数体内调用这些API的地方捕获这些异常,要么就得在应用程序的函数原型中声明这些异常。否则就会导致编译错误。这对于一些库来说,就要求它们为了应用程序着想,把它们的所有异常类从一个基类衍生出来,从而应用程序只需要声明那个基类即可。出于这个原因,我们写注释,就只为典型的异常(在实际场合中容易遇到的异常)写注释。那些很难出现,甚至理论上不可能出现的异常完全不用写。而且,必要的时候,虽然checked exception声明的可能是基类,但我们的注释却要反映出子类异常的具体发生情况。
第二点,注释中要写清楚重要的细节。在上一点的例子中已经带到过这一点:排序算法的时间复杂度和稳定性就是这种重要细节。
第三点,注释本身不要有冗余信息。注释是用来解释程序的。写注释的时候,为了注释的完整性,其内容可能会和程序代码的含义有一些重叠,亦即冗余信息。这一点很难完全避免。但是,注释内部不同部分之间出现的冗余则可以被避免。要避免这种冗余,没有定法,主要就是在写注释的过程中多读几遍,把重复内容删掉;有些地方可以用引用的方式,避免写重复的注释。
第四点,注释要随时更新。这一点是为了拥有高质量的注释,我们所必须做的事情。每次函数期望的功能、接口、契约发生改变时,注释也应该被相应地更新。当然,在实际的软件工程中,完全理想的时刻更新很难保证,但至少要力求对于大的变化,注释是足够新的,而且,我们在阅读程序代码的过程中如果发现注释不正确,也可以顺便调研一下程序的行为,并据此来更新注释。
第五点,当遇到复杂的、不直观的实现时,也要为实现写注释。有的时候,一个函数是很简短的,它的实现不言自明。但也有时候,一个函数的实现比较复杂。这可能是由于复杂的算法、复杂的业务逻辑等原因,这时,用以表示执行步骤的注释就会很方便。下面的拓扑排序函数就演示了这一点:
def topological_sort(graph, output_func):
# Time complexity is O(n ^ 2)
while len(graph) > 0:
# Output dependency-free nodes
to_pop_node_name = []
for node_name in graph:
# Remove and output all nodes without dependencies
if len(graph[node_name].dependencies) == 0:
output_func(node_name)
to_pop_node_name.append(node_name)
# Remove the nodes
for node_name in to_pop_node_name:
graph.pop(node_name)
to_pop_node_name = None # finished using
# Remove dependency links
for node_name in graph:
current_node = graph[node_name]
to_pop_node_name = []
for child_node_name in current_node.dependencies:
if child_node_name not in graph:
to_pop_node_name.append(child_node_name)
for child_node_name in to_pop_node_name:
current_node.dependencies.pop(child_node_name)
to_pop_node_name = None # finished using
其中,Output dependency-free nodes、Remove the nodes等都是对一个代码块的注释。此类注释能表示出程序的步骤。这种注释还可以跨越更大的范围,此时的技巧是,用大括号或begin、end的字样来表明其范围。如下所示:
int i;
int max = -1;
int sum = 0;
// Do the first thing {
for (i = 0; i < arr.Length; i++) {
if (max < 0 || arr[i] > max) {
max = arr[i];
}
}
// }
// Do the second thing {
for (i = 0; i < arr.Length; i++) {
sum += arr[i];
}
// }
对于那些老板不喜欢在注释中看见大括号的情况,用begin、end来代替:
// Begin of "Do the second thing"
for (i = 0; i < arr.Length; i++) {
sum += arr[i];
}
// End of "Do the second thing"
准确使用注释中的大括号或者begin、end的好处是,当一大块代码中还有嵌套注释时,依然可以清晰地表示出一段范围。另外,我个人的准则是不给注释加上step 1、step 1.1、step 2之类字样,原因很简单,一旦在原有的步骤里面插入一个新步骤,那么从这个步骤往后所有步骤的编号都要调整,太费事儿。
另外,对于复杂的算法或业务需求,也需要加注释说明,以免在数年后回头来看这段代码时,忘记当初为什么是这样写的了。对于算法,要说清楚这个算法的需求是什么,它是怎么设计的,什么样的输入需要被处理,处理的原理是什么,等等。对于业务逻辑,需要说明要支持的输入情况(包括全局、静态变量和数据库等环境数据的情况)、所有的处理步骤在业务流程中的含义、处理完成之后对数据和业务状态带来什么影响,等等。以算法为例,下面的例子来自一个拓扑排序之前检查图是否有循环的方法:
def detect_loop(graph, o_loop):
"""
detect_loop:
Detects any loop in a graph.
Parameters:
graph - the graph to test
o_loop - a list to receive the looping nodes
Return value:
True if a loop has been detected. False otherwise.
"""
# We are using depth-first search to find loops.
#
# If we did not implement recursive calls, we could use trace-back in a
# non-recursive manner; python default recursion limit is about 900, which
# is in general enough here, as the dependency we analyze is usually less
# than 100.
#
# Due to the fact that if we traverse a non-tree directed acyclic graph
# (DAG), we may end up in a time complexity of O(2 ^ n), we make a deep
# copy of the graph first, and make it into a tree. During the process, we
# can detect loops. The time complexity is O(n ^ 2) where n is the number
# of nodes.
result = False
copied_graph = GraphNode.deep_copy_graph(graph)
# Cases:
# Root 1 leads to a loop--will be detected and the function will return.
# The loop will have a link pointing back to an ancestor node or the
# current node itself.
# Root 1 leads to a DAG--any link to a visited node (cannot be an ancestor
# node or the current node itself) will be detected and removed, and
# made into a tree
# Root 1 leads to a DAG (call it DAG1), root 2 links to DAG1--no loop can
# involve DAG1. Reason: suppose there is a loop involving DAG1, then
# from a node that is a part of the intersection, we can go back to
# it through the links, thus making DAG1 not a DAG--contradiction.
# So if root 2 leads to a loop--the loop will be detected by checking the
# DFS traversal stack
# If all of root 1..n-1 lead to DAGs, and root n leads to a loop, it will
# be detected only there
#
# accessed: used to mark accessed nodes in the DAG. Its members are the
# names of accessed nodes. When an accessed node is met through a link,
# the traversal returns and the link is removed, because the link should
# not be added to the tree.
accessed = set() # of node name (string)
# traversal_stack: is used to record the loop to show to the user
traversal_stack = []
for node_name in copied_graph:
result = detect_loop_rec(copied_graph, node_name, accessed, traversal_stack)
if result:
# Loop detected
o_loop.extend(traversal_stack)
break
return result
第六点,要为简化、抽象和缩写的变量名或函数名,注释其全称及其含义。比如,你用winnt4wks来代表Microsoft Windows NT Workstation 4 i386 Multiprocessor Free的时候,你就应该吧这个全名用注释的方式写在右边(或上方):
// winnt4wks: Microsoft Windows NT Workstation 4 i386 Multiprocessor Free
object winnt4wks;
注意上面的例子中,如果注释是写在变量名上方的,那么首先要用这个变量名先导,然后加个冒号,接下来才是解释。
第七点,不要为不言自明的代码加注释。这一点很自然。比如一段代码大家都知道是干啥的,就根本没必要写注释。写了注释反倒是干扰视听,弄不好将来修改代码时还需要维护,或者忘了维护,造成后来者被误导的情况。比如,下面代码的注释在生产代码中就完全是没必要的:
// Loop and print every element of the array
for (i = 0; i < arr.Length; i++) {
Console.WriteLine(arr[i]);
}
第八点,不要为频繁变化的代码写冗余的注释。前面说到过,注释和代码所表达的含义可能有一点重合。此时,如果某段代码经常改,那么,基本上可以肯定的是,代码的负责人知道这段代码是什么含义,因为最近刚刚改过。那么,必要的注释仍然要加,但是与代码含义重复的、冗余的信息就不那么必要了,可以省略。
作为一名程序员,掌握了以上要点,就能写出好的注释,让自己的代码变得更加易读、易维护。
|