Python 函数、模块与异常
# Python 函数、模块与异常
理解函数参数、作用域、模块导入和异常传播,学会把一个脚本拆成边界清楚的小项目。
# 先记住一句话
函数负责建立输入与输出契约,模块负责组织职责,异常负责把当前层无法处理的失败交给合适的上层。
# 参数与返回值
def search_notes(
# query 位于星号前,可以按位置传入,例如 search_notes("python")。
query: str,
# 普通函数不一定需要 *;当布尔开关、数量限制等可选参数容易混淆时使用它。
# 单独的 * 是分隔符,它会强制调用方为后面的参数写出名称。
*,
# 最多返回 5 条;调用方可以使用 limit=10 覆盖默认值。
limit: int = 5,
# 默认排除草稿;真实项目可用它控制查询条件。
include_drafts: bool = False,
) -> list[str]:
"""按关键词返回笔记标题。
此处使用内存数据演示,真实项目可替换为数据库或搜索服务。
"""
# 用内存列表模拟数据库记录;title 是标题,is_draft 表示是否为草稿。
notes = [
{"title": "Python 基础", "is_draft": False},
{"title": "Python 草稿", "is_draft": True},
{"title": "Go 并发", "is_draft": False},
]
matched: list[str] = []
for note in notes:
# lower() 统一大小写,使 Python 和 python 能够匹配。
title_matches = query.lower() in str(note["title"]).lower()
# include_drafts 为 True 时允许草稿,否则只保留正式笔记。
draft_allowed = include_drafts or not note["is_draft"]
# 同时满足关键词和草稿条件时,才将标题加入结果。
if title_matches and draft_allowed:
matched.append(str(note["title"]))
# Python 切片的完整格式是 序列[开始位置:结束位置:步长]。
# [:limit] 省略开始位置和步长,表示从开头取到下标 limit 之前,不包含 limit。
return matched[:limit]
# limit 位于 * 后面,因此必须使用 limit=10,不能只写第二个位置参数 10。
result = search_notes("python", limit=10)
# result 为 ["Python 基础"];默认的 include_drafts=False 会排除 “Python 草稿” 。
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
类型标注不会自动阻止错误参数进入运行时。它主要服务于 IDE、静态检查工具和读代码的人;外部 JSON、表单或模型输出仍需要运行时校验。
# 不要使用可变默认参数
# 错误示例:默认列表只在函数定义时创建一次,之后的调用会共享它。
def append_bad(item: str, items: list[str] = []) -> list[str]:
items.append(item)
return items
def append_safe(item: str, items: list[str] | None = None) -> list[str]:
# 每次未传 items 时创建新列表,避免跨调用共享状态。
target = [] if items is None else items
target.append(item)
return target
2
3
4
5
6
7
8
9
10
# 名称查找与闭包
Python 通常按照 Local、Enclosing、Global、Built-in,也就是 LEGB 顺序查找名称。
from collections.abc import Callable
# Callable[[], int] 表示 make_counter 返回一个 “无参数、返回 int” 的函数。
# 它只是类型标注,不添加也不影响代码运行。
def make_counter() -> Callable[[], int]:
count = 0
# 与 JavaScript 相同:内部函数会记住定义时的外层环境,形成闭包。
def increment() -> int:
# 与 JavaScript 不同:Python 修改外层变量前必须声明 nonlocal。
# 否则赋值语句会把 count 当成本函数中新建的局部变量。
nonlocal count
count += 1
return count
# 返回函数本身而不是 increment() 的结果,让调用方以后再执行它。
return increment
# make_counter 已执行结束,但 counter 仍通过闭包记住同一个 count。
counter = make_counter()
print(counter()) # 1
print(counter()) # 2
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
内部函数记住其定义环境,这种结构叫闭包。装饰器、回调和工厂函数经常使用闭包。
# 模块与包怎样组织
note_service/
├─ pyproject.toml # 项目元数据与依赖配置
├─ src/
│ └─ note_service/
│ ├─ __init__.py # 标识普通包,也可暴露稳定公共接口
│ ├─ models.py # 数据结构
│ └─ search.py # 搜索逻辑
└─ tests/
└─ test_search.py # 对应功能的测试
2
3
4
5
6
7
8
9
# 文件位置:src/note_service/search.py
def normalize_query(query: str) -> str:
"""去掉首尾空白并统一大小写,供搜索入口复用。"""
return query.strip().casefold()
2
3
4
5
# 导入模块时究竟发生什么
第一次执行 import note_service.search 时,可以先把过程理解为:
导入模块
├─ 检查 sys.modules 中是否已有缓存
├─ 查找并创建模块对象
├─ 把模块对象放入 sys.modules
└─ 从上到下执行模块顶层代码
2
3
4
5
模块顶层代码是直接写在文件最外层的语句。import、变量赋值、函数定义和类定义都属于顶层代码;其中函数体只会被定义,不会在导入时自动调用。同一进程再次导入该模块时,Python 通常直接复用 sys.modules 中的模块对象,不会重新执行一遍顶层代码。
这也意味着顶层代码可能在调用者没有察觉时产生副作用。下面的模块只要被导入,就会立即创建数据库文件和连接:
# 文件位置:src/note_service/bad_database.py
import sqlite3
# 错误示例:导入该模块时立即创建 notes.db 并打开连接。
connection = sqlite3.connect("notes.db")
2
3
4
5
更稳妥的做法是只在模块中定义创建方法,再由应用入口决定资源何时创建和释放:
# 文件位置:src/note_service/database.py
import sqlite3
def create_connection() -> sqlite3.Connection:
"""创建数据库连接,调用方负责在使用后关闭。"""
return sqlite3.connect("notes.db")
2
3
4
5
6
# 文件位置:src/note_service/main.py
from .database import create_connection
def main() -> None:
# 资源在程序入口显式创建,不会因为其他模块执行 import 而启动。
connection = create_connection()
try:
connection.execute("SELECT 1")
finally:
connection.close() # 入口持有资源,也负责在任务结束时释放。
if __name__ == "__main__":
main()
2
3
4
5
6
7
8
9
10
11
12
13
数据库连接、HTTP 客户端、后台线程和服务器启动等操作,都不应随意放在模块顶层。命令行程序可以使用 main() 作为显式入口;FastAPI 等长期运行的服务,则更适合通过应用生命周期函数统一创建和释放资源。
# 异常传播与处理边界
class NoteNotFoundError(Exception):
"""表示业务上找不到指定笔记,便于入口层映射为 404。"""
# -> dict[str, str] 是返回类型标注,表示返回键和值都是字符串的字典。
# 类型标注主要供阅读、IDE 和静态检查使用,不会自动校验运行时数据。
def get_note(note_id: str) -> dict[str, str]:
# 内存字典仅用于演示;真实项目通常在这里调用 Repository(仓储层)。
# Repository 统一封装数据库或外部 API 的读写,让业务逻辑不依赖具体存储方式。
notes = {"py-01": {"title": "Python 基础"}}
# try 中出现异常后,会停止执行剩余语句并寻找匹配的 except。
try:
return notes[note_id]
# 字典中没有 note_id 时会抛出 KeyError;as exc 把异常对象保存到 exc。
except KeyError as exc:
# 使用 from 保留底层异常链,排障时仍能看到原始原因。
raise NoteNotFoundError(f"笔记不存在:{note_id}") from exc
def main() -> None:
try:
print(get_note("missing"))
# 这里只捕获 NoteNotFoundError;其他异常仍会继续向上层传播。
except NoteNotFoundError as exc:
# 应用入口知道怎样向用户展示错误,因此在这一层处理。
print(exc)
finally:
# finally 无论成功失败都会执行,适合释放本层持有的资源。
print("请求结束")
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
不要写没有边界的 except Exception: pass。它会吞掉程序错误和排障信息。只有在任务入口、请求边界等确实需要统一兜底的地方捕获广义异常,同时记录堆栈并返回明确失败状态。
EAFP 和 LBYL
EAFP 是 Easier to Ask for Forgiveness than Permission 的缩写,字面意思是 “获得原谅比事先请求许可更容易” 。在代码中表示先执行操作,失败后再捕获预期异常。
LBYL 是 Look Before You Leap 的缩写,字面意思是 “跳之前先看” 。在代码中表示执行操作前,先用条件判断是否满足要求。
字典读取等原子操作通常适合 EAFP;涉及转账、文件覆盖等副作用时,不能把异常处理误当成业务校验、幂等控制和并发保护。
# 高频面试题与回答
1. Python 参数传递是值传递还是引用传递?参考答案
更准确的说法是对象共享传递:形参会绑定到实参指向的同一个对象。函数可以修改传入的可变对象,但在函数内把形参重新赋值,不会改变调用方变量原来的绑定。
2. 异常应该在哪一层处理?参考答案
在真正知道如何恢复、转换或向调用方表达失败的层处理。底层可以补充上下文后继续抛出,HTTP 入口再把业务异常映射成状态码。过早吞掉异常会让上层误以为操作成功。
3. 为什么函数不应该使用可变对象作为默认参数?参考答案
默认参数在函数定义时只求值一次,之后每次调用会复用同一个对象,修改列表或字典就会把状态带到下一次调用。通常使用 None 作为默认值,再在函数内部创建新对象;如果确实要共享缓存,应明确命名和封装。
4. *args 和 **kwargs 分别是什么?参考答案
*args 收集额外位置参数为 tuple,**kwargs 收集额外关键字参数为 dict;调用时相同语法也可以展开可迭代对象和映射。它们适合包装器和转发调用,但业务 API 过度使用会隐藏参数契约,应优先保留明确签名和类型标注。
5. Python 闭包为什么容易出现延迟绑定问题?参考答案
闭包通常保存的是外部变量的引用,而不是定义时的值;循环结束后多个闭包可能都读到变量的最终值。需要固定当次值时,可以通过默认参数、额外工厂函数或 functools.partial 显式绑定。
# 接下来学什么
下一篇学习 类、数据模型与类型标注,理解 Python 如何表达业务对象与接口边界。