This page looks best with JavaScript enabled

defer Closeは間違いやすい、のでlinterを作った。

 ·  ☕ 5 min read

最近、ちまちまとGoのlinterを作るのにはまっています。
作ったlinterの一つnamedについて紹介したいと思います。

目次

defer Closeのエラーハンドリングをちゃんとする。

os.Filenet/http.Request.Body は使い終わったあとにCloseメソッドを呼ぶ必要があります。なので、よくあるサンプルコードではClose忘れを防ぐためにdeferを使います。

1
2
3
4
5
file, err := os.Open("sample.txt")
if err != nil {
	return err
}
defer file.Close()

しかし、io.Closer の定義を見れば分かるように、Closeメソッドはエラーを返すことができます。つまり、サンプルコードのように単純にdeferClose呼び出しを行うと、戻り値のエラーを無視してしまうことになります。

ということで、ちゃんとエラーハンドリングをしようとすると以下のような感じになります。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
file, err := os.Open("sample.txt")
if err != nil {
	return err
}
defer func() {
	err2 := file.Close()
	if err2 != nil {
	  if err != nil {
		  err = fmt.Errorf("err from close: %w: %w", err2, err)
	  } else {
		  err = err2
	  }
	}
}

このようなスニペットを専用の関数にまとめてしまうこともあるでしょう。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
func Close(closer io.Closer, errp *error) {
	err := closer.Close()
	if err != nil {
		if *errp != nil {
			*errp = fmt.Errorf("err from close: %w: %w", err, *errp)
		} else {
			*errp = err
		}
	}
}

defer Closeのエラーハンドリングをちゃんとやるには名前付き戻り値を使わないとだめ。

実は、Closeメソッドからの戻り値をちゃんとキャッチしてエラーハンドリングするにはもうちょっと注意が必要です。
次のサンプルコードCloseで発生したエラーを補足するのに失敗した「だめな例」です。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
package main

import (
	"errors"
	"fmt"
	"io"
)

func main() {
	err := f()
	fmt.Println(err)
}

func f() error {
	err := doSomething()
	var c closer
	defer Close(c, &err)
	return err
}

func doSomething() error {
	return errors.New("original error")
}

type closer struct{}

func (c closer) Close() error {
	return errors.New("failed to close")
}

func Close(closer io.Closer, errp *error) {
	err := closer.Close()
	if err != nil {
		if *errp != nil {
			*errp = fmt.Errorf("err from close: %w: %w", err, *errp)
		} else {
			*errp = err
		}
	}
}

The Go Playgroundで実行してみると分かる通り、出力されるエラーメッセージはoriginal errorだけでfailed to closeは含まれていません。
せっかく、defer Closeを呼び出しても、ローカル変数のerrを書き換えただけで、戻り値を書き換えられたわけではないためです。

ということで、タイトルの通り、「名前付き戻り値」を使って、戻り値の書き換えを行ってみます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
package main

import (
	"errors"
	"fmt"
	"io"
)

func main() {
	err := f()
	fmt.Println(err)
}

func f() (err error) {
	err = doSomething()
	var c closer
	defer Close(c, &err)
	return err
}

func doSomething() error {
	return errors.New("original error")
}

type closer struct{}

func (c closer) Close() error {
	return errors.New("failed to close")
}

func Close(closer io.Closer, errp *error) {
	err := closer.Close()
	if err != nil {
		if *errp != nil {
			*errp = fmt.Errorf("err from close: %w: %w", err, *errp)
		} else {
			*errp = err
		}
	}
}

出力は期待通り、err from close: failed to close: original error になります。The Go Playgroundでも確かめられます。

このように、defer Closeのエラーハンドリングをちゃんとやろうとすると、意外と注意することが多いのです。。

Linter named は何をしてくれるのか?

Linter namedは、指定した関数が名前付き戻り値を引数として受け取ることを保証します。
例えば、defer Closeのエラーハンドリングをちゃんとやるには名前付き戻り値を使わないとだめ。 の最初の例(だめな例)に対して、linter namedを実行すると次のようにlintエラーが表示されます。

./main.go:17:8: Close should be called with a named return value as the 2th argument

いい感じですね 😙

Linter namedはよくあるGoのlinterとちがってバイナリを提供しません。go installできません。利用者にbuildしてもらい、go vetと一緒に使ってもらう想定です。こんな感じで main.gonamedの設定をハードコードします。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
package main

import (
  "github.com/qawatake/named"
  "golang.org/x/tools/go/analysis/unitchecker"
)

func main() {
  unitchecker.Main(
    named.NewAnalyzer(
      named.Deferred{
        PkgPath:  "pkg/in/which/target/func/is/defined",
        FuncName: "Close",
        ArgPos:   1,
      },
    ),
  )
}

buildしたバイナリnamedにパスが通っていれば、go vet -vettool=$(which naned)でリントできます。

ちなみに、nameddefer Close以外のユースケースにも対応できます。例えば、準標準package pkgsite の内部で使われている、関数derrors.Wrapにも使えます。derrors.Wrapはエラーをラップしないままにreturnしてしまうのを防ぐための関数であり、先程のdefer Closeのパターンと同様に戻り値を書き換えることを意図しています。そのため、derrors.Wrapの引数にも名前付き戻り値を渡す必要があり、linter namedの守備範囲に含まれることになります。

試しにpkgsitenamed linterをかけてみたところ、エラーは検出されませんでした。流石でした🙇

Q&A

defer Close用の関数を用意してないんだけど??

この場合、namedは使えません。そもそもこのようにdefer Close用の関数が用意されていない場合、Closeメソッドの戻り値であるエラーをどうハンドリングするかは決まっていないのでlinterで誤りを検知するのは難しそうです。

false positiveを許せばチェックは可能かもしれません。呼び出し元の関数で名前付き戻り値を使っていない場合はエラーにするとか、Closeの戻り値が直接的あるいは間接的に名前付き戻り値の値に使われていることをチェックするとか。

どうして設定ファイルを使わずに、設定をGoでハードコードするの?

yamlを書きたくないからです。リポジトリにこれ以上yamlファイルを増やしたくない。。

もうちょっとちゃんと言うと、Goで設定を書けばIDEの恩恵を簡単に受けられるためです。設定を構造体として記述していくので、自動補完も効きますし、フィールドの説明文も自動で表示されて便利です。

Share on

qawatake
WRITTEN BY
qawatake