エンジニアのソフトウェア的愛情

または私は如何にして心配するのを止めてプログラムを・愛する・ようになったか

黒魔術で Elixir に Ruby を召喚する

まず始めに。

ネタ記事です。

ひさびさのネタ記事です。

ネタプログラミングです。

とはいえ。

黒魔術を使うことはなくても、このようなしくみを知っていると、その知識が役に立つときが来るかもしれません(来ないかもしれません)。

Level 1, Medium: Port.open/2 で Ruby を起動する

まずは定石の範囲で。

Port.open/2 を使って Ruby のプロセスを起動します。

外部プロセスの起動には System.cmd/3 という関数もありますが、 Port.open/2 の方が制御を細かくできるのでこちらを利用しすることにします。 ちなみに System.cmd/3 も内部では Port.open/2 を利用していました。

path = System.find_executable("ruby")
port = Port.open({:spawn_executable, path},
         [:binary, args: ["-e", "puts('Hello')"]])

receive do
  {^port, {:data, data}} -> data
end
# => "Hello\n"

Port.open/2 でプロセスを起動すると、そのプロセスの出力をメッセージで送信します。 メッセージはポートの値とデータのタプル、データは :data とデータ本体の値のタプル、という形式になっています。

メッセージの形式の詳細はドキュメントを参照してください。

Port.open/2 の戻り値を送り先に指定すると、起動したプロセスにメッセージを送ることができます。

path = System.find_executable("ruby")

# 1 行読み込んで 1 行出力する
port = Port.open({:spawn_executable, path}, [:binary, args: ["-e", ~S|puts("Hello #{gets.strip}!")|]])

# 1 行分の文字列を送る
send(port, {self(), {:command, "world\n"}})

receive do
  {^port, {:data, data}} -> data
end
# => "Hello world!\n"

Level 2, Seer: irb を起動して対話的に式を評価する

ruby コマンドでは、ループ処理を自分で描かない限り、ワンショットの実行になってしまいます。 そこで代わりに irb を起動して一つのプロセスで何度でも式を評価できるようにしてみます。

path = System.find_executable("irb")
port = Port.open({:spawn_executable, path}, [:binary])

ここで。irb を起動しするとどのような挙動になるか IEx で flush/0 を使って確認してみます。

iex> path = System.find_executable("irb")
iex> port = Port.open({:spawn_executable, path}, [:binary])
#Port<0.5>

iex> flush
{#Port<0.5>, {:data, "Switch to inspect mode.\n"}}

起動しただけでなにやらメッセージを受信しています。

メッセージとして Ruby の式を送ってみます。 評価は行単位で行われるので末尾の改行( "\n" )を忘れないようにしてください。

# 式展開で書くと Elixir の文脈で展開してしまうので文字列連結 ++ で書いています
iex> send(port, {self(), {:command, "puts('Hello ' ++ (gets.strip) ++ '!')\n"}})

# gets が読み込む文字列を送る
iex> send(port, {self(), {:command, "world\n"}})
{#PID<0.108.0>, {:command, "world\n"}}

iex> flush
{#Port<0.5>, {:data, "puts('Hello ' ++ (gets.strip) ++ '!')\n"}}
{#Port<0.5>, {:data, "Hello world!\n"}}
{#Port<0.5>, {:data, "nil\n"}}

入力した評価対象の文字列( "puts('Hello ' ++ (gets.strip) ++ '!')\n" )と、puts の出力結果、そして puts の戻り値の値である nil が送り返されました。

出力した内容( "Hello irb!\n" )だけが得られればよいのですが、入力した評価対象の内容も送られてきてしまっています。

明示的に出力した内容以外の出力を抑制するオプションをつけて irb を起動するようにします。

iex> path = System.find_executable("irb")
# 評価する式の出力を抑える --noverbose と 評価した値の出力を抑える --noecho を指定する
iex> port = Port.open({:spawn_executable, path}, [:binary, args: ["--noverbose", "--noecho"]])

iex> send(port, {self(), {:command, "puts('Hello ' ++ (gets.strip) ++ '!')\n"}})
iex> send(port, {self(), {:command, "world\n"}})

iex> flush
{#Port<0.5>, {:data, "Hello world!\n"}}

これで評価する式と評価した値、加えて起動時のメッセージの出力を抑制することができました。

Level 3, Conjuror: サーバにする

これらを踏まえて。 Ruby の式を評価して結果を返すサーバを書いてみます。

defmodule ExIRB do
  use GenServer

  @name __MODULE__

  def start_link(opts) do
    name = Keyword.get(opts, :name, @name)
    GenServer.start_link(__MODULE__, opts, name: name)
  end

  def eva_ruby(server \\ @name, expression) do
    GenServer.call(server, {:eva_ruby, expression})
  end

  def init(_opts) do
    path = System.find_executable("irb")
    port = Port.open({:spawn_executable, path}, [:binary, args: ["--noverbose", "--noecho"]])

    {:ok, %{port: port, froms: []}}
  end

  def handle_call({:eva_ruby, expression}, from, %{port: port, froms: froms} = state) do
    send(port, {self(), {:command, expression}})

    {:noreply, %{state | froms: [from | froms]}}
  end

  def handle_info({port, {:data, data}}, %{port: port, froms: [from | froms]} = state) do
    GenServer.reply(from, data)

    {:noreply, %{state | froms: froms}}
  end
end

実行。

iex> ExIRB.start_link([])
iex> ExIRB.eva_ruby("puts('Hello ExIRB!')\n")
"Hello ExIRB!\n"

ここで注意点として。この実装では式を評価したら必ずメッセージが返されることを期待しています。 値を出力しない式を与えるとタイムアウトが発生します。

値が返ることを期待しない式を評価したい場合は、次のような関数とハンドラを追加することで実現できます。

  def cast_ruby(server \\ @name, expression) do
    GenServer.cast(server, {:cast_ruby, expression})
  end
  def handle_cast({:cast_ruby, expression}, %{port: port} = state) do
    send(port, {self(), {:command, expression}})

    {:noreply, state}
  end
# irb は gets の入力待ちになる
iex> ExIRB.cast_ruby("puts('Hello ' ++ (gets.strip) ++ '!')\n")

# 文字列を送ると gets が値を返し、結果として上の式が評価された値が返される
iex> ExIRB.eva_ruby("world\n")
"Hello world!\n"

ここで puts を使っていると末尾に改行コードが付いてしまいます。 これは print に変更することでこれを回避できます。

iex> ExIRB.eva_ruby("print('Hello ExIRB!')\n")                
"Hello ExIRB!"

Level 4, Magician: マーシャリングした値を利用する

irb のプロセスを起動し評価したい Ruby の式を渡すことで値を得ることができるようになりました。 しかし文字列という形式でしかやりとりができません。 その文字列を Elixir 上でパースすることもできますが面倒です。

シリアライズした Ruby の値を Elixir でデシリアライズできれば文字列をパースするよりもミスなく値を受け渡すことができるはずです。

と、いうわけで。

Ruby の値を Marshal で出力し、それを Elixir で読み込むことにします。

Format of marshaling

フォーマットの仕様はドキュメントにまとめられています。

フォーマットのバージョンは Ruby 1.8.0 以降は 4.8 なので、先頭の 2 バイトは 0x040x08 です。

niltruefalse はそれぞれ "0""T", "F" という形式になるので、マーシャリングされると

Ruby の値 マーシャリングされた値
nil "\x04\x080"
true "\x04\x08T"
false "\x04\x08F"

irb で確認してみます。

irb> Marshal.load("\x04\x080")
=> nil
irb> Marshal.load("\x04\x08T")
=> true
irb> Marshal.load("\x04\x08F")
=> false

Level 3 で作成した ExIRB で同じように評価してみます。

iex> ExIRB.eva_ruby("print(Marshal.dump(nil))\n")
<<4, 8, 48>>
iex> ExIRB.eva_ruby("print(Marshal.dump(true))\n")
<<4, 8, 84>>
iex> ExIRB.eva_ruby("print(Marshal.dump(false))\n")
<<4, 8, 70>>

文字として表示できない値が含まれるためバイト列として表示されます。 フォーマットが規定されているバイト列のパースは文字列のパースよりも容易ですし、なにより Elixir はバイト列のパースが得意です。

そんなわけで。

マーシャリングされた Ruby の値を Elixir の値に変換するパッケージを がっ! と書いてみました。

mix.exsdepsex_marshal を追加します。

# mix.exs

  defp deps do
    [
      {:ex_marshal, github: "mattsan/ex_marshal"}
    ]
  end

iex を起動しなおし、マーシャリングした値を ExMarshal.load/1 で読み込んでみます。

iex> ExIRB.start_link([])
iex> ExIRB.eva_ruby("print(Marshal.dump(nil))\n") |> ExMarshal.load()
nil
iex> ExIRB.eva_ruby("print(Marshal.dump(false))\n") |> ExMarshal.load()
false
iex> ExIRB.eva_ruby("print(Marshal.dump(true))\n") |> ExMarshal.load()
true
iex> ExIRB.eva_ruby("print(Marshal.dump(123))\n") |> ExMarshal.load()
123
iex> ExIRB.eva_ruby("print(Marshal.dump([1,2,3]))\n") |> ExMarshal.load()
[1, 2, 3]
iex> ExIRB.eva_ruby("print(Marshal.dump({a: 1, b: '2', c: :three}))\n") |> ExMarshal.load()
%{a: 1, b: "2", c: :three}

扱える値はドキュメント( lib/ex_marshal.ex の moduledoc )を参照してください。

Ruby のコードを外部の関数として扱うと割り切ればマーシャリングの処理をハンドラの中に隠すことができます。 思い切って書き換えてみます。

  def handle_call({:eva_ruby, expression}, from, %{port: port, froms: froms} = state) do
    send(port, {self(), {:command, "print(Marshal.dump(#{expression}))\n"}})

    {:noreply, %{state | froms: [from | froms]}}
  end

  def handle_info({port, {:data, data}}, %{port: port, froms: [from | froms]} = state) do
    GenServer.reply(from, ExMarshal.load(data))

    {:noreply, %{state | froms: froms}}
  end

修正したコードの内容を反映して Ruby の式を評価してみます。

iex> ExIRB.eva_ruby("nil")
nil
iex> ExIRB.eva_ruby("true")
true
iex> ExIRB.eva_ruby("true")
true
iex> ExIRB.eva_ruby("false")
false
iex> ExIRB.eva_ruby("123")
123
iex> ExIRB.eva_ruby("[1,2,3]")
[1, 2, 3]
iex> ExIRB.eva_ruby("{a: 1, b: '2', c: :three}")
%{a: 1, b: "2", c: :three}

文字列の中は Ruby の式として評価されるのでメソッドも記述できます。 結果は ExMarshal.load/1 で変換しているので Elixir の値として返されます。

iex> ExIRB.eva_ruby("(1..10).map {|i| i * i }")
[1, 4, 9, 16, 25, 36, 49, 64, 81, 100]

Level 5, Enchanter: ライブラリを読み込む

irbコマンドラインオプション -r で起動時にライブラリを読み込むことができます。

ここでは ExIRB.start_link/1 の引数で指定できるようにしてみます。

ExIRB.init/1 を次のように書き換えます。

  def init(opts) do
    path = System.find_executable("irb")
    requires =
      opts
      |> Keyword.get(:require, [])
      |> Enum.map(&"-r#{&1}")
    port = Port.open({:spawn_executable, path}, [:binary, args: ["--noverbose", "--noecho" | requires]])

    {:ok, %{port: port, froms: []}}
  end

オプションで :require で指定された値をライブラリ名のリストとして -r をつけて irb のオプションの形式にします。 Port.open/2 の起動時の引数にそれらを渡します。

ExIRB.start_link/1 でライブラリを指定します。

iex> ExIRB.start_link(require: ["yaml"])

ここでは yaml を指定しています。 これで YAML のメソッドを利用することができるようになりました。

iex> ExIRB.eva_ruby("YAML.load('---\n- 1\n- 2\n')")
[1, 2]

混乱しそうですが、こんなこともできます。

iex> yaml = """
...> ---
...> - 1
...> - 2
...> - a:
...>   - 3
...>   - 4
...> """
"---\n- 1\n- 2\n- a:\n  - 3\n  - 4\n"
iex> ExIRB.eva_ruby("YAML.load('#{yaml}')")
[1, 2, %{"a" => [3, 4]}]

Level 6, Warlock: apply(module, function, arguments)

Ruby の式を文字列で記述する代わりにモジュール名とメソッド名と引数列を評価できれば Elixir の値を渡すことができます。 ちょうど Kernel.apply/3 のような記述です。

このためにはまず引数列を文字列に変換する必要がありますが、これは個々の値を Kernel.inspect/2 で評価してコンマで連結すれば良さそうです。

iex> args = [1, "2", :three] |> Enum.map(&inspect/1) |> Enum.join(",")
"1,\"2\",:three"

iex> "Foo.bar(#{args})"
"Foo.bar(1,\"2\",:three)"

よさそうなのですが、いくつか落とし穴があるので注意しなければなりません。

長い値は省略される

要素の数が多く表現が長くなるばあい、途中から省略されてしまいます。

iex> (1..55) |> Enum.to_list() |> inspect()
"[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, ...]"

すべての要素を表示させるためには :limit オプションに :infinity を指定します。

iex> (1..55) |> Enum.to_list() |> inspect(limit: :infinity)
"[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55]"

表示できる文字コードのリストは charlist として表示される

Erlang や Elixir の「クセ」の部分。 整数値のリストなのに、その整数値が文字コードとして表示できるばあい、文字のリスト (charlist) として表現されてしまいます。

iex> (65..90) |> Enum.to_list() |> inspect(limit: :infinity)
"'ABCDEFGHIJKLMNOPQRSTUVWXYZ'"

整数値のリストとして表示させるためには :charlists オプションに false を指定します。

iex> (65..90) |> Enum.to_list() |> inspect(limit: :infinity, charlists: false)
"[65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90]"

マップはには % が付く

マップを inspect すると、%{} で囲まれた文字列になります。 これは Elixir の仕様ですから、もうどうしようもありません。

iex> %{a: 1} |> inspect(limit: :infinity, charlits: false)
"%{a: 1}"

…と、あきらめかけていたのですが。 バージョン 1.9.0 から :inspect_fun オプションに適切な変換を行う関数を渡すことで任意の表示をさせることができるようになりました。

たとえば次のような関数 MapToHash.inspect_fun/2 を用意します。

defmodule MapToHash do
  import Inspect.Algebra

  def inspect_fun(term, opts) when is_map(term) do
    arrow = string("=>")
    comma = string(",")

    docs =
      term
      |> Enum.reduce([string("}")], fn {k, v}, acc ->
        key = inspect_fun(k, opts)
        value = inspect_fun(v, opts)
        [comma, key, arrow, value | acc]
      end)

    docs =
      case docs do
        [^comma | docs] -> docs
        docs -> docs
      end

    concat([string("{") | docs])
  end

  def inspect_fun(term, opts) do
    Inspect.inspect(term, opts)
  end
end

この関数を :inspect_fun オプションに指定します。

iex> inspect(%{a: 1}, limit: :infinity, charlists: false, inspect_fun: &MapToHash.inspect_fun/2)
"{:a=>1}"

入れ子になったマップでも空のマップでも Ruby のハッシュの形式の文字列で出力できます。

iex> inspect(%{a: 1, b: %{c: %{d: :e}}}, limit: :infinity, charlists: false, inspect_fun: &MapToHash.inspect_fun/2)
"{:b=>{:c=>{:d=>:e}},:a=>1}"
iex> inspect(%{}, limit: :infinity, charlists: false, inspect_fun: &MapToHash.inspect_fun/2)
"{}"

Elixir のモジュール名には Elixir が付いている

あとはモジュール名と関数名とを連結して先に書いた関数で Ruby にメッセージを送ればよさそうです。

まず期待する文字列を組み立てられるか、次のような関数を書いて確かめてみます。

  def apply(module, function, args) do
    args_str =
      args
      |> Enum.map(fn arg ->
        arg
        |> inspect(limit: :infinity, charlists: false, inspect_fun: &MapToHash.inspect_fun/2)
      end)
      |> Enum.join(",")

    "#{module}.#{function}(#{args_str})"
  end

モジュール名と関数名を文字列で指定してみます。

iex> ExIRB.apply("YAML", "load", ["---\n- 1\n - 2\n"])
"YAML.load(\"---\\n- 1\\n - 2\\n\")"

よさそうです。

次に関数名をアトムで指定してみます。

iex> ExIRB.apply("YAML", :load, ["---\n- 1\n - 2\n"])
"YAML.load(\"---\\n- 1\\n - 2\\n\")"

これもよさそうです。

最後に kernel.apply/3 と同じようにモジュール名で指定してみます。

iex> ExIRB.apply(YAML, :load, ["---\n- 1\n - 2\n"])
"Elixir.YAML.load(\"---\\n- 1\\n - 2\\n\")"

Elixir の文字列がつきました。

Elixir のモジュール名の実態はアトムです。

iex> is_atom(YAML)
true

ですのでモジュールが定義されていなくてもモジュール名は自由に使うことができます。 ただし注意する点がああります。たとえば YAML と書いたばあい内部的には :"Elixir.YAML" という値になっているという点です。 これは IEx でも簡単に確認することができます。

iex> :"Elixir.YAML"
YAML

iex> :"Elixir.YAML" == YAML
true

モジュールは Module.split/1 を使うと安全に文字列として分割できるので今回はこれを使うことにします。

iex> [module_name] = Module.split(YAML)
["YAML"]
iex> module_name
"YAML"

上に書いた apply 関数を修正して、組み立てた文字列を Ruby で評価する関数にします。

  def apply(server \\ @name, module, function, args) do
    [module_name] = Module.split(module)
    args_str =
      args
      |> Enum.map(fn arg ->
        arg
        |> inspect(limit: :infinity, charlists: false, inspect_fun: &MapToHash.inspect_fun/2)
      end)
      |> Enum.join(",")

    GenServer.call(server, {:eval_ruby, "#{module_name}.#{function}(#{args_str})"})
  end
iex> ExIRB.start_link(require: ["yaml"])
iex> ExIRB.apply(YAML, :load, ["---\n- 1\n- 2\n"])
[1, 2]
iex> ExIRB.apply(YAML, :dump, [%{a: 1}])
"---\n:a: 1\n"

これで Kernel.apply/3 と同じ書式で Ruby のメソッドを呼び出せるようになりました。

Level 7, Sorcerer: 関数を定義する

apply できるようになりましたが、やはり本来の関数名で呼び出したいものです。

関数を定義してみましょう。

defmodule ExYAML do
  def load(str), do: ExIRB.apply(YAML, :load, [str])
  def dump(term), do: ExIRB.apply(YAML, :dump, [term])
end
iex> ExIRB.start_link(require: ["yaml"])
iex> ExYAML.dump(%{a: 1})
"---\n:a: 1\n"
iex(3)> ExYAML.load("---\n- 1\n- 2\n")
[1, 2]

呼び出しは便利になりましたが関数を定義するのが面倒です。

そんなときは。 そう、マクロです。

関数を定義するマクロを定義します。

defmodule Importer do
  defmacro import_ruby(module, function) do
    quote do
      def unquote(function)(args) do
        ExIRB.apply(unquote(module), unquote(function), args)
      end
    end
  end
end

関数を定義したいモジュールでマクロを展開します。

defmodule ExYAML do
  require Importer
  import Importer

  import_ruby YAML, :load
  import_ruby YAML, :dump
end

これだけで関数が使えるようになります。

iex> ExIRB.start_link(require: ["yaml"])
iex> ExYAML.dump([%{a: 1}])
"---\n:a: 1\n"
iex(3)> ExYAML.load(["---\n- 1\n- 2\n"])
[1, 2]

しかし。このままでは一つの引数しか受け取れないので複数の引数を渡したいときにはリストにまとめて渡す必要があります。

引数の数も定義できる方法を考えます。

まず、quote された関数の構造を調べます。

iex> quote do
...>   def dump(term, opts) do
...>     ExIRB.apply(YAML, :dump, [term, opts])
...>   end
...> end
{:def, [context: Elixir, import: Kernel],
 [
   {:dump, [context: Elixir], [{:term, [], Elixir}, {:opts, [], Elixir}]},
   [
     do: {{:., [], [{:__aliases__, [alias: false], [:ExIRB]}, :apply]}, [],
      [
        {:__aliases__, [alias: false], [:YAML]},
        :dump,
        [{:term, [], Elixir}, {:opts, [], Elixir}]
      ]}
   ]
 ]}

二つの引数 (term, opts) は、[{:term, [], Elixir}, {:opts, [], Elixir}] という構造に展開されていることがわかります。 また ExIRB.apply/3 の三つ目の引数 [term, opts] も同じ構造になっています。

つまり必要な引数の数だけこの構造を並べたものをマクロの値として返すようにすれば、欲しい引数の数の関数を得ることができるはずです。

引数の構造を組み立てます。 26 個より多い引数を持つ関数はないだろうという大雑把な考えのもと、Stream で個々の引数のタプルを組み立て Enum.take/2 で切り出します。

args =
  (?a..?z)
  |> Stream.map(&{String.to_atom(<<&1>>), [], __MODULE__})
  |> Enum.take(arity)

これを関数の定義に組み込みます。 全体では、このようになります。

defmodule Importer do
  defmacro import_ruby(module, function, arity) do
    args =
      (?a..?z)
      |> Stream.map(&{String.to_atom(<<&1>>), [], __MODULE__})
      |> Enum.take(arity)

    {
      :def, [context: __MODULE__, import: Kernel],
      [
        {
          function,
          [context: __MODULE__],
          args
        },
        [do: {
          {:., [], [ExIRB, :apply]},
          [],
          [
            module,
            function,
            args
          ]
        }]
      ]
    }
  end
end

マクロを展開します。 追加した第三引数に引数の個数を指定する必要がありますが、これを利用することで引数の個数違いの関数を定義することができるようになりました。

defmodule ExYAML do
  require Importer
  import Importer

  import_ruby YAML, :load, 1
  import_ruby YAML, :dump, 1
  import_ruby YAML, :dump, 2
end
iex> ExIRB.start_link(require: ["yaml"])
iex> ExYAML.dump([1, [2, [3, 4]]])
"---\n- 1\n- - 2\n  - - 3\n    - 4\n"
iex> ExYAML.dump([1, [2, [3, 4]]], %{indentation: 4})
"---\n- 1\n-   - 2\n    -   - 3\n        - 4\n"

これで扱いやすくなりました。

しかしまだ何度も import_ruby YAML と書かなければならない手間があります。

Kernel.SpecialForms.import/2 のように、関数名と引数の個数をペアにしたキーワードリストでまとめて定義できるようにしてみます。

マクロを修正します。

defmodule Importer do
  defmacro import_ruby(module, functions) do
    functions
    |> Enum.map(fn {function, arity} ->
      args =
        (?a..?z)
        |> Stream.map(&{String.to_atom(<<&1>>), [], __MODULE__})
        |> Enum.take(arity)

      {
        :def, [context: __MODULE__, import: Kernel],
        [
          {
            function,
            [context: __MODULE__],
            args
          },
          [do: {
            {:., [], [ExIRB, :apply]},
            [],
            [
              module,
              function,
              args
            ]
          }]
        ]
      }
    end)
  end
end

利用する側も修正します。

defmodule ExYAML do
  require Importer
  import Importer

  import_ruby YAML, load: 1, dump: 1, dump: 2
end

利用しやすくなりました。

Level 8, Necromancer: モジュールを定義する

しかしまだ問題があります。

defmodule ExYAMLandJSON do
  require Importer
  import Importer

  import_ruby YAML, load: 1, dump: 1
  import_ruby JSON, load: 1, dump: 1
end

同じ名前、同じ引数の数の関数を定義すると、先に定義した関数にすべてのパタンがマッチしてしまい後から定義した関数は呼ばれません。

iex> ExYAMLandJSON.dump(%{a: 1})
"---\n:a: 1\n"

名前空間をわけたいところです。 Ruby のモジュール名を渡しているので、これを名前空間にできないか考えます。

こんどは quote されたモジュールの構造を調べます。

iex> quote do
...>   defmodule Yaml do
...>     def dump(term) do
...>       ExIRB.apply(YAML, :dump, [term])
...>     end
...>
...>     def load(str) do
...>       ExIRB.apply(YAML, :dump, [str])
...>     end
...>   end
...> end
{:defmodule, [context: Elixir, import: Kernel],
 [
   {:__aliases__, [alias: false], [:Yaml]},
   [
     do: {:__block__, [],
      [
        {:def, [context: Elixir, import: Kernel],
         [
           {:dump, [context: Elixir], [{:term, [], Elixir}]},
           [
             do: {{:., [], [{:__aliases__, [alias: false], [:ExIRB]}, :apply]},
              [],
              [
                {:__aliases__, [alias: false], [:YAML]},
                :dump,
                [{:term, [], Elixir}]
              ]}
           ]
         ]},
        {:def, [context: Elixir, import: Kernel],
         [
           {:load, [context: Elixir], [{:str, [], Elixir}]},
           [
             do: {{:., [], [{:__aliases__, [alias: false], [:ExIRB]}, :apply]},
              [],
              [
                {:__aliases__, [alias: false], [:YAML]},
                :dump,
                [{:str, [], Elixir}]
              ]}
           ]
         ]}
      ]}
   ]
 ]}

関数を定義するタプル {:def, ...} の部分を除くと思いの外シンプルです。

また、マクロを利用するモジュールの名前空間の中でモジュールを定義したいわけですが、そのモジュール名は Kernel.SpecialForms.__CALLER__/0 で取得することができます。

これらをすべて適用します。

defmodule Requirer do
  defmacro require_ruby(module, functions) do
    ruby_module =
      case module do
        {:__aliases__, _, [ruby_module]} -> ruby_module
        module -> module
      end

    quoted_functions =
      functions
      |> Enum.map(fn {function, arity} ->
        args =
          (?a..?z)
          |> Stream.map(&{String.to_atom(<<&1>>), [], __MODULE__})
          |> Enum.take(arity)

        {
          :def, [import: Kernel],
          [
            {function, [], args},
            [do: {
              {:., [], [ExIRB, :apply]},
              [],
              [module, function, args]
            }]
          ]
        }
      end)

    {
      :defmodule,
      [import: Kernel],
      [
        Module.concat(__CALLER__.module, ruby_module),
        [do: {:__block__, [], quoted_functions}]
      ]
    }
  end
end

マクロを利用するモジュールを書きます。

defmodule ExYAMLandJSON do
  require Requirer
  import Requirer

  require_ruby YAML, load: 1, dump: 1
  require_ruby JSON, load: 1, dump: 1
end

使ってみます。

iex> ExIRB.start_link(require: ["yaml", "json"])
iex> ExYAMLandJSON.JSON.dump(%{a: 1, b: [1, 2, 3]})
"{\"b\":[1,2,3],\"a\":1}"
iex> ExYAMLandJSON.YAML.dump(%{a: 1, b: [1, 2, 3]})
"---\n:b:\n- 1\n- 2\n- 3\n:a: 1\n"

マクロを利用するモジュールの名前に require_ruby したモジュールの名前を連結した新しいモジュールが定義され、その各々に関数が定義されました。

Level 9, Wizard: ぜんぶまとめると

これらのコードを整理して GitHub に push しました

使ってみましょう。

プロジェクトを作成する

新しいプロジェクトを作成します。プロセスを supervisor で管理したいので --sup オプションを指定します。

$ mix new sample --sup
$ cd sample

依存パッケージを設定する

mix.exs を編集して依存パッケージに ex_irb を指定します。

defmodule Sample.MixProject do
  use Mix.Project

  def project do
    [
      app: :sample,
      version: "0.1.0",
      elixir: "~> 1.9",
      start_permanent: Mix.env() == :prod,
      deps: deps()
    ]
  end

  def application do
    [
      extra_applications: [:logger],
      mod: {Sample.Application, []}
    ]
  end

  defp deps do
    [
      {:ex_irb, github: "mattsan/ex_irb"}
    ]
  end
end

パッケージを取得します。

$ mix deps.get

ExIRB プロセスの起動を設定する

lib/sample/application.ex を編集して ExIRB のプロセスを起動する設定を記述します。

defmodule Sample.Application do
  @moduledoc false

  use Application

  def start(_type, _args) do
    children = [
      {ExIRB, require: ["yaml", "json"]}
    ]

    opts = [strategy: :one_for_one, name: Sample.Supervisor]
    Supervisor.start_link(children, opts)
  end
end

召喚するモジュールを記述する

ExIRB.Requirer を使って Ruby のモジュールを Elixir のモジュールに召喚します。

defmodule Sample do
  @moduledoc """
  Documentation for Sample.
  """

  require ExIRB.Requirer
  import ExIRB.Requirer

  require_ruby YAML, load: 1, dump: 1, dump: 2
  require_ruby JSON, load: 1, dump: 1
end

使う

iex を起動します。

$ iex -S mix

iex のプロンプトから召喚した Ruby のメソッドを呼びます。 アプリケーションが起動するときに ExIRB を起動しているのですぐに利用することができます。

iex> Sample.JSON.dump(%{a: 1})
"{\"a\":1}"
iex> Sample.YAML.dump(%{a: 1})
"---\n:a: 1\n"
iex> Sample.JSON.load(~S[{"a":1}])
%{"a" => 1}
iex> Sample.YAML.load("---\na: 1\n")
%{"a" => 1}

ちなみに。

終了処理をきちんと書いていないので、 iex を終了すると irb と接続しているポートが閉じて irb が例外を投げスタックトレイスを吐き出して停止します。

まぁ、黒魔術だから…。

結論

わかりやすく書こう。

----うますぎるプログラムはいけない

プログラム書法 第2版

いつか読むはずっと読まない:霊媒師、占い師、奇術師、…

ダンジョンズ&ドラゴンズ スターター・セット第5版

ダンジョンズ&ドラゴンズ スターター・セット第5版