JavaScript関数型プログラミング Ch.5 複雑性を抑えるデザインパターン
まえがき
- FPのエラー処理はエレガント
- エラー処理は複雑になりがち
- 例外
- null参照
- 複雑なコードの例外処理のためにさらに複雑なコードになる
- 開発者なんだから体力ではなく知力で闘おう
- Functor
- Monad
例外の問題
- stack unwindingのため合成やチェーン化が適用できない
- 参照透過性に反する
- 関数は単一の予測可能な値に評価される
- 例外を投げると、出口パスが複数になってしまう
- 予期しないstack unwindingは関数呼び出しだけでなくシステム全体に影響を及ぼし、副作用を招く
- 局所性の原則に反する
- エラーから回復するコードは、エラーの原因から離れている
- 呼び出し元に大きな責任を負わせる
- 入れ子になると扱いづらい
適切な使いどころ
nullチェックの問題
- 例外を投げるかわりにnullを返す
- 関数の出口パスは一応1つになる
- でもクソコードになる
function getCountry(student) { const school = student.getSchool(); if (school !== null) { const addr = school.getAddress(); if (addr !== null) { let country = addr.getCountry(); return country; } return null; } throw new Error('Error extracting coutry info'); }
- オブジェクトのプロパティを抽出するだけでこのざま
student.school.address.country
に注目するような、シンプルなレンズを定義することもできる- ただし、アドレスが
null
の場合レンズはundefined
を返すことができるが、エラーメッセージを出力できない
- ただし、アドレスが
より優れたソリューション:ファンクター
- 安全でない可能性のあるコードを安全な箱(コンテナ)でくるむという点は
try-catch
と同じ - 実体のない
try-catch
の代わりに、実体のあるデータ型で包むtry-catch
は捨てる
安全ではない値のラッピング
class Wrapper { constructor(value) { this._value = value; }, // map :: (a -> b) -> Wrapper a -> b map(f) { return f(this._value); }, // fmap :: (a -> b) -> Wrapper a -> Wrapper b fmap(f) { return new Wrapper(f(this._value)); }, toString() { return 'Wrapper (' + this._value + ')'; } } const wrap = val => new Wrapper(val);
- ラッピングされた値への直接アクセス禁止
- 値へのアクセスは、コンテナに処理をmappingすることによってのみ可能
- 安全性チェックの責務をコンテナに負わせる
- 包んでいる値が安全か安全でないかはコンテナが知っている
- コンテナの外から関数を渡す人は、値の安全性を意識しなくていい
ファンクターの詳細
処理の流れ
Functor f => fmap :: (a -> b) -> f a -> f b
- 値をファンクターから取り出す(lift)
- 関数を作用させた値を、同種のファンクターで包んで返す
- 新しく作るので不変
実は今まで使ってました
map :: (a -> b) -> Array a -> Array b
[1, 2, 3].map(x => x ** 2);
Array
なんかは正しくファンクター- ラッピングされた値に直接アクセスできちゃうけど
Array.prototype.map
がfmap
相当
Functor則
Identity
を作用させると同じファンクターが得られる- 構造が維持される
f a -> f a
- 値が同じである
- 構造が維持される
- 合成関数
f . g
を作用させる場合
fmap (f . g)
=(fmap f) . (fmap g)
- javascriptの例を挙げると
[1, 2, 3].map(compose((x => x + 1), (x => x ** 2)))
は[1, 2, 3].map(x => x ** 2).map(x => x + 1)
と等しい
- javascriptの例を挙げると
ファンクターの限界
// findStudent :: DB -> String -> Wrapper Student const findStudent = R.curry(function(db, ssn) { return wrap(find(db, ssn)); }); // getAddress :: Wrapper Student -> Wrapper Wrapper String const getAddress = student => wrap(student.fmap(R.prop('address'))); // student.fmap(R.prop('address')) の時点で Wrapper Stringに評価される // それをさらにwrapしているので Wrapper Wrapper String // studentAddress -> Wrapper Wrapper String const studentAddress = R.compose( getAddress, findStudent(db('student')) );
ファンクターをコード全体で利用しようとすると、幾重にもラッピングされてしまう
モナドを使った関数型エラー処理
$('#student-info').fadeIn(3000).text(student.fullname());
モナド:制御フローからデータフローへ
- 値が安全でない場合どうするか、コンテナに知識を持たせる
pp.167-168のコードをちょっと変えたやつ
// 空のコンテナ // 「値がない」場合を安全に取り扱う class Empty { // map :: (a -> b) -> Wrapper a -> b map(f) { // 書籍ではこうなっている。これはおかしいでしょう(型が合わない) // return this; // Identityを作用させてnullが返るのが正しい? return null; // まあこの後消える関数なのであまり深く考えなくてもいい }, // fmap :: (a -> b) -> Wrapper a -> Wrapper b fmap (_) { return new Empty(); // 関数を作用させても何もしない }, toString () { return 'Empty()'; }, } const empty = () => new Empty();
// isEven :: Number -> Boolean const isEven = n => n % 2 == 0; // half :: Number -> (Wrap Number) | Empty const half = val => isEven(val) ? wrap(val / 2) : empty(); half(4); // -> Wrapper(2) half(3); // -> Empty() // 不正な入力値(3)について、エラー値としてEmptyコンテナを返す half(4).fmap(increment); // -> Wrapper(3) half(3).fmap(increment); // -> Empty() // 不正な入力値(3)についても関数をマッピングする方法を知っている // (単になにもしない)
モナドのインターフェース
- モナド型
Wrapper
Empty
- モナド
Wrapper
,Empty
のインターフェースMaybe
Optional
- 型コンストラクタ
Wrapper
Empty
- ユニット関数
wrapper(val)
empty()
- モナド内部に実装された場合は
of
関数と呼ぶらしい
- バインド関数
- 処理をチェーン化する
flatMap
>>=
Monad m => (>>=) -> m a -> (a -> m b) -> m b
- ジョイン関数
- 入れ子のコンテナを単層化
join
Monad m => m (m a) -> m a
- 本書p.170の
join
は1層残して全層引き剥がすみたい
リファクタ
class Wrapper { constructor(value) { this._value = value; }, // of :: a -> Wrapper a static of(a) { return new Wrapper(a); }, // map :: (a -> b) -> Wrapper a -> Wrapper b // fmapと呼んでいたやつ // 以後、単にmapとする map(f) { return Wrapper.of(f(this._value)); }, // join :: Wrapper Wrapper ... a -> Wrapper a // ↑正式にはどう書くんだろ // 1層は残す join() { if(!(this._value instanceof Wrapper)) { return this; } // recursion return this._value.join(); } // get :: Wrapper a -> a get() { return this._value; // Emptyならnullを返すのかな } toString() { return 'Wrapper (' + this._value.toString() + ')'; } }
MaybeモナドとEitherモナドによるエラー処理
- 有効な値がない場合のモデリング
null
undefined
- 下記に対応
- 不純性を論理的に分離する
- nullチェックのロジックを統合する
- 例外を投げる処理を統合する
- 関数の合成をサポートする
- デフォルト値の提供ロジックを一元化する
Maybeでnullチェックを一元化
- 不正データが入力されたら単に何もしない
Either
- 同時に取りえない2つの値aとbとの論理的分離を表す
- Left a : 起こりうるエラーメッセージ、投げうる例外オブジェクトを格納
- Right b : 成功値を格納
- Scalaでは
Try
型として知られるSuccess
Failure
- ただし、完全にはモナドではない
- タプルでいいのでは?
- 不適。タプルは直積型(product type)
- ここまではいい
{succeeded: true, error: ''}
{succeeded: false, error: 'エラー原因'}
- これを表せちゃうのが良くない
{succeeded: false, error: ''}
{succeeded: true, error: 'エラー原因'}
- ここまではいい
- 成否には直和型(or type)を使うべき
- 不適。タプルは直積型(product type)
- 例外を投げうるコードを保護することができる
function decode(uri) { try { const result = decodeURIComponent(url); // throws URIError return Eigher.of(result); } catch(uriError) { return Either.left(uriError); // 例外オブジェクトをLeftで包む } }
Leftコンテナを開けた場合のみ例外が投げられる
IOモナドを使用して外部リソースとやり取りする
- DOMの読み書きは副作用
- readを複数回呼び出す間にwriteが実行されると、readの結果も変わる
- writeの結果は当然毎回変わる
- 予測不能
- が、処理をチェーン化して一度に実行することで、
単一の「疑似的な」参照透過な処理として実行できる- readやwriteの処理中に他の処理が発生しないことが保証される
- 予測不可能な結果を招かない
<div id="student-name">alonzo church</div>
- DOM操作をモナドチェーンで繋ぐ
- 遅延評価
const changeToStartCase = IO.from(readDom('#student-name')) .map(_.startCase) .map(writeDom('#student-name'));
- まだ実行してない
<div id="student-name">alonzo church</div>
- 実行
changeToStartCase.run();
// readDom('#student-name')以降の処理が順に実行される
- 反映される
<div id="student-name">Alonzo Church</div>
モナドチェーンと合成
- モナドチェーン
- データフローを制御する
- 合成
- プログラムフローを制御する
モナドチェーン
const showStudent = (ssn) => Maybe.fromNullable(ssn) .map(cleanInput) .chain(checkLengthSsn) .map(R.tap(trace('Input was valid'))) .chain(findStudent) .map(R.tap(trace('Record fetched successfully!'))) .map(R.props(['ssn', 'firstname', 'lastname'])) .map(csv) .map(R.tap(trace('Student info converted to CSV'))) .map(append('#student-info')) .map(R.tap(trace('Student added to HTML page')));
- 仮引数ssnがある
- traceはログ書き出し。
副作用があるが、「実質的に意味のある」副作用ではないので、純粋なものとみなしている
モナドとプログラマブルカンマ
// Functor f => (a -> b) -> F a -> F b const map = R.curry((f, container) => container.map(f)); // Monad m => (a -> M b) -> M a -> M b const chain = R.curry((f, container) => container.chain(f));
- 引数型によるディスパッチはできないので、メソッド呼び出しのシングルディスパッチを中で呼び出す
- ポリモーフィズムの実現
const showStudent = R.compose( R.tap(trace('Student added to HTML page')), map(append('#student-info')), R.tap(trace('Student info converted to CSV')), map(csv), map(R.props(['ssn', 'firstname', 'lastname'])), R.tap(trace('Record fetched successfully!')), chain(findStudent), R.tap(trace('Input was valid')), chain(checkLengthSsn), lift(cleanInput) );
- 仮引数がない
- 「ポイントフリー」
DOM書き出しをIOモナドで改善
煩雑なので、いったんtraceを外す
const showStudent = R.compose( map(append('#student-info')), map(csv), map(R.props(['ssn', 'firstname', 'lastname'])), chain(findStudent), chain(checkLengthSsn), lift(cleanInput) ); // append('#student-info')が実行され、 // DOMへの書き出しという副作用が起こる。 // 不純な関数 showStudent('4444-44-4444');
showStudent自体は純粋な関数にしたい
// IOのメソッドを関数に // (staticメソッドだからしなくてもいい気がするけど) const liftIO = function (val) { return IO.of(val); } // string -> IO string const showStudent = R.compose( // IOモナドは処理の実行を遅延する map(append('#student-info')), // ここでMaybeモナドから値を取り出し、IOモナドで包み直す liftIO, getOrElse('unable to find Student'), map(csv), map(R.props(['ssn', 'firstname', 'lastname'])), chain(findStudent), chain(checkLengthSsn), lift(cleanInput) ); // showStudentを実行すると、学生のレコードの取得~csvへの成形まで行う // 副作用のあるDOM出力は待機するので、showStudent自体は純粋 // (findStudentが純粋なのかはさておき) const io = showStudent('4444-44-4444'); // io.runして初めてappend('#student-info')が実行される // 副作用を伴い、不純 io.run();
まとめ
- オブジェクト指向のコードで例外を投げるメカニズムにより、関数が純粋ではなくなる。つまり、関数の呼び出し元は、適切なtry-catchのロジックを提供する重い責任を負わされることになる
- 値のコンテナ化のパターンは、副作用のないードを作成するのに使用される。値のコンテナ化は、単一の参照透過な処理を考慮して、変異する可能性のある値をラッピングすることによって実現される
- ファンクターを使って関数をコンテナにマッピングする。そうすることにより、副作用がなく、不変な方法でオブジェクトにアクセスしたり修正したりすることができる
- モナドはプログラミングデザインパターンである。モナドを使って、関数間の安全なデータフローを調整することにより、アプリケーションの複雑性が削減される
- 回復力があり堅牢な関数合成では、Maybe, EigherおよびIOなどのモナド型を差し挟んでいる