今川館

都内勤務の地味OLです

Goのエラー処理② panicとrecover

Goのpanicとrecover

Goのエラー処理は原則として例外を使わず、エラーを丁寧に処理すべき方針はわかったとして、Goの例外機構というものがどういう仕組なのか調べた。

まず、基本に忠実に、golang.jpの説明を読むと、

実践Go言語 - golang.jp

Recover

panicが呼び出されたとき(これには、配列のインデックスが範囲外であるときや、型アサーションに失敗したような暗黙的なものも含む)は、すぐさま、カレントの関数を停止し、ゴルーチンのスタックの巻き戻しを開始します。その途中、遅延指定されている関数をすべて実行します。この巻き戻しがゴルーチンのスタックの先頭にたどり着くと、プログラムは終了します。ただし、組み込み関数recoverを使うことで、ゴルーチンの制御を取り戻し、通常の実行を再開させることが可能です。

panicが発生すると即座に処理を打ち切るので、これが他の言語の例外に相当するものらしい。

「その途中、遅延指定されている関数をすべて実行します」とも言っているので、deferに無名関数を指定してrecoverしないとダメなんじゃないかと思ったら、ご丁寧にその後に明記されていた。

recoverを呼び出すと、巻き戻しを停止し、panicに渡された引数が返ります。巻き戻り中に実行できるコードは、defferで遅延指定された関数内のみなので、recoverは遅延指定された関数内でのみ役立ちます。

Pythonとの比較

例えば、リトライ可能な例外がサブルーチンから送出された場合は例外を握りつぶし、それ以外は上位ルーチンに送出するプログラムをPythonで書くとこんな感じだろう。

class Retryable(Exception):
    pass

try:
    do_something() // 何か例外を送出する可能性がある処理
except Retryable as ex:
    print(ex)
except Exception as ex:
    raise ex

普通にtry-exceptブロック使うでしょう。

対して、Goで似たようなことをやろうとすると、こんな感じだろう。

type Retryable struct{}

defer func() {
	switch p := recover(); p {
	case nil:
		// ここはパニックが起きなかった場合が該当する
		fmt.Println("No error.")
	case Retryable{}:
		err = fmt.Errorf("failed, retry")
		// err = errors.New("failed, retry")
	default:
		panic(p)
	}
}()

Pythonがブロックを扱う方法を採るのに対し、Goはブロックを監視する仕組みではなくあくまでも関数ベースの解決方法を採ることがよく分かる。

「実践Go言語」のページにはこのようにも書いてある。

他言語のブロックレベルのリソース管理に慣れたプログラマには、deferは特異に見えるかも知れませんが、deferのもっとも面白く、強力な活用法は、deferがブロックベースではなく、関数ベースであることによって生み出されます。panicとrecoverのセクションに、この可能性を示す例があります。

やっぱり、recoverはdeferと一緒に使わないと意味がないみたいだ。

また、Pythonのコンテキストマネージャはまさにブロックを監視する仕組みなので、Goで同じことをしようと思ったらdeferを使うのだろう。
とはいえ、Pythonでコンテキストマネージャを作るときはcontextlib.contextmanagerで関数をラップすれば簡単に作れるので、Goの流儀でPythonのコンテキストマネージャを考えると、Pythonには関数ベースの基本的なアプローチの上に、ブロックベースの仕組みに置き換える方法が用意されていると捉えるべきか(謎)。

contextlibを使う場合はこんな感じ。

from contextlib import contextmanager

class Retryable(Exception):
    pass

@contextmanager
def retrying():
    try:
        yield
    except Retryable as ex:
        print(ex)
    # except Exception as ex:
    #     raise ex

with retrying():
    do_something() // 何か例外を送出する可能性がある処理

実際に試した

せっかくなので、エラーを返却する原則的なやり方と、panic/recoverを利用する方法をそれぞれ使い分けるサンプルコードを書いてみた(以下)。

package main

import "fmt"

type Challenger struct {
	count     int
	threshold int
}

type Retryable struct{}

// 非公開メソッドの方はわざとpanicを利用する。
func (c *Challenger) attack() bool {
	if c.count < c.threshold {
		c.count++
		panic(Retryable{}) // パニック、しかしリトライ可能
	}
	if c.count > c.threshold {
		panic("No more chance.")
	}
	c.count++
	return true
}

// 公開メソッドはpanicを捕捉してリカバーをかける。
func (c *Challenger) Attack() (ok bool, err error) {
	defer func() {
		switch p := recover(); p {
		case nil:
			// ここはパニックが起きなかった場合が該当する
			fmt.Println("No error.")
		case Retryable{}:
			err = fmt.Errorf("Failed(%v times)", c.count)
		default:
			panic(p)
		}
	}()
	ok = c.attack()
	return
}

func main() {
	var c *Challenger
	c = &Challenger{0, 3}
	fmt.Println(c.Attack()) // false Failed(1 times)
	fmt.Println(c.Attack()) // false Failed(2 times)
	fmt.Println(c.Attack()) // false Failed(3 times)
	fmt.Println(c.Attack()) // true <nil>
	fmt.Println(c.Attack()) // 以下panic

	// panic: No more chance. [recovered]
	// 	panic: No more chance.
	//
	// goroutine 1 [running]:
	// panic(0x4b9f40, 0xc82000a400)
	// 	/usr/lib/go-1.6/src/runtime/panic.go:481 +0x3e6
	// main.(*Challenger).Attack.func1(0xc820039e30, 0xc820039e58)
	// 	/home/echizen/_go/src/github.com/oyakata/tokyo/trash/retry.go:35 +0x2fc
	// panic(0x4b9f40, 0xc82000a400)
	// 	/usr/lib/go-1.6/src/runtime/panic.go:443 +0x4e9
	// main.(*Challenger).attack(0xc820039e58, 0x52c678)
	// 	/home/echizen/_go/src/github.com/oyakata/tokyo/trash/retry.go:19 +0xd0
	// main.(*Challenger).Attack(0xc820039e58, 0x0, 0x0, 0x0)
	// 	/home/echizen/_go/src/github.com/oyakata/tokyo/trash/retry.go:38 +0x78
	// main.main()
	// 	/home/echizen/_go/src/github.com/oyakata/tokyo/trash/retry.go:49 +0x52c
	// exit status 2
}