dialyzerを使った静的解析

こんにちは、そしてはじめまして [suzuki](https://qiita.com/suzuki-t0505) です。
今回[Elixir Advent Calendar 2021](https://qiita.com/advent-calendar/2021/elixir)の24日目を書かせていただいています。昨日は[@mnishiguchi](https://qiita.com/mnishiguchi)さんでした。

# はじめに
今回、初投稿なので軽く自己紹介をします。
[The Waggle](https://www.thewaggle-training.com/)という会社に今年から新入社員として入社しました。
自分がElixirを初めてまだ１年経っていない（約半年ぐらい）ですが簡単なwebアプリケーションを開発できるまでになったと思っています。
この記事では開発をする上で便利だったライブラリ「Dialyxir」を紹介します。

# Dialyxir

https://hex.pm/packages/dialyxir

DiallyxirはDialyzerという Erlang の静的解析ツールを Elixir の mix タスクとしてさくっと実行可能にしたライフラリです。

Dialyzerは静的解析を行いますが、Elixirは動的型付け言語です。普通に開発をすると型に関与せずにプログラミングをすることになります。
Elixirには[typespec](https://hexdocs.pm/elixir/typespecs.html)という機能が存在します。このtypespecを利用することで型を利用した解析まで行ってくれます。
Dialyzerとtypespecの２つを組み合わせることで保守性が上がります。
※個人的には使ってみてすごくElixirのコードが読みやすかったです。

# Dialyxirの導入
`mix.exs`にDialyxirを追加します。

```elixir:mix.exs
defp deps do
  [
    #・・・省略
    {:dialyxir, "~> 1.1", only: [:dev], runtime: false}
  ]
end
```

以下のコマンドを実行して依存関係を取得してコンパイルします。

```
$ mix dpes.get
$ mix dpes.compile
```

これでDialyxirが使えるようになりました。
Dialyxirの静的解析を使うには`mix dialyzer`コマンドを使う必要があります。←これややこしいですよねw

:::note warn
mix dialyxir コマンドではないので注意が必要です。
:::

Dialyxir経由してDialyzerを使用しているのでこの先はDialyxirではなく`Dialyzer`と表現します。

# 下準備
Phoenixプロジェクトにアカウントテーブルを作成して関係する関数やスキーマのフィールドに対して静的解析をしていきます。
まずはマイグレーションファイルから

```elixir:20211221003013_create_accounts.exs
defmodule DialyxirSample.Repo.Migrations.CreateAccounts do
  use Ecto.Migration

  def change do
    create table(:accounts, primary_key: false) do
      add :id, :binary_id, primary_key: true
      add :name, :string, null: false

      timestamps()
    end

    create unique_index(:accounts, [:name])
  end
end
```

次にスキーマ

```elixir:account.ex
defmodule DialyxirSample.Accounts.Account do
  use Ecto.Schema

  @primary_key {:id, :binary_id, autogenerate: true}
  @foreign_key_type :binary_id
  schema "accounts" do
    field :name, :string

    timestamps()
  end
end
```

Repoコード

```elixir:accounts.ex
defmodule DialyxirSample.Accounts do
  alias DialyxirSample.Accounts.Account
  @moduledoc """
  The Accounts context.
  """

  import Ecto.Query, warn: false
  alias DialyxirSample.Repo

  def get_account(id), do: Repo.get(Account, id)
end
```

# dialyzerの実行
以下のコマンドでdialyzerを実行します。

```
mix dialyzer
```

初回は時間がかかると思います。解析が終わると以下のように表示されると思います。

```
Starting Dialyzer
・・・省略
Total errors: 0, Skipped: 0, Unnecessary Skips: 0
done in 0m1.47s
done (passed successfully)
```
# typespecを使わない解析

コントローラに先程作ったRepoコードの`get_account/1`関数を呼び出すように追加します。

```elixir:page_controller.ex
defmodule DialyxirSampleWeb.PageController do
  use DialyxirSampleWeb, :controller

  alias DialyxirSample.Accounts

  def index(conn, _params) do
    account_id = Ecto.UUID.generate()
    Accounts.get_account(account_id)

    render(conn, "index.html")
  end
end
```

`Ectu.UUID.generate/0`関数はランダムなUUIDを生成してくれます。


dialyzerを実行した結果をファイルに書き写して置くと便利です。
自分は`mix dialyzer`コマンドを使用するときはシェルスクリプトを経由して実行しています。

```shell:dialyzer.sh
#!/bin/bash
set -eu

mix compile

mix dialyzer > log/dialyzer.log 2> log/dialyzer.err.log
```
`log`フォルダ、`dialyzer.log`ファイル、`dialyzer.err.log`ファイルを事前に作成しておいて`dialyzer.sh`を実行するとファイルに実行結果が入力されるようになります。

ファイルのアクセス権限を変更しないと以下のコマンドでは実行できないのでファイルのアクセス権限を変更します。

```
bin/dialyzer.sh
```
ファイルのアクセス権限変更

```
cd bin
chmod 764 dialyzer.sh
```

少しそれましたが先程コントローラに`get_account/1`関数を呼び出すように追加したのでdialyzerで解析してみます。

```
mix dialyzer
```


シェルスクリプトを追加した場合

```
bin/dialyzer.sh
```

特にDialyzerでは何のエラーも発生しません。コントローラ側で呼び出しの引数をなくしてみます。

```elixir:page_controller.ex
def index(conn, _params) do
  # account_id = Ecto.UUID.generate()
  Accounts.get_account()

  render(conn, "index.html")
end
```

ではDialyzerを実行してみます。

```
Total errors: 1, Skipped: 0, Unnecessary Skips: 0
done in 0m1.54s
lib/dialyxir_sample_web/controllers/page_controller.ex:8:call_to_missing
Call to missing or private function DialyxirSample.Accounts.get_account/0.
________________________________________________________________________________
done (warnings were emitted)
Halting VM with exit status 2
```

このように出力されました。
エラーメッセージを見ると`get_account/0`関数はプライベートか不明な呼び出しと言われています。
ついでにファイルの場所と行も教えてくれています。
便利ですね！

# typespecを使った解析

typespecを使った解析をしていきます。
`get_account/1`関数に`@spec`を追加していきます。

```elixir:accounts.ex
defmodule DialyxirSample.Accounts do
  alias DialyxirSample.Accounts.Account

  @moduledoc """
  The Accounts context.
  """

  import Ecto.Query, warn: false
  alias DialyxirSample.Repo

  @spec get_account(Ecto.UUID.t()) :: %Account{
          id: Ecto.UUID.t(),
          inserted_at: NaiveDateTime.t(),
          name: String.t(),
          updated_at: NaiveDateTime.t()
        }
        | nil
  def get_account(id), do: Repo.get(Account, id)
end
```

`Ecto.UUID.t()`は[Ecto.UUID.t()](https://hexdocs.pm/ecto/Ecto.UUID.html#t:t/0)
`NaiveDateTime.t()`は[NavieDateTime.t()](https://hexdocs.pm/elixir/NaiveDateTime.html#t:t/0)
`String.t()`は[String.t()](https://hexdocs.pm/elixir/String.html#t:t/0)
その他は[これ](https://hexdocs.pm/elixir/typespecs.html#built-in-types)
を見てください。
`@spec`の部分が可読性にかけるのであとで直します。

コントローラのコメントアウトを消してください。

```elixir:page_controller.ex
def index(conn, _params) do
  account_id = Ecto.UUID.generate()
  Accounts.get_account(account_id)

  render(conn, "index.html")
end
```

dialyzerを実行します。
特にエラーは発生しませんでした。
ではコントローラの呼び出しの引数の型をUUIDではない型にしたらどうなるか実行してみます。

```elixir:page_controller.ex
def index(conn, _params) do
  # account_id = Ecto.UUID.generate()
  account_id = 1
  Accounts.get_account(account_id)

  render(conn, "index.html")
end
```

dialyzerを実行します。
エラーが発生しました。

```
Total errors: 2, Skipped: 0, Unnecessary Skips: 0
done in 0m1.58s
lib/dialyxir_sample_web/controllers/page_controller.ex:6:no_return
Function index/2 has no local return.
________________________________________________________________________________
lib/dialyxir_sample_web/controllers/page_controller.ex:9:call
The function call will not succeed.

DialyxirSample.Accounts.get_account(_account_id :: 1)

breaks the contract
(Ecto.UUID.t()) ::
  %DialyxirSample.Accounts.Account{
    :__meta__ => term(),
    :id => Ecto.UUID.t(),
    :inserted_at => NaiveDateTime.t(),
    :name => String.t(),
    :updated_at => NaiveDateTime.t()
  }
  | nil

________________________________________________________________________________
done (warnings were emitted)
Halting VM with exit status 2
```
関数の呼び出しは成功しませんという文脈を出力してくれました。
しかもご丁寧に`@spec`で記述した型を出力してくれています。

では`get_account/1`の`@spec`の記述が長いので直していきます。

まず`@tyep`を使って新しい型を作成します。

```elixir:account.ex
defmodule DialyxirSample.Accounts.Account do
  use Ecto.Schema

  @type t :: %__MODULE__{
          id: Ecto.UUID.t(),
          inserted_at: NaiveDateTime.t(),
          name: String.t(),
          updated_at: NaiveDateTime.t()
        }

  @primary_key {:id, :binary_id, autogenerate: true}
  @foreign_key_type :binary_id
  schema "accounts" do
    field :name, :string

    timestamps()
  end
end
```

`@type`でDialyxirSample.Accounts.Account.t()という型ができたので`@spec`ではこれを呼び出します。

```elixir:accounts.ex
@spec get_account(Ecto.UUID.t()) :: Account.t() | nil
def get_account(id), do: Repo.get(Account, id)
```

これで短く記述することができました。

※この状態でdialyzerの実行結果を見るとこうなります。

```
lib/dialyxir_sample_web/controllers/page_controller.ex:6:no_return
Function index/2 has no local return.
________________________________________________________________________________
lib/dialyxir_sample_web/controllers/page_controller.ex:9:call
The function call will not succeed.

DialyxirSample.Accounts.get_account(_account_id :: 1)

breaks the contract
(Ecto.UUID.t()) :: DialyxirSample.Accounts.Account.t() | nil

________________________________________________________________________________
done (warnings were emitted)
Halting VM with exit status 2
```

先程とは表示が違いますが内容は同じです。
こちらのほうが何個もエラーがあった場合は見やすいと思います。

# 注意点
以下の例を見てください。

```elixir:
@spec get_account_by_name(String.t()) :: Account.t() | nil
def get_account_by_name(name), do: Repo.get_by(Account, name: name)
```

呼び出し元

```elixir:
Accounts.get_account_by_name(1)
```

dialyzerは、これで解析をするとエラーを発生させてくれます。
しかし普通に実行するとこの関数は実行されてしまいます。

```
iex> Accounts.get_account_by_name(1)
** (Ecto.Query.CastError) deps/ecto/lib/ecto/repo/queryable.ex:451: value `1` in `where` cannot be cast to type :string in query:

from a0 in DialyxirSample.Accounts.Account,
  where: a0.name == ^1,
  select: a0

    (elixir 1.12.2) lib/enum.ex:2385: Enum."-reduce/3-lists^foldl/2-0-"/3
    (elixir 1.12.2) lib/enum.ex:1704: Enum."-map_reduce/3-lists^mapfoldl/2-0-"/3
    (elixir 1.12.2) lib/enum.ex:2385: Enum."-reduce/3-lists^foldl/2-0-"/3
    (ecto 3.7.1) lib/ecto/repo/queryable.ex:203: Ecto.Repo.Queryable.execute/4
    (ecto 3.7.1) lib/ecto/repo/queryable.ex:19: Ecto.Repo.Queryable.all/3
    (ecto 3.7.1) lib/ecto/repo/queryable.ex:146: Ecto.Repo.Queryable.one/3
    (stdlib 3.16.1) erl_eval.erl:685: :erl_eval.do_apply/6
    (iex 1.12.2) lib/iex/evaluator.ex:298: IEx.Evaluator.handle_eval/3 

```
理由は、ガード句で指定しない限りあらゆる変数の型を受け付けると判断してしまうからです。
dialyzerは強い静的型付け言語にはしません。
この問題を解決したい場合はガード句で以下のように記述します。

```elixir:
@spec get_account_by_name(String.t()) :: Account.t() | nil
def get_account_by_name(name) when is_binary(name) do
  Repo.get_by(Account, name: name)
end
```

```
iex> Accounts.get_account_by_name(1)
** (FunctionClauseError) no function clause matching in DialyxirSample.Accounts.get_account_by_name/1    
    
    The following arguments were given to DialyxirSample.Accounts.get_account_by_name/1:
    
        # 1
        1
    
    Attempted function clauses (showing 1 out of 1):
    
        def get_account_by_name(name) when is_binary(name)
    
    (dialyxir_sample 0.1.0) lib/dialyxir_sample/accounts.ex:15: DialyxirSample.Accounts.get_account_by_name/1
```

実行すると functionclauseerror が発生するようになりました。


# 最後に
dialyzer(dialxir)で簡単に解析できる事が理解していただいたでしょうか。
dialyzer + typespec を使用することでコードの可読性も上がるはずです。

初めての投稿で汚い文だった思いますが、最後まで読んでいただきまして、ありがとうございました:innocent:

明日(12/25)の[Elixir Advent Calendar 2021](https://qiita.com/advent-calendar/2021/elixir)は[@zacky1972](https://qiita.com/zacky1972)さんです。
お楽しみに:relaxed:

# お知らせ
The Waggle では研修講師になりたい人を絶賛募集中です。
JavaやPHP、C、C#, Python、機械学習、統計などの分野でも募集しています。自身のスキルの棚卸しや、コーチングやファシリテーションのスキルなども身につきますので、自身のスキルアップとしてチャレンジしたい方もいましたら[お問い合わせフォーム](https://www.thewaggle-training.com/#contact)にご連絡ください。