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 草稿” 。
1
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
1
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
1
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      # 对应功能的测试
1
2
3
4
5
6
7
8
9
# 文件位置:src/note_service/search.py

def normalize_query(query: str) -> str:
    """去掉首尾空白并统一大小写,供搜索入口复用。"""
    return query.strip().casefold()
1
2
3
4
5

# 导入模块时究竟发生什么

第一次执行 import note_service.search 时,可以先把过程理解为:

导入模块
├─ 检查 sys.modules 中是否已有缓存
├─ 查找并创建模块对象
├─ 把模块对象放入 sys.modules
└─ 从上到下执行模块顶层代码
1
2
3
4
5

模块顶层代码是直接写在文件最外层的语句。import、变量赋值、函数定义和类定义都属于顶层代码;其中函数体只会被定义,不会在导入时自动调用。同一进程再次导入该模块时,Python 通常直接复用 sys.modules 中的模块对象,不会重新执行一遍顶层代码。

这也意味着顶层代码可能在调用者没有察觉时产生副作用。下面的模块只要被导入,就会立即创建数据库文件和连接:

# 文件位置:src/note_service/bad_database.py
import sqlite3

# 错误示例:导入该模块时立即创建 notes.db 并打开连接。
connection = sqlite3.connect("notes.db")
1
2
3
4
5

更稳妥的做法是只在模块中定义创建方法,再由应用入口决定资源何时创建和释放:

# 文件位置:src/note_service/database.py
import sqlite3

def create_connection() -> sqlite3.Connection:
    """创建数据库连接,调用方负责在使用后关闭。"""
    return sqlite3.connect("notes.db")
1
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()
1
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("请求结束")
1
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 如何表达业务对象与接口边界。

# 参考资料

上次更新时间: 2026年09月10日 00:03:52