Elixir v1.20 深度拆解:当一门动态语言决定「不写一行类型注解」也要做类型检查——集合论类型、dynamic() 的收窄语义与 BDD 的编译期手术
2026 年 6 月 3 日,Elixir v1.20 发布。发布公告的标题只有一句话:now a gradually typed language。
这句话背后是从 2022 年立项、2023 年出论文、2026 年落地的一次长跑。更关键的是:它做到了「你一行类型注解都不用写,编译器照样能在你的老项目里挖出确定会崩的 bug 和死代码」。
这篇文章不做版本速览。我们从 BEAM 为什么天生动态讲起,把
dynamic()的两条核心性质、guard 推理、跨子句收窄、map 域键、以及底层那套 lazy BDD 的化简公式,一层一层拆开,最后给出可执行的升级、CI 接入与编译性能调优方案。
一、背景:BEAM 世界那个二十年没补上的洞
1.1 Erlang 的动态是「设计选择」,不是「历史包袱」
很多人把 Erlang/Elixir 的动态类型理解成「老语言没赶上类型系统的潮流」。这个判断是错的。
Erlang 的整个可靠性模型建立在三件事上:
- 进程隔离 + let it crash:错误不做防御性拦截,让进程死掉,由 supervisor 重启到已知良好状态。
- 热代码升级:一个模块可以在系统运行时被新版本替换,新旧两个版本在同一时刻共存于 VM 中。
- 消息传递是无类型的信封:
send/2把任意 term 扔进邮箱,接收端用模式匹配挑自己认识的。
这三件事和「编译期全局静态类型检查」天然存在张力。热升级尤其致命——如果模块 A 在编译期被检查过「调用 B.foo/1 传的是 integer」,那么运行时把 B 换成一个 foo/1 只接受 binary 的版本,编译期结论就作废了。静态类型的前提是「编译期看到的世界等于运行期的世界」,而 BEAM 主动打破了这个前提。
所以 Erlang 选了另一条路:类型信息不进编译器,进工具链。
1.2 Dialyzer 的哲学:宁可漏报,绝不误报
这条路的产物就是 Dialyzer,以及它背后的 success typing(成功类型)。
Dialyzer 的核心承诺非常克制:
我报出来的每一个错误,都是一定会出问题的;我不报的地方,不代表没问题。
用集合的语言说,success typing 推导的是一个函数「可能成功的输入集合的超集」。只有当调用点传入的类型与这个超集完全不相交时,Dialyzer 才开口。这保证了零误报,代价是大量漏报。
工程上,Dialyzer 长期处于「大家都知道它好,但没几个团队真在 CI 里卡住」的尴尬位置:
| 问题 | 具体表现 |
|---|---|
| PLT 构建慢 | 首次分析 OTP + 依赖,几分钟到几十分钟起步 |
| 增量能力弱 | 依赖一变,大面积重算,CI 缓存策略要专门设计 |
| 报错可读性差 | The call lists:keyfind(...) will never return since the success typing is ... 这种句式,新人看半小时 |
| 与语言本体割裂 | @spec 是注释级别的约定,写错了没人管,写漏了更没人管 |
| 漏报太多 | 最常见的 nil 穿透、map 缺 key、拼错的原子,Dialyzer 经常沉默 |
结论:BEAM 生态不是没有类型工具,而是没有一个「默认开启、零配置、低误报、还能真抓到东西」的类型工具。
1.3 时间线:从论文到默认开启
- 2022 年 10 月:Elixir 核心团队公开宣布,要为 Elixir 引入集合论类型(set-theoretic types)。
- 2023 年 6 月:类型系统设计论文发表(arXiv:2306.06391),并宣布工作从「研究」转入「开发」。这项工作由 CNRS 与 Remote 的合作推动,后续开发由 Fresha、Tidewave 赞助。
- 2024 年 12 月,v1.18:开始对函数调用做类型检查,落地第一批能力。
- 2025 年 10 月,v1.19:协议与匿名函数的类型检查、更广的推断、编译提速。
- 2026 年 1 月 9 日:Elixir 首次提交 15 周年,发布 v1.20 的第一个 RC,宣布「对所有语言构造做类型推断」。
- 2026 年 6 月 3 日,v1.20 正式版:每一个 Elixir 程序都会被渐进式类型检查。要求 Erlang/OTP 27+,兼容到 OTP 29。
注意「默认开启、无需注解」这一点的分量:它意味着存量代码零改造直接吃到收益。你 mix deps.get && mix compile,warning 就出来了。
二、核心概念:为什么必须是「集合论类型」
2.1 类型即集合
集合论类型的出发点极其朴素:一个类型就是一组值的集合。于是类型运算直接复用集合运算:
| 运算 | 记号 | Elixir 里的意思 |
|---|---|---|
| 并 union | A or B | 值属于 A 或 B |
| 交 intersection | A and B | 值同时属于 A 和 B |
| 补/差 negation/difference | not A / A and not B | 值不属于 A |
| 顶 | term() | 所有值 |
| 底 | none() | 空集,无值 |
关键推论:
A 是 B 的子类型 ⟺ A ⊆ B ⟺ A \ B = ∅
这句话是整个实现的地基。后面你会看到,判断「这个子句是不是冗余的」「这个字段是不是永远不存在」,最后全部归约成一次 empty?(difference(...)) 调用。
2.2 和 TypeScript 的联合类型差在哪
很多写过 TS 的同学会说:TS 也有 A | B 和 A & B 啊。差别在于否定。
TS 没有一等公民的类型否定。你想表达「一个不含 foo 键的对象」,只能靠 foo?: never 这种技巧绕;你想表达「string 但不是 'a' | 'b'」,标准写法不存在。而集合论类型系统里,否定是基本算子,于是可以直接表达:
# 一个 map,明确不含 :foo 键
%{..., foo: not_set()}
# 一个 map,如果含 :foo 键那它是 integer
%{..., foo: if_set(integer())}
not_set() 和 if_set/1 这两个记号在后面的 map 部分会反复出现,它们正是「否定」和「可选」在 map 域上的具体化。
2.3 一句话概括 v1.20 的类型系统目标
发布公告里给了三条设计目标,值得逐条翻译成工程语言:
- sound(可靠):推导出来的类型必须真实反映程序行为,不能骗人。
- gradual(渐进):有
dynamic()这个逃生舱;当程序里完全不出现dynamic()时,这套系统的行为等价于一个静态类型系统。 - developer friendly(对开发者友好):类型用并/交/否三种基本集合运算描述、实现和组合,报错信息要人能读懂。
第二条是最容易被忽略但最重要的:这不是「加了个 linter」,这是在动态语言里预埋了一个完整的静态类型系统,只是目前所有入参的默认标注都是 dynamic() 而已。等未来引入用户书写的类型签名,同一套引擎立刻变成静态检查器。
三、dynamic():不是黑洞,是可收敛的区间
这是整个 v1.20 里最值得单独理解的一个设计,也是它跟 TS 的 any、Python 的 Any 拉开代差的地方。
3.1 any() 的问题:信息湮灭
在大多数渐进类型系统里,any 的语义是「什么都行,不要检查」。它有两个后果:
- 一旦某个值被标成
any,它流经的所有下游都失去检查能力,形成「any污染」。 any不承载任何信息,无法反推。
3.2 Elixir 的 dynamic():性质一,兼容性(compatibility)
先看官方给的这段代码,它是理解一切的钥匙:
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() or binary()。
/只接受数字 →binary()分支违规。String.upcase/1只接受字符串 →integer()分支违规。
报两个错。但这段程序在运行时永远不会崩,因为两处 value > 1 的判断是同一个条件,只是类型系统看不出来这层关联。
这就是经典的「类型系统会拒绝合法程序」。而对一门已经有海量存量代码的语言来说,误报是致命的——一旦新手第一次编译老项目冒出 300 条 warning,其中 250 条是误报,这个类型系统就死了。
Elixir 的处理是:把 value_or_error 标成 dynamic(integer() or binary()),然后规定:
当调用一个函数、实参类型里带
dynamic()时,只有当「提供的类型」与「接受的类型」完全不相交(disjoint)时,才报违规。
在上面的例子里:
/接受number(),提供dynamic(integer() or binary()),二者交集非空(integer 在里面)→ 不报。String.upcase/1接受binary(),提供同上,交集非空(binary 在里面)→ 不报。
零误报。
再看反例:
value_or_error =
if value > 1 do
value # integer()
else
"not well" # binary()
end
Map.fetch!(value_or_error, :some_key)
Map.fetch!/2 第一个参数要 map。而 dynamic(integer() or binary()) 与 map() 完全不相交——运行时它只可能是整数或二进制,绝不可能是 map。于是这里报错,而且这个错是「verified bug」:只要这行被执行,运行时 100% 抛异常。
这就是「只报确定的 bug」的实现机制。
3.3 性质二,收窄(narrowing)
只报确定 bug 是不够的——如果什么都推不出来,那就永远没有「确定」的机会。所以 dynamic() 必须能被逐步收紧。
def add_a_and_b(data) do
data.a + data.b
end
推导过程:
data初始类型dynamic()。- 出现
data.a,且结果被送进+→data至少是个含a键的 map,且a是数字。 - 同理
data.b。 - 最终
data被精化为%{..., a: number(), b: number()}。前导...表示「还可能有其他键」。
于是当你手滑写成:
def add_a_and_b(data) do
data.a + data # 少写了 .b
end
data 先被收窄成 %{..., a: number()},紧接着又被当作 number() 使用。map 和 number 不相交 → 违规。
一句话总结:Elixir 的 dynamic() 更像一个区间,它随着程序使用不断收缩;一旦某次使用落在区间之外,就报错。而其他语言的 dynamic/any 是把类型信息直接丢掉。
这也解释了为什么官方敢在 ifT-benchmark(If T: Benchmark for Type Narrowing,犹他大学 PLT 组维护的类型收窄基准)里晒成绩:13 个类别通过 12 个。这个基准专门衡量「能否从普通的动态代码里恢复出精确的类型信息」,正是 Elixir 这套方案的命门所在。
3.4 幕后:所有参数默认标注为 dynamic()
官方的描述很直白:推断与检查算法的行为,等价于把所有函数参数都标注成 dynamic()。
这带来一个漂亮的性质:等到未来引入用户书写的类型签名,只要签名里不出现 dynamic(),同一套引擎就会像静态类型语言一样严格。而跨越静态-动态边界时,Elixir 采用了 strong arrows 相关技术,保证渐进类型的 soundness,且不需要插入运行时检查(这一点区别于很多需要 contract/cast 的渐进类型方案,对性能敏感的 BEAM 至关重要)。
四、推理引擎:从 guard 到全函数体
v1.20 的绝大部分工作量,是把类型推断和收窄铺到「所有语言构造」上。下面按重要性排。
4.1 Guard 推理
Elixir 的 guard 是受限表达式,正好适合做类型推断。
def example(x, y) when is_list(x) and is_integer(y)
# x :: list()
# y :: integer()
and 对应交集,or 对应并集:
def example({:ok, x} = y) when is_binary(x) or is_integer(x)
# x :: binary() or integer()
# y :: {:ok, binary() or integer()}
注意 y 的类型:模式匹配的结构信息也参与了推断,得到一个二元组类型,首元素是原子 :ok。
is_map_key 的正反两面是最能体现集合论威力的例子:
def example(x) when is_map_key(x, :foo)
# x :: %{..., foo: dynamic()} —— 一定有 :foo 键
def example(x) when not is_map_key(x, :foo)
# x :: %{..., foo: not_set()} —— 一定没有 :foo 键
第二条尤其重要:函数体里再写 x.foo,直接违规。这在没有否定算子的类型系统里是表达不出来的。
尺寸类 guard 同样被建模:
def example(x) when tuple_size(x) < 3
# x 最多两个元素;函数体里 elem(x, 3) → 违规
对 map 和 list,尺寸检查被转换成「是否为空」的判断。
4.2 全函数体推断(whole-body inference)
不止 guard,函数体本身也参与推断,而且是双向的。
正向:
def add_foo_and_bar(data) do
data.foo + data.bar
end
推断结果:第一个参数是 map,必须含 .foo 和 .bar,值为 integer() 或 float();返回值也是 integer() 或 float()。
反向(这个更有意思):
def sum_to_string(a, b) do
Integer.to_string(a + b)
end
+ 本身接受整数和浮点。但 Integer.to_string/1 只接受整数,于是从下游反推上游:a 和 b 必须都是 integer()。
这就是所谓「occurrence typing」在函数体尺度上的应用:类型信息沿数据流双向传播。
4.3 跨应用推断(cross-application inference)
v1.20 的 CHANGELOG 里有一条容易被忽略的 [Kernel] Perform type inference across applications:
从依赖里推断出来的类型信息,会被用来为你自己的应用推断更精确的类型。
工程含义:你依赖的库越是被这套系统扫过,你自己的代码就能被推得越准。这是一个生态正反馈——随着 hex 上的包陆续在 v1.20 下编译,整个生态的类型精度会集体上升。
4.4 跨子句收窄与冗余子句检测
这是 v1.20 里最能立刻在老项目上出成果的能力。
规则:某个子句的类型 = 它自己的模式与 guard 推出的类型,减去前面所有子句的类型。
def example(x) when is_binary(x), do: ...
def example(x) when is_integer(x), do: ...
def example(x), do: ...
第三个子句虽然没有 guard,但它的类型是 term() and not binary() and not integer()。
于是冗余子句检测就是一个纯集合运算:若三个子句类型分别为 clause1/2/3,那么
clause3 是冗余的 ⟺ clause3 ⊆ (clause1 ∪ clause2)
⟺ empty?(difference(clause3, union(clause1, clause2)))
在 case、cond、with 上同样实现了 occurrence typing:
case System.get_env("SOME_VAR") do
nil -> :not_found
value -> {:ok, String.upcase(value)}
end
System.get_env/1 返回 nil or binary()。第一个子句吃掉了 nil,所以第二个子句里 value 只能是 binary(),String.upcase/1 顺利通过检查。
这条能力在真实项目里的杀伤力:老代码里那种「为了保险再兜一个 _ -> :error」的分支,如果前面已经覆盖全,现在会被明确标为死代码。我在体量稍大的项目里跑一遍,第一批 warning 里死代码往往占三成以上。
4.5 Map:域键(domain keys)与 Map 模块类型化
之前 Elixir 的 map 类型只支持原子键,其他键一律降级成 dynamic()。v1.20 支持了任意域作为键:
%{123 => "hello", 456.0 => :ok}
# 类型:
%{integer() => binary(), float() => :ok}
也可以混合域键和原子键:
%{integer() => integer(), root: integer()}
这套实现依据的是 ICFP 2023 的论文《Typing Records, Maps, and Structs》。
在此基础上,Map 模块的大部分函数被逐一类型化,让类型系统能追踪键的增、改、删:
Map.put(map, :key, 123)
#=> %{..., key: integer()}
Map.delete(map, :key)
#=> %{..., key: not_set()}
Map.replace(map, :key, 123)
#=> %{..., key: if_set(integer())}
Map.replace/3 的语义是「键存在才替换」,所以结果里用 if_set/1 表达「如果这个键存在,那它是 integer」。这种精细度在 TS 的 Record 上是做不到的。
更进一步:结合 bang 系列函数(Map.fetch!/2、Map.pop!/2、Map.replace!/3、Map.update!/3),需求会跨模块传播:
defmodule User do
def name(map), do: Map.fetch!(map, :name)
end
defmodule CallsUser do
def calls_name do
User.name(%{})
end
end
编译输出:
warning: incompatible types given to User.name/1:
User.name(%{})
given types:
%{name: not_set()}
but expected one of:
dynamic(%{..., name: term()})
type warning found at:
│
16 │ User.name(%{})
│ ~
│
└─ lib/calls_user.ex:7:5: CallsUser.calls_name/0
注意这个链路:Map.fetch!(map, :name) → 推出 User.name/1 需要 %{..., name: term()} → 调用点传空 map,其类型是 %{name: not_set()} → 与需求不相交 → 报错。
没有一行 @spec。这就是「零注解也有价值」的具体形态。
五、架构拆解:lazy BDD 与它的三次手术
到这里为止都是「用户可见」的部分。下面进入实现层——这一层你不懂也能用,但懂了才知道它为什么快,以及什么时候会慢。
5.1 为什么需要 BDD
集合论类型的表达式可以任意嵌套:
foo and not (bar or (baz and bat))
如果每次都朴素展开成析取范式,节点数会指数爆炸。类型系统的核心操作又是高频的 subtype? / empty?,所以必须有一个能高效做布尔化简的表示。
答案是 BDD(Binary Decision Diagram,二叉决策图),这是布尔函数表示的经典数据结构,在模型检查、SAT、EDA 里用了几十年。
5.2 lazy BDD 的四元组结构
Elixir 的表示是这样的:
type lazy_bdd() =
:top
or :bottom
or {type(), constrained :: lazy_bdd(), uncertain :: lazy_bdd(), dual :: lazy_bdd()}
其中 type() 是实际类型的表示(比如元组就是元素列表),在文献里叫 literal(文字)。
记 B = {a, C, U, D},其语义是:
B = (a and C) or U or (not a and D)
四个槽位各司其职:
a:当前决策节点上的类型文字C(constrained):a成立时的分支U(uncertain):与a无关、无条件成立的部分D(dual):a不成立时的分支
举例,(foo and not (bar or (baz and bat))) 会被存成:
{foo,
{bar, :bottom, :bottom,
{baz, :bottom,
{bat, :bottom, :bottom, :top}, :top}, :bottom, :bottom}
「lazy」的含义:交、并、差可以在任意深度以结构化的方式挂上去,不需要立刻求值展开。好处是构造便宜,坏处是 BDD 可能长得很大。
5.3 手术一:eager intersection(2026-02)
lazy 的代价立刻显现。看这个类型:
(%Foo{} or %Bar{} or %Baz{} or %Bat{}) and %Bar{}
肉眼可见它等于 %Bar{},但 lazy BDD 会原样存下来。在有大量 struct 模式匹配的模块里,这种冗余节点会滚雪球。
优化思路:交集改为 eager(立即求值)。当交两个 BDD 时:
B1 = {a1, C1, U1, D1}
B2 = {a2, C2, U2, D2}
如果 a1 ∩ a2 = ∅(不相交),就能递归地消掉大量节点。官方描述这次优化「大幅缩小了 BDD 尺寸,显著改善编译时间」。
5.4 手术二:eager literal difference(2026-03)
v1.20.0-rc.2 引入跨子句类型传播和冗余子句检测之后,差集运算的调用量暴增——因为每个子句都要减去前面所有子句。结果是:有 1000+ 子句的模块,编译时间被拖垮。
于是需要对差集也做同样的手术。差集有两条可利用的性质:
- 若
a1与a2不相交 →a1 \ a2 = a1 - 若
a1 ⊆ a2→a1 \ a2 = ∅
右侧是 literal 的情形(B2 = a2):
从基本式出发:
B1 and not B2
= ((a1 and C1) or U1 or (not a1 and D1)) and not a2
分配 and not a2:
(a1 and not a2 and C1) or (U1 and not a2) or (not a1 and not a2 and D1)
- 不相交时,
a1 and not a2 = a1,得到:
(a1 and C1) or (U1 and not a2) or (not a1 and not a2 and D1)
a1 ⊆ a2时,a1 and not a2 = ∅;又因为not a1 and not a2 = not (a1 or a2) = not a2,得到:
(U1 and not a2) or (D1 and not a2)
两个公式里的 and not a2 再递归地用同样的规则处理。
左侧是 literal 的情形(B1 = a1):
a1 and not ((a2 and C2) or U2 or (not a2 and D2))
把 not 分配进去:
a1 and (not a2 or not C2) and (not U2) and (a2 or not D2)
- 不相交时:
a1 and (not a2 or not C2)化简为a1(因为a1 and not a2 = a1,与它的子集取并还是a1);再因为a1 and a2 = ∅,最终得到极简形式:
a1 and not D2 and not U2
a1 ⊆ a2时:a1 and not a2 = ∅,剩a1 and not C2;又a1 and a2 = a1,于是a1 and (a2 or not D2) = a1。最终:
a1 and not C2 and not U2
这四条公式的共同效果是:在最常见的两种关系(不相交 / 子类型)下,把整棵子树直接摘掉。官方给的效果描述是「原本要几十秒编译的项目,现在能在毫秒级完成」。
5.5 手术三:单字段差(one field difference)
上面两招在 struct 上会失效。看这个非常典型的写法:
def example(%MyStruct{x: x}) when is_binary(x)
def example(%MyStruct{x: x}) when is_integer(x)
def example(%MyStruct{x: x})
第三个子句里 x 起始类型是 term(),所以第三个 struct 类型是前两个的超类型——既不相交,也不是子集,两条捷径都不适用。它的类型只能写成:
%MyStruct{x: term()} and not %MyStruct{x: integer()} and not %MyStruct{x: binary()}
观察:struct 做差时,绝大多数字段类型相同,只有一个字段不同。于是可以把「整个 struct 的差」下推为「那一个字段的差」:
%MyStruct{x: term() and not integer() and not binary()}
一次结构级的差,变成一次标量级的差。BDD 节点数从 O(子句数) 降到 O(1)。
工程启示:如果你的项目在 v1.20 下编译变慢,第一嫌疑就是「同一个 struct 上大量多子句分派」。这也是为什么官方专门为这个模式写了一批公式。
六、代码实战:从升级到 CI 卡口
6.1 环境要求与升级
# v1.20 要求 Erlang/OTP 27+,兼容到 OTP 29
elixir --version
# Erlang/OTP 28 [erts-16.x] ...
# Elixir 1.20.0 (compiled with Erlang/OTP 27)
用 asdf 管理版本时:
asdf install erlang 28.0
asdf install elixir 1.20.0-otp-28
asdf local erlang 28.0
asdf local elixir 1.20.0-otp-28
mix deps.clean --all
mix deps.get
mix compile --force
--force 很关键:类型检查发生在编译期,增量编译只会检查变更的模块,第一次一定要全量跑一遍。
6.2 写一个「脏」模块,看它抓出什么
新建 lib/dirty.ex:
defmodule Dirty do
# 1) 明确不含 :foo 键,却访问 .foo
def no_key(x) when not is_map_key(x, :foo) do
x.foo
end
# 2) 元组尺寸越界
def small_tuple(t) when tuple_size(t) < 3 do
elem(t, 3)
end
# 3) 反向推断冲突:+ 的结果给 Integer.to_string,但传了 float
def bad_sum do
sum_to_string(1.5, 2.5)
end
defp sum_to_string(a, b), do: Integer.to_string(a + b)
# 4) 冗余子句 / 死代码
def kind(x) when is_binary(x), do: :string
def kind(x) when is_integer(x), do: :int
def kind(x) when is_binary(x), do: :never_reached
# 5) 跨模块 map 键需求
def call_user, do: fetch_name(%{})
defp fetch_name(map), do: Map.fetch!(map, :name)
# 6) 不相交类型调用
def disjoint(flag) do
v = if flag, do: 1, else: "one"
Map.fetch!(v, :k)
end
end
mix compile --force
你会看到形如下面的输出(节选,实际列号以你的文件为准):
warning: expected a map with key :foo in x.foo, but got type:
%{..., foo: not_set()}
warning: the following clause will never match:
def kind(x) when is_binary(x)
because it has the same pattern as a previous clause
warning: incompatible types given to Dirty.fetch_name/1 ...
重点体会:这些全都是 mix compile 自带的,没有装任何额外工具,没有写一行 @spec。
6.3 把它接进 CI
最激进的做法:
# mix.exs
def project do
[
app: :my_app,
version: "0.1.0",
elixir: "~> 1.20",
elixirc_options: [warnings_as_errors: true],
deps: deps()
]
end
mix compile --force --warnings-as-errors
但对存量项目,一上来就 warnings_as_errors 会直接把 CI 打红。推荐三阶段渐进方案:
阶段一:先量化,不阻塞。
#!/usr/bin/env bash
# scripts/type_report.sh —— 统计类型 warning 数量并按模块归类
set -euo pipefail
mix compile --force 2>&1 | tee /tmp/compile.log >/dev/null
echo "=== 类型相关 warning 总数 ==="
grep -cE "warning: (incompatible types|expected a map|the following clause|this clause)" /tmp/compile.log || true
echo
echo "=== 按文件归类 Top 20 ==="
grep -oE "lib/[A-Za-z0-9_/.-]+\.ex" /tmp/compile.log \
| sort | uniq -c | sort -rn | head -20
阶段二:设基线,只卡增量。
#!/usr/bin/env bash
# scripts/type_gate.sh —— 与基线对比,只允许下降
set -euo pipefail
BASELINE_FILE=".type_baseline"
CURRENT=$(mix compile --force 2>&1 \
| grep -cE "warning: (incompatible types|expected a map|the following clause)" || true)
if [ ! -f "$BASELINE_FILE" ]; then
echo "$CURRENT" > "$BASELINE_FILE"
echo "已写入基线: $CURRENT"
exit 0
fi
BASELINE=$(cat "$BASELINE_FILE")
echo "基线=$BASELINE 当前=$CURRENT"
if [ "$CURRENT" -gt "$BASELINE" ]; then
echo "❌ 类型 warning 增加了 $((CURRENT - BASELINE)) 条,请修复后再提交"
exit 1
fi
if [ "$CURRENT" -lt "$BASELINE" ]; then
echo "$CURRENT" > "$BASELINE_FILE"
echo "✅ 基线下调到 $CURRENT"
fi
阶段三:清零后开 warnings_as_errors。
GitHub Actions 示例:
name: ci
on: [push, pull_request]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: erlef/setup-beam@v1
with:
otp-version: "28.0"
elixir-version: "1.20.0"
- uses: actions/cache@v4
with:
path: |
deps
_build
key: ${{ runner.os }}-mix-${{ hashFiles('**/mix.lock') }}
restore-keys: ${{ runner.os }}-mix-
- run: mix deps.get
- run: mix compile --force --warnings-as-errors
- run: mix test
6.4 修 warning 的四种常见手法
手法一:把 nil 显式挡在门外。
# 之前:value 可能是 nil,下游 String.trim/1 违规
def normalize(value), do: String.trim(value)
# 之后:用子句吃掉 nil,类型系统自动把剩余收窄为 binary()
def normalize(nil), do: ""
def normalize(value) when is_binary(value), do: String.trim(value)
手法二:用模式匹配代替「先取后判」。
# 之前
def handle(result) do
if result.status == :ok, do: result.data, else: nil
end
# 之后:结构直接进模式,类型系统能推出更精确的 map 形状
def handle(%{status: :ok, data: data}), do: data
def handle(%{status: _}), do: nil
手法三:给中间变量拆分支,避免「一个变量承载互斥语义」。
前面 percentage_or_error 那种写法虽然被 dynamic() 放行了,但它本身就是坏味道。重构:
def percentage_or_error(value) when is_integer(value) do
if value > 1 do
value / 100
else
String.upcase("not well")
end
end
手法四:确实需要放行时,用 dynamic 的语义边界。
Elixir 目前没有用户可写的类型注解,所以「压制」的正确姿势不是加注解,而是让类型在运行时被真正确认:
# 让类型系统看到一次真实的运行时判定
def to_int(v) when is_integer(v), do: v
def to_int(v) when is_binary(v), do: String.to_integer(v)
不要用 apply/3、Kernel.then/2 之类的间接调用来「骗过」类型检查器。那等于把 bug 藏起来。
6.5 和 Dialyzer 怎么共存
短期内两者互补,长期看内置类型系统会吃掉 Dialyzer 的大部分场景。
| 维度 | 内置类型系统(v1.20) | Dialyzer |
|---|---|---|
| 触发方式 | mix compile,默认开启 | 独立任务,需建 PLT |
| 注解 | 不需要 | 依赖 @spec 才有精度 |
| 误报 | 极低(只报 verified bug) | 零(success typing 保证) |
| 覆盖面 | guard / 子句 / map 键 / 跨应用推断 | 依赖 @spec 覆盖度 |
| 速度 | 编译期内联,增量友好 | 慢,PLT 构建重 |
| 冗余子句/死代码 | ✅ | 部分 |
建议:v1.20 上线后先跑内置检查清一轮,把 Dialyzer 从「每次 PR 跑」降级为「每日定时跑」,等类型签名落地后再评估是否下线。
七、编译性能::module_definition 与它的代价
类型检查是纯粹的编译期开销,所以 v1.20 同时在编译速度上做了不少补偿。
7.1 官方的几项优化
CHANGELOG 里与编译速度直接相关的条目:
[Code] Make module purging opt-in and move temporary module deletion to the background:模块清除改为可选,临时模块删除挪到后台,减少编译主路径阻塞。[mix deps] Parallelize dep lock status checks during deps.loadpaths:并行化依赖锁检查,对有大量 git 依赖的项目,启动时间改善明显。[EEx] Optimize compiler by flattening expr list only once- 多核机器上的整体编译提速。
官方还维护了一个跨 BEAM 语言的编译基准 langcompilebench,并称在其合成基准中 Elixir 的构建工具在 BEAM 系语言里最快。这类自家基准要辩证看,真正有意义的是你自己项目的数字。
7.2 :module_definition 选项
这是 v1.20 新增的编译选项,直接影响 defmodule 内部代码的执行方式:
# mix.exs
def project do
[
# ...
elixirc_options: [module_definition: :interpreted]
]
end
或运行时设置:
Code.put_compiler_option(:module_definition, :interpreted)
:compiled(默认):defmodule内部内容被编译后执行。:interpreted:defmodule内部内容被解释执行。
关键点:它不影响最终写到磁盘的 .beam 文件,产物的性能和行为完全一致。它只改变「编译过程中如何执行 defmodule 里的代码」。
收益:在大型项目、尤其是高核心数机器上,可能带来「drastic」级别的编译时间改善。典型受益场景是那些在 defmodule 内部大量执行元编程的项目——比如为几百个 API 端点动态生成函数、大批量 use 宏展开、编译期读取 schema 生成代码。
代价(两条,都要认真对待):
- 编译期错误的 stacktrace 精度下降。宏展开炸了之后,定位会更痛苦。
defmodule内的匿名函数最多 20 个参数。超过要改用 map 或 tuple 打包。注意这个限制只作用于defmodule内部的匿名函数,def定义的具名函数仍然支持最多 255 个参数。
7.3 一个可以直接跑的编译基准脚本
#!/usr/bin/env bash
# scripts/bench_compile.sh —— 对比 :compiled 与 :interpreted 的冷编译耗时
set -euo pipefail
run_once() {
local mode="$1"
rm -rf _build/dev
local start end
start=$(python3 -c 'import time;print(time.time())')
if [ "$mode" = "interpreted" ]; then
MIX_ENV=dev elixir -e '
Code.put_compiler_option(:module_definition, :interpreted)
Mix.CLI.main()
' -- compile >/dev/null 2>&1
else
MIX_ENV=dev mix compile >/dev/null 2>&1
fi
end=$(python3 -c 'import time;print(time.time())')
python3 -c "print(f'{$end - $start:.2f}')"
}
echo "CPU 核心数: $(getconf _NPROCESSORS_ONLN)"
for mode in compiled interpreted; do
total=0
for i in 1 2 3; do
t=$(run_once "$mode")
echo " [$mode] run$i = ${t}s"
total=$(python3 -c "print($total + $t)")
done
python3 -c "print(f'==> $mode 平均: {$total/3:.2f}s')"
done
更简单的做法是直接用 mix.exs 切换后各跑三次取中位数:
# 基线
rm -rf _build && time mix compile
# 打开 interpreted 后
rm -rf _build && time mix compile
判定规则:
- 提速 < 10%:不值得,因为你要付出 stacktrace 精度的代价。
- 提速 10%~30%:只在 CI 上开,本地保持
:compiled以获得好的报错体验。 - 提速 > 30%:两边都开,同时在文档里写清「本地调试宏时临时关掉」。
7.4 编译慢的排查顺序
如果升到 v1.20 后编译明显变慢,按这个顺序查:
- 是不是全量编译? 第一次
--force慢是正常的,看第二次增量。 - 有没有超大
case/多子句函数? 尤其是同一 struct 上的多子句分派——这正是 5.5 节那个 pattern。先把子句数最多的模块揪出来:
# 粗略统计每个文件的 def/defp 子句数
grep -c -E "^\s+defp?\s" lib/**/*.ex 2>/dev/null \
| sort -t: -k2 -rn | head -20
- 有没有巨型 map/struct 类型在到处传? 字段越多,BDD 节点越多。
- 依赖是不是也在 v1.20 下重编?
mix deps.compile --force一次,看是哪个依赖慢。 - 开
:interpreted试试,尤其是元编程重的项目。 - 还是慢,去 Elixir Forum 开帖并附上最小复现——类型系统的性能问题目前是核心团队的高优先级。
八、十条踩坑清单
- 别指望它抓所有 bug。设计目标是「只报 verified bug」,也就是「执行到就一定崩」的那类。业务逻辑错误、
nil的语义误用(而非类型误用)它管不了。 dynamic()不相交才报错。所以「可能是 nil」这种情况,只要下游函数接受的类型里包含非 nil 的部分,就不会报。要真正消灭 nil,还是得靠显式子句或模式匹配。- 升级要 OTP 27+。还在 OTP 25/26 的项目,先升 OTP 再升 Elixir,别两个一起动。
- 第一次务必
mix compile --force。增量编译只检查变更模块,不 force 你会以为「我的项目很干净」。 warnings_as_errors别一步到位。存量项目上来就开必红,用基线脚本渐进收敛。:module_definition, :interpreted有副作用。stacktrace 变模糊、defmodule内匿名函数参数上限 20。不要因为「据说能提速」就无脑全局打开。- 注意 v1.20 的两个潜在破坏性变更:字符串/注释/
?之后不再允许裸 CR 换行(安全原因);require SomeModule不再在编译期展开为该模块,虽然运行时仍返回模块,但require(SomeMod).some_macro()这类写法会挂。 - 不要用间接调用绕过检查。
apply/3能让 warning 消失,但 bug 还在,而且以后加类型签名时会翻倍还债。 - 冗余子句 warning 要一条条看。有些「冗余」是你刻意留的防御性兜底,删之前先确认调用方真的不会传进来;但更多时候它就是真死代码。
- 依赖的类型精度会影响你。如果某个核心依赖还没在 v1.20 下编译过,你这边的推断精度会打折。升级时优先推动核心依赖跟进。
九、还没到的部分:类型签名什么时候来
官方对下一步说得很坦白。引入用户可写的类型签名,需要先满足四个前提:
- 对 v1.20 现有类型系统的性能满意(已经做了三轮 BDD 优化,还在继续);
- 能高效实现递归类型;
- 能高效实现参数化类型(泛型);
- 能高效实现「把 map 的键值对当作 enumerable 遍历」——这一条明确写着「仍在研究可能的方案」。
四个前提都过了,才会开始讨论 typed struct 定义,最后才是类型签名。
我的判断:第 4 条是真正的硬骨头。集合论类型下,map 的键可以是任意域(%{integer() => binary()}),要在这种表示上支持「遍历所有键值对并保持类型精度」,本质上是在类型层面做一次带存在量词的归纳。这不是工程量问题,是研究问题。所以别指望明年就有 @spec 的替代品——但也别小看现在这个状态:零注解、零配置、跑一次 compile 就有产出,这个投入产出比在任何语言的类型系统迁移史上都属于极优。
十、总结:这件事对整个动态语言阵营的意义
把 v1.20 放在更大的坐标系里看,它证明了三件事:
第一,渐进类型的 dynamic 不必是信息黑洞。 主流方案(TS 的 any、Python 的 Any)把动态类型当作「关闭检查」的开关,结果是污染扩散。Elixir 用「兼容性 + 收窄」两条性质,把 dynamic() 变成一个可以持续收缩的区间。这个设计是可移植的——任何想给动态语言加类型的项目都该抄。
第二,零注解迁移是可行的,前提是把误报率压到接近零。 类型系统在存量语言上的最大敌人不是「表达力不够」,是「第一次跑出 300 条 warning 把人吓跑」。Elixir 的答案是:宁可少报,也要让每一条 warning 都值得修。ifT-benchmark 13 项过 12 项,说明「少报」并没有付出太多精度代价。
第三,类型系统的工程化落地,一半工作量在数据结构上。 从公告到实现,最硬的部分是那三轮 BDD 手术:eager intersection、eager literal difference、one field difference。功能上它们什么都没加,但没有它们,1000+ 子句的模块就编译不动,这套系统就是个玩具。这是给所有做静态分析的同学的提醒:算法正确性只是入场券,可用性取决于化简策略。
对 Elixir 开发者的行动建议就三句:
- 今天就升:OTP 27+,
mix compile --force,跑一遍看 warning 数。 - 先量化再阻塞:用基线脚本卡增量,别一上来
warnings_as_errors。 - 别急着上
:interpreted:测出来提速 >10% 再考虑,而且优先只在 CI 开。
参考与延伸
- Elixir v1.20 发布公告:
elixir-lang.org/blog/2026/06/03/elixir-v1-20-0-released/ - Elixir v1.20 CHANGELOG:
github.com/elixir-lang/elixir/releases/tag/v1.20.0 - 集合论类型设计论文:arXiv:2306.06391
- Lazy BDDs with eager literal differences(2026-03-19,内部实现细节)
- Lazy BDDs with eager literal intersections(2026-02-26)
- Typing Records, Maps, and Structs(ICFP 2023)
- If T: Benchmark for Type Narrowing:
github.com/utahplt/ifT-benchmark