编程 Elixir v1.20 深度拆解:当一门动态语言决定「不写一行类型注解」也要做类型检查——集合论类型、dynamic() 的收窄语义与 BDD 的编译期手术

2026-08-09 05:27:40

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 的整个可靠性模型建立在三件事上:

  1. 进程隔离 + let it crash:错误不做防御性拦截,让进程死掉,由 supervisor 重启到已知良好状态。
  2. 热代码升级:一个模块可以在系统运行时被新版本替换,新旧两个版本在同一时刻共存于 VM 中。
  3. 消息传递是无类型的信封: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 里的意思
并 unionA or B值属于 A 或 B
交 intersectionA and B值同时属于 A 和 B
补/差 negation/differencenot 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 的语义是「什么都行,不要检查」。它有两个后果:

  1. 一旦某个值被标成 any,它流经的所有下游都失去检查能力,形成「any 污染」。
  2. 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

推导过程:

  1. data 初始类型 dynamic()。
  2. 出现 data.a,且结果被送进 + → data 至少是个含 a 键的 map,且 a 是数字。
  3. 同理 data.b。
  4. 最终 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 生成代码。

代价(两条,都要认真对待):

  1. 编译期错误的 stacktrace 精度下降。宏展开炸了之后,定位会更痛苦。
  2. 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 后编译明显变慢,按这个顺序查:

  1. 是不是全量编译? 第一次 --force 慢是正常的,看第二次增量。
  2. 有没有超大 case/多子句函数? 尤其是同一 struct 上的多子句分派——这正是 5.5 节那个 pattern。先把子句数最多的模块揪出来:
# 粗略统计每个文件的 def/defp 子句数
grep -c -E "^\s+defp?\s" lib/**/*.ex 2>/dev/null \
  | sort -t: -k2 -rn | head -20
  1. 有没有巨型 map/struct 类型在到处传? 字段越多,BDD 节点越多。
  2. 依赖是不是也在 v1.20 下重编? mix deps.compile --force 一次,看是哪个依赖慢。
  3. 开 :interpreted 试试,尤其是元编程重的项目。
  4. 还是慢,去 Elixir Forum 开帖并附上最小复现——类型系统的性能问题目前是核心团队的高优先级。

八、十条踩坑清单

  1. 别指望它抓所有 bug。设计目标是「只报 verified bug」,也就是「执行到就一定崩」的那类。业务逻辑错误、nil 的语义误用(而非类型误用)它管不了。
  2. dynamic() 不相交才报错。所以「可能是 nil」这种情况,只要下游函数接受的类型里包含非 nil 的部分,就不会报。要真正消灭 nil,还是得靠显式子句或模式匹配。
  3. 升级要 OTP 27+。还在 OTP 25/26 的项目,先升 OTP 再升 Elixir,别两个一起动。
  4. 第一次务必 mix compile --force。增量编译只检查变更模块,不 force 你会以为「我的项目很干净」。
  5. warnings_as_errors 别一步到位。存量项目上来就开必红,用基线脚本渐进收敛。
  6. :module_definition, :interpreted 有副作用。stacktrace 变模糊、defmodule 内匿名函数参数上限 20。不要因为「据说能提速」就无脑全局打开。
  7. 注意 v1.20 的两个潜在破坏性变更:字符串/注释/? 之后不再允许裸 CR 换行(安全原因);require SomeModule 不再在编译期展开为该模块,虽然运行时仍返回模块,但 require(SomeMod).some_macro() 这类写法会挂。
  8. 不要用间接调用绕过检查。apply/3 能让 warning 消失,但 bug 还在,而且以后加类型签名时会翻倍还债。
  9. 冗余子句 warning 要一条条看。有些「冗余」是你刻意留的防御性兜底,删之前先确认调用方真的不会传进来;但更多时候它就是真死代码。
  10. 依赖的类型精度会影响你。如果某个核心依赖还没在 v1.20 下编译过,你这边的推断精度会打折。升级时优先推动核心依赖跟进。

九、还没到的部分:类型签名什么时候来

官方对下一步说得很坦白。引入用户可写的类型签名,需要先满足四个前提:

  1. 对 v1.20 现有类型系统的性能满意(已经做了三轮 BDD 优化,还在继续);
  2. 能高效实现递归类型;
  3. 能高效实现参数化类型(泛型);
  4. 能高效实现「把 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

推荐文章

程序员茄子在线接单