なんでもかんでも関数型 -JavaScript編-


はじめに

JavaScript編となっておりますが、他の言語編を作成するかは未定です。
俺が/私が 〇〇編を書いてやるぜ、という意欲的な執筆者様は、遠慮なくやってください。当記事のコードをそのまま移植しても構いません。
また、もっと良い書き方がありますよ、という場合は是非編集リクエストをお願いします。

この記事の目的

あんな処理やこんな処理を関数型の考え方で書くとどうなるのかを把握し、関数型プログラミングを学ぶとっかかりにしていただいたり、どこまで関数型の発想を採用するのかを考えるための資料としていただくことを期待しています。

タイトルの通り、見境なく関数型の発想で記述したコードを掲載しますが、必ずしも関数型のアプローチが良いケースとは限りません。他のプログラミングパラダイムの方がスマートな場合もあるかと思いますので、そのあたりは各自で適宜判断してください。

対象環境

レガシーブラウザは考慮しません。執筆時点で最新の主要ブラウザで動作するコードを意識しております。
ECMAScript 6 の構文を利用しています。

基本編

基本編では、関数型プログラミングでよく使うメソッドの利用法を記載します。

forEach(callback)

全ての要素に対して callback を呼び出します。

['one', 'two', 'three']
.forEach(str => console.log(str));
  // => 'one'
  // => 'two'
  // => 'three'

map(callback)

各要素を変換します。

console.log(
  ['one', 'two', 'three']
  .map(str => str.toUpperCase())
); // => Array ['ONE', 'TWO', 'THREE']

filter(callback)

要素にフィルタをかけます(条件に合致した要素のみを取り出します)。

console.log(
  ['one', 'two', 'three' ]
  .filter(str => str.includes('o'))
); // => Array ['one', 'two']

reduce(callback), reduceRight(callback), join(separator)

関数 説明
reduce 結果を集約する
reduceRight (末尾から)結果を集約する
join 文字列を指定した separator で結合する
console.log(
  ['one', 'two', 'three']
  .reduce((prev, current) => `${prev}, ${current}`)
); // => 'one, two, three'

console.log(
  ['one', 'two', 'three']
  .reduceRight((prev, current) => `${prev}, ${current}`)
); // => 'three, two, one'

console.log(
  ['one', 'two', 'three']
  .join(', ')
); // => 'one, two, three'

Object.keys(object), Object.values(object), Object.entries(object)

対象のオブジェクト自身が持つ列挙可能(enumerable = true)とマークされているプロパティを取得する関数です。

関数 説明
keys() オブジェクトのキーを含む配列を取得する
values() オブジェクトの値を含む配列を取得する
entries() オブジェクトのキーと値のペアを含む配列を取得する
const obj = { one: 1, two: 2, three: 3 };

console.log(Object.keys(obj));
  // => Array ['one', 'two', 'three']

console.log(Object.values(obj));
  // => Array [1, 2, 3]

Object.entries(obj)
  .forEach(([key,value])=>console.log(`${key} - ${value}`));
  // => 'one - 1'
  // => 'two - 2'
  // => 'three - 3'

実践編

Fizz-Buzz を記述する

まずは肩慣らしです。

new Array(15).fill(0)
  .map((zero, i) => i + 1)
  .map(n => {
    if (n % 15 === 0) return 'Fizz Buzz';
    if (n %  5 === 0) return 'Buzz';
    if (n %  3 === 0) return 'Fizz';
    return `${n}`;
  })
  .forEach(str => console.log(str));
  // => '1'
  // => '2'
  // => 'Fizz'
  // => '4'
  // => 'Buzz'
  // ...
  // => '14'
  // => 'Fizz Buzz'

単一のオブジェクトに集約する

Object.assignを利用して集約していきます。
reduce(callback, initialValue) で初期値を与えることもできますが、Object.assign は破壊的関数なので、初期値として与えたオブジェクトが変更されてしまうことに注意してください。

console.log(
  ['first:one', 'second:two', 'third:three']
  .map(s => s.split(':'))
  .map(([key, value]) => ({[key]: value}))
  .reduce((obj, current) => Object.assign(obj, current))
); // => Object { first: "one", second: "two", third: "three" }

解説
事前に map()を用いて [key, value] のような配列に変換し、
次の map()計算されたプロパティ名の機能を利用してオブジェクトに変換しています。
最後に reduce(callback) の callback 内で Object.assign(target, source) を用いて単一のオブジェクトに結合しています。
手続き的に処理する方法に対してかなり余分にオブジェクトを生成することになるので、巨大なデータをターゲットにする際は注意が必要です。

指定された範囲の数値を含む配列を作成する

Array.prototype.map(callback) のコールバックが第二引数にその要素のインデックスを受け取ることを利用します。

const FROM = 4;
const TO   = 7;
console.log(
  new Array(TO - FROM)
  .fill(0)
  .map((zero, i) => FROM + i)
); // => Array [4, 5, 6]

任意の配列を(動的に)作成する

ジェネレータ関数スプレッド構文を利用することで、イテレータから配列を生成することができます。
応用次第でどのような配列でも作成できるでしょう。

下記のコードは環境が2ミリ秒を刻むまでひたすら要素を生成し、それらすべての要素を持つ配列を生成しています。

[...
  (function*(to){
    for (let i = 0; performance.now() < to; i++) yield i; 
  })(performance.now() + 2)
]
.forEach(num=> console.log(num));
  // => 1
  // => 2
  // => 3
  // => ...

CSV を解析して配列に変換する

遅いアルゴリズムなので、巨大なデータを読み込む可能性のある場合は工夫が必要です。

const CSV = 
`FIRST,SECOND,THIRD
1,2,3
one,two,three`;

const arr = CSV
  .split(/\n|\r\n|\r/g)
  .map(line => line.split(','));
const header = arr.shift();
console.log(
  arr.map(lineArray =>
    lineArray
    .map((v,i)=>[header[i],v])
    .map(([k,v])=>({[k]:v}))
    .reduce((obj,current)=>Object.assign(obj,current))
  )
); // => Array [
   //      { FIRST: "1", SECOND: "2", THIRD: "3" },
   //      { FIRST: "one", SECOND: "two", THIRD: "three" }
   //    ]

文字種別テーブルを作成する

".map((v, i) => 条件 ? : v)" を繰り返して配列を埋めていきます。
構文解析を記述するようなときによく利用します。

const charTable =
  new Array(0x80)
  .fill('UNKNOWN')
  .map((v, i) => /\s/          .test(String.fromCharCode(i)) ? 'WHITE_SPACE' : v)
  .map((v, i) => /[\w]/        .test(String.fromCharCode(i)) ? 'WORD'        : v)
  .map((v, i) => /[\d]/        .test(String.fromCharCode(i)) ? 'NUMBER'      : v)
  .map((v, i) => /["'`]/       .test(String.fromCharCode(i)) ? 'QUOTE'       : v)
  .map((v, i) => /[.#[\]+~:>,]/.test(String.fromCharCode(i)) ? 'OPERATOR'    : v);

console.log(charTable['x'.charCodeAt(0)]); // => 'WORD'
console.log(charTable['9'.charCodeAt(0)]); // => 'NUMBER'

(単方向)連結リストを作成する

右方向から結合する reduceRight()を利用すると、最終的に返されるオブジェクトが先頭のノードになります。

let linkedList = 
  ['one', 'two', 'three']
  .reduceRight((right, left) => ({value: left, next: right}), {});

console.log(linkedList.value); // => 'one'
linkedList = linkedList.next;
console.log(linkedList.value); // => 'two'
linkedList = linkedList.next;
console.log(linkedList.value); // => 'three'

<ul>,<li> タグを用いたツリーを作成する

さすがにやりすぎか……

<div id="target"></div>
document.getElementById('target').appendChild(
  (function createElement(obj){
    return (obj.children || [])
      .map(createElement)
      .map(node =>
        document.createElement('li')
        .appendChild(node)
        .parentNode
      )
      .reduce(
        (parent, node) =>
          parent
          .appendChild(node)
          .parentNode,
        Object.assign(
          document.createElement('div'),
          { innerHTML : `<span>${obj.name}</span>` }
        )
        .appendChild(document.createElement('ul'))
      )
      .parentNode;
  })(
    {
      name: 'なんでもかんでも関数型',
      children:[
        { name: 'はじめに',
          children:[
            { name: 'この記事の目的' },
            { name: '対象環境' },
          ]
        },
        { name: '基本編',
          children:[
            { name: 'forEach(callback)' },
            { name: 'map(callback)' },
            { name: 'filter(callback)' }
          ]
        }
      ]
    }
  )
);

出力:

なんでもかんでも関数型

  • はじめに
    • この記事の目的
    • 対象環境
  • 基本編
    • forEach(callback)
    • map(callback)
    • filter(callback)

Node.appendChildは追加した子要素の方を返すことに注意。

ありがちな失敗

new Array(n).forEach(callback) で指定回数ループしない

new Array(n)length == nの配列が作成されますが、値が初期化されないため、forEach では列挙されません。
map 等のメソッドでも同様です。

new Array(3).forEach(() => console.log('hoge'));
  // => (何も出力されない)

対策:Array.prototype.fill() を用いてなんでもいいから値を入れる。

new Array(3)
  .fill(0)
  .forEach(() => console.log('hoge'));
  // => 'hoge'
  // => 'hoge'
  // => 'hoge'

reduce() で例外が出るコードを書いてしまう

与えられた(内容を把握していない)配列を集約しようとして、空の配列が渡される可能性を考慮していない。

function sum (arr) {
  return arr.reduce((prev, crnt) => prev + crnt);
}

console.log(sum([1, 2, 3])); // => 6
console.log(sum([])); // => ERROR: Reduce of empty array with no initial value

対策:空の配列が渡される可能性がある場合には、 reduce(callback, initialValue) の第二引数に初期値を与える。

function sum (arr) {
  return arr.reduce((prev, crnt) => prev + crnt, 0);
}

console.log(sum([])); // => 0

参考になりそうな記事

  • ECMAScript6にシンボルができた理由
    関数型プログラミングをしていると、Array.prototype にモンキーパッチを当てたくなる場面が出てくると思います。 上記の記事で紹介されている Symbol オブジェクトを利用することで、(おそらく)安全にモンキーパッチを当てることができます。 素晴らしい!
  • ES Nextのパイプライン演算子ってどうなの?
    まだまだ先の話という感じですが、この記事で紹介されているパイプライン演算子が実装されれば、関数型プログラミングは格段にやりやすくなることでしょう。
    データ → フィルタや変換 → 集約 → 結果の処理
    という流れがネストせずに全て同じインデントで書けるようになれば、非常にコードが読みやすくなりそうですね!