AI 技术博客
返回首页
Python 基础 29 分钟阅读

实战:图书管理系统

Python面向对象实战图书管理系统命令行

引言

第八章前七篇已经把类的基础零件全部备齐:类与实例(第 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() 解析、可变默认参数)都来自本机实测或经典教训。下一篇进入订单与库存的对象建模,把「对象协作」再推进一步。

练习与思考题

  1. 补全 ISBN 错误消息:当前 Book("12345", ...) 的提示是「ISBN 长度错误」。「12345」只有 5 位,你能否修改 check_isbn,让错误消息同时提示实际位数与期望位数(如「ISBN 长度错误:收到 5 位,应为 13 位」)?实现后在 demo.py 中验证输出。

  2. 实现超借计数保护:在 borrow 之外,想一想如果两个管理员同时(比如在两张终端上)对同一本书操作会发生什么?available < count 的检查与 book.borrowed += count 的修改之间没有间隙保护,这属于并发一致性话题——先用文字说明问题,不必实现(并发留到并发章节)。

  3. 给系统加「删除图书」功能:新增菜单操作 7「下架图书」,输入 ISBN 后把书从 books 列表与 by_isbn 字典同时移除;若该书仍有未归还册数则拒绝下架并给出提示。实现并思考:为什么必须两个容器同步删除?(提示:双结构的一致性。)

  4. 思考题:stats 返回的是元组 (kinds, total, available, borrowed),调用方一次解包四个变量。如果以后要加第五个统计项(如「借阅次数」),这个函数签名会牵动哪些调用点?更稳的返回方式是类(如返回一个具名元组或小对象)——这件事在第 9 篇订单建模时会再次出现,先记下这个疑问。