プログラミング言語Elixirの大きな特徴は、並行処理が書きやすく、分散アプリケーションをシンプルに記述できることです。
その背景となる「ErlangのVMにおけるプロセスをベースにした考え方」と「Erlang/OTP」については、前回の記事で説明しました。さらに、Elixirのプロジェクト管理ツールであるmixについても解説しました。
いよいよ今回は、こういったElixirの基礎知識をふまえて、Elixirで処理を並行に扱う方法を紹介します。
Elixirで並行処理を書きやすいわけ
そもそも、CやJavaといった言語では、どうして処理を並行に実行するのが大変なのでしょうか?
並行に実行したい処理をこれらの言語で記述する場合、通常はOSのスレッドを複数使って、プログラムを同時に実行します。複数のスレッド間でデータのやりとりが必要になるため、スレッドはすべて同じメモリ空間を共有しています。
このため、プログラムが予想外の動作をしないような排他制御が必要になります。結果的に、プログラムが複雑になってしまい、エラーの原因になり得ます。
一方、Elixirでは、ErlangのVMにおけるプロセス(OSのプロセスではないので注意してください)を使って並行処理を記述します。Erlangのプロセスは、OSのスレッドと違ってメモリ空間を共有していません。プロセスの間でデータのやりとりが必要なときは、前回の記事で見たように、メッセージの受け渡しをするだけです。排他制御が必要ないので、シンプルに処理を記述できるのです。
Elixirの並行処理に挑戦
Erlangのプロセスを使った並行処理を、実際にElixirで書いてみましょう。
ここでは例として、次のような処理を、並行にたくさん実行させてみることにします。
この処理を、並行ではなく逐次処理でたくさん実行すると、実行したぶんだけ長時間にわたって処理がスリープします。
しかし、すべての処理を並行に実行できれば、1つの処理がスリープする時間はたかだか1,000ミリ秒なので、たくさんの処理を実行しても、スリープ時間は1秒程度に収まるはずです。
本当にそうなるか、実際にElixirでプログラミングして確かめてみましょう!
ランダムな時間スリープする処理を定義する
バックグラウンドで実行させるサンプルの関数として、次の2つを定義します。
| 関数 | 処理 |
|---|---|
random |
1,000以下のランダムな数字を返す |
sleep |
引数で与えた時間(ミリ秒)スリープし、文字列を返す |
これを、Workerモジュールとして、worker.exファイルに定義します。
defmodule Worker do # 1,000以下のランダムな数字を生成 def random do :rand.uniform(1000) end # nミリ秒スリープして結果を文字列として返す def sleep(n) do IO.puts "sleep(#{inspect n}) started." :timer.sleep(n) # nミリ秒スリープする IO.puts "sleep(#{inspect n}) ended." "result-sleep(#{inspect n})" end end
randomとsleepそれぞれをIExで対話的に実行すると、結果は以下のようになります。
$ iex worker.ex # ランダムな1,000以下の数字を生成 iex(1)> Worker.random 837 iex(2)> Worker.random 957 iex(3)> Worker.random 987 # 100、1,000、10,000ミリ秒スリープして結果を返す iex(4)> Worker.sleep(100) sleep(100) started. sleep(100) ended. "result-sleep(100)" iex(5)> Worker.sleep(1000) sleep(1000) started. sleep(1000) ended. "result-sleep(1000)" iex(6)> Worker.sleep(10000) sleep(10000) started. sleep(10000) ended. "result-sleep(10000)" iex(7)>
ランダムな整数のリストを作る
これからやりたいことは、次のような処理です。
Worker.randomでランダムな整数を生成し、それを引数にしてWorker.sleepを実行する
しかも、これを逐次的に何回も実行したり、並行して何個も実行したりする必要があります。
for文のような繰り返しの仕組みを使いたくなるかもしれませんが、ここでは次のような手順で、この処理を何回も実行させることにします。
- スリープさせる時間を表す「ランダムな整数のリスト」を作成
- そのリストの各要素を引数にして、
Worker.sleepを実行
逐次的に実行する場合と、並行に実行する場合とでは、2のやり方が変わります。そこで、まず両者に共通する1のほうから考えていきましょう。
Enum.map、無名関数、Range.new
Elixirには、リストのようなコレクションを扱うために、Enumという汎用モジュールが用意されています。このモジュールのEnum.mapという関数を使うと、「コレクションの要素を関数に適用した結果のコレクション」が得られます。
単純な例をIExで実行して、確認してみましょう。
iex> Enum.map([1, 2, 3], fn(x) -> x * 2 end)
[2, 4, 6]
上記の例では、第一引数である[1,2,3]に対し、各要素を2倍にする無名関数を適用しています。Elixirの無名関数は次のようにして定義できます。
fn(<引数>) -> <実行したい処理> end
なお、引数がない無名関数は、fn -> <実行したい処理> endのように引数を省略して定義できます。
定義した無名関数を実行するには、<関数>.(<引数>)とします。IEx上で、無名関数の定義と実行を試してみましょう。
# 無名関数をfに代入して呼び出し iex(1)> f = fn(a) -> "arg is #{a}." end #Function<6.118419387/1 in :erl_eval.expr/5> iex(2)> f.("abc") "arg is abc." iex(3)> f.(123) "arg is 123." # 直接無名関数を呼び出し iex(4)> (fn -> "result" end).() "result" iex(5)> (fn(a,b,c) -> "result is #{a},#{b},#{c}." end).("x","y","z") "result is x,y,z."
無名関数の使い方がわかったところで、これをEnum.mapと組み合わせて「ランダムな整数のリスト」を作る話に戻りましょう。
ランダムな整数は、先ほど定義したWorker.randomを使えば1つ生成できます。ということは、Worker.randomを呼び出すだけの無名関数を使って、次のようにすれば「ランダムな整数が100個含まれたリスト」が作れそうです。
Enum.map(【長さが100のリスト】, fn(_a) -> Worker.random end)
Worker.random自体は引数をとりませんが、無名関数はEnum.mapと組み合わせるため、引数を1つとるものとして定義する必要があります。この引数には、_aというように、先頭に_が付く名前をつけました。これは、使用されない引数であることを示すための慣習です。
先頭が_でなくても動作上問題はありませんが、コンパイラが警告を出します。可読性をあげるためにも、Elixirのプログラムを書くときは、使用しない引数名の先頭を_としましょう。
さて、残るは【長さが100のリスト】を用意する方法だけです。これには、Range.new(<最初の要素>,<最後の要素>)というElixirの関数が使えます。
例えば、Range.new(1, 100)とすれば、1から100までの整数のリスト[1,2,3,4,5,...,100]が取得できます(Range.new(<最初の要素>,<最後の要素>)には、<最初の要素> .. <最後の要素>という省略表記もあるので、1 .. 100としても同じです)。
以上により、次のようにして「ランダムな整数が100個含まれたリスト」を作成できるようになりました!
iex(1)> Enum.map(Range.new(1, 100), fn(_a) -> Worker.random end)
[34, 747, 725, 197, 113, 262, 756, 104, 503, 606, 97, 44, 919, 664, 973, 997,
479, 793, 410, 767, 682, 140, 357, 198, 40, 824, 594, 281, 459, 833, 333, 865,
810, 331, 344, 686, 128, 358, 882, 56, 448, 968, 779, 867, 607, 25, 16, 440,
161, 310, ...]
パイプ演算子で連鎖する処理をすっきり表現
先ほどの例で、Enum.mapとRange.newとが入れ子になっていることに気がついたでしょうか?
Elixirには、このような関数の入れ子の呼び出しを簡潔に書くための記法が用意されています。それはパイプ演算子です。パイプ演算子(|>)を使うと、A(B(x))をx |> B |> Aと書けます。
この例のように入れ子の深さが浅い場合、あまりパイプ演算子のメリットは感じられないかもしれません。しかし、たとえばA(B(C(D(E(F(G(x)))))))のように深い入れ子になると、パイプ演算子なしでは構造が把握しづらくなります。この例は、パイプ演算子を使って書くと、x |> G |> F |> E |> D |> C |> B |> Aという具合に処理の連鎖を直感的に理解しやすい構造で記述でき、非常に見通しがよくなります。
先ほどの例をパイプ演算子を使って記述し、さらにRange.newの省略表記を使えば、以下のように書けます。
iex(2)> 1 .. 100 |> Enum.map(fn(_a) -> Worker.random end)
[956, 316, 558, 208, 346, 433, 320, 974, 344, 899, 322, 129, 660, 873, 20, 5,
201, 4, 56, 462, 603, 306, 63, 168, 568, 299, 12, 582, 788, 189, 527, 938, 295,
108, 681, 502, 594, 377, 994, 390, 183, 860, 712, 57, 685, 516, 669, 392, 50,
428, ...]
まずは普通に逐次実行
ランダムな1,000以下の整数100個からなるリストが取得できるようになったので、それぞれの要素にWorker.sleepを適用させて逐次実行してみましょう。
やはりEnum.mapを使い、Worker.sleepを引数にして、以下のように実行します。
iex(1)> 1 .. 100 |> Enum.map(fn(_a) -> Worker.random end) |> Enum.map(fn(t) -> Worker.sleep(t) end) sleep(409) started. sleep(409) ended. sleep(803) started. sleep(803) ended. 〜略〜 sleep(780) started. sleep(780) ended. ["result-sleep(409)", "result-sleep(803)", "result-sleep(755)", 〜略〜 "result-sleep(552)", "result-sleep(603)", "result-sleep(84)", "result-sleep(193)", "result-sleep(638)", ...] iex(2)>
パイプ演算子を使ったことで、生成したリストを次のEnum.mapに渡していることが直感的に理解できるでしょう。
この逐次実行の処理を、Workerモジュール内の関数として、exex_seqという名前で定義しておきましょう。
defmodule Worker do def sleep(n) do # 略 end def random do # 略 end def exec_seq do IO.puts "===== 逐次実行開始 =====" result = 1 .. 100 |> Enum.map(fn(_) -> random() end) |> Enum.map(fn(t) -> Worker.sleep(t) end) IO.puts "===== 逐次実行結果 =====" result end end
逐次処理は、実行する処理(ここではWorker.sleep)の回数が多ければ多いほど処理時間が長くなります。この例では実行回数が100回ですが、これを1,000回、10,000回、100,000回と増やせば、処理時間も10倍、100倍、1,000倍と線形に増加していきます。
Taskを使って並行実行
逐次実行では、回数に比例して処理時間が増加していくことを見ました。同じ数の処理でも、同時に処理を走らせることで、処理時間の短縮が見込めます。
バックグラウンドで処理を走らせたいとき、ElixirではTaskを利用します。
Task.async(<実行させたい関数>)とすれば、引数の関数を実行する独立したプロセスが起動し、そのプロセスのIDと参照(タスクディスクリプタといいます)が返されます。Task.asyncの結果は、Task.await(<タスクディスクリプタ>)とすれば取得できます。
まとめると、Elixirのプログラムで新たなプロセスを生成して、バックグラウンドで処理を行わせる定型のパターンは、以下のようになります。
-
Task.asyncで処理を行うプロセスを生成 -
Task.awaitで結果を取得
IEx上で試してみましょう。
# 1.Task.asyncで引数の関数を処理するプロセスを生成 iex(1)> task = Task.async(fn -> :timer.sleep(4000); "result" end) # 4秒sleepして"result"を返却 %Task{owner: #PID<0.80.0>, pid: #PID<0.82.0>, ref: #Reference<0.0.4.210>} # タスクディスクリプタ # 2.Task.awaitで結果を取得する iex(2)> Task.await(task) "result"
いまの例では、複数個のプロセスでWorker.sleepを実行させたいので、Enum.mapを使ってタスクディスクリプタのリストを生成し、それをさらにEnum.mapを使って結果のリストに変換します。
iex(1)> 1 .. 100 |> Enum.map(fn(_a) -> Worker.random() end) |> # sleepさせる整数のリスト ...(1)> Enum.map(fn(t) -> Task.async(Worker, :sleep, [t]) end) |> # タスクディスクリプタのリスト ...(1)> Enum.map(fn(t) -> Task.await(t) end) # Worker.sleepの結果のリスト sleep(466) started. sleep(453) started. sleep(761) started. sleep(856) started. 〜略〜 sleep(968) ended. sleep(994) ended. ["result-sleep(234)", "result-sleep(894)", "result-sleep(38)", 〜略〜 "result-sleep(307)", "result-sleep(912)", ...] iex(2)>
exex_seqと同様に、Workerモジュールにexec_conとして並行実行の処理を定義しましょう。
defmodule Worker do def sleep(n) do # 略 end def random do # 略 end def exec_seq do # 略 end def exec_con do IO.puts "===== 並行実行開始 =====" result = 1 .. 100 |> Enum.map(fn(_) -> random() end) |> Enum.map(fn(t) -> Task.async(Worker, :sleep, [t]) end) |> Enum.map(fn(d) -> Task.await(d) end) IO.puts "===== 並行実行結果 =====" result end end
コンパイルして逐次実行と並行実行にかかる時間を比べてみる
逐次実行のサンプルであるWorker.exec_seqと、並行実行のサンプルであるWorker.exec_conは、処理の内容は同じですが、処理時間が大きく異なります。実際に両者の実行時間を計測して確かめてみましょう。
実行時間を計測するために、ソースコードをスクリプトとしてIExで実行するのではなく、ErlangのVM上で実行できるバイナリファイルにコンパイルしてから実行しましょう。Workerモジュールは、すべてworker.exというファイルに実装していたので、これをソースコードとしてコンパイルする手順を説明します。
(Elixirのソースコードを保存するファイルの拡張子には.exと.exsの2種類があり、どちらも実行可能なソースコード(スクリプト)なのですが、慣習としてコンパイルするソースコードには.ex、スクリプトとして実行するソースコードには.exsという拡張子を使います。)
Elixirコードのコンパイルは、elixircというコマンドで行います。
$ ls worker.ex $ elixirc worker.ex # worker.exをコンパイル $ ls Elixir.Worker.beam worker.ex $
コンパイルに成功すると、上記のように、Elixir.Worker.beamというバイナリファイルが生成されているはずです(.beamはErlangのVM上で動作するバイナリファイルを表す拡張子です)。
生成されたバイナリファイルを実行するには、elixirコマンドを使います。elixirコマンドは、実行時、カレントディレクトリ(現在のディレクトリ)にある.beamファイルを自動で読み込みます。
その際に実行するモジュールと関数を、-eオプションを使ってelixir -e <モジュール>.<関数>、または文字列としてelixir -e "モジュール.関数(引数)"のように指定します(ソースコードであるworker.exには、Workerモジュール内の関数として、exec_seqとexec_palを定義していたことを思い出してください)。
$ elixir -e "Worker.sleep(3)" sleep(3) started. sleep(3) ended. $ elixir -e Worker.exec_seq ===== 逐次実行開始 ===== sleep(261) started. sleep(261) ended. 〜略〜 sleep(944) ended. ===== 逐次実行結果 ===== $ elixir -e Worker.exec_con ===== 並行実行開始 ===== sleep(93) started. sleep(85) started. 〜略〜 sleep(996) ended. ===== 並行実行結果 ===== $
実際にexec_seqとexec_conを実行してみればわかりますが、両者の実行時間は大きく異なります。筆者の手元の環境でtimeコマンドを使って実行時間を計測した結果を下記に示します。
$ time elixir -e Worker.exec_seq ===== 逐次実行開始 ===== sleep(834) started. 〜略〜 sleep(970) ended. ===== 逐次実行結果 ===== elixir -e Worker.exec_seq 0.29s user 0.16s system 0% cpu 50.947 total $ time elixir -e Worker.exec_con ===== 並行実行開始 ===== sleep(823) started. 〜略〜 ===== 並行実行結果 ===== elixir -e Worker.exec_con 0.28s user 0.16s system 34% cpu 1.305 total $
上記の例では、ランダムな整数を100個生成してそれぞれの時間だけスリープする処理をすべて終えるまでに、逐次実行では50.9秒、並行実行では1.3秒と大きく処理時間が異なっています。
この差は、同時に実行させる処理の数を増やせば増やすほど大きくなります。ぜひ、exec_seqとexec_palの定義を自分で書き換えて確かめてみてください。
チュートリアルが終わったら次に何をすべきか
ここまでの説明で、ErlangのVMにおけるプロセスをElixirで扱うための基本と、そのプロセスを使った並行プログラミングの概要を見てきました。これでElixirの基本機能については一通り解説したことになります。Elixirで並行処理を簡潔に記述できることが実感できたのではないかと思います。
さらに深くElixirを学ぶため、また、もっと規模の大きなElixirのプログラムを書くためには、どうすればいいでしょうか? 筆者は、以下のような点を学ぶとよいのではないかと考えています。
- 高度なElixirの言語機能の理解:例えば、マクロとプロトコル
- フレームワークの理解:例えばPhoenix
- Erlang/OTPの理解
マクロ
Elixirのマクロは、構文を「拡張」できる仕組みです。でも、構文を拡張するとは、一体どういうことでしょうか?
Elixirでプログラムが走るときには、ソースコードが下記の順に変換され、最終的にはバイトコードがErlangのVM上で実行されます。
- ソースコード
- AST(抽象構文木、コードの内部表現のこと)
- バイトコード
Elixirのマクロは、このうち、2のASTを直接操作することで構文や機能を拡張する仕組みです。
quoteでASTを取得する
ElixirのASTは、{:<関数名>, <メタデータ>, <関数に引数>}という構造で表現されます(なお、このように複数の種類のデータを{...}でまとめたものをタプルと呼びます)。
IEx上でquote do:に続けて式を指定すると、その式のASTを実際に取得できます。試しに、いくつかの式のASTをquote do:で見てみましょう。
iex(1)> quote do: 1 + 1 {:+, [context: Elixir, import: Kernel], [1, 1]} iex(2)> quote do: h Kernel {:h, [context: Elixir, import: IEx.Helpers], [{:__aliases__, [alias: false], [:Kernel]}]} iex(3)> quote do: (1 + 1) * 2 {:*, [context: Elixir, import: Kernel], [{:+, [context: Elixir, import: Kernel], [1, 1]}, 2]}
quoteには、数字、文字列、リスト、タプル、アトム(シンボル)なども指定できます。これらについては、自分自身が返されます。
iex(1)> quote do: 1 1 iex(2)> quote do: "a" "a" iex(3)> quote do: [1,2] [1, 2] iex(4)> quote do: {1,2} {1, 2} iex(5)> quote do: :atom :atom
このようなElixirのASTを、Elixirのプログラムで操作するのが、マクロというわけです。
ASTに別のコードを注入する
マクロで構文を拡張するには、quoteでASTを取得するだけでなく、ASTを操作できる必要もあります。そのための道具となるのはunquoteです。具体的には、quote do:に続く式の中でunquoteを使うと、unquoteの引数の値(変数や関数)が、その式のASTに注入されます。
言葉による説明だけではわかりにくいかもしれませんが、例を見ればすぐわかります。
iex(1)> a = 100 100 # aは{:a, [], Elixir}という変数としてASTに展開される iex(2)> quote do: 1 + a {:+, [context: Elixir, import: Kernel], [1, {:a, [], Elixir}]} # aをunquoteすることで、aの値(100)がASTに注入される iex(3)> quote do: 1 + unquote(a) {:+, [context: Elixir, import: Kernel], [1, 100]} iex(4)>
上記の(3)では、aという変数をquoteの中でunquoteしています。最初にaに代入していた100という値がASTに注入されているのがわかります。
defmacroでマクロを定義
いよいよElixirのマクロを定義してみましょう。マクロは、defmacroを使って定義できます。
例として、整数に1を加えるマクロplus1と、整数に2を加える関数plus2を定義してみます。以下のモジュールをm.exというファイル名で作成してください。
defmodule M do defmacro plus1(a) do quote do unquote(a) + 1 end end def plus2(a) do a + 2 end end
マクロを利用するには、定義したモジュールをrequireする必要があります。
iex(1)> M.plus1(1) ** (CompileError) iex:1: you must require M before invoking the macro M.plus1/1 (elixir) src/elixir_dispatch.erl:99: :elixir_dispatch.dispatch_require/6 iex(1)> require M M iex(2)> M.plus1(1) 2 iex(3)> M.plus2(1) 3
ここで、関数とマクロは何が違うのか、疑問に思う人がいるかもしれません。
関数が実行されるときは、まず引数が評価され、その結果を使って式が評価されて、結果が返されます。
一方、マクロが引数として受け取るのはASTです。処理されたASTが返され、そのASTがマクロから抜けた時点で評価されます。
例として、以下のような関数func_ifとマクロmacro_ifを考えてみましょう。いずれも、第1引数として渡された式がnilかfalse以外なら第2引数の式の評価値を返し、nilかfalseの場合は第3引数の式の評価値を返すという、if文に相当するものです。
defmodule M do defmacro plus1(a) do # 略 end def plus2(a) do # 略 end defmacro macro_if(clause, then_exp, else_exp) do quote do if unquote(clause) do unquote(then_exp) else unquote(else_exp) end end end def func_if(clause, then_exp, else_exp) do if clause do then_exp else else_exp end end end
func_ifとmacro_ifの第2引数と第3引数に、副作用のある式(例えばIO.puts)を渡すと、何が起こるでしょうか?
iex(1)> require M M iex(2)> M.func_if(true, IO.puts("then-exp"), IO.puts("else-exp")) then-exp else-exp :ok iex(3)> M.macro_if(true, IO.puts("then-exp"), IO.puts("else-exp")) then-exp :ok iex(4)>
func_ifでは、第2引数と第3引数のIO.putsがそれぞれ評価されて(その結果、副作用として標準出力されて)、それからIO.puts("then-exp")の結果の:okが返されました。これは、関数が評価される際に、まず引数のIO.putsが評価されてM.func_ifに渡されるからです。
一方、macro_ifでは第3引数のIO.puts("else-exp")が評価されません(副作用のthen-expが出力されていません)。これはどういうことでしょうか?
すでに説明したように、マクロに引数が渡されるときは、引数のASTが渡されます。そのため、関数func_ifには引数が評価された値が渡されるのに対し、マクロmacro_ifには引数がASTとして渡されます。
つまり、macro_ifの引数には、IO.puts(xxx)の評価値ではなく、{{:., [], [{:__aliases__, [alias: false], [:IO]}, :puts]}, [], ["xxx"]}が渡されます。
関数とマクロで渡されてくる引数の状態が異なることを確かめるために、引数を表示して返却するマクロと関数を書いてみましょう。
defmodule M do # 略 defmacro explain_macro(code) do IO.inspect code # マクロの引数を表示して code # そのまま返す end def explain_func(code) do IO.inspect code # 関数の引数を表示して code # そのまま返す end end
実行すると次のようになります。
iex(1)> require M M iex(2)> M.explain_func(1 + 1) 2 # 関数の引数 2 # 関数の実行結果 iex(3)> M.explain_macro(1 + 1) {:+, [line: 2], [1, 1]} # マクロの引数 2 # マクロの実行結果 iex(4)>
explain_funcでは、引数の1 + 1の評価値である2が渡されています。
これに対し、explain_macroでは、そのASTである{:+, [line: 2], [1, 1]}が渡されています。このASTはマクロから抜けたときに評価され、最終的には1 + 1の結果である2になっています。
ここまでくれば、先ほど見たマクロmacro_ifと関数func_ifの実行結果の違いもわかりますね。macro_ifでは、引数のIO.puts(xxxx)は評価される前のASTとして渡され、then節とelse節のどちらか一方の引数だけが評価される(両方の節が評価されない)ということです。
実は、Elixirの構文であるifも、マクロとして実装されています。マクロを使うことで、if文のように、Elixirを構文レベルで拡張できるのです!
(なお、Elixirのif文でも、then節かelse節の片方のみが評価されます。もしifが関数として実装されていたら、then節とelse節の両方が評価されていたかもしれません!)
マクロの説明の最後に、ひとつ注意点を述べておきます。強力で便利なマクロですが、構文を拡張できるということは、それだけ影響も大きいということです。マクロはあくまでも最終手段として、どうしても必要なとき(関数では実現できないとき)だけ利用するようにしましょう。
プロトコル
引数を文字列に変換するto_stringという関数を考えてみましょう。この関数は、引数として与えられた構造をバイナリ(文字列)に変換します。to_stringは、引数の型が整数、浮動小数点数、文字列、アトムのどの場合でも期待通りに動作します。
iex(1)> to_string(1) "1" iex(2)> to_string(1.1) "1.1" iex(3)> to_string("abc") "abc" iex(4)> to_string(:atom) "atom"
Elixirでは、to_stringのようなポリモーフィックな関数を実現するのに、プロトコルという仕組みを使います。to_stringは、Elixirの標準プロトコルの1つであるString.Charsに所属しています。
to_stringに、上記以外の型の値を渡すとどうなるでしょうか? 試しに、Map型のデータを渡してみましょう。
iex(5)> to_string(%{a: 1})
** (Protocol.UndefinedError) protocol String.Chars not implemented for %{a: 1}
(elixir) lib/string/chars.ex:3: String.Chars.impl_for!/1
(elixir) lib/string/chars.ex:17: String.Chars.to_string/1
to_stringは、引数がMap型の場合、上記のようにエラー(String.Chars not implemented)になりました。これは、String.Charsプロトコルで、Map型に対するto_stringが「定義されていない」からです。
String.Charsに対し、引数がMap型の場合のto_stringを定義するにはどうすればいいでしょうか? Elixirでは、ある型に対するプロトコルを次のようにして定義できます。
defimpl <実装したいプロトコル>, for: <対象の型>
Map型に対するString.Charsプロトコルを定義してto_stringを実装するには、次のようにします。
defimpl String.Chars, for: Map do def to_string(map) do case Enum.count(map) do 0 -> "blank-map" # 要素数が0の時はblank-mapと表示 _ -> inspect map # 要素数が1以上の時はinspectする end end end
上記をmap_p.exというファイルに実装し、IExで読み込んでから、Map型に対するto_stringを再度実行してみましょう。
$ iex map_p.ex iex(1)> to_string(%{}) "blank-map" iex(2)> to_string(%{a: 1, b: 2}) "%{a: 1, b: 2}"
うまくいきました! プロトコルの実装は、この例からわかるように、モジュールとは分離して(いまの例ではmap_p.exというファイルで)管理できます。
Phoenix
Phoenixは、Elixirにおけるデファクト・スタンダードのWebアプリケーションフレームワークです。
Phoenixには、ソフトリアルタイム処理(WebSocket通信)を簡単に扱えるという特徴があります。具体的には、WebSocket通信をPhoenixChannelという層で抽象化しており、シンプルな操作(join、terminate、handle_in、handle_outというコールバック関数の実装のみ)で通信を扱えます。
また、PhoenixではHTTPコネクションをプロセスとして管理するので、大量の同時接続処理を比較的軽量に(楽に)扱えます。40コア/128GBのサーバーで200万のWebSocketを同時接続したという事例もあります。
The Road to 2 Million Websocket Connections in Phoenix · Phoenix
この記事ではPhoenixの使い方については解説しませんが、興味がある方は、ぜひ後述する書籍『Programming Phoenix』を読んでみてください。
Erlang/OTPとの関係
最後に、Elixirとは切っても切れないErlang/OTPについても補足します。
ErlangモジュールをElixirで利用する
すでに解説でも利用していたのですが、ElixirではErlangのモジュールや関数を呼び出せます。Erlangのモジュール名をアトムとして、「:モジュール名.関数名」のように指定するだけです。
iex(1)> :erlang.system_time # erlangモジュールのsystem_time関数 1493725226735738040 iex(2)> :timer.sleep(100) # timerモジュールのsleep関数 :ok iex(3)>
上記の例では、(1)ではErlangのtimerモジュールのsleep関数を、(2)ではErlangのerlangモジュールのsystem_time関数を呼び出しています。
Erlangの理解は必要?
Elixirは、ErlangのVM上で動作します。
前節のように、ErlangのモジュールをElixirから利用できるので、Erlangで提供されているデバッグ用のツールが使えたり、ErlangのVMのチューニングでElixirのプログラムの性能が向上できたりします。Erlang固有の知識が重宝するケースがかなりあるということです。
前回説明したように、ElixirにもライブラリのエコシステムとしてHex.pmがありますが、これはまだ成熟しているとはいえません。当面はErlangのモジュールを利用するケースが多いと予想されます。
Phoenixのようなフレームワークに完全にのっとって開発するのであれば、Erlangの知識なしで済む場合もあるでしょう。とはいえ、日本語で読めるErlangの書籍もあるので、ぜひErlangについても学んでみてはいかがでしょうか?
参考文献
Elixirの参考書
プログラミングElixir
Elixirの文法だけでなく、どうやって並行処理を記述するのかをコンパクトに解説した本です。Elixirを学び始めた方におすすめしたい一冊です。現在、日本語で読める唯一のElixir関連書籍です。
Programming Phoenix
Phoenixの基礎的な利用方法から、チャネルを使ったリアルタイム処理、開発時のテストまでをカバーした解説書です。Phoenixは次のバージョン1.3からモジュールとディレクトリの構成が大きく変更されるため、1.3対応の『Programming Phoenix 1.3』の刊行も予定されています。
Metaprogramming Elixir
Elixirのマクロについての解説をコンパクトにまとめた書籍です。マクロの知識は、Elixirでライブラリやフレームワークを作成するうえでは必須なので、ライブラリ作成者にはぜひ読んでおいてほしい書籍です。
Erlangの参考書
すごいErlangゆかいに学ぼう
Erlangの基礎からアプリケーション設計、テストまでを網羅した書籍です。入門書ながら実用的な事例を具体的に解説しており、特にOTPの解説がおすすめです。
プログラミングErlang
Erlangの開発者の一人であるJoe Armstrongの著作で、カバーの絵柄にちなんで「飛行機本」と呼ばれています。少し古い内容ですが非常に読みやすい入門書です。
まとめ
Elixirの基礎を、2回にわたって解説してきました。プロセスを使ったパターンや、OTPというフレームワークに沿ってプログラムを実際に記述することで、並行処理をシンプルに記述できることが実感できたのではないでしょうか?
JavaやRubyといったオブジェクト指向言語ではオブジェクトを中心にプログラミングを考えていくのに対して、Elixirでは軽量プロセスを中心にしてプログラムを組み立てます。最初はとっつきにくいかもしれませんが、スキルセットの引き出しにElixirも入れておくことで、並行・分散処理が必要になった際に役立ててもらえればと思います。
執筆者プロフィール
大原常徳(おおはら・つねのり、GitHub・Twitter)
関連記事
いま学ぶべき第二のプログラミング言語はコレだ! 未来のために挑戦したい9つの言語とその理由
最速で知る! ElixirプログラミングとErlang/OTPの始め方【第二言語としてのElixir】
来月は、次世代のシステムプログラミング言語「Rust」を2回にわたって解説します。
編集協力:鹿野桂一郎(しかの・けいいちろう、Twitter) 技術書出版ラムダノート