晦涩性(Obscurity) 是第 2.3 节中提到的复杂性的两大根源之一。当一个系统的重要信息对新加入的开发者来说并不明显时,就会产生晦涩性。解决晦涩性的办法,就是以一种能够让代码本身更容易理解的方式编写代码。本章将讨论哪些因素会让代码变得更容易理解,或更难理解。
如果代码是 显而易见(obvious) 的,那么意味着别人能够快速读懂代码,不需要花太多思考,他们对代码行为和含义的第一印象通常就是正确的。如果代码足够直观,读者无需耗费大量时间和精力去收集所有必要的信息,就能够开始修改或使用它。反之,如果代码不够直观,读者就必须投入大量时间和精力去理解它。这不仅降低了开发效率,也增加了误解代码和引入 Bug 的可能性。显而易见的代码通常比晦涩的代码需要更少的注释。
“ 代码是否显而易见 ”存在于读者的认知之中:发现别人代码中的晦涩之处,总是比发现自己代码中的问题更容易。因此,判断代码是否真正直观的最佳方式,就是通过 Code Review 。如果阅读你代码的人认为它不够直观,那么无论你自己觉得它多么清晰,它实际上都不够直观。努力理解别人为什么会觉得你的代码难懂,你就能逐渐学会如何写出更好的代码。
前面的章节已经介绍过两种最重要的方法。第一种是选择好的名字(第 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)
{
// 将长度向上取整到 8 字节的倍数,保证内存对齐。
...
// 如果 firstAvailable 处有足够空间,则直接使用。
// 从顶部向下分配,因为这部分内存保证已经对齐。
...
if (...) {
...
return ...;
}
// 接着检查最后一个 chunk 的尾部是否还有剩余空间。
if (...) {
...
return ...;
}
// 必须创建新的空间,并在其中完成分配。
...
return ...;
}
这种方式尤其有效,因为每个空行后的第一行都是描述接下来代码块用途的注释。空行能够让这些注释更加醒目,从而帮助读者快速理解整个方法的结构。
语句内部的空白同样能够帮助表达代码结构。比较下面两个 for 循环:
for(int pass=1;pass>=0&&!empty;pass--) {
与
for (int pass = 1; pass >= 0 && !empty; pass--) {
第二种写法明显更容易阅读,因为运算符、变量和表达式之间的逻辑关系更加清晰。
有时候,代码不可避免地会变得不够直观。这种情况下,应该利用注释来补充那些代码本身没有表达出来的重要信息。
为了写出真正有价值的注释,你必须站在读者的角度思考:哪些地方最容易让人产生疑惑?哪些额外的信息能够消除这些疑惑?
下一节将给出一些具体示例。
很多因素都会让代码变得不够直观,本节将介绍其中一些典型例子。其中有些机制,例如事件驱动编程(Event-driven programming),在某些场景下本身就是有价值的,因此你可能仍然会使用它们。当不得不采用这些技术时,额外的文档能够有效减少读者的困惑。
在事件驱动程序中,应用程序会响应各种外部事件,例如网络数据包到达、鼠标按钮被点击等。
其中一个模块负责检测和上报这些事件,而程序的其他模块则通过向事件模块注册回调函数(或方法)的方式表达自己对某些事件的兴趣。当相应事件发生时,事件模块便会调用这些已注册的函数。
事件驱动编程使得 控制流(control flow) 变得难以追踪。事件处理函数从来不会被直接调用,而是由事件模块间接调用,通常依赖函数指针或接口来完成。
即使你找到了事件模块中真正执行调用的位置,也仍然无法确定最终究竟会调用哪个具体函数,因为这取决于程序运行时到底注册了哪些事件处理器。
正因如此,事件驱动代码往往很难推理,也很难让人确信它一定能够正确工作。
为了弥补这种晦涩性,应该为每个事件处理函数编写接口注释(interface comment),说明它会在什么情况下被调用。例如:
/**
* 当底层 Transport 因传输层错误
* 导致某个 RPC 无法完成时,
* 本方法将在 dispatch 线程中被调用。
*/
void
Transport::RpcNotifier::failed() {
...
}
如果代码的含义和行为无法通过快速阅读理解,那么这就是一个危险信号(Red Flag)。
这通常意味着:代码中存在某些重要信息,并没有立即呈现给阅读者。
许多编程语言都提供了用于将两个或多个对象组合到一起的泛型容器,例如 Java 中的 Pair,以及 C++ 中的 std::pair。这些类很有吸引力,因为它们能够方便地用一个变量传递多个对象。
它们最常见的用途之一,是作为方法返回多个值。例如下面这个 Java 示例:
return new Pair<Integer, Boolean>(currentTerm, false);
遗憾的是,泛型容器往往会导致代码变得不够直观,因为被组合在一起的元素只有非常泛化的名称,掩盖了它们真正的含义。在上面的例子中,调用方必须通过 result.getKey() 和 result.getValue() 来访问两个返回值,而这些名称完全无法体现它们实际表示的意义。
因此,**最好不要使用泛型容器。**如果确实需要一个容器,应当针对具体用途定义一个专门的类或结构体。这样,你就可以为每个成员赋予具有实际意义的名字,并且还能在类的声明中补充额外的文档说明,而这些都是泛型容器无法做到的。
这个例子说明了一条普遍原则:**软件应该为了便于阅读而设计,而不是为了便于编写。**对于写代码的人来说,泛型容器确实更加省事;但它会给之后所有阅读代码的人带来困惑。与其为了省几分钟时间而直接使用 Pair,不如多花一点时间设计一个专门的数据结构,让最终的代码更加直观易懂。
考虑下面这段 Java 代码:
private List<Message> incomingMessageList;
...
incomingMessageList = new ArrayList<Message>();
变量被声明为 List 类型,但实际创建出来的对象却是 ArrayList。从语法上来说这是完全合法的,因为 List 是 ArrayList 的父接口。
然而,这种写法可能会误导那些只能看到变量声明,却没有看到对象创建位置的读者。实际类型可能会影响变量应该如何使用(例如,ArrayList 与 List 的其他实现类在性能特性和线程安全方面可能存在差异),因此,最好让变量声明与实际分配的类型保持一致。
请看下面这段 Java 应用程序的主函数:
public static void main(String[] args) {
...
new RaftClient(myAddress, serverAddresses);
}
大多数应用程序都会在 main 函数返回之后退出,因此读者很自然地会认为这里也会发生同样的事情。
然而,事实并非如此。RaftClient 的构造函数创建了额外的线程,因此即使应用程序的主线程结束,这些线程仍会继续运行。
这种行为应该在 RaftClient 构造函数的接口注释中进行说明。不过,由于这种行为非常违反一般读者的预期,因此即使如此,也值得在 main 函数的最后再添加一句简短的注释。
这条注释应该明确指出:程序将在其他线程中继续执行。
当代码符合读者习惯性的预期时,它最容易理解;而一旦违背这些约定,就必须将这种特殊行为明确记录下来,否则读者很容易产生困惑。
理解“代码是否直观”的另一种方式,是从**信息(information)**的角度来看待它。
如果代码不够直观,通常意味着:代码中存在一些读者所不知道、却又十分重要的信息。
例如,在 RaftClient 的例子中,读者可能不知道 RaftClient 的构造函数会创建新的线程;而在 Pair 的例子中,读者可能不知道 result.getKey() 返回的是当前任期(current term)的编号。
为了让代码变得直观,你必须确保读者始终拥有理解代码所需的全部信息。实现这一目标有三种方式。
第一,也是最好的方法,是减少读者所需要掌握的信息量。可以通过抽象(abstraction)、消除特殊情况(special cases)等设计技巧来做到这一点。
第二,可以利用读者已经在其他场景中掌握的知识(例如遵循通用约定、符合大家的预期),这样读者就无需为了你的代码再学习一套新的规则。
第三,可以把那些重要的信息直接呈现在代码中,例如使用清晰准确的命名,以及有针对性的注释等技术。