Goブログ

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() }

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

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

変更の詳細を説明する前に、以前のバージョンの言語でエラーがどのように検査および構築されるかを簡単に確認しましょう。

Go 1.13以前のエラー

エラーの検査

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

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

特定のエラーが発生したかどうかを確認するために、既知のセマンティック値とエラーを比較する場合があります。

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では、他のエラーを含むエラーの処理を簡素化するために、errorsfmt標準ライブラリパッケージに新しい機能が導入されました。これらの中で最も重要なのは、変更ではなく慣例です。別のエラーを含むエラーは、基になるエラーを返すUnwrapメソッドを実装できます。e1.Unwrap()e2を返す場合、e1e2ラップし、e1アンラップしてe2を取得できると言います。

この慣例に従って、上記のQueryError型に、含まれるエラーを返すUnwrapメソッドを与えることができます。

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

エラーをアンラップした結果は、それ自体にUnwrapメソッドを持つことができます。繰り返しアンラップによって生成されるエラーのシーケンスをエラーチェーンと呼びます。

IsとAsを使用したエラーの検査

Go 1.13のerrorsパッケージには、エラーの検査のための2つの新しい関数IsAsが含まれています。

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関数はセマンティックエラーとの比較のように動作し、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メソッドがない場合はnilを返す新しいUnwrap関数も含まれています。ただし、これらの関数は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.Iserrors.Asで使用できるようになります。

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

ラップするかどうか

fmt.Errorfを使用するか、カスタム型を実装するかにより、エラーに追加のコンテキストを追加する場合は、新しいエラーが元のエラーをラップするかどうかを決定する必要があります。この質問に対する単一の答えはありません。新しいエラーが作成されるコンテキストによって異なります。エラーをラップして、呼び出し元に公開します。そうすることで実装の詳細が公開される場合は、エラーをラップしないでください。

例として、io.Readerから複雑なデータ構造を読み取るParse関数を考えてみましょう。エラーが発生した場合、発生した行番号と列番号を報告します。io.Readerからの読み取り中にエラーが発生した場合、そのエラーをラップして、基になる問題を検査できるようにします。呼び出し元が関数にio.Readerを提供したため、そのエラーを公開するのは理にかなっています。

対照的に、データベースへの複数の呼び出しを行う関数は、それらの呼び出しの1つの結果にアンラップされるエラーを返すべきではありません。関数が使用するデータベースが実装の詳細である場合、これらのエラーを公開することは抽象化の違反です。たとえば、パッケージpkgLookupUser関数が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以外のエラー値を返すことで述べることです。多くの場合、それ以上の情報は必要ありません。

「項目が見つかりません」など、識別可能なエラー状態を関数が返すようにしたい場合、センチネルをラップしたエラーを返すことができます。

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)
    }
    // ...
}

呼び出し側がセマンティックに検査できるエラーを提供するための、他の既存のパターンがあります。例えば、センチネル値、特定の型、または述語関数で検査できる値を直接返すなどです。

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

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)
}

関数が何らかのセンチネルまたは型をラップしたエラーを返すように定義されている場合、基になるエラーを直接返さないでください。

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つの関数とフォーマット動詞に過ぎませんが、Goプログラムにおけるエラー処理の改善に大きく貢献することを願っています。追加のコンテキストを提供するためのラップが一般的になり、プログラムがより良い意思決定を行い、プログラマーがバグをより迅速に見つけるのに役立つと期待しています。

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

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