面向对象综合训练:从图书管理系统掌握封装、继承与多态
发布时间:2026/9/24 0:01:39 作者:尧图编辑部 阅读量:1,286

面向对象学完语法之后最尴尬的阶段就是“懂的都懂一写就懵”。day09这个综合训练说白了就是把前面封装、继承、多态、抽象这些概念从“背概念”切换到“用概念”。这篇我把自己的练习过程完整拆开从选题思路到代码落地再到多语言对照和常见坑位一次性给你盘清楚。1. 综合练习怎么选项目别再用零散小demo1.1 我的选题思路到了综合训练这个阶段我不建议再去写那种Dog extends Animal、Cat extends Animal的课堂demo。为什么因为这种例子只能让你看懂语法根本练不到“设计”。真正的面向对象不是你定义了几个类、用了extends或者implements就叫面向对象而是你能不能把一堆散落的功能用合理的对象协作方式组织起来。我当时给自己定的题目是“图书管理系统”但只做命令行版不做界面。理由是纯命令行能逼着我把注意力放在对象建模上而不是被按钮、布局、事件这些界面逻辑分散精力。图书管理系统有一个很关键的优点——它天然包含实体书、用户、借阅记录、行为借书、还书、查询、规则借阅数量上限、借期、罚款这些正好是封装、继承、多态、组合的最佳练兵场。你在选练习项目的时候也一样不要贪大求全不要一上来就搞秒杀系统、电商平台选一个你闭着眼都能说出业务规则的小领域比如图书借阅管理学生选课系统员工考勤记录宠物收养中心这类项目的共同点是实体关系清晰、业务规则明确、扩展空间足够。你不需要懂复杂的算法反而能把精力全部放在“如何用类来表达这个世界”上。1.2 需求拆解从“要做什么”到“有哪些类”拿到项目后我第一件事不是开IDE写代码而是拿纸笔把需求拆成一个个名词和动词。这一步很多人跳过结果写着写着发现类的关系乱成一团。我当时是这样拆的名词大概率会成为类图书Book书号、书名、作者、库存总量、剩余可借数量用户User用户ID、姓名、类型学生/教师借阅记录BorrowRecord哪本书、哪个用户、借出日期、应还日期、实际归还日期动词大概率会成为方法借书检查用户是否达到借阅上限、检查书是否还有库存、创建借阅记录还书计算是否逾期、更新库存、更新记录查询按书名查、按用户查他的借阅列表这个过程实际上就是“从业务需求反推类结构”的经典做法。你写下来的名词不一定全部成为独立类有的可能只是另一个类的属性但你先列出来再根据关系取舍比直接上手写类要稳妥得多。拆完之后我得到最核心的设计决策User必须有子类。因为学生和教师的借阅规则不同比如学生最多借5本、借期30天教师最多借10本、借期60天且有罚款豁免。如果不用继承和多态你就得在User里面写if (type student)这种判断一两个地方还能忍规则一多就是灾难。2. 封装、继承、多态到底怎么落进项目2.1 封装先管住“属性”这扇门封装的核心不是“把变量设为private”而是控制外部对对象内部状态的修改方式。很多人写了一个private属性然后又生成一堆setter所有地方都能随便调用那封装等于没有。我在写Book类的时候故意不提供set_available_copies这样的方法。因为“剩余可借数量”这个状态只能通过borrow()和return_book()这两个行为来改变外部没有任何理由直接给它赋值。外面如果要借书调borrow()要还书调return_book()。这样库存永远不可能出现“借书成功但库存没减”或者“负数库存”这种数据不一致。class Book: def __init__(self, book_id: str, title: str, author: str, total_copies: int): self.__book_id book_id self.__title title self.__author author self.__total_copies total_copies self.__available_copies total_copies property def available_copies(self) - int: return self.__available_copies def borrow(self) - bool: if self.__available_copies 0: return False self.__available_copies - 1 return True def return_book(self) - None: self.__available_copies 1Python的property很好用它让我在读取属性时不用加get_前缀外部代码写起来像直接访问属性但内部依然保留控制权。Java没有这个语法糖老老实实写public int getAvailableCopies()就行语义是一样的。有一个封装的小细节值得注意能不暴露的就不暴露。比如Book类里的__book_id我在这个项目里没有提供任何getter。为什么因为业务上没有任何地方需要知道书的内部编号列表展示用的是书名和作者。把不必要的getter删掉等于告诉以后的维护者这个字段对外没有意义你不要依赖它。2.2 继承把公共的东西往上抽我的用户体系一开始是两张“平级”的类图Student有学号、姓名、可借数量Teacher有工号、姓名、可借数量。写到一半发现不对劲——大量重复字段和方法。这时候就该抽出父类User。继承的决策标准我总结成一句话当两个类之间有明显的is-a关系并且有公共的字段和行为时才考虑继承。学生是一个用户教师也是一个用户这就符合is-a。from abc import ABC, abstractmethod class User(ABC): def __init__(self, user_id: str, name: str): self.user_id user_id self.name name self.borrowed_books [] property abstractmethod def max_borrow_count(self) - int: pass property abstractmethod def max_borrow_days(self) - int: pass def can_borrow(self) - bool: return len(self.borrowed_books) self.max_borrow_count def borrow_book(self, book: Book) - bool: if not self.can_borrow(): return False if not book.borrow(): return False self.borrowed_books.append(book) return True这里我用了一个很关键的设计max_borrow_count和max_borrow_days是抽象属性子类必须实现。这样父类只负责定义“用户能借书”这个通用逻辑框架具体的规则数字由子类自己决定。这就是模板方法模式的思想雏形——把不变的流程写在父类把变化的细节留给子类。2.3 多态与抽象面向父类编程多态的价值在写图书馆管理器的借书方法时体现得最明显。如果我没有向上抽象就得写borrow_book_for_student(student, book)和borrow_book_for_teacher(teacher, book)两个几乎一样的方法。有了继承之后class LibraryManager: def __init__(self): self.books [] self.users [] def borrow_book(self, user: User, book: Book) - bool: if user.borrow_book(book): record BorrowRecord(user, book) print(f{user.name} 借书成功{book.title}) return True print(f{user.name} 借书失败请检查借阅上限或库存) return False方法参数类型是User但传入的既可以是Student也可以是Teacher。运行时Python会自动调用对应子类的max_borrow_count和max_borrow_days。这就是多态——同一个调用因为对象实际类型不同表现出不同的行为。我在Java版本里把这个逻辑也写了一遍Java是强类型语言多态的约束感更强public abstract class User { public abstract int getMaxBorrowCount(); public abstract int getMaxBorrowDays(); public boolean canBorrow() { return borrowedBooks.size() getMaxBorrowCount(); } } public class Student extends User { Override public int getMaxBorrowCount() { return 5; } Override public int getMaxBorrowDays() { return 30; } }写到这里我有一个很深的体会抽象类是约定不是实现。父类里那两行abstractmethod等于在跟所有子类说“你们可以不关心怎么借书但必须告诉我你们能借几本、借几天。”这种约束越清晰后面的业务代码就越省心。2.4 组合关系对象之间如何协作继承解决了“是什么”的问题组合解决的是“拥有什么”和“跟谁协作”的问题。图书馆里一个借阅记录必须同时引用一个User和一个Book这就是组合关系。class BorrowRecord: def __init__(self, user: User, book: Book): self.user user self.book book self.borrow_date datetime.now() self.due_date self.borrow_date timedelta(daysuser.max_borrow_days) self.return_date None def overdue_days(self) - int: if self.return_date is None: return (datetime.now() - self.due_date).days return (self.return_date - self.due_date).days注意一个细节借期长度不是BorrowRecord自己拍脑袋定的而是从user.max_borrow_days获取的。这就是对象之间的协作——每个对象只管自己那部分逻辑需要别人的信息时通过方法去问而不是自己复制一份数据。我在最初版本里犯过一个组合相关的错误在BorrowRecord里直接存了user.name和book.title的字符串副本。结果还书时发现用户改了名字记录里还是旧名字。后来才想明白存对象的引用就够了展示时通过对象现取。这个坑我会在后面的问题排查里再细说。3. 再进一步让代码“好改”的几个设计手法3.1 用策略模式干掉if-else的罚款规则图书管理项目做到一半产品经理我自己突然提了新需求逾期罚款规则要改——学生按每天0.5元收费教师免费VIP会员按每天0.2元且封顶10元。如果我在BorrowRecord的calc_fine()里用if isinstance(user, Student)来判断每加一种用户类型就要改一次这个方法非常痛苦。这个场景我引入了策略模式。思路很简单把“计算罚款”这个行为封装成单独的策略对象不影响主逻辑的前提下我直接在BorrowRecord里加了一个fine_rule对象。真正的核心是用策略模式来封装变化。class FineStrategy(ABC): abstractmethod def calc(self, days: int, price: float) - float: pass class StudentFine(FineStrategy): def calc(self, days: int, price: float) - float: return max(days, 0) * 0.5 class TeacherFine(FineStrategy): def calc(self, days: int, price: float) - float: return 0 class VipFine(FineStrategy): def calc(self, days: int, price: float) - float: return min(max(days, 0) * 0.2, 10)然后在User类里加一个fine_strategy属性不同子类注入不同的策略对象。以后要加一种新用户你只需要新增一个策略类然后在对应子类里注入完全不需要动BorrowRecord。class VipUser(User): def __init__(self, user_id: str, name: str): super().__init__(user_id, name) self.fine_strategy VipFine()这个模式用一句话总结就是把会变的规则拿出来让它们各自成为独立对象比堆在业务逻辑里强得多。我在实际工作中看到太多“一个方法三百行、全是if-else”的代码真的建议大家在这个综合练习里就试试策略模式。3.2 单例与工具类的边界图书管理系统的LibraryManager我建议做成单例。为什么因为整个程序只需要一个图书馆管理器多个管理器同时操作同一份数据会产生各种诡异问题。Python的单例写法class LibraryManager: _instance None def __new__(cls): if cls._instance is None: cls._instance super().__new__(cls) cls._instance.books [] cls._instance.users [] return cls._instanceJava里更常见的是双重检查锁虽然单线程练习不需要这么严格但思想都一样全局只有一份。这里要小心一个边界问题Logger、Config这类无状态的工具类用静态方法就够了不需要单例。但凡是持有数据的管理器、服务类用单例更合理。3.3 多语言写法对照含ArkTS视角这个综合练习的特点是可以灵活选择语言实现。我为了加深理解把同一套逻辑分别用Python、Java、C风格梳理过一遍C#的写法和Java几乎同源ArkTS基于TypeScript。下表是我整理的对照要点语言封装的关键途径继承接口/抽象单例常用写法Python约定_nameproperty单继承ABC /abstractmethod__new__Javaprivate getter/setter单继承interface/abstract classgetInstance()静态方法Cprivate 友元多继承纯虚函数静态局部变量C11线程安全C#private 属性Property单继承interface/abstract class静态属性 LazyTArkTSprivate 访问器单继承interface/abstract class静态实例属性其中ArkTS是鸿蒙开发的声明式UI语言基于TypeScript面向对象写法跟TS基本一致。它支持private、protected、public也支持interface和abstract class所以你在ArkTS里同样可以用这套图书管理系统的设计。唯一的区别是ArkTS不推荐用any类型你在定义User、Book这类对象时类型约束要写得更严谨。// ArkTS 风格示例 abstract class User { protected userId: string; protected name: string; constructor(userId: string, name: string) { this.userId userId; this.name name; } abstract getMaxBorrowCount(): number; abstract getMaxBorrowDays(): number; } class Student extends User { getMaxBorrowCount(): number { return 5; } getMaxBorrowDays(): number { return 30; } }我试过把同一套设计在Python和Java各写一遍收获比只写一遍大得多。因为Java会让你显式写implements、extends、Override这些关键字会逼着你思考“我到底在干哪种关系”而Python因为语法太灵活初学者很容易用着用着又回到面向过程的老路。4. 实操全流程复盘与高频坑位4.1 我推荐的编码顺序很多初学者拿到综合练习后第一反应是从实体类开始写写完Book写User写完User再想借书逻辑。我发现这个顺序容易导致后期返工因为你在设计实体类时没有充分考虑行为逻辑需要什么方法。我现在的习惯是先写使用场景主流程再写接口/抽象类再补实体类和实现类。举个具体例子先写main函数里演示借书流程的伪代码明确要有manager.borrow_book(user, book)方法根据调用需求定义LibraryManager的接口以及它依赖的User、Book、BorrowRecord定义User抽象类列出子类必须实现的方法max_borrow_count、max_borrow_days最后才写Student、Teacher这些具体子类和它们的细节字段。这种从外向内的顺序能保证你写出来的类都是“被需要的”而不是“我猜可能需要所以先写着”。整个练习里我的Book类和User类经历过一次比较大的重构就是这个顺序没做好的代价。4.2 经典bug速查表这个练习做完后我整理了以下几个出现频率极高的bug每一个我都踩过症状根本原因解决方案借书时库存减了但用户列表里没记录忘记在borrow_book里同步调用user.borrowed_books.append()把“库存扣减”和“用户记录”封装在同一个方法里Student对象打印出来只有__main__.Student object at 0x...没重写__str__User类中实现__str__返回name还书后罚款算成负数overdue_days()返回负数计算时用max(days, 0)兜底修改系别的学生信息后旧借阅记录显示也跟着变借阅记录里存了对象引用但还是期望它“即时更新”明确需求——对象是共享引用所以显示会自动更新如果不想被影响就要在设计时决定是否用值对象借书方法一会儿传入Student一会儿传入Teacher导致类型错误没把参数类型定为User在不同地方分别写了两个方法统一面向User编程BorrowRecord里直接复制user.name字符串算是这个项目里最深的一个坑。我当时想的是“把快照存下来防止以后用户信息变化影响历史记录”。这确实是个合理的需求——比如学生毕业改名了历史借阅记录应该保留当时的信息。但在一个综合练习里我更倾向于先做成对象引用然后通过方法动态获取。等到真需要“历史快照”时再引入值对象或者事件溯源也不迟。不要在没有明确需求的情况下引入额外的数据冗余。4.3 代码写完后自查一遍综合练习最容易被忽视的是最后一步——自查。我总结了一套面向对象代码的自查清单每次写完代码会逐条过一遍实体类的属性是否都是private有没有任何地方绕过了方法直接修改另一个对象的状态父类里有没有出现“根据子类类型判断”的代码如果有说明多态没用好。有没有方法名是getXxx()但是内部做了修改状态的操作这种“伪装成查询的修改”特别容易产生隐患。扩展一个“新用户类型”需要改几个文件如果超过两个说明设计还有优化空间。理想情况是只新增一个类其他代码零改动开闭原则。重复代码是否被提取到父类或辅助类了比如计算借阅剩余天数、格式化时间这些可以做成静态工具方法。这些自查点不一定全对但它们能帮你从“功能正确”迈向“设计合理”。功能正确是及格线设计合理才是面向对象综合训练真正要练的东西。回到开头那个问题面向对象到底怎么学我的答案永远是动手做一个需要多个类协作的完整小项目。你可以在一个周末里把这个图书管理系统的练习做完然后换个领域再做一个。第二次你会明显感觉到自己不会再纠结“这个变量该放哪个类里”这种问题了因为你的思维已经习惯从对象协作的角度去看代码。这个练习做完之后我建议你尝试着给系统增加一个“预约借书”的功能。你很快就会体会到前面设计的好与坏在扩展功能的那一刻会暴露得清清楚楚。