你写了 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 是什么?

可变与不可变:同一个 +=,两种结局

既然”修改对象”会被所有名字看到,下一个问题是:哪些对象能被修改?

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 仍然指向原来那个对象?

拷贝:浅拷贝复制的是一层引用

回到开头的 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 是什么?

传参:函数拿到的是同一个对象

函数调用时发生的事和 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 里有什么?

原地修改返回 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 里,真正需要改的代码在哪里?

装饰器的工程用法: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) 这种叠法,哪些说法正确?

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(...),结果是什么?

少写类: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?

记住这三件事

  1. 变量是贴在对象上的名字:修改对象,所有名字都看得到;重新绑定,只影响这一个名字。赋值、传参、@装饰器 都只是绑定。
  2. 共享不可变对象安全,共享可变对象才有风险:浅拷贝只复制一层引用,默认参数在 def 时只求值一次,可 hash 的前提是不可变。
  3. 装饰器默认加 functools.wraps,叠加顺序就是嵌套顺序;with 保证清理一定执行,__exit__ 的返回值决定异常是否被吞掉。

延伸阅读