😺

errors.Is について調べたらめちゃ解像度が上がった話

2024/08/28に公開

はじめに

StoreHero でバックエンドエンジニアをしている美野です。
普段は Go 言語を使った開発をしており、最近 errors パッケージの errors.Is を使う機会がありました。
その際に自分自身が勘違いしたまま理解していたことが分かったので改めて学び直したのですが、色々と学びがあったのでアウトプットも兼ねて記事を書こうと思います。
結構細かく見ているので、読んだ後に少しでも勉強になったなと思ってもらえると嬉しいです!

使用言語とパッケージ

言語:go v1.21.4
パッケージ:errors / fmt

最初にしていた勘違い

最初 errors.Is について二つの勘違いをしていました。

その1: エラーメッセージが同じであれば true が返ってくると勘違いしていた。
※今回の調査でこちらの勘違いは errors.Is とは無関係であることがわかりました 😇

package main

import (
 "errors"
 "fmt"
)

func main() {
 // ケース1
 error1 := errors.New("エラーです")
 error2 := errors.New("エラーです")
 isMatch := errors.Is(error1, error2)
 // isMatchがtrueになると思っていた

 // ケース2
 error1 := fmt.Errorf("エラーです")
 error2 := fmt.Errorf("エラーです")
 isMatch := errors.Is(error1, error2)
 // isMatchがtrueになると思っていた
}

その2: fmt.Errorf() でラップしたエラーメッセージは完全一致した時だけ true が返ってくる勘違いしていた。

package main

import (
 "errors"
 "fmt"
)

func main() {
 // "エラー2です: エラー1です"で完全一致した時だけ true になると思っていた
 error1 := errors.New("エラー1です")
 error2 := fmt.Errorf("エラー2です: %w", error1)
 isMatch := errors.Is(error2, error1)
 // isMatchがfalseになると思っていた
}

そもそも errors.Is とは??

まず勘違いしていた部分を理解できるようにするため、そもそも errors.Is について、そしてその周辺の理解を深めるようにしました。
※以降 errors.Is(err error, target error) に則って、比較元のエラーを err と記載し、比較先のエラーを target と記載するように統一します。

まず公式ドキュメントでは次のような説明がされています。

Is reports whether any error in err’s tree matches target.
The tree consists of err itself, followed by the errors obtained by repeatedly calling its Unwrap() error or Unwrap() []error method. When err wraps multiple errors, Is examines err followed by a depth-first traversal of its children.
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.

err のツリー内のエラーが target と一致するかどうかを報告します。
ツリーは、 err 自体と、その Unwrap() error または Unwrap() []error メソッドを繰り返し呼び出すことによって取得されたエラーで構成されます。 err が複数のエラーをラップする場合、 Is は err を調べ、その後にその子の深さ優先トラバーサルを実行します。
エラーが target と等しい場合、または Is(target) が true を返すような Is(error) bool メソッドを実装している場合、エラーは target と一致すると見なされます。

少し長いので、一つずつ見ていきます。

err のツリー内のエラーが target と一致するかどうかを報告します。
ツリーは、 err 自体と、その Unwrap() error または Unwrap() []error メソッドを繰り返し呼び出すことによって取得されたエラーで構成されます。

ツリーというのは、データ構造の「木(ツリー)」をイメージしてもらうと良いと思います。ツリー構造は階層構造でデータ管理する構造になっており、ツリーの最上位であるルートからブランチ(枝)を伸ばして各ノード(節点)を繋いでいきます。
出典:IT 用語辞典 e-Words
ツリー構造

そもそも Go では Go1.20 から err がこのツリー構造が取れるように なりました。
そのため次のように err が他の複数の err をラップできるようになり(ツリー構造で err を作れるように)、errors.Is のようなマッチ処理でもツリー構造を考慮し、err のラップを一つずつ解きながら target と一致するかどうかを精査できるようになりました。

// fmt.Errorf()を使った err のラップ
error1 := fmt.Errorf("エラー1です")
error2 := fmt.Errorf("エラー2です: %w", error1)
error3 := fmt.Errorf("エラー3です: %w", error2)
fmt.Println(error3) // エラー3です: エラ-2です: エラー1です

// errors.Joinを使った err のラップ
e1 := errors.New("エラー1です")
e2 := errors.New("エラ-2です")
e3 := errors.New("エラ-3です")
wrapErr := errors.Join(e1, e2, e3)
fmt.Println(wrapError) // エラー3です\nエラ-2です\nエラー1です

【イメージ】
Go1.20 から導入された err のツリー構造

err が複数のエラーをラップする場合、 Is は err を調べ、その後にその子の深さ優先探索を実行します。

errors.Is はまずツリー構造の最上位であるルートノードの err が target と一致するかを確認します。
一致しない場合、err がラップしている err(子ノード)に対して、深さ優先探索を行います。
これを target 一致するまで行い、一致した段階で探索を終了するようになっています。その場合分岐するすべてのブランチ(枝)を調べることはしないため、あるブランチでは true でも他のブランチでは false ということも発生する可能性があります。

深さ優先探索とは、まず一つのブランチを最大限に探索し、その後に次のブランチに移る方法です。これにより最も内側にラップされた err まで辿り、target と一致するかどうかを確認できます。

【イメージ 1: 一つのブランチを最大限探索】
深さ優先探索1
【イメージ 2: 別ブランチの探索 ①】
深さ優先探索2
【イメージ 3: 別ブランチの探索 ②】
深さ優先探索3

少し遠回りしましたが、ここだけでも次の二つのことが分かりました。

  • そもそも err はツリー構造を取ることができる
  • errors.Is はツリー内で target と一致するかどうかを確認しているのでラップした err も考慮して比較することが可能

コードリーディングをしてみる

前のセクションでは言葉を噛み砕いて理解できるようにしてきました。
このセクションでは、テキストやイメージで理解した部分がどのようにコードで実装されているか実装を見ていきましょう。

まずは errors.Is 全体のソースコードです。
そこまで複雑ではなさそうなので、いくつかに分けて確認していきましょう。

func Is(err, target error) bool {
 if target == nil {
  return err == target
 }

 isComparable := reflectlite.TypeOf(target).Comparable()
 for {
  if isComparable && err == target {
   return true
  }
  if x, ok := err.(interface{ Is(error) bool }); ok && x.Is(target) {
   return true
  }
  switch x := err.(type) {
  case interface{ Unwrap() error }:
   err = x.Unwrap()
   if err == nil {
    return false
   }
  case interface{ Unwrap() []error }:
   for _, err := range x.Unwrap() {
    if Is(err, target) {
     return true
    }
   }
   return false
  default:
   return false
  }
 }
}

まずはこちらのコードです。

if target == nil {
 return err == target
}

比較する target が nil の場合は、err も nil であれば true を返す処理になっています。

次のコードはこちらです。

isComparable := reflectlite.TypeOf(target).Comparable()

ここでは、TypeOf で target の型情報を取得し、その型が比較可能なものかを真偽値で返す処理をしています。
https://pkg.go.dev/internal/reflectlite#TypeOf

次からは for ループに処理が入ります。
こちらは無限ループになっているので条件に一致して return するまで処理を続けます。
一つずつ見ていきましょう。

for {
  if isComparable && err == target {
    return true
  }
  if x, ok := err.(interface{ Is(error) bool }); ok && x.Is(target) {
    return true
  }
  switch x := err.(type) {
  case interface{ Unwrap() error }:
    err = x.Unwrap()
    if err == nil {
      return false
    }
  case interface{ Unwrap() []error }:
    for _, err := range x.Unwrap() {
      if Is(err, target) {
        return true
      }
    }
    return false
  default:
    return false
  }
}

まずループの最初の処理です。

if isComparable && err == target {
 return true
}

こちらでは、target が比較可能であり、かつ err と一致する場合に true を返すようになっています。
ツリー構造で例えると最上位のルートで target と一致するかどうかの確認になります。
もしこの条件に一致した場合はここでループを抜けて処理を終了します。
ツリー構造のルートでtargetと一致する

次の処理です。

if x, ok := err.(interface{ Is(error) bool }); ok && x.Is(target) {
 return true
}

ここではまず err が Is メソッドを持つインターフェースへの型アサーションが成功するかどうかを確認します。
(= err が Is メソッドを実装しているかどうか)

interface{
  Is(error) bool
}

型アサーションが成功したら(= Is メソッドを実装していたら)、Is メソッドを実際に呼び出し err と target が一致するかどうか確認します。例えば fmt パッケージでは Is メソッドが実装されていないので一致しませんが、err に対して独自で Is メソッドを実装している場合はこちらの条件に一致します。

ok && x.Is(target) {
 return true
}

ここからループ内の最後の処理になります。
初回のループにおいて、ここまででまだ一致が確認できていない場合は比較したい target が err の中にラップされていることがわかるかと思います。(ツリー最上位の err と target の一致確認の処理は既に終わっているため)
以降は err をアンラップして一つずつ err を取り出していき target と一致するかどうか、を繰り返しいっていきます。
errをアンラップしながらtargetと一致するか確認する

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

ここでは型 switch を使って err が Unwrap() error or Unwrap() []error のどちらのメソッドを実装しているかどうかで分岐して処理を行っています。どちらも実装していない場合は false が返されループを抜けて処理が終了します。

Unwrap() error を実装している場合

case interface{ Unwrap() error }:
  err = x.Unwrap()
  if err == nil {
    return false
  }

Unwrap メソッドで err をアンラップしてラップされた err を取り出します。
err が nil の場合は false が返されループを抜けて処理が終了します。
もし取り出した err が nil ではない場合、その err を使って for ループの最初に戻り再度処理をスタートします。あとは return するまで繰り返すだけです。

例えば fmt パッケージでは次のように Unwrap() error が実装されており、ラップした err を取り出しています。

func (e *wrapError) Unwrap() error {
 return e.err
}

ちなみに err をラップするもう一つのメソッドの errors.Join() では Unwrap() error実装がされていません
そのためここでは fmt.Errorf("エラーです: %w", err) のように fmt パッケージを使って err をラップしている場合に、この条件に一致するはずです。

ではなぜ fmt.Errorf("エラーです: %w", err) で err をラップしただけで Unwrap() error が実装されるようになるのでしょうか。
それは パッケージの説明 にもあるように verb(書式指定子)で %w を含んでいる場合に返される err は Unwrap メソッドを実装するようになっているからです。
ここで詳しい言及はしないのですが verb で %w が指定された場合にどのように Unwrap を実装した err を返しているかは fmt パッケージの Errorf メソッドで 確認することができます

また、こちらもパッケージの説明でされていますが %w が複数指定されている場合は Unwrap() []error メソッドを実装するため、fmt.Errorf("エラーです: %w: %w", err1, err2) のような場合は次に紹介する case の条件に一致します。

最後にここで気をつけたいことが 2 つあります。

  1. Unwrap メソッドは verb に %w を指定した場合のみ実装されるということです。 そのため fmt.Errorf("エラー1です %s", "hoge") のように指定した場合は、これらの条件に一致することなく false を return して処理を終了します。エラーをラップする場合は %w を使うようにしましょう

  2. fmt.Errorf("エラーです %w", err2)err2 が別の err を複数ラップしている可能性があるということです。 例えば次のようなパターンが考えられます

    err1 := fmt.Errorf("エラー1です") // エラー1です
    err2 := fmt.Errorf("エラー2です %w", err1) // エラー2です エラー1です
    err3 := fmt.Errorf("エラー3です %w", err2) // エラー3です エラー2です エラー1です
    errors.Is(err3, err1)
    

    この場合複数の err をラップしていますが %w は一つのため Unwrap() error を実装します。(Unwrap() []error ではない)そのためアンラップした err を取り出してループの最初に戻り処理を再度スタートします。(この場合だと err2 から再度ループをスタートして一致しなければ err1 を取り出して...を繰り返す)

Unwrap() []error を実装している場合

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

Unwrap() []error メソッドはラップしている err を持ったスライスを返す処理を行います。
そのためスライスに入っている err の個数分ループ処理をして err を一つずつ取り出しつつ Is メソッドを再帰的に呼び出し target と一致するかどうかを確認します。ここで一致すれば true を返し、一致しなければ false を返すことで処理を終了します。

errors と fmt パッケージでは次のように Unwrap() []error が実装されています。

// fmtパッケージ
func (e *wrapErrors) Unwrap() []error {
 return e.errs
}

// errorsパッケージ
func (e *joinError) Unwrap() []error {
 return e.errs
}

また「どのように複数の err をラップしてスライスが作られているか」については各パッケージで実装されている処理で確認できます。
fmt パッケージの errors.go
errors パッケージの join.go

これでコードリーディングは終了です。

勘違いしていた所を理解できるようにする

次に errors.Is について理解が深まったところで二つの勘違いについて、納得できるように理解し直していきます。
まずは 2 つ目の勘違いから見ていきます。

fmt.Errorf() でラップしたエラーメッセージも完全一致した時に true が返ってくると思っていた

こちらはここまでの流れで十分に理解できたのでもう書く必要はないですね。ラップした err でもアンラップしながら target と比較する処理を行っているため、完全一致している必要はありません。

エラーメッセージが同じであれば true が返ってくると思っていた

これは今回調査していく中で errors.Is とは無関係の勘違いであることがわかりました。
そもそも Go の比較演算子に関して理解ができていなかったのが原因でした。

今回はそこまで掘り下げることはしませんが勘違いしていた部分は理解できるように確認してみます。
まず errors.New()fmt.Errorf() はどのような err を返しているのでしょうか。

// errors.New()
type errorString struct {
 s string
}
// 構造体のポインタを返す
func New(text string) error {
 return &errorString{text}
}

// fmt.Errorf()
// 注)少し長いので該当箇所のみコードを抜き出しています
// 詳細は https://cs.opensource.google/go/go/+/refs/tags/go1.23.0:src/fmt/errors.go;l=22
type wrapError struct {
 msg string
 err error
}

func Errorf(format string, a ...any) error {
 switch len(p.wrappedErrs) {
 // エラーをラップしない場合は errors.New() で err を返す(= 構造体のポインタを返す)
 case 0:
  err = errors.New(s)
 // エラーをラップする場合は構造体のポインタを返す
 case 1:
  w := &wrapError{msg: s}
  err = w
 default:
  err = &wrapErrors{s, errs}
 }
 return err
}

コードに書かれている通り構造体のポインタを返していることが分かりました。
勘違いを正すにはポインタ型の比較について理解する必要がありそうです。

ポインタ型の比較で一致する条件は次のどちらになります。

  • 同じ変数や同じオブジェクトのアドレスを指している

    x := 1
    p1 := &x
    p2 := &x
    p1 == p2 //true
    
  • または両方が nil である

今回勘違いしていたケースだと同じエラーメッセージではありますが、それぞれの変数が別のアドレスに格納されており同じアドレスを指していなかったため一致しなかったということが分かりました。

error1 := errors.New("エラーです")
error2 := errors.New("エラーです")
error1 == error2 // false

これで二つの勘違いについて、何に勘違いしていたかが分かり、勘違いしていた部分が実際にどのような実装がされているかを知ることができました。

最後に

今回ちょっと調べるつもりでしたが、errors.Is を理解するために周辺の知識を理解する必要があったので副作用で多くの学びを得ることができました。
理解するまでに少し時間はかかってしまいましたが、結果的に良かったかなと思います。

弊社では、毎週 100 Go Mistakes をディスカッションしながら読み進めたり、Go について勉強したりする環境があるので、引き続き Go について理解できるようにしていきたいと思います!

参考にさせていただいた記事

https://speakerdeck.com/convto/introduction-of-tree-structure-err-added-since-go-1-20
https://tip.golang.org/doc/go1.20#errors
https://go.dev/ref/spec
https://convto.hatenablog.com/entry/2023/01/31/133821

GitHubで編集を提案
株式会社StoreHero

Discussion