Elixir v1.20 发布:如今是一门渐进类型语言
- #Elixir
- #类型系统
- #渐进类型
- #Hacker News
- #elixir-lang.org
Elixir v1.20 发布:如今是一门渐进类型语言 2026年6月3日 · José Valim · 公告
2022年,我们宣布了为 Elixir 添加集合论类型的计划。2023年6月,我们发表了一篇关于 Elixir 类型系统设计的获奖论文,并表示我们的工作正从研究转向开发。随着 Elixir v1.20 的发布,我们完成了第一个开发里程碑:无需引入类型标注,即可对每个 Elixir 程序执行类型推断和渐进类型检查。这意味着 Elixir 能越来越多地报告死代码和已确认的 bug:即那些一旦执行就必定在运行时失败的类型违规。Elixir 能高效地在现有程序中发现已确认的 bug,且不增加开发者负担,误报率极低。本文将详解类型系统的目标、dynamic() 类型在 Elixir 中的含义,以及它是如何发现已确认 bug 的。值得一提的是,我们的实现在“If T: 类型窄化基准测试”中表现良好,通过了 13 个类别中的 12 个,表明它能够从普通 Elixir 代码中恢复精确的类型信息,并用于发现动态类型程序中的已确认 bug。该类型系统的实现得益于 CNRS 和 Remote 的合作,开发工作目前由 Fresha 和 Tidewave 赞助。
类型,在我的 Elixir 里? 我们的目标是引入一个类型系统,它应具备以下特性:
- 可靠:类型系统推断和分配的类型与程序行为一致。
- 渐进:Elixir 的类型系统包含 dynamic() 类型,可用于在运行时检查变量或表达式的类型。当不使用 dynamic() 时,Elixir 的类型系统表现为静态类型系统。
- 开发者友好:类型通过基本的集合操作(并集、交集、否定)来描述、实现和组合(因此是集合论类型系统),并配有清晰的错误消息。
将类型系统引入现有语言是一项复杂的变更。因此,我们的第一个里程碑是在不引入类型标注的情况下实现类型系统,同时通过发现死代码和已确认 bug 为开发者提供价值。这通过 dynamic() 类型实现,它在 Elixir 中与其他渐进类型语言有显著不同。让我们详细说明。
dynamic() 类型
许多渐进类型系统都有 any() 类型,从类型系统的角度看,它通常意味着“什么都可以”,不会报告类型违规。而 Elixir 的渐进类型称为 dynamic(),它具有两个重要属性:兼容性和窄化。
在静态类型系统中,当你有一个类型为 integer() 或 binary() 的值并调用函数时,该函数必须能接受这两种类型。然而,由于类型系统无法精确捕捉所有程序的意图,这可能导致误报。例如,考虑以下简单代码:
def percentage_or_error(value) when is_integer(value) do
value_or_error = if value > 1 do value else "not well" end
# ... 更多代码 ...
if value > 1 do
value_or_error / 100
else
String.upcase(value_or_error)
end
end
尽管 value_or_error 的类型是 integer() 或 binary(),运算符 / 只接受数字,String.upcase 只接受二进制/字符串,但上述程序是有效的,运行时不会抛出异常。然而,类型系统仍然会报告两次违规,因为传给 / 和 String.upcase 的类型不是所接受类型的子类型。虽然上述程序可以写得更好以避免类型违规,但类型系统总会拒绝一些有效程序。如果 Elixir 在现有代码库中引入太多误报,就会迅速侵蚀对类型系统的信任。因此,Elixir 的渐进类型系统将上面的 value_or_error 变量标记为类型 dynamic(integer() or binary()),这意味着运行时它要么是 integer() 要么是 binary()。当使用 dynamic() 类型调用函数时,仅当提供的类型和接受的类型无交集时,Elixir 才会报告类型违规。在上述程序中,尽管 / 只期望数字,但 dynamic(integer() or binary()) 可以是 integer(),且接受和提供的类型并非无交集,因此没有类型违规。
但是,如果将程序改为:
value_or_error = if value > 1 do value else "not well" end
Map.fetch!(value_or_error, :some_key)
由于 Map.fetch! 期望一个映射数据结构,而 value_or_error 在运行时只能是 integer 或 binary,接受和提供的类型无交集,因此会引发违规。这就是兼容性属性,它解释了 Elixir 如何只报告已确认的 bug。
然而,如果一开始就找不到很多 bug,只报告已确认的 bug 也没用。我们通过确保 Elixir 的 dynamic 类型可以被窄化来解决这个问题。考虑以下代码:
def add_a_and_b(data) do
data.a + data.b
end
在上面的程序中,data 一开始是 dynamic() 类型。然后我们在加法运算符中将其用作 data.a 和 data.b,因此 Elixir 会将 data 变量精化为类型 %{..., a: number(), b: number()},这意味着它是一个包含 a 和 b 字段且值为数字的映射(可能还有其他字段,因此有前导的 ...)。因此,如果你忘记选择 .b 字段而写成:
def add_a_and_b(data) do
data.a + data
end
data 会首先被窄化为形状 %{..., a: number()} 的映射,然后尝试作为 number() 使用,这将引发违规。换句话说,Elixir 中的 dynamic() 类型实际上像一个范围,随着程序的使用而被精化,当类型检查超出范围时报告违规。这与其他渐进类型系统形成对比,后者使用 dynamic 丢弃所有类型信息。
在幕后,我们的类型推断和类型检查算法表现得好像我们将所有参数类型都标注为 dynamic()。一旦引入用户提供的类型标注,只要不使用 dynamic(),Elixir 的类型系统就会像任何静态类型语言一样。而当你跨越静态-动态边界时,我们开发了新技术来确保渐进取值的可靠性,无需额外的运行时检查。
守卫、子句等的类型化
本次发布的大部分工作是将类型检查和窄化引入到多个构造中。让我们看看其中一些。
在守卫方面,我们可以推断并集、交集和否定:
def example(x, y) when is_list(x) and is_integer(y)
上面的代码正确地推断出 x 是列表,y 是整数。
def example({:ok, x} = y) when is_binary(x) or is_integer(x)
上面的代码推断出 x 是 binary 或 integer,y 是一个二元元组,第一个元素是 :ok,第二个是 binary 或 integer。
def example(x) when is_map_key(x, :foo)
上面的代码推断出 x 是一个具有 :foo 键的映射,表示为 %{..., foo: dynamic()}。前导的 ... 表示映射可能还有其他键。
def example(x) when not is_map_key(x, :foo)
上面的代码推断出 x 是一个没有 :foo 键的映射,类型为 %{..., foo: not_set()}。因此,在函数体内访问 x.foo 将引发类型违规。
你还可以使用对数据结构大小进行断言的表达式:
def example(x) when tuple_size(x) < 3
Elixir 会正确跟踪元组最多有两个元素,因此访问 elem(x, 3) 会引发类型违规。对于映射和列表,我们将大小检查转换为空性检查。换句话说,Elixir 可以查看复杂的守卫,推断类型,并利用这些信息在代码中发现 bug。
对于 case 和条件等构造,Elixir 使用前一个子句的信息来精化后续子句:
case System.get_env("SOME_VAR") do
nil -> :not_found
value -> {:ok, String.upcase(value)}
end
System.get_env("SOME_VAR") 返回 nil 或 binary()。由于第一个子句匹配 nil,类型系统知道 value 不再可能是 nil,因此它必须是 binary(),这使得第二个子句也能通过类型检查而无违规。跨子句的窄化还有助于类型系统在现有代码库中发现冗余子句和死代码。
此外,我们已为标准库中许多操作元组和映射的函数添加了类型信息。更多详情请参阅发布说明。
编译时间改进
Elixir v1.20 进一步改进了编译时间,尤其是在多核机器上运行的应用程序。尽管 BEAM 语言通常编译效率较高,但我们的综合基准测试现在将 Elixir 的构建工具列为其中最快的。如果你希望贡献更多示例和场景,请发起讨论,以便我们提供透明的基准测试套件和结果。
它还引入了一个名为 :module_definition 的新编译器选项,用于指定模块定义是 :compiled(默认)还是 :interpreted。这可以改善大型项目的编译时间,且不影响写入磁盘的 .beam 文件,只影响 defmodule 内部内容的执行方式。你可以在 mix.exs 中设置 elixirc_options: [module_definition: :interpreted] 来启用它。请阅读文档了解更多信息。
下一步是什么?
我们面临的最大问题是:Elixir 何时会引入利用集合论类型的新类型签名?正如我最近在 ElixirConf EU 2026 主题演讲中讨论的那样,我们面前仍有研究和开发工作。我们将在以下条件满足后才引入类型签名:
- 我们对 Elixir v1.20 中类型系统的性能感到满意(我们已经做了大量优化工作);
- 我们能高效实现递归类型;
- 我们能高效实现参数化类型;
- 我们能高效实现将映射的键值对作为可枚举对象遍历(我们仍在研究可能的解决方案)。
一旦这些问题得到解决,我们将开始探索和讨论带类型的结构定义,最后是类型签名。像往常一样,我们会通过新闻和 Elixir 论坛向社区通报进展。感谢所有试用候选版本、运行基准测试并给予反馈的人!试试 Elixir v1.20,并记得修复它将免费为你发现的所有 bug!
评论