The Go Blog

Go 1.13におけるエラー処理

Damien Neil と Jonathan Amsterdam
2019年10月17日

はじめに

Goがエラーを値として扱うことは、この10年間、私たちに大いに役立ってきました。標準ライブラリのエラーに対するサポートは最小限でしたが(メッセージのみを含むエラーを生成する`errors.New`と`fmt.Errorf`関数のみ)、組み込みの`error`インターフェースにより、Goプログラマーは必要な情報を何でも追加できます。必要なのは、`Error`メソッドを実装する型だけです。

type QueryError struct {
    Query string
    Err   error
}

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

このようなエラー型はいたるところに存在し、タイムスタンプからファイル名、サーバーアドレスまで、格納する情報は多岐にわたります。多くの場合、その情報には、追加のコンテキストを提供する別の下位レベルのエラーが含まれています。

あるエラーが別のエラーを含むパターンはGoコードに非常に浸透しているため、広範な議論の後、Go 1.13で明示的なサポートが追加されました。この投稿では、そのサポートを提供する標準ライブラリへの追加機能(`errors`パッケージの3つの新しい関数と、`fmt.Errorf`の新しいフォーマット動詞)について説明します。

変更点を詳しく説明する前に、以前のバージョンの言語でエラーがどのように調査され、構築されていたかを復習しましょう。

Go 1.13以前のエラー

エラーの調査

Goのエラーは値です。プログラムはいくつかの方法でそれらの値に基づいて決定を下します。最も一般的なのは、操作が失敗したかどうかを確認するためにエラーを`nil`と比較することです。

if err != nil {
    // something went wrong
}

特定のSentinel値とエラーを比較して、特定のエラーが発生したかどうかを確認することもあります。

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

if err == ErrNotFound {
    // something wasn't found
}

エラー値は、言語で定義された`error`インターフェースを満たす任意の型にすることができます。プログラムは型アサーションまたは型スイッチを使用して、エラー値を特定の型として見ることができます。

type NotFoundError struct {
    Name string
}

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

if e, ok := err.(*NotFoundError); ok {
    // e.Name wasn't found
}

情報の追加

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

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 {
    // query failed because of a permission problem
}

標準ライブラリの`os.PathError`型は、別のエラーを含む別のエラーの例です。

Go 1.13におけるエラー

Unwrapメソッド

Go 1.13では、他のエラーを含むエラーを扱うのを簡素化するために、`errors`と`fmt`標準ライブラリパッケージに新しい機能が導入されました。これらの中で最も重要なのは、変更というよりも慣例です。別のエラーを含むエラーは、基になるエラーを返す`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`関数は、エラーと値を比較します。

// Similar to:
//   if err == ErrNotFound { … }
if errors.Is(err, ErrNotFound) {
    // something wasn't found
}

`As`関数は、エラーが特定の型であるかどうかをテストします。

// Similar to:
//   if e, ok := err.(*QueryError); ok { … }
var e *QueryError
// Note: *QueryError is the type of the error.
if errors.As(err, &e) {
    // err is a *QueryError, and e is set to the error's value
}

最も単純なケースでは、`errors.Is`関数はSentinelエラーとの比較のように動作し、`errors.As`関数は型アサーションのように動作します。ただし、ラップされたエラーを操作する場合、これらの関数はチェーン内のすべてのエラーを考慮します。基になるエラーを調査するために`QueryError`をアンラップする上記の例をもう一度見てみましょう。

if e, ok := err.(*QueryError); ok && e.Err == ErrPermission {
    // query failed because of a permission problem
}

`errors.Is`関数を使用すると、これを次のように書くことができます。

if errors.Is(err, ErrPermission) {
    // err, or some error that it wraps, is a permission problem
}

`errors`パッケージには、エラーの`Unwrap`メソッドを呼び出した結果を返す新しい`Unwrap`関数も含まれています。エラーに`Unwrap`メソッドがない場合は`nil`を返します。ただし、これらの関数は1回の呼び出しでチェーン全体を検査するため、通常は`errors.Is`または`errors.As`を使用する方が良いでしょう。

注:ポインターへのポインターを取ることは奇妙に感じるかもしれませんが、この場合は正しいです。代わりに、エラー型の値へのポインターを取ると考えてください。この場合、返されるエラーがポインター型であるだけです。

%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 {
    // Return an error which unwraps to 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`を関数に提供したため、それによって生成されたエラーを公開することは理にかなっています。

対照的に、複数のデータベース呼び出しを行う関数は、それらの呼び出しのいずれかの結果にアンラップするエラーを返すべきではありません。関数が使用するデータベースが実装の詳細である場合、これらのエラーを公開することは抽象化の違反です。たとえば、パッケージ`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`関数は、ターゲット値と一致するかどうかをチェーン内の各エラーで調べます。デフォルトでは、2つのエラーが等しい場合、エラーはターゲットと一致します。さらに、チェーン内のエラーは、`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's User field is "someuser".
}

`errors.As`関数も同様に、存在する場合は`As`メソッドを呼び出します。

エラーとパッケージAPI

エラーを返すパッケージ(ほとんどのパッケージがそうですが)は、プログラマーが信頼できるエラーのプロパティを記述する必要があります。適切に設計されたパッケージは、信頼すべきではないプロパティを持つエラーを返すことも避けます。

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

関数に「アイテムが見つかりません」のような識別可能なエラー条件を返させたい場合は、Sentinelをラップするエラーを返すことができます。

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

// FetchItem returns the named item.
//
// If no item with the name exists, FetchItem returns an error
// wrapping ErrNotFound.
func FetchItem(name string) (*Item, error) {
    if itemNotFound(name) {
        return nil, fmt.Errorf("%q: %w", name, ErrNotFound)
    }
    // ...
}

呼び出し元が意味的に検査できるエラーを提供するための既存のパターンには、Sentinel値、特定の型、または述語関数で検査できる値を直接返すなど、他にもあります。

いずれの場合も、内部の詳細をユーザーに公開しないように注意する必要があります。「ラップするかどうか」で触れたように、別のパッケージからエラーを返す場合は、その特定のエラーを将来返すことを約束する意思がない限り、基になるエラーを公開しない形式に変換する必要があります。

f, err := os.Open(filename)
if err != nil {
    // The *os.PathError returned by os.Open is an internal detail.
    // To avoid exposing it to the caller, repackage it as a new
    // error with the same text. We use the %v formatting verb, since
    // %w would permit the caller to unwrap the original *os.PathError.
    return fmt.Errorf("%v", err)
}

関数が特定のSentinelまたは型をラップするエラーを返すように定義されている場合、基になるエラーを直接返さないでください。

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

// DoSomething returns an error wrapping ErrPermission if the user
// does not have permission to do something.
func DoSomething() error {
    if !userHasPermission() {
        // If we return ErrPermission directly, callers might come
        // to depend on the exact error value, writing code like this:
        //
        //     if err := pkg.DoSomething(); err == pkg.ErrPermission { … }
        //
        // This will cause problems if we want to add additional
        // context to the error in the future. To avoid this, we
        // return an error wrapping the sentinel so that users must
        // always unwrap it:
        //
        //     if err := pkg.DoSomething(); errors.Is(err, pkg.ErrPermission) { ... }
        return fmt.Errorf("%w", ErrPermission)
    }
    // ...
}

まとめ

私たちが議論した変更は、3つの関数と1つのフォーマット動詞に過ぎませんが、Goプログラムでエラーが処理される方法を改善するために大いに役立つことを願っています。追加のコンテキストを提供するためのラッピングが一般的になり、プログラムがより良い決定を下すのに役立ち、プログラマーがバグをより迅速に見つけるのに役立つと期待しています。

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

次の記事:Goモジュール:v2以降
前の記事:Goモジュールの公開
ブログインデックス