1. 为什么我会盯上操作符重载而不是普通方法
先说个场景。某次我在写一个二维坐标系统,定义了Point类,里面存x和y。业务逻辑里频繁要做坐标平移、向量相加、按比例缩放。一开始我老老实实写了add_point、scale_point这类方法,调用起来就是p1.add_point(p2)。代码跑起来倒是没问题,但读起来非常别扭,尤其是在表达式嵌套比较多的地方,满屏都是方法调用,逻辑被切得稀碎。
后来我花了一个晚上把Point类改成支持操作符重载,调用方直接写p1 + p2、p1 * 2.5,整个业务代码瞬间清爽了很多。那次重构给我的触动挺大的:操作符重载不是语言层面的炫技,而是真正能改变代码表达方式的手段,让自定义类型用起来就像内置类型一样自然。
这个话题适合谁看?如果你写过类,但还没系统梳理过__add__、eq、__getitem__这一整套dunder方法;或者你已经在用但只停留在"照着文档抄一遍"的程度、遇到报错只能瞎试;再或者你想知道怎样重载才算合理、怎样会写出灾难代码——这篇文章就是给你准备的。我会从机制原理讲到实战案例,再讲到设计边界和踩坑经验,争取让你看完就能在自己的项目里用起来。
操作符重载在Python里说白了就是:让+、-、*、/、[]、==这些运算符,作用在你自定义的类实例上时,执行你自己定义的逻辑。语法层面靠的是以双下划线开头和结尾的特殊方法,官方叫法是dunder methods,全称是double underscore methods。你不需要改变运算符本身,只需要在类里定义对应的方法,Python解释器会自动做映射。
这个机制解决的核心问题是:让领域模型拥有自然的表达力。向量加向量就是+,矩阵乘矩阵就是*,字典式取值就是[],判断两个对象相等就是==。代码的意图不再藏在一堆命名各异的方法背后,而是直接通过运算符这种全人类通用的符号语义表达出来。换句话说,操作符重载是Python让"领域语言"(DSL)成为可能的重要支柱之一。
2. 操作符重载的底层机制:从dunder方法到Python的运算符分派逻辑
2.1 运算符和dunder方法的映射关系
Python的每一个运算符都有对应的dunder方法。你定义类的过程中一旦实现了对应方法,该运算符就自动支持了。举个例子,表达式a + b在解释器内部会尝试调用type(a).add(a, b),如果a的类型没实现__add__,再尝试调用type(b).radd(b, a),双方都不支持就抛TypeError。
下表是我整理的核心映射关系,建议收藏起来对照使用:
| 运算符 | 正向方法 | 反向方法 | 说明 |
|---|---|---|---|
| + | add | radd | 加法 |
| - | sub | rsub | 减法 |
| * | mul | rmul | 乘法 |
| / | truediv | rtruediv | 真除法 |
| // | floordiv | rfloordiv | 整除 |
| % | mod | rmod | 取余 |
| ** | pow | rpow | 幂运算 |
| == | eq | — | 相等判断 |
| != | ne | — | 不等判断 |
| < | lt | — | 小于 |
| > | gt | — | 大于 |
| [] 读 | getitem | — | 下标/切片访问 |
| [] 写 | setitem | — | 下标赋值 |
| len() | len | — | 容器长度 |
| str() | str | — | 字符串化 |
| repr() | repr | — | 开发者视角字符串 |
| int() | int | — | 转整数 |
| float() | float | — | 转浮点数 |
| in | contains | — | 成员判断 |
| bool() | bool | — | 真值判断 |
| callable() | call | — | 让实例可调用 |
什么叫反向方法?反向方法处理的是运算符左侧不是本类型的情况。比如你定义了一个Vector类,实现了__mul__,能处理Vector * 2,但表达式2 * v的时候解释器发现左侧的int不支持与Vector相乘,就会回头找Vector.rmul。如果不实现__rmul__,2 * v就报错。这个细节很多人容易漏,但实际写代码的时候,标量乘向量和向量乘标量都该成立才对。
2.2 解释器的解析顺序与返回约定
返回值约定是重载时最容易埋坑的地方。add__不是void函数,它应当返回一个新的对象,而不是修改自身再返回self。不可变对象尤其如此。但像__iadd(对应+=)这类就地运算方法,如果你不实现它,Python会退化成先调用__add__再用结果重新赋值,这在不可变类型上是安全的,但可变类型会有额外对象创建开销。
我的习惯是:如果对象的语义是"值对象"(如向量、金额、日期),重载方法一律返回新对象,不修改self;如果是"可变容器"(如自定义列表类),+=就实现__iadd__做原地修改。保持这个习惯能避免大量隐蔽bug。
另一个关键点是,从Python 3.5开始,矩阵乘法运算符@也有了对应方法__matmul__。如果你在写线性代数相关代码,用@重载矩阵乘法会让表达式接近教科书写法,强烈推荐。
对了,还要注意__eq__的一个连带效应:一旦你在类里定义了__eq__,Python会把该类的__hash__设置为None,导致实例不可哈希,无法放入set和dict的键。这是因为Python的约定是"相等的对象必须有相同的哈希值"——你重写了相等逻辑,解释器不敢再盲目用默认的基于内存地址的哈希,干脆直接禁用。解决办法是同时重写__eq__和__hash__,后者基于与相等判断相同的关键字段计算。
3. 三个高价值实战案例:从向量运算到领域专用语法
3.1 二维向量类:最典型的操作符重载场景
用向量类作为入门案例,是因为它几乎覆盖了上面表格里所有常用重载方法,而且逻辑简单,方便看懂。直接看代码:
import math class Vector: def __init__(self, x, y): self.x = x self.y = y def __repr__(self): return f"Vector({self.x!r}, {self.y!r})" def __add__(self, other): if isinstance(other, Vector): return Vector(self.x + other.x, self.y + other.y) return NotImplemented def __radd__(self, other): return self.__add__(other) def __sub__(self, other): if isinstance(other, Vector): return Vector(self.x - other.x, self.y - other.y) return NotImplemented def __mul__(self, scalar): # 标量乘法:Vector * 2 if isinstance(scalar, (int, float)): return Vector(self.x * scalar, self.y * scalar) return NotImplemented def __rmul__(self, scalar): # 反向标量乘法:2 * Vector return self.__mul__(scalar) def __truediv__(self, scalar): if isinstance(scalar, (int, float)): return Vector(self.x / scalar, self.y / scalar) return NotImplemented def __neg__(self): # 一元负号:-v return Vector(-self.x, -self.y) def __abs__(self): # abs(v)返回向量模长 return math.hypot(self.x, self.y) def __eq__(self, other): if isinstance(other, Vector): return self.x == other.x and self.y == other.y return NotImplemented def __hash__(self): # 重写__eq__后必须补上__hash__,否则实例不可哈希 return hash((self.x, self.y)) def __bool__(self): # 零向量视为False return self.x != 0 or self.y != 0这个类实现完之后,你会发现调用方写出来的代码非常接近数学表达式:
a = Vector(1, 2) b = Vector(3, 4) c = a + b # Vector(4, 6) d = c * 2 # Vector(8, 12) e = 2 * d # Vector(16, 24),注意这依赖__rmul__ f = e - a # Vector(15, 22) g = -f # Vector(-15, -22) h = abs(g) # 约26.42 print(a == Vector(1, 2)) # True这里面有几个细节值得单独拎出来说。首先是return NotImplemented而不是直接抛TypeError。NotImplemented是个特殊哨兵值,告诉解释器"我这个类不支持这个操作,你再去别处找找"(比如反向方法或者右侧类型的方法)。如果两个类互相不知道对方,解释器最后会抛TypeError,但那是由解释器统一处理的,报错信息也更友好。如果你自己抛TypeError,反而会破坏反向分派机制——比如某个已有类型明明支持与你的类做运算,结果你的类提前抛了异常,就把这条路堵死了。
其次,eq__返回NotImplemented在Python 3里是允许的,解释器会尝试反射调用右侧的__eq,双方都不支持才返回False。很多人不知道这个机制,以为__eq__里必须返回布尔值,其实这里有个隐式的双向往来。当然我这里逻辑上直接返回True/False就足够,但用NotImplemented做兜底是个更稳妥的模式。
3.2 简化路径拼接:用真除法语义解决层级嵌套
第二个案例来自真实业务需求。我在做一个文件处理工具,需要频繁拼接各种相对路径和文件名。天然的想法是让路径类型支持/运算符,因为在现代操作系统的路径语义里,/本来就是路径分隔符。用操作符重载以后,路径拼接表达式可以直接写成:
class PathBuilder: def __init__(self, segments): self._segments = list(segments) def __truediv__(self, other): # 路径拼接:base / child other_str = str(other).strip() if not other_str: return self return PathBuilder(self._segments + other_str.split("/")) def __str__(self): return "/".join(self._segments) @property def value(self): return str(self)调用效果:
base = PathBuilder(["home", "user"]) path = base / "projects" / "demo" / "main.py" print(path) # home/user/projects/demo/main.py为什么选/而不是+?这是语义对齐的问题。/在文件系统语境里已经是约定俗成的分隔符,用它来表达路径拼接几乎不需要学习成本。这也是操作符重载的一个重要原则:尽量选择与领域直觉一致的运算符。价格和金额用+,矩阵和向量用*和@,路径用/,时间点之间的差值用-——符号本身携带的语义能让代码自解释。
要注意的一点是,这里的__truediv__我返回了PathBuilder自身类型,从而支持链式调用。如果你让它返回str,就没法继续拼下去了。链式调用是操作符重载在流畅接口(fluent interface)里的常见玩法,写得好能让表达式一气呵成。
3.3 定制区间对象:为业务规则打造迷你DSL
第三个案例稍微进阶一点,但也是真实项目里我反复使用的套路。运营系统里经常出现区间判断:订单金额在某个区间内享受折扣A,在另一个区间享受折扣B。常规写法是写一堆if判定:
if 100 <= amount < 200: rate = 0.9 elif 200 <= amount < 500: rate = 0.8这种写法有两个问题:一是边界值散落在业务代码里,业务人员修改策略要进代码;二是可读性一般,规则本身没有沉淀为一个可命名的对象。我的做法是构造一个Range类型,把区间变成可复用的对象,并且让它天然支持in操作和逻辑比较:
class Range: def __init__(self, low, high): self.low = low self.high = high def __contains__(self, value): # 支持 value in range_obj return self.low <= value < self.high def __and__(self, other): # 支持区间交集:r1 & r2 new_low = max(self.low, other.low) new_high = min(self.high, other.high) if new_low < new_high: return Range(new_low, new_high) return None def __or__(self, other): # 支持区间并集(仅处理相邻或重叠,简化版) if self.low > other.high or other.low > self.high: raise ValueError("两个区间不相邻,无法合并") return Range(min(self.low, other.low), max(self.high, other.high)) def __invert__(self): # 支持 ~r 表示补区间 return Range(float("-inf"), self.low) if self.low != float("-inf") else None def __str__(self): return f"[{self.low}, {self.high})"看到没有,&对应交集、|对应并集、~对应补集,这完全就是数学上集合运算的符号映射。一旦建立了这个映射,业务规则的表达就非常紧凑。此外还可以挂上__call__,让Range对象本身就可以当判断函数调用。比如:
standard = Range(100, 200) vip = Range(200, 500) def apply_rate(amount): if amount in standard: return 0.9 if amount in vip: return 0.8 return 1.0代码读起来几乎接近自然语言了。如果你把RateRule再封装一层,就可以把折扣规则配置化、模板化,业务同学改规则只需要调整配置文件里的数值,不用动代码逻辑——这才是操作符重载帮我们把业务复杂度"藏"起来的地方。
3.4 自定义容器下标与切片:__getitem__的完整使用姿势
再来一个容器类的场景。假设你在维护一个日志行分割器,日志每行是"时间戳 级别 消息"三段式,你需要经常按字段访问又不想每次split:
class LogLine: def __init__(self, raw): parts = raw.split(" ", 2) self.timestamp = parts[0] self.level = parts[1] self.message = parts[2] def __getitem__(self, index): if isinstance(index, str): # 支持 log["level"] 这种语义化访问 return getattr(self, index) if isinstance(index, int): # 支持 log[0] 这种位置访问 return [self.timestamp, self.level, self.message][index] if isinstance(index, slice): # 支持 log[1:] 这种切片 return [self.timestamp, self.level, self.message][index] raise KeyError(f"Unsupported index: {index!r}")注意这里__getitem__用了isinstance分派:字符串索引走属性名,整数索引走位置,切片走列表切片。这让同一个对象同时支持了多种访问习惯,调用方既能log["level"]也能log[1]。这种灵活性正是操作符重载让人上瘾的原因——你可以把一个对象的"下标协议"设计成完全贴合领域需求的形态,而不是被Python内置的索引规则限制死。
另外提醒一下,实现了__getitem__之后,Python还会自动让你的类支持for循环迭代和in判断。这是迭代器协议和成员测试协议对__getitem__的隐式依赖。如果你不想支持迭代,记得别让它越界时抛IndexError以外的异常,否则解释器不知道何时该停。
4. 重载设计的原则:从可读性到不变量保护
4.1 运算符语义必须符合直觉,不能自创符号含义
操作符重载最大的忧虑是什么?是"滥用"。Python社区有个经典调侃:C++程序员用构造函数做类型转换,Python程序员用__str__做日志,疯狂的程序员用__getitem__控制宇宙。任何一个机制都存在被用歪的可能性,操作符重载尤其如此。因为运算符数量有限,而领域语义无限,很容易出现强行套用的情况。
我在代码审查里见过最离谱的案例:某个ORM风格的库把数据库查询的.filter和.or_直接用&和|重载,这本身很合理(SQL的AND/OR就是逻辑运算符)。但同一个库里的人又加了星号操作符表示"执行查询",乘号表示"执行"这在语义上毫无根据,纯粹是为了少写几个字母。结果代码库的新人每次看到x * y都得去翻文档确认这到底是什么操作。这类重载带来的可读性损失远超它省下的几个字符。
所以我给自己定了三条规则:第一,运算符的语义必须与该运算符在数学或语言里的通用含义一致,+就是合并或相加,-就是相减或移除,*就是缩放或重复;第二,如果找不到天然匹配的运算符,宁可写命名方法也不要硬造;第三,一个运算符在一个类里只能有一种语义,不能因为参数类型不同就表现出完全不相关的行为。
4.2 左操作数优先与NotImplemented的协作机制
前面提到过NotImplemented的作用,但这里值得再深入一层,因为它涉及到一个真实开发中很常见的bug。看这个场景:
class Money: def __init__(self, amount): self.amount = amount def __add__(self, other): if isinstance(other, Money): return Money(self.amount + other.amount) if isinstance(other, (int, float)): return Money(self.amount + other) return NotImplemented def __radd__(self, other): # 如果加法左侧是数字,比如 100 + Money(50) return self.__add__(other)如果没有实现__radd__,sum([Money(1), Money(2), Money(3)])会直接崩溃,因为sum的初始值是0,第一步算的是0 + Money(1),走到了Money.__radd__上头。这个坑我在好几个项目里都见过,症状就是"明明我的类实现了加法,为什么sum用不了"。排查方式也很简单,只要在Money类里加上__radd__方法转发给__add__就行。
再说一个关于类型检查的细节。上面代码里的isinstance检查是标准做法,但要注意别把检查写得过于严格导致鸭子类型失效。如果你把isinstance(other, (int, float))换成type(other) not in (int, float)来拒绝传入numpy数值或Decimal,通常不是什么好事。Python的惯例是:只要能参与运算的数字类型,都应该被接受。保守写法是尝试运算,遇到不支持再返回NotImplemented让它兜底。
4.3 保护不变量:不可变对象返回新实例
设计重载方法时,要特别注意"对象的内部状态是否会被破坏"。对于值对象(向量、金额、日期),我强烈建议使用不可变设计。举例:
class Point: def __init__(self, x, y): self._x = x self._y = y @property def x(self): return self._x @property def y(self): return self._y def __add__(self, other): if isinstance(other, Point): return Point(self._x + other.x, self._y + other.y) return NotImplemented def __eq__(self, other): return isinstance(other, Point) and self._x == other.x and self._y == other.y def __hash__(self): return hash((self._x, self._y))想象一下如果__add__直接改self._x再返回self会发生什么:表达式p1 + p2执行完,p1变了。第一个plus的链式调用结果完全依赖执行顺序,你的代码逻辑会立刻变成不可预测的状态。更糟的是如果p1被存在dict里当键用,哈希值因为内部状态改变而失效,整个容器都坏掉了。
所以值对象的操作符重载,铁律就是:谁调用了表达式,谁拿结果,原对象永远不变。这也让表达式本身具备引用透明性,在并发场景下更安全。
4.4 方法的对称性和反射优先级
反射(reflected)操作符的处理顺序值得单独说一下。对于a + b,解释器的查找顺序是:
- type(a).add(a, b);
- 如果返回NotImplemented,尝试type(b).radd(b, a);
- 如果也返回NotImplemented,抛TypeError。
这个顺序意味着左侧类型的方法拥有第一优先权。设计反向方法的时候,不要做与正向方法不一致的假设。比如__radd__里不要再做isinstance(self, Money)之类的判断,直接复用__add__的逻辑通常是最省心的。
有个跟反射相关的坑:当两个不同类型的对象互相兼容时,可能出现"一个人干活两个人抢"的问题。比如两个类都实现了__add__和__radd__,且互相接受对方类型,调用a + b时:先试A.add,它认识B就返回结果;如果A.__add__不认识B但B.__radd__认识A,就走第二条路。如果两边都返回NotImplemented,才会报错。这个协作流程是Python操作符重载里最优雅也最容易被忽略的设计。
5. 容易被忽略的重载点:比较、哈希、真值、隐式转换的联动效应
5.1 __eq__和__hash__必须成对出现
这一条在前面提了两次,但我还是想单独拿出来再讲一遍,因为它是操作符重载里最经典的连锁反应。只要你定义了__eq__,Python就认为"这个对象的相等性是由业务逻辑决定的,不再是默认的内存地址比较",于是自动把__hash__置为None。结果:实例变成unhashable,放进set或dict键时直接TypeError。
解决办法很简单,同时重写__hash__,让它的计算字段和__eq__保持一致。比如上面Point类里,eq__比较x和y,hash__就用hash((x, y))。如果你重载了__lt、gt__这类比较方法,而没有实现__eq__对应的__ne,情况会更隐蔽:!=会先尝试__eq,如果没定义就退化为"not (a == b)"的反向推断,但这个推断在自定义类里可能产生让你困惑的结果。我的建议是,一旦实现了__eq__顺手就把__ne__也实现了,哪怕只是简单返回not self.eq(other)。
5.2 __bool__和对象真值的坑
Python里每个对象都能被当作布尔值用,if obj这样的判断无处不在。默认情况下,非None对象都是True,哪怕你的对象内部状态是"空的"。如果你实现了__len__,情况就变了:bool()会退化为"长度是否非零"的判断。但你也可以显式实现__bool__来自定义真值语义。
class TaskQueue: def __init__(self): self._tasks = [] def add(self, task): self._tasks.append(task) def __len__(self): return len(self._tasks) def __bool__(self): # 有任务且首个任务未完成时视为"非空" return len(self._tasks) > 0 and not self._tasks[0].done这里的__bool__和__len__语义不完全一致。__len__告诉世界"我有多少东西",__bool__告诉世界"我当前是否应该被视为活跃状态"。但如果不小心,两者就会打架。比如你去重一个对象列表,第一反应是len(lst) == len(set(lst)),如果list里对象的__eq__和__hash__不一致,或者bool语义没处理好,结果会非常诡异。写重载时务必保证这些协议方法协同工作,而不是彼此矛盾。
5.3str__与__repr:两种视角的分工
很多人分不清__str__和__repr__,实际上区别很简单:__repr__的目标读者是开发者,应该输出能准确反映对象核心状态的、最好是能用来重建对象的字符串;__str__的目标读者是终端用户,输出友好易懂的展示文本。
class Temperature: def __init__(self, celsius): self.celsius = celsius def __repr__(self): return f"Temperature(celsius={self.celsius!r})" def __str__(self): return f"{self.celsius}°C"在调试的时候,解释器用repr展示对象;在print或f-string里用的是str。两个都实现了,体验会好很多。注意f-string里可以用!r强制走repr,这也是调试利器。
但__str__的坑在于:它必须返回字符串,不能返回None。如果你在__str__里写了复杂的处理逻辑但忘了return,print对象会打出None,而且解释器不会给警告,排查半天才发现是__str__的问题。再一个,__str__应该绝对无副作用,不该修改对象状态,否则print一下就把业务逻辑给改了,这种bug防不胜防。
5.4 隐式转换方法:int、float、__bool__的强制转换场景
内置转换函数int()、float()、bool()、str()、bytes()等也会触发对应的dunder方法。设计这些方法时要想清楚"被转换"是否永远安全。比如金额类实现__float__很合理,但日期类实现__int__就很奇怪——除非你定义了从纪元开始的秒数这样的语义。
有个容易被忽略的场景是:某些第三方库在做数值计算时,会尝试用float()或int()把你自定义的对象转成内置数值类型。如果你的类型本意就是数值(比如百分比、利率、金额),实现__float__能大幅提升互操作性。反之如果没有实现,库会报TypeError,这个报错信息往往很隐晦,让人误以为是库的问题,实际是你的类型不支持隐式转换。
6. 高级组合:把重载方法与上下文管理器、属性访问揉在一起用
6.1 用__getattr__和__setattr__给重载方法叠加保护逻辑
操作符重载经常会跟属性访问控制结合起来。我自己常用的一种模式是:用__setattr__拦截非法状态修改,让重载方法只负责运算逻辑,状态校验统一走属性入口。
class Interval: def __init__(self, start, end): self.start = start self.end = end def __setattr__(self, name, value): if name in {"start", "end"} and not isinstance(value, (int, float)): raise TypeError(f"{name} 必须是数值类型") super().__setattr__(name, value) def __and__(self, other): return type(self)(max(self.start, other.start), min(self.end, other.end))这里__setattr__在赋值前做了类型校验,而__and__直接构造新的同类型对象。这样即使运算逻辑里出现边界值异常,也能在构造阶段暴露出来。合理使用这两个魔法方法,可以让你的类在"运算时"和"赋值时"都保持数据干净。
但要小心别在__setattr__里使用self.start = value这类普通赋值,否则会无限递归。正确做法是用object.__setattr__或者super().setattr。这是新手常踩的递归陷阱。
6.2 结合contextlib.contextmanager实现"可运算的上下文"
有时候你希望一个上下文管理器同时能参与运算。比如数据库事务对象,你想让它同时支持"事务加事务"的合并语义,又希望它能被with语句使用。直接把__enter__、__exit__和__add__混在一起设计也是常见的做法:
from contextlib import contextmanager class Transaction: def __init__(self, actions): self._actions = actions def __add__(self, other): return Transaction(self._actions + other._actions) def __call__(self): # 让对象本身可调用,执行事务 for action in self._actions: action() @contextmanager def begin(self): print("开始事务") try: yield self print("事务提交") except Exception: print("事务回滚") raise调用方就能写:
t = Transaction([action_a, action_b]) + Transaction([action_c]) with t.begin() as tx: tx()把重载方法和上下文管理器结合,平时不一定要用,但在表达"组合一批操作、然后按事务方式执行"这种场景时,代码会非常简洁。当然,这种组合也会增加理解成本,建议只在确实收益大于复杂度的项目里使用。
6.3 __call__带来的函数式编程便利
实现了__call__之后,类的实例可以像函数一样被直接调用。如果同时配合操作符重载,能构建一套非常像函数式库的API。
class Discount: def __init__(self, rate): self.rate = rate def __call__(self, price): return price * self.rate def __mul__(self, other): # Discount * Discount 表示折扣叠加 if isinstance(other, Discount): return Discount(self.rate * other.rate) return NotImplemented def __add__(self, other): # Discount + Discount 表示并列折扣,取最低折扣率 if isinstance(other, Discount): return Discount(min(self.rate, other.rate)) return NotImplemented像这样,Discount对象既可以直接作用于价格,也能跟另一个折扣做叠加。业务规则从"嵌套函数调用"变成"算子组合",代码表达力会有质的提升。
7. 踩坑记录:重写__eq__之后的连锁反应排查过程
最后分享一段真实的排错经历,它把前面提到的多个机制串在了一起,也最能说明操作符重载"牵一发动全身"的特性。
某天我在写一个模拟订单匹配的模块,Order类保存价格和数量。为了比较两个订单是否"等价",我重写了__eq__,只比较价格和数量,忽略了订单ID和创建时间。跑测试的时候首先出现的问题是:把订单放进set去重时直接TypeError,报错信息说unhashable type。当时我还纳闷,明明没碰任何哈希相关的东西,怎么set都用不了了。
排查链路是这样的:先确认报错来自哪里,然后查Order类定义,看到只有__eq__没有__hash__。翻了官方文档确认:定义__eq__会禁用默认__hash__。当时第一反应是加一个__hash__,但加什么字段得想清楚——如果哈希字段和相等字段不一致,set和dict会同时存在两个"相等"但哈希不同的对象,这可能比报错更危险。最终我把__hash__基于price和quantity来算,跟__eq__保持一致。
结果第二个问题来了:排序逻辑开始出乱子。Order类原本支持按价格排序,我隐约记得之前定义过__lt__,但此时发现它的行为变得很奇怪。排查之后发现,我在Order类里定义的__lt__是"比价格小",但重写__eq__之后,__ne__也没定义,导致_a != _b的判断退化成not (a == b),在有些排序场景里出现"等于"被错误判断的情况。
解决方法是补上__ne__,并且明确__lt__、gt、le、ge__的完整语义。最终那个Order类同时实现了__eq、ne、lt、le、gt、ge__和__hash。改完之后set去重、排序、业务比较全部正常。
这整个排查过程走了大概两个多小时,事后复盘,最好的解决办法其实是写类的时候先列一个"协议方法清单":这个对象需要支持哪些运算、哪些比较、哪些转换、哪些容器行为。一次性都设计好,而不是等到用到哪个报错再补哪个。操作符重载本身不复杂,麻烦的是各个协议之间的联动效应。你补一个方法,可能无意中改变了另一个协议的默认行为。
分享一下我现在写自定义类时的习惯。动手之前先在注释里写下"这个类的值语义是什么、不变量的边界在哪、哪些运算符有意义"。然后按顺序实现:__init__和属性校验打底,repr__和__str__保证可调试可展示,eq__和__hash__保证可比较可哈希,常用运算匹配的组合方法(如__add/radd)保证可计算,容器类补__len/getitem/contains,需要时再补__bool__和__int__等转换方法。实验完一部分就用交互式环境手动验证一遍表达式,别等到业务代码跑挂了再回头查。
另外还有个实用技巧:多写几个基于属性的小测试,专门验证"原对象在运算后保持不变"这类不变量。操作符重载导致的bug往往很隐蔽,但都有一个共同特征——运算表达式执行后,原来的对象莫名其妙变了。只要你把"运算前后对象状态对比"做成自动化断言,这一类坑基本都能提前拦住。
操作符重载是Python里少有的、能同时影响代码可读性和数据安全性的语言特性。用好了,表达式就是文档;用歪了,表达式就是谜语。我的建议是:先在值对象、DSL、容器协议这几个场景里练手,把每一个运算符的语义都问一遍"它像不像数学或自然语言里的那个符号",答案都是肯定的,再动手写代码。