- 编程语言
- 编译器
- 标准库
- 语言运行时
- 并发编程
【免费下载链接】elixir
Simple from zero to scale
Elixir 正在把集合论类型(set-theoretic types)逐步引入编译器,本文基于仓库中的官方文档 gradual-set-theoretic-types.md 编写,系统讲解这一类型系统的三大属性、全部数据类型的书写语法、dynamic()渐进类型的语义,以及编译器当前基于源码的类型推断机制与已知误报场景。读完本文,你将能够读懂 Elixir 编译器在类型检查阶段产生的各类类型警告与诊断,理解or/and/not集合运算如何组合类型,并掌握dynamic()在无需任何类型标注的前提下为存量 Elixir 代码带来静态检查能力的原理。
三个核心属性:sound、gradual 与 developer friendly
Elixir 的类型系统在设计上同时满足三个特性,这也是理解后续所有语法与语义的起点:
- sound(健全):类型系统推断出的类型与实际运行时程序行为保持一致,不会出现"类型上说得通、运行必然出错"的偏离。
- gradual(渐进):类型系统内置
dynamic()类型,用于表示"类型在运行时才检查"的值。但与其他渐进类型语言不同,dynamic()不是简单地丢弃类型信息,而是以"类型区间(range)"的方式工作。例如写出dynamic(integer() or binary())后,如果某个调用对这两类都不接受,编译器依然会发出违规警告。 - developer friendly(对开发者友好):所有类型都通过基本的集合运算来描述、实现与组合——并集(unions)、交集(intersections)与否定(negation),这正是"集合论类型系统"这一名称的由来。
当前里程碑的目标是从现有程序(不包含任何类型签名)中推断类型并用于类型检查,让编译器在不要求改动现有代码的前提下发现代码库中的缺陷。用户提供类型签名的能力被规划在后续版本中。底层原理、理论与路线图详见论文"The Design Principles of the Elixir Type System"(作者:Giuseppe Castagna、Guillaume Duboc、José Valim)。
快速入门:基本类型与集合运算
基本类型
类型书写形式是"类型名 + 圆括号",例如integer()或list(integer())。基本类型包括:
atom() binary() bitstring() empty_list() integer() float() function() map() non_empty_list(elem_type, tail_type) pid() port() reference() tuple()其中许多类型还可以写得更精确。例如:
atom()表示所有原子,而原子:ok在类型系统中也可以直接写作:ok;tuple()表示所有元组,而一个"首元素为原子:ok、次元素为整数"的二元组可以写作{:ok, integer()}。
此外还有三个特殊类型:
none():表示空集,即没有任何值;term():表示全集,即所有值;dynamic():表示给定类型的区间(渐进类型)。
集合运算:or、and、not
由于类型是集合论的,可以自由组合:
- 并集:
atom() or integer()表示"要么原子、要么整数",例如一个函数返回原子或整数时可这样书写; - 交集:求两个操作数共有的元素。例如
atom() and integer(),由于原子与整数没有交集,结果就是空集none(); - 差集 / 否定:交集与否定结合可以实现差集。例如"除
nil之外的所有原子"(nil本身也是原子),可以写作atom() and not nil。
更完整的运算符速查表见仓库中的 set-theoretic types cheatsheet,其中收录了type1 or type2、type1 and type2、type1 and not type2、not type四种写法,以及boolean() = true or false、number() = integer() or float()、list() = empty_list() or non_empty_list(term())等便捷别名。
数据类型语法详解
现阶段开发者主要通过编译器的警告与诊断来接触这些类型,但完整掌握其语法有助于精确理解诊断信息。
宽泛类型(Broad types)
宽泛类型无法表示单个元素,只能表示整个集合。例如数字1和42都属于integer()。这类类型包括:
binary()、bitstring()、integer()、float()、pid()、port()、reference()。
其中binary()是较少使用的bitstring()的子类型:二进制(binary)是"总位数可被 8 整除"的位串(bitstring)。
原子(Atoms)
atom()表示所有原子;每个具体原子也可用其字面量作为(互不相同的)类型,例如:foo、:hello_world。nil、true、false也都是原子,可直接书写;boolean()是true or false的便捷别名。
元组(Tuples)
tuple()表示所有元组;也可用花括号字面量精确描述,如{:ok, binary()}。在元组末尾使用...表示"整体大小未知":
# 至少包含两个元素 {:ok, binary(), ...}列表(Lists)与 improper lists
list()表示所有正规列表(proper lists),包含空列表[]。也可以用参数指定元素类型,例如list(integer())表示[]和[1, 2, 3],但不包括[1, "two", 3]。
内部实现上,Elixir 把list(a)表示为两个类型的并集:empty_list()与non_empty_list(a),即:
list(integer()) == empty_list() or non_empty_list(integer())这一点可以在源码 descr.ex 中得到印证:empty_list()构造为%{bitmap: @bit_empty_list},而list(type)经由list_descr(type, @empty_list, true)构造,@empty_list正是空列表的描述符。
Improper lists(非正规列表)
大多数开发者只需list(a),但类型系统可以通过给non_empty_list传第二个参数(尾部的类型)表达 Elixir 列表的各种表示:
- 正规列表的尾部就是空列表:
non_empty_list(integer())等价于non_empty_list(integer(), empty_list()); - 若
tail_type不是列表类型,则是不正规列表:值[1, 2 | 3]的类型为non_empty_list(integer(), integer()); - 若传入的尾部是列表类型,则该列表类型会被合并进元素类型:
non_empty_list(integer(), list(binary()))等价于non_empty_list(integer() or binary(), empty_list())。
映射(Maps)
map()表示所有映射;也可以用字面量语法精确描述:
%{name: binary(), age: integer()}上面这个类型描述的是恰好有两个键:name(值为binary())与:age(值为integer())的映射,被称为"封闭(closed)"映射——只支持显式定义的键。在首位加入...可标记为"开放(open)"映射:
%{..., name: binary(), age: integer()}表示:name与:age必须存在且类型分别正确,但允许存在其他键。map()本身等价于%{...};空映射可写%{},不过文档建议用empty_map()以表意更清晰。
可选键:if_set/1与not_set()
用if_set/1操作符作用于键值类型,可把某个键标记为可选:
%{name: binary(), age: if_set(integer())}表示:name键必然存在,:age键可能不存在(若存在,其值类型为integer())。
用not_set()表示某键不可能存在:
%{..., age: not_set()}表示"该映射可以有任意键,唯独不能有:age"。这正是Map.delete(map, :age)返回值的类型。
域类型(Domain types)
映射的键也可以是其他类型:
# 封闭映射 %{binary() or atom() => integer()} # 开放映射 %{..., binary() or atom() => integer()}当前类型系统只跟踪每个独立类型的顶层作为域键。例如:
%{list(integer()) => integer(), list(binary()) => binary()}等价于指定了所有列表:
%{list() => integer() or binary()}支持的域键为:atom()、bitstring()、binary()、integer()、float()、fun()、list()、map()、pid()、port()、reference()、tuple()。其中bitstring()域只存储非二进制的键,属于binary()的键存储在binary()域下。这一列表与源码 descr.ex 中的@domain_key_types([:binary, :bitstring_no_binary, :integer, :float, :pid, :port, :reference, :fun, :atom, :tuple, :map, :list])完全对应。
需要特别注意:域键按定义是可选(optional)的。对于%{integer() => integer()},当你尝试取某个键时,必须假设该键可能不存在——因为不可能把无穷多个整数全部存成映射键。
混合键(Mixed keys)
域键与原子键可以混用。例如下面这个映射表示"所有原子键的值类型都是binary(),唯独:root键是integer()":
# 封闭映射 %{atom() => binary(), root: integer()} # 开放映射 %{..., atom() => binary(), root: integer()}键的顺序按精度递增排列::root比atom()更精确,因此排在后。这与映射的运行时语义一致——重复键时后者覆盖前者。在 expr_test.exs 的 maps 测试组(如 "creating maps as records"、"creating maps as dictionaries")中,可以观察到closed_map(foo: {atom([:bar]), false})、closed_map([{domain_key(:integer), integer()}])等字面量映射的推断结果。
函数(Functions)与箭头类型
function()表示所有函数,但实际中大多数函数表示为箭头(arrow):
# 接收一个整数、返回布尔值 (integer() -> boolean()) # 接收两个整数、返回字符串(二进制) (integer(), integer() -> binary())表示多子句、多输入类型的函数时使用交集。考虑如下函数:
def negate(x) when is_integer(x), do: -x def negate(x) when is_boolean(x), do: not x给它整数就取反,给它布尔值就取非。这个函数同时属于集合(integer() -> integer())(能接收整数并返回整数)与集合(boolean() -> boolean())(能接收布尔值并返回布尔值)。把该函数传给期望(boolean() -> boolean())的另一个函数时,类型检查会成功。因此该函数的整体类型是:
(integer() -> integer()) and (boolean() -> boolean())交集表示该函数同时属于两个集合。
为什么用交集而不是并集?
文档用了一个贴切的例子:一件绿黄条纹的 T 恤。它既属于"绿色 T 恤"集合,也属于"黄色 T 恤"集合:
(t_shirts_with_green() or t_shirts_with_yellow()):包含纯绿、绿红、绿黄、纯黄、黄红等所有"至少含一种颜色"的 T 恤;(t_shirts_with_green() and t_shirts_with_yellow()):只包含同时含绿色与黄色的 T 恤(可能还有其他颜色)。
由于这件 T 恤两种颜色兼有,说它属于并集无法捕捉"同时具备两种颜色"这一事实,因此用交集更精确。函数同理:(integer() -> integer())与(boolean() -> boolean())兼有才最精确。实际使用中,在 Elixir 里定义两个函数的并集没有意义,因此如果你写错方向,编译器会给出提示。
dynamic()类型:渐进类型的核心
存量 Elixir 程序没有类型声明,但依然要能对其做类型检查,这正是dynamic()类型存在的意义。
当编译器看到上面的negate/1时,会按函数类型(dynamic() -> dynamic())进行类型检查;随后基于模式与 guard 把变量x分别精化为dynamic() and integer()与dynamic() and boolean()。因为dynamic()是渐进类型,这套机制被称为渐进式集合论类型(gradual set-theoretic types)。
理解dynamic()最简单的方式:它是"类型的一个区间(range)"。若变量类型是atom() or integer(),底层代码需要同时能处理这两类。例如调用Integer.to_string(var)且var类型为atom() or integer()时,类型系统会发出警告,因为Integer.to_string/1不接受原子。
但如果用dynamic()做交集,类型就变成渐进的了,只需要类型的一个子集合法即可:
# var 的类型为 dynamic() and (atom() or integer()) Integer.to_string(var) # 不会产生警告,因为 Integer.to_string/1 至少能处理其中一类为书写方便,大多数程序写成dynamic(atom() or integer())而非交集形式,二者等价。源码 descr.ex 中dynamic()被实现为%{dynamic: :term},而none()是空描述符%{}、term()是:term;descr_test.exs 中有大量关于dynamic()与并集、交集、差集、子类型、兼容性(compatibility)交互的断言,例如opt_union(dynamic(atom()), atom())等价于atom(),以及compatible?(integer(), opt_intersection(dynamic(), integer()))为真而compatible?(opt_intersection(dynamic(), integer()), atom())为假。
与其他渐进类型语言相比,Elixir 的dynamic()相当强大:它通过交集把程序限制在特定类型范围内,同时在确定代码必然失败时依然发出警告,这让dynamic()成为给存量代码添加有意义警告的绝佳工具。
最后一个关键性质:dynamic()永远处于根部(root)。例如写一个元组类型{:ok, dynamic()},Elixir 会重写为dynamic({:ok, term()})。这带来的坏处是你无法让元组/映射/列表的部分渐进、只能整体渐进;好处是dynamic()始终显式出现在根部,很难在静态类型程序中无意混入dynamic()。
类型推断(Type inference)
类型推断(又称类型重构)是类型系统在编译期自动推导表达式类型的能力,可发生在不同层级:很多语言能自动推断变量类型(局部类型推断),但并非都能推断函数的类型签名。
推断函数签名的权衡
文档列出了推断函数签名的一系列代价:
- 速度:类型推断算法通常比类型检查算法计算量更大;
- 表达能力:任何类型系统中,支持推断的构造都是可被类型检查构造的子集。如果一门语言被限制为"完全重构类型",其表达能力弱于纯类型检查的语言;
- 增量编译:推断会复杂化增量编译。若模块 A 依赖 B、B 依赖 C,改动 C 可能要求重构 B 的签名,进而要求重算 A……这种依赖链可能迫使大型项目显式添加类型签名以换取稳定性与编译效率;
- 级联错误:当用户误写类型或代码存在冲突假设时,推断可能产生更不清晰的错误信息,因为类型系统要在多条代码路径间调和发散的类型假设。
Elixir 的推断策略
推断的好处在于:无需用户添加类型标注即可对函数与代码库做类型检查。为平衡上述取舍,Elixir 的目标是跨依赖进行类型推断:推断函数类型时考虑当前模块、Elixir 标准库与项目依赖;而同一项目内模块之间的调用默认假定为dynamic()。类型推断完成后,整个项目再基于全部模块与全部类型(推断的或其他来源)进行类型检查。
类型推断是best-effort(尽力而为)的:它不保证发现所有可能的类型不兼容,只承诺"在类型的所有组合必然失败时"可能发现缺陷——即便没有任何显式类型标注。它是一条高效、无需开发者付出任何努力、同时保留语言表达力的静态检查捷径。
长期来看,想要静态类型保证的开发者可以显式添加函数类型签名(见 Roadmap 一节)。任何带显式签名的函数都会像其他静态类型语言那样,被按照用户提供的注解做类型检查。
在实现层面,types.ex 定义了@modes [:static, :dynamic, :infer]三种模式并实现了infer/6:static表示已给出类型签名;dynamic表示未给出签名(对强箭头执行兼容性检查并返回dynamic(OUT),对弱箭头返回dynamic());infer与dynamic相同但跳过远程调用,且仅在:elixir_config.get(:infer_signatures)非false、存在缓存且非协议模块时推断签名。类型警告最终由 parallel_checker.ex 通过Module.Types.warnings(...)汇总并按severity区分 warning 与 error 后输出。
误报(False positives)
Elixir 的类型推断通常避免发出误报(即没有运行时错误却发出的类型违规警告),但存在两类已知场景可能误报,文档明确列出。
for推导假定至少执行一次
推导式(for comprehension)假定其至少执行一次。例如:
def example(x, list) do for _i <- list do Atom.to_string(x) end x + 1 end这里的x + 1会报错,因为类型系统假定x来自Atom.to_string(x)调用、是原子,尽管若list为空列表,运行时可能根本不会出错。这是有意为之:有助于发现推导式内外的类型分歧。解决办法是把推导式显式包裹进if list != [] do ... end(或类似条件)。
struct 更新语法必须静态证明
如果使用 struct 更新语法%User{user | name: "John Doe"},而类型系统无法静态证明给定值不具有该 struct 类型,就会发出类型违规警告。例如:
user = find_user_by_id(42) %User{user | name: "John Doe"}即使运行时能保证user一定是Userstruct,只要类型系统无法证明,也会触发违规——这是 struct 更新按设计如此。解决办法是在定义变量时就用模式匹配固定 struct:
%User{} = user = find_user_by_id(42) %User{user | name: "John Doe"}Roadmap:三个阶段
当前阶段,Elixir 已实现对所有语言构造的类型推断,目标是先评估性能、收集错误信息质量的反馈,再引入面向用户的类型。
- 下一里程碑:若结果令人满意,将引入"类型化 struct(typed structs)"的机制。Elixir 程序频繁对 struct 做模式匹配,这能揭示字段是否存在,但对其类型一无所知;通过在整个程序中传播 struct 及其字段的类型,可进一步提高类型系统发现错误的能力。相关语言表面改动提案会在此阶段提交给社区。
- 第三里程碑:引入函数的集合论类型签名。现有的 Erlang Typespecs 精度不足以支撑集合论类型,将在该阶段结束后被逐步移出语言,其后处理迁移到独立库。
参考资料与致谢
- (论文)The Design Principles of the Elixir Type System,作者 Giuseppe Castagna、Guillaume Duboc、José Valim;
- (视频)The foundations of the Elixir type system(José Valim);
- (视频)Precision in type system design(José Valim)。
该类型系统的实现得益于 CNRS 与 Remote 的合作,开发工作目前由 Fresha 与 Tidewave 赞助。对类型系统内部实现感兴趣的读者,可继续阅读仓库中的 Module.Types 实现、集合论类型描述符实现(其中用位图表示不可分类型、以 DNF/BDD 表示集合论类型的并集与否定)以及配套测试 descr_test.exs、expr_test.exs 与 map_test.exs。
- 编程语言
- 编译器
- 标准库
- 语言运行时
- 并发编程
【免费下载链接】elixir
Simple from zero to scale
相关推荐
Flow 类型注解全指南:类型推断、注解语法与 15 大类类型系统详解
Flow 类型注解全指南:类型推断、注解语法与 15 大类类型系统详解 为 JavaScript 代码添加类型注解,是与 Flow 交互中最重要的一环。Flow
开发工具静态分析代码质量WebAssembly动态类型系统:awesome-wasm渐进式类型检查
WebAssembly动态类型系统:awesome wasm渐进式类型检查 你是否在WebAssembly(Wasm)开发中遇到过类型不匹配导致的运行时错误?是
文档深入 Ruff 内置类型检查器 ty:`Any` 动态类型与渐进类型系统的语义解析
深入 Ruff 内置类型检查器 ty: Any 动态类型与渐进类型系统的语义解析 Any 是 Python 渐进类型系统(gradual type system
开发工具Lint格式化静态分析CLI
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考