查看源代码 类型和函数规范

Erlang 类型语言

Erlang 是一种动态类型语言。不过,它也提供了一种用于声明 Erlang 项集合以形成特定类型的表示法。这有效地形成了所有 Erlang 项集合的特定子类型。

随后,这些类型可以用于指定记录字段的类型以及函数的参数和返回类型。

类型信息可以用于以下方面

  • 记录函数接口
  • 为错误检测工具(如 Dialyzer)提供更多信息
  • 被文档工具(如 ExDocEDoc)利用,以生成文档

本节中描述的类型语言预计会取代并替换 EDoc 使用的纯粹基于注释的 @type@spec 声明。

类型及其语法

类型描述了 Erlang 项的集合。类型由一组预定义的类型构成并从中构建,例如,integer/0atom/0pid/0。预定义类型表示属于该类型的通常无限的 Erlang 项集。例如,类型 atom/0 表示所有 Erlang 原子的集合。

对于整数和原子,允许使用单例类型;例如,整数 -142,或原子 'foo''bar'。所有其他类型都使用预定义类型或单例类型的并集构建。在类型与其子类型之一的类型并集中,子类型会被超类型吸收。因此,该并集会被视为子类型不是该并集的组成部分。例如,类型并集

atom() | 'bar' | integer() | 42

描述与类型并集相同的项集

atom() | integer()

由于类型之间存在子类型关系,所有类型(除了 dynamic/0)都形成一个格,其中最顶层的元素 any/0 表示所有 Erlang 项的集合,而最底层的元素 none/0 表示空的项集。

为了便于 Erlang 的渐进式类型化,提供了类型 dynamic/0。类型 dynamic/0 表示静态未知的类型。它类似于 Python 中的 Any、TypeScript 中的 any 和 Hack 中的 dynamicany/0dynamic/0成功类型化的交互方式相同,因此 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>>,其中 MN 必须计算为正整数。它表示一个 M + (k*N) 位长的位串(即,一个以 M 位开头并以 kN 位段继续的位串,其中 k 也是一个正整数)。<<_:_*N>><<_:M>><<>> 表示 MN 或两者都为零的情况的简便简写。

由于列表是常用的,它们有简写的类型表示法。类型 list(T)nonempty_list(T) 分别具有简写形式 [T][T,...]。这两个简写形式之间的唯一区别是 [T] 可以是空列表,而 [T,...] 不能。

请注意,list/0(即,未知类型元素的列表)的简写是 [_](或 [any()]),而不是 []。表示法 [] 指定空列表的单例类型。

映射类型的一般形式是 #{AssociationList}AssociationList 中的键类型可以重叠,如果重叠,则最左边的关联优先。如果映射关联属于此类型,则它在 AssociationList 中具有键。AssociationList 可以包含强制性 (:=) 和可选 (=>) 关联类型。如果关联类型是强制性的,则需要存在具有该类型的关联。对于可选关联类型,不需要存在键类型。

表示法 #{} 指定空映射的单例类型。请注意,此表示法不是 map/0 类型的简写。

为方便起见,以下类型也是内置的。它们可以被认为是表中也显示的类型并集的预定义别名。

内置类型定义为
term/0any/0
binary/0<<_:_*8>>
nonempty_binary/0<<_:8, _:_*8>>
bitstring/0<<_:_*1>>
nonempty_bitstring/0<<_:1, _:_*1>>
boolean/0'false' | 'true'
byte/00..255
char/00..16#10ffff
nil/0[]
number/0integer/0 | float/0
list/0[any()]
maybe_improper_list/0maybe_improper_list(any(), any())
nonempty_list/0nonempty_list(any())
string/0[char()]
nonempty_string/0[char(),...]
iodata/0iolist() | binary()
iolist/0maybe_improper_list(byte() | binary() | iolist(), binary() | [])
map/0#{any() => any()}
function/0fun()
module/0atom/0
mfa/0{module(),atom(),arity()}
arity/00..255
identifier/0pid() | port() | reference()
node/0atom/0
timeout/0'infinity' | non_neg_integer()
no_return/0none/0

表:内置类型,预定义别名

此外,还存在以下三种内置类型,可以认为它们定义如下,尽管严格来说,它们的“类型定义”不符合上面定义的类型语言的语法。

内置类型可以认为由以下语法定义
non_neg_integer/00..
pos_integer/01..
neg_integer/0..-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 不会发出任何额外的警告。

用户定义类型的类型声明

如前所述,类型的基本语法是一个原子后跟闭括号。新类型使用 -type-opaque 属性声明,如下所示

-type my_struct_type() :: Type.
-opaque my_opaq_type() :: Type.

类型名称是原子 my_struct_type,后跟括号。Type 是上一节中定义的类型。当前的限制是 Type 只能包含预定义的类型,或以下任一的用户定义的类型

  • 模块本地类型,即在模块代码中存在的定义
  • 远程类型,即在其他模块中定义并由其导出的类型;稍后会详细介绍。

对于模块本地类型,编译器会强制执行其定义存在于模块中的限制,并导致编译错误。(目前记录也存在类似的限制。)

类型声明还可以通过在括号之间包含类型变量进行参数化。类型变量的语法与 Erlang 变量相同,即以大写字母开头。这些变量将出现在定义的 RHS 上。下面是一个具体的示例

-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 的类型表示一组术语,这些术语的结构不应从其定义模块外部可见。也就是说,只有定义它们的模块才允许依赖于它们的术语结构。因此,这种类型作为模块局部类型没有太大意义 - 模块局部类型无论如何都不能被其他模块访问 - 并且总是要导出的。

阅读更多关于 不透明类型 的信息

记录声明中的类型信息

记录字段的类型可以在记录的声明中指定。其语法如下:

-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{}

函数规范

函数的规范(或契约)是使用 -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.

请注意,以上规范没有以任何方式限制输入和输出类型。这些类型可以通过类似 guard 的子类型约束来约束,并提供有界量化:

-spec id(X) -> X when X :: tuple().

目前,:: 约束(读作“是子类型”)是唯一可以在 -spec 属性的 when 部分中使用的 guard 约束。

注意

上面的函数规范使用了同一个类型变量的多次出现。这比以下缺少类型变量的函数规范提供了更多的类型信息:

-spec id(tuple()) -> tuple().

后一个规范说明该函数接受某个元组并返回某个元组。带有 X 类型变量的规范指定该函数接受一个元组并返回相同的元组。

但是,是否考虑此额外信息取决于处理规范的工具。

:: 约束的作用域是它出现的 (...) -> RetType 规范之后。为避免混淆,建议在重载契约的不同组成部分中使用不同的变量,如下例所示:

-spec foo({X, integer()}) -> X when X :: atom();
         ([Y]) -> Y when Y :: number().

Erlang 中的某些函数不打算返回;要么是因为它们定义了服务器,要么是因为它们用于引发异常,如下面的函数所示:

my_error(Err) -> throw({error, Err}).

对于此类函数,建议使用特殊的 no_return/0 类型作为它们的“返回”,通过以下形式的契约:

-spec my_error(term()) -> no_return().

注意

Erlang 使用简写版本 _ 作为匿名类型变量,等效于 term/0any/0。例如,以下函数:

-spec Function(string(), _) -> string().

等效于:

-spec Function(string(), any()) -> string().