面试Python岗位,十次有八次绕不开迭代器、生成器、装饰器。我做了几年Python开发,也当过技术面试官,这三兄弟几乎成了筛简历后的第一道关。很多候选人能背出概念定义,但一问到底层机制就露馅了,比如为什么for循环能遍历自定义对象、yield到底怎么暂停函数的、装饰器执行顺序是啥,这些才是区分“背过题”和“真明白”的关键。
这篇文章把这些年面试中被反复问到的题目、追问方式、以及背后的原理一次性梳理清楚。不管你是准备面试的求职者,还是带实习生需要出题的组长,都可以直接拿来用。我会从最基础的协议讲起,逐步深入到高级用法和实战场景,尽量把每个知识点的“为什么”也讲明白,毕竟面试官最常干的事就是——你讲完一个知识点,他紧接着问一句“为什么”。
1. 面试官到底在考什么:三个概念的底层逻辑
先说一个很多教程没点破的事实:迭代器、生成器、装饰器在Python里属于不同层面的东西,但它们经常被放在一起问,是因为这三者恰好覆盖了Python数据模型、函数式编程、语法糖实现三个核心区域。面试官问这些,表面在考语法,实际在考察你对Python执行机制的理解深度。
1.1 三个概念之间的隐式关系
迭代器和生成器是一对紧密相关的东西。生成器是迭代器的一种特殊形式,但生成器的实现依赖于迭代器协议。装饰器则是另一条线,它跟函数和类绑定得更深,但高级用法里经常跟生成器配合使用。
举一个典型的例子,Python 3里的生成器表达式和列表推导式非常像,但一个返回生成器对象,一个返回列表。生成器对象就是一个迭代器,它没有一次性把所有数据算出来,而是用到一个算一个。这种“惰性求值”的思想,在写数据处理代码时特别重要。
装饰器则是一种“包装”思想。它不改变原函数的名字和调用方式,却能在函数执行前后插入额外的逻辑。如果你理解了装饰器的本质是“把一个函数传给另一个函数,再返回一个新的函数”,那面试中百分之八十的装饰器题目都能解开。
1.2 面试中的高频考察点分布
根据我面试候选人时记录的提问频率,这三块内容的考察点大致如下:
| 概念 | 最高频考察点 | 追问方向 |
|---|---|---|
| 迭代器 | iter()和next()的配合、StopIteration异常 | 如何自定义可迭代对象 |
| 生成器 | yield的工作机制、惰性求值 | yield from、send()方法 |
| 装饰器 | 闭包、functools.wraps、带参数装饰器 | 执行顺序、类装饰器 |
你会发现,面试官很少直接问“什么是迭代器”,而是喜欢问“for循环是怎么遍历一个自定义类的”这种看似基础但极其致命的问题。一旦你答不上来for循环背后调用了iter()再不断调next(),那就等于告诉面试官你写Python全凭IDE自动补全。
2. 迭代器:不只是next()这么简单
迭代器这块,面试题的核心就两个:什么是可迭代对象、什么是迭代器、两者有什么关系,以及如何自定义迭代器。
2.1 迭代器协议与可迭代对象的区别
先摆定义。可迭代对象(Iterable)是实现了__iter__()方法的对象,或者实现了__getitem__()且按下标从0开始索引的对象。迭代器(Iterator)是实现了__iter__()和__next__()两个方法的对象。
一个常见的面试陷阱是:列表、元组、字典、集合是迭代器吗?答案是:不是,它们是可迭代对象。每次调用iter([1, 2, 3])都会返回一个新的迭代器,这个迭代器才有__next__()方法。
为什么要有这个区分?因为迭代器是有状态的,它在内部记录了“我已经遍历到哪个位置了”。如果你把列表本身当作迭代器,那么两个for循环同时遍历同一个列表就会互相干扰。所以Python的设计是:可迭代对象是数据容器,迭代器是遍历指针。
我自己面试时会追问一个点:for i in obj:这种语句,Python解释器具体做了什么?完整回答应该是三段:先调用iter(obj)获取迭代器,然后循环调用next(iterator)获取下一个元素,直到捕获StopIteration异常退出循环。这个回答能体现你对for循环本质的理解。
2.2 手写迭代器类的完整实现
面试手撕代码时,最经典的题目是“实现一个可以迭代的类”。比如写一个计数器,从1数到n,每次加1。这道题的满分答案是:
class Counter: def __init__(self, n): self.n = n self.current = 0 def __iter__(self): return self def __next__(self): if self.current >= self.n: raise StopIteration self.current += 1 return self.current这里有一个关键细节:__iter__返回self,使得Counter既是可迭代对象又是自己的迭代器。这在某些场景下没问题,但如果数据需要被多次遍历,这种写法就有缺陷了。更规范的做法是让容器和迭代器分离——容器实现__iter__()返回一个新的迭代器对象,迭代器对象实现__next__()。
为什么面试官喜欢考这个?因为很多候选人用过itertools,但完全不知道里面那些工具是怎么实现的。能手写迭代器,说明你对Python协议有真正的感知力。
2.3 迭代器相关的经典面试追问
迭代器还有一个高频考点:迭代器的“一次性”问题。面试官可能会给你一段代码,让你分析输出结果:
nums = [1, 2, 3] iter1 = iter(nums) iter2 = iter(iter1) print(iter1 is iter2) # 输出什么?答案是True。因为iter()函数在传入一个迭代器对象时,会直接返回它本身,而不是创建一个新的迭代器。这个特性叫作“迭代器的迭代器是自己”,也是为什么for循环在多层嵌套时不会出问题。
另一个常见追问是:如何判断一个对象是可迭代对象还是迭代器?答案是用collections.abc模块:isinstance(obj, Iterable)和isinstance(obj, Iterator)。但有个坑,生成器既是迭代器也是可迭代对象,列表只是可迭代对象不是迭代器。
还有个我特别喜欢问的面试题:给定一个非常大的文件,如何倒序读取最后几行?常规思路是把文件全部读进内存然后切分,但大文件会内存溢出。一个合格的回答是用deque配合迭代器实现固定长度的滚动窗口,这其实也算迭代器思想的应用。
3. 生成器:面试中的性价比之王
如果说迭代器考的是“懂不懂协议”,那生成器考的就是“懂不懂惰性求值”。生成器是Python中最优雅的特性之一,面试题基本围绕yield的工作机制和实际应用场景展开。
3.1 yield关键字的工作机制详解
生成器函数的核心是yield。遇到yield时函数返回值并暂停,下次调用next()时从暂停位置继续执行。这个“暂停-恢复”机制是面试中必须讲清楚的点。
我一般会画一个函数执行的流程对比:普通函数从头执行到尾,生成器函数可以执行到一半就停下来,而且局部变量全部保留在“现场”。这个现场其实就是生成器对象的内部状态。
一个经典的面试追问是:生成器函数和普通函数有什么区别?从源码层面看,函数体内只要出现yield关键字,Python在编译时就会将这个函数标记为生成器函数,调用它时不执行任何函数体代码,而是直接返回一个生成器对象。也就是说,即使生成器函数里第一行有语法错误,在调用函数时也不会报错,只有真正迭代到那一行才会暴露。
手撕代码最常见的题目是“用生成器实现斐波那契数列”。标准答案:
def fibonacci(): a, b = 0, 1 while True: yield a a, b = b, a + b注意while True在这个场景下是合理的,因为生成器是惰性的,没人来取值就不会死循环。这也是生成器能让“无限序列”成为可能的原因。
3.2 yield from和send()的进阶用法
如果面试进行到高阶,面试官会问yield from。这个语法是Python 3.3引入的,用于在生成器中委托另一个生成器。
def generator_a(): yield 1 yield 2 def generator_b(): yield 0 yield from generator_a() yield 3yield from的作用相当于展开内层生成器,但它的真正价值在于打通了send()、throw()、close()这些方法的传递通道。面试中能答出这一点的人不多。
send()方法也是高频考点。next(gen)等价于gen.send(None),而send(value)会把value作为yield表达式的结果传回生成器内部。这让生成器既能产出数据,又能接收外部传来的数据,变成一个双向通道。
写一个能体现send()价值的例子:
def accumulator(): total = 0 while True: value = yield total if value is None: continue total += value调用acc.send(10)会把10传给value,然后更新total。这种协程雏形在某些异步框架里还能看到。面试时能主动提到“协程是基于生成器的send机制演变而来的”,会显得知识面很扎实。
3.3 生成器的经典面试场景题
生成器在实战中的价值,最典型的就是处理大数据。
面试官常问:有一个10GB的日志文件,需求是统计每个IP出现的次数,内存只有2GB,怎么做?最经济安全的回答是用生成器逐行读取文件,然后进行流式处理,避免一次把所有内容载入内存。
def read_large_file(file_path): with open(file_path, 'r') as f: for line in f: yield line.strip()注意啊,这里其实文件对象本身就支持迭代,直接用for line in f就行,但面试官想听到的是你具备“用生成器封一层”的意识。你可以在生成器里加入过滤、清洗、格式转换等逻辑,实现职责分离。
另一个经典题是“实现一个带有缓冲区的生成器,每收集足够一行再输出”。这类题的考察重点是理解yield暂停后挂起状态的保存逻辑。
还有一个基础知识要掌握:生成器表达式。(x * x for x in range(10))返回的是生成器对象,跟列表推导式相比,不会一次性创建10个元素的列表。如果数据量是百万级,这能节省不少内存。面试时可以顺带说一句“生成器表达式占用的内存几乎可以忽略,因为它一次性只生成一个值”。
4. 装饰器:要讲透必须理解这几点
装饰器是面试中的“重头戏”,因为它考察的不只是语法,还有你对函数式编程、闭包、Python对象模型的理解。面试官问装饰器的题目通常都不止一问,会层层深入。
4.1 闭包是装饰器的基础
讲装饰器之前,先必须讲闭包。闭包的定义很简洁——一个内层函数引用了外层函数的变量,并且外层函数把这个内层函数作为返回值。这个内层函数连同它引用的外部变量,就构成了闭包。
def outer(x): def inner(y): return x + y return inner这个例子中,inner引用了outer的参数x,outer返回了inner。你在外部拿到inner之后,即使outer已经执行结束,inner仍然记得x的值。
闭包的关键在于:Python的变量查找是LEGB规则,内层函数找不到局部变量时会去外层函数的局部作用域找。而这个“外层局部作用域”在函数返回后并没有被销毁,因为闭包持有它的引用。
面试追问常是:闭包有什么坑?典型的是“循环变量陷阱”:
funcs = [] for i in range(3): funcs.append(lambda: i) print([f() for f in funcs]) # 输出 [2, 2, 2]因为lambda捕获的是变量i的引用,不是i当时的值。循环结束后i的值是2,所以每个lambda都返回2。解决办法是加默认参数lambda i=i: i,或者用functools.partial。
4.2 手写通用装饰器模板
装饰器的本质是语法糖,@decorator等价于func = decorator(func)。所以手写通用装饰器的标准模板是:
import functools def my_decorator(func): @functools.wraps(func) def wrapper(*args, **kwargs): # 调用前逻辑 result = func(*args, **kwargs) # 调用后逻辑 return result return wrapper这里有两个关键点必须理解,不然面试必挂:
第一个是*args和**kwargs的作用。装饰器要装饰的函数参数完全未知,所以wrapper必须能接收任意位置参数和关键字参数,然后原样传递给被装饰函数。如果漏写了**kwargs,被装饰函数一旦有关键字参数就会直接报错。
第二个是@functools.wraps(func)的意义。它把被装饰函数的元信息(__name__、__doc__等)拷贝到wrapper上。如果没有它,函数的__name__就变成了wrapper,调试和日志输出会非常混乱。
面试里最常见的题是“写一个计算函数执行时间的装饰器”。答案就是用这个模板:
import functools import time def timer(func): @functools.wraps(func) def wrapper(*args, **kwargs): start = time.perf_counter() result = func(*args, **kwargs) end = time.perf_counter() print(f'{func.__name__} cost {end - start:.6f}s') return result return wrapper面试官很可能会追问:为什么用time.perf_counter()而不是time.time()?因为time.time()的精度受系统时间影响,还可能被NTP同步修改,不适合精确计时。而perf_counter()专门用于测量短时时间间隔,精度最高,是业内通用的计时方案。
4.3 带参数的装饰器与functools.wraps
带参数的装饰器比普通装饰器多一层嵌套。比如实现一个“可重试N次”的装饰器:
import functools def retry(max_attempts): def decorator(func): @functools.wraps(func) def wrapper(*args, **kwargs): for attempt in range(1, max_attempts + 1): try: return func(*args, **kwargs) except Exception as e: if attempt == max_attempts: raise print(f'Attempt {attempt} failed: {e}, retrying...') return None return wrapper return decorator @retry(max_attempts=3) def unstable_api_call(): return 'ok'注意这里有三层函数:retry接收参数,decorator接收被装饰函数,wrapper接收运行时参数。很多候选人能写对普通装饰器,但一写带参数的装饰器就少了一层。
面试官还会问:类装饰器的优缺点?类装饰器可以借助类的__call__方法,实现更复杂的带状态装饰逻辑:
class CountCalls: def __init__(self, func): self.func = func self.calls = 0 def __call__(self, *args, **kwargs): self.calls += 1 return self.func(*args, **kwargs)类装饰器的优势是能通过实例属性保存状态,不用依赖闭包。面试中答出这种写法会比较加分。
4.4 装饰器面试真题:缓存、重试、限流
实战中还有几个常见的装饰器场景,虽然不是语法难点,但经常被用来考察“能不能把装饰器真正用到工程里”。
缓存装饰器是最经典的。Python内置的functools.lru_cache就是一个带参数的装饰器:
import functools @functools.lru_cache(maxsize=128) def fib(n): if n < 2: return n return fib(n-1) + fib(n-2)面试官可能要求你手写一个简单的缓存装饰器,通常用字典就能实现。这里要注意缓存key的设计,简单场景下直接用args做key,复杂场景要处理关键字参数和可变对象。
限流装饰器也常被问到,比如接口每秒最多调用N次:
import functools import time def rate_limit(max_calls, period): def decorator(func): call_times = [] @functools.wraps(func) def wrapper(*args, **kwargs): now = time.time() call_times[:] = [t for t in call_times if now - t < period] if len(call_times) >= max_calls: raise RuntimeError('Rate limit exceeded') call_times.append(now) return func(*args, **kwargs) return wrapper return decorator这种题的目的就是让候选人灵活运用闭包状态和装饰器语法。能把上面几个写出来,装饰器这一关基本能过。
5. 三者联动:面试中的综合应用题
面试官出题有个习惯:单一的语法点问完了,就会出一些综合题,把迭代器、生成器、装饰器串起来。这种题最容易暴露出候选人知识体系是否完整。
5.1 装饰器+生成器的组合场景
一个典型的综合题是“写一个装饰器,让生成器在迭代时自动记录日志”。
先定义一个生成器,比如逐行读取数据文件:
def read_data(): for i in range(100): yield i再定义一个装饰器,包装这个生成器,每当生成器产出一个值时打印日志:
import functools def log_generator_output(func): @functools.wraps(func) def wrapper(*args, **kwargs): gen = func(*args, **kwargs) for item in gen: print(f'Yielded: {item}') yield item return wrapper这里有个容易出错的点:wrapper本身因为包含yield变成了生成器函数,所以调用wrapper返回的不是原生成器的包装结果,而是一个新的生成器对象。面试官如果追到这里,基本就是在看你在迭代器和生成器之间能不能灵活转换。
还有个更隐蔽的面试题:在装饰器里怎么判断被装饰函数的返回值是不是迭代器?可以用isinstance(result, Iterator)判断。这个看起来简单的题,其实把两个概念贯通了:装饰器操作函数返回值,而迭代器协议定义了什么是可遍历对象。
5.2 常见误区和性能坑
三块知识结合起来之后,有几个真实业务中很容易踩的坑。
第一个坑:生成器被复用后数据全没了。很多新人写代码时,生成器对象保存下来觉得可以反复遍历,结果第二次遍历发现是空的。因为生成器是一次性的,遍历完就消耗完了。想反复遍历只能重新创建生成器对象。
第二个坑:装饰器改变了函数的元信息导致序列化失败。某些框架在传输函数时依赖函数名,没加@functools.wraps会导致函数名变成了wrapper,轻则日志难排查,重则框架内部映射关系错乱。
第三个坑:生成器配合for循环产生“慢半拍”的行为。面试中我出过这样一道题:
def gen(): print('start') yield 1 print('middle') yield 2 print('end') g = gen() print('created') next(g) print('after first next')输出顺序是什么?正确答案是:
created start after first next注意start是在第一次next()时才打印的,说明生成器函数体在创建时根本没执行。这个点容易跟装饰器的“调用即执行”逻辑混在一起。装饰器包裹的函数,在模块加载时就完成包装了;而生成器函数是调用时返回生成器对象、迭代时才真正执行函数体。面试中能把这个区别讲得清清楚楚,就能证明你是真的理解而不是背范文。
6. 高频面试题速查与答题思路
最后整理一份可以直接拿去复习和模拟的面试题清单,每道题都附带答题要点和踩分关键。这部分不只是给求职者看的,面试官也可以用来出题自查。
6.1 面试题速查表
| 题目 | 核心考点 | 标准答案要点 |
|---|---|---|
for循环的内部实现? | 迭代器协议 | 先iter(),再循环next(),捕获StopIteration退出 |
| 列表和生成器有什么区别? | 惰性求值与内存占用 | 列表一次性创建所有元素,生成器逐个产出,节省内存 |
yield和return有什么区别? | 生成器工作机制 | return结束函数并返回,yield暂停函数并产出值 |
| 装饰器执行顺序(多个装饰器叠加)? | 装饰器栈 | 从上往下装饰,从下往上执行 |
| 如何让一个装饰器同时支持有参和无参调用? | 双模式兼容 | 判断第一个参数是否为函数,用callable()或利用*args |
iter()调用一个迭代器会怎样? | 迭代器协议 | 返回自身,iter(iter(obj)) is iter(obj) |
生成器的send(None)和next()之间的关系? | 发送机制 | next(gen)等价于gen.send(None),send可以把值传给yield表达式结果 |
多装饰器执行顺序这道题尤其要重点记住。看下面这个例子:
@a @b def func(): pass等价于func = a(b(func))。执行时是先执行装饰器a传入的原函数b(func),即a先执行其外层逻辑,然后调用b(func),最后执行func本身。所以装饰器的“执行顺序”和“定义顺序”是反的——定义时从上到下装饰,运行时从下到上执行。这个考点几乎必考,而且能区分出大量候选人。
6.2 实战建议与个人体会
根据我带实习生的经验,这三部分知识要真正融会贯通,不能只靠背题,一定要自己动手写一遍。我建议的练习路径是先实现一个自定义迭代器类,再把同一个逻辑改成生成器函数实现,观察两者的区别;最后用装饰器给这两个实现加上执行时间统计,比较性能差异。整个过程做完,面试时基本不会有太大问题。
实际业务中还有一个被很多人忽视的点:itertools标准库是这三块知识的集大成者。itertools.chain的原理就是迭代器的串联,itertools.islice就是生成器的切片,itertools.lru_cache则是装饰器的典型应用。面试前花时间读一遍itertools源码,比刷十道面试题都管用。
最后分享一个我面试时常用的“六字心法”:对象、协议、语法糖。迭代器的核心是协议设计,生成器的核心是惰性计算,装饰器的核心是函数式包装。抓住这三条主线,无论面试题怎么变,你都能快速找到对应的知识坐标,用底层原理去拆解题目,而不是生搬硬套答案。这套理解方式,也是我在实际写代码时判断一个候选人是否具备长期成长潜力的关键依据。