ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Python进阶:生成器、装饰器、上下文管理器与类型注解深度解析

Python进阶:生成器、装饰器、上下文管理器与类型注解深度解析

1. 先把这几个语法点的“为什么”讲透

如果你已经能把函数、类、异常三板斧用得挺顺,再去读开源项目或者接手老代码时,大概率还是会在某些地方卡住:一个函数里冒出个yield,函数上面叠了一排@,一个with open(...) as f不知道背后到底发生了什么,满屏的类型注解也不知道究竟是装饰性的还是真有约束力。这些不是零散的“新语法”,而是同一层进阶逻辑在不同场景下的表现。

这篇笔记是 Python 语法进阶系列的第六篇,我挑了我自己在实际项目里高频使用、且早期反复踩坑的四块内容来讲:生成器与迭代器、装饰器、上下文管理器、类型注解。适合已经把基础语法过完、开始读别人源码、或者想把自己写的小工具做得更干净的开发者。下面每一节都会从一个“为什么这么设计”的角度切入,再给可直接复制的代码和踩坑记录。

1.1 语法背后的“协议”思维

Python 里有很多看着很普通的关键字,本质都是在调用对象身上的特殊方法。for x in obj不是简单地从下标 0 遍历到 n,而是先调用iter(obj)拿到迭代器,然后不断调用next(),直到抛出StopIteration为止。with obj:也不是语法糖那么简单,它要求对象实现__enter__和__exit__,然后按固定顺序调用。装饰器同样不神秘,它本质上就是“接收一个函数、返回一个新函数”的普通高阶函数。

把这些东西串起来看,进阶语法的核心不是记 API,而是理解协议。比如你自己写一个类,只要实现__iter__和__next__,它就能被放进for循环;只要实现__enter__和__exit__,它就能配合with使用。这比继承某个基类灵活得多,也是一种“面向协议编程”的感觉。我在项目中经常用这个思路做插件化设计:不要求调用方必须继承某个抽象类,只要实现约定的方法和协议,就能被框架识别并接入。

理解协议之后,最直接的好处是排错方向变了。遇到“为什么 for 循环没有数据”“为什么 with 块里的资源没释放”这类问题,你会下意识去看对应对象是否实现了相关协议,而不是在旁边干瞪眼,或者把锅甩给解释器。

1.2 为什么把这四个语法点放在同一篇

表面上看,生成器、装饰器、上下文管理器、类型注解是四个完全不同的话题,但它们都涉及同一件事:代码的执行时机和控制权转移。

  • 生成器让一段代码“暂停”,把当前执行状态保存下来,下次再继续;
  • 装饰器把一段逻辑包在另一段逻辑外面,先做一些准备工作,再决定是否调用原函数;
  • 上下文管理器把一段代码包在“进入/退出”的固定边界里,一旦离开with块就触发清理动作;
  • 类型注解则在代码真正运行之前,先把数据结构约束描述清楚。

说白了,前面几篇笔记讲的是“数据怎么算”,这一篇讲的是“代码怎么被调度、包装和保护”。我把它们放在同一个系列里讲,是因为实际项目中它们经常组合出现:生成器惰性产出数据,装饰器统计耗时,with负责释放资源,注解交代数据结构。单独拆开看都能懂,但组合起来才是日常开发里真正需要的写法。

下面开始逐个拆。

语法点核心协议或机制解决的核心问题典型场景
生成器迭代器协议 + yield 暂停恢复大数据不能一次性载入内存流式读日志、处理超大文件
装饰器高阶函数 + 闭包在不改原代码的前提下附加逻辑日志、计时、鉴权、重试
上下文管理器资源管理协议确保退出时执行清理动作文件、锁、临时配置
类型注解类型描述 + 静态检查让数据结构在运行前被理解接口定义、复杂数据建模

2. 生成器与迭代器:从 for 循环到 send 传值

2.1 手动写一个迭代器

先不急着讲yield,我们从一个最原始的迭代器开始。假设要实现一个简单的区间类,支持for i in MyRange(3),最直接的做法是写一个类,同时实现__iter__和__next__。

class MyRange: def __init__(self, end): self.current = 0 self.end = end def __iter__(self): return self def __next__(self): if self.current >= self.end: raise StopIteration value = self.current self.current += 1 return value

注意__next__里必须用raise StopIteration来告诉 for 循环“数据已经取完了”。for 循环内部会捕获这个异常并正常结束。这个类初看没问题,但它有一个明显缺点:__iter__返回的是自己,所以同一个对象只能被完整遍历一次。如果你写两个 for 循环来遍历同一个MyRange(3),第二个循环会直接拿不到任何数据。

这种写法适合理解协议本身,但不适合日常使用。因为你要手动维护current状态,代码冗长而且容易漏掉边界条件。生成器就是来简化这个过程的。

2.2 生成器函数与惰性求值

把上面的类改写成生成器函数,只需要一个yield:

def gen_range(end): current = 0 while current < end: yield current current += 1

调用gen_range(3)的时候,函数体根本不会执行,它只是创建了一个生成器对象。真正执行要从next()或 for 循环开始。每次执行到yield,当前状态就冻结住,返回值给调用方;下一次再进来,从yield后面继续走。

这就是惰性求值。我最早理解这个点是在一次日志处理项目里:一个文件几个 GB,直接readlines()会瞬间吃掉大量内存,但用生成器逐行处理时,内存占用基本稳定。哪怕文件再大,也只有在某一行真正被读取到内存时才会有那一行的开销。

def read_log_lines(path, keyword=None): with open(path, encoding="utf-8") as f: for line in f: if keyword is None or keyword in line: yield line.rstrip("\n")

用的时候直接for line in read_log_lines("app.log", keyword="error"):,完全不用关心文件的打开和关闭细节。这里文件对象本身也是支持迭代协议的,所以for line in f每次只读一行,而不是把整个文件塞进列表。

有一个新手很容易忽略的坑:生成器对象只能迭代一次。一旦被 for 循环完整跑过一遍,它就“耗尽”了。如果你后面还要统计数据、做二次过滤,最好在拿到结果后先转成列表,或者重新创建一个新的生成器对象。

2.3 send、throw、close 与 yield from

yield不只是往外吐数据,它也能接收数据。这个特性常被用来做最简单的协程模型。

def consumer(): total = 0 while True: item = yield total if item is None: continue total += item c = consumer() print(c.send(None)) # 启动生成器,total 初始为 0 print(c.send(10)) # 传入 10,total 变为 10 print(c.send(20)) # 传入 20,total 变为 30 c.close()

第一次调用必须传None,也就是c.send(None),这是为了把生成器驱动到第一个yield处。之后每次send(value),这个值会成为item = yield total这个表达式的返回值,生成器继续执行,直到下一次遇到yield再返回结果。

throw用于在生成器挂起点注入一个异常,方便外部让生成器处理某些错误场景;close用于主动关闭生成器,关闭时会在挂起的yield处抛出GeneratorExit,所以生成器里如果有try/finally,finally块会执行,适合放清理逻辑。

yield from则是用来委托子生成器的,常见的处理方式是简化嵌套的迭代逻辑:

def split_words(lines): for line in lines: yield from line.split(",") for word in split_words(["a,b,c", "d,e"]): print(word) # a b c d e

这里yield from line.split(",")会把列表中的每个元素逐个 yield 出去,相当于在这里嵌了一层子循环。需要注意的是,子生成器中的return 表达式不会直接作为数据产出,而是作为yield from整体表达式的返回值被外层捕获。这个特性在编写递归生成器时特别有用,比如遍历树形结构后把最终结果带回给上层。

2.4 内存和性能边界

惰性求值最大的优势是内存可控,但它不是免费的。做一个简单对比:

import sys lst = list(range(1000000)) gen = (x for x in range(1000000)) print(sys.getsizeof(lst)) # 约 8000056,单位字节 print(sys.getsizeof(gen)) # 约 112,生成器对象本体很小

list一次性把一百万个整数全放进去,占用以兆字节计;生成器对象本身只有不到 200 字节,因为它既不保存全部元素,也不提前计算。这个差异在处理大数据时是决定性的。

但生成器也有代价:多次迭代时,它每次都要重新生成数据,用一个生成器对象遍历两遍,第二遍必然为空;而列表可以反复遍历。在性能上,生成器每次迭代都发生在 Python 解释器调用链里,做一些简单的数值运算时,它通常比同等逻辑的列表推导慢一点。所以我的建议是:能明确知道数据规模很小、且需要多次访问时,直接用列表;数据规模大、只需要顺序消费一遍时,优先考虑生成器。实践中我见过有人为了“显得高级”把所有列表推导改成生成器,结果数据量很小,性能反而变差了,可读性也下降了。

3. 装饰器:闭包、参数传递与签名保持

3.1 先理解闭包与函数对象

装饰器的底层机制是函数对象和闭包。Python 中函数是一等公民,它和整数、字符串一样可以被赋值、被当作参数传递、被作为返回值返回。闭包的作用是让内层函数记住外层函数运行时的局部变量,即使外层函数已经执行完。

def make_multiplier(factor): def multiply(x): return x * factor return multiply double = make_multiplier(2) print(double(5)) # 10

这里double能正常工作,是因为multiply捕获了factor。你可以通过double.__closure__看到闭包对象,这也是理解装饰器的关键:装饰器本质上就是“接收一个函数,返回一个新函数,新函数内部闭包持有原函数”。

我给不少朋友解释装饰器时喜欢用一个类比:原函数是一台机器,装饰器是在机器外面加一个透明罩子。机器内部完全不用改,罩子负责记录进出情况、检查权限、统计次数。拆掉罩子,机器还是那台机器。

3.2 从普通装饰器到带参数装饰器

先写一个最简单的日志装饰器:

import functools def log_call(func): @functools.wraps(func) def wrapper(*args, **kwargs): print(f"call {func.__name__}") return func(*args, **kwargs) return wrapper @log_call def add(a, b): return a + b print(add(2, 3))

这里有两个细节值得注意。

第一,wrapper用*args, **kwargs接受任意参数,再透传给原函数,这样装饰器才能适配不同的函数签名。如果不这样做,装饰器只能用在固定参数个数的函数上。

第二,必须用functools.wraps(func)。它会将原函数的__name__、__doc__、__module__等属性复制到wrapper上,同时给wrapper设置一个__wrapped__属性指向原函数。如果不加,add.__name__就变成wrapper,有些代码依赖函数名做反射或日志时会莫名其妙出错。

带参数的装饰器,比如“给装饰器传一个日志级别”,需要多包一层:

def log_call(level="INFO"): def decorator(func): @functools.wraps(func) def wrapper(*args, **kwargs): print(f"[{level}] call {func.__name__}") return func(*args, **kwargs) return wrapper return decorator @log_call(level="DEBUG") def add(a, b): return a + b

写成@log_call(level="DEBUG")时,实际过程是:先执行log_call("DEBUG")得到decorator,再用decorator去装饰add。所以多出来的这一层不是没意义,它是在“装饰器本身也需要配置”时常见的结构。

如果装饰器本身既可以被直接使用,也可以被传入参数使用,就需要写一个兼容写法,我一般用functools.partial或者加一个判断分支,不过日常项目中很少需要这种双重用法,优先保证代码可读。

多个装饰器叠加时的顺序也常被搞混。看这个例子:

def a(func): @functools.wraps(func) def wrapper(*args, **kwargs): print("enter a") result = func(*args, **kwargs) print("exit a") return result return wrapper def b(func): @functools.wraps(func) def wrapper(*args, **kwargs): print("enter b") result = func(*args, **kwargs) print("exit b") return result return wrapper @a @b def hello(): print("hello")

执行hello()时输出顺序是:enter a -> enter b -> hello -> exit b -> exit a。原因很简单:@a @b等价于hello = a(b(hello)),所以外层是a,内层是b。装饰遵循“从下往上执行,从上往下生效”的顺序。这个顺序在叠加缓存、日志、鉴权时很重要,比如先鉴权再打印日志,还是先打印日志再鉴权,完全取决于排列方式。

3.3 类装饰器与执行时机

装饰器不一定非要返回函数,只要返回的是一个可调用对象就行。类只要实现了__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"count={self.count}") return self.func(*args, **kwargs) @CountCalls def say(text): print(text) say("hi") say("hi again")

这里用functools.update_wrapper(self, func)手动把原函数元数据复制到实例上,作用和wraps类似。

一个容易忽略的点是装饰器的执行时机:装饰器是在函数定义阶段就执行的,不是在函数调用阶段。所以装饰器内部如果写了比较重的初始化逻辑,比如连接数据库、加载远程配置、发起网络请求,模块被 import 的时候就会触发。我踩过一次坑:写了一个重试装饰器,装饰器内部构建了一个线程池,结果模块一导入就创建了一堆空闲线程,占用资源。后来才把重资源的初始化挪到第一次调用时再触发。这也是装饰器进阶里很重要的一条经验:装饰器里放轻量包装逻辑,真正重的东西延迟到调用时再初始化。

检查某个函数被哪些装饰器包过,可以顺着__wrapped__属性往下找。functools.wraps会自动建立这条链条,调试时很有用。

3.4 适用场景和滥用倾向

装饰器最常见的应用场景是:日志记录、耗时统计、权限校验、缓存计算结果、参数校验、重试机制、事务边界。它们都属于“横切关注点”,也就是本来和业务逻辑无关、却要散落在每个函数里的重复逻辑。把这些逻辑抽到装饰器里,业务函数只需要关心自身职责。

但装饰器不是越多越好。多层装饰器叠加会让调用链路变长,错误堆栈也更难阅读。我见过一个内部工具类函数上叠了六个装饰器,最后排查问题时单是确认执行顺序就花了不少时间。所以我的建议是:装饰器数量控制在两到三层以内;如果一个函数需要太多横切逻辑,考虑把这些逻辑合并成一个职责更清晰的装饰器,或者直接用上下文管理器包一层,而不是继续往上叠。

实现方式优点注意点
普通函数装饰器简单直观,适合日志、耗时需要functools.wraps
带参数装饰器可配置日志级别、重试次数多一层嵌套,结构稍复杂
类装饰器适合保存跨调用状态需要实现__call__
叠加装饰器功能组合灵活执行顺序容易混淆

4. 上下文管理器:with 不只是自动关闭文件

4.1 with 协议与 __exit__ 返回值

很多人对with open(...) as f的理解停留在“它会自动关闭文件”,但真正让这个语法成立的是两个协议方法:__enter__和__exit__。

class Timer: def __init__(self, name): self.name = name def __enter__(self): self.start = time.perf_counter() return self def __exit__(self, exc_type, exc_val, exc_tb): self.cost = time.perf_counter() - self.start print(f"{self.name} cost {self.cost:.6f}s") return False

__enter__方法在进入with语句块时被调用,它的返回值会赋给as后面的变量。__exit__方法在离开with块时被调用,包括正常离开和因为异常离开。它接收三个参数:异常类型、异常实例、回溯对象。如果块内没有异常,这三个参数都是None。

__exit__的返回值是一个关键点:如果返回True,表示当前异常已经被处理掉了,调用方不会看到异常,代码会继续往下走;如果返回False,异常会继续向外传播。所以“异常是否被 with 吞掉”完全由这个返回值决定。我见过有人为了写“防御性代码”错误地把__exit__写成返回True,结果异常被静默吞掉,排查了很久才发现问题。如果没有特殊理由,return False是更安全的选择。

4.2 contextmanager 生成器写法

用类实现上下文管理器比较正式,但很多时候逻辑很短,类写法反而显得啰嗦。标准库contextlib提供了contextmanager装饰器,允许用生成器函数来定义上下文管理器:

from contextlib import contextmanager import time @contextmanager def timed(name): start = time.perf_counter() try: yield finally: cost = time.perf_counter() - start print(f"{name} cost {cost:.6f}s")

在这个写法里,yield之前的代码相当于__enter__,yield之后的代码相当于__exit__。关键点是:如果用try/finally把yield包起来,就可以确保无论with块中是正常结束还是抛异常,清理代码都会执行。下面这个临时修改对象属性的例子在实际测试中非常有用:

@contextmanager def patch_attr(obj, name, value): old_value = getattr(obj, name) setattr(obj, name, value) try: yield finally: setattr(obj, name, old_value)

contextmanager内部有一套异常转发逻辑:如果with块中发生了异常,这个异常会被重新抛出到生成器的yield语句处,所以生成器内部可以用try/except捕获它。如果捕获后不重新抛出,异常就被吞下去了;如果希望异常继续传播,需要再用raise丢出去。这个行为第一次接触时容易懵,但只要记住一点:yield是“把控制权交回用户代码”的边界,异常从用户代码反向传回生成器。

两种实现方式的选择标准很简单:需要保存复杂状态、需要提供多个属性给外部使用时,用类实现;只是一个简单的“进入了、做点事、退出了”逻辑,用contextmanager更轻量。

4.3 多资源管理、嵌套与清理顺序

多个资源需要一起管理时,可以写成:

with open("a.txt") as fa, open("b.txt") as fb: pass

这样两个文件都能在块结束后自动关闭。清理顺序并不是从左到右,而是从右往左:后进入的资源先退出。这个顺序在资源有依赖关系时很重要,比如外层事务依赖内层连接先关闭,就需要小心设计。

如果需要管理的资源数量不固定,比如根据配置动态决定要打开几个文件,with语句就没法穷举了。这时可以使用contextlib.ExitStack。

from contextlib import ExitStack with ExitStack() as stack: files = [stack.enter_context(open(name, encoding="utf-8")) for name in file_names] # 在这个块里使用所有文件

ExitStack会记录进入过的所有上下文对象,离开时按照后进先出的顺序统一退出清理。这个技巧在写测试代码和处理一组外部资源时非常顺手,比手动维护一堆try/finally干净得多。

还有一个常见问题:with块中提前return时,__exit__会被调用吗?会。不管是通过return、异常还是正常走到块结尾离开,__exit__都会执行。这一点让上下文管理器成为“无论如何都要执行的清理动作”的可靠载体。

4.4 一个完整脚本

把前面几个知识点组合起来,可以实现一个“自动计时,并保证即使出错了也能记录结果”的片段:

@contextmanager def timed(name): start = time.perf_counter() try: yield except Exception: print(f"{name} failed after {time.perf_counter() - start:.6f}s") raise else: print(f"{name} ok in {time.perf_counter() - start:.6f}s")

这里except里打印失败耗时,再用raise把原始异常继续向上抛;else分支只有在块内没有异常时执行。这样既保留了异常,又获得了完整的耗时信息。这种写法我经常用在数据同步任务里,省去在每个异常分支手动打日志的重复劳动。

5. 类型注解的进阶用法与运行时边界

5.1 注解本质与版本差异

类型注解不是运行时约束,这一点必须放在最前面。def add(x: int, y: int) -> int:里的int只是附加在函数对象上的元数据,解释器不会因为你传了字符串而报错。注解的真正价值是给静态检查工具和读代码的人提供语义信息。

基础写法是:

from typing import Optional, Union def parse(text: str) -> Optional[int]: if text.isdigit(): return int(text) return None

Optional[int]表示结果可能是int,也可能是None。它本质上等价于Union[int, None],但在语义上“可能为空”的表达更直接。

版本差异很容易踩坑:Python 3.8 及更早版本里,内置的list还不能直接当作泛型用,必须写typing.List[int];Python 3.9 开始支持list[int]、dict[str, int]这种简洁写法。如果代码要兼容多个版本,要么统一用typing.List,要么在文件头部加一行:

from __future__ import annotations

这行代码会把所有注解延迟为字符串,也就是定义时不求值。它的好处是避免了“注解里用了还未定义的类型”导致的NameError,同时让模块导入更快。但它也有副作用:有些工具在读取注解时需要额外解析字符串。在普通应用开发里,from __future__ import annotations是一个省心的默认选择。

5.2 泛型与 Protocol

泛型用来表达“多个位置之间存在类型关联”。最简单的场景是:一个函数取列表的第一个元素,返回值的类型应该和列表元素类型一致。

from typing import TypeVar T = TypeVar("T") def first(items: list[T]) -> T: return items[0]

这里T是一个类型变量。调用first([1, 2, 3])时,类型检查工具能推断出返回值是int;调用first(["a"])时,返回值是str。如果不用泛型而直接写def first(items: list) -> ...,返回值类型信息就丢失了。泛型让“输入类型和输出类型绑定”的关系更清晰。

Protocol则是一种“结构化子类型”的描述方式。它不要求你继承某个基类,只要你的类有相同的方法和属性,类型检查工具就认为它满足协议。

from typing import Protocol class Loader(Protocol): def load(self, path: str) -> bytes: ... class FileLoader: def load(self, path: str) -> bytes: with open(path, "rb") as f: return f.read() def read_data(loader: Loader, path: str) -> bytes: return loader.load(path)

这里FileLoader并没有继承Loader,但它实现了load方法,类型检查工具会认为read_data(FileLoader(), "data.bin")是合法的。这就是所谓的“鸭子类型的静态化表达”:运行时依然是鸭子类型,但在静态检查层面多了一双眼睛。Protocol在写插件系统和抽象接口时非常有用,它让调用方不必耦合到某个具体类。

需要注意,Protocol默认不参与isinstance检查。除非用@runtime_checkable标记,否则运行时并不能用它来判断一个对象是否满足协议。实际开发中,我更愿意把它当作静态检查时的契约,而不是运行时的类型判断工具。

5.3 TypedDict、Annotated 与数据建模

在纯 Python 项目中,字典是最常见的数据容器,但普通注解只能写dict[str, Any],没法描述内部字段。TypedDict可以解决这个问题,它专门用于描述“固定字段的字典”。

from typing import TypedDict class User(TypedDict): name: str age: int email: str def format_user(user: User) -> str: return f"{user['name']} <{user['email']}>"

User不是一个真正的类,它就是一段字典结构的类型描述。在类型检查工具下,user["naem"]这类拼写错误会在静态检查阶段被提醒,尽管运行时不会报错。对于从接口读取 JSON 数据、再交给多个函数使用的场景,这个功能能大幅减少“字段名打错”的低级 bug。

Annotated则用来给类型附加额外元数据,常见用途是描述取值范围、格式、或者校验规则:

from typing import Annotated Port = Annotated[int, "1-65535"] def bind(port: Port) -> None: ...

Annotated[int, "1-65535"]在类型上仍然只是int,附加的字符串是给静态检查工具或框架读取的。单独使用注解时,这些附加信息不会做任何运行时校验,但如果是写一个需要读取这些元数据做校验的库或框架,这个语法就非常关键。

我做数据清洗类项目时,一个很常用的组合是:TypedDict描述外部输入结构,Protocol描述处理器的统一接口,泛型标注让处理前后的类型关联保持清楚。三者在静态检查阶段能抓到非常多低级错误,实际收益比想象的更大。

5.4 运行时的几条边界

类型注解虽然好用,但有几条边界必须清楚。

第一条,注解在默认情况下于函数定义时求值。也就是说,def f(x: some_dict["type"])里的表达式会在定义函数时执行一次。如果这个表达式有副作用或者依赖运行顺序,可能引发意外。用from __future__ import annotations可以把求值延后成字符串,规避这个问题。

第二条,运行时不会因为类型不对而抛错。函数接收了错误类型的数据,解释器不会自动拒绝,大部分情况下是等到真正运算时才会抛出一个莫名其妙的方法错误或类型错误。所以不要指望注解替代输入校验。

第三条,typing命名空间里的一些写法已经过时了。比如typing.List、typing.Dict是为了兼容旧版本而存在,Python 3.9 之后直接使用内置的list、dict加下标即可。在类型检查工具看来,两者大多数时候等价,但新版代码里继续写typing.List会让风格变得老旧。

第四条,注解信息可以通过typing.get_type_hints在运行时读取,但含义有限。它能告诉你“官方”的类型描述是什么,却不会替你保证数据真的符合这个描述。把注解当成“接口契约”而不是“守卫逻辑”,使用起来才不容易失望。

注解能力静态检查工具运行时解释器
函数参数类型能检查不检查
返回值类型能检查不检查
字典字段约束能检查不检查
泛型类型关联能检查不检查
检查数组越界看类型推断不检查

6. 常见问题速查与性能实测记录

6.1 高频问题速查表

把这几类语法在实际使用中遇到的问题整理成一张速查表,排查时可以对照着看。

现象可能原因解决办法
生成器第二次 for 循环拿不到数据生成器只能迭代一次重新创建生成器对象,或数据量小时转成列表
看到函数名变成wrapper装饰器没用functools.wraps在内部函数上添加@functools.wraps(func)
多层装饰器执行顺序和预期不符忘了叠加等价于从下往上执行换位顺序,或写日志验证输出顺序
with块里的异常不见了__exit__返回了True除非故意吞异常,否则返回False
contextmanager函数里 yield 后面的清理代码没执行生成器没有用try/finally包裹用try/finally保证清理逻辑执行
注解里用了未定义类型导致报错注解在定义时求值使用from __future__ import annotations
Python 3.8 里list[int]报错版本不支持内置泛型改用typing.List[int]
类型注解传错类型运行时却没报错注解不参与运行时校验使用类型检查工具,必要处显式校验
生成器内部raise StopIteration变成RuntimeErrorPEP 479 行为变更不要在生成器内手动抛出StopIteration,用return结束

其中两条特别值得展开说明。

第一,PEP 479 改变了很多老代码的行为。以前可以在生成器内部手动抛出StopIteration来结束迭代,现在解释器会把这个异常替换为RuntimeError。这不是语法变了,而是为了防止生成器内部的StopIteration影响外层基于迭代器协议的逻辑。所以生成器正常结束时,直接写return就好,不需要手动抛异常。

第二,functools.wraps也不是万能的。它能把__name__、__doc__等常见属性复制过来,但对于inspect.signature这种需要完整参数签名的场景,依赖的是__wrapped__属性。大多数情况下没有问题,但如果装饰器对参数做了拦截和重写,签名依然可能与原函数不一致。所以在写对外 API 的装饰器时,需要确认调用方是否依赖参数签名信息。

6.2 性能实测:惰性求值、装饰器开销、with 开销

这些语法看着优雅,但都有额外开销。我用自己的理解做了一个粗略的对比测试,重点看量级,不要当作精确基准。

先看生成器与列表在“只遍历一次”时的差别:

import timeit def sum_list(): return sum([i for i in range(1000000)]) def sum_gen(): return sum(i for i in range(1000000)) print(timeit.timeit(sum_list, number=10)) print(timeit.timeit(sum_gen, number=10))

在我的本机环境中,列表推导后sum和直接生成器给sum的耗时在同一量级,有时候生成器稍快,有时候列表稍快,原因在于列表构建本身也有开销,但sum内部对列表的遍历可以走更快的 C 层循环;生成器则每一步都需要解释器驱动。如果数据足够大,内存是主要瓶颈,生成器胜出;如果数据很小且要反复访问,列表更直接。

装饰器的额外开销主要体现在“多一层函数调用”。一个空函数不用装饰器和用装饰器包一层,百万次调用的差异可以从几毫秒拉到几十毫秒量级。如果函数内部有真实的 I/O 操作、网络请求、复杂计算,这层开销可以忽略不计;但如果你写的是一个被高频调用的小工具函数,每一层装饰器都会放大调用成本。

with的开销主要来自__enter__和__exit__的调用。单纯用with包一下代码,和直接写普通语句相比,会有额外的函数调用开销,但极其微小。问题通常不是性能,而是__exit__里如果写了重逻辑,比如同步文件、发送指标,那么每个 with 块都会带上这些成本。我的习惯是:上下文管理器只做轻量级的进入和退出动作,重清理放到任务结束后统一执行。

6.3 踩坑后养成的几个习惯

写了很多次这些语法之后,我形成了一些自己的习惯,谈不上标准答案,但确实帮我少踩了不少坑。

写生成器之前,我会先问自己:这个数据流要被消费几次?如果只消费一次,生成器是合适的选择。如果后面要做分组、排序、多次统计,我会先想清楚内存是否允许,不允许的话就得设计分阶段处理,而不是简单地把生成器转成列表。

写装饰器时,所有内部包装函数都会带上functools.wraps。这件事我已经条件反射了,因为吃过太多“查日志时函数名全是 wrapper”的亏。另外,装饰器内部不做重初始化,所有资源延迟到第一次调用时建立,避免模块导入阶段出现奇怪的行为。

写上下文管理器时,我会明确确认“异常在这里应该被吞掉还是继续传播”。这不是能含糊过去的细节。很多隐性 bug 都来自于异常被某个上下文静默吞掉,导致上层逻辑以为一切都正常。默认情况下我会让异常继续传播,只有少数场景会主动返回True,比如期望在退出时把特定异常记录并恢复现场的情况。

写类型注解时,我提醒自己它是一份“静态契约”,不是运行时守卫。真正需要保护的数据入口,比如外部接口传入的数据,我一定会做显式校验;注解的作用更多是让内部代码的调用关系更清晰,而不是替代防御式编程。这也让我在给一个大型接口添加注解时,能够把注意力放在“类型关系是否合理”上,而不是纠结还要不要额外写一堆检查逻辑。

还有一个建议:这些语法点不是一次性就能完全掌握的,最好的方式是组合着用。给一个函数写一个装饰器,装饰器内部用with管理资源,函数体用生成器逐行处理数据,再给TypedDict把返回结构定义清楚。这样一个 50 行的小工具,把这一篇里的内容全部串起来之后,读起来会非常舒服,排查问题时定位也很快。语言特性本身不解决问题,真正有用的是你在真实场景里把它们组合出合理的结构。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进