在上篇文章中,我們探討了如何透過分層設計(Stratified Design)來管理程式碼的複雜性。但如果我們能將程式中最核心的元素——「行為」本身——也當作積木一樣自由傳遞、儲存和組合,這會為我們程式碼的設計帶來不同的變化。
在 MDN 文件中,它是這樣形容 JavaScript 的:「JavaScript (JS) is a lightweight interpreted (or just-in-time compiled) programming language with first-class functions.」(JavaScript 是一種「擁有一等公民函式(first-class functions)的輕量級直譯式程式語言),可看出 JavaScript 程式語言本身就具備 first-class 的特性,而理解 Higher-Order Function 和 First-Class Function,也是學習 Functional Programming(FP) 的重要元素,因此今天就要來好好了解它~
first-class 有人稱作「頭等」、「一級」或「一等」,以下文章我會以頭等來稱呼。
在程式設計的世界裡,可以做到以下操作的程式語言元素,稱為「頭等物件」:
在 JavaScript 中,基本資料型別可以做到上述四種操作,因此屬於頭等物件,而 JavaScript 的函式在抽象化後也可以做到上述的操作,因此也屬於頭等物件,又因為它是函式,也可以稱為「頭等函式」(first-class functions)。
稍微補充一下什麼是「抽象化」,正常來講,我們要製作咖啡需要經過許多步驟,包含啟動水汞、調整水溫、研磨咖啡豆、沖泡咖啡粉等,而如果這些底層細節都要自己完成,我們就需要具備許多技術才能泡咖啡,但如果現在有個「自動咖啡機」,自動咖啡機只提供一個「開始」按鈕,或「美式咖啡」、「Espresso」等按鈕,我們只需按一個按鈕就能得到一杯咖啡,這個咖啡機其實就是隱藏了咖啡的實作細節,也就是做到了泡咖啡的抽象化,不懂咖啡機原理的人,也可透過抽象化後的按鈕喝到咖啡。
在程式語言中,函式本身作為頭等物件,代表它可像資料一樣被傳遞、賦值、作為參數傳入其他函式,示意圖如下。
圖 1 頭等抽象化示意圖(資料來源: 自行繪製)
這個特性的優點如下:
接著看一下頭等函式「可以被存入變數」、「可以作為參數傳遞」以及「可以作為函式的回傳值」具體的一些例子吧~
就像我們可以把數字 42
或字串 "hello world"
存入一個變數中一樣,我們也可以將一個完整的函式賦值給一個變數。
// 就像把一個數值存入變數
const myNumber = 10;
// 我們也可以把一個完整的「行為」(函式)存入變數
const sayHello = function(name) {
console.log(`Hello, ${name}!`);
};
// 之後便可以透過這個變數名稱,加上括號 () 來呼叫(執行)這個行為
sayHello('鐵人賽'); // Hello, 鐵人賽!
在這個例子中,sayHello
這個變數現在所「持有」的,就是一個函式。這種將函式定義為表示式一部分的語法,稱為函式表示式(function expression)。關於函式表示式的更多介紹可參考 MDN 文件 Function expressions的說明。
因為函式是一個值,所以它可以被當作參數傳遞給另一個函式。當一個函式被當作參數傳遞時,我們通常稱之為「回呼函式」(Callback Function)。
假設有一個函式,它的職責是對使用者資料進行某項操作。但具體是什麼操作,我們希望能保持彈性,由外部呼叫的人來決定。
// 這個函式接受一個 user 物件和一個 action 函式作為參數
function operateOnUser(user, action) {
console.log(`準備對 ${user.name} 進行操作...`);
// 在這裡,我們執行傳入的「行為」
// 並把 user 物件傳給它
action(user);
}
const currentUser = { name: 'Bob', role: 'editor' };
// 這是我們想要執行的其中一種「行為」
const logUserDetails = (u) => {
console.log(`使用者名稱: ${u.name}, 角色: ${u.role}`);
};
// 這是另一種可能的「行為」
const grantAdminRights = (u) => {
console.log(`正在將 ${u.name} 的權限提升為管理員...`);
u.role = 'admin';
};
// 將 logUserDetails 這個「行為」傳遞給 operateOnUser
operateOnUser(currentUser, logUserDetails);
// Output:
// 準備對 Bob 進行操作...
// 使用者名稱: Bob, 角色: editor
// 將 grantAdminRights 這個「行為」傳遞給 operateOnUser
operateOnUser(currentUser, grantAdminRights);
// Output:
// 準備對 Bob 進行操作...
// 正在將 Bob 的權限提升為管理員...
operateOnUser
函式變得非常通用且可重用性高,它定義了一個操作的框架,但將具體的「行為」交由外部傳入的函式決定。這種模式讓我們可以將不變的邏輯與可變的邏輯分離開來。這種設計思維也就是我之前在這篇提到的「依賴反轉」概念。
函式不僅可以作為輸入,也可以作為輸出的結果。一個函式可以執行完畢後,回傳另一個函式。這讓我們可以創建「函式工廠」——一個專門用來生產其他函式的函式。
// 這是一個「函式工廠」,它接收一個參數 factor
// 並回傳一個新的函式
function createMultiplier(factor) {
// 它回傳的這個匿名函式,會記住外層的 factor 參數
return function(number) {
return number * factor;
};
}
// 利用工廠創建一個「乘以 5」的專屬函式
const multiplyBy5 = createMultiplier(5);
// 創建另一個「乘以 10」的專屬函式
const multiplyBy10 = createMultiplier(10);
console.log(multiplyBy5(8)); // Output: 40
console.log(multiplyBy10(8)); // Output: 80
createMultiplier
就像一個生產工廠,我們給它一個規格(factor
),它就能產出一個符合該規格的全新函式。這個新產出的函式(例如 multiplyBy5
)會透過「閉包」(Closure)的機制,記住它被創建時的環境(也就是 factor
的值)。
介紹完頭等函式(First-Class Function),接著要來談談高階函式(Higher-Order Function)。
一個函式只要滿足以下兩個條件之一,就是一個高階函式:
回頭看看我們剛剛的例子:
operateOnUser
函式接受了 logUserDetails
作為參數,所以它是一個高階函式。createMultiplier
函式回傳了一個新的函式,所以它也是一個高階函式。那頭等函式和高階函式到底有什麼區別呢?
圖 2 頭等函式和高階函式的關係示意圖(資料來源: 自行繪製)
兩者的關係是因果關係:正因為 JavaScript 擁有「頭等函式」這個特性,我們才能使用「高階函式」這種模式。
for
迴圈到 forEach
:抽象化迭代行為接下來看一個日常開發常見的遍歷陣列應用。
假設我們有一個家庭代辦事項清單,我們想要完成清單上的每一項任務。
const chores = [
{
name: 'Wash dishes'
},
{
name: 'Do laundry'
},
{
name: 'Take out trash'
},
];
for
迴圈在學習高階函式之前,最直覺的作法就是使用 for
迴圈:
console.log('--- for 迴圈 ---');
for (let i = 0; i < chores.length; i++) {
const chore = chores[i];
// 執行「完成任務」這個行為
chore.status = 'completed';
console.log(`家務 "${chore.name}" 已經完成。`);
}
console.log(chores);
/*
Output:
家務 "Wash dishes" 已經完成。
家務 "Do laundry" 已經完成。
家務 "Take out trash" 已經完成。
*/
這段程式碼完全沒問題,而且運作也很正常。
但讓我們仔細分析一下:let i = 0
、i < chores.length
、i++
以及 const chore = chores[i]
。這些程式碼都在描述「如何」進行遍歷的細節。這使得「做什麼」(完成任務)的意圖,與「如何做」(迭代控制)的實作細節混雜在一起,違反了我們在上一篇文章中提到的分層設計原則。當不同層級的抽象概念混雜在同一段程式裡,就容易讓人失去焦點,不知道要專注於遍歷的細節,還是要專注於對每個家務的操作。
forEach
如果我們想要把「如何做」(迴圈本身)和「做什麼」(完成家務)分開,該怎麼做呢?我們可以利用高階函式的概念,寫一個函式來封裝迴圈的邏輯。這個函式會接收一個陣列和一個「行為」函式作為參數。
// 這是一個高階函式,它封裝了遍歷陣列的邏輯
function processItems(array, action) {
for (let i = 0; i < array.length; i++) {
// 對陣列中的每一個元素,執行傳入的 action
action(array[i]);
}
}
// 這是我們想執行的「行為」,被獨立出來了
const completeChore = (chore) => {
chore.status = 'completed';
console.log(`家務 "${chore.name}" 已經完成。`);
};
// 現在,我們可以這樣使用它
console.log('--- 使用自訂的高階函式 ---');
processItems(chores, completeChore);
我們將迴圈遍歷 item 的實作細節放進 processItems
函式中。現在,當我們需要遍歷 chores
陣列時,我們不再需要手寫 for 迴圈,而是直接呼叫 processItems
,並告訴它我們想要對每個元素「做什麼」(也就是傳入 completeChore
這個行為)。
這個模式的好處是可重用性。如果我們想定義另一種行為,例如「延後任務」,我們只需要再寫一個函式,就可以把它傳給同一個 processItems
函式,完全不需要再寫一次迴圈。
Array.prototype.forEach
這種「遍歷一個陣列並對每個元素執行某個操作」的模式是日常開發很常見的需求,因此 JavaScript 直接將這個功能內建在 Array 的原型(prototype)中。這就是我們熟悉的 forEach
方法。它本質上就是一個更強大、更優化的 processItems
。
現在我們可以用 forEach
改寫最初的程式:
const chores = [
{
name: 'Wash dishes'
},
{
name: 'Do laundry'
},
{
name: 'Take out trash'
},
];
const completeChore = (chore) => {
chore.status = 'completed';
console.log(`家務 "${chore.name}" 已經完成。`);
};
console.log('--- 內建 forEach ---');
chores.forEach(completeChore);
forEach
本身就是一個高階函式,它接受 completeChore
這個函式作為參數。
在這裡,我們完成了關注點分離:
forEach
函式抽象化並隱藏起來了。我們不再需要關心這些細節。completeChore
函式中。我們不再對電腦下達一步步的指令,而是直接宣告我們的意圖:「對於 chores 陣列中的每一個元素,請執行 completeChore
這個行為」。這種「做什麼」與「如何做」的分離,正是從指令式程式設計邁向宣告式程式設計的核心,它能帶來更易讀、更易維護的程式碼。
今天介紹了 JavaScript 的核心特性——頭等函式(First-Class Functions),在 JavaScript 的世界裡,函式可以像普通變數一樣被儲存、傳遞和回傳。這個基礎特性催生了高階函式(Higher-Order Functions)這種程式設計模式。
透過 forEach
的例子,我們體會到從指令式到宣告式的思維轉變。我們不再命令電腦「如何」一步步做事,而是直接告訴它我們「想要什麼」結果。這種關注點的分離,將「行為」與「執行」解耦,是我們寫出更清晰、更具組合性程式碼的關鍵第一步。
接著會再介紹其他遍歷陣列的高階函式,例如利用 map
高階函式從 chores
陣列中只取出所有任務的 name
,形成一個新的字串陣列,或是利用 reduce
遍歷整個陣列,將其「歸納」為單一值。