ファンタジー・ランドの保護地区をゆく Part 2 --- Maybe モナド

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

この記事は 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 は引数が nullundefined ならば Maybe のサブクラスである Nothing 型のインスタンスを返し,それ以外ならば Just 型のインスタンスを返します.どちらも Maybe 型のサブクラスなので,共通のインターフェースを持ち,JustNothing の区別なく 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 行目の mapgreet 関数がモナド内の値に適用されるため,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 モナドはデフォルトでは nullundefined に対して Nothing を返すので,自分で条件を設定して Nothing を返します.

引数が数値か否かの判定はいろいろ面倒なので lodashisNumber 関数を使うのが良いでしょう.

その他

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 重ループを簡潔に表現することが可能になります.

wshito

Read more posts by this author.

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