Joseph Kainさんの2015年12月1日付のブログ記事Learning Elixir's withの翻訳です。
この記事でも書きましたがElixirはもうすぐ1.2がリリース予定(2015年12月10日現在v1.2.0-rc.0)で、いろいろと変更が入ります。
with
はLanguage improvementsに含まれており
* Addition of the with special form to match on multiple expressions:
with {:ok, contents} <- File.read("my_file.ex"),
{res, binding} <- Code.eval_string(contents),
do: {:ok, res}
「複数の式にマッチさせるためにこのスペシャルフォームを追加した」とありますね。
リリースが近づいてきたElixir 1.2では新しいスペシャルフォーム with
の導入が予定されています。本投稿でこの新しい特徴がどのように動作し、今後どのように効率的に使っていくとよいのかを調べてみます。
with
は異なる構造化された結果を返すコマンドをまとめてチェーンするのを助けるために用意されました。特にひとつ使用例を挙げるとすればエラーハンドリングをちょっときれいに書くためです。あるコマンドの戻り値がwith
節とマッチしなかった場合、プログラムは全部の式を飛ばしてwith
節を抜けます。この記事の下の方に書いた例でもう少し理解しやすくなるでしょう。
例に入る前に、私が今試しているのはwith
をサポートしているElixirのマスターブランチであるということにご注意ください。とはいえ1.2リリースに向けてマスターブランチは既にクローズになっていますし、読者の方々もすぐに安定版リリースでこの機能を使えるようになるはずです。
ドキュメントからの例
まず、私はwith
が実装済みですがまだ作業中のマスターブランチを使っていることを明記しておきます。正常かどうか確認するためElixirのドキュメントにある例をExUnitテスト用にコピーしてみます。
最初のテスト:
test "Example 1" do
opts = %{width: 10, height: 15}
assert {:ok, 150} ==
with {:ok, width} <- Map.fetch(opts, :width),
{:ok, height} <- Map.fetch(opts, :height),
do: {:ok, width * height}
end
ここでwith
は2つの節をマッチしようとします(<-
を含む2行がそれ)。opts
が渡されて両方のマッチが成功し、do
ブロックが実行されその結果を返します。ですのでwith
式の結果は{:ok, 150}
となり、つまりはアサーションは「真」となってテストが通ります。
test "Example 2" do
opts = %{width: 10}
assert :error ==
with {:ok, width} <- Map.fetch(opts, :width),
{:ok, height} <- Map.fetch(opts, :height),
do: {:ok, width * height}
end
こちらは先の例を少し変更したものです。例1と同じwith
式を持っていますが与えるデータが違います。このケースでは:height
キー/バリューのペアがopts
から抜けています。
:height
がないのでMap.fetch/2
1は:error
を返し、with
式は2番目の節のマッチに失敗します。with
はマッチしない節に出くわすと評価を止めて右辺のマッチしない場合の値を返します。この場合は:error
です。ですのでアサーションは「真」となりテストは通ります。
test "Example 3" do
width = nil
opts = %{width: 10, height: 15}
assert {:ok, 300} ==
with {:ok, width} <- Map.fetch(opts, :width),
double_width = width * 2,
{:ok, height} <- Map.fetch(opts, :height),
do: {:ok, double_width * height}
assert width == nil
end
ドキュメントからとった最後の例はwith
式のスコープについて示しています。
マッチングの挙動は例1と同じですが節は追加された値を計算します。
最初の節ではdouble_width
が計算されそのdouble_width
はdo
ブロック中でもスコープに入っています。
この例でもうひとつ。最初のマッチ節で変数width
に値がバインドされていますね。しかしこれはwith
式のスコープの外にある変数width
とは別のものです。つまりwith
式のスコープの内側でバインドされた変数はスコープの外側には影響しません。これはfor
スペシャルフォームと同じ挙動です。
RFCの調査
with
の機能はIntroducing withというRFC 2 で提案されています。RFCの内容をまとめてみましょう。
with
はfor
とは違ってコレクションから取り出された値ではなく値そのものにマッチします。簡単な例とそれよりもう少し複雑な例が書いてありますね。私はある一番気に入った例からネストしたcase文を簡単にするやり方を思いつきました。こんな感じです:
case File.read(path) do
{:ok, binary} ->
case :beam_lib.chunks(binary, :abstract_code) do
{:ok, data} ->
{:ok, wrap(data)}
error ->
error
end
error ->
error
end
これを例えばこんな風に書けます:
with {:ok, binary} <- File.read(path),
{:ok, data} <- :beam_lib.chunks(binary, :abstract_code),
do: {:ok, wrap(data)}
with
版の方が極めて簡潔で追いかけやすいと思います。ネストしたcase版はエラー処理のための余計で繰り返しの多い行が多いため、やりたいことがぼやかされてしまうのです。
withで既存のエラー処理コードを置き換える
Elixirでうまくエラーを処理する方法を探していたので私はwith
シンタックスには注意を払ってきました。エラーチェックはよくできたパイプラインをぶち壊しにしてしまうのです。私が見つけた一つの方法としてはエラー値を表現するためにモナドを作成してそこにマップしてしまう方法です。
私が実際に作業しているPhoenixベースのプロジェクトでは、もっと簡単な方法を使うことにしました。チェーンの中の関数それぞれについて関数の頭の部分を分離させて書いたのです。これによりチェーンの中の関数はエラーをパイプラインの末尾まで送ります。これは簡単ですがちょっと長ったらしくなりますし、余分な関数の頭の部分は少々プログラムの意図をわかりにくくします。
こういうコードですね:
defp results(conn, search_params) do
conn.assigns.current_user
|> Role.scope(can_view: Service)
|> within(search_params)
|> all
|> preload(:user)
end
defp within(query, %{"distance" => ""}), do: {:ok, query}
defp within(query, %{"distance" => x, "location" => l}) do
{dist, _} = Float.parse(x)
Service.within(query, dist, :miles, l)
end
defp within(query, _), do: {:ok, query}
defp all({:error, _} = result), do: result
defp all({:ok, query}), do: {:ok, Repo.all(query)}
defp preload({:error, _} = result, _), do: result
defp preload({:ok, enum}, field) do
{:ok, Repo.preload(enum, field)}
end
プログラムの背景について少し説明します。
results
関数はEctoのクエリの合成で、User
モデルを起点にそのユーザーが見ることが許可されているサービスを問い合わせます。次に指定された位置からの距離(distance)に含まれる集合に、それらのサービスを限定します。これはフォームのパラメータに基づいています。そしてサービスを取得しユーザーをプリロードします3。
within
関数はService
の関数のためのラッパーです。この関数は距離(distance)を探さなくていい2つのケースを扱います。距離を探す必要がある場合はクエリを構築する作業をService.within
関数に受け渡します。この関数は失敗してもよいです。
all
関数はエラー処理対応の一部です。もしwithin
が{:error,_}
を返したら、all
はそのエラーをそのまま受け渡します。もしwithin
が:ok
と値を返したらall
はRepo
にクエリを投げます。
preload
関数はall
と同じようにエラーはそのまま受け渡し、エラーでない場合はRepo.preload
を呼びます。
with
を使うことでエラーの場合を取り除くことができました:
defp results(conn, search_params) do
with user <- conn.assigns.current_user,
query <- Role.scope(user, can_view: Orthrus.Service),
{:ok, query} <- within(query, search_params),
query <- all(query),
do: {:ok, preload(query, :user)}
end
defp within(query, %{"distance" => ""}), do: {:ok, query}
defp within(query, %{"distance" => x, "location" => l}) do
{dist, _} = Float.parse(x)
Service.within(query, dist, :miles, l)
end
defp within(query, _), do: {:ok, query}
defp all(query), do: Repo.all(query)
defp preload(enum, field) do {:ok, Repo.preload(enum, field)}
改良された点:
- エラーを最後まで送るためだけに存在した余分な関数の頭の部分の必要がなくなった。
-
{:ok, term}
の形式をそれらの関数に渡す必要がなくなった。with
で値が取り出せるようになった。 -
all
からの{"ok, term"}
に含まれる結果をラップする必要がなくなった。単に通過させるだけになった。
{:ok, term}
に含まれるRepo.preload
の結果についてはresults/2
の戻り値だったためにラップする必要がありました。これを取り除くためにはresults/2
を呼び出している側をなんとかしないといけませんし。
実際、もっと単純化を進めることができます。現時点で既にRepo.all
までラップする理由はないでしょう。ですのでall
関数を取り除いて、さらにdoブロックの中でタプルを生成することによりpreload
関数も取り除くことができます。
defp results(conn, search_params) do
with user <- conn.assigns.current_user,
query <- Role.scope(user, can_view: Orthrus.Service),
{:ok, query} <- within(query, search_params),
query <- Repo.all(query),
do: {:ok, Repo.preload(query, :user)}
end
defp within(query, %{"distance" => ""}), do: {:ok, query}
defp within(query, %{"distance" => x, "location" => l}) do
{dist, _} = Float.parse(x)
Service.within(query, dist, :miles, l)
end
defp within(query, _), do: {:ok, query}
もうひとつ、やらなくてもいいことをやってるところを改良できそうですね、with
で。
defp results(conn, search_params) do
with user <- conn.assigns.current_user,
query <- Role.scope(user, can_view: Orthrus.Service),
{:ok, query} <- within(query, search_params),
query <- Repo.all(query),
do: {:ok, Repo.preload(query, :user)}
end
defp within(query, %{"distance" => x, "location" => l}) do
{dist, _} = Float.parse(x)
Service.within(query, dist, :miles, l)
end
defp within(query, _), do: {:ok, query}
最初のwithin/1
は最後の節とかぶっていたので取り除きました。
ここまできて結果に大満足です。コードは小さくなりましたし、個人的には意図がすごく追いかけやすくなったと思います。
結論
この投稿では皆さんといっしょに新しいwith
スペシャルフォームについて読んで、いじってみました。特徴とその利点について例を用いて説明しました。with
を使っていくつかの既存コードをよりきれいで簡潔な形にリファクターしました。
私はこの新機能にワクワクしていると言わざるを得ません。私のプロジェクトをElixir 1.2にアップグレードして、コマンドのチェーンにおいてよりよいエラー処理をするためwith
の利点を活かしていこうと思います。