很多人在掌握 __int__ 之后,都会在一个地方栽跟头:明明自己的类已经能传给 int() 了,为什么扔进列表下标、range() 参数里,解释器还是甩过来一句 TypeError?这个问题的答案,往往就是 Python 整数协议体系里那个最低调、却最核心的方法:__index__。这篇聊透它,并顺带把 __index__ 和 __int__、__trunc__ 的关系理清楚。
__index__ 是 Python 魔术方法里少有的“系统级协议方法”。它不负责给人看,不负责数值展示,它负责的是让一个任意对象可以被解释器当成“精确整数索引”来使用。无论你是想写一个自定义的数值类型接入列表切片,还是想让自己的类被 range()、operator.index()、甚至 C 扩展里的下标逻辑接受,都得靠它。这就是为什么我把这期的主题放在 Python 3.12 MagicMethods 系列的第 72 个位置——它不是最常用的,却是最容易被误解的。
这篇内容会把这几个问题讲透:为什么 Python 要同时存在 __int__、__index__、__trunc__ 三种“转整数”协议;__index__ 从哪来、如何被 CPython 解释器调用;怎么给自己的类实现 __index__ 并接入真实项目;numpy 和自定义容器场景里它怎么工作;以及 Python 3.12 环境下实践时需要注意的细节和自查规则。
1. 三种“转整数”协议并存,index 为什么独立存在
1.1 int、index、trunc 各管什么
很多 Python 初学者会默认“只要对象能转成 int,就哪都能用”,但解释器的真实规则要严格得多。int(obj)、math.trunc(obj)、obj[n] 三者的底层协议并不互通,至少在语义上被刻意区分开了。
看下面这几个方法:
__int__:面向“人类可读的数值转换”,允许有损转换。比如float转int会截断小数位,str转int会做解析,这些都是有损或带解析规则的场景。当调用int(obj)时,解释器优先找这个协议。__trunc__:面向math.trunc(obj),核心语义是向零取整。它解决的问题是“这个对象有没有整数截断形式”。__index__:面向“解释器内部需要精确整数索引”的场景,禁止有损转换。返回值必须是真正的int(技术上可以是int子类),浮点、字符串等通通不行。
三者真正的区别不在返回值形式上,而在“能否允许丢精度”。
__int__ 被设计成可以接受各种“近似整数”,比如浮点数可以作为 int() 的参数,但浮点不能作为列表下标。如果让解释器在下标场景自动回退到 __int__,那么 3.7 这种值到底该被当成 3 还是报错?无论选哪种,都会产生反直觉行为。所以 Python 内部把那套“必须精确、不能丢精度”的转换单独抽出来,形成了 __index__ 协议。
| 魔术方法 | 主要触发场景 | 返回值要求 | 是否允许有损 |
|---|---|---|---|
__int__ |
int(obj)、format(obj) 等 |
通常会转成 int |
允许截断、解析等 |
__trunc__ |
math.trunc(obj) |
整数 | 只做朝向零的截断 |
__index__ |
切片、range()、bin()、C 层 PyNumber_Index |
必须是真正的 int / int 子类 |
不允许有损 |
从 Python 3.8 开始,官方文档对 __index__ 的表述也变得更严格:它不再只是“为了切片而存在”,而是被定义为“在需要无损整数的地方被调用”。这个口径的变化,说明解释器内部已经把它当作一个通用的“无损失整数协议”来用,而不是一个专门给 list[n] 用的特例补丁。
1.2 只有 int 没有 index 的类型,为什么不能切片
这里用一个最常见的失败案例来展开。假设你写了一个表示页码的类:
python复制class PageNumber:
def __init__(self, n):
self.n = n
def __int__(self):
print("calling __int__")
return self.n
在 REPL 里验证它能不能作为列表下标:
python复制data = ["a", "b", "c", "d"]
page = PageNumber(2)
int(page) # 正常,输出 2
data[page] # 报错
实际报错内容取决于你访问的是 list 还是其它容器,但常见的 TypeError 文案是:
text复制TypeError: list indices must be integers or slices, not PageNumber
这里有一个很关键的现象:解释器并不会因为你实现了 __int__,就自动“宽宏大量”地把对象当成整数下标。因为 __int__ 的语义允许有损,你不一定知道调用方想截断成多少。所以对容器索引这套逻辑来说,唯一安全的整数来源是 __index__。
