💨

Go言語~deferについて~

2022/09/28に公開約6,500字

こんちには!
LIFULLエンジニアの吉永です。

本日はGoのdeferについて、コードレビューしていて「あれ?これどう動くんだろう?」ってちょっと判断に迷ったdeferの記述方法があり、改めてdeferについてと、Go Playgroundで動作確認した結果をまとめました。
※本記事はQiitaにて一度公開済みなのですが、ZennとQiitaで記事の読まれ方の違いを把握したいので、Zennにも投稿させていただきます。いずれどちらかに集約したいと思っています。

deferとは?

deferの公式ドキュメント

https://go.dev/ref/spec#Defer_statements

特徴

遅延実行の為の仕組みで、JavaやC#など多数のプログラミング言語で採用されているtry~catch~finallyのfinallyと似たようなものですが、単純比較すると下記のような特徴を持ちます。

  • return時だけでなく、panic時にも実行される。
  • 一つのブロック内に複数のdefer定義が可能。
  • deferで定義された処理はLIFO(Last In First Out)で実行される。

利用用途

個人的な今までの利用用途としては下記が多かったです。

  • finallyのようにオープンしたリソースを必ずクローズしたい時、メソッド内の処理中にエラーになったか否かに関わらず確実に最後に処理しておきたいものを実行する。
  • return前に共通で行う処理を実行。
    • 例えば、リダイレクト先のURLを条件分岐に応じてreturnするURLを変える場合に、URLは可変だが、URLに付与する計測パラメーターは共通の場合、URL末尾にクエリストリングを追記する処理をdefer部分で行うなど。

Go Playgroundでdeferの動作確認

Go Playgroundを活用して、各種記述の動作確認を行います。

Hello World

まずはdefer含めたHello World

Go Playground

package main

import "fmt"

func main() {
	defer fmt.Print(" World")
	fmt.Print("Hello")
}
Hello World

プログラミングの逐次実行の性質であるプログラムはコードの上から順に実行されるが無視されて、記述順とは逆に実行されていることが体感できますね。

LIFO

次は複数deferのLIFOで実行されていることの動作確認です。

Go Playground

package main

import "fmt"

func main() {
	defer fmt.Print(" World")
	defer fmt.Print(" Go")
	fmt.Print("Hello")
}
Hello Go World

deferで定義した順の逆順で実行されているので、LIFOであることが確認できましたね。
複数defer定義はこのような性質も持っているので、実際に採用する際は実行順にも気を配りながらソースレビューした方が良さそうです。
リソースのクローズ手順が、リソースのオープン手順の逆順であるという場面の場合、意図しない順番でリソースをクローズしてしまわないようになど。

複数ステートメント

先ほどのLIFOの件もあるように、複数defer定義は混乱の元になりがちです。
そこでdeferで実行すべき処理の実行順を気にしないで済む一つの手段として、deferで実行する処理をクロージャ化することもできます。

Go Playground

package main

import "fmt"

func main() {
	defer func() {
		fmt.Print(" Go")
		fmt.Print(" World")
	}()
	fmt.Print("Hello")
}
Hello Go World

関数のブロック内で定義された場合

関数内のif/for/switchなどのブロック内で定義されたdeferはどうなるんでしょう?
今回この記事を書くきっかけにもなったことなのですが、レビュー時にちょっと不安だったので、動きを確認しました。
まずはdeferを採用した背景について、下記のコードを見てください。

defer採用を検討した背景1

Go Playground

import "fmt"

func main() {
	data := map[string]string{
		"key1": "val1",
		"key2": "val2",
		"key3": "val3",
	}
	fmt.Printf("%v\n", data)
	mapHoge(data)
	fmt.Printf("%v\n", data)

}

func mapHoge(data map[string]string) {
	data["key1"] = "val1_1"
}
map[key1:val1 key2:val2 key3:val3]
map[key1:val1_1 key2:val2 key3:val3]

APIからのレスポンスJSONをパースしてmap[string]string型の変数へ格納して、mapHogeメソッドでそのAPIレスポンスを元に色々処理を行う、その過程でmap内のデータを書き換えると、上記出力のようにmapは参照渡しになっているので、意図せず変数が変更されてしまっています。
回避するにはmapのディープコピーを関数の引数へ渡すなどすれば良いのですが、Goではmapのディープコピーはちょっと手間がかかる(自前で実装するかライブラリを利用するか)のと、APIレスポンスが膨大なjsonだとメモリ使用量の観点からも宜しくなかったので、mapHoge内で一時的に書き換えた後にreturn前に元の値に戻すようにしてました。

defer採用を検討した背景2

ちょっと例がイマイチかもしれませんが、mapHogeを下記のようにして途中returnの時にも元に戻すようにして、レビュー依頼がありました。

Go Playground

package main

import (
	"errors"
	"fmt"
)

func main() {
	data := map[string]string{
		"key1": "val1",
		"key2": "val2",
		"key3": "val3",
	}
	fmt.Printf("%v\n", data)
	fmt.Println("====================")
	if err := mapHoge(data, "key0", "val1_1"); err != nil {
		fmt.Printf("%s\n", err.Error())
	}
	fmt.Printf("%v\n", data)
	fmt.Println("====================")
	if err := mapHoge(data, "key1", "val1_1"); err != nil {
		fmt.Printf("%s\n", err.Error())
	}
	fmt.Printf("%v\n", data)
	fmt.Println("====================")
	if err := mapHoge(data, "key2", "val1_1"); err != nil {
		fmt.Printf("%s\n", err.Error())
	}
	fmt.Printf("%v\n", data)
	fmt.Println("====================")
	if err := mapHoge(data, "key3", "val1_2"); err != nil {
		fmt.Printf("%s\n", err.Error())
	}
	fmt.Printf("%v\n", data)
}

func mapHoge(data map[string]string, key, val string) error {
	if before, ok := data[key]; ok {
		data[key] = val
		// バリュー書き換え後のhogehoge処理
		// ・
		// ・
		// ・

		if key == "key1" {
			data[key] = before
			return errors.New("key error")
		}
		if val == "val1_1" {
			data[key] = before
			return errors.New("val error")
		}
		data[key] = before
		return nil
	}
	return errors.New("not found")
}
map[key1:val1 key2:val2 key3:val3]
====================
not found
map[key1:val1 key2:val2 key3:val3]
====================
key error
map[key1:val1 key2:val2 key3:val3]
====================
val error
map[key1:val1 key2:val2 key3:val3]
====================
map[key1:val1 key2:val2 key3:val3]

これだと、今後のメンテでreturn時に元に戻すのが漏れそうだなと思ったので、deferを使ってもらいました。

deferを採用した結果

Go Playground

package main

import (
	"errors"
	"fmt"
)

func main() {
	data := map[string]string{
		"key1": "val1",
		"key2": "val2",
		"key3": "val3",
	}
	fmt.Printf("%v\n", data)
	fmt.Println("====================")
	if err := mapHoge(data, "key0", "val1_1"); err != nil {
		fmt.Printf("%s\n", err.Error())
	}
	fmt.Printf("%v\n", data)
	fmt.Println("====================")
	if err := mapHoge(data, "key1", "val1_1"); err != nil {
		fmt.Printf("%s\n", err.Error())
	}
	fmt.Printf("%v\n", data)
	fmt.Println("====================")
	if err := mapHoge(data, "key2", "val1_1"); err != nil {
		fmt.Printf("%s\n", err.Error())
	}
	fmt.Printf("%v\n", data)
	fmt.Println("====================")
	if err := mapHoge(data, "key3", "val1_2"); err != nil {
		fmt.Printf("%s\n", err.Error())
	}
	fmt.Printf("%v\n", data)
}

func mapHoge(data map[string]string, key, val string) error {
	if before, ok := data[key]; ok {
		defer func() {
			data[key] = before
			fmt.Printf("defer data[%s] = %s → %s\n", key, val, before)
		}()
		data[key] = val
		// バリュー書き換え後のhogehoge処理
		// ・
		// ・
		// ・

		if key == "key1" {
			return errors.New("key error")
		}
		if val == "val1_1" {
			return errors.New("val error")
		}
		return nil
	}
	return errors.New("not found")
}
map[key1:val1 key2:val2 key3:val3]
====================
not found
map[key1:val1 key2:val2 key3:val3]
====================
key error
map[key1:val1 key2:val2 key3:val3]
====================
val error
map[key1:val1 key2:val2 key3:val3]
====================
map[key1:val1 key2:val2 key3:val3]

動作的にはdefer採用前と同じになっており、各所return時に行っていた処理が一つにまとまりすっきりしました。

レビュー時に心配だったのは、deferがif文ブロック内を通らない時って、実行されないんだよな?っていうのがありまして、よくよく考えてみたらdeferが実行されなければ遅延実行もされなくて当たり前なのですが、finally的な感じで、return後に必ず実行されたらどうしよう?というのが心配だったのだと思います。

まとめ

deferはある程度の規模のプログラムになってくると避けては通れないと思いますし、動作を正しく理解しておけば、いざ実装する際にも効率よく可読性の良いコードにできることもあると思います。

deferのように、実際の動作を確認しないと不安な場合にGo Playgroundはさくっと任意のバージョンのGoで動作確認出来るのでとても良いですね!

最後までご覧いただき、ありがとうございました。
それではまた次の記事でお会いしましょう。

Discussion

ログインするとコメントできます