Skip to main content

Command Palette

Search for a command to run...

函式設計:單一職責原則

Published
2 min read

這是《Eloquent JavaScript》第三章 Functions,談到 Growing Function 所舉的一個例子。這個例子提供了三種寫法,很好地體現了「每支函式應該只做一件事」的精神。

功能需求:有座牧場畜養了一群動物,種類有乳牛、雞、豬,需紀錄牠們的個別隻數。牧場主人希望數字保持三位數,若位數不足要加 0 補齊,並在數字後註明是哪種動物,方便他列印表單。像這樣:

  • 007 Cows

  • 011 Chickens

  • 003 Pigs

關鍵知識點:

  • while 迴圈:滿足條件期間,重複執行函式主體

  • 將數字轉為字串:String(…)

  • 字串可用運算子加號串聯起來

第一版程式碼利用一支函式 printFarmInventory 完成所有工作,這是程式初學者很直覺會想到的寫法。先將函式參數設為兩個:乳牛的隻數、雞的隻數,列印邏輯分別寫兩次。呼叫函式時,按順序分別傳入兩種動物的隻數。

function printFarmInventory(cows, chickens) {
  let cowString = String(cows);
  while (cowString.length < 3) {
    cowString = "0" + cowString;
 }
  console.log(`${cowString} Cows`);

 let chickenString = String(chickens);
 while (chickenString.length < 3){
   chickenString = "0" + chickenString; 
 }
console.log(`${chickenString} Chickens`);
}

printFarmInventory(7, 11);

仔細觀察,一樣的程式碼重複出現兩次。隨著牧場動物越養越多種類,還要再對羊、鴨、鵝……族繁不及備載,個別複製一遍相同程式碼。哪天列印的規則改了,還要重新修正全部的內容,超沒效率。

這讓我們思考,如何寫出可重複使用的函式?讓我們先來盤點一下:

  1. 需處理的變數有哪些?動物隻數、種類,以及數值位數。

  2. 為什麼會發生重複的問題?主要是因為動物種類不同。那有沒有辦法將它改成參數,個別動態傳入函式呢?

因此,就有了第二版程式碼。我們把動物種類替換成參數,這樣就解決重複的問題了!

function printZeroPaddedWithLabel(number, label) {
  let numberString = String(number);
  while (numberString.length < 3) {
  numberString = "0" + numberString;
 }
console.log(`${numberString} ${label}`);
}

function printFarmInventory(cows, chickens, pigs) {
  printZeroPaddedWithLabel(cows, "Cows");
  printZeroPaddedWithLabel(chickens, "Chickens");
  printZeroPaddedWithLabel(pigs, "Pigs");
}

printFarmInventory(7, 11, 3);

第二版處理流程是:呼叫 printFarmInventory 傳入動物數量,然後呼叫 printZeroPaddedWithLabel 三次,將動物數量往下傳遞,再加入標籤參數,每呼叫一次就印出正確的動物數量 + 種類。我們來觀察第二版優化的地方:動物種類(這裡命名為標籤)放在另一支函式處理。

你可能會說,printZeroPaddedWithLabel 和 printFarmInventory 這兩支函式,前者的名字感覺有點奇怪,而且兩支函式的名字都有個 print,這還可以修正吧?那就讓我們再分析一下,列印邏輯到底具體做了哪些事情?其實就三件事:動物隻數轉字串、補0至三位數、打印至控制台。第二版雖然解決程式碼重複的問題,但仍然將三件事混為一談。

我們能不能挑出一個職責,交給一支函式專心處理?沒問題,更精進的第三版程式碼來了:

function zeroPad(number, width){
 let string = String(number);
 while (string.length < width) {
  string = "0" + string;
 }
return string;
}

function printFarmInventory(cows, chickens, pigs) {
 console.log(`${zeroPad(cows, 3)} Cows`);
 console.log(`${zeroPad(chickens, 3)} Chickens`);
 console.log(`${zeroPad(pigs, 3)} Pigs`);
}

printFarmInventory(7, 16, 3);

瞧瞧這個函式的名稱多麼俐落:zeroPad!它就只負責將數字前面帶上零,這就是我們挑出來獨立的職責。而列印工作交給 printFarmInventory,各司其職。第三版的處理流程:printFarmInventory 負責接收動物隻數,傳遞隻數、位數參數給 zeroPad,最終列印出 zeroPad 處理好的規範化數值和種類字串。哪怕改天牧場主人想統計他有幾個裝牛奶的木桶,也可以另寫一支函式,針對工具類型做不同的字串處理,然後在裡面呼叫 zeroPad 處理數值呈現。

比較這三種寫法,第一種顯然是最不可取的。第二、三種都是不錯的寫法,不過我目前的程度無法明確說出究竟差別在哪,因此就請出 ChatGPT 幫忙回答。他指出了兩個關鍵差異「抽象層級」與「副作用切割」:

  1. 職責切割的粒度
  • 第二版:格式化 + 列印綁在一起(printZeroPaddedWithLabel)。

    • 後果:想重用「只格式化、不列印」時做不到。
  • 第三版:把「補零」抽成純函式 zeroPad(number, width);列印留在 printFarmInventory

    • 後果:關注點分離、可自由組合。
  1. 副作用(Side effects)隔離
  • 第二版:函式一呼叫就 console.log不可測、不可組合。

  • 第三版:zeroPad 無副作用、可單測、可在任何地方重用(UI、API、字串模板…)。

  1. 通用性與參數化
  • 第二版:寬度寫死為 3,語意上其實是「列印某標籤的三位數」。

  • 第三版:把「寬度」提升為參數 width場景擴張(票號、訂單號、i18n 不同規則)。

  1. API 設計方向
  • 第二版:輸入(數值, 標籤)→ 直接輸出到 I/O(列印)。

  • 第三版:輸入(數值, 寬度)→ 回傳值(格式化字串),呼叫端決定後續行為。

    • 這種「回傳值優先、I/O 後置」的 API 更適合大型專案。
  1. 變更影響面(Change surface)
  • 第二版:改列印方式 / 換輸出介面(console → DOM / 檔案)=得動到函式本體

  • 第三版:改輸出僅動到呼叫處;zeroPad 穩定不動,風險更小

  1. 可測試性與可讀性
  • 第二版:要測要攔 console;行為被 I/O 綁死。

  • 第三版:zeroPad(7, 3) === "007" 這種斷言一眼可懂、一行測得。

什麼情況用第二版?什麼情況用第三版?

  • 第二版 OK 的情境:小腳本、一次性列印,重點是快,不打算重用/測試。

  • 第三版建議優先:功能會擴充(更多類型/不同寬度/不同輸出載體)、需要單測、多人協作或長期維護。

結語

第二版解決了「重複」;第三版解決了「耦合」。把「格式化」做成純函式、把「輸出」留在邊界,是從能跑到好維護的那一步。