ファンタジー・ランドの保護地区をゆく Part 1 --- Functor

JavaScript の関数型プログラミング・ライブラリである Sanctuary.js のサーベイ Part 1.

この記事は 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 メソッドを見てみよう

コード 1. 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 aa に関数 f を適用することを意味する.つまり x はファンクター f の型で内部に a の型を値として保持していることを表す.

  • (a → b)map の第 1 引数が Type a から Type b への関数であることを表す.

  • 最後の f bmap の戻り値の型を表す.Type b を格納する Functor 型が返る.

例として,コード 2 の配列への map 呼び出しを,コード 1 の型定義に照らし合わせて見てみましょう.

コード 2. 配列への map
[4].map(Math.sqrt)
//=> [ 2 ]
  • map 関数が呼び出されるオブジェクトの型は配列で,配列の中は number 型なので,f a ~> の部分は f が配列型で,anumber 型になる.

  • map の第 1 引数 Math.sqrtnumber → number の関数で,コード 1(a → b ) 部分に対応する.したがって bnumber 型になる.

  • コード 2 の戻り値は [2]number を内部にもつ配列なので,確かに f b を満たす.

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 だけを使用したバージョンです.

コード 3. 成績処理のコード例
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' } } ]

コード 3markEach 関数では副作用が生じないように,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 を使います.(もっと手っ取り早い方法は Ramdamap を使います.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 の定義は以下のように定められています.関数型で書かれたコードを数学の計算法則に従って簡約化する場合以外は,通常のプログラミングで代数法則を意識する必要はないでしょう.

  1. map メソッドを持つこと.

  2. u.map(a ⇒ a) は自分自信 u と同値であること.

  3. 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 すると関数合成が実現できます.

wshito

Read more posts by this author.

Itoshima, Japan http://diary.wshito.com