Ruff/ty 一元运算符类型推断深入解析:UAdd、USub 与 Invert 的实现原理
【免费下载链接】ruffAn extremely fast Python linter and code formatter, written in Rust.项目地址: https://gitcode.com/GitHub_Trending/ru/ruff
本文基于 ruff 仓库中
ty类型检查器的官方 mdtest 用例 invert_add_usub.md,系统讲解一元正号+、一元负号-与按位取反~三类一元运算符在类型推断中的求值规则。你将掌握:reveal_type在 unary 场景下的预期输出、unsupported-operator诊断的触发条件与消息格式、__pos__/__neg__/__invert__三个 dunder 方法的调用策略,以及带边界(bound)与受约束(constrained)的TypeVar在一元运算中的特殊处理逻辑。全文以仓库源码 builder.rs 为底层依据,可直接对照验证。
一、文档定位:mdtest 是什么
ty是 ruff 仓库中的 Python 类型检查器(type checker),其核心类型推断逻辑位于crates/ty_python_semantic目录。该目录下的resources/mdtest/存放了大量以 Markdown 编写的、可执行的类型检查测试用例(mdtest,即 markdown test)。
每个 mdtest 文件由"普通 Markdown 说明 + 带reveal_type与error:注释的 Python 代码块 + 可选的snapshot快照块"组成。类型检查器会执行这些用例,将reveal_type(x)展开为实际推断出的类型,并与注释中的# revealed:期望值比对;# error: [rule-code]则用于断言诊断是否在指定代码行触发。
invert_add_usub.md 正是这个测试体系中对+、-、~三个一元运算符的专项测试。与它同目录的还有 not.md(一元not)、custom.md(自定义一元运算与继承行为)、integers.md(整数与字面量的一元运算),共同构成完整的 unary 测试矩阵。本文聚焦于invert_add_usub.md本身,结合源码验证其行为。
二、三类一元运算符的语义与 dunder 方法映射
Python 语言规范中,+x、-x、~x分别调用操作数的__pos__、__neg__、__invert__方法。ty 类型检查器在推断一元表达式时同样遵循这一约定,其 dunder 方法映射在 builder.rs 中明确定义:
| 一元运算符 | Python 语法 | 对应的 dunder 方法 | 典型语义 |
|---|---|---|---|
+ | UAdd(Unary Add) | __pos__ | 一元正号 |
- | USub(Unary Subtract) | __neg__ | 一元负号 |
~ | Invert | __invert__ | 按位取反 |
在 AST 层面,这三者属于ast::UnaryOp枚举的UAdd、USub、Invert变体,与布尔取反Not并列。类型推断入口为 infer_unary_expression:先对操作数递归求值得到operand_type,再调用 infer_unary_expression_type 按"运算符 + 操作数类型"的组合分发处理。
需要特别指出:Not不在此文档讨论范围内(它走独立的真值推断路径,见 not.md)。本文只覆盖+、-、~。
三、实例(Instance)场景:dunder 方法的调用与错误报告
3.1 定义了 dunder 方法的类
文档第一个测试块展示了最核心的实例场景:
from typing import Literal class Number: def __init__(self, value: int): self.value = 1 def __pos__(self) -> int: return +self.value def __neg__(self) -> int: return -self.value def __invert__(self) -> Literal[True]: return True a = Number(0) reveal_type(+a) # revealed: int reveal_type(-a) # revealed: int reveal_type(~a) # revealed: Literal[True]推断结果清晰呈现了三条规则:
+a的结果类型是Number.__pos__的返回类型int;-a的结果类型是Number.__neg__的返回类型int;~a的结果类型是Number.__invert__的返回类型Literal[True]——注意这里连字面量类型(Literal type)都被完整保留,说明一元运算符的结果类型完全由 dunder 方法的返回类型精确决定。
从源码看,这一行为对应 fallback_unary_expression_type 中的try_call_dunder调用:以CallArguments::none()(dunder 方法隐式接收self,无需额外参数)调用对应方法,成功则返回其返回类型(outcome.return_type(db, env)),并顺带执行废弃绑定检查(check_deprecated_bindings)。
3.2 未定义 dunder 方法的类:unsupported-operator
第二个测试块定义了完全不实现任何一元 dunder 的类NoDunder:
class NoDunder: ... b = NoDunder() +b # error: [unsupported-operator] "Unary operator `+` is not supported for object of type `NoDunder`" -b # error: [unsupported-operator] "Unary operator `-` is not supported for object of type `NoDunder`" ~b # error: [unsupported-operator] "Unary operator `~` is not supported for object of type `NoDunder`"当try_call_dunder返回错误时,推断器调用 report_unsupported_unary_operator 生成诊断。该诊断的规则为unsupported-operator,在 diagnostic.rs 中注册:
declare_lint! { #[doc = include_str!("../../resources/lint_docs/unsupported-operator.md")] pub(crate) static UNSUPPORTED_OPERATOR = { summary: "detects binary, unary, or comparison expressions where the operands don't support the operator", status: LintStatus::stable("0.0.1-alpha.1"), default_level: Level::Error, } }诊断消息模板为"Unary operator \{op}` is not supported for object of type `{operand_type}`",其中op是+/-/~的源码文本,operand_type是操作数的显示类型。这与 mdtest 中断言的错误文本完全一致。规则级别为Error(稳定状态自0.0.1-alpha.1),与unresolved-reference、invalid-attribute-access` 等检查同属稳定错误规则。
值得一提的是错误报告还具备细化能力:当 dunder 调用失败源于"方法可能未绑定"(CallDunderError::PossiblyUnbound)时,会额外追加\{ty}` does not implement `{unary_dunder_method}`` 信息行,帮助用户定位到具体哪个类型成员缺少对应方法。
3.3 相关补充:继承与类对象
虽然不在invert_add_usub.md内,但同目录的 custom.md 提供了与本文档互补的关键行为,可帮助理解 dunder 的查找范围:
- 继承生效:子类实例会继承父类的
__pos__/__neg__/__invert__,reveal_type(+Sub())同样得到父类方法的返回类型; - 类对象不支持:dunder 方法定义在类上,仅对其实例生效,对类本身不生效——
reveal_type(+Yes)会触发unsupported-operator,因为要让类对象支持一元运算,方法必须定义在type上; - 失败时的回退类型:当 dunder 缺失导致报错时,表达式的结果类型回退为
Unknown(见 builder.rs 的e.fallback_return_type分支)。
四、字面量类型的快速路径
在调用 dunder 方法之前,推断器对字面量类型(Type::LiteralValue)提供了"快速路径"(fast path),直接计算结果而不经过方法查找,具体规则位于 builder.rs:
| 运算符 | 字面量类型 | 结果 | 实现说明 |
|---|---|---|---|
+ | int字面量 | 保持原值的int字面量 | Type::int_literal(value.as_i64()) |
+ | bool字面量 | 0/1的int字面量 | i64::from(value) |
- | int字面量 | 取负后的int字面量 | 使用checked_neg();溢出(如-(-2^63))时回退为普通int实例 |
- | bool字面量 | 0/-1的int字面量 | -i64::from(value) |
~ | int字面量 | 按位取反后的int字面量 | Type::int_literal(!value.as_i64()) |
~ | bool字面量 | 0/-1的int字面量 | Type::int_literal(!i64::from(value)) |
这里有两处值得注意的实现细节:
- 负号溢出保护:
-i64::MIN在数学上会溢出,源码使用checked_neg(),溢出时回退到KnownClass::Int的普通实例类型,避免产生错误的字面量结果; ~bool的废弃检查:typeshed 中bool.__invert__已被标记为废弃(deprecated),因此~作用于bool字面量时,推断器会先通过member_lookup_with_policy查找__invert__并执行废弃检查(check_deprecated),再返回计算结果。源码注释明确说明:之所以只为~bool做这项检查,是因为int.__neg__之类的 dunder 被废弃的可能性微乎其微,不值得为所有字面量快速路径都付出额外查找开销。
除上述匹配的字面量种类外,其余字面量(如字符串、浮点字面量等)会落入fallback_unary_expression_type()走正常的 dunder 调用路径。
五、TypeVar 的一元运算:边界与约束的三种情形
文档的第二大部分(自## TypeVar with bounds起)专门测试 TypeVar 场景,这也是本 mdtest 最具深度的部分。对应的源码分支在 builder.rs,源码注释明确将其标注为对受约束 TypeVar 的特殊处理,并指出未来迁移到新 solver 后会有更通用的方案。
5.1 边界为float的 TypeVar:委托给边界类型
from typing import TypeVar T = TypeVar("T", bound=float) def neg_float_bound(a: T) -> float: reveal_type(-a) # revealed: float return -a def pos_float_bound(a: T) -> float: reveal_type(+a) # revealed: float return +a文档给出了关键说明:在类型注解中,float被视为int | float的联合类型,因此以float为边界的 TypeVar 应当支持一元+和-。
这一特殊约定在源码中有多处印证:
- newtype.rs 注释:
float与complex在类型位置上被特殊处理,分别指向int | float与int | float | complex; - set_theoretic.rs 中
Float成员被定义为int | float; - 一元推断的分支
Some(TypeVarBoundOrConstraints::UpperBound(bound))会递归调用infer_unary_expression_type(op, bound, unary),即将运算符直接作用于边界类型进行推断(builder.rs)。
于是-a与+a的类型按int | float参与推断,最终结果为float(int | float在显示上折叠为float)。
5.2 边界为int的 TypeVar:支持全部数值一元运算
U = TypeVar("U", bound=int) def neg_int_bound(a: U) -> int: reveal_type(-a) # revealed: int return -a def invert_int_bound(a: U) -> int: reveal_type(~a) # revealed: int return ~a以int为边界的 TypeVar 与float边界走同一条UpperBound委托路径,区别在于边界类型是int而非int | float。int原生支持__neg__与__invert__,因此-a与~a均推断为int。文档点明:只要边界类型支持对应运算符,带边界的 TypeVar 就支持该一元运算符。
5.3 受约束的 TypeVar:逐约束检查与类型保持
V = TypeVar("V", int, float) def neg_constrained(a: V) -> V: reveal_type(-a) # revealed: V@neg_constrained return -a受约束 TypeVar(TypeVar("V", int, float))走的是完全独立的分支TypeVarBoundOrConstraints::Constraints(constraints),源码逻辑在 builder.rs,包含两个关键设计:
- 对每个约束逐个调用 dunder:先对所有约束
try_call_dunder并收集结果,这一步在报告错误前完成,目的是让废弃(deprecation)报告不依赖"是否有约束失败"; map_constrained_typevar_constraints映射结果:将每个约束的一元运算结果类型映射回原 TypeVar。当所有约束都支持该运算符且返回类型一致时(如-int -> int与-float -> float的显示类型同为float),映射成功,TypeVar 被保留,因此-a的推断结果为V@neg_constrained(函数内的 TypeVar 实例,带函数名后缀以区分不同作用域的 TypeVar);- 部分失败时:只要有一个约束不支持该运算符,
map_constrained_typevar_constraints返回None,此时触发unsupported-operator诊断,并以整个 TypeVar 的回退调用结果作为兜底类型。
六、操作数类型的快速跳过与分发总览
除上述主要路径外,infer_unary_expression_type 还处理了若干特殊操作数类型,形成完整的分发表:
| 操作数类型 | 行为 |
|---|---|
Dynamic(Any相关) | 对+/-/~直接返回操作数自身类型,不调用 dunder |
Divergent(NoReturn等) | 直接返回操作数类型 |
Never | 返回Never |
TypeAlias | 解包别名后递归推断其值类型 |
TypeVar(上界/约束) | 按第五节所述三种情形处理 |
其余类型(函数、可调用、模块、类、联合、交集、TypedDict、NewType实例等) | 统一走fallback_unary_expression_type(),即 dunder 调用路径,失败则报unsupported-operator |
从这份分发表可以提炼出 ty 对一元运算的设计原则:能用字面量常量折叠就折叠,能用类型代数直接推导就推导,其余情况一律通过 dunder 方法调用收口,保证了推断结果的精确性与可预测性。
七、如何本地运行与验证这些用例
若要亲身体验这些类型推断行为,可使用仓库自带的测试基础设施:
- 运行整个 unary 测试目录:在仓库根目录执行 cargo 测试命令,让
ty_python_semantic的测试框架扫描crates/ty_python_semantic/resources/mdtest/下的全部 mdtest 文件并逐一断言(包括invert_add_usub.md、not.md、custom.md); - 修改与新增用例:mdtest 文件即"文档即测试"——在 Markdown 的 Python 代码块中添加
reveal_type与# error:注释即可扩展覆盖,快照(snapshot块)用于锁定错误输出的精确格式; - 对照源码:类型推断逻辑集中在 crates/ty_python_semantic/src/types/infer/builder.rs,一元运算入口为 infer_unary_expression,错误规则定义见 crates/ty_python_semantic/src/types/diagnostic.rs。
八、小结
以 invert_add_usub.md 为骨架,结合源码可以总结出 ty 对+、-、~三类一元运算符的完整推断策略:
- 实例对象:通过
try_call_dunder调用__pos__/__neg__/__invert__,成功则返回 dunder 的返回类型(保留字面量精度),失败则触发Error级别的unsupported-operator诊断并回退为Unknown; - 字面量:对
int/bool字面量走常量折叠快速路径,包含溢出保护(checked_neg)与~bool废弃检查两个细节处理; - 带边界的 TypeVar:
float边界等价于int | float联合,int边界直接支持,均通过委托给上界类型推断,结果为边界类型; - 受约束的 TypeVar:逐个约束调用 dunder 并映射结果,全部约束支持且返回类型一致时保持 TypeVar 类型(如
V@neg_constrained),任一约束失败则报错; - 其余类型:统一走 dunder 回退路径,构成兜底。
这套"mdtest 文档 + 源码实现"的组合,既是 ty 类型检查器的一元运算行为规范,也是理解 ruff 仓库如何用"可执行文档"驱动并验证类型推断正确性的最佳样例。
【免费下载链接】ruffAn extremely fast Python linter and code formatter, written in Rust.项目地址: https://gitcode.com/GitHub_Trending/ru/ruff
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考