loading...
gumi TECH Blog

Elixir入門 10: EnumとStream

gumitech profile image gumi TECH Updated on ・4 min read

本稿はElixir公式サイトの許諾を得て「Enumerables and Streams」の解説にもとづき、加筆補正を加えてElixirのモジュールEnumStreamについてご説明します。

Enum

Elixirは列挙型(enumerable)の考え方にもとづくEnumモジュールを備えています。Enum.map/2関数を使うと、第1引数のリスト要素に第2引数の関数で処理を加え、その戻り値の要素を納めた新たなリストが得られます。

iex> Enum.map([1, 2, 3], fn x -> x * x end)
[1, 4, 9]
iex> Enum.map(%{2 => :one, 4 => :two}, fn({k, v}) -> {v, div(k, 2)} end)
[one: 1, two: 2]

Enumモジュールには多くの関数があり、変換や並べ替え、グループ化、フィルタ、項目の取り出しなどに用いられます。Elixirの開発者にもっとも使われるモジュールのひとつです。Enumの関数はさまざまな列挙可能なデータ型に使える多態性をもちます。扱うデータはEnumerableプロトコルを実装していればよいのです。

連番整数のデータは../2でも表せます。Enum.reduce/3は、各データを処理してひとつの値にして返す関数です。第1引数のデータを第3引数の関数で処理し、その戻り値がつぎのデータのコールバックに渡されます。第2引数は最初のデータのコールバックに渡される初期値です。

iex> Enum.map(1..3, &(&1 * &1))
[1, 4, 9]
iex> Enum.reduce(1..3, 0, &+/2)
6

なお、&+/2+/2&演算子でキャプチャしています(「Elixir入門 08: モジュールと関数」「関数のキャプチャ」参照)。

Enumモジュールの関数の仕事は、データ構造の要素をすべて取り出して処理すること(列挙)にかぎられます。要素を差し込んだり、値を書き替えるには、そのデータ型にもとづく操作が必要です。たとえばリストのインデックスに値を差し込むのなら、ListモジュールのList.insert_at/3をお使いください。

パイプ演算子

パイプ演算子|>は左オペランドの値を左オペランドの第1引数に渡します。なお、List.flatten/1は、引数に渡したリスト内の入れ子を平坦化する関数です。

iex> [1, [[2], 3]] |> List.flatten
[1, 2, 3]

関数の戻り値をつぎの関数の引数に渡す処理が重なると、関数が入れ子になります。

iex> Enum.reduce(Enum.map(1..3, fn x -> x * x end), 0, fn(x, acc) -> x + acc end )
14

|>演算子はこうした場合に、左の出力を右に入力するという見やすい書き方にできるのです。つぎの例ではキャプチャ演算子&も併せて使いました。

iex> 1..3 |> Enum.map(&(&1 * &1)) |> Enum.reduce(0, &+/2)
14

Enum.reduce/3は使い道の広い関数です。けれど、ただすべての要素を足せばよいときはEnum.sum/1が使えます。

iex> 1..3 |> Enum.map(&(&1 * &1)) |> Enum.sum
14

なお、パイプ演算子|>を使うとき、関数に第2引数以降がある場合には、引数をかっこ()でかこんでください。たとえば、List.flatten/2は第2引数のリストをつないだうえで平坦化します。このとき()を省くと、加えるように警告が示されます。コードに誤解を招きやすくなるからです。

iex> [1, [[2], 3]] |> List.flatten [4, [5]]
warning: parentheses are required when piping into a function call. For example:

    foo 1 |> bar 2 |> baz 3

is ambiguous and should be written as

    foo(1) |> bar(2) |> baz(3)

Ambiguous pipe found at:
  iex:

[1, 2, 3, 4, [5]]

先行と遅延

Enumモジュールの関数はすべて先行処理です。たとえば、上のコードはつぎの処理と同じく、ひとつ目の関数が直ちに戻り値のリストを返し、そのデータからふたつ目の関数が結果のリストをつくります。つまり、関数の呼び出しがいくつも重なると、その数だけ途中経過のリストができるのです。さらにデータの要素数が膨大になれば、その負荷を考えなければならないかもしれません。

iex> square = Enum.map(1..3, &(&1 * &1))
[1, 4, 9]
iex> sum = Enum.sum(square)
14

Streamモジュールは、Enumと同じようにEnumerableプロトコルのデータを扱う関数が備わり、しかも遅延処理です。つまり、処理を求められるまで、データの取り出し(列挙)は行いません。

Stream

Streamは、複数の関数が組み合わせられる遅延処理の列挙型です。Streamは、列挙の処理がいくつ続いても、データから要素をひとつずつ取り出します。つまり、途中経過のリストはつくりません。

たとえば、../2で定めた連番整数はStreamです。Streamに対してEnumの関数を呼び出すと、データは遅延実行されます。なお、Stream.filter/2は、第2引数のコールバックがtrueを返す要素だけ取り出します。

iex> range = 1..6
1..6
iex> odd = Stream.filter(range, &(rem(&1, 2) != 0))
#Stream<[enum: 1..6, funs: [#Function<40.58052446/1 in Stream.filter/2>]]>
iex> square = Stream.map(odd, &(&1 * &1))
#Stream<[
  enum: 1..6,
  funs: [#Function<40.58052446/1 in Stream.filter/2>,
   #Function<48.58052446/1 in Stream.map/2>]
]>
iex> sum = Enum.reduce(square, 0, &(&1 + &2))
35

Streamは、途中経過のリストはつくらず、列挙処理の手順を表します。そして、Enumモジュールの関数に渡されたとき、もとのデータから項目をひとつずつ取り出すのです。Streamは膨大なデータを扱うとき有効です。無限のデータでもつくれます。

Streamモジュールは、Enumerableプロトコルのデータを扱う多くの関数が備わり、戻り値はStreamです。また、指定にしたがったStreamをつくる関数もあります。たとえば、Stream.cycle/1は引数の列挙型データから無限のStreamを返す関数です。Enumの列挙する関数に渡すと、処理が終わらなくなるので気をつけましょう。Enum.take/2は、引数の数だけ取り出して処理します。

iex> [1, 2, 3] |> Stream.cycle |> Enum.take(10)
[1, 2, 3, 1, 2, 3, 1, 2, 3, 1]

Stream.unfold/2の処理の流れは、Enum.reduce/3と似ています。ただ、コールバック間で受け渡すデータがタプル{現在値, 集計値}のかたちをとるのです。そして、第1引数は初期値になります。

数学の数列で第1項と第2項を決めて、それ以降の数を求める考え方です。コールバックに終了のための処理がないかぎり無限のStreamになります。たとえば、つぎのコードはフィボナッチ数列から10項を取り出してリストで返します。

iex> fibs = Stream.unfold({1, 1}, fn({n0, n1}) -> {n0, {n1, n0 + n1}} end)
#Function<64.58052446/2 in Stream.unfold/2>
iex> Enum.take(fibs, 10)
[1, 1, 2, 3, 5, 8, 13, 21, 34, 55]

File.stream!/3は、引数のパスから読み込んだファイルをStream(File.stream)にします。ストリーミングをはじめると、ファイルはElixirが自動的に開きます。そして、読み込み終わるか、失敗すると閉じられるのです。読み込まれたファイルが処理されると、行単位で要素に分けられたリストになります。

iex> stream = File.stream!("test.txt")
%File.Stream{
  line_or_bytes: :line,
  modes: [:raw, :read_ahead, :binary],
  path: "test.txt",
  raw: true
}
iex> Enum.take(stream, 10)

Streamは大きなファイルやネットワークの重いリソースを扱うのに適しています。はじめはEnumを使って慣れていくのがよいでしょう。遅延処理が必要になったり、重いリソースや膨大なデータを扱うようになったとき、Streamの利用をお考えください。

Elixir入門もくじ

番外

Posted on by:

gumitech profile

gumi TECH

@gumitech

gumi TECH は、株式会社gumiのエンジニアによる技術記事公開やDrinkupイベントなどの技術者交流を行うアカウントです。 gumi TECH Blog: http://dev.to/gumi / gumi TECH Drinkup: http://gumitech.connpass.com

gumi TECH Blog

株式会社gumiのエンジニアによる技術記事を公開しています。

Discussion

pic
Editor guide