ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

面向对象编程实战:用Python把通讯录做成带JSON存储的完整项目

面向对象编程实战:用Python把通讯录做成带JSON存储的完整项目 先说一个比较普遍的现象很多人在学习面向对象的时候语法都看懂了类、对象、继承这些概念背得滚瓜烂熟但一让他独立写个小程序立马就懵了。根本原因不是知识没学会而是缺少一个“把理论落到代码里”的练习过程。第十八节这个项目实战——简易通讯录恰好就是干这个用的。它不复杂但麻雀虽小五脏俱全能让你把类设计、对象交互、数据存储这一整条链路跑通。这篇博文我会把完整的实现思路、代码结构、常见坑位都拆开讲清楚不管是刚学完面向对象语法的新手还是想复习一下文件持久化写法的老手都能直接照着做。1. 项目整体设计与核心思路拆解1.1 为什么通讯录是面向对象教学的经典案例我每次跟朋友聊教学案例的时候都会说选项目最重要的标准就是“问题域清晰”。通讯录这个场景几乎每个人都用过手机里的联系人应用它的需求非常好理解能添加联系人、能删除联系人、能查找联系人、能修改联系人信息最后这些数据还得能存下来下次打开还在。这个需求本身就天然对应着两个核心概念一个是“联系人”这个实体另一个是“通讯录”这个容器。你在生活里的直觉是“一个联系人是一个对象”“一本通讯录是一个管理对象的对象”而这套直觉翻译成代码就是两个类——Contact和AddressBook。如果把同样功能用纯函数式去写也不是不行但你会发现所有数据都要靠全局变量或者传来传去的参数维护函数一多变量满天飞稍加需求就控制不住了。面向对象的思路是把“数据”和“操作数据的方法”绑在一起正好符合通讯录这种“数据和操作高度聚合”的场景。这就是为什么我强烈建议这个项目必须用面向对象来做而不只是“可以用”。1.2 文件持久化解决的痛点是什么如果没有文件持久化你的通讯录程序运行起来挺好数据往列表里一放增删改查都流畅。但是一关程序什么都没了再打开就得从头输入。这在真实世界里显然不可接受。持久化的本质就是把内存中的对象状态转换成一串能够长期存储在磁盘上的数据比如JSON文本程序启动时再把这串数据重新装载成对象。这一存一取正好对应着两个专业名词——序列化与反序列化。我见过不少人跳过持久化这个环节做出来的项目只能算“半成品”。这个项目之所以要把持久化单独列为一个重点就是为了让你在早期就养成“数据可落地”的意识。后面你做任何实际项目爬虫也好、Web应用也好都离不开这一层。2. 面向对象核心设计类与职责划分2.1 Contact类数据实体的最小封装我们先从最底层的实体开始设计。一个联系人至少要有姓名和电话这两个字段。但实际使用中我们通常还会给每个人分配一个唯一的ID。为什么要这个ID因为姓名可能重复电话也可能换但ID作为内部标识是不会变的我们的增删改查就靠它来精准定位。class Contact: def __init__(self, name, phone, contact_idNone): 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, nameNone, phoneNone): 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( namedata[name], phonedata[phone], contact_iddata[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, encodingutf-8) as f: json.dump(data, f, ensure_asciiFalse, indent2) def load_from_file(self, filename): try: with open(filename, r, encodingutf-8) as f: data json.load(f) self.contacts [Contact.from_dict(item) for item in data] except FileNotFoundError: self.contacts []注意save里那两个容易被忽略的关键参数ensure_asciiFalse是为了让中文正常显示而不是变成一堆\u开头indent2是为了让文件格式化方便排查问题。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_filedata/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(fID: {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(fID: {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_filedata/book.json): self.storage_file storage_file os.makedirs(os.path.dirname(storage_file), exist_okTrue) self.contacts [] self.load_from_file()这个细节初看起来不起眼但在实际运行中价值很大。你拿到别人的项目或者把项目放到一台干净的机器上不可能每次都手动创建目录。程序自己管理好自己的存储目录这是专业项目的基本素养。4.4 修改联系人功能的实现上面的代码里我还没有写修改功能但实际通讯录里这是刚需。你可以自己试着在菜单里加一个“修改联系人”分支。我给一个参考思路根据ID查到联系人然后让用户选择修改姓名还是修改电话注意要让用户输入新值。def update_contact(self, contact_id, nameNone, phoneNone): 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读写文件时总是忘记写encodingutf-8。在Windows默认编码下往往是GBK你写中文进去、再读出来会出现乱码甚至直接报错。解决方案就是统一标准写文件时用encodingutf-8读文件时也用encodingutf-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文件对应现实中的纸质通讯录存档——你把现实模型翻译成软件模型代码自然就有了生命。照着这个思路独立做一遍把代码一个个敲出来比看我文章里贴的代码产生的效果强十倍。
返回列表