一個函式應該只做一件事,並且做好它。
如果你的函式既要處理計算,又要跟外部世界(螢幕、檔案、網路)打交道,那它就是散發臭味了。
讓計算歸計算,I/O 歸邊界。
「副作用」指的是函式在執行過程中,做了除了「回傳一個值」以外的任何事。
像一個廚師在料理過程中,自己跑出廚房的行為。
在程式碼中,「副作用」就是函式跨越了自身的邊界,與它控制範圍外的世界進行了互動。
一個好的函式, 應該讓廚師專注在自己的廚房裡。 他的任務是「料理」。
你把所有食材(參數)一次性給他,他就在廚房裡專心處理這些食材,最後端出一道菜(回傳值)。
他不管食材是從哪裡來的,只專心做菜。這個過程是可預測的:同樣的食材,一定會做出同樣的菜。
只要你的函式有這些行為,它就變得不可預測。
同樣的輸入,可能因為網路斷線或檔案權限問題而產生完全不同的結果,這就是發臭的開始。
可以將系統劃分為不同的層次,建立清晰的資料流(Data Pipeline),來管理副作用:
核心層 (Core):主要邏輯的資料轉換與運算,只包含純函式 (Pure Functions),完全不觸碰外部。
邊界層 (Boundary):處理所有 I/O 操作與外部系統的整合。這一層是副作用的集中地,也統一進行監控與錯誤處理。
組裝層 (Composition):負責串連核心層與邊界層,將依賴注入,協調完整的業務流程。
// 🔴 髒臭味:商業邏輯、檔案讀取、檔案寫入、資料格式化,全綁在一起。
function generateVipDiscountReport(inputFile, outputFile) {
try {
// --- 副作用:讀取檔案 (I/O) ---
const data = fs.readFileSync(inputFile, 'utf8');
const users = data.split('\n').map(line => {
const [name, level, amount] = line.split(',');
return { name, level, amount: Number(amount) };
});
let reportLines = [];
for (const user of users) {
// --- 主要商業邏輯 ---
if (user.level === 'VIP' && user.amount > 1000) {
const discount = user.amount * 0.1; // 10% 折扣
// --- 資料格式化邏輯 ---
reportLines.push(`${user.name},${discount}`);
}
}
// --- 副作用:寫入檔案 (I/O) ---
fs.writeFileSync(outputFile, reportLines.join('\n'));
console.log('Report generated.');
} catch (error) {
console.error('Error generating report:', error);
}
}
這段程式碼無法測試、無法重用、職責混亂。
如果需求改成從資料庫讀取,你就得重寫。
如果折扣規則改變,還要來碰一個處理檔案的函式。
// 🟢 好品味:一個函式只做一件事
const fs = require('fs');
// 1. 核心層 主要商業邏輯 (純函式)
// - 它不認識檔案系統。它只懂資料。這才是你該測試的地方。
function processUserData(users) {
return users
.filter(u => u.level === 'VIP' && u.amount > 1000)
.map(u => ({
name: u.name,
discount: u.amount * 0.1,
}));
}
// 2. 邊界層工具 (處理 I/O)
// - 這些函式很笨,只負責跟外部世界溝通。
function readUsersFromFile(filePath) {
const data = fs.readFileSync(filePath, 'utf8');
return data.split('\n').map(line => {
const [name, level, amount] = line.split(',');
return { name, level, amount: Number(amount) };
});
}
function writeReportToFile(filePath, processedData) {
const reportLines = processedData.map(d => `${d.name},${d.discount}`);
fs.writeFileSync(filePath, reportLines.join('\n'));
}
// 3. 組裝層 膠水函式 (Orchestrator)
// - 協調主要與邊界,完成整個流程。
function generateReport(inputFile, outputFile) {
try {
const users = readUsersFromFile(inputFile);
const processedData = processUserData(users);
writeReportToFile(outputFile, processedData);
console.log('Report generated successfully.');
} catch (error) {
console.error('Error in report generation process:', error);
}
}
測試變得更簡單:你可以用一百種假資料陣列去測試 processUserData
,確保所有商業邏輯都正確無誤。整個過程不需要模擬 (mock) 任何東西。
可以重複利用:明天,資料源變成資料庫?很好,你只需要寫一個 readUsersFromDatabase()
來替換 readUsersFromFile()
。主要商業邏輯 processUserData
一行都不用改。
除錯更直接:報告裡的數字錯了?問題 100% 在 processUserData
。報告檔案沒產生?問題 100% 在 writeReportToFile
。問題被精準地隔離了。
別想太多,就兩步:
把所有計算邏輯、商業規則抓出來,放進一個新的、純粹的函式裡。
結束了。
所有外部世界的東西(資料庫、設定檔、時間)都應該作為參數傳進去,而不是在函式內部去讀取。
真正的問題只有一個:「這個函式是不是只做一件事?」。
如果不是,就去修改它。