CS61A Sec1
观前须知:
本章节内容基于 Berkeley 大学的教材 Composing Programs by John DeNero,并在此基础上做了部分修改,是在知识共享协议下所许可的。
计算机科学的高生产力之所以可能,是因为该学科是建立在一套优雅而强大的基本思想之上。所有的程序都从信息的表示开始,然后寻找一种逻辑来处理这些信息,并设计抽象概念来解释和控制这种逻辑。有了这些认识后,我们就需要准确的理解计算机是如何解释我们写的程序并进行计算的。
A language isn't something you learn so much as something you join.
—Arika Okrent
为了定义计算过程,我们需要一种编程语言;最好是许多人类和大量的计算机都能理解的语言。所以在 cs61a 中,伯克利主要使用 Python 语言来进行教学。
(在之前的 cs61a 课程中,Berkeley 大学主要使用 Scheme 来进行教学,(可能会写一篇文章来说说 Python 和 Scheme 语言和编程上的区别?) 不过在现在的课程中还是有关于 Scheme 的内容,所以并不用太过伤心?🙄)
学习目标
在这一部分我们要学习的内容主要是函数(Functions)和控制(Control)
一个简单的例子
为了给 Python 一个适当的介绍,我们将从一个使用几种语言特征的例子开始。
Python 内置了对广泛的常见编程的支持,如操作文本、显示图形和通过互联网进行通信。
from urllib.request import urlopen
这个 Python 代码是一个导入语句,加载了在互联网上访问数据的功能。实际上,它提供了一个叫做 urlopen 的函数,它可以在一个统一资源定位符(URL)上访问内容,可以通过它来访问互联网上的数据。
语句和表达式
Python 代码由语句和表达式组成。大体上,计算机程序由以下指令组成
- 计算一些值
- 进行一些操作
语句通常描述行动;当 Python 解释器执行一个语句时,它执行相应的动作。另一方面,表达式通常描述的是计算;当 Python 评估一个表达式时,它计算该表达式的值。在这篇文章下,介绍了几种类型的声明和表达方式。
赋值语句
shakespeare = urlopen('http://www.composingprograms.com/shakespeare.txt')
注意:在伯克利大学的教材中,上述代码中的 url 并没有添加 "www.",导致现在(至少在写这篇文章的时候)无法打开原文中的 url(可能还会写一篇文章来讲解 "www."?)
将变量名 shakespeare
用 =
和后面的表达式的值联系起来。该表达式将 urlopen
函数应用于一个 URL,该 URL 包含威廉 - 莎士比亚 37 部戏剧的完整文本,全部保存在一个文本文件中。
函数
函数封装了操作数据的逻辑。
这句话告诉我们,可以从两个角度来看函数:
- 在调用函数的时候,我们关注的是要处理的数据
- 在定义函数的时候,我们关注的是如何处理数据
shakespeare = urlopen('http://www.composingprograms.com/shakespeare.txt')
urlopen
是一个函数。一个网络地址是一种数据,而莎士比亚戏剧的文本是另一种数据。从网络地址到文本的过程可能很复杂,但我们可以只用一个简单的表达式来应用这个过程,因为这个复杂性被藏在一个函数中。
你可能不了解 urlopen
这个函数背后的逻辑,但这不影响你去调用这个函数,这就是函数封装的好处之一。
因此,函数是本章节关注的重点。
我们来看另一个赋值语句:
words = set(shakespeare.read().decode().split())
这个语句将名字词与莎士比亚戏剧中出现的所有出现过的词(重复出现的词只统计一次)的集合联系起来,其中有 33,721 (?) 个词。上述语句包含一个读取、解码和分割的命令链,每个命令都在一个中间计算实体上操作:我们从打开的 URL 中读取数据,然后将数据解码成文本,最后将文本分割成单词。所有这些词都被放在一个集合(Set,Python 中的一种数据类型)中。
对象
前文中提到的 Set,不仅仅是数据类型,也是一个对象。对象用一种能同时处理两者复杂性的方式,把数据和操作该数据的逻辑无缝衔接在一起。
对象会是我们后面章节所要讨论的内容。
现在让我们来看这个例子中的最后一个语句:
>>> {w for w in words if len(w) == 6 and w[::-1] in words}
{'redder', 'drawer', 'reward', 'diaper', 'repaid'}
2
第一行的 ">>>" 表示输入,第二行则是交互式会话的输出
这是一个复合表达式,其值是所有长度为 6 的、本身和反向拼写都在原集合中的词组成的集合。其中的 w[::-1]
是一种隐式表达,它枚举了 w
中的所有字母,但因为 step = -1
规定了步长是反方向的。
解释器
计算复合表达式需要一个精确的程序,以可预测的方式解释代码。一个能实现程序和计算符合表达式的程序被称为解释器;没错,其实解释器是程序(可能再写一篇文章来讲讲解释器和编译器的区别?)
与其他计算机程序相比,编程语言的解释器在通用性方面是独一无二的。Python 的设计没有考虑到莎士比亚,然而,它的不可思议的灵活性使我们能够只用几个语句和表达式来处理大量的文本。
最后,我们会发现所有这些核心概念都是密切相关的:函数是对象,对象是函数,而解释器是两者的实例。然而,要掌握编程的艺术,关键是要清楚地理解每个概念及其在组织代码中的作用。
解释器的设计和实现也是我们之后的主要议题。
编程原本
编程语言不仅仅是指示计算机执行任务的一种手段,同时也是一个框架,我们在这个框架内组织我们关于计算过程的想法。程序的作用是在编程社区的成员之间交流这些想法,所以,编写的程序必须让人们容易阅读,而且只是顺便让机器执行。
当我们描述一种语言时,我们应该特别注意该语言为结合简单的想法以形成更复杂的想法所提供的手段。
每种强大的语言都有三种这样的机制:
- 原始的表达式和语句,代表了该语言提供的最简单的构建模块。
- 组合的方式,由较简单的元素建立成复合元素。
- 抽象的手段,通过它,复合元素可以作为单位被命名和操作。
在编程中,我们处理两种元素:函数和数据。(很快就会发现,它们其实并不那么明显)。不那么正式地说,数据是我们想要操作的东西,而函数描述了操作数据的规则。因此,任何强大的编程语言都应该能够描述原始数据和原始函数,以及有一些方法来组合和抽象函数和数据。
在上一小节中对 Python 解释器进行了实验后,我们现在重新开始,有条不紊地逐个开发 Python 语言元素。如果例子看起来很简单,那就耐心一点,因为更多令人兴奋的点很快就会出现。
我们从原始表达式开始。一种原始表达式是数字。更确切地说,你输入的表达式由代表十进制的数字组成。
>>> 42
42
2
代表数字的表达式可以与数学运算符相结合,形成一个复合表达式,解释器将对其进行计算。
>>> -1 - -1
0
>>> 1/2 + 1/4 + 1/8 + 1/16 + 1/32 + 1/64 + 1/128
0.9921875
2
3
4
这些数学表达式使用中缀符号,其中运算符(例如,+,-,,或 /)出现在操作数 *(数字)之间。Python 包括许多形成复合表达式的方法。我们不会试图立即列举它们,而是会随着我们的学习引入新的表达形式,以及它们所支持的语言特性。
最重要的一种复合表达式是调用表达式,它将一个函数应用于一些参数。回顾一下代数,函数的数学概念是一个从一些自变量到因变量的映射。例如,一个求最大值的函数将其的多个输入映射到当中最大值的一个单一的输出。Python 表达函数应用的方式与传统数学中相同。
>>> max(7.5, 9.5)
9.5
2
这个调用表达式有子表达式:操作符是括号前的表达式,它包含了一个用逗号分隔的操作数列表。
运算符指定了一个函数。当这个调用表达式被评估时,我们说对参数 7.5
和 9.5
调用函数 max
,并返回一个 9.5 的返回值。
调用表达式中参数的顺序很重要。例如,函数 pow
计算第一个参数的第二个参数次方。
>>> pow(100, 2)
10000
>>> pow(2, 100)
1267650600228229401496703205376
2
3
4
与中缀表示法的数学约定相比,函数表示法有三个主要优点。首先,函数可以接受任意数量的参数:
>>> max(1, -2, 3, -4)
3
2
不会产生歧义,因为函数名总是优先于其参数。
此外,函数符号以一种直接的方式延伸到嵌套表达式,其中的元素本身就是复合表达式。在嵌套的调用表达式中,与复合的中缀表达式不同,嵌套的结构在括号中是完全明确的。
>>> max(min(1, -2), min(pow(3, 5), -4))
-2
2
对于这种嵌套的深度以及 Python 解释器可以计算的表达式的整体复杂性,(原则上)没有限制。然而,人类很快就会被多级嵌套所迷惑。作为一个程序员,你的一个重要作用是构造表达式,使它们仍然可以由你自己、你的编程伙伴和其他将来可能阅读你的表达式的人来解释。
同时,数学符号有各种各样的形式:乘法出现在术语之间,指数显示为上标,除法显示为斜杠,平方根显示为有斜边的屋顶。其中一些符号是很难打出来的!然而,所有这些复杂性都可以通过调用表达式的符号来统一。虽然 Python 支持使用中缀表达式的常见数学运算符(如 +
和 -
),但任何运算符都可以表示为一个有名称的函数。
导入库函数
Python 定义了大量的函数,包括上一节中提到的运算符函数,但默认不提供它们的所有名称。作为替代,它将函数和其他量组织到模块中,这些模块共同构成了 Python 库。为了使用这些元素,人们将它们导入。例如,数学模块提供了各种熟悉的数学相关的函数。
>>> from math import sqrt
>>> sqrt(256)
16.0
2
3
而运算符模块提供了对应于中缀表达式的函数的访问:
>>> from operator import add, sub, mul
>>> add(14, 28)
42
>>> sub(100, mul(7, add(8, 4)))
16
2
3
4
5
一个导入语句指定了一个模块的名称(例如, operator
或 math
),然后列出要导入的该模块的命名属性(例如, sqrt
)。一旦一个函数被导入,它可以被多次调用。
使用这些运算符函数(如 add
)和运算符符号本身(如 +
)之间没有区别。传统上,大多数程序员使用符号和中缀表达式来表达简单的算术。
Python 3 库文档列出了每个模块所定义的功能,如数学模块。然而,这个文档是为那些对整个语言很了解的开发者编写的。现在,你可能会发现,对一个函数进行实验比阅读文档能告诉你更多关于它的行为。随着你对 Python 语言和词汇的熟悉,这个文档将成为有价值的参考来源。
变量名和环境
编程语言的一个关键方面是它提供了使用变量名来指代计算对象的手段。如果一个值被赋予了一个变量名,我们就说这个变量名与这个值绑定了。
在 Python 中,我们可以使用赋值语句建立新的绑定,其中包含左边的变量名 =
右边的值。
>>> radius = 10
>>> radius
10
>>> 2 * radius
20
2
3
4
5
变量名也是可以通过导入语句来绑定的。
>>> from math import pi
>>> pi * 71 / 223
1.0002380197528042
2
3
=
符号在 Python(以及许多其他语言)中被称为赋值操作符。赋值是我们最简单的抽象手段,因为它允许我们使用简单的名称来指代复合操作的结果。用这种方式,复杂的程序就是通过一步一步地建立复杂度越来越高的计算对象来构建的。
将变量名与值绑定,然后通过变量名检索这些值意味着解释器必须保持某种内存,以跟踪变量名、值和绑定。这样的内存空间被称为环境。
变量名也可以被绑定到函数上。例如,变量名 max
与我们使用的求最大值的函数绑定。与数字不同的是,函数在呈现为文本时很棘手,所以当被要求描述一个函数时,Python 会打印一个识别描述。
>>> max
<built-in function max>
2
我们可以使用赋值语句给现有的函数起别名。
函数也可以看作是值。
>>> f = max
>>> f
<built-in function max>
>>> f(2, 3, 4)
4
2
3
4
5
在同一个环境下的连续的赋值语句可以将一个名字重新绑定到一个新的值。
>>> f = 2
>>> f
2
2
3
在 Python 中,名称通常被称为变量名或变量,因为它们在执行程序的过程中可能被绑定到不同的值。当一个名称通过赋值被绑定到一个新的值时,它就不再被绑定到任何以前的值。人们甚至可以将内置名称与新值绑定。
>>> max = 5
>>> max
5
2
3
在将 max
赋值为 5 后, max
这个名称不再与函数绑定,因此试图调用 max(2, 3, 4)
会造成错误。
在执行赋值语句时,Python 在改变对左边变量名的绑定之前,对 =
右边的表达式进行计算。因此,人们可以在右侧表达式中引用一个变量名,即使它是要被赋值语句绑定的变量名。
>>> x = 2
>>> x = x + 1
>>> x
3
2
3
4
我们还可以在一个语句中给多个变量名赋值,其中左边的变量名和右边的表达式分别用逗号隔开。
>>> area, circumference = pi * radius * radius, 2 * pi * radius
>>> area
314.1592653589793
>>> circumference
62.83185307179586
2
3
4
5
改变一个变量的值并不影响其他变量。下面,尽管变量名 area
被绑定到一个最初以 radius
定义的值,但 area
的值并没有改变。更新 area
的值需要另一个赋值语句。
>>> radius = 11
>>> area
314.1592653589793
>>> area = pi * radius * radius
380.132711084365
2
3
4
5
通过多重赋值的语句,在左边的任何变量名被绑定到这些值之前,右边的所有表达式都将被计算。由于这个规则,交换绑定在两个变量名上的值可以在一个语句中进行。
>>> x, y = 3, 4.5
>>> y, x = x, y
>>> x
4.5
>>> y
3
2
3
4
5
6
计算嵌套表达式
我们在本小节的目标之一是分离出关于像程序一样思考的问题。从下面这个例子中,我们应该意识到,在计算嵌套调用表达式时,解释器本身是在遵循某种步骤。
为了计算一个调用表达式,Python 将做按以下规则来工作:
- 计算运算符和操作数的子表达式,然后
- 将作为运算符子表达式的值的函数应用于作为运算符子表达式的值的参数。
即使这是个简单的程序也说明了关于一般过程的一些重要观点。第一步决定了为了完成一个调用表达式的计算过程,我们必须首先计算其他表达式。因此,计算过程在本质上是递归的;也就是说,作为其步骤之一,它也包括调用规则本身。
例如,计算
>>> sub(pow(2, add(1, 10)), pow(2, 5))
2016
2
需要这个按照上述过程重复四次。如果我们画出每个被计算的表达式,我们就可以直观地看到这个过程的层次结构。
这张插图被称为表达式树。在计算机科学中,树(Tree,一种数据结构,我们将在后续的章节中进行讨论)通常是自上而下生长的。树中每一点的对象被称为节点;在这张插图的情况下,节点是与值配对的表达式。
计算它的根,即顶部的完整表达式,需要首先计算作为其子表达式的分支。叶表达式(即没有分支的节点)代表函数或数字。内部节点有两个部分:我们的计算规则所适用的调用表达式,以及该表达式的结果。从这棵树的计算来看,我们可以想象操作数的值是向上渗滤的,从末端节点开始,然后在越来越高的层级上进行组合。
接下来,观察一下,步骤一的重复应用使我们需要计算的不是调用表达式,而是数字(如 2
)和名称(如 add
)等原始表达式。
我们通过规定以下几点来处理这种情况:
- 数字计算为它的名称所代表的数量
- 名称计算为与当前环境中的名称相关的值。
请注意环境在决定表达式中符号的含义方面的重要作用。在 Python 中,在没有给定环境或是明确所有名称所指代的内容时,谈论一个表达式的价值是没有意义的,比如
>>> add(x, 1)
而不指定任何关于环境为名称 x
(甚至是名称 add
)提供意义的信息。环境提供了计算发生的背景,这对我们理解程序执行起着重要作用。
上述的计算过程不足以计算所有的 Python 代码,只计算调用表达式、数字和名称。
例如,它不处理赋值语句
>>> x = 3
这个语句不返回一个值,也不在某些参数上调用一个函数,因为赋值的目的是将一个变量名绑定到一个值上。
一般来说,赋值语句不是被计算而是被执行;它们不产生一个值,而是做一些改变。每种类型的表达式或语句都有自己的计算或执行过程。
纯函数和非纯函数
在本小节中,我们将区分两种函数 纯函数 函数有一些输入(它们的参数)并返回一些输出(应用它们的结果)。 例如内置函数
>>> abs(-2)
2
2
可以被描述为一台接受输入并产生输出的小型机器。 函数
abs
是纯函数。纯函数的特性是,调用它们除了返回一个值之外没有任何影响。此外,当用相同的参数调用两次时,一个纯函数必须总是返回相同的值。
非纯函数 除了返回一个值之外,应用一个非纯函数会产生副作用,从而使解释器或计算机的状态发生一些变化。一个常见的副作用是,使用 print
函数,在返回值之外产生额外的输出。
>>> print(1, 2, 3)
1 2 3
2
虽然 print
和 abs
在这些例子中可能看起来很相似,但它们的工作方式根本不同。打印返回的值总是 None
,这是一个特殊的 Python 值,不代表任何东西。交互式 Python 解释器不会自动打印值 None
。在 print
的情况下,函数本身是打印输出,也是被调用的副作用。
对 print
函数的嵌套调用突出了纯函数和非纯函数的区别
>>> print(print(1), print(2))
1
2
None None
2
3
4
如果你发现这个输出出乎意料,可以画一个表达式树来弄清楚为什么计算这个表达式会产生这个奇特的输出。
请注意! print
函数的返回值 None
意味着它不应该是赋值语句中的表达式。
>>> two = print(2)
2
>>> print(two)
None
2
3
4
纯函数是被限制的,因为它们不能有副作用或随时间改变行为。施加这些限制会产生巨大的好处。 首先,纯函数可以更可靠地组成复合调用表达式。我们可以在上面的非纯函数例子中看到, print
在操作数表达式中使用时并没有返回一个我们期望的结果。另一方面,我们已经看到,像 max
、 pow
和 sqrt
这样的函数可以有效地用于嵌套表达式。 其次,纯函数往往更容易测试。一个参数列表将总是导致相同的返回值,这可以与预期返回值进行比较。关于测试将在之后的章节详细讨论。
在之后的章节中,我们将说明纯函数对于编写并发程序的重要性,其中多个调用表达式可以同时被计算。 与之对应的,我们也将研究非纯函数并了解他们的用途。
出于这些问题的考虑,我们将在下一章节中着重讨论创建和使用纯函数。 print
函数的使用只是为了让我们看到计算的中间结果。
课后作业
一个好的课程怎么能少得了精心准备的课后作业呢?🤗
如果被题目卡住了,那就再去看看食用指南吧!😋
📥
本小节课后作业下载