Skip to content

1.2 编程要素

INFO

译者:mancuojclcs

来源:1.2 Elements of Programming

对应:HW 01

编程语言不仅仅是指导计算机执行任务的工具,它还应该充当一个框架,使我们能够在其中组织自己有关计算过程的思想。程序也用于在编程社区的成员之间传达想法,因此,程序必须首先为人类阅读而编写,其次才是为机器执行而编写。

这样,当我们描述一种语言时,应特别关注该语言所提供的能够将简单想法组合成复杂想法的机制。每一种强大的语言都有这样三种机制:

  • 原始表达式和语句(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),运算符(例如 +-*/)出现在操作数之间。Python 包含许多种形成复合表达式的方法,我们会在学习中慢慢引入新的表达式形式和它们所支持的语言特性,而不是立即把它们列举出来。

1.2.2 调用表达式

最重要的一种复合表达式是调用表达式(call expression),它将函数应用于某些参数上。回想一下代数中的概念,函数的数学概念是将某些输入参数映射到一个输出值的过程。例如,max 函数会输出一个最大的输入值,也就是将多个输入映射到了一个输出。Python 中函数应用的方式与传统数学相同。

py
>>> max(7.5, 9.5)
9.5

这个调用表达式包含子表达式(subexpressions):运算符(operator)是一个表达式,它位于括号之前,而括号里面是一个以逗号分隔的操作数表达式(operand expressions)的列表。

call_expression

运算符指定了一个函数,在对这个调用表达式被求值时,我们会说:使用参数 7.59.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 定义了大量的函数,包括上一节中提到的运算符函数,但默认情况下我们不能直接使用名字来调用它们。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 语句需要指定模块名称(例如 operatormath),然后列出要导入该模块里的命名属性(例如 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 定义的,但 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

这个图叫做表达式树(expression tree),在计算机科学里,树的生长方向通常是从上往下长的。树上的每一个点都叫作节点(nodes),这里每个节点都是一对“表达式+它的值”。

根节点(root,即顶部的完整表达式)的值,必须先求它的各分支(branches,子表达式)的值。那些没有分支的叶子节点(leaf)要么是函数,要么是数值。而中间的节点则包含两部分:我们要应用求值规则的调用表达式,以及应用规则后得到的结果。从这棵树的角度来看,可以想象数值从最底层的叶子节点开始,一层一层往上传递、组合,最终汇聚到根节点。

接下来要注意的是:反复执行第 1 步,我们最终会碰到不再是调用表达式,而是最基本的原始表达式,例如数字(例如 2)和名称(例如 add)。我们通过下面这两条约定来处理这些原始情况:

  • 数值就是数字的值(A numeral evaluates to the number it names)
  • 名称取它在当前环境中绑定的值

环境在这里起到了至关重要的作用。如果不给出环境信息,很多表达式的值根本无从谈起。例如:

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

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

function_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 在执行过程中主动打印出来的副作用,而不是它的返回值。

function_print

一个嵌套的 print 调用能更清楚地展示它的非纯特性

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

如果你觉得这行输出很奇怪,建议画出表达式树,你立刻就能明白为什么会先打印 12,再打印两个 None

一定要小心 print!因为它返回 None,所以绝不能把它直接用在赋值语句的右边:

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

纯函数受到严格限制:不能有副作用、不能随时间改变行为,但这恰恰带来了巨大好处:

  1. 纯函数可以非常可靠地嵌套组合成更复杂的表达式。前面 print 的例子已经说明,非纯函数在子表达式位置往往会出问题;而 max、pow、round 这类纯函数却可以随意嵌套
  2. 纯函数极易测试:同样的参数永远得到同样的返回值,直接和预期结果对比即可。
  3. 第 4 章会讲到,编写并发程序时(多个表达式可能同时求值),纯函数几乎是唯一安全的选择。

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

正因为如此,本章余下内容将主要围绕如何定义和使用纯函数展开。print 只在需要看到中间计算结果时才会被用到。

基于 MIT 许可发布

布局切换

调整 VitePress 的布局样式,以适配不同的阅读习惯和屏幕环境。

全部展开
使侧边栏和内容区域占据整个屏幕的全部宽度。
全部展开,但侧边栏宽度可调
侧边栏宽度可调,但内容区域宽度不变,调整后的侧边栏将可以占据整个屏幕的最大宽度。
全部展开,且侧边栏和内容区域宽度均可调
侧边栏宽度可调,但内容区域宽度不变,调整后的侧边栏将可以占据整个屏幕的最大宽度。
原始宽度
原始的 VitePress 默认布局宽度

页面最大宽度

调整 VitePress 布局中页面的宽度,以适配不同的阅读习惯和屏幕环境。

调整页面最大宽度
一个可调整的滑块,用于选择和自定义页面最大宽度。

内容最大宽度

调整 VitePress 布局中内容区域的宽度,以适配不同的阅读习惯和屏幕环境。

调整内容最大宽度
一个可调整的滑块,用于选择和自定义内容最大宽度。

聚光灯

支持在正文中高亮当前鼠标悬停的行和元素,以优化阅读和专注困难的用户的阅读体验。

ON开启
开启聚光灯。
OFF关闭
关闭聚光灯。