JavaScript(ES6)とRamdaで関数型プログラミング

2018/02/01


さっき「関数型プログラミング」でGoogle検索したら最初に表示された「関数型プログラミングはまず考え方から理解しよう」というページ、本文はもちろん、活発な議論がなされたコメント部分も、とても面白かったです。

ただ、コメント中のHaskellの美しいコードは、無限リストが使えるからこその書き方なんですよね……。このやり方は、JavaScriptだとちょっと辛い。コメント中でHaskellと同様のやり方をJavaScriptで書いてくださっている方も、繰り返す数を指定することで対処しています。

うんやっぱりHaskellはすげーなぁというのは当然なんだけど、JavaScriptを気に入っている私としては、実はJavaScriptもそれなりにすごいんだよということを示しておきたい。だから、JavaScript(EcmaScript 6)に関数型プログラミング向けライブラリのRamdaをインポートして、同じお題でプログラミングしてみました。

元ページのお題

詳しくは元ページを見ていただくとして、簡単に書くとこんな感じ。

とりあえず、関数型で書いてみた

#後で述べますけど、このコードはかなりヘッポコです……。後で修正しますから、ここで見捨てないで。

というわけで、とりあえず関数型プログラミングした結果は以下の通り。

// ES6なのでrequireじゃなくてimport。
import R from 'ramda';

// 元ネタから、データ構造をちょっと変更。
const lunchBoxes = [{'唐揚げ': {count: 10}, '玉子焼き': {count: 1}},
                    {'唐揚げ': {count:  8}},
                    {'唐揚げ': {count:  6}}];

// つまみ食い。
function eatWithFinger(foodName, lunchBoxes) {
  // foodNameの数量が最大の弁当のインデックスを取得します。
  const canEatIndex = R.apply(R.reduce(R.maxBy(([lunchBox, i]) => lunchBox[foodName].count)),
                              R.juxt([R.head, R.tail])(R.addIndex(R.map)(R.constructor(Array), lunchBoxes)))[1];

  // canEatIndexの弁当のfoodNameの数量を1減らした、新しいリストを返します。
  return R.assocPath([canEatIndex, foodName, 'count'], lunchBoxes[canEatIndex][foodName].count - 1, lunchBoxes);
}

export default function main() {
  // 唐揚げを5回つまみ食い。
  const eatKaraageWithFingerFiveTimes = R.apply(R.pipe)(R.repeat(R.curry(eatWithFinger)('唐揚げ'), 5));

  console.log(eatKaraageWithFingerFiveTimes(lunchBoxes));
}

// [10, 8, 6] → [9, 8, 6] → [8, 8, 6] → [7, 8, 6] → [7, 7, 6] → [6, 7, 6]となるので、
// 各弁当の唐揚げの数は、6個、7個、6個になります。

以下、Ramdaの使い方の解説です。Ramdaをご存知の方は飛ばしてください。

R.applyは、f(a, b, c)という関数を[a, b, c]という引数で呼び出せるように変換する関数です。で、Ramdaが提供する関数はすべてカリー化されていて、だからR.apply(f)とやると[a, b, c]を引数にとる関数が返ってきます(R.apply(f(a))なら、[b, c]が引数になる)。で、上のコードでR.reduceR.applyしているのは、R.reduceの引数はR.reduce(f, initialValue, xs)となっていて、初期値(initialValue)を必ず指定しなければならないためです(JavaScriptのArrayreduceでは、初期値を指定しない場合は自動でArrayの最初の要素が初期値になるのに……)。

R.maxByは、R.maxBy(pred, x, y)とするとpred(x)pred(y)を比較して、大きな方(xy)を返す関数です。これも当然カリー化されているので、R.maxBy(pred)すると引数を2個とる関数が返されて、それはR.reduceの第一引数の関数にちょうどよいというわけ。なので、上のコードではR.reduce(R.maxBy())して最大の要素を求めています。

R.juxtは、複数の関数に同じ引数を渡すための処理です。R.juxt([foo, bar])(x)とすると[foo(x), bar(x)]が返ってきます。R.headは最初の要素、R.tailは二番目から最後までのリストを返すので、これでやっとR.reduceの引数が揃います。

で、そのR.juxtした関数に渡しているのは、R.addIndex(R.map)(R.constructor(Array))したlunchBoxesです。R.addIndexはインデックス対応バージョンの関数を返しますので、これで[[lunchBox[0], 0], [lunchBox[1], 1] ...]が手に入るというわけ。あ、R.constructorは、扱いが面倒なコンストラクタを関数化する関数です。あと、[lunchBox, index]のうち、canEatIndexとして表現したいのはindexの方なので、最後に[1]を追加しています。

あとは、R.assocPathで、lunchBoxesを修正した新しいlunchBoxesを返すだけ。この関数をR.curryでカリー化して、R.repeatで5個並べて、R.pipeで順に呼び出すようにしています(R.pipe(a, b, c)(x)とするとc(b(a(x)))になります。aしてbしてcするのを順序どおりに表現できるので、私はこの関数が大好きです)。

これで、繰り返し回数をつまみ食いする関数から分離できました(R.repeatの引数に移動しただけという気もしますが……)。元ページみたいにつまみ食いした途中のlunchBoxesを取得したい場合は、R.tapするとか(デバッグのときに便利です)、R.pipeじゃなくてR.reduceするとかで大丈夫かと。

以上、Ramdaの解説終わり。ふう、これで関数型プログラミングのコードができあがりました。

念のため、手続き型で書いてみた

でも、関数型プログラミングのコードだけあっても、良いか悪いか判断できませんよね? 異なる手法でプログラミングしたコードと比較しないと。というわけで、手続き型で同じ処理を書いてみました。

const lunchBoxes = [{'唐揚げ': {count: 10}, '玉子焼き': {count: 1}},
                    {'唐揚げ': {count:  8}},
                    {'唐揚げ': {count:  6}}];

function eatWithFinger(foodName) {
  let canEatIndex = 0;
  for (let i = 1; i < lunchBoxes.length; ++i) {
    canEatIndex = lunchBoxes[i][foodName].count > lunchBoxes[canEatIndex][foodName].count ? i : canEatIndex;
  }

  lunchBoxes[canEatIndex][foodName].count--;
}

export default function main() {
  for (let i = 0; i < 5; ++i) {
    eatWithFinger('唐揚げ');
  }

  console.log(lunchBoxes);
}

……あれ? さっきの関数型より、この手続き型の方が簡単で分かりやすい?

もう一度、関数型で書いてみた

……冷静になれ、私。

最初のコードをよく見てみると、Ramdaがxだからyしているという部分があって、その結果としてコードが複雑になっています。たとえばR.reduceの引数に初期値が必要とかね。でもこれはRamda的にはしょうがなくて、Ramdaはカリー化を前提にしているので引数の数によるオーバーローディングができないんですよ。だから、R.reduceでは必ず初期値を指定するしかない。

でもね、私が今書いているのはJavaScriptで、JavaScriptでは引数の数によるオーバーローディングは当たり前の技術で、Arrayreduceは初期値の省略が可能になっています。だから、サクっとreduceの別バージョンを作っちゃいました(Ramdaの公式サイトのサンプルでも、その場で関数をじゃかじゃか追加しているしね)。内容が単純なので、ラムダ式で書きます。

const reduce = (pred, xs) => R.apply(R.reduce(pred), R.juxt([R.head, R.tail])(xs));

あとあれだ、最大の要素を探すときにR.maxByR.reduceを組み合わせるのも面倒くさかった。なので、別バージョンを。

const maxBy = (pred, xs) => reduce(R.maxBy(pred), xs);
// カリー化を活用してconst maxBy = reduce(R.maxBy(pred))の方が短いけど、引数が消えるとわかりづらくなりそうだったので……。

インデックス化もね。

const indexed = (xs) => R.addIndex(R.map)(R.constructor(Array), xs);

あれ、今作ったmaxByindexedを組み合わせると、最大の要素のインデックスを取得する関数も作れちゃう?

const maxIndexBy = (pred, xs) => maxBy(R.apply(pred), indexed(xs))[1];
// JavaScriptは引数の数が足りない場合は単純に無視するので、R.apply(pred)するだけで大丈夫。

ついでに、assocPathの値ではなくて関数を取るバージョンを、adjustPathとして定義しちゃいましょう。

const adjustPath = (path, func, xs) => R.assocPath(path, func(R.path(path, xs)), xs);

関数型プログラミングは関数という小さな単位が基本要素なので、組み合わせの際に小回りが利いて実に便利ですな。サクサク新しい関数を作れちゃう。今回作成した関数群は唐揚げ弁当問題に特化していない、汎用的なものなので再利用できそうですしね。

というわけで、これらの関数群をutility.mjsとしてまとめた上で、もう一度関数型プログラミングしてみましょう。

import R                        from 'ramda';
import {adjustPath, maxIndexBy} from './utility';

const lunchBoxes = [{'唐揚げ': {count: 10}, '玉子焼き': {count: 1}},
                    {'唐揚げ': {count:  8}},
                    {'唐揚げ': {count:  6}}];

const canEatIndex = (foodName, lunchBoxes) => maxIndexBy(R.path([foodName, 'count']), lunchBoxes);
const eatWithFinger = (foodName, lunchBoxes) => adjustPath([canEatIndex(foodName, lunchBoxes), foodName, 'count'],
                                                           R.dec,
                                                           lunchBoxes);

export default function main() {
  const eatKaraageWithFingerFiveTimes = R.apply(R.pipe)(R.repeat(R.curry(eatWithFinger)('唐揚げ'), 5));

  console.log(eatKaraageWithFingerFiveTimes(lunchBoxes));
}

おお、ちょー短い。canEatIndexは数量が最も大きい食材のインデックスを返すこと、eatWithFingerはその食材の数量をデクリメントすることが、コードからすぐに分かります(adjustPathという関数が何するのかは、R.adjustから推測できるはず……ということにしてください)。

で、上のコードで注目して頂きたい点が、もう一つあります。

今回書き直した際にcanEatIndexを別の関数に分割したわけですけど、手続き型の場合は、このように関数を分割するのはかなり勇気がいります。だって、別にした関数の中でlunchBoxesを変更していないことを確認するには、その関数の中身を確認しなければならないわけですからね。今回みたいに単純なプログラムならいいですけど、でも、大きなプログラムだったり、複数人で開発していたりしたら? 関数型プログラミングは副作用を嫌いますから、関数型プログラミングしていればそんな心配は不要となります(ちなみにeatWithFingerにも副作用はありません。新しいリストを返しますから)。うん、私は関数型プログラミングな人で良かったなぁと。

参考

関数型プログラミングはまず考え方から理解しよう ←元ネタ。

唐揚げつまんでみた ←Haskellやっぱりすげー。Haskell的な書き方をJavaScriptでもやっていてすげー。

PHPでも唐揚げつまんでみた ←PHPでHaskellライクにやってます。すげー。

Ramda ←とても便利。おすすめです。

functional-programming-with-es6-and-ramda ←本稿で作成したコードです。