🗺️ 开篇故事:一张好的地图
想象一下,你正在一个陌生的城市里寻找一个著名的景点。现在你有两种地图可以选择:
地图A:
- 路线清晰,地标明确
- 街道名称一目了然
- 你一眼就能看懂,毫不费力地找到目的地
地图B:
- 路线杂乱无章,充满了奇怪的符号
- 街道名称用的是晦涩的缩写
- 你需要花费大量时间研究,甚至可能走错路
哪张地图更好? 答案显而易见。
软件代码就是一张指引我们理解系统逻辑的地图。正如第2.3节所说,晦涩难懂是复杂性的两大元凶之一。当系统的重要信息对新开发者不明显时,代码就变得晦涩了。
核心原则:解决晦涩问题的唯一方法,就是以显而易见的方式编写代码。
🤔 什么是"显而易见"的代码?
如果一段代码是显而易见的,那么:
- 快速理解:你不需要反复琢磨,就能快速读懂
- 第一印象正确:你对代码行为和含义的初步猜测就是正确的
- 信息获取容易:你不需要花费大量时间和精力就能收集到所有需要的信息
相反,如果代码不明显,你会:
- 浪费大量时间去理解它
- 效率低下
- 增加误解和引入bug的可能性
有趣的事实:显而易见的代码比不明显的代码需要更少的注释。因为代码本身就在说话!
🧐 "显而易见"是谁的感受?
"显而易见"是读者的感受,而不是作者的。
- 你很容易发现别人的代码晦涩难懂
- 却很难看到自己代码里的问题
最佳验证方法:代码审查(Code Review)
如果有人在读你的代码时说:"这里我看不懂",那么无论你觉得它多清晰,它就是不明显的。你应该感谢这位读者,并尝试理解是什么让他感到困惑,这样你未来才能写出更好的代码。
18.1 让代码更明显的方法 ✨
Things that make code more obvious
要让你的代码像一张清晰的地图,你可以使用以下几种强大的技术:
🏷️ 1. 起个好名字(第14章精华回顾)
精确且有意义的名字是代码清晰度的基石。
// ❌ 不明显的名字
public class Manager {
public void processData(List<Item> items) {
for (Item i : items) {
if (i.status == 1) {
// ...
}
}
}
}
// ✅ 明显的名字
public class ExpiredLicenseManager {
public void deactivateExpiredLicenses(List<License> licenses) {
for (License license : licenses) {
if (license.isExpired()) {
deactivate(license);
}
}
}
}
- 不好的名字:迫使读者深入代码去推断其真实含义
- 好的名字:直接阐明代码行为,减少对文档的依赖
🏛️ 2. 保持一致性(第17章精华回顾)
相似的事情用相似的方式处理,这能让读者利用已有的知识。
// ✅ 一致的模式
// 用户服务
public User getUser(String id) { ... }
public void deleteUser(String id) { ... }
// 订单服务 (遵循相同模式)
public Order getOrder(String id) { ... }
public void deleteOrder(String id) { ... }
当读者看到 getOrder,他们可以安全地假设它和 getUser 的行为类似,而无需深入分析代码。
📄 3. 明智地使用空白
格式化能够极大地影响代码的可读性。
示例1:参数文档
// ❌ 挤在一起的文档
/**
* ...
* @param numThreads The number of threads...should be at least equal to...
* @param handler Used as a callback in order to handle incoming messages...
*/
// ✅ 使用空白后的文档
/**
* @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...
*
* @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.
*/
改进效果:
- 结构清晰
- 参数分离
- 容易快速扫描
示例2:方法内代码块
// ❌ 没有空行的代码块
void* Buffer::allocAux(size_t numBytes) {
uint32_t numBytes32 = (downCast<uint32_t>(numBytes) + 7) & ~0x7;
assert(numBytes32 != 0);
if (availableLength >= numBytes32) {
availableLength -= numBytes32;
return firstAvailable + availableLength;
}
if (extraAppendBytes >= numBytes32) {
extraAppendBytes -= numBytes32;
return lastChunk->data + lastChunk->length + extraAppendBytes;
}
uint32_t allocatedLength;
firstAvailable = getNewAllocation(numBytes32, &allocatedLength);
availableLength = allocatedLength - numBytes32;
return firstAvailable + availableLength;
}
// ✅ 使用空行分离逻辑块
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.
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;
}
黄金组合:空行 + 描述性注释,让代码结构一目了然。
示例3:语句内空白
| 无空白 (不明显) | 有空白 (明显) |
|---|---|
for(int i=0;i<10;i++){ | for (int i = 0; i < 10; i++) { |
if(x>0&&y<0){ | if (x > 0 && y < 0) { |
💬 4. 必要的注释
当你无法避免写出不那么明显(但可能是必要的)的代码时,注释就是你的救星。
注释的艺术:把自己放在读者的位置,思考他们可能会困惑什么,然后用注释提供这些缺失的信息。
18.2 让代码不那么明显的事情 🚩
Things that make code less obvious
了解哪些模式会天然地导致代码晦涩,可以帮助我们有意识地避免或补偿它们。
⚡ 1. 事件驱动编程
在事件驱动编程中,代码响应外部事件(如点击按钮、收到网络包)。
问题:难以追踪控制流。
// 事件发布者
public class EventBus {
public void register(EventHandler handler) { ... }
public void post(Event event) {
// 在这里,我们不知道具体会调用哪个handler
// 这取决于运行时注册了哪些handler
handler.handle(event);
}
}
// 事件处理器
public class UserLoginHandler implements EventHandler {
@Override
public void handle(Event event) {
// 这个方法从不会被直接调用
// 它是被 EventBus 间接调用的
System.out.println("User has logged in!");
}
}
挑战:
- 很难推理代码的执行顺序
- 很难说服自己代码是正确的
补偿方案:在注释中明确调用时机。
/**
* 当RPC因为传输层错误而无法完成时,
* 这个方法会被分发线程在一个传输(transport)中调用。
*/
void Transport::RpcNotifier::failed() {
// ...
}
🚩 危险信号:不明显的代码
如果一段代码的含义和行为无法通过快速阅读来理解,这就是一个危险信号。这通常意味着有重要的信息对读者来说不是立即可见的。
📦 2. 通用容器 (Generic Containers)
像Java的Pair或C++的std::pair这样的通用容器非常诱人,因为它们可以轻松地将多个值打包在一起返回。
问题:分组的元素名字是通用的,掩盖了它们的真实含义。
// 从一个方法返回多个值
return new Pair<Integer, Boolean>(currentTerm, false);
// 调用者如何使用?
Pair<Integer, Boolean> result = someMethod();
int term = result.getKey(); // getKey() 是什么意思?
boolean voted = result.getValue(); // getValue() 又是什么?
挑战:
getKey()和getValue()没有提供任何关于值的实际含义的线索- 读者必须去
someMethod的实现中才能理解返回值的意义
更好的方案:定义一个专用的类或结构体。
// 定义一个专用的返回类型
public class VoteResult {
public final int term;
public final boolean voteGranted;
public VoteResult(int term, boolean voteGranted) {
this.term = term;
this.voteGranted = voteGranted;
}
}
// 返回一个明显的对象
return new VoteResult(currentTerm, false);
// 调用者使用起来非常清晰
VoteResult result = someMethod();
int term = result.term;
boolean voted = result.voteGranted;
📖 设计黄金法则
软件应该为易于阅读而设计,而不是为易于编写而设计。
编写代码的人花几分钟定义一个专用容器,可以为后来所有的读者节省大量的时间和困惑。
🎭 3. 声明和分配类型不同
在Java中,你可以用父类或接口来声明变量,用子类来实例化它。
private List<Message> incomingMessageList;
...
incomingMessageList = new ArrayList<Message>();
问题:可能会误导读者。
- 读者只看到声明
List,可能会对它的性能和线程安全做出错误的假设。 ArrayList和LinkedList的行为和性能特征是截然不同的。
更好的方案:让声明和分配的类型匹配。
// 如果你真的需要一个ArrayList,就这样声明它
private ArrayList<Message> incomingMessageList;
...
incomingMessageList = new ArrayList<Message>();
// 或者,如果接口足够,确保实现不重要
// 并在必要时注释
private List<Message> incomingMessageList; // 性能非关键,任何List实现均可
🤯 4. 违反读者期望的代码
代码最明显的时候,是它符合读者的普遍预期。如果它不符合,就必须明确说明。
示例:Java程序的 main 方法
public static void main(String[] args) {
...
new RaftClient(myAddress, serverAddresses);
}
读者的普遍预期:main 方法执行完毕,程序就退出了。
实际行为:RaftClient的构造函数创建了新的后台线程,即使主线程结束了,程序依然在运行。
补偿方案:在注释中说明非典型行为。
public static void main(String[] args) {
...
new RaftClient(myAddress, serverAddresses);
// 注意:应用程序不会在此处退出。
// RaftClient会在后台线程中继续运行。
}
18.3 结论 🎯
Conclusion
💡 显而易见的本质是信息
我们可以从信息的角度来思考"显而易见"。
如果代码不明显,通常意味着读者缺少了理解它所需要的重要信息:
- 在
Pair的例子中,读者不知道getKey()返回的是当前任期号。 - 在
RaftClient的例子中,读者不知道构造函数创建了新线程。
🚀 如何让代码显而易见?
要让代码显而易见,你必须确保读者始终拥有理解它所需的信息。有三种方法可以做到这一点:
1. 减少所需信息量 (最高级)
- 抽象:隐藏不重要的细节。
- 消除特殊情况:让代码路径更通用。
- 这是最好的方法,从根本上降低复杂性。
2. 利用已有信息 (最聪明)
- 遵循约定:利用读者已有的知识(例如,所有
get方法都不会修改状态)。 - 符合预期:让代码的行为符合标准模式。
- 这样读者就不需要为你的代码学习新知识。
3. 清晰地呈现信息 (最直接)
- 起好名字:名字本身就是信息。
- 战略性注释:在信息无法通过代码自身传达时,用注释来补充。
- 这是在无法做到前两点时的必要补充。
最终目标:编写的代码应该让读者感觉"理应如此",而不是"这是什么鬼?"。让显而易见成为你代码的内在品质。
"好的代码自己会说话,而显而易见的代码说得清晰、准确、毫不费力。" 🌟