Help us understand the problem. What is going on with this article?

[翻訳] Elixirのwithを学ぶ

More than 3 years have passed since last update.

Joseph Kainさんの2015年12月1日付のブログ記事Learning Elixir's withの翻訳です。

この記事でも書きましたがElixirはもうすぐ1.2がリリース予定(2015年12月10日現在v1.2.0-rc.0)で、いろいろと変更が入ります。

withLanguage 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/21: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_widthdoブロック中でもスコープに入っています。

この例でもうひとつ。最初のマッチ節で変数widthに値がバインドされていますね。しかしこれはwith式のスコープの外にある変数widthとは別のものです。つまりwith式のスコープの内側でバインドされた変数はスコープの外側には影響しません。これはforスペシャルフォームと同じ挙動です。

RFCの調査

withの機能はIntroducing withというRFC 2 で提案されています。RFCの内容をまとめてみましょう。

withforとは違ってコレクションから取り出された値ではなく値そのものにマッチします。簡単な例とそれよりもう少し複雑な例が書いてありますね。私はある一番気に入った例からネストした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と値を返したらallRepoにクエリを投げます。

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の利点を活かしていこうと思います。


  1. with式の2行めの方です、念のため。 

  2. Request For Comment コメント求む、仕様案を提示してコメントを求める。 

  3. どうやら動画配信のためのアンテナみたいなものを想定しているぽい?>"service"。つまりあるユーザーが観る権利を持っているチャンネル(サービス)を発信しているアンテナが半径nマイル内に何本立ってるか?みたいな?? 

HirofumiTamori
黒猫の錬金術師と呼ばれたいおっさん
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away