📑 本页目录(点开跳转)
01 · 名字、对象和绑定
⏱ 56 分钟 | ⭐ Python 里没有「变量」,只有贴在对象上的名字 —— 这一句能解释掉后面八成的诡异 bug
🎯 一句话
赋值从来不复制对象,它只是把一个名字贴到某个对象上。
「为什么我改了 b,a 也变了」「为什么这个函数第二次调用结果不对」「为什么 target 网络跟着主网络一起动」——都是同一件事的不同长相。
🧩 一、Python 里没有「变量」
学过 C 的人脑子里有一张图:变量是一个盒子,赋值就是往盒子里放东西。这张图在 Python 里是错的,而且错得很贵。
Python 的图是:对象漂在堆上,名字是贴上去的标签。a = b 不是「把 b 盒子里的东西抄进 a 盒子」,是「把 a 这张标签也贴到 b 指着的那个对象上」。
# 名字绑到对象:赋值从来不复制对象
a = [1, 2, 3]
b = a # 不是复制一份,是给同一个列表再贴一个名字
b.append(4)
print("a =", a)
print("a is b:", a is b, "| id 相同:", id(a) == id(b))
b = [9, 9] # 这一行不是改列表,是把名字 b 撕下来贴到新对象上
print("a =", a, " b =", b, "| a is b:", a is b)
要点
a = [1, 2, 3, 4]
a is b: True | id 相同: True
a = [1, 2, 3, 4] b = [9, 9] | a is b: False
⭐ 两行代码的区别就是全部:
| 写法 | 干了什么 | 别的名字看得见吗 |
|---|---|---|
b.append(4) |
改对象(原地修改) | ✅ 看得见 —— 它们指着同一个对象 |
b = [9, 9] |
改名字(重新绑定) | ❌ 看不见 —— a 还贴在老对象上 |
⚠️ 麻烦的是这两件事在源码里长得很像。b += [4] 对列表是改对象(相当于 extend),对元组和数字是改名字(造一个新的再绑上去)。同一个 +=,两种行为。
⭐ 背下来这句:
是不是同一个对象用is/id()查;内容一样不一样用==查。 这一章之后所有的坑,都是「以为在复制、其实在共享」。
🧩 二、传参:既不是传值也不是传引用
面试爱问「Python 是传值还是传引用」,正确答案是两个都不是,而且这个问法本身就基于那张错误的盒子图。
真实规则只有一条:形参是又一个贴到同一个对象上的名字。
# 传参既不是传值也不是传引用:形参是又一个贴上去的名字
def push(lst):
lst.append("X") # 改的是【对象】,外面看得见
def rebind(lst):
lst = ["新的"] # 改的是【名字】,外面看不见
lst.append("Y")
data = [1, 2]
push(data)
print("push 之后:", data)
rebind(data)
print("rebind 之后:", data)
要点
push 之后: [1, 2, 'X']
rebind 之后: [1, 2, 'X']
⭐ rebind 里的 lst.append("Y") 是真的执行了的 —— 只不过它改的是函数内部那个新列表,函数一返回就没人指着它了,被回收掉。
📋 由此得到一条实用的写代码规矩:
| 你想让调用方看到修改 | 怎么写 |
|---|---|
| 想 | 原地改传进来的对象(lst.append / d["k"] = v),并在函数名里说清楚(add_、update_、PyTorch 里的下划线后缀) |
| ⭐ 不想 | 返回新对象,不碰传进来的。这是默认应该选的那个 —— 出问题时能一眼看出谁改了什么 |
⚠️ 三、可变默认参数:全站命中 0 的一号杀手
默认值是在执行 def 那一刻求值一次的,之后所有调用共用同一个对象。
# 可变默认参数:默认值在【定义函数那一刻】只创建一次
def collect(x, bucket=[]):
bucket.append(x)
return bucket
print(collect(1))
print(collect(2))
print(collect(3))
print("函数对象上挂的那个默认值:", collect.__defaults__)
def collect_ok(x, bucket=None):
if bucket is None:
bucket = [] # 每次调用都新建
bucket.append(x)
return bucket
print(collect_ok(1), collect_ok(2), collect_ok(3))
要点
[1]
[1, 2]
[1, 2, 3]
函数对象上挂的那个默认值: ([1, 2, 3],)
[1] [2] [3]
⭐ 最后那行 collect.__defaults__ 是全部证据:那个列表挂在函数对象上,跟调用次数无关。函数只要还活着,它就一直在那攒。
💀 为什么这个 bug 特别难抓:
| 特征 | 后果 |
|---|---|
| 第一次调用完全正确 | 你的单元测试如果只调一次,永远是绿的 |
| 不报错、不警告 | 静态检查工具里也常常只是个低优先级提示 |
| 在长驻进程里最狠 | 脚本跑完就退出看不出来;服务跑一周,那个 bucket 里攒了几百万条 —— 长得像内存泄漏,你会去查别的地方 |
⚠️ 同族的还有 def f(x, t=time.time())(时间戳冻结在进程启动那一刻)、def f(model=Model())(所有调用共用一个模型实例)。判据统一是:默认值只要是「会变的东西」或「有构造代价的东西」,就写 None 再在函数体里造。
🧯 四、copy 和 deepcopy:浅拷贝只复制最外面一层
「不想共享」的正规做法是拷贝。但拷贝有两种,它们的差别只在嵌套结构上显现,而配置字典、超参数、嵌套的模型对象恰好全都是嵌套结构。
# copy 与 deepcopy:浅拷贝只复制最外面那一层
import copy
cfg = {"name": "run1", "layers": [64, 64]}
shallow = copy.copy(cfg)
deep = copy.deepcopy(cfg)
cfg["layers"].append(128) # 只动嵌套在里面的那个列表
cfg["name"] = "run2" # 动最外层的键
print("原件 :", cfg)
print("浅拷贝:", shallow)
print("深拷贝:", deep)
print("内层列表是同一个吗 —— 浅:", shallow["layers"] is cfg["layers"],
"深:", deep["layers"] is cfg["layers"])
要点
原件 : {'name': 'run2', 'layers': [64, 64, 128]}
浅拷贝: {'name': 'run1', 'layers': [64, 64, 128]}
深拷贝: {'name': 'run1', 'layers': [64, 64]}
内层列表是同一个吗 —— 浅: True 深: False
⭐ 看浅拷贝那一行:name 没跟着变(外层复制了),layers 跟着变了(内层还是同一个)。这种「一半独立一半共享」的对象是最难查的,因为它的表现取决于你改的是哪一层。
| 写法 | 复制到哪一层 | 什么时候用 |
|---|---|---|
b = a |
什么都没复制 | 你确实想要两个名字指同一个东西 |
a[:] / list(a) / dict(a) / copy.copy(a) |
只有最外层 | 结构是平的(一层列表、一层字典) |
⭐ copy.deepcopy(a) |
递归到底 | 嵌套结构、或者你不确定有没有嵌套 |
⚠️ deepcopy 的代价是真的:它要递归遍历整个对象图、还要维护一张 memo 表处理循环引用。在训练循环内部对大对象 deepcopy,会成为剖析报告里排第一的那一行(怎么确认,见 05 章)。
⚠️ 另外 deepcopy 不是万能的:持有文件句柄、socket、数据库连接、锁的对象,深拷贝出来的那份要么复制失败,要么复制出一个坏掉的副本。这类对象要么别拷,要么自己实现 __deepcopy__。
💀 五、站内那一行:DQN 的 target 网络为什么必须 deepcopy
强化学习基础 第 8 章 DQN 里有这么一行:
# 🧩 骨架:`net` 来自你自己的代码,这一段只看写法
self.q, self.q_target = net, __import__('copy').deepcopy(net)
⭐ 这一行的正确性完全依赖本章。 把 deepcopy(net) 写成 net,代码照跑,但 DQN 的核心机制当场失效:
# 为什么 DQN 的 target 网络必须 deepcopy
import copy
class TinyNet:
"""只有 3 个权重的假网络,train_one_step 把权重原地加一。"""
def __init__(self, w):
self.w = list(w)
def train_one_step(self):
for i in range(len(self.w)):
self.w[i] += 1.0
q = TinyNet([0.0, 0.0, 0.0])
target_wrong = q # 写成 = net
target_right = copy.deepcopy(q) # 正确写法
for _ in range(5):
q.train_one_step()
print("q :", q.w)
print("target_wrong :", target_wrong.w, " is q:", target_wrong is q)
print("target_right :", target_right.w, " is q:", target_right is q)
对照
q : [5.0, 5.0, 5.0]
target_wrong : [5.0, 5.0, 5.0] is q: True
target_right : [0.0, 0.0, 0.0] is q: False
💀 事故的完整形状:
| 项 | 内容 |
|---|---|
| 症状 | 训练不报错,loss 还在下降,只是 agent 学不出策略 |
| 为什么没被发现 | target 网络的作用是给 TD 目标一个不动的参照。写成 = net 之后目标跟着参数一起动,DQN 退化成一个自己追自己的回归 —— 而这种发散在曲线上长得像「调参没调好」 |
| 代价 | 你会去调学习率、调 replay buffer 大小、调探索率,全都不是根因 |
| 该补什么 | 建完 target 之后加一句 assert self.q_target is not self.q;同步权重用 load_state_dict(复制数值),而不是重新赋值对象 |
⚠️ 顺带说那行的写法本身:__import__('copy').deepcopy(net) 是为了省一行 import copy 而用的内联写法,可读性代价远大于收益,别学。
🛑 读到这里可以停 —— 已经读了约 24 分钟。 最后一段还有(约 27 分钟):类属性被所有实例共享 ·
is为什么在小数字上「碰巧对」 · 一张排查表 · 检查点与走神救援 回来的时候不用重读,直接从下一节接着看就行。
📋 六、类属性被所有实例共享
同一个坑的第三副面孔。写在 class 体里的赋值属于类,不属于实例。
# 类属性被所有实例共享
class Runner:
history = [] # 类属性:一个列表,全体实例共用
def __init__(self, name):
self.name = name
def log(self, x):
self.history.append(x) # 读不到实例属性 → 落到类属性上
a, b = Runner("A"), Runner("B")
a.log("a1"); b.log("b1")
print("a.history:", a.history)
print("b.history:", b.history)
print("是同一个列表吗:", a.history is b.history is Runner.history)
class RunnerOK:
def __init__(self, name):
self.name = name
self.history = [] # 实例属性:每个实例一份
def log(self, x):
self.history.append(x)
c, d = RunnerOK("C"), RunnerOK("D")
c.log("c1"); d.log("d1")
print("c.history:", c.history, " d.history:", d.history)
要点
a.history: ['a1', 'b1']
b.history: ['a1', 'b1']
是同一个列表吗: True
c.history: ['c1'] d.history: ['d1']
⚠️ self.history.append(x) 看起来像在动实例属性,其实属性查找是「先找实例,找不到再找类」——找不到,于是拿到类上那个列表,然后原地改它。
⭐ 但如果写的是 self.history = [...](赋值),就会在实例上新建一个属性把类属性遮住。所以「读的是类的、写的是实例的」这种半吊子状态真的会出现,而且极难查。
📋 规矩:类体里只放不可变的东西(常量、字符串、数字、类型注解);任何列表 / 字典 / 集合 / 模型对象,一律在 __init__ 里建。
🚦 七、is 为什么在小数字上「碰巧对」
这是本章最坑的一节 —— 因为错误的写法大部分时候能给出正确结果。
# is 在小数字上会碰巧对
a = 257
b = 257
print("同一段代码里的字面量 257 :", a is b)
c = int("257") # 运行时才算出来的 257
print("运行时算出来的 257 :", a is c, " 而 a == c 是", a == c)
x = 256
y = int("256")
print("256 呢 :", x is y)
s1, s2 = "hello", "hello"
print("短字符串字面量 :", s1 is s2)
s3 = "hello world!"
s4 = "".join(["hello", " world!"])
print("运行时拼出来的同样内容 :", s3 is s4, " 而 s3 == s4 是", s3 == s4)
对照
同一段代码里的字面量 257 : True
运行时算出来的 257 : False 而 a == c 是 True
256 呢 : True
短字符串字面量 : True
运行时拼出来的同样内容 : False 而 s3 == s4 是 True
⭐ 看第一行和第二行的对比:同样是 257,写在源码里的两个 257 是同一个对象,运行时算出来的那个就不是。原因有两层,都属于 CPython 的实现细节:
| 机制 | 范围 | 后果 |
|---|---|---|
| 小整数缓存 | −5 到 256 提前造好复用 | x is 256 基本总是 True |
| 编译期常量合并 | 同一个代码对象里的相同字面量共用一份 | 同一个函数 / 同一个文件里写两个 257,is 也是 True |
| 字符串驻留 | 像标识符的短字符串 | "hello" is "hello" 是 True,"hello world!" 拼出来的就不是 |
💀 这就是为什么它比一个总是出错的 bug 更贵:你写 if status is 200,本地测试全过(200 在缓存范围内),上线后遇到从 JSON 解析出来的 200……⚠️ 也可能还是对的,取决于解析器有没有复用对象。这种「时对时错」是最难排查的一类。
📋 规矩,没有例外:
| 场景 | 用什么 |
|---|---|
和 None / True / False 比 |
⭐ is / is not(它们是单例,这是唯一该用 is 的常见场景) |
| 和数字、字符串、任何值比 | ⭐ == |
| 想确认「是不是同一个对象」 | is —— 而这正是本章前面几节要检查的东西 |
📋 八、一张排查表
出现「我明明没动它,它怎么变了」的时候,从上往下过:
| 现象 | 大概率是哪一节 | 一行验证 |
|---|---|---|
改了 b,a 跟着变 |
一、赋值只是贴标签 | print(a is b) |
| 函数调用完,传进去的东西被改了 | 二、形参是同一个对象的又一个名字 | 函数里改成返回新对象 |
| 函数第二次调用结果就不对 | ⭐ 三、可变默认参数 | print(f.__defaults__) |
| 改了内层,拷贝出来的那份也变了 | 四、浅拷贝只复制外层 | print(new["k"] is old["k"]) |
| 模型 / 缓存/ 计数器在多个实例间串了 | 六、类属性共享 | print(a.attr is b.attr) |
| 判断时对时错,换个输入就不灵 | ⭐ 七、拿 is 当 == 用 |
把 is 换成 == |
⛔ 有一类共享不在本章范围内:NumPy 的切片、reshape、torch.from_numpy 得到的是同一块内存缓冲区的不同视图——那不是「两个名字指同一个对象」,是「两个不同的对象指同一块内存」。⭐ copy.deepcopy 和 is 都不是那边的工具,机制和急救写法归 NumPy 那一层。
🔗 这一章连到哪里
| 相关的地方 | 为什么 |
|---|---|
| 02 · 生成器与惰性求值 | 下一章的「生成器只能走一次」是同一类问题的另一副面孔:你以为拿到的是数据,其实拿到的是一个一次性的取数动作 |
| 04 · GIL:为什么多线程救不了你 | 进程之间不共享对象,传过去的是 pickle 出来的副本 —— 本章「共享」的全部直觉在那里要反过来用 |
| 05 · 怎么量:计时、剖析、内存 | deepcopy 大对象贵不贵不能靠猜,那一章教怎么让剖析器指给你看 |
| 强化学习基础 08 · DQN | 第五节那行 deepcopy(net) 的出处。⭐ 读那一章之前先读本章,否则你只会把它当成一句咒语抄下来 |
| 机器学习与深度学习基础 11 · 训练调试手册 | 它列的是模型层面的调试清单(loss 不降、梯度爆炸);本章补的是语言层面那一类 —— 两者的症状很像,都是「不报错但不对」 |
| AI全栈 15b · 怎么测不确定的系统 | ⚠️ 可变默认参数只在「同一个函数被调用第二次」时才暴露,只调一次的测试永远是绿的。那一章讲怎么设计能抓到这类问题的用例 |
| 代码题拆解 01 · 链表:指针到底在保住什么 | ⭐ 本章那句「赋值只是把名字重新贴一次」的练手场:反转链表里 nxt = cur.next 必须先写,prev, cur = cur, nxt 为什么能一次换两个 |
✅ 检查点
a = b到底做了什么?b.append(4)和b = [9, 9]的区别是什么?- Python 传参是传值还是传引用?为什么这个问法本身有问题?
def collect(x, bucket=[])连着调用三次,返回什么?为什么?用什么一行代码能看到证据?- 可变默认参数这个 bug 有哪三个特征让它特别难抓?在长驻服务里它长得像什么?
- 浅拷贝和深拷贝的差别在哪一层显现?本章那个
cfg例子里,浅拷贝的哪个键跟着变了、哪个没有? - DQN 的 target 网络写成
= net会怎样?为什么这个错误在训练曲线上看不出来?该加一句什么断言? - 类属性被共享时,为什么
self.history.append(x)会改到类属性,而self.history = []不会? is在什么场景下是唯一正确的选择?为什么「x is 256返回 True」比一个总是出错的 bug 更麻烦?
👀 答案
a = b什么都没复制,只是把名字a也贴到b指着的那个对象上。b.append(4)是改对象(原地修改,所有指着它的名字都看得见);b = [9, 9]是改名字(把b撕下来贴到新对象上,a还在老对象上)。实跑输出:a is b: True→ 重新绑定后a is b: False。- 两个都不是。规则只有一条:形参是又一个贴到同一个对象上的名字。所以
lst.append("X")外面看得见([1, 2, 'X']),lst = ["新的"]外面看不见。这个问法基于「变量是盒子」那张图,而那张图在 Python 里就是错的。 - 返回
[1]、[1, 2]、[1, 2, 3]—— 因为默认值在执行def那一刻只创建一次,之后所有调用共用同一个列表。证据是print(collect.__defaults__),输出([1, 2, 3],):那个列表挂在函数对象上。 - ① 第一次调用完全正确(只调一次的测试永远绿);② 不报错不警告;③ 在长驻进程里最狠 —— 跑一周攒几百万条,长得像内存泄漏,你会去查别的地方。同族的还有
t=time.time()(时间戳冻在启动那一刻)和model=Model()。 - 只在嵌套结构上显现。
cfg例子里浅拷贝的name没跟着变(外层复制了),layers跟着变了(内层还是同一个列表,shallow["layers"] is cfg["layers"]为True)。 - target 网络和主网络变成同一个对象,参数一更新目标跟着动,DQN 退化成自己追自己的回归。看不出来是因为它不报错、loss 还在降,发散在曲线上长得像「调参没调好」,于是你会去调学习率、buffer、探索率,全都不是根因。该加
assert self.q_target is not self.q;同步权重用load_state_dict复制数值。 - 属性查找是「先找实例,找不到再找类」。
append是读属性再原地改那个对象 → 读到类上那个列表;self.history = []是赋值,会在实例上新建一个属性把类属性遮住。所以「读的是类的、写的是实例的」这种半吊子状态真的会出现。规矩:类体里只放不可变的东西,容器一律在__init__里建。 - 只有和
None/True/False这三个单例比较时该用is。麻烦在于它时对时错:CPython 缓存 −5~256 的小整数、同一代码对象里的相同字面量还会合并,所以x is 256基本总是True、a is b在两个字面量257上也是True,但int("257")算出来的就是False。一个总是出错的 bug 当天就被修了;一个大部分时候对的 bug 会活到线上。
🛑 可以停在这里
⚡ 走神救援
⭐ Python 里没有「变量」,只有贴在对象上的名字。 赋值从来不复制对象。
⭐ 判据:改对象 = 别人看得见,改名字 = 别人看不见——而这两件事在源码里长得很像(
+=对列表是改对象,对数字和元组是改名字)。传参既不是传值也不是传引用:形参只是又一个贴到同一对象上的名字,所以lst.append(...)外面看得见,lst = [...]外面看不见。⚠️ 一号杀手是可变默认参数:默认值在执行
def那一刻只造了一次,它挂在函数对象上(证据是f.__defaults__)。它难抓的三个原因:第一次调用完全正确(只调一次的测试永远绿)、不报错不警告、在长驻服务里攒成长得像内存泄漏的东西。⭐ 统一判据:默认值只要会变或有构造代价,就写None再在函数体里造。拷贝的差别只在嵌套层显现:浅拷贝改内层会串,深拷贝不会。⚠️
deepcopy递归到底、有真实开销,还处理不了文件句柄、socket、锁那类对象。💀 站内实例:DQN 里
self.q_target = deepcopy(net)写成= net之后,target 跟着主网络一起动,DQN 退化成自己追自己——⭐ 不报错、loss 还在降、只是学不出来,于是你会去调学习率和 buffer,全不是根因。该补的是一行assert self.q_target is not self.q。⚠️ 同一个坑的第三副面孔是类属性:
class Runner: history = []之后,所有实例的history是同一个列表。
下一节 👉 02-生成器与惰性求值.md