返回首页
## 引言
这是课程 01《Python 编程基础与进阶》第二章「变量、类型与运算符」的第一篇。在第一章里你已经学会了安装 Python、运行脚本与 REPL、使用 print() 输出结果——那是「程序怎么跑起来」的问题;从这一篇开始,我们进入「程序里的数据是怎么存放和组织的」这个更根本的问题。
绝大多数教材都会告诉你「变量是存放数据的盒子」,然后画一个方框里面装一个数字。这个形象是错的,而且会误导你很久。真实情况是:**变量只是名字(name),数据本体是独立存在的对象(object),赋值操作做的是「把名字绑定(binding)到对象」这件事**。理解这一点,才能解释为什么 `a = b` 之后修改 b 会连累 a,为什么 `is` 和 `==` 结果不同,为什么小整数和普通整数行为不一样。这些坑在面试和真实工程里反复出现。
本篇读完,你将能用 id() 亲眼看到「名字与对象分离」的结构,看懂不可变对象(immutable)与可变对象(mutable)的本质区别,并理解 CPython 垃圾回收的入口概念——引用计数。
## 概念与原理
### 对象:一切数据的载体
在 CPython 中,程序里的一切数据——整数、浮点数、字符串、列表、函数、类——都是「对象」。每个对象在内存中占据一块区域,并且具备三个基本属性:
- **身份(identity)**:对象在内存中的位置,可用内置函数 id() 获取。只要是同一个对象,id() 一定相同;同一时刻两个活着的对象不可能共用一个 id。
- **类型(type)**:对象属于哪个类,决定它支持哪些操作,可用 type() 获取。
- **值(value)**:对象携带的数据内容,比如整数 42、字符串 "hello"。
关键点来了:**这三个属性都属于对象,而不属于变量名**。变量名只是「指向对象的名字」。用 `type(a)` 查到的其实是 a 所指向对象的类型——如果 a 被重新绑定到别的对象,type(a) 的结果会跟着变,这正说明类型不是 a 自带的。
### 堆内存与名字绑定
对象通常分配在内存的「堆」(heap)区域,生命周期不由代码块的语法结构决定,而是由它是否还被「引用」(reference)决定。赋值语句 `a = 42` 的真实执行过程是:先确保对象 42 存在(小整数 42 是早已缓存的,这个细节下一节细说),然后在当前作用域的「名字表」里登记:名字 "a" → 对象 42。Python 内部用字典(dict)来存储一个作用域内所有名字与对象的对应关系,用内置函数 dir() 看不到名字表本身,但 001 章讲过的 REPL 里输入 `a = 1` 后输入 `dir()`,你会看到 'a' 出现在列表中——这就是登记完成的效果。
正因为赋值是「登记名字」,而非「把数据搬进盒子」,同样的赋值写法对数字和列表行为完全不同,差别来自对象的可变性,而不是赋值语句。
名字表本身也是真实的运行时结构:在 REPL 里输入 globals(),能看到当前模块「名字 → 对象」的字典,自己的变量名赫然在列——这是验证「登记」行为最直观的做法。名字表上的操作还有两个:del 语句删除一行绑定(对象的引用计数随之减一),重新赋值则覆盖旧绑定。同一名字在程序的不同阶段可以指向不同类型的对象,这在静态语言里不可想象,却是 Python 动态类型的日常:`x = 3` 之后 `x = "三"` 完全合法,规则只有一个——使用时 x 必须已经绑定到某个对象,否则抛出 NameError。
### 不可变与可变:两类对象的本质差异
- **不可变对象(immutable)**:int、float、str、tuple、frozenset、complex 等。对象一旦创建,值就不能再改变。任何看起来「修改了它」的操作,实际上都是创建了一个新对象,然后重新绑定名字。
- **可变对象(mutable)**:list、dict、set、bytearray 等。对象的内容可以就地修改,修改后身份不变,所有指向它的名字都会「看到」新内容。
于是两个名字绑定到同一个列表时,通过其中一个名字 `xs.append(4)`,另一个名字 ys 指向的还是同一个对象,所以也能看到追加结果。而两个名字绑定到同一个整数时,`a = 100` 只是让名字 a 改绑一个新建的 int 对象,b 绑定的 42 纹丝不动。**「重新绑定名字」与「修改对象」是完全不同的两件事**,这是本篇最重要的心智模型。
### 引用计数:对象何时被回收
对象在堆上分配,那么谁负责回收?CPython 的默认策略是引用计数(reference counting):每个对象内部记录着当前有多少个「引用」指向它(名字、容器元素、函数参数、临时变量都算)。当计数归零时,对象立即被回收,内存归还。注意 `sys.getrefcount(x)` 返回的值比真实绑定数多 1,因为函数参数本身也临时持有一次引用。两个对象互相引用时会出现计数永远不为零的循环引用(cycle),这超出本篇范围,CPython 另有一个 gc 模块做补充回收,留到后续章节展开。
### 小整数缓存与 is 比较
CPython 启动时就会在内存中预创建 -5 到 256 一共 262 个整数对象并常驻。凡是在这个范围内的整数运算结果,引用的一律是这些「单例」对象,不新分配。这就是为什么 `int("256") is int("256")` 为 True,而 `int("257") is int("257")` 为 False——257 不在缓存里,int() 每次都会新建对象。字符串也有类似的「驻留」(interning)机制但没有统一的范围边界,行为更加依赖细节。所以说:**is 判断的是身份(是不是同一个对象),== 判断的是值(内容是否相等)**。写业务代码时一律用 ==,is 只用于与 None 比较(`x is None` 是 PEP 8 推荐写法)。小整数缓存是 CPython 的实现细节而非语言规范,PyPy、Jython 等实现可以不同,这再次说明不要依赖 is 判断整数相等。
## 操作与实现
下面是本篇的核心实验代码,请在 REPL 或脚本中亲手运行一遍。先看名字与对象分离的直观证据。
```python
a = 42
print(type(a)) # <class 'int'>:类型属于对象,不属于名字
print(id(a)) # 对象身份值(实为内存地址的包装,每次运行可能不同)
b = a # 把名字 b 也绑定到同一个 int 对象
print(b is a) # True:二者指向同一对象
print(id(b) == id(a)) # True:id 相同即同一对象
a = 100 # 重新绑定:a 指向一个新对象
print(a, b) # 100 42:b 仍然指向原来的 42
print(b is a) # False
```
在我本机(Python 3.12)的实际输出中,`id(a)` 打印出 `140708881061848` 这样的整数,它每次运行、每台机器都不同,因为对象在堆上的布局不由程序决定。真正稳定的是 `True`、`100 42`、`False` 这几个判断结果。
接下来对比可变与不可变对象在共享时的行为差异。
```python
xs = [1, 2, 3]
ys = xs
ys.append(4) # 原地修改共享的列表对象
print(xs, ys) # [1, 2, 3, 4] [1, 2, 3, 4]
print(xs is ys) # True
ys = [0, 0] # 重新绑定 ys,不再共享
print(xs, ys) # [1, 2, 3, 4] [0, 0]
s = "hello"
t = s
s = s + "!" # 字符串不可变:这里创建了新对象并重新绑定 s
print(t, s) # hello hello!
print(t is s) # False
```
这段代码浓缩了本篇全部要点:append 没有换掉对象,所以 xs、ys 看到的列表内容一致;`ys = [0, 0]` 换掉的是 ys 的绑定,xs 不受任何影响;字符串的 `+` 造出新对象,t 与 s 从此分道扬镳。
链式赋值是绑定语义的延伸:`a = b = c = [...]` 把三个名字依次绑定到**同一个**对象。
```python
a = b = c = [1, 2, 3]
a.append(4)
print(b, c) # [1, 2, 3, 4] [1, 2, 3, 4]:三个名字共享一个列表
```
再用缓存与引用计数收尾。用 int("...") 构造整数是为了绕开常量折叠的干扰:脚本中两处字面量 257 若出现在同一段代码里,编译器可能复用同一个常量对象,导致结果失真,而 int() 每次调用都是实打实的新建动作。
```python
a = int("256")
b = int("256")
print(a is b) # True:命中 CPython 的 -5..256 小整数缓存
x = int("257")
y = int("257")
print(x == y) # True:值相等
print(x is y) # False:身份不同,缓存之外每次新建对象
```
```python
import sys
xs = []
print(sys.getrefcount(xs)) # 2:1 来自名字 xs,1 来自函数参数临时引用
ys = xs
print(sys.getrefcount(xs)) # 3
del ys
print(sys.getrefcount(xs)) # 2:删除名字,计数回落
```
## 易错点与陷阱
### 陷阱一:用 is 判断值相等
`x == y` 是判断值,`x is y` 是判断身份。初学者常用的错误是看到 `int("256") is int("256")` 为 True 就以为 is 可以当 == 用,结果换到 257 就翻车。字符串的驻留更隐蔽:REPL 里两行分别输入 `a = "hello"` 和 `b = "hello"`,在某些版本里 `a is b` 是 True(字面量驻留),但 `a = input()` 之后再 `b = input()` 输入同样内容,is 就是 False 了。**凡是依赖 is 判断内容相等的代码,都是定时炸弹**,一律改用 ==。
### 陷阱二:以为赋值是拷贝
`b = a` 不会复制任何数据,只是多了一个名字。新手最常见的现场是:把列表传给某个函数处理(虽然函数章节在第四章才讲,但原理现在就能说),函数内部修改了列表,调用方看到数据变了,于是惊呼「Python 怎么传引用」。真相是 Python 根本没有「传值/传引用」的二分法,函数参数传递的本来就是对象引用。若想真正复制一份独立的列表,要显式调用 list.copy() 或用切片 xs[:](这一细节在第六章列表章节会展开)。
### 陷阱三:试图「修改」不可变对象
`s = "hello"`,然后 `s[0] = "H"` 会得到 TypeError: 'str' object does not support item assignment。不可变对象没有原地修改的入口,这是语言设计:整数、字符串做哈希键(字典键)时依赖「值不变」这一保证,让它们可变会破坏哈希结构的正确性。需要「看起来变了」的字符串时,正确姿势是拼接生成新对象再重新绑定,这在本篇的代码里已经出现过了。
### 陷阱四:链式赋值配可变对象
`a = b = []` 看着人畜无害,实则是两个名字共享一个列表。如果后续代码把 a 当默认值填充内容,b 会「被连坐」。工程上更推荐 `a = []; b = []` 或先 `a = []` 再 `b = a.copy()`,把共享意图显式写出来。
## 小结
变量不是装数据的盒子,而是指向堆上对象的名字:对象拥有身份、类型、值三者,赋值语句做的是名字绑定。不可变对象无法原地修改,任何「修改」都是创建新对象并重新绑定;可变对象可以原地修改,所有绑定的名字都会看到变化。CPython 用引用计数回收对象,小整数 -5..256 被缓存为单例,因此 is 与 == 的差别真实存在且可观测。带着「名字与对象分离」的视角,下一篇的浮点数误差、再往后的字符串不可变性都将顺理成章。
## 练习与思考题
1. 运行 `a = 300; b = 300; print(a is b)` 和 `x = 200; y = 200; print(x is y)`,观察结果差异并解释原因。(提示:常量折叠只在同一段编译单位内生效,且本身是实现细节,不要背结果,要理解机制。)
2. 写出下面代码的输出并说明依据:`a = [1]; b = a; b = b + [2]; print(a, b)`(注意 b = b + [2] 与 b.append(2) 的区别)。
3. 字符串 s = "abc" 执行 s += "d" 后,s 指向的对象是否还是原来的对象?用 id() 验证。
4. 思考题:小整数缓存为什么要缓存负数?提示:这个区间在 CPython 源码里写为 -5 到 256,与 Python 最常用的整数下标、哨兵值分布有关——先给出你的猜想,读到第四章函数参数相关章节时再回头检验。