判断一个对象是什么类型看着就是type()一行代码的事真到线上排查时才知道坑有多深。前阵子帮人看一段数据清洗脚本函数入口写的是if type(data) str: ...本地跑得好好的上线后某个上游把布尔开关一路传了进来True被当成数字处理静默算错了一整批报表——因为type(True) bool不假但后面那个elif分支里又用isinstance(x, int)兜底两套判断标准打架逻辑直接分叉。这种事我见得太多了type、isinstance、__class__、抽象基类、typing泛型五种手段各有各的适用面混着用就是埋雷。这篇笔记就把“获取对象类型”这一件事彻底讲透从 CPython 里isinstance怎么走继承链到bool为什么是int的私生子再到泛型别名的get_origin/get_args拆解、代理对象伪造__class__的骚操作、以及各种判断写法的性能量级对比。适合刚过语法关、正在写业务代码的人也适合写了几年仍然习惯性type(x) str的朋友对照自查。1. 类型判断在 Python 里究竟难在哪1.1 三个层次值类型、协议类型、声明类型刚学 Python 的人对“类型”的理解通常是单层的变量装的是整数那它的类型就是int。这个理解在静态语言里基本成立在 Python 里只覆盖了第一层。我把实际开发中遇到的类型判断需求拆成三层后面的所有选型都围绕这三层展开。第一层是值类型也就是对象创建时由构造器决定的那个具体类。type(3)得到inttype([1,2])得到list这层信息确定、稳定、查询成本极低是所有判断手段的基础。第二层是协议类型指“这个对象表现得像什么”。一个自定义类只要实现了__iter__它在语义上就是可迭代的哪怕它压根没继承list一个类只要实现了__len__和__getitem__它就能被for循环和切片使用。这类判断靠具体类名是无解的必须靠抽象基类或结构化子类判定。第三层是声明类型也就是类型注解里写的List[int]、Optional[str]、Dict[str, Tuple[int, float]]。这些在运行时根本不是类而是typing模块构造出来的特殊对象你拿isinstance去试会直接抛异常。想从它们身上挖信息得走typing.get_origin()和typing.get_args()这条路。三层的处理方式完全不同混用就是灾难。我见过最典型的错误是用type(x) str判断“是不是字符串”结果上游传进来一个str的子类比如某个框架自定义的SafeString判断直接失效代码走进默认分支。这是拿第一层的手段去回答第二层的问题。1.2 我给自己定的选型原则踩过几轮坑之后我给自己定了三条硬规则写在团队规范里执行下来确实少了很多事故。规则一判断“能不能用”用isinstance判断“必须精确等于”用type(x) is T。绝大多数业务场景属于前者——你只关心传入的对象能不能.append()、能不能被迭代那子类当然也应该被接受。只有在做序列化、做分发表、做协议编码这类对具体类型极度敏感的场景才需要精确匹配。规则二永远不用type(x).__name__ xxx做核心逻辑分支。这个写法的脆弱程度超乎想象类被重命名、类被functools.wraps包装、类被动态生成任何一个变化都会让它失效而且失效时不会报错只是默默走错分支。规则三如果一段代码里出现三次以上的类型判断先停下来想想能不能改成多态。类型判断本身没有错但密集的类型判断几乎总是设计信号——说明你在用if/elif手工模拟一个本该由继承或协议解决的问题。下面这张表是我日常查阅频率最高的对照表先摆出来后面逐条展开细节。判断目标推荐写法不推荐核心原因是不是某个类或其子类isinstance(x, T)type(x) T子类会被漏掉是不是精确的某个类type(x) is Ttype(x) T元类可能重写__eq__是不是若干类之一isinstance(x, (A, B, C))三次or串联少写重复代码短路求值是不是某类的父类issubclass(C, T)T in C.__bases__前者遍历整条 MRO是不是可迭代isinstance(x, Iterable)hasattr(x, __iter__)后者会误伤属性为 None 的情况是不是函数callable(x)或inspect.isfunction(x)type(x) type(lambda: 0)后者对装饰器、内建函数失效是不是 Nonex is Noneisinstance(x, type(None))后者多余且慢泛型别名里的真实类typing.get_origin(tp)tp.__origin__前者是公开 API2. type() 与class最常用也最容易翻车2.1 type() 到底返回什么type(obj)返回的是对象头里ob_type指针指向的那个类对象这是对象生命周期中最稳定的一份信息构造完成后基本不会变。对普通实例来说它返回实例的类对类本身调用它返回元类99% 情况是type这三者的关系很容易绕晕class Point: pass p Point() print(type(p)) # class __main__.Point print(type(Point)) # class type print(type(type)) # class type注意第二行和第三行类的类型是type而type自己的类型还是type。这是个自举bootstrap结构type是它自己的元类。如果你写的是type(Point) is Point那永远返回False写type(Point) is type才对。我第一次接触元类时就在这卡了半小时总觉得“类的类型应该是它自己”其实完全不是。还有一点必须强调type()是不接受关键字参数以外任何花活的单参数调用当它只接收一个参数时就是查询类型当它接收三个参数type(name, bases, dict)时是动态创建类。同一个名字两套语义读代码时别搞混。2.2__class__属性和 type() 的微妙差别大部分时候obj.__class__和type(obj)返回同一个东西所以很多人以为这俩等价。它们不等价差别在于__class__是一个可以被自定义的属性而type()读的是对象的内存头部改不了。class Liar: property def __class__(self): return int obj Liar() print(type(obj)) # class __main__.Liar print(isinstance(obj, int)) # True这段代码跑出来isinstance是True很多人第一次见会愣住。原因是 CPython 的isinstance实现分了快速路径和慢速路径先拿type(obj)沿 MRO 找找不到再退回来读obj.__class__如果它与type(obj)不同就对这个值再做一次检查。property在这里被正常触发返回了int于是判断通过。注意这个“伪造类型”的行为不是 bug而是语言规范层面承认的机制它的正经用途是给代理对象、懒加载对象、Mock 对象打掩护。但如果你在业务代码里见到某个类偷偷定义了__class__property务必留个心眼因为所有isinstance判断都会被它带偏。最典型的正当用例是unittest.mock。当你写Mock(specint)时这个 mock 的__class__会被真的设置成int于是isinstance(mock_obj, int)返回True。这是官方有意为之目的是让 mock 对象能顺利通过被测代码里的类型守卫。它的代价是你在被测代码里做类型判断时测试环境和生产环境的行为可能不一致——mock 过了真实对象反而没过或者反过来。另一个会踩的场景是某些 ORM 和 RPC 框架的延迟加载对象它们在数据真正加载前用一个代理壳子顶着访问属性时才去查数据库。这种代理对象通常会把__class__转发给真实对象所以isinstance看着像真的但任何依赖type()精确匹配的逻辑都会失效。我遇到过的最麻烦的一次是序列化type(obj) is Order返回False导致整个对象被当成未知类型丢进了兜底分支日志里还看不出任何异常因为str(obj)看起来完全正常。2.3is和的那点区别type(x) int和type(x) is int在 99.9% 的情况下结果相同因为类对象的__eq__默认走的是object.__eq__也就是身份比较。但那 0.1% 值得知道如果元类重写了__eq__两者就会分叉。class WeirdMeta(type): def __eq__(cls, other): return True # 极其危险的写法 class Foo(metaclassWeirdMeta): pass print(type(Foo()) int) # True —— 被元类骗了 print(type(Foo()) is int) # False —— 身份比较不受影响现实里没人会这么写元类但库代码里为了做“宽松相等”而重写__eq__的情况确实存在。既然is的语义更严格、执行更快、还不会被重载干扰那判断类型时就没理由用。我的习惯是在代码评审里把所有type(...) 都标出来要求改掉理由统一写成“防元类重载”。3. isinstance()继承链、虚拟子类与协议判定3.1 isinstance 与 type 的本质差别isinstance(obj, T)的核心动作是沿type(obj)的 MRO方法解析顺序链往上爬只要链上任意一个类等于T就返回True。这意味着它天然支持继承、多继承、super()构造出来的父子关系而type(obj) is T只认最精确的那一个。class Animal: pass class Dog(Animal): pass d Dog() print(type(d) is Animal) # False print(isinstance(d, Animal)) # True print(isinstance(d, Dog)) # True这个差别在业务里的后果很实际。假设你写了个分发表{Animal: handle_animal, Dog: handle_dog}用type(obj)去查表那么Dog实例永远命中handle_dog而它的子类Puppy会查不到键直接 KeyError。改用isinstance循环匹配或者用type(obj).mro()逐级查表才能覆盖到所有子类。后者虽然啰嗦一点但查表顺序可控性能也比一串isinstance好我在处理消息路由时更倾向这种写法。第二个参数除了类还接受元组这是最常被低估的语法糖if isinstance(value, (int, float, Decimal)): ...元组里的类从左到右逐个匹配命中即短路。有个细节值得说如果这个元组很长比如十来个类而且这段判断在热循环里跑那每次调用都做线性扫描的开销就不可忽略了。我的做法是把常用的类型集合提成模块级常量# 模块顶部 NUMERIC_TYPES (int, float, complex, Decimal, Fraction) # 热循环里反复使用同一个元组对象 if isinstance(value, NUMERIC_TYPES): ...这不只是为了省那点创建对象的开销更重要的是让意图集中在一处改的时候不用满文件搜索。3.2 bool 与 int 的那个经典陷阱bool是int的子类这是 Python 里最出名也最容易忘的类型关系。print(isinstance(True, int)) # True print(type(True) is int) # False print(True True) # 2后果很直接任何用isinstance(x, int)做守卫的地方都会放行布尔值。写财务计算、写计数逻辑、写索引校验时这个口子非常危险——True会以1的身份混进求和报表上多出来的那一块钱可能查一整天。反过来如果你真的想排除布尔值标准写法是先查bool再查intdef is_true_integer(value): return isinstance(value, int) and not isinstance(value, bool)顺序不能反因为isinstance(True, int)恒为真。另一种写法是用type(value) is int一步到位、语义清晰代价是不接受int的子类比如某些框架的IntEnum成员或者自定义的MyInt。IntEnum尤其麻烦它既是int的子类又不是int的精确类型具体该不该放行完全取决于业务语义没有通用答案。我的经验是如果这个函数的名字里带count、index、amount这类词就用type(x) is int严格把关如果它是个通用的工具函数用isinstance加布尔排除更稳妥。3.3 抽象基类和虚拟子类不继承也能算“自己人”collections.abc里那一堆抽象基类最厉害的地方不是让你去继承它们而是它们自带结构化子类判定。一个类只要实现了约定的方法issubclass和isinstance就会认定它是该 ABC 的子类尽管它的 MRO 里根本没有这个基类。from collections.abc import Iterable, Sized class MyRange: def __init__(self, n): self.n n def __iter__(self): return iter(range(self.n)) def __len__(self): return self.n r MyRange(5) print(isinstance(r, Iterable)) # True print(isinstance(r, Sized)) # True print(Iterable in MyRange.__mro__) # False最后一行说明了真相Iterable并不在MyRange的继承链上但isinstance依然返回True。这是Iterable.__subclasshook__的功劳它内部检查__iter__是否存在存在就认。这套机制叫“结构化子类”本质上是 SQL 里LIKE的 Python 版本——不看血缘看长相。这套机制也有盲区。它只认类上定义的方法不认实例上临时挂的属性。下面这个对比很能说明问题class Plain: pass p Plain() p.__iter__ lambda: iter([]) # 实例上挂一个 print(hasattr(p, __iter__)) # True print(isinstance(p, Iterable)) # False另一个盲区是第三方库的类型常常没注册。某些数值计算库的数组对象、某些图数据库的节点对象功能上完全可迭代、有长度但它们的作者没有把它们注册到collections.abcisinstance就返回False。所以判断“是不是容器”这种宽泛概念时我一般会退化成组合判断isinstance(x, Iterable)或hasattr(x, __iter__)两者取或牺牲一点严谨换兼容性。如果真的需要让一个第三方类被认作某个 ABC 的子类用registerfrom collections.abc import MutableMapping class MyBag: def __init__(self): self._data {} def __getitem__(self, k): return self._data[k] def __setitem__(self, k, v): self._data[k] v def __delitem__(self, k): del self._data[k] def __iter__(self): return iter(self._data) def __len__(self): return len(self._data) MutableMapping.register(MyBag) print(isinstance(MyBag(), MutableMapping)) # True注意register只是贴了个标签不会帮你实现任何方法也不会检查你到底有没有实现。如果你忘了写__setitem__就注册了isinstance照样返回True然后代码在真正调用时崩掉。所以注册之后务必补一个测试把 ABC 要求的方法挨个跑一遍。还有一种更轻量的写法是用装饰器形式MutableMapping.register放在类定义上方效果一样但更显眼适合写在类的同一文件里。3.4 numbers 模块的数值塔数值类型判断是另一个高频场景很多人第一反应是isinstance(x, (int, float))但这一漏就是一大片Decimal不是float的子类Fraction也不是complex更不是还有布尔值反而混在里面。标准库里那套numbers抽象基类专门解决这个问题它定义了一个层级Number→Complex→Real→Rational→Integral。int注册在Integral上float注册在Real上Decimal和Fraction也都注册了各自的位。import numbers from decimal import Decimal from fractions import Fraction for v in [5, 5.0, True, Decimal(1.5), Fraction(1, 3), 12j]: print(type(v).__name__, isinstance(v, numbers.Integral), isinstance(v, numbers.Real), isinstance(v, numbers.Number))输出大概是int的 Integral/Real/Number 全为真float的 Integral 假、后两个真bool因为继承intIntegral 也是真Decimal和Fraction的 Real 是真complex只有 Number 为真。这张关系表我第一次画出来的时候才发现判断“是不是数值”根本不需要枚举类型一个isinstance(x, numbers.Number)就够了而且自动覆盖所有注册过的第三方数值类型。4. 类型名字符串判断见一次劝退一次4.1 为什么__name__判断是个陷阱网上教程里经常出现这种写法if type(x).__name__ str: ...它的问题不是“跑不通”而是“什么时候跑不通你看不出来”。列几种会失效的情况第一种子类。class SafeString(str)的实例__name__是SafeString判断失败。业务里只要有一处包装整条链路就断了。第二种动态生成的类。type(Dynamic, (object,), {})造出来的类名字叫Dynamic但你可能根本不知道有这回事。第三种被functools.wraps或元类改过__name__的类。虽然少见但一旦发生日志里显示的名字和判断用的名字就对不上了。第四种也是最隐蔽的__name__是实例属性可以覆盖的。某些库会在实例上挂一个__name__用来存业务名称这时候type(x).__name__不受影响但如果有人写了getattr(x, __name__, )去判断直接读到业务名称。4.2 字符串类型名的正经用途说这些不是要否定__name__它在日志、序列化、错误信息、调试输出这些场景里非常好用因为那些地方需要的是“给人看的可读名字”而不是“给程序用的精确类对象”。差别在于前者错了只是信息不准后者错了是逻辑分叉。def log_type(obj): # 这里用 __name__ 完全没问题甚至更好读 return f{type(obj).__module__}.{type(obj).__qualname__}我用__qualname__而不是__name__因为嵌套类、内部类的__name__只有短名__qualname__会带上外部类的路径比如Outer.Inner排查时信息量明显更大。配上__module__前缀就能得到完整可定位的类路径。如果确实需要根据字符串去找对应的类正确做法是维护一张显式的注册表而不是反向解析__name__TYPE_REGISTRY { str: str, list: list, dict: dict, } def resolve(name): cls TYPE_REGISTRY.get(name) if cls is None: raise ValueError(f未知类型: {name}) return cls这张表看起来笨但它是白名单可审计、可扩展、可测试。动态导入importlib.import_module再getattr去反查类也可以但那就引入了任意代码加载的风险除非输入完全可信否则别这么干。5. typing 体系里的类型信息怎么取5.1 get_origin 与 get_args 拆解泛型别名写框架、写校验器、写自动生成文档的时候经常需要从一个类型注解里反推出真实类。比如拿到List[int]需要知道“容器是list元素是int”。这件事靠isinstance无解得用typing提供的两个工具。from typing import List, Optional, Dict, Tuple, Union, get_origin, get_args import types print(get_origin(List[int])) # class list print(get_args(List[int])) # (class int,) print(get_origin(Optional[str])) # typing.Union print(get_args(Optional[str])) # (class str, class NoneType) print(get_origin(Dict[str, int])) # class dict print(get_args(Dict[str, int])) # (class str, class int)get_origin返回的是“外壳”也就是被参数化的那个原始类get_args返回的是“参数列表”。两者配合就能把任意嵌套的注解剥洋葱一样一层层拆开。这里有个版本差异必须提醒Python 3.9 之后可以直接写list[int]、dict[str, int]它和typing.List[int]在get_origin/get_args下的表现一致都能正确拆出list和int。但更早的版本不支持下标语法写list[int]会直接 TypeError。写库代码时如果用到了这个语法记得在元数据里把最低版本标清楚。联合类型更绕一点。Optional[int]在内部是typing.Union[int, None]get_origin返回typing.Union。而 Python 3.10 之后支持int | None这种写法它的get_origin返回的是types.UnionType是另一个对象。所以处理联合类型的代码要同时认这两个def split_union(tp): origin get_origin(tp) union_types (Union, getattr(types, UnionType, Union)) if origin in union_types: return get_args(tp) return ()getattr那层兜底是为了兼容 3.10 以下没有types.UnionType的版本。这类兼容代码很难看但写一次就够了封装成一个工具函数后续复用。5.2 TypeVar 不能直接拿去 isinstanceTypeVar是泛型里的占位符它根本不是一个类拿它做类型判断会抛异常from typing import TypeVar T TypeVar(T, boundint) isinstance(1, T) # TypeError: isinstance() arg 2 must be a type想用它做校验得手动取约束def matches_typevar(value, tv): if tv.__bound__ is not None: return isinstance(value, tv.__bound__) if tv.__constraints__: return isinstance(value, tv.__constraints__) return True # 无约束谁都行这段代码我基本只在自己写的参数校验小工具里用业务代码里从来不做运行时的 TypeVar 校验因为静态检查器已经在编译期把这件事干了运行时再查一遍属于重复劳动还拖慢速度。注意typing里的绝大多数构造List、Dict、Optional、Callable、TypeVar、Protocol都不能直接塞进isinstance的第二个参数。老版本的typing会抛TypeError: Subscripted generics cannot be used with class and instance checks新版本可能给出更明确的提示但结论一样先get_origin取出真实类再判断。5.3 Callable 与协议类的运行时判定isinstance(x, Callable)是少数几个可以直接用的typing构造它会退化成检查__call__属性。但要注意它的判定是“有__call__”就算类本身就是可调用的因为type有__call__所以isinstance(SomeClass, Callable)也返回True哪怕这个类压根没实现__init__实例化出来也没法调用。要区分“是类”和“是函数”得配合inspect判断目标写法说明可直接调用callable(x)最宽松类、函数、带__call__的实例都算是普通函数inspect.isfunction(x)只认def定义的函数排除内建和 bound method是方法inspect.ismethod(x)只认绑定方法是内建函数inspect.isbuiltin(x)len、print这类是类inspect.isclass(x)等价于isinstance(x, type)是协程函数inspect.iscoroutinefunction(x)判断async def是生成器isinstance(x, types.GeneratorType)生成器对象不是生成器函数这张表我在写装饰器时翻得最多因为装饰器必须能区分“被装饰的是函数还是类”两者包装方式完全不同。callable太宽松type(x) is type(lambda: 0)太窄挡不住functools.partial和装饰器返回的 wrapperinspect.isfunction是折中点。6. 性能量级与工程实践6.1 几种判断方式的耗时对比写热路径代码时类型判断的开销是要算的。我给个量级参考同一台机器上timeit跑出来的相对关系绝对值随硬件和版本浮动别拿具体数字当基准写法相对耗时说明x is None1 倍基准身份比较最快type(x) is int约 1.5 倍一次指针比较不遍历 MROisinstance(x, int)约 2 到 3 倍单类型走一次 MRO 扫描isinstance(x, (int, float))约 4 到 5 倍元组线性扫描isinstance(x, (A, B, C, D, E))约 10 倍以上逐项尝试全部失败最慢x.__class__.__name__ int约 15 到 20 倍属性访问加字符串比较这些倍数看着吓人但单次调用的绝对时间在百纳秒量级业务代码里一天跑几百万次也就多花零点几秒完全不用管。真正需要在意的是循环体内部的判断比如一千万条数据的清洗流程里每条都做五次类型判断累积起来就是好几秒的差距。我在这种场景下的做法是把类型判断尽量前置到入口处做一次循环体内部只做已经确定类型后的运算实在必须在循环里判断就用type(x) is T这种最便宜的写法并且把最可能命中的类型放在elif链最前面利用短路省掉后续比较。6.2 什么时候压根不该判断类型这条经验我是花了不少代价才学会的超过一半的类型判断本质上是设计问题的症状。举个具体的。我见过一个数据导出函数入口处判断了八种类型str走一个分支、list走一个分支、dict走一个分支、bytes走一个分支……每加一种输入格式就要改一次函数。后来重构的时候我们把每类输入抽成一个适配器类统一实现to_rows()方法函数入口只剩一句def export(source): rows source.to_rows() ...类型判断彻底消失新增格式只需要加一个类不改任何已有代码。这就是“多态替代条件判断”的老生常谈但它在类型判断这个具体场景里体现得最明显。另一个不该判断类型的场景是参数校验。很多人在函数开头写一堆if not isinstance(x, int): raise TypeError(...)写得很勤快。问题是 Python 不强制类型你这么写等于把静态类型检查的活搬到了运行时性能不升反降而且和duck typing的设计哲学相悖。更好的做法是让操作本身失败——你直接对参数做x 1传进来字符串自然就 TypeError 了错误信息还更准确。当然边界处该校验还是要校验比如接收外部 JSON、接收 HTTP 参数、接收插件传入的配置。这类数据不受你控制早失败早好。我的判断标准是数据来自你的代码内部交给调用栈去兜数据来自外部世界入口处老老实实查。6.3 测试里怎么断言类型单测里的类型断言和业务代码里的判断目的完全不同。业务判断是为了分流测试断言是为了发现意外变化所以写法更严格语义也更明确。import unittest class TestTypes(unittest.TestCase): def test_result_type(self): result compute() self.assertIsInstance(result, list) self.assertEqual(type(result), list) # 精确类断言assertIsInstance接受子类assertEqual(type(x), T)只接受精确类。什么时候用哪个如果被测函数的文档承诺返回list那用前者如果返回的是某个特定的子类比如collections.OrderedDict那就必须用后者否则一旦实现改成普通dict测试还会绿着。pytest 里更常见的写法是直接assert isinstance(result, list)失败时输出会自动带上实际类型可读性很好。但要注意assert isinstance(result, (list, tuple))这种多类型断言失败信息只说“不是这两者”没法告诉你到底拿到了什么调试时不如先print(type(result))再断言。还有个小技巧值得提断言类型时顺手断言一下“不是别的类型”。比如某个函数本该返回int那加一句assert not isinstance(result, bool)能挡住True混进来的情况。这个坑我在前面提过测试里加一行比事后排查省事得多。7. 常见问题排查速查表下面这些是我这些年被问过最多、自己也踩过的问题整理成表格方便对照。每一条后面都注了根因因为只知道“怎么改”不知道“为什么”下次换个形式还会再踩。现象根因解法isinstance(True, int)返回 True计数出错bool是int的子类加not isinstance(x, bool)或改type(x) is int子类实例通过不了判断用了type(x) T改isinstance(x, T)泛型注解做isinstance抛 TypeErrorList[int]不是类get_origin取真实类再判断代理对象isinstance结果异常对象自定义了__class__用type(x)或x.__wrapped__取真实对象判断函数失败type(x)是function却不是被装饰器包裹用callable()或inspect系列issubclass抛TypeError: arg 1 must be a class第一个参数传了实例换成isinstance判断“是不是数组/容器”返回 False第三方类型没注册 ABC组合判断或用hasattr兜底hasattr(x, __iter__)为 True 但迭代报错实例属性被设为 None改用isinstance(x, Iterable)Python 3.9 下list[int]报错内建泛型是 3.9 才有的语法用typing.List[int]兼容旧版get_origin对int | None和Optional[int]结果不同两者内部是不同对象同时判断typing.Union和types.UnionType还有两类问题不在表里单独说。第一类是跨进程、跨语言传过来的类型名。比如服务 A 把{type: datetime}发给服务 BB 用type(x).__name__ datetime去匹配看着能用。但一旦 A 升级换了实现或者传的是date的实例B 就直接失配。这类场景的正确做法是在协议层定义一套稳定的枚举值两边都按枚举走绝不传递语言层面的类型名。类型名是给人看的枚举值才是给机器用的。第二类是多重继承下的 MRO 歧义。isinstance沿 MRO 找只要链上有就返回 True所以一个类同时继承A和B对两者的判断都是 True。这在绝大多数情况是期望行为但如果你的分支逻辑依赖“它到底是 A 还是 B”那isinstance给不出答案。这时候要么用type(x) is A精确匹配要么用A in type(x).__mro__手动控制优先级。后者的好处是可以精确指定“先看 A 再看 B”而不是被 Python 的 C3 线性化顺序牵着走。最后一类是我个人踩得最深的坑运行时的类型检查和静态类型注解经常对不上。你给函数标注了def f(x: int) - str运行时传个字符串进去照样跑静态检查器却在 CI 里报错。原因很简单注解只是元数据Python 不会强制执行。所以别指望注解能替代校验也别指望校验能替代注解两者的职责是分开的注解给人和工具看校验给运行时防。我现在写新模块时的默认动作是先分清这个函数的输入来自哪里。来自内部调用注解标好就不管了最多在文档字符串里写一句预期。来自外部输入入口处一定有一小段显式校验优先用isinstance加抽象基类能覆盖子类和自定义实现。至于type(x) is T只留给那些真正需要精确匹配的少数场景比如注册表键的匹配和序列化分发的入口——用错地方的话它制造的问题比解决的问题多得多。
阅读完成 · 觉得有帮助?