Goのプラクティスまとめ: error handling
Goのプラクティスまとめ: error handling
筆者がGo
を使い始めた時に分からなくて困ったこととか最初から知りたかったようなことを色々まとめる一連の記事です。
以前書いた記事のrevisited版です。話の粒度を細かくしてあとから記事を差し込みやすくします。
他の記事へのリンク集
- (まだ)
今はこうやる集 - (まだ)
プロジェクトを始める - (まだ)
dockerによるビルド -
error handling
: ここ - (まだ)
fileとio - (まだ)
jsonやxmlを読み書きする - (まだ)
cli - (まだ)
environment variable - (まだ)
concurrent Go - (まだ)
context.Context: long running taskとcancellation - (まだ)
http client / server - (まだ)
structured logging - (まだ)
test - (まだ)
filesystem abstraction
(リンク集は別の記事で出そうかと思ったんですが、そういえばzennだとリンク集とか見たことがない、本使えと怒られるかもなあ・・・とちょっと不安になったので一連の記事に相互リンクを張る形にします。)
error handling
Go
にはtry-catch
のような構文や、Result<T, E>
のようなtagged union typeのようなものは現状存在しません。(sum typeのproposalは長く存在するが一向に進まない。#19412, #57644など)
代わりに、Go
は関数が複数の値を返すことが可能で、慣習的にソース順で最後の返り値の型をerror
とすることでerrorしうる処理を表現します。
error handlingとはその値をチェックすることをさします。
この記事ではerror handling, errorの組み立て方などについて色々述べます。
前提知識
- A Tour of Go
- ほか言語での開発経験
環境
# go version
go version go1.23.2 linux/amd64
TL;DR
-
fmt.Errorfでerrorはラップできる
- 基本的にラップしてメッセージを追加したほうがよい
- ただしio.EOFなどはラップしてはいけない
-
errors.Is, errors.Asでerrorを判別する
-
os.Open
のerrorの判別はfs.ErrNotExistなどと比較するとよい
-
-
error
を実装する型を定義する際には以下を気を付ける- method receiverはpointerのほうが良い
- typed-nilに注意する
- panic-recoverで一気に抜けることもできる
- 自分で行ったpanicが以外を拾わないように、特定の型の値でpanicするなど注意する
- resourceの解放は常にdeferで行うとよい
- http.Serverがpanicを勝手にrecoverしてしまうため、panicは勝手にrecoverされるものと思っておいたほうが良い
- また、呼び出しているライブラリの関数がふいにpanicすることも十分ありうる
- stacktraceはerrorについて回らないので付けたかったら自分でつける
-
recover
した関数内でstacktraceを取得するとpanicのstacktraceを取得できるので、ログしたいときはこれを用いる
-
errorはinterface
下記に示される通り、error
とはError
メソッドのみをもつinterface
です。
// https://pkg.go.dev/builtin#error
// The error built-in interface type is the conventional interface for
// representing an error condition, with the nil value representing no error.
type error interface {
Error() string
}
error
型の値がnon-nilであるとき、Error
は返り値でerrorが何に関してなのかとか、どうして起きたのかとかを説明します。
error
はさらにほかのerrorをラップすることで木構造を構築することがあり、その木構造の中に特定の型や、特定の値を含むことで、どのようなerrorであったのか判定に使われることがあります。
値や型は後述するerrors.Isやerrors.Asで探索されます。
基本: 失敗ならerr != nil
タイトルのような慣習(というべきなのかよくわかりませんが)があります。
-
err == nil
ならば、ほかの返り値は使ってもよい- 返り値の最後の値が
error
型であるとき、それがnilならばそれ以外の値は基本的に使ってよいことになります。-
interface
,*T
,chan T
,map[K]V
はnon-nil -
T
は(それがふさわしければ)non-zero - (ただしスライス
[]T
がnilなことは比較的普通かも)
-
- 返り値の最後の値が
-
err != nil
なら、ほかの返り値は基本使ってはいけない- 明確にnon-nil error時に他の値を使ってもよいとドキュメントされている場合除きます。
- e.g.
io.Reader
がn > 0, io.EOF
を返してくることがある
- e.g.
- 明確にnon-nil error時に他の値を使ってもよいとドキュメントされている場合除きます。
慣習的にポインターを返す関数がそれの返り値のnil checkをさせることはほとんどありません。
かわりに最後の返り値のerrorのnil check, もしくはok bool
をチェックさせます。
func failableWork(...any) (ret1 io.Reader, ret2 *UltraBigBigData, err error)
func foo() error {
ret1, ret2, err := failableWork()
if err != nil {
// ret1, ret2は普通はzero value.
// io.Reader, *Tの場合はnilなので、
// ret1.Readを呼ぶとnil pointer derefでパニックする
return err
}
// この時点ret1, ret2は普通はnon-nilな値であり、
// メソッドよんだり、pointer derefするなりしても安全。
n, err := ret1.Read(make([]byte, 5))
if err != nil {
// ...
}
_ = *ret2
// ret1, ret2を使って何かする
return nil
}
慣習的にerror型の変数名はerr
特別な事情がない限りerr
という名前の変数を使用することが多いです。
また基本的にerr
という名前の変数は複数回使いまわします。
のちにそのerrorが参照されるわけではない場合、別の変数名を当てるのはやめましょう。
foo, bar, err := failableWork1()
if err != nil {
// ...
}
baz, qux, err := failableWork2()
if err != nil {
// ...
}
errorの判別
err != nil
でerrorなことはわかるけどどういうerrorなのかを判定したいときは多くあります。
例えばファイルを開くのがerrorしたとき、ENOENT
なのかEPERM
なのかぐらいは最低でも知らないとハンドルできないですよね。
- errorは基本的に、特定の値(pointerなど)で特定できるものと、型で特定できるものがある
- e.g. 値 => io.EOF, net.ErrClosed, (os/exec).ErrNotFoundなど
- e.g. 型 => *(encoding/json).SyntaxError, *(io/fs).PathError
- 取り合えずerrors.Is, errors.Asを使っておけばよい
errors.Is, errors.As
errors.Isでerrorが特定の値を含むのかどうか、errors.Asでerrorが特定の型を含むのかどうかを判定できます。
Go
におけるerrorは木構造を持てます。木構造の構築のしかたは後述しますが、その木構造をdepth-firstで探索して特定の値を含むか、もしくは特定の型を含むかの判定を上記の二つの関数で行います。
errors.Is: errorが特定の値を含むかどうかの判別
前述の(os/exec).ErrNotFoundの例でerrors.Isを利用すると以下のようになります。
var someCommand string = "something"
_, err := exec.LookPath(someCommand)
if errors.Is(err, exec.ErrNotFound) {
fmt.Printf("command %q not found\n", someCommand)
} else err != nil {
// handle error other than "not found"...
}
// continue working...
errors.As: errorが特定の型を含むかどうかの判別
前述の*(encoding/json).SyntaxErrorの例で、errors.Asを利用すると以下のようになります。
var tgtData any
err := json.Unmarshal(brokenJsonBinary, &tgtData)
var syntaxErr *json.SyntaxError
if errors.As(err, &syntaxErr) {
fmt.Printf("err = %#v\n", syntaxErr) // err2 matched, err = &json.SyntaxError{msg:"", Offset:19}
}
errors.Asは第二引数で取り出したいerrorの具体的な型の変数の参照をを渡します。
pointer渡しするのは、As
が第一引数のerr
を探索しながら、第二引数に渡された値の型に代入可能なものを探し、可能ならば代入するからです。
ですので、As
がtrueを返す時、上記のsyntaxErr
は取り出されたerrorの値となっています(Offset: 19のように、zero valueでなくなっている。)
err == tgt / err.(T)
errorがラップされていない状況においてのみ、
- comparison:
err == tgt
-
type assertion:
err.(T)
-
type switch:
switch x := err.(type) {}
でもerrorの判別を行えます。
基本的にerrors.Isかerrors.Asを使っておくほうが好ましいです。前述通りこれらはラップされているerrorに対しても正常に動作するからです。
// comparison
if err == io.EOF {
// handle eof...
}
// type-assertion
syntaxErr, ok := err.(*json.SyntaxError)
if ok {
// handle error...
_, err := r.Seek(syntaxErr.Offset, io.SeekStart)
}
// type-switch
switch x := err.(type) {
case nil:
fmt.Println("no error")
case *json.SyntaxError:
// このブランチではxは*json.SyntaxError型
_, err := r.Seek(x.Offset, io.SeekStart)
}
例: filesystem関連errorの判別
Go
には、io/fsパッケージがあり、抽象的なfs
操作が可能であることから、典型的なerrorはfs
パッケージで再定義されています。
syscall.Errnoがinterface { Is(error) bool }
というerrors
パッケージが特別な考慮を行うためのinterfaceを実装しているので、それをラップして返すos
パッケージのerrorに対しての判別に使うことができます。
f, err := os.Create("path/to/file")
if err != nil {
switch {
case errors.Is(err, fs.ErrNotExist):
// errors.Is(err, syscall.ENOENT)と同じ効果
fmt.Println(fs.ErrNotExist.Error()) // file does not exist
case errors.Is(err, fs.ErrPermission):
// errors.Is(err, syscall.EPERM)と同じ効果
fmt.Println(fs.ErrPermission.Error())
case errors.Is(err, syscall.EROFS):
}
}
fs
で定義されていないerrorに関してはsyscallパッケージ定義されるsyscall.Errnoとerrors.Isで比較を行います。これはerrno(3)の数値にerror
を実装したものですが、windows向けにも同名シンボルが定義してあるのでwindowsに対しても使うことができます。
windows向けに*invent*されたerrno
雑にgo doc -all syscall | sed -n '/^const (/,/^)/p'
をGOOS=linux
, GOOS=windows
で出し分けてみましたがどちらも133個だったのでwindows向けにも全部inventされているっぽいですかね。
これらのErrno
を利用する場合には自身でGOOS=windows
でもgo build
してみることで型チェックを通しておくことをお勧めします。(package main
でないパッケージに対してgo build
をかけると単に型チェックだけがかかる。go vet
でもよいです。)
errorのラッピング
前述通りGoのerror
は木構造を持つことができ、errors.Is, errors.Asを用いることでその木構造をdepth-firstに探索したマッチができます。
木構造をたどるにはinterface { Unwrap() error }
もしくはinterface { Unwrap() []error }
の実装をチェックし、呼び出すわけですが、この語彙を逆にして、error
を子ノードとしてもつerror
を作成することを「ラップする」/「ラッピング」などと呼びます。
その方法について以下で述べます。
fmt.Errorf
いちばん手軽なのはfmt.Errorfを使うことでerrorをラップする方法です。
format stringで%w
verbを指定し、引数にerror
型を渡すことでラップを行います。
%w
なしだと別のerrorをラップしないerrorを得られます。
%w
以外のverbは他のfmt.*printf
系の関数と同じように使えます。error messageをfmt.Sprintf
で出力するのと等価です。
err := fmt.Errorf("foo")
wrapped := fmt.Errorf("bar: %w, param1 = %d, param2 = %.2f", err, 10, 0.1234)
fmt.Printf("err = %v\n\n", wrapped) // err = bar: foo, param1 = 10, param2 = 0.12
fmt.Printf("not same = %t\n", err == wrapped) // not same = false
fmt.Printf("but is wrapped = %t\n", errors.Is(wrapped, err)) // but is wrapped = true
複数のerrorを渡すことで複数のerrorをラップする単一のerrorを得られます。
err1 := fmt.Errorf("foo")
err2 := fmt.Errorf("bar")
err3 := fmt.Errorf("baz")
wrapped := fmt.Errorf("multiple: %w, %w, %w", err1, err2, err3)
fmt.Printf(
"wraps all = %t, %t, %t\n",
errors.Is(wrapped, err1),
errors.Is(wrapped, err2),
errors.Is(wrapped, err3),
) // wraps all = true, true, true
複数errorを持つためここで木構造が生じます。
当然errors.Asも機能します。
type wrappeeErr struct {
error
}
wrapped := fmt.Errorf("bar: %w", wrappeeErr{fmt.Errorf("foo")})
var tgt wrappeeErr
fmt.Printf("wrapped = %t\n", errors.As(wrapped, &tgt)) // wrapped = true
型を定義する
別のerrorを含むことができるerror
interfaceを実装する型を定義することでもerrorをラップすることができます。
この場合、Unwrap() error
もしくはUnwrap() []error
を実装することでerrors.Is, errors.Asがこれらをunwrapすることができます。
type customErr struct {
Reason string
Param any
Err error
}
func (e *customErr) Error() string {
return fmt.Sprintf("*customErr: %s, param = %v", e.Reason, e.Param)
}
func (e *customErr) Unwrap() error {
return e.Err
}
func wrapByCustomError() {
err := fmt.Errorf("foo")
wrapped := &customErr{
Reason: "bar",
Param: "baz",
Err: err,
}
fmt.Printf("err = %v\n", wrapped)
// err = *customErr: bar, param = baz
fmt.Printf("wrapped = %t\n", errors.Is(wrapped, err))
// wrapped = true
}
例外: io.EOFはラップしない
基本的にはerrorはラップすることでメッセージを付け足せるのでしたほうがよいのですが、例外的にラップしてはいけないerrorもあります。
それはio.EOFのような、
- sentinel valueとして用いられるerror
-
Go1.13
以前から利用されていたもの
です。
errorのラッピングの概念はGo 1.13(2019-09-03以降)からstdに追加されています。
ですから、Go1.13
以前に利用されるerrorはラッピングを考慮していないことがあります。
stdの範囲でも、err == io.EOF
のように比較を行うことでこれらのsentinel valueとして使うツールがいくつかあります。
例えばio.Copyはio.Readerがio.EOFを返したときにnilを返す考慮があります。
== io.EOF
でGo
のrepositoryを検索するとすごくたくさん出てきます。
このようなsentinel valueとして使われるerrorの値はラップしないようにしてください。
特に特定のinterface
(e.g. io.Reader)を実装してそれを使う関数に渡す時、特定のerrorを返すのがinterface
の規約として決まっているとき(e.g. io.EOF)、そのerrorはラップしないように気を付けてください。
error valueを定義する
パッケージとして判別可能なerrorを返す時はexportされた変数をerrors.Newかfmt.Errorfで定義し、失敗しうる関数はこれを直接返すか、ラップして返します。
var (
ErrCauseCause = errors.New("root cause..")
ErrFooFoo = fmt.Errorf("foo foo")
)
// someFailableWork does ...
// When ... someFailableWork returns [ErrCauseCause]
func someFailableWork() (..., error) {
return ..., fmt.Errorf("cause of failures...: %w", ErrCauseCause)
}
- 慣習的に
Err
から始まる変数名を使います - 慣習的にerror messageはすべて小文字にします。
-
fmt.Errrof("foo bar: %w", fmt.Errorf("baz qux: %w", err))
みたいな感じでメッセージをつなげていくことが多いため、先頭大文字だと変に見えるからです。
-
- errorを返す関数はどのようなときに、どのerrorをラップして返すかをdoc commentで明確に説明します
-
doc comment
上で[SymbolName]
とするとリンクとして機能する挙動がgo docにはあるため、これを積極的に用います。
-
- error messageはわかりやすければ何でもいいですが、root causeのみを説明するとよいです
- errorをラップして上位のコンテクストを徐々に追加形式になりがちなため、冗長なerror messageは重複を生みかねないためです。
- i.e.
ErrNotEligible = errors.New("not eligible")
,uid = 10 : unprivileged user: not eligible
error typeを定義する
fmt.Errorfでerrorをラッピングして回れば事足りる場面も多いですが、例えば下記のようなときerror
interfaceを実装する型を定義することがあります。
- あとからパラメータをとり出したい
- あとからパラメータを変更したい
- ほぼ共通だが複雑なerror messageの構築処理がある
- 複数のcategoryに複数のkindがるため、categoryを型として表現したい
- e.g. jsonにおけるio error, syntax error, semantic error
型を定義する際に気をつけるべき注意点を述べます。
すでにサンプルの中で使用していますが、定義自体は以下のように行います。
type customErr struct {
Reason string
Param any
Err error
}
func (e *customErr) Error() string {
return fmt.Sprintf("*customErr: %s, param = %v", e.Reason, e.Param)
}
func (e *customErr) Unwrap() error {
return e.Err
}
method receiverはpointerのほうが好ましい
つまり下記のような形です。
// こうではなく
func (e customErr) Error() string
// こう
func (e *customErr) Error()
これは二つ理由があります。
- (1) non-pointer type
T
がmethod receiverであるとき、T
、*T
どちらもinterfaceを満たすこと - (2) interfaceはspec上comparableだが、比較するとき2つのinterfaceのdynamic typesが同一でcomparableでないときruntime-panicが起きること
(1)に関しては単純に紛らわしいということです。特定の型のerrorを返す場合はドキュメントに明確に書いておくほうが良いので、どちらでもよいといえばいいのですが、method receiverがpointerであればpointerでないとinterfaceを満たせないためどちらなのかを気にする必要すらありません。
ただし、type someErr int
のようなbuilt-inかつcomparableな型をベースとする場合はmethod receiverはnon-pointerであるほうが一般的だと思います。これはある程度のサイズ(昔ググった時はdouble型が3つ分以上、という風に言われてました)がないデータはpointerをderefするより値を渡してしまったほうがより高速であるからという理由があるからなはずです(特に出展を示せません。)
(2)に関しては以下のsnippetをご覧ください。
package main
import (
"fmt"
)
type uncomparableErr1 []error
func (e uncomparableErr1) Error() string {
return "uncomparableErr1"
}
type uncomparableErr2 struct {
errs []error
}
func (e uncomparableErr2) Error() string {
return "uncomparableErr2"
}
func compareErr(err1, err2 error) {
if err1 == err2 {
fmt.Println("huh?")
}
func() {
defer func() {
if rec := recover(); rec != nil {
fmt.Printf("comparing err1 panicked = %v\n", rec)
}
}()
if err1 == err1 {
fmt.Printf("err1 equal = %v\n", err1)
}
}()
func() {
defer func() {
if rec := recover(); rec != nil {
fmt.Printf("comparing err2 panicked = %v\n", rec)
}
}()
if err2 == err2 {
fmt.Printf("err2 equal = %v\n", err2)
}
}()
}
func main() {
ue1 := uncomparableErr1{fmt.Errorf("foo"), fmt.Errorf("bar")}
ue2 := uncomparableErr2{errs: []error{fmt.Errorf("foo"), fmt.Errorf("bar")}}
// invalid operation: ue1 == ue1 (slice can only be compared to nil) compiler(UndefinedOp)
// if ue1 == ue1 {
// }
compareErr(ue1, ue2)
// comparing err1 panicked = runtime error: comparing uncomparable type main.uncomparableErr1
// comparing err2 panicked = runtime error: comparing uncomparable type main.uncomparableErr2
compareErr(&ue1, &ue2)
// err1 equal = uncomparableErr1
// err2 equal = uncomparableErr2
}
上記の通り、non-pointer typeをmethod receiverとしたときに、error
typeとしてuncomparableErr1
, uncomparableErr2
を引数に渡すと、err == err
でcomparing uncomparable typeでrun-time panicをおこします。
この挙動はspecのcomparison operatorsの部分で明記されています。
https://go.dev/ref/spec#Comparison_operators
A comparison of two interface values with identical dynamic types causes a run-time panic if that type is not comparable.
つまり別のerror
type, 例えばio.EOF
との比較はrun-time panicになりませんので、大きな問題にはなりにくいでしょう。
問題になるのは例えば、複数回同じ関数を実行してerrorが比較されるときなどでしょうか?
こういった比較を行うことがありうるかは筆者には想像がつきませんが、compilation errorにならずにrun-time errorになってしまうため、避けられるらならさけたほうがいい問題でしょう。
error
のdynamic typeがpointerである場合、当然ですがpointerのアドレス同士の比較となるためrun-time panicとなりません。
もし仮に、これらのerror typeのmethod receiverをpointerに変えると
type uncomparableErr1 []error
-func (e uncomparableErr1) Error() string {
+func (e *uncomparableErr1) Error() string {
return "uncomparableErr1"
}
compareErr(ue1, ue2)
の部分でcompilation errorとなります。pointerではないので、error
interfaceを満たせなくなるためです。
cannot use ue1 (variable of type uncomparableErr1) as error value
in argument to compareErr: uncomparableErr1 does not implement error
(method Error has pointer receiver)compiler(InvalidIfaceAssign)
こうすると同様に、関数の返り値がerror
typeであるときにnon pointerのuncomparableErr1
を返すこともcompilation errorとすることができます。
そのため事故的にさえnon pointer typeをerror
として返すことがなくなります。
上記より、error
interfaceを満たす型はpointer receiverでError
メソッドを実装したほうが良いです。ただし例外としてint
, float64
のような組み込み型、サイズの小さいcomparableな型をunderlying typeとするerror
はError
methodのreceiverをnon-pointerとしておいたほうがいいでしょう(c.f. syscall.Errno)。
typed nilに注意
stdを含めて、多くのライブラリが自らが定義したerror
typeを返り値の型に使うことはなく、error
interfaceで返すことが多いです。
// こうではなく
func failableWork() (any, *MyError)
// こう
func failableWork() (any, error)
// たとえ、実際には`&MyError{}`を返しているときでも。
自らが定義したerror typeの値を後から変更するにはどうするか
決まり切った型のerrorしか返さないunexport functionの返り値のerrorがnon-nilなときに、中身を変更したいときはstdは下記のようにしています。
type assertion
で特定の型として取り出し、そのうえで中身を変更します。
こうやって中身を変えるという観点からもerror
typeのmethod receiverはpointerであるべきだといえます。
non-pointerとして取り出して変更する場合、変更した変数を元のerr
に代入しなおす必要がありますが、interfaceに変換される際にbox化でallocationが起きるため避けたほうが良いです。
それはなぜなのかというと
-
error
typeに変換するときのtyped nilの可能性 -
func() (..., error)
なinterfaceを満たせない - 後方互換性のために、その関数が返す型を追加したり変えたりできなくなる
後者二つはまあそのままなので分かると思います。
問題はtyped-nilで、下記のようなことが起きます。
type MyError struct {
Msg string
}
func (e *MyError) Error() string {
return e.Msg
}
func myTask() (someResult string, err *MyError) {
return "ok", nil
}
func someTask() (string, error) {
ret, err := myTask()
return ret, err
}
func main() {
ret, err := someTask()
if err == nil {
fmt.Println("success")
} else {
fmt.Println("failed") // failed
}
fmt.Printf("ret = %s, err = %#v\n", ret, err) // ret = ok, err = (*main.MyError)(nil)
}
someTask
から返ってくるerr
がnon-nilとなっています。
これは、myTask
が*MyError(nil)
を返し、someTask
はそれをerror
に変換しているためです。
Go
のmethodは暗黙的にreceiverを第一引数とする関数のように取り扱われます。
method呼び出しをobjdumpしてmethod receiverの扱いを確かめる
下記のソースを用意します。*foo.Bar
の内容には今回関心がないので、//go:noinline
をつけて、main
にこの関数がinlineされないようにします。
package main
import "fmt"
type foo int
//go:noinline
func (f *foo) Bar(baz int) {
fmt.Println(f, baz)
}
func main() {
f := foo(0x55)
f.Bar(0x123)
}
go build -o main ./
で適当にバイナリ出力し、objdump -d ./main > main.exec.txt
とすると以下のように出力されます。(かなり端折ってます)
000000000048f1e0 <main.main>:
48f1e0: 49 3b 66 10 cmp 0x10(%r14),%rsp
48f1e4: 76 2b jbe 48f211 <main.main+0x31>
48f1e6: 55 push %rbp
48f1e7: 48 89 e5 mov %rsp,%rbp
48f1ea: 48 83 ec 10 sub $0x10,%rsp
48f1ee: 48 8d 05 eb 94 00 00 lea 0x94eb(%rip),%rax # 4986e0 <type:*+0x86e0>
48f1f5: e8 86 d6 f7 ff call 40c880 <runtime.newobject>
48f1fa: 48 c7 00 55 00 00 00 movq $0x55,(%rax)
48f201: bb 23 01 00 00 mov $0x123,%ebx
48f206: e8 35 ff ff ff call 48f140 <main.(*foo).Bar>
48f20b: 48 83 c4 10 add $0x10,%rsp
48f20f: 5d pop %rbp
48f210: c3 ret
48f211: e8 ca a9 fd ff call 469be0 <runtime.morestack_noctxt.abi0>
48f216: eb c8 jmp 48f1e0 <main.main>
48f1e0: 49 3b 66 10 cmp 0x10(%r14),%rsp
48f1e4: 76 2b jbe 48f211 <main.main+0x31>
...
48f211: e8 ca a9 fd ff call 469be0 <runtime.morestack_noctxt.abi0>
48f216: eb c8 jmp 48f1e0 <main.main>
まではstack growth preambleとかと呼ばれていて、(多分)すべての関数の先頭についています。Go
は、というかgoroutine
はstackが固定サイズでなく成長することがあるので、まず成長が必要かのチェックが走るらしいです。さらにこのmorestack
の呼び出しの中でcooperativeなgoroutine
の切り替えが起こることがあります。つまり特定のタイミングで、stack growthが不要でも必要であるかのようにふるまうことがあります。
method receiverがpointerであるが、値はnon-pointerであるので自動的にobjectに変換されています。
48f1ee: 48 8d 05 eb 94 00 00 lea 0x94eb(%rip),%rax # 4986e0 <type:*+0x86e0>
48f1f5: e8 86 d6 f7 ff call 40c880 <runtime.newobject>
この直後に%rax
(=runtime.newobject
の返り値であるunsafe.Pointer
)の指し示すアドレスにimmediate valueの0x55
をコピーしています。
methodの引数である0x123
は値渡しなのでbx
にコピーしています。
48f1fa: 48 c7 00 55 00 00 00 movq $0x55,(%rax)
48f201: bb 23 01 00 00 mov $0x123,%ebx
48f206: e8 35 ff ff ff call 48f140 <main.(*foo).Bar>
という感じで、レジスタにmethod receiver,methodの引数が置かれていますね。
Go
は筆者がdeassembleしている限りにおいては関数のcalling conventionとして引数と返り値はレジスタに直接置く形をとっています。ですので、ax
が第一引数、bx
が第二引数となっています。
筆者はアセンブリにもamd64にも全く詳しくないのでこれ以上はよくわかりません。
型を持つnil
に対するmethodの呼び出しは、method receiverがnon-pointerならnil pointer dereferenceでrun-time panicとなりますが、pointer receiverならばnilを渡すことにになります。関数の引数がpointerであるとき、そこにnilを渡すことが何かの意味を持つというのは普通にありうる話であり、これを禁じるのもまた、おかしな話です。
であるため、interfaceへの変換がかかる部分で、型情報のあるnilを渡すと、変換後のinterfaceはnon-nilとなります。nilをreceiverとしたmethodの呼び出しは合法であり、意図的にそれをすることも十分ありえるからでしょう。
この場合、以下のように変更すれば、nil
がプリントされます。
func someTask() (string, error) {
ret, err := myTask()
+ if err != nil {
+ return ret, err
+ }
- return ret, err
+ return ret, nil
}
interfaceに値を渡す時は、typed-nilに注意しましょう。
関数呼び出しの引数や返り値にはerror
interfaceのみを使うようにし、自ら定義したerror typeはそのsurfaceに一切出現させないほうが良いです。
(ただし型そのものをexportするのはよい)
Advanced: interface { Is(error) bool }を実装する
必要になることはめったにないと思いますが、interface { Is(error) bool }
をerror
typeに実装するとerrors.Isとともに用いられるときに挙動がカスタマイズできるため、便利な場面があります。
errors.Isはそのdoc commentより第一引数がinterface { Is(error) bool }
を実装するとき、そちらの実装も使います。
An error is considered to match a target if it is equal to that target or if > it implements a method Is(error) bool such that Is(target) returns true.
An error type might provide an Is method so it can be treated as equivalent to > an existing error. For example, if MyError defines
func (m MyError) Is(target error) bool { return target == fs.ErrExist }
then Is(MyError{}, fs.ErrExist) returns true. See syscall.Errno.Is for an example in the standard library. An Is method should only shallowly compare err and the target and not call Unwrap on either.
あまりはっきり書かれていない気がしますが、errors.Isはerr
を順次unwrapしながらunwrapped == target
という比較を繰り返す挙動になっています。そのため、target
(第二引数)のdynamic typeがuncomparableであるとき基本的に何もしません。(逆に言ってuncomparable同士の比較でpanicを起こすこともありません。)
そこで、err
(第一引数)かそれをunwrapして得られたerrorがinterface { Is(error) bool }
を実装するときにはそちらの実装による比較も行うようになっています。単なるerr1 == err2
を超えた挙動を実現できるため、例えば複数のerror値に対してマッチするようにするなどカスタマイズに幅があります。
実装サンプルを以下に挙げます。
このサンプルでは、bit flagで表現されるerrorを複数bitwise-ORすることで持つことができるerror
typeを定義し、これに対して特定のbit flagを持っているかをerrors.Isで検査できるようにします。
type ErrKind int
func (e ErrKind) Error() string {
var s strings.Builder
s.WriteString("kind ")
var count int
for i := 0; i < 32; i++ {
if e&(1<<i) > 0 {
if count > 0 {
s.WriteByte('&')
}
s.WriteString(strconv.Itoa(i + 1))
count++
}
}
return s.String()
}
const (
ErrKind1 = ErrKind(1 << iota)
ErrKind2
)
type errBare struct {
Msg string
Kind ErrKind
}
func (e *errBare) Error() string {
return e.Msg
}
type errIs struct {
errBare
}
func (e *errIs) Is(err error) bool {
if k, ok := err.(ErrKind); ok {
return e.Kind&k > 0
}
return false
}
当然、Is
を実装しないerrBare
では、ErrKind1
との比較でtrueが返ってくることはありませんが、
err1 := &errBare{Msg: "is", Kind: ErrKind1}
fmt.Printf("is = %t\n", errors.Is(err1, ErrKind1)) // is = false
fmt.Printf("is = %t\n", errors.Is(fmt.Errorf("wrapped; %w", err1), err1)) // is = true
errIs
ではこのIs
の実装が利用されるため、trueとなります。
err2 := &errIs{errBare{Msg: "is", Kind: ErrKind1 | ErrKind2}}
fmt.Printf("is = %t\n", errors.Is(err2, ErrKind1)) // is = true
fmt.Printf("is = %t\n", errors.Is(err2, ErrKind2)) // is = true
fmt.Printf("is = %t\n", errors.Is(fmt.Errorf("wrapped: %w", err2), err2)) // is = true
Is
が実装されていても、err == target
の比較はそれはそれとしてerrors.Isが行うため、Is
の実装そのものがreceiver == input
を判定する必要はありません。したほうが良いとは思います。
(つまりこうしたほうが基本的にはいいはず)
func (e *errIs) Is(err error) bool {
if k, ok := err.(ErrKind); ok {
return e.Kind == k
}
- return false
+ return e == err
}
(method receiverがpointerであるとき、必ずcomparableなのでuncomparable同士の比較によるrun-time panicを恐れる必要はない)
interface { Is(error) bool }実装の典型: syscall.Errno
そのほかの典型例としてはsyscall.Errnoがあります。errors.Isのdoc commentでも触れられていますね。
os
パッケージの各種関数が返すerrorでerrors.Is(err, fs.ErrNotExist)
が機能するのはErrno
にIs
が実装されているからです。
(unix版)
(windows版)
oserror
とは何でしょうか?
これらの値の参照をたどると以下で出てきます。
わざわざ一旦関数を経由して値を定義しているのはoserror
という文字列をgo docに乗せたくないからなのかなあと思います。io/fsのvariable sectionを見るとわかる通り、constやvariableセクションはソースコードがそのまま乗ってしまうのです。
os
パッケージで使うと書いているのはどういうことかというと
という感じでプラットフォーム間/API間でerrorを同一扱いするためにこういうことをしているようです。
Advanced: interface { As(any) bool }を実装する
Is
に更に輪をかけて必要になる場面が少ないと思いますが、interface { As(any) bool }
をerror
typeに実装するとerrors.Asとともに用いられるときに挙動がカスタマイズできるため、便利な場面があります。
errors.Asはそのdoc commentより第一引数がinterface { As(any) bool }
を実装するとき、そちらの実装も使います。
An error matches target if the error's concrete value is assignable to the value pointed to by target, or if the error has a method As(any) bool such that As(target) returns true. In the latter case, the As method is responsible for setting target.
An error type might provide an As method so it can be treated as if it were a different error type.
As panics if target is not a non-nil pointer to either a type that implements error, or to any interface type.
こちらも同じくあまりはっきり書かれていない気がしますが、errors.Asは単にerr
を順次unwrapながらtarget
に対してunwrapped
がassign可能かを判定し、可能ならassignしてtrueを返す挙動になっています。
こちらも同じく、err
(第一引数)かそれをunwrapして得られたerrorがinterface { As(any) bool }
を実装するときにはそれを使用するようになっています。単なる*target == err
を超えた挙動を実現できるため、変換しながら代入とかいろいろできるようになっています。
サンプルを以下に挙げます。
このサンプルでは、Is
実装用いたのと同じbit flagで表現されるerrorを複数bitwise-ORすることで持つことができるerror
typeを定義し、bit flag部分だけを取り出せるようにAs
を実装します。
type ErrKind int
func (e ErrKind) Error() string {
var s strings.Builder
s.WriteString("kind ")
var count int
for i := 0; i < 32; i++ {
if e&(1<<i) > 0 {
if count > 0 {
s.WriteByte('&')
}
s.WriteString(strconv.Itoa(i + 1))
count++
}
}
return s.String()
}
const (
ErrKind1 = ErrKind(1 << iota)
ErrKind2
)
type errBare struct {
Msg string
Kind ErrKind
}
func (e *errBare) Error() string {
return e.Msg
}
type errAs struct {
errBare
}
func (e *errAs) As(tgt any) bool {
if k, ok := tgt.(*ErrKind); ok {
*k = e.Kind
return true
}
return false
}
As
の引数に渡されるのは、errors.Asの第二引数そのままなので、場合によって**T
かもしれませんし, *T
, **T
どちらも渡されるというのもありうるので注意しましょう。
当然As
を実装しないerrBare
に対してerrors.Asを実行してもtrueは帰ってきませんが、
err1 := &errBare{Msg: "is", Kind: ErrKind1}
var kind ErrKind
fmt.Printf("as = %t\n", errors.As(err1, &kind)) // as = false
errAs
では、As
の実装が利用されるため、trueになり、さらにinterfaceの規約通り、As
実装がtarget
にassignを行うため、渡した&kind
には値が代入されています。
err2 := &errAs{errBare{Msg: "is", Kind: ErrKind1}}
kind = 0
fmt.Printf("as = %t, kind = %s\n", errors.As(err2, &kind), kind) // as = true, kind = kind 1
kind = 0
fmt.Printf("as = %t, kind = %s\n", errors.As(fmt.Errorf("wrapped: %w", err2), &kind), kind) // as = true, kind = kind 1
err2 = &errAs{errBare{Msg: "is", Kind: ErrKind1 | ErrKind2}}
kind = 0
fmt.Printf("as = %t, kind = %s\n", errors.As(err2, &kind), kind) // as = true, kind = kind 1&2
interface { As(any) bool }の唯一の実装例: net/http.http2StreamError
std内で適当に検索をかけたところ、interface { As(any) bool }
を実装するのは以下のhttp2StreamError
だけのようです。
As
の実装は以下です。
reflectを使って、target
と自身がお互いにstructで、各fieldの名前が同一で型がConvertible
かどうかを判定し、同じであるときに各fieldに値をSet
しています。
すべてのfieldが代入可能かまず最初にチェックを行っているのは中途半端に代入を行って失敗を返さないようにするためでしょうね。読者の皆さんも似たような機能を実装する際には同じように中途半端な値を入れない考慮を行うとよりよいでしょう。
ConvertibleTo
を使っていることのポイントは、ほぼ同一構造で変換可能な型で構成されるstructに代入できるようにすることでしょう。
つまり、As
は以下のような別のstructに対してもtrue
を返します。
(実際に代入可能であることはplaygroundで確認してください。)
type fakeHttp2Err struct {
StreamID int // `uint32`や`http2ErrCode`の代わりに`int`を使っていることがポイントです。
Code int
Cause error // optional additional detail
}
func (e fakeHttp2Err) Error() string {
return "fake"
}
ただfunc (e *fakeHttp2Err) Error() string
としまうと、errors.As
には**fakeHttp2Err
を渡すことになりますが、http2StreamError
のAs
実装はそのケースを無視しているので**fakeHttp2Err
に対しては常にfalse
を返してしまいますね。
*T
, **T
両方に対応するためには以下のように変更します。
func (e http2StreamError) As(target any) bool {
- dst := reflect.ValueOf(target).Elem()
+ dstOrig := reflect.ValueOf(target).Elem()
+ dst := dstOrig // T or *T
dstType := dst.Type()
+
+ needSet := false
+ if dstType.Kind() == reflect.Pointer {
+ // *T
+ dstType = dstType.Elem() // T
+ if dst.IsNil() {
+ needSet = true // needs allocation but deferred until needed.
+ } else {
+ dst = dst.Elem() // T, addressable
+ }
+ }
+
if dstType.Kind() != reflect.Struct {
return false
}
src := reflect.ValueOf(e)
srcType := src.Type()
numField := srcType.NumField()
if dstType.NumField() != numField {
return false
}
for i := 0; i < numField; i++ {
sf := srcType.Field(i)
df := dstType.Field(i)
if sf.Name != df.Name || !sf.Type.ConvertibleTo(df.Type) {
return false
}
}
+
+ if needSet {
+ // newly allocated value, mutating dst is not gonna propagate to `target`.
+ dst = reflect.New(dstType).Elem()
+ }
+
for i := 0; i < numField; i++ {
df := dst.Field(i)
df.Set(src.Field(i).Convert(df.Type()))
}
+ if needSet {
+ dstOrig.Set(dst.Addr())
+ }
return true
}
任意の同一構造のstructを受け付る気があるならこういう感じで*U
/**U
両対応が必要です。
読者がただちにこういった実装が必要になるかはわかりませんが、考慮事項としてこういうものがあると気に留めておくとよいかもしれません。
Advanced: interface { Format(fmt.State, rune) }を実装する
fmt.Formatterを実装するとfmt.*printf
でどのようにprintされるかの挙動を完全にコントロールできるようになります。
// Formatter is implemented by any value that has a Format method.
// The implementation controls how [State] and rune are interpreted,
// and may call [Sprint] or [Fprint](f) etc. to generate its output.
type Formatter interface {
Format(f State, verb rune)
}
実装はいい例が思いつきませんでしたが、%+v
の時はError
methodを無視してすべてのfieldを表示するように変更してみます。
type errFormat struct {
errBare
}
func (e *errFormat) Format(state fmt.State, verb rune) {
if verb == 'v' {
if state.Flag('+') {
_, _ = fmt.Fprintf(state, "msg = %s, kind = %s", e.Msg, e.Kind)
return
}
}
// plain does not inherit method from errFormat.
type plain errFormat
_, _ = fmt.Fprintf(state, fmt.FormatString(state, verb), (*plain)(e))
}
fmt.State自体がio.Writerで、結果をここにWrite
するのがFormat
methodの規約となります。
fmt.FormatStringでstate
とverb
から"%#v"
のようなformat stringを再建できます。
%+v
以外のときの挙動を一切変更しないために、それ以外の場合は再建したformat stringでFprintf
します。
type plain errFormat
とするとこでFormat
methodのないが構造が同じ型を定義します。そうしないとFormat
methodが再帰的に呼びされてstack overflowが起きます。
この例ではerrBase
がembedされていることでError
methodは継承されますので都合よく動作します。基本的にはこういったdelegationを全く行わないでこのFormat
methodのなかですべてのパターンをハンドルするか、でなければError
やString
, GoString
などは継承するようにしたほうが良いです。
全フォーマットを網羅してprintしてerrBase
とerrFormat
の結果を比較します。
fmt
の実装を洗って全パターンを網羅しました。以下です。
bare := &errBare{Msg: "is", Kind: ErrKind1}
for _, v := range "vTtbcdoOqxXUeEfFgGsqp" {
verb := string([]rune{v})
fmt.Printf("verb %%%s, bare = %"+verb+", format = %"+verb+"\n", verb, bare, &errFormat{*bare})
for _, f := range " +-#0" {
verb := string([]rune{f, v})
fmt.Printf("verb %%%s, bare = %"+verb+", format = %"+verb+"\n", verb, bare, &errFormat{*bare})
}
}
多くなるのでv
の各パターンのみ結果を表示します
verb %v, bare = is, format = is
verb % v, bare = is, format = is
verb %+v, bare = is, format = msg = is, kind = kind 1
verb %-v, bare = is, format = is
verb %#v, bare = &main.errBare{Msg:"is", Kind:1}, format = &main.plain{errBare:main.errBare{Msg:"is", Kind:1}}
%+v
のみ挙動をが変更できています。%#v
も変わっていますが、これは型を定義した副作用です。
panic-recover
panic
とrecover
を活用して一気に関数を抜けるというerror handlingも存在しえます。
panic
とは何かという話からpanic-recover
で一気に処理を中断する方法の実装例を紹介します。
panic
組み込み関数のpanicを呼び出したり、sliceやarrayのout of index access, nil pointer derefなどが行われるとpanic
が起きます。
quoted from https://go.dev/ref/spec#Run_time_panics
Execution errors such as attempting to index an array out of bounds trigger a run-time panic equivalent to a call of the built-in function panic with a value of the implementation-defined interface type runtime.Error.
quoted from https://go.dev/ref/spec#Handling_panics
Two built-in functions, panic and recover, assist in reporting and handling run-time panics and program-defined error conditions.
panicが起きるとその行から通常のプログラム実行が止まり、
defer
に登録された関数があれば登録されたのと逆順で実行していきます。
ある関数F
のなかでdefer
された関数がrecover
を呼ぶと、F
を呼び出すG
から通常の関数実行の順序に戻ります。
func() {
defer func() {
if rec := recover(); rec != nil {
fmt.Printf("panicked = %#v\n", rec) // panicked = runtime.boundsError{x:6, y:4, signed:true, code:0x0}
}
}()
var a [4]int
var b []int = a[:]
_ = b[6] // index out of range!
fmt.Printf("this line can not be reached\n")
}()
fmt.Printf("back to normal execution order.\n")
recover
されずにpanic
がgoroutineを終了させるとプロセス全体がエラー終了します。
// つまりこうすれば確実にプロセスを殺せます
go func() { panic("die!") }()
try-catch-finally
で例えると、defer
がfinally
、recover
がcatch
だといえます。
と、こう書くとpanic
は基本的にrecover
されない究極的なエラー終了手段かのように聞こえるかもしれません、実際上は*http.Server
がrecover
してしまうので逆に基本的にrecover
されるものと思ったほうが良いです。もちろん100%挙動をコントロールできるシチュエーションでは別ですが、例えば*http.Serverを使わずにhttp serverを実装することは少ないと思いますし、Go
を書いていてhttp serverを実装しないことも結構珍しいと思います。
https://pkg.go.dev/net/http@go1.22.4#Handler
If ServeHTTP panics, the server (the caller of ServeHTTP) assumes that the effect of the panic was isolated to the active request. It recovers the panic, logs a stack trace to the server error log...
panic
時にプロセスが強制終了されてほしいとき、*http.Serverを使うプログラムの場合、ユーザーが自ら特別な措置を実装する必要があります。
つまりこころもちとしては
-
panic
を意図的にする場合は- 意図的にrecoverし、意図しないpanicはre-panicする
- もしくは、プロセスは異常終了すべき
- 一方で
panic
はコントロールしていないエリアで勝手にrecover
されるのは当然起こる
と思っているといいという感じです。
前述通り、そのgoroutine
でpanic
した時は通常の関数実行順序をやめ、defer
に登録されている関数を登録の逆順で実行してきます。
つまりリソース解放処理は必ずdefer
でしなければ、コントロールしていないコードによってrecover
され、静かに不正状態に陥る可能性があります。
- リソース解放処理は必ず
defer
で行おう-
sync.Mutex
のUnlock
-
*os.File
のClose
- 何かのカウントをincrementしたときのdecrement
-
sync.WaitGroup
のDone
-
-
Go
はすべてのgoroutine
が眠りにつくとdeadlockであるとして、プロセスを終了してくれるガードが入っていますが、*http.Serverがコネクション待ちに入ってる状態はdeadlockに見えないはずなので、エラーしないのに動かない状態になるはずです。
もしくはrecoverされたくないならgo panic("panic cause")
とわざとするとよいかもしれません。
ただし、panic
がgoroutine
を終了させたときの強制終了処理は他のgoroutine
のdefer
を呼び出しませんので、リソース解放処理をあてにしたプログラムが不正な中途状態を書き出すような場合は、それがプロセス終了後に観測されてしまいます。
できれば、あらゆるgoroutine
で起きたpanic
はrecover
で拾ってpanic
しなおして、拾って・・・というのを繰り返してmain goroutine
まで伝搬させたほうがいいでしょう。
main goroutine
でpanic
時のエラーログなどを書きだしてすべてのgoroutine
を終了させるなどすると穏当にプロセスを終了させられます。
どのようにpanic
を伝搬させるかはGo
が勝手に判断できるタイプの仕事ではないのでユーザーが意図的に行う必要があります。
とはいえ、電源断(power outage / power failure, 停電など)の恐れがあるようなシステムではリソース解放が漏れなくても電断でおかしな状態になりうるので、
どちらにせよ回帰する方法がプロセス起動時に呼ばれなければなりません。
panic-recoverの実装例
panic
を使うとdefer
された関数以外を無視して一気に脱出ができるのでもちろんtry-catch的に使うこともできます。
Effective Go
にtry-catch
的panic-recover
の使い方が述べられています。
ポイントは以下になります
- 特定の値/型で
panic
することで処理をabortする - パッケージをまたいでpanicが伝搬しないように公開関数/メソッドでは必ずrecoverする
- 意図した型以外でのpanicははre-panicする
前述の*http.Serverはhttp.ErrAbortHandler
をsentinel valueとして、handlerをabortするためのpanic
ができるようになっています。
iterator-collectorを抜ける
errorによる中断をサポートしないiteratorのcollectorを中断するのにpanic-recoverは便利です
Go 1.23からfor-range-func
などと呼ばれる、for-range
が特定のシグネチャを満たす関数を処理する仕様が追加されました。その関数のことをカジュアルにiterator
と呼びます。
これによってerror発生時の処理終了をサポートしないが長くかかる関数というのが現れやすくなったと筆者は予測しており、panic-recoverも使いどころが増えたのではないかと思います。
例えば、下記のreduce
があるとします
func reduce[V, Accum any](
reducer func(accum Accum, next V) Accum,
initial Accum,
seq iter.Seq[V],
) Accum {
accum := initial
for v := range seq {
accum = reducer(accum, v)
}
return accum
}
これはシグネチャ上、reducer
の失敗時に早期に中断できるようになっていません。
これをpanic-recoverによって中断可能にします。
func reduceUsingFailableWork[V, Accum any](
work func(accum Accum, next V) (Accum, error),
initial Accum,
seq iter.Seq[V],
) (a Accum, err error) {
type wrapErr struct {
err error
}
defer func() {
rec := recover()
if rec == nil {
return
}
w, ok := rec.(wrapErr)
if !ok {
panic(rec)
}
err = w.err
}()
a = reduce[V, Accum](
func(accum Accum, next V) Accum {
var err error
accum, err = work(accum, next)
if err != nil {
panic(wrapErr{err})
}
return accum
},
initial,
seq,
)
return a, nil
}
ポイントは
- 独自の型を定義し、error時にそれでpanicを呼び出す
-
defer
でrecover
を実行 - そもそも
panic
してないときにもdeferは実行されるためrecover
の返り値がnilならそのままreturn- どこかのバージョンから
panic(nil)
するとrecover
できなくなっているのでrec
がnilならpanic
してないです。
- どこかのバージョンから
- 前述の既知の型でないときre-panic
- 関数の返り値を名前付きにしておき、wrapされた
error
の値をそれに代入します。
実行してみると以下のような感じ。きちんと中断できています。
sampleErr := errors.New("sample")
fmt.Println(
reduceUsingFailableWork(
func(accum int, next int) (int, error) {
fmt.Printf("next = %d\n", next)
if accum > 50 {
return accum, sampleErr
}
return accum + next, nil
},
10,
slices.Values([]int{5, 7, 1, 2}),
),
)
/*
next = 5
next = 7
next = 1
next = 2
25 <nil>
*/
fmt.Println(
reduceUsingFailableWork(
func(accum int, next int) (int, error) {
fmt.Printf("next = %d\n", next)
if accum > 50 {
return accum, sampleErr
}
return accum + next, nil
},
40,
slices.Values([]int{5, 7, 1, 2}),
),
)
/*
next = 5
next = 7
next = 1
0 sample
*/
他にもやりようはいくらでもあるのでpanic-recoverでこれをクリするとは限らないですが、覚えておいて損はないはずですよ。
std
実際std内でも上記のようにpanic
がthrow
的に使われています。
encoding/json
内では以下のようにpanicをthrow
的に使っています。
少なくとも https://codereview.appspot.com/953041 のころからそうなので2010-04-21からずっとこんな感じでした
any
同士の比較でpanicするのをrecoverしている部分もあります。
stacktrace
Go
のstd libraryはstacktraceの付いたerrorを返してくることがないため、慣習的にerrorにはstacktraceがついていないのが普通です。
stacktraceはついていない
stdのerrorが全般的にstacktrace情報を含んでくれればと思うのですが、
- 現状のerrorがstacktraceを含まず、
-
strings.Contains(err.Error(), "...")
でerrorの判別をするコードが存在し、 - 同様に
fmt.Sprintf("...", err)
で文字列をくみ上げるコードも存在する
ので、暗黙的に既存コードから帰ってくるerrorにstacktraceを持たせる変更は破壊的変更となります。
io.EOFのようにsentinel valueとしてふるまうerrorが普通にあり得てしまうため、そういったものに勝手にstacktraceをつけてしまうとパフォーマンスに影響することも考えられます。
そのため、既存の挙動を全く破壊せずにstacktraceを取り出す方法が実装されない限り、入れる意味がないのでstdのコードがstacktraceを含むerrorを返してくることはないでしょう。
自分向けのざっくり作ったツールほどerrorのメッセージを丁寧にラップしないので、そういうときこそstacktraceが欲しいですね。
errorメッセージをさぼって、後になってどこで起きたのかわからないerrorが吐かれて慌ててerrorのラップを整備しだすんですよね(n敗)。
いつかstdでもstacktraceがつくかもしれませんがいつになるのかわからないので必要であればライブラリを利用したほうがよろしいかと思います。
ライブラリで付ける
以下のライブラリが有名です
どちらも筆者は使ったことがないので何ともです。
自分でつける
サクっと実装するとこんなものっていう感じのexampleをつけておきます。
const maxDepth = 100
type withStack struct {
err error
pc []uintptr
}
func (e *withStack) Error() string {
return e.err.Error()
}
func (e *withStack) Unwrap() error {
return e.err
}
func wrapStack(err error, override bool) error {
if !override {
var ws *withStack
if errors.As(err, &ws) {
// already wrapped
return err
}
}
var pc [maxDepth]uintptr
// skip runtime.Callers, WithStack|WithStackOverride, wrapStack
n := runtime.Callers(3, pc[:])
return &withStack{
err: err,
pc: pc[:n],
}
}
func WithStack(err error) error {
return wrapStack(err, false)
}
func WithStackOverride(err error) error {
return wrapStack(err, true)
}
func Frames(err error) iter.Seq[runtime.Frame] {
return func(yield func(runtime.Frame) bool) {
var ws *withStack
if !errors.As(err, &ws) {
return
}
frames := runtime.CallersFrames(ws.pc)
for {
f, ok := frames.Next()
if !ok {
return
}
if !yield(f) {
return
}
}
}
}
func PrintStack(w io.Writer, err error) error {
for f := range Frames(err) {
_, err := fmt.Fprintf(w, "%s(%s:%d)\n", f.Function, f.File, f.Line)
if err != nil {
return err
}
}
return nil
}
WithStack
を適当に深いところで呼び出して、PrintStack
を実行すると以下のようになります。
func example(err error) error {
return deep(err)
}
func deep(err error) error {
return calling(err)
}
func calling(err error) error {
return frames(err)
}
func frames(err error) error {
return WithStack(err)
}
func main() {
sample := errors.New("sample")
wrapped := example(sample)
fmt.Printf("%v\n", wrapped) // sample
err := PrintStack(os.Stdout, wrapped)
if err != nil {
panic(err)
}
/*
main.frames(github.com/ngicks/go-example-basics-revisited/error-handling/with-stack/main.go:96)
main.calling(github.com/ngicks/go-example-basics-revisited/error-handling/with-stack/main.go:92)
main.deep(github.com/ngicks/go-example-basics-revisited/error-handling/with-stack/main.go:88)
main.example(github.com/ngicks/go-example-basics-revisited/error-handling/with-stack/main.go:84)
main.main(github.com/ngicks/go-example-basics-revisited/error-handling/with-stack/main.go:102)
runtime.main(runtime/proc.go:272)
*/
}
runtime.Frame.File
がpackage pathになっていますがこれはgo run -trimpath ./with-stack/
で実行しているためです。-trimpath
オプションがなければソースコードの表示はローカルストレージ上のフルパスになりますので注意してください。
panicのstacktraceをログに残す
panic
のstacktraceをprintしてログに残したいことはあると思います。
というのが、nil pointer dereferenceがふいに起きたとき、どこでどう起きたのかログにだせないと見当がつかなくて困ります(1敗)。
-
- panicを拾わず(=
recover
せず)プロセスを落とすことでGoにstacktraceを吐かせる
- panicを拾わず(=
-
-
recover
することで、panic時のstacktraceを任意の出力先に出す
-
-
- 別
goroutine
で起きたpanicのstacktraceを順次main goroutineに伝搬する
- 別
についてそれぞれ述べます。
recover
せず)プロセスを落とすことでGoにstacktraceを吐かせる
1. panicを拾わず(=単にpanic
のstacktraceを表示するだけなら以下のようにします。
package main
func main() {
aaa()
}
func aaa() {
bbb()
}
func bbb() {
ccc()
}
func ccc() {
panic("hey")
}
/*
panic: hey
goroutine 1 [running]:
main.ccc(...)
/tmp/sandbox1615949582/prog.go:16
main.bbb(...)
/tmp/sandbox1615949582/prog.go:12
main.aaa(...)
/tmp/sandbox1615949582/prog.go:8
main.main()
/tmp/sandbox1615949582/prog.go:4 +0x25
*/
panic
がrecover
されなかった場合stderrに上記のフォーマットでstacktraceが表示されてプロセスが終了します。
recover
することで、panic時のstacktraceを任意の出力先に出す
2. 任意のフォーマットや出力先を選択する、例えばslog.Loggerに出力したいときなどはrecover
を呼んだ関数の中で前述のruntime.Callers/runtime.CallersFramesを用いればよいです。
この時点ではSP
(Stack Pointer)が巻き戻されていないので、stackを表示するとpanicを呼び出したところからのstackが表示されます。詳しいことはよくわかっていないのでrecover
を呼び出したコードをコンパイルしてobjdump -d
したり、実装などを参照してほしいです。panic
の実装はruntime.gopanic
の呼び出しに書き換えれるのが見て分かるのでstacktraceの先端がgopanic
である理由がよくわかると思います。
func main() {
defer func() {
rec := recover()
if rec == nil {
return
}
pc := make([]uintptr, 100)
// skip runtime.Callers, this closure, runtime.gopanic
n := runtime.Callers(3, pc)
pc = pc[:n]
fmt.Printf("panicked: %v\n", rec)
frames := runtime.CallersFrames(pc)
for {
f, ok := frames.Next()
if !ok {
break
}
fmt.Printf(" %s(%s:%d)\n", f.Function, f.File, f.Line)
}
}()
// work...
}
goroutine
で起きたpanicのstacktraceを順次main goroutineに伝搬する
3. 別あるgoroutine
で起きたpanic
をrecover
して他のgoroutine
に伝搬させるのはよくあります。(e.g. singleflight.(*Group).Do・・・特にdoc commentでは触れられていないがpanicが伝搬される)
panic
がrecover
されずにgo
キーワードをつけて呼び出された関数を終了させるとプロセス全体が強制終了します。このとき他のgoroutine
のdefer
が実行されません。
ライブラリのつくりのよっては新しくgoroutine
を作って関数を動作させるかもしれませんし、複数のgoroutine
から同じ関数の呼び出し結果を用いるかもしれません(前述のsingleflight
がまさにこれです)。これらの処理をまたぐときにpanic
が問答無用の強制終了にしかならないとなると、例えば前述したようなpanic-recover
で一気に脱出をする手法が不可能になったり、main goroutine
でpanic
のログをとっている場合などと相性が非常に悪くなってしまいます。
呼び出し側にpanic
の取り扱いをゆだねるには、panic
のrecover
と伝搬は必須となります。
以下ではあるgoroutine
で起きたpanic
を順次伝搬し、main goroutine
でログに書き出すexampleを示します。
exampleでは省略されていますが、main goroutine
でpanic
が起きたらすべての処理がキャンセルされる(i.e. context.Context
をcancelする、など)ようにすれば、穏当な終了処理を適切なerrorログとともに行うことができます。
前述通り、panic
時のstacktraceはrecover
した関数内で取得できます。
こうして各goroutine
で取得したstacktraceをpanic()
の引数に渡す変数に収めれば、すべてのstacktraceをmain goroutine
まで伝搬することができます。
このスニペットの中で使っているserr
パッケージはstacktrace/自分でつけるで載せているスニペットもうちょっと凝ってライブラリとして実装したものです。
package main
import (
"context"
"fmt"
"sync"
"github.com/ngicks/go-common/serr"
)
//go:noinline
func example(ctx context.Context) {
deep(ctx)
}
func deep(ctx context.Context) {
calling(ctx)
}
func calling(ctx context.Context) {
frames(ctx)
}
func frames(ctx context.Context) {
var (
panicVal any
panicOnce sync.Once
wg sync.WaitGroup
)
ctx, cancel := context.WithCancelCause(ctx)
defer cancel(nil)
wg.Add(1)
go func() {
defer wg.Done()
defer func() {
rec := recover()
if rec == nil {
return
}
panicOnce.Do(func() {
// In case there's many goroutines running and
// you are going to capture only first panic value recovered.
panicVal = serr.WithStack(fmt.Errorf("panicked: %v", rec))
})
cancel(panicVal.(error))
}()
example2(ctx)
}()
wg.Wait()
if panicVal != nil {
panic(panicVal)
}
}
//go:noinline
func example2(ctx context.Context) {
deep2(ctx)
}
func deep2(ctx context.Context) {
calling2(ctx)
}
func calling2(ctx context.Context) {
frames2(ctx)
}
func frames2(_ context.Context) {
s := make([]int, 2)
_ = s[4]
}
func main() {
defer func() {
rec := recover()
if rec == nil {
return
}
// skip runtime.Callers, inner func, WithStackOpt.
err := serr.WithStackOpt(rec.(error), &serr.WrapStackOpt{Override: true, Skip: 3})
fmt.Printf("panicked: %v\n", rec)
var i int
for seq := range serr.DeepFrames(err) {
if i > 0 {
fmt.Printf("caused by\n")
}
i++
for f := range seq {
fmt.Printf(" %s(%s:%d)\n", f.Function, f.File, f.Line)
}
}
}()
ctx, cancel := context.WithCancel(context.Background())
defer cancel() // canaled after occurrence of panic
example(ctx)
//nolint
// panicked: panicked: runtime error: index out of range [4] with length 2
// main.main.func1(github.com/ngicks/go-example-basics-revisited/error-handling/log-stacktrace/main.go:80)
// runtime.gopanic(runtime/panic.go:785)
// main.frames(github.com/ngicks/go-example-basics-revisited/error-handling/log-stacktrace/main.go:51)
// main.calling(github.com/ngicks/go-example-basics-revisited/error-handling/log-stacktrace/main.go:21)
// main.deep(github.com/ngicks/go-example-basics-revisited/error-handling/log-stacktrace/main.go:17)
// main.example(github.com/ngicks/go-example-basics-revisited/error-handling/log-stacktrace/main.go:13)
// main.main(github.com/ngicks/go-example-basics-revisited/error-handling/log-stacktrace/main.go:93)
// runtime.main(runtime/proc.go:272)
// caused by
// main.frames.func1.1.1(github.com/ngicks/go-example-basics-revisited/error-handling/log-stacktrace/main.go:43)
// sync.(*Once).doSlow(sync/once.go:76)
// sync.(*Once).Do(sync/once.go:67)
// main.frames.func1.1(github.com/ngicks/go-example-basics-revisited/error-handling/log-stacktrace/main.go:40)
// runtime.gopanic(runtime/panic.go:785)
// runtime.goPanicIndex(runtime/panic.go:115)
// main.frames2(github.com/ngicks/go-example-basics-revisited/error-handling/log-stacktrace/main.go:70)
// main.calling2(github.com/ngicks/go-example-basics-revisited/error-handling/log-stacktrace/main.go:65)
// main.deep2(github.com/ngicks/go-example-basics-revisited/error-handling/log-stacktrace/main.go:61)
// main.example2(github.com/ngicks/go-example-basics-revisited/error-handling/log-stacktrace/main.go:57)
// main.frames.func1(github.com/ngicks/go-example-basics-revisited/error-handling/log-stacktrace/main.go:47)
}
runtime.gopanic
やsync.(*Once).doSlow
など表示の必要がなさそうな冗長な情報が載っているので実際は必要に合わせてSkip
の数値は増加させたほうがよいでしょう。
慣れていない人には混乱する情報になるかもしれません。
serr.DeepFramesでiter.Seq[iter.Seq[runtime.Frame]]
を得られます。
今回の実装では単にstdoutに書き出していますが、これを適当にmapしてslog.Value
に変換できればslog.Logger
でログに残せます。
小技集
[]errorをラップして一つにする(簡易)
errors.Joinで複数errorを1つにまとめられます。返ってくるerror
のError
methodはそれぞれのerrorのError
を呼び出して結果を"\n"
で結合します。
それが気に入らない場合はstrings.Repeatで%w
⁺sepを繰り返し、最後の余計なsepをstrings.TrimSuffix切り落とします。最後にfmt.Errorfでerrorをラップします。こうすることで任意のprefix, sepを持ったフォーマットでerrorでprint可能です。
var (
err1 = errors.New("1")
err2 = errors.New("2")
err3 = errors.New("3")
)
fmt.Printf("errors.Join: %v\n", errors.Join(err1, err2, err3))
/*
errors.Join: 1
2
3
*/
errs := []any{err1, err2, err3}
const sep = ", "
format := strings.TrimSuffix(strings.Repeat("%w"+sep, len(errs)), sep)
wrapped := fmt.Errorf("foobar error: "+format, errs...)
fmt.Printf("err = %v\n", wrapped) // err = foobar error: 1, 2, 3
[]errorをラップして一つにする(型)
基本的には上記のerrors.Join/fmt.Errorfを使うパターンで事足りるんですがラップされた情報の詳細度がたりなくて困ることがあります。
%w
でエラーをラップした場合はUnwrap() error
もしくはUnwrap() []error
を実装したerror
が返されます。
ただしこのあたりを見るとわかる通り、返されたerrorのError
methodが返すstringは%w
verbを%v
に置き換えてfmt.Sprintf
で出力したものをキャッシュしておき、それを返す実装となっています。
つまり、返ってきたerrorを%#v
のようなより詳細な情報を要求するverbでprintしたとしてもこのキャッシュされたstring以上の情報は表示できません。
そこで以下のように型を定義します。
type gathered struct{ errs []error }
func (e *gathered) Unwrap() []error {
return e.errs
}
func (e *gathered) format(w io.Writer, fmtStr string) {
for i, err := range e.errs {
if i > 0 {
_, _ = w.Write([]byte(`, `))
}
_, _ = fmt.Fprintf(w, fmtStr, err)
}
}
func (e *gathered) Error() string {
var s strings.Builder
e.format(&s, "%s")
return s.String()
}
func (e *gathered) Format(state fmt.State, verb rune) {
e.format(state, fmt.FormatString(state, verb))
}
Advanced: interface { Format(fmt.State, rune) }を実装するで述べた通り、interface { Format(fmt.State, rune) }
を実装するとfmt.*printf
で各verbが何を表示するかをコントロールできます。
この実装では受け取ったflagとverbでラップされた各errorをprintすることで、flagとverbによるprintされる情報の詳細度のコントロールを受け付けられるようになります。
このerror typeはgithub.com/ngicks/go-common/serrとしてパッケージ化してあります。
実装サンプルとしてまとめてあるだけで筆者自身は使っていません。
おわりに
現状筆者が知らなくて困ったerror周りの話は全部入れれたと思いますがまたなんかあったら追記します。
Discussion