この記事は JavaScript2 Advent Calendar 2018 の 19 日目の記事として書かれました.コードは GitHub にまとめてあります.
Fantasy Land を実装した Sanctuary.js の使い方サーベイの第 2 弾.今回は null
対策として Maybe モナドの使い方を見ていきましょう.Functor を扱った Part 1 は こちら.
モナドとは?
Fantasy Land のページにある クラス構造 を見ると,モナドは Functor の派生型の末端に位置していて,Apply,Applicative,Chain のインターフェースを実装した型であることが分かります.モナドも Functor なので値を中に保持するコンテナです.Apply,Applicative,Chain は,コンテナ内の値に関数を適用するために必要なインターフェースを提供します.これらのインターフェースを使うと map だけでは実現できない複雑な処理を行うことができます.Apply,Applicative,Chain については次回以降順に見て行きます.ここでは Maybe モナドを使った null
対策に特化して話しを進めます.
null の注意点
JavaScript は型に対して非常に寛容な言語です.他の言語では null
にアクセスすると地雷を踏んでしまいますが,JavaScript ではちょっとやそっとでは例外を投げません.特に注意すべきは欠損値を含む数値データを扱う時です.以下のように,大小関係の比較で >=
と <=
を使う場合には 0 との比較で true
が返ります.
null >= 0; //=> true
null <= 0; //=> true
null == 0; //=> false
null > 0; //=> false
null < 0; //=> false
例えば,前回 のコードで,0 点から 100 点の範囲外の値の時に「欠席」と評価付けしたい以下のコードでは,NA
などの文字列では正しく欠席になりますが,null
は D 判定になります.
// 'NA' は欠席になるが null は D になる.
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 => "欠席"))));
grade(0) //=> 'D'
grade(null) //=> 'D'
grade('NA') //=> '欠席'
Maybe モナドを使うと null
をうまく処理することができます.
Maybe モナドとは?
Maybe モナドは,モナド型のインターフェースを持つコンテナです.特徴はコンテナ内の値が null
の時には,値に関数を適用するメソッドが何もせずスルーされる点です.例えば Functor のインターフェースである map
を実行しても,関数は適用されず単に自分自身(後述の Nothing
)が返ります.null
以外の値に対しては,通常通り関数が適用されます.
null
か否かで振る舞いが変わる Maybe モナドですが,処理の度に内部で null
チェックが行われている訳ではありません.Maybe モナド内に値を格納するときに 1 回だけ null
チェックが行われます.
Maybe モナド・インスタンスの作成は toMaybe
関数を使います.toMaybe
は引数が null
や undefined
ならば Maybe のサブクラスである Nothing
型のインスタンスを返し,それ以外ならば Just
型のインスタンスを返します.どちらも Maybe 型のサブクラスなので,共通のインターフェースを持ち,Just
か Nothing
の区別なく Maybe モナドとして使うことが出来ます.コンテナ内の値に関数を適用すると,Just
型は関数を適用しますが,Nothing
型は何もせず自分自身を返すように実装されています.こうして,処理毎の null
チェックを回避しています.モナドのユーザ側からは内部の値の詳細を知る必要がなく,Maybe モナドという共通のインターフェースを使うだけで,Maybe モナド側が適切に処理を変更します.関数型プログラミングがオブジェクト指向型のポリモーフィズムを利用しているわけです.
それでは実際に Maybe モナドを作成して動作を見てみましょう.
const S = require('sanctuary');
const greet = console.log; // console.logをgreet関数と命名
const monad1 = S.toMaybe('Merry Christmas!'); // モナドに文字列を格納
S.map(greet)(monad1); // モナドにgreet関数を適用
//=> Merry Christmas!
// Just (undefined)
const monad2 = S.toMaybe(null); // nullをモナドに格納するとNothingが返る
S.map(greet)(monad2); // Nothingには関数を適用しても何も実行されない
//=> Nothing
const monad3 = S.toMaybe(undefined); // undefinedでもNothingが返る
S.map(greet)(monad3)
//=> Nothing
4 行目の monad1
には,文字列が格納された Maybe モナドである Just
型インスタンスが設定されます.5 行目の map
で greet
関数がモナド内の値に適用されるため,Merry Christmas が副作用として画面に表示されます.モナドは,コンテナ内の値に関数が適用されたら,結果を再びモナドに格納して返します.ここでは console.log
関数の返り値が undefined
なため,内部に undefined
を持つモナドが返っていることがわかります.
9 行目で null
から作成された Maybe モナドは Nothing
インスタンスを返すため,monad2
に対して map
された greet
関数は実行されていません.13 行目の undefined
から作成したモナドも同様です.
このように,処理したい値を全て一度 Maybe モナドに格納してから,処理関数を map
で適用することで,処理関数側での null
チェックが不要になります.
null
を含む成績データの処理
前回 紹介した成績データ処理を Maybe モナドを使って書き換えてみましょう.
const S = require('sanctuary');
const R = require('ramda');
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: 0, 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 grade2 = R.compose(
S.fromMaybe("欠席"), // モナドから値を取り出す.Nothingなら欠席が返る
S.map(grade), // Nothingはこのmapをスルーする
S.toMaybe); // Maybeモナドに値を格納.JustかNothingが返る
const gradeLens = R.lensProp("grade");
// student.examsの値が同じ型ではないのでS.map()の型制約を満たさない.unchecked.map()を使う.
const mark = student => R.set(gradeLens, S.unchecked.map(grade2)(student.exams), student);
console.log(students.map(mark));
console.log("----------------");
console.log(students);
前回の成績処理プログラムでは null
は 'D'
に,'NA'
は '欠席'
に変換されました.今回変更点の核心は grade2
関数で,まず S.toMaybe
で得点データを Maybe モナドにリフトし(ラッピングすること),Maybe モナドに対して grade
関数をマップしています.これによって得点が null
の場合は grade
関数による処理がスルーされます.最後の S.fromMaybe('欠席')
でモナドから値を取り出しています.モナドが Nothing
インスタンスの場合は引数の '欠席'
が返ります.
上のコードでは,null
は Maybe モナドで欠席に変換され,'NA'
などの文字列は grade
関数によって欠席に変換されており,ちぐはぐ感があります.grade
関数は数値データのみを扱い,A から D までの成績付けを行い,数値以外は全て Maybe モナド側で欠席に変換したい場合は,以下のようにします.
const geqAlt = (border, grade) => alt => x => x >= border ? grade : alt(x);
const grade = geqAlt(90, "A")(geqAlt(80, "B")(geqAlt(60, "C")(x => "D")));
// sanctuaryのcomposeは2引数までなのでramdaを使う
const grade2 = R.compose(
S.fromMaybe("欠席"),
S.map(grade), // Nothingはこのmapをスルーする
S.ifElse(_.isNumber)(S.toMaybe)(x => S.Nothing)); // lodashのisNumberを使う
grade2
関数の一番最初に S.ifElse
があり,ここで引数が Number 型なら S.toMaybe
を起動しモナド化し,そうでなければ x ⇒ S.Nothing
を実行し Nothing
を生成するように変更しています.Maybe モナドはデフォルトでは null
と undefined
に対して Nothing
を返すので,自分で条件を設定して Nothing
を返します.
引数が数値か否かの判定はいろいろ面倒なので lodash の isNumber
関数を使うのが良いでしょう.
その他
Sanctuary の解説なのにところどころで ramda を使っています.Sanctuary は可変引数をサポートしていないので,関数合成によって新たな関数を作成するには ramda の compose
関数を使う方が私は好きです.例えば,grade2
関数を Sanctuary の compose
を使うと以下のようになります.Sanctuary の compose
は 2 引数までしかサポートしておらず,カリー化されているため,以下のような難解な呼び出しになってしまいます.
const grade2 = S.compose(
S.fromMaybe("欠席")) // getOrElse()と同じ.
(S.compose((S.map(grade))) // Nothingはこのmapをスルーする
(S.ifElse(_.isNumber)(S.toMaybe)(x => S.Nothing)));
おわりに
Maybe モナドを使えば null
を含むデータの複雑なパイプライン処理も,簡潔に美しく書くことができます.あとは例外処理をうまく抽象化する Either モナドをマスターすれば,全ての実用的処理を関数合成のみで美しく書き上げることができます.Either モナドについてはいずれ取り上げましょう.次回は,Functor のすぐサブクラスに位置する Apply について解説する予定です.Apply をマスターすると 2 重ループを簡潔に表現することが可能になります.