Python 可变对象的几个常见陷阱

本文最初写于 2017 年,2026 年按现代 Python 语法校订。很多所谓“Python 陷阱”并不是特殊规则,它们大多可以归结为一句话:名字绑定到对象,赋值本身不会复制对象。

可变默认参数只创建一次

下面的默认列表在函数定义执行时创建,而不是每次调用时创建:

def collect(value, items=[]):
    items.append(value)
    return items

print(collect(1))  # [1]
print(collect(2))  # [1, 2]

如果希望每次调用都得到新列表,使用 None 作为哨兵:

def collect(value, items=None):
    if items is None:
        items = []
    items.append(value)
    return items

这里必须写 is None,不能写 if not items。后者会把调用者显式传入的空列表、空字符串或 0 一并当成“没有传值”。

可变默认参数并非绝对禁止。缓存、状态累积等少数场景可能会有意利用它,但这种意图应在函数名、文档和测试里说明,否则维护者很难判断共享状态是不是缺陷。

dataclass 字段使用 default_factory

类字段也有相同问题。现代 Python 的 dataclass 会阻止一部分明显的可变默认值,并提供 default_factory:

from dataclasses import dataclass, field

@dataclass
class UrlBucket:
    urls: list[str] = field(default_factory=list)

工厂函数会在每次实例化时调用,因此不同实例不会共享列表。

需要注意,“不可变容器”不一定意味着内部完全不可变。比如元组可以包含列表。判断默认值是否安全,要看调用者是否能通过它修改可达对象,而不只是看最外层类型。

类属性与实例属性不是一回事

class UrlBucket:
    urls = []

    def add(self, url: str) -> None:
        self.urls.append(url)

urls 定义在类体中,是所有实例共享的类属性。self.urls.append(...) 修改的仍是同一个列表。

实例状态应该在初始化时创建:

class UrlBucket:
    def __init__(self) -> None:
        self.urls: list[str] = []

类属性适合常量、配置元数据或明确需要共享的状态。即便确实要共享可变状态,也要考虑锁、生命周期和测试隔离。

赋值不是复制

original = {"tags": ["python"]}
alias = original
alias["tags"].append("testing")

print(original)  # {'tags': ['python', 'testing']}

alias = original 只是让两个名字指向同一个字典。

浅拷贝会创建新的外层容器,但仍共享内部对象:

copied = original.copy()
copied["tags"].append("database")

print(original["tags"])  # 内部列表仍被修改

如果确实需要递归复制,可以使用 copy.deepcopy,但它不是免费的,也未必适合文件句柄、连接、锁或自定义资源对象。更稳妥的做法通常是明确数据所有权,或使用不可变数据结构和“创建新值”的更新方式。

字典视图不是快照

dict.keys()、dict.values() 和 dict.items() 返回动态视图:

data = {"a": 1}
keys = data.keys()
data["b"] = 2

print(list(keys))  # ['a', 'b']

需要快照时要显式转换,例如 list(data) 或 tuple(data.items())。

遍历字典时直接增删键会触发 RuntimeError。可以遍历键的副本,或者更直接地构造新字典:

filtered = {key: value for key, value in data.items() if value >= 2}

闭包读取的是调用时的变量

funcs = [lambda: index for index in range(3)]
print([func() for func in funcs])  # [2, 2, 2]

闭包捕获的是变量,不是定义瞬间的值。循环结束后,三个函数读取到的都是最终的 index。

可以通过默认参数绑定当时的值:

funcs = [lambda index=index: index for index in range(3)]

复杂回调更适合用普通函数或 functools.partial,可读性通常比 lambda 技巧更好。

is 比较身份,== 比较值

left = []
right = []

print(left == right)  # True:内容相等
print(left is right)  # False:不是同一个对象

is 最常见的用途是判断 None 或自定义哨兵:

MISSING = object()

def read(value=MISSING):
    if value is MISSING:
        ...

不要用整数、字符串驻留或编译器常量折叠现象来推断身份关系;这些属于实现细节。同样,普通真假判断通常写 if enabled 或 if not enabled,不必机械地写 is True、is False。

最后归纳

遇到共享状态问题时,可以依次问四个问题:

  1. 这个对象在什么时候创建?
  2. 现在有多少名字或实例指向它?
  3. 当前操作是在重新绑定名字,还是原地修改对象?
  4. 调用者需要共享、浅拷贝,还是完全独立的数据?

把这四件事说清楚,可变默认值、类属性、浅拷贝和闭包等行为就不再神秘。

参考资料

// command palette

搜索文章

输入关键词搜索,使用 ↑ ↓ 选择,Enter 打开。

King Without a Crown xueyuan
0:00 2:55
智能缓存 · 跨页续播