类型规范和 Erlang
PLT 是最好的三明治
回到 类型(或缺少类型),我介绍了 Dialyzer,它是一种用于查找 Erlang 中类型错误的工具。本章将重点介绍 Dialyzer 以及如何使用它来实际查找 Erlang 中的一些类型错误,以及如何使用该工具查找其他类型的差异。我们将从了解 Dialyzer 的创建原因开始,然后介绍它的指导原则以及它在查找类型相关错误方面的功能,最后,我们将介绍一些使用示例。
Dialyzer 是一个非常有效的 Erlang 代码分析工具。它用于查找各种差异,例如永远不会执行的代码,但它最常用于查找 Erlang 代码库中的类型错误。
在深入了解之前,我们将创建 Dialyzer 的持久查找表(PLT),它包含 Dialyzer 可以识别有关标准 Erlang 发行版中应用程序和模块以及 OTP 之外的代码的所有详细信息。编译所有内容需要相当长的时间,尤其是如果您在没有 HiPE 本机编译的平台(即 Windows)上运行它,或者在较旧版本的 Erlang 上运行它。幸运的是,随着时间的推移,事情往往会变得更快,新版本 Erlang 中的最新版本(从 R15B02 开始)正在获得并行 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 用户会看到一条错误消息,提示“需要设置 HOME 环境变量,以便 Dialyzer 知道在哪里查找默认 PLT”。这是因为 Windows 并不总是设置 HOME
环境变量,而 Dialyzer 也不知道将 PLT 放在哪里。将变量设置为您希望 Dialyzer 将其文件放置在的任何位置。
如果您愿意,可以通过将它们添加到 --apps
后面的序列中添加 ssl
或 reltool
等应用程序,或者如果您的 PLT 已构建,则可以通过调用以下命令添加:
$ dialyzer --add_to_plt --apps ssl reltool
如果您想将自己的应用程序或模块添加到 PLT,可以使用 -r Directories
,它将查找所有 .erl
或 .beam
文件(只要它们是用 debug_info
编译的)以将它们添加到 PLT 中。
此外,Dialyzer 允许您通过在任何命令中指定 --plt Name
选项来拥有多个 PLT,并选择特定的 PLT。或者,如果您构建了多个不相交的 PLT,其中包含的模块之间没有共享,则可以使用 --plts Name1 Name2 ... NameN
将它们“合并”。当您希望在系统中为不同的项目或不同的 Erlang 版本拥有不同的 PLT 时,这尤其有用。这样做的一个缺点是,从合并的 PLT 获得的信息不如一开始将所有信息都包含在一个 PLT 中那样精确。
在 PLT 仍在构建时,我们应该熟悉 Dialyzer 查找类型错误的机制。
成功类型
与大多数其他动态编程语言一样,Erlang 程序总是存在遭受类型错误的风险。程序员将一些他不应该使用的参数传递给函数,也许他忘记了正确地测试某些东西。程序被部署,一切都似乎运行良好。然后,在凌晨 4 点,贵公司的运营人员的手机开始响起,因为您的软件反复崩溃,以至于主管无法应对您错误的巨大压力。
第二天早上,您来到办公室,发现您的电脑已经被格式化,您的汽车被划伤,您的提交权限也被撤销,所有这些都是由运营人员完成的,他已经受够了您不小心控制他的工作时间表。
整个灾难本来可以通过一个在程序运行之前验证程序的静态类型分析器编译器来避免。虽然 Erlang 不像其他动态语言那样渴望类型系统,但由于它对运行时错误的反应式方法,从早期类型相关错误发现带来的额外安全性中获益无疑是件好事。
通常,具有静态类型系统的语言会被设计成那样。语言的语义受其类型系统在允许和不允许的操作方面的影响很大。例如,这样的函数
foo(X) when is_integer(X) -> X + 1; foo(X) -> list_to_atom(X).
大多数类型系统无法正确表示上述函数的类型。它们可以看到它可以接受一个整数或一个列表并返回一个整数或一个原子,但它们不会跟踪函数的输入类型与其输出类型之间的依赖关系(条件类型和交集类型可以做到,但它们可能很冗长)。这意味着,在 Erlang 中完全正常地编写此类函数会导致类型分析器在这些函数稍后在代码中使用时产生一些不确定性。
一般来说,分析器将希望实际证明在运行时不会出现类型错误,就像在数学上证明一样。这意味着在某些情况下,类型检查器会为了消除可能导致崩溃的不确定性而禁止某些实际上有效的操作。
实现这样的类型系统很可能意味着强制 Erlang 改变其语义。问题是,当 Dialyzer 出现时,Erlang 已经在非常大型的项目中使用。对于像 Dialyzer 这样的任何工具来说,要想被接受,它就需要尊重 Erlang 的理念。如果 Erlang 允许在类型中出现纯粹的胡说八道,这只能在运行时解决,那就这样吧。类型检查器没有权利抱怨。没有程序员喜欢一个告诉他程序无法运行的工具,而他的程序已经在生产环境中运行了几个月了!
那么另一个选择就是拥有一个不会证明错误不存在的类型系统,而是尽最大努力检测它能检测到的任何东西。您可以使这种检测非常好,但它永远不会完美。这是一个需要权衡的取舍。
因此,Dialyzer 的类型系统做出了一个决定,即在类型方面不证明程序是无错误的,而是只找到尽可能多的错误,而永远不会与现实世界中发生的事情相矛盾
我们的主要目标是揭示 Erlang 代码中的隐式类型信息,并使其在程序中显式可用。由于典型 Erlang 应用程序的规模,类型推断应该完全自动,并忠实地遵循语言的操作语义。此外,它不应该强加任何类型的代码重写。这样做的原因很简单。重写通常是安全关键的,由数十万行代码组成的应用程序只是为了满足类型推断器而重写,这不是一个会获得很大成功的选择。但是,大型软件应用程序必须进行维护,而且通常不是由最初的作者进行维护。通过自动揭示已经存在的类型信息,我们提供了可以与程序一起演进并且不会腐烂的自动文档。我们还认为,在精度和可读性之间取得平衡很重要。最后但同样重要的是,推断出的类型绝不应错误。
正如 Dialyzer 背后的 成功类型论文 所解释的那样,像 Erlang 这样的语言的类型检查器应该在没有类型声明的情况下工作(尽管它接受提示),应该简单易读,应该适应语言(而不是相反),并且只对保证会崩溃的类型错误进行抱怨。
因此,Dialyzer 在每次分析开始时都乐观地假设所有函数都是好的。它会将它们视为始终成功,接受任何东西,并且可能返回任何东西。无论如何使用未知函数,都是一个好的方法。这就是为什么在生成 PLT 时有关未知函数的警告不是什么大问题。反正都很好;Dialyzer 在进行类型推断时是一个天生的乐观主义者。
随着分析的进行,Dialyzer 对您的函数越来越了解。这样做时,它可以分析代码并看到一些有趣的东西。假设您的函数之一在其两个参数之间有一个 +
运算符,并且它返回加法的结果。Dialyzer 不再假设该函数接受任何东西并返回任何东西,而是现在期望参数是数字(整数或浮点数),并且返回的值类似地是数字。该函数将具有一个与其关联的基本类型,表明它接受两个数字并返回一个数字。
现在假设您的函数之一用一个原子和一个数字调用上面描述的函数。Dialyzer 会思考代码并说“等等,你不能用一个原子和一个数字来使用 +
运算符!”然后它会惊慌失措,因为该函数以前可以返回一个数字,但现在由于您的使用方式,它无法返回任何东西。
然而,在更一般的情况下,您可能会看到 Dialyzer 对您知道有时会导致错误的许多事情保持沉默。例如,一段代码看起来有点像这样
main() -> X = case fetch() of 1 -> some_atom; 2 -> 3.14 end, convert(X). convert(X) when is_atom(X) -> {atom, X}.
这段代码假设存在一个 fetch/0
函数,该函数返回 1 或 2。基于此,我们返回一个原子或一个浮点数。
从我们的角度来看,似乎在某个时候,对 convert/1
的调用会失败。当 fetch()
返回 2 时,我们会期望在那里出现类型错误,它将浮点数发送到 convert/1
。Dialyzer 并不这么认为。请记住,Dialyzer 是乐观的。它对您的代码有象征性的信心,因为对 convert/1
的函数调用有可能在某个时候成功,所以 Dialyzer 会保持沉默。在这种情况下不会报告类型错误。
类型推断和差异
为了实际了解上述原则,让我们在几个模块上试用 Dialyzer。这些模块是 discrep1.erl、discrep2.erl 和 discrep3.erl。它们是彼此的演变。这是第一个
-module(discrep1). -export([run/0]). run() -> some_op(5, you). some_op(A, B) -> A + B.
其中的错误是显而易见的。你不能将 5
添加到 you
原子上。我们可以在这段代码上尝试 Dialyzer,假设 PLT 已经创建
$ 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' 是 Dialyzer 发出的标准警告,每当一个函数被证明没有返回值(除了可能抛出异常)时,就会发出该警告,因为其中一个被调用的函数碰巧触发了 Dialyzer 的类型错误检测器,或者它本身抛出了异常。当发生这种情况时,函数可能返回的值的类型集合为空;它实际上没有返回。此错误会传播到调用它的函数,从而导致我们看到 'no local return' 错误。
第二个错误有点更容易理解。它说调用 some_op(5, 'you')
会破坏 Dialyzer 检测到的使函数正常运行所需的类型,即两个数字 (number()
和 number()
)。虽然现在这种符号可能有点陌生,但我们很快就会详细了解它。
第三个错误又是没有局部返回值。第一个错误是因为 some_op/2
会失败,而这个错误是因为 +
调用会失败。这就是第四个也是最后一个错误的原因。加号运算符(实际上是函数 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)
糟糕。不知何故,对文件的更改使事情变得足够复杂,以至于 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
文件。我们需要添加 --src
标志来告诉 Dialyzer 使用 .erl
文件进行分析
$ 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
目录添加到所有请求中。你也可以在没有它的情况下进行相同的搜索,但 Dialyzer 会抱怨与 EUnit 测试相关的许多错误,这取决于一些断言宏在代码分析方面的运行方式——我们并不真正关心这些。另外,如果你有时测试失败并在测试中故意使软件崩溃,Dialyzer 会抓住这一点,你可能不希望它这样做。
关于类型类型的输入
正如 discrep3.erl 所示,Dialyzer 有时可能无法按照我们预期的方式推断出所有类型。这是因为 Dialyzer 无法读懂我们的思想。为了帮助 Dialyzer 完成这项任务(并且主要是帮助我们自己),可以声明类型并对函数进行注释,以便同时记录它们并帮助形式化我们代码中对类型的隐式期望。
Erlang 类型可以是简单的事情,比如数字 42,表示为类型 42
(与通常情况没有什么不同),或者特定的原子,比如 cat
,或者可能是 molecule
。这些被称为 _单例类型_,因为它们指的是值本身。以下单例类型存在
'some atom' |
任何原子都可以是它自己的单例类型。 |
42 |
给定的整数。 |
[] |
空列表。 |
{} |
空元组。 |
<<>> |
空二进制文件。 |
你可以看到,只使用这些类型来编写 Erlang 代码可能会很烦人。无法使用单例类型来表达诸如年龄之类的东西,更不用说我们程序的“所有整数”了。而且,即使我们有办法同时指定许多类型,通过手动编写它们来表达“任何整数”之类的东西也会很麻烦,这在任何情况下都不可能实现。
因此,Erlang 有 _联合类型_,它允许你描述包含两个原子的类型,以及 _内置类型_,它们是预定义的类型,不一定能够手动构建,而且它们通常很有用。联合类型和内置类型通常共享类似的语法,它们使用 TypeName()
的形式表示。例如,所有可能整数的类型将被表示为 integer()
。使用括号的原因是它们让我们能够区分类型 atom()
(表示所有原子)和 atom
(表示特定原子 atom
)。此外,为了使代码更清晰,许多 Erlang 程序员选择在类型声明中引用所有原子,从而得到 'atom'
而不是 atom
。这明确说明了 'atom'
应该是一个单例类型,而不是程序员忘记括号的内置类型。
以下是语言提供的内置类型的表格。请注意,它们并不都与联合类型具有相同的语法。其中一些,如二进制文件和元组,具有特殊的语法以使其更易于使用。
any() |
任何 Erlang 项。 |
none() |
这是一个特殊的类型,表示没有项或类型有效。通常,当 Dialyzer 将函数的可能返回值归结为 none() 时,这意味着该函数应该崩溃。它与“这个东西不会起作用”同义。 |
pid() |
进程标识符。 |
port() |
端口是文件描述符的底层表示(除非我们深入研究 Erlang 库的内部,否则我们很少会看到它),套接字,或者通常是允许 Erlang 与外部世界进行通信的东西,例如 erlang:open_port/2 函数。在 Erlang shell 中,它们看起来像 #Port<0.638> 。 |
reference() |
make_ref() 或 erlang:monitor/2 返回的唯一值。 |
atom() |
一般而言的原子。 |
binary() |
二进制数据的块。 |
<<_:Integer>> |
已知大小的二进制文件,其中 Integer 是大小。 |
<<_:_*Integer>> |
具有给定单元大小但长度未指定的二进制文件。 |
<<_:Integer, _:_*OtherInteger>> |
两种之前形式的混合,用于指定二进制文件可以具有最小长度。 |
integer() |
任何整数。 |
N..M |
一个整数范围。例如,如果你想表示一年中的月份数,可以定义范围 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} 。 |
鉴于上述内置类型,我们可以更容易地想象如何为我们的 Erlang 程序定义类型。但是,其中仍然缺少一些东西。也许事情太含糊了,或者不适合我们的需求。你还记得 discrepN
模块之一的错误中提到的类型 number()
吗?该类型既不是单例类型,也不是内置类型。那么它将是一个联合类型,这意味着我们可以自己定义它。
表示类型联合的符号是管道 (|
)。基本上,这让我们可以这样说,给定类型 TypeName 表示为 Type1 | Type2 | ... | TypeN
的联合。因此,包含整数和浮点值的 number()
类型可以表示为 integer() | float()
。布尔值可以定义为 'true' | 'false'
。还可以定义只使用另一个类型的类型。虽然它看起来像一个联合类型,但实际上是一个 _别名_。
事实上,许多这样的别名和类型联合都是预定义的。以下是一些
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()}.
这样,我们就拥有一个既可以为空节点,也可以为非空节点的树。请注意,我们也可以使用 'nil'
而不是 {'node', '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{}]
类型表示用户记录可以包含零个或多个其他用户记录的列表。它还告诉我们,可以通过将其写成 #RecordName{}
来将记录用作类型。最后一部分告诉我们个人简介可以是字符串或二进制。
此外,为了给类型声明和定义提供更统一的风格,人们倾向于添加类似 -type Type() :: #Record{}.
的别名。我们还可以更改 friends
定义以使用 user()
类型,最终结果如下
-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 了解您声明的类型,还需要做一步。我们需要在所有要增强的函数上添加类型签名声明,将我们的类型声明与模块中的函数连接起来。
本章的大部分内容都是关于“这是这样和那样的语法”,但现在是时候实际操作了。需要类型化的简单示例可能是玩扑克牌。有四种花色:黑桃、梅花、红心和方块。扑克牌可以从 1 到 10(A 为 1)编号,然后是 J、Q 或 K。
在正常情况下,我们可能会将扑克牌表示为 {Suit, CardValue}
,这样我们就可以拥有黑桃 A 为 {spades, 1}
。现在,与其让它悬而未决,我们可以定义类型来表示它
-type suit() :: spades | clubs | hearts | diamonds. -type value() :: 1..10 | j | q | k. -type card() :: {suit(), value()}.
suit()
类型只是可以表示花色的四个原子的并集。值可以是 1 到 10(1..10
)之间的任何扑克牌,或者花牌的 j
、q
或 k
。card()
类型将它们作为元组组合在一起。
这些三种类型现在可以用于表示常规函数中的扑克牌,并提供一些有趣的保证。例如,请看以下 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
函数中,您可以看到第三次调用使用了 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)
哦,真有趣。根据我们的规范,使用具有 rubies
花色的“扑克牌”调用 kind/1
是无效的。
在这种情况下,Dialyzer 会尊重我们提供的类型签名,并且当它分析 main/0
函数时,它会发现其中存在对 kind/1
的错误使用。这会引发来自第 15 行的警告(number = kind({rubies, 4}),
)。从那时起,Dialyzer 会假设类型签名是可靠的,并且如果代码要根据它使用,那么它在逻辑上将是无效的。这种违反合同的行为会传播到 main/0
函数,但在该级别上无法说出为什么它会失败;只是它确实会失败。
注意: 只有在定义了类型规范之后,Dialyzer 才会抱怨这一点。在添加类型签名之前,Dialyzer 无法假设您计划仅使用 card()
参数来使用 kind/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
的调用会失败。该函数接受一个列表并返回一个元组,或者接受一个元组并返回一个列表。但是,如果我们在代码上运行 Dialyzer,它将不会发现任何问题。
这是因为 Dialyzer 推断出的类型签名类似于
-spec convert(list() | tuple()) -> list() | tuple().
或者换句话说,该函数接受列表和元组,并返回列表和元组。这是真的,但这也太真实了。该函数不像类型签名所暗示的那样宽松。这是 Dialyzer 退缩并试图在没有 100% 确定问题的情况下不要说太多的地方之一。
为了帮助 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().
这完全一样,只是将分号放在另一行,因为它可能更容易阅读。在撰写本文时,没有广泛接受的标准。
通过以这种方式使用类型定义和规范,我们实际上能够让 Dialyzer 找到我们之前 discrep
模块中的错误。看看 discrep4.erl 是如何做到的。
类型化练习
我一直在编写一个队列模块,用于先进先出 (FIFO) 操作。您应该知道队列是什么,因为 Erlang 的邮箱是队列。第一个添加的元素将是第一个被弹出(除非我们进行选择性接收)的元素。该模块的工作原理如我们之前多次看到的这张图片所示
为了模拟队列,我们使用两个列表作为堆栈。一个列表存储新元素,另一个列表允许我们从队列中移除元素。我们总是向同一个列表添加元素,并从第二个列表中移除元素。当我们移除元素的列表为空时,我们将添加元素的列表反转,它将成为新的移除列表。这通常保证比使用单个列表执行这两项任务具有更好的平均性能。
以下是 我的模块,我添加了一些类型签名来用 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
函数中,我没有指定 none()
类型,即使其中一个函数子句调用了 erlang:error/1
。
如前所述,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”,至少我们知道如何处理——我们只需跳到下一个错误,它就会消失。
现在,让我们关注第一个错误,关于域重叠的错误。如果我们进入 fifo_types 的第 16 行,我们会看到:
-spec empty({fifo, [], []}) -> true; ({fifo, list(), list()}) -> false. empty({fifo, [], []}) -> true; empty({fifo, _, _}) -> false.
那么,什么是域重叠?我们必须参考域和像的概念。简单来说,域是指函数所有可能的输入值的集合,像是指函数所有可能的输出值的集合。因此,域重叠指的是两组输入存在重叠。
为了找到问题根源,我们必须查看 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()]}}
这个问题可以用三种方式解释:
- 类型签名正确,调用正确,问题是期望的返回值。
- 类型签名正确,调用错误,问题是给定的输入值。
- 调用正确,但类型签名错误。
我们可以立即排除第一个选项。我们实际上并没有对返回值进行任何操作。这留下了第二和第三个选项。最终的决定取决于我们是否希望将不完整的列表用于我们的队列。这是由库的编写者做出的判断,我可以毫不怀疑地说,我并不打算将不完整的列表用于这段代码。事实上,你很少希望使用不完整的列表。答案是第二个,调用错误。为了解决它,请删除调用或修复它:
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)
现在更有意义了。
导出类型
一切都很好。我们有类型,有签名,还有额外的安全性和验证。那么,如果我们想在另一个模块中使用我们的队列会怎样?对于我们经常使用的任何其他模块呢,例如 dict
、gb_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()
引用到类型规范中。
这样做有一个缺点。使用这种类型不会禁止任何人使用 card
模块来拆分类型并对其进行操作。任何人都可以编写匹配卡的代码片段,类似于 {Suit, _} = ...
。这并不总是好的:它阻止我们将来更改 cards
模块的实现。我们特别希望在表示数据结构的模块(例如 dict
或 fifo_types
(如果它导出类型))中强制执行这一点。
Dialyzer 允许你以一种告诉用户“你知道吗?我允许你使用我的类型,但不要胆敢查看它们的内部!”的方式导出类型。这相当于用以下声明替换一个声明:
-type fifo() :: {fifo, list(), list()}.
用:
-opaque fifo() :: {fifo, list(), list()}.
然后你仍然可以将其导出为 -export_type([fifo/0])
。
将类型声明为不透明意味着只有定义了该类型的模块有权查看它的结构并对其进行修改。它禁止其他模块对值进行模式匹配,除了整个值,保证(如果他们使用 Dialyzer)他们永远不会被突然的实现更改所影响。
不要喝太多酷饮
有时,不透明数据类型的实现要么不足以执行它应该执行的操作,要么实际上是有问题的(即存在错误)。Dialyzer 不会在先推断出函数的成功类型之前考虑函数的规范。
这意味着,当你的类型在没有考虑任何 -type
信息的情况下看起来相当通用时,Dialyzer 可能会被某些不透明类型搞糊涂。例如,Dialyzer 分析 card()
数据类型的不透明版本时,可能在推断后将其视为 {atom(), any()}
。正确使用 card()
的模块可能会发现 Dialyzer 报错,因为他们违反了类型契约,即使他们实际上没有违反。这是因为 card()
类型本身没有包含足够的信息让 Dialyzer 联系起来,并意识到实际发生了什么。
通常,如果你看到这种错误,标记你的元组会有所帮助。从 -opaque card() :: {suit(), value()}.
形式的类型改为 -opaque card() :: {card, suit(), value()}.
可能会让 Dialyzer 正常处理不透明类型。
Dialyzer 的实现者目前正在努力改进不透明数据类型的实现,并加强它们的推断。他们还试图让用户提供的规范更重要,并在 Dialyzer 的分析过程中更信任它们,但这仍然是一个正在进行的工作。
类型化行为
在 客户端和服务器 中,我们已经看到可以使用 behaviour_info/1
函数声明行为。导出此函数的模块将为行为命名,而第二个模块可以通过添加 -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
函数。只能使用其中之一。这意味着如果你想创建自定义行为,那么在 R15 之前的 Erlang 版本和之后的版本之间存在差异。
好处是,较新的模块将能够使用 Dialyzer 进行一些分析,以检查返回类型的错误,从而提供帮助。
多态类型
哦,真是个标题。如果你从未听说过多态类型(或者换句话说,参数化类型),这可能听起来有点吓人。幸运的是,它并不像名字听起来那么复杂。
对多态类型的需求来自于这样一个事实,即当我们为不同的数据结构进行类型化时,有时我们可能会发现自己想要对它们可以存储的内容非常具体。我们可能希望本章早期的队列有时处理任何东西,有时只处理扑克牌,或者有时只处理整数。在这最后两种情况下,问题是我们可能希望 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。在我们的动物园里,我们有两种动物:一只小熊猫和一只鱿鱼。当然,这是一个相当简陋的动物园,但这不应该阻止我们把入场费定得高高的。
我们决定自动喂养我们的动物,因为我们是程序员,程序员有时喜欢出于懒惰而自动化一些事情。经过一番研究,我们发现小熊猫可以吃竹子、一些鸟类、蛋类和浆果。我们还发现鱿鱼可以与抹香鲸战斗,所以我们决定用我们的 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/1
和 feed_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 是一个自由类型,稍后决定。然后,我们在 feeder/1
的类型规范中限定 food 类型,方法是使用 food(red_panda())
和 food(squid())
。然后 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 在其方法中的乐观态度,这对于有效地使用它至关重要。
如果你仍然感到沮丧,你可以尝试将 -Woverspecs
选项添加到 Dialyzer
$ 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)
这会警告你,实际上,你的规范对于你的代码预期接受的内容来说过于严格,并告诉你(虽然是间接的)你应该要么使你的类型规范更宽松,要么在你的函数中更好地验证你的输入和输出以反映类型规范。
你是我的类型
在使用 Erlang 编程时,Dialyzer 通常会被证明是一个真正的朋友,尽管频繁的唠叨可能会诱惑你放弃它。要记住的一件事是,Dialyzer 几乎永远不会出错,而你很可能会出错。你可能会觉得某些错误无关紧要,但与许多类型系统不同,Dialyzer 只有在知道自己正确时才会说出来,并且其代码库中的错误很少见。Dialyzer 可能会让你沮丧,强迫你变得谦虚,但它极不可能成为产生错误、不干净代码的根源。
注意: 在编写本章时,我在使用更完整的流模块版本时遇到了一个讨厌的 Dialyzer 错误消息。我烦躁地去 IRC 吐槽,说 Dialyzer 不足以处理我对类型的复杂使用。
真是愚蠢的我。事实证明(毫不意外地)我错了,而 Dialyzer 一直都是对的。它会一直告诉我我的 -spec
是错的,而我一直相信它没有错。我输掉了战斗,Dialyzer 和我的代码赢了。我认为这是一件好事。
所以,这就是 Learn You Some Erlang for great good! 的全部内容!感谢您的阅读。没有太多可说的,但如果你想获得更多探索主题的列表和我的一些通用意见,你可以阅读本指南的结论。一路顺风!你是并发皇帝。