7  类型和函数规范

7  类型和函数规范

Erlang 是一种动态类型语言。但是,它提供了一种表示法来声明 Erlang 术语的集合以形成特定类型。这有效地形成了所有 Erlang 术语集的特定子类型。

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

类型信息可用于以下目的:

  • 记录函数接口
  • 为错误检测工具(如 Dialyzer)提供更多信息
  • 被文档工具(如 EDoc)利用,以生成各种形式的程序文档

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

类型描述 Erlang 术语的集合。类型由一组预定义类型构成,并由其构建,例如 integer()atom()pid()。预定义类型表示通常无限的 Erlang 术语集合,这些术语属于此类型。例如,类型 atom() 表示所有 Erlang 原子的集合。

对于整数和原子,允许使用单例类型;例如,整数 -142,或原子 '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>>,其中 MN 必须求值为正整数。它表示一个长度为 M + (k*N) 位的位串(即,一个以 M 位开头,并以 kN 位段(每个段 k 也是一个正整数)结尾的位串)。符号 <<_:_*N>><<_:M>><<>> 是对 MN 或两者都为零的情况的方便简写。

由于列表的使用很普遍,因此它们有简写类型符号。类型 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()

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

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

内置类型 可以认为由以下语法定义
non_neg_integer() 0..
pos_integer() 1..
neg_integer() ..-1

表 7.2:  额外的内置类型

注意

还存在以下内置列表类型,但它们预计很少使用。因此,它们有很长的名称

  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 变量相同,即以大写字母开头。当然,这些变量可以 - 而且应该 - 出现在定义的右侧。下面是一个具体示例

  -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.

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

  -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().