Python 语法篇 · 第12章

高级特性:迭代器、生成器、闭包与装饰器

掌握这些"Pythonic"特性,你就能读懂高手的代码,并写出优雅、省内存的程序。装饰器更是框架学习(Flask/FastAPI)的前置知识。

🎯 学习目标

  • 理解可迭代对象、迭代器协议(__iter__/__next__)
  • 掌握生成器函数 yield、yield from 与生成器表达式
  • 理解迭代器惰性求值、节省内存的意义
  • 理解闭包及其典型用途
  • 从零推导并掌握装饰器(带参数、堆叠、保留元信息)
  • 熟练运用列表/字典/集合三大推导式

1可迭代对象与迭代器

可迭代对象(Iterable):能被 for 循环遍历的东西——str、list、tuple、dict、set、range、文件对象等。判定标准:实现了 __iter__()。

迭代器(Iterator):一个"带记忆的游标",实现了 __iter__() 和 __next__(),每次 next() 吐出下一个值,取完后抛 StopIteration。

nums = [1, 2, 3]          # list 是可迭代对象,但不是迭代器
it = iter(nums)           # iter() 取出它的迭代器
print(next(it))           # 1   游标前进
print(next(it))           # 2
print(next(it))           # 3
# next(it)                 # StopIteration:没有下一个了

# for 循环的本质就是这一套:
# 先 iter(x) 拿迭代器,再不断 next(),遇到 StopIteration 结束
it2 = iter(nums)
while True:
    try:
        print(next(it2))
    except StopIteration:
        break
from collections.abc import Iterable, Iterator
print(isinstance([1, 2], Iterable))    # True
print(isinstance([1, 2], Iterator))    # False 列表本身不是迭代器
print(isinstance(iter([1, 2]), Iterator))  # True iter 之后才是

# 文件对象本身就是迭代器(所以能逐行读且不回头)
# 迭代器只能往前走,不能重置、不能 len()、不能索引

自定义迭代器

class Countdown:
    def __init__(self, start):
        self.current = start

    def __iter__(self):
        return self                     # 自己就是迭代器

    def __next__(self):
        if self.current <= 0:
            raise StopIteration         # 到头
        self.current -= 1
        return self.current + 1

for n in Countdown(3):
    print(n)                            # 3 2 1

2生成器:写迭代器的偷懒神器

手写迭代器要实现两个魔法方法,很繁琐。只要函数体里出现 yield,它就变成生成器函数,调用后返回一个自动实现迭代器协议的生成器对象。

def countdown(start):
    print("开始")
    while start > 0:
        yield start        # 每次 next 执行到这里"暂停",把值吐出去
        start -= 1
    print("结束")

g = countdown(3)            # 函数体【并没有执行】,只是创建生成器
print(next(g))              # 打印"开始",返回 3,函数暂停
print(next(g))              # 从暂停处继续,返回 2
print(next(g))              # 1
# print(next(g))            # 打印"结束",抛 StopIteration

for n in countdown(3):      # 实际使用都是 for 驱动
    print(n)

理解 yield 的关键词是"暂停并返回":普通函数 return 后栈就销毁了;生成器 yield 时会冻结现场(局部变量全部保留),下次 next 从冻结点继续。

为什么生成器重要:惰性求值,内存恒定

# 普通做法:100 万条数据全部进内存
# data = [x ** 2 for x in range(10**6)]   # 占用几十 MB

# 生成器做法:用一个算一个,内存几乎为 0
def gen_squares(n):
    for x in range(n):
        yield x ** 2

g = gen_squares(10 ** 6)
print(sum(g))               # 遍历时才逐个计算
# 处理 100G 语料文件也不会爆内存(NLP 大数据必备思想)

yield from:委托给另一个生成器

def chain_all(*iterables):
    for it in iterables:
        yield from it       # 等价于 for x in it: yield x

print(list(chain_all([1, 2], (3, 4), "ab")))   # [1,2,3,4,'a','b']

# 实战:逐行读取多个大文件
def lines_of(paths):
    for path in paths:
        with open(path, encoding="utf-8") as f:
            yield from f    # 把该文件的每一行依次产出

生成器表达式

# 列表推导式用 [],生成器表达式用 ()
sum(x ** 2 for x in range(1, 101))        # 338350
any(x > 5 for x in [1, 2, 6])            # True
all(x > 0 for x in [1, 2, 3])            # True

# 大数据场景用生成器表达式,避免中间列表
with open("out.txt", "w", encoding="utf-8") as f:
    f.writelines(f"{x}\n" for x in range(1_000_000))

3常用迭代工具

# zip:并行打包多个序列
names = ["张三", "李四"]
scores = [90, 85]
print(list(zip(names, scores)))          # [('张三',90),('李四',85)]
for name, score in zip(names, scores):
    print(name, score)
dict(zip(["a", "b"], [1, 2]))            # {'a':1,'b':2} 两列表变字典
list(zip("abc", "123", "XY"))            # 按最短的来:3组

# enumerate:编号(第4章);reversed:反向迭代
for i, ch in enumerate("abc", 1):
    print(i, ch)
for x in reversed([1, 2, 3]):
    print(x)                              # 3 2 1

# itertools 是迭代器工具宝库(第14章细讲)
from itertools import chain, groupby, islice
list(chain([1, 2], [3, 4]))              # [1,2,3,4] 展平一层
list(islice(range(100), 0, 5))           # 迭代器版切片

4闭包:会"记住环境"的函数

闭包 = 内层函数 + 它记住的外层变量。外层函数已经执行完,内层函数仍能访问其局部变量:

def make_counter():
    count = 0                    # 外层局部变量
    def counter():
        nonlocal count           # 修改外层变量需 nonlocal
        count += 1
        return count
    return counter               # 返回内层函数(注意不带括号)

c1 = make_counter()
print(c1(), c1(), c1())          # 1 2 3 —— count 被"记住"了
c2 = make_counter()
print(c2())                      # 1 —— 各自独立的一份环境
# 实用:工厂函数批量生成相似函数
def make_power(n):
    def power(x):
        return x ** n
    return power

square = make_power(2)
cube = make_power(3)
print(square(5), cube(5))        # 25 125

闭包常见的"晚绑定坑":在循环中创建引用循环变量的 lambda/闭包,所有函数会共享循环结束后的最终值。需要立即捕获当前值时,用默认参数 lambda x, i=i: ... 或用工厂函数包一层。

5装饰器(本章高潮)

装饰器是一个"接收函数、返回增强后新函数"的函数,在不修改原函数代码的前提下给它增加功能(日志、计时、权限、缓存、重试……),是 AOP(面向切面编程)思想的体现,也是 Flask/FastAPI/pytest 等框架的核心语法。

第一步:理解"函数也是对象,能被传递替换"

def say_hi():
    print("hi")

def log(func):
    def wrapper():
        print(f"调用 {func.__name__} 之前")
        func()
        print("调用之后")
    return wrapper

say_hi = log(say_hi)        # 手动装饰:用增强版替换原函数
say_hi()
# 调用 say_hi 之前
# hi
# 调用之后

第二步:@ 语法糖

def log(func):
    def wrapper(*args, **kwargs):          # 接收任意参数
        print(f"[日志] 开始执行 {func.__name__}")
        result = func(*args, **kwargs)      # 把参数原样转给原函数
        print(f"[日志] 结束执行 {func.__name__}")
        return result                       # 原样返回结果
    return wrapper

@log                      # 等价于 add = log(add)
def add(a, b):
    return a + b

print(add(2, 3))
# [日志] 开始执行 add
# [日志] 结束执行 add
# 5

第三步:保留原函数信息 functools.wraps

# 不处理的话,add.__name__ 会变成 'wrapper',docstring 也丢失
# 标准做法:
import functools

def log(func):
    @functools.wraps(func)          # 把 func 的名字/文档复制给 wrapper
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs)
    return wrapper

第四步:带参数的装饰器(三层嵌套)

import functools, time

def repeat(times):                  # 多一层:接收装饰器自己的参数
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            result = None
            for i in range(times):
                result = func(*args, **kwargs)
            return result
        return wrapper
    return decorator

@repeat(3)                          # 打招呼重复 3 次
def greet(name):
    print(f"hi {name}")

greet("小明")

第五步:实用装饰器合集

# 1) 计时器
def timer(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        start = time.perf_counter()
        result = func(*args, **kwargs)
        print(f"{func.__name__} 耗时 {time.perf_counter()-start:.4f}s")
        return result
    return wrapper

# 2) 缓存(标准库自带 functools.lru_cache,递归/重复计算神器)
from functools import lru_cache

@lru_cache(maxsize=None)
def fib(n):
    return n if n < 2 else fib(n-1) + fib(n-2)
print(fib(100))            # 瞬间出结果(朴素递归到 40 就要等很久)

# 3) 多个装饰器可以堆叠(从下往上依次包装)
# @timer
# @lru_cache
# def fib(n): ...

类装饰器与 functools 全家桶(了解)

# 用类实现装饰器(靠 __call__)
class CountCalls:
    def __init__(self, func):
        functools.update_wrapper(self, func)
        self.func = func
        self.count = 0
    def __call__(self, *args, **kwargs):
        self.count += 1
        print(f"第 {self.count} 次调用")
        return self.func(*args, **kwargs)

@CountCalls
def hello():
    print("hello")

hello(); hello()        # 第1次… 第2次…

6推导式大全

# 列表推导式:[表达式 for 变量 in 可迭代 if 条件]
[x * x for x in range(1, 8)]                      # 平方
[x for x in range(20) if x % 2 == 0]              # 筛选偶数
[x * y for x in [1, 2] for y in [10, 20]]         # 双重循环:4个结果
[(x, y) for x in range(3) for y in range(3) if x != y]

# 嵌套推导式:矩阵转置
matrix = [[1, 2, 3], [4, 5, 6]]
[[row[i] for row in matrix] for i in range(3)]    # [[1,4],[2,5],[3,6]]

# 字典推导式
{w: len(w) for w in ["a", "bb", "ccc"]}           # {'a':1,'bb':2,'ccc':3}
{v: k for k, v in {"a": 1}.items()}               # 键值反转

# 集合推导式(自动去重)
{w[0] for w in ["apple", "ant", "banana"]}        # {'a','b'}

# 生成器表达式(圆括号,惰性)
total = sum(x for x in range(101))

推导式适合一句话能表达清楚的变换+筛选。嵌套超过两层、逻辑复杂时请改写成普通循环——可读性永远比炫技重要。

7NLP 案例:流式处理大语料

import json, re

def iter_jsonl(path):
    """生成器:逐行读取 JSONL,一行一个 dict,内存恒定。"""
    with open(path, encoding="utf-8") as f:
        for line in f:
            if line.strip():
                yield json.loads(line)

def build_pipeline(paths, min_len=5):
    """多文件 → 清洗 → 过滤 → 产出 (id, 文本) 流水线。"""
    for path in paths:
        for rec in iter_jsonl(path):
            text = re.sub(r"\s+", "", rec["text"])
            if len(text) >= min_len:
                yield rec["id"], text

# 使用:即使语料有几十 GB,也是读一条处理一条
# for doc_id, text in build_pipeline(["news1.jsonl", "news2.jsonl"]):
#     tokens = jieba.lcut(text)
#     ... 后续分词 / 统计 / 入库

8本章小结与练习

📌 关键结论

  • 迭代器 = __iter__ + __next__,单向、惰性、省内存。
  • 带 yield 的函数是生成器函数;yield 暂停保留现场,yield from 委托产出。
  • 闭包让内层函数记住外层变量;修改外层变量用 nonlocal。
  • 装饰器 = 入参为函数、返回增强函数的函数;标配 *args, **kwargs + @functools.wraps。
  • 大文件/大数据用生成器流水线,不要一次性读入。
练习1:写一个生成器 fib_gen(),不断产出斐波那契数 1,1,2,3,5…,并用它打印前 10 项。
def fib_gen(): a, b = 1, 1 while True: yield a a, b = b, a + b g = fib_gen() print([next(g) for _ in range(10)]) 无限生成器靠调用方控制取多少,内存恒定。
练习2:写一个 @timer 装饰器,打印被装饰函数的执行耗时。
用 time.perf_counter() 在 wrapper 内记录前后时间差,结构:def timer(func): @functools.wraps(func) def wrapper(*a,**kw): start=...; r=func(*a,**kw); print(耗时); return r; return wrapper。本章 5.5 有完整代码。
练习3:列表推导式和生成器表达式在内存使用上有何区别?处理百万行文件应选哪个?
列表推导式立即生成包含全部结果的列表,数据量大时占用大量内存;生成器表达式只保存算法状态,每次迭代才计算一个值,内存恒定。处理百万行文件应选生成器(或 yield 生成器函数)逐条处理,避免内存溢出。