news 2026/10/23 7:40:48

Python面向对象实战:命令行通讯录类设计与JSON持久化实现

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Python面向对象实战:命令行通讯录类设计与JSON持久化实现

做了这么多年 Python 教学相关的项目,我始终觉得"面向对象"是很多初学者跨不过去的一道坎。语法都认识,class、self、方法都能默写,但一到写实际项目就懵了,不知道类到底该长什么样、方法该放哪个类里。所以我一直主张用项目来"逼"着大家把面向对象用起来,而不是先背一堆特性再找场景。

这个简易通讯录项目就是很好的切入角度,代码量不大,逻辑边界足够清晰,而且天然绕不开"文件持久化"——程序一关,数据就没了,这谁受得了。我在这篇文章里会把我做这个项目时的完整思路、类怎么拆、文件格式怎么选、踩过的坑都摊开讲,适合刚学完 Python 基础、想做第一个练手项目的人,也适合想弄清楚"类到底怎么用"的同学。

1. 先把需求想清楚:通讯录到底要做什么

很多同学拿到题目第一反应是打开编辑器直接写代码,这其实是最大的误区。通讯录看着简单,但你如果连它要解决什么问题都没想明白,写出来的代码大概率是"一次性的脚本",用完就废。我习惯先花十几分钟把需求一条条列出来,再考虑用什么代码结构去满足它。

1.1 核心功能清单

一个能用的通讯录,最少得具备下面几件事:

  • 添加联系人:录入姓名、电话号码,最好还能存邮箱、备注这类附加信息
  • 删除联系人:按姓名或电话删掉某个人
  • 修改联系人:电话换了、邮箱变了,要能更新
  • 查询联系人:输入关键字,能按姓名或电话模糊搜索
  • 列出所有联系人:方便通览
  • 数据要能保存:程序关了再打开,之前录的联系人不丢

用户操作界面不需要花哨,命令行菜单就行。这里的关键在于,"保存"这件事必须贯穿始终,而不是额外附加的选项——很多人写项目时把"保存"做成菜单里的一项,让用户记得手动点,这不太合理。正常人的思维是:"我录入了就希望它一直在,下次打开就能看到。"所以保存的时机应该在每次数据变更后立即触发,而不是靠用户主动保存。

1.2 为什么这个项目适合练面向对象

有人会问:通讯录用字典和列表也能做啊,干嘛非要面向对象?

确实能做。你可以用一个列表套字典的结构,比如contacts = [{"name": "张三", "phone": "138..."}],然后写一堆add_contact()、delete_contact()函数。小项目这么搞没问题,但你要再往上走一个台阶——比如加上分组、加上多字段排序、以后迁移到数据库——这一堆游离的函数就会迅速失控,它们到处操作同一个全局变量,谁也说不清哪个函数改了什么。

面向对象的思路是把"联系人"变成一个有身份的对象——Contact,把"通讯录"变成管理一批联系人的容器——AddressBook。联系人自己知道怎么展示自己、怎么转换成可存储的格式;通讯录知道怎么增删改查、怎么保存和加载。数据和操作被绑定在同一个实体里,代码的自然组织方式就出来了,而不是靠程序员靠记忆硬撑。

我说句实在话,等项目规模到了几百行,面向对象和面向过程的差距会越来越明显。面向过程的代码里,你改一个数据结构,所有相关函数都得跟着改;面向对象里,你只需要改对应的类。

2. Contact 类与 AddressBook 类:职责怎么划分最合理

类的设计没有唯一标准答案,但有一条原则我特别想强调,就是"每个类只关心自己的事"。很多人把类拆得太碎,一个项目搞出七八个类,反倒看不懂了;也有人把所有事堆在一个类里,那跟面向过程也没什么区别。

2.1 Contact 类:一个联系人自己该会什么

Contact类代表单个联系人,它应该管的是"我是谁"和"我怎么描述自己"。我设计的字段有:姓名、电话、邮箱、备注,构造函数里给邮箱和备注设默认空字符串,这样 add 的时候不是每次都得传四个参数。

一个联系人需要具备的能力有这么几个:

  • __str__()定义打印格式。直接print(contact)就能看到姓名: 张三 | 电话: 138... | 邮箱: ... | 备注: ...,不用在外面拼字符串
  • to_dict()把自身转成字典,供 JSON 序列化用
  • from_dict()是一个类方法,从字典还原出一个Contact对象,供反序列化用

这三个方法配在一起,Contact就能完成"自描述、可存储、可重建"的完整闭环。我特意提到from_dict是类方法而不是下面的普通方法,因为它需要从一个字典构造一个全新的实例,用@classmethod的写法更清晰,调用的时候直接Contact.from_dict(data)就行。

2.2 AddressBook 类:通讯录容器负责组织和持久化

AddressBook内部维护一个self.contacts列表,所有对列表的操作都封装成方法。它的职责范围:

  • add()追加联系人并立即保存
  • remove()按姓名删除,找到就删除并保存,没找到要明确告诉用户
  • update()按姓名定位联系人,修改字段并保存
  • search()遍历支持姓名、电话关键字的模糊匹配,返回一个结果列表
  • list_all()打印全部联系人,没有联系人的时候也得有提示
  • save()把整个列表写入文件
  • load()启动时从文件读取数据

我这里的update()是按姓名查的,因为新手阶段用姓名最直观。但你要注意一个隐藏问题:姓名相同的人怎么办?后面我会专门聊这个坑。

连接Contact和AddressBook的逻辑一句话就能说清:AddressBook负责"管理一批 Contact 对象",Contact不知道AddressBook的存在。方向是单向的,依赖关系非常干净。

2.3 先画类图再写代码

我习惯在两个类之间画一个简单的类图,就很土的那种方框加连线的图,不需要用建模工具,手画都行。

Contact - name, phone, email, remark + __str__() + to_dict() + from_dict() (类方法) AddressBook - contacts: list - filename: str + add(contact) + remove(name) + update(name, ...) + search(keyword) + list_all() + save() + load()

这一步看似多余,其实作用是帮你把"方法该写在哪"提前定了,后面写代码不会再犹豫。我见过不少同学写 update 功能时,居然去 Contact 里写方法,然后在 Contact 里访问 AddressBook 的列表——绕了一圈,把两个类的耦合搞得非常高。先画图能有效避免这类问题。

3. 文件持久化选型:为什么我选了 JSON 而不是 CSV 或 pickle

通讯录只是教学项目,但在文件选型上其实值得认真想一想,因为存储格式会直接影响代码复杂度和日后数据迁移的方便程度。常见的三种方案是 JSON、CSV 和 pickle,我最终选择了 JSON,下面说说理由。

3.1 三种格式的横向对比

格式可读性跨语言通用性处理复杂结构安全风险
JSON好,纯文本可直接查看强,几乎所有语言都支持好,支持嵌套对象低
CSV一般,列多了容易乱较好,表格工具可打开差,嵌套结构很难表达低
pickle差,二进制乱码差,基本只有 Python 能读好,Python 对象直接序列化高,恶意文件可执行代码

pickle 我在最初教学演示时用过,它确实省事——直接把 Python 对象存盘,再pickle.load一下全回来了。但它的可读性为零,用户想用文本编辑器打开通讯录文件看一看,看到的是一堆乱码。更致命的是 pickle 存在安全隐患,反序列化恶意构造的文件可能导致任意代码执行,这种东西在教学项目里尤其不该用。

CSV 呢,适合扁平表格数据,Excel 能直接打开,用来导出一份给人看的联系人列表很合适,但通讯录字段有 4 个而且以后可能加分组、生日等嵌套信息,CSV 表达起来就很费劲。JSON 没有这个问题,它是树形结构,还能加字段压缩。

3.2 JSON 的序列化细节

通讯录保存这件事,本质上是把"内存中的对象列表"转换成"磁盘上的文本"。json.dump负责转储,json.load负责还原。但 JSON 库里默认不认自定义对象,所以需要中间人——Contact.to_dict()把对象转成普通字典,json.dump才能处理;读取时json.load得到一组字典,再用Contact.from_dict()还原成对象。

我用indent=2让 JSON 文件格式整齐一点,每层缩进两格,人眼可以直接阅读和修改。这里有一个特别容易忽略的参数:ensure_ascii=False。不加这个参数,文件里的中文会变成\u5f20\u4e09这种转义序列,虽然丢数据不会丢,但人完全没法看。这个参数我放在下面代码里,你看到就明白了。

3.3 保存时机:每次变更立即保存

我之前也考虑过"退出程序时统一保存"的方案——在用户选择退出菜单前,把所有改动写进文件。这样做会带来一个问题:如果程序崩溃、断电、用户直接关掉终端,未保存的数据全部丢失。养成"每次修改立即保存"的习惯更稳妥,虽然多写几行代码,但换来的数据安全性是值得的。

另外一个相关的小设计是load()里文件的兼容处理:如果文件不存在,说明是第一次运行,把contacts初始化为空列表,不要让程序直接崩溃;如果文件存在但里面是空列表,也正常返回。这就保证了无论什么时候启动程序,self.contacts都已经是一个可用的列表,后续 add 操作不用做额外判断。

4. 核心功能逐个落地:增删改查的完整实现与主循环设计

设计方案有了,接下来就是把代码写出来。我会把完整代码放在这一段,每个核心方法后面都配上说明,方便你直接运行体会,也方便拿来改造成自己想做的项目。

4.1 Contact 类的完整代码

import json import os class Contact: """单个联系人""" def __init__(self, name, phone, email="", remark=""): self.name = name self.phone = phone self.email = email self.remark = remark def __str__(self): return (f"姓名: {self.name} | 电话: {self.phone} " f"| 邮箱: {self.email} | 备注: {self.remark}") def to_dict(self): return { "name": self.name, "phone": self.phone, "email": self.email, "remark": self.remark } @classmethod def from_dict(cls, data): return cls( name=data["name"], phone=data["phone"], email=data.get("email", ""), remark=data.get("remark", "") )

Contact.__str__是让我特别舒服的一个方法,它在需要"给人看"的地方直接被调用。list_all里print(contact)就能输出整齐的一行信息。用data.get("email", "")而不是data["email"],是为了兼容旧版 JSON 文件里可能缺失的字段,不会因为读旧数据直接抛 KeyError。

4.2 AddressBook 类的完整代码

class AddressBook: """通讯录容器""" def __init__(self, filename="contacts.json"): self.filename = filename self.contacts = [] self.load() def add(self, contact): self.contacts.append(contact) self.save() print(f"已添加联系人: {contact.name}") def remove(self, name): for contact in self.contacts: if contact.name == name: self.contacts.remove(contact) self.save() print(f"已删除联系人: {name}") return print(f"未找到姓名为 [{name}] 的联系人") def update(self, name, phone=None, email=None, remark=None): for contact in self.contacts: if contact.name == name: if phone is not None: contact.phone = phone if email is not None: contact.email = email if remark is not None: contact.remark = remark self.save() print(f"已更新联系人: {name}") return True print(f"未找到姓名为 [{name}] 的联系人") return False def search(self, keyword): results = [c for c in self.contacts if keyword in c.name or keyword in c.phone] return results def list_all(self): if not self.contacts: print("通讯录为空,暂时没有联系人。") return print(f"共 {len(self.contacts)} 位联系人:") for i, contact in enumerate(self.contacts, start=1): print(f"{i}. {contact}") def save(self): with open(self.filename, "w", encoding="utf-8") as f: json.dump( [c.to_dict() for c in self.contacts], f, ensure_ascii=False, indent=2 ) def load(self): if not os.path.exists(self.filename): self.contacts = [] return try: with open(self.filename, "r", encoding="utf-8") as f: data = json.load(f) self.contacts = [Contact.from_dict(d) for d in data] except (json.JSONDecodeError, KeyError) as e: print(f"读取联系人数据时出错: {e}") self.contacts = []

保存和加载是这套代码的中枢,我单独拎出来说几个细节。save()里用了列表推导式,输出的 JSON 数据结构是数组,数组里每个元素是一个字典,层级很清晰。load()里有try/except防护,如果用户不小心把 JSON 文件改坏了,程序不会直接崩溃,而是提示错误并用空列表兜底。这种容错思路其实应该贯穿整个项目——凡是读取外部数据的地方,都要问一句:如果数据不合法怎么办?

4.3 主循环与菜单设计

主程序靠一个while True菜单驱动。用户输入数字选择操作,程序分发到对应方法。这里我会用input()接收用户输入,数字操作和字符串操作合理配合。

def main(): book = AddressBook("contacts.json") while True: print("\n===== 简易通讯录 =====") print("1. 添加联系人") print("2. 删除联系人") print("3. 修改联系人") print("4. 搜索联系人") print("5. 列出所有联系人") print("6. 退出") choice = input("请选择操作: ").strip() if choice == "1": name = input("姓名: ").strip() phone = input("电话: ").strip() email = input("邮箱(可留空): ").strip() remark = input("备注(可留空): ").strip() book.add(Contact(name, phone, email, remark)) elif choice == "2": name = input("要删除的姓名: ").strip() book.remove(name) elif choice == "3": name = input("要修改的姓名: ").strip() phone = input("新电话(直接回车则不修改): ").strip() email = input("新邮箱(直接回车则不修改): ").strip() remark = input("新备注(直接回车则不修改): ").strip() book.update( name, phone=phone or None, email=email or None, remark=remark or None ) elif choice == "4": keyword = input("输入搜索关键字(姓名或电话): ").strip() results = book.search(keyword) if results: print(f"找到 {len(results)} 位联系人:") for item in results: print(item) else: print("没有匹配的联系人。") elif choice == "5": book.list_all() elif choice == "6": print("已保存数据,退出程序。") break else: print("无效选项,请重新输入。") if __name__ == "__main__": main()

我在修改联系人的输入设计上花了一点心思:如果用户直接回车不输入新值,就用phone or None把空串变成None,然后在update里判断None,这样就不会把原字段误改成空字符串。你如果直接把空串传进update,条件if phone is not None永远成立,服务器没有输入电话的联系人就会变成空电话。

这段程序完全可以直接跑起来。你把前面两个类合并到一个文件里,再在文件末尾放上主函数,保存为address_book.py,运行后按菜单操作即可。试试添加一个 "张三"、退出程序、再重新打开,你会发现张三还在那里。

5. 实测中踩过的坑:编码问题、重名处理与异常输入

一个项目写出来能跑是第一步,好用的项目得经得起"折腾"。我把这项目给很多学生跑过,自己也反复测过,总结出几个必踩的坑,分别讲清楚现象、原因和解决方案。

5.1 中文变乱码或转义串

第一个坑几乎人人都会踩。不加ensure_ascii=False时,JSON 文件里的张三长这样:

{ "name": "\u5f20\u4e09", "phone": "138..." }

数据确实是存的,但打开文件跟看天书一样。原理是ensure_ascii=True时,json.dump会把所有非 ASCII 字符转成\uXXXX转义序列,这是 JSON 规范允许的行为。解决办法很简单:ensure_ascii=False。但光有它还不够,读写文件时都要用encoding="utf-8",不然 Windows 默认的编码可能又是 GBK,打开读出乱码来。

写完这段代码我建议你做一个验证动作:程序跑起来添加一个中文联系人,然后用文本编辑器打开 JSON 文件,确认里面显示的是"张三"而不是\u开头的一串。看到中文,持久化这一步才算真正过关。

5.2 重名联系人处理:用姓名唯一定位是个隐患

我前面提到remove和update都是按contact.name == name匹配的,那问题来了:通讯录里有两个"张三"怎么办?remove("张三")只删掉第一个,第二个留着;update("张三", ...)只改第一个,第二个完全没动。这显然是隐蔽的逻辑错误。

几种解法:一是给每个联系人分配唯一 ID,用自增数字或 UUID,各类操作都以 ID 为准;二是允许重名但删除时提示用户选择删哪个;三是把姓名和电话合并作为唯一键。考虑到这是教学项目,不必过度设计,但至少要意识到 bug 的存在。我会在代码中保留按姓名查找的版本,但在注释里提醒读者——如果你的通讯录可能重名,请给 Contact 加一个 id 字段再做精确定位。

5.3 电话的合法性校验

用户手一抖,可能往电话栏输入abc。要不要拦截?看项目定位。简易通讯录可以不做强校验,但至少不能让它影响程序运行。比较务实的方案是:只在数字键盘的菜单里校验(如果有的话),字符串格式自己控制。如果你想加上校验,可以用isdigit()做基础检查,要求电话由数字组成,位数在 5 到 15 之间:

def is_valid_phone(phone): return phone.isdigit() and 5 <= len(phone) <= 15

如果检验不通过,直接让用户重新输入。功能虽小,但对使用体验的提升非常明显。我见过很多项目里的电话字段五花八门,有带横杠的、有带空格的,后面做数据分析时疼得要命。做通讯录时养成"入口处校验"的习惯,以后做什么管理系统都受益。

6. 后续还能怎么玩:从通讯录到更通用的数据管理

项目做完先别急着扔。我会基于当前版本逐步拓展,每次只加一个新功能,观察类设计的适应能力——这其实是比通讯录本身更值钱的学习过程。

6.1 给联系人添加分组和 ID

改进一版:给Contact加group字段,预设"家人、朋友、同事、其他"四个分组;再加id字段,每次新增时取当前最大 ID 加 1,作为唯一标识。这能解决的正是第 5 节说的重名问题。AddressBook里add时自动生成 ID,remove/update改用 ID 定位,用户界面仍然可以用姓名搜索,但底层一定是 ID 驱动。这套设计思路以后做订单系统、库存系统完全通用。

6.2 按拼音排序和分组展示

list_all目前按添加顺序输出,实际使用中更期望按姓名排序。用sorted(self.contacts, key=lambda c: c.name)就能按 Unicode 码点排序,中文会大致按拼音排,不过多音字会有少量问题。想要更精确的拼音排序需要三方库,我就不展开讲了。还可以在菜单新增"按分组展示",遍历时按分组归类,这样通讯录的实用性会明显提升。

6.3 数据量变大时的思考:JSON 什么时候不够用

当你把通讯录用到几百上千人,JSON 的方案可能还在可接受范围内,但如果每个联系人的字段增长到十几个、需要按条件筛选组合,JSON 的读写效率就会成为瓶颈。这时候值得考虑把存储层替换为 SQLite。神奇的是,只要类设计没有变形,替换存储层只改save/load两个方法就能完成,其他地方几乎不动。

这也正是当初引入面向对象的目的——数据访问和业务操作分离之后,业务代码不受数据库影响。很多人到这一步才真正明白"持久化"和"面向对象"为什么会放到同一节课里面讲。

最后再分享一点我做这个项目的体会:不要满足于把代码跑通,试着改掉一个字段名,看编译器能不能帮你找出所有引用点;试着删掉save()调用,观察数据什么时候丢;试着把 JSON 文件手动写坏,看load()会不会崩。每一次破坏性实验都会让你对这套设计理解得更透。通讯录只是载体,你真正练的是"设计一个能应对变化的代码结构"的能力,这项能力往后到任何系统都不过时。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/23 7:40:48

Python字符串进阶:不可变性、格式化与文本处理实战

诚实说&#xff0c;字符串这关是很多 Python 开发者最容易产生“我好像会了&#xff0c;但一写就露馅”的地方。上一篇我们聊了基础定义、索引切片、简单拼接&#xff0c;那都属于开胃菜&#xff1b;今天这篇进入 Python 字符串系列的真正主场&#xff1a;不可变性、格式化、编…

作者头像 李华
网站建设 2026/10/19 15:00:14

AI Agent 时代,云计算架构为何必须重新整合计算、推理与数据

1. AI Agent 时代&#xff0c;云到底该长什么样这两年跟同行聊天&#xff0c;话题绕来绕去最后总会落到一个点上&#xff1a;AI Agent 跑起来之后&#xff0c;云计算的账算不平了。以前我们做云&#xff0c;思路很清晰——计算归计算&#xff0c;存储归存储&#xff0c;推理服务…

作者头像 李华
网站建设 2026/10/19 19:55:52

pstack与Claude Code组合,快速定位C++线上服务疑难故障

1. 为什么我会把 pstack 和 Claude Code 凑到一块先交代一下背景。我平时维护的业务后端是 C 写的&#xff0c;线上跑着几十个实例&#xff0c;最头疼的事情之一就是“进程还活着&#xff0c;但业务卡死”或者“偶发崩溃&#xff0c;core 文件却抓不到现场”。这种场景下&#…

作者头像 李华
网站建设 2026/10/20 6:46:31

移动云MAS平台:企业级通信中枢的.NET集成与HTTP实践

1. 移动云MAS平台的本质&#xff1a;不是“发短信的工具”&#xff0c;而是企业级通信中枢很多人第一次看到“移动云MAS平台”这个词&#xff0c;第一反应是&#xff1a;“哦&#xff0c;就是个发验证码、通知短信的后台系统吧&#xff1f;”——这种理解在技术层面不算错&…

作者头像 李华
网站建设 2026/10/19 13:44:53

JavaWeb项目MySQL实战:多表查询、事务与索引优化指南

我刚开始带团队做JavaWeb项目的时候&#xff0c;发现一个特别普遍的现象&#xff1a;很多人单体CRUD写得飞起&#xff0c;MyBatis的Mapper接口一个接一个地建&#xff0c;但是一旦涉及多表关联的数据要查出来、或者某个接口需要同时写两三张表、再或者线上某个查询页面慢得让人…

作者头像 李华
网站建设 2026/10/19 1:43:20

Agent互联的通信协议设计:从注册发现到会话管理全实践

1. 项目背景与整体拆解我在上个月初开始做Agent-Reach这个项目&#xff0c;起因挺简单的——团队里前后写了十几个Agent&#xff0c;散落在不同的服务里&#xff0c;有做意图识别的、有做数据检索的、还有做流程编排的&#xff0c;能力都不错&#xff0c;但彼此之间完全不通气。…

作者头像 李华