Python 的 int 为什么不会溢出?拆开 PyLongObject 和它背后那串变长数组

0 阅读4分钟

你有没有试过在 Python 里算 2 ** 10000?它不会像 C 那样默默变成 0,也不会抛异常,而是老老实实吐出一长串数字。很多人说"Python 的 int 没有上限",这句话对,但它真正的代价藏在内存里。今天我把 CPython 的整数对象拆开,看看它为了"永不溢出"到底付出了什么。

一、先说一个反直觉的小事实

在 Python 启动的那一刻,解释器就偷偷把 -5 到 256 这 262 个整数全部预先建好缓存起来了。你可以验证:

a = 256
b = 256
print(a is b)   # True,指向同一个对象
x = 257
y = 257
print(x is y)   # 在多数交互环境里是 False,每次都是新对象

这意味着"小整数"几乎不占额外内存,而且比较 is 还能命中同一个对象。这一个细节,是很多人优化热点循环时忽略的第一点——反复创建 257 以上的小整数,其实在不断 malloc 新对象。

二、PyLongObject 到底是什么

CPython 源码里,整数对象的核心是这么个结构(简化):

struct _longobject {
    PyObject_VAR_HEAD
    int ob_digit[1];   // 变长数组,每一位存 30 个二进制位
};

关键点有三个,我一个一个说:

  1. ob_size 同时编码"长度"和"符号"。它是负数表示这个整数是负的,绝对值才是数组长度。一个字段干了两件事,这是 C 里常见的省内存技巧。
  2. ob_digit 不是按"字节"存的,而是按 30 位(64 位平台上)一块一块存。也就是说,一个 digit 是 30 个二进制位,而不是 8 个。这样做是为了在 32 位整型里留足余量做乘法进位,避免溢出。
  3. 数组是变长的。小数字只需要 1 个 digit,大数字就不断追加。这就是"永不溢出"的真相——不够就加 digit,直到内存用完。

三、实测:一个整数到底占多少内存

我在一台 Python 3.13(64 位)上跑了 sys.getsizeof,看不同量级整数占用的字节:

数值二进制位数估算 digit 数实际 sizeof
00028 B
2558128 B
2**3031232 B
2**6061336 B
2**100101440 B
2**5255251896 B
2**1000100134160 B

注意看:2**1000 是 1001 个二进制位,理论上 1001/8 ≈ 126 字节就够了,但实际是 160 字节。多出来的部分,一部分是 30 位对齐的浪费(每个 digit 实际占 4 字节即 32 位,有 2 位闲着),另一部分是对象头 PyObject_VAR_HEAD 的固定开销(28 字节起)。

我个人的判断是:这个设计在"小整数"上极度省(28 字节封顶),但在"几百位以上的大整数"上,相对理论最小编码有约 20%~30% 的冗余。对绝大多数业务来说无所谓,但如果你在内存里塞了几百万个大素数,这个冗余就值得换方案了(比如直接用 gmpy2 的 mpz)。

四、和别的语言比一比

类型上限溢出行为内存
C long (64位)±9.2×10¹⁸静默回绕8 字节固定
Java BigInteger无限不溢出变长,类似思路
Python int无限不溢出28B 起,变长

C 快但会溢出(还静默),Java 和 Python 都走"变长"路线保证正确,代价就是慢一点、占内存多一点。

五、三个最容易踩的坑

  1. 别拿 is 比较大整数。只有 -5~256 有缓存,超出范围 is 结果不可靠,老老实实用 ==。
  2. 大整数运算慢。每一位都要做 30 位乘法和进位处理,2**100000 的乘法比你想的贵得多。
  3. sys.getsizeof 只算对象本身,如果你把大整数放进列表,列表里存的只是指针,整数对象的内存是另算的——这点做内存评估时经常漏。

结语

Python 的 int 不会溢出,不是魔法,是"用内存换正确性"的取舍:一个 30 位的变长数组 + 复用的对象头。小数字几乎白送,大数字按位计费。理解这一点,你就不会再奇怪为什么 2**1000 是 160 字节,也不会再误用 is 去比较整数了。