Ruff Ty 类型检查器中的 Eager Scopes:作用域求值时机与惰性粘滞语义详解
【免费下载链接】ruffAn extremely fast Python linter and code formatter, written in Rust.项目地址: https://gitcode.com/GitHub_Trending/ru/ruff
导读
本文深入解析 Ruff 项目 ty 类型检查器(位于crates/ty_python_semantic)中“急切作用域(Eager Scopes)”的核心语义:类定义、推导式等作用域在定义时立即解析外层变量引用,而函数定义、lambda 与类型别名则在调用时才解析。全文以规格文档 eager.md 为主干,逐一剖析函数、类、列表/集合/字典推导式、生成器表达式、顶层作用域、注解延迟求值以及“惰性作用域粘性”规则,并对照 scope.rs 中的ScopeKind::laziness()实现与 place_load.rs 中的快照解析源码,让你既能读懂这些 mdtest 用例的每一处revealed:断言,也能理解类型检查器在底层是如何按作用域类别决定解析时机的。读完本文,你将掌握 ty 类型检查器作用域模型的完整行为表,并能在实际 Python 类型推断中准确预判“外层变量何时被立即绑定、何时被延迟到调用时”。
一、什么是 Eager Scopes:定义与核心判据
在 Python 语义中,不同作用域对外层变量的引用存在两种解析时机:
- 急切(eager)解析:作用域被执行(求值)时,立即解析对外层作用域变量的引用,绑定到那一刻可见的绑定;
- 惰性(lazy)解析:引用被延迟到作用域真正被调用/使用时才解析,此时外层变量的最终绑定可能已经改变。
ty 类型检查器把这一区分建模为每个作用域的“惰性程度(laziness)”,在 scope.rs 中通过ScopeLaziness枚举与ScopeKind::laziness()方法明确定义:
pub(crate) enum ScopeLaziness { /// The scope is evaluated lazily (e.g. function, type alias scope). Lazy, /// The scope is evaluated eagerly (e.g. module, class, comprehension scope). Eager, } impl ScopeKind { pub(crate) const fn laziness(self) -> ScopeLaziness { match self { ScopeKind::Module | ScopeKind::Class | ScopeKind::Comprehension | ScopeKind::TypeParams => ScopeLaziness::Eager, ScopeKind::Function | ScopeKind::Lambda | ScopeKind::TypeAlias => ScopeLaziness::Lazy, } } }从源码结构可以提炼出 eager/lazy 作用域的分类表:
| 作用域类型 | 惰性程度 | 说明 |
|---|---|---|
Module(模块) | Eager | 模块体从上到下顺序执行 |
Class(类体) | Eager | 类体在定义时执行 |
Comprehension(推导式) | Eager | 列表/集合/字典推导式在书写位置立即求值 |
TypeParams(类型参数作用域) | Eager | 但 bounds/constraints 延迟求值(见下文) |
Function(函数体) | Lazy | 调用时才执行 |
Lambda(lambda) | Lazy | 调用时才执行 |
TypeAlias(类型别名) | Lazy | 别名体延迟展开 |
在 place_load.rs 中,源码对 eager 作用域的快照语义有一段关键注释:
“An eager nested scope uses the global snapshot captured when it began, so a class body cannot see a module binding created only after that body finishes.”(一个嵌套的 eager 作用域使用其开始时捕获的全局快照,因此类体无法看到在该类体结束之后才创建的模块级绑定。)
这正是下文所有revealed:断言背后的统一原理:eager 作用域捕获的是“进入作用域那一刻”的外层绑定快照。
二、函数定义:典型惰性求值
函数体是惰性作用域,即使外层变量在定义后被重新赋值,函数体内部的引用也要到调用时才解析,因此最终看到的是所有可达绑定(这里即Literal[1, 2])。
x = 1 def f(): reveal_type(x) # revealed: Literal[1, 2] x = 2reveal_type(x)在类型检查器中会输出一条RevealedType诊断(参见 builder.rs 中reveal_type的内建处理逻辑),mdtest 快照据此断言推断出的类型为Literal[1, 2]——因为惰性解析会收集x在函数可见范围内的全部赋值。
三、类定义:急切求值
类体在定义时立即执行,因此类体内的引用在类定义开始处就被急切解析,只能看到此刻已有的绑定:
def _(): x = 1 class A: reveal_type(x) # revealed: Literal[1] y = x x = 2 reveal_type(A.y) # revealed: int注意两个关键差异:
- 类体内
reveal_type(x)解析为Literal[1],因为它发生在x = 2之前,且类是 eager 作用域,只捕获进入类体时的快照; A.y是类属性,其值在类定义时就已确定为x = 1的int,所以类外reveal_type(A.y)得到的不是Literal[1]而是更宽的int——类属性的类型在绑定发生时就已定型,不再跟随外层x变化。
四、推导式:列表、集合、字典全部急切求值
三类推导式(comprehension)都是 eager 作用域,在书写位置立即求值:
def _(): x = 1 # revealed: Literal[1] [reveal_type(x) for a in range(1)] x = 2def _(): x = 1 # revealed: Literal[1] {reveal_type(x) for a in range(1)} x = 2def _(): x = 1 # revealed: Literal[1] {a: reveal_type(x) for a in range(1)} x = 2三个例子结构完全对称:推导式在x = 1之后、x = 2之前执行,因此捕获到的x只有Literal[1]这一种绑定。这验证了 scope.rs 中Comprehension => ScopeLaziness::Eager的实现。
五、生成器表达式:通常急切,但存在“延迟执行”例外
生成器表达式(generator expression)在规范上并不必然立即求值,但实践中几乎总是立即被消费,因此 ty 类型检查器默认按 eager 处理是更好的默认值:
def _(): x = 1 # revealed: Literal[1] list(reveal_type(x) for a in range(1)) x = 2这里生成器被立即传给list()消费,等价于急切求值,所以reveal_type(x)得到Literal[1]。
5.1 未被立即消费时的“错误”推断
当生成器表达式被保存下来、延迟到之后才迭代时,eager 假设会产生与运行时不一致的结果:
def evaluated_later(): x = 1 # revealed: Literal[1] y = (reveal_type(x) for a in range(1)) x = 2 # The generator isn't evaluated until here, so at runtime, `x` will evaluate to 2, contradicting # our inferred type. print(next(y))此时y只是被赋值、并未被迭代,生成器体真正求值发生在next(y)处,运行时x已经是 2;而类型检查器按 eager 假设在定义处就解析为Literal[1]——注释明确承认这与运行时结果相矛盾。这是该文档承认的“已知不精确”场景。
5.2 最左for子句的 iterable 表达式总是立即求值
但存在一个关键的精确场景:根据 Python 语言规范,生成器表达式最左侧for子句中的 iterable 表达式会被立即求值(eager.md 中引用的是 Python 语言参考手册关于生成器表达式的条文,本文以文档语义为准,不提供外部链接)。因此即使生成器体本身延迟执行,其首个 iterable 的求值时机仍然是 eager 的:
def iterable_evaluated_eagerly(): x = 1 # revealed: Literal[1] y = (a for a in [reveal_type(x)]) x = 2 # Even though the generator isn't evaluated until here, the first iterable was evaluated # immediately, so our inferred type is correct. print(next(y))[reveal_type(x)]作为最左for子句的 iterable 立即求值,捕获x = 1,因而最终推断Literal[1]是正确的——这与 5.1 中生成器体变量引用的场景形成了精确对比:iterable 表达式 eager、生成器体变量按 eager 默认处理但运行时可能延迟。
六、顶层(模块级)Eager Scopes
上述所有行为在 eager 作用域直接嵌套于全局作用域时完全一致。但顶层例子还额外展示了另一个重要行为:eager 作用域不能看到其结束后才创建的绑定(“不可前向引用”)。
6.1 顶层类定义
x = 1 class A: reveal_type(x) # revealed: Literal[1] y = x x = 2 reveal_type(A.y) # revealed: int类体在x = 1与x = 2之间执行,只看到Literal[1];A.y定型为int。
6.2 顶层推导式:同时验证后向绑定不可见
列表、集合、字典推导式在顶层的行为完全一致,且都验证了 eager 作用域“看不见其后定义的变量”:
x = 1 # revealed: Literal[1] [reveal_type(x) for a in range(1)] x = 2 # error: [unresolved-reference] [y for a in range(1)] y = 1x = 1 # revealed: Literal[1] {reveal_type(x) for a in range(1)} x = 2 # error: [unresolved-reference] {y for a in range(1)} y = 1x = 1 # revealed: Literal[1] {a: reveal_type(x) for a in range(1)} x = 2 # error: [unresolved-reference] {a: y for a in range(1)} y = 1三者的要点相同:推导式求值时y尚未绑定(y = 1在其后),因此产生unresolved-reference错误——eager 作用域不会等待未来的绑定。这也与 place_load.rs 的快照注释相互印证:eager 作用域使用的是“开始时”的快照。
6.3 顶层生成器表达式
x = 1 # revealed: Literal[1] list(reveal_type(x) for a in range(1)) x = 2 # error: [unresolved-reference] list(y for a in range(1)) y = 1立即消费的生成器按 eager 处理得到Literal[1];而y未定义在生成器之前,报unresolved-reference。
对应的两个延迟场景文件(文档中分别以evaluated_later.py、iterable_evaluated_eagerly.py命名):
x = 1 # revealed: Literal[1] y = (reveal_type(x) for a in range(1)) x = 2 # The generator isn't evaluated until here, so at runtime, `x` will evaluate to 2, contradicting # our inferred type. print(next(y))x = 1 # revealed: Literal[1] y = (a for a in [reveal_type(x)]) x = 2 # Even though the generator isn't evaluated until here, the first iterable was evaluated # immediately, so our inferred type is correct. print(next(y))七、核心规则:Lazy Scopes 是“粘性”的
当类型检查器向外逐层搜索引用的绑定/定义时,只要沿途遇到任意一个 lazy 作用域,整个查找就变为惰性——即使该 lazy 作用域之外还有 eager 作用域。这一“粘性(sticky)”规则决定了所有嵌套组合的解析结果。下面五种组合完整覆盖了该规则的行为空间。
7.1 Eager 作用域内嵌 Eager 作用域:保持急切
未遇到 lazy 作用域时查找保持 eager,且解析到的绑定不必在紧邻的外层作用域中。下例中列表推导式与类定义都是 eager,x立即被解析为唯一的x = 1绑定:
def _(): x = 1 class A: # revealed: Literal[1] [reveal_type(x) for a in range(1)] x = 27.2 类体绑定对嵌套作用域不可见
类定义虽是 eager 作用域,但其内部产生的绑定明确不对任何嵌套作用域可见(这些嵌套作用域通常是惰性的函数定义,但该规则同样适用于推导式、其他类定义等嵌套 eager 作用域):
def _(): x = 1 class A: x = 4 # revealed: Literal[1] [reveal_type(x) for a in range(1)] class B: # revealed: Literal[1] [reveal_type(x) for a in range(1)] x = 2 x = 1 def _(): class C: # revealed: Literal[1] [reveal_type(x) for _ in [1]] x = 2即使A内部已经绑定了x = 4,嵌套在A中的推导式仍然解析到外层函数_中的x = 1(而非A.x),嵌套类B同理;类C中x = 2定义在推导式之后,推导式仍解析到全局x = 1。这印证了 implicit_attributes.rs 中处理类体属性可见性时对 eager 作用域父链的向上查找逻辑。
7.3 Eager 作用域位于 Lazy 作用域之内:变惰性
列表推导式本身是 eager 作用域,但它被包在惰性的函数定义中。由于在遇到任何绑定/定义之前必须先穿过这个 lazy 作用域,查找变为惰性:
def _(): x = 1 def f(): # revealed: Literal[1, 2] [reveal_type(x) for a in range(1)] x = 2推导式最终看到x的全部可达绑定Literal[1, 2],而不是急切场景下的Literal[1]——惰性“传染”给了原本 eager 的推导式。
7.4 Lazy 作用域位于 Eager 作用域之内:仍然惰性
函数定义是 lazy 作用域,它被包在 eager 的类定义中。即便先穿过了 eager 作用域,由于在遇到绑定/定义前穿过了 lazy 作用域,查找依旧保持惰性:
def _(): x = 1 class A: def f(): # revealed: Literal[1, 2] reveal_type(x) x = 27.5 Lazy 作用域套 Lazy 作用域:层层惰性
无论穿过多少个 lazy 作用域才遇到绑定/定义,查找始终保持惰性:
def _(): x = 1 def f(): def g(): # revealed: Literal[1, 2] reveal_type(x) x = 27.6 组合场景:Eager 内嵌 Lazy 内嵌 Eager
综合场景:列表推导式(eager)被函数定义(lazy)包裹,再被类定义(eager)包裹。虽然最后穿过的作用域是 eager 的,但由于途中遇到了 lazy 作用域,整个查找仍是惰性的:
def _(): x = 1 class A: def f(): # revealed: Literal[1, 2] [reveal_type(x) for a in range(1)] x = 2粘性规则一句话总结:查找过程中的“第一个 lazy 作用域”决定一切——它之前的 eager 作用域无关紧要,之后的查找全部惰性化。
八、注解(Annotations):延迟求值的边界
类型注解有时会被延迟求值(deferred)。一旦延迟,注解中引用的类型即使在 eager 作用域中也会被惰性查找。这解释了为什么同一个类属性在不同注解模式下会得到不同结果。
8.1 Python 文件中的急切注解
默认(无from __future__ import annotations)情况下注解立即求值,类属性var捕获x = int时的类型:
from typing import ClassVar x = int class C: var: ClassVar[x] reveal_type(C.var) # revealed: int x = str8.2 Python 文件中的延迟注解
启用from __future__ import annotations后,注解字符串被延迟求值,引用以惰性方式解析,最终合并了int与str两种可能:
from __future__ import annotations from typing import ClassVar x = int class C: var: ClassVar[x] reveal_type(C.var) # revealed: int | str x = str8.3 Stub 文件(.pyi)中的延迟注解
Stub 文件中的注解默认就是延迟的,但当前实现存在一处已知偏差(文档中以 TODO 标注):
from typing import ClassVar x = int class C: var: ClassVar[x] # TODO: should ideally be `str`, but we currently consider all reachable bindings reveal_type(C.var) # revealed: int | str x = str理想情况下应为str(因为 stub 文件不参与运行时赋值顺序),但当前实现仍考虑所有可达绑定,因此给出int | str。这是文档明确声明的实现局限,读者应将其视为当前行为而非最终规范。
8.4 注解作用域(Annotation Scopes)
以下用例通过[environment]配置指定python-version = "3.12"(见 eager.md 内嵌的 TOML 配置块),用于启用 3.12 的 PEP 695 语法(type别名与[T]类型参数):
[environment] python-version = "3.12"类型别名(type alias)的注解作用域是惰性的。type Foo = Bar在Bar定义之前声明,但别名体延迟求值,因此后续能正确解析到Bar并完成类型收窄:
type Foo = Bar class Bar: pass def _(x: Foo): if isinstance(x, Bar): reveal_type(x) # revealed: Bar else: reveal_type(x) # revealed: Never类型参数作用域(type-param scope)是 eager 的,但其 bounds/constraints 延迟求值。基类列表中的类型引用与函数参数注解中的类型引用按 eager 解析,因此在Bar定义之前引用会报unresolved-reference;而 bound 约束(T: Bar)可以引用后定义的Bar:
# error: [unresolved-reference] class DT: pass class E[T: Bar]: pass # error: [unresolved-reference] def gT: pass def hT: Bar: pass class Bar: pass这正对应 scope.rs 中ScopeKind::TypeParams => ScopeLaziness::Eager的实现:类型参数作用域本身急切,但注解内容按前文规则走延迟路径。
九、如何运行这些用例:mdtest 测试体系
eager.md属于 ty 类型检查器的mdtest文档驱动测试体系。mdtest 会解析 Markdown 文件中的代码块,把revealed:、error: [rule]等断言与类型检查诊断逐一比对。运行方式参见 mdtest.py:
- 测试套件目录通过
MDTEST_SUITES注册,mdtest 目录为crates/ty_python_semantic/resources/mdtest; - 脚本会先编译
mdtest可执行文件(通过cargo test --test=mdtest探测产物,见 mdtest.py),再以--exact <test_function>::<markdown_file>精确运行单个文档的用例; - 环境变量
INSTA_FORCE_PASS=1、INSTA_OUTPUT=none控制快照断言,MDTEST_EXTERNAL、MDTEST_UPDATE_SNAPSHOTS等控制外部依赖与快照更新行为。
你可以把eager.md中任意一个代码块独立抽出,作为理解 ty 类型检查器“该作用域到底怎么解析”的最小复现样本;修改revealed:行后重新运行 mdtest,即可观察类型推断结果的变化。断言比对的具体实现位于crates/mdtest/src/assertion.rs与crates/mdtest/src/matcher.rs(mdtest crate 的src目录下),reveal_type的诊断输出则在 builder.rs 的report_revealed_type调用链中产生。
十、总结:Eager Scopes 行为速查
| 作用域场景 | 解析时机 | 结果特征 |
|---|---|---|
| 函数体 / lambda / 类型别名 | Lazy | 收集全部可达绑定(如Literal[1, 2]) |
| 类体 | Eager | 捕获进入类体时的快照(如Literal[1]) |
| 列表/集合/字典推导式 | Eager | 立即解析书写位置的绑定 |
| 生成器表达式(立即消费) | 默认 Eager | 按 eager 推断,通常正确 |
| 生成器表达式(延迟迭代) | 实际运行时 Lazy | eager 假设可能与运行时矛盾 |
最左for的 iterable 表达式 | 总是 Eager | 推断精确 |
| Eager 内嵌 Eager | Eager | 解析最近可用的 eager 绑定 |
| 类体绑定对嵌套作用域 | 不可见 | 嵌套作用域跳过类内绑定 |
| 途经任一 Lazy 作用域 | 整体 Lazy(粘性) | 一旦遇 lazy,查找即惰性化 |
| 延迟注解 / stub 注解 | Lazy | 引用合并多种可能类型 |
| TypeParams 作用域 | Eager,bounds 延迟 | 基类/参数注解 eager,bound 可引用后定义类型 |
这套语义是 ty 类型检查器准确推断 Python 程序类型的基础之一,其权威行为定义即 eager.md,源码级佐证可继续查阅 scope.rs、place_load.rs 与 mdtest.py。理解“惰性粘性”与“eager 快照”这两个核心概念后,你就能精确预判任意嵌套作用域下reveal_type的输出。
【免费下载链接】ruffAn extremely fast Python linter and code formatter, written in Rust.项目地址: https://gitcode.com/GitHub_Trending/ru/ruff
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考