🏠 总目录📚 本教程 01 · 名字、对象和绑定 ← →
📑 本页目录(点开跳转)

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 为什么能一次换两个

✅ 检查点

  1. a = b 到底做了什么?b.append(4) 和 b = [9, 9] 的区别是什么?
  2. Python 传参是传值还是传引用?为什么这个问法本身有问题?
  3. def collect(x, bucket=[]) 连着调用三次,返回什么?为什么?用什么一行代码能看到证据?
  4. 可变默认参数这个 bug 有哪三个特征让它特别难抓?在长驻服务里它长得像什么?
  5. 浅拷贝和深拷贝的差别在哪一层显现?本章那个 cfg 例子里,浅拷贝的哪个键跟着变了、哪个没有?
  6. DQN 的 target 网络写成 = net 会怎样?为什么这个错误在训练曲线上看不出来?该加一句什么断言?
  7. 类属性被共享时,为什么 self.history.append(x) 会改到类属性,而 self.history = [] 不会?
  8. is 在什么场景下是唯一正确的选择?为什么「x is 256 返回 True」比一个总是出错的 bug 更麻烦?
👀 答案
  1. a = b 什么都没复制,只是把名字 a 也贴到 b 指着的那个对象上。b.append(4) 是改对象(原地修改,所有指着它的名字都看得见);b = [9, 9] 是改名字(把 b 撕下来贴到新对象上,a 还在老对象上)。实跑输出:a is b: True → 重新绑定后 a is b: False。
  2. 两个都不是。规则只有一条:形参是又一个贴到同一个对象上的名字。所以 lst.append("X") 外面看得见([1, 2, 'X']),lst = ["新的"] 外面看不见。这个问法基于「变量是盒子」那张图,而那张图在 Python 里就是错的。
  3. 返回 [1]、[1, 2]、[1, 2, 3] —— 因为默认值在执行 def 那一刻只创建一次,之后所有调用共用同一个列表。证据是 print(collect.__defaults__),输出 ([1, 2, 3],):那个列表挂在函数对象上。
  4. ① 第一次调用完全正确(只调一次的测试永远绿);② 不报错不警告;③ 在长驻进程里最狠 —— 跑一周攒几百万条,长得像内存泄漏,你会去查别的地方。同族的还有 t=time.time()(时间戳冻在启动那一刻)和 model=Model()。
  5. 只在嵌套结构上显现。cfg 例子里浅拷贝的 name 没跟着变(外层复制了),layers 跟着变了(内层还是同一个列表,shallow["layers"] is cfg["layers"] 为 True)。
  6. target 网络和主网络变成同一个对象,参数一更新目标跟着动,DQN 退化成自己追自己的回归。看不出来是因为它不报错、loss 还在降,发散在曲线上长得像「调参没调好」,于是你会去调学习率、buffer、探索率,全都不是根因。该加 assert self.q_target is not self.q;同步权重用 load_state_dict 复制数值。
  7. 属性查找是「先找实例,找不到再找类」。append 是读属性再原地改那个对象 → 读到类上那个列表;self.history = [] 是赋值,会在实例上新建一个属性把类属性遮住。所以「读的是类的、写的是实例的」这种半吊子状态真的会出现。规矩:类体里只放不可变的东西,容器一律在 __init__ 里建。
  8. 只有和 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

打卡记录保存在你的浏览器里,首页能看到总进度