Python对象比较入门:==、is、__eq__与__hash__核心解析

📅 发布时间:2026/9/4 3:21:14
Python对象比较入门:==、is、__eq__与__hash__核心解析
如果你刚开始学 Python可能遇到过这样的困惑用同一个类创建了两个对象打印出来内容完全一样可当你用去比较时结果却是False。这就像两个双胞胎站在你面前容貌相同、衣着相同但 Python 不认他们的脸只认他们是不是同一个人。对于很多从 Java、C 转过来的同学来说这个行为有点反直觉因为 Java 里对象的也是比较地址但 Python 对两个“看起来相等”的对象处理得更加细致。今天这篇文章我们就围绕“同一个类为什么两个对象大不相同”这个问题展开。我会从 Python 对象模型中最核心的id、type、值三个概念讲起重点解释is与的区别、类属性与实例属性的存储差异、自定义对象的__eq__和__hash__该怎么写最后用一个完整的用户对象去重案例把比较规则真正落地。无论你是刚入门 Python还是写了一段时间业务代码后经常被列表去重、集合去重搞晕都可以从这篇文章里找到答案。1. 从一个看似“诡异”的现象说起先看一段非常简单的代码很多初学者都亲手运行过类似逻辑class User: def __init__(self, user_id, name): self.user_id user_id self.name name u1 User(1, 张三) u2 User(1, 张三) print(u1) print(u2) print(u1 u2) print(u1 is u2)输出结果会是__main__.User object at 0x000001A8B0C6C0D0 __main__.User object at 0x000001A8B0C6C730 False False如果你在 IDE 或者 Jupyter 里运行print(u1)可能显示类似__main__.User object at 0x...的信息看起来并不直观。即便你给类加上了__repr__让两个对象打印出来都是User(user_id1, name张三)的结果依然是False。为什么因为 Python 中用户自定义类默认继承自object而object类实现的__eq__本质上就是身份比较也就是“这两个对象是不是同一个对象”。你创建u1和u2时虽然传入的参数一样但 Python 会在内存中创建两个不同的对象它们的身份不同自然返回False。这个现象在真实项目中很容易引发问题。比如你从数据库查出了两条记录每条记录封装成一个对象又比如你从两个接口分别拿到了用户信息想要合并到一个集合里去重。如果你没有自定义“相等”的规则Python 默认只认对象身份不认业务主键结果就会和你的直觉大相径庭。这篇文章要解决的就是这种“看起来相同实际不相等”的问题。下面我们先从 Python 对象模型最底层的概念入手。2. 理解 Python 对象的三要素id、type 与值在 Python 中一切数据都是对象。整数是对象字符串是对象列表是对象函数也是对象。一个对象在创建之后至少包含三个核心信息身份identity、类型type和值value。2.1 身份id(obj)身份是对象的唯一标识可以使用内置函数id()获取。在 CPython 官方解释器中id()返回的值通常可以理解为对象的内存地址但在语言规范层面它更应该被理解为一个“身份编号”。程序运行期间只要对象没有被销毁这个编号就不会改变。a [1, 2, 3] b a print(id(a)) print(id(b)) print(id(a) id(b))运行结果中id(a)和id(b)完全一样因为b a并没有创建新列表只是让b指向了a同一个对象。如果写成b a[:]就是切片复制会生成一个新的列表对象id(b)和id(a)就不一样了。这个差异是理解后续内容的基础。2.2 类型type(obj)类型决定了一个对象支持哪些操作。可以使用type(obj)查看对象类型print(type(100)) # class int print(type(hello)) # class str print(type([])) # class list这里也能看出类和对象的直接关系User是一个类通过User(...)创建出来的实例就是一个具有User类型的对象。类本身也是一个对象我们称之为“类对象”它的类型是type。2.3 值对象的内部数据值是对象真正保存的数据。同样是int类型对象100和200的值不同同样是列表对象[1,2]和[1,2,3]的值也不同。对于自定义对象来说值通常由实例属性构成。把这三个信息放在一起看可以更准确地回答“对象大不相同”的问题两个对象可能因为身份不同而不同因为类型不同而不同也可能因为实例属性值不同而不同。而 Python 中is和正是从不同维度来判断对象是否相同is看身份看值但“值”是否相等又取决于类是否定义了比较规则。3. 环境准备与示例代码说明这篇文章的示例代码不依赖任何第三方库你只需要准备一个可用的 Python 环境即可。建议使用 Python 3.7 及以上版本因为后续实战部分会用到dataclasses模块它在 Python 3.7 才正式成为标准库。如果你本机还没有安装 Python可以去 Python 官网下载稳定版本安装时记得勾选“Add Python to PATH”。在命令行中确认安装成功python --version如果你的电脑上同时有 Python 2 和 Python 3可能需要这样检查python3 --versionWindows 下有时会使用py -3 --version只要能看到类似Python 3.9.x或更高的版本号环境就满足要求了。在编辑器方面VS Code、PyCharm、Jupyter Notebook 都可以。为了方便看到每个对象的id和哈希值建议使用一个单独的.py文件运行或者直接在交互式 Python 终端中逐步执行。4. “is” 与 “” 的本质区别很多 Python 学习者都听过这样一句话is比较的是内存地址比较的是值。这种说法大体正确但不是特别严谨。更准确地说is比较的是对象身份而比较的是对象值是否相等。4.1 is 的身份比较is是一个身份运算符它判断两个变量是否指向同一个对象。如果两个变量指向同一个对象那么它们的id一定相同is返回True。a [1, 2, 3] b a c a[:] print(a is b) # True print(a is c) # False print(id(a) id(c)) # False这里c a[:]通过切片创建了一个新列表它的内容虽然和a一样但对象身份不同所以is的结果是False。is有一个非常重要的特点它不会触发对象里的__eq__方法。即使一个类把__eq__写得再复杂is也只会去看对象的身份。这也就是为什么判断一个对象是否为None最规范、性能最好的写法是if x is None而不是if x None。因为None在 Python 中是单例对象全程序只有一个None用is判断最安全。4.2 的相等比较其实是一个语法糖它会调用左操作数的__eq__方法。例如left right会被 Python 翻译成类似left.__eq__(right)的形式。如果left.__eq__返回的是NotImplementedPython 会再尝试调用right.__eq__(left)来兜底。对于内置类型比如整数、字符串、列表会比较它们的内容print(100 100) # True print(abc abc) # True print([1, 2] [1, 2]) # True对于自定义的普通类如果没有定义__eq__继承自object的默认实现就是身份比较。换句话说对于普通自定义对象u1 u2本质上等价于u1 is u2正是因为这种默认行为才出现了文章开头代码中的False。我们后面会讲如何重写__eq__来改变这个行为。4.3 小心小整数缓存带来的误判Python 为了性能会在解释器启动时缓存一部分小整数对象通常是-5到256之间的整数。这意味着在某种写法下两个看似不同的整数变量可能指向同一个对象。a 100 b 100 print(a is b) # True因为小整数被缓存有人可能会感到疑惑不是说is比较身份吗为什么这里返回True原因就是100是缓存区间内的整数Python 不会为每次赋值都创建新对象。再看超出这个范围的整数a 1000 b 1000 print(a is b) # 在某些环境中可能是 False这个结果并不稳定和代码块编译方式、解释器实现都有关系。因此一定不要把is用于数值或字符串字面量比较。判断数值是否相等老老实实使用。4.4 从对象角度看“大不相同”综合本节内容可以得出一个结论两个对象即使打印出来完全一致它们也可能因为身份不同而归属于“不同的人”而即便身份相同也就是同一个对象被两个变量引用它们也一定相等。理解这层关系后再去看列表的index、count、in操作就不会觉得奇怪了因为这些操作底层都用到了“相等”判断。如果你发现列表里的对象明明内容一样list.index()却找不到它多半是因为这个类没有实现合理的__eq__。下一节讲完属性存储后我们会专门处理自定义相等规则。5. 类属性与实例属性另一个“对象不同”的来源除了比较规则之外同一个类创建出的对象还经常因为属性存储位置不同而表现不同。Python 中的属性分为类属性和实例属性很多人踩坑就是分不清这两者的生命周期。5.1 类属性是所有实例共享的吗先看一个例子class Counter: count 0 c1 Counter() c2 Counter() print(c1.count) # 0 print(c2.count) # 0这里count定义在类的内部属于类属性。在没有被实例覆盖之前c1.count和c2.count实际上访问的是同一个Counter.count。因此我们也可以说类属性在类这个层面是所有实例共享的。不过要注意一点c1.count 1并不是修改类属性而是给c1这个实例创建了一个新的实例属性count。这个操作的完整含义是“先把c1.count读出来加 1然后把这个新值绑定到c1上”。c1.count 1 print(c1.count) # 1 print(c2.count) # 0 print(Counter.count) # 0从输出可以看到c1自己有了一个实例属性后就不再和类属性共享了。这也是面向对象初学者最常见的“对象之间大不相同”的原因之一你以为是修改公共计数实际上只是在某一个对象上创建了私有属性。5.2 可变类属性的经典陷阱真正容易引发线上问题的是可变类型的类属性。比如class Student: courses [] def __init__(self, name): self.name name s1 Student(小明) s2 Student(小红) s1.courses.append(数学) print(s2.courses)这里append操作并不会触发实例属性创建而是直接修改了Student.courses这个类属性指向的列表。所以输出是[数学]也就是说你只是想给小明选一门课结果小红也被选了。归根结底是因为所有实例都共享了同一个列表。正确的做法是在__init__中把可变对象设置为实例属性class Student: def __init__(self, name): self.name name self.courses [] s1 Student(小明) s2 Student(小红) s1.courses.append(数学) print(s2.courses) # []这样每个实例都会有自己的列表互不干扰。5.3 用slots控制对象属性范围默认情况下Python 的类实例会有一个__dict__字典用来保存实例属性。这带来很大的灵活性你可以在运行时给对象动态添加新属性。但灵活的代价是内存占用更高而且某些属性名写错时不会及时报错。如果你希望类的实例严格固定属性可以使用__slots__class Point: __slots__ (x, y) def __init__(self, x, y): self.x x self.y y p Point(1, 2) p.z 3 # AttributeError: Point object has no attribute z使用__slots__后实例不再创建__dict__只能用声明过的属性。这在创建大量对象时能降低内存使用也能避免属性名写错带来的隐蔽 bug。不过__slots__不影响__eq__和__hash__的默认行为。它解决的是“属性存储不同”的问题而不是“对象比较规则”的问题。接下来我们要解决的是如何让两个业务上相同的对象能够被 Python 判定为相等。6. 控制对象相等性重写eq与hash业务系统里最典型的场景就是两个人其实代表同一个用户只要他们的user_id相同就应该认为是同一个人。这时候默认的身份比较显然不满足需求我们需要自定义比较规则。6.1 最简版eq先写一个只按user_id判断相等的类class User: def __init__(self, user_id, name): self.user_id user_id self.name name def __eq__(self, other): if not isinstance(other, User): return NotImplemented return self.user_id other.user_id这里用isinstance(other, User)做类型检查避免把User和别的对象混着比较。如果类型不对返回NotImplemented让 Python 去尝试other侧的__eq__而不是直接返回False这样更符合 Python 的协议惯例。现在再看u1 User(1, 张三) u2 User(1, 李四) print(u1 u2) # True print(u1 ! u2) # False你会发现只重写了__eq__Python 3 也会自动处理!它会用__eq__的结果取反所以u1 ! u2返回False。这是因为User的两个实例虽然名字不同但user_id相同被我们定义为同一用户。这个结果符合业务直觉但如果你马上把这些对象放到集合里可能立刻遇到新问题。6.2 为什么重写eq之后对象变得不可哈希看下面的操作u1 User(1, 张三) u2 User(1, 李四) s {u1, u2}运行后会出现一个异常TypeError: unhashable type: User原因在于 Python 的哈希协议要求如果两个对象相等它们的哈希值也必须相等。默认情况下一个类如果重写了__eq__却没有定义__hash__Python 3 会把该类的__hash__自动设置为None表示这个类的对象不可哈希。set、frozenset、dict的键都依赖对象的哈希值来快速定位。现在对象不可哈希它们自然无法进入这些容器。这是很多初学者在自定义类之后忽然发现“对象无法放进集合”的直接原因。6.3 同时定义hash要让对象重新变成可哈希可以显式定义__hash__。它的核心原则是如果a b那么hash(a)必须等于hash(b)。既然我们的__eq__只看user_id那么__hash__也应该只基于user_id计算class User: def __init__(self, user_id, name): self.user_id user_id self.name name def __eq__(self, other): if not isinstance(other, User): return NotImplemented return self.user_id other.user_id def __hash__(self): return hash(self.user_id)现在再执行u1 User(1, 张三) u2 User(1, 李四) print(u1 u2) # True print(hash(u1)) print(hash(u2)) s {u1, u2} print(len(s)) # 1两个user_id相同的对象会被放入集合中的同一个位置因此集合自动去重长度为 1。这里有一个需要特别提醒的点u1 u2是True的时候如果hash(u1) ! hash(u2)会直接破坏哈希表的不变式整个集合的行为都可能变得不可预测。所以千万不要在__eq__里用多个字段但__hash__只用一个字段那样会导致“相等对象哈希不同”的问题。6.4hash与可变对象的矛盾上面的实现虽然解决可哈希问题但有一个隐患user_id仍然是可变的属性。如果对象创建后有人修改了user_id或name集合中已有的哈希值就会失效。比如u User(1, 张三) s {u} u.user_id 2 print(u in s) # False因为没有重新计算哈希对象已经放进集合再修改影响哈希的字段会导致对象“找不到自己”。所以在工程上凡是用于哈希的字段都应该尽量不可变。最稳妥的方案是使用dataclass的frozenTrue让整个对象在创建后不可修改或者通过property只读封装核心字段。7. 完整实战让用户对象能比较、去重、可哈希理论知识讲完我们用一个完整的实战例子把方案串起来。假设你现在有一个用户列表其中部分用户虽然user_id相同但name字段可能因为数据来源不同而不一致。现在需要判断两个用户对象是否代表同一个用户。对用户列表按user_id去重。能够以用户对象作为字典的键。对象创建后不能被随意修改保证哈希稳定。7.1 使用 dataclass 实现不可变 User在 Python 3.7 及以上版本中我们可以使用dataclasses快速实现这个需求。dataclass会自动根据字段生成初始化方法、__repr__并在eqTrue时自动生成基于字段的__eq__。不过这里的重点是相等性只需要看user_id不需要看name。所以我们要把name字段排除出compare和hash计算。完整代码如下from dataclasses import dataclass, field dataclass(eqTrue, frozenTrue) class User: user_id: int name: str field(compareFalse, hashFalse, reprTrue)解释一下关键参数eqTrue生成__eq__方法。frozenTrue实例创建后不可修改保证__hash__稳定。name字段使用field(compareFalse, hashFalse)表示它不参与相等比较也不参与哈希值计算。reprTrue是默认值让repr(user)仍然能看到名字。再看一组运行验证u1 User(1, 张三) u2 User(1, 李四) print(u1) print(u1 u2) print(len({u1, u2}))输出User(user_id1, name张三) True 1u1和u2的name不一样但它们被判定为同一个用户去重后只剩一个。并且由于frozenTrue尝试修改属性会报错u1.name 王五 # dataclasses.FrozenInstanceError: cannot assign to field name这样写既符合业务规则也保证了哈希安全。7.2 列表去重方法假设原始用户列表是users [ User(1, 张三), User(2, 李四), User(3, 王五), User(1, 张三丰), User(2, 李四), ]我们希望按user_id去重同时保留第一次出现的顺序。可以这样写def unique_users(user_list): seen set() result [] for user in user_list: if user in seen: continue seen.add(user) result.append(user) return result result unique_users(users) for user in result: print(user)输出User(user_id1, name张三) User(user_id2, name李四) User(user_id3, name王五)这里user in seen会先调用User.__hash__定位哈希桶再用User.__eq__判断是否与桶内已有元素相等效率很高而且逻辑非常清晰。7.3 以对象作为字典键由于User现在可哈希它可以作为字典的 key。比如我们要记录每个用户是否属于 VIPvip_map { User(1, 张三原始): True, User(2, 李四原始): False, } print(vip_map[User(1, 另一个名字)]) # True当使用User(1, 另一个名字)查询字典时Python 会先计算哈希哈希值和键一致再调用__eq__比较两个键user_id相同于是找到了原来的键。这正是哈希表按照业务主键索引对象的典型案例。7.4 不引入 dataclass 的手写等价版本如果你更想理解底层实现手写版本也很直观class User: __slots__ (_user_id, _name) def __init__(self, user_id, name): self._user_id user_id self._name name property def user_id(self): return self._user_id property def name(self): return self._name def __eq__(self, other): if not isinstance(other, User): return NotImplemented return self.user_id other.user_id def __hash__(self): return hash(self.user_id) def __repr__(self): return fUser(user_id{self.user_id!r}, name{self.name!r})_user_id和_name使用下划线表示私有只提供只读属性外部无法直接修改。这个版本不依赖dataclass逻辑清晰而且同样能安全地放入集合。两者选一个即可如果你的 Python 版本较老推荐手写版本。8. 常见问题与排查思路在开发中经常有人因为对象比较规则没有定义好出现各种难以排查的“灵异现象”。下面整理几个常见问题并给出排查思路。问题现象常见原因解决思路两个对象内容相同但返回False类没有重写__eq__默认做身份比较根据业务需要实现__eq__比较关键字段将对象放入set报unhashable type重写了__eq__但没有定义__hash__同步定义__hash__或者设置__hash__ None明确不支持哈希重写__eq__后对象放入集合出现两个重复项__hash__的字段与__eq__字段不一致保证相等对象的哈希值相等通常让双方使用同一组主键字段类中定义了列表或字典属性一个实例修改后其他实例也变可变对象作为类属性被所有实例共享在__init__中把可变对象初始化为实例属性对象放入集合后修改主键之后in判断不准确对象的哈希字段被改变集合定位失效让对象不可变例如frozenTrue或避免修改参与哈希的字段运行代码时发现两个变量is为True但代码中明明是两次赋值小整数缓存、字符串驻留等原因导致指向同一对象对数值、字符串比较使用不要使用is如果你遇到上述以外的异常建议按下面的顺序排查先确定对象类型是不是同一个类。打印id(obj)观察身份是否相同。打印obj.__dict__或使用dir(obj)查看有哪些实例属性和类属性。检查类中是否重写了__eq__和__hash__。在一个临时脚本中单独测试两个对象的相等性和哈希值排除业务代码干扰。9. 工程最佳实践了解了原理之后我们再从工程视角总结一些经验。这些建议能帮你减少很多不必要的坑。9.1 注意 和 is 的适用场景用于判断“值相等”适合业务比较is用于判断“身份相同”适合比较单例对象。在写代码时判断None一定要用is None因为None是单例不存在业务上的“相等 None”概念。判断真值、空列表、空字符串时遵循 Python 的习惯写法更清晰。9.2 重写eq就一定要考虑hashPython 的哈希协议不是可选项而是集合、字典正确工作的前提。如果你重写了__eq__要么同步重写__hash__让相等对象拥有相同哈希要么把__hash__主动设为None让对象保持不可哈希避免误用。一旦进入set就必须保证参与__eq__和__hash__的字段不会被修改。9.3 优先使用 dataclass 或值对象对于纯数据类优先使用标准库dataclass。它能自动生成初始化、比较、显示等方法减少手写重复代码。搭配frozenTrue会让对象更接近值对象不可变性也让哈希行为更加稳定。需要说明的是dataclass在默认情况下eqTrue会比较所有字段。如果某些字段不参与业务相等性要通过field(compareFalse, hashFalse)排除。这样可以保证语义和性能都符合预期。9.4 定义尽量不可变的领域对象在分布式系统、缓存系统或消息队列里一个领域对象的唯一标识往往是业务主键。设计对象时主键字段应当尽量不可变。如果确实需要修改优先使用dataclasses.replace生成一个新对象而不是原地修改from dataclasses import replace u1 User(1, 张三) u2 replace(u1, name张三改) print(u2)这样保证了原对象仍能在集合和字典中正常工作新对象拥有新的名称但user_id不变。9.5 不要依赖 id 的“内存地址”来做业务比较有些开发者为了性能会直接用id()做对象缓存。这种做法弊大于利。因为id在解释器中只保证对象存活期间唯一对象销毁后它的id可能被后续新对象复用。如果你长期持有不正确的 id 值代码会在极端情况下出错。真正业务上的唯一性应当由业务主键字段显式表达。10. 总结与进一步学习回到文章开头的问题同样是一个类凭什么你的 Python 对象和别人的大不相同本质上是因为“对象相同”从来不是一句话能说清的。从身份维度看两个对象除非是同一个对象否则一定不同从值维度看只有类定义了合理的比较规则对象才可能被判定为相等从属性存储维度看类属性和实例属性各有生命周期可变对象共享也会导致对象之间行为不一致。真正理解了id、is、、__eq__、__hash__这些概念你再碰到自定义对象去重、列表查找、字典索引的问题就不会一头雾水了。文章的实战例子只是最基础的一层接下来你还可以继续研究functools.total_ordering它可以帮助你通过少量方法补齐排序比较也可以深入学习NamedTuple、attrs、pydantic这些库它们在数据类领域有不同的设计取舍。如果今天的内容对你有帮助建议收藏这篇笔记下次在项目里被 Python 对象比较问题困住时再打开看看。也欢迎在评论区留言分享你遇到的“看起来相等却不相等”的坑一起讨论更优雅的解法。