💪

Goのプラクティスまとめ: error handling

2025/01/30に公開

Goのプラクティスまとめ: error handling

筆者がGoを使い始めた時に分からなくて困ったこととか最初から知りたかったようなことを色々まとめる一連の記事です。

以前書いた記事のrevisited版です。話の粒度を細かくしてあとから記事を差し込みやすくします。

他の記事へのリンク集

(リンク集は別の記事で出そうかと思ったんですが、そういえば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の組み立て方などについて色々述べます。

前提知識

環境

# 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.Iserrors.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.Readern > 0, io.EOFを返してくることがある

慣習的にポインターを返す関数がそれの返り値の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なのかぐらいは最低でも知らないとハンドルできないですよね。

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がラップされていない状況においてのみ、

でもerrorの判別を行えます。

基本的にerrors.Iserrors.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.Errnointerface { Is(error) bool }というerrorsパッケージが特別な考慮を行うためのinterfaceを実装しているので、それをラップして返すosパッケージのerrorに対しての判別に使うことができます。

snippet

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.Errnoerrors.Isで比較を行います。これはerrno(3)の数値にerrorを実装したものですが、windows向けにも同名シンボルが定義してあるのでwindowsに対しても使うことができます。

windows向けに*invent*されたerrno

https://github.com/golang/go/blob/go1.23.4/src/syscall/zerrors_windows.go#L6-L148

雑に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で出力するのと等価です。

snippet

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することができます。

snippet

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.Copyio.Readerio.EOFを返したときにnilを返す考慮があります。

https://github.com/golang/go/blob/go1.23.4/src/io/io.go#L444-L456

== io.EOFGoのrepositoryを検索するとすごくたくさん出てきます。

https://github.com/search?q=repo%3Agolang%2Fgo+%3D%3D+io.EOF&type=code

このようなsentinel valueとして使われるerrorの値はラップしないようにしてください。
特に特定のinterface(e.g. io.Reader)を実装してそれを使う関数に渡す時、特定のerrorを返すのがinterfaceの規約として決まっているとき(e.g. io.EOF)、そのerrorはラップしないように気を付けてください。

error valueを定義する

パッケージとして判別可能なerrorを返す時はexportされた変数をerrors.Newfmt.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をご覧ください。

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とするerrorError 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は下記のようにしています。

https://github.com/golang/go/blob/go1.23.4/src/net/http/client.go#L716-L718

type assertionで特定の型として取り出し、そのうえで中身を変更します。

こうやって中身を変えるという観点からもerror typeのmethod receiverはpointerであるべきだといえます。
non-pointerとして取り出して変更する場合、変更した変数を元のerrに代入しなおす必要がありますが、interfaceに変換される際にbox化でallocationが起きるため避けたほうが良いです。

それはなぜなのかというと

  • error typeに変換するときのtyped nilの可能性
  • func() (..., error)なinterfaceを満たせない
  • 後方互換性のために、その関数が返す型を追加したり変えたりできなくなる

後者二つはまあそのままなので分かると思います。

問題はtyped-nilで、下記のようなことが起きます。

snippet

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がプリントされます。

playground

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.Iserrを順次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で検査できるようにします。

snippet

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)が機能するのはErrnoIsが実装されているからです。

(unix版)
https://github.com/golang/go/blob/master/src/syscall/syscall_unix.go#L120-L132

(windows版)
https://github.com/golang/go/blob/go1.22.3/src/syscall/syscall_windows.go#L168-L194

oserrorとは何でしょうか?

https://github.com/golang/go/blob/master/src/internal/oserror/errors.go#L5-L18

これらの値の参照をたどると以下で出てきます。

https://github.com/golang/go/blob/go1.22.3/src/io/fs/fs.go#L139-L154

わざわざ一旦関数を経由して値を定義しているのはoserrorという文字列をgo docに乗せたくないからなのかなあと思います。io/fsのvariable sectionを見るとわかる通り、constやvariableセクションはソースコードがそのまま乗ってしまうのです。

osパッケージで使うと書いているのはどういうことかというと

https://github.com/golang/go/blob/go1.22.3/src/os/error.go#L17-L24

という感じでプラットフォーム間/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を実装します。

snippet

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だけのようです。

https://github.com/golang/go/blob/go1.22.3/src/net/http/h2_bundle.go#L1233-L1237

Asの実装は以下です。

https://github.com/golang/go/blob/go1.22.3/src/net/http/h2_error.go#L13-L37

reflectを使って、targetと自身がお互いにstructで、各fieldの名前が同一で型がConvertibleかどうかを判定し、同じであるときに各fieldに値をSetしています。

すべてのfieldが代入可能かまず最初にチェックを行っているのは中途半端に代入を行って失敗を返さないようにするためでしょうね。読者の皆さんも似たような機能を実装する際には同じように中途半端な値を入れない考慮を行うとよりよいでしょう。

ConvertibleToを使っていることのポイントは、ほぼ同一構造で変換可能な型で構成されるstructに代入できるようにすることでしょう。

つまり、Asは以下のような別のstructに対してもtrueを返します。
(実際に代入可能であることはplaygroundで確認してください。)

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を渡すことになりますが、http2StreamErrorAs実装はそのケースを無視しているので**fakeHttp2Errに対しては常にfalseを返してしまいますね。

*T, **T両方に対応するためには以下のように変更します。

playground

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を表示するように変更してみます。

snippet

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.FormatStringstateverbから"%#v"のようなformat stringを再建できます。
%+v以外のときの挙動を一切変更しないために、それ以外の場合は再建したformat stringでFprintfします。
type plain errFormatとするとこでFormat methodのないが構造が同じ型を定義します。そうしないとFormat methodが再帰的に呼びされてstack overflowが起きます。
この例ではerrBaseがembedされていることでError methodは継承されますので都合よく動作します。基本的にはこういったdelegationを全く行わないでこのFormat methodのなかですべてのパターンをハンドルするか、でなければErrorString, GoStringなどは継承するようにしたほうが良いです。

全フォーマットを網羅してprintしてerrBaseerrFormatの結果を比較します。

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

panicrecoverを活用して一気に関数を抜けるという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で例えると、deferfinallyrecovercatchだといえます。

と、こう書くとpanicは基本的にrecoverされない究極的なエラー終了手段かのように聞こえるかもしれません、実際上は*http.Serverrecoverしてしまうので逆に基本的に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...

https://github.com/golang/go/blob/go1.23.4/src/net/http/server.go#L1937-L1950

panic時にプロセスが強制終了されてほしいとき、*http.Serverを使うプログラムの場合、ユーザーが自ら特別な措置を実装する必要があります。

つまりこころもちとしては

  • panicを意図的にする場合は
    • 意図的にrecoverし、意図しないpanicはre-panicする
    • もしくは、プロセスは異常終了すべき
  • 一方でpanicはコントロールしていないエリアで勝手にrecoverされるのは当然起こる

と思っているといいという感じです。

前述通り、そのgoroutinepanicした時は通常の関数実行順序をやめ、deferに登録されている関数を登録の逆順で実行してきます。
つまりリソース解放処理は必ずdeferでしなければ、コントロールしていないコードによってrecoverされ、静かに不正状態に陥る可能性があります。

  • リソース解放処理は必ずdeferで行おう
    • sync.MutexUnlock
    • *os.FileClose
    • 何かのカウントをincrementしたときのdecrement
      • sync.WaitGroupDone

Goはすべてのgoroutineが眠りにつくとdeadlockであるとして、プロセスを終了してくれるガードが入っていますが、*http.Serverがコネクション待ちに入ってる状態はdeadlockに見えないはずなので、エラーしないのに動かない状態になるはずです。

もしくはrecoverされたくないならgo panic("panic cause")とわざとするとよいかもしれません。
ただし、panicgoroutineを終了させたときの強制終了処理は他のgoroutinedeferを呼び出しませんので、リソース解放処理をあてにしたプログラムが不正な中途状態を書き出すような場合は、それがプロセス終了後に観測されてしまいます。

できれば、あらゆるgoroutineで起きたpanicrecoverで拾ってpanicしなおして、拾って・・・というのを繰り返してmain goroutineまで伝搬させたほうがいいでしょう。
main goroutinepanic時のエラーログなどを書きだしてすべてのgoroutineを終了させるなどすると穏当にプロセスを終了させられます。
どのようにpanicを伝搬させるかはGoが勝手に判断できるタイプの仕事ではないのでユーザーが意図的に行う必要があります。

とはいえ、電源断(power outage / power failure, 停電など)の恐れがあるようなシステムではリソース解放が漏れなくても電断でおかしな状態になりうるので、
どちらにせよ回帰する方法がプロセス起動時に呼ばれなければなりません。

panic-recoverの実装例

panicを使うとdeferされた関数以外を無視して一気に脱出ができるのでもちろんtry-catch的に使うこともできます。

https://go.dev/doc/effective_go#recover

Effective Gotry-catchpanic-recoverの使い方が述べられています。

ポイントは以下になります

  • 特定の値/型でpanicすることで処理をabortする
  • パッケージをまたいでpanicが伝搬しないように公開関数/メソッドでは必ずrecoverする
  • 意図した型以外でのpanicははre-panicする

前述の*http.Serverhttp.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があるとします

snippet

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を呼び出す
  • deferrecoverを実行
  • そもそも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内でも上記のようにpanicthrow的に使われています。

encoding/json内では以下のようにpanicをthrow的に使っています。
少なくとも https://codereview.appspot.com/953041 のころからそうなので2010-04-21からずっとこんな感じでした

https://github.com/golang/go/blob/go1.23.4/src/encoding/json/encode.go#L302-L305

https://github.com/golang/go/blob/go1.23.4/src/encoding/json/encode.go#L283-L300

any同士の比較でpanicするのをrecoverしている部分もあります。

https://github.com/golang/go/blob/go1.23.4/src/crypto/hmac/hmac.go#L141-L149

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がつくかもしれませんがいつになるのかわからないので必要であればライブラリを利用したほうがよろしいかと思います。

参考: Goのerrorがスタックトレースを含まない理由

ライブラリで付ける

以下のライブラリが有名です

どちらも筆者は使ったことがないので何ともです。

自分でつける

サクっと実装するとこんなものっていう感じのexampleをつけておきます。

log/slogパッケージのこの辺とかこの辺を参考に。

snippet

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敗)。

    1. panicを拾わず(=recoverせず)プロセスを落とすことでGoにstacktraceを吐かせる
    1. recoverすることで、panic時のstacktraceを任意の出力先に出す
    1. goroutineで起きたpanicのstacktraceを順次main goroutineに伝搬する

についてそれぞれ述べます。

1. panicを拾わず(=recoverせず)プロセスを落とすことでGoにstacktraceを吐かせる

単にpanicのstacktraceを表示するだけなら以下のようにします。

playground

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
*/

panicrecoverされなかった場合stderrに上記のフォーマットでstacktraceが表示されてプロセスが終了します。

2. recoverすることで、panic時のstacktraceを任意の出力先に出す

任意のフォーマットや出力先を選択する、例えば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...
}

3. 別goroutineで起きたpanicのstacktraceを順次main goroutineに伝搬する

あるgoroutineで起きたpanicrecoverして他のgoroutineに伝搬させるのはよくあります。(e.g. singleflight.(*Group).Do・・・特にdoc commentでは触れられていないがpanicが伝搬される)

panicrecoverされずにgoキーワードをつけて呼び出された関数を終了させるとプロセス全体が強制終了します。このとき他のgoroutinedeferが実行されません。
ライブラリのつくりのよっては新しくgoroutineを作って関数を動作させるかもしれませんし、複数のgoroutineから同じ関数の呼び出し結果を用いるかもしれません(前述のsingleflightがまさにこれです)。これらの処理をまたぐときにpanicが問答無用の強制終了にしかならないとなると、例えば前述したようなpanic-recoverで一気に脱出をする手法が不可能になったり、main goroutinepanicのログをとっている場合などと相性が非常に悪くなってしまいます。
呼び出し側にpanicの取り扱いをゆだねるには、panicrecoverと伝搬は必須となります。

以下ではあるgoroutineで起きたpanicを順次伝搬し、main goroutineでログに書き出すexampleを示します。
exampleでは省略されていますが、main goroutinepanicが起きたらすべての処理がキャンセルされる(i.e. context.Contextをcancelする、など)ようにすれば、穏当な終了処理を適切なerrorログとともに行うことができます。

前述通り、panic時のstacktraceはrecoverした関数内で取得できます。
こうして各goroutineで取得したstacktraceをpanic()の引数に渡す変数に収めれば、すべてのstacktraceをmain goroutineまで伝搬することができます。

このスニペットの中で使っているserrパッケージはstacktrace/自分でつけるで載せているスニペットもうちょっと凝ってライブラリとして実装したものです。

snippet

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.gopanicsync.(*Once).doSlowなど表示の必要がなさそうな冗長な情報が載っているので実際は必要に合わせてSkipの数値は増加させたほうがよいでしょう。
慣れていない人には混乱する情報になるかもしれません。

serr.DeepFramesiter.Seq[iter.Seq[runtime.Frame]]を得られます。
今回の実装では単にstdoutに書き出していますが、これを適当にmapしてslog.Valueに変換できればslog.Loggerでログに残せます。

小技集

[]errorをラップして一つにする(簡易)

errors.Joinで複数errorを1つにまとめられます。返ってくるerrorError methodはそれぞれのerrorのErrorを呼び出して結果を"\n"で結合します。

それが気に入らない場合はstrings.Repeat%w⁺sepを繰り返し、最後の余計なsepをstrings.TrimSuffix切り落とします。最後にfmt.Errorfでerrorをラップします。こうすることで任意のprefix, sepを持ったフォーマットでerrorでprint可能です。

snippet

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周りの話は全部入れれたと思いますがまたなんかあったら追記します。

GitHubで編集を提案

Discussion