CS61A Summary
Summary
没有太多充足的时间看Textbook,先暂时用GPT进行一些总结。
第一章:用函数构建抽象
1.1 入门
计算机科学是一个广泛的学科领域,涉及分布式系统、人工智能、机器人技术、图形学、安全性、科学计算等多个领域。计算机科学的迅速进步对人类生活的许多方面都产生了深远的影响,如商业、通信、科学、艺术、娱乐和政治。
计算机科学的高效生产力得益于一组优雅且强大的基本思想。所有计算都始于信息表示、处理逻辑的定义,以及用于管理这些逻辑复杂性的抽象设计。掌握这些基础知识要求我们精确理解计算机如何解释程序并执行计算过程。
1.1.1 使用 Python 编程
编程语言不仅是学习工具,更是让人融入开发者社区的桥梁。Python 是一种广泛使用的编程语言,拥有庞大的用户群。它的代码强调可读性、美观性和简洁性,因此非常适合作为教学语言。本书将通过 Python 来引入抽象和计算模型。
1.1.2 安装 Python 3
建议安装 Python 3 的最新稳定版本。本书不会使用 Python 2.7,因此确保下载 Python 3。
1.1.3 交互式会话
通过 Python 交互式会话,用户可以在提示符 >>>
后输入 Python 代码,并立即得到结果。这是一种与 Python
解释器互动的方式,可以帮助用户迅速了解代码的执行结果。
1.1.4 第一个示例
Python
具备广泛的功能支持,比如处理文本、显示图形以及网络通信。通过一个简单的例子展示了
Python 的多种功能,如使用 urlopen
函数从互联网获取数据,处理莎士比亚全集,并找出反转后依然是单词的六个字母的词。
1.1.5 错误处理
计算机强大但“愚蠢”,它们严格执行指令,但也容易因为细小错误而产生意外结果。程序员需要通过调试(debugging)来解决这些问题。调试的几个原则包括:增量测试、隔离错误、检查假设、以及寻求他人帮助。
本书将通过这些调试技巧帮助读者克服编程中的挑战,并培养在整个计算机科学生涯中的调试能力。
1.2 编程的元素
编程语言不仅是指令计算机执行任务的工具,还用于表达和交流关于计算过程的想法。编程不仅为了机器执行,也为了让人理解,因此编写的程序需要清晰易读。每种强大的编程语言都提供了三种机制来构建复杂的计算:
- 原始表达式和语句:最基本的构建模块。
- 组合方法:通过简单元素组合成更复杂的元素。
- 抽象方法:将复合元素命名并作为一个单元进行操作。
编程中的两大核心要素是函数和数据。数据是我们想要操作的内容,而函数是操作数据的规则。强大的编程语言应该能够描述原始数据、原始函数,并提供组合和抽象的方法。
1.2.1 表达式
表达式是程序的基本构件。最简单的表达式是数字,比如
42
。通过数学运算符(如
+
、-
、*
、/
),可以将简单表达式组合成复杂表达式。
1.2.2 调用表达式
调用表达式是最重要的复合表达式,用于调用函数并传入参数。比如
max(7.5, 9.5)
调用了 max
函数,传入了两个参数。函数调用的优点包括能够接收任意数量的参数、支持嵌套表达式,以及统一表示不同的操作符(如
pow
、add
)。
1.2.3 导入库函数
Python 提供了大量的内置函数和模块,但并非所有函数都默认可用。通过
import
语句可以引入模块中的函数。例如,math
模块提供数学函数,operator
模块提供对应运算符的函数。
1.2.4 名称与环境
Python
中的名称通过赋值语句绑定到值。比如,radius = 10
将名称 radius
绑定到值
10
。赋值是最简单的抽象形式,允许通过名称引用复杂计算的结果。环境是解释器跟踪名称和值的绑定关系的内存。
1.2.5 嵌套表达式的求值
Python 对调用表达式的求值过程是递归的。Python 先求值操作符和操作数,再应用函数。该过程可以用表达式树表示,树的叶子节点表示原始表达式(如数字或函数),内部节点表示调用表达式及其结果。
1.2.6 非纯粹的 print
函数
Python
中有两类函数:纯函数和非纯函数。纯函数只返回值,不会产生副作用;而非纯函数(如
print
)则会产生副作用,如输出结果。纯函数的优势包括更易于组合、测试和并发执行。
在 Python 中,函数定义是一个非常重要的抽象技术,它使我们可以把一组操作封装在一个函数中,然后通过函数名来引用和使用这些操作。以下是 Python 中定义和使用函数的核心概念和步骤:
1.3 定义新的功能
1.3.1 定义一个新函数
定义一个函数的语法格式如下:
def 函数名(参数): |
示例:定义一个平方函数
def square(x): |
在这个示例中,square
是函数名,x
是参数,return x * x
是函数的返回值,它会返回参数
x
的平方。
1.3.2 函数调用
定义函数后,我们可以通过函数名来调用它,并传递适当的参数。例如:
4) square( |
这个调用返回 16
,因为 4 * 4
等于
16
。
1.3.3 函数的参数
函数可以接收一个或多个参数。比如,我们可以定义一个函数来计算两个数的平方和:
def sum_squares(x, y): |
在这个函数中,x
和 y
是两个参数,返回的结果是 x
的平方加上 y
的平方。
3, 4) sum_squares( |
这个调用返回 25
,因为
3 * 3 + 4 * 4 = 9 + 16 = 25
。
1.3.4 局部变量与全局变量
在函数内部定义的参数和变量只在该函数的作用域内有效,我们称之为局部变量。外部的全局变量和函数内的局部变量是相互独立的,即使它们的名字相同也不会混淆。例如:
x = 10 # 全局变量 |
调用 foo(3)
返回 8
,因为函数内的
x
是局部变量,取值为 3
,而不是全局的
x = 10
。
1.3.5 函数的作用域与环境
在 Python 中,每当调用函数时,都会创建一个新的局部作用域,即一个独立的环境,用来存储函数参数和函数内定义的变量。Python 解释器通过这种方式确保变量不会相互混淆。
例如,调用 sum_squares(5, 12)
时,Python
会创建一个局部环境,其中 x = 5
和
y = 12
,然后依次计算它们的平方并返回结果。
1.3.6 抽象的作用
函数是非常有用的抽象工具,因为它们可以隐藏复杂的实现细节。你可以使用函数,而不需要知道它内部是如何实现的。对于使用者来说,函数更像是一个“黑箱”,他们只需要知道如何调用函数,以及函数的输入和输出是什么即可。
这种抽象特性使得编写和维护代码更加简单和高效。
1.4 设计函数
函数是所有程序的重要组成部分,无论大小,它们是我们在编程语言中表达计算过程的主要媒介。本文将讨论什么是良好函数的特征,这些特征本质上都强化了函数的抽象性。
1.4.1 函数的特点
单一职责:每个函数应该只有一个明确的任务,并且可以用简短的名称和一行文字描述。执行多个任务的函数应拆分为多个函数。
遵循DRY原则:不要重复自己(DRY原则)是软件工程的核心原则,意味着多个代码片段不应描述冗余的逻辑,而应将逻辑实现一次、命名并多次应用。如果发现自己在复制粘贴代码块,可能是抽象化的机会。
通用性:函数应通用。例如,平方操作不在Python库中,因为它是
pow
函数(可以将数字提升到任意幂)的特殊情况。
这些指导方针提高了代码的可读性,减少了错误数量,并通常最小化了书写的总代码量。将复杂任务分解为简洁函数的技能需要经验来掌握。幸运的是,Python提供了多种功能来支持这一努力。
1.4.2 文档说明
函数定义通常包括文档说明,称为文档字符串(docstring),它必须与函数体缩进对齐。文档字符串通常用三重引号括起来。第一行描述函数的任务,后续行可以描述参数并阐明函数的行为。例如:
def pressure(v, t, n): |
调用help
函数并传入函数名作为参数,可以查看其文档字符串。
help(pressure) |
在编写Python程序时,建议为所有但最简单的函数包含文档字符串。记住,代码只写一次,但常常被阅读多次。Python文档包括文档字符串的指导方针,以保持不同Python项目之间的一致性。
1.4.3 注释
在Python中,可以在行尾使用#
符号附加注释。例如,上述关于玻尔兹曼常数的注释描述了k
。这些注释不会出现在Python的帮助信息中,解释器也会忽略它们。它们仅供人类参考。
1.4.4 默认参数值
定义通用函数的一个结果是引入额外的参数。具有多个参数的函数可能调用起来显得笨拙且难以阅读。
在Python中,可以为函数的参数提供默认值。调用函数时,带有默认值的参数是可选的。如果不提供,则将默认值绑定到形式参数名。例如,如果应用程序通常计算一摩尔颗粒的压力,则可以将该值作为默认值提供:
def pressure(v, t, n=6.022e23): |
在这个例子中,=
符号在不同的上下文中有不同的含义。在def
语句头中,=
并不是赋值,而是指示调用函数时使用的默认值。相比之下,在函数体中对k
的赋值语句将名称k
绑定到玻尔兹曼常数的近似值。
pressure(1, 273.15) # 输出:2269.974834 |
pressure
函数被定义为接受三个参数,但在第一个调用表达式中只提供了两个。在这种情况下,n
的值取自def
语句中的默认值。如果提供了第三个参数,则忽略默认值。
作为指导原则,大多数在函数体中使用的数据值应表示为命名参数的默认值,以便于检查并可以由函数调用者更改。某些不会改变的值(例如基本常量k
)可以在函数体内或全局框架中绑定。
当然可以,下面是更详细的学习笔记,包括一些适当的示例。
1.5 控制语句
控制语句赋予程序根据逻辑比较的结果执行不同操作的能力,从而控制程序的执行流。
1.5.1 语句
定义:语句是解释器执行的命令,而不是计算值。执行语句会改变解释器的状态。
类型:
赋值语句:将值绑定到变量。例如:
x = 10
函数定义(def):定义函数的过程。例如:
def square(x):
return x * x返回语句(return):在函数中返回值。例如:
def add(a, b):
return a + b
执行与表达式:
表达式可以被执行为语句,但它们的值会被丢弃。例如:
print(square(4)) # 输出:16,但没有存储返回值
1.5.2 复合语句
定义:复合语句由多个简单语句组成,通常包括以冒号结尾的头部和缩进的代码块。
示例:
def greet(name):
"""打印问候信息"""
print("Hello, " + name + "!")执行顺序:在复合语句中,执行是有序的,直到遇到控制语句(如返回或条件语句)为止。
1.5.3 定义函数 II:局部赋值
功能:局部赋值允许在函数内部定义变量,这些变量只在该函数的局部环境中有效。
示例:
def percent_difference(x, y):
difference = abs(x - y)
return 100 * difference / x
result = percent_difference(40, 50)
print(result) # 输出:25.0- 这里,
difference
是局部变量,不会影响全局命名空间。
- 这里,
复杂表达式:使用局部赋值可以使复杂的计算更易于理解。
def calculate_area(length, width):
area = length * width
return area
1.5.4 条件语句
定义:条件语句使用
if
、elif
和else
结构来选择执行不同的代码块。示例:
def absolute_value(x):
"""计算绝对值"""
if x > 0:
return x
elif x == 0:
return 0
else:
return -x
print(absolute_value(-2)) # 输出:2布尔上下文:条件表达式在布尔上下文中计算,返回
True
或False
。布尔值和操作符:
布尔值:
True
和False
是Python中的布尔值。示例:
print(4 < 2) # 输出:False
print(5 >= 5) # 输出:True布尔操作符:
print(True and False) # 输出:False
print(True or False) # 输出:True
print(not False) # 输出:True
1.5.5 迭代
定义:控制语句也用于表示重复操作,允许在满足条件时重复执行相同的语句。
示例:
def fib(n):
"""计算第n个斐波那契数"""
pred, curr = 0, 1 # 第1和第2个斐波那契数
for k in range(2, n): # 从第3个开始
pred, curr = curr, pred + curr
return curr
print(fib(8)) # 输出:21while循环:
def count_down(n):
while n > 0:
print(n)
n -= 1 # 减少n的值
print("Blast off!")
count_down(5) # 输出:5 4 3 2 1 Blast off!注意:确保每次迭代都能改变某些绑定,避免出现无限循环。
1.5.6 测试
定义:测试是验证函数行为是否符合预期的过程,通常通过断言语句进行验证。
示例:
def test_fib():
assert fib(2) == 1, 'The 2nd Fibonacci number should be 1'
assert fib(3) == 2, 'The 3rd Fibonacci number should be 2'
assert fib(8) == 21, 'The 8th Fibonacci number should be 21'
test_fib() # 如果所有断言通过,什么也不输出文档测试(doctest):
def sum_naturals(n):
"""返回前n个自然数的和。
>>> sum_naturals(10)
55
>>> sum_naturals(100)
5050
"""
total, k = 0, 1
while k <= n:
total, k = total + k, k + 1
return total
if __name__ == "__main__":
import doctest
doctest.testmod()- 这段代码可以直接在文档字符串中进行测试,提供了简洁的测试方式。
实用建议
- 缩进规范:确保所有代码块的缩进一致,以避免IndentationError。
- 测试习惯:在实现新功能后立即编写和运行测试,以确保其行为符合预期。良好的测试习惯有助于快速发现和修复错误。
通过以上内容,我们可以更清晰地理解控制语句如何在程序中起到核心作用,从而使编程更具表达力和可维护性。
这里补充介绍一下文档测试:
文档测试(doctest)是一种在 Python 中用于测试代码的技术。它的主要思想是将代码的使用示例写入文档字符串(docstring)中,然后
doctest
模块会自动执行这些示例并检查它们的输出是否与期望的结果一致。这样,你不仅可以提供代码的使用说明,还可以确保代码的行为符合预期。代码解释
def sum_naturals(n):
"""返回前n个自然数的和。
>>> sum_naturals(10)
55
>>> sum_naturals(100)
5050
"""
total, k = 0, 1
while k <= n:
total, k = total + k, k + 1
return total函数
sum_naturals(n)
这个函数用于计算前
n
个自然数的和。
参数:
n
:要计算的前n
个自然数。实现逻辑:
total, k = 0, 1
:初始化两个变量,total
用于存储总和,k
用于从 1 开始计数。while k <= n:
:循环从k = 1
开始,逐步将k
累加到total
,并每次将k
增加 1,直到k > n
为止。return total
:返回最终累加得到的总和。文档字符串(docstring):
文档字符串中包含了两个使用示例:
10) sum_naturals(
55
100) sum_naturals(
5050
>>>
表示 Python 交互式解释器中的命令,doctest
将执行这些命令并验证它们的输出是否与文档中给出的结果一致。
if __name__ == "__main__":
import doctest
doctest.testmod()主程序部分
这部分代码会在脚本作为主程序运行时执行:
import doctest
:导入doctest
模块。doctest.testmod()
:运行当前模块中的文档测试。它会检查所有函数的 docstring 中的测试用例,自动执行它们,并验证输出是否与预期结果匹配。总结
- 文档测试的优势:它将代码示例嵌入文档中,可以直接运行这些示例进行测试,非常适合用于快速验证代码的正确性,尤其在编写教程或提供使用说明时。
- 这段代码的作用:计算前
n
个自然数的和,并使用doctest
来验证sum_naturals
函数是否正确通过了文档中提供的测试用例。
下面是关于高阶函数的学习笔记,包含示例和要点,以帮助理解这一概念。
1.6 高阶函数
在计算机科学中,高阶函数是指接受其他函数作为参数或者返回一个函数的函数。高阶函数可以帮助我们抽象出常见的编程模式,从而提高代码的可读性和可复用性。
1.6.1 函数作为参数
高阶函数允许我们将一个函数作为参数传递给另一个函数,从而抽象出通用的计算模式。
示例:
求自然数的和:
def sum_naturals(n):
total, k = 0, 1
while k <= n:
total, k = total + k, k + 1
return total
print(sum_naturals(100)) # 输出:5050求立方数的和:
def sum_cubes(n):
total, k = 0, 1
while k <= n:
total, k = total + k*k*k, k + 1
return total
print(sum_cubes(100)) # 输出:25502500使用通用的求和函数: 我们可以定义一个通用的
summation
函数,并将计算每个项的函数作为参数传递:def summation(n, term):
total, k = 0, 1
while k <= n:
total, k = total + term(k), k + 1
return total
def cube(x):
return x*x*x
def sum_cubes(n):
return summation(n, cube)
print(sum_cubes(3)) # 输出:36求自然数的和: 我们可以使用
identity
函数来求自然数的和:def identity(x):
return x
def sum_naturals(n):
return summation(n, identity)
print(sum_naturals(10)) # 输出:55直接调用
sumation
函数:def square(x):
return x * x
print(summation(10, square)) # 输出:385使用
summation
计算π:def pi_term(x):
return 8 / ((4*x-3) * (4*x-1))
def pi_sum(n):
return summation(n, pi_term)
print(pi_sum(1e6)) # 输出:接近 π 的值
1.6.2 函数作为通用方法
高阶函数不仅可以简化特定计算,还可以创建通用的计算方法。
示例:
改进算法:
def improve(update, close, guess=1):
while not close(guess):
guess = update(guess)
return guess这个
improve
函数是一个通用的重复改进方法。计算黄金比例:
def golden_update(guess):
return 1/guess + 1
def square_close_to_successor(guess):
return approx_eq(guess * guess, guess + 1)
def approx_eq(x, y, tolerance=1e-15):
return abs(x - y) < tolerance
phi = improve(golden_update, square_close_to_successor)
print(phi) # 输出接近黄金比例测试
improve
函数的正确性:from math import sqrt
phi_exact = 1/2 + sqrt(5)/2
def improve_test():
approx_phi = improve(golden_update, square_close_to_successor)
assert approx_eq(phi_exact, approx_phi), 'phi differs from its approximation'
improve_test() # 如果通过测试,则没有输出
总结
- 抽象能力:高阶函数提供了一种强大的抽象机制,使得我们可以通过函数来表达通用的计算模式,而不仅仅是特定的计算。
- 代码复用:通过将函数作为参数传递,我们可以编写更加通用和可复用的代码。
- 简化复杂性:高阶函数可以帮助我们简化复杂的逻辑,使代码更易于理解和维护。
高阶函数在实际编程中具有重要意义,能显著提高程序的表达能力和灵活性。
1.6.3 嵌套函数定义
嵌套函数定义使得在函数内部定义其他函数成为可能,这有助于简化全局命名空间并避免函数签名的限制。
示例:计算平方根
def average(x, y): |
sqrt_update
和sqrt_close
函数仅在sqrt
函数内部可用,利用了词法作用域(lexical scoping)。
词法作用域
- 嵌套函数能够访问其定义环境中的变量,而不是调用环境中的变量。
- 这意味着在调用内嵌函数时,可以访问包含它的函数的参数。
1.6.4 函数作为返回值
通过创建返回函数的函数,可以实现更强大的功能,例如函数组合。
示例:函数组合
def compose1(f, g): |
compose1
返回一个新的函数h
,该函数执行f(g(x))
。
1.6.5 牛顿法(Newton's Method)
牛顿法是一种用于寻找函数零点的迭代改进算法,适用于可微分函数。
示例:实现牛顿法
def newton_update(f, df): |
- 通过使用牛顿更新方法,可以计算平方根和任意次数的根。
示例:平方根
def square_root_newton(a): |
一般化至任意次数的根
通过定义相应的函数和导数,可以计算任意次数的根。
def nth_root_of_a(n, a): |
- 示例调用:
nth_root_of_a(2, 64) # 返回8.0 |
总结
- 嵌套函数和函数作为返回值极大增强了编程语言的表达能力。
- 词法作用域允许内嵌函数访问其定义环境中的变量。
- 牛顿法提供了一种通用的迭代方法,用于求解可微分函数的零点。尽管牛顿法并不总是收敛,但它在计算机科学中的应用非常广泛。
1.6.6 柯里化 (Currying)
柯里化是将一个多参数的函数转换为一系列单参数函数的过程。给定一个函数
f(x, y)
,我们可以定义一个高阶函数 g
,使得
g(x)(y)
等价于 f(x, y)
。
示例:定义幂函数的柯里化版本
def curried_pow(x): |
在 Python 中,柯里化可以用于需要单参数函数的场景,如 map
模式。可以通过 map_to_range
函数结合
curried_pow
来计算二的前十个幂:
def map_to_range(start, end, f): |
自动化柯里化与反柯里化
def curry2(f): |
1.6.7 Lambda 表达式
Lambda 表达式用于创建匿名函数,具有单一返回表达式。其语法如下:
lambda 参数: 表达式 |
示例:使用 lambda 表达式定义复合函数
def compose1(f, g): |
尽管 lambda
表达式简洁,但复合表达式可能难以阅读,因此在复杂情况下推荐使用
def
语句。
1.6.8 抽象与一等函数
用户定义函数是重要的抽象机制,使我们能够以明确的方式表达通用计算方法。一等函数具有以下特性:
- 可以绑定到名字
- 可以作为参数传递给函数
- 可以作为函数的返回值
- 可以包含在数据结构中
Python 中的函数具备完全的一等地位,显著提升了表达能力。
1.6.9 函数装饰器 (Function Decorators)
装饰器是 Python 中用于高阶函数的一种特殊语法,可以在定义函数时应用。常见的例子是跟踪函数调用的装饰器:
def trace(fn): |
使用装饰器的语法相当于:
def triple(x): |
总结
通过柯里化、lambda 表达式、函数的一等地位以及装饰器,Python 允许更灵活和强大的函数式编程。这些概念使得程序员能够构建更高效和抽象的代码结构。
1. 7 递归函数的定义
递归函数是指函数的主体调用自身,无论是直接还是间接。递归函数在 Python 中没有特殊的语法,但理解和创建递归函数需要一定的努力。
1.7.0 示例:求自然数的数字和
- 通过 % 和 // 操作符将一个数字分解为其最后一位和其余部分。算法步骤为:求数字 n 的最后一位 n % 10,加上去掉最后一位后的数字的数字和 sum_digits(n // 10)。
- 特殊情况:如果 n 只有一位数,则返回 n 本身。
def sum_digits(n): |
1.7.1 递归函数的结构
- 基本情况:函数体开始部分的条件语句,定义了函数对最简单输入的行为。
- 递归调用:后续的递归调用逐步简化原问题。
例子:计算阶乘
- 迭代实现:
def fact_iter(n): |
- 递归实现:
def fact(n): |
1.7.2 互递归
- 两个相互调用的函数称为互递归。例如,定义偶数和奇数的函数。
def is_even(n): |
1.7.3 打印递归函数中的信息
- 可以使用打印语句可视化递归函数的计算过程。
def cascade(n): |
1.7.4 树形递归
- 树形递归指一个函数多次调用自身的情况,例如计算斐波那契数列。
def fib(n): |
1.7.5 示例:整数的划分
- 使用部分的划分问题的树形递归函数示例。
def count_partitions(n, m): |
结论
- 递归函数提供了一种通过简化问题逐步构造解决方案的方法。通过对简单情况的处理和递归调用的组合,能够以较少的代码实现复杂的计算任务。然而,理解递归函数的调用过程和返回值的构建需要一定的实践和思考。
第二章:通过数据构建抽象
2.1 引言
在第一章中,我们重点讨论了计算过程以及函数在程序设计中的作用。我们学习了如何使用原始数据(如数字)和原始操作(如算术运算),如何通过组合和控制构建复合函数,以及如何通过为过程命名来创建函数抽象。我们还了解到,高阶函数增强了语言的能力,使我们能够操作并推理一般的计算方法。这些是编程的本质。
本章将重点关注数据。我们将在这里探讨的技术将使我们能够表示和操作来自不同领域的信息。由于互联网的快速发展,海量的结构化信息可供我们在线获取,计算可以应用于各种不同的问题。有效使用内置和用户定义的数据类型是数据处理应用程序的基础。
2.1.1 原生数据类型
在 Python
中,每个值都有一个类来决定其数据类型。具有相同类的值共享相同行为。例如,整数
1 和 2 都是 int
类的实例,它们可以执行相同的操作,如取反或加另一个整数。Python 中的
type
函数可以用于查看任意值的类型。
Python
中常用的一些原生数据类型包括整数(int
)、浮点数(float
)和复数(complex
)。
- 整数(int) 可以精确表示,不存在大小限制。
- 浮点数(float)
则是近似值,表示范围广,但会有精度损失,尤其是在执行多次浮点数运算时,可能会产生舍入误差。
- 复数(complex) 用于表示具有实部和虚部的复数。
浮点数的近似特性在进行等值比较时可能会引发问题,比如:
1 / 3 == 0.333333333333333312345 # 注意浮点数近似 |
除此之外,Python
还包含其他非数值类型,如布尔值(bool
),用来表示
True
和
False
。然而,大多数值的类型需要程序员自行定义,后续章节将介绍如何通过组合和抽象来创建有用的数据抽象。
总之,原生数据类型的数量有限,记住这些差异可以帮助编程。许多编程语言都遵循类似的标准,例如 IEEE 754 浮点数标准,使这些细节在不同语言中一致。
2.2 数据抽象
当我们考虑想在程序中表示的广泛事物时,发现大多数事物具有复合结构。例如,地理位置由纬度和经度组成。我们希望编程语言能够将纬度和经度结合起来,形成一个复合数据值。使用复合数据可以提高程序的模块化,使计算过程中不必关心数据的具体表示方式。这种隔离数据表示与操作的设计方法称为数据抽象,它能使程序更容易设计、维护和修改。
数据抽象类似于函数抽象。函数抽象隐藏了函数的实现细节,而数据抽象则隔离了数据的使用与其表示细节。
基本思路:
- 程序操作抽象数据,尽量少假设数据的具体细节。
- 数据的具体表示作为程序独立部分进行定义。
- 抽象数据和具体表示通过少量函数连接。
2.2.1 例子:有理数
有理数是整数比值,如 1/3
或
17/29
。直接计算这些数的比值会产生浮点数近似,导致精度丢失。例如:
1 / 3 |
我们可以通过将分子和分母组合,创建精确的有理数表示。
通过构造函数和选择器函数实现有理数的抽象:
rational(n, d)
构造有理数。numer(x)
获取有理数的分子。denom(x)
获取有理数的分母。
使用这些函数,可以定义加法、乘法、打印和相等性测试操作,而无需关心有理数的具体表示:
def add_rationals(x, y): |
2.2.2 复合数据:对
Python 提供了列表作为一种复合结构,可以用于实现对(pair)。通过列表,我们可以将分子和分母组合为有理数。列表可以通过索引访问元素,或通过多重赋值解构:
pair = [10, 20] |
为了使有理数保持最简形式,可以在构造函数中使用最大公约数(GCD)简化分子和分母:
from fractions import gcd |
2.2.3 抽象屏障
抽象屏障的概念:
- 数据抽象通过一组基本操作来屏蔽底层数据的具体表示。
- 在操作数据时,只能使用这些基本操作,不能直接访问数据的具体表示。
- 如果打破抽象屏障,程序的可维护性和灵活性会下降。
例如,实现有理数平方时,应该使用 mul_rational
函数,而不是直接操作分子和分母:
def square_rational(x): |
2.2.4 数据的性质
抽象屏障不仅适用于有理数,也适用于我们之前使用的对。实际上,列表并不是创建对的唯一方法。我们可以通过高阶函数创建一个同样能够表示对的实现,满足数据抽象的要求即可:
def pair(x, y): |
通过数据抽象,可以轻松切换数据的表示方式,确保程序的灵活性和正确性。
2.3 序列
序列是有序的值集合,是计算机科学中的一种重要抽象。序列不是特定的内置类型或抽象数据表示,而是一组共享特定行为的不同数据类型。所有序列都有以下共同特性:
- 长度:序列具有有限长度。空序列的长度为 0。
- 元素选择:序列中每个元素都有对应的非负整数索引,索引从 0 开始。
在 Python 中,多个内置数据类型都是序列,最重要的是列表。
2.3.1 列表
列表是可以具有任意长度的序列。列表有丰富的内置行为以及特定的语法来表达这些行为。我们已经见过列表字面量(literal),它评估为列表实例,还有元素选择表达式,用于获取列表中的值。内置的
len
函数返回序列的长度。以下是一个示例,digits
是一个包含四个元素的列表,索引 3 的元素是 8。
digits = [1, 8, 2, 8] |
此外,列表可以相加和乘以整数。对于序列来说,加法和乘法并不是对元素的加法或乘法,而是组合和重复序列本身。+
运算符(和 add
函数)返回两个序列的连接,*
运算符(和 mul
函数)将返回一个包含原列表 k
次重复的列表。
[2, 7] + digits * 2 # 输出:[2, 7, 1, 8, 2, 8, 1, 8, 2, 8] |
任何值都可以包含在列表中,包括另一个列表。可以多次应用元素选择以选择嵌套在列表中的深层元素。
pairs = [[10, 20], [30, 40]] |
2.3.2 序列迭代
在许多情况下,我们希望遍历序列中的元素,并对每个元素执行某些计算。这种模式如此常见,以至于
Python 提供了额外的控制语句来处理顺序数据:for
语句。
考虑计算一个值在序列中出现多少次的问题。我们可以使用
while
循环实现此计数的函数。
def count(s, value): |
使用 for
语句可以简化这个函数体,直接遍历元素值,而不需要引入索引名称。
def count(s, value): |
for
语句的结构为:
for <name> in <expression>: |
for
语句的执行过程如下:
- 评估头部
<expression>
,该表达式必须返回一个可迭代值。 - 对于该可迭代值中的每个元素值,按顺序:
- 将
<name>
绑定到当前值。 - 执行
<suite>
。
- 将
这个执行过程提到了可迭代值。列表是一种序列类型,而序列是可迭代值。它们的元素按顺序处理。Python 还包括其他可迭代类型,但我们暂时将重点放在序列上;关于“可迭代”的一般定义将在第四章的迭代器部分中讨论。
重要的是,执行完 for
语句后,<name>
将绑定到序列的最后一个元素,这意味着 for
循环又为更新环境引入了一种新方式。
序列解包
程序中常见的一种模式是拥有元素序列,而这些元素自身也是固定长度的序列。for
语句的头部可以包含多个名称,以“解包”每个元素序列。例如,我们可能有一个包含二元列表的列表。
pairs = [[1, 2], [2, 2], [2, 3], [4, 4]] |
我们希望找出这些对中有多少对具有相同的第一个和第二个元素。
same_count = 0 |
此时,same_count
的值为 2。
这种将多个名称绑定到多个值的模式称为序列解包;它与赋值语句中将多个名称绑定到多个值的模式相同。
范围
范围是 Python 中的另一种内置序列类型,表示一系列整数。范围是通过
range
创建的,它接受两个整数参数:第一个数字和想要的范围的最后一个数字的下一个数字。
range(1, 10) # 包含 1,但不包含 10 |
调用 list
构造函数来评估范围将返回一个与范围相同元素的列表,以便可以轻松检查这些元素。
list(range(5, 8)) # 输出:[5, 6, 7] |
如果只给出一个参数,它被解释为从 0 开始到最后值的下一个值。
list(range(4)) # 输出:[0, 1, 2, 3] |
范围通常作为 for
头部中的表达式出现,以指定套件应执行的次数:通常约定如果名称在套件中未使用,则在
for
头部使用单个下划线字符:
for _ in range(3): |
这个下划线在解释器看来只是环境中的另一个名称,但在程序员中有一个约定的含义,表示该名称在未来的表达式中不会出现。
2.3.3 序列处理
序列是复合数据的一种常见形式,整个程序通常围绕这一抽象进行组织。可以通过模块化组件混合处理具有序列作为输入和输出的功能。常见的序列处理操作包括:
列表推导式(List Comprehensions)是用于执行序列处理操作的一种简洁表达方式。例如,增加每个奇数的 1:
odds = [1, 3, 5, 7, 9] |
可以选择满足某些条件的值的子集,例如:
print([x for x in odds if 25 % x == 0]) # 输出: [1, 5] |
聚合(Aggregation)是将序列中的所有值合并为单个值的操作。例如,计算一个数的所有因子的和:
def divisors(n): |
使用列表推导式可以计算 1 到 1000 的所有完美数:
print([n for n in range(1, 1000) if sum(divisors(n)) == n]) # 输出: [6, 28, 496] |
还可以使用更复杂的函数处理,例如计算给定面积的矩形的最小周长:
def width(area, height): |
高阶函数。序列处理的常见模式可以通过高阶函数来表达,例如
map
和 filter
。以下是一些示例:
def apply_to_all(map_fn, s): |
使用 reduce
函数可以聚合序列中的元素:
from functools import reduce |
在 Python 程序中,通常直接使用列表推导式而不是高阶函数。
2.3.4 序列抽象
我们已经介绍了两种符合序列抽象的原生数据类型:列表和范围。Python 还包括满足序列抽象的两个其他行为:
- 成员资格:可以使用
in
和not in
操作符来测试一个值是否在序列中。
digits = [1, 8, 2, 8] |
- 切片(Slicing):序列可以包含更小的子序列。切片是原始序列的任何连续范围,使用两个整数表示。
print(digits[0:2]) # 输出: [1, 8] |
序列的丰富抽象在计算中是非常普遍的,因此学习一些复杂的行为是合理的。在定义用户自定义抽象时,通常应该尽量保持简单。
2.3.5 字符串
字符串是计算机科学中非常基础的文本值类型,Python
程序本身就是以文本形式编写和存储的。Python 中的字符串数据类型由构造函数
str
表示。
这一部分将介绍字符串的基本行为,包括如何表示、表达和操作字符串。
字符串字面量
字符串字面量可以用单引号或双引号包围,表示任意文本:
print('I am string!') # 输出: I am string! |
字符串的特性
字符串满足序列的两个基本条件:具有长度和支持元素选择。
city = 'Berkeley' |
字符串的元素本身是长度为 1 的字符串,表示单个字符。字符可以是字母、标点符号或其他符号。与许多其他编程语言不同,Python 没有单独的字符类型,任何文本都是字符串。
字符串的组合
字符串可以通过加法和乘法进行组合:
print('Berkeley' + ', CA') # 输出: Berkeley, CA |
成员资格
字符串的行为与其他序列类型有所不同。字符串的成员资格操作符
in
适用于字符串,但它的行为与在序列上使用时完全不同。它匹配的是子字符串,而不是单个元素。
print('here' in "Where's Waldo?") # 输出: True |
多行字面量
字符串不仅限于单行。三重引号可以定义跨越多行的字符串字面量。我们已经在文档字符串中广泛使用了三重引号。
print("""The Zen of Python |
在上述输出中,\n
被视为一个单一元素,表示新行。
字符串强制转换
可以通过调用 str
构造函数将任何对象转换为字符串,这对于构建描述性字符串非常有用:
digits = [1, 8, 2, 8] |
深入阅读
文本编码在计算机中是一个复杂的话题。本节中我们抽象掉了字符串的具体表示细节,但了解字符串在计算机中的编码方式对许多应用来说是至关重要的。关于字符编码和 Unicode 的描述可以参考《Dive Into Python 3》的字符串章节。
2.3.6 树
我们可以将列表作为其他列表的元素,这种组合能力是数据类型的闭包属性。闭包属性意味着,数据组合的结果仍然可以使用相同的方法继续组合。这一属性是组合方法强大之处的关键,因为它允许我们创建层次结构——由部分组成的结构,这些部分本身又由更小的部分组成,依此类推。
树是一个基本的数据抽象,能使我们以规则的方式构建和操作层次结构的值。树有一个根标签和一系列分支。每个分支也是一棵树。没有分支的树称为叶子。树中的每一棵子树称为这棵树的子树,而子树的根称为该树的一个节点。
树的基本实现
树的构造函数是 tree
,选择器是 label
和
branches
:
def tree(root_label, branches=[]): |
树的验证和叶子的判断
is_tree
函数用于验证树的结构是否正确:
def is_tree(tree): |
is_leaf
函数用于判断一棵树是否为叶子:
def is_leaf(tree): |
构建和操作树
我们可以通过嵌套表达式构建树:
t = tree(3, [tree(1), tree(2, [tree(1), tree(1)])]) |
树递归函数可以用来构造树,例如,Fibonacci 树:
def fib_tree(n): |
计算叶子的数量
树递归函数还可以用来处理树,例如 count_leaves
函数用于计算树的叶子数量:
def count_leaves(tree): |
分割树
树还可以用来表示整数的分割。例如,partition_tree
函数构建分割树:
def partition_tree(n, m): |
我们还可以通过递归遍历分割树打印分割结果:
def print_parts(tree, partition=[]): |
二叉化树
right_binarize
函数将树二叉化,转换成右分支结构:
def right_binarize(tree): |
2.3.7 链表
链表简介
链表(Linked List)是一种序列结构,它由嵌套的成对元素表示。链表的每一个节点包含两个部分:一个是序列的第一个元素,另一个是指向剩余部分的链接。
例如,四个元素 1, 2, 3, 4
的链表表示为:
four = [1, [2, [3, [4, 'empty']]]] |
这里,'empty'
表示链表的结束,[1, [2, [3, [4, 'empty']]]]
是一个递归结构。
链表的构造与选择器
我们可以定义一些函数来构造和操作链表:
构造器:
def link(first, rest):
"""从第一个元素和剩余部分构造一个链表"""
assert is_link(rest), "rest 必须是一个链表"
return [first, rest]选择器:
def first(s):
"""返回链表的第一个元素"""
return s[0]
def rest(s):
"""返回链表的其余部分"""
return s[1]
链表的验证与基本操作
验证链表:
def is_link(s):
"""验证是否为链表"""
return s == 'empty' or (len(s) == 2 and is_link(s[1]))长度计算: 计算链表的长度可以使用迭代或递归:
def len_link(s):
"""返回链表的长度(迭代版)"""
length = 0
while s != 'empty':
s, length = rest(s), length + 1
return length递归版:
def len_link_recursive(s):
"""返回链表的长度(递归版)"""
if s == 'empty':
return 0
return 1 + len_link_recursive(rest(s))元素访问: 可以根据索引访问链表中的元素:
def getitem_link(s, i):
"""返回链表中索引为 i 的元素"""
while i > 0:
s, i = rest(s), i - 1
return first(s)递归版:
def getitem_link_recursive(s, i):
"""返回索引为 i 的元素(递归版)"""
if i == 0:
return first(s)
return getitem_link_recursive(rest(s), i - 1)
递归操作
链表的递归结构使得递归操作十分方便。
扩展链表: 我们可以将两个链表连接起来:
def extend_link(s, t):
"""将链表 s 和链表 t 连接"""
if s == 'empty':
return t
return link(first(s), extend_link(rest(s), t))对链表中每个元素应用函数:
def apply_to_all_link(f, s):
"""对链表 s 中的每个元素应用函数 f"""
if s == 'empty':
return s
return link(f(first(s)), apply_to_all_link(f, rest(s)))例如,对链表中每个元素求平方:
apply_to_all_link(lambda x: x*x, four)
筛选链表中的元素: 我们可以保留链表中满足特定条件的元素:
def keep_if_link(f, s):
"""返回一个只包含满足条件 f 的元素的链表"""
if s == 'empty':
return s
kept = keep_if_link(f, rest(s))
if f(first(s)):
return link(first(s), kept)
else:
return kept拼接链表中的元素: 使用指定的分隔符,将链表的元素转换为字符串并拼接:
def join_link(s, separator):
"""返回由 s 中的所有元素组成的字符串,元素间由 separator 分隔"""
if s == 'empty':
return ""
elif rest(s) == 'empty':
return str(first(s))
return str(first(s)) + separator + join_link(rest(s), separator)
递归构造与组合
链表特别适用于递归构造序列的场景。例如,可以用递归的方式构造所有整数划分的链表:
def partitions(n, m): |
输出划分
可以将划分的结果转换为可读格式:
def print_partitions(n, m): |
例如:
print_partitions(6, 4) |
输出:
4 + 2 |
总结
链表是一种具有递归结构的序列类型,可以通过迭代或递归进行操作。它提供了构建复杂数据结构的基础,并且适合于增量地构造和处理序列。
2.4 可变数据
在大型系统中,抽象对帮助我们应对复杂性至关重要。为了有效编写程序,我们还需要组织原则,帮助我们设计模块化的程序。模块化程序分成独立开发和维护的部分,其中一种强有力的技术是使用可以随时间变化状态的数据。
添加状态到数据中是面向对象编程(OOP)中的核心思想。通过这种方式,数据对象可以独立于程序的其他部分,表现出随历史变化的行为。
2.4.1 对象的隐喻
最开始,我们区分了函数和数据:函数执行操作,而数据是被操作的对象。当函数值也被视为数据后,数据就开始拥有了行为。对象结合了数据和行为,既代表信息,又像它们所代表的事物一样有行为。
例如,日期对象结合了日期信息和相关的行为。在Python中,datetime
模块的date
类就是一个例子:
from datetime import date |
对象不仅有属性,还拥有方法(行为)。方法是函数,它们通过使用对象自身和传入的参数来进行计算。例如,strftime
方法格式化日期输出。
2.4.2 序列对象
Python中,所有的值都是对象。某些对象是不可变的(例如数字和字符串),而列表是可变的。这意味着它们可以随时间改变。可变对象的行为类似于现实世界中的事物,例如一个人虽然每天都在改变,但仍然是同一个人。
列表的可变性
列表是可变的,我们可以对其进行增删操作。例如:
chinese = ['coin', 'string', 'myriad'] |
修改列表的操作会影响引用同一列表的所有变量。例如,修改suits
列表时,chinese
列表也会随之改变,因为它们引用的是同一个列表对象。
我们可以通过list()
函数复制列表,从而避免这种情况:
nest = list(suits) # 创建一个新列表,内容相同 |
列表的身份与相等性
Python中使用is
和is not
判断两个对象是否是同一个,而使用==
判断两个对象的内容是否相等:
suits is nest[0] # 输出:True |
列表推导式
列表推导式创建新列表,并不会修改原有列表。例如,使用unicodedata
模块来查找扑克牌花色符号:
from unicodedata import lookup |
元组
元组(tuple
)是不可变的序列对象。它们的创建方式与列表类似,但不能修改其内容:
code = ("up", "down", "left", "right") |
元组常用于多值赋值:
a, b = 1, 2 # 相当于 a = (1, 2) |
虽然元组不可变,但其内部包含的可变元素(如列表)仍可以改变:
nest = (10, 20, [30, 40]) |
总结:Python中的所有值都是对象,有些对象是不可变的(如元组),而有些是可变的(如列表)。对于可变对象,修改它们会影响所有引用该对象的变量。
2.4.3 字典
字典是 Python 中内置的数据类型,用于存储和操作键值对。字典中的键和值都是对象,通常用于存储非连续整数索引的对应关系。
键-值对:字典由键值对组成,例如:
numerals = {'I': 1.0, 'V': 5, 'X': 10}
通过键来查找对应的值:
numerals['X'] # 输出: 10
字典无序性:字典是无序集合,键值对的顺序无法预知,不同运行结果可能不一样。
修改和添加键值对:可以通过赋值来添加或修改键值对。
numerals['I'] = 1
numerals['L'] = 50键的要求:字典的键必须是不可变类型,例如字符串、元组,不能是列表等可变类型。
get
方法:get
方法可用于返回键对应的值,若键不存在则返回默认值:numerals.get('A', 0) # 输出: 0
字典推导式:可以使用字典推导式快速创建字典:
{x: x*x for x in range(3,6)} # 输出: {3: 9, 4: 16, 5: 25}
2.4.4 局部状态
字典和列表有局部状态,即它们的值会随着程序的执行而变化。函数也可以具有局部状态,例如模拟银行账户的取款操作。
函数的局部状态:定义一个
withdraw
函数,模拟取款操作。取款会减少账户余额,每次调用的结果依赖于之前的操作:def make_withdraw(balance):
def withdraw(amount):
nonlocal balance
if amount > balance:
return 'Insufficient funds'
balance -= amount
return balance
return withdraw通过
nonlocal
关键字,可以在函数中修改父作用域中的变量balance
。当调用withdraw
函数时,balance
会被更新,影响下一次调用的结果。nonlocal
声明:nonlocal
允许在局部作用域中修改外部变量,而不再局限于仅查找变量。它的作用是使得变量的赋值影响整个闭包作用域,而非仅限于局部作用域。错误示例:如果没有
nonlocal
声明,Python 会认为balance
是局部变量,并抛出UnboundLocalError
:UnboundLocalError: local variable 'balance' referenced before assignment
通过使用 nonlocal
语句,允许函数在多个调用间共享状态,管理复杂的数据流和逻辑。
2.4.5 非本地赋值的好处
非本地赋值为我们提供了一种管理局部状态的方法,这个状态随着函数的多次调用而变化。每个
withdraw
函数的 balance
是独立的,并且不可被程序中的其他部分直接访问。通过调用
make_withdraw
创建了不同的 withdraw
实例,每个实例都管理自己的余额,并且它们不会互相影响。
示例:
def make_withdraw(balance): |
2.4.6 非本地赋值的代价
非本地赋值引入了复杂性。特别是当两个变量指向同一个函数实例时,它们的状态会互相影响。例如,wd
和 wd2
指向同一个函数时,改变 wd2
的状态会影响
wd
的状态。
示例:
wd = make_withdraw(12) |
2.4.7 实现可变链表和字典
可以通过函数实现可变链表。链表使用一个函数来表示,它接受不同的消息进行操作。如下的链表支持
len
、getitem
、push_first
、pop_first
和 str
操作。
def mutable_link(): |
通过消息传递的方式,也可以实现一个简单的字典。字典存储键值对,并通过
setitem
和 getitem
消息进行插入和查询操作。
def dictionary(): |
非本地赋值虽然增加了程序的复杂性,但它为实现模块化程序提供了重要的工具。通过函数和局部状态的组合,我们能够模拟许多内置的可变数据类型,如列表和字典。
2.4.8 消息传递系统和字典
我们可以使用字典来实现消息传递接口,避免使用条件语句来处理不同消息。字典的键可以是字符串,用于查找对应的值或函数。
示例:银行账户的消息传递系统 在下面的银行账户实现中,我们通过字典存储账户状态(余额)和操作(存款、取款函数)。通过消息传递的方式,使用字典中的函数实现操作。
def account(initial_balance): |
2.4.9 约束传播系统
约束传播系统模拟了变量之间的关系和约束,允许双向计算。它是一种声明式编程,其中程序员声明变量的关系,而不指定具体的计算过程。
理想气体定律:
\(p * v = n * k * t\)
传统程序语言中,通常我们只能将一个变量表示为其他变量的函数。例如,计算压力
p 需要给定
v、n、k、t。但在约束传播系统中,我们可以实现双向计算,任意一个未知量都可以通过其他已知量来计算。
摄氏温度与华氏温度的转换网络
通过定义基本约束(例如加法器、乘法器)和连接器,我们可以构建一个转换摄氏温度和华氏温度的网络。
摄氏温度与华氏温度的关系式:
9 * c = 5 * (f - 32) |
该系统会在任何连接器的值发生变化时,自动更新其他受影响的连接器。以下是如何使用约束系统计算温度转换:
# 创建连接器 |
实现约束和连接器
在约束传播系统中,约束和连接器都是字典,它们通过消息进行通信。
- 约束(Constraints):接收连接器的消息,更新其他连接器的值。
- 连接器(Connectors):保存当前的值,并在值更新时通知约束。
加法器的实现:
from operator import add, sub |
连接器的实现:
def connector(name=None): |
- 字典是实现消息传递和存储状态的有效方式。
- 约束系统可以建立变量间复杂的关系,并实现多方向的计算。
- 使用字典和消息传递的方式可以模拟对象的行为,这为后续的面向对象编程奠定了基础。
2.5 面向对象编程学习笔记
2.5.1 面向对象编程(OOP)概述
- OOP简介:面向对象编程是一种通过对象与方法来组织程序的编程范式。对象结合了数据(状态)和行为(方法),能够抽象复杂性,并且通过交互产生有用的结果。
- 核心概念:
- 对象:拥有属性和方法的数据类型。
- 类:定义对象的模板。
- 继承:对象可以从其他对象继承特性和行为。
2.5.2 定义类和对象
类定义:使用
class
关键字定义类。类通常包含属性(实例属性)和方法。实例化对象:类通过调用生成具体的对象实例。实例化是通过调用类名,并传入参数来完成的。
class Account:
def __init__(self, account_holder):
self.balance = 0 # 实例属性
self.holder = account_holder # 实例属性
a = Account('Kirk')实例属性:对象的属性,例如账户的余额和持有人。它们是通过
self
来绑定到对象实例的。a.holder # 'Kirk'
a.balance # 0
方法定义与调用
方法:类内定义的函数,用于操作对象的属性。例如,
deposit
方法用于给账户存钱,withdraw
方法用于取钱。class Account:
def deposit(self, amount):
self.balance += amount
return self.balance方法调用:对象方法的调用通过点号表示法,例如
spock_account.deposit(100)
。
2.5.3 消息传递与点号表示法
消息传递:对象通过点号表示法接收消息,访问其方法和属性。例如,
spock_account.deposit
是一个点表达式,它返回spock_account
对象上的deposit
方法。spock_account.deposit(100) # 存款100
getattr
和hasattr
:可以使用getattr
来动态访问对象的属性或方法,使用hasattr
检查对象是否具有某个属性或方法。getattr(spock_account, 'balance') # 返回余额
hasattr(spock_account, 'deposit') # True
2.5.4 类属性
类属性:类属性是与类关联的值,而非对象实例。例如,银行的利率是所有账户共享的,可以通过类属性定义。
class Account:
interest = 0.02 # 类属性类属性访问与修改:类属性可以通过类或实例访问,但修改时需要特别注意。如果修改类属性,将影响所有实例;而如果修改实例的同名属性,实例将拥有独立的属性值。
Account.interest = 0.04 # 修改类属性
spock_account.interest # 0.04
kirk_account.interest = 0.08 # kirk的独立实例属性
方法与函数的区别
方法绑定:在类中定义的方法在实例化时会自动绑定到对象,传入的第一个参数默认为对象本身 (
self
)。Account.deposit(spock_account, 1000) # 直接调用函数
spock_account.deposit(1000) # 调用绑定的方法
总结
- 面向对象编程通过对象、方法和类将数据与行为整合在一起。类定义了对象的属性和行为,实例化后可以对每个对象进行操作。点号表示法用于访问对象的属性和方法,类属性为所有实例共享的值,方法通过自动绑定
self
来实现对象的操作。
2.5.5 继承
在面向对象编程中,类之间常常有某种关联性。某些类可能代表另一个类的特例。比如,CheckingAccount
(支票账户)是
Account
(账户)的一个特例。支票账户每次取款时收取额外费用,利率也更低。
例子:
'Spock') ch = CheckingAccount( |
CheckingAccount
继承了 Account
类的所有属性和方法,但可以覆盖某些方法,比如 withdraw
方法。这样可以只编写不同的部分,其余行为由基类 Account
继承。
2.5.6 使用继承
下面是完整的 Account
类实现,包含了存款和取款操作的定义:
class Account: |
支票账户是对普通账户的特例,继承了 Account
的基本功能,但修改了 withdraw
方法,增加了取款费用:
class CheckingAccount(Account): |
例子:
'Sam') checking = CheckingAccount( |
2.5.7 多重继承
Python 支持多重继承,即子类可以继承多个父类。假设有一个
SavingsAccount
类(储蓄账户),对每次存款收取费用:
class SavingsAccount(Account): |
我们还可以创建一个既有 CheckingAccount
又有
SavingsAccount
特性的账户:
class AsSeenOnTVAccount(CheckingAccount, SavingsAccount): |
在 AsSeenOnTVAccount
中,取款和存款操作都会收取费用:
例子:
"John") such_a_deal = AsSeenOnTVAccount( |
在多重继承中,Python 采用 C3 方法解析顺序来处理属性或方法的查找。
2.5.8 对象的角色
Python 的对象系统提供了方便且灵活的数据抽象和消息传递机制。类、方法、继承、点表达式等语法特性帮助我们在程序中实现对象模型,使得编写和管理大型程序变得更加有条理。
2.6 实现类和对象
在面向对象编程范式中,我们使用对象隐喻来指导程序的组织。大多数关于如何表示和操作数据的逻辑都在类声明中表达。在这一节中,我们看到类和对象本身可以仅用函数和字典来表示。通过这种方式实现对象系统的目的是为了说明,使用对象隐喻并不需要特定的编程语言。即使在没有内置对象系统的编程语言中,程序也可以是面向对象的。
为了实现对象,我们将放弃点符号(这需要内置语言支持),而创建调度字典,行为与内置对象系统的元素非常相似。我们已经看到如何通过调度字典实现消息传递行为。为了完整实现对象系统,我们将在实例、类和基类之间发送消息,所有这些都作为包含属性的字典。
我们不会实现整个Python对象系统,因为它包括一些在本书中未覆盖的特性(例如,元类和静态方法)。相反,我们将专注于用户定义的类,不使用多重继承,也不使用反射行为(例如返回实例的类)。我们的实现并不打算遵循Python类型系统的精确规范,而是旨在实现支持对象隐喻的核心功能。
2.6.1 实例
实例具有命名属性,例如账户的余额,可以设置和获取。我们使用调度字典来实现实例,该字典响应“获取”和“设置”属性值的消息。属性本身存储在名为
attributes
的局部字典中。
示例:创建实例
def make_instance(cls): |
在这个实现中,make_instance
函数返回一个调度字典
instance
,它响应 get
和 set
消息。
绑定方法
def bind_method(value, instance): |
当调用方法时,第一个参数 self
将绑定到
instance
的值。
2.6.2 类
类也是对象,既在Python的对象系统中,也在我们实现的系统中。为了简化起见,我们假设类本身没有类(在Python中,类确实有类;几乎所有类都共享同一个类,称为
type
)。类可以响应 get
和 set
消息,以及 new
消息。
示例:创建类
def make_class(attributes, base_class=None): |
初始化
def init_instance(cls, *args): |
2.6.3 使用实现的对象
现在我们使用之前的银行账户示例,创建 Account
类、CheckingAccount
子类,以及每个类的实例。
示例:创建账户类
def make_account_class(): |
创建账户实例
Account = make_account_class() # 创建Account类 |
使用 get
消息传递给
kirk_account
,可以检索属性和方法。可以调用方法来更新账户余额。
获取账户持有者
kirk_account['get']('holder') # 获取账户持有者 |
小结
本节展示了如何在不依赖内置对象系统的情况下实现面向对象编程,通过调度字典模拟实例和类的行为。通过这种方法,我们能够创建和管理对象,同时理解类的继承和属性的设置与获取机制。
2.7 对象抽象
对象系统允许程序员高效地构建和使用抽象数据表示。它的设计允许多种抽象数据表示在同一程序中共存。
对象抽象的一个核心概念是泛型函数,即可以接受多种不同类型值的函数。我们将考虑三种实现泛型函数的不同技术:共享接口、类型调度和类型强制。在构建这些概念的过程中,我们还将发现支持创建泛型函数的Python对象系统特性。
2.7.1 字符串转换
为了有效地表示数据,对象值应表现出它所代表的数据类型,包括生成自身的字符串表示。数据值的字符串表示在交互式语言(如Python)中尤为重要,因为在交互式会话中,Python会自动显示表达式值的字符串表示。
示例:基本字符串表示
12e12 |
在没有有效的字符串表示可以评估为原始值的情况下,Python通常会生成一个用尖括号括起来的描述。
repr(min) |
人类可读和Python可读的字符串表示
字符串的构造函数str
通常与repr
相吻合,但在某些情况下提供更易理解的文本表示。例如,对于日期,我们看到
str
和 repr
的区别:
from datetime import date |
实现__repr__
和__str__
方法
我们希望repr
函数能适用于所有数据类型,包括那些在实现时不存在的数据类型。我们希望它是一个泛型或多态函数,能应用于多种(poly)不同形式(morph)的数据。对象系统在这种情况下提供了优雅的解决方案:repr
函数总是调用其参数的__repr__
方法。
tues.__repr__() |
同样,str
构造函数通过调用其参数的__str__
方法实现:
tues.__str__() |
这些多态函数是一个更一般原则的例子:某些函数应该适用于多种数据类型。创建这样的函数的一种方法是使用共享属性名,并在每个类中定义不同的实现。
2.7.2 特殊方法
在Python中,某些特殊名称在特定情况下由Python解释器自动调用。例如,类的__init__
方法在构造对象时自动调用;__str__
方法在打印时自动调用,__repr__
方法在交互式会话中显示值时调用。
布尔值
所有Python对象都有一个布尔值。默认情况下,用户定义类的对象被认为是True,但可以使用特殊的__bool__
方法覆盖此行为。例如,我们可以让余额为0的银行账户被视为False:
Account.__bool__ = lambda self: self['get']('balance') != 0 # 定义__bool__方法 |
可以通过调用bool
构造函数来查看对象的布尔值,并在布尔上下文中使用任何对象。
bool(Account('Jack')) # 查看账户的布尔值 |
序列操作
我们可以使用len
函数确定序列的长度,该函数调用其参数的__len__
方法。
len('Go Bears!') |
如果序列未提供__bool__
方法,Python会使用序列的长度来确定其布尔值。空序列为False,而非空序列为True。
bool('') # 空字符串 |
可调用对象
在Python中,函数是第一类对象,可以像数据一样传递,并具有与其他对象相同的属性。Python还允许我们通过包含__call__
方法来定义可以像函数一样“调用”的对象。
class Adder(object): |
算术运算
特殊方法还可以定义应用于用户定义对象的内置运算符的行为。为了提供这种通用性,Python遵循特定协议来应用每个运算符。例如,为了评估包含+
运算符的表达式,Python首先检查左操作数的__add__
方法,然后检查右操作数的__radd__
方法。
2.7.3 多重表示法
抽象屏障 允许我们分离数据的使用与表示。然而,在大型程序中,可能并不总是能谈论“数据类型的底层表示”。可能会有多个有用的表示方式,我们希望设计能处理多重表示的系统。
示例:复数的表示
复数可以用两种方式表示:矩形形式(实部和虚部)和极坐标形式(幅度和角度)。在不同情况下,两种形式可能都更合适。例如,矩形形式便于加法,而极坐标形式更适合于乘法。
以下是复数类的定义:
class Number: |
这里的 Number
类需要实现 add
和
mul
方法,但没有定义它们。这意味着 Number
不是直接实例化的,而是作为具体数字类的超类。
复数类的实现
复数类 Complex
继承自
Number
,并定义了复数的加法和乘法:
class Complex(Number): |
复数的矩形表示
ComplexRI
类使用实部和虚部表示复数,并通过
@property
装饰器计算幅度和角度:
from math import atan2 |
使用示例:
ri = ComplexRI(5, 12) |
复数的极坐标表示
ComplexMA
类使用幅度和角度表示复数:
from math import sin, cos, pi |
使用示例:
ma = ComplexMA(2, pi/2) |
2.7.4 泛型函数
泛型函数
是适用于不同类型参数的方法或函数。例如,Complex.add
方法可以接受 ComplexRI
或 ComplexMA
作为参数。
示例:有理数类
为了实现复数与有理数的混合操作,我们可以定义 Rational
类:
from fractions import gcd |
类型调度
通过类型调度,我们可以基于参数的类型选择行为:
def is_real(c): |
结论
通过抽象屏障和接口,我们可以在同一个程序中实现多种不同的数据表示。泛型函数允许我们在不改变程序意义的情况下,灵活处理不同类型的数据。
注意事项
- 多重表示法 有助于应对复杂系统中不同的设计选择。
- 泛型函数 提供了对不同类型的操作能力,增强了代码的灵活性与可维护性。
- 类型调度 和 类型强制 是实现泛型函数的两种重要方法。
2.8 效率
2.8.1 测量效率
效率通常是由表示和处理数据的方式决定的。效率指的是某个表示或过程所需的计算资源,比如计算某个函数的结果所需的时间和内存。这些资源的使用情况可能因实现的细节而有所不同。
斐波那契函数
我们用计算斐波那契数列的树递归函数 fib
作为例子:
def fib(n): |
例如,调用 fib(5)
时,计算过程呈现树状结构。每个蓝点表示完成的斐波那契数计算。该函数效率低下,因为存在大量重复计算,特别是计算
fib(3)
的过程会被重复多次。
计算调用次数
为了测量这个效率,可以使用高阶函数 count
来记录调用次数:
def count(f): |
空间复杂度
在评估函数的空间需求时,我们需要了解在计算环境中如何使用、保留和回收内存。评估表达式时,解释器会保留所有活动环境及其引用的值。树递归函数的空间需求通常与树的最大深度成正比。
例如,调用 fib(3)
的环境结构显示如下:
fib(3) |
使用帧计数器
使用 count_frames
函数来跟踪活动帧的数量:
def count_frames(f): |
总结一下,fib
函数的空间需求与输入大小 n
的关系是 \(\Theta(n)\),而时间需求则是
\(\Theta(\phi^n)\),其中 \(\phi\) 是黄金比例。
2.8.2 备忘录化
树递归计算过程可以通过备忘录化技术提高效率。备忘录化会存储任何先前接收参数的返回值,以避免重复计算。
备忘录化的实现
以下定义创建了一个缓存来存储之前计算的结果:
def memo(f): |
将备忘录化应用于斐波那契函数,计算过程的模式如下:
counted_fib = count(fib) |
2.8.3 增长阶数
计算过程中消耗空间和时间的速度可能相差很大。通过增长阶数来分析过程,可以有效地描述资源需求如何随输入增长。
示例:因数计数
以下函数计算输入 n
的因数数量:
from math import sqrt |
复杂度分析
count_factors(n)
的执行时间为 \(\Theta(\sqrt{n})\)。这意味着总的计算步骤与输入的平方根成正比。
2.8.4 示例:指数计算
计算给定数字的指数可以通过以下递归实现:
def exp(b, n): |
这种线性递归过程的时间复杂度为 \(\Theta(n)\),空间复杂度也为 \(\Theta(n)\)。
使用快速幂法
通过快速幂法,我们可以减少所需的步骤,例如:
def fast_exp(b, n): |
在此实现中,所需的步骤和空间复杂度均为 \(\Theta(\log n)\)。
2.8.5 增长类别
增长阶数帮助简化分析和比较计算过程,许多不同的过程可能具有相同的增长阶数。
常见类别
类别 | Theta 表示 | 增长描述 | 示例 |
---|---|---|---|
常数 | \(\Theta(1)\) | 增长与输入无关 | abs |
对数 | \(\Theta(\log n)\) | 输入增加资源增加 | fast_exp |
线性 | \(\Theta(n)\) | 输入增加资源线性增加 | exp |
二次 | \(\Theta(n^2)\) | 输入增加资源以平方速度增加 | one_more |
指数 | \(\Theta(b^n)\) | 输入增加资源以指数速度增加 | fib |
对于快速增长的过程,变化的基数会影响增长阶数。
通过以上内容,可以更好地理解时间和空间复杂度,以及如何通过不同的技术提高效率。
2.9 递归对象
对象可以具有其他对象作为属性值。当一个类的对象具有该类的属性值时,它就是一个递归对象。这种结构在编程中非常有用,特别是在处理复杂数据结构时,如链表、树等。
2.9.1 链表类
链表是一种递归定义的数据结构,由一个首元素和其余部分组成。链表的其余部分本身也是一个链表。这种定义是递归的,因而形成了链表的特性。空链表是一个特殊的链表,它没有首元素或其余部分。
链表的特点包括:
- 有限长度:链表的长度是有限的,可以通过递归方法计算。
- 元素选择:支持通过索引选择元素。
实现链表类
下面我们实现一个链表类 Link
,以便能够使用内置的
len
函数和元素选择操作符(如方括号或
operator.getitem
)进行操作。这些内置函数会调用类的特殊方法名:
__len__
:计算链表的长度。__getitem__
:获取链表中的元素。
在实现中,空链表用一个空元组表示,它的长度为 0,并且没有元素。
class Link: |
在上述代码中,__len__
和 __getitem__
的定义实际上是递归的。当调用 len(s)
时,Python 内置函数
len
会调用 s
的 __len__
方法。在这个方法中,我们递归地调用 len(self.rest)
,直到
self.rest
为 Link.empty
,这时返回
0。类似地,__getitem__
方法也会递归地调用自身,以获取所需的元素。
调试和字符串表示
为了方便调试,我们可以定义一个函数将链表转换为字符串表达式,以便查看链表的内容。
def link_expression(s): |
我们希望在每次显示 Link
实例时都使用这个字符串表示。为此,可以将 link_expression
函数设置为特殊类属性 __repr__
的值,Python
将通过调用该方法来显示 Link
实例。
Link.__repr__ = link_expression |
链表的嵌套
Link
类具有闭包属性。就像一个列表的元素本身可以是一个列表一样,一个
Link
可以将另一个 Link
作为其首元素。
# 创建一个嵌套的链表 |
在这个例子中,s_first
链表有两个元素,其中第一个元素是一个链表,包含三个元素,第二个元素是数字
6。
递归处理链表
递归函数特别适合处理链表。例如,递归 extend_link
函数构建一个包含 Link
实例 s
的元素后跟
Link
实例 t
的链表。将此函数安装为
Link
类的 __add__
方法可以模拟内置列表的加法行为。
def extend_link(s, t): |
使用高阶函数处理链表
而不是使用列表推导式,可以使用两个高阶函数:map_link
和
filter_link
从一个链表生成另一个链表。以下定义的
map_link
函数将函数 f
应用到链表
s
的每个元素,并构建一个包含结果的链表。
def map_link(f, s): |
filter_link
函数返回一个链表,包含链表 s
中所有 f
返回真值的元素。可以使用 map_link
和
filter_link
的组合来表达与列表推导式相同的逻辑。
def filter_link(f, s): |
连接链表的元素
join_link
函数递归构建一个包含链表元素的字符串,并以某个分隔符字符串分隔。结果比
link_expression
的输出更紧凑。
def join_link(s, separator): |
递归构造示例
链表在递归计算时非常有用,尤其是在逐步构建序列时,这种情况经常发生。例如,我们可以使用递归方法来列举划分。
在第 1 章中,count_partitions
函数计算了使用不大于
m
的部分来划分整数 n
的方式。我们还可以使用类似的过程明确列举这些划分。
划分 n
使用不大于 m
的整数主要有两种方式:
- 使用
m
的划分:划分n-m
使用不大于m
的整数。 - 不使用
m
的划分:划分n
使用不大于m-1
的整数。
以下是关于树类和集合的学习笔记,包含适当的例子及时间复杂度的分析,使用了LaTeX格式。
2.9.2 树类
树可以通过用户定义的类表示,而不是嵌套的内置序列类型。树是任何数据结构,其属性是一个分支序列,这些分支也是树。
内部值
我们之前定义的树使所有值出现在树的叶子上。现在,我们定义树,使每个子树的根也有内部值。内部值在树中被称为标签。下面的
Tree
类表示这样的树,每棵树有一系列分支,分支也是树。
class Tree: |
例子:斐波那契树
fib_tree(n)
函数返回一个 Tree
,其中包含第
n
个斐波那契数作为标签,并在其分支中追踪之前计算的所有斐波那契数。
def fib_tree(n): |
输出:
Tree(3, (Tree(1, (Tree(0), Tree(1))), Tree(2, (Tree(1), Tree(1, (Tree(0), Tree(1))))))) |
递归处理
我们可以使用递归函数来计算树的标签之和。作为基例,返回空分支的标签和为0。
def sum_labels(t): |
输出:
10 |
2.9.3 集合
Python 还有一种内置的容器类型叫做集合(set)。集合文字遵循数学符号的表示方式,元素用大括号括起来。重复元素在构造时被移除。集合是无序的,因此打印顺序可能与集合文字中的元素顺序不同。
s = {3, 2, 1, 4, 4} |
集合操作
集合支持多种操作,包括成员测试、长度计算以及并集和交集等标准集合操作。
# 成员测试 |
集合的实现
作为无序序列的集合
一种表示集合的方法是将其视为一个不包含重复元素的序列。空集合由空序列表示。成员测试通过递归遍历列表。
def empty(s): |
此实现的 set_contains
在平均情况下需要 \(\Theta(n)\) 时间,\(n\) 是集合 s 的大小。
作为有序序列的集合
通过将集合元素按升序列出,可以加速集合操作。
def set_contains(s, v): |
在最坏情况下,查找的步骤与无序表示相同,但在平均情况下,预计需要检查约一半的元素,因此平均所需的步骤为 \(\Theta(n)\)。
作为二叉搜索树的集合
我们可以使用二叉树来表示集合,树的根节点包含一个元素,左分支包含所有小于根的元素,右分支包含所有大于根的元素。
def set_contains(s, v): |
输出:
Tree(2, Tree(1), Tree(3)) |
时间复杂度
- 在无序集合中,集合操作的时间复杂度为 \(\Theta(n^2)\)。
- 在有序集合中,查找的时间复杂度为 \(\Theta(n)\),而交集操作可以优化为 \(\Theta(n)\)。
- 在平衡的二叉搜索树中,查找和添加元素的时间复杂度为 \(\Theta(\log n)\),这对大集合的性能有显著提升。
Python 集合实现
Python
内置的集合类型使用了一种基于哈希的技术实现,提供常数时间的成员测试和添加操作。由于哈希的性质,内置的
Python 集合不能包含可变数据类型,例如列表和字典。为允许嵌套集合,Python
还包括一个内置的不可变类 frozenset
。
第三章:解释计算机程序
3.1 介绍
第一、二章探讨了编程中两个基本要素——函数与数据之间的紧密联系。我们看到如何使用高阶函数将函数视为数据来操作,也了解了通过消息传递和面向对象系统赋予数据行为。此外,还学习了组织大型程序的技术,如函数抽象、数据抽象、类继承和泛型函数。这些核心概念为构建模块化、可维护、可扩展的程序奠定了坚实的基础。
本章重点介绍编程的第三个基本要素:程序本身。Python程序仅仅是一段文本,只有通过解释过程,才能基于该文本进行有意义的计算。像Python这样的编程语言之所以有用,是因为我们可以定义一个解释器——这个程序执行Python的求值和执行过程。可以说,解释器是编程中最基本的思想,它决定了编程语言中的表达式意义,而解释器本身也只是一个程序。
理解这一点会改变我们作为程序员的自我认知:我们不仅仅是使用他人设计语言的用户,而是语言的设计者。
3.1.1 编程语言
编程语言在其语法结构、功能和应用领域方面有很大差异。在通用编程语言中,函数定义和函数应用是普遍存在的构造。然而,有些强大的语言不包含面向对象系统、高阶函数、赋值操作,甚至不包含控制结构(如while和for循环)。作为一个具有最小特性集合的强大语言的例子,文中引入了Scheme编程语言。本书介绍的Scheme子集完全不允许可变值。
本章我们研究解释器的设计以及其在执行程序时创建的计算过程。设计一个通用编程语言的解释器似乎是一项艰巨的任务,毕竟解释器是根据输入执行任何可能计算的程序。然而,许多解释器具有一种优雅的共同结构:两个相互递归的函数。第一个在环境中求值表达式,第二个将函数应用于参数。
这些函数是递归的,因为它们是基于彼此定义的:应用一个函数需要对其主体中的表达式进行求值,而求值表达式可能涉及应用一个或多个函数。
通过这一学习笔记,我们可以更好地理解程序设计中的解释器设计思想,并将程序员的角色从语言的使用者转变为语言的设计者。
3.2 函数式编程
现代计算机上的软件使用多种编程语言编写。有物理语言,如特定计算机的机器语言。这些语言关注数据的表示和控制,涉及存储的位数和原始的机器指令。机器语言程序员专注于使用现有硬件构建系统和工具,以高效实现资源受限的计算。高层语言建立在机器语言的基础上,隐藏了关于数据作为位集合表示以及程序作为原始指令序列表示的关切。这些语言具有组合和抽象手段,如函数定义,适用于软件系统的大规模组织。
本节我们将介绍一种鼓励使用函数式编程风格的高级编程语言——Scheme语言的一个子集。它与Python有类似的计算模型,但只使用表达式(没有语句)、专注于符号计算,并只使用不可变的值。
3.2.1 表达式
Scheme程序由表达式组成,这些表达式要么是调用表达式,要么是特殊形式。调用表达式由操作符表达式和零个或多个操作数子表达式组成,类似于Python。操作符和操作数都包含在括号内:
(quotient 10 2) |
结果是
5
。Scheme完全使用前缀表示法,运算符通常是符号,如+
和 *
。调用表达式可以嵌套,并且可以跨越多行:
(+ (* 3 5) (- 10 6)) |
结果是 19
。
再来看一个复杂的嵌套表达式:
(+ (* 3 |
结果是 57
。
在Scheme中,表达式可以是原始的或组合的。数字字面量是原始的,而调用表达式是组合形式,包含任意的子表达式。Scheme的调用表达式的求值过程与Python类似:首先求值操作符和操作数表达式,然后将操作符的值(函数)应用于操作数的值(参数)。
3.2.2 定义
值可以使用define
特殊形式命名,例如:
(define pi 3.14) |
结果是 6.28
。
可以使用define
定义新的函数(Scheme中称为过程):
(define (square x) (* x x)) |
然后我们可以通过以下方式调用它:
(square 21) ; 441 |
匿名函数可以使用lambda
表达式创建:
(lambda (x) (* x x)) |
这一表达式与定义了函数的define
相同,唯一的区别是它没有与任何名称关联。
3.2.3 复合值
Scheme中内置了对“对”(pair)的支持,使用cons
函数创建,使用car
和cdr
函数访问对的元素:
(define x (cons 1 2)) |
可以使用递归定义列表,例如:
(cons 1 (cons 2 (cons 3 (cons 4 nil)))) |
3.2.4 符号数据
Scheme中的一个优势是可以操作任意符号。使用单引号'
可以构造符号列表,例如:
(list 'a 'b) ; (a b) |
这个单引号类似于引用操作,用来表示不进行求值,而是直接使用符号本身。
3.2.5 乌龟图形绘制(Turtle Graphics)
本节所介绍的Scheme实现中包含了Turtle图形绘制,这是一个源自Logo语言(另一种Lisp方言)的图形环境。乌龟图形绘制是通过让“乌龟”在画布上移动并绘制线条来实现的,最初旨在引导儿童学习编程,但它同样也是一个非常适合高级程序员的可视化工具。
在Scheme程序执行过程中,乌龟始终有一个位置和朝向。通过调用单参数过程,如forward
和right
,可以改变乌龟的当前位置和朝向。常用的指令有缩写,例如forward
可以简写为fd
。
多重命令
Scheme中的begin
特殊形式允许在一个表达式中包含多个子表达式,适合用于发出多个命令,例如:
(define (repeat k fn) |
这个定义了一个repeat
函数,可以多次重复执行某个函数。
绘制五角星示例
可以使用repeat
函数绘制复杂的图形,例如五角星:
(repeat 5 |
递归绘制Sierpinski三角形
乌龟图形绘制还可以用于递归图形,例如Sierpinski三角形。以下是递归定义的Sierpinski三角形绘制过程:
triangle
:重复绘制三次,并在每次绘制后左转。sier
:接受一个边长d
和递归深度k
的参数。如果深度为1,则绘制普通三角形;否则通过调用leg
来递归绘制。leg
:绘制Sierpinski三角形的一条边,包含对sier
的递归调用,先绘制一半边长,然后乌龟移动到下一个顶点。
为了防止在乌龟移动时绘制多余的线条,可以使用penup
和pendown
过程抬起和放下乌龟的画笔。通过sier
和leg
的互相递归调用,可以绘制出完整的Sierpinski三角形。
(sier 400 6) |
3.3 异常处理(Exceptions)
程序员在编写程序时,必须时刻注意可能出现的错误。常见的错误场景包括:函数接收到不符合要求的参数、所需资源缺失、或网络连接丢失。在设计程序时,应考虑可能出现的异常情况,并采取适当的措施进行处理。
没有统一的错误处理方法。对于提供持续服务的程序(如Web服务器),应在记录错误日志的同时尽可能继续提供服务。而对于解释型语言(如Python),解释器会立即终止程序并打印错误信息,方便程序员及时解决问题。无论如何,程序员都需要做出明确选择,决定程序在遇到异常时的反应。
异常的引发(Raising Exceptions)
异常(Exception)是一个从BaseException
类继承的对象。当Python解释器检测到表达式或语句中的错误时,会自动引发异常。用户也可以使用raise
或assert
语句手动引发异常。
示例1:引发异常
在下面的例子中,我们引发了一个基本的异常
Exception
,并输出了自定义的错误信息"An error occurred"
。raise Exception('An error occurred')
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
Exception: an error occurred当引发异常后,当前代码块中的其他语句将不再执行。如果异常未被捕获和处理,解释器会直接返回到交互式会话或终止整个程序,并输出一个堆栈跟踪(stack backtrace),即展示异常发生时调用的嵌套函数。
异常的处理(Handling Exceptions)
可以通过try
语句处理异常。try
语句由多个子句组成,首先是try
子句,后面是一个或多个except
子句,用于捕获和处理不同类型的异常。
示例2:处理
ZeroDivisionError
异常在下面的例子中,尝试执行除法
1/0
时会引发ZeroDivisionError
。except
子句捕获到该异常,并输出处理信息,同时将x
设置为0
。try:
x = 1 / 0
except ZeroDivisionError as e:
print('handling a', type(e)) # 输出异常类型
x = 0 # 设置 x 为 0
handling a <class 'ZeroDivisionError'>
x
0当
1 / 0
导致ZeroDivisionError
时,程序不会崩溃,而是跳转到except
子句进行处理。在这个例子中,异常对象e
被捕获并绑定到异常类ZeroDivisionError
。示例3:处理函数中的异常
下面的示例演示了如何在函数中处理异常。我们定义了两个函数:
invert
和invert_safe
。在invert
函数中,除数为0
时会引发ZeroDivisionError
;而在invert_safe
中,我们通过try-except
结构捕获该异常,并返回错误信息。def invert(x):
result = 1 / x # 如果 x 为 0,会引发 ZeroDivisionError
print('This will not print if x is 0')
return result
def invert_safe(x):
try:
return invert(x)
except ZeroDivisionError as e:
return str(e) # 返回异常描述信息
2) invert_safe(
0.5
0) invert_safe(
'division by zero'在这个例子中,当
invert(0)
引发ZeroDivisionError
时,invert_safe(0)
捕获到异常并返回错误信息'division by zero'
,而不是让程序崩溃。
3.3.1 异常对象(Exception Objects)
异常对象可以拥有属性,如错误信息或异常发生的位置。用户还可以定义自己的异常类,并添加自定义属性。
示例4:定义自定义异常类
在此例中,我们创建了一个名为
IterImproveError
的自定义异常类,继承自Exception
,并增加了一个last_guess
属性,用于存储迭代过程中的最后一个猜测值。class IterImproveError(Exception):
def __init__(self, last_guess):
self.last_guess = last_guess该异常类可在迭代改进算法中使用,当迭代过程中发生异常时,我们可以将最新的猜测值保存下来。
示例5:在改进的牛顿法中使用自定义异常
在这个示例中,改进的牛顿法使用了自定义的异常处理。
improve
函数在发生ValueError
时,会引发一个IterImproveError
并存储当前猜测的值。find_zero
函数会捕获该异常并返回最后的猜测值。def improve(update, done, guess=1, max_updates=1000):
k = 0
try:
while not done(guess) and k < max_updates:
guess = update(guess)
k += 1
return guess
except ValueError:
raise IterImproveError(guess) # 引发自定义异常并保存最后的猜测值
def find_zero(f, guess=1):
def done(x):
return f(x) == 0
try:
return improve(newton_update(f), done, guess)
except IterImproveError as e:
return e.last_guess # 返回最后的猜测值
from math import sqrt
lambda x: 2 * x * x + sqrt(x)) find_zero(
-0.030211203830201594尽管这个结果距离正确答案 \(0\) 仍有较大误差,但在某些应用中,能够返回一个粗略的近似值比抛出异常更有用。
时空复杂度分析(Time and Space Complexity Analysis)
引发异常的时空复杂度:
当程序执行
raise
语句引发异常时,程序的控制流会立刻跳转到最近的异常处理器。因此,raise
语句的时间复杂度为 $ O(1) $,因为它的执行时间与异常处理逻辑的复杂度无关。空间复杂度也为 $ O(1) $,因为引发异常本身只涉及常量空间。异常处理的时空复杂度:
对于
try-except
结构,try
块中的代码的时间复杂度取决于其包含的代码逻辑。如果发生异常,程序会跳转到最近的except
子句。这个跳转过程是常数时间的,因此异常处理的时间复杂度也是 $ O(1) $。在处理异常时,通常只绑定异常对象并执行一些处理逻辑,因此空间复杂度为 $ O(1) $。自定义异常类的时空复杂度:
创建自定义异常类的时间复杂度主要体现在实例化操作上,这通常是常数时间 $ O(1) $。自定义异常对象的空间复杂度取决于其存储的属性,在示例4中,
IterImproveError
类只存储了一个last_guess
属性,因此其空间复杂度也是 $ O(1) $。改进的牛顿法中的时空复杂度:
在使用自定义异常的改进牛顿法中,假设最大迭代次数为 $ k $,则
improve
函数的时间复杂度为 $ O(k) $,因为它最多进行 $ k $ 次迭代。当发生异常时,异常处理器会保存最后的猜测值并返回,因此处理过程的时间复杂度为 $ O(1) $。同样,保存最后的猜测值只需常量空间,因此空间复杂度也是 $ O(1) $。
3.4 解释器与组合语言
概述
本节介绍了如何通过编写解释器(Interpreter)来定义新语言。解释器是一种函数,负责接受并执行一种编程语言的表达式。我们通过一个名为Calculator的Scheme子集语言来学习构建解释器的基本原理,最终将其扩展为Scheme的一个完整实现。
3.4.1 Scheme语法的计算器(Calculator)
Calculator是一种基于Scheme的简单语言,支持基本的四则运算(加、减、乘、除)。它的语法和运算符行为与Scheme类似。每个表达式都是一个列表,运算符是第一个元素,后面跟随参数。
示例
(+ 1 2 3 4) ; 结果为 10 |
解释:
(+ 1 2 3 4)
代表求和运算,输出 10。(*)
表示乘法的空操作,返回 1。(- 10 1 2 3)
计算结果为 10 - 1 - 2 - 3,输出 4。(/ 30 5 2)
表示 30 除以 5 再除以 2,输出 3。
3.4.2 表达式树(Expression Trees)
要编写解释器,我们需要将输入的表达式作为数据进行操作。一个基本表达式可以是数字或符号,而组合表达式通常是一个嵌套的Scheme列表。我们通过Python的Pair
类模拟Scheme中的列表结构。
示例
# Pair类用于构建表达式树 |
- 在这个例子中,
Pair
类的实例用于构建表达式树,表达式树的结构体现了嵌套的操作,例如( + (* 3 4) 5)
。最终的计算结果是17
。
3.4.3 解析表达式(Parsing Expressions)
表达式解析的第一步是将输入的字符串解析成语法单元(Token)。然后,语法单元再进一步解析为嵌套的表达式树。
词法分析:将输入字符串切割成符号(Token),如数字和运算符。
语法分析:将Token序列转换为嵌套的表达式树。
示例
# Scheme读取函数:将输入的字符串解析为表达式树 |
scheme_read
函数使用递归的方法将语法单元(Token)序列解析为嵌套的表达式树,类似于解析嵌套的Scheme列表。
3.4.4 计算器的求值(Evaluation of Expressions)
计算器解释器的核心是一个递归的求值函数
calc_eval
。这个函数的基本思路是:
- 如果表达式是数字,直接返回该数字。
- 如果表达式是组合表达式(列表),则递归地对每个操作数求值,然后将结果传递给相应的运算符进行计算。
代码与注释
def calc_eval(exp): |
calc_apply
函数负责应用操作符,它根据传入的操作符和参数执行相应的运算。
示例
def calc_apply(operator, args): |
示例
(+ (* 3 4) 5) ; 输出为17 |
(+ (* 3 4) 5)
的求值过程首先计算内层表达式(* 3 4)
,其结果为12
。然后计算外层表达式( + 12 5)
,结果为17
。
3.4.5 交互式求值循环(REPL)
交互式解释器的经典实现是REPL(Read-Eval-Print Loop),它不断地读取用户输入、对表达式进行求值并打印结果。以下是一个简单的REPL实现,它能够处理用户的输入并返回相应的计算结果。
示例
def read_eval_print_loop(): |
这个函数实现了REPL的基本功能:
- 读取用户输入,并通过
scheme_read
解析为表达式树。 - 求值表达式,并通过
calc_eval
函数计算其结果。 - 打印求值结果,并在发生错误时捕捉并处理异常。
总结
通过编写一个简单的解释器,我们学习了如何解析表达式树、递归求值以及处理错误。这些是解释器和编译器设计中的基本概念。
3.5 支持抽象的语言解释器
概述
在这一节中,我们从简单的计算器语言(Calculator)转向更为通用的Scheme语言,该语言支持抽象,通过绑定名称到值,并定义新的操作来实现更复杂的计算。我们将描述如何构建一个支持抽象的解释器,并探讨环境模型及其在计算中的作用。
3.5.1 解释器的结构
Scheme解释器的结构与之前的计算器解释器类似。它的基本流程包括解析表达式并通过求值器(Evaluator)执行表达式。不同之处在于Scheme语言支持用户定义的函数、特殊形式(Special Forms),并且依赖环境模型进行计算。
解析(Parsing):
解析过程由scheme_reader
模块实现,该模块可以将Scheme表达式解析为嵌套的数据结构。然而,目前它还不支持点对列表(dotted
lists)和引用(quotation)。要实现一个完整的Scheme解释器,我们需要扩展该模块。
示例
(car '(1 . 2)) ; 解析为 (car (quote (1 . 2))) |
解释:
(car '(1 . 2))
是一个调用表达式,quote
引用了一个点对列表
(1 . 2)
。我们需要能够正确地解析这些语法结构。
求值(Evaluation):
求值是逐个表达式进行的。scheme_eval
函数负责在当前环境下求值每个表达式。求值的主要内容包括三种形式:
- 原子表达式(Primitive)
- 特殊形式(Special Forms)
- 调用表达式(Call Expressions)
scheme_eval
函数会根据表达式的第一部分来决定如何处理。每种特殊形式都有其独特的求值规则。以下是一个简化的scheme_eval
实现:
示例
def scheme_eval(expr, env): |
解释:
这个函数首先检查表达式类型,如果是符号则在当前环境中查找其绑定的值。如果是组合表达式(列表),则根据其第一部分来决定是特殊形式还是普通的函数调用。
函数调用(Procedure Application):
scheme_apply
函数负责函数调用的实际执行。Scheme支持两种函数类型:
- 原生函数(PrimitiveProcedure):用Python实现的函数。
- Lambda函数(LambdaProcedure):用Scheme实现的函数。
每个LambdaProcedure
都包含一个body
,该body
是在一个新的环境中求值的。新的环境通过扩展当前环境来构造,参数列表中的符号与实际传入的参数值绑定。
示例:
(lambda (x) (+ x 1)) ; 一个简单的Lambda表达式 |
解释:
在调用LambdaProcedure
时,会为参数x
创建一个新的环境,将传入的实际值绑定到x
,然后对(+ x 1)
进行求值。
求值/应用的递归关系:
scheme_eval
和scheme_apply
是相互递归的。求值函数在遇到调用表达式时调用scheme_apply
,而函数应用则依赖求值来解析操作数。这种互相递归的结构在解释器中非常常见,它是解释器求值过程的核心。
3.5.2 环境(Environments)
环境的作用:
解释器中的环境模型由Frame
类实现。每个Frame
实例代表一个符号绑定到值的环境,并且有一个指向父环境的引用。通过这种方式,可以支持嵌套作用域。
环境的主要操作有两个:
- lookup(查找): 查找符号在环境中的值。如果当前环境没有找到,会递归查找父环境。
- define(定义): 在当前环境中绑定符号到值。
示例
(define (factorial n) |
解释:
define
创建了一个LambdaProcedure
,其参数为n
,在调用factorial
时,会为n
创建一个新的环境并绑定其值为5。接下来,解释器在这个新的环境中对factorial
的主体进行求值,最终计算得到120
。
环境模型的实现:
Frame
类的lookup
和define
方法是实现环境模型的关键。定义函数时,解释器需要创建一个新的LambdaProcedure
并绑定到当前环境中。当调用该函数时,需要在一个新的环境中对其主体进行求值。
示例
class Frame: |
总结
通过引入抽象机制,我们的Scheme解释器支持用户定义函数、局部变量和递归等复杂操作。环境模型通过作用域链实现了符号的绑定与查找。