本节目标:把「一切皆对象」从口号变成可验证的机制——你能用
id、type、sys.getsizeof、sys.getrefcount亲手量出 CPython 对象的三要素与内存布局,理解「名字绑定」在字节码层面到底做了什么,并说清小整数缓存、字符串驻留和不朽对象的真实边界。
适用版本:Python 3.12+(实测 3.14.6)
1.1 一切皆对象:类型、标识与引用
入门篇里我们已经用过 class、__dict__ 和 is,知道「类也是对象」。但那是结论。本节要回答的是CPython 到底把一个值存成了什么:为什么 id() 能唯一标识一个对象、sys.getrefcount() 能数出引用、type() 永远有答案,以及 x = obj 这行代码在解释器里究竟改动了哪个字节。读懂这些,第 4 章的内存管理与第 2 章的元编程才有共同的语言。
一、对象的三要素:标识、类型、值
Python 里每个值都是一个 PyObject。判断「某个东西是不是对象」只有一个标准:它有没有标识(identity)、类型(type)和值(value)。下面的实测覆盖了从整数到函数的一切:
import sys
for obj in (42, 3.14, "s", [1, 2], {1: 2}, None, True, len, print, int, object):
print(f"{type(obj).__name__:>8} | id={id(obj):>16} | type={type(obj)}")
输出(本机 3.14.6):
int | id= 4387825720 | type=<class 'int'>
float | id= 4456379024 | type=<class 'float'>
str | id= 4387891960 | type=<class 'str'>
list | id= 4457174656 | type=<class 'list'>
dict | id= 4457915200 | type=<class 'dict'>
NoneType | id= 4387662440 | type=<class 'NoneType'>
bool | id= 4387571184 | type=<class 'bool'>
builtin_function_or_method | id= 4456989056 | type=<class 'builtin_function_or_method'>
builtin_function_or_method | id= 4456989776 | type=<class 'builtin_function_or_method'>
type | id= 4387644448 | type=<class 'type'>
type | id= 4387685832 | type=<class 'type'>
连 len、print、int、object 都有 id 和 type——它们是 builtin_function_or_method 或 type。类型本身也是对象,所以 type(type) is type 为真。这条自指链的顶端是 object:
print(type(type) is type) # True
print(type(object)) # <class 'type'>
print(object.__bases__) # () —— 万物之祖没有基类
print(type.__bases__) # (<class 'object'>,) —— type 继承自 object
| 观察 | 结论 |
|---|---|
每个值都有 id | 标识由 CPython 分配,存活期内不变 |
每个值都有 type | 类型决定「能对它做什么」,永远不为空 |
type(type) is type | 类型系统是自描述的,无需求助外部元数据 |
object.__bases__ == () | object 是继承树的根,type 也继承自它 |
id() 在 CPython 里返回的就是对象的内存地址,所以它在一个对象存活期间唯一且稳定;对象被回收后,地址可能被新对象复用——这就是「id 相同不代表同一对象」的根源。
二、PyObject:两个指针的对象头
CPython 里所有对象的内存布局都以同一个头部开始:一个引用计数 + 一个指向类型对象的指针。这是理解后面一切的地基,可以直接量出来:
import ctypes
print("sys.getsizeof(object()) =", sys.getsizeof(object()))
print("指针宽度 =", ctypes.sizeof(ctypes.c_void_p))
输出:
sys.getsizeof(object()) = 16
指针宽度 = 8
16 字节正好是两个指针:ob_refcnt(引用计数,8 字节)+ ob_type(类型指针,8 字节)。任何对象都先有这 16 字节,再谈自己的载荷。整数就体现得很清楚——载荷随数值大小增长:
for v in (0, 256, 257, 2**30, 2**30 + 1, 2**60):
print(f"int {str(v):>22} getsizeof = {sys.getsizeof(v)}")
输出:
int 0 getsizeof = 28
int 256 getsizeof = 28
int 257 getsizeof = 28
int 1073741824 getsizeof = 32
int 1073741825 getsizeof = 32
int 1152921504606846976 getsizeof = 36
CPython 的整数用「30 位一档」的 digit 数组存储。0、256、257 都只占一个 digit,所以同为 28 字节(16 字节头 + 4 字节 digit + 对齐)。跨过 2**30 后需要两个 digit,涨到 32 字节。同样的「整数」在内存里大小不同,这是 CPython 的实现细节,不是语言规范——换一个实现(如 PyPy)数字完全不同。
三、引用计数实测,与 3.12+ 的不朽对象
ob_refcnt 是可见的,用 sys.getrefcount() 就能读。注意它本身会临时加一次引用,所以读数总是比「真实持有数」多 1:
x = []
print("getrefcount(x) =", sys.getrefcount(x)) # 2:变量 x 一次 + 传参一次
y = x
print("after y=x, getrefcount(x) =", sys.getrefcount(x)) # 3
del y
print("after del y, getrefcount(x) =", sys.getrefcount(x)) # 2
输出:
getrefcount(x) = 2
after y=x, getrefcount(x) = 3
after del y, getrefcount(x) = 2
但如果你去数一个小整数的引用计数,会看到一个荒谬的数字:
print("getrefcount(256) =", sys.getrefcount(256))
print("getrefcount(257) =", sys.getrefcount(257))
输出:
getrefcount(256) = 3221225472
getrefcount(257) = 3
3221225472 等于 0xC0000000。这不是计数溢出,而是 PEP 683 的「不朽对象」(immortal objects),从 Python 3.12 起生效:对 None、True、False、小整数、模块名这类永生对象,解释器把引用计数钉在一个巨大的哨兵值上,增减引用都直接跳过,从而省掉每次访问的原子操作、并为自由线程铺路。实测这些对象全部命中同一个值:
IMM = 0xC0000000
for obj in (256, None, True, False, "__main__"):
print(f"{repr(obj):>10} -> {sys.getrefcount(obj)} 不朽: {sys.getrefcount(obj) == IMM}")
输出:
256 -> 3221225472 不朽: True
None -> 3221225472 不朽: True
True -> 3221225472 不朽: True
False -> 3221225472 不朽: True
'__main__' -> 3221225472 不朽: True
再加一千个引用也不会变:
before = sys.getrefcount(256)
lst = [256] * 1000
print(before, sys.getrefcount(256), before == sys.getrefcount(256))
输出:
3221225472 3221225472 True
这是 3.11 及更早版本看不到的现象:在那些版本上 getrefcount(256) 会是一个随程序状态波动的正常小数。判读引用计数时,先看它是否等于 0xC0000000,再谈具体数字。
四、名字绑定不是赋值
x = obj 常被说成「把 obj 赋给 x」,但字节码里的真相是:x 是一个名字,绑定到一个对象;赋值只是让某个字典的键指向该对象。用 dis 把顶层赋值拆开:
import dis
src = compile("x = [1, 2, 3]\ny = x\n", "<demo>", "exec")
dis.dis(src)
输出(节选,省略了 RESUME/RETURN_VALUE 等固定指令):
1 BUILD_LIST 0
LOAD_CONST 2 ((1, 2, 3))
LIST_EXTEND 1
STORE_NAME 0 (x)
2 LOAD_NAME 0 (x)
STORE_NAME 1 (y)
关键在于最后两行:y = x 编译成 LOAD_NAME x + STORE_NAME y,只是让名字 y 指向 x 当前指向的那个对象。全程没有复制 [1, 2, 3],也没有拷贝对象头。这解释了三条常识:多个名字可指向同一对象、del x 只解除绑定不销毁对象(除非引用计数归零)、传参是按对象引用传递而非按值拷贝。
对象自身则用 __dict__ 记录「名字到对象」的映射:
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
p = Point(1, 2)
print("p.__dict__ =", p.__dict__)
print("id(p.x) == id(p.__dict__['x']) :", id(p.x) == id(p.__dict__['x']))
输出:
p.__dict__ = {'x': 1, 'y': 2}
id(p.x) == id(p.__dict__['x']) : True
p.x 与 p.__dict__['x'] 是同一个对象,属性访问只是换了个入口去查同一个字典。类、函数同样有 __dict__——Point.__dict__ 存方法,g.__dict__ 存函数自己的属性。
五、小整数缓存与字符串驻留
既然一切都是对象,创建对象就有成本。CPython 用两种缓存削减高频对象的开销,而它们的边界经常被误传。
小整数缓存:-5 到 256 在解释器启动时预先创建并永久复用。要验证它,必须绕过常量折叠——同一段代码里的两个 257 字面量可能指向同一个常量对象,直接用 is 会得出错误结论。用 int(str(n)) 强制运行时新建:
for n in (-6, -5, -1, 0, 256, 257, 1000):
a = int(str(n)); b = int(str(n))
print(f"n={n:>6} is -> {a is b}")
输出:
n= -6 is -> False
n= -5 is -> True
n= -1 is -> True
n= 0 is -> True
n= 256 is -> True
n= 257 is -> False
n= 1000 is -> False
边界正是 [-5, 256]。这也是为什么永远不要用 is 比较整数:在缓存区间内 is 偶然为真,出了区间就为假,代码会随数值大小诡异地「时灵时不灵」。
字符串驻留:编译期字面量、以及看起来像标识符的字符串会被驻留(intern),运行时拼出来的字符串默认不会:
import sys
q1 = "".join(["he", "llo"])
q2 = "".join(["he", "llo"])
print("运行时拼接 q1 is q2 =", q1 is q2) # False
print("sys.intern 后 =", sys.intern(q1) is sys.intern(q2)) # True
输出:
运行时拼接 q1 is q2 = False
sys.intern 后 = True
驻留带来的收益是字符串比较可以先比指针:相等时命中同一对象,直接返回,无需逐字符比对。实测 200 万次等长比较:
等长但不同的字符串比较 200 万次: 23.7 ms
相同对象(驻留命中)比较 200 万次: 15.7 ms
指针相等的短路让命中路径快约三分之一。这也提醒我们:当字典键是大量重复的短字符串时,先 sys.intern 能省下可观的内存与比较开销。
六、为什么这些细节值得知道
id/type/引用计数不是考试知识点,它们决定了后面几章的走向:
- 第 4 章的循环 GC 之所以必要,正是因为引用计数(本节量出的
ob_refcnt)无法处理环; - 第 2 章的元编程之所以可行,是因为类型也是对象、也有
__dict__; - 「不朽对象」决定了在自由线程构建下,哪些共享对象的引用计数操作被彻底消除。
关于引用计数如何失效、GC 如何补位,下一章会展开,这里先把 引用计数、循环 GC 与分代回收
记在心里。若想先看对象在内存里如何排布,对象布局、__slots__ 与内存占用测量
会接着本节继续往下挖。
小结
- 每个值都是
PyObject,具备标识(id)、类型(type)、值三要素;type(type) is type,object是继承树之根。 - CPython 对象以 16 字节对象头开场:
ob_refcnt(引用计数)+ob_type(类型指针),其余载荷按类型各异,sys.getsizeof可直接量。 - Python 3.12+ 起,
None、布尔、小整数、模块名等是不朽对象,sys.getrefcount恒为0xC0000000;判读引用计数前先排除这个哨兵值。 x = obj是名字绑定,字节码LOAD_NAME+STORE_NAME只让字典指向同一对象,不复制数据。- 小整数缓存区间是
[-5, 256],字符串驻留在编译期与sys.intern下生效;两者都意味着用is比较值是不安全的。
下一节 属性查找、描述符协议与 __getattr__
会接着问一个更细的问题:当我们写 obj.attr 时,CPython 究竟按什么顺序在「实例字典、类字典、描述符」之间挑一个答案。
阅读导航:上一节:《Python高级编程》目录 · 下一节:1.2 属性查找、描述符协议与 getattr 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。