🦕

Deno標準ライブラリ0.104.0で増強されたcollectionsの紹介

2021/08/14に公開

先日、Deno標準ライブラリのバージョン0.104.0が公開されました。

https://github.com/denoland/deno_std/releases/tag/0.104.0

こちらで増強されたcollectionsというモジュールを紹介します。

https://deno.land/std@0.104.0/collections

その名の通り、集合の扱いを支援してくれるモジュールです。

本記事では0.104.0のリリースで追加された関数を紹介します。
既存のものはバージョン0.102.0の時点での記事で解説していますので、あわせてご覧ください。

https://zenn.dev/kawarimidoll/articles/7d1fc9f0fb6538

associateBy

配列と関数を引数に取り、関数で変換された値をキーとするレコードを返します。
キーが重複する場合はあとに来たもので上書きされます。

const fruits = [
  { id: "f001", name: "apple", count: 21 },
  { id: "f002", name: "banana", count: 20 },
  { id: "f003", name: "cherry", count: 84 },
  { id: "f004", name: "kiwi", count: 34 },
  { id: "f003", name: "coconut", count: 12 },
];
assertEquals(
  associateBy(fruits, (fruit) => fruit.id),
  {
    f001: { id: "f001", name: "apple", count: 21 },
    f002: { id: "f002", name: "banana", count: 20 },
    f003: { id: "f003", name: "coconut", count: 12 },
    f004: { id: "f004", name: "kiwi", count: 34 },
  },
);

「データベースから取得した値は配列だけど、IDをキーとしたレコードにしたほうがスクリプトで扱いやすい」みたいな場合に便利そうです。
キーが重複した場合はあとのもので上書きされるため、distinctByのように重複要素を削除する作用に着目した使い方もできるかもしれませんね。

deepMerge

2つのレコードを引数に取り、ネストされた部分まで再帰的にマージします。
キーが重複する場合は、2つ目のものが優先されます。

const r1 = {
  name: "foo",
  version: 2,
  nest: {
    array: [1, 2],
  },
};
const r2 = {
  name: "bar",
  nest: {
    deep: {
      nested: true,
    },
    array: [2, 3],
  },
};

assertEquals(
  deepMerge(r1, r2),
  {
    name: "bar",
    version: 2,
    nest: {
      deep: {
        nested: true,
      },
      array: [1, 2, 2, 3],
    },
  },
);

ユースケースとして、「第一引数にデフォルト値、第二引数にユーザー設定値を渡して、ユーザーの設定していない部分のみ良い感じにデフォルト値を適用させる」といったものを思いつきましたが、いろいろなところで使えそうではあります。

また、値が配列・マップ・セットの場合、それらを置き換えるかマージするかをオプションで選択できます。

const r1 = {
  name: "foo",
  version: 2,
  array: [1, 2],
};
const r2 = {
  name: "bar",
  array: [2, 3],
};

assertEquals(
  deepMerge(r1, r2, { arrays: "replace" }),
  {
    name: "bar",
    version: 2,
    array: [2, 3],
  },
);
ネストしてるとオプションが機能しない?

このarrays: "replace"の指定ですが、ネストしている値には機能しないようです。バグなのか仕様なのかちょっと微妙なライン…でもdeepMergeを名乗っている以上、再帰的に同じ処理をしてほしい気がします。

const r1 = {
  name: "foo",
  version: 2,
  nest: {
    array: [1, 2],
  },
};
const r2 = {
  name: "bar",
  nest: {
    deep: {
      nested: true,
    },
    array: [2, 3],
  },
};

assertEquals(
  deepMerge(r1, r2, { arrays: "replace" }),
  {
    name: "bar",
    version: 2,
    nest: {
      deep: {
        nested: true,
      },
      array: [2, 3],
    },
  },
);

// error: Uncaught AssertionError: Values are not equal:
//
//   [Diff] Actual / Expected
//
// {
//   name: "bar",
//   nest: {
//   array: [
// -   1,
// -   2,
//     2,
//     3,
//   ],
//     deep: {
//       nested: true,
//     },
//   },
//   version: 2,
// }
//
// throw new AssertionError(message);

なお、READMEでは

Use includeNonEnumerable option to include non enumerable properties too.

と記載があるのですが、この変数はコード内に見当たりませんでした。

この関数は再帰処理が入っていることもあり、collectionsの中ではかなり重量級の関数です。自分もコードリーディングが十分できていないので、理解が進んだらまた記事を書きたいと思います。

明らかにこれだけ容量がでかい

findLastIndex

配列と関数を引数に取り、与えられた述語関数を満たす最後の要素のインデックスを返します。

const fruits = [
  { name: "apple", count: 21 },
  { name: "banana", count: 20 },
  { name: "cherry", count: 84 },
  { name: "kiwi", count: 34 },
];
assertEquals(
  findLastIndex(fruits, (fruit) => fruit.count % 7 === 0),
  2,
);

見つからない場合は-1が返ります。

追記: バージョン0.106.0にて、見つからない場合にundefinedが返るように変更されました。

実はこれ、前回の記事で「findLastを追加したのだからfindLastIndexがあってもいいのでは?」と書いていました。

どうやら以下のissueで提案されて実装に至ったみたいですね。提案したユーザー、ありがとう!

https://github.com/denoland/deno_std/issues/1060

(なんちゃって 茶番失礼しました〜)

mapNotNullish

配列と関数を引数に取り、述語関数の返り値がnullishにならないものを抽出します。

const fruits = [
  { name: "apple", count: 21 },
  { name: null, count: 84 },
  { name: "banana", count: 20 },
  { name: undefined, count: 34 },
  { name: "coconut", count: 12 },
];

assertEquals(
  mapNotNullish(fruits, (fruit) => fruit.name),
  ["apple", "banana", "coconut"],
);

// same effect
assertEquals(
  fruits.map((fruit) => fruit.name).filter((fruit) => fruit != null),
  ["apple", "banana", "coconut"],
);

上記の通り、Array.prototype.map()Array.prototype.filter()の組み合わせでも同様の結果が得られますが、これを一撃でやれるのはなかなか良さそうですね。

sortBy

これは何をやるのかが名前でわかりますね。配列と関数を引数に取り、受け取った関数の返り値でソートします。

const fruits = [
  { name: "apple", count: 21 },
  { name: "banana", count: 20 },
  { name: "cherry", count: 84 },
  { name: "kiwi", count: 34 },
  { name: "peach", count: 12 },
];
assertEquals(
  sortBy(fruits, (fruit) => fruit.count),
  [
    { name: "peach", count: 12 },
    { name: "banana", count: 20 },
    { name: "apple", count: 21 },
    { name: "kiwi", count: 34 },
    { name: "cherry", count: 84 },
  ],
);

Array.prototype.sort()との違いは以下のような点です。

  • sort()は対象の配列を破壊的に変更するが、sortBy()は新しい配列を返す
  • sort()は引数の関数の返り値の正負によってソートするが、sortBy()は返り値が昇順になるようにソートする

実装を見ると、引数として受け取った配列をコピーしてsort()しているようです。
既存のsort()の能力を使いつつ関数型っぽく変換する、うまい実装だと思いました。

union,unzip,zipの公開

これらはバージョン0.102.0の時点ではモジュール内にあるのにmod.tsにない状態だったのですが、今回のリリースでmod.tsに含まれました。READMEにも説明が追加されています。

またしても隠し要素…?

今回もREADMEに載ってない関数がありました。mod.tsには入っているので、単に説明の追加忘れだと思われます。

sumOf

これも名前でわかりやすいやつですね。配列と関数を引数に取り、関数の結果を合計します。

const fruits = [
  { name: "apple", count: 21 },
  { name: "banana", count: 20 },
  { name: "cherry", count: 84 },
  { name: "kiwi", count: 34 },
  { name: "peach", count: 12 },
];
assertEquals(
  sumOf(fruits, (fruit) => fruit.count),
  171,
);

シンプルですが、使い所は多そうです。

おわりに

関数以外に、READMEに少し説明が追加されました。

This module includes pure functions for specific common tasks around collection types like Array and Record. This module is heavily inspired by kotlins stdlib.

  • All provided functions are pure, which also means that hey do not mutate your inputs, returning a new value instead.
  • All functions are importable on their own by referencing their snake_case named file (e.g. collections/sort_by.ts)

If you want to contribute or undestand why this is done the way it is, see the contribution guide.

ここにあるように、collections専用のCONTRIBUTING.mdも追加する気合の入れようです。
これはコントリビューションチャンス…!
今後もこのモジュールは増強されそうなので、更新され次第、記事にしたいと考えています。

Discussion