型指定とErlang

PLTは最高のサンドイッチです

a BLT sandwich

型(または型の欠如)で、Erlangの型エラーを見つけるためのツールであるDialyzerを紹介しました。この章では、Dialyzerに焦点を当て、Erlangで型エラーを見つける方法、およびこのツールを使用して他の種類の不一致を見つける方法について詳しく説明します。まず、Dialyzerが作成された理由、その背後にある指針となる原則と型関連エラーを見つけるための機能、そして最後に、いくつかの使用例を見ていきます。

Dialyzerは、Erlangコードを分析する上で非常に効果的なツールです。実行されることのないコードなど、あらゆる種類の不一致を見つけるために使用されますが、主な用途は通常、Erlangコードベースの型エラーを見つけることにあります。

詳細を見る前に、Dialyzerの *永続ルックアップテーブル* ( *PLT* ) を作成します。これは、標準的なErlangディストリビューションの一部であるアプリケーションとモジュール、およびOTP以外のコードに関するDialyzerが識別できるすべての詳細のコンパイルです。特に、HiPEによるネイティブコンパイルがないプラットフォーム(つまりWindows)や、古いバージョンのErlangで実行している場合は、コンパイルにかなりの時間がかかります。幸いなことに、時間の経過とともに処理速度が向上する傾向があり、新しいリリース(R15B02以降)の最新バージョンのErlangでは、並列Dialyzerが導入され、さらに高速化されています。次のコマンドをターミナルに入力し、必要なだけ実行してください。

$ dialyzer --build_plt --apps erts kernel stdlib crypto mnesia sasl common_test eunit
Compiling some key modules to native code... done in 1m19.99s
Creating PLT /Users/ferd/.dialyzer_plt ...
eunit_test.erl:302: Call to missing or unexported function eunit_test:nonexisting_function/0
Unknown functions:
compile:file/2
compile:forms/2
...
xref:stop/1
Unknown types:
compile:option/0
done in 6m39.15s
done (warnings were emitted)

このコマンドは、含めるOTPアプリケーションを指定することでPLTを構築します。Dialyzerは型エラーを検索する際に不明な関数に対処できるため、警告は無視しても問題ありません。この章の後半で、その型推論アルゴリズムの仕組みについて説明する際に、その理由がわかります。Windowsユーザーの中には、「DialyzerがデフォルトのPLTの場所を認識するために、HOME環境変数を設定する必要があります」というエラーメッセージが表示される場合があります。これは、Windowsには常にHOME環境変数が設定されているとは限らず、DialyzerがPLTをダンプする場所を認識しないためです。変数を、Dialyzerにファイルを配置させたい場所に設定してください。

必要に応じて、--appsの後に続くシーケンスにsslreltoolなどのアプリケーションを追加するか、PLTが既に構築されている場合は、以下のように呼び出すことができます。

$ dialyzer --add_to_plt --apps ssl reltool

独自のアプリケーションまたはモジュールをPLTに追加する場合は、-r Directoriesを使用して実行できます。これにより、すべての.erlファイルまたは.beamファイル(debug_infoでコンパイルされている限り)が検索され、PLTに追加されます。

さらに、Dialyzerでは、実行するコマンドのいずれかで--plt Nameオプションを指定してPLTを複数指定し、特定のPLTを選択できます。または、含まれるモジュールがPLT間で共有されていない多くの *非連結PLT* を構築した場合、--plts Name1 Name2 ... NameNを使用してそれらを「マージ」できます。これは、異なるプロジェクトや異なるErlangバージョンに対してシステムに異なるPLTを持つ必要がある場合に特に便利です。これの欠点は、マージされたPLTから取得された情報は、すべての情報が単一のPLTに含まれている場合ほど正確ではないということです。

PLTが構築されている間、Dialyzerの型エラー検出メカニズムについて理解しておきましょう。

成功型付け

他のほとんどの動的プログラミング言語と同様に、Erlangプログラムは常に型エラーの影響を受けるリスクがあります。プログラマーが関数に間違った引数を渡したり、適切にテストすることを忘れてしまう場合があります。プログラムがデプロイされ、すべてが順調に進んでいるように見えます。しかし、午前4時に、会社の運用担当者の携帯電話が鳴り始めます。ソフトウェアが繰り返しクラッシュし、管理者がそのエラーの多さに対応できなくなっているのです。

Atlas lifting a rock with bad practice terms such as 'no tests', 'typos', 'large messages', 'bugs', etc.

翌朝、オフィスに到着すると、コンピューターがフォーマットされ、車が傷つけられ、運用担当者によってコミット権限が取り消されていることに気づきます。彼は、あなたのミスによって自分の仕事のスケジュールが何度も狂わされたことにうんざりしていたのです。

実行前にプログラムを検証する静的型分析器を持つコンパイラがあれば、このような事態全体を防ぐことができました。Erlangは他の動的言語ほど型システムを必要としませんが、ランタイムエラーへの反応的なアプローチのおかげで、早期の型関連エラー検出によって通常提供される追加の安全性から恩恵を受けるのは間違いなく良いことです。

通常、静的型システムを持つ言語は、そのような方法で設計されています。言語のセマンティクスは、許可されるものと許可されないものにおいて、型システムによって大きく影響を受けます。たとえば、次のような関数があります。

foo(X) when is_integer(X) -> X + 1;
foo(X) -> list_to_atom(X).

ほとんどの型システムは、上記の関数の型を適切に表現できません。整数またはリストを受け取り、整数またはアトムを返すことができることはわかりますが、関数の入力型とその出力型間の依存関係を追跡しません(条件付き型と交差型は可能ですが、冗長になる可能性があります)。つまり、Erlangでは完全に通常のこのような関数を記述すると、後でコードで使用された場合、型分析器にとって不確実性が生じる可能性があります。

一般的に、分析器は、実行時に型エラーが発生しないことを実際に *証明* しようとします。つまり、状況によっては、クラッシュにつながる可能性のある不確実性を排除するために、型チェッカーが特定の実用的な有効な操作を許可しない場合があります。

そのような型システムを実装すると、Erlangのセマンティクスを変更せざるを得なくなる可能性があります。問題は、Dialyzerが登場するまでに、Erlangはすでに非常に大規模なプロジェクトで広く使用されていたことです。Dialyzerのようなツールが受け入れられるためには、Erlangの哲学を尊重する必要がありました。Erlangが実行時にしか解決できない純粋なナンセンスな型を許可するなら、それはそれで構いません。型チェッカーが文句を言う権利はありません。何ヶ月も本番環境で動作していたプログラムが実行できないと告げるツールを気に入るプログラマーはいません!

もう1つの選択肢は、エラーの不存在を *証明* しないが、可能な限り多くのエラーを検出するための最善の努力をする型システムを持つことです。そのような検出を非常に優れたものにすることができますが、決して完璧になることはありません。それはトレードオフです。

したがって、Dialyzerの型システムは、型に関する限り、プログラムにエラーがないことを証明するのではなく、現実世界で起こることと矛盾することなく、できるだけ多くのエラーを見つけることを決定しました。

私たちの主な目標は、Erlangコードの暗黙的な型情報を明らかにし、プログラムで明示的に使用できるようにすることです。一般的なErlangアプリケーションのサイズを考慮すると、型推論は完全に自動化され、言語の動作セマンティクスを忠実に尊重する必要があります。さらに、いかなるコードの書き換えも課してはなりません。これは単純な理由からです。型推論器を満たすためだけに、数十万行のコードで構成される、多くの場合安全性が重要なアプリケーションを書き換えることは、成功する可能性はほとんどありません。しかし、大規模なソフトウェアアプリケーションは保守する必要があり、多くの場合、元の作成者によって保守されるわけではありません。既に存在する型情報を自動的に明らかにすることで、プログラムと共に進化し、腐敗しない自動的なドキュメントを提供します。また、精度と可読性のバランスを取ることも重要だと考えています。最後に、推論された型付けは決して間違ってはいけません。

Dialyzerを支えるSuccess Typings論文で説明されているように、Erlangのような言語の型チェッカーは、型宣言がない(ヒントを受け入れるものの)状態で動作し、シンプルで可読性が高く、言語に適応する(逆ではない)必要があり、クラッシュを保証する型エラーについてのみ警告する必要があります。

したがって、Dialyzerは、すべての関数が正常であると楽観的に仮定して、分析を開始します。すべての関数は常に成功し、何でも受け入れ、何でも返すものとみなされます。未知の関数の使用方法に関係なく、それが良い使用方法です。これが、PLTを生成する際の未知の関数に関する警告が大きな問題にならない理由です。とにかくすべて大丈夫です。Dialyzerは型推論に関しては楽観主義者です。

分析が進むにつれて、Dialyzerは関数をより深く理解していきます。そうすることで、コードを分析して興味深い点を見つけることができます。関数の引数の間に+演算子があり、加算の結果を返すものがあるとします。Dialyzerは、関数が何でも受け入れ、何でも返すものと仮定しなくなります。代わりに、引数が数値(整数または浮動小数点値)であることを期待し、返される値も同様に数値になります。この関数には、2つの数値を受け入れ、数値を返すという基本的な型が関連付けられます。

さて、関数の1つがアトムと数値で上記で説明した関数に呼び出しを行うとしましょう。Dialyzerはコードについて考え、「ちょっと待って、アトムと数値を+演算子と一緒に使うことはできません!」と考えます。次に、関数は以前は数値を返す可能性がありましたが、使用方法によっては何も返せなくなるため、パニックになります。

しかし、より一般的な状況では、 *時々* エラーを引き起こすことがわかっている多くの事柄について、Dialyzerが無言を続ける可能性があります。たとえば、次のようなコードスニペットを考えてみましょう。

main() ->
    X = case fetch() of
        1 -> some_atom;
        2 -> 3.14
    end,
    convert(X).

convert(X) when is_atom(X) -> {atom, X}.

このコードスニペットは、1または2を返すfetch/0関数の存在を前提としています。これに基づいて、アトムまたは浮動小数点数を返します。

私たちの視点から見ると、ある時点でconvert/1への呼び出しが失敗するようです。fetch()が2を返し、浮動小数点値をconvert/1に送信するたびに、そこで型エラーが発生すると予想する可能性があります。Dialyzerはそうは思いません。覚えておいてください、Dialyzerは楽観的です。あなたのコードを文字通り信じているため、convert/1への関数呼び出しが成功する *可能性* があるため、Dialyzerは黙っています。この場合、型エラーは報告されません。

型推論と不一致

上記の原則の実用的な例として、いくつかのモジュールでDialyzerを試してみましょう。モジュールはdiscrep1.erldiscrep2.erl、およびdiscrep3.erlです。これらは互いに進化したものです。最初のモジュールは次のとおりです。

-module(discrep1).
-export([run/0]).

run() -> some_op(5, you).

some_op(A, B) -> A + B.

このエラーは比較的明らかです。アトムyou5を加算することはできません。PLTが作成されていると仮定して、このコードでDialyzerを試すことができます。

$ dialyzer discrep1.erl
  Checking whether the PLT /home/ferd/.dialyzer_plt is up-to-date... yes
  Proceeding with analysis...
discrep1.erl:4: Function run/0 has no local return
discrep1.erl:4: The call discrep1:some_op(5,'you') will never return since it differs in the 2nd argument from the success typing arguments: (number(),number())
discrep1.erl:6: Function some_op/2 has no local return
discrep1.erl:6: The call erlang:'+'(A::5,B::'you') will never return since it differs in the 2nd argument from the success typing arguments: (number(),number())
 done in 0m0.62s
done (warnings were emitted)

面白いですね、Dialyzerが何かを見つけました。これはどういう意味でしょうか?最初のエラーは、Dialyzerを使用する際に非常に頻繁に見られるエラーです。「Function Name/Arity has no local return」は、関数が呼び出す関数の1つがDialyzerの型エラー検出器に引っかかったり、例外を発生させたりするため、関数が何も返さない(例外を発生させる場合を除く)ことが証明された場合に発行される標準的なDialyzer警告です。このようなことが起こると、関数が返す可能性のある値の型のセットは空になります。実際には何も返しません。このエラーは、それを呼び出した関数に伝播し、「no local return」エラーが発生します。

2番目のエラーはやや理解しやすいです。some_op(5, 'you')を呼び出すと、Dialyzerが検出した、関数を動作させるために必要な型(number()number())が壊れると述べています。表記は今のところ少しわかりにくいですが、すぐに詳しく見ていきます。

3つ目のエラーは、再びローカルでない戻り値に関するものです。1つ目は`some_op/2`が失敗するためでしたが、これは`+`呼び出しが失敗するためです。これが4つ目にして最後のエラーに関する内容です。プラス演算子(実際には関数`erlang:'+'/2`)は、数値`5`とアトム`you`を足し合わせることはできません。

discrep2.erlはどうでしょうか? これがその見た目です。

-module(discrep2).
-export([run/0]).

run() ->
    Tup = money(5, you),
    some_op(count(Tup), account(Tup)).

money(Num, Name) -> {give, Num, Name}.
count({give, Num, _}) -> Num.
account({give, _, X}) -> X.

some_op(A, B) -> A + B.

このファイルを再度Dialyzerで実行すると、以前と同様のエラーが発生します。

$ dialyzer discrep2.erl
  Checking whether the PLT /home/ferd/.dialyzer_plt is up-to-date... yes
  Proceeding with analysis...
discrep2.erl:4: Function run/0 has no local return
discrep2.erl:6: The call discrep2:some_op(5,'you') will never return since it differs in the 2nd argument from the success typing arguments: (number(),number())
discrep2.erl:12: Function some_op/2 has no local return
discrep2.erl:12: The call erlang:'+'(A::5,B::'you') will never return since it differs in the 2nd argument from the success typing arguments: (number(),number())
 done in 0m0.69s
done (warnings were emitted)

Dialyzerは解析中に、`count/1`関数と`account/1`関数を透過的に型を認識します。タプルの各要素の型を推論し、それらが渡す値を推測します。その後、問題なくエラーを検出できます。

discrep3.erlを使用して、さらに詳しく見てみましょう。

-module(discrep3).
-export([run/0]).

run() ->
    Tup = money(5, you),
    some_op(item(count, Tup), item(account, Tup)).

money(Num, Name) -> {give, Num, Name}.

item(count, {give, X, _}) -> X;
item(account, {give, _, X}) -> X.

some_op(A, B) -> A + B.

このバージョンでは、間接参照のレベルが新しく導入されています。カウント値とアカウント値について明確に定義された関数を使用する代わりに、これはアトムを使用し、異なる関数節に切り替えます。Dialyzerを実行すると、次のようになります。

$ dialyzer discrep3.erl
  Checking whether the PLT /home/ferd/.dialyzer_plt is up-to-date... yes
  Proceeding with analysis... done in 0m0.70s
done (passed successfully)
A check for 5 cents made to 'YOU!'

おっと。何らかの理由でファイルへの新しい変更により、状況が複雑になりすぎて、Dialyzerが型定義に迷子になりました。ただし、エラーはまだ存在します。Dialyzerがこのファイルのエラーを検出しない理由と、その修正方法については後で説明しますが、現時点では、Dialyzerを実行する方法は他にもいくつかあります。

例えば、Process QuestリリースでDialyzerを実行したい場合は、次のように実行できます。

$ cd processquest/apps
$ ls
processquest-1.0.0  processquest-1.1.0  regis-1.0.0  regis-1.1.0  sockserv-1.0.0  sockserv-1.0.1

たくさんのライブラリがあります。Dialyzerは、同じ名前のモジュールが多数あるとエラーを発生させるため、ディレクトリを手動で指定する必要があります。

$ dialyzer -r processquest-1.1.0/src regis-1.1.0/src sockserv-1.0.1/src
Checking whether the PLT /home/ferd/.dialyzer_plt is up-to-date... yes
Proceeding with analysis...
dialyzer: Analysis failed with error:
No .beam files to analyze (no --src specified?)

そうです。デフォルトでは、Dialyzerは`.beam`ファイルを検索します。`.erl`ファイルを解析に使用するには、`--src`フラグを追加する必要があります。

$ dialyzer -r processquest-1.1.0/src regis-1.1.0/src sockserv-1.0.1/src --src
Checking whether the PLT /home/ferd/.dialyzer_plt is up-to-date... yes
Proceeding with analysis... done in 0m2.32s
done (passed successfully)

すべての要求に`src`ディレクトリを追加したことに注意してください。`src`ディレクトリを追加せずに同じ検索を行うこともできますが、その場合、一部のアサーションマクロがコード解析に関してどのように機能するかを基にしたEUnitテストに関する多くのエラーについて、Dialyzerから警告を受けます。これらは実際には気にする必要はありません。さらに、テスト内で意図的にソフトウェアをクラッシュさせるために、失敗をテストすることがある場合、Dialyzerがそれを検出し、それを望まない可能性があります。

型の型の型について

discrep3.erlで見たように、Dialyzerは意図したとおりにすべての型を推論できない場合があります。これは、Dialyzerが私たちの考えを読み取ることができないためです。Dialyzerの作業を支援するために(そして主に自分自身を支援するために)、型を宣言し、関数を注釈付けることで、それらを文書化し、コードに設定した型の暗黙的な期待を公式化することができます。

Erlangの型は、数値42(`42`として記述され、通常の表記と変わりません)や`cat`、`molecule`などの特定のアトムなど、単純なものでありえます。これらは、値自体を参照するため、シングルトン型と呼ばれます。次のシングルトン型が存在します。

`some atom` 任意のアトムは、それ自身のシングルトン型になります。
42 指定された整数。
[] 空リスト。
{} 空タプル。
<<>> 空バイナリ。

これらの型のみを使用してErlangをプログラミングするのは面倒だとわかるでしょう。シングルトン型を使用することで、年齢や、プログラムの「すべての整数」などを表現する方法はありません。そして、一度に多くの型を指定する方法があったとしても、すべてを手動で記述せずに「任意の整数」などのことを表現するのは非常に面倒です。これは実際には不可能です。

そのため、Erlangにはユニオン型があり、これにより、2つのアトムを含む型を記述できます。また、組み込み型があり、これは事前に定義された型であり、必ずしも手動で構築できるわけではなく、一般的に役立ちます。ユニオン型と組み込み型は、一般的に同様の構文を共有しており、`TypeName()`という形式で表記されます。たとえば、すべての可能な整数の型は`integer()`と表記されます。括弧を使用する理由は、すべてのアトムの型`atom()`と、特定のアトム`atom`のアトム`atom`を区別するためです。さらに、コードを明確にするために、多くのErlangプログラマーは型宣言内のすべてのアトムを引用符で囲んで`'atom'`を使用します。これにより、`'atom'`がシングルトン型であり、プログラマーが括弧を忘れた組み込み型ではないことが明示されます。

以下は、言語で提供されている組み込み型の表です。それらはすべて、ユニオン型と同じ構文を持つわけではないことに注意してください。バイナリやタプルなど、一部には、使いやすくするための特別な構文があります。

any() 任意のErlang項。
none() これは、項または型が無効であることを意味する特別な型です。通常、Dialyzerが関数の可能な戻り値を`none()`に絞り込んだ場合、関数はクラッシュするはずです。「これは機能しません」と同義です。
pid() プロセス識別子。
port() ポートは、ファイル記述子(Erlangライブラリの内部を深く掘り下げない限り、めったに見ません)、ソケット、または一般的に`erlang:open_port/2`関数など、Erlangが外部世界と通信できるようにするものの基盤となる表現です。Erlangシェルでは、`#Port<0.638>`のように見えます。
reference() `make_ref()`または`erlang:monitor/2`によって返される一意の値。
atom() 一般的にアトム。
binary() バイナリデータの塊。
<<_:Integer>> 既知のサイズのバイナリ。ここで、Integerはサイズです。
<<_:_*Integer>> 指定された単位サイズを持つが、長さが不明なバイナリ。
<<_:Integer, _:_*OtherInteger>> バイナリが最小長を持つことができるように指定するための、前述の2つの形式の組み合わせ。
integer() 任意の整数。
N..M 整数の範囲。たとえば、1年の月の数を表す場合は、範囲`1..12`を定義できます。Dialyzerは、この範囲をより大きな範囲に拡張する権利を留保しています。
non_neg_integer() 0以上の整数。
pos_integer() 0より大きい整数。
neg_integer() -1までの整数
float() 任意の浮動小数点数。
fun() 任意の種類の関数。
fun((...) -> Type) 任意の引数個数を取り、指定された型を返す無名関数。リストを返す指定された関数は`fun((...) -> list())`と表記できます。
fun(() -> Type) 引数を取らず、指定された型の項を返す無名関数。
fun((Type1, Type2, ..., TypeN) -> Type) 既知の型の指定された数の引数を取る無名関数。整数と浮動小数点値を処理する関数の例は`fun((integer(), float()) -> any())`と宣言できます。
[] 空リスト。
[Type()] 指定された型を含むリスト。整数のリストは`[integer()]`と定義できます。または`list(Type())`と書くこともできます。型`list()`は`[any()]`の略記です。リストは不適切な場合があります(`[1, 2 | a]`など)。そのため、Dialyzerには`improper_list(TypeList, TypeEnd)`で不適切なリストについて宣言された型があります。たとえば、不適切なリスト`[1, 2 | a]`は`improper_list(integer(), atom())`と型付けできます。さらに複雑なことに、リストが適切かどうかが実際にはわからない場合があります。そのような状況では、`maybe_improper_list(TypeList, TypeEnd)`型を使用できます。
[Type(), ...] これは`[Type()]`の特別なケースであり、リストが空ではないことを意味します。
tuple() 任意のタプル。
{Type1, Type2, ..., TypeN} 既知のサイズと既知の型を持つタプル。たとえば、バイナリツリーノードは`{'node', any(), any(), any(), any()}`と定義でき、`{'node', LeftTree, RightTree, Key, Value}`に対応します。
A venn diagram. The leftmost circle is a gold ingot, the rightmost one is a shower head. In the center is a pixelated and censored coloured bit

上記の組み込み型を考えると、Erlangプログラムの型を定義する方法を想像するのは少し簡単になります。それでも一部は不足しています。ニーズに合わない、曖昧すぎる場合があります。`discrepN`モジュールのエラーの1つで`number()`型が言及されていることを覚えています。その型は、シングルトン型でも組み込み型でもありません。つまり、ユニオン型であり、自分で定義できます。

型のユニオンを表す表記はパイプ(`|`)です。基本的に、これにより、指定された型TypeNameは`Type1 | Type2 | ... | TypeN`のユニオンとして表されます。したがって、整数と浮動小数点値を含む`number()`型は`integer() | float()`と表すことができます。ブール値は`'true' | 'false'`と定義できます。他の型が1つだけ使用される型を定義することもできます。ユニオン型のように見えますが、実際はエイリアスです。

実際、このようなエイリアスとユニオン型は、事前に多く定義されています。以下はそれらのいくつかです。

term() これは`any()`と同等であり、他のツールが以前`term()`を使用していたため追加されました。または、`_`変数を`term()`と`any()`の両方のエイリアスとして使用できます。
boolean() `'true' | 'false`
byte() `0..255`として定義され、存在する任意の有効なバイトです。
char() `0..16#10ffff`として定義されていますが、この型が文字の特定の規格を参照しているかどうかは明確ではありません。競合を回避するために、非常に一般的なアプローチがとられています。
number() integer() | float()
maybe_improper_list() これは、一般的に不適切なリストの`maybe_improper_list(any(), any())`の簡単なエイリアスです。
maybe_improper_list(T) ここで、Tは任意の指定された型です。これは`maybe_improper_list(T, any())`のエイリアスです。
string() 文字列は[char()]、つまり文字のリストとして定義されます。また、nonempty_string()[char(), ...]として定義されます。残念ながら、バイナリ文字列専用の文字列型はまだありませんが、これはバイナリ文字列が、選択した任意の型で解釈されるデータの塊であるためです。
iolist() 良い旧iolistです。これらはmaybe_improper_list(char() | binary() | iolist(), binary() | [])として定義されています。iolist自体がiolistの観点から定義されていることがわかります。DialyzerはR13B04から再帰型をサポートしています。それ以前は使用できず、iolistのような型は面倒な体操を通してしか定義できませんでした。
module() これはモジュール名を表す型であり、現在はatom()のエイリアスです。
timeout() non_neg_integer() | 'infinity'
node() Erlangのノード名で、アトムです。
no_return() これは関数の戻り値型で使用することを目的としたnone()のエイリアスです。(理想的には)永遠にループし、戻らない関数を注釈付けるために特に使用されます。

すでにいくつかの型ができました。前に、{'node', any(), any(), any(), any()}と書かれたツリーの型について述べました。型についてもう少し詳しくなったので、モジュールで宣言できます。

モジュールでの型宣言の構文は次のとおりです。

-type TypeName() :: TypeDefinition.

そのため、ツリーは次のように定義できます。

-type tree() :: {'node', tree(), tree(), any(), any()}.

または、変数名を型コメントとして使用できる特別な構文を使用して

-type tree() :: {'node', Left::tree(), Right::tree(), Key::any(), Value::any()}.

しかし、この定義ではツリーが空になることを許していないため機能しません。より良いツリー定義は、tree.erlモジュールで再帰で行ったように、再帰的に考えることで構築できます。そのモジュールでは、空のツリーは{node, 'nil'}として定義されています。再帰関数でそのようなノードに遭遇すると、停止します。通常の非空ノードは{node, Key, Val, Left, Right}として示されます。これを型に変換すると、次の形式のツリーノードが得られます。

-type tree() :: {'node', 'nil'}
              | {'node', Key::any(), Val::any(), Left::tree(), Right::tree()}.

このように、空のノードまたは非空のノードのいずれかであるツリーが得られます。{'node', 'nil'}の代わりに'nil'を使用しても、Dialyzerは問題ありません。単にtreeモジュールの書き方に準拠したかっただけです。型を付けたいがまだ考えていないErlangコードの別の部分があります...

レコードはどうでしょうか?レコードには、型を宣言するための便利な構文があります。それを確認するために、#user{}レコードを考えてみましょう。ユーザーの名前、特定のメモ(tree()型を使用)、ユーザーの年齢、友達のリスト、短い伝記を保存したいと考えています。

-record(user, {name="" :: string(),
               notes :: tree(),
               age :: non_neg_integer(),
               friends=[] :: [#user{}],
               bio :: string() | binary()}).

型宣言の一般的なレコード構文はField :: Typeであり、デフォルト値を指定する場合はField = Default :: Typeになります。上記のレコードでは、名前は文字列である必要があり、メモはツリーである必要があり、年齢は0から無限大までの整数である必要があります(人の年齢がどれくらいになるかはわかりません!)。興味深いフィールドはfriendsです。[#user{}]型は、ユーザーレコードが0個以上の他のユーザーレコードのリストを保持できることを意味します。また、#RecordName{}として書くことで、レコードを型として使用できることも示しています。最後の部分は、伝記が文字列またはバイナリになる可能性があることを示しています。

さらに、型宣言と定義により統一されたスタイルを与えるために、-type Type() :: #Record{}.などのエイリアスを追加する傾向があります。また、user()型を使用してfriendsの定義を変更することもでき、次のようになります。

-record(user, {name = "" :: string(),
               notes :: tree(),
               age :: non_neg_integer(),
               friends=[] :: [user()],
               bio :: string() | binary()}).

-type user() :: #user{}.

レコードのすべてのフィールドに型を定義しましたが、デフォルト値のないものもあります。#user{age=5}としてユーザーレコードインスタンスを作成した場合、型エラーは発生しません。デフォルト値が提供されていない場合、すべてのレコードフィールド定義には暗黙的に'undefined'のユニオンが追加されます。以前のバージョンでは、この宣言により型エラーが発生していました。

関数の型付け

型を一日中定義して、ファイルに一杯に詰め込み、印刷して額装し、達成感を感じることができますが、Dialyzerの型推論エンジンでは自動的に使用されません。Dialyzerは、宣言した型から実行できることとできないことを絞り込むために動作しません。

では、なぜこれらの型を宣言するのでしょうか?ドキュメント?部分的にそうです。Dialyzerに宣言した型を理解させるには、追加の手順が必要です。強化したいすべての関数に型シグネチャ宣言を散りばめ、型宣言とモジュール内の関数を橋渡しする必要があります。

5 playing cards, the 3 of clubs, ace of diamonds, 3 of spades, 3 of hearts, 7 of diamonds

この章の大部分は「これとあれの構文はこうだ」といった内容でしたが、実践的な内容に移ります。型付けが必要なものの簡単な例として、トランプゲームを考えてみましょう。スペード、クラブ、ハート、ダイヤの4つのスートがあります。カードは1から10まで番号付けでき(エースは1)、ジャック、クイーン、キングもあります。

通常は、スペードのエースを{spades, 1}として持つことができるように、カードを{Suit, CardValue}として表します。空中に浮かせたままにするのではなく、これらを表す型を定義できます。

-type suit() :: spades | clubs | hearts | diamonds.
-type value() :: 1..10 | j | q | k.
-type card() :: {suit(), value()}.

suit()型は、スートを表すことができる4つのアトムの単純なユニオンです。値は1から10までの任意のカード(1..10)、または絵札を表すjqkです。card()型はそれらをタプルとして結合します。

これらの3つの型を使用して、通常の関数でカードを表し、いくつかの興味深い保証を得ることができます。たとえば、次のcards.erlモジュールを見てください。

-module(cards).
-export([kind/1, main/0]).

-type suit() :: spades | clubs | hearts | diamonds.
-type value() :: 1..10 | j | q | k.
-type card() :: {suit(), value()}.

kind({_, A}) when A >= 1, A =< 10 -> number;
kind(_) -> face.

main() ->
    number = kind({spades, 7}),
    face   = kind({hearts, k}),
    number = kind({rubies, 4}),
    face   = kind({clubs, q}).

kind/1関数は、カードが絵札か数字のカードかを返す必要があります。スートは決してチェックされないことに注意してください。main/0関数では、3回目の呼び出しがrubiesスートで行われていることがわかります。これは、型で意図したものではなく、kind/1関数でもおそらく意図したものではありません。

$ erl
...
1> c(cards).
{ok,cards}
2> cards:main().
face

すべて正常に動作します。そうあるべきではありません。Dialyzerを実行しても何も起こりません。しかし、次の型シグネチャをkind/1関数に追加すると

-spec kind(card()) -> face | number.
kind({_, A}) when A >= 1, A =< 10 -> number;
kind(_) -> face.

さらに興味深いことが起こります。Dialyzerを実行する前に、その仕組みを見てみましょう。型シグネチャの形式は-spec FunctionName(ArgumentTypes) -> ReturnTypes.です。上記の仕様では、kind/1関数は作成したcard()型に従ってカードを引数として受け入れることを示しています。また、関数はアトムfaceまたはnumberを返します。

モジュールでDialyzerを実行すると、次のようになります。

$ dialyzer cards.erl
  Checking whether the PLT /home/ferd/.dialyzer_plt is up-to-date... yes
  Proceeding with analysis...
cards.erl:12: Function main/0 has no local return
cards.erl:15: The call cards:kind({'rubies',4}) breaks the contract (card()) -> 'face' | 'number'
 done in 0m0.80s
done (warnings were emitted)
A contract, ripped in two, saying 'I will always say the truth no matter what' signed by 'Spec'

大変な状況です。rubiesスートを持つ「カード」でkind/1を呼び出すことは、仕様に従って有効ではありません。

この場合、Dialyzerは指定した型シグネチャを尊重し、main/0関数を分析すると、そこにkind/1の不正な使用があることがわかります。これにより、15行目(number = kind({rubies, 4}),)からの警告が表示されます。Dialyzerはその後、型シグネチャが信頼できるものと仮定し、コードがそれに従って使用される場合、論理的に有効ではないとします。この契約違反はmain/0関数に伝播しますが、そのレベルでは失敗する理由についてほとんど言及できません。単に失敗するということです。

注記: Dialyzerは、型仕様が定義された場合にのみこれについて文句を言いました。型シグネチャが追加される前に、Dialyzerはcard()引数でのみkind/1を使用することを計画していると仮定できませんでした。シグネチャがあれば、それを独自の型定義として使用できます。

型付けするもう1つの興味深い関数は、convert.erlにあります。

-module(convert).
-export([main/0]).

main() ->
    [_,_] = convert({a,b}),
    {_,_} = convert([a,b]),
    [_,_] = convert([a,b]),
    {_,_} = convert({a,b}).

convert(Tup) when is_tuple(Tup) -> tuple_to_list(Tup);
convert(L = [_|_]) -> list_to_tuple(L).

コードを読むと、convert/1への最後の2つの呼び出しは失敗することが明らかです。関数はリストを受け取りタプルを返し、またはタプルを受け取りリストを返します。ただし、コードでDialyzerを実行しても、何も見つかりません。

これは、Dialyzerが次のような型シグネチャを推論するためです。

-spec convert(list() | tuple()) -> list() | tuple().

言い換えると、関数はリストとタプルを受け入れ、リストをタプルで返します。これは事実ですが、残念ながら少しtoo事実です。関数は型シグネチャが暗示するほど許容的ではありません。これは、Dialyzerが座って、問題を100%確信していない限りあまり多くを言わないようにしようとする場所の1つです。

Dialyzerを少し助けるために、より高度な型宣言を送信できます。

-spec convert(tuple()) -> list();
             (list()) -> tuple().
convert(Tup) when is_tuple(Tup) -> tuple_to_list(Tup);
convert(L = [_|_]) -> list_to_tuple(L).

tuple()型とlist()型を単一のユニオンにまとめるのではなく、この構文を使用すると、代替句付きの型シグネチャを宣言できます。タプルでconvert/1を呼び出す場合は、リストを期待し、その逆も同様です。

このより具体的な情報により、Dialyzerはより興味深い結果を出すことができます。

$ dialyzer convert.erl
  Checking whether the PLT /home/ferd/.dialyzer_plt is up-to-date... yes
  Proceeding with analysis...
convert.erl:4: Function main/0 has no local return
convert.erl:7: The pattern [_, _] can never match the type tuple()
 done in 0m0.90s
done (warnings were emitted)

エラーが見つかりました!成功です!これでDialyzerを使用して、私たちが知っていたことを伝えることができます。もちろん、そう言うと無意味に聞こえますが、関数を正しく型付けして、チェックするのを忘れて小さなミスをした場合、Dialyzerがバックアップしてくれるため、エラーログシステムが夜中に起こすよりも間違いなく優れています(またはオペレーション担当者に車を傷つけられるよりも)。

注記:一部の人は、複数句の型シグネチャに次の構文を好みます。

-spec convert(tuple()) -> list()
      ;      (list()) -> tuple().

これはまったく同じですが、セミコロンを別の行に配置することで、より読みやすくなる可能性があります。執筆時点では、広く受け入れられている標準はありません。

このように型定義と仕様を使用することで、実際には、以前のdiscrepモジュールでエラーを見つけることができます。discrep4.erlがどのように行っているかを確認してください。

型付けの練習

FIFO(先入れ先出し)操作のためのキューモジュールを作成しました。Erlangのメールボックスがキューであることを考えると、キューが何かを知っているはずです。追加された最初の要素は、(選択的な受信を行わない限り)最初にポップされる要素になります。モジュールは、すでに何度か見てきたこの画像で説明されているように動作します。

Drawing representing the implementation of a functional queue

キューをシミュレートするために、2つのリストをスタックとして使用します。1つのリストは新しい要素を格納し、もう1つのリストはキューから要素を削除するために使用します。常に同じリストに追加し、2番目のリストから削除します。削除するリストが空になると、アイテムを追加するリストを反転し、それが削除する新しいリストになります。これは一般的に、単一のリストで両方のタスクを実行するよりも、平均パフォーマンスが向上することを保証します。

こちらが私のモジュールです。Dialyzerでチェックするためにいくつかの型シグネチャを追加しました。

-module(fifo_types).
-export([new/0, push/2, pop/1, empty/1]).
-export([test/0]).

-spec new() -> {fifo, [], []}.
new() -> {fifo, [], []}.

-spec push({fifo, In::list(), Out::list()}, term()) -> {fifo, list(), list()}.
push({fifo, In, Out}, X) -> {fifo, [X|In], Out}.

-spec pop({fifo, In::list(), Out::list()}) -> {term(), {fifo, list(), list()}}.
pop({fifo, [], []}) -> erlang:error('empty fifo');
pop({fifo, In, []}) -> pop({fifo, [], lists:reverse(In)});
pop({fifo, In, [H|T]}) -> {H, {fifo, In, T}}.

-spec empty({fifo, [], []}) -> true;
           ({fifo, list(), list()}) -> false.
empty({fifo, [], []}) -> true;
empty({fifo, _, _}) -> false.

test() ->
    N = new(),
    {2, N2} = pop(push(push(new(), 2), 5)),
    {5, N3} = pop(N2),
    N = N3,
    true = empty(N3),
    false = empty(N2),
    pop({fifo, [a|b], [e]}).

キューを{fifo, list(), list()}形式のタプルとして定義しました。fifo()型を定義しなかったことに気づかれると思いますが、これは主に、空のキューと要素を含むキューに対して簡単に異なる節を作成できるようにしたいためです。empty(...)型指定はそれを反映しています。

注記: 関数pop/1では、関数節の1つがerlang:error/1を呼び出しているにもかかわらず、none()型を指定していないことに注意してください。

前述のように、none()型は、特定の関数が返らないことを意味する型です。関数が失敗するか値を返すかのどちらかである場合、値とnone()の両方を返す型として指定しても意味がありません。none()型は常に存在すると仮定され、そのため、Type() | none()というユニオンはType()のみと同じです。

none()が正当化される状況は、erlang:error/1を自分で実装する場合など、常に呼び出し時に失敗する関数を記述する場合です。

上記の型指定はすべて理にかなっているように見えます。念のため、コードレビュー中に、Dialyzerを一緒に実行して結果を確認してください。

$ dialyzer fifo_types.erl
  Checking whether the PLT /home/ferd/.dialyzer_plt is up-to-date... yes
  Proceeding with analysis...
fifo_types.erl:16: Overloaded contract has overlapping domains; such contracts are currently unsupported and are simply ignored
fifo_types.erl:21: Function test/0 has no local return
fifo_types.erl:28: The call fifo_types:pop({'fifo',nonempty_improper_list('a','b'),['e',...]}) breaks the contract ({'fifo',In::[any()],Out::[any()]}) -> {term(),{'fifo',[any()],[any()]}}
 done in 0m0.96s
done (warnings were emitted)

なんてこった。たくさんのエラーが表示されています。そして、呪うべきことに、それらはそれほど読みやすいものではありません。「Function test/0 has no local return」という2番目のエラーは、少なくとも対処方法が分かっているものです。次のエラーにスキップすれば、消えるはずです。

とりあえず、最初のエラー、つまり重複するドメインを持つ契約に関するエラーに焦点を当てましょう。fifo_typesの16行目を見てみましょう。

-spec empty({fifo, [], []}) -> true;
           ({fifo, list(), list()}) -> false.
empty({fifo, [], []}) -> true;
empty({fifo, _, _}) -> false.

では、その重複するドメインとは何でしょうか?ドメインとイメージの数学的概念を参照する必要があります。簡単に言えば、ドメインは関数へのすべての可能な入力値の集合であり、イメージは関数のすべての可能な出力値の集合です。したがって、重複するドメインとは、重複する2つの入力集合を指します。

an url from 'http://example.org/404' with an arrow pointing to the traditional 'broken image' icon, with a caption saying 'an invalid domain leads to an invalid image

問題の原因を見つけるには、list()を確認する必要があります。以前の大規模なテーブルを覚えているなら、list()[any()]とほぼ同じです。そして、これらの両方の型には空のリストも含まれていることを覚えているでしょう。そして、そこに重複するドメインがあります。list()が型として指定されている場合、[]と重複します。これを修正するには、次のように型シグネチャを置き換える必要があります。

-spec empty({fifo, [], []}) -> true;
           ({fifo, nonempty_list(), nonempty_list()}) -> false.

または、代わりに

-spec empty({fifo, [], []}) -> true;
           ({fifo, [any(), ...], [any(), ...]}) -> false.

その後、Dialyzerを再度実行すると、警告がなくなります。しかし、これだけでは不十分です。誰かが{fifo, [a,b], []}を送信してきたとしたらどうでしょうか?Dialyzerがそれを問題視しない場合でも、上記の型指定がこれを考慮していないことは、人間にとって明らかです。仕様は関数の意図した使用方法と一致しません。代わりに、より詳細な情報を提供し、次のアプローチをとることができます。

-spec empty({fifo, [], []}) -> true;
           ({fifo, [any(), ...], []}) -> false;
           ({fifo, [], [any(), ...]}) -> false;
           ({fifo, [any(), ...], [any(), ...]}) -> false.

これらは両方とも機能し、正しい意味を持ちます。

次のエラーへ(複数行に分割しました)

fifo_types.erl:28:
The call fifo_types:pop({'fifo',nonempty_improper_list('a','b'),['e',...]})
breaks the contract
({'fifo',In::[any()],Out::[any()]}) -> {term(),{'fifo',[any()],[any()]}}

人間に翻訳すると、これは28行目に、ファイルで指定した型を破る推論された型を持つpop/1への呼び出しがあることを意味します。

pop({fifo, [a|b], [e]}).

それが呼び出しです。エラーメッセージは、不正なリスト(空ではない)を識別したと述べていますが、これは完全に正しいです。[a|e]は不正なリストです。また、契約を破っているとも述べています。エラーメッセージからわかる次のもの間で破られた型定義に一致させる必要があります。

{'fifo',nonempty_improper_list('a','b'),['e',...]}
{'fifo',In::[any()],Out::[any()]}
{term(),{'fifo',[any()],[any()]}}

問題は3つの方法のいずれかで説明できます。

  1. 型シグネチャは正しく、呼び出しは正しく、問題は期待される戻り値です。
  2. 型シグネチャは正しく、呼び出しは間違っており、問題は与えられた入力値です。
  3. 呼び出しは正しいが、型シグネチャは間違っている。

最初のものをすぐに排除できます。実際には戻り値を何も処理していません。残りは2番目と3番目の選択肢です。決定は、不正なリストをキューで使用したいかどうかによって異なります。これはライブラリの作成者によって行われる判断であり、不正なリストをこのコードで使用することを意図していなかったことは間違いありません。実際、不正なリストを使用したいことはめったにありません。勝者は2番目、呼び出しが間違っている、です。解決するには、呼び出しを削除するか修正します。

test() ->
    N = new(),
    {2, N2} = pop(push(push(new(), 2), 5)),
    ...
    pop({fifo, [a, b], [e]}).

そして、Dialyzerを再度実行します。

$ dialyzer fifo_types.erl
  Checking whether the PLT /home/ferd/.dialyzer_plt is up-to-date... yes
  Proceeding with analysis... done in 0m0.90s
done (passed successfully)

これで、より意味が通ります。

型のエクスポート

それは非常に良いです。型があり、シグネチャがあり、追加の安全性と検証があります。他のモジュールでキューを使用したい場合はどうなりますか?dictgb_trees、またはETSテーブルなど、頻繁に使用する他のモジュールはどうでしょうか?Dialyzerを使用して、それらに関連する型エラーを見つけるにはどうすればよいですか?

他のモジュールからの型を使用できます。そうするには、通常、ドキュメントを調べてそれらを見つける必要があります。たとえば、etsモジュールのドキュメントには、次のエントリがあります。

---
DATA TYPES

continuation()
    Opaque continuation used by select/1 and select/3.

match_spec() = [{match_pattern(), [term()], [term()]}]
    A match specification, see above.

match_pattern() = atom() | tuple()

tab() = atom() | tid()

tid()
    A table identifier, as returned by new/2.
---

これらはetsによってエクスポートされるデータ型です。ETSテーブル、キーを受け入れ、一致するエントリを返す型指定がある場合、おそらく次のように定義できます。

-spec match(ets:tab(), Key::any()) -> Entry::any().

そして、それはすべてです。

独自の型をエクスポートする方法は、関数の場合とほぼ同じです。-export_type([TypeName/Arity]).という形式のモジュール属性を追加するだけで済みます。たとえば、次の行を追加することで、cardsモジュールからcard()型をエクスポートできます。

-module(cards).
-export([kind/1, main/0]).

-type suit() :: spades | clubs | hearts | diamonds.
-type value() :: 1..10 | j | q | k.
-type card() :: {suit(), value()}.

-export_type([card/0]).
...

そして、その時点から、モジュールがDialyzerに表示されている場合(PLTに追加するか、他のモジュールと同時に分析するか)、型指定で他のコードからcards:card()として参照できます。

A VHS tape saying 'mom and dad wedding night', with a caption that says 'some things are better left unseen'

ただし、これを行うと1つの欠点があります。このように型を使用しても、カードモジュールを使用する人が型をばらばらにして操作することを禁じるわけではありません。誰かがカードに一致するコード片を書いている可能性があります。{Suit, _} = ...のようなものです。これは常に良い考えではありません。将来、cardsモジュールの実装を変更できなくなります。これは、dictfifo_types(型をエクスポートする場合)などのデータ構造を表すモジュールで特に適用したいことです。

Dialyzerを使用すると、ユーザーに「知っていますか?あなたの型を使用してもかまいませんが、内部を覗き見しないでください!」と伝える方法で型をエクスポートできます。これは、次のような宣言を

-type fifo() :: {fifo, list(), list()}.

次のように置き換えるという問題です。

-opaque fifo() :: {fifo, list(), list()}.

その後、-export_type([fifo/0])としてエクスポートできます。

型を不透明として宣言することは、型を定義したモジュールだけが、その作成方法を見て変更を行う権利を持つことを意味します。他のモジュールが全体以外の値にパターンマッチングすることを禁止し、(Dialyzerを使用している場合)実装の突然の変更によって影響を受けることがなくなることを保証します。

クーレイドを飲みすぎないで
不透明なデータ型の実装は、場合によっては、本来あるべきことを行うのに十分なほど強力ではないか、実際には問題がある(つまりバグがある)場合があります。Dialyzerは、最初に関数の成功型を推論するまで、関数の仕様を考慮しません。

これは、型情報が考慮されていない場合に型がかなり汎用的に見えると、Dialyzerが一部の不透明な型に混乱する可能性があることを意味します。たとえば、card()データ型の不透明なバージョンを分析するDialyzerは、推論後、{atom(), any()}として認識する可能性があります。card()を正しく使用しているモジュールは、そうでなくても型契約を破っているため、Dialyzerが文句を言っているのを見る可能性があります。これは、card()型自体には、Dialyzerが点を結びつけて何が起こっているのかを認識するのに十分な情報が含まれていないためです。

通常、そのようなエラーが発生した場合は、タプルにタグ付けすると役立ちます。-opaque card() :: {suit(), value()}.という形式の型から-opaque card() :: {card, suit(), value()}.に変更すると、Dialyzerが不透明な型で正常に動作するようになる可能性があります。

Dialyzerの実装者は現在、不透明なデータ型の実装を改善し、その推論を強化しようとしています。また、ユーザー提供の仕様をより重要にし、Dialyzerの分析中にそれらをより信頼できるようにしようとしていますが、これはまだ進行中です。

型付きビヘイビア

クライアントとサーバーで、behaviour_info/1関数を使用してビヘイビアを宣言できることを確認しました。この関数をエクスポートするモジュールは、そのビヘイビアに名前を付け、2番目のモジュールは-behaviour(ModName).をモジュール属性として追加することでコールバックを実装できます。

たとえば、gen_serverモジュールのビヘイビア定義は次のとおりです。

behaviour_info(callbacks) ->
    [{init, 1}, {handle_call, 3}, {handle_cast, 2}, {handle_info, 2},
     {terminate, 2}, {code_change, 3}];
behaviour_info(_Other) ->
    undefined.

これの問題は、Dialyzerがその型定義をチェックする方法がないことです。実際、ビヘイビアモジュールがコールバックモジュールが実装する型の種類を指定する方法がなく、したがってDialyzerが何かをする方法がありません。

R15B以降、Erlang/OTPコンパイラがアップグレードされ、-callbackという新しいモジュール属性を処理できるようになりました。-callbackモジュール属性はspecと同様の構文を持ちます。これを使用して関数型を指定すると、behaviour_info/1関数が自動的に宣言され、仕様がモジュールのメタデータに追加され、Dialyzerが機能できるようになります。たとえば、R15B以降のgen_serverの宣言は次のとおりです。

-callback init(Args :: term()) ->
    {ok, State :: term()} | {ok, State :: term(), timeout() | hibernate} |
    {stop, Reason :: term()} | ignore.
-callback handle_call(Request :: term(), From :: {pid(), Tag :: term()},
                      State :: term()) ->
    {reply, Reply :: term(), NewState :: term()} |
    {reply, Reply :: term(), NewState :: term(), timeout() | hibernate} |
    {noreply, NewState :: term()} |
    {noreply, NewState :: term(), timeout() | hibernate} |
    {stop, Reason :: term(), Reply :: term(), NewState :: term()} |
    {stop, Reason :: term(), NewState :: term()}.
-callback handle_cast(Request :: term(), State :: term()) ->
    {noreply, NewState :: term()} |
    {noreply, NewState :: term(), timeout() | hibernate} |
    {stop, Reason :: term(), NewState :: term()}.
-callback handle_info(Info :: timeout() | term(), State :: term()) ->
    {noreply, NewState :: term()} |
    {noreply, NewState :: term(), timeout() | hibernate} |
    {stop, Reason :: term(), NewState :: term()}.
-callback terminate(Reason :: (normal | shutdown | {shutdown, term()} |
                               term()),
                    State :: term()) ->
    term().
-callback code_change(OldVsn :: (term() | {down, term()}), State :: term(),
                      Extra :: term()) ->
    {ok, NewState :: term()} | {error, Reason :: term()}.

そして、ビヘイビアが何かを変更しても、コードが壊れることはありません。ただし、モジュールは-callback形式とbehaviour_info/1関数を同時に使用できないことに注意してください。どちらか一方のみです。つまり、カスタムビヘイビアを作成する場合は、Erlang R15より前のバージョンで使用できるものと、それ以降のバージョンで使用できるものの間に違いがあります。

利点は、新しいモジュールでは、Dialyzerがそこで返される型のエラーをチェックするための分析を実行できることです。

多相型

ああ、なんてセクションタイトルでしょう。*多相型*(または、*パラメータ化された型*)を聞いたことがない場合、これは少し恐ろしく聞こえるかもしれません。幸いなことに、名前から想像するほど複雑ではありません。

ditto with a beard

多相型の必要性は、さまざまなデータ構造の型付けを行う際に、それらが格納できるものをかなり具体的に指定したいと思うことが時々あるという事実から生まれます。この章の前半のキューが、何でも処理する場合、トランプだけを処理する場合、または整数だけを処理する場合があるとします。後者の2つの場合、問題は、Dialyzerに、浮動小数点を整数キューに入れたり、タロットカードをトランプのキューに入れたりしようとしていることを文句を言わせたいということです。

これは、これまで行ってきた方法で厳密に型付けを行うだけでは適用できないことです。多相型が登場します。多相型とは、他の型で「構成」できる型です。幸いなことに、既にそれを実行するための構文を知っています。整数のリストを[integer()]またはlist(integer())として定義できると言ったとき、それらは多相型でした。それは、型を引数として受け入れる型です。

キューに整数またはカードのみを受け入れるようにするには、次のように型を定義できます。

-type queue(Type) :: {fifo, list(Type), list(Type)}.
-export_type([queue/1]).

他のモジュールがfifo/1型を使用したい場合、パラメータ化を行う必要があります。そのため、cardsモジュール内の新しいカードの山は、次のシグネチャを持つことができます。

-spec new() -> fifo:queue(card()).

そして、Dialyzerは可能であれば、モジュールを分析して、それが処理するキューからのカードのみを送信および期待していることを確認しようとします。

実演のために、「Learn You Some Erlang」の執筆がほぼ完了したことを祝うため、動物園を購入することにしました。私たちの動物園には、レッドパンダとイカの2種類の動物がいます。確かに、かなり質素な動物園ですが、入場料を高く設定することを妨げるものではありません。

私たちはプログラマーなので、怠け心から物事を自動化することが好きなので、動物の餌やりを自動化することにしました。少し調査した結果、レッドパンダは竹、鳥、卵、ベリーを食べることができることが分かりました。また、イカはマッコウクジラと戦うことができることも分かったので、zoo.erlモジュールを使って、それらをイカの餌として与えることにしました。

-module(zoo).
-export([main/0]).

feeder(red_panda) ->
    fun() ->
            element(random:uniform(4), {bamboo, birds, eggs, berries})
    end;
feeder(squid) ->
    fun() -> sperm_whale end.

feed_red_panda(Generator) ->
    Food = Generator(),
    io:format("feeding ~p to the red panda~n", [Food]),
    Food.

feed_squid(Generator) ->
    Food = Generator(),
    io:format("throwing ~p in the squid's aquarium~n", [Food]),
    Food.

main() ->
    %% Random seeding
    <<A:32, B:32, C:32>> = crypto:rand_bytes(12),
    random:seed(A, B, C),
    %% The zoo buys a feeder for both the red panda and squid
    FeederRP = feeder(red_panda),
    FeederSquid = feeder(squid),
    %% Time to feed them!
    %% This should not be right!
    feed_squid(FeederRP),
    feed_red_panda(FeederSquid).

このコードは、動物の名前を受け取り、フィーダー(餌となるアイテムを返す関数)を返すfeeder/1を使用しています。レッドパンダへの給餌はレッドパンダのフィーダーで行い、イカへの給餌はイカのフィーダーで行う必要があります。feed_red_panda/1feed_squid/1のような関数定義では、フィーダーの誤用を警告する方法がありません。実行時チェックがあっても、不可能です。餌を与えてしまえば、手遅れです。

1> zoo:main().
throwing bamboo in the squid's aquarium
feeding sperm_whale to the red panda
sperm_whale

しまった、私たちの小さな動物たちはそんな食べ方はしていません!多相型が役立つかもしれません。次の型指定は、多相型の力を使って考案することができます。

-type red_panda() :: bamboo | birds | eggs | berries.
-type squid() :: sperm_whale.
-type food(A) :: fun(() -> A).

-spec feeder(red_panda) -> food(red_panda());
            (squid) -> food(squid()).
-spec feed_red_panda(food(red_panda())) -> red_panda().
-spec feed_squid(food(squid())) -> squid().

ここで重要なのはfood(A)型です。Aは後で決定される自由型です。次に、food(red_panda())food(squid())を行うことで、feeder/1の型指定においてfood型を修飾します。すると、food型は、不明なものを返す抽象的な関数ではなく、fun(() -> red_panda())fun(() -> squid())として認識されます。これらの仕様をファイルに追加してDialyzerを実行すると、次のようになります。

$ dialyzer zoo.erl
  Checking whether the PLT /Users/ferd/.dialyzer_plt is up-to-date... yes
  Proceeding with analysis...
zoo.erl:18: Function feed_red_panda/1 will never be called
zoo.erl:23: The contract zoo:feed_squid(food(squid())) -> squid() cannot be right because the inferred return for feed_squid(FeederRP::fun(() -> 'bamboo' | 'berries' | 'birds' | 'eggs')) on line 44 is 'bamboo' | 'berries' | 'birds' | 'eggs'
zoo.erl:29: Function main/0 has no local return
 done in 0m0.68s
done (warnings were emitted)

そして、エラーは正しく表示されます。多相型万歳!

上記は非常に有用ですが、コードの小さな変更が、Dialyzerが検出できるものに予期せぬ影響を与える可能性があります。例えば、main/0関数が次のコードを持っていた場合

main() ->
    %% Random seeding
    <<A:32, B:32, C:32>> = crypto:rand_bytes(12),
    random:seed(A, B, C),
    %% The zoo buys a feeder for both the red panda and squid
    FeederRP = feeder(red_panda),
    FeederSquid = feeder(squid),
    %% Time to feed them!
    feed_squid(FeederSquid),
    feed_red_panda(FeederRP),
    %% This should not be right!
    feed_squid(FeederRP),
    feed_red_panda(FeederSquid).

状況は異なります。関数が間違った種類のフィーダーで呼び出される前に、まず正しい種類のフィーダーで呼び出されます。R15B01時点では、Dialyzerはこのコードでエラーを見つけることはありません。これは、複雑なモジュールローカルな絞り込みが行われている場合、Dialyzerが給餌関数において無名関数が呼び出されているかどうかに関する情報を必ずしも保持しないためです。

多くの静的型付けのファンにとっては少し残念なことですが、私たちは徹底的に警告されました。Dialyzerの成功型の実装について説明する論文からの引用を紹介します。

成功型とは、関数が値に評価できる型の集合を過剰に近似する型シグネチャです。シグネチャのドメインには、関数がパラメータとして受け入れる可能性のあるすべての値が含まれ、その範囲には、このドメインに対するすべての可能な戻り値が含まれます。

静的型付けの愛好家にとっては弱いと感じるかもしれませんが、成功型には、その成功型によって許可されていない方法で関数を使用した場合(例:パラメータp ∈/ αで関数を適用した場合)、その適用は確実に失敗するという特性があります。「オオカミが来た!」と間違って叫ぶことのない欠陥検出ツールに必要なのはまさにこの特性です。また、成功型は、意図しない使用であっても、関数の可能性のある使用を必ず捉えるため、プログラムの自動ドキュメント化にも使用できます。

繰り返しますが、Dialyzerは楽観的なアプローチであることを念頭に置いておくことが、効率的に作業するために不可欠です。

それでもまだあまりにも気が滅入る場合は、Dialyzerに-Woverspecsオプションを追加してみてください。

$ dialyzer zoo.erl -Woverspecs 
   Checking whether the PLT /home/ferd/.dialyzer_plt is up-to-date... yes
     Proceeding with analysis...
     zoo.erl:17: Type specification zoo:feed_red_panda(food(red_panda())) -> red_panda() is a subtype of the success typing: zoo:feed_red_panda(fun(() -> 'bamboo' | 'berries' | 'birds' | 'eggs' | 'sperm_whale')) -> 'bamboo' | 'berries' | 'birds' | 'eggs' | 'sperm_whale'zoo.erl:23: Type specification zoo:feed_squid(food(squid())) -> squid() is a subtype of the success typing: zoo:feed_squid(fun(() -> 'bamboo' | 'berries' | 'birds' | 'eggs' | 'sperm_whale')) -> 'bamboo' | 'berries' | 'birds' | 'eggs' | 'sperm_whale'
 done in 0m0.94s
done (warnings were emitted)

これは、実際、あなたの仕様がコードが受け入れることが期待されるものに対して厳しすぎることを警告し、型指定を緩めるか、型指定を反映するように関数に入力と出力をより適切に検証する必要があることを(間接的にですが)伝えます。

A red panda and a squid sharing a meal (sperm whale, bamboo, eggs and grapes

あなたは私の型です

Dialyzerは、Erlangプログラミングにおいて真の友人となることがよくありますが、頻繁な苦言はそれを放棄したくなるかもしれません。覚えておくべきことは、Dialyzerは事実上間違ったことがなく、おそらくあなたが間違っているということです。いくつかのエラーは意味がないように感じるかもしれませんが、多くの型システムとは異なり、Dialyzerは自分が正しいと確信している場合にのみ発言し、そのコードベースのバグはまれです。Dialyzerはあなたを苛立たせ、謙虚さを強制するかもしれませんが、悪く、汚いコードのソースになることは非常にまれです。

注記:この章を書いている間、より完全なバージョンのストリームモジュールを使用している際に、厄介なDialyzerエラーメッセージが発生しました。私は腹が立ってIRCで文句を言い、Dialyzerは私の複雑な型の使用を処理するには不十分だと訴えました。

ばかでした。驚くべきことに、ずっとが間違っていて、Dialyzerは正しかったのです。Dialyzerは私の-specが間違っていると繰り返し言ってくれましたが、私はそれが間違っていないと信じていました。私は戦いに敗れ、Dialyzerと私のコードが勝ちました。これは良いことだと思います。

さあ、Learn You Some Erlangはこれでおしまい!読んでくれてありがとうございました。言うことはあまりありませんが、さらに探求すべきトピックのリストと私からの一般的な言葉を得たい場合は、ガイドの結論を読んでください。幸運を!あなた、並行処理の皇帝。