Go言語のdeferによる遅延実行の仕組みをわかりやすく解説

スポンサーリンク
スポンサーリンク
amazon
スマイルSALE
--:--:--
ad. 価格範囲を指定して商品を探せます

deferの概要

関数の終了時に処理を実行するGoの予約語

defer

概要 deferは、呼び出し元の関数が終了(returnによる正常終了、またはpanicによる異常終了)する直前まで、指定した関数(遅延関数)の実行を保留(スタックへ登録)するために使用します。

  • ファイル記述子(File Descriptor)のクローズ、ミューテックスのロック解除、ソケットの切断といった「一対のリソース管理」を局所化する。
  • 1つの関数内で複数の defer が呼び出された場合、それらはスタック領域に積まれ、登録とは逆の順序(LIFO: Last-In, First-Out / 後入れ先出し)で順次実行される。
  • 関数内に panic(致命的例外)が伝播している最中であっても確実に遅延実行されるため、システムのクラッシュ回避(recover)やリソースリークの絶対的防止に寄与する。

deferの考え方をイメージで理解

基本的なdeferの使い方(実行タイミングの遅延)

defer の後ろには、通常の関数呼び出しやメソッド呼び出しを記述します。プログラムのトップダウンの実行フローからその命令だけが切り離され、関数の出口へと退避されます。

package main

import "fmt"

func main() {
    fmt.Println("開始")
    
    // この行が読み込まれた時点で、実行が関数の終了直前まで保留される
    defer fmt.Println("遅延実行(defer)")
    
    fmt.Println("終了")
}

構造解説:

  • コードは上から順に処理されるため、まず「開始」が出力されます。
  • 次に defer 行に到達しますが、この時点では評価とスタック登録のみが行われ、命令自体は実行されません。
  • 「終了」が出力され、main 関数のスコープを抜ける(終了する)まさにその直前に、溜められていた defer の関数が実行されます。

実行結果:

開始
終了
遅延実行(defer)

複数のdeferを使用した場合の実行順(LIFOスタック特性)

1つの関数内で複数の defer を登録した場合、データの格納構造は「スタック(机の上に本を積み重ねるイメージ)」と同じ挙動を示します。最後に登録されたクリーンアップ処理が、最も最初に実行されます。

package main

import "fmt"

func main() {
    // 登録順: 1 -> 2 -> 3
    defer fmt.Println("1番目に登録された処理")
    defer fmt.Println("2番目に登録された処理")
    defer fmt.Println("3番目に登録された処理")

    fmt.Println("メインロジックの実行")
}

構造解説:

  • 「メインロジックの実行」が終了すると、ランタイムは defer スタックからポップ(取り出し)を開始します。
  • 最後にスタックへ積まれた「3番目に登録された処理」が最初に出力され、逆順(3 → 2 → 1)にドミノ倒しのように実行されていきます。この性質は、依存関係のある複数リソース(例:データベース接続を開き、次にトランザクションを開始した場合に、逆順でコミット/クローズする)を破綻なく片付けるために極めて重要です。

実行結果:

メインロジックの実行
3番目に登録された処理
2番目に登録された処理
1番目に登録された処理

deferを使ったリソースのクリーンアップ(確実な解放パターン)

Go言語における最も標準的、かつ実践的な defer のユースケースです。ファイル操作や通信、メモリロックなどは、取得に「成功した直後」に defer を使って解放宣言を記述するのが鉄則となっています。

package main

import (
    "fmt"
    "os"
)

func writeFile() error {
    file, err := os.Create("resource.txt")
    if err != nil {
        return err
    }
    // リソース取得に成功した直後にクローズを遅延登録
    // これにより、以降のコードでどこでエラーリターンしても確実にファイルが閉じる
    defer file.Close() 

    _, err = file.WriteString("Go言語のdefer制御")
    if err != nil {
        return err // ここで早期離脱(アーリーリターン)しても、直前にfile.Close()が自動実行される
    }

    fmt.Println("ファイル書き込み完了")
    return nil
}

func main() {
    _ = writeFile()
}

構造解説:

  • os.Create でエラーが起きなかった場合のみ defer file.Close() がバインドされます。これにより、その後の書き込み処理(file.WriteString)で予期せぬエラーが発生して関数が途中で return を迎えたとしても、OSのファイル記述子リーク(割当量枯渇)を完全に防止できます。

実行結果:

ファイル書き込み完了

panicとrecoverを協調させたランタイムエラーの復旧制御

Go言語には try-catch のような例外構文がありません。プログラムが致命的な状況に陥った際に出る panic を捕捉して安全にシステムを継続させるには、defer と組み込み関数 recover の連携が必須となります。

package main

import "fmt"

func executeDangerousLogic() {
    // 匿名関数(即時実行関数)をdeferに登録
    defer func() {
        if r := recover(); r != nil {
            // panicが放たれた場合、ここで割り込んで捕捉できる
            fmt.Println("【システム復旧】パニックを検知・捕捉しました:", r)
        }
    }()

    fmt.Println("問題のある処理を開始します...")
    
    // 意図的なパニックの発生(ゼロ除算などのランタイムエラーと同等)
    panic("致命的なメモリアクセス異常")
    
    fmt.Println("この行は実行されません")
}

func main() {
    executeDangerousLogic()
    fmt.Println("メインプロセスはクラッシュせず、正常に継続しています。")
}

構造解説:

  • panic が発生すると、関数の通常のコードラインは即座に凍結されますが、defer に登録された関数だけは例外的に呼び出されます。
  • その遅延処理の内部で recover() を呼び出すと、パニック状態が消滅し、値(パニックに渡されたメッセージ)を取得できます。これにより、サーバープロセス全体が道連れで強制終了するのを防ぎます。

実行結果:

問題のある処理を開始します...
【システム復旧】パニックを検知・捕捉しました: 致命的なメモリアクセス異常
メインプロセスはクラッシュせず、正常に継続しています。

学術的・設計的注意点

  • 引数の即時評価(Argument Evaluation)の罠: defer の最も深い仕様トラップの1つが「引数の評価タイミング」です。defer fmt.Println(variable) のように遅延関数に引数を渡す場合、その値が評価(コピー)されるのは「関数が終了したとき」ではなく「defer行を通過した(登録された)瞬間」です。後から変数の値がどれだけ書き換わっても、遅延実行される中身には影響しません。実行時の最新の状態を参照したい場合は、引数なしの匿名関数(defer func() { fmt.Println(variable) }())でクロージャとして変数をキャプチャさせる必要があります。
  • ループ内(forスコープ)でのdefer累積によるメモリ圧迫: defer の生存期間(スコープ)は、ブロック({ ... })単位ではなく、あくまで「関数(func)単位」です。そのため、for ループの内部で defer file.Close() などを何度もループ回数分呼び出すと、関数全体が終了するまでファイルが一切クローズされず、スタックにメモリとリソースが累積し続けます。最悪の場合、リソースの枯渇やメモリリークを引き起こすため、ループ内では defer を使わず手動でクローズするか、ループの中身を別関数として切り出す設計が必要です。

よくある質問(FAQ)

Q: os.Exit()でプログラムを終了した場合も、deferは動作しますか?
A: 動作しません。os.Exit(code) が呼び出されると、Go言語のランタイムはすべてのゴルーチンとコールスタックを強制的に即時停止してプロセスを終了させるため、登録されている defer は一切実行されません。確実にクリーンアップを行いたいメインルーチンなどでは、os.Exit の直接呼び出しを避ける設計にすべきです。
Q: deferの中で、関数の「名前付き戻り値(Named Return Variables)」を書き換えることは可能ですか?
A: はい、可能です。Goの関数定義で戻り値に変数を割り当てている場合(例:func test() (result int))、defer 内の処理が走る段階でもその result 変数へのアクセス権が残っています。これを利用して、関数本体がリターンした後の値を defer で最終検証し、エラーコードを一括変更するなどの高度なラッパー実装が行えます。
Q: deferを呼び出すことによるパフォーマンス(オーバーヘッド)への影響はありますか?
A: 過去の古いGoのバージョン(Go 1.13以前など)では、defer はスタックやヒープへのメモリ確保を伴うため一定のオーバヘッドがあり、極限のパフォーマンスが求められる環境では敬遠されることもありました。しかし、近年のコンパイル最適化(オープンコード化技術の導入)により、通常の関数呼び出しとほぼ変わらないレベルまで超高速化されているため、現在はパフォーマンスを理由に defer を避ける必要はほぼありません。

まとめ

  • defer は、内包する関数がリターンするかパニックを起こして終了する瞬間に、指定した処理を確実に割り込ませる遅延実行キーワード。
  • リソースの「生成・取得」と「解放・破棄」のコードを隣接して記述できるため、リソースリークの発生率を構造的に引き下げる効果を持つ。
  • 複数登録された遅延関数はスタック(LIFO)管理され、登録順の「真逆」から実行されるため、依存関係のある複数リソースの解体に最適。
  • 引数の即時評価の特性や、関数スコープに縛られる制限を意識し、ループ内での誤用や値の不一致を防ぐ設計が求められる。