田舎で並行処理の夢を見る

試したこと、需要がないかもしれないけど細々とアウトプットしてます

【サンプルコード多数有り】ElixirのEnumの基礎と実践的な使い方について

Enumとは何か

(すでにEnumについてご存知の方はこの章をすっ飛ばしてください)

公式ドキュメントより引用

Enum
Provides a set of algorithms to work with enumerables.
列挙型に対して使用可能なアルゴリズム

hexdocs.pm

Pythonなどでstring型の値に対して.replace().split()といった関数が呼び出せるようにEnumが提供するアルゴリズム(関数)は第1引数に列挙型(enumerables)を受け取り処理を実行します。

Python

s_val = "I am Elixir boy"
print(s_val.split(" "))

# ['I', 'am', 'Elixir', 'boy']

Elixir

# map: 第1引数に列挙型を受け取り、各要素に指定の操作を実行(ここでは要素を10倍に)
Enum.map([1,2,3], fn n -> n * 10 end) |> IO.inspect()

# [10, 20, 30]

列挙型には何があるのか

ElixirEnumの公式ドキュメントの一番上の所に書かれているものを引用しました。

In Elixir, an enumerable is any data type that implements the Enumerable protocol.
Elixirでは列挙型は列挙可能なプロトコルのいくつかのデータ型です。
Lists ([1, 2, 3]), Maps (%{foo: 1, bar: 2}) and Ranges (1..3) are common data types used as enumerables:
リスト、マップ、レンジが列挙可能型に該当します。

(翻訳については誤訳、お許しください)
hexdocs.pm

つまりは以下の3つのデータ型についてEnumは使用可能ということです。

  • リスト([1, 2, 3])
  • マップ(%{"a" => "apple"})
  • レンジ(1..100)
# list
Enum.map([1,2,3], fn n -> n * 10 end) |> IO.inspect()
# [10, 20, 30]

# map
Enum.map(%{"a" => 1, "b" => 2, "c" => 3}, fn {_, v} -> v * 10 end) |> IO.inspect()
# [10, 20, 30]

# range
Enum.map(1..3, fn n -> n * 10 end) |> IO.inspect()
# [10, 20, 30]

Enumの基本: map, filter, reduce について

ほとんどの繰り返し処理はmap, filter, reduceを組み合わせることで記述可能です。この3つの関数についてはPythonJavaScriptに実装されている関数なので馴染みのある方も多いのではないかと思います。

map

mapに関しては先ほどすでにサンプルのコードを紹介しましたが、改めて。mapは列挙型の各要素に対して、第2引数で指定した処理を適応します。戻り値は必ず同じ要素数を持つリストです。map(データ型), range(データ型)を第1引数にした場合にリストになっている事に先ほど気づかれたでしょうか。

data = Enum.map(1..10, fn n -> n end)

# 要素を10倍
Enum.map(data, fn n -> n * 10 end) |> IO.inspect()
# [10, 20, 30, 40, 50, 60, 70, 80, 90, 100]

# 偶数であればeven, 奇数であればoddに変換
Enum.map(data, fn n -> 
  if rem(n, 2) == 0 do
    "#{n} is even"
  else
    "#{n} is odd"
  end
end) |> IO.inspect()
# ["1 is odd", "2 is even", "3 is odd", "4 is even", "5 is odd", "6 is even", "7 is odd", "8 is even", "9 is odd", "10 is even"]

filter

filterは名前の通り、列挙型の要素を絞り込むためにあります。第2引数に条件式を与えることで、false判定となる要素を削ぎ落とし、新たなリストを作成します。戻り値は第1引数で渡された列挙型と要素数が異なるリストになっています。

data = Enum.map(1..10, fn n -> n end)

# 5より大きな値を除去
Enum.filter(data, fn n -> n < 5 end) |> IO.inspect()
# [1, 2, 3, 4]

# 偶数の値のみ抽出
Enum.filter(data, fn n -> rem(n, 2) == 0 end) |> IO.inspect()
# [2, 4, 6, 8, 10]

reduce

reduceに関しては奥が深い、やれることがいくらでもあるため、詳細は別の記事にまとめています。reduceの最大の特徴は受け取った列挙型を任意のデータ型に変換出来るという点です。リストを文字列に変換したり、合計値を算出したり、key, valueを用いてmap型に変換したりと、第1引数で受け取った列挙型がmapfilterとは異なり必ずしもリストが戻り値になりません。reduceの詳細は以下の記事を過去に書いていますのでご参照下さい。

www.okb-shelf.work

reduceは第2引数にアキュムレーターと呼ばれる戻り値の初期値となる値をセットします。

data = Enum.map(1..10, fn n -> n end)

# 要素の合計値を算出(戻り値はintegerなので0を指定)
Enum.reduce(data, 0, fn n, acc -> n + acc end) |> IO.inspect()
# 55

# key, valueを持つmap型に変換。keyはnの数値, valueは配列の要素を10倍した数値
# Map.put()を用いて新たなkey, valueのpairを追加する
Enum.reduce(data, %{}, fn n, acc -> 
  Map.put(acc, n, n * 10)
end) |> IO.inspect()

# %{
#   1 => 10, 2 => 20, 3 => 30, 4 => 40, 5 => 50,
#   6 => 60, 7 => 70, 8 => 80, 9 => 90, 10 => 100
# }

ちなみにreduceを使うことでmapfilterの実装も出来てしまいます。

map

data = Enum.map(1..10, fn n -> n end)

# reduceを用いて作成したmap関数
my_map = fn enum, func -> 
  Enum.reduce(enum, [], fn v, acc ->
    # 受け取った関数の戻り値をリストに結合
    acc ++ [func.(v)]
  end) 
end

# 作成した無名関数を実行(無名関数の実行 関数名.(引数))
my_map.(data, fn n -> n * 2 end) |> IO.inspect()

# [2, 4, 6, 8, 10, 12, 14, 16, 18, 20]

filter

data = Enum.map(1..10, fn n -> n end)

# reduceを用いて作成したfilter関数
my_filter = fn enum, func ->
  Enum.reduce(enum, [], fn v, acc -> 
    # 受け取った関数の結果がtrueの場合にリストに結合
    if func.(v) do
      acc ++ [v]
    else 
      acc
    end
  end)
end

# 作成した無名関数を実行(無名関数の実行 関数名.(引数))
my_filter.(data, fn n -> n < 5 end) |> IO.inspect()

# [1, 2, 3, 4]

Enumの基礎であるmap, filter, reduceについて触れたところでEnumを使って、もう少し実用的な処理で遊んでみましょう。 一連の処理にはパイプライン演算子という戻り値を次の実行関数の第1引数としてカリー化してくれる便利な演算子を使います。

# それぞれの関数の戻り値が次の関数の実行時に第1引数として渡される(めちゃくちゃ便利)
Enum.map(1..10, fn n -> n end)
|> Enum.filter(fn n -> n < 5 end)
|> Enum.reduce(0, fn n, acc -> acc + n end)
|> IO.inspect() 

Enumで遊ぼう編

2次元リストの要素を1つだけランダムに取得した後に先頭要素を取得する

使い所はともかく、Enumで遊ぶ基礎的な処理です。

# 2次元リストを作成
Enum.map(1..10, fn _ ->  
  Enum.map(1..10, fn m -> m end)
end)
|> Enum.map(fn lst -> Enum.random(lst) end) # リストの要素をランダムに1つ取得
|> Enum.at(0) # 先頭要素を取得
|> IO.puts()

# 5(実行するたびに違う結果になります)

この部分でリストinリストのデータを作成しています。

Enum.map(1..10, fn _ ->  
  Enum.map(1..10, fn m -> m end)
end)

# [
#   [1, 2, 3, 4, 5, 6, 7, 8, 9, 10],
#   [1, 2, 3, 4, 5, 6, 7, 8, 9, 10],
#   :
#   :
#   [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
# ]

Enum.random()はランダムに値を1つ習得する関数です。map型の場合にはkey, valueのセットのまま取得されます。Enum.at()に関しては多言語でいうところのindex番号を指定して要素から値を取得する関数に該当するものです。

リストinマップのデータからB'zの曲を取り出してtitleとreleaseを「:」で結合して返す

lst_data = [
  %{title: "熱き鼓動の果て", singer: "B'z", released: 2002},
  %{title: "ワインレッドの心", singer: "安全地帯", released: 1984},
  %{title: "ZERO", singer: "B'z", released: 1992},
  %{title: "calling", singer: "B'z", released: 1997},
  %{title: "さまよえる蒼い弾丸", singer: "B'z", released: 1998},
  %{title: "あの頃へ", singer: "安全地帯", released: 1992},
  %{title: "TIME", singer: "B'z", released: 2002},
  %{title: "じれったい", singer: "安全地帯", released: 1988},
  %{title: "love me, I love you", singer: "B'z", released: 1995},
  %{title: "悲しみにさよなら", singer: "安全地帯", released: 1985},
  %{title: "CHAMP", singer: "B'z", released: 2017},
  %{title: "HOME", singer: "B'z", released: 1998},
]

# 一連の処理をパイプライン化
lst_data
|> Enum.filter(fn %{singer: singer} ->
    # 歌手情報がB'zのrecordのみ抽出
    singer === "B'z"
    end
  )
|> Enum.map(fn %{title: title, released: year} -> 
    # 抽出したデータを文字列に変換
    "#{year}: #{title}"
    end
  )
|> Enum.each(fn v -> IO.puts(v) end) # 結果を出力

実行結果

2002: 熱き鼓動の果て
1992: ZERO
1997: calling
1998: さまよえる蒼い弾丸
2002: TIME
1995: love me, I love you
2017: CHAMP
1998: HOME

僕はB'zと安全地帯が好きです。まぁ、その話は置いておいて...

Enum.each()は各要素に対して指定の処理を実行するmapに似たものですが、戻り値がリストではなく、:okというアトム型が戻り値になります。今回の場合は文字列を出力して終了するのが目的であっため、戻り値がリストであるmapを使わずにEnum.each()を仕様しています。

このような処理はAPIの戻り値や、http request経由で何らかの情報がリスト内にオブジェクトで送られてくるようなケース、データーベースから取得したレコードデータに対して処理を適応したい場合など実務に沿ったものをイメージして作成してみました。一連の処理を気軽にパイプラインとして実行することが可能なのがElixirの強力な所です。

リストを指定要素ごとに分割して合計をそれぞれ算出する

並行処理をする場合に近いことをやります。実際に合計を算出させるのは別プロセスであることが多いですが、今回は簡略化のため、同じプロセスの中でやってしまいます。ついでなので最小値と最大値の取得までやっていますが、Elixirのスタイルガイド的にはパイプライン演算子で無名関数を使うのはNGなので今回はパイプラインで一括で処理できるように止むなしとしました。

# 1 ~ 10000までの要素を持つリストを作成
Enum.map(1..10000, fn n -> n end)
|> Enum.chunk_every(100) # 要素を100ずつ分解して2次元リストに
|> Enum.map(fn lst -> Enum.sum(lst) end) # それぞれのリストの合計値を算出
|> (fn lst -> {Enum.min(lst), Enum.max(lst)} end).() # 最小値と最大値を取得
|> IO.inspect()

# {5050, 995050}

Enum.chunk_every()は列挙型のデータを指定の要素数ごとに分解してくれます。オプションを細かく設定することが出来るのですが、今回は割愛します。

Enum.map(1..10, fn n -> n end)
|> Enum.chunk_every(2)
|> IO.inspect()

# [[1, 2], [3, 4], [5, 6], [7, 8], [9, 10]]

Enum.min()Enum.max()に関しては見ての通りというか、想像の通りの結果が返ってきます。

data = Enum.map(1..10, fn n -> n end)
IO.puts(Enum.min(data)) # 最小値
IO.puts(Enum.max(data)) # 最大値

# 1
# 10

指定数だけプロセスを立ち上げてメッセージを送信。受信した値を合計する

最後に少しElixirに寄せた処理をEnumを使って実装してみました。実際はTaskモジュールなどを使えばもっと、シンプルに記述することが出来るのですが、せっかくなのでEnumとプロセス同士のmessage passing(メッセージパッシング )を用いて、起動した各プロセスから実行結果を受け取り、メインのプロセスにて合計してみます。

# 送信元のプロセスのid
sender = self()

# 受信用のプロセスを起動(10回メッセージを受信する)
receiver = spawn(fn -> 
  nums = Enum.map(1..10, fn _ -> 
    receive do
      {:ok, num} -> num
      _ -> 0
    end
  end)
  # 10回メッセージを受け取った後に立ち上げたプロセスに返す
  send(sender, {:ok, nums})
end)

# プロセスを10個立ち上げる -> プロセスのidの一覧をリストに保持
Enum.map(1..10, fn n -> 
  # 立ちあげたプロセス内で受け取った値を10倍
  spawn(fn -> 
    res = n * 10
    # 受信用のプロセスに10倍した値を送信
    send(receiver, {:ok, res})
  end)
end)

# 受信用のプロセスが受け取った値を受け取る
# うまくいっていれば[10, 20, ...100]というリストが送られてくる
receive do
  # 集計値を受け取り、合計値を算出
  {:ok, nums} -> Enum.sum(nums) |> IO.puts()
  _ -> 0
end

# 550

こんなところでしょうか。パイプライン演算子を使ってゴリ押ししようかと迷いましたが、可読性が著しく低下するのでやめました。Elixirで並行処理をする際にはEnum.map(1..10, spawn(fnuction))というような記述が立ち上げるプロセス数も指定出来て便利です。今回はプロセス数は10個で固定ですが、引数経由で受け取るようにすれば拡張性は増すでしょう。

最後に

Enumは非常に便利で、Elixirを記述する内の半数程度ではEnumを記述している気がします。適した関数を選択して、うまく処理することが出来れば、ほとんどの処理はEnumパイプライン演算子のコンボで解決することが可能です。
ただ、時には「Enumで上手く書けない!」と思い詰める時もあるかと思います。そんな時は再帰関数を使ってみると上手く記述することが出来るかもしれません。例えばEnum.reduce()ではアキュムレーターの値を1つしか保持させることが出来ませんが、任意で作成した再帰関数であれば引数の数はいくらでも拡張が出来るので複数のアキュムレーターを用意することが出来ます。

今回、扱った内容の多くはデファクトスタンダードの一冊である「プログラミングElixir」にて学ぶことが出来ます。第5, 6, 7, 10が参考になるかと思います。

プログラミングElixir

プログラミングElixir

  • 作者:Dave Thomas
  • 発売日: 2016/08/19
  • メディア: 単行本(ソフトカバー)

参考文献