Webエンジニアでも圏論したい!(関手篇)
この記事の目標は、「配列の map
関数は関手である」とドヤ顔で言えるようになることです。
初回の記事はこちら。
配列の map
関数を観察する
まずは配列の map
関数をよく観察してみます。 map
関数はこんなやつですね。(コードはまた TypeScript です)。
const numbers = [1, 2, 3] const double = (num: number): number => 2 * num // 配列 numbers の各要素 n から double(n) を作って新しい配列にする const evens = numbers.map(double) console.log(evens) // [ 2, 4, 6 ]
ここで、 map
関数は JavaScript では Array のインスタンスメソッドとして提供されていますが、理解を深めるためにこれを独立した関数として書き直してみましょう。
と、その前に。 TypeScript のジェネリクスという機能を簡単に説明しておきます。ジェネリクスは「型につかえる変数」みたいなもので、次のような感じで使えます。
// 引数の型を T とすると、戻り値の型はその配列 T[] type ToArray = <T>(x: T) => T[] // たとえば引数を配列でラップするだけの関数 const wrapArray: ToArray = (x) => [x]
さて、ジェネリクスを使って map
関数を書き直してみます。 map
関数の型も明示してみます。
type ArrayMap = <X,Y>(func: (x: X) => Y) => (array: X[]) => Y[] const map: ArrayMap = (func) => (array) => array.map(func) // 最初の map の例も書き換える const numbers = [1, 2, 3] const double = (num: number): number => 2 * num const doubleMap: (arr: number[]) => number[] = map(double) const evens = doubleMap(numbers)
map
関数に付けられた ArrayMap
型に注目します。すると、map
関数がある種の高階関数(関数を返す関数)であり、すっきりした特徴を持っていることがわかります。
map
関数は、関数を受け取り関数を返すのですが、その型が特徴的です。 X -> Y
という形の関数を引数として受け取り、 X[] -> Y[]
という形の関数を返します。つまり、関数型の引数・戻り値の型をそれぞれ []
でラップするという一貫した規則によって新しい関数型が作られます。
これは圏論の言葉で書けます。
map
関数を圏論的な視点で見る
前回の記事で書いたとおり、「型付きの純粋な関数」は圏になります。そこでは圏の対象が「型」で、圏の射が「関数」でした。
ではこの圏で map
関数がどういう役割をしているのかを見てみます。ただ、ちょっとしたコツですが、 map
関数も本当はこの圏の射なのですが、いったんそのことは忘れてみます。そうすると何が見えてくるでしょうか。
map
関数は「射を操作する関数」です。しかも、他にも目立った性質があります。それが以下です。
- 恒等射を恒等射に写す。
- 射の合成を保存する。
コードで書いて確認します。
まず 1. 恒等射を恒等射に写す
です。
// 何もしない関数 id const id = (n: number) => n // map する const idArray = map(id) // 結果、配列に対して何もしない関数になる idArray([1,2,3]) // [ 1, 2, 3 ]
次、 射の合成を保存する
です。これは、合成した射を map
で写したものと、2つの射を map
で写してから合成したものが等しいということです。合成した射は map
で写した先でも合成した射になる、だから「保存する」という言葉を使います。
// 2つの射を定義する const double = (x) => 2 * x const plus5 = (x) => x + 5 // 射を合成するための関数 const compose = (f, g) => (x) => f(g(x)) // 合成してから map した射 const a = map(compose(double, plus5)) // map してから合成した射 const b = compose(map(double), map(plus5)) a([1, 2, 3]) b([1, 2, 3]) // 両方とも同じ結果 // [ 12, 14, 26 ] // 等しい射 (∩´∀`)∩ワーイ
関手の定義
そして、上で挙げた map
関数の性質が、まさに関手の定義になっています。
圏 C
から圏 D
への関手 F
は、圏の対象と射に関する関数です。
- 圏
C
の各対象X
を圏D
の対象F(X)
に写す。 - 圏
C
の各射f: X -> Y
を圏D
の射F(f): F(X) -> F(Y)
に写し、以下の性質を満たす。- 恒等射
id: X -> X
に対して、F(id): F(X) -> F(X)
は恒等射 - 任意の射
f
,g
に対して、F(f ∘ g) = F(f) ∘ F(g)
- 恒等射
以上が関手の定義です。
型付き純粋関数の圏を C
とすると、 map
関数は、圏 C
から圏 C
への関手になります。ただし、関手は対象から対象への関数でもあるので、対象に関しては map(X) = X[]
と定義する必要があります。
これで、記事の目標である「配列の map
関数は関手である」と言えるところまで来ました。
皆さんもなんとなく「map
関数はきれいだな、美しいな」と感じた経験がおありではないでしょうか。それは、map
関数が関手というきれいな性質を満たしていることに由来するのかもしれませんね。