很多人第一次碰到"获取对象类型"这个问题,都是在某个函数里写了句type(x) == str,然后在 code review 时被人指着说"你这判断有问题"。更离谱的情况是,明明传进来的是True,你的isinstance(x, int)也返回了 True,于是金额计算里多了个 1。这类问题不像语法错误那样会当场报错,它会安安静静地把错误数据带到下游,等到财务报表或者接口返回对不上数才被发现。这篇笔记就围绕 Python3 中获取对象类型的几种方式展开,把type()、isinstance()、__class__、issubclass()以及它们背后的设计意图讲清楚,顺便把我在数据校验、序列化、插件加载这些场景里踩过的坑一并交代。适合已经会写 Python 但还没系统梳理过类型判断的人,也适合想搞清楚"鸭子类型到底怎么落地"的读者。
1. 从一次数据清洗事故说起:类型判断到底在防什么
1.1 那个把 True 当成 1 的金额字段
几年前做过一个对账脚本,外部接口返回的金额字段有时候是数字,有时候是字符串,我写了个清洗函数,逻辑大意是"如果是数字就保留,如果是字符串就转成 Decimal"。当时用的是isinstance(value, int),本地测试全绿,上了生产第一周就出问题了:某个布尔字段被误传进了金额位置,True一路被当成 1 参与求和,最后差了一块钱。查了两个小时才定位到isinstance(True, int)返回 True 这件事。
这个坑的本质在于,Python 的布尔类型是整型的子类,bool继承自int。所以任何基于isinstance(x, int)的判断,都会把True和False一起放进来。修法很简单,要么在判断里显式排除 bool:
isinstance(value, int) and not isinstance(value, bool)要么换个更精确的写法,直接比对类型:
type(value) is int这两种写法的差别,就是本篇要讲的核心。前者看的是"这个对象是不是 int 或者 int 的子孙",后者看的是"这个对象的类型恰好是不是 int 本身"。选哪个不是风格问题,是语义问题。
1.2 类型判断在 Python 里到底有几个入口
Python 里获取和判断对象类型的入口不止type()一个,实际写代码时常见的至少有这几类:
| 入口 | 作用 | 典型用途 |
|---|---|---|
type(obj) | 返回对象的实际类型 | 精确类型比对、调试打印 |
obj.__class__ | 对象上声明的类 | 属性访问、配合自定义逻辑 |
isinstance(obj, cls) | 判断是否为某类或其子类实例 | 大多数业务判断 |
issubclass(cls, cls) | 判断类之间的继承关系 | 元编程、框架注册 |
hasattr/callable | 判断能力而非类型 | 鸭子类型场景 |
typing.Protocol | 结构化类型判断 | 接口约束、类型检查 |
看起来选择很多,实际上每一层都有它该待的位置。type()回答的是"这是什么",isinstance()回答的是"这能不能当作什么用"。前者是身份,后者是能力。把这两个问题混为一谈,就是我上面那个事故的根源。
1.3 为什么 Python 不强制你声明类型
在讲具体用法之前,得先把 Python 的类型模型理顺,不然后面很多行为看起来会很怪。Python 是动态类型语言,变量本身没有类型,类型附着在对象上。a = 1之后再a = "hello"完全合法,因为a只是一个名字,指向的对象换了而已。这意味着类型判断永远是在运行时对对象做检查,而不是在编译期对变量做约束。
这套模型带来的好处是灵活,代价是所有类型相关的错误都推到运行时才暴露。所以类型判断写得好不好,直接决定了你的错误是"当场崩"还是"数据悄悄错"。我个人的习惯是:在系统边界(接口入参、文件读取、消息队列消费)上做严格判断,在内部分支上尽量依赖接口约定,别到处撒isinstance。
2. type() 与 isinstance() 的分工边界
2.1 type() 返回的到底是实例类型还是元类
先说清楚type()这个函数本身。它有两个完全不同的用法,很多人会混淆。
单参数形式type(obj)返回的是对象所属的类,也就是实例类型:
type(1) # <class 'int'> type("abc") # <class 'str'> type(None) # <class 'NoneType'> type([1, 2]) # <class 'list'>三参数形式type(name, bases, dict)是动态创建类,这时候type扮演的是元类的角色。默认情况下,所有普通类的元类都是type,所以type(int)返回的是<class 'type'>。这就引出一个经典问题:想拿"某个类的类型",type(cls)拿到的是元类,不是父类。要拿父类得用cls.__bases__或者cls.__mro__。
这个区别在写框架时特别重要。我见过有人在元编程代码里写type(MyClass) == type来判断"这是不是一个类",这在绝大多数情况下能跑通,但只要对方用了自定义元类(比如 ABCMeta),判断就失效了。正确写法是isinstance(MyClass, type),因为它会沿着元类的继承链往上走。
2.2 isinstance() 的继承链遍历逻辑
isinstance(obj, cls)的语义是:type(obj)是不是cls本身或者cls的子孙类。它内部大致会做这几件事:
- 取出
obj的类型,优先读obj.__class__,读不到或者不合适再走type(obj); - 拿这个类型和
cls比对; - 不匹配就沿着 MRO(方法解析顺序)往上找;
- 如果
cls是抽象基类,还会额外调用cls.__instancecheck__。
第二步到第三步就是继承链遍历。举个最直观的例子:
class Animal: pass class Dog(Animal): pass d = Dog() type(d) is Dog # True type(d) is Animal # False isinstance(d, Dog) # True isinstance(d, Animal) # Truetype(d) is Animal为 False,因为d的实例类型就是Dog,它不会因为继承了Animal就"变成"Animal类型。而isinstance为 True,因为它承认继承关系。
这个差异在多继承场景下会更明显,因为 MRO 是一条链而不是一棵树。isinstance会沿着 C3 线性化后的顺序逐个检查,只要命中任意一个祖先就返回 True。这也意味着一个类如果被注册到某个抽象基类上,哪怕没有实际继承,isinstance也会返回 True,这是另一套机制,后面单独讲。
2.3 什么场景必须用 type,什么场景必须用 isinstance
我给自己定的规则很简单:
用type(obj) is SomeClass的场景:
- 需要精确匹配,子类不应该被当作父类处理。比如金额字段必须是
int,不能是bool; - 分派逻辑里,
bool和int要分开处理,str和UserString要分开处理; - 调试或日志里打印真实类型。
用isinstance(obj, SomeClass)的场景:
- 只要对象"能当这个类用"就行,子类完全应该被接受。比如序列化函数接受
dict,那OrderedDict、defaultdict这些子类也该被接受; - 判断抽象基类,比如
isinstance(x, collections.abc.Mapping); - 写库的时候,给用户自定义子类留出空间。
有个特别好记的类比:type是查身份证,isinstance是查"你属不属于这个族群"。你养了只柴犬,查身份证它就叫柴犬,查族群它属于犬科。业务代码里绝大多数时候你关心的是族群,只有少数场景(金额、布尔、序列化格式)才需要精确到身份证。
顺带说一句,精确比较时用is而不是==。类型对象是单例,type(x) is int是内存地址比较,比==快,也不会有重载__eq__带来的意外。这个习惯值得养成。
3.class属性与 issubclass 的配合
3.1 obj.class并不是 type(obj) 的同义词
初学的时候很多人把obj.__class__当作type(obj)的语法糖,绝大多数情况下确实等价,但严格来说不等价。__class__是一个属性访问,走的是__getattribute__流程,可以被重载、可以被伪装。
最典型的例子是weakref.proxy。代理对象在被代理对象还活着的时候,访问proxy.__class__拿到的是被代理对象的类,所以isinstance(proxy, SomeClass)会返回 True,但type(proxy)拿到的是weakref.ProxyType。这两者不一致是有意为之的,代理就是为了让调用方感觉不到代理层的存在。
再看一个更极端的例子:
class Faker: @property def __class__(self): return int f = Faker() type(f) # <class '__main__.Faker'> f.__class__ # <class 'int'> isinstance(f, int) # True isinstance(f, Faker) # False这段代码里isinstance(f, Faker)居然是 False,因为isinstance优先读了__class__这个属性,看到返回的是int,就不再去查真实的类型了。这种写法在正经业务里基本用不上,但它说明了一个事实:isinstance的判断依据是对象的__class__声明,而不是内存里的类型标记。
知道这点有什么用?两个地方:一是框架里做对象包装(代理、惰性对象、ORM 实体)时,可以靠重写__class__让自己对外表现成目标类型;二是在排查一些稀奇古怪的isinstance行为时,先看看有没有哪个库重写了这个属性。我就在一个 ORM 项目里遇到过,模型实例的__class__被动态改成了查询结果集对应的代理类,导致type()和isinstance()结论打架。
3.2 issubclass 的入参校验和常见误用
issubclass(cls, classinfo)判断的是两个类之间的关系,第一个参数必须是类,传实例进去会直接抛TypeError:
issubclass(bool, int) # True issubclass(int, bool) # False issubclass(True, int) # TypeError: issubclass() arg 1 must be a class这个校验其实挺贴心的,它逼着你想清楚自己在判断"类关系"还是"实例关系"。我在代码审查里见过最常见的误用是把实例传进去,然后靠 try/except 吞掉异常当作 False,这种写法一旦遇到真异常就全乱了。
classinfo参数可以是元组,任意一个命中就返回 True,这个特性在写分派逻辑时很好用:
if issubclass(handler_cls, (FileHandler, StreamHandler)): handler_cls().attach()isinstance的第二个参数同样支持元组,这个后面讲实战时还会用到。另外从 Python 3.10 开始,两者都支持联合类型写法int | str,写起来更清爽,但要注意这会引入运行时开销,热路径上我还是偏向用元组。
3.3 MRO 在类型判断里扮演的角色
多继承下,isinstance的判断顺序取决于 MRO。看这个带混入类的例子:
class Base: pass class Mixin: pass class Child(Mixin, Base): pass Child.__mro__ # (Child, Mixin, Base, object)isinstance(Child(), Base)返回 True 是没疑问的,但如果你在某个父类的__instancecheck__里做了副作用操作,那判断顺序就有影响了。抽象基类的register机制就依赖这一点:注册到父类上的子类,isinstance检查会先命中父类的__instancecheck__。
实践中的建议是,别在热路径上做复杂的 MRO 判断。isinstance本身不慢,但如果命中的是一个重写了__instancecheck__的抽象基类,那开销可能翻好几倍。比如isinstance(x, collections.abc.Iterable)背后要走__subclasshook__,比isinstance(x, list)贵不少。如果这个判断在循环里跑了上百万次,性能差异就能测出来了。
4. 鸭子类型与结构化判断:不该用 isinstance 的地方
4.1 hasattr 与 callable:判断能力而不是身份
Python 社区有句老话:如果它走起来像鸭子、叫起来像鸭子,那它就是鸭子。翻译成代码就是,与其判断对象是不是某个类,不如判断它有没有某个方法。
举个真实场景。写一个通用的"关闭资源"函数,参数可能是文件对象、数据库连接、网络会话,它们的共同点是都有close方法,但互相之间没有任何继承关系。这时候用isinstance就得把所有类型列一遍,还得担心用户自定义的对象没被覆盖到。用能力判断就干净很多:
def safe_close(resource): close = getattr(resource, "close", None) if callable(close): close()这里没有涉及任何类型,只关心"有没有可调用的 close"。这就是鸭子类型的落地方式。callable()判断的是对象是否可调用,它检查的是类型上有没有__call__,比hasattr(obj, "__call__")更准确,因为后者可能被实例属性伪装。
不过hasattr有个坑得提醒一下:它内部会尝试getattr并捕获AttributeError。如果目标属性的 property getter 内部抛了别的异常,hasattr会把它原样抛出来,而不是返回 False。Python 3.2 之后收窄了捕获范围,只吞AttributeError,这算是改进了,但依然意味着属性访问的副作用会被触发。对惰性加载的属性来说,一次hasattr可能就触发了一次数据库查询,这点在性能敏感的地方要注意。
4.2 runtime_checkable 的 Protocol:结构化类型判断的正解
Python 3.8 引入的typing.Protocol把鸭子类型往前推了一步,从"运行时碰运气"变成了"可以显式声明结构"。看个例子:
from typing import Protocol, runtime_checkable @runtime_checkable class Closable(Protocol): def close(self) -> None: ... class MyConn: def close(self) -> None: print("closed") isinstance(MyConn(), Closable) # TrueMyConn没有继承Closable,但因为结构上匹配,isinstance返回 True。这背后的机制是Protocol生成的类重写了__instancecheck__,逐个检查协议里声明的方法是否存在。
这里有个必须知道的限制:runtime_checkable只检查方法名是否存在,不检查方法签名。也就是说,就算你的close方法要求三个必填参数,只要名字在,isinstance照样返回 True。所以这个机制适合做"粗筛",不适合做严格契约校验。真要校验签名,得用inspect.signature手动比对,那就复杂了,一般项目里不值得。
另一个限制是它不能用于issubclass的某些场景,也不能检查数据属性(只有方法才会被检查,Protocol里声明的变量在运行时检查中会被忽略)。这些细节在类型检查器(比如静态分析工具)里行为是一致的,但运行时判断会松一些,心里要有数。
4.3 抽象基类注册:不改继承也能通过 isinstance
collections.abc里那些抽象基类支持register方法,能把一个不相关的类"挂"到基类名下:
from collections.abc import Mapping class MyConfig: def __getitem__(self, key): ... def __iter__(self): ... def __len__(self): ... Mapping.register(MyConfig) isinstance(MyConfig(), Mapping) # True这种注册是虚拟的,MyConfig.__mro__里不会出现Mapping,也不会继承到任何方法,仅仅是让isinstance和issubclass认账。适合的场景是你没法改那个类的定义(比如第三方库),但希望它在类型判断上被当作某个抽象类型。
代价是这种方法只影响判断,不影响行为。如果代码里真去调mapping.keys(),而MyConfig没实现,照样崩。所以我的建议是,注册之前先确认这个类确实实现了抽象基类要求的所有方法,否则就是在给未来的自己挖坑。更稳妥的做法是继承抽象基类,让 Python 在实例化时就把缺失的方法报出来。
5. 高频实战:类型判断落在哪几类代码里
5.1 数据序列化与反序列化中的类型分派
写 JSON 序列化扩展的时候,类型分派是最典型的应用。标准库的json.dumps遇到不认识的类型会抛TypeError,要支持Decimal、datetime、自定义对象,就得靠default回调做类型判断:
import json from decimal import Decimal from datetime import datetime, date def default_handler(obj): if isinstance(obj, Decimal): return str(obj) if isinstance(obj, (datetime, date)): return obj.isoformat() if isinstance(obj, set): return sorted(obj) raise TypeError(f"不可序列化的类型: {type(obj).__name__}")注意这里我刻意用了isinstance而不是type() is。原因是日期相关类型有大量子类,比如某些库会返回datetime的子类来携带时区附加信息,用type精确比对会漏掉。而Decimal的子类极少见,用isinstance也不会有问题。
顺序也有讲究。判断要按"从具体到宽泛"来排,bool一定要在int前面,datetime最好在date前面(虽然datetime是date的子类,但处理逻辑不同)。这类分派代码一旦顺序写反,症状是某些类型走了错误的分支,而且不会报错,属于典型的静默 bug。
5.2 参数校验里的类型收窄
接口层做参数校验时,我一般会写一个统一的校验函数,把类型判断、范围检查、格式转换放在一起。核心思路是先把类型收窄到预期范围,再做业务判断:
def parse_amount(value): if isinstance(value, bool): raise TypeError("金额不接受布尔值") if isinstance(value, int): return Decimal(value) if isinstance(value, str): try: return Decimal(value.strip()) except Exception: raise ValueError(f"金额格式非法: {value!r}") raise TypeError(f"金额类型不支持: {type(value).__name__}")这里第一行显式拦掉了bool,这就是开头那个事故的补丁。为什么要单独判bool而不是直接type(value) is int?因为业务上可能真的存在int的合法子类(比如某些数值库的自定义整数类型),用isinstance加排除法更宽容。两种写法没有绝对对错,取决于你的业务边界要多严。
type(value).__name__这个写法在报错信息里很好用,比直接打印type(value)可读性好,日志里看起来清爽。但它有个前提,对象得真的有__class__,对某些奇怪的对象(比如某些 C 扩展返回的特殊句柄)可能拿不到名字,所以关键路径上我会再包一层getattr兜底。
5.3 插件加载与策略路由
插件系统里,类型判断通常用在两个地方:加载时校验插件是否实现了必需接口,运行时按类型路由到不同处理器。
校验接口我倾向于用issubclass而不是isinstance,因为这时候手里只有类,还没有实例:
class PluginBase: def run(self, payload): ... def load_plugin(cls): if not issubclass(cls, PluginBase): raise TypeError(f"{cls.__name__} 未继承 PluginBase") return cls()路由部分则常用字典映射加类型判断兜底:
handlers = { "text": TextHandler, "image": ImageHandler, } def dispatch(payload): for key, handler_cls in handlers.items(): if isinstance(payload, handler_cls.accepts): return handler_cls().handle(payload) raise TypeError(f"没有处理器接受 {type(payload).__name__}")这种写法的好处是新增类型只要改handlers表,不用动路由逻辑。坑在于遍历顺序会影响结果,如果有两个处理器的accepts类型存在继承关系,先命中的那个会赢。所以我在注册表里加了一个优先级字段,按数值排序后再遍历,避免依赖字典插入顺序这种隐式行为。
6. 那些容易翻车的类型判断细节
6.1 bool、int 与枚举之间的纠缠
bool是int的子类这件事前面提过了,再补一个相关的:IntEnum也是int的子类。这意味着如果你在金额、计数这类数值字段上用isinstance(x, int)做校验,枚举值也能混进来。看这个:
from enum import IntEnum class Status(IntEnum): ACTIVE = 1 INACTIVE = 0 isinstance(Status.ACTIVE, int) # True type(Status.ACTIVE) is int # False如果业务上要求金额只能是纯数字,那就得用type(x) is int,或者显式排除bool和IntEnum。我现在的习惯是,凡是涉及钱、数量、ID 这类必须精确的字段,一律用type(x) is int,宁可严格一点也不放过脏数据。毕竟在数据边界上严格,下游能少很多事。
还有一个坑是float和int的关系。isinstance(1, float)是 False,isinstance(1.0, int)也是 False,它们之间没有继承关系。但如果用numbers.Number做判断,两者都会通过,Decimal也会通过。要不要用numbers抽象基类,取决于你希望多宽容。对需要精确计算的场景,我反而希望它窄一点,明确只接受Decimal,把float挡在外面,避免二进制浮点误差。
6.2 迭代器、生成器与 Iterable 的边界
判断一个对象能不能迭代,很多人第一反应是hasattr(x, "__iter__"),也有人用isinstance(x, collections.abc.Iterable)。这两种写法在绝大多数情况下结论一致,但有一个著名的例外:老式的序列协议。
只实现了__getitem__、没有__iter__的类,for循环能正常遍历它(Python 会退化成按索引取),但isinstance(x, Iterable)返回 False,因为Iterable的 hook 只检查__iter__。这个差异在对接老代码或者某些 C 扩展返回的对象时会撞上。
更稳妥的迭代判断是直接尝试iter():
def is_iterable(obj): try: iter(obj) return True except TypeError: return False这个写法的语义最准确:能拿到迭代器就是可迭代的。代价是有异常开销,在热路径上不如isinstance快。所以我的做法是,冷路径(配置解析、初始化)用iter()试,热路径(每帧、每个请求)用isinstance加缓存。
生成器的判断还有个细节,types.GeneratorType只匹配生成器函数产生的对象,不匹配实现了迭代器协议的类。如果要判断"是不是生成器",得用inspect.isgenerator;如果要判断"是不是迭代器",用isinstance(x, collections.abc.Iterator)更合适。这几个概念混在一起的时候,先问自己到底需要什么语义,再选工具。
6.3 性能:什么时候该在意类型判断的开销
单次isinstance的开销在纳秒到微秒级,绝大多数业务代码完全不用管。但如果类型判断出现在百万级循环里,或者每帧执行几十次,那就有必要优化了。
我实测过一组粗略数据,在普通办公机上跑一百万次,大致是这样的量级:
| 写法 | 相对开销 |
|---|---|
type(x) is int | 最快,一次指针比较 |
isinstance(x, int) | 略慢,需要走继承链 |
isinstance(x, (int, str, float)) | 随元组长度线性增长 |
isinstance(x, collections.abc.Mapping) | 明显更慢,要调 hook |
isinstance(x, SomeProtocol) | 最慢,逐个方法检查 |
具体数字受解释器版本、机器影响很大,我不建议死记,记住量级关系就够了。优化方向有几个:把最常命中的类型判断放前面,减少元组长度,对协议检查的结果做缓存(同一个类型只查一次)。最后这个技巧在插件路由里特别有效,可以按type(payload)建立缓存字典,键是类型对象,值是命中的处理器,这样第一次之后就是 O(1) 的字典查找。
要提醒的是,别为了性能把可读性牺牲掉。type(x) is int和isinstance(x, int)的差距在业务代码里基本测不出来,只有在明确的性能分析报告指出这里是热点时才值得优化。我见过有人把所有isinstance都改成了type(...) is ...,结果子类对象全被拒绝,接口直接挂掉,这个教训比那点性能收益值钱多了。
还有一个跟性能相关但容易被忽略的点:try/except的异常开销。用异常做类型判断(EAFP 风格)在成功路径上很快,但失败路径的开销比isinstance大得多。如果失败率超过百分之十左右,isinstance反而更划算。这个阈值是经验值,具体要看场景。
7. 一套可以直接抄走的判断规范
把上面这些揉一揉,我目前在项目里用的规范大致是这样的,写出来给需要的人参考。
边界层(接口入参、文件解析、消息消费)用严格判断,优先type(x) is T;内部逻辑用宽容判断,优先isinstance;判断"能不能用"的时候,优先能力判断(hasattr、callable、Protocol)而不是类型判断。数值字段统一加一条规则:bool和IntEnum必须显式排除,这类 bug 一旦流到下游,排查成本极高。
写分派逻辑时,把判断规则和处理器做成表,别写一长串if/elif。表驱动的写法有两个好处:新增类型只改表,容易做单元测试;遍历顺序显式可控,不依赖代码位置。
另一个我坚持的习惯是,报错信息里永远带上真实类型名。raise TypeError(f"期望 str,实际收到 {type(value).__name__}")这一行字,在线上排查时能省下半小时。如果对象可能是None,再补一个value is None的单独分支,因为type(None).__name__打印出来是NoneType,不直观。
最后说个我自己的体会。类型判断写得多不多,其实反映的是你对边界的定义清不清楚。如果一个模块里到处是isinstance,大概率是接口契约没定好,把责任推给了运行时。反过来,如果整个模块一个类型判断都没有,又可能在边界上太松懈。我现在的做法是,只在三个地方写类型判断:系统入口、序列化层、插件注册。其他地方靠约定和测试兜底。这套划分不一定适合所有人,但至少让我少踩了不少坑。