🐱

Goのアンチパターン集

に公開

Go言語で開発を続けていると、便利な言語機能ゆえについ陥ってしまう非推奨なコーディングパターン=アンチパターンが存在します。本記事では、広範なカテゴリーにわたるアンチパターンとその改善策を、具体的なコード例とともに解説します。各アンチパターンについて、その状況と文脈、問題点(保守性・性能・正当性への悪影響)、そして推奨されるベストプラクティスや改善例を示します。

コーディングスタイルにおけるアンチパターン

まずは基本的なコーディングスタイル上のアンチパターンです。これらはコードの可読性や意図の明確さを損ね、場合によってはバグの温床にもなりかねません。

Blank識別子(_)の不必要な乱用

アンチパターンの文脈: Goでは使わない変数を表すためにブランク識別子_が使えますが、不要な場面で乱用するケースがあります。例えばforループでインデックス変数を使わないのに_で受けてしまう、あるいはマップアクセスやチャネル受信で戻り値を_に代入するといったコードです。

問題点: 不要な_はコードを冗長にし、読みにくくします。また、本来処理すべきエラーや値を意図せず無視してしまう危険もあります。Go仕様では、for rangeループの最後の変数をブランクにする場合、それは変数宣言を省略したのと同じ意味になります。つまりfor _ = range ...と書く必要はなく、単にfor range ...で十分です。

アンチパターン例(不要な_の使用): 次のコードでは、forループでインデックスを使わないにもかかわらず_を明示しています。また、マップから値を取り出す際やチャネル受信時にも無意味に_に代入しています。

// 良くない例
for _ = range list {
    process()  // インデックスは使わないのに_を使用
}

value, _ := someMap[key]  // キーの存在だけ確認したいのに値を_に捨てている
_ = <-ch                  // 受信値を使わず無意味に_へ代入

改善策: 不要なブランク識別子は省き、シンプルに書きます。例えば上記の例では以下のように書き換えられます。

// 改善例
for range list {
    process()  // インデックスを使わない場合はそのままでOK
}

value := someMap[key]    // 必要な値のみ取得する
<-ch                     // 受信して破棄するだけなら代入しない

このように、冗長な_を避けることでコードの意図が明確になり、余計な情報が減ります。

無意味なreturn・不要なbreakの記述

アンチパターンの文脈: 関数の最後に値を返さないreturnを書いたり、switch文の各ケース末尾に明示的なbreakを書いてしまうケースがあります。C言語などの習慣で書いてしまうことがありますが、Goでは不要です。

問題点: これらは冗長であり、読んだ人に「特別な意図があるのか?」と余計な考察を促してしまいます。Goの関数では、戻り値のない場合は最後のreturnを省略できますし、switchはデフォルトでフォールスルーしないためbreakも不要です。

アンチパターン例(無駄なreturnbreak

func logMessage(msg string) {
    fmt.Println(msg)
    return        // ←このreturnは不要
}

switch mode {
case 1:
    fmt.Println("Mode1")
    break         // ←Goでは不要
case 2:
    fmt.Println("Mode2")
    break         // 同上
}

上記では、logMessage関数の最後のreturnも、switch文のbreakも意味を成しません。

改善策: 不要なキーワードは削除し、シンプルにします。returnは何も返すものがなければ書かなくて構いません。switchでは暗黙で各ケース終了時に抜けるためbreakは省略します。

func logMessage(msg string) {
    fmt.Println(msg)
    // returnは不要
}

switch mode {
case 1:
    fmt.Println("Mode1")
case 2:
    fmt.Println("Mode2")
}

このように書くことで、コードがすっきりしGoの言語仕様にも沿った形になり、無駄な部分で読み手を混乱させることがなくなります。

アーキテクチャ設計のアンチパターン

コードの構造や設計に関するアンチパターンです。アプリケーション全体の保守性や拡張性に影響を与える重要な部分なので、注意が必要です。

グローバル状態の乱用

アンチパターンの文脈: 設定情報やデータベース接続、ログインスタンスなどをパッケージ内のグローバル変数として定義し、どこからでも参照・更新する設計です。また、シングルトン的なグローバルオブジェクトに依存するコードもここに含まれます。

問題点: グローバル状態に依存すると、モジュール間の結合度が高くなり、コードの振る舞いがコンテキストによって変わり得るため理解とテストが困難になります。テスト時に状態をモックしたりリセットしたりするのが難しく、予期せぬ副作用でテストが不安定になることもあります。また、並行処理で共有状態を適切に保護しないとデータ競合の原因にもなります。

アンチパターン例(グローバル状態の利用)

// グローバル変数の乱用例
package config

var DB *sql.DB  // グローバルなデータベース接続

func InitDB() {
    DB, _ = sql.Open("postgres", "connstring")  // グローバルに接続を設定
}

// 別のパッケージで直接グローバルDBを使用
package user

import "myapp/config"

func GetUser(id int) (*User, error) {
    row := config.DB.QueryRow("SELECT ...", id)
    // ...
}

この例では、config.DBがどこでも直接参照されており、例えばテストで別の設定に差し替えることが困難です。

改善策: 依存関係は明示的に渡す(Dependency Injection)か、必要に応じインターフェースで抽象化して扱います。上記の例では、DBを直接使うのではなく、例えばGetUser関数に*sql.DBを引数で渡すか、UserStoreインターフェースを定義して実装を注入する方法があります。こうすることでグローバルな共有状態を避け、モジュール間の独立性を高められます。またシングルトンが必要な場合でも、シングルトンオブジェクトを返す関数を用意し、テスト時にはそれを差し替えるなどの工夫でグローバル依存を薄めることが望ましいです。

過剰な抽象化とインターフェースの乱用

アンチパターンの文脈: 将来の拡張を考えすぎるあまり、必要になるか分からない段階でインターフェースや抽象層を定義してしまうケースです(いわゆる Preemptive Interface アンチパターン)。例えば、具体的な型が一つしかないのにインターフェースIXXXを定義し、ファクトリ関数でインターフェースを返すような実装です。

問題点: 不必要な抽象化はコードを複雑にし、読み手にとって余計な負荷となります。特にGoではインターフェース実装は暗黙的であり、必要になってから定義すれば十分な場合が多いです。早すぎる抽象化はYAGNI(必要になるまでは実装するな)の原則に反し、保守性を下げます。

アンチパターン例(不要なインターフェース実装)

// インターフェースを先に作ってしまう例
type IUser interface {
    Hello() error
}
type User struct{}
func NewUser() IUser {
    return User{}  // インターフェースで返す
}
func (u User) Hello() error {
    return nil
}

上記ではUser型しか存在しないにもかかわらず、IUserというインターフェースを定義しNewUserでそれを返しています。このような「先走ったインターフェース」はGoでは非推奨の場合があります。

改善策: 真に複数の実装を切り替える必要が出てからインターフェースを導入すれば十分です。例えば、テストのためにモック実装が欲しい場合や、将来的に別の実装を提供する予定が明確な場合に限りインターフェースを使います。それ以外では具体型をそのまま扱った方がシンプルで可読性が高く、オーバーヘッドも減ります。また、インターフェース名にIプレフィックスを付ける(例: IUser)命名もGoの慣習にはなく、避けるのが一般的です。

標準型の埋め込みの誤用(構造体埋め込みによる型非互換)

アンチパターンの文脈: 標準ライブラリの型を拡張しようとして、既存型をそのまま構造体埋め込みするパターンです。例えば、標準のtime.Time型を埋め込んだ独自構造体を作り、新たなメソッドを追加するようなケースが該当します。

// time.Timeを埋め込んで独自拡張する例
package mytime
import "time"
type Time struct {
    time.Time            // time.Timeを埋め込む
}
func (t *Time) Hello() {
    fmt.Println("hello")
}

一見、埋め込むことで元のtime.Timeのメソッドもそのまま使え、新たなメソッドHelloも追加できて便利に思えます。

問題点: 埋め込んだ結果生まれる新しい型(上記ではmytime.Time)は、元のtime.Timeとは互換性のない別型になります。そのため、ライブラリや他パッケージの関数で「具体的にtime.Time型」を要求している場合に渡せなかったり、interface{}経由で渡せても内部で型アサーションされて動作しない、といった予期せぬ問題を引き起こします。実際に、Google Cloud Datastoreなどのライブラリではinterface{}引数に渡された値がtime.Timeかどうかチェックしており、埋め込み型は通らずに無視されてしまうという実例があります。

改善策: 標準型の振る舞いを拡張したい場合、埋め込みではなくラップする(コンポジション)か、ユーティリティ関数を別途用意する方法が安全です。例えば上記mytime.Timeに対して、埋め込みではなく内部にtime.Timeフィールドを持たせ、必要なら明示的にtime.Timeを取り出すメソッドを用意することも検討します。あるいは、追加したい機能を単独の関数(例えばmytime.Hello(t time.Time))として提供するだけでも十分な場合があります。要は、埋め込みによる型拡張はその型の判別ロジックを壊すリスクがあると理解し、慎重に使うか代替手段をとることが肝要です。

車輪の再発明

アンチパターンの文脈: 標準ライブラリや既存の実績あるパッケージがあるにもかかわらず、同じ機能を一から自前実装してしまうケースです。例えば、ログ出力機能を自作する、文字列操作関数を独自に書く、並列処理のワークキューを一から書く、といったことが当てはまります。

問題点: 自前実装は時間と労力の無駄になるだけでなく、実績ある実装に比べてバグを生み込む可能性が高いです。Goには豊富な標準ライブラリと高品質な外部パッケージが存在するので、それらを使わずに同等機能を作るのは「車輪の再発明」と言われます。既存ライブラリは多くの開発者により十分にテストされ、最適化も施されているため、利用することで時間節約・バグ防止・保守性向上に繋がります。

アンチパターン例(車輪の再発明): 下記はシンプルな例ですが、例えば文字列をCSV形式にエスケープする処理を自前で実装しています。

// 良くない例: 自前でCSVエスケープを実装
func escapeCSV(s string) string {
    result := ""
    for _, ch := range s {
        if ch == ',' || ch == '"' {
            result += `"` + string(ch) + `"`
        } else {
            result += string(ch)
        }
    }
    return result
}

上記はカンマやダブルクオートをクオートする処理ですが、このような処理は標準ライブラリencoding/csvで既に提供されています。自作するとエスケープ漏れなどバグの原因になります。

改善策: 実装に取り掛かる前に、標準パッケージや実績ある外部ライブラリに同等の機能がないか調査しましょう。Goコミュニティには充実したパッケージエコシステムがあり、たいていの問題は既存コードで解決されています。どうしても独自実装が必要な場合でも、既存の実装を参考にしたり、それをラップする形で不足部分を補うなど、ゼロから書かない工夫が重要です。

なお、並行処理における同期処理の自前実装も再発明の一種です。例えば、ゴルーチンの完了を待つのに無理矢理カウンタやチャネルで実装するより、sync.WaitGroupを使う方が明瞭ですし、mutexの実装を自作するべきでないのは言うまでもありません。Goのsyncパッケージは並行処理の基本ツールセットなので、活用しないこと自体がアンチパターンとされています。

テストに関するアンチパターン

ソフトウェアテストの書き方にもアンチパターンがあります。テストコードはプロダクションコード以上に安定性と明瞭さが求められるため、悪いテストの書き方はバグを見逃したり、将来のリファクタリング時に信頼できるセーフティネットとならなかったりします。

グローバル状態に依存するテスト

アンチパターンの文脈: テストケース同士が暗黙の共有状態に依存している場合です。例えば、テストAでセットしたグローバル変数をテストBが前提としている、または複数のテストが同じデータベースや外部リソースを共有しているといった状況です。

問題点: テスト順序によって結果が変わる非決定的なテストになり、テストスイート全体の信頼性を損ないます。あるテストが副作用でグローバル状態を変えると、別のテストが通ったり失敗したりしてデバッグが困難になります。並列実行 (go test -parallel) 時にはさらに不安定さが増します。

アンチパターン例(テスト間でグローバル状態を共有)

var configLoaded bool

func LoadConfig() {
    configLoaded = true
}

func TestLoadConfig(t *testing.T) {
    LoadConfig()
    if !configLoaded {
        t.Fatal("設定がロードされていない")
    }
}

func TestFeatureX(t *testing.T) {
    if !configLoaded {
        t.Fatal("前提: 設定がロードされていないと失敗")  // TestLoadConfigが先に動くかで結果が変わる
    }
    // ...
}

TestFeatureXconfigLoadedtrueであることを期待していますが、これはTestLoadConfigが先に実行されることに依存しています。実行順が変われば失敗しうるテストです。

改善策: 各テストは独立して完結するようにします。必要な前提条件(ここでは設定ロード)は、各テスト関数内でセットアップするか、テストのSetup/Teardown処理(例えばTestMain関数や各テストで共通の初期化関数)で行い、グローバルな副作用を排除します。上記例では、TestFeatureX内で明示的にLoadConfig()を呼ぶか、モック可能な構造にしてテストごとに初期化するのが望ましいです。また、外部リソース(DBやファイル)はテスト用に毎回新しい環境を用意する、もしくはインメモリの代替を使うなど、テストケース間で状態を持ち回らないように設計します。

タイミングに依存するテスト

アンチパターンの文脈: 並行処理や非同期処理の結果を検証する際に、固定のSleepで待ち時間を入れてテストが通るだろうとするケースです。また、時間経過やタイマーに依存して結果が変わるロジックをそのままテストして、環境や負荷によって結果が不安定になる場合も含まれます。

問題点: 一定時間待つ方法は、環境によって必要時間が異なるため脆いテストになります。例えばtime.Sleep(1 * time.Second)で十分だと思っていても、CI環境や高負荷時には処理が終わらずテストが失敗することがありますし、逆に長すぎる待ち時間はテストを遅くします。結果的にテストの信頼性が低下し、タイミングが原因のゆらぎ(フレーク)テストとなります。

アンチパターン例(Sleepによる待機)

func TestAsyncWork(t *testing.T) {
    done := false
    go func() {
        doWork()       // ゴルーチンで非同期処理
        done = true
    }()
    time.Sleep(100 * time.Millisecond)  // とりあえず100ms待つ
    if !done {
        t.Fatal("非同期処理が完了していない")
    }
}

上記テストはdoWork()の完了を100ms待っています。しかし処理時間が100msを超えれば不安定に失敗しますし、逆に早く終わった場合でも無駄に待っていることになります。

改善策: 明示的な同期手段を用いて待ち合わせるべきです。例えばチャネルやsync.WaitGroupを使って「完了したらシグナルを送る」ようにします。上記をチャネルで書き換えると

func TestAsyncWork(t *testing.T) {
    doneCh := make(chan bool)
    go func() {
        doWork()
        doneCh <- true  // 完了を通知
    }()
    select {
    case <-doneCh:
        // 正常終了、処理完了
    case <-time.After(1 * time.Second):
        t.Fatal("非同期処理がタイムアウトしました")
    }
}

これで一定時間内に完了しなければタイムアウトで失敗とし、完了すれば即座に先に進みます。時間依存を避け、論理的な完了通知を待つことでテストの確実性が向上します。
また、時間に依存するロジック(例えば現在時刻によって振る舞いが変わる関数)は、時間をinjectできるように設計し、テスト時には固定の時刻を与えて検証するなどの工夫も重要です。

検証漏れ・エラー無視のテスト

アンチパターンの文脈: テストコード自体が、呼び出した関数のエラー戻り値を無視したり、期待すべき出力を検証しなかったりする場合です。意図したアサーションを書いておらず、テストが失敗すべき状況でも成功してしまうケースが当てはまります。

問題点: テストが真にコードの正しさを検証できておらず、擬似的な成功をしてしまうため、バグの見逃しにつながります。特にerrを返す関数をテスト中に呼び出しておきながら、そのerrをチェックしないのは、本番コード中のエラー無視と同様に有害です。

アンチパターン例(テスト内でのエラー無視)

func doSomething() error {
    return errors.New("failure")
}

func TestDoSomething(t *testing.T) {
    doSomething()  // 戻り値のエラーを無視している
    // 何も検証せずに終わる -> エラーが起きてもテストは成功してしまう
}

このテストはdoSomething()が必ずエラーを返すのにそれをチェックしていないため、テスト自体は常に成功してしまいます。

改善策: テストではあらゆる戻り値や結果を検証するようにします。エラーを返す関数であれば、エラーが期待通り発生する/しないを必ずチェックするべきです。上記の場合、例えば「エラーが発生すること」を確認したいなら

func TestDoSomething(t *testing.T) {
    err := doSomething()
    if err == nil {
        t.Fatal("エラーが発生すべき状況でerrがnilでした")
    }
    // エラーメッセージや型が期待通りかもチェックできる
}

逆にエラーが起きないことを期待するならif err != nil { t.Fatal(...)}でテスト失敗させます。テストは成功条件および失敗条件を明示的に検証することで初めて意味があります。出力値のチェック、副作用の確認、複数回実行時の挙動など、漏れなく検証する習慣を持ちましょう。

エラー処理のアンチパターン

Goにおけるエラー処理はシンプルですが、その分開発者の判断に委ねられる部分も多く、誤ったパターンが入り込みがちです。堅牢なアプリケーションを作るにはエラー処理のベストプラクティスを守る必要があります。

エラーを無視する (if err != nil を書かない)

アンチパターンの文脈: 関数からのエラー戻り値をチェックせず無視することです。典型的には_で捨てたり、変数を受け取らずに関数だけ呼んでしまうケースです。

問題点: エラー無視は最も深刻なアンチパターンの一つです。エラーを無視すると、発生した問題に気付けず後続処理を続行してしまい、データ不整合やセキュリティ脆弱性、プログラムのクラッシュなど思わぬ不具合に繋がります。Goではエラーは明示的に返す設計になっているため、無視して良いエラーは基本的に存在しません。

アンチパターン例(エラーの無視)

data, _ := os.ReadFile("config.json")  // ファイル読み込みエラーを無視している
config := parseConfig(data)

上記ではファイル読み込みのエラーを_で捨てています。ファイルが存在しない場合などdataは不定となり、そのままparseConfigを呼べばパニックになるかもしれません。

改善策: 常にエラーをチェックし処理することです。エラーが返ったら、適切にハンドリングするか呼び元に返すようにします。例えば

data, err := os.ReadFile("config.json")
if err != nil {
    return nil, fmt.Errorf("設定ファイル読み込み失敗: %w", err)
}
config := parseConfig(data)

このようにすれば、エラー発生時に原因を包んで上位に伝播できます。使わないエラーであってもerr := foo(); _ = errのように明示的に無視するコードは極力避け、設計上無視が妥当な場合でもコメントで理由を示すべきです。「エラーを無視しない」——これがGo開発の鉄則です。

エラー処理にpanicを乱用する

アンチパターンの文脈: エラー発生時にpanicで実行を中断しようとするパターンです。特にエラーを返すべき通常のケースまでpanicで対処してしまうコードが該当します。

問題点: panicはプログラムの実行を即座に中断しスタックを巻き戻す非常手段です。通常のエラー処理に乱用すべきではありません。ライブラリ関数でpanicが起これば、そのプロセス全体(スレッドではなくGoのゴルーチン全体)が中断し、リカバリしなければプログラム自体がクラッシュします。呼び出し側でrecoverによる対処が必要になりますが、エラー戻り値と異なりコントロールフローが見えづらく保守を難しくします。

アンチパターン例(安易なpanic

func findConfig(path string) string {
    data, err := os.ReadFile(path)
    if err != nil {
        panic("設定ファイルが見つかりません!")  // ファイル無いだけでpanicしている
    }
    return string(data)
}

設定ファイルがないという起こり得るエラーpanicしてしまっています。これはライブラリ関数として提供されているなら非常に危険ですし、ウェブサーバー内で使われていればリクエスト処理が途中で落ち、他の処理にも影響します。

改善策: 通常のエラー処理にはerrorを使い、panicは本当にありえない事態のみと覚えてください。基本はエラーを戻り値で返し、呼び出し元で適切に処理させます。上記関数も(string, error)を返すようにし、err != nilの場合はerrorを返すべきです。例外的に、初期化処理で致命的なエラーが起きた場合にログを出力して終了するなど、プログラム全体を止めたい場面ではpanicも選択肢です。その場合でもlog.Fatalos.Exitで代替可能です。

Goの公式ガイドラインでも、「ライブラリ関数内でpanicを避け、内部ではpanicしても外部にはerrorとして返すようにすべき」と示されています。つまりパニックは外部に伝播させず、エラー値に変換して扱うのが原則です。panic/recoverは高度な制御が必要な場面(例えば並行処理のゴルーチン内部でパニックをキャッチしてログに記録しゴルーチンを終了させる等)に限り使用し、通常のエラー制御フローとして乱用しないことが大切です。

不明瞭なエラー情報(コンテキストの欠如・nilで返す等)

アンチパターンの文脈: エラー発生時に十分な情報を持たせず返すことや、エラーを示すのにエラー値ではなく特定の値(例えばnilやマジックナンバー)で表現してしまうケースです。例えば「単にerrors.New("failed")だけ返してどこで何が失敗したか分からない」「エラー発生時にnilを返して呼び出し側でチェックさせようとする」などが当てはまります。

問題点: エラーの原因や文脈が分からないと、デバッグやログ解析に大きな支障が出ます。特に上流にエラーを伝播する際に、何も付加情報を与えずそのまま返すとスタックトレースも無いGoでは追跡が困難です。また、エラーを本来error型で返すべきところをnilや特別な戻り値で示す設計は、呼び出し側がそれを見落とす可能性があり危険です。Goではエラーは必ずerrorインターフェースで表現し、正常時でもエラー戻り値はnilを返すシグネチャが一般的です。設計を逸脱してnilそのものに別意味を持たせると可読性・安全性が下がります。

アンチパターン例1(エラーに文脈を付与しない)

func parse(data []byte) error {
    // ...
    return errors.New("parse failed")
}

err := parse(input)
if err != nil {
    log.Fatal(err)  // ログには "parse failed" だけでは原因が不明
}

この例ではparse failedというメッセージだけでは何の解析に失敗したのか分かりません。複数箇所で同様のエラーを返していれば特定不能です。

アンチパターン例2(エラーをnilで表現する)

func GetResource(id string) *Resource {
    if notFound {
        return nil  // リソースが無い場合はnilを返すだけ
    }
    if err != nil {
        return nil  // エラー時もnilで済ませてしまう
    }
    return &Resource{/*...*/}
}

// 呼び出し側
res := GetResource("abc")
if res == nil {
    // エラーなのか、ただ存在しないだけなのか区別できない
}

GetResourceが失敗時にエラーを返さずnilだけ返す設計はアンチパターンです。呼び出し側は返り値がnilだった場合に原因を知るすべがなく、ログにも残りません。

改善策: エラーには十分な文脈情報を付与しましょう。エラーを上位に返す際は、fmt.Errorf("~: %w", err)のようにどの操作中に何が起きたか明示します。Go1.13以降の%wによるエラーラップを活用すれば、元のエラーを保持しつつメッセージを追加できます。上記parse関数の例なら

return fmt.Errorf("入力データの解析失敗: %w", err)

のように返すだけで、ログには「入力データの解析失敗: 元のエラー…」と原因までわかります。あるいは必要に応じてカスタムエラー型を定義し、フィールドに詳細情報(IDや発生箇所)を持たせるのも有効です。

また、関数のシグネチャ設計として、エラーを返すべき場合には必ずerror型を返すようにします。上記GetResourceの例では、(*Resource, error)を返すよう変更すべきです。例えば

func GetResource(id string) (*Resource, error) {
    if notFound {
        return nil, errors.New("resource not found")
    }
    if err != nil {
        return nil, fmt.Errorf("error loading resource: %w", err)
    }
    return &Resource{/*...*/}, nil
}

こうすることで呼び出し側はres, err := GetResource("abc")とし、errを判定すれば「エラーで取れなかった」のか「存在しないだけなのか」を判別できます。実際、エラーの代わりにnil値を返すのはGoでよく見られるアンチパターンであり、避けるべき設計だとされています。

並行処理のアンチパターン

Goの特徴であるゴルーチンとチャネルを用いた並行処理は強力ですが、その分ミスも起きやすい領域です。ここでは並行プログラミング特有のアンチパターンを紹介します。

共有データへの同期なしアクセス(データ競合)

アンチパターンの文脈: 複数のゴルーチンから同じ変数やデータ構造を保護せず読み書きするケースです。初心者のみならず上級者でも、うっかり保護を忘れると発生します。

問題点: いわゆるレースコンディションが発生し、プログラムの動作が不定になります。競合状態では実行タイミングによって結果が変わったり、メモリ破壊や不正な値の読み取りが起こり得ます。Goではビルトインの競合検出ツール(go run -race)がありますが、検出される時点でバグです。

アンチパターン例(同期なしの共有変数アクセス)

var count = 0

func increment() {
    count++  // 複数ゴルーチンから呼ばれると競合の可能性
}

func main() {
    for i := 0; i < 1000; i++ {
        go increment()
    }
    time.Sleep(1 * time.Second)
    fmt.Println("count =", count)  // 常に1000になるとは限らない
}

上記では1000回インクリメントしていますが、競合状態のためcountが1000になる保証はありません。実行のたびに結果が異なるか、最悪メモリ不正が起きます。

改善策: 共有データには適切な同期(ロックやチャネル)を用いることです。シンプルなカウンタであればsync.Mutexで保護するか、後述のatomicを使います。上記をMutexで修正すると

var mu sync.Mutex
var count = 0

func increment() {
    mu.Lock()
    count++
    mu.Unlock()
}

これで同時更新が直列化され、競合は起きません。読み込みだけでも、他で書き込みがあるならロックが必要です。また、チャネルを活用してゴルーチン間のデータ受け渡しを行い、共有メモリを極力持たないという方針も有効です(「共有メモリによる通信ではなく、通信によるメモリ共有を」するというGoのスローガン参照)。いずれにせよ、並行アクセスするデータには明示的な同期を忘れないことが重要です。

過度なロックによるボトルネック

アンチパターンの文脈: 排他制御のためにMutexなどのロックを使うのは正しいアプローチですが、範囲が広すぎたり高頻度で使いすぎると、ゴルーチンがロック待ちで滞留して性能を損ねる場合があります。特に軽量な操作にも関わらず大きなロックで囲んでしまうケースです。

問題点: ロック競合が激しいと、CPUのコアがあってもゴルーチンが実行できず待ちが発生し、スループットが低下します。場合によってはデッドロックの危険もあります。ゴルーチン数が増えると線形に性能が落ち、並行性の利点が失われます。

アンチパターン例(不要に広いロック範囲)

type Counter struct {
    mu sync.Mutex
    value int64
}
func (c *Counter) Increment() {
    c.mu.Lock()
    c.value++    // この操作自体は軽いが毎回ロック
    c.mu.Unlock()
}

単なるカウンタ増加でもMutexでロックしており、頻繁に呼ばれると競合待ちになります。

改善策: ロックの粒度を適切に調整します。一つは軽量な原子操作への置き換えです。上記カウンタであれば、sync/atomicパッケージの原子操作でロック無しに加算できます

import "sync/atomic"
type Counter struct {
    value int64
}
func (c *Counter) Increment() {
    atomic.AddInt64(&c.value, 1)
}

これでロックのオーバーヘッドなしに安全な加算が可能です(ただし複雑な複数変数の操作にはatomicよりMutexが必要な場合もあります)。他にも、読み込みが圧倒的に多い場合はsync.RWMutexで読取ロックと書込ロックを分離する、クリティカルセクションをできるだけ短くする、といった対策もあります。

要は、必要以上のロックは並行性能を殺すので、データ構造や用途に応じて最適な同期手段を選ぶことが重要です。

無制御のゴルーチン生成(ゴルーチンリーク)

アンチパターンの文脈: ゴルーチンを生成するのが軽量だからと安易にどんどん生成し、制御や終了管理をしていないケースです。特にサーバーでリクエストごとに新たなゴルーチンを起動しっぱなしにするようなコードや、無限にゴルーチンを立ち上げるバックグラウンド処理が該当します。

問題点: ゴルーチンが終了しないまま溜まり続けると、メモリを圧迫し最終的にシステム資源枯渇やスローダウン、クラッシュを招きます。また過剰なゴルーチン生成はコンテキストスイッチのオーバーヘッドも増大させます。

アンチパターン例(リクエストごと無責任にゴルーチン起動)

func handler(w http.ResponseWriter, r *http.Request) {
    go doWork(r.FormValue("id"))  // リクエスト処理とは別に非同期ジョブを起動
    w.WriteHeader(http.StatusOK)
    // 応答返した後もdoWorkは動き続けるが、管理されていない
}

このHTTPハンドラはリクエストのたびにdoWorkゴルーチンを投げっぱなしにしています。大量リクエスト時に何千ものゴルーチンが発生し、完了やキャンセルの管理をしていないため、サーバーが高負荷になります。

改善策: ゴルーチンにはライフサイクル管理とキャンセル手段を持たせることです。上記の例では、context.Contextを利用してキャンセルやタイムアウト制御を行う方法が考えられます。例えば

func handler(w http.ResponseWriter, r *http.Request) {
    ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
    defer cancel()
    go func() {
        // コンテキストのDoneチャンネルを使い、終了を適宜判断
        select {
        case <-ctx.Done():
            return  // タイムアウト等で中断
        default:
            doWork(r.FormValue("id"))
        }
    }()
    w.WriteHeader(http.StatusOK)
}

このようにすれば、リクエストキャンセルやタイムアウト時にゴルーチン内でctx.Done()を受信して終了できます。加えて、ワーカープールを使ってゴルーチン数を一定に抑える、ジョブキューが溢れたら古いものを捨てるなどの対策も状況によっては必要です。

ゴルーチンリークを防ぐには「開始したゴルーチンには必ず終了条件を用意する」「不要になったら確実に終了させる」ことが基本原則です。contextの活用や、チャネルのクローズを使ったシグナルでゴルーチンを終了させるパターンなどを組み合わせ、野放図な生成は避けましょう。

チャネルの不適切な使用(無バッファチャネル/クローズ忘れ等)

アンチパターンの文脈: チャネルはゴルーチン間のデータ受け渡しに便利ですが、使い方を誤るとデッドロックやパフォーマンス低下を招きます。典型例として、無容量(バッファなし)チャネルを大量の送受信に使う、チャネルをクローズせず受信側が永遠に待ち続ける、といったケースがあります。

問題点: 無バッファチャネルは送信と受信がペアで揃わないとブロックするため、並行度の高いシナリオでスループットが出ません。大量のワーカーに無バッファチャネルでジョブを送るとワーカーが頻繁にブロックし、CPUが有効活用されなくなります。一方、チャネルのクローズ漏れは、受信側がfor rangeで待ち続けてゴルーチンが終了しない原因となります。また、送信側がいなくなっても受信側がブロックし続けるケース(終了シグナルをデータ送信で伝える実装のバグなど)もデッドロックの元です。

アンチパターン例1(無バッファチャネルで大量タスク投入)

jobs := make(chan Job)  // バッファ0のチャネル
for i := 0; i < 100; i++ {
    go worker(jobs)
}
for j := 0; j < 1000; j++ {
    jobs <- Job{ID: j}  // ワーカーが空いていないとここでブロックする
}
close(jobs)

100個のワーカーに1000個のジョブを流していますが、バッファ無しのためジョブ投入側も受信側も互いに待つ場面が多発し、CPUが遊んでしまいます。

アンチパターン例2(チャネルのクローズ忘れ)

func produce(out chan<- int) {
    for i := 0; i < 10; i++ {
        out <- i
    }
    // close(out) を忘れた
}

func consume(in <-chan int) {
    for v := range in {
        fmt.Println("recv:", v)
    }
    fmt.Println("done")  // チャネルが閉じられない限りここに到達しない
}

func main() {
    ch := make(chan int)
    go consume(ch)
    produce(ch)
}

produce関数でclose(ch)しないため、consume側のfor rangeは10個受信した後もチャネルが閉じられるのを待ち続け、doneが表示されずゴルーチンが終了しません。これも一種のゴルーチンリークです。

改善策: チャネルの特性に応じた適切な使い方を心がけます。大量のデータを送る場合は、必要に応じてバッファを持つチャネルを使いブロッキングを緩和します。たとえば上記jobsチャネルはmake(chan Job, 100)などとすれば、一時的に100件バッファしつつワーカーが平行に処理できます(最適なバッファサイズは状況によります)。一方で、バッファを大きくしすぎるとメモリを圧迫するので適切な値を選ぶ必要があります。

チャネルのクローズについては、送信側が全ての送信を終えたら必ずcloseするのが原則です。受信側ではfor v := range chパターンを使うことで、閉じられた時にループを自動で抜けて終了処理できます。closeしないまま受信側が待機するとデッドロックになるので注意しましょう。Goではチャネルに対するcloseは受信完了通知として使うのが望ましく、終了シグナルを送るためだけの別チャネルを設けてデータを送信するやり方は迷子のゴルーチンを生む可能性があり推奨されない場合があります。

また、select文でのチャネル待ちも注意点があります。ケースが一つしかないselectは不要なので通常の送受信で書く、複数チャネルを待つ場合も最終的にどちらもブロックする組み合わせにならないよう設計する必要があります(デフォルトケースでタイムアウトや無処理を入れるなど)。

パフォーマンスに関するアンチパターン

最後に、Goプログラムの性能面で陥りやすいアンチパターンです。高性能が売りのGoですが、無自覚な実装でその利点を殺してしまうことがあります。

ループ内でのdefer多用

アンチパターンの文脈: 後片付けを簡潔に書けるdeferですが、短いループの中で多数呼び出すと実行遅延コールが蓄積しパフォーマンスに影響します。また、ループ内でdeferすると実行がループ終了後(関数終了時)までされないため、リソース解放が遅れる恐れもあります。

問題点: Goの全体的なランタイム性能向上がありますが、defer呼び出しでも何万回も呼べば依然として無視できないコストとなります。特に簡単な計算の繰り返し内でdeferを使うと、ループ毎に後処理登録の負荷がかかり遅くなります。また、例えばファイルをループ内で開いてdefer file.Close()とすると、ファイルクローズは関数終了まで行われず、ループが長時間回るとファイルディスクリプタを大量に占有する可能性があります。

アンチパターン例(ループ中でのdefer

for _, file := range files {
    f, err := os.Open(file)
    if err != nil { continue }
    defer f.Close()  // ループ内でdefer
    // ...ファイル処理...
}

1000個のファイルを読む場合、このコードは1000回deferを登録し、関数終了時まで全ファイルを開きっぱなしにします。

改善策: ループ内ではdeferを避け、明示的にクローズや解放を行う方が良い場合があります。上記は以下のように書きます

for _, file := range files {
    f, err := os.Open(file)
    if err != nil { continue }
    // ...ファイル処理...
    f.Close()  // その場でクローズ
}

これなら都度確実にクローズし、無駄なdefer蓄積もありません。deferは主に関数スコープの終わりでまとめて片付けたい場合に使い、ループ内など多数発生する場面では注意が必要です(どうしてもdeferが使いたい場合は、ループ本体を別の関数に切り出し、その中でdeferするといった手もあります)。JetBrainsの静的解析でも「ループ内のdeferはリソースリークや予測困難な実行順序につながる」と指摘されています。

非効率な文字列連結

アンチパターンの文脈: 文字列をループで結合する際に+演算子やfmt.Sprintfを多用するケースです。Goでは文字列はイミュータブル(変更不可)なので+でつなぐたびに新しい文字列を生成します。そのため大量の連結処理ではメモリアロケーションが嵩み性能が低下します。

問題点: 繰り返しの文字列連結は累積的にヒープ割り当てとコピーが発生し、実行速度とメモリ使用量に悪影響です。例えば10000要素の文字列スライスを+で順につなげると、要素数に比例してオブジェクトが作られGCの負担も増えます。少数の連結なら問題ありませんが、大きなループではアンチパターンと言えます。

アンチパターン例(ループ内+による文字列構築)

result := ""
for _, s := range list {
    result += s   // ループ毎に新たな文字列を生成
}

このコードは要素数nのリストに対し、おおよそ\boldsymbol{O(n^2)}動作となり非効率です。

改善策: 標準パッケージのstrings.Builderbytes.Bufferを使って効率的にバッファしながら連結するのが定石です。上記をstrings.Builderで書き換えると

var sb strings.Builder
for _, s := range list {
    sb.WriteString(s)
}
result := sb.String()

strings.Builderは内部バッファを伸長しながらコピーを最小限に抑えてくれるため、大量連結でも高速です。また、固定の区切り文字がある場合はstrings.Join(list, ",")のような高水準関数も活用できます。少数回の連結なら+でも問題ありませんが、繰り返し連結する場合は専用手段を使うのがベストプラクティスです。

高コストなリソースの再作成・非再利用

アンチパターンの文脈: 重い初期化コストのあるオブジェクトやコネクションを毎回使い捨てで生成するケースです。代表例として、HTTPクライアントやデータベース接続をリクエスト毎に新規作成する、スレッドプールを使わず都度作り直す、といったものがあります。

問題点: 使いまわせるものを再利用しないのは大きな性能ペナルティです。例えばhttp.Clientはコネクションプールを内部に持ち複数リクエストで共有できますが、毎回http.Client{}を新規にしているとTCP接続の確立を繰り返すことになり遅延とスループット低下を招きます。DB接続においても、コネクションプールを使わず都度sql.Openするのは非常に重く、接続ハンドシェイクの時間が無駄になります。

アンチパターン例(HTTPクライアントの使い捨て生成)

func fetchURL(url string) ([]byte, error) {
    client := &http.Client{}           // 毎回新規クライアント生成
    resp, err := client.Get(url)       // 内部で新規TCP接続
    if err != nil {
        return nil, err
    }
    defer resp.Body.Close()
    return io.ReadAll(resp.Body)
}

このfetchURLを何度も呼ぶと、そのたびに新しいhttp.Clientとコネクションが作られます。結果、接続確立のオーバーヘッドで最大40%近く性能が悪化したとの報告もあります(言語は違いますがHTTPクライアント共通の話として)。

改善策: 高コストなオブジェクトは可能な限り再利用することです。http.Clientはスレッドセーフで複数ゴルーチンから使えるので、一度作ったら使い回します。例えば上記を改善するには、パッケージ変数などにひとつクライアントを保持しておきfetchURLではそれを使う、あるいは初期化時に設定した*http.Clientポインタを関数に渡すようにします。Go標準のHTTPクライアントであるhttp.DefaultClientを使うのも手です。

データベース接続も、sql.DBは内部にコネクションプールを持つためアプリ起動時に一度開いてずっと使うのが一般的です。都度Open/Closeすると遅いだけでなくコネクション不足によるエラーの原因にもなります。

他にも、例えば大量に生成と破棄を繰り返す一時オブジェクトがある場合は、sync.Poolによるオブジェクトプールで再利用してGC負担を減らす手法もあります。ただし、プールの利用は効果測定した上で導入する必要があります。

まとめると、「一度作れば繰り返し使えるもの」は賢くキャッシュ・プールして、毎回初期化コストを払うような実装を避けるのが性能チューニングの基本です。


以上、Goの様々なアンチパターンについて見てきました。これらを踏まえて、常に「これから書くコードは読みやすく保守しやすいか、将来的に問題を起こさないか」を自問し、必要に応じてリファクタリングやパターンの適用を検討しましょう。優れたGoコードを書くには、アンチパターンを知りそれを避けることが第一歩です。ぜひ日々のコーディングで意識してみてください。

参考文献

株式会社BALEEN STUDIO

Discussion