《Python高级编程》1.1 一切皆对象:类型、标识与引用

本节从 CPython 实现层拆开「一切皆对象」:用 id/type/sys.getsizeof 实测 PyObject 的引用计数与类型指针,用 sys.getrefcount 观察 3.12+ 的不朽对象(PEP 683),用 dis 反汇编说清名字绑定只是字典指向对象,并给出小整数缓存与字符串驻留的真实边界。

本节目标:把「一切皆对象」从口号变成可验证的机制——你能用 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 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「python」更多文章

  1. 《Python高级编程》目录
  2. 《Python高级编程》11.3 PEP 流程与版本迁移策略
  3. 《Python高级编程》11.2 嵌入式与自由线程运行时