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

2026-08-09 05:27:40 +0800 CST views 9

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 | BA & 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 只接受整数,于是从下游反推上游ab 必须都是 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)))

casecondwith 上同样实现了 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!/2Map.pop!/2Map.replace!/3Map.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+ 子句的模块,编译时间被拖垮

于是需要对差集也做同样的手术。差集有两条可利用的性质:

  • a1a2 不相交 → a1 \ a2 = a1
  • a1 ⊆ a2a1 \ 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 ⊆ a2a1 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 ⊆ a2a1 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/3Kernel.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 内部内容被编译后执行。
  • :interpreteddefmodule 内部内容被解释执行

关键点:它不影响最终写到磁盘的 .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

推荐文章

nuxt.js服务端渲染框架
2024-11-17 18:20:42 +0800 CST
#免密码登录服务器
2024-11-19 04:29:52 +0800 CST
12 个精选 MCP 网站推荐
2025-06-10 13:26:28 +0800 CST
Vue3中的v-model指令有什么变化?
2024-11-18 20:00:17 +0800 CST
前端代码规范 - Commit 提交规范
2024-11-18 10:18:08 +0800 CST
如何将TypeScript与Vue3结合使用
2024-11-19 01:47:20 +0800 CST
程序员茄子在线接单