软件设计

最小惊讶原则:getUser 不应发送邮件的原因

你在代码审查时遇到过这样的函数吗?

3 分钟阅读
最小惊讶原则:getUser 不应发送邮件的原因 封面图

你在代码审查时遇到过这样的函数吗?

它名为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 的温床。
  • 不要把副作用隐藏在查询函数中,也不要静默吞掉失败。
  • 在团队中,乏味且可预测的代码胜过花哨的代码。

如果只能选一个好代码的标准,我会选这个:不会让阅读者感到意外的代码。

如果你最近读代码时曾想过“咦,为什么它会这样运行?”,那一刻就是这条原则被破坏的现场。