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 関数がある種の高階関数(関数を返す関数)であり、すっきりした特徴を持っていることがわかります。

f:id:fuji_haruka:20170819205910j:plain

map 関数は、関数を受け取り関数を返すのですが、その型が特徴的です。 X -> Y という形の関数を引数として受け取り、 X[] -> Y[] という形の関数を返します。つまり、関数型の引数・戻り値の型をそれぞれ [] でラップするという一貫した規則によって新しい関数型が作られます。

これは圏論の言葉で書けます。

map 関数を圏論的な視点で見る

前回の記事で書いたとおり、「型付きの純粋な関数」は圏になります。そこでは圏の対象が「型」で、圏の射が「関数」でした。

f:id:fuji_haruka:20170819205919j:plain

ではこの圏で map 関数がどういう役割をしているのかを見てみます。ただ、ちょっとしたコツですが、 map 関数も本当はこの圏の射なのですが、いったんそのことは忘れてみます。そうすると何が見えてくるでしょうか。

map 関数は「射を操作する関数」です。しかも、他にも目立った性質があります。それが以下です。

  1. 恒等射を恒等射に写す。
  2. 射の合成を保存する。

f:id:fuji_haruka:20170819205926j:plain f:id:fuji_haruka:20170819205930j:plain

コードで書いて確認します。

まず 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[] と定義する必要があります。

f:id:fuji_haruka:20170819205935j:plain

これで、記事の目標である「配列の map 関数は関手である」と言えるところまで来ました。

皆さんもなんとなく「map 関数はきれいだな、美しいな」と感じた経験がおありではないでしょうか。それは、map 関数が関手というきれいな性質を満たしていることに由来するのかもしれませんね。