小叶用AI整理好了几条活动问卷。程序能统计选项,也能把每条记录打印出来。可当同学问他“明天从哪里继续处理”时,他发现自己只有一个仍在运行的程序和一屏输出。结果在眼前,并不等于已经成为一份可以交接的资料。
上一章解决了多个程序怎样共同使用规则。本章再向前一步:程序中的数据怎样保存到文件,下次怎样重新读回;读回以后,怎样知道编号、文字、数量和原来的意思都保住了。文件路径、字符编码、读写模式、CSV与JSON,会沿着这个问题逐步出现。
本章仍用问卷处理作例子。以下三条资料均为虚构教学记录,选项已经规范为YES或NO。我们从这一步开始,集中看怎样把记录保存下来,并在下次运行时完整取回。
| 编号 | 规范选项 | 备注内容 |
|---|---|---|
| 001 | YES | 希望增加交流,安排在下午 |
| 002 | YES | 第一行是喜欢"案例",第二行是希望加练习 |
| 003 | NO | 空文本,表示已确认没有留言 |
第一条备注中有一个英文逗号;第二条备注中有一对英文双引号,并且两段文字之间有一次真正的换行。它们都属于备注本身,保存时需要保留。编号是用于辨认记录的文本,开头的零也属于编号;它不是要参与加减的数量。第三条的空文本已有明确含义,不能把它理解成尚未确认有没有取得备注。
最终,小叶希望得到一份便于按行列交接的CSV,以及一份同时包含明细和统计结果的JSON。两种文件服务于不同使用方式,但读回明细后都应与这组三条资料相符。我们不拿这些教学记录推断真实活动的满意度。
一、数据在内存里、显示在屏幕上、保存成文件,是三件事
程序运行时,列表、字典等对象保存在进程能够使用的内存中。函数可以继续读取或修改它们。程序结束以后,下一次启动不会自动恢复这些对象。若想以后使用,必须事先把需要的信息写到某种能够留存的位置,并安排怎样取回。
文件可以理解为存储设备上按名称组织的一段数据。程序通过文件系统给出的位置来访问它。对小叶而言,保存文件的直接价值是让结果跨过这一次运行:同学不用保留小叶电脑里那个列表对象,也能够从交接的文件重新取得记录。
打印主要解决的是当前怎样展示。屏幕上的一个字典,可以供小叶观察,却未必已经写进某个结果文件。某些运行环境会另外保存日志,但那是环境的另一个安排,不能从一条普通print推出问卷已按要求保存。
同样,源码里写着三条样本,也与保存处理结果不同。下次运行源码确实可以重新建立这三条数据,但真实任务中的输入可能已经变化,处理规则也可能更新。如果需要保留本次确认的结果,应明确保存这次的产物,而不是默认以后重新运行一定得到同样内容。
先把本章共同样本放在一个新的file_demo目录中,文件名为sample_data.py,完整内容如下。后面的主工程示例都从它取得同一组三条记录,减少反复抄写导致的差异。
records = [
{
"record_id": "001",
"answer": "YES",
"note": "希望增加交流,安排在下午",
},
{
"record_id": "002",
"answer": "YES",
"note": '喜欢"案例"\n希望加练习',
},
{
"record_id": "003",
"answer": "NO",
"note": "",
},
]
这里的\n在Python字符串中表示一个换行字符。第二条备注实际不是反斜杠和字母n连成的两个普通字符。单引号与双引号在这段源码中帮助界定字符串,内部那一对英文双引号则是备注数据,要原样保存。
准备好内存对象,只是起点。接下来的保存至少要回答三个问题:写到哪里,按什么格式表达,读取者按什么约定理解。遗漏任何一项,都可能出现“程序已经执行过,接收者却无法继续工作”的情况。
二、路径先决定程序到底在读写哪一个位置
路径是用来定位文件或目录的表达。比如answers.csv只给出了一个文件名,而D:/study/file_demo/answers.csv还给出了从盘符开始的位置。后一个路径只是本节假设的目录,不要求读者电脑上真的存在,也不要直接把它当作自己的交付位置。
从明确的根位置出发定位文件,称为绝对路径。相对于某个起点来解释的路径,称为相对路径。Python中普通文件读写使用相对路径时,通常以进程的当前工作目录为起点。它是本次运行正在使用的目录,不必与源码文件所在目录相同。
这个区别非常实际。假设小叶的脚本保存在D盘的学习目录,他在终端先进入另一个目录,再用完整路径启动这个脚本。脚本里的open("answers.csv")仍然按照当前工作目录查找,不能仅因脚本在学习目录就断言会读取它旁边的文件。编辑器、终端和自动任务也可能各自设置不同起点。
可以在file_demo中保存一个独立的check_paths.py,查看这两个事实:
from pathlib import Path
print(Path.cwd())
print(Path("answers.csv").resolve())
第一条输出表示当前工作目录。第二条把这个相对文件名解析成绝对路径,帮助小叶看清此时它会指向哪里。实际文字取决于本次运行位置,没有一条所有电脑都应照抄的标准路径。
Path是Python标准库中用于表达和操作路径的类型。这里先用它观察位置,不要求立即掌握全部文件系统方法。resolve()得到一条绝对路径,也不保证相应文件已经存在;“准备去这里找”和“这里确实有这份资料”是两个需要分别确认的事实。
本章后续主工程采用一个明确约定:先在终端进入file_demo,再从那里启动各个示例脚本。因此示例中的相对文件名都指向这个教学目录。若改变启动安排,就要重新确认路径。将来需要让脚本不依赖工作目录时,可以显式传入文件路径,或按项目约定建立稳定的路径起点。
路径问题常常表现得像数据问题。小叶修改了一个文件,程序仍读到旧内容,也许并不是转换规则没生效,而是读了另一个同名文件;屏幕提示写入完成,文件管理器却找不到结果,也可能是打开了另一个文件夹。先打印实际位置,比在同一段读写代码里反复猜更有依据。
这里需要的是能够确认位置,不是把所有路径都永久写成某台电脑的绝对地址。绝对路径容易定位当前文件,相对路径便于把整个项目一起移动;怎样选择,应看使用者从哪里启动、资料怎样交接。这个判断与上一章检查实际模块来源相似,但模块搜索规则与普通文件路径规则不能直接混成一套。
三、文本需要编码,文件保存的是字节
小叶看到的是汉字、字母和标点,存储设备上保存的却是字节。字节是计算机存储数据的基本单位之一,一个字节包含八个二进制位。字符编码规定文字怎样对应到字节序列,以及读取时怎样从字节恢复文字。
在Python中,str用来表达文本,bytes用来表达字节数据。它们可以转换,但不是同一类型。用一个不涉及文件的独立小例先观察:
text = "小叶"
data = text.encode("utf-8")
print(len(text))
print(len(data))
print(data.decode("utf-8"))
三条输出依次是2、6、小叶。这两个汉字在本例的UTF-8编码中各占三个字节,所以两个字符得到六个字节。这个例子用来说明字符数量与字节数量可以不同,不要据此推广为所有文字都固定占三个字节。
编码把文本转换成字节,解码按相应规则把字节恢复为文本。文件写入和读取若采用不一致的编码,可能报错,也可能显示成乱码。程序没有报错也不自动证明文字完全恢复,某些错误组合仍可能解码成看似可显示但内容不对的字符。
本章对自己生成的文本文件统一明确使用UTF-8。好处是读写双方知道采用同一种编码,不必依赖各台电脑当时的默认设置。遇到已有文件时,则应依据来源约定判断编码;不能为了让读取不报错,就随意丢弃无法解码的内容,然后声称原文已经保住。
文本模式让程序读写字符串,并由文件接口按编码处理字节转换;二进制模式直接读写字节。读取问卷备注适合文本模式,复制一张图片的原始文件则通常需要二进制处理。把所有文件都当作UTF-8文字,不能自动理解图片、压缩包等内容。
扩展名也不能替代编码。一个文件叫notes.txt,仍可能使用不同编码;一个文件叫answers.csv,也不意味着读取者已经知道每个字节怎样还原为中文。本章会把文件位置、字符编码和数据格式逐层说明,因为它们分别回答不同问题。

图中,数据格式负责表达字段与层次,字符编码负责文字与字节之间的对应。普通文本也可以直接编码写入;CSV和JSON则在这之前增加了表达结构的约定。读取时按相反方向取回,才能让文件内容重新成为程序可使用的数据。
四、用with把一次读写的范围说明白
先做一个比完整问卷更小的实验:只保存三条规范选项,每个选项占一行。这会丢掉编号和备注,因此只是基础读写示例,还没有完成开篇的资料交接。把以下内容保存成file_demo里的write_plain.py,再从这个目录启动:
answers = ["YES", "YES", "NO"]
with open("answers.txt", "w", encoding="utf-8") as f:
for answer in answers:
f.write(answer + "\n")
print("本轮选项写入结束")
open打开文件并返回一个文件对象。这里用f接住它,后面的write通过这个对象写入内容。"w"表示以写入方式打开;关于它对旧文件的影响,第六节会实际比较。encoding="utf-8"说明文本按什么编码写入。
with语句把使用文件的一段操作组织起来。进入这段代码后使用文件对象,离开缩进范围时关闭文件,即使使用过程中抛出异常,也会进行相应的关闭处理。小叶因此不需要在每个正常或异常出口分别记住close。
在本例中,每次write交给文件一个选项和换行字符。换行是为了让下一条选项从下一行开始,write本身不会因为调用了第二次就自动另起一行。三次写入完成后退出with,再显示“本轮选项写入结束”,所以提示发生在这段写入与关闭之后。
关闭文件是完成文件使用的重要步骤,但它并没有替小叶核对资料是否完整。程序完全可能顺利保存一份缺备注的文件。这里的结束提示只报告本段写入已结束,不应该改叫“全部问卷已正确交付”。
随后结束这个程序,另建read_plain.py,从file_demo重新启动:
with open("answers.txt", "r", encoding="utf-8") as f:
text = f.read()
print(repr(text))
"r"表示读取。f.read在这里读出剩余全部内容,返回一个字符串,交给text保存。屏幕显示'YES\nYES\nNO\n'。repr提供便于辨认特殊字符的表示,所以换行在这次显示中写成\n;它不是说文件里一定存着反斜杠和字母n。
这次程序没有导入write_plain,也没有使用前一次进程中的answers对象。它从磁盘文件取得文本,完成了一次跨运行的取回。退出with后,文件对象已关闭,但读出来的字符串仍由text持有,后面的打印可以继续使用它。
文本写入通常经过缓冲:程序交出的一小段文字,可能先暂存在内存中,等合适的时机再写出。退出with时会关闭文件,并处理尚未写出的缓冲内容。因此本例先结束写入程序,再由另一个程序读回,观察保存下来的结果。
五、读取位置会前进,空内容也有不同含义
文件对象不仅关联一个文件,还会记录当前读到哪里。若在同一次打开期间连续调用read,第二次通常接着上一次的位置继续,并不会自动回到开头。可以另建read_twice.py,仍读取刚才的answers.txt:
with open("answers.txt", "r", encoding="utf-8") as f:
first = f.read()
second = f.read()
print(repr(first))
print(repr(second))
第一条输出仍是'YES\nYES\nNO\n',第二条则是''。文件没有因此被清空。第一次read已把当前文件对象读到末尾,第二次没有剩余内容可取,所以得到空字符串。换一个新打开的文件对象,又可以从开头读取。
如果小叶把这段现象误判为“读取操作删除了文件”,就会让AI沿错误方向修复。需要区分文件中保存的内容与当前文件对象的读取位置。前者没有改变,后者已经前进到末尾;这与上一章区分磁盘规则和进程对象一样,都是先认清正在观察哪一个状态。
不带大小参数的read适合能够整体放进内存的小文件。本例只有几条文字,一次读取很清楚;若文件非常大,一次全部读入会占用相应内存。可以按行处理,而不是先把所有文本一次取得。
例如另建count_plain.py,用文件对象本身作迭代对象:
counts = {}
with open("answers.txt", "r", encoding="utf-8") as f:
for line in f:
answer = line.rstrip("\n")
counts[answer] = counts.get(answer, 0) + 1
print(counts)
结果为{'YES': 2, 'NO': 1}。循环每次取得一行,去掉行末换行,再更新计数。这里的文件由前面的程序生成,已知每行恰好是一条非空选项,所以可以采用这样的处理;并没有顺便定义任意外来文件中的空行该算什么。
按行读取有助于控制一次取得的文本量,但整个程序是否节省内存,还要看后续做什么。如果小叶在循环里把每行都追加到另一个大列表,全部内容仍会逐渐积累在内存中。本例只保留两类选项的统计字典,才没有另外保存整份文本。
读取时也要辨别空字符串与空行。对readline而言,没有内容可读时返回'';文件中的一行若只包含换行,读取时仍能得到'\n'。这两个结果分别表示到达末尾和读到一条空行,不能在尚未明确任务约定时随意当作同一种数据。
还要注意,这一节的“每行一条”来自我们自己约定的简单文本。后面的CSV允许一条备注内部换行,那时一条记录可以跨多个文本行。读取策略要跟着格式走,不能把此处有效的数行办法直接推广到所有文件。
六、替换与追加应按本次任务的目标选择
打开文件时,模式会影响接下来怎样使用它。最常见的三种是r读取、w写入、a追加。它们对应的实际目标不同,不只是让程序不报错的三个可交换字母。
以w打开一个已存在的文件,会先截断它原有的内容。也就是说,旧内容的丢失并不需要等第一条write才发生;成功以这种方式打开,就已经开始了一次替换。若路径本来不存在而父目录存在且可写,则会创建文件。
这很适合明确要重建的结果文件。例如小叶每次从完整原始记录重新生成answers.csv,希望它只包含本轮结果,w可以表达这项意图。但如果他误把原始输入路径也传给这段代码,就可能在真正读取之前把来源内容清空。
用一个只操作新建教学文件mode_demo.txt的独立例子观察。下面保存为mode_demo.py,从file_demo启动;不要把文件名换成自己尚未备份的重要资料。
with open("mode_demo.txt", "w", encoding="utf-8") as f:
f.write("第一轮\n")
with open("mode_demo.txt", "w", encoding="utf-8") as f:
f.write("第二轮\n")
with open("mode_demo.txt", "a", encoding="utf-8") as f:
f.write("第三轮\n")
with open("mode_demo.txt", "r", encoding="utf-8") as f:
print(repr(f.read()))
最后显示'第二轮\n第三轮\n'。第一轮的文字被第二次w替换,第三次a把新文字放到已有内容之后。追加保留已有内容,但也不会自动替旧内容补上缺失的换行;本例每段自己提供了换行,才形成清楚的两行。
追加适合保留连续的新记录,却不自动去重。假如小叶每次都把完整的三条问卷用a追加到同一文件,第二次执行后可能得到重复的一批。它保住了原内容,却未必符合“这个文件恰好是本轮三条记录”的任务要求。
读取r不会在找不到文件时替小叶创建一份空文件。缺少来源应当被识别为需要处理的条件;若为了让程序继续就改成某种可创建文件的模式,可能把“资料没找到”伪装成“资料已经找到但没有记录”。这会改变后续统计的含义。
因此,选择模式之前先说清本次动作:读取已有输入、替换可重新生成的产物,还是追加一条新事件。文件模式只是执行这些意图的工具。对本章的主工程,原始样本保留在sample_data.py,生成产物另用answers.csv和summary.json;后面的保存不会把输入文件当作输出目标。
七、CSV用字段和引号保住表格关系
前面的answers.txt只保存选项,不能表达编号与备注怎样对应。CSV是一类用分隔符组织表格记录的文本格式。最常见的形式用英文逗号分隔字段,一条记录中的不同字段对应不同列。具体读取还涉及分隔符、引号等约定,并不是任何带逗号的文字都能无歧义地读成表格。
如果每条记录只有001、YES和一段不含特殊字符的备注,直接用逗号连接看起来很容易。但是第一条备注自己就含有一个逗号。若不区分备注内部的逗号与字段分隔,读取者就可能把一份备注误拆成两列。
第二条还更进一步:它含有双引号和换行。原文里的换行属于同一条备注,并不表示另一条问卷开始。保存格式必须让读取者知道,哪里是字段的边界,哪里只是字段内部的文字。这就是不能仅凭肉眼“看着像几列”来判断保存是否正确的原因。
Python的csv模块已经提供这些处理。把下面的完整程序保存为write_csv.py,与sample_data.py放在file_demo同一层:
import csv
from sample_data import records
fields = ["record_id", "answer", "note"]
with open("answers.csv", "w", encoding="utf-8", newline="") as f:
writer = csv.DictWriter(f, fieldnames=fields)
writer.writeheader()
writer.writerows(records)
print("CSV写入结束")
字段在这里是一项有名称的数据,例如record_id或note。DictWriter按给出的字段顺序,把字典中的值放到对应列中。writeheader先写列名,writerows再写出这组三条记录。列的含义和顺序因此是明确约定,而不依赖某个字典恰好怎样显示。
newline=""让csv模块按自己的规则处理换行,避免文本文件接口的额外换行转换干扰CSV记录,尤其涉及字段内部换行时。它与UTF-8承担不同作用:前者关心行结束和CSV处理,后者关心中文怎样对应字节。无需把两个参数背成没有原因的固定搭配。
按这个示例写出文件,普通文本视图中可以看到以下内容。第二条备注确实跨了两行,第三条最后一个字段为空:
record_id,answer,note
001,YES,"希望增加交流,安排在下午"
002,YES,"喜欢""案例""
希望加练习"
003,NO,
第一条备注两侧的双引号把含逗号的文本围成一个字段。第二条备注也被围在引号内,其中原始双引号按这个CSV约定写成连续的两枚双引号。读取器按照相同规则解释后,得到的仍是原来那一对引号,而不是要求小叶在备注中保留两对。
这里有两种不同的“引号”:一部分属于CSV组织字段的语法,另一部分属于备注数据。格式工具负责让它们能区分开来。若手工把看起来多余的引号删掉,可能破坏的正是这种区别。

文件显示成五个文本行,其中包含表头;真正的数据记录仍是三条。图中第二条记录跨越两行,是因为备注内部保留了换行。因此“总行数减一”在本例中不能作为问卷条数。格式读取器应按完整记录解释,而不是每遇到换行就宣布上一条问卷结束。
八、读回CSV,还要保持字段的含义与类型
写完以后,用一个新程序读取更能说明文件是否可以交接。把read_csv.py保存到同一目录,并在write_csv.py结束后再启动:
import csv
with open("answers.csv", "r", encoding="utf-8", newline="") as f:
restored = list(csv.DictReader(f))
print(len(restored))
print(restored[0]["record_id"])
print(repr(restored[1]["note"]))
第一条输出为3,第二条为001,第三条显示'喜欢"案例"\n希望加练习'。它们分别确认读取器取得三条记录、编号的前导零保住了,以及第二条备注的引号和换行仍属于一个字段。第一条备注内部的逗号也由读取器保留在note中。
DictReader把各列对应到字段名称。在这个有表头的文件中,没有另行提供fieldnames时,它把第一条记录当作列名,后面的记录才作为数据返回。若来源文件没有表头,就要另外给出相应列名,不能默认第一条真实数据可以拿来充当表头。
本章使用csv模块的通常读取方式,各字段读回后是字符串。001不会自动变成整数1,这恰好符合编号的用途。也正因为这样,若CSV另有一列写着数量2,读回通常是文本"2",需要参与运算时再按该列约定转换为数字。
关键不是“所有看起来像数字的内容都转int”。编号001和数量1虽然都用数字字符写出,用途却不同。将编号转为整数,会丢掉前导零;将金额、日期或缺失标记一律套同一种转换,也可能改变资料含义。先认清字段承担什么作用,再决定程序中的类型。
表格软件也可能按自己的规则解释CSV中的文本,例如把某些编号当作数字显示。那是另一个读取步骤,不能用Python保住了001来保证所有软件都会保留相同显示。交接时应说明字段含义,并在接收方实际采用的读取方式下核对关键字段;本章没有模拟所有表格软件的导入行为。
CSV也没有自动给每一列规定完整的缺失值语义。本例003的note为空,是已经确认没有留言。如果另一个来源用空字段表示尚未采集,或者用某段特殊文字表示不适用,就需要另行约定。读取器取出空文本,不等于已经替小叶判断了为什么空。
有时文件能够被读取,列结构仍不符合预期。例如某一条记录少了字段,DictReader可能用None填充缺少的值;多出字段也可能以额外内容的形式返回。这些行为不是自动修好了缺失资料。小叶仍要检查表头、字段是否齐全,以及额外内容应怎样处理。
实际合作时,可以把约定说得很具体:“record_id保持文本;answer只允许YES或NO;note原样保留逗号、引号和换行;空note在这份资料中表示确认无留言。”这比只要求AI“输出干净的CSV”更能决定保存与读取应该做什么。Python的CSV说明提供了相关读取规则,继续遇到不同分隔符时再按来源格式查阅即可。
九、JSON适合表达记录之外的层次关系
小叶现在不仅想交出三条明细,还想把统计结果、资料说明和明细一起交给下一个程序。CSV很适合一张按行列展开的表,但若直接在表格顶部插入几行说明、末尾再塞一张结构不同的统计表,读取者就需要额外规则才能知道哪些行属于哪一部分。
JSON是一种用对象、数组、字符串、数字等形式表达数据的文本格式。对本章的Python程序,可以先把JSON对象对应为字典、数组对应为列表:字典表达按名称取得内容,列表表达按顺序排列的一组内容。它适合保存“一个结果里有资料说明、有多条记录、另有统计”的层次。
这样的结构不是因为更时髦才有价值,而是让下一个程序能明确访问所需部分。例如result中的records放逐条问卷,counts放选项数量。明细的note仍是一项字符串,统计中的数量则保留为数字,不必让接收者从一段总结句中重新猜出来。
把以下完整程序保存为write_json.py,与sample_data.py同层:
import json
from collections import Counter
from sample_data import records
counts = Counter()
for record in records:
counts[record["answer"]] += 1
result = {
"source": "虚构教学问卷",
"records": records,
"counts": dict(counts),
}
with open("summary.json", "w", encoding="utf-8") as f:
json.dump(result, f, ensure_ascii=False, indent=2)
print("JSON写入结束")
Counter用来计数,尚未出现的选项按0起计,所以可以直接逐条加1。前半按三条已规范选项统计;后半把三个有名字的部分放进同一个字典,再写成JSON。序列化就是按某种格式把程序中的数据结构转换成可存储或传递的表达;这里由json.dump把支持的数据结构写成JSON文本。
ensure_ascii=False让中文可以直接作为中文字符出现在生成的JSON文本中,indent=2让层次用缩进展示,方便人阅读。这两个选项都没有改变三条记录或选项数量。文件用UTF-8保存,是open中的encoding决定的,不能把ensure_ascii误认成设置文件编码。
反过来,即使JSON把某些汉字显示为\u开头的转义表达,读取后也可以恢复原文字。判断是否乱码,要看采用正确方式解析后的内容,而不只是看文件里是不是直接出现汉字。同理,备注内部换行在JSON字符串中会被转义表示,解析后仍是一项真正包含换行的文本。
JSON与Python源码也有不同的语法约定。JSON的字符串和字段名使用双引号,真假和空值写作true、false、null,而Python相应值是True、False、None。让json模块负责转换,比把Python字典print出来,再手动给文件改一个.json后缀更可靠。
但JSON不能无条件保存所有Python对象。这里使用的是字典、列表、字符串和整数,属于基本支持范围。文件对象、任意自定义实例等需要额外约定;字典键也要考虑JSON使用字符串字段名的规则。这里三个顶层键本来就是字符串,读回后仍能用source、records和counts取得对应内容。
十、从文件读回结构,与从字符串解析结构要分清
写完summary.json后,另建read_json.py,在新的进程中启动:
import json
with open("summary.json", "r", encoding="utf-8") as f:
restored = json.load(f)
print(restored["counts"]["YES"])
print(type(restored["counts"]["YES"]).__name__)
print(restored["records"][0]["record_id"])
三条输出依次为2、int、001。json.load从文件对象读取JSON并恢复相应的Python结构,这一步称为反序列化。统计次数成为整数,编号仍为字符串;读取者能够按原来的字段层次分别取得它们。
这与单纯f.read不同。read取得文件中的文本,json.load还会按JSON语法把文本解释成字典、列表等结构。若只读到一大段字符串,就不能直接把它当作已经恢复的结果字典去访问counts。
json模块还有一对名称很近的方法:dumps把数据转换成字符串,loads从字符串解析。它们不要求这里已经有一个文件对象。可以另存一个独立小例json_text_demo.py来对照:
import json
message = {"answer": "YES", "count": 2}
payload = json.dumps(message, ensure_ascii=False)
decoded = json.loads(payload)
print(type(payload).__name__)
print(decoded["count"])
两条输出是str和2。payload是可以传给后续步骤的JSON文本;decoded才是从这段文本恢复的字典。这里没有写入文件。直接向文件对象写入JSON时用dump,从文件对象读取并恢复结构时用load;生成JSON字符串用dumps,从JSON字符串恢复结构用loads。选择时先看当前拿到的是数据结构、文本还是文件对象,再看下一步要得到什么。
连续保存多个对象时,还要知道它们怎样组成一个文件。普通JSON文档需要有一个完整的顶层值。若小叶先dump一个字典,又在同一个文件后面继续dump另一个字典,就会得到类似{"answer": "YES"}{"answer": "NO"}的相邻片段,通常不能用一次json.load把它们当作一个合法JSON文档读回。
需要保存多条记录时,可以把它们放进同一个列表,再一次dump;本章则把记录列表放在result的records字段中。这样读取者知道一组记录属于同一个结构。另有JSON Lines等按一行一个JSON值组织的约定,但它要求相应的逐行处理,不应在仍称“单个普通JSON文件”时悄悄切换格式。
Python的JSON说明区分了这几组方法,并说明连续dump的限制。遇到AI给出的调用,可以先核对参数究竟是文件对象还是文本,再看恢复出的类型,而不是遇到报错就随机换一个名字相近的方法。
十一、格式能够解析,不等于这份资料满足任务
前面两个格式各自解决了结构表达,但它们不会代替业务判断。一份合法JSON完全可以只有{"message": "完成"},它能被解析成字典,却没有开篇要求的三条问卷。CSV也可能有正确的逗号和引号,表头却叫另一个名字,或者同一编号被重复写入两次。
数据约定规定使用者期待哪些字段、字段是什么类型、允许哪些取值,以及记录之间应满足什么关系。有时也称为数据结构约定或schema。这个概念的作用,是让“文件读开了”之后还有明确的检查对象,而不是再凭整体看起来顺眼来判断。
对本章三条资料,可以先用几项很直接的约定:每条包含record_id、answer、note;编号是文本且不重复;answer来自YES和NO;备注是文本并保留原内容。更严格的真实项目可能还需要数量范围、来源、时间或额外字段,但这些内容应从任务需要生长出来。
例如另一个文件把编号001写成数字1,即使它是合法JSON,也没有保住本章编号的形式。另一个文件把没有取得备注用空字符串代替,若当前任务要求区分“确认无留言”和“资料缺失”,这个转换就抹掉了需要的信息。问题在数据含义,不在JSON能不能解析。
同样,统计字典里的YES数量等于2,并不单独证明明细正确。明细也许漏了一条YES,同时重复了一条别的YES,总数仍然可能碰巧一致。根据交付目标,除了汇总,还应查看编号对应的实际记录、备注等需要保留的字段。
本章的样本很小,最直接的核对方式是读回后与已知源记录比较。真实数据量更大时,也可以在明确的字段和唯一标识下核对,而不需要只依靠一次人工扫视。C10会继续讨论怎样选择有效的测试与验证,这里先把“验证什么”放回数据任务本身。
交给AI的保存任务因而可以这样描述:“从已确认记录生成CSV和JSON,保留文本编号及备注中的标点换行;CSV字段按约定排列,JSON包含明细与统计;写入新结果路径,结束后重新读取并核对。”这是一项具体成果要求,不需要先学完所有文件格式,也比只说“给我导出一下”更能减少误解。
十二、让下一次运行完成一次实际的交接检查
现在把主工程的两种文件放在一起检查。先从file_demo启动write_csv.py和write_json.py,分别生成answers.csv和summary.json;两个写入程序都结束后,再启动下面这个独立verify_files.py。
本例仍使用第一节那份未修改的sample_data.py作为已知源记录。它不是验证程序自己从产物猜出的答案,也没有用汇总数字替代原明细。验证程序的完整内容为:
import csv
import json
from sample_data import records
with open("answers.csv", "r", encoding="utf-8", newline="") as f:
csv_records = list(csv.DictReader(f))
with open("summary.json", "r", encoding="utf-8") as f:
summary = json.load(f)
print(csv_records == records)
print(summary["records"] == records)
print(summary["counts"] == {"YES": 2, "NO": 1})
三条输出均为True。前两项比较本次解析得到的记录列表与已知源列表,在当前字符串字段的条件下,既比较各条记录及顺序,也比较字典中各字段对应的值。因此001、002、003的文本形式,逗号、引号和备注换行都参与了比较。第三项再单独核对当前教学样本的两类统计次数。
这次检查所支持的结论是具体的:使用本章的写入、编码与读取方式,两种文件都能在新的运行中恢复这组三条明细,JSON统计也符合已知样本。它没有证明所有未来输入都正确,更没有替另一个尚未使用的表格软件完成兼容性检查。
回看开篇,小叶的困难已经被逐项处理。内存结果写成了明确路径下的文件;备注内部字符由相应格式保留;读取时采用匹配编码与解析方式;新进程取得明细后,与已知资料和统计要求进行了比较。交接的对象因此是一份能够取回并核对的文件,而不再只是小叶屏幕上曾经出现过的结果。
CSV与JSON也不必争出谁更高级。需要按行列交接明细时,CSV合适;需要把说明、明细和统计组织在同一个有层次的结果中,JSON更直接。若只保存一段笔记,普通文本也可以够用。选择应由接收者怎样使用、哪些信息必须保留决定。
下一章讨论API时,数据可能通过网络传到另一个程序。到那时,路径会变成另一种定位问题,传递过程也会增加新的条件,但今天建立的区分仍有用:取得了一段字节或文本,是否已经恢复所需结构;结构能读取,是否真的满足双方约定。先把本地的一次交接做清楚,就有了继续判断数据流动的基础。
练习与参考分析
练习一:脚本没有移动,为什么结果文件换了位置
小叶把脚本保存在学习目录中。第一次从学习目录启动,第二次从另一个临时目录通过完整脚本路径启动。脚本两次都写open("result.txt", "w", encoding="utf-8"),没有更改当前工作目录的其他代码。他发现两个目录分别出现一个result.txt。这个现象应怎样解释?要让运行位置清楚,可以查看什么或怎样约定?
**参考分析:**相对文件名按当前工作目录解释,脚本的保存位置没有单独决定输出文件位置。两次启动起点不同,相同相对表达可以指向不同目标。可以查看Path.cwd及目标路径的解析结果,再核对是否符合本次交付位置。
若任务希望从固定目录启动,就明确这一约定;若希望调用者自行选择输出位置,可以显式传入路径;若需要与项目位置关联,则按相应规则构造路径。这里不必一律改成小叶电脑上的硬编码绝对地址。真正要解决的是起点和交付位置未说清,而不是再复制一份同名脚本。
练习二:为什么保存失败以后,旧文字已经没有了
小叶原有一个mode_demo.txt,里面保存着一段旧文字。他以w成功打开它,随后因为程序中的另一处错误,没有执行任何write就退出了with。有人说“没有调用write,旧文字就一定还在”。这句话成立吗?如果文件是不可重新取得的输入,应该怎样安排处理结果?
**参考分析:**不成立。成功以w打开已有文件时就会截断内容,不是等第一次write才开始替换。with能够帮助关闭文件,但不会因此恢复打开时已经清除的旧内容。故障发生在write之前,也不能反推原文件没受影响。
若原文件需要保留,应把读取来源和新产物路径分开,先明确结果写到哪里;不要让一段用于生成结果的w覆盖唯一输入。若确实需要替换已存在产物,则依据任务安排相应的保存与核验流程。这里的关键是模式在什么时点改变文件,而不是笼统认为“代码没全部执行完就没有任何效果”。
练习三:数出四行数据,为何只读回三条问卷
一份含表头的CSV在文本编辑器里显示五行,其中一条备注用引号包围,内部有一次换行。小叶按“总行数减去表头”算出四条问卷,用csv.DictReader却读到三条。他应该先断言读取器漏了一条,还是先检查什么?能否直接用每行split逗号代替?
**参考分析:**先检查CSV中的记录边界与备注内部换行。一个字段可以包含换行,因此显示行数不等于数据记录数。本题的五行包含一行表头,某条记录又占两行,读成三条数据记录符合这个格式安排。
直接按每行split会同时忽略字段引号和跨行关系;若备注还有逗号,还可能拆错列。应按文件实际CSV约定读取,再核对每条编号和备注内容,而不是为了让条数符合先前误算去强行改变读取器。若文件本身有引号损坏,需另查来源,不应把当前已说明的合法跨行记录也当成损坏。
练习四:001和2都是数字形状,为何不一起转整数
CSV中一列record_id为001,一列count为2。小叶准备在程序里用count继续计算,同时要求record_id交给同学后仍保留三位形式。AI建议把所有只含数字字符的字段都转换成int。这个建议是否满足两个目标?
**参考分析:**它会把001变成整数1,丢掉作为标识形式一部分的前导零,不满足编号要求。count则表示要运算的数量,按已确认列约定转换为整数可以合适。外观看起来相似,不能替代字段含义的区分。
可以保持record_id为文本,只对count按规则转换,并核对不合法或缺失内容应怎样处理。不能先把编号全部转成数字,再假定以后都能从数值猜回原来有几个零;除非另有明确的固定宽度规则,否则原有形式已经丢失。
练习五:json.dumps执行完,文件为什么还不存在
小叶把一个结果字典传给json.dumps,得到了一个字符串。他认为这个动作已把结果保存为summary.json,关闭程序后却找不到文件。同学又提出,连续两次dump到同一个文件尾部,就能把两份结果合成一个普通JSON文档。分别判断这两个说法。
**参考分析:**dumps返回JSON文本,并没有仅凭这个调用确定文件路径和写入动作。需要保存时,可以把这段文本交给文件对象的write;也可以把原结果字典和可写文件对象交给dump,由它生成并写入JSON。判断方法要回到调用的输入输出,而不是见到“JSON”就以为已经持久保存。
连续dump两个完整对象会产生相邻JSON片段,并不自动构成一个普通JSON文档。若想保存两份结果,可以用列表把它们组织起来再一次保存;若使用其他分条格式,就要明确相应的读取约定。把两次调用执行成功,与读回时能够理解一个完整结构,是不同检查。
练习六:能够解析并且总数相同,是否已经完成交付
小叶交出的文件能正常解析,统计也是YES两条、NO一条。但检查者发现一条编号被重复,另一条编号缺失,第二条备注的换行也被删除。小叶说“格式正确,总数也正确,应该已经完成”。本章的交付约定能支持这个判断吗?
**参考分析:**不能。交付不仅要求统计总数,还要求保留三条编号对应的明细和备注内容。重复某条、漏掉另一条仍可能让选项计数碰巧相同;删除换行也可能不影响计数,却改变了明确要求保留的备注。
应按唯一编号核对实际记录及相应字段,判断源记录怎样进入产物,并查看处理过程中是否发生重复、遗漏或擅自改写。小样本可以直接读回与已知源列表比较;更大任务采用什么验证方式,由数据约定和错误后果决定。总数是有用证据,但它只回答总数这一部分,不能代替明细和文字保留的要求。