有很多事情可以使代码变得不明显。本节提供了一些示例。其中某些功能(例如事件驱动的编程)在某些情况下很有用,因此您可能最终还是要使用它们。发生这种情况时,额外的文档可以帮助最大程度地减少读者的困惑。
事件驱动的编程。在事件驱动的编程中,应用程序对外部事件做出响应,例如网络数据包的到来或按下鼠标按钮。一个模块负责报告传入事件。应用程序的其他部分通过在事件发生时要求事件模块调用给定的函数或方法来注册对某些事件的兴趣。
事件驱动的编程使其很难遵循控制流程。永远不要直接调用事件处理函数。它们是由事件模块间接调用的,通常使用函数指针或接口。即使您在事件模块中找到了调用点,也仍然无法确定将调用哪个特定功能:这将取决于在运行时注册了哪些处理程序。因此,很难推理事件驱动的代码或说服自己相信它是可行的。
为了弥补这种模糊性,请为每个处理程序函数使用接口注释,以指示何时调用该函数,如以下示例所示:
/**
* 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 应用程序的主程序:
public static void main(String[] args) {
...
new RaftClient(myAddress, serverAddresses);
}
大多数应用程序在其主程序返回时退出,因此读者可能会认为这将在此处发生。但是,事实并非如此。RaftClient 的构造函数创建其他线程,即使应用程序的主线程完成,该线程仍可继续运行。应该在 RaftClient 构造函数的接口注释中记录此行为,但是该行为不够明显,因此值得在 main 末尾添加简短注释。该注释应指示该应用程序将继续在其他线程中执行。如果代码符合读者期望的惯例,那么它是最明显的。如果没有,那么记录该行为很重要,以免使读者感到困惑。
There are many things that can make code nonobvious; this section provides a few examples. Some of these, such as event-driven programming, are useful in some situations, so you may end up using them anyway. When this happens, extra documentation can help to minimize reader confusion.
Event-driven programming. In event-driven programming, an application responds to external occurrences, such as the arrival of a network packet or the press of a mouse button. One module is responsible for reporting incoming events. Other parts of the application register interest in certain events by asking the event module to invoke a given function or method when those events occur.
Event-driven programming makes it hard to follow the flow of control. The event handler functions are never invoked directly; they are invoked indirectly by the event module, typically using a function pointer or interface. Even if you find the point of invocation in the event module, it still isn’t possible to tell which specific function will be invoked: this will depend on which handlers were registered at runtime. Because of this, it’s hard to reason about event-driven code or convince yourself that it works.
To compensate for this obscurity, use the interface comment for each handler function to indicate when it is invoked, as in this example:
/**
* 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() {
...
}
If the meaning and behavior of code cannot be understood with a quick reading, it is a red flag. Often this means that there is important information that is not immediately clear to someone reading the code.
Generic containers. Many languages provide generic classes for grouping two or more items into a single object, such as Pair in Java or std::pair in C++. These classes are tempting because they make it easy to pass around several objects with a single variable. One of the most common uses is to return multiple values from a method, as in this Java example:
return new Pair<Integer, Boolean>(currentTerm, false);
Unfortunately, generic containers result in nonobvious code because the grouped elements have generic names that obscure their meaning. In the example above, the caller must reference the two returned values with result.getKey() and result.getValue(), which give no clue about the actual meaning of the values.
Thus, it’s better not to use generic containers. If you need a container, define a new class or structure that is specialized for the particular use. You can then use meaningful names for the elements, and you can provide additional documentation in the declaration, which is not possible with the generic container.
This example illustrates a general rule: software should be designed for ease of reading, not ease of writing. Generic containers are expedient for the person writing the code, but they create confusion for all the readers that follow. It’s better for the person writing the code to spend a few extra minutes to define a specific container structure, so that the resulting code is more obvious.
Different types for declaration and allocation. Consider the following Java example:
private List<Message> incomingMessageList;
...
incomingMessageList = new ArrayList<Message>();
The variable is declared as a List, but the actual value is an ArrayList. This code is legal, since List is a superclass of ArrayList, but it can mislead a reader who sees the declaration but not the actual allocation. The actual type may impact how the variable is used (ArrayLists have different performance and thread-safety properties than other subclasses of List), so it is better to match the declaration with the allocation.
Code that violates reader expectations. Consider the following code, which is the main program for a Java application
public static void main(String[] args) {
...
new RaftClient(myAddress, serverAddresses);
}
Most applications exit when their main programs return, so readers are likely to assume that will happen here. However, that is not the case. The constructor for RaftClient creates additional threads, which continue to operate even though the application’s main thread finishes. This behavior should be documented in the interface comment for the RaftClient constructor, but the behavior is nonobvious enough that it’s worth putting a short comment at the end of main as well. The comment should indicate that the application will continue executing in other threads. Code is most obvious if it conforms to the conventions that readers will be expecting; if it doesn’t, then it’s important to document the behavior so readers aren’t confused.