LoginSignup
12
2

More than 1 year has passed since last update.

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

Last updated at Posted at 2021-10-09

はじめに

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を取ることを想定しているので、関数を呼び出す際に整数の入っているn1n2を与えれば両者を足し合わせた数字が返されます。
しかし次の場合はどうでしょうか。

defmodule Adder do
  def add(a, b) do
    a + b
  end
end

n1 = 10
n2 = nil

Adder.add(n1, n2) |> IO.inspect()

今度はn2nilが入ってしまっています。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をインストールします。

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

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

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

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

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

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

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

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に登録します。

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

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

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

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が動き出します。

mix dialyzer

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

done (passed successfully)

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

specの書き方

スキーマ

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

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モジュール

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

  @type t :: %__MODULE__{
    age: integer(),
    name: String.t(),
    inserted_at: any(),
    updated_at: any()
  }

%__MODULE__{...}という部分は%User{...}と同じです。チェンジセットの中のvalidate_requirednameageに対して効いているのでこの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の挙動について説明していきます。

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

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

失敗する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_idinteger()が入りさえすれば動作するので警告が出ません。

ちなみにこの部分はspecからinteger()を削除しても警告が出ません

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

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

12
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
12
2