mmag

ハマったことメモなど

マクロでTypespecsも書く

前の記事でもマクロでゴニョゴニョするときのことを書いたけど、今回もそれ系。

前提

マクロで関数定義してるとします。使う例は以下のAPIクライアント。Enum.mapで回しててdefmacro使ってないけど、やりたいことはcompile timeに関数を定義するってことなのでその辺は勘弁。

defmodule MyAPI do
  [
    %{fun_name: :get_profile, url: "..."}
  ]
  |> Enum.map(fn %{fun_name: fun_name, url: url} ->
    def unquote(fun_name)(params) do
      HTTP.get(unquote(url), params) |> handle_response()
    end
  end)
end

関数名とURLを受け取って定義してる。これでMyAPI.get_profile/1が定義できちゃう。

やりたいこと

ここに@specも書きたい。Profile.t()が用意されているとして、それを返す関数だよ〜ということを書いておきたい。

いつもなら

@spec get_profile(params :: map()) :: Profile.t()

なので、

defmodule MyAPI do
  [
    %{fun_name: :get_profile, url: "...", return_type: Profile}
  ]
  |> Enum.map(fn %{fun_name: fun_name, url: url, return_type: module} ->
    @spec unquote(fun_name)(params :: map()) :: unquote(module).t()
    def unquote(fun_name)(params) do
      # ...
    end
  end)
end

これでいける。簡単かよ。なお、%{return_type: Profile.t()}はできない。t/0なんて関数はありませんと怒られます。

ではちょっと難しくして、Post.t()のリストを返す関数を追加。

defmodule MyAPI do
  [
    %{fun_name: :get_profile, url: "...", return_type: Profile},
    %{fun_name: :get_posts, url: "...", return_type: [Post]}  # <- 追加
  ]
  |> Enum.map(fn %{fun_name: fun_name, url: url, return_type: module} ->
    @spec unquote(fun_name)(params :: map()) :: unquote(module).t()
    def unquote(fun_name)(params) do
      # ...
    end
  end)
end

invalid remote in typespec: [Post].t()コンパイルエラー。ここは[Post.t()]とかlist(Post.t())とか書かないといけない。じゃあこの変換をやってくれる君をつくろう。

defmodule Type do
  def convert([return_type]) do
    [{{:., [], [return_type, :t]}, [], []}] # [return_type.t()]のAST
  end

  def convert(return_type) do
    {{:., [], [return_type, :t]}, [], []} # return_type.t()のAST
  end
end

defmodule MyAPI do
  [
    %{fun_name: :get_profile, url: "...", return_type: Profile},
    %{fun_name: :get_posts, url: "...", return_type: [Post]}
  ]
  |> Enum.map(fn %{fun_name: fun_name, url: url, return_type: return_type} ->
    @spec unquote(fun_name)(params :: map()) :: unquote(Type.convert(return_type)) # ASTをunquote
    def unquote(fun_name)(params) do
      # ...
    end
  end)
end

急にどうしたんだという感じだけど、ASTをunquoteに渡してあげてる。これで実際IEx立ち上げるなどしてspec見ると、それぞれProfile.t()[Post.t()]が返ると書かれている。上でちょっと触れたけどreturn_type.t()は関数ではないので呼び出したらそこでエラーになってしまうため、ASTの形で扱っている。ここでさらにマクロを書くともっとキレイに、ASTを意識せずに書ける気がしているんだけどうまくできなかった。なお、自分は最初

@spec unquote(fun_name)(params :: map()) :: Type.convert(unquote(return_type))

と書いてしまい、Type.convert/1という@typeがあってそれを返すんですね〜と解釈されて、そうじゃないんです...みたいな気持ちになりました。

まとめ

@specではASTをunquote