第02篇|变量、对象与内存模型:一切皆对象、引用与 id
本篇定位:《Python 教程》专栏一「Python 从零入门」的第 2 篇,正式进入语言本身。搞清楚“名字、对象、引用”三者关系,是后面所有容器、函数、NumPy 视图与 PyTorch 张量共享的地基。
读完你将能够:用“名字绑定到对象”解释
a = b;说出每个对象的 identity / type / value;正确区分is与==;解释可变对象共享引用为何会“牵一发而动全身”;用引用计数的视角理解对象何时被回收,并能联想到张量视图与 Agent 缓存中的同类陷阱。预计用时:阅读 35~45 分钟,动手 20~30 分钟。
上一篇我们搭好了 Python 环境,知道了“解释器 + 虚拟环境 + site-packages”的三角关系。但你一旦开始写代码,立刻会碰到更基础的困惑:a = b 到底是复制还是引用?为什么改了列表 b,a 也变了?is 和 == 有什么区别?id() 打印的那串数字又是什么?
这些问题表面是语法细节,背后其实是 CPython 的对象模型。本篇用实验把这套模型摸清楚。它会在第 04 篇(列表)、第 07 篇(函数参数)、第 30 篇(NumPy 视图与拷贝)、第 37 篇(PyTorch 张量共享存储)反复出现;做 Agent 时缓存可变对象、做推理时管理 KV Cache,同样离不开这套心智模型。
建议你边读边打开上一篇建好的环境(uv run python 或 VS Code 交互窗口),把文中的代码亲手跑一遍。对象模型这种东西,光看文字“好像懂了”,一打印 id() 才真正长进脑子里。
一、变量不是盒子:名字与对象的绑定
1.1 丢掉“盒子”隐喻
很多入门书把变量画成盒子:a = 42 就是把 42 放进名叫 a 的盒子。这个隐喻在 C、Java 等语言里大致成立,在 Python 里却会把你带沟里。
Python 的变量是名字(name),对象住在堆内存里,赋值做的是把名字绑定(bind)到对象上——更像给对象贴标签,而不是往盒子里塞东西。
这个差别为什么重要?因为盒子模型会诱导你做出错误预期:“赋值 = 复制一份独立的值”。一旦对象是可变的(列表、字典、自定义类、张量),错误预期就会立刻变成难以察觉的 bug。标签模型则始终一致:赋值只是让另一个名字指向同一对象;若要独立副本,必须显式拷贝。

b=a是复制一份新值,若修改b的值不会影响a,而这不符合实际。正确模型则说明,变量是指向对象的名字标签,图中变量a和b的标签都指向同一个值为42的int对象,b=a只是让b成为了指向同一对象的新标签,并非复制新值,也不是创建新对象。图底部的总结句明确强调,Python里没有“装值的变量”,只有“贴在对象上的名字”,后续列表、字典等共享内存的特性都基于这个模型。图1 变量是名字,不是装值的盒子
亲手验证:
a = 1000 # 创建一个 int 对象,名字 a 绑定到它
b = a # 没有复制 1000,只是让 b 也指向同一个对象
print(a is b) # True:同一个对象
print(id(a), id(b))
b = 1001 # b 改绑到新对象;a 仍然指向原来的 1000
print(a, b)
print(a is b) # False
预期输出(id 的具体数字因机器而异,关键是前两行相同、后两行不同):
True
140162646366448 140162646366448
1000 1001
False
b = a 没有制造第二个 1000;它只是多贴了一张标签。后面 b = 1001 是给 b 换标签,原来的对象(只要还有别的名字指向它)不受影响。
再看一个更有“杀伤力”的例子——这次换成可变的列表:
a = [10, 20]
b = a # 别名:两个名字,一个列表
b.append(30)
print(a) # [10, 20, 30] —— a 也变了!
print(a is b) # True
[10, 20, 30]
True
如果脑子里还是“盒子模型”,你会觉得不可思议:“我明明只改了 b”。换成“标签模型”就顺理成章:append 改的是列表对象本身,两张标签都贴在同一张纸上,当然两边都看得见。
1.2 赋值、删除与“当前命名空间”
x = obj:在当前命名空间(namespace)里,把名字x绑定到obj。若x原先指向别的对象,旧绑定被解除。del x:删除名字x,不直接“删除对象”。对象是否回收,取决于还有没有别的引用指向它。- 多个名字可以同时指向同一对象,这叫别名(alias)。
x = [1, 2, 3]
y = x
del x
print(y) # [1, 2, 3] 还在:对象没被删,只是名字 x 没了
[1, 2, 3]
可以把命名空间想象成一张“名字 → 对象”的对照表。模块有模块级对照表,函数调用时会临时多一张局部对照表(第 07 篇讲 LEGB 时会展开)。dir() 不带参数时打印的,大致就是当前命名空间里有哪些名字:
pi = 3.14
print("pi" in dir()) # True:名字 pi 在当前命名空间里
True
和上一篇的字节码对照一下更有感觉:赋值语句编译后大致是“把对象放到栈顶,再 STORE_NAME / STORE_FAST”——存的是引用,不是把对象字节逐个拷进变量槽。上一篇用 dis.dis 看过 LOAD_FAST,对应的存储指令就是在写这张对照表。
AI Infra 视角
在 Agent 或推理服务里,你常把配置字典、消息历史、工具结果放进“全局缓存”。如果你以为赋值是深拷贝,实际却是别名,后续某一步原地改了缓存内容,别的请求读到的就是被污染的数据。这类 bug 极难查——根因往往就是“把名字当成了盒子”。
同样的模型也解释了为什么“把同一个 dict 传给两个协程/线程去改”会出诡异结果(第 23、25 篇还会从并发角度再讲):不是字典“复制错了”,而是根本没有复制。
二、一切皆对象:type、id、value
2.1 三个基本属性
Python 里一切皆对象(everything is an object):整数、字符串、函数、模块、甚至类型本身,都是对象。这不是一句广告语,而是可以立刻验证的事实——你可以对函数做 id(func),可以对模块做 type(sys),可以像传数据一样把函数当作参数传来传去(第 08 篇的闭包与装饰器、第 28 篇的 Agent 工具调用都会用到“函数也是对象”)。
每个对象有三个基本属性:
| 属性 | 含义 | 如何查看 |
|---|---|---|
| identity(身份) | 对象在内存中的唯一标识,存活期间不变 | id(x) |
| type(类型) | 决定对象能做什么操作 | type(x) 或 x.class |
| value(值) | 对象承载的数据;可变对象的值可就地改变 | 用 == 比较;打印或索引查看 |

图2 CPython 对象的三要素
x = 42
print(type(x)) # <class 'int'>
print(id(x)) # 一串整数,CPython 中通常是对象的内存地址
print(x) # 值:42
print(type(int)) # <class 'type'> —— 类型本身也是对象
print(type(type)) # <class 'type'> —— type 的类型还是 type
<class 'int'>
10760176
42
<class 'type'>
<class 'type'>
(id 的具体数值每次运行、每台机器都可能不同;小整数的 id 在同一次进程内通常稳定,因为它们被缓存了,见第三节。)
值得多说一句:id(x) 在 CPython 里通常就是对象的内存地址,但这是实现细节,不是语言标准的保证。别的 Python 实现(如 PyPy)可能用别的方式生成身份标识。可移植的写法是:用 is 判断身份,用 == 判断相等,而不要把 id 的数值存起来当“指针”做算术——那是 C 的思维方式,拿到 Python 里会害人。
2.2 CPython 里对象长什么样(高层次)
在 CPython 的 C 实现里,每个对象至少包含:
ob_refcnt:引用计数(reference count),有多少指针正指向它;ob_type:指向类型对象的指针;- 载荷:真正的数据(
int存数字,list存元素指针数组,str存字符……)。
你不需要会写 C 扩展也能受益于这个图景:名字 / 容器里存的从来不是“值本身”,而是指向对象的引用(指针)。这就是为什么 b = a 如此便宜——只是复制了一个指针;也是为什么共享可变对象如此危险——多个指针打到同一块可变内存上。
import sys
lst = [1, 2, 3]
print(sys.getrefcount(lst)) # 通常为 2:lst 本身 + getrefcount 的参数临时引用
2
sys.getrefcount 会把传入的参数也算一次引用,所以看到的数字往往比“你以为的”多 1。用它观察趋势即可,不必死磕绝对值。
再观察一次“绑定如何改变计数”:
import sys
obj = []
print("仅 obj :", sys.getrefcount(obj))
alias = obj
print("加上 alias :", sys.getrefcount(obj))
del alias
print("删掉 alias :", sys.getrefcount(obj))
仅 obj : 2
加上 alias : 3
删掉 alias : 2
数字每次都比“肉眼可见的名字数”多 1,正是 getrefcount 自己那次临时引用。趋势比绝对值重要:多一个别名就 +1,少一个就 −1。
三、id、is 与 ==:同一对象还是相等
3.1 身份 vs 相等
初学者常把 is 和 == 当成同义词。它们问的是完全不同的问题:
a is b:比较身份,等价于id(a) == id(b),问“是不是同一个对象”。a == b:比较值,调用对象的eq方法,问“内容是否相等”。

is与==的区别,标题明确标注“is比身份,==比值”。左侧对应a is b的判断逻辑,聚焦对象身份,当变量a、b指向同一个对象时,两者身份ID一致,结果为True,等价于判断id(a) == id(b)。右侧对应a == b的判断逻辑,聚焦内容(值)是否相等,示例中变量a指向的list对象A与变量b指向的list对象B,内容均为[1,2,3],值相等但二者指向不同对象,is判定结果为False。图底部总结了使用法则:判断是否为同一对象用is,多用来和None、True、False这类单例比较;判断内容是否相等用==,同时提醒不要依赖小整数、短字符串被缓存时,is可能巧合返回True的情况。图3 is 比身份,== 比值
a = [1, 2, 3]
b = [1, 2, 3]
print(a == b) # True:内容相同
print(a is b) # False:两个不同的列表对象
c = a
print(a is c) # True:c 只是 a 的别名
True
False
True
3.2 什么时候该用 is
几乎只有这些情况推荐用 is:
- 与单例比较:
x is None、x is True、x is False(None/True/False在进程里各只有一份); - 明确想知道“是不是同一个对象”(例如缓存里是否命中同一实例)。
日常判断内容是否相同,一律用 ==。 写成 if x == None 虽能跑,但风格上应写成 if x is None。
为什么对 None 特别推荐 is?因为 None 是单例——整个进程里只有那一个对象,身份判断既快又不会被奇怪的 eq 重载骗到。某些自定义类可能把 == None 实现成别的含义,is None 则始终可靠。
x = None
print(x is None) # 推荐
print(x == None) # 能跑,但不推荐
True
True
3.3 小整数缓存与字符串驻留
CPython 为了省内存、提速度,会缓存一小段整数(通常是 -5 到 256),并可能对某些短字符串做驻留(intern)。结果是:两个“看起来分别创建”的小整数,is 也可能为 True。
a = 256
b = 256
print(a is b) # True:小整数缓存
# 用 int("...") 强制走转换路径,避开源码常量折叠
a = int("257")
b = int("257")
print(a == b, a is b) # True False:值相等,但不是同一对象
s1 = "hello"
s2 = "hello"
print(s1 is s2) # True:字面量常被驻留
s3 = "".join(["hel", "lo"]) # 运行时拼出来的
print(s1 == s3, s1 is s3) # True False
True
True False
True
True False
千万不要依赖 is 来比较数字或字符串。 缓存是解释器的内部优化,不同版本、不同代码路径行为可能不同;你的业务逻辑只应依赖 ==。
补充一个实验:在交互式解释器里连续输入两行 a = 257 和 b = 257,再看 a is b,结果往往是 False;而把两行写进同一个 .py 文件一起运行,有时又会变成 True(编译器把常量折叠了)。同一段“看起来一样”的代码,在不同执行方式下身份关系都可能不同——这正是“不要依赖”的最好理由。
若你确实需要强制共享同一个字符串对象(例如大量重复 token 做字典键),可以用 sys.intern(s) 显式驻留;这是有意为之的优化,不是碰运气。
四、可变与不可变:为什么 a = a + 1 会换对象
4.1 两类对象
| 类别 | 代表类型 | 值能否就地改 | 典型操作 |
|---|---|---|---|
| 不可变(immutable) | int、float、bool、str、tuple、frozenset、bytes | 不能 | 任何“修改”都产生新对象 |
| 可变(mutable) | list、dict、set、bytearray,以及大多数自定义类实例 | 能 | append、setitem 等就地改 |
| copy.copy / copy.deepcopy | a.copy() 才是副本 | a.clone() / a.detach().clone() |
判断口诀:能不能在不改变 id 的前提下改内容。 能,就是可变;不能,就是不可变。字符串没有 append,想“变长”只能拼接出新字符串再赋值;列表有 append,id 可以始终不变。
读书时不妨边看边在纸上画“名字框 → 箭头 → 对象框”。箭头只有一根还是两根、对象框外面写不写“可就地改”,比单纯背定义有效得多。本篇的几张配图,正是这种画法的标准答案。
s = "hi"
print(id(s))
s = s + "!" # 不是改原字符串,而是新建 "hi!",再让 s 绑过去
print(id(s)) # id 变了
lst = [1, 2]
print(id(lst))
lst.append(3) # 就地修改,不新建列表
print(id(lst), lst) # id 不变
140162645900000
140162645900096
140162647133440
140162647133440 [1, 2, 3]
(上面的 id 是示意;你本机跑出来的数字不同,但“字符串赋值后 id 变、列表 append 后 id 不变”这一规律成立。)
4.2 重新绑定 vs 就地修改
这是本篇最容易混淆、也最值钱的一对概念:

图4 可变对象的共享引用
# 重新绑定:a + [...] 生成新列表,再让名字 a 指向新对象
a = [1, 2]
b = a
a = a + [3]
print("rebind:", a, b, a is b)
# a == [1,2,3], b == [1,2], a is b == False
# 就地修改:+= 对 list 调用的是 extend,改的是原对象
a = [1, 2]
b = a
a += [3]
print("inplace:", a, b, a is b)
# a == [1,2,3], b == [1,2,3], a is b == True
rebind: [1, 2, 3] [1, 2] False
inplace: [1, 2, 3] [1, 2, 3] True
对不可变对象,+= 只能走“算新值再重绑”这条路,所以 n += 1 和 n = n + 1 效果相同。对可变对象,两者可能天差地别——共享引用时尤其危险。
可以用 id 一眼看穿发生了哪一种:
a = [1, 2]
b = a
print("before", id(a), id(b))
a = a + [3] # 重绑
print("after +", id(a), id(b), a is b)
a = [1, 2]
b = a
print("before", id(a), id(b))
a += [3] # 就地
print("after +=", id(a), id(b), a is b)
before 140162647100000 140162647100000
after + 140162647100128 140162647100000 False
before 140162647100256 140162647100256
after += 140162647100256 140162647100256 True
(具体 id 随环境变化;请关注“+ 之后两个 id 不同,+= 之后两个 id 仍相同”。)
4.3 a = a + 1 到底发生了什么
a = 10
print(id(a))
a = a + 1 # 1. 取出 a 的值 10;2. 计算 11(新 int 对象);3. 让名字 a 绑到 11
print(a, id(a)) # id 已经变了(小整数除外时也可能碰巧相同,但语义是重绑)
整数不可变,不存在“把盒子里的 10 改成 11”;只有“名字改贴到另一个对象上”。理解这一点,你就不会再问“为什么 Python 的整数没有 ++ 运算符”——因为根本没有可就地自增的整数对象。
元组(tuple)也是不可变的,但有一个细微之处值得提前知道:元组本身不能增删改元素槽位,若某个元素是可变对象(比如列表),那个元素对象仍可被就地修改。
t = ([1, 2], 3)
# t[0] = [9, 9] # TypeError:不能改元组的槽位
t[0].append(9) # 合法:改的是槽位里那个 list 对象
print(t)
([1, 2, 9], 3)
“容器不可变”不等于“里面装的东西也不可变”。第 04 篇讲列表与元组时会再强调一次。
五、共享引用、参数传递与拷贝预览
5.1 函数参数也是绑定
Python 的函数参数传递,本质是把实参对象绑定到形参名字上——既不是“传值复制一份”,也不是 C++ 那种“传引用变量本身”,而是传对象的引用(pass by object reference)。
def append_item(x):
x.append(99) # 就地修改传入的对象
def rebind_local(x):
x = x + [99] # 只让局部名字 x 改绑,调用方看不到
data = [1]
append_item(data)
print("after append_item:", data) # [1, 99]
data = [1]
rebind_local(data)
print("after rebind_local:", data) # [1]
after append_item: [1, 99]
after rebind_local: [1]
规则很简单:函数里如果就地改可变对象,调用方看得到;如果只是给形参重新赋值,调用方不受影响。 第 07 篇讲作用域时还会回到这一点。
这也解释了为什么“在函数里做 x = something 无法把新对象传回给调用方”——你改的是局部名字的绑定,不是调用方手里的那个名字。若要传回新对象,请用 return:
def add_one(nums):
return nums + [1] # 返回新列表,由调用方决定是否接收
data = [0]
data = add_one(data)
print(data)
[0, 1]
5.2 浅拷贝与深拷贝(预览)
需要“断开共享”时,才谈得上拷贝:
import copy
orig = [[1, 2], [3, 4]]
shallow = copy.copy(orig) # 或 orig.copy() / list(orig) / orig[:]
deep = copy.deepcopy(orig)
shallow[0][0] = 99 # 改的是“内层列表”,外层虽是新的,内层仍共享
print("orig :", orig)
print("shallow:", shallow)
print("deep :", deep) # 深拷贝完全独立
orig : [[99, 2], [3, 4]]
shallow: [[99, 2], [3, 4]]
deep : [[1, 2], [3, 4]]
- 浅拷贝(shallow copy):只复制最外层容器,元素仍是原来的对象引用。
- 深拷贝(deep copy):递归复制所有层级,得到一棵完全独立的对象树。
列表、字典的详细拷贝方式放到第 04、05 篇;这里先建立“共享引用 → 浅拷贝 → 深拷贝”的光谱即可。
怎么选?
- 只是想多一个名字、一起看变化 → 直接赋值
b = a; - 只要外层容器独立,内层可以共享 → 浅拷贝;
- 必须彻底断开、互不影响 → 深拷贝(更慢、更占内存,嵌套很深或含锁/套接字等对象时还可能失败)。
实战中还有第三条路:根本不修改,把数据当成不可变的来用(返回新对象而不是就地改)。函数式风格在 Agent 的状态更新、配置合并里特别好用,能从根上减少别名 bug。
5.3 默认参数的可变陷阱(预告)
def add_todo(item, bucket=[]): # 危险:默认列表在函数定义时创建一次,之后复用
bucket.append(item)
return bucket
print(add_todo("write doc"))
print(add_todo("review PR")) # 咦,上次的 item 还在?
['write doc']
['write doc', 'review PR']
正确写法是 bucket=None,在函数体内再 bucket = []。根因正是本篇的模型:默认参数的对象只创建一次,被多次调用共享。第 07 篇会专门展开。
安全写法对照:
def add_todo(item, bucket=None):
if bucket is None:
bucket = []
bucket.append(item)
return bucket
print(add_todo("write doc"))
print(add_todo("review PR")) # 互不影响
['write doc']
['review PR']
每次调用在 bucket is None 时新建列表,自然就不会“跨调用攒数据”。注意这里用的正是上一节推荐的 is None。
六、AI Infra 视角:张量视图、缓存与 Agent
6.1 引用计数与垃圾回收

图5 引用计数与对象回收
CPython 销毁对象的主机制是引用计数:每次新绑定 +1,解除绑定 -1,归零就立刻调用析构并释放内存。这让 Python 的内存回收通常很“及时”。
但有一个漏洞:循环引用(A 的属性指向 B,B 的属性又指向 A)会让双方计数永远 ≥ 1。为此 CPython 还有一套分代垃圾回收(generational GC)定期扫描容器对象、打破环。绝大多数日常代码感知不到 GC;但在写缓存、图结构、父子节点互相挂接时,需要知道“环会拖住内存”。
import sys
import gc
a = []
b = []
a.append(b)
b.append(a) # 循环引用
print(sys.getrefcount(a)) # ≥ 3:名字 a、b[0]、以及 getrefcount 参数等
del a, b # 名字没了,但环还在,等 GC 收拾
gc.collect() # 手动触发一次(演示用;生产代码很少需要)
日常开发几乎不必手动调用 gc.collect()。需要记住的只有两件事:(1) 没有引用的对象会很快消失;(2) 有环的对象要等 GC。 若你在长时间运行的服务里看到内存只增不减,优先查“是不是把大对象塞进了全局列表/缓存却忘了删”,而不是先怀疑 GC 坏了。
6.2 与 NumPy / PyTorch 的直接类比
后面你会反复见到同一模式:
今天把 list 的别名问题想透,将来调试“为什么改了 batch 里的一张量,另一份数据也变了”会快得多。
一个预告性的小实验(装了 NumPy 就可以现在跑;没有就记住结论,第 29~30 篇再动手):
# 需已安装 numpy:uv add numpy
import numpy as np
a = np.arange(4)
b = a[:2] # 视图:共享数据缓冲
b[0] = 99
print(a) # [99, 1, 2, 3] —— 原数组也被改了
[99 1 2 3]
这和 b = a; b.append(...) 是同一个故事,只是舞台从 Python list 换成了连续内存上的数组。
6.3 Agent 与推理服务里的真实坑
- 消息历史被原地修改:Agent 把
messages列表传给工具函数,工具里messages.append(...)或改了某条 content,主循环里的历史也被改掉,下轮提示词错乱。 - 缓存里存了可变对象:用 dict 做 LRU 缓存时,若缓存的是 list/dict 且调用方随后就地修改,缓存内容被污染;多请求并发时更会串数据。
- KV Cache / 显存“放不掉”:推理框架里大块 CUDA 内存被 Python 侧多余引用挂住时,
del了变量显存占用也不降——因为引用计数还没归零。排查时要像今天查 list 别名一样查“还有谁指着这块存储”。 - 工具返回值被后续步骤改写:Agent 把工具输出存进轨迹后又传给下一个工具“参考”,若后者就地改了这份 list/dict,历史轨迹也被改——回放、训练、审计时会对不上。防御方式是:入库前
copy.deepcopy,或约定工具只返回不可变结构(tuple / 冻结字典 / pydantic 模型)。
把这些场景和本篇的 list 别名对照着看,你会发现它们不是“高级话题”,而是同一条规则在工程里的回声。
写代码时可以养成一个小习惯:凡是“把可变对象交给别人”(传参、入缓存、入队列、挂到全局),先停三秒问自己——对方会不会就地修改?我是否还需要一份未被改动的原件? 答案是“会改 / 需要原件”时,就在交出去之前拷贝,或改成传入只读视图 / 不可变结构。这个习惯比事后用调试器追别名省力得多。
一句话迁移
Python 的名字绑定、NumPy 的 view、PyTorch 的 shared storage、Agent 缓存里的可变对象——四者是同一类问题在不同层级的投影:共享的可变状态,是 bug 的高产区;需要独立时就显式拷贝,需要共享时就写清楚生命周期。
七、踩坑大全
下面这些坑,几乎都能用一句话归因:“把名字当成了盒子,或把优化当成了保证”。遇到诡异的“串数据 / 改 A 却动了 B / is 结果不稳定”时,先回到本篇的模型排查。
7.1 概念类
坑 1:用 is 比较数字或字符串。 现象:本地测试 a is b 为 True,换个环境或写法就变 False。原因:小整数缓存与字符串驻留是优化,不是语言保证。解决:内容比较一律用 ==;is 只留给 None / 单例 / 明确的身份判断。
坑 2:以为 b = a 复制了列表。 现象:改 b 后 a 跟着变;或者把列表放进字典/传给函数后,原处也被改了。原因:只是多了一个名字或又一次绑定。解决:需要独立副本时用 a.copy()、list(a)、a[:](浅拷贝)或 copy.deepcopy(a);若语义上不应被改,考虑改用元组等不可变结构。
坑 3:+= 与 = 混用。 现象:a = a + [x] 不影响别名,a += [x] 影响别名。原因:列表的 += 是就地 extend。解决:共享引用场景下对可变对象优先写清晰的 append / extend,避免靠 += 的“隐式就地”。
坑 4:函数默认参数用了可变对象。 现象:多次调用之间数据“累积”。原因:默认对象在 def 时创建一次。解决:默认值写 None,函数体内再创建新列表/字典。
7.2 调试与平台
坑 5:在 REPL 里看 257 is 257 为 True,在脚本里用 int("257") 又是 False。 原因:同一代码块里的常量可能被折叠成同一对象。解决:理解即可,不要写依赖这种行为的代码。
坑 6:sys.getrefcount 的数字对不上。 原因:调用时多一次临时引用;交互式环境、调试器还可能多挂引用。解决:看相对变化,不要和“理论值”死磕。
坑 7(macOS / Linux / Windows 共通):在 Jupyter 里反复运行单元格导致“对象越来越多”。 原因:内核进程不重启,旧名字可能仍活着,大对象(含张量)占着内存。解决:需要干净状态时 Restart Kernel;删大对象后可 gc.collect(),GPU 上再配合框架的空缓存接口(第 38、45 篇)。
坑 8(GPU 服务器):del tensor 后 nvidia-smi 显存不降。 原因:还有别的 Python 引用或框架缓存。解决:先查清引用链(别名、闭包、全局缓存、计算图),再考虑 gc.collect() 与 torch.cuda.empty_cache()——空缓存不能替代正确的生命周期管理。
八、小结
把本篇收成六条可自测的要点。合上文章后,试着用自己的话讲给别人听——讲得通,才算真的会了。
- Python 变量是名字,赋值是绑定,不是往盒子里放值;
b = a制造别名,不复制对象。 - 一切皆对象,每个对象有 identity(
id)、type(type)、value;CPython 用引用计数为主、分代 GC 为辅管理内存。 is比身份,==比值;日常用==,is留给None等单例。不要依赖小整数/字符串缓存。- 不可变对象只能通过重绑“修改”;可变对象可以就地改,所有别名一起看到变化。分清
a = a + [...]与a += [...]。 - 函数参数传递是传对象引用:就地修改可见,重新绑定不可见。默认参数不要用可变对象。
- 同一模型映射到 AI Infra:NumPy view、PyTorch shared storage、Agent 缓存污染、KV Cache 显存挂死——共享可变状态必须显式管理。
若只能带走一句话,请带走这句:先问“这是同一个对象吗”,再问“它可变吗”,最后才决定要不要拷贝。
九、课后练习
- (基础)依次执行下面五行,在每行后写下
id(x)是否变化,并解释原因:x = 1000→x = x + 1→x = [1, 2]→x.append(3)→x = x + [4]。 - (基础)创建两个内容相同的列表
a、b,验证a == b与a is b的结果;再执行c = a,验证a is c。 - (理解)写一个函数
def f(x): x += [1],传入列表后观察调用方是否变化;再把函数体改成x = x + [1],对比差异,并用本篇术语解释。 - (理解)用
int("256")与int("257")分别构造两对整数,打印is与==的结果,说明小整数缓存的范围与“不可依赖”的原因。 - (理解)构造一个嵌套列表,分别做浅拷贝与深拷贝,修改内层元素,画出三份数据的引用关系(可用纸笔或 ASCII 图)。
- (进阶)复现“默认参数可变对象”陷阱,并改成安全写法;再思考:如果默认参数是不可变的
tuple,还会有同样问题吗?为什么? - (进阶 · AI Infra)查阅 NumPy 文档中
ndarray.view/ndarray.copy的说明(或等到第 30 篇),用本篇的“别名 vs 拷贝”语言写一段话,说明二者与b = a/b = a.copy()的对应关系。 - (进阶 · Agent)假设你有一个全局
cache: dict[str, list],工具函数返回前对列表做了result.append(debug_info)。说明这可能导致什么 bug,并给出两种修复思路(拷贝 / 约定不可变)。
下一篇预告
对象模型清楚了,下一篇 《第03篇|数字、字符串与格式化》 会钻进最常用的两类内置对象:数字(int / float / decimal / 布尔)与字符串(编码、切片、不可变性、f-string 与格式化)。你会知道为什么 0.1 + 0.2 != 0.3、如何安全地拼接提示词、以及在日志与 Agent 输出里把数据打印得既可读又可靠。带着本篇的“字符串不可变、赋值即重绑”,看格式化与拼接会轻松很多。
专栏一「Python 从零入门」的后续路线:数字与字符串(03)→ 列表与元组(04)→ 字典与集合(05)→ 流程控制(06)→ 函数(07、08)→ 模块与 import(09)→ 文件(10)→ 异常(11)→ 面向对象与数据模型(12~14)。把本篇的“名字与对象”带进后续每一篇,你会一次比一次看得更透。
