你写了 exp = config,想在副本上调学习率做实验,结果原来的 config 也被改了。再往后,一个函数第二次调用时莫名记得第一次的结果,一个 with 块里的异常凭空消失。这三个 bug 背后是同一件事:Python 的变量不是装值的盒子,而是贴在对象上的名字。把这一点想清楚,拷贝、传参、装饰器和 with 都会变得很好推理。
变量是名字,不是盒子
先看那个 bug 本身:
config = {"lr": 1e-3, "layers": [64, 64]}
exp = config # 想“复制一份配置”做实验
exp["lr"] = 1e-2
print("config:", config)
print("同一个对象:", exp is config, id(exp) == id(config))
exp = {"lr": 3e-4} # 重新绑定:只挪动名字 exp
print("config:", config)config: {'lr': 0.01, 'layers': [64, 64]}
同一个对象: True True
config: {'lr': 0.01, 'layers': [64, 64]}如果把变量想成盒子,exp = config 应该是把盒子里的东西抄一份放进新盒子。实际发生的是:{...} 这个字面量在内存里造出一个 dict 对象,config = ... 把名字 config 绑定到它;exp = config 不造任何新东西,只是让名字 exp 也指向同一个对象。之后 exp["lr"] = ... 改的是对象,从哪个名字看过去都是改过的样子。
每个对象有三样东西:类型、值、身份。身份用 id() 看,在 CPython 里就是对象的内存地址,对象活着时不会变。is 比较身份(是不是同一个对象),== 比较值(调用 __eq__)。两个内容相同的 list 是 == 但不 is;exp 和 config 是同一个对象,所以 is 为真。
最后两行展示了另一半规则:exp = {"lr": 3e-4} 是重新绑定,它把名字 exp 从旧对象上撕下来贴到新对象上,旧对象和 config 都不受影响。整篇的核心就是区分这两种操作:
- 修改对象(
x.append、x[k] = v、x.attr = v):所有指向它的名字都看得到。 - 重新绑定名字(
x = ...):只影响这一个名字。
名字住在哪里、函数调用时怎么查找它们,是 函数与环境模型 里环境图讲的事;这里只关心名字和对象之间那根箭头。
is 的正确用途只有单例:x is None、x is True、自定义哨兵 _MISSING = object()。拿 is 比数字或字符串,结果取决于 CPython 有没有缓存这个对象(小整数 -5..256 会被复用,运行时拼出来的字符串不会被驻留),属于实现细节,比值一律用 ==。
执行 a = [1]; b = a; b = b + [2] 之后,a 是什么?
b + [2] 先造出一个新 list [1, 2],再把名字 b 挪过去,这是重新绑定;a 仍指向原来的 [1]。
选 [1, 2] 是把 b + [2] 当成了原地修改,那是 b.append(2) 或 b += [2] 的行为。名字随时可以重新绑定,Python 不会报错。
可变与不可变:同一个 +=,两种结局
既然”修改对象”会被所有名字看到,下一个问题是:哪些对象能被修改?
s = "hi"; old = id(s)
s += "!"
print("str 同一个对象:", id(s) == old)
lst = [1]; old = id(lst)
lst += [2]
print("list 同一个对象:", id(lst) == old)
t = (1, [2, 3])
t[1].append(4)
print("t =", t)
try:
hash(t)
except TypeError as e:
print("TypeError:", e)
a = [1, 2]; b = [1, 2]
print("a == b:", a == b, "| a is b:", a is b)str 同一个对象: False
list 同一个对象: True
t = (1, [2, 3, 4])
TypeError: unhashable type: 'list'
a == b: True | a is b: False不可变(immutable)对象创建后值不能改:int、float、str、tuple、frozenset。对它们写 s += "!",Python 只能造一个新 str 再把 s 重新绑定过去,所以 id 变了。可变(mutable)对象可以原地改:list、dict、set、大多数自定义类的实例、PyTorch 的 tensor。list 的 += 调用的是 __iadd__,它原地扩展并返回自身,id 不变。
于是同样一行 x += y,对 str 是”重新绑定”,对 list 是”修改对象”。如果 x 还有别的名字指着,前者别人看不到变化,后者别人看得到。
tuple 那个例子揭示了”不可变”的真正含义:tuple 里存的是若干个引用,不可变指的是这些引用不能换(t[1] = [9] 会报错),但引用指向的 list 本身照样可以 append。这也是为什么 hash(t) 失败:tuple 的 hash 由元素的 hash 算出,里面有个可变的 list 就算不出来。
hash 这件事值得多想一步。dict 和 set 查找时先用 hash(k) 定位桶,再用 == 确认。如果一个 key 放进去之后值还能变,它的 hash 也跟着变,之后就再也在原来的桶里找不到它。所以 Python 让 list、dict、set 都不可 hash,只有不可变对象(或者按身份比较的普通实例)才能当 key。自己写类时,只定义 __eq__ 会让 Python 把 __hash__ 置为 None;想当 key,用 @dataclass(frozen=True) 让字段不可改,它会生成一致的 __eq__ 和 __hash__。
下面哪些操作执行后,名字 x 仍然指向原来那个对象?
list 是可变的,+=(走 __iadd__)和 append 都在原对象上原地修改,id 不变。
str 和 tuple 不可变,+= 只能造新对象再把 x 重新绑定,所以 x 已经换了对象。只看写法都是 += 就以为行为相同,是最常见的误判。
拷贝:浅拷贝复制的是一层引用
回到开头的 bug,修法看起来很直接:别写 exp = config,真的复制一份。copy.copy 就是复制,但它只复制一层:
import copy
base = {"lr": 1e-3, "layers": [64, 64]}
shallow = copy.copy(base)
deep = copy.deepcopy(base)
shallow["lr"] = 1e-2 # 改外层键:只影响副本
shallow["layers"].append(128) # 改内层 list:它是共享的
print("base: ", base)
print("deep: ", deep)
print("layers 共享:", shallow["layers"] is base["layers"])
rows = [[0] * 3] * 3
rows[0][0] = 7
print("rows:", rows)
ok = [[0] * 3 for _ in range(3)]
ok[0][0] = 7
print("ok: ", ok)
a = [1]; a.append(a) # 自己装自己
b = copy.deepcopy(a)
print("循环结构保留:", b[1] is b, "| 和原对象无关:", b is not a)base: {'lr': 0.001, 'layers': [64, 64, 128]}
deep: {'lr': 0.001, 'layers': [64, 64]}
layers 共享: True
rows: [[7, 0, 0], [7, 0, 0], [7, 0, 0]]
ok: [[7, 0, 0], [0, 0, 0], [0, 0, 0]]
循环结构保留: True | 和原对象无关: True浅拷贝(copy.copy(x)、x[:]、list(x)、dict(x)、x.copy())造一个新的外层容器,但里面放的仍是原来那批对象的引用。改 shallow["lr"] 是在新 dict 上重新绑定一个键,base 不受影响;shallow["layers"].append(128) 却是修改那个共享的 list,base 跟着变。bug 只是往下躲了一层。
深拷贝 copy.deepcopy(x) 递归复制整个对象图。它内部维护一个 memo 字典(原对象 id → 副本),遇到已经复制过的对象就直接复用副本,所以共享和循环引用在副本里保持原样,不会无限递归,最后一个例子 b[1] is b 证明了这一点。代价是要遍历全部对象:复制一个大模型的参数时它很慢,PyTorch 里复制 tensor 用 t.clone(),只在确实需要一份独立模型(EMA、teacher 模型)时才 deepcopy(model)。
[[0] * 3] * 3 是浅拷贝问题的经典化身:外层的 * 3 把同一个内层 list 的引用放了三次,三行是同一个对象,改一格三行都变。内层的 [0] * 3 没问题,因为 int 不可变,共享也无害。列表推导式每次循环都执行一次 [0] * 3,造出三个独立的 list。
规律可以提炼成一句:共享不可变对象永远安全,共享可变对象才有风险。所以拷贝要拷到”最后一层可变对象”为止;全是 int、str 的扁平结构,浅拷贝就够。
grid = [[0, 0], [0, 0]]; g2 = grid[:]; g2[0][0] = 1; g2[1] = [9, 9],之后 grid 是什么?
grid[:] 是浅拷贝:外层 list 是新的,两个内层 list 仍和 grid 共享。g2[0][0] = 1 修改了共享的内层 list,所以 grid 看得到;g2[1] = [9, 9] 只是在新的外层 list 上换了一个引用,grid[1] 不变。
选第一项是把切片当成了深拷贝;选含 [9, 9] 的项是把"换掉外层的一个槽"误当成了修改共享对象。
传参:函数拿到的是同一个对象
函数调用时发生的事和 b = a 完全一样:形参名被绑定到调用方传进来的那个对象上。
def mutate(xs):
xs.append(99)
def rebind(xs):
xs = [0]
nums = [1, 2]
mutate(nums); print("after mutate:", nums)
rebind(nums); print("after rebind:", nums)
def log_loss(loss, history=[]):
history.append(loss)
return history
print(log_loss(0.9))
print(log_loss(0.8))
print("默认值藏在:", log_loss.__defaults__)
def log_loss_ok(loss, history=None):
if history is None:
history = []
history.append(loss)
return history
print(log_loss_ok(0.9), log_loss_ok(0.8))after mutate: [1, 2, 99]
after rebind: [1, 2, 99]
[0.9]
[0.9, 0.8]
默认值藏在: ([0.9, 0.8],)
[0.9] [0.8]mutate 通过形参 xs 修改了对象,调用方看得到;rebind 只是把局部名字 xs 挪到新 list 上,调用方的 nums 纹丝不动。这种语义常被叫作 call by sharing(传对象引用):它不是 C 的传值,因为对象没被复制;也不是 C++ 的传引用,因为函数里重新赋值改不到调用方的名字。用前两节的规则推理就够:原地修改会被看到,重新绑定不会;传进去的是不可变对象,函数里无论怎么写都影响不了调用方。
log_loss 是同一规则和”求值时机”叠加出来的坑。默认值表达式 [] 在 def 语句执行时求值一次,结果存进函数对象的 __defaults__;之后每次不传 history 的调用,形参都绑定到这同一个 list 上。第一次调用往里 append 了 0.9,第二次调用看到的就是 [0.9]。修法是用不可变的 None 做默认值,在函数体里(每次调用都会执行)再造新 list。
由此还能推出一条接口约定:一个函数要么原地修改参数并返回 None(像 list.sort()),要么不碰参数、返回新对象(像 sorted()),别两样都做一半,否则调用方无法判断自己手里的对象是否还安全。
def f(x, cache={}) 被不传 cache 地调用三次,每次都往 cache 写一个键。第三次调用开始时 cache 里有什么?
默认值在 def 执行时求值一次并存在 f.__defaults__ 里,三次调用的 cache 都绑定到同一个 dict,写进去的内容会一直留着。
Python 不会每次调用重新求值默认参数,也不禁止修改它;"每次新建"是把默认值当成了函数体里的语句。
原地修改返回 None:顺着 traceback 找病根
上一节的约定有个副作用:很多原地方法(list.sort、list.append、dict.update、random.shuffle)返回 None。忘了这一点,错误往往在离病根很远的地方才爆出来:
def clean(words):
words.sort() # 原地排序,返回 None;忘了 return words
def build_vocab(words):
vocab = clean(words)
vocab.append("<unk>")
return vocab
build_vocab(["b", "a"])Traceback (most recent call last):
File ".../e5.py", line 9, in <module>
build_vocab(["b", "a"])
File ".../e5.py", line 6, in build_vocab
vocab.append("<unk>")
^^^^^^^^^^^^
AttributeError: 'NoneType' object has no attribute 'append'traceback 的读法是先看最后一行,再看最下面一块,需要时往上追。最后一行说明是什么:某个值是 None,却被当成 list 用了。最下面一块说明在哪:build_vocab 第 6 行的 vocab。most recent call last 的意思是最深的调用打印在最后,上面一块是谁调用了它(<module> 第 9 行)。异常从出错的帧开始一层层往外传,每穿过一层就记下一帧,所以打印出来正好是从外到内的调用链。
但报错的位置只是使用处。'NoneType' object has no attribute 的固定追问是:这个 None 是从哪来的?顺着 vocab = clean(words) 回到 clean,发现它调用了原地的 sort() 却没有 return words,函数走到末尾自动返回 None。这里 words 其实已经排好序了,因为 sort() 修改的就是调用方传进来的那个对象;只要直接用 words,或者让 clean 返回 sorted(words),问题就消失。traceback 的其他读法、调试器和日志放在 调试测试与工程实践。
上面的 traceback 里,真正需要改的代码在哪里?
报错指向的是使用 None 的地方,病根在产生 None 的地方:clean 里 words.sort() 返回 None,函数也没有 return。
第 9 行传的 list 完全合法;第 6 行对 list 调 append 也没错,错在 vocab 根本不是 list。只盯着最下面一块改代码,会把症状当成病因。
装饰器的工程用法:wraps 与叠加顺序
理解了”名字绑定对象”,装饰器就只剩一句话:@timer 写在 def forward 上面,等价于 forward = timer(forward),是一次重新绑定。函数也是对象,装饰器接收它、返回一个包装函数,名字 forward 从此指向包装函数,包装函数靠闭包记住原函数。闭包和这层语法糖的原理在 高阶函数与递归;这里讲把装饰器真正用进工程时会踩的两个坑。
第一个坑是”身份丢失”:
import functools, time
def timer(func):
def wrapper(*args, **kwargs):
start = time.perf_counter()
try:
return func(*args, **kwargs)
finally:
print(f"[timer] {func.__name__} {(time.perf_counter() - start) * 1e3:.2f} ms")
return wrapper
@timer
def forward(x):
"""一次前向"""
return x * 2
print(forward(3))
print("name:", forward.__name__, "| doc:", forward.__doc__)
def timer2(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
@timer2
def forward2(x):
"""一次前向"""
return x * 2
print("name:", forward2.__name__, "| doc:", forward2.__doc__, "| __wrapped__:", forward2.__wrapped__.__name__)[timer] forward 0.00 ms
6
name: wrapper | doc: None
name: forward2 | doc: 一次前向 | __wrapped__: forward2名字 forward 现在指向 wrapper 对象,它的 __name__ 自然是 wrapper、文档是 None。日志、traceback、help()、按函数名注册的框架看到的全是 wrapper。functools.wraps(func) 本身是个带参数的装饰器,内部调用 functools.update_wrapper:把 __module__、__name__、__qualname__、__doc__ 等属性抄到 wrapper 上,合并 __dict__,并设置 wrapper.__wrapped__ = func。inspect.signature 会顺着 __wrapped__ 找到原函数的签名,测试时也能用 f.__wrapped__ 绕过装饰器直接测原函数。工程里写装饰器,@functools.wraps(func) 应当是默认动作。
timer 里用 try/finally 是为了函数抛异常时也打印耗时;perf_counter 是单调时钟,不会被系统校时影响。测 GPU 代码时还要先同步,否则测到的只是 kernel 的发射时间,见 GPU 与 Kernel。
第二个坑是叠加顺序。@A @B def f 等价于 f = A(B(f)):应用时自下而上,离 def 最近的先包;调用时自上而下进入,最外层 A 的包装函数先跑。顺序会改变行为:
import functools
def trace(func):
@functools.wraps(func)
def wrapper(*args):
print(f" [trace] {func.__name__}{args}")
return func(*args)
return wrapper
@trace
@functools.lru_cache(maxsize=None)
def a(x):
return x * 2
@functools.lru_cache(maxsize=None)
@trace
def b(x):
return x * 2
print("trace 在外:"); a(1); a(1)
print("lru_cache 在外:"); b(1); b(1)
print(a.__wrapped__.cache_info())trace 在外:
[trace] a(1,)
[trace] a(1,)
lru_cache 在外:
[trace] b(1,)
CacheInfo(hits=1, misses=1, maxsize=None, currsize=1)trace 在外时,每次调用都先经过 trace,缓存命中也会被记录;lru_cache 在外时,它缓存的是 trace 的包装函数,第二次调用直接返回缓存结果,trace 根本不执行。最后一行通过 __wrapped__ 剥掉外层 trace,拿到 lru_cache 对象查看命中统计。经验上,想观察”每一次调用”的装饰器(日志、计时、计数)放外层;@property、@staticmethod、@classmethod 这类改变访问方式的放最上面。
缓存也逃不开 hash 规则:lru_cache 按参数的 hash 查缓存,所以参数必须可 hash。传 list 会直接报 unhashable type,这正是第二节规则的直接后果;要缓存,先把参数转成 tuple。
关于 @trace @functools.lru_cache def a(x) 这种叠法,哪些说法正确?
叠加就是嵌套调用,离 def 最近的 lru_cache 先应用、在内层;名字 a 指向 trace 的包装函数,所以每次调用都先经过 trace,再进入缓存。
"写在上面就先应用"把应用顺序和调用顺序搞反了。trace 只是原样转发参数,list 传到 lru_cache 时照样因为不可 hash 而报 TypeError。
with:把”成对的操作”交给对象
打开/关闭文件、加锁/解锁、关梯度/恢复梯度,这类操作的后半步必须在任何情况下执行,包括中途抛异常。朴素做法是到处写 try/finally,上下文管理器(context manager)把这对操作封装进一个对象:with obj as x: 进入时调用 obj.__enter__(),返回值绑定给 x(不一定是 obj 本身);离开时不论是否出异常都调用 obj.__exit__(exc_type, exc, tb)。
关键细节在 __exit__ 的返回值:块里抛了异常时,返回真值表示”异常已处理”,Python 不再往外抛。这个设计很方便,也很容易写错:
class Leaky:
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
print("exit, exc_type =", exc_type.__name__ if exc_type else None)
return self
with Leaky():
raise RuntimeError("bug")
print("RuntimeError 被吞掉了,程序继续")
class Quiet:
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return exc_type is not None and issubclass(exc_type, KeyError)
with Quiet():
{}["k"]
print("KeyError 被吞")
try:
with Quiet():
1 / 0
except ZeroDivisionError:
print("ZeroDivisionError 照常传出")exit, exc_type = RuntimeError
RuntimeError 被吞掉了,程序继续
KeyError 被吞
ZeroDivisionError 照常传出Leaky 顺手写了 return self,普通对象的真值是 True,于是所有异常,包括真正的 bug,都被悄悄吞掉。这正是开头说的”异常凭空消失”。Python 只看返回值的真假,不要求是 bool。没有特殊需要时 __exit__ 就不写 return(返回 None,假值,异常照常传播);确实要吞,像 Quiet 一样按 exc_type 只吞预期的那一种,标准库的 contextlib.suppress(KeyError) 就是现成实现。另外,__enter__ 本身抛异常时还没进入 with,__exit__ 不会被调用。
__exit__ 里写了 return self,代码块里 raise RuntimeError(...),结果是什么?
Python 只判断返回值的真假,普通对象默认为真,效果和 return True 相同,例子里 Leaky 就吞掉了 RuntimeError。
"没写 return True 就不吞"是误解;__exit__ 的返回值也不要求是 bool,任何对象都按真值判断。
少写类:contextmanager 与 ExitStack
为每个资源写一个带 __enter__/__exit__ 的类太啰嗦。@contextlib.contextmanager 让一个生成器函数充当上下文管理器:yield 之前相当于 __enter__,yield 出去的值给 as,yield 之后相当于 __exit__。生成器怎么暂停和恢复见 迭代器生成器与解释器。
另一个问题是数量:with 语句要求写代码时就知道有几个资源,可训练时要打开的数据分片数是运行时才知道的。contextlib.ExitStack 是一个”待清理栈”:enter_context(cm) 进入一个资源并把它的 __exit__ 压栈,离开时按后进先出依次清理。
import contextlib
@contextlib.contextmanager
def opened(name):
print(f" open {name}")
try:
yield name
finally:
print(f" close {name}")
with opened("ckpt") as n:
print(" use", n)
class Res:
def __init__(self, name, fail=False):
self.name, self.fail = name, fail
def __enter__(self):
if self.fail:
raise OSError(f"打开 {self.name} 失败")
print(f" open {self.name}")
return self
def __exit__(self, *exc):
print(f" close {self.name}")
try:
with contextlib.ExitStack() as stack:
for n, bad in [("shard0", False), ("shard1", False), ("shard2", True)]:
stack.enter_context(Res(n, fail=bad))
except OSError as e:
print(" OSError:", e) open ckpt
use ckpt
close ckpt
open shard0
open shard1
close shard1
close shard0
OSError: 打开 shard2 失败opened 里的 try/finally 不能省:块里的异常会在 yield 那一行被重新抛进生成器,清理代码如果只是写在 yield 后面,出异常时就执行不到。反过来,如果在 yield 外面 except 住异常又不再 raise,就等于吞掉它,和 __exit__ 返回真值是同一回事。
ExitStack 的例子里,第三个分片打开失败,它没有进栈;退出时已打开的 shard1、shard0 按逆序关闭,然后异常照常传出。逆序是因为后打开的资源可能依赖先打开的。手写嵌套 with 也能做到,但层数得写死。
PyTorch 里最常见的上下文管理器是 torch.no_grad():__enter__ 关掉 autograd 的计算图记录,__exit__ 恢复进入前保存的状态(不是无条件打开,所以嵌套使用是安全的)。它同时实现了装饰器接口,所以 @torch.no_grad() 可以直接套在评估函数上。注意括号:no_grad() 先构造对象,再由这个对象去装饰函数。推理框架里更常见的 torch.inference_mode() 更激进,产出的 tensor 不能再参与反向传播,见 推理系统。
下面哪些写法会让代码块里的 ValueError 不再传出 with?
suppress 的 __exit__ 在异常类型匹配时返回真值;@contextmanager 里异常在 yield 处重新抛出,except 捕获后不再 raise,生成器正常结束,就被当作已处理。
不写 return 等于返回 None,是假值,异常继续传播;finally 只负责清理、不捕获异常,执行完异常照样往外抛。
记住这三件事
- 变量是贴在对象上的名字:修改对象,所有名字都看得到;重新绑定,只影响这一个名字。赋值、传参、
@装饰器都只是绑定。 - 共享不可变对象安全,共享可变对象才有风险:浅拷贝只复制一层引用,默认参数在
def时只求值一次,可 hash 的前提是不可变。 - 装饰器默认加
functools.wraps,叠加顺序就是嵌套顺序;with保证清理一定执行,__exit__的返回值决定异常是否被吞掉。
延伸阅读
- Python Language Reference, Data model:对象、身份、可变性与
__hash__、__enter__/__exit__的定义 - Python 文档 copy、functools、contextlib
- Ned Batchelder, Facts and myths about Python names and values(PyCon 2015)
- PEP 343 – The “with” Statement
- CS 61A / Composing Programs(John DeNero,CC BY-SA):composingprograms.com