Rubyの「後置if」をElixirマクロで実装する

この記事は、[Elixir Advent Calendar 2023 シリーズ13](https://qiita.com/advent-calendar/2023/elixir) の13日目です

---

[piacere](https://twitter.com/piacere_ex) です、ご覧いただいてありがとございます :bow:

Rubyは、下記のように「後置 `if`」が書けます

```diff_ruby:実行
irb> name = "hoge"
irb> id = :id001
irb> name = "piacere" if id == :id001
irb> name
"piacere"

irb> id = :id002
irb> name = "piacere" if id == :id001
irb> name
nil
```

これをElixirでも追加してみます

なお、こうしたDSL（Domain Specific Language：ドメイン固有言語）は、マクロを使って実装するのが通例なので、[**書籍「プログラミングElixir」**](https://www.amazon.co.jp/exec/obidos/ASIN/4274219151/onelinebiz-22) のP303（第一版だとP243）以降を参照することをオススメします

[![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/155423/a4e2076c-8106-51d1-ab0a-59f63dc59a2e.png)](https://www.amazon.co.jp/exec/obidos/ASIN/4274219151/onelinebiz-22)

[![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/155423/73019c11-dc0d-9900-e06b-1d188240a4ae.png)](https://www.amazon.co.jp/exec/obidos/ASIN/4274226379/onelinebiz-22)

あと、このコラムが、面白かったり、役に立ったら、![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/155423/907a8695-4d83-1b5c-8052-d0ce52de5f66.png) をお願いします :bow:

# 既存の `if` 式

まず、Elixirの `if` 式のおさらいです

Elixirの `if` 式は、戻り値を返すので、`name` の再束縛（他言語だと代入に相当だが、厳密にはElixirはイミュータブルなので異なる）は `if` の左に書く必要があります

```diff_elixir:実行
iex> name = "hoge"
iex> id = :id001
iex> name = if id == :id001, do: "piacere"
iex> name
"piacere"

iex> id = :id002
iex> name = if id == :id001, do: "piacere"
iex> name
nil
```

これは、後置 `if` であっても同様です

# マクロでオレオレ `if` 式を実装

[**「プログラミングElixir」**](https://www.amazon.co.jp/exec/obidos/ASIN/4274226379/onelinebiz-22) にも解説ありますが、Elixirのマクロは、

<font color="red">**「記載されたコードを条件次第で実行したり、しなかったりを切り替える」**</font>

<font color="red">**「記載されたコードの構造次第では、全く異なる挙動に入れ替える」**</font>

という機能です

早速、オレオレ `if` 式を実装してみましょう（書籍よりも短縮記載し、どの条件を通ったかをデバッグするように改造しています）

```diff_elixir
defmodule My do
  defmacro if(cond, cls) do
    do_cl   = cls[:do]
    else_cl = cls[:else]
    quote do
      case unquote(cond) do
        v when v in [false, nil] -> 
          IO.puts("### I'm original if! - false ###")
          unquote(else_cl)
        _ -> 
          IO.puts("### I'm original if! - true ###")
          unquote(do_cl)
      end
    end
  end
end
```

```diff_elixir:実行
iex> name = "hoge"
iex> id = :id001
iex> require My
iex> name = My.if id == :id001, do: "piacere"
### I'm original if! - true ###
iex> name
"piacere"

iex> id = :id002
iex> name = My.if id == :id001, do: "piacere"
### I'm original if! - false ###
iex> name
nil
```

# 後置 `if` 式を実装する

後置 `if` 式は、一種の「二項演算子」です

なので、まず、[**「プログラミングElixir」**](https://www.amazon.co.jp/exec/obidos/ASIN/4274226379/onelinebiz-22) のP315（第一版だとP243）にある演算子の上書きから、二項演算子の書き換えを試します

足し算を文字列連結に書き換えるマクロと、その実行結果は、こうです（書籍よりも短縮記載してます）

```diff_elixir
defmodule Op, do: defmacro a + b, do: quote do: "#{unquote(a)}#{unquote(b)}"
```

```diff_elixir:実行
iex> 123 + 456
579
iex> import Kernel, except: [+: 2]
iex> import Op
iex> 123 + 456
"123456"
```

同じことを、`if` で定義可能か試してみます

```diff_elixir
iex> defmodule Ruby, do: defmacro a if b, do: quote do: "#{unquote(a)}#{unquote(b)}"
warning: missing parentheses for expression following "do:" keyword. Parentheses are required to solve ambiguity inside keywords.

This error happens when you have function calls without parentheses inside keywords. For example:

    function(arg, one: nested_call a, b, c)
    function(arg, one: if expr, do: :this, else: :that)

In the examples above, we don't know if the arguments "b" and "c" apply to the function "function" or "nested_call". Or if the keywords "do" and "else" apply to the function "function" or "if". You can solve this by explicitly adding parentheses:

    function(arg, one: if(expr, do: :this, else: :that))
    function(arg, one: nested_call(a, b, c))

Ambiguity found at:
└─ iex:38
…
```

むむ、カッコが曖昧でエラーになりました … 

単数行記載が問題あるかもなので、複数行にバラしてみます

```diff_elixir:lib/basic.ex
iex> defmodule Ruby do
...>   defmacro a if b do
...>     quote do
...>       "#{unquote(a)}#{unquote(b)}"
...>     end
...>   end
...> end
error: cannot find or invoke local if/1 inside match. Only macros can be invoked in a match and they must be defined before their invocation. Called as: if(b)
└─ iex:39: Ruby.a/1

error: undefined variable "a"
└─ iex:40: Ruby.a/1

** (CompileError) iex: cannot compile module Ruby (errors have been logged)
    (elixir 1.16.0) src/elixir_module.erl:185: anonymous fn/9 in :elixir_module.compile/7
    iex:38: (file)
```

どうやら、`if` で二項演算子を組むと、`a` の部分が読まれず、エラーとなっているようです

[**「プログラミングElixir 第一版」**](https://www.amazon.co.jp/exec/obidos/ASIN/4274219151/onelinebiz-22) のP255（第二版からは記載そのものが消されている）にある下記で拾える演算子群が、定義可能な二項演算子かも知れません … が、最新Elixirでは関数そのものが無くなっていた …

```diff_elixir:実行
iex> require Macro
iex> Macro.binary_ops
** (UndefinedFunctionError) function Macro.binary_ops/0 is undefined or private
    (elixir 1.16.0) Macro.binary_ops()
    iex:2: (file)
iex> 
```

古いElixirソースコードから拾ってみたところ、`if` は無さそうなので、代わりに `in` を書き換えてみようと思います :stuck_out_tongue_winking_eye:

https://github.com/elixir-lang/elixir/blob/9c91d2cc91fa2ee2ceb88075e69e5eb18bbb1af8/lib/elixir/lib/macro.ex#L25C1-L35C39

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/155423/0fb6d8af-6203-d6e0-a9e7-b52b119e4591.png)

`in` での定義は、ちゃんと通りました

```diff_elixir
defmodule Ruby do
  defmacro a in b do
    quote do
      "#{unquote(a)}#{unquote(b)}"
    end
  end
end
```

上手く行きました

```diff_elixir:実行
iex> import Kernel, except: [in: 2]
iex> import Ruby
iex> name = 123 in 456
iex> name
"123456"
```

それでは、後置 `if` ならぬ、後置 `in` を定義してみましょう

なお、`in` を再定義してしまうので、マクロ内でも `in` を使うのは避けます

```diff_elixir
defmodule Ruby do
  defmacro cls in cond do
    quote do
      case unquote(cond) do
        false -> 
          IO.puts("### I'm original in! - false ###")
          nil
        nil -> 
          IO.puts("### I'm original in! - nil ###")
          nil
        _ -> 
          IO.puts("### I'm original in! - true ###")
          unquote(cls)
      end
    end
  end
end
```

実行してみます

```diff_elixir:実行
iex> name = "hoge"
iex> id = :id001
iex> import Kernel, except: [in: 2]
iex> import Ruby
iex> name = "piacere" in id == :id001
### I'm original if! - true ###
false
```

おや？ … これはもしかして、`in` の後が `id` で途切れてしまっている？

```diff_elixir:実行
iex> name = "piacere" in (id == :id001)
### I'm original if! - true ###
"piacere"
iex> name
"piacere"
```

ビンゴッ :stuck_out_tongue_closed_eyes: :tada:

# 終わりに

Elixirで、Rubyのような「後置 `if`」ならぬ「後置 `in`」をマクロで定義してみました

このように、Elixirは独自の言語仕様をマクロで追加することが出来ます（ただし、二項演算子は使える対象が限られてはいますが）

これを応用すれば、Elixir言語内でのDSL（Domain Specific Language：ドメイン固有言語）も実装できます

たとえば、`Ecto` の `schema ～ do` や `ExUnit` の `test ～ do`、`Mock` の `with_mock ～ do` なんかが代表的な一例で、いずれも実用的で便利なマクロです

今回は、パロディ的な実践的で無いマクロでしたが、業務にフィットしたDSLなんかを今年は披露したいと思います

# p.s.このコラムが、面白かったり、役に立ったら…

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/155423/907a8695-4d83-1b5c-8052-d0ce52de5f66.png) にて、どうぞ応援よろしくお願いします :bow: