ブログに戻る

Go(Golang)でよくある間違い 10 選:開発者が避けるべき落とし穴

Go(Golang)で特によくある 10 の間違いと、その回避方法を解説します。文字列のバイト処理、slice の append、map のゼロ値、エラーのラップ、値レシーバー、ループ内の defer、バッファなし channel などを取り上げます。

Go(Golang)でよくある間違い 10 選:開発者が避けるべき落とし穴

Golang という名前でも検索される Go は小さな言語なので、つい甘く見られがちです。Go を何年も書いてきた開発者でも、同じいくつかの落とし穴にはまります。こうしたバグのほとんどは問題なくコンパイルできます。本番環境で、プログラムが誤った答えを返したり、リソースをリークさせたり、デッドロックを起こしたりするだけです。

以下では、特によくある 10 の間違いを紹介します。それぞれに、実行できる Golang のコード例と Go らしい修正方法を添えています。

目次


1. 文字列をバイト列ではなく文字として扱ってしまう

Go の文字列は、文字ではなくバイトの読み取り専用 slice です。len(s) はバイト数を返し、s[i] でインデックスを指定するとバイトが返ります。ASCII のテキストならそれで問題ありません。しかし、1 文字が複数バイトを占めるとすぐに壊れます。

example.gogo
package main

import (
    "fmt"
    "unicode/utf8"
)

func main() {
    word := "kött"
    fmt.Println("len:", len(word))
    fmt.Println("runes:", utf8.RuneCountInString(word))
}

スウェーデン語で肉を意味するこの単語は 4 文字ですが、5 バイトあります。バイト単位のインデックスで切り出すと rune が途中で分断され、不正な UTF-8 になることがあります。

修正方法:文字数を数えるには utf8.RuneCountInString を使います。反復処理では、for i, r := range s で rune を取り出せます。部分文字列を切り出すときは、先に []rune に変換します。

2. append の戻り値を再代入し忘れる

append は、渡した slice を更新するわけではありません。容量が足りなくなると新しい基底配列を確保し、それを指す slice を返します。戻り値を無視すると、追加した要素は失われます。

example.gogo
package main

import "fmt"

func main() {
    nums := []int{1, 2, 3}
    appendWrong(nums)
    fmt.Println("wrong:", nums)

    nums = appendRight(nums)
    fmt.Println("right:", nums)
}

func appendWrong(s []int) {
    _ = append(s, 4)
}

func appendRight(s []int) []int {
    return append(s, 4)
}

実は Go では、単独の append(s, 4) 呼び出しはコンパイルエラーになります。戻り値を必ず使わなければならないからです。appendWrong の _ = は、コンパイラを通すためだけに置いています。動作としては、append を呼び出して結果を代入し直さないのと同じです。

修正方法:必ず s = append(s, x) のように再代入します。ヘルパー関数が slice に要素を追加する場合は、新しい slice を返すようにして、呼び出し側で再代入します。

3. ゼロ値が map の存在しないキーを隠してしまう

map から存在しないキーを読み取っても、エラーは返りません。返るのは map の値の型のゼロ値です。map[string]int なら 0 になり、意図的にゼロを設定したキーと見分けがつきません。

example.gogo
package main

import "fmt"

func main() {
    scores := map[string]int{"alice": 0}

    if v := scores["alice"]; v == 0 {
        fmt.Println("alice looks the same as missing users")
    }

    if v, ok := scores["bob"]; ok {
        fmt.Println("bob:", v)
    } else {
        fmt.Println("bob is missing")
    }
}

修正方法:comma-ok イディオム v, ok := m[key] を使います。この真偽値で、キーが存在したかどうかがわかります。

4. エラーのラップに %w ではなく %v を使う

%v を使った fmt.Errorf は、元のエラーを新しい文字列に埋め込むだけで、エラー値そのものは捨ててしまいます。%w を使うと新しいエラーが元のエラーをラップするので、errors.Is や errors.As で引き続き見つけられます。

example.gogo
package main

import (
    "errors"
    "fmt"
    "os"
)

func main() {
    _, err := os.Open("does-not-exist.txt")
    wrapped := fmt.Errorf("reading config: %w", err)

    fmt.Println(wrapped)
    fmt.Println("is not-exist:", errors.Is(wrapped, os.ErrNotExist))
}

修正方法:呼び出し側が元のエラーを確認する可能性があるなら、必ず %w でラップします。%v を使うのは、元のエラーが呼び出し側に依存してほしくない実装の詳細である場合だけにします。

5. 値レシーバーで変更が黙って失われる

値レシーバーを持つメソッドは、struct のコピーを受け取ります。そのコピーに加えた変更は、メソッドから戻った時点ですべて消えてしまいます。

example.gogo
package main

import "fmt"

type Counter struct {
    n int
}

func (c Counter) IncValue() {
    c.n++
}

func (c *Counter) IncPointer() {
    c.n++
}

func main() {
    c := Counter{}
    c.IncValue()
    fmt.Println("after value receiver:", c.n)

    c.IncPointer()
    fmt.Println("after pointer receiver:", c.n)
}

修正方法:メソッドがレシーバーを変更する場合や、struct が大きくてコピーが無駄になる場合は、ポインタレシーバーを使います。ある型の一部のメソッドにポインタレシーバーが必要なら、すべてのメソッドをポインタレシーバーにそろえたほうが、たいていすっきりします。

6. シャドーイングされた変数が nil エラーを返す

if の中の短い変数宣言(:=)は、そのブロックの中だけで有効な新しい変数を作ります。これが誤って外側の err をシャドーイングすると、外側の変数は nil のままになります。その結果、処理が失敗しているのに関数は成功を報告してしまいます。

example.gogo
func loadUser(id string) (*User, error) {
    var err error
    user, err := fetch(id)
    if err != nil {
        return nil, err
    }

    if err := validate(user); err != nil {
        log.Println(err)
        // the outer err is still nil here
    }

    return user, err
}

修正方法::= ではなく = を使って外側の変数に代入するか、if ブロック内ですぐに return します。go vet -shadow または govet リンターを有効にすれば、レビュー前にこうした問題を検出できます。

7. エクスポートされていない struct フィールドが JSON から消える

encoding/json から見えるのは、エクスポートされたフィールドだけです。小文字で始まるフィールドはマーシャラーから見えないので、エラーも出ないまま出力から除外されます。

example.gogo
package main

import (
    "encoding/json"
    "fmt"
)

type User struct {
    Name  string `json:"name"`
    email string
    Age   int    `json:"age,omitempty"`
}

func main() {
    u := User{Name: "Ada", email: "[email protected]", Age: 36}
    data, _ := json.Marshal(u)
    fmt.Println(string(data))
}

出力には email キーがありません。しかも、それを知らせる警告は何も出ません。

修正方法:シリアライズしたいフィールドは大文字で始め、struct タグで JSON のキー名を指定します。省略可能なフィールドには omitempty を付けます。

8. ループ内で defer を使う

defer が実行されるのは、ループの各反復が終わったときではなく、それを囲む関数から戻るときです。ループで 1,000 個のファイルを開き、それぞれに file.Close() を defer すると、関数を抜けるまで 1,000 個すべてのファイルディスクリプタが開いたままになります。

example.gogo
func processAll(paths []string) error {
    for _, p := range paths {
        f, err := os.Open(p)
        if err != nil {
            return err
        }
        defer f.Close() // all closes pile up until processAll returns
        // ... read file ...
    }
    return nil
}

修正方法:1 回分の反復処理をヘルパー関数に移し、呼び出しが終わるたびに defer が実行されるようにします。

example.gogo
func processAll(paths []string) error {
    for _, p := range paths {
        if err := processOne(p); err != nil {
            return err
        }
    }
    return nil
}

func processOne(path string) error {
    f, err := os.Open(path)
    if err != nil {
        return err
    }
    defer f.Close()
    // ... read file ...
    return nil
}

9. ブランク識別子でエラーを無視する

result, _ := doThing() は、その関数が報告する失敗をすべて捨ててしまいます。プログラムはゼロ値や中途半端な状態のまま動き続け、ようやくクラッシュしたときには、本当の原因から遠く離れた場所になっています。

example.gogo
data, _ := os.ReadFile("config.json")
_ = json.Unmarshal(data, &cfg)

ファイルが存在しなければ data は nil になり、Unmarshal は誰にも読まれないエラーを返し、cfg はゼロ値のままです。問題に気づくのは後になってから、アプリがまるで設定がまったくないかのように振る舞ったときです。

修正方法:エラーを処理するか、呼び出し元に返します。_ を使うのはエラーが本当に問題にならない場合だけにして、その理由を短いコメントで残します。

10. バッファなし channel によるデッドロック

バッファなし channel では、送信は受信側が同じタイミングで値を受け取ったときにだけ完了します。いつまでも受信されなければ、送信は永遠にブロックします。

example.gogo
package main

import "fmt"

func main() {
    ch := make(chan int) // unbuffered
    ch <- 42             // deadlock: no goroutine is receiving
    fmt.Println(<-ch)
}

送信は受信側を待ちますが、受信処理は次の行にあるため、実行される機会がありません。ランタイムはすべての goroutine が止まっていることを検出し、fatal error: all goroutines are asleep - deadlock! を出して終了します。

修正方法:送信する前に受信側の goroutine が準備できていることを確認するか、プロデューサーとコンシューマーの処理速度が異なる場合はバッファ付き channel を使います。結果を 1 つだけ受け渡すなら、バッファ 1 の channel が安全な定番です。

example.gogo
package main

import "fmt"

func main() {
    ch := make(chan int, 1) // buffered
    ch <- 42
    fmt.Println(<-ch)
}

さらに学ぶには

これら 10 個の Golang の落とし穴を知っておけば、デバッグの手間を大きく減らせます。残りは、実際のプロジェクトを作り、デバッガーを開いてエッジケースにぶつかる中で身についていきます。体系的に練習したいなら、LevelUpGo の Go トラックで、実行できる演習とコードへの自動フィードバックを通じてこれらのパターンを学べます。

シニアエンジニアのように Go を書く

ブラウザで学べるインタラクティブなレッスン。最初のレッスンは無料です。

無料レッスンを試すまたは無料アカウントを作成