makoto-developer's テックブログ

連載「Goで書く実践圏論」 2/4

errors.Join はモノイドである —— Goで書く実践圏論(2)

前回(第1回)は、Go の型と関数が圏をなすことを確かめました。合成があり、恒等射があり、結合律と単位律が成り立ちます。そして法則とは「安全に書き換えてよい範囲の宣言」だ、という話をしました。

今回は既存のコードを読み直します。新しいライブラリは入れません。errors.Joinstrings.Builderslices.SortFunciter.Seqnet/http の middleware。全部、すでに使っているものばかりですよね。そこに何が構造として入っていたのかを見ていきます。

今回、全体を貫く主張は1つです。

法則は最適化の許可証だ。

法則が成り立っているから、ループを1本にまとめてよい。実装を差し替えてよい。並列に走らせてよい。どれも「書き換えても結果が変わらない」という保証があってはじめて成立します。今回は、その許可証を使ったら実際にどれだけ得をするのかを測ります。結論を先に出しておきましょう。

法則許可される書き換え実測した効果
Functor 則2(合成の保存)2回の map を1回に融合メモリは小さい配列で 1/3・大きい配列で半分、速度は小さい配列で 1.8倍
モノイド則(結合律)実装の差し替えstrings.Join は素朴な連結の 55倍速
モノイド則(結合律)分割して並列に畳み込む2〜3万要素から得、それ未満は最大8倍損
合成の結合律middleware の括り直し実行時コストは変わらない(16段で 58 ns)

コードは前回と同じリポジトリの part2/ にあります。

インストールもログインも無しで、いますぐ手を動かしたいという方へ。下のサンドボックスは、この記事の山場を1ファイルに縮めたものです。Functor則が本当に成り立つこと(1万通りのランダムな配列で確かめます)、ループを1周にまとめるとメモリが半分になること(10万要素で実測)、そして並列集計が壊れる境目(合計は平気、平均は壊れる、合計と個数に分ければ直る)が、そのまま流れます。

このサンドボックスが動かしている main.go の正本は、リポジトリの examples/functor-monoid/main.go です。起動時に GitHub から取ってくる作りにしてあるので、リポジトリを直せばサンドボックスの中身も最新になります。


1. Functor —— 「map できる」とはどういうことか

1.1 まず、毎日書いているコード

names := make([]string, len(users))
for i, u := range users {
    names[i] = u.Name
}

やっているのは []User[]string に変えること。もう少し正確に言うと、User → string という射を、[]User → []string という射に持ち上げているわけです。

射を持ち上げています。ここが要点です。変換されているのは値だけじゃありません。射も変換されているんですね。

1.2 関手(Functor)の定義

関手とは、圏から圏への写像で、次を満たすもののことです。

  1. 対象を対象に写す(User[]User に)
  2. 射を射に写す(User → string[]User → []string に)
  3. 恒等射を恒等射に写す
  4. 合成を保つ

3と4を式で書くと、これが Functor 則になります。

Go で書けば、こうなります。

// 則1: 恒等射で写しても何も変わらない
MapSlice(xs, Identity) == xs

// 則2: 2回写すのと、合成した射で1回写すのは同じ
MapSlice(MapSlice(xs, f), g) == MapSlice(xs, Compose(f, g))

則2 が今回の主役です。左辺は中間スライスを作ります。右辺は作りません。法則が「両者は等しい」と言っているので、中間スライスを消してよいわけです。これがループ融合(loop fusion)の許可証になります。

1.3 Go にある4つの Functor

// MapSlice はスライス上の Functor。要素に射を適用し、長さと順序という構造は保つ。
func MapSlice[A, B any](xs []A, f func(A) B) []B {
	if xs == nil {
		return nil
	}
	out := make([]B, len(xs))
	for i, x := range xs {
		out[i] = f(x)
	}
	return out
}

// MapPtr はポインタ上の Functor。値が無いという構造を保つため nil は nil に写す。
func MapPtr[A, B any](p *A, f func(A) B) *B {
	if p == nil {
		return nil
	}
	b := f(*p)
	return &b
}

// MapSeq は iter.Seq 上の Functor。遅延のまま写すので、途中で break されれば残りは評価されない。
func MapSeq[A, B any](seq iter.Seq[A], f func(A) B) iter.Seq[B] {
	return func(yield func(B) bool) {
		for a := range seq {
			if !yield(f(a)) {
				return
			}
		}
	}
}

// MapErr は (T, error) 上の Functor。エラーはそのまま素通しする。
func MapErr[A, B any](a A, err error, f func(A) B) (B, error) {
	if err != nil {
		var zero B
		return zero, err
	}
	return f(a), nil
}

4つとも「中身に射を適用し、外側の構造は保つ」という同じことをしています。保たれる構造がそれぞれ違うだけですね。

Functor保たれる構造
[]T長さと順序
*T値があるか無いか
iter.Seq[T]要素の並びと、評価されるタイミング
(T, error)成功か失敗か

MapPtrnilnil に写すのは、実装の都合ではありません。「値が無い」という構造を保たなければ Functor 則が破れてしまうからです。もし nil に対してゼロ値を返す実装にしたら、MapPtr(nil, Identity)&0 になって、則1が破れます。

1.4 法則をテストする

// Functor 則1(恒等の保存): 恒等射で写しても何も変わらない。
// Functor 則2(合成の保存): 2回写すのと、合成した射で1回写すのは等しい。
// 則2 は「ループを1本にまとめてよい」という許可証そのものである。
func TestSliceFunctorLaws(t *testing.T) {
	rapid.Check(t, func(t *rapid.T) {
		xs := rapid.SliceOf(rapid.Int()).Draw(t, "xs")

		if got := MapSlice(xs, Identity[int]); !slices.Equal(got, xs) {
			t.Fatalf("恒等の保存が破れた: got=%v, want=%v", got, xs)
		}

		twice := MapSlice(MapSlice(xs, itoa), length)
		fused := MapSlice(xs, Compose(itoa, length))

		if !slices.Equal(twice, fused) {
			t.Fatalf("合成の保存が破れた: 2回=%v, 合成=%v", twice, fused)
		}
	})
}

*Titer.Seq[T](T, error) についても同じ形のテストを書きました。全部通ります。

1.5 許可証を使う —— ループ融合の実測

法則が「等しい」と言うなら、速いほうを使えばいいですよね。どれだけ違うのか、測ってみます。

// Functor 則2(合成の保存)が保証しているのは、この2つが同じ結果になることである。
// 同じ結果になるなら、速いほうを選んでよい。
func BenchmarkMapTwice(b *testing.B) {
	for _, n := range []int{100, 10_000, 1_000_000} {
		xs := intsOf(n)
		b.Run("separate/"+strconv.Itoa(n), func(b *testing.B) {
			for b.Loop() {
				sinkInts = MapSlice(MapSlice(xs, itoa), length)
			}
		})
		b.Run("fused/"+strconv.Itoa(n), func(b *testing.B) {
			for b.Loop() {
				sinkInts = MapSlice(xs, Compose(itoa, length))
			}
		})
	}
}
要素数2回に分ける (ns/op)融合 (ns/op)2回 (B/op)融合 (B/op)
1005703212,688928
10,000127,000111,000284,643120,833
1,000,00012,430,00011,850,00031,729,82715,722,508

小さい配列で 1.8倍速、メモリ 1/3。大きい配列では速度差が 5% 程度に縮みますが、メモリは常に半分になります。100万要素で 31.7 MB が 15.7 MB。GC の負荷が半分になると考えると、実測の 5% より効く場面は多そうです。

ここで大事なのは、この最適化が安全だと分かっていることです。「たぶん同じ結果になるはず」ではなく、Functor 則が保証してくれています。テストも書いてあります。だから安心して融合できるわけですね。

1.6 iter.Seq では融合が自動で起きる

Go 1.23 で入った range-over-func(iter.Seq)を使うと、この融合を手でやる必要がなくなります。

// iter.Seq は遅延なので、途中で打ち切れば残りの要素に射は適用されない。
// 同じ Functor 則を満たしながら、評価回数はスライス版と違う。
func TestSeqFunctorIsLazy(t *testing.T) {
	applied := 0
	counted := func(n int) int {
		applied++
		return n * 2
	}

	for range MapSeq(slices.Values([]int{1, 2, 3, 4, 5}), counted) {
		break
	}

	if applied != 1 {
		t.Fatalf("遅延評価されていない: 適用回数=%d, want=1", applied)
	}
}

5要素のうち1要素しか射が適用されていません。MapSeq を何段重ねても中間スライスは生まれず、要素は1つずつ全段を通り抜けます。構造としては同じ Functor でありながら、評価戦略が違うわけですね。

[]T の代わりに iter.Seq[T] を返す API にすべきか」という設計判断は、Functor 則の観点ではどちらでもいいんです(両方とも法則を満たすので)。違うのは評価タイミングとメモリの使い方だけになります。法則が同じなら、あとは性能特性で選べばいいです。この切り分けができるようになるのが、構造を見ることの利点だと思います。

1.7 Functor にならないもの

filter は Functor ではありません。要素数が変わるので、構造を保っていないからです。

// これは Functor 則1 を破る
func MapAndFilter[A, B any](xs []A, f func(A) (B, bool)) []B

MapAndFilter(xs, func(a A) (A, bool) { return a, false }) は空スライスを返します。Identity を渡しても元に戻らないので、則1が成り立ちません。

なので filter は「関手ではない別のもの」として扱う必要があります。実務的にどう効くかというと、filter を挟んだパイプラインでは、Functor 則を根拠にした融合ができないということです(別の根拠が要ります)。map だけのパイプラインは自由に括り直せます。filter が入ると、順序を入れ替えたときに結果が変わる可能性が出てきます。


2. Monoid —— 「まとめられる」とはどういうことか

2.1 対象が1つしかない圏

第1回で触れたとおり、モノイドは対象が1つだけの圏です。射がその型の値、合成が二項演算、恒等射が単位元にあたります。

// Monoid は結合的な二項演算 Append と、その単位元 Empty の組。
// 対象がひとつだけの圏だと思ってもよい(射が T の値、合成が Append)。
type Monoid[T any] struct {
	Empty  T
	Append func(T, T) T
}

法則は圏のものがそのまま降りてきます。

2.2 標準ライブラリはモノイドだらけだ

// 標準ライブラリの中に元からあるモノイドたち。
var (
	// SumInt は加算と 0。
	SumInt = Monoid[int]{Empty: 0, Append: func(a, b int) int { return a + b }}

	// ConcatString は連結と空文字列。strings.Builder が速いのは、この構造を使い回すため。
	ConcatString = Monoid[string]{Empty: "", Append: func(a, b string) string { return a + b }}

	// JoinErrors は errors.Join と nil。単位元は == ではなく、後述の観測的等価性のもとで成り立つ。
	JoinErrors = Monoid[error]{Empty: nil, Append: func(a, b error) error { return errors.Join(a, b) }}

	// MaxDuration は最大値と 0。単位元 0 が効くのは duration >= 0 に限る(SLO 集計を想定)。
	MaxDuration = Monoid[time.Duration]{Empty: 0, Append: func(a, b time.Duration) time.Duration { return max(a, b) }}
)

errors.Join がモノイドだというのは、言われてみれば当然です。ただし何を「等しい」とするかを決めないと、この主張は成り立ちません

errors.Join(nil, err)err== で一致しません。errors.Join は非 nil が1つしか残らない場合でも内部の型で包んで返すので、値としては別物です。そこで観測的等価性を使うのですが、ここは定義を詰めないと成り立ちません。

使うのはこれです。アプリケーションが原因判定に使うエラーの一覧 を先に固定して(センチネルエラーの集合だと思ってください)、「すべての について errors.Is(err, s) の真偽が一致する」を等しさとします。この等価性のもとでなら、nil が単位元として振る舞い、errors.Join(errors.Join(a, b), c)errors.Join(a, errors.Join(b, c)) は同じものとして扱えます。

訂正(2026-08-13): 公開当初、ここを「errors.Is で観測できる原因エラーの集合が同じ」とだけ書いていました。観測対象を限定していないので、このままでは成り立ちません。 判定に使える target には、errors.Join が返すラッパー自身も含まれてしまうからです。Join(Join(a,b),c)Join(a,Join(b,c)) として target に を渡すと、errors.Is(x, x) は真、errors.Is(y, x) は偽になり、結合律が破れます。観測する集合を先に固定する必要がありました。

固定するというのは、実務では自然な前提です。エラーを見て分岐するなら、どのセンチネルを見るかは設計時に決まっているはずだからです。裏を返すと、この等価性はラッパーの入れ子の形・重複の数・並び順・Error() の文字列を、意図的に捨てています

だからバリデーションのエラー収集では、どのように括ってまとめても結果が同じになります。

ただし「モノイドだから可換でもある」とは言えません。モノイドが要求するのは結合律 だけで、可換律 は含まれないからです。errors.Join の場合、上で決めた等価性のもとでは順序を入れ替えても等しくなります(原因の集合が変わらないので)。でもそれはこの等価性が並び順を捨てているからであって、モノイドだからではありません。実際 errors.Join は並び順を保つので、Error() の文字列は変わります。ユーザーに見せるメッセージの順番を当てにするなら、それは等価性の外側の話だと意識しておく必要があります。

MaxDuration にも前提があります。time.Duration は負の値を取れるので、max(0, -1s) = 0 ≠ -1s となり、負の値を許すと Empty: 0 は単位元になりません。ここで想定しているのは SLO のレイテンシ集計、つまり duration >= 0 に定義域を限った場合です。

法則をテストします。等価性の判定方法だけ型ごとに変わるので、そこは外から渡します。

// checkMonoidLaws は結合律と単位律を確かめる。等価性の判定は呼び出し側から渡す
// (error のように == で比べられない型があるため)。
func checkMonoidLaws[T any](t *rapid.T, m Monoid[T], gen *rapid.Generator[T], eq func(T, T) bool) {
	a := gen.Draw(t, "a")
	b := gen.Draw(t, "b")
	c := gen.Draw(t, "c")

	left := m.Append(m.Append(a, b), c)
	right := m.Append(a, m.Append(b, c))
	if !eq(left, right) {
		t.Fatalf("結合律が破れた: (a·b)·c=%v, a·(b·c)=%v", left, right)
	}

	if got := m.Append(m.Empty, a); !eq(got, a) {
		t.Fatalf("左単位律が破れた: e·a=%v, a=%v", got, a)
	}
	if got := m.Append(a, m.Empty); !eq(got, a) {
		t.Fatalf("右単位律が破れた: a·e=%v, a=%v", got, a)
	}
}

errors.Join を含め、全部通ります。

2.3 いちばん実務に効くモノイド —— 多段ソートの比較関数

「部署順、同じ部署なら年齢順、同じなら名前順」というソートを書くとき、こう書く人が多いと思います。

slices.SortFunc(employees, func(a, b employee) int {
    if r := cmp.Compare(a.Dept, b.Dept); r != 0 {
        return r
    }
    if r := cmp.Compare(a.Age, b.Age); r != 0 {
        return r
    }
    return cmp.Compare(a.Name, b.Name)
})

この「先に差がついたほうを採る」という形は、モノイドです。単位元は「常に 0(同順)を返す比較関数」です。

// CompareBy は比較関数のモノイド。単位元は「常に同順」を返す比較で、
// Append は「先に差がついたほうを採る」。多段ソートの比較関数はこの形をしている。
// 標準ライブラリの cmp.Or が値に対してやっていることと同じ構造。
func CompareBy[T any](cmps ...func(a, b T) int) func(a, b T) int {
	return func(a, b T) int {
		for _, cmp := range cmps {
			if r := cmp(a, b); r != 0 {
				return r
			}
		}
		return 0
	}
}

すると先ほどのソートはこう書けます。

slices.SortFunc(employees, CompareBy(byDept, byAge, byName))

そしてモノイドなので、括り方は自由です。

// 比較関数のモノイド則。どう括ってまとめても、同じ順序になる。
// 「部署でまとめた比較」を先に用意してから年齢を足しても、結果は変わらない。
func TestCompareByIsMonoid(t *testing.T) {
	rapid.Check(t, func(t *rapid.T) {
		a := employeeGen.Draw(t, "a")
		b := employeeGen.Draw(t, "b")

		flat := CompareBy(byDept, byAge, byName)
		grouped := CompareBy(CompareBy(byDept, byAge), byName)
		nested := CompareBy(byDept, CompareBy(byAge, byName))

		if flat(a, b) != grouped(a, b) || flat(a, b) != nested(a, b) {
			t.Fatalf("結合律が破れた: a=%+v, b=%+v", a, b)
		}
	})
}

実際にソートしても並びは変わりません。

// 実際にソートしても、括り方によらず同じ並びになる。
func TestSortIsUnaffectedByGrouping(t *testing.T) {
	rapid.Check(t, func(t *rapid.T) {
		xs := rapid.SliceOfN(employeeGen, 0, 20).Draw(t, "xs")

		flat := slices.Clone(xs)
		grouped := slices.Clone(xs)

		slices.SortStableFunc(flat, CompareBy(byDept, byAge, byName))
		slices.SortStableFunc(grouped, CompareBy(CompareBy(byDept, byAge), byName))

		if !slices.Equal(flat, grouped) {
			t.Fatalf("括り方でソート結果が変わった:\n  平坦=%+v\n  束ねた=%+v", flat, grouped)
		}
	})
}

何が嬉しいか。「標準の並び順」を部品として定義し、あとから条件を足せるようになります。

var defaultOrder = CompareBy(byDept, byAge)

// 検索結果の画面では、スコア順を先頭に足す
searchOrder := CompareBy(byScore, defaultOrder)

defaultOrder の中身を知らなくても安全に合成できるのは、結合律があるからです。Go 1.22 で入った cmp.Or も、値に対する同じ構造(最初の非ゼロを採る、単位元はゼロ値)です。

2.4 結合律は並列化の許可証 —— ただし損益分岐点がある

結合律があると、畳み込みを分割して並列に実行できます。 に括り直してよいからです。

// FoldParallel はスライスを分割して並列に畳み込み、部分結果をさらに畳み込む。
// 分割の仕方によらず同じ答えになるのは結合律のおかげで、それ以外の保証は無い。
func FoldParallel[T any](m Monoid[T], xs []T, workers int) T {
	if workers <= 1 || len(xs) < workers {
		return Fold(m, xs)
	}

	partials := make([]T, workers)
	chunk := (len(xs) + workers - 1) / workers
	var wg sync.WaitGroup

	for w := range workers {
		lo := w * chunk
		hi := min(lo+chunk, len(xs))
		if lo >= hi {
			partials[w] = m.Empty
			continue
		}
		wg.Add(1)
		go func(w, lo, hi int) {
			defer wg.Done()
			partials[w] = Fold(m, xs[lo:hi])
		}(w, lo, hi)
	}
	wg.Wait()

	return Fold(m, partials)
}

答えが一致することは、分割数をランダムに振って確かめます。

// 結合律があるので、どう分割して並列に畳み込んでも答えは変わらない。
func TestFoldParallelMatchesSequential(t *testing.T) {
	rapid.Check(t, func(t *rapid.T) {
		xs := rapid.SliceOfN(rapid.IntRange(-1000, 1000), 0, 500).Draw(t, "xs")
		workers := rapid.IntRange(1, 8).Draw(t, "workers")

		if got, want := FoldParallel(SumInt, xs, workers), Fold(SumInt, xs); got != want {
			t.Fatalf("並列と逐次で答えが違う: workers=%d, got=%d, want=%d", workers, got, want)
		}
	})
}

では、並列化すると速くなるのか。18スレッドの環境で測ります。

要素数逐次 (ns/op)並列 (ns/op)並列/逐次
1,0006225,0508.1× 遅い
10,0005,6508,5001.5× 遅い
30,00017,80015,1001.18× 速い
100,00063,20031,1002.0× 速い
10,000,0006,320,0001,230,0005.1× 速い

損益分岐点は 2〜3万要素あたり。それ未満では goroutine の起動と sync.WaitGroup の同期コスト(固定で 2,752 B / 38 allocs)に負けます。1,000要素では 8倍も遅い

この数字は覚えておく価値があります。

結合律は並列化を可能にするだけで、得になることは保証しません。

「モノイドだから並列化できる」は正しいです。「モノイドだから並列化すべき」は間違いです。1命令の演算(int の加算)なら数万要素から、もっと重い演算(正規表現マッチ、暗号化)ならもっと少ない要素数から得になります。測って決めるしかありません。

2.5 平均は壊れる —— 実演

結合律が無いとどうなるか。平均でやってみます。

// 平均は結合的でない。素朴に分割並列化すると答えが変わる。
func TestMeanBreaksUnderParallelSplit(t *testing.T) {
	xs := []float64{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}

	sequential := Mean(xs)
	parallel := MeanParallel(xs, 3)

	if sequential == parallel {
		t.Fatalf("平均が並列化で壊れなかった: 逐次=%v, 並列=%v", sequential, parallel)
	}
	t.Logf("逐次=%v / 3分割=%v (差=%v)", sequential, parallel, math.Abs(sequential-parallel))
}
monoid_test.go:114: 逐次=5.5 / 3分割=6.166666666666667 (差=0.666666666666667)

から の平均は です。3分割して部分平均の平均を取ると になります。12% ずれた

分割数は実行環境の CPU 数やチャンクサイズで変わるので、この種のバグは「ローカルでは合っていたのに本番で数字が違う」という形で現れます。しかもエラーになりません。静かに間違った数字がダッシュボードに出ます。

2.6 直し方 —— モノイドになる形に変える

平均がモノイドでないのは、平均という値の中に「何個から作られたか」の情報が入っていないからです。入れてやればいいです。

// SumCount は「合計と個数」の組。平均をモノイドで扱うための正しい形。
type SumCount struct {
	Sum   float64
	Count int
}

// MeanMonoid は合計と個数を別々に畳み込む。両方とも結合的なので分割して並列化できる。
var MeanMonoid = Monoid[SumCount]{
	Empty: SumCount{},
	Append: func(a, b SumCount) SumCount {
		return SumCount{Sum: a.Sum + b.Sum, Count: a.Count + b.Count}
	},
}

SumCount も加算なので結合的。だから SumCount の組も結合的になる(積のモノイドは各成分のモノイド)。最後に割り算をして平均を取り出します。

// 合計と個数に分ければ両方とも結合的になり、並列化しても答えが合う。
func TestSumCountMonoidFixesParallelMean(t *testing.T) {
	rapid.Check(t, func(t *rapid.T) {
		xs := rapid.SliceOfN(rapid.Float64Range(-1e6, 1e6), 1, 200).Draw(t, "xs")
		workers := rapid.IntRange(1, 8).Draw(t, "workers")

		pairs := MapSlice(xs, func(x float64) SumCount { return SumCount{Sum: x, Count: 1} })

		got := FoldParallel(MeanMonoid, pairs, workers).Value()
		want := Fold(MeanMonoid, pairs).Value()

		if math.Abs(got-want) > 1e-9 {
			t.Fatalf("並列と逐次で答えが違う: workers=%d, got=%v, want=%v", workers, got, want)
		}
	})
}

これは分散集計の定石と同じ形です。MapReduce で平均を取るときに「合計と件数を持ち回って最後に割る」のは、平均をモノイドにするための変形だったわけです。パーセンタイルで t-digest や HyperLogLog のようなスケッチが使われるのも同じ理由で、「マージできる形=モノイドになる形」に情報を持たせています。

分散システムの設計で「この集計は分割できるか」を判断するとき、問うべきことは一つになります。モノイドか、モノイドになる形に変形できるか

2.7 浮動小数点は、そもそも結合的でない

ついでに、もっと不穏な事実も確かめておきます。

// 浮動小数点の加算は結合的でないため、分割数を変えると答えがずれることがある。
func TestFloatAdditionIsNotAssociative(t *testing.T) {
	a, b, c := 1e16, -1e16, 1.0

	left := (a + b) + c
	right := a + (b + c)

	if left == right {
		t.Fatalf("結合律が破れなかった: 両方とも %v", left)
	}
	t.Logf("(a+b)+c = %v / a+(b+c) = %v", left, right)
}
monoid_test.go:144: (a+b)+c = 1 / a+(b+c) = 0

float64 の加算はモノイドの法則を満たしません。。値そのものが変わります。

つまり SumCount に直したとしても、float64 を使う限り並列化で数字が微妙にずれる可能性は残る。金額を扱うなら整数(最小単位)か decimal を使え、というよく聞く助言の根拠がここにあります。整数の加算はきちんとモノイドです。

2.8 モノイド則は実装差し替えの許可証 —— 55倍の差

結合律のもう一つの使い道は、実装の差し替えです。同じモノイドなら、どの実装で計算しても答えは同じ。だから速いものを選べます。

文字列連結のモノイドを4つの実装で測りました。1,000 個の 8 バイト文字列を連結します。

実装ns/opB/opallocs/op最速比
a + b で畳み込む228,0004,273,89999955×
毎回 strings.Builder を作って連結238,0004,273,9101,00058×
strings.Builder を1つ使い回す4,48034,296151.1×
strings.Join4,1208,1921

55倍。同じ法則を満たし、同じ答えを返す実装の間に、これだけの開きがあります。

2行目にも注目したいです。「strings.Builder を使えば速い」と覚えていると、こう書いてしまうことがあります。

Append: func(a, b string) string {
    var sb strings.Builder
    sb.WriteString(a)
    sb.WriteString(b)
    return sb.String()
}

Builder を使っているのに、素朴な a + b より遅いんです。畳み込みのたびに Builder を作って捨てているので、 のコピーは何も減っていないからです。モノイドの Append を速くしても意味がありません。速くすべきは畳み込み全体です

これは重要な区別だと思います。法則は「2要素の演算」に対して定義されていますが、最適化の余地は「n 要素の畳み込み」の側にあります。strings.Join は「全体の長さを先に計算して1回だけ確保する」という、2要素の演算からは出てこない戦略を取っています。だから1アロケーションで済みます。

法則は「置き換えてよい」と言いますが、「同じ速さだ」とは言いません。だからこそ置き換える価値がある。


3. Kleisli 合成 —— if err != nil に潜む構造

3.1 前回の続き

第1回の最後で、func(A) (B, error)Compose では繋がらないという話をしました。専用の合成器を作れば繋がり、それも圏になります。名前は Kleisli 圏。

今回はもう少し整理して書きます。

// Kleisli は「失敗しうる射」。Go のコードのほとんどはこの形をしている。
type Kleisli[A, B any] func(A) (B, error)

// Then は Kleisli 射の合成。メソッドにしたいところだが、Go はメソッドに型パラメータを
// 追加できない(型 C を導入できない)ため、関数として書くしかない。
func Then[A, B, C any](f Kleisli[A, B], g Kleisli[B, C]) Kleisli[A, C] {
	return func(a A) (C, error) {
		b, err := f(a)
		if err != nil {
			var zero C
			return zero, err
		}
		return g(b)
	}
}

// Pure は普通の射を Kleisli 射に持ち上げる。失敗しない処理を混ぜるときに使う。
func Pure[A, B any](f func(A) B) Kleisli[A, B] {
	return func(a A) (B, error) { return f(a), nil }
}

Pure があることで、失敗しない射と失敗する射を同じパイプラインに混ぜられます。これは通常の圏から Kleisli 圏への関手になっています。

3.2 ここで Go の壁に当たる

Then はメソッドにしたいです。

// こう書きたい
pipeline := parseInput.Then(validate).Then(save)

書けません。Then は新しい型 C を導入するが、Go はメソッドに型パラメータを追加できない

// これはコンパイルできない
func (f Kleisli[A, B]) Then[C any](g Kleisli[B, C]) Kleisli[A, C]

これは Go の設計上の既知の制約です(golang/go#49085)。理由は「型理論として不可能だから」ではなく、Go の暗黙的なインターフェース充足と組み合わせたときに、必要な実体化をいつ・どこで生成すればいいかが決まらなくなる、というコンパイルモデル側の事情です。第3回の 4.4 で正面から扱います。

結果として、Go では合成がメソッドチェーンにならず、関数のネストになります

// 読み順が内側から外側になる
pipeline := Then(Then(parseInput, validate), save)

これが実務での使い勝手を大きく左右します。

3.3 context 付きの Step

現実のパイプラインは context.Context を取ります。

// Step は context を取る Kleisli 射。実務のパイプラインはたいていこの形になる。
type Step[A, B any] func(context.Context, A) (B, error)

// ChainStep は Step の合成。context のキャンセルを各段で確認する。
func ChainStep[A, B, C any](f Step[A, B], g Step[B, C]) Step[A, C] {
	return func(ctx context.Context, a A) (C, error) {
		var zero C
		if err := ctx.Err(); err != nil {
			return zero, err
		}
		b, err := f(ctx, a)
		if err != nil {
			return zero, err
		}
		return g(ctx, b)
	}
}

合成器の中でキャンセル確認を入れておくと、すべての段が自動的にキャンセルに応答するようになります。各ステップの実装者は ctx.Err() を書く必要がありません。

// context 付きの合成では、キャンセル済みなら最初の段すら実行されない。
func TestChainStepStopsOnCanceledContext(t *testing.T) {
	called := false
	first := Step[int, int](func(ctx context.Context, n int) (int, error) {
		called = true
		return n, nil
	})
	// ... second は省略

	ctx, cancel := context.WithCancel(context.Background())
	cancel()

	if _, err := ChainStep(first, second)(ctx, 1); !errors.Is(err, context.Canceled) {
		t.Fatalf("キャンセルが伝わっていない: %v", err)
	}
	if called {
		t.Fatal("キャンセル済みなのに最初の段が実行された")
	}
}

これが合成器を自分で持つことの実利です。横断的な関心事を合成器に1回だけ書けば、全段に効く。ロギング、メトリクス、リトライ、タイムアウトも同じ場所に入れられます。第4回で本格的に扱います。

3.4 正直な話 —— どこまで使うか

とはいえ、Go で全部を Then で書くのはおすすめしません。

// 素直な Go
func handle(ctx context.Context, raw []byte) (Response, error) {
    req, err := parse(raw)
    if err != nil {
        return Response{}, fmt.Errorf("parse: %w", err)
    }
    user, err := authenticate(ctx, req)
    if err != nil {
        return Response{}, fmt.Errorf("auth: %w", err)
    }
    // ...
}

このコードには Then チェーンに無い利点があります。各ステップで違うエラーラップができること、途中の中間値を後段で使えること、スタックトレースが素直なこと。

Kleisli 合成が効くのは、次の条件が揃ったときです。

  1. ステップが一直線に繋がる(分岐や中間値の再利用が無い)
  2. 同じパイプラインを複数の場所で組み替えて使う
  3. リトライ・計測・キャンセルなどを全段に一律に掛けたい

1つのハンドラを書くだけなら if err != nil のほうが読みやすいです。10種類のワークフローを組み替えながら運用し、そのすべてにリトライと計測を入れたいなら、合成器が勝ちます。第4回はまさにその条件下での比較になります。


4. middleware —— 括り直せるのに、順序は変えられない理由

4.1 middleware は自己射だ

// Middleware は Handler を Handler に写す。出発点と行き先が同じ対象なので、自己射にあたる。
type Middleware func(http.Handler) http.Handler

// Chain は middleware を左から順に適用する。ms[0] が最も外側になる。
// 引数ゼロなら素通し(恒等)になり、これが単位元として振る舞う。
func Chain(ms ...Middleware) Middleware {
	return func(next http.Handler) http.Handler {
		for i := len(ms) - 1; i >= 0; i-- {
			next = ms[i](next)
		}
		return next
	}
}

Middlewarehttp.Handlerhttp.Handler に写します。出発点と行き先が同じ対象なので、これは http.Handler という対象の上の自己射(endomorphism)です。

関手ではありません。Handler → Handler という型だから自己関手(endofunctor)だ、とは言えません。自己関手 は、圏のすべての対象すべての射に作用したうえで、恒等射と合成を保たなければなりません。Middleware が写しているのは http.Handler という1つの対象の値だけです。ここは公開当初「エンドファンクタ」と書いていましたが、誤りだったので訂正しました。

自己射どうしは関数合成で閉じています。合成は結合的で、恒等関数(何もしない middleware)が単位元になります。つまり http.Handler 上の自己射の全体は、関数合成に関してモノイドをなす

「モナドとは自己関手の圏におけるモノイド対象である」という有名なフレーズがあります。ここに出ているのも確かに「合成と単位元」というモノイド的な構造ですが、階層が違います。あちらは自己関手を対象とする圏でのモノイド対象、こちらは1つの対象の上の自己射がなすモノイドです。同じものだと思わないでください。

第3回で出てくる Functional Option(ServerConfig → ServerConfig)も、同じ「自己射のモノイド」です。

4.2 括り直してよい(結合律)

// 結合律: middleware をどう括ってまとめても、実行順は変わらない。
// 「認証系だけ先に束ねておく」といったリファクタリングが安全なのはこのため。
func TestChainIsAssociative(t *testing.T) {
	var flat, grouped, nested []string

	run := func(build func(a, b, c Middleware) Middleware, order *[]string) {
		a, b, c := Tap("a", order), Tap("b", order), Tap("c", order)
		final := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			*order = append(*order, "handler")
		})
		build(a, b, c)(final).ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/", nil))
	}

	run(func(a, b, c Middleware) Middleware { return Chain(a, b, c) }, &flat)
	run(func(a, b, c Middleware) Middleware { return Chain(Chain(a, b), c) }, &grouped)
	run(func(a, b, c Middleware) Middleware { return Chain(a, Chain(b, c)) }, &nested)

	if !slices.Equal(flat, grouped) || !slices.Equal(flat, nested) {
		t.Fatalf("結合律が破れた:\n  平坦=%v\n  前2つを束ねた=%v\n  後2つを束ねた=%v", flat, grouped, nested)
	}
	t.Logf("実行順: %v", flat)
}
実行順: [a b c handler c:after b:after a:after]

3つの組み方すべてで実行順が一致します。だから実務でよくやるこれが安全だと分かります。

// 認証系だけ先に束ねて、別のルータでも使い回す
authStack := Chain(RequestID, Logging, Auth)

api := Chain(authStack, RateLimit, CORS)
admin := Chain(authStack, AuditLog)

authStack の中身を知らなくても、外側から自由に足せます。結合律が「部分的にまとめる」ことを許可している

4.3 順序は変えてはいけない(非可換)

// 結合律はあるが可換律は無い。括り方は変えてよいが、並び順は変えてはいけない。
func TestChainIsNotCommutative(t *testing.T) {
	// ... a, b の順と b, a の順で実行順を比較
	if slices.Equal(forward, reversed) {
		t.Fatal("順序を入れ替えても同じ結果になった(可換になっている)")
	}
}
a,b の順: [a b handler b:after a:after]
b,a の順: [b a handler a:after b:after]

当たり前に見えますが、この当たり前を結合律と区別できていることが大事です。

結合律(括り方は自由)と可換律(順序は自由)は別物です。

middleware には前者があり、後者はありません。だから「認証を先に、レート制限を後に」という順序は守らなければならないが、「認証とログをひとまとめにする」のは自由。混同すると、順序依存のバグを「リファクタリングで壊れた」と誤診することになります。

4.4 空のチェーンは恒等

// 空のチェーンは恒等射。設定なしで素通しになるのは、この構造の帰結。
func TestEmptyChainIsIdentity(t *testing.T) {
	called := false
	final := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { called = true })

	Chain()(final).ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/", nil))

	if !called {
		t.Fatal("空のチェーンがハンドラに届いていない")
	}
}

設定ファイルに middleware を1つも書かなかったとき、素通しになってほしいです。エラーになったり nil を返したりしてほしくないですよね。この「自然な期待」に対応しているのが、単位元の存在という圏の性質です。

4.5 コストを測る

// middleware チェーンの深さごとのコスト。1リクエストあたりで見る。
func BenchmarkMiddlewareChain(b *testing.B) {
	// ... 何もしない middleware を depth 段重ねて ServeHTTP する
}
段数ns/opB/opallocs/op
0(素通し)1.7300
12.900
46.800
1657.900

チェーンの構築(4段)は 61.3 ns / 128 B / 5 allocs。

読み取れることは2つあります。

実行時のアロケーションはゼロ。middleware を重ねること自体はメモリを食いません。食うのは各 middleware がやっている仕事(ログのフォーマット、JWT の検証)のほうです。

16段で単価が上がる。4段までは1段あたり約 1.3 ns だが、16段では約 3.5 ns になります。間接呼び出しが16回連鎖すると分岐予測が効かなくなるためと思われます。とはいえ 16段で 58 ns なので、HTTP ハンドラの処理時間(早くてもマイクロ秒オーダー)から見れば無視できます。

そして構築コスト 61 ns は、起動時に1回払えば済む。第1回で見たとおり、リクエストごとに組み直すのは損です。

4.6 で、自然変換とは何だったのか

ここまで自己射のモノイドの話はしましたが、「自然変換」の話をしていませんでした。

自然変換とは、関手から関手への射です。関手 があるとき、各対象 ごとに射 を用意します。ただし勝手に用意していいわけではなく、次の図式が可換になる(どちらの道を通っても同じ)ことが要求されます。

この条件を自然性といいます。噛み砕くと、「 は中身に何が入っていても、同じやり方で変換する」ということです。中身を見て振る舞いを変えてはいけません。

Go での例が分かりやすい。

// []T から *T への変換(先頭要素を取る)
func Head[T any](xs []T) *T {
    if len(xs) == 0 {
        return nil
    }
    return &xs[0]
}

Head は「スライス Functor」から「ポインタ Functor」への自然変換です。自然性が言っているのは、次の2つが等しいということです。

// 先に写してから先頭を取る
MapPtr(Head(xs), f)

// 先に先頭を取ってから写す
Head(MapSlice(xs, f))

Head は要素の中身をまったく見ていません。見ているのは「空かどうか」という外側の構造だけです。だから f をどのタイミングで適用しても結果が同じになります。もし Head が「int なら先頭、string なら末尾」のように中身の型によって振る舞いを変えたら、自然性は壊れます

ここで、*T をどういうものとみなしているかを書いておきます。アドレスや別名(aliasing)を観測できるポインタとしてではなく、「nil か、T の値がひとつあるか」を表す Option 型として扱っています。等しさも「両方 nil か、指している先の値が等しいか」で判定します。

この断りが要るのは、外延的等号だけでは足りないからです。MapPtr(Head(xs), f)Head(MapSlice(xs, f)) は別々に確保されたポインタなので、アドレスを値として見るかぎり別物です。アドレスを観測しないと決めてはじめてHead は自然変換になります。数学としてきれいにやるなら、ポインタではなく明示的な Option[T] を使うところですね。

4.7 middleware は自然変換ではない

さて、ここで「middleware も自然変換だ」と言いたくなります。言えません。

訂正(2026-08-13): 公開当初、この節の結論として「再利用可能な middleware の条件は、圏論の言葉では自然性だ」と書いていました。誤りです。 自然変換は関手から関手への射なので、どの2つの関手の間の話なのかが決まっていなければ、そもそも自然変換になりえません。http.Handler は圏の対象であって関手ではなく、Middleware はその上の自己射です(4.1)。「どのハンドラにも一様に効く」という性質だけでは、自然性の条件を満たしたことになりません。

4.1 で「自己関手ではなく自己射」と訂正しておきながら、この節で同じ誤りを別の形で繰り返していました。第4回 3.3 では同じ構図に正しく但し書きを入れてあるので、連載内で食い違っていたことになります。

言えるのはここまでです。包む相手の中身を見ない middleware は、広い範囲のハンドラにそのまま適用できます。Head の自然性を成り立たせているのも同じ「中身を見ない」ことなので、連想は働きます。ただし連想が働くというだけで、middleware が自然変換になっているわけではありません。

それでも、問いとしては十分に使えます。API 設計のレビューで「この共通処理は中身を見ているか?」と問えばいいです。ロギング middleware はハンドラの中身を知らないので、どのハンドラにも付けられます。逆に「特定のハンドラのときだけ挙動を変える middleware」は適用範囲が狭く、テストもしづらくなります。

ここで一点、混同しやすいので釘を刺しておきます。中身を見ないことは、順序を入れ替えてよい理由にはなりません。 4.3 で見たとおり middleware の合成は非可換で、それは中身を見るかどうかとは関係のない話です。一様性が保証してくれるのは「どのハンドラにも付けられる」ことまでで、「どの順に付けてもよい」ことではありません。

そして、使えるのは問いのほうであって、「自然変換だから」という根拠づけのほうではありません。


5. 積と余積 —— struct と、Go に無いもの

5.1 struct は積だ

// Pair は積。struct がこれにあたる。
type Pair[A, B any] struct {
	First  A
	Second B
}

// Fanout は積の普遍性そのもの。C から A への射と C から B への射があれば、
// C から Pair[A, B] への射がただ一つ決まる。
func Fanout[C, A, B any](f func(C) A, g func(C) B) func(C) Pair[A, B] {
	return func(c C) Pair[A, B] { return Pair[A, B]{First: f(c), Second: g(c)} }
}

積の普遍性とは、「 から への射と から への射があれば、 から積への射がただ一つ決まる」という性質です。Fanout がそれを実装しています。逆向きには射影(.First.Second)があります。

節タイトルは短く「struct は積だ」としましたが、正確にはどんな struct でも積になるわけではありませんPair[A, B] のように射影が揃っていて、フィールドの組み合わせに余計な不変条件が課されていないものが積です。「両方 nil は許さない」といった制約を持つ struct は、値の組み合わせが減っているので積ではなくなります。

// 積の普遍性: Fanout で作った射を射影で戻すと、元の2本の射に一致する。
func TestFanoutSatisfiesUniversalProperty(t *testing.T) {
	f := func(n int) string { return strconv.Itoa(n) }
	g := func(n int) bool { return n%2 == 0 }
	both := Fanout(f, g)

	rapid.Check(t, func(t *rapid.T) {
		n := rapid.Int().Draw(t, "n")
		p := both(n)

		if p.First != f(n) || p.Second != g(n) {
			t.Fatalf("積の普遍性が破れた: n=%d, pair=%+v", n, p)
		}
	})
}

実務では、同じ入力から複数の値を計算して構造体に詰める場面すべてがこの形です。「ユーザーIDから、表示名と権限とアバターURLを作る」。3本の射があれば、構造体を作る射が自動的に決まります。

5.2 Go には余積が無い

積の双対が余積(直和)です。「 のどちらか」を表す型。Rust の enum、TypeScript の判別可能ユニオン、Haskell の Either にあたる。

Go にはありません。

無いので作ります。

// Either は余積(直和)。Go に直和型が無いのでタグ付きの構造体で表す。
type Either[A, B any] struct {
	isRight bool
	left    A
	right   B
}

// Left は余積の左側への入射。
func Left[A, B any](a A) Either[A, B] { return Either[A, B]{left: a} }

// Right は余積の右側への入射。
func Right[A, B any](b B) Either[A, B] { return Either[A, B]{isRight: true, right: b} }

// Fanin は余積の普遍性。A から C への射と B から C への射があれば、
// Either[A, B] から C への射がただ一つ決まる。
// 分岐が構造体の中に閉じているので、case の書き漏らしが起こりえない。
func Fanin[A, B, C any](onLeft func(A) C, onRight func(B) C) func(Either[A, B]) C {
	return func(e Either[A, B]) C {
		if e.isRight {
			return onRight(e.right)
		}
		return onLeft(e.left)
	}
}

Fanin は積の Fanout を矢印の向きだけ逆にしたものです。積では「2本の射から1本を作って送り込む」、余積では「2本の射から1本を作って受け取る」。

5.3 余積が無いことの実害

Go で「 か」を表現する方法は、だいたい3つあります。

方法1: インターフェース

type Shape interface{ isShape() }

type Circle struct{ R float64 }
type Rect struct{ W, H float64 }

func (Circle) isShape() {}
func (Rect) isShape()   {}

未公開メソッドで実装を封じます(sealed interface)。ですが switch の網羅性はコンパイラが検査してくれません

func Area(s Shape) float64 {
    switch v := s.(type) {
    case Circle:
        return math.Pi * v.R * v.R
    case Rect:
        return v.W * v.H
    }
    return 0  // ← Triangle を足したとき、ここに落ちる
}

新しい型を足したとき、コンパイラは何も言いません。実行時に静かに 0 を返します。Rust なら match が非網羅的だとコンパイルエラーになります。ここが Go の弱点です。

方法2: 全フィールドを持つ struct

type Result struct {
    Value *int
    Err   error
}

「両方 nil」「両方非 nil」というあり得ない状態が表現できてしまう。積で余積を代用すると必ずこうなります。 で表そうとすれば、状態数が合いません。

方法3: Either のようなタグ付き型

上で作ったもの。Fanin を使う限り分岐漏れは起こりません。Fanin が両方の射を要求するので、片方を忘れたらコンパイルエラーになるからです。

実務での落としどころは、次のようになると思います。

  • 標準的な「値かエラー」は (T, error) を使います。Go の作法から外れるべきではない
  • 3つ以上の状態を持つドメイン型(注文ステータス、決済手段、通知チャネル)は sealed interface + exhaustive linter を CI に入れます。linter が Rust の match の代わりをする
  • ライブラリの内部でパターンが固定されるなら Fanin 方式が安全

いずれにせよ、「これは余積である」と認識できていることが先に立ちます。認識できていないと、方法2を選んで、あり得ない状態のバリデーションを延々と書くことになります。

5.4 カリー化 —— 次回への伏線

積があると、もう一つの構造が現れます。

// Curry は f(Pair[A, B]) C を f(A)(B) C に変える。積と冪の随伴の片側。
func Curry[A, B, C any](f func(Pair[A, B]) C) func(A) func(B) C {
	return func(a A) func(B) C {
		return func(b B) C { return f(Pair[A, B]{First: a, Second: b}) }
	}
}

// Uncurry は Curry の逆向き。往復すると元の射に戻る。
func Uncurry[A, B, C any](f func(A) func(B) C) func(Pair[A, B]) C {
	return func(p Pair[A, B]) C { return f(p.First)(p.Second) }
}

「2引数関数」と「1引数関数を返す1引数関数」が同じものだ、という同型。これは随伴(adjunction)と呼ばれる関係の一例で、圏論のもっとも強力な道具の一つです。Go の Functional Option パターンがこの形をしています。第3回で扱います。


6. 余談 —— property-based test が実際にバグを見つけた

今回のコードを書いているとき、テストが90秒でタイムアウトしました。

犯人は Kleisli 合成のテストに使っていた、この射でした。

func sqrtInt(n int) (int, error) {
	if n < 0 {
		return 0, errNegative
	}
	r := 0
	for r*r <= n {
		r++
	}
	return r - 1, nil
}

整数の平方根を素朴に求める関数です。rapid.Int()math.MaxInt 近傍の値を引いたとき、r は 30億回以上ループします。さらに r*r がオーバーフローして負になれば、r*r <= n が成立し続けて無限ループになります。

自分で選んだ例なら絶対に踏みません。テストに sqrtInt(16) と書いて満足していたはずです。ランダム生成器が、人間が選ばない値を投げてくれたおかげで見つかりました。

property-based test の価値は、法則の検証だけではありません。入力空間の端(境界値、極端な値、オーバーフロー)を機械的に攻めてくれることにあります。今回は「射を圏の言葉で検証する」という目的で導入しましたが、副産物のほうが実務的な価値が高いかもしれません。

なお、この射は結局「偶数だけを半分にする」という単純なものに差し替えました。テストの主題は結合律であって、平方根の実装ではないからです。


7. まとめ

Functor

  • Functor とは「中身に射を適用し、外側の構造を保つ」写像。[]T*Titer.Seq[T](T, error) がそれぞれ違う構造を保っている
  • Functor 則2(合成の保存)はループ融合の許可証。実測でメモリは小さい配列で 1/3・大きい配列で半分、速度は小さい配列で 1.8倍(大きい配列では 5% 程度)
  • iter.Seq は同じ Functor 則を満たしながら評価戦略が違います。法則が同じなら、あとは性能で選べばよい
  • filter は Functor ではない(構造を保たない)。だから map だけのパイプラインほど自由に括り直せない

Monoid

  • モノイドは対象が1つの圏。errors.Joinstrings.Buildertime.Duration、多段ソートの比較関数、cmp.Or がすべてこの構造
  • ただし「モノイドである」と言うには先に何を等しいとするかを決める必要があります。errors.Join なら「分岐に使うセンチネルの集合を固定して、errors.Is の真偽が一致すること」。MaxDuration なら「duration >= 0 に限る」
  • 比較関数をモノイドとして扱うと、「標準の並び順」を部品にして後から条件を足せる
  • 結合律は並列化の許可証。ただし損益分岐点は 2〜3万要素で、1,000要素では8倍遅い。可能にするだけで、得を保証しない
  • 平均は結合的でないので並列化すると壊れる(5.5 が 6.17 になった)。合計と個数に分ければ直る。MapReduce の定石はこの変形だった
  • float64 の加算はそもそも結合的でない。
  • モノイド則は実装差し替えの許可証。同じ答えを返す実装の間に 55倍の差がありました。ただし速くすべきは2要素の演算ではなく畳み込み全体

Kleisli 合成

  • if err != nil は、合成できない射を人間が繋いでいる作業。専用の合成器を作れば1か所に閉じ込められる
  • 合成器に ctx.Err() を1回書けば全段に効きます。横断的関心事を1か所に集約できるのが実利
  • ただし Go はメソッドに型パラメータを追加できないため、メソッドチェーンにならず関数のネストになります。読みやすさで不利
  • 一直線・組み替えて再利用・全段に一律の処理、が揃ったときだけ合成器が勝ちます。1本のハンドラなら if err != nil のほうが読みやすい

middleware と自然変換

  • middleware は http.Handler 上の自己射(endomorphism)。自己関手ではない。その全体は関数合成に関してモノイドをなす
  • 結合律(括り方は自由)と可換律(順序は自由)は別物。middleware には前者だけがある
  • 実行時のアロケーションはゼロ、16段でも 58 ns。構築コスト 61 ns は起動時に1回払えばよい
  • 自然変換は関手から関手への射。Head(スライス Functor → ポインタ Functor)がその例
  • middleware は自然変換ではないhttp.Handler は対象であって関手ではないので、そもそも自然性を問える形になっていない。ただし「中身を見ない」という一様性は両者に共通していて、再利用できるかを問う道具にはなる

積と余積

  • struct は積(射影が揃っていて、余計な不変条件が無ければ)。同じ入力から複数の値を作って詰める処理は、すべて積の普遍性
  • Go には余積(直和)がありません。sealed interface では switch の網羅性が保証されず、全フィールドを持つ struct ではあり得ない状態が表現できてしまう
  • 落としどころは (T, error) + sealed interface + exhaustive linter。だがまず「これは余積である」と認識できることが先

次回は、ここまで出てきた構造を積み上げます。Functor から Applicative へ、そして Monad へ。何が増えるとバリデーションのエラーメッセージがどう変わるのか。式木を fold ひとつで畳んで評価・整形・最適化を書き分けます。iter.Seq が実は継続渡し(CPS)だったという話。

そして Go の型システムの天井を、正面から測る。Functor[F[_]] が書けないことの回避策を3通り実装して、それぞれの速度を比べます。

(次回「Goに高階カインドはない —— Goで書く実践圏論(3)」に続く)

この記事のコード: part2/ — 何を実行して何を見ればいいかは、そのディレクトリの README にまとめてあります。Codespaces で開けばそのまま動きます。

連載一覧

  1. Goの関数と型は圏をなす
  2. errors.Join はモノイドである(本記事)
  3. Goに高階カインドはない
  4. 圏論でワークフローエンジンを組み直したら1ステップ30ns