引言
第八章前七篇已经把类的基础零件全部备齐:类与实例(第 1 篇)、__init__ 与属性(第 2 篇)、方法与 self(第 3 篇)、封装与 property(第 4 篇)、继承(第 5 篇)、多态(第 6 篇),以及上一篇文章的魔法方法协议(第 7 篇)。零件再好,也要有一台机器把它们拧在一起——本篇就是第八章第一个实战项目:图书管理系统。
系统要解决的具体问题很小却很完整:图书馆管理员要把书登记入库、按 ISBN 查书、办理借出与归还、随时知道馆里还有多少书可借。听起来简单,但它强迫你回答几个真正的设计问题:一本书作为数据对象应该保存什么字段?「可借」这个状态是谁算出来的?借出五本、还回三本之后库存怎么流转?借一本不存在的书该让程序「崩」还是给出人话提示?这些问题正是前七篇知识点的用武之地:Book 类负责书的静态信息与状态,Library 类负责业务规则,自定义异常(第四章第 8 篇)表达「查无此书」,命令行菜单(第三章的循环与分支)提供人机交互。
阅读前提:本篇只使用第八章第 1-7 篇讲解过的语法——__init__、self、property、__repr__、__eq__、__hash__、自定义异常与 try/except、while True 菜单与 input()。订单与库存的对象建模是下一篇(第 9 篇)的主题,本篇不展开;测验在第 10 篇。所有代码与输出均在本机 CPython 3.12.10(Windows)实测,你可以完全照抄运行。
概念与原理
实体划分:谁该是类,谁该是实例
面向对象设计的第一步是识别实体(entity)——问题域里需要「单独存在、带状态」的事物。图书管理系统里有两种明显实体:书(一本书的馆藏记录)与图书馆(管理一批书的机构)。于是自然划出两个类:Book 描述单种图书「是什么」,Library 描述「怎么管」。
注意每个 Book 实例代表的是一种书(按 ISBN 区分),而不是某一本实体书。因此它同时保存两类信息:静态信息(ISBN、书名、作者)与动态状态(馆藏总册数、已借出册数)。「馆里还有几本可借」不需要单独存字段——它是一个派生量(derived value),由 total - borrowed 即时算出,这正是第 4 篇 property 的场景:调用方只是读属性,逻辑集中在一处,永远不会出现「两个字段对不上」的脏数据。
状态流转:借出与归还如何改变数字
图书的状态机(state machine)只有两个动作、两类数字:借出让 borrowed +1,归还让 borrowed -1,total 全程不变,「可借」永远等于 total - borrowed。两个动作都要做前置校验:借出前检查可借册数是否足够,否则会出现「可借 -1」这种荒谬状态;归还前检查已借出册数,防止「还回一本从没借出的书」。校验失败如何表达?直接 raise——业务失败用 RuntimeError 表达,查不到书专门定义 BookNotFoundError(ValueError) 继承自 ValueError(第四章第 8 篇讲过自定义异常与继承层次),这样调用方既能统一捕获,又能按类型区分处理。
双结构存储:列表保序,字典提速
Library 内部用两种容器协同。self.books 是列表,装全部图书对象,保留入库顺序——图书列表打印时按这个顺序输出;self.by_isbn 是字典,键是 ISBN,值是对应的 Book 对象——按 ISBN 查询(含借出、归还的前置查找)平均 O(1) 命中,不必每次遍历整个列表。列表的追加是 O(1),字典判重也是 O(1),所以「同 ISBN 重复入库只加册数」的判断同样高效。代价是两次写入(append 与 dict[...] =)必须成对出现——这是典型的空间换时间设计,工程上叫索引(index)。
在这套设计里,前一篇的魔法方法直接变成工具箱:__repr__ 让调试时一眼认出是哪本书、__eq__/__hash__ 让同 ISBN 的书「相等且可哈希」、__str__ 让 print(book) 输出管理员看得懂的文案——三个方法加起来不到十行,却承包了整系统的所有显示与比较需求。
操作与实现
第一部分:异常、ISBN 校验与 Book 类
把下面三段代码按顺序保存到同一个文件 library.py 中,就是完整的图书管理系统。第一段定义自定义异常、ISBN-13 校验函数与 Book 类:
class BookNotFoundError(ValueError):
"""馆藏中没有该 ISBN 时抛出的自定义异常。"""
def check_isbn(isbn):
"""校验 ISBN-13:去掉连字符后必须为 13 位数字,且末位校验位正确。"""
digits = []
for c in isbn.replace("-", ""):
if not c.isdigit():
raise ValueError("ISBN 只能包含数字与连字符")
digits.append(int(c))
if len(digits) != 13:
raise ValueError("ISBN 长度错误:应为 13 位数字")
weight = [1, 3] * 6 # 前 12 位的权重
check = (10 - sum(d * w for d, w in zip(digits, weight)) % 10) % 10
if digits[-1] != check:
raise ValueError("ISBN 校验位不正确")
class Book:
"""单种图书:馆藏总数与已借出册数,可借册数由两者相减得出。"""
def __init__(self, isbn, title, author, total=1):
check_isbn(isbn) # 入库前先校验
self.isbn = isbn.replace("-", "") # 规范化存储:统一不带连字符
self.title = title
self.author = author
self.total = total # 馆藏总册数
self.borrowed = 0 # 已借出册数
@property
def available(self):
return self.total - self.borrowed
def __repr__(self):
return f"Book({self.isbn!r}, {self.title!r})"
def __str__(self):
return (f"《{self.title}》{self.author}|ISBN {self.isbn}"
f"|可借 {self.available}/{self.total} 册")
def __eq__(self, other):
return isinstance(other, Book) and self.isbn == other.isbn
def __hash__(self):
return hash(self.isbn)
先看 ISBN 校验的原理。国际标准书号(International Standard Book Number)的 ISBN-13 是 13 位数字,末位是校验位(check digit),由前 12 位算出来:前 12 位从左到右依次乘以权重 1、3、1、3……交替,加权求和后对 10 取余,校验位等于 (10 - 余数) % 10。以畅销书《Python编程:从入门到实践》(人民邮电出版社,ISBN 9787115428028)为例:前 12 位 978711542802 的加权和为 9×1+7×3+8×1+7×3+1×1+1×3+5×1+4×3+2×1+8×3+0×1+2×3=112,112 对 10 取余得 2,校验位是 (10−2)%10=8,与第 13 位一致,通过。这个算法的作用是拦截抄写错误——任何一位数字写错,校验位几乎必然对不上(只有约 1/10 的错位能侥幸通过),这是图书行业防手误的标准手段,我们把它做成入库前的守门员。
Book 的另一处设计值得注意:self.isbn = isbn.replace("-", "") 把用户可能输入的 978-7-115-42802-8 统一规范化为 9787115428028 再存储。这是本次实战实测踩出来的坑——如果不规范化,带连字符和不带连字符的同一本书会被 __eq__ 视为两本,字典索引也会出现两个条目(详见文末易错点)。
第二部分:Library 类
第二段是管理中枢 Library:所有改动馆藏的操作都必须经过它,杜绝绕过校验直接改数据:
class Library:
"""图书管理系统:用列表保存全部馆藏,用字典按 ISBN 建立索引。"""
def __init__(self, name):
self.name = name
self.books = [] # 馆藏列表:装 Book 对象
self.by_isbn = {} # 索引字典:isbn -> Book
def add_book(self, isbn, title, author, total=1):
if isbn in self.by_isbn: # 同 ISBN 再次入库:只增册数
self.by_isbn[isbn].total += total
else:
book = Book(isbn, title, author, total)
self.books.append(book)
self.by_isbn[isbn] = book
def find(self, isbn):
if isbn not in self.by_isbn:
raise BookNotFoundError(f"馆藏中找不到 ISBN {isbn}")
return self.by_isbn[isbn]
def borrow(self, isbn, count=1):
book = self.find(isbn)
if book.available < count:
raise RuntimeError(f"《{book.title}》可借不足:仅剩 {book.available} 册")
book.borrowed += count
return book
def return_book(self, isbn, count=1):
book = self.find(isbn)
if book.borrowed < count:
raise RuntimeError(f"归还 {count} 册超过已借出 {book.borrowed} 册")
book.borrowed -= count
return book
def stats(self):
kinds = len(self.books)
total = sum(b.total for b in self.books)
borrowed = sum(b.borrowed for b in self.books)
return kinds, total, total - borrowed, borrowed
def __str__(self):
return f"图书馆「{self.name}」:共 {len(self.books)} 种馆藏"
四个业务方法各司其职。add_book 先查字典:ISBN 已存在说明是「补货」,只把 total 加上新册数;不存在才构造新的 Book 并同时写入列表与字典。find 是唯一的查询出口,查不到抛 BookNotFoundError。borrow 与 return_book 对称:先 find、再校验、最后修改 borrowed——三个动作的顺序不能乱,校验必须在修改之前。stats 用三次 sum 聚合出四个统计数:馆藏种数、总藏册数、在架册数、已借出册数,其中「在架 = 总藏 − 已借出」与 Book.available 的推导逻辑完全一致,数据永远自洽。
第三部分:命令行菜单
第三段是命令行交互层(复用第三章的 while True + 分支菜单)。它把用户输入的字符串翻译成对 Library 的方法调用,整数输入交给内置的 int() 解析,业务失败与输入错误统一收进一个 except 兜底:
def main():
lib = Library("青松书屋")
menu = (
"1 添加图书 | 2 查询图书 | 3 借出图书 | 4 归还图书\n"
"5 图书列表 | 6 统计信息 | 0 退出"
)
while True:
print("\n===== " + lib.name + " =====")
print(menu)
choice = input("请选择操作: ").strip()
if choice == "0":
print("再见,欢迎再来!")
break
try:
if choice == "1":
lib.add_book(
input("ISBN: ").strip(),
input("书名: ").strip(),
input("作者: ").strip(),
int(input("册数(默认1): ").strip() or "1"),
)
print("入库成功")
elif choice == "2":
print(lib.find(input("ISBN: ").strip()))
elif choice == "3":
book = lib.borrow(
input("ISBN: ").strip(),
int(input("借出册数(默认1): ").strip() or "1"),
)
print(f"借出成功:{book}")
elif choice == "4":
book = lib.return_book(
input("ISBN: ").strip(),
int(input("归还册数(默认1): ").strip() or "1"),
)
print(f"归还成功:{book}")
elif choice == "5":
if not lib.books:
print("馆藏为空")
else:
for book in lib.books:
print(book)
elif choice == "6":
kinds, total, available, borrowed = lib.stats()
print(f"馆藏 {kinds} 种,总藏 {total} 册,"
f"在架 {available} 册,已借出 {borrowed} 册")
else:
print("无效的操作编号")
except (BookNotFoundError, RuntimeError, ValueError) as e:
print("操作失败:", e)
if __name__ == "__main__":
main()
注意入口守卫 if __name__ == "__main__":——运行 python library.py 时执行 main();被别的脚本 import library 时不会弹出菜单,只导入类。这是模块化(第四章)的标准姿势。except 的元组里囊括三类失败:BookNotFoundError(查无此书)、RuntimeError(数量不足等业务违规)、ValueError(ISBN 非法与 int() 解析失败),统一打印「操作失败:原因」,菜单循环继续——这正是第四章 EAFP 风格(先尝试、后兜底)的实践:不预先判断输入是否合法,撞上异常再处理。
组装运行:API 级快速验证
把三段合并保存为 library.py 后,先不急着进菜单,用一个小脚本直接调用 API,验证 Book 的协议行为与借还状态流转。在 library.py 同目录新建 demo.py(import library 时菜单不会弹出):
from library import Book, Library, BookNotFoundError
# Book 的协议行为:字符串、相等性、哈希
b1 = Book("9787115428028", "Python编程:从入门到实践", "埃里克·马瑟斯", 3)
print(repr(b1))
print(str(b1))
b1b = Book("978-7-115-42802-8", "Python编程:从入门到实践", "埃里克·马瑟斯", 3)
print("带连字符同样入库,且视为同一本书:", b1 == b1b)
# 非法 ISBN 被拒绝
try:
Book("12345", "坏书", "某人")
except ValueError as e:
print("拒绝入库:", e)
# 借出/归还的状态流转
lib = Library("测试馆")
lib.add_book("9787115428028", "Python编程:从入门到实践", "埃里克·马瑟斯", 3)
b = lib.borrow("9787115428028", 1)
print("借出 1 册后可借:", b.available, "/", b.total)
lib.return_book("9787115428028", 1)
print("归还后可借:", lib.find("9787115428028").available, "/", b.total)
# 找不到书时抛自定义异常
try:
lib.find("9780000000000")
except BookNotFoundError as e:
print("捕获自定义异常:", e)
实际运行输出:
Book('9787115428028', 'Python编程:从入门到实践')
《Python编程:从入门到实践》埃里克·马瑟斯|ISBN 9787115428028|可借 3/3 册
带连字符同样入库,且视为同一本书: True
拒绝入库: ISBN 长度错误:应为 13 位数字
借出 1 册后可借: 2 / 3
归还后可借: 3 / 3
捕获自定义异常: 馆藏中找不到 ISBN 9780000000000
这里的输出逐字为本机运行结果,顺序与 demo.py 脚本的书写顺序完全一致。前四行与最后三行分别展示三个协议在实战中的形态:repr 给开发者看(可重建对象的紧凑形式)、str 给管理员看(含作者与可借数)、__eq__ 让带不带连字符的同一本书相等;非法 ISBN 被 ValueError 拒绝、查无此书被 BookNotFoundError 捕获,异常体系把两类失败安排得明明白白。
一次完整运行会话
运行 python library.py,输入以下十二个操作(这是本机一次完整的真实会话,含正常流程与三类失败场景),菜单打印与提示符逐字为程序输出,输入内容为管理员实际键入:
===== 青松书屋 =====
1 添加图书 | 2 查询图书 | 3 借出图书 | 4 归还图书
5 图书列表 | 6 统计信息 | 0 退出
请选择操作: 1
ISBN: 9787115428028
书名: Python编程:从入门到实践
作者: 埃里克·马瑟斯
册数(默认1): 3
入库成功
===== 青松书屋 =====
1 添加图书 | 2 查询图书 | 3 借出图书 | 4 归还图书
5 图书列表 | 6 统计信息 | 0 退出
请选择操作: 1
ISBN: 9787115479044
书名: 流畅的Python
作者: 卢西亚诺·拉马略
册数(默认1): 2
入库成功
===== 青松书屋 =====
1 添加图书 | 2 查询图书 | 3 借出图书 | 4 归还图书
5 图书列表 | 6 统计信息 | 0 退出
请选择操作: 6
馆藏 2 种,总藏 5 册,在架 5 册,已借出 0 册
===== 青松书屋 =====
1 添加图书 | 2 查询图书 | 3 借出图书 | 4 归还图书
5 图书列表 | 6 统计信息 | 0 退出
请选择操作: 3
ISBN: 9787115428028
借出册数(默认1): 2
借出成功:《Python编程:从入门到实践》埃里克·马瑟斯|ISBN 9787115428028|可借 1/3 册
===== 青松书屋 =====
1 添加图书 | 2 查询图书 | 3 借出图书 | 4 归还图书
5 图书列表 | 6 统计信息 | 0 退出
请选择操作: 3
ISBN: 9787115428028
借出册数(默认1): 2
操作失败: 《Python编程:从入门到实践》可借不足:仅剩 1 册
===== 青松书屋 =====
1 添加图书 | 2 查询图书 | 3 借出图书 | 4 归还图书
5 图书列表 | 6 统计信息 | 0 退出
请选择操作: 4
ISBN: 9787115428028
归还册数(默认1): 1
归还成功:《Python编程:从入门到实践》埃里克·马瑟斯|ISBN 9787115428028|可借 2/3 册
前半场展示了状态流转的完整闭环:入库时 可借 3/3,借出 2 册后 可借 1/3,再借 2 册被守卫拦下(只剩 1 册可借),归还 1 册后回到 可借 2/3。菜单继续,后半场验证列表、非法 ISBN、查无此书与统计:
===== 青松书屋 =====
1 添加图书 | 2 查询图书 | 3 借出图书 | 4 归还图书
5 图书列表 | 6 统计信息 | 0 退出
请选择操作: 5
《Python编程:从入门到实践》埃里克·马瑟斯|ISBN 9787115428028|可借 2/3 册
《流畅的Python》卢西亚诺·拉马略|ISBN 9787115479044|可借 2/2 册
===== 青松书屋 =====
1 添加图书 | 2 查询图书 | 3 借出图书 | 4 归还图书
5 图书列表 | 6 统计信息 | 0 退出
请选择操作: 1
ISBN: 12345
书名: 测试书
作者: 某作者
册数(默认1): 1
操作失败: ISBN 长度错误:应为 13 位数字
===== 青松书屋 =====
1 添加图书 | 2 查询图书 | 3 借出图书 | 4 归还图书
5 图书列表 | 6 统计信息 | 0 退出
请选择操作: 3
ISBN: 9787000000000
借出册数(默认1): 1
操作失败: 馆藏中找不到 ISBN 9787000000000
===== 青松书屋 =====
1 添加图书 | 2 查询图书 | 3 借出图书 | 4 归还图书
5 图书列表 | 6 统计信息 | 0 退出
请选择操作: 2
ISBN: 9787115479044
《流畅的Python》卢西亚诺·拉马略|ISBN 9787115479044|可借 2/2 册
===== 青松书屋 =====
1 添加图书 | 2 查询图书 | 3 借出图书 | 4 归还图书
5 图书列表 | 6 统计信息 | 0 退出
请选择操作: 6
馆藏 2 种,总藏 5 册,在架 4 册,已借出 1 册
===== 青松书屋 =====
1 添加图书 | 2 查询图书 | 3 借出图书 | 4 归还图书
5 图书列表 | 6 统计信息 | 0 退出
请选择操作: 0
再见,欢迎再来!
注意几个细节。图书列表(操作 5)按 books 列表的入库顺序打印,元素走各自的 __str__;非法 ISBN 与查无此书都没有让程序崩溃——异常被 except 接住、打印一句人话、循环继续;最终统计「在架 4 册,已借出 1 册」与前半场「借出 2 册、归还 1 册」的净额完全吻合(总藏 5 − 已借出 1 = 在架 4),状态机的数字全程自洽。
易错点与陷阱
陷阱一:ISBN 不做规范化,同一本书被当成两本
用户在现实里输入 ISBN 的习惯五花八门:9787115428028、978-7-115-42802-8、甚至带着空格。如果 Book 把传入字符串原样存进 isbn 字段,带连字符与不带连字符的两个 Book 在 __eq__ 面前就是两本书,by_isbn 字典里也会出现两个条目——补货时 add_book 查不到已有记录,不会叠加册数,反而再建一条重复馆藏。本次实战第一版就实测踩中此坑。修复只有一行:入库时 isbn.replace("-", "") 统一规范化,__eq__、__hash__、字典键全部基于规范形式,连字符问题就地消失。教训:凡是作为唯一标识的字段,必须在入口处规范化,不要在比较、索引的每个使用点各做一遍。
陷阱二:借出不校验数量,可借数变成负数
如果 borrow 里漏掉 if book.available < count 这行守卫,借 5 本藏 3 本的书会得到「可借 -2/3」的荒谬状态;更隐蔽的是,负数可借会让后续归还逻辑全部错乱(归还 5 本反而「合法」)。归还方向同理:不校验 borrowed < count,管理员手滑多还一次也会产生「已借出为负」。本项目的两个守卫实测输出「可借不足:仅剩 1 册」与「归还 N 册超过已借出 M 册」,把矛盾暴露在发生的那一刻。工程上这类守卫叫防御性检查,宁可多报一次错,也不让状态机进入非法状态。
陷阱三:int() 解析用户输入,失败消息全是英文
菜单里 int(input("册数(默认1): ")) 遇到输入 abc 会抛 invalid literal for int() with base 10: 'abc'——这是真实输出,字符串一眼看不懂英文。它被统一 except ValueError 接住后打印为「操作失败: invalid literal for int() with base 10: ‘abc’」,虽然不崩,但提示不友好。改进方向:解析前先过滤(如 if not s.isdigit(): 提示「请输入数字」),或单独捕获 ValueError 输出中文提示。小系统可以容忍,真正的产品必须处理——这正是第四章「输入校验」话题在实战中的落点。
陷阱四:可变默认参数让所有图书馆共享同一份馆藏
Library.__init__(self, name, books=[]) 这种写法是 Python 的经典陷阱:默认参数 [] 在定义时只创建一次,所有没传 books 的实例共享同一个列表——一个图书馆加的书,另一个图书馆的馆藏「凭空出现」。实测:两个未传参的实例中,向 a.items 追加元素后 b.items 同步可见。正确姿势正是本项目用的 self.books = [](在函数体内创建新列表)。类似地,注意 find 返回的是 Book 对象引用,调用方可以直接 book.total = 999 绕过 add_book——如果要求彻底封装,需要返回副本或只读视图,这是第 4 篇封装话题的延伸。
扩展方向:Student 借阅者
当前系统按书管理,借出的「人」是缺席的。下一步自然扩展是增加 Student 借阅者类:每个学生有借书证号、姓名与可借上限(如 5 册),Library 增加 borrow_to(student, isbn) 与 return_from(student, isbn)——借出时同时校验「书的可借数」与「学生已借册数」,并记录借阅流水(哪本书、何时借、何时还)。这会让设计复杂一截:借阅记录(借书证号、ISBN、借出/归还时间)需要一个新的实体类,统计也不再只是两个数字的减法。这类「多实体协作 + 状态与记录的平衡」正是下一篇第 9 篇《订单与库存的对象建模》的主题——订单系统本质上就是书、读者、借阅记录三者的抽象升级。学完本篇后,你可以先尝试为 Student 写一个草图,再对照第 9 篇的建模思路检查自己的分工是否合理。
小结
本篇用约 140 行代码完成了一个可运行的图书管理系统,把第八章前七篇的知识全部落到了实处:Book 封装静态信息与借还状态(property 派生「可借」数,__repr__/__str__/__eq__/__hash__ 提供显示与比较协议),Library 用列表保序、字典索引,借还与入库全部带前置校验,自定义异常 BookNotFoundError 与统一的 except 兜底保证任何输入都不会让程序崩溃,第三章的菜单循环提供人机交互。完整的十二步真实会话展示了状态流转自洽、失败场景友好的完整闭环。文末四个易错点(ISBN 规范化、数量守卫、int() 解析、可变默认参数)都来自本机实测或经典教训。下一篇进入订单与库存的对象建模,把「对象协作」再推进一步。
练习与思考题
-
补全 ISBN 错误消息:当前
Book("12345", ...)的提示是「ISBN 长度错误」。「12345」只有 5 位,你能否修改check_isbn,让错误消息同时提示实际位数与期望位数(如「ISBN 长度错误:收到 5 位,应为 13 位」)?实现后在demo.py中验证输出。 -
实现超借计数保护:在
borrow之外,想一想如果两个管理员同时(比如在两张终端上)对同一本书操作会发生什么?available < count的检查与book.borrowed += count的修改之间没有间隙保护,这属于并发一致性话题——先用文字说明问题,不必实现(并发留到并发章节)。 -
给系统加「删除图书」功能:新增菜单操作 7「下架图书」,输入 ISBN 后把书从
books列表与by_isbn字典同时移除;若该书仍有未归还册数则拒绝下架并给出提示。实现并思考:为什么必须两个容器同步删除?(提示:双结构的一致性。) -
思考题:
stats返回的是元组(kinds, total, available, borrowed),调用方一次解包四个变量。如果以后要加第五个统计项(如「借阅次数」),这个函数签名会牵动哪些调用点?更稳的返回方式是类(如返回一个具名元组或小对象)——这件事在第 9 篇订单建模时会再次出现,先记下这个疑问。