Go の errors.Join の挙動を調べた

Go 1.20 で追加された errors.Join の挙動を調べてみたので、備忘録としてポストしておく。

errors.Join の概要

Go doc と実装をベースに概要を簡単にまとめる。

errors.Join は error を返す。返された error は内部的に引数に渡された可変数の error を保持している。

Error メソッドを呼び出した際に返される string は、内部に保持している可変数の error のそれぞれの Error メソッドの返り値を \n で連結したものとなる。

返される error インタフェースの実体は Error メソッド、Unwrap メソッドを実装した joinError 構造体となっている。

この joinError 構造体はプライベートな構造体であるため、呼び出し元でアサーションはできない。

挙動確認

気になった挙動をそれぞれ確認してみる。

引数を渡さない

引数を渡さない場合は nil が返る。

package main

import (
    "errors"
    "fmt"
)

func main() {
    fmt.Println(errors.Join())
}
<nil>

https://go.dev/play/p/OGSXyKLKQIk

nil のみを渡す

nil のみを渡した場合は、nil が返る。

package main

import (
    "errors"
    "fmt"
)

func main() {
    fmt.Println(errors.Join(nil))
    fmt.Println(errors.Join(nil, nil))
}
<nil>
<nil>

https://go.dev/play/p/CverKIAv9xw

1つ以上の error と、1つ以上の nil を渡す

nil ではない error が返る。Error メソッドの出力は、保持されている error の Error メソッドの出力を改行区切りで出力したものとなっている。引数で渡した nil は無視されている。

package main

import (
    "errors"
    "fmt"
)

func main() {
    err1 := errors.New("error 1")
    err2 := errors.New("error 2")
    errs := errors.Join(err1, err2, nil, nil)
    fmt.Println(errs == nil)
    fmt.Println(errs)
}
false
error 1
error 2

https://go.dev/play/p/jkkDgYtEmAL

1つ以上の error を渡す

nil ではない error が返る。Error メソッドの出力は、保持されている error の Error メソッドの出力を改行区切りで出力したものとなっている。

package main

import (
    "errors"
    "fmt"
)

func main() {
    err1 := errors.New("error 1")
    err2 := errors.New("error 2")
    fmt.Println(errors.Join(err1, err2))
}
false
error 1
error 2

https://go.dev/play/p/2WUmfcvbCAW

errors.Is で比較する

返された error と、引数で渡した error との比較は true となる。引数で渡したいかなる error でも結果は等しく true である。

package main

import (
    "errors"
    "fmt"
)

func main() {
    err1 := errors.New("error 1")
    err2 := errors.New("error 2")
    errs := errors.Join(err1, err2)
    fmt.Println(errors.Is(errs, err1))
    fmt.Println(errors.Is(errs, err2))
}

https://go.dev/play/p/lEzt8wovFGH

errors.As で変換する

返された error は、Join の引数で渡した error に、As の引数で渡した error とマッチする error があった場合に変換を行い true を返す。

package main

import (
    "errors"
    "fmt"
)

type error1 struct {
    string
}

func (e error1) Error() string {
    return e.string
}

type error2 struct {
    string
}

func (e error2) Error() string {
    return e.string
}

func main() {
    err1 := error1{"error 1"}
    err2 := error2{"error 2"}
    errs := errors.Join(err1, err2)

    var err1forErrorsAs error1
    var err2forErrorsAs error2
    fmt.Println(errors.As(errs, &err1forErrorsAs))
    fmt.Println(errors.As(errs, &err2forErrorsAs))
    fmt.Println(err1forErrorsAs.Error())
    fmt.Println(err2forErrorsAs.Error())
}
true
true
error 1
error 2

https://go.dev/play/p/_3hljYeNHiH

マッチする error が複数ある場合は、Join で先に渡した引数の error に変換される。

こちらは、挙動確認に加えてコードも確認した。Join 実行時に引数で渡された順番で joinError の []error 型のフィールドに詰め替えており、Unwrap メソッドでは、フィールドをそのままかえしている。As の方では、Unwrap の返り値を for-range で回して要素ごとに As の第1引数に渡している。このような実装により、先に渡した引数の error に変換される挙動が実現している。

package main

import (
    "errors"
    "fmt"
)

type error1 struct {
    string
}

func (e error1) Error() string {
    return e.string
}

func main() {
    err1 := error1{"error 1"}
    err2 := error1{"error 2"}
    errs := errors.Join(err1, err2)

    var err1forErrorsAs error1
    var err2forErrorsAs error1
    fmt.Println(errors.As(errs, &err1forErrorsAs))
    fmt.Println(errors.As(errs, &err2forErrorsAs))
    fmt.Println(err1forErrorsAs.Error())
    fmt.Println(err2forErrorsAs.Error())
}
true
true
error 1
error 1

https://go.dev/play/p/C0vDnUD16Xg

まとめ

errors.Join が実装されたおかげで、複数の error をまとめるために標準以外のライブラリを使用する必要がなくなったことは嬉しい。

返り値が error インターフェースで抽象化されているので、これまで扱ってきたような error と同じように扱えて、使われ方を気にする必要がなくなったのも自分にはポジティブだ。

また、errors.Join の実装自体がシンプルで処理を追いやすかったので内部動作の把握も捗った。

参考