📌 30 秒摘要(Layer 3)

程式碼被讀的次數遠多於被寫的次數,所以可讀性就是生產力。整潔的程式碼靠一堆小紀律堆出來:取有意義的名字、函式短小只做一件事、少而精的註解、清楚的錯誤處理。核心心態是童子軍規則——每次離開時讓程式碼比你來時更乾淨一點。

🗺 心智圖(Canvas)

程式碼整潔之道

程式碼整潔之道

🧭 心態:童子軍規則

🏷 命名

⚙️ 函式

💬 註解

⚠️ 錯誤處理

🧪 類別、邊界與測試

離開時讓程式碼 比你來時更乾淨一點

你寫程式的時間, 遠少於你讀程式的時間

🎯 什麼情境該想到我

分工:這張是語言無關的命名判準與流程。Go 特有的規則(套件名幫你省字、Getter 不加 Get、-er 介面、縮寫全大寫)→ 工具-Go命名慣例;把爛名字改好的重構步驟 → 神秘命名。

當你在替變數/函式/類別取名字,或看到一堆 d、tmp、data 看不懂時。

⚙️ 怎麼用

  1. 名字要能回答:它是什麼、為何存在、怎麼用。看到名字就不用再看註解。
  2. 用可讀、可搜尋的名字:elapsedTimeInDays 勝過 d;避免單字母(迴圈計數例外)。
  3. 別用誤導性名字、別加型別雜訊(匈牙利命名法過時)。
  4. 類別用名詞、函式用動詞;同一概念用同一個字(別 fetch/get/retrieve 混用)。

★ 名字長度 ∝ 使用處與宣告處的距離

這是最常被誤解的一條——「名字要有意義」不等於「名字要長」:

作用域讀者看到名字時,宣告在哪?合適的名字
3 行的迴圈就在上一行i、k, v、r(for _, r := range rows)
20 行的函式區域變數同一個螢幕total、buf、cfg
套件層級/匯出可能在另一個檔案、另一個 repoDefaultRetryPolicy、ErrQuotaExceeded

同一個概念在小範圍用長名字反而是雜訊:for index, currentRowValue := range rows 比 for i, r := range rows 難讀。所以命名問題有一半要靠 工具-縮小變數作用域 解決——作用域縮小了,短名字就合法了。

★ 命名的四步流程(卡住時照做)

  1. 先說出它是什麼:用一句完整的話描述它,「這是還沒被付款的訂單的數量」。
  2. 從句子裡挑名詞/動詞:unpaidOrderCount。
  3. 刪掉讀者從上下文就知道的字:在 package order 裡 → unpaidCount;在 func (s *Stats) 的方法裡已經很清楚 → 可能只要 unpaid。
  4. 說出來測試:唸給同事聽,對方能不能不看程式碼就猜到它是什麼?會不會跟另一個東西搞混?

第 1 步說不出來,就是設計問題而非命名問題——這個東西可能同時做了兩件事,或根本不該存在。

★ 一眼可認的壞名字

壞名字問題改法
data、info、item、obj什麼都是,等於什麼都沒說說它是什麼資料:invoice、quote
Manager、Helper、Util、Processor職責不明的收納箱,會一直長大拆開,各自用具體動作命名
flag、status 配 true/false讀呼叫端完全看不懂isRetryable、hasShipped
notDisabled、!isNotValid雙重否定一律用正向:isEnabled、isValid
timeoutMs int、sizeKB int單位靠名字記,會被搞錯用型別:Go time.Duration;TS 用 branded type 或物件 { ms: number }
同一概念 fetch/get/load/retrieve 混用讀者會以為它們行為不同全專案一個動詞對一個語義(例如 Get = 讀快取、Fetch = 走網路),寫進 工具-通用語言

★ 改名要靠工具,不要靠搜尋取代

  • Go:gopls 的 rename(編輯器 F2)以型別資訊找出所有引用,連跨套件、介面實作都會改;sed 會連字串和註解裡的同名字一起改爛。
  • TS:編輯器的 Rename Symbol 同理。
  • 匯出的名字改名 = 破壞 API。對外套件先加新名字、舊名字標 // Deprecated: use X. 再過一個版本刪。

🧪 我實際套用的紀錄

  • 2026-07-14:(待填)

⚠️ 注意

  • 想不到好名字,往往代表那段職責不清 → 該先釐清設計,命名是設計的鏡子。

  • 別為了「可搜尋」把所有名字都拉長:可搜尋性只對大作用域的名字有意義;區域變數沒人會全域搜尋。

  • 縮寫只用團隊公認的:ctx、req、cfg 大家都懂;calcAmtWTx 只有作者懂。

🔗 相關工具

🎯 什麼情境該想到我

分工:這張是「函式該多大、該做幾件事」的判準。怎麼偵測過長函式、Go 裡哪三種長函式不要拆 → 過長函式;拆的機械步驟 → 提煉函式;參數太多的 Go 解法(option struct / variadic options)→ 工具-Go函式參數太多怎麼辦。

當一個函式很長、要捲很久、或你發現它「先…再…然後…」做很多事時。

⚙️ 怎麼用

  1. 只做一件事:函式內的敘述應在同一抽象層次;「還能再被有意義地拆出一個函式」就代表它不只做一件事。
  2. 越短越好:理想幾行;巢狀縮排別太深。
  3. 參數越少越好:0–2 個最佳,3 個以上考慮包成物件;避免旗標參數(傳 boolean 通常代表函式做了兩件事)。
  4. 無副作用:函式名說做什麼,就別偷偷做別的。

★ 「一件事」的可操作判準:單一抽象層次

「只做一件事」太模糊,實際能用的判準是:函式裡的每一行,都在同一個抽象層次上。

// ✗ 混了三層:業務流程、SQL 細節、字串格式
func CloseMonth(db *sql.DB, month time.Time) error {
	rows, err := db.Query(`SELECT id, amount FROM invoices WHERE month = $1 AND status = 'open'`, month)
	if err != nil { return err }
	defer rows.Close()
	var total int64
	for rows.Next() { /* … scan、累加 … */ }
	subject := fmt.Sprintf("[%s] 月結 %d 元", month.Format("2006-01"), total)
	return sendMail("finance@corp", subject)
}
 
// ✓ 讀起來是一份「步驟清單」,每一步的細節在下一層
func CloseMonth(ctx context.Context, repo InvoiceRepo, n Notifier, month time.Time) error {
	invoices, err := repo.OpenInvoices(ctx, month)
	if err != nil {
		return fmt.Errorf("load open invoices: %w", err)
	}
	return n.MonthClosed(ctx, month, sumAmount(invoices))
}

自我檢查:把函式唸出來,如果是「先 A、再 B、然後 C」,而 A/B/C 是同一層級的業務步驟 → 這是一件事(編排);如果中間夾著「然後開一個 for 迴圈把每列掃進 struct」→ 層次混了。

這也叫降層規則(step-down rule):程式從上往下讀,每個函式後面緊接著它呼叫的下一層函式,像讀文章的大綱→段落→細節。

★ 旗標參數:一個函式做兩件事的最明顯訊號

// ✗ 呼叫端:Render(doc, true) —— true 是什麼?
func Render(doc Doc, draft bool) string { if draft { … } else { … } }
 
// ✓ 兩件事就是兩個函式;共用部分抽成第三個
func RenderDraft(doc Doc) string { … }
func RenderFinal(doc Doc) string { … }

不能拆(兩條路徑 90% 相同)時,至少換成具名型別:Render(doc, ModeDraft),呼叫端讀得懂。

★ 命令與查詢分離(CQS)

一個函式要嘛回答問題(查詢,無副作用),要嘛改變狀態(命令),不要兩者都做。

  • func (c *Cart) Total() int 如果順便把過期商品移掉 → 呼叫 Total() 兩次結果不同,測試、除錯、快取全亂。
  • Go 的慣例例外:m, ok := <-ch、v, loaded := m.LoadOrStore(k, v) 這類「原子地查+改」是為了併發正確性刻意合一,因為拆開就有 race。

★ 兩派的真實分歧(別當教條)

《程式碼整潔之道》Go 社群常見的做法
長度「越短越好」,幾行為佳線性、無分支的 50 行 handler 可以接受
理由函式名就是文件跳來跳去讀 10 個 3 行小函式,比從上讀到下更累
共識都同意:巢狀深、抽象層混雜、旗標參數是問題

真正的判準是讀者要同時記住多少東西(見 工具-管理複雜度),不是行數。拆出來的函式如果名字說不清、或只被呼叫一次且讀者一定得跳進去看,那就是拆過頭。

🧪 我實際套用的紀錄

  • 2026-07-14:(待填)

⚠️ 注意

  • 拆函式讓抽象層次一致,不是為短而短;配合 工具-提煉函式 手法安全地拆。

  • 「無副作用」在 Go 最常見的違規:名為 Validate/Check/Get 的函式偷偷寫入 log 以外的狀態(補預設值、改欄位)。要補預設值就叫 WithDefaults() 並回傳新值。

🔗 相關工具

🎯 什麼情境該想到我

分工:這張是「動手寫註解前的五秒判準」。四種註解的完整分類、Go 必寫的五類註解、最該刪的三種 → 註釋;Go doc comment 的格式慣例 → 工具-Go命名慣例。

當你正想加一段註解來「解釋」一段程式碼時,先停一下。

⚙️ 怎麼用

  1. 先試著用程式碼表達:把那段抽成一個命名清楚的函式,往往就不需要註解了。
  2. 註解是表達的失敗:能用命名/結構說清楚就別靠註解。
  3. 好註解:法律聲明、意圖說明、警告、TODO、對公開 API 的說明。
  4. 壞註解:多餘(複述程式碼)、誤導、被註解掉的死程式碼(直接刪,交給版本控制)、跟程式不同步的過時註解。

★ 寫註解前問四個問題(依序,前一題能解就不用往下)

#問題能解 → 做這個,不寫註解
1能不能換個名字說清楚?改名:d → elapsedDays;check() → isEligibleForRefund()
2能不能抽成函式,讓函式名說清楚?提煉:// 計算含稅價 下面那 6 行 → priceWithTax(order)
3能不能用型別說清楚?timeout int // 毫秒 → timeout time.Duration,單位跟著型別走,註解自然消失
4能不能用測試或範例說清楚?寫一個 test case 或 Go 的 Example 函式(見下)

四題都答「不能」——剩下的就是程式碼本身表達不了的東西,這時註解不是失敗,是必要:

  • 為什麼這樣寫(Why):「這裡刻意不用 sync.Pool:物件平均存活 > 1 個 GC 週期,pool 反而增加壓力(benchmark 見 #1234)」
  • 為什麼不那樣寫(Why not):擋住下一個人「好心」改回看起來比較直覺、但你已經踩過雷的寫法
  • 外部約束:「上游 API 對同一 key 每秒只准 1 次,所以這裡要 sleep」——這是程式碼外的事實
  • 不變式/前置條件:「呼叫前必須持有 mu」「items 必須已排序」

判準一句話:註解應該說程式碼說不出的事(why),而不是重述程式碼已經說了的事(what)。

★ 讓註解不會過時的唯一可靠方法:讓它被執行

「過時註解比沒有更糟」的根因是:編譯器和測試都不檢查註解。所以真正有效的做法是把「要說明的行為」搬到會被執行的地方:

// 放在 _test.go 裡;go test 會實際執行它,並比對 // Output: 之後的內容
func ExampleParseDuration() {
	d, _ := ParseDuration("1h30m")
	fmt.Println(d.Minutes())
	// Output: 90
}
  • 這段會出現在 godoc 當範例,又是測試——行為一改、輸出對不上,go test 就紅。註解做不到這件事。
  • 輸出順序不固定(例如走訪 map)時用 // Unordered output:。
  • 沒有 // Output: 的 Example 只編譯不執行——還是比註解強,至少 API 改名時會編譯失敗。
  • TS/JS 對應物:把用法寫成測試案例(it('parses 1h30m as 90 minutes', …)),測試名本身就是規格。

★ 註解的「維護成本帳」

每一行註解都是一份沒有編譯器幫你檢查的副本。程式碼改了 N 次,註解要被人記得同步 N 次;只要漏一次,它就從「說明」變成「誤導」。所以:

  • what 註解(重述程式碼):價值 ≈ 0,維護成本 > 0 → 負資產,刪。
  • why 註解:價值高,而且 why 很少跟著實作細節變 → 正資產,留。
  • TODO:只在附上 issue 編號時留(// TODO(#482): 改成批次查詢);沒編號的 TODO 通常永遠不會被做,golangci-lint 的 godox 可以把它們全列出來盤點。

🧪 我實際套用的紀錄

  • 2026-07-14:(待填)

⚠️ 注意

  • 註解不會隨程式碼自動更新 → 過時註解比沒有更糟。

  • 別把《程式碼整潔之道》的「註解是失敗」讀成「不准寫註解」:它針對的是 what 註解。公開 API 的文件註解、why 註解、法律聲明、警告都是它自己列為「好註解」的類別。

  • 被註解掉的程式碼一律刪。它不會被編譯、不會被測試,半年後沒人敢刪也沒人敢用;要找回來有 git log -S。

🔗 相關工具

🎯 什麼情境該想到我

分工:這張原本出自 Java 語境的《程式碼整潔之道》;Go 沒有例外,所以這裡講的是「原則怎麼翻譯到 Go 與 TS」。Go 錯誤處理的完整機制(%w、errors.Is/AsType/Join)→ 工具-Go現代錯誤處理;該包還是該轉、錯誤字串格式 → 工具-Go錯誤該包還是該轉。

當你的程式碼被一堆「檢查回傳值/錯誤碼」的 if 淹沒,或常被 null 炸到時。

⚙️ 怎麼用

  1. 用例外(Exception)而非回傳錯誤碼:讓正常流程乾淨,錯誤處理分離。
  2. 別回傳 null,也別傳 null 當參數:改回傳空集合、Optional,或丟例外。
  3. 例外訊息要有情境:說清楚哪裡、為什麼失敗。
  4. 把 try/catch 的主體抽成獨立函式,讓錯誤處理成為「一件事」。

★ 原則 vs 語法:哪些能帶走、哪些不能

書中原則背後的目的Go 的做法TS 的做法
用例外取代錯誤碼正常流程不被錯誤檢查淹沒不適用字面意思。Go 用 error 回傳值;用 guard clause(提早 return) 讓主流程靠左throw + 在邊界 try/catch;或回傳 Result<T, E>
例外要帶情境看錯誤訊息就知道哪裡、為何fmt.Errorf("load user %d: %w", id, err) 逐層加情境new Error("load user", { cause: err })(ES2022 cause)
把 try 主體抽成函式錯誤處理是「一件事」把「一串可能失敗的步驟」收進一個回傳 error 的函式,呼叫端只處理一次同書中做法
別回傳 null呼叫端忘了檢查就炸見下一節開 strictNullChecks,讓編譯器逼你處理 undefined
別用例外控制流程例外是給意外panic 只給程式錯誤(不該發生的狀態);可預期的失敗一律回 error別用 throw 做迴圈跳出或分支

Guard clause 就是 Go 版的「讓正常流程乾淨」:

func Ship(ctx context.Context, id OrderID) error {
	o, err := repo.Get(ctx, id)
	if err != nil {
		return fmt.Errorf("get order %s: %w", id, err)
	}
	if o.Status != Paid {
		return ErrNotPaid
	}
	// 走到這裡的都是「正常情況」,不用再縮排
	return carrier.Book(ctx, o.Address)
}

★ 「別回傳 null」在 Go 的四種對應

情況做法為什麼
回傳集合,查無資料回傳 nil slice 通常就好:len、range、append 對 nil 都安全但要注意 JSON:json.Marshal 把 nil slice 編成 null、空 slice 編成 [](Go 1.26.4 實測,2026-09-25)——API 回應要 [] 就得明確 make([]T, 0)
可能沒有值comma-ok:func Find(id) (User, bool)呼叫端被語法逼著看到 ok
找不到是一種錯誤回傳哨兵錯誤 ErrNotFound,呼叫端 errors.Is讓「找不到」與「DB 掛了」可以分開處理
指標可能為 nil能用值型別就用值型別;必須用指標時在文件註解寫明「可能為 nil」nil map 可讀不可寫:讀回零值、寫入會 panic

★ panic 與 recover 的邊界

  • panic 用在:「如果發生,代表程式本身有 bug」——陣列越界、不可能的 switch 分支、init 時設定檔缺必要欄位(regexp.MustCompile 這類 Must 函式就是這個語義)。
  • recover 只放在邊界:每個請求、每個 worker goroutine 的最外層,把 panic 轉成錯誤回應 + log,避免整個程序掛掉。
  • net/http 已經幫你 recover 了,但只到一半:它在「處理這條連線的 goroutine」裡 recover handler 的 panic 並記 log(http: panic serving …,見 Go 1.26.4 net/http/server.go);handler 裡自己 go func() 開的 goroutine 若 panic,沒有人接,整個程序直接結束。所以自己開的 goroutine 要自己包 recover,或改用會回收錯誤的 errgroup。

🧪 我實際套用的紀錄

  • 2026-07-14:(待填)

⚠️ 注意

  • 別用例外控制正常流程;例外是給「意外」用的。

  • Go 裡「處理 or 回傳,只做一件」:同一個錯誤又 log 又 return,上層再 log 一次,最後一個錯誤在 log 裡出現三次。詳細規則見 工具-Go錯誤該包還是該轉。

  • TS 的 catch (e) 型別是 unknown(strict 下):先用 e instanceof Error 收窄再讀 .message,別假設它一定是 Error——JS 允許 throw 任何值。

🔗 相關工具

類別要小:單一職責、高內聚

整潔的邊界:包裝第三方程式碼

單元測試 F.I.R.S.T Fast / Independent / Repeatable / Self-validating / Timely

Link to original

🧰 這本書給我的工具

✨ 關鍵重點(Layer 1–2)

  • 童子軍規則:離開時讓程式碼比來時更乾淨。
  • 命名:名字要能回答「它是什麼、為何存在、怎麼用」。
  • 函式:越短越好、只做一件事、參數越少越好、無副作用。
  • 註解:註解常是「表達失敗」的補償;能用程式碼表達就別寫註解。
  • 錯誤處理:用例外而非回傳碼;別回傳 null、也別傳 null。
  • 整潔的邊界、單元測試(F.I.R.S.T)、類別要小。

💬 金句原文(Layer 0)

  • 「你寫程式的時間,遠少於你讀程式的時間。」
  • 「函式應該只做一件事,把它做好,而且只做這件事。」

🔗 相關

  • 重構 —— 配套型錄,本書講什麼叫乾淨,那本給 54 個把它變乾淨的具體手法