Python 面试高频:装饰器、迭代器、生成器和上下文管理器一次讲清

Python 面试高频:装饰器、迭代器、生成器和上下文管理器一次讲清

开场:这些题为什么总被问?

如果面试官问 Python 基础,除了昨天讲过的对象、引用、可变默认参数、浅拷贝、GIL,后面大概率会追这几个问题:

  • 装饰器本质是什么?
  • 闭包为什么能记住外层变量?
  • functools.wraps 有什么用?
  • 迭代器和可迭代对象有什么区别?
  • 生成器为什么省内存?
  • yield 到底暂停了什么?
  • with 语句背后发生了什么?
  • 上下文管理器为什么适合管理文件、连接和锁?

这些问题不是为了考你会不会背术语,而是看你有没有理解 Python 的几个核心设计:

函数可以像对象一样传递,遍历依赖统一协议,惰性计算可以保存执行现场,资源释放应该交给明确的进入和退出边界。

这句话听起来长,拆开就是今天这篇文章的四条线:

  1. 装饰器和闭包:函数也是对象,函数可以包函数。
  2. 迭代器协议:for 循环背后不是魔法,而是 iter()next()
  3. 生成器:yield 让函数可以暂停、恢复、逐个产出数据。
  4. 上下文管理器:with 让资源进入和退出有稳定边界。

这几个点学完,再去看 FastAPI 的路由装饰器、依赖注入、流式响应和数据库连接生命周期,会顺很多。

一、装饰器:本质是"函数包一层函数"

先看最常见的写法:

python 复制代码
@timer
def query_user(user_id):
    return {"id": user_id}

很多人第一次看到 @timer 会把它当成一种特殊语法。其实装饰器最核心的理解很简单:

装饰器就是一个接收函数、返回新函数的函数。

上面的代码基本等价于:

python 复制代码
def query_user(user_id):
    return {"id": user_id}

query_user = timer(query_user)

也就是说,@timer 不是在函数调用时才执行。它会在函数定义完成后,把原函数传给 timer,再把返回结果重新绑定给原函数名。

写一个最小装饰器:

python 复制代码
def timer(func):
    def wrapper(*args, **kwargs):
        print("before")
        result = func(*args, **kwargs)
        print("after")
        return result
    return wrapper

@timer
def add(a, b):
    return a + b

print(add(1, 2))

执行流程可以这样理解:

text 复制代码
定义 add
  -> 把 add 传给 timer
  -> timer 返回 wrapper
  -> add 这个名字重新绑定到 wrapper
  -> 调用 add(1, 2) 时,实际调用 wrapper(1, 2)
  -> wrapper 内部再调用原始 func(1, 2)

这里有两个关键点:

第一,函数在 Python 里是对象。

函数可以赋值给变量:

python 复制代码
def hello():
    return "hello"

f = hello
print(f())

函数也可以作为参数传给另一个函数:

python 复制代码
def run(fn):
    return fn()

print(run(hello))

函数还可以作为返回值:

python 复制代码
def outer():
    def inner():
        return "inner"
    return inner

fn = outer()
print(fn())

装饰器就是把这三个能力组合起来:函数传进去,新函数返回来。

第二,wrapper(*args, **kwargs) 是为了尽量兼容原函数参数。

如果原函数有不同参数:

python 复制代码
def add(a, b): ...
def get_user(user_id, verbose=False): ...
def save(**payload): ...

装饰器不应该把参数写死。*args 接收位置参数,**kwargs 接收关键字参数,这样包装函数才能转发不同形状的调用。

面试回答模板

如果面试官问"装饰器本质是什么",可以这样答:

装饰器本质上是一个接收函数并返回函数的可调用对象。@decorator 只是语法糖,等价于 func = decorator(func)。它通常通过内部的 wrapper 函数在调用原函数前后加入日志、鉴权、计时、缓存等逻辑。

二、闭包:为什么 wrapper 还能拿到原函数?

上面的 timer 里有一个容易被忽略的问题:

python 复制代码
def timer(func):
    def wrapper(*args, **kwargs):
        result = func(*args, **kwargs)
        return result
    return wrapper

timer 已经执行结束了,为什么 wrapper 后面还能用 func

答案就是闭包。

闭包是指内部函数引用了外部函数作用域里的变量,并且这个内部函数被返回或传递到外部继续使用。

看一个更小的例子:

python 复制代码
def make_counter():
    count = 0

    def inc():
        nonlocal count
        count += 1
        return count

    return inc

counter = make_counter()
print(counter())  # 1
print(counter())  # 2
print(counter())  # 3

make_counter() 执行结束后,局部变量 count 并没有马上消失,因为返回的 inc 函数还引用着它。

这就是闭包的作用:

text 复制代码
外部函数变量
  -> 被内部函数引用
  -> 内部函数返回到外部
  -> 变量随内部函数一起保留下来

装饰器里的 wrapper 能继续拿到 func,也是这个原因。

nonlocal 是干什么的?

如果内部函数只是读取外层变量,通常不用写 nonlocal

python 复制代码
def outer():
    name = "python"

    def inner():
        return name

    return inner

但如果内部函数要重新赋值,就要写 nonlocal

python 复制代码
def make_counter():
    count = 0

    def inc():
        nonlocal count
        count += 1
        return count

    return inc

不写 nonlocal,Python 会把 count += 1 里的 count 当成内部函数的局部变量处理,结果就会报错。

带参数的装饰器,其实多包了一层

普通装饰器:

python 复制代码
@timer
def add(a, b):
    return a + b

等价于:

python 复制代码
add = timer(add)

带参数的装饰器:

python 复制代码
@retry(times=3)
def request_api():
    pass

等价于:

python 复制代码
request_api = retry(times=3)(request_api)

所以带参数装饰器通常是三层函数:

python 复制代码
def retry(times):
    def decorator(func):
        def wrapper(*args, **kwargs):
            last_error = None
            for _ in range(times):
                try:
                    return func(*args, **kwargs)
                except Exception as exc:
                    last_error = exc
            raise last_error
        return wrapper
    return decorator

三层分别负责:

层级 作用
retry(times) 接收装饰器参数
decorator(func) 接收被装饰的原函数
wrapper(*args, **kwargs) 接收原函数调用参数

这个结构一旦看懂,很多 Python 框架里的 @xxx(...) 就不会显得神秘了。

闭包常见坑:状态会被保留下来

闭包能保留状态,这是优点,也可能是坑。

比如下面这个装饰器记录调用次数:

python 复制代码
def count_calls(func):
    count = 0

    def wrapper(*args, **kwargs):
        nonlocal count
        count += 1
        print(f"call count = {count}")
        return func(*args, **kwargs)

    return wrapper

每个被装饰的函数都会有自己的 count。如果你把这个状态用于缓存、鉴权、限流,就要想清楚状态的作用域:是每个函数独立,还是全局共享,是否线程安全,是否会在长生命周期进程里越积越多。

面试回答模板

如果面试官问"闭包是什么",可以这样答:

闭包是内部函数引用了外部函数作用域中的变量,并且内部函数在外部函数结束后仍然继续存在。Python 会把被引用的外层变量随内部函数一起保存下来。装饰器里的 wrapper 能记住原函数 func,就是典型闭包。

三、为什么装饰器要加 functools.wraps

上面的装饰器还能跑,但有一个问题:

python 复制代码
def timer(func):
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs)
    return wrapper

@timer
def add(a, b):
    """Add two numbers."""
    return a + b

print(add.__name__)
print(add.__doc__)

你可能会得到:

text 复制代码
wrapper
None

因为 add 这个名字已经被重新绑定到 wrapper 了。此时外部看到的函数元信息,不再是原来的 add,而是包装后的 wrapper

这在普通脚本里可能没什么问题,但在框架里就可能出事:

  • 日志里函数名全是 wrapper,排查困难。
  • 文档工具拿不到原函数说明。
  • 测试和调试信息变得不直观。
  • 某些依赖函数签名或元信息的框架会受到影响。

所以正式写装饰器时,通常要加 functools.wraps

python 复制代码
from functools import wraps

def timer(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs)
    return wrapper

wraps 会把原函数的一些元信息复制到 wrapper 上,比如 __name____doc____module____annotations__ 等。

这不是为了"好看",而是为了让包装后的函数尽量像原函数。

面试回答模板

装饰器会用 wrapper 替换原函数,如果不加 functools.wraps,外部看到的函数名、文档、注解等元信息可能变成 wrapperwraps 的作用是把原函数的元信息复制到包装函数上,方便调试、文档生成和框架反射。

四、迭代器:for 循环背后不是魔法

接着看第二组高频题:迭代器和可迭代对象。

先看平时最常见的写法:

python 复制代码
for item in [1, 2, 3]:
    print(item)

你可以把它理解成:

python 复制代码
items = iter([1, 2, 3])

while True:
    try:
        item = next(items)
    except StopIteration:
        break
    print(item)

也就是说,for 循环背后主要依赖两个动作:

动作 含义
iter(obj) 拿到一个迭代器
next(iterator) 从迭代器里取下一个值

当没有更多数据时,迭代器会抛出 StopIterationfor 循环捕获它并结束循环。

可迭代对象和迭代器有什么区别?

一句话:

可迭代对象是"能被 iter() 转成迭代器的对象";迭代器是"能被 next() 不断取值的对象"。

常见可迭代对象:

  • list
  • tuple
  • dict
  • set
  • str
  • 文件对象
  • 生成器对象

迭代器对象要实现两个方法:

python 复制代码
class CountDown:
    def __init__(self, start):
        self.current = start

    def __iter__(self):
        return self

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

for n in CountDown(3):
    print(n)

输出:

text 复制代码
3
2
1

这里 CountDown 同时是可迭代对象,也是迭代器,因为它的 __iter__() 返回了自己。

但列表不是迭代器:

python 复制代码
nums = [1, 2, 3]
it = iter(nums)

print(next(it))  # 1
print(next(it))  # 2

nums 是可迭代对象,it 才是迭代器。

这个区别在面试里很容易被问:

list 可以 for 循环,但它本身不是迭代器。它是可迭代对象,调用 iter(list) 后才会得到迭代器。

迭代器是有状态的

迭代器会记住自己遍历到哪里了:

python 复制代码
nums = [1, 2, 3]
it = iter(nums)

print(next(it))  # 1
print(next(it))  # 2

for x in it:
    print(x)     # 只会输出 3

这意味着迭代器通常是一次性消费的。你把一个迭代器传给两个地方一起用,很容易出现"怎么少数据了"的问题。

面试回答模板

可迭代对象是实现了可迭代协议、能被 iter() 获取迭代器的对象;迭代器是实现了 __next__() 的对象,每次 next() 返回一个值,没有值时抛出 StopIterationfor 循环本质上就是先调用 iter(),再不断调用 next()

五、生成器:yield 让函数暂停和恢复

生成器是 Python 面试里非常高频的点。

先看普通函数:

python 复制代码
def build_list():
    result = []
    for i in range(3):
        result.append(i)
    return result

print(build_list())  # [0, 1, 2]

普通函数一调用,就会一路执行到 return,然后结束。

再看生成器函数:

python 复制代码
def gen_numbers():
    for i in range(3):
        yield i

g = gen_numbers()

print(next(g))  # 0
print(next(g))  # 1
print(next(g))  # 2

只要函数体里出现 yield,调用这个函数时不会立刻执行函数体,而是返回一个生成器对象。

真正执行发生在你调用 next(g) 的时候。

执行到 yield i 时,函数会:

  1. 产出当前值 i
  2. 暂停在这一行。
  3. 保存当前局部变量和执行位置。
  4. 等下一次 next() 再从暂停处继续。

这就是生成器和普通函数最大的区别:

text 复制代码
普通函数:一次执行到 return
生成器函数:多次 next,多次暂停和恢复

生成器为什么省内存?

假设你要处理 1000 万行数据。

列表写法:

python 复制代码
def load_all_rows(path):
    rows = []
    with open(path, "r", encoding="utf-8") as f:
        for line in f:
            rows.append(line.strip())
    return rows

这个函数会把所有数据都放进内存。

生成器写法:

python 复制代码
def read_rows(path):
    with open(path, "r", encoding="utf-8") as f:
        for line in f:
            yield line.strip()

这个函数每次只产出一行。调用方需要一行,就取一行。

python 复制代码
for row in read_rows("data.txt"):
    handle(row)

所以生成器省内存的原因不是它"压缩了数据",而是:

它不一次性构造完整结果,而是按需逐个产出。

生成器也是迭代器

生成器对象可以直接用于 for 循环:

python 复制代码
def gen():
    yield 1
    yield 2

for x in gen():
    print(x)

因为生成器对象本身就是迭代器。它有 __iter__(),也有 __next__()

生成器常见坑:只能消费一次

python 复制代码
g = (x * 2 for x in range(3))

print(list(g))  # [0, 2, 4]
print(list(g))  # []

第二次为什么是空?

因为第一次 list(g) 已经把生成器消费完了。生成器记住自己的执行位置,走到终点后就结束了。

如果你需要重复遍历,就要重新创建生成器:

python 复制代码
def make_gen():
    return (x * 2 for x in range(3))

print(list(make_gen()))
print(list(make_gen()))

yieldreturn 有什么关系?

生成器函数里可以写 return,但它不是像普通函数那样返回最终列表。

python 复制代码
def gen():
    yield 1
    return
    yield 2

return 会结束生成器。外部继续 next() 时会触发 StopIteration

日常面试里不需要把生成器协议讲得特别深,但要能说清楚:

  • yield 产出值并暂停。
  • 下一次 next() 从暂停位置继续。
  • 生成器按需产出,所以适合大数据流、文件读取、分页拉取、流式响应。
  • 生成器是一次性消费的。

面试回答模板

生成器函数是包含 yield 的函数,调用它不会立即执行函数体,而是返回生成器对象。每次 next() 会执行到下一个 yield,产出值后暂停,并保存当前执行状态。生成器按需产出数据,不需要一次性把所有结果放进内存,所以适合处理大文件、数据流和惰性计算。

六、上下文管理器:with 解决的是资源退出边界

最后看 with

最常见写法:

python 复制代码
with open("data.txt", "r", encoding="utf-8") as f:
    content = f.read()

它解决的问题很朴素:

无论代码正常结束,还是中途抛异常,都要把文件关掉。

不用 with,你可能会写:

python 复制代码
f = open("data.txt", "r", encoding="utf-8")
try:
    content = f.read()
finally:
    f.close()

with 就是把这种 try/finally 资源管理模式变成了协议。

with 背后的两个方法

一个对象只要实现了上下文管理协议,就可以放进 with

python 复制代码
class ManagedResource:
    def __enter__(self):
        print("enter")
        return self

    def __exit__(self, exc_type, exc, traceback):
        print("exit")
        return False

with ManagedResource() as resource:
    print("use resource")

执行顺序:

text 复制代码
调用 __enter__()
  -> 把返回值绑定给 as 后面的变量
  -> 执行 with 代码块
  -> 调用 __exit__()

即使代码块中抛异常,__exit__() 也会被调用。

__exit__() 的三个参数用于接收异常信息:

参数 含义
exc_type 异常类型
exc 异常对象
traceback 异常调用栈

如果 __exit__() 返回 True,表示异常被处理,不再向外抛;返回 FalseNone,异常会继续向外传播。

日常写资源管理时,大多数情况下应该让异常继续抛出去,不要随便吞异常。

contextlib.contextmanager 简化写法

如果不想专门写一个类,可以用 contextlib.contextmanager

python 复制代码
from contextlib import contextmanager

@contextmanager
def managed_resource():
    print("enter")
    try:
        yield "resource"
    finally:
        print("exit")

with managed_resource() as resource:
    print(resource)

这个写法里:

  • yield 前面的代码相当于 __enter__()
  • yield 产出的值会绑定给 as resource
  • finally 里的代码相当于 __exit__(),负责释放资源。

这也解释了为什么今天把生成器和上下文管理器放在一起讲:contextmanager 装饰器就是用生成器来写上下文管理器。

上下文管理器适合哪些场景?

常见场景:

  • 文件打开和关闭。
  • 数据库连接获取和释放。
  • 事务提交和回滚。
  • 锁的获取和释放。
  • 临时切换配置,再恢复旧配置。
  • 测试里临时 mock 某个资源。

它的价值不是少写几行代码,而是把资源生命周期写成明确边界:

text 复制代码
进入 with:拿资源
代码块中:使用资源
离开 with:释放资源

这比依赖对象析构、垃圾回收或"记得手动 close"稳定得多。

面试回答模板

上下文管理器用于管理资源的进入和退出。with 语句会先调用对象的 __enter__(),代码块结束后调用 __exit__(),即使中间抛异常也会执行退出逻辑。它适合管理文件、连接、锁和事务,本质上是把 try/finally 资源释放模式协议化。

七、把这几个点串起来

今天这几个知识点看起来分散,其实可以串成一条线:

知识点 一句话理解 常见用途
装饰器 函数包一层函数 日志、鉴权、缓存、重试、路由注册
闭包 内部函数记住外层变量 装饰器参数、状态保存、函数工厂
wraps 保留原函数元信息 调试、文档、框架反射
迭代器 按协议逐个取值 for 循环、自定义遍历
生成器 暂停和恢复的惰性迭代器 大文件、数据流、分页、流式返回
上下文管理器 明确资源进入和退出 文件、连接、事务、锁

后面学 FastAPI 时,这些点会反复出现:

  • @app.get(...) 是装饰器。
  • 自定义鉴权、日志、限流,也常用装饰器或中间件。
  • 依赖函数、路由函数的签名和元信息很重要,所以装饰器不能乱丢函数信息。
  • 流式响应常常和生成器、异步生成器有关。
  • 数据库 Session、事务、文件资源,都绕不开上下文管理。

所以今天这篇不是为了堆语法,而是先把 Python 框架背后的语言机制补齐。

八、面试快速复盘

最后给一份可直接复述的回答清单。

1. 装饰器是什么?

装饰器是接收函数并返回新函数的可调用对象,@decorator 等价于 func = decorator(func)。它常用于在不修改原函数代码的情况下增加日志、计时、鉴权、缓存、重试等逻辑。

2. 闭包是什么?

闭包是内部函数引用外部函数作用域里的变量,并且内部函数在外部函数结束后仍然存在。Python 会把这些被引用的变量和内部函数一起保存下来。

3. 为什么装饰器要用 functools.wraps

因为装饰器会用 wrapper 替换原函数。如果不加 wraps,函数名、文档、注解等元信息可能丢失,影响日志、调试、文档生成和框架反射。

4. 可迭代对象和迭代器有什么区别?

可迭代对象能被 iter() 获取迭代器;迭代器能被 next() 不断取值,没有数据时抛出 StopIterationfor 循环本质上就是 iter()next()

5. 生成器为什么省内存?

生成器不会一次性构造完整结果,而是每次执行到 yield 时产出一个值并暂停。下一次 next() 再从暂停位置继续,所以适合大文件、数据流和惰性计算。

6. with 语句背后是什么?

with 背后是上下文管理协议。进入代码块前调用 __enter__(),离开代码块时调用 __exit__(),即使中间抛异常也会执行退出逻辑,适合管理文件、连接、事务和锁。

总结

Python 基础面试里,装饰器、迭代器、生成器和上下文管理器不是孤立语法点。

它们共同指向一件事:

Python 很多"简洁写法"的背后,都是对象、协议和执行状态管理。

装饰器靠函数对象和闭包扩展行为;迭代器靠协议统一遍历;生成器靠暂停和恢复做惰性计算;上下文管理器靠进入和退出边界管理资源。

把这些机制搞懂,再去看 FastAPI、Django、SQLAlchemy、pytest 这些框架时,很多"框架魔法"都会变成可以解释的普通 Python 机制。

参考资料

相关推荐
basketball6161 小时前
C++ 高级编程:1. 多线程基本操作
开发语言·c++
YJlio1 小时前
OpenClaw v2026.5.26-beta.1 / beta.2 预发布解读:Gateway 加速、transcript 路径统一、多通道修复、语音增强与安装更新链路加固
人工智能·windows·python·ui·缓存·gateway·outlook
rqtz2 小时前
【机器人】ROS结合Qt开发上位机软件工作空间配置
开发语言·qt·ros
许彰午10 小时前
14_Java泛型完全指南
java·windows·python
广州灵眸科技有限公司10 小时前
瑞芯微RV1126B开发板(EASY-EAI-PI2) Easy-Eai编译环境准备与更新
服务器·前端·人工智能·python·深度学习
TechWayfarer10 小时前
IP风险等级评估接入实战:金融信贷如何用IP画像辅助风控审核
python·tcp/ip·安全·金融
Esaka_Forever10 小时前
uv init 完整用法(Python 最快包管理器)
服务器·python·uv
代码中介商13 小时前
C++左值与右值:核心判断法则详解
开发语言·c++
JAVA96513 小时前
JAVA面试-并发篇 05-并发包AQS队列实现原理是什么
java·开发语言·面试