避免副作用 (Avoid Side Effects)

★★★★Draft

FromClean Code

📚 From the Books

避免副作用 (Avoid Side Effects)Functions

副作用是函式在承諾只做一件事時,卻偷偷做了別的事——修改全域變數、改動隱藏狀態、初始化不該初始化的系統。函式名字說一件事,實際卻多做了第二件;這第二件事看不見,於是產生時序性耦合 (Temporal Coupling) 與難以除錯的 Bug。

🧠 Why It's a Problem

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

✍️ My Notes

No notes yet — jot your takeaways or Q&A here.

📖 Further Reading