Compare commits

..

4 Commits

Author SHA1 Message Date
zhiye.sun
a28c3b3168 feat(数据库): 新增第四阶段前两课教学内容 2026-08-12 17:23:12 +08:00
zhiye.sun
f1d9548646 feat(面向对象): 完成第三阶段差异化课程 2026-08-11 17:53:53 +08:00
zhiye.sun
b348dc0a1f feat(python进阶): 新增进阶综合项目课程 2026-08-10 17:26:38 +08:00
zhiye.sun
aff05283a2 feat(python进阶): 完成第三至第八课教学内容 2026-08-07 17:14:48 +08:00
53 changed files with 7308 additions and 25 deletions

3
.gitignore vendored
View File

@@ -14,3 +14,6 @@ venv/
.env .env
.env.* .env.*
!.env.example !.env.example
# 数据库课程的本地 TOML 配置包含远程数据库账号和密码,不应提交。
04_数据库/**/config.toml

View File

@@ -0,0 +1,2 @@
example_data/
practice_data/

View File

@@ -0,0 +1,341 @@
# 第 2-3 课:异常处理
## 一、本课目标
完成本课后,你将能够:
1. 解释异常是什么,以及为什么需要处理异常;
2. 阅读 Python 异常信息中的异常类型;
3. 使用 `try...except` 处理预期内的错误;
4. 分别处理 `ValueError``ZeroDivisionError``FileNotFoundError`
5. 理解 `else``finally` 的执行时机;
6. 避免使用过大的 `try` 范围或空白 `except`
7. 让文件读取和数据转换失败时给出清楚的中文提示。
## 二、前置知识
学习本课前,需要掌握:
- 条件判断、函数和返回值;
- `int()` 类型转换;
- 模块与程序入口判断;
- 使用 `Path` 读取和写入文本文件。
## 三、异常是什么
异常Exception是程序运行过程中发生的非正常情况。
例如:
```python
number = int("")
```
运行后会看到类似信息:
```text
ValueError: invalid literal for int() with base 10: '五'
```
中文解释:`int()` 需要可以转换为整数的内容,但字符串 `"五"` 不符合要求。
如果不处理异常,程序会在出错位置停止。异常处理可以让程序识别预期内的问题,给用户明确提示,并决定是否继续运行。
异常处理不是“隐藏所有错误”。我们只处理已经理解并且知道如何应对的异常。
## 四、使用 `try...except`
基本结构如下:
```python
try:
number = int(number_text)
except ValueError:
print("请输入有效整数。")
```
执行顺序:
1. Python 先执行 `try` 中的代码;
2. 没有异常时,跳过 `except`
3. 发生 `ValueError` 时,停止执行 `try` 中剩余代码;
4. 转到对应的 `except ValueError`
5. `except` 执行结束后,程序可以继续向下运行。
## 五、只捕获明确的异常
本课会遇到三种常见异常。
### 5.1 `ValueError`
值错误Value Error`ValueError`)表示值的类型可能可以使用,但具体内容不符合要求:
```python
int("优秀")
```
### 5.2 `ZeroDivisionError`
除零错误Zero Division Error`ZeroDivisionError`)表示程序尝试用数字除以零:
```python
average = 180 / 0
```
### 5.3 `FileNotFoundError`
文件未找到错误File Not Found Error`FileNotFoundError`)表示程序读取的路径不存在:
```python
content = file_path.read_text(encoding="utf-8")
```
不推荐写空白 `except`
```python
try:
number = int(number_text)
except:
print("出错了。")
```
这种写法会把多种不同问题混在一起,用户不知道发生了什么,开发者也更难找到真正原因。
## 六、一个 `try` 对应多个 `except`
一段代码可能出现不同异常,可以分别处理:
```python
try:
content = file_path.read_text(encoding="utf-8")
number = int(content.strip())
except FileNotFoundError:
print("没有找到文件。")
except ValueError:
print("文件内容不是有效整数。")
```
Python 会根据实际异常类型进入匹配的分支。清楚区分异常类型,才能给出准确提示。
## 七、`else` 的作用
`else` 只在 `try` 没有发生异常时执行:
```python
try:
average = total_score / agent_count
except ZeroDivisionError:
print("Agent 数量不能为 0。")
else:
return average
```
这样可以让 `try` 只包含可能发生异常的操作,成功后的普通逻辑放到 `else`
## 八、`finally` 的作用
`finally` 无论有没有发生异常都会执行:
```python
try:
content = file_path.read_text(encoding="utf-8")
except FileNotFoundError:
print("没有找到文件。")
finally:
print("文件读取结束。")
```
它常用于必须执行的收尾工作。本课用输出语句观察执行时机。上一课使用的 `with` 已经能帮助关闭文件,因此不要为了使用 `finally` 而放弃更清晰的 `with``Path.read_text()`
## 九、异常与条件判断的区别
条件判断适合处理程序能够直接检查的业务规则:
```python
if score < 0 or score > 100:
print("评分必须在 0 到 100 之间。")
```
异常处理适合处理操作执行时可能失败的情况:
```python
try:
score = int(score_text)
except ValueError:
print("评分必须是整数。")
```
因此,评分转换失败使用 `except ValueError`,评分超出范围使用 `if`,两者各自负责不同问题。
## 十、缩小 `try` 的范围
推荐:
```python
try:
number = int(number_text)
except ValueError:
return None
print("转换完成。")
```
不要把大量无关代码全部放进 `try`。范围过大时,即使其他代码产生同类型异常,也可能被误认为是预期问题。
## 十一、完整示例
本课示例文件是:
```text
02_python进阶/2_3_异常处理/exception_example.py
```
示例依次演示:
1. 使用 `try...except` 处理整数转换失败;
2. 使用 `else` 返回成功的除法结果;
3. 分别处理正常文件和不存在的文件;
4. 使用 `finally` 输出每次读取结束提示。
示例只在本课 `example_data` 中创建文件,该目录已由 `.gitignore` 忽略。
## 十二、运行方法
在项目根目录运行:
```powershell
python .\02_python进阶\2_3_异常处理\exception_example.py
```
完成练习后运行:
```powershell
python .\02_python进阶\2_3_异常处理\practice.py
```
## 十三、示例运行结果
```text
一、处理整数转换异常
“五”不是有效整数。
有效结果5
无效结果None
==============================
二、处理除以零异常
Agent 数量不能为 0。
正常结果2.0
异常结果None
==============================
三、处理文件读取异常
已完成对 agent_count.txt 的读取尝试。
没有找到文件missing.txt
已完成对 missing.txt 的读取尝试。
文件中的 Agent 数量3
不存在文件的读取结果None
```
## 十四、关键代码解析
### 14.1 异常类型写在 `except` 后
```python
except ValueError:
```
它表示这里只处理 `ValueError`。如果发生其他没有处理的异常Python 仍会显示异常信息,帮助我们发现新的问题。
### 14.2 异常分支也要明确返回
如果函数成功时返回数字,失败时可以明确返回 `None`
```python
except ValueError:
print("请输入有效整数。")
return None
```
调用者随后可以使用:
```python
if result is not None:
print(result)
```
注意不能简单写 `if result:`,因为合法数字 `0` 也会被当作假值。
### 14.3 `return` 后仍会执行 `finally`
即使 `try``except` 中执行了 `return`,函数真正结束前仍会执行 `finally`。可以运行示例观察这一点。
## 十五、常见错误
### 15.1 使用空白 `except`
空白 `except` 会捕获范围过广,让真正的程序错误难以发现。请写出明确异常类型。
### 15.2 捕获异常后什么都不做
不推荐:
```python
except ValueError:
pass
```
用户看不到提示,开发者也不知道数据为什么没有得到处理。
### 15.3 用异常代替全部条件判断
`try...except` 不能取代正常业务判断。数字是否在 0 到 100 之间仍应使用 `if` 检查。
### 15.4 把 `try` 包住整个程序
这会让异常来源变得模糊。应只包住可能发生且准备处理的操作。
### 15.5 把英文错误信息原样交给用户
学习时可以阅读英文异常类型,但面向用户的提示应说明他们可以如何修正输入。
## 十六、课堂练习
打开 `practice.py`,依次完成:
1. `parse_score(score_text)`:处理整数转换并检查评分范围;
2. `calculate_average(total_score, agent_count)`:处理除以零;
3. `read_score(file_path)`:处理不存在的评分文件;
4. `main()`:准备安全的练习数据并测试正常、异常分支。
练习题面已经明确给出了参数、返回值、输出文字、测试输入和预期结果。请先独立完成,不要直接复制示例函数。
## 十七、参考答案
参考答案暂不写入练习文件。你完成后,我会从以下三个方面检查:
- 正确性:正常和异常分支是否符合预期;
- 可读性:`try` 范围、命名和中文提示是否清楚;
- 知识掌握:是否能正确选择异常类型,并区分异常处理与条件判断。
## 十八、本课小结
- 异常是程序运行时出现的非正常情况;
- `try` 放置可能发生异常的代码;
- `except` 处理明确类型的异常;
- `else` 只在没有异常时执行;
- `finally` 无论是否发生异常都会执行;
- `ValueError``ZeroDivisionError``FileNotFoundError` 对应不同问题;
- 异常处理用于应对失败,条件判断用于检查业务规则;
- 不要使用空白 `except` 隐藏问题。
## 十九、验收标准
- 能用自己的话解释异常处理的作用;
- 能看懂三个本课异常类型;
- 示例程序输出与讲义一致;
- 能针对不同错误使用明确的 `except`
- 能说明 `else``finally` 的执行时机;
- `parse_score("0")``parse_score("100")` 均能返回合法结果;
- 非数字和越界评分会给出不同中文提示;
- 除数为零时程序不会崩溃;
- 文件不存在时程序不会崩溃;
- 练习只在 `practice_data` 中写入数据;
- 没有使用空白 `except` 或用 `pass` 隐藏异常。

View File

@@ -0,0 +1,90 @@
# 第 2-3 课完整示例:异常处理
#
# 本程序通过固定测试数据演示异常处理,不会要求用户输入。
# 文件示例只会读取当前课程目录中的 example_data 文件夹。
from pathlib import Path
LESSON_DIR = Path(__file__).parent
DATA_DIR = LESSON_DIR / "example_data"
CONFIG_FILE = DATA_DIR / "agent_count.txt"
def parse_positive_integer(number_text):
"""把文本转换为正整数,失败时返回 None。"""
try:
number = int(number_text)
except ValueError:
# ValueError 表示值的内容不符合转换要求。
print(f"{number_text}”不是有效整数。")
return None
if number <= 0:
print("数字必须大于 0。")
return None
return number
def divide_tool_count(tool_count, agent_count):
"""计算平均工具数量,除数为零时返回 None。"""
try:
average = tool_count / agent_count
except ZeroDivisionError:
# ZeroDivisionError 表示程序尝试用数字除以零。
print("Agent 数量不能为 0。")
return None
else:
# 只有 try 中没有发生异常时,才会执行 else。
return average
def read_agent_count(file_path):
"""读取文件中的 Agent 数量,并保证输出结束提示。"""
try:
content = file_path.read_text(encoding="utf-8")
return int(content.strip())
except FileNotFoundError:
print(f"没有找到文件:{file_path.name}")
except ValueError:
print(f"{file_path.name} 中保存的不是有效整数。")
finally:
# 无论是否发生异常finally 都会执行。
print(f"已完成对 {file_path.name} 的读取尝试。")
return None
def prepare_example_file():
"""在课程专用目录中准备安全的示例文件。"""
DATA_DIR.mkdir(parents=True, exist_ok=True)
CONFIG_FILE.write_text("3\n", encoding="utf-8")
def main():
"""依次运行整数转换、除法和文件读取示例。"""
print("一、处理整数转换异常")
valid_number = parse_positive_integer("5")
invalid_number = parse_positive_integer("")
print(f"有效结果:{valid_number}")
print(f"无效结果:{invalid_number}")
print("=" * 30)
print("二、处理除以零异常")
average = divide_tool_count(6, 3)
failed_average = divide_tool_count(6, 0)
print(f"正常结果:{average}")
print(f"异常结果:{failed_average}")
print("=" * 30)
print("三、处理文件读取异常")
prepare_example_file()
agent_count = read_agent_count(CONFIG_FILE)
missing_count = read_agent_count(DATA_DIR / "missing.txt")
print(f"文件中的 Agent 数量:{agent_count}")
print(f"不存在文件的读取结果:{missing_count}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,58 @@
# 第 2-3 课课堂练习:异常处理
#
# 请先阅读讲义并运行完整示例,再按照顺序完成练习。
# 不要删除题目、测试数据、预期结果和自查注释。
# 本练习只读取当前课程目录中的 practice_data 文件,不删除或覆盖其他文件。
# 第一部分:定义 parse_score(score_text) 函数
# 1. 参数 score_text 接收一个字符串。
# 2. 在 try 中使用 int(score_text) 转换为整数,并保存结果。
# 3. 捕获 ValueError输出“评分必须是整数。”然后 return None。
# 4. 转换成功后,检查评分是否处于 0 到 100 之间。
# 5. 超出范围时输出“评分必须在 0 到 100 之间。”,然后 return None。
# 6. 合法时 return 转换后的整数,不要在函数内输出合法结果。
# 7. 分别传入 "90"、"优秀"、"120",预期返回 90、None、None。
# 第二部分:定义 calculate_average(total_score, agent_count) 函数
# 1. 在 try 中计算 total_score / agent_count。
# 2. 捕获 ZeroDivisionError输出“Agent 数量不能为 0。”并 return None。
# 3. 使用 else return 平均分。
# 4. 传入 180 和 2预期返回 90.0。
# 5. 传入 180 和 0预期返回 None程序不能崩溃。
# 第三部分:定义 read_score(file_path) 函数
# 1. 在 try 中使用 read_text(encoding="utf-8") 读取文本。
# 2. 调用 strip() 清理文本,再调用 parse_score() 并 return 结果。
# 3. 捕获 FileNotFoundError输出“没有找到评分文件。”并 return None。
# 4. 添加 finally并输出“评分文件读取结束。”。
# 5. 注意parse_score() 已经负责处理 ValueError不要重复捕获同一错误。
# 第四部分:定义 main() 函数
# 1. 使用 mkdir(parents=True, exist_ok=True) 创建 PRACTICE_DIR。
# 2. 使用 write_text() 把 "90\n" 写入 SCORE_FILE并指定 utf-8。
# 3. 调用 read_score(SCORE_FILE),把返回值保存为 saved_score。
# 4. saved_score 不是 None 时输出“读取到的评分90”。
# 5. 再调用 read_score(PRACTICE_DIR / "missing.txt") 测试文件不存在的分支。
# 6. 添加程序入口判断,直接运行本文件时调用 main()。
# 最终验收测试:
# 1. parse_score("0") 返回 0
# 2. parse_score("100") 返回 100
# 3. parse_score("优秀") 输出提示并返回 None
# 4. parse_score("-1") 和 parse_score("101") 均返回 None
# 5. calculate_average(180, 2) 返回 90.0
# 6. calculate_average(180, 0) 不崩溃并返回 None
# 7. 存在的评分文件可以正常读取;
# 8. 不存在的文件会输出提示finally 中的文字仍然出现;
# 9. 所有异常都捕获了明确类型,没有使用空白 except。
# 完成后自查:
# 1. try 中是否只放可能发生异常的代码;
# 2. 是否分别理解 ValueError、ZeroDivisionError 和 FileNotFoundError
# 3. 出现异常后,程序是否给出中文提示而不是直接崩溃;
# 4. 是否使用 return 返回结果,而不是只在函数中 print()
# 5. 是否理解 else 只在没有异常时执行;
# 6. 是否理解 finally 无论有无异常都会执行;
# 7. 是否没有使用 except Exception 隐藏所有问题;
# 8. 是否没有删除题目和验收说明。

View File

@@ -0,0 +1,355 @@
# 第 2-4 课:推导式与简化写法
## 一、本课目标
完成本课后,你将能够:
1. 解释推导式是什么,以及它适合解决什么问题;
2. 使用列表推导式转换和筛选数据;
3. 使用字典推导式创建键值对应关系;
4. 使用集合推导式收集不重复的数据;
5. 使用简单的条件表达式返回二选一结果;
6. 在普通循环和推导式之间选择更清晰的写法;
7. 避免编写难以阅读的复杂推导式。
## 二、前置知识
学习本课前,需要掌握:
- 列表、字典和集合;
- `for` 循环和 `if` 条件判断;
- 函数、参数和返回值;
- 字典的 `get()` 方法。
## 三、推导式是什么
推导式Comprehension是 Python 根据可遍历数据快速创建新集合的一种写法。
第一阶段中,我们使用普通循环生成新列表:
```python
agent_names = []
for agent in agents:
agent_names.append(agent["name"])
```
使用列表推导式可以写成:
```python
agent_names = [agent["name"] for agent in agents]
```
两段代码的结果相同。推导式没有改变原来的 `agents`,而是创建并返回一个新列表。
## 四、列表推导式的基本结构
```python
[需要保存的值 for 临时变量 in 可遍历数据]
```
示例:
```python
agent_names = [agent["name"] for agent in agents]
```
建议从右向左理解:
1. `for agent in agents`:依次取出每个 Agent
2. `agent["name"]`:取得当前 Agent 的名称;
3. `[...]`:把每次得到的名称放入新列表。
推导式中的 `agent` 只是本次遍历使用的临时变量。
## 五、使用列表推导式筛选数据
普通循环写法:
```python
enabled_names = []
for agent in agents:
if agent["enabled"]:
enabled_names.append(agent["name"])
```
列表推导式写法:
```python
enabled_names = [
agent["name"]
for agent in agents
if agent["enabled"]
]
```
带筛选条件的结构是:
```python
[需要保存的值 for 临时变量 in 可遍历数据 if 条件]
```
只有条件为 `True` 的数据才会进入新列表。
## 六、转换与筛选的区别
转换表示每条数据都参与,但保存为另一种形式:
```python
name_lengths = [len(agent["name"]) for agent in agents]
```
筛选表示只有符合条件的数据才保留:
```python
enabled_agents = [agent for agent in agents if agent["enabled"]]
```
一个推导式也可以同时转换和筛选:
```python
enabled_names = [
agent["name"]
for agent in agents
if agent["enabled"]
]
```
## 七、字典推导式
字典推导式Dictionary Comprehension用于创建新字典
```python
tool_counts = {
agent["name"]: len(agent.get("tools", []))
for agent in agents
}
```
基本结构:
```python
{: for 临时变量 in 可遍历数据}
```
上述示例的结果类似:
```python
{
"代码助手": 2,
"聊天助手": 1,
}
```
字典的键不能重复。如果多个数据生成同一个键,后生成的值会覆盖之前的值。因此,应先确认选作键的数据具有合适的唯一性。
## 八、集合推导式
集合推导式Set Comprehension用于创建不包含重复值的集合
```python
models = {agent["model"] for agent in agents}
```
基本结构:
```python
{需要保存的值 for 临时变量 in 可遍历数据}
```
集合与字典都使用花括号,但字典推导式包含 `键: 值`,集合推导式只有一个值。
如果每个 Agent 中还有一个工具列表,可以使用两层 `for`
```python
unique_tools = {
tool
for agent in agents
for tool in agent.get("tools", [])
}
```
它等价于:
```python
unique_tools = set()
for agent in agents:
for tool in agent.get("tools", []):
unique_tools.add(tool)
```
本课只练习这一种容易理解的两层结构,不加入更多条件。
## 九、条件表达式
条件表达式Conditional Expression可以根据一个条件从两个值中选择一个
```python
status_text = "启用" if enabled else "停用"
```
执行顺序:
1. 检查 `enabled`
2. 条件为 `True`,得到 `"启用"`
3. 条件为 `False`,得到 `"停用"`
它等价于:
```python
if enabled:
status_text = "启用"
else:
status_text = "停用"
```
条件表达式适合简单的二选一。如果每个分支需要执行多步操作,应继续使用普通 `if...else`
## 十、筛选条件与条件表达式不要混淆
筛选只决定某条数据是否保留:
```python
[agent["name"] for agent in agents if agent["enabled"]]
```
条件表达式会为每条数据选择一个结果:
```python
[
"启用" if agent["enabled"] else "停用"
for agent in agents
]
```
第一段结果数量可能减少,第二段会为每个 Agent 产生一个状态文字。
## 十一、什么时候使用普通循环
以下情况优先使用普通循环:
- 每次遍历需要执行多个步骤;
- 需要输出中间过程;
- 包含多层条件判断;
- 推导式换行后仍难以理解;
- 需要在循环中提前 `break``return`
代码行数更少不代表代码更好。能够让自己和其他人快速理解的写法才更合适。
## 十二、完整示例
示例文件:
```text
02_python进阶/2_4_推导式与简化写法/comprehension_example.py
```
示例会演示:
1. 普通循环与列表推导式得到相同结果;
2. 筛选已启用的 Agent 名称;
3. 创建名称到工具数量的字典;
4. 收集所有不重复工具;
5. 使用条件表达式显示 Agent 状态。
## 十三、运行方法
在项目根目录运行示例:
```powershell
python .\02_python进阶\2_4_推导式与简化写法\comprehension_example.py
```
完成练习后运行:
```powershell
python .\02_python进阶\2_4_推导式与简化写法\practice.py
```
## 十四、预期结果
```text
普通循环结果:['代码助手', '测试助手']
列表推导式结果:['代码助手', '测试助手']
工具数量字典:{'代码助手': 2, '聊天助手': 1, '测试助手': 2}
不重复工具:['搜索', '测试', '终端']
Agent 状态:
- 代码助手:启用
- 聊天助手:停用
- 测试助手:启用
```
集合没有固定顺序。示例使用 `sorted()` 只是为了让输出顺序稳定,便于核对;集合本身仍然不保证顺序。
## 十五、常见错误
### 15.1 忘记最外层括号
列表推导式使用方括号,字典和集合推导式使用花括号。
### 15.2 把筛选条件写到错误位置
筛选条件写在 `for...in...` 后面:
```python
[agent["name"] for agent in agents if agent["enabled"]]
```
### 15.3 混淆字典和集合推导式
```python
{agent["name"] for agent in agents}
```
这是集合,因为没有 `键: 值`。创建字典必须写冒号。
### 15.4 推导式过于复杂
不要在一个推导式中堆叠多层条件表达式。看不懂时应立即改回普通循环。
### 15.5 误以为推导式会修改原数据
推导式通常创建新列表、新字典或新集合。除非其中主动修改对象,否则原集合不会因为推导式本身被替换。
## 十六、课堂练习
打开 `practice.py`,依次完成:
1. 创建全部 Agent 名称列表;
2. 筛选已启用 Agent 的名称;
3. 创建名称到模型的字典;
4. 收集不重复工具;
5. 使用条件表达式返回状态文字;
6.`main()` 中保存并输出所有返回结果。
题目已经给出参数、返回值、示例数据和预期结果。请先独立完成。
## 十七、参考答案
参考答案暂不写入练习文件。你完成后,我会从正确性、可读性和知识掌握情况三个方面验证,并重点检查你是否能够解释推导式,而不只是记住格式。
## 十八、本课小结
- 推导式可以根据已有数据创建新集合;
- 列表推导式使用 `[]`
- 字典推导式使用 `{键: 值}`
- 集合推导式使用 `{值}`
- 末尾的 `if` 用于筛选;
- 条件表达式用于从两个结果中选择一个;
- 推导式适合简单逻辑,复杂逻辑应使用普通循环;
- 清晰比简短更重要。
## 十九、验收标准
- 能用普通循环和列表推导式实现相同功能;
- 能解释列表推导式各部分的执行顺序;
- 能使用末尾 `if` 筛选数据;
- 能创建名称到模型的字典;
- 能使用集合推导式去重;
- 能正确使用简单条件表达式;
- 能区分筛选条件与条件表达式;
- 所有练习函数通过 `return` 返回结果;
- 推导式不会修改原始 Agent 列表;
- 没有为了简短而编写难以阅读的复杂表达式。

View File

@@ -0,0 +1,77 @@
# 第 2-4 课完整示例:推导式与简化写法
#
# 本示例先展示普通循环,再展示作用相同的推导式。
# 推导式适合简单的数据转换和筛选,不应为了缩短代码而堆叠复杂逻辑。
def get_enabled_agent_names(agents):
"""使用列表推导式返回所有已启用 Agent 的名称。"""
return [agent["name"] for agent in agents if agent["enabled"]]
def get_agent_tool_counts(agents):
"""使用字典推导式建立 Agent 名称与工具数量的对应关系。"""
return {
agent["name"]: len(agent.get("tools", []))
for agent in agents
}
def get_unique_tools(agents):
"""使用集合推导式收集不重复的工具名称。"""
return {
tool
for agent in agents
for tool in agent.get("tools", [])
}
def get_status_text(agent):
"""使用条件表达式返回简短的中文状态。"""
return "启用" if agent["enabled"] else "停用"
def main():
"""准备 Agent 数据,并演示四种简化写法。"""
agents = [
{
"name": "代码助手",
"enabled": True,
"tools": ["搜索", "终端"],
},
{
"name": "聊天助手",
"enabled": False,
"tools": ["搜索"],
},
{
"name": "测试助手",
"enabled": True,
"tools": ["终端", "测试"],
},
]
# 普通循环写法:先创建空列表,再逐条判断和添加。
names_from_loop = []
for agent in agents:
if agent["enabled"]:
names_from_loop.append(agent["name"])
# 列表推导式可以简洁表达同一个“遍历、筛选、保存”过程。
enabled_names = get_enabled_agent_names(agents)
tool_counts = get_agent_tool_counts(agents)
unique_tools = get_unique_tools(agents)
print(f"普通循环结果:{names_from_loop}")
print(f"列表推导式结果:{enabled_names}")
print(f"工具数量字典:{tool_counts}")
print(f"不重复工具:{sorted(unique_tools)}")
print("Agent 状态:")
for agent in agents:
status_text = get_status_text(agent)
print(f"- {agent['name']}{status_text}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,65 @@
# 第 2-4 课课堂练习:推导式与简化写法
#
# 请先阅读讲义并运行完整示例,再按照题目顺序完成。
# 每个函数都需要使用 return 返回结果,不要只在函数中 print()。
# 不要删除题目、测试数据、预期结果和自查注释。
# 第一部分:定义 get_agent_names(agents) 函数
# 1. 使用列表推导式遍历 agents。
# 2. 取出每个字典中的 name。
# 3. return 生成的新列表,不要修改原列表。
# 4. 调用后保存结果并输出。
# 5. 预期结果:['代码助手', '聊天助手', '测试助手']。
# 第二部分:定义 get_enabled_agent_names(agents) 函数
# 1. 使用带 if 的列表推导式。
# 2. 只保留 enabled 为 True 的 Agent 名称。
# 3. return 新列表。
# 4. 预期结果:['代码助手', '测试助手']。
# 第三部分:定义 get_model_mapping(agents) 函数
# 1. 使用字典推导式。
# 2. 字典的键是 Agent 名称,值是模型名称。
# 3. return 新字典。
# 4. 预期结果:
# {'代码助手': 'gpt-5', '聊天助手': 'o3', '测试助手': 'gpt-5'}。
# 第四部分:定义 get_unique_tools(agents) 函数
# 1. 使用集合推导式和两层 for。
# 2. 外层遍历 agents内层遍历每个 agent.get("tools", [])。
# 3. return 不重复的工具集合。
# 4. 预期集合包含:搜索、终端、测试。
# 5. 集合没有固定顺序,验收时比较内容,不比较显示顺序。
# 第五部分:定义 get_status_text(enabled) 函数
# 1. 使用条件表达式,不使用多行 if...else。
# 2. enabled 为 True 时 return "启用",否则 return "停用"。
# 3. 分别传入 True 和 False。
# 4. 预期结果分别是“启用”和“停用”。
# 第六部分:定义 main() 函数
# 1. 依次调用前面五个函数,并把每个返回值保存到有意义的变量中。
# 2. 使用 print() 输出所有 Agent 名称、启用名称、模型字典和工具集合。
# 3. 调用 get_status_text(agents[0]["enabled"]) 并输出第一个 Agent 的状态。
# 4. 添加程序入口判断,直接运行本文件时调用 main()。
# 最终验收测试:
# 1. get_agent_names(agents) 返回三个名称;
# 2. get_enabled_agent_names(agents) 只返回两个启用的名称;
# 3. get_model_mapping(agents) 返回名称到模型的正确映射;
# 4. get_unique_tools(agents) 返回三个不重复工具;
# 5. get_status_text(True) 返回“启用”;
# 6. get_status_text(False) 返回“停用”;
# 7. 所有函数都使用 return 返回结果;
# 8. 推导式没有修改原始 agents
# 9. 没有使用嵌套条件表达式或难以阅读的复杂推导式。
# 完成后自查:
# 1. 是否能从左到右解释每个推导式的执行过程;
# 2. 是否分清列表、字典和集合推导式使用的括号;
# 3. 是否知道筛选条件写在推导式末尾;
# 4. 是否知道条件表达式的结果分别写在 if 两侧;
# 5. 是否优先保证可读性,而不是一味缩短代码;
# 6. 是否保留了完整题目和验收说明。

View File

@@ -0,0 +1,371 @@
# 第 2-5 课:迭代器与生成器
## 一、本课目标
完成本课后,你将能够:
1. 解释可迭代对象、迭代器和生成器分别是什么;
2. 使用 `iter()` 创建迭代器;
3. 使用 `next()` 逐个取得数据;
4. 理解数据取完时出现的 `StopIteration`
5. 使用包含 `yield` 的生成器函数逐个产生结果;
6. 说明生成器为什么适合处理大量数据;
7. 理解同一个生成器被消费后不能自动回到开头。
## 二、前置知识
学习本课前,需要掌握:
- 列表、字典和集合;
- `for` 循环和 `while` 循环;
- 函数、参数和返回值;
- `try...except` 异常处理;
- 列表推导式。
## 三、从 `for` 循环开始理解
我们已经多次使用 `for` 遍历列表:
```python
agent_names = ["代码助手", "聊天助手", "测试助手"]
for agent_name in agent_names:
print(agent_name)
```
列表可以依次提供其中的数据因此它是可迭代对象Iterable。可迭代对象可以暂时理解为“能够被 `for` 循环逐项访问的数据”。
目前学过的常见可迭代对象包括:
- 字符串;
- 列表;
- 元组;
- 字典;
- 集合;
- `range()` 产生的对象。
## 四、迭代器是什么
迭代器Iterator是记录当前遍历位置并能提供下一个值的对象。
使用 `iter()` 可以根据可迭代对象创建迭代器:
```python
agent_names = ["代码助手", "聊天助手"]
name_iterator = iter(agent_names)
```
使用 `next()` 逐个取得值:
```python
print(next(name_iterator))
print(next(name_iterator))
```
输出:
```text
代码助手
聊天助手
```
每调用一次 `next()`,迭代器都会向后移动一步。它不会自动回到开头。
## 五、`StopIteration`
当迭代器中的数据全部取完后,再调用 `next()` 会出现:
```text
StopIteration
```
停止迭代异常Stop Iteration`StopIteration`)表示迭代器已经没有下一个值。
可以手动处理:
```python
try:
print(next(name_iterator))
except StopIteration:
print("数据已经全部取完。")
```
`for` 循环实际上会在内部不断取得下一个值,并在遇到 `StopIteration` 时自动结束,所以平时使用 `for` 不需要自己捕获它。
## 六、手动还原 `for` 循环的过程
下面两段代码表达相似的过程。
使用 `for`
```python
for value in values:
print(value)
```
手动使用迭代器:
```python
value_iterator = iter(values)
while True:
try:
value = next(value_iterator)
except StopIteration:
break
print(value)
```
真实的 `for` 循环由 Python 自动完成这些步骤。本课手动编写一次,是为了理解其工作方式,而不是建议以后都替换 `for`
## 七、生成器是什么
生成器Generator是一种特殊的迭代器。它可以按需要逐个产生数据不必先把全部结果存入列表。
包含 `yield` 的函数称为生成器函数:
```python
def generate_numbers():
yield 1
yield 2
yield 3
```
调用普通函数时函数会立即执行并返回结果。调用生成器函数时Python 先创建生成器对象:
```python
number_generator = generate_numbers()
```
这时函数体还没有完整执行。可以用 `next()``for` 逐个取得值:
```python
print(next(number_generator))
print(next(number_generator))
```
## 八、`yield` 与 `return` 的区别
普通函数使用 `return`
```python
def get_names(agents):
names = []
for agent in agents:
names.append(agent["name"])
return names
```
它会先准备完整列表,然后一次返回。
生成器函数使用 `yield`
```python
def generate_names(agents):
for agent in agents:
yield agent["name"]
```
`yield` 会完成三件事:
1. 交出当前结果;
2. 暂停函数;
3. 保存当前执行位置,下一次继续从这里向后运行。
函数中的 `return` 会结束整个函数;`yield` 交出一个值后还能继续生成后面的值。
## 九、使用 `for` 遍历生成器
生成器也是迭代器,因此可以直接使用 `for`
```python
for agent_name in generate_names(agents):
print(agent_name)
```
这种写法最常见,也比手动调用 `next()` 更安全。
如果确实需要把所有结果放入列表,可以使用:
```python
agent_names = list(generate_names(agents))
```
注意:这样会立即消费生成器,并把全部结果保存到内存中。
## 十、为什么需要生成器
假设需要处理一百万条记录。列表写法通常先准备一百万个结果,再开始使用;生成器可以产生一条、处理一条。
这种按需产生数据的方式称为惰性求值Lazy Evaluation。这里的“惰性”不是运行缓慢而是“需要时才计算”。
生成器的主要优点:
- 不必一次保存全部结果;
- 可以更早开始处理第一条数据;
- 适合文件逐行读取、大量查询结果或持续数据流。
本课使用少量 Agent 数据是为了便于观察。数据量很小时,普通列表通常同样合适。
## 十一、生成器只能继续向后执行
```python
name_generator = generate_names(agents)
first_result = list(name_generator)
second_result = list(name_generator)
```
第一次已经把生成器消费完,因此第二次得到空列表。
如果确实需要重新遍历,必须重新调用生成器函数创建一个新生成器:
```python
new_generator = generate_names(agents)
```
## 十二、生成器表达式
上一课学习了列表推导式:
```python
enabled_names = [
agent["name"]
for agent in agents
if agent["enabled"]
]
```
把最外层方括号改成圆括号会得到生成器表达式Generator Expression
```python
enabled_name_generator = (
agent["name"]
for agent in agents
if agent["enabled"]
)
```
列表推导式立即创建完整列表,生成器表达式按需产生结果。本课练习以 `yield` 为主,先把生成器函数的执行过程理解清楚。
## 十三、完整示例
示例文件:
```text
02_python进阶/2_5_迭代器与生成器/iterator_generator_example.py
```
示例依次演示:
1. 使用 `iter()``next()` 手动取出名称;
2. 捕获数据取完后的 `StopIteration`
3. 使用生成器筛选启用 Agent
4. 使用生成器逐条产生 Agent 报告。
## 十四、运行方法
在项目根目录运行示例:
```powershell
python .\02_python进阶\2_5_迭代器与生成器\iterator_generator_example.py
```
完成练习后运行:
```powershell
python .\02_python进阶\2_5_迭代器与生成器\practice.py
```
## 十五、预期结果
```text
一、手动使用迭代器
代码助手
聊天助手
测试助手
名称已经全部取完。
==============================
二、使用生成器函数
生成器对象generator
启用 Agent代码助手
启用 Agent测试助手
==============================
三、逐条生成 Agent 报告
代码助手状态启用工具数量2
聊天助手状态停用工具数量1
测试助手状态启用工具数量2
```
## 十六、常见错误
### 16.1 对可迭代对象直接使用 `next()`
列表是可迭代对象,但列表本身不是迭代器。应先调用:
```python
name_iterator = iter(agent_names)
```
### 16.2 数据取完后继续调用 `next()`
迭代器不会自动回到开头。数据取完后继续调用会产生 `StopIteration`
### 16.3 在生成器函数中使用 `return result_list`
如果先创建完整列表再返回,就没有实现按需生成。需要逐个结果时应使用 `yield`
### 16.4 以为调用生成器函数就会立即执行
调用生成器函数只会创建生成器对象。第一次调用 `next()` 或开始 `for` 遍历时,函数才真正向前执行。
### 16.5 重复消费同一个生成器
同一个生成器不会自动重置。第二次遍历已经消费完的生成器不会再次得到原数据。
### 16.6 所有地方都改用生成器
少量数据需要反复读取时,列表可能更直观。生成器适合按顺序处理、无需返回头部或数据量较大的场景。
## 十七、课堂练习
打开 `practice.py`,依次完成:
1. 使用 `iter()``next()` 取得前两个名称;
2. 手动捕获 `StopIteration` 并消费迭代器;
3. 使用 `yield` 生成启用 Agent 名称;
4. 使用 `yield` 生成所有工具名称;
5. 观察同一个生成器被消费两次的结果;
6.`main()` 中输出并核对全部结果。
## 十八、参考答案
参考答案暂不写入练习文件。完成后,我会从正确性、可读性和知识掌握情况三个方面验证,并检查生成器函数是否真正使用 `yield` 按需产生数据。
## 十九、本课小结
- 可迭代对象能被 `for` 逐项访问;
- 迭代器记录当前遍历位置;
- `iter()` 创建迭代器,`next()` 取得下一个值;
- 数据取完时会出现 `StopIteration`
- `for` 循环会自动处理停止迭代;
- 包含 `yield` 的函数是生成器函数;
- `yield` 交出一个结果并暂停当前函数;
- 生成器按需产生数据,适合顺序处理大量内容;
- 同一个生成器消费完后不能自动重新开始。
## 二十、验收标准
- 能解释可迭代对象与迭代器的区别;
- 能使用 `iter()``next()` 手动取得数据;
- 能正确处理 `StopIteration`
- 能解释 `for` 如何自动结束迭代;
- 能编写包含 `yield` 的生成器函数;
- 能使用 `for``list()` 消费生成器;
- 能解释 `yield``return` 的区别;
- 能说明同一个生成器第二次消费为什么为空;
- 生成器函数没有提前创建完整结果列表;
- 示例与练习均不会修改原始 Agent 数据。

View File

@@ -0,0 +1,74 @@
# 第 2-5 课完整示例:迭代器与生成器
#
# 本示例使用少量固定数据观察迭代过程。
# 重点不是缩短代码,而是理解数据如何被逐个取出和按需生成。
def generate_enabled_agent_names(agents):
"""逐个生成处于启用状态的 Agent 名称。"""
for agent in agents:
if agent["enabled"]:
# yield 每次只交出一个结果,并暂停函数当前的执行位置。
yield agent["name"]
def generate_agent_reports(agents):
"""逐个生成 Agent 的中文报告。"""
for agent in agents:
status_text = "启用" if agent["enabled"] else "停用"
tool_count = len(agent.get("tools", []))
yield (
f"{agent['name']}|状态:{status_text}"
f"工具数量:{tool_count}"
)
def main():
"""演示 iter()、next()、StopIteration 和生成器。"""
agents = [
{
"name": "代码助手",
"enabled": True,
"tools": ["搜索", "终端"],
},
{
"name": "聊天助手",
"enabled": False,
"tools": ["搜索"],
},
{
"name": "测试助手",
"enabled": True,
"tools": ["终端", "测试"],
},
]
print("一、手动使用迭代器")
agent_names = [agent["name"] for agent in agents]
name_iterator = iter(agent_names)
print(next(name_iterator))
print(next(name_iterator))
print(next(name_iterator))
try:
next(name_iterator)
except StopIteration:
print("名称已经全部取完。")
print("=" * 30)
print("二、使用生成器函数")
enabled_name_generator = generate_enabled_agent_names(agents)
print(f"生成器对象:{type(enabled_name_generator).__name__}")
for agent_name in enabled_name_generator:
print(f"启用 Agent{agent_name}")
print("=" * 30)
print("三、逐条生成 Agent 报告")
for report in generate_agent_reports(agents):
print(report)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,73 @@
# 第 2-5 课课堂练习:迭代器与生成器
#
# 请先阅读讲义并运行完整示例,再按照题目顺序完成。
# 需要生成多个结果的函数必须使用 yield不要提前创建完整结果列表。
# 不要删除题目、测试数据、预期结果和自查注释。
# 第一部分:手动使用迭代器
# 1. 定义 show_first_two_names(agents) 函数。
# 2. 使用列表推导式取得所有 Agent 名称。
# 3. 使用 iter() 把名称列表转换成迭代器,并保存为 name_iterator。
# 4. 连续调用两次 next(),分别保存第一个和第二个名称。
# 5. return 一个包含两个名称的元组。
# 6. 调用函数并保存结果,预期返回 ("代码助手", "聊天助手")。
# 第二部分:处理 StopIteration
# 1. 定义 consume_iterator(values) 函数,参数接收列表。
# 2. 使用 iter(values) 创建迭代器。
# 3. 使用 while True 持续调用 next(),把每个结果加入 result 列表。
# 4. 捕获 StopIteration 后使用 break 结束循环。
# 5. return result。
# 6. 传入 ["搜索", "终端"],预期返回相同内容的新列表。
# 7. 传入空列表 [],预期返回 [],程序不能崩溃。
# 第三部分:定义 generate_enabled_names(agents) 生成器函数
# 1. 使用 for 遍历 agents。
# 2. 只处理 enabled 为 True 的 Agent。
# 3. 使用 yield 逐个生成 Agent 名称,不要创建结果列表。
# 4. 调用函数时先把生成器对象保存为 enabled_name_generator。
# 5. 使用 list(enabled_name_generator) 收集结果并输出。
# 6. 预期结果:['代码助手', '测试助手']。
# 第四部分:定义 generate_tool_names(agents) 生成器函数
# 1. 使用两层 for外层遍历 agents内层遍历 agent.get("tools", [])。
# 2. 使用 yield 逐个生成工具名称。
# 3. 本练习不去重,重复工具也需要保留。
# 4. 使用 list() 收集生成结果。
# 5. 预期结果:['搜索', '终端', '搜索', '终端', '测试']。
# 第五部分:观察生成器只能继续向后取值
# 1. 调用 generate_enabled_names(agents) 创建一个新生成器。
# 2. 第一次使用 list() 收集并保存结果,预期有两个名称。
# 3. 对同一个生成器再次使用 list(),预期得到空列表。
# 4. 输出两次结果,并用自己的话解释第二次为什么为空。
# 注意:不要为了让第二次也有数据而重新创建生成器,本题就是观察同一个生成器。
# 第六部分:定义 main() 函数
# 1. 依次调用前面四个函数,并把返回值或生成结果保存到有意义的变量中。
# 2. 输出前两个名称、迭代器消费结果、启用名称和全部工具名称。
# 3. 完成第五部分的两次生成器收集实验。
# 4. 添加程序入口判断,直接运行本文件时调用 main()。
# 最终验收测试:
# 1. show_first_two_names(agents) 返回正确的两个名称;
# 2. consume_iterator(["搜索", "终端"]) 返回相同内容的新列表;
# 3. consume_iterator([]) 返回空列表且不报错;
# 4. generate_enabled_names() 是包含 yield 的生成器函数;
# 5. 启用名称生成结果为代码助手和测试助手;
# 6. generate_tool_names() 是包含 yield 的生成器函数;
# 7. 工具生成结果保留重复的搜索和终端;
# 8. 同一个生成器第一次收集有数据,第二次收集为空;
# 9. 生成器函数中没有先创建完整结果列表再 return。
# 完成后自查:
# 1. 是否能解释可迭代对象和迭代器的区别;
# 2. 是否知道 iter() 创建迭代器、next() 取出下一个值;
# 3. 是否知道数据取完后会出现 StopIteration
# 4. 是否理解 for 循环会自动处理 StopIteration
# 5. 是否理解 yield 会交出结果并暂停函数;
# 6. 是否知道生成器只能从当前位置继续向后执行;
# 7. 是否保留了完整题目和验收说明。

View File

@@ -0,0 +1,357 @@
# 第 2-6 课:装饰器
## 一、本课目标
完成本课后,你将能够:
1. 理解函数可以像其他值一样赋值和传递;
2. 解释装饰器用于解决什么问题;
3. 编写接收函数并返回新函数的简单装饰器;
4. 使用 `@装饰器名称` 增强函数;
5. 使用 `*args``**kwargs` 转发参数;
6. 保留被装饰函数的返回值;
7. 使用 `functools.wraps` 保留原函数信息。
## 二、前置知识
学习本课前,需要掌握:
- 定义和调用函数;
- 参数、返回值和局部变量;
- `*args``**kwargs`
- 条件判断;
- 模块导入。
## 三、为什么需要装饰器
多个函数可能需要执行相同的额外操作,例如:
- 调用前记录日志;
- 检查用户是否有权限;
- 检查 Agent 是否启用;
- 统计函数执行时间;
- 统一处理函数调用结果。
如果把相同代码复制到每个函数中修改时容易遗漏。装饰器Decorator可以在不修改原函数主体的情况下为函数统一增加行为。
本课使用“记录调用日志”和“检查 Agent 状态”两个简单场景。
## 四、函数也可以作为值
定义函数后,函数名不加括号表示函数本身:
```python
def greet_agent(name):
return f"你好,{name}"
greeting_function = greet_agent
result = greeting_function("代码助手")
print(result)
```
这里没有调用 `greet_agent`,而是把函数交给另一个变量。随后可以通过新变量调用同一个函数。
注意区别:
```python
greeting_function = greet_agent # 保存函数本身
greeting_result = greet_agent("代码助手") # 立即调用并保存结果
```
装饰器能够工作,正是因为函数可以作为值传入另一个函数,也可以从另一个函数返回。
## 五、函数内部可以定义函数
Python 允许在一个函数中定义另一个函数:
```python
def outer():
def inner():
return "内部函数的结果"
return inner
```
`outer()` 返回的是 `inner` 函数本身,而不是它的调用结果:
```python
inner_function = outer()
result = inner_function()
```
装饰器通常会在内部定义一个名为 `wrapper` 的函数。`wrapper` 的中文含义是“包装器”,它会包住原函数的调用过程。
## 六、第一个装饰器
```python
def log_call(func):
def wrapper():
print("函数开始执行。")
result = func()
print("函数执行结束。")
return result
return wrapper
```
逐层理解:
1. `log_call(func)` 接收需要增强的原函数;
2. 内部定义 `wrapper()`
3. `wrapper()` 先执行额外逻辑;
4. `func()` 调用原函数;
5. 保存并返回原函数结果;
6. `log_call()` 最后返回 `wrapper` 函数本身。
这里必须写:
```python
return wrapper
```
不要写成 `return wrapper()`,后者会立即调用包装函数。
## 七、手动使用装饰器
假设有一个函数:
```python
def get_agent_count():
return 3
```
可以手动包装:
```python
get_agent_count = log_call(get_agent_count)
```
等号右边把原函数传给装饰器,装饰器返回 `wrapper`;等号左边再让原名称指向包装后的函数。
以后调用 `get_agent_count()`,实际先进入 `wrapper()`,再由它调用原函数。
## 八、使用 `@` 语法
Python 提供了更清楚的写法:
```python
@log_call
def get_agent_count():
return 3
```
它与下面的手动写法表达相同含义:
```python
get_agent_count = log_call(get_agent_count)
```
`@log_call` 必须紧挨着函数定义的上一行。
## 九、转发不同参数
如果 `wrapper()` 不接收参数,被装饰函数也就无法正常接收参数。为了适应不同函数,可以使用之前学过的 `*args``**kwargs`
```python
def log_call(func):
def wrapper(*args, **kwargs):
print(f"调用函数:{func.__name__}")
result = func(*args, **kwargs)
return result
return wrapper
```
- `*args` 收集并转发位置参数;
- `**kwargs` 收集并转发关键字参数;
- `func.__name__` 是原函数名称。
这使装饰器可以包装参数数量不同的函数。
## 十、不要丢失原函数返回值
错误写法:
```python
def wrapper(*args, **kwargs):
func(*args, **kwargs)
```
原函数虽然被调用,但结果没有返回,调用者最终得到 `None`
正确过程:
```python
def wrapper(*args, **kwargs):
result = func(*args, **kwargs)
return result
```
装饰器可以增加行为,但不应无意中改变原函数正常的返回结果。
## 十一、使用 `functools.wraps`
包装后,函数名称默认可能变成 `wrapper`。这会影响调试、日志和帮助信息。
Python 标准库的 `functools` 模块提供 `wraps`
```python
from functools import wraps
def log_call(func):
@wraps(func)
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
```
`@wraps(func)` 会帮助包装函数保留原函数的名称和说明文字。它本身也是装饰器;本课先掌握固定写法。
## 十二、调用前进行条件检查
装饰器可以决定是否调用原函数:
```python
def require_enabled(func):
@wraps(func)
def wrapper(agent, *args, **kwargs):
if not agent.get("enabled", False):
print("Agent 已停用,不能执行任务。")
return None
return func(agent, *args, **kwargs)
return wrapper
```
如果 Agent 已停用,`wrapper` 提前返回,原函数不会执行;如果已启用,参数会继续转交给原函数。
## 十三、完整示例
示例文件:
```text
02_python进阶/2_6_装饰器/decorator_example.py
```
示例演示:
1. 使用 `@log_operation` 统一增加开始和结束日志;
2. 正确转发 Agent 参数;
3. 保留工具数量返回值;
4. 使用 `@require_enabled` 阻止停用 Agent 执行任务;
5. 使用 `@wraps` 保留原函数名称。
## 十四、运行方法
在项目根目录运行示例:
```powershell
python .\02_python进阶\2_6_装饰器\decorator_example.py
```
完成练习后运行:
```powershell
python .\02_python进阶\2_6_装饰器\practice.py
```
## 十五、预期结果
```text
一、统一记录操作日志
开始执行count_tools
执行结束count_tools
工具数量2
==============================
二、执行前检查 Agent 状态
聊天助手 当前已停用,无法执行操作。
启用 Agent 的结果:代码助手 正在执行任务:检查代码
停用 Agent 的结果None
==============================
三、检查函数名称
被装饰后的函数名count_tools
```
## 十六、常见错误
### 16.1 把函数赋值写成函数调用
```python
saved_function = greet_agent # 保存函数
saved_result = greet_agent("代码助手") # 保存结果
```
括号会立即调用函数,两种写法含义不同。
### 16.2 返回 `wrapper()`
装饰器最后应返回函数本身:
```python
return wrapper
```
写成 `return wrapper()` 会在装饰阶段提前调用。
### 16.3 忘记调用原函数
如果 `wrapper` 只输出日志却没有 `func(...)`,原业务功能就不会执行。
### 16.4 忘记返回原函数结果
需要使用 `result = func(...)``return result`,否则调用者可能得到 `None`
### 16.5 参数没有继续传递
包装函数接收到 `*args``**kwargs` 后,还必须通过 `func(*args, **kwargs)` 转交。
### 16.6 忘记使用 `@wraps`
程序可能仍能运行,但函数名称会变成 `wrapper`,不利于日志和调试。
### 16.7 把所有逻辑都写进装饰器
装饰器适合通用的附加行为。具体业务逻辑仍应留在原函数中,保持职责清楚。
## 十七、课堂练习
打开 `practice.py`,依次完成:
1. 把函数本身赋值给另一个变量;
2. 编写并使用日志装饰器;
3. 检查 `@wraps` 是否保留函数名称;
4. 编写 Agent 启用状态装饰器;
5. 验证停用 Agent 不会执行原函数;
6.`main()` 中输出正常和拦截结果。
## 十八、参考答案
参考答案暂不写入练习文件。完成后,我会检查装饰器是否真正调用原函数、是否保留参数和返回值,以及停用分支是否正确阻止原函数执行。
## 十九、本课小结
- 函数可以赋值给变量,也可以传入和返回;
- 装饰器接收原函数并返回包装函数;
- `@decorator` 是手动重新赋值的简化语法;
- `wrapper` 在调用原函数前后增加统一行为;
- `*args``**kwargs` 可以转发不同参数;
- 包装函数应保留原函数返回值;
- `@wraps(func)` 可以保留原函数名称等信息;
- 装饰器可以在调用前检查条件并阻止无效操作。
## 二十、验收标准
- 能解释函数名加括号与不加括号的区别;
- 能说明装饰器接收和返回的内容;
- 能手动完成一次函数包装;
- 能使用 `@` 语法应用装饰器;
- `wrapper` 能转发位置参数和关键字参数;
- 原函数返回值不会丢失;
- `@wraps` 能保留原函数名称;
- 启用 Agent 可以执行任务;
- 停用 Agent 会被阻止并返回 `None`
- 装饰器只负责通用附加行为,原函数保留具体业务逻辑。

View File

@@ -0,0 +1,79 @@
# 第 2-6 课完整示例:装饰器
#
# 装饰器可以在不修改原函数主体的情况下,统一增加额外行为。
# 本示例为 Agent 操作增加开始、结束日志,并保留参数和返回值。
from functools import wraps
def log_operation(func):
"""为函数增加开始和结束日志。"""
@wraps(func)
def wrapper(*args, **kwargs):
# func.__name__ 表示被装饰函数原来的名称。
print(f"开始执行:{func.__name__}")
result = func(*args, **kwargs)
print(f"执行结束:{func.__name__}")
return result
return wrapper
def require_enabled(func):
"""只允许对处于启用状态的 Agent 执行操作。"""
@wraps(func)
def wrapper(agent, *args, **kwargs):
if not agent.get("enabled", False):
print(f"{agent['name']} 当前已停用,无法执行操作。")
return None
return func(agent, *args, **kwargs)
return wrapper
@log_operation
def count_tools(agent):
"""返回 Agent 的工具数量。"""
return len(agent.get("tools", []))
@require_enabled
def run_agent(agent, task_name):
"""返回 Agent 执行任务时的说明文字。"""
return f"{agent['name']} 正在执行任务:{task_name}"
def main():
"""演示装饰器如何增强函数并保留返回值。"""
code_agent = {
"name": "代码助手",
"enabled": True,
"tools": ["搜索", "终端"],
}
chat_agent = {
"name": "聊天助手",
"enabled": False,
"tools": ["搜索"],
}
print("一、统一记录操作日志")
tool_count = count_tools(code_agent)
print(f"工具数量:{tool_count}")
print("=" * 30)
print("二、执行前检查 Agent 状态")
enabled_result = run_agent(code_agent, "检查代码")
disabled_result = run_agent(chat_agent, "整理对话")
print(f"启用 Agent 的结果:{enabled_result}")
print(f"停用 Agent 的结果:{disabled_result}")
print("=" * 30)
print("三、检查函数名称")
print(f"被装饰后的函数名:{count_tools.__name__}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,83 @@
# 第 2-6 课课堂练习:装饰器
#
# 请先阅读讲义并运行完整示例,再按照题目顺序完成。
# 装饰器必须调用原函数并保留原函数的返回值。
# 不要删除题目、测试数据、预期结果和自查注释。
# 第一部分:理解函数可以赋值给变量
# 1. 定义 greet_agent(name) 函数。
# 2. 函数 return f"你好,{name}。",不要在函数内部 print()。
# 3. 把 greet_agent 函数本身赋值给变量 greeting_function赋值时不要写括号。
# 4. 调用 greeting_function("代码助手"),保存并输出返回值。
# 5. 预期结果:“你好,代码助手。”。
# greeting_function = greet_agent
# print(greeting_function("代码助手"))
# 第二部分:定义 log_call(func) 装饰器
# 1. 在 log_call() 内定义 wrapper(*args, **kwargs)。
# 2. 在 wrapper 上方使用 @wraps(func)。
# 3. wrapper 先输出 f"调用函数:{func.__name__}"。
# 4. 使用 func(*args, **kwargs) 调用原函数,并把返回值保存为 result。
# 5. wrapper return result不能丢失原函数返回值。
# 6. log_call() return wrapper注意不要写成 wrapper()。
# 第三部分:使用 @log_call
# 1. 定义 get_agent_name(agent) 函数。
# 2. 在函数上方写 @log_call。
# 3. 函数 return agent["name"]。
# 4. 调用 get_agent_name(enabled_agent),保存并输出结果。
# 5. 预期先输出“调用函数get_agent_name”再得到“代码助手”。
# 6. 检查 get_agent_name.__name__预期仍是 "get_agent_name"。
# print(get_agent_name(enabled_agent))
# print(get_agent_name.__name__)
# 第四部分:定义 require_enabled(func) 装饰器
# 1. 在 require_enabled() 内定义 wrapper(agent, *args, **kwargs)。
# 2. 在 wrapper 上方使用 @wraps(func)。
# 3. 如果 agent.get("enabled", False) 为 False
# 输出“Agent 已停用,不能执行任务。”并 return None。
# 4. 如果 Agent 已启用,调用 func(agent, *args, **kwargs) 并 return 原结果。
# 5. require_enabled() return wrapper。
# 第五部分:使用 @require_enabled
# 1. 定义 run_task(agent, task_name) 函数。
# 2. 在函数上方写 @require_enabled。
# 3. 函数 return f"{agent['name']} 正在执行:{task_name}"。
# 4. 传入 enabled_agent 和 "检查代码",预期返回:
# “代码助手 正在执行:检查代码”。
# 5. 传入 disabled_agent 和 "整理对话",预期输出停用提示并返回 None。
# print(run_task(enabled_agent ,"检查代码"))
# print(run_task(disabled_agent ,"整理对话"))
# 第六部分:定义 main() 函数
# 1. 完成第一部分的函数赋值实验,并输出问候结果。
# 2. 调用 get_agent_name(enabled_agent),保存并输出返回值。
# 3. 输出 get_agent_name.__name__确认 @wraps 保留了原函数名。
# 4. 分别使用启用和停用 Agent 调用 run_task()。
# 5. 保存并输出两个调用结果,停用 Agent 的结果应为 None。
# 6. 添加程序入口判断,直接运行本文件时调用 main()。
# 最终验收测试:
# 1. greeting_function 与 greet_agent 指向同一个函数;
# 2. greeting_function("代码助手") 返回正确文字;
# 3. get_agent_name() 调用前输出函数名称;
# 4. get_agent_name(enabled_agent) 返回“代码助手”;
# 5. get_agent_name.__name__ 仍是 "get_agent_name"
# 6. run_task(enabled_agent, "检查代码") 返回正确文字;
# 7. run_task(disabled_agent, "整理对话") 返回 None
# 8. 停用 Agent 不会执行原 run_task() 的函数主体;
# 9. 两个装饰器都能接收参数并保留原函数返回值。
# 完成后自查:
# 1. 是否理解函数名不加括号表示函数本身;
# 2. 是否理解函数名加括号表示立即调用函数;
# 3. 装饰器是否接收原函数并返回 wrapper
# 4. wrapper 是否调用并返回原函数结果;
# 5. 是否使用 *args 和 **kwargs 转发参数;
# 6. 是否使用 @wraps(func) 保留原函数信息;
# 7. 是否能解释 @log_call 与手动重新赋值的关系;
# 8. 是否保留了完整题目和验收说明。

View File

@@ -0,0 +1,383 @@
# 第 2-7 课:类型注解
## 一、本课目标
完成本课后,你将能够:
1. 解释类型注解是什么,以及它能解决什么问题;
2. 为变量、函数参数和返回值添加类型注解;
3. 标注 `str``int``float``bool`
4. 标注字符串列表与字符串字典;
5. 使用 `类型 | None` 表示结果可能不存在;
6. 使用 `-> None` 标注只执行操作、不返回业务结果的函数;
7. 理解类型注解通常不会在运行时自动强制检查数据。
## 二、前置知识
学习本课前,需要掌握:
- 变量和基本数据类型;
- 列表与字典;
- 函数、参数与返回值;
- 条件判断;
- `None` 的基本含义。
## 三、为什么需要类型注解
观察下面的函数:
```python
def format_status(name, enabled):
...
```
只看函数定义,我们无法立即确定:
- `name` 应该传字符串还是字典;
- `enabled` 应该传布尔值还是文字;
- 函数最后返回什么类型。
添加类型注解Type Hint
```python
def format_status(name: str, enabled: bool) -> str:
...
```
现在可以看出:
- `name` 预期是字符串;
- `enabled` 预期是布尔值;
- `-> str` 表示预期返回字符串。
类型注解的主要作用是帮助人和工具理解代码。
## 四、类型注解不会自动改变数据
```python
age: int = 18
```
`int` 说明变量预期保存整数,但它不会执行类型转换。下面两段代码不同:
```python
age: int = "18" # 只是写了注解,值仍然是字符串
age = int("18") # 真正把字符串转换为整数
```
Python 通常也不会因为注解与实际值不一致而立即阻止程序运行。编辑器或静态类型检查工具可以提前提示问题。
静态类型检查Static Type Checking表示在不真正执行全部业务代码的情况下根据注解检查可能的类型错误。
## 五、变量类型注解
基本格式:
```python
变量名: 类型 =
```
常见示例:
```python
agent_name: str = "代码助手"
tool_count: int = 2
completion_rate: float = 0.75
enabled: bool = True
```
冒号后面是预期类型,等号后面仍然是实际值。
简单变量的类型通常很容易从值中看出,因此不必强迫每个局部变量都写注解。类型不明显或需要强调时再添加。
## 六、参数和返回值注解
```python
def get_status_text(enabled: bool) -> str:
return "启用" if enabled else "停用"
```
- `enabled: bool` 是参数注解;
- `-> str` 是返回值注解;
- 注解不影响函数正常调用方式。
调用仍然写成:
```python
status_text = get_status_text(True)
```
## 七、常见基本类型
本课使用以下类型:
| 注解 | 表示的数据 |
|---|---|
| `str` | 字符串 |
| `int` | 整数 |
| `float` | 浮点数 |
| `bool` | 布尔值 |
| `None` | 没有业务结果 |
类型名称不需要加引号:
```python
name: str = "代码助手"
```
不要写成:
```python
name: "str" = "代码助手"
```
字符串形式的类型注解有其他用途,本课暂不展开。
## 八、列表类型注解
字符串列表:
```python
tools: list[str] = ["搜索", "终端"]
```
可以从外向内理解:
- `list`:这是列表;
- `[str]`:列表中的元素预期是字符串。
函数示例:
```python
def count_tools(tools: list[str]) -> int:
return len(tools)
```
返回字符串列表:
```python
def get_names() -> list[str]:
return ["代码助手", "聊天助手"]
```
## 九、字典类型注解
名称到模型的对应关系,可以标注为:
```python
models: dict[str, str] = {
"代码助手": "gpt-5",
"聊天助手": "o3",
}
```
`dict[str, str]` 表示:
- 第一个 `str`:字典键是字符串;
- 第二个 `str`:字典值也是字符串。
任务列表可能写成:
```python
tasks: list[dict[str, str]] = [
{"title": "学习类型注解", "status": "已完成"},
]
```
从外向内理解:这是一个列表,列表中每项是字典,字典的键和值都是字符串。
## 十、结果可能是 `None`
查找操作可能找到字符串,也可能找不到:
```python
def find_model(
models: dict[str, str],
agent_name: str,
) -> str | None:
return models.get(agent_name)
```
`str | None` 表示返回值有两种可能:
- 找到时返回 `str`
- 未找到时返回 `None`
符号 `|` 可以理解为“或者”。
调用者应检查:
```python
model_name = find_model(models, "测试助手")
if model_name is None:
print("没有找到模型。")
```
## 十一、返回值标注为 `None`
只负责输出、不返回业务结果的函数:
```python
def print_agent(name: str) -> None:
print(name)
```
`-> None` 表示调用者不应期待它返回可供后续使用的业务数据。
程序入口也常写成:
```python
def main() -> None:
...
```
## 十二、类型注解和默认值
默认值写在类型注解之后:
```python
def create_agent(name: str, enabled: bool = True) -> str:
...
```
阅读顺序:
- 参数名是 `enabled`
- 类型是 `bool`
- 默认值是 `True`
不要把类型和默认值的位置写反。
## 十三、类型注解能带来什么
类型注解可以帮助:
- 阅读者更快理解参数和返回值;
- 编辑器提供更准确的自动补全;
- 编辑器提前提示可能的类型错误;
- 重构代码时发现受影响的位置;
- 静态类型检查工具检查大型项目。
类型注解不能代替:
- 运行时数据校验;
- `try...except` 异常处理;
- 业务规则判断;
- 单元测试。
例如Web 接口收到的外部数据仍然需要真实校验,不能只依靠注解。
## 十四、完整示例
示例文件:
```text
02_python进阶/2_7_类型注解/type_annotation_example.py
```
示例会演示:
1. 字符串和布尔参数注解;
2. 字符串列表和字符串字典;
3. 整数返回值;
4. `str | None`
5. 只输出函数的 `-> None`
## 十五、运行方法
在项目根目录运行示例:
```powershell
python .\02_python进阶\2_7_类型注解\type_annotation_example.py
```
完成练习后运行:
```powershell
python .\02_python进阶\2_7_类型注解\practice.py
```
## 十六、预期结果
```text
代码助手|状态:启用
模型gpt-5
不存在的模型None
Agent 名称:代码助手
工具数量2
```
## 十七、常见错误
### 17.1 把注解当作类型转换
```python
count: int = "2"
```
实际值仍然是字符串。需要转换时必须使用 `int("2")`
### 17.2 忘记返回值箭头
参数使用冒号,返回值使用 `->`
```python
def count_tools(tools: list[str]) -> int:
...
```
### 17.3 列表没有写元素类型
只写 `list` 无法说明元素是什么。本课优先写成 `list[str]`
### 17.4 混淆字典键和值的类型
`dict[str, int]` 表示字符串键和整数值,前后顺序不能颠倒。
### 17.5 可能返回 `None` 却只标注字符串
如果函数可能找不到结果,应标注 `str | None`,提醒调用者处理空结果。
### 17.6 认为有注解就不需要测试
类型正确不代表业务结果正确。边界条件、异常和实际输出仍需验证。
## 十八、课堂练习
打开 `practice.py`,依次完成:
1. 为任务状态格式化函数添加基本类型;
2. 创建并返回字符串字典;
3. 处理任务字典列表;
4. 使用 `float | None` 表示两种完成率结果;
5. 使用 `-> None` 标注输出函数和主函数;
6. 运行正常与零任务数用例。
## 十九、参考答案
参考答案暂不写入练习文件。完成后,我会验证函数行为与 `__annotations__` 中的实际注解,并检查注解是否与真实返回值一致。
## 二十、本课小结
- 类型注解说明代码预期使用的类型;
- 参数类型写在冒号后,返回类型写在 `->` 后;
- `list[str]` 表示字符串列表;
- `dict[str, str]` 表示字符串键和字符串值;
- `str | None` 表示可能返回字符串或 `None`
- 只执行操作的函数可以标注 `-> None`
- 类型注解不会自动转换或验证运行时数据;
- 类型注解提高可读性,但不能代替校验、异常处理和测试。
## 二十一、验收标准
- 能解释类型注解的作用;
- 能为基本类型参数和返回值添加注解;
- 能正确标注字符串列表;
- 能正确标注字符串字典;
- 能看懂嵌套的 `list[dict[str, str]]`
- 能使用 `float | None` 表达两种结果;
- 能使用 `-> None` 标注输出函数;
- 能说明注解与类型转换的区别;
- 能说明类型注解通常不会自动强制检查运行时数据;
- 所有注解与函数真实返回值保持一致。

View File

@@ -0,0 +1,84 @@
# 第 2-7 课课堂练习:类型注解
#
# 请先阅读讲义并运行完整示例,再按照题目顺序完成。
# 每个函数都需要写参数类型和返回值类型。
# 不要删除题目、测试数据、预期结果和自查注释。
# 第一部分:定义 format_task_status(title, completed) 函数
# 1. 参数 title 标注为 str。
# 2. 参数 completed 标注为 bool。
# 3. 返回值标注为 str。
# 4. completed 为 True 时状态文字是“已完成”,否则是“未完成”。
# 5. return f"{title}|状态:{status_text}"。
# 6. 传入“学习类型注解”和 True预期返回
# “学习类型注解|状态:已完成”。
# print(format_task_status("学习类型注解", True))
# 第二部分:定义 build_task(title, status) 函数
# 1. 两个参数都标注为 str。
# 2. 返回值标注为 dict[str, str]。
# 3. return 包含 title 和 status 的新字典。
# 4. 传入“复习生成器”和“未开始”,预期返回:
# {"title": "复习生成器", "status": "未开始"}。
# print(build_task("复习生成器", "未开始"))
# 第三部分:定义 get_task_titles(tasks) 函数
# 1. 参数标注为 list[dict[str, str]]。
# 2. 返回值标注为 list[str]。
# 3. 使用列表推导式取得每个任务的 title。
# 4. return 新列表,不要修改原 tasks。
# 5. 使用顶部测试数据,预期返回:
# ["学习类型注解", "完成课堂练习"]。
# print(get_task_titles(tasks))
# 第四部分:定义 calculate_completion_rate(completed_count, total_count) 函数
# 1. 两个参数都标注为 int。
# 2. 返回值标注为 float | None表示可能返回浮点数也可能返回 None。
# 3. total_count 为 0 时 return None。
# 4. 否则 return completed_count / total_count。
# 5. 传入 3 和 4预期返回 0.75。
# 6. 传入 0 和 0预期返回 None程序不能崩溃。
# print(calculate_completion_rate(3,4))
# print(calculate_completion_rate(0,0))
# 第五部分:定义 print_task(task) 函数
# 1. 参数标注为 dict[str, str]。
# 2. 返回值标注为 None因为本函数只输出不返回业务结果。
# 3. 按“任务:标题|状态:状态文字”的格式输出。
# 4. 传入第一个测试任务,预期输出:
# “任务:学习类型注解|状态:已完成”。
# print_task(tasks[0])
# 第六部分:定义 main() 函数
# 1. 返回值标注为 None。
# 2. 依次调用前面五个函数,并保存需要使用的返回值。
# 3. 输出格式化状态、新任务字典、任务标题列表和两种完成率。
# 4. 调用 print_task(tasks[0]) 输出第一个任务。
# 5. 添加程序入口判断,直接运行本文件时调用 main()。
# 最终验收测试:
# 1. format_task_status() 的参数和返回值注解正确;
# 2. build_task() 返回正确字典,并标注 dict[str, str]
# 3. get_task_titles() 返回正确列表,并标注 list[str]
# 4. calculate_completion_rate(3, 4) 返回 0.75
# 5. calculate_completion_rate(0, 0) 返回 None
# 6. 完成率返回类型标注为 float | None
# 7. print_task() 和 main() 的返回值标注为 None
# 8. 原始 tasks 没有被修改;
# 9. 直接运行程序时,所有结果均正确输出。
# 完成后自查:
# 1. 是否知道冒号后写参数或变量类型;
# 2. 是否知道 -> 后写函数返回值类型;
# 3. 是否能区分 list[str] 和 dict[str, str]
# 4. 是否理解 float | None 表示两种可能结果;
# 5. 是否知道只输出的函数返回值标注为 None
# 6. 是否理解类型注解通常不会自动检查运行时数据;
# 7. 是否保留了完整题目和验收说明。

View File

@@ -0,0 +1,53 @@
# 第 2-7 课完整示例:类型注解
#
# 类型注解用于说明变量、参数和返回值预期使用的类型。
# Python 通常不会在运行时自动强制检查这些注解。
def format_agent_status(name: str, enabled: bool) -> str:
"""根据名称和启用状态返回中文说明。"""
status_text = "启用" if enabled else "停用"
return f"{name}|状态:{status_text}"
def count_tools(tools: list[str]) -> int:
"""返回工具名称列表中的元素数量。"""
return len(tools)
def find_agent_model(
model_mapping: dict[str, str],
agent_name: str,
) -> str | None:
"""返回指定 Agent 的模型,找不到时返回 None。"""
return model_mapping.get(agent_name)
def print_agent_report(name: str, tools: list[str]) -> None:
"""输出 Agent 报告,本函数不返回业务结果。"""
print(f"Agent 名称:{name}")
print(f"工具数量:{count_tools(tools)}")
def main() -> None:
"""准备带类型注解的数据并调用示例函数。"""
agent_name: str = "代码助手"
enabled: bool = True
tools: list[str] = ["搜索", "终端"]
model_mapping: dict[str, str] = {
"代码助手": "gpt-5",
"聊天助手": "o3",
}
status_text = format_agent_status(agent_name, enabled)
model_name = find_agent_model(model_mapping, agent_name)
missing_model = find_agent_model(model_mapping, "测试助手")
print(status_text)
print(f"模型:{model_name}")
print(f"不存在的模型:{missing_model}")
print_agent_report(agent_name, tools)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,410 @@
# 第 2-8 课:虚拟环境与依赖管理
## 一、本课目标
完成本课后,你将能够:
1. 解释虚拟环境是什么,以及为什么需要它;
2. 在 Windows PowerShell 中创建、激活和退出虚拟环境;
3. 确认命令实际使用的是哪个 Python 解释器;
4. 使用 `python -m pip` 查看、安装和卸载第三方依赖;
5. 使用 `requirements.txt` 记录与恢复项目依赖;
6. 理解版本固定的基本作用;
7. 知道 `.venv` 不应提交到 Git。
## 二、前置知识
学习本课前,需要掌握:
- 在 PowerShell 中进入目录并运行 Python 文件;
- 模块与导入;
- 文件和路径;
- 基础异常处理;
- 类型注解。
## 三、什么是第三方依赖
Python 自带的模块组成标准库Standard Library例如 `pathlib``sys``platform`,使用它们通常不需要安装额外内容。
第三方依赖Third-Party Dependency是其他开发者发布、项目需要额外安装的软件包。例如以后 Web 阶段会使用 FastAPI、Django 等框架。
安装依赖后,我们可以在代码中导入它:
```python
import package_name
```
这里的 `package_name` 只是格式示例,不要求本课安装这个不存在的软件包。
## 四、为什么不能把所有依赖都装在同一个环境
假设有两个项目:
- 项目 A 需要某个软件包的旧版本;
- 项目 B 需要同一个软件包的新版本。
如果两个项目共用一个 Python 环境,升级项目 B 的依赖可能导致项目 A 无法运行。
还可能遇到:
- 不知道某个依赖属于哪个项目;
- 删除一个依赖时误伤其他项目;
- 自己的机器能运行,换一台机器却缺少依赖;
- 项目依赖越来越杂乱。
## 五、什么是虚拟环境
虚拟环境Virtual Environment是为一个项目准备的独立 Python 运行环境。它拥有自己的 Python 入口和依赖安装位置。
可以把它理解为每个项目自己的“工具箱”:
```text
项目 A → 自己的虚拟环境 → 自己的依赖
项目 B → 自己的虚拟环境 → 自己的依赖
```
虚拟环境不会复制整个操作系统,也不会删除系统 Python。它只是隔离 Python 解释器入口和软件包目录。
## 六、创建虚拟环境
本项目统一把虚拟环境放在项目根目录的 `.venv` 中。
先在 PowerShell 进入项目根目录:
```powershell
cd D:\Code\Python
```
创建环境:
```powershell
python -m venv .venv
```
逐段理解:
- `python`:使用当前 Python
- `-m venv`:运行标准库中的 `venv` 模块;
- `.venv`:创建的环境目录名称。
创建可能需要几秒钟。成功后根目录会出现 `.venv`,但项目根目录的 `.gitignore` 已忽略它。
## 七、激活虚拟环境
PowerShell 中执行:
```powershell
.\.venv\Scripts\Activate.ps1
```
激活后,命令提示符前通常会出现:
```text
(.venv)
```
激活的作用主要是临时调整当前终端的命令查找顺序,让 `python``pip` 优先指向 `.venv`
激活只影响当前终端窗口。关闭终端后,下次需要重新激活。
## 八、遇到 PowerShell 脚本限制怎么办
部分机器可能阻止运行 `Activate.ps1`。本课不要求为了激活环境而修改整台电脑的执行策略。
即使不激活,也可以直接使用虚拟环境中的 Python
```powershell
.\.venv\Scripts\python.exe --version
.\.venv\Scripts\python.exe .\02_python进阶\2_8_虚拟环境与依赖管理\environment_example.py
```
这种写法目标明确,也能保证使用 `.venv`。如果遇到执行策略错误,保留英文错误原文,再说明你执行的命令,我会帮助判断。
## 九、确认正在使用哪个 Python
激活前后都可以执行:
```powershell
python -c "import sys; print(sys.executable)"
```
激活虚拟环境后,输出路径应指向项目中的 `.venv\Scripts\python.exe`
还可以执行本课示例:
```powershell
python .\02_python进阶\2_8_虚拟环境与依赖管理\environment_example.py
```
示例通过比较:
```python
sys.prefix != sys.base_prefix
```
判断当前是否处于普通 `venv` 虚拟环境。
## 十、退出虚拟环境
激活后执行:
```powershell
deactivate
```
退出只会恢复当前终端的命令环境,不会删除 `.venv`
以后重新使用时再次运行激活命令即可。
## 十一、什么是 `pip`
`pip` 是 Python 常用的软件包安装工具。它可以从软件包仓库下载和管理第三方依赖。
本课推荐始终使用:
```powershell
python -m pip 命令
```
而不是只写:
```powershell
pip 命令
```
`python -m pip` 能更明确地表示:“使用当前这个 Python 对应的 pip”减少多个 Python 环境之间装错位置的风险。
## 十二、查看依赖
查看 pip 版本和安装位置:
```powershell
python -m pip --version
```
查看当前环境已安装的软件包:
```powershell
python -m pip list
```
查看某个已安装软件包的信息:
```powershell
python -m pip show 软件包名称
```
检查已安装依赖之间是否存在已知的不兼容:
```powershell
python -m pip check
```
## 十三、安装与卸载依赖
安装命令格式:
```powershell
python -m pip install 软件包名称
```
安装指定版本:
```powershell
python -m pip install 软件包名称==版本号
```
卸载:
```powershell
python -m pip uninstall 软件包名称
```
安装和卸载会修改当前 Python 环境。执行前必须先确认解释器路径,避免改动错误环境。本课不强制安装任何第三方依赖,也不要把示例中的占位名称原样执行。
## 十四、什么是版本固定
版本固定Version Pinning表示明确记录项目使用的依赖版本
```text
some-package==1.2.3
```
它可以减少不同机器安装到不同版本而产生的差异。
需要注意:版本号必须来自项目实际验证过的环境,不要随意照抄教程中的旧版本。以后开始 FastAPI、Django 项目时,会根据实际环境选择并验证版本。
## 十五、使用 `requirements.txt`
`requirements.txt` 是常见的依赖清单文件,每行记录一个直接依赖或带版本的依赖:
```text
package-a==1.2.3
package-b==4.5.6
```
在另一台机器或新虚拟环境中恢复依赖:
```powershell
python -m pip install -r requirements.txt
```
根据当前环境导出所有已安装包:
```powershell
python -m pip freeze > requirements.txt
```
`pip freeze` 会记录当前环境中的全部软件包,包括间接依赖。导出前要确认环境干净、确实属于当前项目,避免把无关包写入清单。
本课没有第三方依赖,因此不会提前创建包含虚假依赖的 `requirements.txt`
## 十六、不要提交 `.venv`
`.venv` 可能包含大量文件,并带有当前机器的路径和平台信息,不适合提交到 Git。
应该提交的是:
- Python 源代码;
- 课程文档;
- 经过确认的依赖清单。
不应该提交的是:
- `.venv/`
- `__pycache__/`
- 本地密钥和 `.env`
本项目根目录 `.gitignore` 已包含:
```text
.venv/
venv/
```
## 十七、完整示例
示例文件:
```text
02_python进阶/2_8_虚拟环境与依赖管理/environment_example.py
```
它只读取并输出:
- Python 版本;
- 当前解释器路径;
- 当前环境目录名称;
- 是否为普通 `venv` 虚拟环境;
- 当前环境的 pip 版本。
示例不会创建环境,也不会安装或卸载依赖。
## 十八、运行方法
未激活虚拟环境时运行:
```powershell
python .\02_python进阶\2_8_虚拟环境与依赖管理\environment_example.py
```
创建环境后,不激活而直接运行:
```powershell
.\.venv\Scripts\python.exe .\02_python进阶\2_8_虚拟环境与依赖管理\environment_example.py
```
完成代码练习后运行:
```powershell
python .\02_python进阶\2_8_虚拟环境与依赖管理\practice.py
```
## 十九、预期结果
具体路径和版本因机器而异,格式类似:
```text
Python 版本3.x.x
Python 解释器:某个实际路径
当前环境目录:某个环境名称
是否为 venv 虚拟环境:是或否
pip 版本:某个实际版本
```
在普通环境中通常显示“否”;使用 `.venv\Scripts\python.exe` 运行时应显示“是”。
## 二十、常见错误
### 20.1 创建环境后仍然使用原 Python
创建 `.venv` 不等于已经切换环境。需要激活,或者直接调用 `.venv\Scripts\python.exe`
### 20.2 把依赖安装到错误环境
安装前先执行:
```powershell
python -c "import sys; print(sys.executable)"
```
确认路径后再使用 `python -m pip install`
### 20.3 把 `.venv` 提交到 Git
虚拟环境是本地生成内容,应由 `.gitignore` 忽略。其他机器根据依赖清单重新创建。
### 20.4 把依赖清单当成虚拟环境
`requirements.txt` 只记录依赖名称和版本,不包含已安装文件。仍需先创建环境,再安装清单。
### 20.5 认为激活会永久改变系统
激活主要影响当前终端。执行 `deactivate` 或关闭终端即可退出。
### 20.6 随意修改 PowerShell 执行策略
激活脚本被阻止时,可以直接使用 `.venv\Scripts\python.exe`,本课不要求修改系统级策略。
## 二十一、课堂练习
练习分为两部分。
终端操作:
1. 在普通环境运行 `environment_example.py` 并记录结果;
2. 在项目根目录创建 `.venv`
3. 激活环境,或直接使用 `.venv\Scripts\python.exe`
4. 再次运行示例并比较解释器路径;
5. 执行 `python -m pip --version``python -m pip list`
6. 退出环境并确认解释器恢复。
代码练习:打开 `practice.py`,完成环境判断、版本读取、环境报告和输出函数。
## 二十二、参考答案
参考答案暂不写入练习文件。完成后,我会验证函数行为和类型注解,并分别在当前解释器与临时虚拟环境中运行,确认环境判断能够反映真实差异。
## 二十三、本课小结
- 虚拟环境为项目隔离 Python 解释器入口和依赖;
- 使用 `python -m venv .venv` 创建环境;
- 激活后,当前终端优先使用虚拟环境;
- 不激活时也可以直接调用虚拟环境中的 Python
- `python -m pip` 能减少把依赖装错环境的风险;
- `requirements.txt` 用于记录和恢复依赖;
- 版本固定有助于保持不同机器的环境一致;
- `.venv` 是本地生成目录,不应提交到 Git。
## 二十四、验收标准
- 能解释虚拟环境解决的问题;
- 能创建、进入和退出 `.venv`
- 能确认当前 Python 解释器路径;
- 能使用 `python -m pip` 查看依赖;
- 能说明安装与卸载会修改当前环境;
- 能解释 `requirements.txt` 的用途;
- 能说明版本固定的基本作用;
- 能解释为什么 `.venv` 不应提交;
- 环境检查脚本在普通环境与 `.venv` 中显示不同判断;
- 没有把个人绝对路径或虚假依赖写入项目文件。

View File

@@ -0,0 +1,45 @@
# 第 2-8 课完整示例:虚拟环境与依赖管理
#
# 本示例只读取当前 Python 解释器和环境信息,不安装、卸载任何依赖。
import platform
import sys
from importlib.metadata import PackageNotFoundError, version
from pathlib import Path
def is_virtual_environment() -> bool:
"""判断当前程序是否运行在普通 venv 虚拟环境中。"""
# 普通环境中 sys.prefix 和 sys.base_prefix 通常相同。
# venv 激活并使用其 Python 后,这两个路径通常不同。
return sys.prefix != sys.base_prefix
def get_package_version(package_name: str) -> str | None:
"""返回已安装依赖的版本,未安装时返回 None。"""
try:
return version(package_name)
except PackageNotFoundError:
return None
def get_environment_name() -> str:
"""返回当前环境目录名称。"""
return Path(sys.prefix).name
def main() -> None:
"""输出解释器、虚拟环境和 pip 版本信息。"""
in_virtual_environment = is_virtual_environment()
environment_status = "" if in_virtual_environment else ""
pip_version = get_package_version("pip")
print(f"Python 版本:{platform.python_version()}")
print(f"Python 解释器:{sys.executable}")
print(f"当前环境目录:{get_environment_name()}")
print(f"是否为 venv 虚拟环境:{environment_status}")
print(f"pip 版本:{pip_version}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,78 @@
# 第 2-8 课课堂练习:检查虚拟环境
#
# 本文件只读取环境信息,不负责创建环境或安装依赖。
# 创建、激活和退出虚拟环境的操作请按照讲义在 PowerShell 中完成。
# 不要删除题目、预期结果和自查注释。
# 第一部分:定义 is_virtual_environment() 函数
# 1. 不接收参数,返回值标注为 bool。
# 2. 比较 sys.prefix 和 sys.base_prefix。
# 3. 两者不相等时返回 True否则返回 False。
# 4. 不要根据路径中是否包含“.venv”来判断。
# 第二部分:定义 get_python_version() 函数
# 1. 不接收参数,返回值标注为 str。
# 2. 调用 platform.python_version() 并 return 结果。
# 3. 不要手工写死版本号,因为不同机器的版本可能不同。
# 第三部分:定义 get_environment_name() 函数
# 1. 不接收参数,返回值标注为 str。
# 2. 使用 Path(sys.prefix).name 取得当前环境目录名称。
# 3. return 得到的名称。
# 第四部分:定义 get_environment_report() 函数
# 1. 不接收参数,返回值标注为 dict[str, str]。
# 2. 调用前面三个函数。
# 3. 把布尔值转换为中文:“是”或“否”。
# 4. return 包含以下三个键的字典:
# "python_version":当前 Python 版本;
# "environment_name":当前环境目录名称;
# "is_virtual_environment":中文“是”或“否”。
# 第五部分:定义 print_environment_report(report) 函数
# 1. 参数标注为 dict[str, str],返回值标注为 None。
# 2. 依次输出:
# Python 版本:实际版本
# 当前环境:实际环境名称
# 是否为虚拟环境:是或否
# 3. 本函数只负责输出,不修改 report。
# 第六部分:定义 main() 函数
# 1. 返回值标注为 None。
# 2. 调用 get_environment_report() 并保存结果。
# 3. 调用 print_environment_report() 输出报告。
# 4. 添加程序入口判断,直接运行本文件时调用 main()。
# 第七部分:在两个环境中运行
# 1. 未激活本课虚拟环境时运行一次 practice.py记录输出。
# 2. 按讲义创建并激活项目根目录下的 .venv。
# 3. 再次运行 practice.py。
# 4. 对比两次的环境名称和“是否为虚拟环境”。
# 5. 两台机器的路径和 Python 版本可能不同,不要求写死相同结果。
# 最终验收测试:
# 1. 所有函数的参数和返回值注解正确;
# 2. Python 版本来自 platform.python_version()
# 3. 环境名称来自 Path(sys.prefix).name
# 4. 虚拟环境判断来自 sys.prefix != sys.base_prefix
# 5. 报告字典包含三个指定键,值都是字符串;
# 6. 输出函数不修改报告字典;
# 7. 普通环境与 .venv 中的输出能够体现环境差异;
# 8. 没有在代码中写死个人机器路径或 Python 版本;
# 9. 没有在脚本中自动安装或卸载依赖。
# 完成后自查:
# 1. 是否知道虚拟环境用于隔离项目依赖;
# 2. 是否知道激活环境不会删除系统 Python
# 3. 是否优先使用 python -m pip
# 4. 是否知道 requirements.txt 用于记录项目依赖;
# 5. 是否知道 .venv 不应提交到 Git
# 6. 是否能退出环境并重新激活;
# 7. 是否保留了完整题目和验收说明。

View File

@@ -0,0 +1,405 @@
# 补充Conda、pip、uv 与 Python 虚拟环境的区别
## 一、先给出结论
这些名称不完全属于同一类工具:
- `venv``virtualenv`:主要负责创建相互隔离的 Python 环境;
- `pip`:主要负责向某个 Python 环境安装和卸载 Python 软件包,本身不负责创建虚拟环境;
- Conda同时管理环境、Python 版本、Python 包和部分非 Python 软件;
- `uv`:同时覆盖 Python 版本、虚拟环境、依赖安装、项目依赖和锁文件等工作。
因此“Conda、pip、uv 哪个虚拟环境更好”这个问题并不完全准确。更合适的问题是:
> 当前项目应该使用哪一种环境管理与依赖管理组合?
## 二、几个概念不要混在一起
### 2.1 Python 解释器
Python 解释器是实际运行 `.py` 文件的程序,例如当前机器检测到的:
```text
C:\ProgramData\miniconda3\python.exe
```
不同环境可能有自己的解释器入口,也可能使用不同的 Python 版本。
### 2.2 虚拟环境
虚拟环境负责隔离项目使用的 Python 和已安装软件包。例如项目 A 与项目 B 可以分别拥有自己的 `.venv`,互不影响。
### 2.3 包管理器
包管理器负责查找、安装、更新和卸载软件包:
- `pip` 主要管理 Python 软件包;
- Conda 可以管理 Conda 软件包,其中可以包含 Python、Python 包以及其他软件;
- `uv` 可以通过项目工作流或兼容 pip 的命令管理 Python 依赖。
### 2.4 依赖解析与锁定
依赖解析负责计算“安装哪些版本才能彼此兼容”。锁文件用于记录解析后的精确结果,使其他机器尽量复现相同环境。
常见文件包括:
- pip 工作流:`requirements.txt`
- Conda 工作流:`environment.yml`,也可配合相应锁文件;
- uv 项目工作流:`pyproject.toml``uv.lock`
## 三、`venv`Python 自带的基础选择
`venv` 是 Python 标准库中的虚拟环境工具,不需要单独安装。
创建环境:
```powershell
python -m venv .venv
```
激活:
```powershell
.\.venv\Scripts\Activate.ps1
```
在环境中安装包:
```powershell
python -m pip install 软件包名称
```
特点:
- Python 自带,概念简单;
- 通常基于创建它的现有 Python
- 只负责创建环境,不负责完整项目锁定;
- 通常与 `pip``requirements.txt` 一起使用;
- 适合普通 Python 脚本、Web 后端和入门学习。
典型组合:
```text
venv 创建环境
+ pip 安装依赖
+ requirements.txt 记录依赖
```
## 四、`virtualenv`:功能更丰富的环境创建工具
`virtualenv` 是需要额外安装的第三方工具。Python 官方打包指南说明,`venv` 是 Python 自带方案,而 `virtualenv` 提供更广的兼容性和额外能力。
基本命令:
```powershell
virtualenv .venv
```
特点:
- 主要职责与 `venv` 相同,都是创建隔离环境;
- 需要先安装;
- 提供更丰富的解释器选择、配置和兼容能力;
- 在只需要基础环境隔离的新项目中,通常先用 `venv` 就够了。
对当前零基础课程,不必同时学习 `venv``virtualenv`。先理解 `venv`,以后遇到明确需求再使用 `virtualenv`
## 五、`pip`:软件包安装器,不是虚拟环境
`pip` 的职责是把 Python 软件包安装到某个环境。
推荐命令:
```powershell
python -m pip install 软件包名称
```
这里的关键是“当前这个 Python”
- 如果当前 Python 来自 `.venv`,依赖会装入 `.venv`
- 如果当前 Python 来自 Conda 环境,依赖会装入该 Conda 环境;
- 如果当前 Python 是全局环境,依赖可能被装入全局环境。
所以 `pip` 自己不提供隔离,隔离由 `venv``virtualenv` 或 Conda 环境等机制提供。
`pip` 常见能力:
```powershell
python -m pip install 软件包名称
python -m pip uninstall 软件包名称
python -m pip list
python -m pip show 软件包名称
python -m pip check
python -m pip install -r requirements.txt
```
优点:
- Python 生态中最通用;
- PyPI 上的软件包覆盖广;
-`venv` 配合简单;
- 教程、部署平台和持续集成环境普遍支持。
需要注意:
- 安装前必须确认当前解释器;
- `requirements.txt` 与环境本身是两回事;
- `pip freeze` 会导出当前环境全部软件包,环境不干净时可能带入无关依赖。
## 六、Conda环境管理器加跨语言包管理器
Conda 同时负责环境和包管理。它可以在创建环境时指定 Python 版本:
```powershell
conda create -n python-course python=3.13
conda activate python-course
```
安装 Conda 软件包:
```powershell
conda install 软件包名称
```
查看环境:
```powershell
conda env list
```
Conda 环境与普通 `venv` 的重要区别:
- Conda 可以直接管理环境中的 Python 版本;
- Conda 包不局限于纯 Python 包,也能包含本地库、命令行程序等内容;
- 软件包来自 Conda channel例如 `defaults``conda-forge`
- 环境通常集中保存在 Conda 安装目录的 `envs` 下,也可以使用指定路径;
- 环境描述常使用 `environment.yml`
Conda 更适合:
- 数据科学、机器学习和科学计算;
- 依赖 NumPy、PyTorch、CUDA 或本地二进制库的项目;
- 需要同时管理 Python 与非 Python 软件的环境;
- 已经以 Anaconda、Miniconda 或 Miniforge 作为主要 Python 发行方式的团队。
代价和注意事项:
- 工具体系比 `venv + pip` 更大;
- channel 和包来源会影响解析结果;
- Conda 包版本可能与 PyPI 发布节奏不同;
- 同一环境混用 Conda 与 pip 时需要保持清晰边界。
## 七、Conda 环境中能不能使用 pip
可以,但建议遵守以下顺序:
1. 创建并激活独立 Conda 环境;
2. 优先一次性安装需要的 Conda 包;
3. 确认使用的是当前环境中的 pip
4. 只有 Conda channel 没有需要的软件包时,再使用 pip
5. pip 安装后如果还要大规模调整 Conda 依赖,优先考虑重建环境,而不是反复混装。
示例:
```powershell
conda create -n python-course python=3.13 pip
conda activate python-course
python -c "import sys; print(sys.executable)"
python -m pip --version
```
然后才能确认 pip 指向当前 Conda 环境。
不建议在一个环境中无计划地交替执行大量 `conda install``pip install`。两套工具使用不同的软件包格式与依赖信息,复杂项目可能出现一方不知道另一方具体修改了什么的问题。
## 八、`uv`:速度较快的一体化 Python 工具
`uv` 是独立工具,不依赖当前 Python 才能启动。它覆盖多项工作:
- 安装和选择 Python
- 创建虚拟环境;
- 安装与卸载 Python 软件包;
- 管理 `pyproject.toml` 项目;
- 生成并使用 `uv.lock`
- 同步项目环境;
- 在项目环境中运行命令。
仅创建虚拟环境:
```powershell
uv venv
```
使用类似 pip 的接口:
```powershell
uv pip install 软件包名称
uv pip list
uv pip check
```
完整项目工作流:
```powershell
uv init
uv add 软件包名称
uv sync
uv run python main.py
```
在项目工作流中:
- `pyproject.toml` 声明项目及直接依赖;
- `uv.lock` 记录解析后的精确依赖;
- 默认项目环境通常是 `.venv`
- `uv run` 会在运行前确认锁文件和项目环境保持同步。
官方文档建议:使用 uv 项目工作流时,通过 `uv add` 管理项目依赖,不要把 `uv pip install` 当作项目依赖声明方式。`uv pip` 更适合兼容传统 pip 工作流或手动管理环境。
## 九、`uv` 与 pip 的关系
`uv pip` 提供与 pip 类似的命令体验,但 `uv` 并不是 pip 的插件,也不是简单调用 pip。
可以这样理解:
```text
传统方式venv + pip + requirements.txt
uv 兼容方式uv venv + uv pip + requirements.txt
uv 项目方式uv + pyproject.toml + uv.lock + .venv
```
如果使用完整 uv 项目工作流,常用命令通常是:
```powershell
uv add
uv remove
uv sync
uv run
```
而不是手动激活环境后不断执行 `uv pip install`
## 十、`uv` 与 Conda 的区别
两者都能管理 Python 和环境,但侧重点不同。
Conda
- 以 Conda 包、channel 和 Conda 环境为核心;
- 能管理更广泛的非 Python 依赖;
- 在科学计算、数据分析和机器学习生态中常见;
- 可以管理 Python、R、系统库和二进制程序等内容。
uv
- 以现代 Python 项目、PyPI、`pyproject.toml` 和锁文件为核心;
- 默认使用项目内 `.venv`
- 适合普通 Python 应用、库、工具和 Web 后端;
- 依赖操作和环境同步速度通常是其主要优势之一。
如果项目强依赖 CUDA、本地科学计算库或 Conda channelConda 通常更自然。如果项目主要是 PyPI 中的 Python 依赖uv 项目工作流通常更轻量。
## 十一、核心对比表
| 工具 | 创建隔离环境 | 安装 Python 包 | 管理 Python 版本 | 管理非 Python 软件 | 典型依赖文件 | 适合场景 |
|---|---|---|---|---|---|---|
| `venv` | 是 | 否,通常配合 pip | 否,使用已有 Python | 否 | 无,常配合 `requirements.txt` | 入门、普通脚本、Web 项目 |
| `virtualenv` | 是 | 否,通常配合 pip | 选择已有解释器 | 否 | 无,常配合 `requirements.txt` | 需要更强环境创建能力 |
| `pip` | 否 | 是 | 否 | 通常不负责 | `requirements.txt` | 向现有 Python 环境安装包 |
| Conda | 是 | 是 | 是 | 是 | `environment.yml` 等 | 数据科学、机器学习、二进制依赖 |
| `uv` | 是 | 是 | 是 | 主要面向 Python 生态 | `pyproject.toml``uv.lock` | 现代 Python 项目和 Web 后端 |
## 十二、应该如何选择
### 12.1 零基础学习和理解原理
优先学习:
```text
venv + pip
```
原因是它们能清楚展示“环境隔离”和“包安装”是两个不同职责,也是 Python 官方文档中的基础工作流。
### 12.2 普通 Python、FastAPI 或 Django 项目
可以选择:
```text
venv + pip
```
或者:
```text
uv 项目工作流
```
前者通用、容易理解;后者在依赖解析、锁定和同步方面更加一体化。
### 12.3 数据科学和机器学习
如果涉及复杂科学计算、CUDA 或系统二进制依赖,可以优先考虑:
```text
Conda
```
必要时在独立 Conda 环境中谨慎补充 pip 包。
### 12.4 当前教学项目
当前机器的 `python` 来自 Miniconda但这不等于每个项目都必须使用 Conda 环境。
为了理解基础概念,本课程讲义使用 `venv + pip`。以后进入 FastAPI 和 Django 阶段,可以根据学习目标二选一:
- 继续使用 `venv + pip`,保持通用和透明;
- 切换到 uv 项目工作流,学习现代依赖锁定与同步。
不建议在同一个项目中同时维护 `requirements.txt``environment.yml``uv.lock` 三套互不一致的依赖事实来源。应该选定一种主要工作流。
## 十三、常见误区
### 13.1 “pip 创建了虚拟环境”
不正确。通常是 `venv` 创建环境pip 向环境安装软件包。
### 13.2 “激活环境后就安装了依赖”
不正确。激活只切换当前终端优先使用的解释器和工具,依赖仍需单独安装或同步。
### 13.3 “Conda 环境就是 Python venv”
两者都能实现隔离,但内部结构、包格式、依赖来源和管理范围不同,不能简单视为同一个实现。
### 13.4 “uv pip 就是速度更快的 pip 命令别名”
不准确。它提供兼容 pip 的操作方式,但 uv 是独立实现并且还有完整的项目、Python、环境和锁文件管理能力。
### 13.5 “有锁文件就不需要虚拟环境”
不正确。锁文件描述应该安装什么,虚拟环境保存实际安装结果,两者职责不同。
### 13.6 “把 `.venv` 复制到另一台机器就能复用”
不推荐。虚拟环境可能包含绝对路径和平台相关文件。应在新机器重新创建环境,并根据依赖文件安装或同步。
## 十四、推荐记忆方式
```text
venv / virtualenv创建隔离房间
pip向房间里安装 Python 包
Conda创建房间并管理 Python 与更广的软件
uv创建房间并管理现代 Python 项目、依赖和锁文件
```
## 十五、官方资料
- [Python 打包指南:使用 pip 和 venv](https://packaging.python.org/en/latest/guides/installing-using-pip-and-virtual-environments/)
- [Python 打包指南:安装软件包](https://packaging.python.org/en/latest/tutorials/installing-packages/)
- [virtualenv 官方文档](https://virtualenv.pypa.io/en/stable/)
- [Conda管理环境](https://docs.conda.io/projects/conda/en/stable/user-guide/tasks/manage-environments.html)
- [Conda管理软件包](https://docs.conda.io/projects/conda/en/stable/user-guide/tasks/manage-pkgs.html)
- [uvPython 环境](https://docs.astral.sh/uv/pip/environments/)
- [uv项目结构、环境与锁文件](https://docs.astral.sh/uv/concepts/projects/layout/)
- [uv从 pip 工作流迁移到 uv 项目](https://docs.astral.sh/uv/guides/migration/pip-to-project/)

View File

@@ -0,0 +1,6 @@
# 运行完整示例时自动生成的本地任务数据。
task_data/
practice_data/
# Python 自动生成的缓存文件。
__pycache__/

View File

@@ -0,0 +1,167 @@
# 第 2-9 课Python 进阶综合项目——多文件任务管理程序
## 一、本课目标
完成本课后,你将能够把第二阶段所学内容组合成一个可运行的小程序:
1. 用多个 Python 文件组织程序;
2. 用 JSON 文件保存并恢复任务;
3. 用异常处理保护文件读取和用户输入;
4. 使用推导式、生成器、装饰器和类型注解;
5. 解释每个模块的职责,并完成第二阶段验收。
## 二、前置知识
需要完成或阅读过第二阶段第 1 至第 8 课。第 8 课虽然已跳过,但本项目不依赖第三方软件包,只使用 Python 标准库。
## 三、项目要解决什么问题
单个文件中的代码变多后,阅读和修改都会变困难。这个项目把任务管理功能拆分为三部分:
```text
main.py 程序入口:组织执行顺序、显示结果
task_service.py 业务功能:新增、完成、筛选和汇总任务
task_store.py 数据读写:把任务保存到 JSON 文件、再读回来
```
职责拆分的好处是:保存格式改变时主要修改 `task_store.py`;新增业务规则时主要修改 `task_service.py`;入口显示变化时主要修改 `main.py`
## 四、什么是 JSON
JSONJavaScript Object Notation是一种常用的文本数据格式。它能保存列表、字典、字符串、数字和布尔值适合本课保存任务数据。
Python 使用标准库 `json`
```python
json.dumps(data) # 把 Python 数据转换成 JSON 文本
json.loads(text) # 把 JSON 文本转换成 Python 数据
```
本项目保存的单条任务类似:
```python
{
"id": 1,
"title": "整理本课学习笔记",
"priority": "",
"completed": False,
}
```
## 五、完整示例
完整示例由 `main.py``task_service.py``task_store.py` 组成。请先逐个打开文件,再运行入口文件。
## 六、运行方法
在项目根目录执行:
```powershell
cd D:\Code\Python
python .\02_python进阶\2_9_python进阶综合项目\main.py
```
首次运行会自动创建:
```text
02_python进阶/2_9_python进阶综合项目/task_data/tasks.json
```
这是本地运行数据,已由 `.gitignore` 忽略。再次运行会继续读取已有任务,所以任务数量会增加,这是正常现象。
## 七、正常结果示例
第一次运行时,输出格式类似:
```text
读取到 0 条已有任务。
正在执行add_task
正在执行add_task
正在执行complete_task
本次新增的任务:
[1] 整理本课学习笔记|优先级:高|状态:已完成
[2] 运行任务管理程序|优先级:中|状态:未完成
未完成任务:
[2] 运行任务管理程序|优先级:中|状态:未完成
任务汇总:
全部2 条已完成1 条未完成1 条。
```
编号和总数量会因之前运行过的次数而不同。
## 八、关键代码解析
### 8.1 文件不存在时返回空列表
```python
if not data_file.exists():
return []
```
第一次运行还没有任务文件,这不是错误。返回空列表后,程序就可以从第一条任务开始添加。
### 8.2 装饰器记录调用
`@log_call` 会在原函数执行前输出函数名,但不会改变原函数的参数和返回值。它适合为多个关键操作增加相同的提示。
### 8.3 生成器逐条提供未完成任务
```python
for task in tasks:
if not task["completed"]:
yield task
```
`yield` 让函数每找到一条未完成任务就交出一条,不需要先创建完整的新列表。
### 8.4 类型注解
例如 `list[dict[str, object]]` 表示“由任务字典组成的列表”。任务中的值既可能是编号、文字,也可能是布尔值,所以这里使用 `object`
## 九、常见错误
### 9.1 直接运行 task_service.py
它是功能模块,不是入口程序。应运行 `main.py`
### 9.2 JSON 文件内容损坏
手工编辑 `tasks.json` 时漏写逗号或引号,会导致 JSON 格式错误。示例会安全返回空列表;练习中应捕获 `json.JSONDecodeError` 并给出提示。
### 9.3 忘记保存
列表只在程序运行期间存在。调用 `add_task()``complete_task()` 后,要调用 `save_tasks()` 才能写入文件。
### 9.4 使用 `wrapper()` 代替 `wrapper`
装饰器最后应 `return wrapper`,不是 `return wrapper()`;后者会在装饰阶段提前执行函数。
## 十、课堂练习
打开 `practice.py`,按六部分要求自己创建 `practice_store.py``practice_service.py``practice_main.py`。练习文件保持题面说明形式,不包含预置函数骨架。
## 十一、参考答案
本课不提前写入参考答案。完成后把三个练习文件保留在本课目录,我会按模块职责、功能正确性、可读性和知识掌握情况进行验证。
## 十二、本课小结
- 模块让不同职责的代码分开放置;
- JSON 能把 Python 的任务数据保存为文本;
- `Path` 让路径处理更安全清晰;
- `try...except` 让预期的读取问题不至于让程序崩溃;
- 推导式适合快速生成汇总数据,生成器适合逐条提供结果;
- 装饰器可为多个函数增加共同功能;
- 类型注解让函数接收什么、返回什么更清楚。
## 十三、验收标准
- 能独立运行完整示例并说明三个模块的职责;
- 能解释 JSON 保存和读取的方向;
- 能说明本项目中异常处理、推导式、生成器、装饰器和类型注解分别在哪里使用;
- 能按题目完成三个练习模块;
- 再次运行练习程序时,能够读取上次保存的任务;
- 已完成第二阶段的综合项目,具备进入第三阶段“面向对象编程”的基础。

View File

@@ -0,0 +1,52 @@
# 第 2-9 课完整示例:多文件任务管理程序入口
#
# 直接运行本文件会创建当前课程目录下的 task_data/tasks.json。
# 该文件是练习数据,已通过 .gitignore 排除,不会被提交到 Git。
from pathlib import Path
from task_service import add_task, build_task_report, complete_task, generate_pending_tasks
from task_store import load_tasks, save_tasks
LESSON_DIR = Path(__file__).parent
DATA_FILE = LESSON_DIR / "task_data" / "tasks.json"
def print_task(task: dict[str, object]) -> None:
"""按统一格式输出一条任务。"""
status_text = "已完成" if task["completed"] else "未完成"
print(f"[{task['id']}] {task['title']}|优先级:{task['priority']}|状态:{status_text}")
def main() -> None:
"""按照读取、修改、保存和汇总的顺序运行完整示例。"""
tasks = load_tasks(DATA_FILE)
print(f"读取到 {len(tasks)} 条已有任务。")
try:
first_task = add_task(tasks, "整理本课学习笔记", "")
second_task = add_task(tasks, "运行任务管理程序", "")
except ValueError as error:
# 本示例会传入合法标题;这里演示业务错误的安全处理方式。
print(f"添加任务失败:{error}")
return
complete_task(tasks, int(first_task["id"]))
save_tasks(DATA_FILE, tasks)
print("\n本次新增的任务:")
print_task(first_task)
print_task(second_task)
print("\n未完成任务:")
for task in generate_pending_tasks(tasks):
print_task(task)
report = build_task_report(tasks)
print("\n任务汇总:")
print(f"全部:{report['total']} 条,已完成:{report['completed']} 条,未完成:{report['pending']} 条。")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,74 @@
# 第 2-9 课课堂练习:扩展多文件任务管理程序
#
# 请先运行 main.py理解三个模块分别负责什么再完成下面的扩展。
# 本练习不预置导入、变量、函数或 pass请根据题目自己编写。
# 练习数据只能保存到当前课程目录的 practice_data 文件夹中。
import pathlib
# 第一部分:拆分模块职责
# 1. 新建 practice_store.py只放“读取和保存任务数据”的函数。
# 2. 新建 practice_service.py只放“新增、完成、查询任务”的函数。
# 3. 新建 practice_main.py作为程序入口从两个模块导入并调用函数。
# 4. 使用 pathlib.Path(__file__).parent 计算当前目录,不手工拼接路径。
LOCAL_PATH = pathlib.Path(__file__).parent
# 第二部分:保存与读取任务
# 1. 在 practice_store.py 中导入 json 和 Path。
# 2. 定义 load_tasks(data_file);文件不存在时 return []。
# 3. 使用 read_text(encoding="utf-8") 和 json.loads() 读取数据。
# 4. 捕获 FileNotFoundError 和 json.JSONDecodeError出现问题时返回 [] 并输出中文提示。
# 5. 定义 save_tasks(data_file, tasks);先创建父目录,再用 json.dumps() 保存。
# 6. 保存中文时使用 ensure_ascii=False 和 encoding="utf-8"。
# 第三部分:新增与完成任务
# 1. 在 practice_service.py 中定义 add_task(tasks, title, priority)。
# 2. title 去掉首尾空白后为空时,使用 raise ValueError("任务标题不能为空。")。
# 3. 新任务包含 id、title、priority、completed 四个键completed 初始为 False。
# 4. 用列表推导式取得已有 id并计算下一个 id空列表的第一个 id 是 1。
# 5. 定义 complete_task(tasks, task_id),找到任务后把 completed 设为 True。
# 6. 找到时 return True找不到时 return False。
# 第四部分:生成器与汇总
# 1. 定义 generate_pending_tasks(tasks) 生成器函数。
# 2. 只对 completed 为 False 的任务使用 yield。
# 3. 定义 build_task_report(tasks),返回全部、已完成、未完成数量组成的字典。
# 4. 已完成任务可以使用带 if 的列表推导式取得。
# 第五部分:增加调用日志
# 1. 从 functools 导入 wraps。
# 2. 定义 log_call(func) 装饰器,内部定义 wrapper(*args, **kwargs)。
# 3. wrapper 输出“正在执行:函数名”,调用原函数并 return 原结果。
# 4. 为 add_task() 和 complete_task() 添加 @log_call。
# 第六部分:入口程序
# 1. 在 practice_main.py 准备 practice_data/tasks.json 路径。
# 2. 读取已有任务后,新增两条任务:一条高优先级、一条中优先级。
# 3. 把第一条新增任务标记为完成,再保存全部任务。
# 4. 遍历生成器并输出未完成任务。
# 5. 输出汇总结果;再次运行时,能够继续读取上次保存的任务。
# 6. 捕获 ValueError 并输出中文错误提示,不让程序直接崩溃。
# 最终验收测试:
# 1. 三个模块职责清晰,入口文件不直接处理 JSON 细节;
# 2. 首次运行时能自动创建 practice_data/tasks.json
# 3. 再次运行时能读取已保存的任务;
# 4. 空标题不会添加任务,并给出中文提示;
# 5. 未完成任务通过包含 yield 的函数逐条获得;
# 6. 新增与完成任务时都会输出调用日志;
# 7. 汇总数量正确,已完成数和未完成数之和等于总数;
# 8. 不把 practice_data、__pycache__ 或虚拟环境提交到 Git。
# 完成后自查:
# 1. 是否能说明模块、函数和入口文件各自的职责;
# 2. 是否知道 JSON 文件为何要指定 UTF-8
# 3. 是否能解释 try...except 保护的是哪一段可能失败的代码;
# 4. 是否能解释列表推导式和生成器各自适合什么场景;
# 5. 是否知道装饰器在本项目中为函数增加了什么功能;
# 6. 是否写了类型注解,并让返回值与注解一致;
# 7. 是否只保存当前课程目录中的练习数据。

View File

@@ -0,0 +1,69 @@
# 第 2-9 课完整示例:任务业务功能模块
#
# 本模块使用字典表示任务。面向对象的“类”会在第三阶段学习。
from functools import wraps
def log_call(func):
@wraps(func)
def wrapper(*args, **kwargs):
print(f"正在执行:{func.__name__}")
return func(*args, **kwargs)
return wrapper
def get_next_task_id(tasks: list[dict[str, object]]) -> int:
"""根据已有任务计算下一个可用编号。"""
if not tasks:
return 1
# 列表推导式只取出编号max() 找到其中最大的编号。
task_ids = [int(task["id"]) for task in tasks]
return max(task_ids) + 1
@log_call
def add_task(tasks, title: str, priority: str) -> dict[str, object]:
"""创建一条未完成任务,添加到列表并返回这条新任务。"""
cleaned_title = title.strip()
if not cleaned_title:
raise ValueError("任务标题不能为空。")
task = {
"id": get_next_task_id(tasks),
"title": cleaned_title,
"priority": priority,
"completed": False,
}
tasks.append(task)
return task
@log_call
def complete_task(tasks: list[dict[str, object]], task_id: int) -> bool:
"""按编号把任务标记为完成;找到任务返回 True否则返回 False。"""
for task in tasks:
if task["id"] == task_id:
task["completed"] = True
return True
return False
def generate_pending_tasks(tasks: list[dict[str, object]]):
"""逐条生成未完成任务,不预先创建新的完整列表。"""
for task in tasks:
if not task["completed"]:
yield task
def build_task_report(tasks: list[dict[str, object]]) -> dict[str, object]:
"""汇总全部、已完成和未完成任务数量。"""
completed_tasks = [task for task in tasks if task["completed"]]
return {
"total": len(tasks),
"completed": len(completed_tasks),
"pending": len(tasks) - len(completed_tasks),
}

View File

@@ -0,0 +1,33 @@
# 第 2-9 课完整示例:任务数据读写模块
#
# 本模块只负责把任务列表保存到文件,或从文件读取任务列表。
import json
from pathlib import Path
def load_tasks(data_file: Path) -> list[dict[str, object]]:
"""从 JSON 文件读取任务列表;文件不存在或内容有误时返回空列表。"""
if not data_file.exists():
return []
try:
content = data_file.read_text(encoding="utf-8")
tasks = json.loads(content)
except (OSError, json.JSONDecodeError):
# 读取失败或 JSON 格式不正确时,给出安全的空列表,避免程序崩溃。
return []
# JSON 可以保存多种数据。这里只接受列表,避免后续遍历时出现意外错误。
if not isinstance(tasks, list):
return []
return tasks
def save_tasks(data_file: Path, tasks: list[dict[str, object]]) -> None:
"""把任务列表保存为 UTF-8 编码的 JSON 文件。"""
# 父目录不存在时先创建exist_ok=True 允许程序重复运行。
data_file.parent.mkdir(parents=True, exist_ok=True)
content = json.dumps(tasks, ensure_ascii=False, indent=2)
data_file.write_text(content, encoding="utf-8")

View File

@@ -0,0 +1,259 @@
# 第 3-1 课Python 与 Java 的类和对象
## 一、本课定位
你已经有成熟的 Java 开发经验,因此本阶段不再从“什么是对象”开始长篇讲解。本课采用差异驱动方式:先建立 Java 写法与 Python 写法的对应关系,再通过代码和练习形成 Python 语感。
即使没有完整阅读本文,也请至少运行示例并完成 `practice.py`。示例输出和练习题中会重复本课的关键差异。
## 二、本课目标
完成本课后,你将能够:
1. 使用 Python 定义类、初始化对象并调用实例方法;
2. 理解 `self` 与 Java `this` 的关系和语法差异;
3. 区分实例属性与类属性;
4. 区分实例方法、类方法和静态方法;
5. 避免把 Java 的字段声明、构造器和 `new` 原样搬到 Python
6. 理解 Python 对象模型更动态,但工程代码仍应保持清晰约束。
## 三、前置知识
- 已有 Java 类、对象、构造器、实例字段和 `static` 成员经验;
- 已学习 Python 函数、字典和类型注解;
- 能在 PowerShell 中运行 Python 文件。
## 四、先看差异速查表
| 关注点 | Java | Python |
|---|---|---|
| 定义类 | `public class Agent` | `class Agent:` |
| 创建对象 | `new Agent(...)` | `Agent(...)`,没有 `new` |
| 初始化入口 | 与类同名的构造器 | `__init__` 特殊方法 |
| 当前对象 | 隐式 `this` | 定义方法时显式写 `self` |
| 实例字段 | 通常在类体中声明 | 通常在 `__init__` 中赋值创建 |
| 类级数据 | `static` 字段 | 类属性 |
| 实例方法 | 默认接收隐式 `this` | 第一个参数通常写 `self` |
| 类级工厂 | 常用 `static` 方法 | 常用 `@classmethod``cls` |
| 静态工具方法 | `static` 方法 | `@staticmethod`,无 `self`/`cls` |
| 访问控制 | `public/protected/private` | 主要依赖命名约定,后续课详讲 |
| 方法重载 | 支持同名不同参数 | 不按 Java 方式支持,后续课详讲 |
## 五、最小类定义
```python
class Agent:
def __init__(self, name: str) -> None:
self.name = name
def describe(self) -> str:
return f"Agent{self.name}"
agent = Agent("代码助手")
print(agent.describe())
```
对应 Java 思维可以理解为:
- `__init__` 负责接收初始化数据,但它不是与类同名的构造器;
- `self.name = name` 在当前实例上创建并赋值 `name` 属性;
- `self` 类似 `this`,只是 Python 要在方法定义中明确写出来;
- 调用 `agent.describe()` 时不传 `self`,解释器会自动传入 `agent`
- 创建对象直接调用 `Agent(...)`,不写 `new`
严格来说Python 先通过 `__new__` 创建对象,再由 `__init__` 初始化对象。本阶段日常业务代码先掌握 `__init__` 即可,暂不展开 `__new__`
## 六、Python 属性不要求预先声明
Java 通常先声明字段:
```java
private String name;
```
Python 常在 `__init__` 中直接建立实例属性:
```python
self.name = name
```
Python 甚至允许对象创建后动态添加属性:
```python
agent.status = "工作中"
```
这体现了 Python 的动态性。它不是鼓励随意改变对象结构;在成熟项目中,仍应尽量在 `__init__` 中集中建立稳定的实例属性,让阅读者和类型检查工具更容易理解对象。
## 七、实例属性与类属性
```python
class Agent:
platform = "Codex"
def __init__(self, name: str) -> None:
self.name = name
```
- `self.name` 是实例属性,每个对象可以保存不同值;
- `Agent.platform` 是类属性,由类统一提供,作用类似 Java `static` 字段;
- `agent.platform` 也能读取类属性,但类级含义明确时优先使用 `Agent.platform`
- 如果执行 `agent.platform = "其他平台"`,通常会在该实例上创建同名实例属性,从而遮蔽类属性,而不是修改整个类的值。
最后一点与 Java 字段访问直觉不同,是常见问题。
## 八、三种方法
### 8.1 实例方法
```python
def describe(self) -> str:
return self.name
```
通过 `self` 访问当前对象,适合处理实例状态。
### 8.2 类方法
```python
@classmethod
def from_config(cls, config: dict[str, str]) -> "Agent":
return cls(config["name"])
```
装饰器Decorator`@classmethod` 会让方法接收当前类 `cls`。它很适合命名工厂:使用 `cls(...)` 而非写死 `Agent(...)`,子类继承时更自然。
### 8.3 静态方法
```python
@staticmethod
def is_valid_name(name: str) -> bool:
return bool(name.strip())
```
静态方法不接收 `self``cls`。它只是逻辑上属于该类,但不需要访问实例状态或类状态。
不要为了模仿 Java 工具类而大量使用静态方法。普通模块函数也是 Python 中很自然的组织方式。
## 九、完整示例
示例文件:
```text
03_面向对象/3_1_Python与Java的类和对象/class_object_comparison.py
```
示例会直接输出五项差异:
1. `__init__` 与 Java 构造器的区别;
2. `self``this` 的区别;
3. Python 属性的动态创建;
4. 类属性与 `static` 字段的对应关系;
5. Python 创建对象不写 `new`
## 十、运行方法
在项目根目录 `D:\Code\Python` 打开 PowerShell运行
```powershell
python .\03_面向对象\3_1_Python与Java的类和对象\class_object_comparison.py
```
完成练习后运行:
```powershell
python .\03_面向对象\3_1_Python与Java的类和对象\practice.py
```
## 十一、示例预期结果
输出中应依次看到“差异 1”到“差异 5”并看到
```text
代码助手 使用 gpt-5.2运行平台Codex
动态添加的 status工作中
通过类读取Codex通过实例读取Codex
审查助手 使用 gpt-5运行平台Codex
名称是否合法True
```
## 十二、Java 开发者常见误区
### 12.1 创建对象时写 `new`
Python 没有这种语法,直接写:
```python
agent = Agent("代码助手")
```
### 12.2 调用方法时手动传 `self`
调用 `agent.describe()` 时 Python 会自动绑定实例,不要写 `agent.describe(agent)`
### 12.3 忘记在方法定义中写 `self`
实例方法定义必须显式接收当前对象:
```python
def describe(self) -> str:
...
```
`self` 不是保留关键字,但它是整个 Python 社区遵守的标准命名,不要改成其他名字。
### 12.4 把所有工厂方法都写成静态方法
如果方法需要创建“当前类”的实例,优先考虑 `@classmethod``cls(...)`,这比写死类名更适合继承。
### 12.5 认为没有 `private` 就无法封装
Python 更依赖约定、属性和接口设计。单下划线、双下划线及 `property` 会在下一课结合 Java 访问控制集中讲解。
### 12.6 用多个同名方法实现重载
Python 类体中后定义的同名方法会覆盖前一个定义,不能照搬 Java 重载。常见替代方式包括默认参数、关键字参数和单分派,后续按需讲解。
## 十三、课堂练习
打开 `practice.py`,完成一个 `Book` 类。练习覆盖:
1. `__init__` 和实例属性;
2. `self` 和实例方法;
3. 类属性;
4. `@classmethod` 命名工厂;
5. `@staticmethod` 校验方法;
6. 不使用 `new` 创建并运行对象。
每部分都附有 Java 对照提醒和最终预期输出,因此可以直接从练习开始。
## 十四、参考答案
参考答案暂不写入 `practice.py`,避免直接照抄。完成后可以把代码交给我,我会从以下三方面反馈:
1. 正确性:对象创建、属性和方法结果是否正确;
2. 可读性:命名、结构和注释是否符合 Python 习惯;
3. 知识掌握:是否仍残留 Java 式写法,能否解释选择原因。
## 十五、本课小结
- Python 用 `class` 定义类,但通常不写 `public`
- 使用 `__init__` 初始化实例,创建对象时不写 `new`
- `self` 类似 Java `this`,在定义实例方法时必须显式声明;
- 实例属性通常通过 `self.xxx``__init__` 中建立;
- 类属性类似 `static` 字段,但实例同名赋值会产生遮蔽;
- `@classmethod` 接收 `cls`,适合可继承的命名工厂;
- `@staticmethod` 不接收实例或类,只处理与类有关的独立逻辑;
- Python 更动态,但成熟项目仍需要稳定、清晰的对象结构。
## 十六、验收标准
- 能不使用 `new` 创建 Python 对象;
- 能正确编写 `__init__``self`
- 能解释 `self` 与 Java `this` 的语法差异;
- 能区分实例属性和类属性;
- 能区分实例方法、类方法和静态方法;
- 能解释为什么命名工厂中优先使用 `cls(...)`
- 能指出 Python 不支持 Java 式同名方法重载;
- `practice.py` 的实际输出与题目预期一致。

View File

@@ -0,0 +1,63 @@
# 第 3-1 课完整示例Python 与 Java 的类和对象
#
# 本示例不重复讲解通用的面向对象概念,而是集中展示 Python 与 Java 的写法差异。
# 运行程序时,每一段输出都会再次提醒对应差异,便于只看代码和结果也能掌握重点。
class Agent:
"""表示一个简单的 Agent并演示 Python 类的核心语法。"""
# Python 的类属性类似 Java 的 static 字段,由所有实例共同访问。
platform = "Codex"
def __init__(self, name: str, model: str = "gpt-5") -> None:
"""初始化实例属性__init__ 的作用接近 Java 构造器,但它不是类名同名方法。"""
# Python 通常直接在 __init__ 中创建实例属性,不需要提前声明字段。
# self 类似 Java 的 this但在实例方法定义中必须显式写出来。
self.name = name
self.model = model
def describe(self) -> str:
"""返回当前对象的说明文字。"""
return f"{self.name} 使用 {self.model},运行平台:{self.platform}"
@classmethod
def from_config(cls, config: dict[str, str]) -> "Agent":
"""通过配置创建对象cls 指向当前类,作用类似可继承的命名工厂。"""
return cls(config["name"], config.get("model", "gpt-5"))
@staticmethod
def is_valid_name(name: str) -> bool:
"""校验名称;该方法不需要访问某个对象或当前类。"""
return bool(name.strip())
def main() -> None:
"""创建对象并输出 Python 与 Java 的关键差异。"""
code_agent = Agent("代码助手", "gpt-5.2")
review_agent = Agent.from_config({"name": "审查助手"})
print("差异 1Python 使用 __init__ 初始化对象,不写与类同名的构造器。")
print(code_agent.describe())
print()
print("差异 2self 类似 Java 的 this但定义实例方法时必须显式声明。")
print(f"通过对象访问实例属性:{code_agent.name}")
print()
print("差异 3实例属性通常在运行时创建不需要像 Java 字段那样预先声明。")
code_agent.status = "工作中"
print(f"动态添加的 status{code_agent.status}")
print()
print("差异 4类属性类似 static 字段,但也能通过实例读取。")
print(f"通过类读取:{Agent.platform};通过实例读取:{review_agent.platform}")
print()
print("差异 5Python 不使用 new直接调用类即可创建对象。")
print(review_agent.describe())
print(f"名称是否合法:{Agent.is_valid_name(review_agent.name)}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,82 @@
# 第 3-1 课课堂练习Python 与 Java 的类和对象
#
# 这份练习把关键差异直接写进题目,不要求先完整阅读讲义。
# 请先独立完成;不要删除题目、预期输出和自查说明。
# 第一部分:定义 Book 类
# 1. 不要像 Java 一样预先声明 title、author、price 字段。
# 2. 定义 __init__(self, title, author, price=0.0) 方法。
# 3. 为 title 和 author 添加 str 类型注解,为 price 添加 float 类型注解。
# 4. 返回值标注为 None。
# 5. 在 __init__ 中通过 self.title、self.author、self.price 创建实例属性。
#
# Java 对照提醒:
# - Python 的 __init__ 承担初始化职责,但不是“与类同名的构造器”;
# - self 类似 Java 的 this但必须写在方法参数列表中
# - 创建对象时不写 new也不手动传入 self。
# 第二部分:定义实例方法 get_summary(self)
# 1. 返回值标注为 str。
# 2. 返回格式书名《Python 后端开发》作者小明价格59.0 元。
# 3. 必须通过 self 读取三个实例属性。
#
# Java 对照提醒:
# - Python 方法没有 public 关键字;
# - 调用时写 book.get_summary()Python 会自动把 book 传给 self。
# 第三部分:添加类属性 category
# 1. 在 Book 类中、所有方法外定义 category = "编程"。
# 2. 在后面的 main() 中分别使用 Book.category 和 book.category 输出它。
#
# Java 对照提醒:
# - 类属性在用途上类似 Java 的 static 字段;
# - 为避免实例属性遮蔽类属性,本题不要执行 book.category = ...。
# 第四部分:定义类方法 from_text(cls, text)
# 1. 在方法上方添加 @classmethod。
# 2. text 的格式固定为“书名|作者”,例如“流畅的 Python|Luciano”。
# 3. 使用 text.split("|") 得到书名和作者。
# 4. 使用 cls(title, author) 创建并返回对象,让 price 使用默认值。
#
# Java 对照提醒:
# - cls 指向当前类classmethod 常用于实现命名工厂;
# - 不要把类名 Book 写死在 return 中,使用 cls 更利于继承。
# 第五部分:定义静态方法 is_valid_price(price)
# 1. 在方法上方添加 @staticmethod。
# 2. price 标注为 float返回值标注为 bool。
# 3. price 大于或等于 0 时返回 True否则返回 False。
#
# Java 对照提醒:
# - staticmethod 不接收 self 或 cls
# - 当逻辑与 Book 有关、但不需要对象状态和类状态时,可以使用它。
# 第六部分:定义 main() 并完成运行验证
# 1. 使用 Book("Python 后端开发", "小明", 59.0) 创建 book注意不写 new。
# 2. 输出 book.get_summary()。
# 3. 分别通过类和对象输出 category。
# 4. 使用 Book.from_text("流畅的 Python|Luciano") 创建 imported_book。
# 5. 输出 imported_book.get_summary(),其价格应为默认值 0.0。
# 6. 输出 Book.is_valid_price(59.0) 和 Book.is_valid_price(-1.0)。
# 7. 添加程序入口判断,直接运行本文件时调用 main()。
#
# 预期输出:
# 书名《Python 后端开发》作者小明价格59.0 元
# 通过类读取分类:编程
# 通过对象读取分类:编程
# 书名:《流畅的 Python》作者Luciano价格0.0 元
# 59.0 是合法价格True
# -1.0 是合法价格False
# 完成后自查:
# 1. 是否能解释 self 与 Java this 的相同点和写法差异;
# 2. 是否记住 Python 创建对象时不写 new
# 3. 是否知道 __init__ 负责初始化,而不是 Java 式的同名构造器;
# 4. 是否能区分实例属性、类属性、实例方法、类方法和静态方法;
# 5. 是否没有在类外直接替学习者写出答案。

View File

@@ -0,0 +1,244 @@
# 第 3-2 课Python 与 Java 的封装差异
## 一、本课定位
Java 通过 `public``protected``private` 等访问修饰符实施较严格的访问控制。Python 更信任开发者主要使用命名约定和属性Property表达接口边界。
本课不重复封装的一般理论,只讲 Java 开发者迁移到 Python 时必须调整的认识。
## 二、本课目标
完成本课后,你将能够:
1. 理解 Python 没有与 Java 完全等价的强制 `private`
2. 解释普通名称、单下划线和双下划线的区别;
3. 使用 `@property` 提供读取接口;
4. 使用 `@属性名.setter` 在赋值时执行校验;
5. 判断何时直接使用公开属性,何时需要 property
6. 避免把 Java getter/setter 机械翻译成 Python 方法。
## 三、前置知识
- Java 的访问修饰符、getter 和 setter
- Python 类、`self`、实例属性和实例方法;
- Python 异常处理。
## 四、差异速查表
| 意图 | Java 常见写法 | Python 常见写法 |
|---|---|---|
| 公开成员 | `public` | 普通名称,例如 `name` |
| 仅供内部使用 | `protected` 或包访问 | `_name` 命名约定 |
| 避免子类意外覆盖 | `private` | `__name` 触发名称改写 |
| 读取属性 | `getPrice()` | `product.price``@property` |
| 校验后赋值 | `setPrice(value)` | `product.price = value` 配合 setter |
| 强制安全边界 | 访问修饰符 | 下划线不能提供真正的安全隔离 |
## 五、单下划线:约定,不是限制
```python
self._owner = owner
```
`_owner` 表示“这是内部实现,外部代码通常不要直接依赖”。但是下面的代码仍然可以运行:
```python
print(account._owner)
```
因此,单下划线主要是团队沟通约定,不是运行时访问控制。
## 六、双下划线:名称改写,不是绝对私有
```python
self.__balance = 100.0
```
Python 会进行名称改写Name Mangling把属性名称处理成类似
```text
_Account__balance
```
主要目的之一是避免子类无意中使用相同名称覆盖父类属性。外部仍能通过改写后的名字访问它,所以它不是安全机制,也不能保护密码或密钥。
业务项目通常不要把所有内部属性都写成双下划线。只有确实需要避免子类名称冲突时再使用。
## 七、property保持属性语法同时执行方法逻辑
Java 常见写法:
```java
public double getBalance() {
return balance;
}
public void setBalance(double value) {
if (value < 0) {
throw new IllegalArgumentException();
}
balance = value;
}
```
Python 可以写成:
```python
@property
def balance(self) -> float:
return self.__balance
@balance.setter
def balance(self, value: float) -> None:
if value < 0:
raise ValueError("余额不能小于 0。")
self.__balance = value
```
调用方式仍像普通属性:
```python
print(account.balance)
account.balance = 200.0
```
读取时实际执行 getter 方法,赋值时实际执行 setter 方法。
## 八、不要为每个字段机械创建 property
如果属性只是公开保存数据,没有校验、计算或兼容需求,可以直接使用:
```python
self.name = name
```
不需要照搬 Java为它创建无逻辑的 `get_name()``set_name()`
适合使用 property 的情况包括:
- 赋值时需要校验;
- 返回值需要动态计算;
- 希望提供只读接口;
- 内部存储方式可能改变,但不希望调用方式变化。
## 九、只读 property
只定义 getter、不定义 setter
```python
@property
def owner(self) -> str:
return self._owner
```
调用者可以读取 `account.owner`,但执行 `account.owner = "其他人"` 时会报错。
需要注意:这仍然不是不可绕过的安全边界。调用者如果直接修改 `_owner`Python 不会阻止。它表达的是正常接口,不是对恶意代码的防护。
## 十、初始化时复用 setter
下面的写法可以让初始值和后续赋值使用相同校验:
```python
def __init__(self, balance: float) -> None:
self.__balance = 0.0
self.balance = balance
```
第二行 `self.balance = balance` 会调用 property setter。这样不会出现“对象创建时允许负数创建后却不允许负数”的规则不一致。
## 十一、完整示例
示例文件:
```text
03_面向对象/3_2_Python与Java的封装差异/encapsulation_comparison.py
```
运行命令:
```powershell
python .\03_面向对象\3_2_Python与Java的封装差异\encapsulation_comparison.py
```
示例输出会直接展示:
1. 单下划线属性仍能从外部访问;
2. property 使用属性语法执行校验逻辑;
3. 双下划线在对象中实际发生了名称改写;
4. 非法余额会被 setter 阻止。
## 十二、常见误区
### 12.1 认为 `_name` 等于 Java protected
它只是一种命名约定,不会限制外部或非子类代码访问。
### 12.2 认为 `__name` 能保护敏感信息
双下划线不是加密或权限控制。敏感信息仍需使用安全的配置、存储和权限机制。
### 12.3 为所有属性生成 getter 和 setter
没有额外逻辑时,直接访问公开属性更符合 Python 风格。需要保持属性语法并增加逻辑时再使用 property。
### 12.4 在 setter 内给 property 本身赋值
下面会无限递归:
```python
@balance.setter
def balance(self, value: float) -> None:
self.balance = value
```
setter 内应给实际存储属性赋值,例如 `self.__balance = value`
## 十三、课堂练习
打开:
```text
03_面向对象/3_2_Python与Java的封装差异/practice.py
```
完成 `Product` 类,练习内容包括:
1. 单下划线和双下划线属性;
2. 只读 `name` property
3. 可读写 `price` property
4. 价格与折扣率校验;
5. 使用 `try...except` 验证非法数据。
练习题中包含 Java 对照提醒和完整预期输出,可以直接从练习开始。
## 十四、参考答案
参考答案暂不写入练习文件。完成后,我会按照以下层级验证:
- 必须修复:语法错误、运行错误、业务结果错误、关键知识点使用错误;
- 建议改进:重复逻辑、容易产生误解的结构、明显影响可读性的写法;
- 可选优化:非必要类型注解、输出美化和不影响理解的风格统一。
只有“必须修复”的问题会阻止课程验收。
## 十五、本课小结
- Python 主要通过约定和接口设计实现封装;
- `_name` 表示内部使用,但外部仍能访问;
- `__name` 触发名称改写,主要用于避免子类名称冲突;
- 双下划线不是 Java `private` 的完全等价物,也不是安全机制;
- `@property` 让方法逻辑保留属性式调用体验;
- setter 可以集中完成赋值校验;
- 没有额外逻辑时,不必机械创建 getter 和 setter。
## 十六、验收标准
- 能解释单下划线和双下划线的区别;
- 能说明下划线为什么不能保护敏感信息;
- 能定义只读 property
- 能定义包含校验的 property setter
- 能避免 setter 自我赋值造成无限递归;
- 能用 property 统一初始化和后续赋值规则;
- `practice.py` 能正确阻止负数价格;
- 能说明 Python property 与 Java getter/setter 的调用差异。

View File

@@ -0,0 +1,69 @@
# 第 3-2 课完整示例Python 与 Java 的封装差异
#
# 本示例重点演示 Python 的下划线命名约定和 @property。
# 运行时会直接输出关键结论,不完整阅读讲义也能看到本课重点。
class Account:
"""表示一个账户,并演示 Python 常见的封装方式。"""
def __init__(self, owner: str, balance: float = 0.0) -> None:
# 单下划线表示“仅供内部使用”的约定,但不会禁止外部访问。
self._owner = owner
# 双下划线会触发名称改写Name Mangling避免子类意外覆盖同名属性。
# 它不是 Java private 那样的安全边界,也不能用于保护敏感信息。
self.__balance = 0.0
self.balance = balance
@property
def owner(self) -> str:
"""允许调用者以 account.owner 的形式读取账户所有者。"""
return self._owner
@property
def balance(self) -> float:
"""读取余额;调用时不需要写成 get_balance()。"""
return self.__balance
@balance.setter
def balance(self, value: float) -> None:
"""设置余额,并集中执行非负校验。"""
if value < 0:
raise ValueError("余额不能小于 0。")
self.__balance = value
def deposit(self, amount: float) -> None:
"""存入资金,并复用 balance 属性中的校验入口。"""
if amount <= 0:
raise ValueError("存入金额必须大于 0。")
self.balance = self.balance + amount
def main() -> None:
"""运行封装差异示例。"""
account = Account("小明", 100.0)
print("差异 1Python 主要依靠命名约定表达成员用途。")
print(f"单下划线属性仍可访问:{account._owner}")
print()
print("差异 2@property 让方法校验和普通属性访问可以同时存在。")
print(f"{account.owner} 当前余额:{account.balance}")
account.balance = 150.0
print(f"赋值后的余额:{account.balance}")
print()
print("差异 3双下划线是名称改写不是绝对私有或安全机制。")
print(f"对象中保存的实际属性名:{account.__dict__}")
print()
print("差异 4属性赋值可以统一执行校验。")
try:
account.balance = -1.0
except ValueError as error:
print(f"非法赋值已被阻止:{error}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,70 @@
# 第 3-2 课课堂练习Python 与 Java 的封装差异
#
# 关键规则已经写进每部分题目,可以不先完整阅读讲义。
# 请保留题目、预期结果和自查说明,并独立完成 Product 类。
# 第一部分:定义 Product 类和初始化方法
# 1. 定义 __init__(self, name, price) 方法。
# 2. name 标注为 strprice 标注为 float返回值标注可自行决定是否添加。
# 3. 使用 self._name = name 保存名称。
# 4. 使用 self.__price = 0.0 建立双下划线属性。
# 5. 最后执行 self.price = price通过后面定义的 setter 完成初始价格校验。
#
# Java 对照提醒:
# - _name 只表示“内部使用”的约定,外部访问不会产生语法错误;
# - __price 会触发名称改写,但不等于 Java 的强制 private
# - 双下划线不能作为保存密码等敏感信息的安全措施。
# 第二部分:为 name 定义只读 property
# 1. 在方法上方添加 @property。
# 2. 定义 name(self) 方法并返回 self._name。
# 3. 不要定义 @name.setter使调用者不能通过 product.name = ... 正常修改名称。
#
# Java 对照提醒:
# - Java 常写 getName()Python 调用时直接写 product.name
# - 虽然调用形式像字段,但实际上会执行 name() 方法。
# 第三部分:为 price 定义可读写 property
# 1. 使用 @property 定义 price(self),返回 self.__price。
# 2. 使用 @price.setter 定义同名方法 price(self, value)。
# 3. value 标注为 float。
# 4. value 小于 0 时执行 raise ValueError("价格不能小于 0。")。
# 5. 校验通过后执行 self.__price = value。
#
# Java 对照提醒:
# - 这相当于 getPrice() 和 setPrice(),但调用形式仍是 product.price
# - 不要同时保留公开的 price 实例属性,否则会和 property 的职责混淆。
# 第四部分:定义 discount(self, rate) 实例方法
# 1. rate 标注为 float。
# 2. rate 必须满足 0 <= rate <= 1否则抛出 ValueError("折扣率必须在 0 到 1 之间。")。
# 3. 合法时执行 self.price = self.price * rate。
# 4. 通过 property 修改价格,复用统一的价格入口。
# 第五部分:定义 main() 并验证行为
# 1. 创建 Product("机械键盘", 500.0)。
# 2. 输出“商品机械键盘价格500.0 元”。
# 3. 执行 product.price = 450.0再输出“修改后价格450.0 元”。
# 4. 执行 product.discount(0.8)再输出“折扣后价格360.0 元”。
# 5. 使用 try...except 测试 product.price = -1.0。
# 6. 捕获 ValueError 后输出“非法价格已被阻止:价格不能小于 0。”。
# 7. 添加程序入口判断并调用 main()。
#
# 预期输出:
# 商品机械键盘价格500.0 元
# 修改后价格450.0 元
# 折扣后价格360.0 元
# 非法价格已被阻止:价格不能小于 0。
# 完成后自查:
# 1. 是否知道单下划线只是内部使用约定;
# 2. 是否知道双下划线会名称改写,但不是绝对私有;
# 3. 是否能用 @property 代替简单的 Java getter
# 4. 是否能用 @属性名.setter 在赋值时执行校验;
# 5. 是否能说明 product.price 看似访问字段、实际会执行方法;
# 6. 是否只把运行错误和逻辑错误视为阻塞项,不把可选类型注解当作必须项。

View File

@@ -0,0 +1,242 @@
# 第 3-3 课Python 与 Java 的继承和多态差异
## 一、本课定位
你已经掌握 Java 的继承、接口和多态,因此本课不重复通用理论,重点建立以下 Python 差异:
- `super()` 不需要显式写父类名;
- Python 没有强制的 `@Override`
- Python 支持多继承,并使用方法解析顺序决定查找路径;
- 多态调用经常基于鸭子类型,不要求对象继承共同父类或显式实现接口。
## 二、本课目标
完成本课后,你将能够:
1. 定义父类和子类并重写方法;
2. 使用 `super()` 复用继承链中的实现;
3. 解释方法解析顺序Method Resolution OrderMRO
4. 使用鸭子类型Duck Typing完成统一调用
5. 理解 Python 多继承和 Java 单类继承的差异;
6. 避免通过大量 `isinstance()` 分支模拟多态。
## 三、差异速查表
| 关注点 | Java | Python |
|---|---|---|
| 继承类 | `extends` | `class Child(Parent):` |
| 调用继承逻辑 | `super(...)` | 常用 `super()` |
| 重写标记 | 推荐 `@Override` | 没有强制对应标记 |
| 类继承数量 | 单继承 | 支持多继承 |
| 方法查找 | 类继承链 | 按 MRO 查找 |
| 接口多态 | 通常显式 `implements` | 常见鸭子类型,无需声明接口 |
| 类型判断 | 编译期和运行时类型体系 | 运行时更关注对象是否提供所需行为 |
## 四、继承和方法重写
```python
class Notifier:
def send(self, message: str) -> None:
raise NotImplementedError("子类必须实现 send() 方法。")
class EmailNotifier(Notifier):
def send(self, message: str) -> None:
print(f"发送邮件:{message}")
```
Python 子类把父类写在类名后的圆括号中,不使用 `extends`。子类定义同名方法即可重写,语言本身不要求添加 `@Override`
缺少 `@Override` 也意味着方法名拼错时不会获得同样的编译期保护,因此测试和静态检查更加重要。
## 五、正确理解 super()
```python
class EmailNotifier(Notifier):
def __init__(self, sender: str, address: str) -> None:
super().__init__(sender)
self.address = address
```
Python 3 通常直接写 `super()`,不需要传入当前类和 `self`
在简单单继承中,可以暂时把它理解为调用父类实现。但更准确的理解是:`super()` 会沿当前类的方法解析顺序继续查找方法。这个区别在多继承中尤其重要。
不要写死父类名称:
```python
Notifier.__init__(self, sender)
```
写死类名可能破坏多继承中的协作调用链。
## 六、方法解析顺序 MRO
Python 支持一个类继承多个父类:
```python
class LoggedEmailNotifier(EmailNotifier, LogMixin):
pass
```
可以查看查找顺序:
```python
print(LoggedEmailNotifier.mro())
```
当调用某个方法时Python 会按 MRO 中的顺序寻找第一个匹配实现。日常代码应避免设计过于复杂的多继承层次。
常见的安全用途是混入类Mixin它只提供一项小而明确的可复用能力通常不独立代表完整业务对象。
## 七、鸭子类型
经典描述是:“如果一个对象走起来像鸭子、叫起来像鸭子,就把它当作鸭子使用。”
在代码中,这表示调用者更关心对象是否提供所需行为:
```python
def send_message(notifier, message: str) -> None:
notifier.send(message)
```
下面两个类不必拥有共同父类:
```python
class EmailNotifier:
def send(self, message: str) -> None:
...
class ConsoleNotifier:
def send(self, message: str) -> None:
...
```
只要对象具有可调用的 `send()` 方法,`send_message()` 就可以使用它。
与 Java 接口多态相比:
- Java 常在编译期要求对象声明实现某个接口;
- Python 运行时可以直接尝试调用所需方法;
- Python 也能使用协议Protocol为鸭子类型提供静态检查后续结合类型设计再展开。
## 八、不要用类型分支代替多态
不推荐:
```python
if isinstance(processor, CardProcessor):
...
elif isinstance(processor, QrCodeProcessor):
...
```
推荐让每个对象自己实现统一行为:
```python
processor.pay(amount)
```
这样新增处理器时,调用方通常不需要修改。
`isinstance()` 本身并非错误;当业务规则确实依赖类型时可以使用。但如果只是为了选择同一操作的不同实现,通常应优先使用多态。
## 九、继承还是组合
Java 与 Python 都不应为了复用少量代码而滥用继承。
- 真正存在“是一个”关系,并需要替换父类型行为时,可以考虑继承;
- 只是“拥有一个”协作者或需要灵活替换能力时,优先考虑组合;
- 只需要调用统一行为时Python 的鸭子类型可能已经足够。
组合会在后续业务模型课程中结合实例继续练习。
## 十、完整示例和运行方法
示例文件:
```text
03_面向对象/3_3_Python与Java的继承和多态差异/inheritance_polymorphism_comparison.py
```
在项目根目录运行:
```powershell
python .\03_面向对象\3_3_Python与Java的继承和多态差异\inheritance_polymorphism_comparison.py
```
示例输出会直接展示:
1. `super()` 复用继承逻辑;
2. 方法重写;
3. 没有共同父类的鸭子类型调用;
4. 多继承类的 MRO。
## 十一、常见错误
### 11.1 把 super() 当成固定父类对象
它实际按照 MRO 继续查找。在多继承中,下一站不一定是你凭直觉认定的某个父类。
### 11.2 忘记调用父类初始化逻辑
如果父类负责建立必要属性,子类通常需要调用 `super().__init__(...)`,否则使用父类属性时可能出现 `AttributeError`
### 11.3 认为多态必须继承共同父类
Python 鸭子类型只要求对象提供当前操作需要的方法。
### 11.4 滥用多继承
多继承会增加 MRO 和初始化协作的理解成本。优先使用清晰的小型 Mixin复杂业务能力通常更适合组合。
### 11.5 假设方法已经被正确重写
Python 没有强制 `@Override`。方法名或参数写错时,应通过测试、类型检查或代码审查发现。
## 十二、课堂练习
打开:
```text
03_面向对象/3_3_Python与Java的继承和多态差异/practice.py
```
完成支付处理器练习:
1. 定义父类统一操作;
2. 子类通过 `super()` 复用初始化;
3. 未继承父类的二维码处理器参与统一调用;
4. 使用一个函数调用两种处理器;
5. 输出并观察 `CardProcessor.mro()`
## 十三、参考答案与验证标准
参考答案暂不写入练习文件。完成后按以下层级验证:
- 必须修复:语法错误、运行错误、支付结果错误、没有使用 `super()`、通过具体类型分支实现调用;
- 建议改进:重复代码、职责混乱、明显影响理解的命名;
- 可选优化:非必要类型注解、输出美化和不影响行为的格式问题。
只有“必须修复”会阻止课程验收。
## 十四、本课小结
- Python 使用 `class Child(Parent)` 表示继承;
- 子类定义同名方法即可重写,没有强制 `@Override`
- `super()` 会沿 MRO 继续查找实现;
- Python 支持多继承,但复杂多继承应谨慎使用;
- 鸭子类型关注对象提供的行为,不要求共同父类或显式接口;
- 统一调用不应依赖大量具体类型判断;
- 继承用于真正的类型关系,单纯复用通常优先考虑组合。
## 十五、验收标准
- 能正确使用 `super().__init__()`
- 能重写父类方法;
- 能输出并解释简单类的 MRO
- 能让未继承共同父类的对象参与统一调用;
- 能解释鸭子类型与 Java 接口多态的区别;
- 能避免通过 `isinstance()` 分支实现本题多态;
- `practice.py` 输出正确支付结果。

View File

@@ -0,0 +1,78 @@
# 第 3-3 课完整示例Python 与 Java 的继承和多态差异
#
# 示例集中演示 super()、方法重写、多继承的方法解析顺序和鸭子类型。
class Notifier:
"""所有通知器都可以复用的基础类。"""
def __init__(self, sender: str) -> None:
self.sender = sender
def send(self, message: str) -> None:
"""定义统一操作;具体子类负责实现发送行为。"""
raise NotImplementedError("子类必须实现 send() 方法。")
class EmailNotifier(Notifier):
"""通过继承实现邮件通知器。"""
def __init__(self, sender: str, address: str) -> None:
# Python 3 中通常直接写 super(),不需要传入类名和 self。
super().__init__(sender)
self.address = address
def send(self, message: str) -> None:
"""重写父类方法Python 不要求添加 @Override。"""
print(f"邮件|{self.sender} -> {self.address}{message}")
class ConsoleNotifier:
"""没有继承 Notifier但同样提供 send() 方法。"""
def send(self, message: str) -> None:
print(f"控制台|{message}")
class LogMixin:
"""混入类Mixin只提供一项可复用能力。"""
def record(self) -> None:
print("通知行为已记录。")
class LoggedEmailNotifier(EmailNotifier, LogMixin):
"""Python 可以继承多个类,查找方法时遵循 MRO。"""
def send_message(notifier, message: str) -> None:
"""只要传入对象具有 send() 方法,就可以完成调用。"""
notifier.send(message)
def main() -> None:
"""运行继承和多态差异示例。"""
email = EmailNotifier("系统", "user@example.com")
console = ConsoleNotifier()
print("差异 1子类使用 super() 复用父类逻辑,不需要写父类名称。")
print(f"邮件发送者:{email.sender}")
print()
print("差异 2Python 重写方法时没有强制的 @Override。")
email.send("课程开始")
print()
print("差异 3鸭子类型关注对象有什么行为而不是声明了什么接口。")
send_message(email, "继承得到 send()")
send_message(console, "未继承也能调用 send()")
print()
print("差异 4Python 支持多继承,并通过 MRO 决定方法查找顺序。")
logged_email = LoggedEmailNotifier("系统", "admin@example.com")
logged_email.record()
print([class_type.__name__ for class_type in LoggedEmailNotifier.mro()])
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,72 @@
# 第 3-3 课课堂练习Python 与 Java 的继承和多态差异
#
# 关键差异已经写进每部分题目,可以直接完成练习。
# 请保留题目、预期结果和自查说明。
# 第一部分:定义 PaymentProcessor 父类
# 1. 定义 __init__(self, channel_name) 方法。
# 2. channel_name 标注为 str并保存为 self.channel_name。
# 3. 定义 pay(self, amount) 方法amount 标注为 float。
# 4. pay() 中执行 raise NotImplementedError("子类必须实现 pay() 方法。")。
#
# Java 对照提醒:
# - 本题暂不使用抽象基类Abstract Base ClassABC先观察普通父类的写法
# - Python 重写方法时不要求 @Override拼错方法名也不会由该注解提醒。
# 第二部分:定义 CardProcessor 子类
# 1. 使用 class CardProcessor(PaymentProcessor): 表示继承。
# 2. 定义 __init__(self, card_number) 方法card_number 标注为 str。
# 3. 执行 super().__init__("银行卡"),复用父类初始化逻辑。
# 4. 保存 self.card_number只保留卡号最后四位即可card_number[-4:]。
# 5. 重写 pay(self, amount),返回:
# “银行卡支付 100.0 元卡号末四位1234”。
#
# Java 对照提醒:
# - Python 3 通常直接写 super(),不需要传当前类和 self
# - super() 按方法解析顺序继续查找方法,不应简单理解为固定的“父类对象”。
# 第三部分:定义 QrCodeProcessor 类,但不要继承 PaymentProcessor
# 1. 定义 pay(self, amount) 方法。
# 2. 返回“二维码支付 50.0 元”。
#
# 多态重点:
# - 该类没有 extends也没有 implements
# - 只要它提供调用者需要的 pay(),就能参与统一调用;
# - 这种“关注行为而非声明类型”的方式称为鸭子类型Duck Typing
# 第四部分:定义 process_payment(processor, amount) 函数
# 1. 调用 processor.pay(amount) 并 return 它的结果。
# 2. processor 的类型注解本题可以省略,不作为验收阻塞项。
# 3. 函数内部不要使用 isinstance() 判断具体类型。
#
# Java 对照提醒:
# - Java 通常要求 processor 实现统一接口;
# - Python 运行时只要求传入对象具有可调用的 pay() 方法。
# 第五部分:定义 main() 并验证两种处理器
# 1. 创建 CardProcessor("6222020012341234")。
# 2. 创建 QrCodeProcessor()。
# 3. 使用 process_payment(card_processor, 100.0) 并输出结果。
# 4. 使用 process_payment(qr_processor, 50.0) 并输出结果。
# 5. 输出 CardProcessor 的方法解析顺序名称:
# [class_type.__name__ for class_type in CardProcessor.mro()]
# 6. 添加程序入口判断并调用 main()。
#
# 预期输出:
# 银行卡支付 100.0 元卡号末四位1234
# 二维码支付 50.0 元
# ['CardProcessor', 'PaymentProcessor', 'object']
# 完成后自查:
# 1. 是否会使用 super() 复用父类初始化;
# 2. 是否知道 Python 方法重写不要求 @Override
# 3. 是否能解释 super() 与 MRO 的关系;
# 4. 是否知道未继承同一父类的对象也能参与鸭子类型调用;
# 5. 是否避免通过 isinstance() 分支实现伪多态;
# 6. 是否只把运行错误、结果错误和关键知识点错误视为阻塞项。

View File

@@ -0,0 +1,266 @@
# 第 3-4 课Python 特殊方法与数据类
## 一、本课定位
Java 业务对象中经常需要构造器、`toString()``equals()``hashCode()`,项目也常借助 record 或 Lombok 减少样板代码。Python 通过特殊方法和数据类Data Class`@dataclass`)解决类似问题。
本课只选择业务开发中最常见的内容,不展开运算符重载的全部细节。
## 二、本课目标
完成本课后,你将能够:
1. 理解 Python 特殊方法的调用机制;
2. 区分 `__str__``__repr__`
3. 理解 `__eq__` 如何改变对象相等比较;
4. 使用 `@dataclass` 减少数据对象的样板代码;
5. 使用 `__post_init__` 执行业务校验;
6. 使用 `field(default_factory=list)` 避免共享可变默认值;
7. 理解 dataclass 与 Java POJO、record、Lombok 的边界差异。
## 三、差异速查表
| 需求 | Java 常见方式 | Python 常见方式 |
|---|---|---|
| 用户友好显示 | `toString()` | `__str__` |
| 开发调试表示 | 通常仍使用 `toString()` | `__repr__` |
| 内容相等 | 重写 `equals()` | 重写 `__eq__` |
| 减少数据类样板代码 | record 或 Lombok | `@dataclass` |
| 构造后校验 | 构造器代码块 | `__post_init__` |
| 每个对象独立列表 | 构造时 `new ArrayList<>()` | `field(default_factory=list)` |
## 四、特殊方法是什么
名称前后都有双下划线的方法称为特殊方法Special Method也常被称为双下方法Dunder Method
通常不是直接调用:
```python
item.__str__()
```
而是使用正常语法,让 Python 自动调用:
```python
str(item)
print(item)
item_one == item_two
```
这与 Java 运行时在字符串拼接或打印对象时调用 `toString()` 的体验相似。
## 五、__str__ 与 __repr__
`__str__` 面向使用者,强调友好、易读:
```python
def __str__(self) -> str:
return f"{self.name}{self.price}"
```
`__repr__` 面向开发和调试,通常包含类名和明确字段:
```text
Product(name='机械键盘', price=500.0)
```
调用关系:
- `str(obj)``print(obj)` 优先使用 `__str__`
- `repr(obj)`、交互环境和容器显示通常使用 `__repr__`
- 如果没有定义 `__str__`Python 可能退回使用 `__repr__`
## 六、__eq__ 与对象相等
普通类如果没有实现 `__eq__`,两个字段内容相同但身份不同的对象通常不相等:
```python
first = Product("键盘", 500.0)
second = Product("键盘", 500.0)
```
实现 `__eq__` 后,可以按照业务字段比较。`@dataclass` 默认会根据声明的全部字段自动生成 `__eq__`
注意:相等比较与对象身份判断不同:
- `first == second` 调用相等逻辑;
- `first is second` 判断是否为同一个对象。
## 七、@dataclass 生成什么
```python
from dataclasses import dataclass
@dataclass
class Product:
name: str
price: float
```
默认会自动生成常用方法,包括:
- `__init__`:接收字段并初始化;
- `__repr__`:生成适合调试的表示;
- `__eq__`:按照字段值比较。
因此一般不要再手写完全相同的样板方法。
## 八、dataclass 不是什么
`@dataclass` 并不会:
- 自动进行运行时类型检查;
- 自动完成业务规则校验;
- 默认创建不可变对象;
- 自动等同于 Java record
- 自动替代数据库对象关系映射Object Relational MappingORM模型。
例如,`price: float` 只是类型提示,不能自动阻止负数或字符串。
## 九、使用 __post_init__ 校验
dataclass 自动生成 `__init__` 后会调用 `__post_init__`
```python
@dataclass
class Product:
name: str
price: float
def __post_init__(self) -> None:
if self.price < 0:
raise ValueError("价格不能小于 0。")
```
它适合执行字段间校验、格式整理或需要在初始化完成后执行的逻辑。
## 十、可变默认值与 default_factory
列表是可变对象。下面这种字段定义不应使用:
```python
items: list[Product] = []
```
dataclass 会直接拒绝常见的可变默认值,避免多个实例意外共享同一列表。
正确写法:
```python
from dataclasses import dataclass, field
@dataclass
class Cart:
items: list[Product] = field(default_factory=list)
```
`default_factory=list` 表示每次创建 `Cart` 时调用 `list()`,得到新的独立列表。
## 十一、与 Java record 和 Lombok 的区别
可以建立近似理解,但不要认为它们完全相同:
- dataclass 默认可变Java record 的组件引用不可重新赋值;
- dataclass 可以写普通方法、继承和自定义特殊方法;
- dataclass 是 Python 标准库功能,不需要额外依赖或编译期注解处理;
- `@dataclass(frozen=True)` 可以限制字段重新赋值,但仍不是安全边界,也不是深层不可变。
本阶段先掌握默认 dataclass`frozen=True` 按业务需要再使用。
## 十二、什么时候使用 dataclass
适合:
- 主要职责是保存数据;
- 需要清晰的字段定义;
- 希望自动获得初始化、调试显示和内容比较;
- 对象仍然包含少量与数据紧密相关的行为。
不应仅因为“这是一个类”就添加 dataclass。复杂服务对象、资源管理器或主要依靠行为工作的对象通常使用普通类更自然。
## 十三、完整示例与运行方法
示例文件:
```text
03_面向对象/3_4_Python特殊方法与数据类/special_methods_dataclass_example.py
```
在项目根目录运行:
```powershell
python .\03_面向对象\3_4_Python特殊方法与数据类\special_methods_dataclass_example.py
```
示例会展示普通类与 dataclass 的代码差异、字符串显示、字段相等、初始化校验和独立列表。
## 十四、常见错误
### 14.1 把 __repr__ 只当作另一个 __str__
二者用途不同:一个偏向开发调试,一个偏向使用者阅读。
### 14.2 认为类型注解会自动校验
`price: float` 不会自动拒绝字符串或负数。外部数据与业务规则仍需显式校验。
### 14.3 手写 dataclass 已生成的方法
如果自动行为已经符合需求,不要重复编写 `__init__``__repr__``__eq__`
### 14.4 共享可变默认值
列表、字典和集合等可变默认值应使用 `default_factory` 创建。
### 14.5 默认认为 dataclass 可以哈希
可变 dataclass 通常不会自动提供可用的 `__hash__`。是否可作为字典键或集合元素涉及可变性和相等契约,本课不强行展开。
## 十五、课堂练习
打开:
```text
03_面向对象/3_4_Python特殊方法与数据类/practice.py
```
完成订单项和购物车:
1. 使用 dataclass 定义字段;
2. 使用 `__post_init__` 校验单价和数量;
3. 使用 property 计算小计;
4. 使用 `__str__` 提供友好显示;
5. 验证自动生成的 `__repr__``__eq__`
6. 使用 `default_factory=list` 创建独立购物车列表。
## 十六、参考答案与验证标准
参考答案暂不写入练习文件。完成后按以下层级验证:
- 必须修复:语法或运行错误、计算错误、校验缺失、共享列表、手写并破坏 dataclass 自动行为;
- 建议改进:重复计算、职责或命名明显不清晰;
- 可选优化:非必要类型注解、输出美化和不影响行为的格式问题。
只有“必须修复”会阻止课程验收。
## 十七、本课小结
- 特殊方法让对象参与 Python 的标准语法;
- `__str__` 面向使用者,`__repr__` 面向开发调试;
- `__eq__` 决定 `==` 的相等逻辑;
- `@dataclass` 自动生成初始化、调试表示和字段比较;
- dataclass 类型注解不会自动执行运行时校验;
- `__post_init__` 适合完成初始化后的业务校验;
- `default_factory` 为每个实例创建独立的可变字段。
## 十八、验收标准
- 能正确使用 `@dataclass`
- 能解释 `__str__``__repr__` 的区别;
- 能观察自动生成的 `__eq__` 行为;
- 能使用 `__post_init__` 阻止非法数据;
- 能使用 `field(default_factory=list)`
- 能说明 dataclass 与 Java record 并非完全等价;
- `practice.py` 的购物车计算与独立列表验证正确。

View File

@@ -0,0 +1,87 @@
# 第 3-4 课课堂练习Python 特殊方法与数据类
#
# 关键规则已经写入每部分题目,可以直接完成练习。
# 请保留题目、预期结果和自查说明。
# 第一部分:定义 OrderItem 数据类
# 1. 在类上方添加 @dataclass。
# 2. 定义三个字段,不需要手写 __init__
# product_name: str
# unit_price: float
# quantity: int = 1
#
# Java 对照提醒:
# - @dataclass 会生成 __init__、__repr__ 和 __eq__ 等常用方法;
# - 它类似减少 POJO 样板代码的工具,但不是 Java record 的完全等价物;
# - 数据类默认仍然可变,并不会自动进行运行时类型检查。
# 第二部分:使用 __post_init__ 校验字段
# 1. 定义 __post_init__(self) 方法。
# 2. unit_price 小于 0 时抛出 ValueError("单价不能小于 0。")。
# 3. quantity 小于或等于 0 时抛出 ValueError("数量必须大于 0。")。
#
# Java 对照提醒:
# - dataclass 自动生成 __init__ 后,会自动调用 __post_init__
# - 类型注解只表达预期类型,不会阻止负数,所以业务校验仍需手写。
# 第三部分:定义 subtotal 只读 property
# 1. 使用 @property 定义 subtotal(self),返回单价乘数量。
# 2. 调用方式应为 item.subtotal而不是 item.get_subtotal()。
# 第四部分:定义 __str__(self)
# 1. 返回“机械键盘 × 21000.0 元”格式的使用者友好文字。
# 2. 使用 self.subtotal 取得小计。
#
# 特殊方法提醒:
# - print(item) 和 str(item) 会调用 __str__
# - repr(item) 会使用 dataclass 自动生成的 __repr__
# - item_one == item_two 会使用自动生成的 __eq__ 比较全部字段。
# 第五部分:定义 ShoppingCart 数据类
# 1. 在类上方添加 @dataclass。
# 2. 定义 owner: str 字段。
# 3. 定义 items 字段:
# items: list[OrderItem] = field(default_factory=list)
# 4. 定义 add(self, item) 方法,把 item 添加到 self.items。
# 5. 定义 total 只读 property返回所有 item.subtotal 的总和。
#
# 重要提醒:
# - 不要写 items = [] 作为类级默认列表;
# - default_factory=list 会为每个 ShoppingCart 对象创建独立列表;
# - 这与 Java 实例字段通常在构造时 new ArrayList<>() 的目的相似。
# 第六部分:定义 main() 并验证行为
# 1. 创建两个内容相同的 OrderItem("机械键盘", 500.0, 2)。
# 2. 输出第一个对象,预期调用 __str__。
# 3. 输出 repr(item_one),观察自动生成的调试表示。
# 4. 输出“两个订单项相等True”。
# 5. 创建 ShoppingCart("小明") 和 ShoppingCart("小红")。
# 6. 只向小明的购物车添加第一个订单项。
# 7. 输出两个购物车的商品种类数,证明列表没有共享。
# 8. 输出小明购物车的总金额。
# 9. 使用 try...except 创建 OrderItem("错误商品", -1.0),捕获并输出错误。
# 10. 添加程序入口判断并调用 main()。
#
# 预期输出:
# 机械键盘 × 21000.0 元
# OrderItem(product_name='机械键盘', unit_price=500.0, quantity=2)
# 两个订单项相等True
# 小明的商品种类数1
# 小红的商品种类数0
# 小明购物车总金额1000.0 元
# 非法订单项已被阻止:单价不能小于 0。
# 完成后自查:
# 1. 是否没有为 OrderItem 手写 __init__、__repr__ 和 __eq__
# 2. 是否能区分 __str__ 的用户显示与 __repr__ 的调试表示;
# 3. 是否知道 dataclass 的相等比较默认比较字段值;
# 4. 是否使用 __post_init__ 完成运行时业务校验;
# 5. 是否使用 default_factory=list 避免共享可变默认值;
# 6. 是否只把运行错误、结果错误和关键知识点错误视为阻塞项。

View File

@@ -0,0 +1,94 @@
# 第 3-4 课完整示例Python 特殊方法与数据类
#
# 本示例对比普通类和 @dataclass并演示 __str__、自动 __repr__、
# 自动 __eq__、__post_init__ 以及 field(default_factory=list)。
from dataclasses import dataclass, field
class ManualProduct:
"""手动实现初始化、字符串显示和相等比较的普通类。"""
def __init__(self, name: str, price: float) -> None:
self.name = name
self.price = price
def __repr__(self) -> str:
"""返回适合开发和调试的对象表示。"""
return f"ManualProduct(name={self.name!r}, price={self.price!r})"
def __eq__(self, other: object) -> bool:
"""根据属性值判断两个商品是否相等。"""
if not isinstance(other, ManualProduct):
return NotImplemented
return self.name == other.name and self.price == other.price
@dataclass
class Product:
"""数据类会根据字段自动生成常用特殊方法。"""
name: str
price: float
def __post_init__(self) -> None:
"""在自动生成的 __init__ 执行后校验数据。"""
if self.price < 0:
raise ValueError("价格不能小于 0。")
def __str__(self) -> str:
"""返回面向使用者的友好文字。"""
return f"{self.name}{self.price}"
@dataclass
class Cart:
"""购物车使用工厂函数为每个实例创建独立列表。"""
owner: str
items: list[Product] = field(default_factory=list)
def add(self, product: Product) -> None:
"""向当前购物车添加商品。"""
self.items.append(product)
def main() -> None:
"""运行特殊方法与数据类示例。"""
manual_one = ManualProduct("机械键盘", 500.0)
manual_two = ManualProduct("机械键盘", 500.0)
print("差异 1普通类需要手动实现 Java 常见样板能力。")
print(repr(manual_one))
print(f"两个普通类对象按内容相等:{manual_one == manual_two}")
print()
keyboard_one = Product("机械键盘", 500.0)
keyboard_two = Product("机械键盘", 500.0)
print("差异 2@dataclass 自动生成 __init__、__repr__ 和 __eq__。")
print(repr(keyboard_one))
print(f"两个数据类对象按字段相等:{keyboard_one == keyboard_two}")
print()
print("差异 3__str__ 类似面向用户的 toString() 显示。")
print(str(keyboard_one))
print()
print("差异 4类型注解不会自动校验业务规则要在 __post_init__ 中实现。")
try:
Product("错误商品", -1.0)
except ValueError as error:
print(f"非法商品已被阻止:{error}")
print()
print("差异 5default_factory 确保不同购物车使用不同列表。")
first_cart = Cart("小明")
second_cart = Cart("小红")
first_cart.add(keyboard_one)
print(f"小明的商品数:{len(first_cart.items)}")
print(f"小红的商品数:{len(second_cart.items)}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,226 @@
# 第 3-5 课:面向对象综合项目
## 一、项目目标
本课完成一个内存版图书管理系统,集中验证第三阶段知识。数据只保存在程序运行期间,不读写文件,也不连接数据库,避免重复第二阶段内容。
完成后,你将能够:
1. 从业务职责中识别类;
2. 使用 dataclass 定义数据对象;
3. 使用普通类组织业务操作;
4. 使用组合建立对象关系;
5. 使用 property 和特殊方法提供清晰接口;
6. 使用自定义异常表达业务失败;
7. 使用鸭子类型接入通知能力;
8. 对借书、还书和异常流程进行完整验证。
## 二、项目角色
| 角色 | 主要职责 |
|---|---|
| `Book` | 保存图书信息和可借状态,提供友好显示 |
| `Member` | 保存会员信息和已借 ISBN计算已借数量 |
| `Library` | 管理图书、会员以及借还业务规则 |
| `ConsoleNotifier` | 输出借还成功通知 |
| 业务异常 | 区分图书不存在、会员不存在、图书不可借等失败 |
职责边界很重要:
- `Book` 不负责查找会员;
- `Member` 不负责维护整个图书馆;
- `ConsoleNotifier` 不负责改变借阅状态;
- `Library` 负责协调对象并保证借还规则一致。
## 三、为什么 Library 使用普通类
`Book``Member` 主要保存数据,适合使用 `@dataclass`
`Library` 的主要职责是管理业务行为和对象关系,并不是单纯的数据容器,因此使用普通类更清晰。不是所有类都应该加 `@dataclass`
## 四、组合关系
图书馆“拥有”图书和会员:
```python
self.books: dict[str, Book] = {}
self.members: dict[str, Member] = {}
```
这是组合,而不是继承。`Library` 不是一种 `Book`,所以不应写成:
```python
class Library(Book):
...
```
也不应为了复用字典操作就让 `Library` 继承 `dict`。把字典作为内部属性更容易维护业务约束。
## 五、业务异常体系
项目使用一个共同基础异常:
```python
class LibraryError(Exception):
pass
```
具体异常继承它:
```python
class BookNotFoundError(LibraryError):
pass
```
调用者既可以捕获具体错误,也可以统一处理所有图书馆业务错误:
```python
try:
library.borrow_book(...)
except LibraryError as error:
print(error)
```
与 Java 不同Python 没有受检异常的强制 `throws` 声明。
## 六、借书事务中的一致性
一次成功借书必须同时更新:
```text
Book.available = False
Member.borrowed_isbns 添加 ISBN
发送成功通知
```
更新前要先完成全部业务校验。如果先修改一部分状态再发现错误,容易留下不一致数据。
本课数据都在内存中,因此暂不涉及数据库事务。第四阶段学习数据库后,会把这里的业务模型升级为持久化版本。
## 七、鸭子类型通知器
`Library` 不要求通知器继承指定父类,只调用:
```python
notifier.send(message)
```
未来可以传入邮件通知器、短信通知器或测试通知器,只要它们提供兼容的 `send()` 方法。
不要在业务类中写:
```python
if isinstance(notifier, ConsoleNotifier):
...
```
这会让业务类依赖具体实现,降低扩展能力。
## 八、参考示例
先运行不同业务场景的仓库示例:
```powershell
python .\03_面向对象\3_5_面向对象综合项目\domain_model_example.py
```
示例展示:
- dataclass 数据对象;
- property 计算状态;
- 普通业务类通过组合管理对象;
- 自定义异常;
- 操作前校验和状态修改。
示例不是综合项目答案,只用于参考对象职责和代码组织方式。
## 九、完成综合项目
打开:
```text
03_面向对象/3_5_面向对象综合项目/practice.py
```
题目已经按实现顺序提供:
1. 业务异常;
2. `Book`
3. `Member`
4. 通知器;
5. `Library` 数据结构;
6. 添加与注册;
7. 内部查找;
8. 借书;
9. 还书;
10. 可借查询;
11. 正常流程;
12. 异常流程。
可以直接从 `practice.py` 开始,不必先完整阅读本文。
## 十、运行方法
完成代码后,在项目根目录运行:
```powershell
python .\03_面向对象\3_5_面向对象综合项目\practice.py
```
每完成一两个类就运行一次,不要等全部写完再集中排错。
## 十一、常见错误
### 11.1 把所有行为放进一个类
如果 `Library` 同时负责格式化所有对象、直接保存通知历史和处理用户输入,职责会迅速膨胀。让每个对象管理与自己紧密相关的数据和行为。
### 11.2 使用继承表达拥有关系
图书馆拥有图书,不代表图书馆是一种图书。拥有关系使用组合。
### 11.3 校验前修改状态
先确认会员、图书和可借状态全部合法,再同时更新图书与会员。
### 11.4 捕获 Exception 隐藏程序错误
练习中优先捕获 `LibraryError`。直接捕获所有 `Exception` 可能把拼写错误等程序缺陷误当成业务失败。
### 11.5 为通知器判断具体类型
直接调用 `notifier.send()`,保持鸭子类型。不要为每种通知方式增加一个条件分支。
## 十二、验证层级
完成后按以下层级检查:
- 必须修复:语法和运行错误、状态不一致、业务规则遗漏、共享借阅列表、具体类型分支;
- 建议改进:重复查找、职责混乱、容易误解的结构;
- 可选优化:非必要类型注解、说明文字差异、输出美化和格式统一。
只有“必须修复”会阻止项目和第三阶段验收。
## 十三、第三阶段知识回顾
- `self` 显式出现在实例方法定义中;
- `__init__` 负责初始化,创建对象不使用 `new`
- 下划线主要表达约定,不是 Java 访问控制的完全替代;
- property 保持属性语法并执行方法逻辑;
- `super()` 按 MRO 继续查找实现;
- 鸭子类型关注对象行为而非声明的共同接口;
- dataclass 减少数据对象样板代码;
- 特殊方法让对象参与 Python 标准语法;
- 组合通常比为了复用而继承更灵活。
## 十四、阶段验收标准
- `practice.py` 正常运行;
- 正常借还流程状态正确;
- 主要异常流程均被阻止;
- 类的职责边界清晰;
- 能解释项目中继承、组合和鸭子类型分别出现在哪里;
- 能说明 dataclass 和普通类的选择原因;
- 能说明 Python 面向对象写法与 Java 的主要差异。
通过后,第三阶段完成,下一阶段开始数据库编程,并逐步把本项目升级为持久化版本。

View File

@@ -0,0 +1,82 @@
# 第 3-5 课参考示例:小型仓库领域模型
#
# 本示例使用与综合项目不同的“仓库”场景,演示如何让对象各自负责自己的数据和行为。
# 综合项目仍需独立完成,不能直接复制本示例得到答案。
from dataclasses import dataclass, field
class InventoryError(Exception):
"""表示仓库业务规则错误。"""
@dataclass
class Product:
"""保存商品数据,并负责与商品自身有关的显示。"""
code: str
name: str
quantity: int = 0
def __post_init__(self) -> None:
"""确保创建商品时库存数量合法。"""
if self.quantity < 0:
raise InventoryError("库存数量不能小于 0。")
@property
def in_stock(self) -> bool:
"""根据当前数量计算是否有库存。"""
return self.quantity > 0
def __str__(self) -> str:
"""返回面向使用者的商品信息。"""
return f"{self.name}|库存:{self.quantity}"
@dataclass
class Warehouse:
"""通过组合管理多个 Product 对象。"""
name: str
products: dict[str, Product] = field(default_factory=dict)
def add_product(self, product: Product) -> None:
"""添加商品,商品编码重复时阻止操作。"""
if product.code in self.products:
raise InventoryError("商品编码已存在。")
self.products[product.code] = product
def remove_stock(self, code: str, quantity: int) -> None:
"""扣减指定商品库存。"""
if code not in self.products:
raise InventoryError("商品不存在。")
product = self.products[code]
if quantity <= 0:
raise InventoryError("出库数量必须大于 0。")
if product.quantity < quantity:
raise InventoryError("库存不足。")
product.quantity -= quantity
def main() -> None:
"""运行仓库领域模型示例。"""
warehouse = Warehouse("教学仓库")
keyboard = Product("P001", "机械键盘", 3)
warehouse.add_product(keyboard)
print(keyboard)
print(f"是否有库存:{keyboard.in_stock}")
warehouse.remove_stock("P001", 2)
print(f"出库后:{keyboard}")
try:
warehouse.remove_stock("P001", 2)
except InventoryError as error:
print(f"业务操作失败:{error}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,177 @@
# 第 3-5 课综合项目:内存版图书管理系统
#
# 本项目只使用内存数据,不读写文件、不连接数据库。
# 请按照题目顺序实现,不要删除业务规则、测试流程、预期输出和验收说明。
# 第一部分:定义业务异常
# 1. 定义 LibraryError(Exception),类体中只写 pass。
# 2. 定义 BookNotFoundError(LibraryError),类体中只写 pass。
# 3. 定义 BookUnavailableError(LibraryError),类体中只写 pass。
# 4. 定义 MemberNotFoundError(LibraryError),类体中只写 pass。
#
# Python 与 Java 对照:
# - 自定义异常同样通过继承建立分类;
# - Python 没有 Java 受检异常Checked Exception的强制 throws 声明;
# - 调用者可以捕获具体异常,也可以统一捕获 LibraryError。
# 第二部分:定义 Book 数据类
# 1. 使用 @dataclass。
# 2. 定义字段:
# isbn: str
# title: str
# author: str
# available: bool = True
# 3. 定义 __str__返回格式
# “《Python 编程》|作者:小明|状态:可借”。
# 4. available 为 False 时,状态文字应为“已借出”。
#
# 设计提醒:
# - dataclass 自动生成初始化、调试显示和字段相等比较;
# - __str__ 只负责一个 Book 自身的友好显示。
# 第三部分:定义 Member 数据类
# 1. 使用 @dataclass。
# 2. 定义字段:
# member_id: str
# name: str
# borrowed_isbns: list[str] = field(default_factory=list)
# 3. 定义 borrowed_count 只读 property返回已借 ISBN 的数量。
#
# 设计提醒:
# - 使用 default_factory避免不同会员共享同一借阅列表
# - Member 只保存自己的借阅结果,不负责查找图书馆中的书。
# 第四部分:定义 ConsoleNotifier
# 1. 不需要继承任何通知父类或接口。
# 2. 定义 send(self, message) 方法,直接输出:
# “通知:{message}”。
#
# 鸭子类型提醒:
# - Library 只要求通知器具有 send() 方法;
# - 不要在 Library 中使用 isinstance() 判断通知器类型。
# 第五部分:定义 Library 类和初始化方法
# 1. 定义 __init__(self, name) 方法并保存 self.name。
# 2. 创建实例属性:
# self.books: dict[str, Book] = {}
# self.members: dict[str, Member] = {}
# 3. books 使用 ISBN 作为键members 使用会员编号作为键。
#
# 组合提醒:
# - Library “拥有”多个 Book 和 Member因此这里使用组合而不是继承
# - 不要让 Library 继承 list 或 dict。
# 第六部分:实现 add_book(self, book)
# 1. book.isbn 已存在时抛出 LibraryError("ISBN 已存在。")。
# 2. 不存在时把 book 保存到 self.books。
# 3. 本方法不需要输出。
# 第七部分:实现 register_member(self, member)
# 1. member.member_id 已存在时抛出 LibraryError("会员编号已存在。")。
# 2. 不存在时把 member 保存到 self.members。
# 3. 本方法不需要输出。
# 第八部分:实现两个内部查找方法
# 1. 定义 _get_book(self, isbn),找到时返回 Book。
# 2. 找不到时抛出 BookNotFoundError("图书不存在。")。
# 3. 定义 _get_member(self, member_id),找到时返回 Member。
# 4. 找不到时抛出 MemberNotFoundError("会员不存在。")。
#
# 单下划线提醒:
# - _get_book 和 _get_member 表示 Library 内部使用的辅助方法;
# - 单下划线是约定,不是 Java private 式的强制限制。
# 第九部分:实现 borrow_book(self, member_id, isbn, notifier)
# 1. 通过 _get_member() 查找会员。
# 2. 通过 _get_book() 查找图书。
# 3. book.available 为 False 时抛出:
# BookUnavailableError("图书已被借出。")。
# 4. 校验通过后执行:
# book.available = False
# member.borrowed_isbns.append(isbn)
# 5. 调用 notifier.send(),消息格式:
# “小明成功借阅《Python 编程》”。
#
# 多态提醒:
# - notifier 的类型注解可以省略,不影响验收;
# - 只调用 send(),不要判断通知器的具体类型。
# 第十部分:实现 return_book(self, member_id, isbn, notifier)
# 1. 通过两个内部查找方法取得会员和图书。
# 2. isbn 不在 member.borrowed_isbns 中时抛出:
# LibraryError("该会员没有借阅这本书。")。
# 3. 校验通过后执行:
# member.borrowed_isbns.remove(isbn)
# book.available = True
# 4. 调用 notifier.send(),消息格式:
# “小明成功归还《Python 编程》”。
# 第十一部分:实现 available_books(self)
# 1. 返回所有 available 为 True 的 Book 对象组成的新列表。
# 2. 不要修改 self.books。
# 3. 可以使用列表推导式。
# 第十二部分:定义 main() 并完成正常流程
# 1. 创建 Library("城市图书馆")。
# 2. 创建 ConsoleNotifier()。
# 3. 添加以下两本书:
# Book("978-001", "Python 编程", "小明")
# Book("978-002", "流畅的 Python", "Luciano")
# 4. 注册 Member("M001", "小红")。
# 5. 调用 library.available_books() 取得当前可借图书列表。
# 6. 使用 len() 统计列表长度并输出“初始可借数量2”参考写法
# print(f"初始可借数量:{len(library.available_books())}")
# 7. 借出 ISBN 为 978-001 的书。
# 8. 再次调用 available_books(),使用 len() 输出“借阅后可借数量1”。
# 9. 读取前面创建的 Member 对象的 borrowed_count输出“会员已借数量1”。
# 10. 归还 ISBN 为 978-001 的书。
# 11. 再次调用 available_books(),使用 len() 输出“归还后可借数量2”。
# 12. 再次读取该 Member 对象的 borrowed_count输出“会员已借数量0”。
# 第十三部分:在 main() 中完成异常流程
# 1. 再次借出 978-001。
# 2. 使用 try...except 再借一次同一本书。
# 3. 捕获 LibraryError并输出
# “重复借阅已被阻止:图书已被借出。”。
# 4. 使用 try...except 借阅 ISBN 为 978-999 的书。
# 5. 捕获 LibraryError并输出
# “不存在的图书已被阻止:图书不存在。”。
# 6. 添加程序入口判断并调用 main()。
#
# 完整预期输出:
# 初始可借数量2
# 通知小红成功借阅《Python 编程》
# 借阅后可借数量1
# 会员已借数量1
# 通知小红成功归还《Python 编程》
# 归还后可借数量2
# 会员已借数量0
# 通知小红成功借阅《Python 编程》
# 重复借阅已被阻止:图书已被借出。
# 不存在的图书已被阻止:图书不存在。
# 最终验收标准:
# 1. Book 和 Member 使用 dataclass且不同会员不共享借阅列表
# 2. Book 的 __str__ 能区分“可借”和“已借出”;
# 3. Member.borrowed_count 能随借还操作变化;
# 4. Library 通过组合管理 Book 和 Member
# 5. 借书和还书同时更新图书状态与会员借阅记录;
# 6. 通知器通过鸭子类型调用,没有具体类型判断;
# 7. 业务异常继承关系正确,并能统一捕获 LibraryError
# 8. 重复 ISBN、重复会员、图书不存在、会员不存在、重复借阅和错误归还均被阻止
# 9. available_books() 返回新列表,不修改图书字典;
# 10. 程序实际输出的业务结果正确;说明文字或非必要类型注解不阻塞验收。

View File

@@ -0,0 +1,286 @@
# 第 4-1 课PostgreSQL 与 Psycopg 入门
## 一、本课定位
你已经掌握 SQL、事务和 Java 数据库开发因此本课不再从表、字段和增删改查讲起而是集中回答一个问题Python 程序怎样安全地连接 PostgreSQL 并执行 SQL
Python 数据库 APIDatabase APIDB-API规定了数据库驱动的通用操作方式。Psycopg 3 是 PostgreSQL 的 Python 驱动,本课会把它与 JDBC 逐项对照。
## 二、本课目标
完成本课后,你将能够:
1. 解释 DB-API、Psycopg 和 PostgreSQL 的关系;
2. 使用本地 TOML 文件保存数据库连接配置;
3. 使用 `psycopg.connect()` 创建连接;
4. 使用游标执行只读 SQL 并取得结果;
5. 使用参数化查询传递数据;
6. 使用 `with` 自动释放连接和游标;
7. 对照 JDBC 理解 Python 数据库代码;
8. 识别连接失败、依赖缺失和参数占位符错误。
## 三、前置知识
本课默认已经掌握:
- PostgreSQL 数据库地址、端口、数据库、用户名和密码的含义;
- `SELECT` 基础语法;
- JDBC 的 `Connection``PreparedStatement``ResultSet`
- Python 函数、异常、上下文管理器和文件读取基础。
本课只连接专用练习数据库。不要连接生产数据库,也不要使用具有创建用户、删除数据库等高权限的账号。
## 四、DB-API、Psycopg 与 JDBC
DB-API 是 Python 数据库驱动遵循的接口约定,不是一个需要单独安装的框架。不同数据库有不同驱动,但常见操作方式比较统一。
| Java/JDBC | Python/Psycopg | 作用 |
|---|---|---|
| PostgreSQL JDBC Driver | Psycopg 3 | 与 PostgreSQL 通信 |
| `DriverManager.getConnection()` | `psycopg.connect()` | 创建数据库连接 |
| `Connection` | `Connection` | 表示一次数据库会话 |
| `PreparedStatement` | `Cursor.execute(sql, params)` | 执行参数化 SQL |
| `ResultSet` | `Cursor``fetchone()` 等方法 | 读取查询结果 |
| `try-with-resources` | `with` | 自动释放资源 |
| `SQLException` | `psycopg.Error` | 表示数据库访问错误 |
Psycopg 的游标Cursor同时承担“执行 SQL”和“读取结果”的职责。它不是数据库界面中的鼠标光标。
## 五、准备远程练习数据库
建议为课程准备:
- 一个独立数据库,例如 `python_course`
- 一个专用账号,例如 `python_student`
- 只授予课程所需权限;
- 不与生产环境或其他重要测试数据共用。
本课不使用环境变量,也不要求把所有信息拼成数据库连接串,而是把连接参数分字段写入 TOML 配置:
```toml
[postgresql]
host = "数据库主机"
port = 5432
dbname = "数据库名"
user = "用户名"
password = "密码"
connect_timeout = 10
```
仓库中的 [config.example.toml](./config.example.toml) 只包含占位内容,可以提交 Git。实际配置写入同目录的 `config.toml`,该文件已经被项目 `.gitignore` 排除。
TOMLTom's Obvious Minimal Language是一种结构化配置格式。Python 3.11 及以上版本内置 `tomllib`,读取 TOML 不需要安装额外依赖,也不会修改操作系统或当前进程的环境变量。
## 六、安装 Psycopg 3
你当前使用 Conda 的 `base` 环境,可以直接安装 Psycopg
```powershell
conda install -n base -c conda-forge "psycopg>=3,<4" psycopg-c
```
安装完成后验证:
```powershell
python -c "import psycopg; print(psycopg.__version__)"
```
如果以后改用 Python 虚拟环境,也可以使用 `requirements.txt` 安装。无论使用哪种方式,导入时都写 `import psycopg`,不是 `import psycopg3`
## 七、创建本地 TOML 配置
进入本课目录,复制配置模板:
```powershell
Copy-Item .\config.example.toml .\config.toml
```
然后只在本地 `config.toml` 中填写真实的主机、端口、数据库名、用户名和密码。程序通过 Python 文件自身的位置寻找配置,因此从项目根目录或课程目录启动都可以。
### 7.1 为什么选择 TOML
- Python 3.13 可以直接使用内置 `tomllib`
- 字段和类型清楚,端口可以保持整数;
- 不需要污染环境变量;
- 比 XML 简洁,比 YAML 少一个第三方解析依赖;
- 配置节结构与 Java 项目的 YAML、Properties 配置思路相近。
### 7.2 代码硬编码可以怎么写
从技术上可以直接构造字典:
```python
database_config = {
"host": "数据库主机",
"port": 5432,
"dbname": "数据库名",
"user": "用户名",
"password": "密码",
}
```
这能帮助理解 `psycopg.connect()` 接收哪些参数但真实密码一旦硬编码Hard Coding就可能进入 Git 历史。本课程标准示例使用 `config.toml`;如自行尝试硬编码,只能放在不提交的个人练习文件中。
## 八、完整示例
示例文件为 [connection_example.py](./connection_example.py)。核心结构如下:
```python
database_config = load_database_config(CONFIG_PATH)
with psycopg.connect(**database_config) as connection:
with connection.cursor() as cursor:
cursor.execute(
"SELECT current_database(), current_user, %s::text",
("Psycopg 连接成功",),
)
database_name, user_name, message = cursor.fetchone()
```
示例只读取当前数据库名和当前用户,不创建表、不修改数据。
### 8.1 为什么参数使用 `%s`
Psycopg 使用 `%s` 表示值参数,即使参数是整数也仍然使用 `%s`。参数值通过 `execute()` 的第二个参数单独传入:
```python
cursor.execute("SELECT %s::text", (message,))
```
不要使用 f-string、字符串拼接或 `%` 运算符把数据直接写进 SQL
```python
# 错误示例:数据被直接拼进 SQL可能产生 SQL 注入。
cursor.execute(f"SELECT '{message}'")
```
### 8.2 单个参数为什么有逗号
```python
(message,)
```
这是只有一个元素的元组。写成 `(message)` 只是在字符串外加括号,不是元组。
### 8.3 with 做了什么
- 离开游标的 `with` 时关闭游标;
- 离开连接的 `with` 时结束事务并关闭连接;
- 正常离开连接块时提交当前事务;
- 块内出现异常时回滚当前事务。
本课执行的是只读查询,但仍要建立正确的资源和事务管理习惯。
## 九、运行方法
确认 `config.toml` 已经创建并填写完成,然后在项目根目录运行:
```powershell
python .\04_数据库\4_1_PostgreSQL与Psycopg入门\connection_example.py
```
正常情况下会看到类似结果:
```text
连接成功。
当前数据库python_course
当前用户python_student
参数化查询结果Psycopg 连接成功
```
数据库名和用户名应以你的远程练习环境为准。
## 十、关键执行顺序
1. `main()` 调用 `load_database_config(CONFIG_PATH)`
2. `Path` 根据当前 Python 文件的位置定位 `config.toml`
3. `tomllib.load()` 读取 `[postgresql]` 配置节;
4. 缺少文件、配置节或必填字段时主动抛出中文 `RuntimeError`
5. `psycopg.connect(**database_config)` 把字典展开为连接参数;
6. `connection.cursor()` 创建游标;
7. `cursor.execute()` 将 SQL 和查询参数分别交给驱动;
8. `cursor.fetchone()` 读取一行结果;
9. 两层 `with` 依次释放游标和连接;
10. `main()` 输出结果,并分别处理配置异常和数据库异常。
## 十一、常见错误
### 11.1 `ModuleNotFoundError: No module named 'psycopg'`
含义:当前 Python 环境没有安装 Psycopg。
处理:激活 `.venv`,再使用本课的 `requirements.txt` 安装依赖。可以运行 `python -m pip show psycopg` 检查安装位置。
### 11.2 未找到 `config.toml`
含义:本课目录中还没有实际配置文件。
处理:把 `config.example.toml` 复制为 `config.toml` 并填写连接参数。不要直接把真实信息写进示例模板。
### 11.3 TOML 格式错误
含义:配置不符合 TOML 语法,例如字符串缺少引号或同一个键重复出现。
处理:对照 `config.example.toml` 检查配置节、等号、引号和字段名。
### 11.4 `connection refused` 或连接超时
含义:程序无法到达数据库地址和端口。
检查数据库服务、主机名、端口、防火墙、白名单和 VPN不要先假设一定是密码错误。
### 11.5 `password authentication failed`
含义:服务器已经收到连接,但用户名或密码校验失败。
检查 `config.toml` 中的账号、密码和数据库名。分字段传参时无需手动拼接 URL也避免了连接串中特殊字符编码问题。
### 11.6 `execute()` 参数写错
下面两种写法都不符合本课要求:
```python
cursor.execute("SELECT '%s'", (message,))
cursor.execute("SELECT %s", message)
```
占位符外不要加引号,参数序列只有一个值时要写成 `(message,)`
### 11.7 使用 `fetchone()` 却没有处理空结果
`fetchone()` 在没有数据时可能返回 `None`。本课查询必然返回一行,所以可以直接解包;以后查询业务表时必须判断空结果。
## 十二、课堂练习
打开 [practice.py](./practice.py),按照注释完成练习。练习要求你独立完成:
1. 使用 `Path` 定位本地 `config.toml`
2. 使用 `tomllib` 读取 PostgreSQL 配置;
3. 使用两层 `with` 管理连接和游标;
4. 执行包含两个参数的只读查询;
5. 使用 `fetchone()` 保存并输出结果;
6. 分别捕获配置读取异常和数据库访问异常。
练习仍然只执行只读 SQL不创建、修改或删除远程数据。
## 十三、本课小结
- DB-API 是 Python 数据库驱动的通用接口约定;
- Psycopg 3 是 PostgreSQL 的 Python 驱动;
- Psycopg 的基础层次与 JDBC 相似;
- `Connection` 表示数据库会话,`Cursor` 负责执行 SQL 和读取结果;
- SQL 与参数必须分开传递;
- `with` 用于可靠地结束事务和释放资源;
- 数据库密码保存在被 Git 忽略的本地 TOML 配置中,不写入环境变量或源码。
## 十四、验收标准
- 能说明 DB-API、Psycopg 和 PostgreSQL 的关系;
- 能说出 Psycopg 与 JDBC 的主要对象对应关系;
- 能复制模板并通过本地 `config.toml` 配置连接参数;
- `connection_example.py` 可以连接练习数据库并输出三项查询结果;
- `practice.py` 使用参数化查询,没有拼接 SQL
- 连接和游标都通过 `with` 管理;
- 能区分网络不可达、认证失败和依赖缺失;
- 程序没有读取、设置或修改环境变量;
- 未把 `config.toml` 或真实数据库连接信息写入 Git 跟踪文件。

View File

@@ -0,0 +1,10 @@
# 复制本文件并重命名为 config.toml再填写本地练习数据库信息。
# config.toml 已加入 .gitignore不会被 Git 跟踪。
[postgresql]
host = "数据库主机"
port = 5432
dbname = "数据库名"
user = "用户名"
password = "密码"
connect_timeout = 10

View File

@@ -0,0 +1,86 @@
"""第 4-1 课示例:从 TOML 配置读取参数并连接 PostgreSQL。"""
from pathlib import Path
import tomllib
import psycopg
# 使用当前 Python 文件的位置定位配置,避免程序依赖 PowerShell 的工作目录。
CONFIG_PATH = Path(__file__).with_name("config.toml")
def load_database_config(config_path: Path) -> dict[str, str | int]:
"""读取并检查 TOML 中的 PostgreSQL 连接配置。"""
if not config_path.exists():
raise RuntimeError(
"未找到 config.toml请复制 config.example.toml 并填写练习数据库配置。"
)
# tomllib 要求以二进制模式读取 TOML 文件,因此这里使用 "rb"。
with config_path.open("rb") as config_file:
config_data = tomllib.load(config_file)
postgresql_config = config_data.get("postgresql")
if not isinstance(postgresql_config, dict):
raise RuntimeError("config.toml 缺少 [postgresql] 配置节。")
required_names = ("host", "port", "dbname", "user", "password")
missing_names = [
name for name in required_names if postgresql_config.get(name) in (None, "")
]
if missing_names:
missing_text = "".join(missing_names)
raise RuntimeError(f"config.toml 缺少数据库配置:{missing_text}")
return postgresql_config
def query_connection_info(
database_config: dict[str, str | int],
) -> tuple[str, str, str]:
"""连接 PostgreSQL执行只读参数化查询并返回连接信息。"""
message = "Psycopg 连接成功"
# ** 会把字典中的键值展开为关键字参数,例如 host="数据库主机"。
# 连接信息来自本地 config.toml不会写入环境变量。
with psycopg.connect(**database_config) as connection:
# 第二层 with 管理游标。游标同时负责执行 SQL 和读取查询结果。
with connection.cursor() as cursor:
# SQL 和参数必须分开传递,不能使用 f-string 拼接用户数据。
cursor.execute(
"SELECT current_database(), current_user, %s::text",
(message,),
)
result = cursor.fetchone()
# 这条 PostgreSQL 查询必然返回一行。普通业务查询仍要考虑 None。
if result is None:
raise RuntimeError("数据库没有返回连接验证结果。")
database_name, user_name, returned_message = result
return database_name, user_name, returned_message
def main() -> None:
"""组织配置读取、数据库查询和结果输出。"""
try:
database_config = load_database_config(CONFIG_PATH)
database_name, user_name, message = query_connection_info(database_config)
except (OSError, tomllib.TOMLDecodeError, RuntimeError) as error:
# 这里处理文件读取、TOML 格式以及课程程序主动检查到的问题。
print(f"配置读取失败:{error}")
return
except psycopg.Error as error:
# Psycopg 的具体异常信息可以保留英文,前面补充中文场景说明。
print(f"数据库访问失败:{error}")
return
print("连接成功。")
print(f"当前数据库:{database_name}")
print(f"当前用户:{user_name}")
print(f"参数化查询结果:{message}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,93 @@
# 第 4-1 课练习:从 TOML 配置连接 PostgreSQL
#
# 本文件只提供题目,不包含导入、代码骨架、测试数据或参考答案。
# 请完成只读查询,不要创建表,也不要新增、修改或删除远程数据库中的数据。
# 第一部分:准备并定位配置文件
# 1. 导入 pathlib.Path、tomllib 和 psycopg。
# 2. 使用 Path(__file__).with_name("config.toml") 得到配置文件路径,
# 并保存为 CONFIG_PATH。
# 3. 复制 config.example.toml重命名为 config.toml再填写本地连接信息。
# 4. 不得修改环境变量,也不得把真实连接信息写入 Python 文件。
#
# 与 Java 对照:
# - config.toml 类似独立的 application.yml 或 properties 本地配置;
# - CONFIG_PATH 类似确定配置资源位置;
# - 本课手动读取配置,后续框架可能负责自动绑定配置对象。
# 第二部分:读取 TOML 配置
# 1. 定义 load_database_config(config_path) 函数。
# 2. config_path 不存在时抛出 RuntimeError消息为
# “未找到 config.toml请先复制并填写配置文件。”。
# 3. 使用 config_path.open("rb") 和 with 打开文件。
# 4. 调用 tomllib.load(config_file),保存完整配置。
# 5. 使用配置节名称 postgresql 取得数据库配置字典。
# 6. 配置节不存在或不是字典时抛出:
# RuntimeError("config.toml 缺少 [postgresql] 配置节。")
# 7. 返回 postgresql 配置字典。
# 第三部分:执行参数化只读查询
# 1. 定义 query_lesson_info(database_config, lesson_name, lesson_number) 函数。
# 2. 调用 psycopg.connect(**database_config),并使用 with 管理 Connection。
# 3. 调用 connection.cursor(),并使用第二层 with 管理 Cursor。
# 4. 调用 cursor.execute() 执行:
# SELECT current_database(), %s::text, %s::integer
# 5. 把 lesson_name 和 lesson_number 组成二元素元组,作为 execute() 的第二个参数。
# 6. 不得使用 f-string、字符串拼接或 % 运算把参数拼进 SQL。
# 7. 调用 cursor.fetchone(),把返回值保存到 result。
# 8. 离开两层 with 后,如果 result 是 None抛出
# RuntimeError("数据库没有返回练习结果。")
# 9. 解包并返回 database_name、returned_name、returned_number。
# 第四部分:组织 main() 正常流程
# 1. 定义 main()。
# 2. 调用 load_database_config(CONFIG_PATH),保存 database_config。
# 3. 调用:
# query_lesson_info(database_config, "PostgreSQL 与 Psycopg 入门", 1)
# 4. 保存返回的三个结果。
# 5. 分别使用 print() 输出:
# “当前数据库:实际数据库名”
# “课程名称PostgreSQL 与 Psycopg 入门”
# “课程编号1”
# 第五部分:处理异常
# 1. 在 main() 中使用 try...except 包围配置读取和数据库查询。
# 2. 捕获 OSError、tomllib.TOMLDecodeError 和 RuntimeError输出
# “配置读取失败:{错误信息}”,然后结束 main()。
# 3. 捕获 psycopg.Error输出“数据库访问失败{错误信息}”,然后结束 main()。
# 4. 添加程序入口判断并调用 main()。
#
# 缺少配置文件时的预期输出:
# 配置读取失败:未找到 config.toml请先复制并填写配置文件。
#
# 正确连接后的预期输出格式:
# 当前数据库python_course
# 课程名称PostgreSQL 与 Psycopg 入门
# 课程编号1
#
# 注意:第一行的 python_course 只是示例,应以实际数据库名为准。
# 自查清单:
# 1. 是否通过当前 Python 文件位置定位 config.toml
# 2. 是否没有读取、设置或修改任何环境变量?
# 3. 是否没有在 Python 文件中填写真实账号、密码和主机地址?
# 4. Connection 和 Cursor 是否都使用 with 管理?
# 5. 是否使用 **database_config 把配置字典传给 psycopg.connect()
# 6. SQL 和参数是否通过 execute() 的两个参数分别传入?
# 7. 是否保存并检查了 fetchone() 的结果?
# 8. 是否分别处理配置异常和 psycopg.Error
# 最终验收标准:
# 1. practice.py 可以通过 Python 语法检查;
# 2. 缺少 config.toml 时能输出指定的中文提示;
# 3. 配置正确时能连接 PostgreSQL 并输出数据库名、课程名和课程编号;
# 4. 查询全程只读,不产生远程数据库写入;
# 5. SQL 使用参数化查询,不存在字符串拼接;
# 6. 连接和游标能够可靠关闭;
# 7. 程序不读写环境变量;
# 8. config.toml 和真实连接信息没有进入 Git 跟踪文件。

View File

@@ -0,0 +1,3 @@
# Psycopg 3 是 PostgreSQL 的 Python 数据库驱动。
# binary 额外依赖适合本地学习,可避免首次安装时配置 C 编译环境。
psycopg[binary]>=3,<4

View File

@@ -0,0 +1,420 @@
# 第 4-2 课Python 数据库事务与数据访问层
## 一、本课定位
第一课已经完成 PostgreSQL 连接、游标、参数化查询和资源释放。本课先系统复习事务的核心概念,再学习 Python/Psycopg 中的事务边界,以及如何把 SQL 从业务逻辑中分离。数据库基础不会展开成完整 SQL 课程,但事务是后续 ORM 和 Web 业务正确性的基础,必须讲清楚。
本课会写入远程练习数据库:示例创建专用表 `course_bank_account`,只重置并操作 `COURSE-` 前缀数据;练习使用另一张专用表 `course_wallet_account`,只操作 `PRACTICE-` 前缀数据。不要连接生产数据库。
## 二、本课目标
完成本课后,你将能够:
1. 说明事务是什么以及为什么需要事务;
2. 结合转账解释原子性、一致性、隔离性和持久性;
3. 区分提交、回滚、自动提交和事务失败状态;
4. 解释常见并发异常和事务隔离级别;
5. 解释 Psycopg 默认事务行为;
6. 使用连接上下文自动提交和回滚;
7. 理解为什么异常必须传播出事务上下文;
8. 使用 `executemany()` 批量执行同一条参数化 SQL
9. 使用 `FOR UPDATE` 锁定待修改数据;
10. 使用 Repository 隔离 SQL
11. 把业务规则放在 Service 中;
12. 让一次业务操作共享同一连接和事务;
13. 对照 JDBC、MyBatis 和 Spring 事务理解 Python实现。
## 三、事务是什么
事务Transaction是数据库中的一个工作单元它包含一条或多条操作这些操作应该作为一个不可分割的整体完成。
转账至少包含两条更新:
```text
账户A扣款200元
账户B入账200元
```
如果第一条成功、第二条失败,却保留了第一条结果,钱就凭空减少了。事务要求最终只能出现两种结果:
```text
全部成功 → 提交COMMIT
任一步失败 → 回滚ROLLBACK恢复到事务开始前
```
对应的SQL概念是
```sql
BEGIN;
UPDATE account SET balance = balance - 200 WHERE account_no = 'A';
UPDATE account SET balance = balance + 200 WHERE account_no = 'B';
COMMIT;
```
中间发生错误时执行:
```sql
ROLLBACK;
```
使用Psycopg时通常不需要手写`BEGIN`。驱动会按连接状态自动开始事务,代码负责正确划定提交和回滚边界。
## 四、事务的ACID特性
ACID是事务需要满足的四类核心性质。
### 4.1 原子性Atomicity
事务内的操作要么全部成功,要么全部失败。转账中的扣款和入账不能只保留其中一步。
本课的失败示例会先扣除50元再主动抛出异常。最终余额不变就是在验证原子性。
### 4.2 一致性Consistency
事务执行前后,数据都必须满足数据库约束和业务规则。例如:
- 余额不能小于0
- 转账前后两个账户的余额总额不应无故变化;
- 目标账户必须存在;
- 主键、非空和检查约束仍然成立。
一致性不是只靠数据库自动保证。数据库约束、事务、锁和Service中的业务校验要共同工作。
### 4.3 隔离性Isolation
多个事务并发执行时,一个事务不应随意看到另一个尚未完成事务的中间状态。
假设账户A余额为1000元两个请求同时转出800元。如果二者都先读取到1000再分别扣款就可能发生超额转账。事务隔离级别和行锁用于控制这种并发影响。
隔离不等于所有事务完全串行。隔离越强,并发冲突通常越少,但等待和资源成本可能越高。
### 4.4 持久性Durability
事务成功提交后,结果应该持久保存。程序退出或连接关闭后,再使用新连接查询,仍然能够看到已提交的余额。
本课使用独立连接回查成功转账结果,就是在直观验证持久性。
## 五、提交、回滚与事务状态
### 5.1 提交
`COMMIT`确认事务中的修改。提交成功后,其他事务才能按照隔离规则观察到这些结果,当前事务也不能再整体撤销。
### 5.2 回滚
`ROLLBACK`取消当前事务中尚未提交的修改。它不是反向执行一条新的补偿SQL而是让数据库放弃本次事务的未提交结果。
### 5.3 PostgreSQL事务失败状态
PostgreSQL事务中的某条SQL失败后当前事务通常进入失败状态。即使后面的SQL本身正确也会收到类似错误
```text
current transaction is aborted, commands ignored until end of transaction block
```
中文含义是当前事务已经失败在事务结束前忽略后续命令。此时必须回滚或让Psycopg的连接上下文因异常退出并自动回滚。
### 5.4 自动提交
自动提交Autocommit表示每条独立SQL完成后立即提交。它适合部分不需要多语句原子性的操作但无法把扣款和入账自动组合成一个事务。
Psycopg默认`autocommit=False`。本课保持默认行为,不开启自动提交。
## 六、隔离级别与并发异常
常见并发异常如下:
| 并发现象 | 含义 |
|---|---|
| 脏读Dirty Read | 读取到其他事务尚未提交的数据;对方回滚后,读到的数据从未真正成立 |
| 不可重复读Non-repeatable Read | 同一事务两次读取同一行,期间被其他事务提交修改,结果不同 |
| 幻读Phantom Read | 同一事务两次执行相同范围查询,结果行数量因其他事务提交而变化 |
| 丢失更新Lost Update | 两个事务基于同一旧值更新,后提交的结果覆盖前一个结果 |
SQL标准定义四个主要隔离级别
| 隔离级别 | 基本含义 | PostgreSQL说明 |
|---|---|---|
| `READ UNCOMMITTED` | 理论上允许读取未提交数据 | PostgreSQL内部按`READ COMMITTED`处理 |
| `READ COMMITTED` | 每条语句读取执行开始前已提交的数据 | PostgreSQL默认级别 |
| `REPEATABLE READ` | 事务内多次查询基于稳定快照 | 提交时仍可能出现并发冲突 |
| `SERIALIZABLE` | 尽量表现得像事务串行执行 | 冲突时可能要求应用重试事务 |
隔离级别不能代替所有业务并发控制。本课在默认`READ COMMITTED`下使用`SELECT ... FOR UPDATE`锁住即将修改的账户行。
## 七、JDBC与Psycopg事务对照
| Java常见写法 | Psycopg写法 | 含义 |
|---|---|---|
| `connection.setAutoCommit(false)` | 默认连接首次操作自动进入事务 | 开始事务工作 |
| `connection.commit()` | 正常离开连接`with` | 提交 |
| `connection.rollback()` | 异常离开连接`with` | 回滚 |
| `try-with-resources` | `with psycopg.connect(...)` | 管理连接生命周期 |
| Mapper/DAO | Repository | 封装SQL和结果转换 |
| Service | Service | 组织业务规则 |
| `@Transactional` | 外层连接/事务上下文 | 划定事务边界 |
Python没有Spring默认提供的声明式`@Transactional`。直接使用Psycopg时需要显式组织事务上下文后续SQLAlchemy和FastAPI课程会进一步统一Session生命周期。
## 八、Psycopg默认事务行为
Psycopg遵循DB-API习惯连接默认不是自动提交模式。包括`SELECT`在内的数据库操作通常都会启动事务。
```python
with psycopg.connect(**database_config) as connection:
connection.execute("UPDATE ...")
```
正常离开`with`时提交;块内异常传播出去时回滚;最后关闭连接。
### 8.1 异常不能在事务内部被吞掉
错误写法:
```python
with psycopg.connect(**database_config) as connection:
try:
connection.execute("UPDATE ...")
raise TransferError("后续失败")
except TransferError:
print("失败")
```
异常已经在`with`内部被捕获,连接上下文看到的是“正常结束”,可能提交前面的更新。
正确边界:
```python
try:
with psycopg.connect(**database_config) as connection:
connection.execute("UPDATE ...")
raise TransferError("后续失败")
except TransferError as error:
print(error)
```
异常先离开`with`,触发回滚,然后才由外层处理。
### 8.2 手动提交和回滚
连接上下文适合“整个代码块就是一个事务”的情况,也可以显式控制:
```python
connection = psycopg.connect(**database_config)
try:
connection.execute("UPDATE ...")
connection.execute("UPDATE ...")
connection.commit()
except Exception:
connection.rollback()
raise
finally:
connection.close()
```
这与JDBC手动事务非常接近。但只要业务边界能自然表达为代码块优先使用`with`,可以减少遗漏回滚或关闭连接的风险。
### 8.3 连接with与事务with的区别
本课使用:
```python
with psycopg.connect(**database_config) as connection:
...
```
它同时管理事务和连接生命周期。Psycopg还提供
```python
with connection.transaction():
...
```
后者只划定一个事务或保存点范围,不负责创建连接。它适合长连接或连接池场景。本阶段后续课程结合连接池时再深入使用。
## 九、Repository与Service职责
本课采用:
```text
main
↓ 创建连接并划定事务
Service
↓ 组织转账规则
Repository
↓ 执行参数化SQL
PostgreSQL
```
Repository不应在每个方法中调用`commit()`,否则扣款刚提交、入账却失败时,外层已经无法回滚整个业务操作。
```python
class AccountRepository:
def __init__(self, connection):
self.connection = connection
def change_balance(self, account_no, amount):
self.connection.execute(...)
# 这里不提交。
```
Service也不负责创建连接它复用同一个Repository从而保证多个SQL处于同一事务。
## 十、FOR UPDATE、锁与死锁
转账前先查询余额:
```sql
SELECT balance
FROM course_bank_account
WHERE account_no = %s
FOR UPDATE
```
`FOR UPDATE`会锁定选中的行,直到事务提交或回滚。它能防止两个并发事务同时读取相同旧余额后分别扣款。
这种“先锁定,再判断和修改”的做法属于悲观锁:代码假设并发冲突可能发生,因此提前取得排他性的行锁。
锁会持续到事务提交或回滚。事务范围过大,会让其他请求等待更久,因此事务中不应夹杂耗时的网络请求、人工操作或无关计算。
### 10.1 死锁
如果事务一先锁A再锁B事务二同时先锁B再锁A双方可能互相等待。数据库会检测死锁并中止其中一个事务。
降低死锁风险的常用办法:
- 多个事务按照统一顺序锁定资源,例如始终按账号升序;
- 缩短事务时间;
- 只锁真正需要修改的行;
- 应用捕获死锁或序列化失败,并按策略重试整个事务。
## 十一、批量操作
多条数据执行相同SQL时可以使用
```python
with connection.cursor() as cursor:
cursor.executemany(
"INSERT INTO course_bank_account VALUES (%s, %s, %s)",
accounts,
)
```
它比手工拼接多条SQL安全也明确表达“同一语句、不同参数”。它不等于无限制地一次提交海量数据生产中仍要根据数据量分批。
## 十二、完整示例
示例文件为[transaction_repository_example.py](./transaction_repository_example.py),包含:
- `AccountRepository`:建表、初始化、查询和更新;
- `TransferService`:金额检查、余额检查、扣款和入账;
- 成功事务转账200元并自动提交
- 失败事务先扣50元再抛出异常验证自动回滚
- 独立连接回查:证明提交和回滚的最终状态。
## 十三、准备配置
在第二课目录执行:
```powershell
Copy-Item .\config.example.toml .\config.toml
```
填写第一课使用的同一套专用练习数据库配置。`config.toml`已被项目`.gitignore`排除。
## 十四、运行方法与预期结果
激活已安装Psycopg的Conda环境
```powershell
conda activate python-test
python .\transaction_repository_example.py
```
关键结果应为:
```text
初始余额:
COURSE-A001小明余额1000.00
COURSE-A002小红余额500.00
成功转账 200 元后:
COURSE-A001小明余额800.00
COURSE-A002小红余额700.00
失败事务已回滚:模拟第二步失败,验证前一步更新会被回滚。
失败事务回滚后:
COURSE-A001小明余额800.00
COURSE-A002小红余额700.00
```
重复运行时,示例会先删除`COURSE-`前缀数据并重新初始化,因此结果保持一致。
## 十五、常见错误
### 15.1 Repository内部提交
这会破坏跨多个SQL的原子性。提交和回滚应由业务事务边界统一控制。
### 15.2 在with内部捕获业务异常
异常没有传播给连接上下文,可能导致错误提交。先让异常离开事务块,再在外层捕获。
### 15.3 失败后继续使用同一事务
PostgreSQL语句失败后当前事务通常进入失败状态。在回滚前继续执行SQL会收到`current transaction is aborted`一类错误。
### 15.4 使用float表示金额
二进制浮点数可能产生精度误差。课程金额使用`Decimal("200.00")`,数据库使用`NUMERIC(12, 2)`
### 15.5 先查询再更新却没有锁
单用户测试可能正常,但并发时可能发生余额覆盖或超额扣款。本课使用`FOR UPDATE`锁定账户行。
### 15.6 清理范围过大
不要使用无条件`DELETE``TRUNCATE`。示例和练习只删除指定前缀的课程数据。
## 十六、课堂练习
打开[practice.py](./practice.py),实现独立的钱包转账练习。题目已经明确:
1. 配置读取;
2. Repository方法
3. Service业务规则
4. 批量初始化;
5. 成功事务;
6. 失败回滚;
7. 回查、输出和异常处理。
## 十七、本课小结
- 事务把多条数据库操作组织成一个工作单元;
- ACID分别是原子性、一致性、隔离性和持久性
- 提交确认修改,回滚取消尚未提交的修改;
- PostgreSQL事务中的SQL失败后通常必须先回滚才能继续
- 隔离级别控制并发事务互相可见的范围;
- 事务边界应该覆盖完整业务操作;
- Repository封装SQL但不擅自提交
- Service组织业务规则但复用外部连接
- 异常必须先离开事务上下文才能触发自动回滚;
- `executemany()`适合相同SQL的多组参数
- `FOR UPDATE`用于锁定即将修改的数据;
- 金额应使用`Decimal`与数据库`NUMERIC`
## 十八、验收标准
- 能使用转账说明为什么需要事务;
- 能结合示例解释ACID四个特性
- 能区分提交、回滚和自动提交;
- 能简要说明脏读、不可重复读、幻读和丢失更新;
- 能解释连接上下文何时提交、何时回滚;
- 能说明为什么Repository不能随意提交
- 标准示例重复运行且结果一致;
- 练习的成功转账同时更新两个钱包;
- 模拟失败后第一条更新被完整回滚;
- SQL全部参数化
- 只操作课程专用表和指定前缀数据;
- 未提交真实`config.toml`

View File

@@ -0,0 +1,10 @@
# 复制本文件并重命名为 config.toml再填写本地练习数据库信息。
# config.toml 已加入项目 .gitignore不会被 Git 跟踪。
[postgresql]
host = "数据库主机"
port = 5432
dbname = "数据库名"
user = "用户名"
password = "密码"
connect_timeout = 10

View File

@@ -0,0 +1,130 @@
# 第 4-2 课练习:使用事务完成安全转账
#
# 本文件只提供题目,不包含导入、代码骨架、测试数据或参考答案。
# 本练习会创建 course_wallet_account 表,并只操作 PRACTICE- 前缀的数据。
# 请勿把表名改成现有业务表,也不要删除不属于本练习的数据。
# 第一部分:准备配置和异常
# 1. 导入 Decimal、Path、tomllib 和 psycopg。
# 2. 从 psycopg 导入 Connection。
# 3. 使用 Path(__file__).with_name("config.toml") 定义 CONFIG_PATH。
# 4. 定义 WalletError(Exception),类体只写 pass。
# 5. 实现 load_database_config(config_path),要求与第一课相同:
# - 文件不存在时抛出明确的 RuntimeError
# - 使用 tomllib 读取 [postgresql]
# - 配置节不是字典时抛出明确的 RuntimeError
# - 返回数据库配置字典。
# 第二部分:定义 WalletRepository
# 1. __init__(self, connection) 保存 Connection但不在 Repository 中创建连接。
# 2. create_table() 执行以下建表逻辑:
# - 表名 course_wallet_account
# - wallet_no VARCHAR(30) 主键;
# - owner_name VARCHAR(50) 非空;
# - balance NUMERIC(12, 2) 非空并且大于等于 0
# - 使用 CREATE TABLE IF NOT EXISTS。
# 3. reset_practice_wallets() 只删除 wallet_no LIKE 'PRACTICE-%' 的记录,
# 模式字符串必须作为参数传入,不得拼接 SQL。
# 4. add_wallets(wallets) 使用 with connection.cursor() 创建游标,
# 再调用 cursor.executemany() 批量新增。
# 5. find_balance_for_update(wallet_no) 使用参数化 SQL 和 FOR UPDATE
# - 找不到时抛出 WalletError("钱包不存在:{wallet_no}")
# - 找到时返回 Decimal 余额。
# 6. change_balance(wallet_no, amount) 使用 balance = balance + %s 更新余额;
# - cursor.rowcount 不等于 1 时抛出钱包不存在异常;
# - 本方法不调用 commit() 或 rollback()。
# 7. find_practice_wallets() 查询 PRACTICE- 前缀记录,按 wallet_no 排序并返回结果。
#
# Repository 边界提醒:
# - Repository 负责 SQL 和结果转换;
# - 不要在 add_wallets()、change_balance() 内提交事务;
# - 多个 Repository 操作需要由外层业务事务统一提交或回滚。
# 第三部分:定义 WalletTransferService
# 1. __init__(self, repository) 保存 WalletRepository。
# 2. transfer(self, source_no, target_no, amount) 按顺序执行:
# - amount <= 0 时抛出 WalletError("转账金额必须大于 0。")
# - 调用 find_balance_for_update(source_no),保存 source_balance
# - 调用 find_balance_for_update(target_no),确认目标钱包存在并锁定;
# - source_balance < amount 时抛出 WalletError("钱包余额不足。")
# - 调用 change_balance(source_no, -amount)
# - 调用 change_balance(target_no, amount)。
# 3. transfer() 不调用 commit() 或 rollback()。
# 第四部分:准备练习数据
# 1. 定义 prepare_data(database_config)。
# 2. 使用 with psycopg.connect(**database_config) as connection 管理事务。
# 3. 创建 WalletRepository(connection)。
# 4. 依次调用 create_table() 和 reset_practice_wallets()。
# 5. 调用 add_wallets() 批量新增:
# - ("PRACTICE-W001", "张三", Decimal("800.00"))
# - ("PRACTICE-W002", "李四", Decimal("300.00"))
# 6. 正常离开 with让初始化事务自动提交。
# 第五部分:完成成功事务
# 1. 定义 run_successful_transfer(database_config)。
# 2. 使用一个连接上下文创建 Repository 和 Service。
# 3. 调用:
# service.transfer("PRACTICE-W001", "PRACTICE-W002", Decimal("150.00"))
# 4. 正常离开 with不要手工调用 commit()。
# 5. 成功后两个余额应分别为 650.00 和 450.00。
# 第六部分:完成失败事务并验证回滚
# 1. 定义 run_failed_transfer(database_config)。
# 2. 在 try 中创建连接上下文和 Repository。
# 3. 先调用:
# repository.change_balance("PRACTICE-W001", Decimal("-50.00"))
# 4. 紧接着抛出 WalletError("模拟入账失败。")。
# 5. 在连接上下文外捕获 WalletError并输出
# “失败事务已回滚:模拟入账失败。”
# 6. 不要在 except 前捕获并吞掉异常,否则连接上下文无法自动回滚。
# 7. 回查后余额必须仍为 650.00 和 450.00,而不是 600.00 和 450.00。
# 第七部分:查询与 main() 输出
# 1. 定义 query_wallets(database_config),使用独立连接查询并返回练习钱包。
# 2. 定义 print_wallets(title, wallets),按以下格式逐行输出:
# “PRACTICE-W001张三余额800.00”。
# 3. main() 依次调用:
# - load_database_config(CONFIG_PATH)
# - prepare_data(database_config)
# - print_wallets("初始余额:", query_wallets(database_config))
# - run_successful_transfer(database_config)
# - print_wallets("成功转账 150 元后:", query_wallets(database_config))
# - run_failed_transfer(database_config)
# - print_wallets("失败事务回滚后:", query_wallets(database_config))。
# 4. 分别捕获配置异常和 psycopg.Error并输出中文场景说明。
# 5. 添加程序入口判断并调用 main()。
# 预期关键输出:
# 初始余额:
# PRACTICE-W001张三余额800.00
# PRACTICE-W002李四余额300.00
# 成功转账 150 元后:
# PRACTICE-W001张三余额650.00
# PRACTICE-W002李四余额450.00
# 失败事务已回滚:模拟入账失败。
# 失败事务回滚后:
# PRACTICE-W001张三余额650.00
# PRACTICE-W002李四余额450.00
# 自查清单:
# 1. Repository 是否复用外部传入的 Connection
# 2. Repository 和 Service 是否都没有自行提交事务?
# 3. 扣款和入账是否处于同一个连接上下文?
# 4. 失败异常是否离开连接 with 后才被捕获?
# 5. 是否使用 FOR UPDATE 锁定待修改账户?
# 6. 批量初始化是否使用 executemany()
# 7. 所有值是否通过参数化查询传入?
# 8. 是否只清理 PRACTICE- 前缀的练习数据?
# 最终验收标准:
# 1. practice.py 通过语法检查并能重复运行;
# 2. 初始化、成功转账和失败回滚的余额符合预期;
# 3. 失败事务没有保留第一条扣款更新;
# 4. Repository 只负责数据访问Service 负责业务规则;
# 5. 外层连接上下文控制事务提交和回滚;
# 6. SQL 全部参数化,不拼接业务数据;
# 7. 只操作本课专用表和 PRACTICE- 前缀数据;
# 8. config.toml 与真实连接信息没有进入 Git。

View File

@@ -0,0 +1,3 @@
# 第二课继续使用 Psycopg 3不新增第三方框架。
# 若使用 Conda可以在课程环境中安装 psycopg 或 psycopg-c。
psycopg>=3,<4

View File

@@ -0,0 +1,214 @@
"""第 4-2 课示例:使用事务和 Repository 完成安全转账。"""
from decimal import Decimal
from pathlib import Path
import tomllib
import psycopg
from psycopg import Connection
from psycopg.rows import dict_row
CONFIG_PATH = Path(__file__).with_name("config.toml")
class TransferError(Exception):
"""表示转账过程中可以预期的业务失败。"""
def load_database_config(config_path: Path) -> dict[str, str | int]:
"""读取第二课本地 TOML 数据库配置。"""
if not config_path.exists():
raise RuntimeError(
"未找到 config.toml请复制 config.example.toml 并填写练习数据库配置。"
)
with config_path.open("rb") as config_file:
config_data = tomllib.load(config_file)
database_config = config_data.get("postgresql")
if not isinstance(database_config, dict):
raise RuntimeError("config.toml 缺少 [postgresql] 配置节。")
return database_config
class AccountRepository:
"""封装账户表 SQL但不自行提交或回滚事务。"""
def __init__(self, connection: Connection) -> None:
self.connection = connection
def create_table(self) -> None:
"""创建本课专用表;表已存在时保持不变。"""
self.connection.execute(
"""
CREATE TABLE IF NOT EXISTS course_bank_account (
account_no VARCHAR(30) PRIMARY KEY,
owner_name VARCHAR(50) NOT NULL,
balance NUMERIC(12, 2) NOT NULL CHECK (balance >= 0)
)
"""
)
def reset_course_accounts(self) -> None:
"""只清理 COURSE- 前缀的课程数据,避免影响其他记录。"""
self.connection.execute(
"DELETE FROM course_bank_account WHERE account_no LIKE %s",
("COURSE-%",),
)
def add_accounts(self, accounts: list[tuple[str, str, Decimal]]) -> None:
"""使用 executemany() 批量新增课程账户。"""
# executemany() 是 Cursor 的方法,因此显式创建并关闭游标。
with self.connection.cursor() as cursor:
cursor.executemany(
"""
INSERT INTO course_bank_account (account_no, owner_name, balance)
VALUES (%s, %s, %s)
""",
accounts,
)
def get_balance_for_update(self, account_no: str) -> Decimal:
"""查询并锁定账户,防止并发事务同时修改同一余额。"""
result = self.connection.execute(
"""
SELECT balance
FROM course_bank_account
WHERE account_no = %s
FOR UPDATE
""",
(account_no,),
).fetchone()
if result is None:
raise TransferError(f"账户不存在:{account_no}")
return result[0]
def change_balance(self, account_no: str, amount: Decimal) -> None:
"""使用数据库加法更新余额,并检查目标账户是否存在。"""
cursor = self.connection.execute(
"""
UPDATE course_bank_account
SET balance = balance + %s
WHERE account_no = %s
""",
(amount, account_no),
)
if cursor.rowcount != 1:
raise TransferError(f"账户不存在:{account_no}")
def find_course_accounts(self) -> list[dict[str, object]]:
"""按账号查询课程账户,并以字典行返回。"""
cursor = self.connection.cursor(row_factory=dict_row)
try:
cursor.execute(
"""
SELECT account_no, owner_name, balance
FROM course_bank_account
WHERE account_no LIKE %s
ORDER BY account_no
""",
("COURSE-%",),
)
return list(cursor.fetchall())
finally:
cursor.close()
class TransferService:
"""组织转账业务规则;事务由调用它的连接上下文统一管理。"""
def __init__(self, repository: AccountRepository) -> None:
self.repository = repository
def transfer(self, source_no: str, target_no: str, amount: Decimal) -> None:
"""在同一事务中完成扣款与入账。"""
if amount <= 0:
raise TransferError("转账金额必须大于 0。")
source_balance = self.repository.get_balance_for_update(source_no)
self.repository.get_balance_for_update(target_no)
if source_balance < amount:
raise TransferError("账户余额不足。")
self.repository.change_balance(source_no, -amount)
self.repository.change_balance(target_no, amount)
def print_accounts(title: str, accounts: list[dict[str, object]]) -> None:
"""输出当前课程账户余额。"""
print(title)
for account in accounts:
print(
f"{account['account_no']}{account['owner_name']}"
f"余额:{account['balance']}"
)
def prepare_data(database_config: dict[str, str | int]) -> None:
"""创建专用表并重置本课固定数据。"""
with psycopg.connect(**database_config) as connection:
repository = AccountRepository(connection)
repository.create_table()
repository.reset_course_accounts()
repository.add_accounts(
[
("COURSE-A001", "小明", Decimal("1000.00")),
("COURSE-A002", "小红", Decimal("500.00")),
]
)
def run_successful_transfer(database_config: dict[str, str | int]) -> None:
"""演示正常离开连接上下文时自动提交事务。"""
with psycopg.connect(**database_config) as connection:
service = TransferService(AccountRepository(connection))
service.transfer("COURSE-A001", "COURSE-A002", Decimal("200.00"))
def run_failed_transfer(database_config: dict[str, str | int]) -> None:
"""演示异常离开连接上下文时自动回滚整个事务。"""
try:
with psycopg.connect(**database_config) as connection:
repository = AccountRepository(connection)
# 先执行一条成功更新,再主动触发业务异常。
# 外层 with 会回滚,因此这 50 元扣款不会保留下来。
repository.change_balance("COURSE-A001", Decimal("-50.00"))
raise TransferError("模拟第二步失败,验证前一步更新会被回滚。")
except TransferError as error:
print(f"失败事务已回滚:{error}")
def query_accounts(
database_config: dict[str, str | int],
) -> list[dict[str, object]]:
"""使用独立连接回查已经提交的数据。"""
with psycopg.connect(**database_config) as connection:
return AccountRepository(connection).find_course_accounts()
def main() -> None:
"""依次演示初始化、提交、回滚和回查。"""
try:
database_config = load_database_config(CONFIG_PATH)
prepare_data(database_config)
print_accounts("初始余额:", query_accounts(database_config))
run_successful_transfer(database_config)
print_accounts("成功转账 200 元后:", query_accounts(database_config))
run_failed_transfer(database_config)
print_accounts("失败事务回滚后:", query_accounts(database_config))
except (OSError, tomllib.TOMLDecodeError, RuntimeError) as error:
print(f"配置读取失败:{error}")
except psycopg.Error as error:
print(f"数据库访问失败:{error}")
if __name__ == "__main__":
main()

View File

@@ -69,16 +69,24 @@
### 第四阶段:数据库编程 ### 第四阶段:数据库编程
- 数据库和关系型数据库的基本概念; 本阶段采用精简路线。学习者已经具备数据库、SQL 和 Java 数据库开发基础,因此不再单独讲解关系型数据库、增删改查、关联查询、索引和事务等通用知识,而是重点学习 Python 数据库编程方式以及它与 JDBC、MyBatis、MyBatis-Plus、JPA/Hibernate 的差异。
- SQL 基础语法;
- 表、字段、主键和外键; 本阶段统一使用 PostgreSQL不同时维护 MySQL 和 PostgreSQL 两套示例。涉及常见数据库差异时,通过补充说明对照 PostgreSQL 与 MySQL不影响后续将知识迁移到 MySQL。
- 数据的新增、查询、修改和删除;
- 条件查询、排序、分组和关联查询; 本阶段共五课:
- 索引与事务基础;
- Python 连接 MySQL 或 PostgreSQL 1. `4_1_PostgreSQL与Psycopg入门`:连接远程 PostgreSQL认识 Python 数据库 APIDatabase APIDB-API使用 Psycopg 3 执行参数化 SQL并与 JDBC 的连接、语句和结果集进行对照
- 参数化查询与 SQL 注入防护 2. `4_2_Python数据库事务与数据访问层`学习提交、回滚、异常处理、批量操作以及数据访问对象Data Access ObjectDAO和 Repository 分层
- SQLAlchemy 对象关系映射 3. `4_3_SQLAlchemy基础`:学习 SQLAlchemy 2.x 的 Engine、Session、声明式模型和基本增删改查并与 JPA/Hibernate、MyBatis-Plus 进行对照
- 数据库综合项目:持久化图书管理系统。 4. `4_4_SQLAlchemy关系映射与工程实践`学习一对多、多对多、级联操作、加载策略、N+1 查询问题和业务分层;
5. `4_5_数据库综合项目`:使用 PostgreSQL 和 SQLAlchemy 2.x把第三阶段的内存版图书管理系统升级为持久化版本。
本阶段采用两层数据库操作路线:
- Psycopg 3 对应 JDBC 驱动层,用于理解连接、游标、参数化 SQL、查询结果和事务
- SQLAlchemy 2.x 提供 SQL 工具和对象关系映射Object Relational MappingORM能力其中 ORM 的定位更接近 JPA/Hibernate并具备部分与 MyBatis-Plus 相似的常规增删改查体验。
数据库连接地址、用户名和密码通过本地 `config.toml` 提供,不写入环境变量。仓库只提交脱敏的 `config.example.toml`,真实配置文件必须由 `.gitignore` 排除。课程只使用专用练习数据库和受限账号,不连接生产数据库。
### 第五阶段Web 开发基础 ### 第五阶段Web 开发基础
@@ -176,8 +184,27 @@ Python/
│ ├── 1_2_变量与数据类型/ │ ├── 1_2_变量与数据类型/
│ └── 1_3_输入与输出/ │ └── 1_3_输入与输出/
├── 02_python进阶/ ├── 02_python进阶/
│ ├── 2_1_模块与包/
│ ├── 2_2_文件与目录操作/
│ ├── 2_3_异常处理/
│ ├── 2_4_推导式与简化写法/
│ ├── 2_5_迭代器与生成器/
│ ├── 2_6_装饰器/
│ ├── 2_7_类型注解/
│ ├── 2_8_虚拟环境与依赖管理/
│ └── 2_9_python进阶综合项目/
├── 03_面向对象/ ├── 03_面向对象/
│ ├── 3_1_Python与Java的类和对象/
│ ├── 3_2_Python与Java的封装差异/
│ ├── 3_3_Python与Java的继承和多态差异/
│ ├── 3_4_Python特殊方法与数据类/
│ └── 3_5_面向对象综合项目/
├── 04_数据库/ ├── 04_数据库/
│ ├── 4_1_PostgreSQL与Psycopg入门/
│ ├── 4_2_Python数据库事务与数据访问层/
│ ├── 4_3_SQLAlchemy基础/
│ ├── 4_4_SQLAlchemy关系映射与工程实践/
│ └── 4_5_数据库综合项目/
├── 05_web基础/ ├── 05_web基础/
├── 06_fastapi/ ├── 06_fastapi/
├── 07_django/ ├── 07_django/
@@ -188,13 +215,13 @@ Python/
## 当前学习进度 ## 当前学习进度
- 当前阶段:第阶段——Python 进阶 - 当前阶段:第阶段——数据库编程
- 当前课程:`2_2_文件与目录操作` - 当前课程:`4_2_Python数据库事务与数据访问层`
- 当前状态:正在学习文件与目录操作 - 当前状态:第四阶段前两课已完成,并通过 PostgreSQL 连接、成功提交和异常回滚验证
- 已完成课程:`1_1_hello_world``1_14_python基础综合项目`,以及 `2_1_模块与包` - 已完成课程:第一阶段 `1_1_hello_world``1_14_python基础综合项目`、第二阶段 `2_1_模块与包``2_9_python进阶综合项目`、第三阶段 `3_1``3_5`,以及第四阶段 `4_1``4_2`
- 学习中的课程:`2_2_文件与目录操作` - 学习中的课程:
- 已创建课程目录:第一阶段全部课程,以及 `02_python进阶/2_1_模块与包/``02_python进阶/2_2_文件与目录操作/` - 已创建课程目录:第一阶段全部课程、第二阶段全部课程、第三阶段 `3_1``3_5`,以及第四阶段 `4_1``4_2`
- 下一步:完成文件与目录操作练习,然后学习异常处理 - 下一步:进入 `4_3_SQLAlchemy基础`,学习 Engine、Session、声明式模型和 ORM 增删改查
## 建议环境 ## 建议环境
@@ -202,19 +229,22 @@ Python/
- Python学习开始时选择当前稳定版本 - Python学习开始时选择当前稳定版本
- 编辑器PyCharm 或 Visual Studio Code - 编辑器PyCharm 或 Visual Studio Code
- 命令行PowerShell - 命令行PowerShell
- 数据库:前期可使用 SQLite后期学习 MySQL 或 PostgreSQL - 数据库:第四阶段统一使用专用的远程 PostgreSQL 练习数据库
- Python 数据库驱动Psycopg 3
- 对象关系映射工具SQLAlchemy 2.x
- 浏览器Chrome、Edge 或其他现代浏览器。 - 浏览器Chrome、Edge 或其他现代浏览器。
具体版本和安装步骤将在第一课中核对并讲解,避免因为版本变化使用过时的安装方式。 具体版本和安装步骤将在第一课中核对并讲解,避免因为版本变化使用过时的安装方式。
## 下一步 ## 下一步
上一课“模块与包”已经完成。当前正在学习文件与目录操作,本课将学习 第三阶段已完成,已经掌握
1. 文件、目录和路径的基本含义 1. 使用 dataclass 建模图书和会员
2. 使用 `Path` 表示和组合路径 2. 使用普通业务类协调借书与还书
3. 创建练习目录 3. 通过组合管理对象关系
4. 写入、追加和读取文本文件 4. 使用自定义异常表达业务失败
5. 检查路径并遍历目录内容。 5. 使用鸭子类型接入通知器;
6. 验证正常流程和主要异常流程。
完成本课练习并确认继续后,下一课将学习异常处理 第四阶段前两课已经完成,能够使用本地 TOML 配置、Psycopg 3 和参数化 SQL 安全访问远程 PostgreSQL并通过转账场景掌握事务提交、异常回滚、批量操作以及 Repository/Service 分层。下一步进入 SQLAlchemy 2.x 基础