🦕

Deno標準ライブラリ0.102.0で追加されたcollectionsの紹介

2021/07/24に公開

先日、Deno標準ライブラリのバージョン0.102.0が公開されました。
https://github.com/denoland/deno_std/releases/tag/0.102.0

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

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

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

なお、READMEに載っているサンプルコードは記述にミスがあるのでそのままでは動作しません。
PRが出ているのでそのうち解決されると思います。
https://github.com/denoland/deno_std/pull/1046

本記事では先にRecord用のものを紹介し、あとからArray用のものを紹介します。
また、紹介順序も辞書順ではなく、説明をしやすいように調整しています。
したがって公式ページのREADMEとは関数説明の順番が揃っていませんのでご注意ください。

filterRecord系

Array.prototype.filter()のような操作をRecordに対して行えます。
なかなか便利そうです。

filterKeys

keyでフィルタリングします。

const fruits = {
  apple: 25,
  banana: 20,
  cherry: 84,
  kiwi: 34,
  lemon: 56,
  melon: 90,
  orange: 42,
  peach: 87,
};

assertEquals(
  filterKeys(fruits, (key) => key.length === 6),
  {
    banana: 20,
    cherry: 84,
    orange: 42,
  },
);

filterValues

valueでフィルタリングします。

const fruits = {
  apple: 25,
  banana: 20,
  cherry: 84,
  kiwi: 34,
  lemon: 56,
  melon: 90,
  orange: 42,
  peach: 87,
};

assertEquals(
  filterValues(fruits, (value) => value > 50),
  {
    cherry: 84,
    lemon: 56,
    melon: 90,
    peach: 87,
  },
);

filterEntries

[key, value]のペアでフィルタリングします。

const fruits = {
  apple: 25,
  banana: 20,
  cherry: 84,
  kiwi: 34,
  lemon: 56,
  melon: 90,
  orange: 42,
  peach: 87,
};

assertEquals(
  filterEntries(fruits, ([key, value]) => key.length === 6 && value > 50),
  { cherry: 84 },
);

filterKeysfilterValuesと比べると出番が少ないかも…?

mapRecord系

Array.prototype.map()のような操作をRecordに対して行えます。

mapKeys

keyに対して処理を行います。

const fruits = {
  apple: 25,
  banana: 20,
  cherry: 84,
  kiwi: 34,
};

assertEquals(
  mapKeys(fruits, (key) => "my_" + key),
  {
    my_apple: 25,
    my_banana: 20,
    my_cherry: 84,
    my_kiwi: 34,
  },
);

これはちょっと活用が難しいかな…?キーを書き換えたいときってパッとイメージできませんね。

mapValues

valueに対して処理を行います。

const fruits = {
  apple: 25,
  banana: 20,
  cherry: 84,
  kiwi: 34,
};

assertEquals(
  mapValues(fruits, (value) => value += 15),
  {
    apple: 40,
    banana: 35,
    cherry: 99,
    kiwi: 49,
  },
);

これは活躍の機会が多そうです。

mapEntries

[key, value]のペアに対して処理を行います。
第二引数の関数は第一要素を文字列とした配列で返す必要があります。

const fruits = {
  apple: 25,
  banana: 20,
  cherry: 84,
  kiwi: 34,
};

assertEquals(
  mapEntries(fruits, ([key, value]) => [`${value}`, key]),
  {
    25: "apple",
    20: "banana",
    84: "cherry",
    34: "kiwi",
  },
);

こんな感じで「キーとバリューを入れ替える」ことも簡単にできます。
これは便利そう。

chunked

配列を任意の個数ごとにまとめられた二次元配列に変換します。
元の配列の要素数が与えられた個数で割り切れない場合、最後の個数が揃わなくなります。この場合もエラーは出さずその状態で返却されます。

RubyのEnumerable#each_sliceみたいな感じですね。

const fruits = [
  "apple",
  "banana",
  "cherry",
  "kiwi",
  "lemon",
  "melon",
  "orange",
  "peach",
];

assertEquals(chunked(fruits, 2), [
  ["apple", "banana"],
  ["cherry", "kiwi"],
  ["lemon", "melon"],
  ["orange", "peach"],
]);
assertEquals(chunked(fruits, 3), [
  ["apple", "banana", "cherry"],
  ["kiwi", "lemon", "melon"],
  ["orange", "peach"],
]);

distinct

配列から重複する要素を取り除きます。
こちらもRubyの例を出すと、Enumerable#uniqのような感じでしょうか。

重複するものは先に出たものが残り、後のものが取り除かれます。

const numbers = [3, 2, 5, 2, 5];
assertEquals(distinct(numbers), [3, 2, 5]);

内部的には入力された配列をSetにして、それをArray.from()しているようです。
したがって、要素が配列などの場合は重複除去は行われません。

const fruits = [
  ["apple", "banana"],
  ["apple", "cherry"],
  ["apple", "banana"],
];

assertEquals(
  distinct(fruits),
  [
    ["apple", "banana"],
    ["apple", "cherry"],
  ],
);
// error: Uncaught AssertionError: Values are not equal:
//   [Diff] Actual / Expected
// [
//   [
//     "apple",
//     "banana",
//   ],
//   [
//     "apple",
//     "cherry",
//   ],
// - [
// -   "apple",
// -   "banana",
// - ],
// ]

distinctBy

distinctの要素比較方法を自分で設定できる、より一般的なバージョンです。
第2引数として受け取った関数の返り値を一意にする配列を返します。

例えば、文字数が一通りになるように単語を抽出するとか(使い所は不明)。

const fruits = [
  "apple",
  "banana",
  "cherry",
  "kiwi",
  "lemon",
  "melon",
  "orange",
  "peach",
];

assertEquals(
  distinctBy(fruits, (fruit) => fruit.length),
  ["apple", "banana", "kiwi"],
);

distinctとは違い、各要素が配列等の場合も対応可能です。
下記でJSON.stringifyでの比較が適切かはさておき、distinctとの違いを見てください。

const fruits = [
  ["apple", "banana"],
  ["apple", "cherry"],
  ["apple", "banana"],
];
assertEquals(
  distinctBy(fruits, (fruits) => JSON.stringify(fruits)),
  [
    ["apple", "banana"],
    ["apple", "cherry"],
  ],
);

設計に関する個人的な意見としては、自分だったらdistinctにオプショナル引数を追加する形で一緒にしてしまうかなあ…と思いました。

findLast

Array.prototype.find()と似ていますが、述語関数を満たす最後の要素を返します。

const fruits = [
  { name: "apple", count: 25 },
  { name: "banana", count: 20 },
  { name: "cherry", count: 84 },
  { name: "kiwi", count: 34 },
];
assertEquals(
  findLast(fruits, (fruit) => fruit.count < 50),
  { name: "kiwi", count: 34 },
);

Array.prototype.findIndex()はあるのでfindLastIndexがあっても良さそうですが、今のところは無いみたいですね[1]

groupBy

配列を第二引数(セレクター関数)の結果に応じてグループ分けします。
日本語だとちょっと説明が難しいので、具体的なコードを示します。

const fruits = [
  { name: "apple", count: 25 },
  { name: "banana", count: 20 },
  { name: "cherry", count: 84 },
  { name: "kiwi", count: 34 },
];
assertEquals(
  groupBy(fruits, (fruit) => `${fruit.count % 3}`),
  {
    "0": [
      { name: "cherry", count: 84 },
    ],
    "1": [
      { name: "apple", count: 25 },
      { name: "kiwi", count: 34 },
    ],
    "2": [
      { name: "banana", count: 20 },
    ],
  },
);

ここでは、fruit.count % 3の結果で要素をグループ分けしています。
なお、結果がRecordになる関係上、セレクター関数の返り値は文字列となる必要があります。

intersect

入力された配列の共通部分(全てに存在する要素)の配列を返します。

const fruits1 = ["apple", "banana", "cherry"];
const fruits2 = ["cherry", "kiwi", "peach"];
const fruits3 = ["apple", "cherry", "peach"];

assertEquals(
  intersect(fruits1, fruits2, fruits3),
  ["cherry"],
);

partition

渡された術後関数に基づいて配列から要素を抽出します。
Array.prototype.filter()と異なる点は、「条件を満たす要素」と「条件を満たさない要素」のペアとして返してくれるところです。

const fruits = [
  { name: "apple", count: 25 },
  { name: "banana", count: 20 },
  { name: "cherry", count: 84 },
  { name: "kiwi", count: 34 },
];
assertEquals(
  partition(fruits, (fruit) => fruit.count > 50),
  [
    [
      { name: "cherry", count: 84 },
    ],
    [
      { name: "apple", count: 25 },
      { name: "banana", count: 20 },
      { name: "kiwi", count: 34 },
    ],
  ],
);

返り値の第一要素が条件を満たしたもの、第二要素が条件を満たさなかったものとなります。

permutations

与えられた配列の要素で作られる順列を返します。

const fruits = [
  "apple",
  "banana",
  "cherry",
];
assertEquals(
  permutations(fruits),
  [
    ["apple", "banana", "cherry"],
    ["banana", "apple", "cherry"],
    ["cherry", "apple", "banana"],
    ["apple", "cherry", "banana"],
    ["banana", "cherry", "apple"],
    ["cherry", "banana", "apple"],
  ],
);

現時点では「n個の要素を持つ配列からk個選ぶ」といったことはできず、必ずすべての要素が使われるようです。つまりn個の要素を持つ配列を渡すと長さがnの階乗である配列が返ってきます。
ここは抽出個数を選べるとありがたいので、機能追加を期待したいところです[2]

隠し要素?

現バージョン(0.102.0)では、公開されているのにREADMEに載っておらず、mod.ts内でもexportされていない項目が存在します。

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

未だ正式リリースできないという判断なのか、それとも単に忘れられているのかは不明です。
mod.tsから読み込むことはできませんが、以下のように直接参照すれば使用できます。

import { union } from "https://deno.land/std@0.102.0/collections/union.ts";

union

複数の配列をまとめ、重複した要素を取り除きます。

const fruits1 = ["apple", "banana", "cherry"];
const fruits2 = ["cherry", "kiwi", "peach"];
const fruits3 = ["apple", "cherry", "peach"];

assertEquals(
  union(fruits1, fruits2, fruits3),
  ["apple", "banana", "cherry", "kiwi", "peach"],
);

なお、「配列をまとめて重複を除去する」なので、前述のdistinctを使っても書くことができます。

assertEquals(
  distinct([...fruits1, ...fruits2, ...fruits3]),
  union(fruits1, fruits2, fruits3),
);

distinctを使うよりはunionを使ったほうが簡潔です。
ただ、重複条件を自分で定義したい場合はdistinctByを採用する場合もあるかもしれません。

zip

2つの配列を引数に取り、「それらの同一インデックスの要素の組」の配列を返します。

RubyのEnumerable#zipと類似していますが、Rubyのそれと違い、受け取れる配列は2つのみです。
配列の長さが異なる場合、最も小さいサイズのものに合わせられます。言い方を変えると、はみ出したぶんは考慮されません。

const fruitNames = ["apple", "banana", "cherry", "kiwi"];
const fruitCounts = [25, 20, 84, 34, 999];

assertEquals(
  zip(fruitNames, fruitCounts),
  [
    ["apple", 25],
    ["banana", 20],
    ["cherry", 84],
    ["kiwi", 34],
  ],
);

unzip

上記のzipの逆関数です。タプルの配列を各要素の配列に分解します。

const fruits: [string, number][] = [
  ["apple", 25],
  ["banana", 20],
  ["cherry", 84],
  ["kiwi", 34],
];

assertEquals(
  unzip(fruits),
  [
    ["apple", "banana", "cherry", "kiwi"],
    [25, 20, 84, 34],
  ],
);

上記のfruitsは、Typescriptの型推論で(string | number)[][]と推定されます(内部のペアの型の順序・要素数までは指定されません)が、これではunzipで扱うことができません。
各要素が2要素の配列で、それらの型も決まっていることを明示する必要があります。

おわりに

いくつかRubyの例を出しましたが、「他の言語ならこの処理を簡単に書けるのにな…」というものをまとめてくれた印象です。
このモジュールを活用することで、コード内から中間変数やループを減らすことができ、見通しの向上に繋がりそうです。関数型っぽく書ける場合も増えそうですね。
chunkedなどは同じようなものを自作して使った経験があるのですが、Deno公式で提供してくれるのはありがたいです。

脚注
  1. これはOSS…言い出しっぺの法則…! ↩︎

  2. これはOSS…言い出しっぺの法則…! ↩︎

Discussion