从名字绑定到生成器:21 个 Python 细节与 22 组实验
合并今天的逐次追问与手工笔记,细分赋值与对象创建、id/is/==、Heap 与循环引用、函数对象与返回值、延迟调用、iter/next、生成器暂停与耗尽。附 22 组已运行实验、原笔记核对表及十道折叠复盘题。
从今天的追问开始:细小区别怎样连成一条线
这篇笔记合并了 2026 年 10 月 20 日的 Python 讨论与 Mokin 自己整理的笔记。起点是“名字什么时候绑定对象”,随后追问到对象身份、Heap、容器引用、函数调用和生成器。保留这些小问题,是为了以后看到一行代码时,能说清楚它实际做了什么。
| 复习组 | 这组要回答的问题 | 入口 |
|---|---|---|
| 名字与对象 | 什么时候建立绑定?有没有创建对象?身份变了吗? | 01–05 |
| 容器与存活 | 列表保存什么?环是不是死锁?Heap 管什么? | 06–08 |
| 函数与调用 | 函数对象、引用、返回值与打印有什么区别? | 09–12 |
| 迭代与按需执行 | 谁记录进度?什么时候计算?耗尽后怎么处理? | 13–21 |
每个标注“独立运行”的程序使用新进程运行;先预测输出,再展开答案。篇末有原笔记核对表与十道复盘题。本篇沿用普通同步 Python 示例,涉及 CPython 内存实现时会明确标出。
前置知识可回看 Namespace、Scope 与 LEGB;后续路线在 Python 进阶 Guidebook。这次讨论主要补充 Guidebook 的阶段 0、1 和 6。
01 · “执行前确定”的是什么:作用域类别还是对象绑定?
你的问题:普通变量的绑定,是不是在代码执行前就建立好了?
需要分开两件事:对普通函数,Python 会分析整个函数代码块中的绑定操作,确定名字属于局部名、自由变量或全局名等类别;具体绑定到哪个对象,则要看运行时实际执行了什么。知道这里有一个局部名字 results,不等于它已经指向某个列表。
LEGB 用来理解普通函数裸名解析涉及的作用域层次。它不能被机械理解成“每次先找 L,没值就继续找 E、G、B”。已经判定为局部名,但读取时尚未绑定,会抛出 UnboundLocalError。
局部名已分类,不代表已经绑定
results = ["GLOBAL"]
def reset():
print(results)
results = []
try:
reset()
except UnboundLocalError as exc:
print(type(exc).__name__)
print(results)
展开参考输出:先预测,再核对
UnboundLocalError
['GLOBAL']
函数后面出现对 results 的赋值,已足以影响整段函数中该名字的类别;它不需要先执行到赋值那一行。第一次读取失败以后,也不会借用模块里的同名值。
把两个时间点写在笔记上:代码分析确定如何解析这个名字;语句执行建立或改变实际绑定。参数绑定、函数定义、类定义和 import 也可能建立绑定,赋值不是唯一入口。
如果需要修改另一份绑定:global 指向 G;nonlocal 指向最近包含对应绑定定义的外层函数 E。E 可以嵌套多层。详见原文的 E/G 同名实验。
规则依据:名字分类与解析。
02 · 普通赋值:先求值右侧,再把结果赋给目标
你的原句:“普通赋值也需要执行,才会建立绑定。”这点正确。后面的“得到一个新对象”要去掉“新”。
“对表达式求值”是这里更准确的说法。表达式不一定是算术运算,也可以是名字查找、列表构造或函数调用。左侧是绑定目标;它不必预先指向一个对象。
先完成右侧求值,再改变名字绑定
results = ["OLD"]
def build_results():
print("RHS sees:", results)
return ["NEW"]
results = build_results()
print("after binding:", results)
try:
results = 1 / 0
except ZeroDivisionError as exc:
print(type(exc).__name__)
print("after failed RHS:", results)
展开参考输出:先预测,再核对
RHS sees: ['OLD']
after binding: ['NEW']
ZeroDivisionError
after failed RHS: ['NEW']
右侧函数先执行,其中的打印先出现,之后才轮到左侧绑定。右侧抛出异常时,本例中左侧名字的新绑定尚未建立,原来的绑定仍在。注意:右侧在失败前已经产生的其他副作用,不会因此自动撤销。
边界:这里说的是普通赋值。增强赋值 += 会先求值目标;items[0] = value 和 obj.name = value 则分别更新元素或属性,不能一概叫“建立一个局部名字”。
规则依据:普通赋值与增强赋值。
03 · 创建对象、首次绑定、重新绑定:可以同时发生吗?
可以。对象是否创建,看右侧做了什么;名字是否重新绑定,看这个绑定之前是否存在。它们是两个判断维度。
| 依次执行 | 对象侧 | 名字侧 |
|---|---|---|
a = [] | 创建列表 A | 首次将 a 绑定到 A |
b = a | 取得已有列表 A,不复制 | 若 b 此前未绑定,这是 b 的首次绑定 |
a = [] | 创建另一个列表 B | a 重新绑定到 B;b 仍绑定 A |
b = a | 取得已有列表 B | b 重新绑定到 B |
创建对象与建立绑定是两个维度
a = []
first_id = id(a)
b = a
print(b is a)
a = []
print(id(a) == first_id)
print(id(b) == first_id)
print(a is b)
展开参考输出:先预测,再核对
True
False
True
False
第一行和第三行的 id(a) 会不会变化?在这个例子里会。旧列表由 b 保留,新列表是另一个同时存活的对象,所以两者 id 不同。变化的是 a 当前取得的对象;旧对象自身的身份没有改变。
id 在对象存活期间保持不变。对象结束生命周期后,其 id 数值将来可能被复用,所以不要用“先打印一个已销毁对象的 id,再比较另一个对象”来证明对象相同。
[] 每次求值产生新列表,但 b = a 不复制它;函数调用也不保证新建对象,函数完全可以返回已有对象。整数和字符串还可能复用已有对象,不能从字面量出现了几次推断对象分配次数。
04 · == 与 is:相等和同一个对象有什么区别?
你的理解:“== 判断值是否相等;is 判断是不是同一个对象。”用于入门理解是对的,再补一句:相等的具体规则由类型定义,自定义类可以实现 __eq__;is 比较对象身份,不能通过 __eq__ 改写。
相等与同一对象分别判断
a = [1, 2]
b = a
c = [1, 2]
print(a == b)
print(a is b)
print(a == c)
print(a is c)
展开参考输出:先预测,再核对
True
True
True
False
分别创建的两个列表可以内容相等,却不是同一个列表;两个名字也可以引用同一列表。后续修改对象时,是否共享身份就有实际影响。
需要判断是否为 None,通常写 value is None。比较数值、字符串或业务内容则用相等规则,不依赖整数缓存或字符串复用。
一个精度补充:同一对象一定 == 自己吗?
不应把这个当作普遍规律。例如 NaN 与自身比较也不相等,自定义类型的相等规则也可能不同。这不会改变 is 对身份的判断。
规则依据:对象身份与相等比较。
05 · clear() 和 = []:为什么别名看到的结果不同?
results.clear() 先通过 results 找到列表,再修改这个列表。results = [] 创建新列表,并改变所选名字的绑定。其他名字不会自动跟着改绑。
clear 修改原对象,赋值可以改变绑定
results = ["PASS"]
alias = results
results.clear()
print(results, alias, results is alias)
results = ["NEW"]
print(results, alias, results is alias)
展开参考输出:先预测,再核对
[] [] True
['NEW'] [] False
函数里调用 results.clear() 可以通过名字解析找到模块中的列表并修改它,通常不需要 global;函数里写 results = [],在没有 global/nonlocal 时建立的是该函数调用的局部绑定。
如果目的是重新绑定模块中的 results,才写 global results。声明改变的是名字所指向的绑定环境,不是把列表变成一种“全局对象”。同一对象可以同时被局部名字、模块名字和容器引用。
继续区分复制:other = results.copy() 创建新的外层列表,但其中元素仍可引用相同的对象;浅复制不等于把整张对象关系图复制一遍。
06 · 为什么有列表、字典、字符串?列表能保存哪些对象?
看具体表达式,而不要把“Python 有这些类型”误读成“任何列表内部都有它们”。例如:
results = [{"status": "PASS"}]
| 代码部分 | 在对象关系中扮演的角色 |
|---|---|
| 最外层 [...] | 一个列表,其第一个元素引用字典 |
| {"status": "PASS"} | 一个字典,把键对象与值对象关联起来 |
| "status"、"PASS" | 字符串对象,分别参与键和值的关联;不据此断言每次都重新分配字符串 |
| results | 绑定到最外层列表的名字 |
内置列表可以引用任意 Python 对象,元素不要求同类型,也不要求可哈希。字典、函数、类、实例、模块、None 都可以成为元素;同一对象也可以出现在多个位置。
列表可以保存不同类型对象的引用
def run_case():
return "PASS"
case = {"status": "PASS"}
items = [case, case, run_case, None]
items[0]["status"] = "FAIL"
print(items[1]["status"])
print(items[0] is items[1])
print(items[2] is run_case)
print(items[3] is None)
展开参考输出:先预测,再核对
FAIL
True
True
True
两个位置引用同一字典时,通过一个位置修改字典,另一个位置也会看到变化。列表保存的引用关系,让这些对象成为可从列表访问的对象;没有隐含的元素复制。
在 CPython 中,list 使用的元素数组保存对象引用;引用槽位连续,不要求被引用对象的本体也连续。相关存储视角见 数据结构分类中的 list 引用槽位。
规则依据:容器与列表元素;CPython 的列表实现。
07 · Heap 里放什么?是谁决定可用内存?
先分层:名字绑定和对象身份是 Python 的语言概念;Heap、分配器和操作系统是在解释这些对象如何得到存储空间。不要把它们混成“名字必定放栈,对象必定放某块固定堆”的规则。
以 CPython 常见实现为例,解释器管理动态对象和辅助数据结构所需的内存:对象的类型等管理信息、对象自身的数据,以及列表引用数组、字典内部表等。空列表没有元素,也仍然需要对象自身的存储。
| 观察角度 | 要回答的问题 |
|---|---|
| Namespace | 哪个名字关联到哪个对象? |
| 对象关系 | 列表、字典、函数或其他对象又保留了哪些引用? |
| 解释器分配器 | 这些对象和辅助结构需要多少空间,哪些内存块可以复用? |
| 进程与操作系统 | 虚拟地址空间、可用资源及进程或容器的限制,允许继续使用多少内存? |
创建对象时,解释器可能复用已经管理的内存,不一定每次都向操作系统申请;需要扩展时才与底层分配机制交互。因此 Heap 不能简单理解为启动时由 OS 划定的一块永不变化的固定容量。
可用空间受机器与操作系统资源、地址空间、进程限制、容器内存上限等共同影响。CPython 的分配方式也可能随版本、对象大小及构建方式变化。本篇不把某次运行的对象地址或内存占用数字当成跨环境保证。
同理,函数返回不意味着所有关联对象立即销毁:调用者、容器、闭包等仍可能保留引用。可以回看 闭包与绑定的生命周期。学习当前问题时,先画清楚引用关系,通常比推测物理内存位置更有帮助。
规则依据:CPython 内存管理;进程资源限制;Linux 容器内存限制。
08 · 列表引用自身,会不会死循环、死锁或无法回收?
你的问题:“a 的第一个元素引用 a 自己,会不会出问题,类似死锁?”创建这样的引用关系本身是合法的。
自引用列表仍然只有一个元素
a = []
a.append(a)
print(len(a))
print(a[0] is a)
print(a)
for item in a:
print("loop:", item is a)
展开参考输出:先预测,再核对
1
True
[[...]]
loop: True
这里始终只有一个列表对象。append 只执行了一次,新增的那个元素引用列表自己;没有不断创建新列表。普通的一层 for 只访问一个元素,也能结束。
内置列表的表示机制会防止递归展开,因而输出 [[...]]。这里的 ... 是显示用的标记;它不代表该元素是 Ellipsis 对象,真实元素仍是列表自己。
| 概念 | 描述什么 | 本例的情况 |
|---|---|---|
| 循环引用 | 对象之间的引用关系形成环 | 存在 |
| 死循环/无终止递归 | 执行过程不能按预期停止 | 引用关系本身不等于无限执行;不识别环的递归处理可能出问题 |
| 死锁 | 执行单元因资源等待关系而无法推进 | 这个单线程列表构造没有建立这种等待关系 |
同一个对象,交给不同操作会有不同结果
len 和普通列表显示能处理本例;一个“遇到列表就递归下去”的简单遍历函数,若没有识别已访问对象,则可能最终触发 RecursionError。JSON 也不是通用的对象引用图格式:
JSON 编码器默认检查循环引用
import json
a = []
a.append(a)
try:
json.dumps(a)
except ValueError as exc:
print(type(exc).__name__)
展开参考输出:先预测,再核对
ValueError
json.dumps 默认检查循环引用,本例抛出 ValueError。关闭环检查不会把这张引用图变成可正常表达的 JSON 树。
不用以后,环由谁处理?
在 CPython 中,循环垃圾回收是对引用计数的补充,能够处理这类已经不可达的普通列表环。del a 只删除一个名字的绑定:如果 other、容器、闭包或尚存活的调用状态仍保留它,这个对象就不一定已经不可达。例如,上面 for 循环结束后,item 也仍引用这个列表,因此只执行 del a 并不足以让它不可达。即使没有外部可达路径,也不要假定回收发生在删除名字的同一瞬间。
你的复习句可以保留:先检查处理它的算法能否识别环,再检查不再使用时是否仍有外部引用保留它。对象回收与归还操作系统内存,还要按上一节的三个事件分别考虑。
09 · “函数引用就是函数对象吗?”把三个概念分开
你的问题:“如果只是稍后调用 run_case,保存函数引用已经足够;函数引用就是函数对象吗?”表达式 run_case 求值得到函数对象,而“引用”描述的是访问这个对象的关系。
| 概念 | 在普通 def 示例中是什么 |
|---|---|
| 名字 | run_case 这个标识符 |
| 函数对象 | 执行 def 语句时创建、可被调用的对象 |
| 绑定/引用关系 | 名字或容器元素关联到这个对象,使程序能访问它 |
多个访问路径可以引用同一个函数
def run_case():
return "PASS"
another_name = run_case
tasks = [run_case]
print(type(run_case).__name__)
print(another_name is run_case)
print(tasks[0] is run_case)
print(run_case(), another_name(), tasks[0]())
展开参考输出:先预测,再核对
function
True
True
PASS PASS PASS
another_name、run_case 和列表里的那个元素可以访问同一个函数。没有复制函数,也没有额外创建一个需要你操纵的“函数引用包装对象”。调用 tasks[0](),就是先取出那个函数对象,再调用它。
type(run_case) 查看的是名字求值后得到的对象的类型,不是在检查源代码中这个单词的拼写。执行普通 def 会创建并绑定函数,但不会在定义这一刻运行函数体;本节不涉及装饰器替换绑定的情况。
10 · [run_case()] 为什么先打印?results 最终绑定什么?
你的推理方向正确:为了完成右侧求值,需要执行其中的调用。把“对 Object 进行计算”改成“对右侧表达式求值”,就更精确了。
函数调用先发生,返回值成为列表元素
def run_case():
print("Running...")
return "PASS"
results = [run_case()]
print(results)
展开参考输出:先预测,再核对
Running...
['PASS']
| 表达式/名字 | 求值取得的对象 |
|---|---|
run_case | 函数对象 |
run_case() | 函数返回的字符串对象 "PASS" |
[run_case()] | 包含返回值的列表对象 ["PASS"] |
赋值完成后的 results | 上面那个完整列表 |
按执行顺序看:查找 run_case,调用函数,函数体打印 Running...,函数返回 PASS,右侧列表表达式完成求值,最后把 results 绑定到列表。这里的步骤用于追踪可观察行为,不据此推断列表底层分配内存的精确时点。
打印是执行中的副作用,返回值是调用表达式取得的结果。print 输出的文字不会自动变成外层函数的返回值;如果删掉 return "PASS" 而保留打印,这个普通函数返回 None,列表就会保存 None。
11 · return None、return 和不写 return,何时可以互换?
对于普通函数,执行到末尾没有显式返回值,会返回 None;裸 return 与 return None 也都返回 None。
普通函数的三种 None 返回方式
def implicit():
pass
def bare_return():
return
def explicit_none():
return None
print(implicit())
print(bare_return())
print(explicit_none())
展开参考输出:先预测,再核对
None
None
None
但 “返回同样的值”不等于“删除这行不影响控制流程”。放在分支中的 return 还负责让本次调用提前结束:
删除提前返回会改变后续控制流
def with_return(enabled):
if not enabled:
print("Skipped")
return None
print("Running...")
def without_return(enabled):
if not enabled:
print("Skipped")
print("Running...")
print("with return:")
with_return(False)
print("without return:")
without_return(False)
展开参考输出:先预测,再核对
with return:
Skipped
without return:
Skipped
Running...
当 enabled 为假时,带提前返回的版本在 Skipped 后结束;删掉 return 的版本会继续执行后面的 Running...。因此,末尾 return None 往往可以省略,中途 return None 可以改成 return,却不能随意删掉。
本节限定普通函数。生成器里的 return 表示结束迭代,之后会在生成器部分单独说明。
规则依据:return 语句。
12 · 只保存函数就可以延迟执行,为什么还会谈惰性求值?
你的联想:[run_case] 让我想到 lazy evaluation。这个联系有道理,准确地说,保存可调用对象为“之后再调用”提供了条件;还需要调用方安排何时执行。
保存函数以后,可以选择调用时机
def run_case():
print("Running...")
return "PASS"
tasks = [run_case]
print("saved")
result = tasks[0]()
print("result:", result)
展开参考输出:先预测,再核对
saved
Running...
result: PASS
| 执行 tasks = [run_case] 时 | 是否发生 |
|---|---|
| 查找 run_case 并取得函数对象 | 已经发生 |
| 创建列表并保存对函数的引用 | 已经发生 |
| 调用 run_case、运行它的函数体、取得调用的返回值 | 尚未发生 |
之后执行 result = tasks[0]() 才会调用。这体现了 deferred execution(延迟执行);如果按照结果需求来决定调用,则形成按需计算。仅仅保存函数,不意味着后台自动执行、异步调度或自动缓存。
如果目标只是“稍后调用这一个函数”,这样已经足够。下面引入生成器,是继续研究“怎样把一连串结果逐项提供给使用者”。
13 · iter() 是“转换”吗?为什么 iter(iterator) 不会重置?
你的原句:“iter() 将可迭代对象转换为迭代器。”作为第一印象可以理解,但笔记里建议写成:单参数 iter(obj) 按迭代协议或序列协议取得一个迭代器。
| 对象/操作 | 含义 |
|---|---|
| 可迭代对象 iterable | 能让 iter(obj) 取得迭代器的对象 |
| 迭代器 iterator | 可由 next() 推进,提供下一项或抛出 StopIteration |
| iter(列表) | 取得一个新的列表迭代器,具有自己的遍历位置 |
| iter(符合协议的迭代器) | 返回迭代器自身;不复制,也不从头开始 |
取得迭代器可能返回原对象
tasks = ["A", "B"]
first = iter(tasks)
second = iter(tasks)
same = iter(first)
print(first is second)
print(same is first)
print(next(first))
print(next(same))
print(next(second))
展开参考输出:先预测,再核对
False
True
A
B
A
first 与 same 是同一个迭代器的两个名字,共享进度;second 是另外创建的列表迭代器,所以可以独立从第一项开始。列表自身没有被“转换掉”,仍然可以用它获取新的迭代器。
“可迭代”不保证“每次 iter 都从头提供一份新数据”。生成器对象自己就是迭代器,也是可迭代对象;对它调用 iter() 取得的仍是它本身。
这里讨论单参数形式;iter(callable, sentinel) 是另外一种用法,当前不需要展开。
14 · 函数放进 list 再逐项调用,为什么不可以?完全可以
你的追问:“为什么不能将函数引用放在 list 中,然后逐项获取呢?”可以,而且你指出了一个比较时必须保留的区别:函数列表不是已经计算好的结果列表。
取得函数与取得计算结果是两种接口
def run_case():
print("Running...")
return "PASS"
tasks = [run_case, run_case]
task_iter = iter(tasks)
print("task iterator created")
task = next(task_iter)
print("next returned function:", task is run_case)
print("first result:", task())
print("second result:", next(task_iter)())
results = (task() for task in tasks)
print("result generator created")
print("first result:", next(results))
print("second result:", next(results))
展开参考输出:先预测,再核对
task iterator created
next returned function: True
Running...
first result: PASS
Running...
second result: PASS
result generator created
Running...
first result: PASS
Running...
second result: PASS
| 方式 | 创建阶段 | 取一次结果 | 进度由谁保留 |
|---|---|---|---|
[task() for task in tasks] | 逐个调用,保存全部返回值 | 从结果列表取值 | 后续访问结果列表的代码/迭代器 |
task_iter = iter(tasks) | 取得遍历函数对象的迭代器,不调用任务 | next(task_iter)() | 列表迭代器保留任务位置 |
results = (task() for task in tasks) | 创建生成器并取得最外层迭代器,不调用任务 | next(results) | 生成器保留执行状态,内部迭代器保留任务位置 |
在本例里,next(task_iter) 返回函数对象,需要再调用;next(results) 直接返回任务执行结果。生成器把“取下一个任务 → 调用 → 提供结果”封装成一个可迭代的结果生产过程。
两种延迟方式都能只算需要的部分、记录进度,并避免保留所有结果。给现有 tasks 列表包一层生成器,也不会让列表本身的内存占用消失。普通 for 循环同样可以边处理边使用结果。
所以,简单场景下函数列表已经够用;当你希望消费者只需取得结果,或要把筛选、转换、暂停位置组织在一起时,生成器接口会更方便。它不意味着自动并发或自动缓存。
比较的边界:异常情况下也完全等价吗?
这里的并排实验限定任务正常返回。若任务调用的异常没有在生成器内部处理而逸出,生成器会结束;在外部手动调用任务时,列表迭代器可能仍可继续使用。不能仅凭正常输出一样,就断言所有行为都等价。
规则依据:迭代器与生成器的组织方式。
15 · 生成器为什么能暂停?局部变量为什么没有重置?
普通 def 的自身函数体中包含 yield,会使它成为生成器函数;只在另一个嵌套函数中出现 yield,不会把外层函数也变成生成器函数。调用生成器函数取得生成器对象,函数体暂时不运行。
next() 开始或恢复执行;到 yield 时把一个值交给调用者并暂停,保留继续执行所需的局部绑定、迭代进度和位置。下次推进从暂停处继续,不从函数第一行重新执行。
生成器保留局部状态与继续执行的位置
from inspect import getgeneratorstate
def generate_results():
count = 0
for label in ("A", "B"):
count += 1
print("body:", label, count)
yield label
print("body: end")
results = generate_results()
print("created:", getgeneratorstate(results))
print("next:", next(results))
print("paused:", getgeneratorstate(results))
print("next:", next(results))
print("paused:", getgeneratorstate(results))
try:
next(results)
except StopIteration as exc:
print(type(exc).__name__)
print("finished:", getgeneratorstate(results))
展开参考输出:先预测,再核对
created: GEN_CREATED
body: A 1
next: A
paused: GEN_SUSPENDED
body: B 2
next: B
paused: GEN_SUSPENDED
body: end
StopIteration
finished: GEN_CLOSED
| 观察时点 | 本例的状态 | 发生的事情 |
|---|---|---|
| 调用 generate_results() 之后 | GEN_CREATED | 取得生成器对象;count = 0 尚未在函数体中执行 |
| 第一次 next() 返回后 | GEN_SUSPENDED | 交出 A,保存 count = 1 及暂停位置 |
| 第二次 next() 返回后 | GEN_SUSPENDED | 交出 B,保存 count = 2;还没执行末尾的打印 |
| 第三次 next() | 最终为 GEN_CLOSED | 继续执行,打印 body: end,结束并报告 StopIteration |
正在执行时还有 GEN_RUNNING;本例从生成器外部、调用间隙检查,所以没有直接打印该状态。交出最后一个值时可能仍暂停,下一次推进才运行到结束。
普通函数的 return 结束调用;生成器的 yield 交出元素并暂停,return 或执行到末尾结束迭代。生成器 return 的值不会成为额外的 yield 元素;更进一步的例子见 Guidebook 阶段 6。
调用时的实参表达式仍先求值。例如 generate_results(make_tasks()) 若对应定义接收该参数,会先调用 make_tasks,再创建生成器对象;延迟的是生成器函数体的执行。
规则依据:yield 与恢复执行;生成器状态检查。
16 · 右侧还是要先求值,为什么 task() 没有马上执行?
在 results = (task() for task in tasks) 中,赋值规则没有改变。右侧立即求值得到的对象就是生成器,之后左侧名字绑定到它;右侧不是先算好一整份任务结果。
但“生成器是惰性的”也不能理解成右侧什么都没做。最左侧 for 的可迭代表达式会立即求值,并取得其迭代器;元素表达式 task() 随后在推进生成器时才执行。
生成器表达式会立即取得最外层迭代器
def run_case():
print("Running...")
return "PASS"
class Tasks:
def __iter__(self):
print("__iter__")
return iter([run_case, run_case])
print("before expression")
results = (task() for task in Tasks())
print("after expression")
print(next(results))
展开参考输出:先预测,再核对
before expression
__iter__
after expression
Running...
PASS
__iter__ 出现在 after expression 之前,说明构造阶段已取得迭代器;Running... 则等到 next(results) 才出现。下面的展开方式有助于理解时机:
def generate_results(task_iterator):
for task in task_iterator:
yield task()
results = generate_results(iter(tasks))
把 iter(tasks) 放在调用参数里,是为了表现最外层迭代器立即取得的时间点。这个展开用于解释本例,不要求把生成器表达式想成预先存了一份所有结果的隐藏列表。
本例没有过滤条件,因此每次成功取到结果会运行一个任务。带过滤的生成器一次 next 可能检查多个输入;也有生成器始终不耗尽。不要把这个小例子的“一次取值对应一个任务”推广为所有生成器的规则。
规则依据:生成器表达式的求值时机。
17 · 有小括号,为什么不是元组?
你的问题:“(task() for task in tasks) 的右侧为什么不是元组?”要看完整语法。元组表达式中的逗号起决定作用;带 for 的这套语法是生成器表达式。
| 写法 | 结果 |
|---|---|
(1) | 整数;括号用于分组 |
(1,) | 一个元素的元组 |
(1, 2) | 两个元素的元组 |
() | 空元组的特殊写法 |
(num for num in range(3)) | 生成器对象 |
[num for num in range(3)] | 列表推导式,得到列表 |
{num for num in range(3)} | 集合推导式,得到集合 |
完整语法决定对象类型
print(type((1)).__name__)
print(type((1,)).__name__)
print(type(()).__name__)
numbers = (num for num in range(3))
print(type(numbers).__name__)
print(tuple(numbers))
print(tuple(numbers))
展开参考输出:先预测,再核对
int
tuple
tuple
generator
(0, 1, 2)
()
tuple(numbers) 会消费迭代器来构造元组,因此第一次得到 (0, 1, 2),第二次面对已经耗尽的同一个生成器,只得到空元组。想保存函数对象可以用 (run_case, run_case);写成 (run_case(), run_case()) 就会立即调用两次。
类似地,tuple(task() for task in tasks) 虽然内部用了生成器,但外面的 tuple 构造过程会立即消费它,从而执行任务。set(...) 也是调用;空集合写 set(),{} 则是空字典。
18 · print(generator) 为什么两次一样,而不是 0、1?
这是你实际运行过的代码:
number_print = (num for num in range(10))
print(number_print)
print(number_print)
两次都会显示同一个生成器对象的描述,形如 <generator object <genexpr> at 0x...>;其中地址样式的部分会因运行而异。print 没有要求生成器提供下一项,所以不会产生你期待的 0、1。
要取值,写 print(next(number_print))。先执行 next 取得下一项,再把该项交给 print。连续执行两次,输出才会是 0 和 1。
展开可复现实验:核对两次打印没有推进生成器
打印生成器不会推进它
import io
from contextlib import redirect_stdout
number_print = (num for num in range(10))
buffer = io.StringIO()
with redirect_stdout(buffer):
print(number_print)
print(number_print)
lines = buffer.getvalue().splitlines()
assert len(lines) == 2
assert lines[0] == lines[1]
assert lines[0].startswith("<generator object ")
print("same description:", lines[0] == lines[1])
print("generator description:", lines[0].startswith("<generator object "))
print(next(number_print))
print(next(number_print))
展开参考输出:先预测,再核对
same description: True
generator description: True
0
1
StringIO 只是为了收集两次打印出的文字,避免把每次不同的地址写成固定答案;后面的 next 仍得到 0、1,说明前面的打印没有消费它。
number_print 始终绑定到同一个生成器,变化的是生成器内部的进度;它不会在产出 0 后改绑到整数 0。仅看对象描述,也不能判断当前是刚创建、暂停还是已经耗尽。
规则依据:print 与 next;next()。
19 · 生成器也会耗尽吗?“只能遍历一次”该怎么理解?
会。生成器对象就是一种迭代器。同一个有限生成器走到结尾后不会自动从头开始;没有默认值的 next 会抛出 StopIteration,for 则把这个信号作为迭代正常结束。
耗尽后再次迭代不会自动重启
numbers = (num for num in range(3))
print(next(numbers))
print(list(numbers))
print(list(numbers))
print(next(numbers, "DONE"))
print(iter(numbers) is numbers)
try:
next(numbers)
except StopIteration as exc:
print(type(exc).__name__)
展开参考输出:先预测,再核对
0
[1, 2]
[]
DONE
True
StopIteration
第一项 0 已经取走;第一次 list(numbers) 只收集剩下的 1、2,并继续推进到结束;第二次 list(numbers) 得到 []。带默认值的 next(numbers, "DONE") 在耗尽时返回默认值,省略默认值则会抛出异常。
“一次性”不等于只能调用一次 next,也不等于只能写一个 for。你可以先消费一项,暂停,之后继续消费剩余项;耗尽后,才没有后续元素可取。若 for 中提前 break,普通生成器也不会因此自动从头重启,之后通常可以继续推进它。
iter(numbers) is numbers 仍为 True,它不负责重置;已经耗尽的生成器还可以被名字引用、被打印,耗尽不等于对象立即消失。
规则依据:迭代器耗尽约定。
20 · 重新创建生成器,和把旧生成器重置,是同一回事吗?
重新执行生成器表达式,会得到一个新的生成器对象,再把名字绑定到它。原来的对象如果还有别名,仍保持原来的进度或耗尽状态。
新建生成器与重置上游是两件事
tasks = [0, 1]
results = (item for item in tasks)
old = results
print(list(results))
results = (item for item in tasks)
print(results is old)
print(list(results))
print(list(old))
upstream = iter(tasks)
print(list(upstream))
wrapped = (item for item in upstream)
print(list(wrapped))
fresh = (item for item in iter(tasks))
print(list(fresh))
展开参考输出:先预测,再核对
[0, 1]
False
[0, 1]
[]
[0, 1]
[]
[0, 1]
前半部分的 tasks 是列表,可以再次取得新的列表迭代器;results 绑定新的生成器,old 仍指向旧的、已经耗尽的生成器。两者不是一个对象。
后半部分把列表的迭代器 upstream 先消费完了,再用生成器包它,得到的依然是空结果。新包装不会恢复已经耗尽的输入。需要从可重复遍历的原数据重新获取迭代器,或重新打开/构造数据来源。
如果表达式里是 task(),新建生成器再消费,会重新调用这些任务。它不自动记住上一次的结果;任务如果读取时间、网络或外部状态,第二次结果也未必相同。
21 · 想反复查看结果,要保存函数、生成器,还是结果列表?
取决于你希望重复的是“计算”还是“读取已算好的结果”。
| 需求 | 可以保存什么 | 需要知道的代价 |
|---|---|---|
| 之后选择时机再运行任务 | 函数对象,或包含函数的列表 | 每次显式调用都会执行任务 |
| 逐项计算,只处理需要的部分 | 生成器,或任务迭代器加显式调用 | 消费会推进状态;结果不会自动缓存 |
| 反复查看同一批已经计算的结果 | saved = list(results) 等结果容器 | 立即消费剩余项,并占用保存这些结果所需的空间 |
保存结果可以复用,但只收集剩余项
def run_case():
print("Running...")
return "PASS"
tasks = [run_case, run_case]
results = (task() for task in tasks)
saved = list(results)
print("saved once:", saved)
print("saved again:", saved)
print("generator now:", list(results))
numbers = (num for num in range(3))
print("taken:", next(numbers))
print("saved remaining:", list(numbers))
展开参考输出:先预测,再核对
Running...
Running...
saved once: ['PASS', 'PASS']
saved again: ['PASS', 'PASS']
generator now: []
taken: 0
saved remaining: [1, 2]
Running... 只在 list(results) 消费生成器时出现。之后两次打印 saved 都只是读取已保存的结果,不会再次调用任务。最后一段提醒:已经被 next 取走的 0 不会被自动补回,list 只取得尚未消费的 [1, 2]。
如果生成器永远没有结束,直接 list 它就无法正常收集完;对很大的结果流也应考虑内存。所以是否物化为列表,应由“是否需要反复访问全部结果”决定。
规则依据:迭代器消费与惰性计算。
22 · 你的手工笔记:哪些保留,哪些补上条件?
这张表对应你发来的原笔记。原来的观察大部分可以直接保留;修订集中在“是否新建”“何时执行”“是否重置”这几个词。
| 原笔记中的理解 | 整理后的准确表述 | 回看 |
|---|---|---|
| 执行前确定普通函数中名字的作用域类别 —— LEGB | 区分静态类别判定与运行时名字解析;局部未绑定不会自动退到 G | 01 |
| 右侧求值,得到一个新对象 | 得到一个对象;是否新建由右侧表达式决定 | 02 |
| 先求值右侧,再把结果赋给目标 | 保留,适用于这里的普通赋值;与 += 等情况分别看 | 02 |
| == 规则可由 __eq__ 定义;is 判断身份 | 保留,不把身份等同于值比较,也不把内存地址当作语言层定义 | 04 |
| 函数末尾返回 None;打印内容不是返回值 | 保留,明确是普通函数 | 11 |
| return None 还负责提前结束 | 保留;分支中的它可以改成 return,不能随意整行删除 | 11 |
| [[...]] 表示递归引用,没有不断创建列表 | 保留;... 是显示标记,真实元素仍引用原列表 | 08 |
| JSON 默认检查循环引用并报错 | 保留;本例抛 ValueError,关闭检查也不能正常表达这个环 | 08 |
| 循环垃圾回收补充引用计数 | 保留 CPython 的限定,并检查不可达性;不保证立即回收 | 07、08 |
| [run_case] 保存函数,未执行函数体 | 保留;名字查找和列表创建已经发生,调用时机由后续代码决定 | 12 |
| 函数引用与函数对象可以分成名字/对象/关系三层 | 保留;run_case 表达式求值得到函数对象,没有额外的引用包装对象 | 09 |
| results = [run_case()] 绑定整个列表,返回值成为元素 | 保留;Running... 是右侧求值期间函数执行的副作用 | 10 |
| iter() 从可迭代对象创建迭代器 | 改为“取得迭代器”;对符合协议的迭代器返回自身,不保证新建 | 13 |
| 迭代器只能遍历一次,耗尽要重新创建 | 可以分多次消费;耗尽后不自动重启,重新创建还要考虑上游能否重新提供数据 | 19、20 |
| 生成器保留状态、暂停并逐项交出结果 | 保留;函数列表加迭代器也能保存进度,生成器进一步封装结果生产 | 14、15 |
| 函数体包含 yield,调用后先得到生成器对象 | 保留,限定该 def 自身函数体;调用参数仍先求值 | 15 |
| 逗号决定元组,(... for ... in ...) 是生成器表达式 | 保留,补充空元组 () 与 tuple(...) 消费输入的行为 | 17 |
这些修订都围绕一个习惯:先指出是哪一个表达式、哪一个对象、哪一份绑定、哪一次推进,再描述它发生了什么。
23 · 十道复盘题:先解释,再运行
下面是复习提示,答案默认折叠。读过文章、文章已发布,以及自己能独立解释,是不同的状态;学习地图中的复盘标记由你自己确认。
1. 为什么函数里先 print(results),后 results = [],可能连第一句都失败?
函数的整个代码块中有对 results 的绑定操作,且没有 global/nonlocal,所以它被分类为局部名。读取时这个局部名尚未绑定,抛 UnboundLocalError;不会临时借用模块里的 results。
2. a = [];b = a;a = []:创建了几个列表?b 是否改了指向?
两次 [] 创建两个列表。b = a 只建立绑定,不复制;第三句只让 a 绑定新列表,b 仍引用第一个。旧列表仍由 b 保留,因此这一例里两次观察到的 id(a) 不同。
3. 两个列表 == 为 True,能推出 is 为 True 吗?
不能。同样内容可以存在于两个不同列表中。要判断共享对象身份,需要看 is;自定义类型还可以定义自己的相等规则。
4. a.append(a) 之后为什么 len(a) 是 1?... 是真实元素吗?
append 只新增一个引用原列表的元素。... 是递归显示保护标记,真实元素不是 Ellipsis。是否出现无限递归,要看处理它的算法;环本身不是死锁。
5. [run_case]、[run_case()]、run_case() 分别得到什么?
第一项是包含函数对象的列表,不执行任务;第二项先调用,再得到包含返回值的列表;第三项得到函数返回值。函数打印的文字和返回值必须分别追踪。
6. 把 if 分支中的 return None 删除,为什么可能改变行为?
它既返回 None,也让调用提前结束。可以用裸 return 取得相同效果,整行删除后则会继续执行分支之后的语句。
7. 把函数放进列表,再用 next(task_iter)(),是否也是按需执行?
是。列表迭代器保存遍历进度,调用者决定何时调用取出的函数。生成器 results 则可把这两个动作封装起来,使 next(results) 直接返回任务结果;两者不是“一个必然全算,一个才能按需”。
8. results = (task() for task in tasks) 创建时,什么已发生,什么未发生?
已创建生成器,并立即求值最左侧可迭代表达式、取得其迭代器;本例尚未调用 task。推进到元素计算时才执行 task()。右侧已经求值,只是结果为生成器对象。
9. print(g) 两次、next(g) 两次、iter(g) 三者的作用有什么不同?
print(g) 显示同一生成器的对象描述;next(g) 开始或恢复迭代,取得下一项或报告结束;iter(g) 返回同一个生成器,不推进也不重置。
10. 耗尽后重建生成器,是否一定能重新拿到值?怎样避免重复计算?
要看上游。列表可以提供新迭代器,已耗尽的同一个上游迭代器则不能靠新包装恢复。若要复用已计算结果,可以在首次消费时保存它们;list(g) 只收集剩余项,并会使用保存这些项所需的内存。
建议复习方式:第一次只做 1–5,第二次做 6–10;每题用“对象、绑定、执行时机、进度”四个词检查自己的解释。回到 学习地图,可以沿本次新增的两条路径回看相关文章。
24 · 资料、实验范围与下一步
本文依据本次对话与用户提供的手工笔记原创整理。语言规则核对 Python 3.14 官方文档;文章中 22 个标注“独立运行”的完整实验在 CPython 3.12.14 中实际执行,并比对页面显示的预期输出。解释性片段、复盘变式和内存管理说明不声称经过完整跨实现验证。
检查直接提取文章里的代码,而不是只执行另一份相似示例。每个实验独立运行,检查退出码、标准错误、实际输出与显示输出,并记录源代码摘要。可在源码仓库运行 python3 scripts/verify_python_details.py;执行记录保存在 docs/verification/2026-10-20-python-details.json。
- Execution model:名字绑定与解析
- Simple statements:普通赋值、增强赋值与 return
- Data model:身份、类型、容器与生命周期
- copy:赋值不复制、浅复制与深复制
- Function definitions:执行 def 的含义
- Expressions:括号、生成器表达式及 yield
- Built-in functions:id、iter、next、print
- Built-in types:迭代器协议
- inspect:生成器执行状态
- json:循环引用检查;gc:循环垃圾回收
- CPython Memory Management;CPython 列表实现
- 进程资源限制;Linux cgroup 内存限制
- Functional Programming HOWTO:迭代器与生成器
继续学习时可以从 Guidebook 阶段 1:闭包与默认参数理解函数如何保留环境,或者从 阶段 6:生成器状态继续实验 yield 与 return。先能解释本页的小时机差异,再扩展到装饰器、上下文管理器和协程。