Elixir
ElixirDay 23

静的型チェックツールDialyzerをElixirから使う

More than 3 years have passed since last update.

Elixir Advent Calendar 2014 23日目です.

動的型付け言語である Erlang で,静的に型エラーをチェックするためのツール Dialyzer というものがあります.
日本語で Web 上に公開されている情報だと33. 型仕様とErlangが一番わかりやすいでしょう.

この文章では Dialyzer を Elixir で使う方法をいくつか紹介すると同時に,Dialyzer がどんなものか雰囲気がつかめることを願って書いています.

手動で設定する

Programming Elixir という Elixir を始めるとっかかりにするにはとても良い本があります.

その中の Appendix 2 に Type Specifications and Type Checking という章があり,Elixir で Dialyzer を使う方法を紹介しています.試してみましょう.

プロジェクトを作る

まずはプロジェクトを作ります.今回は simple という名前にしました.

$ mix new simple
(...snip...)
$ cd simple

型付情報を用意する

lib/simple.ex を以下のように編集します.
dialyzer の挙動を理解するため,この時点では関数の中身はまだ実装しません.

defmodule Simple do
  @type atom_list :: list(atom)
  @spec count_atoms(atom_list)  :: non_neg_integer
  def count_atoms(list) do
    # ...
  end
end

Dialyzer を動かす

プロジェクトをコンパイルする

  • Dialyzer は ErlangVM である BEAM に対して行う
  • Erlang や Elixir をコンパイルすると,拡張子が .beam な BEAM ができる

ので,まずは Elixir をコンパイル ( mix compile ) します.すると _build 以下にファイルが作られます.

$ mix compile
lib/simple.ex:4: warning: variable list is unused
Compiled lib/simple.ex
Generated simple.app
$ tree _build/
_build/
└── dev
    └── lib
        └── simple
            └── ebin
                ├── Elixir.Simple.beam
                └── simple.app

4 directories, 2 files

Dialyzer が利用する基本的な情報を準備する

次に

  • Dialyzer はこのプログラムの実行に利用する全てのライブラリの静的な仕様を必要とする

ため,これを生成して保存しておかなければなりません.
このファイルのことを PLT(Persistent Lookup Table) といいます.

今回は Erlang の基本的なライブラリ (ert) と Elixir の実行用ライブラリの仕様が必要です.
これを生成するためには,ライブラリのパスを知らなければならなりません.
ライブラリのパスは

$ iex -S mix
Erlang/OTP 17 [erts-6.3] [source] [64-bit] [smp:4:4] [async-threads:10] [hipe] [kernel-poll:false] [dtrace]

Interactive Elixir (1.0.2) - press Ctrl+C to exit (type h() ENTER for help)
iex(1)> :code.lib_dir(:elixir)
'/usr/local/Cellar/elixir/1.0.2/bin/../lib/elixir'

で得られた結果に /ebin を加えたものになります.
つまり,今回ですと /usr/local/Cellar/elixir/1.0.2/bin/../lib/elixir/ebin です.

それではこのパスを利用して PLT を作成しましょう.この処理には数分くらいかかります.

$ dialyzer --build_plt --apps erts /usr/local/Cellar/elixir/1.0.2/bin/../lib/elixir/ebin
  Compiling some key modules to native code... done in 0m56.23s
  Creating PLT /Users/niku/.dialyzer_plt ...
  (...snip...)
done (passed successfully)

Dialyzer 実行

できたら dialyzer を動かしてみましょう.

$ dialyzer _build/dev/lib/simple/ebin
Checking whether the PLT /Users/niku/.dialyzer_plt is up-to-date... yes
  Proceeding with analysis...
simple.ex:3: Invalid type specification for function 'Elixir.Simple':count_atoms/1. The success typing is (_) -> 'nil'
 done in 0m0.48s
done (warnings were emitted)

書いてある仕様と合わないというメッセージが表示されました.
プログラムには @spec count_atoms(atom_list) :: non_neg_integer と書いてあり,
non_neg_integer( 負ではない整数 ) が返る仕様なのですが,実際には nil が返っているためです.

仕様を満たすとどうなるでしょうか. lib/simple.ex の内容を書き換えてみましょう.

defmodule Simple do
  @type atom_list :: list(atom)
  @spec count_atoms(atom_list)  :: non_neg_integer
  def count_atoms(list) do
    length list # ここを書き換え
  end
end

書き換えたら,コンパイルして beam ファイルを生成してから dialyzer にかけます.

$ mix compile
Compiled lib/simple.ex
Generated simple.app
$ dialyzer _build/dev/lib/simple/ebin
  Checking whether the PLT /Users/niku/.dialyzer_plt is up-to-date... yes
  Proceeding with analysis... done in 0m0.45s
done (passed successfully)

今度はうまくいきました.

さらにもう一つモジュールを追加して試してみましょう.
lib/simple/client.ex というファイルを新たに追加します.

defmodule Client do
  @spec other_function() :: non_neg_integer
  def other_function do
    Simple.count_atoms [1, 2, 3]
  end
end

追加したら,コンパイルして dialyzer にかけてみましょう.

$ mix compile
Compiled lib/client.ex
Generated simple.app
$ dialyzer _build/dev/lib/simple/ebin
  Checking whether the PLT /Users/niku/.dialyzer_plt is up-to-date... yes
  Proceeding with analysis...
client.ex:3: Function other_function/0 has no local return
client.ex:4: The call 'Elixir.Simple':count_atoms([1 | 2 | 3,...]) breaks the contract (atom_list()) -> non_neg_integer()
 done in 0m0.44s
done (warnings were emitted)

新しいエラーが出ていますね.
client.ex:3: Function other_function/0 has no local return
other_function が何も返さないと書いてあるようです.

これには次の行
client.ex:4: The call 'Elixir.Simple':count_atoms([1 | 2 | 3,...]) breaks the contract (atom_list()) -> non_neg_integer()
が関係しています.

dialyzer は 4 行目で count_atoms(atom_list()) -> non_neg_integer() が守れていない 1 ため,ここでエラーになる(=例外を発生させる)だろうと推測します.
すると 3 行目の other_function 関数全体としては例外を発生させるので,返り値としては何も返さないであろうと推測しているわけです.
賢いですね.

それではこれを直してみましょう.再度 lib/client.ex を修正します.

defmodule Client do
  @spec other_function() :: non_neg_integer
  def other_function do
    Simple.count_atoms [:a, :b, :c]
  end
end
$ mix compile
Compiled lib/client.ex
Generated simple.app
$ dialyzer _build/dev/lib/simple/ebin
  Checking whether the PLT /Users/niku/.dialyzer_plt is up-to-date... yes
  Proceeding with analysis... done in 0m0.45s
done (passed successfully)

うまくいったようです.

Dialyxir を使う

Mix tasks to simplify use of Dialyzer in Elixir projects.

と謳っている Dialyxir というライブラリがあるので,同じように使ってみましょう.

README を参考に作業します.その前に,上で手動で作った PLT ファイルも消しておきましょう.

$ cd ~
$ rm .dialyzer_plt
$ git clone git@github.com:jeremyjh/dialyxir.git
Cloning into 'dialyxir'...
remote: Counting objects: 146, done.
remote: Total 146 (delta 0), reused 0 (delta 0)
Receiving objects: 100% (146/146), 26.78 KiB | 0 bytes/s, done.
Resolving deltas: 100% (58/58), done.
Checking connectivity... done.
$ cd dialyxir
$ mix archive.build
Compiled lib/dialyxir/helpers.ex
lib/mix/tasks/dialyzer.ex:40: warning: unused import Enum
Compiled lib/mix/tasks/dialyzer.ex
Compiled lib/mix/tasks/dialyzer.plt.ex
Generated dialyxir.app
Generated archive dialyxir-0.2.6.ez with MIX_ENV=dev
$ mix archive.install dialyxir-0.2.6.ez
Are you sure you want to install archive dialyxir-0.2.6.ez? [Yn]
* creating /Users/niku/.mix/archives/dialyxir-0.2.6.ez
$ mix dialyzer.plt
Starting PLT Core Build ... this will take awhile
(...snip...)
 done in 3m50.05s
done (passed successfully)

mix dialyzer.plt には数分程度時間がかかりました.
それでは再び simple プロジェクトに dialyzer をかけてみましょう.

$ cd simple
$ mix compile
$ mix dialyzer
Starting Dialyzer
dialyzer --no_check_plt --plt /Users/niku/.dialyxir_core_17_1.0.2.plt -Wunmatched_returns -Werror_handling -Wrace_conditions -Wunderspecs /Users/niku/projects/dialyzer-samples/simple/_build/dev/lib/simple/ebin
  Proceeding with analysis... done in 0m1.72s
done (passed successfully)

先ほど面倒だった dialyzer のセットアップがなくて,かわりに mix dialyzer のコマンド一つですんでいますね.
dialyzer を普段使うなら,こちらを利用した方が楽できそうです.

Dialyze を使う

Dialyxir はシステム全体に対して mix dialyzer というコマンドを提供するツールでした.
それに対し Dialyze はプロジェクト毎に mix dialyze というコマンドを提供するツールのようです.

README を参考に作業しましょう.simple プロジェクトの mix.exs ファイルを以下のように書き換えます.

defmodule Simple.Mixfile do
  use Mix.Project

  def project do
    [app: :simple,
     version: "0.0.1",
     elixir: "~> 1.0",
     deps: deps]
  end

  # Configuration for the OTP application
  #
  # Type `mix help compile.app` for more information
  def application do
    [applications: [:logger]]
  end

  # Dependencies can be Hex packages:
  #
  #   {:mydep, "~> 0.3.0"}
  #
  # Or git/path repositories:
  #
  #   {:mydep, git: "https://github.com/elixir-lang/mydep.git", tag: "0.1.0"}
  #
  # Type `mix help deps` for more examples and options
  defp deps do
    [{:dialyze, "~> 0.1.3"}] # この行を追加
  end
end

そして sample プロジェクトで以下のように実行してみます.

$ cd sample
$ mix do deps.get, deps.compile
Running dependency resolution
Unlocked:   dialyze
Dependency resolution completed successfully
  dialyze: v0.1.3
* Getting dialyze (Hex package)
Checking package (https://s3.amazonaws.com/s3.hex.pm/tarballs/dialyze-0.1.3.tar)
Fetched package
Unpacked package tarball (/Users/niku/.hex/packages/dialyze-0.1.3.tar)
==> dialyze
Compiled lib/mix/tasks/dialyze.ex
Generated dialyze.app
$ mix dialyze
Compiled lib/simple.ex
Compiled lib/client.ex
Generated simple.app
Finding applications for analysis
Finding suitable PLTs
(...snip...)
Analysing 2 modules with dialyze_erlang-17.4_elixir-1.0.2_deps-dev.plt

これはすんごい時間かかりました.十数分くらい.もしかしたら PLT の生成対象としているモジュールの数に違いがあるかもしれません.2

成功だと何も出ないようなので,lib/client.ex のメソッドの中身を Simple.count_atoms [1, 2, 3] に戻して実行してみましょう.

$ mix dialyze
Finding applications for analysis
Finding suitable PLTsLooking up modules in dialyze_erlang-17.4_elixir-1.0.2_deps-dev.pltFinding applications for dialyze_erlang-17.4_elixir-1.0.2_deps-dev.plt
Finding modules for dialyze_erlang-17.4_elixir-1.0.2_deps-dev.plt
Checking 1081 modules in dialyze_erlang-17.4_elixir-1.0.2_deps-dev.pltFinding modules for analysis
Analysing 2 modules with dialyze_erlang-17.4_elixir-1.0.2_deps-dev.plt
lib/client.ex:3: Function other_function/0 has no local return
lib/client.ex:4: The call 'Elixir.Simple':count_atoms([1 | 2 | 3,...]) breaks the contract (atom_list()) -> non_neg_integer()

ふむふむ,先ほどと同じように失敗が表示されましたね.

まとめ

  • Elixir でも Erlang の静的型エラーチェックツール Dialyzer が利用できることがわかりました.
  • 手動だと少し面倒なので,mix コマンドから実行できる DialyxirDialyze というツールがあります

個人的には

  • 手動だと面倒なので,ツールを使いたい.
  • チェックの結果は Dialyxir の方が速く表示される

ので,2014-12-23 現在 Elixir で dialyzer を利用するなら Dialyxir がいいかなあと思いました.

明日は @keithseahus さんです.たのしみー!


  1. 引数に atom のリストを期待しているのに, integer のリストが渡されている 

  2. Dialyxir は必要なところだけ,Dialyze は全てのモジュールを対象にしている?