【Go】エラーハンドリングの基本:if err != nilの流儀

Go

こんにちは、かつコーチです。

Go言語を書き始めた人が最初にとまどうのが、エラー処理の書き方だと思います。

「なんでtry-catchがないんだろう」「if err != nilばっかりでコードが縦に伸びる」。

そう感じたことがある人は多いはずです。

この記事では、Goのエラーハンドリングの基本思想と書き方、そして実際につまずきやすいポイントを、私の実体験も交えて解説します。

読み終える頃には、if err != nilの意味とGo流の作法が理解できるはずです。

エラーハンドリングとは?

例外機構がないGoの設計思想

多くの言語には例外機構(プログラム実行中の異常を専用の仕組みで検知し、通常の処理フローから外れて対応するしくみ)があります。

JavaやPythonならtry-catchtry-exceptを使い、エラーが起きた処理を外側でまとめて捕まえます。

ところがGoには、この例外機構がありません。

Goの設計では、エラーは戻り値(関数が呼び出し元に返す値)として明示的に返す、というスタイルを取っています。

つまりGoでは「エラーは特別な仕組みで飛んでくるもの」ではなく「関数が返す普通のデータの一種」として扱われます。

なぜ戻り値でエラーを返すのか

この設計には理由があります。

例外機構は便利な反面、どこでエラーが発生し、どこで捕まえられるのかがコードを読むだけでは分かりにくくなりがちです。

一方、Goのように戻り値でエラーを返すスタイルだと、関数を呼び出すたびに「このエラーは今すぐ処理すべきか」を強制的に意識させられます。

読みにくいと言われがちなif err != nilの連続は、裏を返せば「エラー処理を後回しにさせない」ためのGoらしい仕組みだと言えます。

基本の書き方

手順1: errorインターフェースを理解する

Goでエラーを表す型はerrorというインターフェース(実装方法を問わない「役割の契約書」のようなもの。中身がどんな型でも、契約書に書かれたメソッドさえ持っていればOKというルール)です。

errorインターフェースは、Error() stringというメソッドを1つ持つだけのシンプルな契約書です。

package main

import "fmt"

type error interface {
	Error() string
}

func main() {
	fmt.Println("errorはこういうインターフェースです")
}

実際には自分でこの定義を書く必要はなく、標準ライブラリに用意されています。

手順2: if err != nilの基本形

Goで関数がエラーを返す場合、多くは戻り値の最後にerror型を追加します。

呼び出し側は、その戻り値がnil(値が「何もない」ことを表す特別な値)かどうかをチェックします。

package main

import (
	"fmt"
	"strconv"
)

func main() {
	// 文字列を数値に変換する
	num, err := strconv.Atoi("123")
	if err != nil {
		fmt.Println("変換に失敗しました:", err)
		return
	}
	fmt.Println("変換成功:", num)

	// わざと失敗させる
	num2, err2 := strconv.Atoi("abc")
	if err2 != nil {
		fmt.Println("変換に失敗しました:", err2)
		return
	}
	fmt.Println("変換成功:", num2)
}

このように「エラーが返ってきたら、その場ですぐ処理する」のが基本の流儀です。

手順3: errors.Newとfmt.Errorfでエラーを作る

自分の関数でエラーを返したい場合、標準ライブラリのerrorsパッケージやfmtパッケージを使います。

errors.Newはシンプルな文字列からエラーを作る関数です。

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

一方fmt.Errorfは、変数を埋め込んだメッセージを作りたいときに便利です。

package main

import (
	"fmt"
)

func validateAge(age int) error {
	if age < 0 {
		return fmt.Errorf("年齢は0以上である必要があります(入力値: %d)", age)
	}
	return nil
}

func main() {
	if err := validateAge(-5); err != nil {
		fmt.Println(err)
	}
}

%dのような書式指定子を使えば、エラーメッセージに具体的な値を埋め込めるので、デバッグ時にとても役立ちます。

つまずきやすい設定・注意点

エラーを返す関数を作るとき、正常時は必ずnilを返すことを忘れないようにしましょう。

error型の戻り値を用意したのに正常時も何かエラー値を返してしまうと、呼び出し側は常にエラーだと誤判定してしまいます。

また、複数のエラーチェックが連続する場合でも、1つずつ丁寧にif err != nilを書くのがGoの流儀です。

まとめて処理しようとせず、素直に書いた方が結果的に読みやすいコードになります。

よくあるつまずきポイント・エラー対処

nil pointer dereferenceで実際にハマった話

私が実際にGoを書いていて一番よく出会い、かつ一番焦ったのがnil pointer dereferenceinvalid memory address or nil pointer dereferenceというエラーメッセージで表される、存在しないメモリ番地を参照しようとしたときに起きるエラー)です。

これはGo初心者が最も高い確率で踏むエラーだと思います。

私が実際に踏んだのは、外部APIからJSONを取得してデコードする処理でした。

package main

import "fmt"

type User struct {
	Name string
	Age  int
}

func findUser(id int) *User {
	// 本来はDBやAPIを検索する処理だが、
	// 見つからなかった場合は nil を返す
	if id != 1 {
		return nil
	}
	return &User{Name: "田中", Age: 30}
}

func main() {
	user := findUser(2)
	// ここでuserはnilなのに、いきなりフィールドにアクセスしてしまった
	fmt.Println(user.Name)
}

このコードを実行すると、次のようなエラーで落ちます。

panic: runtime error: invalid memory address or nil pointer dereference
[signal SIGSEGV: segmentation violation code=0x1 addr=0x0 pc=0x…]

私の場合、原因は完全に自分のチェック漏れでした。

「findUserは絶対に何か返してくるだろう」と思い込み、戻り値がnilかどうかの確認を省いてしまったのです。

さらに厄介だったのは、:=によって新しいスコープで変数が作られ、外側の変数を上書きしていないつもりが変数シャドーイング(同じ名前の変数を内側のスコープで新しく宣言してしまい、外側の変数とは別物になってしまうこと)を起こしていたことでした。

if文の中で:=を使ってエラーチェック用の変数を作ったつもりが、実は外側のuserとは別の変数になっていて、外側のusernilのまま参照され続けていたのです。

原因が分かるまで、ログを何度も仕込んで1時間近く格闘しました。

これを踏まえて、私が実践している対策は次の3つです。

❌ Before

user := findUser(2)
fmt.Println(user.Name) // userがnilかもしれないのに即アクセス

✅ After

user := findUser(2)
if user == nil {
	fmt.Println("ユーザーが見つかりませんでした")
	return
}
fmt.Println(user.Name)

ポインタを返す関数を呼んだら、フィールドにアクセスする前に必ずnilチェックを入れる。

これだけで、このエラーの大半は防げます。

エラーを握りつぶしてしまうミス

もう1つよくあるのが、エラーを受け取ったのに何もせず無視してしまうパターンです。

❌ Before

data, _ := readFile("config.json")
fmt.Println(string(data))

✅ After

data, err := readFile("config.json")
if err != nil {
	fmt.Println("設定ファイルの読み込みに失敗しました:", err)
	return
}
fmt.Println(string(data))

_でエラーを捨ててしまうと、問題が起きたときに原因を追う手がかりが消えてしまいます。

急いでいるときほどやりがちなので、私も気をつけている点です。

応用・一歩先の使い方

基本が身についたら、次はerrors.Iserrors.Asを使ったエラーの種類判定に挑戦してみましょう。

Go1.13以降では、エラーをラップ(fmt.Errorf%wを使って元のエラー情報を包んで返すこと)して、呼び出し元でその正体を調べられるようになっています。

package main

import (
	"errors"
	"fmt"
)

var ErrNotFound = errors.New("データが見つかりません")

func findData(id int) error {
	if id != 1 {
		return fmt.Errorf("findData失敗: %w", ErrNotFound)
	}
	return nil
}

func main() {
	err := findData(2)
	if errors.Is(err, ErrNotFound) {
		fmt.Println("これは「見つからない」エラーです")
	}
}

これができるようになると、単に「エラーがあるかないか」だけでなく「どんな種類のエラーか」に応じた処理の出し分けができるようになります。

まとめ

この記事のポイント

  • Goには例外機構がなく、エラーは戻り値として明示的に返す設計になっている
  • if err != nilは面倒に見えて、エラー処理を後回しにさせないGoらしい工夫
  • 自作のエラーはerrors.Newfmt.Errorfで作れる
  • nil pointer dereferenceは、ポインタのnilチェック漏れや変数シャドーイングが原因になりやすい
  • エラーを_で握りつぶさず、必ず何らかの形で処理する

次に読むべき記事

エラー処理の基本を押さえたら、次はGoの並行処理の入口である「goroutineの基本:軽量スレッドで並行処理する」に進んでみてください。

エラーハンドリングと同じくらい、Go初心者がつまずきやすいポイントを解説しています。

タイトルとURLをコピーしました