你曾在程式碼審查時遇過這樣的函式嗎?
它名為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 中提到的「普通的迴圈勝過看似厲害的一行程式碼」完全是同一個脈絡。
結語
關於最小驚訝原則,有三件事要記住。
- 程式碼應該按照名稱與慣例所建立的期待運作。打破期待的程式碼,是錯誤的溫床。
- 不要把副作用藏在查詢函式裡,也不要默默吞掉失敗。
- 在團隊中,無聊又可預測的程式碼勝過奇技淫巧。
如果只能選一個好程式碼的標準,我會選這個:不會讓閱讀者感到意外的程式碼。
如果你最近讀程式碼時曾想過「咦,為什麼它會這樣運作?」,那一刻就是這項原則被違反的現場。

