想像一下:你走進廚房按下電燈開關,結果打開的不是燈,而是抽油煙機;你轉開水龍頭,流出來的卻是熱水器的排水。這些「驚喜」不會讓你覺得有趣,只會讓你覺得這間房子的設計出了問題。
程式碼也是一樣的道理。驚喜應該留給生日派對,而不是留給接手你程式碼的同事。
這就是我們今天要介紹的 最小驚訝原則(Principle of Least Astonishment,簡稱 POLA),也常被稱為最小驚喜原則(Principle of Least Surprise,POLS)。根據 Wikipedia 的說法,這個原則主張:系統中的每個元件,都應該以「大多數使用者所預期的方式」運作,不該讓使用者感到驚訝。而它還有一條重要的推論——如果某個必要的功能有很高的「驚訝係數」,那麼該重新設計的是這個功能本身。
值得一提的是,這個原則的歷史比許多人想像的更悠久。早在 1967 年的 PL/I Bulletin 中就出現了「最小驚訝法則」的說法,而 PL/I 這門語言本身正是違反此原則的著名案例:由於精度轉換規則的設計,25 + 1/3 和 1/3 + 25 這兩個運算式竟然會產生不同的結果——不是拋出致命錯誤,就是算出 5.33333333333 這種錯得離譜的答案。另一個大家更熟悉的例子是 JavaScript 的 parseInt():早期版本遇到以 0 開頭的字串會預設用八進位解析,parseInt("08") 會回傳出乎所有人意料的結果,直到 ECMAScript 5 才修正為預設十進位。
換句話說,最小驚訝原則要求的是:程式碼應該「做它看起來會做的事」。當開發者看到函式名稱、參數型別與使用情境時,他心中會形成一個預期;如果實際行為與這個預期不符,驚訝就產生了——而驚訝的背後,往往就是 Bug 的溫床。
在日常開發中,違反最小驚訝原則最常見的兩種形式是:
接下來我們就透過一個會員系統的實際場景,看看這兩種「驚喜」如何在程式碼中埋下地雷。
// 不好的範例:名字說「查詢」,行為卻偷偷「修改」
interface User {
id: string;
name: string;
email: string;
lastLoginAt: Date | null;
}
class UserService {
private users: Map<string, User> = new Map();
constructor(users: User[]) {
users.forEach((user) => this.users.set(user.id, user));
}
// 問題:函式名稱是 getUser(取得使用者),
// 卻在查詢的同時偷偷更新了最後登入時間——這是隱藏的副作用!
getUser(userId: string): User | undefined {
const user = this.users.get(userId);
if (user) {
// 問題:呼叫者完全想不到「查個資料」竟然會改動資料
// 名字看起來是唯讀操作,實際上卻是寫入操作
user.lastLoginAt = new Date();
}
return user;
}
}
// 災難現場:管理後台只是想「查看」使用者報表
class AdminDashboard {
constructor(private userService: UserService) {}
printUserReport(userIds: string[]): void {
for (const id of userIds) {
// 管理員的認知:我只是讀取資料,不會改變任何東西
const user = this.userService.getUser(id);
if (user) {
// 慘了:所有被「查看」過的使用者,
// 最後登入時間全部被覆寫成現在這一刻!
console.log(`${user.name} 最後登入:${user.lastLoginAt}`);
}
}
}
}
// 使用範例——資料就這樣默默被弄髒了
const service = new UserService([
{ id: 'u1', name: '小明', email: 'ming@example.com', lastLoginAt: null },
{ id: 'u2', name: '小華', email: 'hua@example.com', lastLoginAt: null },
]);
const dashboard = new AdminDashboard(service);
dashboard.printUserReport(['u1', 'u2']);
// 小明明明「從未登入」,報表卻顯示他剛剛才登入過!
這段程式碼最可怕的地方在於:它完全能正常執行,也不會拋出任何錯誤。
get 開頭的函式在幾乎所有程式語言的慣例中都代表「唯讀查詢」,但這個 getUser() 卻夾帶了寫入行為。呼叫者根據名字建立的預期,與實際行為完全不符lastLoginAt。等到有人發現「從未登入的使用者怎麼有登入紀錄?」時,往往已經過了好幾個星期,除錯難度極高// 不好的範例:參數順序與專案既有慣例背道而馳
// 專案中既有的工具函式:一律遵守「資料在前、選項在後」的慣例
function formatCurrency(amount: number, currency: string): string {
return `${currency} ${amount.toLocaleString()}`;
}
function truncateText(text: string, maxLength: number): string {
return text.length > maxLength ? `${text.slice(0, maxLength)}…` : text;
}
// 問題:新加入的 formatDate 卻把「格式」放在前面、「資料」放在後面!
// 與同專案其他函式的順序完全相反
function formatDate(format: string, date: string): string {
const d = new Date(date);
const yyyy = d.getFullYear().toString();
const mm = (d.getMonth() + 1).toString().padStart(2, '0');
const dd = d.getDate().toString().padStart(2, '0');
return format.replace('YYYY', yyyy).replace('MM', mm).replace('DD', dd);
}
// 呼叫端:依照專案一貫的「資料在前」慣例,直覺地寫下……
const orderDate = '2026-07-15';
console.log(formatDate(orderDate, 'YYYY/MM/DD'));
// 預期輸出:2026/07/15
// 實際輸出:2026-07-15 —— 格式完全沒有套用!
//
// 原因:參數被反著吃進去了——
// '2026-07-15' 被當成「格式字串」(裡面沒有 YYYY、MM、DD 樣板,所以原樣輸出),
// 'YYYY/MM/DD' 被當成「日期」去解析(變成 Invalid Date,但根本沒被用到)。
//
// 最糟的是:兩個參數都是 string,TypeScript 編譯器完全不會報錯,
// 這個 Bug 就這樣安安靜靜地上了正式環境
這時候我們可以發現,第二個問題比第一個更隱蔽:
formatCurrency 和 truncateText 都是「資料在前、選項在後」,開發者早已養成肌肉記憶。formatDate 偏偏反過來,等於在走廊中間放了一個看不見的絆腳石format 和 date 都是 string,參數傳反時編譯器毫無反應。這是「字串型別參數相鄰」最經典的陷阱// 修正範例:把「查詢」與「副作用」拆開,名字誠實反映行為
interface User {
id: string;
name: string;
email: string;
lastLoginAt: Date | null;
}
class UserService {
private users: Map<string, User> = new Map();
constructor(users: User[]) {
users.forEach((user) => this.users.set(user.id, user));
}
// 改動重點 1:getUser 回歸純查詢——呼叫一百次,資料也不會有任何變化
getUser(userId: string): User | undefined {
return this.users.get(userId);
}
// 改動重點 2:副作用獨立成「命令」函式
// 動詞 record 清楚宣告:「我會寫入資料」,回傳 void 更強化了命令的語意
recordLogin(userId: string): void {
const user = this.users.get(userId);
if (!user) {
throw new Error(`找不到使用者:${userId}`);
}
user.lastLoginAt = new Date();
}
// 改動重點 3:若登入流程真的需要「記錄 + 取得」一次完成,
// 就把兩個行為誠實地寫進名字裡——看到名字就知道會發生什麼事
recordLoginAndGetUser(userId: string): User | undefined {
const user = this.users.get(userId);
if (user) {
user.lastLoginAt = new Date();
}
return user;
}
}
// 登入流程:名字明明白白告訴你「會記錄登入時間」
class AuthService {
constructor(private userService: UserService) {}
login(userId: string): User {
const user = this.userService.recordLoginAndGetUser(userId);
if (!user) {
throw new Error('登入失敗:找不到使用者');
}
return user;
}
}
// 管理後台:純查詢,怎麼看報表都不會弄髒資料
class AdminDashboard {
constructor(private userService: UserService) {}
printUserReport(userIds: string[]): void {
for (const id of userIds) {
const user = this.userService.getUser(id); // 純查詢,安心使用
if (user) {
console.log(
`${user.name} 最後登入:${user.lastLoginAt?.toISOString() ?? '從未登入'}`
);
}
}
}
}
// 使用範例——報表怎麼跑,資料都不會被污染
const service = new UserService([
{ id: 'u1', name: '小明', email: 'ming@example.com', lastLoginAt: null },
{ id: 'u2', name: '小華', email: 'hua@example.com', lastLoginAt: null },
]);
const dashboard = new AdminDashboard(service);
dashboard.printUserReport(['u1', 'u2']); // 小明、小華都正確顯示「從未登入」
const auth = new AuthService(service);
auth.login('u1'); // 只有真正登入時,lastLoginAt 才會被更新
dashboard.printUserReport(['u1', 'u2']); // 小明有登入時間,小華依然「從未登入」
// 修正範例:參數順序與專案慣例一致,並用型別堵住傳反的可能
// 專案既有慣例:「資料在前、選項在後」
function formatCurrency(amount: number, currency: string): string {
return `${currency} ${amount.toLocaleString()}`;
}
function truncateText(text: string, maxLength: number): string {
return text.length > maxLength ? `${text.slice(0, maxLength)}…` : text;
}
// 改動重點 1:formatDate 改為「資料(date)在前、選項(format)在後」,
// 與 formatCurrency、truncateText 的慣例完全一致
// 改動重點 2:第一個參數從 string 改收 Date 型別——
// 就算有人不小心把格式字串放到前面,編譯器也會立刻報錯
function formatDate(date: Date, format: string): string {
const yyyy = date.getFullYear().toString();
const mm = (date.getMonth() + 1).toString().padStart(2, '0');
const dd = date.getDate().toString().padStart(2, '0');
return format.replace('YYYY', yyyy).replace('MM', mm).replace('DD', dd);
}
// 使用範例——直覺的順序,正確的結果
const orderDate = new Date('2026-07-15');
console.log(formatDate(orderDate, 'YYYY/MM/DD')); // 2026/07/15
// 若不小心傳反:
// formatDate('YYYY/MM/DD', orderDate);
// 編譯錯誤:Argument of type 'string' is not assignable to parameter of type 'Date'
// 這次錯誤在「編譯時期」就被攔下來,不會再靜默流入正式環境
// 改動重點 3(進階):若選項未來會越來越多,改用具名物件參數,
// 順序從此不再是問題,呼叫端的程式碼也更能「自我說明」
interface FormatDateOptions {
format: string;
fallbackText?: string; // 日期無效時顯示的文字
}
function formatDateWithOptions(
date: Date,
options: FormatDateOptions
): string {
if (Number.isNaN(date.getTime())) {
return options.fallbackText ?? '無效日期';
}
return formatDate(date, options.format);
}
console.log(
formatDateWithOptions(orderDate, { format: 'YYYY/MM/DD' })
); // 2026/07/15
console.log(
formatDateWithOptions(new Date('不是日期'), {
format: 'YYYY/MM/DD',
fallbackText: '日期待確認',
})
); // 日期待確認
getUser() 回歸純查詢;需要副作用時,函式改名為 recordLogin() 或 recordLoginAndGetUser()——動詞誠實地宣告了「我會改資料」,呼叫者不再需要點進實作才能知道會發生什麼事void 或明確標示行為,兩者責任清晰、各自可測試formatDate 的參數順序改為與 formatCurrency、truncateText 一致的「資料在前、選項在後」,開發者的肌肉記憶不再是陷阱,而是助力Date 而非 string,傳反參數會直接編譯錯誤;進階作法改用具名物件參數,讓每個引數在呼叫端都有名字,徹底消除順序問題這時候我們可以發現,最小驚訝原則其實不需要什麼高深的技巧——它要求的只是誠實:名字說到做到、介面遵守慣例、行為符合預期。當這三件事都成立時,讀程式碼的人根本不需要「猜」,自然也就不會被「嚇」。
綜合以上所述,我們成功避免了兩種最常見的「程式碼驚喜」:
get 開頭的函式就該是純查詢;需要修改狀態時,用 record、update、save 這類動詞誠實命名,或依照命令查詢分離(CQS)原則直接拆成兩個函式值得一提的是,《97 Things Every Programmer Should Know》書中 Michael Feathers 提出的「API 設計黃金法則」與此高度呼應:光是為你開發的 API 寫測試還不夠,你必須為「使用你 API 的程式碼」寫單元測試。因為只有親自站在使用者的角度呼叫自己的 API,你才會體會到哪些設計會讓人驚訝、哪些介面會讓人踩坑——驚訝是站在「呼叫者」的視角被定義的,不是「作者」的視角。
需要注意的是,「驚訝」是相對於受眾與情境的。同一個設計,對熟悉函式程式設計(Functional Programming)的團隊是理所當然,對習慣物件導向的團隊可能就是驚喜。因此實務上的判斷基準是:優先遵守你所在的平台、框架與專案的既有慣例。如果專案裡所有查詢函式都以 get 開頭、所有工具函式都是資料在前,那麼新加入的程式碼就該跟上這個節奏——就算你個人偏好另一種風格也一樣。一致性本身,就是對讀者最大的體貼。
換句話說,這正是我們在 Day 4 討論 KISS 原則時強調的可讀性精神的延伸:KISS 要求程式碼「簡單到一眼能懂」,而最小驚訝原則進一步要求「懂了之後,行為不會背叛你的理解」。簡單讓人讀得快,不驚訝讓人讀得對——兩者加起來,才是真正易於維護的程式碼。
下次為函式命名或設計介面時,不妨停下來問一句:「第一次看到這段程式碼的人,會不會被它嚇到?」如果答案是會,那該修改的不是讀者的預期,而是你的程式碼。