Plan 9とGo言語のブログ

主にPlan 9やGo言語の日々気づいたことを書きます。

Goで関数呼び出しを繋げてパイプライン演算子を再現する

最近、Goで関数呼び出しを無限に繋げる書き方を気に入っています。文字で書いても伝わらないと思うので実例を挙げると、例えばこういう書き方。

repeat(yield)("しか", 1)("のこ", 3)("こし", 1)("たん", 2)

どうやって実現しているのかというと、自身を参照する型を作ればいいだけです。

type Emitter func(s string, n int) Emitter

func repeat(yield func(string) bool) Emitter

完全なコード例は以下のGo Playgroundを見てください。

このような、関数呼び出しを繋げる方法でパイプライン演算子を再現するとどうなるか?と思って試してみた記事です。

パイプラインを作る

パイプライン演算子を使うと、 c(b(a(10))) という呼び出しを 10 |> a |> b |> c のように書けます。左から右に読めるので、処理の流れを追いやすくなりますね。

話は変わって、いま所属している企業では関数型ドメインモデリングの読書会が行われています。この書籍ではパイプライン演算子を多用していますが、Goにはパイプライン演算子がありません。無くてもそれほど困らないものの、パイプライン自体は関数を無限に繋げていくものなので、最初に紹介した方法を使ってパイプラインを実現できないかなと考えました。

// 空文字列ならエラー
func require(s string) (string, error)

// printしてsを返す
func tee(s string) string

// 以下のように書けると嬉しいが、このまま実現はできない
result := pipe("hello world")(require)(tee)(strings.ToUpper)

なんだけど、実際は色々な課題があって上記のようには実現できません。

  • パイプラインの初期値や、計算途中の状態を保存する場所がない
  • 関数の戻り値が関数なので、最後に結果を返す手段がない
  • requirestringerror を返すので型が異なる

試行錯誤の結果、dmmf-go/internal/pipeでは少し不恰好だけど近しいものを実現できました。

result, err := pipe.Value("hello world").Catch(require)(tee)(strings.ToUpper).ValueErr()

以下で、実現のためにやったことの一部を紹介します。

計算の状態を保存する

まず、ここで実装するパイプラインでは関数呼び出しを繋げたいので、パイプライン型の基底型は関数です。

type pipe[T any] func(f func(v T) T) pipe[T]

このように定義すると pipe(f1)(f2) のように連続して呼び出せるのですが、 pipe[T] は関数なので任意の値を持たせることができません。具体的には、pipe[T] 型に パイプラインを識別する情報 が追加できません。そういった制約があるため、計算の状態を残すには「実行時に取れる情報」から決める必要があります。例えば実行時のコールスタックやゴルーチンIDなどが考えられますが、今回は関数ポインタをパイプライン識別に利用しました。

どういうことかというと、一般的に関数ポインタは関数ごとに1つですが、無名関数の場合は記述する毎に作られます。例えば以下の場合、

package main
func main() {
    f1 := func() { ... }
    f2 := func() { ... }
}

このとき、f1f2 は異なる関数ポインタを持ちます。内部的には、無名関数は main.main.func1main.main.func2 として作られるようですね。そしてGoは関数のインライン展開を行うので、以下の例でいえば pipe.Value の呼び出しをインライン展開できれば、関数ポインタをパイプラインの特定に使えます。

// pipe.Valueをインライン展開できれば、p1とp2の関数ポインタは異なるので識別できる
p1 := pipe.Value(10)
p2 := pipe.Value(20)

インライン展開されるためには、複雑な関数ではないことが条件です。

Go 1.22.5では、次のような複雑度ならインライン展開されます。

var states map[uintprt]*state

func Value[T any](v T) pipe[T] {
    s := &state{}
    var f pipe[T]
    f = func(g func(T) T) pipe[T] {
        s.current = g(s.current)
        return f
    }
    addr :=  **(**uintptr)(unsafe.Pointer(&f))
    states[addr] = s
    return f
}

ここで、本当は reflect.Value.Pointer を使いたいけれど、使ってしまうとインライン展開されなかったので、関数ポインタの取得を unsafe.Pointer で行っています。

エラーを扱う

Goでは型にメソッドを実装できるので、関数にメソッドを追加しました。計算の状態を保存できるようになったので、これはすぐに実装できます。

func (p pipe[T]) Catch(f func(T) (T, error)) pipe[T] {
    addr :=  **(**uintptr)(unsafe.Pointer(&p))
    s := states[addr]
    s.current, s.err = f(s.current)
    return p
}

pipe[T]func(f func(T) (T, error)) pipe[T] としてもいいのですが、エラーを常に求められるのも使いづらいなと感じたので、そのようにはしませんでした。

結果を返す

上記と同様に、こちらもメソッドを実装して対応しました。

func (p pipe[T]) ValueErr() (T, error) {
    addr :=  **(**uintptr)(unsafe.Pointer(&p))
    s := states[addr]
    delete(states, addr)
    return s.current, s.err
}

作ってみた感想など

上記の他にも、関数呼び出しを繋げるために色々と工夫をしています。

  • パイプラインの途中でエラーが発生した場合は後続の関数を呼ばない
  • パイプラインをコピーさせないように pipe[T] 型を公開しない
    • インライン展開された場所に依存するので、例えば再帰呼び出しされると関数ポインタが競合する
    • 他の関数引数や戻り値に pipe[T] を使えないのでコピーされるリスクを減らせる
  • 型の変換をするために別の関数を使って行う
    • Go 1.22.5時点ではメソッドに型パラメータを持たせられないので仕方なく

今回、パイプラインを作ってみてどうかでいえば、エラー処理を一箇所にまとめられるのは便利かなと思いました。次のコードは書籍の例ですが、エラーを最後に判定するだけになっていて若干すっきり記述できています。

func PlaceOrder(order *UnvalidatedOrder) {
    var (
        validateOrderConfig    ValidateOrderConfig
        priceOrderConfig       PriceOrderConfig
        acknowledgeOrderConfig AcknowledgeOrderConfig
    )
    p1 := pipe.Value(order)
    p2 := pipe.From(p1, validateOrderConfig.ValidateOrder)
    p3 := pipe.From(p2, priceOrderConfig.PriceOrder)
    p4 := pipe.From(p3, acknowledgeOrderConfig.AcknowledgeOrder)
    v, err := p4.ValueErr()
}

ただし、関数を繋げられる必要はあまりないかもしれません。関数呼び出しを繋げるために不要な制限を持ち込んでしまっているので、普通に構造体を返した方が扱いやすいと思います。まあ試してみた記事の結論としては、これで十分でしょう。