Goのエラーについて改めて学び直してみた

こんにちは、zaihoです。PHPerなりかけの卵をやってます。

実は学生時代からGoばかり触っていたせいで、例外の概念がまったく理解できなかった赤ちゃんなんですよね。今回は「Go at Google」から学んだことも踏まえて、改めてGoのエラーについて学びなおしたので、まとめていきたいと思います。

ここでは、Goのエラーについての考え方とerrorsパッケージについて基本的な使い方についてまとめます。

目次 見出しへのリンク

Goのエラーに対する考え方 見出しへのリンク

Goには鉄の掟があります。それは**「エラーは値である」**ということ。

Gopherたるもの、エラーは明示的に値として処理しなければなりません。

例外処理を採用しない設計思想 見出しへのリンク

実はGoって、意図的に従来の例外処理(try-catch)を採用していないんです。

Goではエラーを通常の値として扱います。

  • エラーはerrorインターフェースを実装した値
  • 明示的にチェックして、明示的に処理する
  • 制御フローが明確で理解しやすい

なぜ例外処理を採用しないのか 見出しへのリンク

エラーは例外的ではない 見出しへのリンク

例えばファイルを開くときに、こんなコードを書きますよね。

file, err := os.Open("config.json")
if err != nil {
    return fmt.Errorf("設定ファイルを開けません: %w", err)
}

ファイルアクセスが失敗するのって、別に例外的な状況ではなくて通常の動作条件なんです。

制御フローの明確性 見出しへのリンク

こういうコードを見たことありませんか?

data, err := readFile(path)
if err != nil {
    return err  // エラーを明示的に処理
}

result, err := processData(data)
if err != nil {
    return err  // 各ステップでエラーをチェック
}

Goは明示的チェックすることで直接的で理解しやすくなるようにしています。

Go at Google 見出しへのリンク

参照: Go at Google: Language Design in the Service of Software Engineering

「Go at Google」では、例外を採用しない理由についてこう説明されています:

明示的なエラーチェックは、プログラマーにエラーについて考えさせ、発生したエラーに対処することを強制させる。例外は、エラーを処理するのではなく無視することを容易にし、コールスタックに責任を転嫁し、問題の修正や適切な診断が間に合わなくなるまで、エラー処理を遅らせる。

ちなみにPHPの例外についてはこんな説明があります:

参照: PHP: 例外(exceptions) - Manual

例外がスローされ、現在の関数スコープに catch ブロックがなかった場合、その例外は、マッチする catch ブロックが見つかるまで関数のコールスタックを “遡って” いきます。その途中で見つかった全ての finally ブロックが実行されます。グローバルスコープに遡るまで全てのコールスタックを探しても、マッチする catch ブロックが見つからない場合は、グローバルな例外ハンドラが設定されていない限り fatal error となり、プログラムが終了します。

この考えからGoでは例外を採用しない設計になったんですね。

責任の強制 見出しへのリンク

Goのエラー処理には、こんなトレードオフがあります

メリット

  • 開発者はエラーを無視できない
  • 例外処理みたいに、エラー処理を先送りする誘惑がない
  • 先送りしないから診断も容易

デメリット

  • コードは冗長になる

でも、透明性と保守性が向上するし、エラー処理の意図が明白になるので、個人的には良いトレードオフだと思ってます。

エラー型の基礎 見出しへのリンク

errorインターフェース 見出しへのリンク

Goのエラーって、めちゃくちゃシンプルなインターフェースなんですよね。

type error interface {
    Error() string
}

このインターフェースを実装すれば、何でもエラーになれちゃいます。シンプルですよね。

errors.New()によるエラー生成 見出しへのリンク

最も基本的なエラーの生成方法がこれです。

import "errors"

err := errors.New("データベース接続に失敗しました")

fmt.Errorf()によるフォーマット付きエラー 見出しへのリンク

より詳細な情報を含むエラーを生成したいときは、こっちを使います。

err := fmt.Errorf("ユーザーID %d が見つかりません", userID)

コンテキスト情報を含むエラーメッセージを作成する際に利用します。

エラーラッピングと慣習 見出しへのリンク

エラーラッピングとは 見出しへのリンク

エラーをラップすることで、こんなことができます。

  • 元のエラー情報を保持
  • コンテキスト情報を追加

%w 動詞を使用したラッピング 見出しへのリンク

originalErr := errors.New("接続タイムアウト")
wrappedErr := fmt.Errorf("データベース接続エラー: %w", originalErr)

%wを使うと

  • 元のエラーがラップされる
  • errors.Iserrors.Asで検査可能になる
  • errors.Unwrapで元のエラーを取得できる

errors.Join()による複数エラーの結合 見出しへのリンク

複数のエラーを1つのエラー値に結合したいときは、これを使います。

err1 := errors.New("データベースエラー")
err2 := errors.New("キャッシュエラー")
err := errors.Join(err1, err2)

特徴としては以下になります。

  • nilエラーは無視される
  • Unwrap() []errorを実装する
  • errors.Iserrors.Asで各エラーをチェックできる

errors.Is と errors.As 見出しへのリンク

errors.Is() - エラー値の比較 見出しへのリンク

errors.Is()の目的は、エラーツリー内で特定のエラー値と一致するかを検査することです。

import (
    "errors"
    "io/fs"
)

if errors.Is(err, fs.ErrNotExist) {
    fmt.Println("ファイルが存在しません")
}

カスタムエラーとの比較もできます。

var ErrInvalidUser = errors.New("無効なユーザー")

if errors.Is(err, ErrInvalidUser) {
    // 特定のエラーに対する処理
}

errors.Is()の内部動作 見出しへのリンク

内部では、こんな感じで動いています。

  • errtargetを直接比較
  • ラップされたエラーをUnwrap()で取得し、再帰的にチェック

errors.As() - エラー型の検査 見出しへのリンク

errors.As()の目的は、エラーツリー内で特定の型に割り当て可能な値を検索し、取得することです。

var pathError *fs.PathError
if errors.As(err, &pathError) {
    fmt.Printf("失敗したパス: %s\n", pathError.Path)
    fmt.Printf("操作: %s\n", pathError.Op)
    fmt.Printf("元のエラー: %v\n", pathError.Err)
}

エラーの詳細情報にアクセスできるのが強みですね。 今見てみると引数に変更を加えているのって何気にすごい処理してますよね。 ちなみに、Go 1.26からはerrors.AsTypeが追加されるらしいです。

Unwrapの内部処理 見出しへのリンク

errors.Unwrap()の動作 見出しへのリンク

ラップされたエラーから元のエラーを抽出するのがUnwrap()です。

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

処理フロー 見出しへのリンク

  1. エラーがUnwrap() errorメソッドを実装しているか型アサーション
  2. 実装していれば、そのメソッドを呼び出して内部エラーを返す
  3. 実装していなければnilを返す

シンプルですよね。

errors.Join()との関係 見出しへのリンク

ここ、ちょっと注意が必要なんですが、errors.Join()で作成されたエラーはUnwrap() []errorを実装するんです。

errors.Unwrap()は対応していません

err1 := errors.New("エラー1")
err2 := errors.New("エラー2")
joined := errors.Join(err1, err2)

// errors.Unwrap()はnilを返す
fmt.Println(errors.Unwrap(joined))  // nil

// errors.Isやerrors.Asは正しく動作する
fmt.Println(errors.Is(joined, err1))  // true

使い分けのガイドライン 見出しへのリンク

関数用途返り値
errors.Is特定のエラー値との一致を検査boolif errors.Is(err, fs.ErrNotExist)
errors.As特定の型のエラーを検索・取得boolif errors.As(err, &pathError)
errors.Unwrap1つ内側のエラーを取得errorinnerErr := errors.Unwrap(err)

errors.Isを使う場合 見出しへのリンク

エラーが特定の定義済みエラー値かどうかをチェックしたいとき、エラーの詳細情報は不要で、エラーの種類だけを判定したいときに使います。

if errors.Is(err, sql.ErrNoRows) {
    // データが見つからない場合の処理
    return err
}

errors.Asを使う場合 見出しへのリンク

エラーの型を確認して、追加情報を取得したいとき、エラー構造体のフィールドにアクセスする必要があるときに使います。

var netErr *net.OpError
if errors.As(err, &netErr) {
    // ネットワークエラーの詳細情報を使用
    fmt.Printf("ネットワーク操作 %s が失敗: %v\n",
        netErr.Op, netErr.Err)
}

errors.Unwrapを使う場合 見出しへのリンク

エラーチェーンを手動で走査したいときや、複数レベルのラッピングを段階的に処理したいときに使います。

ただし注意点として、通常はerrors.Iserrors.Asの方が推奨されます。

// あまり推奨されない使い方
for err != nil {
    fmt.Println(err)
    err = errors.Unwrap(err)
}

まとめ 見出しへのリンク

  • Goのエラー処理は例外ではなく、通常の値として扱う
  • 明示的なエラーチェックにより、制御フローが明確になる
  • errors.Isは特定のエラー値との一致を検査
  • errors.Asは特定の型のエラーを検索し、詳細情報を取得
  • Unwrapはエラーチェーンを1段階ずつ辿る
  • エラーは%wでラップし、コンテキスト情報を追加する
  • 適切なエラー処理により、保守性と診断性が向上する