来源:
allendowney.github.io/ThinkPython/
译者:飞龙
6. 返回值
在前面的章节中,我们使用了内置函数——如abs
和round
——以及数学模块中的函数——如sqrt
和pow
。当你调用这些函数中的一个时,它返回一个值,你可以将其赋值给一个变量或作为表达式的一部分使用。
迄今为止我们编写的函数是不同的。有些使用print
函数显示值,有些使用 turtle 函数绘制图形。但它们没有返回我们赋值给变量或在表达式中使用的值。
在本章中,我们将看到如何编写返回值的函数。
6.1. 有些函数有返回值
当你像调用math.sqrt
这样的函数时,结果被称为返回值。如果函数调用出现在单元格的末尾,Jupyter 会立即显示返回值。
import math
math.sqrt(42 / math.pi)
3.656366395715726
如果你将返回值赋值给一个变量,它不会被显示。
radius = math.sqrt(42 / math.pi)
但你可以稍后显示它。
radius
3.656366395715726
或者你可以将返回值作为表达式的一部分使用。
radius + math.sqrt(42 / math.pi)
7.312732791431452
这是一个返回值的函数示例。
def circle_area(radius):
area = math.pi * radius**2
return area
circle_area
将radius
作为参数,计算该半径的圆的面积。
最后一行是一个return
语句,它返回area
的值。
如果我们像这样调用函数,Jupyter 会显示返回值。
circle_area(radius)
42.00000000000001
我们可以将返回值赋值给一个变量。
a = circle_area(radius)
或者将其作为表达式的一部分使用。
circle_area(radius) + 2 * circle_area(radius / 2)
63.000000000000014
后面我们可以显示赋值给结果的变量的值。
a
42.00000000000001
但我们无法访问area
。
area
NameError: name 'area' is not defined
area
是函数中的局部变量,因此我们无法从函数外部访问它。
6.2. 有些函数返回 None
如果一个函数没有return
语句,它会返回None
,这是一个特殊的值,类似于True
和False
。例如,这里是第三章中的repeat
函数。
def repeat(word, n):
print(word * n)
如果我们像这样调用它,它会显示蒙提·派森歌曲《芬兰》的第一行。
repeat('Finland, ', 3)
Finland, Finland, Finland,
这个函数使用print
函数来显示一个字符串,但它没有使用return
语句返回值。如果我们将结果赋值给一个变量,它仍然会显示这个字符串。
result = repeat('Finland, ', 3)
Finland, Finland, Finland,
如果我们显示变量的值,我们什么也得不到。
result
result
实际上有一个值,但 Jupyter 不会显示它。不过我们可以像这样显示它。
print(result)
None
repeat
的返回值是None
。
现在这里有一个类似repeat
的函数,不同之处在于它有一个返回值。
def repeat_string(word, n):
return word * n
请注意,我们可以在return
语句中使用一个表达式,而不仅仅是一个变量。
使用这个版本,我们可以将结果赋值给一个变量。当函数运行时,它不会显示任何内容。
line = repeat_string('Spam, ', 4)
但之后我们可以显示赋值给line
的值。
line
'Spam, Spam, Spam, Spam, '
这样的函数被称为纯函数,因为它不会显示任何内容或产生任何其他效果——除了返回一个值。
6.3. 返回值与条件语句
如果 Python 没有提供abs
,我们可以像这样编写它。
def absolute_value(x):
if x < 0:
return -x
else:
return x
如果x
为负,第一条return
语句返回-x
,函数立即结束。否则,第二条return
语句返回x
,函数结束。因此,这个函数是正确的。
然而,如果你将return
语句放在条件语句中,你必须确保程序的每一条路径都能到达一个return
语句。例如,这是一个错误版本的absolute_value
。
def absolute_value_wrong(x):
if x < 0:
return -x
if x > 0:
return x
如果我们以0
作为参数调用这个函数,会发生什么呢?
absolute_value_wrong(0)
什么都没有得到!问题在于:当x
为0
时,两个条件都不成立,函数结束而没有执行return
语句,这意味着返回值是None
,因此 Jupyter 不会显示任何内容。
另一个例子,这是一个带有额外return
语句的absolute_value
版本。
def absolute_value_extra_return(x):
if x < 0:
return -x
else:
return x
return 'This is dead code'
如果x
为负,第一条return
语句执行,函数结束。否则,第二条return
语句执行,函数结束。无论哪种情况,我们都不会到达第三条return
语句——因此它永远不会执行。
不能运行的代码叫做死代码。通常情况下,死代码不会造成任何危害,但它通常表明存在误解,并且可能会让试图理解程序的人感到困惑。
6.4. 增量开发
当你编写更大的函数时,可能会发现你花费更多时间在调试上。为了应对越来越复杂的程序,你可能会想尝试增量开发,这是一种每次只添加和测试少量代码的方式。
举个例子,假设你想找出由坐标((x_1, y_1))和((x_2, y_2))表示的两点之间的距离。根据毕达哥拉斯定理,距离是:
[\mathrm{distance} = \sqrt{(x_2 - x_1)² + (y_2 - y_1)²}]
第一步是考虑一个distance
函数在 Python 中应该是什么样子的——也就是说,输入(参数)是什么,输出(返回值)是什么?
对于这个函数,输入是点的坐标。返回值是距离。你可以立即写出函数的大纲:
def distance(x1, y1, x2, y2):
return 0.0
这个版本尚未计算距离——它总是返回零。但它是一个完整的函数,具有返回值,这意味着你可以在使其更复杂之前进行测试。
为了测试这个新函数,我们将用样本参数调用它:
distance(1, 2, 4, 6)
0.0
我选择这些值是为了让水平距离为3
,垂直距离为4
。这样,结果就是5
,这是一个3-4-5
直角三角形的斜边长度。测试一个函数时,知道正确的答案是非常有用的。
此时,我们已经确认函数可以运行并返回一个值,我们可以开始向函数体中添加代码。一个好的下一步是找出x2 - x1
和y2 - y1
的差值。这是一个将这些值存储在临时变量中的版本,并显示它们。
def distance(x1, y1, x2, y2):
dx = x2 - x1
dy = y2 - y1
print('dx is', dx)
print('dy is', dy)
return 0.0
如果函数正常工作,它应该显示dx is 3
和dy is 4
。如果是这样,我们就知道函数已经得到了正确的参数并且正确地进行了第一次计算。如果不是,检查的代码行就很少。
distance(1, 2, 4, 6)
dx is 3
dy is 4
0.0
到目前为止很好。接下来我们计算dx
和dy
的平方和:
def distance(x1, y1, x2, y2):
dx = x2 - x1
dy = y2 - y1
dsquared = dx**2 + dy**2
print('dsquared is: ', dsquared)
return 0.0
再次运行函数并检查输出,应该是25
。
distance(1, 2, 4, 6)
dsquared is: 25
0.0
最后,我们可以使用math.sqrt
来计算距离:
def distance(x1, y1, x2, y2):
dx = x2 - x1
dy = y2 - y1
dsquared = dx**2 + dy**2
result = math.sqrt(dsquared)
print("result is", result)
然后进行测试。
distance(1, 2, 4, 6)
result is 5.0
结果是正确的,但这个版本的函数显示了结果,而不是返回它,因此返回值是None
。
我们可以通过用return
语句替换print
函数来修复这个问题。
def distance(x1, y1, x2, y2):
dx = x2 - x1
dy = y2 - y1
dsquared = dx**2 + dy**2
result = math.sqrt(dsquared)
return result
这个版本的distance
是一个纯函数。如果我们这样调用它,只有结果会被显示。
distance(1, 2, 4, 6)
5.0
如果我们将结果赋值给一个变量,什么也不会显示。
d = distance(1, 2, 4, 6)
我们编写的print
语句对于调试很有用,但一旦函数正常工作,就可以将它们移除。这样的代码称为临时代码,它在构建程序时很有帮助,但不是最终产品的一部分。
这个例子演示了渐进式开发。这个过程的关键方面包括:
-
从一个可运行的程序开始,进行小的修改,并在每次修改后进行测试。
-
使用变量来保存中间值,以便你可以显示和检查它们。
-
一旦程序工作正常,就可以移除临时代码。
在任何时候,如果出现错误,你应该有一个清晰的方向去找出问题。渐进式开发可以节省大量的调试时间。
6.5. 布尔函数
函数可以返回布尔值True
和False
,这通常方便将复杂的测试封装在函数中。例如,is_divisible
检查x
是否能被y
整除且没有余数。
def is_divisible(x, y):
if x % y == 0:
return True
else:
return False
这是我们如何使用它的方式。
is_divisible(6, 4)
False
is_divisible(6, 3)
True
在函数内部,==
运算符的结果是一个布尔值,因此我们可以通过直接返回它来更简洁地编写这个函数。
def is_divisible(x, y):
return x % y == 0
布尔函数通常用于条件语句中。
if is_divisible(6, 2):
print('divisible')
divisible
可能会想写成这样:
if is_divisible(6, 2) == True:
print('divisible')
divisible
但是比较是没有必要的。
6.6. 带返回值的递归
现在我们可以编写具有返回值的函数,我们也可以编写具有返回值的递归函数,有了这个能力,我们已经跨越了一个重要的门槛——我们现在拥有的 Python 子集是图灵完备的,这意味着我们可以执行任何可以通过算法描述的计算。
为了演示带返回值的递归,我们将评估几个递归定义的数学函数。递归定义类似于循环定义,定义中会引用正在定义的事物。真正的循环定义并不十分有用:
vorpal:用于描述某物是 vorpal 的形容词。
如果你在字典里看到了这个定义,可能会觉得很烦恼。另一方面,如果你查阅阶乘函数的定义,用符号(!)表示,可能会得到如下内容:
[\begin{split}\begin{aligned} 0! &= 1 \ n! &= n~(n-1)! \end{aligned}\end{split}]
这个定义表示,(0)的阶乘是(1),而任何其他值(n)的阶乘是(n)与(n-1)的阶乘相乘。
如果你能写出某个东西的递归定义,你就能写一个 Python 程序来计算它。按照增量开发的过程,我们首先从一个接受n
作为参数并总是返回0
的函数开始。
def factorial(n):
return 0
现在让我们添加定义的第一部分——如果参数恰好是0
,我们只需要返回1
:
def factorial(n):
if n == 0:
return 1
else:
return 0
现在让我们填写第二部分——如果n
不为0
,我们必须进行递归调用,找到n-1
的阶乘,然后将结果与n
相乘:
def factorial(n):
if n == 0:
return 1
else:
recurse = factorial(n-1)
return n * recurse
这个程序的执行流程类似于第五章中的countdown
流程。如果我们用值3
调用factorial
:
由于3
不等于0
,我们采取第二个分支并计算n-1
的阶乘。…
由于
2
不等于0
,我们采取第二个分支并计算n-1
的阶乘。…由于
1
不等于0
,我们采取第二个分支并计算n-1
的阶乘。…由于
0
等于0
,我们采取第一个分支并返回1
,不再进行递归调用。返回值
1
与n
(即1
)相乘,结果被返回。返回值
1
与n
(即2
)相乘,结果被返回。
返回值2
与n
(即3
)相乘,结果6
成为整个过程启动时函数调用的返回值。
下图展示了这个函数调用序列的栈图。
[外链图片转存中…(img-VlxVBguv-1748168092715)]
返回值被显示为从栈中返回。在每一帧中,返回值是n
与recurse
的乘积。
在最后一帧中,本地变量recurse
不存在,因为创建它的分支没有执行。
6.7. 信念的飞跃
跟踪执行流程是阅读程序的一种方式,但它很快就会变得让人不堪重负。另一种方法是我所称的“信念的飞跃”。当你遇到一个函数调用时,与你跟踪执行流程不同,你可以假设该函数正确工作并返回正确的结果。
实际上,当你使用内置函数时,你已经在实践这种“信念的飞跃”。当你调用abs
或math.sqrt
时,你并没有检查这些函数的内部实现——你只是认为它们是有效的。
当你调用自己的函数时,也是如此。例如,之前我们写了一个名为is_divisible
的函数,用来判断一个数是否能被另一个数整除。一旦我们确信这个函数是正确的,就可以在不再查看函数体的情况下使用它。
递归程序也是如此。当你到达递归调用时,应该假设递归调用是正确的,而不是跟随执行流程。然后你应该问自己,“假设我可以计算(n-1)的阶乘,我能计算(n)的阶乘吗?”阶乘的递归定义意味着你可以通过乘以(n)来计算。
当然,假设一个函数在你还没写完的时候就能正确工作,这有点奇怪,但这就是为什么它被称为信任的跳跃!
6.8. 斐波那契
在factorial
之后,最常见的递归函数示例是fibonacci
,它有如下定义:
[\begin{split}\begin{aligned} \mathrm{fibonacci}(0) &= 0 \ \mathrm{fibonacci}(1) &= 1 \ \mathrm{fibonacci}(n) &= \mathrm{fibonacci}(n-1) + \mathrm{fibonacci}(n-2) \end{aligned}\end{split}]
将其翻译为 Python 代码,像这样:
def fibonacci(n):
if n == 0:
return 0
elif n == 1:
return 1
else:
return fibonacci(n-1) + fibonacci(n-2)
如果你尝试跟踪这里的执行流程,即使是对于较小的(n)值,你也会感到头晕目眩。但根据信任的跳跃法则,如果你假设两个递归调用是正确的,你就可以确信最后的return
语句是正确的。
顺便提一下,这种计算斐波那契数的方法效率非常低。在第十章中,我会解释为什么,并提出一种改进方法。
6.9. 检查类型
如果我们调用factorial
并将1.5
作为参数传递,会发生什么呢?
factorial(1.5)
RecursionError: maximum recursion depth exceeded in comparison
看起来像是无限递归。这怎么可能呢?函数在n == 1
或n == 0
时有基本情况。但如果n
不是整数,我们可能会错过基本情况并进行无限递归。
在这个例子中,n
的初始值为1.5
。在第一次递归调用中,n
的值是0.5
。接下来是-0.5
,然后它变得更小(更负),但永远不会是0
。
为了避免无限递归,我们可以使用内置函数isinstance
来检查参数的类型。下面是我们检查一个值是否为整数的方法。
isinstance(3, int)
True
isinstance(1.5, int)
False
现在,这是一个带有错误检查的factorial
版本。
def factorial(n):
if not isinstance(n, int):
print('factorial is only defined for integers.')
return None
elif n < 0:
print('factorial is not defined for negative numbers.')
return None
elif n == 0:
return 1
else:
return n * factorial(n-1)
首先,它检查n
是否为整数。如果不是,它会显示一个错误消息并返回None
。
factorial('crunchy frog')
factorial is only defined for integers.
然后,它检查n
是否为负数。如果是,它会显示一个错误消息并返回None
。
factorial(-2)
factorial is not defined for negative numbers.
如果我们通过了两个检查,我们就知道n
是一个非负整数,因此可以确信递归会终止。检查函数的参数以确保它们具有正确的类型和值,称为输入验证。
6.10. 调试
将一个大型程序分解成更小的函数会创建自然的调试检查点。如果某个函数无法正常工作,可以考虑三种可能性:
-
函数获取的参数有问题——也就是说,前置条件被违反了。
-
函数有问题——也就是说,后置条件被违反了。
-
调用者在返回值的使用上出现了问题。
为了排除第一种可能性,可以在函数的开始处添加print
语句,显示参数的值(可能还包括它们的类型)。或者你可以写代码显式检查前置条件。
如果参数看起来没问题,可以在每个return
语句之前添加print
语句,显示返回值。如果可能,使用有助于检查结果的参数调用函数。
如果函数似乎在工作,检查函数调用,确保返回值被正确使用——或者至少被使用!
在函数的开始和结束处添加print
语句可以帮助使执行流程更加可见。例如,以下是带有print
语句的factorial
函数版本:
def factorial(n):
space = ' ' * (4 * n)
print(space, 'factorial', n)
if n == 0:
print(space, 'returning 1')
return 1
else:
recurse = factorial(n-1)
result = n * recurse
print(space, 'returning', result)
return result
space
是一个由空格字符组成的字符串,用来控制输出的缩进。以下是factorial(3)
的结果:
factorial(3)
factorial 3
factorial 2
factorial 1
factorial 0
returning 1
returning 1
returning 2
returning 6
6
如果你对执行流程感到困惑,这种输出可能会很有帮助。开发有效的脚手架代码需要一些时间,但少量的脚手架代码可以节省大量调试时间。
6.11. 术语表
返回值: 函数的结果。如果函数调用作为表达式使用,则返回值是该表达式的值。
纯函数: 一个不会显示任何内容或产生任何其他副作用的函数,除了返回返回值之外。
死代码: 程序中无法运行的部分,通常是因为它出现在return
语句之后。
增量开发: 一种程序开发计划,旨在通过一次只添加和测试少量代码来避免调试。
脚手架代码: 在程序开发过程中使用的代码,但不是最终版本的一部分。
图灵完备: 如果一个语言或语言的子集能够执行任何可以通过算法描述的计算,那么它是图灵完备的。
输入验证: 检查函数的参数,确保它们具有正确的类型和值。
6.12. 练习
# This cell tells Jupyter to provide detailed debugging information
# when a runtime error occurs. Run it before working on the exercises.
%xmode Verbose
Exception reporting mode: Verbose
6.12.1. 向虚拟助手询问
在本章中,我们看到一个不正确的函数,它可能在没有返回值的情况下结束。
def absolute_value_wrong(x):
if x < 0:
return -x
if x > 0:
return x
这是同一个函数的一个版本,它在末尾有死代码。
def absolute_value_extra_return(x):
if x < 0:
return -x
else:
return x
return 'This is dead code.'
我们看到了以下示例,这虽然是正确的,但不够地道。
def is_divisible(x, y):
if x % y == 0:
return True
else:
return False
向虚拟助手询问这些函数的错误,并看看它是否能发现错误或改进风格。
然后问:“编写一个函数,接受两点的坐标并计算它们之间的距离。”看看结果是否与我们在本章中编写的distance
函数类似。
6.12.2. 练习
使用增量开发编写一个名为hypot
的函数,给定直角三角形的其他两个边的长度作为参数,返回斜边的长度。
注意:在数学模块中有一个叫做hypot
的函数,执行相同的操作,但你不应该在这个练习中使用它!
即使你第一次就能正确编写该函数,还是从一个始终返回0
的函数开始,并练习逐步修改,边修改边测试。完成后,该函数应仅返回一个值——不应输出任何内容。
6.12.3. 练习
编写一个布尔函数is_between(x, y, z)
,如果(x < y < z)或者(z < y < x),则返回True
,否则返回False
。
6.12.4. 练习
阿克曼函数(A(m, n))定义如下:
[\begin{split}\begin{aligned} A(m, n) = \begin{cases} n+1 & \mbox{如果} m = 0 \ A(m-1, 1) & \mbox{如果} m > 0 \mbox{ 且 } n = 0 \ A(m-1, A(m, n-1)) & \mbox{如果} m > 0 \mbox{ 且 } n > 0. \end{cases} \end{aligned}\end{split}]
编写一个名为ackermann
的函数来计算阿克曼函数。当你调用ackermann(5, 5)
时,会发生什么?
6.12.5. 练习
(a)和(b)的最大公约数(GCD)是能够整除它们两者且没有余数的最大数。
找到两个数的 GCD 的一种方法是基于以下观察:如果(r)是将(a)除以(b)后的余数,那么(gcd(a, b) = gcd(b, r))。作为基础情况,我们可以使用(gcd(a, 0) = a)。
编写一个名为gcd
的函数,接受参数a
和b
,并返回它们的最大公约数。
版权 2024 Allen B. Downey
代码许可证:MIT 许可证
文本许可证:创意共享署名-非商业性使用-相同方式共享 4.0 国际版
7. 迭代与搜索
1939 年,厄尼斯特·文森特·赖特(Ernest Vincent Wright)出版了一本名为*《盖兹比》*的小说,这本小说有 50,000 个单词,却不包含字母“e”。因为“e”是英语中最常见的字母,甚至写几个不使用它的单词也是困难的。为了感受它的难度,在本章中我们将计算英语单词中至少包含一个“e”的比例。
为此,我们将使用for
语句循环遍历字符串中的字母和文件中的单词,并在循环中更新变量以统计包含字母“e”的单词数量。我们将使用in
运算符来检查字母是否出现在单词中,你将学习一种叫做“线性搜索”的编程模式。
作为练习,你将使用这些工具来解决一个叫做“拼字蜂”(Spelling Bee)的字谜。
7.1. 循环与字符串
在第三章中,我们看到一个使用range
函数的for
循环来显示一系列数字。
for i in range(3):
print(i, end=' ')
0 1 2
这个版本使用了关键字参数end
,因此print
函数在每个数字后面添加一个空格,而不是换行符。
我们还可以使用for
循环来显示字符串中的字母。
for letter in 'Gadsby':
print(letter, end=' ')
G a d s b y
请注意,我将变量名从i
更改为letter
,这样可以提供更多关于它所代表的值的信息。在for
循环中定义的变量称为循环变量。
现在我们可以遍历单词中的字母,检查它是否包含字母“e”。
for letter in "Gadsby":
if letter == 'E' or letter == 'e':
print('This word has an "e"')
在继续之前,我们将把这个循环封装成一个函数。
def has_e():
for letter in "Gadsby":
if letter == 'E' or letter == 'e':
print('This word has an "e"')
我们让它成为一个纯函数,如果单词包含“e”则返回True
,否则返回False
。
def has_e():
for letter in "Gadsby":
if letter == 'E' or letter == 'e':
return True
return False
我们可以将其推广为以单词作为参数。
def has_e(word):
for letter in word:
if letter == 'E' or letter == 'e':
return True
return False
现在我们可以这样测试:
has_e('Gadsby')
False
has_e('Emma')
True
7.2. 阅读单词列表
为了查看有多少单词包含字母“e”,我们需要一个单词列表。我们将使用一个包含约 114,000 个官方填字游戏单词的列表;即那些被认为在填字游戏和其他字谜游戏中有效的单词。
单词列表保存在一个名为words.txt
的文件中,该文件已在本章的笔记本中下载。为了读取它,我们将使用内置函数open
,它接受文件名作为参数,并返回一个文件对象,我们可以用它来读取文件。
file_object = open('words.txt')
文件对象提供了一个叫做readline
的函数,它从文件中读取字符,直到遇到换行符,并将结果作为字符串返回:
file_object.readline()
'aa\n'
请注意,调用readline
的语法与我们到目前为止看到的函数不同。这是因为它是一个方法,即与对象相关联的函数。在这种情况下,readline
与文件对象相关联,因此我们使用对象的名称、点操作符和方法名称来调用它。
列表中的第一个单词是“aa”,它是一种岩浆。序列\n
代表换行符,分隔这个单词与下一个单词。
文件对象会记录它在文件中的位置,因此如果你再次调用readline
,就会得到下一个单词:
line = file_object.readline()
line
'aah\n'
为了去掉单词结尾的换行符,我们可以使用strip
,它是一个与字符串关联的方法,因此我们可以这样调用它。
word = line.strip()
word
'aah'
strip
方法会去除字符串开头和结尾的空白字符——包括空格、制表符和换行符。
你还可以在for
循环中使用文件对象。这个程序读取words.txt
并打印每个单词,每行一个:
for line in open('words.txt'):
word = line.strip()
print(word)
现在我们可以读取单词列表,下一步是统计它们的数量。为此,我们需要能够更新变量。
7.3. 更新变量
正如你可能已经发现的,给同一个变量做多个赋值是合法的。新的赋值语句会使一个已存在的变量指向新的值(并停止指向旧值)。
例如,下面是一个创建变量的初始赋值。
x = 5
x
5
这里是一个改变变量值的赋值语句。
x = 7
x
7
下图展示了这些赋值在状态图中的样子。
[外链图片转存中…(img-z0Qdprxz-1748168092717)]
虚线箭头表示x
不再指向5
。实线箭头表示它现在指向7
。
一种常见的赋值类型是更新,其中变量的新值依赖于旧值。
x = x + 1
x
8
这条语句的意思是:“获取x
的当前值,增加 1,然后将结果重新赋值给x
。”
如果你试图更新一个不存在的变量,会得到一个错误,因为 Python 在赋值给变量之前会先计算右边的表达式。
z = z + 1
NameError: name 'z' is not defined
在你更新变量之前,你必须初始化它,通常通过简单的赋值来实现:
z = 0
z = z + 1
z
1
增加变量值的操作称为增量;减少变量值的操作称为减量。由于这些操作非常常见,Python 提供了增强赋值运算符,使得更新变量变得更加简洁。例如,+=
运算符会将变量增加给定的数值。
z += 2
z
3
其他算术运算符也有增强赋值运算符,包括-=
和*=
。
7.4. 循环与计数
以下程序计算单词列表中单词的数量。
total = 0
for line in open('words.txt'):
word = line.strip()
total += 1
它首先将total
初始化为0
。每次循环时,它会将total
增加1
。因此,当循环结束时,total
表示单词的总数。
total
113783
这种用来计算某件事情发生次数的变量叫做计数器。
我们可以在程序中添加第二个计数器,用来追踪包含字母“e”的单词数量。
total = 0
count = 0
for line in open('words.txt'):
word = line.strip()
total = total + 1
if has_e(word):
count += 1
让我们来看一看有多少个单词包含字母“e”。
count
76162
作为total
的百分比,大约三分之二的单词使用了字母“e”。
count / total * 100
66.93618554617122
因此,你可以理解为什么在不使用这些词的情况下构造一本书是多么困难。
7.5. in
运算符
我们在本章编写的 has_e
版本比实际需要的要复杂。Python 提供了一个运算符 in
,它检查一个字符是否出现在字符串中。
word = 'Gadsby'
'e' in word
False
所以我们可以将 has_e
改写为这样。
def has_e(word):
if 'E' in word or 'e' in word:
return True
else:
return False
由于 if
语句的条件有布尔值,我们可以省略 if
语句,直接返回布尔值。
def has_e(word):
return 'E' in word or 'e' in word
我们可以通过使用 lower
方法进一步简化这个函数,lower
会将字符串中的字母转换为小写。这里是一个例子。
word.lower()
'gadsby'
lower
会创建一个新的字符串——它不会修改现有的字符串——所以 word
的值不会改变。
word
'Gadsby'
这是我们在 has_e
中如何使用 lower
的方法。
def has_e(word):
return 'e' in word.lower()
has_e('Gadsby')
False
has_e('Emma')
True
7.6. 搜索
基于这个简化版的 has_e
,我们可以编写一个更通用的函数 uses_any
,它接受第二个参数,该参数是一个字母的字符串。如果单词中使用了这些字母中的任何一个,它返回 True
,否则返回 False
。
def uses_any(word, letters):
for letter in word.lower():
if letter in letters.lower():
return True
return False
这里是一个结果为 True
的例子。
uses_any('banana', 'aeiou')
True
另一个例子中结果是 False
。
uses_any('apple', 'xyz')
False
uses_any
会将 word
和 letters
转换为小写,因此它可以处理任何大小写组合。
uses_any('Banana', 'AEIOU')
True
uses_any
的结构类似于 has_e
。它会循环遍历 word
中的字母并逐个检查。如果找到一个出现在 letters
中的字母,它会立即返回 True
。如果循环结束都没有找到任何字母,它会返回 False
。
这种模式称为线性搜索。在本章末的练习中,你将编写更多使用这种模式的函数。
7.7. Doctest
在第四章中,我们使用了文档字符串来记录函数——即解释它的作用。也可以使用文档字符串来测试一个函数。这里是包含测试的 uses_any
函数版本。
def uses_any(word, letters):
"""Checks if a word uses any of a list of letters.
>>> uses_any('banana', 'aeiou')
True
>>> uses_any('apple', 'xyz')
False
"""
for letter in word.lower():
if letter in letters.lower():
return True
return False
每个测试都以 >>>
开头,这是一些 Python 环境中用来表示用户可以输入代码的提示符。在 doctest 中,提示符后跟一个表达式,通常是一个函数调用。接下来的一行表示如果函数正确工作,表达式应具有的值。
在第一个例子中,'banana'
使用了 'a'
,所以结果应该是 True
。在第二个例子中,'apple'
没有使用 'xyz'
中的任何字符,所以结果应该是 False
。
为了运行这些测试,我们必须导入 doctest
模块,并运行一个名为 run_docstring_examples
的函数。为了让这个函数更容易使用,我编写了以下函数,它接受一个函数对象作为参数。
from doctest import run_docstring_examples
def run_doctests(func):
run_docstring_examples(func, globals(), name=func.__name__)
我们还没有学习 globals
和 __name__
,可以忽略它们。现在我们可以像这样测试 uses_any
。
run_doctests(uses_any)
run_doctests
会找到文档字符串中的表达式并对其进行评估。如果结果是预期的值,则测试通过。否则,它失败。
如果所有测试都通过,run_doctests
将不显示任何输出——在这种情况下,无消息即好消息。若要查看测试失败时发生的情况,下面是uses_any
的一个错误版本。
def uses_any_incorrect(word, letters):
"""Checks if a word uses any of a list of letters.
>>> uses_any_incorrect('banana', 'aeiou')
True
>>> uses_any_incorrect('apple', 'xyz')
False
"""
for letter in word.lower():
if letter in letters.lower():
return True
else:
return False # INCORRECT!
这是我们测试时发生的情况。
run_doctests(uses_any_incorrect)
**********************************************************************
File "__main__", line 4, in uses_any_incorrect
Failed example:
uses_any_incorrect('banana', 'aeiou')
Expected:
True
Got:
False
输出包括失败的示例、函数预期生成的值和函数实际生成的值。
如果你不确定为什么这个测试失败,你将有机会作为练习进行调试。
7.8. 词汇表
循环变量: 在for
循环头部定义的变量。
文件对象: 一个表示已打开文件的对象,负责追踪文件的哪些部分已被读取或写入。
方法: 与对象关联的函数,并通过点操作符调用。
更新: 一种赋值语句,用于给已存在的变量赋新值,而不是创建新变量。
初始化: 创建一个新变量并为其赋值。
增量: 增加变量的值。
递减: 减少变量的值。
计数器: 用于计数的变量,通常初始化为零,然后递增。
线性搜索: 一种计算模式,它通过一系列元素进行搜索,并在找到目标时停止。
通过: 如果测试运行并且结果符合预期,则该测试通过。
失败: 如果测试运行后结果与预期不符,则该测试失败。
7.9. 练习
# This cell tells Jupyter to provide detailed debugging information
# when a runtime error occurs. Run it before working on the exercises.
%xmode Verbose
Exception reporting mode: Verbose
7.9.1. 向虚拟助手询问
在uses_any
中,你可能注意到第一个return
语句在循环内部,而第二个在外部。
def uses_any(word, letters):
for letter in word.lower():
if letter in letters.lower():
return True
return False
当人们第一次编写像这样的函数时,通常会犯一个错误,就是将两个return
语句放在循环内部,像这样。
def uses_any_incorrect(word, letters):
for letter in word.lower():
if letter in letters.lower():
return True
else:
return False # INCORRECT!
向虚拟助手询问这个版本有什么问题。
7.9.2. 练习
编写一个名为uses_none
的函数,它接受一个单词和一个禁用字母的字符串,如果该单词不包含任何禁用字母,则返回True
。
这是一个包含两个文档测试的函数大纲。填写函数代码以通过这些测试,并添加至少一个文档测试。
def uses_none(word, forbidden):
"""Checks whether a word avoid forbidden letters.
>>> uses_none('banana', 'xyz')
True
>>> uses_none('apple', 'efg')
False
"""
return None
7.9.3. 练习
编写一个名为uses_only
的函数,它接受一个单词和一个字母字符串,如果该单词仅包含字符串中的字母,则返回True
。
这是一个包含两个文档测试的函数大纲。填写函数代码以通过这些测试,并添加至少一个文档测试。
def uses_only(word, available):
"""Checks whether a word uses only the available letters.
>>> uses_only('banana', 'ban')
True
>>> uses_only('apple', 'apl')
False
"""
return None
7.9.4. 练习
编写一个名为uses_all
的函数,它接受一个单词和一个字母字符串,如果该单词包含该字符串中的所有字母至少一次,则返回True
。
这是一个包含两个文档测试的函数大纲。填写函数代码以通过这些测试,并添加至少一个文档测试。
def uses_all(word, required):
"""Checks whether a word uses all required letters.
>>> uses_all('banana', 'ban')
True
>>> uses_all('apple', 'api')
False
"""
return None
7.9.5. 练习
纽约时报每天发布一个名为“拼字蜂”的谜题,挑战读者使用七个字母拼尽可能多的单词,其中一个字母是必需的。单词必须至少有四个字母。
例如,在我写这篇文章的那一天,字母是ACDLORT
,其中R
是必需的字母。所以“color”是一个合法的单词,但“told”不是,因为它没有使用R
,而“rat”也不是,因为它只有三个字母。字母可以重复,因此“ratatat”是合法的。
编写一个名为check_word
的函数,用于检查给定的单词是否符合要求。它应该接受三个参数:要检查的单词、一个包含七个可用字母的字符串,以及一个包含单个必需字母的字符串。你可以使用你在之前练习中写的函数。
下面是包含文档测试的函数大纲。填写函数并检查所有测试是否通过。
def check_word(word, available, required):
"""Check whether a word is acceptable.
>>> check_word('color', 'ACDLORT', 'R')
True
>>> check_word('ratatat', 'ACDLORT', 'R')
True
>>> check_word('rat', 'ACDLORT', 'R')
False
>>> check_word('told', 'ACDLORT', 'R')
False
>>> check_word('bee', 'ACDLORT', 'R')
False
"""
return False
根据“拼字蜂”的规则,
-
四个字母的单词值 1 分。
-
较长的单词每个字母得 1 分。
-
每个谜题至少包含一个“全字母句”(pangram),即包含所有字母的句子。这些可以获得 7 个额外分数!
编写一个名为score_word
的函数,接受一个单词和一串可用字母,并返回该单词的得分。你可以假设这个单词是合法的。
再次,这是包含文档测试的函数大纲。
def word_score(word, available):
"""Compute the score for an acceptable word.
>>> word_score('card', 'ACDLORT')
1
>>> word_score('color', 'ACDLORT')
5
>>> word_score('cartload', 'ACDLORT')
15
"""
return 0
7.9.6. 练习
你可能注意到你在之前练习中写的函数有很多相似之处。实际上,它们如此相似,以至于你可以经常用一个函数来写另一个。
例如,如果一个单词没有使用任何一组禁止字母,这意味着它根本没有使用任何字母。所以我们可以这样写一个uses_none
的版本。
def uses_none(word, forbidden):
"""Checks whether a word avoids forbidden letters.
>>> uses_none('banana', 'xyz')
True
>>> uses_none('apple', 'efg')
False
>>> uses_none('', 'abc')
True
"""
return not uses_any(word, forbidden)
uses_only
和uses_all
之间也有相似之处,你可以加以利用。如果你已经有了uses_only
的工作版本,看看你能否写出一个调用uses_only
的uses_all
版本。
7.9.7. 练习
如果你在前一个问题上卡住了,试着向虚拟助手提问:“给定一个函数,uses_only
,它接受两个字符串并检查第一个字符串是否只使用第二个字符串中的字母,用它来写uses_all
,它接受两个字符串并检查第一个字符串是否使用了第二个字符串中的所有字母,允许重复字母。”
使用run_doctests
检查答案。
7.9.8. 练习
现在让我们看看是否能基于uses_any
写出uses_all
。
向虚拟助手提问:“给定一个函数,uses_any
,它接受两个字符串并检查第一个字符串是否使用了第二个字符串中的任何字母,你能否用它来写uses_all
,它接受两个字符串并检查第一个字符串是否使用了第二个字符串中的所有字母,允许重复字母。”
如果它说可以,确保测试结果!
# Here's what I got from ChatGPT 4o December 26, 2024
# It's correct, but it makes multiple calls to uses_any
def uses_all(s1, s2):
"""Checks if all characters in s2 are in s1, allowing repeats."""
for char in s2:
if not uses_any(s1, char):
return False
return True
版权所有 2024 Allen B. Downey
代码许可证:MIT 许可证
文本许可证:创作共用署名-非商业性使用-相同方式共享 4.0 国际版
8. 字符串和正则表达式
字符串不同于整数、浮点数和布尔值。字符串是一个序列,意味着它包含多个按特定顺序排列的值。在本章中,我们将学习如何访问组成字符串的值,并使用处理字符串的函数。
我们还将使用正则表达式,它是查找字符串中模式并执行如搜索和替换等操作的强大工具。
作为练习,你将有机会将这些工具应用到一个叫做 Wordle 的单词游戏中。
8.1. 字符串是一个序列
字符串是字符的序列。字符可以是字母(几乎所有字母表中的字母)、数字、标点符号或空格。
你可以使用方括号操作符从字符串中选择一个字符。这个示例语句从fruit
中选择第 1 个字符,并将其赋值给letter
:
fruit = 'banana'
letter = fruit[1]
方括号中的表达式是一个索引,之所以这么叫,是因为它指示要选择序列中的哪个字符。但结果可能不是你预期的。
letter
'a'
索引为1
的字母实际上是字符串中的第二个字母。索引是从字符串开始位置的偏移量,所以第一个字母的偏移量是0
。
fruit[0]
'b'
你可以把'b'
当作'banana'
的第 0 个字母——读作“零个”。
方括号中的索引可以是一个变量。
i = 1
fruit[i]
'a'
或者是包含变量和运算符的表达式。
fruit[i+1]
'n'
但是,索引的值必须是整数——否则你会遇到TypeError
。
fruit[1.5]
TypeError: string indices must be integers
正如我们在第一章中看到的,我们可以使用内置函数len
来获取字符串的长度。
n = len(fruit)
n
6
为了获取字符串中的最后一个字母,你可能会想写成这样:
fruit[n]
IndexError: string index out of range
但这会导致IndexError
,因为在'banana'
中没有索引为 6 的字母。因为我们从0
开始计数,所以六个字母的编号是0
到5
。要获取最后一个字符,你需要从n
中减去1
:
fruit[n-1]
'a'
但有更简单的方法。要获取字符串中的最后一个字母,你可以使用负索引,它从字符串的末尾向后计数。
fruit[-1]
'a'
索引-1
选择最后一个字母,-2
选择倒数第二个字母,以此类推。
8.2. 字符串切片
字符串的一部分称为切片。选择切片类似于选择单个字符。
fruit = 'banana'
fruit[0:3]
'ban'
运算符[n:m]
返回字符串从第n
个字符到第m
个字符的部分,包括第一个字符但不包括第二个字符。这种行为是反直觉的,但可以通过想象索引指向字符之间的空间来帮助理解,如图所示:
[外链图片转存中…(img-0YGbxqfD-1748168092717)]
例如,切片[3:6]
选择了字母ana
,这意味着6
在作为切片的一部分时是合法的,但作为索引时是不合法的。
如果省略第一个索引,切片将从字符串的开头开始。
fruit[:3]
'ban'
如果省略第二个索引,切片将一直延伸到字符串的末尾:
fruit[3:]
'ana'
如果第一个索引大于或等于第二个索引,结果将是一个空字符串,由两个引号表示:
fruit[3:3]
''
空字符串不包含任何字符,长度为 0。
继续这个例子,你认为fruit[:]
意味着什么?试试看吧。
8.3. 字符串是不可变的
很容易在赋值语句的左侧使用[]
运算符,试图更改字符串中的字符,如下所示:
greeting = 'Hello, world!'
greeting[0] = 'J'
TypeError: 'str' object does not support item assignment
结果是一个TypeError
。在错误信息中,“对象”是字符串,“项”是我们试图赋值的字符。目前,对象与值是相同的,但我们稍后会进一步细化这个定义。
发生这个错误的原因是字符串是不可变的,这意味着你不能更改一个已有的字符串。你能做的最好的事是创建一个新的字符串,它是原始字符串的变体。
new_greeting = 'J' + greeting[1:]
new_greeting
'Jello, world!'
这个例子将一个新的首字母连接到greeting
的切片上。它不会对原始字符串产生影响。
greeting
'Hello, world!'
8.4. 字符串比较
关系运算符适用于字符串。要检查两个字符串是否相等,我们可以使用==
运算符。
word = 'banana'
if word == 'banana':
print('All right, banana.')
All right, banana.
其他关系运算符对于将单词按字母顺序排列很有用:
def compare_word(word):
if word < 'banana':
print(word, 'comes before banana.')
elif word > 'banana':
print(word, 'comes after banana.')
else:
print('All right, banana.')
compare_word('apple')
apple comes before banana.
Python 处理大写字母和小写字母的方式不同于人类。所有大写字母都排在所有小写字母之前,所以:
compare_word('Pineapple')
Pineapple comes before banana.
为了解决这个问题,我们可以在进行比较之前将字符串转换为标准格式,例如全小写。请记住,如果你需要防御一个手持菠萝的人时,这一点非常重要。
8.5. 字符串方法
字符串提供了执行多种有用操作的方法。方法类似于函数——它接受参数并返回一个值——但语法有所不同。例如,upper
方法接受一个字符串并返回一个新的字符串,其中所有字母都为大写。
它使用的方法语法是word.upper()
,而不是函数语法upper(word)
。
word = 'banana'
new_word = word.upper()
new_word
'BANANA'
这种使用点运算符的方式指定了方法的名称upper
,以及要应用该方法的字符串名称word
。空的括号表示此方法不接受任何参数。
方法调用称为调用;在这种情况下,我们可以说我们正在对word
调用upper
方法。
8.6. 写入文件
字符串运算符和方法对于读取和写入文本文件非常有用。举个例子,我们将处理德古拉的文本,这本由布拉姆·斯托克创作的小说可以从古腾堡计划获取(www.gutenberg.org/ebooks/345
)。
我已经将这本书下载为名为pg345.txt
的纯文本文件,我们可以像这样打开它进行阅读:
reader = open('pg345.txt')
除了书籍的文本外,这个文件在开头包含了一段关于书籍的信息,在结尾包含了一段关于许可证的信息。在处理文本之前,我们可以通过找到以 '***'
开头的特殊行来去除这些额外的内容。
以下函数接收一行并检查它是否是特殊行之一。它使用 startswith
方法,检查字符串是否以给定的字符序列开头。
def is_special_line(line):
return line.startswith('*** ')
我们可以使用这个函数来遍历文件中的行,并仅打印特殊行。
for line in reader:
if is_special_line(line):
print(line.strip())
*** START OF THE PROJECT GUTENBERG EBOOK DRACULA ***
*** END OF THE PROJECT GUTENBERG EBOOK DRACULA ***
现在,让我们创建一个新的文件,命名为 pg345_cleaned.txt
,其中只包含书籍的文本。为了再次循环遍历书籍,我们必须重新打开它以进行读取。并且,为了写入新文件,我们可以以写入模式打开它。
reader = open('pg345.txt')
writer = open('pg345_cleaned.txt', 'w')
open
接受一个可选参数,指定“模式”——在这个例子中,'w'
表示我们以写入模式打开文件。如果文件不存在,它将被创建;如果文件已经存在,内容将被替换。
作为第一步,我们将遍历文件,直到找到第一行特殊行。
for line in reader:
if is_special_line(line):
break
break
语句“跳出”循环——也就是说,它会立即结束循环,而不等到文件末尾。
当循环退出时,line
包含了使条件为真的特殊行。
line
'*** START OF THE PROJECT GUTENBERG EBOOK DRACULA ***\n'
因为 reader
会跟踪文件中的当前位置,我们可以使用第二个循环从我们离开的地方继续。
以下循环逐行读取文件的其余部分。当它找到表示文本结束的特殊行时,它会跳出循环。否则,它会将该行写入输出文件。
for line in reader:
if is_special_line(line):
break
writer.write(line)
当这个循环退出时,line
包含第二个特殊行。
line
'*** END OF THE PROJECT GUTENBERG EBOOK DRACULA ***\n'
此时,reader
和 writer
仍然打开,这意味着我们可以继续从 reader
读取行或向 writer
写入行。为了表示我们完成了,我们可以通过调用 close
方法关闭这两个文件。
reader.close()
writer.close()
为了检查这个过程是否成功,我们可以读取刚创建的新文件中的前几行。
for line in open('pg345_cleaned.txt'):
line = line.strip()
if len(line) > 0:
print(line)
if line.endswith('Stoker'):
break
DRACULA
_by_
Bram Stoker
endswith
方法检查字符串是否以给定的字符序列结尾。
8.7. 查找与替换
在 1901 年的冰岛语版《德古拉》中,其中一个角色的名字从 “Jonathan” 改成了 “Thomas”。为了在英文版中进行这一更改,我们可以遍历整本书,使用 replace
方法将一个名字替换成另一个,并将结果写入新文件。
我们将从计数文件清理版的行数开始。
total = 0
for line in open('pg345_cleaned.txt'):
total += 1
total
15499
为了查看一行是否包含“Jonathan”,我们可以使用 in
运算符,检查该字符序列是否出现在行中。
total = 0
for line in open('pg345_cleaned.txt'):
if 'Jonathan' in line:
total += 1
total
199
有 199 行包含这个名字,但这还不是它出现的总次数,因为它可能在一行中出现多次。为了得到总数,我们可以使用 count
方法,它返回字符串中某个序列出现的次数。
total = 0
for line in open('pg345_cleaned.txt'):
total += line.count('Jonathan')
total
200
现在我们可以像这样将 'Jonathan'
替换为 'Thomas'
:
writer = open('pg345_replaced.txt', 'w')
for line in open('pg345_cleaned.txt'):
line = line.replace('Jonathan', 'Thomas')
writer.write(line)
结果是一个新文件,名为 pg345_replaced.txt
,其中包含了德古拉的一个版本,在这个版本中,Jonathan Harker 被称为 Thomas。
8.8. 正则表达式
如果我们确切知道要寻找的字符序列,可以使用 in
运算符来查找,并使用 replace
方法替换它。但还有一种工具,叫做正则表达式,也可以执行这些操作——而且功能更多。
为了演示,我将从一个简单的例子开始,然后逐步增加难度。假设,我们再次想要找到所有包含特定单词的行。为了换个方式,我们来看一下书中提到主人公德古拉伯爵的地方。这里有一行提到他。
text = "I am Dracula; and I bid you welcome, Mr. Harker, to my house."
这是我们将用于搜索的模式。
pattern = 'Dracula'
一个名为 re
的模块提供了与正则表达式相关的函数。我们可以像这样导入它,并使用 search
函数来检查模式是否出现在文本中。
import re
result = re.search(pattern, text)
result
<re.Match object; span=(5, 12), match='Dracula'>
如果模式出现在文本中,search
将返回一个包含搜索结果的 Match
对象。除了其他信息外,它还包含一个名为 string
的变量,其中包含被搜索的文本。
result.string
'I am Dracula; and I bid you welcome, Mr. Harker, to my house.'
它还提供了一个名为 group
的方法,可以返回匹配模式的文本部分。
result.group()
'Dracula'
它还提供了一个名为 span
的方法,可以返回模式在文本中开始和结束的位置。
result.span()
(5, 12)
如果模式在文本中没有出现,search
的返回值是 None
。
result = re.search('Count', text)
print(result)
None
因此,我们可以通过检查结果是否为 None
来判断搜索是否成功。
result == None
True
将这些都结合起来,这里有一个函数,它循环遍历书中的每一行,直到找到匹配给定模式的行,并返回 Match
对象。
def find_first(pattern):
for line in open('pg345_cleaned.txt'):
result = re.search(pattern, line)
if result != None:
return result
我们可以用它来找到某个字符的首次出现。
result = find_first('Harker')
result.string
'CHAPTER I. Jonathan Harker’s Journal\n'
对于这个例子,我们并不需要使用正则表达式——我们可以用 in
运算符更轻松地完成相同的事情。但正则表达式可以做一些 in
运算符无法做到的事情。
例如,如果模式中包括竖线字符,'|'
,它可以匹配左边或右边的序列。假设我们想要在书中找到 Mina Murray 的首次提及,但我们不确定她是用名字还是姓氏来称呼的。我们可以使用以下模式,它可以匹配这两个名字。
pattern = 'Mina|Murray'
result = find_first(pattern)
result.string
'CHAPTER V. Letters—Lucy and Mina\n'
我们可以使用这样的模式来查看某个字符通过名字提到的次数。这里有一个函数,它会遍历书中的每一行,并统计与给定模式匹配的行数。
def count_matches(pattern):
count = 0
for line in open('pg345_cleaned.txt'):
result = re.search(pattern, line)
if result != None:
count += 1
return count
现在让我们看看 Mina 被提到多少次。
count_matches('Mina|Murray')
229
特殊字符'^'
匹配字符串的开始,因此我们可以找到以给定模式开头的行。
result = find_first('^Dracula')
result.string
'Dracula, jumping to his feet, said:--\n'
特殊字符'$'
匹配字符串的结尾,因此我们可以找到以给定模式结尾的行(忽略行尾的换行符)。
result = find_first('Harker$')
result.string
"by five o'clock, we must start off; for it won't do to leave Mrs. Harker\n"
8.9. 字符串替换
布拉姆·斯托克出生在爱尔兰,*而《德古拉》*于 1897 年出版时,他正居住在英格兰。因此,我们可以预期他会使用英国拼写方式,例如“centre”和“colour”。为了验证这一点,我们可以使用以下模式,这个模式可以匹配“centre”或者美国拼写“center”。
pattern = 'cent(er|re)'
在这个模式中,括号包围的是竖线(|
)所应用的模式部分。所以这个模式匹配的是一个以'cent'
开头并且以'er'
或're'
结尾的序列。
result = find_first(pattern)
result.string
'horseshoe of the Carpathians, as if it were the centre of some sort of\n'
正如预期的那样,他使用了英国拼写。
我们还可以检查他是否使用了“colour”的英国拼写。以下模式使用了特殊字符'?'
,表示前一个字符是可选的。
pattern = 'colou?r'
这个模式可以匹配带有'u'
的“colour”或没有'u'
的“color”。
result = find_first(pattern)
line = result.string
line
'undergarment with long double apron, front, and back, of coloured stuff\n'
同样,正如预期的那样,他使用了英国拼写。
现在假设我们想制作一本使用美国拼写的书籍版本。我们可以使用re
模块中的sub
函数,它执行字符串替换。
re.sub(pattern, 'color', line)
'undergarment with long double apron, front, and back, of colored stuff\n'
第一个参数是我们想要查找并替换的模式,第二个是我们想用来替换的内容,第三个是我们要搜索的字符串。在结果中,你可以看到“colour”已被替换为“color”。
8.10. 调试
当你在读取和写入文件时,调试可能会很棘手。如果你在使用 Jupyter 笔记本,可以使用shell 命令来帮助。例如,要显示文件的前几行,可以使用命令!head
,像这样:
!head pg345_cleaned.txt
初始的感叹号!
表示这是一个 shell 命令,而不是 Python 的一部分。要显示最后几行,可以使用!tail
。
!tail pg345_cleaned.txt
当你处理大文件时,调试可能会变得困难,因为输出可能过多,无法手动检查。一个好的调试策略是从文件的一部分开始,先让程序正常工作,然后再用完整的文件运行它。
为了制作一个包含大文件部分内容的小文件,我们可以再次使用!head
并配合重定向符号>
,表示结果应该写入文件而不是显示在屏幕上。
!head pg345_cleaned.txt > pg345_cleaned_10_lines.txt
默认情况下,!head
读取前 10 行,但它也接受一个可选参数,用于指示要读取的行数。
!head -100 pg345_cleaned.txt > pg345_cleaned_100_lines.txt
这个 shell 命令读取pg345_cleaned.txt
的前 100 行,并将它们写入名为pg345_cleaned_100_lines.txt
的文件。
注意:shell 命令!head
和!tail
并非在所有操作系统上都可用。如果它们在你这里无法使用,我们可以用 Python 编写类似的函数。请参考本章最后的第一个练习获取建议。
8.11. 术语表
sequence(序列): 一种有序的值集合,每个值由一个整数索引标识。
character(字符): 字符串中的一个元素,包括字母、数字和符号。
index(索引): 用于选择序列中项的整数值,例如字符串中的字符。在 Python 中,索引从0
开始。
slice(切片): 由一系列索引范围指定的字符串的一部分。
empty string(空字符串): 一个不包含任何字符且长度为0
的字符串。
object(对象): 一个变量可以引用的事物。对象有类型和数值。
immutable(不可变): 如果一个对象的元素不能被改变,则该对象是不可变的。
invocation(调用): 一个表达式——或表达式的一部分——用于调用方法。
regular expression(正则表达式): 定义搜索模式的字符序列。
pattern(模式): 规定一个字符串必须满足的要求,以便构成匹配。
string substitution(字符串替换): 用另一个字符串替换字符串的部分内容。
shell command(shell 命令): 用于与操作系统交互的 shell 语言中的语句。
8.12. 练习
# This cell tells Jupyter to provide detailed debugging information
# when a runtime error occurs. Run it before working on the exercises.
%xmode Verbose
Exception reporting mode: Verbose
8.12.1. 向虚拟助手提问
在本章中,我们仅触及了正则表达式能做的事情的表面。为了了解正则表达式的可能性,可以问虚拟助手:“Python 正则表达式中最常用的特殊字符有哪些?”
你还可以请求匹配特定类型字符串的模式。例如,尝试询问:
-
编写一个匹配带有连字符的 10 位电话号码的 Python 正则表达式。
-
编写一个匹配带有数字和街道名称的街道地址的 Python 正则表达式,地址后跟
ST
或AVE
。 -
编写一个匹配完整姓名的 Python 正则表达式,该姓名可能包含常见的称谓,如
Mr
或Mrs
,后跟一个或多个以大写字母开头的名字,名字之间可能有连字符。
如果你想看一些更复杂的内容,可以尝试询问正则表达式,它匹配任何合法的 URL。
正则表达式通常在引号前面加上字母r
,表示这是一个“原始字符串”。欲了解更多信息,可以问虚拟助手:“Python 中的原始字符串是什么?”
8.12.2. 练习
看看你能否编写一个函数,实现与 shell 命令!head
相同的功能。它应该接受三个参数:要读取的文件名、要读取的行数以及要将这些行写入的文件名。如果第三个参数是None
,它应该显示这些行,而不是将它们写入文件。
考虑请虚拟助手帮忙,但如果这样做,请告诉它不要使用with
语句或try
语句。
8.12.3. 练习
“Wordle”是一个在线字谜游戏,目标是在六次或更少的尝试中猜出一个五个字母的单词。每次尝试必须被识别为一个单词,不包括专有名词。每次尝试后,你会得到有关你猜测的字母哪些出现在目标单词中,哪些在正确位置的信息。
例如,假设目标单词是MOWER
,你猜测了TRIED
。你会得知E
在单词中并且位置正确,R
在单词中但位置不正确,T
、I
和D
不在单词中。
作为一个不同的例子,假设你已经猜测了单词SPADE
和CLERK
,并且你得知E
在这个单词中,但不在这两个位置上,且其他字母都没有出现在单词中。在单词列表中的单词中,有多少个可能是目标单词?编写一个名为check_word
的函数,接收一个五个字母的单词并检查它是否可能是目标单词,基于这些猜测。
你可以使用上一章中的任何函数,如uses_any
。
8.12.4. 练习
继续上一个练习,假设你猜测了单词TOTEM
,并得知E
仍然不在正确的位置,但M
在正确的位置。剩下多少个单词?
8.12.5. 练习
基督山伯爵是亚历山大·仲马的小说,被认为是经典。然而,在这本书的英文版序言中,作家翁贝托·埃科承认他发现这本书是“有史以来写得最糟糕的小说之一”。
特别地,他说它在“重复使用相同的形容词”方面是“厚颜无耻的”,并特别提到“它的角色要么颤抖,要么变得苍白”的次数。
为了验证他的意见是否有效,让我们统计包含单词pale
的所有行,无论是pale
、pales
、paled
、paleness
,还是相关的单词pallor
。使用一个正则表达式匹配这些单词。作为额外的挑战,确保它不会匹配其他单词,比如impale
——你可能需要向虚拟助手寻求帮助。
版权所有 2024 Allen B. Downey
代码许可:MIT 许可证
文本许可:创作共用 署名-非商业性使用-相同方式共享 4.0 国际版
9. 列表
本章介绍了 Python 最有用的内建类型之一——列表。你还将学习更多关于对象的知识,以及当多个变量指向同一对象时会发生什么。
在本章末的练习中,我们将创建一个单词列表,并用它来查找特殊单词,如回文和变位词。
9.1. 列表是一个序列
像字符串一样,列表是值的序列。在字符串中,值是字符;在列表中,它们可以是任何类型。列表中的值称为元素。
有多种方法可以创建一个新列表;最简单的方法是将元素括在方括号([
和]
)中。例如,这是一个包含两个整数的列表。
numbers = [42, 123]
这里是一个包含三个字符串的列表。
cheeses = ['Cheddar', 'Edam', 'Gouda']
列表的元素不必是相同的类型。以下列表包含了一个字符串,一个浮点数,一个整数,甚至另一个列表。
t = ['spam', 2.0, 5, [10, 20]]
一个包含在另一个列表中的列表被称为嵌套列表。
一个不包含任何元素的列表被称为空列表;你可以用空括号[]
来创建一个空列表。
empty = []
len
函数返回列表的长度。
len(cheeses)
3
空列表的长度是0
。
以下图显示了cheeses
、numbers
和empty
的状态图。
[外链图片转存中…(img-a5tshYjZ-1748168092717)]
列表用带有“list”字样的盒子表示,列表的编号元素位于其中。
9.2. 列表是可变的
要读取列表的一个元素,我们可以使用括号运算符。第一个元素的索引是0
。
cheeses[0]
'Cheddar'
与字符串不同,列表是可变的。当括号运算符出现在赋值语句的左侧时,它标识了将被赋值的列表元素。
numbers[1] = 17
numbers
[42, 17]
numbers
的第二个元素,原本是123
,现在是17
。
列表索引的工作方式与字符串索引相同:
-
任何整数表达式都可以用作索引。
-
如果你尝试读取或写入一个不存在的元素,将会出现
IndexError
。 -
如果索引是负值,它从列表的末尾开始倒数。
in
运算符作用于列表——它检查给定的元素是否出现在列表中的任何位置。
'Edam' in cheeses
True
'Wensleydale' in cheeses
False
虽然一个列表可以包含另一个列表,但嵌套的列表仍然被视为一个元素——因此,在以下列表中,只有四个元素。
t = ['spam', 2.0, 5, [10, 20]]
len(t)
4
而10
不被视为t
的一个元素,因为它是嵌套列表中的一个元素,而不是t
的元素。
10 in t
False
9.3. 列表切片
切片运算符作用于列表的方式与它在字符串上作用的方式相同。以下示例选择了四个字母列表中的第二和第三个元素。
letters = ['a', 'b', 'c', 'd']
letters[1:3]
['b', 'c']
如果省略第一个索引,切片将从列表的开头开始。
letters[:2]
['a', 'b']
如果省略第二个索引,切片将延伸到列表的末尾。
letters[2:]
['c', 'd']
如果省略两个索引,切片将是整个列表的副本。
letters[:]
['a', 'b', 'c', 'd']
复制列表的另一种方式是使用 list
函数。
list(letters)
['a', 'b', 'c', 'd']
由于 list
是一个内置函数的名称,你应该避免将它用作变量名。
9.4. 列表操作
+
运算符用于连接列表。
t1 = [1, 2]
t2 = [3, 4]
t1 + t2
[1, 2, 3, 4]
*
运算符会将列表重复给定的次数。
['spam'] * 4
['spam', 'spam', 'spam', 'spam']
没有其他数学运算符可以与列表一起使用,但内置函数 sum
会将元素相加。
sum(t1)
3
min
和 max
用于找到最小和最大元素。
min(t1)
1
max(t2)
4
9.5. 列表方法
Python 提供了对列表操作的方法。例如,append
会将一个新元素添加到列表的末尾:
letters.append('e')
letters
['a', 'b', 'c', 'd', 'e']
extend
接受一个列表作为参数,并将其中的所有元素附加到当前列表中:
letters.extend(['f', 'g'])
letters
['a', 'b', 'c', 'd', 'e', 'f', 'g']
有两种方法可以从列表中删除元素。如果你知道要删除元素的索引,可以使用 pop
。
t = ['a', 'b', 'c']
t.pop(1)
'b'
返回值是被删除的元素。我们也可以确认列表已经被修改。
t
['a', 'c']
如果你知道要删除的元素(但不知道索引),可以使用 remove
:
t = ['a', 'b', 'c']
t.remove('b')
remove
的返回值是 None
,但我们可以确认列表已经被修改。
t
['a', 'c']
如果你请求的元素不在列表中,那就会抛出 ValueError 错误。
t.remove('d')
ValueError: list.remove(x): x not in list
9.6. 列表和字符串
字符串是字符的序列,而列表是值的序列,但字符列表和字符串并不相同。要将字符串转换为字符列表,可以使用 list
函数。
s = 'spam'
t = list(s)
t
['s', 'p', 'a', 'm']
list
函数将字符串拆分为单独的字母。如果你想将字符串拆分为单词,可以使用 split
方法:
s = 'pining for the fjords'
t = s.split()
t
['pining', 'for', 'the', 'fjords']
一个可选参数叫做分隔符,用于指定哪些字符作为单词边界。以下示例使用了连字符作为分隔符。
s = 'ex-parrot'
t = s.split('-')
t
['ex', 'parrot']
如果你有一个字符串列表,可以使用 join
将它们连接成一个单一的字符串。join
是一个字符串方法,因此你需要在分隔符上调用它,并将列表作为参数传递。
delimiter = ' '
t = ['pining', 'for', 'the', 'fjords']
s = delimiter.join(t)
s
'pining for the fjords'
在这个例子中,分隔符是空格字符,所以 join
会在单词之间加上空格。要将字符串连接在一起而不添加空格,可以使用空字符串 ''
作为分隔符。
9.7. 遍历列表
你可以使用 for
语句遍历列表中的元素。
for cheese in cheeses:
print(cheese)
Cheddar
Edam
Gouda
例如,使用 split
将字符串分割成单词列表后,我们可以使用 for
遍历它们。
s = 'pining for the fjords'
for word in s.split():
print(word)
pining
for
the
fjords
对一个空列表进行 for
循环时,缩进的语句永远不会执行。
for x in []:
print('This never happens.')
9.8. 排序列表
Python 提供了一个内置函数 sorted
,用于对列表的元素进行排序。
scramble = ['c', 'a', 'b']
sorted(scramble)
['a', 'b', 'c']
原始列表保持不变。
scramble
['c', 'a', 'b']
sorted
可以与任何类型的序列一起使用,不仅限于列表。所以我们可以像这样对字符串中的字母进行排序。
sorted('letters')
['e', 'e', 'l', 'r', 's', 't', 't']
结果是一个列表。要将列表转换为字符串,我们可以使用 join
。
''.join(sorted('letters'))
'eelrstt'
使用空字符串作为分隔符时,列表中的元素将被连接在一起,中间没有任何分隔符。
9.9. 对象和值
如果我们运行这些赋值语句:
a = 'banana'
b = 'banana'
我们知道 a
和 b
都指向一个字符串,但我们不知道它们是否指向同一个字符串。有两种可能的状态,如下图所示。
[外链图片转存中…(img-8Z9yVi3i-1748168092718)]
在左侧的图表中,a
和 b
引用两个具有相同值的不同对象。在右侧的图表中,它们引用同一个对象。要检查两个变量是否引用同一个对象,可以使用 is
运算符。
a = 'banana'
b = 'banana'
a is b
True
在这个例子中,Python 只创建了一个字符串对象,a
和 b
都引用它。但是当你创建两个列表时,你得到两个对象。
a = [1, 2, 3]
b = [1, 2, 3]
a is b
False
所以状态图看起来是这样的。
[外链图片转存中…(img-jap7Os7V-1748168092718)]
在这种情况下,我们会说这两个列表是等价的,因为它们有相同的元素,但不是相同的,因为它们不是同一个对象。如果两个对象是相同的,则它们也是等价的,但如果它们是等价的,则它们不一定是相同的。
9.10. 别名
如果 a
引用一个对象,然后你赋值 b = a
,那么两个变量都引用同一个对象。
a = [1, 2, 3]
b = a
b is a
True
所以状态图看起来是这样的。
[外链图片转存中…(img-mQqt7QXE-1748168092718)]
将变量与对象的关联称为引用。在这个例子中,有两个对同一对象的引用。
拥有多个引用的对象有多个名称,因此我们说对象是别名的。如果别名对象是可变的,则使用一个名称进行更改会影响到另一个名称。在这个例子中,如果我们更改 b
所引用的对象,也会更改 a
所引用的对象。
b[0] = 5
a
[5, 2, 3]
因此我们会说 a
“看到”了这个变化。虽然这种行为可能很有用,但也容易出错。一般来说,在处理可变对象时最好避免使用别名。
对于像字符串这样的不可变对象,别名不是太大的问题。在这个例子中:
a = 'banana'
b = 'banana'
是否 a
和 b
引用同一个字符串几乎没有影响。
9.11. 列表参数
当你将列表传递给函数时,函数会得到对列表的引用。如果函数修改了列表,则调用者会看到更改。例如,pop_first
使用列表方法 pop
来删除列表中的第一个元素。
def pop_first(lst):
return lst.pop(0)
我们可以这样使用它。
letters = ['a', 'b', 'c']
pop_first(letters)
'a'
返回值是已从列表中删除的第一个元素,我们可以通过显示修改后的列表来看到。
letters
['b', 'c']
在这个例子中,参数 lst
和变量 letters
是同一个对象的别名,所以状态图看起来是这样的:
[2.04, 1.24, 1.06, 0.85]
[外链图片转存中…(img-piI7Dkk2-1748168092718)]
将对象的引用作为参数传递给函数会创建一种别名形式。如果函数修改了该对象,这些更改将在函数结束后持续存在。
9.12. 创建单词列表
在上一章中,我们读取了 words.txt
文件并搜索了具有特定属性的单词,比如使用字母 e
。但是我们多次读取整个文件,这样效率不高。更好的做法是只读取一次文件,并将单词存入列表。以下循环展示了如何操作。
word_list = []
for line in open('words.txt'):
word = line.strip()
word_list.append(word)
len(word_list)
113783
在循环之前,word_list
被初始化为空列表。每次循环时,append
方法会将一个单词添加到列表末尾。当循环结束时,列表中有超过 113,000 个单词。
另一种做法是使用 read
将整个文件读取为一个字符串。
string = open('words.txt').read()
len(string)
1016511
结果是一个包含超过百万个字符的单一字符串。我们可以使用 split
方法将其拆分为一个单词列表。
word_list = string.split()
len(word_list)
113783
现在,为了检查一个字符串是否出现在列表中,我们可以使用 in
运算符。例如,'demotic'
在列表中。
'demotic' in word_list
True
但是,'contrafibularities'
不是。
'contrafibularities' in word_list
False
我得说,我对它感到有点麻木。
9.13. 调试
请注意,大多数列表方法修改参数并返回 None
。这与字符串方法相反,后者返回一个新字符串,并且不修改原始字符串。
如果你习惯于编写像这样的字符串代码:
word = 'plumage!'
word = word.strip('!')
word
'plumage'
很容易写出像这样的列表代码:
t = [1, 2, 3]
t = t.remove(3) # WRONG!
remove
修改列表并返回 None
,因此你接下来在 t
上执行的操作可能会失败。
t.remove(2)
AttributeError: 'NoneType' object has no attribute 'remove'
这个错误信息需要一些解释。一个属性是与对象关联的变量或方法。在这个案例中,t
的值是 None
,它是一个 NoneType
对象,并没有一个名为 remove
的属性,因此结果是 AttributeError
。
如果你看到这样的错误信息,应该向后检查程序,看看你是否错误地调用了列表方法。
9.14. 术语表
列表: 一种包含一系列值的对象。
元素: 列表或其他序列中的一个值。
嵌套列表: 作为另一个列表的元素的列表。
分隔符: 用于指示字符串应该在哪儿拆分的字符或字符串。
等价的: 具有相同的值。
相同的: 是指相同的对象(这意味着等价性)。
引用: 变量与其值之间的关联。
别名化: 如果有多个变量引用同一个对象,那么这个对象就是别名化的。
属性: 与对象关联的命名值之一。
9.15. 练习
# This cell tells Jupyter to provide detailed debugging information
# when a runtime error occurs. Run it before working on the exercises.
%xmode Verbose
9.15.1. 向虚拟助手提问
在本章中,我使用了“contrafibularities”和“anaspeptic”这两个词,但它们实际上并不是英语单词。它们出现在英国电视节目黑爵士第 3 季第 2 集“墨水与无能”中。
然而,当我询问 ChatGPT 3.5(2023 年 8 月 3 日版本)这些单词的来源时,它最初声称这些单词来自《蒙提·派森》,后来又声称它们来自汤姆·斯托帕德的剧作*《罗斯恩·克兰茨与吉尔登斯特恩死了》*。
如果你现在提问,你可能会得到不同的结果。但这个例子提醒我们,虚拟助手并不总是准确的,因此你应该检查结果是否正确。随着经验的积累,你会对哪些问题虚拟助手能够可靠回答有一个直觉。在这个例子中,常规的网络搜索可以迅速识别这些单词的来源。
如果在本章的任何练习中遇到困难,可以考虑向虚拟助手寻求帮助。如果你得到的结果使用了我们还没有学过的功能,你可以为虚拟助手分配一个“角色”。
例如,在你提问之前,尝试输入“Role: Basic Python Programming Instructor”。之后,你得到的回答应该仅使用基本功能。如果你仍然看到我们还没有学过的功能,你可以跟进询问:“能否只用基本的 Python 功能编写那个?”
9.15.2. 练习
如果两个单词的字母可以重新排列使其拼写为另一个单词,则这两个单词是字谜。例如,tops
是stop
的字谜。
检查两个单词是否是字谜的一种方法是将两个单词中的字母排序。如果排序后的字母列表相同,则这两个单词是字谜。
编写一个名为is_anagram
的函数,该函数接受两个字符串,并返回True
(如果它们是字谜)或False
(如果它们不是字谜)。
使用你的函数和单词列表,找到takes
的所有字谜。
9.15.3. 练习
Python 提供了一个名为reversed
的内置函数,它接受一个序列(如列表或字符串)作为参数,并返回一个reversed
对象,其中包含按相反顺序排列的元素。
reversed('parrot')
<reversed at 0x7fe3de636b60>
如果你希望反转的元素以列表形式返回,可以使用list
函数。
list(reversed('parrot'))
['t', 'o', 'r', 'r', 'a', 'p']
或者,如果你希望它们以字符串形式呈现,可以使用join
方法。
''.join(reversed('parrot'))
'torrap'
所以我们可以这样编写一个函数来反转一个单词。
def reverse_word(word):
return ''.join(reversed(word))
回文是指正着读和反着读都一样的单词,如“noon”和“rotator”。编写一个名为is_palindrome
的函数,该函数接受一个字符串作为参数,如果它是回文,返回True
,否则返回False
。
你可以使用以下循环查找单词列表中至少包含 7 个字母的所有回文。
for word in word_list:
if len(word) >= 7 and is_palindrome(word):
print(word)
9.15.4. 练习
编写一个名为reverse_sentence
的函数,该函数接受一个字符串作为参数,该字符串包含由空格分隔的若干单词。它应该返回一个新的字符串,其中包含按相反顺序排列的单词。例如,如果参数是“Reverse this sentence”,结果应该是“Sentence this reverse”。
提示:你可以使用capitalize
方法将第一个单词首字母大写,并将其他单词转换为小写。
9.15.5. 练习
编写一个名为total_length
的函数,接受一个字符串列表,并返回这些字符串的总长度。word_list
中单词的总长度应该是(902{,}728)。
版权所有 2024 Allen B. Downey
代码许可证:MIT 许可证