简介:面向有一定Python基础、希望深入掌握面向对象编程与常见设计模式的开发者,这份详细设计源码提供了从类与对象、属性方法到继承多态、工厂模式等知识点的完整代码示例与配套笔记。压缩包共43个文件,包含21个Python源码、21个Markdown笔记及1个说明txt,整体仅36KB。Markdown侧系统讲解类定义、构造函数、继承、魔术方法、静态方法、特性方法等核心概念,并附有总结与补充知识;Python侧则展示多个类的继承、实例属性继承、登录案例实战、工厂设计模式等具体落地,部分文件还呈现动态参数、作用域与代码复用技巧,便于边读边练。整个资源小巧但体系完整,已有420人学习下载,适合自学、备课,也可作为面向对象编程复习和设计模式入门的高密度参考包;配合源码中的登录案例与工厂模式实例,读者能快速将所学迁移到自动化脚本、后端接口封装等真实场景。
1. 详细设计不是画几张图,而是要能直接变成 Python 面向对象源码
在软件工程课程里,概要设计之后进入详细设计:模块内部的类、接口、数据结构、流程分支都要落到文档上,随后照着设计产出 Python 面向对象源码。头歌软件工程导论实验的"软件详细设计-2"关卡,要求的就是设计写清楚之后,代码能和设计一一对应。
但很多人把这两件事拆成两步:图画完就翻篇,代码另起炉灶。类名改了、参数换了、业务分支少写两个,文档最后成了摆设。面向对象设计只有在能约束代码时才有价值。真正落地的做法是:类图上每个类都能在源码里找到,时序图上每条消息都对应一次方法调用,数据结构表里每个字段都对应一个 dataclass 属性。
这篇文章适合正在做实验的学生,也适合接手带设计文档项目的工程师。下面用借书管理系统走完整流程:从设计文档抽类、实现方法体、用 pytest 验证一致性,最后从源码反向维护设计。
2. 把详细设计文档拆成 Python 面向对象类骨架:类、接口与数据模型
2.1 从用例文字和时序图里抓候选类与方法
详细设计的前半部分通常是"模块结构 + 接口设计 + 数据结构 + 流程描述"。把它们变成 Python 代码,第一步不是写类,而是把设计材料里的名词和动词挑出来:名词对应候选类,动词对应候选方法。对比面向过程的全局函数加字典写法,面向对象的优势在于把数据字段和操作它们的方法收拢到同一个类里,设计文档里的依赖关系才走得通。
以借书流程为例,详细设计里往往这样描述:读者持借书证提出借书,系统验证读者身份和可借余额,检查图书库存,登记借阅记录并扣减库存。这一句里能抽出的类就包括 Reader、Book、BorrowRecord、BookRepository、BorrowService;动词"验证""检查""登记""扣减"分别对应 BorrowService.borrow 内部的私有方法或仓库接口方法。抽完类之后再对照类图补属性:Reader 有 reader_id、name、borrow_limit,Book 有 book_id、title、available_stock。
设计文档里的内容与源码落点的对应关系,我一般维持这样一张表:
| 详细设计条目 | Python 源码落点 | 示例 |
|---|---|---|
| 类图里的类 | 模块里的 class | Reader、Book |
| 类图中的属性 | dataclass 字段 / 实例属性 | book_id、available_stock |
| 类图中的依赖箭头 | 构造参数 + 类型注解 | BorrowService 构造器里的 book_repo |
| 接口规格 | ABC 抽象方法 | BookRepository.update_stock |
| 时序图消息 | 方法调用链 | borrow → record_repo.save |
| 数据结构字段表 | dataclass + type hint | BorrowRecord.record_id |
这张表也是后面写测试时做追溯的索引:每个条目都应该有源码对应物,没有对应物的地方就是设计做过头了。
2.2 用 dataclass 把详细设计的数据结构表写进模型层
详细设计通常会为每个实体给一张字段表:字段名、类型、可空、默认值、说明。把这张表翻译成 Python 时,我推荐先用 dataclass 而不是手写普通类,原因有两个:字段声明看着像表,且自动生成__init__和__repr__,减少样板代码。领域模型层可以先按下面这样写:
# domain/models.py """领域模型:对应详细设计中的实体数据结构表。""" from dataclasses import dataclass, field from datetime import date from enum import Enum class BorrowStatus(str, Enum): BORROWED = "borrowed" RETURNED = "returned" OVERDUE = "overdue" @dataclass class Book: book_id: str title: str total_stock: int available_stock: int = 0 @dataclass class Reader: reader_id: str name: str borrow_limit: int = 5 borrowed_ids: list[str] = field(default_factory=list) @dataclass class BorrowRecord: record_id: str reader_id: str book_id: str borrow_date: date due_date: date return_date: date | None = None status: BorrowStatus = BorrowStatus.BORROWED这里几个参数选择要结合设计文档说清楚。BorrowStatus 用 str 枚举而不是 int 枚举,是为了让状态值在数据库和日志里可读,BORROWED 存成字符串而不是 0。Reader.borrowed_ids 用 default_factory=list 而不是直接写= [],因为 Python 的默认参数在函数定义时只计算一次,直接给空列表会让所有 Reader 实例共享同一个列表,这是写默认可变值最常见的坑。return_date 标成date | None,对应字段表里"可空"那一列;项目跑在 3.10 以下的旧版本时,这里要换成Optional[date]。
2.3 用 ABC 和类型注解把接口设计钉死在契约里
详细设计的接口部分给出的是方法签名,不负责具体实现。Python 里最能表达"这是接口"的机制是abc.ABCMeta加@abstractmethod。把接口单独放一个文件,业务实现类再去继承,这样设计文档里的每个接口都能在源码里被 grep 到,而且漏实现时类会无法实例化,报错比运行时才报 AttributeError 早得多。
# services/contracts.py """服务与仓库接口:对应详细设计第 2 节的接口规格。""" from abc import ABC, abstractmethod from domain.models import Book, BorrowRecord, Reader class BookRepository(ABC): @abstractmethod def find_by_id(self, book_id: str) -> Book: """按编号查书,未找到返回 None。""" @abstractmethod def update_stock(self, book_id: str, delta: int) -> None: """调整库存,delta 为负表示扣减。""" class BorrowRecordRepository(ABC): @abstractmethod def next_id(self) -> str: """生成新的记录编号。""" @abstractmethod def find_by_id(self, record_id: str) -> BorrowRecord | None: """按记录编号查询。""" @abstractmethod def save(self, record: BorrowRecord) -> None: """保存记录。""" class ReaderRepository(ABC): @abstractmethod def find_by_id(self, reader_id: str) -> Reader | None: """按读者编号查读者。""" class BorrowService(ABC): @abstractmethod def borrow(self, reader_id: str, book_id: str) -> BorrowRecord: ... @abstractmethod def return_book(self, record_id: str) -> BorrowRecord: ...注意这里的签名顺序和返回类型也要照设计文档抄,哪怕你觉得参数还能再合并。接口是详细设计与实现之间的合同,后面单元测试和代码评审都会拿它当基准。仓库接口可以再多一个按读者查记录的方法,取决于时序图里有没有对应消息;设计方案里没有的接口不要预先加,面向对象设计阶段的一个典型过度设计,就是给接口堆一堆"将来可能用到"的方法。
提示:如果团队约定用 Protocol 而不是 ABC,也可以,但要在项目规范里写死。对课程实验和中小型项目,ABC 的错误信息更直接:少实现一个方法,实例化时就报抽象方法未实现。
3. 把详细设计流程翻译成 Python 面向对象方法体:借书主流程落地
3.1 时序图消息如何变成方法调用链
接口钉死之后,剩下工作就是把时序图和活动图翻译成方法体。详细设计里的时序图是一条条消息:读者请求借书、查询读者、查询图书、校验规则、创建记录、扣库存、返回结果。每条消息到源码里的落点是有规律的,我一般列这样一张映射表:
| 时序图元素 | 源码落点 |
|---|---|
| 参与者发起请求 | 服务入口方法 BorrowService.borrow |
| 查询读者 | reader_repo.find_by_id |
| 检查图书库存 | book_repo.find_by_id 后判断 available_stock |
| 分支 alt 段(超限/无库存) | guard clause:先判空再 raise |
| 创建记录 | BorrowRecord 构造函数 |
| 写库 | record_repo.save |
| 扣库存 | book_repo.update_stock(-1) |
| 返回消息 | return record |
翻译时遵循两条原则:一条时序图消息对应一次方法调用,不要在分支里塞额外业务;分支条件就是详细设计里规则表写的"读者未超限""库存大于 0",出现设计文档没有的分支时,先回设计层改文档,而不是顺手在代码里打补丁。
3.2 借书主流程完整实现
# services/loan_service.py """借还书服务:实现详细设计中的借书时序图与业务规则表。""" from datetime import date, timedelta from domain.models import BorrowRecord, BorrowStatus from services.contracts import ( BookRepository, BorrowRecordRepository, BorrowService, ReaderRepository, ) class BookNotAvailable(Exception): """库存不足或图书不存在,对应设计规则 BR-03。""" class BorrowLimitExceeded(Exception): """读者已超过可借数量,对应设计规则 BR-02。""" def __init__(self, reader_id: str, limit: int): super().__init__(f"reader {reader_id} reached borrow limit {limit}") self.reader_id = reader_id self.limit = limit class LibraryBorrowService(BorrowService): def __init__( self, book_repo: BookRepository, record_repo: BorrowRecordRepository, reader_repo: ReaderRepository, borrow_days: int = 30, ): self._book_repo = book_repo self._record_repo = record_repo self._reader_repo = reader_repo self._borrow_days = borrow_days # 来自详细设计参数表:默认借期 def borrow(self, reader_id: str, book_id: str) -> BorrowRecord: reader = self._reader_repo.find_by_id(reader_id) if reader is None: raise ValueError(f"reader not found: {reader_id}") if len(reader.borrowed_ids) >= reader.borrow_limit: raise BorrowLimitExceeded(reader_id, reader.borrow_limit) book = self._book_repo.find_by_id(book_id) if book is None or book.available_stock <= 0: raise BookNotAvailable(book_id) record = BorrowRecord( record_id=self._record_repo.next_id(), reader_id=reader_id, book_id=book_id, borrow_date=date.today(), due_date=date.today() + timedelta(days=self._borrow_days), ) self._record_repo.save(record) reader.borrowed_ids.append(book_id) self._book_repo.update_stock(book_id, -1) return record def return_book(self, record_id: str) -> BorrowRecord: record = self._record_repo.find_by_id(record_id) if record is None: raise ValueError(f"record not found: {record_id}") record.return_date = date.today() record.status = BorrowStatus.RETURNED self._record_repo.save(record) self._book_repo.update_stock(record.book_id, 1) return record逻辑说明:方法体严格按"读读者—校验—读图书—校验—建记录—写库—扣库存"的顺序组织,这个顺序和时序图从上到下的顺序一致,评审时按图对代码,几分钟能过一遍。两个自定义异常类把设计文档里 BR-02、BR-03 两条规则编号写进了名称和 docstring,异常堆栈里能直接追溯到设计条款。borrow_days 通过构造参数传入而不是在方法里写死 30,是为了对应详细设计里的部署参数表;还书时库存要加回 1,同时把更新后的记录重新 save 一次,这条写库容易被漏,测试里应当重点覆盖。
3.3 依赖注入让类图的依赖方向在源码里显形
类图里 BorrowService 指向 BookRepository、BorrowRecordRepository 的依赖箭头,在上一段代码里体现为三个 repo 的构造参数。常见的新手写法是在 borrow 方法内部直接BookRepository(),等于把类图上的依赖箭头折断,后续换数据库或写测试时只能去改业务类。面向对象源码要能反映设计意图,依赖方向必须保持和类图一致:业务类只依赖接口,具体实现由组装处决定。封装体现在私有下划线成员,继承体现在class LibraryBorrowService(BorrowService),多态体现在多个仓库实现可以随时替换。
# app.py """模块入口:按详细设计中的组装描述装配对象。""" from services.loan_service import LibraryBorrowService from services.repositories import ( MemoryBookRepository, MemoryRecordRepository, MemoryReaderRepository, ) service = LibraryBorrowService( book_repo=MemoryBookRepository(), record_repo=MemoryRecordRepository(), reader_repo=MemoryReaderRepository(), borrow_days=30, )这里三个 Memory 仓库实现同一套 ABC 接口,测试时替换成桩实现也就是一行的事。参数说明:组装处的命名参数和 contract 里的接口名一一对应;仓库实现全部写MemoryBookRepository(BookRepository)这样的继承声明,让 Python 在实例化时兜底检查有没有漏方法。
4. 用 pytest 校验 Python 面向对象源码与详细设计的一致性
4.1 多条规则一组用例:每个规则表分支都写成断言
设计文档里的业务规则最终要落到用例。最直接的做法是拿详细设计的规则表建一个用例矩阵:正常借出、读者超限、库存不足、还书加库存。仓库内存实现先放在 services 层,业务代码和测试共用一套,避免在测试里再造 mock 导致"设计一致性"验证失真:
# services/repositories.py """内存版仓库实现:测试和本地演示共用同一套实现。""" from domain.models import Book, BorrowRecord, Reader from services.contracts import BookRepository, BorrowRecordRepository, ReaderRepository class MemoryBookRepository(BookRepository): def __init__(self, books: list[Book]): self._books = {b.book_id: b for b in books} def find_by_id(self, book_id: str) -> Book | None: return self._books.get(book_id) def update_stock(self, book_id: str, delta: int) -> None: self._books[book_id].available_stock += delta class MemoryReaderRepository(ReaderRepository): def __init__(self, readers: list[Reader]): self._readers = {r.reader_id: r for r in readers} def find_by_id(self, reader_id: str) -> Reader | None: return self._readers.get(reader_id) class MemoryRecordRepository(BorrowRecordRepository): def __init__(self): self._records: dict[str, BorrowRecord] = {} self._seq = 0 def next_id(self) -> str: self._seq += 1 return f"R{self._seq:04d}" def find_by_id(self, record_id: str) -> BorrowRecord | None: return self._records.get(record_id) def save(self, record: BorrowRecord) -> None: self._records[record.record_id] = record逻辑说明:Memory 仓库用字典做存储,next_id用计数器生成稳定有序的记录编号,方便断言。参数说明:delta在 update_stock 里就是简单的数值加法,调用方传 -1 表示扣减、传 1 表示归还,这个约定要写进接口 docstring,否则 MySQL 版本实现时容易对符号产生分歧。
# tests/test_borrow_service.py from datetime import timedelta import pytest from domain.models import Book, Reader from services.loan_service import BookNotAvailable, BorrowLimitExceeded, LibraryBorrowService from services.repositories import ( MemoryBookRepository, MemoryReaderRepository, MemoryRecordRepository, ) def make_service(books, readers): return LibraryBorrowService( book_repo=MemoryBookRepository(books), record_repo=MemoryRecordRepository(), reader_repo=MemoryReaderRepository(readers), borrow_days=30, ) def test_borrow_ok_updates_stock_and_due_date(): books = [Book(book_id="b1", title="Python 设计模式", total_stock=1, available_stock=1)] readers = [Reader(reader_id="r1", name="张三", borrow_limit=2)] svc = make_service(books, readers) record = svc.borrow("r1", "b1") assert books[0].available_stock == 0 assert record.due_date - record.borrow_date == timedelta(days=30) @pytest.mark.parametrize("reader_borrowed_count,raises", [ (0, None), (2, BorrowLimitExceeded), ]) def test_borrow_enforces_reader_limit(reader_borrowed_count, raises): books = [Book(book_id="b1", title="Python 设计模式", total_stock=5, available_stock=5)] borrowed = [f"pre{i}" for i in range(reader_borrowed_count)] readers = [Reader(reader_id="r1", name="张三", borrow_limit=2, borrowed_ids=borrowed)] svc = make_service(books, readers) if raises is not None: with pytest.raises(raises): svc.borrow("r1", "b1") else: svc.borrow("r1", "b1")逻辑说明:第一个用例断言库存扣到 0、借期等于 30 天,这两个断言直接来自设计规则 BR-01 和 BR-02。第二个用例用 parametrize 跑两个分支,对应 BR-03 的"未超限放行、超限抛异常"两个边界。参数说明:borrow_limit=2 是从设计参数表抄的具体数字,不要随手填 5;raises=None表示该分支不应抛异常,pytest.raises(raises)在有异常预期时才进入。
4.2 接口契约测试:用 inspect 盯住方法签名
需求变成代码后最常被悄悄改动的是方法签名。详细设计里如果写了borrow(reader_id, book_id),实现里改成borrow(reader_id),编译器不会报错,但调用方和评审的人会懵。契约测试在测试集里记录设计签名,只要实现偏移就失败:
# tests/test_contract.py import inspect from services.contracts import BorrowService from services.loan_service import LibraryBorrowService EXPECTED_SIGNATURES = { "borrow": ["reader_id", "book_id"], "return_book": ["record_id"], } def test_service_signature_matches_detailed_design(): assert issubclass(LibraryBorrowService, BorrowService) for method, params in EXPECTED_SIGNATURES.items(): real = list(inspect.signature(getattr(LibraryBorrowService, method)).parameters) assert real[1:] == params, f"{method} 的参数应为 {params},实现为 {real[1:]}"逻辑说明:issubclass 检查继承关系,Python 在实例化时兜底抽象方法;inspect.signature拿到参数名列表,real[1:]去掉 self 后与设计表的参数名逐一比对,顺序错了也会失败。参数说明:EXPECTED_SIGNATURES 里直接抄设计文档接口表的原文,新增参数要先改设计再改这里,测试会强制你走这个顺序。
4.3 常见不一致场景与几分钟内的定位手段
| 症状 | 定位手段 |
|---|---|
| 测试报 AttributeError | 看 Memory 仓库是否漏实现某个接口方法,ABC 会在实例化时报更早的错 |
| 某个规则分支没测试覆盖 | pytest --cov 看异常分支行号,对照规则表补齐 case |
| 参数顺序换了 | 契约测试变红,回时序图核对实参顺序 |
| 状态枚举值拼错 | 断言record.status is BorrowStatus.BORROWED,不要把状态值写死成字符串 |
如果分支总是漏写,一个可操作的检查原则是:设计规则表里每条"当…则…"都至少对应一个pytest.raises用例,规则表有几行,测试里就应该有几个分支;源码里用 try/except 吞掉异常的不算实现规则,只会让详细设计与行为之间的缝隙越来越大。
5. 从 Python 源码反向维护详细设计:标记设计编号并用 pyreverse 对拍类图
5.1 docstring 里埋设计编号,一条 grep 重建追溯表
写实现时,把详细设计里每个模块、规则和时序图的编号抄进 docstring,统一前缀。之后想看"某条规则有没有实现",就变成了搜字符串:
def borrow(self, reader_id: str, book_id: str) -> BorrowRecord: """借书主流程。 :design: DD-2.1.3 借书时序图 :rule: BR-02 读者借阅数量必须小于借阅上限 :rule: BR-03 借出前必须检查可借库存 :param reader_id: 读者编号 :param book_id: 图书编号 :raises BookNotAvailable: 无库存或图书不存在 """检查时跑:
grep -rhE "^ :(design|rule):" src/ | sort | uniq -c这样能数出每条规则在源码里出现了几次,0 次的就是漏实现,2 次以上的有可能是在两个方法里重复实现,提示你去简化。参数说明:-r递归、-h不输出文件名、-E启用扩展正则,(design|rule)匹配两类标记,sort | uniq -c统计重复次数。Sphinx 文档里也能用 autodoc 把这些行原样带到设计文档,源头就一份。
5.2 用 pyreverse 从源码导出类图,直接和设计文档对拍
pylint 自带的 pyreverse 能从源码反向生成类图和包图,拿它和详细设计类图并排比对,新增类、依赖反转立刻可见:
pyreverse -o png -p library src参数说明:-o指定输出格式,png 适合贴进设计文档;-p是工程名,生成文件名带此前缀;最后的 src 是包路径,可以同时传多个。比对时重点看三处:设计里有的类在图上有没有;依赖箭头方向是否和设计一致;图上出现设计没有的类时,判断是合理拆分还是过度设计。类图能对上,接口契约测试能过,业务规则用例全绿,详细设计与源码的一致性就有了机器保障。
5.3 一条命令完成设计-代码一致性检查
把契约测试、规则用例和类图导出串成一个命令,每次需求变更后跑一遍,就能在合并前拦住大多数"文档没改、代码先走"的问题:
pytest -q && pyreverse -o png -p library src && grep -rhE "^ :(design|rule):" src/ | sort | uniq -c顺序有讲究:先跑 pytest 验证行为与规则一致,再导类图核对结构,最后统计设计编号覆盖。当设计与源码冲突时,行为变化必须先改设计文档再改代码,纯实现优化才可以先进代码后补文档,但要在对应方法 docstring 里留下:rationale:标记说明差异原因。把这条命令加进 CI 的合并后任务,详细设计与 Python 面向对象源码之间的偏差,会在下一次构建时直接暴露出来。
本文还有配套的精品资源,点击获取