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
まず注目すべきは、渡されたenumerable
がlist
だったら、: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/1
comprehensionもビルドブロックとして多用される。
逆に言うと、reduce/3
やfor/1
は表現力が高すぎるので、言語使用者側がそれだけを使ってコードを書いてしまうと、それぞれのchunkが部分的に何をしているものなのか理解しづらくなる。2
だからこそEnum
には多数の関数が標準で用意されている。結局中ではreduce/3
やfor/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無しで呼び出せることが期待されているということは、同じEnum
module内にあるはずだ。
再びenum.ex
next/3
defmodule Enum do
# ...
defmacrop next(_, entry, acc) do
quote do: [unquote(entry) | unquote(acc)]
end
# ...
end
ごく単純な代物である。acc
がlist
であることを暗に要求しつつ、entry
とacc
から次(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
に用意されている関数を文脈によって適切に使い分けることはかなり重要と感じている。
-
例えば
map
を入れたのにlist
が返ってきて違和感、といった話を時々聞く。Collectable
に意図の説明があり、端的にはそのギャップはEnum.into/3
で埋めよ、とされる。 ↩ -
もちろん、accumulatorを使ってある程度複雑な処理をしながらも、1回のループで走査したいと思ったら
reduce/3
に頼る必要はでてくる。適切な名前の関数で包んでやれば良い。また、対象のデータサイズがある程度少なく、増加しないことがわかっているようなケースでは、Enum
に用意されている関数を組み合わせて書き、見た目のわかりやすさを優先してもいいかもしれない。 ↩