ruff 仓库 ty 类型检查器对 Walrus 运算符(赋值表达式)的类型推断详解
【免费下载链接】ruffAn extremely fast Python linter and code formatter, written in Rust.项目地址: https://gitcode.com/GitHub_Trending/ru/ruff
本文以
crates/ty_python_semantic/resources/mdtest/assignment/walrus.md为骨架,结合crates/ty_python_semanticcrate 中类型推断的实现源码,系统讲解 ty(ruff 仓库中正在开发、以 Rust 编写的 Python 类型检查器)如何对 PEP 572 引入的 Walrus 运算符(赋值表达式:=)进行类型推断,包括基础求值、自增模式((x := x + 1))的绑定更新,以及其在条件表达式、增强赋值、多目标赋值等场景下的行为,并给出在本地运行这些 Markdown 测试的具体方法。
一、Walrus 运算符与测试文档定位
Python 3.8(PEP 572)引入了赋值表达式,即 Walrus 运算符:=。它在表达式中完成赋值的同时返回被赋的值,因此x := 1既将1绑定到名字x,又让整个表达式求值得到1。由于形态类似海象(walrus)的牙齿,故得名。
在 ruff 仓库中,这一语言特性由类型检查器 ty 支持,其语义验证以"Markdown 即测试"(mdtest)的形式固化在crates/ty_python_semantic/resources/mdtest/assignment/walrus.md中。该文件虽然只有两个测试用例,却精确刻画了 Walrus 运算符类型推断的两条核心语义:
- 赋值表达式整体参与外层表达式运算(
x = (y := 1) + 1); - Walrus 目标引用自身旧值进行更新(
(x := x + 1)),即"自增/自更新"模式。
根据 crates/ty_python_semantic/resources/README.md 的说明,mdtest/子目录下的所有 Markdown 文件都是"类型推断与类型检查"的测试套件,由tests/mdtest.rs集成测试执行;具体的测试书写规范见 crates/ty_test/README.md。
二、测试用例逐行解读
2.1 基础用例:表达式求值与字面量精确化
x = (y := 1) + 1 reveal_type(x) # revealed: Literal[2] reveal_type(y) # revealed: Literal[1]这是整个测试文件中标注为## Basic的第一个用例,验证两条行为:
(y := 1)作为一个表达式,其值是1,因此(y := 1) + 1整体求值为2,x被推断为Literal[2];- 赋值表达式内部完成了对名字
y的绑定,y的类型是Literal[1]; - 两侧的
reveal_type断言使用了 mdtest 的# revealed:断言语法,要求与 ty 实际揭示的类型逐字符精确匹配(详见 crates/ty_test/README.md 的 "Assertions" 一节)。
其中Literal[2]、Literal[1]这类"字面量类型"是 ty 精确推断能力的体现:它不仅能说出x是int,还能说出"这个int的字面量值恒为2"。这一能力的底层数据结构是crates/ty_python_semantic/src/types/literal.rs中定义的LiteralValueType<'db>(见该文件第 15 行)及其IntLiteralType、StringLiteralType、BytesLiteralType、EnumLiteralType等内部类型,整数字面量类型通过From<i64> for LiteralValueTypeKind(第 268 行)等转换直接承载具体值。
2.2 自增用例:Walrus 自引用旧值
x = 0 (x := x + 1) reveal_type(x) # revealed: Literal[1]标注为## Walrus self-addition的第二个用例,验证的是"Walrus 更新已有绑定"的语义:
- 首先
x = 0将x绑定为Literal[0]; - 随后
(x := x + 1)在执行赋值表达式时,右侧的x + 1读取的是x的旧值0,运算得1,再回写绑定x; - 因此最终
x被更新为Literal[1]。
这里的关键点是:Walrus 运算符"读旧值、算新值、写回同一名字"的三步顺序。与 Python 运行时的求值顺序一致,ty 的类型推断也必须保证在推断x + 1时使用的是更新前的绑定类型,而不是回写后的类型——这正是实现中"先推断右侧值、再插入绑定"顺序的由来(见下一节)。
三、源码级实现:Walrus 类型推断的核心调用链
两个测试用例背后,是crates/ty_python_semantic/src/types/infer/builder.rs中两处紧密配合的实现。
3.1 作为表达式:infer_named_expression
当 ty 的类型推断器(inference builder)遇到 AST 中的ast::Expr::Named(即 Walrus 赋值表达式)时,会分派到infer_named_expression(builder.rs):
fn infer_named_expression(&mut self, named: &ast::ExprNamed) -> Type<'db> { // See https://peps.python.org/pep-0572/#differences-between-assignment-expressions-and-assignment-statements if named.target.is_name_expr() && !self.in_string_annotation() { let definition = self.index.expect_single_definition(named); let result = infer_definition_types(self.db(), definition); self.extend_definition(definition, result); result.binding_type(definition) } else { // String annotations have no indexed definitions, and syntactically invalid targets // cannot define a name. Both sides still need inference to preserve their diagnostics. self.infer_expression(&named.target, TypeContext::default()); self.infer_expression(&named.value, TypeContext::default()); Type::unknown() } }从源码可以读出三条重要信息:
主路径按"定义"处理:当目标是普通名字(
is_name_expr())且不在字符串注解中时,ty 通过self.index.expect_single_definition(named)找到该 Walrus 表达式对应的唯一"定义"(Definition),再调用infer_definition_types对定义做类型推断,最终以result.binding_type(definition)返回绑定类型作为整个表达式的结果类型。这就解释了(y := 1) + 1中表达式的结果为何是Literal[1]——它返回的正是y这条绑定所持有的类型。兜底路径仍要保留诊断:当目标是字符串注解(无索引定义)或语法上无法定义名字的非法目标时,两侧(target 与 value)依然会被
infer_expression各推断一遍,以保证相关诊断不丢失,但表达式整体返回Type::unknown()。注释直接引用 PEP 572:源码中保留了指向 PEP 572 "Differences between assignment expressions and assignment statements" 一节的链接,说明该实现刻意对照了规范中"赋值表达式与赋值语句的差异"来设计。
3.2 作为定义:infer_named_expression_definition
Walrus 对名字的绑定本身是一条Definition。推断器在infer_definition的分发中,遇到DefinitionKind::NamedExpression(named_expression)时进入infer_named_expression_definition(builder.rs)。其实现位于 builder.rs:
fn infer_named_expression_definition( &mut self, named: &'ast ast::ExprNamed, definition: Definition<'db>, ) -> Type<'db> { let ast::ExprNamed { range: _, node_index: _, target, value, } = named; let add = self.add_binding(named.target.as_ref().into(), definition); let ty = self.infer_expression(value, add.type_context()); self.store_expression_type(target, ty); add.insert(self, ty) }这段实现精确对应了 2.2 节描述的"读旧值、算新值、写回"语义:
self.add_binding(...)先为target(如x)建立绑定;self.infer_expression(value, ...)随后才对右侧value(如x + 1)进行推断——此刻对x的读取命中的仍是旧绑定(Literal[0]),因此x + 1被推为Literal[1];- 最后
store_expression_type记录目标的表达式类型,add.insert(self, ty)把新类型Literal[1]写回绑定。
这一"先登记绑定、后推断右侧、最后插入结果"的顺序,正是(x := x + 1)能推出Literal[1]的机制保证;若顺序颠倒为"先推断右侧再登记绑定",右侧对x的读取就可能拿到未定义或错误的类型。
3.3 从表达式到定义的联通
两条路径通过Definition与DefinitionKind::NamedExpression相互衔接:外层表达式求值(3.1)与内层名字绑定(3.2)共享同一节点,从而保证"表达式结果类型"与"绑定类型"始终一致——这正是x与y在 2.1 用例中被同时精确推断的前提。
四、Walrus 在周边场景中的协同语义
assignment/目录下的其他测试文件与walrus.md同属一个测试套件家族,从侧面印证了 Walrus 语义在 ty 中的整体地位:
4.1 与增强赋值(augmented assignment)结合
crates/ty_python_semantic/resources/mdtest/assignment/augmented.md 的## Walrus target一节展示了(ys := [1])[0] += 1的写法:Walrus 的返回值(一个列表)被立即作为下标赋值的左值。这说明 Walrus 表达式的结果可以是任何可索引对象,其类型(此处为list[int | str])随后参与元素类型与操作符的检查。
4.2 与多目标赋值(multi-target assignment)结合
crates/ty_python_semantic/resources/mdtest/assignment/multi_target.md 中"共享值里的赋值表达式"一节验证了更精细的规则:first = second = (named := lambda: 0)中,named的绑定只发生一次,且所有目标(first、second、named)都获得同一个可调用类型() -> Literal[0];而"赋值表达式中的上下文推断"一节则表明,当named带有声明类型Callable[[int], int]时,该声明类型会反过来为共享的 lambda 提供参数上下文。
4.3 在条件表达式中的特殊处理
ty 在冗余条件(redundant condition)诊断中,对包含 Walrus 的表达式有专门分类。crates/ty_python_semantic/src/types/infer/builder/redundant_conditions/mod.rs中:
- 定义了
ConditionKind::ContainsWalrus(第 115 行),并说明"Walrus 表达式是例外:它们总是有副作用"(第 105 行注释); - 通过
any_over_expr(expression, ast::Expr::is_named_expr)检测条件中是否含有:=(第 563-565 行),命中即归类为ContainsWalrus; - 在
crates/ty_python_semantic/src/types/infer/builder/redundant_conditions/exemptions.rs中,redundant-condition-strict规则对含 Walrus 的条件(如assert (value := "foo"))予以豁免(第 70 行)。
这从诊断角度说明:ty 认识到 Walrus 即使出现在看似"冗余"的条件下也必然产生副作用(赋值),因此不应以静态真值判断将其判为冗余——这与 PEP 572 的语义是一致的。
五、如何本地运行这些 Walrus 测试
这些 Markdown 测试是可直接运行的回归测试,运行方式有两条(详见 crates/ty_test/README.md 的 "Running the tests" 一节与 crates/ty_python_semantic/mdtest.py):
方式一:通过 cargo 过滤运行
cargo test -p ty_python_semantic -- mdtest该命令会运行ty_python_semanticcrate 下所有以mdtest为名的测试(即tests/mdtest.rs驱动的全部 Markdown 套件)。也可进一步缩小范围,例如仅针对 walrus 相关测试:
cargo test -p ty_python_semantic --test mdtest -- walrus方式二:使用带监听模式的 Python runner
uv run crates/ty_python_semantic/mdtest.pymdtest.py基于rich与watchfiles(见其文件头部的依赖声明),支持监听模式:当 Markdown 测试文件变化时自动重跑对应测试,当 Rust 源码变化时自动重新编译,适合在开发类型推断逻辑时反复迭代。
测试如何工作:框架会把每个 Markdown 文件视为一个测试套件,其中被三个反引号围栏包裹的py代码块会被写入内存文件系统(默认路径/src/mdtest_snippet.py)并进行类型检查,随后用代码内的断言注释(# revealed:、# error:)与产生的诊断逐一比对,全部匹配才算通过。例如walrus.md中reveal_type(x) # revealed: Literal[2]断言"第 3 行的x被揭示为Literal[2]",若推断结果变为int或其他类型,测试即失败。每个测试在相互独立的 Salsa 数据库中从头执行,互不共享状态。
六、小结
crates/ty_python_semantic/resources/mdtest/assignment/walrus.md以两个短小精悍的用例,划定了 ty 对 Walrus 运算符类型推断的核心语义边界:
| 用例 | 核心断言 | 对应实现 |
|---|---|---|
| Basic | 表达式整体求值 + 绑定同时生效,Literal[2]/Literal[1] | infer_named_expression(builder.rs) |
| Walrus self-addition | 读旧值、算新值、回写绑定,Literal[0] → Literal[1] | infer_named_expression_definition(builder.rs) |
结合augmented.md、multi_target.md中关于 Walrus 目标的增强赋值、共享值绑定、上下文推断,以及redundant_conditions中对 Walrus 副作用的豁免处理,可以完整看到 ty 对赋值表达式的支撑面:既能在表达式层级精确求值,也能在定义层级正确处理自引用更新,还能在诊断层面尊重其必然副作用。对希望为 ty 贡献类型推断逻辑、或想深入理解"Rust 实现的 Python 类型检查器如何处理表达式级副作用"的开发者来说,这个文件及其背后不足百行的推断实现,是一个小而完整的入口。
【免费下载链接】ruffAn extremely fast Python linter and code formatter, written in Rust.项目地址: https://gitcode.com/GitHub_Trending/ru/ruff
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考