Pyrefly 惰性类型检查探秘:从「继承属性访问」的 demand tree 看按需计算架构
【免费下载链接】pyreflyA fast type checker and language server for Python项目地址: https://gitcode.com/GitHub_Trending/py/pyrefly
导读
Pyrefly(A fast type checker and language server for Python)在类型检查上采用了典型的按需(demand-driven / lazy)计算架构:只有被真正需要的模块、符号和类型信息才会被解析与求解。本文以仓库中pyrefly/test_laziness/test_attribute_inherited.md这一条"惰性快照测试"为核心案例,逐行拆解当程序访问一个从父类继承而来的属性时,Pyrefly 究竟"按需"计算了哪些内容、哪些计算是必要的、哪些又是可以继续优化的多余开销。读完之后,你将掌握 Pyrefly 五阶段检查流水线(Load → Ast → Exports → Answers → Solutions)的运作机制,学会阅读 demand tree 快照,并理解属性查找、MRO 计算等核心路径的底层实现。
什么是 Pyrefly 的惰性(Laziness)机制
Pyrefly 不会"从头到尾"把整个项目每个模块都完整检查一遍。相反,它的每个模块都被拆解为一条渐进式流水线,只有当前端(调用方)真正提出需求时,后端(被依赖模块)才会推进到更深的阶段。
这条流水线定义在 pyrefly/lib/state/steps.rs 中,由Step枚举表示,共五个阶段:
| 阶段 | 说明 |
|---|---|
Load | 从磁盘或内存读取源文件内容 |
Ast | 将源码解析为 AST 与 token |
Exports | 从 AST 构建模块的导出符号表(Exports) |
Answers | 对模块内所有绑定(绑定表、函数签名)做求解,得到类型答案(Answers) |
Solutions | 最终求解——检查错误、解析表达式类型,得到完整检查结果(Solutions) |
从StepsMut的实现可以看到,每个步骤的产物都存放在锁无关的ArcSwapOption槽位中(steps.rs),并通过AtomicStep记录当前已完成的最大步骤;读者只要看到current_step >= X,就能保证第 X 步的数据已经就绪(steps.rs)。这意味着:
- 一个只被
import但从未使用的模块,可能永远停在Exports阶段; - 一个从未被触碰的依赖模块,甚至可以是
Nothing(一步都不算); - 只有真正被"demand"(需求)触及的键(Key),才会被求解并缓存进计算单元(Calculation cell)。
这套机制的目的非常明确:在增量场景(尤其是 LSP 编辑器内)里,把无关模块的计算量压到最低。而验证这套机制是否真的"按需"的唯一手段,就是记录每一次跨模块需求(demand),形成一棵可读的 demand tree——这正是test_laziness/目录下这批 Markdown 快照测试的职责。
测试用例:跨三个模块的继承属性访问
test_attribute_inherited.md构造了一个最小但非常典型的继承场景,涉及三个模块(源码见 pyrefly/test_laziness/test_attribute_inherited.md):
a.py(被检查的入口模块):
from b import Child c = Child() x = c.base_attrb.py(定义Child):
from c import Base class Child(Base): child_attr: str = 'hello'c.py(定义Base,被继承的属性真正所在处):
class Base: base_attr: int = 1访问c.base_attr时,base_attr定义在Base(模块c)中,被Child(模块b)继承。要给出x的类型,Pyrefly 必须沿着a → b → c的依赖链逐层解析。这个用例的精妙之处在于:一条属性访问语句,几乎能覆盖 Pyrefly 惰性检查中所有典型的"类相关"需求键(Key)。
逐步解读 demand tree:哪些需求是必要的
a.py检查完成后的需求树快照(见 test_attribute_inherited.md)如下,我们逐条分析:
a: Solutions b: Answers c: Answers首部三行是每个模块最终停靠的阶段:入口a走完了全部流水线(Solutions),而b、c都只推进到Answers——它们没有被完整"检查",只是提供了足够的类型信息。这本身就是惰性机制的体现。
需求树正文(注释为本文添加):
(66 builtin demands hidden) ← 内建模块(builtins/typing)的 66 条需求被聚合隐藏 # 解析导入:a 需要从 b 拿到 "Child" 符号 a -> b::KeyExport(Name("Child")) # 解析 import,得到 Child 类 # 计算 Child 的类元数据(含 4 次对 c 的元数据级联) a -> b::KeyClassMetadata(ClassDefIndex(0)) b -> c::KeyExport(Name("Base")) # 计算 MRO 需要先知道基类 Base b -> c::KeyClassMetadata(ClassDefIndex(0)) ×4 # 校验类头关键字、__init_subclass__ 等 # 抽象类检查(文档标注:对属性访问是多余的) a -> b::KeyAbstractClassCheck(ClassDefIndex(0)) b -> c::KeyAbstractClassCheck(ClassDefIndex(0)) # MRO 计算:为了遍历祖先,必须解析 Base a -> b::KeyClassMro(ClassDefIndex(0)) b -> c::KeyClassMetadata(ClassDefIndex(0)) ×2 b -> c::KeyClassBaseType(ClassDefIndex(0)) b -> c::KeyClassMro(ClassDefIndex(0)) # 合成字段检查:MRO 遍历需要检查每个祖先的合成字段 a -> b::KeyClassSynthesizedFields(ClassDefIndex(0)) a -> c::KeyClassSynthesizedFields(ClassDefIndex(0)) # 真正的属性解析(含对 int 的惰性内建查找) a -> b::KeyClassMro(ClassDefIndex(0)) # 再次计算/读取已缓存的 MRO a -> c::KeyClassField(ClassDefIndex(0), Name("base_attr")) # 关键一步!文档将其中的需求分为两类:
必要的需求(All necessary demands):
a -> b::KeyExport("Child")—— 解析 import,拿到Child类本身;a -> b::KeyClassMetadata(0)—— 计算Child的元数据以确定其基类(MRO 的前提);而计算元数据时会校验类头关键字是否与继承的__init_subclass__兼容,这需要遍历Child的基类,从而再次(重新)需求Base的已缓存的c::KeyClassMetadata——注意,b -> c::KeyClassMetadata出现 4 次,但因为有计算单元缓存,重复需求只是哈希查找 +Arc克隆,成本可忽略(这点在 OPPORTUNITIES.md 中有明确说明);b -> c::KeyExport("Base")与b -> c::KeyClassMetadata(0)—— 解析Child的 MRO 必须先知道Base;a -> b::KeyClassMro(0)—— 计算 MRO 以便遍历祖先;a -> c::KeyClassField(0, "base_attr")——最终一击,在Base上真正解析出base_attr的类型(其子需求中还包括对int的惰性内建查找);a -> b/c::KeyClassSynthesizedFields(0)—— MRO 遍历时每个祖先都要检查合成字段(如__slots__、dataclass 生成的字段等)。
多余的(Superfluous)需求:
a -> b::KeyAbstractClassCheck(0)—— 抽象类检查只在实例化时才相关,纯属性访问根本不需要它。
深入源码:属性查找为什么要遍历整个 MRO
需求树中KeyClassMro、KeyClassSynthesizedFields反复出现,根因在于属性解析的实现方式。
get_field_from_mro是属性查找的核心(pyrefly/lib/alt/class/class_field.rs):
fn get_field_from_mro<'s>( &'s self, cls: &Class, name: &Name, get_field: &impl Fn(&Class, &Name) -> Option<&'s ClassField>, ) -> Option<WithDefiningClass<Cow<'s, ClassField>>> { get_field(cls, name) // 先查类自身 .map(|field| WithDefiningClass { value: Cow::Borrowed(field), defining_class: cls.dupe() }) .or_else(|| { // 自身没有,才去祖先里找 self.get_field_from_ancestors( cls, self.get_mro_for_class(cls).ancestors(self.stdlib), name, get_field, ) }) }get_class_member_impl(class_field.rs)在get_field_from_mro之上把"当前类字段"(含合成字段)作为查询函数传入。而合成字段的获取本身又会需求KeyClassSynthesizedFields:
pub(crate) fn get_synthesized_field_from_current_class_only(...) -> Option<&ClassField> { Some(self.get_from_class(cls, &KeyClassSynthesizedFields(cls.index()))?.get(name)?...) }需求树中"a -> b::KeyClassMro(0)出现两次"也由此解释:第一次来自 MRO 本身的计算(get_mro_for_class),第二次来自get_field_from_mro中通过ancestors遍历祖先的需求。而遍历祖先的循环位于 pyrefly/lib/alt/attr.rs(for ancestor in mro.ancestors_no_object()),它会逐个访问 MRO 中的每一个祖先——即使属性在第一个类上就已命中,也不会提前退出。
这正是对比测试test_attribute_on_class_itself.md(pyrefly/test_laziness/test_attribute_on_class_itself.md)要揭示的问题:当base_attr换成定义在Child自身的child_attr时,需求树与本测试几乎完全相同,Base所在的模块c依旧被解析了 7 个键——而按理想情况,这些本应全部是多余的。
从单条快照到整体优化机会
test_attribute_inherited.md展示的并非孤例,而是 Pyrefly 惰性机制中"类层级相关需求"的一个缩影。仓库中的 pyrefly/test_laziness/OPPORTUNITIES.md 汇总了多份 demand tree 的统计结论,与本用例直接相关的有:
- MRO 无早退遍历是大头:类相关键(
KeyClassSynthesizedFields+KeyClassMetadata+KeyClassMro+KeyClassField)合计约占全部跨模块需求的 64%。其中KeyClassSynthesizedFields约 24%(占 Answer 需求 36%)、KeyClassMetadata约 23%、KeyClassMro约 9%、KeyClassField约 8%。深继承层级越深,开销越明显(OPPORTUNITIES.md); - 抽象类检查的定位:每次
Foo()实例化都会触发KeyAbstractClassCheck,它递归枚举所有父类的抽象方法。本测试证明它在属性访问路径上纯属多余(OPPORTUNITIES.md); - 重复需求不可怕:同一个键被多次需求是正常现象——计算单元会缓存结果,重复只是哈希查找 +
Arc克隆。只有针对真正不必要的键的需求才是优化目标(OPPORTUNITIES.md)。
文中提到的理想优化方向(先查类自身、命中即返回,仅在未命中时才物化 MRO 迭代器等)就是这些测试驱动下的待办清单。
如何运行与复现这套验证
惰性快照测试的运作方式
test_laziness/目录下的每个.md文件都是一条快照测试:文档中嵌入 Python 文件源码和## Check目标,以及```expected代码块中的期望输出(模块最终阶段 + demand tree)。测试运行器位于 pyrefly/test_laziness/mod.rs:
parse_test解析 Markdown,提取(模块名, 源码)列表与检查目标(mod.rs);run_test用MapDatabase将源码放入内存,强制单线程(ThreadCount::NumThreads(1))执行以保证 demand tree 顺序确定性,再挂上TestSubscriber与DemandCollector收集每一步信息(mod.rs);- 输出按规则渲染:检查目标模块标为
Solutions优先排序,其余模块按字母序;builtins/typing的跨模块需求被聚合为(N builtin demands hidden)一行,避免淹没关键信息(mod.rs); run_laziness_test将实际输出与期望快照比对,不一致时给出 unified diff,并可一键重录快照(mod.rs)。
若快照失配,运行器会打印类似如下的重录命令:
UPDATE_SNAPSHOTS=1 cargo test test_attribute_inherited -- --test-threads=1(在 buck 环境下对应buck test pyrefly:pyrefly_laziness_tests -- --env UPDATE_SNAPSHOTS=1 -r test_attribute_inherited,见 mod.rs。)设置UPDATE_SNAPSHOTS=1后,差异会就地写回源.md文件并让测试失败,以便开发者通过 diff 审查改动——这正是"快照即规格"的维护方式。
在真实项目上观察 demand tree
同样的需求收集机制也暴露给了 CLI:pyrefly check --report-demand-tree <out.json> <file>。该参数在 pyrefly/lib/commands/check.rs 中定义,检查完成后会从事务中取走全部需求根节点,连同每个模块的最终阶段(Step::label,缺失则为Nothing)一起序列化为 JSON 写出(check.rs)。OPPORTUNITIES.md中的百分比统计正是用它对真实代码库采样聚合而来——你完全可以对自家项目跑同样的命令,验证哪些模块被推进到了过深的阶段。
小结:一条属性访问背后的一整套按需机制
test_attribute_inherited.md用 20 余行快照回答了一个关键问题:Pyrefly 在"最普通的继承属性读取"上到底做了多少必要工作、多少多余工作?答案是:解析 import、类元数据、MRO、合成字段、最终字段解析都是必要的;而KeyAbstractClassCheck是明确的多余需求。同时,b、c两个依赖模块都只停在Answers阶段而不是被完整检查,这证明了流水线分级的有效性。
对 Pyrefly 的贡献者来说,这套测试是一份"可执行的需求规格":任何改动如果改变了跨模块计算的时机,快照测试会立刻报警。对使用者来说,它揭示了类型检查器性能优化的真实战场——MRO 遍历、类元数据、合成字段这些"看不见的键",恰恰是大型代码库检查耗时的最大来源。理解 demand tree,就是理解 Pyrefly 性能调优的第一步。
延伸阅读
- 惰性测试框架整体实现:pyrefly/test_laziness/mod.rs
- 五阶段流水线与 Step 定义:pyrefly/lib/state/steps.rs
- 属性查找与 MRO 遍历实现:pyrefly/lib/alt/class/class_field.rs、pyrefly/lib/alt/attr.rs
- 惰性优化机会汇总(含真实项目统计):pyrefly/test_laziness/OPPORTUNITIES.md
- 对照组(属性定义在类自身时的需求树):pyrefly/test_laziness/test_attribute_on_class_itself.md
- 命令行导出需求树:pyrefly/lib/commands/check.rs
【免费下载链接】pyreflyA fast type checker and language server for Python项目地址: https://gitcode.com/GitHub_Trending/py/pyrefly
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考