Ruff(ty)类型检查器中的 `==` / `!=` 等式收窄(Equality Narrowing)完全指南
导读
本文基于当前仓库中 ty 类型检查器的官方窄化(narrowing)测试规范文档 crates/ty_python_semantic/resources/mdtest/narrow/conditionals/eq.md 展开,系统讲解 ty 在 if x == y / if x != y 等条件分支中对操作数做类型收窄的完整语义:从最基础的 None、布尔值与字面量收窄,到枚举(Enum / IntEnum / StrEnum / Flag)、LiteralString、NewType、Any、受限类型变量、元组与模块字面量的深度分析,以及 strict-equality-semantics 配置项如何切换"直觉但可能不健全"与"保守健全"两种模式。读完本文,你将掌握 ty 等式收窄的判定规则、适用边界与配置方法,并能利用 reveal_type 验证收窄结果、为 ty 补充新的窄化测试用例。
1. 等式收窄的核心思想与总体架构
ty 是当前仓库中基于 Rust 实现的 Python 类型检查器(位于 crates/ty_python_semantic),其"窄化(narrowing)"逻辑负责在条件分支内根据运行时事实缩小变量的静态类型。等式/不等式收窄是该体系中最重要的一环,其目标可以概括为:
- 正向分支收窄(positive narrowing):
if x == y成立时,x被约束为所有"可能与y相等"的值; - 反向分支收窄(negative narrowing):
if x != y成立时,x被约束为所有"不可能与y相等"的值。
例如最基本的 None 判断(摘自 eq.md 开头):
from typing import Literal
def _(x: None | Literal[1]):
if x != None:
reveal_type(x) # revealed: Literal[1]
else:
reveal_type(x) # revealed: None
操作数颠倒时同样生效(None != x),== 的反向操作数形式也支持:
def _(x: None | Literal[1]):
if None != x:
reveal_type(x) # revealed: Literal[1]
else:
reveal_type(x) # revealed: None
def _(x: None | Literal[1]):
if None == x:
reveal_type(x) # revealed: None
else:
reveal_type(x) # revealed: Literal[1]
1.1 底层实现入口
从源码看,等式/不等式收窄的求值入口集中在 crates/ty_python_semantic/src/types/equality.rs:
evaluate_type_equality/evaluate_type_inequality(equality.rs):分别针对==与!=,为left在给定分支(正/负)计算收窄约束,无法安全推导时返回None;evaluate_type_comparison(equality.rs):统一入口,依次尝试enum_literal_constraint(枚举字面量约束)、builtin_literal_constraint(内建字面量约束),最后落到ComparisonEvaluator递归求值;ComparisonResult枚举(equality.rs):AlwaysTrue/AlwaysFalse/CanNarrow(type)/Ambiguous四种结果,其中CanNarrow明确区分"运行时结果未知但可以安全收窄"这一关键情形——这正是==/!=收窄与普通布尔真值分析的本质差异;ComparisonSoundnessPolicy(equality.rs):由AnalysisSettings.strict_equality_semantics决定是否允许"不安全的等式假设"(allow_unsafe_equality),对应文档后文详述的配置开关。
1.2 求值管线
evaluate_comparison_once(equality.rs)展示了完整的求值顺序:
- 枚举比较
evaluate_enum_comparison(实现在 equality/enums.rs)——优先处理枚举域(enum domain)的投影与交集; - 动态值比较
evaluate_dynamic_comparison——在逐个枚举成员之前先处理Any,避免"把一个多成员枚举的所有成员逐个排除"导致的错误收窄; - 有限值比较
evaluate_finite_comparison——把两侧展开为有限的候选值集合逐一比较; - 结构比较
evaluate_structural_comparison——兜底处理字面量、联合、交集、NewType、模块字面量、具名实例等结构。
整个求值通过 ComparisonEvaluator.active 集合检测递归比较(equality.rs),一旦发现"结果依赖于自身"就保守地返回 Ambiguous,从而保证递归类型别名场景下收窄必然终止。
2. 内建字面量收窄:str / int / bytes 与布尔-整数关系
2.1 字面量收窄的基本规则
与字面量相等会将宽泛的内建类型收窄到该字面量(eq.md 的 "Narrowing builtin types to literals" 一节):
def narrow_string(value: str):
if value == "a":
reveal_type(value) # revealed: Literal["a"]
else:
reveal_type(value) # revealed: str & ~Literal["a"]
def narrow_reversed_string(value: str):
if "a" == value:
reveal_type(value) # revealed: Literal["a"]
def narrow_integer(value: int):
if value == 1:
reveal_type(value) # revealed: Literal[1]
def narrow_bytes(value: bytes):
if value == b"a":
reveal_type(value) # revealed: Literal[b"a"]
def narrow_inequality_else(value: str):
if value != "a":
reveal_type(value) # revealed: str & ~Literal["a"]
else:
reveal_type(value) # revealed: Literal["a"]
注意反向收窄的表示法:不等分支并不会把 value 收窄为一个有限的字面量集合,而是记录为"排除 "a""(str & ~Literal["a"])。源码注释(equality.rs)解释了原因:若循环中后续把新的字面量赋给 x(例如 x = "D"),仅记录"当前候选集合"会让类型永远无法增长;记录"排除字面量"则既排除了不该出现的值,又允许后续新增候选。
2.2 布尔与整数的相等性(Python 语义)
Python 中 True == 1、False == 0,ty 完整继承了这一语义:
from typing import Literal
def _(b: bool, i: Literal[1, 2]):
if b == 1:
reveal_type(b) # revealed: Literal[True]
else:
reveal_type(b) # revealed: Literal[False]
if b == 6:
reveal_type(b) # revealed: Never
else:
reveal_type(b) # revealed: bool
if i == True:
reveal_type(i) # revealed: Literal[1]
else:
reveal_type(i) # revealed: Literal[2]
实现位于 builtin_literals_equal_to(equality.rs):整数字面量 0/1 会额外引入与之相等的布尔字面量,布尔字面量也会引入对应的整数字面量。因此 x != 0 实际排除的是 Literal[0] 与 Literal[False] 两个值;这一规则同时作用于 == 收窄与不等式排除。
枚举比较键同样会做这种归一化(eq.md 的 "Integer comparison keys normalize booleans" 一节):
from enum import Enum, IntEnum
class BooleanKey(int, Enum):
FALSE = False
class IntegerKey(IntEnum):
ZERO = 0
reveal_type(BooleanKey.FALSE == IntegerKey.ZERO) # revealed: Literal[True]
class IntegerAliases(IntEnum):
ZERO = 0
FALSE = False
reveal_type(IntegerAliases.ZERO == IntegerAliases.FALSE) # revealed: Literal[True]
2.3 收窄的乐观性边界
字面量收窄对"宽泛内建类型"是乐观的,但绝不越界:
- 子类被保留(eq.md "The narrowing only treats the broad builtin types optimistically"):
class StringSubclass(str): ...
class AlwaysEqual:
def __eq__(self, other: object) -> bool:
return True
def preserve_subclass(value: StringSubclass):
if value == "a":
reveal_type(value) # revealed: StringSubclass
def preserve_custom_comparison(value: str | AlwaysEqual):
if value == "a":
reveal_type(value) # revealed: Literal["a"] | AlwaysEqual
final标量子类不参与字面量收窄(eq.md "Final subclasses of scalar builtins"):@final class FinalInt(int)的实例可以等于1,但静态类型FinalInt与Literal[1]不相交,因此收窄不会把FinalInt收成Literal[1],两个分支都保持FinalInt;- 字面量两侧的语义匹配:
(Type::LiteralValue(left), Type::LiteralValue(right))分支通过known_literal_equality(equality.rs)精确处理 int/bool/str/bytes 两两组合,语义不同(如int对str)直接返回"必然不等"。
3. 枚举比较收窄:ty 的重头戏
枚举(Enum)是等式收窄最复杂、也最能体现 ty 能力的领域。eq.md 用超过三分之二的篇幅系统覆盖了枚举的各种形态。
3.1 基础:单例与非单例枚举
同域枚举成员之间的 == / != 会把操作数收窄到对应成员:
from enum import Enum
from typing import Literal
class Answer(Enum):
NO = 0
YES = 1
def _(answer: Answer):
if answer != Answer.NO:
reveal_type(answer) # revealed: Literal[Answer.YES]
else:
reveal_type(answer) # revealed: Literal[Answer.NO]
实现入口为 enum_literal_constraint(equality.rs):当左操作数的所有可能值都属于 right 所在的同一个枚举域,且该枚举没有自定义 __eq__/__ne__ 时,直接生成"等于 right"的约束(不等分支再取反)。域检查由 is_same_enum_domain(equality.rs)完成,它递归遍历类型别名、联合、NewType、交集与枚举补集,并借助 ActiveRecursionDetector 保证递归类型别名下不无限展开。
单一成员枚举在"与 int 联合"场景下也能精确收窄:
class Single(Enum):
VALUE = 1
def _(x: Single | int):
if x != Single.VALUE:
reveal_type(x) # revealed: int
else:
reveal_type(x) # revealed: Single
3.2 跨枚举类比较:比较键(comparison key)投影
当两个操作数分属不同枚举类时,结果取决于枚举的比较语义:
- 普通
Enum按身份比较,不同类的成员即使值相同也不相等; IntEnum继承整数的值相等性,StrEnum继承字符串的值相等性;- 枚举内定义的标量 mixin(如
class IntMember(int, Enum))同样按标量值比较。
ty 的实现引入"枚举键投影(key projection)"来避免逐一比较每一个成员组合:EnumKeyProjection 将每个枚举域投影为比较键集合,再判断两侧投影是否相交(equality/enums.rs)。这正是 eq.md "The same comparison-key projection applies when each operand spans several enum classes" 一节的来源——该节示例左右两侧各含 18 个可能值,若逐一配对需 324 次比较,投影方法则直接得到交集结果:
class MixedLeft0(IntEnum): # A=0 ... I=8
...
class MixedLeft1(IntEnum): # A=9 ... I=17
...
class MixedRight0(IntEnum): # A=0 ... I=8
...
class MixedRight1(IntEnum): # A=18 ... I=26
...
def compare_mixed_domains(left: MixedLeft0 | MixedLeft1, right: MixedRight0 | MixedRight1):
if left == right:
reveal_type(left) # revealed: MixedLeft0
reveal_type(right) # revealed: MixedRight0
跨域收窄的典型规则(eq.md 的 StrEnum 跨类示例):
class Left(StrEnum):
A = "a"
SHARED = "shared"
C = "c"
class Right(StrEnum):
SHARED = "shared"
B = "b"
D = "d"
reveal_type(Left.SHARED == Right.SHARED) # revealed: Literal[True]
reveal_type(Left.A == Right.B) # revealed: Literal[False]
def compare_domains(left: Left, right: Right):
if left == right:
reveal_type(left) # revealed: Literal[Left.SHARED]
reveal_type(right) # revealed: Literal[Right.SHARED]
else:
reveal_type(left) # revealed: Left
reveal_type(right) # revealed: Right
3.3 两域无交集:== 恒为假、!= 恒为真
当两侧候选值没有匹配项时,比较结果可以直接判定:
def compare_disjoint_cross_enum_alternatives(
left: Literal[Left.A] | None,
disjoint: Literal[Right.B] | Literal[1],
overlapping: Literal[Right.B] | None,
):
reveal_type(left == disjoint) # revealed: Literal[False]
reveal_type(left != disjoint) # revealed: Literal[True]
reveal_type(left == overlapping) # revealed: bool # 共享 None 使结果不确定
反之,当所有可能值都匹配时:
def compare_matching_cross_enum_alternatives(
left: Literal[Left.SHARED] | Literal["shared"],
right: Literal[Right.SHARED],
):
reveal_type(left == right) # revealed: Literal[True]
reveal_type(left != right) # revealed: Literal[False]
3.4 别名检测:运行时值决定成员等价
ty 能识别"拥有相同已知运行时值"的成员为别名(即使值来自函数调用):
def make_value() -> Literal["value"]:
return "value"
class RuntimeAlias(StrEnum):
FIRST = make_value()
SECOND = "value"
reveal_type(RuntimeAlias.FIRST == RuntimeAlias.SECOND) # revealed: Literal[True]
str 数据类型的强制转换也会被建模——str 将 1 转换为 "1",因此以下两个成员也是别名:
class CoercingAlias(str, Enum):
FIRST = 1
SECOND = "1"
reveal_type(CoercingAlias.FIRST == CoercingAlias.SECOND) # revealed: Literal[True]
reveal_type(CoercingAlias.SECOND == "1") # revealed: Literal[True]
而当别名检测不充分时(如 class OpaqueAliases(Behavior, Enum),运行时是别名但静态无法证明),ty 保守地给出 bool。别名逻辑位于 same_enum_member 与 known_literal_equality(equality.rs):同域成员先解析到规范化成员,不同语义(如普通 Enum 对 IntEnum)直接判定不等,Object 身份语义直接判定"不同成员必然不等"。
3.5 边界情形:Flag / IntFlag、_missing_、自定义元类
Flag/IntFlag允许零值与未命名组合,命名成员不覆盖所有可能值,因此同类型比较保持bool:
class Permission(Flag):
READ = 1
def compare_flags(left: Permission, right: Permission):
reveal_type(left == right) # revealed: bool
if left != right:
reveal_type(left) # revealed: Permission
- 自定义
_missing_不改变静态成员集合:只有一个声明成员的枚举仍是单例,比较恒为真;但自定义_missing_也不影响比较收窄本身。 - 自定义元类注入成员(如
InjectingEnumMeta在__new__中写入namespace["INJECTED"] = 2)会让枚举真正"开放":两个值未必相等,同类型比较退化为bool。 - 自定义
__eq__/__ne__必须被尊重:定义__eq__返回Literal[False]的枚举,成员之间==恒为Literal[False];若该 dunder 有确定的返回类型,ty 会直接采用其返回值(equality.rs 通过try_call_dunder_with_policy调用 dunder 求值)。 - 未知的成员值:成员值来自非字面量函数调用时,两个不同成员可能相等,比较结果保守为
bool。 __new__变换值:自定义__new__替换声明值后(如Shifted将value + 1存为成员值),ty 仍能收窄运行时值已知的枚举(Foo),但必须整体保留运行时值无法静态确定的枚举(Shifted)。_value_注解:显式_value_: int只影响公开的.value类型,不抹去具体比较载荷(AnnotatedInteger.ONE == 1仍为Literal[True]);但自定义构造器变换成员时,注解无法描述继承比较方法使用的标量载荷。
4. == / != 的 dunder 语义独立性与自定义比较方法
ty 明确要求 == 与 != 分别遵循各自 dunder 的语义(eq.md "== and != must use the semantics of their respective dunder methods"):
class IndependentEquality(Enum):
NO = 0
YES = 1
def __ne__(self, other: object) -> bool:
return True
def _(answer: IndependentEquality):
# 自定义 __ne__ 不影响基于 __eq__ 的收窄
if answer == IndependentEquality.NO:
reveal_type(answer) # revealed: Literal[IndependentEquality.NO]
else:
reveal_type(answer) # revealed: Literal[IndependentEquality.YES]
# 自定义 __ne__ 使其结果不可预测,!= 分支无法收窄
if answer != IndependentEquality.NO:
reveal_type(answer) # revealed: IndependentEquality
else:
reveal_type(answer) # revealed: IndependentEquality
对称地,自定义 __eq__ 同时影响两个运算符——因为默认的 __ne__ 委托给 __eq__(equality.rs 中 of_instance 在 __ne__ 未定义时回退查找 __eq__)。
ty 从不检查用户自定义比较方法的函数体来预测结果(eq.md "Comparisons with user-defined methods"):
class Left:
def __eq__(self, other: object) -> bool:
return True
class Right:
def __eq__(self, other: object) -> bool:
return False
def _(value: Right | None):
if Left() == value:
reveal_type(value) # revealed: Right | None
else:
reveal_type(value) # revealed: Right | None
但当自定义比较方法来自 mixin、且与内建类型通过 isinstance 相交时,ty 仍会保留 mixin 的存在,避免错误地以内建比较为权威(eq.md "Custom comparison methods also remain visible")。此外,enum_literal_constraint 在枚举定义了或继承了自定义 __eq__/__ne__ 时会被禁用(equality.rs 的文档注释),转而走更保守的通用求值路径。
5. Any、Unknown 与渐变类型(Gradual Typing)的处理
Any 是等式收窄中最容易出错的类型:它既能匹配任何值,又必须保持自身的动态性。ty 的核心规则是**Any 与枚举比较时两侧都保持 Any**(eq.md "Any must stay Any when compared with an enum"):
def enum_against_any(value: Color, other: Any):
if value != other:
reveal_type(other) # revealed: Any
def any_against_enum(value: Any, other: Color):
if value != other:
reveal_type(value) # revealed: Any
可选枚举与 Any 相比、Any 与 bool | None 相比,Any 同样保持不动。Color | Any 与 Color | None 比较时,Color 与 Any 两个分量都必须保留:
def gradual_enum_union(value: Color | Any, other: Color | None):
if value != other:
reveal_type(value) # revealed: Color | Any
但 Any 也参与有限排除:与单个枚举成员或可判定值的比较可以产生负约束(x != Color.RED 得到 Any & ~Literal[Color.RED])。实现上,evaluate_dynamic_comparison(equality.rs)专门处理动态值:多成员枚举不逐成员排除,而单成员枚举可以排除该成员;其余动态场景一律 Ambiguous,这正是"Any 保持 Any"的机制来源。
Unknown(ty_extensions._internal.Unknown)与 Any 行为一致(eq.md "The same comparisons also preserve Unknown"),AnyAlias: TypeAlias = Any 也保持相同结果。
字符串字面量与 Any 的相交联合(Intersection[Any, Literal["a"]])可以精确收窄:
def equality(value: Intersection[Any, Literal["a"]] | Intersection[Any, Literal["b"]]):
if value == "a":
reveal_type(value) # revealed: Any & Literal["a"]
else:
reveal_type(value) # revealed: Any & Literal["b"]
ty 还专门优化了大联合场景(eq.md "Larger unions must narrow without expanding the complement of every rejected alternative"):当联合包含 20 个 Any & Literal[...] 分支时,不能为每个被拒绝分支展开补集,否则内存会指数级膨胀。实现上,evaluate_target_union(equality.rs)在构造负约束前先检查幸存分支是否已与所有被拒绝分支不相交(narrowed.is_disjoint_from(...)),从而跳过冗余排除。
6. 跨类型语义消除:bool、TypedDict、LiteralString、模块与哨兵
6.1 已知内建相等行为(Known built-in equality behavior)
bool、LiteralString、TypedDict 以及继承 object.__eq__ 的 final 类具有已知的相等行为;两个行为相同的值比较时可以消除不相交的联合元素(eq.md "Known built-in equality behavior"):
class Payload(TypedDict):
value: int
@final
class A: ...
@final
class B: ...
def narrow_final_object_equality(value: A | B, other: A):
if value == other:
reveal_type(value) # revealed: A
if value != other:
reveal_type(value) # revealed: A | B
else:
reveal_type(value) # revealed: A
相反,继承不同内建相等实现的 final 类不可能相等:
@final
class FinalObject: ...
@final
class FinalInt(int): ...
def narrow_different_equality_implementations(value: FinalObject | FinalInt, other: FinalObject):
if value == other:
reveal_type(value) # revealed: FinalObject
这一规则由 KnownComparisonSemantics 实现(equality.rs):Object / Int / Str / Bytes / Tuple / Dict 六种已知语义,通过 dunder 查找与 same_member_implementation 判断类型继承了哪一种内建实现;compare_different_semantics(equality.rs)在语义不同时判定必然不等(None 特判为必然不等)。
6.2 LiteralString 与字符串枚举
LiteralString 可以被继承了 str 相等实现的字符串枚举成员收窄(eq.md "LiteralString and string-valued enums"):
class Color(StrEnum):
RED = "red"
def narrow_literal_string_with_enum(value: LiteralString | None):
if value == Color.RED:
reveal_type(value) # revealed: Literal["red"]
else:
reveal_type(value) # revealed: (LiteralString & ~Literal["red"]) | None
实现位于 narrow_literal_string_against_enum(equality.rs),前提是该枚举成员的比较语义确认为 Str 且其运行时可静态获取。
6.3 字符串字面量来源与排除(origin)
字符串字面量的"来源"(是否具有 literal origin)会影响排除语义(eq.md "String-literal origin and exclusions"):
from typing_extensions import LiteralString
from ty_extensions import Intersection, Not
def without_literal_origin(value: Intersection[str, Not[LiteralString]]) -> None:
if value == "hello":
reveal_type(value) # revealed: str & ~LiteralString
def trusted_value_is_excluded(value: Intersection[LiteralString, Not[Literal["hello"]]]) -> None:
if value == "hello":
reveal_type(value) # revealed: Never
一个"没有 literal origin 的字符串"与字面量相等时,并不会获得该字面量的来源,成功分支仍可达、原有排除保持;而一旦字面量来源已知,排除一个字符串字面量确实排除了其运行时值(Never)。对应实现是 equality.rs 的 Intersection + LiteralString 特判分支。
6.4 模块字面量、哨兵与 typing 特殊对象
- 模块只与同一个模块对象相等(eq.md "Module literals"):
def narrow_module_literal(flag: bool):
value = sys if flag else typing
if value == sys:
reveal_type(value) # revealed: <module 'sys'>
else:
reveal_type(value) # revealed: <module 'typing'>
- 哨兵(
Sentinel)是单例,必然等于自身(MISSING == MISSING揭示为Literal[True]); - typing 特殊对象(
Literal、NamedTuple、Annotated[...]、Callable、List[int]等)一律回退到其名义实例比较,结果为bool(eq.md "Known typing-object equality behavior")。原因是 ty 对这些 API 做了大量特判,同一静态类型并不保证同一运行时对象:例如Annotated[int, "a"] == Annotated[int, "b"]静态类型相同但运行时必然不等,因此保守地不推断其相等性。
7. 受限类型变量、NewType 与递归别名
7.1 受限类型变量(constrained TypeVar)
相等性分析会把受限类型变量的约束(constraints)展开到任一操作数位置,并将结果约束与类型变量本身相交,保留其身份(eq.md "Constrained type variables"):
T = TypeVar("T", ConstraintA, ConstraintB)
def constrained_left(value: T | None, other: ConstraintA):
if value != other:
pass
else:
reveal_type(value) # revealed: T@constrained_left & ConstraintA
def constrained_right(value: ConstraintA | None, other: T):
if value != other:
pass
else:
reveal_type(value) # revealed: ConstraintA
ty 还支持关联类型变量(correlated TypeVar)的收窄,即在 value == other 时把 value 收窄为 EnumT@correlated_typevar_eq 等与类型变量相关的类型。实现上,equality.rs 会检查"右操作数是受限 TypeVar 且每个约束都满足相等收窄"这一前置条件,避免过早展开而丢失相关性。
7.2 NewType 的身份擦除
NewType 构造器在运行时原样返回参数,因此 WrappedIdentityEnum 的值既可能是 A 也可能是 B,与成员比较结果未知(eq.md "Narrowing with NewTypes"):
WrappedIdentityEnum = NewType("WrappedIdentityEnum", IdentityEnum)
def literal_with_erased_identity(value: WrappedIdentityEnum) -> None:
reveal_type(IdentityEnum.A == value) # revealed: bool
reveal_type(IdentityEnum.A != value) # revealed: bool
但"已与字面量相交的 NewType"可以参与收窄,并保持 NewType 身份:
def compare_branded_member(
branded: Intersection[WrappedIdentityEnum, Literal[IdentityEnum.B]],
other: IdentityEnum,
) -> None:
if branded == other:
reveal_type(branded) # revealed: WrappedIdentityEnum & Literal[IdentityEnum.B]
reveal_type(other) # revealed: Literal[IdentityEnum.B]
else:
reveal_type(other) # revealed: Literal[IdentityEnum.A]
NewType 不改变 IntEnum 的值比较方式,也不改变自定义 __eq__ 的决定性。实现上,evaluate_structural_comparison 对 NewTypeInstance 先以其具体基类求值再 discard_narrowing()(equality.rs)——保留确定性真值但丢弃可能破坏 NewType 静态身份的窄化约束。
7.3 递归别名与终止性
无效的递归枚举别名仍会使用其非递归成员(eq.md "Recursive aliases containing enum domains"):
type Recursive = EnumValue | Recursive # error: [cyclic-type-alias-definition]
def _(left: Recursive, right: EnumValue):
reveal_type(left == right) # revealed: bool
BrandedEnumValue = NewType("BrandedEnumValue", EnumValue)
type RecursiveBrand = BrandedEnumValue | RecursiveBrand # error: [cyclic-type-alias-definition]
def compare_recursive_brand_to_member(left: RecursiveBrand) -> None:
if left == EnumValue.VALUE:
reveal_type(left) # revealed: BrandedEnumValue & Literal[EnumValue.VALUE]
else:
reveal_type(left) # revealed: BrandedEnumValue & Literal[EnumValue.OTHER]
递归别名如果带变化的类型参数(如 type Changing[T] = T | Changing[bool]),可能引入原枚举域之外的值(True 与整数值成员相等),因此 bool 备选必须保持可达。互递归别名同样可能引入域外值。而在"递归序列别名包含渐变键(gradual key)的映射"时,等式收窄必须终止(eq.md "Recursive aliases containing gradual generic branches"):
type RecursiveMappingKey = Sequence[RecursiveMappingKey] | Mapping[Any, int]
def narrow_recursive_mapping_key(value: RecursiveMappingKey) -> None:
assert value == 0
_ = value
终止性由 ComparisonEvaluator.active 递归检测保证(equality.rs):重复的比较键返回 Ambiguous,避免无限展开;is_same_enum_domain 中的 ActiveRecursionDetector 则防止域检查自身循环。
8. 标签联合(tagged union)与属性收窄
ty 支持通过比较对象的字面量标签属性来收窄标签联合(eq.md "Narrowing tagged unions by attribute"):
class BaseA:
tag: Literal["a"]
class A(BaseA):
field_a: int
class B:
tag: Literal["b"]
field_b: str
def _(x: A | B):
if x.tag == "a":
reveal_type(x) # revealed: A
reveal_type(x.field_a) # revealed: int
else:
reveal_type(x) # revealed: B
reveal_type(x.field_b) # revealed: str
if x.tag != "a":
reveal_type(x) # revealed: B
else:
reveal_type(x) # revealed: A
支持场景包括:
- 反向操作数(
"b" == x.tag); - 多值标签(
tag: Literal["c", 1]):与某个值比较时,其他值候选保持在 else 分支; - 真值守卫前置:
if not value: return之后再做标签比较,收窄结果带有~AlwaysFalsy约束; - 嵌套属性(
container.value.tag); - 相交联合(
Intersection[A, Marker] | Intersection[B, Marker],Marker是Protocol); - Protocol 联合(
TaggedA | TaggedB,标签通过@property声明为Literal); - 枚举字面量作为标签(
tag: Literal[Tag.A]); NamedTuple(既支持x.tag == "a"也支持x[0] == "a");- 非字面量标签分支保留:
tag: str的类在正向收窄时保留(A | B),因为str分支可能满足tag == "a";但 else 分支只保留B | C。
此外还支持**赋值表达式(walrus)**参与收窄(eq.md "Assignment expressions"):if (x := f()) != 1 之后 x 在正/负分支分别揭示为 Literal[2, 3] 与 Literal[1];if result := (value == 1) 会同时收窄 value 与 result(后者为 Literal[True] / Literal[False]);被比较器重新绑定的变量(如 value := value.tag、value := "a")也正确处理。
9. 配置开关:strict-equality-semantics 与 strict-literal-narrowing
9.1 两种模式的区别
ty 默认(strict-equality-semantics = false)采用"符合多数 Python 程序员直觉、但某些场景不健全"的等式假设;开启该选项后切换到保守模式。核心差异(eq.md "Integers and booleans with strict/non-strict equality semantics"):
[analysis]
strict-equality-semantics = false
reveal_type(1 == True) # revealed: Literal[True]
def f(x: int, y: Literal[1, True, 2]):
if x == 1:
reveal_type(x) # revealed: Literal[1] # 非严格:宽 int 收窄为字面量
if y == 1:
reveal_type(y) # revealed: Literal[1, True] # 显式字面量联合保留相等布尔
[analysis]
strict-equality-semantics = true
def f(x: int, y: Literal[1, True, 2]):
if x == 1:
reveal_type(x) # revealed: int # 严格:宽 int 保持不变
if y == 1:
reveal_type(y) # revealed: Literal[1, True] # 显式联合仍收窄到相等字面量
简而言之:严格模式保留宽泛内建类型与可能相等的联合备选(包括元组),同时仍然安全地收窄字面量联合与枚举成员。x in [1, 2] 的成员测试行为与 == 一致。
9.2 配置出处与默认值
- 配置项定义于 crates/ty_project/src/metadata/options.rs(
strict_equality_semantics: Option<bool>); - ty 分析默认值在 crates/ty_python_semantic/src/lib.rs:
strict_equality_semantics: false; - 选项的官方说明见 crates/ty/docs/configuration.md:默认
false,开启后 ty 对等式检查的类型推断与收窄更保守,更少推断Literal[True]/Literal[False]作为比较结果,进而减少收窄机会、对控制流采用更保守的假设。文档给出了典型的"不健全"场景:str子类实例(如StringSubclass("a"))与"a"比较相等,却并非Literal["a"]的成员,因此默认的if value == "a"收窄到Literal["a"]并不健全:
def parse(value: str) -> Literal["a"] | None:
# 开启 strict-equality-semantics = true 后这里不再收窄,
# 返回语句会触发类型错误
if value == "a":
return value
return None
StrEnum 成员同理(MyEnum.A == "a" 为 True)。
从实现看,该开关通过 ComparisonSoundnessPolicy::from_analysis_settings(equality.rs)注入求值管线:默认模式下 allow_unsafe_equality = true,允许 compare_literal_to_other 把宽泛 int/str/bytes 视为"只有该字面量本身能相等"(equality.rs),并允许对非 final 类假设子类不会覆写 dunder(equality.rs);严格模式关闭这两条捷径。
9.3 别名配置项
strict-literal-narrowing 仍是 strict-equality-semantics 的别名(eq.md "The strict literal narrowing alias"):
[analysis]
strict-literal-narrowing = true
def union(value: Foo | None, other: Foo):
if value == other:
reveal_type(value) # revealed: Foo | None # 严格模式下保持原联合
def literal(value: str):
if value == "a":
reveal_type(value) # revealed: str # 严格模式下宽 str 不收窄
在配置文件中写入二者之一即可,效果一致。
10. 元组与序列的相等性收窄
ty 假设元组子类不会覆写 tuple.__eq__(其仅对其它元组返回 True),因此在与元组值比较时排除不相交的非元组候选(eq.md "Narrowing with tuple types"):
def _(x: Literal["a", "b"] | tuple[int, int]):
if x == "a":
reveal_type(x) # revealed: Literal["a"] # 元组与字符串字面量不相交,被排除
else:
reveal_type(x) # revealed: Literal["b"] | tuple[int, int]
固定长度元组逐元素比较:不同长度必然不等;元素使用身份先于相等,因此即使元素静态类型不同也可能得出确定结果;每个元素还支持非自反的自定义 __eq__(返回 Literal[False] 的元素在"同一变量自比较"时因身份而恒真,但不同变量比较时结果不确定)。实现位于 compare_nominal_instances(equality.rs)与 TupleEqualityEvaluator(equality.rs),后者复用了活动比较集分配,并按"身份不能把真结果变假"的原则处理元组元素。
Sequence[object] 可以是空元组,因此 value == () 的相等分支保持可达、其中的错误照常报告;真值序列与字面量比较("x" == text)也不会让相等分支不可达(eq.md "Comparing truthy sequences with literals")。
11. 联合收窄与宽类型比较的假设
ty 在与宽类型比较时,假设其子类不会覆写相等方法,从而允许移除比较语义不兼容的联合成员(eq.md "Narrowing unions and inferring comparisons against broad types"):
def strings(value: str | None, other: str):
reveal_type(None == other) # revealed: Literal[False]
reveal_type(None != other) # revealed: Literal[True]
if value == other:
reveal_type(value) # revealed: str
else:
reveal_type(value) # revealed: str | None
基础规则是:
None与任意非可选值比较:==恒假、!=恒真;- 宽类型右侧(
x != y中y为宽类型)无法收窄x(eq.md "!=for broad types":int | None保持原样); - 字面量与宽类型混合(
Literal[1, 2]vsint):结果仍bool,收窄不发生(eq.md "Mix of literal and broad types"); - 继承关系的类(
BasevsChild):比较结果为bool,但==分支仍可收窄到Base(排除了None); - 重叠类(
LeftvsRight,二者有共同子类Shared):==结果bool,正向分支收窄为Left; - 自定义相等类(
AlwaysEqual)不受内建语义支配:value == other后AlwaysEqual | None保持不变。
元组、Sequence 与 in 成员测试(value in [1, 2])遵循与 == 相同的语义配置。相关的有限值展开逻辑 finite_alternatives(equality.rs)会在比较语义已知的前提下,把 bool、枚举实例、枚举补集、NewType 包裹的枚举展开为有限候选集合。
12. 如何运行这些用例与继续深入
12.1 文档本身即测试套件
eq.md 是 ty 的 mdtest 测试规范:文件内每个 ```py 代码块都是可执行的窄化测试,reveal_type(...) # revealed: ... 注释即期望结果,# error: [code] 注释即期望的诊断。相关基础设施位于:
- crates/mdtest(解析与断言)、crates/ruff_mdtest(mdtest 的 Ruff 集成);
- 测试数据库与配置装载:crates/ty_test/src/db.rs、crates/ty_test/src/config.rs;
- 相邻主题文档(同目录下):boolean.md、is.md、is_not.md、in.md、nested.md、not.md 等。
文档中的 [environment] / [analysis] TOML 块是前置配置。例如:
[environment]
python-version = "3.12"
[analysis]
strict-equality-semantics = true
12.2 核心实现文件速查
- 等式收窄主逻辑:crates/ty_python_semantic/src/types/equality.rs
- 枚举比较(域投影、比较键):crates/ty_python_semantic/src/types/equality/enums.rs
- 配置项定义:crates/ty_project/src/metadata/options.rs
- 选项官方文档:crates/ty/docs/configuration.md
12.3 实践建议
- 用
reveal_type观察收窄:在if/elif/else分支内放置reveal_type(x),对照"revealed"注释验证你的理解; - 新增测试用例:向
eq.md追加一个```py代码块即可作为回归测试运行——仓库的 mdtest 框架会将其解析为独立用例(这也是该文件本身作为文档+测试双重用途的原因); - 注意配置前提:涉及
strict-equality-semantics、Python 3.12 语法(PEP 695type别名)、ty_extensions的用例,必须在代码块前正确声明[environment]/[analysis],否则结果可能不同。
结语
== / != 等式收窄是 ty 类型检查器控制流分析中最精细的部分:它以 Python 运行时语义(整数-布尔相等、枚举别名、dunder 委托关系、NewType 身份擦除、渐变类型)为基准,在"能确定"与"必须保守"之间取得平衡,并通过 strict-equality-semantics 让用户在直觉性与健全性之间自由切换。本文所覆盖的每一条规则都可以在 eq.md 中找到可执行示例、在 equality.rs 与 equality/enums.rs 中找到对应实现——阅读、运行、修改它们,是深入理解 ty 乃至整个 Python 类型收窄生态的最佳路径。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust4.21 K637- DDeepSeek-V4.1-FlashDeepSeek-V4.1-Flash 是一个多模态混合专家(MoE)模型,拥有 5520 亿骨干参数,并支持最多一百万 token 的上下文长度。该模型原生支持图像和文本输入,并以自回归方式生成文本Python270
cherry-studio🍒 Cherry Studio 是一款支持多个 LLM 提供商的桌面客户端TypeScript2 K146
hello-agents📚 《从零开始构建智能体》——从零开始的智能体原理与实践教程Python46066
new-apiAI模型聚合管理中转分发系统,一个应用管理您的所有AI模型,支持将多种大模型转为统一格式调用,支持OpenAI、Claude、Gemini等格式,可供个人或者企业内部管理与分发渠道使用。🍥 A Unified AI Model Management & Distribution System. Aggregate all your LLMs into one app and access them via an OpenAI-compatible API, with native support for Claude (Messages) and Gemini formats.Go20143
JeecgBoot🔥企业级低代码平台集成了AI应用平台,帮助企业快速实现低代码开发和构建AI应用!前后端分离架构 SpringBoot,SpringCloud、Mybatis,Ant Design4、 Vue3.0、TS+vite!强大的代码生成器让前后端代码一键生成,无需写任何代码! 引领AI低代码开发模式: AI生成->OnlineCoding-> 代码生成-> 手工MERGE,显著的提高效率,又不失灵活~Java34051