Go が for ループをやめるために足りないもの

ジェネリクスの話題になると常に出てくるのが、 for ループの代わりに関数型スタイルで書きたいという要望です。 for ループで書くのは、可読性が悪く、筋力がいるとまで言う人もいます。

しかし、ジェネリクスが追加されても、このスタイルのプログラミングは実用的にはなりません。ジェネリクス以外にも足りない部分がたくさんあるのです。

例えば、次のようなコードを考えてみましょう。

type PointLog struct {
    ID     int64
    UserID int64
    Point  int32
}

// 今の書き方
func UserTotalScore(log []PointLog, userID int64) int64 {
    var t int64 = 0
    for _, p := range log {
        if p.UserID == userID {
            t += int64(p.Point)
        }
    }
    return t
}

ジェネリクスが入ったとして、 Filter, Map, Reduce で実装してみましょう。構文は仮とします。 なお、コンパイルが通らないコードなので、間違いがあっても気にしないでください。だいたいこういうコードになるという雰囲気だけ見てください。

func[T] Filter(f func(T) bool, xs []T) []T {...}
func[T,U] Map(f func(T) U, xs []T) []U {...}
func[T] Reduce(f func(T,T) T, start T, xs []T) T {...}

// 関数型スタイルの書き方?
func UserTotalScoreFP(log []PointLog, userID int64) int64 {
    return Reduce(func(x, y int64) int64 { return x + y }, 0,
        Map(func(p PointLog) int64 { return int64(p.Point) },
        Filter(func(p PointLog) bool { return p.UserID == userID }, log)))
}

可読性があがり、筋力がいらなくなり…ませんね。短い関数を省略形で書く、ラムダ式と呼ばれたりする記法が足りません。また、ラムダ式の引数や戻り値の型を書かなくて良いようにするためには、型推論の機能も全然足りません。

なお、ここまででも、「実用」するならコンパイラの最適化機能を向上しないといけないかも知れません。 ループで書いたときに比べて、大量の関数呼び出しと、それに伴う一時変数が増えます。上の例では簡単のために引数をスライスで受け渡ししていますが、こういう一時スライスはループで書くには明らかに冗長で、現在の典型的なGoのコードには出現しないものです。そのため、今のGoの最適化機能ではこの一時スライスを除去できません。

スライスの代わりに、値を逐次的に取り出せる一時オブジェクトを使う方式も考えられますが、それも今のGoの最適化機能では for ループと同じ効率になるとは限りません。

さて、ジェネリクスと最適化機能に加えて、ラムダ式とそのための型推論をGoに追加したとしましょう。ループで書いたときと比べてみてください。

// 関数型スタイルの書き方
func UserTotalScoreFP(log []PointLog, userID int64) int64 {。
    return Reduce((x,y) -> x+y, 0,
        Map((p) -> int64(p.Point),
        Filter((p) -> p.UserID == userID, log)))
}

大分スッキリしてきましたね。でも、処理の流れが右から左(サンプルコードでは改行してるので下から上)になっているので、これを左から右(上から下)に変えるJavaのStream APIのようなものを用意したほうがより多くの人にとって可読性が高くなるかも知れません。ついでに Stream API から mapToInt と IntStream も借りて、Reduceの代わりにSumを使いましょう。

// Stream APIもどきを使った書き方
func UserTotalScoreStream(log []PointLog, userID int64) int64 {
    return Stream(log).
        Filter((p) -> p.UserID == userID).
        MapToInt64((p) -> int64(p.Point)).
        Sum()
}

さて、元のコードをもう一度書いておきます。どれくらい可読性が悪く、筋力が必要だったでしょうか?

// 今の書き方
func UserTotalScore(log []PointLog, userID int64) int64 {
    var t int64 = 0
    for _, p := range log {
        if p.UserID == userID {
            t += int64(p.Point)
        }
    }
    return t
}

脱線しますが、個人的にはPythonHaskellの内包表記が、可読性が高く必要な筋力も少ないと思います。

user_totalscore = sum(x.point for x in log if x.userID == userID)

Goに移植するとしたら、上のジェネレータ内包表記(やHaskellの遅延リストの内包表記)は難しいので、リスト内包を追加するのが良さそうです。Pythonの構文を借りるなら、

func SumInt32(xs []int32) int64 {...}  // 戻り値が int64 なのがポイント

t := SumInt32([]int32{x.Point for _, x := range log if x.UserID == userID})

この SumInt32 が不格好に見えるかも知れませんが、それを Sum にするのにはジェネリクスではなくオーバーロードでも可能です。 例えば上の例では引数が int32 の配列ですが、オーバーフローを考えると合計は int64 にしたいかもしれません。こういった引数と戻り値の型が非対称な同名の関数を作りたいならオーバーロードのほうがシンプルです。

オーバーロードの proposal も出ていますが、さて、ジェネリクスオーバーロード、両方追加するべきでしょうか?僕にはわかりません。脱線はここまでにします。


さて、まとめます。関数型スタイルの書き方を現実的にするには、Goを次のように強化する必要がありました。

これらの機能を全部入れようとすると、コンパイラとリンカを複雑にし、コンパイル時間とバイナリサイズを大きくする危険性があります。

なお、私は言語設計者ではないので、まだ足りない大きな部分があるかも知れません。気づいた方は教えてください。


教えてもらったもの

今の panic() はあまり推奨されない機能だし、中断機構を実装するにもなにかしら言語に影響が出そうですね。

このブログに乗せているコードは引用を除き CC0 1.0 で提供します。