Mypy 类型收窄(Type Narrowing)完全指南:从 isinstance 到 TypeGuard 与 TypeIs 的进阶实战
Mypy 类型收窄Type Narrowing完全指南从 isinstance 到 TypeGuard 与 TypeIs 的进阶实战【免费下载链接】mypyOptional static typing for Python项目地址: https://gitcode.com/GitHub_Trending/my/mypy类型收窄Type narrowing是让类型检查器相信一个宽泛类型实际上更具体的技术——例如把Shape收窄为Square。本文以 mypy 官方文档 type_narrowing.rst 为主线系统讲解 mypy 支持的四大类收窄手段内建收窄表达式isinstance/issubclass/type/callable/None判断、cast强制转换、PEP 647 的用户自定义TypeGuard以及 PEP 742 的TypeIs并深入 mypy 源码揭示其底层实现原理控制流分析器binder与checker的配合帮助读者写出类型更安全、更精确的 Python 代码。什么是类型收窄类型收窄是指让类型检查器相信一个更宽泛的类型实际上更加具体。例如一个类型为Shape的对象实际上可能是更窄的类型Square。mypy 提供了以下四类类型收窄手段类型收窄表达式type narrowing expressions基于isinstance、issubclass、type、callable、is not None等内建判断进行收窄强制转换casts通过typing.cast告诉检查器某个值的类型用户自定义类型守卫User-Defined Type GuardsPEP 647通过TypeGuard让自定义函数参与条件收窄TypeIsPEP 742通过TypeIs让自定义函数同时在if与else两个分支收窄行为更接近内建的isinstance。类型收窄表达式最简单的收窄方式是使用下列受支持的内建表达式isinstance(obj, float)—— 将obj收窄为float类型issubclass(cls, MyClass)—— 将cls收窄为Type[MyClass]type(obj) is int—— 将obj收窄为int类型callable(obj)—— 将对象收窄为可调用类型obj is not None—— 将对象收窄为其非可选形式。收窄是上下文相关的类型收窄是上下文相关的。例如基于条件的不同mypy 只会在if分支内部收窄表达式def function(arg: object): if isinstance(arg, int): # Type is narrowed within the if branch only reveal_type(arg) # Revealed type: builtins.int elif isinstance(arg, str) or isinstance(arg, bool): # Type is narrowed differently within this elif branch: reveal_type(arg) # Revealed type: builtins.str | builtins.bool # Subsequent narrowing operations will narrow the type further if isinstance(arg, bool): reveal_type(arg) # Revealed type: builtins.bool # Back outside of the if statement, the type isnt narrowed: reveal_type(arg) # Revealed type: builtins.object注意最后一处reveal_type一旦离开if语句块类型就恢复为原始声明object不再保留分支内的收窄结果。return 与异常提前退出也参与收窄mypy 能理解return或抛出异常对类型可能性的影响。如果某个分支提前返回那么后续代码中该类型就会被排除def function(arg: int | str): if isinstance(arg, int): return # arg cant be int at this point: reveal_type(arg) # Revealed type: builtins.strassert 收窄我们同样可以用assert在同一上下文中收窄类型def function(arg: Any): assert isinstance(arg, int) reveal_type(arg) # Revealed type: builtins.int注意--warn-unreachable与不可达代码开启--warn-unreachable后将类型收窄到某种不可能的状态会被视为错误def function(arg: int): # error: Subclass of int and str cannot exist: # would have incompatible method signatures assert isinstance(arg, str) # error: Statement is unreachable print(so mypy concludes the assert will always trigger)如果不开启--warn-unreachablemypy 只会简单地不去检查它判定为不可达的代码x: int 1 assert isinstance(x, str) reveal_type(x) # Revealed type is builtins.int print(x !) # Typechecks with mypy, but fails in runtime.上面的例子中assert isinstance(x, str)在int上永远失败但 mypy 没有报错只是不再收窄xprint(x !)虽然类型检查通过但在运行时必然失败——这正是收窄到不可能状态的典型陷阱。关于不可达代码的更多细节可参阅官方文档 unreachable 相关章节--warn-unreachable的完整行为说明。收窄的源码实现binder 与控制流分析从源码结构看mypy 的收窄能力由两大组件协作完成mypy/binder.py 中的ConditionalTypeBinder与Frame类负责记录在当前代码点某个表达式以字面量哈希literal_hash为键应该具有什么类型。Frame的类注释明确指出每个新的作用域或控制流分支都会压入一个新的Frame赋值与isinstance检查等收窄操作都会更新帧内的类型信息Frame.types离开分支弹出帧后类型自然恢复原状——这正是文档中分支内收窄、分支外恢复现象的实现基础。mypy/checker.py 的find_isinstance_check_helpermypy/checker.py第 6632 行起负责识别收窄表达式并计算条件为真 / 条件为假两张类型映射表TypeMap。该函数依次处理builtins.isinstance调用conditional_types_with_intersection求交集第 6642-6651 行builtins.issubclass调用infer_issubclass_maps第 6652-6656 行builtins.callable调用conditional_callable_type_map第 6657-6662 行builtins.hasattr调用hasattr_type_maps第 6663-6668 行其他调用表达式尝试从可调用类型或RefExpr中提取TypeGuard/TypeIs信息第 6669-6724 行。也就是说isinstance、issubclass、callable等并非语法层面的魔法而是 mypy 检查器在 AST 层面对这些内建函数调用做的专门识别与映射。issubclass在类型与元类层面的更优推断mypy 还可以利用issubclass在与类型、元类打交道时做出更好的类型推断class MyCalcMeta(type): classmethod def calc(cls) - int: ... def f(o: object) - None: t type(o) # We must use a variable here reveal_type(t) # Revealed type is builtins.type if issubclass(t, MyCalcMeta): # issubclass(type(o), MyCalcMeta) wont work reveal_type(t) # Revealed type is Type[MyCalcMeta] t.calc() # Okay这里有两个值得注意的细节必须先赋值给变量再调用issubclass直接写issubclass(type(o), MyCalcMeta)无法触发收窄。原因在于 mypy 只对可绑定bindable的表达式变量、属性访问、索引做收窄记录——从binder.py中BindableExpression的类型别名IndexExpr | MemberExpr | NameExpr可以看出只有这类表达式才会被存入Frame.types。收窄目标是Type[MyCalcMeta]issubclass判断的是类对象之间的继承关系因此收窄后的类型是类类型Type[MyCalcMeta]从而可以安全地调用元类上的类方法calc()。mypy 在 test-data/unit/check-isinstance.test 中为issubclass收窄准备了大量用例如第 1846 行起的Type[Goblin]系列测试覆盖了TypeVar、多重issubclass链、以及issubclass(cls, (A, B))元组形式等场景。callable把联合类型拆成可调用与非可调用两部分mypy 在类型检查阶段就能判断哪些类型可调用、哪些不可调用因此它知道callable()的返回值。例如from collections.abc import Callable x: Callable[[], int] if callable(x): reveal_type(x) # N: Revealed type is def () - builtins.int else: ... # Will never be executed and will raise error with --warn-unreachablecallable函数甚至可以把联合类型拆分为可调用与非可调用两部分from collections.abc import Callable x: int | Callable[[], int] if callable(x): reveal_type(x) # N: Revealed type is def () - builtins.int else: reveal_type(x) # N: Revealed type is builtins.int从源码看这一行为由checker.py中的conditional_callable_type_mapmypy/checker.py第 6455 行起实现它调用partition_by_callable将当前类型按可调用性分区若两部分都存在则分别生成if与else的类型映射若类型全部可调用则else分支映射为UninhabitedType不可达类型这正是上述示例中--warn-unreachable会报错的根源。Casts只影响类型检查的提示mypy 支持类型强制转换cast通常用于把一个静态类型值强转为它的子类型。与 Java、C# 等语言不同mypy 的 cast只作为类型检查器的提示不会在运行时执行任何类型检查。使用typing.cast进行转换from typing import cast o: object [1] x cast(list[int], o) # OK y cast(list[str], o) # OK (cast performs no actual runtime check)为什么要设计成这样若要支持cast(list[str], o)这样的运行时检查就必须检查列表中所有元素的类型对于大型列表来说代价极高。因此 cast 的定位是消除误报压制类型检查器给出的虚假警告辅助推断在类型检查器无法完全理解代码意图时提供一点帮助。注意需要运行时检查时请使用断言def foo(o: object) - None: print(o 5) # Error: cant add object and int assert isinstance(o, int) print(o 5) # OK: type of o is int hereassert isinstance(...)既收窄了类型通过前面介绍的 assert 收窄机制又在运行时真正执行检查是运行时安全 类型精确两全的做法。与 Any 的交互类型为Any的表达式不需要cast赋给类型为Any的变量也不需要cast你也可以把Any作为 cast 的目标类型——这样就能对结果执行任意操作from typing import cast, Any x 1 x.whatever() # Type check error y cast(Any, x) y.whatever() # Type check OK (runtime error)上面的y.whatever()虽然类型检查通过但运行时必然抛AttributeError——cast 到Any是把类型安全完全交给开发者的一种逃生舱应谨慎使用。User-Defined Type Guards用户自定义类型守卫PEP 647mypy 支持 PEP 647 定义的User-Defined Type Guards。类型守卫type guard是程序基于运行时检查、影响类型检查器条件收窄行为的一种机制。本质上TypeGuard是bool类型的一个智能别名。先看一个普通bool返回函数的例子def is_str_list(val: list[object]) - bool: Determines whether all objects in the list are strings return all(isinstance(x, str) for x in val) def func1(val: list[object]) - None: if is_str_list(val): reveal_type(val) # Reveals list[object] print( .join(val)) # Error: incompatible type返回bool时mypy无法从is_str_list的返回值推断出任何类型信息val仍是list[object] .join(val)报错。同样的例子改用TypeGuardfrom typing import TypeGuard def is_str_list(val: list[object]) - TypeGuard[list[str]]: Determines whether all objects in the list are strings return all(isinstance(x, str) for x in val) def func1(val: list[object]) - None: if is_str_list(val): reveal_type(val) # list[str] print( .join(val)) # ok工作原理TypeGuard把函数的第一个参数这里是val收窄为第一个类型参数这里是list[str]指定的类型。注意收窄不是严格的non-strict narrowingPEP 647 并不强制严格收窄。例如你可以把str收窄为intdef f(value: str) - TypeGuard[int]: return True由于不强制严格收窄很容易破坏类型安全。不过 mypy 文档同时指出破坏类型安全的方式其实很多最常见的是 cast 和Any如果一个 Python 开发者愿意花时间学习并实现用户自定义类型守卫可以合理假定他们关心类型安全不会写出破坏类型安全或产生荒谬结果的守卫函数。泛型 TypeGuardTypeGuard可以与泛型类型一起使用Python 3.12 语法from typing import TypeGuard # use typing_extensions for python3.10 def is_two_element_tupleT - TypeGuard[tuple[T, T]]: return len(val) 2 def func(names: tuple[str, ...]): if is_two_element_tuple(names): reveal_type(names) # tuple[str, str] else: reveal_type(names) # tuple[str, ...]注意这里的类型变量T在收窄时会被绑定当传入tuple[str, ...]时TypeGuard[tuple[T, T]]实例化为tuple[str, str]。带额外参数的 TypeGuard类型守卫函数可以接收额外参数Python 3.12 语法from typing import TypeGuard # use typing_extensions for python3.10 def is_set_ofT - TypeGuard[set[T]]: return all(isinstance(x, type) for x in val) items: set[Any] if is_set_of(items, str): reveal_type(items) # set[str]收窄仍然只作用于第一个参数val额外参数type只参与泛型实例化不影响收窄目标的选择。方法作为 TypeGuard方法同样可以作为TypeGuard使用class StrValidator: def is_valid(self, instance: object) - TypeGuard[str]: return isinstance(instance, str) def func(to_validate: object) - None: if StrValidator().is_valid(to_validate): reveal_type(to_validate) # Revealed type is builtins.str注意TypeGuard不会收窄self/cls隐式参数PEP 647 规定TypeGuard不会收窄self或cls隐式参数的类型。如果确实需要收窄self/cls可以把该值作为显式参数传给类型守卫函数class Parent: def method(self) - None: reveal_type(self) # Revealed type is Parent if is_child(self): reveal_type(self) # Revealed type is Child class Child(Parent): ... def is_child(instance: Parent) - TypeGuard[Child]: return isinstance(instance, Child)在这里self被当作显式实参传入is_child因此可以被收窄为Child。赋值表达式作为 TypeGuard有时你可能想创建新变量与把它收窄到某个具体类型同时完成。这可以通过TypeGuard与海象运算符:赋值表达式组合实现from typing import TypeGuard # use typing_extensions for python3.10 def is_float(a: object) - TypeGuard[float]: return isinstance(a, float) def main(a: object) - None: if is_float(x : a): reveal_type(x) # N: Revealed type is builtins.float reveal_type(a) # N: Revealed type is builtins.object reveal_type(x) # N: Revealed type is builtins.object reveal_type(a) # N: Revealed type is builtins.object这里发生了什么创建新变量x并把a的值赋给它对x执行is_float()类型守卫在if上下文中把x收窄为float不影响a。注意同样的写法对isinstance(x : a, float)同样有效。从源码实现看checker.py的find_isinstance_check_helper中对AssignmentExpr第 6727-6739 行的处理正是分别对node.target与node.value递归查找收窄检查再把两张映射合并——海象表达式场景由此得到支持。TypeIs更精确的双分支收窄PEP 742mypy 支持 PEP 742 定义的TypeIs。TypeIs收窄函数允许你定义自定义类型检查它可以像内建isinstance()一样在条件判断的if与else两个分支中同时收窄变量的类型。TypeIs是 Python 3.13 新增的——在旧版 Python 中请使用typing_extensions提供的反向移植backport版本。看一个使用TypeIs的完整示例from typing import TypeIs def is_str(x: object) - TypeIs[str]: return isinstance(x, str) def process(x: int | str) - None: if is_str(x): reveal_type(x) # Revealed type is str print(x.upper()) # Valid: x is str else: reveal_type(x) # Revealed type is int print(x 1) # Valid: x is int在这个例子中is_str是一个返回TypeIs[str]的收窄函数在if分支中x被收窄为str在else分支中x被收窄为int——两个分支都收窄了这正是与TypeGuard最核心的差异。关键要点函数必须至少接受一个位置参数返回类型标注为TypeIs[T]其中T是希望收窄到的类型函数必须返回bool值在if分支函数返回True时参数类型被收窄为其原始类型与T的交集在else分支函数返回False时参数类型被收窄为其原始类型与T的补集的交集。TypeIs vs TypeGuard两者都允许定义自定义类型收窄函数但在关键行为上存在重要差异对比维度TypeIsTypeGuard收窄行为在if和else两个分支都收窄只在if分支收窄兼容性要求要求被收窄类型T与函数输入类型兼容无此限制可收窄到任意类型类型推断类型检查器可结合既有类型信息与T推断出更精确的类型直接替换为T下面是用TypeGuard重写同一逻辑的对比示例from typing import TypeGuard, reveal_type def is_str(x: object) - TypeGuard[str]: return isinstance(x, str) def process(x: int | str) - None: if is_str(x): reveal_type(x) # Revealed type is builtins.str print(x.upper()) # ok: x is str else: reveal_type(x) # Revealed type is Union[builtins.int, builtins.str] print(x 1) # ERROR: Unsupported operand types for (str and int) [operator]注意else分支的差别TypeGuard下x仍保持int | str因此print(x 1)会报operator错误而TypeIs下else分支的x已是int可以直接做加法运算。泛型 TypeIsTypeIs函数同样可以配合泛型类型使用from typing import TypeVar, TypeIs T TypeVar(T) def is_two_element_tuple(val: tuple[T, ...]) - TypeIs[tuple[T, T]]: return len(val) 2 def process(names: tuple[str, ...]) - None: if is_two_element_tuple(names): reveal_type(names) # Revealed type is tuple[str, str] else: reveal_type(names) # Revealed type is tuple[str, ...]带额外参数的 TypeIsTypeIs函数可以接受除第一个参数之外的额外参数类型收窄只作用于第一个参数from typing import Any, TypeVar, reveal_type, TypeIs T TypeVar(T) def is_instance_of(val: Any, typ: type[T]) - TypeIs[T]: return isinstance(val, typ) def process(x: Any) - None: if is_instance_of(x, int): reveal_type(x) # Revealed type is int print(x 1) # ok else: reveal_type(x) # Revealed type is Any方法中的 TypeIs方法同样可以作为TypeIs函数。注意在实例方法或类方法中类型收窄作用于第二个参数即self/cls之后的那个参数class Validator: def is_valid(self, instance: object) - TypeIs[str]: return isinstance(instance, str) def process(self, to_validate: object) - None: if Validator().is_valid(to_validate): reveal_type(to_validate) # Revealed type is str print(to_validate.upper()) # ok: to_validate is str这与TypeGuard方法示例形成对照TypeGuard方法收窄的也是self之后的第一个显式参数instance只是TypeGuard明确不作用于self/cls本身。赋值表达式与 TypeIs你也可以把海象运算符:与TypeIs组合使用在创建新变量的同时收窄其类型from typing import TypeIs, reveal_type def is_float(x: object) - TypeIs[float]: return isinstance(x, float) def main(a: object) - None: if is_float(x : a): reveal_type(x) # Revealed type is float # x is narrowed to float in this block print(x 1.0)TypeIs / TypeGuard 的源码级实现从 mypy 源码看TypeGuard与TypeIs的差别被编码在类型系统的两个字段上mypy/types.py 中CallableType定义了type_guard与type_is两个属性第 2178-2244 行注释明确写着type_guardT若 -TypeGuard[T]此时ret_type是booltype_isT若 -TypeIs[T]此时ret_type是bool。二者与ret_type并存表示函数表面返回bool、实际携带收窄目标类型。mypy/types.py 第 480 行定义了专门的TypeGuardedType包装类型用于在类型映射中标记被TypeGuard收窄的目标。mypy/nodes.py 的RefExpr第 2406-2430 行同样缓存了type_guard与type_is字段使函数引用的收窄信息可以在 AST 层直接读取。mypy/checker.py 第 6669-6724 行的关键分支展示了两者的行为分叉当检查到TypeGuard时直接返回{expr: TypeGuardedType(type_guard)}总是正确即使类型不重叠也照单全收——对应 PEP 647 的非严格收窄当检查到TypeIs时则走conditional_types_with_intersection求交集consider_runtime_isinstanceFalse产生if/else两张映射——这正是TypeIs能在else分支也收窄的机制来源。局限性mypy 不做跨符号关系追踪mypy 的分析局限于单个符号symbol不会追踪符号之间的关系。例如下面的代码人类很容易推断出如果a是None那么b必然不是None因此a or b永远是C的实例但 mypy 做不到class C: pass def f(a: C | None, b: C | None) - C: if a is not None or b is not None: return a or b # Incompatible return value type (got C | None, expected C) return C()在类型检查器中追踪这种跨变量条件会带来显著的复杂性与性能开销从binder.py的Frame设计可以看出mypy 按帧存储的是表达式 → 类型的单向映射天然不维护变量之间的约束关系。三个绕行方案面对这种场景可以用以下任一方式绕过用assert说服类型检查器用cast覆盖推断见上文 Casts 一节把函数重写得稍显冗长让每个变量单独收窄def f(a: C | None, b: C | None) - C: if a is not None: return a elif b is not None: return b return C()总结如何选择收窄手段场景推荐手段说明常规条件判断isinstance/issubclass/callable/is not None内建收窄零成本、零额外标注需要运行时也做校验assert isinstance(...)类型收窄 运行时防护类型检查器看不懂你的意图cast仅提示检查器不做运行时检查自定义复杂判断只关心if分支TypeGuard[T]PEP 647收窄第一个参数自定义复杂判断if/else都要收窄TypeIs[T]PEP 742行为近似isinstance需类型兼容创建变量并同时收窄海象运算符:TypeGuard/TypeIs/isinstance一步完成赋值与收窄值得强调的是TypeGuard与TypeIs都属于程序主动影响类型检查器的高级手段使用它们意味着你承担了保证守卫函数正确性的责任——就像 PEP 647 文档中所说破坏类型安全的方式cast、Any始终存在守卫函数的价值恰恰建立在使用者对类型安全的重视之上。延伸阅读类型收窄的完整官方说明见 docs/source/type_narrowing.rst收窄相关的大规模回归测试见 test-data/unit/check-isinstance.testisinstance、issubclass、callable、TypeGuard等用例与 test-data/unit/check-typeguard.test--warn-unreachable的完整语义见 docs/source/command_line.rst 与 docs/source/common_issues.rst严格可选类型strict optional即obj is not None收窄到非可选形式的背景见 docs/source/kinds_of_types.rst。【免费下载链接】mypyOptional static typing for Python项目地址: https://gitcode.com/GitHub_Trending/my/mypy创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考