ty 类型检查器中的 del 语句语义:从名称删除到属性与下标删除的完整类型建模
【免费下载链接】ruffAn extremely fast Python linter and code formatter, written in Rust.项目地址: https://gitcode.com/GitHub_Trending/ru/ruff
导读
本文围绕 ruff 仓库中 ty 类型检查器的del语句语义测试文档(del.md)展开,系统讲解类型检查器如何处理 Python 的del语句:包括名称删除后的未解析引用检测、del对局部作用域解析的强制影响、属性删除(property deleter、__delattr__、描述符__delete__、Final 属性、元类)以及下标删除(__delitem__与 TypedDict 键删除规则)。读完本文,你将掌握 ty 在del上的完整类型规则、每条规则对应的诊断码(如unresolved-reference、invalid-assignment、not-subscriptable、invalid-argument-type),以及这些规则在仓库源码中的落地位置,可直接用于理解或扩展类型检查行为。
del语句与 mdtest:这份文档是什么
crates/ty_python_semantic/resources/mdtest/del.md是 ty 语义分析模块的 mdtest 测试文档。它把"可执行代码 + 期望诊断"写在同一个 Markdown 文件中:代码块中的# error: [诊断码]注释声明该行应当触发指定诊断,reveal_type(x) # revealed: T声明表达式x的推断类型为T,而# snapshot: <name>配合独立snapshot代码块用于校验多行诊断快照的精确文本。
文档覆盖了三条主线,对应本文的三个核心章节:
- 名称删除(
del a、del x, y):作用域绑定关系、可达性与未解析引用; - 属性删除(
del obj.attr):property、描述符、__delattr__、__delete__、Final、元类等一整套协议; - 下标删除(
del obj[key]):__delitem__独立性、TypedDict 键的可删除性判定。
名称删除:绑定生命周期与未解析引用
基本删除与多目标删除
del从当前作用域移除一个名称的绑定。删除后再引用该名称,ty 报告unresolved-reference:
a = 1 del a # error: [unresolved-reference] reveal_type(a) # revealed: Unknown对同一个变量执行两次删除(第二次删除一个已经不存在的绑定)同样报错:
# error: [invalid-syntax] "Invalid delete target" del 1 # error: [unresolved-reference] del a注意第一行的del 1:删除目标必须是名称、属性或下标等可删除目标,直接删除字面量属于语法错误,ty 报invalid-syntax且诊断消息为 "Invalid delete target"。
del支持一次删除多个目标,每个目标独立处理绑定:
x, y = 1, 2 del x, y # error: [unresolved-reference] reveal_type(x) # revealed: Unknown # error: [unresolved-reference] reveal_type(y) # revealed: Unknown条件删除与 possibly-unresolved-reference
当del位于条件分支内时,删除是否真正生效取决于分支是否执行,此时绑定状态是"可能未定义",ty 使用possibly-unresolved-reference表达这种不确定性:
def cond() -> bool: return True b = 1 if cond(): del b # error: [possibly-unresolved-reference] reveal_type(b) # revealed: Literal[1]b的静态类型仍保留为Literal[1](赋值时推断出的字面量类型),但它的绑定状态是可能已被删除,因此访问时报possibly-unresolved-reference而非unresolved-reference——两者的差别正是流分析对"必然未定义"与"可能未定义"的区分。
对称地,当某个分支不删除变量、另一个分支删除它时,删除路径仍是可能的:
c = 1 if cond(): c = 2 else: del c # error: [possibly-unresolved-reference] reveal_type(c) # revealed: Literal[2]函数内的 del 与外部变量:按名称而非按值删除
del删除的是绑定关系,而非改变对象本身。模块级变量d在函数内部被删除时,ty 将其视为对该名称的引用:
d = [1, 2, 3] def delete(): del d # error: [unresolved-reference] "Name `d` used when not defined" delete() reveal_type(d) # revealed: list[int]函数delete内部没有global d声明,因此del d使d成为delete的局部名称;而函数体内引用局部名称时该名称在函数作用域中"未定义",所以报unresolved-reference。模块作用域中的d不受影响,仍为list[int]。
当删除目标不是名称时,del不会强制局部解析。删除d[0]只是对d的元素做删除操作,d本身按外层作用域解析:
def delete_element(): # When the `del` target isn't a name, it doesn't force local resolution. del d[0] print(d)global / nonlocal 声明下的重复删除
一旦使用global或nonlocal声明,del操作的是外部作用域的绑定。ty 对这些场景采取保守策略:虽然在此平凡示例中可以推断出变量已未绑定,但因为变量可能在两次del之间被重新初始化,ty 选择不跟踪全局/非局部绑定的"未绑定"状态,以避免误报:
def delete_global(): global d del d # We could lint that `d` is unbound in this trivial case, but because it's global we'd need to # be careful about false positives if `d` got reinitialized somehow in between the two `del`s. del d delete_global() # Again, the variable should have been removed, but we don't check it. reveal_type(d) # revealed: list[int]nonlocal场景同理:
def delete_nonlocal(): e = 2 def delete_nonlocal_bad(): del e # error: [unresolved-reference] "Name `e` used when not defined" def delete_nonlocal_ok(): nonlocal e del e # As with `global` above, we don't track that the nonlocal `e` is unbound. del e注意delete_nonlocal_bad内没有nonlocal e声明,del e使e成为该函数局部名且未定义,故报错;delete_nonlocal_ok声明了nonlocal,两次del e都合法。
del 与局部作用域解析:强制 vs 例外
即使不可达,del 也强制局部解析
这是del语义中比较微妙的一点:只要函数foo中存在del x(哪怕位于if False:这类不可达分支中),x就成为foo的局部名称,而局部名称又被判定为未定义,于是连内层函数bar中对x的引用也解析到foo的绑定并报错:
x = 1 def foo(): print(x) # error: [unresolved-reference] "Name `x` used when not defined" if False: # Assigning to `x` would have the same effect here. del x def bar(): print(x) # error: [unresolved-reference] "Name `x` used when not defined"这与"对x赋值具有相同效果"——Python 的作用域规则中,任何对名称的绑定操作(赋值、del、import等)都会使其成为所在函数的局部变量,del与赋值在此处行为一致。文档注释也明确说明了这一点。
global / nonlocal 变量不受 del 强制局部解析
但global x声明会改变上述行为:foo中的del x不再使x局部化,bar中的print(x)解析到全局作用域,模块级的x类型保持不变:
x = 1 def foo(): global x def bar(): # allowed, refers to `x` in the global scope reveal_type(x) # revealed: Literal[1] bar() del x # allowed, deletes `x` in the global scope (though we don't track that)nonlocal x具有类似效果。给nonlocal一个可引用的enclosing作用域后,del x合法,且内层引用解析到enclosing中的绑定:
def enclosing(): x = 2 def foo(): nonlocal x def bar(): # allowed, refers to `x` in `enclosing` reveal_type(x) # revealed: Literal[2] bar() del x # allowed, deletes `x` in `enclosing` (though we don't track that)删除属性:协议、窄化失效与限制
属性删除比名称删除复杂得多。ty 从源码结构看,将属性删除校验集中在 builder.rs 的validate_attribute_deletion中,其处理顺序为:拆解联合类型 → 查找并调用__delattr__→ 校验 Final 属性 → 检查属性值类型上的描述符__delete__→ 检查 property deleter。下面按文档示例逐一说明。
删除属性使窄化失效
属性被删除后,如果之后再被引用,运行时会报错;但 ty不把"删除后引用"当作静态错误——因为删除语句与方法调用之间可能存在重新定义。不过删除属性会使赋值窄化(narrowing)失效,属性类型回退到其原始声明类型:
class C: x: int = 1 c = C() del c.x reveal_type(c.x) # revealed: int删除不存在的属性会报错(unresolved-attribute):
# error: [unresolved-attribute] del c.non_existent窄化失效可以反复发生:赋值使类型收窄为Literal[1],删除后再次回退为声明的int:
c.x = 1 reveal_type(c.x) # revealed: Literal[1] del c.x reveal_type(c.x) # revealed: int删除实例属性定义(类级删除)
在类上执行del C.x删除的是类属性定义。ty 对由此导致的实例属性不可解析暂不检查(因为后续C()构造或运行时行为可能不同),实例访问的类型仍按原声明推断:
class C: x: int = 1 c = C() reveal_type(c.x) # revealed: int del C.x c = C() # This attribute is unresolved, but we won't check it for now. reveal_type(c.x) # revealed: intProperty deleters:read-only 属性不可删除
只定义了 getter 而没有 deleter 的@property是只读属性,删除报invalid-assignment。定义了@x.deleter的属性可以删除;若 deleter 返回NoReturn(声明其必然抛异常,如raise AttributeError),删除同样报错:
from typing import NoReturn class ReadOnlyProperty: @property def x(self) -> int: return 1 class SupportsDelete: @property def x(self) -> int: return 1 @x.deleter def x(self) -> None: pass class RejectsDescriptorDelete: @property def x(self) -> int: return 1 @x.deleter def x(self) -> NoReturn: raise AttributeError("x") class ExplicitNoneDeleter: def get(self) -> int: return 1 keyword = property(get, fdel=None) positional = property(get, None, None) read_only = ReadOnlyProperty() # error: [invalid-assignment] "Cannot delete read-only property `x` on object of type `ReadOnlyProperty`" del read_only.x supports_delete = SupportsDelete() del supports_delete.x rejects_descriptor_delete = RejectsDescriptorDelete() # error: [invalid-assignment] "Cannot delete attribute `x` on type `RejectsDescriptorDelete` whose `__delete__` method returns `Never`/`NoReturn`" del rejects_descriptor_delete.x explicit_none_deleter = ExplicitNoneDeleter() # error: [invalid-assignment] "Cannot delete read-only property `keyword` on object of type `ExplicitNoneDeleter`" del explicit_none_deleter.keyword # error: [invalid-assignment] "Cannot delete read-only property `positional` on object of type `ExplicitNoneDeleter`" del explicit_none_deleter.positional注意property(get, fdel=None)与property(get, None, None)这两种传参方式都被识别为"无 deleter",等价于只读属性。源码中对应的判定逻辑是 property_deleter_returns_never:调用 deleter 后检查返回类型是否为Never,并结合__delete__的调用结果共同决定是否报错。
实例__delattr__:自定义删除行为
ty 通过调用对象的__delattr__方法来建模属性删除。若__delattr__返回None(正常接受删除),删除合法;返回NoReturn(声明必然抛AttributeError),删除报invalid-assignment;若__delattr__与object.__delattr__签名不兼容(如参数类型从str变为int),定义处报invalid-method-override,删除处报invalid-assignment:
from typing import NamedTuple, NoReturn class SupportsCustomDelete: @property def x(self) -> int: return 1 def __delattr__(self, name: str) -> None: pass class RejectsDelete: @property def x(self) -> int: return 1 def __delattr__(self, name: str) -> NoReturn: raise AttributeError(name) class BadDelAttr: x: int = 1 # error: [invalid-method-override] "Invalid override of method `__delattr__`: Definition is incompatible with `object.__delattr__`" def __delattr__(self, name: int) -> None: pass class DeletableNamedTuple(NamedTuple): x: int def __delattr__(self, name: str) -> None: pass supports_custom_delete = SupportsCustomDelete() del supports_custom_delete.x rejects_delete = RejectsDelete() # error: [invalid-assignment] "Cannot delete attribute `x` on type `RejectsDelete` whose `__delattr__` method returns `Never`/`NoReturn`" del rejects_delete.x bad_delattr = BadDelAttr() # error: [invalid-assignment] "Cannot delete attribute `x` on type `BadDelAttr` with custom `__delattr__` method" del bad_delattr.x deletable_namedtuple = DeletableNamedTuple(1) del deletable_namedtuple.x从 validate_attribute_deletion 的源码可以确认:ty 用MRO_NO_OBJECT_FALLBACK策略查找__delattr__,构造CallArguments::positional([Type::string_literal(db, attribute)])调用它,当返回类型为Never时报告 "whose__delattr__method returnsNever/NoReturn"。NamedTuple只要显式定义了__delattr__,其字段删除即被允许。
描述符__delete__:签名与回退
描述符协议中__delete__负责删除行为。若描述符的__delete__签名异常(本例中带额外的extra参数),调用失败,ty 回退到实例属性删除路径,最终删除仍被允许:
class Weird: def __delete__(self, instance: object, extra: object) -> None: pass class FallbackInstanceAttribute: def __init__(self) -> None: self.x = Weird() fallback_instance_attribute = FallbackInstanceAttribute() del fallback_instance_attribute.x源码中这一回退逻辑位于 builder.rs:先通过assignment_attribute_members找到属性的类型成员,尝试调用其__delete__,MethodNotAvailable时回退放行,而CallError则通过report_bad_dunder_delete_call报告调用错误。
Final 属性:一律禁止删除
Final标记的属性不可删除。实例属性、带自定义__delattr__的实例属性、以及类属性三类场景都报invalid-assignment:
from typing import Final class FinalAttribute: def __init__(self) -> None: self.x: Final[int] = 1 class FinalAttributeWithDelAttr: def __init__(self) -> None: self.x: Final[int] = 1 def __delattr__(self, name: str) -> None: pass class FinalClassAttribute: x: Final[int] = 1 final_attribute = FinalAttribute() # error: [invalid-assignment] "Cannot delete final attribute `x` on type `FinalAttribute`" del final_attribute.x final_attribute_with_delattr = FinalAttributeWithDelAttr() # error: [invalid-assignment] "Cannot delete final attribute `x` on type `FinalAttributeWithDelAttr`" del final_attribute_with_delattr.x # error: [invalid-assignment] "Cannot delete final attribute `x` on type `<class 'FinalClassAttribute'>`" del FinalClassAttribute.x注意第三处:即使自定义__delattr__接受删除,Final 约束仍然优先,禁止删除。该校验独立实现于 final_attribute.rs 的validate_final_attribute_deletion,会遍历属性成员的所有限定符(qualifiers)检查 Final 标记。
元类__delattr__:类属性删除走元类
对类对象执行属性删除(del Cls.x)时,调用的是元类的__delattr__。元类__delattr__返回None则允许删除;返回NoReturn则报invalid-assignment:
from typing import NoReturn class SupportsClassDeleteMeta(type): def __delattr__(self, name: str) -> None: pass class SupportsClassDelete(metaclass=SupportsClassDeleteMeta): x: int = 1 class RejectsClassDeleteMeta(type): def __delattr__(self, name: str) -> NoReturn: raise AttributeError(name) class RejectsClassDelete(metaclass=RejectsClassDeleteMeta): x: int = 1 del SupportsClassDelete.x # error: [invalid-assignment] "Cannot delete attribute `x` on type `<class 'RejectsClassDelete'>` whose `__delattr__` method returns `Never`/`NoReturn`" del RejectsClassDelete.x注意错误消息中对象类型显示为<class 'RejectsClassDelete'>,表明删除发生在类字面量(ClassLiteral)上,这正是元类路径的判别特征。
删除元素:__delitem__与 TypedDict 规则
基本元素删除同样使窄化失效
下标删除del l[0]与属性删除一样,会使赋值窄化失效,但元素访问本身仍然合法(列表长度运行时未知,删除后访问可能是运行时错误,却不构成静态类型错误):
def f(l: list[int]): del l[0] # If the length of `l` was 1, this will be a runtime error, # but if it was greater than that, it will not be an error. reveal_type(l[0]) # revealed: int # error: [invalid-argument-type] del l["string"] l[0] = 1 reveal_type(l[0]) # revealed: Literal[1] del l[0] reveal_type(l[0]) # revealed: int同时,下标类型要经过参数类型校验:list[int]的下标必须是整数,del l["string"]报invalid-argument-type。
__delitem__与__getitem__相互独立
ty 从源码结构看,在下标删除的校验函数 can_delete_subscript 中只查找__delitem__,因此两个协议是独立建模的:
- 只有
__delitem__没有__getitem__:支持删除,但不支持读取(读取报not-subscriptable)。协议类与普通类都遵循此规则:
from typing import Protocol, TypeVar KT = TypeVar("KT", contravariant=True) class CanDelItem(Protocol[KT]): def __delitem__(self, k: KT, /) -> None: ... def f(x: CanDelItem[int], k: int): # This should be valid - the object has __delitem__ del x[k] class OnlyDelItem: def __delitem__(self, key: int) -> None: pass d = OnlyDelItem() del d[0] # OK # error: [not-subscriptable] "Cannot subscript object of type `OnlyDelItem` with no `__getitem__` method" d[0]- 只有
__getitem__没有__delitem__:支持读取,但删除报not-subscriptable,诊断消息明确指出 "Cannot delete subscript ... with no__delitem__method":
class OnlyGetItem: def __getitem__(self, key: int) -> str: return "value" g = OnlyGetItem() reveal_type(g[0]) # revealed: str # error: [not-subscriptable] "Cannot delete subscript on object of type `OnlyGetItem` with no `__delitem__` method" del g[0]TypedDict 键删除:required 不可删,NotRequired 可删
删除 TypedDict 的 required 键会使对象不再符合该 TypedDict 类型,因此是类型错误;而NotRequired键与total=FalseTypedDict 的所有键都可以删除。文档定义了三种 TypedDict:
from typing_extensions import TypedDict, NotRequired class Movie(TypedDict): name: str year: int class PartialMovie(TypedDict, total=False): name: str year: int class MixedMovie(TypedDict): name: str year: NotRequired[int] m: Movie = {"name": "Blade Runner", "year": 1982} p: PartialMovie = {"name": "Test"} mixed: MixedMovie = {"name": "Test"}删除 required 键报invalid-argument-type,并给出完整的诊断快照(错误位置、字段定义位置与修复建议):
# snapshot: invalid-argument-type del m["name"]error[invalid-argument-type]: Cannot delete required key "name" from TypedDict `Movie` --> src/mdtest_snippet.py:19:7 | 19 | del m["name"] | ^^^^^^ info: Field defined here --> src/mdtest_snippet.py:3:7 | 3 | class Movie(TypedDict): | ---------------- `Movie` defined here 4 | name: str | --------- | | | `name` declared as required here | Consider making it `NotRequired` info: Only keys marked as `NotRequired` (or in a TypedDict with `total=False`) can be deletedtotal=False的 TypedDict 中所有键均可删除:
del p["name"]NotRequired键始终可删除:
del mixed["year"]但混合 TypedDict 中的 required 键仍然不可删除:
# snapshot: invalid-argument-type del mixed["name"]error[invalid-argument-type]: Cannot delete required key "name" from TypedDict `MixedMovie` --> src/mdtest_snippet.py:23:11 | 23 | del mixed["name"] | ^^^^^^ info: Field defined here --> src/mdtest_snippet.py:11:7 | 11 | class MixedMovie(TypedDict): | --------------------- `MixedMovie` defined here 12 | name: str | --------- | | | `name` declared as required here | Consider making it `NotRequired` info: Only keys marked as `NotRequired` (or in a TypedDict with `total=False`) can be deleted不存在的键同样不可删除:
# snapshot: invalid-argument-type del mixed["non_existent"]error[invalid-argument-type]: Cannot delete unknown key "non_existent" from TypedDict `MixedMovie` --> src/mdtest_snippet.py:25:11 | 25 | del mixed["non_existent"] | ^^^^^^^^^^^^^^这些规则的实现位于 subscript.rs:ty 首先尝试删除任意字面量键(允许NotRequired键或total=False),随后调用__delitem__;当键为 required 或未知时,通过report_cannot_delete_typed_dict_key分别报告 "Cannot delete required key" 与 "Cannot delete unknown key"。
源码实现总览:del 的完整处理链路
将文档示例与仓库实现对照,可以梳理出 ty 处理del语句的完整链路:
- 语法分发:builder.rs 将
ast::Stmt::Delete路由到infer_delete_statement; - 目标遍历:infer_delete_statement 遍历
del的每个目标并逐一infer_expression,即统一交给表达式推断,名称、属性、下标三类目标由此分别进入各自的推断逻辑; - 名称目标:由绑定表(place table)与 use-def 分析共同决定
unresolved-reference/possibly-unresolved-reference,并受作用域规则(global/nonlocal、函数局部化)约束; - 属性目标:
validate_attribute_deletion依次处理__delattr__调用、Final 属性校验、描述符__delete__与 property deleter 返回类型,相关校验分散于 builder.rs 与 final_attribute.rs; - 下标目标:
can_delete_subscript校验__delitem__存在性与参数类型,并对 TypedDict 键执行 required/NotRequired/未知键的三态判定(subscript.rs); - 删除对窄化的影响:删除会收集
deleted_narrowing_constraints并使绑定状态转为DefinitionState::Deleted(place.rs),这正是"删除使赋值窄化失效、类型回退到声明类型"这一贯穿全文行为的基础。
小结
del语句在 ty 类型检查器中被建模为三类目标分别处理:名称删除受作用域与可达性约束(unresolved-reference/possibly-unresolved-reference);属性删除受__delattr__、描述符__delete__、property deleter、Final 标记与元类等协议约束(invalid-assignment/unresolved-attribute/invalid-method-override);下标删除受__delitem__独立性与 TypedDict 键规则约束(not-subscriptable/invalid-argument-type)。贯穿三者的共同原则是:删除会使赋值窄化失效,但不会把"删除后访问"当作必然错误——前者源于删除对类型窄化约束的撤销,后者源于 ty 对运行时可重新定义场景的保守处理。理解这些规则,即可准确预判 ty 对任意del代码的诊断结果,并能在阅读或扩展 del.md 这类 mdtest 测试时快速定位对应实现。
【免费下载链接】ruffAn extremely fast Python linter and code formatter, written in Rust.项目地址: https://gitcode.com/GitHub_Trending/ru/ruff
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考