LoginSignup
5
2

More than 5 years have passed since last update.

Enum.map/2のことを考えない日などあるだろうか?

ない。Elixirを書いていれば人はmapせずにはいられない。

そんなわけでEnum.map/2の中身を知るべく、分解してみる。

記事中太字で書いている部分は個人的に注意している一般的なElixir Tipsでもある。

enum.ex

def map(enumerable, fun)

defmodule Enum do
  # ...

  @type t :: Enumerable.t
  @type element :: any

  require Stream.Reducers, as: R

  # ...

  @spec map(t, (element -> any)) :: list
  def map(enumerable, fun)

  def map(enumerable, fun) when is_list(enumerable) do
    :lists.map(fun, enumerable)
  end

  def map(enumerable, fun) do
    reduce(enumerable, [], R.map(fun)) |> :lists.reverse
  end

  # ...
end

まず注目すべきは、渡されたenumerablelistだったら、:listsにぶん投げるところだ。Erlangに存在しているものを再生産しないようにするのは、Elixir開発時に気をつける必要がある。

ここで扱いたいのはlistだけではなく、Enumerable.tつまり数え上げる事に関するprotocolを実装した任意のdata typeであるから、より抽象的な手続きも用意せねばならない。

しかし第2のclauseも最終的に:lists.reverseしているところを見ると、どうやら結局はlistに落ちてくるようである。これはElixirで開発していればお馴染みのことだろう。Enum.map/2の出力は常にlistである。1

reduce/3

第2clauseはすぐさまEnum.reduce/3している。表現力の高い(、言い換えればだいたいなんでもできる)reduce/3は様々な関数のビルドブロックになる。同じ理由でfor/1comprehensionもビルドブロックとして多用される。

逆に言うと、reduce/3for/1は表現力が高すぎるので、言語使用者側がそれだけを使ってコードを書いてしまうと、それぞれのchunkが部分的に何をしているものなのか理解しづらくなる。2
だからこそEnumには多数の関数が標準で用意されている。結局中ではreduce/3for/1に落ちると知りつつも、文脈に応じて呼び出す関数を変えることで、意図を読み手に伝えやすくできる

そして何やらStream.Reducers.map/1なるmacroが登場している。

stream/reducers.ex

Stream.Reducers.map/1

defmodule Stream.Reducers do
  # Collection of reducers shared by Enum and Stream.

  # ...

  defmacro map(callback, f \\ nil) do
    quote do
      fn(entry, acc) ->
        next(unquote(f), unquote(callback).(entry), acc)
      end
    end
  end

  # ...
end

つまりreduce/3をビルドブロックとして作られる関数定義の際、頻出となる無名関数をまとめているだけの話だ。

Enum.map/2の定義内に手動で展開してやると、こう変わる。

  def map(enumerable, fun) do
    reduce(enumerable, [], fn(entry, acc) ->
      next(nil, fun.(entry), acc)
    end)
    |> :lists.reverse
  end

Mapper関数の適用が行われたが、同時にnext/3なる物が出てきた。prefix無しで呼び出せることが期待されているということは、同じEnummodule内にあるはずだ。

再びenum.ex

next/3

defmodule Enum do
  # ...

  defmacrop next(_, entry, acc) do
    quote do: [unquote(entry) | unquote(acc)]
  end

  # ...
end

ごく単純な代物である。acclistであることを暗に要求しつつ、entryaccから次(next)のaccとして使うlistを生成している。
Module privateなmacroなので、これくらいの前提は暗黙に置いても読み手の心の静謐を犯すことはないというのだろう。

Elixirのmacroには@docは書けても@specは書けなかったり、もっと一般的には関数やmacroにどの程度細かくdocやannotationを付すか迷ったり、といったことで我々は往々にして立ち止まる。命名、可視性、責任範囲、処理内容、平均的な読み手のレベルなどからじっと考えれば、自ずと要・不要は明らかになるという例として大げさに受け取ってみる。

再度Enum.map/2に取り込んでやると、こう展開される。

  def map(enumerable, fun) do
    reduce(enumerable, [], fn(entry, acc) ->
      [fun.(entry) | acc]
    end)
    |> :lists.reverse
  end

かんぺきにわかりました。

結び

こうなるとEnum.reduce/3を分解してみたくなる。別でやるかも。

Enumに用意されている関数を文脈によって適切に使い分けることはかなり重要と感じている。


  1. 例えばmapを入れたのにlistが返ってきて違和感、といった話を時々聞く。Collectableに意図の説明があり、端的にはそのギャップはEnum.into/3で埋めよ、とされる。 

  2. もちろん、accumulatorを使ってある程度複雑な処理をしながらも、1回のループで走査したいと思ったらreduce/3に頼る必要はでてくる。適切な名前の関数で包んでやれば良い。また、対象のデータサイズがある程度少なく、増加しないことがわかっているようなケースでは、Enumに用意されている関数を組み合わせて書き、見た目のわかりやすさを優先してもいいかもしれない。 

5
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
2