Ponz Dev Log

ソース派のシステムエンジニアの開発日記

JavaScriptの2次元配列の展開・重複削除・集約をLodashを簡潔に書く

2018年末に書いたSlack Botを少しリファクタリングした時の話。2次元配列と配列要素の重複削除ってよく使いそうで意外とベストなやり方ってよく分からないですよね。自分の場合は2次元配列は mapforeach、重複削除は Set オブジェクトで実装していましたが、後からぱっと見て処理が訳わからんコードになりがちです。JSって flatMap ってないので気合いで書いちゃう。

以前書いたTrello/Slack/OpenWhiskで作ったBotでも同じような処理を実装しなければいけなくて、やっぱり読みづらいコードが爆誕してしまいました。正直コード書いてから1週間経った今でさえ分からんw

ponzmild.hatenablog.com

さて、こんなダメコードを書いていてはいけないと解決策を模索したところ辿り着いたのがLodash

lodash.com

ライブラリではよく中の実装で使われている印象があり今更かよ感が出ていますが、Lodashで関数を組み合わせて実装してみると思った以上に読みやすくなったのでBefore/Afterで見比べながら書き残します。

解決策

入力データ(Trello APIを呼び出した時のレスポンス)は以下のようなJSONです。

[
  {
    id: "dfhajsdfhalsdfhasrber",
    name: "task1",
    labels: [
      { id: "aaa", name: "hoge" },
      { id: "bbb", name: "fuga" }
    ]
  },
  {
    id: "ruweqioryqweryqweyr",
    name: "task2",
    labels: [
      { id: "aaa", name: "hoge" },
      { id: "ccc", name: "piyo" }
    ]
  }
]

この関数のアウトプットとしては、{name: "hoge", count: n} というオブジェクトの配列にしたい。

改善前

まずは年末の働かない頭で書いた以下のコードを見て欲しい。

function summarizeLabels(cards) {

  // 二次元配列でラベルを取り出して展開し、シンプルな文字列のみの配列にする ... (1)
  let labelLists = [];
  cards.map(card => {
    card.labels.forEach(label => labelLists.push(label.name));
  });

  // 配列内のラベル一覧を重複削除で取り出す ... (2)
  const labelSet = new Set(labelLists);

  // ラベルごとの件数を配列とラベル一覧から導出 ... (3)
  let labelResults = [];
  for (let targetLabel of labelSet) {
    const labelCount =
      labelLists.filter(cardLabel => cardLabel == targetLabel).length || 0;
    labelResults.push({ name: targetLabel, count: labelCount });
  }

  return labelResults;
}

やっていることとしては、以下3つ。文字に起こすと長いですね。

  1. まず label プロパティを取り出すと配列の中に配列が入れ子になった2次元配列の形になる。これは扱いづらいから展開しておく(1)。
  2. 次にキーを取り出すために(1)の配列を重複削除してキーのリストを取り出す(2)。
  3. 最後に(2)のキーのそれぞれに対して(1)の中で合致するラベルの数をカウントしてオブジェクトに突っ込む(3)。

いやー、、、 よ゛み゛つ゛ら゛い゛よ゛お゛

正直後で直したくないコードが見事に爆誕です。理解しづらい理由としては、2次元配列を展開していることを中から外に向かって処理を追わないと理解できない、Setってなんだっけ、filter関数と突っ込む先の配列定義が離れすぎて何の配列か分からない。こんなところでしょうか。

改善後

Lodashで関数チェーンを作り上げて直感的に書くことを試みます。最終的なアウトプットに値を集計する関数のみ自分で作成し、これ以外はLodashで定義された関数を使います。

const _ = require("lodash");

/**
 * 名称ごとの出現数を集計する.
 * @param {Object} stat 集計値
 * @param {String} name 集計キー ... 配列内の個々の値が入る
 */
const gatherNames = (stat, name) => {
  if (_.isUndefined(stat[name])) {
    stat[name] = { name: name, count: 0 };
  }
  stat[name].count++;
  return stat;
};

function summarizeLabels(cards) {
  const labelResults = _.chain(cards)
    .flatMap(_.property("labels")) // ... (1)
    .map(label => label.name)
    .reduce(gatherNames, {}) // ... (2)/(3)
    .values()
    .value(); // ... これを呼び出して初めて上記の関数チェーンが実行される
  return labelResults;
}

以前よりもコードが美しくなった気がします! 改善前のコードで分かりづらかった2次元配列の展開は _.flatMap() で一発で書けます。Lodashのメソッドもmap, reduceといった比較的聞き覚えのある名称なので理解しやすい。for文回さずシンプルに書けるところもポイント高いですね。 一番読みやすくなっているポイントは、処理を関数を組み合わせて関数チェーンで表現できていることでしょうか。

Lodashくん、、、君はすごいライブラリだったのか。。。色んなライブラリで多用される理由も納得です。