前書き
ES2015も最近は、だいぶ浸透してきた感があります。よく読まれる記事の中にも、ES2015に関するものが入ることが多い印象があります。
なので今回は1歩踏み込んで、業務でよく使うデータ構造の変換をES2015ではどう書くのかを紹介します。
一から書いてもいいのですが、すでにあるものは30 seconds of codeから引用します。
自分がここ最近サイトの中でも間違えなくベストに近いサイトで、興味があれば全部目を通してみてください。
ただ、30秒以内に読めるコードをそろえましたという文言はキャッチーですが、その基準は明らかにおかしいんじゃないかと思っています。
データ構造の変換
周知のとおり、ES2015では配列に関するArrayのAPIが強化されました。
そのため、今回はとりあえず配列とそれ以外のデータ構造を相互に変換する方法ついてのみ触れます。
配列そのもの
まずは配列そのものに関するデータ構造の変換を扱います。
結合のconcatや部分を取り出すslice、配列を展開するスプレッド演算子あたりに注目してもらえばいいのかなと思います。
基本的に副作用のないように考えてあるので、そこらへんもポイントになるかなと。引数を変更してしまうようなことはES2015時代では避けたほうがいいでしょう。
// 下記はすべて(30 seconds of code)より引用
// 配列の分割
const chunk = (arr, size) =>
Array.from({ length: Math.ceil(arr.length / size) }, (v, i) =>
arr.slice(i * size, i * size + size)
);
chunk([1, 2, 3, 4, 5], 2); // [[1,2],[3,4],[5]]
// 部分集合の取得(右側から要素を除外)
const dropRight = (arr, n = 1) => arr.slice(0, -n);
dropRight([1, 2, 3]); // [1,2]
dropRight([1, 2, 3], 2); // [1]
dropRight([1, 2, 3], 42); // []
// 非破壊的なソート(自作)
// 普通のソートは破壊的なので、基本的には使わないほうがいいです(配列を自体変更してしまう)
const safeSort = (arr, comp) => [...arr].sort(comp);
safeSort([1, 2, 3], (a, b) => b - a); // [3, 2, 1]
// 配列の平坦化(2次元)
const flatten = arr => [].concat(...arr);
flatten([1, [2], 3, 4]); // [1,2,3,4]
// 配列の平坦化(N次元)
// 再帰で複雑なことを簡単に表現していることに注目!
const deepFlatten = arr => [].concat(...arr.map(v => (Array.isArray(v) ? deepFlatten(v) : v)));
deepFlatten([1, [2], [[3], 4], 5]); // [1,2,3,4,5]
// 配列の合成
const zip = (...arrays) => {
const maxLength = Math.max(...arrays.map(x => x.length));
return Array.from({ length: maxLength }).map((_, i) => {
return Array.from({ length: arrays.length }, (_, k) => arrays[k][i]);
});
};
zip(['a', 'b'], [1, 2], [true, false]); // [['a', 1, true], ['b', 2, false]]
zip(['a'], [1, 2], [true, false]); // [['a', 1, true], [undefined, 2, false]]
集合
集合は重複を許さない配列のようなものです。この相互変換に難しいことは特にありません。
これを利用することで配列から簡単に重複を消すことができます。あとは、配列同士の比較をしたいときも使うことがあります。
// 配列から集合への変換
const arrayToSet = arr => new Set(arr);
arrayToSet([1, 2, 3, 3]); // [1, 2, 3]
// 集合から配列への変換
const setToArray = s => [...s];
setToArray(new Set([1, 2, 3, 3])); // [1, 2, 3]
// 参考(30 seconds of code)より引用
// 集合を使って差分を取得
const difference = (a, b) => {
const s = new Set(b);
return a.filter(x => !s.has(x));
};
difference([1, 2, 3], [1, 2, 4]); // [3]
単方向リスト
いわゆる連結リストです。これは無限に続く構造なので比較的難しいです。
無限に続く構造は基本的に再帰で解くのが常套手段です。ここでは次を示すポインタがないことが停止条件です。
APIをうまく使えば、ワンライナーで再帰が書けます。このテクニックは便利なので、ぜひ習得しましょう。
Object.assignは第1引数のオブジェクトを上書きする副作用があるので、空のオブジェクトをはさむようにしました。イディオムみたいなものと覚えておけばいいでしょう。
// 配列から単方向リスト
const arrayToLinkedList = arr => arr.map((v, i, a) => Object.assign({}, v, {next: a[i+1]}));
arrayToLinkedList([{val: 1}, {val: 2}]); // [{"val":1,"next":{"val":2}},{"val":2}]
// 単方向リストから配列
const linkedListToArray = (v, next) => [].concat(next(v)? [v, ...linkedListToArray(next(v), next)] : [v]);
linkedListToArray({"val":1,"next":{"val":2}}, v=> v.next); // [{"val":1,"next":{"val":2}},{"val":2}]
オブジェクト
配列からオブジェクトへの変換はreduceを使って行います。キーや値をどのように設定するかによって変わるので、汎用的には書けません。なのでいくつか例を引っ張ってきます。
配列を集約するためのreduceはここからも頻出なので、使いこなし方をぜひ覚えてください。
オブジェクトから配列への変換はObject.keysやObject.valuesを使えば比較的簡単に書けます。
// 下記はすべて(30 seconds of code)より引用
// 値をキーとして扱ったうえで、値に関数を適用した結果を使ってオブジェクトに変換
const mapObject = (arr, fn) =>
(a => (
(a = [arr, arr.map(fn)]), a[0].reduce((acc, val, ind) => ((acc[val] = a[1][ind]), acc), {})
))();
const squareIt = arr => mapObject(arr, a => a * a);
squareIt([1, 2, 3]); // { 1: 1, 2: 4, 3: 9 }
// 条件(関数)に基づいて、グループ化したオブジェクトを返す
const groupBy = (arr, func) =>
arr.map(typeof func === 'function' ? func : val => val[func]).reduce((acc, val, i) => {
acc[val] = (acc[val] || []).concat(arr[i]);
return acc;
}, {});
groupBy([6.1, 4.2, 6.3], Math.floor); // {4: [4.2], 6: [6.1, 6.3]}
groupBy(['one', 'two', 'three'], 'length'); // {3: ['one', 'two'], 5: ['three']}
// オブジェクトから配列に変換
const objectToPairs = obj => Object.keys(obj).map(k => [k, obj[k]]);
objectToPairs({ a: 1, b: 2 }); // [['a',1],['b',2]])
文字
これをできると文字列処理もだいぶ楽になりますね。一度、文字列から配列に変換し、APIを使って気分よく処理してから文字列に戻すのが必勝パターンです。
// 文字列から配列に変換
const strToArray = str => [...str];
// 配列から文字列に変換
const arrayToStr = arr.join('');
// 参考(30 seconds of code)より以下引用
// 文字列を配列にしてから処理する例
// 先頭を大文字にする
const capitalize = ([first, ...rest], lowerRest = false) =>
first.toUpperCase() + (lowerRest ? rest.join('').toLowerCase() : rest.join(''));
capitalize('fooBar'); // 'FooBar'
capitalize('fooBar', true); // 'Foobar'
// 文字列の結合+末尾のみ個別処理
const join = (arr, separator = ',', end = separator) =>
arr.reduce(
(acc, val, i) =>
i == arr.length - 2
? acc + val + end
: i == arr.length - 1 ? acc + val : acc + val + separator,
''
);
join(['pen', 'pineapple', 'apple', 'pen'], ',', '&'); // "pen,pineapple,apple&pen"
join(['pen', 'pineapple', 'apple', 'pen'], ','); // "pen,pineapple,apple,pen"
join(['pen', 'pineapple', 'apple', 'pen']); // "pen,pineapple,apple,pen"
数値
困ったらreduceで集約できます。数値にするときはとりあえず、reduceと覚えておけばいいでしょう。
逆に数値から配列を作るときは、rangeみたいなものが欲しいですが、ないので自分で組みます。
Array.fromとArray.fillが便利ですね。これがあれば、関数を覚えていなくても、N個のシーケンスが書けます。
// 下記はすべて(30 seconds of code)より引用
// 平均
const average = (...nums) => [...nums].reduce((acc, val) => acc + val, 0) / nums.length;
average(...[1, 2, 3]); // 2
average(1, 2, 3); // 2
// 合計
const sum = (...arr) => [...arr].reduce((acc, val) => acc + val, 0);
sum(...[1, 2, 3, 4]); // 10
// 同じ値の数を数える
const countOccurrences = (arr, val) => arr.reduce((a, v) => (v === val ? a + 1 : a + 0), 0);
countOccurrences([1, 1, 2, 1, 2, 3], 1); // 3
// 数値から配列
const digitize = n => [...`${n}`].map(i => parseInt(i));
digitize(123); // [1, 2, 3]
// 範囲を配列に変換
const initializeArrayWithRange = (end, start = 0, step = 1) =>
Array.from({ length: Math.ceil((end + 1 - start) / step) }).map((v, i) => i * step + start);
initializeArrayWithRange(5); // [0,1,2,3,4,5]
initializeArrayWithRange(7, 3); // [3,4,5,6,7]
initializeArrayWithRange(9, 0, 2); // [0,2,4,6,8]
テーブルの結合(追記)
よくあるRDBのJoinを書きます。やる気があれば、可変長引数でまとめてJoinしたり、あるいは関数の合成ができるともう少し使いやすい気もします。夢が広がりますね。
今回はやらないので、これは宿題ということで。
const t1 =
[{id: 1, outerId: 1},
{id: 2, outerId: 1},
{id: 3, outerId: 2}];
const t2 =
[{id: 1, value: "hoge"},
{id: 2, value: "foo"},
{id: 3, value: "var"}];
// 外部結合
const leftJoin = (t1, t2, on, map) => t1.map(v1 => map(v1, t2.filter(v2 => on(v1,v2))));
leftJoin(t1, t2, (v1, v2) => v1.outerId === v2.id, (v1, v2s) => ({t1: v1, t2: v2s}));
// 結果
// [{"t1":{"id":1,"outerId":1},"t2":[{"id":1,"value":"hoge"}]},
// {"t1":{"id":2,"outerId":1},"t2":[{"id":1,"value":"hoge"}]},
// {"t1":{"id":3,"outerId":2},"t2":[{"id":2,"value":"foo"}]}]
// 内部結合
// ループ2回なので、効率を考えるとreduceとかのほうがいいかも
const innerJoin = (t1, t2, on, map) => t1.filter(v1 => t2.some(v2 => on(v1, v2)))
.map(v1 => map(v1, t2.find(v2 => on(v1, v2))));
innerJoin(t1, t2, (v1, v2) => v1.outerId === v2.id, (v1, v2) => ({t1: v1, t2: v2}))
// 結果
// [{"t1":{"id":1,"outerId":1},"t2":{"id":1,"value":"hoge"}},
// {"t1":{"id":2,"outerId":1},"t2":{"id":1,"value":"hoge"}},
// {"t1":{"id":3,"outerId":2},"t2":{"id":2,"value":"foo"}}]
ツリー構造
現実的に単方向リストとほぼ同じ方法(再帰)で行けるはずなので、省略します。
興味があれば、やってみてください。ここまで使ったAPIの組み合わせを使えば、色んな方法でもできる気がしますね。
ツリー構造をつぶして配列にするのは配列のところで紹介したdeepFlatten が近いですね。結合条件を関数でもらって汎用的にするところがコツかなと思ったり。
タプル
JavaScriptにはタプルはありません。しかし、オブジェクトで頑張るか、配列と分割代入で近いことができるので、それが欠点とは思いません。
分割代入はES2015の構文の中であまり取り上げられませんが、短いコードを書く場合にはかなり便利です。
const tuple = [1, 2];
// インデックスに基づいて名前を付けて取り出す
[first, second] = tuple; //first = 1, second = 2
キュー、スタック(追記)
両方とも、何もしなくても実現できます。
配列はデフォルトでpush(末尾に追加)、pop(末尾から取り出し)ができるので、スタックとして扱えます。
また、unshift(先頭に追加)、shift(先頭から取り出し)もできるので、変換せずともキューライクなことはできます。
とはいえ、破壊的な操作を伴うので、関数型的に扱うのならラップしたものを自分で組んだほうがいいでしょう。
まぁ、短く書きたいのなら、分割代入とスプレッド演算子で頑張ったら副作用なしでいけるような気もするので、必要なところだけ使うように我慢したほうがいいのではないでしょうか。
// スタック
const s = [1, 2, 3];
s.push(4);
s; // [1, 2, 3, 4]
s.pop(); // 4
s // [1, 2, 3]
// キュー
const q = [1, 2, 3];
q.unshift(4);
q; // [4, 1, 2, 3]
q.shift(); // 4
q; // [1, 2, 3]
// 未検証ですが、こんな感じでエミュレートできるはず(これも宿題で。。)
// 先頭を取り出す
// [first, ...rest] = [1, 2, 3]; // first = 1, rest = [2, 3]
// 末尾を取り出す(先頭部分もsliceで行けると思う)
// arr.slice(-1)[0]
// 先頭に追加
// [first, ...rest]
// 末尾に追加
// [...args, last]
まとめ
ES2015を使えば、短い表現でデータ変換ができることが伝わればいいなと思います。
基本的にはLodashとか便利なライブラリがあるので、こういうのを自作することはあまりないでしょう。なので、ES2015の構文をきちんと理解して使いこなすというところに注目してもらえるといいかなと思います。
あるいは、いろんなデータを扱いつつ、アルゴリズムも学べるので新入社員の教育等にいかがでしょうか。
また、30秒以内に解けた人から手を挙げるみたいな、社内勉強会で使うのも面白いかなとか。夢が広がりますね。
あと、手前味噌ですが自分のブログで、なぜES2015はこれだけ短く書けるかについてまとめましたので、そちらも見てもらえる役に立つと思います。では。