Python 知识点地图:这些概念不是一张清单,是一棵树
先做一个小测验。下面这段代码不到十行,涉及五个知识点。先别运行,逐行猜猜输出是什么:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
fs = [lambda: i for i in range(3)]
print([f() for f in fs])
def counter():
n = 0
def inc():
nonlocal n
n += 1
return n
return inc
c = counter()
print(c(), c(), c())
import sys
print(sys.getsizeof([None] * 1000), sys.getsizeof(range(1000)))
答案是 [2, 2, 2]、1 2 3、8056 48。如果你三组全猜对,这篇你大概率可以跳过;如果有任何一组让你愣住,恭喜,你正好撞见了这篇文章要讲的事。
很多 Python 学习者的困惑不是”某个知识点不懂”,而是”知识点太多、太散,不知道它们之间是什么关系”。装饰器、闭包、生成器、迭代器、协程、yield from、itertools……教程里每个都讲过,但它们在脑子里是一堆互不相识的名词,像一本没有目录的词典。
这篇不教你任何新知识,只做一件事:把这一系列文章挂到一棵树上,标清谁长在谁的枝头。你会发现,那张”知识清单”其实是一棵树–树根极小,所有看起来很新的特性,都是树根上几块积木的重新组合。
这棵树怎么读
这棵树用标题层级画成:二级标题是层,三级标题是知识点;子节点长在父节点给的养料上–想懂一个 ###,先得懂它脚下那一层。比如”装饰器”长在第 4 层,而它需要的养料是第 3 层的”闭包”和”高阶函数”,一个都不能少。
每个知识点只写三样东西:它依赖谁、一句话点透它是什么、成文的文章链接。没有链接的节点是还没写的楼层,先占个座。树刻意只留了常用且有深度的知识点–面面俱到的树没人爬得完。
你甚至可以不看正文,只把侧边栏的目录从上往下扫一遍,这棵树就已经装进脑子里了。
第 1 层:地基
Python 里最值钱的一句话是”一切皆对象”。这一层薄薄几块砖,撑起了上面所有的枝叶。《Python 的函数》是这层的旧文。
一切皆对象
函数是对象,类是对象,模块还是对象–都能赋值、传参、塞进列表。整棵树的树根:后面所有的”协议”,都是对象之间约定好的对话方式。
名字与绑定
变量不是装东西的盒子,是绑向对象的一根线;赋值不是拷贝,是重新绑线。开篇 [2, 2, 2] 谜题的钥匙就在这:三个 lambda 共享的是同一个名字 i,而不是三个快照。
LEGB 作用域
名字查找的固定顺序:Local → Enclosing → Global → Builtin。闭包里那个”E”,说的就是函数出生时所在的那层作用域。
__call__:可调用是一门协议
对象只要实现 __call__,就能被当函数调用。”能不能加括号”不问出身,只问协议–这和迭代器协议是同一个思路。
类与继承
类本身也是对象(type 的实例);继承的本质是属性查找沿着一条委托链往上走。(系列未写,先占座)
第 2 层:协议
for 循环为什么不挑食,列表、文件、字典、生成器通通能吃?因为语言不认识任何一个容器,它只认识协议:几个约好的双下方法,谁实现谁就能上桌。
迭代器协议
__iter__ 加 __next__,”怎么逐个要东西”的对话格式。想弄懂”为什么列表能 for 却不能 next”,看《Python 迭代器》。
上下文管理器
__enter__ / __exit__,”怎么配合 with 收拾现场”的格式。和迭代器是同班同学:又一门谁实现谁受益的协议。(系列未写,先占座)
描述符协议
属性访问(obj.x)背后的底层机制,property 的真身。理解了它,”访问属性”从一个黑盒变成一次可编程的函数调用。(系列未写,先占座)
第 3 层:积木
三块可以拿在手里反复用的东西:
生成器
把函数的执行状态变成可暂停、可恢复的对象。range(1000) 只有 48 字节的原因:它压根没存那一千个数,被 next 的时候才现算。看《Python 生成器》。
闭包
让函数记住自己出生时的环境。关键细节:闭包持有的是活的 cell,不是快照–这就是为什么一个闭包能数数(1 2 3),三个 lambda 却共享同一个 i([2, 2, 2]):同一个机制,两种表现。看《Python 闭包与作用域》。
高阶函数与 lambda
把代码本身当成值传来传去。函数是对象(第 1 层),所以它天然可以作为参数和返回值流动。看《匿名函数与高阶函数》。
第 4 层:组合
这一层没有新机制,全是第 3 层积木的拼装。这也是整棵树最值得反复看的一层:每个”新特性”拆开,里面全是老朋友。
装饰器 = 闭包 + 高阶函数
在函数诞生的那一刻把它换掉。@deco 只是一行 f = deco(f) 的语法糖,内核零改动–PEP 318 吵了好几年,最后定案恰恰是”不引入新机制”。看《Python 装饰器》。
yield from = 生成器 + 生成器
一个生成器把暂停权委派给另一个,自己当传声筒。递归遍历树的代码能写成平的,靠的就是它。看《Python yield from》。
协程 = 生成器 + 事件循环
yield 的”暂停”被赋予了新语义:”我在等 IO”。先有 yield from 委派(PEP 380),再有事件循环接管,最后才有 async/await 关键字–整条演化路径没有一步推翻重来。看《Python 协程》。
property 与 classmethod = 描述符 + 装饰器语法
第 4 层的组合之王:机制来自第 2 层的描述符,语法来自本层的装饰器。看懂它,就同时复习了两层。(系列未写,先占座)
元类
类的类。ORM 框架们魔法的源头–Model 在被继承的那一刻,元类已经把字段表建好了。(系列未写,先占座)
第 5 层:工程
积木搭好了,怎么在真实项目里用得体面。
itertools
迭代器协议预制好的一盒标准零件:无限迭代器、组合、分组,全是”协议 + 生成器”的现成实现。看《Python itertools》。
module 与 package
模块是对象(回到第 1 层),import 是执行。代码怎么组织、循环导入为什么发生,答案都在这两个事实里。看《module 和 package》。
__slots__
整棵树里少数”往回看”的节点:不是新积木,而是给”一切皆对象”的地基做的一次性能妥协–对象默认背着 __dict__ 背包,一百万个实例时就该换紧身衣了。看《对象的背包与紧身衣》。
线程、进程与 GIL
协程篇留下的那条岔路:单线程事件循环解决不了 CPU 密集任务,什么时候必须多进程,得先弄懂 GIL 为什么存在。(系列未写,先占座)
类型注解与 typing
又一代”协议”故事的最新章节:注解在运行时基本不生效,它是一门给类型检查器看的旁路语言。(系列未写,先占座)
为什么要自底向上爬这棵树
树的本质不是”分类树”(按主题把知识归堆),而是依赖树(按”理解 X 之前必须先理解 Y”连边)。分类树怎么走都行,依赖树走错顺序就处处碰壁。
用开头的测验当例子。三行输出各自连着一条依赖链:
[2, 2, 2]这条链是:名字绑定 → 闭包 → lambda 捕获。lambda: i里的i不是”定义时拷贝了一个值”,而是”指向外层名字的引用”。不先懂名字绑定,[2, 2, 2]就永远是玄学;懂了名字绑定,闭包那篇开头的同款谜题会当场解开。1 2 3这条链是:嵌套作用域 → nonlocal → 闭包持有的是 cell 而不是快照。同样是闭包,为什么一个记住了”活的变量”,一个却”共享到出错”?两个现象共用一个机制。8056 48这条链是:迭代器协议 → 惰性求值 → range 不是列表。这条链一直通到生成器和 itertools。
把这个”往下挖”的方法反过来用,就是学习顺序:遇到一个不懂的上层概念,别在原地反复读,顺着树往下爬一层,爬到根再回来。看不懂装饰器,八成是闭包没通;看不懂协程,八成是生成器的暂停语义没抓住。原地打转和下潜一层再浮上来,花的力气差不多,收获差一个量级。
还有一个自检信号值得记住:如果一个”新知识点”让你觉得”这也太多了吧”,通常说明你把它当成了树根,而它其实是第 4 层的组装件–拆开看看,里面全是老朋友。
这棵树为什么长这样:小内核,大组合
退后两步眯起眼看整棵树,问一个问题:Python 为什么选择”极小的内核 + 反复组合”,而不是”每个特性一套专用机制”?
先说本质。去掉所有术语,Python 的知识树只有一条树根:
万物皆对象,对象之间靠少数几个公开协议对话。
迭代器协议是”怎么逐个要东西”的对话格式;__call__ 是”怎么被当函数用”的格式;__enter__/__exit__ 是”怎么配合 with 收拾现场”的格式。语言只负责定义格式,不关心谁来实现。for 不认识列表,列表也不认识 for,它们只是碰巧都会说”迭代器”这门普通话。
有了这条根,上层每个”新特性”的成本都低得惊人:
- 装饰器(PEP 318)当年吵了好几年,吵的核心就是”要不要引入一个新机制”。最后的定案恰恰是不引入–
@decorator只是一行f = decorator(f)的语法糖,内核零改动。Guido 在 PEP 里明确说过:如果装饰器需要新机制才能工作,那这个提案就是错的。 - 协程的演化更极端:
asyncio不是”加了个并发系统”,而是发现生成器的暂停/恢复本来就可以表达”我在等 IO”,于是先有了yield from委派(PEP 380),再有了事件循环接管,最后才有了async/await关键字把”生成器”和”协程”在语法上分开。整条演化路径没有一步是推翻重来。
再追问一层:专用机制这个”显然的替代方案”输在哪?假设语言为装饰器专门做一套机制:解析到 @ 就走特权通道。那么你自己写的普通函数就没法干同样的事了,工具库(比如各种注册框架)会被排除在外;每加一个特性,解释器、类型检查器、教程各胖一圈,而使用者要背的东西线性增长。组合式设计的代价是”初次理解需要拆包”(这正是本系列文章存在的原因),收益是知识不随语言版本线性膨胀–这十年来 Python 加了很多特性,但第 1、2 层几乎没动过。
顺带一提,__slots__ 是树里少数往回看的节点:它不是新积木,而是给”一切皆对象”这条树根本身做的一次性能妥协。
动手环节:一段代码,把整棵树走一遍
光看树不算数。下面这段不到 15 行的代码,从根到树冠穿过五个层。老规矩,先预测输出,再运行:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import sys
def trace(func): # 第 3 层:闭包 + 高阶函数
def wrapper(*args, **kw): # 第 1 层:*args 是名字绑定
print(f"调用 {func.__name__}")
return func(*args, **kw)
return wrapper
@trace # 第 4 层:装饰器 = trace(evens)
def evens(n): # 第 3 层:生成器
for i in range(n): # 第 2 层:迭代器协议
if i % 2 == 0:
yield i
print(list(evens(6))) # 你猜这里打印几行"调用"?
print(sys.getsizeof(evens(10_000_000))) # 你猜这个有多大?
两个预测点,想好了再看答案:
list(evens(6))打印几行调用 evens? 一行。装饰器在evens诞生时就把它换成了wrapper,之后无论调用多少次、产出多少个值,@trace那行代码只执行一次。产出 3 个偶数是迭代器协议在干活,和装饰器无关–两层各司其职,互不知晓。getsizeof(evens(10_000_000))多大? 两百字节左右,和n的大小毫无关系。生成器对象里没有一千万个数,只有代码指针和一小段冻结的执行状态;每次next才执行到下一个yield。把10_000_000改成10_000_000_000,内存占用一个字节都不涨。
一段代码,五层枝叶全亮了灯。如果哪个预测错了,这棵树上就标出了你该去补的那个分叉。
自测问题
@trace那行代码,是在evens被定义时执行,还是在evens(6)被调用时执行?为什么这能证明”装饰器没有新机制”?- 把
evens里的yield i改成return [i for i in range(n) if i % 2 == 0],函数立刻从生成器退化成普通函数。sys.getsizeof的结果会发生什么量级的变化?这背后是哪一层的知识点? - 协程那篇说
async/await是”被事件循环标准化的生成器”。如果 Python 当年没有生成器,协程还能这样”顺手”长出来吗?说说你的推理。
收尾速查表
概念 → 依赖 → 文章的速查表,迷路时回来查:
| 想弄懂 | 它依赖谁(先读) | 文章 |
|---|---|---|
| 迭代器 / for 的原理 | 作用域、对象方法调用 | Python 迭代器 |
| 生成器 / yield | 迭代器 | Python 生成器 |
| 闭包 / nonlocal | 作用域、名字绑定 | Python 闭包 |
| lambda / map / sorted(key=) | 函数是对象 | 匿名函数与高阶函数 |
| 装饰器 | 闭包 + 高阶函数 | Python 装饰器 |
| yield from | 生成器 | Python yield from |
| 协程 / asyncio | 生成器 + yield from | Python 协程 |
| itertools | 迭代器 + 生成器 | Python itertools |
__slots__ / 内存优化 | 一切皆对象、__dict__ | 对象的背包与紧身衣 |
| import / 包结构 | 模块是对象 | module 和 package |
树上标着”先占座”的七个节点–类与继承、上下文管理器、描述符、property、元类、并发、类型注解–就是本系列接下来的候选篇目,按依赖顺序排。
下一站建议别按顺序读,而是做个实验:随手挑一个你平时”会用但说不清”的特性,试着把它拆到第 2 层为止,看看拆出来的零件是不是全是这棵树上的老面孔。当你发现每个新名词都能拆成三个旧名词时,这棵树就算装进脑子里了–而 Python 以后再出新特性,对你来说也只是又一条新枝,而不是又一篇要背的课文。