第29章 项目开发三:JSON持久化与异常处理
昨天的程序能搜索、能删除了,但它有一个致命伤:关掉程序,数据全没了。内存就像便签纸,撕下来就完;文件就像笔记本,写上去就一直留着。今天解决的就是这件事,顺便再给程序穿一件盔甲。
今天做两件大事:① 用 JSON 把数据写进文件、启动时读回来——这叫持久化,数据永久保存;② 用
try/except让程序"输错不崩"——用户乱输(把"abc"当编号)程序也不崩溃,这叫异常处理。这两件事是"专业程序"和"玩具程序"的分水岭。做完今天,你的通讯录就是"真正的产品"了:添加数据、关电脑、第二天开机,数据还在;手滑输错,程序微笑提醒而不是当场死机。
为什么需要持久化:内存 vs 文件
持久化(Persistence)= 数据在程序关闭后依然存在。程序运行时的数据存在内存里,内存断电就没(电脑关机,正在编辑的文档不保存就丢);文件存在硬盘里,关机关电都在(保存过的文档,下次打开还在)。
| 对比 | 内存(程序运行时) | 文件(硬盘) |
|---|---|---|
| 速度 | 飞快 | 较慢 |
| 程序关了 | 数据消失 | 数据还在 |
| 怎么用 | 程序里直接读写的列表 | 用 open + json 读写 |
以后凡是"要保存数据"的程序,都按三步走:启动时读——程序开始时,把文件里的数据加载进内存列表;运行时改——用户增删改,都改内存里的列表(程序操作列表最快);改动后写——每改一次,立刻把内存列表写回文件。记个画面:内存是工作台,文件是仓库,干活在工作台,收工时把货搬回仓库。你的 contacts 列表就是工作台,contacts.json 就是仓库。
两步缺一不可:只有内存列表、不写文件 → 关程序数据就丢;只写文件、不读回来 → 启动时列表还是空的,看不到历史数据。还有,别只在退出时保存一次——用户中途断电、强退,数据就全丢了。每改一次存一次,最多丢一次操作的量。
有人会问:那为什么不在每次查看时直接读文件?因为文件读写比内存慢得多。程序里所有操作都对着内存列表做,只在数据变化的那一瞬间写一次文件——这就是"内存是工作台、文件是仓库"的完整含义。
JSON:字典列表的"纸质版"
JSON(JavaScript Object Notation)= 一种通用的数据存放格式,长得很像 Python 的字典和列表,任何语言都能读。你可以把 JSON 文件理解成字典列表的"纸质版":程序里是活的列表,文件里是写下来的文本。它之所以长得像 Python,是因为字典和列表这两种结构太通用了,几乎所有语言都有差不多的东西,JSON 只是把它们固定成一种标准写法。
[
{
"name": "小明",
"phone": "13800000000"
},
{
"name": "小红",
"phone": "13900000000"
}
]
打开文件,一眼就能看懂:
| 看到什么 | 是什么 |
|---|---|
[ ] 方括号 |
对应 Python 的列表(所有联系人) |
{ } 花括号 |
对应 Python 的字典(一个联系人) |
"name": "小明" |
键值对(键和值都用双引号包着) |
| 逗号、缩进 | 分隔和排版,方便人看 |
Python 和 JSON 有三处小差别,但不用你手动管——json 模块会帮你自动转换:
| Python | JSON |
|---|---|
True |
true |
None |
null |
| 单引号 | 双引号 |
你可能想问:为什么不用记事本存?记事本存的是"纯文本",一行行字符串,读回来还得自己拼字典;JSON 天生就是"结构化的字典列表",存进去什么样、读出来什么样。为什么不存成 Python 文件?JSON 是行业通用标准,别的语言(JavaScript、Java)都能直接读,以后做网站前后端传数据都靠它——学会一次,到处能用。
save_contacts:把列表写进文件
save_contacts = 把内存列表"搬运"到 JSON 文件里。用到两个关键写法:with open(...) 和 json.dump(...)。
import json
FILE = "contacts.json" # 文件名常量,全项目统一用这一个
def save_contacts(contacts):
with open(FILE, "w", encoding="utf-8") as f:
json.dump(contacts, f, ensure_ascii=False, indent=2)
逐行拆开:
| 代码 | 意思 |
|---|---|
import json |
把 json 模块请进来(工具包) |
FILE = "contacts.json" |
起一个"全项目统一"的文件名常量,大写表示"不变" |
with open(FILE, "w", ...) |
打开文件,"w" = write 写入模式(把旧内容清空重写);with 保证用完自动关闭 |
encoding="utf-8" |
指定编码,中文必须加,否则乱码或报错 |
as f |
给打开的文件起个别名 f,后面用它操作 |
json.dump(contacts, f, ...) |
把 contacts 列表转成 JSON 文本写进文件 f |
ensure_ascii=False |
中文不要转成 \uXXXX 乱码,直接存汉字(不加它,文件里全是 \u5c0f\u660e) |
indent=2 |
存的时候每层缩进 2 空格,文件可读(不加就是一行挤在一起) |
"w" 模式要当心:它是"覆盖写",每次保存都会把文件旧内容清空、重新写入。这正好符合需求——每次保存时,文件内容 = 当前列表的最新全量数据。
中文相关最容易出问题,三个点记牢:encoding="utf-8" 和 ensure_ascii=False 缺一不可——少了第一个会乱码(Windows 上还容易 UnicodeEncodeError),少了第二个文件里全是转义码,虽不影响读取但没法给人看。另外用 "w" 而不是 "a":"a" 是追加模式,会把新数据叠在旧数据后面造成重复,保存应该用覆盖写。最后,json.dump 的第二个参数是文件对象 f,不是文件名"contacts.json",把字符串当文件传会报 AttributeError。
还有一处容易漏:文件顶部别忘了 import json。漏了它,运行到 json.dump 会报 NameError: name 'json' is not defined,查错方向就歪了。
load_contacts:启动时把文件读回来
load_contacts = 启动程序时,把 JSON 文件"读"回内存列表。难点在第一次运行:文件还不存在,直接读会崩溃。所以要用 try / except 兜底:文件不存在就当"空的通讯录"处理,返回空列表。
def load_contacts():
try:
with open(FILE, "r", encoding="utf-8") as f:
return json.load(f) # 文件在:读出来,直接返回列表
except FileNotFoundError:
return [] # 文件不在:返回空列表
两种情况各走各的:
| 情况 | 过程 | 返回值 |
|---|---|---|
| 第一次运行(无文件) | 打开失败 → 触发 FileNotFoundError → 被 except 接住 |
[] 空列表 |
| 以后运行(有文件) | 打开成功 → json.load 把 JSON 文本变回列表 |
之前存的所有联系人 |
FileNotFoundError 一定要被接住,因为"第一次运行"是必然场景:谁都没有文件,程序不能因为"没文件"就崩溃。说白了就是:去仓库取货,仓库还是空的,那就拎个空箱子上班,别在门口闹罢工。
"r" 是 read(读)模式,只读不写,和保存时的 "w" 区分开。打开模式的口诀就三个字母:r 读、w 写(覆盖)、a 追加。
三个细节:json.load(f) 的参数也是文件对象,不是文件名;except 只接住 FileNotFoundError 这一种——文件在但内容坏了会报 JSONDecodeError(明天会提到处理思路),文件被占用会报别的错,只处理"文件不存在"这一种叫"精准捕获",不吞掉其它错误;最后别忘了在 main() 里启动时调用 contacts = load_contacts(),只定义了函数不调用,程序启动还是空列表。
把 save 和 load 缝进现有代码
接入只有三个改动点,但每处都关键:
改动点 1:main() 启动时读文件
def main():
contacts = load_contacts() # 原来写 contacts = [],改成从文件读
while True:
# ...菜单不变...
改动点 2:add_contact 添加后保存
def add_contact(contacts):
name = input("姓名:")
phone = input("电话:")
contacts.append({"name": name, "phone": phone})
save_contacts(contacts) # 新增:每次添加后立即写文件
print("添加成功!")
改动点 3:delete_contact 删除后保存
if 1 <= num <= len(contacts):
removed = contacts.pop(num - 1)
save_contacts(contacts) # 新增:每次删除后立即写文件
print(f"已删除 {removed['name']}")
改动的逻辑就一句话:凡是"数据发生变化"的操作(添加、删除,以后还有修改),后面都跟一次 save_contacts。查看、搜索不改数据,所以不需要保存。
几个容易犯的错。只改数据不改文件 = 白存,只改文件不改内存 = 假存,每次操作两个都要改到位。别在 show_contacts、search_contact 里加保存——它们不动数据,存了是浪费(文件读写比内存慢)。如果你昨天加了"修改功能",修改后也要保存——凡是 append、pop、contacts[i] = ... 出现过的地方,检查是否都配了保存。
改完这三处,运行验证一下:添加一个联系人,打开 contacts.json 看看内容;删一个,再打开看看。"改动 → 保存 → 文件变化"这个闭环肉眼可见了,心里就踏实了。
try / except:给程序穿盔甲
异常(Exception)= 程序运行中冒出来的"意外",比如 int("abc") 失败。异常处理 = 提前预测意外,安排应对方案。不处理的话,意外会直接让程序崩溃(弹出一堆红字);处理了,程序会走你安排的"逃生通道",继续正常工作。
昨天删除时,用户输入"abc"程序会挂掉:
# num = int(input("输入要删除的编号:")) ← abc 转不了数字,ValueError,程序挂掉
# 今天的正确写法:穿盔甲
try:
num = int(input("输入要删除的编号:"))
except ValueError:
print("请输入数字!")
return # 提前结束本函数,不执行下面的删除
两个分支只走一个:输入 2(正常)→ try 里 int("2") 成功 → 跳过 except → 继续往下执行删除;输入 abc(异常)→ try 里 int("abc") 抛 ValueError → 立刻跳进 except → 打印提示 → return 结束。
为什么用 try 而不是先检查?有些错误能"提前预防"(如编号范围,用 if 判断),有些"防不胜防"(用户输入什么字符,int 只有转了才知道结果)。try 的优势是"试了才知道,错了有后手",适合"输入转换、文件读写"这类不可控操作。
顺便说一句,用户直接按回车(输入空字符串),int("") 一样抛 ValueError,所以这个 try 同时罩住了"空输入"和"字母输入"两种意外。
今天的完整 delete_contact,三层保护全上:
def delete_contact(contacts):
show_contacts(contacts)
if not contacts: # 保护 1:空的没得删
return
try:
num = int(input("输入要删除的编号:")) # 保护 2:输字母不崩
except ValueError:
print("请输入数字!")
return
if 1 <= num <= len(contacts): # 保护 3:编号范围合法
removed = contacts.pop(num - 1)
save_contacts(contacts)
print(f"已删除 {removed['name']}")
else:
print("编号无效")
三层保护各防各的,缺一不可:
| 保护 | 防的是什么 | 手段 |
|---|---|---|
| 1 | 列表为空 | if + return |
| 2 | 输入不是数字 | try / except ValueError |
| 3 | 编号超出范围 | if 范围判断 |
看出门道了吗?保护 1 和 3 是"提前堵",保护 2 是"事后接",两种思路互补。以后写任何带输入的程序,都可以照着这个"能堵就堵、堵不住就接"的套路来。
写 try/except 有三条纪律。第一,except 只接 ValueError 这一种异常,不写 except: 一把抓——否则程序出什么错都装看不见,bug 会被掩盖,不好排查。第二,except 里处理完要 return——不然程序会"接着往下走",用 num 这个没赋上值的变量,又冒出 UnboundLocalError。第三,只有"可能出错的语句"放进 try 里,print、if 这些不会错的别放进去,缩小 try 范围更容易定位问题。
认识常见异常:看懂报错的第一步
报错的最后一行就是异常名,认识它是看懂报错的第一步——你能判断"是哪一类意外",也能在 AI 提问时准确描述。今天开始见一个记一个:
| 异常名 | 意思 | 常见触发场景 | 怎么解决 |
|---|---|---|---|
ValueError |
值不对 | int("abc") 转数字失败 |
try/except 或先校验再转 |
FileNotFoundError |
文件不存在 | 打开不存在的文件 | try/except 兜底(今天刚学) |
KeyError |
字典键不存在 | c["name"] 但键名写错 |
检查键名拼写 |
IndexError |
下标越界 | 删除编号越界 | 边界检查 |
JSONDecodeError |
JSON 文件坏了 | 手动改坏了 json 文件 | 检查格式、删除文件重来 |
TypeError |
类型不对 | 字符串 + 数字 | 用 f-string 格式化 |
其中最容易混的是 ValueError 和 TypeError:ValueError 是"值不合法"(abc 不是数字),TypeError 是"类型不匹配"(字符串不能直接加数字),看报错最后一行就能分辨。
今天列的六个异常里,你已经亲手处理过两个(FileNotFoundError、ValueError),反复见过两个(KeyError、IndexError),剩下两个(JSONDecodeError、TypeError)也都不陌生。等六周结束,这六个名字你会闭着眼说出来。
对异常的态度应该是:"报错不可怕,报错是程序在告诉你'这里有问题'"。看懂异常名 → 定位出错的代码 → 修好,这个流程跑顺了,你就基本出师了。还要分清"友好处理"和"掩盖问题":print("请输入数字") 给了用户反馈,是友好处理;什么都不做才是掩盖问题,两种完全不同。
随堂练习
-
练习一:加文件读写(20 分钟)——在项目文件顶部加
import json和FILE = "contacts.json",加上load_contacts、save_contacts两个函数,然后做三个改动点(main 读文件、添加后保存、删除后保存)。完成后测试:添加小明 → 退出程序 → 重新运行 → 输入 2 查看,小明还在,就说明持久化成功了。 -
练习二:看懂 JSON 文件(10 分钟)——用记事本/VSCode 打开项目同目录下的
contacts.json,对照你的数据回答:① 方括号对应什么?② 花括号对应什么?③ 文件里一共有几条数据?④ 如果删了 indent=2,文件会变成什么样?(答案:所有数据挤成一行)。把这个文件当成你的"仓库账本",看懂了才算真的存住了。 -
练习三:加异常保护(15 分钟)——给
delete_contact的int(input(...))包上 try/except,测试:输入"abc" → 程序不崩溃、提示"请输入数字!";输入 2 → 正常删除。再想想:add_contact里的input需要 try 吗?(答案:不需要,input 永远返回字符串,不会抛异常,只有 int() 转换才可能出错)。
做完练习三,把今天的三个练习连起来看:加文件读写验证持久化、看懂 JSON 文件验证存储、加异常保护验证健壮——合起来正好是"专业程序"的完整画像。
- ★ 进阶题(搜索也加上保护)——现在搜索输入空字符串时(直接按回车),
"" in "小明"结果是 True,会"搜出所有人"。请给search_contact加一个判断:kw为空时提示"搜索内容不能为空"并 return。这个小细节在答辩时提出来,是"界面提示友好"的加分点。
让 AI 帮你查 JSON 问题
场景:JSON 保存后中文变乱码 / 重启数据丢失
提示词:我是 Python 初学者,通讯录项目用 JSON 保存数据。
现在遇到问题:<描述你的问题,例如:保存后 json 文件里中文变成了 \u5c0f\u660e
这样的代码 / 或者重启程序后数据是空的>
相关代码:
<粘贴 load_contacts 和 save_contacts 两个函数>
请用大白话分点解释:1. 为什么会出现这个问题;2. 应该怎么改;
3. 顺便检查我的代码还有没有其它 bug。
报错速查
| 报错信息 | 什么意思 | 怎么办 |
|---|---|---|
FileNotFoundError: [Errno 2] |
打开的文件不存在 | 这是第一次运行的正常情况,用 try/except 兜底返回空列表 |
UnicodeDecodeError: 'utf-8' codec can't decode |
文件编码不是 utf-8 | open 时统一加 encoding="utf-8",删掉旧的乱码文件重新存 |
UnicodeEncodeError |
中文写文件失败 | open 加 encoding="utf-8",json.dump 加 ensure_ascii=False |
AttributeError: 'str' object has no attribute 'write' |
把文件名当文件对象传了 | json.dump 第二个参数必须是 f(open 返回的文件对象) |
JSONDecodeError |
json 文件内容坏了 | 可能手改过文件,检查格式;数据不重要就删掉文件重跑程序 |
UnboundLocalError: local variable 'num' referenced before assignment |
try 里赋值失败后又用了 num | except 里处理完必须 return,不能让程序继续用没值的 num |
收个尾
今天做完,你的通讯录就是"真正的产品"了:数据能存住、输错不崩。今天两个核心动作——持久化三步(启动读、内存干活、改动存)和 try/except 穿盔甲——以后每个项目都用得上,务必吃透。
明天是本周最后一天开发:加进阶功能(修改、数据校验、排序、统计)、整理完整代码、准备答辩讲稿,并把项目提交到 Git。答辩就问三个问题:做了什么、怎么做的、AI 帮了什么。
睡前自己验一遍:添加几条数据 → 关掉程序 → 重开 → 查看,数据一条不少,说明今天的持久化真成了。再把今天这版代码完整跑一遍"添加 → 查看 → 搜索 → 删除 → 重启还在",带着全绿的状态迎接周五。