你在代码审查时遇到过这样的函数吗?
它名为getUser(),但打开后却发现,它会查询用户、更新最后登录时间、刷新缓存,甚至在账户处于休眠状态时发送通知邮件。
如果只看名字,以为“只是查询,应该很安全”,然后在循环里调用它,用户岂不是会收到邮件轰炸?
今天要介绍的原则,就是用来识别这类代码的最小惊讶原则。这是继 KISS、DRY、YAGNI、SOLID 之后的开发原则系列,而今天这个原则光是名字就很有趣。
最小惊讶原则(Principle of Least Astonishment,简称 POLA)讲的是:
代码应该按照阅读者预期的方式运行。如果某种设计会让用户感到意外,就应该重新思考这种设计。
这是源自 1960~70 年代系统设计的古老原则,但从 UI 设计、API 设计到日常代码都可以广泛应用。
为什么惊讶会带来成本?
在编程中,“惊讶”不是情绪问题,而是成本问题。
开发者阅读代码时,会根据名称和惯例建立假设。getUser应该只会查询,isValid应该会返回布尔值。当代码按照假设运行时,阅读就会很顺畅。
但假设被打破的瞬间,你就必须停下阅读,进入函数内部确认它真正做了什么。即使代码库里只有十个这样的函数,也会产生“这份代码的名称不可信”的不信任感。从那以后,阅读每个函数时都要先打开查看。代码阅读速度会下降一半。
最糟糕的情况,是不加确认就按照假设使用代码,最终引发故障,就像上面的邮件轰炸一样。
代码中出现惊讶的场景
下面主要整理一些我实际遇到过的模式。
1. 名称与行为不一致的函数
// 名称表示查询,但内部却改变了状态
func getUser(id: Int) -> User {
let user = db.find(id)
user.lastSeenAt = Date() // 惊讶 1: 副作用
db.save(user)
if user.isDormant {
mailer.send(to: user.email, message: "解除休眠通知") // 惊讶 2: 外部调用
}
return user
}
隐藏在查询函数中的写操作,是最典型的惊讶来源。get 应该只负责读取;如果会改变状态,就应该使用 update 或 touch 这样的名称。
2. 违背惯例的返回值
如果同一个代码库中,有的函数找不到结果时返回 nil,有的函数抛出异常,还有的函数返回空对象,那么调用方每次都得碰运气。重要的是统一采用一种方式。
3. 静默失败
func parseConfig(_ json: Data) -> Config {
guard let config = try? JSONDecoder().decode(Config.self, from: json) else {
return Config() // 惊讶:配置已损坏,却像什么都没发生一样返回空配置
}
return config
}
配置文件损坏后,如果系统悄悄回退到默认值,用户过很久才会痛苦地问:“为什么配置没有生效?”明确地报告失败,反而更不容易让人感到意外。
编写更少令人意外的代码
以下是我努力遵守的做法。
| 做法 | 内容 |
|---|---|
| 名称就是契约 | 只做名称承诺的事情;如果做得更多,就修改名称 |
| 遵循惯例 | 没有理由就不要偏离语言、框架和团队现有的风格 |
| 明确展示副作用 | 让人能从名称看出函数会改变状态 |
| 对令人意外的行为进行文档说明 | 如果特殊行为不可避免,就在注释和文档中醒目地说明 |
其中最有力的是第二点。从整个团队来看,乏味的标准方式总是胜过自作聪明的个人方式。这和上一篇 KISS 中提到的观点完全一致:普通的循环胜过“看起来很厉害的一行代码”。
结语
关于最小惊讶原则,有三点需要记住。
- 代码应该按照名称和惯例建立的预期运行。打破预期的代码是 bug 的温床。
- 不要把副作用隐藏在查询函数中,也不要静默吞掉失败。
- 在团队中,乏味且可预测的代码胜过花哨的代码。
如果只能选一个好代码的标准,我会选这个:不会让阅读者感到意外的代码。
如果你最近读代码时曾想过“咦,为什么它会这样运行?”,那一刻就是这条原则被破坏的现场。

