この記事は JavaScript2 Advent Calendar 2018 の 4 日目の記事として書かれました.コードは GitHub にまとめてあります.
最近,JavaScript の関数型プログラミングにはまっているので,Fantasy Land を実装した Sanctuary.js の使い方をサーベイして行きたいと思います.今回は基本中の基本,Functor から.
Functor とは何か
ざっくばらんに言えば,map
メソッドを提供する型のことです.JavaScript の配列も map
メソッドを持つので,配列も Functor と言えます.配列を例に Functor の特徴を見てみましょう.
まず,配列は内部に値を保持するコンテナです.Functor も内部に値を保持します.Functor は内部に値を保持するラッパー,もしくは箱であると説明されることがよくあります.
配列の map
メソッドで各要素に関数を適用すると,適用結果は再び配列に格納されて返ります.Functor も内部に保持する値に map
を使って関数を適用すると,その結果は再び Functor でラッピングされて返ります.ちなみに,Functor が内部に保持するのはデータだけでなく関数も保持できます(関数は第1級オブジェクトなので当然ですが….).
Sanctuary ライブラリでは JavaScript のオブジェクト {}
も Functor に分類されています.つまり,オブジェクト {}
にも map
関数が使えるということです.配列もオブジェクトも map
が使えるので,もはや for
ループが不要になります.
Sanctuary で提供する型は,ここのページまとめられています.Functor のサブクラスが色々ありますね.多くのメソッドが Functor を引数に取ります.
何が Functor になり得るの?
Functor か否かは Functor.test()
メソッドを使って調べることができます.
$ node
> let Z = require('sanctuary-type-classes') // 型の定義
> let S = require('sanctuary') // Sanctuary本体
> Z.Functor.test(S.Maybe) // Maybe型はFunctorのサブクラス
true
> Z.Functor.test(S.toMabye(5)) // Just型もFunctor
true
> Z.Functor.test(S.Just)
true
> Z.Functor.test(S.toMabye(null)) // Nothing型もFunctor
true
> Z.Functor.test(S.Nothing)
true
> Z.Functor.test({}) // オブジェクトもFunctor
true
> Z.Functor.test([]) // 配列もFunctor
true
> Z.Functor.test(5) // ただの値はFunctorではない
false
map メソッドを見てみよう
map
の型定義map :: Functor f => f a ~> (a -> b) -> f b
型定義を読めないと,Sancutary で迷子になってしまうので,まずはコード 1の型定義を一つずつ見ていきましょう.
-
Functor f ⇒
の部分でf
に Functor 型の制約を置いている. -
f a ~>
の部分は,map
メソッドが帰属するオブジェクトの型を表す.例えば,x.map(Math.sqrt)
というコードのx
の部分の型を規定している.f a
はa
に関数f
を適用することを意味する.つまりx
はファンクターf
の型で内部にa
の型を値として保持していることを表す. -
(a → b)
はmap
の第 1 引数が Typea
から Typeb
への関数であることを表す. -
最後の
f b
はmap
の戻り値の型を表す.Typeb
を格納する Functor 型が返る.
map
[4].map(Math.sqrt)
//=> [ 2 ]
Sancturay.js では,オブジェクト・メソッドとして map
を呼び出す以外に,独立した関数として map
を呼び出すこともできます.独立した map
関数はカリー化されているので,下のように第 1 引数から順に関数呼び出しする必要があります.
S.map(Math.sqrt)([4]) // fantasy-land/mapのカリー化バージョン
//=> [ 2 ]
もう少し,例を見てみましょう.
[1, 2, 3, 4, 5].map(Math.sqrt)
//=> [ 1, 1.4142135623730951, 1.7320508075688772, 2, 2.23606797749979 ]
S.map(Math.sqrt)([1, 2, 3, 4, 5]) // fantasy-land/mapのカリー化バージョン
//=> [ 1, 1.4142135623730951, 1.7320508075688772, 2, 2.23606797749979 ]
次に,オブジェクトも Functor として扱えるので,オブジェクトに対しても map
を実行してみましょう.
Z.Functor.test({a: " 1 ", b: " 2 "});
//=> true
{a: " 1 ", b: " 2 "}.map(Math.sqrt) // Object.prototype.map はないのでエラー
//=> エラー!
S.map(Math.sqrt)({a: " 1 ", b: " 2 "}) // こちらを使いましょう.
//=> { a: 1, b: 1.4142135623730951 }
上の例を見て分かる通り,オブジェクトには map
メソッドがないので,独立した map
関数を使う方法しかありません.
当然,Functor の定義を満たしてないやんか!と突っ込みたくなりますが,まぁ,メソッドが提供されてなくとも map
関数は適用できるということで大目に見ましょう.似たようなケースとして,StackOverflow では Contravariant Functor のインスタンスに contramap
メソッドが定義されていないという指摘がありましたが,皆さん,独立した関数で OK やんかというお答えのようです.
関数型プログラミングではフルーエントよりもポイントフリーで書くことが多いでしょうから,独立した map
関数が使えれば十分ですね.
コード例
オブジェクトに map
を適用する例として,オブジェクトに格納された試験の点数データを成績に変換する処理を考えてみましょう.まずは Sanctuary だけを使用したバージョンです.
const S = require('sanctuary')
const students = [
{ name: "太朗", exams: { midterm: 80, endterm: 65 } },
{ name: "花子", exams: { midterm: 93, endterm: 38 } },
{ name: "謙太", exams: { midterm: 55, endterm: 65 } },
{ name: "春子", exams: { midterm: 92, endterm: 78 } },
{ name: "五朗", exams: { midterm: 48, endterm: 25 } },
{ name: "郁子", exams: { midterm: 73, endterm: 82 } },
]
const geqAlt = (border, grade) => alt => x => x >= border ? grade : alt(x); (1)
const grade = geqAlt(90, "A")(geqAlt(80, "B")(geqAlt(60, "C")(geqAlt(0, "C")(x => "欠席"))))
markEach = student => {
return {...student, grade: S.map(grade)(student.exams)}; (2)
};
console.log(students.map(markEach)); (3)
1 | ボーダーラインと grade を引数に取り,ボーダーライン以上なら grade を返し,そうでなければ 第 3 引数に与えられる関数に処理を委譲する.パラメータ x が数字でないと S.map が例外を投げるので,grade 関数の "欠席" はここでは呼び出されない. |
2 | 各学生の exams オブジェクトに対するマップ. |
3 | 全学生の配列データに map を使って grade 関数を適用. |
上のコードを grade.js
に保存して実行した結果は以下のとおり.
$ node grade.js
[ { name: '太朗',
exams: { midterm: 80, endterm: 65 },
grade: { midterm: 'B', endterm: 'C' } },
{ name: '花子',
exams: { midterm: 93, endterm: 38 },
grade: { midterm: 'A', endterm: 'C' } },
{ name: '謙太',
exams: { midterm: 55, endterm: 65 },
grade: { midterm: 'C', endterm: 'C' } },
{ name: '春子',
exams: { midterm: 92, endterm: 78 },
grade: { midterm: 'A', endterm: 'C' } },
{ name: '五朗',
exams: { midterm: 48, endterm: 25 },
grade: { midterm: 'C', endterm: 'C' } },
{ name: '郁子',
exams: { midterm: 73, endterm: 82 },
grade: { midterm: 'C', endterm: 'B' } } ]
コード 3 の markEach
関数では副作用が生じないように,destructuring を使って新たなオブジェクトを生成して結果を返しています.Ramda.js を使えばもう少し宣言的に markEach
関数を純粋化できます.Ramda の set
関数は値を設定した新たなオブジェクトを返します.
const S = require('sanctuary')
const R = require('ramda')
// studentsオブジェクトの定義は省略.
const geqAlt = (border, grade) => alt => x => x >= border ? grade : alt(x);
const grade = geqAlt(90, "A")(geqAlt(80, "B")(geqAlt(60, "C")(geqAlt(0, "D")(x => "欠席"))))
const gradeLens = R.lensProp("grade"); (1)
const markEach = student => R.set(gradeLens, S.map(grade)(student.exams), student) (2)
console.log(students.map(markEach))
console.log("----------------")
console.log(students)
1 | プロパティ・レンズで値の設定先を指定. |
2 | これで mark 関数は引数を変更しない. |
手っ取り早い NULL 対策
JavaScript は動的型付け言語で,型の違いに対して寛容なところが良いところで,仕様を理解していれば,型定義に面倒な追加的定義をしなくてもうまく動かすことができます.
しかし,Sanctuary.js はちょっと Haskell かぶれしているところがあって,型定義が厳密です.デフォルトでは開発モードになっており,型チェックが入り,オブジェクトの中に数値型とそれ以外のものが混在していると,Sancutary.js の map
が コード 1 の型定義に従わないと例外を投げます.本番モードに設定すれば型チェックが入りませんが,そうすると型に関する詳細なエラーメッセージが出なくなるので,間違った使い方をしている時に困ります.
JavaScript の仕様を理解した上で,部分的に型チェックを回避したい場合は S.map
関数の代わりに S.unchecked.map
を使います.(もっと手っ取り早い方法は Ramda の map
を使います.Ramda は型チェックがありません.)
例えば,成績データに "欠席"
や "NA"
などの文字列か null
が含まれている場合,上の grade
関数は,null
のとき "D"
が返り,"欠席"
や "NA"
などの文字列のとき最後の "欠席"
が返ります.もし,これが意図している動作なら,以下のように S.unchecked.map
を使うことで解決できます.
const S = require('sanctuary')
const R = require('ramda')
// setting for swank-js
console.log = (typeof inspect === "undefined") ? console.log : inspect;
const students = [
{ name: "太朗", exams: { midterm: 'NA', endterm: 65 } },
{ name: "花子", exams: { midterm: 93, endterm: 38 } },
{ name: "謙太", exams: { midterm: null, endterm: 65 } },
{ name: "春子", exams: { midterm: 92, endterm: 78 } },
{ name: "五朗", exams: { midterm: 48, endterm: 25 } },
{ name: "郁子", exams: { midterm: 73, endterm: null } }
]
const geqAlt = (border, grade) => alt => x => x >= border ? grade : alt(x);
const grade = geqAlt(90, "A")(geqAlt(80, "B")(geqAlt(60, "C")(geqAlt(0, "D")(x => "欠席"))))
const gradeLens = R.lensProp("grade");
const markEach = student => R.set(gradeLens, S.unchecked.map(grade)(student.exams), student)
console.log(students.map(markEach));
console.log("----------------");
console.log(students);
もちろん,これは本来の関数型プログラミングのやり方ではありません.もっとエレガントなやり方は,モナドの紹介記事のためにとっておきましょう.モナドを使えば,null
でも 'NA'
同様に "欠席"
として扱うことができるようになります.
余談
一応,functor の定義は以下のように定められています.関数型で書かれたコードを数学の計算法則に従って簡約化する場合以外は,通常のプログラミングで代数法則を意識する必要はないでしょう.
-
map
メソッドを持つこと. -
u.map(a ⇒ a)
は自分自信u
と同値であること. -
u.map(x ⇒ f(g(x)))
はu.map(g).map(f)
と同値であること.
結局のところ map
は数学の「写像(map)」に対応しているので,上の定義の最後の 2 つは数学の関数が満たす当たり前の性質です.
例えばファンクター u
の中身を数学の関数 \(u(x) = x^2 +4\) と考えると \(x = x\) という恒等写像を \(u(x)\) に適用(恒等写像の右辺に \(u\) を代入)すれば \(x = u = x^2 + 4\) で元の \(u\) を得るということです.
同様に定義の 3 番目も数学の関数に置き換えるために,\(f(x) = x + 1\),\(g(x) = 2x^2\) を使って考えてみましょう.
u.map(x ⇒ f(g(x)))
は \(f(g(x)) = g + 1 = 2x^2 + 1\) を先に計算し,これを \(u(x)\) に適用することを意味しているので,\(f(g(u(x))) = 2u^2 + 1 = 2(x^2 + 4)^2 + 1\) を得ます.
u.map(g).map(f)
は,先に \(g(u(x)) = 2u^2 = 2(x^2 + 4)^2\) を計算し,その結果に \(f\) を適用するので \(f(g(u(x)))) = g(u(x)) + 1 = 2(x^2 + 4)^2 + 1\) を得ます.
つまり 3 つ目の定義は,合成写像が結合法則 \((f \circ g) \circ u = f \circ (g \circ u)\) を満たすことを表しています.
もし,Functor u
に恒等写像 x ⇒ x
が格納されている場合,u.map(g).map(f)
は \(f \circ (g \circ u) = f \circ g\) で \(f\) と \(g\) の合成写像になるので,u
は関数を合成する combinator になります(B Combinator または Compose).つまり恒等写像を内部に持つ Functor に,関数を続けて map
すると関数合成が実現できます.