首页
/ Ruff(ty)类型检查器中的 `==` / `!=` 等式收窄(Equality Narrowing)完全指南

Ruff(ty)类型检查器中的 `==` / `!=` 等式收窄(Equality Narrowing)完全指南

2026-09-10 12:43:43作者:幸俭卉

导读

本文基于当前仓库中 ty 类型检查器的官方窄化(narrowing)测试规范文档 crates/ty_python_semantic/resources/mdtest/narrow/conditionals/eq.md 展开,系统讲解 ty 在 if x == y / if x != y 等条件分支中对操作数做类型收窄的完整语义:从最基础的 None、布尔值与字面量收窄,到枚举(Enum / IntEnum / StrEnum / Flag)、LiteralStringNewTypeAny、受限类型变量、元组与模块字面量的深度分析,以及 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_inequalityequality.rs):分别针对 ==!=,为 left 在给定分支(正/负)计算收窄约束,无法安全推导时返回 None
  • evaluate_type_comparisonequality.rs):统一入口,依次尝试 enum_literal_constraint(枚举字面量约束)、builtin_literal_constraint(内建字面量约束),最后落到 ComparisonEvaluator 递归求值;
  • ComparisonResult 枚举(equality.rs):AlwaysTrue / AlwaysFalse / CanNarrow(type) / Ambiguous 四种结果,其中 CanNarrow 明确区分"运行时结果未知但可以安全收窄"这一关键情形——这正是 ==/!= 收窄与普通布尔真值分析的本质差异;
  • ComparisonSoundnessPolicyequality.rs):由 AnalysisSettings.strict_equality_semantics 决定是否允许"不安全的等式假设"(allow_unsafe_equality),对应文档后文详述的配置开关。

1.2 求值管线

evaluate_comparison_onceequality.rs)展示了完整的求值顺序:

  1. 枚举比较 evaluate_enum_comparison(实现在 equality/enums.rs)——优先处理枚举域(enum domain)的投影与交集;
  2. 动态值比较 evaluate_dynamic_comparison——在逐个枚举成员之前先处理 Any,避免"把一个多成员枚举的所有成员逐个排除"导致的错误收窄;
  3. 有限值比较 evaluate_finite_comparison——把两侧展开为有限的候选值集合逐一比较;
  4. 结构比较 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 == 1False == 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_toequality.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,但静态类型 FinalIntLiteral[1] 不相交,因此收窄不会把 FinalInt 收成 Literal[1],两个分支都保持 FinalInt
  • 字面量两侧的语义匹配(Type::LiteralValue(left), Type::LiteralValue(right)) 分支通过 known_literal_equalityequality.rs)精确处理 int/bool/str/bytes 两两组合,语义不同(如 intstr)直接返回"必然不等"。

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_constraintequality.rs):当左操作数的所有可能值都属于 right 所在的同一个枚举域,且该枚举没有自定义 __eq__/__ne__ 时,直接生成"等于 right"的约束(不等分支再取反)。域检查由 is_same_enum_domainequality.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.mdStrEnum 跨类示例):

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 数据类型的强制转换也会被建模——str1 转换为 "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_memberknown_literal_equalityequality.rs):同域成员先解析到规范化成员,不同语义(如普通 EnumIntEnum)直接判定不等,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__ 替换声明值后(如 Shiftedvalue + 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.rsof_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. AnyUnknown 与渐变类型(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 相比、Anybool | None 相比,Any 同样保持不动。Color | AnyColor | None 比较时,ColorAny 两个分量都必须保留:

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_comparisonequality.rs)专门处理动态值:多成员枚举不逐成员排除,而单成员枚举可以排除该成员;其余动态场景一律 Ambiguous,这正是"Any 保持 Any"的机制来源。

Unknownty_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_unionequality.rs)在构造负约束前先检查幸存分支是否已与所有被拒绝分支不相交(narrowed.is_disjoint_from(...)),从而跳过冗余排除。


6. 跨类型语义消除:boolTypedDictLiteralString、模块与哨兵

6.1 已知内建相等行为(Known built-in equality behavior)

boolLiteralStringTypedDict 以及继承 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_semanticsequality.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_enumequality.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.rsIntersection + 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 特殊对象LiteralNamedTupleAnnotated[...]CallableList[int] 等)一律回退到其名义实例比较,结果为 booleq.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_comparisonNewTypeInstance 先以其具体基类求值再 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]MarkerProtocol);
  • 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) 会同时收窄 valueresult(后者为 Literal[True] / Literal[False]);被比较器重新绑定的变量(如 value := value.tagvalue := "a")也正确处理。


9. 配置开关:strict-equality-semanticsstrict-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.rsstrict_equality_semantics: Option<bool>);
  • ty 分析默认值在 crates/ty_python_semantic/src/lib.rsstrict_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_settingsequality.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_instancesequality.rs)与 TupleEqualityEvaluatorequality.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 != yy 为宽类型)无法收窄 xeq.md "!= for broad types":int | None 保持原样);
  • 字面量与宽类型混合(Literal[1, 2] vs int):结果仍 bool,收窄不发生(eq.md "Mix of literal and broad types");
  • 继承关系的类(Base vs Child):比较结果为 bool,但 == 分支仍可收窄到 Base(排除了 None);
  • 重叠类(Left vs Right,二者有共同子类 Shared):== 结果 bool,正向分支收窄为 Left
  • 自定义相等类(AlwaysEqual)不受内建语义支配:value == otherAlwaysEqual | None 保持不变。

元组、Sequencein 成员测试(value in [1, 2])遵循与 == 相同的语义配置。相关的有限值展开逻辑 finite_alternativesequality.rs)会在比较语义已知的前提下,把 bool、枚举实例、枚举补集、NewType 包裹的枚举展开为有限候选集合。


12. 如何运行这些用例与继续深入

12.1 文档本身即测试套件

eq.md 是 ty 的 mdtest 测试规范:文件内每个 ```py 代码块都是可执行的窄化测试,reveal_type(...) # revealed: ... 注释即期望结果,# error: [code] 注释即期望的诊断。相关基础设施位于:

文档中的 [environment] / [analysis] TOML 块是前置配置。例如:

[environment]
python-version = "3.12"

[analysis]
strict-equality-semantics = true

12.2 核心实现文件速查

12.3 实践建议

  • reveal_type 观察收窄:在 if / elif / else 分支内放置 reveal_type(x),对照"revealed"注释验证你的理解;
  • 新增测试用例:向 eq.md 追加一个 ```py 代码块即可作为回归测试运行——仓库的 mdtest 框架会将其解析为独立用例(这也是该文件本身作为文档+测试双重用途的原因);
  • 注意配置前提:涉及 strict-equality-semantics、Python 3.12 语法(PEP 695 type 别名)、ty_extensions 的用例,必须在代码块前正确声明 [environment] / [analysis],否则结果可能不同。

结语

== / != 等式收窄是 ty 类型检查器控制流分析中最精细的部分:它以 Python 运行时语义(整数-布尔相等、枚举别名、dunder 委托关系、NewType 身份擦除、渐变类型)为基准,在"能确定"与"必须保守"之间取得平衡,并通过 strict-equality-semantics 让用户在直觉性与健全性之间自由切换。本文所覆盖的每一条规则都可以在 eq.md 中找到可执行示例、在 equality.rsequality/enums.rs 中找到对应实现——阅读、运行、修改它们,是深入理解 ty 乃至整个 Python 类型收窄生态的最佳路径。

登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.16 K
2.78 K
kernelkernel
deepin linux kernel
C
34
18
docsdocs
暂无描述
Markdown
904
5.83 K
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
932
1.86 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
862
1.36 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.95 K
1.03 K
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.38 K
1.47 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
535
606
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
549
398
leetcodeleetcode
🔥LeetCode solutions in any programming language | 多种编程语言实现 LeetCode、《剑指 Offer(第 2 版)》、《程序员面试金典(第 6 版)》题解
Markdown
77
23