开始前,会这些就够了
- 能读懂 Python 的字典、列表与嵌套取值
- 先读Java 值与引用,知道内容相等和对象身份是两件事;本文的 Python 用 == 比较内容、is 比较身份
- Python 3.10+,仅标准库;本例不启动真实 Agent
协调者准备给两个执行者各发一份计划,于是先把模板放进列表两次,再对整个列表做 deepcopy。原模板确实没有被改动,可第一份计划把步骤标为完成,第二份也跟着完成了。深复制不是失效了:它保留了副本内部原有的共享关系。
既有 Java 值与引用课已用身份映射演示保留别名。这里把那个基础推进到 Python 标准库和嵌套执行计划:六组实验都从新夹具开始,记录复制前后的对象关系,再依次修改步骤与便条。最终交付的是变化从哪里传播到哪里的账本。
把例子带到自己的电脑上
下载完整练习包
需要 Python 3.10 或更新版本,无第三方依赖;制作时实测 Python 3.14.0。使用标准库,不需要安装第三方包。下载 ZIP 后选择“全部提取”,进入能看到 README.md 的文件夹。
下载 ZIP · 5 个文件包里有什么
- copy_aliases.py:完整示例
- README.md:环境、范围与独立练习
- observed.stdout.txt:制作时的实际输出
- verification.json:源码与产物验证记录
- SHA256SUMS.txt:文件内容摘要
在这个文件夹打开 PowerShell,逐行运行
python --version
python -B copy_aliases.py解压到独立练习文件夹,在含源文件的目录打开终端运行。程序会在当前目录写入教学报告;先预测结果,再查看当次输出。普通 Python 字典和列表的六组顺序修改及对象关系;不复制连接、文件或进程,也不证明并发隔离。
找不到命令或文件时,先按 README 检查环境和所在目录。文中标出的阅读时间只估算文章篇幅,安装环境和动手练习可按自己的节奏进行。
先区分两种希望得到的隔离
第一种目标是新的一批计划改动时,不影响原来的模板。第二种目标是这一批里面,甲的修改不影响乙。它们是两条不同要求。整个图搬到新的对象上,能够满足第一条,却仍可能保留甲乙指向同一个子对象的关系。
本例模板包含标题、步骤列表和便条列表,步骤里还有一张记录状态的字典。source=[template,template] 只把同一个计划放到两个列表位置,并没有创建两份计划。纸上可以画两支箭头:起点不同,终点仍是同一个盒子。
Python 的复制文档区分赋值绑定、浅复制与深复制,并说明一次复制过程会记录已经复制过的对象。理解这份记录,比笼统地认为“深”就会把所有共享关系打散更有用。
打印内容一样,看不出背后有几份对象
六组复制后、修改前的计划内容都与来源相等。== 能说明这些普通字典与列表中的值相等,却不能说明其中有几份独立的可变对象。程序因此同时记录 result[0] is result[1],以及两份计划的 steps、notes 是否各自共享。
对象表给当前案例里的可变对象分配 object-1 等短标签。两个路径标签相同,表示它们当时指向同一对象。这些标签仅在本案例内有意义,不是内存地址、业务编号或可以跨进程追踪的永久身份。
读账本时,先筛选一个案例,再把标签相同的路径圈在一起。比如两条步骤路径同属一个圈,表示写入其中一个入口就能从另一个入口看见变化;两条路径内容恰好一样,却属于不同的圈,则没有这种必然联动。这样可以先解释结构,再检查修改结果。
程序另存每阶段的值快照,展示修改前、改步骤后和加便条后的内容。JSON 本身只保存这里的值,不能表达这些位置原先是否共用对象;因此对象表与值快照需要一起读,不能用一份漂亮的 JSON 证明内存关系已经分开。
先看三个对照,确定共享藏在哪一层
assignment 直接沿用原列表。shallow_collection 只创建新的外层列表,两份计划仍指向原计划。shallow_each_plan 给每个位置创建新的字典,这次两份外层字典不同,但里面的步骤和便条仍沿用原来的列表。
这三组中,修改 result[0] 的首个步骤后,source 的两条路径与 result 的两条路径都能看到 done;追加便条也一样。账本记录四条发生变化的路径,并不是说修改了四个独立步骤:共享时,一次修改会从多个入口被看到。
最后这一组尤其容易误判。只检查两份计划字典不是同一个对象,还不够证明计划内部的可变内容彼此独立。验收要沿着允许修改的字段继续往下看,而不是只在最外层做一次 is 判断。
整批深复制会保留内部别名,这是设计行为
deep_collection 对整个 source 调用一次 deepcopy。复制后的所有可变对象都与 source 分开,原模板不会随修改变化;但 result 的两个位置仍指向同一份新计划。修改第一份步骤,第二份也显示 done,追加便条也会一起出现。
CPython 3.14.0 的实现能帮助追踪原因:遍历列表时沿用本轮 memo;再次遇到同一个原对象,直接使用已经建立的副本。因此同一个原计划不会仅因出现两次,就被无条件变成两份不同计划。
这种保留关系有实际用途。如果两个入口本来就代表同一份共享检查单,复制整组后仍希望它们保持一致,打散关系反而会改变含义。这里的失败来自预期与构造方式不一致,不能把 deepcopy 保留别名描述成库的缺陷。
需要逐份独立,就在分配边界分别构造
deep_each_plan 对每个位置分别调用 deepcopy,每次都让函数自行建立新的本轮记录。两份计划的步骤与便条不再共享,也不与来源共享。此时改第一份步骤,只会在 result[0] 那条路径看到 done;第一份便条也只出现在那里。
对本题这些普通数据,也可以在循环里每次调用模板工厂,直接创建新字典与新列表。Python 的多维列表 FAQ用重复行说明了相同问题:重复一个引用与重复执行构造过程,得到的对象关系不同。
这不是要求所有资料一律复制成独立份。先区分每位执行者拥有的可变草稿、明确共享的内容与不可变资料,再决定构造边界。若状态含有连接、文件或自定义对象,需要按它们的契约处理,不能从本题推出 deepcopy 能复制整个运行环境。
运行六组关系与传播账本
保存完整代码后运行,会生成 copy-aliases-report.json、copy-aliases-summary.csv 和 copy-aliases-objects.csv。摘要表有六行,对象表有一百零八行;JSON 还保存每组的三个值快照和实际受影响路径。重复运行会更新这些同名教学产物。
本次在 Python 3.14.0 实际通过。先在纸上预测每种复制方式的箭头,再看 flags 与 affected_steps、affected_notes。deep_collection 与 deep_each_plan 的初始值快照完全相同,但对象关系和修改后的结果不同,这组对照应当一起保留。
展开收起代码 · 小实验
长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。
"""Ordinary dict/list plan graphs; copy boundaries are not concurrency boundaries."""
from copy import deepcopy
from pathlib import Path
import csv
import json
import sys
def template():
return {"title": "draft", "steps": [{"task": "check", "status": "todo"}], "notes": []}
def locations(source, result):
found = {}
for prefix, plans in (("source", source), ("result", result)):
found[prefix] = plans
for i, plan in enumerate(plans):
base = f"{prefix}[{i}]"
found[base] = plan
found[base + ".steps"] = plan["steps"]
found[base + ".steps[0]"] = plan["steps"][0]
found[base + ".notes"] = plan["notes"]
return found
def relationships(source, result):
names, seen = [], []
for path, obj in locations(source, result).items():
label = next((f"object-{i + 1}" for i, previous in enumerate(seen) if obj is previous), None)
if label is None:
seen.append(obj)
label = f"object-{len(seen)}"
names.append({"path": path, "object_label": label, "kind": type(obj).__name__})
return names
def snapshot(source, result):
# JSON captures values only. The separate relationship table records identity.
return json.loads(json.dumps({"source": source, "result": result}))
def one_case(name, method, shared_steps_only=False):
first = template()
if shared_steps_only:
second = template()
second["steps"] = first["steps"]
source = [first, second] # Distinct plan dictionaries, one shared checklist.
else:
source = [first, first] # Two list positions, only one original plan.
result = method(source)
assert result == source
graph = relationships(source, result)
source_objects = list(locations(source, source).values())
result_objects = list(locations(result, result).values())
external_isolation = not any(a is b for a in source_objects for b in result_objects)
flags = {"same_result_plans": result[0] is result[1],
"same_result_steps": result[0]["steps"] is result[1]["steps"],
"same_result_notes": result[0]["notes"] is result[1]["notes"],
"result_mutables_detached_from_source": external_isolation}
stages = [{"stage": "before", "values": snapshot(source, result)}]
result[0]["steps"][0]["status"] = "done"
stages.append({"stage": "edit_first_step", "values": snapshot(source, result)})
result[0]["notes"].append("checked-by-first")
stages.append({"stage": "append_first_note", "values": snapshot(source, result)})
affected_steps = [path for path, obj in locations(source, result).items()
if path.endswith(".steps[0]") and obj["status"] == "done"]
affected_notes = [path for path, obj in locations(source, result).items()
if path.endswith(".notes") and obj == ["checked-by-first"]]
return {"case": name, "input_shape": "shared_steps_only" if shared_steps_only else "repeated_plan",
"all_values_initially_equal": True, "flags": flags, "relationships": graph,
"stages": stages, "affected_steps": affected_steps, "affected_notes": affected_notes}
def main():
cases = [
one_case("assignment", lambda plans: plans),
one_case("shallow_collection", lambda plans: list(plans)),
one_case("shallow_each_plan", lambda plans: [dict(plan) for plan in plans]),
one_case("deep_collection", deepcopy),
one_case("deep_each_plan", lambda plans: [deepcopy(plan) for plan in plans]),
one_case("distinct_plans_shared_steps", deepcopy, shared_steps_only=True),
]
expected = {
"assignment": (True, True, True, False, 4, 4),
"shallow_collection": (True, True, True, False, 4, 4),
"shallow_each_plan": (False, True, True, False, 4, 4),
"deep_collection": (True, True, True, True, 2, 2),
"deep_each_plan": (False, False, False, True, 1, 1),
"distinct_plans_shared_steps": (False, True, False, True, 2, 1),
}
rows = []
for case in cases:
actual = tuple(case["flags"].values()) + (len(case["affected_steps"]), len(case["affected_notes"]))
assert actual == expected[case["case"]], (case["case"], actual)
rows.append({"case": case["case"], **case["flags"],
"changed_step_paths": len(case["affected_steps"]),
"changed_note_paths": len(case["affected_notes"])})
# Value equality alone cannot identify the graph before mutation.
assert cases[3]["stages"][0]["values"] == cases[4]["stages"][0]["values"]
report = {"python": sys.version.split()[0], "assertions": "passed",
"scope": "Six deterministic ordinary dict/list cases; sequential mutations; no workers, threads, model, files or sockets are copied.",
"identity_labels": "Local stable labels per case, not memory addresses or persistent IDs.", "cases": cases}
Path("copy-aliases-report.json").write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8")
with Path("copy-aliases-summary.csv").open("w", newline="", encoding="utf-8") as file:
writer = csv.DictWriter(file, fieldnames=list(rows[0]))
writer.writeheader()
writer.writerows(rows)
graph_rows = [{"case": case["case"], **row} for case in cases for row in case["relationships"]]
with Path("copy-aliases-objects.csv").open("w", newline="", encoding="utf-8") as file:
writer = csv.DictWriter(file, fieldnames=list(graph_rows[0]))
writer.writeheader()
writer.writerows(graph_rows)
print(json.dumps({"assertions": "passed", "cases": len(cases), "graph_rows": len(graph_rows),
"deep_collection_changed_step_paths": len(cases[3]["affected_steps"]),
"deep_each_changed_step_paths": len(cases[4]["affected_steps"])}))
if __name__ == "__main__":
main()
怎样运行:Python 3.10+,标准库。保存为 copy_aliases.py,在独立练习目录运行 python -B copy_aliases.py;不要加 -O,验收使用 assert。 2026-10-16 已用 Python 3.14.0 执行这份代码并通过自带检查;下方是当次输出。普通 Python 字典和列表的六组顺序修改及对象关系;不复制连接、文件或进程,也不证明并发隔离。
运行后对照结果
{"assertions": "passed", "cases": 6, "graph_rows": 108, "deep_collection_changed_step_paths": 2, "deep_each_changed_step_paths": 1}把复制要求写成可检查的所有权约定
移到应用时,写出每个执行者允许修改哪些字段、哪些内容应该联动、哪些必须不受影响。用一个可识别的标记修改其中一份,再检查其他计划和原模板,能够把含糊的“给他一份副本”变成具体的验收要求。
本实验只在一个进程内顺序修改普通字典与列表,没有真实并发、线程隔离、模型调用或持久化。对象分开不能替代并发同步,复制成功也不证明参数适合执行。这里只验证已声明的对象关系与变化传播,不把它扩大成 Agent 执行安全证明。
记住这些要点
- 与原稿隔离、批内彼此隔离是两个目标。
- 一次 deepcopy 会保留已复制图中的共享关系,这通常是设计需要。
- 内容相等和外层对象不同都不能证明嵌套状态独立。
- 按所有权构造计划,并同时保存对象关系与变化记录。
别把例子用过头
- 把列表里出现两次当成创建两份计划。
- 只检查外层字典不同,漏掉共享的步骤列表。
- 把四条变化路径误报成四个不同对象被修改。
- 为了打散共享而忽略原本需要联动的资料。
自己试一小步
三份计划中,一部分应共享、一部分应独立,你怎样验证这个要求?
- 自行设计三份不同名称的计划,分别包含可变草稿、共同检查单和每份独立便条。
- 先画出希望得到的对象关系,再比较整批复制、逐份复制与显式构造三种方法。
- 按预先固定的顺序修改至少三个嵌套位置,保存对象关系表与每阶段值快照。
- 提交哪些变化应该传播、哪些不应传播的说明,保留不符合预期的对照,不接真实执行工具。
做到这些,就算完成
- 值相等与对象相同是否分开记录?
- 是否分别检查来源、其他计划与当前计划的嵌套字段?
- 是否说明共享关系被保留或打散的业务依据,而不是默认越独立越好?
查原始资料
想确认一个细节,先看这份资料对应的部分,不必从头读完所有文档。
- Python 3.14:copy (新标签页)↗
核读赋值、浅/深复制、memo 与自定义复制边界,支撑一次复制过程记录已复制对象。
- CPython 3.14.0:copy.py (新标签页)↗
实际核读固定标签 raw 源码中 deepcopy、
_deepcopy_list、_deepcopy_dict,追踪 memo 查询与共享副本复用。 - Python Programming FAQ:多维列表 (新标签页)↗
核读重复列表仅重复引用及每行重新构造的解释,用于核对模板分配问题。