CS自学指南 CS61A: Structure and Interpretation of Computer Programs
第一章 使用函数构建抽象 :
1.2 编程要素 :
语言的三种机制 :
- 原始表达式和语句:语言所关心的最简单的个体
- 组合方法:由简单元素组合构建复合元素
- 抽象方法:命名复合元素,并将其作为单元进行操作
任何强大的编程语言都必须能表达基本的数据和函数,并且提供对函数和数据进行组合和抽象的方法
表达式表示的数字可以与数学运算符组合形成一个复合表达式,解释器将对其进行求值:
1
2
3
4
5
6
742
42
1 - -1 -
0
1/2 + 1/4 + 1/8 + 1/16 + 1/32 + 1/64 + 1/128
0.9921875
# 这些数学表达式使用中缀表示法(infix notation),运算符(例如 +、-、* 或 /)出现在操作数之间。最重要的一种复合表达式是调用表达式,它将函数运用于一些参数上
例如,max 函数会输出一个最大的输入值,也就是将多个输入映射到了单个输出上
1
2
3
4
5
6max(7.5, 9.5)
9.5
'''
调用表达式包含子表达式(subexpressions):在括号之前是一个运算符表达式,而括号里面是一个以逗号分隔的操作数表达式的列表
运算符指定了一个函数,在对这个调用表达式进行求值时,我们会说:使用参数 7.5 和 9.5 来调用函数 max,最后返回 9.5。
'''例如,pow 函数的第二个参数是第一个参数的幂
1
2
3
4pow(100, 2)
10000
pow(2, 100)
1267650600228229401496703205376函数符号相比传统的中缀数学符号有三个主要优点
- 函数名总是在参数前面,函数可以接收任意数量的参数而不会产生歧义
1
2max(1, -2, 3, -4)
3 - 函数可以直接扩展为嵌套(nested)表达式,其元素本身就是复合表达式。不同于中缀复合表达式,调用表达式的嵌套结构在括号中是完全明确的
1
2max(min(1, -2), min(pow(3, 5), -4))
-2 - 数学符号在形式上多种多样:星号表示乘法,上标表示幂指数,水平横杠表示除法,带有倾斜壁板的屋顶表示平方根,而其中一些符号很难被输入
- 函数名总是在参数前面,函数可以接收任意数量的参数而不会产生歧义
Python 将已知函数和其他东西组织起来放入到了模块中,而这些模块共同组成了 Python 库。我们要使用的时候需要导入它们
例如,math模块提供了各种熟悉的数学函数
1
2
3from math import sqrt
256) sqrt(
16.0例如,operator模块提供了中缀运算符对应的函数:
1
2
3
4
5from operator import add, sub, mul
14, 28) add(
42
100, mul(7, add(8, 4))) sub(
16import语句需要指定模块名称(例如operator或math),然后列出要导入该模块里的具名函数(例如sqrt)。一个函数被导入后就可以被多次调用
使用赋值语句建立新的绑定,=左边是名称,右边是值
1
2
3
4
5
6
7
8
9
1010 radius =
radius
10
2 * radius
20
# 名称也可以通过 import 语句绑定
from math import pi
71 / 223 pi *
1.0002380197528042将名称与值绑定,之后通过名称检索可能的值,就意味着解释器必须维护某种内存来记录名称、值和绑定,这种内存就是环境(environment)
名称也可以与函数绑定。例如,名称 max 就和我们之前使用的 max 函数进行了绑定。与数字不同,函数很难以文本呈现,因此当询问一个函数时,Python 会打印一个标识来描述:
1
2
3
4
5
6
7
8
9
10
11
12
13
14max
<built-in function max>
# 赋值语句可以为现有函数赋予新名称
max f =
f
<built-in function max>
2, 3, 4) f(
4
# 之后再次赋值可以将已有名称与新值绑定
2 f =
f
2名称通常被称为“变量名 variable names”或“变量 variables”,因为它们可以在执行程序的过程中与不同的值绑定。当一个变量通过赋值语句与一个新值绑定,它就不再绑定以前的值。你甚至可以将内置名称与新值绑定
还可以在单个语句中为多个变量分配值,左右都用逗号隔开
1
2
3
4
52 * pi * radius area, circumference = pi * radius * radius,
area
314.1592653589793
circumference
62.83185307179586为了求值一个表达式,Python将执行以下操作:
- 求解运算符子表达式和操作数子表达式
- 然后将操作数子表达式的值作为运算符子表达式的函数的参数
求值程序本质上是递归的,也就是说它会自己调用自己作为步骤之一
函数分为两种类型:
- 纯函数(Pure functions):函数有一些输入(参数)并返回一些输出(调用返回结果)
1
2abs(-2)
2 - 非纯函数(Non-pure functions):除了返回值外,调用一个非纯函数还会产生其他改变解释器和计算机的状态的副作用(side effect)。一个常见的副作用就是使用 print 函数产生(非返回值的)额外输出
1
2
3
4print(2) two =
2
print(two)
None
- 纯函数(Pure functions):函数有一些输入(参数)并返回一些输出(调用返回结果)
1.3 定义新的函数 :
函数是一种更为强大的抽象技术,通过它可以将名称与复合操作绑定为一个单元
如何定义函数:函数定义包含 def 语句、<name 函数名> 和一个以逗号分隔的 <formal parameters 形式参数> 列表,然后是一个被称为函数体的 return 语句,它指定了调用函数时要计算的表达式,也就是函数的 <return expression 返回表达式> :
1
2def <name>(<formal parameters>):
return <return expression>函数的第二行必须进行缩进,大多数程序员使用四个空格。例如:
1
2
3
4
5
6
7
8
9def square(x):
return mul(x, x)
21) square(
441
2, 5)) square(add(
49
3)) square(square(
81复合函数:我们还可以将 square 作为一个构建单元来定义其他函数。例如,我们可以很容易地定义一个函数 sum_squares,给定任意两个数字作为参数,返回它们的平方和:
1
2
3
4
5def sum_squares(x, y):
return add(square(x), square(y))
3, 4) sum_squares(
25实现者为函数的形参选择的名称不应该影响函数行为。所以,以下函数应该提供相同的行为:
1
2
3
4def square(x):
return mul(x, x)
def square(y):
return mul(y, y)局部名称的作用域限于定义它的函数的主体,当一个名称不可再访问时,就是超出了作用域。这种界定作用域的行为并不是我们模型的新细节,而是环境工作方式的结果。
名称的选择注意事项:
- 函数名是小写的,单词之间用下划线分隔。鼓励使用描述性名称
- 函数名称通常反映解释器应用于参数的操作(例如,print,add, square)或结果(例如,max,abs,sum)
- 参数名称是小写的,单词之间用下划线分隔。首选单个词的名称
- 参数名称应该反映参数在函数中的作用,而不仅仅是允许的参数类型
- 当作用明确时,单字参数名称可以接受,但应避免使用 l(小写的 L)和 O(大写的 o)或 I(大写的 i)以避免与数字混淆
sum_squares 而言,square 并不是一个特定的函数体,而是一个函数的抽象,也就是所谓的函数抽象(functional abstraction)。在这个抽象层次上,任何计算平方的函数都是等价的。所以在只考虑返回值的情况下,以下两个计算平方数的函数是无法区分的:它们都接受数值参数并返回该数的平方值
1
2
3
4
5def square(x):
return mul(x, x)
def square(x):
return mul(x, x-1) + x
# 换句话说,函数定义能够隐藏细节。只需要调用,而不需要知道实现该功能的细节函数抽象的各个方面:思考抽象函数的三个核心属性通常对掌握其使用很有帮助
- 函数的域domain:是它可以接受的参数集合
- 范围range:是它可以返回的值的集合
- 意图intent:是计算输入和输出之间的关系(以及它可能产生的任何副作用)
/是常规除法,因此即使除数可以整除被除数,它也会产生浮点数(十进制小数):
1
2
3
45 / 4
1.25
8 / 4
2.0//会将结果向下舍入到一个整数:
1
2
3
45 // 4
1
5 // 4 -
-2这两个运算符算是truediv和floordiv函数的简写
1
2
3
4
5from operator import truediv, floordiv
5, 4) truediv(
1.25
5, 4) floordiv(
1
1.4 设计函数 :
函数定义通常包括描述函数的文档,称为“文档字符串 docstring”,它必须在函数体中缩进。文档字符串通常使用三个引号,第一行描述函数的任务,随后的几行可以描述参数并解释函数的意图:
1
2
3
4
5
6
7
8
9
10
11def pressure(v, t, n):
"""计算理想气体的压力,单位为帕斯卡
使用理想气体定律:http://en.wikipedia.org/wiki/Ideal_gas_law
v -- 气体体积,单位为立方米
t -- 绝对温度,单位为开尔文
n -- 气体粒子
"""
k = 1.38e-23 # 玻尔兹曼常数
return n * k * t / v当你使用函数名称作为参数调用 help 时,你会看到它的文档字符串(键入 q 以退出 Python help)
1
help(pressure)
可以为函数的参数提供默认值。当调用该函数时,具有默认值的参数是可选的。如果未提供,则将默认值绑定到形参上
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20def pressure(v, t, n=6.022e23):
"""计算理想气体的压力,单位为帕斯卡
使用理想气体定律:http://en.wikipedia.org/wiki/Ideal_gas_law
v -- 气体体积,单位为立方米
t -- 绝对温度,单位为开尔文
n -- 气体粒子,默认为一摩尔
"""
k = 1.38e-23 # 玻尔兹曼常数
return n * k * t / v
# =符号在此示例中表示两种不同的含义,具体取决于使用它的上下文。在def语句中,=不执行赋值,而是指示调用pressure函数时使用的默认值
1, 273.15) pressure(
2269.974834
1, 273.15, 3 * 6.022e23) pressure(
6809.924502
# pressure 函数的定义接收三个参数,但上面的第一个调用表达式中只提供了两个。在这种情况下,n 的值取自 def 语句中的默认值
# 如果提供了第三个参数,默认值将被忽略
1.5 控制 :
执行一个控制语句决定了解释器接下来应该做什么,而不是计算某些东西
语句不会被求解,而会被执行。每个语句都描述了对解释器状态的一些更改,并且执行语句就会应用该更改.正如我们在 return 和赋值语句中看到的那样,执行语句可能涉及求解其包含的子表达式
表达式也可以作为语句执行,在这种情况下,它们会被求值,但它们的值会被丢弃。执行纯函数没有效果,但执行非纯函数会因为调用函数而产生效果
1
2def square(x):
mul(x, x) # 小心!此调用不返回值。表达式本身是一个有效的语句,但语句的效果是调用 mul 函数,然后把结果丢弃。如果你想对表达式的结果做些什么,你需要用赋值语句存储它或用 return 语句返回它:
1
2def square(x):
return mul(x, x)有时在调用 print 等非纯函数时,拥有一个主体为表达式的函数确实有意义
1
2def print_square(x):
print(square(x))简单语句是不以冒号结尾的单行,而由其他语句(简单语句和复合语句)组成被称为复合语句。
复合语句通常跨越多行,以单行头部(header)开始,并以冒号结尾,其中冒号标识语句的类型。头部和缩进的句体(suite)一起称为子句。复合语句由一个或多个子句组成:
1
2
3
4
5
6
7
8
9<header>:
<statement>
<statement>
...
<separating header>:
<statement>
<statement>
...
...def 语句是复合语句,def 头后面的句体定义了函数体
多行程序:要执行一系列语句,会先执行第一个语句。如果该语句不重定向控制,则继续执行语句序列的其余部分(如果还有的话)
赋值语句可以出现在函数体内,称为局部赋值,函数体内的赋值语句不会影响全局
条件语句(Conditional statement):Python 中的条件语句由一系列头部和句体组成:必需的 if 子句、可选的 elif 子句序列,最后是可选的 else 子句:
1
2
3
4
5
6if <expression>:
<suite>
elif <expression>:
<suite>
else:
<suite>执行条件语句时,每个子句都会按顺序被考虑。执行条件子句的计算过程如下:
- 求解头部的表达式
- 如果它是真值,则执行该句体。然后,跳过条件语句中的所有后续子句
条件块头部语句内的表达式被称为布尔上下文:它们值的真假对控制流很重要,另外,它们的值不会被赋值或返回
布尔值(Boolean values):Python 有两个布尔值,分别叫做 True 和 False 。布尔值表示逻辑表达式中的真值。内置的比较运算符 >, <, > =, <=, ==, != 会返回这些值
逻辑表达式的真值可以在不对其所有子表达式求值的情况下确定,这一特性称为短路(short-circuiting)
- 求解表达式
and 的步骤如下: - 求解子表达式
- 如果左边的结果为假值 v,则表达式的计算结果就是 v
- 否则,表达式的计算结果为子表达式
的值
- 求解子表达式
- 求解表达式
or 的步骤如下: - 求解子表达式
- 如果左边的结果为真值 v,则表达式的计算结果就是 v
- 否则,表达式的计算结果为子表达式
的值
- 求解子表达式
- 求解表达式 not
的步骤如下: - 求解
,如果结果为假值,则值为 True ,否则为 False
- 求解
- 求解表达式
while 子句包含一个头部表达式,后跟一个句体:
1
2while <expression>:
<suite>要执行 while 子句:
- 求解头部的表达式
- 如果是真值,则执行后面的句体,然后返回第 1 步
为了防止 while 子句的句体无限期地执行,句体应该总是在每次循环中更改一些绑定
断言(Assertions):程序员使用 assert 语句来验证是否符合预期,例如验证被测试函数的输出。
assert 语句在布尔上下文中有一个表达式,后面是一个带引号的文本行(单引号或双引号都可以,但要保持一致),如果表达式的计算结果为假值,则显示该行
1
assert fib(8) == 13, '第八个斐波那契数应该是 13'
当被断言的表达式的计算结果为真值时,执行断言语句无效。而当它是假值时,assert 会导致错误,使程序停止执行
文档测试(Doctests):Python 提供了一种方便的方法,可以将简单的测试直接放在函数的文档字符串中doctest模块来验证交互
1.6 高阶函数 :
强大的编程语言提出的要求之一就是能够通过将名称分配给通用模板(general patterns)来构建抽象,然后直接使用该名称进行工作
为了将某些通用模板实例化(named concepts),我们需要构造一种“可以接收其他函数作为参数”或“可以把函数当作返回值”的函数。这种可以操作函数的函数就叫做高阶函数(higher-order functions)
1 | # 第一个 sum_naturals 会计算从 1 到 n 的自然数之和 |
1 | # 第二个 sum_cubes 函数会计算 1 到 n 的自然数的立方之和 |
1 | # 第三个 pi_sum 会计算下列各项的总和,它的值会非常缓慢地收敛(converge)到 PI |
这三个函数显然在背后共享着一个通用的模板(pattern)。它们在很大程度上是相同的,仅在名称和用于计算被加项 k 的函数上有所不同。我们可以通过在同一模板中填充槽位(slots)来生成每个函数:
1
2
3
4
5def <name>(n):
total, k = 0, 1
while k <= n:
total, k = total + <term>(k), k + 1
return total在下面的示例中,summation 函数将上界 n 和计算第 k 项的函数 term 作为其两个参数。我们可以像使用任何函数一样使用 summation ,它简洁地表达了求和
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16# 使用会返回其参数的 identity 函数,我们还可以使用完全相同的 summation 函数对自然数求和。将函数作为参数传入
def summation(n, term):
total, k = 0, 1
while k <= n:
total, k = total + term(k), k + 1
return total
def identity(x):
return x
def sum_naturals(n):
return summation(n, identity)
10) sum_naturals(
55
# summation 函数也可以直接调用,而无需为特定的数列去定义另一个函数
10, square) summation(
385有了高阶函数后,我们会看到一种更强大的抽象:用一些函数来表达计算的通用方法(general methods),而且和它们调用的特定函数无关
例如下面的黄金分割率函数,迭代改进算法从方程的 guess 解(推测值)开始,重复应用 update 函数来改进该猜测,并调用 close 比较来检查当前的 guess 是否已经“足够接近”正确值
1
2
3
4def improve(update, close, guess=1):
while not close(guess):
guess = update(guess)
return guess这个 improve 函数是迭代求精(repetitive refinement)的通用表达式。它并不会指定要解决的问题,而是会将这些细节留给作为参数传入的 update 和 close 函数
黄金比例的一个著名的特性是它可以通过反复叠加任何正数的倒数加上 1 来计算,而且它比它的平方小 1。我们可以将这些特性表示为与 improve 一起使用的函数
1
2
3
4
5
6
7
8
9
10def golden_update(guess):
return 1/guess + 1
def square_close_to_successor(guess):
return approx_eq(guess * guess, guess + 1)
# approx_eq 函数:如果其参数大致相等,则返回 True。为了实现 approx_eq ,我们可以将两个数字差的绝对值与一个小的公差值(tolerance value)进行比较
def approx_eq(x, y, tolerance=1e-15):
return abs(x - y) < tolerance使用参数 golden_update 和 square_close_to_successor 来调用 improve 将会计算出黄金比例的有限近似值
1 | improve(golden_update, square_close_to_successor) |
在这段代码中,close 是作为参数传递给 improve 函数的函数对象。close 实际上是 square_close_to_successor 函数的别名或实际值。虽然没有看到类似 def close() 的定义,但 square_close_to_successor 这个函数通过参数传递给了 close,因此 close 事实上指向 square_close_to_successor。这个方式是高阶函数的典型用法,允许将函数作为参数传递并在另一个函数内调用
首先,将 update、close 和 guess 绑定在构造 improve 的局部帧上。然后在 improve 的函数体中,将名称 close 绑定到 square_close_to_successor ,它会使用 guess 的初始值进行调用
这个例子说明了计算机科学中两个相关的重要思想:
- 命名和函数使我们能将大量的复杂事物进行抽象。虽然每个函数定义都很简单,但我们的求解程序触发的计算过程非常复杂
- 正是由于我们对 Python 语言有一个极其通用的求解过程,小的组件才能组合成复杂的程序
嵌套定义函数,将函数定义放在另一个函数定义内,例如:
1
2
3
4
5
6def sqrt(a):
def sqrt_update(x):
return average(x, a/x)
def sqrt_close(x):
return approx_eq(x * x, a)
return improve(sqrt_update, sqrt_close)与局部赋值一样,局部的 def 语句只影响局部帧。被定义的函数仅在求解 sqrt 时在作用域内。而且这些局部 def 语句在调用 sqrt 之前都不会被求解,与求解过程一致
通过创建“返回值就是函数”的函数,我们可以在我们的程序中实现更强大的表达能力
函数组合(composition)就成为编程语言中的一种自然的组合方法。也就是说,给定两个函数 f(x) 和 g(x),我们可能想要定义 h(x) = f(g(x))。我们可以使用我们现有的工具来定义函数组合:
1
2
3
4def compose1(f, g):
def h(x):
return f(g(x))
return h