类型

Erlang 有我们可以用于定义新的数据类型,及将类型注解添加到咱们代码的类型记法。类型注解使代码更易于理解和维护,以及可用于在编译时检测错误。

在本章中,我们将介绍类型记法,并讨论两个可用于查找咱们代码中错误的程序。

我们将要讨论的这两个程序叫做 dialyzertyper,且他们都包含在标准 Erlang 发行版中。dialyzer 是 “DIscrepancy AnaLYZer for ERlang programs”,ERlang 程序差异分析器的缩写,他所做的正如其名称所暗示的那样:他会发现 Erlang 代码中的差异。typer 提供有关咱们程序中用到的类型信息。在完全没有类型注释下,dialyzertyper 都会很好地工作,但若咱们将类型注解,添加到咱们的程序,这两个工具的分析质量将得到提升。

这是相当复杂的一章,因此我们将从一个简单示例开始,然后我们将深入研究类型语法;之后,我们将与 dialyzer 进行会话。我们将讨论用于 dialyzer 的工作流程,以及 dialyzer 无法发现的错误类别。最后,我们将以 dialyzer 工作原理相关理论结束本章,这将有助于我们理解 dialyzer 发现的那些错误。

指定数据与函数的类型

我们将去徒步旅行,很幸运有个可以用来规划我们徒步旅行的模组。该模组这样开始:

walks.erl

-module(walks).
-export([plan_route/2]).


-spec plan_route(point(), point()) -> route().

-type direction() :: north | south | east | west.
-type point()	  :: {integer(), integer()}.
-type route()	  :: [{go,direction(),integer()}].


...

这个模组导出了个名为 plan_route/2 的函数。该函数的输入和返回类型,指定于一个 类型规范 中,同时三种新的类型在使用 类型声明 下被定义出来。他们会如下解释:

  • -spec plan_route(point(), point()) -> route().

表示当函数 plan_route/2 以两个参数调用,且两个都是 point() 类型时,那么他将返回一个类型为 route() 的对象。

  • -type direction() :: north | south | east | west.

引入一个名为 direction() 的新类型,其值为原子 northsoutheastwest 之一。

  • -type point() :: {integer(), integer()}.

表示 point() 这个类型,是两个整数的元组(integer() 是个预定义的类型)。

  • -type route() :: [{go, direction(), integer()}].

将类型 route() 定义为一个 3 元素元组的列表,其中每个元组包含原子 go 、一个 direction 类型的对象和一个整数。[X] 这种记法,表示一个类型 X 的列表。

仅从这些类型注解,我们就可以设想运行 plan_route 就会看到这样的结果:

> walks:plan_route({1,10}, {25, 57}).
[{go, east, 24},
 {go, north, 47},
 ...
]

当然,我们不知道函数 plan_route 是否会返回任何值;他可能会崩溃而不返回值。但当他真的返回了值时,那么在输入参数是 point() 时,返回值的类型就应是 route()。我们也不知道上一表达式中的数字表示什么。他们是英里、公里、厘米等等吗?我们只知道类型声明告诉我们的,即他们是些整数。

要将表达能力添加到这些类型,我们可以一些描述性变量注解他们。例如,我们可将 plan_route 的规格,修改为以下内容:

-spec plan_route(From:: point(), To:: point()) -> ...

类型注解中的名字 FromTo,给到用户有关这些参数在函数中所扮演角色的一些想法。他们还用于将文档中的名称,链接到类型注解中的变量。Erlang 的官方文档,使用了编写类型注解的严格规则,因此类型注解中的名称,与相应文档中使用的名称相对应。

表明我们的路径从 From 处开始,同时 From 是个整数对,对于该函数的文档可能足够了,也可能还不够;这取决于语境。通过添加更多信息,我们可以轻松完善这些类型定义。例如,通过写下这段代码:

-type angle()	    :: {Degrees::0..360, Minutes::0..60, Seconds::0..60}.
-type position()	:: {latitude | longtitue, angle()}.
-type plan_route1(From::position(), To::position()) -> ...

这种新的形式,给到我们更多信息,但同样需要猜测。我们可能会猜测角度的单位是度,因为允许的数值范围是 0360,但他们可能只是弧度,这样我们就猜错了。

随着类型注解变长,我们可能会以增加冗长为代价,最终提高精确度。注解篇幅的增加,可能使代码更加难以阅读。写出良好的类型注解,与编写清晰的代码一样,都是一门艺术 -- 这非常困难,需要多年的实践。这是一种禅宗冥想的形式:咱们做得越多,就会变得越容易,咱们也就越来越好!

我们已经看了一个如何定义类型的简单示例,下一节会正式介绍类型记法。一旦我们对类型符号感到满意,我们将与 dialyzer 会话。

Erlang 的类型记法

到目前为止,我们已经经由一些非正式的描述,介绍了类型。要充分利用类型系统,我们需要理解类型语法,这样我们才能读写更精确的类型描述。

类型的语法

类型是使用如下语法,被非正式地定义的:

T1 :: A | B | C ...

这表示 T1 被定义未 ABC 之一。

运用这种记法,我们可将 Erlang 的类型子集定义如下:

Type :: any() | none() | pid() | port() | reference() | []
      | Atom | binary() | float() | Fun | Integer | [Type]
      | Tuple | Union | UserDefined

Union :: Type1 | Type2 | ...

Atom :: atom() | Erlang_Atom

Integer :: integer() | Min .. Max

Fun :: fun() | fun((...) -> Type)

Tuple :: tuple() | {T1, T2, ... Tn}

在上一示例中,any() 表示任何的 Erlang 项,X() 表示类型 X 的某个 Erlang 对象,而 none() 这个符号,则用于表示某个不会返回的函数类型。

[X] 写法表示 X 类型的一个列表,而 {T1, T2, ..., Tn} 表示参数为 T1, T2, ... Tn 类型,大小为 n 的一个元组。

新类型可以下面的语法定义:

-type NewTypeName(TVar1, TVar2, ... TVarN) :: Type.

下面是一些示例:

-type onOff()	    :: on | off.
-type person()	    :: {person, name(), age()}.
-type people()	    :: [person()].
-type name()	    :: {firstname, string()}.
-type age()	        :: integer().
-type dict(Key,Val) :: [{Key,Val}].

这些规则表明,比如,{firstname, "dave"} 属于 name() 类型,而 [{person, {firstname, "john"}, 35}, {person, {firstname, "mary"}, 26}] 属于 people() 类型,以此类推。类型 dict(Key,Val) 展示了类型变量的使用,并将字典类型定义为 {Key, Val} 元组的一个列表。

预定义的类型

除类型语法外,以下类型别名还被预定义了出来:

-type term() :: any().
-type boolean() :: true | false.
-type byte() :: 0..255.
-type char() :: 0..16#10ffff.
-type number() :: integer() | float().
-type list() :: [any()].
-type maybe_improper_list() :: maybe_improper_list(any(), any()).
-type maybe_improper_list(T) :: maybe_improper_list(T, any()).
-type string() :: [char()].
-type nonempty_string() :: [char(),...].
-type iolist() :: maybe_improper_list(byte() | binary() | iolist(),
                                      binary() | []).
-type module() :: atom().
-type mfa() :: {atom(), atom(), atom()}.
-type node() :: atom().
-type timeout() :: infinity | non_neg_integer().
-type no_return() :: none().

其中 maybe_improper_list 用于指定那些最终尾部为非空,non-nil,的列表类型。这类列表很少使用,但要指定他们的类型!

还有少数其他预定义的类型。其中 non_neg_integer() 为非负整数,pos_integer() 为正整数,neg_integer() 为负整数。最后,[X,...] 这种写法表示类型 X 的一个非空列表。

现在我们可以定义类型了,接下来是函数规范。

指定函数的输入与输出类型

函数规范指出了某个函数的参数类型为何,以及该函数的返回值类型为何。函数规范被写如下:

-spec functionName(T1, T2, ..., Tn) -> Tret when
    Ti :: Typei,
    Tj :: Typej,
    ...

这里 T1, T2, ..., Tn 描述某个函数参数的类型,Tret 描述该函数返回值的类型。必要时可在可选的 when 关键字后,引入其他类型变量。

我们将以一个示例开始。下面的类型规范:

-spec file:open(FileName, Modes) -> {ok, Handle} | {error, Why} When
    FileName	:: string(),
    Modes	    :: [Mode],
    Mode	    :: read | write | ...
    Handle	    :: file_handle(),
    Why	        :: error_term().

是说,当我们打开文件 FileName 时,我们将得到一个要么是 {ok,Handle}{error,Why} 的返回值。FileName 是个字符串,Modes 是个 Mode 的列表,而 Mode 则是 readwrite 等之一。

上面的函数规范,可以有多种等价写法,例如,我们可能写成下面这样,而不使用 when 的限定符:

-spec file:open(string(), [read|write|...]) -> {ok, Handle} | {error, Why}

这种写法的问题是,首先,我们失去了 FileNameModes 等描述性变量等;其次,类型规范会变得更长,而因此在印刷文档中更难阅读和格式化。当函数参数没有命名时,我们就无法在理想情况下程序后的文档中,引用这些参数。

在第一种规范编写方式中,我们写下了以下内容:

-spec file:open(FileName, Modes) -> {ok, Handle} | {error, Why} When
    FileName	:: string(),
    ...

因此,这个函数的全部文档,都可以 FileName 这个名称,明确指代正在打开的文件。而当我们写下这个:

-spec file:open(string(), [read|write|...]) -> {ok, Handle} | {error, Why}

而丢弃 when 这个限定符时,那么文档就不得不将正在打开的文件,称为 "open 函数的第一个参数",这是第一种函数规范写法中,不必要的省略。

类型变量可用于参数,如下面的示例:

-spec lists:map(fun(A) -> B, [A]) -> [B].
-spec lists:filters(fun(X) -> bool(), [X]) -> [X].

这表示 map 会取一个从类型 A 到类型 B 函数,以及一个类型 A 对象的列表,并返回一个类型 B 对象的列表,以此类推。

导出与本地类型

有时我们希望某个类型的定义,属于定义他的模组本地;而在别的情况下,我们则打算导出该类型到另一模组。设想有两个模组 ab,模组 a 生成了一些 rich_text 类型的对象,模组 b 会操作这些对象。在模组 a 中,我们构造了以下一些注解:

-module(a).
-type rich_text() :: [{font(), char()}].
-type font()	  :: integer().
-export_type([rich_text/0, font/0]).

我们不仅要声明 rich_textfont 类型,我们还要使用一个 -export_type(...) 注解,导出他们。

设想模组 b 会操作一些 rich_text 的实例;其中可能存在某个计算 rich_text 对象长度的函数 rich_text_length。我们可将这个函数的类型规范,写作如下:

-module(b).
...
-spec rich_text_length(a:rich_text()) -> integer().
...

rich_text_length 的输入参数,使用了完全限定的类型名称 a:rich_text(),即导出自模组 a 的类型 rich_text()

不透明类型

在上一小节中,两个模组 ab 以操作表示富文本的对象内部结构,相互合作。不过,我们可能希望隐藏这个富文本数据结构的内部细节,这样只有创建这个数据结构的模组,才知道该类型的细节。这最好一个示例,加以解释。

假定模组 a 像下面这样开头:

-module(a).
-opaque rich_text() :: [{font(), char()}].
-export_type([rich_text/0]).

-export([make_text/1, bounding_box/1]).
-spec make_text(string()) -> rich_text().
-spec bounding_box(rich_text()) -> {Height::integer(), Width::integer()}.
...

下面这个语句:

-opaque rich_text() :: [{font(), char()}].

创建了个名为 rich_text() 的不透明类型。现在我们来看看一些尝试操作 rich_text() 对象的代码:

-module(b).
...

do_this() ->
    X = a:make_text("hello world"),
    {W, H} = a:bounding_box(X)

模组 b 永远不需要知道变量 X 的内部结构。X 是在模组 a 内创建的,并在我们调用 bounding_box(X) 时,X 又被传回 a

现在,设想我们要编写会用到一些有关 rich_text 对象形状知识的代码。例如,设想我们要创建一个 rich_text() 对象,然后询问渲染该对象需要哪些字体。我们可能写下这段代码:

-module(c).
...

fonts_in(Str) ->
    X = a:make_text(Str),
    [F || {F,_} <- X].

在列表综合下,我们 “知道” X 是个 2 元元组的列表。在模组 a 中,我们曾将 make_text 的返回类型,声明为一个不透明类型,这意味着我们不应知道该类型内部结构的任何情况。当我们在有关函数中,正确地声明了类型的可见性时,利用类型的内部结构,就被称为一次 抽象背离1,同时这会被 dialyzer 检测到。

译注

1:an abstraction violation,一次抽象背离

参考:Violating Data Abstraction

dialyzer 的一次交谈

首次运行 dialyzer 时,咱们需要构建出标准库中,咱们打算用到的所有类型的缓存。这是个一次性操作。当咱们启动 dialyzer 时,他会告诉咱们该做什么。

$ dialyzer
  Checking whether the PLT c:/Users/ZBT7RX/AppData/Local/erlang/Cache/.dialyzer_plt is up-to-date...
dialyzer: Could not find the PLT: c:/Users/ZBT7RX/AppData/Local/erlang/Cache/.dialyzer_plt
Use the options:
   --build_plt   to build a new PLT; or
   --add_to_plt  to add to an existing PLT

For example, use a command like the following:
   dialyzer --build_plt --apps erts kernel stdlib mnesia
Note that building a PLT such as the above may take 20 mins or so

If you later need information about other applications, say crypto,
you can extend the PLT by the command:
  dialyzer --add_to_plt --apps crypto
For applications that are not in Erlang/OTP use an absolute file name.

PLT 是 持久查找表,persistent lookup table 的简称。PLT 应包含标准系统中所有类型的缓存。构建 PLT 需要几分钟时间。我们下达的第一条命令,会构建出 ertsstdlibkernel 的 PLT。

$ dialyzer --build_plt --apps erts kernel stdlib compiler crypto syntax_tools parsetools
  Creating PLT /home/hector/.cache/erlang/.dialyzer_plt ... done in 0m25.10s
done (passed successfully)

译注 其中 parsetools 对应 YECC -- Erlang 的解析器生成器,是 parsetools 应用的一部分;syntax_tools 选项对应 erlang_syntaxcrypto 对应 cryptocompiler 对应 compile。若没有后面 4 个选项,输出如下所示。

$ dialyzer --build_plt --apps erts kernel stdlib
  Creating PLT /home/hector/.cache/erlang/.dialyzer_plt ...
Unknown functions:
  compile:file/2 (c.erl:509:10)
  compile:forms/2 (escript.erl:803:12)
  compile:noenv_forms/2 (erl_abstract_code.erl:34:9)
  compile:noenv_forms/2 (qlc_pt.erl:455:14)
  compile:output_generated/1 (c.erl:568:10)
  crypto:crypto_one_time/5 (beam_lib.erl:1411:11)
  crypto:hash_info/1 (inet_dns_tsig.erl:184:31)
  crypto:mac_finalN/2 (inet_dns_tsig.erl:317:5)
  crypto:mac_init/3 (inet_dns_tsig.erl:276:16)
  crypto:mac_update/2 (inet_dns_tsig.erl:303:16)
  crypto:strong_rand_bytes/1 (net_kernel.erl:2632:37)
  erl_syntax:map_field_assoc_name/1 (shell_docs_test.erl:386:24)
  erl_syntax:map_field_assoc_value/1 (shell_docs_test.erl:387:25)
  erl_syntax:map_field_exact/2 (shell_docs_test.erl:388:17)
  erl_syntax:revert/1 (shell_docs_test.erl:382:5)
  erl_syntax:type/1 (shell_docs_test.erl:384:14)
  erl_syntax_lib:map/2 (shell_docs_test.erl:383:5)
Unknown types:
  compile:option/0 (c.erl:149:19)
  compile:option/0 (erl_expand_records.erl:56:26)
  compile:option/0 (erl_lint.erl:100:47)
  compile:option/0 (qlc.erl:746:32)
  compile:option/0 (qlc_pt.erl:78:32)
  crypto:mac_state/0 (inet_dns_tsig.erl:71:48)
  yecc:option/0 (c.erl:1411:23)
  yecc:yecc_ret/0 (c.erl:1411:57)
 done in 0m14.62s
done (warnings were emitted)

现在我们已构建好 PLT,那么就可以运行 dialyzer 了。之所以会出现未知函数的告警,是因为提及的那些函数,不在我们选择分析的三个应用中。

dialyzer保守的。当他抱怨时,那么程序中必然有不合理之处。构造 dialyzer 的项目目标之一,就是要消除虚假警告消息,即那些并非属于真正错误的警告消息。

在后面的小节中,我们给出一些不当程序的示例;我们将对这些程序运行 dialyzer,并说明我们可依赖 dialyzer 报告哪些错误。

BIF 返回值的不当使用

dialyzer/test1.erl

-module(test1).
-export([f1/0]).


f1() ->
    X = erlang:time(),
    seconds(X).


seconds({_Year, _Month, _Day, Hour, Min, Sec}) ->
    (Hour * 60 + Min)*60 + Sec.
$ dialyzer test1.erl
  Checking whether the PLT /home/hector/.cache/erlang/.dialyzer_plt is up-to-date... yes
  Proceeding with analysis...
test1.erl:5:1: Function f1/0 has no local return
test1.erl:7:13: The call test1:seconds
         (X :: {byte(), byte(), byte()}) will never return since it differs in the 1st argument from the success typing arguments:
         ({_, _, _, number(), number(), number()})
test1.erl:10:1: Function seconds/1 has no local return
test1.erl:10:1: The pattern
          {_Year, _Month, _Day, Hour, Min, Sec} can never match the type
          {byte(), byte(), byte()}
 done in 0m0.16s
done (warnings were emitted)

这个相当可怕的错误消息,是由于 erlang:time() 返回的是个名为 {Hour, Min, Sec} 的 3 元组,而不是我们所期望的 6 元组。“函数 f1/0 不会有本地返回值” 这个消息,意味着 f1/0 将崩溃。dialyzer 知道 erlang:time() 的返回值是 {non_neg_integer(), non_neg_integer(), non_neg_integer()} 类型的实例,因此绝不会与其中的 6 元组模式,也就是 seconds/1 的参数匹配。

某个 BIF 的不当参数

当我们以不当参数,调用某个 BIF 时,我们可使用 dialyzer 告诉我们 。下面是这方面的一个示例:

dialyzer/test2.erl

-module(test2).
-export([f1/0]).


f1() ->
    tuple_size(list_to_tuple({a, b, c})).
$ dialyzer test2.erl
  Checking whether the PLT /home/hector/.cache/erlang/.dialyzer_plt is up-to-date... yes
  Proceeding with analysis...
test2.erl:5:1: Function f1/0 has no local return
test2.erl:6:30: The call erlang:list_to_tuple
         ({'a', 'b', 'c'}) breaks the contract
          (List) -> tuple() when List :: [term()]
 done in 0m0.16s
done (warnings were emitted)

这告诉我们,list_to_tuple 期望一个类型 [term()] 的参数,而不是 {'a'、'b'、'c'}

译注:原文为 “list_to_tuple 期望一个类型 [any()] 参数......”,这里由于 dialyzer 版本更新等原因,其输出已更新。

不当的程序逻辑

dialyzer 还能检测到错误的程序逻辑。下面是个示例:

dialyzer/test3.erl

-module(test3).
-export([test/0, factorial/1]).


test() -> factorial(-5).

factorial(0) -> 1;
factorial(N) -> N*factorial(N-1).
$ dialyzer test3.erl
  Checking whether the PLT c:/Users/ZBT7RX/AppData/Local/erlang/Cache/.dialyzer_plt is up-to-date... yes
  Proceeding with analysis...
test3.erl:5:1: Function test/0 has no local return
test3.erl:5:21: The call test3:factorial
         (-5) will never return since it differs in the 1st argument from the success typing arguments:
         (non_neg_integer())
 done in 0m0.33s
done (warnings were emitted)

这实际上是相当了不起的。其中阶乘的定义是不当的。当使用某个负的参数调用 factorial 时,这个程序将进入无限循环,耗尽栈空间,最终 Erlang 将耗尽内存而死亡。dialyzer 已推断出,factorial 的参数,为 non_neg_integer() 类型,因此,那个到 factorial(-5) 的调用,便是个错误。

dialyzer 不会打印函数的推断类型,因此我们将询问 typer,这些类型是什么。

$ typer test3.erl

%% File: "test3.erl"
%% -----------------
-spec test() -> none().
-spec factorial(non_neg_integer()) -> pos_integer().

typer 已推导出,factorial 的类型是 (non_neg_integer()) -> pos_integer(),同时 test() 的类型是 none()

程序的推理如下:其中递归的基础情形,是 factorial(0),因此 factorial 的参数要为零,调用 factorial(N-1) 最终必须要降至零;因此 N 必须大于或等于 1,这就是上面推断出的阶乘类型的原因。这一点非常巧妙。

使用 dialyzer

使用 dialyzer 检查咱们程序的类型错误,涉及一项特定工作流程。咱们不应做的是,在无类型注释下编写整个程序,然后,当咱们认为程序就绪时,回头将类型注释添加到所有程序项目,然后运行 dialyzer。当咱们这样做,咱们将很可能会得到大量令人费解的错误,而且不知道从哪里开始,修复错误这些错误。

使用 dialyzer 的最佳方式,是在开发的每一阶段都使用他。当咱们开始编写某个新模组时,首先要考虑类型,并在咱们编写咱们代码前,声明这些类型。编写咱们模组中所有导出函数的类型规范。要在咱们开始编写代码前,完成这件事情。咱们可以注释掉那些尚未实现函数的类型规范,然后在实现了这些函数时,取消他们的注释。

现在逐个编写咱们的函数,并在咱们编写完各个新的函数后,检查 dialyzer 是否找到咱们程序中任何的错误。当该函数被导出时,就要添加类型规范。当该函数没有被导出时,则在咱们认为其将有助于类型分析,或帮助我们理解程序(请记住,类型注解会提供程序的良好文档)时,添加类型规范。当 dialyzer 发现了任何的错误时,那么就要停下来,思考并找出错误的确切含义。

会迷惑 dialyzer 的东西

dialyzer 会很容易变得迷惑。通过遵循几条简单规则,我们可帮助避免这种情况。

  • 要避免使用 -compile(export_all)。当咱们导出模组中的所有函数时,dialyzer 就可能无法推理出那些咱们的导出函数参数;这些导出函数可从任何地方调用,并有着任意的类型。这些参数的值,可传播到模组中的其他函数,而产生令人困惑的错误;
  • 提供模组中 导出的 函数所有参数的详细类型规范。要尽量严格约束导出函数的参数。例如,乍一看咱们可能会认为某个函数的一个参数是个整数,但稍加思考后,咱们可能就确定了该参数是个正整数,甚至是个有界整数。咱们对咱们的类型越精确,咱们从 dialyzer 得到的结果就将越好。此外,当可行时,咱们要添加精确的条件测试到咱们的代码。这样做将有助于程序分析,并通常还将帮助编译器,生成质量更佳的代码;
  • 提供某个记录定义中所有元素的默认参数。当咱们不提供默认参数时,原子 undefined 就会被取作默认参数,而这种类型将在程序中传播,而可能产生奇怪的类型错误;
  • 在某个函数的参数中使用匿名变量,往往会造成远不如咱们预期那么具体的一些类型;要尽量限制变量。

类型推断与继承类型

dialyzer 产生的一些错误,会非常奇怪。要理解这些错误,我们必须了解 dialyzer 推导 Erlang 函数类型的过程。理解这一点将有助于我们解释这些令人费解的错误消息。

所谓 类型推断,是通过分析代码,推导出某个函数类型的过程。要完成这点,我们会分析程序,寻找 约束条件;根据约束条件,我们会构建一组约束方程,然后求解这些方程。求解结果就是一个我们称之为该程序的 成功类型 的类型集。我们来看一个简单的模组,看看他会告诉我们什么。

dialyzer/types1.erl

-module(types1).
-export([f1/1, f2/1, f3/1]).


f1({H,M,S}) -> (H+M*60)*60+S.
f2({H,M,S}) when is_integer(H) -> (H+M*60)*60+S.
f3({H,M,S}) ->
    print(H,M,S),
    (H+M*60)*60+S.


print(H,M,S) ->
    Str = integer_to_list(H) ++ ":" ++ integer_to_list(M) ++ ":" ++
        integer_to_list(S),
    io:format("~s", [Str]).

在阅读下一小节前,请花点时间仔细阅读这段代码,并尝试找出那些在这段代码中出现变量的类型。

下面是当我们运行 dialyzer 时发生的情况:

$ dialyzer types1.erl
  Checking whether the PLT c:/Users/ZBT7RX/AppData/Local/erlang/Cache/.dialyzer_plt is up-to-date... yes
  Proceeding with analysis... done in 0m0.31s
done (passed successfully)

dialyzer 发现这段代码中没有类型错误。但这并不意味着这段代码是正确的;这只意味着该程序中所有数据类型,都被一致地使用。在将小时、分钟和秒转换为秒时,我(作者)写了 (H+M*60)*60+S ,这完全是错误的 -- 应是 (H*60+M)*60+S。任何类型系统都将检测不到这点。即使咱们有个类型良好的程序,咱们仍必须提供测试用例。

对这个同一个程序运行 typer,会产生如下内容:

$ typer types1.erl

%% File: "types1.erl"
%% ------------------
-spec f1({number(),number(),number()}) -> number().
-spec f2({integer(),number(),number()}) -> number().
-spec f3({integer(),integer(),integer()}) -> integer().
-spec print(integer(),integer(),integer()) -> 'ok'.

typer 报告了他分析的该模组中所有函数的类型。types 指出函数 f1 的类型如下:

-spec f1({number(),number(),number()}) -> number().

这是通过查看 f1 的定义得出,该定义如下:

f1({H,M,S}) -> (H+M*60)*60+S.

这个函数提供给我们五个不同的约束条件。首先,f1 的参数必须是个三个元素的元组。每个算术运算符都提供了个额外约束。例如,子表达式 M*60 告诉我们,M 必须是个 number() 类型,因为乘法运算符的两个参数,都必须是个数字。同样,...+S 告诉我们,S 必须是个数字。

现在看看 f2。下面是函数 f2 的代码与推导出的类型:

f2({H,M,S}) when is_integer(H) -> (H+M*60)*60+S.

-spec f2({integer(),number(),number()}) -> number().

is_integer(H) 条件的补充,增加了 H 必须是个整数的额外约束条件,同时这个约束条件将 f2 的元组参数的第一个元素类型,从 number() 改变为更精确的 integer() 类型。

请注意,要严格正确地说,我们应说 “添加了当该函数成功时,那么 H 必须是个整数的额外约束"。这就是为什么我们将函数的推断类型,称为 成功类型 --他字面上表示 “为了使函数求值成功,函数中的参数而必须有的类型。”

现在我们来看看 types1.erl 中的最后那个函数。

f3({H,M,S}) ->
    print(H,M,S),
    (H+M*60)*60+S.

print(H,M,S) ->
    Str = integer_to_list(H) ++ ":" ++ integer_to_list(M) ++ ":" ++
        integer_to_list(S),
    io:format("~s", [Str]).

推导出的类型如下:

-spec f3({integer(),integer(),integer()}) -> integer().
-spec print(integer(),integer(),integer()) -> 'ok'.

这里,咱们可以看到调用 integer_too_list 如何将其参数,约束为一个整数。函数 print 中出现的这个约束条件,会传播到函数 f3 的主体中。

正如我们所见,类型分析分两个阶段进行。首先,我们推导出一个约束方程集合;然后,我们求解这些方程。当 dialyzer 没有发现任何错误时,说明这个约束方程集是可解的,而 typer 会打印出这些方程的解。当这些方程不一致且无法求解时,dialyzer 就会报告一个错误。

现在我们将对前一程序稍作修改,引入一个错误,看看他对分析有什么影响。

dialyzer/types1_bug.erl

-module(types1_bug).
-export([f4/1]).


f4({H,M,S}) when is_float(H) ->
    print(H,M,S),
    (H+M*60)*60+S.


print(H,M,S) ->
    Str = integer_to_list(H) ++ ":" ++ integer_to_list(M) ++ ":" ++
        integer_to_list(S),
    io:format("~s", [Str]).

我们将首先运行 typer

$ typer types1_bug.erl

%% File: "types1_bug.erl"
%% ----------------------
-spec f4(_) -> none().
-spec print(integer(),integer(),integer()) -> 'ok'.

typer 指出 f4 的返回类型是 none()。这是表示 “此函数将永不返回” 的一种特殊类型。

当我们运行 dialyzer 时,我们会看到以下结果:

$ dialyzer types1_bug.erl
  Checking whether the PLT c:/Users/ZBT7RX/AppData/Local/erlang/Cache/.dialyzer_plt is up-to-date... yes
  Proceeding with analysis...
types1_bug.erl:5:1: Function f4/1 has no local return
types1_bug.erl:6:11: The call types1_bug:print
         (H :: float(),
          M :: any(),
          S :: any()) will never return since it differs in the 1st argument from the success typing arguments:
         (integer(),
          integer(),
          integer())
types1_bug.erl:10:1: Function print/3 has no local return
types1_bug.erl:11:27: The call erlang:integer_to_list
         (H :: float()) breaks the contract
          (Integer) -> string() when Integer :: integer()
 done in 0m0.36s
done (warnings were emitted)

现在回头看一下这段代码。条件测试 is_float(H) 告诉系统,H 必须是个浮点数。但 H 会被传播到函数 print 中,而 print 中的函数调用 integer_too_list(H) 则告诉系统,H 必须是个整数。现在,dialyzer 就不知道这两条语句中,哪条是正确的,所以他认为这两条语句都是错误的。这就是为什么他会说 Function print/3 has no local return。这就是类型系统的局限之一;他们只能指出程序不一致,然后将其留给程序员找出原因。

类型系统的局限

我们来看看当我们添加了类型规范到代码时,会发生什么。我们将从众所周知的布尔 and 函数开始。当 and 的两个参数都为 true 时,and 为真;而当其任何一个参数为 false 时,andfalse。我们将如下定义一个函数 myand1(其被假定为与 and 工作相似):

types2.erl

myand1(true, true) -> true;
myand1(false, _) -> false;
myand1(_, false) -> false.

对这段代码运行 typer,我们会看到如下结果:

$ typer types2.erl

%% File: "types2.erl"
%% ------------------
-spec myand1(_,_) -> boolean().

推断出的 myand1(_,_) -> boolean(),这表示 myand1 的各个参数,都可以是咱们喜欢的任何类型,而返回类型将是 boolean。由于参数位置处的那些下划线,typer 推断出 myand1 的两个参数,可以是任何内容。例如,myand1 的第二个子句为 myand1(false, _) -> false,由此 typer 推断了第二个参数可以是任何值。

现在,设想我们将一个错误函数 bug1 添加到该模组,如下所示:

bug1(X, Y) -> 
    case myand1(X, Y) of
        true -> X + Y
    end.

然后我们让 typer 分析这个模组。

$ typer types2.erl

%% File: "types2.erl"
%% ------------------
-spec myand1(_,_) -> boolean().
-spec bug1(number(),number()) -> number().

typer 知道 + 会取两个数字作参数并返回一个数字,因此他会推断出 XY 都是数字。他还推断出 myand1 的参数可以是任何内容,这与 XY 都是数字是一致的。当我们对这个模组运行 dialyzer 时,将不返回任何错误。typer 认为以两个数字参数调用 bug1, 将返回一个数字,但他并不会。他将崩溃。这个示例展示了,参数类型指定不足(即使用 _ 而不是 boolean() 作为类型),如何导致在分析程序时,无法检测的错误。

现在,我们了解了有关类型的所有知识。在下一章中,我们将以介绍编译和运行程序的多种方法,结束本书的第二部分。在 shell 中我们可以完成的很多事情,都可以自动化,我们将探讨实现自动化的方法。当咱们读完下一章时,咱们将就会对构建与运行顺序 Erlang 代码了如指掌。在那之后,我们就可以转向并发编程,这才是本书主旨,但在会跑之前咱们必须先学会走,而在能编写并发程序前,咱们必须会编写顺序程序。

练习

  1. 请编写导出单个函数的一些小型模组。编写这些导出函数的类型规范。在这些函数中构造一些类型错误;然后对这些程序运行 dialyzer,并尝试理解错误消息。有时咱们将构造出 dialyzer 找不出的错误;请仔细观察程序,试着找出为何咱们没有得到预期错误的原因;
  2. 请查看标准库中代码的类型注解。找到模组 lists.erl 的源码,阅读所有类型注解;
  3. 为什么在编写模组前,先思考模组中某个函数的类型是个好主意?这总是个好主意吗?
  4. 不透明类型的实验。请创建两个模组;第一个应导出一个不透明类型。第二个模组应以某种造成抽象背离的方式,使用第一个模组导出的不透明类型的内部数据结构,。对这两个模组运行 dialyzer,确保咱们理解那些错误消息。
Last change: 2025-09-09, commit: 518e9fa

小额打赏,赞助 xfoss.com 长存......

微信 | 支付宝

若这里内容有帮助到你,请选择上述方式向 xfoss.com 捐赠。