第 18 章 代码应该是易理解的
模糊性是第 2.3 节所述的复杂性的两个主要成因之一。当新开发人员无法轻易看出系统的重要信息时,就会产生模糊性。解决办法是让代码易于理解。本章讨论哪些因素会让代码更易理解,哪些会让代码更难理解。
本章概览什么让代码难理解,什么让它易理解?
如果代码易于理解,人们就能快速读懂,无需多想,对代码行为或含义的最初判断就是正确的。读者不需要花费太多时间或精力,就能收集到使用代码所需的全部信息。如果代码难以理解,读者就必须花费大量时间和精力来弄懂它。这不仅会降低效率,还会增加误解和引入缺陷的可能性。易理解的代码比难理解的代码需要的注释更少。
这里说的“易理解”取决于读者的感受:发现别人代码里难以理解的地方,比发现自己代码中的问题容易得多。因此,确定代码是否易理解的最佳方法是代码审查。如果阅读你代码的人说它难以理解,那么它就是难以理解的,无论你自己觉得它多么清晰。试着弄清楚代码为什么难以理解,你就能学会如何在今后写出更好的代码。
18.1 使代码更易理解的事情
前面的章节已经讨论了使代码易理解的两种最重要的技术。首先是选取好名称(第 14 章)。精确而有意义的名称可以阐明代码的行为,并减少对文档的需求。如果名称含糊不清,读者就不得不通读代码,以推断被命名实体的含义,这既费时又容易出错。第二种技术是一致性(第 17 章)。如果总是以相似的方式完成相似的事情,读者就可以识别出以前见过的模式,无需详细分析代码就能立即得出可靠的结论。
以下是其它一些使代码更易理解的通用技术:
明智地使用空格。代码的排版会影响它是否容易理解。看看以下参数文档,其中的空格已被压缩:
/**
* ...
* @param numThreads The number of threads that this manager should
* spin up in order to manage ongoing connections. The MessageManager
* spins up at least one thread for every open connection, so this
* should be at least equal to the number of connections you expect
* to be open at once. This should be a multiple of that number if
* you expect to send a lot of messages in a short amount of time.
* @param handler Used as a callback in order to handle incoming
* messages on this MessageManager's open connections. See
* {@code MessageHandler} and {@code handleMessage} for details.
*/
很难看出一个参数的文档在哪里结束,下一个参数的文档又在哪里开始,甚至看不出有多少个参数、它们的名称是什么。添加一些空格后,结构就会变得清晰,文档也更容易阅读:
/**
* @param numThreads
* The number of threads that this manager should spin up in
* order to manage ongoing connections. The MessageManager spins
* up at least one thread for every open connection, so this
* should be at least equal to the number of connections you
* expect to be open at once. This should be a multiple of that
* number if you expect to send a lot of messages in a short
* amount of time.
* @param handler
* Used as a callback in order to handle incoming messages on
* this MessageManager's open connections. See
* {@code MessageHandler} and {@code handleMessage} for details.
*/
空白行也可用于分隔方法中的主要代码块,例如以下示例:
void* Buffer::allocAux(size_t numBytes) {
// Round up the length to a multiple of 8 bytes, to ensure alignment.
uint32_t numBytes32 = (downCast<uint32_t>(numBytes) + 7) & ~0x7;
assert(numBytes32 != 0);
// If there is enough memory at firstAvailable, use that. Work down
// from the top, because this memory is guaranteed to be aligned
// (memory at the bottom may have been used for variable-size chunks).
if (availableLength >= numBytes32) {
availableLength -= numBytes32;
return firstAvailable + availableLength;
}
// Next, see if there is extra space at the end of the last chunk.
if (extraAppendBytes >= numBytes32) {
extraAppendBytes -= numBytes32;
return lastChunk->data + lastChunk->length + extraAppendBytes;
}
// Must create a new space allocation; allocate space within it.
uint32_t allocatedLength;
firstAvailable = getNewAllocation(numBytes32, &allocatedLength);
availableLength = allocatedLength - numBytes32;
return firstAvailable + availableLength;
}
如果每个空白行之后的第一行是描述下一个代码块的注释,这种方法就特别有效:空白行使注释更加醒目。
语句中的空格有助于呈现语句的结构。比较以下两个语句,一个加了空格,另一个没有:
for(int pass=1;pass>=0&&!empty;pass--) {
for (int pass = 1; pass >= 0 && !empty; pass--) {
注释。有时无法避免难以理解的代码。这时,重要的是用注释补充读者缺少的信息。要做好这一点,你必须站在读者的角度,弄清楚什么可能使他们感到困惑,以及哪些信息可以消除这种困惑。下一节将介绍几个示例。
18.2 使代码难理解的事情
有很多因素会使代码变得难以理解,本节提供了一些示例。其中一些做法,例如事件驱动编程,在某些情况下很有用,所以你可能仍会使用它们。这时,补充文档有助于尽量减少读者的困惑。
事件驱动编程。在事件驱动编程中,应用程序响应外部事件,例如收到网络数据包或鼠标按钮被按下。一个模块负责报告传入的事件;应用程序的其他部分向事件模块注册自己对某些事件的关注,要求它在这些事件发生时调用指定的函数或方法。
事件驱动编程使控制流程难以跟踪。事件处理函数不会被直接调用,而是由事件模块间接调用,通常通过函数指针或接口来实现。即使你在事件模块中找到了调用点,也仍然无法确定具体会调用哪个函数:这取决于运行时注册了哪些处理程序。正因为如此,很难推断事件驱动代码的行为,也很难确信它能正确工作。
为了弥补这种模糊性,可以在每个处理函数的接口注释中说明它何时会被调用,如以下示例所示:
/**
* This method is invoked in the dispatch thread by a transport if a
* transport-level error prevents an RPC from completing.
*/
void Transport::RpcNotifier::failed() {
...
}
译者图解调用点看不到的运行时连线
危险信号:难理解的代码
如果无法通过快速阅读来理解代码的含义和行为,这就是一个危险信号。通常,这意味着阅读代码的人无法立即了解某些重要信息。
通用容器。许多语言提供了将两个或多个元素组合到一个对象中的通用类,例如 Java 中的 Pair 或 C++ 中的 std::pair。这些类很有吸引力,因为使用它们可以方便地通过单个变量传递多个对象。最常见的用途之一是从一个方法返回多个值,如以下 Java 示例所示:
return new Pair<Integer, Boolean>(currentTerm, false);
不幸的是,通用容器会导致代码不清晰,因为组合在一起的元素使用的是含义模糊的通用名称。在上面的示例中,调用者必须使用 result.getKey() 和 result.getValue() 引用返回的两个值,而这些名称无法说明值的实际含义。
因此,最好不要使用通用容器。如果需要容器,请为特定用途定义新的类或结构。这样,你就可以为元素使用有意义的名称,并在声明中提供额外的文档,而使用通用容器无法做到这些。
此示例说明了一条通用规则:软件应设计得易于阅读,而不是易于编写。通用容器对编写代码的人很方便,却会给所有后续读者带来困惑。编写代码时,多花几分钟定义专用的容器结构是更好的选择,这样写出来的代码更容易理解。
声明类型与分配对象时使用的类型不同。考虑以下 Java 示例:
private List<Message> incomingMessageList;
...
incomingMessageList = new ArrayList<Message>();
该变量被声明为 List,但实际分配的对象是 ArrayList。这段代码是合法的,因为 List 是 ArrayList 的超类,但是它会误导只看到声明、没看到实际对象分配的读者。实际类型可能会影响变量的使用方式(ArrayList 与 List 的其他子类相比,具有不同的性能和线程安全属性),因此最好让声明类型与分配对象时使用的类型一致。
译者注(勘误):在 Java 中,
List是接口,ArrayList实现该接口,其直接父类是AbstractList。原文这里的“超类/子类”称谓不准确,正文保留原文表述。参见 Java API 文档。
不符合读者预期的代码。考虑以下代码,这是 Java 应用程序的主程序:
public static void main(String[] args) {
...
new RaftClient(myAddress, serverAddresses);
}
大多数应用程序会在主程序返回时退出,因此读者可能会认为这里也是如此。然而,事实并非如此。RaftClient 的构造函数创建了额外的线程,即使应用程序的主线程结束,这些线程仍会继续运行。应该在 RaftClient 构造函数的接口注释中记录这一行为。但由于这一行为不够明显,也值得在 main 方法的末尾添加简短注释,说明应用程序将继续在其他线程中执行。如果代码遵循的约定符合读者的预期,就容易理解。否则,就有必要记录其行为,以免读者感到困惑。
译者图解main 返回,不等于这个程序结束
18.3 结论
也可以从信息的角度来思考代码是否易理解。如果代码难以理解,通常意味着读者不了解与代码有关的某些重要信息:在 RaftClient 示例中,读者可能不知道 RaftClient 构造函数创建了新线程;在 Pair 示例中,读者可能不知道 result.getKey() 返回当前任期的编号。
为了使代码容易理解,你必须确保读者始终掌握理解代码所需的信息。可以通过三种方式做到这一点。最好的方法是使用抽象、消除特殊情况等设计技术,减少读者需要了解的信息量。其次,可以利用读者在其他场景中已经掌握的信息,例如遵循约定、符合预期,这样读者就不必为理解你的代码而学习新信息。第三,可以通过好名称和有针对性的注释,在代码中向读者提供重要信息。