
Ruff 类型检查器中global引用的作用域语义从 mdtest 测试规范到源码实现【免费下载链接】ruffAn extremely fast Python linter and code formatter, written in Rust.项目地址: https://gitcode.com/GitHub_Trending/ru/ruff导读global是 Python 作用域规则中最微妙的关键字之一它把函数内部的绑定与模块级命名空间联通又与nonlocal、类作用域、嵌套函数产生大量边界行为。本文以 Ruff 仓库中 ty 类型检查器的 Markdown 测试规范 global.md 为骨架系统梳理global引用的全部语义——从隐式全局查找、类型窄化、语义语法错误到嵌套作用域可见性的激进/保守取舍——并结合 mdtest.py 运行器与 diagnostic.rs 中的诊断定义说明这些行为如何被测试与实现。读完本文你将完整掌握 ty 类型检查器对global的处理规则并能直接复现这些测试用例验证行为。测试规范的定位mdtest 中的scopes/global.md在 Ruff 仓库中ty 类型检查器的语义行为以 Markdown 测试文档的形式沉淀在crates/ty_python_semantic/resources/mdtest/目录下与 global.md 同级的还有 nonlocal.md、builtin.md、class_implicit_attrs.md 等共同构成作用域scopes测试套件。这些文档采用统一的执行约定每个 Markdown 小节##标题是一个独立测试用例文档中的 Python 代码块会被逐段送入类型检查器代码内的reveal_type(x)调用会揭示x的推断类型并写入# revealed: ...注释作为断言# error: [诊断码] 消息注释则断言某行必须产生指定诊断。运行器实现在 mdtest.py它会先通过cargo test --package ty_python_semantic --testmdtest编译测试二进制再以过滤参数执行对应文档。例如只运行本文档可以执行cargo run --package ty_python_semantic --bin mdtest -- scopes/global.mdmdtest.py还支持传入局部路径过滤如loops/for.md以及--enable-external、--no-lockfile-upgrades、--no-snapshot-updates等参数并默认监听resources/mdtest/目录下的.md变更实现热重跑。文档也可以携带 TOML 配置块参见 mdtest_config.md按标题层级继承配置——例如在根节配置[environment] python-version 3.10后所有子节默认继承子节又可覆盖。基本语义隐式全局与显式全局隐式全局未定义符号的函数内查找global.md的第一条规则最基础函数中对从未定义符号的名称引用隐式地就是一次全局查找。换句话说只要函数作用域内没有同名绑定读取就会穿透到模块作用域x 1 def f(): reveal_type(x) # revealed: Literal[1]这里x虽然在f内没有定义但类型检查器能正确解析到模块层的x并给出字面量类型Literal[1]。这正是 Python LEGB 作用域规则Local → Enclosing → Global → Builtins中 G 层查找的静态化体现。显式全局global语句声明如果函数内部出现了global x声明读取语义不变只是把隐式全局变成显式声明x 1 def f(): global x reveal_type(x) # revealed: Literal[1]两者的类型结论一致。区别在于global声明不仅影响读取还授权函数对模块层绑定进行写操作并且会触发一系列后续章节将讨论的语法约束与可见性规则。全局绑定上的类型检查赋值与声明错误无效赋值invalid-assignment一旦函数通过global声明了x对x的赋值就必须与模块层x的声明类型兼容。在下面的例子中模块层x: int函数内部对x赋就会触发invalid-assignment而局部变量y的同类赋值错误则与全局无关两者并列展示了局部与全局路径的检查是并行的x: int 1 def f(): y: int 1 # error: [invalid-assignment] Object of type Literal[] is not assignable to int y global x # error: [invalid-assignment] Object of type Literal[] is not assignable to int x 无效声明invalid-declaration与合成定义更微妙的场景是函数内先global z再赋z 此时模块层尚未声明z。ty 的处理是——函数作用域内为z建立合成定义synthetic definition该定义在函数结束后回灌到模块作用域由于z的实际推断类型是Literal[]随后模块层再写z: int就会触发invalid-declarationx: int 1 def f(): global x # error: [invalid-assignment] Object of type Literal[] is not assignable to int x global z # 允许f 中 z 的无效声明充当 z: Unknown与局部场景的 # x 42 # ok # x: str # error # 结果类似。将来也可以在这里报错关键是下面两行至少有一行应失败。 z # 此声明看到了 f 结束后回灌到本作用域的 z 的合成定义 # error: [invalid-declaration] Cannot declare type int for inferred type Literal[] z: int从源码结构看这种合成定义流经函数回到模块作用域的机制与 ty 的绑定/定义推断模型见crates/ty_python_semantic/src/types/infer/下的定义推断模块一致函数体的global写入被视为对定义作用域的一种潜在副作用需要在定义作用域的类型推断中参与合并。窄化Narrowingglobal声明不阻挡类型窄化global声明不会破坏控制流分析中的类型窄化。赋值之后的读取能看到窄化结果x: int | None def f(): global x x 1 reveal_type(x) # revealed: Literal[1]if分支同样适用即便声明了global x在if x 1分支内x已知非None可以放心赋值给int变量x: int | None def f(): # 这里的 global 关键字并非必需但用于测试它不会妨碍窄化 global x if x 1: y: int x # 允许因为此分支内 x 不可能是 None条件重绑定下的嵌套函数解析当外层函数条件性地重绑定x时内层函数依然要能穿过这层条件绑定解析到全局名。flag为真时x 2并提前返回为假时内层函数读取的x就可能是模块层的1或已被重绑定的2因此揭示为联合类型x 1 def outer(flag: bool) - None: global x if flag: x 2 return def inner() - None: reveal_type(x) # revealed: Literal[1, 2]语义语法错误global的合法性约束nonlocal与global互斥一个名字不能同时是nonlocal和global。这属于语义语法错误会触发invalid-syntax诊断。原文特别指出CPython 把错误标在nonlocal行而 mypy、pyright 和 RuffPLE0115标在global行ty 与后者保持一致x 1 def f(): x 1 def g() - None: nonlocal x global x # error: [invalid-syntax] name x is nonlocal and global x None使用先于global声明在同作用域内先使用名字、后写global声明是语法错误invalid-syntax消息为namexis used prior to global declaration。原文档用大量排列组合覆盖了这一规则的边界单次global声明在使用之后读取场景x 1 y 2 def f(): print(x) global x # error: [invalid-syntax] name x is used prior to global declaration print(x)重复声明时只要任意一次global位于使用之后即报错def f(): global x print(x) global x # error: [invalid-syntax] name x is used prior to global declaration print(x)多名字声明global x, y时只要有名字被先使用即报错对x报错y不受影响def f(): print(x) global x, y # error: [invalid-syntax] name x is used prior to global declaration print(x)先赋值再global同样报错赋值也构成使用前的绑定def f(): x 1 global x # error: [invalid-syntax] name x is used prior to global declaration x 1del操作同样构成使用规则一致def f(): del x global x, y # error: [invalid-syntax] name x is used prior to global declaration del xf-string 内插值读取也算使用def f(): print(f{x}) global x # error: [invalid-syntax] name x is used prior to global declaration该规则在模块作用域同样生效——即使x已在模块层定义先使用后global依然报错# 模块作用域依然是错误 x None global x # error: [invalid-syntax] name x is used prior to global declaration对global绑定做注解是语法错误global x之后不能再写x: str foo形式的带注解赋值x: int 1 def f(): global x x: str foo # error: [invalid-syntax] annotated name x cant be global这与nonlocal.md中 annotated namexcant be nonlocal 的约束对应。不过模块作用域中的global关键字本身是允许的虽然无用且不阻碍后续对同名变量的注解global y y: int 42合法的重复声明global之后的再次声明只要global声明先于任何使用后续的赋值、再声明都合法def f(): global x y x x 1 # 无错误 x 2局部绑定遮蔽与公共类型可见性局部绑定遮蔽全局公共类型函数内global x之后如果又产生了局部赋值则该作用域内x的类型由局部绑定决定遮蔽全局公共类型x 42 def f(): global x reveal_type(x) # revealed: Literal[42, 56] x 56 reveal_type(x) # revealed: Literal[56]注意第一次reveal_type的结果是Literal[42, 56]由于本作用域后续存在x 56这一局部绑定且函数可能被多次调用所以读到的类型是全局初始值 42 与局部绑定 56 的联合——这体现了下文的后置绑定可见规则。局部赋值阻止回退到外层作用域反过来如果函数内没有global声明但有局部赋值那么读取x不再回退到模块层而是解析到本作用域尚未初始化的局部绑定触发unresolved-referencex 42 def f(): # error: [unresolved-reference] Name x used when not defined reveal_type(x) # revealed: Unknown x 56 reveal_type(x) # revealed: Literal[56]这正是局部绑定 look ahead机制一旦作用域内存在同名绑定先于该绑定的读取就视为使用了未定义的名字而不是回退到外层。未使用的global声明也会影响推断即使global声明后没有任何赋值ty 也保守地假设它可能被使用从而影响该名字的推断类型x 1 def f(): global x # TODO: reveal_type(x) # revealed: Unknown | Literal[1]文档中这仍是一个 TODO 断言被注释掉说明该行为尚未完全定案但注释已经写出了预期的推断方向。unresolved-global诊断全局名需要显式定义这是global语义中最重要的错误诊断之一。其定义位于 diagnostic.rsUNRESOLVED_GLOBAL并在同文件的register_lints中注册消息模板与 lint 文档resources/lint_docs/unresolved-global.md关联。未在全局作用域定义的新名字可以用global声明一个全局作用域中完全没有定义的新名字但 ty 认为这是可疑行为倾向 lint 它x 1 y: int # z 在全局作用域中既无绑定也无声明 def f(): global x, y, z # error: [unresolved-global] Invalid global declaration of z: z has no declarations or bindings in the global scope可以看到x有绑定、y有声明都合法只有z既无绑定也无声明报错。隐式全局 vs 内置名隐式全局implicit global不需要定义但内置名built-in也不行def f(): global __file__ # 允许隐式全局 global int # error: [unresolved-global] Invalid global declaration of int: int has no declarations or bindings in the global scope__file__这类由运行时提供的隐式全局可以声明int这类 builtin 虽然随时可用但并不是全局作用域中的声明或绑定因此global int被视为无效声明。模块级未解析时的嵌套急切作用域即使global声明在模块级无法解析会报unresolved-global同一函数内嵌套的急切作用域如类体仍然能看到已经发生的重绑定def factory(): global x # error: [unresolved-global] Invalid global declaration of x: x has no declarations or bindings in the global scope x 1 class C: reveal_type(x) # revealed: Literal[1]类体在函数执行时被立即求值x 1已经生效所以类体内能读到Literal[1]与global x本身无法解析到模块级定义互不冲突。嵌套类与推导式急切作用域对global重绑定的感知显式模块级绑定在条件重绑定下依然可见模块层有value 0外层函数条件性重绑定value嵌套类应同时看到两种可能value 0 def conditional_global_factory(flag: bool): global value if flag: value updated class Nested: reveal_type(value) # revealed: Literal[updated, 0]若条件被静态判定为假Literal[False]嵌套类应只看到模块层原始绑定且不报告未解析引用from typing import Literal known_false_value 0 def known_false_global_factory(flag: Literal[False]): global known_false_value if flag: known_false_value updated class Nested: reveal_type(known_false_value) # revealed: Literal[0]声明与内置名的条件绑定模块层仅有声明无绑定时条件绑定后的嵌套类看到声明类型declared_value: int def conditional_declared_global_factory(flag: bool): global declared_value if flag: declared_value 1 class Nested: reveal_type(declared_value) # revealed: int条件重绑定下未绑定快照会继续穿透到隐式全局__file__的类型是strdef conditional_factory(flag: bool): global __file__ if flag: __file__ shadow class C: reveal_type(__file__) # revealed: str未绑定快照还可以穿过模块作用域直达 builtin——条件是global len本身非法len是 builtin 而非全局声明但仍揭示出len的类型def conditional_builtin_factory(flag: bool): global len # error: [unresolved-global] Invalid global declaration of len: len has no declarations or bindings in the global scope if flag: len 1 class C: reveal_type(len) # revealed: Literal[1] | (def len(obj: Sized, /) - int)推导式同样是急切作用域列表推导式也是急切求值的嵌套作用域能看到原始模块级绑定与条件性全局重绑定的并集value 0 def factory(flag: bool): global value if flag: value updated [reveal_type(value) for _ in [0]] # revealed: Literal[updated, 0]类作用域内的解析顺序与global回退类作用域有一套特殊的名称解析规则未定义之前访问的变量会回退到全局。原文档用一个综合用例系统地验证了各种情况import secrets x: str a def f(x: int, y: int): class C: reveal_type(x) # revealed: int class D: x None reveal_type(x) # revealed: None class E: reveal_type(x) # revealed: str x None # error: [unresolved-reference] reveal_type(y) # revealed: Unknown y None # 声明也算定义即使没有绑定 class F: reveal_type(x) # revealed: str x: int reveal_type(x) # revealed: str # 显式 nonlocal 变量不算数即使被绑定 class G: nonlocal x reveal_type(x) # revealed: int x 42 reveal_type(x) # revealed: Literal[42] # 可能未绑定的变量与回退查找取并集 class H: if secrets.randbelow(2): x None reveal_type(x) # revealed: None | str逐条解读C类体内无x绑定解析到函数参数x: intD类体内x None先于读取看到局部绑定NoneE读取在绑定之前回退到外层x: str而y既未绑定也未在外层定义报unresolved-reference并得到UnknownF仅注解声明x: int无绑定也算定义但不建立类型所以读取仍是外层strGnonlocal x显式引用了函数参数绑定x 42后读取为Literal[42]H条件性绑定导致可能未绑定与回退查找取并集得到None | str。类体看不到正在定义的类名类名在类体求值之后才绑定因此类体内不能把类名解析到自身class A: A A # error: [unresolved-reference] B 1 class B: reveal_type(B) # revealed: Literal[1] B B第一个class A中A A右侧的A无法解析报unresolved-reference第二个class B则因为模块层已有B 1类体内的reveal_type(B)解析到模块层的Literal[1]B B也合法。嵌套与兄弟作用域中global绑定的可见性这是global.md中篇幅最大、理论性最强的部分也是与 nonlocal.md 中同名测试互相对应的一节。基本现象内层写入影响定义作用域的推断内层作用域的global写入会影响全局定义作用域中该变量的推断类型同理嵌套函数中的读取也能看到自身作用域中更靠后的绑定。文档以三个命名文件逐步展开global1.py——先读后写x 1 def f(): global x # 如果 f 在此之前被调用过下面的赋值 2 就是可见的 reveal_type(x) # revealed: Literal[1, 2] x 2 # 一旦本作用域建立绑定就遮蔽外层作用域的绑定 reveal_type(x) # revealed: Literal[2] # 从现在起我们假设 f 可能在任意时刻被调用 reveal_type(x) # revealed: Literal[1, 2]这里揭示的类型依赖两个方向相反的假设一是保守假设——f可能在最后的 reveal 之前被调用尽管从源码看它从未被调用足够聪明的编译器本可窄化到Literal[1]但 ty 不能追踪函数何时被调用尤其在全局作用域二是激进假设——f内赋值后立刻得到Literal[2]这在一般情况下并不健全。global2.py——兄弟函数的反例x 1 def f(): global x x 2 reveal_type(x) # revealed: Literal[2] def g(): global x x 3 f() # 上面给出 Literal[2] 的逻辑在这里同样给出 Literal[3]尽管 f() 的调用 # 意味着运行时 x 其实是 2。我们只能对这些做局部推理无法做全程序控制流 # 分析也无法解决停机问题。完全健全的类型检查器通常需要在这里和上面 # 都推断 Literal[2, 3]。那当然更好——错误的答案很糟——但会破坏太多 # 期望在 f 这类简单场景中得到 Literal[2] 的真实世界代码。 reveal_type(x) # revealed: Literal[3] reveal_type(x) # revealed: Literal[1, 2, 3]文档明确承认这里存在有意为之的不健全假设unsound assumptions目的是让简单、常见的场景符合用户直觉。遮蔽与嵌套保留的双重策略global3.py揭示了更精细的规则激进地遮蔽外层/兄弟作用域的绑定但保守地保留当前作用域之后遇到的内层绑定按代码自上而下阅读的顺序x 1 # 我们刚定义 x尚未遇到任何它的嵌套绑定 reveal_type(x) # revealed: Literal[1] def bar(): global x # 尚未遇到 x 的任何局部绑定其公共类型对 bar 可见包括 bar 下面 # 自己对 3 的赋值 reveal_type(x) # revealed: Literal[1, 2, 3] x 2 # 局部绑定遮蔽整个公共类型 reveal_type(x) # revealed: Literal[2] # 我们遇到了 3 的嵌套赋值所以把它与局部绑定一起保留在本作用域中 reveal_type(x) # revealed: Literal[1, 2] x 3 # 该赋值遮蔽了之前的局部绑定但嵌套绑定依然可见 reveal_type(x) # revealed: Literal[2, 3]第二个行为同样不健全——嵌套函数可以逃逸其定义作用域并影响定义行之上读取的类型——但这是用户期望的行为。嵌套global被外层局部隐藏若内层的global x写入需要穿过outer作用域才能到达模块层那么当x在outer内解析到局部绑定时内层写入不应干扰这些作用域中的读取x 1 def outer(): x 2 def inner(): def writer(): global x x 3 reveal_type(x) # revealed: Literal[2] reveal_type(x) # revealed: Literal[2] reveal_type(x) # revealed: Literal[1, 3]writer的global x写入3需要流经outer回到模块层所以模块层读到Literal[1, 3]但outer及其内部作用域中的x解析到outer的局部绑定2不受影响。中间作用域中的可见性嵌套的global绑定在中间作用域非定义作用域、也未声明global/nonlocal仅作为自由变量使用也是可见的def _(): def _(): global x x 1 global x x 2 # 最内层函数的绑定在这里可见因为它嵌套于本作用域之下 # 尽管本作用域不是 x 的定义作用域也没有把 x 声明为 global #而是把它当作自由变量使用 reveal_type(x) # revealed: Literal[1, 2] x 3 reveal_type(x) # revealed: Literal[1, 2, 3]条件窄化可过滤嵌套绑定isinstance窄化可以过滤掉不兼容的嵌套绑定x 42 def hello(): global x x hello reveal_type(x) # revealed: Literal[42, hello] if isinstance(x, int): reveal_type(x) # revealed: Literal[42]窄化后x必为int嵌套绑定hello被排除。条件性global绑定保留父作用域绑定常规的分支合并规则同样作用于遮蔽行为条件分支内的赋值会在分支内遮蔽公共类型分支外恢复可见def flag(): ... x 1 def foo(): global x x 2 def bar(): global x if flag(): x 3 # x 的公共类型在此被遮蔽... reveal_type(x) # revealed: Literal[3] # ...但在这里依然可见 reveal_type(x) # revealed: Literal[3, 1, 2]参数默认值先于函数体求值正常执行时无需考虑这个顺序函数体在被调用前不产生副作用但在推断中必须考虑因为存在遇到嵌套绑定后视为可见的通常不健全的规则x 1 # 这里的 x 看不到下面的 x 2 def f(yreveal_type(x)): # revealed: Literal[1] global x x 2global与局部/nonlocal的穿插nonlocal声明不允许解析到对同一变量有global声明的作用域这是语义语法错误因此nonlocal绑定不能穿过一个同名global的作用域反之global绑定可以穿过中间那些将同名变量作为局部/nonlocal的作用域只要所有nonlocal声明都合法解析。原文档用一个五层嵌套的综合用例验证了free、global、nonlocal三种读取视角x 1 def _(): def _(): def global_middle(): def _(): def _(): def global_inner(): global x x 2 # free 情形我们看到父作用域中的局部 x reveal_type(x) # revealed: Literal[3] x 3 global x # global 情形我们看到全局 x reveal_type(x) # revealed: Literal[2, 1] nonlocal x # nonlocal 情形我们看到父作用域中的*另一个*局部 x reveal_type(x) # revealed: Literal[4, 5] x 4 x 5 # module 情形我们看到全局 x reveal_type(x) # revealed: Literal[1, 2]自由读取与窄化约束合成嵌套绑定定义同时存储global和nonlocal嵌套写入推断时再决定尊重哪些。对自由读取当前作用域只有使用没有绑定的作用域本来几乎可以忽略该问题——推断最终会走到定义作用域去解析自由读取但如果自由读取被窄化嵌套绑定可能未被窄化就变得重要了x: int | str 1 def f(): def g1(): global x x g1 if isinstance(x, int): def g2(): global x x g2 # 窄化条件覆盖了 g1 的嵌套写入但没有覆盖 g2 的。 # 要处理正确必须检测到嵌套 global 写入在本作用域中可见。 reveal_type(x) # revealed: Literal[g2] | intg1的写入在窄化条件之前条件成立时已被排除而g2定义在窄化分支内部其写入不受窄化约束因此揭示为Literal[g2] | int。增强赋值、复杂约束与已知限制global的拓宽与循环一致循环中使用会触发不动点分析当Literal值列表达到上限后拓宽为int。global的增强赋值同理——内层函数体可能运行任意多次x 1 def f(): global x x 1 reveal_type(x) # revealed: int复杂性上限回退在 nonlocal.md 中有明确记载global场景同理ty 对嵌套绑定定义设置了过度复杂上限超过后停止考虑嵌套绑定以保证性能。其表现是y的类型退化为Literal[0] | Unknown。TODO类作用域中的global写入应被急切应用类体是急切求值的所以类作用域中的global绑定应更像普通赋值。目前 ty 把类作用域当作函数作用域式的惰性嵌套绑定处理导致类型比应有的更宽x 1 class C: global x x 2 # TODO: 应为 Literal[2] reveal_type(x) # revealed: Literal[1, 2] x 3 # TODO: 应为 Literal[3] reveal_type(x) # revealed: Literal[2, 3]但类内函数作用域的嵌套绑定依然是惰性的函数内的类体从函数调用者的视角看同样呈现惰性。这三条行为类内直接写、类内函数写、函数内类体写在文档中分别给出了对照用例结论均为当前揭示类型比理想类型宽。结语从测试规范到类型检查器实现回到本文的起点global.md 不仅仅是一份文档它是 ty 类型检查器global语义的可执行规格。每一条# revealed:与# error:注释都是对类型推断与诊断系统的硬性断言运行器 mdtest.py 负责把 Markdown 编译成 Rust 测试并断言执行结果。当你修改类型检查器源码如crates/ty_python_semantic/src/types/下的定义推断、绑定解析或诊断注册后只要执行cargo test --package ty_python_semantic --testmdtest这些语义行为就会被自动回归验证。如果想进一步深入源码UNRESOLVED_GLOBAL等诊断在 diagnostic.rs 中定义unresolved-reference、invalid-assignment、invalid-declaration、invalid-syntax等诊断的触发点分散在crates/ty_python_semantic/src/types/infer/与crates/ty_python_semantic/src/place.rs等模块中而与global行为严格对称的nonlocal语义则可对照阅读 nonlocal.md。理解这份规范就等于理解了 ty 在局部/全局/非局部三角关系上的全部设计取舍——包括那些为了用户体验而有意为之的不健全假设。【免费下载链接】ruffAn extremely fast Python linter and code formatter, written in Rust.项目地址: https://gitcode.com/GitHub_Trending/ru/ruff创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考