【每天一个 Python 小案例17】Python 实现通讯录
数据要长期留下来,就该换一种能"整存整取"的格式。
系列:《每天一个 Python 小案例》
第 17 篇:Python 实现通讯录
关键词:Python、字典、json 模块、增删改查、菜单循环、文件持久化
摘要
本篇实现一个命令行通讯录:用"姓名 → 联系人信息"的二级字典管理数据,支持新增、查询、修改、删除、列出全部,并用 json 模块把数据保存到 contacts.json,下次启动自动读回。重点练习 json.dump/load 的用法与常见参数(ensure_ascii、indent)、字典的增删改查、菜单循环的结构,以及 os.path.exists 的用途。
一、案例需求
前面三个案例(录入、查询、排名)用的都是"姓名,成绩"这样的自定义文本格式。这次做一个通讯录,数据变复杂了:一个人有电话、邮箱,以后可能还有备注、分组。
需求如下:
- 启动时自动读入通讯录文件,没有就新建;
- 菜单式操作:1 新增、2 查询、3 修改、4 删除、5 查看全部、0 保存退出;
- 新增时重名要提示,并询问是否覆盖;
- 查询支持按姓名关键字模糊匹配;
- 数据保存成 JSON 文件,重启后还在。
二、思路分析
先说数据结构。
一个人的信息不止一项,用"姓名 → 电话"的单层字典不够用。改成两层:
python
contacts = {
"张三": {"phone": "13800000001", "email": "zhangsan@example.com"},
"李四": {"phone": "13800000002", "email": ""},
}
外层字典的键是姓名,值是另一个字典。这样取值就是 contacts["张三"]["phone"]。
再说存储格式。
之前的 scores.txt 是纯文本,一行一条。如果通讯录还用这种格式,字段一多就得自己定分隔符、自己处理转义,很容易写错。Python 自带 json 模块,字典可以直接存成文件:
python
json.dump(contacts, f, ensure_ascii=False, indent=2) # 存
contacts = json.load(f) # 读
好处是:结构不变形、中文可读、以后加字段不用改读写代码。
流程:
启动 → 读 contacts.json → 进入菜单循环 → 各功能操作内存里的字典
→ 选 0 时把字典写回文件 → 退出
关键设计:内存里操作,退出时统一保存。这样每一次增删改都不用立刻动文件,速度更快,也少了很多"写一半崩了"的风险。
三、Python 代码
python
import json
import os
FILE = "contacts.json"
contacts = {} # {姓名: {"phone": ..., "email": ...}}
def load():
"""从文件读入通讯录"""
global contacts
if not os.path.exists(FILE):
print(f"没有找到 {FILE},将新建一个通讯录。")
return
try:
with open(FILE, "r", encoding="utf-8") as f:
contacts = json.load(f)
print(f"已载入 {len(contacts)} 位联系人。")
except json.JSONDecodeError:
print(f"{FILE} 内容不是合法的 JSON,已忽略,请检查文件。")
contacts = {}
def save():
"""把通讯录写回文件"""
with open(FILE, "w", encoding="utf-8") as f:
json.dump(contacts, f, ensure_ascii=False, indent=2)
print(f"已保存 {len(contacts)} 位联系人到 {FILE}。")
def add():
name = input("姓名:").strip()
if not name:
print("姓名不能为空。")
return
if name in contacts:
answer = input(f"{name} 已存在,是否覆盖?(y/n):").strip().lower()
if answer != "y":
print("已取消。")
return
phone = input("电话:").strip()
email = input("邮箱(可留空):").strip()
if phone and not phone.replace("-", "").isdigit():
print("电话应只包含数字和短横线,本次仍然保存。")
contacts[name] = {"phone": phone, "email": email}
print(f"已保存联系人:{name}")
def search():
key = input("请输入姓名关键字:").strip()
if not key:
print("关键字不能为空。")
return
matched = {n: c for n, c in contacts.items() if key in n}
if not matched:
print(f"没有找到包含"{key}"的联系人。")
return
print(f"共找到 {len(matched)} 位:")
for n, c in matched.items():
print(f" {n} 电话:{c['phone'] or '(未填)'} 邮箱:{c['email'] or '(未填)'}")
def modify():
name = input("要修改哪位联系人?").strip()
if name not in contacts:
print(f"通讯录里没有 {name}。")
return
info = contacts[name]
print(f"当前:电话 {info['phone'] or '(未填)'} 邮箱 {info['email'] or '(未填)'}")
print("直接回车表示保持原值。")
phone = input("新电话:").strip() or info["phone"]
email = input("新邮箱:").strip() or info["email"]
contacts[name] = {"phone": phone, "email": email}
print(f"已更新 {name} 的信息。")
def delete():
name = input("要删除哪位联系人?").strip()
if name not in contacts:
print(f"通讯录里没有 {name}。")
return
answer = input(f"确认删除 {name}?(y/n):").strip().lower()
if answer == "y":
del contacts[name]
print(f"已删除 {name}。")
else:
print("已取消。")
def show_all():
if not contacts:
print("通讯录是空的。")
return
print(f"{'姓名':<8}{'电话':<16}{'邮箱':<24}")
print("-" * 48)
for n in sorted(contacts):
c = contacts[n]
print(f"{n:<8}{c['phone'] or '(未填)':<16}{c['email'] or '(未填)':<24}")
print(f"共 {len(contacts)} 位联系人。")
def main():
load()
menu = """
====== 通讯录 ======
1. 新增联系人
2. 查询联系人
3. 修改联系人
4. 删除联系人
5. 查看全部
0. 保存并退出
====================
"""
while True:
print(menu)
choice = input("请选择(0-5):").strip()
if choice == "1":
add()
elif choice == "2":
search()
elif choice == "3":
modify()
elif choice == "4":
delete()
elif choice == "5":
show_all()
elif choice == "0":
save()
print("再见。")
break
else:
print("输入无效,请输入 0 到 5 之间的数字。")
main()
保存为 contacts.py,运行:
bash
python contacts.py
四、运行结果
text
没有找到 contacts.json,将新建一个通讯录。
====== 通讯录 ======
1. 新增联系人
2. 查询联系人
3. 修改联系人
4. 删除联系人
5. 查看全部
0. 保存并退出
====================
请选择(0-5):1
姓名:张三
电话:13800000001
邮箱(可留空):zhangsan@example.com
已保存联系人:张三
请选择(0-5):1
姓名:李四
电话:138-0000-0002
邮箱(可留空):
已保存联系人:李四
请选择(0-5):2
请输入姓名关键字:张
共找到 1 位:
张三 电话:13800000001 邮箱:zhangsan@example.com
请选择(0-5):5
姓名 电话 邮箱
------------------------------------------------
李四 138-0000-0002 (未填)
张三 13800000001 zhangsan@example.com
共 2 位联系人。
请选择(0-5):0
已保存 2 位联系人到 contacts.json。
再见。
生成的 contacts.json:
json
{
"张三": {
"phone": "13800000001",
"email": "zhangsan@example.com"
},
"李四": {
"phone": "138-0000-0002",
"email": ""
}
}
中文直接显示,没有被转成 \u5f20\u4e09------这就是 ensure_ascii=False 的作用。
五、知识点总结
- json.dump 与 json.dumps 的区别
json.dump(obj, f):把对象写进文件对象;json.dumps(obj):把对象转成字符串返回。
读的时候同理:json.load(f) 读文件,json.loads(s) 读字符串。名字差一个 s,用途完全不同。
- ensure_ascii=False
默认 ensure_ascii=True,中文会被转成 \uXXXX 转义序列。文件能存能读,但没法直接看。加上 ensure_ascii=False 并指定 encoding="utf-8",中文就能原样显示。
- indent=2 让文件可读
不加 indent 时整个 JSON 挤在一行,方便程序、不方便人。加了缩进之后结构一眼就清,出问题也好手改。
- 二级字典
python
contacts["张三"]["phone"]
外层键找"人",内层键找"字段"。这种"一层索引 + 一层属性"的结构,在真实项目里到处都是(配置、接口返回、数据库记录)。
- global 关键字
load() 里要重新赋值 contacts,必须在函数内声明 global contacts,否则只会建一个同名的局部变量,外面的还是空的。
如果只是 contacts[x] = y 这样的修改,不声明 global 也能生效------因为它没改变量指向,只改了变量里面的内容。这个区别是初学者最容易踩的坑之一。
- os.path.exists
读写之前先判断文件是否存在,避免"第一次运行就报错"。比直接 try/except FileNotFoundError 语义更清楚,两种写法可以配合用。
- 内存操作 + 退出统一保存
每次操作都写文件,代码更简单但速度慢(还要处理写入中断);统一在退出时保存,效率高但崩溃会丢数据。本例选了后者,可以再补一个"每次修改后自动保存"的开关作为练习。
六、常见问题与易错点
- JSON 文件里全是
\u5f20\u4e09
忘了 ensure_ascii=False。加上就正常了。
- 中文乱码或报 UnicodeDecodeError
文件用 utf-8 写的,读的时候却用了系统默认编码(Windows 上是 GBK)。读写两头都要显式写 encoding="utf-8"。
- 第二次启动数据"没了"
load() 里忘了 global contacts,读进来的数据赋给了局部变量,main() 里的 contacts 一直是空字典。
- json.dump 报 "Object of type xxx is not JSON serializable"
JSON 只认 dict / list / str / int / float / bool / None。如果往里塞了 datetime、set 或自定义对象,得先转成字符串或列表。
- 存了元组,读回来变成列表
JSON 没有元组类型,("a", "b") 存进去、读出来是 ["a", "b"]。需要元组时自己 tuple() 转一下。
- 单引号和双引号
JSON 规范只接受双引号。手改文件时写成 {'name': '张三'} 会报 JSONDecodeError。所以代码里加了那一层 except,提示"内容不是合法的 JSON"。
- 删除后文件没变
删的是内存里的字典,没选 0 退出(或中途强制关闭窗口),没走到 save()。可以改成每次增删改后立刻保存。
七、可以继续扩展的功能
- 增加"分组/标签"字段(家人、同学、同事),按组查看;
- 电话格式校验(正则
re模块)与邮箱格式校验; - 支持导出 CSV,方便导入手机或其他软件;
- 支持按姓名首字母排序、分页显示;
- 加一个"最近修改时间"字段;
- 把数据换成 SQLite(
sqlite3标准库),体会数据库和 JSON 文件的区别; - 做成带界面的版本(
tkinter的Listbox+Entry)。
建议先把"增删改查 + 存读"这六件事做扎实,再考虑界面------界面只是壳子,数据逻辑才是骨架。
八、总结
本篇完成了通讯录工具:
- 用二级字典
{姓名: {phone, email}}组织联系人数据; - 用
json.dump(..., ensure_ascii=False, indent=2)保存,json.load()读回; - 用
os.path.exists()判断文件是否存在; - 用
global在函数里正确重新赋值模块级变量; - 用
while True+if/elif搭菜单循环,break退出; - 用字典推导式
{n: c for n, c in ... if key in n}做模糊查询。
一句话概括这一篇的重点:数据结构和存储格式要对得上。 结构复杂了,就该换成能表达复杂结构的格式。
下一篇预告
《每天一个 Python 小案例18:Python 实现待办事项列表》
下一篇将继续学习:
- 列表 + 字典的组合,怎么表示"多条、可重复"的数据;
- 用
sorted()的多关键字排序,把未完成的任务排在前面; - 如何标记完成、删除任务,并统计完成率。
每天一个小案例,让 Python 学习更有节奏。