深入解析 ty 类型检查器规则 parameter-already-assigned:同一函数参数被传入多个值
【免费下载链接】ruffAn extremely fast Python linter and code formatter, written in Rust.项目地址: https://gitcode.com/GitHub_Trending/ru/ruff
ty 是 Astral 开发的高性能 Python 类型检查器,其完整 Rust 源码位于本仓库的crates/ty*系列 crate 中。parameter-already-assigned是 ty 类型推导阶段内置的一项稳定诊断,用于在函数调用参数绑定时发现「同一个参数被传入多个值」的调用,例如f(1, x=2)。读完本文,你将掌握该规则的全部触发形态、报错信息含义、底层绑定算法原理,以及它与*args/**kwargs展开、functools.partial等复杂调用场景的交互边界,并能用ty check在本地复现验证。
规则速览:它检测什么
规则文档位于 crates/ty_python_semantic/resources/lint_docs/parameter-already-assigned.md,对规则的定位描述得非常简洁:
What it does:Checks for calls which provide more than one argument for a single parameter.
也就是说,只要一次调用中,有两个及以上的实参最终落到了同一个形参上,ty 就会报出该诊断。最常见的形态是"位置实参 + 关键字实参"叠在一起命中同一参数:
def f(x: int) -> int: return x f(1, x=2) # error在源码层面,规则本体在 crates/ty_python_semantic/src/types/diagnostic.rs 中通过宏declare_lint!声明,其注册信息为:
- 规则名:
parameter-already-assigned - summary:
detects multiple arguments for the same parameter - 状态:
stable("0.0.1-alpha.1")(自 0.0.1-alpha.1 起即稳定启用) - 默认级别:
Level::Error(默认按错误级上报,会影响ty check的退出状态)
注意,这是一条类型检查(type checker)规则,而不是纯文本风格 lint 规则——它需要先完成参数的类型推导,才能确定各实参与形参的对应关系,因此实现位于类型语义 crate(ty_python_semantic)的调用绑定阶段,而不是文本扫描阶段。
为什么这是错误:运行时必然抛 TypeError
文档中 "Why is it bad?" 给出的理由只有一句话,但背后是 Python 语言的硬性调用约定:
Providing multiple values for a single parameter will raise a
TypeErrorat runtime.
以f(1, x=2)为例,CPython 在真正调用时会发现:形参x已经被第 1 个位置实参占用,而关键字实参x=2又要再赋给x一次,于是抛出:
TypeError: f() got multiple values for argument 'x'这种错误纯属"手滑",静态类型检查器完全可以提前拦截,让错误在 CI 阶段而非线上运行时暴露。
需要区分的是另一类重复赋值:f(x=1, x=2)(同一个关键字在字面上写了两次)在 CPython 中属于语法错误(SyntaxError),甚至等不到运行时。从实现上看,ty 的参数绑定代码刻意不重复上报这类字面重复关键字(详见下文实现剖析),避免和语法层面的报错重复。
触发该诊断的典型代码形态
ty 的文档化测试(mdtest)在 crates/ty_python_semantic/resources/mdtest/call/function.md 中给出了与规则文档完全一致的最小触发用例,并标注了期望的错误位置(第 18 列对应x=2)和完整报错文案:
def f(x: int) -> int: return 1 # error: 18 [parameter-already-assigned] "Multiple values provided for parameter `x` of function `f`" reveal_type(f(1, x=2)) # revealed: int把调用包进reveal_type(...)只是为了拿到推导结果的类型;诊断本身仍精确地定位到重复实参x=2上。以下场景同样会触发该规则:
位置实参 + 关键字实参重叠(最常见):
def f(a: int, b: str) -> bool: return True f(1, b="s", a=2) # a 已被位置实参占用,再传 a=2 → error*关键字实参命中已被args 类展开占用的形参:
def f(a: str, b: str, c: float) -> None: ... def _(args: tuple[str, str, str]) -> None: # error: [invalid-argument-type] # error: [parameter-already-assigned] "Multiple values provided for parameter `c` of function `f`" f(*args, c=1.0)关键字实参 + 携带已知键的**kwargs展开(ty 对此采取保守策略):
from typing import TypedDict def f(a: str, b: str, c: float) -> None: ... class CKwargs(TypedDict): c: float def _(args: list[str]) -> None: # error: [invalid-argument-type] # error: [parameter-already-assigned] f(*args, **CKwargs(c=1.0))functools.partial构造阶段:
from functools import partial def f(a: int, b: str) -> bool: return True p = partial(f, 1, a=2) # error: [parameter-already-assigned]namedtuple等特殊构造器:mdtest 中甚至覆盖了collections.namedtuple场景(见 crates/ty_python_semantic/resources/mdtest/named_tuple.md),当typename、field_names等参数同时被位置与关键字实参赋值时也会报出该错误。
诊断信息的可读性设计
当规则触发时,ty 输出的错误文案统一为:
Multiple values provided for parameter `x` of function `f`这里的消息渲染逻辑在 crates/ty_python_semantic/src/types/call/bind.rs 中:
Self::ParameterAlreadyAssigned { argument_index, parameter, } => { let range = context.get_range(node, *argument_index); if let Some(builder) = context.report_lint(&PARAMETER_ALREADY_ASSIGNED, range) { let mut diag = builder.into_diagnostic(format_args!( "Multiple values provided for parameter {parameter}{}", callable_description .map(|description| format!(" of {description}")) .unwrap_or_default() )); // ... } }参数名与人称后缀(of function f、of ...)来自callable_description,因此报错会明确指出"是哪个函数/可调用对象的哪个参数出了问题",对partial返回、方法调用等场景也能给出可读的函数名。
若参数没有可显示的名字,或冲突来自位置参数(positional标志为真),ParameterContext的Display实现(bind.rs)会退化为输出1 起始的参数序号,如1或1 (x),保证即使对匿名/合成形参也能定位。
此外,代码在生成诊断后会附加可选的上下文信息:当错误来自某个被展开的复合调用(如 union 调用分支)时会附加compound_diag上下文;当callable_ty持有函数签名 span 时,还会追加一条 "signature here" 的辅助子诊断,用Annotation::primary把读者引导到函数签名定义处(bind.rs)。
实现剖析:在参数绑定阶段按"已匹配标记"拦截
真正触发规则的逻辑不在诊断渲染层,而在调用绑定的核心函数assign_argument(crates/ty_python_semantic/src/types/call/bind.rs)中。ty 在逐个分配实参时会维护一张parameter_info表,记录每个形参是否已被matched。每当要把一个实参赋给某个形参时,先做如下检查:
if self.parameter_info[parameter_index].matched && !parameter.is_variadic() && !parameter.is_keyword_variadic() // Repeated explicit keywords are already reported as syntax errors. && !matches!( argument, Argument::Keyword(name) if self.arguments.iter().take(argument_index).any(|(previous, _)| { matches!(previous, Argument::Keyword(previous_name) if previous_name == name) }) ) { self.errors.push(BindingError::ParameterAlreadyAssigned { argument_index: self.get_argument_index(argument_index), parameter: ParameterContext::new(parameter, parameter_index, positional), }); }四个条件缺一不可,值得逐条拆解:
parameter_info[parameter_index].matched为真:该形参已经被更早的实参占用。顺序敏感的设计意味着只有"后来的重复者"会收到诊断,而第一个成功匹配的实参不受影响。!parameter.is_variadic():形参本身若是*args这类变长参数,任何数量的位置实参都合法,永远不会"重复赋值"。!parameter.is_keyword_variadic():同理,**kwargs会吞下任意未命名形参的关键字,不存在冲突。- 排除字面重复的关键字实参:如果检测到同一个关键字名在实参列表中反复出现(如
f(x=1, x=2)),这一般已被语法层判为 SyntaxError,这里跳过以免重复报错。
错误通过枚举BindingError::ParameterAlreadyAssigned携带现场信息(bind.rs),包括:
argument_index: Option<usize>:触发冲突的实参位置,供渲染层画下划线定位;parameter: ParameterContext:冲突形参的名字、序号与"是否位置匹配"标志。
其中argument_index经过get_argument_index的特殊处理(bind.rs):如果冲突实参是编译器为某些特殊调用**合成(synthetic)**的实参,它并不存在于调用点源码中,此时返回None,渲染层便会把诊断整体挂到整个Call节点上,而不是指向一个不存在的参数位置。
从枚举的定义和 match 结构看,ParameterAlreadyAssigned与MissingArguments、UnknownArgument、TooManyPositionalArguments、PositionalOnlyParameterAsKwarg等属于同一"参数绑定错误"家族,统一走report_lint→ 构造诊断的流程,错误发生与否的决定性逻辑全部收敛在bind.rs这一处绑定引擎中。
与 *args/**kwargs 展开的交互边界
变长实参展开是最容易产生"假重复"的地方,ty 为此有非常细致的处理,在 mdtest 中甚至为它单独写了回归说明:
当一个变长可迭代对象被
*展开、其后又跟着显式关键字实参时,变参展开不应贪心地吞掉那些已有显式关键字绑定的形参,从而避免产生假的 "parameter already assigned" 错误。 —— crates/ty_python_semantic/resources/mdtest/call/function.md
对应的用例为:
def f(a: str, b: str, c: float) -> None: ... # 显式关键字实参优先于变长展开: # 列表展开只应填充 a 和 b,把 c 留给关键字实参 —— 不报错 def _(args: list[str]) -> None: f(*args, c=1.0) # 定长元组展开同理 —— 不报错 def _(args: tuple[str, str]) -> None: f(*args, c=1.0) # 但若定长元组过长,确实撞上了 c,则按预期报错 def _(args: tuple[str, str, str]) -> None: f(*args, c=1.0)而一旦没有显式关键字兜底,ty 的处理又回归保守:f(*args, 1.0)(变长展开后紧跟位置实参)、f(*args1, *args2)(多个变长展开)、f(*args, **kwargs)(键完全未知的关键字变参)等都属于"位置上模棱两可"的调用,ty 会倾向于给出参数类型或数量上的诊断而非凭空猜测(function.md)。
在绑定引擎内部,上述"先到先得、显式优先"的规则同样体现在 bind.rs 的一段注释与代码中:当调用是若干定长元组类型的 union时,多出来的实参位置仍需按正常位置匹配流程走一遍,从而对更长的 union 分支报出与"具体更长元组"一致的错误(too-many-positional-arguments,或当显式关键字撞上该形参时给出parameter-already-assigned),而不是静默丢弃多余位置——这保证了错误行为的一致性,不因调用方类型是"单个长元组"还是"元组 union"而产生差异。
functools.partial 与变参回退场景
partial把一部分实参"预先绑定"在构造阶段,因此重复赋值的检测点前移到了partial(...)表达式本身。ty 专门在 crates/ty_python_semantic/resources/mdtest/call/functools_partial.md 中覆盖了两类情况:
from functools import partial def f(a: int, b: str) -> bool: return True # partial 构造时位置实参 1 已占用 a,再传 a=2 → error p = partial(f, 1, a=2) # error: [parameter-already-assigned]def f(a: int, **kwargs: str) -> bool: return True # a 的类型本应是 int,却收到 str(invalid-argument-type); # 同时 a 已被位置实参占用,又被显式关键字赋值(parameter-already-assigned) # error: [invalid-argument-type] # error: [parameter-already-assigned] p = partial(f, 1, a="hello") reveal_type(p) # revealed: partial[(**kwargs: str) -> bool]注意第二个用例展示了**"重复赋值"与"类型不匹配"可以同时上报**:a="hello"既撞上了已绑定的a=1,其类型str也不满足int标注,两条诊断互不遮蔽——因为规则触发点在assign_argument的"是否已匹配"检查,与后续的类型断言是两条独立路径。这也意味着,即便参数重复,ty 仍会继续完成剩余绑定与类型检查,而不是中止整个调用分析。
与同族参数错误规则的对照
要写出无歧义的调用代码,除了避免重复赋值,还需同时规避同族错误。它们共享同一个BindingError枚举与诊断渲染框架,在 bind.rs 等处被统一处理:
| 规则 | 检测对象 | 典型形态 |
|---|---|---|
missing-argument | 必填形参没有任何实参 | f()(f需要x) |
unknown-argument | 关键字实参不对应任何形参 | f(x=1, y=2) |
too-many-positional-arguments | 位置实参数量超过形参容量 | len([], 1) |
positional-only-parameter-as-kwarg | 对纯位置形参使用关键字传参 | f(1, x=2)(x为/前纯位置参数时) |
parameter-already-assigned | 同一形参收到多个值 | f(1, x=2) |
值得注意的是parameter-already-assigned与too-many-positional-arguments在定长元组展开场景下可以同时出现(如前文f(*args, c=1.0)的长元组用例),读者在阅读诊断输出时不必惊讶于"错误列表同时出现两条",这正是 ty 有意为之的完整性问题描述。
如何在本地复现与验证
规则文档本身不提供运行方式,但从仓库结构可以推导出完整的复现路径。ty 的可执行入口位于 crates/ty/src/main.rs,其参数分派在 crates/ty/src/lib.rs,支持check、server、explain、version等子命令:
# 在工作区根目录构建 ty 后,对示例文件运行类型检查 cargo run -p ty -- check path/to/file.py # 或直接查看本规则的文档说明(rule 名使用连字符形式) cargo run -p ty -- explain rule parameter-already-assignedty check会把parameter-already-assigned按默认的Error级别输出,因此命中该错误的文件会让命令以非零状态退出,适合直接接进 CI。
若想观察"期望的诊断"如何在测试中被断言,可查看仓库内的两类测试资源:
- mdtest 期望注解:在
.py代码块中以# error: 18 [parameter-already-assigned] "Multiple values provided for parameter \x` of function `f`"` 的形式标注行/列、规则名与完整文案,例如 crates/ty_python_semantic/resources/mdtest/call/function.md 与 crates/ty_python_semantic/resources/mdtest/diagnostics/semantic_syntax_errors.md; - 快照(snapshot):对展开/子调用场景的精确落点有快照文件,例如 crates/ty_python_semantic/resources/mdtest/paramspec_subcall_error_location.md 中记录了错误
Multiple values provided for parameter \a` of function `foo`` 在 ParamSpec 子调用场景下的精确输出,可用于对照你自己的报错格式。
小结
parameter-already-assigned虽然"身材小巧"(规则文档仅十几行),却是 Python 调用约定中一类必然崩溃错误的精确建模。它真正有价值的部分隐藏在绑定引擎的边界处理里:先到先得 + 显式关键字优先、跳过已报语法错误的字面重复关键字、对*args/**kwargs/定长元组 union 分别给出保守或精确的策略、以及把functools.partial、namedtuple等"预绑定式"构造器也纳入检测范围。
在代码评审或 CI 中,看到这条错误时可以直接断定:某次调用的同一形参被两条实参路径同时命中,修复方向是删掉冗余的位置实参、或合并关键字实参,让每个形参恰好只被赋值一次。
【免费下载链接】ruffAn extremely fast Python linter and code formatter, written in Rust.项目地址: https://gitcode.com/GitHub_Trending/ru/ruff
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考