函式設計:單一職責原則
這是《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);
仔細觀察,一樣的程式碼重複出現兩次。隨著牧場動物越養越多種類,還要再對羊、鴨、鵝……族繁不及備載,個別複製一遍相同程式碼。哪天列印的規則改了,還要重新修正全部的內容,超沒效率。
這讓我們思考,如何寫出可重複使用的函式?讓我們先來盤點一下:
需處理的變數有哪些?動物隻數、種類,以及數值位數。
為什麼會發生重複的問題?主要是因為動物種類不同。那有沒有辦法將它改成參數,個別動態傳入函式呢?
因此,就有了第二版程式碼。我們把動物種類替換成參數,這樣就解決重複的問題了!
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 幫忙回答。他指出了兩個關鍵差異「抽象層級」與「副作用切割」:
- 職責切割的粒度
第二版:格式化 + 列印綁在一起(
printZeroPaddedWithLabel)。- 後果:想重用「只格式化、不列印」時做不到。
第三版:把「補零」抽成純函式
zeroPad(number, width);列印留在printFarmInventory。- 後果:關注點分離、可自由組合。
- 副作用(Side effects)隔離
第二版:函式一呼叫就
console.log,不可測、不可組合。第三版:
zeroPad無副作用、可單測、可在任何地方重用(UI、API、字串模板…)。
- 通用性與參數化
第二版:寬度寫死為 3,語意上其實是「列印某標籤的三位數」。
第三版:把「寬度」提升為參數
width→ 場景擴張(票號、訂單號、i18n 不同規則)。
- API 設計方向
第二版:輸入(數值, 標籤)→ 直接輸出到 I/O(列印)。
第三版:輸入(數值, 寬度)→ 回傳值(格式化字串),呼叫端決定後續行為。
- 這種「回傳值優先、I/O 後置」的 API 更適合大型專案。
- 變更影響面(Change surface)
第二版:改列印方式 / 換輸出介面(console → DOM / 檔案)=得動到函式本體。
第三版:改輸出僅動到呼叫處;
zeroPad穩定不動,風險更小。
- 可測試性與可讀性
第二版:要測要攔
console;行為被 I/O 綁死。第三版:
zeroPad(7, 3) === "007"這種斷言一眼可懂、一行測得。
什麼情況用第二版?什麼情況用第三版?
第二版 OK 的情境:小腳本、一次性列印,重點是快,不打算重用/測試。
第三版建議優先:功能會擴充(更多類型/不同寬度/不同輸出載體)、需要單測、多人協作或長期維護。
結語
第二版解決了「重複」;第三版解決了「耦合」。把「格式化」做成純函式、把「輸出」留在邊界,是從能跑到好維護的那一步。