for x in range(10**9) 不会先造出十亿个整数,for line in open('huge.log') 也不会把整个文件读进内存。for 怎么知道”下一个”是什么,又怎么知道该停了?这一篇先拆开 for 循环,看到迭代器和生成器,理解”需要时才计算”的惰性求值。然后再拆一层更大的东西:用不到 80 行 Python 写一个 Scheme 解释器,看一门语言本身是怎么跑起来的。前三篇的环境模型、闭包、递归,到这里会全部变成代码。
拆开 for 循环:迭代器协议
先手动做 for 做的事:
s = [10, 20, 30]
it = iter(s)
print(next(it), next(it), next(it))
try: next(it)
except StopIteration: print('StopIteration')
it = iter(s)
print(next(it), list(it), list(it))10 20 30
StopIteration
10 [20, 30] []
iter(s) 从列表得到一个迭代器(iterator),它是一个记住”走到哪了”的对象。每次 next(it) 返回下一个元素并前进一步;没有元素时抛出 StopIteration。第二段说明迭代器是一次性的:next 取走 10,list(it) 取走剩下的 20、30,再 list(it) 就是空的,因为位置已经到了末尾。
这就是 for x in s: body 的全部真相,它等价于:
it = iter(s)
while True:
try:
x = next(it)
except StopIteration:
break
body这里有两个角色要分清:
- 可迭代对象(iterable):能被
iter()调用、返回一个迭代器的东西。列表、字符串、字典、range都是。它本身不记位置,可以反复遍历。 - 迭代器(iterator):有
__next__方法、记着位置的东西。它的__iter__返回自己,所以迭代器也是可迭代对象,但只能走一遍。
这又是上一篇的特殊方法:iter(x) 调用 x.__iter__(),next(it) 调用 it.__next__()。自己实现一个迭代器类:
class Countdown:
def __init__(self, start): self.cur = start
def __iter__(self): return self
def __next__(self):
if self.cur <= 0: raise StopIteration
self.cur -= 1
return self.cur + 1
print(list(Countdown(3)))[3, 2, 1]
任何定义了这两个方法的对象都能放进 for、list()、sum()、zip()。这就是协议(protocol)的力量:for 不关心你是列表、文件还是网络流,只要会回答”下一个是什么”。
it = iter([1, 2, 3]),执行 a = list(it) 再执行 b = list(it),b 是什么?
迭代器只保存当前位置,第一次 list(it) 把它推到了末尾;第二次一开始 next 就收到 StopIteration,list 把它当作正常结束,得到空列表。
能"从头再来"的是可迭代对象(列表本身),每次 iter(列表) 都给一个新迭代器。StopIteration 被 list 内部捕获,不会传出来。
生成器:让函数暂停
写 Countdown 要手动维护 self.cur,要写 raise StopIteration,状态分散在三个方法里。同样的东西用**生成器函数(generator function)**写只要几行:
def countdown(n):
print('start')
while n > 0:
yield n
n -= 1
g = countdown(3)
print(type(g).__name__)
print(next(g)); print(next(g))
print(list(g))generator
start
3
2
[1]
函数体里只要出现 yield,调用它就不执行函数体,而是返回一个生成器对象。注意 start 是在第一次 next(g) 时才打印的,不是在 countdown(3) 时。之后:
- 每次
next(g),函数体从上次停下的地方继续执行,直到遇到下一个yield,把yield后面的值交出去,然后暂停。 - 函数体执行到末尾(或
return),生成器抛出StopIteration。
用环境模型看:普通函数返回后,它的帧就用完了;生成器的帧在两次 next 之间被冻结保留,局部变量 n 和”执行到第几行”都还在。原来要写在 self.cur 里的状态,现在就是普通的局部变量,控制流就是普通的 while。生成器是写迭代器最省事的方法。
yield from 把另一个可迭代对象的元素逐个转交出去,适合递归结构:
def flatten(t):
for x in t:
if isinstance(x, list): yield from flatten(x)
else: yield x
print(list(flatten([1, [2, [3, 4]], 5])))[1, 2, 3, 4, 5]
每一层递归都是一个生成器,yield from 把内层产出的值原样传到外层。代码结构仍然是第二篇的递归模板:遇到子列表就递归,遇到元素就交出去。
def gen(): print('A'); yield 1; print('B'); yield 2。执行 g = gen() 和一次 next(g) 后,屏幕上打印了什么?
调用生成器函数只创建生成器对象,函数体一行都不执行。第一次 next 从头执行到第一个 yield,途中打印 A,交出 1 后暂停,print('B') 要等下一次 next。
打印并不会延迟到耗尽才发生;"调用时就打印 A"是把生成器函数当成了普通函数。
惰性求值:需要时才计算
生成器只在被要求时才算下一个值,这叫惰性求值(lazy evaluation)。它带来两个直接的好处。
第一是省内存:
import sys
print(sys.getsizeof([x for x in range(10**6)]), sys.getsizeof(x for x in range(10**6)))8448728 112
列表推导式立即造出一百万个元素,8 MB;把方括号换成圆括号得到生成器表达式,它只是一个 112 字节的对象,记着”怎么算下一个”。处理大文件、数据流时,这个区别决定了程序能不能跑起来。
第二是可以表示无限序列:
def naturals():
n = 0
while True:
yield n; n += 1
def take(k, it):
return [x for _, x in zip(range(k), it)]
evens_sq = (x * x for x in naturals() if x % 2 == 0)
print(take(5, evens_sq))[0, 4, 16, 36, 64]
naturals() 永远不会结束,如果是列表根本造不出来。但 evens_sq 只是在它上面叠了一层”过滤 + 平方”的描述,take 要五个,它就只从 naturals 拉够需要的数。整条管道从下游往上游按需拉取。
内置的 map、filter、zip 在 Python 3 里返回的也是惰性迭代器:
m = map(lambda x: print('compute', x) or x * 10, [1, 2, 3])
print('map created')
print(next(m))map created
compute 1
10
map 创建时一个元素也没算,next 时才算第一个。这里的 print(...) or x * 10 借用了第一篇讲的短路:print 返回 None(假),于是 or 返回右边的值。
把惰性流推到极致,可以写出一个”无限质数流”:
def primes():
def sieve(s):
p = next(s)
yield p
yield from sieve(x for x in s if x % p != 0)
yield from sieve(n for n in naturals() if n >= 2)
print(take(10, primes()))[2, 3, 5, 7, 11, 13, 17, 19, 23, 29]
每找到一个质数 p,就在剩下的流上再套一层”去掉 p 的倍数”的过滤。这是埃拉托斯特尼筛法的惰性版本,代码几乎就是算法的定义。代价是每个质数多一层生成器嵌套和递归,第几百个质数时就会碰到递归上限,所以它是一个展示思想的例子,不是高效实现。
惰性也有代价:迭代器只能走一遍;值是在被拉取的那一刻才算的,如果计算依赖会变的外部状态,结果取决于何时拉取。
关于 g = (x * x for x in range(10**9)),下面哪些说法正确?
生成器表达式只记录怎么生成下一个值,创建时不计算任何元素;每次 next 算一个。
生成器没有 __len__,len(g) 会报 TypeError,因为它事先并不知道(也不打算算出)有多少个元素。它是一次性的,第一次 sum 已经把它耗尽,第二次得到 0。
一门语言需要什么:Scheme 子集
现在换一个更大的问题:Python 本身是怎么”运行”一段代码的?与其去读 CPython 的几十万行 C,不如自己写一门小语言的解释器。CS61A 选的是 Scheme,Lisp 家族的一员。它的语法简单到几乎没有语法:
(+ 1 (* 2 3)) ; 调用表达式:(操作符 操作数 ...)
(define square (lambda (x) (* x x))) ; 绑定名字
(if (= n 0) 1 (* n (fact (- n 1)))) ; 条件
所有东西都是括号包起来的表达式,第一个元素是操作符,后面是操作数。(+ 1 (* 2 3)) 就是 Python 的 add(1, mul(2, 3))。这一点让 Scheme 成了写解释器的理想对象:程序的文本形式本身就是一棵树,几乎不需要处理优先级、缩进、关键字。
我们要支持的子集只有四种东西:
- 整数和名字(原始表达式)
(define 名字 表达式):在当前帧绑定(if 条件 then else):条件分支(lambda (形参 ...) 函数体):创建函数- 其他所有括号表达式都是调用
够不够?lambda 带来函数,if 带来分支,函数可以调用自己带来循环(递归),闭包带来数据(第三篇用闭包表示了分数)。这几样凑起来已经是一门图灵完备的语言。
解释器的工作分两步:parse(把文本变成树)和 eval(对树求值)。
parse:从文本到树
第一步分词(tokenize),把字符串切成记号;第二步解析(parse),按括号把记号组装成嵌套列表:
def tokenize(src):
return src.replace('(', ' ( ').replace(')', ' ) ').split()
def parse(tokens):
tok = tokens.pop(0)
if tok == '(':
expr = []
while tokens[0] != ')':
expr.append(parse(tokens))
tokens.pop(0)
return expr
try:
return int(tok)
except ValueError:
return tokprint(tokenize('(+ 1 (* 2 3))'))
print(parse(tokenize('(+ 1 (* 2 3))')))['(', '+', '1', '(', '*', '2', '3', ')', ')']
['+', 1, ['*', 2, 3]]
parse 是一个递归下降解析器:读到 ( 就开一个新列表,反复递归解析里面的子表达式直到遇到对应的 );读到别的就是一个原子,能转成整数的转整数,否则当作名字(字符串)。嵌套的括号自然变成嵌套的列表,数据的递归结构和函数的递归结构又一次一一对应。
parse 之后,程序就变成了一个普通的 Python 数据结构。接下来的 eval 只和这个树打交道,不再关心原始文本长什么样。
eval 与 apply:两个互相递归的函数
求值器需要环境。照搬第一篇的模型:帧是一张表加一个父帧指针,查找沿父帧向上。用户定义的函数要记住定义时的环境:
class Frame:
def __init__(self, bindings, parent):
self.bindings, self.parent = bindings, parent
def lookup(self, name):
f = self
while f is not None:
if name in f.bindings:
return f.bindings[name]
f = f.parent
raise NameError(name)
class Lambda:
def __init__(self, params, body, env):
self.params, self.body, self.env = params, body, envLambda 对象就是 Composing Programs 环境图里的 func f(x) [parent=...]:形参、函数体、父帧,一样不少。然后是整个解释器的核心:
def scheme_eval(expr, env):
if isinstance(expr, int):
return expr
if isinstance(expr, str):
return env.lookup(expr)
head = expr[0]
if head == 'define':
_, name, value = expr
env.bindings[name] = scheme_eval(value, env)
return name
if head == 'if':
_, test, conseq, alt = expr
return scheme_eval(conseq if scheme_eval(test, env) else alt, env)
if head == 'lambda':
_, params, body = expr
return Lambda(params, body, env)
proc = scheme_eval(head, env)
args = [scheme_eval(arg, env) for arg in expr[1:]]
return scheme_apply(proc, args)
def scheme_apply(proc, args):
if callable(proc):
return proc(*args)
frame = Frame(dict(zip(proc.params, args)), proc.env)
return scheme_eval(proc.body, frame)
import operator as op
global_env = Frame({'+': op.add, '-': op.sub, '*': op.mul, '=': op.eq, '<': op.lt}, None)
def run(src):
return scheme_eval(parse(tokenize(src)), global_env)逐段对照前几篇:
- 整数求值为自身,名字沿环境查找:第一篇的”原始表达式”。
define、if、lambda在求值操作数之前就被拦下单独处理:它们是第一篇说的特殊形式。if只求值一个分支,这就是为什么fact的递归能停下来;lambda根本不求值函数体,只把它连同当前环境env打包起来。- 其他情况是调用表达式:先求值操作符和所有操作数,再交给
scheme_apply。这就是第一篇那条递归的求值规则,原样写成了代码。 scheme_apply对内置函数直接调用 Python 函数;对用户函数,新建一个帧,父帧是proc.env,在新帧里求值函数体。这一行就是词法作用域。
eval 调用 apply,apply 又调用 eval,这对互相递归的函数就是整个解释器。跑起来:
print(run('(+ 1 (* 2 3))'))
print(run('(define square (lambda (x) (* x x)))'), run('(square 12)'))
run('(define fact (lambda (n) (if (= n 0) 1 (* n (fact (- n 1))))))')
print(run('(fact 20)'))
run('(define make-adder (lambda (n) (lambda (k) (+ n k))))')
print(run('((make-adder 3) 4)'))7
square 144
2432902008176640000
7
递归能跑,闭包也能跑:make-adder 返回的内层 Lambda 记住了外层调用的帧,n 被找到。我们没有为闭包写任何专门的代码,它是”lambda 记下当前环境 + 调用时父帧取 proc.env”这两条规则自然的结果。
把 scheme_apply 里的 proc.env 换成调用者的环境,就得到动态作用域。用第一篇开头的谜题测试两个版本:
run('(define x 1)')
run('(define f (lambda () x))')
run('(define g (lambda (x) (f)))')
print('lexical:', run('(g 2)'))lexical: 1
dynamic: 2
(dynamic 那一行来自把父帧改成调用者 env 的另一版求值器。)一门语言的作用域规则,在解释器里就是一个参数填什么。
为什么 if 必须在 scheme_eval 里作为特殊形式处理,而不能做成一个放在 global_env 里的内置函数?
普通调用走的是"先求值所有操作数再 apply"。如果 if 是函数,(fact (- n 1)) 这个分支在 n 为 0 时也会被求值,递归永远停不下来。
用一个 Python 函数实现条件选择本身没有障碍(选项一不成立),问题全在求值顺序。速度和环境访问都不是原因。
解释器里没有任何专门处理闭包的代码,((make-adder 3) 4) 却能返回 7。是哪两处代码共同实现了闭包?
内层 lambda 在 make-adder 的调用帧里被求值,于是记下了那个帧;调用它时新帧的父帧就是这个帧,查找 n 自然找到 3。
parse 只管语法结构,不涉及环境。n 从未被放进全局帧。"不删除帧"只是 Python 垃圾回收的结果(Lambda 引用着帧),决定查找路径的是父帧指针。
尾递归:不占栈的递归
这个解释器有一个毛病。写一个计数函数,它的递归调用是函数体最后一件事:
run('(define count (lambda (n acc) (if (= n 0) acc (count (- n 1) (+ acc 1)))))')
print(run('(count 100 0)'))
try: print(run('(count 5000 0)'))
except RecursionError: print('RecursionError')100
RecursionError
Scheme 的每一层调用都对应 Python 里 scheme_eval → scheme_apply → scheme_eval 的几层调用,很快就撞上 Python 的 1000 层上限。但是仔细看 count:递归调用 (count (- n 1) (+ acc 1)) 的结果直接就是当前调用的结果,返回之后什么也不用做。这样的调用叫尾调用(tail call),处在尾位置上的递归叫尾递归。
对比 fact 的 (* n (fact (- n 1))):递归调用返回后还要乘以 n,所以当前帧必须留着等结果。count 的当前帧没有任何待完成的工作,留着它纯属浪费。第二篇说过,循环版 fact_iter 把”还剩什么没做”放在变量里;尾递归的 count 把它放在参数 acc 里,调用自己时就已经带上了全部状态。
Scheme 语言标准要求实现正确处理尾调用:无论尾递归多少层,只用常数空间。在我们的解释器里做到这一点,只需要把”在尾位置上递归调用 scheme_eval”改成”更新 expr 和 env,回到循环开头”:
def scheme_eval(expr, env):
while True:
if isinstance(expr, int):
return expr
if isinstance(expr, str):
return env.lookup(expr)
head = expr[0]
if head == 'define':
env.bindings[expr[1]] = scheme_eval(expr[2], env)
return expr[1]
if head == 'if':
_, test, conseq, alt = expr
expr = conseq if scheme_eval(test, env) else alt
continue
if head == 'lambda':
return Lambda(expr[1], expr[2], env)
proc = scheme_eval(head, env)
args = [scheme_eval(a, env) for a in expr[1:]]
if callable(proc):
return proc(*args)
expr, env = proc.body, Frame(dict(zip(proc.params, args)), proc.env)
run('(define count (lambda (n acc) (if (= n 0) acc (count (- n 1) (+ acc 1)))))')
print(run('(count 100000 0)'))
run('(define fact (lambda (n) (if (= n 0) 1 (* n (fact (- n 1))))))')
try: print(run('(fact 2000)'))
except RecursionError: print('RecursionError')100000
RecursionError
输出的第一行是 (count 100000 0):十万层尾递归顺利完成。第二行是 (fact 2000):它仍然报错,因为 (* n (fact ...)) 里的递归调用是 * 的操作数,不在尾位置,求值操作数时 Python 照样要递归调用 scheme_eval。
改动只在两处:if 选好分支后不递归,而是把分支当成新的 expr 继续循环;调用用户函数时不再调用 scheme_apply,而是把函数体和新帧当成新的 expr、env 继续循环。哪些位置是”尾位置”,正是这两处:if 的分支,和函数体本身。Python 自己没有尾调用优化(Guido 的理由是会丢失 traceback 里的帧信息,不利于调试),所以在 Python 里写深递归仍然要改成循环。
下面哪些 Scheme 函数中的递归调用处在尾位置,能被上面的解释器以常数栈空间执行?
第一和第三项中,递归调用就是 if 分支本身,返回值直接作为整个函数的结果,没有待完成的计算。第三项把累积结果放在参数 acc 里,是把阶乘改写成尾递归的标准做法。
第二和第四项的递归调用是 + 或 * 的操作数,返回后还要再做一次加法或乘法,当前帧必须保留,所以不是尾调用,深度一大仍会溢出。
记住这三件事
for循环就是反复调用next直到StopIteration;生成器是冻结在yield处的函数帧,用它写迭代器最简单,而且天然惰性。- 解释器就是 parse 加上互相递归的 eval 和 apply;特殊形式不求值全部操作数,词法作用域和闭包只是”lambda 记下环境、调用时以它为父帧”两行代码。
- 尾位置上的调用不需要保留当前帧,把它改成循环就是尾调用优化;不在尾位置的递归仍然占栈。
延伸阅读
- Composing Programs 4.2 Implicit Sequences(迭代器、生成器、流,CC BY-SA)
- Composing Programs 3.2 Functional Programming 与 3.5 Interpreters for Languages with Abstraction(Scheme 与解释器,CC BY-SA)
- CS61A 课程主页(Scheme 解释器项目,含尾调用优化的可选部分)
- Peter Norvig: (How to Write a (Lisp) Interpreter (in Python))
- SICP 4.1 The Metacircular Evaluator
- Python 文档:Iterator types