Go 1.13におけるエラーの取り扱い

Working with Errors in Go 1.13 by Damien Neil and Jonathan Amsterdam

はじめに

Goの「エラーは値」という考え方は、この10年間うまく機能してきました。 標準ライブラリによるエラーのサポートは、メッセージのみを含むエラーを生成する errors.New と fmt.Errorf という2つの関数だけと最小限のものでしたが、組み込みの error インターフェースのおかげで、 Goのプログラマは望むどんな情報でも追加できます。必要なのは Error メソッドを実装する型だけです。

type QueryError struct {
    Query string
    Err   error
}

func (e *QueryError) Error() string { return e.Query + ": " + e.Err.Error() }

このようなエラー型はいたるところにあり、そこに格納される情報はタイムスタンプ、ファイル名、サーバーのアドレスなど 実にさまざまです。多くの場合、その情報にはより詳しい状況を伝えるために、別の下位のエラーが含まれています。

このように1つのエラーが別のエラーを含むというパターンはGoのコードにおいて非常に広く見られるため、 広範な議論を経て、Go 1.13ではこれに対する明示的な サポートが追加されました。この記事では、その裏付けとなる標準ライブラリへの追加、すなわち errors パッケージの3つの新しい関数と、 fmt.Errorf 用の新しいフォーマット動詞について解説します。

変更の詳細に入る前に、これまでのバージョンの言語でエラーがどのように検査され、構築されてきたかを 振り返っておきましょう。

Go 1.13より前のエラー

エラーの検査

Goのエラーは値です。プログラムはその値をもとにいくつかの方法で判断を下します。最も一般的なのは、 処理が失敗したかどうかを見るためにエラーを nil と比較する方法です。

if err != nil {
    // 何か問題が発生した
}

特定のエラーが発生したかどうかを確認するために、既知の センチネル 値とエラーを比較することもあります。

var ErrNotFound = errors.New("not found")

if err == ErrNotFound {
    // 何かが見つからなかった
}

エラー値は、言語で定義された error インターフェースを満たす任意の型になりえます。プログラムは 型アサーションや型switchを使って、エラー値をより具体的な型として見ることができます。

type NotFoundError struct {
    Name string
}

func (e *NotFoundError) Error() string { return e.Name + ": not found" }

if e, ok := err.(*NotFoundError); ok {
    // e.Name が見つからなかった
}

情報の追加

関数がコールスタックを遡ってエラーを渡す際に、エラーが発生したときに何が起きていたかの簡単な説明のような 情報を追加することがよくあります。これを行う簡単な方法は、以前のエラーのテキストを含む新しいエラーを 構築することです。

if err != nil {
    return fmt.Errorf("decompress %v: %v", name, err)
}

fmt.Errorf で新しいエラーを作成すると、元のエラーからテキスト以外の情報がすべて失われます。 先ほどの QueryError の例で見たように、コードが検査できるように元となるエラーを保持したまま、 下位のエラーを含む新しいエラー型を定義したい場合があります。もう一度 QueryError を見てみましょう。

type QueryError struct {
    Query string
    Err   error
}

プログラムは *QueryError の値の中身を調べ、その下位のエラーをもとに判断を下せます。これは エラーを「アンラップする」と呼ばれることがあります。

if e, ok := err.(*QueryError); ok && e.Err == ErrPermission {
    // 権限の問題によってクエリが失敗した
}

標準ライブラリの os.PathError 型も、あるエラーが別のエラーを含んでいる例の1つです。

Go 1.13のエラー

Unwrapメソッド

Go 1.13では、別のエラーを含むエラーを扱いやすくするために、 errors パッケージと fmt パッケージに 新しい機能が導入されました。その中で最も重要なのは、変更というよりも1つの規約です。すなわち、 別のエラーを含むエラーは、その下位のエラーを返す Unwrap メソッドを実装してもよい、というものです。 もし e1.Unwrap() が e2 を返すなら、 e1 は e2 を ラップする と言い、 e1 を アンラップ すると e2 が得られると言います。

この規約に従い、先ほどの QueryError 型に、含んでいるエラーを返す Unwrap メソッドを持たせられます。

func (e *QueryError) Unwrap() error { return e.Err }

あるエラーをアンラップした結果自体が Unwrap メソッドを持つこともあります。このように繰り返し アンラップすることで生成されるエラーの並びを エラーチェーン と呼びます。

IsとAsによるエラーの検査

Go 1.13の errors パッケージには、エラーを検査するための新しい関数が2つ追加されました。 Is と As です。

errors.Is 関数は、エラーをある値と比較します。

// 次と同様:
//   if err == ErrNotFound { … }
if errors.Is(err, ErrNotFound) {
    // 何かが見つからなかった
}

As 関数は、エラーが特定の型であるかどうかを調べます。

// 次と同様:
//   if e, ok := err.(*QueryError); ok { … }
var e *QueryError
// 注意: *QueryError がエラーの型です。
if errors.As(err, &e) {
    // err は *QueryError であり、e にはそのエラーの値がセットされる
}

最も単純なケースでは、 errors.Is 関数はセンチネルエラーとの比較のように振る舞い、 errors.As 関数は型アサーションのように振る舞います。しかし、ラップされたエラーを対象にする場合、これらの関数は チェーン内のすべてのエラーを考慮します。先ほどの、下位のエラーを調べるために QueryError を アンラップする例をもう一度見てみましょう。

if e, ok := err.(*QueryError); ok && e.Err == ErrPermission {
    // 権限の問題によってクエリが失敗した
}

errors.Is 関数を使うと、これは次のように書けます。

if errors.Is(err, ErrPermission) {
    // err、あるいはそれがラップしているいずれかのエラーが権限の問題である
}

errors パッケージには新しい Unwrap 関数も含まれており、これはエラーの Unwrap メソッドを 呼び出した結果を返し、そのエラーが Unwrap メソッドを持たない場合は nil を返します。ただし、 通常は errors.Is や errors.As を使うほうがよいでしょう。これらの関数は1回の呼び出しで チェーン全体を調べてくれるからです。

注意: ポインタへのポインタを取るのは奇妙に感じられるかもしれませんが、この場合は正しい書き方です。 むしろ、エラー型の値へのポインタを取っていると考えてください。たまたまこの場合、返されるエラーが ポインタ型であるというだけのことです。

%wによるエラーのラップ

先に触れたように、エラーに追加の情報を加えるために fmt.Errorf 関数を使うのはよくあることです。

if err != nil {
    return fmt.Errorf("decompress %v: %v", name, err)
}

Go 1.13では、 fmt.Errorf 関数が新しい %w 動詞をサポートします。この動詞が使われると、 fmt.Errorf が返すエラーは、 %w の引数(これはエラーでなければなりません)を返す Unwrap メソッドを持つようになります。それ以外の点では、 %w は %v とまったく同じです。

if err != nil {
    // err にアンラップされるエラーを返す。
    return fmt.Errorf("decompress %v: %w", name, err)
}

%w でエラーをラップすると、そのエラーは errors.Is と errors.As から利用できるようになります。

err := fmt.Errorf("access denied: %w", ErrPermission)
...
if errors.Is(err, ErrPermission) ...

ラップすべきかどうか

fmt.Errorf を使う場合であれ、独自の型を実装する場合であれ、エラーに追加の文脈情報を加える際には、 新しいエラーが元のエラーをラップすべきかどうかを判断する必要があります。この問いに唯一の正解はなく、 新しいエラーがどのような文脈で作られるかによります。呼び出し元に公開したいのであればエラーをラップ してください。ラップすることで実装の詳細が公開されてしまう場合は、ラップすべきではありません。

一例として、 io.Reader から複雑なデータ構造を読み込む Parse 関数を考えてみましょう。エラーが 発生した場合、それが発生した行と列の番号を報告したいとします。もしそのエラーが io.Reader からの 読み込み中に発生したのであれば、下位の問題を検査できるようにそのエラーをラップしたくなるでしょう。 呼び出し元がこの関数に io.Reader を渡しているのですから、そこで生成されたエラーを公開するのは 理にかなっています。

これに対して、データベースへ何度も呼び出しを行う関数は、それらの呼び出しのうちの1つの結果へと アンラップされるようなエラーを返すべきではないでしょう。もしその関数が使っているデータベースが 実装の詳細であるなら、そのエラーを公開することは抽象化に反することになります。たとえば、あなたの パッケージ pkg の LookupUser 関数がGoの database/sql パッケージを使っているとすると、 sql.ErrNoRows というエラーに遭遇するかもしれません。もしそのエラーを

fmt.Errorf("accessing DB: %v", err)

として返すのであれば、呼び出し元は中を覗いて sql.ErrNoRows を見つけることはできません。しかし、 その関数が代わりに fmt.Errorf("accessing DB: %w", err) を返すのであれば、呼び出し元は 次のように書けるようになります。

err := pkg.LookupUser(...)
if errors.Is(err, sql.ErrNoRows) …

そうなった時点で、クライアントを壊したくないのであれば、たとえ将来別のデータベースパッケージに 切り替えたとしても、その関数は必ず sql.ErrNoRows を返さなければならなくなります。言い換えると、 エラーをラップすることは、そのエラーをあなたのAPIの一部にするということです。将来にわたって そのエラーをAPIの一部としてサポートし続けるつもりがないのであれば、そのエラーをラップすべきでは ありません。

覚えておくべきは、ラップしてもしなくても、エラーのテキスト自体は変わらないということです。 そのエラーを理解しようとする 人間 にとって得られる情報はどちらの場合も同じです。ラップするかどうかの 選択とは、より多くの情報を プログラム に与えてより多くの情報に基づいた判断をできるようにするか、 それとも抽象化のレイヤーを保つためにその情報を伏せておくかという選択です。

IsメソッドとAsメソッドによるエラーテストのカスタマイズ

errors.Is 関数は、チェーン内の各エラーがターゲットの値と一致するかどうかを調べます。デフォルトでは、 両者が等しい場合にエラーはターゲットと一致すると みなされます。加えて、チェーン内のエラーは Is メソッド を実装することで、あるターゲットと一致すると 自ら宣言できます。

一例として、Upspinのエラーパッケージ に着想を得た、次のようなエラーを考えてみましょう。これは、テンプレート内でゼロ値でないフィールドのみを 考慮して、エラーをテンプレートと比較するものです。

type Error struct {
    Path string
    User string
}

func (e *Error) Is(target error) bool {
    t, ok := target.(*Error)
    if !ok {
        return false
    }
    return (e.Path == t.Path || t.Path == "") &&
           (e.User == t.User || t.User == "")
}

if errors.Is(err, &Error{User: "someuser"}) {
    // err の User フィールドは "someuser" である。
}

errors.As 関数も同様に、 As メソッドが存在すればそれを参照します。

エラーとパッケージAPI

エラーを返すパッケージ(ほとんどのパッケージがそうです)は、そのエラーのどのような性質にプログラマが 依存してよいかを説明すべきです。よく設計されたパッケージは、依存すべきでない性質を持つエラーを 返すことも避けるでしょう。

最も単純な仕様は、操作が成功するか失敗するかのいずれかであり、それぞれ nil または非nilのエラー値を 返す、というものです。多くの場合、これ以上の情報は必要ありません。

ある関数に「項目が見つからない」といった識別可能なエラー状態を返してほしい場合、センチネルをラップした エラーを返すことが考えられます。

var ErrNotFound = errors.New("not found")

// FetchItem は指定された名前の項目を返します。
//
// その名前を持つ項目が存在しない場合、FetchItem は ErrNotFound を
// ラップしたエラーを返します。
func FetchItem(name string) (*Item, error) {
    if itemNotFound(name) {
        return nil, fmt.Errorf("%q: %w", name, ErrNotFound)
    }
    // ...
}

呼び出し元が意味的に検査できるエラーを提供する方法としては、他にも、センチネル値を直接返す、 特定の型を返す、述語関数によって検査できる値を返すといった既存のパターンがあります。

いずれの場合でも、内部の詳細をユーザーに公開しないよう注意すべきです。前述の「ラップすべきかどうか」で 触れたように、別のパッケージから受け取ったエラーを返すときは、将来にわたってその特定のエラーを 返し続けることを約束するのでない限り、下位のエラーを公開しない形に変換すべきです。

f, err := os.Open(filename)
if err != nil {
    // os.Open が返す *os.PathError は内部の詳細である。
    // これを呼び出し元に公開しないために、同じテキストを持つ新しい
    // エラーとして詰め直す。%w を使うと呼び出し元が元の *os.PathError を
    // アンラップできてしまうため、ここでは %v というフォーマット動詞を使う。
    return fmt.Errorf("%v", err)
}

もしある関数が、あるセンチネルや型をラップしたエラーを返すと定義されているなら、下位のエラーを そのまま直接返してはいけません。

var ErrPermission = errors.New("permission denied")

// DoSomething は、ユーザーがその操作を行う権限を持たない場合、
// ErrPermission をラップしたエラーを返します。
func DoSomething() error {
    if !userHasPermission() {
        // ErrPermission を直接返してしまうと、呼び出し元は
        // 次のように書いて、その正確なエラー値に依存するように
        // なってしまうかもしれません。
        //
        //     if err := pkg.DoSomething(); err == pkg.ErrPermission { … }
        //
        // これでは、将来エラーに追加の文脈情報を加えたくなったときに
        // 問題が生じます。これを避けるため、センチネルをラップした
        // エラーを返し、ユーザーが必ずそれをアンラップするようにします。
        //
        //     if err := pkg.DoSomething(); errors.Is(err, pkg.ErrPermission) { ... }
        return fmt.Errorf("%w", ErrPermission)
    }
    // ...
}

まとめ

ここまで議論してきた変更は、たった3つの関数と1つのフォーマット動詞にすぎませんが、Goのプログラムに おけるエラーの扱い方を大きく改善する助けになると私たちは期待しています。追加の文脈情報を加えるために ラップすることが一般的になり、プログラムがより適切な判断を下し、プログラマがより早くバグを見つける 助けになると考えています。

Russ CoxがGopherCon 2019の基調講演で述べたように、Go 2への道の上で 私たちは実験し、単純化し、そして出荷します。今回これらの変更を出荷したので、次はどのような実験が 続くのか楽しみにしています。

By Damien Neil and Jonathan Amsterdam