Edited at

JavaScript で関数型プログラミング実践

More than 1 year has passed since last update.


何これ

私はここ1,2 か月集中的に JavaScript (Node) + Ramda + Folktale で関数型プログラミングを勉強していて、そろそろ実践的なコードが書けそうだなと思ったので披露をするよ。「関数型プログラミングって何よ?」という質問に対しては、本稿では「以下のスタイルでコードを作成すること」とするよ。



  • pipe, compose を使った関数合成で処理を構築するスタイル


    • 可読性を失わない範囲でポイントフリーを指向するスタイル


    • map, reduce 等を使って制御構造を削減するスタイル



  • モナドを使うスタイル


    • 関数を純粋に構成するスタイル


    • lift, sequence, traverse, chain/flatMap, map を活用するスタイル



質問あればコメントしてね。


作るもの


仕様

以下のように資産クラスが与えられた際に、実行時の資産総額を出力するプログラム。VISA と JT と テンセントの株を持っていて、銀行預金として、みずほとりそな、SBIの外貨預金をしている例を示す。

const asset = {

stocks: [
{ label: 'V', number: 100, comment: 'VISA' },
{ label: '2914:TYO', number: 500, comment: 'JT' },
{ label: '0700:HKG', number: 100, comment: 'Tencent' },
],
cache: [
{ value: 500000, currency: 'JPY', comment: 'mizuho' },
{ value: 300000, currency: 'JPY', comment: 'resona' },
{ value: 10000, currency: 'USD', comment: 'SBI' },
],
};


実行例

この人は4.8 万ドル持っているみたい。お金もちだね。

$ node fin.js

48683.32551606166


実装


使用ライブラリ


  • HTTP リクエスト: axios

  • XML パーサ: xml2js

  • Task モナド: Folktale

  • 便利な関数: Ramda

モナドは、Monet.js と Folktale がお勧め。便利関数群は Ramda と Lodash があるけど、Ramda の方が機能が多いような気がする(Lodash で traverse とか lift ってできないような)


WebAPI


  • Google finance: 全世界の取引所の株価を提供

  • ECB: 為替データを提供

Google Finance のAPI getprices は非公式API であるが、現時点で世界の取引所の株価を取得できる現実的なAPI はここしかない。

為替データは EU の中央銀行 ECB が公式に継続的にデータを配信しているので利用させてもらう。XML形式なので JavaScript との相性は悪い。


コード



  • 概要:以下3つをTaskモナドでくるみ、集計関数をlift して適用して結果を得る


    • 為替データの取得: getExchangeTable

    • 株データの取得: getStock

    • 現金データの取得: getCache




  • ひとこと


    • callback型のXMLパーサのメソッド呼び出しはnodebackFromTaskTask モナドに変換する

    • Promise は promisedToTaskTask に変換する

    • エラーのときは NaN が返る



  • ソース


const axios = require('axios');

const xml2js = require('xml2js');
const R = require('ramda');
const nodebackFromTask = require('folktale/conversions/nodeback-to-task');
const promisedToTask = require('folktale/conversions/promised-to-task');
const Task = require('folktale/concurrency/task');

const xmlToJSON = nodebackFromTask((new xml2js.Parser()).parseString);

// FX stuff, getExchangeTable() and related.
const createRawExchangeTable = R.pipe(
R.path(['gesmes:Envelope', 'Cube', 0, 'Cube', 0, 'Cube']),
R.map(R.prop('$')),
R.reduce((acc, x) => { acc[x.currency] = parseFloat(x.rate); return acc; }, {}),
);
const createExchangeTable = table => ({
JPY: table.JPY / table.USD,
HKD: table.HKD / table.USD,
EUR: table.USD,
USD: 1.0,
});
const getExchangeTable = R.pipe(
() => 'http://www.ecb.int/stats/eurofxref/eurofxref-daily.xml',
promisedToTask(axios.get), // -> Task
R.map(R.prop('data')),
R.chain(xmlToJSON), // flatten Task of Task
R.map(createRawExchangeTable),
R.map(createExchangeTable),
);

// Stock stuff, getStocks() and related.
const attachInfo = (entry) => {
const [ticker, exchange] = entry.label.split(':');
const url = `https://finance.google.com/finance/getprices?q=${ticker}&i=300&p=5d&f=o`;
/* eslint no-nested-ternary: 'off' */
const currency = exchange === 'HKG' ? 'HKD' : exchange === 'TYO' ? 'JPY' : 'USD';
return ({
ticker,
exchange,
url: exchange ? url.concat(`&x=${exchange}`) : url,
currency,
...entry,
});
};
const getPrice = R.pipe(
R.prop('url'),
promisedToTask(axios.get),
R.map(R.pipe(
R.prop('data'),
R.split(/(?:\n)/g),
R.takeLast(2),
R.take(1),
parseFloat,
)),
);
const attachPrice = (entry) => {
const price = getPrice(entry);
return R.chain(p => Task.of({ price: p, ...entry }))(price);
};
const getStock = R.pipe(
R.prop('stocks'),
R.map(attachInfo),
R.map(attachPrice), // ask Google howmuch. returns Array of Task
R.sequence(Task.of), // to a single Task (of Array)
);

// cache stuff
const getCache = obj => Task.of(obj.cache);

// Reporting Stuff
/* eslint no-mixed-operators: 'off' */
const calcTotalUSD = (xTable, stocks, caches) => (
stocks.reduce((acc, x) => acc + x.number * x.price / xTable[x.currency], 0) +
caches.reduce((acc, x) => acc + x.value / xTable[x.currency], 0)
);

// Main
const asset = {
stocks: [
{ label: 'V', number: 100, comment: 'VISA' },
{ label: '2914:TYO', number: 500, comment: 'JT' },
{ label: '0700:HKG', number: 100, comment: 'Tencent' },
],
cache: [
{ value: 500000, currency: 'JPY', comment: 'mizuho' },
{ value: 300000, currency: 'JPY', comment: 'resona' },
{ value: 10000, currency: 'USD', comment: 'SBI' },
],
};

R.lift(calcTotalUSD)(getExchangeTable(), getStock(asset), getCache(asset))
.run().promise().then(console.log);