Ruff 类型检查器深度解析:typing.Unpack在 ty 中的语义与实现
【免费下载链接】ruffAn extremely fast Python linter and code formatter, written in Rust.项目地址: https://gitcode.com/GitHub_Trending/ru/ruff
导读
Unpack[Ts]是 Python 3.11 引入的 legacy 拼写形式,等价于 PEP 646 中的*Ts。本文以 Ruff 仓库中 ty 类型检查器的 mdtest 规格文档 unpack.md 为主体,系统讲解Unpack在泛型特化、可变参数推断、Callable 参数展开、类型别名、默认值等场景下的完整语义,并深入其底层实现源码,帮助你理解并掌握 variadic 泛型在类型检查器中的行为边界、错误恢复策略与诊断规则。
背景:Unpack[Ts]与*Ts的等价关系
在 PEP 646(TypeVarTuple,类型变量元组)之前,Python 的类型语法中不存在 "任意数量位置参数" 的抽象。TypeVarTuple正是为此而生:一个TypeVarTuple可以绑定零个或多个类型参数。而Unpack[Ts]是它的 legacy 拼写——在支持*Ts星号解包语法之前,Unpack[Ts]承担了同样的职责,二者语义完全等价。
在 ty 的 mdtest 测试目录结构中,这两种拼写分别由两份规格文档覆盖:
- typevartuple.md:legacy 记法下
TypeVarTuple的定义与特化(含*Ts); - unpack.md(本文主体):legacy 记法下
Unpack的 distinct 语法路径; - ../pep695/typevartuple.md:PEP 695 新语法下的共享语义。
如文档开篇所述,"The shared semantics of type variable tuples are covered in../pep695/typevartuple.md; this file checks the distinct syntax paths used byUnpack"——即类型变量元组的共享语义在 PEP 695 文档中覆盖,本文件专门检查Unpack使用的不同语法路径。
所有测试片段默认在python-version = "3.11"环境下运行:
[environment] python-version = "3.11"Unpack在运行时既可从typing导入,也可从typing_extensions导入。这一点在源码中得到了明确印证:在 special_form.rs 中,SpecialFormType::Unpack的注释写着 "The symboltyping.Unpack(which can also be found astyping_extensions.Unpack)"。
如何阅读与运行这份规格文档
该文档是 ty 类型检查器的mdtest 测试规格,而不是普通的说明文档。每个代码块中的reveal_type(...)调用代表类型检查器应在该处揭示出的精确类型,# error: [rule-name]注释则代表预期诊断。mdtest 测试框架通过 tests/mdtest.rs 注册:
datatest_stable::harness! { { test = mdtest, root = "./resources/mdtest", pattern = r"\.md$" }, { test = lint_doc, root = "./resources/lint_docs", pattern = r"\.md$" }, }即resources/mdtest目录下所有.md文件都会被作为测试夹具(fixture)执行。运行方式为标准的 cargo 测试:
cargo test -p ty_python_semantic --test mdtest每个测试片段会被提取为一个独立的 Python 文件(mdtest_snippet.py)进行类型检查,并生成快照(snapshot)到resources/mdtest/snapshots目录。这意味着本文档中的每一行断言都是"可执行"的事实,这也是 ty 项目保证类型检查器行为可回归、可验证的方式。详细的测试机制说明见 ty_test/README.md。
泛型特化(Generic specialization)
Unpack可以在 legacy 泛型声明中引入一个类型变量元组,也可以作为特化参数展开一个固定元组。
from typing import Generic, TypeVarTuple, Unpack Ts = TypeVarTuple("Ts") class Array(Generic[Unpack[Ts]]): value: tuple[Unpack[Ts]] reveal_type(Array[()]().value) # revealed: tuple[()] reveal_type(Array[int, str]().value) # revealed: tuple[int, str] reveal_type(Array[Unpack[tuple[int, str]]]().value) # revealed: tuple[int, str]三种特化方式得到一致的结果:
| 特化写法 | value的揭示类型 |
|---|---|
Array[()] | tuple[()](零个参数) |
Array[int, str] | tuple[int, str] |
Array[Unpack[tuple[int, str]]] | tuple[int, str] |
第三种写法展示了一个重要特性:Unpack既可以在泛型声明中引入TypeVarTuple,也可以在特化时展开一个具体的元组类型。Unpack[tuple[int, str]]与直接写int, str等价。
从源码实现看,这正是 type_expression.rs 中SpecialFormType::Unpack分支的处理逻辑:
// Preserve valid unpack targets so that `Unpack[...]` follows the same // argument-binding path as an equivalent starred annotation. if inner_ty.exact_tuple_instance_spec(self.db()).is_some() || matches!( inner_ty, Type::TypeVar(typevar) if typevar.is_typevartuple(self.db()) ) { inner_ty }只要Unpack的操作数解析为"确切的元组实例"或"TypeVarTuple",就原样保留该类型,让它走与等价的星号标注完全相同的参数绑定路径。同时,每个Unpack[...]下标表达式都会被打上TypeExpressionFlags::UNPACK标志(type_expression.rs),供后续上下文判断使用。
可变参数推断(Variadic parameter inference)
Unpack应用于*args时,ty 会保留位置参数的数量与类型,而不是退化为tuple[Any, ...]:
from typing import TypeVarTuple, Unpack Ts = TypeVarTuple("Ts") def collect(*args: Unpack[Ts]) -> tuple[Unpack[Ts]]: reveal_type(args) # revealed: tuple[*Ts@collect] raise NotImplementedError reveal_type(collect()) # revealed: tuple[()] reveal_type(collect(1, "a")) # revealed: tuple[Literal[1], Literal["a"]]注意两点:
- 函数体内
args的类型是tuple[*Ts@collect]——@collect后缀表示这是绑定到collect函数作用域的TypeVarTuple,而不是模块级的Ts; - 调用点处
collect(1, "a")被推断为tuple[Literal[1], Literal["a"]],字面量类型被保留。
参数派生类型在赋值冲突中依然保留
legacy 拼写还必须保证:当外围赋值期望一个不兼容的返回类型时,从参数派生出的类型依然保留(以便后续诊断指向真正的错误来源,而不是错误地抹掉推断精度):
inferred = collect(1) reveal_type(inferred) # revealed: tuple[Literal[1]] # error: [invalid-assignment] indirect: tuple[str] = inferred # error: [invalid-assignment] direct: tuple[str] = collect(1)collect(1)的类型是tuple[Literal[1]],把它赋值给tuple[str]无论是通过中间变量间接赋值还是直接赋值,都会触发invalid-assignment诊断,同时Literal[1]的精度在推断阶段不受影响。
Callable 参数展开
Unpack可以将类型变量元组展开进 callable 的位置参数列表,同一个元组也可以描述转发给该 callable 的参数:
from typing import Callable, TypeVar, TypeVarTuple, Unpack R = TypeVar("R") Ts = TypeVarTuple("Ts") def invoke( callback: Callable[[Unpack[Ts]], R], *args: Unpack[Ts], ) -> R: raise NotImplementedError def format_value(value: int, label: str, /) -> str: return f"{label}: {value}" reveal_type(invoke(format_value, 1, "value")) # revealed: str # TODO: Validate arguments matched to the variadic parameter against the `TypeVarTuple` inferred # from the callback. reveal_type(invoke(format_value, 1)) # revealed: str这里invoke是一个高阶函数:callback接受Unpack[Ts]展开后的位置参数,*args同样是Unpack[Ts]。调用invoke(format_value, 1, "value")时,ty 从callback与实参同时推断Ts,最终R被绑定为str。
源码中紧随其后的TODO注释则诚实地标出了当前实现的已知限制:尚未验证实参与从 callback 推断出的TypeVarTuple是否匹配——因此invoke(format_value, 1)(参数数量不匹配)目前仍被接受。这种 TODO 标注在 mdtest 规格中用于记录已知行为缺口,是 ty 持续演进的一部分。
通过解包的 TypeVarTuple 转发 ParamSpec
ParamSpec描述的是 callable 的完整参数集(含关键字参数),而TypeVarTuple只描述位置参数。二者可以协同工作:一个转发参数规格的 callable,其本身可以连同参数一起传给一个用解包 TypeVarTuple 描述位置参数的 callable:
from typing import Callable, ParamSpec, TypeVarTuple, Unpack P = ParamSpec("P") Ts = TypeVarTuple("Ts") def invoke(callback: Callable[[Unpack[Ts]], None], *args: Unpack[Ts]) -> None: ... def forward(callback: Callable[P, None], *args: P.args, **kwargs: P.kwargs) -> None: ... def one_arg(value: int) -> None: ... invoke(forward, one_arg, 1)调用链分析:forward是Callable[P, None],其P被one_arg((value: int) -> None)和实参1实例化为[int];invoke的Ts则从位置实参forward, one_arg, 1推断。整条调用链既涉及ParamSpec的实例化,又涉及TypeVarTuple的解包展开,ty 均能正确处理。这也印证了本文档的测试主题:legacy 泛型各语法路径之间的互操作性。
类型别名(Type aliases)
legacy 别名可以使用Unpack[Ts],并且在特化时既可以接收逐个类型,也可以接收解包的元组类型:
from typing import TypeVarTuple, Unpack Ts = TypeVarTuple("Ts") Alias = tuple[int, Unpack[Ts]] def f( fixed: Alias[str, bool], unbounded: Alias[Unpack[tuple[str, ...]]], ) -> None: reveal_type(fixed) # revealed: tuple[int, str, bool] reveal_type(unbounded) # revealed: tuple[int, *tuple[str, ...]]Alias[str, bool]:固定元素,展开为tuple[int, str, bool];Alias[Unpack[tuple[str, ...]]]:无界元组(str, ...),无法确定长度,因此揭示为tuple[int, *tuple[str, ...]]——*解包符出现在揭示类型中,表示这是一个"可变长段"。
fixed与unbounded的揭示类型差异正是"固定长度 vs 未知长度"两种 TypeVarTuple 特化的典型体现。关于别名的更多边界行为(如Never保留、无法拆分 TypeVarTuple 等)可参阅 typevartuple.md 的 "Type Aliases" 一节。
不支持的 Union 解包(Unsupported union unpacking)
将 TypeVarTuple 解包进Union目前不被支持。被拒绝的联合体无论单独出现还是嵌套在另一个泛型特化中,都会恢复为object;运行时元素访问同样恢复为object:
from typing import TypeVarTuple, Union, Unpack Ts = TypeVarTuple("Ts") # TODO: shouldn't error # error: [invalid-type-form] def reject_union(value: Union[Unpack[Ts]]) -> None: # TODO: should reveal `Union[*Ts]` representation reveal_type(value) # revealed: object # error: [invalid-type-form] "Unpacking a `TypeVarTuple` in `Union` is not supported" def reject_nested_union(value: list[Union[Unpack[Ts], None]]) -> None: reveal_type(value) # revealed: list[object] def element_types(values: tuple[Unpack[Ts]]) -> None: # TODO: should reveal `Union[*Ts]` representation reveal_type(values[0]) # revealed: object for value in values: # TODO: should reveal `Union[*Ts]` representation reveal_type(value) # revealed: object这里可以提炼出三条行为准则:
Union[Unpack[Ts]]直接触发invalid-type-form诊断(源码中对应诊断消息为 "Unpacking aTypeVarTupleinUnionis not supported",见 type_expression.rs 附近的诊断构造);- 嵌套形式
list[Union[Unpack[Ts], None]]同样报错,且外层list[...]的元素类型恢复为object; - 对
tuple[Unpack[Ts]]的元素做索引或迭代访问时,当前揭示为object。
代码中的多个TODO: should reveal Union[*Ts]表明:ty 认为这里应该揭示出Union[*Ts]这种表示形式(*Ts展开进联合体),只是目前尚未实现,属于已知的精度缺口而非最终设计。
Union 中嵌套的无效解包操作数
Unpack[int]在 Python 语法上是合法的写法(可以解析、可以运行),但其操作数不是元组类型。当这样的联合体出现在泛型特化内部时,ty 会报告一条普通的invalid-type-form诊断:
from typing import Union, Unpack # error: [invalid-type-form] "`Unpack` can only unpack a tuple type or `TypeVarTuple`" def invalid_operand(value: list[Union[Unpack[int], None]]) -> None: reveal_type(value) # revealed: list[tuple[Unknown, ...] | None]注意揭示类型中的细节:无效的Unpack[int]并没有恢复为object,而是恢复为tuple[Unknown, ...](未知元素的同质元组)。这与源码实现完全对应——在 type_expression.rs 中,当操作数既不是确切元组也不是TypeVarTuple时:
self.store_type_expression_flags( ast::ExprRef::from(subscript), TypeExpressionFlags::INVALID_UNPACK, ); if !inner_ty.is_unknown() && let Some(builder) = self.context.report_lint(&INVALID_TYPE_FORM, subscript) { diagnostic::add_type_expression_reference_link(builder.into_diagnostic( "`Unpack` can only unpack a tuple type or `TypeVarTuple`", )); } Type::homogeneous_tuple(db, env, Type::unknown())实现细节值得注意:Unpack会被打上INVALID_UNPACK标志,诊断消息为 "Unpackcan only unpack a tuple type orTypeVarTuple",并且无论操作数是什么,始终恢复为Type::homogeneous_tuple(..., Type::unknown()),即tuple[Unknown, ...]。这就是测试断言中tuple[Unknown, ...] | None的来源。
无效解包上下文仍会推断操作数
一个重要的设计原则是:无效的解包上下文不应压制其操作数的运行时错误。同时,字符串注解不会执行其内容,因此无效字符串注解中未解析的名字保持静默:
from typing import Unpack # error: [invalid-type-form] "`Unpack` is not allowed in parameter annotations" # error: [unresolved-reference] "Name `Missing` used when not defined" def invalid_context(value: Unpack[Missing]) -> None: ... # error: [invalid-type-form] "`Unpack` is not allowed in parameter annotations" def invalid_stringified_context(value: "Unpack[Missing]") -> None: ...两条路径的差异正是"是否执行":
| 写法 | invalid-type-form诊断 | unresolved-reference诊断 |
|---|---|---|
Unpack[Missing](未字符串化) | 有 | 有(Missing未定义) |
"Unpack[Missing]"(字符串化) | 有 | 无(字符串不执行) |
Unpack出现在普通参数注解(非*args/**kwargs)中本身是非法上下文,报 "Unpackis not allowed in parameter annotations"(源码消息模板为 "Unpackis not allowed in {}s",见 type_expression.rs,其中{}由当前类型表达式上下文填充)。但操作数Missing在第一种写法中仍会被求值,从而暴露出未定义名字错误。
源码中的相关处理逻辑(type_expression.rs):
let inner_ty = if self.in_string_annotation() && (is_nested_unpack || is_nested_kwargs || is_invalid_context) { // Invalid string annotations never execute, so their operands must not // produce runtime errors even though their inferred types are still needed. let mut speculative = self.speculate_without_diagnostics(); let inner_ty = speculative.infer_type_expression(arguments_slice); self.extend(speculative); inner_ty } else { self.infer_type_expression(arguments_slice) };即:字符串注解 + 嵌套/关键字/无效上下文时,通过speculate_without_diagnostics()进行"无诊断的推测性推断",既拿到操作数的推断类型,又不产生运行时错误类诊断;非字符串路径则正常推断并报告诊断。此外,同一段源码还实现了两种额外的非法形式:
- 嵌套 Unpack(
Unpack[Unpack[...]])→ "Unpackcannot be nested"; - 嵌套 kwargs(
Unpack出现在非顶层的**kwargs注解中)→ "Unpackis only valid as the top-level**kwargsannotation form"。
这些错误路径也都会在后面的验证部分看到对应的测试断言。
具体元组与嵌套元组解包
Unpack可以为*args展开一个具体的元组注解,包括嵌套的无界元组:
from typing import Unpack def accept( *args: Unpack[tuple[bool, Unpack[tuple[str, ...]], bytes]], ) -> None: ... accept(True, "phase", "status", b"ok") accept(True, b"ok") accept(True, 1, b"bad") # error: [invalid-argument-type]*args的类型规格是(bool, *str..., bytes)——即第一个参数必须是bool,最后一个必须是bytes,中间可以有任意数量的str。因此:
accept(True, "phase", "status", b"ok"):合法(bool+ 两个str+bytes);accept(True, b"ok"):合法(零个str的退化情形);accept(True, 1, b"bad"):非法(中间出现int,不匹配str),触发invalid-argument-type。
这是"具体类型 + 嵌套 Unpack"组合在可变参数上的精确应用,展现了 ty 对嵌套可变长段的建模能力。
默认值(Defaults)
从 Python 3.13 起,TypeVarTuple支持default参数。默认值本身可以使用Unpack,显式特化会覆盖默认值:
[environment] python-version = "3.13"from typing import Generic, TypeVarTuple, Unpack Ts = TypeVarTuple("Ts", default=Unpack[tuple[int, str]]) class WithDefault(Generic[Unpack[Ts]]): value: tuple[Unpack[Ts]] reveal_type(WithDefault().value) # revealed: tuple[int, str] reveal_type(WithDefault[bool, bytes]().value) # revealed: tuple[bool, bytes]- 不提供类型参数时,
Ts落入默认值Unpack[tuple[int, str]],value为tuple[int, str]; - 显式传入
bool, bytes时,默认值被覆盖,value为tuple[bool, bytes]。
关于默认值的补充规则(记录在 typevartuple.md 中):TypeVarTuple的默认值必须是解包的元组类型或另一个TypeVarTuple,直接写default=tuple[int, str]会触发invalid-legacy-type-variable诊断;typing_extensions.TypeVarTuple可将default反向移植到旧版 Python。在更早的 Python 版本(如 3.10)上,需要通过typing_extensions导入Unpack才能配合使用:
from typing import Generic from typing_extensions import TypeVarTuple, Unpack Ts = TypeVarTuple("Ts", default=Unpack[tuple[int, str]]) class WithBackportedDefault(Generic[Unpack[Ts]]): attr: tuple[Unpack[Ts]] reveal_type(WithBackportedDefault().attr) # revealed: tuple[int, str]验证规则(Validation)
Unpack要求操作数是元组类型,且元组特化中只能有一个 variadic 解包。这一节集中了所有非法形式的诊断断言:
from typing import Generic, TypeVar, TypeVarTuple, Unpack U = TypeVar("U") Ts = TypeVarTuple("Ts") Xs = TypeVarTuple("Xs") Ys = TypeVarTuple("Ys") class Pair(Generic[Unpack[Ts], U]): ... # error: [invalid-generic-class] "Only one `TypeVarTuple` parameter is allowed in a `Generic` subscription" class MultipleUnpack(Generic[Unpack[Xs], Unpack[Ys]]): ... # error: [invalid-generic-class] "Only one `TypeVarTuple` parameter is allowed in a `Generic` subscription" class StarThenUnpack(Generic[*Xs, Unpack[Ys]]): ... # error: [invalid-generic-class] "Only one `TypeVarTuple` parameter is allowed in a `Generic` subscription" class UnpackThenStar(Generic[Unpack[Xs], *Ys]): ... def invalid( # error: [invalid-type-form] "`Unpack` can only unpack a tuple type or `TypeVarTuple`" non_tuple: Pair[Unpack[int], str], # error: [invalid-type-form] "Multiple unpacked variadic tuples are not allowed in a `tuple` specialization" multiple: tuple[Unpack[Ts], Unpack[tuple[str, ...]]], ) -> None: reveal_type(non_tuple) # revealed: Pair[*tuple[Unknown, ...], str] # error: [invalid-type-form] "`Unpack` can only unpack a tuple type or `TypeVarTuple`" def invalid_vararg(*args: Unpack[int]) -> None: reveal_type(args) # revealed: tuple[Unknown, ...] # error: [invalid-type-form] "`Unpack` can only unpack a tuple type or `TypeVarTuple`" def invalid_stringified_vararg(*args: "Unpack[int]") -> None: reveal_type(args) # revealed: tuple[Unknown, ...] # error: [invalid-type-form] "`Unpack` cannot be nested" def nested(*args: Unpack[Unpack[tuple[int, ...]]]) -> None: ... # error: [invalid-type-form] "Bare TypeVarTuple `Ts` is not valid in this context in a parameter annotation" def nested_bare_typevartuple(*args: Unpack[tuple[Ts]]) -> None: ...逐条解析这些规则:
规则一:Generic订阅中只允许一个TypeVarTuple。MultipleUnpack、StarThenUnpack、UnpackThenStar三种写法(分别对应两个Unpack、*Xs与Unpack[Ys]混用、Unpack[Xs]与*Ys混用)都触发invalid-generic-class,诊断消息统一为 "Only oneTypeVarTupleparameter is allowed in aGenericsubscription"。这也与 typevartuple.md 中Generic[*Xs, *Ys]的约束一致——legacy 与 PEP 695 语法在"单 TypeVarTuple"这一根本约束上行为相同。
规则二:Unpack操作数必须是元组或TypeVarTuple。Unpack[int]无论是作为Pair的特化参数(Pair[Unpack[int], str]),还是直接用于*args注解(invalid_vararg、invalid_stringified_vararg),都会触发 "Unpackcan only unpack a tuple type orTypeVarTuple"。注意后两者的恢复类型均为tuple[Unknown, ...]——与前面 Union 嵌套场景的恢复策略一致。
规则三:元组特化中不允许出现多个解包的可变长段。tuple[Unpack[Ts], Unpack[tuple[str, ...]]]触发 "Multiple unpacked variadic tuples are not allowed in atuplespecialization"——一个元组类型只能有一个可变长段(否则元素位置无法唯一确定)。
规则四:Unpack不可嵌套。Unpack[Unpack[tuple[int, ...]]]触发 "Unpackcannot be nested",对应源码中is_nested_unpack检查分支(type_expression.rs)。
规则五:裸TypeVarTuple不能出现在Unpack的元组内部。Unpack[tuple[Ts]]中Ts是裸的(未解包),触发 "Bare TypeVarTupleTsis not valid in this context in a parameter annotation"。此外,non_tuple的揭示类型Pair[*tuple[Unknown, ...], str]也揭示了错误恢复的另一面:无效的Unpack[int]在Pair特化内部恢复为*tuple[Unknown, ...],U固定为str,整体类型仍然可读可用,避免了级联错误。
源码实现全景:Unpack的完整检查管线
综合前文各节,ty 对Unpack[...]的检查管线可以归纳为以下步骤(全部位于 type_expression.rs 的SpecialFormType::Unpack分支):
- 标记:为下标表达式存储
TypeExpressionFlags::UNPACK; - 上下文判定:读取推断标志,判定三种非法情形——嵌套 Unpack(
IN_UNPACK_TYPE_ARGUMENT)、嵌套 kwargs(IN_KWARG_ANNOTATION+IN_NESTED_TYPE_EXPRESSION)、无效上下文(不在IN_VARARG_ANNOTATION | IN_KWARG_ANNOTATION | IN_VALID_UNPACK_CONTEXT集合内); - 操作数推断:对
Unpack的操作数递归推断类型;字符串注解中的无效场景使用speculate_without_diagnostics静默推断; - 合法性检查:操作数必须解析为确切元组实例或
TypeVarTuple,否则打上INVALID_UNPACK标志并报 "Unpackcan only unpack a tuple type orTypeVarTuple",恢复为tuple[Unknown, ...]; - 传播:合法的
Unpack结果走与等星球号标注相同的参数绑定路径(与typevartuple.md中的*Ts行为统一)。
正是这一管线的存在,使得本文档中的每一条reveal_type断言和每一条error:诊断都有了确定的实现依据。文档中所有TODO注释(如Union[*Ts]表示、参数匹配验证)则标记了管线的已知边界,属于 ty 的持续改进项。
总结
Unpack[Ts]作为*Ts的 legacy 拼写,在 ty 中拥有完整的、可回归验证的语义实现。本文从规格文档出发,覆盖了它的全部使用场景:泛型声明与特化(含具体元组展开)、*args可变参数的类型精度保留、Callable 位置参数展开、与ParamSpec的协同转发、类型别名、默认值,以及五大类非法形式(Union 解包、非元组操作数、嵌套 Unpack、多可变长段、裸TypeVarTuple)的精确诊断与错误恢复策略。
若要进一步探索,推荐按以下路径深入:
- unpack.md:本文的规格来源,可执行断言全集;
- typevartuple.md:
TypeVarTuple的定义、特化、方差与默认值规则; - paramspec.md:
ParamSpec与P.args/P.kwargs的完整约束; - type_expression.rs:
Unpack与所有特殊形式类型的底层实现; - special_form.rs:特殊形式符号(含
Unpack)的运行时识别; - tests/mdtest.rs 与 ty_test/README.md:mdtest 规格的运行机制。
【免费下载链接】ruffAn extremely fast Python linter and code formatter, written in Rust.项目地址: https://gitcode.com/GitHub_Trending/ru/ruff
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考