下面这段代码返回 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 求值它的规则只有一条:

  1. 先求值操作符和每个操作数;
  2. 再把操作符的值(一个函数)应用到操作数的值(参数)上。

规则的第一步里又要”求值操作数”,而操作数本身可能又是调用表达式,于是这条规则在调用自己。把表达式画成树:根是整个式子,孩子是操作符和操作数,叶子是数字和名字。求值就是从叶子往上算,每个内部节点把孩子的值组合起来。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 分别是多少?

调用一个用户定义函数:新开一个帧

内置函数 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) 的过程:

  1. 求值操作符 sum_squares,在全局帧里找到函数对象;求值操作数得到 3、4。
  2. 新建一个局部帧,在里面把形参 x 绑定到 3,y 绑定到 4。
  3. 在这个新帧构成的环境里执行函数体。

函数体里又调用 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 最核心的练习:每次调用画一个帧,标出名字、父帧和返回值。在纸上画一遍,比在脑子里推靠谱得多,等到下一篇闭包出现时,环境图几乎是唯一不出错的推理方式。

停一下,检查理解单选

函数调用时新建的帧,它的父帧由什么决定?

赋值让名字变成局部的

规则还有一处细节会咬人。看这段:

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 被调用时会怎样?

纯函数与非纯函数

到目前为止函数做的事都是”拿参数,算出返回值”。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。

停一下,检查理解多选

下面哪些函数是纯函数?

设计函数:抽象、文档与 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 判断一个样例是否通过,比较的是什么?

控制:条件、循环与短路

最后补上让函数能”做决定”的部分。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" 给空字符串一个兜底值。它们能工作,全靠”只求值需要的那一部分操作数”。

记住这三件事

  1. 求值调用表达式只有一条递归规则:先求值操作符和操作数,再把函数应用到参数上;if/and/or 是不按这条规则走的特殊形式。
  2. 每次函数调用新开一个帧,新帧的父帧是函数定义时所在的帧;名字沿父帧链向上查找,这叫词法作用域。
  3. 区分返回值与副作用,尽量写纯函数,并用 docstring 和 doctest 把函数的约定写进代码。

延伸阅读