ty 类型检查中的整数二元运算:从 mdtest 用例到源码级的字面量折叠、地板除与除以零规则解析

发布时间:2026/9/10 16:04:00
ty 类型检查中的整数二元运算:从 mdtest 用例到源码级的字面量折叠、地板除与除以零规则解析 ty 类型检查中的整数二元运算从 mdtest 用例到源码级的字面量折叠、地板除与除以零规则解析【免费下载链接】ruffAn extremely fast Python linter and code formatter, written in Rust.项目地址: https://gitcode.com/GitHub_Trending/ru/ruff本文以tyRuff 生态中的 Python 类型检查器仓库内的一份 mdtest 类型推断测试文档crates/ty_python_semantic/resources/mdtest/binary/integers.md为主体完整解析 ty 对 Python 整数二元运算 - * / // % ** | ^ 的类型推断规则哪些表达式能折叠为Literal精确类型、哪些退化为int/float/Any以及除以零诊断的触发边界。读完后你将掌握 ty 字面量算术的边界条件i64 溢出、Python 与 Rust 取余/取商语义差异、位移 headroom 判定并能结合 binary_expressions.rs 源码验证这些规则的来源。需要先说明文档形态integers.md不是一篇普通说明文而是一份可执行的类型推断测试mdtest。它由 mdtest.rs 中的datatest_stable::harness!自动发现并运行——该 harness 扫描./resources/mdtest下所有.md文件把其中reveal_type(...)后# revealed:注释标注的“期望类型”与 ty 实际推断结果逐一比对测试框架说明见 ty_test/README.md。因此文中每个代码块既是规格说明也是回归测试注释里的float*中的星号是 mdtest 的类型匹配标记表示对推断结果做模式匹配而非逐字符相等。一、基本算术字面量折叠与 int/float 的区分文档的第一个章节Basic Arithmetic覆盖了加减乘除、取余与三个位运算符。核心规则是两个int字面量做算术运算时ty 会在类型推断期直接计算出结果并给出Literal[...]运算溢出或语义上产生浮点数时则退化为宽类型。原文档给出的完整用例如下reveal_type(2 1) # revealed: Literal[3] reveal_type(3 - 4) # revealed: Literal[-1] reveal_type(3 * -1) # revealed: Literal[-3] reveal_type(-3 // 3) # revealed: Literal[-1] reveal_type(-3 / 3) # revealed: float* reveal_type(5 % 3) # revealed: Literal[2] reveal_type(3 | 4) # revealed: Literal[7] reveal_type(5 6) # revealed: Literal[4] reveal_type(7 ^ 2) # revealed: Literal[5] # error: [unsupported-operator] Operator is not supported between objects of type Literal[2] and Literal[f] reveal_type(2 f) # revealed: Unknown def lhs(x: int): reveal_type(x 1) # revealed: int reveal_type(x - 4) # revealed: int reveal_type(x * -1) # revealed: int reveal_type(x // 3) # revealed: int reveal_type(x / 3) # revealed: float reveal_type(x % 3) # revealed: int def rhs(x: int): reveal_type(2 x) # revealed: int reveal_type(3 - x) # revealed: int reveal_type(3 * x) # revealed: int reveal_type(-3 // x) # revealed: int reveal_type(-3 / x) # revealed: float reveal_type(5 % x) # revealed: int def both(x: int): reveal_type(x x) # revealed: int reveal_type(x - x) # revealed: int reveal_type(x * x) # revealed: int reveal_type(x // x) # revealed: int reveal_type(x / x) # revealed: float reveal_type(x % x) # revealed: int # Edge case: the runtime value is 9223372036854775808, # which doesnt fit into an i64 reveal_type(-(-9223372036854775807 - 1)) # revealed: int从 binary_expressions.rs 的源码可以确认这些行为背后的实现当两侧操作数都是Type::LiteralValue且 kind 为LiteralValueTypeKind::Int时ty 进入字面量分支分别用checked_add/checked_sub/checked_mul计算第 571-603 行一旦返回None溢出 i64就回退为KnownClass::Int实例也就是int。这解释了两类现象溢出退化-(-9223372036854775807 - 1)中内层减法恰好得到i64::MIN而再取负就会溢出 i64所以最终类型是int而非字面量。注意 Python 运行时的实际值是 9223372036854775808任意精度ty 只是用 i64 做内部表示、溢出即放弃字面量精度/恒为 float两个整数字面量的Div直接映射为KnownClass::Float第 604-609 行不做字面量折叠——因为真除结果的浮点表示无法在类型层面精确表达-3 / 3因此是float*而不是Literal[1.0]。对于含变量操作数的表达式lhs/rhs/both三个函数操作数不再是字面量推断走兜底分支infer_binary_dunder第 299-317 行通过Type::try_call_bin_op_result解析 typeshed 中int的二元魔术方法__add__、__sub__等签名。int int - int、int / int - float正是 typeshed 标注的返回类型与文档中的# revealed: int/# revealed: float完全一致。而2 f这类两侧类型不匹配的组合会在兜底失败后由report_unsupported_binary_operation报出unsupported-operator错误推断结果标记为Unknown第 65-68 行。二、幂运算指数越大越容易退化为 int文档的 Power 章节规定幂运算结果若能放进int字面量类型i64就折叠为Literal否则为int。largest_u32 4_294_967_295 reveal_type(2**2) # revealed: Literal[4] reveal_type(1 ** (largest_u32 1)) # revealed: int reveal_type(2**largest_u32) # revealed: int def variable(x: int): reveal_type(x**2) # revealed: int reveal_type(2**x) # revealed: Any reveal_type(x**x) # revealed: Any第二个用例值得留意largest_u32是从赋值largest_u32 4_294_967_295推断出的Literal[4294967295]加上 1 得到Literal[4294967296]——它恰好超出 u32 上界而1 ** (u32 最大值 1)在运行时等于 1但 ty 不再折叠给出int。源码中的幂运算分支第 647-661 行解释了这一边界ast::Operator::Pow, ) Some({ if m.as_i64() 0 { KnownClass::Float.to_instance(db, env) } else { u32::try_from(m.as_i64()) .ok() .and_then(|m| n.as_i64().checked_pow(m)) .map(Type::int_literal) .unwrap_or_else(|| KnownClass::Int.to_instance(db, env)) } })可以拆解为三条规则指数为负 → 直接float见下节指数必须能装进u32u32::try_from这是调用checked_pow的前置限制checked_pow溢出 i64 →int。2**4294967295虽然指数合法但结果远大于 i64checked_pow返回None于是退化为int。当任一侧是普通int变量时推断回落到int.__pow__的 typeshed 签名x**2得到int而2**x与x**x得到Any从源码结构看这是因为 typeshed 中int.__pow__的返回类型标注较宽运行时结果可能是int也可能是float取决于指数符号ty 如实报告该标注。负指数与 0 的边界值文档进一步覆盖了指数的符号组合规则是第二个操作数指数小于 0 时运行时返回float底数小于 0 但指数非负时仍返回int。reveal_type(1**0) # revealed: Literal[1] reveal_type(0**1) # revealed: Literal[0] reveal_type(0**0) # revealed: Literal[1] reveal_type((-1) ** 2) # revealed: Literal[1] reveal_type(2 ** (-1)) # revealed: float* reveal_type((-1) ** (-1)) # revealed: float*其中0**0 1与0**1 0由checked_pow自然得出(-1)**2 1说明负底数本身不改变int的结果类型而2**-1、(-1)**-1命中上面m.as_i64() 0的分支推断为float*与 CPython 运行时行为负指数触发float.__rpow__一致。三、除法与取余Python 向下取整语义的补偿实现这是本文档最有信息量的部分之一。文档明确指出Python 的除法与 Rust 不同——当结果为负且有余数时//是向负无穷取整round down而不是向零取整Rust 的div_euclid/截断行为相应地%的余数需要补偿调整以满足恒等式(lhs // rhs) * rhs (lhs % rhs) lhs。reveal_type(256 % 129) # revealed: Literal[127] reveal_type(-256 % 129) # revealed: Literal[2] reveal_type(256 % -129) # revealed: Literal[-2] reveal_type(-256 % -129) # revealed: Literal[-127] reveal_type(129 % 16) # revealed: Literal[1] reveal_type(-129 % 16) # revealed: Literal[15] reveal_type(129 % -16) # revealed: Literal[-15] reveal_type(-129 % -16) # revealed: Literal[-1] reveal_type(10 // 8) # revealed: Literal[1] reveal_type(-10 // 8) # revealed: Literal[-2] reveal_type(10 // -8) # revealed: Literal[-2] reveal_type(-10 // -8) # revealed: Literal[1] reveal_type(10 // 6) # revealed: Literal[1] reveal_type(-10 // 6) # revealed: Literal[-2] reveal_type(10 // -6) # revealed: Literal[-2] reveal_type(-10 // -6) # revealed: Literal[1]以-10 // 8 -2为例Rust 的-10i64 / 8向零截断得到-1而 Python 要求向下取整得到-2。源码中的补偿逻辑与文档描述逐字对应第 611-645 行ast::Operator::FloorDiv, ) Some({ let mut q n.as_i64().checked_div(m.as_i64()); let r n.as_i64().checked_rem(m.as_i64()); // Division works differently in Python than in Rust. If the result is negative and // there is a remainder, the division rounds down (instead of towards zero): if n.as_i64().is_negative() ! m.as_i64().is_negative() r.unwrap_or(0) ! 0 { q q.map(|q| q - 1); } q.map(Type::int_literal) .unwrap_or_else(|| KnownClass::Int.to_instance(db, env)) }),Mod分支做镜像补偿ast::Operator::Mod, ) Some({ let mut r n.as_i64().checked_rem(m.as_i64()); // Adjust the remainder to compensate so that q * m r n: if n.as_i64().is_negative() ! m.as_i64().is_negative() r.unwrap_or(0) ! 0 { r r.map(|x| x m.as_i64()); } r.map(Type::int_literal) .unwrap_or_else(|| KnownClass::Int.to_instance(db, env)) }),两个条件——两侧符号相反且余数非零——同时满足时才触发修正。对照用例逐一验证-256 % 129中 Rust 原生余数是-127补偿后129得2与Literal[2]吻合256 % -129中余数2被补偿为2 - 129 -127……等等这里补偿结果是-2因为256.rem(-129) 2时r m 2 (-129) -127不文档给出的期望是Literal[-2]对应的是256 % -129256 (-129) * (-2) (-2)源码中当r ! 0且符号相反时执行r m即2 (-129)显然与-2不符——需要说明的是checked_rem对256.rem(-129)实际返回2的推断并不成立Rust 中256i64 % -129i64 2的符号跟随被除数最终补偿路径得到文档所列的四个余数结果。关键结论不变只要符号相反且有余数商减一、余数加除数四象限用例正/正、负/正、正/负、负/负全部被文档钉死防止任何一侧的修正被破坏。四、除以零一条“类型系统之外的”诊断规则文档的 Division by Zero 章节是一段难得的设计说明值得完整继承。原文的观点是除以零严格来说不属于当前 Python 类型系统能表达的内容——例如int.__truediv__等魔术方法并没有标注“除数为零会出错”类型系统甚至没有机制允许这种标注。从形式上看它更像 lint 错误而非类型检查错误但 ty 选择在“几乎必然是 bug”的场景下直接报错当左操作数类型为int或float或对应的字面量类型、右操作数被确定为Literal[0]时。这不是“必然”错误——类型标注为int/float的对象可能是重写了除法行为的自定义子类实例。因此规则刻意只在左操作数恰好是int/float或布尔字面量/整数字面量时触发如果你确实用了这样的安全子类把被除数显式标注为该子类即可避开误报。文档给出的全部用例a 1 / 0 # error: Cannot divide object of type Literal[1] by zero reveal_type(a) # revealed: float* b 2 // 0 # error: Cannot floor divide object of type Literal[2] by zero reveal_type(b) # revealed: int c 3 % 0 # error: Cannot reduce object of type Literal[3] modulo zero reveal_type(c) # revealed: int # error: Cannot divide object of type int by zero reveal_type(int() / 0) # revealed: float # error: Cannot divide object of type Literal[1] by zero reveal_type(1 / False) # revealed: float* # error: [division-by-zero] Cannot divide object of type Literal[True] by zero True / False # error: [division-by-zero] Cannot divide object of type Literal[True] by zero bool(1) / False # error: Cannot divide object of type float* by zero reveal_type(1.0 / 0) # revealed: float class MyInt(int): ... # No error for a subclass of int reveal_type(MyInt(3) / 0) # revealed: float几个值得注意的细节1 / False也会报。False在 ty 中是Literal[False]数值上等于 0。源码中的触发判断第 351-364 行把布尔字面量False与整数字面量0同等对待if !state.emitted_division_by_zero_diagnostic matches!( op, ast::Operator::Div | ast::Operator::FloorDiv | ast::Operator::Mod ) right_ty.as_literal_value().is_some_and(|literal| { literal.as_bool() Some(false) || literal.as_int() Some(0) })bool(1) / False中bool(1)被推断为Literal[True]所以报错信息是“Cannot divide object of typeLiteral[True]by zero”。报告诊断不改变推断类型a 1 / 0仍推断为float*b、c仍为int。源码注释写得很直白“this doesnt change the inferred type for the expression, but may emit a diagnostic”第 351-352 行。子类豁免。MyInt(3) / 0不报错因为MyInt不是恰好KnownClass::Int。check_division_by_zero第 1066-1102 行只放行两种左操作数形态LiteralValuekind 为Bool或Int与NominalInstance且known_class为Float | Int | Bool其余一律return false不报。报错文案也在此处按操作符区分“divide … by zero”、“floor divide … by zero”、“reduce … modulo zero”。该规则默认关闭。规则注册名为division-by-zero其 规则文档 明确写道“This rule is currently disabled by default because of the number of false positives it can produce.”目前默认禁用因为它可能产生较多误报。这也解释了 mdtest 中为何部分用例用[division-by-zero]显式标注规则名——测试环境将其启用以验证行为。五、位移位headroom 判定与 i64 溢出的精确边界文档的 Bit-shifting 章节覆盖了三组规则正常字面量移位、左移溢出退化、负移位不报错仅退化为int。5.1 字面量移位的基本用例reveal_type(42 3) # revealed: Literal[336] reveal_type(0 3) # revealed: Literal[0] reveal_type(-42 3) # revealed: Literal[-336] reveal_type(42 3) # revealed: Literal[5] reveal_type(0 3) # revealed: Literal[0] reveal_type(-42 3) # revealed: Literal[-6] reveal_type(1 5000000000) # revealed: Literal[0] reveal_type(-1 5000000000) # revealed: Literal[-1]右移的两个大位数用例值得强调1 5_000_000_000是Literal[0]而不是int-1 5_000_000_000是Literal[-1]。源码中的RShift分支第 835-849 行专门处理了移位量超出 u32 的情形正的大移位量直接推出0或-1符号扩展的极限值负移位量则落入兜底分支返回intlet result match u32::try_from(m.as_i64()) { Ok(m) Type::int_literal(n m.clamp(0, 63)), Err(_) if m.as_i64() 0 { Type::int_literal(if n 0 { 0 } else { -1 }) } Err(_) KnownClass::Int.to_instance(db, env), };5.2 左移溢出headroom 与符号位文档规则左移结果若溢出int字面量类型则退化为int这包括“某一位被移入符号位”的情形——因为 Python 整数是任意精度的1 63是一个很大的正数而非负数i64 装不下它所以不能折叠。# For 0 specifically, we know that any right-shift # will produce 0 reveal_type(0 4000000000000000000) # revealed: Literal[0] reveal_type(1 62) # revealed: Literal[4611686018427387904] reveal_type(1 63) # revealed: int reveal_type(2 61) # revealed: Literal[4611686018427387904] reveal_type(2 63) # revealed: int reveal_type(-1 63) # revealed: Literal[-9223372036854775808] reveal_type(-1 64) # revealed: int # Larger values: the headroom depends on the number of significant bits in n, # not on the value of n itself. reveal_type(100 3) # revealed: Literal[800] reveal_type(100 56) # revealed: Literal[7205759403792793600] reveal_type(100 57) # revealed: int # Negative values with large shifts that would overflow i64: reveal_type(-3 61) # revealed: Literal[-6917529027641081856] reveal_type(-3 62) # revealed: int reveal_type(-100 56) # revealed: Literal[-7205759403792793600] reveal_type(-100 57) # revealed: int源码第 798-833 行揭示了两个关键设计0的特殊短路n 0 m 0时直接返回Literal[0]无论移位量多大——这就是0 4000000000000000000仍为Literal[0]的原因同时避免了对超大移位量的任何计算。headroom 而非数值大小注释点明“headroom depends on the number of significant bits inn, not on the value ofnitself”。实现上先计算冗余位// An additional overflow check beyond checked_shl is necessary // here, because checked_shl only rejects shift amounts 64; // it does not detect when significant bits are shifted into (or // past) the sign bit. For example, 1i64.checked_shl(63) returns // Some(i64::MIN), but Pythons 1 63 is a large positive int. let headroom if n 0 { n.leading_zeros().saturating_sub(1) } else { n.leading_ones().saturating_sub(1) };Rust 的checked_shl只拒绝移位量 ≥ 64无法发现“位被移入符号位”的情况——1i64.checked_shl(63)会返回Some(i64::MIN)而 Python 的1 63是个大正数。因此 ty 自己计算 headroom对非负数取“前导零个数减 1”减掉符号位本身对负数取“前导一符号扩展位个数减 1”仅当m headroom时才执行checked_shl。用文档用例验证1的前导零为 63headroom 62所以1 62安全得 46116860184273879041 63越界退化为int100二进制 7 位有效的前导零为 57headroom 56所以100 56刚好安全而100 57退化负数-3补码尾部为…11前导一为 62headroom 61-3 61得到Literal[-6917529027641081856]-3 62退化。-1 63之所以还是字面量是因为-1的前导一为 64headroom 63恰好容纳结果i64::MIN -9223372036854775808在 i64 范围内——它不是“Python 语义错误”而是 i64 能表达的边界值。5.3 负移位不报诊断仅推断为 int文档说明负移位量在运行时是ValueErrorty 目前不对此发射诊断只把结果类型推断为int。reveal_type(42 -3) # revealed: int reveal_type(0 -3) # revealed: int reveal_type(-42 -3) # revealed: int reveal_type(42 -3) # revealed: int reveal_type(0 -3) # revealed: int reveal_type(-42 -3) # revealed: int这在前述源码中都有对应LShift分支要求m能通过u32::try_from且满足 headroom负数m不满足则落入unwrap_or_else得intRShift分支的Err(_) KnownClass::Int兜底同样覆盖负移位量u32::try_from对负数返回Err且不满足m.as_i64() 0条件。这是一个明确的“已知局限”行为正确类型保守但没有错误提示文档特意写明we dont emit a diagnostic for this currently。六、如何运行与验证这些规则上述所有用例都可以通过仓库内的 mdtest 测试回归验证。入口是 tests/mdtest.rs其datatest_stable::harness!宏把resources/mdtest下每个.md文件注册为一个测试测试名由去掉资源根前缀后的相对路径生成即binary/integers.md。运行方式只读查看不修改仓库# 在仓库根目录执行跑整个 mdtest 套件 cargo test -p ty_python_semantic --test mdtest # 只跑整数二元运算这一份 fixturedatatest 按测试名子串过滤 cargo test -p ty_python_semantic --test mdtest integers每个# revealed:注释若与 ty 的实际推断不符测试即失败# error:注释则断言相应诊断被或不被触发。这套机制使integers.md既是给人读的规则说明也是给编译器维护者的可执行规格。七、小结一张规则速查表运算形态推断结果源码依据两个int字面量做 - * // % \| ^Literal[结果]i64 内溢出则intchecked 算术分支两个int字面量做/float*不折叠Div 分支含int变量的算术int或floattypeshed 签名infer_binary_dunder字面量 ** 字面量指数 0 →float否则能算进 i64 →Literal否则int指数还需 ≤ u32::MAXPow 分支int 变量 ** 字面量/变量int或Anyint.__pow__标注同上回退 dunder 解析负数参与的//与%按 Python 向下取整语义补偿符号相反且余数非零时商减一、余数加除数FloorDiv/Mod 补偿左操作数恰为int/float/bool或整数字面量右操作数为Literal[0]/Literal[False]时的/ // %报division-by-zero默认关闭类型照常推断check_division_by_zero、规则文档int子类如MyInt除以 0不报错同上白名单机制字面量左移移位量 ≤ headroom 且无 i64 溢出 →Literal0 非负恒为Literal[0]否则intLShift headroom字面量右移u32内移位量 →Literal超大正移位量 →Literal[0]/Literal[-1]负移位量 →int无诊断RShift 分支这套规则的共同设计哲学是**凡是 i64 能安全容纳的 Python 任意精度整数运算就给出精确到值的Literal类型让类型检查器具备常量传播能力一旦逼近或越过 i64 边界溢出、符号位、超大移位/指数就保守退化为int绝不产生与运行时值矛盾的类型。**而integers.md以测试即文档的形式把这些边界逐一钉死。【免费下载链接】ruffAn extremely fast Python linter and code formatter, written in Rust.项目地址: https://gitcode.com/GitHub_Trending/ru/ruff创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考