一张报名表里,已知材料需求的合计算对了,材料却发给了不需要的人,这是什么错误?

小叶检查已取得的数量,发现它们都是合法整数;检查这些数量的加法,也没有算错。问题出在他整理名单时,把编号顺序调整了,却没有同时移动对应的需求。程序保存了所有数字,丢掉的却是“这个数字属于谁”的关系。

使用AI编写程序时,这类错误很值得关注。AI可以很快生成读取、排序、筛选和汇总代码,但这些操作是否应该作用于一个值、一整条记录,还是一组记录,需要从任务里判断。本章围绕这件事理解列表、字典,以及数据怎样组合和改变。

以下另设一组虚构教学记录,不沿用上一章的人数总账。小叶收到三份有效报名,每份来自不同的同学,编号按活动约定保存为三位数字文本。当前按收到记录的顺序整理如下。额外材料数量的单位是份,零表示明确不需要,未确定表示尚未取得数量,没有默认零的约定。

收到的次序 编号 确认状态 额外材料需求
第一份 017 待确认 2份
第二份 003 已确认 0份
第三份 021 已确认 未确定

小叶希望按顺序查看记录,按编号找到同学,并在需要时试改一个方案。他还希望试改不会悄悄改变正式记录。这几个要求,会把我们带到不同的数据组织问题。

一、先确定一项数据是什么,再决定把它放在哪里

最直接的写法,是给每项信息各起一个变量名:第一位同学的编号和状态、第二位同学的编号和状态。三个人时勉强可以管理,到了三十个人,每次增加报名都要增加一组名字。更麻烦的是,程序没有一个明确的整体表示“第二份报名记录”,只能靠作者记得哪些名字属于同一人。

这里需要的数据结构,可以先理解为在程序中组织一组数据的方式。它既规定哪些值放在一起,也影响我们怎样找到、增加、替换和组合这些值。数据结构不是给散乱变量换一套更正式的名字,而是把后续操作需要的关系表达出来。

在这个案例里,有两种关系。三份报名有先后顺序,这使小叶能从第一份读到第三份;每份报名内部又有字段之间的归属关系,编号、确认状态、材料需求共同描述某一份记录。前一种关系适合先用列表认识,后一种适合先用字典认识。两者可以组合,不需要强迫整个任务只选其中一个。

字段是一条记录中有明确含义的一项信息,例如编号字段、状态字段。字段名称回答“这是什么”,字段值回答“这一条具体是多少或处于什么状态”。两份记录都可以有状态字段,但各自的状态值不同。把字段名称和字段值分开,后面的程序就能对不同记录提出同一个问题。

“一项”指多大范围,并不是程序自动规定的。在编号列表里,一项只是一个编号;在记录列表里,一项可以是包含三个字段的整份记录。小叶若想调整名单的显示次序,应当移动完整记录;若只想把017的状态改为已确认,就应当找到这份记录,再改它的状态字段。结构选得合适,这两个操作的区别会直接反映在代码里。

这也解释了为什么不能只问AI“帮我把数据整理成列表”。列表里准备放编号、字段值,还是完整记录,决定了程序后面能保留什么关系。一个更具体的要求是:每项代表一份报名,项内保留编号、状态和材料需求,外层次序表示当前读取顺序。这样的描述已经把组织原则说清了,代码才有可以遵循的对象。

二、列表按位置保存一组项目

Python中的列表,类型名称是list,可以依次放入多个项目。先只保存三个编号:

可复制模板
ids = ["017", "003", "021"]
print(ids[0])
print(ids[1])
print(ids[-1])
print(len(ids))

四次打印依次得到017、003、021和3。方括号中的三个文本构成列表;取值时,方括号里的整数表示位置,这个位置编号叫索引。索引从零开始,因此索引零对应第一项,索引一对应第二项。-1则从末尾取最后一项。len(ids)给出这个列表当前有几项。

这里的零不是编号000,也不是“没有人”,而是列表采用的位置起点。同一个ids[1],日常语言应读作“列表里的第二项”。把这层对应讲清,比逐个记住这几个编号的取值结果更有用:以后小叶要查看第五份记录,就知道要找索引四;有五项时,索引五已经超出范围。

**索引描述当下位置,不描述永久身份。**现在第二项碰巧是003,如果后来把另一份记录插到最前面,003的位置就会向后移。一个编号仍可以标识原来的报名,而它处于第几项会随列表操作改变。尚未发生调整时,两种找法可能指向同一人;调整发生以后,就不能继续把“第二项”当作003的别名。

取连续一段项目时,可以使用切片。例如ids[0:2]取得索引零和一这两项,结果是["017", "003"]。切片从起点开始,取到终点之前;这里终点二不包含在结果里。省略起点的ids[:2]同样表示取前两项,ids[1:]则从第二项取到末尾,得到["003", "021"]。

这种范围约定适合分批处理。小叶先取前两份,再取从第三份开始的其余记录,两个范围可以接起来,不会因为都包含边界而重复取同一项。若只想检查其中一份,直接索引较合适;若想得到一小批供后续处理的项目,切片得到的仍是列表。切片创建新列表,但里面的项目是否也被独立复制,后面还要进一步区分。

索引和切片遇到范围不足时,行为也不同。三个编号的列表没有ids[3],直接取它会产生IndexError,也就是索引越界错误;ids[:10]则取得现有三项,不会凭空补出七项。若任务要求“必须有十份”,切片能够执行不代表十份已经齐全,还需要检查实际数量。这里的差别关系到完成标准,而不只是两种写法的记忆。

列表允许重复项目,也能保存不同类型的值。["017", "017", "003"]的长度是三,表示有三项,不自动证明有三个不同的人。当前主例已经说明三个编号不同,所以长度三可以对应三份不同报名。条件若改成保留每次提交,列表长度就可能是在数提交次数。len只数当前容器的项目,它不会替小叶解释这些项目在现实里代表什么。

三、增加和删除项目时,要分清改了什么、返回什么

小叶又收到编号034的一份报名。先用单纯编号列表观察怎样添加。下面这个小例重新从原来三个编号开始,追加只发生在这一段中。

可复制模板
ids = ["017", "003", "021"]
result = ids.append("034")
print(ids)
print(result)

列表变成["017", "003", "021", "034"],而result显示None。append把一个项目加到列表末尾。它是列表提供的一个方法,可以暂时理解为附在这类对象上的操作;点号说明对前面的那个列表执行操作。

这里同时发生两件不同的事:原列表增加了项目,这次方法调用返回了None。返回值是一个操作结束后交回给调用处的值;它不一定就是被修改的对象,也不一定承载全部工作成果。要查看新增后的名单,应看ids指向的列表,而不是期待result里出现另一份名单。

因此,ids = ids.append("034")会造成一个常见错误:右边先把034追加到原列表,然后返回None;左边再让ids这个名字指向None。名单操作本身已经发生,名字却不再指向那份列表。如果没有其他名字保存原列表入口,后续代码就不能再靠ids访问它。这与“append没有起作用”是两种不同的情况,修法也不同。

理解返回值以后,还能区分两种取走项目的需要。如果只是要让名单不再包含某个位置的项目,可以删除它;如果还想知道刚才移出了谁,可以使用pop。例如重新令ids = ["017", "003", "021"],执行removed_id = ids.pop(1),removed_id会是"003",列表则剩["017", "021"]。这个方法既修改列表,又返回被取出的项目。

这说明不能把“会修改列表的方法都返回None”当作规则。append和负责原地排序的sort,都在完成相应修改后返回None;pop则有取出并交回项目的用途。阅读AI生成的代码时,可以具体查这个操作的返回约定,不必凭方法外形猜测。Python列表方法文档分别说明了这些行为。

还要分清一次增加一项和增加多项。append(["034", "055"])会把这整个小列表作为一个新项目放进去;extend(["034", "055"])则把其中两个编号分别加到外层列表。原来有三项时,前者得到四个外层项目,最后一项又包含两个编号;后者得到五个外层项目。若小叶需要一份平铺的编号名单,后者符合意图;若新项目本来就是“一组编号”,前者也可能合理。

列表为空时,[]表示当前这个列表没有项目。它本身不说明为什么为空:可能活动确实还无人报名,也可能程序只建立了空列表,尚未开始读取。程序中的值需要结合执行时点和已完成步骤解释。后来我们会用状态和执行过程继续研究这件事;在这里,至少应当知道“长度为零”和“已核实无人报名”并不是同一句话。

四、字典把字段名称与字段值联系起来

只保存编号,可以找出读取次序,却还不能回答某位同学的材料需求。先把017的一份记录放在一起:

可复制模板
record = {
    "id": "017",
    "status": "待确认",
    "extra_copies": 2,
}
print(record["id"])
print(record["extra_copies"])
record["status"] = "已确认"
print(record["status"])

三次打印依次得到017、2、已确认。花括号中每一组冒号左右的内容组成一个键值对:冒号前是键,后面是这个键关联的值。这里用"id"、"status"、"extra_copies"三个文本作键。Python把这种按键建立对应关系的类型称为字典,类型名称是dict。

取record["extra_copies"]时,程序按键找到数量二,不必靠作者记住“第三个位置总是材料需求”。这使记录的含义更容易检查。以后增加一个阅读时长字段,材料需求仍按同一个键取得,不因为多了一个字段就改成第四项。

键是约定,不是解释器自动理解的自然语言标签。若一段程序保存"extra_copies",另一段程序却去取"copies",Python不会因为两个英文词意思相关就自动把它们当成同一个字段。中文键也可以使用,但同样需要前后约定一致。让AI生成不同模块时,这种字段对应往往比变量名看起来是否漂亮更重要。

字段键与编号值在这里也不同。"id"回答“这一项信息叫作什么”,"017"回答“这一份的编号是什么”。多份记录都使用键"id",并不会使它们成为同一份记录,因为它们分别位于不同的字典中。在同一个字典内部,一个键只关联一个当前值;不同字典各有自己的对应关系。

record["status"] = "已确认"修改的是这份记录的状态字段,没有同时修改它的编号或材料数量。这个小例给定小叶已经取得确认,再记录该变化;不能因为代码会写“已确认”,就把这行赋值当作同学真的确认了。程序负责保存状态,状态的依据仍要由实际流程提供。

同一个字典中的值可以有不同类型,因此编号文本、状态文本和整数份数能够放在一条记录中;未知数量也可以按本活动约定保存为None。这不是把所有内容变成同一种值,而是让不同类型的信息通过字段名保持关系。字典有三个字段时,len(record)得到三,数的是键值对数量,不能读成三名报名者。

Python要求字典的键是可哈希的对象。哈希值可以先理解为辅助查找的数字标记:对象的这个标记在其生存期间应保持不变,比较相等的键必须有相同标记。标记相同却不一定是同一个键,字典还要检查键是否相等。字符串满足这些要求,普通列表和字典不能直接用作键。本章的编号和字段名称都能用文本表示,已经符合当前查找需要。Python对可哈希对象的说明给出了更一般的范围,不能简单概括为“任何东西都能作键”。

五、把整条记录放进列表,移动时才不会拆散归属

现在把原来三份记录都放进一个列表。每个花括号表示一份记录,外面的方括号表示按读取次序组织的三份记录。下面重新使用开篇状态,017仍是待确认;上一节的确认修改只是独立演示。

可复制模板
records = [
    {"id": "017", "status": "待确认", "extra_copies": 2},
    {"id": "003", "status": "已确认", "extra_copies": 0},
    {"id": "021", "status": "已确认", "extra_copies": None},
]
print(len(records))
print(records[0]["id"])
print(records[0]["extra_copies"])

结果依次是3、017和2。records[0]先找到外层列表的第一项,也就是017那份字典;再用["extra_copies"]从该字典中取得需求。这里的两次查找分别回答“哪份记录”和“这份记录的哪个字段”。即使代码写在同一行,也可以沿这两个问题读懂。

这种把容器放进容器的组织方式称为嵌套。嵌套的价值是表达层次,不是让括号尽可能多。此处一层表示多份记录,下一层表示每份记录的字段,正好对应活动需要。若又给每个简单字段套三层只有一个项目的列表,关系未增加,查找却更绕,就没有相同的理由。

三份记录的次序与字段归属
图 C02-01|三份记录的次序与字段归属

图中每一行是一份完整记录。行的位置可以调整,编号、状态和材料需求仍应同行。把这些字段放在一份字典里,提供了整体移动的单位;以后排序、选取子集时,就可以移动或选取整条记录。

回到开篇的错误。小叶原先分别保存编号列表["017", "003", "021"]与材料列表[2, 0, None],默认同一位置相互对应。为了让打印名单按编号排列,他只把编号列表排序成["003", "017", "021"],材料列表仍保持原次序。于是第一项被读成003需要两份,第二项被读成017不需要,双方需求被对调了。

材料列表的已知数量仍是二加零,已知部分的合计并未变化。这个事实解释了为什么只检查合计发现不了归属错误。发放材料时需要的是“给谁几份”,合计只能回答“已知一共几份”。前者包含的信息更多,一笔正确总数无法补回被拆散的配对。

一种直接修法是从原始对应关系重新构造三份完整记录,再整体重排。如果已经只留下错配后的两列,且没有其他依据,程序不能凭这两列猜回正确配对;应重新读取可靠记录。数据结构能帮助后续操作保持关系,但不会自动修复此前已经丢掉的关系。

为了看清整体移动,不必先展开排序算法。对刚才的records,可以按已知需要写出ordered = [records[1], records[0], records[2]],得到003、017、021的顺序。003的数量零与它一起进入第一项,017的数量二与它一起进入第二项。这里利用三份记录的已知位置演示重排,实际规模扩大时再让程序按字段排序。

新建外层列表并不表示每条内部记录也被复制。整体重排能保持当前字段归属;新旧名单是否仍共享内部记录,则决定了后续修改会影响哪些入口看到的内容。我们会在后面用同一类记录实际观察,避免把“新列表”误当成“完全独立的全部数据”。

六、按编号查找时,字典的键可以承担另一种角色

小叶打印名单时关心顺序,接到同学消息时却常从编号开始查:“003的材料需求是多少?”若只知道当前列表第二项是003,暂时可以取第二项;名单一旦重排,这个记忆就会过期。

当编号在活动中唯一且稳定时,可以再考虑一种组织方式:把编号作为字典的键,把对应的状态和需求作为值。下面重新给出同一组三份记录,但不在内层重复存放编号,因为外层键已经承担这项作用。

可复制模板
by_id = {
    "017": {"status": "待确认", "extra_copies": 2},
    "003": {"status": "已确认", "extra_copies": 0},
    "021": {"status": "已确认", "extra_copies": None},
}
print(by_id["003"]["extra_copies"])
print(list(by_id))

第一次打印得到零,第二次得到["017", "003", "021"]。by_id["003"]按编号取到对应的小字典,再按字段键取数量。前一层的键表示记录身份,后一层的键表示字段名称;两层都采用字典,但在组织中承担不同用途。

list(by_id)把这个字典的键依次取出,放入一个列表。它不是取得所有内层值,也不是把所有字段铺平。由此可以看到,Python字典也有顺序:它保留键的插入顺序。更新一个已有键的值,不会使这个键自动移到末尾;删除后重新插入,才会作为一次新的插入放到末尾。Python字典文档明确规定了这些行为。

因此,不能把列表与字典的区别说成“一个有序,一个无序”。在这里,更有用的区别是取值依据:列表按位置取,字典按键取。字典当前显示017在最前面,也不会使by_id["003"]改为取得017。显示次序和键的对应关系可以同时存在,分别服务不同需要。

插入顺序也不自动等于报名时间顺序。这里按收到记录的顺序建立字典,保留的是接收次序,尚不能据此认定它与实际提交次序相同;如果程序先按编号排序再建立字典,插入顺序反映的就是那次整理顺序。任务若确实需要保留报名时间,应保存相应的时间信息,而不是在以后从容器的显示位置猜测。

编号作为键还需要保持表示约定。文本"003"、文本"3"和整数3是不同的键,字典不会自动替小叶补零或统一类型。上一章讨论的编号格式,在这里直接影响能否找到记录。若不同来源采用不同格式,先决定按什么规则转换,再建立索引;不要一边保存三位文本,一边拿整数去查,却把查不到解释为没有报名。

列表与按编号组织的字典也可以同时存在,但要说清哪些信息共享。小叶可以保留按顺序组织的记录列表,再建立按编号查找的入口。这样做是否复制内部记录、之后修改会传播到哪里,需要明确安排。对于目前三份教学数据,先理解两种组织各自支持什么判断,已经能解释许多AI代码为什么要从一种结构转换成另一种结构。

七、同一个键只能保留一个当前值,历史要另作安排

接下来单独看一次导入,不再假设每个编号只出现一次。017先提交了一次“待确认”,稍后又提交“已确认”。小叶让程序把两次消息依次写入同一个按编号组织的字典。

可复制模板
latest_status = {}
latest_status["017"] = "待确认"
latest_status["017"] = "已确认"
print(latest_status)
print(len(latest_status))

结果是{'017': '已确认'},长度为一。第二次写入的是同一个键,所以它替换了这个键原来的值,没有增加第二个017键。空花括号{}在这里建立的是空字典。

如果任务只需要保留最后到达的一次提交,这种覆盖正好符合要求。但“最后一次写入”不天然等于“最新、有效、应采用的事实”。假设先收到的是已确认,后面因为重试又收到一条更早的待确认消息,机械覆盖就会把当前状态改回去。要判断应采用哪次,需要时间、版本或有效性等依据;只有键相同这一点还不够。

上面的字典保留最后写入的值,无法仅凭最终结果告诉小叶两条消息各在什么时候产生。若他要研究确认过程、查明误改原因或回看此前状态,就需要保存这些提交本身。

例如,这两次提交可放在一个列表里,每项记录编号、状态与教学设定的提交时间:九点为待确认,九点十分为已确认。此时列表长度二数的是两次提交,按编号整理的当前表长度一数的是一个编号。它们没有互相矛盾,只是保存和计数的对象不同。这里的两项属于同一编号的两次提交,不能把提交列表的长度二用作人数。

还可能把一个编号关联到一个提交列表,让"017"对应它的两次消息。这时外层字典负责按编号找到某人的记录组,内层列表负责保留该组的多次提交。这样组合不是重复使用容器,而是增加了“一个人可能有多次事件”的层次。若只问当前状态,仍需根据明确规则从历史中选出应采用的那一次。

因此,当前表与历史表服务的是不同问题。把全部历史塞进每次展示会增加阅读负担;把历史全部覆盖成一个值,又会失去追溯依据。小叶可以按实际需要同时保存原始提交和当前整理结果,但两者的关系应当明确,不能把当前结果说成完整历史。

同键覆盖也解释了为什么“换成字典就完成去重”不够准确。程序确实让每个键只留一个值,却没有说明保留下来的值是否正确、不同文本编号是否其实属于同一人、同一编号是否被错误分给两人。结构能实现某一种合并方式,是否符合业务上的同一性与有效性,还要有另外的依据。

八、键不存在,与键存在但数量未知,是两种信息

回到三份不同编号的材料需求。为了集中看缺失信息,下面只保留“编号对应数量”这一关系:

可复制模板
copies_by_id = {"017": 2, "003": 0, "021": None}
print(copies_by_id.get("021"))
print(copies_by_id.get("099"))
print("021" in copies_by_id)
print("099" in copies_by_id)
print(copies_by_id.get("003", 5))

五次结果依次是None、None、True、False和0。021这个键存在,只是其数量未知;099这个键根本不在当前字典中。get在键存在时返回所存的值,在键不存在时返回默认值;没有另外提供默认值时,默认是None。

所以前两次打印一样,不足以证明两种情况一样。in在字典上检查的是键是否存在,接着的两次in检查把它们区分开。"021" in copies_by_id得到真,说明当前表里有这个键;它不说明对应值非空,更不说明数量已确定。

最后一次调用给了默认值五,却仍返回零,因为003的键存在。默认值只在键不存在时使用,不会因为现有值是零、None或其他在真假判断中为假的值,就自动替换它。若取得021并给默认五,结果也仍是None。把get当作“遇到不满意的值就补一个数”,会误解它真正做的判断。

直接写copies_by_id["099"]会产生KeyError,表示没有找到这个键。使用get可以选择在缺少键时得到一个约定值,但是否应该继续计算,并没有因此解决。若活动原规则是未知量不当作零,使用get("099", 0)虽然不报错,却可能把“不在表中”写成“明确不要材料”。操作执行成功和保留原信息,是两项检查。

这组区别会影响小叶下一步联系谁、核对什么。021已有记录,可以继续取得这份记录的材料需求;099不在当前表中,应先核对编号或当前数据是否已完整读入。如果当前表只包含某一批报名,查不到也不能证明此人在整个活动中从未报名。这里确定的是当前容器的内容,结论范围要随数据范围一起走。

并非所有任务都需要区分所有缺失原因。如果一个临时页面只需显示“暂无可展示的数量”,键不存在和数量未知都可以暂时呈现这句话;但后台是否合并保存,要看后续是否还需要分别处理。显示相同与数据中保留相同信息,并不是同一个选择。小叶可以让页面简洁,同时在记录中保留不同状态。

在原三份材料记录中,已知的两项数量是二和零,合计两份,还有一项未知。这个合计是已知部分的合计。即使程序另外把未知数量替换为零,得到数值二,也不能把它写成“全部需求确定为两份”。上一章对零与未知的区分,在这里多了一层:除了字段值未知,还可能整条记录不在当前容器里。

九、两个名字指向同一个列表,修改会被共同看见

小叶想给一份名单增加试用编号,但不想影响原名单。他先写trial_ids = ids,直觉上觉得给它另起了名字,就得到一个副本。实际看看这段程序:

可复制模板
ids = ["017", "003", "021"]
trial_ids = ids
trial_ids.append("034")
print(ids)
print(trial_ids is ids)

第一行打印的原名单也包含034,第二行得到True。is检查两个名字当前是否指向同一个对象;这里确实是同一个列表。赋值建立了第二个名字与原列表的联系,没有独立制造另一份列表。

可以把这两个名字理解为通向同一份名单的两个入口。通过其中一个入口增加项目,另一个入口再查看时也会看到增加后的内容。这个类比只表达名字与对象的关系,不表示它们是两个自动同步的文件。这里从一开始就只有一个列表对象,根本没有发生两份对象之间的同步。

上一章的整数例子并没有失效。对整数做加法后再赋值,是让名字指向运算后的值;整数本身不会原地变成另一个数。列表则是可变对象,append可以直接改动这一个对象的内容。赋值都建立绑定,差别在于随后进行了重新绑定,还是对可变对象作了修改。

如果现在执行trial_ids = ["055"],它会让试用名字指向新建的一项列表。原来的ids仍指向含四项的旧列表,不会自动变成只有055。这又说明“两个名字曾指向同一列表”和“两个名字永远一起变化”不是一回事。要看的是每一步具体操作。

当小叶确实需要独立调整外层项目时,可以先复制列表:

可复制模板
ids = ["017", "003", "021"]
trial_ids = ids.copy()
trial_ids.append("034")
print(ids)
print(trial_ids)
print(trial_ids is ids)

原名单仍为三个编号,试用名单有四个,最后得到False。copy()创建了一个新的外层列表,于是添加、删除或重排试用列表的项目,不会对原列表执行同一个外层操作。它也没有改变两份名单起初包含的编号文本。

这是一种浅复制。先用这个简单例子理解它的实际作用:外层容器新建了,里面所引用的项目仍来自原来的列表。当前各项目是不可变的字符串,不能通过原地修改某个字符串,把另一份列表中的编号文字也改掉。若把试用列表的第一项替换为别的文本,修改的仍是试用列表的那个位置。

这种独立性足以支持小叶试排编号顺序,却还不能直接推出含完整记录的名单也在所有层次上独立。记录列表里面放的是可以改字段的字典,浅复制之后会有什么结果,需要再往里面看一层。

十、浅复制分开外层,内部记录可能仍然共享

小叶现在想在试用方案中把017的状态改为已确认,正式记录仍保留原来的待确认。下面只取一份记录展示复制关系,避免其他编号干扰观察;这是另一个从初始状态开始的小例。

可复制模板
records = [{"id": "017", "status": "待确认", "extra_copies": 2}]
trial = records.copy()
trial[0]["status"] = "已确认"
print(records[0]["status"])
print(trial is records)
print(trial[0] is records[0])

结果依次是已确认、False和True。两个外层列表不同,里面第一项却仍指向同一个记录字典。修改trial[0]里的状态,实际就是修改这份共享字典;从records[0]再看,自然能看到已确认。浅复制确实执行了,只是它的独立范围没有深入到内部记录。

浅复制分开外层,但共享内部记录
图 C02-02|浅复制分开外层,但共享内部记录

图中两条外层列表分别存在,箭头仍通向同一个内部字典。沿着箭头读代码,就能看清修改到哪里:trial[0]先找到共享记录,后面的状态赋值改的是该记录的字段。无须知道真实内存地址,也可以判断这个变化会从哪些入口被看见。

再比较一个不同操作。重新从待确认记录开始,这次直接替换试用列表的第一项:

可复制模板
records = [{"id": "017", "status": "待确认", "extra_copies": 2}]
trial = records.copy()
trial[0] = {"id": "017", "status": "已确认", "extra_copies": 2}
print(records[0]["status"])
print(trial[0]["status"])
print(trial[0] is records[0])

结果是待确认、已确认和False。这次没有修改原共享字典,而是在试用列表的第一个位置放入一个新字典。原列表仍保留原记录,试用列表改为指向新记录。两段程序看起来都与trial[0]有关,但一个是改这个位置所指记录的内部字段,另一个是替换这个位置所指的对象。

这种区别还会出现在批量建立空记录时。假设小叶先造一个空字典模板,再把同一模板多次放进列表,表面上有三个位置,实际却都指向同一份字典。修改其中一个位置找到的字段,另外两个位置也会看到变化。要为三位同学保留彼此独立的可变记录,就应当分别创建记录对象,不能只靠外层有三个位置判断已经分开。

对本章这些由普通列表、字典和简单值组成的数据,可以使用深复制建立内部记录与原名单分开的试用版本:

可复制模板
from copy import deepcopy

records = [{"id": "017", "status": "待确认", "extra_copies": 2}]
trial = deepcopy(records)
trial[0]["status"] = "已确认"
print(records[0]["status"])
print(trial[0]["status"])

结果为待确认和已确认。第一行从Python的copy模块取得deepcopy这个操作,后面用它建立试用数据。在本例中,试用记录与原记录分开,试改不会沿共享字典回到原名单。若原列表的三个位置本来就引用同一份模板,深复制仍会保留这三个位置在副本内部的共享关系;它不会判断三个位置应该属于三个不同的人。因此,为不同同学分别创建记录,应在建立名单时完成,不能交给深复制补救。

深复制的目的不是让所有数据都尽可能多地复制。若两份视图本来就应当共同显示最新状态,把它们完全分开以后,修改一份反而不会体现在另一份。小叶需要的是实验草稿还是同一正式记录的另一个入口,应当先决定。前者可能需要独立副本,后者可能有意共享。

深复制也不是保存历史或备份文件的全部方案。它在当前程序中创建副本,程序结束后如何保存、以后如何恢复,还属于文件与持久化安排。某些对象代表打开的文件、连接或其他资源,也不能按普通记录字典的方式理解复制。Python复制文档说明了浅复制、深复制及其限制。本章的小例只对这里明确给出的普通数据结构作判断。

十一、结构应当服务操作,不必让所有任务使用同一种容器

到这里,我们已经能按两个方向检查一份结构:它保存了哪些关系,以及改变其中一处会影响哪里。还可以用几个邻近选择来加深这个判断。

如果小叶只想保存两个固定含义的数,例如场地宽与长,可以把它们放在一个元组中。元组也是按位置组织的序列,但不能像列表那样替换某个位置上的项目。若看到(6, 8),仍需约定六和八各代表什么;不可改动并不会自动补出单位或含义。如果读者容易混淆宽长,使用有字段名称的记录也可能更清楚。

元组不能替换某个位置上的项目,也不等于其内部可能包含的所有对象都不可修改。若元组里放的是一个列表,那个列表仍有自己的可变性。它与浅复制的提醒来自同一条关系:容器外层的性质,不应该被自动推广到它内部的每一个对象。具体用途只有两个简单数时,这个复杂情况尚未发生,但概括规则时要避免过度。

如果只关心出现过哪些不同编号,而不需要重复次数和原先次序,可以用集合表达不重复项目。把["017", "017", "003"]转换为集合,得到两个不同编号。它适合回答是否出现过、有哪些不同值;若还需要知道017提交了两次、先提交了谁,就不能只保留这个集合。集合不提供供业务依赖的项目次序,显示出来的顺序也不能当成报名顺序。

这个选择与前面的同键覆盖也不同。编号集合只保留有哪些不同编号;按编号的字典还要为每个编号关联相应内容;提交列表可以把同一编号的不同事件分别保留下来。不能因为三者最后都能数出某个数字,就认为保存的信息相同。Python数据结构教程介绍了元组与集合。

同样,列表中项目能够混放不同类型,是语言允许的能力,不是组织报名记录时的目标。若某一项是完整记录,下一项却只是编号文本,再下一项是材料数量,后续“读取每项的状态”就没有统一对象。混合本身未必错误,例如一条固定格式的小消息可以有不同类型;问题是它是否符合正在执行的操作。

对于反复采用同一结构的记录,最好让字段名称和含义保持一致。允许材料需求为整数或None,是一项已经讲明的状态约定;突然让同一个字段有时保存“联系后再说”的整段文字,则可能让数量统计无法继续。可以另设备注字段保存原因,让数量字段继续承担原来的用途。这是按信息作用分开保存,不是要求把现实压成只有数字的表。

这些选择也适用于与AI协作。小叶可以把一条示例记录和字段约定给AI,要求它解释整理前后各项的对应关系,再用一组包括零、未知和重复提交的小样本检查输出。如果AI建议用字典合并,他可以追问同编号多次提交保留哪次;如果建议复制一份,他可以检查是外层复制还是内部记录也要独立。问题来自实际操作,而非泛泛要求“代码更规范”。

十二、回到小叶的名单:按用途安排数据的去向

现在重新回到开篇三份原始记录,017待确认且需两份材料,003已确认且不需额外材料,021已确认但材料数未知。此前追加、覆盖和试改都是各自明确重建的演示,没有成为这份正式名单的新事实。

小叶要打印按编号排列的名单,应从完整记录出发整体重排。这样003进入第一行时携带自己的零份需求,017进入第二行时携带两份需求,021仍保留未知。最后可报告三份记录、两名已确认者;材料方面报告已知两份、尚有一项未知。每个数量都有相应的计数对象,不把字段数当人数,也不把未知压成零。

同学拿编号来查询时,可以使用按编号的字典入口。来源编号格式应与保存格式相同;没找到时,先判断是当前记录范围内确实没有,还是格式或读取范围不同。确认记录存在以后,再区分数量零与数量未知。这样,查找结果才能支持具体的核对和联系,而不只是让页面出现一个默认数字。

若小叶只想试排不同顺序,可以新建一个外层列表,保留其中的记录引用。此时试排不会重排原列表,但通过试用入口修改某条共享记录,仍会影响正式记录。若试用还包含改状态或材料数量,就需要另外建立独立记录,或者按本例使用深复制。选择依据是准备修改到哪一层,而不是代码里有没有出现一个新变量名。

如果实际流程允许同编号多次提交,保存结构还要照顾历史与当前状态的分工。当前表可以方便查询,原始提交可以提供追溯依据;采用最后到达、最后产生还是最后核实的消息,要按明确要求决定。结构把规则实施出来,并不替我们挑选规则。

本章学会的不是一张容器名称表。列表让小叶按位置组织项目,字典让他按键找到相关信息,嵌套表达一项内部还有哪些字段;修改与复制则决定这些关系会在哪里继续共享、在哪里分开。沿着“哪份记录、哪个字段、哪一个对象”看程序,很多表面上正常运行却改变了含义的地方就有了可以检查的依据。下一章再把这些记录交给条件和循环,让同一个处理规则作用于多份数据。

练习与参考分析

练习一:第三份记录和前三份记录

小叶保存ids = ["008", "012", "030", "041"]。他分别需要取得第三个编号、前三个编号,以及从第三个开始的全部编号。怎样写?若他想确认至少有十份记录,能否只凭ids[:10]没有报错就确认?

**参考分析:**第三个编号使用ids[2],得到"030";前三个使用ids[:3],得到["008", "012", "030"];从第三个开始使用ids[2:],得到["030", "041"]。单项索引返回该位置的项目,切片返回一个列表,二者不仅写法不同,后续可以进行的操作也不同。

ids[:10]只会取得现有四项,没有报错不说明已有十项。需要确认数量时应检查len(ids)是否至少为十。若要求的是十个不同编号,还要另外检查重复,不能从列表长度直接推出不同编号的数量。

练习二:一个名字变成None,另一处为什么还能看到新增项

判断下面程序的输出,并解释要保留ids作为名单入口时应怎样修改。

可复制模板
ids = ["008", "012"]
saved = ids
ids = ids.append("030")
print(ids)
print(saved)

**参考分析:**第一次打印None,第二次打印['008', '012', '030']。saved和ids起初指向同一个列表,append先修改了该列表,再返回None;赋值使ids转而指向None,saved仍保留原列表入口。新增项目没有消失,变化的是名字与对象的联系。

要让ids继续作为名单入口,直接执行ids.append("030")即可。如果还要求保存一份新增前的独立编号列表,起初就应使用saved = ids.copy(),因为单纯saved = ids并不创建历史副本。这个修法针对两种不同需要,不能把更换变量名当成复制。

练习三:总数一样,配对就一定一样吗

一批独立记录中,编号["020", "005", "011"]依次对应材料数量[4, 1, 3]。程序只对编号排序,再按位置把两列配成记录。新的对应是什么?为什么核对材料总数仍可能发现不了错误?怎样组织才能在今后的重排中保留原归属?

**参考分析:**排序后的编号为005、011、020,若材料次序不变,就会被配为005需四份、011需一份、020需三份。正确关系则是005需一份、011需三份、020需四份。三个需求被放到了错误的人身上。

两组数量合计都是八份,说明合计没有保留每个数量的归属信息。应从仍可靠的原始配对构造完整记录,再对整条记录排序;不能先把两列弄乱,再期待增加一层花括号自动恢复对应。若原配对已不可查,需要回到可靠来源确认,而不是从总数八猜测各人份数。

练习四:后到达的消息一定较新吗

某活动规定以提交时间较晚的有效消息作为当前状态。编号008九点十分提交已确认,但九点的一条待确认消息因延迟在后面才到达。两条消息均有效。程序按到达次序执行两次current["008"] = ...,最后保存待确认。字典是否按自己的规则执行了?这是否符合活动规定?需要保留什么信息才能作正确选择?

**参考分析:**字典按同键后写覆盖前写的规则执行了,但到达较晚的消息产生得更早,机械覆盖不符合题目按提交时间选取的规定。应保留提交时间与有效性依据,比较后采用九点十分的已确认。没有这些信息,单靠最终键值无法知道哪条应当保留。

若还需要追溯过程,可另存两条提交记录。当前状态表中一个键与提交列表中两项可以同时正确,因为前者记录一个编号的选定状态,后者记录两次事件。不能把容器数量差异当作程序已经漏掉一位同学。

练习五:默认值替换了谁

给定needs = {"008": 0, "012": None},分别判断needs.get("008", 9)、needs.get("012", 9)和needs.get("030", 9)。如果任务禁止把未取得数量当作确定需求,能否直接把三项结果相加作为总需求?

**参考分析:**结果依次为零、None和九。默认九只用于不存在的030,不替换已存在的零或None。三个表达式能返回结果,不表示它们都是可以合计的真实需求;其中一项仍未知,另一项九只是程序选择的默认值。

直接相加还会遇到整数与None不能这样相加的问题,但消除这个报错不是全部任务。即使把None也替换为九,仍没有获得编号012与030对应的真实需求。应保留已知数量与待核对情况,按任务需要用键存在性和缺失状态分别处理,再说明合计涵盖哪些记录。

练习六:改字段与换记录,影响范围一样吗

小叶从records = [{"id": "008", "status": "待确认"}]出发,建立trial = records.copy()。第一种试验执行trial[0]["status"] = "已确认";第二种试验重新从同样的初始状态开始,执行trial[0] = {"id": "008", "status": "已确认"}。两种试验后正式记录各是什么状态?若只调换记录顺序与还要试改内部状态,分别需要考虑哪种复制范围?

**参考分析:**第一种会使正式记录也成为已确认,因为两个外层列表共享内部字典。第二种保留正式记录待确认,因为只替换了试用列表的第一项,原列表仍指向旧字典。两种情况必须分别重建初始数据,不能先做第一种再接第二种,却声称正式记录从未被改。

仅调整外层顺序时,新建外层列表足以让排序操作独立;如果还会通过试用名单改内部字段,就需要使这些可变记录也按需要独立,可以对本题这样的普通记录使用深复制。反过来,若试用入口本来是为了共同操作同一正式记录,完全分开又可能违背目的。选择应由修改范围与共享要求决定,不能只凭“副本”两个字作判断。