正在阅读从 Namespace 出发的 Python 进阶 Guidebook:机制、实验与学习路线
香港天气加载中…
QWeather ↗

从 Namespace 出发的 Python 进阶 Guidebook:机制、实验与学习路线

从今天的 Namespace、Scope 与 LEGB 讨论出发,规划闭包、装饰器、模块、属性、描述符、生成器和异步的学习路线。十个阶段,每阶段有前置条件、动手任务、明确产出、通过标准和回看位置,附九组已运行的参考实验与 Case Runner 综合项目。

从这里开始:把 Guidebook 当作学习时的导航

这份 Guidebook 从你今天围绕 Namespace → Scope → LEGB → global / nonlocal → 闭包 → 递归栈帧的追问出发,回答之后每次学习时最实际的四个问题:现在学什么?怎样动手?怎样判断理解了?遇到困难回看哪里?

从 namespace 入手是合适的,因为函数、模块、闭包和不少测试问题,都与“名字绑定到哪个对象、代码从哪里取得它”有关。继续学习时,我们会逐步补上对象的属性协议与执行状态、资源和调度。它们共同帮助你解释代码;namespace 本身不能包办这些机制。

先备知识是基本变量、函数、参数、条件、循环,以及能运行一个 Python 文件。后面的异常、类和异步会在各阶段列出前置要求。编号表示推荐顺序,真正的前置关系见下一节。不要求你按天完成,也不因为“看懂参考答案”就判定通过。

如何理解页面状态:这份学习指南包含路线、实验与验收任务;Namespace、递归和迭代等已有专门笔记,文中提供直达链接。其余阶段当前由本指南的任务、参考实验和官方文档支持,完整专题可按学习需要继续展开。页面已经发布,不代表你已经学习或掌握其中每一项。

需要完整复习今天的内容,打开 《Python Namespace 与 Scope》;需要知道后面如何推进,使用这份 Guidebook。

路线总览:三种视角,十个阶段

视角遇到代码时先问什么主要覆盖
名字与绑定这个名字属于哪份环境?现在指向哪个对象?作用域、闭包、装饰器、模块、依赖替换
对象与属性点号后面的访问,会触发什么查找和协议?实例与类、绑定方法、property、descriptor
执行状态与控制流哪次执行正在运行或暂停?谁恢复它、谁负责清理?生成器、上下文管理器、协程、任务取消
阶段前置条件这一阶段的产出下一站
0 · 绑定与作用域诊断基础函数和循环一份能解释输出的绑定记录1;也可分支到 3、4、6
1 · 函数对象与闭包0;理解每次调用的独立局部环境回调实验、两个独立计数器2
2 · 装饰器1;函数能作为参数和返回值保留行为的普通与带参日志装饰器3 或综合项目的扩展
3 · 模块与依赖替换0;先补模块导入基础三文件实验和一份查找位置说明综合项目的同步版
4 · 属性与绑定方法0;class、实例、self 基本语法两个实例的状态对照和绑定方法记录5
5 · property 与 descriptor4一个可复用的非负整数属性按实际属性需求应用
6 · 迭代器与生成器0;函数调用和循环控制流一次 next / yield / return 的执行记录7 的生成器实现,或 8
7 · 异常与资源清理try / except / finally;生成器写法另需 6正常、失败、提前返回三条清理路径同步项目;8
8 · 协程、Task 与取消可恢复执行状态;7 的清理与传播一个可取消、可等待清理结束的任务确有并发需求时扩展项目
9 · 综合项目同步版先用 0、3、7;其余按需可测试的 Case Runner 和验收记录根据项目问题选下一专题

推荐第一条主线:0 → 1 → 2 → 3。它最贴近你今天的问题,也能较快用到实际代码中。属性分支 4 → 5 可以独立推进;执行状态分支 6 → 7 → 8 不要求先学完 descriptor。综合项目先做小的同步版,再选择有收益的扩展。

每次怎样学:预测、观察、变式、解释

  1. 带着一个具体问题开始。例如“外层函数返回后,内层函数读取的 count 在哪里?”把预期输出先写下来。
  2. 运行当前实验。独立运行文件;跨模块实验使用自己的目录。记录实际输出,不用上一轮交互环境中残留的变量解释结果。
  3. 只改一个条件。例如将 clear() 换成重新赋值,或将 from-import 换成 import。先预测,再运行,找出规则在什么地方改变了。
  4. 用机制解释差异。把名字、对象、环境、执行时机说清楚。必要时查本阶段的官方文档,而不是把某次输出当成所有情况的规律。
  5. 留下一个可复现的产出。保存代码、预期与实际、一个你曾经预测错的变式;按通过标准决定下一步。

下面是可复制的实验记录模板。遇到问题时,把最小代码、Python 版本、预期与实际一起带回来,就能继续针对机制分析。

实验记录模板
阶段 / 本次问题:
Python 实现与版本:
最小可运行代码:
运行前预测:
实际输出或异常:
涉及的名字 → 所属环境 → 当前对象:
对象是否被修改?名字是否被重新绑定?
何时定义 / 调用 / 暂停 / 恢复 / 清理?
本次只改变的一项条件:
为什么出现变化:
通过标准中已能证明的项:
仍不确定的问题 / 下次从哪里继续:

通过不等于背熟术语。能在不看参考答案时预测基本例子,解释一个变式,并在自己的小程序里重现该机制,就可以继续。暂时卡住时,回退到本阶段明确列出的知识点;无需整条路线重新学习。

阶段 0 · 确认起点:名字、对象、绑定和作用域

核心问题:看到一个名字时,你能否指出它属于哪份环境,以及当前这条语句改变的是哪份绑定或哪个对象?

先区分:namespace 是一组名字到对象的映射;scope 是代码中名字绑定的可见性规则。模块、函数调用和类的命名空间需要分别考虑;if、普通 for 和 with 的缩进不会各自建立新的函数局部作用域。普通函数的 E 来自词法上包围它的函数作用域,可有多层;G 是函数所属的模块全局环境。详见 命名空间归属与 多层 LEGB。

下面一个程序包含三组诊断。先写出每行输出,再分别解释:列表别名与重绑、调用者与定义环境、读取局部名时的异常。

实验:重绑、调用者环境和局部名分类

保存为一个单独的 .py 文件运行;每次实验使用新的进程。

展开实验代码:先预测输出,再运行
Python · 独立运行
results = ["PASS"]
alias = results

def reset():
    results = []

reset()
print("after local reset:", results, alias)
results.clear()
print("after clear:", results, alias)
results = ["NEW"]
print("after rebind:", results, alias)

timeout = 30

def read_timeout():
    return timeout

def caller():
    timeout = 5
    return read_timeout()

print("caller:", caller())

def broken():
    print(timeout)
    timeout = 10

try:
    broken()
except UnboundLocalError:
    print("broken: UnboundLocalError")
展开参考输出与机制说明:预测后再看
after local reset: ['PASS'] ['PASS']
after clear: [] []
after rebind: ['NEW'] []
caller: 30
broken: UnboundLocalError

reset 中的赋值建立该次调用的局部 results,不改变模块绑定。clear 修改原列表,所以 alias 也看到空列表;之后模块 results 重新绑定到新列表,alias 仍指向旧的空列表。read_timeout 定义在模块中,caller 的局部 timeout 不会成为它的 E。broken 中有对 timeout 的绑定操作且没有 global/nonlocal 声明,因而 timeout 被判为该函数的局部名;执行读取时尚未绑定,抛出 UnboundLocalError。

容易误解:LEGB 不能理解成“局部当前没值就继续往外找”。局部名的判定会考虑该代码块中的绑定操作,而不只是执行到当前位置的语句。

官方依据 1;官方依据 2。

动手任务与产出

  1. 给程序中的每个 results 标上“模块”或“reset 的本次调用”,画一张名字与列表的对应表。
  2. 在 reset 中加入 global results,保留赋值。重新预测 alias 的去向,并运行验证。
  3. 回到 E/G 同名实验,分别运行 global 与 nonlocal 版本;指出究竟改变了哪一层。

需要保留的一句话:global 指向 G;nonlocal 指向词法上最近的、把该名字确定为局部绑定的外层函数作用域 E。这项选择依据代码结构,不要求外层赋值已经执行;若读取时仍没有值,仍可能报错。nonlocal 不会退到 G,也不能理解成按调用栈逐层寻找。

通过标准

  • 能解释同一列表的两个名字为什么一起看到 clear() 的结果,而重新绑定其中一个名字不会自动移动另一个绑定。
  • 能明确说出 E 与 G 的区别,解释多层 E 的查找关系,以及 global 为何越过同名 E。
  • 能预测 broken 的异常,并说明这里不会临时退到 G 取值。

卡住时回看:修改对象与重绑、函数的全局环境、名字相关异常。通过后进入阶段 1。

阶段 1 · 函数对象、闭包与默认参数

前置:阶段 0;能区分函数定义与函数调用。核心问题:函数作为返回值离开工厂函数后,还能通过什么继续取得外层信息?

函数也是对象,可以作为参数传入、赋给另一个名字、放入列表或作为返回值。闭包使函数能够访问所需的外层函数绑定;默认参数则在每次执行函数定义时求值,并把结果对象保存为默认值。这两种机制都能让以后调用用到信息,但它们保留的信息关系不同。参见 函数对象的数据模型和 默认参数。

实验:同一个外层绑定,为什么三个函数都读到 2?

保存为一个单独的 .py 文件运行;每次实验使用新的进程。

展开实验代码:先预测输出,再运行
Python · 独立运行
def make_readers():
    late = []
    fixed = []
    for i in range(3):
        def read_late():
            return i

        def read_fixed(value=i):
            return value

        late.append(read_late)
        fixed.append(read_fixed)
    return late, fixed


late, fixed = make_readers()
print("late:", [read() for read in late])
print("fixed:", [read() for read in fixed])
print("same cell:", late[0].__closure__[0] is late[1].__closure__[0])
print("defaults:", [read.__defaults__ for read in fixed])
展开参考输出与机制说明:预测后再看
late: [2, 2, 2]
fixed: [0, 1, 2]
same cell: True
defaults: [(0,), (1,), (2,)]

三个 read_late 函数来自同一次 make_readers 调用,使用同一个外层 i 绑定;for 每轮会重新绑定 i,但不会为每轮建立新的函数作用域。循环结束后调用它们,都读到最终的 2。read_fixed 的默认值表达式 i 则在各次 def 执行时求值,调用时参数 value 使用对应默认对象。主例直接观察 closure cell 身份和 __defaults__,没有依赖顶层 global i。

容易误解:默认参数保存的是定义时得到的对象引用,不是自动深复制;把默认值用于这组整数能固定各次取值,不代表它会冻结任意可变对象。lambda 并不是这一行为的成因。

官方依据 1;官方依据 2。

动手任务与产出

  1. 在 make_readers 返回之前、循环结束之后加入 i = 9。先预测 late 与 fixed 的结果,再运行。
  2. 实现一个 make_counter(start=0):返回的函数每次把 count 加一再返回。使用 nonlocal;分别创建 a 与 b,连续调用 a、a、b、a,检查两次工厂调用的状态是否独立。
  3. 写一个默认参数为列表的小函数,连续两次调用;再改成默认值 None、在函数体中新建列表。解释为什么两种写法的共享行为不同。

产出:回调与计数器两个小文件,加一张“闭包绑定 / 默认值对象 / 本次调用参数”的对照表。默认参数保留对象引用,不会自动深复制或冻结可变对象。

通过标准

  • 能解释为何 late 是 [2, 2, 2]、fixed 是 [0, 1, 2],并预测把外层 i 改为 9 的结果。
  • 两个从 0 开始的计数器按 a、a、b、a 调用,能得到 1、2、1、3;能说明独立状态来自两次工厂调用。
  • 能解释外层调用已经返回与所需绑定仍被闭包保留可以同时成立,而不声称整个调用栈一直没退出。

卡住时回看:闭包、绑定和对象的生命周期;如果混淆“每次调用”,回看 递归中的局部绑定。下一步进入阶段 2。

阶段 2 · 装饰器:追踪函数对象的包装与绑定

前置:阶段 1;理解函数能接收和返回函数。核心问题:经过装饰以后,定义的名字绑定到什么对象?原函数何时运行?

先手写“定义原函数,再执行 run_case = traced(run_case)”的过程,观察对象关系;再改用 @traced。这种展开有助理解包装和重绑,实际装饰语法的细节以 函数定义规则为准。装饰器也可以返回其他合适的对象,不要求所有装饰器都使用闭包。

实验:装饰发生在什么时候,原函数又保存在哪里?

保存为一个单独的 .py 文件运行;每次实验使用新的进程。

展开实验代码:先预测输出,再运行
Python · 独立运行
from functools import wraps

print("module: before definition")

def traced(func):
    print("decorate:", func.__name__)

    @wraps(func)
    def wrapper(*args, **kwargs):
        print("wrapper:", func.__name__)
        return func(*args, **kwargs)

    return wrapper


@traced
def run_case(case_id):
    print("body:", case_id)
    return "PASS"


print("module: after definition")
print("public name:", run_case.__name__)
print("original body:", run_case.__wrapped__.__code__.co_name)
print("wrapper body:", run_case.__code__.co_name)
original = run_case.__closure__[0].cell_contents
print("original retained:", original is run_case.__wrapped__)
print("result:", run_case("case-01"))
展开参考输出与机制说明:预测后再看
module: before definition
decorate: run_case
module: after definition
public name: run_case
original body: run_case
wrapper body: wrapper
original retained: True
wrapper: run_case
body: case-01
result: PASS

模块执行到带装饰器的 def 时,traced 接收新建的原函数并返回 wrapper,run_case 最终绑定到 wrapper。此时原函数函数体尚未运行。wrapper 的闭包保留 func;functools.wraps 同时更新名称等元数据,并提供 __wrapped__ 指向被包装函数。__name__ 显示 run_case 并不表示当前调用直接执行原函数体,主例通过 __code__.co_name 区分二者。

容易误解:wraps 维护元数据并建立 __wrapped__,不会把 wrapper 变回原函数。将 @traced 口头展开为 def 后赋值便于学习,但真实装饰语法不会先把原函数临时绑定到 run_case 再装饰。闭包也是装饰器的常见实现,并非唯一实现。

官方依据 1;官方依据 2;官方依据 3。

动手任务与产出

  1. 把日志分成“执行定义并应用装饰器”和“后来调用函数”两组。增加第二次 run_case 调用,检查哪些日志重复。
  2. 把 traced 改成带参数的 traced(prefix),支持 @traced("CASE")。分别记录工厂调用、应用装饰器、调用 wrapper 三个时机。
  3. 让原函数接收位置参数和关键字参数,返回一个你可以比较身份的对象;再写一个抛出指定异常实例的原函数,检查包装后结果与异常是否保持约定。

产出:普通和带参日志装饰器,以及一张时间表。每行写“正在执行哪一层、收到了哪个对象、返回了什么”。先把单层包装解释清楚,再尝试叠加两个装饰器。

本阶段的设计约定是只增加调用日志,保留原函数的参数、返回值与异常传播。functools.wraps 帮助保留名称、文档等元数据,并提供 __wrapped__;转发是否正确,仍由 wrapper 的代码决定。参见 wraps 官方说明。

通过标准

  • 能指出外部名字 run_case、wrapper 的 func、__wrapped__ 的对象关系。
  • 能解释第二次普通调用为什么再次执行 wrapper 和原函数,而不重新应用装饰器。
  • 包装后的函数能够传递位置参数和关键字参数、返回原结果;失败时同一异常按约定向上传播。
  • 能解释带参版本的三个时机,而不是仅能照写三层 def。

卡住时回看:不会解释“为什么能返回函数”就回阶段 1;分不清包装后的名字就回阶段 0。下一步建议阶段 3,把名字解析用于实际的依赖替换。

阶段 3 · 模块、导入与测试中的依赖替换

前置:阶段 0 与模块基础;可先读 导入模块的两文件实验。核心问题:被测函数真正从哪里取得它要调用的依赖?

两个模块可以各有一个 fetch 名字,最初都指向同一个函数对象。后来替换其中一份绑定,不会自动改掉另一份。函数的 __globals__ 关联其定义模块的全局命名空间;导入这个函数不会把它的 G 改成调用方模块。参见 已有跨模块笔记。

实验:为什么 patch 了定义模块,被测函数仍然调用原实现?

把下面三个文件保存到同一新目录,运行 python main.py。dependency.py 是本地假依赖。

展开实验代码:先预测输出,再运行
dependency.py
def fetch():
    return "ORIGINAL-STUB"
sut.py
from dependency import fetch


def run_case():
    return fetch()
main.py
from unittest.mock import patch
import dependency
import sut

print("initial:", sut.run_case())
print("same before:", sut.fetch is dependency.fetch)
with patch("dependency.fetch", return_value="PATCHED-DEFINITION"):
    print("dependency patched:", dependency.fetch())
    print("sut unchanged:", sut.run_case())
    print("same inside:", sut.fetch is dependency.fetch)
print("definition restored:", sut.fetch is dependency.fetch)

with patch("sut.fetch", return_value="PATCHED-LOOKUP"):
    print("sut patched:", sut.run_case())
    print("dependency unchanged:", dependency.fetch())
print("sut restored:", sut.run_case())
print("same after:", sut.fetch is dependency.fetch)
展开参考输出与机制说明:预测后再看
initial: ORIGINAL-STUB
same before: True
dependency patched: PATCHED-DEFINITION
sut unchanged: ORIGINAL-STUB
same inside: False
definition restored: True
sut patched: PATCHED-LOOKUP
dependency unchanged: ORIGINAL-STUB
sut restored: ORIGINAL-STUB
same after: True

sut 已经执行 from dependency import fetch,所以 sut.fetch 与 dependency.fetch 起初是两个命名空间中指向同一原函数的绑定。patch dependency.fetch 只临时改变 dependency 模块的属性绑定;sut.run_case 中的裸名 fetch 在 sut 的全局命名空间查找,因此仍使用 sut.fetch。patch sut.fetch 才替换它实际查找的绑定;退出 with 后恢复原绑定。dependency.fetch 只是返回字符串的假依赖,全例不发起网络请求。

容易误解:本主例先导入 sut,再开始 patch。若在首次导入 sut 之前已经替换依赖,from-import 可能取得替换后的对象;不能把结论概括为“patch 定义模块永远无效”。三个文件必须放在同一目录并运行 main.py。

官方依据 1;官方依据 2;官方依据 3。

动手任务与产出

  1. 记录主例每一阶段的 dependency.fetch 与 sut.fetch 分别指向什么;先导入 sut 再 patch 的顺序要写进记录。
  2. 把 sut.py 改成 import dependency,并在 run_case 中调用 dependency.fetch()。重新决定 patch 目标,再验证恢复行为。
  3. 进阶变式:将依赖显式作为参数传入 run_case,调用时传本地 fake。与 patch 对比,解释哪种方式更容易看清依赖关系。

产出:一个三文件目录,以及一份“调用表达式 → 查找位置 → 替换目标 → 退出后恢复”的记录。这里只需要标准库 unittest.mock;不需要先引入完整测试框架或真实外部服务。

避免机械背目标字符串:官方建议是在对象被查找的位置 patch。若依赖早已被保存为默认参数或闭包中的引用,随后修改原模块名字可能影响不到那份已有引用,应该继续追踪对象关系。参见 Where to patch。

通过标准

  • 能在运行前从 sut 的调用表达式指出本例为什么应该 patch sut.fetch。
  • 改成 dependency.fetch() 后,能重新定位有效目标;退出 with 后能证明依赖已恢复。
  • 能给出“显式传 fake”和“替换命名空间中的绑定”两种方案,并解释各自如何进入被测代码。

卡住时回看:G 的归属回阶段 0;已保存的默认值与闭包引用回阶段 1。走到这里已经完成第一条主线,可以进入属性分支,也可以先补阶段 7,做综合项目的同步版。

阶段 4 · 从裸名走到属性:实例、类与绑定方法

前置:阶段 0;能创建类、实例并理解 self 参数。核心问题:device 与 device.describe 分别需要什么规则来解释?

先解析裸名 device 得到对象,随后进行属性访问。普通类中的函数通过实例访问时,可以产生绑定方法:其中 __self__ 关联实例,__func__ 关联原函数。普通方法中的裸名不会自动搜索类属性,应区分 timeout 与 self.timeout。参见 实例方法。

下面的实验同时出现 property。此阶段先观察 getter/setter 何时被调用;它背后的 descriptor 机制在下一阶段展开。

实验:属性访问为什么可以生成绑定方法,或者执行 getter/setter?

保存为一个单独的 .py 文件运行;每次实验使用新的进程。

展开实验代码:先预测输出,再运行
Python · 独立运行
class Device:
    def __init__(self, timeout):
        self.timeout = timeout

    @property
    def timeout(self):
        print("get timeout")
        return self._timeout

    @timeout.setter
    def timeout(self, value):
        print("set timeout:", value)
        if value < 0:
            raise ValueError("timeout must be nonnegative")
        self._timeout = value

    def describe(self):
        return f"timeout={self.timeout}"


device = Device(3)
bound = device.describe
print("bound self:", bound.__self__ is device)
print("bound function:", bound.__func__ is Device.describe)
print("method result:", bound())
device.timeout = 5
print("property result:", device.timeout)
print("stored values:", vars(device))
try:
    device.timeout = -1
except ValueError as exc:
    print(type(exc).__name__ + ":", exc)
print("after rejection:", device.timeout)
展开参考输出与机制说明:预测后再看
set timeout: 3
bound self: True
bound function: True
get timeout
method result: timeout=3
set timeout: 5
get timeout
property result: 5
stored values: {'_timeout': 5}
set timeout: -1
ValueError: timeout must be nonnegative
get timeout
after rejection: 5

device.describe 的属性访问得到绑定方法,__self__ 是 device,__func__ 是类中原函数;调用 bound() 会向原函数补入该实例。timeout 是类中的 property 对象:读取 device.timeout 调用 getter,赋值调用 setter;这个类选择把实际数值保存在实例的 _timeout 属性。负值被拒绝后,已存的 5 保持不变。vars(device) 在主例中只用于观察,没有绕过属性协议写入。

容易误解:LEGB 描述裸名解析,不能完整解释 device.timeout 的属性协议。这里的 _timeout 是本类选择的存储约定,property 并不要求所有类都使用这个名字。普通类方法中的裸名也不会自动在类属性中查找。

官方依据 1;官方依据 2;官方依据 3。

动手任务与产出

  1. 分别调用 device.describe() 与 Device.describe(device),比较结果并解释实例参数来自哪里。
  2. 创建两个 Device 实例,修改其中一个 timeout。记录共享的类中函数、property 对象,以及每个实例自己的数值。
  3. 增加普通类属性 category = "generic",再对一个实例赋值 device.category = "special"。比较两个实例与类上的 category。
  4. 定义一个继承 Device 的小子类,打印该类 __mro__ 中各类的名字。先理解继承查找次序,不要求推导 C3 算法。

产出:一张“对象 / 属性名 / 从哪里取得 / 是否触发方法”的记录表。普通实例属性遮蔽同名普通类属性的实验,要与 property 的实验分别记录。

通过标准

  • 能说明 device 这个裸名的查找,与随后 .describe 或 .timeout 的属性行为是两件事。
  • 能用 __self__ 和 __func__ 解释绑定方法,而不是认为函数被复制进每个实例。
  • 能解释普通属性遮蔽和两个实例的状态独立;知道 MRO 确定类查找的次序。

卡住时回看:裸名、属性与观察工具。不要把“遍历几个 __dict__”当成完整的属性机制;下一阶段会解释为什么。

阶段 5 · property 与 descriptor:属性访问也是协议

前置:阶段 4。核心问题:当一个属性需要在读取、写入时执行逻辑,怎样提供可理解、可复用的行为?

从真实需求开始:timeout 或 retries 需要拒绝不合要求的输入。先用 property 完成一个属性的 getter/setter;当多个字段需要相似规则时,再考虑 descriptor。描述符对象放在类属性中,其类型上的 __get__、__set__ 或 __delete__ 参与相应访问。property 与普通函数的方法绑定都与描述符协议有关。参见 Descriptor Guide。

实验:同一个描述符怎样管理两个实例的独立值?

保存为一个单独的 .py 文件运行;每次实验使用新的进程。

展开实验代码:先预测输出,再运行
Python · 独立运行
class NonNegative:
    def __set_name__(self, owner, name):
        self.storage = "_" + name

    def __get__(self, instance, owner=None):
        if instance is None:
            return self
        return instance.__dict__[self.storage]

    def __set__(self, instance, value):
        if type(value) is not int or value < 0:
            raise ValueError("expected a nonnegative int")
        instance.__dict__[self.storage] = value


class Case:
    retries = NonNegative()

    def __init__(self, retries):
        self.retries = retries


a, b = Case(1), Case(3)
a.__dict__["retries"] = 99
print("reads:", a.retries, b.retries)
a.retries = 2
print("after write:", a.retries, b.retries)
print("class access:", isinstance(Case.retries, NonNegative))
try:
    a.retries = -1
except ValueError as exc:
    print(type(exc).__name__ + ":", exc)
print("stored:", vars(a))
展开参考输出与机制说明:预测后再看
reads: 1 3
after write: 2 3
class access: True
ValueError: expected a nonnegative int
stored: {'_retries': 2, 'retries': 99}

retries 中的 NonNegative 对象放在类上,定义了 __get__ 和 __set__,属于数据描述符。它保存存储字段名,具体值则写到各自实例的 _retries 中,因此 a 与 b 不会共用一个数值。常规实例属性访问中,类查找所选出的数据描述符优先于实例字典中的同名条目,所以诊断用的 retries=99 没有接管 a.retries 的读取。类访问传入 instance=None,本例选择返回描述符自身。

容易误解:不要把每个实例的值直接存在共享描述符的 self.value 中。此实现有意只支持有 __dict__、经构造器完成初始化的普通实例;校验要求非负 int,明确拒绝 bool。直接写 __dict__ 在这里只用于观察查找优先级,不是日常赋值方式。

官方依据 1;官方依据 2。

动手任务与产出

  1. 先不看描述符代码,用 property 写出非负整数 retries。明确 0、负数、字符串、bool 各应怎样处理,再实现校验。
  2. 阅读并运行描述符版,说明 descriptor 自身保存的字段名与实例保存的数值分别是什么。
  3. 添加 timeout = NonNegative(),在构造器初始化;同时测试两个字段、两个实例,检查是否互相覆盖。

产出:property 版本与可复用 descriptor 版本,附有效赋值、无效赋值和实例隔离的验证。描述符负责复用访问规则;是否值得引入,要看它是否解决了实际重复。

需要理解的查找关系:在常规 object.__getattribute__ 的实例访问中,先沿 MRO 确定类中的候选属性;候选若为数据描述符,优先于实例字典中的同名条目。否则实例属性可以优先于非数据描述符或普通类属性。自定义访问逻辑会影响行为,因而不能把这段关系当作所有对象通用的字典搜索程序。参见 官方实例访问说明。

通过标准

  • 能解释 property 为什么可以在赋值时执行校验,并指出实际值存放的位置。
  • 能解释主例中 99 为什么没有成为 a.retries 的读取结果,以及两个实例为何保持独立。
  • 非法输入被拒绝后,已保存的合法值仍保持;能说明这来自先验证、后写入的实现。

卡住时回看:实例与类混淆就回阶段 4;装饰形式不清楚就回阶段 2。完成这里以后,无需为了继续主线立即学习元类,先把属性机制用在有明确需求的地方。

阶段 6 · 迭代器与生成器:局部状态如何暂停和恢复

前置:函数调用、循环、阶段 0;可结合 for 与迭代器、调用栈帧。核心问题:函数的执行暂停以后,下一次从哪里开始,之前的局部状态如何继续使用?

先区分三个角色:iterable 可通过 iter() 取得 iterator;iterator 可由 next() 推进,耗尽时抛出 StopIteration;生成器是实现迭代器协议的一种方式。调用生成器函数会返回生成器对象,函数体在后续推进时开始执行。参见 迭代器协议与 yield 表达式。

实验:生成器暂停后,下一次执行从哪里继续?

保存为一个单独的 .py 文件运行;每次实验使用新的进程。

展开实验代码:先预测输出,再运行
Python · 独立运行
from inspect import getgeneratorstate


def case_stream():
    completed = 0
    print("body: start")
    completed += 1
    yield f"case-{completed}"
    print("body: resume")
    completed += 1
    yield f"case-{completed}"
    return completed


stream = case_stream()
print("created:", getgeneratorstate(stream))
print("next:", next(stream))
print("paused:", getgeneratorstate(stream))
print("next:", next(stream))
print("paused:", getgeneratorstate(stream))
try:
    next(stream)
except StopIteration as exc:
    print("return value:", exc.value)
print("finished:", getgeneratorstate(stream))
展开参考输出与机制说明:预测后再看
created: GEN_CREATED
body: start
next: case-1
paused: GEN_SUSPENDED
body: resume
next: case-2
paused: GEN_SUSPENDED
return value: 2
finished: GEN_CLOSED

case_stream() 创建生成器对象时尚未执行函数体,所以首先观察到 GEN_CREATED。next 开始或恢复执行,yield 把值交给调用者并保留暂停位置及所需局部状态,因此 completed 能从 1 继续到 2。第三次 next 执行 return completed,以 StopIteration.value 暴露最终值 2,生成器状态变为 GEN_CLOSED;return 的 2 不是第三个 yield 元素。

容易误解:生成器的函数调用、执行到 yield、函数最终结束是三个不同事件。要重新遍历需要新建生成器;GEN_RUNNING 也是合法状态,但本例只在生成器外、运行间隙查询,所以实际观察到的是 CREATED、SUSPENDED 和 CLOSED。

官方依据 1;官方依据 2;官方依据 3。

动手任务与产出

  1. 记录创建、第一次 next、第二次 next、第三次 next 的事件;每步写下 completed 的值、暂停位置和交给调用者的内容。
  2. 用 for 遍历一个新生成器,观察两次 yield 与最终 return 的差别。再尝试遍历已经耗尽的同一个生成器。
  3. 使用简单列表比较两次 iter(list) 与对同一个 iterator 连续调用 iter() 的关系。说明“可再次遍历”与“恢复同一个进度”有何不同。

产出:一份执行记录与一个逐个产生 case ID 的生成器。先掌握 next / yield / return,再按需求学习 send、throw、yield from。

通过标准

  • 能解释生成器创建时为什么还没出现 body: start,而不是将它当作普通函数已经执行完。
  • 能说明 completed 和暂停位置如何保留;第二次 next 不会从函数开头重来。
  • 能区分 yield 元素与 StopIteration.value 中的 return 结果,并解释耗尽后为何不自动重启。

容易误解:生成器有 CREATED、RUNNING、SUSPENDED、CLOSED 四种检查状态;主例在外部暂停点观察,只展示其中三种。保留执行状态本身不会让代码自动并发。参见 状态检查。

卡住时回看:局部变量为何保持,回阶段 0 与递归的每次调用;执行如何继续,回 调用者恢复位置。下一步进入阶段 7。

阶段 7 · 异常与上下文管理器:把清理责任写清楚

前置:try / except / finally;下方生成器写法还需阶段 6。核心问题:正常完成、异常或提前返回时,由谁结束资源使用?异常是否继续传播?

先用 try / finally 表达清理,再理解 with 的进入与退出协议。常规上下文管理器提供 __enter__ 与 __exit__;contextlib.contextmanager 则把只 yield 一次的生成器函数适配为这种协议。上下文管理器不一定是生成器。参见 with 语句与 contextmanager。

实验:正常结束和抛出异常时,资源为什么都能清理一次?

保存为一个单独的 .py 文件运行;每次实验使用新的进程。

展开实验代码:先预测输出,再运行
Python · 独立运行
from contextlib import contextmanager


@contextmanager
def case_resource(name):
    resource = {"name": name, "closed": False, "release_count": 0}
    print("acquire:", name)
    try:
        yield resource
    finally:
        resource["closed"] = True
        resource["release_count"] += 1
        print("release:", name, resource["release_count"])


with case_resource("normal") as first:
    print("body:", first["name"])
print("normal state:", first["closed"], first["release_count"])

try:
    with case_resource("error") as second:
        print("body:", second["name"])
        raise RuntimeError("case failed")
except RuntimeError as exc:
    print("propagated:", exc)
print("error state:", second["closed"], second["release_count"])
展开参考输出与机制说明:预测后再看
acquire: normal
body: normal
release: normal 1
normal state: True 1
acquire: error
body: error
release: error 1
propagated: case failed
error state: True 1

contextmanager 把一次 yield 的生成器函数适配为上下文管理器。进入 with 时运行至 yield,yield 的资源对象绑定给 as 后的名字;离开 with 时恢复生成器。正常与异常路径都经过 finally,因此两个模拟资源各自的 release_count 都为 1。异常路径把 with 内异常送回暂停的 yield 位置,finally 清理后异常仍传播到外层 except。字典模拟资源状态,主例没有打开文件或网络连接。

容易误解:如果生成器捕获异常后不重新抛出,contextmanager 会把异常视为已经处理。本例演示 Python 正常控制流及异常传播的清理,不宣称进程被强制终止后仍能执行 finally。

官方依据 1;官方依据 2;官方依据 3。

动手任务与产出

  1. 把主例的成功与失败日志分别写成进入、执行主体、清理、离开的序列。
  2. 增加一个函数,在 with 内提前 return 资源对象,函数外检查 closed 与 release_count。
  3. 在 contextmanager 的 try 与 finally 之间加入 except RuntimeError,只打印而不 raise;观察外层是否还接到异常。然后重新 raise,验证传播恢复。
  4. 把同一资源写成带 __enter__ / __exit__ 的类;显式返回 False 表示不抑制主体异常。

产出:一个本地 fake resource,覆盖正常、失败、提前 return 三条路径。分别记录清理次数与异常去向;资源关闭和对象继续被引用可以同时发生。

通过标准

  • 三条路径中,成功取得的每个资源都恰好清理一次;检查实际状态而不只看一条日志。
  • 能解释清理完成并不等于异常被吞掉,知道 __exit__ 返回真值会抑制传入的异常。
  • 能说明 with 不会新建函数局部作用域,as 绑定的名字为何可能在块外仍然可见。

边界:这些保证讨论正常 Python 控制流;资源还未成功取得或进入失败时,要另行设计清理责任。如果练习生成器 close(),先明确是否已执行进入对应 try 区域,不能假定未启动的生成器也会执行该 finally。

卡住时回看:异常基础见 清理动作;生成器的恢复回阶段 6。此时已经可以完成阶段 9 的同步版;有并发需求再进入阶段 8。

阶段 8 · 协程、Task 与取消:加入调度这层机制

前置:阶段 6 的可恢复执行状态,以及阶段 7 的异常传播和清理;不依赖 descriptor。核心问题:谁推进协程?哪些代码可能交错?取消后怎样确认清理已经结束?

协程对象、Task 和事件循环分别承担不同角色。调用协程函数创建协程对象;Task 负责让协程在事件循环中运行。await 可以等待协程或其他可等待对象,不意味着每次都新建一个 Task,也不保证每次都会实际暂停。同步阻塞代码放进 async def,仍可能阻塞事件循环。参见 Coroutines and Tasks。

实验:创建协程、调度任务和等待取消清理

保存为一个单独的 .py 文件运行;每次实验使用新的进程。

展开实验代码:先预测输出,再运行
Python · 独立运行
import asyncio


async def probe(ready, gate, events):
    events.append("started")
    try:
        ready.set()
        await gate.wait()
        events.append("finished")
    finally:
        events.append("cleanup")


async def main():
    ready, gate = asyncio.Event(), asyncio.Event()
    events = []
    coro = probe(ready, gate, events)
    print("after coroutine creation:", events)
    task = asyncio.create_task(coro)
    await ready.wait()
    print("after task starts:", events)
    print("done before cancel:", task.done())
    task.cancel()
    try:
        await task
    except asyncio.CancelledError:
        print("cancel observed")
    print("events:", events)
    print("task state:", task.done(), task.cancelled())


asyncio.run(main())
展开参考输出与机制说明:预测后再看
after coroutine creation: []
after task starts: ['started']
done before cancel: False
cancel observed
events: ['started', 'cleanup']
task state: True True

调用 probe 只创建协程对象,此时 events 仍为空。create_task 把协程交给事件循环调度;ready 使观察者确定 probe 已经开始,未被设置的 gate 使它停在等待中。cancel 请求取消,随后 await task 等待任务实际结束;取消进入协程,finally 记录 cleanup,CancelledError 继续传播到等待方。gate 没有放行,所以 finished 没有发生。

容易误解:取消是协作过程,不是强制在任意指令处终止。任务需要得到继续执行的机会,代码也可能捕获或延迟处理取消。本例使用 Event 确定执行顺序,不依赖 sleep 时长或谁先获得时间片的猜测。

官方依据 1;官方依据 2;官方依据 3。

动手任务与产出

  1. 区分“创建协程对象”“交给 Task 调度”“等待 ready”“请求取消”“等待取消完成”五个事件,逐行解释日志。
  2. 写成功路径:收到 ready 后调用 gate.set(),await task 正常完成,检查 finished 与 cleanup 都出现且清理只有一次。
  3. 再创建两个 probe,各有自己的 gate,让它们都到达 ready 后分别放行;用事件记录交错,区分“自己的局部名字”与“共同引用的 events 列表”。

产出:一个使用本地事件协调的异步实验,至少包含成功和取消两条路径。先能解释一个任务如何结束,再按需求加入 TaskGroup、超时与并发上限;不需要先连接真实服务器。

通过标准

  • 能区分 coroutine 与 Task,解释为什么单独调用 async 函数还没有完成任务。
  • 能说明取消请求与任务已经结束的区别;请求后 await 任务,并证明清理执行且取消状态符合预期。
  • 能解释事件循环在可等待点推进任务,知道 CPU 密集计算或同步阻塞不会因 async 关键字自动提速。

容易误解:asyncio.CancelledError 继承 BaseException;普通 except Exception 不会捕获它。通常通过 finally 清理;若显式捕获取消,完成清理后通常应继续传播。随意吞掉它会影响 TaskGroup、超时等依赖取消的结构。参见 取消规则。

卡住时回看:暂停和恢复回阶段 6,finally 与异常传播回阶段 7,多个任务引用同一个列表回阶段 0。这里需要调度和控制流的解释,不能只用 LEGB 说明所有行为。

阶段 9 · 综合项目:可测试的 Case Runner

项目目标:用一个小程序,把名字绑定、依赖替换、结果记录和资源清理连在一起。先实现同步版;后续功能按需求添加。下面是供你完成的项目任务与验收契约,参考实验通过不代表这个项目已经由你实现或验收。

前置:阶段 0、3、7 足够开始。整个项目使用本地假依赖和字典模拟资源;先保持为少量函数,数据也先用普通列表与字典。是否引入类、装饰器或 descriptor,由实际需求决定。

先确定行为契约

部分同步版约定为什么这样规定
输入每个 case 由唯一 case_id 与一个 callable 组成;case 接收本次资源对象。让函数对象和每个结果的身份都可追踪。
成功与失败case 返回 True 记为 PASS,返回 False 记为 FAIL;其他返回类型视为协议错误,记为 ERROR。先明确成功条件,再实现执行器。
结果run_suite 每次返回新列表;每项包含 case_id、status、detail,按输入顺序排列。不让前次调用的结果污染本次运行。
依赖资源工厂作为显式参数传入;测试传本地 fake,记录取得和释放事件。依赖来源清楚,能够验证行为。
资源每个 case 独立取得资源;成功进入上下文后,正常返回或抛异常都调用清理逻辑一次;清理失败记为 ERROR。清理责任不依赖 case 是否成功。
普通异常case 或资源操作抛出 Exception 时记为 ERROR,detail 记录类型与信息,继续后续 case。单个 case 的失败不会丢掉后续结果;清理失败也不能记为 PASS。
终止信号不把 KeyboardInterrupt、SystemExit 等 BaseException 一并转换为普通 ERROR;按已有清理机制退出。将“一个 case 出错”与“本次执行被终止”分开处理。

这些是本项目的设计约定,不是 Python 对所有测试执行器的规定。输入检查先处理 case_id 与 callable 的基本有效性;若取得资源前已经失败,不应伪造一次“已取得资源的释放”事件。资源工厂还要负责进入失败前已部分取得的资源。调用了清理逻辑不代表清理必然成功,成功与失败要分别记录。

按可运行版本推进

  1. 版本 A:最小同步执行器。先写本地 case 与 run_suite,得到正确的 PASS / FAIL / ERROR 和独立结果列表。此时就运行验收样例。
  2. 版本 B:资源与可替换依赖。接入阶段 7 的资源工厂,用显式 fake 验证清理;另写一个小实验练习阶段 3 的 patch,并说明两种方式实际改变什么。
  3. 版本 C:有需要的扩展。需要统一日志时加入阶段 2 的装饰器;输入很多且逐个产生时使用阶段 6 的生成器;确有可重叠等待时,再完成阶段 8 后增加异步版。

源码可以从 cases.py、resources.py、runner.py 与一个验收文件开始。不要在模块导入时自动执行整套测试;用已学过的入口保护来明确启动时机。

验收时至少证明这些行为

情景需要观察到的结果
一个返回 True、一个返回 False、一个抛 ValueError 的 case按顺序得到 PASS、FAIL、ERROR,三项都有正确 case_id;每个成功取得的资源各清理一次。
错误 case 后面还有一个成功 case后一个仍执行并记录 PASS。
连续运行两次 run_suite返回两个不同的列表;第二次不包含第一次残留结果。
换入另一个本地资源工厂case 使用了新的 fake;通过参数记录或身份比较证明,而不只依赖“测试没报错”。
资源取得失败与清理失败分别记录 ERROR;能区分资源是否已经成功取得,清理失败不被成功返回值掩盖。
增加日志包装相同输入仍得到约定的结果与异常行为,只增加设计要求的日志。
可选异步版本结果始终能对应 case_id;取消后等待任务清理结束;并发完成次序不被误当成身份映射。

通过标准

  • 能拿出可运行代码和验收记录,说明结果隔离、依赖替换与清理分别由哪段机制保证。
  • 能独立解释一个失败案例,定位是名字绑定、对象共享、属性行为还是执行控制流的问题。
  • 为每个加入的高级机制指出具体需求;没有需求时保留简单实现。

最终产出:一个小型 Case Runner、运行说明、验收记录,以及一段复盘:最初你会怎样写,理解这些机制后在哪些地方做出了不同选择。

卡住时回哪里,以及什么时候再扩展

你遇到的现象先检查什么回看位置
“我改了变量,另一个地方怎么没变?”是对象被修改,还是某个名字被重绑?两个名字还指向同一个对象吗?阶段 0
“几个回调为什么结果都一样?”它们共享哪个绑定?默认参数在哪次定义时求值?阶段 1
“还没调用函数,怎么就打印了?”这段代码在装饰时运行,还是在 wrapper 调用时运行?阶段 2
“patch 之后为什么仍调用原实现?”调用表达式查找哪份引用?它是否早已被保存到别处?阶段 3
“类中明明有这个名字,方法里为什么找不到?”使用的是裸名还是 self.attr?类属性并非普通方法的 E。阶段 4
“赋属性值,为什么执行了别的代码?”该类是否定义 property 或其他 descriptor?阶段 5
“第二次遍历为什么没有元素?”你重用了一个已耗尽的 iterator,还是重新取得一个?阶段 6
“资源清理了,异常为什么还在?”清理与异常传播分别有什么约定?阶段 7
“用了 async,为什么还是卡住?”是否存在同步阻塞?任务在哪里可以暂停并让其他任务推进?阶段 8

完成主线以后,再根据代码中实际遇到的问题选专题:数据结构反复手写时看 dataclasses;接口约定难表达时看 typing 与 Protocol;确实需要定制类创建时看元类;性能出现可测问题时,再研究分析工具、算法与并发方式。它们不是完成这份 Guidebook 的必修清单。

这样每一次新问题都有明确位置,也能把新增理解补回自己的实验记录。你今天已经讨论过的知识是起点,是否需要回退由入口诊断和练习结果决定。

官方资料与实验核验范围

本指南的技术规则参考 Python 3.14 官方文档。页面中的 9 组完整参考实验已在 CPython 3.12.14 中分别运行,其中模块实验包含三个文件。核验直接提取页面源码中的代码与显示的参考输出,使用独立临时目录和进程,检查输出一致且无运行错误;不依赖先前实验留下的全局名字。

页面中“动手任务”、变式和综合项目是留给后续学习的任务,未全部计入上述 9 组主例。实测版本与参考文档版本分别列明,不据此声称已经覆盖每个 Python 版本或所有边界。

主题直接阅读的官方入口
Namespace、scope 与名字解析Scopes and Namespaces;Resolution of names
函数、闭包与默认参数User-defined functions;Default Argument Values
定义、装饰器与 wrapsFunction definitions;functools.wraps
模块与测试替换Modules;Where to patch
属性和 descriptorDescriptor Guide
迭代器与生成器Iterator Types;Yield expressions;getgeneratorstate
异常与资源Defining Clean-up Actions;contextmanager
异步和取消Coroutines and Tasks;asyncio.Event

阅读官方文档的方法:带着本阶段的一条预测去查相应小节,先找能解释当前实验的规则;理解后回到变式验证。暂时不用把整个语言参考从头读完。

PythonGuidebookNamespace学习路线闭包装饰器描述符生成器asyncio

整理于 2026-10-19 · 从 Namespace 出发的学习导航与实验手册。语言规则参考 Python 3.14 官方资料,9 组主例在 CPython 3.12.14 中核验;阶段任务与综合项目供后续实践。

MORE IN DEVELOPMENT3.1 数据结构分类:逻辑关系、存储方式与 Python 容器