Help us understand the problem. What is going on with this article?

Go 1.13時代のエラー実装者のお作法

Goアドベントカレンダーその2の3日目のエントリーです。

Goではエラー処理の方法としてはプリミティブな方法しか提供しておらず、他の言語のユーザーからやいのやいの言われてきました。Go2でそれを改善するぞからプロポーザル募集でいろいろ意見を募っては二転三転みたいな感じで、Go 1.13ではだいぶおとなしい感じに機能拡張されました。基本的な方向性としてはgithub.com/pkg/errorsから少し機能を取り込んだ感じです。

すでに、数多くのエントリーやらプレゼンテーションやらでGo 1.13の利用者視点でのerrorsの変更点については触れられてきましたので詳しくはそちらをご覧ください。サマリーとしては下請けのパッケージで出てきた詳細なエラーをラップして扱うための便利な機構がいろいろ追加された感じです。

これらでは主にアプリケーションコードの実装者というかライブラリの利用者向けの説明が多く、Go 1.13以降のerrorsを活用するためにエラーを定義する一番底レイヤーな開発者がどのようにエラーを定義すべきか、という点について触れた資料は見かけたことがなかったので(単なる僕の見落としかもしれませんが)、そちら視点でHow Toをまとめます。

次の2つに分けて説明します

  • ラップとアンラップ
  • エラーの比較

ラップとアンラップ

Go 1.13ではエラーを構造化(ラップ)することができるようになりました。2つの機能が提供されました。

  • fmt.Errorf()で、"%w"という識別子を使って、エラーをラップできるようになった
  • errors.Unwrap(err)

ラップできるということは、例えばデータベースの細かなアクセスエラーに対して、データの読み書きのレイヤーでは「データベースでエラーがあったよ」という抽象度の高いエラーとして扱い、なおかつ、HTTPのハンドラーのレイヤーでは「internal server errorで500ですよ」というさらに抽象度の高いエラーとして扱うことが可能になるということです。そして、ただ抽象度をあげるだけではなくて、必要に応じてUnwrap()で詳細なエラーを取り出すことができるというスンポーです。

なお、 errors.Unwrap(err)は次のような実装になっています。あんまり見慣れない感じな人も多いですが、型アサーションでUnwrap()メソッドを持っているかどうかを調べ、持っていたら呼び出すというコードになっています。事前にinterfaceを定義しておくコードであれば、まだおとなしい感じですが、この書き方だと静的型付け言語のくせに、まるでRubyやPythonのようなダックタイピングになっていますね。

func Unwrap(err error) error {
    u, ok := err.(interface {
        Unwrap() error
    })
    if !ok {
        return nil
    }
    return u.Unwrap()
}

Unwrap()メソッドがあればそれを呼び、なければnilを返すコードになっています。errorインタフェースを満たすオブジェクトを作成する2つの標準ライブラリの関数のうち、errors.New()は最上位のエラーを作成するためのものなのでUnwrap()メソッドは持ちません。fmt.Errorf()で生成するエラーの場合はUnwrap()メソッドがあるので、ラップされた子エラーを取り出すことができます。

fmt.Errorf()を使う場合以外で、他の詳細なエラーに対して情報を付与して抽象度の高いエラーを扱う構造体などを作りたい場合には、このUnwrap()メソッドを用意するのが、エラー実装者のお作法ということになります。

バッチ処理的に複数のリクエストをバルクで送信したときに、一括でエラーを受け取るというのがGo CloudのdocstoreにはAPIであります。このActionListErrorは、errorインタフェースを満たす型ですが、配列でもあります。この型のUnwrap()は、配列が1つだけの要素であればその子要素を返すという実装になっています。

type ActionListError []struct {
    Index int
    Err   error
}

func (e ActionListError) Error() string {
    :
}

func (e ActionListError) Unwrap() error {
    if len(e) == 1 {
        return e[0].Err
    }
    return nil
}

エラーの比較

1.12以前の標準ライブラリ内のエラー比較

Goの標準ライブラリは設計が比較的きれいで、Goの勉強には標準ライブラリのコードを読めば良い、と長らく言われてきましたが、その中であまり美しくないのがエラーの比較です。標準ライブラリの中で3種類あります。

まず最初に、比較関数を使う方法です。osパッケージにはIsExists, IsNotExist, IsPermission, IsTimeoutがありますが、あまり見かけません。

_, err := os.Stats("file")
if os.IsNotExist(err) {
  // ファイルが存在しない
}

インスタンスの比較パターンもあります。インスタンスが比較できるというのは、エラー発生時には、グローバル変数に入っているerrors.New()などで定義しておいたもインスタンスをそのままreturnで返すし、比較にも使う方法です。追加情報がないものに限定されますが、使う側もシンプルで一番コードがきれいになります。

if err == io.EOF {
    // ファイル読み込み中に残りのデータを読み込めずに末尾に到達してしまった
}

それ以外だと型アサーションでキャストする方法もあります。

err := json.Unmarshal(jsonStr, person)
if _, ok := err.(*json.InvalidUnmarshalError); ok {
  // unmarshal errorのとき
}

Go 1.13以降のエラーの比較

Go 1.13ではエラー比較に使える関数が二つ追加されました。前者は2つのエラーを比較して同一種類であればtrueを返します。後者のAsはIsと似ていますが、指定された型へのキャストも行います。先ほどのGo 1.12以前に例で触れた分類で言えば、「型があっているかどうか」というシンプルな比較であれば前者、例えば文法チェックエラーで、エラーの情報とエラー箇所の追加情報を持っている場合に、それらにアクセスするという目的があればAsを使います。

  • errors.Is(err, target error) bool
  • errors.As(err error, target interface{}) bool

errors.Is(err, target error) bool

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
        }
        // TODO: consider supporing target.Is(err). This would allow
        // user-definable predicates, but also may allow for coping with sloppy
        // APIs, thereby making it easier to get away with them.
        if err = Unwrap(err); err == nil {
            return false
        }
    }
}
  1. targetがnilならnilと比較
  2. 以下ループ
    1. targetが比較可能なら比較して一致したらtrue(以前のio.EOFパターン)
    2. errに Is(err error) boolメソッドがあったら、それを呼び出し、trueならそのまま返す
    3. errをUnwrapする。できなければfalseを返す

比較可能であれば比較して確認しますし、fmt.Errorfでラップした場合は、最後のUnwrapで必要とする値が出てくるまでは子供の探索をし続ける、というのは、1.13のラップ機構を使い始めた人には納得のロジックだと思います。もう一つきになるパターンが、Unwrapの時と同じ、 Is(err error) boolメソッドがあるかどうかをアノニマスなインタフェースで確認し、存在したら呼び出している点です。これでユーザー定義の比較関数が定義できるようになっています。TODOには逆にtarget.Isがあったら呼べるようにするというのが書かれています。現在はerr側のIsだけを呼んでいます。

errors.As(err error, target interface{}) bool

Asの実装は次のようになっています。Isよりもリフレクションバリバリですね。

func As(err error, target interface{}) bool {
    if target == nil {
        panic("errors: target cannot be nil")
    }
    val := reflectlite.ValueOf(target)
    typ := val.Type()
    if typ.Kind() != reflectlite.Ptr || val.IsNil() {
        panic("errors: target must be a non-nil pointer")
    }
    if e := typ.Elem(); e.Kind() != reflectlite.Interface && !e.Implements(errorType) {
        panic("errors: *target must be interface or implement error")
    }
    targetType := typ.Elem()
    for err != nil {
        if reflectlite.TypeOf(err).AssignableTo(targetType) {
            val.Elem().Set(reflectlite.ValueOf(err))
            return true
        }
        if x, ok := err.(interface{ As(interface{}) bool }); ok && x.As(target) {
            return true
        }
        err = Unwrap(err)
    }
    return false
}

エラーチェックがある以外の基本の流れはIsと似ています。

  1. targetには代入するので有効なポインタ型でなければpanic
  2. targetの要素は型を一致させて代入させる必要があるのでインタフェースかエラー型でないとpanic
  3. 以下ループ
    1. ターゲット型にerrが代入可能であれば代入して完了
    2. As(interface{}) boolメソッドがあればそれを呼ぶ
    3. Unwrapする。nilだったら終了

こちらもAsメソッドがあれば呼ぶという実装になっています。

実装例

Is/Asメソッドを用意しなくても、エラー型とインスタンスが1対1であれば他のロジックで正しく動作します。わざわざIs/Asメソッドを実装しなければならないのはそうならないケースです。下記のコードが実装例です。今までの比較では簡単に実装できなかったような比較元やキャスト元と先の型が違う(左辺がMyErrorで、右辺がMyError2という別の型)というのを実装してみました。

実際にはこのようなトリッキーな実装はあんまりないかもしれませんが、ActionListErrorのような複数のエラーをhas-aでまとめているようなWrapの特殊ケースとか、リトライしたときの過去のエラーも全部保持しておいて適切に処理したいケースとか、そういう場面でのみ役に立つでしょう。とはいえ、エラー処理なのでどのようなニッチケースが発生するかわかりませんので、存在を知っておいて(詳細は必要になったらこのエントリーを探してもらえれば)損はないでしょう。

package main

import (
    "errors"
    "fmt"
    "log"
)

type MyError struct {
    Detail string
}

type MyError2 struct {
    Detail string
}

func (e MyError2) Error() string {
    return fmt.Sprintf("MyError2: %s", e.Detail)
}

func (e MyError) Error() string {
    return fmt.Sprintf("MyError: %s", e.Detail)
}

func (e MyError) Is(target error) bool {
    log.Println("calling Is")
    _, ok := target.(*MyError2)
    return ok
}

func (e MyError) As(target interface{}) bool {
    log.Println("calling As")
    if cast, ok := target.(**MyError2); ok {
        (*cast).Detail = e.Detail
        return true
    }
    return false
}

func main() {
    myError := &MyError{
        Detail: "お腹すいた",
    }
    log.Println(errors.Is(myError, &MyError2{}))

    e := &MyError2{}
    if errors.As(myError, &e) {
        log.Println(e.Detail)
    }
}

まとめ

error型のインタフェース自体はError() stringメソッドを実装すれば満たされるという点は以前から変わっていません(変わっていたら大事件)。Go 1.13からは、これとは別に名前のない3種類のインタフェースを通じて、3つのメソッドがerrorsパッケージによって呼ばれる可能性があります。自前のエラー構造体を実装する場合、つぎの3つのメソッドも実装するとGoの言語標準のエラー処理で正しく扱うことができるようになります。

  • Unwrap() error
  • Is(target error) bool
  • As(target interface{}) bool

とはいえ、実装例で触れたように、基本的にエラーの構造体を1つ作り、その型とのIs/AsだけであればGo 1.12のようなError()メソッドだけを持つ構造体を作れば問題にはなりません。より特殊な構造化が必要なときだけ必要になる機能ですが、一応そのような場面でも対処できる退路はきちんと確保してあるよ、というのが本エントリーの結論です。

明日は@crifffさんの投稿です。

Why do not you register as a user and use Qiita more conveniently?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away