サーバーサイドエンジニアの @shuymn です。
Yappliではサーバーサイド領域で利用するプログラミング言語のうちの1つとしてGoを採用しています。
Goには様々な言語機能がありますが、その中の1つにdefer文(defer statement)と呼ばれる機能があります。
本記事ではdefer文の簡単な紹介と実際にプロダクトコードで利用する際に気をつけるべきことを紹介します。
defer文の紹介
defer文はifやforなどの制御構文の一種で、以下のように使うことで与えられた処理を関数がreturnされた後や関数の末尾に到達した後に実行させることができます。
func main() { defer fmt.Println("Yappli") fmt.Print("Hello, ") }
Go Playground: https://go.dev/play/p/LRA_QtTX2Vl
実行すると以下のように標準出力されます。
Hello, Yappli
このように、defer文に与えられた処理は後続の処理よりも後に実行されていることがわかります。そのためdefer文はリソースの解放を行うような処理を書く際に使うことが多いです。
下のサンプルコードは example.txt
というファイルを作り、そこに content
という文字列を書き込み、ファイルを閉じる処理をします。ファイルを閉じる処理がリソースの解放にあたります。
package main import ( "fmt" "log" "os" ) func main() { f, err := os.Create("example.txt") if err != nil { log.Println(err) return } defer func() { _ = f.Close() }() if _, err := f.Write([]byte("content")); err != nil { log.Println(err) return } fmt.Println("ok") }
Go Playground: https://go.dev/play/p/odJsqrgrbG5
このコードをdefer文を使わずに書くとこうなります。
package main import ( "fmt" "log" "os" ) func main() { f, err := os.Create("example.txt") if err != nil { log.Println(err) return } if _, err := f.Write([]byte("content")); err != nil { _ = f.Close() log.Println(err) return } _ = f.Close() fmt.Println("ok") }
Go Playground: https://go.dev/play/p/2eP7s0dSUx3
このように、returnする条件分岐があるたびに f.Close()
を忘れずに呼ぶ必要があります。現実のプロダクトコードでは条件分岐がサンプルコードよりも複雑になるので呼び忘れていないか確認しながらコードを書かなければなりませんし、それをレビューするのも一苦労です(そしていつの日か必ず呼び忘れがある状態でプロダクションにデプロイされます)。
解放しなければならないリソース*1を受け取ったらすぐにdefer文を書くことでリソースの解放が正しくできているか神経質にならずに済みますし、可読性の向上と認知負荷の低減につながります。
defer文は他にもpanicをrecoverする用途に使われます。この記事では説明を割愛しますが、参考となる記事を紹介しますので気になる方はそちらをご覧ください。
defer文を使う時に気をつけること
ここまででdefer文の簡単な紹介をしました。ここからは応用編として実際にプロダクトコードでdefer文を使う場合に気をつけたいことについて紹介していきます。
defer文の中身が実行される順番を把握する
1つの関数の中で複数回defer文がある場合はどのような順番で処理されるかを把握しないと意図しない挙動を引き起こす可能性があります。
defer文はスタックのデータ構造になっています。
package main import ( "fmt" ) func main() { defer fmt.Println("first") defer fmt.Println("second") defer fmt.Println("third") fmt.Println("go") }
Go Playground: https://go.dev/play/p/QnhOrZLAlpE
実行すると以下のように出力されます。
go third second first
後に書かれたdefer文から順番に実行されます。書いた順番に実行されると誤解していると、解放されたリソースへアクセスしてしまうようなコードを書いてしまうおそれがあります(長いので割愛したサンプルコード)。
os.Exitするとdefer文は実行されない
Goではmain関数以外の箇所からプログラムを終了させたい場合に以下の選択肢があります*2。
os
パッケージのExit
関数- ビルトイン関数の
panic
runtime
パッケージのGoexit
関数
それぞれの詳細な説明は割愛します。気になる方は以下の記事を参照してください。
そして上記の関数のうち os.Exit
のみdefer文を実行しないという挙動になります。
package main import ( "fmt" "os" ) func main() { defer fmt.Println("Yappli") fmt.Print("Hello, ") os.Exit(3) }
Go Playground: https://go.dev/play/p/UnXYHP0aIgs
実行すると以下のように出力されます。
Hello,
このように os.Exit
を使ってプログラムを終了するとdefer文の内容は実行されません。とはいえ os.Exit
はそこまで頻繁に使う関数ではなく、使うとしてもmain関数に近い場所で使われると思います。しかし os.Exit
を間接的に呼び出す関数というのが存在します。
それが log.Fatal
log.Fatalf
log.Fatalln
です。(ソースコード)
これら関数はライブラリのサンプルコードのエラー処理などで頻繁に目にする関数のため、サンプルコードをコピペしてそのまま使ってしまうと意図せずdefer文が呼ばれないコードが混入してしまう可能性があります。サードパーティ製のlogライブラリにもFatal系の関数は実装されていることが多いので、使う際は os.Exit
が使われるような実装になっているのか確認してから使いましょう。
defer文で発生するエラーをどうするか
ここまでのサンプルコードではdefer文の中で発生するエラーは簡単のために握りつぶしていましたが、プロダクトコードでは無視するわけにはいきません。
以下のようにdefer文の中でエラーをreturnすれば一見どうにかなりそうに見えますが、defer文の中で値を返してもなにも起こりません。
package main import ( "fmt" ) func main() { fmt.Println(run()) } func run() error { defer func() error { return fmt.Errorf("defer") }() return fmt.Errorf("error") }
Go Playground: https://go.dev/play/p/KYqLoep6cBV
ではdefer文の中で発生したエラーを呼び出し元に伝えることは不可能なのかというとそうではなく、Named Return Valuesを利用するテクニックが存在します。Named Return Valuesについて初見の方はA Tour of Goの該当の節をご覧ください。
Named Return Valuesを使うことで以下のように書くことができます。
package main import ( "fmt" ) func main() { fmt.Println(run()) } func run() (err error) { defer func() { err = fmt.Errorf("defer") }() return fmt.Errorf("error") }
Go Playground: https://go.dev/play/p/MHmmZR-0Kbv
ここまでは様々なブログ記事で紹介されていてご存じの方もいるかと思います。
しかし上記のコードではdefer文によって return fmt.Errorf("error")
で発生したエラーが消えてしまった(defer文中で上書きされた)ことにお気づきでしょうか。このようにプロダクションコードでのdefer文が絡むエラーハンドリングはサンプルコードよりもずっと複雑になります。
エラーが発生しうるときの状態を表にまとめると以下のようになります*3。
関数本体 | defer文中 | 呼び出し元 |
---|---|---|
エラーなし | エラーなし | エラーなし |
エラーなし | エラーあり | defer文中のエラー |
エラーあり | エラーなし | 関数本体のエラー |
エラーあり | エラーあり | 両方のエラー |
今回解決したいのは関数本体でもエラーが発生し、さらにdefer文中でもエラーが発生した場合となります。このように複数個のエラーを扱いたい場合に使われるライブラリとして uber-go/multierr があります。このライブラリは以下のように使います。
package main import ( "fmt" "go.uber.org/multierr" ) func main() { fmt.Println(run()) } func run() (err error) { defer func() { err = multierr.Append(err, fmt.Errorf("defer")) }() return fmt.Errorf("error") }
Go Playground: https://go.dev/play/p/LvYE5-ZPCdG
実行すると以下のように表示されます。
error; defer
ということで、複数のエラーの情報を漏れなく取得することができるようになりました*4。multierr.Append
の実装は第一引数や第二引数が nil
である可能性も考慮された実装になっているので条件分岐が必要なくすっきりと記述することができます。
このテクニックはdefer文が複数あり、それぞれでエラーが発生する可能性があるようなケースでも使えるので汎用的でオススメのテクニックです。
defer文に渡した関数の引数は即時評価される
A Tour of Goのdeferの節にもしれっと記述があるのですが、defer文に渡した関数の引数は即時評価される仕様になっています。具体的にサンプルコードを見てみましょう。
package main import "fmt" func main() { f1() f2() } func f1() { msg := "Hello, Yappli" defer fmt.Println("f1:", msg) msg = "Good bye, Yappli" } func f2() { msg := "Hello, Yappli" defer func() { fmt.Println("f2:", msg) }() msg = "Good bye, Yappli" }
Go Playground: https://go.dev/play/p/_2Ie_WSH4FS
どちらも書き方は似たように見えるので実行すると同じ出力がされそうですが、実際は以下のように異なります。
f1: Hello, Yappli f2: Good bye, Yappli
引数が即時評価されるというのがどういう挙動なのかなんとなく分かっていただけたかと思います。関数 f2
を関数 f1
と同じ挙動にするためには、defer文に渡す無名関数の引数に変数 msg
を受け取れるようにする必要があります。
コードが複雑になり、defer文を書いた後にdefer文に渡した関数で使っている変数に再代入が発生するようになると意図しない挙動となる可能性があるので注意が必要です。関数 f1
のパターンでもポインタを扱う場合は後続の処理でポインタが指す値の上書きは可能だと言うことも覚えておいてください。
forループでループごとにdefer文を実行する方法
実装の都合上for文の中で1ループごとにdefer文を実行したくなることがあります。そして以下のようなコードを書くと思います。
package main import ( "fmt" "os" ) func main() { names := []string{"a.txt", "b.txt", "c.txt"} for _, name := range names { fmt.Printf("open: %s\n", name) f, err := os.Create(name) if err != nil { panic(err) } defer func() { fmt.Printf("close: %s\n", f.Name()) _ = f.Close() }() } fmt.Println("ok") }
Go Playground: https://go.dev/play/p/TAsDBGQ4MjY
期待している出力は
open: a.txt close: a.txt open: b.txt close: b.txt open: c.txt close: c.txt ok
ですが、実行すると以下のようになります。
open: a.txt open: b.txt open: c.txt ok close: c.txt close: b.txt close: a.txt
defer文の基本に立ち返るとループごとにdefer文に渡した処理が実行されるわけがないというのは分かりますが、うっかり書いてしまいがちなんじゃないかと思います。今回は f.Close
する前にログ出力をしているのでわかりやすいですが、ログ出力が無ければ気がつくことは難しいです。
ではどうすればループごとにdefer文を実行できるようになるのかというと、無名関数を使う方法があります(メソッド・関数化でも可能です)。
package main import ( "fmt" "os" ) func main() { names := []string{"a.txt", "b.txt", "c.txt"} for _, name := range names { // ループ変数はループ全体で共通の変数なので // このように再宣言することでループ内のローカル変数にする // 詳しくは "go loop closure" とかで検索してください name := name fmt.Printf("open: %s\n", name) func() { f, err := os.Create(name) if err != nil { panic(err) } defer func() { fmt.Printf("close: %s\n", f.Name()) _ = f.Close() }() }() } fmt.Println("ok") }
Go Playground: https://go.dev/play/p/DDaL8vmpq9N
これで期待した動作になります。コメントにも書きましたがループ変数の取り扱いにはご注意ください*5。
defer文はエラー処理より先に書かない
こちらもうっかりパターンになるのですが、リソースの解放が必要な返り値を受け取ったあとにエラー処理よりも先にdefer文を書くとpanic(nil pointer dereference)する可能性があるので注意しましょう。
package main import ( "fmt" "net/http" ) func main() { resp, err := http.Get("") defer func() { _ = resp.Body.Close() }() if err != nil { fmt.Println(err) return } }
Go Playground: https://go.dev/play/p/OQjzc6vakmB
file.Close
は file
が nil
だった時の処理がされていてサンプルとして使えなかったので、http.Get
を使うパターンをサンプルとしました。エラーが起きると第一返り値の resp
は nil
になるので resp.Body
へのアクセス時にpanicが発生します。
このようにdefer文をエラー処理よりも前に書いてしまうと思わぬバグを混入させてしまう可能性があるので、defer文の有無に限った話ではありませんがエラーを受け取ったらエラー処理を最優先としましょう。
さいごに
いかがでしたでしょうか。思ったよりも量が多くなってしまいましたが、すべてを一気に覚えるのでは無くなんとなく頭の片隅に置いておいて必要に応じて参照するのが良いのかなと思います。
YappliではGoをプロダクトで利用するのみならず、今回の記事のような細かいテクニックをメンバー同士で共有する文化のある会社です。Go Studyという社内勉強会も定期的に開催しています。そんなGoに真剣なYappliに興味がありましたら是非カジュアル面談でお話しましょう!
参考記事
- Defer, Panic, and Recover - The Go Programming Language
- Goのコアコミッターによるdefer文の紹介記事です
- The Go Programming Language Specification - The Go Programming Language
- Goの仕様書にあるdefer文についての項目です
*1:超余談なんですが、サンプルコードでsql.Openしたらdefer文でCloseするコードってありがちなんですが、sql.DB構造体が良い感じにコネクションを管理してくれるのでプロダクションコードではいちいちOpenしてCloseする必要はなかったりします。 https://pkg.go.dev/database/sql#Open
*2:main関数の中で呼び出すこともできます。
*3:関数の返値が(error, error)のようになっていたり、defer文が複数回記述されているようなケースもかんがえられますが、ここでは最も簡単なケースを想定しています。
*4:pkg/errorsを使ってスタックトレースを表示させるようなケースでも綺麗に表示してくれますし、errors.Isやerrors.Asを使ってエラー処理することもできます。
*5:govetによる静的解析が可能なのでCI等に組み込むことをオススメします。