Skip to content

1.2 编程要素 ​

INFO

来源:1.2 Elements of Programming

编程语言不只是指挥计算机干活的工具。它还是一个框架,让我们把关于计算过程的想法组织起来。程序也是在编程社区里传达这些想法的载体。所以,程序首先是写给人读的,让机器执行只是顺带。

描述一门语言时,要特别留意它提供了哪些机制,能把简单的想法组合成更复杂的想法。凡是强大的语言,都有三种这样的机制:

  • 基本表达式和语句(primitive expressions and statements):语言提供的最简单的构建模块
  • 组合方法(means of combination):把简单元素搭成复合元素
  • 抽象方法(means of abstraction):给复合元素命名,把它们当成一个整体来用

编程里我们打交道的元素只有两种:函数和数据(很快我们就会发现,它们其实没那么泾渭分明)。简单说:数据是我们想操作的材料,函数则是操作数据的「规则」。所以,任何强大的编程语言,都得能描述基本的数据和函数,还要有组合、抽象它们的手段。

1.2.1 表达式 ​

上一节里,我们试用了完整的 Python 解释器;现在,我们从头开始,按部就班地一个一个讲 Python 语言的要素。如果例子看着太简单,请耐心点——更精彩的内容马上就来。

我们从基本表达式讲起。有一种基本表达式就是数字。更准确地说,你输入的表达式,是由表示这个数的十进制数字组成的。

py
>>> 42
42

表示数字的表达式可以和数学运算符结合,构成复合表达式,解释器会求它的值:

py
>>> -1 - -1
0
>>> 1/2 + 1/4 + 1/8 + 1/16 + 1/32 + 1/64 + 1/128
0.9921875

这些数学表达式用的是中缀表示法(infix notation):运算符(operator,比如 +、-、*、/)夹在操作数(operand,也就是数字)中间。Python 构造复合表达式的方式很多。与其一下子全列出来,不如边学边引入新的表达式形式,以及它们对应的语言特性。

1.2.2 调用表达式 ​

最重要的复合表达式是调用表达式(call expression),它把函数作用到一些参数上。回想代数里的定义:函数就是把若干输入参数映射成一个输出值。比如 max 函数,把多个输入映射成其中最大的那个输出。Python 表达函数调用的方式,和传统数学一样。

py
>>> max(7.5, 9.5)
9.5

这个调用表达式包含子表达式(subexpressions):运算符是一个表达式,位于括号前面;括号里是一串用逗号隔开的操作数表达式(operand expressions)。

调用表达式的运算符与操作数

运算符指定了一个函数。求这个调用表达式的值时,我们就说:用参数 7.5 和 9.5 调用函数 max,它返回 9.5。

参数在调用表达式中的顺序很重要。比如 pow 函数,以第二个参数为指数,对第一个参数求幂:

py
>>> pow(100, 2)
10000
>>> pow(2, 100)
1267650600228229401496703205376

相比传统的中缀表示法,函数表示法有三个主要优点。首先,函数可以接受任意数量的参数:

py
>>> max(1, -2, 3, -4)
3

不会产生歧义,因为函数名永远排在参数前面。

其次,函数表示法能直接扩展到嵌套表达式(nested expressions)——元素本身也是复合表达式。和中缀复合表达式不同,嵌套调用表达式的结构,在括号里一目了然。

py
>>> max(min(1, -2), min(pow(3, 5), -4))
-2

原则上,嵌套可以有多深、表达式能有多复杂,都没有上限,Python 解释器都能求值。但人很快就会被多层嵌套绕晕。作为程序员,你的一个重要任务,就是把表达式的结构组织好,让自己、同伴,以及以后可能读到你表达式的人都能看懂。

第三,数学符号花样太多:乘法写在两项之间,指数写成上标,除法用横杠,平方根像带斜边的屋顶,有些符号还特别难打!但这些花样,都能用调用表达式统一起来。Python 虽然支持用中缀写常见运算符(比如 +、-),但任何运算符都能写成一个有名字的函数。

1.2.3 导入库函数 ​

Python 定义了大量函数,包括上一节提到的运算符函数,但默认不会把所有函数名都直接给你用。它把知道的函数和其他东西整理进模块(module),这些模块合起来就是 Python 标准库。要用的时候,得先把它们导入(import)。比如 math 模块提供了各种常见的数学函数:

py
>>> from math import sqrt
>>> sqrt(256)
16.0

而 operator 模块提供了中缀运算符对应的函数:

py
>>> from operator import add, sub, mul
>>> add(14, 28)
42
>>> sub(100, mul(7, add(8, 4)))
16

import 语句先指定模块名(比如 operator 或 math),再列出要从这个模块导入的命名属性(比如 sqrt)。函数一旦导入,就能反复调用。

用运算符函数(比如 add)和直接用运算符符号(比如 +),没有区别。按惯例,简单的算术运算,多数程序员还是用符号和中缀表示法。

Python 3 库文档 列出了每个模块定义的函数,比如 math 模块。不过,这份文档是写给熟悉整门语言的开发者的。眼下,动手试一个函数,比读文档更能弄清它的行为。等你熟悉了 Python 的语言和词汇,它就会成为很有价值的参考资料。

1.2.4 名称与环境 ​

编程语言的一个关键之处,在于能用名称指代计算对象。一个值一旦有了名字,我们就说名称绑定(binds)到了这个值上。

在 Python 里,我们用赋值语句建立新的绑定:= 左边是名称,右边是值:

py
>>> radius = 10
>>> radius
10
>>> 2 * radius
20

名称也可以通过 import 语句绑定:

py
>>> from math import pi
>>> pi * 71 / 223
1.0002380197528042

等号 = 在 Python(以及许多其他语言)里叫赋值运算符(assignment operator)。赋值是最简单的抽象方法:它让我们能用简单的名称,指代复合运算的结果,比如上面算出的 area。就这样一步一步搭出越来越复杂的计算对象,复杂的程序也就构造出来了。

名称能绑定到值,之后又能靠名称取回值,这意味着解释器得维护一块内存,把名称、值和绑定关系都记下来。这块内存就叫环境(environment)。

名称也可以绑定到函数。比如名称 max,就绑定在我们一直用的 max 函数上。和数字不同,函数很难用文字表示,所以你要查看一个函数时,Python 会打印一段标识说明:

py
>>> max
<built-in function max>

我们可以用赋值语句给已有的函数起新名字:

py
>>> f = max
>>> f
<built-in function max>
>>> f(2, 3, 4)
4

连续的赋值语句还能把名称重新绑定到新值:

py
>>> f = 2
>>> f
2

在 Python 里,名称常被叫作变量名(variable names)或变量(variables),因为它们在程序执行过程中能绑定到不同的值。名称一旦被赋成新值,就不再指向旧值。你甚至可以把内置名称也绑到新值上:

py
>>> max = 5
>>> max
5

把 max 赋成 5 之后,名称 max 就不再绑定函数了,这时调用 max(2, 3, 4) 就会报错。

执行赋值语句时,Python 会先求值 = 右侧的表达式,再修改左侧名称的绑定。所以,右侧表达式里可以引用某个名称,哪怕它正是这次赋值要绑定的名称:

py
>>> x = 2
>>> x = x + 1
>>> x
3

一条语句里还能同时给多个名称赋多个值:= 左边的名称、右边的表达式,都用逗号隔开:

py
>>> area, circumference = pi * radius * radius, 2 * pi * radius
>>> area
314.1592653589793
>>> circumference
62.83185307179586

改变一个名称的值,不会影响其他名称。下面这个例子里,area 当初是按 radius 算出来绑定的,但改 radius 并没有影响 area。想更新 area,得再写一条赋值语句。

py
>>> radius = 11
>>> area
314.1592653589793
>>> area = pi * radius * radius
380.132711084365

多重赋值时,= 右边的所有表达式都会先求值,然后才绑定到左边的名称。正因为这条规则,交换两个名称绑定的值,一条语句就能完成。

py
>>> x, y = 3, 4.5
>>> y, x = x, y
>>> x
4.5
>>> y
3

1.2.5 求值嵌套表达式 ​

本章的一个目标,是把程序化思维(procedural thinking)单独拎出来讲。举个例子:求值嵌套的调用表达式时,解释器自己其实也在按一套固定的流程走。

Python 求值一个调用表达式时,会这样做:

  1. 先求值运算符和操作数子表达式,得到它们的值;
  2. 再把运算符子表达式的值(也就是函数),作用到操作数子表达式的值(也就是参数)上。

就算规则这么简单,也点出了「计算过程」的一些本质。第 1 步就要求:要完成一个调用表达式的求值,得先求值别的表达式。所以,这套求值流程本质上是递归的——它把这条规则自己也当成了其中一步。

比如,下面这行代码:

py
>>> sub(pow(2, add(1, 10)), pow(2, 5))
2016

要算出结果,这套求值流程一共要用到四次。把每一步要求值的表达式都画出来,就能看清它的层次结构:

表达式求值的层次结构

这个图叫表达式树(expression tree)。在计算机科学里,树习惯上是从上往下长。树上每个点都叫节点(node),在这里,每个节点都是「表达式 + 它的值」。

求根节点(root,也就是顶部那个完整表达式)的值,得先求它各分支(branches,也就是子表达式)的值。没有分支的叶子节点(leaf)要么是函数,要么是数字。中间节点则有两部分:套用求值规则的调用表达式,以及求出的结果。按这棵树来看,可以想象操作数的值从最底下的叶子开始往上冒,一层层组合,最后汇到根节点。

接下来注意:第 1 步反复做下去,最后碰到的就不再是调用表达式,而是基本表达式,比如数字(2)和名称(add)。对这些基本情况,我们规定两条:

  • 数字字面量求值为它所指的那个数;
  • 名称求值为它在当前环境里绑定的那个值。

环境在这里很关键:不给环境信息,很多表达式的值就无从谈起。例如:

py
>>> add(x, 1)

如果不说清 x(甚至 add)在当前环境里绑的是什么,这个表达式的值就根本定不下来。环境为求值提供了上下文(context),这是我们理解程序执行的关键。

上面这套求值流程,目前只能处理调用表达式、数字和名称这三种,还不足以求值所有 Python 代码。比如,它处理不了赋值语句:

py
>>> x = 3

它不返回值,也不把函数作用到参数上——赋值的目的,只是把名称绑定到一个值。一般来说,语句不是被求值(evaluated),而是被执行(executed);它们不产生值,而是改变状态。每种表达式或语句,都有各自的求值或执行规则。

一点学究式的说明:我们说「数字字面量求值为一个数」,真正的意思是 Python 解释器把它求值成了一个数。赋予编程语言意义的,是解释器。正因为解释器是一个行为始终如一的固定程序,我们才可以说:在 Python 程序里,数字字面量(和表达式)本身就求值为某个值。

1.2.6 非纯函数 print ​

在本书中,我们会区分两种函数。

纯函数(Pure functions):有输入(参数),也有输出(返回值)。比如内置函数 abs:

py
>>> abs(-2)
2

可以把它想象成一台小机器:丢进去一个数,吐出来另一个数。

abs 函数:输入一个数,返回它的绝对值

纯函数的特性是:除了返回值之外没有别的副作用,而且同样的参数永远返回完全相同的结果。

非纯函数(Non-pure functions):除了返回值之外,调用它们还会产生副作用(side effect),也就是改变解释器或计算机的某种状态。最常见的副作用,就是用 print 在屏幕上多打印点东西:

py
>>> print(1, 2, 3)
1 2 3

print 和 abs 在这些例子里看着差不多,但工作方式根本不同。print 返回的值永远是 None——Python 里代表「空」的特殊值。交互式解释器不会主动打印 None。上面看到的 1 2 3,其实是 print 被调用时打印出来的副作用,不是它的返回值。

print 函数:返回 None

嵌套的 print 调用,更能看出它的非纯特性:

py
>>> print(print(1), print(2))
1
2
None None

如果你觉得这行输出很奇怪,画出表达式树就明白了:为什么会先打印 1 和 2,最后再打印两个 None。

一定要小心 print!它返回 None,所以千万别把它直接放在赋值语句右边:

py
>>> two = print(2)
2
>>> print(two)
None

纯函数受到严格限制:不能有副作用,也不能随时间改变行为。但这些限制换来的好处很大:

  1. 纯函数能可靠地嵌套组合成更复杂的表达式。前面 print 的例子说明,非纯函数放在子表达式里往往出问题;而 max、pow、sqrt 这类纯函数,随便嵌套都没事。
  2. 纯函数更好测试:同一组参数永远得到同一个返回值,直接和预期结果对比即可。本章后面会详细讲测试。
  3. 第 4 章会讲到,写并发程序(多个表达式可能同时求值)时,纯函数几乎是唯一安全的选择。

相比之下,第 2 章会介绍一大堆非纯函数,以及它们的典型用途。

正因为如此,本章剩下的部分,主要讲怎么定义和使用纯函数。print 只在要看中间计算结果时才用得上。

基于 MIT 许可发布