5
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

javascriptを関数型で書く

Last updated at Posted at 2020-04-08

概要

  • javascriptを関数型で記述し、堅牢性・生産性を検証する
  • クライアントサイドに絞ったテストのコード化の手法を説明する

参考:  

メリット / デメリット

メリット

  • コンテキストによりコロコロ変わるthisを極力使わないで済む
  • 関数チェーンの導入により、同期/非同期を あまり 意識しないでよい
  • 予期せぬ挙動(副作用)を減少できる
  • モジュール性の向上、関数の再利用促進が期待できる
  • テストのコード化が容易となる
  • 他の言語にも関数型の概念は取り入れられている その理解の助けとなる
    CakePHP Collection型

デメリット

  • ES6サポート前提である
  • リソース効率が命令型と比べ悪い
  • 学習コストがかかる

用語・Tips

宣言型

  • データがどのように処理されているか明示しない = 制御フローを関数に移譲する
  • 反意語:命令型
// 命令型
let array = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
for (let i = 0; i< array.length; i++) {  // どのように実行すべきか細かく指示
  array[i] = array[i] ** 2;
}
array;  // -> [0, 1, 2, 9, 16, 25, 36, 49, 64, 81]
// 宣言型
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map(num => num ** 2) // map関数に制御フローの一部(ループ制御)を移譲
// -> [0, 1, 2, 9, 16, 25, 36, 49, 64, 81]
  • 制御フローを関数化することにより、制御フロー自身の再利用が可能
// 命令型
let array = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
let new_array = [];
for (let i = 0; i< array.length; i++) {
  array[i] = array[i] ** 2;
}
for (let j = 0; j< array.length; j++) {  // 制御フロー自身は再利用できない
    array[j] = array[j] ** 3;
}
array;  // -> [0, 1, 64, 729, 4096, 15625, 46656, 117649, 262144, 531441]
// 宣言型
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
  .map(num => num ** 2)
  .map(num => num ** 3) // 制御フロー自身を再利用する
// -> [0, 1, 64, 729, 4096, 15625, 46656, 117649, 262144, 531441]

純粋

不変性

  • 状態 ( = データ ) を変えない
    • スコープ外の値を変えない
    • 引数の値を変えない
  • プリミティブ型が理想
  • const宣言より確実にオブジェクト型を変えない手法をとる

副作用

  • 副作用の例)

    • this
    • スコープ外の変数・プロパティ・データ構造(DOM含む)を変更する
    • ユーザー入力を処理する
    • 例外を外部に投げる
    • 画面表示・ログ出力
    • クッキーの読み書き・サーバへの問い合わせ
  • 副作用を全て排除するわけではない
    純粋な部分とそうでない部分を分離する
    関数型での例外処理

参照透過性

  • 入力値のみに戻り値が左右される
  • コードでのテストがやりやすくなる
  • モックやスタブで置き換えやすくなる
// 参照透過性なし
function getDay() {
  let today = new Date();  // 実行日によって変わるため、参照透過性は失われる
  return today.getDate();
}
// 参照透過性あり
function getDay(today) {
  return today.getDate();
}

関数

  • javascriptでは関数もオブジェクト

  • 「関数は単一の機能を持つべき」

  • 小さく分解することで

    • より再利用しやすく
    • より信頼性を高く
    • より簡単に理解
  • 戻り値必須(void不可)

純粋関数

  • 副作用状態変化を伴わない関数
  • 隠された値には依存しない
    引数省略不可
    = 暗黙的なメンバ変数使用不可
    = グローバル変数不可
  • 外部スコープの値には依存しない
  • 外部スコープの値を変更しない

高階関数

  • 関数を引数にとる関数
  • javascriptの言語仕様上、関数全てにあてはまる

関数チェーン

  • 必要になったとき初めて実行される = 遅延評価(必要呼び) ⇒ 同期/非同期関係なし
  • 入出力のオブジェクトに注意する
funcA().funcB().funcC().funcD() ...
// ≒ funcA() | funcB() | funcC() | funcD() | ...  シェルでのイメージ

レンズ

  • 別名:関数参照
  • 参照元のオブジェクトを、setterの部分だけを変えてコピーできる
  • コピー時は動的に加えたプロパティも適用される

参考:
超強力な関数型プログラミング用ライブラリ Ramda.js を学ぼう #2 - lens でオブジェクト操作も思いのまま
Ramda.jsのLensとは?

関数合成

  • 関数を組み合わせて、別の機能を持つ高階関数を作り上げる
  • 関数チェーンより入出力のオブジェクトの制限は緩い
    • 接続される関数同士において、引数の個数が同一で、引数の型に互換性があればよい
  • 入出力の型に気をつける
  • Ramdaライブラリの機能を使う
const hoge = func(funcA, funcB, funcC, funD);
// = funcA | funcB | funcC | funcD Shellでのイメージ
let fuga = hoge('piyo'); // -> funcの戻り値
const trim = (str) => str.trim();
const normalize = (str) => str.replace(/\-/g, '');
const addExtention = (str) => str + '.jpg';
const createFileName = R.pipe(trim, normalize, addExtention);
// ≒ addExtention(normalize(trim()))
// ≒ trim() | normalize() | addExtention()  Shellでのイメージ
console.log(createFileName('44-44- 44 '));  //-> 4444 44.jpg

カリー化

  • 引数を省略できない関数型プログラミングにおいて、引数を減らしたり、引数の数を揃えるためのテクニック
  • 濫用するとメモリの多用につながる

参考:
関数をcurry化して処理を先送り

const print = (a, b, c, d) => `${a}_${b}_${c}_${d}`
const curriedPrint = R.curry(print);  // カリー化
const complementedPrint = curriedPrint('aaa', 'bbb');

console.log(complementedPrint('ccc', 'ddd'));  // -> aaa_bbb_ccc_ddd

部分適用

  • 引数を省略できない関数型プログラミングにおいて、引数を減らしたり、引数の数を揃えるためのテクニック
  • 挙動がカリー化と異なる
// カリー化
const print = (a, b, c, d) => `${a}_${b}_${c}_${d}`
const curriedPrint = R.curry(print);  // カリー化
const complementedPrint = curriedPrint('aaa', 'bbb');
console.log(complementedPrint('ccc'));  // -> 再度カリー化関数が作られる

// 部分適用
const partialPrint =_.partial(print, 'aaa', 'bbb');
console.log(partialPrint('ccc'));  // -> aaa_bbb_ccc_undefined

コンピネータ

  • 制御文を関数にすることで、制御文を含んだ形での関数合成を可能にする

  • pipe

    • 関数合成
  • compose

    • 関数合成 (pipeとは因数の順序が逆)
R.pipe(funcA, funcB, funcC, funcD) == R.compose(funcD, funcC, funcB, funcA)
  • identify

    • 与えられた入力値と同じ値を返す
  • tap

    • 関数実行と同時に、与えられた入力値を返すので、void型関数・非同期処理を呼び出す起点に使える
const writeTxt = function (path, context) {
  // バイナリデータを作ります。
  let blob = new Blob([context], {type: "text/plain;charset=utf-8"});

  // IEか他ブラウザかの判定
  if(window.navigator.msSaveBlob)
  {
    // IEなら独自関数を使います。
    window.navigator.msSaveBlob(blob, path);
  } else {
    // それ以外はaタグを利用してイベントを発火させます
    let a = document.createElement("a");
    a.href = URL.createObjectURL(blob);
    a.target = '_blank';
    a.download = path;
    a.click();
  }
}

const partialLogging1 = R.tap(_.partial(writeTxt, 'log1.txt'));
const partialLogging2 = R.tap(_.partial(writeTxt, 'log2.txt'));
const cangeCase = (str) =>  str + ' → B';

const hogePipe = R.pipe(partialLogging1, cangeCase, partialLogging2);
hogePipe('ケースA');   // log1.txt -> ケースA
                       // log2.txt -> ケースA → B

参考:
JavaScriptだけでファイルの保存機能を実装する

  • alternation
    • if - else文
function thenFnc(val){
  console.log(`then: ${val} > 10`);
}
function elseFnc(val){
  console.log(`else: ${val} <= 10`);
}
const CurriedAlt = R.curry(
  (thenFnc, elseFnc, val) => {
    if (val > 10) {
      thenFnc(val);
    }else{
      elseFnc(val);
    }
  }
);
const logging = R.tap(console.log);
const partialAlt = R.pipe(logging, CurriedAlt(thenFnc, elseFnc));
partialAlt(9);  // -> 9
                // -> else: 9 <= 10
  • sequence

    • = Array.forEach関数
    • _.mapとは違い、値は返さない
  • fork

    • 結果を統合する
    • ≒ join関数・concat関数
function FncA(val){
  return [`${val}_A1`, `${val}_A2`];
}
function FncB(val){
  return [`${val}_B1`, `${val}_B2`];
}
function FncC(val){
  return [`${val}_C1`, `${val}_C2`];
}
const CurriedFork = R.curry((FncA, FncB, FncC, val) => FncA(val).concat(FncB(val), FncC(val)));
const logging = R.tap(console.log);

const partialFork = R.pipe(logging, CurriedFork(FncA, FncB, FncC));
console.log(partialFork('Array'));  // -> Array
                                    // -> ["Array_A1", "Array_A2", "Array_B1", "Array_B2", "Array_C1", "Array_C2"]

関数型での例外処理

  • try-catchの問題点
    • 例外処理を含んだ形での関数チェーン化・関数合成が困難となる
    • 例外により、関数の出口(戻り値の種類)が増えてしまう
    • 意図しない副作用の原因となる
    • 局所性の原則(エラー回復コードはエラー原因の関数呼び出しの近くにあるべき)に反しやすい
    • 複数エラー条件に対応しようとすると、try-catch構文が入れ子になってしまい、扱いづらい

純粋な部分とそうでない部分に分離する

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

モナド

  • 関数型プログラミングにおいてのデザインパターン
  • モナドはあくまで総称
  • 関数合成に挟み込むことで、堅牢性を上げている

パターン:

  • Maybeモナド
    • 期待する値か否かで戻り値のオブジェクトごと分ける = 純粋な部分とそうでない部分に分離する
    • 関数チェーンを壊さずにエラー処理を実装することが可能
  • Eitherモナド
  • IOモナド

参考:
30分でわかるJavaScriptプログラマのためのモナド入門
モナドはポケモン。数学が出てこないモナド入門

非同期処理

  • Promiseを有効活用する
  • 時間的な依存関係のグループを作る ⇒ 時間的結束

リアクティブプログラミング

  • 動的な値の変更を伝播させるデータフロー指向のプログラミングパラダイム
  • RxJSやらCycle.jsやらVue.js(部分的)やら
  • 今回は触れない

参考:
リアクティブプログラミングへの理解がイマイチだったのでまとめてみた

コーディング

ライブラリの準備

cd {インストールディレクトリ}
npm i lodash
npm i ramda
    <script src="{インストールディレクトリ}/node_modules/lodash/lodash.js"></script>     <!-- _で呼び出し可能-->
    <script src="{インストールディレクトリ}/node_modules/ramda/dist/ramda.js"></script>  <!-- Rで呼び出し可能-->

テスト

cd {インストールディレクトリ}
npm i mocha
npm i chai
<head>
    <link rel="stylesheet" type="text/css" href="./test_js/node_modules/mocha/mocha.css">
    <script src="{インストールディレクトリ}/node_modules/mocha/mocha.js"></script>
    <script src="{インストールディレクトリ}/node_modules/chai/chai.js"></script>
</head>
    <body>
        <!-- テスト結果表示エリア -->
        <div id="mocha"></div>

        <!-- Mocha設定 -->
        <script class="mocha-init">
          mocha.setup('bdd');  // BDD用にセットアップする (describeとかitが使えるようになる)
          mocha.checkLeaks();  // テスト実行中にグローバル変数が追加されたらエラー終了させる
        </script>

        <script src="./js/Sample_curry.js"></script> <!-- テスト対象js -->
        <script src="./test_js/Test_Sample_curry.js"></script> <!-- テストケースjs -->

        <!-- テスト実行 -->
        <script class="mocha-exec">
          mocha.run();
        </script>
    </body>
// Sample_curry.js
const print = (a, b, c) => `${a}_${b}_${c}`

// e.g.) 1. curry化されてない関数
R.tap(console.log, 'e.g.) 1. Not To curry ----')
console.log(print(1, 2))

// e.g.) 2. curry化
R.tap(console.log, 'e.g.) 2. To curry ----')
const curriedPrint = R.curry(print);
const f2 = curriedPrint('aaa', 'bbb');
console.log(f2('ccc', 'ddd'));

// e.g.) 2'. curry化
R.tap(console.log, 'e.g.) 2. To curry ----')
const curriedPrint2 = R.curryN(3, print);
const f2_2 = curriedPrint2('aaa', 'bbb');
console.log(f2_2('ccc'));

// Test_Sample_curry.js
const expect = chai.expect;
const assert = chai.assert;  // chaiとmochaはグローバルに読み込み済み
describe('Carry化 テスト', () => {
	it('curry化 (R.curry)', () => {
		assert.equal(f2('ccc'), 'aaa_bbb_ccc');
    });
    
    it('curry化 (R.curryN)', () => {
		expect(f2_2('ccc')).to.equal('ddd_aaa_bbb'); // わざと失敗させている
	});
});

結果

test_result.png

参考:
MochaとChaiでなんでもテスト ~ブラウザ用JavaScript編~

評価

以下の処理を行うプログラムで比較する

  1. JSONを取得しnames属性(配列)を取得
  2. nullチェックを実施
  3. name属性の記号を取り除く
  4. name属性の先頭を大文字化
  5. name属性の重複除去
  6. 5件以内なら、mdのテーブルとして追記、log.mdファイルとして出力
  7. コンソールに出力

コード

// コード1: プロミス、Maybeモナド、関数チェーン、カリー化使用
class Maybe {
  static just(obj) {
    return new Just(obj);
  }
  static nothing(){
    return new Nothing();
  }

  // fromNulllable:: Object -> Maybe
  static fromNulllable(obj) {
    if ((obj.names == null) || (!obj.names[Symbol.iterator])) {  // 2
      return Maybe.nothing();
    } else {
      return Maybe.just(obj);
    }
  }
}

// 想定内の値の場合のクラス
class Just extends Maybe {
  constructor(obj) {
    super();
    this._obj = obj;
  }
  get value() {
    return this._obj;
  }
}

// 想定外の値の場合のクラス
class Nothing extends Maybe {
  get value() {
    return {names: []};
  }
}

// writeTxt:: str, str -> void
function writeTxt(path, context) {
  let blob = new Blob([context], {type: "text/plain;charset=utf-8"});

  // IEか他ブラウザかの判定
  if(window.navigator.msSaveBlob)
  {
    // IEなら独自関数を使います。
    window.navigator.msSaveBlob(blob, path);
  } else {
    // それ以外はaタグを利用してイベントを発火させます
    let a = document.createElement("a");
    a.href = URL.createObjectURL(blob);
    a.target = '_blank';
    a.download = path;
    a.click();
  }
}

// writeMd:: str -> str , str -> writeTxt -> void
const writeMd = R.tap(_.partial(writeTxt, 'log.md'));

// logConsole:: str -> str
const logConsole = R.tap(console.log);

// thenFnc::array -> void
function thenFnc(array) {
  const md_contents = '| Phonetic Code |\n| *---* |\n'
    + '| ' + _(array).reduce((total,val) => total + ' |\n| '+ val) + ' |'
  const logging = R.pipe(writeMd, logConsole);
  logging(md_contents);  // 7
  return;
}

// elseFnc:: array -> void
function elseFnc(array) {
  console.log('[error] 0 Items OR Over 5 Items!');
  return;
}

const isValid = (val) => !_.isUndefined(val) && !_.isNull(val) && (val !='');

// CurriedAlt:: Maybe -> array -> void
const CurriedAlt = R.curry((thenFnc, elseFnc, maybe) => {
  const array = _.chain(maybe.value.names)
    .filter(isValid)
    .map((obj) => obj.toString().replace(/\-/, ''))  // 3
    .map(_.startCase)  // 4
    .uniq()  // 5
    .value();
  if ((array.length > 0) && (array.length <= 5)) { // 6
    thenFnc(array);
  } else {
    elseFnc(array);
  }
});

const toMd = R.pipe(Maybe.fromNulllable, CurriedAlt(thenFnc, elseFnc));

Promise.resolve($.getJSON('./tmp/sample.json')).then((data) => {  // 1
  toMd(data);
}).catch((e) => console.log(e));
// コード2: プロミス、Maybeモナド、関数チェーン使用
class Maybe {
  static just(obj) {
    return new Just(obj);
  }
  static nothing(){
    return new Nothing();
  }

  // fromNulllable:: Object -> Maybe
  static fromNulllable(obj) {
    if ((obj.names == null) || (!obj.names[Symbol.iterator])) {  // 2
      return Maybe.nothing();
    } else {
      return Maybe.just(obj);
    }
  }
}

// 想定内の値の場合のクラス
class Just extends Maybe {
  constructor(obj) {
    super();
    this._obj = obj;
  }
  get value() {
    return this._obj;
  }
}

// 想定外の値の場合のクラス
class Nothing extends Maybe {
  get value() {
    return {names: []};
  }
}

// writeMd:: str , str -> void
function writeMd(path, context) {
  let blob = new Blob([context], {type: "text/plain;charset=utf-8"});

  // IEか他ブラウザかの判定
  if(window.navigator.msSaveBlob)
  {
    // IEなら独自関数を使います。
    window.navigator.msSaveBlob(blob, path);
  } else {
    // それ以外はaタグを利用してイベントを発火させます
    let a = document.createElement("a");
    a.href = URL.createObjectURL(blob);
    a.target = '_blank';
    a.download = path;
    a.click();
  }
}


// thenFnc::array -> void
function thenFnc(array) {
  const md_contents = '| Phonetic Code |\n| *---* |\n'
    + '| ' + _(array).reduce((total,val) => total + ' |\n| ' + val) + ' |'
  writeMd('log.md', md_contents);
  console.log(md_contents);  // 7
  return;
}

// elseFnc:: array -> void
function elseFnc(array) {
  console.log('[error] 0 Items OR Over 5 Items!');
  return;
}

// alt:: Maybe -> array -> void
const alt = (maybe) => {
  const array = _.chain(maybe.value.names)
    .filter(isValid)
    .map((obj) => obj.toString().replace(/\-/, ''))  // 3
    .map(_.startCase)  // 4
    .uniq()  // 5
    .value();
  if ((array.length > 0) && (array.length <= 5)) {  // 6
    thenFnc(array);
  } else {
    elseFnc(array);
  }
};

const toMd = (obj) => alt(Maybe.fromNulllable(obj));

const isValid = (val) => !_.isUndefined(val) && !_.isNull(val) && (val !='');

Promise.resolve($.getJSON('./tmp/sample.json')).then((data) => {  // 1
  toMd(data);
}).catch((e) => console.log(e));
// コード3: プロミス、Maybeモナド、関数チェーン不使用
// writeMd:: str , str -> void
function writeMd(path, context) {
  let blob = new Blob([context], {type: "text/plain;charset=utf-8"});

  // IEか他ブラウザかの判定
  if(window.navigator.msSaveBlob)
  {
    // IEなら独自関数を使います。
    window.navigator.msSaveBlob(blob, path);
  } else {
    // それ以外はaタグを利用してイベントを発火させます
    let a = document.createElement("a");
    a.href = URL.createObjectURL(blob);
    a.target = '_blank';
    a.download = path;
    a.click();
  }
}


// thenFnc::array -> void
function thenFnc(array) {
  let md_contents = '| Phonetic Code |\n| *---* |\n'
  
  for (const val of array) {
    md_contents += `| ${val} |\n`;
  }

  writeMd('log.md', md_contents);
  console.log(md_contents);  // 7
  return;
}

// elseFnc:: () -> void
function elseFnc() {
  console.log('[error] 0 Items OR Over 5 Items!');
  return;
}

// toMd:: obj -> void
const toMd = (obj) => {
  try {
    let array = [];
    for (let i = 0; i < obj.names.length; i++) {
      let n = obj.names[i];
      if (n !== undefined && n !== null && n !== '' ) { // 2
        n = n.toString().replace(/\-/, '');  // 3
        n = n.substr(0, 1).toUpperCase() + n.substr(1);  // 4
        if (array.indexOf(n) == -1) {  // 5
          array.push(n);
        }
      }
    }
    if ((array.length > 0) && (array.length <= 5)) {  // 6
      thenFnc(array);
    } else {
      elseFnc();
    }
  } catch (e) {  // 2
    elseFnc();
  }
}

try {
  $.getJSON('./tmp/sample.json', (data) => {  // 1
    toMd(data);
  });
} catch(e){
  console.log(e);
}

結果

文字数 メモリ使用量
コード1 2257 46236 KB
コード2 2018 45200 KB
コード3 1504 44376 KB

結論

  • javascriptは「命令型だけ」、「関数型だけ」と、片方のみサポートしているわけではない
    うまく組み合わせると、堅牢性の高いコードの作成が可能である
    • 命令型でも、コード2のように不変性・参照透過性を意識するようにするとよい
    • コード3ではどうしても変数をlet宣言しなければならず、その分堅牢性が劣ると考える
  • ループが絡む場合、関数チェーンの方がコードの見通しが良い
    • コード3ではnullチェック処理がぱっと見では何をしているかわからなかった
  • コード1ではUnixのようなパイプ処理が可能なことが魅力だが、コード量・メモリ使用量ともに増えている
    • AWS Lambdaのようにメモリ使用量が費用に直結する場合は注意が必要である
5
6
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?