我用一章学的东西写了个图书借阅系统|Python 面向对象高级(5):综合实战
系列说明:第十一章「面向对象高级」学习笔记最后一篇。前四篇分别是封装、继承与方法重写、多继承与 MRO、多态与鸭子类型。这一篇把它们拼成一个能跑的程序。
案例名称:《书海灯塔 · 命令行图书借阅系统》
需求清单很直接:会员凭卡号密码登录,登录后可以借书、还书、查看自己的借阅,数据存 JSON。但里面藏着一条专门为多态准备的规则:
-
普通会员最多借 3 本。
-
VIP 会员最多借 6 + VIP等级 本(未指定等级默认为 1)。
-
图书可用数量 > 0 才能借;借出减 1,归还加 1。
-
已经借满的人不能再借。
一看到"不同会员上限不同",我就明白了:这就是上一篇的 member.get_max_borrow(),绝对不该写成 if type(member) == VIPMember。
类结构设计
我设计了五个类,职责分成两层:
-
业务对象层(是什么) :
Book封装编号、书名、作者、总数、可用数和借还动作;Member会员父类封装私有的卡号、密码、姓名和公共借阅记录;NormalMember和VIPMember继承父类,各自重写借阅上限。 -
系统层(怎么运作) :
LibrarySystem管 JSON 读写、登录、菜单循环、借书、还书、查看借阅。它只跟Member这个父类打交道,完全不知道有几种会员。
会员这一支,正好把整章串了一遍:卡号密码用 __ 私有化(封装),两个子类继承父类(继承),上限方法各自重写、系统只调父类签名(多态)。
数据文件设计
首次运行会生成两个 JSON,之后所有借还都写回它们。
books.json:
{"book_id": "B001", "title": "三体", "author": "刘慈欣", "total": 5, "available": 5}, {"book_id": "B002", "title": "平凡的世界", "author": "路遥", "total": 3, "available": 3}, {"book_id": "B003", "title": "战争与和平", "author": "托尔斯泰", "total": 2, "available": 2}, {"book_id": "B004", "title": "我是猫", "author": "夏目漱石", "total": 1, "available": 1}, {"book_id": "B005", "title": "唐诗三百首(绝版典藏)", "author": "佚名", "total": 2, "available": 0}
members.json(跑完一轮后的样子):
{ "type": "normal", "card_no": "1001", "password": "123", "name": "张三", "borrowed_ids": \["B001", "B003", "B004"
},
{
"type": "vip",
"card_no": "9001",
"password": "vip888",
"name": "王五",
"borrowed_ids": "B001",
"vip_level": 2
}
]
type 字段是我特意加的:读回来时靠它决定实例化哪个子类。而 borrowed_ids 只存图书编号,不存整本书的信息------避免数据冗余,改库存时不会出现"张三手里那本《三体》还剩几本"和图书馆实际库存不一致。
核心代码:会员与图书
by tryer07 | Python 面向对象高级(5)综合实战 · 学习笔记
class Book:
参数与属性显式标注类型,避免后续 min() 推导出未知的协议联合类型导致连锁报错
def init(self, book_id: str, title: str, author: str, total: int, available: int):
self.book_id = book_id
self.title = title
self.author = author
self.total: int = total
self.available: int = min(available, total) # 可用数不得大于总数
def is_available(self) -> bool:
return self.available > 0
def borrow(self) -> None:
self.available -= 1
def return_back(self) -> None:
self.available = min(self.available + 1, self.total)
def to_dict(self) -> dict:
return {"book_id": self.book_id, "title": self.title, "author": self.author,
"total": self.total, "available": self.available}
class Member:
卡号、密码、姓名全部私有
def init(self, card_no: str, password: str, name: str):
self.__card_no: str = card_no
self.__password: str = password
self.__name: str = name
self.borrowed_books: listBook = \[\]
def get_card_no(self) -> str:
return self.__card_no
def get_name(self) -> str:
return self.__name
def check_password(self, password: str) -> bool:
return password == self.__password
父类不定义具体规则,强制子类重写
def get_max_borrow(self) -> int:
return 0
def is_borrow_full(self) -> bool:
return len(self.borrowed_books) >= self.get_max_borrow()
def to_dict(self) -> dict:
return {
"type": "normal",
"card_no": self.__card_no,
"password": self.__password,
"name": self.__name,
"borrowed_ids": book.book_id for book in self.borrowed_books,
}
class NormalMember(Member):
def get_max_borrow(self) -> int:
return 3
class VIPMember(Member):
def init(self, card_no: str, password: str, name: str, vip_level: int = 1):
super().init(card_no, password, name)
self.__vip_level: int = vip_level
def get_max_borrow(self) -> int:
return 6 + self.__vip_level
def to_dict(self) -> dict:
data = super().to_dict()
data"type" = "vip"
data"vip_level" = self.__vip_level
return data
几个我觉得很妙的设计点:
-
get_max_borrow()父类故意返回 0 :父类不知道该定多少,但必须有个方法占位,否则is_borrow_full()里调它就会报错。返回 0 意味着"父类对象一本都借不了",把"必须由子类给出真实规则"变成了硬约束。 -
is_borrow_full()写在父类 :判断逻辑(已借数量 ≥ 上限)对两种会员完全一样,只有上限数值不同。父类实现逻辑,子类提供数字------共性上提,差异下放,这是继承最实在的样子。 -
VIPMember.to_dict()用super().to_dict()打底:父类把公共字段拼好,子类只加专属字段。如果重新写一遍全部字段,将来父类加字段就得改两遍。 -
密码私有,但又需要存入 JSON :
to_dict()是类内部方法,能读self.__password。外部只能问check_password()对不对,拿不到原文。
系统层:它不知道自己服务几种会员
class LibrarySystem:
def init(self):
self.books: dictstr, Book = {}
self.members: dictstr, Member = {}
def load_data(self) -> None:
... 省略文件检查 ...
for item in self._read_json("members.json"):
多态的应用:靠 type 字段决定实例化哪个子类
if item"type" == "vip":
member: Member = VIPMember(item"card_no", item"password",
item"name", item.get("vip_level", 1))
else:
member = NormalMember(item"card_no", item"password", item"name")
恢复借阅记录,引用真实的图书对象
for book_id in item.get("borrowed_ids", \[\]):
if book_id in self.books:
member.borrowed_books.append(self.booksbook_id)
self.membersmember.get_card_no() = member
def borrow_book(self, member: Member) -> None:
先验上限、再验编号、验库存
if member.is_borrow_full():
print(f"借阅失败:达到最大可借数量({member.get_max_borrow()} 本)")
return
book_id = input("请输入要借阅的图书编号(输0取消):").strip()
target_book = self.books.get(book_id)
if target_book is None:
print("借阅失败:图书编号不存在")
return
if not target_book.is_available():
print(f"借阅失败:《{target_book.title}》暂无库存")
return
target_book.borrow()
member.borrowed_books.append(target_book)
self.save_data()
print(f"借阅成功:您已借 {len(member.borrowed_books)}/{member.get_max_borrow()} 本")
login() 返回 Member | None:卡号或密码错就循环重问,输 0 就返回 None。我觉得这里最优雅的是------"只有登录成功才能访问系统"不是靠一个 is_logged_in 标志位到处判断,而是靠没有 member 对象就根本进不到菜单循环这条结构约束。想绕过都拿不到入场券。
实测:规则全命中
会话 1:普通会员张三(1001 / 123),测试边界规则
-
借《三体》成功,库存 5 → 4,已借 1/3。
-
借《唐诗三百首》,库存 0,拒绝。
-
借《平凡的世界》和《战争与和平》成功,达到 3/3。
-
再借被拒,提示达到上限。
-
归还《平凡的世界》,库存 2 → 3。
-
借《我是猫》(总数 1),成功,该馆尚余 0 本。
会话 2:VIP 王五(9001 / vip888,等级 2)
-
登录成功,系统提示最多可借 8 本(6 + 2)。
-
借《三体》成功,提示已借 1/8 本。
-
同一段菜单代码,张三那里显示 /3,王五这里显示 /8。 系统层没有一行 if 判断会员种类,全是
member.get_max_borrow()自己返回不同的数。
会话 3:张三重新登录
- 程序重启,上一次会话已经销毁,借阅记录仍然完整回来了------
load_data()把borrowed_ids又还原成了Book对象引用,上限依旧是 3/3。
踩到的三个坑
坑一:一行没有注解的 min(),让三处代码一起报错
Book 最初我没写类型注解。self.available = min(available, total) 这一行,因为参数没有类型来源,检查器只能推断出"支持 < 和 > 比较的未知类型"(即 SupportsDunderLT | SupportsDunderGT)。这个非 int 的结论顺着 self.available 传到了所有用到它的地方,导致下游的 > 0、-= 1、min(...) 全部报警。
修法 :不要加抑制注释,而是给源头加类型标注。参数写 total: int,属性写 self.available: int = ...。属性类型一旦确定,下游的告警自然全部消失。
坑二:变量名复用导致的类型冲突
borrow_book 里,我先在展示循环用了 for book in self.books.values(),然后顺手写了 book = self.books.get(book_id)。mypy 立刻报错:循环里的 book 已经被推断为 Book,而 dict.get() 返回的是 Book | None------名字前后的类型不兼容。
修法 :查找结果换个名字 target_book。循环变量用完就别复用,尤其是接下来要接一个可空结果的时候。
坑三:show_borrowed(self, member) 根本不用 self
第 1 篇我就遇到过 PyCharm 的「可降级为静态方法」提示。这两个方法只是"逻辑上属于系统",但不碰任何实例状态,标成 @staticmethod 更诚实。不过加了 @staticmethod 后就不能再用 self,改之前得先把方法体读一遍,确认没用到实例状态。