Goで関数型プログラミングをする
以前 関数型言語と圏論 で、モナドやモノイドといった関数型言語の用語が圏論のどこに対応するかを整理した。そこで出てきた「純粋関数を合成する」「法則を型で保証する」という考え方は、Haskell のような言語だけのものではない。
Goに高階型はなく、モナド構文も用意されていない。それでも、関数型言語から借りられる部分は多い。特に、ジェネリクスと型定義を組み合わせると、「コンパイルが通った時点で、ありえない状態がかなり減っている」コードを書ける。
この記事では、関数型の考え方のうちGoに持ち込める部分を選び、初心者でも書ける小さな部品から実際のアプリケーションの形まで組み上げる。掲載するコードはすべて手元で go test を通したものを載せる。
まとめ
- 関数型から持ち込む価値が高いのは3つ。純粋関数(同じ入力なら同じ出力、副作用なし)、合成(小さい関数をつないで大きい処理を作る)、型で不正な状態を表現不能にすること
- Goで最も効くのは3つ目だ。
type Yen int64のような defined type と、非公開フィールド+ スマートコンストラクタを使うと、不正な値を持つ型の値をパッケージの外に作れなくできる。ただしゼロ値だけは外からでも作れるため、そこだけは別途守る必要がある - ジェネリクス(Go 1.18〜)で
Map/Filter/Reduce/Composeを自分で書ける。標準ライブラリのslicesにMapとFilterは入っていないため、必要なら10行程度の自前ヘルパを持つことになる - ただし
Composeの出番は限られる。1回の変換ならg(f(x))のほうが読みやすい。合成した結果を関数の値として渡す場面でだけ使う - 圏論記事で扱ったモノイド(結合律と単位元)はGoでも構造体1つで表現できる。結合律が成り立つ集約は、分割して並列に計算しても結果が変わらないという保証になる
- Go 1.27 でジェネリックメソッドが入った。
func (r Result[T]) Map[U any](f func(T) U) Result[U]が書けるので、OptionやResultをメソッドチェーンで扱えるようになった。手元の go1.27.0 で確認したところ、-langを go1.26 にするとgeneric method requires go1.27 or laterで弾かれる - ただしインターフェースのメソッドは型パラメータを持てない(
interface method must have no type parameters)。つまりFunctorやMonadを1つのインターフェースで抽象化することは今もできない。Goでは「モナドという抽象」ではなく「モナド的な型を個別に実装する」形になる - 設計方針は functional core, imperative shell に落とす。計算は純粋関数に閉じ込め、I/Oは外側の薄い層へ寄せる。テストが単なる入出力比較になり、テーブル駆動テストと相性がよい
- 代償はある。10000要素の
Filter+Mapは、素朴なforループに対して実測で約1.6倍遅く、割り当ても増えた。iter.Seqによる遅延パイプラインはさらに遅い場合がある。ホットパスではforループを選ぶ - 検証環境: Go 1.27.0(
linux/amd64)。バージョン依存の記述はその都度明示する
前提
- 対象読者: Goの構造体、インターフェース、
if err != nilによるエラー処理を一通り書いたことがある人。関数型言語の経験は前提にしない - 前提記事: 関数型言語と圏論。用語(射、合成、モノイド、モナド)の由来はこちらに書いた。読んでいなくても本記事は追えるようにする
- ねらい: Goを関数型言語のように「見せる」ことではなく、堅牢さにつながる部分だけを選んで取り込むこと
関数型から何を持ち込むか
関数型プログラミングを構成要素に分解すると、Goに持ち込みやすいものと、そうでないものに分かれる。
| 関数型の要素 | Goでの扱い | 難易度 |
|---|---|---|
| 純粋関数・参照透過性 | 規律として実践できる。言語の強制はない | 低 |
| 関数の第一級性・クロージャ | 最初から言語機能として存在する | 低 |
| 型による不正状態の排除 | defined type とスマートコンストラクタで実現できる | 低 |
高階関数(Map / Filter / Reduce) | ジェネリクスで自作する | 低 |
| モノイドによる集約 | 構造体1つで表現できる | 中 |
Option / Result | 型として定義できる。Go 1.27 からメソッドチェーンも可能 | 中 |
| 不変データ | 値レシーバとコピーで近づけられる。言語的な保証はない | 中 |
| 代数的データ型(直和型) | インターフェース+非公開メソッドで代用する。網羅性検査はない | 中 |
高階型(Functor の抽象化) | 表現できない | 不可 |
| 遅延評価・無限リスト | iter.Seq で部分的に可能 | 中 |
| 末尾呼び出し最適化 | 仕様として保証されない。深い再帰は避ける | 不可 |
圏論記事で書いた対応関係をGoの言葉に置き換えると、次のようになる。
| 圏論 | 関数型言語 | Go |
|---|---|---|
| 対象 | 型 | 型(Yen、Order、Result[T]) |
| 射 | 関数 a -> b | func(A) B |
| 合成 | g . f | Compose(f, g) |
| 恒等射 | id | func Identity[A any](a A) A |
| モノイド | Monoid 型クラス | Monoid[A] 構造体 |
| ファンクター | Functor 型クラス | 個別の Map メソッド(抽象化はできない) |
| Kleisli 合成 | >>= | Then(r, f) / r.ThenM(f) |
注目してほしいのは、モノイドの行とファンクターの行だ。関数型言語ではどちらも型クラスという同じ仕組みで表せるのに対し、Goでは Monoid[A] 構造体と型ごとの Map メソッドに分かれ、共通の仕組みへまとまらない。Goには高階型がないため、「Map を持つ型すべて」を1つのインターフェースで表せないからだ。この点は後述する。
型定義で不正な状態を作れなくする
関数型言語のうちGoに最も持ち込みやすいのは、型設計だ。ジェネリクスすら要らない。
defined type で意味を型に載せる
Goでは既存の型に名前を付けて別の型を作れる。これを defined type と呼ぶ。単なる別名ではなく、代入互換性のない独立した型になる。
1package domain
2
3type Yen int64
4type Quantity int
5
6func (y Yen) Add(other Yen) Yen { return y + other }
7func (y Yen) Mul(q Quantity) Yen { return Yen(int(y) * int(q)) }
8func (q Quantity) IsPositive() bool { return q > 0 }
Yen と Quantity はどちらも中身が整数だが、混ぜて代入するとコンパイルエラーになる。「金額を入れるべき引数に個数を渡していた」という種類のバグは、この時点で書けなくなる。
引数が int ばかりの関数は、呼び出し側が順番を間違えても気づけない。型を分けるコストは1行で、効果はコンパイル時に永続する。
スマートコンストラクタで「検証済み」を型にする
次は、バリデーションの結果を型として残す方法だ。構造体のフィールドを非公開にし、コンストラクタ経由でしか値を作れないようにする。
1type Email struct {
2 raw string
3}
4
5var ErrInvalidEmail = errors.New("invalid email")
6
7func NewEmail(s string) (Email, error) {
8 s = strings.TrimSpace(s)
9 if !strings.Contains(s, "@") || strings.HasPrefix(s, "@") || strings.HasSuffix(s, "@") {
10 return Email{}, fmt.Errorf("%w: %q", ErrInvalidEmail, s)
11 }
12 return Email{raw: s}, nil
13}
14
15func (e Email) String() string { return e.raw }
こうすると、domain パッケージの外から Email の中身を自由に詰めることはできなくなる。フィールドが非公開なので、次のコードはコンパイルを通らない。
1cannot refer to unexported field raw in struct literal of type domain.Email
ただし「パッケージの外にある Email はすべて検証済み」とまでは言えない。ゼロ値だけは外からでも作れるからだ。次のどれも NewEmail を経由しない。
1var e1 domain.Email // ゼロ値の変数
2e2 := domain.Email{} // 空の複合リテラルは他パッケージでも書ける
3var s struct{ To domain.Email } // 構造体のフィールドの初期値
4// encoding/json でデコードした結果も、非公開フィールドは埋まらないまま通る
つまりこの手法で消せるのは「不正な文字列が入った Email」であり、「空の Email」は残る。それでも、確認すべき異常が1種類に絞られる意味は大きい。関数のたびに if !isValidEmail(x) を書く必要はなくなり、代わりにゼロ値だけを見ればよくなる。
1func (e Email) IsZero() bool { return e.raw == "" }
関数型界隈でこの考え方は Parse, don’t validate と呼ばれる。「検証する(真偽値を返す)」のではなく「解析する(成功したら、より制約の強い型を返す)」ほうが、得た情報を型として持ち越せる、という主張だ。
直和型はインターフェースと非公開メソッドで代用する
関数型言語の代数的データ型(data Payment = Card | Bank | Point のような直和型)は、Goには存在しない。近いものはインターフェースで作る。非公開メソッドを1つ持たせると、他パッケージから実装を増やせなくなる。
1// PaymentMethod は閉じた型の集合。isPaymentMethod が非公開なので
2// 他パッケージで実装を追加できない。
3type PaymentMethod interface {
4 isPaymentMethod()
5 Fee(amount Yen) Yen
6}
7
8type CreditCard struct{ Brand string }
9type BankTransfer struct{ BankCode string }
10type Point struct{}
11
12func (CreditCard) isPaymentMethod() {}
13func (BankTransfer) isPaymentMethod() {}
14func (Point) isPaymentMethod() {}
15
16func (CreditCard) Fee(amount Yen) Yen { return Yen(float64(amount) * 0.03) }
17func (BankTransfer) Fee(amount Yen) Yen { return 330 }
18func (Point) Fee(amount Yen) Yen { return 0 }
ここで注意したいのは、Goのコンパイラは型switchの網羅性を検査しないことだ。Haskell や Rust のようにケースの書き漏らしを教えてはくれない。
言語仕様として default は必須でない。どの case にも当たらなければ、何もせず次の行へ進むだけになる。
1// default なし。Point{} を渡すと、何も起きずに通過する
2func notify(m PaymentMethod) {
3 switch m.(type) {
4 case CreditCard:
5 notifyCard()
6 case BankTransfer:
7 notifyBank()
8 }
9}
ただし、値を返す関数の中では話が変わる。default のない switch は終端文にならないため、後ろに return がないとコンパイルエラーになる。
1./sw.go:11:1: missing return
つまり「戻り値のある関数なら気づける、戻り値のない switch は黙って素通りする」という非対称がある。危ないのは後者だ。だから型switchで直和型を扱う場合は、default を自分で用意して想定外を実行時のエラーとして表に出す。
1func Charge(m PaymentMethod, amount Yen) (Yen, error) {
2 switch m.(type) {
3 case CreditCard, BankTransfer, Point:
4 return amount + m.Fee(amount), nil
5 default:
6 return 0, fmt.Errorf("unknown payment method: %T", m)
7 }
8}
もっとも、上の例のように振る舞い(Fee)をインターフェースのメソッドとして持たせられるなら、型switch自体を書かずに済む。分岐が必要になった時点で「これはメソッドにできないか」と考えるほうがGoらしい。
ジェネリクスで合成を書く
Go 1.18 で入ったジェネリクスによって、型ごとに Map を書き分ける必要がなくなった。標準ライブラリの slices パッケージには Sort や Contains はあるが、Map や Filter は入っていない(Go 1.27 時点)。よって、次の程度のヘルパは自分で持つことになる。
1package fp
2
3// Map は s の各要素に f を適用した新しいスライスを返す
4func Map[A, B any](s []A, f func(A) B) []B {
5 r := make([]B, 0, len(s))
6 for _, v := range s {
7 r = append(r, f(v))
8 }
9 return r
10}
11
12// Filter は pred を満たす要素だけを返す
13func Filter[A any](s []A, pred func(A) bool) []A {
14 r := make([]A, 0, len(s))
15 for _, v := range s {
16 if pred(v) {
17 r = append(r, v)
18 }
19 }
20 return r
21}
22
23// Reduce は init を初期値として左から畳み込む
24func Reduce[A, B any](s []A, init B, f func(B, A) B) B {
25 acc := init
26 for _, v := range s {
27 acc = f(acc, v)
28 }
29 return acc
30}
重要なのは、これらが元のスライスを書き換えない点だ。入力を破壊しないので、同じ入力に対して何度呼んでも同じ結果になる。
関数合成と恒等関数
圏論記事で扱った射の合成は、Goでは次のように書ける。
1// Compose は g ∘ f を返す
2func Compose[A, B, C any](f func(A) B, g func(B) C) func(A) C {
3 return func(a A) C { return g(f(a)) }
4}
5
6// Identity は恒等射
7func Identity[A any](a A) A { return a }
ここで率直な話をしておく。値を1回変換するだけなら、Compose を使わず g(f(x)) と書くほうが読みやすい。 Goの読み手にとって g(f(x)) は説明の要らない式だが、Compose(f, g) は定義を見に行かないと引数の順と適用の順の関係がわからない。1回きりの変換をわざわざ合成関数に置き換える理由はない。
Compose が効くのは、合成した結果そのものを関数の値として扱う場合だけだ。典型例は、適用するステップの並びが実行時に決まるケースだろう。
1func TestPipelineFromSteps(t *testing.T) {
2 trim := strings.TrimSpace
3 lower := strings.ToLower
4 collapse := func(s string) string { return strings.Join(strings.Fields(s), " ") }
5
6 // 設定やフラグでステップの並びが変わるような場面を想定する
7 steps := []func(string) string{trim, lower, collapse}
8 pipeline := fp.Reduce(steps, fp.Identity[string], fp.Compose[string, string, string])
9
10 if got := pipeline(" Hello WORLD "); got != "hello world" {
11 t.Errorf("got %q, want %q", got, "hello world")
12 }
13 // ステップが空なら恒等関数になる。これが単位元にあたる
14 empty := fp.Reduce(nil, fp.Identity[string], fp.Compose[string, string, string])
15 if empty("x") != "x" {
16 t.Errorf("identity law broken")
17 }
18}
ここで起きていることは、次の節のモノイドそのものだ。func(A) A という型の関数の集まりは、合成を二項演算、恒等関数を単位元とするモノイドになっている。だから空のステップ列でも壊れず、途中で分割して合成しても結果が変わらない。
単位律はテストとして固定できる。Compose(Identity[int], double) と Compose(double, Identity[int]) は、どちらも double と同じ振る舞いになる。
1func TestComposeIdentity(t *testing.T) {
2 double := func(n int) int { return n * 2 }
3 f := fp.Compose(fp.Identity[int], double)
4 g := fp.Compose(double, fp.Identity[int])
5 for _, n := range []int{-1, 0, 3, 100} {
6 if f(n) != double(n) || g(n) != double(n) {
7 t.Errorf("identity law broken at %d", n)
8 }
9 }
10}
なお、Goには可変長の型パラメータがない。上のように型が変わらない func(A) A なら畳み込みで任意個数をつなげるが、A -> B -> C -> D と型が変わっていく合成は、引数の個数ごとに定義を書くしかない。3個版・4個版を並べるか、Compose(Compose(f, g), h) と重ねるか、あるいは中間変数を並べる。ここは無理をせず、素直に h(g(f(x))) と書くほうがよい場面が多い。
モノイドと畳み込み
圏論記事で扱ったモノイドは、「結合的な二項演算」と「単位元」の組だった。Goでは構造体で表せる。
1// Monoid は二項演算と単位元の組
2type Monoid[A any] struct {
3 Empty A
4 Append func(A, A) A
5}
6
7func Fold[A any](m Monoid[A], s []A) A {
8 acc := m.Empty
9 for _, v := range s {
10 acc = m.Append(acc, v)
11 }
12 return acc
13}
14
15var SumInt = Monoid[int]{
16 Empty: 0,
17 Append: func(a, b int) int { return a + b },
18}
19
20var ConcatString = Monoid[string]{
21 Empty: "",
22 Append: func(a, b string) string { return a + b },
23}
わざわざ構造体にする利点は、集約のルールを値として渡せることにある。集約処理を1つ書けば、合計・最大・連結・集合の和など、モノイドを差し替えるだけで再利用できる。
さらに実務的な意味がある。結合律が成り立つなら、入力を分割して別々に集約し、後から結合しても結果が変わらない。つまり並列化してよい、という判断の根拠になる。
1// 分割して集約しても結果が変わらないことをテストで固定する
2func TestFoldIsAssociative(t *testing.T) {
3 xs := []int{1, 2, 3, 4, 5, 6, 7, 8}
4 whole := fp.Fold(fp.SumInt, xs)
5 left := fp.Fold(fp.SumInt, xs[:4])
6 right := fp.Fold(fp.SumInt, xs[4:])
7 if whole != fp.SumInt.Append(left, right) {
8 t.Errorf("fold is not associative: %d != %d", whole, fp.SumInt.Append(left, right))
9 }
10}
逆にいうと、結合律が成り立たない集約(平均、減算、順序依存の上書きなど)を並列化すると壊れる。「並列にしてよいか」を感覚ではなく法則で判断できる点が、この抽象の実利になる。
Option と Result をGoで扱う
Maybe や Either に相当する型もGoで定義できる。
1package result
2
3type Result[T any] struct {
4 value T
5 err error
6}
7
8func Ok[T any](v T) Result[T] { return Result[T]{value: v} }
9func Err[T any](err error) Result[T] { return Result[T]{err: err} }
10func (r Result[T]) IsOk() bool { return r.err == nil }
11func (r Result[T]) Unwrap() (T, error) { return r.value, r.err }
12
13// FromGo は Goの (T, error) 慣習を Result へ持ち上げる
14func FromGo[T any](v T, err error) Result[T] {
15 if err != nil {
16 return Err[T](err)
17 }
18 return Ok(v)
19}
圏論記事で bind(>>=)と呼んだ操作は、Goでは次の関数になる。失敗している場合は後続を呼ばずに短絡する。
1// Then は Kleisli 合成。失敗しうる処理をつなぐ
2func Then[A, B any](r Result[A], f func(A) Result[B]) Result[B] {
3 if r.err != nil {
4 return Err[B](r.err)
5 }
6 return f(r.value)
7}
Go 1.27 のジェネリックメソッド
長いあいだGoでは、メソッドに型パラメータを書けなかった。そのため Then や Map はパッケージレベルの関数にするしかなく、r.Then(f).Map(g) のようなチェーンは書けなかった。
Go 1.27 でこの制限が外れた。手元の go1.27.0 で確認したところ、次のコードがコンパイルされ、動作する。
1// Go 1.27 以降: メソッドが自分の型パラメータを持てる
2func (r Result[T]) Map[U any](f func(T) U) Result[U] {
3 if r.err != nil {
4 return Err[U](r.err)
5 }
6 return Ok(f(r.value))
7}
8
9func (r Result[T]) ThenM[U any](f func(T) Result[U]) Result[U] {
10 if r.err != nil {
11 return Err[U](r.err)
12 }
13 return f(r.value)
14}
go.mod の go ディレクティブを 1.26 に下げると、次のように拒否される。言語バージョンで制御されている機能だとわかる。
1./main.go:12:24: generic method requires go1.27 or later (-lang was set to go1.26; check go.mod)
これでメソッドチェーンが書けるようになった。次のテストは実際に通る。
1func TestResultChain(t *testing.T) {
2 parse := func(s string) result.Result[int] { return result.FromGo(strconv.Atoi(s)) }
3 half := func(n int) result.Result[int] {
4 if n%2 != 0 {
5 return result.Err[int](fmt.Errorf("%d is odd", n))
6 }
7 return result.Ok(n / 2)
8 }
9 ok := parse("10").ThenM(half).Map(strconv.Itoa)
10 if v, err := ok.Unwrap(); err != nil || v != "5" {
11 t.Fatalf("got %q, %v", v, err)
12 }
13 ng := parse("7").ThenM(half).Map(strconv.Itoa)
14 if _, err := ng.Unwrap(); err == nil {
15 t.Fatal("want error")
16 }
17}
スライス版も同様にチェーンできる。
1type Slice[T any] []T
2
3func (s Slice[T]) Map[U any](f func(T) U) Slice[U] {
4 r := make(Slice[U], 0, len(s))
5 for _, v := range s {
6 r = append(r, f(v))
7 }
8 return r
9}
10
11// Filter は型が変わらないので、Go 1.26 以前でもメソッドとして書けた
12func (s Slice[T]) Filter(pred func(T) bool) Slice[T] {
13 r := make(Slice[T], 0, len(s))
14 for _, v := range s {
15 if pred(v) {
16 r = append(r, v)
17 }
18 }
19 return r
20}
21
22// 使用例: Slice[int]{1, 2, 3, 4, 5}.Filter(isOdd).Map(strconv.Itoa) は ["1" "3" "5"]
型が変わらない Filter は以前からメソッドにできた。型が T から U へ変わる Map だけが書けなかった、というのが Go 1.27 以前の制約だった。
それでも高階型はない
ジェネリックメソッドが入っても、インターフェースのメソッドは型パラメータを持てない。次のコードはコンパイルできない。
1./iface.go:4:5: interface method must have no type parameters
つまり「Map を持つ型すべて」を表すインターフェース、すなわちファンクターやモナドの抽象は、今も書けない。Goでできるのは、Option や Result といった具体的な型それぞれに Map や Then を実装することまでだ。「モナドという抽象を共有する」段階には届かない。
これは欠陥というより設計上の割り切りだと考えたほうがよい。抽象の層が1つ減る代わりに、コードを読むときに追う定義も減る。
公開APIは (T, error) のままにする
Result[T] は便利だが、Goのエコシステム全体は (T, error) を前提に動いている。errors.Is / errors.As、defer、既存ライブラリのシグネチャ、いずれも error を返す形に最適化されている。
現実的な折衷案としては、次の使い分けを勧めたい。
- パッケージの公開APIは
(T, error)のままにする - パッケージ内部で、多段の変換パイプラインを書くときだけ
Result[T]を使う - 境界では
FromGo(v, err)とUnwrap()で相互変換する
こうすると、利用者はGoらしいAPIのままで、実装側は短絡評価の恩恵を受けられる。
実践: functional core, imperative shell
ここまでの部品を使って、小さなアプリケーションを組む。題材は「注文のCSV行を読み込んで、顧客ごとの合計金額を出す」処理にする。
設計の指針は functional core, imperative shell だ。計算部分をすべて純粋関数にまとめ、ファイルやネットワークといったI/Oは外側の薄い層に押し出す。関数型言語で IO モナドが担う「副作用を端に寄せる」という発想を、Goでは構造の分離として実現する。
純粋なコア
1package app
2
3type Order struct {
4 ID string
5 Customer domain.Email
6 Unit domain.Yen
7 Qty domain.Quantity
8}
9
10func (o Order) Total() domain.Yen { return o.Unit.Mul(o.Qty) }
11
12// ParseOrder は純粋関数。同じ入力なら常に同じ結果を返し、I/Oをしない
13func ParseOrder(line string) result.Result[Order] {
14 cols := strings.Split(line, ",")
15 if len(cols) != 4 {
16 return result.Err[Order](fmt.Errorf("want 4 columns, got %d: %q", len(cols), line))
17 }
18 email, err := domain.NewEmail(cols[1])
19 if err != nil {
20 return result.Err[Order](err)
21 }
22 unit, err := strconv.ParseInt(strings.TrimSpace(cols[2]), 10, 64)
23 if err != nil {
24 return result.Err[Order](fmt.Errorf("unit price: %w", err))
25 }
26 qty, err := strconv.Atoi(strings.TrimSpace(cols[3]))
27 if err != nil {
28 return result.Err[Order](fmt.Errorf("quantity: %w", err))
29 }
30 return result.Ok(Order{
31 ID: strings.TrimSpace(cols[0]),
32 Customer: email,
33 Unit: domain.Yen(unit),
34 Qty: domain.Quantity(qty),
35 })
36}
37
38func Validate(o Order) result.Result[Order] {
39 if !o.Qty.IsPositive() {
40 return result.Err[Order](fmt.Errorf("order %s: quantity must be positive", o.ID))
41 }
42 if o.Unit < 0 {
43 return result.Err[Order](fmt.Errorf("order %s: unit price must not be negative", o.ID))
44 }
45 return result.Ok(o)
46}
47
48// 失敗しうる2段階を合成する
49func ParseAndValidate(line string) result.Result[Order] {
50 return result.Then(ParseOrder(line), Validate)
51}
集約はモノイド的な畳み込みになる。顧客ごとの合計は加算なので、入力の順番に依存しない。
1func SumByCustomer(orders []Order) map[domain.Email]domain.Yen {
2 return fp.Reduce(orders, map[domain.Email]domain.Yen{},
3 func(acc map[domain.Email]domain.Yen, o Order) map[domain.Email]domain.Yen {
4 acc[o.Customer] = acc[o.Customer].Add(o.Total())
5 return acc
6 })
7}
8
9// Report が純粋なコア。エラーは落とさず集めて返す
10func Report(lines []string) (map[domain.Email]domain.Yen, []error) {
11 parsed := fp.Map(lines, ParseAndValidate)
12 var errs []error
13 var orders []Order
14 for _, r := range parsed {
15 o, err := r.Unwrap()
16 if err != nil {
17 errs = append(errs, err)
18 continue
19 }
20 orders = append(orders, o)
21 }
22 return SumByCustomer(orders), errs
23}
SumByCustomer は畳み込みの内側でマップを書き換えているが、そのマップは関数の中で作ったものなので、外からは観測できない。関数型言語のように完全な不変性を目指すより、「外に見える副作用がない」ところまで守るほうがGoでは現実的になる。
Report は行の配列を受け取り、集計結果とエラー一覧を返すだけだ。ファイル、DB、時刻のいずれも参照しない。エラーを最初の1件で打ち切らず全部集めているのも、入力の全体像を返すためだ。
I/Oは外側の殻に置く
1func main() {
2 f, err := os.Open("orders.csv")
3 if err != nil {
4 log.Fatal(err)
5 }
6 defer f.Close()
7
8 var lines []string
9 sc := bufio.NewScanner(f)
10 for sc.Scan() {
11 lines = append(lines, sc.Text())
12 }
13 if err := sc.Err(); err != nil {
14 log.Fatal(err)
15 }
16
17 sum, errs := app.Report(lines) // 純粋なコアを1回だけ呼ぶ
18 for _, e := range errs {
19 log.Printf("skip: %v", e)
20 }
21 for email, total := range sum {
22 fmt.Printf("%s\t%s\n", email, total)
23 }
24}
この分離の効果はテストに直結する。コアのテストでファイルやモックは要らない。
1func TestParseAndValidate(t *testing.T) {
2 tests := map[string]struct {
3 line string
4 wantErr bool
5 want domain.Yen
6 }{
7 "正常系": {line: "o1,a@example.com,1200,2", want: 2400},
8 "列が足りない": {line: "o1,a@example.com,1200", wantErr: true},
9 "メールが不正": {line: "o1,example.com,1200,2", wantErr: true},
10 "数量が0": {line: "o1,a@example.com,1200,0", wantErr: true},
11 "単価が数値でない": {line: "o1,a@example.com,abc,2", wantErr: true},
12 }
13 for name, tt := range tests {
14 t.Run(name, func(t *testing.T) {
15 o, err := ParseAndValidate(tt.line).Unwrap()
16 if tt.wantErr {
17 if err == nil {
18 t.Fatalf("want error, got %+v", o)
19 }
20 return
21 }
22 if err != nil {
23 t.Fatalf("unexpected error: %v", err)
24 }
25 if got := o.Total(); got != tt.want {
26 t.Errorf("Total() = %v, want %v", got, tt.want)
27 }
28 })
29 }
30}
純粋関数のテストは、テーブルに入力と期待値を並べるだけで済む。テストの表がそのまま仕様の一覧になるという話は、Goで書くコード品質の高め方 にも書いた。
なお、この「計算とI/Oを分ける」構造は、レイヤの依存方向を内向きに固定するオニオンアーキテクチャの考え方とも重なる。Goでの実装例は ドメイン駆動設計とオニオンアーキテクチャ、Go言語での実現について に書いた。
イテレータで遅延パイプラインにする
Go 1.23 で iter.Seq と range over func が入り、遅延的な列を扱えるようになった。全件をメモリへ載せずに流す処理や、途中で打ち切る処理と相性がよい。
1func MapSeq[A, B any](seq iter.Seq[A], f func(A) B) iter.Seq[B] {
2 return func(yield func(B) bool) {
3 for v := range seq {
4 if !yield(f(v)) {
5 return
6 }
7 }
8 }
9}
10
11func FilterSeq[A any](seq iter.Seq[A], pred func(A) bool) iter.Seq[A] {
12 return func(yield func(A) bool) {
13 for v := range seq {
14 if pred(v) && !yield(v) {
15 return
16 }
17 }
18 }
19}
yield が false を返したら即座に戻る点が重要で、これが短絡(break や slices.Collect の途中終了)を成立させている。結果を集めるには slices.Collect を使う。
1nums := MapSeq(lines, toInt)
2even := FilterSeq(nums, func(n int) bool { return n%2 == 0 })
3got := slices.Collect(even) // []int{2, 4}
どこまでやるか
コストを実測する
10000要素のスライスに対し、「偶数だけ残して2倍する」処理を3通りで測った。Go 1.27.0、linux/amd64、Xeon 2.80GHz の仮想マシンでの結果を載せる。
1BenchmarkForLoop-4 41586 28947 ns/op 81920 B/op 1 allocs/op
2BenchmarkMapFilter-4 26455 47697 ns/op 122880 B/op 2 allocs/op
3BenchmarkSeqPipeline-4 11673 102948 ns/op 128440 B/op 23 allocs/op
読み取れることは3つある。
Filter+Mapは素朴なforループの約1.6倍の時間がかかった。中間スライスを1本余分に作るためで、割り当ても増えているiter.Seqの遅延パイプラインは、この条件では最も遅い。全件を最後まで流す場合、yield呼び出しのコストが効く。遅延評価が得をするのは、途中で打ち切る場合や、そもそも全件をメモリに載せられない場合だ- とはいえ絶対値は10000要素で数十マイクロ秒だ。1リクエストに数msかかるWebアプリの中では誤差の範囲になる
つまり判断は場所による。ホットパスや大きなループの内側では素直なforループを、それ以外では読みやすさを優先した合成を選ぶ。数値はマシンと入力サイズで変わるので、気になる場合は自分の環境で go test -bench . -benchmem を実行して確かめるとよい。
周辺ツールのバージョンに注意する
ジェネリックメソッドのように新しい構文を使う場合、コンパイラだけでなく周辺ツールも追随している必要がある。実際、今回の検証では次の現象が起きた。
1$ gofmt -l .
2result/result.go:66:25: method must have no type parameters
3slice.go:5:22: method must have no type parameters
これはPATH上の gofmt が古いバージョン(go1.24.7 同梱)だったために起きたものだ。同じソースを go1.27.0 の gofmt にかけると何も出ない。CIやエディタが使うツールチェーンのバージョンも揃えておかないと、コンパイルは通るのにフォーマッタやリンタが落ちる、という状況になりうる。
1# 実行されるツールチェーンを確認する
2$ go version
3$ go env GOTOOLCHAIN
4$ go fmt ./... # go 経由で呼べば go.mod のツールチェーンに従う
チームで使うときの線引き
関数型のスタイルはやりすぎると読みにくくなる。次のあたりを線引きの目安にするとよい。
- 積極的に使う: defined type、スマートコンストラクタ、純粋関数への分離、テーブル駆動テスト。これらはGoらしさを損なわず、効果が大きい
- 場合による:
Map/Filter/Reduce、Monoid、Result[T]。パイプライン的な処理では読みやすくなる。単純なループ1つを置き換えるだけなら、forのままのほうがよい - 避ける: 深い関数合成のネスト、独自のモナド風DSL、
Resultを公開APIに露出させること。読み手にGoの標準的な読み方を捨てさせる負荷が大きい
判断軸は「関数型らしいか」ではなく、「バグの分類を1つ消せるか」「読み手にとって追う概念が減るか」だ。
注意点
- Goの値は自由にコピーできるが、スライスやマップを含む構造体をコピーしても中身は共有される。
Mapに渡した関数の中で入力のスライスやマップを書き換えると、純粋関数の前提が崩れる。不変性は規律で守るしかない - 再帰は深くしないほうがよい。Goの仕様は末尾呼び出し最適化を保証しない。goroutine のスタックは伸長するのでいきなり落ちはしないが、深い再帰は素直にループで書くほうが安全になる
- 型switchの網羅性はコンパイラが検査しない。
defaultの省略も言語としては許されるため、実装を増やしたときの追従は人間の責任になる。自分でdefaultを書き、テストで固定する - ジェネリクスは万能ではない。型パラメータを増やすほど推論が効かなくなり、明示的な instantiate が必要になる場面が出てくる。読みにくくなったら、具体型で2つ書くほうが早いこともある
- ここで紹介したGo 1.27 のジェネリックメソッドは、手元の go1.27.0 で挙動を確認したものだ。細かい仕様は公式のリリースノートと言語仕様を確認してほしい
- 本記事のコードはすべて
go vetとgo testを通しているが、エラー処理や境界値は説明のために簡略化してある。実運用ではCSVはencoding/csvを使うなど、標準ライブラリに任せられる部分は任せるとよい
参考
- The Go Programming Language Specification — 型パラメータと defined type の一次情報
- Go Wiki: Generics — ジェネリクスの公式チュートリアル
- Go 1.23 Release Notes —
iterパッケージと range over func - Alexis King, “Parse, don’t validate” (2019) — 型で検証結果を持ち越す考え方
- Gary Bernhardt, “Boundaries” (2012) — functional core, imperative shell の出典
- 関数型言語と圏論 — 本記事の前提となる用語と法則の整理
- Goで書くコード品質の高め方 — テストを仕様として運用する話
- ドメイン駆動設計とオニオンアーキテクチャ、Go言語での実現について — 依存方向とレイヤ分割