先说一个比较普遍的现象:很多人在学习面向对象的时候,语法都看懂了,类、对象、继承这些概念背得滚瓜烂熟,但一让他独立写个小程序,立马就懵了。根本原因不是知识没学会,而是缺少一个“把理论落到代码里”的练习过程。第十八节这个项目实战——简易通讯录,恰好就是干这个用的。它不复杂,但麻雀虽小五脏俱全,能让你把类设计、对象交互、数据存储这一整条链路跑通。这篇博文,我会把完整的实现思路、代码结构、常见坑位都拆开讲清楚,不管是刚学完面向对象语法的新手,还是想复习一下文件持久化写法的老手,都能直接照着做。
1. 项目整体设计与核心思路拆解
1.1 为什么通讯录是面向对象教学的经典案例
我每次跟朋友聊教学案例的时候都会说,选项目最重要的标准就是“问题域清晰”。通讯录这个场景,几乎每个人都用过手机里的联系人应用,它的需求非常好理解:能添加联系人、能删除联系人、能查找联系人、能修改联系人信息,最后这些数据还得能存下来,下次打开还在。
这个需求本身就天然对应着两个核心概念:一个是“联系人”这个实体,另一个是“通讯录”这个容器。你在生活里的直觉是“一个联系人是一个对象”“一本通讯录是一个管理对象的对象”,而这套直觉翻译成代码,就是两个类——Contact和AddressBook。
如果把同样功能用纯函数式去写,也不是不行,但你会发现所有数据都要靠全局变量或者传来传去的参数维护,函数一多,变量满天飞,稍加需求就控制不住了。面向对象的思路是把“数据”和“操作数据的方法”绑在一起,正好符合通讯录这种“数据和操作高度聚合”的场景。这就是为什么我强烈建议这个项目必须用面向对象来做,而不只是“可以用”。
1.2 文件持久化解决的痛点是什么
如果没有文件持久化,你的通讯录程序运行起来挺好,数据往列表里一放,增删改查都流畅。但是一关程序,什么都没了,再打开就得从头输入。这在真实世界里显然不可接受。
持久化的本质就是:把内存中的对象状态,转换成一串能够长期存储在磁盘上的数据(比如JSON文本),程序启动时再把这串数据重新装载成对象。这一存一取,正好对应着两个专业名词——序列化与反序列化。
我见过不少人跳过持久化这个环节,做出来的项目只能算“半成品”。这个项目之所以要把持久化单独列为一个重点,就是为了让你在早期就养成“数据可落地”的意识。后面你做任何实际项目,爬虫也好、Web应用也好,都离不开这一层。
2. 面向对象核心设计:类与职责划分
2.1 Contact类:数据实体的最小封装
我们先从最底层的实体开始设计。一个联系人,至少要有姓名和电话这两个字段。但实际使用中,我们通常还会给每个人分配一个唯一的ID。为什么要这个ID?因为姓名可能重复,电话也可能换,但ID作为内部标识是不会变的,我们的增删改查就靠它来精准定位。
class Contact: def __init__(self, name, phone, contact_id=None): self.contact_id = contact_id if contact_id is not None else self._generate_id() self.name = name self.phone = phone @staticmethod def _generate_id(): import uuid return str(uuid.uuid4())[:8]这里我用了UUID的前8位做ID,不需要UUID也行,你用当前时间的毫秒数拼接一个随机数也完全OK。重点是让Contact作为一个“纯粹的数据模型”,只负责存数据、展示数据,不掺和通讯录的管理逻辑。
顺便说一句,关于“纯数据模型”的好处:如果以后你想增加邮箱、地址、分组这些字段,只需要在这个类里加属性,其他代码几乎不用动。这就是低耦合带来的直接红利。
2.2 AddressBook类:管理者的职责边界
Contact是“一个人”,AddressBook则是“一本书”。这本书要做什么?它内部要维护一个联系人列表,对外提供增、删、改、查、存、取这些操作。这部分代码要写在AddressBook里,而不是写在主逻辑里。
class AddressBook: def __init__(self): self.contacts = [] def add_contact(self, contact): self.contacts.append(contact) return True def remove_contact(self, contact_id): for i, c in enumerate(self.contacts): if c.contact_id == contact_id: del self.contacts[i] return True return False def find_contact(self, name): return [c for c in self.contacts if name in c.name] def update_contact(self, contact_id, name=None, phone=None): for c in self.contacts: if c.contact_id == contact_id: if name: c.name = name if phone: c.phone = phone return True return False你注意看,AddressBook完全没有涉及“用户界面怎么显示”的问题,也不关心数据最后是存JSON还是存CSV。它只负责通讯录本身的业务逻辑。这种“单一职责”原则,你越往后写越能体会到威力——改界面不影响业务,换存储格式也不影响业务。
2.3 面向对象接口设计的思考
很多人写完类就完事了,不考虑“接口好不好用”。实际开发里,“面向对象接口”是否合理,直接影响后期扩展和工作效率。
什么叫好的接口?我的判断标准很简单:调用方写起来自然,不暴露不必要的内部细节。拿这个项目举例,AddressBook的add_contact方法,接收一个Contact对象,而不是接收一串字符串自己在内部组装。这样主逻辑的代码读起来就是“book.add_contact(contact)”,语义非常清晰。
如果你把接口设计成“book.add_contact(name, phone)”虽然也行,但你就把“创建联系人”的职责放到了AddressBook里,这个类的职责就加重了。好的接口设计,应该是每个类都清楚自己的边界所在。
3. 文件持久化的实现方案与选型
3.1 为什么首选JSON而不是CSV或Pickle
既然要保存数据,方案就有不少:CSV、JSON、pickle、SQLite,甚至直接存纯文本。对于通讯录这个体量,我的建议是优先用JSON。
JSON的好处很实在:第一,它本身就是文本,你用记事本打开也能看懂,调试起来方便;第二,Python标准库json对字典、列表这些基础结构支持非常成熟;第三,JSON是跨语言的,你以后如果用Java、JavaScript写别的程序读取这个文件也不会遇到障碍。
CSV在表格软件里打开方便,但表示嵌套结构(比如一个联系人可以有多个电话)就很别扭。pickle虽然能把对象原封不动地序列化,但保存出来的内容只有Python自己认识,而且存在安全隐患——反序列化恶意构造的数据可能导致代码执行。作为教学项目,我们当然要选择“安全、可读、跨用”的方案,这三点JSON全占了。
3.2 序列化与反序列化的核心逻辑
要把一个Contact对象变成JSON文本,我们需要把对象先转成字典。这个转换过程就是自定义序列化逻辑。
class Contact: def to_dict(self): return { "contact_id": self.contact_id, "name": self.name, "phone": self.phone } @classmethod def from_dict(cls, data): return cls( name=data["name"], phone=data["phone"], contact_id=data["contact_id"] )to_dict负责“出口”,from_dict负责“入口”。AddressBook这边的序列化就水到渠成:
import json class AddressBook: def save_to_file(self, filename): data = [c.to_dict() for c in self.contacts] with open(filename, "w", encoding="utf-8") as f: json.dump(data, f, ensure_ascii=False, indent=2) def load_from_file(self, filename): try: with open(filename, "r", encoding="utf-8") as f: data = json.load(f) self.contacts = [Contact.from_dict(item) for item in data] except FileNotFoundError: self.contacts = []注意save里那两个容易被忽略的关键参数:ensure_ascii=False是为了让中文正常显示,而不是变成一堆\u开头;indent=2是为了让文件格式化,方便排查问题。load这里用try捕获FileNotFoundError,文件不存在时直接置空列表,而不是让程序崩掉。这些细节,就是“能跑”和“好用”的差别。
3.3 文件路径与原子写入的进阶经验
上面这个写法已经能完成任务了,但如果你想更讲究一点,可以考虑“原子写入”。什么意思呢?如果程序在json.dump的过程中,突然断电或者崩溃,你写到一半的临时文件可能已经覆盖了原来完整的数据,导致整个通讯录内容损毁。
更稳的做法是:先写入一个临时文件,比如data.json.tmp,写完之后再用os.replace把临时文件正式替换成目标文件。这样即使中途出错,原文件也是完整的。通讯录也许不需要这么高的可靠性,但这个意识值得培养,以后做服务端配置文件、日志系统都会用到。
文件路径方面,我建议用相对于程序所在目录的路径,而不是写死绝对路径。比如data/book.json,这样即使整个项目文件夹被挪到别的电脑,程序也照常运行。我自己早期就吃过这个亏——写死了“D:/我的文档/通讯录/contact.json”,结果换了一台电脑,程序直接找不到文件了。
4. 完整代码实现与交互逻辑
4.1 整体结构预览
我们把之前的类组合到一起,再补一个交互入口。为了阅读方便,我按模块划分:Contact类负责数据模型,AddressBook类负责业务逻辑和数据存取,main中用一个循环来接收用户的指令。
我们先把AddressBook类补全,让它具备完整的增删改查加存取能力:
class AddressBook: def __init__(self, storage_file="data/book.json"): self.storage_file = storage_file self.contacts = [] self.load_from_file() def add_contact(self, contact): self.contacts.append(contact) self.save_to_file() print(f"已添加联系人:{contact.name}") def remove_contact(self, contact_id): if self._delete_contact(contact_id): self.save_to_file() print("联系人删除成功") else: print("未找到指定ID的联系人") def find_contact(self, keyword): result = [c for c in self.contacts if keyword in c.name or keyword in c.phone] return result def list_contacts(self): if not self.contacts: print("通讯录为空") return for c in self.contacts: print(f"ID: {c.contact_id} | 姓名: {c.name} | 电话: {c.phone}") def _delete_contact(self, contact_id): for i, c in enumerate(self.contacts): if c.contact_id == contact_id: del self.contacts[i] return True return False这种设计的好处是:所有会改变数据的操作,在内部自动完成保存。调用方的代码非常干净——它不需要自己去调用save_to_file,也不需要了解通讯录内部是怎么存储数据、什么时候触发持久化的。这又是一个“封装”的实践:控制方法内部对细节进行管理。
4.2 菜单循环与交互入口的实现
主函数这部分没有太多花活,重点是菜单循环的结构清晰,每个分支的逻辑简单直接。
def main(): book = AddressBook() while True: print("\n=====简易通讯录=====") print("1. 添加联系人") print("2. 删除联系人") print("3. 查找联系人") print("4. 显示全部") print("5. 退出") choice = input("请选择操作:").strip() if choice == "1": name = input("请输入姓名:").strip() phone = input("请输入电话:").strip() if not name or not phone: print("姓名和电话不能为空!") continue book.add_contact(Contact(name, phone)) elif choice == "2": contact_id = input("请输入要删除的ID:").strip() book.remove_contact(contact_id) elif choice == "3": keyword = input("请输入姓名或电话关键字:").strip() results = book.find_contact(keyword) if results: for c in results: print(f"ID: {c.contact_id} | 姓名: {c.name} | 电话: {c.phone}") else: print("未找到匹配的联系人") elif choice == "4": book.list_contacts() elif choice == "5": print("已退出程序,数据已保存") break else: print("无效选择,请重新输入") if __name__ == "__main__": main()代码看起来不多,但这里面包含的教学点很多。首先是输入校验,姓名或电话为空的时候直接continue,避免脏数据进列表。其次是.strip()的使用,用户很容易在输入时带出前后空格,不处理的话查找和匹配都会出问题。
4.3 一个容易被忽视的细节:主程序与数据目录
如果你直接把数据文件定义成“data/book.json”,记得要确保data目录存在,否则open会报FileNotFoundError。最简单的办法是在AddressBook.__init__里用os.makedirs自动创建:
import os class AddressBook: def __init__(self, storage_file="data/book.json"): self.storage_file = storage_file os.makedirs(os.path.dirname(storage_file), exist_ok=True) self.contacts = [] self.load_from_file()这个细节初看起来不起眼,但在实际运行中价值很大。你拿到别人的项目,或者把项目放到一台干净的机器上,不可能每次都手动创建目录。程序自己管理好自己的存储目录,这是专业项目的基本素养。
4.4 修改联系人功能的实现
上面的代码里我还没有写修改功能,但实际通讯录里这是刚需。你可以自己试着在菜单里加一个“修改联系人”分支。我给一个参考思路:根据ID查到联系人,然后让用户选择修改姓名还是修改电话,注意要让用户输入新值。
def update_contact(self, contact_id, name=None, phone=None): for c in self.contacts: if c.contact_id == contact_id: if name: c.name = name if phone: c.phone = phone self.save_to_file() return True return False这里会牵涉一个脑力小问题:如果用户想把某人的电话清空怎么办?上面这个写法里传空字符串可能被判断为假值。这种情况我一般会单独把“是否清空”的决定放在交互层处理,而不是把“空值”这个语义混进业务层。否则接口就容易出歧义。
5. 常见问题与排查技巧实录
5.1 中文乱码问题
很多初学者用open读写文件时,总是忘记写encoding="utf-8"。在Windows默认编码下(往往是GBK),你写中文进去、再读出来,会出现乱码甚至直接报错。
解决方案就是统一标准:写文件时用encoding="utf-8",读文件时也用encoding="utf-8"。千万不要一个用UTF-8一个用GBK,那样更乱。JSON文件中出现\u开头的内容,多半也是编码没指定对。
5.2 JSON文件损坏或格式错误
还有一种经典场景:JSON文件手贱改了一下,格式错了,程序一启动就报json.decoder.JSONDecodeError。虽然刚才的代码只捕获了FileNotFoundError,但如果你希望程序更健壮,可以把JSONDecodeError也捕获,并给出一个明确提示,比如“数据文件损坏,已重置通讯录”。
我个人偏好的策略是:发现文件损坏时,先备份这个损坏文件,命名为book.json.bak,然后创建一个全新的通讯录。这样既不会让程序崩溃,也不至于把原来可能修复的数据全丢了。这就是真实开发里常说的“容错处理”。
5.3 联系人ID重复的隐患
如果用随机数生成ID,严格来说有小概率重复。如果你在意这个,可以在AddressBook.add_contact里检查一下当前ID是否已存在,存在就重新生成。这个检查可以写成一个循环,直到拿到一个不重复的ID为止。
不过对于教学项目,UUID截断前8位已经够用了。“够用就好”也是工程里的一条重要原则,不要为了理论完美过度设计。你要想做得更极致,也可以直接用完整的UUID字符串,只是展示的时候比较长。
5.4 变量作用域与列表修改的坑
很多新手在实现“删除联系人”的时候,会掉进一个经典陷阱:在遍历列表的同时删除列表元素,结果导致部分元素被跳过。比如你删除第一个匹配项后,后一个元素自动补位,但循环的索引已经走到下一个了。
我上面的实现方式是:先找到索引,del之后再返回。这个逻辑就没有遍历修改的问题。如果你确实想在遍历中删除多个符合条件的项,比较好的做法是构建一个新列表,用列表推导式过滤,或者遍历列表的副本。
6. 如何给这个项目做扩展
最后一个部分,我想谈谈项目做完之后能往哪些方向走。学习编程,项目迭代的过程本身就是最有效的进阶方式。通讯录这个项目,扩展空间其实很大。
你可以给联系人增加一组额外字段,比如邮箱、生日、地址,然后像前面说的,在Contact类和交互层同步扩展。你还可以把存储升级成SQLite,体验一下“从JSON迁移到数据库”的开发过程。你也可以为这个程序加上GUI界面,用tkinter或者PyQt,把它变成一个真正能双击运行的应用。
另外想做点有意思的话,可以加上“为联系人分组”的功能,实现类似群组通讯录的概念;也可以加入模糊搜索、按拼音首字母排序、导出成CSV在手机里看等等。
我个人的经验是:把一个基础项目往一个方向深挖三步,比做十个不痛不痒的小练习成长快得多。因为扩展的过程里,你需要重新审视原来的类和接口设计得够不够灵活,而这恰恰是面向对象设计能力提升的关键。
这个简易通讯录做到这里,设计和代码都齐了。写这个项目的时候,我脑子里反复强调的一条主线就是:类不是从语法里“变”出来的,而是从需求里“长”出来的。Contact对应现实中的联系人,AddressBook对应现实中的通讯录,JSON文件对应现实中的纸质通讯录存档——你把现实模型翻译成软件模型,代码自然就有了生命。照着这个思路独立做一遍,把代码一个个敲出来,比看我文章里贴的代码产生的效果强十倍。