🎯 什麼情境該想到我
當你在設計一個模組/函式庫/API 的公開介面,決定「露出什麼、藏什麼」時。
⚙️ 怎麼用(書中第 4 章的四原則)
- 隱藏實作(資訊隱藏):使用者只看到介面,內部可自由改。
- 選一小組正交的基本操作:提供外界需要的全部功能,但絕不更多;功能之間不重疊、可自由組合,同一件事不提供好幾種做法。
- 不要在使用者背後搞小動作:不偷寫檔案、不偷改全域變數、改呼叫者的資料要特別謹慎;也不要為了實作者方便,強迫使用者先準備一堆別的東西。
- 一致:命名、參數順序、錯誤處理風格全體一致,可預期。
好的介面讓呼叫端「很難用錯」。
同一章小結還有三條配套(資源與錯誤):釋放資源與分配資源應該在同一層次進行、在低層檢查錯誤,在高層處理、只把例外用在異常的情況。
★ 書中的反例:strtok 一次違反兩條
C 的 strtok 是書裡拿來示範「背後搞小動作」的例子:它在輸入字串裡寫入空位元組(偷改呼叫者的資料),又在兩次呼叫之間偷偷保留狀態(用空指標表示「從上次停下的地方繼續」)——結果是意外修改、難以除錯,而且無法同時處理兩個字串,也無法並行使用。更好的設計是一個獨立、無隱藏狀態的函式。
★ 七條原則在 Go 裡長什麼樣
| 原則 | Go 的具體做法 | 違反的樣子 |
|---|---|---|
| 隱藏實作 | 不匯出的欄位 + 建構函式;internal/ 目錄由編譯器強制只能被上層目錄的套件匯入 | 匯出 struct 欄位讓人直接改,之後無法加驗證 |
| 小而正交 | io.Reader 只有一個方法,卻能和 bufio、gzip、http 任意組合 | 一個 Store 介面 20 個方法,每個使用者只用其中 2 個 |
| 不搞小動作 | 函式不改傳進來的 slice/map,要改就在名字上說清楚(slices.Sort 就地排序,名字和文件都講明) | 在 init() 裡連 DB、改 http.DefaultClient、偷開 goroutine 而呼叫者無法停止 |
| 一致 | ctx context.Context 永遠是第一個參數、error 永遠是最後一個回傳值、NewX 建構、Close() error 釋放 | 有的函式 (ctx, id)、有的 (id, ctx) |
| 資源同層釋放 | 誰 Open 誰 defer Close();回傳需要關閉的東西時,文件明說「呼叫者必須 Close」(例:resp.Body) | 函式 A 開檔、函式 B 關檔,錯誤路徑上必漏 |
| 低層偵測、高層處理 | 函式庫回傳 error,由呼叫端決定重試、降級或回報 | 函式庫裡 log.Fatal / os.Exit / 自己印 log——使用者無法決定怎麼處理 |
| 例外只用在異常 | panic 只留給程式錯誤;可預期的失敗回傳 error | 用 panic 當「找不到資料」的訊號 |
★ 「很難用錯」的四個設計手法
- 讓非法狀態無法被建構:
NewOrder(items)在建構時驗證,之後的Order保證合法;而不是讓使用者Order{}再呼叫Validate()。 - 用型別取代註解中的約定:
func Sleep(d time.Duration)不會被傳錯單位;func Sleep(ms int)會。 - 零值可用:Go 的
sync.Mutex、bytes.Buffer宣告了就能用,不需要初始化——使用者就不可能「忘記初始化」。 - 把危險操作做得明顯:
unsafe、Must...、...Unchecked這類名字讓 code review 一眼看到風險。
★ 介面設計的時間軸:寧可先小
加一個方法很容易,拿掉一個方法幾乎不可能(會破壞所有使用者)。所以:
- 第一版只放你已經有使用者需要的操作。
- Go 介面尤其如此:對一個已發布的介面加方法,會讓所有外部實作者編譯失敗。常見做法是另定義一個擴充介面(如
io.WriterTo),再用型別斷言探測實作者有沒有支援。 - TS 對應:公開型別加必填欄位是破壞性變更;加選填欄位才相容。
🧪 我實際套用的紀錄
- 2026-07-14:(待填)
⚠️ 注意
-
介面一旦公開就難改;上線前多想一輪,寧可先小再擴。
-
「正交」不代表「最小化到難用」:
io.ReadAll、os.ReadFile這類便利函式雖然可以由基本操作組出來,但因為幾乎每個使用者都需要,提供它們反而減少錯誤。判準是「使用者自己組會不會常常組錯」。
🔗 相關工具
- 工具-管理複雜度、工具-針對介面編程(GoF)、工具-限界上下文(DDD)
- 工具-Go介面小而隱式 —— Go 版本的「小而正交」:介面定義在使用端、只含用到的方法