生成器完全指南:`yield` 与惰性求值的工程价值

文章目录

一、从一个内存溢出场景开始

有一项任务:处理日志文件,统计其中所有包含 ERROR 的行。

方案 A(直觉写法)

python 复制代码
def get_error_lines(filepath):
    with open(filepath, "r", encoding="utf-8") as f:
        lines = f.readlines()           # 把整个文件读入内存
    return [line for line in lines if "ERROR" in line]

errors = get_error_lines("system.log")
for error in errors:
    print(error.rstrip())

system.log 有 10 万行、每行 200 字节时,readlines() 会一次性占用约 20MB 内存。如果文件有 1GB,这个函数会让进程 OOM(内存耗尽)崩溃。

方案 B(生成器写法)

python 复制代码
def get_error_lines(filepath):
    with open(filepath, "r", encoding="utf-8") as f:
        for line in f:
            if "ERROR" in line:
                yield line              # 每次只产出一行

for error in get_error_lines("system.log"):
    print(error.rstrip())

两段代码的外部调用方式完全相同,但内存占用从"整个文件"变成了"当前处理的一行"。无论文件有多大,内存使用始终稳定。

这就是生成器(Generator)最直观的工程价值------以时间换空间,用惰性求值代替急切求值


二、生成器是什么:从迭代器协议出发

上一篇(#05)建立了迭代器协议的完整模型:任何实现了 __iter____next__ 的对象都是迭代器。但手动编写迭代器类需要大量样板代码:

python 复制代码
class EvenNumbers:
    """手动实现的偶数迭代器(样板代码较多)"""
    def __init__(self, limit):
        self.current = 0
        self.limit = limit

    def __iter__(self):
        return self

    def __next__(self):
        if self.current >= self.limit:
            raise StopIteration
        value = self.current
        self.current += 2
        return value

生成器函数提供了一种等价但极其简洁的写法:

python 复制代码
def even_numbers(limit):
    current = 0
    while current < limit:
        yield current
        current += 2

两者的行为完全等价,但生成器版本把状态管理(self.current)、协议实现(__iter____next__)、结束信号(StopIteration)全部隐藏到了语言机制里。

2.1 如何识别生成器函数

判断标准 :函数体内包含至少一个 yield 语句(或 yield from 语句),该函数就是生成器函数

调用生成器函数不会执行函数体 ,而是立即返回一个生成器对象

python 复制代码
def simple_gen():
    print("执行到第一个 yield 前")
    yield 1
    print("执行到第二个 yield 前")
    yield 2
    print("函数体执行完毕")

# 调用生成器函数:什么都没发生,没有任何输出
gen = simple_gen()
print(type(gen))    # <class 'generator'>
print(gen)          # <generator object simple_gen at 0x...>

# 第一次 next():执行到第一个 yield,暂停
value = next(gen)
# 打印:"执行到第一个 yield 前"
print(value)        # 1

# 第二次 next():从上次暂停的位置继续
value = next(gen)
# 打印:"执行到第二个 yield 前"
print(value)        # 2

# 第三次 next():执行剩余代码,然后抛出 StopIteration
try:
    next(gen)
    # 打印:"函数体执行完毕"
except StopIteration:
    print("生成器耗尽")

三、yield 的暂停与恢复机制

yield 最独特的地方在于:它不只是"返回一个值",而是让函数的执行状态完整冻结,等待下次被唤醒时从同一位置继续。

这与普通函数的 return 有根本区别:

特性 return yield
执行后 函数帧销毁,局部变量消失 函数帧被挂起,局部变量保留
再次调用 从头开始执行 从上次 yield 之后继续
调用次数 每次调用是独立的 多次 next() 是同一个执行流
内存模型 栈帧弹出 栈帧保持活跃(heap 中的帧对象)

帧对象 (f_locals, f_lasti) 生成器对象 调用方 (for 循环 / next()) 帧对象 (f_locals, f_lasti) 生成器对象 调用方 (for 循环 / next()) next(gen) 第1次 恢复执行(从头开始) yield 1(挂起,保存 f_lasti) 返回值 1 next(gen) 第2次 恢复执行(从 f_lasti 继续) yield 2(挂起,保存 f_lasti) 返回值 2 next(gen) 第3次 恢复执行(从 f_lasti 继续) return / 函数体结束 抛出 StopIteration

3.1 帧对象的持久化

在 CPython 中,生成器对象内部持有一个帧对象frame object),帧对象保存了函数执行的完整上下文:

python 复制代码
def stateful_gen():
    x = 10
    y = 20
    yield x + y      # 帧在这里冻结,x=10, y=20 都被保留
    z = 30
    yield x + y + z  # 恢复执行,x, y 依然可用

gen = stateful_gen()
print(gen.gi_frame)          # 帧对象
print(gen.gi_frame.f_locals) # 当前局部变量(执行前为空)

next(gen)  # 执行到第一个 yield
print(gen.gi_frame.f_locals) # {'x': 10, 'y': 20}

next(gen)  # 执行到第二个 yield
print(gen.gi_frame.f_locals) # {'x': 10, 'y': 20, 'z': 30}

四、生成器表达式:列表推导式的惰性版本

Python 提供了生成器表达式语法,形式上与列表推导式几乎相同,只是将 [] 换成 ()

python 复制代码
# 列表推导式:立即计算,全部存入内存
squares_list = [x**2 for x in range(1_000_000)]  # 约 8MB 内存

# 生成器表达式:惰性计算,按需产出
squares_gen = (x**2 for x in range(1_000_000))   # 几乎 0 内存

生成器表达式在很多场景下可以直接替代列表推导式:

python 复制代码
import sys

data = range(1, 10001)

# 内存占用对比
list_comp = [x**2 for x in data]
gen_expr  = (x**2 for x in data)

print(f"列表推导式占用:{sys.getsizeof(list_comp):,} bytes")
# 列表推导式占用:85,176 bytes
print(f"生成器表达式占用:{sys.getsizeof(gen_expr):,} bytes")
# 生成器表达式占用:112 bytes(始终固定大小)

4.1 生成器表达式作为函数参数

当函数只接受一个可迭代对象时,生成器表达式可以省略外层括号:

python 复制代码
# 标准写法(两层括号)
total = sum((x**2 for x in range(1, 101)))

# 可以省略内层括号(只在单参数调用时成立)
total = sum(x**2 for x in range(1, 101))

# 多个参数时不能省略
combined = list(zip(
    (x for x in range(5)),
    (y**2 for y in range(5))
))

4.2 嵌套生成器表达式

python 复制代码
# 展平二维列表(惰性版本)
matrix = [[1, 2, 3], [4, 5, 6], [7, 8, 9]]
flattened = (item for row in matrix for item in row)
print(list(flattened))  # [1, 2, 3, 4, 5, 6, 7, 8, 9]

五、生成器的工程价值:惰性求值的三个维度

5.1 内存效率:处理超过 RAM 容量的数据

python 复制代码
def read_large_csv(filepath: str):
    """逐行解析 CSV,不把整个文件载入内存"""
    import csv
    with open(filepath, newline="", encoding="utf-8") as f:
        reader = csv.DictReader(f)
        for row in reader:
            yield row


def filter_active_users(rows):
    """过滤活跃用户"""
    for row in rows:
        if row.get("status") == "active":
            yield row


def enrich_with_score(rows):
    """为每行添加评分字段"""
    for row in rows:
        score = int(row.get("purchase_count", 0)) * 10
        yield {**row, "score": score}


# 三个生成器串联,任何时刻内存中只有"当前行"
pipeline = enrich_with_score(
    filter_active_users(
        read_large_csv("users_10gb.csv")
    )
)

for user in pipeline:
    save_to_database(user)

这个模式称为生成器流水线(Generator Pipeline)------每个生成器是流水线上的一道工序,数据以单条记录为单位流过整个管道,与磁盘文件大小无关,内存始终稳定。

5.2 计算效率:只计算真正需要的值

python 复制代码
def find_first_prime_over(n: int) -> int:
    """找到第一个大于 n 的质数"""
    def is_prime(num):
        if num < 2:
            return False
        for i in range(2, int(num**0.5) + 1):
            if num % i == 0:
                return False
        return True

    def primes_from(start):
        """无限质数序列(惰性的)"""
        candidate = start
        while True:
            if is_prime(candidate):
                yield candidate
            candidate += 1

    # next() 只计算到找到第一个质数为止,后续的永不计算
    return next(primes_from(n + 1))

print(find_first_prime_over(100))   # 101
print(find_first_prime_over(1000))  # 1009

如果用列表来实现"无限质数序列",程序会立即死循环。生成器天然支持无限序列,因为它只在被请求时计算下一个值。

5.3 表达力:把复杂的状态机写成顺序代码

经典问题:解析括号嵌套的表达式树。用普通函数需要维护一个显式的状态变量;用生成器可以把解析过程写成线性的"遇到什么做什么":

python 复制代码
def tokenize(expr: str):
    """将表达式字符串分解为 token 序列"""
    token = []
    for char in expr:
        if char in "()":
            if token:
                yield "".join(token).strip()
                token = []
            if char.strip():
                yield char
        elif char == ",":
            if token:
                yield "".join(token).strip()
                token = []
        else:
            token.append(char)
    if token:
        yield "".join(token).strip()

tokens = list(tokenize("add(mul(2, 3), sub(10, 4))"))
print(tokens)
# ['add', '(', 'mul', '(', '2', '3', ')', 'sub', '(', '10', '4', ')', ')']

生成器让解析器状态以局部变量的形式自然存活,无需在对象属性里显式管理。


六、实战案例:ETL 数据管道

下面是一个更完整的工程场景:从多个 JSON 文件中提取、转换、加载数据。

python 复制代码
import json
import pathlib
from typing import Iterator
from datetime import datetime

# === Extract 阶段:从多个文件读取原始事件 ===
def extract_events(data_dir: str) -> Iterator[dict]:
    """
    扫描目录下所有 .json 文件,逐条产出事件记录。
    单文件可能有数百万行,不预先全部读入内存。
    """
    base = pathlib.Path(data_dir)
    for json_file in base.rglob("*.json"):
        with open(json_file, encoding="utf-8") as f:
            for line in f:
                line = line.strip()
                if line:
                    try:
                        yield json.loads(line)
                    except json.JSONDecodeError:
                        pass  # 跳过损坏的行


# === Transform 阶段:数据清洗与标准化 ===
def normalize_timestamp(events: Iterator[dict]) -> Iterator[dict]:
    """将不同格式的时间戳统一转换为 ISO 8601"""
    for event in events:
        ts = event.get("timestamp") or event.get("ts") or event.get("time")
        if ts:
            if isinstance(ts, (int, float)):
                event["timestamp"] = datetime.fromtimestamp(ts).isoformat()
            elif isinstance(ts, str):
                event["timestamp"] = ts  # 假设已是标准格式
        yield event


def filter_by_type(events: Iterator[dict], event_type: str) -> Iterator[dict]:
    """只保留指定类型的事件"""
    for event in events:
        if event.get("type") == event_type:
            yield event


def add_processing_meta(events: Iterator[dict]) -> Iterator[dict]:
    """为每条事件添加处理元数据"""
    processed_at = datetime.now().isoformat()
    for i, event in enumerate(events):
        yield {
            **event,
            "_processed_at": processed_at,
            "_sequence": i,
        }


# === Load 阶段:按批次写入目标系统 ===
def batch_load(events: Iterator[dict], batch_size: int = 500):
    """
    将事件收集成批次,批量写入(减少 I/O 次数)。
    利用生成器实现"按需收集",不预先构建所有批次。
    """
    batch = []
    for event in events:
        batch.append(event)
        if len(batch) >= batch_size:
            yield batch
            batch = []
    if batch:  # 不要忘记最后一个不完整的批次
        yield batch


# === 组装完整管道 ===
def run_pipeline(data_dir: str, target_event_type: str):
    raw       = extract_events(data_dir)
    with_ts   = normalize_timestamp(raw)
    filtered  = filter_by_type(with_ts, target_event_type)
    enriched  = add_processing_meta(filtered)
    batches   = batch_load(enriched, batch_size=500)

    total = 0
    for batch in batches:
        # write_to_warehouse(batch)  # 实际写入目标数据库
        total += len(batch)
        print(f"已写入 {total} 条记录", end="\r")

    print(f"\n管道完成,共处理 {total} 条 '{target_event_type}' 事件")

整个管道从数据目录到最终写入,中间每一步都是生成器------任何时刻内存里只存在"当前正在处理的那条记录"加上"当前积累的批次"。


七、生成器的状态与生命周期

每个生成器对象都有四个状态,可通过 inspect.getgeneratorstate() 查询:
调用生成器函数
next() 或 send()
遇到 yield
next() 或 send()
return 或函数体结束
.close() 或被垃圾回收
GEN_CREATED
GEN_RUNNING
GEN_SUSPENDED
GEN_CLOSED

python 复制代码
import inspect

def lifecycle_demo():
    yield 1
    yield 2

gen = lifecycle_demo()
print(inspect.getgeneratorstate(gen))   # GEN_CREATED

next(gen)
print(inspect.getgeneratorstate(gen))   # GEN_SUSPENDED

next(gen)
print(inspect.getgeneratorstate(gen))   # GEN_SUSPENDED

try:
    next(gen)
except StopIteration:
    pass
print(inspect.getgeneratorstate(gen))   # GEN_CLOSED

7.1 close() 与资源释放

当生成器持有文件句柄、数据库连接等资源时,需要确保生成器被正确关闭:

python 复制代码
def resource_holding_gen(filepath):
    with open(filepath, "r") as f:        # 文件在此打开
        for line in f:
            yield line.rstrip()
    # 函数体结束或 .close() 被调用时,with 块的 __exit__ 确保文件关闭

gen = resource_holding_gen("data.txt")
next(gen)
next(gen)
gen.close()  # 触发 GeneratorExit,文件正确关闭
# 即使不显式调用 close(),垃圾回收时也会自动调用

# 更好的做法:用完即销毁,依赖 with 语句

.close() 被调用时,Python 在生成器挂起的位置抛出 GeneratorExit 异常。with 语句的 __exit__ 会捕获这个异常并完成清理。这是生成器与上下文管理器协作的基础。


八、return 在生成器中的特殊含义

生成器函数里的 return 语句不返回值给调用方,而是终止生成器并将返回值附在 StopIteration 异常的 value 属性上

python 复制代码
def gen_with_return():
    yield 1
    yield 2
    return "finished"  # 这里的 "finished" 不会被 for 循环看到

gen = gen_with_return()
print(next(gen))  # 1
print(next(gen))  # 2
try:
    next(gen)
except StopIteration as e:
    print(e.value)  # finished

这个机制的用途在 yield from 中会发挥关键作用------子生成器的 return 值会成为 yield from 表达式的值,这是下一篇(#07)的重点内容。


九、性能对比:何时选择生成器,何时选择列表

生成器不总是比列表好,两者有各自的适用场景:

python 复制代码
import time
import sys

N = 1_000_000

# 场景 1:只需要遍历一次,不需要随机访问
def bench_single_pass():
    # 列表:先全部生成,再遍历
    t0 = time.perf_counter()
    total = sum([x**2 for x in range(N)])
    t1 = time.perf_counter()
    list_time = t1 - t0

    # 生成器:惰性生成,边产出边求和
    t0 = time.perf_counter()
    total = sum(x**2 for x in range(N))
    t1 = time.perf_counter()
    gen_time = t1 - t0

    print(f"列表推导式:{list_time:.3f}s,内存约 {N*8//1024//1024}MB")
    print(f"生成器表达式:{gen_time:.3f}s,内存约 112B")

bench_single_pass()
# 列表推导式:0.089s,内存约 7MB
# 生成器表达式:0.071s,内存约 112B

选择指引:

场景 推荐 原因
需要随机访问(lst[i] 列表 生成器不支持索引访问
需要多次遍历 列表 生成器一次性消耗
需要 len() 列表 生成器无法预先知道长度
数据量超过内存 生成器 核心使用场景
无限序列 生成器 列表无法表示无限数据
只遍历一次的流式数据 生成器 内存高效
流水线中间结果 生成器 避免中间数据物化

十、常见错误与陷阱

错误一:在生成器函数里混用 return 和 yield(Python 2 遗留习惯)

python 复制代码
# Python 3 中合法:return 用于提前终止生成器
def gen_until_sentinel(data, sentinel):
    for item in data:
        if item == sentinel:
            return  # 提前终止,等价于不再 yield
        yield item

list(gen_until_sentinel([1, 2, "STOP", 3, 4], "STOP"))
# [1, 2]

错误二:误以为 yield 会立即执行

python 复制代码
def deferred():
    print("这行代码在 next() 被调用前不会执行")
    yield 42

gen = deferred()  # 什么都不发生,没有打印
val = next(gen)   # 这里才打印,然后暂停在 yield

错误三:在生成器外层捕获 StopIteration(PEP 479)

python 复制代码
# Python 3.7+ 起,生成器内部逃逸的 StopIteration 会变成 RuntimeError
def buggy_gen():
    it = iter([1, 2])
    while True:
        yield next(it)   # 当 it 耗尽,StopIteration 从 next() 逃出
                         # Python 3.7+ 会将其转换为 RuntimeError!

# 正确写法
def correct_gen():
    it = iter([1, 2])
    while True:
        try:
            yield next(it)
        except StopIteration:
            return

错误四:期望生成器表达式在定义时绑定外部变量

python 复制代码
# 陷阱:生成器表达式中对外部变量的引用是惰性的
multiplier = 2
gen = (x * multiplier for x in range(5))
multiplier = 10  # 修改了外部变量

print(list(gen))  # [0, 10, 20, 30, 40] ← 使用了修改后的值!

# 如果需要在定义时捕获值,用默认参数技巧
gen = (x * m for x in range(5) for m in [multiplier])
# 或者直接用列表推导式
result = [x * 2 for x in range(5)]  # 值在定义时已固定

十一、与前序文章的联系

生成器是迭代器协议(#05)的最优雅实现,它把手动编写 __iter____next__StopIteration 的工作,隐藏到了 yield 关键字背后。

生成器对象同时也是一个闭包的特殊形式------它捕获了函数帧里的所有局部变量,就像 Python 进阶 #02 中讨论的闭包捕获自由变量。两者的区别在于:普通闭包捕获的是对外部作用域变量的引用 ,而生成器保存的是整个执行帧(包括程序计数器位置),支持从中断点继续执行。

这个"可暂停的执行帧"概念,正是 Python 异步编程(asyncio、协程)的底层基础------协程本质上就是一个可以被调度器暂停和恢复的生成器。


小结

  • 生成器函数含有 yield,调用后返回生成器对象而非立即执行
  • yield 冻结函数执行帧,next() 恢复执行,StopIteration 信号结束
  • 生成器是惰性的:不需要的值永远不会计算,支持无限序列和超大数据集处理
  • 生成器表达式是列表推导式的惰性版本,仅将 [] 替换为 ()
  • 生成器流水线将多个惰性变换串联,在不增加内存的前提下完成复杂的数据处理
  • return 在生成器中表示终止,其值附在 StopIteration.value
  • Python 3.7+ 起,生成器内部逃逸的 StopIteration 会变成 RuntimeError(PEP 479)

文章内容若有帮助,点个赞再走------每一个赞都是继续深挖 Python 底层机制的动力。收藏不点赞等于白嫖(手动狗头)。本专栏持续更新,欢迎关注,和更多 Python 开发者一起把基础打牢。

相关推荐
玛卡巴卡ldf1 小时前
【LeetCode 手撕算法】(二分查找)搜索插入位置、搜索二维矩阵、查找数组相同的所有位置、搜索旋转排序数组、旋转升序数组的最小值
数据结构·算法·leetcode
谷雨不太卷8 小时前
进程的状态码
java·前端·算法
jieyucx9 小时前
Go语言深度解剖:Map扩容机制全解析(增量扩容+等量扩容+渐进式迁移)
开发语言·后端·golang·map·扩容策略
YJlio9 小时前
7.4.5 Windows 11 企业网络连接与网络重置实战:远程访问、本地策略与故障恢复
前端·chrome·windows·python·edge·机器人·django
脏脏a9 小时前
【C++模版】泛型编程:代码复用的终极利器
开发语言·c++·c++模版
island13149 小时前
【C++仿Muduo库#3】Server 服务器模块实现上
服务器·开发语言·c++
散峰而望9 小时前
【算法竞赛】C/C++ 的输入输出你真的玩会了吗?
c语言·开发语言·数据结构·c++·算法·github
小龙报9 小时前
【C语言】内存里的 “数字变形记”:整数三码、大小端与浮点数存储真相
c语言·开发语言·c++·创业创新·学习方法·visual studio
躺不平的理查德9 小时前
时间复杂度与空间复杂度备忘录
数据结构·算法