Python 编程基础与工程实践(四):函数、装饰器与迭代协议

Python 的函数不只是“把几行代码包起来”。函数本身也是对象,可以被传递、返回和装饰;for、生成器和 with 看似不同,背后也都建立在明确的协议之上。

本文以 CPython 3.14 为基线,讲清参数模型、作用域、闭包、装饰器、迭代器、生成器和上下文管理器。理解这些机制后,很多“Python 魔法”会变成普通的方法调用。

1. 函数是对象

1
2
3
4
5
6
7
def add(left: int, right: int) -> int:
    """返回两个整数之和。"""
    return left + right


operation = add
print(operation(2, 3))  # 5

名称 add 绑定到一个函数对象,因此它可以像其他对象一样保存到容器、传给另一个函数或作为返回值。

类型标注不会自动阻止错误调用:

1
print(add("a", "b"))  # 运行时得到 'ab'

标注主要服务于静态分析、编辑器和读者。真正的运行时约束仍来自函数体执行的操作;类型系统会在第七篇详细讨论。

2. Python 的参数模型

函数定义可以明确哪些参数只能按位置传入,哪些只能按关键字传入:

1
2
3
4
5
6
7
8
9
def connect(
    host: str,
    port: int = 5432,
    /,
    *,
    timeout: float = 5.0,
    use_tls: bool = True,
) -> str:
    return f"{host}:{port}, timeout={timeout}, tls={use_tls}"
  • / 之前是仅限位置参数;
  • * 之后是仅限关键字参数;
  • 中间区域可按位置或关键字传入。

调用方式:

1
connect("db.example.com", 5432, timeout=2.0)

仅限关键字参数能让布尔值和多个同类型参数更易读,也为以后添加可选参数留下空间。

可变参数收集额外实参:

1
2
3
4
5
6
7
def summarize(*values: float, precision: int = 2) -> str:
    average = sum(values) / len(values)
    return f"{average:.{precision}f}"


options = {"precision": 3}
print(summarize(1.0, 2.0, 3.0, **options))

*values 得到元组,**kwargs 得到字典。它们适合转发参数和开放扩展点,但不应代替清晰的接口;业务函数如果所有参数都藏在 **kwargs 里,类型检查和调用文档都会变差。

3. 传参是对象共享,不是按引用传变量

调用函数时,形参名称绑定到实参对象。函数可以修改传入的可变对象,但在函数内重新绑定形参不会改变调用者的名称:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
def append_item(items: list[int]) -> None:
    items.append(3)


def replace_list(items: list[int]) -> None:
    items = [99]


values = [1, 2]
append_item(values)
assert values == [1, 2, 3]

replace_list(values)
assert values == [1, 2, 3]

与其争论“值传递还是引用传递”,更准确的说法是:实参对象被绑定到新的局部名称。接口设计时还应明确函数是否会修改传入对象。

4. 作用域与闭包

名称查找常用 LEGB 概括:Local、Enclosing、Global、Builtins,即局部、外层函数、模块全局和内置作用域。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
from collections.abc import Callable


def make_counter() -> Callable[[], int]:
    count = 0

    def increment() -> int:
        nonlocal count
        count += 1
        return count

    return increment

这里 increment() 闭包保留了外层的 countnonlocal 表示重新绑定最近的外层函数作用域名称;global 则绑定模块全局名称。大量依赖可变全局状态会让测试和并发控制困难,通常应把状态封装到对象或显式依赖中。

闭包捕获的是名称,不是定义时的值。循环中创建函数要特别留意晚绑定:

1
2
3
4
5
6
7
# 错误示例:三个函数最终都读取同一个 i
bad = [lambda: i for i in range(3)]
assert [func() for func in bad] == [2, 2, 2]

# 默认参数在定义时求值,可用于冻结当前值
good = [lambda i=i: i for i in range(3)]
assert [func() for func in good] == [0, 1, 2]

这里利用默认参数是有意保存快照,与无意共享可变默认值是两种不同场景。

5. 高阶函数与装饰器

接收或返回函数的函数称为高阶函数:

1
2
3
4
5
6
7
8
from collections.abc import Callable


def apply_twice(operation: Callable[[int], int], value: int) -> int:
    return operation(operation(value))


assert apply_twice(lambda x: x + 1, 10) == 12

装饰器是在函数定义后替换绑定对象的语法糖:

1
2
3
@trace
def calculate() -> int:
    return 42

大致等价于:

1
2
3
4
5
def calculate() -> int:
    return 42


calculate = trace(calculate)

一个保留元数据的计时装饰器:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
from collections.abc import Callable
from functools import wraps
from time import perf_counter
from typing import Any


def timed(func: Callable[..., Any]) -> Callable[..., Any]:
    @wraps(func)
    def wrapper(*args: Any, **kwargs: Any) -> Any:
        started = perf_counter()
        try:
            return func(*args, **kwargs)
        finally:
            elapsed = perf_counter() - started
            print(f"{func.__name__}: {elapsed:.6f}s")

    return wrapper

functools.wraps 会复制名称、文档等元数据,并通过 __wrapped__ 保留原函数链。更精确地保留参数类型需要 ParamSpec,将在类型系统一篇介绍。

装饰器在函数定义执行时应用,不是每次调用时重新创建。带参数的装饰器则是“返回装饰器的函数”,不要把它和被装饰函数的调用参数混在一起。

6. 可迭代对象与迭代器

for 的核心不是下标,而是迭代协议:

  1. 对目标调用 iter(obj) 获得迭代器;
  2. 反复调用 next(iterator)
  3. 捕获 StopIteration 后正常结束。
1
2
3
4
5
6
values = [10, 20, 30]
iterator = iter(values)

print(next(iterator))  # 10
print(next(iterator))  # 20
print(next(iterator))  # 30

列表是可重复迭代的容器,每次 iter(values) 可产生新迭代器;迭代器通常是一次性的,并保存当前进度。

自定义倒计时迭代器:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
class Countdown:
    def __init__(self, start: int) -> None:
        self.current = start

    def __iter__(self) -> "Countdown":
        return self

    def __next__(self) -> int:
        if self.current <= 0:
            raise StopIteration
        value = self.current
        self.current -= 1
        return value

不过大多数顺序生成任务使用生成器更简单。

7. 生成器:把状态机交给语言

函数体中出现 yield 时,调用它会返回生成器对象,而不是立即执行到结束:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
from collections.abc import Iterator


def countdown(start: int) -> Iterator[int]:
    current = start
    while current > 0:
        yield current
        current -= 1


for value in countdown(3):
    print(value)

每次 next() 会从上次 yield 后继续,局部状态自动保留。生成器适合流式读取、管道处理和大数据集,但也有边界:

  • 通常只能消费一次;
  • 错误可能推迟到迭代时才发生;
  • 打开的外部资源必须明确关闭;
  • 惰性管道若从不消费,就不会执行其中逻辑。

yield from 可以委托另一个可迭代对象:

1
2
3
def flatten(groups: list[list[int]]) -> Iterator[int]:
    for group in groups:
        yield from group

它不等于任意层级递归展开,只是把当前子迭代器的值依次转发。

8. 上下文管理协议

with 用于表达“进入—执行—退出”的资源生命周期:

1
2
with open("data.txt", encoding="utf-8") as file:
    content = file.read()

对象通过 __enter__()__exit__() 实现同步上下文管理协议。即使代码块抛出异常,__exit__() 仍会执行;它是否抑制异常取决于返回值。

简单场景可使用 contextlib.contextmanager

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
from collections.abc import Iterator
from contextlib import contextmanager
from time import perf_counter


@contextmanager
def measure(name: str) -> Iterator[None]:
    started = perf_counter()
    try:
        yield
    finally:
        elapsed = perf_counter() - started
        print(f"{name}: {elapsed:.6f}s")


with measure("load"):
    sum(range(100_000))

yield 前是进入逻辑,yield 后是退出逻辑。清理必须放在 finally 中,否则代码块抛异常时可能跳过清理。

9. 组合一个惰性处理管道

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
from collections.abc import Iterable, Iterator


def parse_numbers(lines: Iterable[str]) -> Iterator[float]:
    for line_number, line in enumerate(lines, start=1):
        text = line.strip()
        if not text or text.startswith("#"):
            continue
        try:
            yield float(text)
        except ValueError as error:
            raise ValueError(f"第 {line_number} 行不是数字:{text!r}") from error


with open("measurements.txt", encoding="utf-8") as file:
    values = parse_numbers(file)
    total = sum(values)  # 实际项目计算均值还需同时记录数量

这段管道不会把整个文件读入内存,错误也保留了原始异常链。注意生成器消费发生在 with 内部;如果把 values 带出代码块后再迭代,文件已经关闭。

10. 小结

  • 参数是新的局部名称绑定;修改共享可变对象与重新绑定形参是两回事。
  • /* 能把位置参数、关键字参数的接口边界写清楚。
  • 闭包捕获名称,循环创建闭包时要理解晚绑定。
  • 装饰器是重新绑定函数对象,编写包装器时使用 functools.wraps
  • for 建立在迭代协议上,生成器用 yield 保存迭代状态。
  • with 建立在上下文管理协议上,清理逻辑必须覆盖异常路径。

下一篇将进入类、继承、组合、特殊方法和 dataclass,把对象模型用于领域建模。

参考资料

Licensed under CC BY-NC-SA 4.0