《簡約的軟體開發思維:用 Functional Programming 重構程式》CH12 ~ CH13
目錄
Ch12. 利用函數走訪
Code Smell: 有相似的函式實作
- 隱性 -> 顯性函數
- 回呼函式
map()、filter()、reduce() 是三個常見的高階函數,讓 FP 的邏輯看起來更簡潔。
特性 | for 迴圈 | forEach() | map() |
---|---|---|---|
語法 | for (let i = 0; …) | array.forEach(…) | array.map(…) |
回傳值 | 無 | 無 (undefined) | 新陣列 |
可否中斷 | 是 (break, continue) | 否 | 否 |
修改原陣列 | 可手動控制 | 通常用於修改,但不回傳新陣列 | 不會修改(創建新陣列) |
適用對象 | 任何可迭代物件 | 陣列 | 陣列 |
主要用途 | 高度控制,處理通用迭代 | 對每個元素執行副作用 | 將每個元素轉換為新形式,生成新陣列 |
1. map()
使用情境:將陣列中的每個元素轉換為新形式,生成新陣列。
蘋果 🍎 -> 包裝機 📦 -> 貼標的蘋果 🍎 💰
const arr = [1, 2, 3];
const newArr = arr.map((item) => {
return item * 2;
});
console.log(newArr);
* 陣列的值可能為 null,使用 filter()
、 ?.
來避免拋出錯誤。
練習 12-1:Mega Mart 寄送賀卡
- customers 陣列包涵所有 customer 物件
- 使用 map() 產生賀卡,包涵
cursomer.firstName
,cursomer.lastName
,cursomer.address
const customers = [
{
firstName: "John",
lastName: "Doe",
address: "123 Main St",
habbit: "reading",
},
{
firstName: "Jane",
lastName: "Smith",
address: "456 Main St",
habbit: "reading",
},
{
firstName: "Jim",
lastName: "Beam",
address: "789 Main St",
habbit: "reading",
},
];
const cards = "YOUR ANSWER HERE";
2. filter()
使用情境:過濾陣列中的元素,生成新陣列。
一籃蘋果 🍎 🍏 -> 篩選 🔍 -> 好蘋果 🍎
- 業務情境: Mega Mart 想要與高消費力的顧客建立關係,因此需要過濾出高消費力的顧客。
- 總顧客 10 人 -> 高消費 4 人 / 低消費 6 人
function selectBestCustomers(customers) {
return customers.filter((customer) => {
return customer.total > 1000;
});
}
如何整理過於複雜得篩選邏輯:回呼函式!
業務邏輯:「最近一年內有交易、總金額超過 $1000、且 email 有驗證、VIP 等級為 gold 或 platinum 的顧客」
function isHighSpender(customer) {
return customer.total > 1000;
}
function isRecentBuyer(customer) {
const oneYearAgo = new Date();
oneYearAgo.setFullYear(oneYearAgo.getFullYear() - 1);
return new Date(customer.lastPurchaseDate) > oneYearAgo;
}
function hasVerifiedEmail(customer) {
return customer.emailVerified === true;
}
function isVipCustomer(customer) {
return ["gold", "platinum"].includes(customer.vipLevel);
}
function selectBestCustomers(customers) {
return customers.filter((customer) => {
return (
isHighSpender(customer) &&
isRecentBuyer(customer) &&
hasVerifiedEmail(customer) &&
isVipCustomer(customer)
);
});
}
練習 12-2:Mega Mart 寄測試信件給顧客清單中的 1/3 使用者
- 測試名單 testGroup:顧客 id 可以整除 3 的顧客
- 非測試名單:其餘顧客
const customers = [
{ id: 1, name: "John" },
{ id: 2, name: "Jane" },
{ id: 3, name: "Jim" },
{ id: 4, name: "Jill" },
{ id: 5, name: "Jack" },
];
const testGroup = customers.filter("YOUR ANSWER HERE");
const nonTestGroup = customers.filter("YOUR ANSWER HERE");
3. reduce()
使用情境:將陣列中的元素累積為一個值。
一堆水果 🍎 🍏 -> 變成果汁 🍹
function sum(numbers) {
return reduce(numbers, 0, function (total, number) {
return total + number;
});
}
/
// 將所有數字相乘
function product(numbers) {
return reduce(numbers, 1, function (total, number) {
return total * number;
});
}
reduce()
的常見用途:
功能 | 概念說明 | 應用場景 | 核心特點 | 實際例子 |
---|---|---|---|---|
復原 (Undo) | 透過累積歷史狀態,可以回到之前的任何狀態 | 文字編輯器、繪圖軟體、遊戲存檔 | 狀態堆疊、歷史記錄 | Word 的 Ctrl+Z 功能 |
重複 (Redo) | 在復原後,可以重新執行被復原的操作 | 與復原配合使用的前進功能 | 雙向狀態管理 | Word 的 Ctrl+Y 功能 |
時間移動除錯 | 記錄每一步的執行過程,可以跳到任意時間點 | 程式除錯、狀態分析、效能監控 | 時間軸記錄、快照 | Redux DevTools 的時間旅行 |
審計軌跡 | 完整記錄所有操作的詳細資訊 | 系統安全、合規檢查、操作追蹤 | 不可變記錄、完整性 | 銀行交易記錄、系統日誌 |
按編:這邊算是自己找的例子,不在讀書會及書中提及。
// ==========================================
// 1. 復原 (Undo) - 純函數式文字編輯器
// ==========================================
// 純函數:執行單一編輯操作
const applyEdit = (content, edit) => {
switch (edit.type) {
case "INSERT":
return (
content.slice(0, edit.position) +
edit.text +
content.slice(edit.position)
);
case "DELETE":
return (
content.slice(0, edit.position) +
content.slice(edit.position + edit.length)
);
case "REPLACE":
return (
content.slice(0, edit.position) +
edit.newText +
content.slice(edit.position + edit.oldText.length)
);
default:
return content;
}
};
// 純函數:建立編輯歷史狀態
const createEditHistory = (edits, initialContent = "") => {
return edits.reduce(
(history, edit) => {
const previousContent = history[history.length - 1];
const newContent = applyEdit(previousContent, edit);
return [...history, newContent];
},
[initialContent]
);
};
// 純函數:復原操作
const undo = (history, currentIndex) => ({
content: history[Math.max(0, currentIndex - 1)],
newIndex: Math.max(0, currentIndex - 1),
});
// 純函數:重複操作
const redo = (history, currentIndex) => ({
content: history[Math.min(history.length - 1, currentIndex + 1)],
newIndex: Math.min(history.length - 1, currentIndex + 1),
});
// 使用範例
const edits = [
{ type: "INSERT", position: 0, text: "Hello" },
{ type: "INSERT", position: 5, text: " World" },
{ type: "REPLACE", position: 6, oldText: "World", newText: "JavaScript" },
];
const history = createEditHistory(edits);
console.log("編輯歷史:", history);
// ['', 'Hello', 'Hello World', 'Hello JavaScript']
const undoResult = undo(history, 3);
console.log("復原後:", undoResult);
// { content: 'Hello World', newIndex: 2 }
// ==========================================
// 2. 重複 (Redo) - 純函數式狀態管理
// ==========================================
// 純函數:建立狀態管理器
const createStateManager = (actions, initialState = null) => {
return actions.reduce(
(stateHistory, action) => {
const currentState = stateHistory.states[stateHistory.states.length - 1];
const newState = action.reducer(currentState, action.payload);
return {
states: [...stateHistory.states, newState],
actions: [...stateHistory.actions, action],
currentIndex: stateHistory.states.length,
};
},
{
states: [initialState],
actions: [],
currentIndex: 0,
}
);
};
// 純函數:執行復原/重複操作
const timeTravel = (stateManager, targetIndex) => ({
...stateManager,
currentIndex: Math.max(
0,
Math.min(stateManager.states.length - 1, targetIndex)
),
currentState:
stateManager.states[
Math.max(0, Math.min(stateManager.states.length - 1, targetIndex))
],
});
// 使用範例 - 購物車狀態管理
const cartActions = [
{
type: "ADD_ITEM",
payload: { id: 1, name: "iPhone", price: 999 },
reducer: (state, item) => ({
...state,
items: [...(state?.items || []), item],
total: (state?.total || 0) + item.price,
}),
},
{
type: "ADD_ITEM",
payload: { id: 2, name: "MacBook", price: 1999 },
reducer: (state, item) => ({
...state,
items: [...state.items, item],
total: state.total + item.price,
}),
},
{
type: "REMOVE_ITEM",
payload: { id: 1 },
reducer: (state, payload) => {
const newItems = state.items.filter((item) => item.id !== payload.id);
const newTotal = newItems.reduce((sum, item) => sum + item.price, 0);
return { ...state, items: newItems, total: newTotal };
},
},
];
const cartHistory = createStateManager(cartActions, { items: [], total: 0 });
console.log("購物車狀態歷史:", cartHistory.states);
// 復原到第1步
const undoToStep1 = timeTravel(cartHistory, 1);
console.log("復原到第1步:", undoToStep1.currentState);
// 重複到第2步
const redoToStep2 = timeTravel(cartHistory, 2);
console.log("重複到第2步:", redoToStep2.currentState);
// ==========================================
// 3. 時間移動除錯 - 純函數式執行追蹤
// ==========================================
// 純函數:建立除錯快照
const createDebugSnapshot = (
step,
operation,
input,
output,
metadata = {}
) => ({
step,
timestamp: new Date().toISOString(),
operation,
input,
output,
metadata,
performance: {
executionTime: metadata.executionTime || 0,
memoryUsage: metadata.memoryUsage || 0,
},
});
// 純函數:執行可追蹤的操作序列
const executeWithTimeTravel = (operations, initialData) => {
return operations.reduce(
(debugState, operation, index) => {
const startTime = performance.now();
const result = operation.fn(debugState.currentData, operation.params);
const endTime = performance.now();
const snapshot = createDebugSnapshot(
index + 1,
operation.name,
debugState.currentData,
result,
{
params: operation.params,
executionTime: endTime - startTime,
}
);
return {
currentData: result,
snapshots: [...debugState.snapshots, snapshot],
totalExecutionTime:
debugState.totalExecutionTime + (endTime - startTime),
};
},
{
currentData: initialData,
snapshots: [],
totalExecutionTime: 0,
}
);
};
// 純函數:跳轉到特定時間點
const jumpToSnapshot = (debugState, stepNumber) => {
const targetSnapshot = debugState.snapshots.find(
(s) => s.step === stepNumber
);
return targetSnapshot ? targetSnapshot.output : debugState.currentData;
};
// 使用範例 - 數據處理管道
const dataOperations = [
{
name: "FILTER_ACTIVE_USERS",
fn: (users) => users.filter((user) => user.active),
params: { condition: "active === true" },
},
{
name: "MAP_USER_SCORES",
fn: (users) => users.map((user) => ({ ...user, score: user.points * 1.5 })),
params: { multiplier: 1.5 },
},
{
name: "SORT_BY_SCORE",
fn: (users) => [...users].sort((a, b) => b.score - a.score),
params: { order: "desc" },
},
{
name: "TAKE_TOP_5",
fn: (users) => users.slice(0, 5),
params: { limit: 5 },
},
];
const initialUsers = [
{ id: 1, name: "Alice", active: true, points: 100 },
{ id: 2, name: "Bob", active: false, points: 80 },
{ id: 3, name: "Charlie", active: true, points: 120 },
{ id: 4, name: "Diana", active: true, points: 90 },
{ id: 5, name: "Eve", active: true, points: 110 },
];
const debugResult = executeWithTimeTravel(dataOperations, initialUsers);
console.log("執行結果:", debugResult.currentData);
console.log("執行快照:", debugResult.snapshots);
// 跳轉到第2步查看狀態
const step2State = jumpToSnapshot(debugResult, 2);
console.log("第2步狀態:", step2State);
// ==========================================
// 4. 審計軌跡 - 純函數式操作日誌
// ==========================================
// 純函數:建立審計記錄
const createAuditEntry = (
action,
user,
resource,
oldValue,
newValue,
metadata = {}
) => ({
id: crypto.randomUUID ? crypto.randomUUID() : Math.random().toString(36),
timestamp: new Date().toISOString(),
action,
user: {
id: user.id,
name: user.name,
role: user.role,
ip: user.ip || "unknown",
},
resource: {
type: resource.type,
id: resource.id,
name: resource.name,
},
changes: {
before: oldValue,
after: newValue,
},
metadata: {
userAgent: metadata.userAgent || "unknown",
sessionId: metadata.sessionId || "unknown",
...metadata,
},
hash: `${action}-${user.id}-${resource.id}-${Date.now()}`,
});
// 純函數:處理業務操作並生成審計軌跡
const executeBusinessOperations = (operations, initialState) => {
return operations.reduce(
(auditState, operation) => {
const oldValue = auditState.currentState[operation.resourceId];
const newValue = operation.transformer(oldValue, operation.payload);
const auditEntry = createAuditEntry(
operation.action,
operation.user,
operation.resource,
oldValue,
newValue,
operation.metadata
);
return {
currentState: {
...auditState.currentState,
[operation.resourceId]: newValue,
},
auditTrail: [...auditState.auditTrail, auditEntry],
};
},
{
currentState: initialState,
auditTrail: [],
}
);
};
// 純函數:查詢審計軌跡
const queryAuditTrail = (auditTrail, filters = {}) => {
return auditTrail.filter((entry) => {
return Object.entries(filters).every(([key, value]) => {
if (key === "user") return entry.user.id === value;
if (key === "action") return entry.action === value;
if (key === "resource") return entry.resource.type === value;
if (key === "dateFrom")
return new Date(entry.timestamp) >= new Date(value);
if (key === "dateTo") return new Date(entry.timestamp) <= new Date(value);
return true;
});
});
};
// 使用範例 - 銀行帳戶操作審計
const bankOperations = [
{
action: "DEPOSIT",
user: {
id: "user123",
name: "John Doe",
role: "customer",
ip: "192.168.1.1",
},
resource: { type: "account", id: "acc001", name: "Checking Account" },
resourceId: "acc001",
payload: { amount: 1000 },
transformer: (currentBalance, payload) =>
(currentBalance || 0) + payload.amount,
metadata: { branch: "downtown", teller: "jane_smith" },
},
{
action: "WITHDRAW",
user: {
id: "user123",
name: "John Doe",
role: "customer",
ip: "192.168.1.1",
},
resource: { type: "account", id: "acc001", name: "Checking Account" },
resourceId: "acc001",
payload: { amount: 200 },
transformer: (currentBalance, payload) => currentBalance - payload.amount,
metadata: { atm: "atm_007", card: "**** 1234" },
},
{
action: "TRANSFER",
user: {
id: "user123",
name: "John Doe",
role: "customer",
ip: "192.168.1.1",
},
resource: { type: "account", id: "acc001", name: "Checking Account" },
resourceId: "acc001",
payload: { amount: 300, toAccount: "acc002" },
transformer: (currentBalance, payload) => currentBalance - payload.amount,
metadata: { transferType: "internal", recipient: "acc002" },
},
];
const bankAudit = executeBusinessOperations(bankOperations, { acc001: 0 });
console.log("帳戶最終狀態:", bankAudit.currentState);
console.log("完整審計軌跡:", bankAudit.auditTrail);
// 查詢特定用戶的操作記錄
const userOperations = queryAuditTrail(bankAudit.auditTrail, {
user: "user123",
});
console.log("用戶 user123 的操作記錄:", userOperations);
// 查詢所有轉帳操作
const transferOperations = queryAuditTrail(bankAudit.auditTrail, {
action: "TRANSFER",
});
console.log("所有轉帳操作:", transferOperations);
// ==========================================
// 額外:組合式使用範例
// ==========================================
// 結合四種功能的完整狀態管理器
const createCompleteStateManager = (actions, initialState) => {
return actions.reduce(
(manager, action, index) => {
const startTime = performance.now();
const oldState = manager.states[manager.states.length - 1];
const newState = action.reducer(oldState, action.payload);
const endTime = performance.now();
// 建立快照 (時間移動除錯)
const snapshot = createDebugSnapshot(
index + 1,
action.type,
oldState,
newState,
{ executionTime: endTime - startTime }
);
// 建立審計記錄
const auditEntry = createAuditEntry(
action.type,
action.user || { id: "system", name: "System" },
action.resource || {
type: "state",
id: "app",
name: "Application State",
},
oldState,
newState
);
return {
// 狀態歷史 (支援復原/重複)
states: [...manager.states, newState],
currentIndex: manager.states.length,
// 除錯快照 (時間移動除錯)
snapshots: [...manager.snapshots, snapshot],
// 審計軌跡
auditTrail: [...manager.auditTrail, auditEntry],
// 效能統計
totalExecutionTime: manager.totalExecutionTime + (endTime - startTime),
};
},
{
states: [initialState],
currentIndex: 0,
snapshots: [],
auditTrail: [],
totalExecutionTime: 0,
}
);
};
// 使用範例
const appActions = [
{
type: "LOGIN",
payload: { userId: "user123", sessionId: "sess_abc" },
reducer: (state, payload) => ({
...state,
user: payload,
isLoggedIn: true,
}),
user: { id: "user123", name: "John Doe", role: "user" },
},
{
type: "UPDATE_PROFILE",
payload: { name: "John Smith", email: "john.smith@example.com" },
reducer: (state, payload) => ({
...state,
user: { ...state.user, ...payload },
}),
user: { id: "user123", name: "John Doe", role: "user" },
},
];
const completeManager = createCompleteStateManager(appActions, {
user: null,
isLoggedIn: false,
});
console.log("完整狀態管理器:", {
currentState: completeManager.states[completeManager.currentIndex],
stateHistory: completeManager.states,
debugSnapshots: completeManager.snapshots,
auditTrail: completeManager.auditTrail,
performance: { totalTime: completeManager.totalExecutionTime },
});
章節提問
- 解決 null 的方法?
- 使用不具有 null 的值
- 使用 filter 過濾掉 null
- map() 和 filter() 的差異?
- map() 會將每個元素轉換為新形式,生成新陣列。
- filter() 會過濾掉不符合條件的元素,生成新陣列。
Ch13. 串連函數式工具
章節重點
- 將函數式工具 串起來 成為多步驟的鏈式操作 (chain) 來處理複雜的任務
- 每個步驟是一項簡單操作,易讀、易撰寫。
- 為了寫函式鏈的下一步,有時需先產生新資料或擴增既有的資料。
- 將隱性訊息,表示成顯性資料。
鏈式操作:將多個函數串接在一起,形成一個新的函數。
當業務情境變得複雜,需要將 map()、filter()、reduce() 串接在一起,形成一個新的函數。
13.1 計算高消費力的最高消費金額
- 總顧客: 10 人 -> 高消費 4 人 / 低消費 6 人
- 需要找到高消費力,且消費次數 3 次以上的顧客,並計算其最高消費金額
function biggestPurchaseBestCustomers(customers) {
var bestCustomers = filter(customers, function (customer) {
return customer.purchases.length > 3;
});
var biggestPurchases = map(bestCustomers, function (customer) {
return reduce(
customer.purchases,
{ total: 0 },
function (biggestSoFar, purchase) {
if (biggestSoFar.total > purchase.total) {
return biggestSoFar;
} else {
return purchase;
}
}
);
});
return biggestPurchases;
}
問題:巢狀結構不好理解
- 命名高階函數
function biggestPurchaseBestCustomers(customers) {
var bestCustomers = selectBestCustomers(customers); // 拆成高階函數
var biggestPurchases = getBiggestPurchase(bestCustomers); // 拆成高階函數
return biggestPurchases;
}
function isBestCustomer(customer) {
return customer.purchases.length > 3;
}
function findBiggestPurchase(biggestSoFar, purchase) {
if (biggestSoFar.total > purchase.total) {
return biggestSoFar;
} else {
return purchase;
}
}
function getBiggestPurchase(customer) {
return reduce(customer.purchases, { total: 0 }, findBiggestPurchase);
}
- 缺點:如果直接命名成高階函數,無法重複使用。
- 依照步驟,命名回呼函數
function biggestPurchaseBestCustomers(customers) {
var bestCustomers = filter(customers, isBestCustomer);
var biggestPurchases = map(bestCustomers, getBiggestPurchase);
return biggestPurchases;
}
// 將複雜的業務邏輯拆成回呼函數,供 filter()、map()、reduce() 使用
function isBestCustomer(customer) {
return customer.purchases.length > 3;
}
function findBiggestPurchase(biggestSoFar, purchase) {
if (biggestSoFar.total > purchase.total) {
return biggestSoFar;
} else {
return purchase;
}
}
function getBiggestPurchase(customer) {
return reduce(customer.purchases, { total: 0 }, findBiggestPurchase);
}
13.5 找出只消費過一次的顧客,以陣列回傳顧客的 email
- 可以使用哪些 javascript 高階函數?
點我看答案
答案是:你可以用 map()
、filter()
- 完整程式碼
點我看答案
function getFirstTimersEmails(customers) {
var firstTimers = filter(customers, function (customer) {
return customer.purchases.length === 1;
});
var firstTimersEmails = map(firstTimers, function (customer) {
return customer.email;
});
return firstTimersEmails;
}
流融合(stream fusion)
var names = map(customers, getFullName);
var nameLengths = map(names, getLength);
流融合:
var nameLengths = map(map(customers, getFullName), getLength);
13.6 當 for loop 難以重構時
- 理解並重寫
- 先讀懂 for loop 的目的
- 再用函數式工具重構
- 依照線索進行重構、細化步驟
- 無法讀懂 for loop 的邏輯
- 根據 for loop 展示的低階訊息
- 轉換成函式鏈
情境問題: 已經有 for loop,要如何重構?
var answer = [];
var window = 5;
for (var i = 0; i < numbers.length; i++) {
var sum = 0;
var count = 0;
for (var w = 0; w < window; w++) {
sum += numbers[i + w];
count++;
}
answer.push(sum / count);
}
如何重構?
點我看提示
- 外層迴圈:外層迴圈將 array 陣列的每個元素取出做處理
- 使用 map()
- 內層迴圈:內層迴圈將 array 陣列的每個元素累進成一個值
- 使用 reduce()
重構後的程式碼
// 可以重複使用的函數
function range(start, end) {
var ret = [];
for (var i = start; i < end; i++) {
ret.push(i);
}
return ret;
}
// 基於 functional programming 的程式碼
var window = 5;
var indices = range(0, numbers.length);
var subarrays = map(indices, function (i) {
return range(i, i + window);
});
var answer = map(subarrays, average);
走訪丟失的購物車
var itemAdded = ["shirt", "shoes", "pants","shirts"];
var shippingCart = reduce(itemAdded, {}, addOne);
function addOne(shippingCart, item) {
if(!cart[item]){
return additem...
}
}
各種練習題們
13.13 情境 :顧客購物車資料遺失,網站以陣列形式記錄所有曾出現過的商品
var itemAdded = ["shirt", "shoes", "pants", "shirts"];
情境問題:使用者的資料丟失,是否可以用上述的陣列,重建購物車?
點我看答案
var itemAdded = ["shirt", "shoes", "pants", "shirts"];
var shippingCart = reduce(itemAdded, {}, addOne);
function addOne(shippingCart, item) {
if(!cart[item]){
return additem...
}
}
練習 13-16~19:棒球練習賽系列
情境問題:Mega Mart 每年都會派員參加職業棒球比賽,教練會根據員工名單生成員工建議位置清單,並將評估資料存入 evaluations 陣列。
┌─────────────────┐
│ Megamart 員工名單 │
└─────────┬───────┘
│
↓
┌─────────────────┐
│ recommendations │
│ (推薦名單) │
└─────────┬───────┘
│
↓
┌─────────────────┐
│ evaluations 陣列 │
│ (評估資料) │
└─────────┬───────┘
│
↓
┌─────────────────┐ ┌─────────────────┐
│ 參賽人員名單 │────→│ 先發選手名單 │
└─────────────────┘ └─────────┬───────┘
↑
│
┌─────────────────┐
│ 教練給每個人的 │
│ 建議 │
└─────────────────┘
var evaluations = [
{
name: "John",
position: "catcher",
score: 2,
},
{
name: "Jane",
position: "pitcher",
score: 3,
},
{
name: "Jim",
position: "pitcher",
score: 1,
},
];
var roaster = {
pitcher: "Jane",
catcher: "John",
};
- 如何根據 evaluations 陣列,生成 roster 物件?
- 其他問題略,請見書本。
章節提問
- 麼是流融合?
- 將 map、filter 等涵式串接再一起,避免過多中繼程式