本 EEP 提议向 Erlang 添加名义类型 -nominal
,其中更改可以专门应用于 Dialyzer(模解析)。作为副作用,名义类型可以编码不透明类型,从而提高 Dialyzer 的可维护性。名义类型已在动态语言中使用过,例如 Flow,或在 TypeScript 和静态语言(如 Rust(元组是结构类型,结构体是名义类型)、OCaml(记录是名义类型,对象是结构类型)、Scala 和/或 Swift)中进行编码。
Erlang 是一种动态类型语言,具有许多用于静态类型检查的可选工具。现有工具采用了许多不同的类型范例,包括成功类型、渐进类型和集合论类型系统等。虽然所有这些类型系统在如何进行类型检查和推断方面有所不同,但它们都是结构类型系统。如果两个类型的结构相同,则它们被视为等效。类型比较基于类型的结构,而不是基于用户显式定义它们的方式。例如,在以下示例中,meter()
和 foot()
在结构类型系统中是等效的,因为它们具有相同的结构。这两种类型可以互换使用。它们都与基本类型 integer()
没有区别。
-type meter() :: integer().
-type foot() :: integer().
名义类型是一种替代类型系统,其中当且仅当两个类型使用相同的类型名称声明时,它们才是等效的。如果上面的例子是在名义类型系统中,则 meter()
和 foot()
不再兼容,因为它们有不同的名称。每当函数期望类型为 meter()
时,传入类型 foot()
将导致错误。名义类型可以防止意外误用具有相同结构的类型。它是与 Erlang 所有现有类型系统正交的有用功能。
名义类型可以被视为具有宽松语义的不透明类型。名义类型和不透明类型的共同点是,名称不同的类型不兼容。名义类型允许对内部结构进行模式匹配,这在不透明类型中是被禁止的。除此之外,通过使用名义类型编码不透明类型,我们可以使 Dialyzer 更快、更易于维护。
此 EEP 提议使用一个新语法 -nominal
来声明名义类型。它可以像以下示例中那样使用
-module(example).
% Declaration of nominal types
-nominal meter() :: integer().
-nominal foot() :: integer().
% Constructor for nominal types
-spec meter_ctor(integer()) -> meter().
meter_ctor(X) -> X.
% Function that has its input and/or output as nominal types
-spec meter_to_foot(meter()) -> foot().
meter_to_foot(X) -> X * 3.
名义类型的声明和使用方式与其他用户定义的类型相同。编译器识别该语法,但不执行额外的类型检查。名义类型的类型检查在 Dialyzer 中完成。
根据名义类型检查规则,如果两个名义类型具有不同的名称,并且一个类型没有嵌套在另一个类型中,则它们不兼容。
例如,如果我们从上面的示例继续
-spec foo() -> foot().
foo() -> meter_ctor(24).
% Output type: meter().
% Expected type: foot().
% Result: Dialyzer error.
Dialyzer 为函数 foo()
返回以下错误
Invalid type specification for function example:foo/0.
The success typing is example:foo
() ->
nominal({'example', 'meter', 0, 'transparent'}, integer())
But the spec is example:foo
() -> foot()
The return types do not overlap
另一方面,名义类型检查不会强制用户包装或注释值为名义类型。在以下示例中,Dialyzer 不会为函数 bar()
返回任何错误。meter_to_foot()
的规范期望类型 meter()
作为输入。允许传入类型 integer()
,因为 meter()
被定义为具有类型 integer()
。只有当输入是不同的基本类型时,例如 atom()
,Dialyzer 才会拒绝它。
-spec bar() -> foot().
bar() -> meter_to_foot(24).
% Input type: integer().
% Expected type: meter().
% Result: No error.
当期望名义类型时,也允许传入具有相同结构的类型。在以下示例中,Dialyzer 不会为函数 qaz()
返回任何错误。meter_ctor()
的规范期望类型 integer()
作为输入。允许传入类型 meter()
,因为 meter()
被定义为具有类型 integer()
。类似地,当期望 integer()
时,也允许传入类型 foot()
。
-spec qaz() -> integer().
qaz() -> meter_ctor(meter_ctor(24)).
% Input of the outer meter_ctor(): meter().
% Expected type: integer().
% Result: No error.
还值得注意的是,支持嵌套的名义类型。在以下示例中
-nominal state() :: integer().
-nominal container() :: state().
-nominal record_container() :: #{a => state(), b => [container()|atom()]}.
Dialyzer 中的名义类型检查可以正确识别 container()
可以安全地用于任何期望类型 state()
的情况,正如 state()
可以安全地用于任何期望类型 integer()
的情况一样。使用 [state()]
作为 record_container()
构造的第二个参数是允许的,即使 container()
和 state()
具有不同的名称。
为了指定名义类型检查规则,需要定义三个术语。这些定义的范围仅限于此 EEP。
对于两个名义类型 s()
和 t()
,如果 t()
嵌套在 s()
中,则 s()
是 t()
的名义子类型,并且 t()
是 s()
的名义超类型。
s()
是 t()
的名义子类型的情况t()
可以直接嵌套在 s()
中。
-nominal s() :: t().
t()
可以嵌套在其他名义类型中,然后再嵌套在 s()
中。
-nominal s() :: nominal_1().
-nominal nominal_1() :: nominal_2().
-nominal nominal_2() :: t().
如果非名义类型的结构被类型检查器认为是兼容的,则该非名义类型与名义或非名义类型兼容。
对于 Dialyzer,如果两个类型共享公共值,则它们是兼容的。函数 erl_types:t_inf/2
计算 2 个类型的交集。具有非空交集的两个类型在结构上是兼容的。
integer()
在结构上兼容。(它们的交集是值 4711。)list(any_type)
和 []
在结构上兼容。(它们的交集是 []
。)-nominal t() :: integer()
和 4711 在结构上兼容。(它们的交集是值 t() :: 4711
。)-nominal t() :: non_neg_integer()
和 neg_integer()
在结构上不兼容。(没有值同时属于 non_neg_integer()
和 neg_integer()
。)本 EEP 提出的名义类型检查规则可以总结如下
一个 -spec
声明参数或返回类型为名义类型 a/0
(或任何其他元数)的函数,接受或可能返回
a/0
a/0
的名义超类型或子类型一个 -spec
声明参数或返回类型为结构类型 b/0
(或任何其他元数)的函数,接受或可能返回
当期望名义子类型时,允许使用超类型,原因如下 3 个(即使子类型关系不是对称的)
此名义类型实现的一个好处是它们可以编码不透明类型,因此可以减少 OTP 团队的维护工作。名义类型的逻辑完全取代了 Dialyzer 中不透明类型的逻辑。此外,该实现使不透明类型的类型检查以线性时间而不是四次时间运行。
此更新为不透明类型带来了许多性能和代码可维护性方面的优势,并且不需要用户端的任何更改。
以下列表总结了不透明类型的不变和变化方面,以及证明此更新合理性的简短讨论
当前实现:https://github.com/lucioleKi/otp/tree/cleanup
不包含不透明类型或不使用 Dialyzer 的代码没有任何更改。
包含不透明类型并使用 Dialyzer 的代码可能会遇到上述更改。
如果其他类型检查器未实现名义类型检查,则它们可以像处理 -type
一样处理 -nominal
。
如果其他类型检查器选择实现名义类型检查,则它们应以与此 EEP 一致的方式实现它。目的是确保名义类型保持相同的语义,并在不同的类型检查器中以相同的方式进行类型检查。
本文档置于公有领域或 CC0-1.0-Universal 许可之下,以较宽松者为准。