Yappli Tech Blog

株式会社ヤプリの開発メンバーによるブログです。最新の技術情報からチーム・働き方に関するテーマまで、日々の熱い想いを持って発信していきます。

Goのdefer文を使うときに気をつけること

サーバーサイドエンジニアの @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

ということで、複数のエラーの情報を漏れなく取得することができるようになりました*4multierr.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.Closefilenil だった時の処理がされていてサンプルとして使えなかったので、http.Get を使うパターンをサンプルとしました。エラーが起きると第一返り値の respnil になるので resp.Body へのアクセス時にpanicが発生します。

このようにdefer文をエラー処理よりも前に書いてしまうと思わぬバグを混入させてしまう可能性があるので、defer文の有無に限った話ではありませんがエラーを受け取ったらエラー処理を最優先としましょう。

さいごに

いかがでしたでしょうか。思ったよりも量が多くなってしまいましたが、すべてを一気に覚えるのでは無くなんとなく頭の片隅に置いておいて必要に応じて参照するのが良いのかなと思います。

YappliではGoをプロダクトで利用するのみならず、今回の記事のような細かいテクニックをメンバー同士で共有する文化のある会社です。Go Studyという社内勉強会も定期的に開催しています。そんなGoに真剣なYappliに興味がありましたら是非カジュアル面談でお話しましょう!

open.talentio.com

参考記事

*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等に組み込むことをオススメします。