這是 Go 語言從零到 Web 應用系列的第十二篇,也是本系列的最終篇。這篇整理 Go 開發中最常踩到的陷阱——即使是有經驗的 Go 開發者也可能中招。每個陷阱都附帶「踩坑範例」和「正確做法」,讓你一次看清問題在哪。
Slice 陷阱
陷阱 1:Append 可能修改原始 Slice
當 slice 的 capacity 還有剩餘空間時,append 會直接修改底層 array,影響到共享同一個 array 的其他 slice。
package main
import "fmt"
func main() {
original := make([]int, 3, 5) // len=3, cap=5
original[0], original[1], original[2] = 1, 2, 3
// 截取一個子 slice
sub := original[:2]
// append 到 sub — cap 還有空間,不會分配新 array
sub = append(sub, 99)
fmt.Println("original:", original) // [1 2 99] ← 被改了!
fmt.Println("sub:", sub) // [1 2 99]
}
正確做法: 使用 full slice expression 限制 capacity
// 用 original[:2:2] 限制 sub 的 cap = 2
sub := original[:2:2]
// 現在 append 會分配新 array,不影響 original
sub = append(sub, 99)
fmt.Println("original:", original) // [1 2 3] ← 沒被改
fmt.Println("sub:", sub) // [1 2 99]
陷阱 2:大 Slice 截取小片段導致記憶體無法釋放
// 錯誤:小 slice 持有大 array 的引用,GC 無法回收
func getFirstTen(data []byte) []byte {
return data[:10] // 底層 array 整個被保留
}
// 正確:複製需要的資料到新 slice
func getFirstTen(data []byte) []byte {
result := make([]byte, 10)
copy(result, data[:10])
return result // 原始的大 array 可以被 GC 回收
}
假設 data 是 1GB 的檔案內容,用截取的方式只取 10 bytes,那 1GB 的底層 array 都不會被回收。
Closure 變數捕獲陷阱
陷阱 3:For Loop 中的 Closure 捕獲
這是 Go 最經典的陷阱之一:
package main
import (
"fmt"
"sync"
)
func main() {
var wg sync.WaitGroup
// Go 1.21 及之前:所有 goroutine 都印出相同的值
for i := 0; i < 5; i++ {
wg.Add(1)
go func() {
defer wg.Done()
fmt.Println(i) // 捕獲的是變數 i 本身,不是值
}()
}
wg.Wait()
// 可能輸出:5 5 5 5 5(全部都是迴圈結束後的值)
}
原因: 在 Go 1.21 及之前,for loop 的迴圈變數只有一份,所有 closure 都捕獲同一個變數的 reference。當 goroutine 真正執行時,迴圈已經結束,i 的值已經是 5。
修正方式(Go 1.21 及之前):
for i := 0; i < 5; i++ {
wg.Add(1)
i := i // 建立一個新的局部變數,shadow 迴圈變數
go func() {
defer wg.Done()
fmt.Println(i)
}()
}
Go 1.22+ 的修正: 從 Go 1.22 起,每次迭代的迴圈變數都是一個新的實例,這個陷阱已經被語言層面修正了。但如果你的專案需要支援舊版本,仍然要注意。
Nil Interface 陷阱
陷阱 4:持有 Nil Pointer 的 Interface 不等於 Nil
package main
import "fmt"
type MyError struct {
Message string
}
func (e *MyError) Error() string {
return e.Message
}
func doSomething() error {
var err *MyError // nil pointer
// ... 一些邏輯 ...
return err // 回傳 nil pointer,但型別資訊被帶入 interface
}
func main() {
err := doSomething()
if err != nil {
fmt.Println("got error!") // 會進來!即使 err 的值是 nil
}
}
原因: Go 的 interface 內部是 (type, value) 的組合。doSomething() 回傳的 interface 是 (*MyError, nil)——它有 type 資訊,所以不等於 nil。只有 (nil, nil) 才等於 nil。
正確做法:
func doSomething() error {
var err *MyError
// ... 一些邏輯 ...
if err == nil {
return nil // 直接回傳 nil,不帶型別資訊
}
return err
}
Map 併發陷阱
陷阱 5:併發讀寫 Map 會 Panic
package main
import "sync"
func main() {
m := make(map[int]int)
var wg sync.WaitGroup
// 這會觸發 fatal error: concurrent map writes
for i := 0; i < 100; i++ {
wg.Add(1)
go func(n int) {
defer wg.Done()
m[n] = n // 併發寫入
}(i)
}
wg.Wait()
}
Go 的 runtime 會偵測到 map 的併發寫入,直接觸發 fatal error(不是 panic,無法 recover)。
正確做法:
// 方案一:sync.RWMutex
var mu sync.RWMutex
m := make(map[int]int)
// 寫入
mu.Lock()
m[key] = value
mu.Unlock()
// 讀取
mu.RLock()
v := m[key]
mu.RUnlock()
// 方案二:sync.Map(讀多寫少的場景更高效)
var sm sync.Map
sm.Store(key, value)
v, ok := sm.Load(key)
陷阱 6:Range Map 的順序不固定
package main
import "fmt"
func main() {
m := map[string]int{"a": 1, "b": 2, "c": 3}
// 每次執行的輸出順序可能不同
for k, v := range m {
fmt.Printf("%s=%d ", k, v)
}
// 可能是 "b=2 c=3 a=1" 或 "a=1 c=3 b=2" 或其他順序
}
Go 刻意隨機化 map 的迭代順序,防止開發者依賴特定順序。如果需要固定順序,先取出 keys 排序。
String 與 Byte 陷阱
陷阱 7:Range String 是按 Rune 迭代,不是 Byte
package main
import "fmt"
func main() {
s := "Hello 你好"
// range 按 rune 迭代
for i, r := range s {
fmt.Printf("index=%d rune=%c bytes=%d\n", i, r, len(string(r)))
}
// index=0 rune=H bytes=1
// index=1 rune=e bytes=1
// ...
// index=6 rune=你 bytes=3 ← index 跳了!
// index=9 rune=好 bytes=3
fmt.Println("len(s):", len(s)) // 12(byte 數)
fmt.Println("runes:", len([]rune(s))) // 8(字元數)
}
len(string) 回傳的是 byte 數,不是字元數。中文字在 UTF-8 中佔 3 個 bytes。
陷阱 8:String 與 []byte 轉換有效能開銷
s := "hello"
b := []byte(s) // 分配新記憶體,複製資料
s2 := string(b) // 又分配新記憶體,複製資料
每次轉換都會分配新記憶體和複製。在高頻呼叫的路徑上要注意。如果只需要讀取,考慮使用 strings.Builder 或 bytes.Buffer 來減少分配。
Goroutine 洩漏陷阱
陷阱 9:Channel 未關閉導致 Goroutine 洩漏
// 錯誤:producer goroutine 永遠不會結束
func leakyProducer() <-chan int {
ch := make(chan int)
go func() {
for i := 0; ; i++ {
ch <- i // 如果沒人讀取,這裡永遠阻塞
}
}()
return ch
}
// 正確:用 context 控制 goroutine 生命週期
func producer(ctx context.Context) <-chan int {
ch := make(chan int)
go func() {
defer close(ch)
for i := 0; ; i++ {
select {
case ch <- i:
case <-ctx.Done():
return // context 被取消時,goroutine 優雅退出
}
}
}()
return ch
}
經驗法則: 每個 goroutine 都要有明確的結束條件。啟動 goroutine 時,問自己:「它什麼時候會結束?」
陷阱 10:Select 缺少 Timeout
// 錯誤:如果 ch 永遠沒有資料,goroutine 永遠卡住
select {
case v := <-ch:
process(v)
}
// 正確:加上 timeout
select {
case v := <-ch:
process(v)
case <-time.After(5 * time.Second):
log.Println("timeout waiting for data")
return
}
Error 處理陷阱
陷阱 11:忽略 Error 回傳值
// 錯誤:忽略 error
json.Unmarshal(data, &result)
os.Remove(filename)
fmt.Fprintf(w, "hello")
// 正確:處理每個 error
if err := json.Unmarshal(data, &result); err != nil {
return fmt.Errorf("unmarshaling: %w", err)
}
Go 的 linter(如 errcheck)可以幫你抓到被忽略的 error。
陷阱 12:用 == 比較 Wrapped Error
package main
import (
"errors"
"fmt"
"io"
)
func readData() error {
// 假設底層回傳 io.EOF
return fmt.Errorf("reading failed: %w", io.EOF)
}
func main() {
err := readData()
// 錯誤:== 比較 wrapped error 會失敗
if err == io.EOF {
fmt.Println("got EOF") // 不會進來
}
// 正確:用 errors.Is 解開 wrap 鏈
if errors.Is(err, io.EOF) {
fmt.Println("got EOF (via errors.Is)") // 正確匹配
}
}
errors.Is 會遞迴地展開 error chain 來比較。同理,errors.As 用於提取特定型別的 error。
Defer 陷阱
陷阱 13:Defer 的參數在宣告時就已求值
package main
import "fmt"
func main() {
x := 0
defer fmt.Println("defer x:", x) // x 在這裡就被求值了,是 0
x = 42
fmt.Println("x:", x) // 42
}
// 輸出:
// x: 42
// defer x: 0 ← 不是 42!
如果要延遲求值,用 closure:
x := 0
defer func() {
fmt.Println("defer x:", x) // closure 捕獲變數,真正執行時才讀取
}()
x = 42
// 輸出:defer x: 42
陷阱 14:迴圈中的 Defer 造成資源堆積
// 錯誤:defer 要到函式結束才執行,迴圈中開的檔案全部堆在那裡
func processFiles(filenames []string) error {
for _, name := range filenames {
f, err := os.Open(name)
if err != nil {
return err
}
defer f.Close() // 所有檔案要到函式結束才關閉!
// ... 處理檔案 ...
}
return nil
}
// 正確:抽成獨立函式,讓 defer 在每次迭代結束就執行
func processFiles(filenames []string) error {
for _, name := range filenames {
if err := processOneFile(name); err != nil {
return err
}
}
return nil
}
func processOneFile(name string) error {
f, err := os.Open(name)
if err != nil {
return err
}
defer f.Close()
// ... 處理檔案 ...
return nil
}
如果迴圈處理幾千個檔案,defer 在迴圈中會導致 file descriptor 耗盡。
陷阱 15:Defer 中的 Error 被忽略
// 錯誤:defer 中的 Close error 被丟棄
func writeFile(name string, data []byte) error {
f, err := os.Create(name)
if err != nil {
return err
}
defer f.Close() // Close 的 error 被忽略了
_, err = f.Write(data)
return err
}
// 正確:捕獲 defer 中的 error
func writeFile(name string, data []byte) (err error) {
f, err := os.Create(name)
if err != nil {
return err
}
defer func() {
if cerr := f.Close(); err == nil {
err = cerr // 只在沒有其他 error 時,回報 Close error
}
}()
_, err = f.Write(data)
return err
}
系列回顧
恭喜你完成了 Go 語言從零到 Web 應用完整系列!讓我們回顧走過的路:
- 為什麼要學 Go — 認識 Go 的設計哲學與適用場景
- 環境安裝與第一支程式 — 建立開發環境
- 基礎語法 — 變數、流程控制、函式
- 資料結構 — Array、Slice、Map、Struct
- 錯誤處理與並行 — Error、Goroutine、Channel
- HTTP 基礎 — 用標準庫建立 Web Server
- 專案結構 — 組織大型 Go 專案
- 資料庫整合 — SQLite + Repository Pattern
- 使用者認證 — JWT + Middleware
- 測試與部署 — 單元測試 + Docker
- 面試必問問題 — 核心概念深度解析
- 常見陷阱與反模式 — 開發實戰避坑指南(本篇)
每個陷阱的共同主題:Go 追求簡潔和顯式,但簡潔不代表沒有複雜度。理解這些底層行為,能讓你寫出更可靠的 Go 程式。
下一步
基礎篇和面試/陷阱篇到此告一段落,接下來進入進階篇!我們會學習 Web 框架(Gin vs Echo)、ORM 工具(GORM vs sqlx)、PostgreSQL、gRPC、微服務架構,以及 Kubernetes 部署——把你的 Go 技能提升到職場即戰力。