qinghuan
← 回到知识探索

AI 直觉 / 动手实验

已经深复制,两位助手的草稿为什么还是一起变?

给两份执行计划共用一个模板,比较整批复制与逐份复制;用对象关系表和修改传播记录,分清与原稿隔离、内部彼此隔离两种目标。

展开本文目录
  1. 下载完整练习包
  2. 先区分两种希望得到的隔离
  3. 打印内容一样,看不出背后有几份对象
  4. 先看三个对照,确定共享藏在哪一层
  5. 整批深复制会保留内部别名,这是设计行为
  6. 需要逐份独立,就在分配边界分别构造
  7. 再加一组:外层不同,内部仍可有共同检查单
  8. 运行六组关系与传播账本
  9. 把复制要求写成可检查的所有权约定
  10. 记住这些要点
  11. 别把例子用过头
  12. 自己试一小步
  13. 查原始资料
开始前,会这些就够了
  • 能读懂 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 的初始值快照完全相同,但对象关系和修改后的结果不同,这组对照应当一起保留。

展开收起代码 · 小实验python · 119 行

长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。

小实验 · python
"""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 会保留已复制图中的共享关系,这通常是设计需要。
  • 内容相等和外层对象不同都不能证明嵌套状态独立。
  • 按所有权构造计划,并同时保存对象关系与变化记录。

别把例子用过头

  • 把列表里出现两次当成创建两份计划。
  • 只检查外层字典不同,漏掉共享的步骤列表。
  • 把四条变化路径误报成四个不同对象被修改。
  • 为了打散共享而忽略原本需要联动的资料。

自己试一小步

三份计划中,一部分应共享、一部分应独立,你怎样验证这个要求?

  1. 自行设计三份不同名称的计划,分别包含可变草稿、共同检查单和每份独立便条。
  2. 先画出希望得到的对象关系,再比较整批复制、逐份复制与显式构造三种方法。
  3. 按预先固定的顺序修改至少三个嵌套位置,保存对象关系表与每阶段值快照。
  4. 提交哪些变化应该传播、哪些不应传播的说明,保留不符合预期的对照,不接真实执行工具。

做到这些,就算完成

  • 值相等与对象相同是否分开记录?
  • 是否分别检查来源、其他计划与当前计划的嵌套字段?
  • 是否说明共享关系被保留或打散的业务依据,而不是默认越独立越好?

查原始资料

想确认一个细节,先看这份资料对应的部分,不必从头读完所有文档。

  • Python 3.14:copy (新标签页)↗Python 官方文档 · 英文

    核读赋值、浅/深复制、memo 与自定义复制边界,支撑一次复制过程记录已复制对象。

    2026-10-16 实际打开并核读所列公开内容;实验仅使用普通 dict/list,不复制连接、文件、自定义类或循环引用。

  • CPython 3.14.0:copy.py (新标签页)↗Python 官方仓库 · 英文

    实际核读固定标签 raw 源码中 deepcopy、_deepcopy_list、_deepcopy_dict,追踪 memo 查询与共享副本复用。

    2026-10-16 实际打开并核读所列公开内容;运行解释器为3.14.0;源码说明不当作其他实现或自定义类行为保证。

  • Python Programming FAQ:多维列表 (新标签页)↗Python 官方文档 · 英文

    核读重复列表仅重复引用及每行重新构造的解释,用于核对模板分配问题。

    2026-10-16 实际打开并核读所列公开内容;本文六组计划和对象账本为原创夹具,不把列表示例推广成任意对象隔离方案。