来源:
allendowney.github.io/ThinkPython/
译者:飞龙
13. 文件与数据库
我们迄今为止看到的大多数程序都是临时的,因为它们运行时间很短,生成输出,但当它们结束时,它们的数据会消失。每次运行临时程序时,它都会从一个干净的状态开始。
其他程序是持久的:它们运行时间很长(或者一直运行);它们将至少一部分数据保存在长期存储中;如果它们关闭并重新启动,它们会从上次停止的地方继续。
程序保持数据的一种简单方式是通过读取和写入文本文件。一个更通用的替代方案是将数据存储在数据库中。数据库是专门的文件,比文本文件更高效地读取和写入,并且提供了额外的功能。
在本章中,我们将编写读取和写入文本文件及数据库的程序,并且作为一个练习,你将编写一个程序,搜索照片集中的重复文件。但在你可以操作文件之前,首先要找到它,因此我们将从文件名、路径和目录开始。
13.1. 文件名和路径
文件被组织成目录,也叫做“文件夹”。每个正在运行的程序都有一个当前工作目录,这是大多数操作的默认目录。例如,当你打开一个文件时,Python 会在当前工作目录中查找它。
os
模块提供了用于操作文件和目录的函数(os
代表“操作系统”)。它提供了一个名为getcwd
的函数,用于获取当前工作目录的名称。
import os
os.getcwd()
'/home/dinsdale'
本例中的结果是一个名为dinsdale
的用户的主目录。像'/home/dinsdale'
这样的字符串,它标识了一个文件或目录,称为路径。
像'memo.txt'
这样的简单文件名也被视为路径,但它是一个相对路径,因为它指定了相对于当前目录的文件名。在本例中,当前目录是/home/dinsdale
,所以'memo.txt'
等同于完整路径'/home/dinsdale/memo.txt'
。
以/
开头的路径不依赖于当前目录——它被称为绝对路径。要找到文件的绝对路径,可以使用abspath
。
os.path.abspath('memo.txt')
'/home/dinsdale/memo.txt'
os
模块还提供了其他用于操作文件名和路径的函数。listdir
返回给定目录的内容列表,包括文件和其他目录。下面是列出名为photos
目录内容的示例。
os.listdir('photos')
['digests.dat',
'digests.dir',
'notes.txt',
'new_notes.txt',
'mar-2023',
'digests.bak',
'jan-2023',
'feb-2023']
这个目录包含一个名为notes.txt
的文本文件和三个目录。目录中包含 JPEG 格式的图像文件。
os.listdir('photos/jan-2023')
['photo3.jpg', 'photo2.jpg', 'photo1.jpg']
要检查文件或目录是否存在,可以使用os.path.exists
。
os.path.exists('photos')
True
os.path.exists('photos/apr-2023')
False
要检查路径是否指向文件或目录,我们可以使用isdir
,它返回True
如果路径指向一个目录。
os.path.isdir('photos')
True
还有isfile
,如果路径指向一个文件,它返回True
。
os.path.isfile('photos/notes.txt')
True
处理路径的一个挑战是,不同操作系统上的路径表示不同。在 macOS 和类似 Linux 的 UNIX 系统中,路径中的目录和文件名是由正斜杠/
分隔的。Windows 使用反斜杠\
。因此,如果你在 Windows 上运行这些示例,你会看到路径中的反斜杠,并且你需要将示例中的正斜杠替换为反斜杠。
或者,为了编写在两个系统上都能运行的代码,可以使用os.path.join
,它将目录和文件名连接成一个路径,使用正斜杠或反斜杠,具体取决于你使用的操作系统。
os.path.join('photos', 'jan-2023', 'photo1.jpg')
'photos/jan-2023/photo1.jpg'
在本章稍后,我们将使用这些函数来搜索一组目录并找到所有图像文件。
13.2. f-strings
程序存储数据的一种方式是将其写入文本文件。例如,假设你是一个骆驼观察员,想要记录在一段观察期内看到的骆驼数量。假设在一年半的时间里,你已经观察到23
只骆驼。你在骆驼观察本中的数据可能看起来是这样的。
num_years = 1.5
num_camels = 23
要将这些数据写入文件,可以使用write
方法,我们在第八章中见过。write
的参数必须是一个字符串,因此如果我们想将其他值放入文件中,就必须将它们转换为字符串。最简单的方式是使用内置函数str
。
这看起来是这样的:
writer = open('camel-spotting-book.txt', 'w')
writer.write(str(num_years))
writer.write(str(num_camels))
writer.close()
这有效,但write
不会添加空格或换行,除非你明确地包含它。如果我们重新读取文件,会发现两个数字被连在一起。
open('camel-spotting-book.txt').read()
'1.523'
至少,我们应该在数字之间添加空格。顺便提一下,让我们添加一些说明文字。
要编写一个字符串和其他值的组合,可以使用f-string,它是一个在开头有字母f
的字符串,并且包含一个或多个用大括号括起来的 Python 表达式。以下的 f-string 包含一个表达式,即一个变量名。
f'I have spotted {num_camels} camels'
'I have spotted 23 camels'
结果是一个字符串,其中的表达式已被求值并替换为结果。可以有多个表达式。
f'In {num_years} years I have spotted {num_camels} camels'
'In 1.5 years I have spotted 23 camels'
而且这些表达式可以包含运算符和函数调用。
line = f'In {round(num_years * 12)} months I have spotted {num_camels} camels'
line
'In 18 months I have spotted 23 camels'
所以我们可以像这样将数据写入文本文件。
writer = open('camel-spotting-book.txt', 'w')
writer.write(f'Years of observation: {num_years}\n')
writer.write(f'Camels spotted: {num_camels}\n')
writer.close()
两个 f-string 都以序列\n
结尾,这会添加一个换行符。
我们可以像这样读取文件:
data = open('camel-spotting-book.txt').read()
print(data)
Years of observation: 1.5
Camels spotted: 23
在 f-string 中,大括号中的表达式会被转换为字符串,因此你可以包含列表、字典和其他类型。
t = [1, 2, 3]
d = {'one': 1}
f'Here is a list {t} and a dictionary {d}'
"Here is a list [1, 2, 3] and a dictionary {'one': 1}"
13.3. YAML
程序读取和写入文件的原因之一是存储配置信息,这是一种指定程序应该做什么以及如何做的数据信息。
例如,在一个搜索重复照片的程序中,我们可能有一个名为config
的字典,它包含了要搜索的目录名称、另一个目录的名称(用于存储结果),以及识别图片文件所用的文件扩展名列表。
这可能看起来像这样:
config = {
'photo_dir': 'photos',
'data_dir': 'photo_info',
'extensions': ['jpg', 'jpeg'],
}
为了将这些数据写入文本文件,我们可以像上一节那样使用 f-string。但使用一个名为yaml
的模块会更方便,它专为处理这类事情而设计。
yaml
模块提供了用于处理 YAML 文件的函数,YAML 文件是格式化为便于人类和程序阅读和写入的文本文件。
这里有一个示例,使用dump
函数将config
字典写入 YAML 文件。
import yaml
config_filename = 'config.yaml'
writer = open(config_filename, 'w')
yaml.dump(config, writer)
writer.close()
如果我们读取文件的内容,我们可以看到 YAML 格式的样子。
readback = open(config_filename).read()
print(readback)
data_dir: photo_info
extensions:
- jpg
- jpeg
photo_dir: photos
现在,我们可以使用safe_load
来读取回 YAML 文件。
reader = open(config_filename)
config_readback = yaml.safe_load(reader)
config_readback
{'data_dir': 'photo_info',
'extensions': ['jpg', 'jpeg'],
'photo_dir': 'photos'}
结果是一个包含与原始字典相同信息的新字典,但它不是同一个字典。
config is config_readback
False
将字典之类的对象转换为字符串称为序列化。将字符串转换回对象称为反序列化。如果你先序列化再反序列化一个对象,结果应该与原始对象等效。
13.4. Shelve
到目前为止,我们一直在读取和写入文本文件——现在让我们来考虑数据库。数据库是一个用于存储数据的组织化文件。有些数据库像表格一样,包含行和列的信息。其他的则像字典一样,通过键映射到值,它们有时被称为键值存储。
shelve
模块提供了创建和更新称为“shelf”的键值存储的功能。作为示例,我们将创建一个 shelf 来存储photos
目录中图片的标题。我们将使用config
字典来获取应该放置 shelf 的目录名称。
config['data_dir']
'photo_info'
如果目录不存在,我们可以使用os.makedirs
来创建这个目录。
os.makedirs(config['data_dir'], exist_ok=True)
以及使用os.path.join
来创建一个包含目录名称和 shelf 文件名称captions
的路径。
db_file = os.path.join(config['data_dir'], 'captions')
db_file
'photo_info/captions'
现在我们可以使用shelve.open
打开 shelf 文件。参数c
表示如果文件不存在,则创建该文件。
import shelve
db = shelve.open(db_file, 'c')
db
<shelve.DbfilenameShelf at 0x7fcc902cc430>
返回值官方称为DbfilenameShelf
对象,更通俗地称为 shelf 对象。
shelf 对象在许多方面像字典。例如,我们可以使用括号操作符添加一个条目,它是一个从键到值的映射。
key = 'jan-2023/photo1.jpg'
db[key] = 'Cat nose'
在这个示例中,键是图像文件的路径,值是描述图像的字符串。
我们还使用括号操作符来查找一个键并获取对应的值。
value = db[key]
value
'Cat nose'
如果你对现有的键进行重新赋值,shelve
会替换旧值。
db[key] = 'Close up view of a cat nose'
db[key]
'Close up view of a cat nose'
一些字典方法,如keys
、values
和items
,也适用于 shelf 对象。
list(db.keys())
['jan-2023/photo1.jpg']
list(db.values())
['Close up view of a cat nose']
我们可以使用in
操作符检查一个键是否出现在 shelf 中。
key in db
True
我们还可以使用for
语句来遍历键。
for key in db:
print(key, ':', db[key])
jan-2023/photo1.jpg : Close up view of a cat nose
和其他文件一样,使用完数据库后,应该关闭它。
db.close()
现在,如果我们列出数据目录的内容,我们会看到两个文件。
os.listdir(config['data_dir'])
['captions.dir', 'captions.dat']
captions.dat
包含我们刚刚存储的数据。captions.dir
包含有关数据库组织的信息,这使得访问更高效。后缀dir
代表“目录”,但它与我们之前处理的包含文件的目录无关。
13.5. 存储数据结构
在之前的例子中,架子中的键和值是字符串。但我们也可以使用架子来存储像列表和字典这样的数据结构。
作为例子,让我们重新回顾一下第十一章练习中的字谜例子。回想一下,我们创建了一个字典,它将字母的排序字符串映射到可以用这些字母拼写出来的单词列表。例如,键'opst'
映射到列表['opts', 'post', 'pots', 'spot', 'stop', 'tops']
。
我们将使用以下函数来排序一个单词中的字母。
def sort_word(word):
return ''.join(sorted(word))
这里有一个例子。
word = 'pots'
key = sort_word(word)
key
'opst'
现在让我们打开一个名为anagram_map
的架子。参数'n'
意味着我们应该始终创建一个新的空架子,即使已经存在一个。
db = shelve.open('anagram_map', 'n')
现在我们可以像这样向架子中添加一个项目。
db[key] = [word]
db[key]
['pots']
在这个条目中,键是一个字符串,值是一个字符串列表。
现在假设我们找到另一个包含相同字母的单词,比如tops
。
word = 'tops'
key = sort_word(word)
key
'opst'
这个键与之前的例子相同,所以我们想将第二个单词附加到同一个字符串列表中。如果db
是一个字典,下面就是我们如何做的。
db[key].append(word) # INCORRECT
但是,如果我们运行它并查看架子中的键,它看起来没有被更新。
db[key]
['pots']
这里是问题:当我们查找键时,我们得到的是一个字符串列表,但如果我们修改这个字符串列表,它并不会影响架子。如果我们想要更新架子,必须先读取旧值,更新它,然后将新值写回架子。
anagram_list = db[key]
anagram_list.append(word)
db[key] = anagram_list
现在架子中的值已更新。
db[key]
['pots', 'tops']
作为练习,你可以通过读取单词列表并将所有的字谜存储到一个架子中来完成这个例子。## 13.6. 检查等效文件
现在让我们回到本章的目标:搜索包含相同数据的不同文件。检查的一种方法是读取两个文件的内容并进行比较。
如果文件包含图像,我们必须以'rb'
模式打开它们,其中'r'
表示我们想要读取内容,而'b'
表示二进制模式。在二进制模式下,内容不会被解释为文本,而是作为字节序列处理。
这是一个打开并读取图像文件的例子。
path1 = 'photos/jan-2023/photo1.jpg'
data1 = open(path1, 'rb').read()
type(data1)
bytes
read
的结果是一个bytes
对象——顾名思义,它包含一个字节序列。
一般来说,图像文件的内容是不可读的。但如果我们从第二个文件中读取内容,我们可以使用==
运算符进行比较。
path2 = 'photos/jan-2023/photo2.jpg'
data2 = open(path2, 'rb').read()
data1 == data2
False
这两个文件并不相等。
让我们将目前为止的内容封装成一个函数。
def same_contents(path1, path2):
data1 = open(path1, 'rb').read()
data2 = open(path2, 'rb').read()
return data1 == data2
如果我们只有两个文件,这个函数是一个不错的选择。但假设我们有大量的文件,并且想知道是否有任何两个文件包含相同的数据。逐一比较每对文件将是低效的。
另一种选择是使用哈希函数,它接受文件内容并计算一个摘要,通常是一个大整数。如果两个文件包含相同的数据,它们将有相同的摘要。如果两个文件不同,它们几乎总是会有不同的摘要。
hashlib
模块提供了几种哈希函数——我们将使用的叫做md5
。我们将通过使用hashlib.md5
来创建一个HASH
对象。
import hashlib
md5_hash = hashlib.md5()
type(md5_hash)
_hashlib.HASH
HASH
对象提供了一个update
方法,该方法以文件内容作为参数。
md5_hash.update(data1)
现在我们可以使用hexdigest
来获取摘要,作为一个十六进制数字的字符串,表示一个基数为 16 的整数。
digest = md5_hash.hexdigest()
digest
'aa1d2fc25b7ae247b2931f5a0882fa37'
以下函数封装了这些步骤。
def md5_digest(filename):
data = open(filename, 'rb').read()
md5_hash = hashlib.md5()
md5_hash.update(data)
digest = md5_hash.hexdigest()
return digest
如果我们对不同文件的内容进行哈希处理,我们可以确认我们得到的是不同的摘要。
filename2 = 'photos/feb-2023/photo2.jpg'
md5_digest(filename2)
'6a501b11b01f89af9c3f6591d7f02c49'
现在我们几乎拥有了找到等效文件所需的所有内容。最后一步是搜索一个目录并找到所有的图片文件。 ## 13.7. 遍历目录
以下函数以我们想要搜索的目录作为参数。它使用listdir
循环遍历目录的内容。当它找到一个文件时,它打印出完整路径。当它找到一个目录时,它递归调用自己以搜索子目录。
def walk(dirname):
for name in os.listdir(dirname):
path = os.path.join(dirname, name)
if os.path.isfile(path):
print(path)
elif os.path.isdir(path):
walk(path)
我们可以像这样使用它:
walk('photos')
photos/digests.dat
photos/digests.dir
photos/notes.txt
photos/new_notes.txt
photos/mar-2023/photo2.jpg
photos/mar-2023/photo1.jpg
photos/digests.bak
photos/jan-2023/photo3.jpg
photos/jan-2023/photo2.jpg
photos/jan-2023/photo1.jpg
photos/feb-2023/photo2.jpg
photos/feb-2023/photo1.jpg
结果的顺序取决于操作系统的具体细节。
13.8. 调试
当你在读取和写入文件时,可能会遇到空白字符的问题。这些错误可能很难调试,因为空白字符通常是不可见的。例如,这里有一个包含空格、由序列\t
表示的制表符和由序列\n
表示的新行的字符串。当我们打印它时,看不见空白字符。
s = '1 2\t 3\n 4'
print(s)
1 2 3
4
内置函数repr
可以提供帮助。它接受任何对象作为参数,并返回该对象的字符串表示。对于字符串,它用反斜杠序列表示空白字符。
print(repr(s))
'1 2\t 3\n 4'
这对调试很有帮助。
另一个你可能遇到的问题是,不同的系统使用不同的字符来表示行结束。有些系统使用换行符,表示为\n
。其他系统使用回车符,表示为\r
。有些系统同时使用这两者。如果你在不同系统之间移动文件,这些不一致可能会导致问题。
文件名大小写是你在处理不同操作系统时可能遇到的另一个问题。在 macOS 和 UNIX 中,文件名可以包含小写字母、大写字母、数字和大多数符号。但是许多 Windows 应用程序忽略大小写字母之间的区别,而且在 macOS 和 UNIX 中允许的几个符号在 Windows 中不允许。
13.9. 术语表
短暂的: 短暂程序通常运行一段时间,结束时,其数据会丢失。
持久的: 持久程序可以无限期运行,并将至少一部分数据保存在永久存储中。
目录: 一组文件和其他目录的集合。
当前工作目录: 程序使用的默认目录,除非指定了其他目录。
路径: 指定一系列目录的字符串,通常指向一个文件。
相对路径: 从当前工作目录或某个其他指定目录开始的路径。
绝对路径: 不依赖于当前目录的路径。
f-string: 在开头有字母f
的字符串,其中包含一个或多个用大括号括起来的表达式。
配置数据: 通常存储在文件中,指定程序应该做什么以及如何做的数据。
序列化: 将对象转换为字符串。
反序列化: 将字符串转换为对象。
数据库: 一个文件,其内容被组织成能够高效执行特定操作的形式。
键值存储: 一种数据库,其内容像字典一样组织,键对应着值。
二进制模式: 打开文件的一种方式,使得文件内容被解释为字节序列而不是字符序列。
哈希函数: 一个接受对象并计算出整数的函数,这个整数有时被称为摘要。
摘要: 哈希函数的结果,尤其是在用来检查两个对象是否相同时。
13.10. 练习
# 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
13.10.1. 向虚拟助手提问
本章中出现了几个我没有详细解释的主题。以下是一些你可以向虚拟助手提问的问题,获取更多信息。
-
“短暂程序和持久程序有什么区别?”
-
“什么是持久程序的例子?”
-
“相对路径和绝对路径有什么区别?”
-
“为什么
yaml
模块有名为load
和safe_load
的函数?” -
“当我写一个 Python shelf 时,
dat
和dir
后缀的文件是什么?” -
“除了键值存储,还有哪些类型的数据库?”
-
“当我读取一个文件时,二进制模式和文本模式有什么区别?”
-
“字节对象和字符串有什么区别?”
-
“什么是哈希函数?”
-
“什么是 MD5 摘要?”
和往常一样,如果你在以下练习中遇到困难,可以考虑向虚拟助手求助。除了提问之外,你可能还想粘贴本章中的相关函数。
13.10.2. 练习
编写一个名为replace_all
的函数,该函数接受一个模式字符串、一个替换字符串和两个文件名作为参数。它应该读取第一个文件,并将内容写入第二个文件(如果需要,创建它)。如果模式字符串出现在内容中的任何位置,它应被替换为替换字符串。
这是一个函数的概要,帮助你入门。
def replace_all(old, new, source_path, dest_path):
# read the contents of the source file
reader = open(source_path)
# replace the old string with the new
# write the result into the destination file
为了测试你的函数,读取文件photos/notes.txt
,将'photos'
替换为'images'
,并将结果写入文件photos/new_notes.txt
。
13.10.3. 练习
在前一节中,我们使用了shelve
模块创建了一个键值存储,将排序后的字母字符串映射到一个变位词的列表。为了完成示例,编写一个名为add_word
的函数,该函数接受一个字符串和一个架子对象作为参数。
它应该对单词的字母进行排序以生成一个键,然后检查该键是否已存在于架子中。如果不存在,它应该创建一个包含新单词的列表并将其添加到架子中。如果存在,它应该将新单词附加到现有值的列表中。
13.10.4. 练习
在一个大型文件集合中,可能存在多个相同文件的副本,存储在不同的目录或使用不同的文件名。这个练习的目标是搜索重复文件。作为示例,我们将处理photos
目录中的图像文件。
下面是它的工作原理:
-
我们将使用来自遍历目录的
walk
函数来搜索该目录中的文件,这些文件扩展名与config['extensions']
中的某个扩展名匹配。 -
对于每个文件,我们将使用来自检查等效文件的
md5_digest
来计算内容的摘要。 -
使用架子,我们将从每个摘要映射到包含该摘要的路径列表。
-
最后,我们将搜索架子,查找映射到多个文件的任何摘要。
-
如果找到任何匹配项,我们将使用
same_contents
来确认文件是否包含相同的数据。
我将首先建议编写一些函数,然后我们将把所有内容结合在一起。
-
为了识别图像文件,编写一个名为
is_image
的函数,该函数接受一个路径和一个文件扩展名列表,并在路径以列表中的某个扩展名结尾时返回True
。提示:使用os.path.splitext
,或者让虚拟助手为你编写这个函数。 -
编写一个名为
add_path
的函数,该函数接受一个路径和一个架子作为参数。它应该使用md5_digest
来计算文件内容的摘要。然后,它应该更新架子,要么创建一个新的项,将摘要映射到包含路径的列表,要么将路径附加到已存在的列表中。 -
编写一个名为
walk_images
的walk
函数变体,它接受一个目录并遍历该目录及其子目录中的文件。对于每个文件,它应使用is_image
来检查它是否是图像文件,并使用add_path
将其添加到架子中。
当一切正常时,你可以使用以下程序来创建书架,搜索photos
目录并将路径添加到书架中,然后检查是否有多个文件具有相同的摘要。
db = shelve.open('photos/digests', 'n')
walk_images('photos')
for digest, paths in db.items():
if len(paths) > 1:
print(paths)
你应该找到一对具有相同摘要的文件。使用same_contents
来检查它们是否包含相同的数据。
版权 2024 Allen B. Downey
代码许可:MIT 许可
文本许可:创意共享署名-非商业性使用-相同方式共享 4.0 国际版
14. 类和函数
到目前为止,你已经学会了如何使用函数组织代码,以及如何使用内置类型组织数据。下一步是面向对象编程,它使用程序员定义的类型来组织代码和数据。
面向对象编程是一个庞大的话题,因此我们将逐步进行。在本章中,我们将从不规范的代码开始——也就是说,它不是经验丰富的程序员所写的那种代码——但这是一个不错的起点。在接下来的两章中,我们将使用更多的特性来编写更规范的代码。
14.1. 程序员定义的类型
我们已经使用了许多 Python 的内置类型——现在我们将定义一个新类型。作为第一个例子,我们将创建一个名为Time
的类型,表示一天中的时间。程序员定义的类型也叫做类。一个类的定义如下:
class Time:
"""Represents a time of day."""
头部表示新类的名称是Time
。主体部分是一个文档字符串,用来说明这个类的用途。定义一个类会创建一个类对象。
类对象就像是一个创建对象的工厂。要创建一个Time
对象,你可以像调用函数一样调用Time
。
lunch = Time()
结果是一个新对象,它的类型是__main__.Time
,其中__main__
是定义Time
的模块的名称。
type(lunch)
__main__.Time
当你打印一个对象时,Python 会告诉你它的类型以及它在内存中的存储位置(前缀0x
表示后面的数字是十六进制的)。
print(lunch)
<__main__.Time object at 0x7f31440ad0c0>
创建一个新对象称为实例化,该对象是类的实例。
14.2. 属性
一个对象可以包含变量,这些变量被称为属性,重音在第一个音节上,发音为“AT-trib-ute”,而不是重音在第二个音节上,发音为“a-TRIB-ute”。我们可以使用点符号来创建属性。
lunch.hour = 11
lunch.minute = 59
lunch.second = 1
这个例子创建了名为hour
、minute
和second
的属性,它们分别表示时间11:59:01
的小时、分钟和秒,按我个人的理解,这是午餐时间。
以下图表显示了在这些赋值之后,lunch
及其属性的状态。
[外链图片转存中…(img-l3jl5IU2-1748168145698)]
变量lunch
引用一个Time
对象,该对象包含三个属性。每个属性都引用一个整数。像这样的状态图——展示了对象及其属性——被称为对象图。
你可以使用点操作符来读取属性的值。
lunch.hour
11
你可以将一个属性作为任何表达式的一部分。
total_minutes = lunch.hour * 60 + lunch.minute
total_minutes
719
你还可以在 f-string 表达式中使用点操作符。
f'{lunch.hour}:{lunch.minute}:{lunch.second}'
'11:59:1'
但请注意,之前的例子并不符合标准格式。为了解决这个问题,我们需要在打印 minute
和 second
属性时加上前导零。我们可以通过在大括号中的表达式后面添加 格式说明符 来实现。以下示例中的格式说明符表示 minute
和 second
应该至少显示两位数字,并在需要时加上前导零。
f'{lunch.hour}:{lunch.minute:02d}:{lunch.second:02d}'
'11:59:01'
我们将使用这个 f-string 来编写一个函数,显示 Time
对象的值。你可以像往常一样将一个对象作为参数传递。例如,下面的函数将 Time
对象作为参数。
def print_time(time):
s = f'{time.hour:02d}:{time.minute:02d}:{time.second:02d}'
print(s)
当我们调用它时,我们可以将 lunch
作为参数传递。
print_time(lunch)
11:59:01
14.3. 对象作为返回值
函数可以返回对象。例如,make_time
接受名为 hour
、minute
和 second
的参数,将它们作为属性存储在 Time
对象中,并返回新对象。
def make_time(hour, minute, second):
time = Time()
time.hour = hour
time.minute = minute
time.second = second
return time
可能会让人惊讶的是,参数和属性的名称相同,但这是编写此类函数的常见方式。下面是我们如何使用 make_time
来创建一个 Time
对象。
time = make_time(11, 59, 1)
print_time(time)
11:59:01
14.4. 对象是可变的
假设你去看一场电影,比如 Monty Python and the Holy Grail,它从 9:20 PM
开始,持续 92
分钟,也就是 1
小时 32
分钟。电影什么时候结束?
首先,我们将创建一个表示开始时间的 Time
对象。
start = make_time(9, 20, 0)
print_time(start)
09:20:00
为了找到结束时间,我们可以修改 Time
对象的属性,加入电影的时长。
start.hour += 1
start.minute += 32
print_time(start)
10:52:00
电影将在 10:52 PM
结束。
让我们将这个计算封装成一个函数,并将其通用化,以接受电影时长的三个参数:hours
、minutes
和 seconds
。
def increment_time(time, hours, minutes, seconds):
time.hour += hours
time.minute += minutes
time.second += seconds
这是一个演示效果的示例。
start = make_time(9, 20, 0)
increment_time(start, 1, 32, 0)
print_time(start)
10:52:00
以下堆栈图显示了在 increment_time
修改对象之前,程序的状态。
[外链图片转存中…(img-XtOtb0ZQ-1748168145700)]
在函数内部,time
是 start
的别名,因此当 time
被修改时,start
也会改变。
这个函数是有效的,但运行后,我们会留下一个名为 start
的变量,它指向表示 结束 时间的对象,而我们不再拥有表示开始时间的对象。最好不要改变 start
,而是创建一个新的对象来表示结束时间。我们可以通过复制 start
并修改副本来实现。
14.5. 复制
copy
模块提供了一个名为 copy
的函数,可以复制任何对象。我们可以像这样导入它。
from copy import copy
为了查看它是如何工作的,我们从一个新的 Time
对象开始,表示电影的开始时间。
start = make_time(9, 20, 0)
并且制作一个副本。
end = copy(start)
现在 start
和 end
包含相同的数据。
print_time(start)
print_time(end)
09:20:00
09:20:00
但 is
运算符确认它们不是同一个对象。
start is end
False
让我们看看 ==
运算符的作用。
start == end
False
你可能会期望==
返回True
,因为这些对象包含相同的数据。但对于程序员自定义的类,==
运算符的默认行为与is
运算符相同——它检查的是身份,而不是等价性。
14.6. 纯函数
我们可以使用copy
来编写不修改其参数的纯函数。例如,下面是一个函数,它接受一个Time
对象和一个持续时间(小时、分钟和秒)。它复制原始对象,使用increment_time
来修改副本,并返回它。
def add_time(time, hours, minutes, seconds):
total = copy(time)
increment_time(total, hours, minutes, seconds)
return total
下面是我们如何使用它。
end = add_time(start, 1, 32, 0)
print_time(end)
10:52:00
返回值是一个表示电影结束时间的新对象。我们可以确认start
没有改变。
print_time(start)
09:20:00
add_time
是一个纯函数,因为它不会修改任何传入的对象,其唯一的作用是返回一个值。
任何可以通过不纯函数完成的事情,也可以通过纯函数完成。事实上,一些编程语言只允许使用纯函数。使用纯函数的程序可能更不容易出错,但不纯函数有时也很方便,并且可能更高效。
一般来说,我建议你在合理的情况下编写纯函数,并且只有在有充分的优势时才使用不纯函数。这种方法可能被称为函数式编程风格。
14.7. 原型和修补
在前面的示例中,increment_time
和add_time
似乎可以工作,但如果我们尝试另一个例子,就会发现它们并不完全正确。
假设你到达电影院,发现电影的开始时间是9:40
,而不是9:20
。当我们计算更新后的结束时间时,情况如下。
start = make_time(9, 40, 0)
end = add_time(start, 1, 32, 0)
print_time(end)
10:72:00
结果不是一个有效的时间。问题在于increment_time
没有处理秒数或分钟数加到超过60
的情况。
这是一个改进版本,检查second
是否大于或等于60
——如果是,它会增加minute
——然后检查minute
是否大于或等于60
——如果是,它会增加hour
。
def increment_time(time, hours, minutes, seconds):
time.hour += hours
time.minute += minutes
time.second += seconds
if time.second >= 60:
time.second -= 60
time.minute += 1
if time.minute >= 60:
time.minute -= 60
time.hour += 1
修复increment_time
也修复了使用它的add_time
。所以现在之前的示例可以正确运行。
end = add_time(start, 1, 32, 0)
print_time(end)
11:12:00
但是这个函数仍然不正确,因为参数可能大于60
。例如,假设我们给出的运行时间是92
分钟,而不是1
小时32
分钟。我们可能像这样调用add_time
。
end = add_time(start, 0, 92, 0)
print_time(end)
10:72:00
结果不是一个有效的时间。所以我们尝试不同的方法,使用divmod
函数。我们将复制start
并通过增加minute
属性来修改它。
end = copy(start)
end.minute = start.minute + 92
end.minute
132
现在minute
是132
,相当于2
小时12
分钟。我们可以使用divmod
除以60
,返回整数小时数和剩余的分钟数。
carry, end.minute = divmod(end.minute, 60)
carry, end.minute
(2, 12)
现在minute
是正确的,我们可以将小时数加到hour
中。
end.hour += carry
print_time(end)
11:12:00
结果是一个有效的时间。我们可以对hour
和second
做同样的事情,并将整个过程封装成一个函数。
def increment_time(time, hours, minutes, seconds):
time.hour += hours
time.minute += minutes
time.second += seconds
carry, time.second = divmod(time.second, 60)
carry, time.minute = divmod(time.minute + carry, 60)
carry, time.hour = divmod(time.hour + carry, 60)
在这个版本的increment_time
中,即使参数超过60
,add_time
也能正常工作。
end = add_time(start, 0, 90, 120)
print_time(end)
11:12:00
本节展示了一种我称之为原型与修补的程序开发计划。我们从一个简单的原型开始,它在第一个例子中工作正常。然后我们用更复杂的例子进行了测试——当发现错误时,我们修改程序来修复它,就像给有破洞的轮胎打补丁一样。
这种方法可能有效,特别是当你对问题的理解还不够深入时。但增量修正可能会产生不必要复杂的代码——因为它处理了许多特殊情况——而且不可靠——因为很难确定你是否已经找到了所有错误。
14.8. 设计优先开发
另一种方案是设计优先开发,这种方法在原型设计之前涉及更多的规划。在设计优先的过程中,有时对问题的高层次洞察能让编程变得更加容易。
在这个例子中,洞察力在于我们可以将Time
对象视为一个 60 进制的三位数——也叫做性数字。second
属性是“个位数”列,minute
属性是“六十位数”列,hour
属性是“三千六百位数”列。当我们编写increment_time
时,我们实际上是在进行 60 进制的加法,这就是为什么我们必须从一个列进位到另一个列的原因。
这个观察结果暗示了另一种解决问题的方法——我们可以将Time
对象转换为整数,利用 Python 处理整数运算的特性。
这里是一个将Time
转换为整数的函数。
def time_to_int(time):
minutes = time.hour * 60 + time.minute
seconds = minutes * 60 + time.second
return seconds
结果是自一天开始以来的秒数。例如,01:01:01
是从一天开始算起的1
小时、1
分钟和1
秒,这个值是3600
秒、60
秒和1
秒的总和。
time = make_time(1, 1, 1)
print_time(time)
time_to_int(time)
01:01:01
3661
这里有一个将整数转换为Time
对象的函数——它使用了divmod
函数。
def int_to_time(seconds):
minute, second = divmod(seconds, 60)
hour, minute = divmod(minute, 60)
return make_time(hour, minute, second)
我们可以通过将前面的例子转换回Time
对象来进行测试。
time = int_to_time(3661)
print_time(time)
01:01:01
使用这些函数,我们可以编写一个更加简洁版的add_time
。
def add_time(time, hours, minutes, seconds):
duration = make_time(hours, minutes, seconds)
seconds = time_to_int(time) + time_to_int(duration)
return int_to_time(seconds)
第一行将参数转换为名为duration
的Time
对象。第二行将time
和duration
转换为秒并相加。第三行将结果转换为一个Time
对象并返回。
这就是它的工作原理。
start = make_time(9, 40, 0)
end = add_time(start, 1, 32, 0)
print_time(end)
11:12:00
在某些方面,从 60 进制转换到 10 进制再转换回来,比直接处理时间值要难一些。进制转换更为抽象,而我们对时间值的直觉理解要更强。
但是,如果我们有足够的洞察力,将时间视为以 60 为基数的数字——并投入精力编写转换函数time_to_int
和int_to_time
——我们就能得到一个更简洁、更易于阅读和调试、更可靠的程序。
它也更容易在之后添加新特性。例如,假设你要对两个Time
对象进行相减,以求得它们之间的持续时间。直接实现减法操作需要借位处理,使用转换函数更简单,也更可能正确。
具有讽刺意味的是,有时将问题做得更复杂——或者更通用——反而能使问题更容易,因为特例更少,出错的机会也更少。
14.9. 调试
Python 提供了多个内置函数,可以帮助测试和调试与对象相关的程序。例如,如果你不确定一个对象的类型,可以直接询问。
type(start)
__main__.Time
你还可以使用isinstance
来检查一个对象是否是某个特定类的实例。
isinstance(end, Time)
True
如果你不确定一个对象是否具有某个特定属性,你可以使用内置函数hasattr
。
hasattr(start, 'hour')
True
要获取字典中所有属性及其值,可以使用vars
。
vars(start)
{'hour': 9, 'minute': 40, 'second': 0}
structshape
模块,我们在第十一章中看到的,它也适用于程序员定义的类型。
from structshape import structshape
t = start, end
structshape(t)
'tuple of 2 Time'
14.10. 术语表
面向对象编程: 一种使用对象来组织代码和数据的编程风格。
类: 程序员定义的类型。类定义会创建一个新的类对象。
类对象: 表示一个类的对象——它是类定义的结果。
实例化: 创建属于某个类的对象的过程。
实例: 属于某个类的对象。
属性: 与对象相关联的变量,也叫实例变量。
对象图: 对象、其属性及其值的图形表示。
格式化说明符: 在 f-string 中,格式化说明符决定了值如何被转换为字符串。
纯函数: 一种不会修改其参数,也没有其他副作用的函数,唯一的作用是返回一个值。
函数式编程风格: 一种编程方式,尽可能使用纯函数。
原型与修补: 一种开发程序的方式,通过从粗略草图开始,逐步添加功能和修复错误。
设计优先开发: 一种开发程序的方式,通过更细致的规划,而不是原型开发和修补。
14.11. 练习
# 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
14.11.1. 向虚拟助手提问
本章包含了很多新的词汇。与虚拟助手的对话有助于加深理解。可以考虑询问:
-
“类和类型有什么区别?”
-
“对象和实例有什么区别?”
-
“变量和属性有什么区别?”
-
“纯函数与非纯函数相比有哪些优缺点?”
因为我们刚刚开始学习面向对象编程,本章中的代码并不符合惯用法——这不是经验丰富的程序员所写的代码。如果你向虚拟助手求助于这些练习,你可能会看到我们还没有介绍的特性。特别是,你可能会看到一个名为__init__
的方法,用于初始化实例的属性。
如果这些特性对你来说有意义,尽管使用它们。但如果没有,耐心点——我们很快就会讲到。在此期间,试着仅用我们已学习过的特性来解决以下练习。
此外,在本章中我们看到一个格式说明符的例子。如需更多信息,请问:“Python f-string 中可以使用哪些格式说明符?”
14.11.2. 练习
编写一个名为subtract_time
的函数,该函数接受两个Time
对象,并返回它们之间的间隔(秒数)——假设它们是同一天的两个时间点。
14.11.3. 练习
编写一个名为is_after
的函数,该函数接受两个Time
对象,并返回True
如果第一个时间点比第二个时间点晚,反之返回False
。
def is_after(t1, t2):
"""Checks whether `t1` is after `t2`.
>>> is_after(make_time(3, 2, 1), make_time(3, 2, 0))
True
>>> is_after(make_time(3, 2, 1), make_time(3, 2, 1))
False
>>> is_after(make_time(11, 12, 0), make_time(9, 40, 0))
True
"""
return None
14.11.4. 练习
这里有一个Date
类的定义,它表示一个日期——即年份、月份和日期。
class Date:
"""Represents a year, month, and day"""
-
编写一个名为
make_date
的函数,该函数接受year
、month
和day
作为参数,创建一个Date
对象,将这些参数赋值给属性,并返回新对象。创建一个表示 1933 年 6 月 22 日的对象。 -
编写一个名为
print_date
的函数,该函数接受一个Date
对象,使用 f-string 格式化属性并打印结果。如果你用你创建的Date
对象进行测试,结果应为1933-06-22
。 -
编写一个名为
is_after
的函数,该函数接受两个Date
对象作为参数,并返回True
如果第一个对象在第二个对象之后。创建一个表示 1933 年 9 月 17 日的第二个对象,并检查它是否在第一个对象之后。
提示:你可能会发现编写一个名为date_to_tuple
的函数很有用,该函数接受一个Date
对象并返回一个元组,包含按年份、月份、日期顺序排列的属性。
版权所有 2024 Allen B. Downey
代码许可:MIT 许可证
文字许可:知识共享署名-非商业性使用-相同方式共享 4.0 国际版
15. 类与方法
Python 是一种面向对象的语言——也就是说,它提供支持面向对象编程的特性,具有以下这些定义性特征:
-
大部分计算是通过对对象执行操作来表达的。
-
对象通常代表现实世界中的事物,方法通常对应于现实世界中事物之间的交互方式。
-
程序包括类和方法的定义。
例如,在上一章中我们定义了一个Time
类,它对应了人们记录时间的方式,并且我们定义了对应于人们与时间交互的功能。但Time
类的定义和接下来的函数定义之间没有明确的联系。我们可以通过将函数重写为方法来明确这种联系,方法是在类定义内部定义的。
15.1. 定义方法
在上一章中,我们定义了一个名为Time
的类,并编写了一个名为print_time
的函数,用于显示一天中的时间。
class Time:
"""Represents the time of day."""
def print_time(time):
s = f'{time.hour:02d}:{time.minute:02d}:{time.second:02d}'
print(s)
为了将print_time
变成一个方法,我们所需要做的就是将函数定义移到类定义内部。请注意缩进的变化。
同时,我们会将参数名称从time
改为self
。这个改变不是必须的,但在方法的第一个参数通常命名为self
。
class Time:
"""Represents the time of day."""
def print_time(self):
s = f'{self.hour:02d}:{self.minute:02d}:{self.second:02d}'
print(s)
要调用这个方法,你必须传递一个Time
对象作为参数。这里是我们用来创建Time
对象的函数。
def make_time(hour, minute, second):
time = Time()
time.hour = hour
time.minute = minute
time.second = second
return time
这里是一个Time
实例。
start = make_time(9, 40, 0)
现在有两种方式调用print_time
。第一种(不太常见)是使用函数语法。
Time.print_time(start)
09:40:00
在这个版本中,Time
是类的名称,print_time
是方法的名称,start
作为参数传递。第二种(更符合惯例)是使用方法语法:
start.print_time()
09:40:00
在这个版本中,start
是调用方法的对象,称为接收者,这个术语来源于将方法调用比作向对象发送消息的类比。
不管语法如何,该方法的行为是相同的。接收者被赋值为第一个参数,因此在方法内部,self
指向与start
相同的对象。
15.2. 另一种方法
这里是上一章的time_to_int
函数。
def time_to_int(time):
minutes = time.hour * 60 + time.minute
seconds = minutes * 60 + time.second
return seconds
这里是将其重写为方法的版本。
%%add_method_to Time
def time_to_int(self):
minutes = self.hour * 60 + self.minute
seconds = minutes * 60 + self.second
return seconds
第一行使用了特殊命令add_method_to
,它将方法添加到先前定义的类中。此命令在 Jupyter 笔记本中有效,但它不是 Python 的一部分,因此在其他环境中无法使用。通常,类的所有方法都在类定义内部,这样它们与类一起定义。但是为了本书的方便,我们一次定义一个方法。
如同前一个示例,方法定义是缩进的,参数名是self
。除此之外,方法与函数是相同的。下面是我们如何调用它。
start.time_to_int()
34800
通常我们说“调用”一个函数和“调用”一个方法,但它们的意思是一样的。
15.3. 静态方法
作为另一个示例,假设我们考虑int_to_time
函数。下面是上一章中的版本。
def int_to_time(seconds):
minute, second = divmod(seconds, 60)
hour, minute = divmod(minute, 60)
return make_time(hour, minute, second)
这个函数接受seconds
作为参数,并返回一个新的Time
对象。如果我们将它转换为Time
类的方法,我们必须在Time
对象上调用它。但如果我们试图创建一个新的Time
对象,我们应该在什么上调用它呢?
我们可以通过使用静态方法来解决这个鸡生蛋问题,静态方法是一种不需要类的实例即可调用的方法。下面是我们如何将这个函数重写为静态方法。
%%add_method_to Time
def int_to_time(seconds):
minute, second = divmod(seconds, 60)
hour, minute = divmod(minute, 60)
return make_time(hour, minute, second)
因为它是一个静态方法,所以它没有self
作为参数。要调用它,我们使用Time
,即类对象。
start = Time.int_to_time(34800)
结果是一个新对象,表示 9:40。
start.print_time()
09:40:00
既然我们有了Time.from_seconds
,我们可以利用它将add_time
写成一个方法。下面是上一章的函数。
def add_time(time, hours, minutes, seconds):
duration = make_time(hours, minutes, seconds)
seconds = time_to_int(time) + time_to_int(duration)
return int_to_time(seconds)
这是重写成方法的版本。
%%add_method_to Time
def add_time(self, hours, minutes, seconds):
duration = make_time(hours, minutes, seconds)
seconds = time_to_int(self) + time_to_int(duration)
return Time.int_to_time(seconds)
add_time
有self
作为参数,因为它不是静态方法。它是一个普通方法——也叫做实例方法。要调用它,我们需要一个Time
实例。
end = start.add_time(1, 32, 0)
print_time(end)
11:12:00
15.4. 比较时间对象
作为另一个示例,假设我们将is_after
写成一个方法。下面是is_after
函数,这是上一章练习的一个解答。
def is_after(t1, t2):
return time_to_int(t1) > time_to_int(t2)
这是作为方法的版本。
%%add_method_to Time
def is_after(self, other):
return self.time_to_int() > other.time_to_int()
因为我们在比较两个对象,而第一个参数是self
,所以我们将第二个参数命名为other
。要使用这个方法,我们必须在一个对象上调用它,并将另一个对象作为参数传入。
end.is_after(start)
True
这个语法的一个优点是,它几乎像在问一个问题:“end
在 start
之后吗?”
15.5. __str__
方法
当你编写方法时,你几乎可以选择任何你想要的名字。然而,某些名字有特殊的含义。例如,如果一个对象有一个名为__str__
的方法,Python 会使用这个方法将对象转换为字符串。例如,下面是一个时间对象的__str__
方法。
%%add_method_to Time
def __str__(self):
s = f'{self.hour:02d}:{self.minute:02d}:{self.second:02d}'
return s
这个方法与上一章的print_time
类似,不同之处在于它返回字符串而不是打印它。
你可以用通常的方式调用这个方法。
end.__str__()
'11:12:00'
但 Python 也可以为你调用它。如果你使用内置函数str
将一个Time
对象转换为字符串,Python 会使用Time
类中的__str__
方法。
str(end)
'11:12:00'
如果你打印一个Time
对象,它也会做相同的事情。
print(end)
11:12:00
像__str__
这样的函数被称为特殊方法。你可以通过它们的名字来识别它们,因为它们的名称前后都有两个下划线。
15.6. init方法
最特殊的特殊方法是__init__
,之所以如此称呼,是因为它初始化了新对象的属性。Time
类的一个__init__
方法可能是这样的:
%%add_method_to Time
def __init__(self, hour=0, minute=0, second=0):
self.hour = hour
self.minute = minute
self.second = second
现在,当我们实例化一个Time
对象时,Python 会调用__init__
并传递参数。因此,我们可以在创建对象的同时初始化属性。
time = Time(9, 40, 0)
print(time)
09:40:00
在这个例子中,参数是可选的,因此如果你调用Time
时不传递任何参数,你将获得默认值。
time = Time()
print(time)
00:00:00
如果你提供一个参数,它将覆盖hour
:
time = Time(9)
print(time)
09:00:00
如果你提供两个参数,它们将覆盖hour
和minute
。
time = Time(9, 45)
print(time)
09:45:00
如果你提供三个参数,它们将覆盖所有三个默认值。
当我编写一个新的类时,我几乎总是从编写__init__
开始,这使得创建对象变得更容易,以及__str__
,它对于调试非常有用。
15.7. 运算符重载
通过定义其他特殊方法,你可以指定运算符在程序员定义类型上的行为。例如,如果你为Time
类定义一个名为__add__
的方法,你就可以在Time
对象上使用+
运算符。
这里是一个__add__
方法。
%%add_method_to Time
def __add__(self, other):
seconds = self.time_to_int() + other.time_to_int()
return Time.int_to_time(seconds)
我们可以像这样使用它。
duration = Time(1, 32)
end = start + duration
print(end)
11:12:00
当我们运行这三行代码时,发生了很多事情:
-
当我们实例化一个
Time
对象时,__init__
方法被调用。 -
当我们在
Time
对象上使用+
运算符时,它的__add__
方法被调用。 -
当我们打印一个
Time
对象时,它的__str__
方法被调用。
改变运算符的行为,使其与程序员定义的类型一起工作,这被称为运算符重载。对于每个运算符,比如+
,都有一个相应的特殊方法,如__add__
。
15.8. 调试
如果minute
和second
的值在0
到60
之间(包括0
但不包括60
),并且hour
是正数,则Time
对象是有效的。此外,hour
和minute
应该是整数,但我们可能允许second
有小数部分。像这样的要求被称为不变量,因为它们应该始终为真。换句话说,如果它们不为真,那就意味着出了问题。
编写代码来检查不变量可以帮助检测错误并找出其原因。例如,你可能有一个名为is_valid
的方法,它接受一个Time
对象,如果它违反了不变量,返回False
。
%%add_method_to Time
def is_valid(self):
if self.hour < 0 or self.minute < 0 or self.second < 0:
return False
if self.minute >= 60 or self.second >= 60:
return False
if not isinstance(self.hour, int):
return False
if not isinstance(self.minute, int):
return False
return True
然后,在每个方法的开始部分,你可以检查参数,以确保它们是有效的。
%%add_method_to Time
def is_after(self, other):
assert self.is_valid(), 'self is not a valid Time'
assert other.is_valid(), 'self is not a valid Time'
return self.time_to_int() > other.time_to_int()
assert
语句会计算后面的表达式。如果结果为True
,它什么都不做;如果结果为False
,则会引发AssertionError
。这里是一个例子。
duration = Time(minute=132)
print(duration)
00:132:00
start.is_after(duration)
AssertionError: self is not a valid Time
assert
语句很有用,因为它们区分了处理正常情况的代码和检查错误的代码。
15.9. 词汇表
面向对象语言: 一种提供支持面向对象编程特性的语言,特别是用户定义类型。
方法(method): 定义在类中的函数,并在该类的实例上调用。
接收者(receiver): 方法所调用的对象。
静态方法(static method): 可以在没有对象作为接收者的情况下调用的方法。
实例方法(instance method): 必须在一个对象上调用的方法。
特殊方法(special method): 改变运算符和某些函数与对象交互方式的方法。
运算符重载(operator overloading): 使用特殊方法改变运算符与用户自定义类型之间的交互方式。
不变式(invariant): 程序执行过程中始终应该为真的条件。
15.10. 练习
# This cell tells Jupyter to provide detailed debugging information
# when a runtime error occurs. Run it before working on the exercises.
%xmode Verbose
15.10.1. 向虚拟助手提问
想了解更多关于静态方法的信息,可以向虚拟助手询问:
-
“实例方法和静态方法有什么区别?”
-
“为什么静态方法被称为静态方法?”
如果你请求虚拟助手生成一个静态方法,结果可能会以@staticmethod
开头,这是一种“装饰器”,表示这是一个静态方法。本书没有涉及装饰器的内容,但如果你感兴趣,可以向虚拟助手询问更多信息。
在本章中,我们将几个函数重写为方法。虚拟助手通常擅长这种代码转换。举个例子,将以下函数粘贴到虚拟助手中,并询问:“将此函数重写为Time
类的方法。”
def subtract_time(t1, t2):
return time_to_int(t1) - time_to_int(t2)
15.10.2. 练习
在上一章中,一系列练习要求你编写一个Date
类和一些与Date
对象一起使用的函数。现在,让我们练习将这些函数重写为方法。
-
编写一个
Date
类的定义,用于表示一个日期——即一个年份、月份和日期。 -
编写一个
__init__
方法,接受year
、month
和day
作为参数,并将这些参数赋值给属性。创建一个表示 1933 年 6 月 22 日的对象。 -
编写
__str__
方法,使用 f-string 格式化属性并返回结果。如果你用你创建的Date
对象进行测试,结果应该是1933-06-22
。 -
编写一个名为
is_after
的方法,接受两个Date
对象,如果第一个对象的日期晚于第二个对象,则返回True
。创建一个表示 1933 年 9 月 17 日的第二个对象,并检查它是否晚于第一个对象。
提示:你可能会发现编写一个名为to_tuple
的方法很有用,它返回一个包含Date
对象属性(以年-月-日顺序)的元组。
版权所有 2024 Allen B. Downey
代码许可证:MIT 许可证
文字许可证:创作共用许可证 署名-非商业性使用-相同方式共享 4.0 国际
16. 类和对象
到目前为止,我们已经定义了类,并创建了表示一天中的时间和一年中的某一天的对象。我们还定义了可以创建、修改以及进行计算的这些对象的方法。
在本章中,我们将继续探索面向对象编程(OOP),通过定义表示几何对象的类,包括点、线、矩形和圆形。我们将编写方法来创建和修改这些对象,并使用 jupyturtle
模块来绘制它们。
我将使用这些类来演示面向对象编程(OOP)主题,包括对象身份与等价性、浅拷贝与深拷贝、多态等。
16.1. 创建一个点
在计算机图形学中,屏幕上的位置通常通过一对坐标在 x
-y
平面中表示。按照惯例,点 (0, 0)
通常表示屏幕的左上角,而 (x, y)
表示从原点出发,向右移动 x
单位,向下移动 y
单位的点。与数学课上可能见过的笛卡尔坐标系相比,y
轴是上下颠倒的。
在 Python 中,我们可以通过几种方式来表示一个点:
-
我们可以将坐标分别存储在两个变量
x
和y
中。 -
我们可以将坐标作为列表或元组中的元素存储。
-
我们可以创建一个新的类型来表示点作为对象。
在面向对象编程中,最符合惯例的做法是创建一个新类型。为此,我们将从 Point
的类定义开始。
class Point:
"""Represents a point in 2-D space."""
def __init__(self, x, y):
self.x = x
self.y = y
def __str__(self):
return f'Point({self.x}, {self.y})'
__init__
方法将坐标作为参数并将其赋值给属性 x
和 y
。__str__
方法返回 Point
对象的字符串表示。
现在我们可以像这样实例化并显示一个 Point
对象。
start = Point(0, 0)
print(start)
Point(0, 0)
以下图显示了新对象的状态。
[外链图片转存中…(img-bXUxFCl8-1748168145701)]
像往常一样,程序员定义的类型由一个外部有类型名称、内部有属性的框表示。
通常,程序员定义的类型是可变的,因此我们可以编写一个像 translate
这样的函数,它接受两个数字 dx
和 dy
,并将它们加到属性 x
和 y
上。
%%add_method_to Point
def translate(self, dx, dy):
self.x += dx
self.y += dy
这个函数将 Point
从平面中的一个位置平移到另一个位置。如果我们不想修改现有的 Point
,可以使用 copy
来复制原始对象,然后修改副本。
from copy import copy
end1 = copy(start)
end1.translate(300, 0)
print(end1)
Point(300, 0)
我们可以将这些步骤封装到另一个名为 translated
的方法中。
%%add_method_to Point
def translated(self, dx=0, dy=0):
point = copy(self)
point.translate(dx, dy)
return point
与内置函数 sort
修改列表,sorted
函数创建一个新列表类似,我们现在有了一个 translate
方法来修改 Point
,还有一个 translated
方法来创建一个新的 Point
。
这里有一个例子:
end2 = start.translated(0, 150)
print(end2)
Point(0, 150)
在下一节中,我们将使用这些点来定义并绘制一条线。
16.2. 创建一个 Line
现在让我们定义一个表示两个点之间线段的类。像往常一样,我们将从__init__
方法和__str__
方法开始。
class Line:
def __init__(self, p1, p2):
self.p1 = p1
self.p2 = p2
def __str__(self):
return f'Line({self.p1}, {self.p2})'
有了这两个方法,我们可以实例化并显示一个Line
对象,我们将用它来表示x
轴。
line1 = Line(start, end1)
print(line1)
Line(Point(0, 0), Point(300, 0))
当我们调用print
并传入line
作为参数时,print
会在line
上调用__str__
方法。__str__
方法使用 f-string 来创建line
的字符串表示。
f-string 包含了两个大括号中的表达式self.p1
和self.p2
。当这些表达式被求值时,结果是Point
对象。然后,当它们被转换为字符串时,会调用Point
类中的__str__
方法。
这就是为什么,当我们显示一个Line
时,结果包含了Point
对象的字符串表示。
以下对象图展示了这个Line
对象的状态。
[外链图片转存中…(img-QmnJ0etY-1748168145701)]
字符串表示和对象图对于调试很有用,但这个示例的重点是生成图形,而不是文本!所以我们将使用jupyturtle
模块在屏幕上绘制线条。
正如我们在第四章中所做的那样,我们将使用make_turtle
来创建一个Turtle
对象以及一个可以绘制的画布。为了绘制线条,我们将使用jupyturtle
模块中的两个新函数:
-
jumpto
,它接受两个坐标并将Turtle
移动到给定位置,而不绘制线条,和 -
moveto
,它将Turtle
从当前位置移动到给定位置,并在它们之间绘制一条线段。
这是我们如何导入它们。
from jupyturtle import make_turtle, jumpto, moveto
这里有一个方法,它绘制了一个Line
。
%%add_method_to Line
def draw(self):
jumpto(self.p1.x, self.p1.y)
moveto(self.p2.x, self.p2.y)
为了展示它是如何使用的,我将创建第二条代表y
轴的线。
line2 = Line(start, end2)
print(line2)
Line(Point(0, 0), Point(0, 150))
然后绘制坐标轴。
make_turtle()
line1.draw()
line2.draw()
随着我们定义并绘制更多对象,我们将再次使用这些线条。但首先,让我们来讨论对象的等价性和标识。
16.3. 等价性和标识
假设我们创建了两个坐标相同的点。
p1 = Point(200, 100)
p2 = Point(200, 100)
如果我们使用==
运算符来比较它们,我们会得到程序员定义类型的默认行为——结果只有在它们是同一个对象时才为True
,而它们并不是。
p1 == p2
False
如果我们想改变这种行为,我们可以提供一个特殊的方法__eq__
,定义两个Point
对象相等的标准。
%%add_method_to Point
def __eq__(self, other):
return (self.x == other.x) and (self.y == other.y)
这个定义认为,当两个Point
对象的属性相等时,它们被认为是相等的。现在当我们使用==
运算符时,它会调用__eq__
方法,这表示p1
和p2
被认为是相等的。
p1 == p2
True
但是is
运算符仍然表示它们是不同的对象。
p1 is p2
False
不可能重载 is
运算符 —— 它始终检查对象是否相同。但对于程序员定义的类型,你可以重载 ==
运算符,以便它检查对象是否等价。并且你可以定义什么是“等价”。
16.4. 创建一个矩形
现在让我们定义一个类来表示和绘制矩形。为了简化起见,我们假设矩形要么是垂直的,要么是水平的,而不是倾斜的。你认为我们应该使用什么属性来指定矩形的位置和大小?
至少有两种可能性:
-
你可以指定矩形的宽度和高度,以及一个角的位置。
-
你可以指定两个对角的角。
此时,很难说哪种方式比另一种更好,因此让我们先实现第一种。以下是类的定义。
class Rectangle:
"""Represents a rectangle.
attributes: width, height, corner.
"""
def __init__(self, width, height, corner):
self.width = width
self.height = height
self.corner = corner
def __str__(self):
return f'Rectangle({self.width}, {self.height}, {self.corner})'
和往常一样,__init__
方法将参数赋值给属性,而 __str__
返回对象的字符串表示。现在我们可以实例化一个 Rectangle
对象,使用一个 Point
作为左上角的位置。
corner = Point(30, 20)
box1 = Rectangle(100, 50, corner)
print(box1)
Rectangle(100, 50, Point(30, 20))
以下图展示了该对象的状态。
[外链图片转存中…(img-yK4jJCfa-1748168145702)]
为了绘制一个矩形,我们将使用以下方法来创建四个 Point
对象,表示矩形的四个角。
%%add_method_to Rectangle
def make_points(self):
p1 = self.corner
p2 = p1.translated(self.width, 0)
p3 = p2.translated(0, self.height)
p4 = p3.translated(-self.width, 0)
return p1, p2, p3, p4
然后我们将创建四个 Line
对象来表示矩形的边。
%%add_method_to Rectangle
def make_lines(self):
p1, p2, p3, p4 = self.make_points()
return Line(p1, p2), Line(p2, p3), Line(p3, p4), Line(p4, p1)
然后我们将绘制矩形的边。
%%add_method_to Rectangle
def draw(self):
lines = self.make_lines()
for line in lines:
line.draw()
这是一个示例。
make_turtle()
line1.draw()
line2.draw()
box1.draw()
图中包含两条线来表示坐标轴。
16.5. 修改矩形
现在让我们考虑两种修改矩形的方法,grow
和 translate
。我们将看到 grow
按预期工作,但 translate
存在一个细微的 bug。在我解释之前,看看你能否先找出这个问题。
grow
需要两个数字,dwidth
和 dheight
,并将它们加到矩形的 width
和 height
属性上。
%%add_method_to Rectangle
def grow(self, dwidth, dheight):
self.width += dwidth
self.height += dheight
这是一个示例,通过复制 box1
并在复制对象上调用 grow
来演示效果。
box2 = copy(box1)
box2.grow(60, 40)
print(box2)
Rectangle(160, 90, Point(30, 20))
如果我们绘制 box1
和 box2
,可以确认 grow
按预期工作。
make_turtle()
line1.draw()
line2.draw()
box1.draw()
box2.draw()
现在让我们看看 translate
。它需要两个数字,dx
和 dy
,并将矩形在 x
和 y
方向上移动给定的距离。
%%add_method_to Rectangle
def translate(self, dx, dy):
self.corner.translate(dx, dy)
为了演示效果,我们将 box2
向右和向下移动。
box2.translate(30, 20)
print(box2)
Rectangle(160, 90, Point(60, 40))
现在让我们再看看如果我们重新绘制 box1
和 box2
会发生什么。
make_turtle()
line1.draw()
line2.draw()
box1.draw()
box2.draw()
看起来两个矩形都移动了,这并不是我们想要的结果!下一节将解释出了什么问题。
16.6. 深拷贝
当我们使用 copy
来复制 box1
时,它复制了 Rectangle
对象,但没有复制其中包含的 Point
对象。所以 box1
和 box2
是不同的对象,这正是我们想要的效果。
box1 is box2
False
但是它们的 corner
属性指向相同的对象。
box1.corner is box2.corner
True
以下图展示了这些对象的状态。
[外链图片转存中…(img-XA3e6ND7-1748168145702)]
copy
所做的操作称为浅拷贝,因为它复制了对象本身,而不是对象内部包含的其他对象。因此,改变一个Rectangle
的width
或height
不会影响另一个Rectangle
,但改变共享的Point
属性会影响两个对象!这种行为容易导致混淆和错误。
幸运的是,copy
模块提供了另一个函数,叫做deepcopy
,它不仅复制对象本身,还会复制它所引用的对象,甚至是它们所引用的对象,依此类推。这个操作称为深拷贝。
为了演示,我们从一个新的Rectangle
开始,并包含一个新的Point
。
corner = Point(20, 20)
box3 = Rectangle(100, 50, corner)
print(box3)
Rectangle(100, 50, Point(20, 20))
然后我们将进行深拷贝。
from copy import deepcopy
box4 = deepcopy(box3)
我们可以确认这两个Rectangle
对象分别引用了不同的Point
对象。
box3.corner is box4.corner
False
因为box3
和box4
是完全独立的对象,我们可以修改一个而不影响另一个。为了演示,我们将移动box3
并增大box4
。
box3.translate(50, 30)
box4.grow(100, 60)
我们可以确认效果如预期。
make_turtle()
line1.draw()
line2.draw()
box3.draw()
box4.draw()
16.7. 多态性
在前面的例子中,我们对两个Line
对象和两个Rectangle
对象调用了draw
方法。我们可以通过将对象列表化来更简洁地做同样的事情。
shapes = [line1, line2, box3, box4]
这个列表中的元素是不同类型的,但它们都提供了一个draw
方法,因此我们可以遍历这个列表并对每个元素调用draw
。
make_turtle()
for shape in shapes:
shape.draw()
在循环的第一次和第二次中,shape
指向一个Line
对象,因此当调用draw
时,执行的是Line
类中定义的方法。
第三次和第四次循环时,shape
指向一个Rectangle
对象,因此当调用draw
时,执行的是Rectangle
类中定义的方法。
从某种意义上讲,每个对象都知道如何绘制自己。这个特性被称为多态性。这个词来源于希腊语,意思是“多形态”。在面向对象编程中,多态性是指不同类型的对象能够提供相同的方法,这使得我们可以通过在不同类型的对象上调用相同的方法来执行许多计算——比如绘制图形。
作为本章末尾的练习,你将定义一个新的类,表示一个圆形并提供一个draw
方法。然后,你可以利用多态性来绘制线条、矩形和圆形。
16.8. 调试
在本章中,我们遇到了一个微妙的 bug,因为我们创建了一个Point
对象,它被两个Rectangle
对象共享,然后我们修改了这个Point
。通常,有两种方法可以避免此类问题:要么避免共享对象,要么避免修改对象。
为了避免共享对象,我们可以使用深拷贝,正如我们在本章中所做的那样。
为了避免修改对象,考虑用纯函数如translated
替换不纯函数如translate
。例如,这是一个创建新Point
并且永不修改其属性的translated
版本。
def translated(self, dx=0, dy=0):
x = self.x + dx
y = self.y + dy
return Point(x, y)
Python 提供的功能使得避免修改对象变得更容易。虽然这些功能超出了本书的范围,但如果你感兴趣,可以询问虚拟助手:“如何让一个 Python 对象变为不可变?”
创建一个新对象比修改现有对象花费更多时间,但在实际应用中,这种差异通常并不重要。避免共享对象和不纯函数的程序通常更容易开发、测试和调试——而最好的调试方式是你不需要进行调试。
16.9. 词汇表
浅拷贝: 一种拷贝操作,不会拷贝嵌套对象。
深拷贝: 一种拷贝操作,也会拷贝嵌套对象。
多态: 方法或运算符能够与多种类型的对象一起工作。
16.10. 练习
# This cell tells Jupyter to provide detailed debugging information
# when a runtime error occurs. Run it before working on the exercises.
%xmode Verbose
16.10.1. 请求虚拟助手
对于以下所有练习,可以考虑请求虚拟助手的帮助。如果这样做,你需要将Point
、Line
和Rectangle
类的定义作为提示的一部分提供——否则虚拟助手会猜测它们的属性和方法,生成的代码将无法正常工作。
16.10.2. 练习
为Line
类编写一个__eq__
方法,如果Line
对象引用的Point
对象是相等的(无论顺序如何),则返回True
。
你可以使用以下大纲来开始。
%%add_method_to Line
def __eq__(self, other):
return None
你可以使用这些示例来测试你的代码。
start1 = Point(0, 0)
start2 = Point(0, 0)
end = Point(200, 100)
这个示例应该是True
,因为Line
对象引用的Point
对象是相等的,并且顺序相同。
line_a = Line(start1, end)
line_b = Line(start2, end)
line_a == line_b # should be True
True
line_c = Line(end, start1)
line_a == line_c # should be True
True
等价关系应始终具有传递性——也就是说,如果line_a
和line_b
是相等的,且line_a
和line_c
是相等的,那么line_b
和line_c
也应该是相等的。
line_b == line_c # should be True
True
这个示例应该是False
,因为Line
对象引用的Point
对象是不相等的。
line_d = Line(start1, start2)
line_a == line_d # should be False
False
16.10.3. 练习
编写一个名为midpoint
的Line
方法,该方法计算线段的中点并将结果作为Point
对象返回。
你可以使用以下大纲来开始。
%%add_method_to Line
def midpoint(self):
return Point(0, 0)
你可以使用以下示例来测试你的代码并绘制结果。
start = Point(0, 0)
end1 = Point(300, 0)
end2 = Point(0, 150)
line1 = Line(start, end1)
line2 = Line(start, end2)
mid1 = line1.midpoint()
print(mid1)
Point(150.0, 0.0)
mid2 = line2.midpoint()
print(mid2)
Point(0.0, 75.0)
line3 = Line(mid1, mid2)
make_turtle()
for shape in [line1, line2, line3]:
shape.draw()
16.10.4. 练习
编写一个名为midpoint
的Rectangle
方法,该方法找到矩形中心的点并将结果作为Point
对象返回。
你可以使用以下大纲来开始。
%%add_method_to Rectangle
def midpoint(self):
return Point(0, 0)
你可以使用以下示例来测试你的代码。
corner = Point(30, 20)
rectangle = Rectangle(100, 80, corner)
mid = rectangle.midpoint()
print(mid)
Point(80.0, 60.0)
diagonal = Line(corner, mid)
make_turtle()
for shape in [line1, line2, rectangle, diagonal]:
shape.draw()
16.10.5. 练习
编写一个名为make_cross
的Rectangle
方法,该方法:
-
使用
make_lines
来获取表示矩形四个边的Line
对象列表。 -
计算四条线的中点。
-
创建并返回一个包含两个
Line
对象的列表,这些对象代表连接相对中点的线,形成一个穿过矩形中部的十字。
你可以使用以下大纲来开始。
%%add_method_to Rectangle
def make_diagonals(self):
return []
你可以使用以下示例来测试你的代码。
corner = Point(30, 20)
rectangle = Rectangle(100, 80, corner)
lines = rectangle.make_cross()
make_turtle()
rectangle.draw()
for line in lines:
line.draw()
16.10.6. 练习
编写一个名为Circle
的类的定义,具有属性center
和radius
,其中center
是一个 Point 对象,radius
是一个数字。包括特殊方法__init__
和__str__
,以及一个名为draw
的方法,使用jupyturtle
函数绘制圆形。
你可以使用以下函数,这是我们在第四章中编写的circle
函数的版本。
from jupyturtle import make_turtle, forward, left, right
import math
def draw_circle(radius):
circumference = 2 * math.pi * radius
n = 30
length = circumference / n
angle = 360 / n
left(angle / 2)
for i in range(n):
forward(length)
left(angle)
你可以使用以下示例来测试你的代码。我们将从一个宽度和高度为100
的正方形Rectangle
开始。
corner = Point(20, 20)
rectangle = Rectangle(100, 100, corner)
以下代码应该创建一个可以适应正方形的Circle
。
center = rectangle.midpoint()
radius = rectangle.height / 2
circle = Circle(center, radius)
print(circle)
Circle(Point(70.0, 70.0), 50.0)
如果一切正常,以下代码应该会在正方形内部绘制一个圆(触及四个边)。
make_turtle(delay=0.01)
rectangle.draw()
circle.draw()
版权所有 2024 Allen B. Downey
代码许可证:MIT 许可证