Python 变量不是装数据的盒子,而是指向对象的名称。很多看似奇怪的修改、复制和传参问题,都可以从这句话中找到答案。

写在前面

刚开始学习 Python 时,我们通常会把变量理解成一个容器:

user_name = "Alice"

这段代码似乎是把字符串 "Alice" 放进了变量 user_name。这种理解足以帮助我们学习语法,却无法解释下面的行为:

primary_users = ["Alice"]
backup_users = primary_users
backup_users.append("Bob")

print(primary_users)

运行结果不是 ["Alice"],而是:

["Alice", "Bob"]

如果 backup_users 是一份独立备份,修改它就不应该影响 primary_users。问题在于,赋值语句并没有复制列表,只是让两个名称关联到了同一个对象。

这类问题广泛存在于实际项目中:

  • 修改函数参数后,调用方的数据也发生了变化。
  • 使用字典模板生成记录,多条记录却共享同一个列表。
  • 对配置执行了浅拷贝,修改嵌套字段时仍然污染原配置。
  • 服务运行一段时间后内存持续增长,却找不到没有释放的变量。
  • 删除对象后,操作系统看到的进程内存没有立即下降。

要解释这些现象,需要建立一套更准确的对象模型。

本文主要讨论 Python 语言层面的对象与引用,同时会说明 CPython 中的引用计数、垃圾回收和内存分配行为。CPython 是最常用的 Python 实现,但它的部分行为并不是 Python 语言规范的统一保证。

本文示例以 Python 3.10 及以上版本为基线,使用了内置集合泛型和 | 联合类型标注。

一、变量保存的究竟是什么

Python 中更准确的说法是:名称绑定到对象

执行下面的赋值时:

user_names = ["Alice", "Bob"]

解释器主要完成两个动作:

  1. 创建一个列表对象。
  2. 将名称 user_names 绑定到这个列表对象。

当另一个名称接收 user_names 时:

backup_names = user_names

不会自动创建新列表。此时的关系可以简化为:

user_names   ─┐
              ├──> ["Alice", "Bob"]
backup_names ─┘

因此,通过任何一个名称修改列表,另一个名称再次访问时都会看到变化:

backup_names.append("Carol")

print(user_names)
# ["Alice", "Bob", "Carol"]

这里需要区分两个动作。

1. 修改对象

append() 改变了原列表对象的内容,没有让名称指向其他对象。

2. 重新绑定名称

重新赋值会让名称绑定到另一个对象:

backup_names = ["David"]

print(user_names)
# ["Alice", "Bob", "Carol"]

现在两个名称指向不同的列表。重新绑定 backup_names 不会改变 user_names 的绑定关系,也不会修改原列表。

理解“修改对象”和“重新绑定名称”的区别,是分析 Python 数据变化问题的第一步。

二、对象、引用与 id() 的关系

Python 中的对象通常具有三个需要关注的特征:

  • 身份:用于区分两个对象,可以通过 id() 观察。
  • 类型:决定对象支持哪些操作,可以通过 type() 获取。
  • :对象所承载的数据。

可以通过下面的代码观察两个名称是否绑定到同一个对象:

primary_users = ["Alice"]
backup_users = primary_users

print(id(primary_users))
print(id(backup_users))
print(primary_users is backup_users)

两个 id() 在对象存活期间相同,is 的结果也是 True

需要注意:

  • is 判断的是对象身份是否相同。
  • == 判断的是对象值是否相等。
left = ["Alice"]
right = ["Alice"]

print(left == right)  # True
print(left is right)  # False

两个列表的值相同,但它们是两个独立对象。

业务代码通常使用 == 比较值。is 最常见且明确的用途,是判断一个值是否为 None

if result is None:
    ...

不要依赖小整数或短字符串可能出现的对象复用行为,也不要使用 is 代替普通值比较。解释器可以对部分不可变对象进行缓存或驻留,这属于实现优化,不应该成为业务逻辑的前提。

在 CPython 中,id() 通常与对象的内存地址相关;从 Python 语言层面看,只能保证对象存活期间 id() 唯一。对象被销毁后,原来的 id() 可能被后续对象重新使用。

三、可变对象与不可变对象

对象创建后能否改变自身内容,是理解引用行为的另一个关键。

常见的不可变对象包括:

  • 整数、浮点数和布尔值。
  • 字符串和字节串。
  • 元组。
  • frozenset

常见的可变对象包括:

  • 列表。
  • 字典。
  • 集合。
  • 大多数自定义类实例。

1. 不可变对象的“修改”实际上创建了新对象

retry_count = 3
original_id = id(retry_count)

retry_count += 1

print(original_id == id(retry_count))
# False

整数对象不能原地改变。retry_count += 1 计算出新的整数对象,再将名称 retry_count 绑定到新对象。

字符串拼接也遵循类似思路:

message = "task"
message += " completed"

从语言语义看,字符串本身没有被修改,message 最终绑定到了一个新字符串。

2. 可变对象可以在原对象上修改

tasks = ["extract"]
original_id = id(tasks)

tasks.append("load")

print(original_id == id(tasks))
# True

append() 修改了原列表,因此对象身份没有变化。

对于内置列表,+= 通常也会原地扩展:

tasks = ["extract"]
alias = tasks
tasks += ["load"]

print(alias)
# ["extract", "load"]

这与整数、字符串等不可变对象的 += 行为不同。面对不熟悉的自定义类型时,还要查看它是否实现了原地操作协议,不能只根据运算符外观判断。

3. 元组不可变,不代表它引用的对象都不可变

pipeline = ("daily", ["extract"])
pipeline[1].append("load")

print(pipeline)
# ("daily", ["extract", "load"])

元组不能替换自己的元素,但第二个元素绑定的是一个列表对象。修改这个列表,并没有改变元组保存的引用关系。

因此,“不可变”描述的是对象自身结构,而不是从它出发能够访问到的所有对象。

四、赋值、浅拷贝与深拷贝

当业务需要一份独立数据时,单纯赋值通常不够。Python 中需要区分三种情况。

1. 赋值:不创建新容器

source = {"tags": ["python"]}
target = source

print(source is target)
# True

sourcetarget 指向同一个字典。

2. 浅拷贝:复制外层容器

source = {"tags": ["python"]}
target = source.copy()

print(source is target)
print(source["tags"] is target["tags"])

外层字典已经不同,但嵌套的列表仍然是同一个对象:

False
True

此时修改嵌套列表仍会影响原数据:

target["tags"].append("memory")

print(source)
# {"tags": ["python", "memory"]}

列表切片、list()dict() 以及 copy.copy() 通常也只完成浅拷贝。它们会创建新的外层容器,但不会递归复制其中引用的所有对象。

3. 深拷贝:递归复制对象图

from copy import deepcopy

source = {"tags": ["python"]}
target = deepcopy(source)
target["tags"].append("memory")

print(source)
# {"tags": ["python"]}

deepcopy() 会递归处理嵌套对象,并使用内部备忘机制处理共享引用和循环引用。

但深拷贝不是默认最优解:

  • 大型对象图的复制会消耗时间和内存。
  • 文件句柄、网络连接、锁等资源对象不适合直接复制。
  • 自定义对象可能需要实现特定复制逻辑。
  • 原数据中的共享关系可能具有业务含义,盲目复制会改变语义。

更稳妥的做法,是先明确哪些数据需要隔离。如果只需要修改一个嵌套字段,可以定向创建新对象:

source = {"name": "task", "tags": ["python"]}
target = {
    **source,
    "tags": [*source["tags"], "memory"],
}

这种方式明确表达了复制范围,也避免了对整个对象图执行不必要的深拷贝。

五、函数参数是如何传递的

Python 的函数调用可以理解为:形参名称绑定到调用方传入的对象。这种模型也常被称为对象共享传递。

先看修改可变对象的情况:

def add_task(tasks: list[str]) -> None:
    tasks.append("load")


pipeline_tasks = ["extract"]
add_task(pipeline_tasks)
print(pipeline_tasks)

输出为:

["extract", "load"]

函数内的 tasks 和调用方的 pipeline_tasks 在调用期间指向同一个列表。append() 修改了该对象,所以调用方能够看到变化。

如果函数只是重新绑定形参,调用方不会受到影响:

def replace_tasks(tasks: list[str]) -> None:
    tasks = ["load"]


pipeline_tasks = ["extract"]
replace_tasks(pipeline_tasks)
print(pipeline_tasks)

输出仍然是:

["extract"]

tasks = ["load"] 只改变了函数局部名称的绑定,没有改变调用方名称,也没有修改原列表。

设计函数时,应该明确它是否允许修改传入对象:

  • 如果函数职责是原地更新,应通过命名、类型和文档说明副作用。
  • 如果调用方需要保留原值,应返回新对象,或者在边界处进行必要复制。
  • 不要为了“绝对安全”无条件深拷贝所有参数,这会掩盖所有权设计问题并增加开销。

六、可变默认参数为什么会污染数据

默认参数在函数定义执行时求值,而不是每次调用函数时重新求值。

下面的函数只会创建一次默认列表:

def collect_event(event: str, events: list[str] = []) -> list[str]:
    events.append(event)
    return events


print(collect_event("started"))
print(collect_event("completed"))

第二次调用会继续使用第一次调用时的列表:

["started"]
["started", "completed"]

这个问题在 Web 请求处理、批处理任务和测试用例中尤其隐蔽,因为不同调用之间可能出现不符合预期的状态共享。

常见修复方式是使用 None 作为哨兵值:

def collect_event(
    event: str,
    events: list[str] | None = None,
) -> list[str]:
    current_events = [] if events is None else events
    current_events.append(event)
    return current_events

这里必须使用 events is None,而不是 if not events。调用方主动传入空列表时,空列表虽然是假值,但仍可能是需要被原地更新的有效对象。

不可变默认参数通常没有这个问题,例如 None、整数、字符串和不可变枚举值。不过,如果默认对象内部仍然引用可变数据,也要继续分析其真实共享关系。

七、CPython 如何回收对象

Python 语言要求无法再访问的对象最终可以被回收,但不同解释器的具体实现并不相同。下面主要讨论 CPython。

1. 引用计数

CPython 会为对象维护引用计数。增加一个有效引用,计数通常会上升;引用离开作用域或被重新绑定,计数通常会下降。当计数降到零时,对象一般可以立即释放。

可以使用 sys.getrefcount() 辅助观察:

import sys

tasks = ["extract"]
alias = tasks

print(sys.getrefcount(tasks))

输出值会比直觉多一个,因为将 tasks 传给 getrefcount() 时产生了临时引用。因此,它适合帮助理解,不适合直接作为生产监控指标。

引用计数的优点是大部分对象能够及时回收,但它不能单独处理循环引用:

first: list[object] = []
second: list[object] = [first]
first.append(second)

即使外部名称都被移除,两个列表仍然互相引用,引用计数不会自然降到零。

2. 循环垃圾回收

CPython 还提供了循环垃圾回收器,用于检测和回收部分容器对象形成的引用环。gc 模块可以用于观察和调试相关行为。

在正常业务代码中,不应该频繁调用 gc.collect() 来掩盖内存增长。更合理的顺序是:

  1. 确认对象是否仍被业务结构引用。
  2. 找到引用关系为什么没有按预期结束。
  3. 修复无界容器、缓存或生命周期设计。
  4. 只有在明确理解收益和成本时,才调整垃圾回收策略。

其他 Python 实现可能使用不同的垃圾回收机制,因此不能依赖“离开作用域后对象一定立即销毁”来保证业务正确性。文件、连接和锁等资源应该通过上下文管理器显式管理。

八、对象释放后,进程内存为什么没有下降

排查 Python 内存问题时,经常会遇到一个现象:代码中的对象已经释放,但操作系统看到的常驻内存仍然很高。

这不一定意味着对象仍然存活。

CPython 会通过自身的内存分配器管理大量小对象。对象释放后,相关内存可能先回到 Python 的内存池,等待后续对象复用,而不是立即归还操作系统。不同尺寸的分配还可能经过系统分配器或第三方库。

因此,需要区分三类问题:

  1. 对象仍被引用:容器、缓存、回调或任务保存了对象,属于真正的生命周期问题。
  2. 对象已释放但内存被分配器保留:后续可能复用,进程 RSS 不一定立即下降。
  3. 非 Python 内存增长:C 扩展、图像库、数值计算库或网络库在 Python 堆之外分配内存。

只观察 RSS 无法判断属于哪一种。应该同时查看对象分配、业务容器大小和进程内存趋势。

sys.getsizeof() 也不能直接给出一个复杂对象的完整占用:

import sys

payload = {"items": [1, 2, 3]}
print(sys.getsizeof(payload))

它通常只返回对象自身的浅层大小,不会自动递归统计字典引用的列表及列表中的对象。

九、常见的内存持续增长场景

Python 中很多“内存泄漏”并不是垃圾回收失效,而是程序仍然持有不再需要的数据。

1. 无界容器

processed_records: list[dict[str, object]] = []

def process(record: dict[str, object]) -> None:
    processed_records.append(record)

只要全局列表持续保存记录,对象就不会被回收。需要根据用途改成有界队列、批量落盘或只保留聚合结果。

2. 无界缓存

functools.cachelru_cache(maxsize=None) 会持续保存不同参数对应的结果。如果输入空间没有边界,缓存也没有边界。

生产代码需要明确:

  • 缓存最大条目数。
  • 过期策略。
  • 单个值的大小。
  • 命中率是否值得占用内存。

3. 回调、闭包与事件监听器

长期存活的回调列表可能间接引用大型对象。闭包也会保留其使用的外部变量。注册回调后没有注销,常常会让整个对象关系长期存活。

不需要拥有对象生命周期时,可以评估使用 weakref,但弱引用会增加生命周期的不确定性,应该在明确语义后使用。

4. 消费速度跟不上生产速度

队列持续增长不一定是泄漏,也可能是生产者速度长期高于消费者。此时需要有界队列、背压、限流或扩容,而不是只依赖垃圾回收。

5. 未及时结束的任务和生成器

异步任务、线程任务或生成器可能保留执行栈中的局部对象。如果任务没有完成、取消或关闭,相关对象也可能继续存活。

6. 外部资源没有关闭

文件、响应体、数据库连接和进程句柄没有关闭,主要表现为资源泄漏,也可能伴随缓冲区和关联对象无法释放。应该优先使用上下文管理器或明确的生命周期管理。

十、如何排查 Python 内存问题

内存排查应该从可复现和可比较开始,而不是看到内存增长后立即调用垃圾回收。

第一步:确认增长模式

先回答几个问题:

  • 内存是持续线性增长,还是达到某个水平后稳定?
  • 增长与请求数、任务数还是数据量相关?
  • 停止流量后,内存是否仍然增长?
  • 同一输入能否在测试环境复现?
  • 增长发生在 Python 堆,还是第三方扩展分配的内存?

稳定在高水位可能是缓存或分配器复用;持续无界增长才更值得警惕。

第二步:使用 tracemalloc 比较快照

tracemalloc 是标准库工具,可以跟踪 Python 分配的内存,并比较两个时间点的差异:

import tracemalloc

tracemalloc.start(25)
before = tracemalloc.take_snapshot()

run_target_workload()

after = tracemalloc.take_snapshot()
for statistic in after.compare_to(before, "lineno")[:10]:
    print(statistic)

重点观察持续增长的代码位置,而不是只看某一时刻占用最大的对象。

tracemalloc 主要跟踪 Python 内存分配。如果增长来自某些 C 扩展或系统库,还需要结合对应工具和库自身的诊断能力。

第三步:检查对象数量和引用关系

gc 模块可以辅助查看被垃圾回收器跟踪的对象。gc.get_referrers() 能帮助寻找谁仍在引用目标对象,但它会受到调试环境和临时引用干扰,结果需要谨慎解释。

排查时可以重点检查:

  • 全局字典和列表的长度。
  • 缓存条目数与命中率。
  • 队列积压数量。
  • 未结束的异步任务数量。
  • 连接池和线程池状态。
  • 业务批次结束后,对象数量是否回落。

第四步:缩小到最小复现

关闭无关功能,减少输入规模,反复执行目标路径。如果每次执行都稳定增加同一类对象,通常比直接分析整个生产进程更容易找到原因。

第五步:修复后验证趋势

修复不能只验证“功能仍然正确”,还要重复相同负载,比较:

  • 峰值内存。
  • 稳态内存。
  • 每轮任务结束后的对象数量。
  • 吞吐量和执行时间。

某些内存优化会增加计算或 I/O 成本,需要一起评估,而不是只追求更低的 RSS。

十一、减少不必要的对象创建和复制

理解对象模型之后,可以用更有针对性的方式优化内存。

1. 使用生成器处理流式数据

如果数据只需要遍历一次,不必一次性构造完整列表:

def read_valid_lines(file_path: str):
    with open(file_path, encoding="utf-8") as file:
        for line in file:
            normalized = line.strip()
            if normalized:
                yield normalized

生成器只保留当前执行状态,适合大文件和数据流。但生成器也可能持有局部变量和打开的资源,消费方应及时遍历或关闭。

2. 分批处理,控制峰值

数据库查询、接口数据和消息处理应根据数据量设置合理批次。批次太大增加峰值内存,批次太小则增加网络和调度开销,需要通过测量选择。

3. 避免无条件深拷贝

先明确数据所有权,只复制将被修改且必须隔离的部分。不可变数据可以安全共享,业务明确的只读对象也不需要重复构造。

4. 为缓存和队列设置边界

任何长期运行的容器都应该回答“最多保存多少数据”。边界可以是条目数、字节数、时间窗口或磁盘容量。

5. 复用重量级客户端

数据库连接池、HTTP 客户端和模型客户端通常包含连接与缓冲区。应按框架生命周期正确复用,而不是每次请求重复创建;同时也不能把本应短期存在的请求数据挂在全局客户端上。

6. 对大量简单实例评估 slots

当程序创建大量结构相同的轻量对象时,可以评估 __slots__dataclass(slots=True),减少每个实例的属性存储开销。它会限制动态属性并影响部分继承和序列化行为,不应该在缺少测量时全面启用。

7. 缩短大对象的有效生命周期

在长函数中,大对象可能因为局部引用而持续存活。优先通过拆分职责和缩小作用域自然结束引用。del 可以用于明确移除某个名称,但它删除的是绑定,不保证对象一定释放,也不应该替代清晰的生命周期设计。

十二、实践:定位并修复一次数据污染

下面用一个记录构建函数复现两个常见问题:

  1. 可变默认参数导致多次调用共享记录列表。
  2. 浅拷贝导致不同记录共享嵌套的标签列表。

问题代码

DEFAULT_METADATA = {"tags": []}


def build_record(name: str, records: list[dict] = []) -> list[dict]:
    metadata = DEFAULT_METADATA.copy()
    metadata["tags"].append(name)
    records.append({"name": name, "metadata": metadata})
    return records

连续调用两次:

first_result = build_record("Alice")
second_result = build_record("Bob")

print(first_result is second_result)
print(first_result)

实际结果为:

True
[
    {"name": "Alice", "metadata": {"tags": ["Alice", "Bob"]}},
    {"name": "Bob", "metadata": {"tags": ["Alice", "Bob"]}},
]

可以进一步验证共享关系:

first_tags = first_result[0]["metadata"]["tags"]
second_tags = first_result[1]["metadata"]["tags"]

print(first_tags is second_tags)
# True

问题来自两层共享:

  • records 默认列表只在函数定义时创建一次。
  • dict.copy() 只复制外层字典,tags 仍然引用模板中的同一个列表。

修复代码

def build_record(
    name: str,
    records: list[dict] | None = None,
) -> list[dict]:
    current_records = [] if records is None else records
    metadata = {"tags": [name]}
    current_records.append({"name": name, "metadata": metadata})
    return current_records

再次验证:

first_result = build_record("Alice")
second_result = build_record("Bob")

assert first_result is not second_result
assert first_result[0]["metadata"]["tags"] == ["Alice"]
assert second_result[0]["metadata"]["tags"] == ["Bob"]

如果业务要求调用方传入同一个 records 列表并持续追加,该行为仍然保留:

records: list[dict] = []
build_record("Alice", records)
build_record("Bob", records)

assert len(records) == 2

修复的关键不是“所有对象都复制一遍”,而是明确所有权:

  • 未传入记录列表时,每次调用创建自己的列表。
  • 调用方传入列表时,函数按约定原地追加。
  • 每条记录拥有独立的元数据和标签列表。

这比无条件调用 deepcopy() 更清晰,也减少了不必要的对象复制。

十三、对象与内存问题检查清单

遇到数据被意外修改或内存持续增长时,可以依次检查:

  1. 两个名称是否通过 is 指向同一个对象?
  2. 当前操作是在修改对象,还是重新绑定名称?
  3. 对象自身是否可变,内部是否引用了可变对象?
  4. 当前复制是赋值、浅拷贝还是深拷贝?
  5. 函数是否修改了调用方传入的对象?
  6. 默认参数是否包含列表、字典、集合或自定义可变对象?
  7. 是否存在无界列表、字典、缓存或队列?
  8. 回调、闭包、任务或全局对象是否延长了数据生命周期?
  9. 增长来自 Python 对象、内存分配器,还是第三方扩展?
  10. 修复后是否使用相同负载验证了内存趋势?

结语

Python 对象模型并不复杂,但如果一直沿用“变量是盒子”的理解,赋值、复制和函数传参就会表现得反复无常。

更稳定的判断方式是追踪三个问题:

  1. 当前有哪些对象?
  2. 哪些名称或容器正在引用它们?
  3. 代码是在修改对象,还是改变引用关系?

当内存出现异常时,还要继续区分对象是否存活、分配器是否保留内存,以及内存是否来自 Python 之外。只有先确定增长属于哪一类,优化才不会变成盲目调用垃圾回收或无条件深拷贝。

下一期将从对象与引用继续向外扩展,讨论如何把一个能够运行的单文件脚本,整理成配置清晰、边界明确、可以测试和持续维护的 Python 项目。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐