【每天一个 Python 小案例17】Python 实现通讯录

【每天一个 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 的作用。

五、知识点总结

  1. json.dump 与 json.dumps 的区别
  • json.dump(obj, f):把对象写进文件对象;
  • json.dumps(obj):把对象转成字符串返回。

读的时候同理:json.load(f) 读文件,json.loads(s) 读字符串。名字差一个 s,用途完全不同。

  1. ensure_ascii=False

默认 ensure_ascii=True,中文会被转成 \uXXXX 转义序列。文件能存能读,但没法直接看。加上 ensure_ascii=False 并指定 encoding="utf-8",中文就能原样显示。

  1. indent=2 让文件可读

不加 indent 时整个 JSON 挤在一行,方便程序、不方便人。加了缩进之后结构一眼就清,出问题也好手改。

  1. 二级字典
python 复制代码
contacts["张三"]["phone"]

外层键找"人",内层键找"字段"。这种"一层索引 + 一层属性"的结构,在真实项目里到处都是(配置、接口返回、数据库记录)。

  1. global 关键字

load() 里要重新赋值 contacts,必须在函数内声明 global contacts,否则只会建一个同名的局部变量,外面的还是空的。

如果只是 contacts[x] = y 这样的修改,不声明 global 也能生效------因为它没改变量指向,只改了变量里面的内容。这个区别是初学者最容易踩的坑之一。

  1. os.path.exists

读写之前先判断文件是否存在,避免"第一次运行就报错"。比直接 try/except FileNotFoundError 语义更清楚,两种写法可以配合用。

  1. 内存操作 + 退出统一保存

每次操作都写文件,代码更简单但速度慢(还要处理写入中断);统一在退出时保存,效率高但崩溃会丢数据。本例选了后者,可以再补一个"每次修改后自动保存"的开关作为练习。

六、常见问题与易错点

  1. JSON 文件里全是 \u5f20\u4e09

忘了 ensure_ascii=False。加上就正常了。

  1. 中文乱码或报 UnicodeDecodeError

文件用 utf-8 写的,读的时候却用了系统默认编码(Windows 上是 GBK)。读写两头都要显式写 encoding="utf-8"。

  1. 第二次启动数据"没了"

load() 里忘了 global contacts,读进来的数据赋给了局部变量,main() 里的 contacts 一直是空字典。

  1. json.dump 报 "Object of type xxx is not JSON serializable"

JSON 只认 dict / list / str / int / float / bool / None。如果往里塞了 datetime、set 或自定义对象,得先转成字符串或列表。

  1. 存了元组,读回来变成列表

JSON 没有元组类型,("a", "b") 存进去、读出来是 ["a", "b"]。需要元组时自己 tuple() 转一下。

  1. 单引号和双引号

JSON 规范只接受双引号。手改文件时写成 {'name': '张三'} 会报 JSONDecodeError。所以代码里加了那一层 except,提示"内容不是合法的 JSON"。

  1. 删除后文件没变

删的是内存里的字典,没选 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 学习更有节奏。

相关推荐
爱你真是太好了26 分钟前
Skill 索引瘦身与渐进加载
python
贾公子29 分钟前
检测链路的容量规划——用 Little's Law 算清 QPS、并发与积压
后端·python
海螺_AIHCC34 分钟前
LLM 返回的 JSON 十次坏三次,别急着骂模型——60 行容错解析器先兜住(附可运行代码)
人工智能
露天赏雪38 分钟前
第3篇:Sentinel熔断降级实战:大模型接口抖动时的服务兜底方案
java·开发语言·人工智能·spring boot·spring·servlet·sentinel
lisw0539 分钟前
理解推理大语言模型的可监控性!
人工智能·语言模型·自然语言处理
天天被压力1 小时前
【A股分钟级高频数据完全手册 #06】北交所分钟级数据接口:京市股票Pro版最新分时
java·人工智能·python
在所不辞兄1 小时前
为什么学习率预热(warmup)能显著改善Transformer等大模型的训练稳定性?
人工智能·深度学习·神经网络·机器学习·智能仿真
天天被压力1 小时前
【A股分钟级高频数据完全手册 #05】指数历史分钟数据接口:Pro版沪深指数分时回放
java·人工智能·python
ATMQuant1 小时前
量化策略开发08:Lasso因子选择 - 因子动物园的断舍离
python·量化交易·lasso·vnpy·多因子选股