📌 30 秒摘要(讀完用自己的話寫一句)

這篇在講:Go 官方的慣用寫法指南——但它寫於 2009 年、官方明說不再主動更新。它今天的價值不在語法,而在解釋 Go 為什麼長成這樣:為什麼介面要小、為什麼用嵌入不用繼承、為什麼「靠通訊共享記憶體」。心智模型的部分沒過期,語法與函式庫的部分過期了。

🗺 心智圖(Canvas)

Effective Go

🐹 Effective Go

🏷 命名與介面

🎯 什麼情境該想到我

當你在 Go 裡「想不到這個 package/型別/方法該叫什麼」,或是把別的語言的命名習慣直接搬過來的時候。

⚙️ 怎麼用(步驟 / 公式)

0. 先知道:Go 的命名有語義效果

首字母大寫決定它在 package 外看不看得到。 在多數語言這只是風格,在 Go 是語言規則——所以命名不只是可讀性問題。

1. Package 名:短、精簡、有畫面

  • 小寫、單字、不用底線也不用駝峰。
  • 寧短勿長——每個使用者都要打這個名字。
  • 就是原始目錄的 base name:src/encoding/base64 匯入路徑是 encoding/base64,但名字是 base64,不是 encoding_base64。
  • 不用預先擔心撞名:package 名只是匯入時的預設名,撞到時匯入方可以自己改。

2. ⭐ 用套件名幫你省字(最常被忽略的一條)

使用者看到的永遠是 套件.名稱,所以不要在型別名裡重複套件名:

bufio.Reader   // ✅ 不是 bufio.BufReader
ring.New()     // ✅ 不是 ring.NewRing(Ring 是這個套件唯一匯出的型別)
once.Do(setup) // ✅ 不是 once.DoOrWaitUntilDone(setup)

而且 bufio.Reader 跟 io.Reader 不會衝突,因為總是帶著套件名。

3. Getter 不要加 Get

欄位叫 owner(小寫未匯出),getter 就叫 Owner(),不是 GetOwner()。大小寫本身就是區分欄位與方法的鉤子。setter 才叫 SetOwner()。

owner := obj.Owner()
if owner != user {
    obj.SetOwner(user)
}

4. 單方法介面用 -er

Reader、Writer、Formatter、CloseNotifier——方法名加 -er 構成施事名詞。
反過來的規則同樣重要:

  • Read/Write/Close/Flush/String 有規範化的簽章與意義——除非你的方法簽章與意義完全相同,否則不要用這些名字。
  • 反之,如果你的方法跟知名型別的方法意義相同,就用同一個名字與簽章:字串轉換方法叫 String(),不叫 ToString()。

5. 多字用 MixedCaps/mixedCaps,不用底線

★ 具體對照表(照抄即可)

場景❌ 別這樣✅ Go 的慣例
套件名utils、common、helpers、base★ bytes、http、user —— 單一名詞、小寫、不用底線或駝峰
避免重複chubby.ChubbyFile★ chubby.File —— 呼叫端看到的是 套件.型別
介面IUserService、UserServiceInterface★ Reader、Writer —— 單方法介面用 -er
實作UserServiceImpl★ postgresUserStore(用「它是什麼」命名,不是「它實作了什麼」)
GetterGetName()★ Name() —— Go 不用 Get 前綴;Setter 才用 SetName()
縮寫Url、Id、Http、Api★ URL、ID、HTTP、API —— 整個縮寫同大小寫;未匯出時 url、id
錯誤變數NotFoundError★ ErrNotFound —— sentinel 用 Err 前綴
錯誤型別NotFoundErr★ NotFoundError —— 型別用 Error 後綴
變數長度userRepository★ 作用域越小名字越短:for i、r *http.Request、u User
接收者(this *User)、(self *User)★ (u *User) —— 一到兩個字母,且同一型別全部統一
常數MAX_RETRIES★ MaxRetries —— Go 沒有全大寫的慣例
測試TestFoo1、TestFoo2★ TestFoo + t.Run("空輸入", ...) 子測試(見 工具-Go表格測試與子測試)

★ 三條背後的原則

① 名字的長度該和作用域成正比

for i := range s { ... }                       // ★ 三行內,一個字母就夠
func (c *Client) Do(r *Request) (*Response, error)   // 參數,短
var defaultRetryBackoffMultiplier = 1.5        // ★ 套件層級,要完整

這和多數語言的「名字越長越清楚」直覺相反。
Go 的理由:短作用域裡上下文已經很清楚,長名字只是噪音。

② 匯出名要能和套件名一起讀
http.Get、bytes.Buffer、io.Reader——呼叫端讀到的是「套件.名字」這一整串。
所以 http.HTTPClient 是重複的(應該是 http.Client)。

③ ★ 不要用 utils / common 這種套件名
它們是「我不知道這東西屬於哪裡」的自白。
結果是所有人都往裡面丟,最後變成一個誰都要 import 的巨大耦合點。
→ 按「領域」而不是按「種類」分套件:user、billing、auth,不是 models、services、utils。

★ 註解的慣例(godoc 會直接用)

// Reader 從底層資料流讀取位元組。    ← ★ 以「名字」開頭
//
// Read 在讀到 0 個位元組時回傳 io.EOF。   ← 說明保證
type Reader interface{ ... }
 
// ErrNotFound 表示請求的資源不存在。
var ErrNotFound = errors.New("not found")
  • 匯出的東西一定要有註解,且以該名字開頭(golint 會檢查)
  • 套件註解放在 doc.go 或任一檔案的 package 前
  • ★ 註解寫「為什麼」與「保證什麼」,不要複述程式碼

★ 錯誤訊息的慣例

// ✅ 小寫開頭、不加標點 —— 因為它會被嵌進更長的句子
return fmt.Errorf("open config: %w", err)
// → 上層包裝後:「load settings: open config: permission denied」
 
// ❌ errors.New("Failed to open config file.")

(例外:以專有名詞或縮寫開頭時保留原大小寫,如 HTTP request failed。)

🧪 我實際套用的紀錄

  • 2026-08-28:(待填)

⚠️ 注意 / 什麼時候不適用

  • ★ 一致性勝過個人偏好。進到既有專案就照它的慣例走,不要為了「更正確」而製造兩套風格。
  • gofmt 管格式,不管命名。命名靠 review 與 linter(revive、staticcheck、golangci-lint)。
  • ★ 短名字有邊界。函式超過二三十行,i、v、c 就會開始不清楚——
    那通常代表函式該拆了,而不是名字該變長。
  • 接收者名稱不要用 this/self,也不要在同一型別的不同方法間換名字。
  • Err 前綴只給 sentinel error(可比較的哨兵值)。動態產生的錯誤不需要變數名。
  • 測試檔案命名:foo_test.go 與被測檔案同套件(白箱)或 package foo_test(黑箱,只能用匯出的 API)。
  • 不要過度縮寫。cfg、ctx、req、db 是公認的;usrMgrSvc 不是。

🔗 相關工具

🎯 什麼情境該想到我

當你在 Go 裡設計介面,帶著「先宣告 implements、介面要完整」的其他語言直覺的時候。

⚙️ 怎麼用(步驟 / 公式)

1. 隱式滿足:型別不宣告自己實作了什麼

Go 沒有 implements。只要方法都有,就滿足了。
多數介面轉換是靜態的、編譯期就檢查——把 *os.File 傳給要 io.Reader 的函式,不滿足就編不過。

2. 介面要小

只有一兩個方法的介面在 Go 裡是常態,不是例外(io.Reader 只有一個方法)。

3. ⭐ 只為實作介面而存在的型別,不要匯出

如果一個型別的存在只是為了實作某個介面,而且不會有介面以外的匯出方法,就沒必要匯出這個型別。

這時建構函式應該回傳介面值,而不是實作型別:

// crc32.NewIEEE 與 adler32.New 都回傳 hash.Hash32

好處是換演算法只要改建構呼叫,其餘程式碼完全不動。
只匯出介面也讓「這個值除了介面描述的以外沒有別的有趣行為」變得清楚,還省掉在每個實作上重複寫文件。

4. 用空白識別碼做編譯期介面檢查

有些檢查發生在執行期(例如 encoding/json 用型別斷言看你有沒有實作 json.Marshaler)。這很危險——沒實作到的話 encoder 照樣能跑,只是不會用你的自訂邏輯。

要在編譯期釘死,用這個宣告:

var _ json.Marshaler = (*RawMessage)(nil)

介面一改,這個套件就編不過,你會馬上知道要更新。

但不要每個型別都寫。 慣例是只在程式碼裡本來就沒有靜態轉換時才用,而那很罕見。

5. 判斷「有沒有實作」而不需要用它時,用空白識別碼

if _, ok := val.(json.Marshaler); ok { /* ... */ }

6. ★ 介面該定義在「消費方」,不是「實作方」

這是 Go 相對 Java 最大的差異,也是隱式滿足真正的價值所在:

// ❌ Java 式:實作方定義大介面
package storage
type Storage interface { Get; Put; List; Delete; Stat }   // ★ 消費者只用 Get 卻被迫依賴全部
 
// ✅ Go 式:消費方定義小介面
package app
type Getter interface { Get(context.Context, string) ([]byte, error) }
func Process(g Getter) { ... }        // ★ storage 套件完全不知道 app.Getter 存在

“accept interfaces, return structs” —— 函式收介面(讓呼叫端自由替換),回傳具體型別(讓呼叫端拿到完整能力)。
→ 完整討論見 工具-針對介面編程。

7. ★★ 最惡名昭彰的陷阱:非 nil 的 nil 介面

type MyError struct{ Code int }
func (e *MyError) Error() string { return "boom" }
 
func mayFail() error {
    var e *MyError = nil          // ★ 指標是 nil
    return e                      // ★ 但包成 error 介面後就「不是 nil」了
}
 
if err := mayFail(); err != nil {
    // ★★ 這裡會進來!
}

為什麼:介面值是一組 (型別, 值)。return e 得到的是 (*MyError, nil) ——
型別欄位非空,所以整個介面值不等於 nil。

修法:不要宣告具體型別的錯誤變數再回傳它。

func mayFail() error {
    if ok { return nil }          // ★ 直接回傳字面的 nil
    return &MyError{Code: 1}
}

★ 這個 bug 幾乎每個 Go 工程師都踩過一次,而且它不會有任何編譯警告。
go vet 有 nilness 分析可以抓部分情況,但不是全部。

8. 介面 vs 泛型(Go 1.18+)

用介面用泛型
行為不同(各自有各自的實作)型別不同但邏輯相同
需要執行期多型(一個 slice 裝不同實作)編譯期就知道型別
io.Reader、http.Handlerslices.Sort、maps.Keys
動態分派,無法內聯★ 可特化、可內聯,通常更快

★ 判準:問「這些型別的行為一樣嗎?」 一樣(只是型別不同)→ 泛型;不一樣 → 介面。
不要用泛型取代 io.Reader 這種真正的行為抽象。

🧪 我實際套用的紀錄

  • 2026-08-28:(待填)

⚠️ 注意 / 什麼時候不適用

  • ★★ 非 nil 的 nil 介面(見上)。這是 Go 最常見的執行期 bug 之一。
  • ★ 介面污染(interface pollution):為每個 struct 配一個同名介面(Service + IService)
    在 Go 裡沒有任何好處——它不會讓程式更解耦,只是多一層間接和一個要同步維護的檔案。
    只有一個實作且測試不需要替換 → 不要抽。
  • 介面太大 = 沒有介面。十個方法的介面只有原本那個型別滿足得了,替換性是零。
  • 回傳介面通常是錯的。例外:實作型別未匯出、或真的有多種實作(sql.Driver、hash.Hash)。
  • 動態分派有成本:無法內聯、可能造成逃逸到 heap。極熱的迴圈上具體型別更快——但先 profile。
  • 介面不描述保證。它只說「有這些方法」,不說複雜度、是否阻塞、是否並行安全。那些要寫在註解裡。
  • 空介面 any 不是抽象,是放棄型別。Go 1.18 之後多數 any 的用法應該改成泛型。
  • 嵌入介面會繼承它的全部方法,包含你不想要的(見 工具-Go嵌入機制)。

🔗 相關工具

🧩 組合

🎯 什麼情境該想到我

當你想「借用另一個型別的行為」,但 Go 沒有繼承的時候。

⚙️ 怎麼用(步驟 / 公式)

1. 嵌入省掉的是「轉發方法」這種苦工

不嵌入的話你要自己寫一堆轉發:

type ReadWriter struct {
    reader *Reader
    writer *Writer
}
func (rw *ReadWriter) Read(p []byte) (n int, err error) {
    return rw.reader.Read(p)   // 每個方法都要來一次
}

直接嵌入就沒這回事:

type ReadWriter struct {
    *Reader
    *Writer
}

bufio.ReadWriter 因此同時滿足 io.Reader、io.Writer、io.ReadWriter 三個介面,一行都不用寫。

2. ⭐ 嵌入不是繼承——差別在 receiver

嵌入一個型別時,它的方法會變成外層型別的方法,但呼叫時的 receiver 是內層型別,不是外層。

這一條是所有「以為是繼承」的誤解來源:內層的方法看不到外層,沒有多型覆寫、沒有 virtual dispatch。效果等同你自己手寫的那個轉發方法,不多不少。

3. 嵌入也可以只是圖方便

type Job struct {
    Command string
    *log.Logger
}
job.Println("starting now...")   // 直接有 Logger 的方法

4. 要指名嵌入欄位時,型別名就是欄位名

去掉套件前綴即可——job.Logger。這在想「加工」內層方法時很有用:

func (job *Job) Printf(format string, args ...interface{}) {
    job.Logger.Printf("%q: %s", job.Command, fmt.Sprintf(format, args...))
}

這是 Go 版的「包一層」,但要自己明寫,不會自動發生。

5. 名稱衝突的兩條規則

  1. 淺的蓋深的:外層的欄位或方法 X,會遮蔽更深層的同名 X。
  2. 同一層同名通常是錯:Job 已經有個叫 Logger 的欄位,就不能再嵌入 log.Logger。
    但如果那個重名在型別定義之外從沒被用到,就沒事——這條保護你不被外部型別新增欄位所波及。

6. ★ 介面嵌入:Go 標準庫最漂亮的用法

type Reader interface{ Read(p []byte) (int, error) }
type Writer interface{ Write(p []byte) (int, error) }
 
// ★ 用嵌入組合出大介面,而不是重寫方法
type ReadWriter interface {
    Reader
    Writer
}

★ 這才是嵌入在 Go 裡最主要的正當用途:把小介面組合成大介面。
io 套件的 ReadWriter、ReadCloser、ReadWriteSeeker 全部這樣來的。

7. ★ 嵌入介面做「部分覆寫」(很好用的技巧)

type loggingStore struct {
    Store                              // ★ 嵌入介面:其餘方法自動轉發
    log *slog.Logger
}
 
func (s loggingStore) Put(ctx context.Context, k string, v []byte) error {
    s.log.Info("put", "key", k)
    return s.Store.Put(ctx, k, v)      // ★ 只覆寫這一個
}

好處:Store 介面日後加方法,loggingStore 自動跟著有,不用改。
風險:新方法會「靜默地」不被 log 到——這是可接受的取捨,但要知道。

★ 這正是 裝飾者模式 在 Go 裡的寫法。

8. ★ 嵌入的三個實務陷阱

① 嵌入 sync.Mutex 會把 Lock()/Unlock() 變成公開 API

type Counter struct {
    sync.Mutex        // ❌ 外部可以 c.Lock() 鎖住你的物件
    n int
}
type Counter struct {
    mu sync.Mutex     // ✅ 具名私有欄位
    n  int
}

★ 除非你真的想讓呼叫端參與鎖定,否則一律用具名私有欄位。

② 嵌入會暴露被嵌入型別的「所有」匯出方法
包含你不想要的。Job 嵌入 *log.Logger → Job 也有 SetOutput、SetFlags——這些多半不該是 Job 的 API。

③ ★ JSON 序列化會「攤平」嵌入的欄位

type Base struct{ ID string `json:"id"` }
type User struct {
    Base                                   // ★ 嵌入
    Name string `json:"name"`
}
// 序列化結果:{"id":"...","name":"..."}   ← ★ 攤平,不是 {"Base":{...}}
 
type User2 struct {
    Base Base   `json:"base"`              // 具名欄位 → 巢狀
    Name string `json:"name"`
}
// {"base":{"id":"..."},"name":"..."}

嵌入介面時 JSON 會用動態型別的欄位——這常常不是你要的,而且反序列化會失敗(不知道要建哪個具體型別)。

🧪 我實際套用的紀錄

  • 2026-08-28:(待填)

⚠️ 注意 / 什麼時候不適用

  • ★★ 沒有虛擬分派。嵌入型別的方法永遠呼叫自己的方法,不會分派到外層:
    func (b Base) Greet() string { return "hi, " + b.Name() }   // b 永遠是 Base
    → 不能用嵌入模擬 範本方法模式。要可覆寫的步驟,就用函式欄位或介面參數。
  • ★ 嵌入不是「is-a」。它只是方法轉發的語法糖。不要為了表達分類關係而嵌入。
  • ★ 方法集的 receiver 規則:嵌入 T 只帶來 T 的方法;嵌入 *T 帶來 T 和 *T 的方法。
    介面滿足不成立時,先檢查這裡。
  • 名稱衝突:同一層兩個嵌入型別有同名方法 → 呼叫時才編譯錯誤(宣告時不會錯)。
  • 多層嵌入會讓「這個方法從哪來」變得難追。兩層以上就該考慮改用具名欄位 + 明確轉發。
  • 嵌入 context.Context 是反模式。context 應該當參數傳,不該存進 struct。
  • sync.Mutex / sync.WaitGroup 被嵌入後,整個 struct 就不可複製(go vet 的 copylocks 會警告)。

🔗 相關工具

🔀 並行

🎯 什麼情境該想到我

當你在 Go 裡處理並行,手很癢想用鎖去保護一個共享變數的時候。

「不要透過共享記憶體來通訊;要透過通訊來共享記憶體。」

⚙️ 怎麼用(步驟 / 公式)

1. 心智模型:值在 channel 上傳遞,不被多方同時持有

Go 鼓勵的做法是把值放上 channel 傳來傳去,而不是讓多個執行緒主動共享它。
任何時刻只有一個 goroutine 能存取那個值 → 資料競爭在設計上就不可能發生。

2. 原文給的直覺(很好用)

想像一個單執行緒程式跑在一顆 CPU 上——它不需要同步。
再跑一個實例——它也不需要同步。
現在讓這兩個互相通訊——如果通訊本身就是同步機制,那就仍然不需要其他同步。
Unix pipeline 完美符合這個模型。Go 的並行源自 Hoare 的 CSP,也可以看成是型別安全版的 Unix pipe。

3. goroutine 的成本模型

就是一個「與其他 goroutine 併行執行的函式」。輕量,成本略高於配置堆疊空間;堆疊從小開始、按需增長。它們被多工到多個 OS 執行緒上,所以其中一個因 I/O 阻塞時,其他照跑。

go list.Sort()   // 併行跑,不等它

函式字面量在 Go 裡是閉包,實作會確保被引用的變數活得夠久。

4. ⭐ 但這句標語有邊界——原文自己講的

“This approach can be taken too far. Reference counts may be best done by putting a mutex around an integer variable, for instance.”

引用計數這種東西,用 mutex 包一個整數就好。 channel 是高階做法,不是所有同步問題的答案。
判準:要傳遞「所有權」或「資料流」用 channel;只是保護一小塊狀態用 mutex。

5. ★ goroutine 洩漏:四種最常見的模式

「開了 goroutine 卻沒人負責它結束」是 Go 最常見的生產問題。

洩漏模式為什麼修法
送到沒人收的 channel無緩衝 channel 上的 ch <- v 永遠阻塞加緩衝,或用 select + ctx.Done()
從沒人送的 channel 收<-ch 永遠阻塞同上;或確保一定有人 close
忘記 ctx 取消上游放棄了,下游還在跑★ 每個長命 goroutine 都要 select { case <-ctx.Done(): return; ... }
for range ch 但沒人 closerange 會等到 channel 關閉由「唯一的送出方」負責 close
// ★ 可被取消的 goroutine 骨架
func worker(ctx context.Context, in <-chan Job, out chan<- Result) {
    for {
        select {
        case <-ctx.Done():          // ★ 一定要有這條
            return
        case j, ok := <-in:
            if !ok { return }       // ★ 上游關閉了
            select {
            case out <- process(j):
            case <-ctx.Done():      // ★ 送出時也可能阻塞,也要能取消
                return
            }
        }
    }
}

怎麼偵測:

  • runtime.NumGoroutine() 只增不減 → 有洩漏
  • pprof 的 goroutine profile(/debug/pprof/goroutine?debug=2)能直接看到卡在哪一行
  • go.uber.org/goleak 可以在測試結束時斷言沒有殘留 goroutine

6. ★ Channel 的語義表(必背)

操作nil channel開著且空開著且滿已關閉
<-ch 接收永遠阻塞阻塞取到值★ 立刻回零值,ok == false
ch <- v 送出永遠阻塞送出阻塞★ panic
close(ch)panicokok★ panic

三條由此推出的鐵律:

  1. ★ 只有送出方能 close,而且只能有一個送出方 close(多個送出方就用 sync.WaitGroup + 一個協調者)
  2. 不要 close 接收端拿到的 channel
  3. ★ 「nil channel 永遠阻塞」是特性不是 bug——在 select 裡把某個 case 的 channel 設成 nil,就能動態關掉那個分支

7. Worker Pool 與 errgroup

import "golang.org/x/sync/errgroup"
 
func processAll(ctx context.Context, items []Item) error {
    g, ctx := errgroup.WithContext(ctx)      // ★ 任一個回錯,ctx 自動取消其他的
    g.SetLimit(10)                           // ★ 併發上限(見 [[AI 工程 Coding 題型]] 的批次處理器)
    for _, it := range items {
        it := it                             // ★ Go < 1.22 必須這行;1.22+ 可省
        g.Go(func() error { return process(ctx, it) })
    }
    return g.Wait()                          // ★ 回傳第一個非 nil 的錯誤
}

★ errgroup 是 Go 並行的實務標配:它把「等待、錯誤傳播、取消、限流」四件事一次解決。
自己用 sync.WaitGroup + channel 收錯誤幾乎總是重新發明它。

★ 對照 Java 線程池:Go 的「拒絕策略」長什麼樣

Java 的 ThreadPoolExecutor 有三個關鍵旋鈕:執行緒數、有界佇列、佇列滿時的拒絕策略。常見的踩雷點是 Executors.newFixedThreadPool 背後用的 LinkedBlockingQueue 容量是 Integer.MAX_VALUE,等於無界,量一大就 OOM(阿里巴巴 Java 開發規約因此禁止用 Executors 建執行緒池)。Go 沒有執行緒池,但這三個旋鈕都有對應:

JavaGo滿了會怎樣
核心/最大執行緒數g.SetLimit(n) 或固定 n 個 worker goroutine—
有界佇列make(chan Job, n)送不進去時阻塞
CallerRunsPolicy(呼叫端自己執行,拖慢提交速度)g.Go(...):阻塞到有空位才回傳生產者自然被拖慢 → 背壓
AbortPolicy(預設,丟 RejectedExecutionException 給呼叫端)g.TryGo(...) 回傳 false;或 select { case ch <- job: default: }呼叫端自己決定重試、落地或回錯
  • errgroup 官方註解:Go 會 “block until the new goroutine can be added without the number of goroutines in the group exceeding the configured limit”;TryGo 只在低於上限時才啟動,“The return value reports whether the goroutine was started.”(查證 2026-09-27)
  • 選哪種看場景:離線批次(從 DB 撈千萬筆推送)要的是「生產者慢下來」,用會阻塞的 g.Go/阻塞送 channel。線上請求不能讓請求執行緒被拖住,用 TryGo/select default 快速失敗,回 429 或降級。Java 的 CallerRuns 也是同樣的取捨:在 Web 請求執行緒上用它,會把整個服務卡死。
  • AbortPolicy 不是「資料直接丟了」,而是把例外丟給呼叫端;真正靜默丟棄的是 DiscardPolicy。Go 的 TryGo 回 false 也一樣:丟不丟,取決於呼叫端有沒有處理這個回傳值。
  • 併發數怎麼估:與其背「IO 密集型設 2N」這類經驗值,不如用 Little’s Law 反推:所需併發數 = 目標吞吐 × 單次延遲。例如 1,000 萬則簡訊要在 1 小時內發完 → 10,000,000 ÷ 3,600 ≈ 2,778 則/秒;若簡訊閘道每次呼叫約 200ms,就需要約 2,778 × 0.2 ≈ 556 個同時在途的請求。這是推導出來的起點,還要再看閘道的限流上限、壓測調整(見 工具-限流器設計)。
  • 在記憶體裡排隊的工作,當機就沒了。千萬級任務要先在 DB/Redis 落一個「發送中」狀態,做完回寫「已完成」,再用補償任務重送卡太久的(跟 工具-延遲任務方案選型 的兜底對帳同一個思路)。
    (Java 線程池的部分補充自 https://juejin.cn/post/7626595191144595456,對照 OpenJDK 原始碼與 errgroup 原始碼,查證 2026-09-27)

8. ★ Go 1.22 的迴圈變數變更(2026-09-21 查證)

Go 1.22 release notes:“Previously, the variables declared by a ‘for’ loop were created once and updated by each iteration. In Go 1.22, each iteration of the loop creates new variables, to avoid accidental sharing bugs.”

for _, v := range items {
    go func() { use(v) }()     // ★ Go 1.22 前:所有 goroutine 看到同一個 v(最後一個值)
}                              // ★ Go 1.22 起:每輪新的 v,行為正確

但要注意:

  • 這由 go.mod 裡的 go 指令版本決定,不是編譯器版本
  • go vet 也跟著改了——1.22+ 的檔案不再報告這類引用
  • 舊專案(go 1.21 以下)仍然需要 v := v

🧪 我實際套用的紀錄

  • 2026-08-28:(待填)

⚠️ 注意 / 什麼時候不適用

  • ★ 這句標語有邊界,原文自己講了:“This approach can be taken too far.”
    保護一小塊狀態 → sync.Mutex 更簡單也更快;傳遞所有權或資料流 → channel。
    用 channel 做引用計數是過度工程。
  • ★ channel 不是免費的。每次收送都有排程與鎖的開銷,比 mutex 貴。
    高頻的小操作(計數器、快取查找)用 sync/atomic 或 RWMutex。
  • ★ 無緩衝 channel 是同步點,會讓雙方互相等待。加緩衝能解耦,但緩衝滿了還是會阻塞——
    緩衝只是延後問題,不是背壓的解答(見 工具-MQ消費端防禦三原則)。
  • 無界的 goroutine = 無界的記憶體。for { go handle(req) } 在流量尖峰時會爆。一定要有併發上限。
  • panic 跨不過 goroutine 邊界。子 goroutine 裡的 panic 會直接終止整個程式,主 goroutine 的 recover 攔不到。
    → 每個 go 出去的函式都該有自己的 defer recover()(見 工具-defer-panic-recover)。
  • select 的多個就緒 case 是隨機選的,不是依序。不要依賴順序。
  • sync.WaitGroup 的 Add 必須在 go 之前呼叫,放在 goroutine 內部是競態。

🔗 相關工具

Link to original

⚠️ 時效性(先讀這段再讀其他)

官方在文件頂端自己標了警語,原文照抄:

“Note: This document was written for Go’s release in 2009 and is not actively updated. While it remains a good guide for using the core language, it does not cover significant changes to the language (generics), ecosystem (modules), or libraries added since. See issue 28782 for context.”

我實際掃過全文(2517 行)核對,確認以下完全沒有出現:
generics/型別參數、go.mod/modules、errors.Is/errors.As、%w 錯誤包裝、log/slog、errgroup、range over func。

章節現況
Names、Interfaces、Embedding、Concurrency 的觀念✅ 仍然成立,而且是最好的來源
Formatting(gofmt)、Semicolons、MixedCaps✅ 仍然成立
Errors❌ 明顯過時:它教你用 type switch/type assertion 去辨識錯誤,而 Go 1.13 之後正解是 errors.Is/errors.As。這正是 Modern Go Guidelines 裡標為 Critical 的 erris 診斷
Concurrency 的範例寫法⚠️ 觀念對、寫法舊:手動用 channel 計數等待,現在有 sync.WaitGroup.Go(1.25)與 errgroup
泛型能解決的地方(如 Generality 章的抽象手法)⚠️ 有些在 1.18 之後有更直接的寫法
modules、依賴管理、工具鏈❌ 完全沒提

結論:值得讀,但要跟 Modern Go Guidelines 搭配著看。

  • Effective Go 回答「為什麼這樣設計」——心智模型,變得慢。
  • Modern Go Guidelines 回答「現在該怎麼寫」——語法與 API,變得快。

🎯 為什麼存這篇 / 未來想拿它做什麼

  • Go 是我的主力語言之一,但知識庫裡的軟體工程卡幾乎都是語言無關的。這篇補的是 Go 特有的設計直覺。
  • 從別的語言帶過來的習慣(繼承、getter 命名、用鎖保護共享變數)在 Go 裡都會卡住,這篇解釋卡在哪。
  • 它的過時本身也是有用的資訊:知道哪幾章不能信,比整篇不讀更有價值。

🧰 這篇給我的工具(連到 tools/ 工具卡)

✨ 關鍵重點(仍然成立的部分)

  • 命名有語義效果:首字母大小寫決定可見性,這在多數語言裡只是風格,在 Go 裡是語言規則。
  • 用套件結構幫你命名:bufio 裡的型別叫 Reader 不叫 BufReader,因為使用者看到的是 bufio.Reader。「長名字不會自動讓東西更好讀。」
  • 介面小到只有一兩個方法是常態,而且隱式滿足——型別不需要宣告自己實作了什麼。
  • 只為了實作某介面而存在的型別,不必匯出;建構函式回傳介面而非具體型別,換演算法就只是換一次建構呼叫。
  • 嵌入不是繼承:方法會被提升到外層,但呼叫時的 receiver 是內層型別,不是外層。這一條是所有「以為是繼承」的誤解來源。
  • 並行的標語:「不要透過共享記憶體來通訊;要透過通訊來共享記憶體。」
  • 而且原文自己就給了那條標語的邊界:“This approach can be taken too far.”——引用計數之類的,用 mutex 包一個整數反而best。

💬 原文摘錄

  • 最有名的那句:

    “Do not communicate by sharing memory; instead, share memory by communicating.”

  • 但緊接著的自我節制(很多人只記前半句):

    “This approach can be taken too far. Reference counts may be best done by putting a mutex around an integer variable, for instance.”

  • 關於命名長度:

    “Long names don’t automatically make things more readable. A helpful doc comment can often be more valuable than an extra long name.”

  • 關於嵌入與繼承的關鍵差異:

    “There’s an important way in which embedding differs from subclassing. When we embed a type, the methods of that type become methods of the outer type, but when they are invoked the receiver of the method is the inner type, not the outer one.”

  • 關於 Go 程式該怎麼寫:

    “A straightforward translation of a C++ or Java program into Go is unlikely to produce a satisfactory result — Java programs are written in Java, not Go.”

🔗 相關

  • Go Blog 經典六篇 — 同期官方素材的逐篇查證;它的 Errors 章與這篇一樣停在 2009 年的做法
  • Modern Go Guidelines — 一定要搭配看:那份補的正是這篇沒有的現代語法與 API
  • Google Go 風格指南 — Google 內部審 Go code 的依據,明說以這篇為前提;補的是「兩種寫法都合法時選哪個」
  • Go — 主題索引:16 張 Go 工具卡與四份來源的分工