Menu

Go言語のdefer:実行順序、引数の評価タイミング、落とし穴

deferは、囲んでいる関数が戻るときに実行される呼び出しを予約します。LIFOの順序、引数が評価されるタイミング、ファイルのクローズとミューテックスのアンロック、ループ内のdefer、名前付き戻り値の変更を解説します。

このページのコードはエディタで実行できます - 編集してすぐに結果を確認できます。

deferがすること

defer は関数呼び出しをリストに積みます。囲んでいる関数が戻るとき、そのリストが逆順に実行されます。

出力:

start
end
deferred 3
deferred 2
deferred 1

順序はスタックと同じく後入れ先出しです。これはリソースの入れ子のされ方と一致します。Aを開いてからBを開いたら、たいていはAより先にBを閉じたいはずです。

取得のすぐ隣に後始末を書く

defer の主な用途は、取得の直後の行に後始末を書き、どのreturnの経路でもそれを忘れないようにすることです。

順序に注目してください。先にエラーをチェックし、それからdeferします。os.Open が失敗すると f はnilなので、チェックの前に f.Close() をdeferすると、nilの *os.File に対して Close を呼ぶことになります(誰も見ないエラーを返し、読む人を混乱させます)。

ロックにも同じ形が使えます。

mu.Lock()
defer mu.Unlock()

間のコードがpanicしても、ミューテックスはちゃんとアンロックされます。

引数はすぐに評価される

deferする関数とその引数は、defer 文が実行されたときに評価されます。待つのは呼び出しそのものだけです。

出力:

x is now 2
deferred closure reads: 2
deferred with argument: 1

fmt.Println("...", x)defer の行で値1を捕らえました。クロージャには引数がなく、最終的に実行されるときに x を読みます。記録したいものに合った形を選びます。

これはメソッドのレシーバーにも当てはまります。defer t.Stop() はその場で t を評価するので、後で t に再代入しても、どの値が停止されるかは変わりません。

このルールを意図的に使った、時間計測のよくあるテクニックがあります。

func handle() {
	defer trace("handle")() // trace runs now, the returned func runs at exit
	// ...
}

trace("handle") はすぐに呼ばれ(「開始」を表示して開始時刻を記録できます)、それが返した関数がdeferされます。

ループ内のdefer

deferした呼び出しは、ループの各反復の終わりではなく、関数が戻るときに実行されます。多数のファイルをループすると、関数が終わるまですべてのファイルが開いたままになります。

for _, path := range paths {
	f, err := os.Open(path)
	if err != nil {
		return err
	}
	defer f.Close() // all files stay open until the function returns
	process(f)
}

パスが数千あれば、ファイルディスクリプタを使い果たします。本体を独立した関数に移せば、defer が反復ごとに実行されます。

processFile の呼び出しはそれぞれ、次のファイルを開く前に自分のファイルを閉じます。名前付きのヘルパーが大げさに感じるなら、その場で呼ぶ関数リテラル(func() { ... }())でも同じように動きます。

戻り値を変更する

deferしたクロージャは、return 文が結果を代入した後に実行され、呼び出し側が見る前に名前付き戻り値を変更できます。

これは 10save failed: disk full を表示します。名前のない戻り値でもdeferした関数は実行されますが、返される値を変更する方法はありません。

Closeのエラーを捕まえる

defer f.Close()Close のエラーを捨てます。読むだけのファイルならそれで構いません。書き込んだファイルでは、Close が最後のフラッシュの失敗を報告することがあるので、エラーが重要になります。名前付き戻り値を使えば、それを残せます。

func writeReport(path string, data []byte) (err error) {
	f, err := os.Create(path)
	if err != nil {
		return err
	}
	defer func() {
		if cerr := f.Close(); cerr != nil && err == nil {
			err = cerr
		}
	}()
	_, err = f.Write(data)
	return err
}

両方のエラーを報告したいなら、errors.Join(err, f.Close()) がより短い代替手段です。

defer、panic、recover

deferした呼び出しは、panicがスタックを巻き戻す間に実行されます。recover が何かをするのはこの中だけで、サーバーが1つの不正なリクエストでプロセス全体を落とさずに済むのはこの仕組みのおかげです。詳細はpanicとrecoverのページにあります。

プログラムが os.Exitlog.Fatal で終了するときは、deferした呼び出しは実行されません。main が後始末をdeferしてから os.Exit(1) を呼ぶと、後始末は飛ばされます。

コスト

Go 1.14以降、ほとんどのdeferはコンパイラによってオープンコード化され、コストは数ナノ秒です。ミューテックスの解放やファイルのクローズのたびに defer を使うのが普通のスタイルです。例外はループ内のdeferで、オープンコード化できずに遅い経路になります。これもループの本体を関数に移す理由の1つです。

よくある間違い

  • エラーチェックの前にdeferする。 先に Openerr をチェックし、それから defer Close します。
  • ループで反復ごとの後始末を期待する。 deferは関数の終了時に実行されます。
  • deferした引数が後の変更を反映すると思う。 引数は defer の行で固定されます。終了時に読むにはクロージャを使います。
  • os.Exit でdeferに頼る。 決して実行されません。

よくある質問

Goのdeferは何をしますか?

defer f() は、囲んでいる関数が戻るときに f() を実行するよう予約します。正常に戻っても、早期の return でも、panicでも実行されます。後始末(ファイルのクローズ、ミューテックスのアンロック)を、リソースを取得したコードのすぐ隣に置くために使います。

Goでdeferした呼び出しはどの順序で実行されますか?

後入れ先出しです。最後にdeferした呼び出しが最初に実行されます。defer fmt.Println(1); defer fmt.Println(2) は2を表示してから1を表示します。

deferした関数の引数はいつ評価されますか?

呼び出しが実行されるときではなく、defer 文が実行されたその時点で評価されます。x := 1; defer fmt.Println(x); x = 2 は1を表示します。終了時の値を読むには、クロージャをdeferします:defer func() { fmt.Println(x) }()

deferはpanicやos.Exitのときに実行されますか?

deferした呼び出しはpanicがスタックを巻き戻す間に実行されます。recover がその中で機能するのはこのためです。プログラムが os.Exit(またはそれを呼ぶ log.Fatal)を呼んだときは実行されず、main が戻ったときに他のゴルーチンのdeferが実行されることもありません。

Coddy programming languages illustration

Coddyでコードを学ぼう

始める