Ruff 类型检查器中的未声明符号公共类型:Promotion 与 Widening 机制深度解析

发布时间:2026/9/10 15:01:59
Ruff 类型检查器中的未声明符号公共类型:Promotion 与 Widening 机制深度解析 Ruff 类型检查器中的未声明符号公共类型Promotion 与 Widening 机制深度解析【免费下载链接】ruffAn extremely fast Python linter and code formatter, written in Rust.项目地址: https://gitcode.com/GitHub_Trending/ru/ruff导读在 Ruff 内置类型检查器ty中未标注类型注解的类属性与实例属性如何被赋予公共类型public type直接决定了类型推断的精度、告警的多少以及类型系统的可用性。本文以 public_type_undeclared_symbols.md 为骨架系统讲解ty的 Promotion字面量提升与 Widening单例加宽机制何时把Literal[0]提升为int、何时把类字面量加宽为type[Response]、何时把None加宽为None | Unknown以及如何通过显式注解声明更宽或更窄的公共类型。读完后你将掌握 Ruff 类型检查器对未声明符号的完整类型策略并能在实际项目中用最小注解成本获得既精准又少误报的类型检查体验。背景严格渐进保证的代价Ruff 类型检查器遵循 Python 类型规范的渐进保证gradual guarantee 精神——如果严格应用该保证所有对未注解属性的赋值都应该被允许实现方式是把这类属性的推断类型与Unknown做并集。这样虽然绝对安全但在实践中会要求用户提供过多的类型注解才能达到可靠的类型检查效果。以Counter为例class Counter: def __init__(self) - None: self.value 0 reveal_type(Counter().value) # revealed: int如果严格采用与Unknown求并集的策略self.value会被推断为Unknown那么后续对它的任何使用都失去检查价值。但在绝大多数代码中作者显然不会期望value属性永远只保存字面量0——更合理的预期是它保存任意int。因此ty采用了一个启发式策略在绝大多数场景下为未注解属性猜出正确的公共类型从而在类型安全与注解成本之间取得平衡。这套启发式的核心就是下面要讲的 Promotion 与 Widening。Promotion将推断类型提升为预期的公共类型字面量类型提升到名义超类型对于未注解的属性ty会将其推断类型**提升promote**为对公共类型的最佳猜测。最典型的一类提升是字面量类型被提升为其名义超类型。class Counter: def __init__(self) - None: self.value 0 reveal_type(Counter().value) # revealed: intself.value 0推断出的类型本来是Literal[0]但通过实例访问Counter().value时被提升为int。这正是因为作者几乎不可能想让value永远只等于0。从源码看这个提升动作由 types.rs 中的Type::promote方法触发它通过apply_type_mapping应用TypeMapping::Promote(PromotionMode::On, PromotionKind::Regular)完成整棵类型树的递归映射。底层的 promote_impl 逻辑很直观Type::LiteralValue(literal)且该字面量可提升is_promotable()时调用literal.fallback_instance(db, env)回退到它的实例类型如Literal[0]→int、Literal[a]→strType::FunctionLiteral(literal)被提升为Type::Callable其余类型保持不变。类字面量实例访问加宽类对象直访保持精确存储在未注解属性中的类字面量在通过实例访问时会被加宽widened。原因是子类可以覆盖未声明的类属性所以通过self访问该属性的方法不能假设它仍保存着原始的类对象而直接通过具体类对象访问则保持精确。文档用Response系列类完整演示了这条规则from typing import Final, NewType, TypeVar, final from typing_extensions import assert_never class Response: ... class HtmlResponse(Response): ... class TestResponse: response_class Response response_classes (Response,) def check(self) - None: reveal_type(self.response_class) # revealed: type[Response] reveal_type(self.response_classes) # revealed: tuple[type[Response]] reveal_type(self.response_class Response) # revealed: bool if self.response_class Response: true_branch: int not an int # error: [invalid-assignment] else: false_branch: int not an int # error: [invalid-assignment] class TestHtmlResponse(TestResponse): response_class HtmlResponse reveal_type(TestResponse.response_class) # revealed: class Response reveal_type(TestResponse.response_classes) # revealed: tuple[class Response] def check_type(response: type[TestResponse]) - None: reveal_type(response.response_class) # revealed: type[Response] T TypeVar(T, boundTestResponse) def check_typevar(response: T) - None: reveal_type(response.response_class) # revealed: type[Response] TClass TypeVar(TClass, boundtype[TestResponse]) def check_typevar_class(response: TClass) - None: reveal_type(response.response_class) # revealed: type[Response] final class FinalTestResponse: response_class Response classmethod def check(cls) - None: if cls.response_class is not Response: assert_never(cls.response_class) TFinal TypeVar(TFinal, boundFinalTestResponse) def check_final_typevar(response: type[TFinal]) - None: reveal_type(response.response_class) # revealed: class Response NewTestResponse NewType(NewTestResponse, TestResponse) def check_newtype(response: NewTestResponse) - None: reveal_type(response.response_class) # revealed: type[Response] class ResponseMeta(type): response_class Response NewResponseMeta NewType(NewResponseMeta, ResponseMeta) def check_newtype_class(response: NewResponseMeta) - None: reveal_type(response.response_class) # revealed: type[Response] class AnnotatedResponse: response_class: type[Response] Response def check(self) - None: reveal_type(self.response_class) # revealed: type[Response] reveal_type(self.response_class Response) # revealed: bool class FixedResponse: response_class: Final Response def check(self) - None: reveal_type(self.response_class) # revealed: class Response reveal_type(self.response_class Response) # revealed: Literal[True]这段代码展示了多组对照值得逐条消化实例访问被加宽self.response_class在check()中揭示为type[Response]而非class Response因为TestHtmlResponse可能把response_class覆盖为HtmlResponse方法内不能假设仍是Response本身。self.response_classes同理加宽为tuple[type[Response]]。加宽后仍支持精确分支self.response_class Response揭示为bool而非恒真/恒假的字面量因此if/else两个分支都真实可达——文档特意在真、假两支都放置了int not an int并双双报出[invalid-assignment]验证加宽没有破坏分支的检查。类对象直访保持精确TestResponse.response_class揭示为class Responseresponse_classes揭示为tuple[class Response]因为直接访问类对象时不存在子类覆盖的疑虑。TypeVar 与 NewType 走同样的加宽路径无论是type[TestResponse]、Tbound 到TestResponse还是type[TClass]、NewTestResponse、NewResponseMeta经其访问的response_class均揭示为type[Response]。final类不受加宽影响FinalTestResponse被final标记后不能有子类cls.response_class可以保持精确的class Response因此assert_never(cls.response_class)在is not Response分支中能正常发挥作用。显式注解与Final是精确性的另一条路径AnnotatedResponse.response_class: type[Response]是声明类型与推断无关访问结果同样是type[Response]FixedResponse.response_class: Final Response则完全固定为class Response Response甚至被窄化为Literal[True]。同类规则也适用于未声明的实例属性——在方法中赋值的属性同样会经历类字面量加宽class InstanceResponse: ... class Wrapper: def __init__(self) - None: self.response_class InstanceResponse reveal_type(Wrapper().response_class) # revealed: type[InstanceResponse]源码依据TypeOrigin 与 ClassLiteralsOnly 提升加宽的触发条件在 types.rs 的promote_inferred_attribute_class_literals中定义得很明确成员查找结果满足以下两个条件时会对结果类型应用promote_class_literals即 types.rs 中的TypeMapping::Promote(PromotionMode::On, PromotionKind::ClassLiteralsOnly)成员的Place::Defined来源是TypeOrigin::Inferred——即该属性是推断出来的未显式注解成员限定符中不含TypeQualifiers::FINAL。PromotionKind枚举在 types.rs 中定义了三种模式Regular默认行为递归进入嵌套类型ClassLiteralsOnly只提升类字面量、不提升其他字面量类型SingletonsOnly只提升单例类型递归进入名义实例但不进入联合与非名义类型。正是类字面量与普通字面量的分离设计保证了int、str这类提升与type[...]加宽互不干扰也让模块级、局部集合中的类对象能保留足够的精度见下文模块级变量一节。提升在联合类型上的分布当未注解属性的推断类型本身就是类字面量的联合时加宽会分布到联合的每个元素上class UnionA: ... class UnionB: ... def get_flag() - bool: return bool() class EitherClass: value UnionA if get_flag() else UnionB reveal_type(EitherClass().value) # revealed: type[UnionA | UnionB]value的推断类型是UnionA | UnionB两个类字面量的联合经实例访问后每个元素都被加宽最终揭示为type[UnionA | UnionB]。从实现上看apply_type_mapping_impl对Type::Union会逐元素应用类型映射见 types.rs因此加宽天然对联合类型分布生效。模块级变量保持窄类型与类属性不同模块级变量保持其窄的推断类型。文档强调不变集合中的类字面量保持足够精度以支撑穷尽性相等检查class OffsetA: ... class OffsetB: ... classes {a: OffsetA, b: OffsetB} def choose(name: str) - None: class_value classes[name] if class_value OffsetA: expected 1 elif class_value OffsetB: expected 2 reveal_type(expected) # revealed: Literal[1, 2]这里classes的推断类型是dict[str, type[OffsetA] | type[OffsetB]]更精确地说是对应类字面量的联合。class_value OffsetA与class_value OffsetB两个相等检查能够收窄分支expected在两个分支中被分别赋予1与2最终reveal_type(expected)揭示为Literal[1, 2]。如果模块级变量也盲目加宽这类穷尽性判断就会失效——这也是源码注释中明确提到在集合推断中应用类字面量提升会丢失局部与模块级类对象集合的精度的原因见 types.rs。非字面量单例类型的加宽None 与 Unknown 的并集与字面量类型不同有些推断类型本身没有明显的候选超类型。典型代表就是单例类型None——一个被初始化为None的未注解属性几乎不可能永远只保存None但None之上并没有像Literal[0] → int那样自然的提升目标。ty的处理是与Unknown求并集完成加宽。class Wrapper: value None wrapper Wrapper() reveal_type(wrapper.value) # revealed: None | Unknown wrapper.value 1由于公共类型是None | Unknown后续对None不兼容的使用也会被检查器捕获def accepts_int(i: int) - None: pass def f(w: Wrapper) - None: # This is fine v: int | None w.value # This function call is incorrect, because w.value could be None. We therefore emit the following # error: Argument to function accepts_int is incorrect: Expected int, found None | Unknown c accepts_int(w.value)w.value可以赋给int | None安全但不能直接传给要求int的函数可能为None因此accepts_int(w.value)会报告形如Argument to function accepts_int is incorrect: Expected int, found None | Unknown的错误。同样的加宽也适用于只在__init__内部赋值的未声明实例属性class InstanceWrapper: def __init__(self) - None: self.value None reveal_type(InstanceWrapper().value) # revealed: None | Unknown从源码看单例加宽由 promote_singletons_impl 实现当Type::NominalInstance的实例是单例is_singleton(db)即None、EllipsisType这类时构造self | Unknown的联合类型其递归版本promote_singletons_recursivelytypes.rs配合PromotionKind::SingletonsOnly会在名义类型参数内递归加宽但不进入联合与非名义类型——这样[None]可被推断为list[None | Unknown]而不是死板的list[None]。注意两种加宽的语义差异字面量类型Literal[0]、类字面量走的是提升到更宽但仍明确的类型int、type[...]而单例类型None因为没有合适的提升目标走的是与Unknown求并集。前者保持类型信息可用后者用Unknown显式标记未知但可变。声明更宽的类型主动扩大公共类型任何用户都可以通过添加注解来声明更宽的公共类型。对于上面的Wrapper可以写成class Wrapper: value: int | None None w Wrapper() # The following public type is now # revealed: int | None reveal_type(w.value) # Incompatible assignments are now caught: # error: Object of type Literal[\a\] is not assignable to attribute value of type int | None w.value a加注解后效果立竿见影w.value的公共类型由None | Unknown变为明确的int | None不兼容的赋值w.value a会被拦截报告形如Object of type Literal[a] is not assignable to attribute value of type int | None的错误。这正是最小注解换取最大安全的落点当自动加宽不够准确时一个注解即可把公共类型完全置于你的掌控之下。声明更窄的类型主动避免提升反过来你也可以声明更窄的类型来避免提升。如果某个属性注定只保存几个字面量值之一就可以用注解锁死字面量精度from typing import Literal class Constant: value: Literal[0, 1] 0 # We would have promoted this to int without the explicit annotation: reveal_type(Constant().value) # revealed: Literal[0, 1]同样的手段也适用于避免单例加宽——如果出于某种原因你确实想让一个属性永远只保存Noneclass NoneWrapper: value: None None reveal_type(NoneWrapper().value) # revealed: NoneNoneWrapper().value揭示为精确的None而非None | Unknown。从实现角度理解显式注解的属性其Place::Defined来源是TypeOrigin::Declared而非TypeOrigin::Inferred见 types.rs 中Member的origin字段与 types.rs 处的TypeOrigin::Declared使用因此根本不满足promote_inferred_attribute_class_literals的推断来源前置条件提升/加宽自然被跳过。公共类型的含义同作用域与外部作用域的不同视角文档最后澄清了一个关键语义对未注解属性应用何种类型取决于访问者所处的作用域。外部作用域看到的是本文一直在讨论的公共类型——即经过 Promotion / Widening 之后的加宽类型定义该符号的同一作用域内则可以使用提升之前的、更窄的字面量类型。class Wrapper: value 10 # Type as seen from the same scope: reveal_type(value) # revealed: Literal[10] # Type as seen from another scope: reveal_type(Wrapper.value) # revealed: int同一个value 10在Wrapper类体内部定义作用域reveal_type(value)揭示为Literal[10]而类体外部Wrapper.value揭示为int。也就是说提升并不破坏定义处自身的类型精度只是对外发布时采用了更稳健的公共类型。这解释了为什么作者在定义处可以放心使用字面量的精确语义而外部消费者得到的是语义上更接近真实意图的宽类型。文档即测试这些示例本身就是回归测试值得注意的是本文引用的所有reveal_type揭示结果并不是手写的预期注释而是可执行的测试断言。仓库将 mdtest 文档目录 设计为既是面向用户的文档又是扩展测试套件该目录下的 Markdown 会作为测试输入运行确保文档中的类型揭示结果与类型检查器实际行为时刻保持一致。这意味着本文展示的每个revealed:输出都经过了真实执行验证可以直接作为你使用 Ruff 类型检查器时的行为参考。如果你想在本地亲自验证这些行为可以通过仓库中的 mdtest.py 测试入口运行文档测试类型检查相关的核心实现集中在 types.rs 与 infer/builder.rs例如 builder.rs 中NestedBindingExecution::Eager时对绑定类型调用promote的路径方便进一步追溯每一条规则的真实行为。核心规则速览场景推断类型对外公共类型机制未注解属性赋字面量0Literal[0]intPromotion提升到名义超类型未注解类属性存类Response经实例访问类字面量type[Response]ClassLiteralsOnly 加宽未注解类属性存类Response直接类访问类字面量class Response不加宽未注解属性赋NoneNoneNone \| Unknown单例类型与Unknown求并集模块级变量存类字面量集合精确联合保持精确不提升保精度显式注解value: int \| None—int \| None声明类型跳过提升显式注解value: Literal[0, 1]—Literal[0, 1]声明类型避免提升定义作用域内访问value 10—Literal[10]同作用域用窄类型实战要点总结不加注解时ty会尽量把未声明符号猜成更合理的公共类型——字面量向上提升、类字面量在实例访问时加宽、None与Unknown求并集、模块级变量保持精度当自动策略与你的意图不符时只需一个显式注解即可声明更宽如int | None或更窄如Literal[0, 1]、None的公共类型让类型检查在最小注解成本下达到最贴近真实语义的精度。【免费下载链接】ruffAn extremely fast Python linter and code formatter, written in Rust.项目地址: https://gitcode.com/GitHub_Trending/ru/ruff创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考