作者
Isabell Huang <isabell(at)erlang(dot)org>
状态
已接受
类型
标准跟踪
创建时间
2024年3月18日

EEP 69: 名义类型 #

摘要 #

本 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 个类型的交集。具有非空交集的两个类型在结构上是兼容的。

  • 示例
    • 4711 和 42 在结构上不兼容。(没有整数值可以同时是 4711 和 42。)
    • 4711 和 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 个(即使子类型关系不是对称的)

  • 为了最大限度地减少用户将值转换为名义类型或重写规范的工作量。
  • Dialyzer(成功类型)在结构类型之间允许这样做。大多数现有的 Erlang 类型检查器也允许这样做。
  • 为了使名义类型检查更灵活而不是限制性。

优化不透明类型的类型检查 #

此名义类型实现的一个好处是它们可以编码不透明类型,因此可以减少 OTP 团队的维护工作。名义类型的逻辑完全取代了 Dialyzer 中不透明类型的逻辑。此外,该实现使不透明类型的类型检查以线性时间而不是四次时间运行。

此更新为不透明类型带来了许多性能和代码可维护性方面的优势,并且不需要用户端的任何更改。

以下列表总结了不透明类型的不变和变化方面,以及证明此更新合理性的简短讨论

无需用户更改 #

  • 编译器对不透明类型的处理方式保持不变。
  • 即使在 Dialyzer 中使用名义类型编码,不透明类型也保留其语义。
  • 不透明类型的文档保持隐藏。

新功能 #

  • 现在,对不透明类型进行模式匹配会引发警告,而不会停止 Dialyzer 的分析。(以前,对不透明类型进行模式匹配会引发错误并导致 Dialyzer 停止分析,因此 Dialyzer 无法捕获其他错误。)
  • 完全支持嵌套不透明类型的类型检查,就像名义类型一样。

好处 #

  • 性能:以前,不透明类型在 Dialyzer 中的分析时间最多为四次。现在,它们的分析时间最多为线性时间。
  • 可维护性:名义类型检查不会为 Dialyzer 引入特殊情况。现在可以删除专门针对不透明类型的所有额外类型检查函数。
  • 模式匹配:Dialyzer 对不透明类型的分析不会因模式匹配违规而硬停止,这会产生更精确的警告,因为分析会继续进行。
  • 嵌套不透明类型:检查嵌套不透明类型的能力与名义类型一样有用
    对于不透明类型。

参考实现 #

当前实现:https://github.com/lucioleKi/otp/tree/cleanup

向后兼容性 #

不包含不透明类型或不使用 Dialyzer 的代码没有任何更改。

包含不透明类型并使用 Dialyzer 的代码可能会遇到上述更改。

对于其他类型检查器 #

如果其他类型检查器未实现名义类型检查,则它们可以像处理 -type 一样处理 -nominal

如果其他类型检查器选择实现名义类型检查,则它们应以与此 EEP 一致的方式实现它。目的是确保名义类型保持相同的语义,并在不同的类型检查器中以相同的方式进行类型检查。

版权 #

本文档置于公有领域或 CC0-1.0-Universal 许可之下,以较宽松者为准。