Goを書いていると、「え、そういう動きするの?」と声が出る瞬間がある。
しかも大抵それは、本番環境か、深夜か、デモ直前に発生するのでして。
この記事は、私が実際にやらかした罠を10個まとめたものです。
「常識だろ」と言われそうなものも入れました。知らなかったんだから仕方ない。
🕳️ 地雷1: forループのクロージャ変数キャプチャ
やらかしコード
funcs := make([]func(), 5)
for i := 0; i < 5; i++ {
funcs[i] = func() {
fmt.Println(i)
}
}
for _, f := range funcs {
f()
}
// 5
// 5
// 5
// 5
// 5 ← !?
なぜ起きるか
クロージャは i の値をコピーしない。
i という変数への参照を持っている。
ループが終わった時点で i は 5 になっているので、全部 5 を返す。
正しい書き方
// ループ内で変数をコピーする
for i := 0; i < 5; i++ {
i := i // 新しい変数にシャドーイング
funcs[i] = func() {
fmt.Println(i)
}
}
// またはgoroutine引数で渡す
for i := 0; i < 5; i++ {
go func(i int) {
fmt.Println(i)
}(i)
}
実際の被害
goroutineでAPIを並列リクエストするコードで踏んだ。
全部同じエンドポイントにリクエストが飛んでいた。
「なんで最後のURLにしかアクセスしないんだ」と悩んだ。
なお、Go 1.22以降はループ変数のセマンティクスが変わり、この罠は踏まなくなった。
だがしかし、古いコードベースでは今でも生きている。
🕳️ 地雷2: nilのインターフェースは nil じゃない
やらかしコード
type MyError struct {
msg string
}
func (e *MyError) Error() string {
return e.msg
}
func getError() error {
var err *MyError = nil
return err // nilのポインタを返している
}
func main() {
err := getError()
if err != nil {
fmt.Println("エラーあり!") // ← これが出力される !?
}
}
なぜ起きるか
Goのインターフェースは型情報と値の2つを持つ。
*MyError 型の nil ポインタを error インターフェースに入れると、型情報は *MyError、値は nil になる。
インターフェース自体は nil ではないので、err != nil が true になる。
error インターフェース
├─ 型: *MyError ← これが入ってる
└─ 値: nil
正しい書き方
func getError() error {
// エラーがないときは error 型の nil を直接返す
return nil
}
実際の被害
エラーハンドリングが正しく動いていると思っていたのに、なぜかエラー処理に入り続ける。
原因に気づくまで「Goのバグかな」と思っていた。
違う。私の理解が足りなかっただけだ…orz
🕳️ 地雷3: スライスの append が元スライスを壊す
やらかしコード
a := []int{1, 2, 3}
b := a[:2] // [1, 2]
b = append(b, 99)
fmt.Println(a) // [1, 2, 99] ← !?
fmt.Println(b) // [1, 2, 99]
なぜ起きるか
b := a[:2] はスライスのコピーではなく、同じ配列を指すスライスヘッダを作る。
b の容量はまだ余裕があるので、append しても新しい配列を確保しない。
結果、a が指す配列の3番目の要素が書き換わる。
正しい書き方
// コピーしてから使う
b := make([]int, 2)
copy(b, a[:2])
b = append(b, 99)
fmt.Println(a) // [1, 2, 3] ← 影響なし
// または full slice expression で容量を制限する
b := a[:2:2] // cap=2 に制限
b = append(b, 99) // 容量超えるので新しい配列が確保される
🕳️ 地雷4: goroutine リーク
やらかしコード
func fetchData(url string) <-chan string {
ch := make(chan string)
go func() {
resp, _ := http.Get(url)
body, _ := io.ReadAll(resp.Body)
ch <- string(body) // 受け取る人がいないと永遠にブロック
}()
return ch
}
func main() {
ch := fetchData("https://example.com")
// ch を読まずに return すると goroutine が永遠に残る
}
なぜ起きるか
チャネルに送信しようとした goroutine は、受け取る人が現れるまでブロックする。
受け取る人がいなければ、goroutine はプロセスが終わるまでメモリを食い続ける。
正しい書き方
func fetchData(ctx context.Context, url string) <-chan string {
ch := make(chan string, 1) // バッファ付きにする
go func() {
req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
resp, err := http.DefaultClient.Do(req)
if err != nil {
close(ch)
return
}
body, _ := io.ReadAll(resp.Body)
ch <- string(body)
}()
return ch
}
context でキャンセルできるようにして、バッファ付きチャネルにする。
goroutine リークは地味に積み重なってメモリを食い尽くすので、本番では runtime.NumGoroutine() を監視しておくと吉。
🕳️ 地雷5: map の並行読み書き
やらかしコード
m := make(map[string]int)
go func() {
for {
m["key"] = 1 // 書き込み
}
}()
go func() {
for {
_ = m["key"] // 読み込み
}
}()
// fatal error: concurrent map read and map write
なぜ起きるか
Goの map はスレッドセーフではない。
複数の goroutine から同時に読み書きすると、ランタイムがパニックを起こす。
しかも毎回パニックになるわけではなく、タイミング次第でたまにしか起きない。
正しい書き方
// sync.RWMutex で保護する
var mu sync.RWMutex
m := make(map[string]int)
// 書き込み
mu.Lock()
m["key"] = 1
mu.Unlock()
// 読み込み
mu.RLock()
v := m["key"]
mu.RUnlock()
// または sync.Map を使う(読み込みが多い場合に有効)
var sm sync.Map
sm.Store("key", 1)
v, _ := sm.Load("key")
実際の被害
ローカルでは全く出なかったのに、本番でたまにクラッシュする。
再現できないのが一番つらい。
go test -race で検出できるので、CIに組み込んでおくとイイネ
🕳️ 地雷6: エラーを握りつぶす
やらかしコード
result, _ := doSomething() // エラーを _ で捨てる
なぜ起きるか(というか人間の問題)
「どうせエラーにならないだろ」「とりあえず動けばいい」で書いた _ が本番にそのまま入る。
エラーが起きても何も起きていないように見える。
実際の被害
ファイルのクローズエラーを _ で捨てていて、書き込みが完了していないファイルをずっと使い続けていた。
正しい書き方
result, err := doSomething()
if err != nil {
return fmt.Errorf("doSomething failed: %w", err)
}
%w でラップしておくと、呼び出し元で errors.Is や errors.As が使えるようになる。
エラーは握りつぶさず、必ず上に伝播させる。
🕳️ 地雷7: time.Time のゼロ値
やらかしコード
type User struct {
Name string
CreatedAt time.Time
}
u := User{Name: "しひろ"}
fmt.Println(u.CreatedAt)
// 0001-01-01 00:00:00 +0000 UTC ← !?
// DBに保存しようとすると...
// Error: value "0001-01-01T00:00:00Z" is out of range for MySQL datetime
なぜ起きるか
time.Time の ゼロ値は西暦1年1月1日。
MySQLの datetime 型の最小値は 1000-01-01 なので、そのまま保存するとエラーになる。
しかもこのエラー、「フィールドを設定し忘れた」ことに気づかないまま踏む。
正しい書き方
// ゼロ値チェックには IsZero() を使う
if u.CreatedAt.IsZero() {
u.CreatedAt = time.Now()
}
// またはポインタにして nil チェック
type User struct {
Name string
CreatedAt *time.Time
}
🕳️ 地雷8: defer のループ内での使用
やらかしコード
func processFiles(paths []string) error {
for _, path := range paths {
f, err := os.Open(path)
if err != nil {
return err
}
defer f.Close() // ← これ、いつ実行される?
// f を使う処理...
}
return nil
}
なぜ起きるか
defer は関数が return するときに実行される。
ループ内に書いた defer は、ループが終わるたびではなく、関数全体が終わるまで溜まり続ける。
ファイルを100個開いたら、100個分のファイルディスクリプタが関数終了まで開きっぱなしになる。
正しい書き方
func processFiles(paths []string) error {
for _, path := range paths {
if err := processFile(path); err != nil {
return err
}
}
return nil
}
func processFile(path string) error {
f, err := os.Open(path)
if err != nil {
return err
}
defer f.Close() // この関数が終わったらすぐ閉じる
// f を使う処理...
return nil
}
ループ内の処理を別関数に切り出すのが定石。
🕳️ 地雷9: 構造体のコピーで mutex が壊れる
やらかしコード
type SafeCounter struct {
mu sync.Mutex
count int
}
func (c SafeCounter) Increment() { // 値レシーバ = コピーが渡る
c.mu.Lock()
defer c.mu.Unlock()
c.count++
}
func main() {
c := SafeCounter{}
c.Increment()
fmt.Println(c.count) // 0 ← !?
}
なぜ起きるか
値レシーバでメソッドを呼ぶと、構造体のコピーが渡される。
sync.Mutex はコピーしてはいけない(内部状態が共有されなくなる)。
しかもこれ、コピーしても一見動いているように見えるのが厄介。
count++ はコピー側で起きているので、元の構造体には反映されない。
正しい書き方
func (c *SafeCounter) Increment() { // ポインタレシーバにする
c.mu.Lock()
defer c.mu.Unlock()
c.count++
}
sync.Mutex を含む構造体は必ずポインタレシーバで扱う。
go vet が検出してくれるので、CIに組み込んでおくこと。
🕳️ 地雷10: 標準入力の bufio をサボる
やらかしコード
var n int
fmt.Scan(&n)
for i := 0; i < n; i++ {
var s string
fmt.Scan(&s)
// 処理...
}
// n が大きいと死ぬほど遅い
なぜ起きるか
fmt.Scan は呼び出しのたびにシステムコールを発行する。
入力が大量にあると、オーバーヘッドが積み重なって体感できるレベルで遅くなる。
AtCoderをGoで解こうとして最初に踏む罠でもある。
正しい書き方
reader := bufio.NewReader(os.Stdin)
var n int
fmt.Fscan(reader, &n)
for i := 0; i < n; i++ {
var s string
fmt.Fscan(reader, &s)
// 処理...
}
バッファリングするだけで劇的に速くなる。
毎回 bufio.NewReader の書き方を忘れて検索するのは私だけではないはず。
まとめ
| # | 地雷 | 一言まとめ |
|---|---|---|
| 1 | forクロージャ変数キャプチャ | ループ内で変数をシャドーイング |
| 2 | nilインターフェース | 型情報が入ると nil じゃなくなる |
| 3 | スライスのappend副作用 | コピーするか容量を制限する |
| 4 | goroutineリーク | context とバッファ付きチャネルで対策 |
| 5 | mapの並行読み書き | sync.RWMutex か sync.Map を使う |
| 6 | エラーを_で捨てる |
必ず %w でラップして伝播させる |
| 7 | time.Timeのゼロ値 | IsZero() で確認、DBには要注意 |
| 8 | defer のループ内使用 | 処理を別関数に切り出す |
| 9 | Mutexを含む構造体のコピー | 必ずポインタレシーバで扱う |
| 10 | fmt.Scan が遅い | bufio.NewReader を使う |
「それ知ってるわ」と思った地雷もあったかもしれないが、知ってるのにやらかすのが地雷というものです。
他にも「これは地雷だった」というネタがあればコメントで教えてください。
もし役に立ったら、いいねとストックもらえると励みになります🙇