Compare commits
10
Commits
b5023b8e25
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c3d6402ea | ||
|
|
bf1f7042a9 | ||
|
|
a28c3b3168 | ||
|
|
f1d9548646 | ||
|
|
b348dc0a1f | ||
|
|
aff05283a2 | ||
|
|
f7c820b0f4 | ||
|
|
691405f380 | ||
|
|
42baf5d317 | ||
|
|
298e5b46ea |
@@ -14,3 +14,6 @@ venv/
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# 数据库课程的本地 TOML 配置包含远程数据库账号和密码,不应提交。
|
||||
04_数据库/**/config.toml
|
||||
|
||||
@@ -0,0 +1,530 @@
|
||||
# 第 1-11 课:集合
|
||||
|
||||
## 一、本课目标
|
||||
|
||||
完成本课后,你将能够:
|
||||
|
||||
1. 理解集合是什么,以及集合适合解决什么问题;
|
||||
2. 使用 `{}` 或 `set()` 创建集合;
|
||||
3. 理解集合中元素唯一、集合本身无序;
|
||||
4. 使用 `add()`、`update()` 添加元素;
|
||||
5. 使用 `remove()`、`discard()`、`pop()` 和 `clear()` 删除元素;
|
||||
6. 使用 `in` 和 `not in` 判断元素是否存在;
|
||||
7. 遍历集合;
|
||||
8. 使用交集、并集、差集和对称差集;
|
||||
9. 使用集合为列表去重;
|
||||
10. 根据实际需求在列表、元组、字典和集合之间做出选择。
|
||||
|
||||
## 二、前置知识
|
||||
|
||||
学习本课前,应当已经了解:
|
||||
|
||||
- 变量与常见数据类型;
|
||||
- 条件判断;
|
||||
- `for` 循环;
|
||||
- 字符串;
|
||||
- 列表和元组;
|
||||
- 字典;
|
||||
- `in` 和 `not in`。
|
||||
|
||||
## 三、集合是什么
|
||||
|
||||
集合(Set)是一种保存多个元素的数据结构。
|
||||
|
||||
它有两个非常重要的特点:
|
||||
|
||||
1. **元素不能重复**;
|
||||
2. **元素没有固定位置**。
|
||||
|
||||
例如,一个 Agent 可以拥有多个工具权限:
|
||||
|
||||
```python
|
||||
agent_tools = {"搜索", "计算", "写作"}
|
||||
```
|
||||
|
||||
如果重复写入同一个工具,集合只会保留一份:
|
||||
|
||||
```python
|
||||
agent_tools = {"搜索", "计算", "搜索"}
|
||||
print(agent_tools)
|
||||
```
|
||||
|
||||
集合中最终只有两个元素。
|
||||
|
||||
集合特别适合处理:
|
||||
|
||||
- 去除重复数据;
|
||||
- 快速判断某个元素是否存在;
|
||||
- 比较两组数据有哪些相同项或不同项;
|
||||
- 权限、标签、课程、技能等不允许重复的数据。
|
||||
|
||||
## 四、创建集合
|
||||
|
||||
### 4.1 使用花括号创建
|
||||
|
||||
```python
|
||||
agent_tools = {"搜索", "计算", "写作"}
|
||||
print(agent_tools)
|
||||
```
|
||||
|
||||
集合使用花括号,但它与字典不同:
|
||||
|
||||
```python
|
||||
# 集合:里面直接保存元素
|
||||
agent_tools = {"搜索", "计算"}
|
||||
|
||||
# 字典:里面保存“键: 值”
|
||||
agent_config = {"model": "gpt-5", "enabled": True}
|
||||
```
|
||||
|
||||
### 4.2 使用 `set()` 创建
|
||||
|
||||
```python
|
||||
agent_tools = set(["搜索", "计算", "搜索"])
|
||||
print(agent_tools)
|
||||
```
|
||||
|
||||
`set()` 会把传入的数据转换成集合,并自动去除重复元素。
|
||||
|
||||
### 4.3 创建空集合
|
||||
|
||||
空集合必须写成:
|
||||
|
||||
```python
|
||||
empty_tools = set()
|
||||
```
|
||||
|
||||
不能写成:
|
||||
|
||||
```python
|
||||
empty_tools = {}
|
||||
```
|
||||
|
||||
因为 `{}` 创建的是空字典,不是空集合。
|
||||
|
||||
可以使用 `type()` 验证:
|
||||
|
||||
```python
|
||||
print(type(set()))
|
||||
print(type({}))
|
||||
```
|
||||
|
||||
## 五、集合为什么不支持下标
|
||||
|
||||
列表和元组中的元素有明确位置,所以可以写:
|
||||
|
||||
```python
|
||||
models = ["gpt-5", "o3"]
|
||||
print(models[0])
|
||||
```
|
||||
|
||||
集合是无序数据结构,不保证每个元素一直处于某个固定位置,因此不能写:
|
||||
|
||||
```python
|
||||
agent_tools = {"搜索", "计算"}
|
||||
print(agent_tools[0]) # 错误
|
||||
```
|
||||
|
||||
这里的“无序”不是说 Python 每次一定用不同顺序显示,而是说:
|
||||
|
||||
> 不应该依赖集合元素的显示顺序,也不能使用下标读取集合元素。
|
||||
|
||||
如果业务需要稳定顺序,应使用列表或元组。
|
||||
|
||||
## 六、集合元素必须唯一
|
||||
|
||||
```python
|
||||
models = {"gpt-5", "o3", "gpt-5"}
|
||||
print(len(models))
|
||||
```
|
||||
|
||||
结果为:
|
||||
|
||||
```text
|
||||
2
|
||||
```
|
||||
|
||||
第二个 `"gpt-5"` 不会产生新元素,也不会报错。
|
||||
|
||||
## 七、判断元素是否存在
|
||||
|
||||
集合经常配合 `in` 和 `not in` 使用:
|
||||
|
||||
```python
|
||||
agent_tools = {"搜索", "计算"}
|
||||
|
||||
print("搜索" in agent_tools)
|
||||
print("写作" not in agent_tools)
|
||||
```
|
||||
|
||||
正常结果:
|
||||
|
||||
```text
|
||||
True
|
||||
True
|
||||
```
|
||||
|
||||
集合非常擅长成员判断。数据较多时,通常比逐个检查列表更合适。
|
||||
|
||||
## 八、添加元素
|
||||
|
||||
### 8.1 使用 `add()` 添加一个元素
|
||||
|
||||
```python
|
||||
agent_tools = {"搜索", "计算"}
|
||||
agent_tools.add("写作")
|
||||
print(agent_tools)
|
||||
```
|
||||
|
||||
如果添加已经存在的元素,集合不会发生变化:
|
||||
|
||||
```python
|
||||
agent_tools.add("搜索")
|
||||
```
|
||||
|
||||
### 8.2 使用 `update()` 添加多个元素
|
||||
|
||||
```python
|
||||
agent_tools = {"搜索"}
|
||||
agent_tools.update(["计算", "写作"])
|
||||
print(agent_tools)
|
||||
```
|
||||
|
||||
注意:
|
||||
|
||||
- `add()` 添加一个完整元素;
|
||||
- `update()` 从列表、元组、集合等可遍历数据中逐个取出元素并添加。
|
||||
|
||||
## 九、删除元素
|
||||
|
||||
### 9.1 `remove()`:元素不存在时会报错
|
||||
|
||||
```python
|
||||
agent_tools = {"搜索", "计算"}
|
||||
agent_tools.remove("搜索")
|
||||
```
|
||||
|
||||
如果继续删除不存在的 `"搜索"`,会出现 `KeyError`,中文意思是找不到该元素。
|
||||
|
||||
### 9.2 `discard()`:元素不存在也不会报错
|
||||
|
||||
```python
|
||||
agent_tools = {"搜索", "计算"}
|
||||
agent_tools.discard("写作")
|
||||
```
|
||||
|
||||
当你不确定元素是否存在时,`discard()` 通常更安全。
|
||||
|
||||
### 9.3 `pop()`:删除并返回一个不确定的元素
|
||||
|
||||
```python
|
||||
agent_tools = {"搜索", "计算"}
|
||||
removed_tool = agent_tools.pop()
|
||||
print(removed_tool)
|
||||
```
|
||||
|
||||
集合没有固定顺序,所以不能依赖 `pop()` 删除某个指定元素。
|
||||
|
||||
如果集合为空,调用 `pop()` 会报错。
|
||||
|
||||
### 9.4 `clear()`:清空集合
|
||||
|
||||
```python
|
||||
agent_tools = {"搜索", "计算"}
|
||||
agent_tools.clear()
|
||||
print(agent_tools)
|
||||
```
|
||||
|
||||
结果是空集合:
|
||||
|
||||
```text
|
||||
set()
|
||||
```
|
||||
|
||||
## 十、遍历集合
|
||||
|
||||
集合可以使用 `for` 循环遍历:
|
||||
|
||||
```python
|
||||
agent_tools = {"搜索", "计算", "写作"}
|
||||
|
||||
for tool in agent_tools:
|
||||
print(tool)
|
||||
```
|
||||
|
||||
变量名 `tool` 可以换成其他合法名称,但应当选择能够表达含义的名字。
|
||||
|
||||
不要依赖遍历顺序。如果必须按顺序输出,可以先使用 `sorted()` 排序:
|
||||
|
||||
```python
|
||||
for tool in sorted(agent_tools):
|
||||
print(tool)
|
||||
```
|
||||
|
||||
`sorted()` 会返回一个排好序的新列表,不会修改原集合。
|
||||
|
||||
## 十一、交集:两组数据共同拥有的元素
|
||||
|
||||
交集可以使用 `&` 或 `intersection()`:
|
||||
|
||||
```python
|
||||
code_agent_tools = {"搜索", "计算", "代码执行"}
|
||||
writer_agent_tools = {"搜索", "写作", "图片生成"}
|
||||
|
||||
common_tools = code_agent_tools & writer_agent_tools
|
||||
print(common_tools)
|
||||
```
|
||||
|
||||
结果:
|
||||
|
||||
```text
|
||||
{'搜索'}
|
||||
```
|
||||
|
||||
也可以写成:
|
||||
|
||||
```python
|
||||
common_tools = code_agent_tools.intersection(writer_agent_tools)
|
||||
```
|
||||
|
||||
## 十二、并集:合并两组数据并自动去重
|
||||
|
||||
并集可以使用 `|` 或 `union()`:
|
||||
|
||||
```python
|
||||
all_tools = code_agent_tools | writer_agent_tools
|
||||
print(all_tools)
|
||||
```
|
||||
|
||||
结果包含两组集合的全部工具,重复的 `"搜索"` 只保留一份。
|
||||
|
||||
## 十三、差集:一组有、另一组没有的元素
|
||||
|
||||
差集使用 `-` 或 `difference()`:
|
||||
|
||||
```python
|
||||
code_only_tools = code_agent_tools - writer_agent_tools
|
||||
print(code_only_tools)
|
||||
```
|
||||
|
||||
它表示代码助手拥有、写作助手没有的工具。
|
||||
|
||||
注意方向:
|
||||
|
||||
```python
|
||||
code_agent_tools - writer_agent_tools
|
||||
```
|
||||
|
||||
和:
|
||||
|
||||
```python
|
||||
writer_agent_tools - code_agent_tools
|
||||
```
|
||||
|
||||
结果通常不同。
|
||||
|
||||
## 十四、对称差集:只属于其中一组的元素
|
||||
|
||||
对称差集使用 `^` 或 `symmetric_difference()`:
|
||||
|
||||
```python
|
||||
different_tools = code_agent_tools ^ writer_agent_tools
|
||||
print(different_tools)
|
||||
```
|
||||
|
||||
它会排除两组共同拥有的元素,只保留各自独有的元素。
|
||||
|
||||
## 十五、集合之间的关系判断
|
||||
|
||||
如果集合 A 中的所有元素都在集合 B 中,A 就是 B 的子集:
|
||||
|
||||
```python
|
||||
required_tools = {"搜索", "计算"}
|
||||
available_tools = {"搜索", "计算", "写作"}
|
||||
|
||||
print(required_tools <= available_tools)
|
||||
```
|
||||
|
||||
结果为 `True`。
|
||||
|
||||
也可以使用:
|
||||
|
||||
```python
|
||||
print(required_tools.issubset(available_tools))
|
||||
print(available_tools.issuperset(required_tools))
|
||||
```
|
||||
|
||||
- 子集(Subset):较小集合中的元素全部包含在较大集合中;
|
||||
- 超集(Superset):较大集合包含较小集合的全部元素。
|
||||
|
||||
这很适合判断“当前权限是否满足所需权限”。
|
||||
|
||||
## 十六、使用集合为列表去重
|
||||
|
||||
```python
|
||||
model_history = ["gpt-5", "o3", "gpt-5", "o3", "gpt-5-mini"]
|
||||
unique_models = set(model_history)
|
||||
print(unique_models)
|
||||
```
|
||||
|
||||
如果最终仍然需要列表,可以再转换:
|
||||
|
||||
```python
|
||||
unique_model_list = list(unique_models)
|
||||
```
|
||||
|
||||
注意:这种方法不保证保留原列表顺序。
|
||||
|
||||
当前阶段如果既要去重又要保留顺序,可以使用:
|
||||
|
||||
```python
|
||||
unique_model_list = []
|
||||
|
||||
for model in model_history:
|
||||
if model not in unique_model_list:
|
||||
unique_model_list.append(model)
|
||||
```
|
||||
|
||||
## 十七、哪些数据可以放入集合
|
||||
|
||||
集合元素必须是不可变且可哈希(Hashable)的数据。
|
||||
|
||||
当前阶段可以简单理解为:
|
||||
|
||||
- 字符串、数字、布尔值、元组通常可以放入集合;
|
||||
- 列表、字典、普通集合不能直接放入集合。
|
||||
|
||||
下面会报错:
|
||||
|
||||
```python
|
||||
invalid_set = {[1, 2], [3, 4]}
|
||||
```
|
||||
|
||||
因为列表可以被修改,不能作为集合元素。
|
||||
|
||||
“哈希”的内部原理将在进阶阶段逐步补充,本课只需要记住常见规则。
|
||||
|
||||
## 十八、列表、元组、字典和集合如何选择
|
||||
|
||||
### 使用列表
|
||||
|
||||
- 需要保留顺序;
|
||||
- 允许重复;
|
||||
- 需要使用下标读取或修改。
|
||||
|
||||
### 使用元组
|
||||
|
||||
- 需要保留顺序;
|
||||
- 允许重复;
|
||||
- 创建后不希望修改整体结构。
|
||||
|
||||
### 使用字典
|
||||
|
||||
- 需要使用“键”描述每个“值”;
|
||||
- 需要通过字段名称快速读取数据。
|
||||
|
||||
### 使用集合
|
||||
|
||||
- 不关心顺序;
|
||||
- 不允许重复;
|
||||
- 经常进行成员判断、去重或集合运算。
|
||||
|
||||
## 十九、运行本课示例
|
||||
|
||||
进入项目根目录:
|
||||
|
||||
```powershell
|
||||
cd D:\Code\Python
|
||||
```
|
||||
|
||||
运行示例:
|
||||
|
||||
```powershell
|
||||
python .\01_python基础\1_11_集合\sets.py
|
||||
```
|
||||
|
||||
运行练习:
|
||||
|
||||
```powershell
|
||||
python .\01_python基础\1_11_集合\practice.py
|
||||
```
|
||||
|
||||
集合的显示顺序可能与讲义不同,只要元素相同就是正常现象。
|
||||
|
||||
## 二十、常见错误
|
||||
|
||||
### 20.1 使用 `{}` 创建空集合
|
||||
|
||||
`{}` 是空字典,空集合必须使用 `set()`。
|
||||
|
||||
### 20.2 使用下标读取集合
|
||||
|
||||
集合不支持下标。如果需要按位置读取,应使用列表或元组。
|
||||
|
||||
### 20.3 使用 `remove()` 删除不存在的元素
|
||||
|
||||
不确定元素是否存在时,可以先用 `in` 判断,或者使用 `discard()`。
|
||||
|
||||
### 20.4 依赖集合的输出顺序
|
||||
|
||||
集合不保证业务上的稳定顺序。需要排序展示时使用 `sorted()`。
|
||||
|
||||
### 20.5 把列表或字典放入集合
|
||||
|
||||
列表和字典是可变数据,不能作为集合元素。
|
||||
|
||||
### 20.6 混淆 `add()` 和 `update()`
|
||||
|
||||
`add()` 添加一个元素;`update()` 从其他可遍历数据中添加多个元素。
|
||||
|
||||
## 二十一、课堂练习
|
||||
|
||||
打开:
|
||||
|
||||
```text
|
||||
01_python基础/1_11_集合/practice.py
|
||||
```
|
||||
|
||||
本课练习将创建一个“Agent 工具权限管理器”,内容包括:
|
||||
|
||||
1. 创建集合;
|
||||
2. 检查、添加和删除权限;
|
||||
3. 遍历权限;
|
||||
4. 比较两组权限的交集、并集、差集和对称差集;
|
||||
5. 判断所需权限是否是可用权限的子集;
|
||||
6. 为模型使用记录去重。
|
||||
|
||||
请先独立完成。遇到问题时,我会先给出定位提示,不会直接覆盖你的答案。
|
||||
|
||||
## 二十二、思考题
|
||||
|
||||
1. 为什么 `{}` 不能表示空集合?
|
||||
2. 为什么不能使用 `agent_tools[0]` 读取集合?
|
||||
3. `remove()` 和 `discard()` 有什么区别?
|
||||
4. `A - B` 与 `B - A` 为什么可能不同?
|
||||
5. 使用集合为列表去重时,可能丢失什么信息?
|
||||
|
||||
## 二十三、本课小结
|
||||
|
||||
本课最重要的知识点:
|
||||
|
||||
1. 集合使用 `set` 类型表示;
|
||||
2. 集合元素唯一,且没有可依赖的固定顺序;
|
||||
3. 空集合必须使用 `set()` 创建;
|
||||
4. `add()` 添加一个元素,`update()` 添加多个元素;
|
||||
5. `remove()` 删除不存在的元素会报错,`discard()` 不会;
|
||||
6. 集合支持交集、并集、差集和对称差集;
|
||||
7. 集合适合去重、成员判断和权限比较。
|
||||
|
||||
## 二十四、验收标准
|
||||
|
||||
完成练习后,应满足:
|
||||
|
||||
- 能正确创建非空集合和空集合;
|
||||
- 能解释集合为什么不支持下标;
|
||||
- 能使用 `in` 判断元素;
|
||||
- 能正确添加、删除和遍历集合元素;
|
||||
- 能计算两组集合的交集、并集、差集和对称差集;
|
||||
- 能判断子集关系;
|
||||
- 能使用集合完成列表去重;
|
||||
- 示例和练习均可正常运行。
|
||||
@@ -0,0 +1,104 @@
|
||||
# 第 1-11 课课堂练习:Agent 工具权限管理器
|
||||
#
|
||||
# 完成要求:
|
||||
# 1. 根据注释完成集合的创建、成员判断、添加、删除、遍历和集合运算。
|
||||
# 2. 使用有意义的英文蛇形变量名。
|
||||
# 3. 不要依赖集合的显示顺序。
|
||||
# 4. 删除可能不存在的元素时使用 discard(),或先使用 in 判断。
|
||||
# 5. 不要删除题目注释。
|
||||
# 6. 完成后运行本文件并核对预期结果。
|
||||
|
||||
# 练习一:创建集合 agent_tools,包含以下工具:
|
||||
# "搜索"、"计算"、"写作"、"搜索"。
|
||||
# 输出集合和元素数量,观察重复的“搜索”是否只保留一份。
|
||||
|
||||
|
||||
# 练习二:创建空集合 disabled_tools。
|
||||
# 输出它的值和类型,确认类型是 set,而不是 dict。
|
||||
|
||||
|
||||
# 练习三:完成成员判断:
|
||||
# 1. 判断 agent_tools 中是否存在“搜索”,保存到 can_search;
|
||||
# 2. 判断 agent_tools 中是否不存在“图片生成”,保存到 cannot_generate_image;
|
||||
# 3. 输出两个判断结果。
|
||||
|
||||
|
||||
# 练习四:使用 add() 向 agent_tools 添加“代码执行”。
|
||||
# 再次使用 add() 添加已经存在的“搜索”。
|
||||
# 输出添加后的集合和元素数量,确认没有产生重复元素。
|
||||
|
||||
|
||||
# 练习五:使用 update() 一次添加“图片生成”和“网页浏览”。
|
||||
# 传给 update() 的数据可以使用列表。
|
||||
# 输出更新后的集合。
|
||||
|
||||
|
||||
# 练习六:完成安全删除:
|
||||
# 1. 使用 remove() 删除确定存在的“网页浏览”;
|
||||
# 2. 使用 discard() 删除不存在的“语音识别”;
|
||||
# 3. 输出删除后的集合,确认程序没有报错。
|
||||
|
||||
|
||||
# 练习七:使用 for 遍历 agent_tools。
|
||||
# 为了让输出顺序稳定,请遍历 sorted(agent_tools)。
|
||||
# 每行按照“可用工具:搜索”的格式输出。
|
||||
|
||||
|
||||
# 练习八:创建两个集合:
|
||||
# code_agent_tools = {"搜索", "计算", "代码执行"}
|
||||
# writer_agent_tools = {"搜索", "写作", "图片生成"}
|
||||
# 计算并输出:
|
||||
# 1. 交集 common_tools;
|
||||
# 2. 并集 all_tools;
|
||||
# 3. 代码助手独有的差集 code_only_tools;
|
||||
# 4. 对称差集 different_tools。
|
||||
|
||||
|
||||
# 练习九:创建以下两个集合:
|
||||
# required_tools = {"搜索", "计算"}
|
||||
# available_tools = {"搜索", "计算", "写作"}
|
||||
# 使用 <= 判断 required_tools 是否是 available_tools 的子集,
|
||||
# 将结果保存到 has_required_tools 并输出。
|
||||
|
||||
|
||||
# 练习十:为模型使用记录去重:
|
||||
# model_history = ["gpt-5", "o3", "gpt-5", "o3", "gpt-5-mini"]
|
||||
# 将列表转换成集合 unique_models 并输出;
|
||||
# 再把集合转换成列表 unique_model_list 并输出。
|
||||
# 注意:转换后的顺序可能与原列表不同。
|
||||
|
||||
|
||||
# 练习十一:创建集合 current_permissions:
|
||||
# {"读取配置", "修改配置", "执行任务"}
|
||||
# 再创建集合 revoked_permissions:
|
||||
# {"修改配置", "删除配置"}
|
||||
# 使用差集得到 remaining_permissions,
|
||||
# 表示撤销相关权限后仍然保留的权限,并输出结果。
|
||||
|
||||
|
||||
# 预期核心结果:
|
||||
# agent_tools 中重复的“搜索”只保留一份;
|
||||
# disabled_tools 的类型是 set;
|
||||
# can_search = True;
|
||||
# cannot_generate_image = True;
|
||||
# 重复执行 add("搜索") 不会增加元素数量;
|
||||
# discard() 删除不存在的元素时不会报错;
|
||||
# common_tools = {"搜索"};
|
||||
# all_tools 包含两组 Agent 的全部工具;
|
||||
# code_only_tools = {"计算", "代码执行"};
|
||||
# different_tools 不包含共同拥有的“搜索”;
|
||||
# has_required_tools = True;
|
||||
# unique_models 只有三个元素;
|
||||
# remaining_permissions = {"读取配置", "执行任务"}。
|
||||
|
||||
|
||||
# 完成后进行自查:
|
||||
# 1. 空集合是否使用 set() 创建;
|
||||
# 2. 是否理解集合会自动去除重复元素;
|
||||
# 3. add() 和 update() 是否按要求分别使用;
|
||||
# 4. 删除不存在的元素时是否避免了 KeyError;
|
||||
# 5. 遍历时是否没有依赖集合自身的顺序;
|
||||
# 6. 是否能区分交集、并集、差集和对称差集;
|
||||
# 7. 是否理解差集运算的左右方向;
|
||||
# 8. 是否能使用子集判断检查权限;
|
||||
# 9. 是否理解集合去重可能打乱原列表顺序。
|
||||
@@ -0,0 +1,116 @@
|
||||
# 第 1-11 课示例:集合
|
||||
#
|
||||
# 本文件集中演示集合的创建、成员判断、增删、遍历和集合运算。
|
||||
# 集合没有可依赖的固定顺序,因此你看到的元素显示顺序可能不同。
|
||||
|
||||
|
||||
print("一、创建集合")
|
||||
|
||||
# 集合使用花括号保存多个不重复的元素。
|
||||
agent_tools = {"搜索", "计算", "写作"}
|
||||
print(f"Agent 工具:{agent_tools}")
|
||||
print(f"工具数量:{len(agent_tools)}")
|
||||
|
||||
# 重复元素只会保留一份。
|
||||
models = {"gpt-5", "o3", "gpt-5"}
|
||||
print(f"自动去重后的模型:{models}")
|
||||
|
||||
# 空集合必须使用 set(),因为 {} 表示空字典。
|
||||
empty_tools = set()
|
||||
print(f"空集合:{empty_tools}")
|
||||
print(f"空集合类型:{type(empty_tools)}")
|
||||
print(f"空字典类型:{type({})}")
|
||||
|
||||
|
||||
print("\n二、成员判断")
|
||||
|
||||
print(f"是否拥有搜索工具:{'搜索' in agent_tools}")
|
||||
print(f"是否没有图片生成工具:{'图片生成' not in agent_tools}")
|
||||
|
||||
|
||||
print("\n三、添加元素")
|
||||
|
||||
# add() 每次添加一个元素。
|
||||
agent_tools.add("代码执行")
|
||||
print(f"添加一个工具后:{agent_tools}")
|
||||
|
||||
# 重复添加不会报错,也不会产生重复元素。
|
||||
agent_tools.add("搜索")
|
||||
print(f"重复添加搜索后:{agent_tools}")
|
||||
|
||||
# update() 可以从列表等可遍历数据中添加多个元素。
|
||||
agent_tools.update(["图片生成", "网页浏览"])
|
||||
print(f"添加多个工具后:{agent_tools}")
|
||||
|
||||
|
||||
print("\n四、删除元素")
|
||||
|
||||
# remove() 用于删除确定存在的元素。
|
||||
agent_tools.remove("网页浏览")
|
||||
print(f"remove() 删除后:{agent_tools}")
|
||||
|
||||
# discard() 在元素不存在时也不会报错。
|
||||
agent_tools.discard("不存在的工具")
|
||||
print(f"discard() 安全删除后:{agent_tools}")
|
||||
|
||||
# pop() 会删除并返回一个不确定的元素。
|
||||
# 因为集合无序,不应猜测它会删除哪一个。
|
||||
temporary_tools = {"工具甲", "工具乙"}
|
||||
removed_tool = temporary_tools.pop()
|
||||
print(f"pop() 删除的元素:{removed_tool}")
|
||||
print(f"pop() 删除后的集合:{temporary_tools}")
|
||||
|
||||
|
||||
print("\n五、遍历集合")
|
||||
|
||||
# sorted() 会返回排好序的新列表,方便得到稳定的展示结果。
|
||||
for tool in sorted(agent_tools):
|
||||
print(f"可用工具:{tool}")
|
||||
|
||||
|
||||
print("\n六、集合运算")
|
||||
|
||||
code_agent_tools = {"搜索", "计算", "代码执行"}
|
||||
writer_agent_tools = {"搜索", "写作", "图片生成"}
|
||||
|
||||
# 交集:两组集合共同拥有的元素。
|
||||
common_tools = code_agent_tools & writer_agent_tools
|
||||
print(f"共同工具:{common_tools}")
|
||||
|
||||
# 并集:两组集合的全部元素,重复项只保留一份。
|
||||
all_tools = code_agent_tools | writer_agent_tools
|
||||
print(f"全部工具:{all_tools}")
|
||||
|
||||
# 差集:左侧集合有、右侧集合没有的元素。
|
||||
code_only_tools = code_agent_tools - writer_agent_tools
|
||||
writer_only_tools = writer_agent_tools - code_agent_tools
|
||||
print(f"代码助手独有工具:{code_only_tools}")
|
||||
print(f"写作助手独有工具:{writer_only_tools}")
|
||||
|
||||
# 对称差集:只属于其中一组、不属于两组公共部分的元素。
|
||||
different_tools = code_agent_tools ^ writer_agent_tools
|
||||
print(f"两组不同的工具:{different_tools}")
|
||||
|
||||
|
||||
print("\n七、子集与超集")
|
||||
|
||||
required_tools = {"搜索", "计算"}
|
||||
available_tools = {"搜索", "计算", "写作"}
|
||||
|
||||
has_required_tools = required_tools <= available_tools
|
||||
print(f"现有权限是否满足要求:{has_required_tools}")
|
||||
print(f"required_tools 是否为子集:{required_tools.issubset(available_tools)}")
|
||||
print(f"available_tools 是否为超集:{available_tools.issuperset(required_tools)}")
|
||||
|
||||
|
||||
print("\n八、使用集合去重")
|
||||
|
||||
model_history = ["gpt-5", "o3", "gpt-5", "o3", "gpt-5-mini"]
|
||||
unique_models = set(model_history)
|
||||
print(f"原始模型记录:{model_history}")
|
||||
print(f"去重后的模型集合:{unique_models}")
|
||||
|
||||
# 如果后续操作必须使用列表,可以通过 list() 转换回来。
|
||||
# 转换后的列表不保证保留原列表的先后顺序。
|
||||
unique_model_list = list(unique_models)
|
||||
print(f"转换后的模型列表:{unique_model_list}")
|
||||
@@ -0,0 +1,300 @@
|
||||
# 第 1-12 课:函数
|
||||
|
||||
## 一、本课目标
|
||||
|
||||
完成本课后,你将能够:
|
||||
|
||||
1. 理解函数是什么,以及为什么需要函数;
|
||||
2. 使用 `def` 定义函数;
|
||||
3. 调用函数并传递参数;
|
||||
4. 使用返回值把结果交给调用者;
|
||||
5. 区分“打印结果”和“返回结果”;
|
||||
6. 使用默认参数和关键字参数;
|
||||
7. 理解局部变量和全局变量的基本区别;
|
||||
8. 为简单函数编写文档字符串;
|
||||
9. 把重复代码整理成可复用的函数。
|
||||
|
||||
## 二、前置知识
|
||||
|
||||
- 变量和数据类型;
|
||||
- 条件判断;
|
||||
- `for` 循环;
|
||||
- 字符串、列表、字典和集合;
|
||||
- 基本的布尔判断。
|
||||
|
||||
## 三、函数是什么
|
||||
|
||||
函数(Function)是一段有名字、可以重复执行的代码。
|
||||
|
||||
例如,下面的代码每次都能完成同一件事:
|
||||
|
||||
```python
|
||||
def say_hello():
|
||||
print("你好,Python!")
|
||||
```
|
||||
|
||||
定义函数不会立即执行函数体。只有调用它时,代码才会运行:
|
||||
|
||||
```python
|
||||
say_hello()
|
||||
```
|
||||
|
||||
可以把函数理解为一个“工具盒”:
|
||||
|
||||
- 定义函数:制作工具;
|
||||
- 调用函数:使用工具;
|
||||
- 参数:交给工具的材料;
|
||||
- 返回值:工具加工后的结果。
|
||||
|
||||
## 四、定义和调用函数
|
||||
|
||||
基本格式:
|
||||
|
||||
```python
|
||||
def 函数名():
|
||||
函数体
|
||||
```
|
||||
|
||||
注意:
|
||||
|
||||
1. `def` 是定义函数的关键字;
|
||||
2. 函数名建议使用英文蛇形命名;
|
||||
3. 函数体必须缩进;
|
||||
4. 函数定义后需要通过 `函数名()` 调用。
|
||||
|
||||
示例:
|
||||
|
||||
```python
|
||||
def print_separator():
|
||||
print("=" * 30)
|
||||
|
||||
|
||||
print_separator()
|
||||
```
|
||||
|
||||
## 五、参数:把数据交给函数
|
||||
|
||||
### 5.1 一个参数
|
||||
|
||||
```python
|
||||
def greet_user(name):
|
||||
print(f"你好,{name}!")
|
||||
|
||||
|
||||
greet_user("小明")
|
||||
```
|
||||
|
||||
`name` 是参数(Parameter),`"小明"` 是调用时传入的实参(Argument)。
|
||||
|
||||
### 5.2 多个参数
|
||||
|
||||
```python
|
||||
def print_agent(name, model):
|
||||
print(f"Agent:{name},模型:{model}")
|
||||
|
||||
|
||||
print_agent("代码助手", "gpt-5")
|
||||
```
|
||||
|
||||
参数按位置传递时,顺序很重要。
|
||||
|
||||
### 5.3 关键字参数
|
||||
|
||||
也可以明确写出参数名:
|
||||
|
||||
```python
|
||||
print_agent(model="gpt-5", name="代码助手")
|
||||
```
|
||||
|
||||
使用关键字参数后,调用顺序可以调整,可读性也更好。
|
||||
|
||||
## 六、返回值:把结果交给调用者
|
||||
|
||||
`return` 用于结束函数执行,并把结果返回给调用者:
|
||||
|
||||
```python
|
||||
def add_numbers(first_number, second_number):
|
||||
return first_number + second_number
|
||||
|
||||
|
||||
total = add_numbers(3, 5)
|
||||
print(total)
|
||||
```
|
||||
|
||||
`return` 后面的结果可以保存到变量、参与计算或传给其他函数。
|
||||
|
||||
## 七、打印和返回的区别
|
||||
|
||||
下面两个函数看起来相似,但用途不同:
|
||||
|
||||
```python
|
||||
def print_total(first_number, second_number):
|
||||
print(first_number + second_number)
|
||||
|
||||
|
||||
def get_total(first_number, second_number):
|
||||
return first_number + second_number
|
||||
```
|
||||
|
||||
前者只负责显示,调用者不能直接拿到计算结果;后者把结果交给调用者,更适合程序继续处理。
|
||||
|
||||
```python
|
||||
result = get_total(3, 5)
|
||||
print(result * 2)
|
||||
```
|
||||
|
||||
如果函数没有写 `return`,Python 会自动返回 `None`,表示“没有结果”。
|
||||
|
||||
## 八、默认参数
|
||||
|
||||
可以为参数准备默认值:
|
||||
|
||||
```python
|
||||
def create_agent(name, model="gpt-5"):
|
||||
return f"{name} 使用 {model}"
|
||||
|
||||
|
||||
print(create_agent("代码助手"))
|
||||
print(create_agent("搜索助手", "o3"))
|
||||
```
|
||||
|
||||
调用时不传 `model`,就使用 `"gpt-5"`;传入了新值,就使用新值。
|
||||
|
||||
默认参数通常放在普通参数后面。
|
||||
|
||||
## 九、函数中的条件和循环
|
||||
|
||||
函数体可以包含前面学过的条件和循环:
|
||||
|
||||
```python
|
||||
def count_enabled_agents(agents):
|
||||
enabled_count = 0
|
||||
|
||||
for agent in agents:
|
||||
if agent["enabled"]:
|
||||
enabled_count += 1
|
||||
|
||||
return enabled_count
|
||||
```
|
||||
|
||||
函数的价值之一,就是把一段有明确目的的处理逻辑集中起来。
|
||||
|
||||
## 十、变量作用域
|
||||
|
||||
作用域(Scope)表示变量可以被使用的范围。
|
||||
|
||||
```python
|
||||
message = "这是全局变量"
|
||||
|
||||
|
||||
def show_message():
|
||||
local_message = "这是局部变量"
|
||||
print(message)
|
||||
print(local_message)
|
||||
|
||||
|
||||
show_message()
|
||||
```
|
||||
|
||||
- 在函数外定义的变量通常称为全局变量;
|
||||
- 在函数内定义的变量通常是局部变量;
|
||||
- 函数执行结束后,局部变量通常不能在函数外直接使用。
|
||||
|
||||
入门阶段建议:通过参数传入数据,通过 `return` 返回结果,少依赖全局变量。
|
||||
|
||||
## 十一、文档字符串
|
||||
|
||||
函数第一行的字符串可以作为文档字符串(Docstring),说明函数的用途:
|
||||
|
||||
```python
|
||||
def is_agent_enabled(agent):
|
||||
"""判断 Agent 是否处于启用状态。"""
|
||||
return agent["enabled"]
|
||||
```
|
||||
|
||||
文档字符串会帮助自己和其他开发者理解函数。
|
||||
|
||||
## 十二、运行本课示例
|
||||
|
||||
进入项目根目录:
|
||||
|
||||
```powershell
|
||||
cd D:\Code\Python
|
||||
```
|
||||
|
||||
运行示例:
|
||||
|
||||
```powershell
|
||||
python .\01_python基础\1_12_函数\functions.py
|
||||
```
|
||||
|
||||
运行练习:
|
||||
|
||||
```powershell
|
||||
python .\01_python基础\1_12_函数\practice.py
|
||||
```
|
||||
|
||||
## 十三、常见错误
|
||||
|
||||
### 13.1 忘记调用函数
|
||||
|
||||
只写 `def` 是定义,不会自动执行,必须写 `函数名()`。
|
||||
|
||||
### 13.2 函数体没有缩进
|
||||
|
||||
`def` 下一行开始的函数体必须缩进,通常使用四个空格。
|
||||
|
||||
### 13.3 参数数量不匹配
|
||||
|
||||
函数需要两个参数却只传一个,会出现 `TypeError`,中文意思是参数数量或类型不正确。
|
||||
|
||||
### 13.4 把 `print()` 当成 `return`
|
||||
|
||||
`print()` 负责显示,`return` 负责把结果交给调用者。
|
||||
|
||||
### 13.5 忘记接收返回值
|
||||
|
||||
函数返回的结果如果后续还要使用,应保存到变量中。
|
||||
|
||||
### 13.6 过度使用全局变量
|
||||
|
||||
优先使用参数和返回值传递数据,让函数更容易测试和复用。
|
||||
|
||||
## 十四、课堂练习
|
||||
|
||||
打开:
|
||||
|
||||
```text
|
||||
01_python基础/1_12_函数/practice.py
|
||||
```
|
||||
|
||||
本课练习将创建“Agent 文本处理工具箱”,逐步完成:
|
||||
|
||||
1. 无参数问候函数;
|
||||
2. 带参数的 Agent 信息函数;
|
||||
3. 返回字符串的函数;
|
||||
4. 带默认参数的配置函数;
|
||||
5. 使用条件和循环统计 Agent;
|
||||
6. 使用函数整理工具权限和模型记录。
|
||||
|
||||
## 十五、本课小结
|
||||
|
||||
1. 函数是可重复使用的代码块;
|
||||
2. `def` 定义函数,调用函数时使用括号;
|
||||
3. 参数用于输入数据;
|
||||
4. `return` 用于返回结果;
|
||||
5. `print()` 和 `return` 的用途不同;
|
||||
6. 默认参数可以减少重复传值;
|
||||
7. 局部变量和全局变量的作用范围不同;
|
||||
8. 函数应尽量职责单一、名称清晰。
|
||||
|
||||
## 十六、验收标准
|
||||
|
||||
- 能独立定义并调用函数;
|
||||
- 能正确传递一个或多个参数;
|
||||
- 能区分打印和返回;
|
||||
- 能使用默认参数;
|
||||
- 能从函数外接收返回值;
|
||||
- 能使用函数处理列表或字典;
|
||||
- 示例和练习均可正常运行;
|
||||
- 函数名和变量名符合英文蛇形命名规范。
|
||||
@@ -0,0 +1,56 @@
|
||||
# 第 1-12 课示例:函数
|
||||
#
|
||||
# 本文件演示函数的定义、调用、参数、返回值、默认参数和实际数据处理。
|
||||
|
||||
|
||||
def print_separator():
|
||||
"""输出分隔线,让命令行结果更容易阅读。"""
|
||||
print("=" * 30)
|
||||
|
||||
|
||||
def greet_user(name):
|
||||
"""根据姓名输出问候语。"""
|
||||
print(f"你好,{name}!")
|
||||
|
||||
|
||||
def build_agent_description(name, model="gpt-5"):
|
||||
"""返回 Agent 的文字描述,而不是直接打印。"""
|
||||
return f"Agent:{name},模型:{model}"
|
||||
|
||||
|
||||
def count_enabled_agents(agents):
|
||||
"""统计列表中 enabled 为 True 的 Agent 数量。"""
|
||||
enabled_count = 0
|
||||
|
||||
for agent in agents:
|
||||
if agent["enabled"]:
|
||||
enabled_count += 1
|
||||
|
||||
return enabled_count
|
||||
|
||||
|
||||
def get_available_tools(agent):
|
||||
"""返回 Agent 的工具列表;没有 tools 时返回空列表。"""
|
||||
return agent.get("tools", [])
|
||||
|
||||
|
||||
print_separator()
|
||||
greet_user("Python 学习者")
|
||||
|
||||
print_separator()
|
||||
print(build_agent_description("代码助手"))
|
||||
print(build_agent_description("搜索助手", model="o3"))
|
||||
|
||||
print_separator()
|
||||
agents = [
|
||||
{"name": "代码助手", "enabled": True, "tools": ["搜索", "计算"]},
|
||||
{"name": "搜索助手", "enabled": False, "tools": ["搜索"]},
|
||||
{"name": "写作助手", "enabled": True, "tools": ["写作"]},
|
||||
]
|
||||
|
||||
enabled_count = count_enabled_agents(agents)
|
||||
print(f"启用的 Agent 数量:{enabled_count}")
|
||||
|
||||
for agent in agents:
|
||||
tools = get_available_tools(agent)
|
||||
print(f"{agent['name']} 的工具:{tools}")
|
||||
@@ -0,0 +1,145 @@
|
||||
# 第 1-12 课课堂练习:Agent 文本处理工具箱
|
||||
#
|
||||
# 完成要求:
|
||||
# 1. 根据注释定义和调用函数,练习参数与返回值。
|
||||
# 2. 使用有意义的英文蛇形函数名和变量名。
|
||||
# 3. 函数体使用四个空格缩进。
|
||||
# 4. 不要删除题目注释。
|
||||
# 5. 完成后运行本文件并核对预期结果。
|
||||
|
||||
|
||||
# 练习一:定义并调用一个负责输出问候语的函数。
|
||||
# 1. 定义 greet_user(name) 函数,参数 name 表示需要问候的姓名。
|
||||
# 2. 函数内部使用 print() 输出“你好,{name}!”,本题不需要使用 return。
|
||||
# 3. 调用函数时传入字符串“Python 学习者”。
|
||||
# 4. 预期输出:你好,Python 学习者!
|
||||
# 可参考 functions.py 中 greet_user(name) 的写法。
|
||||
|
||||
|
||||
# 练习二:定义一个用于生成 Agent 描述文本的函数。
|
||||
# 1. 定义 build_agent_description(name, model) 函数。
|
||||
# 参数 name 表示 Agent 名称,参数 model 表示模型名称。
|
||||
# 2. 函数内部不要直接输出,而是使用 return 返回字符串:
|
||||
# “Agent:{name},模型:{model}”。
|
||||
# 3. 使用“代码助手”和“gpt-5”作为参数调用函数。
|
||||
# 4. 把返回值保存到变量 description,再使用 print(description) 输出。
|
||||
# 5. 预期输出:Agent:代码助手,模型:gpt-5
|
||||
# 可参考 functions.py 中 build_agent_description(name, model) 的写法。
|
||||
|
||||
|
||||
|
||||
# 练习三:定义一个计算两个数字之和的函数。
|
||||
# 1. 定义 get_total(first_number, second_number) 函数,两个参数都接收数字。
|
||||
# 2. 在函数内部计算 first_number + second_number,并使用 return 返回计算结果。
|
||||
# 3. 调用函数计算 12 和 8,把返回值保存到变量 total。
|
||||
# 4. 使用 print(total) 输出结果,预期输出:20。
|
||||
# 注意:本题要练习返回值,因此不要只在函数内部 print() 计算结果。
|
||||
|
||||
|
||||
|
||||
# 练习四:定义一个带默认参数的 Agent 描述函数。
|
||||
# 1. 定义 build_agent_description_with_default(name, model="gpt-5") 函数。
|
||||
# 2. 函数使用 return 返回“Agent:{name},模型:{model}”。
|
||||
# 3. 第一次只传入 name="代码助手",把返回值保存并输出;此时 model 自动使用“gpt-5”。
|
||||
# 4. 第二次传入 name="代码助手"和关键字参数 model="o3",再保存并输出返回值。
|
||||
# 5. 两次预期输出分别为:
|
||||
# Agent:代码助手,模型:gpt-5
|
||||
# Agent:代码助手,模型:o3
|
||||
# 可参考 functions.py 中默认参数和关键字参数的示例。
|
||||
|
||||
|
||||
|
||||
# 练习五:定义一个安全读取 Agent 工具列表的函数。
|
||||
# 1. 定义 get_agent_tools(agent) 函数,参数 agent 接收一个字典。
|
||||
# 2. 函数内部使用 return agent.get("tools", []):
|
||||
# 字典存在 tools 键时返回对应的列表,不存在时返回默认的空列表 []。
|
||||
# 3. 创建第一个测试字典:
|
||||
# {"name": "代码助手", "tools": ["搜索", "计算器"]}
|
||||
# 4. 创建第二个测试字典:{"name": "聊天助手"},它没有 tools 键。
|
||||
# 5. 分别调用函数并用 print() 输出返回值。
|
||||
# 6. 两次预期输出分别为:["搜索", "计算器"] 和 []。
|
||||
# 可参考 functions.py 中 get_available_tools(agent) 的写法。
|
||||
|
||||
|
||||
|
||||
# 练习六:定义一个统计已启用 Agent 数量的函数。
|
||||
# 1. 定义 count_enabled_agents(agents) 函数,参数 agents 接收“由字典组成的列表”。
|
||||
# 2. 在函数内先创建计数变量 enabled_count,并把初始值设为 0。
|
||||
# 3. 遍历 agents;如果当前字典的 agent["enabled"] 为 True,就把计数加 1。
|
||||
# 4. 遍历结束后,使用 return 返回 enabled_count。不要遇到 False 就提前结束循环。
|
||||
# 5. 使用下面三条数据测试:
|
||||
# {"name": "代码助手", "enabled": True}
|
||||
# {"name": "聊天助手", "enabled": False}
|
||||
# {"name": "搜索助手", "enabled": True}
|
||||
# 6. 保存并输出函数返回值,预期输出:2。
|
||||
# 可参考 functions.py 中 count_enabled_agents(agents) 的写法。
|
||||
|
||||
|
||||
|
||||
# 练习七:定义一个获取所有已启用 Agent 名称的函数。
|
||||
# 1. 定义 get_enabled_agent_names(agents) 函数,agents 的数据结构与练习六相同。
|
||||
# 2. 在函数内创建空列表 enabled_names,用于保存符合条件的名称。
|
||||
# 3. 遍历 agents;当 agent["enabled"] 为 True 时,把 agent["name"] 添加到列表。
|
||||
# 4. 遍历结束后,使用 return 返回 enabled_names。
|
||||
# 5. 使用练习六给出的三条 Agent 数据调用函数,并使用 print() 输出返回值。
|
||||
# 6. 预期输出:["代码助手", "搜索助手"],停用的“聊天助手”不应出现在结果中。
|
||||
|
||||
# 练习八:定义一个判断 Agent 是否具备全部必需工具的函数。
|
||||
# 1. 定义 has_required_tools(agent, required_tools) 函数:
|
||||
# agent 接收 Agent 字典,required_tools 接收“必需工具集合”。
|
||||
# 2. 使用 agent.get("tools", []) 读取工具列表,再用 set() 转换为集合,
|
||||
# 并把这个集合保存到局部变量 agent_tools。
|
||||
# 3. 使用 required_tools <= agent_tools 判断“必需工具集合是否为 Agent 工具集合的子集”。
|
||||
# 只有每一个必需工具都存在时,表达式才会得到 True。
|
||||
# 4. 使用 return 返回判断结果,不要只在函数内部输出。
|
||||
# 5. 创建测试 Agent:
|
||||
# {"name": "代码助手", "tools": ["搜索", "计算器", "终端"]}
|
||||
# 6. 第一次传入必需工具集合 {"搜索", "终端"},保存并输出结果,预期为 True。
|
||||
# 7. 第二次传入必需工具集合 {"搜索", "数据库"},保存并输出结果,预期为 False。
|
||||
# 可回顾上一课“集合”中的子集判断,也可参考本课练习五的字典读取方式。
|
||||
|
||||
|
||||
# 练习九:定义一个逐行输出 Agent 报告的函数。
|
||||
# 1. 定义 print_agent_report(agents) 函数,agents 接收“由字典组成的列表”。
|
||||
# 2. 每个字典都包含 name、enabled 和 tools 三个键,例如:
|
||||
# {"name": "代码助手", "enabled": True, "tools": ["搜索", "终端"]}
|
||||
# {"name": "聊天助手", "enabled": False, "tools": []}
|
||||
# 3. 遍历 agents,根据 enabled 的布尔值生成中文状态:True 对应“启用”,False 对应“停用”。
|
||||
# 4. 使用 len(agent["tools"]) 取得工具数量。
|
||||
# 5. 在函数内部使用 print() 输出每个 Agent,本题只负责显示报告,不需要 return。
|
||||
# 6. 调用函数后,预期输出:
|
||||
# 代码助手:启用,工具数量:2
|
||||
# 聊天助手:停用,工具数量:0
|
||||
|
||||
|
||||
# 练习十:定义一个对模型使用记录去重并排序的函数。
|
||||
# 1. 定义 normalize_model_history(model_history) 函数,参数接收模型名称列表。
|
||||
# 2. 先使用 set(model_history) 去除重复名称。
|
||||
# 3. 再把去重后的集合传给 sorted();sorted() 会返回一个排好序的新列表。
|
||||
# 4. 使用 return 返回这个新列表,不要只在函数内部输出。
|
||||
# 5. 使用 ["gpt-5", "o3", "gpt-5", "claude"] 调用函数,保存并输出返回值。
|
||||
# 6. 预期输出:["claude", "gpt-5", "o3"]。
|
||||
# 可回顾上一课“集合”中的去重方式和 sorted() 的用法。
|
||||
|
||||
|
||||
# 预期核心结果:
|
||||
# greet_user() 能输出问候语;
|
||||
# build_agent_description() 能返回可保存的字符串;
|
||||
# get_total(12, 8) 返回 20;
|
||||
# 默认参数调用时使用 gpt-5,关键字参数调用时使用 o3;
|
||||
# 没有 tools 的 Agent 返回空列表;
|
||||
# count_enabled_agents() 返回 2;
|
||||
# get_enabled_agent_names() 只返回启用 Agent 的名称;
|
||||
# 权限满足判断返回 True;
|
||||
# 报告函数能正确输出每个 Agent 的状态和工具数量;
|
||||
# 模型记录去重后按排序结果返回。
|
||||
|
||||
|
||||
# 完成后进行自查:
|
||||
# 1. 是否使用 def 定义函数;
|
||||
# 2. 是否正确传递位置参数和关键字参数;
|
||||
# 3. 需要继续使用的结果是否使用 return;
|
||||
# 4. 是否区分 print() 和 return;
|
||||
# 5. 默认参数是否放在普通参数后面;
|
||||
# 6. 函数内的局部变量是否没有误当作全局变量;
|
||||
# 7. 函数是否尽量只负责一个清晰的任务。
|
||||
@@ -0,0 +1,381 @@
|
||||
# 第 1-13 课:函数进阶
|
||||
|
||||
## 一、本课目标
|
||||
|
||||
完成本课后,你将能够:
|
||||
|
||||
1. 使用 `return` 一次返回多个结果;
|
||||
2. 使用提前返回减少不必要的嵌套;
|
||||
3. 使用 `*args` 接收任意数量的位置参数;
|
||||
4. 使用 `**kwargs` 接收任意数量的关键字参数;
|
||||
5. 使用 `*` 和 `**` 在调用函数时解包数据;
|
||||
6. 根据不同任务选择普通参数、`*args` 或 `**kwargs`;
|
||||
7. 继续练习让函数保持清晰、职责单一。
|
||||
|
||||
## 二、前置知识
|
||||
|
||||
学习本课前,需要理解:
|
||||
|
||||
- 如何使用 `def` 定义函数;
|
||||
- 如何调用函数;
|
||||
- 位置参数、关键字参数和默认参数;
|
||||
- `print()` 与 `return` 的区别;
|
||||
- 列表、元组和字典;
|
||||
- 条件判断和循环。
|
||||
|
||||
## 三、一次返回多个结果
|
||||
|
||||
一个函数可以在 `return` 后面写多个值:
|
||||
|
||||
```python
|
||||
def get_agent_summary(agent):
|
||||
return agent["name"], len(agent.get("tools", []))
|
||||
|
||||
|
||||
agent_name, tool_count = get_agent_summary({
|
||||
"name": "代码助手",
|
||||
"tools": ["搜索", "终端"],
|
||||
})
|
||||
|
||||
print(agent_name)
|
||||
print(tool_count)
|
||||
```
|
||||
|
||||
Python 实际上会先把多个返回值组合成一个元组,再把元组解包给多个变量。
|
||||
|
||||
也可以只用一个变量接收:
|
||||
|
||||
```python
|
||||
summary = get_agent_summary({"name": "聊天助手", "tools": []})
|
||||
print(summary)
|
||||
print(type(summary))
|
||||
```
|
||||
|
||||
此时 `summary` 是元组,例如 `("聊天助手", 0)`。
|
||||
|
||||
## 四、提前返回
|
||||
|
||||
`return` 不只负责返回结果,还会立即结束当前函数。
|
||||
|
||||
```python
|
||||
def get_agent_status(agent):
|
||||
if not agent.get("enabled", False):
|
||||
return "停用"
|
||||
|
||||
return "启用"
|
||||
```
|
||||
|
||||
如果 Agent 没有启用,函数执行第一个 `return` 后就结束,不会继续执行下面的代码。
|
||||
|
||||
这种写法称为提前返回(Early Return)。它适合先处理无效数据或特殊情况,避免出现太多层缩进。
|
||||
|
||||
## 五、使用 `*args` 接收多个位置参数
|
||||
|
||||
有时无法提前确定调用者会传入几个位置参数,这时可以使用 `*args`:
|
||||
|
||||
```python
|
||||
def count_tools(*tools):
|
||||
return len(tools)
|
||||
|
||||
|
||||
print(count_tools("搜索"))
|
||||
print(count_tools("搜索", "终端", "计算器"))
|
||||
```
|
||||
|
||||
这里的 `args` 是 arguments 的缩写,表示“多个参数”。
|
||||
|
||||
需要注意:
|
||||
|
||||
- 参数名前面的 `*` 才是关键;
|
||||
- `args` 是约定俗成的名称,也可以换成其他名称;
|
||||
- 函数内部的 `tools` 是一个元组;
|
||||
- 即使没有传入任何工具,`tools` 也是空元组。
|
||||
|
||||
```python
|
||||
def show_tools(*tools):
|
||||
print(tools)
|
||||
print(type(tools))
|
||||
```
|
||||
|
||||
## 六、普通参数和 `*args` 一起使用
|
||||
|
||||
普通参数可以写在 `*args` 前面:
|
||||
|
||||
```python
|
||||
def build_tool_message(agent_name, *tools):
|
||||
return f"{agent_name} 拥有 {len(tools)} 个工具"
|
||||
|
||||
|
||||
message = build_tool_message("代码助手", "搜索", "终端")
|
||||
print(message)
|
||||
```
|
||||
|
||||
调用时,第一个位置参数交给 `agent_name`,剩余位置参数都被收集到 `tools` 元组中。
|
||||
|
||||
## 七、使用 `**kwargs` 接收多个关键字参数
|
||||
|
||||
如果无法提前确定调用者会传入哪些关键字参数,可以使用 `**kwargs`:
|
||||
|
||||
```python
|
||||
def build_agent_config(**config):
|
||||
return config
|
||||
|
||||
|
||||
agent_config = build_agent_config(
|
||||
name="代码助手",
|
||||
model="gpt-5",
|
||||
enabled=True,
|
||||
)
|
||||
|
||||
print(agent_config)
|
||||
```
|
||||
|
||||
`kwargs` 是 keyword arguments 的缩写,中文可理解为“多个关键字参数”。
|
||||
|
||||
函数内部的 `config` 是字典:
|
||||
|
||||
```python
|
||||
{
|
||||
"name": "代码助手",
|
||||
"model": "gpt-5",
|
||||
"enabled": True,
|
||||
}
|
||||
```
|
||||
|
||||
同样,参数名前面的两个星号 `**` 才是关键,`kwargs` 只是常见命名。
|
||||
|
||||
## 八、普通参数和 `**kwargs` 一起使用
|
||||
|
||||
```python
|
||||
def create_agent(name, **settings):
|
||||
return {
|
||||
"name": name,
|
||||
"settings": settings,
|
||||
}
|
||||
|
||||
|
||||
agent = create_agent("代码助手", model="gpt-5", enabled=True)
|
||||
print(agent)
|
||||
```
|
||||
|
||||
`name` 接收普通参数,其他关键字参数被收集到 `settings` 字典中。
|
||||
|
||||
## 九、调用函数时使用 `*` 解包列表或元组
|
||||
|
||||
星号也可以出现在函数调用中。此时它表示把列表或元组中的元素依次作为位置参数传入。
|
||||
|
||||
```python
|
||||
def add_three_numbers(first, second, third):
|
||||
return first + second + third
|
||||
|
||||
|
||||
numbers = [10, 20, 30]
|
||||
total = add_three_numbers(*numbers)
|
||||
print(total)
|
||||
```
|
||||
|
||||
上面的调用相当于:
|
||||
|
||||
```python
|
||||
total = add_three_numbers(10, 20, 30)
|
||||
```
|
||||
|
||||
元素数量必须与函数所需的位置参数数量匹配,否则会产生 `TypeError`。
|
||||
|
||||
## 十、调用函数时使用 `**` 解包字典
|
||||
|
||||
两个星号可以把字典解包成关键字参数:
|
||||
|
||||
```python
|
||||
def describe_agent(name, model):
|
||||
return f"{name} 使用 {model}"
|
||||
|
||||
|
||||
agent_data = {
|
||||
"name": "代码助手",
|
||||
"model": "gpt-5",
|
||||
}
|
||||
|
||||
description = describe_agent(**agent_data)
|
||||
print(description)
|
||||
```
|
||||
|
||||
上面的调用相当于:
|
||||
|
||||
```python
|
||||
description = describe_agent(name="代码助手", model="gpt-5")
|
||||
```
|
||||
|
||||
字典的键必须与函数参数名一致。
|
||||
|
||||
## 十一、如何选择参数形式
|
||||
|
||||
### 使用普通参数
|
||||
|
||||
参数数量固定、含义明确时,优先使用普通参数:
|
||||
|
||||
```python
|
||||
def get_total(first_number, second_number):
|
||||
return first_number + second_number
|
||||
```
|
||||
|
||||
### 使用 `*args`
|
||||
|
||||
需要接收数量不固定的同类位置参数时使用:
|
||||
|
||||
```python
|
||||
def count_tools(*tools):
|
||||
return len(tools)
|
||||
```
|
||||
|
||||
### 使用 `**kwargs`
|
||||
|
||||
需要接收数量或名称不固定的配置项时使用:
|
||||
|
||||
```python
|
||||
def build_config(**config):
|
||||
return config
|
||||
```
|
||||
|
||||
不要为了显得代码高级而强行使用 `*args` 或 `**kwargs`。参数固定时,普通参数通常更清楚。
|
||||
|
||||
## 十二、完整示例
|
||||
|
||||
本课示例文件为:
|
||||
|
||||
```text
|
||||
01_python基础/1_13_函数进阶/advanced_functions.py
|
||||
```
|
||||
|
||||
示例会依次演示:
|
||||
|
||||
1. 返回多个结果;
|
||||
2. 提前返回;
|
||||
3. 使用 `*args`;
|
||||
4. 使用 `**kwargs`;
|
||||
5. 解包列表和字典后调用函数。
|
||||
|
||||
## 十三、运行方法
|
||||
|
||||
在项目根目录执行:
|
||||
|
||||
```powershell
|
||||
python .\01_python基础\1_13_函数进阶\advanced_functions.py
|
||||
```
|
||||
|
||||
运行练习:
|
||||
|
||||
```powershell
|
||||
python .\01_python基础\1_13_函数进阶\practice.py
|
||||
```
|
||||
|
||||
## 十四、示例运行结果
|
||||
|
||||
```text
|
||||
一、返回多个结果
|
||||
Agent 名称:代码助手
|
||||
工具数量:2
|
||||
==============================
|
||||
二、提前返回
|
||||
启用
|
||||
停用
|
||||
==============================
|
||||
三、任意数量的位置参数
|
||||
工具数量:3
|
||||
==============================
|
||||
四、任意数量的关键字参数
|
||||
{'name': '代码助手', 'model': 'gpt-5', 'enabled': True}
|
||||
==============================
|
||||
五、调用时解包数据
|
||||
数字总和:60
|
||||
代码助手 使用 gpt-5
|
||||
```
|
||||
|
||||
字典的显示格式可能因环境而略有差异,但键和值应当一致。
|
||||
|
||||
## 十五、关键代码解析
|
||||
|
||||
```python
|
||||
def count_tools(*tools):
|
||||
return len(tools)
|
||||
```
|
||||
|
||||
执行顺序如下:
|
||||
|
||||
1. 调用者传入零个或多个位置参数;
|
||||
2. `*` 把这些参数收集到 `tools`;
|
||||
3. `tools` 在函数内部是元组;
|
||||
4. `len()` 统计元组中的元素数量;
|
||||
5. `return` 把数量交给调用者。
|
||||
|
||||
```python
|
||||
def build_agent_config(**config):
|
||||
return config
|
||||
```
|
||||
|
||||
执行顺序如下:
|
||||
|
||||
1. 调用者传入零个或多个关键字参数;
|
||||
2. `**` 把参数名称和值收集到 `config`;
|
||||
3. `config` 在函数内部是字典;
|
||||
4. 函数把字典返回给调用者。
|
||||
|
||||
## 十六、常见错误
|
||||
|
||||
### 16.1 混淆 `*args` 和 `**kwargs`
|
||||
|
||||
- `*args` 收集位置参数,函数内部得到元组;
|
||||
- `**kwargs` 收集关键字参数,函数内部得到字典。
|
||||
|
||||
### 16.2 忘记星号
|
||||
|
||||
`args` 和 `kwargs` 本身只是普通名称。真正产生收集效果的是 `*` 和 `**`。
|
||||
|
||||
### 16.3 解包后的参数数量不匹配
|
||||
|
||||
如果函数需要三个参数,列表却只有两个元素,会产生 `TypeError`,表示调用时提供的参数数量不正确。
|
||||
|
||||
### 16.4 字典键与参数名不一致
|
||||
|
||||
使用 `**dictionary` 调用函数时,字典键必须能对应函数参数名。
|
||||
|
||||
### 16.5 在 `return` 后继续编写必须执行的代码
|
||||
|
||||
函数执行到 `return` 就会结束,写在同一执行路径后面的代码不会运行。
|
||||
|
||||
### 16.6 所有函数都使用可变参数
|
||||
|
||||
固定且明确的数据应继续使用普通参数。过度使用可变参数会降低可读性。
|
||||
|
||||
## 十七、课堂练习
|
||||
|
||||
打开:
|
||||
|
||||
```text
|
||||
01_python基础/1_13_函数进阶/practice.py
|
||||
```
|
||||
|
||||
练习题已经明确提供输入数据、实现步骤、返回要求和预期结果。请先独立完成,再进行验证。
|
||||
|
||||
## 十八、本课小结
|
||||
|
||||
1. `return value1, value2` 可以返回多个结果;
|
||||
2. 多个返回值实际会组合成元组;
|
||||
3. 提前返回可以尽早结束函数;
|
||||
4. `*args` 收集位置参数并形成元组;
|
||||
5. `**kwargs` 收集关键字参数并形成字典;
|
||||
6. 调用函数时,`*` 可以解包列表或元组;
|
||||
7. 调用函数时,`**` 可以解包字典;
|
||||
8. 参数固定时应优先使用普通参数。
|
||||
|
||||
## 十九、验收标准
|
||||
|
||||
- 能正确接收函数的多个返回值;
|
||||
- 能解释 `return` 为什么会结束函数;
|
||||
- 能使用 `*args` 接收不同数量的位置参数;
|
||||
- 能说明 `*args` 在函数内部是元组;
|
||||
- 能使用 `**kwargs` 接收关键字参数;
|
||||
- 能说明 `**kwargs` 在函数内部是字典;
|
||||
- 能使用 `*` 解包列表或元组后调用函数;
|
||||
- 能使用 `**` 解包字典后调用函数;
|
||||
- 示例程序和练习程序可以正常运行。
|
||||
@@ -0,0 +1,100 @@
|
||||
# 第 1-13 课示例:函数进阶
|
||||
#
|
||||
# 本文件依次演示:
|
||||
# 1. 返回多个结果;
|
||||
# 2. 提前返回;
|
||||
# 3. 使用 *args 接收任意数量的位置参数;
|
||||
# 4. 使用 **kwargs 接收任意数量的关键字参数;
|
||||
# 5. 在调用函数时解包列表和字典。
|
||||
|
||||
|
||||
def print_separator():
|
||||
"""输出分隔线,让不同示例的结果更容易区分。"""
|
||||
print("=" * 30)
|
||||
|
||||
|
||||
def get_agent_summary(agent):
|
||||
"""返回 Agent 名称和工具数量。"""
|
||||
agent_name = agent["name"]
|
||||
tool_count = len(agent.get("tools", []))
|
||||
return agent_name, tool_count
|
||||
|
||||
|
||||
def get_agent_status(agent):
|
||||
"""根据 Agent 的 enabled 配置返回中文状态。"""
|
||||
# get() 的第二个参数 False 是默认值。
|
||||
# 如果字典中没有 enabled,就把它当作未启用处理。
|
||||
if not agent.get("enabled", False):
|
||||
return "停用"
|
||||
|
||||
# 前面的条件成立时,函数已经提前结束。
|
||||
# 因此执行到这里时,可以确定 Agent 已经启用。
|
||||
return "启用"
|
||||
|
||||
|
||||
def count_tools(*tools):
|
||||
"""返回调用者传入的工具数量。"""
|
||||
# tools 在函数内部是元组,可以使用 len() 统计元素数量。
|
||||
return len(tools)
|
||||
|
||||
|
||||
def build_agent_config(**config):
|
||||
"""把关键字参数收集成 Agent 配置字典并返回。"""
|
||||
# config 在函数内部是字典。
|
||||
return config
|
||||
|
||||
|
||||
def add_three_numbers(first, second, third):
|
||||
"""返回三个数字的和。"""
|
||||
return first + second + third
|
||||
|
||||
|
||||
def describe_agent(name, model):
|
||||
"""返回包含 Agent 名称和模型的描述文本。"""
|
||||
return f"{name} 使用 {model}"
|
||||
|
||||
|
||||
print("一、返回多个结果")
|
||||
example_agent = {
|
||||
"name": "代码助手",
|
||||
"tools": ["搜索", "终端"],
|
||||
}
|
||||
agent_name, tool_count = get_agent_summary(example_agent)
|
||||
print(f"Agent 名称:{agent_name}")
|
||||
print(f"工具数量:{tool_count}")
|
||||
|
||||
print_separator()
|
||||
|
||||
print("二、提前返回")
|
||||
print(get_agent_status({"name": "代码助手", "enabled": True}))
|
||||
print(get_agent_status({"name": "聊天助手", "enabled": False}))
|
||||
|
||||
print_separator()
|
||||
|
||||
print("三、任意数量的位置参数")
|
||||
available_tool_count = count_tools("搜索", "终端", "计算器")
|
||||
print(f"工具数量:{available_tool_count}")
|
||||
|
||||
print_separator()
|
||||
|
||||
print("四、任意数量的关键字参数")
|
||||
agent_config = build_agent_config(
|
||||
name="代码助手",
|
||||
model="gpt-5",
|
||||
enabled=True,
|
||||
)
|
||||
print(agent_config)
|
||||
|
||||
print_separator()
|
||||
|
||||
print("五、调用时解包数据")
|
||||
numbers = [10, 20, 30]
|
||||
total = add_three_numbers(*numbers)
|
||||
print(f"数字总和:{total}")
|
||||
|
||||
agent_data = {
|
||||
"name": "代码助手",
|
||||
"model": "gpt-5",
|
||||
}
|
||||
description = describe_agent(**agent_data)
|
||||
print(description)
|
||||
@@ -0,0 +1,119 @@
|
||||
# 第 1-13 课课堂练习:Agent 函数工具箱进阶
|
||||
#
|
||||
# 完成要求:
|
||||
# 1. 按照每道题给出的函数名、参数、测试数据和预期结果完成代码。
|
||||
# 2. 需要继续使用的结果必须通过 return 返回,再由调用者使用 print() 输出。
|
||||
# 3. 使用有意义的英文蛇形函数名和变量名。
|
||||
# 4. 函数体使用四个空格缩进。
|
||||
# 5. 不要删除题目注释。
|
||||
# 6. 完成后运行本文件,并逐项核对预期结果。
|
||||
|
||||
|
||||
# 练习一:定义一个返回多个结果的函数。
|
||||
# 1. 定义 get_model_summary(models) 函数,参数 models 接收模型名称列表。
|
||||
# 2. 函数使用 len(models) 得到模型数量。
|
||||
# 3. 使用 models[0] 得到第一个模型名称。
|
||||
# 4. 使用一个 return 同时返回“模型数量”和“第一个模型名称”。
|
||||
# 5. 使用 ["gpt-5", "o3", "claude"] 调用函数,并用两个变量接收返回值。
|
||||
# 6. 分别输出两个变量,预期输出:3 和 gpt-5。
|
||||
|
||||
|
||||
# 练习二:观察多个返回值的真实数据类型。
|
||||
# 1. 再次调用练习一的 get_model_summary()。
|
||||
# 2. 这次只使用一个变量 summary 接收全部返回值。
|
||||
# 3. 输出 summary 和 type(summary)。
|
||||
# 4. 预期 summary 为 (3, "gpt-5"),类型为 tuple。
|
||||
|
||||
|
||||
# 练习三:使用提前返回处理无效数据。
|
||||
# 1. 定义 calculate_average(scores) 函数,scores 接收分数列表。
|
||||
# 2. 如果 scores 是空列表,立即使用 return 返回 0。
|
||||
# 3. 如果列表非空,返回 sum(scores) / len(scores)。
|
||||
# sum() 用于计算列表中所有数字的总和。
|
||||
# 4. 第一次使用 [80, 90, 100] 调用,保存并输出结果,预期为 90.0。
|
||||
# 5. 第二次使用空列表 [] 调用,保存并输出结果,预期为 0。
|
||||
|
||||
|
||||
# 练习四:使用 *args 接收任意数量的工具。
|
||||
# 1. 定义 count_agent_tools(*tools) 函数。
|
||||
# 2. 函数内部使用 len(tools) 统计工具数量,并使用 return 返回。
|
||||
# 3. 调用 count_agent_tools("搜索", "终端", "计算器"),保存并输出结果。
|
||||
# 4. 再调用一次 count_agent_tools(),本次不传任何工具。
|
||||
# 5. 两次预期输出分别为 3 和 0。
|
||||
# 可参考 advanced_functions.py 中 count_tools(*tools) 的写法。
|
||||
|
||||
|
||||
# 练习五:确认 *args 在函数内部是元组。
|
||||
# 1. 定义 collect_models(*models) 函数。
|
||||
# 2. 函数直接使用 return 返回 models。
|
||||
# 3. 传入 "gpt-5"、"o3" 和 "claude" 调用函数,把返回值保存到 collected_models。
|
||||
# 4. 输出 collected_models 和它的类型。
|
||||
# 5. 预期输出内容为 ("gpt-5", "o3", "claude"),类型为 tuple。
|
||||
|
||||
|
||||
# 练习六:组合普通参数和 *args。
|
||||
# 1. 定义 build_agent_tool_message(agent_name, *tools) 函数。
|
||||
# 2. 使用 return 返回:"{agent_name} 拥有 {len(tools)} 个工具"。
|
||||
# 3. 使用 "代码助手"、"搜索"、"终端" 调用函数,保存并输出返回值。
|
||||
# 4. 预期输出:代码助手 拥有 2 个工具。
|
||||
|
||||
|
||||
# 练习七:使用 **kwargs 收集关键字参数。
|
||||
# 1. 定义 build_runtime_config(**settings) 函数。
|
||||
# 2. 函数直接使用 return 返回 settings。
|
||||
# 3. 使用以下三个关键字参数调用函数:
|
||||
# model="gpt-5"、timeout=30、enabled=True。
|
||||
# 4. 把返回值保存到 runtime_config,并输出它和它的类型。
|
||||
# 5. 预期字典包含三个对应键值,类型为 dict。
|
||||
# 可参考 advanced_functions.py 中 build_agent_config(**config) 的写法。
|
||||
|
||||
|
||||
# 练习八:组合普通参数和 **kwargs。
|
||||
# 1. 定义 create_agent(name, **settings) 函数。
|
||||
# 2. 函数返回一个字典,结构必须是:
|
||||
# {"name": name, "settings": settings}
|
||||
# 3. 使用 name="代码助手"、model="gpt-5"、enabled=True 调用函数。
|
||||
# 4. 保存并输出返回值。
|
||||
# 5. 预期结果中的 name 为“代码助手”,settings 是包含 model 和 enabled 的字典。
|
||||
|
||||
|
||||
# 练习九:使用 * 解包列表后调用函数。
|
||||
# 1. 定义 multiply_three_numbers(first, second, third) 函数。
|
||||
# 2. 函数使用 return 返回三个数字的乘积。
|
||||
# 3. 创建列表 numbers = [2, 3, 4]。
|
||||
# 4. 使用 multiply_three_numbers(*numbers) 调用函数,而不是手动写三个参数。
|
||||
# 5. 保存并输出返回值,预期输出:24。
|
||||
|
||||
|
||||
# 练习十:使用 ** 解包字典后调用函数。
|
||||
# 1. 定义 build_model_message(name, model) 函数。
|
||||
# 2. 函数使用 return 返回:"{name} 使用模型 {model}"。
|
||||
# 3. 创建字典:
|
||||
# agent_data = {"name": "搜索助手", "model": "o3"}
|
||||
# 4. 使用 build_model_message(**agent_data) 调用函数。
|
||||
# 5. 保存并输出返回值,预期输出:搜索助手 使用模型 o3。
|
||||
|
||||
|
||||
# 预期核心结果:
|
||||
# 练习一分别得到 3 和 gpt-5;
|
||||
# 练习二确认多个返回值组成 tuple;
|
||||
# 非空分数列表的平均分为 90.0,空列表结果为 0;
|
||||
# count_agent_tools() 分别返回 3 和 0;
|
||||
# collect_models() 返回元组;
|
||||
# Agent 工具消息显示 2 个工具;
|
||||
# **kwargs 收集到的数据类型为 dict;
|
||||
# create_agent() 返回包含 name 和 settings 的嵌套字典;
|
||||
# 三个数字的乘积为 24;
|
||||
# 字典解包后生成“搜索助手 使用模型 o3”。
|
||||
|
||||
|
||||
# 完成后进行自查:
|
||||
# 1. 多个返回值是否使用多个变量正确接收;
|
||||
# 2. 只用一个变量接收多个返回值时,是否得到元组;
|
||||
# 3. 空列表是否通过提前返回避免了除以零;
|
||||
# 4. *args 在函数内部是否被当作元组使用;
|
||||
# 5. **kwargs 在函数内部是否被当作字典使用;
|
||||
# 6. 普通参数是否写在 *args 或 **kwargs 前面;
|
||||
# 7. 调用时的 * 是否正确解包列表;
|
||||
# 8. 调用时的 ** 是否正确解包字典;
|
||||
# 9. 需要继续使用的计算结果是否通过 return 返回。
|
||||
@@ -0,0 +1,366 @@
|
||||
# 第 1-14 课:Python 基础综合项目
|
||||
|
||||
## 一、项目名称
|
||||
|
||||
命令行 Agent 管理系统。
|
||||
|
||||
命令行程序是直接在终端中运行和操作的程序。本项目不会创建网页界面,而是通过数字菜单接收用户选择。
|
||||
|
||||
## 二、为什么要做综合项目
|
||||
|
||||
前面的课程分别学习了变量、条件判断、循环、数据结构和函数。单独完成一道小题,只需要使用其中一个知识点;真实程序通常需要把多个知识点组合起来。
|
||||
|
||||
本项目用于检验你能否:
|
||||
|
||||
1. 把一个较大的需求拆分成多个小功能;
|
||||
2. 选择合适的数据结构保存信息;
|
||||
3. 使用函数组织重复逻辑;
|
||||
4. 使用循环让菜单持续运行;
|
||||
5. 使用条件判断处理不同操作;
|
||||
6. 对用户输入进行基本检查;
|
||||
7. 独立完成一个可以反复操作的命令行程序。
|
||||
|
||||
## 三、前置知识
|
||||
|
||||
开始前应当掌握:
|
||||
|
||||
- `print()` 和 `input()`;
|
||||
- 字符串、整数和布尔值;
|
||||
- 比较运算与逻辑运算;
|
||||
- `if...elif...else`;
|
||||
- `for` 和 `while` 循环;
|
||||
- 列表、字典和集合;
|
||||
- 函数、参数和返回值;
|
||||
- `*args` 和 `**kwargs` 的基本含义。
|
||||
|
||||
本项目主要使用普通参数,不会为了展示高级语法而强行使用 `*args` 或 `**kwargs`。
|
||||
|
||||
## 四、项目最终功能
|
||||
|
||||
程序启动后显示以下菜单:
|
||||
|
||||
```text
|
||||
==============================
|
||||
命令行 Agent 管理系统
|
||||
1. 查看全部 Agent
|
||||
2. 新增 Agent
|
||||
3. 修改 Agent 状态
|
||||
4. 删除 Agent
|
||||
5. 查看 Agent 工具
|
||||
0. 退出程序
|
||||
==============================
|
||||
```
|
||||
|
||||
用户输入菜单编号后,程序执行对应功能:
|
||||
|
||||
- 输入 `1`:查看全部 Agent;
|
||||
- 输入 `2`:新增 Agent;
|
||||
- 输入 `3`:启用或停用 Agent;
|
||||
- 输入 `4`:删除 Agent;
|
||||
- 输入 `5`:查看指定 Agent 的工具;
|
||||
- 输入 `0`:结束程序;
|
||||
- 输入其他内容:显示“无效选项,请重新输入。”。
|
||||
|
||||
## 五、数据结构设计
|
||||
|
||||
所有 Agent 使用列表保存:
|
||||
|
||||
```python
|
||||
agents = [
|
||||
{
|
||||
"name": "代码助手",
|
||||
"model": "gpt-5",
|
||||
"enabled": True,
|
||||
"tools": ["搜索", "终端"],
|
||||
},
|
||||
{
|
||||
"name": "聊天助手",
|
||||
"model": "o3",
|
||||
"enabled": False,
|
||||
"tools": ["搜索"],
|
||||
},
|
||||
]
|
||||
```
|
||||
|
||||
选择这种结构的原因:
|
||||
|
||||
- 列表适合保存数量可以增加或减少的多条 Agent 记录;
|
||||
- 每个 Agent 有多个不同含义的字段,适合使用字典;
|
||||
- `tools` 是一组可以按顺序展示的工具,适合使用列表。
|
||||
|
||||
## 六、程序功能拆分
|
||||
|
||||
不要把所有代码都写进一个 `while` 循环。项目应拆分为以下函数。
|
||||
|
||||
### 6.1 `print_menu()`
|
||||
|
||||
职责:输出操作菜单。
|
||||
|
||||
- 参数:无;
|
||||
- 返回值:无;
|
||||
- 主要操作:使用多条 `print()` 输出菜单。
|
||||
|
||||
### 6.2 `print_agents(agents)`
|
||||
|
||||
职责:输出全部 Agent。
|
||||
|
||||
- 参数:Agent 列表;
|
||||
- 返回值:无;
|
||||
- 空列表时输出“当前没有 Agent。”;
|
||||
- 非空时输出每个 Agent 的编号、名称、模型和状态。
|
||||
|
||||
预期格式:
|
||||
|
||||
```text
|
||||
1. 代码助手|模型:gpt-5|状态:启用
|
||||
2. 聊天助手|模型:o3|状态:停用
|
||||
```
|
||||
|
||||
### 6.3 `find_agent_by_name(agents, name)`
|
||||
|
||||
职责:按名称查找 Agent。
|
||||
|
||||
- 参数:Agent 列表和需要查找的名称;
|
||||
- 找到时:返回对应的 Agent 字典;
|
||||
- 未找到时:返回 `None`;
|
||||
- 本函数只负责查找,不负责输出提示。
|
||||
|
||||
### 6.4 `add_agent(agents)`
|
||||
|
||||
职责:接收输入并新增 Agent。
|
||||
|
||||
执行顺序:
|
||||
|
||||
1. 输入 Agent 名称,并使用 `strip()` 清理两侧空白;
|
||||
2. 名称为空时输出“Agent 名称不能为空。”并结束本次操作;
|
||||
3. 调用 `find_agent_by_name()` 检查名称是否重复;
|
||||
4. 名称重复时输出“该 Agent 已存在。”并结束本次操作;
|
||||
5. 输入模型名称;
|
||||
6. 输入工具,多个工具使用英文逗号分隔;
|
||||
7. 使用 `split(",")` 拆分工具;
|
||||
8. 清理每个工具两侧空白,并去掉空字符串;
|
||||
9. 创建 Agent 字典,新 Agent 默认启用;
|
||||
10. 使用 `append()` 加入列表并输出成功提示。
|
||||
|
||||
例如输入:
|
||||
|
||||
```text
|
||||
Agent 名称:搜索助手
|
||||
模型名称:gpt-5
|
||||
工具:搜索,网页浏览
|
||||
```
|
||||
|
||||
新增后的字典应为:
|
||||
|
||||
```python
|
||||
{
|
||||
"name": "搜索助手",
|
||||
"model": "gpt-5",
|
||||
"enabled": True,
|
||||
"tools": ["搜索", "网页浏览"],
|
||||
}
|
||||
```
|
||||
|
||||
### 6.5 `change_agent_status(agents)`
|
||||
|
||||
职责:修改指定 Agent 的启用状态。
|
||||
|
||||
执行顺序:
|
||||
|
||||
1. 输入 Agent 名称;
|
||||
2. 调用 `find_agent_by_name()` 查找;
|
||||
3. 未找到时输出“未找到该 Agent。”;
|
||||
4. 找到时使用 `not` 反转 `enabled`;
|
||||
5. 输出修改后的中文状态。
|
||||
|
||||
### 6.6 `delete_agent(agents)`
|
||||
|
||||
职责:删除指定 Agent。
|
||||
|
||||
执行顺序:
|
||||
|
||||
1. 输入 Agent 名称;
|
||||
2. 调用 `find_agent_by_name()` 查找;
|
||||
3. 未找到时输出“未找到该 Agent。”;
|
||||
4. 找到时使用 `remove()` 删除整个 Agent 字典;
|
||||
5. 输出“Agent 已删除。”。
|
||||
|
||||
### 6.7 `print_agent_tools(agents)`
|
||||
|
||||
职责:输出指定 Agent 的工具。
|
||||
|
||||
- 未找到 Agent 时输出“未找到该 Agent。”;
|
||||
- 工具列表为空时输出“该 Agent 暂无工具。”;
|
||||
- 有工具时按编号逐行输出。
|
||||
|
||||
预期格式:
|
||||
|
||||
```text
|
||||
代码助手的工具:
|
||||
1. 搜索
|
||||
2. 终端
|
||||
```
|
||||
|
||||
### 6.8 `main()`
|
||||
|
||||
职责:组织程序主流程。
|
||||
|
||||
执行顺序:
|
||||
|
||||
1. 创建初始 Agent 列表;
|
||||
2. 使用 `while True` 持续显示菜单;
|
||||
3. 使用 `input()` 接收菜单选项;
|
||||
4. 使用 `if...elif...else` 分发功能;
|
||||
5. 用户输入 `0` 时输出结束提示并使用 `break` 退出循环。
|
||||
|
||||
## 七、完整示例
|
||||
|
||||
示例文件:
|
||||
|
||||
```text
|
||||
01_python基础/1_14_python基础综合项目/agent_manager.py
|
||||
```
|
||||
|
||||
示例是完整可运行版本。建议先阅读讲义,再运行示例了解程序行为,最后关闭示例文件,独立完成 `practice.py`。
|
||||
|
||||
## 八、运行方法
|
||||
|
||||
在项目根目录执行示例:
|
||||
|
||||
```powershell
|
||||
python .\01_python基础\1_14_python基础综合项目\agent_manager.py
|
||||
```
|
||||
|
||||
运行自己的项目练习:
|
||||
|
||||
```powershell
|
||||
python .\01_python基础\1_14_python基础综合项目\practice.py
|
||||
```
|
||||
|
||||
程序等待输入时,在终端输入菜单编号并按回车。
|
||||
|
||||
## 九、建议开发顺序
|
||||
|
||||
不要一次写完整个项目。按照以下顺序逐步完成并运行:
|
||||
|
||||
1. 定义初始 Agent 列表;
|
||||
2. 完成并测试 `print_menu()`;
|
||||
3. 完成并测试 `print_agents()`;
|
||||
4. 完成并测试 `find_agent_by_name()`;
|
||||
5. 完成新增功能;
|
||||
6. 完成状态修改功能;
|
||||
7. 完成删除功能;
|
||||
8. 完成工具查看功能;
|
||||
9. 最后编写 `main()` 菜单循环;
|
||||
10. 按验收用例完整测试。
|
||||
|
||||
每完成一个函数就运行一次,可以更早发现问题。
|
||||
|
||||
## 十、验收测试
|
||||
|
||||
### 测试一:查看初始数据
|
||||
|
||||
操作:输入 `1`。
|
||||
|
||||
预期:显示代码助手和聊天助手,状态分别为启用和停用。
|
||||
|
||||
### 测试二:新增 Agent
|
||||
|
||||
操作:
|
||||
|
||||
```text
|
||||
输入菜单:2
|
||||
名称:搜索助手
|
||||
模型:gpt-5
|
||||
工具:搜索,网页浏览
|
||||
```
|
||||
|
||||
再次输入 `1`,预期显示三条 Agent 数据。
|
||||
|
||||
### 测试三:拒绝重复名称
|
||||
|
||||
再次新增“搜索助手”,预期输出“该 Agent 已存在。”,列表数量不增加。
|
||||
|
||||
### 测试四:修改状态
|
||||
|
||||
输入 `3` 并选择“聊天助手”,预期状态由停用变为启用。
|
||||
|
||||
### 测试五:查看工具
|
||||
|
||||
输入 `5` 并选择“代码助手”,预期依次显示“搜索”和“终端”。
|
||||
|
||||
### 测试六:删除 Agent
|
||||
|
||||
输入 `4` 并删除“聊天助手”,再次查看列表时不再显示它。
|
||||
|
||||
### 测试七:无效名称
|
||||
|
||||
查询、修改或删除一个不存在的名称,程序应显示提示且不崩溃。
|
||||
|
||||
### 测试八:无效菜单
|
||||
|
||||
输入 `9` 或任意未知内容,预期输出“无效选项,请重新输入。”,随后继续显示菜单。
|
||||
|
||||
### 测试九:退出程序
|
||||
|
||||
输入 `0`,预期输出“程序已退出。”并正常结束。
|
||||
|
||||
## 十一、常见错误
|
||||
|
||||
### 11.1 在函数内部重新创建列表
|
||||
|
||||
功能函数应修改 `main()` 传入的同一个列表。不要在 `add_agent()` 中重新创建一个无关列表。
|
||||
|
||||
### 11.2 查找失败后仍继续操作
|
||||
|
||||
如果 Agent 不存在,应使用提前返回结束函数,否则继续读取字典可能产生错误。
|
||||
|
||||
### 11.3 把布尔值保存为字符串
|
||||
|
||||
启用状态应使用 `True` 或 `False`,不要使用字符串 `"True"` 或 `"False"`。
|
||||
|
||||
### 11.4 忘记清理用户输入
|
||||
|
||||
名称和工具建议使用 `strip()`,否则多余空格可能导致名称匹配失败。
|
||||
|
||||
### 11.5 菜单无法退出
|
||||
|
||||
处理选项 `0` 时必须执行 `break`,否则 `while True` 会继续运行。
|
||||
|
||||
### 11.6 在导入文件时自动启动程序
|
||||
|
||||
示例最后使用:
|
||||
|
||||
```python
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
```
|
||||
|
||||
这是程序入口判断。本课先理解为:“直接运行当前文件时才调用 `main()`”。模块与包课程会进一步解释。
|
||||
|
||||
## 十二、项目小结
|
||||
|
||||
完成本项目后,你已经把前 13 课的知识组合成了一个完整程序:
|
||||
|
||||
- 字符串保存名称和模型;
|
||||
- 布尔值保存启用状态;
|
||||
- 列表保存多条记录和工具;
|
||||
- 字典表示单个 Agent;
|
||||
- 集合思想用于理解工具去重;
|
||||
- 条件判断分发菜单操作;
|
||||
- 循环维持程序运行并遍历数据;
|
||||
- 函数拆分不同业务功能;
|
||||
- 返回值传递查找结果。
|
||||
|
||||
## 十三、完成标准
|
||||
|
||||
- 所有菜单选项均能正常执行;
|
||||
- 可以新增、查看、修改和删除 Agent;
|
||||
- 可以查看指定 Agent 的工具;
|
||||
- 重复名称不会被新增;
|
||||
- 无效输入不会导致程序崩溃;
|
||||
- 输入 `0` 可以正常退出;
|
||||
- 每个函数只承担一个清晰职责;
|
||||
- 变量名和函数名使用规范英文蛇形命名;
|
||||
- 代码包含必要的中文注释;
|
||||
- 九组验收测试全部通过。
|
||||
@@ -0,0 +1,194 @@
|
||||
# 第 1-14 课完整示例:命令行 Agent 管理系统
|
||||
#
|
||||
# 本项目综合使用变量、字符串、列表、字典、条件判断、循环和函数。
|
||||
# 建议先运行程序体验功能,再按照讲义中的开发顺序阅读代码。
|
||||
|
||||
|
||||
def print_separator():
|
||||
"""输出分隔线,让菜单和结果更容易阅读。"""
|
||||
print("=" * 30)
|
||||
|
||||
|
||||
def print_menu():
|
||||
"""输出主菜单;本函数只负责显示,不需要返回值。"""
|
||||
print_separator()
|
||||
print("命令行 Agent 管理系统")
|
||||
print("1. 查看全部 Agent")
|
||||
print("2. 新增 Agent")
|
||||
print("3. 修改 Agent 状态")
|
||||
print("4. 删除 Agent")
|
||||
print("5. 查看 Agent 工具")
|
||||
print("0. 退出程序")
|
||||
print_separator()
|
||||
|
||||
|
||||
def get_status_text(enabled):
|
||||
"""把布尔状态转换成适合显示的中文文本。"""
|
||||
if enabled:
|
||||
return "启用"
|
||||
|
||||
return "停用"
|
||||
|
||||
|
||||
def print_agents(agents):
|
||||
"""按照统一格式输出列表中的全部 Agent。"""
|
||||
if not agents:
|
||||
print("当前没有 Agent。")
|
||||
return
|
||||
|
||||
print("全部 Agent:")
|
||||
|
||||
for number, agent in enumerate(agents, start=1):
|
||||
status_text = get_status_text(agent["enabled"])
|
||||
print(
|
||||
f"{number}. {agent['name']}|"
|
||||
f"模型:{agent['model']}|"
|
||||
f"状态:{status_text}"
|
||||
)
|
||||
|
||||
|
||||
def find_agent_by_name(agents, name):
|
||||
"""按名称查找 Agent;找到时返回字典,否则返回 None。"""
|
||||
for agent in agents:
|
||||
if agent["name"] == name:
|
||||
return agent
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def parse_tools(tools_text):
|
||||
"""把英文逗号分隔的文本转换成没有空字符串的工具列表。"""
|
||||
tools = []
|
||||
|
||||
for tool in tools_text.split(","):
|
||||
clean_tool = tool.strip()
|
||||
|
||||
# 只添加非空工具,同时避免同一个工具重复出现。
|
||||
if clean_tool and clean_tool not in tools:
|
||||
tools.append(clean_tool)
|
||||
|
||||
return tools
|
||||
|
||||
|
||||
def add_agent(agents):
|
||||
"""接收用户输入,检查名称后新增 Agent。"""
|
||||
name = input("请输入 Agent 名称:").strip()
|
||||
|
||||
if not name:
|
||||
print("Agent 名称不能为空。")
|
||||
return
|
||||
|
||||
if find_agent_by_name(agents, name) is not None:
|
||||
print("该 Agent 已存在。")
|
||||
return
|
||||
|
||||
model = input("请输入模型名称:").strip()
|
||||
tools_text = input("请输入工具,多个工具使用英文逗号分隔:")
|
||||
tools = parse_tools(tools_text)
|
||||
|
||||
new_agent = {
|
||||
"name": name,
|
||||
"model": model,
|
||||
"enabled": True,
|
||||
"tools": tools,
|
||||
}
|
||||
|
||||
agents.append(new_agent)
|
||||
print("Agent 新增成功。")
|
||||
|
||||
|
||||
def change_agent_status(agents):
|
||||
"""查找指定 Agent,并反转它的启用状态。"""
|
||||
name = input("请输入需要修改状态的 Agent 名称:").strip()
|
||||
agent = find_agent_by_name(agents, name)
|
||||
|
||||
if agent is None:
|
||||
print("未找到该 Agent。")
|
||||
return
|
||||
|
||||
agent["enabled"] = not agent["enabled"]
|
||||
status_text = get_status_text(agent["enabled"])
|
||||
print(f"状态修改成功,当前状态:{status_text}。")
|
||||
|
||||
|
||||
def delete_agent(agents):
|
||||
"""查找并删除指定 Agent。"""
|
||||
name = input("请输入需要删除的 Agent 名称:").strip()
|
||||
agent = find_agent_by_name(agents, name)
|
||||
|
||||
if agent is None:
|
||||
print("未找到该 Agent。")
|
||||
return
|
||||
|
||||
agents.remove(agent)
|
||||
print("Agent 已删除。")
|
||||
|
||||
|
||||
def print_agent_tools(agents):
|
||||
"""输出指定 Agent 的全部工具。"""
|
||||
name = input("请输入需要查看工具的 Agent 名称:").strip()
|
||||
agent = find_agent_by_name(agents, name)
|
||||
|
||||
if agent is None:
|
||||
print("未找到该 Agent。")
|
||||
return
|
||||
|
||||
tools = agent.get("tools", [])
|
||||
|
||||
if not tools:
|
||||
print("该 Agent 暂无工具。")
|
||||
return
|
||||
|
||||
print(f"{agent['name']}的工具:")
|
||||
|
||||
for number, tool in enumerate(tools, start=1):
|
||||
print(f"{number}. {tool}")
|
||||
|
||||
|
||||
def create_initial_agents():
|
||||
"""创建程序启动时使用的初始 Agent 数据。"""
|
||||
return [
|
||||
{
|
||||
"name": "代码助手",
|
||||
"model": "gpt-5",
|
||||
"enabled": True,
|
||||
"tools": ["搜索", "终端"],
|
||||
},
|
||||
{
|
||||
"name": "聊天助手",
|
||||
"model": "o3",
|
||||
"enabled": False,
|
||||
"tools": ["搜索"],
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def main():
|
||||
"""创建数据并通过循环组织整个菜单流程。"""
|
||||
agents = create_initial_agents()
|
||||
|
||||
while True:
|
||||
print_menu()
|
||||
choice = input("请输入菜单编号:").strip()
|
||||
|
||||
if choice == "1":
|
||||
print_agents(agents)
|
||||
elif choice == "2":
|
||||
add_agent(agents)
|
||||
elif choice == "3":
|
||||
change_agent_status(agents)
|
||||
elif choice == "4":
|
||||
delete_agent(agents)
|
||||
elif choice == "5":
|
||||
print_agent_tools(agents)
|
||||
elif choice == "0":
|
||||
print("程序已退出。")
|
||||
break
|
||||
else:
|
||||
print("无效选项,请重新输入。")
|
||||
|
||||
|
||||
# 只有直接运行当前文件时才启动菜单。
|
||||
# 后续“模块与包”课程会进一步解释 __name__ 的作用。
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,135 @@
|
||||
# 第 1-14 课课堂练习:命令行 Agent 管理系统
|
||||
#
|
||||
# 这是 Python 基础阶段综合项目,请按照题目顺序逐步完成。
|
||||
# 完成要求:
|
||||
# 1. 每完成一个函数就单独调用并测试,不要等全部写完再运行。
|
||||
# 2. 需要查找结果时使用 return,不要依赖全局变量传递结果。
|
||||
# 3. 函数名和变量名使用有意义的英文蛇形命名。
|
||||
# 4. 函数体使用四个空格缩进,并添加必要的中文注释。
|
||||
# 5. 不要删除题目、测试数据、预期结果和自查注释。
|
||||
# 6. 完成所有功能后,再执行末尾列出的九组验收测试。
|
||||
|
||||
|
||||
# 第一部分:准备初始数据
|
||||
# 创建列表 agents,其中包含以下两个字典:
|
||||
# 1. name="代码助手"、model="gpt-5"、enabled=True、tools=["搜索", "终端"];
|
||||
# 2. name="聊天助手"、model="o3"、enabled=False、tools=["搜索"]。
|
||||
# 完成后临时使用 print(agents) 检查结构,确认每个 Agent 都是一个字典。
|
||||
|
||||
|
||||
# 第二部分:定义 print_menu() 函数
|
||||
# 1. 函数不接收参数,也不需要 return。
|
||||
# 2. 使用 print() 输出以下菜单:
|
||||
# 1. 查看全部 Agent
|
||||
# 2. 新增 Agent
|
||||
# 3. 修改 Agent 状态
|
||||
# 4. 删除 Agent
|
||||
# 5. 查看 Agent 工具
|
||||
# 0. 退出程序
|
||||
# 3. 单独调用一次 print_menu(),确认六个选项均正确显示。
|
||||
|
||||
|
||||
# 第三部分:定义 get_status_text(enabled) 函数
|
||||
# 1. 参数 enabled 接收布尔值。
|
||||
# 2. enabled 为 True 时 return "启用",否则 return "停用"。
|
||||
# 3. 分别传入 True 和 False,保存并输出返回值。
|
||||
# 4. 两次预期输出分别为“启用”和“停用”。
|
||||
|
||||
|
||||
# 第四部分:定义 print_agents(agents) 函数
|
||||
# 1. 参数 agents 接收 Agent 列表,本函数只负责输出,不需要 return。
|
||||
# 2. 如果列表为空,输出“当前没有 Agent。”并提前结束函数。
|
||||
# 3. 如果列表非空,使用 enumerate(agents, start=1) 遍历。
|
||||
# 4. 调用 get_status_text() 得到中文状态。
|
||||
# 5. 每条记录按照以下格式输出:
|
||||
# 1. 代码助手|模型:gpt-5|状态:启用
|
||||
# 6. 分别传入初始 agents 和空列表 [] 测试两个分支。
|
||||
|
||||
# 第五部分:定义 find_agent_by_name(agents, name) 函数
|
||||
# 1. 遍历 agents,比较 agent["name"] 和参数 name。
|
||||
# 2. 找到同名 Agent 时,立即 return 对应的 Agent 字典。
|
||||
# 3. 循环结束仍未找到时,return None。
|
||||
# 4. 查找“代码助手”,预期返回对应字典。
|
||||
# 5. 查找“不存在的助手”,预期返回 None。
|
||||
# 注意:本函数只负责查找,不在函数内部输出“找到”或“未找到”。
|
||||
|
||||
# 第六部分:定义 parse_tools(tools_text) 函数
|
||||
# 1. 参数接收英文逗号分隔的工具文本。
|
||||
# 2. 使用 tools_text.split(",") 拆分字符串。
|
||||
# 3. 遍历拆分结果,对每个工具调用 strip()。
|
||||
# 4. 只把非空且尚未存在的工具加入结果列表。
|
||||
# 5. 使用 "搜索, 终端,搜索, ,计算器" 测试。
|
||||
# 6. 预期返回 ["搜索", "终端", "计算器"]。
|
||||
|
||||
# 第七部分:定义 add_agent(agents) 函数
|
||||
# 1. 输入 Agent 名称并清理两侧空白。
|
||||
# 2. 名称为空时输出“Agent 名称不能为空。”并提前 return。
|
||||
# 3. 调用 find_agent_by_name() 检查名称;重复时输出“该 Agent 已存在。”并 return。
|
||||
# 4. 输入模型名称。
|
||||
# 5. 输入英文逗号分隔的工具文本,并调用 parse_tools() 得到工具列表。
|
||||
# 6. 创建包含 name、model、enabled 和 tools 的字典,新 Agent 默认 enabled=True。
|
||||
# 7. 使用 agents.append() 添加字典,并输出“Agent 新增成功。”。
|
||||
# 8. 新增“搜索助手”后调用 print_agents(),预期列表共有三条记录。
|
||||
|
||||
|
||||
# 第八部分:定义 change_agent_status(agents) 函数
|
||||
# 1. 输入 Agent 名称并调用 find_agent_by_name()。
|
||||
# 2. 未找到时输出“未找到该 Agent。”并提前 return。
|
||||
# 3. 找到时执行 agent["enabled"] = not agent["enabled"]。
|
||||
# 4. 调用 get_status_text() 得到修改后的中文状态并输出。
|
||||
# 5. 修改“聊天助手”后,它的状态应由 False 变为 True。
|
||||
|
||||
|
||||
# 第九部分:定义 delete_agent(agents) 函数
|
||||
# 1. 输入 Agent 名称并调用 find_agent_by_name()。
|
||||
# 2. 未找到时输出“未找到该 Agent。”并提前 return。
|
||||
# 3. 找到时使用 agents.remove(agent) 删除整个字典。
|
||||
# 4. 输出“Agent 已删除。”。
|
||||
# 5. 删除“聊天助手”后调用 print_agents(),确认它不再出现。
|
||||
|
||||
|
||||
# 第十部分:定义 print_agent_tools(agents) 函数
|
||||
# 1. 输入 Agent 名称并调用 find_agent_by_name()。
|
||||
# 2. 未找到时输出“未找到该 Agent。”并提前 return。
|
||||
# 3. 使用 agent.get("tools", []) 安全读取工具列表。
|
||||
# 4. 工具列表为空时输出“该 Agent 暂无工具。”并提前 return。
|
||||
# 5. 有工具时使用 enumerate(tools, start=1) 遍历并按编号输出。
|
||||
# 6. 查看“代码助手”时,预期依次显示“搜索”和“终端”。
|
||||
|
||||
|
||||
# 第十一部分:定义 main() 函数
|
||||
# 1. 在函数内部使用 while True 持续运行菜单。
|
||||
# 2. 每轮先调用 print_menu(),再用 input() 接收菜单编号并调用 strip()。
|
||||
# 3. 使用 if...elif...else 完成以下分发:
|
||||
# "1" 调用 print_agents(agents);
|
||||
# "2" 调用 add_agent(agents);
|
||||
# "3" 调用 change_agent_status(agents);
|
||||
# "4" 调用 delete_agent(agents);
|
||||
# "5" 调用 print_agent_tools(agents);
|
||||
# "0" 输出“程序已退出。”并使用 break 结束循环;
|
||||
# 其他内容输出“无效选项,请重新输入。”。
|
||||
# 4. 在文件最后调用 main(),启动完整程序。
|
||||
|
||||
# 最终验收测试:
|
||||
# 1. 输入 1,确认显示两个初始 Agent;
|
||||
# 2. 输入 2,新增“搜索助手”,再次查看时共有三条记录;
|
||||
# 3. 再次新增“搜索助手”,确认重复名称被拒绝;
|
||||
# 4. 输入 3,把“聊天助手”从停用修改为启用;
|
||||
# 5. 输入 5,确认“代码助手”显示两个工具;
|
||||
# 6. 输入 4,删除“聊天助手”,确认列表中不再显示它;
|
||||
# 7. 使用不存在的名称进行查询、修改或删除,确认程序不崩溃;
|
||||
# 8. 输入 9,确认显示无效选项后菜单继续运行;
|
||||
# 9. 输入 0,确认程序正常退出。
|
||||
|
||||
|
||||
# 完成后进行代码自查:
|
||||
# 1. 是否使用列表保存多个 Agent;
|
||||
# 2. 每个 Agent 是否使用字典表示;
|
||||
# 3. enabled 是否使用真正的布尔值;
|
||||
# 4. find_agent_by_name() 是否返回字典或 None;
|
||||
# 5. 查找失败后是否提前 return;
|
||||
# 6. 是否使用函数拆分不同功能;
|
||||
# 7. 菜单循环是否能够正常退出;
|
||||
# 8. 重复名称是否不会被添加;
|
||||
# 9. 工具文本是否完成拆分、清理和去重;
|
||||
# 10. 九组验收测试是否全部通过。
|
||||
@@ -65,20 +65,3 @@
|
||||
# 3. 是否正确使用了 and 和 not;
|
||||
# 4. += 是否更新了原变量;
|
||||
# 5. 所有计算结果是否与手工推算一致。
|
||||
|
||||
# 计划学习总天数:30
|
||||
# 已经完成的学习天数:7
|
||||
# 每天计划学习的小时数:2.5
|
||||
# 4
|
||||
# ==============================
|
||||
# 1. 计划总天数和已完成天数: 30,7
|
||||
# 2. 剩余天数:23
|
||||
# 3. 完整星期数和额外天数:4,2
|
||||
# 4. 计划总学习小时数和已完成学习小时数:75.0,17.5
|
||||
# has_started=True
|
||||
# is_plan_finished=False
|
||||
# has_remaining_days=True
|
||||
# is_in_progress=True
|
||||
# completed_tasks=4
|
||||
#
|
||||
# 进程已结束,退出代码为 0
|
||||
|
||||
@@ -0,0 +1,402 @@
|
||||
# 第 2-1 课:模块与包
|
||||
|
||||
## 一、本课目标
|
||||
|
||||
完成本课后,你将能够:
|
||||
|
||||
1. 说出模块和包分别是什么;
|
||||
2. 解释为什么要把较长的程序拆分成多个文件;
|
||||
3. 使用 `import` 导入模块;
|
||||
4. 使用 `from...import...` 导入指定内容;
|
||||
5. 创建并使用自己的模块和包;
|
||||
6. 理解 `if __name__ == "__main__":` 的基本作用。
|
||||
|
||||
## 二、前置知识
|
||||
|
||||
学习本课前,需要掌握:
|
||||
|
||||
- 变量和基本数据类型;
|
||||
- 列表与字典;
|
||||
- 条件判断与循环;
|
||||
- 函数、参数和返回值;
|
||||
- 知道 Python 代码保存在 `.py` 文件中。
|
||||
|
||||
本课是 Python 进阶阶段的第一课,但不会使用复杂语法。
|
||||
|
||||
## 三、模块是什么
|
||||
|
||||
模块(Module)通常就是一个保存了 Python 代码的 `.py` 文件。
|
||||
|
||||
前面的课程大多把函数和主程序写在同一个文件中。程序越来越大后,这种方式会产生三个问题:
|
||||
|
||||
1. 文件太长,不容易阅读;
|
||||
2. 不同功能混在一起,不容易修改;
|
||||
3. 已经写好的函数不方便在其他程序中重复使用。
|
||||
|
||||
把相关函数放进单独文件,可以让代码职责更清晰。例如:
|
||||
|
||||
```text
|
||||
agent_tools.py 保存 Agent 工具相关函数
|
||||
module_example.py 组织程序运行流程
|
||||
```
|
||||
|
||||
`agent_tools.py` 就是一个自定义模块。
|
||||
|
||||
## 四、使用 `import` 导入模块
|
||||
|
||||
导入(Import)表示让当前文件能够使用另一个模块中的内容。
|
||||
|
||||
### 4.1 导入标准库模块
|
||||
|
||||
标准库(Standard Library)是安装 Python 时自带的一组模块,不需要另外下载。本课使用 `random` 模块随机选择列表元素:
|
||||
|
||||
```python
|
||||
import random
|
||||
|
||||
tools = ["搜索", "终端", "计算器"]
|
||||
recommended_tool = random.choice(tools)
|
||||
print(recommended_tool)
|
||||
```
|
||||
|
||||
这里:
|
||||
|
||||
- `import random` 导入整个模块;
|
||||
- `random.choice()` 表示调用 `random` 模块中的 `choice()` 函数;
|
||||
- 函数会从列表中随机选择一个元素,因此每次结果可能不同。
|
||||
|
||||
### 4.2 导入自己的模块
|
||||
|
||||
课程目录中的 `agent_tools.py` 定义了函数和变量。在同一目录的 `module_example.py` 中可以这样使用:
|
||||
|
||||
```python
|
||||
import agent_tools
|
||||
|
||||
tool_count = agent_tools.get_tool_count(agent_tools.DEFAULT_TOOLS)
|
||||
print(tool_count)
|
||||
```
|
||||
|
||||
导入时不写 `.py`,因此使用的是 `import agent_tools`,不是 `import agent_tools.py`。
|
||||
|
||||
通过 `模块名.名称` 可以清楚看出这个变量或函数来自哪里。
|
||||
|
||||
## 五、使用 `from...import...`
|
||||
|
||||
如果当前文件只需要模块中的少量内容,可以导入指定名称:
|
||||
|
||||
```python
|
||||
from agent_tools import build_agent_description
|
||||
|
||||
description = build_agent_description("代码助手", "gpt-5")
|
||||
print(description)
|
||||
```
|
||||
|
||||
导入后可以直接写函数名,不再需要添加 `agent_tools.`。
|
||||
|
||||
两种写法都正确:
|
||||
|
||||
```python
|
||||
import agent_tools
|
||||
agent_tools.build_agent_description("代码助手", "gpt-5")
|
||||
```
|
||||
|
||||
```python
|
||||
from agent_tools import build_agent_description
|
||||
build_agent_description("代码助手", "gpt-5")
|
||||
```
|
||||
|
||||
入门阶段建议优先使用清晰、容易看出来源的写法。不要使用 `from agent_tools import *`,因为星号会一次导入许多名称,容易造成重名和阅读困难。
|
||||
|
||||
## 六、导入模块时会发生什么
|
||||
|
||||
第一次导入模块时,Python 会从上到下执行模块中的代码。函数定义只是创建函数,不会自动调用函数;但直接写在文件最外层的 `print()` 会立即执行。
|
||||
|
||||
为了区分“直接运行文件”和“把文件作为模块导入”,可以使用:
|
||||
|
||||
```python
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
```
|
||||
|
||||
`__name__` 是 Python 自动提供的特殊变量:
|
||||
|
||||
- 直接运行当前文件时,`__name__` 的值是 `"__main__"`;
|
||||
- 当前文件被其他文件导入时,`__name__` 的值是模块名。
|
||||
|
||||
因此,可以把模块的测试代码放进 `main()`,再通过入口判断调用:
|
||||
|
||||
```python
|
||||
def main():
|
||||
print("正在测试模块。")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
```
|
||||
|
||||
这样直接运行模块时会执行测试,导入模块时不会意外输出测试文字。
|
||||
|
||||
## 七、包是什么
|
||||
|
||||
包(Package)是用于组织多个模块的文件夹。
|
||||
|
||||
本课示例结构如下:
|
||||
|
||||
```text
|
||||
agent_package/
|
||||
├── __init__.py
|
||||
└── status_text.py
|
||||
```
|
||||
|
||||
`__init__.py` 用来明确表示这个目录是一个 Python 包。本课暂时不在其中编写功能,只保留说明注释。
|
||||
|
||||
从包中的模块导入函数:
|
||||
|
||||
```python
|
||||
from agent_package.status_text import get_status_text
|
||||
|
||||
status_text = get_status_text(True)
|
||||
print(status_text)
|
||||
```
|
||||
|
||||
可以把导入路径从左到右理解为:
|
||||
|
||||
```text
|
||||
agent_package 包 → status_text 模块 → get_status_text 函数
|
||||
```
|
||||
|
||||
## 八、示例文件
|
||||
|
||||
本课包含以下示例:
|
||||
|
||||
```text
|
||||
02_python进阶/2_1_模块与包/
|
||||
├── README.md
|
||||
├── module_example.py
|
||||
├── agent_tools.py
|
||||
├── practice.py
|
||||
└── agent_package/
|
||||
├── __init__.py
|
||||
└── status_text.py
|
||||
```
|
||||
|
||||
- `module_example.py`:完整主程序;
|
||||
- `agent_tools.py`:自己编写的模块;
|
||||
- `agent_package/`:自己编写的简单包;
|
||||
- `practice.py`:课堂练习。
|
||||
|
||||
## 九、运行方法
|
||||
|
||||
打开 PowerShell,进入项目根目录:
|
||||
|
||||
```powershell
|
||||
cd F:\PyCharm\PythonLearn
|
||||
```
|
||||
|
||||
运行完整示例:
|
||||
|
||||
```powershell
|
||||
python .\02_python进阶\2_1_模块与包\module_example.py
|
||||
```
|
||||
|
||||
直接运行自定义模块的测试:
|
||||
|
||||
```powershell
|
||||
python .\02_python进阶\2_1_模块与包\agent_tools.py
|
||||
```
|
||||
|
||||
完成练习后运行:
|
||||
|
||||
```powershell
|
||||
python .\02_python进阶\2_1_模块与包\practice.py
|
||||
```
|
||||
|
||||
## 十、运行结果
|
||||
|
||||
运行 `module_example.py` 时,会看到类似结果:
|
||||
|
||||
```text
|
||||
一、标准库模块
|
||||
随机推荐工具:搜索
|
||||
==============================
|
||||
二、自定义模块
|
||||
默认工具:['搜索', '终端']
|
||||
默认工具数量:2
|
||||
代码助手 使用 gpt-5 模型
|
||||
==============================
|
||||
三、自定义包
|
||||
Agent 状态:启用
|
||||
```
|
||||
|
||||
第一部分使用了随机选择,因此也可能显示“终端”或“计算器”,这不是错误。
|
||||
|
||||
运行 `agent_tools.py` 时,预期看到:
|
||||
|
||||
```text
|
||||
正在测试 agent_tools 模块。
|
||||
默认工具数量:2
|
||||
```
|
||||
|
||||
## 十一、关键代码执行顺序
|
||||
|
||||
运行 `module_example.py` 时,Python 大致按照以下顺序工作:
|
||||
|
||||
1. 执行文件顶部的导入语句;
|
||||
2. 找到并加载标准库模块 `random`;
|
||||
3. 找到同一目录中的 `agent_tools.py`;
|
||||
4. 找到 `agent_package` 包中的 `status_text.py`;
|
||||
5. 创建当前文件中定义的函数;
|
||||
6. 执行文件末尾的程序入口判断;
|
||||
7. 因为当前文件是直接运行的,所以调用 `main()`;
|
||||
8. `main()` 依次调用三个演示函数并输出结果。
|
||||
|
||||
导入 `agent_tools.py` 时,它的入口判断不成立,所以不会自动执行 `show_module_test()`。
|
||||
|
||||
## 十二、常见错误
|
||||
|
||||
### 12.1 导入时写了 `.py`
|
||||
|
||||
错误写法:
|
||||
|
||||
```python
|
||||
import agent_tools.py
|
||||
```
|
||||
|
||||
正确写法:
|
||||
|
||||
```python
|
||||
import agent_tools
|
||||
```
|
||||
|
||||
模块名不包含文件扩展名 `.py`。
|
||||
|
||||
### 12.2 文件名与导入名不一致
|
||||
|
||||
如果文件名是 `agent_tools.py`,就应使用 `import agent_tools`。少写字母或使用不同名称,会出现:
|
||||
|
||||
```text
|
||||
ModuleNotFoundError: No module named 'agent_tool'
|
||||
```
|
||||
|
||||
中文解释:Python 没有找到名为 `agent_tool` 的模块。请检查文件名、导入名和运行目录。
|
||||
|
||||
### 12.3 导入整个模块后直接调用函数
|
||||
|
||||
如果使用:
|
||||
|
||||
```python
|
||||
import agent_tools
|
||||
```
|
||||
|
||||
就要通过模块名调用:
|
||||
|
||||
```python
|
||||
agent_tools.get_tool_count([])
|
||||
```
|
||||
|
||||
直接写 `get_tool_count([])` 会出现名称未定义错误。
|
||||
|
||||
### 12.4 自定义模块名与标准库重名
|
||||
|
||||
不要把自己的文件命名为 `random.py`,否则 `import random` 可能导入自己的文件,而不是 Python 标准库模块。
|
||||
|
||||
### 12.5 导入模块时出现意外输出
|
||||
|
||||
如果测试用的 `print()` 直接写在模块最外层,导入模块时也会执行。把测试代码放进 `main()`,再使用程序入口判断。
|
||||
|
||||
### 12.6 包或模块不在预期位置
|
||||
|
||||
本课请保持示例目录结构不变,并从项目根目录执行讲义中的命令。目录位置错误会导致 Python 找不到需要导入的内容。
|
||||
|
||||
## 十三、课堂练习
|
||||
|
||||
打开 `practice.py`,按照注释依次完成五部分:
|
||||
|
||||
1. 创建 `calculator.py` 模块;
|
||||
2. 分别使用两种方式导入加法函数;
|
||||
3. 为模块添加安全的测试入口;
|
||||
4. 创建 `text_package` 包;
|
||||
5. 从包中的模块导入文本处理函数。
|
||||
|
||||
建议先独立完成。如果遇到困难,先检查文件名、目录位置和导入语句是否一致。
|
||||
|
||||
## 十四、参考答案
|
||||
|
||||
请先独立练习,再展开查看。
|
||||
|
||||
<details>
|
||||
<summary>查看参考答案</summary>
|
||||
|
||||
`calculator.py`:
|
||||
|
||||
```python
|
||||
def add(first, second):
|
||||
"""返回两个数字相加的结果。"""
|
||||
return first + second
|
||||
|
||||
|
||||
def main():
|
||||
"""测试当前模块的加法功能。"""
|
||||
print("正在测试计算模块。")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
```
|
||||
|
||||
`text_package/__init__.py`:
|
||||
|
||||
```python
|
||||
# 这个文件表示 text_package 是一个 Python 包。
|
||||
```
|
||||
|
||||
`text_package/text_tools.py`:
|
||||
|
||||
```python
|
||||
def make_title(text):
|
||||
"""为文本添加标题装饰。"""
|
||||
return f"=== {text} ==="
|
||||
```
|
||||
|
||||
`practice.py` 中需要补充的核心代码:
|
||||
|
||||
```python
|
||||
import calculator
|
||||
from calculator import add
|
||||
from text_package.text_tools import make_title
|
||||
|
||||
|
||||
result = calculator.add(10, 20)
|
||||
print(f"计算结果:{result}")
|
||||
|
||||
new_result = add(5, 8)
|
||||
print(f"新的计算结果:{new_result}")
|
||||
|
||||
title = make_title("Python 学习")
|
||||
print(title)
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## 十五、本课小结
|
||||
|
||||
- 模块通常是一个 `.py` 文件;
|
||||
- 模块可以拆分功能,并让代码在多个程序中重复使用;
|
||||
- `import 模块名` 会导入整个模块;
|
||||
- `from 模块名 import 名称` 会导入指定内容;
|
||||
- `if __name__ == "__main__":` 可以避免导入模块时自动执行测试或主程序;
|
||||
- 包是用于组织多个模块的文件夹;
|
||||
- 可以使用点号表示“包、模块、名称”之间的层级关系。
|
||||
|
||||
## 十六、验收标准
|
||||
|
||||
完成本课时,应满足以下条件:
|
||||
|
||||
- 可以成功运行 `module_example.py`;
|
||||
- 可以解释模块与包的区别;
|
||||
- 可以使用两种方式导入模块内容;
|
||||
- 可以创建并导入自己的 `.py` 模块;
|
||||
- 可以解释入口判断的基本作用;
|
||||
- 可以创建包含 `__init__.py` 的简单包;
|
||||
- 可以完成 `practice.py` 中的五部分练习;
|
||||
- 运行练习时得到 `30`、`13` 和 `=== Python 学习 ===`;
|
||||
- 导入 `calculator` 时不会自动输出模块测试文字。
|
||||
@@ -0,0 +1,4 @@
|
||||
# 这个文件表示 agent_package 是一个 Python 包。
|
||||
#
|
||||
# 包就是用于组织多个模块的文件夹。
|
||||
# 目前先保留简单注释,后续课程会继续学习更复杂的包结构。
|
||||
@@ -0,0 +1,9 @@
|
||||
# 第 2-1 课示例:包中的模块
|
||||
|
||||
|
||||
def get_status_text(enabled):
|
||||
"""把布尔值状态转换成中文文本。"""
|
||||
if enabled:
|
||||
return "启用"
|
||||
|
||||
return "停用"
|
||||
@@ -0,0 +1,29 @@
|
||||
# 第 2-1 课示例:自定义模块
|
||||
#
|
||||
# 模块就是一个 Python 文件。
|
||||
# 这个文件集中保存与 Agent 工具有关的函数,供其他文件导入使用。
|
||||
|
||||
|
||||
DEFAULT_TOOLS = ["搜索", "终端"]
|
||||
|
||||
|
||||
def get_tool_count(tools):
|
||||
"""返回工具列表中的工具数量。"""
|
||||
return len(tools)
|
||||
|
||||
|
||||
def build_agent_description(name, model):
|
||||
"""根据名称和模型生成 Agent 描述。"""
|
||||
return f"{name} 使用 {model} 模型"
|
||||
|
||||
|
||||
def show_module_test():
|
||||
"""输出本模块的测试结果。"""
|
||||
print("正在测试 agent_tools 模块。")
|
||||
print(f"默认工具数量:{get_tool_count(DEFAULT_TOOLS)}")
|
||||
|
||||
|
||||
# 只有直接运行本文件时,才执行下面的测试代码。
|
||||
# 其他文件导入本模块时,不会自动执行测试。
|
||||
if __name__ == "__main__":
|
||||
show_module_test()
|
||||
@@ -0,0 +1,63 @@
|
||||
# 第 2-1 课完整示例:模块与包
|
||||
#
|
||||
# 本文件依次演示:
|
||||
# 1. 使用 import 导入 Python 标准库模块;
|
||||
# 2. 使用 import 导入自己编写的模块;
|
||||
# 3. 使用 from...import... 导入指定内容;
|
||||
# 4. 从自定义包中导入模块;
|
||||
# 5. 使用程序入口判断组织主程序。
|
||||
|
||||
import random
|
||||
|
||||
import agent_tools
|
||||
from agent_tools import build_agent_description
|
||||
from agent_package.status_text import get_status_text
|
||||
|
||||
|
||||
def print_separator():
|
||||
"""输出分隔线,让不同示例更容易阅读。"""
|
||||
print("=" * 30)
|
||||
|
||||
|
||||
def show_standard_library_example():
|
||||
"""演示使用标准库模块生成随机推荐。"""
|
||||
tools = ["搜索", "终端", "计算器"]
|
||||
recommended_tool = random.choice(tools)
|
||||
print(f"随机推荐工具:{recommended_tool}")
|
||||
|
||||
|
||||
def show_custom_module_example():
|
||||
"""演示使用自己编写的 agent_tools 模块。"""
|
||||
tool_count = agent_tools.get_tool_count(agent_tools.DEFAULT_TOOLS)
|
||||
print(f"默认工具:{agent_tools.DEFAULT_TOOLS}")
|
||||
print(f"默认工具数量:{tool_count}")
|
||||
|
||||
description = build_agent_description("代码助手", "gpt-5")
|
||||
print(description)
|
||||
|
||||
|
||||
def show_package_example():
|
||||
"""演示从自定义包中导入并使用函数。"""
|
||||
enabled = True
|
||||
status_text = get_status_text(enabled)
|
||||
print(f"Agent 状态:{status_text}")
|
||||
|
||||
|
||||
def main():
|
||||
"""按照顺序运行本课的三个示例。"""
|
||||
print("一、标准库模块")
|
||||
show_standard_library_example()
|
||||
|
||||
print_separator()
|
||||
|
||||
print("二、自定义模块")
|
||||
show_custom_module_example()
|
||||
|
||||
print_separator()
|
||||
|
||||
print("三、自定义包")
|
||||
show_package_example()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,46 @@
|
||||
# 第 2-1 课课堂练习:模块与包
|
||||
#
|
||||
# 请先完成讲义中的学习和示例运行,再按照顺序完成练习。
|
||||
# 不要删除题目和预期结果;每完成一部分就运行一次。
|
||||
|
||||
|
||||
|
||||
# 第一部分:创建自定义模块
|
||||
# 1. 在当前课程目录创建 calculator.py。
|
||||
# 2. 在 calculator.py 中定义 add(first, second) 函数。
|
||||
# 3. 函数返回两个数字相加的结果。
|
||||
|
||||
|
||||
|
||||
# 第二部分:使用 import 导入模块
|
||||
# 1. 在本文件中使用 import calculator 导入模块。
|
||||
# 2. 调用 calculator.add(10, 20)。
|
||||
# 3. 输出格式为“计算结果:30”。
|
||||
|
||||
# 第三部分:使用 from...import... 导入函数
|
||||
# 1. 使用 from calculator import add 导入 add()。
|
||||
# 2. 直接调用 add(5, 8)。
|
||||
# 3. 输出格式为“新的计算结果:13”。
|
||||
# 第四部分:理解程序入口
|
||||
# 1. 在 calculator.py 中定义 main() 函数。
|
||||
# 2. main() 输出“正在测试计算模块。”。
|
||||
# 3. 使用 if __name__ == "__main__": 判断后调用 main()。
|
||||
# 4. 直接运行 calculator.py 时,应看到测试文字。
|
||||
# 5. 运行 practice.py 时,不应自动看到测试文字。
|
||||
|
||||
|
||||
# 第五部分:创建自己的包
|
||||
# 1. 在当前课程目录创建 text_package 文件夹。
|
||||
# 2. 在其中创建 __init__.py 和 text_tools.py。
|
||||
# 3. 在 text_tools.py 中定义 make_title(text) 函数。
|
||||
# 4. 函数返回 f"=== {text} ==="。
|
||||
# 5. 在本文件中从 text_package.text_tools 导入 make_title。
|
||||
# 6. 调用函数并输出,预期结果为“=== Python 学习 ===”。
|
||||
|
||||
# 完成后自查:
|
||||
# 1. 是否理解一个 .py 文件可以作为模块;
|
||||
# 2. 是否会使用 import 模块名;
|
||||
# 3. 是否会使用 from 模块名 import 名称;
|
||||
# 4. 是否知道导入模块时不会执行入口判断中的测试代码;
|
||||
# 5. 是否能创建包含 __init__.py 的简单包;
|
||||
# 6. 是否能从包中的模块导入函数。
|
||||
@@ -0,0 +1,4 @@
|
||||
# 本课示例和练习运行时生成的数据。
|
||||
# 这些内容可以重新生成,不需要纳入 Git 版本管理。
|
||||
runtime_data/
|
||||
practice_data/
|
||||
@@ -0,0 +1,405 @@
|
||||
# 第 2-2 课:文件与目录操作
|
||||
|
||||
## 一、本课目标
|
||||
|
||||
完成本课后,你将能够:
|
||||
|
||||
1. 解释文件、目录和路径分别是什么;
|
||||
2. 使用 `Path` 表示和组合路径;
|
||||
3. 创建练习目录;
|
||||
4. 安全地写入、追加和读取文本文件;
|
||||
5. 检查路径是否存在,以及它是不是文件;
|
||||
6. 遍历目录中的文件;
|
||||
7. 说明文件操作可能带来的副作用。
|
||||
|
||||
## 二、前置知识
|
||||
|
||||
学习本课前,需要掌握:
|
||||
|
||||
- 字符串、变量和布尔值;
|
||||
- 条件判断与循环;
|
||||
- 函数、参数和返回值;
|
||||
- 模块、导入和程序入口判断。
|
||||
|
||||
上一课学习了模块。本课将从 Python 标准库中导入 `pathlib` 模块提供的 `Path`。
|
||||
|
||||
## 三、文件、目录和路径是什么
|
||||
|
||||
文件(File)是保存在计算机中的一份数据,例如:
|
||||
|
||||
- Python 程序 `practice.py`;
|
||||
- 文本文件 `learning_notes.txt`;
|
||||
- 图片文件 `photo.png`。
|
||||
|
||||
目录(Directory)也常被称为文件夹,用于组织文件和其他目录。
|
||||
|
||||
路径(Path)用于描述文件或目录在计算机中的位置。例如:
|
||||
|
||||
```text
|
||||
02_python进阶/2_2_文件与目录操作/runtime_data/learning_notes.txt
|
||||
```
|
||||
|
||||
可以把路径理解为从一个位置找到目标文件所经过的路线。
|
||||
|
||||
## 四、为什么使用 `Path`
|
||||
|
||||
不同操作系统表示路径的方式可能不同。Windows 常见反斜杠 `\`,其他系统常见正斜杠 `/`。手工拼接路径容易写错。
|
||||
|
||||
`pathlib` 是 Python 标准库中的路径处理模块。`Path` 是它提供的路径对象:
|
||||
|
||||
```python
|
||||
from pathlib import Path
|
||||
|
||||
lesson_dir = Path(__file__).parent
|
||||
data_dir = lesson_dir / "runtime_data"
|
||||
```
|
||||
|
||||
对象(Object)可以暂时理解为“同时保存数据和操作能力的值”。面向对象课程会详细讲解。
|
||||
|
||||
这里:
|
||||
|
||||
- `__file__` 是 Python 自动提供的特殊变量,表示当前文件的位置;
|
||||
- `Path(__file__)` 把位置转换成路径对象;
|
||||
- `.parent` 得到当前文件所在的目录;
|
||||
- `/ "runtime_data"` 在原路径后面组合新的目录名。
|
||||
|
||||
这里的 `/` 不是数学除法。`Path` 对象把它设计成了清晰的路径组合写法。
|
||||
|
||||
## 五、相对路径与绝对路径
|
||||
|
||||
相对路径(Relative Path)表示相对于当前工作位置的路线:
|
||||
|
||||
```text
|
||||
runtime_data/learning_notes.txt
|
||||
```
|
||||
|
||||
绝对路径(Absolute Path)从磁盘或系统根位置开始,完整描述目标位置。在 Windows 中可能类似:
|
||||
|
||||
```text
|
||||
F:\PyCharm\PythonLearn\02_python进阶\2_2_文件与目录操作
|
||||
```
|
||||
|
||||
本课通过 `Path(__file__).parent` 取得课程目录。这样无论从项目根目录还是课程目录启动示例,数据都会写入本课自己的 `runtime_data`。
|
||||
|
||||
## 六、创建目录
|
||||
|
||||
```python
|
||||
data_dir.mkdir(parents=True, exist_ok=True)
|
||||
```
|
||||
|
||||
`mkdir()` 表示创建目录:
|
||||
|
||||
- `parents=True`:需要时一并创建缺少的上级目录;
|
||||
- `exist_ok=True`:目录已经存在时不报错。
|
||||
|
||||
如果没有 `exist_ok=True`,第二次运行程序时可能出现:
|
||||
|
||||
```text
|
||||
FileExistsError
|
||||
```
|
||||
|
||||
中文解释:程序想创建的文件或目录已经存在。
|
||||
|
||||
## 七、写入文本文件
|
||||
|
||||
```python
|
||||
notes_file.write_text(
|
||||
"第一条:学习使用 Path 表示路径。\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
```
|
||||
|
||||
`write_text()` 会创建文件并写入文本。如果文件已经存在,它会覆盖原有内容。
|
||||
|
||||
覆盖意味着旧内容会被替换,可能难以恢复。因此:
|
||||
|
||||
- 练习时只操作专用数据目录;
|
||||
- 写入前确认路径是否正确;
|
||||
- 不要把重要文件路径交给练习程序;
|
||||
- 中文文本明确使用 `encoding="utf-8"`。
|
||||
|
||||
编码(Encoding)是文字与计算机数据之间的转换规则。UTF-8 是常用的文字编码,可以保存中文和多种语言。
|
||||
|
||||
字符串中的 `\n` 表示换行。
|
||||
|
||||
## 八、追加文本文件
|
||||
|
||||
覆盖写入会清空旧内容。希望在文件末尾增加内容时,可以使用追加模式:
|
||||
|
||||
```python
|
||||
with notes_file.open("a", encoding="utf-8") as file:
|
||||
file.write("第二条:学习读取和追加文件。\n")
|
||||
```
|
||||
|
||||
打开模式(File Mode)`"a"` 中的字母来自 append,表示追加。
|
||||
|
||||
`with` 语句会在代码块结束后帮助关闭文件。即使以后文件操作发生错误,也更容易正确释放文件资源。
|
||||
|
||||
本课先记住固定结构:
|
||||
|
||||
```python
|
||||
with path.open("a", encoding="utf-8") as file:
|
||||
file.write("需要追加的内容")
|
||||
```
|
||||
|
||||
## 九、读取文本文件
|
||||
|
||||
```python
|
||||
content = notes_file.read_text(encoding="utf-8")
|
||||
print(content)
|
||||
```
|
||||
|
||||
`read_text()` 会读取整个文本文件,并返回一个字符串。
|
||||
|
||||
如果文件不存在,可能出现:
|
||||
|
||||
```text
|
||||
FileNotFoundError
|
||||
```
|
||||
|
||||
中文解释:Python 在指定路径没有找到需要读取的文件。
|
||||
|
||||
可以先检查文件是否存在:
|
||||
|
||||
```python
|
||||
if notes_file.exists():
|
||||
content = notes_file.read_text(encoding="utf-8")
|
||||
```
|
||||
|
||||
## 十、检查路径
|
||||
|
||||
常用检查方法:
|
||||
|
||||
```python
|
||||
path.exists()
|
||||
path.is_file()
|
||||
path.is_dir()
|
||||
```
|
||||
|
||||
- `exists()`:路径是否存在;
|
||||
- `is_file()`:路径是否存在并且是文件;
|
||||
- `is_dir()`:路径是否存在并且是目录。
|
||||
|
||||
这些方法返回布尔值 `True` 或 `False`,可以与条件判断配合使用。
|
||||
|
||||
## 十一、遍历目录
|
||||
|
||||
```python
|
||||
for path in data_dir.iterdir():
|
||||
if path.is_file():
|
||||
print(path.name)
|
||||
```
|
||||
|
||||
`iterdir()` 会提供目录中的每一项。每一项仍然是 `Path` 对象:
|
||||
|
||||
- `path.name` 表示文件或目录的名称;
|
||||
- `path.is_file()` 判断它是否是文件。
|
||||
|
||||
本课只遍历当前目录,不递归进入更深层目录。递归表示一个操作继续处理内部的子目录,属于后续扩展内容。
|
||||
|
||||
## 十二、示例文件与数据影响
|
||||
|
||||
本课文件结构:
|
||||
|
||||
```text
|
||||
02_python进阶/2_2_文件与目录操作/
|
||||
├── .gitignore
|
||||
├── README.md
|
||||
├── file_directory_example.py
|
||||
└── practice.py
|
||||
```
|
||||
|
||||
运行示例后会产生:
|
||||
|
||||
```text
|
||||
runtime_data/
|
||||
└── learning_notes.txt
|
||||
```
|
||||
|
||||
运行练习后应产生:
|
||||
|
||||
```text
|
||||
practice_data/
|
||||
└── diary.txt
|
||||
```
|
||||
|
||||
这两个数据目录都被本课 `.gitignore` 忽略,不会加入 Git。程序不会操作课程目录之外的文件。
|
||||
|
||||
每次运行完整示例时,`learning_notes.txt` 会先被写入初始内容,再追加第二条记录,因此结果不会无限重复增加。
|
||||
|
||||
## 十三、运行方法
|
||||
|
||||
在 PowerShell 中进入项目根目录:
|
||||
|
||||
```powershell
|
||||
cd F:\PyCharm\PythonLearn
|
||||
```
|
||||
|
||||
运行完整示例:
|
||||
|
||||
```powershell
|
||||
python .\02_python进阶\2_2_文件与目录操作\file_directory_example.py
|
||||
```
|
||||
|
||||
完成练习后运行:
|
||||
|
||||
```powershell
|
||||
python .\02_python进阶\2_2_文件与目录操作\practice.py
|
||||
```
|
||||
|
||||
## 十四、运行结果
|
||||
|
||||
完整示例的预期结果类似:
|
||||
|
||||
```text
|
||||
数据目录已准备:runtime_data
|
||||
初始内容已写入:learning_notes.txt
|
||||
新的学习记录已追加。
|
||||
学习记录内容:
|
||||
第一条:学习使用 Path 表示路径。
|
||||
第二条:学习读取和追加文件。
|
||||
数据目录中的文件:
|
||||
- learning_notes.txt
|
||||
```
|
||||
|
||||
然后可以打开 `runtime_data/learning_notes.txt`,确认文件内容与终端输出一致。
|
||||
|
||||
## 十五、关键代码执行顺序
|
||||
|
||||
运行 `file_directory_example.py` 时:
|
||||
|
||||
1. 导入 `Path`;
|
||||
2. 根据当前文件位置准备课程目录、数据目录和文件路径;
|
||||
3. 定义文件操作函数;
|
||||
4. 入口判断成立,调用 `main()`;
|
||||
5. 创建 `runtime_data`;
|
||||
6. 覆盖写入初始记录;
|
||||
7. 在文件末尾追加一条记录;
|
||||
8. 读取并输出完整内容;
|
||||
9. 遍历数据目录并输出文件名。
|
||||
|
||||
先创建目录再写文件非常重要。目录不存在时,直接写入内部文件会失败。
|
||||
|
||||
## 十六、常见错误
|
||||
|
||||
### 16.1 手工拼接路径
|
||||
|
||||
不推荐:
|
||||
|
||||
```python
|
||||
file_path = lesson_dir + "/runtime_data/notes.txt"
|
||||
```
|
||||
|
||||
`lesson_dir` 是 `Path` 对象,不能直接与普通字符串这样相加。使用:
|
||||
|
||||
```python
|
||||
file_path = lesson_dir / "runtime_data" / "notes.txt"
|
||||
```
|
||||
|
||||
### 16.2 忘记创建目录
|
||||
|
||||
在 `runtime_data` 不存在时直接写入内部文件,会出现 `FileNotFoundError`。应先调用 `mkdir()`。
|
||||
|
||||
### 16.3 忘记指定中文编码
|
||||
|
||||
读取或写入中文时未指定 UTF-8,可能出现乱码或编码错误。请在读写两端都使用 `encoding="utf-8"`。
|
||||
|
||||
### 16.4 混淆覆盖和追加
|
||||
|
||||
- `write_text()` 会覆盖原内容;
|
||||
- `open("a")` 会在末尾追加内容。
|
||||
|
||||
操作重要文件前必须确认自己需要哪一种行为。
|
||||
|
||||
### 16.5 忘记关闭文件
|
||||
|
||||
手动调用 `open()` 后如果忘记关闭文件,可能造成资源占用。本课使用 `with` 自动管理文件。
|
||||
|
||||
### 16.6 路径指向项目外部
|
||||
|
||||
练习时不要使用系统目录、桌面文件或其他项目文件。把所有练习数据限制在本课的 `practice_data` 中。
|
||||
|
||||
## 十七、课堂练习
|
||||
|
||||
打开 `practice.py`,依次完成:
|
||||
|
||||
1. 准备练习目录和日记文件路径;
|
||||
2. 创建 `practice_data`;
|
||||
3. 写入第一条日记;
|
||||
4. 追加第二条日记;
|
||||
5. 读取并输出完整内容;
|
||||
6. 检查文件是否存在并遍历目录。
|
||||
|
||||
每完成一部分就运行一次。不要直接复制参考答案。
|
||||
|
||||
## 十八、参考答案
|
||||
|
||||
请先独立完成练习,再展开查看。
|
||||
|
||||
<details>
|
||||
<summary>查看参考答案</summary>
|
||||
|
||||
```python
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
lesson_dir = Path(__file__).parent
|
||||
practice_dir = lesson_dir / "practice_data"
|
||||
diary_file = practice_dir / "diary.txt"
|
||||
|
||||
practice_dir.mkdir(parents=True, exist_ok=True)
|
||||
print("练习目录已准备。")
|
||||
|
||||
first_entry = "今天学习了文件与目录。\n"
|
||||
diary_file.write_text(first_entry, encoding="utf-8")
|
||||
print("日记已写入。")
|
||||
|
||||
with diary_file.open("a", encoding="utf-8") as file:
|
||||
file.write("我会使用 Path 处理路径。\n")
|
||||
|
||||
print("日记已追加。")
|
||||
|
||||
content = diary_file.read_text(encoding="utf-8")
|
||||
print("日记内容:")
|
||||
print(content, end="")
|
||||
|
||||
if diary_file.exists():
|
||||
print("日记文件存在。")
|
||||
|
||||
print("练习目录中的文件:")
|
||||
|
||||
for path in practice_dir.iterdir():
|
||||
if path.is_file():
|
||||
print(path.name)
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## 十九、本课小结
|
||||
|
||||
- 文件保存数据,目录用于组织文件,路径描述它们的位置;
|
||||
- `Path` 可以清晰地表示和组合路径;
|
||||
- `mkdir()` 可以创建目录;
|
||||
- `write_text()` 可以写入文本,但会覆盖原内容;
|
||||
- `open("a")` 可以追加内容;
|
||||
- `read_text()` 可以读取整个文本文件;
|
||||
- `with` 可以帮助正确关闭文件;
|
||||
- `exists()`、`is_file()` 和 `is_dir()` 可以检查路径;
|
||||
- `iterdir()` 可以遍历目录;
|
||||
- 文件操作存在副作用,必须限制操作范围并确认目标路径。
|
||||
|
||||
## 二十、验收标准
|
||||
|
||||
完成本课时,应满足:
|
||||
|
||||
- 可以解释文件、目录、相对路径和绝对路径;
|
||||
- 可以使用 `Path(__file__).parent` 定位当前课程目录;
|
||||
- 可以使用 `/` 组合 `Path` 路径;
|
||||
- 可以创建已经存在也不会报错的目录;
|
||||
- 可以正确写入、追加和读取中文文本;
|
||||
- 可以解释覆盖写入与追加写入的区别;
|
||||
- 可以使用 `with` 打开文件;
|
||||
- 可以检查路径并遍历目录中的文件;
|
||||
- 练习数据只出现在 `practice_data` 中;
|
||||
- 运行两次练习,程序都不会崩溃;
|
||||
- 最终日记内容不会因为重复运行而无限增加。
|
||||
@@ -0,0 +1,71 @@
|
||||
# 第 2-2 课完整示例:文件与目录操作
|
||||
#
|
||||
# 本程序只会在当前课程目录的 runtime_data 文件夹中创建和修改文件。
|
||||
# runtime_data 已被本课的 .gitignore 忽略,不会污染 Git 变更记录。
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
# __file__ 表示当前 Python 文件的位置。
|
||||
# parent 表示当前文件所在的目录。
|
||||
LESSON_DIR = Path(__file__).parent
|
||||
DATA_DIR = LESSON_DIR / "runtime_data"
|
||||
NOTES_FILE = DATA_DIR / "learning_notes.txt"
|
||||
|
||||
|
||||
def create_data_directory():
|
||||
"""创建保存示例数据的目录。"""
|
||||
# parents=True 表示需要时一并创建上级目录。
|
||||
# exist_ok=True 表示目录已经存在时不报错。
|
||||
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||
print(f"数据目录已准备:{DATA_DIR.name}")
|
||||
|
||||
|
||||
def write_initial_notes():
|
||||
"""写入初始学习记录。"""
|
||||
notes = "第一条:学习使用 Path 表示路径。\n"
|
||||
|
||||
# write_text() 会覆盖文件原有内容。
|
||||
# encoding="utf-8" 可以正确保存中文。
|
||||
NOTES_FILE.write_text(notes, encoding="utf-8")
|
||||
print(f"初始内容已写入:{NOTES_FILE.name}")
|
||||
|
||||
|
||||
def append_note():
|
||||
"""在文件末尾追加一条学习记录。"""
|
||||
# open() 打开文件后,需要在操作结束时关闭文件。
|
||||
# with 语句会帮助我们自动关闭文件。
|
||||
# 模式 "a" 表示追加,不会清空原有内容。
|
||||
with NOTES_FILE.open("a", encoding="utf-8") as file:
|
||||
file.write("第二条:学习读取和追加文件。\n")
|
||||
|
||||
print("新的学习记录已追加。")
|
||||
|
||||
|
||||
def read_notes():
|
||||
"""读取并输出全部学习记录。"""
|
||||
content = NOTES_FILE.read_text(encoding="utf-8")
|
||||
print("学习记录内容:")
|
||||
print(content, end="")
|
||||
|
||||
|
||||
def show_directory_contents():
|
||||
"""遍历并显示数据目录中的内容。"""
|
||||
print("数据目录中的文件:")
|
||||
|
||||
for path in DATA_DIR.iterdir():
|
||||
if path.is_file():
|
||||
print(f"- {path.name}")
|
||||
|
||||
|
||||
def main():
|
||||
"""按照安全顺序运行文件与目录示例。"""
|
||||
create_data_directory()
|
||||
write_initial_notes()
|
||||
append_note()
|
||||
read_notes()
|
||||
show_directory_contents()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,45 @@
|
||||
# 第 2-2 课课堂练习:文件与目录操作
|
||||
#
|
||||
# 请先阅读讲义并运行完整示例,再按照顺序完成练习。
|
||||
# 所有练习数据只能保存在当前课程目录的 practice_data 文件夹中。
|
||||
# 不要使用本课尚未要求的删除操作,也不要修改项目中的其他文件。
|
||||
|
||||
|
||||
# 第一部分:准备路径
|
||||
# 1. 从 pathlib 导入 Path。
|
||||
# 2. 使用 Path(__file__).parent 得到当前课程目录。
|
||||
# 3. 准备 practice_data 目录路径。
|
||||
# 4. 准备 practice_data/diary.txt 文件路径。
|
||||
|
||||
# 第二部分:创建目录
|
||||
# 1. 使用 mkdir(parents=True, exist_ok=True) 创建 practice_data。
|
||||
# 2. 输出“练习目录已准备。”。
|
||||
# 3. 连续运行两次,确认第二次运行不会报错。
|
||||
# 第三部分:写入文件
|
||||
# 1. 准备字符串“今天学习了文件与目录。\n”。
|
||||
# 2. 使用 write_text() 写入 diary.txt。
|
||||
# 3. 明确指定 encoding="utf-8"。
|
||||
# 4. 输出“日记已写入。”。
|
||||
# 第四部分:追加内容
|
||||
# 1. 使用 with 和 open("a", encoding="utf-8") 打开 diary.txt。
|
||||
# 2. 追加“我会使用 Path 处理路径。\n”。
|
||||
# 3. 输出“日记已追加。”。
|
||||
# 第五部分:读取内容
|
||||
# 1. 使用 read_text(encoding="utf-8") 读取 diary.txt。
|
||||
# 2. 输出标题“日记内容:”。
|
||||
# 3. 输出读取到的全部内容。
|
||||
|
||||
# 第六部分:检查和遍历
|
||||
# 1. 使用 exists() 检查 diary.txt 是否存在。
|
||||
# 2. 存在时输出“日记文件存在。”。
|
||||
# 3. 使用 iterdir() 遍历 practice_data。
|
||||
# 4. 只输出文件,并通过 name 属性显示文件名。
|
||||
|
||||
# 完成后自查:
|
||||
# 1. 是否始终使用 Path 组合路径,而不是手工拼接斜杠;
|
||||
# 2. 是否把练习数据限制在 practice_data 中;
|
||||
# 3. 写入和读取中文时是否指定 utf-8;
|
||||
# 4. 是否知道 write_text() 会覆盖原内容;
|
||||
# 5. 是否知道追加模式 "a" 不会清空原内容;
|
||||
# 6. 是否能解释 with 为什么可以帮助关闭文件;
|
||||
# 7. 是否能使用 exists()、is_file() 和 iterdir()。
|
||||
@@ -0,0 +1,2 @@
|
||||
example_data/
|
||||
practice_data/
|
||||
@@ -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` 隐藏异常。
|
||||
@@ -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()
|
||||
@@ -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. 是否没有删除题目和验收说明。
|
||||
@@ -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 列表;
|
||||
- 没有为了简短而编写难以阅读的复杂表达式。
|
||||
@@ -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()
|
||||
@@ -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. 是否保留了完整题目和验收说明。
|
||||
@@ -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 数据。
|
||||
@@ -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()
|
||||
@@ -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. 是否保留了完整题目和验收说明。
|
||||
@@ -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`;
|
||||
- 装饰器只负责通用附加行为,原函数保留具体业务逻辑。
|
||||
@@ -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()
|
||||
@@ -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. 是否保留了完整题目和验收说明。
|
||||
@@ -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` 标注输出函数;
|
||||
- 能说明注解与类型转换的区别;
|
||||
- 能说明类型注解通常不会自动强制检查运行时数据;
|
||||
- 所有注解与函数真实返回值保持一致。
|
||||
@@ -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. 是否保留了完整题目和验收说明。
|
||||
@@ -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()
|
||||
@@ -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` 中显示不同判断;
|
||||
- 没有把个人绝对路径或虚假依赖写入项目文件。
|
||||
@@ -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()
|
||||
@@ -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. 是否保留了完整题目和验收说明。
|
||||
@@ -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 channel,Conda 通常更自然。如果项目主要是 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)
|
||||
- [uv:Python 环境](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/)
|
||||
@@ -0,0 +1,6 @@
|
||||
# 运行完整示例时自动生成的本地任务数据。
|
||||
task_data/
|
||||
practice_data/
|
||||
|
||||
# Python 自动生成的缓存文件。
|
||||
__pycache__/
|
||||
@@ -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
|
||||
|
||||
JSON(JavaScript 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 保存和读取的方向;
|
||||
- 能说明本项目中异常处理、推导式、生成器、装饰器和类型注解分别在哪里使用;
|
||||
- 能按题目完成三个练习模块;
|
||||
- 再次运行练习程序时,能够读取上次保存的任务;
|
||||
- 已完成第二阶段的综合项目,具备进入第三阶段“面向对象编程”的基础。
|
||||
@@ -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()
|
||||
@@ -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. 是否只保存当前课程目录中的练习数据。
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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")
|
||||
@@ -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` 的实际输出与题目预期一致。
|
||||
@@ -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("差异 1:Python 使用 __init__ 初始化对象,不写与类同名的构造器。")
|
||||
print(code_agent.describe())
|
||||
print()
|
||||
|
||||
print("差异 2:self 类似 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("差异 5:Python 不使用 new,直接调用类即可创建对象。")
|
||||
print(review_agent.describe())
|
||||
print(f"名称是否合法:{Agent.is_valid_name(review_agent.name)}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -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. 是否没有在类外直接替学习者写出答案。
|
||||
@@ -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 的调用差异。
|
||||
@@ -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("差异 1:Python 主要依靠命名约定表达成员用途。")
|
||||
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()
|
||||
@@ -0,0 +1,70 @@
|
||||
# 第 3-2 课课堂练习:Python 与 Java 的封装差异
|
||||
#
|
||||
# 关键规则已经写进每部分题目,可以不先完整阅读讲义。
|
||||
# 请保留题目、预期结果和自查说明,并独立完成 Product 类。
|
||||
|
||||
|
||||
# 第一部分:定义 Product 类和初始化方法
|
||||
# 1. 定义 __init__(self, name, price) 方法。
|
||||
# 2. name 标注为 str,price 标注为 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. 是否只把运行错误和逻辑错误视为阻塞项,不把可选类型注解当作必须项。
|
||||
@@ -0,0 +1,242 @@
|
||||
# 第 3-3 课:Python 与 Java 的继承和多态差异
|
||||
|
||||
## 一、本课定位
|
||||
|
||||
你已经掌握 Java 的继承、接口和多态,因此本课不重复通用理论,重点建立以下 Python 差异:
|
||||
|
||||
- `super()` 不需要显式写父类名;
|
||||
- Python 没有强制的 `@Override`;
|
||||
- Python 支持多继承,并使用方法解析顺序决定查找路径;
|
||||
- 多态调用经常基于鸭子类型,不要求对象继承共同父类或显式实现接口。
|
||||
|
||||
## 二、本课目标
|
||||
|
||||
完成本课后,你将能够:
|
||||
|
||||
1. 定义父类和子类并重写方法;
|
||||
2. 使用 `super()` 复用继承链中的实现;
|
||||
3. 解释方法解析顺序(Method Resolution Order,MRO);
|
||||
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` 输出正确支付结果。
|
||||
@@ -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("差异 2:Python 重写方法时没有强制的 @Override。")
|
||||
email.send("课程开始")
|
||||
print()
|
||||
|
||||
print("差异 3:鸭子类型关注对象有什么行为,而不是声明了什么接口。")
|
||||
send_message(email, "继承得到 send()")
|
||||
send_message(console, "未继承也能调用 send()")
|
||||
print()
|
||||
|
||||
print("差异 4:Python 支持多继承,并通过 MRO 决定方法查找顺序。")
|
||||
logged_email = LoggedEmailNotifier("系统", "admin@example.com")
|
||||
logged_email.record()
|
||||
print([class_type.__name__ for class_type in LoggedEmailNotifier.mro()])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -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 Class,ABC),先观察普通父类的写法;
|
||||
# - 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. 是否只把运行错误、结果错误和关键知识点错误视为阻塞项。
|
||||
@@ -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 Mapping,ORM)模型。
|
||||
|
||||
例如,`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` 的购物车计算与独立列表验证正确。
|
||||
@@ -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. 返回“机械键盘 × 2:1000.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()。
|
||||
#
|
||||
# 预期输出:
|
||||
# 机械键盘 × 2:1000.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. 是否只把运行错误、结果错误和关键知识点错误视为阻塞项。
|
||||
@@ -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("差异 5:default_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()
|
||||
@@ -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 的主要差异。
|
||||
|
||||
通过后,第三阶段完成,下一阶段开始数据库编程,并逐步把本项目升级为持久化版本。
|
||||
@@ -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()
|
||||
@@ -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. 程序实际输出的业务结果正确;说明文字或非必要类型注解不阻塞验收。
|
||||
@@ -0,0 +1,290 @@
|
||||
# 第 4-1 课:PostgreSQL 与 Psycopg 入门
|
||||
|
||||
## 一、本课定位
|
||||
|
||||
你已经掌握 SQL、事务和 Java 数据库开发,因此本课不再从表、字段和增删改查讲起,而是集中回答一个问题:Python 程序怎样安全地连接 PostgreSQL 并执行 SQL?
|
||||
|
||||
Python 数据库 API(Database API,DB-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` 排除。
|
||||
|
||||
TOML(Tom's Obvious Minimal Language)是一种结构化配置格式。Python 3.11 及以上版本内置 `tomllib`,读取 TOML 不需要安装额外依赖,也不会修改操作系统或当前进程的环境变量。
|
||||
|
||||
## 六、安装 Psycopg 3
|
||||
|
||||
安装前建议创建课程专用环境,不要默认向系统级`base`环境安装。本课程同时支持pip和Conda两种方式。下面的命令假设PowerShell已经进入本课目录,并且已经激活目标课程环境。
|
||||
|
||||
pip方式:
|
||||
|
||||
```powershell
|
||||
python -m pip install -r .\requirements.txt
|
||||
```
|
||||
|
||||
Conda方式:
|
||||
|
||||
```powershell
|
||||
conda install -c conda-forge psycopg psycopg-c libpq
|
||||
```
|
||||
|
||||
两种方式的环境创建、区别、镜像配置,以及`EnvironmentNotWritableError`、`no pq wrapper available`、PyCharm原生崩溃等问题,统一参见[附录:Psycopg安装、环境选择与常见问题排查](./附录_Psycopg安装与排错.md)。
|
||||
|
||||
无论采用哪种方式,导入时都写`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`或Conda课程环境,再使用本课的`requirements.txt`安装依赖。可以运行`python -c "import sys; print(sys.executable)"`确认解释器,并使用`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 跟踪文件。
|
||||
@@ -0,0 +1,10 @@
|
||||
# 复制本文件并重命名为 config.toml,再填写本地练习数据库信息。
|
||||
# config.toml 已加入 .gitignore,不会被 Git 跟踪。
|
||||
|
||||
[postgresql]
|
||||
host = "数据库主机"
|
||||
port = 5432
|
||||
dbname = "数据库名"
|
||||
user = "用户名"
|
||||
password = "密码"
|
||||
connect_timeout = 10
|
||||
@@ -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()
|
||||
@@ -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 跟踪文件。
|
||||
@@ -0,0 +1,3 @@
|
||||
# Psycopg 3 是 PostgreSQL 的 Python 数据库驱动。
|
||||
# binary 额外依赖适合本地学习,可避免首次安装时配置 C 编译环境。
|
||||
psycopg[binary]>=3,<4
|
||||
@@ -0,0 +1,494 @@
|
||||
# 附录:Psycopg安装、环境选择与常见问题排查
|
||||
|
||||
## 一、附录用途
|
||||
|
||||
本附录集中说明Windows环境下安装Psycopg 3时容易混淆的问题,并整理本课程实际遇到的错误。正文只保留数据库编程主线;安装失败、解释器不一致或原生库异常时,再查阅本附录。
|
||||
|
||||
本附录覆盖两种安装方式:
|
||||
|
||||
1. 使用pip安装;
|
||||
2. 使用Conda安装。
|
||||
|
||||
无论选择哪一种,都应先创建课程专用环境,不建议把课程依赖继续安装到系统级`base`环境。
|
||||
|
||||
## 二、先分清Psycopg 2和Psycopg 3
|
||||
|
||||
以下名称非常相似,但不是同一个版本:
|
||||
|
||||
| 安装包 | Python导入语句 | 说明 |
|
||||
|---|---|---|
|
||||
| `psycopg` | `import psycopg` | Psycopg 3主体包 |
|
||||
| `psycopg-binary` | 仍然使用`import psycopg` | Psycopg 3预编译二进制实现 |
|
||||
| `psycopg-c` | 仍然使用`import psycopg` | Psycopg 3本地C扩展实现 |
|
||||
| `psycopg2` | `import psycopg2` | Psycopg 2源码/本地库方案 |
|
||||
| `psycopg2-binary` | `import psycopg2` | Psycopg 2预编译方案 |
|
||||
|
||||
不存在以下正确导入方式:
|
||||
|
||||
```python
|
||||
import psycopg3
|
||||
import psycopg_binary
|
||||
import psycopg2_binary
|
||||
```
|
||||
|
||||
本课程使用Psycopg 3,因此代码统一写:
|
||||
|
||||
```python
|
||||
import psycopg
|
||||
```
|
||||
|
||||
如果编辑器只能找到`psycopg2`,通常表示安装的是Psycopg 2,或者编辑器选用了另一个Python解释器。
|
||||
|
||||
## 三、Psycopg 3的三种底层实现
|
||||
|
||||
Psycopg 3主体包会选择一种底层`libpq`包装实现。`libpq`是PostgreSQL官方客户端库,负责底层数据库通信。
|
||||
|
||||
| 实现 | 常见安装方式 | 特点 | 额外要求 |
|
||||
|---|---|---|---|
|
||||
| `python` | `pip install psycopg` | 纯Python包装,适合调试和小型任务 | 系统中必须能找到`libpq` |
|
||||
| `c` | `pip install "psycopg[c]"`或Conda的`psycopg-c` | C扩展,性能较好 | 本地`libpq`;pip源码构建还需要编译工具 |
|
||||
| `binary` | `pip install "psycopg[binary]"` | 预编译并自带客户端库,安装最省事 | 需要当前Python和平台存在可用二进制包 |
|
||||
|
||||
查看当前实际使用的实现:
|
||||
|
||||
```powershell
|
||||
python -c "import psycopg, psycopg.pq; print(psycopg.__version__); print(psycopg.pq.__impl__); print(psycopg.pq.version())"
|
||||
```
|
||||
|
||||
可能输出:
|
||||
|
||||
```text
|
||||
3.3.4
|
||||
binary
|
||||
180004
|
||||
```
|
||||
|
||||
其中:
|
||||
|
||||
- 第一行是Psycopg版本;
|
||||
- 第二行是`python`、`c`或`binary`实现;
|
||||
- 第三行是实际加载的`libpq`版本号。
|
||||
|
||||
## 四、安装前先确认当前环境
|
||||
|
||||
### 4.1 查看Conda环境
|
||||
|
||||
```powershell
|
||||
conda env list
|
||||
```
|
||||
|
||||
当前激活环境前会显示`*`。
|
||||
|
||||
### 4.2 查看Python解释器
|
||||
|
||||
```powershell
|
||||
python -c "import sys; print(sys.executable); print(sys.version)"
|
||||
```
|
||||
|
||||
如果课程环境名为`python-test`,路径应类似:
|
||||
|
||||
```text
|
||||
C:\Users\你的用户名\.conda\envs\python-test\python.exe
|
||||
```
|
||||
|
||||
如果仍然显示:
|
||||
|
||||
```text
|
||||
C:\ProgramData\miniconda3\python.exe
|
||||
```
|
||||
|
||||
说明当前使用的还是系统级`base`解释器。
|
||||
|
||||
### 4.3 为什么不推荐系统级base
|
||||
|
||||
系统级Miniconda可能安装在:
|
||||
|
||||
```text
|
||||
C:\ProgramData\miniconda3
|
||||
```
|
||||
|
||||
普通用户通常没有写权限,安装时会出现:
|
||||
|
||||
```text
|
||||
EnvironmentNotWritableError
|
||||
```
|
||||
|
||||
独立环境还可以避免数据库课程依赖污染其他Python项目,也便于删除和重建。
|
||||
|
||||
## 五、方式一:使用pip安装
|
||||
|
||||
### 5.1 创建并激活Conda环境
|
||||
|
||||
可以让Conda只负责环境和Python,再让pip安装Psycopg:
|
||||
|
||||
```powershell
|
||||
conda create -n python-test python=3.13 pip
|
||||
conda activate python-test
|
||||
```
|
||||
|
||||
### 5.2 推荐安装命令
|
||||
|
||||
本地学习优先使用预编译二进制实现:
|
||||
|
||||
```powershell
|
||||
python -m pip install "psycopg[binary]>=3,<4"
|
||||
```
|
||||
|
||||
也可以使用本课的依赖文件:
|
||||
|
||||
```powershell
|
||||
python -m pip install -r requirements.txt
|
||||
```
|
||||
|
||||
`requirements.txt`中的:
|
||||
|
||||
```text
|
||||
psycopg[binary]>=3,<4
|
||||
```
|
||||
|
||||
表示:
|
||||
|
||||
- 安装Psycopg 3;
|
||||
- 同时安装`binary`额外依赖;
|
||||
- 版本不低于3且低于4。
|
||||
|
||||
### 5.3 为什么使用`python -m pip`
|
||||
|
||||
不要优先直接执行:
|
||||
|
||||
```powershell
|
||||
pip install ...
|
||||
```
|
||||
|
||||
直接运行`pip.exe`时,可能遇到访问被拒绝,或者调用了其他环境中的pip。下面的写法明确表示“使用当前Python解释器对应的pip”:
|
||||
|
||||
```powershell
|
||||
python -m pip install ...
|
||||
```
|
||||
|
||||
这能降低“包安装成功,但程序使用另一个Python”的概率。
|
||||
|
||||
### 5.4 pip国内镜像临时用法
|
||||
|
||||
如果访问PyPI失败,可以只为当前命令指定镜像:
|
||||
|
||||
```powershell
|
||||
python -m pip install -i https://pypi.tuna.tsinghua.edu.cn/simple "psycopg[binary]>=3,<4"
|
||||
```
|
||||
|
||||
临时指定不会永久修改系统或用户配置。
|
||||
|
||||
### 5.5 pip安装后的确认
|
||||
|
||||
```powershell
|
||||
python -m pip show psycopg psycopg-binary
|
||||
python -c "import sys, psycopg, psycopg.pq; print(sys.executable); print(psycopg.__version__); print(psycopg.pq.__impl__)"
|
||||
```
|
||||
|
||||
预期底层实现通常是:
|
||||
|
||||
```text
|
||||
binary
|
||||
```
|
||||
|
||||
## 六、方式二:使用Conda安装
|
||||
|
||||
### 6.1 创建独立环境并一次安装
|
||||
|
||||
推荐让核心包全部来自`conda-forge`,减少Python、OpenSSL和`libpq`混用不同频道的风险:
|
||||
|
||||
```powershell
|
||||
conda create -n python-db --override-channels -c conda-forge python=3.13 psycopg psycopg-c libpq openssl
|
||||
conda activate python-db
|
||||
```
|
||||
|
||||
如果已经创建了`python-test`:
|
||||
|
||||
```powershell
|
||||
conda activate python-test
|
||||
conda install -c conda-forge psycopg psycopg-c libpq
|
||||
```
|
||||
|
||||
不要在激活`python-test`后仍然写:
|
||||
|
||||
```powershell
|
||||
conda install -n base ...
|
||||
```
|
||||
|
||||
`-n base`会明确要求安装到`base`,不会因为当前激活了`python-test`而自动改用当前环境。
|
||||
|
||||
### 6.2 Conda不能使用pip的`-r`
|
||||
|
||||
下面的命令是错误的:
|
||||
|
||||
```powershell
|
||||
conda install -r requirements.txt
|
||||
```
|
||||
|
||||
`-r requirements.txt`是pip的参数,不是Conda的通用安装方式。对应写法是:
|
||||
|
||||
```powershell
|
||||
python -m pip install -r requirements.txt
|
||||
```
|
||||
|
||||
使用Conda时则应直接写包名:
|
||||
|
||||
```powershell
|
||||
conda install -c conda-forge psycopg psycopg-c libpq
|
||||
```
|
||||
|
||||
### 6.3 配置清华Conda镜像
|
||||
|
||||
访问官方`conda-forge`失败时,可以把`conda-forge`映射到清华镜像:
|
||||
|
||||
```powershell
|
||||
conda config --set show_channel_urls yes
|
||||
conda config --set custom_channels.conda-forge https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud
|
||||
conda clean --index-cache
|
||||
```
|
||||
|
||||
查看最终配置来源:
|
||||
|
||||
```powershell
|
||||
conda config --show-sources
|
||||
conda config --show custom_channels
|
||||
```
|
||||
|
||||
Windows用户级配置通常位于:
|
||||
|
||||
```text
|
||||
C:\Users\你的用户名\.condarc
|
||||
```
|
||||
|
||||
然后重新安装:
|
||||
|
||||
```powershell
|
||||
conda activate python-test
|
||||
conda install -c conda-forge psycopg psycopg-c libpq
|
||||
```
|
||||
|
||||
### 6.4 Conda安装后的确认
|
||||
|
||||
```powershell
|
||||
conda list | Select-String "python|psycopg|libpq|openssl"
|
||||
python -c "import sys, psycopg, psycopg.pq; print(sys.executable); print(psycopg.__version__); print(psycopg.pq.__impl__); print(psycopg.pq.version())"
|
||||
```
|
||||
|
||||
使用`psycopg-c`时,底层实现应为:
|
||||
|
||||
```text
|
||||
c
|
||||
```
|
||||
|
||||
## 七、pip和Conda的区别
|
||||
|
||||
| 对比项 | pip | Conda |
|
||||
|---|---|---|
|
||||
| 主要管理对象 | Python包 | Python包、解释器和原生库 |
|
||||
| 默认软件仓库 | PyPI | defaults或conda-forge等频道 |
|
||||
| 环境创建 | 通常配合`venv`或Conda | 原生支持`conda create` |
|
||||
| `libpq`、OpenSSL等原生依赖 | binary包可自带;否则依赖系统 | 可以作为Conda包统一安装 |
|
||||
| 安装速度与包可用性 | PyPI新版本通常更及时 | 取决于频道是否已构建对应平台包 |
|
||||
| 依赖解析范围 | 主要关注Python包 | 同时解析Python和原生库依赖 |
|
||||
| 本课推荐场景 | 希望安装简单、使用`binary`实现 | 希望统一管理Python、C扩展和`libpq` |
|
||||
|
||||
### 7.1 pip方案的优势
|
||||
|
||||
- 命令与大多数Python项目的`requirements.txt`一致;
|
||||
- `psycopg[binary]`通常安装最直接;
|
||||
- 预编译包自带所需客户端库,受本机DLL路径影响较小;
|
||||
- PyPI上的版本通常更新较快。
|
||||
|
||||
### 7.2 Conda方案的优势
|
||||
|
||||
- 可以同时管理Python、`psycopg-c`、`libpq`和OpenSSL;
|
||||
- 不需要手工准备Visual Studio C++编译环境;
|
||||
- 适合已经使用Conda管理数据分析或科学计算环境的项目。
|
||||
|
||||
### 7.3 不建议随意混装
|
||||
|
||||
同一个环境中可以混合使用Conda和pip,但顺序和边界必须清楚。推荐:
|
||||
|
||||
1. 先用Conda安装Python及能够满足的原生依赖;
|
||||
2. 再用当前环境的`python -m pip`安装Conda没有的Python包;
|
||||
3. pip安装后不要再让Conda大范围重算并替换同一批核心依赖;
|
||||
4. 出现原生崩溃时,检查Python、Psycopg、`libpq`和OpenSSL是否来自相互兼容的来源。
|
||||
|
||||
本课程选择一种方案成功后即可,不需要同时安装`psycopg-c`和`psycopg-binary`。
|
||||
|
||||
## 八、实际遇到的问题与原因
|
||||
|
||||
### 8.1 `conda install -r requirements.txt`报参数错误
|
||||
|
||||
错误原因:把pip参数用于Conda。
|
||||
|
||||
正确处理:
|
||||
|
||||
```powershell
|
||||
python -m pip install -r requirements.txt
|
||||
```
|
||||
|
||||
或者使用Conda包名安装。
|
||||
|
||||
### 8.2 直接运行`pip.exe`显示`Access is denied`
|
||||
|
||||
可能原因包括`pip.exe`权限、命令解析或环境路径异常。
|
||||
|
||||
优先改为:
|
||||
|
||||
```powershell
|
||||
python -m pip install -r requirements.txt
|
||||
```
|
||||
|
||||
同时用`sys.executable`确认当前Python。
|
||||
|
||||
### 8.3 `EnvironmentNotWritableError`
|
||||
|
||||
典型信息:
|
||||
|
||||
```text
|
||||
environment location: C:\ProgramData\miniconda3
|
||||
```
|
||||
|
||||
原因:普通用户没有系统级`base`环境写权限。
|
||||
|
||||
推荐处理:创建用户自己的独立环境,不要修改系统目录权限:
|
||||
|
||||
```powershell
|
||||
conda create -n python-test python=3.13 pip
|
||||
conda activate python-test
|
||||
```
|
||||
|
||||
### 8.4 激活新环境后仍然安装到base
|
||||
|
||||
错误命令:
|
||||
|
||||
```powershell
|
||||
conda activate python-test
|
||||
conda install -n base -c conda-forge psycopg
|
||||
```
|
||||
|
||||
`-n base`覆盖了当前环境选择。正确写法:
|
||||
|
||||
```powershell
|
||||
conda install -c conda-forge psycopg
|
||||
```
|
||||
|
||||
或明确指定:
|
||||
|
||||
```powershell
|
||||
conda install -n python-test -c conda-forge psycopg
|
||||
```
|
||||
|
||||
### 8.5 CondaHTTPError访问`conda-forge`失败
|
||||
|
||||
先检查:
|
||||
|
||||
```powershell
|
||||
conda config --show-sources
|
||||
conda config --show channels
|
||||
conda config --show proxy_servers
|
||||
```
|
||||
|
||||
配置没有错误时,可能是网络、代理或官方源可达性问题。可以使用前面的清华镜像配置后清理索引缓存重试。
|
||||
|
||||
### 8.6 只能导入`psycopg2`
|
||||
|
||||
原因通常是安装了`psycopg2-binary`,而不是Psycopg 3。
|
||||
|
||||
确认命令:
|
||||
|
||||
```powershell
|
||||
python -c "import importlib.util; print(importlib.util.find_spec('psycopg')); print(importlib.util.find_spec('psycopg2'))"
|
||||
python -m pip show psycopg psycopg-binary psycopg2-binary
|
||||
```
|
||||
|
||||
本课程需要`psycopg`能够被找到。
|
||||
|
||||
### 8.7 `no pq wrapper available`
|
||||
|
||||
典型信息:
|
||||
|
||||
```text
|
||||
- couldn't import psycopg 'c' implementation
|
||||
- couldn't import psycopg 'binary' implementation
|
||||
- couldn't import psycopg 'python' implementation: libpq library not found
|
||||
```
|
||||
|
||||
含义:
|
||||
|
||||
- 没有`psycopg-c`;
|
||||
- 没有`psycopg-binary`;
|
||||
- 纯Python实现又找不到`libpq`。
|
||||
|
||||
解决方式任选其一:
|
||||
|
||||
```powershell
|
||||
python -m pip install psycopg-binary
|
||||
```
|
||||
|
||||
或者通过Conda安装完整本地库:
|
||||
|
||||
```powershell
|
||||
conda install -c conda-forge psycopg psycopg-c libpq
|
||||
```
|
||||
|
||||
### 8.8 PyCharm退出代码`0xC0000005`
|
||||
|
||||
`0xC0000005`是Windows原生访问冲突,不是普通Python异常,`try...except`无法捕获。它曾发生在`psycopg.connect()`进入C扩展或客户端DLL后。
|
||||
|
||||
排查步骤:
|
||||
|
||||
1. 在终端使用同一个解释器运行同一文件;
|
||||
2. 输出`sys.executable`确认解释器一致;
|
||||
3. 输出`psycopg.pq.__impl__`确认底层实现;
|
||||
4. 检查环境是否混用了不同频道的Python、`psycopg-c`、`libpq`和OpenSSL;
|
||||
5. 优先重建核心包来源一致的环境;
|
||||
6. 本地学习也可以改用`psycopg-binary`降低DLL路径差异。
|
||||
|
||||
Conda的Windows原生库通常位于:
|
||||
|
||||
```text
|
||||
C:\Users\你的用户名\.conda\envs\环境名\Library\bin
|
||||
```
|
||||
|
||||
PyCharm必须选择正确的Conda解释器。必要时检查其运行配置是否能找到该目录中的DLL。
|
||||
|
||||
## 九、编辑器解释器检查
|
||||
|
||||
在PyCharm中选择的解释器必须与终端验证成功的解释器一致。例如:
|
||||
|
||||
```text
|
||||
C:\Users\你的用户名\.conda\envs\python-test\python.exe
|
||||
```
|
||||
|
||||
可以在程序中临时确认:
|
||||
|
||||
```python
|
||||
import sys
|
||||
|
||||
print(sys.executable)
|
||||
```
|
||||
|
||||
如果终端能够导入、PyCharm不能导入,通常不是代码问题,而是编辑器解释器或原生库搜索路径不同。
|
||||
|
||||
## 十、最终安装验收清单
|
||||
|
||||
依次执行:
|
||||
|
||||
```powershell
|
||||
conda env list
|
||||
python -c "import sys; print(sys.executable); print(sys.version)"
|
||||
python -c "import psycopg, psycopg.pq; print(psycopg.__version__); print(psycopg.__file__); print(psycopg.pq.__impl__); print(psycopg.pq.version())"
|
||||
```
|
||||
|
||||
验收标准:
|
||||
|
||||
- Python路径指向预期的课程环境;
|
||||
- `import psycopg`成功;
|
||||
- Psycopg主版本为3;
|
||||
- 底层实现是预期的`binary`、`c`或`python`;
|
||||
- PyCharm与PowerShell使用同一个解释器;
|
||||
- 实际运行第一课连接示例时不出现导入错误或原生崩溃。
|
||||
|
||||
安装只解决客户端依赖。数据库地址、端口、防火墙、代理、PostgreSQL监听和账号权限属于连接问题,应与安装问题分开判断。
|
||||
@@ -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`。
|
||||
@@ -0,0 +1,10 @@
|
||||
# 复制本文件并重命名为 config.toml,再填写本地练习数据库信息。
|
||||
# config.toml 已加入项目 .gitignore,不会被 Git 跟踪。
|
||||
|
||||
[postgresql]
|
||||
host = "数据库主机"
|
||||
port = 5432
|
||||
dbname = "数据库名"
|
||||
user = "用户名"
|
||||
password = "密码"
|
||||
connect_timeout = 10
|
||||
@@ -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。
|
||||
@@ -0,0 +1,3 @@
|
||||
# 第二课继续使用 Psycopg 3,不新增第三方框架。
|
||||
# 若使用 Conda,可以在课程环境中安装 psycopg 或 psycopg-c。
|
||||
psycopg>=3,<4
|
||||
@@ -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()
|
||||
@@ -0,0 +1,470 @@
|
||||
# 第4-3课:SQLAlchemy基础
|
||||
|
||||
## 一、本课定位
|
||||
|
||||
前两课直接使用Psycopg,已经理解连接、游标、参数化SQL、事务和Repository分层。本课开始使用SQLAlchemy 2.x,将生产项目常用的连接池、SQL工具和对象关系映射(Object Relational Mapping,ORM)引入课程。
|
||||
|
||||
本课不会隐藏底层原理。SQLAlchemy最终仍通过Psycopg连接PostgreSQL:
|
||||
|
||||
```text
|
||||
业务代码
|
||||
↓
|
||||
SQLAlchemy ORM和Session
|
||||
↓
|
||||
SQLAlchemy Engine与连接池
|
||||
↓
|
||||
Psycopg
|
||||
↓
|
||||
PostgreSQL
|
||||
```
|
||||
|
||||
本课示例会创建`course_orm_book`表,只重置`ORM-`前缀课程数据;练习创建`course_orm_product`表,只操作`ORM-P-`前缀数据。不要连接生产数据库。
|
||||
|
||||
## 二、本课目标
|
||||
|
||||
完成本课后,你将能够:
|
||||
|
||||
1. 解释SQLAlchemy Core和ORM的关系;
|
||||
2. 使用`Engine`统一管理数据库方言和连接池;
|
||||
3. 使用声明式模型映射Python类与数据库表;
|
||||
4. 使用`Session`管理ORM对象和事务;
|
||||
5. 使用SQLAlchemy 2.x的`select()`查询对象;
|
||||
6. 完成ORM新增、查询、修改和删除;
|
||||
7. 区分`flush()`、`commit()`、`rollback()`和`close()`;
|
||||
8. 理解Session的工作单元和身份映射;
|
||||
9. 对照JDBC、MyBatis-Plus和JPA/Hibernate理解SQLAlchemy。
|
||||
|
||||
## 三、SQLAlchemy解决什么问题
|
||||
|
||||
直接使用Psycopg时,需要自行处理:
|
||||
|
||||
- 创建数据库连接;
|
||||
- 复用或关闭连接;
|
||||
- 手写SQL;
|
||||
- 把查询元组转换为对象;
|
||||
- 跟踪对象修改;
|
||||
- 组织提交和回滚。
|
||||
|
||||
SQLAlchemy提供两个主要层次:
|
||||
|
||||
| 层次 | 作用 |
|
||||
|---|---|
|
||||
| SQLAlchemy Core | Engine、连接池、SQL表达式、表元数据、方言适配 |
|
||||
| SQLAlchemy ORM | 类表映射、Session、对象查询、关系和工作单元 |
|
||||
|
||||
ORM建立在Core之上。使用ORM并不意味着不再需要理解SQL、事务和索引。
|
||||
|
||||
## 四、与Java技术体系对照
|
||||
|
||||
| Java常见技术 | SQLAlchemy中的相近概念 | 说明 |
|
||||
|---|---|---|
|
||||
| JDBC Driver | Psycopg | PostgreSQL底层驱动 |
|
||||
| `DataSource`和HikariCP | `Engine`和连接池 | 管理连接获取、复用和归还 |
|
||||
| JPA实体 | 声明式ORM模型 | 类和表之间的映射 |
|
||||
| `EntityManager` | `Session` | 管理持久化对象和事务 |
|
||||
| Persistence Context | Session身份映射 | 同一Session内按主键维护对象身份 |
|
||||
| Dirty Checking | Session变更跟踪 | 修改对象属性后生成UPDATE |
|
||||
| JPQL/Criteria | `select()`表达式 | 用Python表达式构造查询 |
|
||||
| `@Transactional` | `Session.begin()`上下文 | 正常提交、异常回滚 |
|
||||
|
||||
SQLAlchemy ORM总体更接近JPA/Hibernate。它也能简化常规增删改查,使用体验部分接近MyBatis-Plus,但不是以Mapper接口和SQL模板为中心。
|
||||
|
||||
## 五、Engine和连接池
|
||||
|
||||
创建Engine:
|
||||
|
||||
```python
|
||||
engine = create_engine(
|
||||
database_url,
|
||||
connect_args={"connect_timeout": 10},
|
||||
pool_size=5,
|
||||
max_overflow=5,
|
||||
pool_pre_ping=True,
|
||||
echo=False,
|
||||
)
|
||||
```
|
||||
|
||||
Engine不是一条固定连接,而是数据库访问入口。它组合了:
|
||||
|
||||
- 数据库URL;
|
||||
- PostgreSQL方言;
|
||||
- Psycopg驱动;
|
||||
- 连接池;
|
||||
- SQL执行和事件机制。
|
||||
|
||||
### 5.1 常用连接池参数
|
||||
|
||||
| 参数 | 含义 |
|
||||
|---|---|
|
||||
| `pool_size=5` | 池中长期保留的连接数量上限 |
|
||||
| `max_overflow=5` | 池满时允许临时增加的连接数 |
|
||||
| `pool_pre_ping=True` | 取出连接时先检查连接是否仍可用 |
|
||||
| `pool_timeout` | 连接池耗尽时最多等待多少秒 |
|
||||
| `pool_recycle` | 连接存活超过指定秒数后回收更新 |
|
||||
|
||||
`connect_args`会把驱动专用参数交给Psycopg。本课将TOML中的`connect_timeout`传入,避免网络异常时无限等待。
|
||||
|
||||
`pool_size=5`不代表程序启动时立即创建5条连接。Engine通常按需创建连接。
|
||||
|
||||
生产应用通常在启动时创建一个Engine,不应在每个Repository方法或循环中重复`create_engine()`。
|
||||
|
||||
### 5.2 为什么使用URL.create()
|
||||
|
||||
本课使用:
|
||||
|
||||
```python
|
||||
database_url = URL.create(
|
||||
drivername="postgresql+psycopg",
|
||||
username=database_config["user"],
|
||||
password=database_config["password"],
|
||||
host=database_config["host"],
|
||||
port=database_config["port"],
|
||||
database=database_config["dbname"],
|
||||
)
|
||||
```
|
||||
|
||||
`postgresql+psycopg`表示:
|
||||
|
||||
```text
|
||||
数据库方言:PostgreSQL
|
||||
DB-API驱动:Psycopg 3
|
||||
```
|
||||
|
||||
结构化创建URL可以避免手工拼接连接串,也不用自己处理密码中的`@`、`:`等特殊字符。
|
||||
|
||||
## 六、声明式模型
|
||||
|
||||
先定义共同基类:
|
||||
|
||||
```python
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
```
|
||||
|
||||
再定义映射模型:
|
||||
|
||||
```python
|
||||
class Book(Base):
|
||||
__tablename__ = "course_orm_book"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
isbn: Mapped[str] = mapped_column(String(30), unique=True, nullable=False)
|
||||
title: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
```
|
||||
|
||||
拆开理解:
|
||||
|
||||
- `Book`是可以正常创建和使用的Python类;
|
||||
- `__tablename__`指定数据库表名;
|
||||
- `Mapped[str]`说明ORM属性映射的Python类型;
|
||||
- `mapped_column()`描述数据库列和约束;
|
||||
- `Base.metadata`收集所有模型的表元数据。
|
||||
|
||||
调用:
|
||||
|
||||
```python
|
||||
Base.metadata.create_all(engine)
|
||||
```
|
||||
|
||||
会创建缺失的表,但它不是完整的数据库迁移工具,不能可靠地把已有表自动升级成新结构。后续FastAPI阶段会学习数据库迁移。
|
||||
|
||||
## 七、Session是什么
|
||||
|
||||
Session不是数据库连接本身,也不是线程安全的全局单例。它主要负责:
|
||||
|
||||
- 从Engine申请连接;
|
||||
- 开始和结束数据库事务;
|
||||
- 保存当前持久化上下文中的ORM对象;
|
||||
- 跟踪新增、修改和删除;
|
||||
- 在合适时机把变化同步到数据库;
|
||||
- 提交或回滚事务;
|
||||
- 把连接归还连接池。
|
||||
|
||||
创建Session工厂:
|
||||
|
||||
```python
|
||||
session_factory = sessionmaker(
|
||||
engine,
|
||||
expire_on_commit=False,
|
||||
)
|
||||
```
|
||||
|
||||
`sessionmaker`类似统一配置后的Session工厂。生产代码让每个请求或业务任务创建自己的Session,而不是让多个并发请求共享同一个Session。
|
||||
|
||||
### 7.1 身份映射
|
||||
|
||||
Session内部维护身份映射(Identity Map)。在同一个Session中,以相同主键加载同一行时,通常会得到同一个Python对象实例:
|
||||
|
||||
```python
|
||||
first = session.get(Book, 1)
|
||||
second = session.get(Book, 1)
|
||||
|
||||
print(first is second) # 通常为True
|
||||
```
|
||||
|
||||
这与JPA持久化上下文中的实体身份概念相近。
|
||||
|
||||
### 7.2 工作单元
|
||||
|
||||
工作单元(Unit of Work)表示Session收集一组对象变化,再统一生成SQL:
|
||||
|
||||
```python
|
||||
book = session.scalar(select(Book).where(Book.isbn == "ORM-001"))
|
||||
book.price = Decimal("72.00")
|
||||
```
|
||||
|
||||
这里只修改了Python属性,没有手写`UPDATE`。Session会识别变化,在`flush()`或提交时发送UPDATE。
|
||||
|
||||
## 八、Session事务写法
|
||||
|
||||
写操作推荐使用:
|
||||
|
||||
```python
|
||||
with session_factory.begin() as session:
|
||||
session.add(book)
|
||||
```
|
||||
|
||||
其行为是:
|
||||
|
||||
```text
|
||||
创建Session
|
||||
→ 开始事务
|
||||
→ 执行业务
|
||||
→ 正常结束时flush并commit
|
||||
→ 异常时rollback
|
||||
→ 关闭Session
|
||||
→ 连接归还连接池
|
||||
```
|
||||
|
||||
只读查询可以使用:
|
||||
|
||||
```python
|
||||
with session_factory() as session:
|
||||
books = session.scalars(select(Book)).all()
|
||||
```
|
||||
|
||||
离开Session的`with`会关闭Session并释放连接资源,但不会替你提交尚未提交的写操作。不要把“关闭Session”误认为“提交事务”。
|
||||
|
||||
## 九、flush与commit的区别
|
||||
|
||||
### 9.1 flush
|
||||
|
||||
```python
|
||||
session.add(book)
|
||||
session.flush()
|
||||
print(book.id)
|
||||
```
|
||||
|
||||
`flush()`把Session内待处理变化发送给数据库,例如执行INSERT并取得数据库生成的主键。但是:
|
||||
|
||||
- 当前事务仍未提交;
|
||||
- 其他事务通常还看不到结果;
|
||||
- 后续发生异常仍可以回滚。
|
||||
|
||||
### 9.2 commit
|
||||
|
||||
`commit()`先执行必要的`flush()`,然后提交数据库事务。提交成功后,本事务的修改成为持久结果。
|
||||
|
||||
### 9.3 rollback
|
||||
|
||||
`rollback()`撤销当前事务中未提交的数据库变化,并调整Session中的对象状态。事务失败后必须回滚或关闭Session,才能安全开始后续工作。
|
||||
|
||||
一句话记忆:
|
||||
|
||||
```text
|
||||
flush:把变化发给数据库,但还可以回滚。
|
||||
commit:确认事务结果,完成持久化。
|
||||
```
|
||||
|
||||
## 十、ORM增删改查
|
||||
|
||||
### 10.1 新增
|
||||
|
||||
```python
|
||||
book = Book(isbn="ORM-001", title="Python数据库编程", ...)
|
||||
session.add(book)
|
||||
```
|
||||
|
||||
多个对象使用:
|
||||
|
||||
```python
|
||||
session.add_all([first_book, second_book])
|
||||
```
|
||||
|
||||
### 10.2 查询
|
||||
|
||||
SQLAlchemy 2.x使用`select()`:
|
||||
|
||||
```python
|
||||
statement = (
|
||||
select(Book)
|
||||
.where(Book.isbn.like("ORM-%"))
|
||||
.order_by(Book.isbn)
|
||||
)
|
||||
books = session.scalars(statement).all()
|
||||
```
|
||||
|
||||
不要在本课程中使用旧式:
|
||||
|
||||
```python
|
||||
session.query(Book).filter(...)
|
||||
```
|
||||
|
||||
`session.scalars()`适合只需要ORM对象的查询。`session.execute()`返回更通用的结果行。
|
||||
|
||||
### 10.3 修改
|
||||
|
||||
```python
|
||||
book = session.scalar(select(Book).where(Book.isbn == "ORM-001"))
|
||||
book.price = Decimal("72.00")
|
||||
```
|
||||
|
||||
Session跟踪属性变化,在flush时生成UPDATE。
|
||||
|
||||
### 10.4 删除
|
||||
|
||||
```python
|
||||
session.delete(book)
|
||||
```
|
||||
|
||||
对象会被标记为删除,DELETE在flush时发送。
|
||||
|
||||
## 十一、expire_on_commit
|
||||
|
||||
SQLAlchemy默认`expire_on_commit=True`。事务提交后,Session中的对象属性会被标记为过期;下一次访问时,Session可能重新查询数据库获取最新值。
|
||||
|
||||
本课使用:
|
||||
|
||||
```python
|
||||
sessionmaker(engine, expire_on_commit=False)
|
||||
```
|
||||
|
||||
这样提交后对象仍可读取已经加载的值,适合当前命令行示例,也常用于Web响应层。但它不代表对象永远是数据库最新状态;如果其他事务修改了数据,需要重新查询或刷新。
|
||||
|
||||
## 十二、完整示例
|
||||
|
||||
运行[sqlalchemy_crud_example.py](./sqlalchemy_crud_example.py),会依次演示:
|
||||
|
||||
1. 创建Engine和连接池;
|
||||
2. 创建Session工厂;
|
||||
3. 根据模型创建缺失表;
|
||||
4. 重置并新增课程图书;
|
||||
5. `flush()`后读取数据库生成的ID;
|
||||
6. 使用`select()`查询;
|
||||
7. 修改对象属性并删除对象;
|
||||
8. 主动抛出异常验证事务回滚;
|
||||
9. 使用新Session回查最终数据。
|
||||
|
||||
## 十三、安装与配置
|
||||
|
||||
激活课程环境,在本课目录安装:
|
||||
|
||||
```powershell
|
||||
python -m pip install -r .\requirements.txt
|
||||
```
|
||||
|
||||
如果使用Conda管理SQLAlchemy:
|
||||
|
||||
```powershell
|
||||
conda install -c conda-forge sqlalchemy
|
||||
```
|
||||
|
||||
Psycopg已经在前两课安装完成。然后复制配置:
|
||||
|
||||
```powershell
|
||||
Copy-Item .\config.example.toml .\config.toml
|
||||
```
|
||||
|
||||
填写专用练习数据库信息。真实`config.toml`已被项目`.gitignore`排除。
|
||||
|
||||
## 十四、运行方法与预期结果
|
||||
|
||||
```powershell
|
||||
python .\sqlalchemy_crud_example.py
|
||||
```
|
||||
|
||||
关键输出类似:
|
||||
|
||||
```text
|
||||
flush后第一本书ID:实际ID
|
||||
新增后:
|
||||
ORM-001|Python数据库编程|作者:小明|价格:68.00
|
||||
ORM-002|SQLAlchemy实践|作者:小红|价格:88.00
|
||||
修改并删除后:
|
||||
ORM-001|Python数据库编程|作者:小明|价格:72.00
|
||||
失败事务已回滚:模拟后续业务失败。
|
||||
失败事务回滚后:
|
||||
ORM-001|Python数据库编程|作者:小明|价格:72.00
|
||||
```
|
||||
|
||||
数据库生成的ID不要求固定。重复运行会先清理`ORM-`前缀示例数据。
|
||||
|
||||
## 十五、常见错误
|
||||
|
||||
### 15.1 每个方法都创建Engine
|
||||
|
||||
Engine应当是应用级长生命周期对象。反复创建Engine会反复创建连接池,失去连接复用价值。
|
||||
|
||||
### 15.2 多个请求共享同一个Session
|
||||
|
||||
Session不是供多个线程或并发任务共享的全局对象。常见原则是每个线程一个Session,异步场景每个任务一个AsyncSession。
|
||||
|
||||
### 15.3 关闭Session却没有提交
|
||||
|
||||
```python
|
||||
with session_factory() as session:
|
||||
session.add(book)
|
||||
```
|
||||
|
||||
离开时Session关闭,未提交写入会被回滚。写事务使用`session_factory.begin()`或明确调用`session.commit()`。
|
||||
|
||||
### 15.4 把flush当成commit
|
||||
|
||||
`flush()`只把SQL发送到当前事务,后续异常仍会回滚。
|
||||
|
||||
### 15.5 提交后访问过期对象
|
||||
|
||||
默认配置下,提交会使对象属性过期。Session已关闭后访问需要重新加载的属性,可能出现对象已脱离Session的错误。本课通过`expire_on_commit=False`降低入门干扰。
|
||||
|
||||
### 15.6 使用旧式Session.query()
|
||||
|
||||
当前课程统一使用SQLAlchemy 2.x的`select()`、`Session.scalar()`和`Session.scalars()`。
|
||||
|
||||
### 15.7 把create_all当作迁移工具
|
||||
|
||||
`create_all()`适合创建缺失表,不负责完整版本化迁移。生产项目修改表结构通常使用Alembic等迁移工具。
|
||||
|
||||
## 十六、课堂练习
|
||||
|
||||
打开[practice.py](./practice.py),完成商品ORM练习。题目按以下顺序组织:
|
||||
|
||||
1. 创建配置和声明式基类;
|
||||
2. 定义Product模型;
|
||||
3. 创建并初始化课程数据;
|
||||
4. 使用`select()`查询;
|
||||
5. 修改和删除ORM对象;
|
||||
6. 验证`flush()`后的异常回滚;
|
||||
7. 组织Engine、Session工厂和完整输出。
|
||||
|
||||
## 十七、本课小结
|
||||
|
||||
- Engine统一管理方言、驱动和连接池;
|
||||
- Engine不是一条固定数据库连接;
|
||||
- 声明式模型把Python类映射到数据库表;
|
||||
- Session管理事务、身份映射和工作单元;
|
||||
- `select()`是SQLAlchemy 2.x查询入口;
|
||||
- 修改ORM对象属性后,Session能够跟踪变化;
|
||||
- `flush()`发送SQL但不提交,`commit()`确认事务;
|
||||
- Session的生命周期应位于业务函数之外;
|
||||
- SQLAlchemy ORM更接近JPA/Hibernate,而不是MyBatis-Plus的直接复制。
|
||||
|
||||
## 十八、验收标准
|
||||
|
||||
- 能说明Psycopg、Engine、连接池和Session的调用层次;
|
||||
- 能说明Engine为什么通常只创建一次;
|
||||
- 能使用声明式模型完成类表映射;
|
||||
- 能使用SQLAlchemy 2.x方式完成增删改查;
|
||||
- 能解释身份映射和工作单元;
|
||||
- 能区分`flush()`、`commit()`、`rollback()`和`close()`;
|
||||
- 标准示例和练习的失败事务均能正确回滚;
|
||||
- 未提交真实`config.toml`。
|
||||
@@ -0,0 +1,10 @@
|
||||
# 复制本文件并重命名为 config.toml,再填写本地练习数据库信息。
|
||||
# config.toml 已加入项目 .gitignore,不会被 Git 跟踪。
|
||||
|
||||
[postgresql]
|
||||
host = "数据库主机"
|
||||
port = 5432
|
||||
dbname = "数据库名"
|
||||
user = "用户名"
|
||||
password = "密码"
|
||||
connect_timeout = 10
|
||||
@@ -0,0 +1,162 @@
|
||||
# 第4-3课练习:使用SQLAlchemy 2.x管理课程商品
|
||||
#
|
||||
# 本文件只提供题目,不包含导入、代码骨架、测试数据或参考答案。
|
||||
# 练习会创建course_orm_product表,并只操作ORM-P-前缀的数据。
|
||||
# 请勿改用现有业务表,也不要删除不属于本练习的数据。
|
||||
|
||||
|
||||
# 第一部分:导入、配置与声明式基类
|
||||
# 1. 导入Decimal、Path和tomllib。
|
||||
# 2. 从sqlalchemy导入Numeric、String、URL、create_engine、delete和select。
|
||||
# 3. 从sqlalchemy.orm导入DeclarativeBase、Mapped、Session、mapped_column和sessionmaker。
|
||||
# 4. 使用Path(__file__).with_name("config.toml")定义CONFIG_PATH。
|
||||
# 5. 定义Base(DeclarativeBase),类体中不添加业务字段。
|
||||
# 6. 实现load_database_config(config_path),读取并返回[postgresql]配置字典。
|
||||
# 7. 实现create_database_url(database_config),必须调用URL.create()并指定:
|
||||
# - drivername="postgresql+psycopg";
|
||||
# - username、password、host、port、database分别来自配置;
|
||||
# - 返回URL对象,不手工拼接包含密码的字符串。
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# 第二部分:定义Product ORM模型
|
||||
# 1. 定义Product(Base)。
|
||||
# 2. 设置__tablename__ = "course_orm_product"。
|
||||
# 3. 使用Mapped和mapped_column()声明:
|
||||
# - id:int主键,由数据库生成;
|
||||
# - code:最长30字符,唯一且非空;
|
||||
# - name:最长100字符且非空;
|
||||
# - price:NUMERIC(10, 2)且非空;
|
||||
# - stock:int且非空。
|
||||
# 4. 定义__repr__,至少包含id、code、name和stock,返回字符串供调试使用。
|
||||
#
|
||||
# Java对照提醒:
|
||||
# - Product既是普通Python类,也是数据库映射模型;
|
||||
# - Mapped类似声明持久化属性的类型;
|
||||
# - mapped_column()描述列约束,不等于Java字段的setter。
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# 第三部分:创建并初始化课程数据
|
||||
# 1. 定义reset_practice_data(session),调用session.execute()执行:
|
||||
# delete(Product).where(Product.code.like("ORM-P-%"))。
|
||||
# 2. 定义add_products(session),创建并保存以下两个Product:
|
||||
# - code="ORM-P-001",name="机械键盘",price=Decimal("399.00"),stock=10;
|
||||
# - code="ORM-P-002",name="无线鼠标",price=Decimal("199.00"),stock=20。
|
||||
# 3. 调用session.add_all(products)。
|
||||
# 4. 调用session.flush(),然后读取第一件商品的id并输出:
|
||||
# “flush后第一件商品ID:实际ID”。
|
||||
# 5. 不在add_products()中调用commit()。
|
||||
|
||||
|
||||
|
||||
# 第四部分:实现查询
|
||||
# 1. 定义find_products(session),创建SQLAlchemy 2.x查询语句:
|
||||
# select(Product).where(Product.code.like("ORM-P-%")).order_by(Product.code)
|
||||
# 2. 调用session.scalars(statement).all()取得Product对象。
|
||||
# 3. 返回Product对象列表;没有数据时返回空列表,不返回None。
|
||||
# 4. 不使用旧式session.query()。
|
||||
|
||||
|
||||
|
||||
# 第五部分:实现修改和删除
|
||||
# 1. 定义update_product(session):
|
||||
# - 调用session.scalar(select(Product).where(Product.code == "ORM-P-001"));
|
||||
# - 找不到时抛出RuntimeError("没有找到商品ORM-P-001。");
|
||||
# - 找到后直接把stock属性修改为8;
|
||||
# - 不手写UPDATE SQL,也不在方法中提交。
|
||||
# 2. 定义delete_product(session):
|
||||
# - 查询code为ORM-P-002的Product;
|
||||
# - 找不到时抛出RuntimeError("没有找到商品ORM-P-002。");
|
||||
# - 调用session.delete(product);
|
||||
# - 不手写DELETE SQL,也不在方法中提交。
|
||||
|
||||
|
||||
|
||||
|
||||
# 第六部分:验证事务回滚
|
||||
# 1. 定义demonstrate_rollback(session_factory)。
|
||||
# 2. 在try中使用with session_factory.begin() as session管理事务。
|
||||
# 3. 查询ORM-P-001,把stock修改为0。
|
||||
# 4. 调用session.flush(),让UPDATE先发送给数据库。
|
||||
# 5. 紧接着抛出RuntimeError("模拟库存业务失败。")。
|
||||
# 6. 在事务with外捕获RuntimeError并输出:
|
||||
# “失败事务已回滚:模拟库存业务失败。”
|
||||
# 7. 最终回查时stock必须仍为8,而不是0。
|
||||
|
||||
|
||||
|
||||
|
||||
# 第七部分:输出和main()流程
|
||||
# 1. 定义print_products(title, products),先print(title),再逐个输出:
|
||||
# “ORM-P-001|机械键盘|价格:399.00|库存:10”。
|
||||
# 2. main()依次执行:
|
||||
# - 读取TOML配置并创建URL;
|
||||
# - 调用create_engine(),除database_url外还要传入:
|
||||
# connect_args={"connect_timeout": 配置值或默认值10}、pool_size=5、
|
||||
# max_overflow=5、pool_pre_ping=True、echo=False;
|
||||
# - 调用sessionmaker(engine, expire_on_commit=False)创建Session工厂;
|
||||
# - 调用Base.metadata.create_all(engine)创建缺失的课程表;
|
||||
# - 使用with session_factory.begin() as session重置并新增商品;
|
||||
# - 使用with session_factory() as session查询并输出“新增后:”;
|
||||
# - 使用新的session_factory.begin()事务修改和删除;
|
||||
# - 使用只读Session查询并输出“修改并删除后:”;
|
||||
# - 调用demonstrate_rollback(session_factory);
|
||||
# - 最后查询并输出“失败事务回滚后:”。
|
||||
# 3. 在程序边界分别处理配置错误和数据库访问错误。
|
||||
# 4. 添加程序入口判断并调用main()。
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# 预期关键输出:
|
||||
# flush后第一件商品ID:实际ID
|
||||
# 新增后:
|
||||
# ORM-P-001|机械键盘|价格:399.00|库存:10
|
||||
# ORM-P-002|无线鼠标|价格:199.00|库存:20
|
||||
# 修改并删除后:
|
||||
# ORM-P-001|机械键盘|价格:399.00|库存:8
|
||||
# 失败事务已回滚:模拟库存业务失败。
|
||||
# 失败事务回滚后:
|
||||
# ORM-P-001|机械键盘|价格:399.00|库存:8
|
||||
|
||||
|
||||
# 自查清单:
|
||||
# 1. 是否使用SQLAlchemy 2.x的DeclarativeBase、Mapped和mapped_column()?
|
||||
# 2. Engine是否只创建一次,并启用了pool_pre_ping?
|
||||
# 3. 是否使用sessionmaker统一创建Session?
|
||||
# 4. 是否使用select()和session.scalars(),而不是session.query()?
|
||||
# 5. 修改属性后是否由Session自动识别变化?
|
||||
# 6. Repository式函数中是否没有擅自提交事务?
|
||||
# 7. flush后是否能取得数据库生成的主键,但事务仍可回滚?
|
||||
# 8. 失败事务回滚后库存是否仍为8?
|
||||
# 9. 是否只清理ORM-P-前缀的练习数据?
|
||||
|
||||
|
||||
# 最终验收标准:
|
||||
# 1. practice.py通过语法检查并能重复运行;
|
||||
# 2. 模型字段与数据库表映射正确;
|
||||
# 3. 新增、查询、修改和删除结果符合预期;
|
||||
# 4. Session事务成功时提交、异常时回滚;
|
||||
# 5. 能解释Engine、连接池和Session的职责;
|
||||
# 6. 能解释flush()与commit()的区别;
|
||||
# 7. 不使用SQLAlchemy 1.x旧式查询写法;
|
||||
# 8. config.toml与真实数据库信息没有进入Git。
|
||||
@@ -0,0 +1,5 @@
|
||||
# SQLAlchemy提供Engine、连接池、SQL表达式和ORM。
|
||||
SQLAlchemy>=2,<3
|
||||
|
||||
# SQLAlchemy通过Psycopg 3连接PostgreSQL;具体使用c或binary实现由环境决定。
|
||||
psycopg>=3,<4
|
||||
@@ -0,0 +1,193 @@
|
||||
"""第4-3课示例:使用SQLAlchemy 2.x完成ORM增删改查和事务回滚。"""
|
||||
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
import tomllib
|
||||
|
||||
from sqlalchemy import Numeric, String, URL, create_engine, delete, select
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, Session, mapped_column, sessionmaker
|
||||
|
||||
|
||||
CONFIG_PATH = Path(__file__).with_name("config.toml")
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
"""保存本课所有ORM模型共享的映射元数据。"""
|
||||
|
||||
|
||||
class Book(Base):
|
||||
"""把Python图书对象映射到course_orm_book表。"""
|
||||
|
||||
__tablename__ = "course_orm_book"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
isbn: Mapped[str] = mapped_column(String(30), unique=True, nullable=False)
|
||||
title: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
author: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
price: Mapped[Decimal] = mapped_column(Numeric(10, 2), nullable=False)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""提供适合开发调试的对象显示。"""
|
||||
return (
|
||||
f"Book(id={self.id!r}, isbn={self.isbn!r}, "
|
||||
f"title={self.title!r}, price={self.price!r})"
|
||||
)
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
def create_database_url(database_config: dict[str, str | int]) -> URL:
|
||||
"""用结构化参数创建URL,避免手工拼接和处理密码特殊字符。"""
|
||||
return URL.create(
|
||||
drivername="postgresql+psycopg",
|
||||
username=str(database_config["user"]),
|
||||
password=str(database_config["password"]),
|
||||
host=str(database_config["host"]),
|
||||
port=int(database_config["port"]),
|
||||
database=str(database_config["dbname"]),
|
||||
)
|
||||
|
||||
|
||||
def reset_example_data(session: Session) -> None:
|
||||
"""只删除ORM-前缀的课程示例数据。"""
|
||||
session.execute(delete(Book).where(Book.isbn.like("ORM-%")))
|
||||
|
||||
|
||||
def add_books(session: Session) -> None:
|
||||
"""创建Python对象并交给Session持久化。"""
|
||||
books = [
|
||||
Book(
|
||||
isbn="ORM-001",
|
||||
title="Python数据库编程",
|
||||
author="小明",
|
||||
price=Decimal("68.00"),
|
||||
),
|
||||
Book(
|
||||
isbn="ORM-002",
|
||||
title="SQLAlchemy实践",
|
||||
author="小红",
|
||||
price=Decimal("88.00"),
|
||||
),
|
||||
]
|
||||
session.add_all(books)
|
||||
|
||||
# flush把待处理INSERT发送到数据库,但当前事务尚未提交。
|
||||
session.flush()
|
||||
print(f"flush后第一本书ID:{books[0].id}")
|
||||
|
||||
|
||||
def find_books(session: Session) -> list[Book]:
|
||||
"""使用SQLAlchemy 2.x的select()查询课程图书。"""
|
||||
statement = (
|
||||
select(Book)
|
||||
.where(Book.isbn.like("ORM-%"))
|
||||
.order_by(Book.isbn)
|
||||
)
|
||||
return list(session.scalars(statement).all())
|
||||
|
||||
|
||||
def update_book(session: Session) -> None:
|
||||
"""查询ORM对象并修改属性,由Session跟踪变化。"""
|
||||
book = session.scalar(select(Book).where(Book.isbn == "ORM-001"))
|
||||
if book is None:
|
||||
raise RuntimeError("没有找到待修改图书ORM-001。")
|
||||
|
||||
book.price = Decimal("72.00")
|
||||
|
||||
|
||||
def delete_book(session: Session) -> None:
|
||||
"""查询ORM对象并标记删除。"""
|
||||
book = session.scalar(select(Book).where(Book.isbn == "ORM-002"))
|
||||
if book is None:
|
||||
raise RuntimeError("没有找到待删除图书ORM-002。")
|
||||
|
||||
session.delete(book)
|
||||
|
||||
|
||||
def demonstrate_rollback(session_factory: sessionmaker[Session]) -> None:
|
||||
"""演示异常离开Session.begin()时自动回滚。"""
|
||||
try:
|
||||
with session_factory.begin() as session:
|
||||
book = session.scalar(select(Book).where(Book.isbn == "ORM-001"))
|
||||
if book is None:
|
||||
raise RuntimeError("没有找到回滚演示图书ORM-001。")
|
||||
|
||||
book.price = Decimal("1.00")
|
||||
session.flush()
|
||||
raise RuntimeError("模拟后续业务失败。")
|
||||
except RuntimeError as error:
|
||||
print(f"失败事务已回滚:{error}")
|
||||
|
||||
|
||||
def print_books(title: str, books: list[Book]) -> None:
|
||||
"""输出一个查询阶段的图书结果。"""
|
||||
print(title)
|
||||
for book in books:
|
||||
print(f"{book.isbn}|{book.title}|作者:{book.author}|价格:{book.price}")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""创建Engine和Session工厂,依次演示ORM增删改查。"""
|
||||
try:
|
||||
database_config = load_database_config(CONFIG_PATH)
|
||||
database_url = create_database_url(database_config)
|
||||
|
||||
# Engine通常在应用启动时创建一次;它管理数据库方言和连接池。
|
||||
engine = create_engine(
|
||||
database_url,
|
||||
connect_args={
|
||||
"connect_timeout": int(database_config.get("connect_timeout", 10))
|
||||
},
|
||||
pool_size=5,
|
||||
max_overflow=5,
|
||||
pool_pre_ping=True,
|
||||
echo=False,
|
||||
)
|
||||
session_factory = sessionmaker(engine, expire_on_commit=False)
|
||||
|
||||
# 根据模型元数据创建缺失的课程表,不会迁移已有表结构。
|
||||
Base.metadata.create_all(engine)
|
||||
|
||||
with session_factory.begin() as session:
|
||||
reset_example_data(session)
|
||||
add_books(session)
|
||||
|
||||
with session_factory() as session:
|
||||
print_books("新增后:", find_books(session))
|
||||
|
||||
with session_factory.begin() as session:
|
||||
update_book(session)
|
||||
delete_book(session)
|
||||
|
||||
with session_factory() as session:
|
||||
print_books("修改并删除后:", find_books(session))
|
||||
|
||||
demonstrate_rollback(session_factory)
|
||||
|
||||
with session_factory() as session:
|
||||
print_books("失败事务回滚后:", find_books(session))
|
||||
except (OSError, tomllib.TOMLDecodeError, KeyError, RuntimeError) as error:
|
||||
print(f"配置或课程数据错误:{error}")
|
||||
except SQLAlchemyError as error:
|
||||
# SQLAlchemyError是SQLAlchemy数据库访问异常的共同基础类型。
|
||||
print(f"数据库访问失败:{error}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,368 @@
|
||||
# 第4-4课:SQLAlchemy关系映射与工程实践
|
||||
|
||||
## 一、本课定位
|
||||
|
||||
上一课把一张商品表映射成了Python类,并使用Session完成增删改查。本课进入真实业务中更常见的多表场景:一名客户有多张订单,需要同时查询客户信息和订单信息。
|
||||
|
||||
你已经学习过数据库和Java,因此本课不会重新讲解主键、外键和`JOIN`的基础语法,而是重点说明SQLAlchemy如何表达这些概念,以及它与JPA、MyBatis、MyBatis-Plus之间的差异。
|
||||
|
||||
## 二、本课目标
|
||||
|
||||
完成本课后,你能够:
|
||||
|
||||
1. 使用`ForeignKey`建立数据库外键;
|
||||
2. 使用`relationship()`建立Python对象之间的关系;
|
||||
3. 映射一对多和多对一关系;
|
||||
4. 使用`join()`完成显式联表查询;
|
||||
5. 使用数据传输对象(Data Transfer Object,DTO)承载多表查询结果;
|
||||
6. 使用`selectinload()`避免N+1查询;
|
||||
7. 使用`func.count()`和`group_by()`完成聚合查询;
|
||||
8. 理解Repository与事务边界的基本职责。
|
||||
|
||||
## 三、SQLAlchemy能否实现多表查询
|
||||
|
||||
可以。SQLAlchemy主要提供两种多表查询方式。
|
||||
|
||||
### 3.1 查询ORM实体及其关系
|
||||
|
||||
```python
|
||||
statement = (
|
||||
select(Customer)
|
||||
.options(selectinload(Customer.orders))
|
||||
)
|
||||
customers = session.scalars(statement).all()
|
||||
```
|
||||
|
||||
查询结果是`Customer`对象,每个客户可以通过`customer.orders`访问订单集合。这种方式类似JPA实体关系查询,适合后续业务逻辑需要完整实体对象的场景。
|
||||
|
||||
### 3.2 查询指定列并组装DTO
|
||||
|
||||
```python
|
||||
statement = (
|
||||
select(Order.order_no, Customer.customer_name, Order.amount)
|
||||
.join(Customer, Order.customer_id == Customer.id)
|
||||
)
|
||||
rows = session.execute(statement).all()
|
||||
```
|
||||
|
||||
这种方式只查询需要的列,再把结果转换成DTO。它更接近MyBatis中编写联表SQL并映射到DTO或VO。
|
||||
|
||||
两者没有绝对优劣:需要修改完整业务实体时使用ORM实体;列表、报表、统计接口通常更适合DTO投影。
|
||||
|
||||
## 四、与Java技术体系对照
|
||||
|
||||
| Python与SQLAlchemy | Java中的近似概念 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `ForeignKey` | 数据库外键、JPA `@JoinColumn` | 定义数据库层面的引用约束 |
|
||||
| `relationship()` | JPA `@OneToMany`、`@ManyToOne` | 定义对象之间如何导航 |
|
||||
| `select()`、`join()` | MyBatis SQL、JPA Criteria/JPQL | 构造查询 |
|
||||
| `Session` | JPA `EntityManager` | 管理实体状态和事务工作单元 |
|
||||
| `@dataclass` DTO | Java DTO/VO/record | 承载查询输出,不负责持久化 |
|
||||
| `selectinload()` | ORM批量预加载 | 减少逐条加载关系产生的查询 |
|
||||
|
||||
SQLAlchemy不是MyBatis-Plus的完全对应物。它的ORM部分更接近JPA/Hibernate,同时也允许像SQL构造器一样明确选择表、列、连接条件和聚合表达式。
|
||||
|
||||
## 五、ForeignKey与relationship的区别
|
||||
|
||||
这是本课最重要的区别。
|
||||
|
||||
```python
|
||||
customer_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("course_orm_customer.id"),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
customer: Mapped[Customer] = relationship(back_populates="orders")
|
||||
```
|
||||
|
||||
`ForeignKey`作用在数据库层:它让`order.customer_id`引用`customer.id`,数据库可以阻止无效的客户编号。
|
||||
|
||||
`relationship()`作用在Python对象层:它让代码可以写成`order.customer`或`customer.orders`。它不会代替数据库外键,也不是数据库中的新列。
|
||||
|
||||
简化理解:
|
||||
|
||||
- `customer_id`保存关系;
|
||||
- `ForeignKey`约束关系;
|
||||
- `relationship()`方便Python代码使用关系。
|
||||
|
||||
## 六、一对多双向关系
|
||||
|
||||
父对象的一方:
|
||||
|
||||
```python
|
||||
orders: Mapped[list["Order"]] = relationship(
|
||||
back_populates="customer",
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
```
|
||||
|
||||
子对象的一方:
|
||||
|
||||
```python
|
||||
customer: Mapped[Customer] = relationship(back_populates="orders")
|
||||
```
|
||||
|
||||
`back_populates`明确指出两个属性互为反向关系。当执行下面的代码时,SQLAlchemy能够维护两端对象的一致性:
|
||||
|
||||
```python
|
||||
customer.orders.append(order)
|
||||
```
|
||||
|
||||
### 6.1 cascade的含义
|
||||
|
||||
示例中的`cascade="all, delete-orphan"`表示:
|
||||
|
||||
- 保存客户时,可以级联保存订单集合中的新订单;
|
||||
- 订单从所属客户的集合中移除且不再属于其他父对象时,可以将其删除。
|
||||
|
||||
级联删除有数据副作用,生产项目中必须结合业务规则决定,不能看到一对多就固定照抄。
|
||||
|
||||
## 七、DTO是否是查询要件
|
||||
|
||||
DTO不是联表查询的强制要求。SQLAlchemy可以返回:
|
||||
|
||||
1. 完整ORM实体;
|
||||
2. 多个ORM实体组成的行;
|
||||
3. 指定字段组成的`Row`;
|
||||
4. 自己构造的`dataclass`、普通类或字典。
|
||||
|
||||
本课使用不可变`dataclass`定义DTO:
|
||||
|
||||
```python
|
||||
@dataclass(frozen=True)
|
||||
class OrderSummaryDTO:
|
||||
order_no: str
|
||||
customer_name: str
|
||||
amount: Decimal
|
||||
```
|
||||
|
||||
DTO适合下列场景:
|
||||
|
||||
- 页面列表只需要少数字段;
|
||||
- 返回结果来自多张表,无法自然归属于单个实体;
|
||||
- 统计、分组和报表查询;
|
||||
- 希望隔离数据库模型与对外接口模型。
|
||||
|
||||
DTO不应该调用`session.add()`进行持久化,因为它只是查询结果载体,不是ORM映射实体。
|
||||
|
||||
## 八、显式联表查询
|
||||
|
||||
```python
|
||||
statement = (
|
||||
select(Order.order_no, Customer.customer_name, Order.amount)
|
||||
.join(Customer, Order.customer_id == Customer.id)
|
||||
.where(Customer.customer_code.like("ORM-C-%"))
|
||||
.order_by(Order.order_no)
|
||||
)
|
||||
rows = session.execute(statement).all()
|
||||
```
|
||||
|
||||
执行顺序可以按SQL理解:
|
||||
|
||||
1. `select()`决定返回哪些列;
|
||||
2. `join()`决定关联哪张表以及关联条件;
|
||||
3. `where()`限制数据范围;
|
||||
4. `order_by()`决定结果顺序;
|
||||
5. `session.execute()`执行语句;
|
||||
6. `all()`取得全部结果。
|
||||
|
||||
这里没有使用字符串拼接,SQLAlchemy会把Python值绑定为SQL参数。
|
||||
|
||||
## 九、N+1查询问题
|
||||
|
||||
N+1查询是指:先用1条SQL查询N个客户,随后为了读取每名客户的订单,又追加N条SQL,总计执行N+1条查询。
|
||||
|
||||
直接访问延迟加载的关系可能出现这个问题:
|
||||
|
||||
```python
|
||||
customers = session.scalars(select(Customer)).all()
|
||||
for customer in customers:
|
||||
print(customer.orders)
|
||||
```
|
||||
|
||||
本课使用`selectinload()`预加载:
|
||||
|
||||
```python
|
||||
statement = select(Customer).options(selectinload(Customer.orders))
|
||||
```
|
||||
|
||||
它通常先查询客户,再使用一条带`IN`条件的SQL批量查询这些客户的订单,不会为每名客户分别查询一次。
|
||||
|
||||
常见关系加载策略还有`joinedload()`,它通过连接查询加载关系。集合关系使用连接加载时可能扩大结果行数,因此本课先掌握更直观的`selectinload()`。
|
||||
|
||||
## 十、聚合查询
|
||||
|
||||
```python
|
||||
statement = (
|
||||
select(Customer.customer_name, func.count(Order.id))
|
||||
.join(Order, Customer.id == Order.customer_id)
|
||||
.group_by(Customer.id, Customer.customer_name)
|
||||
)
|
||||
```
|
||||
|
||||
`func.count()`会生成SQL的`COUNT()`,`group_by()`生成`GROUP BY`。统计工作由数据库完成,Python只接收统计结果,不应先查询全部订单再在内存中计数。
|
||||
|
||||
## 十一、Repository与事务边界
|
||||
|
||||
Repository(仓储)负责封装数据访问细节,例如查询客户、查询订单摘要。Service(业务服务)负责组织业务流程和决定事务成功或失败。
|
||||
|
||||
推荐的职责划分:
|
||||
|
||||
```text
|
||||
Service或调用方:开始事务 → 调用多个Repository方法 → 提交或回滚
|
||||
Repository:执行查询、增加、修改、删除 → 不擅自commit
|
||||
```
|
||||
|
||||
这与Java项目中`@Transactional`通常放在Service层的思路一致。如果每个Repository方法都自行提交,那么一个跨多个数据操作的业务事务就会被割裂。
|
||||
|
||||
本课标准示例没有为了展示分层而增加大量类,但其中的数据访问函数都不调用`commit()`,事务由`session_factory.begin()`统一管理。
|
||||
|
||||
## 十二、完整示例
|
||||
|
||||
本课完整示例位于:
|
||||
|
||||
```text
|
||||
relationship_query_example.py
|
||||
```
|
||||
|
||||
示例包含:
|
||||
|
||||
1. `Customer`与`Order`双向关系;
|
||||
2. 外键和级联配置;
|
||||
3. 可重复执行的数据初始化;
|
||||
4. ORM关系对象查询;
|
||||
5. DTO显式联表查询;
|
||||
6. 分组聚合查询;
|
||||
7. 配置异常和数据库异常的分类处理。
|
||||
|
||||
## 十三、安装与配置
|
||||
|
||||
如果`python-test`环境已经安装上一课依赖,不需要重复安装。可以先确认:
|
||||
|
||||
```powershell
|
||||
conda activate python-test
|
||||
python -c "import sqlalchemy, psycopg; print(sqlalchemy.__version__); print(psycopg.__version__)"
|
||||
```
|
||||
|
||||
如未安装,推荐使用当前解释器对应的pip:
|
||||
|
||||
```powershell
|
||||
python -m pip install -r requirements.txt
|
||||
```
|
||||
|
||||
也可以使用conda安装:
|
||||
|
||||
```powershell
|
||||
conda install -c conda-forge sqlalchemy psycopg
|
||||
```
|
||||
|
||||
注意:激活`python-test`后不要再写`-n base`,否则会尝试修改无权限的公共base环境。
|
||||
|
||||
复制配置模板:
|
||||
|
||||
```powershell
|
||||
Copy-Item config.example.toml config.toml
|
||||
```
|
||||
|
||||
随后只修改本地`config.toml`。该文件已由项目`.gitignore`忽略,不使用环境变量,也不要把真实密码写入`config.example.toml`或Python代码。
|
||||
|
||||
## 十四、运行方法与预期结果
|
||||
|
||||
进入本课目录:
|
||||
|
||||
```powershell
|
||||
cd D:\Code\Python\04_数据库\4_4_SQLAlchemy关系映射与工程实践
|
||||
conda activate python-test
|
||||
python relationship_query_example.py
|
||||
```
|
||||
|
||||
正常情况下会看到类似结果:
|
||||
|
||||
```text
|
||||
关系对象查询:
|
||||
张三
|
||||
ORM-O-001|金额:299.00
|
||||
ORM-O-002|金额:99.00
|
||||
李四
|
||||
ORM-O-003|金额:599.00
|
||||
DTO联表查询:
|
||||
ORM-O-001|张三|金额:299.00
|
||||
ORM-O-002|张三|金额:99.00
|
||||
ORM-O-003|李四|金额:599.00
|
||||
聚合查询:
|
||||
张三|订单数量:2
|
||||
李四|订单数量:1
|
||||
```
|
||||
|
||||
数据库自动生成的主键可能继续增长,这是序列的正常行为,不代表练习数据发生重复。
|
||||
|
||||
## 十五、关键代码执行顺序
|
||||
|
||||
1. 读取本地TOML配置;
|
||||
2. 创建`Engine`和连接池;
|
||||
3. 创建`sessionmaker`;
|
||||
4. `create_all()`创建不存在的练习表;
|
||||
5. 在一个事务中清理本课前缀数据并重新新增;
|
||||
6. 在独立Session中查询关系对象;
|
||||
7. 执行联表查询并构造DTO;
|
||||
8. 执行分组统计;
|
||||
9. 关闭Session并释放Engine连接池。
|
||||
|
||||
## 十六、常见错误
|
||||
|
||||
### 16.1 只写relationship而不写ForeignKey
|
||||
|
||||
SQLAlchemy通常需要外键判断两张表如何关联。`relationship()`不能代替数据库外键。
|
||||
|
||||
### 16.2 Session关闭后触发延迟加载
|
||||
|
||||
关系数据尚未加载就关闭Session,之后访问`customer.orders`可能出现对象已脱离Session的错误。应在Session有效期间使用关系,或提前预加载并转换成DTO。
|
||||
|
||||
### 16.3 循环中产生N+1查询
|
||||
|
||||
查询列表后逐个访问延迟加载集合,会产生大量SQL。列表场景应根据需要使用`selectinload()`或明确的联表DTO查询。
|
||||
|
||||
### 16.4 对DTO执行session.add
|
||||
|
||||
只有继承声明式基类并完成表映射的ORM实体才能持久化。DTO没有表映射,只负责传输数据。
|
||||
|
||||
### 16.5 Repository内部随意commit
|
||||
|
||||
这会破坏上层业务事务。Repository可以执行`flush()`以提前同步SQL,但是否提交应由事务调用方决定。
|
||||
|
||||
### 16.6 删除父记录时违反外键约束
|
||||
|
||||
需要先删除子记录,或明确配置数据库/ORM级联规则。级联策略必须符合业务要求。
|
||||
|
||||
## 十七、课堂练习
|
||||
|
||||
练习要求位于`practice.py`。你需要独立完成“课程分类—课程”一对多模型,并实现:
|
||||
|
||||
1. 关系对象查询;
|
||||
2. DTO联表查询;
|
||||
3. 分类课程数量统计;
|
||||
4. 可重复运行的数据初始化;
|
||||
5. 清晰的事务边界。
|
||||
|
||||
本课不在练习文件中提供代码骨架。需要帮助时,可以先询问具体概念或把已完成部分交给我验证。
|
||||
|
||||
## 十八、本课小结
|
||||
|
||||
1. `ForeignKey`负责数据库约束,`relationship()`负责对象导航;
|
||||
2. SQLAlchemy既能查询完整关联实体,也能显式联表并构造DTO;
|
||||
3. DTO不是强制要求,但非常适合列表、报表和跨表结果;
|
||||
4. `selectinload()`可以避免常见的N+1查询;
|
||||
5. 聚合应尽量交给数据库完成;
|
||||
6. Repository负责数据访问,事务边界通常由Service或调用方管理。
|
||||
|
||||
## 十九、验收标准
|
||||
|
||||
- 能解释`ForeignKey`与`relationship()`的区别;
|
||||
- 能建立一对多双向关系;
|
||||
- 能使用关联属性查询子对象集合;
|
||||
- 能使用`join()`查询多张表;
|
||||
- 能把指定列转换成DTO;
|
||||
- 能使用`selectinload()`预加载集合;
|
||||
- 能完成分组统计;
|
||||
- 程序连续运行两次结果一致且没有重复练习数据;
|
||||
- 配置保存在被Git忽略的本地TOML中。
|
||||
@@ -0,0 +1,7 @@
|
||||
[postgresql]
|
||||
host = "你的PostgreSQL服务器地址"
|
||||
port = 5432
|
||||
dbname = "python_test"
|
||||
user = "你的数据库用户名"
|
||||
password = "你的数据库密码"
|
||||
connect_timeout = 10
|
||||
@@ -0,0 +1,183 @@
|
||||
# 第4-4课练习:使用SQLAlchemy完成关系映射与多表查询
|
||||
#
|
||||
# 本文件只提供题目,不提供代码骨架、测试数据代码或参考答案。
|
||||
# 练习会创建course_orm_category和course_orm_lesson两张表,
|
||||
# 并只操作ORM-C-分类前缀及ORM-L-课程前缀的数据。
|
||||
# 请勿改用现有业务表,也不要删除不属于本练习的数据。
|
||||
|
||||
|
||||
# 第一部分:导入、配置与声明式基类
|
||||
# 1. 导入dataclass、Decimal、Path和tomllib。
|
||||
# 2. 从sqlalchemy导入ForeignKey、Numeric、String、URL、create_engine、
|
||||
# delete、func和select。
|
||||
# 3. 从sqlalchemy.exc导入SQLAlchemyError。
|
||||
# 4. 从sqlalchemy.orm导入DeclarativeBase、Mapped、Session、mapped_column、
|
||||
# relationship、selectinload和sessionmaker。
|
||||
# 5. 使用Path(__file__).with_name("config.toml")定义CONFIG_PATH。
|
||||
# 6. 定义Base(DeclarativeBase),类体中不添加业务字段。
|
||||
# 7. 实现load_database_config(config_path),读取并返回[postgresql]配置字典。
|
||||
# 8. 实现create_database_url(database_config),使用URL.create()创建
|
||||
# postgresql+psycopg连接地址,不手工拼接包含密码的字符串。
|
||||
|
||||
|
||||
|
||||
# 第二部分:定义Category和Lesson ORM模型
|
||||
# 1. 定义Category(Base):
|
||||
# - __tablename__ = "course_orm_category";
|
||||
# - id:int主键,由数据库生成;
|
||||
# - category_code:最长30字符,唯一且非空;
|
||||
# - category_name:最长100字符且非空;
|
||||
# - lessons:一对多课程集合,使用relationship();
|
||||
# - 通过back_populates与Lesson.category建立双向关系;
|
||||
# - 配置cascade="all, delete-orphan"。
|
||||
# 2. 定义Lesson(Base):
|
||||
# - __tablename__ = "course_orm_lesson";
|
||||
# - id:int主键,由数据库生成;
|
||||
# - lesson_code:最长30字符,唯一且非空;
|
||||
# - lesson_name:最长100字符且非空;
|
||||
# - price:NUMERIC(10, 2)且非空;
|
||||
# - category_id:int、非空,并使用ForeignKey引用course_orm_category.id;
|
||||
# - category:使用relationship()指向所属Category;
|
||||
# - 通过back_populates与Category.lessons建立双向关系。
|
||||
# 3. 类名使用Category和Lesson,不要使用数据库表名作为Python类名。
|
||||
#
|
||||
# 关系映射提醒:
|
||||
# - ForeignKey建立数据库层面的外键约束;
|
||||
# - relationship()建立Python对象之间的导航关系;
|
||||
# - Category.lessons的元素类型应为Lesson,而不是int;
|
||||
# - 双向关系的两端必须使用对应的back_populates名称。
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# 第三部分:定义DTO
|
||||
# 1. 使用@dataclass(frozen=True)定义 LessonSummaryDTO。
|
||||
# 2. 依次声明以下字段:
|
||||
# - lesson_code:str;
|
||||
# - lesson_name:str;
|
||||
# - category_name:str;
|
||||
# - price:Decimal。
|
||||
# 3. DTO只承载联表查询结果,不继承Base,也不调用session.add()持久化。
|
||||
|
||||
|
||||
|
||||
# 第四部分:重置并新增练习数据
|
||||
# 1. 定义reset_practice_data(session):
|
||||
# - 先找到category_code LIKE "ORM-C-%"的分类ID;
|
||||
# - 先删除这些分类下lesson_code LIKE "ORM-L-%"的课程;
|
||||
# - 再删除category_code LIKE "ORM-C-%"的分类;
|
||||
# - 使用SQLAlchemy的delete(),不拼接SQL;
|
||||
# - 不在函数中commit()。
|
||||
# 2. 定义add_practice_data(session),创建以下对象关系:
|
||||
# - 分类ORM-C-001,名称“数据库课程”;
|
||||
# 包含ORM-L-001“PostgreSQL入门”,价格99.00;
|
||||
# 包含ORM-L-002“SQLAlchemy基础”,价格129.00;
|
||||
# - 分类ORM-C-002,名称“Web课程”;
|
||||
# 包含ORM-L-003“HTTP基础”,价格69.00;
|
||||
# 包含ORM-L-004“FastAPI入门”,价格159.00。
|
||||
# 3. 通过Category.lessons建立对象关系,不手工给category_id编造主键值。
|
||||
# 4. 调用session.add_all()新增两个分类,依靠关系级联新增四门课程。
|
||||
# 5. 不在add_practice_data()中调用commit()。
|
||||
|
||||
|
||||
|
||||
# 第五部分:实现关系对象查询
|
||||
# 1. 定义find_categories_with_lessons(session)。
|
||||
# 2. 查询category_code LIKE "ORM-C-%"的Category,并按category_code排序。
|
||||
# 3. 使用.options(selectinload(Category.lessons))预加载课程集合。
|
||||
# 4. 返回Category对象列表;没有数据时返回空列表,不返回None。
|
||||
# 5. 不使用旧式session.query()。
|
||||
# 6. 输出时通过category.lessons读取课程,不再为每个分类单独查询课程。
|
||||
|
||||
|
||||
# 第六部分:实现DTO联表查询
|
||||
# 1. 定义find_lesson_summaries(session)。
|
||||
# 2. select()只查询Lesson.lesson_code、Lesson.lesson_name、
|
||||
# Category.category_name和Lesson.price。
|
||||
# 3. 使用join()连接Category和Lesson,不使用字符串拼接SQL。
|
||||
# 4. 只查询lesson_code LIKE "ORM-L-%"的数据。
|
||||
# 5. 按Category.category_code和Lesson.lesson_code升序排列。
|
||||
# 6. 调用session.execute(statement).all()取得查询行。
|
||||
# 7. 将每一行转换成LessonSummaryDTO并返回DTO列表。
|
||||
|
||||
|
||||
# 第七部分:实现聚合查询
|
||||
# 1. 定义count_lessons_by_category(session)。
|
||||
# 2. 使用select()查询Category.category_name和func.count(Lesson.id)。
|
||||
# 3. 使用join()关联课程表,并只统计ORM-C-前缀分类。
|
||||
# 4. 使用group_by()按分类分组,按category_code升序排列。
|
||||
# 5. 返回“分类名称、课程数量”组成的查询结果。
|
||||
|
||||
|
||||
# 第八部分:输出和main()流程
|
||||
# 1. 定义print_categories(categories),输出分类及其课程:
|
||||
# - 先输出“关系对象查询:”;
|
||||
# - 每个分类先输出分类名称;
|
||||
# - 再逐行输出两个空格和课程名称。
|
||||
# 2. 定义print_lesson_summaries(summaries),先输出“DTO联表查询:”,
|
||||
# 再按以下格式逐行输出:
|
||||
# “ORM-L-001|PostgreSQL入门|数据库课程|价格:99.00”。
|
||||
# 3. 定义print_category_counts(category_counts),先输出“分类统计:”,
|
||||
# 再按以下格式逐行输出:
|
||||
# “数据库课程|课程数量:2”。
|
||||
# 4. main()依次执行:
|
||||
# - 读取本地TOML配置并创建数据库URL;
|
||||
# - 创建一次Engine,启用pool_pre_ping并配置连接超时;
|
||||
# - 使用sessionmaker(engine, expire_on_commit=False)创建Session工厂;
|
||||
# - 调用Base.metadata.create_all(engine)创建缺失的练习表;
|
||||
# - 使用with session_factory.begin() as session,在同一个事务中
|
||||
# 调用reset_practice_data(session)和add_practice_data(session);
|
||||
# - 使用独立的with session_factory() as session执行三类查询并输出;
|
||||
# - 在finally中调用engine.dispose()释放连接池。
|
||||
# 5. 分别捕获配置错误和SQLAlchemyError,并输出中文场景说明。
|
||||
# 6. 添加程序入口判断并调用main()。
|
||||
#
|
||||
# 预期关键输出:
|
||||
# 关系对象查询:
|
||||
# 数据库课程
|
||||
# PostgreSQL入门
|
||||
# SQLAlchemy基础
|
||||
# Web课程
|
||||
# HTTP基础
|
||||
# FastAPI入门
|
||||
#
|
||||
# DTO联表查询:
|
||||
# ORM-L-001|PostgreSQL入门|数据库课程|价格:99.00
|
||||
# ORM-L-002|SQLAlchemy基础|数据库课程|价格:129.00
|
||||
# ORM-L-003|HTTP基础|Web课程|价格:69.00
|
||||
# ORM-L-004|FastAPI入门|Web课程|价格:159.00
|
||||
#
|
||||
# 分类统计:
|
||||
# 数据库课程|课程数量:2
|
||||
# Web课程|课程数量:2
|
||||
#
|
||||
|
||||
|
||||
# 自查清单:
|
||||
# 1. Python模型类名是否为Category和Lesson,而不是数据库表名?
|
||||
# 2. Category.lessons是否声明为Lesson对象列表,而不是int列表?
|
||||
# 3. Category.lessons和Lesson.category是否使用back_populates互相对应?
|
||||
# 4. category_id是否通过ForeignKey建立真实数据库外键?
|
||||
# 5. 是否通过对象关系新增课程,而不是手工猜测category_id?
|
||||
# 6. 关系对象查询是否使用selectinload()避免N+1查询?
|
||||
# 7. DTO查询是否只选择需要的列并使用join()?
|
||||
# 8. DTO是否没有继承Base,也没有承担持久化职责?
|
||||
# 9. 聚合数量是否由数据库的count()和group_by()完成?
|
||||
# 10. 数据访问函数是否都没有擅自commit()?
|
||||
# 11. 是否只清理ORM-C-和ORM-L-前缀的本课练习数据?
|
||||
|
||||
|
||||
# 最终验收标准:
|
||||
# 1. practice.py通过语法检查并能连续运行两次;
|
||||
# 2. 两张表之间存在真实数据库外键和双向对象关系;
|
||||
# 3. 两个分类与四门课程的数据及对象关系正确;
|
||||
# 4. 关系对象查询结果正确,并使用预加载避免N+1查询;
|
||||
# 5. DTO联表查询包含两张表的数据,字段和顺序符合预期;
|
||||
# 6. 聚合查询正确统计每个分类的课程数量;
|
||||
# 7. 初始化事务由外层统一提交,数据访问函数不自行提交;
|
||||
# 8. 不使用SQLAlchemy 1.x旧式查询写法;
|
||||
# 9. config.toml与真实数据库信息没有进入Git。
|
||||
@@ -0,0 +1,216 @@
|
||||
"""第4-4课标准示例:SQLAlchemy关系映射、联表查询与DTO。"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
import tomllib
|
||||
|
||||
from sqlalchemy import ForeignKey, Numeric, String, URL, create_engine, delete, func, select
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
from sqlalchemy.orm import (
|
||||
DeclarativeBase,
|
||||
Mapped,
|
||||
Session,
|
||||
mapped_column,
|
||||
relationship,
|
||||
selectinload,
|
||||
sessionmaker,
|
||||
)
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
"""所有ORM模型共同继承的声明式基类。"""
|
||||
|
||||
|
||||
class Customer(Base):
|
||||
"""客户模型:一名客户可以拥有多张订单。"""
|
||||
|
||||
__tablename__ = "course_orm_customer"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
customer_code: Mapped[str] = mapped_column(String(30), unique=True, nullable=False)
|
||||
customer_name: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
|
||||
# relationship描述Python对象之间的关系,本身不是数据库中的字段。
|
||||
# back_populates让Customer.orders和Order.customer成为双向关系。
|
||||
orders: Mapped[list["Order"]] = relationship(
|
||||
back_populates="customer",
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
|
||||
|
||||
class Order(Base):
|
||||
"""订单模型:每张订单通过外键归属于一名客户。"""
|
||||
|
||||
__tablename__ = "course_orm_order"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
order_no: Mapped[str] = mapped_column(String(30), unique=True, nullable=False)
|
||||
amount: Mapped[Decimal] = mapped_column(Numeric(12, 2), nullable=False)
|
||||
customer_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("course_orm_customer.id"),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
customer: Mapped[Customer] = relationship(back_populates="orders")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OrderSummaryDTO:
|
||||
"""联表查询结果对象,作用类似Java中专门承载查询结果的DTO。"""
|
||||
|
||||
order_no: str
|
||||
customer_name: str
|
||||
amount: Decimal
|
||||
|
||||
|
||||
def load_database_config() -> dict:
|
||||
"""从本课目录的本地TOML文件读取数据库配置。"""
|
||||
|
||||
config_path = Path(__file__).with_name("config.toml")
|
||||
if not config_path.exists():
|
||||
raise FileNotFoundError(
|
||||
"没有找到config.toml,请复制config.example.toml并填写本地数据库信息。"
|
||||
)
|
||||
|
||||
with config_path.open("rb") as config_file:
|
||||
config = tomllib.load(config_file)
|
||||
|
||||
if "postgresql" not in config:
|
||||
raise KeyError("config.toml中缺少[postgresql]配置段。")
|
||||
return config["postgresql"]
|
||||
|
||||
|
||||
def create_database_url(database_config: dict) -> URL:
|
||||
"""使用URL.create安全构造连接地址,避免手工拼接密码。"""
|
||||
|
||||
return URL.create(
|
||||
drivername="postgresql+psycopg",
|
||||
username=database_config["user"],
|
||||
password=database_config["password"],
|
||||
host=database_config["host"],
|
||||
port=database_config["port"],
|
||||
database=database_config["dbname"],
|
||||
)
|
||||
|
||||
|
||||
def reset_and_add_data(session: Session) -> None:
|
||||
"""清理并重新创建本课专用数据,保证示例可以重复运行。"""
|
||||
|
||||
# 先删子表再删父表,满足数据库外键约束。
|
||||
customer_ids = select(Customer.id).where(Customer.customer_code.like("ORM-C-%"))
|
||||
session.execute(delete(Order).where(Order.customer_id.in_(customer_ids)))
|
||||
session.execute(delete(Customer).where(Customer.customer_code.like("ORM-C-%")))
|
||||
|
||||
alice = Customer(
|
||||
customer_code="ORM-C-001",
|
||||
customer_name="张三",
|
||||
orders=[
|
||||
Order(order_no="ORM-O-001", amount=Decimal("299.00")),
|
||||
Order(order_no="ORM-O-002", amount=Decimal("99.00")),
|
||||
],
|
||||
)
|
||||
bob = Customer(
|
||||
customer_code="ORM-C-002",
|
||||
customer_name="李四",
|
||||
orders=[Order(order_no="ORM-O-003", amount=Decimal("599.00"))],
|
||||
)
|
||||
|
||||
# cascade配置使新增Customer时能够同时新增其orders集合中的订单。
|
||||
session.add_all([alice, bob])
|
||||
|
||||
|
||||
def find_customers_with_orders(session: Session) -> list[Customer]:
|
||||
"""使用预加载一次取得客户及其订单,避免N+1查询。"""
|
||||
|
||||
statement = (
|
||||
select(Customer)
|
||||
.where(Customer.customer_code.like("ORM-C-%"))
|
||||
.options(selectinload(Customer.orders))
|
||||
.order_by(Customer.customer_code)
|
||||
)
|
||||
return list(session.scalars(statement))
|
||||
|
||||
|
||||
def find_order_summaries(session: Session) -> list[OrderSummaryDTO]:
|
||||
"""显式联表并只查询DTO所需列。"""
|
||||
|
||||
statement = (
|
||||
select(Order.order_no, Customer.customer_name, Order.amount)
|
||||
.join(Customer, Order.customer_id == Customer.id)
|
||||
.where(Customer.customer_code.like("ORM-C-%"))
|
||||
.order_by(Order.order_no)
|
||||
)
|
||||
rows = session.execute(statement).all()
|
||||
return [
|
||||
OrderSummaryDTO(
|
||||
order_no=row.order_no,
|
||||
customer_name=row.customer_name,
|
||||
amount=row.amount,
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
|
||||
|
||||
def count_orders_by_customer(session: Session) -> list[tuple[str, int]]:
|
||||
"""让数据库按照客户分组并统计订单数量。"""
|
||||
|
||||
statement = (
|
||||
select(Customer.customer_name, func.count(Order.id))
|
||||
.join(Order, Customer.id == Order.customer_id)
|
||||
.where(Customer.customer_code.like("ORM-C-%"))
|
||||
.group_by(Customer.id, Customer.customer_name)
|
||||
.order_by(Customer.customer_code)
|
||||
)
|
||||
return [(name, order_count) for name, order_count in session.execute(statement)]
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""按事务写入数据,再分别演示三种多表查询。"""
|
||||
|
||||
engine = None
|
||||
try:
|
||||
database_config = load_database_config()
|
||||
engine = create_engine(
|
||||
create_database_url(database_config),
|
||||
pool_size=5,
|
||||
max_overflow=10,
|
||||
pool_pre_ping=True,
|
||||
connect_args={
|
||||
"connect_timeout": database_config.get("connect_timeout", 10)
|
||||
},
|
||||
)
|
||||
session_factory = sessionmaker(engine, expire_on_commit=False)
|
||||
Base.metadata.create_all(engine)
|
||||
|
||||
with session_factory.begin() as session:
|
||||
reset_and_add_data(session)
|
||||
|
||||
with session_factory() as session:
|
||||
print("关系对象查询:")
|
||||
for customer in find_customers_with_orders(session):
|
||||
print(customer.customer_name)
|
||||
for order in customer.orders:
|
||||
print(f" {order.order_no}|金额:{order.amount}")
|
||||
|
||||
print("DTO联表查询:")
|
||||
for summary in find_order_summaries(session):
|
||||
print(
|
||||
f"{summary.order_no}|{summary.customer_name}|"
|
||||
f"金额:{summary.amount}"
|
||||
)
|
||||
|
||||
print("聚合查询:")
|
||||
for customer_name, order_count in count_orders_by_customer(session):
|
||||
print(f"{customer_name}|订单数量:{order_count}")
|
||||
except (OSError, KeyError, tomllib.TOMLDecodeError) as error:
|
||||
print(f"配置读取失败:{error}")
|
||||
except SQLAlchemyError as error:
|
||||
print(f"数据库访问失败:{error}")
|
||||
finally:
|
||||
if engine is not None:
|
||||
engine.dispose()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,2 @@
|
||||
SQLAlchemy>=2.0,<2.1
|
||||
psycopg[binary]>=3.2,<4.0
|
||||
@@ -0,0 +1,284 @@
|
||||
# 第4-5课:数据库综合项目——库存订单管理
|
||||
|
||||
## 一、本课定位
|
||||
|
||||
这是第四阶段的综合项目。本课不再单独讲一个API,而是把前四课知识组合成一个小型业务流程:客户下单后,系统创建订单与订单明细,同时扣减商品库存;只要其中任何一步失败,整张订单和全部库存修改都必须回滚。
|
||||
|
||||
项目使用三张独立练习表和`DBP-`、`DBO-`数据前缀,不操作其他课程或业务数据。
|
||||
|
||||
## 二、本课目标
|
||||
|
||||
完成本课后,你能够:
|
||||
|
||||
1. 使用SQLAlchemy 2.x映射三张存在关联关系的表;
|
||||
2. 使用Repository(仓储)封装数据访问;
|
||||
3. 使用Service(业务服务)组织下单规则;
|
||||
4. 在调用方统一控制事务提交和回滚;
|
||||
5. 使用`SELECT FOR UPDATE`降低并发扣减库存产生的超卖风险;
|
||||
6. 使用DTO承载三表联查结果;
|
||||
7. 使用聚合查询统计订单状态;
|
||||
8. 使用本地TOML配置安全连接PostgreSQL。
|
||||
|
||||
## 三、前置知识
|
||||
|
||||
- PostgreSQL主键、外键、约束和事务;
|
||||
- Psycopg连接与参数化查询;
|
||||
- SQLAlchemy的Engine、连接池和Session;
|
||||
- 声明式ORM模型及增删改查;
|
||||
- `ForeignKey`、`relationship()`、`join()`和DTO。
|
||||
|
||||
第四课练习即使尚未全部完成,也可以先运行本课标准示例;遇到关系映射或DTO不理解时,再回看第四课对应章节。
|
||||
|
||||
## 四、业务模型
|
||||
|
||||
```text
|
||||
Product(商品) 1 ──── N OrderItem(订单明细) N ──── 1 Order(订单)
|
||||
```
|
||||
|
||||
为什么需要订单明细表?因为一张订单可以包含多个商品,一个商品也可以出现在多张订单中。订单与商品本质上是多对多关系,`OrderItem`把它拆成两个一对多关系,并额外保存购买数量和成交单价。
|
||||
|
||||
成交单价必须保存在订单明细中。商品价格以后可能变化,但历史订单金额不能随商品当前价格改变。
|
||||
|
||||
## 五、项目分层
|
||||
|
||||
```text
|
||||
main() / 事务调用方
|
||||
↓ 创建同一个Session
|
||||
OrderService
|
||||
↓ 调用
|
||||
ProductRepository + OrderRepository
|
||||
↓ 操作
|
||||
SQLAlchemy ORM模型与PostgreSQL
|
||||
```
|
||||
|
||||
### 5.1 Repository
|
||||
|
||||
Repository负责查询、增加和修改数据库对象,但不决定什么时候提交:
|
||||
|
||||
```python
|
||||
class OrderRepository:
|
||||
def __init__(self, session):
|
||||
self.session = session
|
||||
|
||||
def add(self, order):
|
||||
self.session.add(order)
|
||||
```
|
||||
|
||||
它与MyBatis项目中的Mapper/DAO职责相近,但操作的是SQLAlchemy的Session和ORM对象。
|
||||
|
||||
### 5.2 Service
|
||||
|
||||
Service负责业务规则:验证订单、查询并锁定商品、判断库存、扣减库存、计算金额、组装订单。
|
||||
|
||||
它不调用`commit()`。因为一个业务用例可能调用多个Repository,必须保证它们处于同一个事务。
|
||||
|
||||
### 5.3 事务调用方
|
||||
|
||||
```python
|
||||
with session_factory.begin() as session:
|
||||
service = create_order_service(session)
|
||||
service.place_order(...)
|
||||
```
|
||||
|
||||
正常离开`with`时提交;异常离开时回滚。`ProductRepository`和`OrderRepository`共享同一个Session,因此库存修改、订单主表和订单明细属于同一个数据库事务。
|
||||
|
||||
## 六、成功事务与失败事务
|
||||
|
||||
成功订单购买两个键盘和一个鼠标:
|
||||
|
||||
```text
|
||||
验证订单 → 锁定商品 → 扣减库存 → 创建明细 → 创建订单 → 提交
|
||||
```
|
||||
|
||||
失败订单先扣减一个键盘,随后发现鼠标库存不足:
|
||||
|
||||
```text
|
||||
锁定键盘 → 内存中扣减键盘 → 锁定鼠标 → 库存不足 → 抛出异常 → 全部回滚
|
||||
```
|
||||
|
||||
回滚必须撤销第一项商品的扣减,也不能留下不完整的订单。不能在处理每项商品后分别提交。
|
||||
|
||||
## 七、为什么使用FOR UPDATE
|
||||
|
||||
普通查询后再扣减库存存在并发窗口:两个事务可能同时读到库存5,并各自认为能够购买4件。
|
||||
|
||||
```python
|
||||
statement = (
|
||||
select(Product)
|
||||
.where(Product.product_code == product_code)
|
||||
.with_for_update()
|
||||
)
|
||||
```
|
||||
|
||||
PostgreSQL会把它转换为`SELECT ... FOR UPDATE`。当前事务结束前,其他需要修改同一行的事务通常需要等待。
|
||||
|
||||
这能解决本项目中的典型并发更新问题,但生产系统还需要考虑锁顺序、死锁重试、事务超时、幂等和高并发架构。本课只要求理解悲观锁的基本作用。
|
||||
|
||||
## 八、金额为什么使用Decimal
|
||||
|
||||
二进制浮点数`float`不能精确表示很多十进制小数,不适合直接保存货币金额。本项目统一使用:
|
||||
|
||||
- Python:`Decimal`;
|
||||
- PostgreSQL:`NUMERIC(10, 2)`或`NUMERIC(12, 2)`。
|
||||
|
||||
```python
|
||||
price=Decimal("399.00")
|
||||
```
|
||||
|
||||
使用字符串创建`Decimal`,避免先经过不精确的浮点数。
|
||||
|
||||
## 九、DTO三表联查
|
||||
|
||||
订单明细页面同时需要订单、商品和明细字段,不适合把某一个ORM实体直接当作查询结果。
|
||||
|
||||
```python
|
||||
statement = (
|
||||
select(
|
||||
Order.order_no,
|
||||
Product.product_name,
|
||||
OrderItem.quantity,
|
||||
OrderItem.unit_price,
|
||||
)
|
||||
.join(OrderItem, Order.id == OrderItem.order_id)
|
||||
.join(Product, OrderItem.product_id == Product.id)
|
||||
)
|
||||
```
|
||||
|
||||
查询结果再转换为`OrderDetailDTO`。这与MyBatis联表SQL映射到DTO/VO的做法非常接近,而且只查询页面真正需要的字段。
|
||||
|
||||
## 十、完整示例
|
||||
|
||||
标准示例位于:
|
||||
|
||||
```text
|
||||
inventory_order_example.py
|
||||
```
|
||||
|
||||
它包含:
|
||||
|
||||
- 三个ORM模型及数据库约束;
|
||||
- Repository/Service分层;
|
||||
- 成功下单事务;
|
||||
- 库存不足事务回滚;
|
||||
- 悲观锁库存查询;
|
||||
- 三表联查DTO;
|
||||
- 订单状态聚合;
|
||||
- 可重复执行的数据初始化。
|
||||
|
||||
## 十一、安装与配置
|
||||
|
||||
如果`python-test`环境已经完成前两课SQLAlchemy练习,不需要重复安装。确认版本:
|
||||
|
||||
```powershell
|
||||
conda activate python-test
|
||||
python -c "import sqlalchemy, psycopg; print(sqlalchemy.__version__); print(psycopg.__version__)"
|
||||
```
|
||||
|
||||
缺少依赖时执行:
|
||||
|
||||
```powershell
|
||||
python -m pip install -r requirements.txt
|
||||
```
|
||||
|
||||
如果第五课继续使用第四课数据库配置,可以在第五课目录执行:
|
||||
|
||||
```powershell
|
||||
Copy-Item ..\4_4_SQLAlchemy关系映射与工程实践\config.toml .\config.toml
|
||||
```
|
||||
|
||||
也可以复制模板后自行填写:
|
||||
|
||||
```powershell
|
||||
Copy-Item config.example.toml config.toml
|
||||
```
|
||||
|
||||
真实配置只保存在被Git忽略的`config.toml`中,不写入环境变量、示例文件或Python代码。
|
||||
|
||||
## 十二、运行方法
|
||||
|
||||
```powershell
|
||||
cd D:\Code\Python\04_数据库\4_5_数据库综合项目
|
||||
conda activate python-test
|
||||
python inventory_order_example.py
|
||||
```
|
||||
|
||||
正常结果应包括:
|
||||
|
||||
```text
|
||||
初始库存:
|
||||
DBP-001|机械键盘|价格:399.00|库存:10
|
||||
DBP-002|无线鼠标|价格:199.00|库存:5
|
||||
成功订单提交后:
|
||||
DBP-001|机械键盘|价格:399.00|库存:8
|
||||
DBP-002|无线鼠标|价格:199.00|库存:4
|
||||
失败订单已回滚:商品库存不足:DBP-002
|
||||
失败订单回滚后:
|
||||
DBP-001|机械键盘|价格:399.00|库存:8
|
||||
DBP-002|无线鼠标|价格:199.00|库存:4
|
||||
```
|
||||
|
||||
随后会输出两条`DBO-001`订单明细和一条`CREATED|订单数量:1`。连续运行两次时输出应保持一致;数据库序列生成的内部ID继续增长属于正常现象。
|
||||
|
||||
## 十三、关键执行顺序
|
||||
|
||||
1. 读取本地TOML配置;
|
||||
2. 创建Engine、连接池和Session工厂;
|
||||
3. 创建缺失的练习表;
|
||||
4. 在一个事务中重置练习数据;
|
||||
5. 查询初始库存;
|
||||
6. 在一个事务中执行成功下单;
|
||||
7. 在另一个事务中模拟库存不足并自动回滚;
|
||||
8. 使用DTO查询订单明细;
|
||||
9. 使用聚合查询统计订单状态;
|
||||
10. 释放连接池。
|
||||
|
||||
## 十四、常见错误
|
||||
|
||||
### 14.1 Repository中直接commit
|
||||
|
||||
这会让成功处理的第一项商品提前提交,后续商品失败时无法完整回滚。
|
||||
|
||||
### 14.2 每个Repository创建自己的Session
|
||||
|
||||
不同Session通常意味着不同事务。订单新增与库存扣减必须共享调用方传入的同一个Session。
|
||||
|
||||
### 14.3 捕获异常后不再抛出
|
||||
|
||||
如果在事务`with`内部吞掉库存不足异常,上下文会误以为业务成功并提交。应让异常离开事务上下文,再在外层捕获。
|
||||
|
||||
### 14.4 使用float计算金额
|
||||
|
||||
可能产生精度问题。金额应使用`Decimal`和数据库`NUMERIC`。
|
||||
|
||||
### 14.5 只检查库存但不锁定
|
||||
|
||||
单人练习时看似正确,并发请求下可能超卖。本课使用`FOR UPDATE`锁定商品行。
|
||||
|
||||
### 14.6 直接用当前商品价格展示历史订单
|
||||
|
||||
商品后来改价会污染历史数据。订单明细应保存成交时的`unit_price`。
|
||||
|
||||
## 十五、课堂练习
|
||||
|
||||
练习位于`practice.py`,仍采用前几课的分步形式,只包含题目、预期结果、自查清单和验收标准。请先独立完成,每完成一部分都可以让我验证。
|
||||
|
||||
## 十六、本课小结
|
||||
|
||||
1. 真实业务写入通常跨越多张表,事务边界应围绕完整业务用例;
|
||||
2. Repository负责数据访问,Service负责业务规则,调用方负责事务;
|
||||
3. 多个Repository必须共享同一个Session才能处于同一个事务;
|
||||
4. `FOR UPDATE`可以在事务中锁定待修改库存;
|
||||
5. 金额使用`Decimal`与`NUMERIC`;
|
||||
6. 多表列表查询适合使用DTO;
|
||||
7. 失败事务必须既不保留订单,也不保留任何库存修改。
|
||||
|
||||
## 十七、验收标准
|
||||
|
||||
- 三张表的约束、外键和ORM关系正确;
|
||||
- 成功订单保存订单与明细并扣减库存;
|
||||
- 库存不足时整个事务回滚;
|
||||
- Repository、Service和事务职责清晰;
|
||||
- DTO联表查询及状态统计正确;
|
||||
- 程序可重复运行且不影响其他数据;
|
||||
- 配置文件不进入Git;
|
||||
- 能解释本项目与Java中Mapper/Service/`@Transactional`/DTO的对应关系。
|
||||
@@ -0,0 +1,7 @@
|
||||
[postgresql]
|
||||
host = "你的PostgreSQL服务器地址"
|
||||
port = 5432
|
||||
dbname = "python_test"
|
||||
user = "你的数据库用户名"
|
||||
password = "你的数据库密码"
|
||||
connect_timeout = 10
|
||||
@@ -0,0 +1,448 @@
|
||||
"""第4-5课标准示例:使用SQLAlchemy实现库存订单综合项目。"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
import tomllib
|
||||
|
||||
from sqlalchemy import (
|
||||
CheckConstraint,
|
||||
ForeignKey,
|
||||
Numeric,
|
||||
String,
|
||||
URL,
|
||||
create_engine,
|
||||
delete,
|
||||
func,
|
||||
select,
|
||||
)
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
from sqlalchemy.orm import (
|
||||
DeclarativeBase,
|
||||
Mapped,
|
||||
Session,
|
||||
mapped_column,
|
||||
relationship,
|
||||
sessionmaker,
|
||||
)
|
||||
|
||||
|
||||
CONFIG_PATH = Path(__file__).with_name("config.toml")
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
"""所有ORM模型共同继承的声明式基类。"""
|
||||
|
||||
|
||||
class OrderError(Exception):
|
||||
"""表示下单过程中可以预期的业务异常。"""
|
||||
|
||||
|
||||
class Product(Base):
|
||||
"""商品模型,保存价格和当前库存。"""
|
||||
|
||||
__tablename__ = "course_shop_product"
|
||||
__table_args__ = (
|
||||
CheckConstraint("price >= 0", name="ck_course_shop_product_price"),
|
||||
CheckConstraint("stock >= 0", name="ck_course_shop_product_stock"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
product_code: Mapped[str] = mapped_column(String(30), unique=True, nullable=False)
|
||||
product_name: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
price: Mapped[Decimal] = mapped_column(Numeric(10, 2), nullable=False)
|
||||
stock: Mapped[int] = mapped_column(nullable=False)
|
||||
|
||||
items: Mapped[list["OrderItem"]] = relationship(back_populates="product")
|
||||
|
||||
|
||||
class Order(Base):
|
||||
"""订单主表模型,保存客户、总金额和订单状态。"""
|
||||
|
||||
__tablename__ = "course_shop_order"
|
||||
__table_args__ = (
|
||||
CheckConstraint(
|
||||
"total_amount >= 0",
|
||||
name="ck_course_shop_order_total_amount",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
order_no: Mapped[str] = mapped_column(String(30), unique=True, nullable=False)
|
||||
customer_name: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
total_amount: Mapped[Decimal] = mapped_column(Numeric(12, 2), nullable=False)
|
||||
status: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||
|
||||
items: Mapped[list["OrderItem"]] = relationship(
|
||||
back_populates="order",
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
|
||||
|
||||
class OrderItem(Base):
|
||||
"""订单明细模型,连接订单和商品并保存成交单价。"""
|
||||
|
||||
__tablename__ = "course_shop_order_item"
|
||||
__table_args__ = (
|
||||
CheckConstraint("quantity > 0", name="ck_course_shop_order_item_quantity"),
|
||||
CheckConstraint(
|
||||
"unit_price >= 0",
|
||||
name="ck_course_shop_order_item_unit_price",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
order_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("course_shop_order.id"),
|
||||
nullable=False,
|
||||
)
|
||||
product_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("course_shop_product.id"),
|
||||
nullable=False,
|
||||
)
|
||||
quantity: Mapped[int] = mapped_column(nullable=False)
|
||||
unit_price: Mapped[Decimal] = mapped_column(Numeric(10, 2), nullable=False)
|
||||
|
||||
order: Mapped[Order] = relationship(back_populates="items")
|
||||
product: Mapped[Product] = relationship(back_populates="items")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OrderDetailDTO:
|
||||
"""向调用方返回的订单明细查询结果。"""
|
||||
|
||||
order_no: str
|
||||
customer_name: str
|
||||
product_code: str
|
||||
product_name: str
|
||||
quantity: int
|
||||
unit_price: Decimal
|
||||
line_amount: Decimal
|
||||
status: str
|
||||
|
||||
|
||||
def load_database_config(config_path: Path) -> dict:
|
||||
"""从本地TOML文件读取PostgreSQL配置。"""
|
||||
|
||||
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
|
||||
|
||||
|
||||
def create_database_url(database_config: dict) -> URL:
|
||||
"""使用URL.create()构造连接地址,避免手工拼接密码。"""
|
||||
|
||||
return URL.create(
|
||||
drivername="postgresql+psycopg",
|
||||
username=str(database_config["user"]),
|
||||
password=str(database_config["password"]),
|
||||
host=str(database_config["host"]),
|
||||
port=int(database_config["port"]),
|
||||
database=str(database_config["dbname"]),
|
||||
)
|
||||
|
||||
|
||||
class ProductRepository:
|
||||
"""封装商品表的数据访问操作。"""
|
||||
|
||||
def __init__(self, session: Session):
|
||||
self.session = session
|
||||
|
||||
def find_by_code_for_update(self, product_code: str) -> Product:
|
||||
"""查询并锁定商品,防止并发下单时同时修改同一库存。"""
|
||||
|
||||
statement = (
|
||||
select(Product)
|
||||
.where(Product.product_code == product_code)
|
||||
.with_for_update()
|
||||
)
|
||||
product = self.session.scalar(statement)
|
||||
if product is None:
|
||||
raise OrderError(f"商品不存在:{product_code}")
|
||||
return product
|
||||
|
||||
def find_practice_products(self) -> list[Product]:
|
||||
"""查询本课练习商品。"""
|
||||
|
||||
statement = (
|
||||
select(Product)
|
||||
.where(Product.product_code.like("DBP-%"))
|
||||
.order_by(Product.product_code)
|
||||
)
|
||||
return list(self.session.scalars(statement))
|
||||
|
||||
|
||||
class OrderRepository:
|
||||
"""封装订单及订单明细的数据访问操作。"""
|
||||
|
||||
def __init__(self, session: Session):
|
||||
self.session = session
|
||||
|
||||
def exists_by_order_no(self, order_no: str) -> bool:
|
||||
"""判断订单编号是否存在。"""
|
||||
|
||||
statement = select(Order.id).where(Order.order_no == order_no)
|
||||
return self.session.scalar(statement) is not None
|
||||
|
||||
def add(self, order: Order) -> None:
|
||||
"""把订单加入Session;事务提交仍由外层调用方负责。"""
|
||||
|
||||
self.session.add(order)
|
||||
|
||||
def find_order_details(self) -> list[OrderDetailDTO]:
|
||||
"""联表查询订单明细并转换为DTO。"""
|
||||
|
||||
statement = (
|
||||
select(
|
||||
Order.order_no,
|
||||
Order.customer_name,
|
||||
Product.product_code,
|
||||
Product.product_name,
|
||||
OrderItem.quantity,
|
||||
OrderItem.unit_price,
|
||||
Order.status,
|
||||
)
|
||||
.join(OrderItem, Order.id == OrderItem.order_id)
|
||||
.join(Product, OrderItem.product_id == Product.id)
|
||||
.where(Order.order_no.like("DBO-%"))
|
||||
.order_by(Order.order_no, OrderItem.id)
|
||||
)
|
||||
|
||||
details = []
|
||||
for row in self.session.execute(statement):
|
||||
details.append(
|
||||
OrderDetailDTO(
|
||||
order_no=row.order_no,
|
||||
customer_name=row.customer_name,
|
||||
product_code=row.product_code,
|
||||
product_name=row.product_name,
|
||||
quantity=row.quantity,
|
||||
unit_price=row.unit_price,
|
||||
line_amount=row.unit_price * row.quantity,
|
||||
status=row.status,
|
||||
)
|
||||
)
|
||||
return details
|
||||
|
||||
def count_orders_by_status(self) -> list[tuple[str, int]]:
|
||||
"""让数据库按状态统计本课订单数量。"""
|
||||
|
||||
statement = (
|
||||
select(Order.status, func.count(Order.id))
|
||||
.where(Order.order_no.like("DBO-%"))
|
||||
.group_by(Order.status)
|
||||
.order_by(Order.status)
|
||||
)
|
||||
return list(self.session.execute(statement).tuples())
|
||||
|
||||
|
||||
class OrderService:
|
||||
"""实现下单和扣减库存的业务规则。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
product_repository: ProductRepository,
|
||||
order_repository: OrderRepository,
|
||||
):
|
||||
self.product_repository = product_repository
|
||||
self.order_repository = order_repository
|
||||
|
||||
def place_order(
|
||||
self,
|
||||
order_no: str,
|
||||
customer_name: str,
|
||||
requests: list[tuple[str, int]],
|
||||
) -> None:
|
||||
"""在调用方提供的事务中创建订单并扣减库存。"""
|
||||
|
||||
if self.order_repository.exists_by_order_no(order_no):
|
||||
raise OrderError(f"订单已存在:{order_no}")
|
||||
if not requests:
|
||||
raise OrderError("订单至少需要一项商品。")
|
||||
|
||||
order_items = []
|
||||
total_amount = Decimal("0.00")
|
||||
|
||||
for product_code, quantity in requests:
|
||||
if quantity <= 0:
|
||||
raise OrderError("购买数量必须大于0。")
|
||||
|
||||
product = self.product_repository.find_by_code_for_update(product_code)
|
||||
if product.stock < quantity:
|
||||
raise OrderError(f"商品库存不足:{product_code}")
|
||||
|
||||
product.stock -= quantity
|
||||
order_items.append(
|
||||
OrderItem(
|
||||
product=product,
|
||||
quantity=quantity,
|
||||
unit_price=product.price,
|
||||
)
|
||||
)
|
||||
total_amount += product.price * quantity
|
||||
|
||||
order = Order(
|
||||
order_no=order_no,
|
||||
customer_name=customer_name,
|
||||
total_amount=total_amount,
|
||||
status="CREATED",
|
||||
items=order_items,
|
||||
)
|
||||
self.order_repository.add(order)
|
||||
|
||||
|
||||
def reset_and_add_products(session: Session) -> None:
|
||||
"""按外键依赖顺序清理并重建本课练习数据。"""
|
||||
|
||||
order_ids = select(Order.id).where(Order.order_no.like("DBO-%"))
|
||||
session.execute(delete(OrderItem).where(OrderItem.order_id.in_(order_ids)))
|
||||
session.execute(delete(Order).where(Order.order_no.like("DBO-%")))
|
||||
session.execute(delete(Product).where(Product.product_code.like("DBP-%")))
|
||||
|
||||
session.add_all(
|
||||
[
|
||||
Product(
|
||||
product_code="DBP-001",
|
||||
product_name="机械键盘",
|
||||
price=Decimal("399.00"),
|
||||
stock=10,
|
||||
),
|
||||
Product(
|
||||
product_code="DBP-002",
|
||||
product_name="无线鼠标",
|
||||
price=Decimal("199.00"),
|
||||
stock=5,
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def create_order_service(session: Session) -> OrderService:
|
||||
"""使用同一个Session组装Repository和Service。"""
|
||||
|
||||
return OrderService(
|
||||
ProductRepository(session),
|
||||
OrderRepository(session),
|
||||
)
|
||||
|
||||
|
||||
def run_successful_order(session_factory: sessionmaker[Session]) -> None:
|
||||
"""执行成功订单,正常离开上下文后自动提交。"""
|
||||
|
||||
with session_factory.begin() as session:
|
||||
service = create_order_service(session)
|
||||
service.place_order(
|
||||
"DBO-001",
|
||||
"张三",
|
||||
[("DBP-001", 2), ("DBP-002", 1)],
|
||||
)
|
||||
|
||||
|
||||
def run_failed_order(session_factory: sessionmaker[Session]) -> None:
|
||||
"""执行库存不足订单,让整个事务自动回滚。"""
|
||||
|
||||
try:
|
||||
with session_factory.begin() as session:
|
||||
service = create_order_service(session)
|
||||
service.place_order(
|
||||
"DBO-002",
|
||||
"李四",
|
||||
[("DBP-001", 1), ("DBP-002", 99)],
|
||||
)
|
||||
except OrderError as error:
|
||||
print(f"失败订单已回滚:{error}")
|
||||
|
||||
|
||||
def query_products(session_factory: sessionmaker[Session]) -> list[Product]:
|
||||
"""使用独立Session查询商品,并在关闭前取得所需字段。"""
|
||||
|
||||
with session_factory() as session:
|
||||
products = ProductRepository(session).find_practice_products()
|
||||
# expire_on_commit=False且这里只读取标量字段,Session关闭后仍可用于输出。
|
||||
return products
|
||||
|
||||
|
||||
def print_products(title: str, products: list[Product]) -> None:
|
||||
"""按统一格式输出商品库存。"""
|
||||
|
||||
print(title)
|
||||
for product in products:
|
||||
print(
|
||||
f"{product.product_code}|{product.product_name}|"
|
||||
f"价格:{product.price}|库存:{product.stock}"
|
||||
)
|
||||
|
||||
|
||||
def print_order_results(session_factory: sessionmaker[Session]) -> None:
|
||||
"""查询并输出DTO明细和订单状态统计。"""
|
||||
|
||||
with session_factory() as session:
|
||||
repository = OrderRepository(session)
|
||||
|
||||
print("订单明细DTO:")
|
||||
for detail in repository.find_order_details():
|
||||
print(
|
||||
f"{detail.order_no}|{detail.customer_name}|{detail.product_code}|"
|
||||
f"{detail.product_name}|数量:{detail.quantity}|"
|
||||
f"单价:{detail.unit_price}|小计:{detail.line_amount}|"
|
||||
f"{detail.status}"
|
||||
)
|
||||
|
||||
print("订单状态统计:")
|
||||
for status, order_count in repository.count_orders_by_status():
|
||||
print(f"{status}|订单数量:{order_count}")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""创建环境并依次验证成功事务、失败回滚和多表查询。"""
|
||||
|
||||
engine = None
|
||||
try:
|
||||
database_config = load_database_config(CONFIG_PATH)
|
||||
engine = create_engine(
|
||||
create_database_url(database_config),
|
||||
connect_args={
|
||||
"connect_timeout": int(database_config.get("connect_timeout", 10))
|
||||
},
|
||||
pool_size=5,
|
||||
max_overflow=5,
|
||||
pool_pre_ping=True,
|
||||
echo=False,
|
||||
)
|
||||
session_factory = sessionmaker(engine, expire_on_commit=False)
|
||||
Base.metadata.create_all(engine)
|
||||
|
||||
with session_factory.begin() as session:
|
||||
reset_and_add_products(session)
|
||||
|
||||
print_products("初始库存:", query_products(session_factory))
|
||||
|
||||
run_successful_order(session_factory)
|
||||
print_products("成功订单提交后:", query_products(session_factory))
|
||||
|
||||
run_failed_order(session_factory)
|
||||
print_products("失败订单回滚后:", query_products(session_factory))
|
||||
|
||||
print_order_results(session_factory)
|
||||
except (RuntimeError, KeyError, tomllib.TOMLDecodeError) as error:
|
||||
print(f"配置读取失败:{error}")
|
||||
except OrderError as error:
|
||||
print(f"订单业务失败:{error}")
|
||||
except SQLAlchemyError as error:
|
||||
print(f"数据库访问失败:{error}")
|
||||
finally:
|
||||
if engine is not None:
|
||||
engine.dispose()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,178 @@
|
||||
# 第4-5课练习:数据库综合项目——库存订单管理
|
||||
#
|
||||
# 本文件只提供题目,不包含导入、代码骨架、测试数据代码或参考答案。
|
||||
# 练习会创建course_shop_product、course_shop_order和course_shop_order_item三张表,
|
||||
# 并只操作DBP-商品前缀及DBO-订单前缀的数据。
|
||||
# 请勿改用现有业务表,也不要删除不属于本练习的数据。
|
||||
|
||||
|
||||
# 第一部分:导入、配置与基础类型
|
||||
# 1. 导入dataclass、Decimal、Path和tomllib。
|
||||
# 2. 从sqlalchemy导入CheckConstraint、ForeignKey、Numeric、String、URL、
|
||||
# create_engine、delete、func和select。
|
||||
# 3. 从sqlalchemy.exc导入SQLAlchemyError。
|
||||
# 4. 从sqlalchemy.orm导入DeclarativeBase、Mapped、Session、mapped_column、
|
||||
# relationship、selectinload和sessionmaker。
|
||||
# 5. 使用Path(__file__).with_name("config.toml")定义CONFIG_PATH。
|
||||
# 6. 定义Base(DeclarativeBase)。
|
||||
# 7. 定义OrderError(Exception),用于表达商品不存在、库存不足等业务失败。
|
||||
# 8. 实现load_database_config(config_path),读取[postgresql]配置。
|
||||
# 9. 实现create_database_url(database_config),使用URL.create()创建连接地址。
|
||||
|
||||
|
||||
# 第二部分:定义三个ORM模型
|
||||
# 1. 定义Product(Base),表名course_shop_product:
|
||||
# - id:int主键;
|
||||
# - product_code:最长30字符,唯一且非空;
|
||||
# - product_name:最长100字符且非空;
|
||||
# - price:NUMERIC(10, 2)且非空;
|
||||
# - stock:int且非空;
|
||||
# - 使用CheckConstraint保证price和stock都大于等于0;
|
||||
# - items:与OrderItem建立双向一对多关系。
|
||||
# 2. 定义Order(Base),表名course_shop_order:
|
||||
# - id:int主键;
|
||||
# - order_no:最长30字符,唯一且非空;
|
||||
# - customer_name:最长100字符且非空;
|
||||
# - total_amount:NUMERIC(12, 2)且非空;
|
||||
# - status:最长20字符且非空;
|
||||
# - items:与OrderItem建立双向一对多关系;
|
||||
# - 配置cascade="all, delete-orphan"。
|
||||
# 3. 定义OrderItem(Base),表名course_shop_order_item:
|
||||
# - id:int主键;
|
||||
# - order_id:外键引用course_shop_order.id,非空;
|
||||
# - product_id:外键引用course_shop_product.id,非空;
|
||||
# - quantity:int且非空,使用CheckConstraint保证大于0;
|
||||
# - unit_price:NUMERIC(10, 2)且非空;
|
||||
# - order:与Order.items互为双向关系;
|
||||
# - product:与Product.items互为双向关系。
|
||||
|
||||
|
||||
# 第三部分:定义DTO
|
||||
# 1. 使用@dataclass(frozen=True)定义OrderDetailDTO。
|
||||
# 2. DTO包含order_no、customer_name、product_code、product_name、quantity、
|
||||
# unit_price、line_amount和status。
|
||||
# 3. DTO不继承Base,不承担数据库持久化职责。
|
||||
# 4. line_amount由查询结果中的unit_price乘以quantity得到。
|
||||
|
||||
|
||||
# 第四部分:实现ProductRepository
|
||||
# 1. 构造方法接收并保存外部传入的Session。
|
||||
# 2. find_by_code_for_update(product_code):
|
||||
# - 使用select(Product).where(...)查询商品;
|
||||
# - 调用with_for_update()锁定商品行;
|
||||
# - 找不到时抛出OrderError("商品不存在:{product_code}");
|
||||
# - 返回Product对象。
|
||||
# 3. find_practice_products()查询DBP-前缀商品并按商品编号排序。
|
||||
# 4. Repository中不得创建Session,不得调用commit()或rollback()。
|
||||
|
||||
|
||||
# 第五部分:实现OrderRepository
|
||||
# 1. 构造方法接收并保存外部传入的Session。
|
||||
# 2. exists_by_order_no(order_no)判断订单编号是否已经存在。
|
||||
# 3. add(order)调用session.add(order),但不提交事务。
|
||||
# 4. find_order_details()使用显式join查询订单、明细和商品:
|
||||
# - 只查询DBO-前缀订单;
|
||||
# - 只选择DTO所需字段;
|
||||
# - 按订单编号和明细ID排序;
|
||||
# - 把结果转换成OrderDetailDTO列表。
|
||||
# 5. count_orders_by_status()使用func.count()和group_by()统计各状态订单数。
|
||||
|
||||
|
||||
# 第六部分:实现OrderService下单业务
|
||||
# 1. 构造方法接收ProductRepository和OrderRepository。
|
||||
# 2. 定义place_order(order_no, customer_name, requests),其中requests是
|
||||
# “商品编号、购买数量”组成的列表。
|
||||
# 3. 订单编号已存在时抛出OrderError("订单已存在:{order_no}")。
|
||||
# 4. requests为空时抛出OrderError("订单至少需要一项商品。")。
|
||||
# 5. 逐项处理购买请求:
|
||||
# - 数量小于等于0时抛出OrderError("购买数量必须大于0。");
|
||||
# - 调用find_by_code_for_update()查询并锁定商品;
|
||||
# - 库存不足时抛出OrderError("商品库存不足:{product_code}");
|
||||
# - 商品库存减去购买数量;
|
||||
# - 使用商品当前价格创建OrderItem;
|
||||
# - 累加订单总金额。
|
||||
# 6. 创建status="CREATED"的Order并关联全部OrderItem。
|
||||
# 7. 调用OrderRepository.add(order),不在Service中提交事务。
|
||||
|
||||
|
||||
# 第七部分:准备数据和验证事务
|
||||
# 1. 定义reset_and_add_products(session):
|
||||
# - 先删除DBO-前缀订单对应的订单明细;
|
||||
# - 再删除DBO-前缀订单;
|
||||
# - 最后删除DBP-前缀商品;
|
||||
# - 新增DBP-001机械键盘,价格399.00,库存10;
|
||||
# - 新增DBP-002无线鼠标,价格199.00,库存5;
|
||||
# - 全过程不调用commit()。
|
||||
# 2. 定义run_successful_order(session_factory):
|
||||
# - 使用with session_factory.begin() as session管理事务;
|
||||
# - 创建两个Repository和OrderService;
|
||||
# - 创建订单DBO-001,客户张三,购买2个DBP-001和1个DBP-002;
|
||||
# - 正常离开with,让事务自动提交;
|
||||
# - 成功后键盘库存为8,鼠标库存为4,订单金额为997.00。
|
||||
# 3. 定义run_failed_order(session_factory):
|
||||
# - 在try中使用with session_factory.begin() as session;
|
||||
# - 创建订单DBO-002,先购买1个DBP-001,再购买99个DBP-002;
|
||||
# - 第二项因库存不足抛出OrderError;
|
||||
# - 在事务with外捕获OrderError并输出失败信息;
|
||||
# - 整个订单事务必须回滚,键盘库存仍为8,且DBO-002不能存在。
|
||||
|
||||
|
||||
# 第八部分:输出和main()流程
|
||||
# 1. 定义print_products(title, products),输出商品编号、名称、价格和库存。
|
||||
# 2. 定义print_order_details(details),输出DTO中的订单和明细信息。
|
||||
# 3. 定义print_order_counts(counts),输出“状态|订单数量:数字”。
|
||||
# 4. main()依次执行:
|
||||
# - 读取TOML配置并创建数据库URL;
|
||||
# - 创建一次Engine并启用pool_pre_ping;
|
||||
# - 使用sessionmaker(engine, expire_on_commit=False)创建Session工厂;
|
||||
# - 调用Base.metadata.create_all(engine);
|
||||
# - 在一个事务中重置数据并新增练习商品;
|
||||
# - 输出初始库存;
|
||||
# - 执行成功订单并输出扣减后的库存;
|
||||
# - 执行失败订单并输出回滚后的库存;
|
||||
# - 查询并输出订单DTO和状态统计;
|
||||
# - 分类捕获配置异常、OrderError和SQLAlchemyError;
|
||||
# - 在finally中调用engine.dispose()。
|
||||
# 5. 添加程序入口判断并调用main()。
|
||||
#
|
||||
# 预期关键输出:
|
||||
# 初始库存:
|
||||
# DBP-001|机械键盘|价格:399.00|库存:10
|
||||
# DBP-002|无线鼠标|价格:199.00|库存:5
|
||||
# 成功订单提交后:
|
||||
# DBP-001|机械键盘|价格:399.00|库存:8
|
||||
# DBP-002|无线鼠标|价格:199.00|库存:4
|
||||
# 失败订单已回滚:商品库存不足:DBP-002
|
||||
# 失败订单回滚后:
|
||||
# DBP-001|机械键盘|价格:399.00|库存:8
|
||||
# DBP-002|无线鼠标|价格:199.00|库存:4
|
||||
# 订单明细DTO:
|
||||
# DBO-001|张三|DBP-001|机械键盘|数量:2|单价:399.00|小计:798.00|CREATED
|
||||
# DBO-001|张三|DBP-002|无线鼠标|数量:1|单价:199.00|小计:199.00|CREATED
|
||||
# 订单状态统计:
|
||||
# CREATED|订单数量:1
|
||||
|
||||
|
||||
# 自查清单:
|
||||
# 1. 三个ORM模型是否建立了真实外键和双向对象关系?
|
||||
# 2. 金额是否全部使用Decimal和NUMERIC,而不是float?
|
||||
# 3. 商品查询是否使用FOR UPDATE锁定待扣减库存的记录?
|
||||
# 4. Repository和Service是否都没有自行提交事务?
|
||||
# 5. 一张订单的全部库存扣减和订单新增是否处于同一个事务?
|
||||
# 6. 失败订单中第一项库存扣减是否也被回滚?
|
||||
# 7. 订单明细查询是否使用join()并转换成DTO?
|
||||
# 8. 状态统计是否由数据库完成count()和group_by()?
|
||||
# 9. 程序是否只清理DBP-和DBO-前缀的练习数据?
|
||||
# 10. 配置是否来自被Git忽略的本地config.toml?
|
||||
|
||||
|
||||
# 最终验收标准:
|
||||
# 1. practice.py通过语法检查并能连续运行两次;
|
||||
# 2. 三张表的字段、约束、外键和关系映射正确;
|
||||
# 3. 成功订单正确保存订单、明细并扣减库存;
|
||||
# 4. 失败订单完全回滚,不保存订单且不改变任何库存;
|
||||
# 5. DTO联表查询结果和订单状态统计符合预期;
|
||||
# 6. Repository负责持久化,Service负责业务规则,调用方负责事务;
|
||||
# 7. SQLAlchemy查询使用2.x写法,不使用session.query();
|
||||
# 8. 所有练习数据与现有数据安全隔离;
|
||||
# 9. config.toml与真实连接信息没有进入Git。
|
||||
@@ -0,0 +1,2 @@
|
||||
SQLAlchemy>=2.0,<2.1
|
||||
psycopg[binary]>=3.2,<4.0
|
||||
@@ -0,0 +1,162 @@
|
||||
# 第5-1课:Web运行原理与客户端服务器
|
||||
|
||||
## 一、本课目标
|
||||
|
||||
完成本课后,你能够:
|
||||
|
||||
1. 解释万维网(World Wide Web,Web)是什么;
|
||||
2. 区分客户端(Client)、服务器(Server)、前端和后端;
|
||||
3. 描述浏览器访问后端时的请求响应过程;
|
||||
4. 理解后端程序为什么不能主动把普通响应随意发给浏览器;
|
||||
5. 运行一个不依赖网络的请求响应模拟程序。
|
||||
|
||||
## 二、前置知识
|
||||
|
||||
- Python函数、字典和条件判断;
|
||||
- 知道程序可以接收输入、处理数据并返回结果;
|
||||
- 不要求学过任何Python Web框架。
|
||||
|
||||
## 三、Web是什么
|
||||
|
||||
Web是建立在互联网之上的信息与应用系统。互联网提供计算机之间的连接,Web则约定如何定位资源、发送请求和返回页面或数据。浏览器、手机应用和接口调试工具都可以充当客户端。
|
||||
|
||||
客户端主动发起请求,服务器监听请求、执行业务逻辑并返回响应:
|
||||
|
||||
```text
|
||||
用户 → 客户端 → HTTP请求 → 服务器
|
||||
用户 ← 客户端 ← HTTP响应 ← 服务器
|
||||
```
|
||||
|
||||
一次普通请求对应一次响应。服务器若要主动通知客户端,需要轮询、服务器发送事件或WebSocket等额外机制,这些将在后续课程学习。
|
||||
|
||||
## 四、前端与后端
|
||||
|
||||
- 前端:直接与用户交互的界面和浏览器端逻辑;
|
||||
- 后端:运行在服务器侧,负责业务规则、权限、数据库和接口;
|
||||
- 数据库:负责持久保存数据,通常不直接暴露给浏览器。
|
||||
|
||||
浏览器是客户端的一种,客户端不只有浏览器。Java中的Controller、Service、Repository分层属于服务器内部结构,不等于客户端与服务器的边界。
|
||||
|
||||
## 五、用生活场景理解通信双方
|
||||
|
||||
可以把访问网站类比成到餐厅点餐:
|
||||
|
||||
| Web概念 | 餐厅类比 | 实际职责 |
|
||||
|---|---|---|
|
||||
| 客户端 | 顾客 | 提出明确请求并接收结果 |
|
||||
| HTTP请求 | 点菜单 | 写明想获取什么或提交什么 |
|
||||
| 服务器 | 餐厅服务体系 | 接收请求并组织处理 |
|
||||
| 后端业务代码 | 后厨处理流程 | 校验规则、计算并访问数据 |
|
||||
| HTTP响应 | 送回的餐品和结果单 | 告诉客户端结果与内容 |
|
||||
|
||||
类比只能帮助入门:真实HTTP请求不是口头交流,而是双方按照协议组织的数据。
|
||||
|
||||
## 六、输入网址后发生了什么
|
||||
|
||||
第一次学习时先掌握以下主干流程:
|
||||
|
||||
1. 用户在浏览器输入网址并确认;
|
||||
2. 浏览器从网址中确定要联系的服务器和资源;
|
||||
3. 浏览器与服务器建立网络连接;
|
||||
4. 浏览器发送HTTP请求;
|
||||
5. 服务器读取请求;
|
||||
6. 后端根据路径执行对应逻辑,必要时查询数据库;
|
||||
7. 服务器生成HTTP响应;
|
||||
8. 浏览器读取响应;
|
||||
9. 如果响应是HTML,浏览器解析并显示页面;如果响应是JSON,通常交给前端代码处理。
|
||||
|
||||
服务器地址解析、连接和HTTP报文将在第二课继续展开。本课先记住:页面不是浏览器凭空产生的,数据需要经过请求与响应往返。
|
||||
|
||||
## 七、页面请求与接口请求
|
||||
|
||||
服务器可能返回不同内容:
|
||||
|
||||
- HTML:描述页面结构,浏览器可以渲染;
|
||||
- CSS:描述页面样式;
|
||||
- JavaScript:在浏览器中执行交互逻辑;
|
||||
- 图片或文件:由浏览器展示或下载;
|
||||
- JSON:结构化数据,常用于前后端接口。
|
||||
|
||||
访问一个页面往往不只有一个请求。浏览器取得HTML后,可能继续请求CSS、JavaScript、图片和接口数据。因此开发者工具中看到很多请求是正常现象。
|
||||
|
||||
## 八、完整示例
|
||||
|
||||
`web_flow_example.py`用字典表示请求和响应,不建立真实网络连接,目的是先看清四个步骤:
|
||||
|
||||
1. 浏览器创建请求;
|
||||
2. 服务器接收请求;
|
||||
3. 服务器根据路径处理;
|
||||
4. 浏览器根据响应展示内容。
|
||||
|
||||
## 九、运行方法
|
||||
|
||||
```powershell
|
||||
cd D:\Code\Python\05_web基础\5_1_Web运行原理与客户端服务器
|
||||
python web_flow_example.py
|
||||
```
|
||||
|
||||
命令执行后,Python直接运行本课文件。示例不访问互联网,也不会启动真实服务器,所以任何时候都可以安全重复运行。
|
||||
|
||||
## 十、预期结果
|
||||
|
||||
```text
|
||||
客户端发送:GET /books
|
||||
响应状态:200
|
||||
内容类型:text/html; charset=utf-8
|
||||
浏览器展示:<h1>图书列表</h1>
|
||||
```
|
||||
|
||||
## 十一、关键代码解析
|
||||
|
||||
`browser_send_request()`模拟创建请求;`server_handle_request()`相当于最简单的路由和业务处理;`browser_render_response()`读取服务器返回的数据。这里的字典不是HTTP消息本身,只是帮助观察结构的Python表示。
|
||||
|
||||
按执行顺序阅读:
|
||||
|
||||
1. Python执行文件底部的入口判断并调用`main()`;
|
||||
2. `main()`把路径`/books`交给`browser_send_request()`;
|
||||
3. 函数返回一份包含方法、地址和请求头的模拟请求;
|
||||
4. `server_handle_request()`检查请求路径;
|
||||
5. 路径是`/books`,因此返回状态码200和HTML内容;
|
||||
6. `browser_render_response()`依次输出状态码、内容类型和正文。
|
||||
|
||||
把`"/books"`临时改成`"/missing"`,可以观察404分支。实验完成后恢复标准示例。
|
||||
|
||||
## 十二、常见错误
|
||||
|
||||
### 12.1 把Web等同于互联网
|
||||
|
||||
互联网是连接基础设施,Web是其上的一种应用体系。电子邮件也使用互联网,但不等于Web页面。
|
||||
|
||||
### 12.2 把服务器理解成一台固定机器
|
||||
|
||||
服务器既可指提供服务的程序,也可指运行该程序的计算机。学习后端时通常更关心服务器程序。
|
||||
|
||||
### 12.3 认为后端直接操作页面
|
||||
|
||||
后端返回HTML或JSON,浏览器中的前端代码决定如何展示。两者通过请求和响应协作。
|
||||
|
||||
### 12.4 认为一次打开页面只有一个请求
|
||||
|
||||
现代页面通常还需要样式、脚本、图片和接口数据,浏览器会继续发送多个请求。
|
||||
|
||||
### 12.5 把“本机”与“服务器”对立起来
|
||||
|
||||
服务器程序也可以运行在自己的电脑上。是否叫服务器取决于它是否向客户端提供服务,而不是电脑放在哪里。
|
||||
|
||||
## 十三、课堂练习
|
||||
|
||||
打开`practice.py`,完成两个练习。练习文件没有答案,请先独立实现。
|
||||
|
||||
## 十四、本课小结
|
||||
|
||||
Web应用最基本的闭环是“客户端发请求,服务器做处理,客户端收响应”。前端、后端和数据库是职责划分,客户端与服务器是通信双方。
|
||||
|
||||
## 十五、验收标准
|
||||
|
||||
- 能区分Web与互联网;
|
||||
- 能区分客户端、服务器、前端和后端;
|
||||
- 能按顺序解释一次请求响应;
|
||||
- 能说明一个页面为什么可能产生多个请求;
|
||||
- 能判断HTML与JSON通常由谁处理;
|
||||
- 示例能够独立运行且输出符合预期;
|
||||
- 完成`practice.py`中的练习。
|
||||
@@ -0,0 +1,39 @@
|
||||
# 第5-1课练习:Web运行原理与客户端服务器
|
||||
#
|
||||
# 本文件只提供题目、预期结果、自查清单和验收标准,不包含参考答案或代码骨架。
|
||||
|
||||
|
||||
# 练习一:补全一次请求响应流程
|
||||
# 1. 编写client_send_request(path),返回包含method、path和headers的字典。
|
||||
# 2. 编写server_handle_request(request),处理/和/about两个路径。
|
||||
# 3. 已知路径返回状态码200,未知路径返回状态码404。
|
||||
# 4. 编写client_show_response(response),输出状态码和响应体。
|
||||
|
||||
|
||||
# 练习二:区分各部分职责
|
||||
# 1. 在注释中说明客户端、服务器、前端和后端分别是什么。
|
||||
# 2. 说明“浏览器”和“客户端”为什么不能始终画等号。
|
||||
# 3. 写出从用户输入URL到页面展示的至少五个步骤。
|
||||
|
||||
|
||||
# 预期关键输出:
|
||||
# 客户端发送:GET /about
|
||||
# 响应状态:200
|
||||
# 页面内容:关于我们
|
||||
# 客户端发送:GET /missing
|
||||
# 响应状态:404
|
||||
# 页面内容:页面不存在
|
||||
|
||||
|
||||
# 自查清单:
|
||||
# 1. 客户端是否负责创建请求并处理响应?
|
||||
# 2. 服务器是否根据路径决定返回内容?
|
||||
# 3. 是否理解请求和响应是方向相反的两份消息?
|
||||
# 4. 是否没有把HTML、HTTP和互联网混为同一个概念?
|
||||
|
||||
|
||||
# 最终验收标准:
|
||||
# 1. practice.py通过语法检查并可独立运行;
|
||||
# 2. 已知路径和未知路径的输出符合预期;
|
||||
# 3. 能用自己的话解释客户端与服务器的职责;
|
||||
# 4. 能完整描述一次请求响应过程。
|
||||
@@ -0,0 +1,48 @@
|
||||
"""第5-1课标准示例:模拟客户端、服务器和请求响应流程。"""
|
||||
|
||||
|
||||
def browser_send_request(url: str) -> dict:
|
||||
"""模拟浏览器根据URL创建HTTP请求。"""
|
||||
|
||||
return {
|
||||
"method": "GET",
|
||||
"url": url,
|
||||
"headers": {"Accept": "text/html"},
|
||||
}
|
||||
|
||||
|
||||
def server_handle_request(request: dict) -> dict:
|
||||
"""模拟服务器读取请求并返回HTTP响应。"""
|
||||
|
||||
if request["url"] == "/books":
|
||||
return {
|
||||
"status": 200,
|
||||
"headers": {"Content-Type": "text/html; charset=utf-8"},
|
||||
"body": "<h1>图书列表</h1>",
|
||||
}
|
||||
return {
|
||||
"status": 404,
|
||||
"headers": {"Content-Type": "text/plain; charset=utf-8"},
|
||||
"body": "页面不存在",
|
||||
}
|
||||
|
||||
|
||||
def browser_render_response(response: dict) -> None:
|
||||
"""模拟浏览器检查响应并展示响应体。"""
|
||||
|
||||
print(f"响应状态:{response['status']}")
|
||||
print(f"内容类型:{response['headers']['Content-Type']}")
|
||||
print(f"浏览器展示:{response['body']}")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""依次模拟发送请求、服务器处理和浏览器展示。"""
|
||||
|
||||
request = browser_send_request("/books")
|
||||
print(f"客户端发送:{request['method']} {request['url']}")
|
||||
response = server_handle_request(request)
|
||||
browser_render_response(response)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,189 @@
|
||||
# 第5-2课:URL与HTTP请求
|
||||
|
||||
## 一、本课目标
|
||||
|
||||
完成本课后,你能够:
|
||||
|
||||
1. 拆解统一资源定位符(Uniform Resource Locator,URL);
|
||||
2. 解释HTTP请求行、请求头和请求体;
|
||||
3. 理解路径参数与查询参数的用途差异;
|
||||
4. 为常见操作选择GET、POST、PUT、PATCH或DELETE;
|
||||
5. 使用Python标准库安全编码查询参数。
|
||||
|
||||
## 二、前置知识
|
||||
|
||||
- 已理解客户端与服务器;
|
||||
- Python字典、函数和字符串;
|
||||
- 不要求记忆HTTP报文的所有字段。
|
||||
|
||||
## 三、URL的组成
|
||||
|
||||
示例:
|
||||
|
||||
```text
|
||||
https://api.example.com:443/books/10?detail=true#summary
|
||||
```
|
||||
|
||||
| 部分 | 示例 | 作用 |
|
||||
|---|---|---|
|
||||
| 协议 | `https` | 约定通信方式 |
|
||||
| 主机 | `api.example.com` | 定位服务器 |
|
||||
| 端口 | `443` | 定位服务器中的服务 |
|
||||
| 路径 | `/books/10` | 定位资源 |
|
||||
| 查询字符串 | `detail=true` | 提供筛选或选项 |
|
||||
| 片段 | `summary` | 通常只供客户端定位页面位置,不发送给服务器 |
|
||||
|
||||
HTTPS是经过传输层安全协议(Transport Layer Security,TLS)保护的HTTP。它保护传输过程,但不能自动修复错误的业务权限。
|
||||
|
||||
浏览器真正访问时还会涉及以下概念:
|
||||
|
||||
- 域名(Domain Name):便于人记忆的服务器名称,例如`api.example.com`;
|
||||
- IP地址(Internet Protocol Address):网络用于定位设备或服务入口的地址;
|
||||
- 域名系统(Domain Name System,DNS):把域名查询为可用于连接的IP地址;
|
||||
- 端口(Port):同一台计算机上用于区分不同网络服务的编号。
|
||||
|
||||
可以把IP地址理解成办公楼地址,把端口理解成楼内具体窗口。这个类比不代表端口是物理接口,它只是操作系统管理网络连接时使用的数字。
|
||||
|
||||
HTTP默认端口通常是80,HTTPS默认端口通常是443。使用默认端口时,URL可以省略端口;使用开发服务器的8000等端口时通常需要明确写出。
|
||||
|
||||
## 四、浏览器如何根据URL定位请求目标
|
||||
|
||||
以`https://api.example.com:443/books/10?detail=true`为例:
|
||||
|
||||
1. 浏览器看到`https`,知道要使用受TLS保护的HTTP;
|
||||
2. 通过DNS查询`api.example.com`对应的IP地址;
|
||||
3. 向该地址的443端口建立连接;
|
||||
4. 把`/books/10?detail=true`作为请求目标;
|
||||
5. 在连接中发送HTTP请求;
|
||||
6. 等待服务器返回HTTP响应。
|
||||
|
||||
实际网络还涉及缓存、代理、网关和连接复用。本阶段只建立主干认识,不展开网络工程细节。
|
||||
|
||||
## 五、HTTP请求结构
|
||||
|
||||
```http
|
||||
POST /books HTTP/1.1
|
||||
Host: localhost:8000
|
||||
Content-Type: application/json
|
||||
|
||||
{"title": "Python入门"}
|
||||
```
|
||||
|
||||
- 请求行:请求方法、请求目标和HTTP版本;
|
||||
- 请求头:描述内容类型、认证信息和客户端能力;
|
||||
- 空行:分隔头部与请求体;
|
||||
- 请求体:提交给服务器的数据,GET通常不依赖请求体。
|
||||
|
||||
逐行阅读这份请求:
|
||||
|
||||
1. `POST /books HTTP/1.1`表示使用POST方法访问`/books`;
|
||||
2. `Host`说明目标主机和端口;
|
||||
3. `Content-Type`说明请求体是JSON;
|
||||
4. 空行表示请求头结束;
|
||||
5. 最后一行JSON是提交给服务器的数据。
|
||||
|
||||
HTTP报文在网络中最终按字节传输。这里用文本形式展示,是为了让结构更容易阅读。
|
||||
|
||||
## 六、常用请求方法
|
||||
|
||||
| 方法 | 常见含义 | 图书示例 |
|
||||
|---|---|---|
|
||||
| GET | 查询 | 查询图书 |
|
||||
| POST | 创建或触发处理 | 新增图书 |
|
||||
| PUT | 整体替换 | 替换图书全部可修改信息 |
|
||||
| PATCH | 局部修改 | 只修改价格 |
|
||||
| DELETE | 删除 | 删除图书 |
|
||||
|
||||
方法表达意图,最终行为仍由服务器代码决定。GET应当是安全方法,即正常调用不应修改业务数据;重复执行PUT或DELETE通常应具有幂等性,即最终效果与执行一次相同。
|
||||
|
||||
安全和幂等是HTTP语义,不等同于权限安全:
|
||||
|
||||
- “GET是安全方法”表示它不应改变业务状态;
|
||||
- “DELETE通常幂等”表示重复删除后最终仍是资源不存在;
|
||||
- 它们都不表示接口无需登录或权限控制。
|
||||
|
||||
## 七、路径参数与查询参数
|
||||
|
||||
- `/books/10`中的`10`通常是路径参数,用于标识某一本书;
|
||||
- `/books?keyword=Python&page=2`中的值是查询参数,用于筛选、排序或分页。
|
||||
|
||||
请求体通常承载新增或修改时的结构化数据,例如书名、价格和作者。不要把大量结构化内容全部塞进URL。
|
||||
|
||||
## 八、URL编码
|
||||
|
||||
URL中的空格、中文、`&`和`=`可能与URL结构规则冲突,因此需要百分号编码(Percent-encoding)或表单风格编码。
|
||||
|
||||
```python
|
||||
urlencode({"keyword": "Python Web", "page": 1})
|
||||
```
|
||||
|
||||
结果中的空格可能显示为`+`:
|
||||
|
||||
```text
|
||||
keyword=Python+Web&page=1
|
||||
```
|
||||
|
||||
客户端负责编码,服务器负责解码。不要先手工替换一次,再交给工具重复编码。
|
||||
|
||||
## 九、完整示例
|
||||
|
||||
`url_request_example.py`使用`urlsplit()`拆分URL,使用`parse_qs()`读取查询参数,使用`urlencode()`处理空格等特殊字符。
|
||||
|
||||
## 十、运行方法
|
||||
|
||||
```powershell
|
||||
cd D:\Code\Python\05_web基础\5_2_URL与HTTP请求
|
||||
python url_request_example.py
|
||||
```
|
||||
|
||||
关键输出包括协议、主机、端口、路径、查询参数,以及编码后的GET请求行。
|
||||
|
||||
## 十一、代码执行顺序
|
||||
|
||||
1. `main()`准备一条完整URL;
|
||||
2. `parse_url()`使用`urlsplit()`拆分结构;
|
||||
3. `parse_qs()`把查询字符串转换为字典;
|
||||
4. 程序分别输出协议、主机、端口、路径和查询参数;
|
||||
5. `build_request_target()`使用`urlencode()`编码参数;
|
||||
6. 程序输出一条简化请求行和请求头。
|
||||
|
||||
`api.example.com`是教学示例域名,程序只解析字符串,不会访问该网站。
|
||||
|
||||
## 十二、常见错误
|
||||
|
||||
### 12.1 手工拼接查询字符串
|
||||
|
||||
空格、中文、`&`和`=`具有特殊含义,应交给`urlencode()`等工具编码。
|
||||
|
||||
### 12.2 用POST处理所有操作
|
||||
|
||||
程序可能运行,但接口意图模糊,缓存、重试、权限和文档也更难设计。
|
||||
|
||||
### 12.3 在URL中传密码
|
||||
|
||||
URL容易进入浏览历史和日志。密码及令牌不应放入查询字符串。
|
||||
|
||||
### 12.4 混淆域名、IP和端口
|
||||
|
||||
域名需要经过DNS解析,IP用于网络定位,端口用于区分服务。它们有关联,但不是同一个值。
|
||||
|
||||
### 12.5 认为HTTPS代表接口一定可信
|
||||
|
||||
HTTPS主要保护传输过程。客户端仍要确认访问的域名,服务器仍要进行身份认证、权限和输入校验。
|
||||
|
||||
## 十三、课堂练习
|
||||
|
||||
打开`practice.py`,完成URL解析和请求方法选择练习。
|
||||
|
||||
## 十四、本课小结
|
||||
|
||||
URL回答“向哪里请求”,HTTP方法回答“想做什么”,请求头描述消息,请求体携带提交的数据。
|
||||
|
||||
## 十五、验收标准
|
||||
|
||||
- 能拆解完整URL;
|
||||
- 能解释域名、DNS、IP和端口的基本关系;
|
||||
- 能解释HTTP请求的主要部分;
|
||||
- 能区分路径参数和查询参数;
|
||||
- 能为常见增删改查选择请求方法;
|
||||
- 示例运行结果符合预期。
|
||||
@@ -0,0 +1,37 @@
|
||||
# 第5-2课练习:URL与HTTP请求
|
||||
#
|
||||
# 本文件只提供题目、预期结果、自查清单和验收标准,不包含参考答案或代码骨架。
|
||||
|
||||
|
||||
# 练习一:URL分析器
|
||||
# 1. 使用urllib.parse.urlsplit()解析一个完整URL。
|
||||
# 2. 分别输出协议、主机、端口、路径、查询字符串和片段。
|
||||
# 3. 使用parse_qs()解析查询参数。
|
||||
# 4. 测试包含中文关键字的URL编码结果。
|
||||
|
||||
|
||||
# 练习二:为业务选择请求方法
|
||||
# 为图书查询、新增、整体修改、局部修改和删除分别选择HTTP方法。
|
||||
# 在注释中解释每种选择,不需要发送真实网络请求。
|
||||
|
||||
|
||||
# 预期关键输出:
|
||||
# 协议:https
|
||||
# 主机:localhost
|
||||
# 端口:8000
|
||||
# 路径:/books/10
|
||||
# 查询参数:{'detail': ['true']}
|
||||
|
||||
|
||||
# 自查清单:
|
||||
# 1. 是否区分主机、端口、路径和查询参数?
|
||||
# 2. 是否使用工具编码查询参数,而不是手工拼接特殊字符?
|
||||
# 3. 是否理解GET通常读取资源,POST通常创建资源?
|
||||
# 4. 是否知道请求头和请求体不是同一部分?
|
||||
|
||||
|
||||
# 最终验收标准:
|
||||
# 1. practice.py通过语法检查并可独立运行;
|
||||
# 2. URL每部分解析正确;
|
||||
# 3. 中文和空格经过正确编码;
|
||||
# 4. 能为五种图书操作选择合理请求方法。
|
||||
@@ -0,0 +1,38 @@
|
||||
"""第5-2课标准示例:解析URL并构造HTTP请求信息。"""
|
||||
|
||||
from urllib.parse import parse_qs, urlencode, urlsplit
|
||||
|
||||
|
||||
def parse_url(url: str) -> None:
|
||||
"""拆分URL,并把查询字符串转换成便于使用的字典。"""
|
||||
|
||||
parts = urlsplit(url)
|
||||
query_parameters = parse_qs(parts.query)
|
||||
print(f"协议:{parts.scheme}")
|
||||
print(f"主机:{parts.hostname}")
|
||||
print(f"端口:{parts.port}")
|
||||
print(f"路径:{parts.path}")
|
||||
print(f"查询参数:{query_parameters}")
|
||||
|
||||
|
||||
def build_request_target(path: str, parameters: dict) -> str:
|
||||
"""把路径和查询参数编码成请求目标。"""
|
||||
|
||||
return f"{path}?{urlencode(parameters)}"
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""展示URL各部分以及一份简化的GET请求。"""
|
||||
|
||||
url = "https://api.example.com:443/books?keyword=Python&page=2"
|
||||
parse_url(url)
|
||||
request_target = build_request_target(
|
||||
"/books",
|
||||
{"keyword": "Python Web", "page": 1},
|
||||
)
|
||||
print(f"请求行:GET {request_target} HTTP/1.1")
|
||||
print("请求头:Accept: application/json")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,174 @@
|
||||
# 第5-3课:HTTP响应与状态码
|
||||
|
||||
## 一、本课目标
|
||||
|
||||
完成本课后,你能够:
|
||||
|
||||
1. 解释HTTP响应状态行、响应头和响应体;
|
||||
2. 理解2xx、3xx、4xx和5xx状态码分类;
|
||||
3. 为常见业务结果选择合适状态码;
|
||||
4. 区分401、403和404;
|
||||
5. 使用Python的`HTTPStatus`避免魔法数字。
|
||||
|
||||
## 二、前置知识
|
||||
|
||||
- 已理解HTTP请求;
|
||||
- Python字典、函数和异常;
|
||||
- 知道后端业务可能成功,也可能因输入或系统问题失败。
|
||||
|
||||
## 三、HTTP响应结构
|
||||
|
||||
```http
|
||||
HTTP/1.1 200 OK
|
||||
Content-Type: application/json; charset=utf-8
|
||||
|
||||
{"id": 1, "title": "Python入门"}
|
||||
```
|
||||
|
||||
- 状态行:HTTP版本、状态码和原因短语;
|
||||
- 响应头:描述响应内容、缓存和认证等信息;
|
||||
- 响应体:页面、JSON、文件或错误详情。
|
||||
|
||||
逐行阅读:
|
||||
|
||||
1. `HTTP/1.1 200 OK`说明HTTP版本和处理结果;
|
||||
2. `Content-Type`说明响应体是UTF-8编码的JSON;
|
||||
3. 空行表示响应头结束;
|
||||
4. 最后一行JSON是响应体;
|
||||
5. 客户端先根据状态码判断结果类别,再按照`Content-Type`解析响应体。
|
||||
|
||||
状态码是给程序和人共同使用的标准信号,原因短语`OK`主要帮助阅读。客户端逻辑应依赖状态码,不应依赖英文短语。
|
||||
|
||||
## 四、状态码分类
|
||||
|
||||
| 范围 | 含义 | 常见情况 |
|
||||
|---|---|---|
|
||||
| 1xx | 信息响应 | 处理中,入门阶段很少手工处理 |
|
||||
| 2xx | 成功 | 查询、创建、更新或删除成功 |
|
||||
| 3xx | 重定向 | 资源位置变化或缓存仍有效 |
|
||||
| 4xx | 客户端请求问题 | 参数、认证、权限或资源错误 |
|
||||
| 5xx | 服务器处理失败 | 未处理异常或上游服务失败 |
|
||||
|
||||
## 五、常用状态码
|
||||
|
||||
| 状态码 | 常见使用场景 |
|
||||
|---|---|
|
||||
| 200 OK | 成功并返回内容 |
|
||||
| 201 Created | 成功创建资源,通常附带`Location`头 |
|
||||
| 204 No Content | 成功但没有响应体 |
|
||||
| 400 Bad Request | 请求格式或通用参数错误 |
|
||||
| 401 Unauthorized | 尚未完成身份认证,名称虽是Unauthorized,实际常表示未认证 |
|
||||
| 403 Forbidden | 身份已知,但没有访问权限 |
|
||||
| 404 Not Found | 资源不存在 |
|
||||
| 409 Conflict | 当前状态冲突,例如编号重复 |
|
||||
| 422 Unprocessable Content | 格式可解析,但字段校验或语义不符合要求 |
|
||||
| 500 Internal Server Error | 服务器出现未预期错误 |
|
||||
|
||||
业务失败不一定是500。例如图书不存在是404,重复编号可以是409。不要把异常详情和堆栈直接返回给外部客户端。
|
||||
|
||||
## 六、选择状态码的思考顺序
|
||||
|
||||
遇到业务结果时,可以依次提问:
|
||||
|
||||
1. 请求是否完成了预期操作?完成则从2xx中选择;
|
||||
2. 是否需要把客户端引导到其他地址?是则考虑3xx;
|
||||
3. 客户端修改请求、身份或操作时机后能否解决?能则通常是4xx;
|
||||
4. 是否因为服务器出现未预期故障而无法完成?是则通常是5xx。
|
||||
|
||||
进一步判断:
|
||||
|
||||
- 成功返回内容:通常200;
|
||||
- 成功创建新资源:201;
|
||||
- 成功且没有响应体:204;
|
||||
- JSON根本无法解析:400;
|
||||
- JSON可解析但字段不符合约束:常用422;
|
||||
- 当前资源状态与操作冲突:409;
|
||||
- 代码出现未处理异常:500。
|
||||
|
||||
状态码选择可能受团队规范影响,但同一项目应保持一致并写入接口文档。
|
||||
|
||||
## 七、401、403、404详细对比
|
||||
|
||||
假设用户请求删除一本图书:
|
||||
|
||||
- 没有登录凭证:401,客户端需要先认证;
|
||||
- 已登录但不是管理员:403,身份明确但操作被禁止;
|
||||
- 有权限但图书编号不存在:404;
|
||||
- 删除成功且不返回正文:204。
|
||||
|
||||
有些安全敏感接口会用404隐藏资源是否存在,这是安全策略,需要由项目统一决定,不能随意混用。
|
||||
|
||||
## 八、完整示例
|
||||
|
||||
`response_status_example.py`使用标准库`HTTPStatus`表示状态码,分别生成图书存在和不存在的响应。
|
||||
|
||||
## 九、运行方法
|
||||
|
||||
```powershell
|
||||
cd D:\Code\Python\05_web基础\5_3_HTTP响应与状态码
|
||||
python response_status_example.py
|
||||
```
|
||||
|
||||
关键输出:
|
||||
|
||||
```text
|
||||
HTTP/1.1 200 OK
|
||||
Content-Type: application/json; charset=utf-8
|
||||
响应体:{'id': 1, 'title': 'Python入门'}
|
||||
---
|
||||
HTTP/1.1 404 Not Found
|
||||
```
|
||||
|
||||
## 十、关键代码解析
|
||||
|
||||
`find_book()`根据业务结果选择状态码;`print_response()`读取枚举的数值和原因短语。实际框架会负责把Python对象转换成网络报文,但选择状态码仍是后端开发者的责任。
|
||||
|
||||
执行过程:
|
||||
|
||||
1. `BOOKS`保存两条内存图书数据;
|
||||
2. 第一次调用`find_book(1)`,字典中存在编号1;
|
||||
3. 函数返回`HTTPStatus.OK`和图书数据;
|
||||
4. 第二次调用`find_book(99)`,没有找到图书;
|
||||
5. 函数返回`HTTPStatus.NOT_FOUND`和错误消息;
|
||||
6. `print_response()`把两种结果按响应结构输出。
|
||||
|
||||
`HTTPStatus`是Python标准库提供的枚举,`HTTPStatus.NOT_FOUND`比直接写`404`更容易读懂。它最终仍能通过`.value`取得数字404。
|
||||
|
||||
## 十一、常见错误
|
||||
|
||||
### 11.1 HTTP永远返回200
|
||||
|
||||
只在响应体里写“失败”会让客户端、监控和网关难以判断真实结果。
|
||||
|
||||
### 11.2 把所有异常都返回500
|
||||
|
||||
输入错误、资源不存在和权限不足属于可预期结果,应明确使用4xx。
|
||||
|
||||
### 11.3 给204响应添加响应体
|
||||
|
||||
204的含义就是没有响应内容,需要返回内容时选择200等状态码。
|
||||
|
||||
### 11.4 只看响应消息,不看状态码
|
||||
|
||||
错误消息可能变化或被翻译,状态码才是客户端判断结果类别的标准信号。
|
||||
|
||||
### 11.5 把程序异常详情返回客户端
|
||||
|
||||
堆栈、SQL和服务器路径可能泄露内部信息。外部响应应提供安全的错误说明,详细异常写入受控日志。
|
||||
|
||||
## 十二、课堂练习
|
||||
|
||||
打开`practice.py`,完成状态码选择和创建资源响应练习。
|
||||
|
||||
## 十三、本课小结
|
||||
|
||||
状态码是HTTP层的统一结果语言。响应体提供细节,不能代替准确的状态码;准确的状态码也不能代替清晰、安全的错误信息。
|
||||
|
||||
## 十四、验收标准
|
||||
|
||||
- 能解释HTTP响应的主要部分;
|
||||
- 能区分2xx、4xx和5xx;
|
||||
- 能正确区分401、403和404;
|
||||
- 能为创建、删除和校验失败选择状态码;
|
||||
- 能按顺序判断一个结果属于2xx、4xx还是5xx;
|
||||
- 示例可独立运行。
|
||||
@@ -0,0 +1,39 @@
|
||||
# 第5-3课练习:HTTP响应与状态码
|
||||
#
|
||||
# 本文件只提供题目、预期结果、自查清单和验收标准,不包含参考答案或代码骨架。
|
||||
|
||||
|
||||
# 练习一:为业务结果选择状态码
|
||||
# 1. 查询成功并返回图书。
|
||||
# 2. 成功创建图书。
|
||||
# 3. 成功删除且不返回响应体。
|
||||
# 4. 请求JSON格式错误。
|
||||
# 5. 未登录、已登录但无权限、图书不存在、服务器未知异常。
|
||||
# 为每种情况选择状态码,并在注释中解释原因。
|
||||
|
||||
|
||||
# 练习二:构造响应
|
||||
# 1. 编写create_book(title),空标题返回客户端错误。
|
||||
# 2. 标题有效时模拟创建图书,返回新资源信息和Location响应头。
|
||||
# 3. 编写print_response()输出状态行、响应头和响应体。
|
||||
|
||||
|
||||
# 预期关键输出:
|
||||
# HTTP/1.1 201 Created
|
||||
# Location: /books/3
|
||||
# 响应体:{'id': 3, 'title': 'Web基础'}
|
||||
# HTTP/1.1 400 Bad Request
|
||||
|
||||
|
||||
# 自查清单:
|
||||
# 1. 是否避免所有结果都返回200?
|
||||
# 2. 是否区分401未认证与403无权限?
|
||||
# 3. 是否区分客户端错误与服务器错误?
|
||||
# 4. 204响应是否没有响应体?
|
||||
|
||||
|
||||
# 最终验收标准:
|
||||
# 1. practice.py通过语法检查并可独立运行;
|
||||
# 2. 各业务情况的状态码合理;
|
||||
# 3. 创建成功包含Location响应头;
|
||||
# 4. 能解释2xx、4xx和5xx的责任边界。
|
||||
@@ -0,0 +1,44 @@
|
||||
"""第5-3课标准示例:根据业务结果生成HTTP响应。"""
|
||||
|
||||
from http import HTTPStatus
|
||||
|
||||
|
||||
BOOKS = {1: "Python入门", 2: "数据库实践"}
|
||||
|
||||
|
||||
def find_book(book_id: int) -> dict:
|
||||
"""查询图书,并用状态码明确表达查询结果。"""
|
||||
|
||||
title = BOOKS.get(book_id)
|
||||
if title is None:
|
||||
return {
|
||||
"status": HTTPStatus.NOT_FOUND,
|
||||
"headers": {"Content-Type": "application/json; charset=utf-8"},
|
||||
"body": {"message": "图书不存在"},
|
||||
}
|
||||
return {
|
||||
"status": HTTPStatus.OK,
|
||||
"headers": {"Content-Type": "application/json; charset=utf-8"},
|
||||
"body": {"id": book_id, "title": title},
|
||||
}
|
||||
|
||||
|
||||
def print_response(response: dict) -> None:
|
||||
"""输出响应状态行、响应头和响应体。"""
|
||||
|
||||
status = response["status"]
|
||||
print(f"HTTP/1.1 {status.value} {status.phrase}")
|
||||
print(f"Content-Type: {response['headers']['Content-Type']}")
|
||||
print(f"响应体:{response['body']}")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""分别展示查询成功与资源不存在的响应。"""
|
||||
|
||||
print_response(find_book(1))
|
||||
print("---")
|
||||
print_response(find_book(99))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user