专家教你系列<二>:如何写出优秀的代码注释?

元闰子 发表于 2021/05/07 19:04:24 2021/05/07
【摘要】 注释是软件开发过程中的性价比极高的一个工具,它只需要花费20%的时间,即可获取80%的价值。代码注释最重要的作用就是让读者可以在不读源码的情况下,快速了解一段代码的主要功能。

前言

相信大家都会遇到这种情况:一周前自己写的代码,现在再拿出来看,发现读不懂了,“ 这代码是我写的???”。这时候,代码注释就可以发挥它的作用了——提高晦涩难懂的代码的可读性;注释可以起到隐藏代码复杂细节的作用,比如接口注释可以帮助开发者在没有阅读代码的情况下快速了解该接口的功能和用法;如果写的好,注释还可以改善系统的设计

既然注释这么多好处,为什么我们程序员还是不愿意写注释?

“代码都写不完了,哪有时间写注释,以后再补吧“

时间不够论。这是最常见的原因,在交付速度飞快的今天,“代码写不完”是一个再常见不过的情况了,但写注释真的会导致需求延迟吗?绝不!相对于写一个接口的实现,写接口注释的时间可能只需要花费前者的5%。但不写注释,后面使用接口的人必须要多花费**50%**的时间去读懂代码!而且对于大部分程序员而言,“以后再补“大概要到2910年才能落实。

“好的代码就是最好的注释,我的代码可读性很好,没必要写注释”

好代码胜过注释论。不少程序员认为,好的代码就是最好的注释,只要代码可读性好,注释就可以省去。然而一个软件系统很多信息是无法通过代码呈现出来的,比如系统的设计思路、函数执行的预置条件等等。此外,代码可读性也不是绝对的,对于一个没有使用过Java 8 的 Lambda表达式的开发者而言,通篇的箭头“->“简直就是一场噩梦。

”过期的注释容易误导人“

过期注释论。不可否认,过期的注释很容易误导读者,但是这并不能成为否认注释的借口。除非是重大的重构或重写,对注释进行大改动的情况很少出现。通常,在更改代码之后,只需花费极少的时间去更新注释,就可以避免过期注释这种情况了。

注释的分类

注释大致可以分成四类:接口注释、数据成员注释、实现注释和模块依赖注释。

接口注释

平时我们所说的接口,通常指的是一个类(包括interface、class、enum)和方法。对类而言,接口注释主要描述该类提供的功能;对方法而言,除了描述方法功能之外,方法的入参和返回值都要进行说明。当然,使用类/方法的一些预置条件和副作用等信息都需要在接口注释中提到。

    /**
     * Returns the length of this string.
     * The length is equal to the number of <a href="Character.html#unicode">Unicode
     * code units</a> in the string.
     *
     * @return  the length of the sequence of characters represented by this
     *          object.
     */
    public int length() {
        return value.length;
    }

典型的接口注释(选自JDK 1.8中的String类)

数据成员注释

数据成员注释和接口注释在大多数情况下都是必须的,这对于让读者快速读懂代码有很大的帮助。数据成员包括类的普通成员变量和静态成员变量,数据成员注释除了描述数据成员的本身用途之外,成员的默认值、副作用等信息都需要提及。

public final class String
    implements java.io.Serializable, Comparable<String>, CharSequence {
    /** The value is used for character storage. */
    private final char value[];

    /** Cache the hash code for the string */
    private int hash; // Default to 0

    /** use serialVersionUID from JDK 1.0.2 for interoperability */
    private static final long serialVersionUID = -6849794470754667710L;

    /**
     * Class String is special cased within the Serialization Stream Protocol.
     *
     * A String instance is written into an ObjectOutputStream according to
     * <a href="{@docRoot}/../platform/serialization/spec/output.html">
     * Object Serialization Specification, Section 6.2, "Stream Elements"</a>
     */
    private static final ObjectStreamField[] serialPersistentFields =
        new ObjectStreamField[0];
    ....
}

典型的数据成员注释(选自JDK 1.8中的String类)

实现注释

对于一段代码,如果无法一眼就看出其含义而需要深入分析其实现才能读懂,就需要添加实现注释对这段代码的含义进行说明。代码的实现注释通常不是必须的,如果一段代码每一行都需要注释,那就要重新审视一下这段代码的设计是否有合理了。此外,实现注释描述还需要描述一些代码无法体现,但是对于读者了解这段代码很有帮助的信息,比如代码的设计思路等。

public final class String {
    ...
    public char[] toCharArray() {
        // Cannot use Arrays.copyOf because of class initialization order issues
        char result[] = new char[value.length];
        System.arraycopy(value, 0, result, 0, value.length);
        return result;
    }
    ...
}

典型的实现注释(选自JDK 1.8中的String类)

模块依赖注释

模块依赖注释相对少见一些,主要描述两个相互依赖的模块的一些依赖信息,因为它并不属于单独某个模块,所以在哪里写这个注释需要认真衡量一下。实在找不到好的位置时,可以写一个类似README的文件,专门用于描述模块之间的依赖关系。

typedef enum Status {
    STATUS_OK = 0,
    STATUS_UNKNOWN_TABLET                = 1,
    STATUS_WRONG_VERSION                 = 2,
    ...
    STATUS_INDEX_DOESNT_EXIST            = 29,
    STATUS_INVALID_PARAMETER             = 30,
    STATUS_MAX_VALUE                     = 30,
    // Note: if you add a new status value you must make the following
    // additional updates:
    // (1)  Modify STATUS_MAX_VALUE to have a value equal to the
    //      largest defined status value, and make sure its definition
    //      is the last one in the list. STATUS_MAX_VALUE is used
    //      primarily for testing.
    // (2)  Add new entries in the tables "messages" and "symbols" in
    //      Status.cc.
    // (3)  Add a new exception class to ClientException.h
    // (4)  Add a new "case" to ClientException::throwException to map
    //      from the status value to a status-specific ClientException
    //      subclass.
    // (5)  In the Java bindings, add a static class for the exception
    //      to ClientException.java
    // (6)  Add a case for the status of the exception to throw the
    //      exception in ClientException.java
    // (7)  Add the exception to the Status enum in Status.java, making
    //      sure the status is in the correct position corresponding to
    //      its status code.
}

典型的模块依赖注释(选自《A Philosophy of Software Design》中的例子)

如何写好代码注释

利用好注释模板

注释模板为注释写作提供了极大的便利,我们常用的开发工具如IDEA、VS Code都对注释模板有很好的支持。在配置好注释模板之后(一般情况下默认模板即可),只需简单的操作,就能生成模板,我们只需往模板里填上内容即可。对于方法的接口注释,注释模板尤为方便,方法的入参和返回值标识注释模板都已经提供好。

/**
* 
* @param user
* @param password
* @return
*/
private boolean login(String user, String password) {
    ...
}

IntelliJ IDEA默认的注释模板

不要重复代码

注释与代码重复是开发者最容易犯的一个错误,这也是很多开发者认为注释是冗余的原因。确实,对于那些通过读代码可以很容易就推断出来的信息,注释就是多余的,更甚者,出现过期注释还容易误导读者。

// Add a horizontal scroll bar
hScrollBar = new JScrollBar(JScrollBar.HORIZONTAL);
add(hScrollBar, BorderLayout.SOUTH);
// Add a vertical scroll bar
vScrollBar = new JScrollBar(JScrollBar.VERTICAL);
add(vScrollBar, BorderLayout.EAST);
// Initialize the caret-position related values
caretX     = 0;
caretY     = 0;
caretMemX  = null;

典型的重复代码的注释(选自《A Philosophy of Software Design》中的例子)

某些程序员喜欢在注释中使用代码中的变量名或其中的单词,这种情况也是注释重复代码的一种体现。像下面的这个例子,注释完全就是冗余的,可以猜测开发者纯粹是为了注释而注释。

/**
 * 用户登录
 * @param user User对象
 * @param password Password对象
 * @return
 */
private boolean login(User user, Password password) {
    ...
}

使用代码变量名的注释

注释也要分层

在进行系统设计时,我们常常会采用分层架构,每一层负责不同功能。系统的顶层往往会更抽象一点,为功能调用者隐藏了很多细节(high-level);底层往往会更细节一点,实现系统的具体功能(low-level)。在进行注释写作时,我们也要学会对注释进行分层。high-level的注释要提供比代码更抽象的信息,比如代码的设计思路;low-level的注释要提供比代码更细节的信息,比如表示一个范围的两个参数是左闭右开还是左闭右闭;我们需要避免写出与代码同一level的注释,因为这往往就是上一节所提到的注释重复代码

low-level注释写作指南

对于需要通过阅读这一段代码才能获取的信息,就适合以low-level注释写在这段代码前面,一些常见的例子有:

  • 变量的单位是什么?
  • 表示范围的一组参数是左闭右开还是左闭右闭?
  • 某个变量为null时代表什么含义?
  • 某个资源应该由谁来负责释放,接口调用者还是接口提供者?

low-level注释不能太过抽象,否则就失去了其应有的作用:

// Current offset in resp Buffer
uint32_t offset;
// Contains all line-widths inside the document and
// number of appearances.
private TreeMap<Integer, Integer> lineWidths;

太过抽象的low-level注释(选自《A Philosophy of Software Design》中的例子)

好的low-level注释应当详细介绍代码的意图及其需要注意的点:

//  Position in this buffer of the first object that hasn't
//  been returned to the client.
uint32_t offset;
//  Holds statistics about line lengths of the form <length, count>
//  where length is the number of characters in a line (including
//  the newline), and count is the number of lines with
//  exactly that many characters. If there are no lines with
//  a particular length, then there is no entry for that length.
private TreeMap<Integer, Integer> numLinesWithLength;

典型的low-level注释(选自《A Philosophy of Software Design》中的例子)

high-level注释写作指南

high-level注释的作用是隐藏具体实现细节,快速让读者对整一段代码有一个全局的了解。high-level注释除了描述对代码进行抽象的概括(What)之外,还经常描述代码的设计思路(Why),这有助于帮助读者更容易、深入地了解整个系统。high-level注释不应该描述具体代码实现的信息,更不能犯注释重复代码的错误。在写high-level注释之前,先问一下自己:

  • 这段代码要做什么事情?
  • 这段代码最核心的功能是什么?
  • 怎样才能以最简单的描述表达这段代码的核心功能?

太过细节的high-level注释其实就是在重复代码,另一方面该注释既不能解释此代码的总体目的,也不能解释其如何适合包含此代码的方法,导致其失去了其应有作用:

// If there is a LOADING readRpc using the same session
// as PKHash pointed to by assignPos, and the last PKHash
// in that readRPC is smaller than current assigning
// PKHash, then we put assigning PKHash into that readRPC.
int readActiveRpcId = RPC_ID_NOT_ASSIGNED;
for (int i = 0; i < NUM_READ_RPC; i++) {
    if (session == readRpc[i].session
            && readRpc[i].status == LOADING
            && readRpc[i].maxPos < assignPos
            && readRpc[i].numHashes < MAX_PKHASHES_PERRPC) {
        readActiveRpcId = i;
        break;
    }
}

太过细节的high-level注释(选自《A Philosophy of Software Design》中的例子)

更好的high-level注释应该在更高级别上描述代码的整体功能:

// Try to append the current key hash onto an existing
// RPC to the desired server that hasn't been sent yet.
int readActiveRpcId = RPC_ID_NOT_ASSIGNED;
for (int i = 0; i < NUM_READ_RPC; i++) {
    if (session == readRpc[i].session
            && readRpc[i].status == LOADING
            && readRpc[i].maxPos < assignPos
            && readRpc[i].numHashes < MAX_PKHASHES_PERRPC) {
        readActiveRpcId = i;
        break;
    }
}

典型的high-level注释(选自《A Philosophy of Software Design》中的例子)

规范接口注释

接口注释是用的最多的一种注释,它可以让读者快速了解整个模块的功能并知道如何使用该接口,而不必花费大量时间去阅读源码。

对于一个的接口注释,其通常是high-level的注释,主要描述这个类提供的一些功能;

对于一个方法的接口注释,则同时需要包括high-level和low-level的注释,常见的有以下几点:

  • 整个方法提供的主要功能
  • 方法的所有参数和返回值含义
  • 调用该方法的副作用(比如改变了系统的某个状态)
  • 该方法可能抛出的所有异常
  • 调用该方法的预置条件(比如调用该方法前,系统必须处于某个状态)

实现注释描述what、why,而不是how

写实现注释时需要记住的最重要的一点就是,描述代码是做什么的(what)和为什么这么做(why),而不是描述怎么做(how)。因为实现代码本身就是how,如果还添加描述how的注释,那就犯了“注释重复代码”的错误了。

前文中的这个注释就是典型的描述how的注释,该注释除了用文字重复一遍代码的逻辑,并没有其他更好的用处:

// If there is a LOADING readRpc using the same session
// as PKHash pointed to by assignPos, and the last PKHash
// in that readRPC is smaller than current assigning
// PKHash, then we put assigning PKHash into that readRPC.

典型的描写how的实现注释(选自《A Philosophy of Software Design》中的例子)

更好的方法是描述what的注释,通过文字表达该段代码的用途,置于该用途是如何实现的,那就交给代码去解释吧

// Try to append the current key hash onto an existing
// RPC to the desired server that hasn't been sent yet.

典型的描写what的实现注释(选自《A Philosophy of Software Design》中的例子)

需要注意的是,实现注释通常不是必须的,对于一些简短的、功能单一的函数,一个好的函数命名即可替代实现注释。

总结

注释是软件开发过程中的性价比极高的一个工具,它只需要花费20%的时间,即可获取80%的价值。代码注释最重要的作用就是让读者可以在不读源码的情况下,快速了解一段代码的主要功能。注释的作用还远远不止这些,John Ousterhout在《A Philosophy of Software Design》一书中提到,写注释最好的时机是在写代码之前。这样,注释就相当于一种设计工具:在编码前通过注释描述这段代码的功能,然后在通过编码实现这个功能,如果这个过程有偏差,则再调整注释。写注释时要切记两点,不要重复代码更新代码后也要更新注释!前者可以减少冗余的注释,后者则可以避免因为过期的注释而误导读者。

当然,注释也不是越多越好,如果一个系统写的注释太多,很有可能就是该系统设计得过于复杂,只能通过注释来帮助读者理解整个系统。

总而言之,学会怎么写好代码注释,是一个程序员的必备技能,这即是为了开发团队的其他成员,也是为了未来的自己。

【版权声明】本文为华为云社区用户原创内容,转载时必须标注文章的来源(华为云社区),文章链接,文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件至:cloudbbs@huaweicloud.com进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容。
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。