ちりもつもればミルキーウェイ

好奇心に可処分時間が奪われる

Go1.20からerrの構造がtreeになるので複数マッチ可能なハンドリングutilを作った

はじめに

みなさん Go1.20リリースノート(現在進行中) は読みましたでしょうか?

crypto関係のパッケージがmath/big.Intと距離を置きだしたりいろいろ面白そうな話題はあるんですが、なんといっても Wrapping multiple errors が一番気になりました!

関連proposalとかにもひとしきり目を通しつつ、あったら便利そうな処理を作ってみたというのが今回の趣旨です。

errがtree構造をとれるようになる

release noteでは https://tip.golang.org/doc/go1.20#errors あたりで言及されてます。

いままでのerrは、wrapしたときは Unwrap() error を実装することでもとのerrを取り出せる作りでした。

go1.20以降では fmt.Errorf() が複数の %w を受け入れたり、標準errorsにも errors.Join() が追加され複数errのwrapができるようになりました。該当のerrたちは新しく Unwrap() []error を実装していて、それを呼び出せば複数のwrapされたerrが取り出せます。

なお errors.Iserrors.As でもそれらの考慮はされていて、実装の中で Unwrap() []error だったときのケースが追加されています。以下はAsの例です。

https://cs.opensource.google/go/go/+/master:src/errors/wrap.go;l=122

...
case interface{ Unwrap() []error }:
    for _, err := range x.Unwrap() {
        if As(err, target) {
            return true
        }
    }
    return false
...

(余談ですがこのへんの実装をみるに Unwrap() error を実装していたらそっちの探索が優先されそうなので、複数errをwrapするerrを自作するときは Unwrap() []error だけ実装しないとアカンっぽいですね。形的にそうじゃないと複数err返せないしそれはそうですが。)

treeになるなら探索可能なAPIあったほうがよくない?

もともとのproposal をみると、いままでのチェーン的なerrと新しいtree errを透過的に取り出せる Split() []error も提案されていたようですが、そちらは受け入れられていません。 いろいろ議論してましたが最終的にproposalのスコープ外とした模様。議論の雰囲気としては Walk() error 的なAPIを欲しがってる人はちょいちょいいたけど、もうちょい情報が貯まらないとどういうデザインがいいかの判断もむずいよ!みたいな感じでした。あと似たような https://pkg.go.dev/go.uber.org/multierr について利用のされ方を調査してAPI提供するか議論とかもしてた。

とりあえずerror treeを作るぞ! Unwap() []error で取り出せるぞ!ということだけ受け入れられれば、あとはいろいろ試してサードパーティ含めていろいろ実験しつつデザイン練れるのでスコープをそこまでに限定したっぽい。

最終的にスコープ調整して整理したproposal本文はこれ
https://github.com/golang/go/issues/53435#issuecomment-1191752789

ちょっと寄り道: proposalのおもしろ議論

treeになるといまの Is とか As の意味が少し変わることへの言及

いままでのerrはチェーンだったのでどこかで一致するerrがあればチェーン全体をそのerrであると言えたので簡単にハンドリングできましたが、treeになるとある枝以下はマッチするけど別の枝はマッチしない!みたいなことがありえます。

例えば以下のような特定の軽微なエラーをwarn log出すだけに留めるみたいなことをしたいときを考えます。

switch err := ExecuteSomeFunc(); {
case errors.Is(err, TargetErr):
    // ignore or logging...
case err != nil:
    return err
}

今までなら単に errors.Is(err, TargetErr) なりでハンドリングして無視できたけど、今後は同treeで別の枝に無視してはアカンerrが含まれていたときにすり抜けるかもしれんで〜という話です。

これはけっこうせやな感ありますが、既存のmultierr系のライブラリでそのような考慮はあまりされていなかったのでニーズも少なそうだし一部一致で良いだろうという結論になったぽい。

targetがtreeでtree内に部分的に一致があったときIsで比較するときどうすんの

ややこしい話だけどデザイン固める上で言及したほうがいいのはそう。いまだとIsでtargetへのUnwrapはしないのでその挙動は守るよ〜という回答

treeになるといろいろ考慮が生まれるので、エコシステム含めいろいろ実験して新しい文化を考えていきたいっすね

go1.20時点のIs/Asのデザインまとめ

proposalの議論を追いかけたりrelease noteよんだり実装を追いかけた感じだと

  • treeを深さ優先探索する
  • マッチしたものがあればそこで探索を打ち切り結果を返す
  • tree上のすべての枝がIs/Asにマッチすることを保証しない

というデザインなようです。

そもそもerr treeという構造に対してどういうハンドリングしたいかニーズを考えてみると

  • tree上に該当エラーが1つ以上存在するか確認したい(go1.20のstd Isで提供されているもの)
  • tree上に該当エラーが1つ以上存在するか確認して取り出したい(go1.20のstd Asで提供されているもの)
  • tree上のすべての枝に該当エラーが存在するか確認したい(未実装)
  • tree上の該当エラーを枝などの関係性を問わずすべて抽出したい(未実装)

あたりはほしい気がします。(他にもいろいろなニーズがありそうだけど取り急ぎ僕がほしいのはこれくらい)

さっきエコシステム含めいろいろ実験して知見ためないとな!と言った手前いっちょ書いたるか〜となった次第です!

作るものの整理

さっき挙げたニーズをどういうAPIで解決するかざっと考えてみます。 未実装な

  • tree上のすべての枝に該当エラーが存在するか確認したい(未実装)
  • tree上の該当エラーを枝などの関係性を問わずすべて抽出したい(未実装)

について考えていきます。

tree上のすべての枝に該当エラーが存在するか確認したい

1つ目は分岐した枝についてすべて探索して一致するものがあればtrueを返すようなイメージです。ある枝についてはマッチした時点で探索終了とします。なのですべてのnodeを探索することは保証しません。

作りたいもののイメージとしては、こういうツリーがあったとき

err
├── target
├── wrapErr
│   └── multiErr
│       ├── target
│       └── target
└── multiErr
    ├── wrapErr
    │   └── target
    └── differentErr

探索可能な枝に differentErr が含まれているのでfalseを返したい!みたいなやつです。

名前はstd Isとの対比をこめて ExactlyIs() 的な感じでやってみます。

tree上の該当エラーを枝などの関係性を問わずすべて抽出したい

2つ目はstd Asではerr treeから一個しかマッチするエラーを取り出せないことへの課題感から欲しくなりました。複数取り出したりする時点で意味合い的に As ぽくないので Scan[T any](err error, target T) []T 的なデザインを考えています。genericsを使えば呼び出し側で渡した具体的なtargetの型情報をもったsliceを返せそうなのでその方向で考えます。

これは以下のツリーがあったとき

err
├── targetA(assignable to `target`)
├── wrapErr
│   └── multiErr
│       ├── targetB(assignable to `target`)
│       └── targetC(assignable to `target`)
└── multiErr
    ├── wrapErr
    │   └── targetD(assignable to `target`)
    └── targetE(assignable to `target`)

[]target{targetA, targetB, targetC, targetD, targetE} のような結果を返したいです。

作ったもの

これです

github.com

さっきいっていた ExactlyIs()Scan() を提供します。ニーズが噛み合えばそこそこ便利に使えると思われます。

実装

ExactlyIs() はだいたいstd Isと同じで、違う点としては枝が分かれたときに全枝がtrueを返さない限りfalseに倒しているところが異なります。

https://github.com/convto/errortree/blob/main/errortree.go#L54-L63

case interface{ Unwrap() []error }:
    errs := x.Unwrap()
    if len(errs) == 0 {
        return false
    }
    for _, err := range errs {
        if !ExactlyIs(err, target) {
            return false
        }
    }
    return true

これで全枝みて結果を返す挙動が実現できます。

Scan[T any](err error, target T) []T のほうはデザインがちょっと悩ましくてまだ整理しきれてないとこもあります。tagretは受け取るのに、std Asとちがってassignはしないからstd Asとの対称性がちょっとないよなーとか、型パラメータ受け取るならうまくやって引数のtarget減らせるんじゃね?とか。絶賛整理中です。一旦現状は target で受け取った型へのAssignableを判定してマッチしたら結果に突っ込む挙動にしてます。

また、一度マッチしてもその枝からの派生で追加でマッチする要素があるなら全部取り出したかったので、ExactlyIsとはちがってマッチしてもその枝の探索を継続しています。

探索のコードは貼ると長いので気になる方は以下のリンクでみてみてください

https://github.com/convto/errortree/blob/main/errortree.go#L99

いろいろ実装してみて思った感想

errがtreeになるのは便利なこともある反面、潜在的なハンドリングの複雑さを抱えていそうだな〜とおもいました。

たとえば今回は挙げてないですが「errors.Asのように結果を取り出したい、かつ今回つくったExactlyIsのように全枝探索して同一視できるかbool値も返したい!」みたいな組み合わせの要求もありえます。

treeに対するnodeのマッチは文脈によって要求が異なるので、それらに対してエコシステム含めて何かしらの回答がいるかな〜という手応えです。

組み合わせが多くてstd packageでメンテするのはコストが高いと思いますが、proposalの議論でも言及されていたように各Unwrapを透過的に実行する Walk() 的な処理があるとけっこう嬉しいのかもしれません。

とはいえまだ判断には時期尚早ですし、引き続きerror関連のエコシステムやproposalは注視しようかなと思います!

[2023/2/14追記]

落ち着いて考えたら、探索要件によってはチェーンの部分と枝が分岐してる部分でロジックを変えたいことがあり得るケースがありそう。透過的な探索にするといっしょくたな扱いになるので、分岐のとき限定のロジックを書いたりできなさそう。なので標準パッケージ実装してるサポートとしてはいまのままの形がよいかも。

Go2のときにUnwrapとかの挙動含めてerror interface自体をちょっと整理できるかもしれないし、errorハンドリングまわりは考えるのが楽しいですね。