做了这么多年 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()还原成对象。我用indent2让 JSON 文件格式整齐一点每层缩进两格人眼可以直接阅读和修改。这里有一个特别容易忽略的参数ensure_asciiFalse。不加这个参数文件里的中文会变成\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( namedata[name], phonedata[phone], emaildata.get(email, ), remarkdata.get(remark, ) )Contact.__str__是让我特别舒服的一个方法它在需要给人看的地方直接被调用。list_all里print(contact)就能输出整齐的一行信息。用data.get(email, )而不是data[email]是为了兼容旧版 JSON 文件里可能缺失的字段不会因为读旧数据直接抛 KeyError。4.2 AddressBook 类的完整代码class AddressBook: 通讯录容器 def __init__(self, filenamecontacts.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, phoneNone, emailNone, remarkNone): 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, start1): print(f{i}. {contact}) def save(self): with open(self.filename, w, encodingutf-8) as f: json.dump( [c.to_dict() for c in self.contacts], f, ensure_asciiFalse, indent2 ) def load(self): if not os.path.exists(self.filename): self.contacts [] return try: with open(self.filename, r, encodingutf-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, phonephone or None, emailemail or None, remarkremark 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_asciiFalse时JSON 文件里的张三长这样{ name: \u5f20\u4e09, phone: 138... }数据确实是存的但打开文件跟看天书一样。原理是ensure_asciiTrue时json.dump会把所有非 ASCII 字符转成\uXXXX转义序列这是 JSON 规范允许的行为。解决办法很简单ensure_asciiFalse。但光有它还不够读写文件时都要用encodingutf-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时自动生成 IDremove/update改用 ID 定位用户界面仍然可以用姓名搜索但底层一定是 ID 驱动。这套设计思路以后做订单系统、库存系统完全通用。6.2 按拼音排序和分组展示list_all目前按添加顺序输出实际使用中更期望按姓名排序。用sorted(self.contacts, keylambda c: c.name)就能按 Unicode 码点排序中文会大致按拼音排不过多音字会有少量问题。想要更精确的拼音排序需要三方库我就不展开讲了。还可以在菜单新增按分组展示遍历时按分组归类这样通讯录的实用性会明显提升。6.3 数据量变大时的思考JSON 什么时候不够用当你把通讯录用到几百上千人JSON 的方案可能还在可接受范围内但如果每个联系人的字段增长到十几个、需要按条件筛选组合JSON 的读写效率就会成为瓶颈。这时候值得考虑把存储层替换为 SQLite。神奇的是只要类设计没有变形替换存储层只改save/load两个方法就能完成其他地方几乎不动。这也正是当初引入面向对象的目的——数据访问和业务操作分离之后业务代码不受数据库影响。很多人到这一步才真正明白持久化和面向对象为什么会放到同一节课里面讲。最后再分享一点我做这个项目的体会不要满足于把代码跑通试着改掉一个字段名看编译器能不能帮你找出所有引用点试着删掉save()调用观察数据什么时候丢试着把 JSON 文件手动写坏看load()会不会崩。每一次破坏性实验都会让你对这套设计理解得更透。通讯录只是载体你真正练的是设计一个能应对变化的代码结构的能力这项能力往后到任何系统都不过时。
阅读完成 · 觉得有帮助?