Goエラーハンドリング
この章では、Goのエラーハンドリングについて学び、ハンズオン形式で学習します。これにより、エラーの伝播とラップ、errors.Is / errors.As による識別、defer によるリソース解放、panic / recover の使いどころといった、実務で必要になるエラーまわりの構文が身につきます。
1. 本章の概要
1.1 本章の目的
実務のGoコードでは、ファイル操作・DB接続・外部API呼び出しといった、失敗の可能性がある処理が数多く登場します。エラーの種類を正確に識別・伝播し、リソースを漏れなく解放できるかどうかが、コード品質を大きく左右します。本章では、エラーハンドリングの基礎から、errors パッケージによる識別(errors.Is / errors.As)、defer によるリソース解放、panic / recover によるパニック回収まで、Goのエラー処理まわりを一通り扱います。
1.2 ハンズオンの流れ
小さなサンプルコードを書きながら、独自エラー型の定義、エラーの伝播・ラップ、errors.Is / errors.As による識別、defer によるリソース解放、panic からの recover を1つずつ動かして挙動を確認します。
1.3 事前準備
必要なツール
この章では、以下のツールを使用します。まだインストールしていない場合は、リンク先の手順に沿って準備をお願いします。
| ツール名 | 関連箇所 | 理由 |
|---|---|---|
| Visual Studio Code | Visual Studio Codeのインストール | Goのコードを記述するエディタとして使用する |
| Go | Goのインストール | 本章のサンプルコードを実行する言語処理系として使用する |
2. エラーハンドリング
エラーハンドリング(error handling)は、処理の途中で失敗が起きたときに、その失敗を検知して適切に対処する仕組みです。ここでは、まずGoの基本的な失敗の扱い方から見て、その後で独自エラー型の定義、fmt.Errorf によるラップ、errors.Is / errors.As による識別といった、実務で必要になるパターンを扱います。
2.1 ハンドリングしない場合の挙動(ゼロ除算)
Go に組み込まれている単純な失敗の例として、ゼロ除算を見てみます。整数を 0 で割ろうとすると、実行時にパニック(プログラム全体の停止)が発生します。
次のコードを main.go に書き込んで保存し、go run main.go で実行します。
package main
import "fmt"
func main() {
a, b := 10, 0
fmt.Println(a / b)
fmt.Println("計算完了")
}
このコードを実行すると、以下のようにパニックのメッセージとスタックトレースが表示されてプログラムが強制終了します。
panic: runtime error: integer divide by zero
goroutine 1 [running]:
main.main()
/path/to/main.go:8 +0x11
exit status 2
割り算の結果が出ないだけでなく、後ろに書いた fmt.Println("計算完了") も実行されず、プロセス自体がここで異常終了します。複数の入力を順に処理するプログラムでは、1件目の失敗でそれ以降の処理がすべて動かないまま終わってしまいます。
2.2 シンプルなエラーハンドリング
失敗する条件が事前に特定できる場合は、if で確認して適切に終了させる分岐を入れられます。ゼロ除算の場合、割り算を行う前に「割る数 b が 0 かどうか」を判定します。
package main
import "fmt"
func main() {
a, b := 10, 0
if b == 0 {
fmt.Println("0で割れません")
return
}
fmt.Println(a / b)
fmt.Println("計算完了")
}
以下のような実行結果が表示されます。
0で割れません
割り算の前に処理を中止できたため、パニックにならず、プロセスも正常に終了しました。
コードを解説します。
if b == 0 {
fmt.Println("0で割れません")
return
}
割り算の前に b == 0 を確認し、該当する場合はメッセージを出して return で main を終了しています。パニックが発生する前に、プログラムをコントロールされた形で終了できます。
2.3 error型の利用
main のように「その場で終了できる」場合は if + return で済みます。ただし、失敗しうる処理を関数として切り出したときは、呼び出し元に「なぜ失敗したのか」を伝える必要が出てきます。Goでは、その手段として戻り値に error 型を追加するのが基本パターンです。
| 💡 ポイント |
|---|
Goには、他の多くの言語にある try / catch のような例外機構がありません。エラーは関数の戻り値として明示的に返すのが基本パターンで、呼び出し側は毎回 if err != nil で判定します。毎回書く手間はある反面、エラーの流れがコード上に明示されるため、失敗の見落としが起きにくいという特徴があります。 |
書き方は以下のとおりです。
// 関数定義側: 戻り値の末尾に error を追加する
func 関数名(引数リスト) (戻り値型, error) {
if 失敗条件 {
return ゼロ値, errors.New("エラーメッセージ")
}
return 通常の結果, nil
}
// 呼び出し側: 戻り値を (結果, err) で受け取り、err != nil で分岐する
result, err := 関数名(引数)
if err != nil {
// 失敗時の処理
}
次のコードは、割り算を行う divide 関数を定義し、b == 0 のときに error を返して呼び出し側で判定する例です。
package main
import (
"errors"
"fmt"
)
func divide(a, b int) (int, error) {
if b == 0 {
return 0, errors.New("0で割れません")
}
return a / b, nil
}
func main() {
result, err := divide(10, 0)
if err != nil {
fmt.Println("エラー:", err)
return
}
fmt.Println(result)
}
以下のような実行結果が表示されます。
エラー: 0で割れません
呼び出し元の main が err を受け取って分岐でき、失敗内容もアプリケーション側で決めた文言で伝えられました。
コードを解説します。
func divide(a, b int) (int, error) {
if b == 0 {
return 0, errors.New("0で割れません")
}
return a / b, nil
}
divide の戻り値を (int, error) の多値返却にすることで、計算結果と失敗情報の両方を返せます。エラーの作り方は後の「errors.New と fmt.Errorf」節で詳しく扱いますが、ここでは errors.New("メッセージ") でエラー値を作り、成功時は nil を返す形として押さえてください。
result, err := divide(10, 0)
if err != nil {
fmt.Println("エラー:", err)
return
}
呼び出し側では多値返却を result, err := ... で受け取り、err != nil で失敗を判定します。この「関数の戻り値の末尾に error を置き、呼び出し側で if err != nil を判定する」パターンが、Goのエラー処理の基本形です。
2.4 標準ライブラリにおけるerror
Goの標準ライブラリでは、失敗する可能性のある関数の多くが (結果, error) の形で結果とエラーの両方を返す作りになっています。例えばファイル読み込みの os.ReadFile も、成功時は中身のバイト列を、失敗時(ファイルが存在しない、読み取り権限がない、ディスクに障害があるなど)は error を返します。
以下は、存在しないファイル missing.txt を読み込もうとして、返された error を _ で無視した書き方です。
package main
import (
"fmt"
"os"
)
func main() {
data, _ := os.ReadFile("missing.txt")
fmt.Println("読み込んだ内容:", string(data))
fmt.Println("後続の処理を開始")
}
以下のような実行結果が表示されます。
読み込んだ内容:
後続の処理を開始
ファイル読み込みは失敗しているのに、_ で無視したため何の警告も出ず、data は空のバイト列 "" のまま fmt.Println に渡されました。この後に「読み込んだ内容を何らかの処理に流す」コードが続いていた場合、失敗を認識せずに空データを扱ってしまい、意図しない挙動につながります。
呼び出し側で err != nil を判定して分岐することで、失敗時の挙動を明示的に制御できます。
package main
import (
"fmt"
"os"
)
func main() {
data, err := os.ReadFile("missing.txt")
if err != nil {
fmt.Println("エラー:", err)
return
}
fmt.Println("読み込んだ内容:", string(data))
fmt.Println("後続の処理を開始")
}
以下のような実行結果が表示されます。
エラー: open missing.txt: no such file or directory
err != nil の分岐に入ってエラーメッセージを出力し、return で処理を中止できました。後続の fmt.Println は実行されず、空データが誤って先に流れることもありません。
コードを解説します。
data, err := os.ReadFile("missing.txt")
if err != nil {
fmt.Println("エラー:", err)
return
}
os.ReadFile の戻り値 error を err で受け取り、err != nil で失敗を判定します。失敗時にはエラーメッセージを出して return で処理を中止するのが定番のパターンです。
3. error の応用構文
実務のコードでは、エラーの種類を判別したり、既存のエラーに文脈を付け足して伝播させたり、独自の型として詳細情報を持たせたりする場面が出てきます。ここでは、error が interface として定義されている仕組みと、errors パッケージによるエラーの作成・ラップ・識別のパターンを扱います。
3.1 独自エラー型の定義
Goで関数から失敗を伝えるときに使う error 型は、実は言語仕様レベルで以下のように定義された interface です(Goインタフェース 参照)。
type error interface {
Error() string
}
つまり、Error() string メソッドを持つ型はすべて error として扱えます。この仕組みのおかげで、errors.New で作った標準のエラーだけでなく、独自の型に Error() string を実装すれば、その型を独自の error として返せます。独自の型にすると、エラーに関連する情報(対象名や ID など)をフィールドとして持たせておけるのが大きな利点です。
独自エラー型は、struct に Error() string メソッドを実装して定義します。書き方は以下のとおりです。
type 独自エラー型名 struct {
フィールド名 型
// 詳細情報を必要に応じて追加
}
func (レシーバ *独自エラー型名) Error() string {
return "エラーメッセージ(フィールドの値を含めて組み立てる)"
}
次のコードでは、NotFoundError という独自の型に Name フィールドを持たせて、Error() string メソッドで Name を含むメッセージを返します。こうすることで、find から error を返しつつ「見つからなかった対象名」も一緒に呼び出し側へ伝えられるようになります。main.go を以下の内容に書き換えて保存し、go run main.go で実行します。
package main
import "fmt"
type NotFoundError struct {
Name string
}
func (e *NotFoundError) Error() string {
return fmt.Sprintf("%s が見つかりません", e.Name)
}
func find(name string) error {
return &NotFoundError{Name: name}
}
func main() {
err := find("user123")
fmt.Println(err)
}
以下のような実行結果が表示されます。
user123 が見つかりません
NotFoundError は Error() string を持つので、自動的に error インタフェースを満たし、find の戻り値 error としてそのまま返せます。追加のフィールド(Name など)に情報を持たせられるので、呼び出し側でエラーの詳細を型として扱えるようになります。
コードを解説します。
type NotFoundError struct {
Name string
}
NotFoundError は Name フィールドを1つ持つ独自の型です。この Name に「見つからなかった対象の識別子」を入れることで、エラーに詳細情報を型として持たせています。フィールドを増やせば、対象のIDだけでなくリソース種別やタイムスタンプなど、任意の情報を持たせることもできます。
func (e *NotFoundError) Error() string {
return fmt.Sprintf("%s が見つかりません", e.Name)
}
NotFoundError に Error() string メソッドを実装することで、この型は自動的に error インタフェースを満たします。特別な登録や宣言は不要で、「Error() string メソッドを持っている」というだけで error として扱えるようになるのが、Goのインタフェースの特徴です。メソッドの中では、fmt.Sprintf で Name フィールドを埋め込んだメッセージを組み立てて返します。
func find(name string) error {
return &NotFoundError{Name: name}
}
find の戻り値の型は error ですが、実際に返しているのは *NotFoundError の値です。*NotFoundError は error インタフェースを満たしているので、そのまま error として返せます。呼び出し側は error として受け取りつつ、必要になった時点で *NotFoundError として取り出して Name を参照できるようになります(型として取り出す具体的な方法は、後述の errors.As の節で扱います)。
err := find("user123")
fmt.Println(err)
find の戻り値を err で受け取り、fmt.Println で表示しています。fmt.Println は error 値を渡されたときに Error() string を自動で呼び出す仕組みになっているため、user123 が見つかりません の文字列が出力されます。
3.2 errors.New と fmt.Errorf
シンプルな固定メッセージのエラーは errors.New で作れます。ただし、変数の値をメッセージに埋め込みたいときに errors.New だけを使おうとすると、以下のように fmt.Sprintf でメッセージを組み立ててから渡す形になり、冗長です。
err := errors.New(fmt.Sprintf("findUser: 無効なID (%d)", id))
fmt.Errorf を使うと、fmt.Sprintf と同じ書式指定子で動的な情報を埋め込みながら、そのままエラー値を作れます。書き方は以下のとおりです。
err := fmt.Errorf("メッセージ: 値 = %v", 埋め込みたい値) // 動的な情報を埋め込んだエラー値を作る
次のコードは、findUser が不正なIDを受け取ったときに、そのIDをメッセージに埋め込んだエラーを返す例です。
package main
import (
"fmt"
)
func findUser(id int) error {
if id <= 0 {
return fmt.Errorf("findUser: 無効なID (%d)", id)
}
return nil
}
func main() {
err := findUser(-1)
fmt.Println(err)
}
以下のような実行結果が表示されます。
findUser: 無効なID (-1)
書式文字列に埋め込まれた id の値 -1 が、そのままエラーメッセージに反映されて表示されました。
コードを解説します。
return fmt.Errorf("findUser: 無効なID (%d)", id)
fmt.Errorf は fmt.Sprintf と同じ書式指定子(%d、%s、%v など)で動的な情報を埋め込みつつ、そのまま error 値を返します。errors.New(fmt.Sprintf(...)) の2段階を1つの呼び出しにまとめられるため、動的な情報を含むエラーメッセージを作るときの定番として使います。
3.3 エラーの識別
エラーが起きたときに、呼び出し側で「どの種類のエラーか」を判定して処理を分岐したい場面があります。例えば「ユーザが見つからないなら空の結果を返す、それ以外なら失敗として上位に伝播する」といった使い分けです。
Goでは、この識別を以下の2つを組み合わせて実現します。
エラーのラップ(fmt.Errorf + %w)は、既存のエラーを保持しつつ、その周りに関数固有の文脈(IDや入力値など)を追加した新しいエラー値を作る仕組みです。書き方は以下のとおりです。
fmt.Errorf("メッセージ ... : %w", ラップしたい元のエラー) // %w で元のエラーをラップする
エラーの判定(errors.Is)は、指定したエラーが err そのもの、または err がラップしているエラーの中に含まれているかを判定する関数です。ラップされたエラーは元のエラーと == で比較しても一致しないため、この関数を使います。書き方は以下のとおりです。
if errors.Is(err, 判定したいエラー) {
// err または err がラップしているエラーが、判定したいエラーと一致するときの処理
}
例えば以下のように == で比較しても、条件式は常に false になります。
if err == ErrNotFound { // err が ErrNotFound をラップしていても false
// ...
}
err は fmt.Errorf("... : %w", ErrNotFound) によって作られた別のエラー値で、内部に ErrNotFound を保持しているだけだからです。
| 💡 ポイント |
|---|
ラップされたエラーの判定には必ず errors.Is を使います。== で比較すると %w でラップされたエラーは別物扱いになり、意図した分岐に入らずバグの原因になります。 |
次のコードでは、ErrNotFound(ユーザが存在しない)と ErrPermissionDenied(権限がない)の2種類のエラーを判定の目印として関数の外で宣言し、findUser が id の値に応じてそれぞれを %w でラップして返します。呼び出し側では errors.Is を使って、返ってきたエラーが「ユーザが見つからない」なのか「権限がない」なのかを判定して処理を分岐します。
package main
import (
"errors"
"fmt"
)
var (
ErrNotFound = errors.New("not found")
ErrPermissionDenied = errors.New("permission denied")
)
func findUser(id int) error {
if id < 0 {
return fmt.Errorf("findUser: id=%d は権限エラー: %w", id, ErrPermissionDenied)
}
if id == 0 {
return fmt.Errorf("findUser: id=%d は見つからない: %w", id, ErrNotFound)
}
return nil
}
func main() {
err := findUser(0)
if errors.Is(err, ErrNotFound) {
fmt.Println("ユーザが見つからない場合の処理")
} else if errors.Is(err, ErrPermissionDenied) {
fmt.Println("権限エラーの処理")
}
}
以下のような実行結果が表示されます。
ユーザが見つからない場合の処理
findUser(0) は ErrNotFound をラップしたエラーを返し、呼び出し側の errors.Is(err, ErrNotFound) がラップの中を辿って一致を検知したため、対応する分岐に入って所定のメッセージが出力されました。もし findUser(-1) を呼んでいれば、ErrPermissionDenied の方の分岐に入ります。
コードを解説します。
var (
ErrNotFound = errors.New("not found")
ErrPermissionDenied = errors.New("permission denied")
)
判定の目印にする固定のエラー値を、パッケージレベル(main の外)でまとめて宣言しています。関数の中で毎回 errors.New を呼ぶと呼び出しのたびに別のエラー値になり、後で errors.Is で識別できなくなるため、このように一度だけ宣言して参照する形にします。複数のエラーを扱うときは、上記のように var (...) でまとめて書けます。
if id < 0 {
return fmt.Errorf("findUser: id=%d は権限エラー: %w", id, ErrPermissionDenied)
}
if id == 0 {
return fmt.Errorf("findUser: id=%d は見つからない: %w", id, ErrNotFound)
}
findUser は id の値に応じて、権限エラーか「見つからない」エラーかを分けて返しています。どちらも %w を使って、目印となる ErrPermissionDenied / ErrNotFound を内部に保持したまま、関数固有の文脈(id=...)を付けたエラーを返しています。
if errors.Is(err, ErrNotFound) {
fmt.Println("ユーザが見つからない場合の処理")
} else if errors.Is(err, ErrPermissionDenied) {
fmt.Println("権限エラーの処理")
}
呼び出し側では、errors.Is を順に呼び出して「どの目印がラップされているか」を判定します。ラップされたエラーでも errors.Is はラップの中を辿って比較するため、err に含まれる目印を正しく検知して該当する分岐に入ります。エラー種別を追加したい場合は、目印用の Err... 変数と、判定用の else if を追加すれば同じパターンで拡張できます。
3.4 型の取り出し
「独自エラー型の定義」の節で扱ったように、独自の型に Error() string を実装すれば、追加フィールドに情報を持たせた独自エラー型として使えます。呼び出し側でそのフィールドを取り出して処理に使いたい場面では、関数から返されたエラー値の中から元の型を取り出す必要があります。
fmt.Errorf の %w でラップされたエラーは、内部に元の型を保持しています。この元の型を取り出すには、errors.As を使います。書き方は以下のとおりです。
var 受け先変数 *独自エラー型
if errors.As(err, &受け先変数) {
// err(またはそのラップ元)が独自エラー型として取り出せた場合の処理
// 受け先変数.フィールド でアクセスできる
}
次のコードでは、フォームのバリデーション失敗を表す独自エラー型 ValidationError(Field と Message の2フィールドを持つ)を定義し、validate がそれを %w でラップして返します。呼び出し側で errors.As を使ってラップの中から *ValidationError として取り出し、フィールドの詳細情報にアクセスします。
package main
import (
"errors"
"fmt"
)
type ValidationError struct {
Field string
Message string
}
func (v *ValidationError) Error() string {
return fmt.Sprintf("%s: %s", v.Field, v.Message)
}
func validate() error {
return fmt.Errorf("validate: %w", &ValidationError{Field: "email", Message: "形式が不正"})
}
func main() {
err := validate()
var verr *ValidationError
if errors.As(err, &verr) {
fmt.Printf("フィールド: %s, メッセージ: %s\n", verr.Field, verr.Message)
}
}
以下のような実行結果が表示されます。
フィールド: email, メッセージ: 形式が不正
err は fmt.Errorf でラップされていますが、errors.As がラップの中を辿って *ValidationError として取り出せたので、その Field と Message を個別に読み出して表示できました。
コードを解説します。
type ValidationError struct {
Field string
Message string
}
func (v *ValidationError) Error() string {
return fmt.Sprintf("%s: %s", v.Field, v.Message)
}
Error() string メソッドを持つ型はすべて error インタフェースを満たすため、ValidationError を独自エラー型として使えます。
func validate() error {
return fmt.Errorf("validate: %w", &ValidationError{Field: "email", Message: "形式が不正"})
}
validate は ValidationError を %w でラップして返します。返されるのは「validate: email: 形式が不正」というメッセージを持つラップ用のエラー値で、内部に *ValidationError を保持しています。呼び出し側では errors.As を使うことで、この内部の *ValidationError を取り出せます。
var verr *ValidationError
if errors.As(err, &verr) {
fmt.Printf("フィールド: %s, メッセージ: %s\n", verr.Field, verr.Message)
}
errors.As(err, &受け先変数) は、err(またはそのラップ元)が指定した型かを判定しつつ、受け先変数に取り出します。ラップされたエラーでもラップの中を辿って比較するので、err に含まれる *ValidationError を検知して verr に代入できます。取り出し後は verr.Field verr.Message のように、通常の struct フィールドとしてアクセスできます。
4. defer
関数の実行中にエラーが発生したり、条件によって早めに return したりして、関数の最後まで到達しないことがあります。このとき、後片付けとして予定していた処理が実行されないと、思わぬ問題を引き起こします。
例えば、ファイルやデータベースへの接続を開いた後、使い終わったら「閉じる」処理を書いておくのが基本です。ところが、途中の分岐で return して抜けたときにその「閉じる」処理が実行されないと、ファイルディスクリプタやデータベースへのコネクションを掴んだままになり、リソースリークにつながります。
以下は、ファイルを開いて末尾で file.Close() を呼ぶ書き方ですが、「ここでファイルを読み書きする処理」の途中で return すると file.Close() は呼ばれずに終わります。
file, err := os.Open("data.txt")
if err != nil {
return err
}
// ここでファイルを読み書きする処理(この途中で return するとどうなる?)
file.Close()
関数内の分岐が増えるほど、閉じ忘れが起きる箇所も増え、ミスに気付きにくくなります。
Go はこの課題に対して、「関数を抜けるときに必ず実行する処理」を登録できる defer を提供しています。開いた直後に defer file.Close() を書けば、途中でどの経路から return しても、ファイルは必ず閉じられます。
4.1 基本の書き方
defer を関数呼び出しの前に付けると、その処理はこの関数を抜けるときに実行されます。複数書いた場合は登録の逆順で実行されます。書き方は以下のとおりです。
defer 関数呼び出し // この関数を抜けるときに実行される
次のコードは、defer に登録した処理が、関数を抜けるタイミングで登録の逆順に実行されることを示す例です。
package main
import "fmt"
func main() {
fmt.Println("開始")
defer fmt.Println("終了処理1")
defer fmt.Println("終了処理2")
fmt.Println("処理中")
}
以下のような実行結果が表示されます。
開始
処理中
終了処理2
終了処理1
開始 と 処理中 は普通の順で表示され、main を抜けるタイミングで defer に登録した「終了処理2 → 終了処理1」が登録の逆順で実行されました。
コードを解説します。
defer fmt.Println("終了処理1")
defer fmt.Println("終了処理2")
defer を関数呼び出しの前に付けると、その処理はこの関数(main)を抜けるときに実行されるように予約されます。書いた時点では実行されません。関数終了時には登録の逆順(LIFO: Last In First Out)で実行されます。
4.2 ファイル操作での利用
defer が実際にどう役立つのかを、ファイルの書き込みで確認します。ファイルを扱うときは「開いたら閉じる」がお決まりですが、書き込みの途中でエラーが起きたときにも、成功して最後まで到達したときにも、同じように閉じてほしいものです。開いた直後に defer file.Close() を登録しておくと、この「どちらの経路でも必ず閉じる」を1行で実現できます。
package main
import (
"fmt"
"os"
)
func writeData() error {
file, err := os.Create("data.txt")
if err != nil {
return fmt.Errorf("ファイルを作成できません: %w", err)
}
defer file.Close()
_, err = file.WriteString("Hello, defer!\n")
if err != nil {
return fmt.Errorf("書き込みに失敗: %w", err)
}
return nil
}
func main() {
if err := writeData(); err != nil {
fmt.Println("エラー:", err)
return
}
fmt.Println("書き込み完了")
}
以下のような実行結果が表示されます。
書き込み完了
正常に処理が完了したので 書き込み完了 が表示されました。実行後にはカレントディレクトリに data.txt が作成され、中に Hello, defer! が書き込まれています。もし途中で書き込みが失敗して return した場合でも、登録した defer file.Close() が実行されるため、ファイルは必ず閉じられます。
コードを解説します。
file, err := os.Create("data.txt")
if err != nil {
return fmt.Errorf("ファイルを作成できません: %w", err)
}
defer file.Close()
os.Create でファイルを作成し、成功したら直後に defer file.Close() を登録します。作成に失敗した場合は file が使えないため、その前で return して抜けます。登録する位置を「開いた直後」にしておくことで、以降のどこで return しても閉じられる形になります。
_, err = file.WriteString("Hello, defer!\n")
if err != nil {
return fmt.Errorf("書き込みに失敗: %w", err)
}
return nil
書き込みに失敗した場合は return して関数を抜けますが、その時点で先に登録した defer file.Close() が実行されるので、ファイルはきちんと閉じられます。正常に return nil で抜ける場合も同じです。
このパターンはファイルに限らず、リソースを掴む処理全般で使われます。例えばデータベース接続の解放(defer db.Close())や、ミューテックスのロック解除(defer mu.Unlock())でも、同じように「開いた/取得した直後に閉じる/解放する処理を defer で予約する」書き方が定石です。
4.3 パニックの回収(recover)
パニックの発生
Goでは通常のエラーは return err で戻しますが、実装バグや配列の範囲外アクセスといった予期しない状況に遭遇すると、panic(パニック)と呼ばれる異常終了が発生し、プログラム全体が停止します。
例えば、以下のコードは配列の範囲外アクセスによってパニックが発生する例です。
package main
import "fmt"
func main() {
fmt.Println("開始")
nums := []int{1, 2, 3}
fmt.Println(nums[10])
fmt.Println("この行は実行されない")
}
以下のような実行結果が表示されます。
開始
panic: runtime error: index out of range [10] with length 3
goroutine 1 [running]:
main.main()
/path/to/main.go:8 +0x25
exit status 2
開始 までは表示されますが、nums[10] で範囲外アクセスが起きた瞬間にパニックが発生してプログラムが停止するため、後続の fmt.Println("この行は実行されない") は実行されません。
recoverによる回収
複数の処理を順に実行するプログラムで、1件目でパニックが起きても残りの処理まで止めたくない場合があります。このとき defer と recover を組み合わせると、パニックを捕まえて処理を継続できます。書き方は以下のとおりです。recover は defer の中でしか意味を持たないため、無名関数を defer に登録するのが定石です。
defer func() {
if r := recover(); r != nil {
// パニックが起きたときの後処理(r はパニックに渡された値)
}
}()
次のコードは、safeRun の中で範囲外アクセスによって発生したパニックを recover で捕まえ、main の処理を継続する例です。
package main
import "fmt"
func safeRun() {
defer func() {
if r := recover(); r != nil {
fmt.Println("パニックを回収:", r)
}
}()
nums := []int{1, 2, 3}
fmt.Println(nums[10]) // ここでパニックが発生
}
func main() {
safeRun()
fmt.Println("処理を継続")
}
以下のような実行結果が表示されます。
パニックを回収: runtime error: index out of range [10] with length 3
処理を継続
safeRun の中でパニックが起きても、recover で捕まえたため main は停止せず、後続の 処理を継続 が出力されました。
コードを解説します。
defer func() {
if r := recover(); r != nil {
fmt.Println("パニックを回収:", r)
}
}()
defer に無名関数を登録し、その中で recover() を呼んでいます。recover() はパニックが発生していない場合は nil を返し、発生している場合はパニックの値(今回は runtime error: index out of range [10] with length 3)を返します。nil でなければ「捕まえた」と判定し、後処理をします。
recover は defer の中でしか意味を持ちません。複数の処理単位(例えば1件ずつのファイル処理や入力処理)の境界となる場所だけで使うのが典型的な使い方です。
5. まとめ
この章では、Goのエラーハンドリングを学びつつ、実際にエラーの伝播とラップ、errors.Is / errors.As による識別、defer によるリソース解放、panic / recover を体験しました。
- Goでは例外機構がなく、エラーは戻り値として明示的に返し、
if err != nilで分岐する - エラーは
errors.New/fmt.Errorf(%wでラップ)で作り、errors.Is/errors.Asで識別する - 独自の型に
Error() stringメソッドを実装すると、errorインタフェースを満たす独自エラー型として使える deferにより、「関数を抜けるときに必ず実行する処理」を書けて、リソース解放漏れを防げるpanicはプログラムを継続不能にする異常事態にだけ使い、通常のエラーはreturn errで伝播する- 複数の処理単位の境界では、
defer+recoverによりパニックを捕まえて処理を継続できる
次の章では、Goの並行処理を学びつつ、実際にgoroutine・channel・selectや、sync・context を組み合わせた並行処理を体験します。