《Python AI 应用开发入门》第 4.4 节。
使用解包与模式匹配处理结构化数据,理解装饰器和上下文管理器的基本运行机制。
本节目标
学完本节后,你应当能够:
- 3. 使用
** 合并字典并向函数传递关键字参数。 - 4. 使用
match / case 处理结构化命令。 - 8. 编写不会丢失参数、返回值和异常的简单装饰器。
- 9. 解释
with 进入和离开代码块时发生了什么。 - 10. 使用类或
contextlib.contextmanager 创建简单上下文管理器。
1. 序列解包
当一组数据的位置固定、含义明确时,可以按索引逐项取出:
command = ("add", "user", "你好")action = command[0]role = command[1]content = command[2]
不过,变量名和索引分开书写,不容易一眼看出三项数据的对应关系。序列解包可以在一次赋值中把各项交给对应变量:
action, role, content = commandprint(action) # addprint(role) # userprint(content) # 你好
可以把它理解为:Python 把右侧序列拆开,再按照位置交给等号左侧的变量。因此两边数量必须一致:
# action, role = command# ValueError: too many values to unpack
列表、元组和字符串等可以逐项读取的对象都能参与解包:
first, second = ["user", "assistant"]letter_a, letter_b = "AI"
解包适合结构固定、每个位置含义明确的数据。如果长度不确定,直接固定多个变量接收就容易报错。
2. 使用 * 收集剩余元素
命令内容可能包含多个单词:
parts = ["add", "user", "请", "解释", "数据类"]action, role, *content_parts = partsprint(action) # addprint(role) # userprint(content_parts) # ['请', '解释', '数据类']
action 和 role 分别接收前两项,带 * 的 content_parts 会把剩余所有元素收集成一个列表:
content = " ".join(content_parts)print(content) # 请 解释 数据类
也可以收集开头或中间部分:
first, *middle, last = [1, 2, 3, 4, 5]print(first) # 1print(middle) # [2, 3, 4]print(last) # 5
一次解包中只能有一个带 * 的变量,否则 Python 无法判断“剩余元素”应该交给谁。
3. 交换变量与忽略数据
解包可以交换变量:
current_role = "user"next_role = "assistant"current_role, next_role = next_role, current_role
如果某个位置的值不需要使用,通常用 _ 接收:
role, _, content = ("user", "message-001", "你好")
Python 并没有为 _ 赋予“删除这个值”的特殊能力,它仍然是普通变量名。这里的下划线只是告诉读者“这个位置的值后面不会用到”,因此不要再依赖 _ 保存重要数据。
4. 字典解包
项目配置常常由“默认配置”和“用户配置”两部分组成。** 可以把两个字典的键值对展开到一个新字典中:
default_config = { "model": "demo-model", "style": "简洁", "max_history": 50,}user_config = { "style": "详细", "max_history": 20,}config = { **default_config, **user_config,}print(config)
Python 按书写顺序合并这些键值对。遇到同名键时,后面的值覆盖前面的值,所以这里的用户配置会覆盖默认配置中的 style 和 max_history。
合并配置时,书写顺序就是覆盖规则,要先确认谁应该拥有更高优先级。密码、权限等安全设置也不应交给未经验证的输入任意覆盖。
5. 调用函数时解包
上一章在函数定义中见过 *args 和 **kwargs。在函数调用的位置,* 和 ** 做的是相反方向的工作:把已有容器拆开,再传给函数。
def create_message(role, content): return { "role": role, "content": content, }values = ("user", "解释解包")message = create_message(*values)
字典键可以对应参数名:
values = { "role": "user", "content": "解释字典解包",}message = create_message(**values)
create_message(*values) 会按顺序传入元组中的两项;create_message(**values) 会把字典键当作参数名、字典值当作参数值。
因此要区分两个位置:
- • 定义函数时,
*args 和 **kwargs 用来收集参数。 - • 调用函数时,
*values 和 **values 用来展开参数。
6. 用 match / case 选择处理方式
多个命令可以使用 if / elif:
if action == "add": print("添加消息")elif action == "list": print("查看消息")elif action == "exit": print("退出")
当判断的不只是一个值,还包括列表或字典的结构时,连续的 if / elif 会逐渐变得难读。Python 3.10 及以上提供了结构化模式匹配:
match action: case "add": print("添加消息") case "list": print("查看消息") case "exit": print("退出") case _: print("未知命令")
match 后面是要检查的数据,每个 case 描述一种可能的值或结构。Python 从上到下检查,执行第一个匹配的分支。case _ 不限制具体值,因此用作最后的默认分支。
match 不需要替代所有 if。判断年龄是否大于 18、字符串是否为空等真假条件,仍然更适合使用 if;match 更适合根据固定值或数据结构选择处理方式。
7. 匹配并提取序列数据
模式匹配不仅能判断命令类型,还能顺便把命令中的数据取出来:
def handle_command(command): parts = command.split() match parts: case ["list"]: return "查看全部消息" case ["add", role, *content_parts] if content_parts: content = " ".join(content_parts) return f"添加 {role} 消息:{content}" case ["recent", amount_text]: return f"查看最近 {amount_text} 条" case ["exit"]: return "退出程序" case _: return "命令格式无效"
逐个看这些模式:
- •
["list"] 只匹配一个元素且内容是 list 的序列。 - •
["add", role, *content_parts] 匹配以 add 开头的序列,并提取角色与剩余内容。 - •
if content_parts 是守卫条件。序列结构匹配后,还要确认内容列表不是空的。
模式中的 role 和 content_parts 是接收数据的新变量,不是要拿来比较的固定值,因此不需要提前定义。
8. 匹配字典结构
JSON 消息解析后会得到字典。字典模式可以检查必需的键是否存在,同时取出对应的值:
def describe_message(value): match value: case {"role": "user", "content": content}: return f"用户:{content}" case {"role": "assistant", "content": content}: return f"助手:{content}" case {"role": role, "content": content}: return f"{role}:{content}" case _: raise ValueError("消息结构无效")
例如第一条模式要求 role 的值必须是 "user",并把 content 对应的值交给同名变量。字典模式只要求写出来的键存在,原字典可以包含其他额外键。
模式匹配能确认键和值的大致结构,却不会自动检查 content 是不是为非空字符串。仍然需要进一步验证:
def describe_non_empty_message(value): match value: case {"role": role, "content": content} if isinstance(content, str) and content.strip(): return f"{role}:{content.strip()}" case _: raise ValueError("消息内容无效")
9. 函数也可以作为值使用
理解装饰器前,先回顾一个关键点:定义函数后,函数名保存的是一个可以调用的函数对象。它也能像字符串、列表一样,被赋给变量、作为参数传入,或者从另一个函数返回。
函数可以赋给变量:
def greet(name): return f"你好,{name}"formatter = greetprint(formatter("小林"))
可以作为参数传入:
def run_formatter(formatter, value): return formatter(value)print(run_formatter(greet, "小林"))
formatter = greet 没有执行 greet,因为后面没有括号;它只是让 formatter 也指向这个函数。直到写出 formatter("小林") 时,函数才真正执行。
函数也可以从另一个函数返回。上一章学习闭包时已经使用过这种能力,装饰器正是在此基础上工作的。
10. 为什么需要装饰器
假设多个业务函数都要在调用前后记录日志。如果把记录代码直接写进每个函数,真正的业务步骤很快会被重复代码包围:
def save_history(): print("开始调用 save_history") print("正在保存") print("结束调用 save_history")
装饰器把这类重复步骤集中到一个地方:它接收原函数,创建一个负责附加行为的新函数,再把新函数返回。
最小装饰器:
def announce(function): def wrapper(*args, **kwargs): print(f"开始调用 {function.__name__}") result = function(*args, **kwargs) print(f"结束调用 {function.__name__}") return result return wrapper
手动使用:
def build_prompt(topic): return f"请解释:{topic}"build_prompt = announce(build_prompt)print(build_prompt("装饰器"))
这段代码发生了三件事:
- 1. 原来的
build_prompt 被传给 announce()。 - 2.
announce() 返回内部定义的 wrapper。 - 3. 变量
build_prompt 改为指向 wrapper,而 wrapper 仍然记得并会调用原函数。
因此再次调用 build_prompt("装饰器") 时,实际先进入 wrapper,打印开始信息,调用原函数,最后打印结束信息并返回原结果。
11. @ 装饰器语法
下面的写法:
@announcedef build_prompt(topic): return f"请解释:{topic}"
等价于:
def build_prompt(topic): return f"请解释:{topic}"build_prompt = announce(build_prompt)
@announce 写在函数定义上方,表示函数定义完成后,立即执行 build_prompt = announce(build_prompt)。它只是更简洁的语法,不是一套完全不同的运行机制。
装饰器常见用途包括:
只有当某种附加行为会稳定地用于一批函数时,装饰器才有价值。如果额外步骤只服务于一个函数,或会隐藏重要的执行顺序,直接写清楚通常更容易维护。
12. 使用 functools.wraps
前面的装饰器还有一个小问题:装饰后变量指向的是 wrapper,所以函数名、文档字符串等信息也会变成包装器的信息:
print(build_prompt.__name__) # wrapper
标准库 functools.wraps 会把原函数的重要信息复制到包装器上:
from functools import wrapsdef announce(function): @wraps(function) def wrapper(*args, **kwargs): print(f"开始调用 {function.__name__}") result = function(*args, **kwargs) print(f"结束调用 {function.__name__}") return result return wrapper
因此,编写函数装饰器时通常要在包装器上加上 @wraps(function)。
一个不改变原函数基本用法的包装器还应该:
13. with 为什么能自动清理资源
前一章使用:
with path.open("r", encoding="utf-8") as file: content = file.read()
文件使用完后必须关闭。即使 file.read() 报错,with 也会负责执行关闭操作。能够配合 with 完成这类准备与清理工作的对象,叫作上下文管理器。
上下文管理器负责两个阶段:
- 2. 离开代码块时清理资源,即使代码块中途发生异常。
一个类可以通过两个特殊方法支持 with:
- •
__enter__():进入时调用,它的返回值交给 as 后的变量。 - •
__exit__():退出时调用,接收异常信息并执行清理。
14. 类形式的上下文管理器
文件对象已经实现了上下文管理器。下面再自己写一个简单的操作记录器,观察 with 的执行过程:
class OperationTrace: def __init__(self, operation_name): self.operation_name = operation_name def __enter__(self): print(f"开始:{self.operation_name}") return self def __exit__(self, error_type, error, traceback): if error is None: print(f"完成:{self.operation_name}") else: print(f"失败:{self.operation_name},原因:{error}") return False
使用:
with OperationTrace("保存聊天历史") as trace: print(trace.operation_name) print("正在保存……")
正常执行顺序:
如果代码块发生异常,Python 会把异常类型、异常对象和追踪信息传给 __exit__(),所以它仍有机会完成清理和记录。这里返回 False,表示错误没有被解决,应该继续向外传播。只有上下文管理器确实已经处理并恢复了错误时,才应考虑返回 True。
15. 函数形式的上下文管理器
如果上下文管理器只需要简单的“准备—执行—清理”流程,可以使用标准库的 contextlib.contextmanager,不必专门定义一个类:
from contextlib import contextmanager@contextmanagerdef operation_trace(operation_name): print(f"开始:{operation_name}") try: yield except Exception as error: print(f"失败:{operation_name},原因:{error}") raise else: print(f"完成:{operation_name}")
使用:
with operation_trace("生成回答"): print("正在生成……")
装饰器会把这个生成器函数转换成可供 with 使用的上下文管理器。可以把 yield 看成代码块的分界线:
- • 执行到
yield:暂时停下,转去运行 with 代码块。 - • 代码块结束后:回到
yield 后面继续执行。
如果需要保存较多状态、返回专门对象或提供额外方法,类形式更清楚;只有少量进入和退出步骤时,函数形式通常更简洁。
16. 装饰器和上下文管理器的区别
装饰器和上下文管理器都能在业务代码周围加入通用行为,但它们包围的范围不同:
如果只想管理某几行代码,with 的范围一眼可见;如果一个函数每次被调用时都必须执行同样的附加行为,装饰器更合适。
17. 综合示例:处理命令
from contextlib import contextmanager@contextmanagerdef operation_trace(name): print(f"开始:{name}") try: yield finally: print(f"结束:{name}")def parse_command(command): parts = command.split() match parts: case ["add", role, *content_parts] if content_parts: return "add", { "role": role, "content": " ".join(content_parts), } case ["recent", amount_text]: return "recent", {"amount_text": amount_text} case ["exit"]: return "exit", {} case _: raise ValueError("命令格式无效")with operation_trace("解析命令"): action, arguments = parse_command("add user 解释上下文管理器")print(action)print(arguments)
这个例子没有为了使用新语法而改变业务含义:
所谓“高级语法”并不是越多越好。只有当一种写法能减少重复、让数据结构或执行范围更清楚时,才值得使用。
常见错误
解包数量不匹配
固定解包前先确认数据结构;长度变化时使用 *rest 或显式检查。
match 分支顺序错误
宽泛模式放在前面会让后面更具体的模式永远没有机会匹配。通配分支通常放最后。
装饰器忘记返回结果
包装器不返回原函数结果,会让调用者得到 None。
装饰器吞掉异常
记录错误后通常应使用 raise 继续传播,除非装饰器明确负责恢复。
上下文管理器隐藏异常
__exit__() 返回真值会表示异常已处理。不能恢复时返回 False。
动手练习
练习 1:命令解包与匹配
编写 parse_command(command),支持:
- •
add <role> <content...>。
返回动作名和参数字典,非法结构抛出 ValueError。
练习 2:调用记录装饰器
编写 log_call:
练习 3:操作上下文
分别使用类和 @contextmanager 实现操作追踪,测试正常结束和主动抛出异常两种情况,确认清理信息都会输出。
随堂小测
- 1.
first, *middle, last 中 middle 是什么类型? - 5.
@announce 与哪段普通赋值代码等价? - 9.
__exit__ 返回 False 表示什么? - 10. 装饰器和上下文管理器的作用范围有什么区别?
参考答案
- 3. 匹配前面分支没有处理的任何值,通常作为默认分支。
- 5.
function = announce(function)。 - 6. 否则包装后函数会丢失原结果,调用者得到
None。 - 10. 装饰器通常覆盖某个函数的每次调用;上下文管理器覆盖明确的代码块。