软件设计的哲学:第十八章 代码的可见性
晦涩是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>();
变量被声明为一个列表,但是实际的值是一个ArrayList。这段代码是合法的,因为List是ArrayList的一个超类,但是它会误导那些只看到声明而没有看到实际分配的读者。实际类型可能会影响变量的使用方式(与List的其他子类相比,arraylist具有不同的性能和线程安全属性),因此最好将声明与分配匹配起来。
违反读者期望的代码。考虑以下代码,它是Java应用程序的主程序:
public static void main(String[] args) {
...
new RaftClient(myAddress, serverAddresses);
}
大多数应用程序在它们的主程序返回时退出,所以读者可能会认为这将在这里发生。然而,事实并非如此。RaftClient的构造函数创建了额外的线程,即使应用程序的主线程已经结束,这些线程仍然继续运行。这种行为应该被记录在RaftClient构造函数的接口注释中,但是这种行为还不够明显,值得在main的末尾加上简短的注释。注释应该表明应用程序将继续在其他线程中执行。如果代码符合读者期望的约定,那么它就是最明显的;如果没有,那么记录这种行为很重要,这样读者就不会感到困惑。
18.3 结论
另一种思考简单易解性的方式是信息。如果代码不明显,这通常意味着有关于代码的重要信息读者没有得到:在RaftClient示例中,读者可能不知道RaftClient构造函数创建了新线程;在结对示例中,读者可能不知道result.getKey()返回当前项的编号。
为了使代码更容易理解,您必须确保读者始终拥有他们需要的信息。有三种方法可以做到这一点:
- 最好的方法是使用抽象和消除特殊情况等设计技术来减少所需的信息量。
- 其次,您可以利用读者在其他上下文中已经获得的信息(例如,通过遵循约定和遵从期望),这样读者就不必为您的代码学习新的信息。
- 第三,您可以使用良好的名称和策略注释等技术,在代码中向他们显示重要的信息。