下面这段代码返回 1 还是 2?
x = 1
def f():
return x
def g():
x = 2
return f()
print(g())1
很多人第一反应是 2:f 是在 g 里面被调用的,g 刚把 x 设成了 2。答案却是 1。要说清楚为什么,得先有一个”名字到底去哪儿找值”的精确模型。这一篇就搭这个模型,它叫环境模型(environment model),后面讲的闭包、nonlocal、对象,甚至在第四篇亲手写一个 Scheme 解释器,全都建立在它上面。
表达式是一棵树,求值是递归的
先从最小的东西开始:一个表达式。
from operator import add, mul
print(add(mul(2, 3), add(4, 5)))
print(mul(add(1, 2), add(3, 4)))15
21
add(mul(2, 3), add(4, 5)) 叫调用表达式(call expression)。它由一个操作符(add)和若干操作数(mul(2, 3)、add(4, 5))组成。Python 求值它的规则只有一条:
- 先求值操作符和每个操作数;
- 再把操作符的值(一个函数)应用到操作数的值(参数)上。
规则的第一步里又要”求值操作数”,而操作数本身可能又是调用表达式,于是这条规则在调用自己。把表达式画成树:根是整个式子,孩子是操作符和操作数,叶子是数字和名字。求值就是从叶子往上算,每个内部节点把孩子的值组合起来。2、3 这种原始表达式是递归的出口:数字求值为自身,名字求值为它绑定的值。
这件事看起来平淡,但注意规则本身是递归的。一门语言最核心的东西往往就这么短;第四篇里我们会把这两步原样写成 scheme_eval 和 scheme_apply 两个函数。
运算符写法 2 * 3 + (4 + 5) 只是同一棵树的另一种拼写。Python 会按优先级把它解析成和函数调用形式等价的树,求值规则不变。
名字、绑定与帧
叶子里的”名字求值为它绑定的值”要解释:值绑定在哪里?
赋值语句 x = 1 做了一件事:在当前帧(frame)里,把名字 x 绑定到值 1。帧就是一张”名字 → 值”的表。程序开始运行时只有一个帧,叫全局帧。
赋值的规则是:先求值右边所有表达式,再从左到右绑定名字。这个顺序让多重赋值可以一步交换或滚动变量:
a, b = 1, 2
a, b = b, a + b
print(a, b)2 3
右边的 b, a + b 先被算成 2, 3,然后才把 a、b 改掉。如果写成两行 a = b; b = a + b,第二行看到的 a 已经是新值,结果就成了 2 4。
def 也是一种绑定。执行 def square(x): ... 时,Python 创建一个函数对象,然后在当前帧里把名字 square 绑定到它。函数名和函数是两回事:
def square(x):
return x * x
print(square)
sq = square
del square
print(sq(5), sq.__name__)<function square at 0x1054a2790>
25 square
删掉名字 square 之后,函数对象还在,因为 sq 仍然指向它;它的 __name__ 记的是定义时的名字,跟现在被什么名字引用无关。名字只是贴在对象上的标签,一个对象可以贴好几个标签。名字和对象这层关系在可变数据上会引出”改了 b、a 也变了”这类现象,详细的引用语义见 Python 对象模型。
执行 a, b = 1, 2 之后再执行 a, b = b, a + b,a 和 b 分别是多少?
赋值语句先把右边 b, a + b 全部求值成 2, 3,此时 a 还是旧值 1,然后才把 a、b 依次绑定。
2 和 4 是把它当成两条顺序执行的赋值:先 a = 2,再 b = a + b 用到新的 a。多重赋值不是这样执行的。
调用一个用户定义函数:新开一个帧
内置函数 add 怎么工作我们看不见。自己定义的函数被调用时,发生的事情可以完全说清楚:
def square(x):
return x * x
def sum_squares(x, y):
return square(x) + square(y)
print(sum_squares(3, 4))25
调用 sum_squares(3, 4) 的过程:
- 求值操作符
sum_squares,在全局帧里找到函数对象;求值操作数得到3、4。 - 新建一个局部帧,在里面把形参
x绑定到3,y绑定到4。 - 在这个新帧构成的环境里执行函数体。
函数体里又调用 square(x),于是再开一个帧,这个帧里 x 绑定到 3;第二次调用 square(y) 又开一个帧,x 绑定到 4。三个帧里都有叫 x 的名字,彼此互不干扰。这就是为什么形参名字可以随便取:square 的作者用 x 不会和调用者的 x 打架。
这里有个容易被忽略的问题:执行 square 函数体时要找名字 square、x、* 对应的值,帧里只有 x,其他去哪找?答案是每个帧都有一个父帧。
把 sum_squares(3, 4) 执行到第一次 square 返回那一刻的状态画出来,大致是这样(箭头指向父帧):
Global frame func square(x) [parent=Global]
square ──────────────────▶ func sum_squares(x, y) [parent=Global]
sum_squares ──────────────────▶
f1: sum_squares [parent=Global]
x 3
y 4
f2: square [parent=Global]
x 3
Return value 9
三点值得盯住。第一,f1 和 f2 的父帧都是 Global,f2 的父帧不是 f1,尽管是 f1 调用了它。第二,square 返回后 f2 就用完了,紧接着第二次调用会新建 f3,里面 x 是 4。第三,帧里存的是名字到值的绑定,函数名绑定的是一个函数对象,对象旁边标着它定义时的父帧,这个标记下一节就要用。
形参还可以带默认值。def power(base, exp=2) 里,调用时没给 exp 就在新帧里绑定 2;也可以用关键字指定绑定哪个形参:
def power(base, exp=2):
return base ** exp
print(power(5), power(2, 10), power(exp=3, base=2))25 1024 8
不管参数怎么传,结果都只是”新帧里形参绑定到什么值”,调用模型本身不变。默认值在 def 执行时只求值一次,所以用可变对象(如 [])当默认值会在多次调用之间共享,这个坑属于 Python 对象模型。
环境就是一串帧,查找沿父帧向上
**环境(environment)**是一串帧:当前帧,它的父帧,父帧的父帧……一直到全局帧(全局帧之后还有一层内置名字,print、len 就住在那里)。查找名字的规则:
在当前帧找;找不到就去父帧找;一直找到全局帧和内置名字。全都没有,报
NameError。
查找链的顺序直接解释了一个经典事故:
print(max(3, 7))
max = 10
try:
print(max(3, 7))
except TypeError as e:
print('TypeError:', e)
del max
print(max(3, 7))
try:
undefined_name
except NameError as e:
print('NameError:', e)7
TypeError: 'int' object is not callable
7
NameError: name 'undefined_name' is not defined
max = 10 在全局帧里新建了一个绑定,它**遮蔽(shadow)**了内置帧里的 max。之后查找 max 在全局帧就停下了,拿到的是整数 10,调用它当然报错。del max 删掉全局绑定后,查找重新落到内置帧,一切恢复。内置函数并没有被”改掉”,只是被挡住了。把变量命名成 list、sum、input 之所以危险,原因就在这里。
关键是父帧是谁。规则是:
一个函数被调用时,新帧的父帧 = 这个函数被定义时所在的帧。
函数对象在被 def 创建的那一刻,记下了”我是在哪个帧里定义的”。用 Composing Programs 的环境图写法,函数对象会标成 func square(x) [parent=Global]。调用时,新帧的父帧直接从函数对象上取,跟是谁调用它无关。
现在回到开头的谜题:
f和g都是在全局帧里定义的,所以它们的父帧都是 Global。- 调用
g(),新帧 g1,父帧 Global。x = 2在 g1 里绑定x。 g调用f(),新帧 f1。父帧是f定义时的帧,也就是 Global,不是 g1。f里找x:f1 没有,去 Global,找到1。
g1 里那个 x = 2 从头到尾不在 f1 的查找路径上。这种”看代码写在哪里”的规则叫词法作用域(lexical scoping)。另一种可能的设计是沿调用者查找,叫动态作用域,早期 Lisp 和 Emacs Lisp 用过它。词法作用域的好处是:读 f 的代码就能确定 x 指什么,不用知道所有可能调用 f 的地方。第四篇写解释器时,这个区别只是一行代码:新帧的父帧填 proc.env 还是填调用者的 env。
画环境图是 CS61A 最核心的练习:每次调用画一个帧,标出名字、父帧和返回值。在纸上画一遍,比在脑子里推靠谱得多,等到下一篇闭包出现时,环境图几乎是唯一不出错的推理方式。
函数调用时新建的帧,它的父帧由什么决定?
函数对象在创建时记下了定义所在的帧,调用时新帧的父帧直接取自函数对象。这就是词法作用域。
选"调用者的帧"是动态作用域,Python 不是这样。"永远是全局帧"只在函数定义在全局时碰巧成立,函数定义在另一个函数里面时父帧就是外层函数的局部帧,这正是下一篇闭包的来源。
赋值让名字变成局部的
规则还有一处细节会咬人。看这段:
def h():
print(y)
y = 3
try:
h()
except Exception as e:
print(type(e).__name__, e)UnboundLocalError local variable 'y' referenced before assignment
按刚才的规则,print(y) 执行时 h1 里还没有 y,应该去父帧找,找不到报 NameError 才对。为什么是 UnboundLocalError?
因为 Python 在编译函数体时就决定了每个名字属于哪个帧:只要函数体里任何地方对 y 赋过值,y 在整个函数里都是局部名字。执行到 print(y) 时,Python 只去局部帧找,发现它还没绑定,于是报错,不会继续去父帧。
这条规则保证一个名字在一个函数里只有一种含义,不会前半段指全局、后半段指局部。代价是:想在函数里修改外层帧的名字,必须显式声明。
n = 0
def bump():
global n
n += 1
return n
print(bump(), bump())1 2
global n 告诉 Python:“n 指全局帧里那个,别给我建局部的。“对外层函数帧的同类声明是 nonlocal,它要配合”函数里定义函数”才有意义,留到 数据抽象与对象 讲可变状态时展开。
全局有 y = 10。函数 def h(): print(y); y = 3 被调用时会怎样?
函数体里有 y = 3,所以 y 在整个 h 里都是局部名字。print(y) 时局部的 y 还没绑定,Python 不会退回去找全局的 10,直接报 UnboundLocalError。
前两个选项假设"赋值之前的读还能看到全局",Python 不是逐行决定作用域的。NameError 是名字在整条环境链上都不存在时才报的,这里 y 被判定为局部、只是尚未绑定。
纯函数与非纯函数
到目前为止函数做的事都是”拿参数,算出返回值”。print 不一样:
r = print(print(1), print(2))
print(r)1
2
None None
None
先求值操作数:内层 print(1) 在屏幕上显示 1,返回 None;print(2) 显示 2,返回 None。然后外层 print(None, None) 显示 None None,自己也返回 None。
输出顺序本身也是信息:1 在 2 前面,而 None None 在最后。这说明 Python 从左到右求值操作数,并且所有操作数都求值完毕之后才调用外层函数。把这个表达式画成树,print(1) 和 print(2) 是外层 print 的两个孩子,孩子先算,根最后算,副作用的发生顺序和树的后序遍历一致。当操作数里有副作用时,这个顺序就决定了程序的行为。
这里出现了两种不同的东西:返回值和副作用(side effect)。print 的返回值永远是 None,它真正”做”的事情是往屏幕上写字,这是副作用。bump 修改全局变量也是副作用。
- 纯函数(pure function):返回值只取决于参数,并且没有副作用。
abs、square、add都是。 - 非纯函数(non-pure function):有副作用,或者返回值依赖参数以外的状态。
print、bump、random.random都是。
纯函数好在哪里?同样的输入永远同样的输出,所以可以单独测试、可以随意组合(square(square(3)) 不用担心顺序)、可以缓存结果(下一篇的记忆化就要求函数是纯的)、可以并行调用而不互相干扰。非纯函数不是坏东西,没有副作用的程序什么也输出不了,但应该把副作用集中在少数地方,让大部分逻辑是纯的。
一个常见的错误是把”显示”当成”返回”。在交互式解释器里,>>> square(3) 显示 9 是因为解释器替你打印了返回值;写在脚本里、或者作为另一个函数的参数时,你只能拿到返回值。函数里用 print 代替 return,调用者拿到的就是 None。
下面哪些函数是纯函数?
纯函数的两个条件:返回值只依赖参数,且没有副作用。第一项和第三项都满足。
第二项返回值确实只依赖 x,但打印是副作用,所以不纯。第四项没有副作用,但返回值依赖参数之外的全局状态,同样的 x 在不同时刻可能得到不同结果,所以也不纯。
设计函数:抽象、文档与 doctest
函数最重要的作用是抽象:调用 square(x) 的人不需要知道里面是 x * x 还是 x ** 2,只需要知道”给一个数,返回它的平方”。CS61A 把一个函数的约定拆成三件事:定义域(接受什么参数)、值域(返回什么)、意图(输入输出之间的关系)。函数的实现可以换,只要这三件事不变,调用者的代码就不用改。这个思想在 数据抽象与对象 里会推广到数据上。
写好一个函数有几条朴素的原则:一个函数只做一件事;不要重复自己(DRY),重复的代码说明有一个抽象还没提取出来;名字说清楚值是什么(total、is_prime),而不是怎么得到的。
约定最好写在代码里。Python 的函数可以有一个文档字符串(docstring),就是函数体第一行的字符串。更进一步,可以在 docstring 里写交互式会话的样例,用 doctest 自动检查它们:
def divide_exact(n, d):
"""Return quotient and remainder of n divided by d.
>>> q, r = divide_exact(2013, 10)
>>> q
201
>>> r
3
>>> divide_exact(7, 2)
(3, 2)
"""
return n // d, n % d运行 python3 -m doctest dt.py,doctest 会把每个 >>> 后面的代码当成交互式输入执行,把结果和下一行比较。最后一个样例是故意写错的:
**********************************************************************
File "dt.py", line 9, in dt.divide_exact
Failed example:
divide_exact(7, 2)
Expected:
(3, 2)
Got:
(3, 1)
**********************************************************************
1 items had failures:
1 of 4 in dt.divide_exact
***Test Failed*** 1 failures.
改成 (3, 1) 之后再跑,没有任何输出,表示全部通过(加 -v 会逐条列出)。doctest 同时是文档和测试:读者看到样例就知道函数怎么用,样例又保证文档不会和代码脱节。它的局限是只适合短小、输出确定的例子;成体系的测试设计、测试框架和 CI 见 调试测试与工程实践。
doctest 也可以在文件内部触发:在文件末尾写 if __name__ == '__main__': import doctest; doctest.testmod(),直接运行脚本时就会检查本模块里所有函数的样例,被别的模块 import 时则不会。CS61A 的作业用的是同一个思路,每道题的 docstring 里带着样例,自动评分器先跑这些样例,再跑隐藏测试。养成”先写样例、再写实现”的习惯,等于在动手前先把定义域、值域和意图想清楚。
divide_exact 还顺便演示了多返回值:return n // d, n % d 返回一个元组,调用者用 q, r = ... 拆开,这和前面多重赋值是同一套机制。
doctest 判断一个样例是否通过,比较的是什么?
doctest 模拟交互式解释器:执行 >>> 后面的代码,把解释器本该显示的内容(返回值的 repr 或打印输出)与期望文本做字符串比较。所以 (3, 1) 写成 (3,1) 也会失败。
它不检查类型、不分析源代码;"没抛异常"也不够,输出不同就算失败。
控制:条件、循环与短路
最后补上让函数能”做决定”的部分。if/elif/else 语句先求值条件,然后只执行一个分支;while 反复求值条件,为真就执行循环体。它们都是语句(statement),执行它们是为了效果,而不是为了得到一个值。
和它们相关的有两个求值规则上的细节,后面会反复用到:
- 真值:
False、0、0.0、None、空字符串和空容器在条件里算假,其余都算真。 - 短路:
a and b先求值a,若为假直接返回a,不再求值b;a or b若a为真直接返回a。返回的是操作数的值,不一定是True/False,所以0 or 'default'得到'default'。同理[] and 1得到[],3 and 4得到4:and要么停在第一个假值上,要么一路走到最后一个操作数。
有了 while 和多重赋值,就能写出迭代版的 Fibonacci。注意 pred, curr = curr, pred + curr 一步滚动两个变量,靠的正是”先求值右边再绑定”:
def fib(n):
"""Return the nth Fibonacci number, fib(0) = 0."""
pred, curr = 0, 1
k = 0
while k < n:
pred, curr = curr, pred + curr
k += 1
return pred
print([fib(k) for k in range(10)])[0, 1, 1, 2, 3, 5, 8, 13, 21, 34]
写循环时心里要有三样东西:循环变量的初值、每轮怎样靠近终止条件、终止条件本身。少一样就会死循环或少算一轮。再看一个把函数组合起来的例子,分解质因数:
def smallest_factor(n):
k = 2
while n % k != 0:
k += 1
return k
def factors(n):
while n > 1:
k = smallest_factor(n)
print(k, end=' ')
n //= k
print()
factors(360)2 2 2 3 3 5
smallest_factor 是纯函数,factors 负责副作用(打印),两者职责分开。smallest_factor 从 2 往上找到的第一个因子一定是质数,因为任何更小的因子早就被找到了。这种”先把一个小问题抽成函数,再用它组合出大问题”的写法,就是函数抽象的日常形态。
短路说明了一件事:and/or/if 不是普通的调用表达式。普通调用要先把所有操作数都算出来,而这三者只求值其中一部分。如果你想写一个函数 my_if(cond, a, b) 代替 if,会发现 a 和 b 在进入函数之前就都被算掉了,my_if(n == 0, 1, 1 / n) 在 n == 0 时照样除零。这类”不按普通规则求值操作数”的结构叫特殊形式(special form)。第四篇写解释器时,if、define、lambda 都要在 eval 里单独处理,原因就在这里。反过来,短路也是一种实用的写法:d != 0 and n / d 在 d 为 0 时直接返回 False,除法根本不会执行;name or "anonymous" 给空字符串一个兜底值。它们能工作,全靠”只求值需要的那一部分操作数”。
记住这三件事
- 求值调用表达式只有一条递归规则:先求值操作符和操作数,再把函数应用到参数上;
if/and/or是不按这条规则走的特殊形式。 - 每次函数调用新开一个帧,新帧的父帧是函数定义时所在的帧;名字沿父帧链向上查找,这叫词法作用域。
- 区分返回值与副作用,尽量写纯函数,并用 docstring 和 doctest 把函数的约定写进代码。
延伸阅读
- Composing Programs 第 1 章(1.1–1.6,环境模型的原始讲法,CC BY-SA)
- CS61A 课程主页(讲义、讨论题、环境图练习)
- Python Tutor(逐步可视化帧和环境图,CS61A 课上用的工具)
- Python 文档:Execution model — Naming and binding
- Python 文档:doctest