Python 基础体系 · 第 5/112 篇。示例统一以 Python 3.14 为语言基线;第三方库使用与其兼容的现代稳定版本,版本敏感行为会单独说明。

Python 名称、对象与绑定:变量不是盒子,赋值不是复制

在 Python 中,变量不是装着值的盒子,赋值也不是把右侧内容复制到左侧。更准确的模型是:

对象保存数据,名称指向对象,赋值建立或改变名称到对象的绑定。

Python 语言参考把名称描述为“引用对象的名称”,名称通过绑定操作被引入;对象则具有身份、类型和值三个基本属性。(docs.python.org)

这个模型可以解释一系列看似分散的现象:

  • 为什么 b = a 后修改 ab 也可能受到影响;
  • 为什么 a = a + 1a.append(1) 的行为不同;
  • 为什么函数可以修改传入列表,却不能通过重新绑定参数修改调用者的名称;
  • 为什么 is== 不能互换;
  • 为什么浅拷贝后嵌套对象仍然共享;
  • 为什么“传值”与“传引用”都不足以准确描述 Python 的参数传递。

一、先建立运行模型:名称、对象和命名空间

1. 名称不是对象

考虑这段代码:

count = 10

可以分解为两个步骤:

  1. 创建或取得整数对象 10
  2. 在当前命名空间中,把名称 count 绑定到这个整数对象。

可以抽象成:

命名空间
┌─────────┐
│ count ──┼────► 整数对象 10
└─────────┘

这里的 count 是名称,不是对象本身。对象才是运行时真正承载值、类型和身份的实体。

如果随后执行:

count = 20

发生的不是“盒子里的数字从 10 改成 20”,而是:

执行前:

count ─────► 10

执行后:

count ─────► 20

原来的整数对象 10 并没有被修改。整数是不可变对象,因此不存在“把整数对象 10 改成 20”这件事;只是名称 count 改为绑定另一个对象。

名称绑定不仅由普通赋值产生,也可能由函数定义、类定义、函数形参、for 循环目标、with ... as、异常处理中的 as、导入语句以及赋值表达式等操作产生。(docs.python.org)


2. 命名空间是名称到对象的映射

在实现层面,模块、函数和类等作用域都拥有命名空间。可以把命名空间近似理解为:

namespace = {
    "count": <对象 10>,
    "name": <对象 "Alice">,
}

这不是说所有命名空间都必须由普通 dict 实现,而是一个有用的语义模型:名称作为键,绑定关系作为映射结果。

可以用 globals() 观察模块级命名空间:

value = 42

print(globals()["value"])
print(globals()["value"] is value)

预期输出:

42
True

globals()["value"] 和表达式 value 取得的是同一个对象。这里的 is 返回 True,不是因为两个整数“值相同”,而是因为两个表达式最终取得了同一个对象。

函数内部则有自己的局部命名空间:

def demo():
    value = 42
    print(locals()["value"] is value)

demo()

输出:

True

不过,locals() 主要适合观察,不应把它当成修改局部变量的可靠接口。局部变量如何存储和访问属于实现细节,不能简单假设修改 locals() 就一定会改变函数中的局部绑定。


3. 绑定关系可以改变,对象身份通常不变

看下面的代码:

items = []
alias = items

print(items is alias)  # True

items = ["new"]

print(items is alias)  # False
print(alias)          # []

执行过程如下。

初始时:

items ─┐
       ├──► 列表对象 []
alias ─┘

执行:

items = ["new"]

后:

items ─────► 新列表对象 ["new"]

alias ─────► 原列表对象 []

items 被重新绑定了,但 alias 没有变化。重新绑定一个名称不会自动修改其他名称的绑定。

这正是“变量不是盒子”模型与“变量是盒子”模型的关键区别:如果变量是盒子,给 items 重新赋值似乎应当影响共享同一个盒子的 alias;但 Python 中两个名称可以独立地改变绑定。


二、对象的三个属性:identity、type 和 value

Python 数据模型规定,每个对象都有:

  1. identity:对象身份;
  2. type:对象类型;
  3. value:对象的值。

对象创建后,身份不会改变;is 用于比较两个名称是否指向同一个对象,id() 返回表示对象身份的整数。在 CPython 中,id() 当前通常对应对象存储位置,但“身份等同于内存地址”不是所有 Python 实现都应依赖的语言层保证。(docs.python.org)

x = []
y = x
z = []

print(x is y)  # True
print(x is z)  # False

print(type(x))  # <class 'list'>
print(id(x) == id(y))  # True

这里有三个列表相关事实:

  • xy 绑定到同一个列表对象;
  • z 绑定到另一个新创建的列表对象;
  • 两个空列表即使内容相同,也不代表身份相同。

Python 数据模型明确区分了:

c = []
d = []

这两个表达式会创建两个不同的新列表;而:

e = f = []

会把同一个列表对象同时绑定给 ef。(docs.python.org)


三、identity 与 equality:is== 比较的不是同一件事

1. identity:是否是同一个对象

a = [1, 2]
b = a
c = [1, 2]

print(a is b)  # True
print(a is c)  # False

a is b 的含义是:

a 和 b 是否取得同一个对象?

它不关心对象内容是否相等。


2. equality:对象的值是否相等

print(a == b)  # True
print(a == c)  # True

== 的含义是:

a 和 b 的值是否相等?

对于列表,值相等通常意味着元素按顺序相等;对于自定义类型,== 可能由 __eq__() 定制。

因此可能出现:

a == b   # True
a is b   # False

最典型的例子是两个独立但内容相同的列表:

a = [1, 2]
b = [1, 2]

assert a == b
assert not (a is b)

比较可以用下表概括:

表达式 比较对象 常见用途
x is y 身份:是否同一个对象 判断单例对象,如 None
x == y 值:是否相等 判断数据内容
id(x) 对象身份的整数表示 调试和观察身份

判断是否没有返回值时,应写:

result = some_function()

if result is None:
    ...

而不是:

if result == None:
    ...

原因不是 None 的值“特殊”,而是 None 是一个单例对象,检查“是否就是这个对象”表达得更准确。Python 数据模型规定 None 类型只有一个对象。(docs.python.org)


3. 相等不等于可互换

即使两个对象满足:

x == y

也不能推断它们身份相同,更不能推断修改其中一个会影响另一个。

x = [1, 2]
y = [1, 2]

x.append(3)

print(x)  # [1, 2, 3]
print(y)  # [1, 2]

它们值曾经相等,但对象是独立的。

反过来,如果两个名称绑定同一可变对象:

x = [1, 2]
y = x

x.append(3)

print(x)  # [1, 2, 3]
print(y)  # [1, 2, 3]

这里不是 y 被“同步更新”,而是 xy 始终观察同一个列表对象。


四、可变与不可变:修改对象,还是重新绑定名称

1. 不可变对象

不可变对象创建后,其值不能原地改变。常见不可变类型包括:

  • int
  • float
  • complex
  • bool
  • str
  • bytes
  • tuple
  • frozenset

“不可变”并不意味着对象永远存在,也不意味着变量不能改变;它只表示该对象自身的值不能被原地修改。

number = 10
other = number

number += 1

print(number)  # 11
print(other)   # 10

number += 1 对整数不能执行原地修改。语义上可以近似理解为:

number = number + 1

过程是:

  1. 读取名称 number 当前绑定的整数对象 10
  2. 计算 10 + 1,得到新的整数对象 11
  3. 将名称 number 重新绑定到 11
  4. other 仍然绑定原来的 10

状态变化如下:

执行前:

number ─┐
        ├──► 整数对象 10
other ──┘

执行 number += 1 后:

number ─────► 整数对象 11
other  ─────► 整数对象 10

对于不可变对象,许多看似“修改变量”的操作,本质上都是计算新对象并重新绑定名称。


2. 可变对象

可变对象允许在原对象上改变其内部状态。常见可变类型包括:

  • list
  • dict
  • set
  • 大多数用户自定义实例
items = [1, 2]
alias = items

items += [3]

print(items)  # [1, 2, 3]
print(alias)  # [1, 2, 3]
print(items is alias)  # True

对于列表,+= 通常可以执行原地扩展,因此两个名称仍然绑定同一个列表对象。

但不能把 += 永远理解成“原地修改”。它的具体行为取决于类型是否实现了对应的原地运算协议。例如,对于元组:

items = (1, 2)
alias = items

items += (3,)

print(items)  # (1, 2, 3)
print(alias)  # (1, 2)
print(items is alias)  # False

元组不可变,不能在原对象上追加元素。因此这里会创建新元组,再把 items 重新绑定到新对象。

判断某段代码是否会影响别名,关键不是看代码中有没有 =,而是区分两类操作:

原地修改对象:
    items.append(3)
    mapping["key"] = value
    values.add(x)

重新绑定名称:
    items = items + [3]
    mapping = {}
    values = values | {x}

原地修改会影响所有指向该对象的名称;重新绑定只影响被重新赋值的名称。


3. 不可变容器也可能包含可变对象

“元组不可变”经常被误解为“元组中所有内容都不可变”。实际情况是,元组本身不能替换元素位置上的引用,但它引用的对象仍可能是可变的。

record = (["pending"],)

record[0].append("done")

print(record)  # (['pending', 'done'],)

这里不能执行:

record[0] = ["finished"]

因为元组的元素绑定不能改变;但可以修改元组中那个列表对象。

结构可以画成:

record ─────► 元组对象
                │
                └──► 列表对象 ["pending"]

执行:

record[0].append("done")

改变的是列表对象,而不是元组中“第 0 个槽位”的绑定。Python 数据模型也明确指出,包含可变对象引用的不可变容器,其整体可观察值可能随着内部可变对象变化。(docs.python.org)

因此,需要区分两种不变性:

  • 容器结构不变:不能增删或替换容器中的引用;
  • 递归对象图不变:容器内部所有可达对象都不能变化。

Python 的 tuple 只保证前者,不自动保证后者。


五、赋值不是复制:= 到底做了什么

1. 普通赋值只建立绑定

source = [1, 2]
target = source

结果是:

source ─┐
        ├──► 列表对象 [1, 2]
target ─┘

没有第二个列表对象产生。Python 官方 copy 文档也直接说明,赋值语句不会复制对象,而是在目标和对象之间建立绑定。(docs.python.org)

验证方式:

source = [1, 2]
target = source

print(source is target)  # True

target.append(3)

print(source)  # [1, 2, 3]

如果想创建一个新的外层列表,需要显式复制:

source = [1, 2]
target = source.copy()

print(source is target)  # False

target.append(3)

print(source)  # [1, 2]
print(target)  # [1, 2, 3]

2. 多目标赋值也不复制对象

a = b = []

这不是创建两个空列表,而是:

  1. 创建一个空列表;
  2. b 绑定到它;
  3. a 绑定到同一个对象。
a = b = []

a.append("x")

print(a)  # ['x']
print(b)  # ['x']
print(a is b)  # True

如果需要两个独立列表,应写:

a = []
b = []

或者:

a, b = [], []

3. 右侧先求值,左侧再绑定

Python 表达式从左到右求值,但赋值时右侧先于左侧求值。(docs.python.org)

因此:

x = 1
x, y = x + 1, x + 2

print(x)  # 2
print(y)  # 3

右侧整体先根据旧绑定计算:

x + 1 → 2
x + 2 → 3

然后才进行左侧绑定:

x ← 2
y ← 3

这也解释了交换写法:

left = "L"
right = "R"

left, right = right, left

print(left, right)  # R L

可以理解为:

  1. 先计算右侧,得到两个对象 "R""L"
  2. 再将 left 绑定 "R"
  3. 再将 right 绑定 "L"

它不是通过一个中间变量复制字符串,而是先保存右侧表达式的结果,再完成目标绑定。


六、参数传递:Python 是“对象共享”语义

1. 调用函数时,形参绑定到实参对象

考虑:

def inspect(value):
    print(value)
    print(id(value))

data = [1, 2]
print(id(data))

inspect(data)

调用函数时,函数参数表达式先求值;对于用户定义函数,函数体开始执行时,形参被绑定到对应的实参对象。(docs.python.org)

因此,调用:

inspect(data)

可以抽象成:

调用者命名空间:

data ──────► 列表对象 [1, 2]

函数局部命名空间:

value ─────► 同一个列表对象 [1, 2]

函数没有收到一个自动复制的列表,也没有收到“调用者名称 data 本身”。它收到的是对象,并在自己的局部命名空间中创建了名称 value

这通常称为:

  • call by sharing
  • pass by object reference
  • 对象共享传递

“Python 按值传递”若把“值”理解为对象引用的值,可以勉强描述一部分现象;“Python 按引用传递”则容易误导,因为函数无法直接重新绑定调用者的名称。对象共享是更精确的说法。


2. 函数可以修改共享的可变对象

def append_item(items):
    items.append("new")

data = ["old"]
append_item(data)

print(data)  # ['old', 'new']

过程如下:

调用前:

data ──────► 列表 ["old"]

进入函数:

data  ─────► 列表 ["old"]
items ─────► 同一个列表 ["old"]

执行 items.append("new"):

data  ─────► 列表 ["old", "new"]
items ─────► 同一个列表 ["old", "new"]

函数调用者和函数局部参数共享同一个列表对象,因此原地修改对调用者可见。


3. 函数不能通过重新绑定形参修改调用者名称

def replace_items(items):
    items = ["replacement"]

data = ["original"]
replace_items(data)

print(data)  # ['original']

进入函数时:

data  ─────► 原列表 ["original"]
items ─────► 原列表 ["original"]

执行:

items = ["replacement"]

之后:

data  ─────► 原列表 ["original"]
items ─────► 新列表 ["replacement"]

函数只改变了局部名称 items 的绑定,调用者的名称 data 仍然指向原列表。

这两种代码的差别必须牢牢记住:

def mutate(items):
    items.append(1)       # 修改共享对象

def rebind(items):
    items = items + [1]   # 创建新对象并重新绑定局部名称

调用者是否看到变化,取决于函数执行的是原地修改还是局部重新绑定,而不是取决于参数是否“传引用”。


4. 返回值是把新对象交给调用者

如果函数想让调用者获得新的绑定,通常通过返回值:

def add_item(items):
    return items + ["new"]

data = ["old"]
data = add_item(data)

print(data)  # ['old', 'new']

过程是:

  1. items + ["new"] 创建新列表;
  2. 函数返回这个新列表对象;
  3. 调用者执行 data = ...
  4. data 重新绑定到新列表。

函数内部的 items 和调用者的 data 仍然是不同命名空间中的名称;返回值只是把对象传回调用者,再由调用者决定是否重新绑定。


5. 默认参数中的可变对象会被共享

函数默认值不是每次调用函数时重新计算,而是在函数定义时计算一次。若默认值是列表或字典,它会被后续多次调用共享。(docs.python.org)

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

print(collect("a"))  # ['a']
print(collect("b"))  # ['a', 'b']

等价于函数定义阶段已经存在一个列表:

函数对象
└── 默认参数 bucket ───► 列表 []

第一次调用把 "a" 加入这个列表,第二次调用使用的仍然是同一个默认列表。

通常应使用 None 作为哨兵,再在函数体内创建新列表:

def collect(value, bucket=None):
    if bucket is None:
        bucket = []

    bucket.append(value)
    return bucket

print(collect("a"))  # ['a']
print(collect("b"))  # ['b']

这里每次省略 bucket 时,函数体都会创建一个新的列表对象。


七、浅拷贝:复制外层,保留内部引用

1. 浅拷贝的定义

对于复合对象,例如列表、字典或自定义实例:

  • 浅拷贝创建一个新的外层复合对象;
  • 外层对象中的元素或属性引用,尽可能直接复用原对象中的引用;
  • 因此,嵌套的可变对象仍然共享。

copy.copy()、列表切片、list.copy()dict.copy() 都可能用于创建浅拷贝;具体语义是创建新的复合对象,但将原对象中的内部引用放入新对象。(docs.python.org)

original = [1, 2]
shallow = original.copy()

print(original is shallow)  # False
print(original == shallow)  # True

这里复制了列表外壳,因此:

original ──► 列表对象 A ──► 1, 2
shallow  ──► 列表对象 B ──► 1, 2

两个外层列表不同。


2. 嵌套结构中的共享引用

original = [["a"], ["b"]]
shallow = original.copy()

print(original is shallow)        # False
print(original[0] is shallow[0])  # True

对象图是:

original ──► 外层列表 A
                │
                ├──► 内层列表 X ["a"]
                └──► 内层列表 Y ["b"]

shallow  ──► 外层列表 B
                │
                ├──► 内层列表 X ["a"]
                └──► 内层列表 Y ["b"]

执行:

shallow[0].append("changed")

只修改了共享的内层列表 X,因此两个外层列表都会观察到变化:

print(original)  # [['a', 'changed'], ['b']]
print(shallow)   # [['a', 'changed'], ['b']]

但如果修改的是外层结构:

shallow.append(["new"])

print(original)  # [['a', 'changed'], ['b']]
print(shallow)   # [['a', 'changed'], ['b'], ['new']]

此时只修改了外层列表 B,所以 original 不受影响。


3. 字典浅拷贝的同样规律

original = {
    "name": "Alice",
    "roles": ["reader"],
}

copied = original.copy()

copied["name"] = "Bob"
copied["roles"].append("writer")

print(original)
print(copied)

输出:

{'name': 'Alice', 'roles': ['reader', 'writer']}
{'name': 'Bob', 'roles': ['reader', 'writer']}

原因分为两部分:

copied["name"] = "Bob"

替换的是新字典中的键值绑定,因此只影响 copied

copied["roles"].append("writer")

取得的是原字典和新字典共同引用的列表,再修改这个列表,因此两者都受到影响。

浅拷贝不是“复制一层语法结构”,而是复制对象图的一个节点,并复用它指向的下一层节点。


4. 多维列表的经典错误

下面的写法容易制造共享引用:

matrix = [[0] * 3] * 2

matrix[0][0] = 1

print(matrix)  # [[1, 0, 0], [1, 0, 0]]

对象图不是两个独立的行,而是:

matrix ──► 外层列表
             │
             ├──► 同一个内层列表 [0, 0, 0]
             └──► 同一个内层列表 [0, 0, 0]

* 2 复制的是对内层列表的引用,不是递归复制内层列表。

如果需要独立的行,应让每次迭代创建新列表:

matrix = [[0] * 3 for _ in range(2)]

matrix[0][0] = 1

print(matrix)  # [[1, 0, 0], [0, 0, 0]]

列表推导式每次执行 [0] * 3,因此创建一个新的内层列表对象。


八、深拷贝:递归复制对象图,但不是无条件的“完全隔离”

1. 深拷贝的基本语义

深拷贝会创建外层对象,并递归复制其中的对象。(docs.python.org)

from copy import deepcopy

original = [["a"], ["b"]]
copied = deepcopy(original)

print(original is copied)        # False
print(original[0] is copied[0])  # False

copied[0].append("changed")

print(original)  # [['a'], ['b']]
print(copied)   # [['a', 'changed'], ['b']]

对象图变为:

original ──► 外层列表 A
                │
                ├──► 内层列表 X
                └──► 内层列表 Y

copied  ───► 外层列表 B
                │
                ├──► 内层列表 X'
                └──► 内层列表 Y'

其中 X'Y' 是递归复制得到的新对象。


2. deepcopy() 必须处理循环引用

对象图不一定是树,也可能包含环:

original = []
original.append(original)

此时结构是:

original ──► 列表 A
              │
              └──────► 列表 A

如果深拷贝算法每遇到一个对象就无条件递归,它会无限循环。deepcopy() 使用 memo 字典记录当前复制过程中已经处理过的对象,从而既避免递归循环,也尽量保持对象图中的共享关系。(docs.python.org)

from copy import deepcopy

original = []
original.append(original)

copied = deepcopy(original)

print(copied is copied[0])  # True
print(copied is original)   # False

结果表示:

  • copied 是新的列表;
  • copied[0] 仍然指向 copied 自己;
  • 循环结构被保留,但节点已经复制。

3. 深拷贝可能复制过多

深拷贝的风险不是只有性能。一个对象图中可能存在本来就应该共享的对象:

class Document:
    def __init__(self, content, schema):
        self.content = content
        self.schema = schema

如果 schema 是全局共享、不可变或只读配置,那么复制文档时通常希望:

文档 A ──► schema S
文档 B ──► schema S

而不是:

文档 A ──► schema S
文档 B ──► schema S'

deepcopy() 不理解业务层面的“哪些对象应该共享”。它只能根据类型和复制协议递归处理对象。官方文档也指出,深拷贝可能复制过多,例如本来就应在副本之间共享的数据。(docs.python.org)

所以“需要独立副本”不能自动等价为“直接调用 deepcopy()”。应先定义对象图中哪些节点需要隔离、哪些节点必须共享。


九、显式控制复制边界:只复制真正需要复制的部分

假设有一个请求对象:

class Request:
    def __init__(self, headers, body, client):
        self.headers = headers
        self.body = body
        self.client = client

其中:

  • headers 是可变字典,需要隔离;
  • body 是不可变字符串,不需要复制;
  • client 代表连接客户端,应该共享,而不是复制连接资源。

与其盲目深拷贝整个对象,不如显式构造:

from copy import copy

class Request:
    def __init__(self, headers, body, client):
        self.headers = headers
        self.body = body
        self.client = client

    def fork(self):
        return Request(
            headers=self.headers.copy(),
            body=self.body,
            client=self.client,
        )

使用:

client = object()
request = Request({"trace-id": "abc"}, "payload", client)
forked = request.fork()

forked.headers["user"] = "alice"

print(request.headers)  # {'trace-id': 'abc'}
print(forked.headers)  # {'trace-id': 'abc', 'user': 'alice'}
print(request.client is forked.client)  # True

这个实现把复制策略写成了业务语义:

  • 复制请求头;
  • 共享客户端连接;
  • 复用不可变正文。

相比 deepcopy(request),它的边界更清晰,也不会尝试复制无法或不应复制的外部资源。


十、名称解析:绑定不仅影响数据,也影响代码行为

名称绑定还决定函数体中名称如何解析。

count = 10

def show():
    print(count)

count = 42
show()

输出:

42

函数执行时,函数体中的自由变量名称会按照运行时环境解析;函数定义时并不会把当前整数值自动复制进函数体。Python 语言参考明确描述了自由变量的运行时解析行为。(docs.python.org)

但只要函数体中出现对某个名称的绑定操作,该名称在整个函数块中通常会被视为局部名称:

count = 10

def broken():
    print(count)
    count = 42

broken()

结果通常是:

UnboundLocalError

原因是 Python 在分析函数代码块时发现了:

count = 42

于是把 count 视为局部名称;执行 print(count) 时,局部名称还没有绑定对象。语言参考明确指出,只要代码块中任何位置出现名称绑定,该名称在整个代码块中的使用都会按当前代码块的绑定处理。(docs.python.org)

如果确实要修改模块级绑定,需要显式声明:

count = 10

def update():
    global count
    count = 42

update()
print(count)  # 42

这里修改的是模块命名空间中的绑定,不是修改整数对象 10


十一、从对象图理解共享、复制和生命周期

名称、对象和引用关系可以统一成一张对象图:

flowchart LR
    N1["名称 a"] --> O1["列表对象 L1"]
    N2["名称 b"] --> O1
    N3["名称 c"] --> O2["浅拷贝列表 L2"]

    O1 --> E1["内层列表 X"]
    O2 --> E1

    O1 --> E2["整数对象 1"]
    O2 --> E2

这张图表示:

  • ab 绑定同一个外层列表 L1
  • c 绑定浅拷贝得到的外层列表 L2
  • L1L2 仍共享内层列表 X
  • 不可变整数对象可以被多个容器共享,而无需担心原地修改。

当执行:

b.append("new")

变化的是 L1

当执行:

c.append("other")

变化的是 L2

当执行:

c[0].append("nested")

变化的是共享的 X,因此通过 abc 都可能观察到变化。

对象何时被回收,也与对象图有关:当对象不再从任何可达根对象访问到时,它才具备被垃圾回收的条件。对象不会被程序显式“销毁”;具体回收时间由 Python 实现决定。CPython 当前使用引用计数,并辅以用于检测循环引用的循环垃圾回收机制,但程序不应依赖对象失去引用后立即完成资源释放。(docs.python.org)

例如:

import gc

class Node:
    pass

a = Node()
b = Node()

a.other = b
b.other = a

del a
del b

print(gc.collect())

ab 之间形成循环,即使外部名称已经删除,两个对象仍互相引用。循环 GC 才可能识别这类不可达循环并回收它们。gc.collect() 可以请求执行收集,但这不是管理文件、 socket 等外部资源的替代方式;外部资源应通过 close()with 显式释放。(docs.python.org)


十二、常见误解与失败表现

误解一:b = a 会复制 a

错误理解:

a = [1, 2]
b = a

认为 b 获得了一个新列表。

实际情况:

assert a is b

如果需要独立的外层列表,应使用:

b = a.copy()

如果嵌套对象也要独立,再考虑:

from copy import deepcopy

b = deepcopy(a)

但深拷贝的范围应由对象图和业务语义决定,而不是机械使用。


误解二:函数参数是调用者变量的别名

错误理解:

def reset(value):
    value = 0

number = 10
reset(number)

print(number)  # 仍然是 10

函数中的 value 只是局部名称。重新绑定它,不会重绑定调用者的 number

但如果参数指向可变对象:

def clear(items):
    items.clear()

items = [1, 2, 3]
clear(items)

print(items)  # []

clear() 修改了共享对象,因此调用者能观察到变化。


误解三:== 为真就可以用 is

a = [1, 2]
b = [1, 2]

print(a == b)  # True
print(a is b)  # False

== 比较值,is 比较身份。除非明确需要判断单例身份,否则不要用 is 替代 ==

尤其不要依赖整数、字符串等不可变对象的身份复用:

a = 1000
b = 1000

print(a == b)  # True

至于:

print(a is b)

结果可能受实现、编译方式和对象缓存策略影响。Python 数据模型允许不可变对象在值相同时复用,但不应把这种身份关系作为程序逻辑。(docs.python.org)


误解四:浅拷贝意味着完全独立

a = [[1]]
b = a.copy()

b[0].append(2)

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

外层列表已经独立,内层列表仍共享。诊断嵌套结构时,应分别检查每一层:

print(a is b)        # False
print(a[0] is b[0])  # True

误解五:元组完全不可变

value = ([],)
value[0].append(1)

print(value)  # ([1],)

元组的元素绑定不可变,但元素指向的列表仍可变。判断一个结构是否安全共享,必须检查其内部对象图,而不能只看最外层类型。


误解六:del name 会销毁对象

items = []
alias = items

del items

print(alias)  # []

del items 只删除名称 items 的绑定。对象仍被 alias 引用,因此仍然可达。只有当对象不再被任何有效引用访问时,才可能成为垃圾回收候选对象。


十三、诊断共享引用的实用方法

1. 用 is 检查共享身份

def inspect_aliases(left, right):
    print("same object:", left is right)
    print("same value:", left == right)
    print("left id:", id(left))
    print("right id:", id(right))

示例:

a = [["x"]]
b = a.copy()

inspect_aliases(a, b)
inspect_aliases(a[0], b[0])

可能输出:

same object: False
same value: True
...
same object: True
same value: True
...

第一组检查外层列表,第二组检查共享的内层列表。


2. 用 copy.copy()copy.deepcopy() 对比对象图

from copy import copy, deepcopy

original = [{"tags": []}]

shallow = copy(original)
deep = deepcopy(original)

print(original is shallow)          # False
print(original[0] is shallow[0])    # True
print(original[0] is deep[0])       # False
print(original[0]["tags"] is deep[0]["tags"])  # False

这个例子展示了复制层级:

赋值:
    外层共享,内部共享

浅拷贝:
    外层独立,内部共享

深拷贝:
    外层独立,内部递归独立

3. 使用 gc 辅助诊断循环和引用

在 CPython 中,可以使用 gc 模块观察垃圾回收状态:

import gc

print(gc.isenabled())
print(gc.get_count())
print(gc.get_threshold())
print(gc.is_tracked([]))
print(gc.is_tracked(1))

gc 是循环垃圾回收器的控制和诊断接口;它可以启用或禁用自动收集、触发收集、查看统计信息和调试引用关系。具体对象是否被跟踪、收集策略如何工作,可能受 Python 实现和版本影响,不应把这些实现细节当作跨实现的对象模型保证。(docs.python.org)

对于普通共享引用问题,优先检查:

x is y

对于对象图循环问题,再使用 gc、调试器或专门的内存分析工具。不要因为观察到两个名称指向同一对象,就直接推断存在内存泄漏;共享可能是设计需要,只有无法释放的可达路径才构成真正的生命周期问题。


十四、如何选择赋值、浅拷贝和深拷贝

可以用对象图需求来做判断:

只想创建另一个名称

alias = original

适用于明确希望共享同一个对象的场景。

只需要隔离外层容器

clone = original.copy()

适用于内部元素不可变,或内部可变对象本来就应该共享的场景。

需要递归隔离大部分对象

from copy import deepcopy

clone = deepcopy(original)

适用于对象图中大多数可变节点都应独立的场景,但需要确认:

  • 是否含有循环引用;
  • 是否包含文件、socket、锁、线程或其他外部资源;
  • 是否存在本来应该共享的对象;
  • 自定义类是否定义了特殊复制逻辑;
  • 复制结果是否保留了期望的共享关系。

需要精确控制边界

显式构造新对象:

clone = Config(
    values=original.values.copy(),
    schema=original.schema,
    cache={},
)

这种方式通常最能表达业务语义:哪些状态复制,哪些状态共享,哪些状态重新初始化。


结语:把 Python 代码看成对象图上的绑定变化

理解 Python 名称与对象,最重要的不是记住“列表可变、整数不可变”这句结论,而是持续追踪三个问题:

  1. 当前名称绑定到哪个对象?
  2. 这个对象能否原地修改?
  3. 其他名称是否也绑定到同一个对象?

赋值改变的是名称绑定:

name = object

原地操作改变的是对象状态:

object.method(...)

浅拷贝复制外层对象、共享内部引用;深拷贝递归复制对象图,但仍需要面对循环、外部资源和共享语义。函数参数则是在新的局部命名空间中,把形参绑定到调用者提供的对象,而不是把调用者的名称交给函数修改。

一旦用“名称—对象—引用关系”而不是“变量盒子”来观察代码,赋值、参数传递、可变性、身份比较和拷贝行为就会统一成同一个模型:名称可以重新绑定,对象可以被共享,只有明确的复制操作才会创建新的对象节点。


系列导航与关联阅读

官方资料

本文依据 Python 官方文档、相关 PEP 与生态项目官方文档重新梳理;正文、示例与工程清单由 WR BLOG 编写。