📌 30 秒摘要(Layer 3)
程式碼被讀的次數遠多於被寫的次數,所以可讀性就是生產力。整潔的程式碼靠一堆小紀律堆出來:取有意義的名字、函式短小只做一件事、少而精的註解、清楚的錯誤處理。核心心態是童子軍規則——每次離開時讓程式碼比你來時更乾淨一點。
🗺 心智圖(Canvas)
程式碼整潔之道
Link to original 程式碼整潔之道
🧭 心態:童子軍規則
🏷 命名
⚙️ 函式
💬 註解
⚠️ 錯誤處理
🧪 類別、邊界與測試
離開時讓程式碼 比你來時更乾淨一點
你寫程式的時間, 遠少於你讀程式的時間
🎯 什麼情境該想到我
分工:這張是語言無關的命名判準與流程。Go 特有的規則(套件名幫你省字、Getter 不加 Get、
-er介面、縮寫全大寫)→ 工具-Go命名慣例;把爛名字改好的重構步驟 → 神秘命名。當你在替變數/函式/類別取名字,或看到一堆
d、tmp、data看不懂時。⚙️ 怎麼用
- 名字要能回答:它是什麼、為何存在、怎麼用。看到名字就不用再看註解。
- 用可讀、可搜尋的名字:
elapsedTimeInDays勝過d;避免單字母(迴圈計數例外)。- 別用誤導性名字、別加型別雜訊(匈牙利命名法過時)。
- 類別用名詞、函式用動詞;同一概念用同一個字(別 fetch/get/retrieve 混用)。
★ 名字長度 ∝ 使用處與宣告處的距離
這是最常被誤解的一條——「名字要有意義」不等於「名字要長」:
作用域 讀者看到名字時,宣告在哪? 合適的名字 3 行的迴圈 就在上一行 i、k, v、r(for _, r := range rows)20 行的函式區域變數 同一個螢幕 total、buf、cfg套件層級/匯出 可能在另一個檔案、另一個 repo DefaultRetryPolicy、ErrQuotaExceeded同一個概念在小範圍用長名字反而是雜訊:
for index, currentRowValue := range rows比for i, r := range rows難讀。所以命名問題有一半要靠 工具-縮小變數作用域 解決——作用域縮小了,短名字就合法了。★ 命名的四步流程(卡住時照做)
- 先說出它是什麼:用一句完整的話描述它,「這是還沒被付款的訂單的數量」。
- 從句子裡挑名詞/動詞:
unpaidOrderCount。- 刪掉讀者從上下文就知道的字:在
package order裡 →unpaidCount;在func (s *Stats)的方法裡已經很清楚 → 可能只要unpaid。- 說出來測試:唸給同事聽,對方能不能不看程式碼就猜到它是什麼?會不會跟另一個東西搞混?
第 1 步說不出來,就是設計問題而非命名問題——這個東西可能同時做了兩件事,或根本不該存在。
★ 一眼可認的壞名字
壞名字 問題 改法 data、info、item、obj什麼都是,等於什麼都沒說 說它是什麼資料: invoice、quoteManager、Helper、Util、Processor職責不明的收納箱,會一直長大 拆開,各自用具體動作命名 flag、status配true/false讀呼叫端完全看不懂 isRetryable、hasShippednotDisabled、!isNotValid雙重否定 一律用正向: isEnabled、isValidtimeoutMs 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函式參數太多怎麼辦。
當一個函式很長、要捲很久、或你發現它「先…再…然後…」做很多事時。
⚙️ 怎麼用
- 只做一件事:函式內的敘述應在同一抽象層次;「還能再被有意義地拆出一個函式」就代表它不只做一件事。
- 越短越好:理想幾行;巢狀縮排別太深。
- 參數越少越好:0–2 個最佳,3 個以上考慮包成物件;避免旗標參數(傳 boolean 通常代表函式做了兩件事)。
- 無副作用:函式名說做什麼,就別偷偷做別的。
★ 「一件事」的可操作判準:單一抽象層次
「只做一件事」太模糊,實際能用的判準是:函式裡的每一行,都在同一個抽象層次上。
// ✗ 混了三層:業務流程、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命名慣例。
當你正想加一段註解來「解釋」一段程式碼時,先停一下。
⚙️ 怎麼用
- 先試著用程式碼表達:把那段抽成一個命名清楚的函式,往往就不需要註解了。
- 註解是表達的失敗:能用命名/結構說清楚就別靠註解。
- 好註解:法律聲明、意圖說明、警告、TODO、對公開 API 的說明。
- 壞註解:多餘(複述程式碼)、誤導、被註解掉的死程式碼(直接刪,交給版本控制)、跟程式不同步的過時註解。
★ 寫註解前問四個問題(依序,前一題能解就不用往下)
# 問題 能解 → 做這個,不寫註解 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 炸到時。
⚙️ 怎麼用
- 用例外(Exception)而非回傳錯誤碼:讓正常流程乾淨,錯誤處理分離。
- 別回傳 null,也別傳 null 當參數:改回傳空集合、Optional,或丟例外。
- 例外訊息要有情境:說清楚哪裡、為什麼失敗。
- 把 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 })(ES2022cause)把 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.4net/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
🧰 這本書給我的工具
- 工具-有意義的命名 — 命名時
- 工具-函式短小且單一職責 — 寫或拆函式時
- 工具-註解是必要之惡 — 想加註解時
- 工具-用例外處理錯誤 — 處理錯誤/回傳值時
✨ 關鍵重點(Layer 1–2)
- 童子軍規則:離開時讓程式碼比來時更乾淨。
- 命名:名字要能回答「它是什麼、為何存在、怎麼用」。
- 函式:越短越好、只做一件事、參數越少越好、無副作用。
- 註解:註解常是「表達失敗」的補償;能用程式碼表達就別寫註解。
- 錯誤處理:用例外而非回傳碼;別回傳 null、也別傳 null。
- 整潔的邊界、單元測試(F.I.R.S.T)、類別要小。
💬 金句原文(Layer 0)
- 「你寫程式的時間,遠少於你讀程式的時間。」
- 「函式應該只做一件事,把它做好,而且只做這件事。」
🔗 相關
- 重構 —— 配套型錄,本書講什麼叫乾淨,那本給 54 個把它變乾淨的具體手法