044、魔术方法实战(一):str、repr、eq、hash
一个让我熬夜到凌晨三点的Bug
去年接手一个老项目,日志系统里打印用户对象时全是<User object at 0x7f8c2a1b3d30>这种鬼东西。排查问题时,我盯着控制台看了半小时,愣是分不清哪个是哪个。更崩溃的是,把用户对象塞进集合(set)时,明明两个用户ID相同,却当成不同对象存了两份——后来发现是__hash__和__eq__没配合好。
那次之后我彻底明白了:魔术方法不是花架子,是保命符。今天就把这几个最常用的魔术方法掰开揉碎讲清楚。
str:给用户看的“名片”
先看一个反面教材:
classUser:def__init__(self,name,age):self.name=name self.age=age user=User("张三",25)print(user)# 输出:<__main__.User object at 0x...>这玩意儿在调试时毫无意义。正确的做法是定义__str__:
classUser:def__init__(self,name,age):self.name=name self.age=agedef__str__(self):returnf"用户:{self.name},年龄:{self.age}"user=User("张三",25)print(user)# 输出:用户:张三,年龄:25这里踩过坑:__str__必须返回字符串,别手滑返回了数字或列表。Python 不会帮你做类型转换,直接抛TypeError。
repr:给开发者看的“身份证”
__repr__和__str__容易搞混。记住一句话:__str__是给人看的,__repr__是给解释器看的。
classUser:def__init__(self,name,age):self.name=name self.age=agedef__repr__(self):returnf"User('{self.name}',{self.age})"user=User("张三",25)print(repr(user))# 输出:User('张三', 25)别这样写:把__repr__返回成和__str__一样的内容。__repr__的理想状态是:把这个字符串扔回eval()能重建对象。虽然不强制,但这是 Python 社区的约定俗成。
实战中,如果只实现了__repr__没实现__str__,Python 会拿__repr__的结果当__str__用。所以偷懒的话,只写__repr__也行——但别这么干,两个都写才是专业做法。
eq:判断“相等”的潜规则
默认情况下,两个对象比较的是内存地址:
classUser:def__init__(self,user_id,name):self.user_id=user_id self.name=name u1=User(1,"张三")u2=User(1,"张三")print(u1==u2)# False,因为内存地址不同业务逻辑里,我们通常认为 user_id 相同就是同一个人:
classUser:def__init__(self,user_id,name):self.user_id=user_id self.name=namedef__eq__(self,other):ifnotisinstance(other,User):returnFalse# 别这样写:直接抛异常,应该优雅返回 Falsereturnself.user_id==other.user_id u1=User(1,"张三")u2=User(1,"李四")# 名字不同但ID相同print(u1==u2)# True这里踩过坑:__eq__返回的不一定是布尔值。如果你返回了None或0,Python 会把它当布尔值处理,但逻辑可能出错。务必返回True或False。
hash:和__eq__的“夫妻关系”
这是最容易被忽视的坑。Python 规定:如果两个对象相等(__eq__返回 True),它们的哈希值必须相等。反过来不成立——哈希值相等不代表对象相等(哈希碰撞)。
classUser:def__init__(self,user_id,name):self.user_id=user_id self.name=namedef__eq__(self,other):ifnotisinstance(other,User):returnFalsereturnself.user_id==other.user_iddef__hash__(self):returnhash(self.user_id)# 和__eq__用同一个字段u1=User(1,"张三")u2=User(1,"李四")# 现在可以正常放进集合了user_set={u1,u2}print(len(user_set))# 1,因为u1和u2相等# 也可以作为字典键user_dict={u1:"数据1"}print(user_dict[u2])# 输出:数据1,因为u2的哈希值和u1相同别这样写:__hash__返回固定值(比如return 1)。虽然符合规则,但会导致哈希表退化成链表,性能暴跌。
另一个坑:如果定义了__eq__没定义__hash__,Python 会自动把__hash__设为None。这意味着对象变成不可哈希的,不能放进集合或作为字典键。所以要么两个都定义,要么都不定义。
实战:一个完整的用户类
把上面这些整合起来,写一个生产级别的用户类:
classUser:def__init__(self,user_id,name,email):self.user_id=user_id self.name=name self.email=emaildef__str__(self):# 给用户看的信息,简洁明了returnf"{self.name}({self.email})"def__repr__(self):# 给开发者看,能重建对象returnf"User(user_id={self.user_id}, name='{self.name}', email='{self.email}')"def__eq__(self,other):ifnotisinstance(other,User):returnNotImplemented# 这里用NotImplemented而不是False,让Python尝试反向比较returnself.user_id==other.user_iddef__hash__(self):returnhash(self.user_id)# 测试u1=User(1,"张三","zhangsan@example.com")u2=User(1,"张三","zhangsan@example.com")u3=User(2,"李四","lisi@example.com")print(u1)# 张三(zhangsan@example.com)print(repr(u1))# User(user_id=1, name='张三', email='zhangsan@example.com')print(u1==u2)# Trueprint(u1==u3)# False# 集合去重users={u1,u2,u3}print(len(users))# 2,因为u1和u2被视为同一个个人经验性建议
调试时优先写
__repr__。__str__可以等上线前再补,但__repr__在开发阶段能救你无数次。我习惯在写完类结构后立刻补上__repr__,哪怕只返回类名和ID。__eq__和__hash__必须用同一组字段。这是铁律。如果__eq__比较了三个字段,__hash__也得用这三个字段算哈希。否则会出现“两个对象相等但哈希不同”的诡异情况,集合和字典会直接崩。不要轻易让对象可变。如果对象在放进集合后修改了参与
__hash__的字段,哈希值会变,导致对象在集合里“丢失”。我见过一个线上事故:用户对象放进集合后改了ID,结果再也查不到了。解决方案:要么用不可变对象(比如namedtuple),要么把参与哈希的字段设为只读。NotImplemented比False更优雅。在__eq__里遇到类型不匹配时,返回NotImplemented而不是False。这样 Python 会尝试调用对方的__eq__,给子类或第三方类留出扩展空间。别滥用
__hash__。如果类不需要放进集合或作为字典键,就别定义__hash__。保持简单,避免不必要的复杂度。
最后说句实在话:这些魔术方法写起来不费事,但漏掉一个可能让你多花三小时排查。我的习惯是——写完类定义后,立刻问自己三个问题:这个类需要打印吗?需要比较吗?需要去重吗?然后对应补上__str__/__repr__、__eq__、__hash__。养成肌肉记忆,比什么都强。