0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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 という変数への参照を持っている。

ループが終わった時点で i5 になっているので、全部 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.Iserrors.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 を使う

「それ知ってるわ」と思った地雷もあったかもしれないが、知ってるのにやらかすのが地雷というものです。

他にも「これは地雷だった」というネタがあればコメントで教えてください。

もし役に立ったら、いいねストックもらえると励みになります🙇

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?