做了这么多年 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()会不会崩。每一次破坏性实验都会让你对这套设计理解得更透。通讯录只是载体,你真正练的是"设计一个能应对变化的代码结构"的能力,这项能力往后到任何系统都不过时。