如果你刚开始学 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_id=1, 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__返回的是NotImplemented,Python 会再尝试调用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的frozen=True,让整个对象在创建后不可修改,或者通过property只读封装核心字段。
7. 完整实战:让用户对象能比较、去重、可哈希
理论知识讲完,我们用一个完整的实战例子把方案串起来。假设你现在有一个用户列表,其中部分用户虽然user_id相同,但name字段可能因为数据来源不同而不一致。现在需要:
- 判断两个用户对象是否代表同一个用户。
- 对用户列表按
user_id去重。 - 能够以用户对象作为字典的键。
- 对象创建后不能被随意修改,保证哈希稳定。
7.1 使用 dataclass 实现不可变 User
在 Python 3.7 及以上版本中,我们可以使用dataclasses快速实现这个需求。dataclass会自动根据字段生成初始化方法、__repr__,并在eq=True时自动生成基于字段的__eq__。
不过这里的重点是:相等性只需要看user_id,不需要看name。所以我们要把name字段排除出compare和hash计算。完整代码如下:
from dataclasses import dataclass, field @dataclass(eq=True, frozen=True) class User: user_id: int name: str = field(compare=False, hash=False, repr=True)解释一下关键参数:
eq=True:生成__eq__方法。frozen=True:实例创建后不可修改,保证__hash__稳定。name字段使用field(compare=False, hash=False),表示它不参与相等比较,也不参与哈希值计算。repr=True是默认值,让repr(user)仍然能看到名字。
再看一组运行验证:
u1 = User(1, "张三") u2 = User(1, "李四") print(u1) print(u1 == u2) print(len({u1, u2}))输出:
User(user_id=1, name='张三') True 1u1和u2的name不一样,但它们被判定为同一个用户,去重后只剩一个。并且由于frozen=True,尝试修改属性会报错:
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_id=1, name='张三') User(user_id=2, name='李四') User(user_id=3, name='王五')这里user in seen会先调用User.__hash__定位哈希桶,再用User.__eq__判断是否与桶内已有元素相等,效率很高,而且逻辑非常清晰。
7.3 以对象作为字典键
由于User现在可哈希,它可以作为字典的 key。比如我们要记录每个用户是否属于 VIP:
vip_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 f"User(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判断不准确 | 对象的哈希字段被改变,集合定位失效 | 让对象不可变,例如frozen=True,或避免修改参与哈希的字段 |
运行代码时发现两个变量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就一定要考虑hash
Python 的哈希协议不是可选项,而是集合、字典正确工作的前提。如果你重写了__eq__,要么同步重写__hash__,让相等对象拥有相同哈希;要么把__hash__主动设为None,让对象保持不可哈希,避免误用。一旦进入set,就必须保证参与__eq__和__hash__的字段不会被修改。
9.3 优先使用 dataclass 或值对象
对于纯数据类,优先使用标准库dataclass。它能自动生成初始化、比较、显示等方法,减少手写重复代码。搭配frozen=True,会让对象更接近值对象,不可变性也让哈希行为更加稳定。
需要说明的是,dataclass在默认情况下,eq=True会比较所有字段。如果某些字段不参与业务相等性,要通过field(compare=False, hash=False)排除。这样可以保证语义和性能都符合预期。
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 对象比较问题困住时,再打开看看。也欢迎在评论区留言,分享你遇到的“看起来相等却不相等”的坑,一起讨论更优雅的解法。