Python 编程思维第三版(四)

来源:allendowney.github.io/ThinkPython/

译者:飞龙

协议:CC BY-NC-SA 4.0

13. 文件与数据库

原文:allendowney.github.io/ThinkPython/chap13.html

我们迄今为止看到的大多数程序都是临时的,因为它们运行时间很短,生成输出,但当它们结束时,它们的数据会消失。每次运行临时程序时,它都会从一个干净的状态开始。

其他程序是持久的:它们运行时间很长(或者一直运行);它们将至少一部分数据保存在长期存储中;如果它们关闭并重新启动,它们会从上次停止的地方继续。

程序保持数据的一种简单方式是通过读取和写入文本文件。一个更通用的替代方案是将数据存储在数据库中。数据库是专门的文件,比文本文件更高效地读取和写入,并且提供了额外的功能。

在本章中,我们将编写读取和写入文本文件及数据库的程序,并且作为一个练习,你将编写一个程序,搜索照片集中的重复文件。但在你可以操作文件之前,首先要找到它,因此我们将从文件名、路径和目录开始。

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' 

一些字典方法,如keysvaluesitems,也适用于 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模块有名为loadsafe_load的函数?”

  • “当我写一个 Python shelf 时,datdir后缀的文件是什么?”

  • “除了键值存储,还有哪些类型的数据库?”

  • “当我读取一个文件时,二进制模式和文本模式有什么区别?”

  • “字节对象和字符串有什么区别?”

  • “什么是哈希函数?”

  • “什么是 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来确认文件是否包含相同的数据。

我将首先建议编写一些函数,然后我们将把所有内容结合在一起。

  1. 为了识别图像文件,编写一个名为is_image的函数,该函数接受一个路径和一个文件扩展名列表,并在路径以列表中的某个扩展名结尾时返回True。提示:使用os.path.splitext,或者让虚拟助手为你编写这个函数。

  2. 编写一个名为add_path的函数,该函数接受一个路径和一个架子作为参数。它应该使用md5_digest来计算文件内容的摘要。然后,它应该更新架子,要么创建一个新的项,将摘要映射到包含路径的列表,要么将路径附加到已存在的列表中。

  3. 编写一个名为walk_imageswalk函数变体,它接受一个目录并遍历该目录及其子目录中的文件。对于每个文件,它应使用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来检查它们是否包含相同的数据。

Think Python: 第 3 版

版权 2024 Allen B. Downey

代码许可:MIT 许可

文本许可:创意共享署名-非商业性使用-相同方式共享 4.0 国际版

14. 类和函数

原文:allendowney.github.io/ThinkPython/chap14.html

到目前为止,你已经学会了如何使用函数组织代码,以及如何使用内置类型组织数据。下一步是面向对象编程,它使用程序员定义的类型来组织代码和数据。

面向对象编程是一个庞大的话题,因此我们将逐步进行。在本章中,我们将从不规范的代码开始——也就是说,它不是经验丰富的程序员所写的那种代码——但这是一个不错的起点。在接下来的两章中,我们将使用更多的特性来编写更规范的代码。

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 

这个例子创建了名为hourminutesecond的属性,它们分别表示时间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' 

但请注意,之前的例子并不符合标准格式。为了解决这个问题,我们需要在打印 minutesecond 属性时加上前导零。我们可以通过在大括号中的表达式后面添加 格式说明符 来实现。以下示例中的格式说明符表示 minutesecond 应该至少显示两位数字,并在需要时加上前导零。

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 接受名为 hourminutesecond 的参数,将它们作为属性存储在 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 结束。

让我们将这个计算封装成一个函数,并将其通用化,以接受电影时长的三个参数:hoursminutesseconds

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)]

在函数内部,timestart 的别名,因此当 time 被修改时,start 也会改变。

这个函数是有效的,但运行后,我们会留下一个名为 start 的变量,它指向表示 结束 时间的对象,而我们不再拥有表示开始时间的对象。最好不要改变 start,而是创建一个新的对象来表示结束时间。我们可以通过复制 start 并修改副本来实现。

14.5. 复制

copy 模块提供了一个名为 copy 的函数,可以复制任何对象。我们可以像这样导入它。

from copy import copy 

为了查看它是如何工作的,我们从一个新的 Time 对象开始,表示电影的开始时间。

start = make_time(9, 20, 0) 

并且制作一个副本。

end = copy(start) 

现在 startend 包含相同的数据。

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_timeadd_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 

现在minute132,相当于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 

结果是一个有效的时间。我们可以对hoursecond做同样的事情,并将整个过程封装成一个函数。

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中,即使参数超过60add_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) 

第一行将参数转换为名为durationTime对象。第二行将timeduration转换为秒并相加。第三行将结果转换为一个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_intint_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""" 
  1. 编写一个名为make_date的函数,该函数接受yearmonthday作为参数,创建一个Date对象,将这些参数赋值给属性,并返回新对象。创建一个表示 1933 年 6 月 22 日的对象。

  2. 编写一个名为print_date的函数,该函数接受一个Date对象,使用 f-string 格式化属性并打印结果。如果你用你创建的Date对象进行测试,结果应为1933-06-22

  3. 编写一个名为is_after的函数,该函数接受两个Date对象作为参数,并返回True如果第一个对象在第二个对象之后。创建一个表示 1933 年 9 月 17 日的第二个对象,并检查它是否在第一个对象之后。

提示:你可能会发现编写一个名为date_to_tuple的函数很有用,该函数接受一个Date对象并返回一个元组,包含按年份、月份、日期顺序排列的属性。

Think Python: 第 3 版

版权所有 2024 Allen B. Downey

代码许可:MIT 许可证

文字许可:知识共享署名-非商业性使用-相同方式共享 4.0 国际版

15. 类与方法

原文:allendowney.github.io/ThinkPython/chap15.html

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_timeself作为参数,因为它不是静态方法。它是一个普通方法——也叫做实例方法。要调用它,我们需要一个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 

这个语法的一个优点是,它几乎像在问一个问题:“endstart 之后吗?”

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 

如果你提供两个参数,它们将覆盖hourminute

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. 调试

如果minutesecond的值在060之间(包括0但不包括60),并且hour是正数,则Time对象是有效的。此外,hourminute应该是整数,但我们可能允许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对象一起使用的函数。现在,让我们练习将这些函数重写为方法。

  1. 编写一个Date类的定义,用于表示一个日期——即一个年份、月份和日期。

  2. 编写一个__init__方法,接受yearmonthday作为参数,并将这些参数赋值给属性。创建一个表示 1933 年 6 月 22 日的对象。

  3. 编写__str__方法,使用 f-string 格式化属性并返回结果。如果你用你创建的Date对象进行测试,结果应该是1933-06-22

  4. 编写一个名为is_after的方法,接受两个Date对象,如果第一个对象的日期晚于第二个对象,则返回True。创建一个表示 1933 年 9 月 17 日的第二个对象,并检查它是否晚于第一个对象。

提示:你可能会发现编写一个名为to_tuple的方法很有用,它返回一个包含Date对象属性(以年-月-日顺序)的元组。

Think Python: 第 3 版

版权所有 2024 Allen B. Downey

代码许可证:MIT 许可证

文字许可证:创作共用许可证 署名-非商业性使用-相同方式共享 4.0 国际

16. 类和对象

原文:allendowney.github.io/ThinkPython/chap16.html

到目前为止,我们已经定义了类,并创建了表示一天中的时间和一年中的某一天的对象。我们还定义了可以创建、修改以及进行计算的这些对象的方法。

在本章中,我们将继续探索面向对象编程(OOP),通过定义表示几何对象的类,包括点、线、矩形和圆形。我们将编写方法来创建和修改这些对象,并使用 jupyturtle 模块来绘制它们。

我将使用这些类来演示面向对象编程(OOP)主题,包括对象身份与等价性、浅拷贝与深拷贝、多态等。

16.1. 创建一个点

在计算机图形学中,屏幕上的位置通常通过一对坐标在 x-y 平面中表示。按照惯例,点 (0, 0) 通常表示屏幕的左上角,而 (x, y) 表示从原点出发,向右移动 x 单位,向下移动 y 单位的点。与数学课上可能见过的笛卡尔坐标系相比,y 轴是上下颠倒的。

在 Python 中,我们可以通过几种方式来表示一个点:

  • 我们可以将坐标分别存储在两个变量 xy 中。

  • 我们可以将坐标作为列表或元组中的元素存储。

  • 我们可以创建一个新的类型来表示点作为对象。

在面向对象编程中,最符合惯例的做法是创建一个新类型。为此,我们将从 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__ 方法将坐标作为参数并将其赋值给属性 xy__str__ 方法返回 Point 对象的字符串表示。

现在我们可以像这样实例化并显示一个 Point 对象。

start = Point(0, 0)
print(start) 
Point(0, 0) 

以下图显示了新对象的状态。

[外链图片转存中…(img-bXUxFCl8-1748168145701)]

像往常一样,程序员定义的类型由一个外部有类型名称、内部有属性的框表示。

通常,程序员定义的类型是可变的,因此我们可以编写一个像 translate 这样的函数,它接受两个数字 dxdy,并将它们加到属性 xy 上。

%%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.p1self.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__方法,这表示p1p2被认为是相等的。

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. 修改矩形

现在让我们考虑两种修改矩形的方法,growtranslate。我们将看到 grow 按预期工作,但 translate 存在一个细微的 bug。在我解释之前,看看你能否先找出这个问题。

grow 需要两个数字,dwidthdheight,并将它们加到矩形的 widthheight 属性上。

%%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)) 

如果我们绘制 box1box2,可以确认 grow 按预期工作。

make_turtle()
line1.draw()
line2.draw()
box1.draw()
box2.draw() 

现在让我们看看 translate。它需要两个数字,dxdy,并将矩形在 xy 方向上移动给定的距离。

%%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)) 

现在让我们再看看如果我们重新绘制 box1box2 会发生什么。

make_turtle()
line1.draw()
line2.draw()
box1.draw()
box2.draw() 

看起来两个矩形都移动了,这并不是我们想要的结果!下一节将解释出了什么问题。

16.6. 深拷贝

当我们使用 copy 来复制 box1 时,它复制了 Rectangle 对象,但没有复制其中包含的 Point 对象。所以 box1box2 是不同的对象,这正是我们想要的效果。

box1 is box2 
False 

但是它们的 corner 属性指向相同的对象。

box1.corner is box2.corner 
True 

以下图展示了这些对象的状态。

[外链图片转存中…(img-XA3e6ND7-1748168145702)]

copy所做的操作称为浅拷贝,因为它复制了对象本身,而不是对象内部包含的其他对象。因此,改变一个Rectanglewidthheight不会影响另一个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 

因为box3box4是完全独立的对象,我们可以修改一个而不影响另一个。为了演示,我们将移动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. 请求虚拟助手

对于以下所有练习,可以考虑请求虚拟助手的帮助。如果这样做,你需要将PointLineRectangle类的定义作为提示的一部分提供——否则虚拟助手会猜测它们的属性和方法,生成的代码将无法正常工作。

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_aline_b是相等的,且line_aline_c是相等的,那么line_bline_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. 练习

编写一个名为midpointLine方法,该方法计算线段的中点并将结果作为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. 练习

编写一个名为midpointRectangle方法,该方法找到矩形中心的点并将结果作为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_crossRectangle方法,该方法:

  1. 使用make_lines来获取表示矩形四个边的Line对象列表。

  2. 计算四条线的中点。

  3. 创建并返回一个包含两个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的类的定义,具有属性centerradius,其中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() 

Think Python: 第 3 版

版权所有 2024 Allen B. Downey

代码许可证:MIT 许可证

文本许可证:知识共享署名-非商业性使用-相同方式共享 4.0 国际版

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值