ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Python 3.12魔术方法__mod__全解析:从%运算符到自定义取模

Python 3.12魔术方法__mod__全解析:从%运算符到自定义取模 如果你写 Python 写过一段时间一定见过这种写法7 % 3得到1-7 % 3得到2。大多数人会告诉你“这是取模运算”然后就没下文了。但如果你稍微往底层看一眼就会发现真正干活的其实是一个叫__mod__的魔术方法。Python 3.12 里%运算符背后的分派逻辑又有了一些调整不搞清楚的话你在自定义类里写__mod__时很容易踩到莫名其妙的坑。这篇文章就专门拆解 Python 3.12 MagicMethods 系列里的__mod__它管什么、在 3.12 里有什么变化、如何正确实现自定义取模、反向运算和原地运算又是怎么回事。内容偏向实操但也会把背后的机制讲明白适合正在学 Python 面向对象、想深入理解魔术方法或者在做自定义数值类型、运算符重载相关开发的人参考。读完你不仅能写出正确的__mod__还能理解为什么%有时候会不按你的预期走。1. 从 % 运算符说起mod到底管什么1.1 初识modPython 取模运算的“幕后接口”先看最基础的现象。你写a % bPython 解释器并不会直接做一次“数学取模”而是先尝试调用a.__mod__(b)。如果a的类型里没有定义这个方法再回头尝试b.__rmod__(a)。整个过程对新手是透明的但对你自定义的类来说__mod__就是%运算符的完整入口。举个例子初始化一个最简单的自定义类class MyNumber: def __init__(self, value): self.value value def __mod__(self, other): print(f__mod__ 被调用了: {self.value} % {other}) return self.value % other a MyNumber(10) print(a % 3)输出结果__mod__ 被调用了: 10 % 3 1这个例子说明两件事第一%确实会映射到__mod__第二__mod__的返回值完全由你决定你甚至可以返回字符串、列表Python 语法层不会拦你。但通常情况下你应该返回一个和运算语义匹配的数值类型否则会让调用方一头雾水。Python 3.12 里%运算符的分发逻辑在内建类型上做了优化对int、float这类对象会走更快的“类型槽”路径而不是每次都去动态查找__mod__方法。这个变化对普通用户基本无感但如果你用 C 扩展或者非常在意微秒级性能就值得留意。后面我会单独讲。1.2 Python 3.12 里有什么变化类型槽与分发机制Python 3.12 的官方发布说明里有几个点直接影响%的底层实现。最核心的是解释器对二进制运算的查询顺序做了调整对于内建数值类型CPython 会直接通过PyNumber_Remainder这类 C 层 API 完成计算不再像旧版本那样先做一遍“方法查找异常捕获”的完整流程。这对我们写 Python 的人来说最直观的影响有这几条自定义类只要实现了__mod__行为保持不变%依然会正确调用它。内建类型之间的取模运算速度更快了尤其是大整数运算场景。operator.mod(a, b)和a % b的结果完全一致但operator.mod更适合在需要把运算作为函数参数传递的场景使用。如果你在 3.12 之前写的代码里对int类型做int.__mod__(7, 3)这种直接调用3.12 里依然能用但不推荐因为它绕过了正常的分派机制容易写出可读性很差的代码。另外一个很多人忽略的变化Python 3.12 对complex类型依旧不支持%。也就是说(34j) % 2会直接抛出TypeError。这其实不是 3.12 才有的但很多初学者会在这里栽跟头。原因在于复数的“取模”在数学上没有一个唯一且自然的定义Python 选择不为complex实现%而是强制你显式用abs()或手动处理实部虚部。1.3 为什么需要rmod正向与反向的完整闭环只实现__mod__是不够的。当你的对象出现在%的右侧时Python 需要一条“反向”路径。比如你写3 % my_obj解释器先尝试(3).__mod__(my_obj)但int不知道怎么处理你的自定义类型于是抛出一个NotImplemented解释器捕获后立刻尝试my_obj.__rmod__(3)。如果这个方法存在就由它接管计算。class RemainderSide: def __init__(self, value): self.value value def __rmod__(self, other): print(f__rmod__ 被调用了: {other} % {self.value}) return other % self.value b RemainderSide(4) print(10 % b)输出__rmod__ 被调用了: 10 % 4 2一个小小的注意点如果你只实现了__mod__上面的代码会得到TypeError: unsupported operand type(s) for %: int and RemainderSide。很多新手自定义数值类型时只做一半导致“左边是自己、右边是别人”的时候能用“左边是别人、右边是自己”的时候崩掉。所以凡是设计__mod__我建议同时把__rmod__也考虑进去哪怕只是让它返回NotImplemented也比报一个晦涩的类型错误更容易排查。2. 自定义mod从需求设计到代码落地2.1 一个真实例子自定义“模时钟”类型学魔术方法最好的方式不是背诵语法而是找个真实场景写一遍。这里我用了“模时钟”的例子一个值在 0 到 N-1 之间循环超过上限就自动回绕。数据库分表、环形缓冲区、日历计算里经常出现这种逻辑。先定义基础类class ModClock: def __init__(self, value, modulus): self.modulus modulus self.value value % modulus def __mod__(self, other): 返回当前时钟值与 other 取模的结果仍然是 ModClock。 return ModClock(self.value % other, self.modulus) def __rmod__(self, other): 支持 other % clock 的写法。 return ModClock(other % self.value, self.modulus) def __repr__(self): return fModClock(value{self.value}, modulus{self.modulus}) c ModClock(27, 12) print(c) # ModClock(value3, modulus12) print(c % 2) # 时钟值 3 对 2 取模得到 1 print(10 % c) # 10 对时钟值 3 取模得到 1这个例子里有几个容易忽略的细节第一我在__init__里就对value做了% modulus这保证对象永远处于合法范围内后面所有运算都不需要再检查边界。第二__mod__返回的是新的ModClock而不是普通整数这样好处是结果依然保有“时钟”的语义你可以继续对它做链式运算。第三__rmod__里我用self.value而不是self去参与运算免得引入类型比较的复杂性。再看一个带%语义的链式用法print((c % 2) % 3)这行代码会先算c % 2得到一个ModClock(value1, modulus12)再对3取模最终得到ModClock(value1, modulus12)。整个过程完全符合预期。2.2 返回值约定不是所有 % 都返回数字__mod__的返回值并不强制要求是数字。我之前见过有人用它实现“颜色取模”效果用%让颜色值在色相环上循环返回一个Color对象也有人用它把%当作“安全索引”操作符处理环形列表的越界访问。class RingList: def __init__(self, items): self.items items def __mod__(self, index): return self.items[index % len(self.items)] colors RingList([red, green, blue]) print(colors % 0) # red print(colors % 3) # red索引 3 回绕到 0 print(colors % 4) # green这个设计非常实用做轮播图、分页、循环队列时都会遇到。但我要提醒你乱用%语义会降低代码可读性。看到colors % 4的人第一反应是“颜色值对 4 取模”很难立刻想到“环形列表索引”。所以这种用法更适合在自己的项目里配合清晰命名使用如果是给团队用的公共库我建议还是老老实实写个get_cyclic方法。如果你决定让__mod__返回自定义对象最好保证以下三点结果对象有清晰的__repr__或__str__方便调试。结果对象与操作数之间的运算规则保持一致别出现“一次返回数字一次返回对象”的随机行为。在文档字符串里明确说明返回值的类型。2.3imod原地取模的正确打开方式Python 里还有一个和%配套的原地运算符%它对应的方法是__imod__。如果你实现了__imod__那么a % b会直接调用它如果没实现Python 会退化为a a % b也就是先算出新值再重新赋值。class Counter: def __init__(self, value): self.value value def __imod__(self, other): print(f__imod__ 被调用了: {self.value} % {other}) self.value % other return self def __repr__(self): return fCounter(value{self.value}) cnt Counter(10) cnt % 3 print(cnt)输出__imod__ 被调用了: 10 % 3 Counter(value1)这里的关键是__imod__应该返回self因为a % b等价于把a重新绑定到方法的返回值。如果你返回None那么赋值之后a就变成None了这是个很隐蔽的 bug。另外对于不可变类型比如int本身%总是走“计算新值再赋值”的路径不存在真正的原地修改所以没必要实现__imod__。再提一个性能相关的小经验如果对象内部持有大数组或者大字符串实现__imod__原地修改通常比生成新对象更省内存。例如做一个自定义的大整数类__imod__可以直接在底层数组上做缩减避免复制整个对象。3. 边界情况、性能细节与调试技巧3.1 复数为什么不能用 % 和 divmod 的关系Python 的%和divmod关系密切。divmod(a, b)一次性返回(a // b, a % b)它要求a和b都支持整除和取模。复数不支持整除//自然也就取不了模。很多新手以为(34j) % 2应该返回类似“模长除以 2 的余数”的东西但 Python 的设计哲学是让运算符的含义尽量统一复数没有定义整数除法%也就无从谈起。就算你自定义复数类想实现__mod__也需要先想清楚数学含义。一种常见的做法是让%返回复数模长对某个数取模的结果class MyComplex: def __init__(self, real, imag): self.real real self.imag imag def __abs__(self): return (self.real ** 2 self.imag ** 2) ** 0.5 def __mod__(self, other): return abs(self) % other def __repr__(self): return fMyComplex({self.real}, {self.imag}) z MyComplex(3, 4) print(z % 3) # 5 % 3 2这种设计在你的项目里也许合理但我必须提醒它很容易让使用者困惑因为z % 3的“z”明明是一个复数对象得到的却是实数取模结果。除非有非常明确的使用场景否则不建议这么写。3.2 Python 3.12 对内建数值类型的特殊处理Python 3.12 在 CPython 内部将二进制运算分派逻辑集中到了_PyBinaryOp这个函数里%对应的二进制操作码是NB_REMAINDER。这意味着当你执行7 % 3时解释器会优先判断两个操作数的类型是否能直接走内部快速路径不行才回退到常规的“查找__mod__/__rmod__”流程。这个优化对自定义类型没有影响但对以下场景有实打实的改善对int和float混合取模比如7.5 % 2。在循环里对大量整数反复取模例如哈希表实现、环形索引计算。与其他 C 扩展类型做运算时内建类型槽可以直接参与计算减少 Python 层的调用开销。有人专门做过基准测试在 Python 3.11 和 3.12 里重复执行一千万次_i % n3.12 大约快了 10% 到 15%。具体数字取决于机器和 Python 编译选项但整体趋势是明确的。如果你在做性能敏感的数据处理这个优化是白捡的便宜不用改代码就能受益。另外一个值得了解的点是%对负数取模的结果符号约定。Python 的%结果总是与除数同号即-7 % 3 2而不是像 C 语言那样结果为 -1。这直接影响你写自定义__mod__时的边界处理。官方文档明确说明a % b满足a b * (a // b) (a % b)这个公式是推导你自定义取模逻辑的基石。3.3 如何调试魔术方法从 type 到 operator自定义__mod__出了问题最常见的表现是“明明定义了__mod__但%还是报类型错误”。这时候我建议你用三个步骤排查。第一步确认方法和类名没有拼写错误。__mod__前后各有两个下划线一共四个少一个都不行。我曾经见过有人把__mod__写成_mod_结果找了半天 bug。第二步用type(a).__mod__查看方法是否存在print(type(c).__mod__) print(hasattr(c, __mod__))如果输出是method __mod__ of ModClock objects和True说明方法确实定义了。如果输出显示NotImplemented或者不存在就要检查继承关系看方法是不是被父类覆盖了。第三步使用operator.mod显式调用import operator print(operator.mod(c, 2)) print(operator.mod(2, c))operator.mod底层用的是和%完全一样的分派逻辑好处是可以直接把调用封装成参数传递。比如你写一个批量取模函数def apply_mod(op1, op2_lst): return [operator.mod(op1, item) for item in op2_lst]这样就不用写循环里的魔法变量代码意图也更明确。4. 常见问题与踩坑实录4.1mod没被调用检查getattribute和类型有个经典坑类里定义了__getattribute__结果所有属性访问都走了自定义逻辑__mod__也被拦截了。__getattribute__是一个“过滤层”只要访问任何属性都会先经过它自然也包括 Python 在内部查找__mod__的过程。class Trap: def __init__(self, value): self.value value def __getattribute__(self, name): print(fattribute access: {name}) return object.__getattribute__(self, name) def __mod__(self, other): return self.value % other t Trap(10) print(t % 3)这段代码会先打印一堆attribute access: ...日志然后再算出结果。如果你的__getattribute__实现有 bug__mod__可能直接报RecursionError或者返回错误结果。解决办法是不要轻易重写__getattribute__如果一定要写务必用object.__getattribute__(self, name)作为兜底返回。另一个常见的“没被调用”场景是操作数类型不匹配。比如你自定义了__mod__但%的右侧是一个 numpy 数组numpy 可能会先用自己的广播机制处理你的__mod__压根没机会执行。这是第三方库类型抢占运算符分派的问题排查时要先确认两侧的真实类型。4.2 反向方法rmod与 int 的“霸道”问题Python 在二元运算分派上有一个优先级原则如果右侧操作数的类型是左侧类型的子类那么右侧的__rmod__有更高优先级。反过来如果两侧是无关类型左侧的__mod__先执行只有它返回NotImplemented时右侧的__rmod__才有机会。这个机制容易导致一个反直觉的现象class Weird: def __rmod__(self, other): return 999 print(10 % Weird())这段代码的输出是999因为int不知道怎么处理Weird返回了NotImplemented于是Weird.__rmod__(10)接管。这符合预期。但如果你把Weird设计成int的子类情况就变了class WeirdInt(int): def __mod__(self, other): return 888 x WeirdInt(10) print(x % 3) # 888 print(10 % x) # 调用 WeirdInt.__rmod__?第二个print(10 % x)会先尝试int.__mod__(10, x)但 Python 内部的类型分派对子类有特殊照顾它会先检查右侧操作数是否为左侧类型的子类实例如果是就优先调用右侧的__rmod__。由于WeirdInt没有定义__rmod__Python 会返回NotImplemented再回退到int.__mod__最终结果就是普通的10 % 10 0。这种机制容易让人困惑但它的设计初衷是为了支持子类重写运算符时不破坏int原有的行为。实际开发中我建议不要轻易继承内建数值类型去改运算符语义除非你非常清楚这些优先级规则。简单组合一个普通类通常更可控。4.3 围绕 % 的配套方法divmod、rdivmod与index最后聊一个很容易被忽略的配套方法__divmod__。当你调用divmod(a, b)时Python 会先尝试a.__divmod__(b)如果不存在再用(a // b, a % b)兜底。所以你的类如果实现了__mod__却没有实现__divmod__divmod仍然能工作但会多一次//运算的开销而且如果__floordiv__的语义和__mod__不一致结果就可能出问题。例如某个对象实现了class DivModDemo: def __init__(self, value): self.value value def __floordiv__(self, other): return self.value // other 1 def __mod__(self, other): return self.value % other d DivModDemo(10) print(divmod(d, 3))这里divmod(d, 3)不是一次性计算的而是先算d // 3得到4再算d % 3得到1最终返回(4, 1)。如果你希望divmod的结果更高效或者更符合业务语义就应该显式实现__divmod__。还有一个__index__方法它影响的是%与索引操作混用时的行为。比如some_list[m % n]里m % n的结果必须是一个整数才能作为列表索引。如果m % n返回自定义对象列表索引就会报TypeError。在 Python 3.12 里内置类型对__index__的处理更严格任何想被当作整数的对象都必须显式实现它。所以如果你做的自定义数值类型想要支持list[obj]这种写法记得实现__index__class Indexable: def __init__(self, value): self.value value def __index__(self): return int(self.value) def __mod__(self, other): return Indexable(self.value % other) idx Indexable(4) lst [a, b, c, d, e] print(lst[idx % 3]) # 4 % 3 1打印 b没有__index__的话上面这一行会在索引时报错。这也是__mod__周边最常见的“隐性依赖”之一。再列一张速查表方便你写代码时对照方法名对应运算符典型场景关键注意事项__mod__a % b取模、周期性回绕、自定义格式化返回类型要和业务语义一致否则易误导__rmod__b % a左侧对象不处理时自定义类型出现在%右侧与__mod__配合实现别漏掉__imod__a % b原地更新大对象、省内存一定要返回self否则赋值结果变成None__divmod__divmod(a, b)需要同时获得商和余数不实现也能工作但可能因//与%语义不同产生不一致__rdivmod__divmod(b, a)反向场景让divmod支持自定义类型优先级规则和__rmod__类似__index__lst[obj]等让自定义类型可被当作整数索引Python 3.12 对缺少__index__报错更明确我在实际项目里用过__mod__最多的场景有两个一个是实现时间序列里的周期对齐另一个是给游戏里的飘字颜色做色相循环。两者的共同点是都需要“在固定范围内反复回绕”%的语义天然匹配。但越是这种场景越要把__mod__、__rmod__、__imod__三个方法一起设计好否则换到另一种写法就出问题。调试的时候也别只盯着运算结果先用type(obj)确认对象类型再用hasattr(obj, __mod__)确认方法存在最后用operator.mod排除运算符解析的干扰。这套流程能帮你省下大量排查时间。Python 3.12 对魔术方法的分派路径虽然做了优化但对我们这些使用方来说只要接口设计正确代码写出来依然很直观。
返回列表