Go の defer、panic、recover: いつ使う

1. 概要

Go には try/catch はありません。その代わり、言語は 3 つのツールを提供します:リソースをクリーンアップするための defer、回復不可能なエラーを報告するための panic、特定の境界で panic をキャッチするための recover です。

この記事では、各ツールのメカニズム、よくある落とし穴、そして最も重要なこととして、いつ使うべきで、いつ使うべきではないか を説明します。この記事を読み終わったら、defer を正しく使う方法がわかり、panic が妥当な場合を知り、recover を使ってサーバーが 1 つのリクエストエラーで落ちないようにする方法を知ることができます。

2. defer

defer は、関数が終了するまで関数呼び出しを遅延させます。関数は通常の return で終了することもあれば、panic によって終了することもあります。どちらの場合でも、defer された呼び出しはすべて実行されます。

2.1. LIFO の実行順序

package main

import "fmt"

func main() {
	for i := 1; i <= 3; i++ {
		defer fmt.Println("defer", i)
	}
	fmt.Println("done")
}

defer に遭遇するたびに、Go は呼び出しを関数独自のスタックにプッシュします。main が終了するとき、呼び出しは逆順で取り出されます:

done
defer 3
defer 2
defer 1

この LIFO 順序は理由があります。後から開かれたリソースは、前に開かれたリソースに依存していることが多いからです。例えば、connection を開いてから transaction を開くので、transaction を connection より先に閉じなければなりません。

2.2. 引数は直ちに評価される

これが最初の落とし穴です。defer された関数の引数は、defer に遭遇した時点で 計算され、実際の呼び出しが実行される時点ではありません。

package main

import "fmt"

func main() {
	total := 10
	defer fmt.Println("deferred total:", total)

	defer func() {
		fmt.Println("closure total:", total)
	}()

	total = 20
	fmt.Println("current total:", total)
}
current total: 20
closure total: 20
deferred total: 10

最初の呼び出しでは、defer に遭遇した時点で値 10 が直ちにキャプチャされます。2 番目の呼び出しの closure は異なります。変数 total への参照を保持しているため、実行時に最新の値である 20 を読み込みます。

最後の値が必要な場合、例えば時間を測定したり状態をログに記録したりする場合は、closure を使用してください。

2.3. 名前付き戻り値で戻り値を変更する

遅延 closure は、関数の 名前付き戻り値 を読み書きできます。このテクニックは、Close() がエラーを返す可能性があるとき、例えばファイルを書き込むときはデータが close 時にしか flush されない場合に非常に有用です。

package main

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

func writeReport(path string, data []byte) (err error) {
	f, err := os.Create(path)
	if err != nil {
		return err
	}
	defer func() {
		err = errors.Join(err, f.Close())
	}()

	_, err = f.Write(data)
	return err
}

func main() {
	err := writeReport("/tmp/report.txt", []byte("Order #42: 3 items\n"))
	fmt.Println("error:", err)
}
error: <nil>

ステートメント return err は値を変数 err に割り当て、その後 defer された closure が実行されます。closure は errors.Join を使用して f.Close() のエラーを err にマージします。errors.Join は Go 1.20 から利用可能です。両方のエラーが nil である場合、errors.Join は nil を返します。

単に defer f.Close() と書いた場合、ファイルを閉じるときのエラーは無視され、誰も知ることがありません。

注意: 読み取り専用ファイルの場合は、defer f.Close() で十分です。読み取り専用ファイルを閉じるときのエラーはほぼデータに影響しません。

2.4. 落とし穴:ループ内の defer

func processAll(paths []string) error {
	for _, p := range paths {
		f, err := os.Open(p)
		if err != nil {
			return err
		}
		defer f.Close() // 間違い:processAll が終わるまで閉じない
		// ... f を読む ...
	}
	return nil
}

defer は 関数 に結びついており、ブロックやループに結びついていません。10,000 個のファイルがある場合、上記のコードは 10,000 個のファイルディスクリプタを同時に開きっぱなしにし、too many open files エラーが非常に発生しやすくなります。

修正方法は、ループの本体を個別の関数に抽出することです:

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

func processFile(path string) error {
	f, err := os.Open(path)
	if err != nil {
		return err
	}
	defer f.Close() // processFile が return するとき直ちに閉じる
	// ... f を読む ...
	return nil
}

3. panic

panic は、現在の goroutine の通常の実行フローを停止します。Go は unwind を開始します:各関数は逆方向に return し、各関数の defer は実行されます。goroutine のスタックの上部に到達して recover がない場合、プログラムはスタック トレースを出力し、終了コード 2 で終了します。

3.1. unwind プロセスの観察

package main

import "fmt"

func main() {
	defer fmt.Println("main: deferred")
	processOrder()
	fmt.Println("never printed")
}

func processOrder() {
	defer fmt.Println("processOrder: deferred")
	panic("inventory service returned invalid state")
}
processOrder: deferred
main: deferred
panic: inventory service returned invalid state

goroutine 1 [running]:
main.processOrder()
	/tmp/main.go:13 +0x...
exit status 2

プログラムが終了する前に、両方の defer が実行されることがわかります。これが、defer mu.Unlock() や defer tx.Rollback() が panic 時でも安全である理由です。

panic は明示的な panic() 呼び出しからだけでなく、runtime からも発生する可能性があります。一般的なケースは、index が範囲外でのアクセス、nil ポインタのデリファレンス、nil map への書き込み、または整数を 0 で除算することです。

3.2. いつ panic を使うべきか

Go の一般的なルール:予測可能なエラーは error を返す。例えば、ファイルが見つからない、入力形式が間違っている、ネットワーク タイムアウトなどです。panic を使う場合は数限られています。

1. プログラミング エラー、つまり不変条件が破られた場合。 これは、code が正しい場合は「起こり得ない」状態であり、実行を続けるとデータが破損するだけです。

func (s OrderStatus) String() string {
	switch s {
	case Pending:
		return "PENDING"
	case Paid:
		return "PAID"
	case Shipped:
		return "SHIPPED"
	}
	panic(fmt.Sprintf("unknown OrderStatus: %d", s))
}

2. MustXxx 関数は初期化時に使用します。 標準ライブラリには regexp.MustCompile と template.Must があります。設定用に同様の関数を作成できます:

package config

import (
	"fmt"
	"strconv"
)

func MustParsePort(s string) int {
	port, err := strconv.Atoi(s)
	if err != nil || port < 1 || port > 65535 {
		panic(fmt.Sprintf("invalid port %q", s))
	}
	return port
}

var emailPattern = regexp.MustCompile(`^[^@\s]+@[^@\s]+$`)

設定が間違っていれば、アプリケーションは起動すべきではありません。起動時に直ちに panic するとエラーが早期に検出され、server が破損した状態で実行されることがなくなります。

注意: ユーザーからの入力やリクエストからのデータには MustXxx を使わないでください。不正な入力はプロセス全体を落とすべきではありません。

4. recover

recover() は unwind プロセスを停止し、panic に渡された値を返します。この関数は、defer 関数内で直接呼び出された 場合にのみ機能します。他の場所で呼び出された場合、それは nil を返し、何もしません。

4.1. panic をエラーに変換する

package main

import "fmt"

func safeDivide(a, b int) (result int, err error) {
	defer func() {
		if r := recover(); r != nil {
			err = fmt.Errorf("safeDivide(%d, %d): %v", a, b, r)
		}
	}()
	return a / b, nil
}

func main() {
	fmt.Println(safeDivide(10, 2))
	fmt.Println(safeDivide(10, 0))
}
5 <nil>
0 safeDivide(10, 0): runtime error: integer divide by zero

b == 0 のとき、runtime が panic します。defer された closure は recover() で panic をキャッチし、それを名前付き戻り値 err に割り当てます。関数は通常通り return し、result は zero 値 0 のままです。

この例は説明用です。実際には、b == 0 をチェックして直接 error を返すべきです。

4.2. 落とし穴:recover は直接呼び出す必要がある

func logPanic() {
	if r := recover(); r != nil {
		log.Println("recovered:", r)
	}
}

func handlerA() {
	defer logPanic() // OK:logPanic 自体が defer 関数
	panic("boom A")
}

func handlerB() {
	defer func() {
		logPanic() // 動作しない:recover がもう一層深い
	}()
	panic("boom B")
}

handlerA は正常に recover します。handlerB はプログラムをまだ落とします。recover は logPanic 内で呼び出されますが、logPanic 自体は defer 関数ではなく、defer 関数によって呼び出されているためです。

4.3. 落とし穴:recover は他の goroutine の panic をキャッチしない

各 goroutine は独自のスタックを持っています。子 goroutine 内の panic は プロセス全体を落とし、main に recover があろうとなかろうと関係ありません。

func main() {
	defer func() { recover() }() // 以下の goroutine に対しては無意味

	go func() {
		panic("worker crashed") // プロセスはまだ死ぬ
	}()
	time.Sleep(time.Second)
}

制御できないバックグラウンド ジョブを実行する場合は、それをラップしてください:

func SafeGo(name string, fn func()) {
	go func() {
		defer func() {
			if r := recover(); r != nil {
				log.Printf("goroutine %s panicked: %v\n%s", name, r, debug.Stack())
			}
		}()
		fn()
	}()
}

debug.Stack() は runtime/debug パッケージにあります。後でデバッグするのに必要な十分なスタック トレースをログに記録するのに役立ちます。

4.4. HTTP の境界で recover する

recover を配置するのが適切な場所はリクエストの境界です。1 つのリクエスト エラーが他のリクエストを引きずり込むべきではありません。

func RecoverMiddleware(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		defer func() {
			if rec := recover(); rec != nil {
				if rec == http.ErrAbortHandler {
					panic(rec) // net/http に設計通り abort を処理させる
				}
				log.Printf("panic on %s %s: %v\n%s", r.Method, r.URL.Path, rec, debug.Stack())
				http.Error(w, `{"error":"internal server error"}`, http.StatusInternalServerError)
			}
		}()
		next.ServeHTTP(w, r)
	})
}

net/http はすでに各接続に対して panic を recover していますが、それはログを記録してから接続を閉じるだけなので、client は適切な response を受け取りません。上記のミドルウェアは明示的に 500 ステータス コードを返し、同時にデバッグしやすいようにメソッドとパスをログに記録します。

5. panic と recover のテストを書く

Go は組み込みの testing パッケージを使用し、外部依存は不要です。

package config

import (
	"strings"
	"testing"
)

func TestSafeDivide_ByZero_ReturnsError(t *testing.T) {
	result, err := safeDivide(10, 0)

	if err == nil {
		t.Fatal("expected error, got nil")
	}
	if result != 0 {
		t.Errorf("expected result 0, got %d", result)
	}
	if !strings.Contains(err.Error(), "divide by zero") {
		t.Errorf("unexpected error message: %v", err)
	}
}

func TestMustParsePort_InvalidInput_Panics(t *testing.T) {
	defer func() {
		r := recover()
		if r == nil {
			t.Fatal("expected panic, got none")
		}
		if msg, _ := r.(string); !strings.Contains(msg, `"abc"`) {
			t.Errorf("unexpected panic value: %v", r)
		}
	}()

	MustParsePort("abc")
}

func TestMustParsePort_ValidInput(t *testing.T) {
	if got := MustParsePort("8080"); got != 8080 {
		t.Errorf("expected 8080, got %d", got)
	}
}
go test ./... -v
=== RUN   TestSafeDivide_ByZero_ReturnsError
--- PASS: TestSafeDivide_ByZero_ReturnsError (0.00s)
=== RUN   TestMustParsePort_InvalidInput_Panics
--- PASS: TestMustParsePort_InvalidInput_Panics (0.00s)
=== RUN   TestMustParsePort_ValidInput
--- PASS: TestMustParsePort_ValidInput (0.00s)
PASS

2 番目のテストは、defer と recover 自体を使用して、関数が panic することと、正しいメッセージで panic することを確認しています。testify を使用する場合、assert.PanicsWithValue はより少ないコードで同じ結果を提供します。

6. 使うべきではない場合

  • panic/recover を exception のように使わない。 panic(ErrNotFound) を書いてから上層で recover するのは、制御フローを読みにくくします。Go の慣習に反します。error を返してください。
  • public ライブラリで panic を使わない。エラーが caller の入力に由来する場合。ライブラリのユーザーは、プロセスが死ぬことを想定していません。
  • recover して黙って無視しない。 少なくとも panic の値とスタック トレースをログに記録してください。データの共有状態が破損した場合など、状態が既に壊れている場合は、プロセスを死なせて orchestrator に再起動させてください。
  • 長いループ内で defer を使わない(セクション 2.4 を参照)。
  • defer のパフォーマンスを過度に心配しない。 Go 1.14 以降、open-coded defer はほとんどの場合ほぼコストがありません。profiler が問題を指摘した場合にのみ最適化してください。

注意: Go 1.21 以降、panic(nil) は recover() が nil を返す原因にはならなくなりました。代わりに、recover() は *runtime.PanicNilError を返します。「panic がない」ことを確認するために recover() == nil を使う古いコードは今より正確です。

7. まとめ

これら 3 つのツールは非常に異なる役割を持っています:

  • defer は、リソースをクリーンアップするための日常的なツールです。2 つのことを覚えてください:引数は defer に遭遇した時点で評価されます。そして defer は関数に紐付いており、ループには紐付いていません。名前付き戻り値と組み合わせることで、defer は Close() からのエラーを見落とさないようにするのに役立ちます。
  • panic は、プログラミング エラーと回復不可能な初期化エラー用です。他のエラーについては、error を返してください。
  • recover は境界にのみ表示されるべきです:HTTP ミドルウェア、worker goroutine、または制御できないコードへの呼び出し。defer 関数内で直接呼び出す必要があり、その goroutine の panic のみをキャッチできます。

これらの境界を完全に理解すれば、安全で Go の精神に適ったコードを書くことができます。つまり、エラーは値であり、panic は本当の例外です。

golangdeferpanicrecovererror-handling