副作用是函式在承諾只做一件事時,卻偷偷做了別的事——修改全域變數、改動隱藏狀態、初始化不該初始化的系統。函式名字說一件事,實際卻多做了第二件;這第二件事看不見,於是產生時序性耦合 (Temporal Coupling) 與難以除錯的 Bug。
🧠 Why It's a Problem
- 它是隱藏的第二件事:函式承諾只做一件事,副作用卻讓它偷偷做了另一件。名字沒說、簽章沒說,讀者只能靠踩雷才發現——這正是 只做一件事 的反面。
- 製造時序性耦合:一旦函式偷偷初始化了某個系統,它就只能在「需要那次初始化」的時機被呼叫。呼叫順序被綁死,但這個約束哪裡都沒寫下來,換個順序就爆。
- 難以除錯:全域變數與隱藏狀態被悄悄改動,Bug 的成因散落在呼叫之外的地方。你盯著出錯那一行,真正的元兇卻在幾次呼叫之前那個「看起來無害」的函式裡。
- 輸出型參數違反讀者預期:讀者預期參數是輸入 (Input)。若參數被拿來輸出結果(改內容後帶回),會造成認知斷層——每個參數都得回頭確認「這到底是進去的還是出來的」。
WARNING
最危險的副作用藏在名字看似「純查詢」的函式裡。呼叫端以為只是在檢查一個條件,卻不知每次呼叫都改了隱藏狀態。至於「查詢卻偷改狀態」這個切面,交給 分離命令與查詢 專門處理。
⚖️ Case Study
前 — 一個檢查函式偷偷初始化系統(時序性耦合)
let currentSession = null;
// 名字承諾「檢查密碼」,卻偷偷初始化了 session
function checkPassword(user, password) {
const valid = user.hashedPassword === hash(password);
if (valid) {
// 隱藏的副作用:呼叫這個函式的人並不知道它會建立 session
currentSession = { user, startedAt: Date.now() };
}
return valid;
}
// 呼叫端只想「問一個問題」,卻被綁進了 session 初始化的時序裡:
// 這行必須在正確的時機呼叫,否則 session 狀態就錯了——但約束哪裡都沒寫
if (checkPassword(user, input)) {
render(dashboard);
}checkPassword 名為查詢、實為命令:它多做了「初始化 session」這第二件事,讓自己只能在需要初始化的時機被呼叫。這就是時序性耦合——換個順序、或在測試裡多問一次,隱藏狀態就被污染。
後 — 把隱藏的第二件事攤到明處
// 純查詢:只回答問題,不碰任何隱藏狀態,可安全反覆呼叫
function isPasswordValid(user, password) {
return user.hashedPassword === hash(password);
}
// 命令:初始化 session 是一件獨立、命名清楚、由呼叫端明確發起的事
function startSession(user) {
return { user, startedAt: Date.now() };
}
// 呼叫端讀起來意圖分明,時序寫在臉上而非藏在函式裡
if (isPasswordValid(user, input)) {
const session = startSession(user);
render(dashboard, session);
}檢查歸檢查、初始化歸初始化。沒有隱藏狀態、沒有被綁死的呼叫順序,isPasswordValid 也能隨意多問幾次而不留痕跡。
前 — 輸出型參數:參數被拿來帶回結果
// report 是「輸入」還是「輸出」?讀者得看完內文才知道它被改了
function appendFooter(report) {
report.lines.push("--- end of report ---");
}
// 呼叫端看到這行,無從得知 report 已被就地修改
appendFooter(report);讀者預期 report 是餵進去的輸入,實際上它被當成輸出通道就地改動——認知斷層由此而生。
後 — 回傳新結果,取代輸出型參數
// 參數是輸入,結果從回傳值出來,方向一目了然
function withFooter(report) {
return {
...report,
lines: [...report.lines, "--- end of report ---"],
};
}
const finalReport = withFooter(report);參數只進不出,結果走回傳值。呼叫端一眼就看懂資料的流向。
TIP
判準:如果一個函式改動了「它的區域變數與回傳值以外」的任何東西——全域變數、傳入物件、系統狀態——就停下來問,這是不是它名字沒承諾的第二件事。至於輸出型參數的更多情況(參數數量、物件參數)交給 函式參數。
🔑 Takeaways
- 副作用 = 函式承諾只做一件事,卻偷偷做了第二件(改全域、改隱藏狀態、亂初始化)。
- 隱藏的初始化會製造時序性耦合:函式被綁進特定呼叫順序,而這約束沒寫在任何地方。
- 全域與隱藏狀態被悄悄改動 → Bug 成因散落在呼叫之外,極難除錯。
- 避免輸出型參數:讀者預期參數是輸入,用參數帶回結果會造成認知斷層——改用回傳值。
- 相關切面:只做一件事(副作用是隱藏的第二件事)、分離命令與查詢(查詢偷改狀態)。
No notes yet — jot your takeaways or Q&A here.