プログラミング
記事内に商品プロモーションを含む場合があります

Go言語の標準ライブラリを使ったエラー処理のまとめ

Aru

Go言語の標準エラー処理が気になったので調べてみました。他のプログラミング言語におけるtry-catchのような例外処理機構とは異なり、Go言語はエラーを明示的な戻り値として扱うという独自のアプローチを採用しています。本記事では、Go言語の標準ライブラリが提供するエラー処理の基本的な使い方を、いくつかのパターンに分けて解説します。

はじめに

Go言語におけるエラー処理は、C++などの言語とは違います。「戻り値でエラーを返す」っていうのはどうなの?と思っていましたが、実際に調べてみると、かなり使いやすいエラーハンドリングになっていることがわかりました。

本記事では、Goの基本的なエラーハンドリングについてサンプルプログラムで確認しながら解説します。

エラーハンドリング

エラーは戻り値として扱う

先ほど書いたように、Go言語ではエラーを戻り値として返すのが基本となります。`try-catch`のような方法がないので、エラーは戻り値で返すしかありません。

基本は、操作が成功した場合はnilが返され、失敗した場合はnilではないerror値を返します。

呼び出し側では、このerror戻り値を確認し、nilでない場合に適切なエラー処理を実装します。

このパターンが、Go言語のコードのエラーハンドリングになります。

以下、簡単な例です。

package main

import (
	"errors"
	"fmt"
)

// divideはaをbで割った商と余りを返す
func divide(a, b int) (int, error) {
	if b == 0 {
		// エラーが発生した場合、nilではないerror値を返す
		return 0, errors.New("Division by zero: ゼロで割ることはできません")
	}
	// 成功した場合、結果とnilを返す
	return a / b, nil
}

func main() {
	// エラーが発生しないケース
	fmt.Print("Case 1: ")
	result, err := divide(10, 2)
	if err != nil {
		// エラーが発生した場合はここに到達する
		fmt.Println("エラー:", err)
		return
	}

	fmt.Print("OK.")
	fmt.Println("結果:", result) // 結果: 5

	fmt.Println("-------------------------")
	fmt.Print("Case 2: ")

	// エラーが発生するケース
	result, err = divide(10, 0)
	if err != nil {
		// エラーが発生した場合はここに到達する
		fmt.Println("エラー:", err) // エラー: ゼロで割ることはできません
		return
	}
	fmt.Println("結果:", result)
}
実行結果
Case 1: OK.結果: 5
-------------------------
Case 2: エラー: Division by zero: ゼロで割ることはできません

この例では、0除算が発生する場合はエラーを返します。エラーを呼び出し側で処理することでエラーを適切に扱うことができます。

2. エラーの生成

先ほどエラーを発生させるために使ったerrors.New()以外にも、エラーを生成する関数が用意されています。ここでは、エラーを生成する関数を2つ紹介します

errors.New():エラー生成

errors.New関数は、静的なエラーメッセージを持つ新しいエラーを生成する関数です。

errors.Newで生成したエラーを変数として定義すると以下のようなコードを記述することが可能です。このような定義を「センチネルエラー (Sentinel Error)」と呼ぶみたいです。

これを使うと、呼び出し側はerrors.Is関数を用いてエラーの種類を確認することができるようになります。

以下、サンプルコードです

package main

import (
	"errors"
	"fmt"
)

// ErrNotFoundを定義
var ErrNotFound = errors.New("Item not found")

// findItem はスライスからアイテムを探します。
func findItem(items []string, target string) (string, error) {
	for _, item := range items {
		if item == target {
			return item, nil
		}
	}
	// 見つからない場合は ErrNotFound を返す
	return "", ErrNotFound
}

func main() {
	data := []string{"apple", "banana", "cherry"}

	// 成功するケース
	item, err := findItem(data, "banana")
	if err != nil {
		fmt.Println("エラー:", err)
		return
	}
	fmt.Println("見つかったアイテム:", item) // 見つかったアイテム: banana

	fmt.Println("---------------------------------")

	// 失敗するケース (アイテムが見つからない)
	item, err = findItem(data, "grape")
	if err != nil {
		// errors.Is を使って、返されたエラーが ErrNotFound であるかを確認
		if errors.Is(err, ErrNotFound) {
			fmt.Println("エラー: アイテムが見つかりませんでした。", "err =", err)
		} else {
			fmt.Println("その他のエラー:", err)
		}
		return
	}
	fmt.Println("見つかったアイテム:", item)
}
実行結果
見つかったアイテム: banana
---------------------------------
エラー: アイテムが見つかりませんでした。 err = Item not found

fmt.Errorf():フォーマット付きエラー生成

fmt.Errorf関数は、fmt.Printfと同様にフォーマット文字列と引数を用いて、動的なエラーメッセージを作成する関数です。エラーメッセージに具体的な変数の値などを含めることができるので、エラーの種類によってはこちらの方が便利です。

package main

import "fmt"

var users []string

// addUserはユーザーをusersに追加する
func AddUser(username string) error {
	if len(username) < 5 {
		// 動的な情報 (username) を含むエラーメッセージを作成
		return fmt.Errorf("ユーザー名 '%s' は短すぎます。5文字以上必要です。", username)
	}
	fmt.Printf("ユーザー '%s' を追加しました。\n", username)
	users = append(users, username)
	return nil
}

func main() {
	// 成功するケース
	err := AddUser("hogehoge")
	if err != nil {
		fmt.Println("エラー:", err)
	}

	fmt.Println("--------------------")

	// 失敗するケース (ユーザー名が短い)
	err = AddUser("hoge")
	if err != nil {
		fmt.Println("エラー:", err) // エラー: ユーザー名 'go' は短すぎます。5文字以上必要です。
	}

	// usersを表示
	fmt.Println("--------------------")
	fmt.Println("ユーザー:", users)
}
実行結果
ユーザー 'hogehoge' を追加しました。
--------------------
エラー: ユーザー名 'hoge' は短すぎます。5文字以上必要です。
--------------------
ユーザー: [hogehoge]

3. エラーのラッピングとアンラッピング

ラッピングはエラーが連鎖的に発生する場合に、手前で発生したエラーを含めて呼び出し元に伝えるために利用します。

これには、%wを利用します。

具体的なコードを見る方がわかりやすいのでコード例を示します。

package main

import (
	"errors"
	"fmt"
)

func div(a, b int) (int, error) {
	if b == 0 {
		return 0, errors.New("divide by zero")
	}

	return a / b, nil
}

func average(datas []int) (int, error) {
	sum := 0
	for _, e := range datas {
		sum += e
	}

	ave, err := div(sum, len(datas))
	if err != nil {
		return 0, fmt.Errorf("平均を計算中にエラーが発生しました: %w", err)
	}

	return ave, nil
}

func main() {
	ave, err := average([]int{1, 2, 3, 4, 5})
	if err != nil {
		// ラップされたエラーは、%v で出力するとチェイン全体が表示される
		fmt.Printf("最終的なエラー: %v\n", err)
		return
	}
	fmt.Println("平均 : ", ave)

	fmt.Println("------------------")
	ave, err = average([]int{})
	if err != nil {
		// ラップされたエラーは、%v で出力するとチェイン全体が表示される
		fmt.Printf("最終的なエラー: %v\n", err)
		return
	}
	fmt.Println("平均 : ", ave)

}

fmt.Errorf("平均を計算中にエラーが発生しました: %w", err)で、divのエラーに新しいエラーを追加して、呼び出し元に返しています。

実行結果
平均 :  3
------------------
最終的なエラー: 平均を計算中にエラーが発生しました: divide by zero

ラップしたエラーを、個別に表示したい場合には以下のようにアンラップします。

package main

import (
	"errors"
	"fmt"
)

func div(a, b int) (int, error) {
	if b == 0 {
		return 0, errors.New("divide by zero")
	}

	return a / b, nil
}

func average(datas []int) (int, error) {
	sum := 0
	for _, e := range datas {
		sum += e
	}

	ave, err := div(sum, len(datas))
	if err != nil {
		return 0, fmt.Errorf("平均を計算中にエラーが発生しました: %w", err)
	}

	return ave, nil
}

func main() {
	ave, err := average([]int{1, 2, 3, 4, 5})
	if err != nil {
		for e := err; e != nil; e = errors.Unwrap(e) {
			fmt.Printf("  - %v\n", e)
		}
		return
	}
	fmt.Println("平均 : ", ave)

	fmt.Println("------------------")
	ave, err = average([]int{})
	if err != nil {
		for e := err; e != nil; e = errors.Unwrap(e) {
			fmt.Printf("  - %v\n", e)
		}
		return
	}
	fmt.Println("平均 : ", ave)

}

for e := err; e != nil; e = errors.Unwrap(e) で、アンラップを行なっています。

これを実行すると以下のようになります。

実行結果
平均 :  3
------------------
  - 平均を計算中にエラーが発生しました: divide by zero
  - divide by zero

特定のエラーと比較(errors.Is

errors.Is関数を使うと、ラップされたエラーチェイン内に特定のエラーが含まれているかどうかをチェックできます。コードは以下のようになります。

package main

import (
	"errors"
	"fmt"
)

var ErrDivideByZero = errors.New("divide by zero")

func div(a, b int) (int, error) {
	if b == 0 {
		return 0, ErrDivideByZero
	}

	return a / b, nil
}

func average(datas []int) (int, error) {
	sum := 0
	for _, e := range datas {
		sum += e
	}

	ave, err := div(sum, len(datas))
	if err != nil {
		return 0, fmt.Errorf("平均を計算中にエラーが発生しました: %w", err)
	}

	return ave, nil
}

func main() {
	ave, err := average([]int{})
	if err != nil {
		if errors.Is(err, ErrDivideByZero) {
			fmt.Println("要素数がゼロです")
		} else {
			fmt.Println("不明なエラー:", err)
		}
		return
	}
	fmt.Println("平均 : ", ave)

}

特定のエラー型へのキャスト(errors.As

errors.Asはカスタムエラーを定義した場合に利用できます。動的なエラーメッセージを受け取り側で判定したい場合は、errors.Asを使います。

以下のプログラムは、整数のスライスの平均を計算する際に、要素数が0だった場合(ゼロ除算)に対してカスタムエラーを定義し、errors.As を使ってその型に応じたエラーハンドリングを行います。

package main

import (
	"errors"
	"fmt"
)

// カスタムエラー型
type DivideByZeroError struct {
	a, b int
}

func (e *DivideByZeroError) Error() string {
	return fmt.Sprintf("cannot divide a = %d by b = %d", e.a, e.b)
}

func div(a, b int) (int, error) {
	if b == 0 {
		return 0, &DivideByZeroError{a, b}
	}
	return a / b, nil
}

func average(datas []int) (int, error) {
	sum := 0
	for _, e := range datas {
		sum += e
	}

	ave, err := div(sum, len(datas))
	if err != nil {
		return 0, fmt.Errorf("平均を計算中にエラーが発生しました: %w", err)
	}

	return ave, nil
}

func main() {
	ave, err := average([]int{})
	if err != nil {
		var divideErr *DivideByZeroError
		if errors.As(err, ÷Err) {
			fmt.Println("要素数がゼロです", divideErr)
		} else {
			fmt.Println("不明なエラー:", err)
		}
		return
	}
	fmt.Println("平均 : ", ave)
}

このプログラムでは、標準の error ではなく、自作の構造体 DivideByZeroError を用意しています。ab は除算の入力値で、ゼロ除算が発生した時にそれらの値を記録します。また、カスタムエラーにError() メソッドを実装して error インターフェースを満たすようにします。

これで、標準のerrorと同じように扱うことが可能になります。また、取り出すときはerros.As関数を使います(main参照)。

実行結果
要素数がゼロです cannot divide a = 0 by b = 0

スタックトレースが欲しい!

エラーがどの行で発生したかのスタックトレースが欲しい場合は、標準ライブラリでは無理なようです。ここでは、cockroachdb/errorsを利用した例を紹介します。

cockroachdb/errorsは、星2.3Kのオープンソースのライブラリです。標準ライブラリで自力で頑張る方法もありますが、手軽にスタックトレースを行うのには良いと思いました。

package main

import (
	"fmt"

	cerrors "github.com/cockroachdb/errors"
)

func div(a, b int) (int, error) {
	if b == 0 {
		// スタックトレース付きエラーを生成
		return 0, cerrors.New("divide by zero")
	}

	return a / b, nil
}

func average(datas []int) (int, error) {
	sum := 0
	for _, e := range datas {
		sum += e
	}

	ave, err := div(sum, len(datas))
	if err != nil {
		// スタックトレースを保持したままラップ
		return 0, cerrors.Wrap(err, "平均を計算中にエラーが発生しました")
	}

	return ave, nil
}

func main() {
	ave, err := average([]int{}) // 要素数ゼロ → div(sum, 0) → divide by zero
	if err != nil {
		fmt.Printf("最終的なエラー:\n%+v\n", err)
		return
	}
	fmt.Println("平均 : ", ave)
}
実行結果
最終的なエラー:
平均を計算中にエラーが発生しました: divide by zero
(1) attached stack trace
  -- stack trace:
  | main.average
  |     /Users/tadanori/go/src/error/main.go:27
  | [...repeated from below...]
Wraps: (2) 平均を計算中にエラーが発生しました
Wraps: (3) attached stack trace
  -- stack trace:
  | main.div
  |     /Users/tadanori/go/src/error/main.go:12
  | main.average
  |     /Users/tadanori/go/src/error/main.go:24
  | main.main
  |     /Users/tadanori/go/src/error/main.go:44
  | runtime.main
  |     /usr/local/go/src/runtime/proc.go:272
  | runtime.goexit
  |     /usr/local/go/src/runtime/asm_arm64.s:1223
Wraps: (4) divide by zero
Error types: (1) *withstack.withStack (2) *errutil.withPrefix (3) *withstack.withStack (4) *errutil.leafError

まとめ

Go言語のエラーハンドリングに調べて、まとめて見ました。try~catchに慣れているとどうもしっくりこない部分もありますが、エラーの型を調べることも可能だし、カスタムのエラーを作ることも可能と、一通り必要な機能は揃っている気がします。

Go言語にはdeferがあるので、エラー発生次のファイルのCloseやメモリ解放などをcatch節に書かなくていいので、案外このやり方が楽なのかもしれません。

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

ABOUT ME
ある/Aru
ある/Aru
IT&機械学習エンジニア/ファイナンシャルプランナー(CFP®)
専門分野は並列処理・画像処理・機械学習・ディープラーニング。プログラミング言語はC, C++, Go, Pythonを中心として色々利用。現在は、Kaggle, 競プロなどをしながら悠々自適に活動中
記事URLをコピーしました