类型(或缺乏类型)

强大的动态类型

您可能已经注意到,在输入来自 入门 (真正开始) 的示例,以及来自 模块函数语法 的模块和函数时,我们从未需要编写变量的类型或函数的类型。在模式匹配时,我们编写的代码不必知道它将匹配什么。元组 {X,Y} 可以与 {atom, 123} 以及 {"A string", <<"binary stuff!">>}{2.0, ["strings","and",atoms]} 或任何其他内容匹配。

当它无法正常工作时,会在您的脸上抛出一个错误,但只有在您运行代码时才会出现。这是因为 Erlang 是动态类型的:每个错误都在运行时被捕获,并且编译器在编译模块时不会总是对您大喊大叫,即使在某些情况下可能会导致失败,例如在 入门 (真正开始) 中的 "llama + 5" 示例。

A knife slicing ham.

静态类型和动态类型支持者之间一个经典的摩擦点与所编写软件的安全问题有关。一个经常提出的想法是,具有严格强制执行的良好静态类型系统将能够在您甚至执行代码之前捕获大多数潜在的错误。因此,静态类型语言被认为比动态语言更安全。虽然与许多动态语言相比这可能是真的,但 Erlang 却持不同意见,并且肯定有记录可以证明这一点。最好的例子是 爱立信 AXD 301 ATM 交换机 上经常报道的九个九(99.9999999%)的可用性,它包含超过 100 万行 Erlang 代码。请注意,这并不意味着基于 Erlang 的系统中的任何组件都没有失败,而是指一个通用交换机系统在 99.9999999% 的时间内可用,包括计划停机。这在一定程度上是因为 Erlang 建立在这样一个理念之上,即一个组件的故障不应影响整个系统。来自程序员的错误、硬件故障或 [某些] 网络故障都将被考虑在内:该语言包含允许您将程序分配到不同的节点、处理意外错误以及永不停机的功能。

简而言之,虽然大多数语言和类型系统旨在使程序免于错误,但 Erlang 采用了一种策略,即假定错误无论如何都会发生,并确保覆盖这些情况:Erlang 的动态类型系统不是程序可靠性和安全的障碍。这听起来像很多预言性的谈话,但您将在后面的章节中看到它是如何实现的。

注意: 历史上选择动态类型的原因很简单;最初实现 Erlang 的人大多来自动态类型语言,因此,使 Erlang 动态成为他们最自然的选择。

Erlang 也是强类型的。弱类型语言会在项之间进行隐式类型转换。如果 Erlang 是弱类型的,我们可能可以执行 6 = 5 + "1". 操作,而实际上,会抛出有关错误参数的异常。

1> 6 + "1".
** exception error: bad argument in an arithmetic expression
     in operator  +/2
        called as 6 + "1"

当然,有时您可能希望将一种类型的数据转换为另一种类型:将普通字符串更改为位字符串以存储它们,或将整数转换为浮点数。Erlang 标准库提供了许多函数来执行此操作。

类型转换

Erlang 与许多语言一样,通过将项强制转换为另一个类型来更改它的类型。这是借助内置函数完成的,因为许多转换无法在 Erlang 本身中实现。这些函数中的每一个都采用 <type>_to_<type> 的形式,并在 erlang 模块中实现。以下是一些示例:

1> erlang:list_to_integer("54").
54
2> erlang:integer_to_list(54).
"54"
3> erlang:list_to_integer("54.32").
** exception error: bad argument
     in function  list_to_integer/1
        called as list_to_integer("54.32")
4> erlang:list_to_float("54.32").
54.32
5> erlang:atom_to_list(true).
"true"
6> erlang:list_to_bitstring("hi there").
<<"hi there">>
7> erlang:bitstring_to_list(<<"hi there">>).
"hi there"

等等。我们在这里遇到了语言的缺陷:由于使用了 <type>_to_<type> 方案,每次将新类型添加到语言中时,都需要添加许多转换 BIF!以下是已有的完整列表:

atom_to_binary/2, atom_to_list/1, binary_to_atom/2, binary_to_existing_atom/2, binary_to_list/1, bitstring_to_list/1, binary_to_term/1, float_to_list/1, fun_to_list/1, integer_to_list/1, integer_to_list/2, iolist_to_binary/1, iolist_to_atom/1, list_to_atom/1, list_to_binary/1, list_to_bitstring/1, list_to_existing_atom/1, list_to_float/1, list_to_integer/2, list_to_pid/1, list_to_tuple/1, pid_to_list/1, port_to_list/1, ref_to_list/1, term_to_binary/1, term_to_binary/2 和 tuple_to_list/1。

有很多转换函数。我们将在本书中看到大多数(如果不是全部)这些类型,尽管我们可能不需要所有这些函数。

保护数据类型

Erlang 的基本数据类型很容易用肉眼识别:元组使用花括号,列表使用方括号,字符串用双引号括起来,等等。因此,通过模式匹配可以强制执行特定的数据类型:一个接受列表的函数 head/1 只能接受列表,否则匹配 ([H|_]) 将失败。

Hi, My name is Tuple

但是,我们在处理数值时遇到了问题,因为我们无法指定范围。因此,我们在关于温度、驾驶年龄等的函数中使用了守卫。现在我们又遇到了一个障碍。我们如何编写一个守卫来确保模式与特定类型的单个数据匹配,例如数字、原子或位字符串?

有一些专门用于此任务的函数。它们将接受一个参数,如果类型正确则返回 true,否则返回 false。它们是守卫表达式中允许的少数几个函数的一部分,被称为类型测试 BIF

is_atom/1           is_binary/1         
is_bitstring/1      is_boolean/1        is_builtin/3        
is_float/1          is_function/1       is_function/2       
is_integer/1        is_list/1           is_number/1         
is_pid/1            is_port/1           is_record/2         
is_record/3         is_reference/1      is_tuple/1          

它们可以像任何其他守卫表达式一样使用,只要守卫表达式允许的地方都可以使用。您可能想知道为什么没有函数只是给出被评估项的类型(类似于 type_of(X) -> Type)。答案很简单。Erlang 是关于为正确的情况编写程序:您只为已知会发生的事情和您期望发生的事情编写程序。其他任何事情都应该尽快导致错误。虽然这听起来可能很疯狂,但您将在 错误和异常 中获得的解释将有望使事情更加清晰。在此之前,请相信我。

注意: 类型测试 BIF 构成守卫表达式中允许的函数的一半以上。其余的也是 BIF,但并不代表类型测试。这些是
abs(Number), bit_size(Bitstring), byte_size(Bitstring), element(N, Tuple), float(Term), hd(List), length(List), node(), node(Pid|Ref|Port), round(Number), self(), size(Tuple|Bitstring), tl(List), trunc(Number), tuple_size(Tuple).

函数 node/1self/0 与分布式 Erlang 和进程/actor 相关。我们最终会使用它们,但在此之前,我们还有其他主题要涵盖。

Erlang 数据结构似乎相对有限,但列表和元组通常足以构建其他复杂结构,而无需担心任何事情。例如,二叉树的基本节点可以表示为 {node, Value, Left, Right},其中 LeftRight 既可以是类似的节点,也可以是空元组。我还可以将自己表示为

{person, {name, <<"Fred T-H">>},
         {qualities, ["handsome", "smart", "honest", "objective"]},
         {faults, ["liar"]},
         {skills, ["programming", "bass guitar", "underwater breakdancing"]}}.

这表明,通过嵌套元组和列表并用数据填充它们,我们可以获得复杂的数据结构并构建函数来对它们进行操作。

更新
R13B04 版本增加了 BIF binary_to_term/2,它允许您以与 binary_to_term/1 相同的方式反序列化数据,只是第二个参数是选项列表。如果您传入 [safe],则如果二进制文件包含未知原子或 匿名函数,则不会对其进行解码,这可能会耗尽内存。

对于类型狂热者

A sign for homeless people: 'Will dance for types'

本节旨在供那些无论出于何种原因都无法没有静态类型系统的程序员阅读。它将包含更多高级理论,并非所有内容都能被所有人理解。我将简要描述用于在 Erlang 中进行静态类型分析的工具,定义自定义类型并以这种方式获得更多安全性。这些工具将在本书的后面部分对任何人进行描述,因为编写可靠的 Erlang 程序不需要使用任何这些工具。因为我们将在后面介绍它们,所以我将对安装、运行它们等提供很少的细节。再次强调,本节是为那些真正无法没有高级类型系统的程序员准备的。

多年来,人们一直尝试在 Erlang 之上构建类型系统。其中一次尝试发生在 1997 年,由格拉斯哥哈斯克尔编译器的主要开发人员之一 Simon Marlow 和 Philip Wadler(他参与了 Haskell 的设计,并为单子理论做出了贡献)进行 (阅读该论文 关于该类型系统的)。Joe Armstrong 后来 对该论文发表了评论

有一天,菲尔给我打电话,宣布 a) Erlang 需要一个类型系统,b) 他已经编写了一个类型系统的小型原型,c) 他有一个为期一年的休假,并将为 Erlang 编写一个类型系统,并且“我们感兴趣吗?”回答 -“是的”。

菲尔·沃德勒和西蒙·马洛在类型系统上工作了一年多,结果发表在 [20] 中。该项目的结果有些令人失望。首先,只有语言的一个子集是可类型检查的,主要的遗漏是缺乏进程类型和进程间消息的类型检查。

进程和消息都是 Erlang 的核心功能之一,这可能解释了为什么该系统从未被添加到语言中。其他对 Erlang 进行类型的尝试也失败了。HiPE 项目(旨在使 Erlang 的性能大幅提升)的努力产生了 Dialyzer,这是一种今天仍在使用的静态分析工具,它拥有自己的类型推断机制。

从中产生的类型系统基于成功类型,这是一个不同于 Hindley-Milner 或软类型类型系统的概念。成功类型在概念上很简单:类型推断不会试图找到每个表达式的确切类型,但它会保证它推断出的类型是正确的,并且它发现的类型错误确实是错误。

最好的例子来自函数 and 的实现,该函数通常接受两个布尔值,如果它们都为 true 则返回 'true',否则返回 'false'。在 Haskell 的类型系统中,这将被写为 and :: bool -> bool -> bool。如果 and 函数必须在 Erlang 中实现,则可以按照以下方式完成

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

在成功类型推断下,函数的推断类型将是 `and(_,_) -> bool()`,其中 _ 代表“任何”。原因很简单:在运行 Erlang 程序并使用参数 `false` 和 `42` 调用此函数时,结果仍然是 `false`。模式匹配中使用 `_` 通配符使得实际上,只要其中一个参数是 `false`,任何参数都可以传递,函数都能正常工作。ML 类型在这种情况下会引发错误(而且用户会崩溃),但 Erlang 不会。如果您想了解有关成功类型实现的论文,可能会更容易理解,该论文解释了这种行为背后的原理。强烈建议所有类型爱好者阅读它,这是一个有趣且实用的实现定义。

有关类型定义和函数注释的详细信息在 Erlang 增强提案 8 (EEP 8) 中有所描述。如果您有兴趣在 Erlang 中使用成功类型,请查看 TypEr 应用程序 和 Dialyzer,这两个都是标准发行版的一部分。要使用它们,请在命令行中输入 `$ typer --help` 和 `$ dialyzer --help` (如果它们在当前目录中可访问,则在 Windows 中输入 `typer.exe --help` 和 `dialyzer.exe --help`)。

TypEr 将用于为函数生成类型注释。当应用于这个小型 FIFO 实现 时,它会输出以下类型注释

%% File: fifo.erl
%% --------------
-spec new() -> {'fifo',[],[]}.
-spec push({'fifo',_,_},_) -> {'fifo',nonempty_maybe_improper_list(),_}.
-spec pop({'fifo',_,maybe_improper_list()}) -> {_,{'fifo',_,_}}.
-spec empty({'fifo',_,_}) -> bool().
Implementation of fifo (queues): made out of two stacks (last-in first-out).

这几乎是正确的。应该避免不正确的列表,因为 `lists:reverse/1` 不支持它们,但是有人绕过模块的接口就可以通过它提交一个不正确的列表。在这种情况下,函数 `push/2` 和 `pop/2` 可能会在导致异常之前成功调用几次。这要么告诉我们要添加守卫,要么手动细化类型定义。假设我们添加了签名 `-spec push({fifo,list(),list()},_) -> {fifo,nonempty_list(),list()}.`,并向模块添加一个将不正确的列表传递给 `push/2` 的函数:当在 Dialyzer 中扫描它(它会检查和匹配类型)时,会输出错误消息 `"The call fifo:push({fifo,[1|2],[]},3) breaks the contract '<Type definition here>'`。

Dialyzer 只有在代码会导致其他代码出错时才会报错,如果它报错了,通常是正确的(它还会对更多问题进行报错,例如永远不会匹配的子句或一般的差异)。也可以使用 Dialyzer 编写和分析多态数据类型:`hd()` 函数可以注释为 `-spec([A]) -> A.` 并被正确分析,尽管 Erlang 程序员似乎很少使用这种类型语法。

不要过度迷信
您无法期望 Dialyzer 和 TypEr 做到的事情包括带有构造函数的类型类、一阶类型和递归类型。Erlang 的类型只是注释,没有实际编译效果或限制,除非您自己强制执行它们。类型检查器永远不会告诉你一个现在可以运行的程序(或者已经运行了两年的程序)存在类型错误,即使它在运行时实际上没有产生任何错误(尽管你可能运行了有错误的代码……)。

虽然递归类型非常有趣,但它们不太可能出现在当前版本的 TypEr 和 Dialyzer 中(上面的论文解释了原因)。目前,您可以通过手动添加一到两级来定义自己的类型以模拟递归类型。

它当然不是一个完整的类型系统,不像 Scala、Haskell 或 Ocaml 等语言那样严格或强大。它的警告和错误消息通常也很隐晦,并不友好。然而,如果真的无法在动态世界中生存或希望获得额外的安全性,它仍然是一个很好的折衷方案;只需要把它当作工具箱中的一个工具,而不是更多。

更新
从 R13B04 版本开始,递归类型现在是 Dialyzer 的实验性功能。这使得之前的“不要过度迷信”部分内容错误。我的错。

请注意,类型文档也已成为官方文档(尽管它仍在不断变化)并且比 EEP8 中的内容更加完整。