7 类型和函数规范
7.1 Erlang 类型语言
Erlang 是一种动态类型语言。但是,它提供了一种表示法来声明 Erlang 术语的集合以形成特定类型。这有效地形成了所有 Erlang 术语集的特定子类型。
随后,这些类型可以用于指定记录字段的类型,以及函数的参数和返回类型。
类型信息可用于以下目的:
- 记录函数接口
- 为错误检测工具(如 Dialyzer)提供更多信息
- 被文档工具(如 EDoc)利用,以生成各种形式的程序文档
预计本节中描述的类型语言将取代并替换 EDoc 使用的纯粹基于注释的 @type 和 @spec 声明。
7.2 类型及其语法
类型描述 Erlang 术语的集合。类型由一组预定义类型构成,并由其构建,例如 integer()、atom() 和 pid()。预定义类型表示通常无限的 Erlang 术语集合,这些术语属于此类型。例如,类型 atom() 表示所有 Erlang 原子的集合。
对于整数和原子,允许使用单例类型;例如,整数 -1 和 42,或原子 'foo' 和 'bar'。所有其他类型都是使用预定义类型或单例类型的并集构建的。在一个类型与它的一个子类型之间的类型并集中,子类型将被超类型吸收。因此,并集将被视为子类型不是并集的组成部分。例如,类型并集
atom() | 'bar' | integer() | 42
描述的术语集与类型并集相同
atom() | integer()
由于类型之间存在子类型关系,因此除 dynamic() 之外的所有类型都形成了一个格,其中最顶层的元素 any() 表示所有 Erlang 术语的集合,最底层的元素 none() 表示空术语集。
为了促进 Erlang 的 逐步类型化,提供了类型 `dynamic()`。它类似于 Python 中的 Any,TypeScript 中的 any 以及 Hack 中的 dynamic。`any()` 和 `dynamic()` 与 成功类型化 的交互方式相同,因此 Dialyzer 不会区分它们。
预定义类型的集合和类型的语法如下:
Type :: any() %% The top type, the set of all Erlang terms | none() %% The bottom type, contains no terms | dynamic() | pid() | port() | reference() | [] %% nil | Atom | Bitstring | float() | Fun | Integer | List | Map | Tuple | Union | UserDefined %% described in Type Declarations of User-Defined Types Atom :: atom() | Erlang_Atom %% 'foo', 'bar', ... Bitstring :: <<>> | <<_:M>> %% M is an Integer_Value that evaluates to a positive integer | <<_:_*N>> %% N is an Integer_Value that evaluates to a positive integer | <<_:M, _:_*N>> Fun :: fun() %% any function | fun((...) -> Type) %% any arity, returning Type | fun(() -> Type) | fun((TList) -> Type) Integer :: integer() | Integer_Value | Integer_Value..Integer_Value %% specifies an integer range Integer_Value :: Erlang_Integer %% ..., -1, 0, 1, ... 42 ... | Erlang_Character %% $a, $b ... | Integer_Value BinaryOp Integer_Value | UnaryOp Integer_Value BinaryOp :: '*' | 'div' | 'rem' | 'band' | '+' | '-' | 'bor' | 'bxor' | 'bsl' | 'bsr' UnaryOp :: '+' | '-' | 'bnot' List :: list(Type) %% Proper list ([]-terminated) | maybe_improper_list(Type1, Type2) %% Type1=contents, Type2=termination | nonempty_improper_list(Type1, Type2) %% Type1 and Type2 as above | nonempty_list(Type) %% Proper non-empty list Map :: #{} %% denotes the empty map | #{AssociationList} Tuple :: tuple() %% denotes a tuple of any size | {} | {TList} AssociationList :: Association | Association, AssociationList Association :: Type := Type %% denotes a mandatory association | Type => Type %% denotes an optional association TList :: Type | Type, TList Union :: Type1 | Type2
整数值可以是整数或字符字面量,也可以是表达式,这些表达式包含可能嵌套的单目或二目运算,这些运算求值为整数。此类表达式也可用于位串和范围。
位串的一般形式为 <<_:M, _:_*N>>,其中 M 和 N 必须求值为正整数。它表示一个长度为 M + (k*N) 位的位串(即,一个以 M 位开头,并以 k 个 N 位段(每个段 k 也是一个正整数)结尾的位串)。符号 <<_:_*N>>、<<_:M>> 和 <<>> 是对 M 或 N 或两者都为零的情况的方便简写。
由于列表的使用很普遍,因此它们有简写类型符号。类型 list(T) 和 nonempty_list(T) 的简写分别是 [T] 和 [T,...]。这两个简写之间的唯一区别是 [T] 可以是空列表,而 [T,...] 则不能。
注意,list() 的简写(即,未知类型元素的列表)是 [_](或 [any()]),而不是 []。符号 [] 指定空列表的单例类型。
映射类型的一般形式为 #{AssociationList}。AssociationList 中的键类型允许重叠,如果重叠,则最左边的关联优先。如果一个关联类型是强制性的,则需要存在具有该类型的关联。在可选关联类型的情况下,不需要键类型存在。
符号 #{} 指定空映射的单例类型。注意,此符号不是 map() 类型的简写。
为了方便起见,以下类型也是内置的。它们可以被认为是预定义的别名,用于表格中也显示的类型并集。
内置类型 | 定义为 |
term() | any() |
binary() | <<_:_*8>> |
nonempty_binary() | <<_:8, _:_*8>> |
bitstring() | <<_:_*1>> |
nonempty_bitstring() | <<_:1, _:_*1>> |
boolean() | 'false' | 'true' |
byte() | 0..255 |
char() | 0..16#10ffff |
nil() | [] |
number() | integer() | float() |
list() | [any()] |
maybe_improper_list() | maybe_improper_list(any(), any()) |
nonempty_list() | nonempty_list(any()) |
string() | [char()] |
nonempty_string() | [char(),...] |
iodata() | iolist() | binary() |
iolist() | maybe_improper_list(byte() | binary() | iolist(), binary() | []) |
map() | #{any() => any()} |
function() | fun() |
module() | atom() |
mfa() | {module(),atom(),arity()} |
arity() | 0..255 |
identifier() | pid() | port() | reference() |
node() | atom() |
timeout() | 'infinity' | non_neg_integer() |
no_return() | none() |
此外,还存在以下三种内置类型,可以认为它们定义如下,尽管严格来说它们的“类型定义”与上面定义的类型语言不符合语法。
内置类型 | 可以认为由以下语法定义 |
non_neg_integer() | 0.. |
pos_integer() | 1.. |
neg_integer() | ..-1 |
还存在以下内置列表类型,但它们预计很少使用。因此,它们有很长的名称
nonempty_maybe_improper_list() :: nonempty_maybe_improper_list(any(), any()) nonempty_improper_list(Type1, Type2) nonempty_maybe_improper_list(Type1, Type2)
其中最后两种类型定义了人们期望的 Erlang 术语集。
同样为了方便起见,允许使用记录符号。记录是对应元组的简写
Record :: #Erlang_Atom{} | #Erlang_Atom{Fields}
记录被扩展为可能包含类型信息。这将在 记录中的类型信息 中介绍。
重新定义内置类型
从 Erlang/OTP 26 开始,允许定义具有与内置类型相同名称的类型。
建议避免故意重复使用内置名称,因为这会导致混淆。但是,当 Erlang/OTP 版本引入新的类型时,碰巧定义了具有相同名称的自身类型的代码将继续工作。
例如,假设 Erlang/OTP 42 版本引入了一个新的类型 gadget(),其定义如下:
-type gadget() :: {'gadget', reference()}.
进一步假设一些代码有它自己的(不同的)gadget() 定义,例如
-type gadget() :: #{}.
由于允许重新定义,因此代码仍然可以编译(但会发出警告),并且 Dialyzer 不会发出任何额外的警告。
7.3 用户定义类型的类型声明
如上所述,类型的基本语法是原子后跟圆括号。使用 -type 和 -opaque 属性声明新类型,如下所示:
-type my_struct_type() :: Type. -opaque my_opaq_type() :: Type.
类型名称是原子 my_struct_type,后跟圆括号。 Type 是上一节中定义的类型。当前限制是 Type 只能包含预定义类型,或者以下两种用户定义类型之一:
- 模块本地类型,即具有在模块代码中存在的定义
- 远程类型,即在其他模块中定义并由其他模块导出的类型;稍后将详细介绍。
对于模块局部类型,它们的定义必须存在于模块中的限制由编译器强制执行,并导致编译错误。(类似的限制目前存在于记录中。)
类型声明也可以通过在括号中包含类型变量来进行参数化。类型变量的语法与 Erlang 变量相同,即以大写字母开头。当然,这些变量可以 - 而且应该 - 出现在定义的右侧。下面是一个具体示例
-type orddict(Key, Val) :: [{Key, Val}].
一个模块可以导出一些类型,以声明其他模块允许将它们引用为 **远程类型**。此声明具有以下形式
-export_type([T1/A1, ..., Tk/Ak]).
这里 Ti 是原子(类型的名称),Ai 是它们的论据
示例
-export_type([my_struct_type/0, orddict/2]).
假设这些类型是从模块 'mod' 导出的,你可以使用以下远程类型表达式从其他模块引用它们
mod:my_struct_type() mod:orddict(atom(), term())
不允许引用未声明为导出的类型。
声明为 opaque 的类型表示一组项,其结构不应该在定义它们的模块之外可见。也就是说,只有定义它们的模块才能依赖于它们的项结构。因此,这种类型作为模块局部类型没有太大意义 - 模块局部类型无法被其他模块访问 - 并且始终要导出。
阅读更多关于 不透明类型
7.4 记录声明中的类型信息
可以在记录的声明中指定记录字段的类型。此语法如下
-record(rec, {field1 :: Type1, field2, field3 :: Type3}).
对于没有类型注释的字段,它们的类型默认为 any()。也就是说,前面的示例是以下内容的简写
-record(rec, {field1 :: Type1, field2 :: any(), field3 :: Type3}).
在存在字段的初始值的情况下,类型必须在初始化之后声明,如下所示
-record(rec, {field1 = [] :: Type1, field2, field3 = 42 :: Type3}).
字段的初始值必须与(即为)相应类型的一员。这由编译器检查,如果检测到违规,则会导致编译错误。
在 Erlang/OTP 19 之前,对于没有初始值的字段,单例类型 'undefined' 会被添加到所有声明的类型中。换句话说,以下两个记录声明具有相同的效果
-record(rec, {f1 = 42 :: integer(), f2 :: float(), f3 :: 'a' | 'b'}). -record(rec, {f1 = 42 :: integer(), f2 :: 'undefined' | float(), f3 :: 'undefined' | 'a' | 'b'}).
现在情况不再如此。如果你需要在你的记录字段类型中使用 'undefined',你必须将其显式添加到类型规范中,如第二个示例所示。
任何记录,无论是否包含类型信息,一旦定义,都可以使用以下语法用作类型
#rec{}
此外,通过添加有关字段的类型信息,可以在使用记录类型时进一步指定记录字段,如下所示
#rec{some_field :: Type}
任何未指定的字段都假定具有原始记录声明中的类型。
当记录用于为 ETS 和 Mnesia 匹配函数创建模式时,Dialyzer 可能需要一些帮助来避免发出错误警告。例如
-type height() :: pos_integer(). -record(person, {name :: string(), height :: height()}). lookup(Name, Tab) -> ets:match_object(Tab, #person{name = Name, _ = '_'}).
Dialyzer 会发出警告,因为 '_' 不在记录字段 height 的类型中。
建议的处理方法是声明最小的记录字段类型以满足你的所有需求,然后根据需要创建细化。修改后的示例
-record(person, {name :: string(), height :: height() | '_'}). -type person() :: #person{height :: height()}.
在规范和类型声明中,类型 person() 优先于 #person{}。
7.5 函数规范
函数的规范(或契约)使用 -spec 属性给出。通用格式如下
-spec Function(ArgType1, ..., ArgTypeN) -> ReturnType.
具有相同名称 Function 的函数实现必须存在于当前模块中,并且函数的元数必须与参数数量匹配,否则会发生编译错误。
只要 Module 是当前模块的名称,以下带有模块名称的更长格式也是有效的。这对于文档目的很有用。
-spec Module:Function(ArgType1, ..., ArgTypeN) -> ReturnType.
此外,为了文档目的,可以给出参数名称
-spec Function(ArgName1 :: Type1, ..., ArgNameN :: TypeN) -> RT.
函数规范可以重载。也就是说,它可以有几种类型,用分号 (;) 分隔
-spec foo(T1, T2) -> T3 ; (T4, T5) -> T6.
当前的限制,目前会导致 Dialyzer 发出警告,是参数类型的域不能重叠。例如,以下规范会导致警告
-spec foo(pos_integer()) -> pos_integer() ; (integer()) -> integer().
类型变量可以在规范中使用,以指定函数的输入和输出参数之间的关系。例如,以下规范定义了多态恒等函数的类型
-spec id(X) -> X.
请注意,以上规范并未以任何方式限制输入和输出类型。这些类型可以通过类似于守卫的子类型约束来约束,并提供有界量化
-spec id(X) -> X when X :: tuple().
目前,:: 约束(读作「是...的子类型」)是在 -spec 属性的 when 部分中可以使用的唯一守卫约束。
以上函数规范使用相同类型变量的多次出现。这提供了比以下函数规范更多的类型信息,其中类型变量缺失
-spec id(tuple()) -> tuple().
后一种规范说,函数接收一个元组并返回一个元组。具有 X 类型变量的规范指定函数接收一个元组并返回 **相同的** 元组。
但是,由处理规范的工具选择是否考虑此额外信息。
一个 :: 约束的范围是它出现的 (...) -> RetType 规范。为了避免混淆,建议在重载契约的不同组成部分中使用不同的变量,如以下示例所示
-spec foo({X, integer()}) -> X when X :: atom() ; ([Y]) -> Y when Y :: number().
Erlang 中的一些函数并不打算返回;要么是因为它们定义了服务器,要么是因为它们用于抛出异常,如下面的函数所示
my_error(Err) -> erlang:throw({error, Err}).
对于此类函数,建议使用特殊类型 no_return() 来表示它们的“返回值”,通过以下形式的契约
-spec my_error(term()) -> no_return().
Erlang 使用简写形式 _ 作为匿名类型变量,等效于 term() 或 any()。例如,以下函数
-spec Function(string(), _) -> string().
等效于
-spec Function(string(), any()) -> string().