JavaScript関数型プログラミング Ch.5 複雑性を抑えるデザインパターン

JavaScript勉強メモ関数型プログラミング

出典: 

まえがき

  • FPのエラー処理はエレガント
  • エラー処理は複雑になりがち

    • 例外
    • null参照
  • 複雑なコードの例外処理のためにさらに複雑なコードになる
  • 開発者なんだから体力ではなく知力で闘おう

    • Functor
    • Monad

例外の問題

  • stack unwindingのため合成やチェーン化が適用できない
  • 参照透過性に反する

    • 関数は単一の予測可能な値に評価される
    • 例外を投げると、出口パスが複数になってしまう
  • 予期しないstack unwindingは関数呼び出しだけでなくシステム全体に影響を及ぼし、副作用を招く
  • 局所性の原則に反する

    • エラーから回復するコードは、エラーの原因から離れている
  • 呼び出し元に大きな責任を負わせる
  • 入れ子になると扱いづらい

適切な使いどころ

  • エッジケース
  • APIの使い方が根本的に間違っている
  • 回復不能なエラー

    • スタックオーバーフロー

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
  1. 値をファンクターから取り出す(lift)
  2. 関数を作用させた値を、同種のファンクターで包んで返す

    • 新しく作るので不変

実は今まで使ってました

map :: (a -> b) -> Array a -> Array b
[1, 2, 3].map(x => x ** 2);
  • Arrayなんかは正しくファンクター

    • ラッピングされた値に直接アクセスできちゃうけど
    • Array.prototype.mapfmap相当

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)
      と等しい

ファンクターの限界

// 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());
  • jQueryはDOMモナド

    • #student-info要素が見つからなくても、例外は発生せず、適切に失敗する

モナド:制御フローからデータフローへ

  • 値が安全でない場合どうするか、コンテナに知識を持たせる

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)を使うべき
  • 例外を投げうるコードを保護することができる
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などのモナド型を差し挟んでいる