Dialyxirを用いたElixirコードの静的解析

# はじめに
ElixirというのはRubyと同じように**強い動的型付け言語**です。プログラムの実行時に型が**動的**に変換され、その型変換の強制力も**強い**です。動的型付け言語では実行時まで型が変換されないので事前に発見できなかったような不具合が実行時に起きてしまうことがあります。

以下にコード例を示していきます。
[Elixirを実行したサイト](https://paiza.io/ja)

## 予期されていない値を与えたときのコード例

```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`を与えれば両者を足し合わせた数字が返されます。
しかし次の場合はどうでしょうか。

```elixir
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`をすることはできないのでこのコードはエラーを吐いてしまいます。

```bash
** (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](https://github.com/jeremyjh/dialyxir)というツールを用います。DialyxirはErlangでのコード解析に用いられていた**Dialyzer**というツールをElixirで簡単に使えるようにしたものです。

## 下準備
まずはサンプルのプロジェクトを作成します。

```bash
mix phx.new dialyzer_kun --no-webpack
```

次に早速Dialyxirをインストールします。

```elixir:mix.exs
  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
```

コンテキストを生成します。

```bash
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`の準備をします。


```elixir:lib/dialyzer_kun/accounts.ex
  @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`のテストが記述されているのですが、今回は不必要な部分を削ってこのような形にします。

```elixir:test/dialyzer_kun/accounts_test.exs
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

```

関数が正しく定義できていればテストが通ると思います。

```bash
> 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を作成します。

```elixir:lib/dialyzer_kun_web/controllers/user_controller.ex
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に登録します。

```elixir:lib/dialyzer_kun_web/router.ex
  scope "/", DialyzerKunWeb do
    pipe_through :browser

    get "/", PageController, :index
    get "/user", UserController, :name
  end
```

そして動作確認用にcontrollerのテストも作成します。

```elixir:test/dialyzer_kun_web/controllers/user_controller_test.exs
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が動き出します。

```bash
mix dialyzer
```

初回実行のみ時間がかかるかと思いますが、

```bash
done (passed successfully)
```

と表示されれば完了です。`@spec`を定義しなくてもdialyzerは使われていない変数やたどり着くことのないパターンマッチを検知して警告してくれますが、今回はそういったものは見られません。
**しかし、user_idにnilなどの不適切な値を与えるとプログラムは正しく動作しないはずです。** なのでdialyzerが関数の情報をより得ることができるように`@spec`を用いて型の情報を付与してあげます。

## specの書き方

### スキーマ
本当は最初に`get_user/1`に型を与えたいところですが、最初は`User`に型を与えます。
`user.ex`というスキーマファイルを開き次のように記述を加えます。

```elixir:lib/dialyzer_kun/accounts/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モジュール](https://github.com/elixir-lang/elixir/blob/7636046bd930bd96b89fd9aa0e882571434a3e1e/lib/elixir/lib/string.ex#L278)

Stringの場合と同じようにtypeをtという名前でモジュール内に定義します。それが以下の記述です。

```elixir
  @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で表現するためには以下のように記述します。

```elixir
@spec get_user(integer()) :: User.t() | nil
def get_user(id), do: Repo.get(User, id)
```

次はControllerでの型定義を行います。

### Controller

```elixir
@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に修正を加えます。

```elixir
def name(conn, %{"user_id" => user_id}) when is_binary(user_id) do
```
さっきまで`is_integer`だった関数のガードを`is_binary`に変更しました。これで実行するとどうでしょう。

```bash
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の挙動について説明していきます。

```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には分かります。

しかし、次の例だとどうなるでしょうか。

```elixir:失敗するController？
  @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()`を削除しても警告が出ません**。

```elixir:Controller
  @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`を用いて関数に引数と戻り値の型を付与することができる。
- 型チェックは厳格なものではなく、必ず失敗する場合のみエラーが出力される。

今回使用したサンプルリポジトリは参考になればご自由にお使いください。
> https://github.com/Papillon6814/dialyzer-kun

間違っている箇所がございましたら修正しますのでコメントでご指摘等お願いいたします。