Go

Goのエラーハンドリングを綺麗に書くイディオムはないのか考えてみた

だいぶ昔の記事ですが、
Golang Error Handling lesson by Rob Pike - Block Rockin’ Codes
の、自分なりの理解をメモします。

if err != nil { ... }が多すぎるコード

Goは例外機構がありませんので、戻り値を使ってエラーを伝搬していく必要があります。

func doSomething() error {
    resA, err := doA()
    if err != nil {
        return err
    }

    resB, err := doB(resA)
    if err != nil {
        return err
    }

    resC, err := doC(resB)
    if err != nil {
        return err
    }

    err := doD(resC)
    if err != nil {
        return err
    }
}

もちろんこれは擬似コードなので、こんなに酷いものにいつも出くわすわけではありません。
が、Go言語あるあるですし、プログラマーとしては、なんとか綺麗に書きたいと思うもの。イディオム的な解法を探ってみます。

同じコードの繰り返しの場合

冒頭の記事のコード例は、特徴がありました。binary.Read()およびエラーチェックが繰り返されている、というものです。
汎用性を上げて擬似コードにするとこんな感じでしょうか。

  1. doA(int)を呼ぶ
  2. エラーチェックする

の繰り返し。

func doSomething(i int) error {
    resA, err := doA(i)
    if err != nil {
        return err
    }

    resB, err := doA(resA)
    if err != nil {
        return err
    }

    resC, err := doA(resB)
    if err != nil {
        return err
    }

    _, err := doA(resC)
    if err != nil {
        return err
    }
}

Rob Pike先生の解説では、これを綺麗にするために構造体を用意していました。

  1. エラーの状態を持つことのできるオブジェクトを用意し、元々の処理をオブジェクトの関数に修正する。
  2. エラー状態であれば、何もしないようにする。
// コードを綺麗にするためだけに作った構造体。当然、exportの必要はない
type doerA struct {
    err error
}

func (d *doerA) doA(r int) int {
    if d.err != nil {
        return 0
    }
    var resp int
    resp, d.err = doA(r)
    return resp
}

このdoerAを使うと、doSomething()を少し綺麗にすることができます。

func doSomething(i int) error {
    d := new(doerA)
    resA := d.doA(i)
    resB := d.doA(resA)
    resC := d.doA(resB)
    _ = d.doA(resC)
    if d.err != nil {
        return d.err
    }
    return nil
}

if err != nil { ... }が一箇所に固まり、すっきりしましたね。ちゃんと、エラーが起きた後は処理がスキップされているはずです。

複数のエラー処理の寄せ集めの場合

Rob Pike先生の手法は、同じ処理の繰り返しだった場合に使えるものです。
これが doA, doB, doC, doD... のように違う処理が並んでいる場合は、構造体+メソッドの形に書き換えてみても、読みやすくはなりません。if文の位置が移動するだけです。

// コードを綺麗にするためだけに作った構造体。当然、exportの必要はない
type doer struct {
    err error
}

func (d *doer) doA(r int) int {
    if d.err != nil {
        return 0
    }
    var resp int
    resp, d.err = doA(r)
    return resp
}

// 処理内容が違うから、別になっちゃった…
func (d *doer) doB(r int) int {
    if d.err != nil {
        return 0
    }
    // ...
}

func (d *doer) doC(r int) int {
    // ...
}

func (d *doer) doD(r int) int {
    // ...
}

これだと、むしろfunc()の定義分だけコードが複雑になっている気がしますね。素直に if err != nil { ... } を書いたほうがマシっぽい。

『それでもif err != nil { ... }を書きたくないでござる!』をやるとしたら

ちょっと整理しましょう。
結局、我々がやりたかったことは

  • エラーが起きたら以降の処理を中断し、大域脱出したい

の1点に行き着きます。その方がコードがスッキリするからで、例外機構だってこのために発明されたものですよね。

Go言語において大域脱出のようなことをするために使える道具といえば。。。私が思いつく限りはこの辺です。

  1. return
  2. break
  3. goto
  4. オブジェクトの状態を使って分岐してスキップする
  5. オブジェクトを使い分けてコード自体を分岐する
  6. panic
  7. &&による短絡評価

(1)は言わずもがな、if err != nil のイディオムで使っています。
(4)はRob Pike先生の手法ですね。
(5)は、NullObjectパターンのようなイメージを言っていますが、ちょっとしたコードを綺麗にするためだけに使うには、無駄なコードが増えすぎます。(空の関数を大量に用意するハメになる)

もちろん、完璧に実現してくれるのはpanicでしょうが、Go的に非推奨ですし、それを使ったら今まで何のために頑張ってたのか、、という。

では、breakgotoを使えば、returnより読みやすくなるのかと言うと、、、そんなことはなさそうです。
使うキーワードを変えたところで、if文は必要で、returnを書くのと大差ないからです。

短絡評価も、boolを返す場合だけ使えるので、イマイチ汎用性がない…。あらゆる処理を汎用的にboolを返す形にするのは現実的ではないし、↓は難しそうだな。。。

ok := doA() && doB() && doC() && doD()
if !ok {
    // 何とかしてerrを取得する
    return err
}

switchとforを組み合わせれば何とかなりませんか

ええい、ならばこれならどうだ!

package main

import (
    "errors"
    "fmt"
)

func main() {
    res, err := doSomething(99)
    fmt.Println(res, err)
}

func doSomething(initial int) (int, error) {
    var (
        err                    error
        resA, resB, resC, resD int
    )
    for i := 0; i < 4; i++ {
        switch i {
        case 0:
            resA, err = doA(initial)
        case 1:
            resB, err = doB(resA)
        case 2:
            resC, err = doC(resB)
        case 3:
            resD, err = doD(resC)
        }
        if err != nil {
            return resD, err
        }
    }
    return resD, err
}

func doA(i int) (int, error) {
    fmt.Println(i)
    if i > 100 {
        return 0, errors.New("100以上禁止")
    }
    return i + 1, nil
}
func doB(i int) (int, error) { return doA(i) }
func doC(i int) (int, error) { return doA(i) }
func doD(i int) (int, error) { return doA(i) }

ちゃんとエラーが起きてからは処理がスキップされますし、
割とマシなコード量で実現できてる気がする!するが、、、

  • インデント2段になっちゃった
  • 変数宣言が関数の冒頭に集まってしまった… (これも読みにくいのでは?)
  • 無駄にコードの流れが追いにくい
  • caseの連番をtypoしたら死ぬ

などなど…。あとはまあ、コードレビューで怒られそうだな…。

私の中の結論

いろいろと脳内で考えてみたけれど、Go1系で、if err != nil { ... }をこれより短く、かつ分かりやすく書く方法は、ない

  • まとめられる特徴があるコードなら、まとめられることはあります。
  • しかし大抵の場合、書き換えると分かりにくくなる傾向がありそう

Go2に期待しましょ!