🧪

Enum.frequencies/1でカウントされるものの種類を調べる

2022/02/22に公開

live in LiveViewJP#4

この記事は「【更に増枠】LiveView JP#4:Livebook始めよう+Fly.io/ngrokでモブプロ」中のLT中に執筆された記事です。
https://liveviewjp.connpass.com/event/237625/

元記事
https://zenn.dev/ito_shigeru/articles/4428e01641ab67

はじめに

『Elixir Enum Cheatsheet』を眺めてたら、Enum.frequencies/1 という関数が目に止まりました。
図を見た感じ、リストの中の同じ要素の数を数えてくれるようです。集計時に便利そうですね。

https://angelika.me/elixir-enum-cheatsheet/

ふと、この関数はどんな要素を同じものとしてカウントするかを疑問に感じたため調べてみました。

この記事のElixirのバージョンは、私の環境に合わせ、v1.12.2で統一しています。
執筆時点(2021/09/25)での最新バージョンはv1.12.3です。

ドキュメントを読む

https://hexdocs.pm/elixir/1.12.2/Enum.html#frequencies/1

Elixirの公式ドキュメントでは、

Returns a map with keys as unique elements of enumerable and values as the count of every element.

とだけあり、具体的にどういった要素がカウントされるかは書かれていませんでした。
ドキュメントの実装例では、BitString文字列も同じ要素として数えてくれるようです。

Enum.frequencies(~w{ant buffalo ant ant buffalo dingo})

%{"ant" => 3, "buffalo" => 2, "dingo" => 1}

実装を読む

https://github.com/elixir-lang/elixir/blob/v1.12.2/lib/elixir/lib/enum.ex#L1258-L1277

ElixirのEnum.frequnecies/1の実装はこのページに記載されています。
以下、実装部分を抜粋。

def frequencies(enumerable) do
  reduce(enumerable, %{}, fn key, acc ->
    case acc do
      %{^key => value} -> %{acc | key => value + 1}
      %{} -> Map.put(acc, key, 1)
    end
  end)
end

上記の実装は、

  1. リストの要素を先頭から1つずつ取り出して変数keyに格納
  2. アキュムレータ(変数acc、初期値は要素数0のMap)でcase文を呼び出し
  3. keyの値がaccのキーに存在(パターンマッチ)すれば、accをvalue+1して更新
  4. accにkeyが存在しなければ、アキュムレータにkeyを登録し値を1とする
    という流れになります。

つまり、Mapのキーに対してパターンがマッチするものを同じ要素としてカウントするようですね。

試してみる

色々なリストをEnum.frequencies/1に渡してElixirのパターンマッチを調べてみます。

データ型が異なるもの

Enum.frequencies([1, 1.0, '1', "1", :"1", :"1"])

%{1 => 1, 1.0 => 1, :"1" => 2, '1' => 1, "1" => 1}

当然ですが、データ型が異なる値はキャストされず厳密に異なる値として扱われるようです。
:'1':"1"が同じ値になるのは始めて知りました。

範囲表現

Enum.frequencies([1..10, 1..100, 1..5, 2..10, 2..100, 2..5, 1..10])

%{1..5 => 1, 1..10 => 2, 1..100 => 1, 2..5 => 1, 2..10 => 1, 2..100 => 1}

範囲表現はstartとendの両方が一致すればマッチするようです。

正規表現

Enum.frequencies([~r{[aiueo]}, ~r{[aiueo]}, ~r{[aiueo]}u])

%{~r/[aiueo]/ => 2, ~r/[aiueo]/u => 1}

正規表現はオプションの有無を区別するようです。

タプル

Enum.frequencies([{:a, :b, 1, 2}, {:a, :b, 1}, {:a, :b, 1, 2}, {:b, 1, 2}])

%{{:a, :b, 1} => 1, {:b, 1, 2} => 1, {:a, :b, 1, 2} => 2}

タプルは要素が完全一致するものだけがマッチするようです。

リスト

Enum.frequencies([[], [], [1, 2, 3], [3, 2, 1], [1, 2, 3], [2, 3, 1], [1, 2, 3, 4]])
%{[] => 2, [1, 2, 3] => 2, [1, 2, 3, 4] => 1, [2, 3, 1] => 1, [3, 2, 1] => 1}

%{[] => 2, [1, 2, 3] => 2, [1, 2, 3, 4] => 1, [2, 3, 1] => 1, [3, 2, 1] => 1}

リストも完全一致するものだけがマッチするようです。

マップ

Enum.frequencies([%{a: 1}, %{a: 2}, %{a: 1}, %{a: 1, b: 1}])

%{%{a: 1} => 2, %{a: 2} => 1, %{a: 1, b: 1} => 1}

マップもタプルやリストと同様。

バイナリ

Enum.frequencies([<<1, 2>>, <<1, 2>>, <<1, 2, 3>>, <<2, 3, 4>>, <<1>>])

%{<<1>> => 1, <<1, 2>> => 2, <<1, 2, 3>> => 1, <<2, 3, 4>> => 1}

バイナリについても、完全一致するものだけがマッチするようです。

無名関数

Enum.frequencies([&(&1 * &1), &(&1 * &1), &(&1 * &2), &(&1 * &2)])

%{
  #Function<44.65746770/1 in :erl_eval.expr/5> => 1,
  #Function<44.65746770/1 in :erl_eval.expr/5> => 1,
  &:erlang.*/2 => 2
}

無名関数に関しては、独自定義した関数は同じ定義でもマッチしないようです。
一方、既存関数が呼ばれるものは、同じ要素としてマッチするようです。

まとめ

  • Enum.frequencies/1はリストの中から同じ要素の数をカウントする
  • 同じ要素かどうかの判定にはパターンマッチが使われる
  • 範囲、タプル、リスト、マップ、バイナリは完全一致したものだけマッチする
  • 無名関数は独自関数はマッチせず、既存関数はマッチする

Discussion