Dave Thomas1 さんの2016年2月23日付けのブログ記事、(Over)using with
in Elixir 1.2の翻訳です。
以前Elixirのwithを学ぶにも別の翻訳記事を書きましたがあちらは主に「複数のマッチ式を扱える」という説明でした。
この記事では「ローカルスコープの変数が使える」という点にもポイントを置いて説明されています。なるほど…!
Elixir 1.2で表現型with
が導入されています。新しすぎてこのブログで使っているシンタックスハイライト機能が知らないぐらいです2。
with
は他の関数型言語のlet
にローカルスコープの変数を定義するという点で少し似ています。こんな感じに書けるということです。
owner = "Jill"
with name = "/etc/passwd",
stat = File.stat!(name),
owner = stat.uid,
do:
IO.puts "#{name} is owned by user ##{owner}"
IO.puts "And #{owner} is still Jill"
with
式は2つの部分からできています。最初の部分は式のリスト、後の部分はdo
ブロックです。初期化式が順番に評価され続いてdo
ブロック内のコードが評価されます。with
の内側で初めて出てきた変数は全てそのwith
のローカル変数となります。この例の場合、owner = stat.uid
の行は新しく変数を生成し、同じ名前3の外側のスコープの変数のバインドを変更しません。
そして、大きな利点ですが、パイプラインに素直に食わせられないような複雑な関数呼出しのシーケンスを自然に分解してくれます。おおまかに言えばテンポラリ変数を使えるようになったということです。それによってコードを読むのも楽しくなります。
例として、私が1年前に書いたコードを挙げましょう。これはEarmark マークダウンパーサーのコマンドラインオプションを処理する部分です:
defp parse_args(argv) do
switches = [
help: :boolean,
version: :boolean,
]
aliases = [
h: :help,
v: :version
]
parse = OptionParser.parse(argv, switches: switches, aliases: aliases)
case parse do
{ [ {switch, true } ], _, _ } -> switch
{ _, [ filename ], _ } -> open_file(filename)
{ _, [ ], _ } -> :stdio
_ -> :help
end
end
ベタベタですね!このコードをよく見て何回switches
変数が関数内で使われているか当ててみてください。じっくり構文解析してみないとわからないと思います。また末尾にある汚いcase
式も大したことない、というわけでもありません。
では次に今朝私がこれをどう書き直したか見てみましょう:4
defp parse_args(argv) do
parse =
with switches = [ help: :boolean, version: :boolean ],
aliases = [ h: :help, v: :version ],
do:
OptionParser.parse(argv, switches: switches, aliases: aliases)
case parse do
{ [ {switch, true } ], _, _ } -> switch
{ _, [ filename ], _ } -> open_file(filename)
{ _, [ ], _ } -> :stdio
_ -> :help
end
end
今度はswitches
とaliases
のスコープは明確です。case
の中で使われていないということもはっきりわかります。
それでもまだparse
という変数があります。これはネストしたwith
で何とかすることもできますが関数が読みにくくなりそうです。代わりにこれを2つのヘルパー関数を使うようにリファクタリングするほうがよさげです:
defp parse_args(argv) do
argv
|> parse_into_options
|> options_to_values
end
defp parse_into_options(argv) do
with switches = [ help: :boolean, version: :boolean ],
aliases = [ h: :help, v: :version ],
do:
OptionParser.parse(argv, switches: switches, aliases: aliases)
end
defp options_to_values(options) do
case options do
{ [ {switch, true } ], _, _ } -> switch
{ _, [ filename ], _ } -> open_file(filename)
{ _, [ ], _ } -> :stdio
_ -> :help
end
end
ぐっとよくなりました。読みやすいし、テストしやすいし、そして変更しやすい。
ところで、ここに来てどうして私がwith
式をparse_into_options
関数に残したのか訝しがられるかもしれません。いい質問ですね。それについてはwith
の2つめの使い方について述べた後にお答えしましょう。
withとパターンマッチング
前の節ではコマンドライン引数のパースをやりました。ではそれを(ちょっとだけ)変更して関数間で受け渡されるオプションの確認について見てみましょう。
私はGitLab、つまりGitHubのオープンソースのそっくりさんのElixirインタフェースを書いている真っ最中です。それは単純ですが少なくとも数十から数百個の幅広いJSON REST API5呼び出しです。それらのAPIのほとんどが名前付きパラメータのセットとともに呼び出されます。パラメータのいくつかは必須ですしオプショナルなパラメータもあります。例えばユーザーを生成するためのAPIは4つの必須パラメータ(email, name, password, username)に加えてオプショナルなパラメータの山(bio, Skype及びTwitterのハンドル、などなど)を持ちます。
私のインタフェースコードで渡されるパラメータがGitLab APIの仕様に合うかどうか確認したかったので簡単なオプションチェックライブラリを書きました。どのように使うかのヒントをいくつか示します:
@create_options_spec %{
required: MapSet.new([ :email, :name, :password, :username ]),
optional: MapSet.new([ :admin, :bio, :can_create_group, :confirm,
:extern_uid, :linkedin, :projects_limit,
:provider, :skype, :twitter, :website_url ])
}
def create_user(options) do
{ :ok, full_options } = Options.check(options, @create_options_spec)
API.post("users", full_options)
end
オプションの仕様は2つのキー:required
と:optional
を持つマップです。これをOptions.chek
に渡すとAPIに渡すオプションが全ての必須の値が含まれていてオプショナルな値がoptionalセットに入っているかを確認します。
以下にチェッカーの最初の実装を示します:
def check(given, spec) when is_list(given) do
with keys = given |> Dict.keys |> MapSet.new,
do:
if opts_required(keys, spec) == :ok && opts_optional(keys, spec) == :ok do
{ :ok, given }
else
:error
end
end
与えられたオプションからキーを抽出し、全ての必須の値が含まれているか及びオプショナル値のリストに何かキーがあるかを確認する2つのヘルパーメソッドを呼び出します。どちらもチェックが通れば:ok
を、ダメなら{:error, msg}
を返します。
このコードで動くんですがコンパクトさを保つためにエラーメッセージを犠牲にしています。どちらかのチェック関数が:ok
を返せなければ処理を中断して:error
を返します。
ここでwith
が光ります。with
とdo
の間の式のリストの中で新しい条件パターンマッチ演算子<-
を使うことができます6。
def check(given, spec) when is_list(given) do
with keys = given |> Dict.keys |> MapSet.new,
:ok <- opts_required(keys, spec),
:ok <- opts_optional(keys, spec),
do:
{ :ok, given }
end
<-
演算子は=
のようにパターンマッチを行います。もしマッチングが成功するとこれら2つの効果は全く同じです-必要であれば左辺の変数が値とバインドされ処理が続行します。
=
と<-
はマッチングが失敗した場合に異なる挙動をします。=
演算子は例外を投げます。しかし<-
はちょっとずるい動きをします: with
式の実行を終了しますが例外を投げません。代わりにwith
式はマッチできなかった値を返します。
このオプションチェッカーではもし必須及びオプショナルのチェックが両方共:ok
を返した場合、処理はずーっと流れていってwith
は{:ok, given}
のタプルを返します。
しかしどちらかが失敗したら、それは{:error, msg}
を返します。そうなると<-
演算子はマッチしないので処理は早いうちにwith
節を抜けます。その値はエラーのタプルで、この関数の戻り値になります。
こじつけ気味の要点
新しく追加されたwith
式はあなたに2つの素晴らしい機能をきちんと箱詰めして提供します:レキシカルスコープと失敗時の早期離脱です。
これでコードがよくなりますね。
使いましょう。
どんどん。
私と Joséの違うところ
Elixir FountainでJohnny Winnが数週間前に José7 にインタビューしています。
話はElixir 1.2の新機能に及び、Joséはwith
について述べていました。結局、なんというか彼は控えめで、あんまり使う必要はないかもしれない、でも使ったならばすごく有益なんだけど、としか語りませんでした。Elixirのソースコードではたしか数回しか使われていないとも。
私が思うにwith
はそんなもんじゃないです。確かに必要に迫られることはめったにないかもしれませんが、使うことで何度も利益を得られるでしょう。私は関数レベルのローカル変数を作るときには常にwith
を使うという実験をしています。
そしてわかったのは、この訓練によって私はよりシンプルで単機能の関数を書くようになったということです。もしある関数がwith
によって簡単にローカルスコープに何かを閉じ込めることができなかったら、それを2つに分割できないかと考える時間を取るようになりました。そのように分割すると常に私のコードは改良されるのです。
それがwith
を先ほどのparse_into_options
関数に残しておいた理由です8。
defp parse_into_options(argv) do
with switches = [ help: :boolean, version: :boolean ],
aliases = [ h: :help, v: :version ],
do:
OptionParser.parse(argv, switches: switches, aliases: aliases)
end
やれ、という訳ではないですが、私は関数の2つの部分の輪郭を描くやり方が好きです。そうすると何がおまけの部分で何が本質かクリアになるからです。私の頭の中ではそういうコードは単純で直線的なコードには欠けているストーリー性を持った構造があるのです。
まあ、これは根拠のない意見に過ぎません。ただあなたもこの技術的な難敵で数週間実験してどんな風に役立つか知りたくなったのではありませんか?