圏論を学ぼうとして挫折した人って、だいたい Haskell で挫折していませんか。
書店で入門書を開くと、Functor、Applicative、Monad が型クラスとして並んでいて、instance Functor Maybe where fmap = ... みたいなコードが出てきます。高階カインド多相(型を取って型を返す型を、そのまま抽象化する能力)が当たり前のように使われています。Go を書いている人間がこれを読むと、だいたい同じ感想にたどり着くと思います。
これ、うちの言語ではそもそも書けないやつでは?
半分は当たっています。Go には型クラスも高階カインドもありません。Functor[F[_]] は書けません。だから道具をそのまま輸入することはできません。
でも残りの半分は外れています。圏論が扱っているのは Haskell の型クラスじゃないんです。合成です。小さいものを繋いで大きいものを作ります。その繋ぎ方がどんな法則に従うか。話はそれだけ。そして Go のコードって、ほとんどが合成でできていますよね。http.Handler を包む middleware も、errors.Join も、iter.Seq も、(T, error) の受け渡しも、全部そうです。
読み終わったときに手に入るもの
先に言っておくと、圏論を学んでも書けるコードが増えるわけではありません。増えるのは判断の材料です。
この4回で、次のような問いに根拠を持って答えられるようになります。
- この集計、並列に分割して大丈夫?(→ モノイドかどうかで決まります。第2回で損益分岐点も測ります)
- このバリデーション、エラーを全部返せる? 最初の1件で止まる?(→ 検証が互いに独立かどうか。第3回)
- この共通処理、本当に使い回せる?(→ 中身を見ていなければ使い回せます。第2回)
- 2回まわしているループ、1回にまとめていい?(→ Functor 則が保証してくれます。第2回でメモリが半分以下になるのを測ります)
- リトライを入れて大丈夫?(→ その処理が純粋かどうか。今回)
どれも「なんとなく気持ち悪い」で止まりがちな判断です。圏論の言葉を知っていると、これが「この演算は結合的でないので分割できません」に変わります。レビューで通る言い方になるわけですね。
もうひとつ、この連載では抽象化するたびに値段を測ります。合成を1段挟むと何ナノ秒かかるのか。any で型を消すといくら払うのか。「たぶん遅くならない」ではなく数字で出します。今回の実測でいちばん驚いたのは、合成した関数は変数に入れて使い回せばコストがほぼゼロなのに、呼ぶたびに組み直すと7倍以上遅くなることでした。
この連載で使うのは Go だけです。Haskell も Elixir も出てきません。そして出てくるコードは全部、実際にコンパイルが通り、テストが通り、ベンチマークが回ります。数字はすべて手元で測ったものです。
コードは以下に置いてあります。
- https://github.com/makoto-developer/category-theory-go
- ブラウザですぐ試す: GitHub Codespaces で開く(Go を入れずに
go test ./...が走ります)
go vet ./...
go test ./... # 圏の法則の検証(property-based test を含む)
go test -bench=. -benchmem ./... # 実測
インストールもログインも無しで、いますぐ結果だけ見たいという方へ。下のサンドボックスは、この記事の山場を1ファイルに縮めたものです。結合律と単位律が本当に成り立つこと(1万通りのランダムな入力で確かめます)と、法則が無いと何が壊れるか(メモ化すると答えが変わる、並列化した平均がずれる)が、そのまま流れます。数字を書き換えて走らせ直すこともできます。
このサンドボックスが動かしている main.go の正本は、リポジトリの examples/laws/main.go です。起動時に GitHub から取ってくる作りにしてあるので、リポジトリを直せばサンドボックスの中身も最新になります。
連載は全4回の予定です。
| 回 | 内容 |
|---|---|
| 第1回(本記事) | 圏とは何か。Go の型と関数が圏をなすことの確認。純粋性が守っているもの。合成のコスト |
| 第2回 | Go の標準ライブラリと日常コードに現れる Functor / Monoid / Kleisli 合成 / 自然変換 |
| 第3回 | Applicative、F代数と fold、iter.Seq と CPS、そして Go の型システムの天井 |
| 第4回 | 圏論の道具でワークフローエンジンを組み直し、得したところと壊れたところを測る |
1. 圏とは何か
1.1 定義から入らない
圏の定義から入ると、たいてい失敗します。「対象の類と射の類があって、合成が定義されていて、結合律と単位律を満たすもの」と言われても、自分の書いているコードとどう繋がるのか見えないからです。
なので逆から行きましょう。まず、毎日やっていることを見てください。
func handleRequest(raw []byte) (Response, error) {
req, err := parse(raw)
if err != nil {
return Response{}, err
}
user, err := authenticate(req)
if err != nil {
return Response{}, err
}
result, err := process(user)
if err != nil {
return Response{}, err
}
return render(result), nil
}
やっていることは1つしかありません。繋いでいるんです。[]byte から Request へ、Request から User へ、User から Result へ、Result から Response へ。矢印を4本つないで、[]byte から Response への長い矢印を1本作っています。
プログラミングの大半はこれですよね。小さい変換を用意して、繋いで、大きい変換にしていきます。関数もそう、HTTP middleware もそう、シェルのパイプもそう、CI のジョブもそう、マイクロサービスの呼び出し連鎖もそうです。
圏論は、この「繋ぐ」だけを取り出して性質を調べる数学です。裏を返せば、繋げるものなら何にでも当てはまる。関数に限りません。汎用性がとても高い。そして高すぎるせいで抽象的に見えてしまう、というわけです。
1.2 圏の定義
そのうえで定義を見てみましょう。圏(category)とは、次の4つ組のことです。
- 対象(object)の集まり
- 各対象のペア に対して、 から への射(morphism)の集まり。 と書く
- 合成(composition)。 と があるとき、 が定まる
- 各対象 に対する恒等射
そして、次の2つの法則を満たす必要があります。
結合律(associativity)
単位律(identity law)
以上です。これで圏の全部。対象が何なのか、射が何なのかは一切問われません。繋げること、繋ぎ方が結合的であること、何もしない繋ぎ目があること。要求はこの3つだけです。
驚くほど少ないですよね。だからこそ、あちこちに圏が見つかります。
1.3 Go の型と関数は圏をなす
対応をとってみます。
| 圏の言葉 | Go |
|---|---|
| 対象 | 型(int, string, User, []byte, …) |
| 射 | func(A) B |
| 合成 | func(a A) C { return g(f(a)) } |
| 恒等射 | func(a A) A { return a } |
この対応で圏の公理が成り立つなら、「Go の型と関数は圏をなす」と言えます。この圏は、集合と関数のなす圏 Set の中に部分圏としてモデル化できるもの、と思ってください。「Go の型だけを取り出した Set」ではありません。集合のあいだの関数のうち Go で書けるものはごく一部なので、射が足りないんですね。
先に前提を書いておきます。 ここでは Go の型を集合、
func(A) Bを純粋かつ全域的な数学的関数とみなす簡略化をしています。「純粋」は同じ入力に必ず同じ出力を返し、外に影響を及ぼさないこと。「全域的」はどの入力に対しても必ず値を返すこと、つまり panic せず、無限ループにも陥らないことです。そして「2つの射が等しい」とは、Go の値としての
==ではなく、すべての入力に対して同じ値を返すこと(外延的等号)を指します。関数値は==で比較できませんし、比較できたところで意味がありません。この連載で「等しい」と書いたら、断りがない限りこの意味です。第2回でerrors.Joinを扱うとき、この「何を等しいとするか」がそのまま主題になります。実際の Go の関数はこの条件を満たすとは限りません。
panicする、時刻や乱数を読む、DB を叩く、goroutine を立てて返ってこない —— どれも起こりえます。言語がこの純粋性を強制してくれるわけではないので、成り立つのは「純粋とみなせる範囲に限る」という条件付きです。このモデルを外れる関数を混ぜたとき何が壊れるのかは、3章で実際に壊して確かめます。あとから前半を否定するのではなく、まず簡略化したモデルで話を通し、そのあとモデルを広げるという順序で進みます。
冒頭のリクエスト処理を、この言葉で描き直すとこうなります。
flowchart LR
A["[]byte"] -->|parse| B["Request"]
B -->|authenticate| C["User"]
C -->|process| D["Result"]
D -->|render| E["Response"]
A -.->|"合成した1本の射"| E
実線が個々の射、点線が合成の結果です。圏論が言っているのは「点線は実線をたどるのと同じものだ」、ただそれだけ。当たり前に見えますよね。でも、この「当たり前」がどこまで当たり前なのかを詰めていくと、あとで見るように条件が付いてきます。
ここで、コードを書くときに必ず引っかかる点を先に潰しておきましょう。合成の向きです。
数学の記法 は「まず 、次に 」と読みます。右から左に流れます。ところが Go でパイプラインを書くときは左から右に流したいわけです。
// 数学の記法に合わせるなら Compose(g, f) と書きたくなるが、
// コードの読み順(上から下、左から右)とは逆になる。
この連載では読み順を優先して、Compose(f, g) を「f してから g」と定義します。数学の にあたります。慣れるまでは混乱しますが、迷ったら型を見ればOKです。型が合う繋ぎ方は一通りしかありませんから。
1.4 Compose を書く
// Package part1 は連載「Goで書く実践圏論」第1回の検証コード。
// 型を対象、func(A) B を射とみなしたとき、圏の公理(結合律・単位律)が
// 本当に成り立つのかをテストで確かめる。
package part1
// Compose は f のあとに g を適用する射を返す。数学の記法では g∘f にあたる。
func Compose[A, B, C any](f func(A) B, g func(B) C) func(A) C {
return func(a A) C { return g(f(a)) }
}
// Identity は恒等射。合成における単位元になる。
func Identity[A any](a A) A { return a }
// Pipe は同じ型の上の射を左から順に合成する。
// 左畳み込みで書けるのは合成が結合的だから(右畳み込みでも結果は変わらない)。
func Pipe[A any](fs ...func(A) A) func(A) A {
out := Identity[A]
for _, f := range fs {
out = Compose(out, f)
}
return out
}
これで全部です。圏の定義に出てきた4つのうち、対象(型)と射(func(A) B)は Go が最初から持っています。だから自分で書くのは合成と恒等射だけ。しかも合わせて10行に満たない量です。
Pipe は Compose の可変長版で、同じ型の上を動く射(自己射、endomorphism)を並べて畳み込みます。この畳み込みが左からでも右からでも同じ結果になる——それがまさに結合律です。あとで実際に確かめます。
1.5 なぜ「法則」がそんなに大事なのか
ここが最初の山場です。「結合律が成り立ちます」と言われても、だから何? となりますよね。
法則とは、安全に書き換えてよい規則のことです。
結合律 が成り立ちます。これは、次の2つのコードが同じものだと保証されている、という意味になります。
// 書き方A: 前半をまとめてから最後を足す
parseAndAuth := Compose(parse, authenticate)
pipeline := Compose(parseAndAuth, process)
// 書き方B: 後半をまとめてから前に足す
authAndProcess := Compose(authenticate, process)
pipeline := Compose(parse, authAndProcess)
これ、日常的なリファクタリングそのものですよね。「この2ステップはひとまとまりだから関数に切り出そう」「共通部分をヘルパーにまとめよう」。関数を切り出すたびに、私たちは知らないうちに結合律を使っています。もし結合律が成り立たない世界だったら、括り方を変えた瞬間に挙動が変わってしまう。切り出しが怖くてできなくなります。
単位律も同じです。 を挟んでも何も変わらないということは、「何もしないステップを足したり消したりしても壊れない」という保証になります。デコレータやフィルタのチェーンに「今回は素通し」を差し込めるのは、単位律のおかげなんですね。
法則は制約ではありません。自由に書き換えていい範囲の宣言です。ここを取り違えると、圏論はただの用語集に見えてしまいます。
1.6 関数だけが圏ではない
ここまで「対象=型、射=関数」で話してきました。でも圏の定義に「関数」という言葉は一度も出てきませんでしたよね。繋げること、結合的であること、恒等射があること。この3つさえ満たせば何でも圏になります。
Go を書いていて出会う圏を、いくつか挙げてみましょう。
対象が1つしかない圏 —— モノイド
対象を1つだけにして、そこから自分自身への射をたくさん用意します。すると射の合成が、そのまま「値どうしの演算」になります。
| 圏の言葉 | Go での例 |
|---|---|
| 唯一の対象 | string |
| 射 | 「この文字列を末尾に足す」という操作 |
| 合成 | 連結 |
| 恒等射 | 空文字列を足す |
これは モノイド(結合的な二項演算と単位元を持つ構造)と同じ形をしています。errors.Join も、time.Duration の最大値も、多段ソートの比較関数も同様です。第2回で、それぞれ何を単位元とし、何を「等しい」とみなせばモノイドとして扱えるのかまで含めて詳しく扱います。
大事なのは、モノイドは「対象が1つの圏」という特殊ケースにすぎないという点です。並列集計が正しく動く理由も、middleware を好きな粒度でまとめられる理由も、出どころは同じ結合律。別々に覚えていたものが1つになります。これが圏論の効き目ですね。
対象間に射が高々1本しかない圏 —— 前順序
射が「 から へ行けるかどうか」だけを表す圏を考えます。行けるなら射が1本、行けないなら0本。値は持ちません。「行ける」という事実だけがあります。この形の圏を薄い圏(thin category)といいます。
- 型の代入可能性(
*bytes.Bufferはio.Writerとして使える) - セマンティックバージョンの互換性
- Kubernetes マニフェストの適用順序(Namespace → ConfigMap → Deployment)
- Makefile のターゲット依存
薄い圏が表しているのは前順序(preorder)です。半順序ではありません。半順序には「 かつ なら 」という反対称性が要りますが、圏の公理はそれを要求しないからです。実際、[]int と type S []int は互いに代入できますが、別々の型のままですよね。「行き来できるなら同じもの」とは限りません。
さて、ここで止まると「へえ、これも圏なんだ」で終わってしまいます。知ると何が変わるのかを書きます。
この形の圏では、圏の公理がそのまま順序の性質になります。恒等射があることが反射性( から へは行ける)、合成が定まることが推移性( から 、 から へ行けるなら から へ行ける)にあたります。結合律のほうは、平行する射が高々1本しかない以上、自動的に成り立ちます。だから行き方をどう括っても、行けるという事実は変わりません。
訂正(2026-08-13): 公開当初、ここに「結合律が『推移性』になります」と書いていました。誤りです。 推移性に対応するのは合成が定まることで、反射性に対応するのが恒等射の存在です。結合律は薄い圏では自動的に成り立つので、そもそも情報を持ちません。あわせて、見出しの「順序」も反対称性を要求していないので、正確には「前順序」でした。
これが効いてくるのは、依存関係を自分で解決するコードを書くときです。
たとえば「このパッケージは v1.2 以上が必要」という制約を集めて、入れるバージョンを決める処理。あるいは k8s のマニフェストを依存順に並べる処理。こういうものを書くとき、次の2つはまったく違います。
- 推移性がある: いちど求めた「行ける」を中間結果として積み上げて再利用できる。個々の対について得た「行ける」の合流は論理和なので、結合的でも可換でもあります
- 推移性がない: 全部の組み合わせを個別に確かめるしかない
ただし、グラフを分割して別々に閉包を求め、あとから和集合を取るだけでは足りません。部分1に 、部分2に があるとき、それぞれの閉包を足しても は出てきません。推移的な関係どうしの和集合は、一般に推移的でないからです。分割するなら、境界をまたぐ経路を合流後にもう一度たどる必要があります。
そして厄介なのは、推移性を壊す制約を人はうっかり書いてしまうことです。「A は B より後」「B は C より後」なら「A は C より後」——これは推移的でいいですね。ところが「A と B は同時に動かしてはいけない」という制約を同じ仕組みに混ぜると、途端に壊れます。排他は推移的ではないからです(A と B が排他、B と C が排他でも、A と C は同時に動いてよい)。
依存グラフのソートに排他条件を混ぜて、なぜか順序が安定しない——という不具合は、この混同から生まれます。「これは前順序か、それとも別のものか」と問えば、混ぜてはいけないものが見えてきます。
もう1つ。この圏では、2つの対象の間に射は高々1本しかありません。「行ける」に種類がないんですね。逆に言うと、行き方を区別したいなら、それは前順序では表せません。「このバージョンには2通りのアップグレード経路があって、経路によって結果が違う」なら、モデルを変えないといけません。マイグレーションの設計で、経路依存があるのに単なる依存グラフで扱おうとすると、ここで破綻します。
対象が状態、射が遷移の圏 —— 状態機械
flowchart LR
Draft -->|submit| Pending
Pending -->|approve| Paid
Pending -->|reject| Canceled
Paid -->|refund| Refunded
注文のステータス遷移も、射を「遷移を繋いだ経路」に取れば圏になります。ここは一手間いります。図に描いた矢印そのもの(1ステップの遷移)の集まりは、合成で閉じていないのでまだ圏ではありません。経路を射に取ると、合成(経路の連結)と恒等射(長さ0の経路)が揃って圏になります。遷移グラフからこうして作った圏を自由圏といいます。
訂正(2026-08-13): 公開当初、ここを「注文のステータス遷移も圏です」とだけ書いていました。一手順飛ばしていました。 遷移の集まりをそのまま射とすると合成で閉じないので、圏になるのは経路を射に取った自由圏のほうです。以下の議論はその上でなら成り立ちます。
ここで圏の視点が仕事をします。Draft → Pending と Pending → Paid があるなら、合成として Draft → Paid が必ず存在します(経路として、です)。「下書きから直接支払い済みにはできない」と実装で禁じたいなら、それは射が無いのではなく、1ステップの遷移として名前を付けずに済ませているだけなんですね。だって「submit してから approve すれば到達できる」のですから。
この区別が効いてきます。「不正な状態遷移を禁止する」と言うとき、禁止したいのは「1ステップで飛ぶこと」なのか「そこへ到達すること自体」なのか。前者ならガードで済みます。後者なら状態設計そのものが間違っています。圏として見ると、この2つを混同できなくなります。
1.7 圏にならないもの
逆に、圏にならない例も見ておくと輪郭がはっきりします。
合成が定義できない: 「1時間以内に終わる処理」だけを射としてみましょう。 が50分、 が50分。繋いだ は100分で、条件を満たしません。合成で閉じていないので圏ではないわけです。リソース制約やタイムアウトを型に埋め込むと、たいていこの罠にはまります。「各ステップは1秒以内」を保証しても、繋いだ全体が1秒以内になるわけではありません。SLO を積み上げるときに毎回やらかすやつです。
恒等射がない: 「必ず何かを変更する操作」だけを射とします。何も変更しない射が存在しないので、圏になりません。マイグレーションのフレームワークで「空のマイグレーションは許さない」と決めると、この構造を壊してしまいます。すると差分が空のときだけ特別扱いが必要になって、コードに if が増えていきます。
結合律が成り立たない: 浮動小数点の加算を合成とみなした、対象が1つの構造です。 が普通に起きます。第2回で実際に壊してみせます。壊れているのは「加算を合成とみなした構造」のほうで、float64 を足す関数そのものは func(float64) float64 の圏では普通に射です。念のため。
並べてみると、「圏である」はそこまできつい条件ではありません。でも無条件でもありません。そして経験上、設計が使いにくいと感じるとき、この3つのどれかが壊れていることが多いです。
2. 公理をテストで確かめる
「成り立ちます」で済ませるのは、このブログの趣味じゃありません。テストを書いて確かめましょう。
2.1 例ではなく性質をテストする
圏の公理は「すべての入力について」成り立つ、という主張です。だから、特定の入力で試すテスト(example-based test)とは相性がよくありません。
// これでは「42のときは結合律が成り立つ」しか言えていない
if left(42) != right(42) {
t.Fatal("結合律が破れた")
}
こういうときに出番なのが property-based test(性質ベーステスト)です。入力をランダムに大量生成して、性質が破れないかを確かめます。Go なら rapid が使いやすいですね。破れる入力を見つけたら、自動で最小の反例まで縮めてくれます(shrink)。ここがこの手のツールの一番おいしいところです。
テストで使う射を用意します。型が int → string → int → bool と移っていく、対象が実際に変わる例にしました。
package part1
import "strconv"
// テストとベンチで共通に使う射。int → string → int → bool と対象が移っていく。
func itoaFn(n int) string { return strconv.Itoa(n) }
func lengthFn(s string) int { return len(s) }
func isEvenFn(n int) bool { return n%2 == 0 }
// 同じ射を関数値としても持つ。直接呼び出しとの差を測るため。
var (
itoa = itoaFn
length = lengthFn
isEven = isEvenFn
)
// Pipe 用の、同じ型の上を動く射。
func double(n int) int { return n * 2 }
func increment(n int) int { return n + 1 }
func negate(n int) int { return -n }
2.2 結合律
// 結合律: (h∘g)∘f と h∘(g∘f) はどんな入力に対しても等しい。
func TestComposeIsAssociative(t *testing.T) {
left := Compose(Compose(itoa, length), isEven)
right := Compose(itoa, Compose(length, isEven))
rapid.Check(t, func(t *rapid.T) {
x := rapid.Int().Draw(t, "x")
if left(x) != right(x) {
t.Fatalf("結合律が破れた: x=%d, (h∘g)∘f=%v, h∘(g∘f)=%v", x, left(x), right(x))
}
})
}
left は「itoa と length を先にまとめてから isEven」、right は「length と isEven を先にまとめてから前に itoa」。括り方が違いますね。それでも、どんな int を入れても結果は一致します。
2.3 単位律
// 単位律: 恒等射を前後どちらに合成しても元の射と等しい。
func TestIdentityIsUnit(t *testing.T) {
rapid.Check(t, func(t *rapid.T) {
x := rapid.Int().Draw(t, "x")
if got := Compose(Identity[int], itoa)(x); got != itoa(x) {
t.Fatalf("左単位律が破れた: x=%d, f∘id=%q, f=%q", x, got, itoa(x))
}
if got := Compose(itoa, Identity[string])(x); got != itoa(x) {
t.Fatalf("右単位律が破れた: x=%d, id∘f=%q, f=%q", x, got, itoa(x))
}
})
}
Identity[int] と Identity[string] が別物になっているところに注目してください。恒等射は対象ごとに1本ずつあります。型パラメータがそれをそのまま表しているわけですね。
2.4 結合律の実用的な帰結
Pipe は左畳み込みで実装しました。右畳み込みでも同じになるはずです。確かめてみます。
// 結合律の実用上の帰結: 畳み込む向きを変えても Pipe の結果は変わらない。
func TestPipeFoldDirectionDoesNotMatter(t *testing.T) {
fs := []func(int) int{double, increment, negate}
leftFold := Pipe(fs...)
rightFold := Compose(fs[0], Compose(fs[1], fs[2]))
rapid.Check(t, func(t *rapid.T) {
x := rapid.Int().Draw(t, "x")
if leftFold(x) != rightFold(x) {
t.Fatalf("畳み込みの向きで結果が変わった: x=%d, 左=%d, 右=%d", x, leftFold(x), rightFold(x))
}
})
}
// 空の Pipe は恒等射になる(射の列の「単位元」)。
func TestEmptyPipeIsIdentity(t *testing.T) {
rapid.Check(t, func(t *rapid.T) {
x := rapid.Int().Draw(t, "x")
if got := Pipe[int]()(x); got != x {
t.Fatalf("空の Pipe が恒等射でない: x=%d, got=%d", x, got)
}
})
}
Pipe()(引数ゼロ)が恒等射になる点も見逃せません。これは実装の都合ではなく、空の合成は恒等射という圏の性質がそのまま出ています。「ミドルウェアを1個も設定しなかったら素通し」「フィルタが空なら全件返す」——こういう挙動を自然に感じるのは、この構造のおかげなんですね。逆に、空のときに例外を投げたり nil を返したりする API は、この構造を壊しています。使いにくいライブラリには、たいてい理由があります。
$ go test ./part1/
ok github.com/makoto-developer/category-theory-go/part1 0.373s
通りました。Go の型と関数は、確かに圏をなしています。
2.5 ただし、通ったのは「今回の射」についてだけ
ここは正直に書いておきます。上のテストが示したのは、itoa、length、isEven、double、increment、negate という具体的な射で結合律が成り立つ、それだけです。Go のすべての関数について証明したわけではありません。
そして実際、Go の関数の中には、圏の射として振る舞わないものがあります。次の章はそこから始まります。
3. 圏が壊れるとき —— 「純粋関数にしろ」は何を守っていたのか
「関数はできるだけ純粋にしろ」「副作用は端に寄せろ」。どこでも聞くアドバイスですよね。でも「なぜ?」と聞かれると、案外答えにくくないですか。「テストしやすいから」「読みやすいから」あたりで止まってしまいます。
圏の言葉を借りると、この経験則が何を守っているのかがはっきりします。順に壊してみましょう。
3.1 メモ化で壊れる
まず、実際に壊すための道具を用意します。
// Memoize は入力ごとに結果を覚える。射が純粋なときに限り、元の射と等価な射になる。
// 記事の議論に必要な最小実装なので並行安全ではない。
func Memoize[A comparable, B any](f func(A) B) func(A) B {
cache := make(map[A]B)
return func(a A) B {
if b, ok := cache[a]; ok {
return b
}
b := f(a)
cache[a] = b
return b
}
}
// Counter は呼ばれた回数を足して返す射を作る。同じ入力に違う結果を返すので純粋ではない。
func Counter() func(int) int {
var n int64
return func(x int) int { return x + int(atomic.AddInt64(&n, 1)) }
}
純粋な射なら、メモ化しても外から見て何も変わりません。
// 純粋な射なら、メモ化しても元の射と区別がつかない。
func TestMemoizeIsTransparentForPureMorphism(t *testing.T) {
memoized := Memoize(itoa)
rapid.Check(t, func(t *rapid.T) {
x := rapid.Int().Draw(t, "x")
if memoized(x) != itoa(x) {
t.Fatalf("メモ化で結果が変わった: x=%d, memo=%q, 元=%q", x, memoized(x), itoa(x))
}
if memoized(x) != memoized(x) {
t.Fatalf("メモ化した射が同じ入力に違う結果を返した: x=%d", x)
}
})
}
ところが Counter はそうなりません。
// 純粋でない射をメモ化すると、2回目から結果が食い違う。
// 「純粋関数にしておけ」という経験則が、圏の言葉では何を守っているのかがここに出る。
func TestMemoizeChangesResultForImpureMorphism(t *testing.T) {
impure := Counter()
memoized := Memoize(impure)
first := memoized(10)
second := memoized(10)
direct := impure(10)
if first != second {
t.Fatalf("メモ化した射が同じ値を返していない: 1回目=%d, 2回目=%d", first, second)
}
if direct == first {
t.Fatalf("メモ化の有無で結果が一致してしまった(Counter が純粋になっている): %d", direct)
}
t.Logf("メモ化: %d, %d / メモ化なし: %d", first, second, direct)
}
=== RUN TestMemoizeChangesResultForImpureMorphism
purity_test.go:44: メモ化: 11, 11 / メモ化なし: 12
--- PASS: TestMemoizeChangesResultForImpureMorphism
同じ impure(10) という式が、キャッシュを挟むかどうかで 11 にも 12 にもなってしまいました。
壊れているのは、「同じ式は同じ値に置き換えていい」という前提です。数学ではこれを参照透過性と呼びます。 と書いたらそれは1つの値であって、何回書いたかで変わったりしません。圏の射も同じです。 は「 の各元に の元を1つ対応させるもの」なので、対応先が呼び出しごとに変わる関数は、そもそも射ではないんですね。
つまり Counter は Go の関数ではあるけれど、圏の射ではないわけです。
3.2 リトライで壊れる
同じ問題は、もっと身近な形でも出てきます。リトライです。
// Retry は f を最大 attempts 回まで呼び直す。純粋な射に対しては何度呼んでも結果が変わらない。
func Retry[A, B any](f func(A) (B, error), attempts int) func(A) (B, error) {
return func(a A) (B, error) {
var b B
var err error
for range attempts {
if b, err = f(a); err == nil {
return b, nil
}
}
return b, err
}
}
// 純粋でない射をリトライすると副作用が二重に走る。
func TestRetryRunsEffectTwiceForImpureMorphism(t *testing.T) {
calls := 0
flaky := func(x int) (int, error) {
calls++
if calls == 1 {
return 0, errors.New("一時的な失敗")
}
return x, nil
}
got, err := Retry(flaky, 3)(7)
// ... 検証は省略
if calls != 2 {
t.Fatalf("呼び出し回数が想定と違う: got=%d, want=2", calls)
}
}
当たり前のことを言っているように見えますよね。「リトライしたら2回呼ばれます。そりゃそうだ」と。でも、この「当たり前」が本番で牙を剥いた形が、課金が2回走った、メールが2通飛んだ、在庫が2回引かれた、という事故です。
リトライは「この射は純粋だから何度呼んでも同じ」という仮定に乗っかっています。純粋でない射をリトライで包むなら、冪等性を別に用意しないといけません。冪等キーを持たせる、INSERT ... ON CONFLICT DO NOTHING にする、処理済みIDを記録します。どれも「純粋でない射を、外から見て純粋に見せる」ための工作です。
圏論の言葉が効くのは、こういう場面です。「リトライを入れるべきか」という漠然とした問いが、「この射は圏の射になっているか。なっていないなら、どう見せかけるか」に変わります。答えを出せる形になります。
3.3 では、何が守られていたのか
整理しましょう。射が純粋なら、次のことが安全にできます。
| やりたいこと | 純粋なら安全な理由 |
|---|---|
| 共通部分式を変数に括り出す | 同じ式は同じ値なので、評価回数を変えてよい |
| メモ化・キャッシュを挟む | 2回目の呼び出しを1回目の結果で置き換えてよい |
| リトライで包む | 何度呼んでも同じ結果、副作用の重複がない |
| 並列に実行する | 実行順序が結果に影響しない |
| テストでモックを減らす | 入力を渡して出力を見るだけで検証が閉じる |
| 遅延評価する(使うときまで呼ばない) | いつ呼んでも同じ結果 |
「純粋関数にしろ」というアドバイスは、この6つをまとめて買うための投資だったわけですね。逆に言えば、6つのうち1つも使わないコードなら、純粋性にこだわる意味は薄いとも言えます。
3.4 現実の Go コードでどこに線を引くか
とはいえ、実務で書くアプリケーションの多くは、DB・HTTP・時刻・乱数といった副作用を完全に避けられません。副作用は必要です(コンパイラやパーサ、変換ツールのように、ほぼ純粋なまま書けるプログラムもありますが、少数派でしょう)。
落としどころは、副作用のある射と無い射を混ぜない、これに尽きます。
// 混ざっている: 取得も計算も保存も1本の中にある
func UpdateUserScore(ctx context.Context, db *sql.DB, userID int) error {
user, err := fetchUser(ctx, db, userID)
if err != nil {
return err
}
score := user.Base*2 + user.Bonus // ここだけが純粋な計算
return saveScore(ctx, db, userID, score)
}
// 分けた: 計算は純粋な射、IO は外側
func CalculateScore(user User) int { // これは圏の射
return user.Base*2 + user.Bonus
}
func UpdateUserScore(ctx context.Context, db *sql.DB, userID int) error {
user, err := fetchUser(ctx, db, userID)
if err != nil {
return err
}
return saveScore(ctx, db, userID, CalculateScore(user))
}
「関数型コア、命令型シェル」(functional core, imperative shell)と呼ばれる設計です。中心に純粋な射だけの層を置いて、その周りを IO が薄く囲みます。中心は圏として扱えるので、合成もテストも並列化も自由にできます。外側は圏ではないので、素直に手続き的に書きます。
大事なのは、線を引く場所を自分で決めること。線がどこにあるか分からないコードは、どこまで書き換えていいかも分かりません。
4. 抽象化のコストを測る
ここまでは「合成はいいものだ」という話をしてきました。次は値段の話です。
Compose はクロージャを作ります。クロージャは関数値なので、呼び出しが間接呼び出しになるかもしれません。Go は関数のインライン化が控えめな言語なので、抽象化の代金が無視できない可能性もあります。
測ってみましょう。
4.1 測定環境
goos: darwin
goarch: arm64
cpu: Apple M5 Pro (18 threads)
go version go1.25.1 darwin/arm64
go test -bench=. -benchmem -count=6 -run='^$' ./part1/
以下の数値はすべて -count=6 の代表値です。
4.2 まず、最も差が出た結果から
// 合成した射を変数に保持してから呼ぶ場合。呼び出しは関数値経由になる。
func BenchmarkComposedStored(b *testing.B) {
composed := Compose(double, increment)
for b.Loop() {
sinkInt = composed(benchInput)
}
}
// 同じ合成をその場で組んで即座に呼ぶ場合。コンパイラが合成ごと畳み込めるかを見る。
func BenchmarkComposedInline(b *testing.B) {
for b.Loop() {
sinkInt = Compose(double, increment)(benchInput)
}
}
// 比較用: 合成を挟まず手で書いた場合。
func BenchmarkComposedByHand(b *testing.B) {
for b.Loop() {
sinkInt = increment(double(benchInput))
}
}
結果はこうなりました。
| 書き方 | ns/op | B/op | allocs/op | 手書きとの差 |
|---|---|---|---|---|
手書き increment(double(x)) | 1.57 | 0 | 0 | — |
Compose 2段・変数に保存して呼ぶ | 1.73 | 0 | 0 | +0.16 ns |
Compose 2段・呼ぶたびにその場で合成 | 12.7 | 32 | 1 | +11.1 ns |
予想と逆だった人、多いんじゃないでしょうか。私も逆を予想していました。
合成した射を変数に入れて使い回すなら、コストは 1 ns に届きません。手書きのネスト呼び出しとほぼ変わりません。合成の構造がソース上で見えているので、コンパイラが関数値の呼び出し先を特定してインライン化してしまうからです。
追記(2026-08-13): この行の
+0.16 nsという数字は、そのまま受け取らないでください。 同じマシン・同じ Go で測り直したところ、ComposedStoredが 1.82〜1.90 ns になる run と 2.47〜2.77 ns になる run があり、手書きとの差は +0.07 ns から +0.8 ns まで振れました。1 ns 級の差はコードのアラインメントや分岐予測の状態で簡単にひっくり返るので、-count=6の代表値ではこの粒度を詰めきれません。主張できるのは「手書きとの差は 1 ns 未満」までで、有効数字2桁は測れていませんでした。 いっぽう 4.3 で測る「段数を増やしたときの傾き」は、差が積み上がる測り方なので安定して再現します(測り直しても 1段あたり 1.2 ns 前後)。1 ns 級の差を主張したいなら、代表値ではなくbenchstatで変動幅ごと出すべきでした。
ところが、呼ぶたびに合成し直すと7倍以上遅くなります。1.73 ns が 12.7 ns(手書き 1.57 ns と比べれば8倍)。おまけに 32 B のアロケーションが毎回走ります。クロージャがヒープに逃げるからです。この差は桁が違うので、上に書いた揺れには埋もれません。
ここから設計の指針が出てきます。
合成はハンドラやサービスの構築時に1回だけやる。リクエストごとに組み直さない。
net/http の middleware チェーンを main() で1回組んで http.Handler として持ち回ります。これが正しいやり方です。リクエストハンドラの中で毎回 Chain(a, b, c) を呼ぶのは間違いです。——という話を、数字で言えるようになります。組み立てそのもののコストも測ってあります。
| 操作 | ns/op | B/op | allocs/op |
|---|---|---|---|
Compose を2回呼んで3段の射を組む | 22.0 | 64 | 2 |
1リクエストあたり 22 ns と 64 B。秒間1万リクエストなら 640 KB/秒のゴミを作り続ける計算になります。GC を余計に回すには十分な量ですね。
4.3 段数を増やすとどうなるか
1段あたりのコストが知りたいので、段数を変えて測ります。
// 合成の段数を増やすと1段あたり何ナノ秒増えるかを測る。
func BenchmarkPipeDepth(b *testing.B) {
for _, depth := range []int{1, 2, 4, 8, 16} {
fs := make([]func(int) int, depth)
for i := range fs {
fs[i] = increment
}
piped := Pipe(fs...)
b.Run("composed/"+strconv.Itoa(depth), func(b *testing.B) {
for b.Loop() {
sinkInt = piped(benchInput)
}
})
b.Run("loop/"+strconv.Itoa(depth), func(b *testing.B) {
for b.Loop() {
x := benchInput
for range depth {
x = increment(x)
}
sinkInt = x
}
})
}
}
| 段数 | Pipe で合成 (ns/op) | ループで直接呼ぶ (ns/op) |
|---|---|---|
| 1 | 2.02 | 1.62 |
| 2 | 2.95 | 1.58 |
| 4 | 4.82 | 2.72 |
| 8 | 8.78 | 4.85 |
| 16 | 18.4 | 8.25 |
きれいに線形ですね。傾きを出してみます。
- 合成 1段あたり 約 1.09 ns()
- ループで直接呼ぶ場合は 1段あたり 約 0.44 ns
- 差の 約 0.65 ns/段 が、クロージャを1枚挟む代金
16段のミドルウェアチェーンでも 18.4 ns。実務では何の問題にもならない数字です。
4.4 Pipe が Compose より遅い理由
ところが、同じ3段でも組み方によって差が出ました。
| 3段の組み方 | ns/op |
|---|---|
手書き negate(increment(double(x))) | 1.54 |
Compose(Compose(double, increment), negate)(静的な入れ子) | 2.50 |
Pipe(double, increment, negate)(スライス経由) | 3.90 |
Compose の入れ子と Pipe は、意味としては同じものを作っています。それでも 1.4 ns 差がつきました。
理由は、コンパイラから見える情報量の差です。Compose(Compose(double, increment), negate) はソース上で合成の構造が丸見えなので、コンパイラは呼び出し先を特定できます。いっぽう Pipe(fs...) はスライスの中身をループで畳み込むので、各段が何の関数なのかコンパイル時には分かりません。結果、全部の段が本物の間接呼び出しになります。
インライン化の判定も確認しておきましょう。
$ go test -gcflags='-m' -run='^$' -bench='^$' ./part1/ 2>&1 | grep compose.go
part1/compose.go:7:6: can inline Compose[go.shape.int,go.shape.string,go.shape.int]
part1/compose.go:8:9: can inline Compose[go.shape.int,go.shape.string,go.shape.int].func1
part1/compose.go:12:6: can inline Identity[go.shape.int]
part1/compose.go:16:6: can inline Pipe[go.shape.int]
Compose 本体も、それが返すクロージャ(.func1)も「インライン化可能」と判定されています。go.shape.int という見慣れない表記は Go のジェネリクス実装に由来するものです。Go は型パラメータごとに完全な特殊化はせず、「gcshape」という単位にまとめてインスタンス化します。
共有される条件は2つだけです。underlying type が同じであること、または双方がポインタ型であること。だから Compose[int, string, int] と Compose[int, int, bool] は別インスタンスになりますし(string と int は別の shape)、type UserID int は int と同じインスタンスを共有します。実際に確かめられます。
$ go tool nm ./app | grep 'Compose\[go.shape' | sed 's/.* //' | sort -u
main.init.Compose[go.shape.*uint8,...].func4 # *int
main.init.Compose[go.shape.*uint8,...].func5 # *string ← *int と共有
main.init.Compose[go.shape.int,...].func1 # int
main.init.Compose[go.shape.int,...].func3 # type MyInt int ← int と共有
main.init.Compose[go.shape.int64,...].func2 # int64 ← int とは別
注目してほしいのは int と int64 です。64bit 環境ではサイズもメモリ表現も同じなのに、別の shape になります。共有の基準は表現ではなく underlying type だからですね。いっぽう *int と *string は、指す先がまったく違うのに go.shape.*uint8 ひとつに落ちます。コードサイズと最適化のトレードオフを、こう解いているわけです。
ここで見たのは、Go がジェネリックなコードをどう実体化するかという実装側の仕組みです。第3回では話を型システム側に移して、そもそも型を取る型を抽象化できない(高階カインドの不在)という別の制約を扱います。層が違う話なので、混ぜないでください。
訂正(2026-08-13): 公開当初、ここに「同じメモリ表現を持つ型をひとまとめにする」「
Compose[int, int, int]とCompose[int32, int32, int32]のような組み合わせは共有されえます」と書いていました。どちらも誤りです。 基準はメモリ表現ではなく underlying type なので、intとint32は(サイズも underlying type も違うため)共有されません。上のint/int64が、この違いがいちばんはっきり出る例です。
4.5 内側の処理が重いとどうなるか
ここまでは n * 2 や n + 1 のような 1 ns 級の処理を繋いでいました。実際のコードはもっと重いはずです。strconv.Itoa を含む例で測ってみます。
| 書き方 | ns/op | B/op | allocs/op |
|---|---|---|---|
手書き isEven(length(strconv.Itoa(x))) | 11.9 | 8 | 1 |
| 同じ処理を関数値経由で呼ぶ | 12.2 | 8 | 1 |
Compose 3段・変数に保存 | 13.4 | 8 | 1 |
strconv.Itoa が1回アロケートする(8 B)ので、そっちが支配的になります。合成のコストは全体の約10%(+1.5 ns)まで薄まりました。
4.6 判断基準
まとめると、こうなります。
射1本の中身が 10 ns 以上あれば、合成のコストは誤差。
10 ns は、文字列変換1回、mutex のロック・アンロック1回、map アクセス数回くらいのオーダーです。HTTP ハンドラやビジネスロジックの1ステップなら、まず間違いなくこれより重いはずです。だから middleware チェーンやバリデーションのパイプラインを合成で組むことに、性能を理由に反対する根拠はありません。
逆に、1 ns 級の算術だけを繋ぐホットループ(画像処理のピクセルループ、数値計算の内側)では、合成は 1.5〜2.5 倍の差になります。そこは手で書いたほうがいいですね。
そして、どのケースにも共通して言えることが1つあります。
合成は構築時に1回。呼ぶたびに組み直さない。
これを守らないと、7倍以上のペナルティとアロケーションが付いてきます。今回の実測でいちばん実用的な発見は、これでした。
5. 合成できない射たち
最後に、次回への橋を架けておきます。
5.1 func(A) (B, error) は合成できない
ここまで扱ってきた射は func(A) B の形でした。でも現実の Go コードは、ほとんどがこうなっていますよね。
func parse(raw []byte) (Request, error)
func authenticate(req Request) (User, error)
func process(user User) (Result, error)
これらを Compose に渡してみます。
Compose(parse, authenticate)
// コンパイルエラー:
// type func(raw []byte) (Request, error) of parse does not match inferred type func(A) B
通りません。parse の出力は (Request, error) という2つの値で、authenticate の入力は Request 1つ。型が繋がらないんです。
これ、些細な不整合ではありません。冒頭に載せた、あの見慣れたコードを思い出してください。
req, err := parse(raw)
if err != nil {
return Response{}, err
}
user, err := authenticate(req)
if err != nil {
return Response{}, err
}
// ...
Go プログラマが毎日書いているこの if err != nil の3行。これは合成できない射を、手作業で繋いでいるんですね。型が繋がらないので、人間が接着剤になっているわけです。
5.2 素朴な解決
繋がらないなら、繋がる形にすればいいです。エラーを持ち回る専用の合成器を書きます。
// ComposeE は error を返す射どうしを繋ぐ。Compose では戻り値の型が合わないため、
// error の受け渡しを合成器の側に閉じ込める。第2回で扱う Kleisli 合成の先取り。
func ComposeE[A, B, C any](f func(A) (B, error), g func(B) (C, error)) func(A) (C, error) {
return func(a A) (C, error) {
b, err := f(a)
if err != nil {
var zero C
return zero, err
}
return g(b)
}
}
// IdentityE は ComposeE における恒等射。
func IdentityE[A any](a A) (A, error) { return a, nil }
これで繋がります。
pipeline := ComposeE(ComposeE(parse, authenticate), process)
result, err := pipeline(raw)
if err != nil が消えました。より正確には、1か所に閉じ込められたわけです。
5.3 これは何なのか
ここは立ち止まる価値があります。ComposeE は Compose と何が違うんでしょうか。
Composeは と を繋いだComposeEは と を繋いだ
後者では、射の「行き先」がただの ではなく「 かエラー」に膨らんでいます。それでも合成できています。
そして肝心なのは、ComposeE も結合律と単位律を満たすという点です。恒等射は IdentityE(値をそのまま返して、エラーは nil)。つまりこれは別の圏になっているんですね。ちゃんと名前もついていて、Kleisli 圏といいます。
5.4 確かめる —— そして、エラーの等価性という落とし穴
主張したからには確かめます。エラーが起きる入力も含めて、結合律を検証しましょう。
// error を返す射の合成でも結合律は成り立つ。エラーが出る入力も含めて確かめる。
func TestComposeEIsAssociative(t *testing.T) {
left := ComposeE(ComposeE(parseInt, reciprocal), formatFloat)
right := ComposeE(parseInt, ComposeE(reciprocal, formatFloat))
rapid.Check(t, func(t *rapid.T) {
// 数字文字列を多めに引くため、int を文字列化したものと任意文字列を混ぜる。
s := rapid.OneOf(
rapid.Custom(func(t *rapid.T) string { return strconv.Itoa(rapid.Int().Draw(t, "n")) }),
rapid.String(),
).Draw(t, "s")
gotL, errL := left(s)
gotR, errR := right(s)
if gotL != gotR || !sameError(errL, errR) {
t.Fatalf("結合律が破れた: s=%q, 左=(%q,%v), 右=(%q,%v)", s, gotL, errL, gotR, errR)
}
})
}
エラー時に後続が呼ばれないことも確かめておきます。if err != nil { return } を合成器に閉じ込めた効果が、ここに出ます。
// エラーが出た時点で後続の射は呼ばれない。if err != nil { return } を合成器に閉じ込めた効果。
func TestComposeEShortCircuits(t *testing.T) {
called := false
spy := func(f float64) (string, error) {
called = true
return formatFloat(f)
}
if _, err := ComposeE(ComposeE(parseInt, reciprocal), spy)("0"); !errors.Is(err, errDivideByZero) {
t.Fatalf("想定したエラーが返っていない: %v", err)
}
if called {
t.Fatal("エラーが出たのに後続の射が呼ばれた")
}
}
ところで、上のテストに sameError という見慣れないヘルパーが出てきました。これ、書いているときに引っかかったところです。
最初は素直に errors.Is(errL, errR) と書きました。落ちました。
--- FAIL: TestComposeEIsAssociative
結合律が破れた: s="", 左=("",strconv.Atoi: parsing "": invalid syntax),
右=("",strconv.Atoi: parsing "": invalid syntax)
左右でまったく同じメッセージなのに「破れた」と言われています。犯人は strconv.Atoi でした。呼ばれるたびに新しい *strconv.NumError を割り当てて返しているんですね。errors.Is は同一性(センチネルとの一致、または Is メソッド)で判定するので、内容が同じでも別インスタンスなら false になります。
// 射の等価性を判定するには error も比較しなければならない。strconv は呼び出しごとに
// 別の *NumError を返すため、errors.Is ではなくメッセージで突き合わせる。
func sameError(a, b error) bool {
if a == nil || b == nil {
return a == nil && b == nil
}
return a.Error() == b.Error()
}
これ、単なるテストの都合ではありません。Kleisli 圏で「2つの射が等しい」と言うには、error の等価性を先に決めないといけない、という話なんです。
func(A) B の圏なら、射の等価性は「すべての入力に対して同じ値を返す」で済みました。B に == があれば判定できます。ところが func(A) (B, error) では、error はインターフェースで、Go は error に汎用の等価性を与えていません。同一インスタンスか、errors.Is で辿れるか、メッセージが一致するか、errors.As で同じ型に落ちるか。どれを「等しい」と呼ぶかは設計者が決めることになっています。
これが牙を剥くのは、エラーを比較する場面すべてです。テストで期待エラーを検証するとき。リトライすべきエラーか判定するとき。エラーを集約して重複を除くとき。「同じエラー」の定義があいまいなまま書かれたコードは、fmt.Errorf でラップされた瞬間に壊れます。
センチネルエラー(var ErrNotFound = errors.New(...))を定義して errors.Is で判定せよ、というのが Go の作法ですよね。その根拠がここにあります。射の等価性を判定できるようにするために、エラーに同一性を持たせているわけです。作法として丸暗記するより、こう理解しておくほうが応用が効くと思います。
(T, error) は Go でいちばん身近なモナドで、if err != nil はその Kleisli 合成を手で展開したもの——これが次回の出発点になります。
同じような構造は、http.Handler を包む middleware にも、errors.Join にも、iter.Seq にも顔を出します。Go の標準ライブラリは圏論の用語を一切使っていませんが、構造そのものはあちこちにあるんですね。次回はそれを1つずつ剥がしていきます。
6. まとめ
第1回で確かめたことを並べておきます。
圏について
- 圏とは、対象・射・合成・恒等射があって、結合律と単位律を満たすもの。それだけです
- Go の型を対象、
func(A) Bを射とみなすと圏になります。property-based test で確認しました。ただし射は純粋・全域とみなしたもので、「等しい」は外延的等号です - 法則は制約ではなく、安全に書き換えていい範囲の宣言です。結合律はリファクタリングの許可証、単位律は「何もしないステップ」の許可証
- 関数だけが圏ではありません。モノイドは対象が1つの圏、依存関係やバージョン互換は射が高々1本の圏(=薄い圏、表しているのは前順序)、ステータス遷移は経路を射に取った自由圏
- 薄い圏では、恒等射が反射性に、合成が定まることが推移性にあたります(結合律ではありません)。依存解決を自作するなら、推移的でない制約(排他など)を同じ仕組みに混ぜてはいけません
- 圏にならないのは、合成で閉じないとき(各ステップ1秒以内でも全体は1秒以内にならない)、恒等射がないとき(空を許さない設計)、結合律が壊れるとき(浮動小数点の加算を合成とみなした構造)
純粋性について
- 同じ入力に違う結果を返す関数は、Go の関数ではあっても圏の射ではありません
- 純粋さが守っているのは、メモ化・リトライ・並列化・遅延評価・共通部分式の括り出し・モックの削減という6つの自由
- リトライで二重課金が起きるのは、純粋でない射を純粋だと思い込んだからです。冪等性の実装は「純粋でない射を、外から純粋に見せる工作」
- 線引きは「関数型コア、命令型シェル」。どこに線があるかを自分で決めておきましょう
コストについて
- 合成を変数に入れて使い回すなら、2段のコストは手書きとの差が 1 ns 未満。実質ゼロです(ただしこの粒度は run ごとに揺れます。4.2 の追記を参照)
- 呼ぶたびに合成し直すと 12.7 ns、32 B のアロケーション。7倍以上のペナルティ。こちらは桁が違うので揺れに埋もれません
- 合成1段あたり約 1.09 ns。16段でも 18.4 ns
- スライス経由の可変長合成(
Pipe)は、静的な入れ子(Compose)より遅くなります。コンパイラが呼び出し先を特定できないからです - 射の中身が 10 ns 以上あれば合成コストは誤差。middleware やバリデーションを合成で組むのに、性能上の反対理由はありません
次回への橋
func(A) (B, error)はComposeでは繋がりません- 毎日書いている
if err != nilは、合成できない射を人間が手で繋いでいる作業でした - 専用の合成器
ComposeEを作れば繋がります。そしてそれもまた圏になる(Kleisli 圏) - ただし「2つの射が等しい」と言うには error の等価性を決めないといけません。センチネルエラーと
errors.Isという Go の作法は、射の等価性を判定できるようにするための仕掛けだったわけです
圏論を学ぶ価値は、新しい道具が増えることではありません。すでに書いているコードの中に、何が構造として入っていたのかが見えるようになることです。if err != nil が合成の手作業だと分かれば、それを機械にやらせる方法も見えてきます。
(次回「errors.Join はモノイドである —— Goで書く実践圏論(2)」に続く)
この記事のコード: part1/ — 何を実行して何を見ればいいかは、そのディレクトリの README にまとめてあります。Codespaces で開けばそのまま動きます。
連載一覧
- Goの関数と型は圏をなす(本記事)
- errors.Join はモノイドである
- Goに高階カインドはない
- 圏論でワークフローエンジンを組み直したら1ステップ30ns