
如果你跟我一样正在把 Python 的魔术方法一个一个看过来看到第 39 个__mod__的时候大概率会有一瞬间的恍惚原来%这个天天用的操作符背后也是一个可以重写的方法。我之前在一个项目里需要把带单位的角度数值统一归一化到 0~360 度区间但数据不是裸的 float而是一个个 Angle 对象当时想的是“这有什么难的”结果下一秒 TypeError 直接教我做人。这篇文章就把我补齐__mod__、__rmod__、__imod__三件套的完整过程记录下来顺带把%操作符背后的分派机制、和字符串格式化的关系、取模与取余的区别串在一起。如果你正在学魔术方法或者想让自定义类稳稳当当地支持%这篇应该能帮你少走不少弯路。1. 先理解__mod__的身份一个%背后的三种角色1.1%操作符的两副面孔Python 里的%是典型的一符多义。第一副面孔是数值取模比如17 % 5拿到余数 2第二副面孔是字符串格式化比如hello %s % world。前者是真正的运算符运算后者是字符串类型内部实现的一套格式化机制。很多人第一次接触__mod__时会困惑我重写一个__mod__到底管的是哪种%答案是它管的是把自定义对象放在%左侧时发生的一切。也就是当你写出obj % other这样的表达式时Python 会去调用obj.__mod__(other)。至于hello %s % obj这个表达式左侧是字符串调用的实际是str.__mod__和自定义对象的__mod__没有直接关系。但问题往往出在这里很多教程只告诉你“实现__mod__就能支持%”然后你就傻乎乎地只写了这一个方法。结果一测obj % 3能跑3 % obj抛异常obj % 3行为诡异。这是因为%的真实协议是三件套常规方法__mod__、反向方法__rmod__、原地方法__imod__。1.2 三件套与 NotImplied 的分工先看三个方法的官方签名class Demo: def __mod__(self, other): # 定义 self % other pass def __rmod__(self, other): # 定义 other % self当 left % self 无法完成时被调用 pass def __imod__(self, other): # 定义 self % other passPython 在计算a % b时会按照这样一套优先级去查找方法先尝试调用左操作数a的__mod__传入b如果a.__mod__不存在或者它返回了NotImplemented注意不是抛异常则尝试调用右操作数b的__rmod__传入a如果两边都不愿意处理最终抛出TypeError: unsupported operand type(s) for %。用生活中点菜的思路去理解你先问坐在对面的朋友要不要吃这道菜他说不明确拒绝返回 NotImplemented你再问自己这边的人如果两个人都说随便那就只能换个餐馆了。这套“左方法优先、右方法兜底”的机制并不是%独有的、-、*、/、//、**全部遵循同一个套路。所以只要把__mod__吃透后面看__add__、__mul__都特别轻松。1.3 为什么只写__mod__远远不够只实现__mod__你实际能覆盖的只有一种情况自定义对象在%左边。一旦把自定义对象放到右边例如3 % obj表达式会先调用int.__mod__(3, obj)内置类型发现参数类型对不上会返回NotImplemented然后 Python 才会回头找obj.__rmod__。如果你没写__rmod__对不起最终结果就是那个眼熟的 TypeError。更隐蔽的是obj % other。Python 执行原地取模的时候会先检查obj有没有__imod__有就用没有就退回成obj obj % other。对不可变类型来说这个退回行为没毛病但如果你设计的是一个可变对象又希望%在原始对象上做修改不实现__imod__就会产生“看起来改了其实绑定关系都变了”的诡异 bug。搞清楚这层关系我们再往下走才不会被代码表象带偏。2. 实操前置让一个带单位的Quantity类支持%2.1 定义可取模的自定义类写代码之前先想清楚你的类到底要赋予%什么语义。这里我拿一个非常常见的需求举例带单位的数值类型Quantity当你做10kg % 3时期望得到1kg这样的结果做10kg % 3kg时也应该得到1kg但前提是两边单位一致。第一版实现长这样class Quantity: def __init__(self, value: float, unit: str): self.value value self.unit unit def __mod__(self, other): if isinstance(other, Quantity): if self.unit ! other.unit: raise ValueError(f单位不一致{self.unit} 和 {other.unit}) return Quantity(self.value % other.value, self.unit) # other 是普通数字直接按同单位处理 return Quantity(self.value % other, self.unit) def __repr__(self): return fQuantity({self.value!r}, {self.unit!r})这里有两个设计决策值得说一句。第一__mod__返回的是一个新的Quantity对象不是直接改self.value这符合不可变类型的行为习惯也让表达式a % b不产生副作用。第二当右边的对象是Quantity时要先做单位校验避免闹出5kg % 2m这种物理意义上毫无道理的结果。这种“前置防御式检查”在自定义数值类型里非常必要。2.2 补齐__rmod__把右边的情况也救回来上面的代码只实现了__mod__你马上会遇到一个很尴尬的场景q Quantity(10, kg) result 3 % q # TypeError原因前面已经说过int.__mod__(3, q)发现q不是整数类型返回NotImplemented然后 Python 尝试调用q.__rmod__(3)但类里根本没有这个方法。反向方法的写法有一个特别容易弄反的地方__rmod__(self, other)中self是出现在%右边的自定义对象other是出现在左边的那个操作数。例如3 % q会调用q.__rmod__(3)方法内部你拿到的other是3self是q自己。class Quantity: # ... 上面的 __mod__ 不变 def __rmod__(self, other): return Quantity(other % self.value, self.unit)加上之后3 % Quantity(10, kg)会得到Quantity(3, kg)因为3 % 10的余数是 3。需要注意的是我在这里没有做单位转换因为other是裸数字没办法知道它原本想表达什么单位只能沿用self.unit。如果你在设计 API这种“默认继承右侧单位”的做法是可接受的但要在文档里写清楚。2.3 原地取模__imod__该不该实现怎么实现接着看obj % other。对Quantity这种可变类型我会推荐实现__imod__这样用户写q % 3时修改的是q自己而不是重新生成一个新对象再赋值给q。class Quantity: # ... 前面的方法不变 def __imod__(self, other): if isinstance(other, Quantity): if self.unit ! other.unit: raise ValueError(f单位不一致{self.unit} 和 {other.unit}) self.value % other.value else: self.value % other return self这个方法的铁律有两个一是要真正修改self的内部状态二是要return self。很多初学者写完__imod__忘记最后一行结果q % 3之后q变成了None。因为 Python 会把方法的返回值重新赋值给左变量你返回None原来的对象引用就丢了。如果你设计的是一个不可变类比如NamedTuple风格的类那么你不必实现__imod__。Python 会自动退化成q q % 3效果上仍然是得到了一个新对象语义也符合不可变类的习惯。所以__imod__要不要写本质上取决于你的类型是可变的还是不可变的。3. 核心细节取模语义、字符串格式化联动与边界处理3.1 Python的%到底算的是取模还是取余很多从 C 语言转过来的同学会在负数取模上栽跟头。-7 % 3在 Python 里的结果是2但在 C 语言里往往是-1。原因在于 Python 的%采用 floor 语义公式是a % b a - b * floor(a / b)-7 / 3的 floor 是-3所以-7 % 3等于-7 - 3 * (-3)结果是2。也就是说Python 取模结果的符号永远和除数b一致。这个特性在角度归一化、循环队列、日历计算等场景非常有用但如果你想要的是 C 风格截断余数就得用math.fmodimport math math.fmod(-7, 3) # -1.0实现__mod__时一定要想清楚你的类到底要遵循哪种语义如果你只是把内部数值透传给%比如return self.value % other那就天然继承了 Python 的 floor 语义这通常没问题但要在文档里写明白免得用户拿负数来测试时一脸问号。浮点取模是另一个坑。0.3 % 0.1在 Python 里输出的是0.09999999999999998并不是你期望的0.0。这是二进制浮点数表示导致的跟__mod__没关系。如果你的自定义数值类型涉及金额、测量数据等高精度场景建议内部用Decimal存储这样__mod__也能跟着规避浮点误差。3.2 字符串格式化会不会触发__mod__会但要分清方向回到开头说的第二副面孔。表达式里程: %s km % q中左侧是str所以调用的是str.__mod__。str.__mod__内部会根据格式符对参数做转换%s会调用str(q)%r会调用repr(q)这些都不需要你的类实现__mod__。但反过来如果你的对象在%左侧例如q % 格式串那调用的就是q.__mod__(格式串)。你完全可以利用这一点设计出“对象在前、返回格式化结果”的 DSL。比如前面那个Angle类你甚至可以约定angle % sin返回角度的正弦值。不过这种用法可读性很差社区里一般不建议这么搞。真正常见的坑是%d % obj。Python 3 的字符串格式化对%d要求比较严格它只接受整数对象或者实现了__index__的对象。如果你只是实现了__int__不好意思%d % obj会直接抛 TypeError。想让自定义数值类能参与%d格式化需要补一个__index__方法class Quantity: def __index__(self): # 仅当你的类型确实是“整数值的包装”时才适用 return int(self.value)还要强调一点格式串 % obj如果内部格式码和对象类型不匹配str.__mod__会直接抛出TypeError它不会返回NotImplemented再交给obj.__rmod__。所以不要幻想通过实现__rmod__来接管所有“字符串%自定义对象”的情况。新代码如果要格式化推荐直接用 f-string 或format()它们比%格式化更清晰、也不容易触发这些语义混乱。3.3 定制类与divmod的搭配关系%的藏得比较深的搭档是divmod。内置函数divmod(a, b)的意思是同时返回(a // b, a % b)但它的底层实现不是简单地把//和%组合起来。Python 会先尝试调用a.__divmod__(b)如果不存在再退回去分别调用a.__floordiv__(b)和a.__mod__(b)。所以如果你的自定义类只实现了__mod__没有实现__floordiv__调用divmod(obj, 3)仍然会报错。想让divmod也正常工作可以直接实现__divmod__class Quantity: def __floordiv__(self, other): if isinstance(other, Quantity): if self.unit ! other.unit: raise ValueError(f单位不一致{self.unit} 和 {other.unit}) return Quantity(self.value // other.value, self.unit) return Quantity(self.value // other, self.unit) def __divmod__(self, other): if isinstance(other, Quantity): if self.unit ! other.unit: raise ValueError(f单位不一致{self.unit} 和 {other.unit}) other_value other.value else: other_value other quotient self.value // other_value remainder self.value % other_value return Quantity(quotient, self.unit), Quantity(remainder, self.unit)这样就能在自定义类型上获得完整的“商和余数”语义。我的建议是既然要支持%顺手把//、divmod一起考虑清楚否则你的类型在用户手里用着用着就会在半路崩掉。3.4 Python 3.12环境下实现__mod__的注意点Python 3.12 并没有给__mod__协议本身带来革命性变化三件套机制和以前一致。但 3.12 的一些新特性会让你写出来的类更舒服也更容易被 IDE 和类型检查器理解。比如 PEP 695 的类型参数语法允许你写出更简洁的泛型自定义类型。如果你想做一个通用的容器或包装类并重写__mod__可以这样写class Box[T]: def __init__(self, value: T): self.value value def __mod__(self, other: T) - Box[T]: return Box(self.value % other)不需要再像老版本那样写from typing import TypeVar再定义T TypeVar(T)类的定义干净很多。再比如 PEP 701 让 f-string 的语法更自由你可以直接在 f-string 里写和外部同类型的引号。做__repr__时就不再容易出现转义地狱了。另外 Python 3.12 对异常消息做了一轮优化遇到TypeError: unsupported operand type(s) for %时报错信息里会包含更具体的类型名提示定位问题比旧版本快不少。如果你是升级到 3.12 之后才频繁接触魔术方法这个细节会帮上忙。4. 完整实战写一个可归一化的Angle类4.1 需求与设计场景很简单你手里有一组角度值来源可能是传感器、图形旋转逻辑或者路径规划这些角度可能超过 360也可能是负数。你想随时把角度归一化到[0, 360)并且代码里到处都是Angle对象而不是裸的 float。这时候让Angle支持%就很自然angle % 360就是归一化。设计目标定下来Angle(480) % 360返回Angle(120)400 % Angle(360)这种反向写法也应该成立angle % 360能原地把对象归一化负数也能正确处理Angle(-30) % 360得到Angle(330)。4.2 完整代码与关键步骤按照这个设计完整的类可以这样写class Angle: def __init__(self, degrees: float): self.degrees degrees def __mod__(self, other): if isinstance(other, Angle): return Angle(self.degrees % other.degrees) return Angle(self.degrees % other) def __rmod__(self, other): return Angle(other % self.degrees) def __imod__(self, other): mod_value other.degrees if isinstance(other, Angle) else other self.degrees % mod_value return self def __repr__(self): return fAngle({self.degrees})写完之后我们来验证一下核心行为。首先是普通取模a Angle(480) b a % 360 print(b) # Angle(120)这个结果是怎么来的480 % 360按a % b a - b * floor(a / b)计算floor(480 / 360) 1所以480 - 360 * 1 120符合预期。然后是反向操作c 400 % Angle(360) print(c) # Angle(40)int.__mod__(400, Angle(360))发现自己处理不了自定义类型返回NotImplemented于是 Python 调用了Angle.__rmod__(400)。Angle.__rmod__里的other是400self.degrees是360结果400 % 360 40。再看原地取模d Angle(-30) d % 360 print(d) # Angle(330)d.__imod__(360)把self.degrees改成-30 % 360。因为floor(-30 / 360) -1所以-30 - 360 * (-1) 330。结果正好落在[0, 360)区间内这正是角度归一化想要的效果。4.3 扩展与边界测试一个容易忽略的地方是除数为 0。Angle(180) % 0会抛出ZeroDivisionError这和内置int的行为一致属于合理的失败方式。如果你的业务不允许除零异常裸奔可以在__mod__里提前判断other是否为 0再抛一个语义更明确的业务异常。另一个值得扩展的是把Angle设计成不可变对象那么__imod__其实没有存在必要。上面的实现刻意保持了可变性__imod__可以直接修改degrees。如果你更推崇纯函数式风格可以删掉__imod__让%退化成angle angle % 360效果上也无所谓。性能方面纯 Python 层实现__mod__肯定没法跟内置int % int比但角度归一化这种操作频率通常不高瓶颈不在运算本身。如果真的需要在循环里对几十万个Angle做取模建议先把degrees批量提出来转成 numpy 数组一次算完再塞回Angle对象。5. 常见问题与排查技巧实录5.1 问题速查表长时间写自定义类型之后我把%相关的常见问题整理成了一张速查表排错的时候非常管用。现象直接原因解决方案TypeError: unsupported operand type(s) for %类里没有实现__mod__/__rmod__或方法返回了NotImplemented按需补全三件套并检查返回值自定义对象在%右边时不生效缺少__rmod__实现__rmod__注意other是左操作数obj % other之后obj变成None__imod__没有return self原地修改后必须返回self%d % obj抛 TypeError对象没有实现__index__实现__index__或用 f-string 替代负数取模结果和 C 语言不一致Python 采用 floor 取模语义需要截断余数时改用math.fmod0.3 % 0.1结果是 0.0999...二进制浮点精度问题高精度场景内部用Decimaldivmod(obj, 3)报错类里缺少__divmod__或__floordiv__实现__divmod__5.2 如何快速确认调用的到底是哪个方法遇到%行为不符合预期最直接的排查方式是在三个方法里临时加打印日志。别觉得土这种方式在调试魔术方法时比任何断点都好使因为你能立刻看清参数顺序和调用顺序class Angle: def __mod__(self, other): print(f__mod__ called: self{self} other{other}) return Angle(self.degrees % other) def __rmod__(self, other): print(f__rmod__ called: other{other} self{self}) return Angle(other % self.degrees)然后逐个验证a Angle(480) print(a % 360) print(400 % a)日志会清清楚楚地告诉你左边场景走的是__mod__右边场景最终走到了__rmod__。如果你发现某个表达式怎么都不进自己的方法多半是左操作数类型已经处理成功了压根没轮到你的类上场。这里有个优先级知识点如果左操作数是某个类的子类实例就算它的父类已经实现了%逻辑只要子类重写了__mod__永远是子类先上场。所以自定义类型在左边时你重写的方法一定会被调用在右边时则要看左边的内置类型给不给机会。5.3 性能与精度补充内置整数的%是在 C 层实现的对小整数来说是单条指令级别非常快。自定义类的__mod__相当于多了一次 Python 函数调用还要经历属性访问和方法查找性能自然慢一截。所以我的经验是如果自定义数值类型只是用来做业务建模那就放心重写%可读性和语义清晰远比微秒级性能重要如果它在高并发热路径上被调用几十万次就要认真考虑缓存、或者下沉到 C 扩展。大整数取模的复杂度也值得注意。Python 的大整数是变长存储的a % b的耗时和a、b的位数相关不是 O(1)。如果你的__mod__内部存的是大整数并且被频繁调用要考虑会不会成为瓶颈。另外负数取模在高性能计算里经常让人意外所以很多数值库在文档里会专门标注自己用的是“Python 取模语义”还是“C 取余语义”。5.4 一个容易被忽略的安全隐患如果你在__mod__里直接做self.value % other当other是用户传入的不可信对象时可能会出现你完全没预料到的行为。比如用户在other位置传了一个字符串int和字符串做%会直接TypeError这还好但如果传进来的对象实现了__index__返回一个负数或者实现了__rmod__做一些奇怪操作你的__mod__就可能触发一些“类库作者没想过的组合”。稳妥的做法是在__mod__入口做类型收敛只允许你预期内的类型进入计算逻辑def __mod__(self, other): if not isinstance(other, (int, float, self.__class__)): return NotImplemented ...return NotImplemented是向 Python 解释器释放信号这个类处理不了这个组合你去试试反向方法。不要主动抛TypeError因为一旦你抛了异常反向方法就没有机会再兜底了。这是写所有运算符魔术方法的核心纪律。6. 扩展思考学会__mod__等于学会了半套运算符协议__mod__并不是孤立的知识点。__add__、__sub__、__mul__、__matmul__、__truediv__、__floordiv__它们全部共享同一套三件套机制常规方法、反向方法、原地方法外加一个NotImplemented作为“我搞不定换人”的信号。所以你只要把__mod__这套逻辑理清楚往后扩展自定义数值类型会顺畅很多。和__mod__经常一起出现的还有__index__。它负责让对象能够被当成整数索引使用比如list[obj]、range(obj)、%d % obj。一个希望完全融入 Python 生态的数值包装类通常需要同时实现__int__、__float__、__index__以及比较运算相关的方法。这些方法单独看都不难组合起来才是真正考验设计能力的地方。operator.mod这个函数可能很多人没用过它其实就是%运算符的函数形态import operator operator.mod(Angle(480), 360) # Angle(120)在map、reduce、或者需要把运算符作为参数传递的高阶函数场景里operator.mod比写 lambda 清爽得多。自定义类实现了__mod__之后operator.mod也会自动生效因为它走的是同一个协议不需要额外适配。最后再多说一句我的体会魔术方法这种东西只看文档很容易觉得“哦就是这么回事”但真正让你理解它的一定是踩坑。我一开始只写__mod__在反向操作上报错后来才知道__rmod__的存在再后来写__imod__忘记return self把一堆对象整成了 None才彻底记住原地操作的纪律。这些坑单独看都是小事但在一个大型项目里任何一个小坑都可能变成线上事故。希望这篇能让你一次性把%这条路走通不用再经历一遍我的折腾。