iT邦幫忙

2026 iThome 鐵人賽

DAY 15
0
自我挑戰組

程式碼門診:診斷壞味道、開出重構處方系列 第 15 篇

程式碼不該有驚喜 - 最小驚訝原則 (Principle of Least Astonishment)

  • 分享至 

  • xImage
  •  

簡單介紹

想像一下:你走進廚房按下電燈開關,結果打開的不是燈,而是抽油煙機;你轉開水龍頭,流出來的卻是熱水器的排水。這些「驚喜」不會讓你覺得有趣,只會讓你覺得這間房子的設計出了問題。

程式碼也是一樣的道理。驚喜應該留給生日派對,而不是留給接手你程式碼的同事。

這就是我們今天要介紹的 最小驚訝原則(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 的溫床。

在日常開發中,違反最小驚訝原則最常見的兩種形式是:

  1. 名不符實的副作用:名字說是「查詢」,實際上卻偷偷修改了資料
  2. 違背慣例的介面設計:參數順序、命名風格與專案(或整個生態系)的慣例背道而馳

接下來我們就透過一個會員系統的實際場景,看看這兩種「驚喜」如何在程式碼中埋下地雷。

TypeScript 不好的範例

問題一:叫 getUser() 的函式,居然偷偷更新最後登入時間

// 不好的範例:名字說「查詢」,行為卻偷偷「修改」

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']);
// 小明明明「從未登入」,報表卻顯示他剛剛才登入過!

問題分析

這段程式碼最可怕的地方在於:它完全能正常執行,也不會拋出任何錯誤。

  1. 名不符實:get 開頭的函式在幾乎所有程式語言的慣例中都代表「唯讀查詢」,但這個 getUser() 卻夾帶了寫入行為。呼叫者根據名字建立的預期,與實際行為完全不符
  2. 資料默默被污染:管理後台、報表系統、背景批次任務——任何「只是想讀資料」的地方,都會在不知情的狀況下覆寫 lastLoginAt。等到有人發現「從未登入的使用者怎麼有登入紀錄?」時,往往已經過了好幾個星期,除錯難度極高
  3. 違反命令查詢分離(CQS):Bertrand Meyer 提出的命令查詢分離原則說得很精準——「問一個問題,不應該改變答案(asking a question should not change the answer)」。一個方法要嘛是命令(執行動作、改變狀態),要嘛是查詢(回傳資料、不留痕跡),不該兩者混在一起
  4. 測試困難:想測試「查詢使用者」的邏輯,卻不得不處理「登入時間被改動」的狀態變化,測試案例之間互相干擾

問題二:formatDate(format, date) 與整個專案的參數順序相反

// 不好的範例:參數順序與專案既有慣例背道而馳

// 專案中既有的工具函式:一律遵守「資料在前、選項在後」的慣例
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 就這樣安安靜靜地上了正式環境

問題分析

這時候我們可以發現,第二個問題比第一個更隱蔽:

  1. 慣例被打破:同一個專案裡,formatCurrency 和 truncateText 都是「資料在前、選項在後」,開發者早已養成肌肉記憶。formatDate 偏偏反過來,等於在走廊中間放了一個看不見的絆腳石
  2. 型別系統救不了你:因為 format 和 date 都是 string,參數傳反時編譯器毫無反應。這是「字串型別參數相鄰」最經典的陷阱
  3. 錯誤靜默發生:傳反參數後程式不會當掉,只是輸出了沒套用格式的日期。這種「看起來只是有點怪」的輸出,往往要等使用者回報才會被發現

修正後範例

解法一:查詢歸查詢、副作用歸副作用

// 修正範例:把「查詢」與「副作用」拆開,名字誠實反映行為

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: '日期待確認',
  })
); // 日期待確認

改進重點說明

  1. 名字與行為一致:getUser() 回歸純查詢;需要副作用時,函式改名為 recordLogin() 或 recordLoginAndGetUser()——動詞誠實地宣告了「我會改資料」,呼叫者不再需要點進實作才能知道會發生什麼事
  2. 命令與查詢分離:查詢函式呼叫再多次都不會改變狀態(可以安心用在報表、快取、重試邏輯中);命令函式回傳 void 或明確標示行為,兩者責任清晰、各自可測試
  3. 慣例的一致性:formatDate 的參數順序改為與 formatCurrency、truncateText 一致的「資料在前、選項在後」,開發者的肌肉記憶不再是陷阱,而是助力
  4. 讓型別系統站在你這邊:第一個參數改收 Date 而非 string,傳反參數會直接編譯錯誤;進階作法改用具名物件參數,讓每個引數在呼叫端都有名字,徹底消除順序問題

這時候我們可以發現,最小驚訝原則其實不需要什麼高深的技巧——它要求的只是誠實:名字說到做到、介面遵守慣例、行為符合預期。當這三件事都成立時,讀程式碼的人根本不需要「猜」,自然也就不會被「嚇」。

總結

綜合以上所述,我們成功避免了兩種最常見的「程式碼驚喜」:

  1. 隱藏的副作用:get 開頭的函式就該是純查詢;需要修改狀態時,用 record、update、save 這類動詞誠實命名,或依照命令查詢分離(CQS)原則直接拆成兩個函式
  2. 違背慣例的介面:參數順序、命名風格應該與專案(乃至整個語言生態系)的慣例一致;當字串參數相鄰導致型別系統無法把關時,改用更精確的型別或具名物件參數
  3. 核心心法:命名與行為一致,就是最好的文件——再詳盡的註解與說明文件都可能過期,但一個「做它看起來會做的事」的函式,永遠不會騙人

值得一提的是,《97 Things Every Programmer Should Know》書中 Michael Feathers 提出的「API 設計黃金法則」與此高度呼應:光是為你開發的 API 寫測試還不夠,你必須為「使用你 API 的程式碼」寫單元測試。因為只有親自站在使用者的角度呼叫自己的 API,你才會體會到哪些設計會讓人驚訝、哪些介面會讓人踩坑——驚訝是站在「呼叫者」的視角被定義的,不是「作者」的視角。

需要注意的是,「驚訝」是相對於受眾與情境的。同一個設計,對熟悉函式程式設計(Functional Programming)的團隊是理所當然,對習慣物件導向的團隊可能就是驚喜。因此實務上的判斷基準是:優先遵守你所在的平台、框架與專案的既有慣例。如果專案裡所有查詢函式都以 get 開頭、所有工具函式都是資料在前,那麼新加入的程式碼就該跟上這個節奏——就算你個人偏好另一種風格也一樣。一致性本身,就是對讀者最大的體貼。

換句話說,這正是我們在 Day 4 討論 KISS 原則時強調的可讀性精神的延伸:KISS 要求程式碼「簡單到一眼能懂」,而最小驚訝原則進一步要求「懂了之後,行為不會背叛你的理解」。簡單讓人讀得快,不驚訝讓人讀得對——兩者加起來,才是真正易於維護的程式碼。

下次為函式命名或設計介面時,不妨停下來問一句:「第一次看到這段程式碼的人,會不會被它嚇到?」如果答案是會,那該修改的不是讀者的預期,而是你的程式碼。

參考資料

上一篇
越早失敗越好 - 快速失敗原則 (Fail Fast)
系列文
程式碼門診:診斷壞味道、開出重構處方 共 15 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言