goでfunc()(object, func(), error)のようなファクトリー関数の扱いについてのメモ

何かのファクトリーを統一的に扱いたいみたいなことを考えたときにそれへの対応を考えることがある。 goの場合はそれぞれの状況で自分の手でコードを書いてつなげなくてはいけない。ファクトリーに限らないがこの種のバリエーション自体に言及しているのはwireやgo-aws-sdkのlambdaの部分なんかがそう。

それぞれのものが全部同じというわけではないのだけれど、今回は func(...)(object, func(), error) のような関数についてだけ考える(利用例)。

errorを含んだ場合

まず、errorを含んだ場合について考えてみる。例えば、DBというstructのようなものがあったとする1。これを返すOpen()のようなファクトリー関数があることにする。そしてerrorを持たない場合は以下のような感じで使われる。

db := Open(...)
return Use(db, ...)

これがerrorを返すように変更されると、errorハンドリングが必要になる。

db, err := Open(...)
if err != nil {
    return err // Useの戻り値がerrorの場合
}
return Use(db, ...)

単体で考えるとこれだけ。

cleanupを含んだ場合

同様にcleanupを含んだ場合は以下のようになる。特にDBがDBオブジェクトとしてではなく、セッションオブジェクトのようなものになったり、トランザクション(をラッピングしたもの)として扱いたくなった場合にこのような変更が起きる。

db, cleanup := Open(...)
defer cleanup()
return Use(db, ...)

errorとcleanupを同時に返すような場合

さて、ここが本題。errorとcleanupの両方に対応するときにどういう形のコードで組み合わせるべきかで少し悩んだ2

最初の方法はerrorを先にハンドリングする方法。

db, cleanup, err := Open(...)
if err != nil {
    return err
}
defer cleanup()
return Use(db, ...)

完全に同じではないが、net/http.Responseなどを扱っているときのClose()の扱いがこの形。エラーの時にはClose()の呼び出しを気にしなくて良い。

ここでの悩みどころは、取り扱い方を統一しようとしたときに本当にcleanupを呼ばずに済ませて良いのだろうか?ということ。

次の方法はcleanupを先にハンドリングする方法。

db, cleanup, err := Open(...)
defer cleanup()
if err != nil {
    return err
}
return Use(db, ...)

先のdeferを呼ぶことにすれば、必ずcleanupが呼ばれることが保証される。とはいえ、こちらは逆に本当に常にcleanupが呼ばれるのが正しいのだろうか?という疑問がすぐに出る。いや、もっと単純に、戻り値がnilだった時にすぐにpanicするのは危険では?という気持ちになってくる。

あるいはケースバイケースで都度呼び分けてというのは最悪で、消費者に常に負担を強いることになる3

ちょっと改良してnilチェックを加える。これが実は良い形と個人的には思っている。

db, cleanup, err := Open(...)
if cleanup != nil {
    defer cleanup()
}    
if err != nil {
    return err
}
return Use(db, ...)

deferは関数スコープ4なのでifを書いても困らない。エラーに関しても提供者側が気を遣うことでユーザー側が気にしなくても意思表明ができる。具体的には以下のような形。

たとえエラーであってもcleanupを呼んでほしいファクトリー関数においてはcleanupをしっかり返してあげれば良い。

return object, cleanup, err

逆に、エラーの時にcleanupをスキップしてほしいファクトリー関数においてはnilを返してあげれば良い。

return object, nil, err

この場合はcleanupがスキップされる。io.CloserのClose()もこのスタイルで書いてあげれば暫定的には対応できる5

rollback?

ところで、もう少し考えることがある。このような場合はどうだろう。

直接手書きするとこういうことになる。

isSuccess := true
defer func(){
    if isSuccess {
        commit()
        return
    }
    rollback()
}()
if err := Use(db, ...); err != nil {
    isSuccess = false
    return err
}
return nil

このハンドリングもいい感じに扱うことができないだろうか?つまるところファクトリー関数の中でいい感じに扱うことはできないだろうか?というのがちょっとした追加の悩みどころ。

これは暫定的な意見だけれど、context.Contextのようにsync.Onceで囲んだメソッドを用意してそこで制御してあげると良いのかもしれない。

type DB interface { // 別にinterfaceにする必要はないかも?今回は説明のため
    ...
    Failure(err error) error
}

type db struct {
    ...
    transaction *Transaction
    once sync.Once
    err error
}

func (db *db) Failure(err error){
  db.once.Do(func(){
      db.err = err
  })
  return err
}

func Open(...) (DB, func(), error) {
    t, err := newTransaction()
    db := &db{transaction: t}
    if err != nil {
        return db, nil, err
    }
    cleanup := func(){
        if db.err != nil {
            return db.transaction.Rollback(err)
        }
        return db.transaction.Commit()
    }
    return db, cleanup, err    
}

こうしてあげるとUseの中でFailure()を呼ぶ対応する必要があるが、何とかなる。

db, cleanup, err := Open(...)
if cleanup != nil {
    defer cleanup()
}    
if err != nil {
    return err
}
return Use(db, ...)

// func Use(db DB, ...) error{
// ...
// if err := cont(...); err != nil {
//    return db.Failure(err)
// }

別の方法としてはcallbackに対してerrorbackを返すようにするというものだけれど、これはもう使い手側が厳しくなってくると思う。

暫定的な意見の理由としては、Failure()を呼ぶのがだるくない?呼び忘れしそうじゃない?というのと本当に最初のエラーを捕まえるので良いの?というところ。

さいごに

ところで、こういう変更が一番めんどくさい(当初の値で良いのでmainで一回作成して渡しておけば良いよねというのがウソになり)。そして、この種の部分が一番先に見越して対応した気になっているとだるくなるだけの部分な気がしている(全部のコンポーネントをセッションとみなす対応はだるい)。

単純に言えば誰かがいい感じに代わりにやってほしい。


  1. interfaceかもしれない。その辺については言及していない。

  2. ちなみにどういう形式で返すべきかということには悩みはない。errorが再右端というのはlintを有効にしていればわかる。通常戻り値は最左端になるので、(object, func(), error) 以外考える必要はない。

  3. まぁそれでも良いという意思決定が随所にみられるのがgoという気もするが。正確に言えばその部分の自由度は最大にしておきたい的な意味合い。

  4. この言葉の定義が正しいかちょっと怪しい。言いたいのはブロックスコープではないのでifで囲っても特に困らないみたいなこと。

  5. Close()の失敗の対応がちょっと面倒。ログに出力するだけにして良いのなら。