はじめに
ElixirというのはRubyと同じように強い動的型付け言語です。プログラムの実行時に型が動的に変換され、その型変換の強制力も強いです。動的型付け言語では実行時まで型が変換されないので事前に発見できなかったような不具合が実行時に起きてしまうことがあります。
以下にコード例を示していきます。
Elixirを実行したサイト
予期されていない値を与えたときのコード例
defmodule Adder do
def add(a, b) do
a + b
end
end
n1 = 10
n2 = 25
Adder.add(n1, n2) |> IO.inspect()
みなさん読んですぐお分かりの通りこのコードを実行すると35
と表示されます。当たり前ですよね。
add/2
関数はそれぞれの引数にinteger
を取ることを想定しているので、関数を呼び出す際に整数の入っているn1
とn2
を与えれば両者を足し合わせた数字が返されます。
しかし次の場合はどうでしょうか。
defmodule Adder do
def add(a, b) do
a + b
end
end
n1 = 10
n2 = nil
Adder.add(n1, n2) |> IO.inspect()
今度はn2
にnil
が入ってしまっています。10 + nil
をすることはできないのでこのコードはエラーを吐いてしまいます。
** (ArithmeticError) bad argument in arithmetic expression: 10 + nil
:erlang.+(10, nil)
Main.exs:3: Adder.add/2
Main.exs:10: (file)
(elixir 1.12.2) lib/code.ex:1261: Code.require_file/2
プログラムの規模が小さいうちはプログラムを書いた人が挙動を把握しておけるかもしれませんが、規模が大きくなってくるとそれぞれの関数がどんな感じで動くのか把握しづらくなってきます。
自動テストを書いたりして動作を担保していくのも良いと思いますが今回は規模の大きくなったプログラムでも「どういう値を入れるとどういう挙動をするのか」という想定がわかりやすくなるようにElixirコードの静的解析ツールを導入します。
Dialyxirを用いたコードの解析
Elixirの静的解析をするにはDialyxirというツールを用います。DialyxirはErlangでのコード解析に用いられていたDialyzerというツールをElixirで簡単に使えるようにしたものです。
下準備
まずはサンプルのプロジェクトを作成します。
mix phx.new dialyzer_kun --no-webpack
次に早速Dialyxirをインストールします。
defp deps do
[
{:phoenix, "~> 1.5.9"},
{:phoenix_ecto, "~> 4.1"},
{:ecto_sql, "~> 3.4"},
{:postgrex, ">= 0.0.0"},
{:phoenix_html, "~> 2.11"},
{:phoenix_live_reload, "~> 1.2", only: :dev},
{:phoenix_live_dashboard, "~> 0.4"},
{:telemetry_metrics, "~> 0.4"},
{:telemetry_poller, "~> 0.4"},
{:gettext, "~> 0.11"},
{:jason, "~> 1.0"},
{:plug_cowboy, "~> 2.0"},
{:dialyxir, "~> 1.0", only: [:dev, :test], runtime: false}
]
end
コンテキストを生成します。
mix phx.gen.context Accounts User users name:string age:integer
あとはマイグレートなり必要なコマンドを入力し、下準備は完了です。
Repoのコード準備
コンテキストを生成したことでUser
に関連するプログラムがいくつか生成されたかと思います。データベースを操作する関数としてget_user!/1
が生成されていると思いますが、今回はそれとは別にget_user/1
を作成します。最終的にはこのget_user/1
をcontrollerから呼び出しますが、まずはget_user/1
の準備をします。
@doc """
Gets a single user.
Returns nil if no user was found.
## Examples
iex> get_user(123)
%User{}
iex> get_user(456)
nil
"""
def get_user(id), do: Repo.get(User, id)
動作の確認にはテストを利用します。accounts_test.exs
にAccounts
のテストが記述されているのですが、今回は不必要な部分を削ってこのような形にします。
defmodule DialyzerKun.AccountsTest do
use DialyzerKun.DataCase
alias DialyzerKun.Accounts
describe "users" do
@valid_attrs %{age: 42, name: "some name"}
def user_fixture(attrs \\ %{}) do
{:ok, user} =
attrs
|> Enum.into(@valid_attrs)
|> Accounts.create_user()
user
end
test "get_user/1 returns the user with given id" do
user = user_fixture()
assert Accounts.get_user(user.id) == user
end
test "get_user/1 returns nil with invalid id" do
user = user_fixture()
assert Accounts.get_user(user.id+1) == nil
end
test "get_user/1 raises an error with nil" do
assert_raise ArgumentError, fn ->
Accounts.get_user(nil)
end
end
end
end
関数が正しく定義できていればテストが通ると思います。
> mix test ./test/dialyzer_kun/accounts_test.exs
...
Finished in 0.09 seconds
3 tests, 0 failures
今テストのファイルにかかれているテスト内容がget_user/1
の挙動を表しているということになります。
Controllerのコード準備
先ほど作成したget_user/1
を呼び出すcontrollerを作成します。
defmodule DialyzerKunWeb.UserController do
use DialyzerKunWeb, :controller
alias DialyzerKun.Accounts
def name(conn, %{"user_id" => user_id}) when is_integer(user_id) do
user = Accounts.get_user(user_id)
json(conn, %{user_name: user.name})
end
def name(conn, %{"user_id" => _user_id}) do
json(conn, %{user_name: nil, error: "user_id should be integer."})
end
end
手軽なのでrenderではなくjsonを使ってレスポンスを返しています。
controllerに関数を定義したので、それをrouterに登録します。
scope "/", DialyzerKunWeb do
pipe_through :browser
get "/", PageController, :index
get "/user", UserController, :name
end
そして動作確認用にcontrollerのテストも作成します。
defmodule DialyzerKunWeb.UserControllerTest do
use DialyzerKunWeb.ConnCase
alias DialyzerKun.Accounts
describe "GET /user" do
@valid_attrs %{age: 42, name: "some name"}
def user_fixture(attrs \\ %{}) do
{:ok, user} =
attrs
|> Enum.into(@valid_attrs)
|> Accounts.create_user()
user
end
test "returns user name with given id", %{conn: conn} do
user = user_fixture()
conn = get(conn, "/user", %{user_id: user.id})
assert json_response(conn, 200) == %{"user_name" => @valid_attrs.name}
end
end
end
これでコードの準備は完了です。
Dialyzerの実行①
それではいよいよDialyzerを実行します。以下のコマンドを実行するとDialyzerが動き出します。
mix dialyzer
初回実行のみ時間がかかるかと思いますが、
done (passed successfully)
と表示されれば完了です。@spec
を定義しなくてもdialyzerは使われていない変数やたどり着くことのないパターンマッチを検知して警告してくれますが、今回はそういったものは見られません。
しかし、user_idにnilなどの不適切な値を与えるとプログラムは正しく動作しないはずです。 なのでdialyzerが関数の情報をより得ることができるように@spec
を用いて型の情報を付与してあげます。
specの書き方
スキーマ
本当は最初にget_user/1
に型を与えたいところですが、最初はUser
に型を与えます。
user.ex
というスキーマファイルを開き次のように記述を加えます。
defmodule DialyzerKun.Accounts.User do
use Ecto.Schema
import Ecto.Changeset
@type t :: %__MODULE__{
age: integer(),
name: String.t(),
inserted_at: any(),
updated_at: any()
}
schema "users" do
field :age, :integer
field :name, :string
timestamps()
end
@doc false
def changeset(user, attrs) do
user
|> cast(attrs, [:name, :age])
|> validate_required([:name, :age])
end
end
Elixirには組み込みの型とそうでない型が存在します。組み込みの型はinteger()
のように記述しますが、組み込みでない型はString.t()
のようにそのモジュール内に定義されたt()
というtypeを参照します。(今回String.t()
と書いた部分はbinary()
と書いても大丈夫です。)
Stringの場合と同じようにtypeをtという名前でモジュール内に定義します。それが以下の記述です。
@type t :: %__MODULE__{
age: integer(),
name: String.t(),
inserted_at: any(),
updated_at: any()
}
%__MODULE__{...}
という部分は%User{...}
と同じです。チェンジセットの中のvalidate_required
がname
とage
に対して効いているのでこの2つにはnilが入ることはありません。
もしnameでnilを許容するとしたら、String.t()
の部分をString.t() | nil
のようにします。
これを利用すると、{:ok, any()} | {:error, any()}
のようなok, errorのタプルでの戻り値にも対応することができます。
関数
次はget_user/1
にspecを付与します。
この関数はintegerのid
を取り、ユーザーが見つかればUser.t()
を返し、見つからなければnil
を返します。
これをspecで表現するためには以下のように記述します。
@spec get_user(integer()) :: User.t() | nil
def get_user(id), do: Repo.get(User, id)
次はControllerでの型定義を行います。
Controller
@spec name(Plug.Conn.t(), %{required(String.t()) => integer() | String.t() | nil}) :: Plug.Conn.t()
def name(conn, %{"user_id" => user_id}) when is_integer(user_id) do
...
一見複雑に見えますが、get_user/1
と同じです。
Dialyzerの実行②
この状態でdialyzer
を実行してもおそらくエラーを吐くことなく完了すると思います。
なのでdialyzerが通らなくなるようにしてみましょう。
controllerに修正を加えます。
def name(conn, %{"user_id" => user_id}) when is_binary(user_id) do
さっきまでis_integer
だった関数のガードをis_binary
に変更しました。これで実行するとどうでしょう。
lib/dialyzer_kun_web/controllers/user_controller.ex:8:call
The function call will not succeed.
DialyzerKun.Accounts.get_user(_user_id :: binary())
breaks the contract
(integer()) :: DialyzerKun.Accounts.User.t() | nil
________________________________________________________________________________
done (warnings were emitted)
Halting VM with exit status 2
型に関する警告が出力されました!integer
しか入れられないはずの変数にbinary
が入っているからですね。
以上がDialyzerでコードを静的解析するときの流れです。
次はDialyzerの注意点についてです。
Dialyzerの注意点
注意しておきたいのがDialyzerはElixirを強い静的型付け言語に変えてくれるツールではないということです。
先ほど例として出したcontrollerの挙動について説明していきます。
@spec name(Plug.Conn.t(), %{required(String.t()) => integer() | String.t() | nil}) :: Plug.Conn.t()
def name(conn, %{"user_id" => user_id}) when is_binary(user_id) do
user = Accounts.get_user(user_id)
json(conn, %{user_name: user.name})
end
def name(conn, %{"user_id" => _user_id}) do
json(conn, %{user_name: nil, error: "user_id should be integer."})
end
まずこれは失敗するコードです。これは失敗する理由が明確です。
when
によるガードでuser_id
変数にはbinaryの値しか入っていないんだということをdialyzerが認識することができるからです。したがって「integer()
の引数にbinary()
の値が入っているからおかしい!」ということがdialyzerには分かります。
しかし、次の例だとどうなるでしょうか。
@spec name(Plug.Conn.t(), %{required(String.t()) => integer() | String.t() | nil}) :: Plug.Conn.t()
def name(conn, %{"user_id" => user_id}) do
user = Accounts.get_user(user_id)
json(conn, %{user_name: user.name})
end
関数のガードを外して一つの関数になったものです。この場合@spec
にも書いてある通りuser_id
に入り得る型はinteger()
かString.t()
かnil
の3種類あります。一方get_user/1
の引数はinteger()
しか受け付けません。
この条件ならdialyzerが警告を出力してもいいものですが、実際に動作をさせてみると警告が出ません。
Dialyzerは必ずエラーが発生する場合にのみエラーを出力します。「失敗するController?」の例ではuser_id
にinteger()
が入りさえすれば動作するので警告が出ません。
ちなみにこの部分はspecからinteger()
を削除しても警告が出ません。
@spec name(Plug.Conn.t(), %{required(String.t()) => String.t() | nil}) :: Plug.Conn.t()
def name(conn, %{"user_id" => user_id}) do
user = Accounts.get_user(user_id)
json(conn, %{user_name: user.name})
end
つまりこのように書いても大丈夫な扱いになってしまいます。
これはローカル変数をwhenなどでガードをしない限りdialyzerはローカル変数をあらゆる型が入る変数とみなしてしまうためです。
したがってdialyzerを導入してもElixirを強い静的型付け言語として扱えるというわけではありません。
まとめ
- dialyzerを用いて強い動的型付け言語であるElixirの型チェックを行うことができる。
-
@spec
を用いて関数に引数と戻り値の型を付与することができる。 - 型チェックは厳格なものではなく、必ず失敗する場合のみエラーが出力される。
今回使用したサンプルリポジトリは参考になればご自由にお使いください。
間違っている箇所がございましたら修正しますのでコメントでご指摘等お願いいたします。