与面向对象编程相比,Python 的许多方面看起来更像是结构化或函数式编程。尽管面向对象编程是过去二十年中最明显的模式,但旧模式最近又重新兴起。与 Python 的数据结构一样,这些工具中的大多数都是底层面向对象实现的语法糖;我们可以把它们看作是建立在(已经抽象的)面向对象范例之上的进一步抽象层。在本章中,我们将介绍一些并非严格面向对象的 Python 特性:
- 在一次调用中处理常见任务的内置函数
- 文件 I/O 和上下文管理器
- 方法重载的替代方法
- 作为对象的功能
Python 中有许多函数在某些类型的对象上执行任务或计算结果,而不是底层类上的方法。它们通常抽象出适用于多种类型的类的通用计算。这是鸭子打字的最佳状态;这些函数接受具有特定属性或方法的对象,并且能够使用这些方法执行泛型操作。其中许多(但不是全部)是特殊的双下划线方法。我们已经使用了许多内置函数,但是让我们快速浏览一些重要的函数,并在此过程中学习一些巧妙的技巧。
最简单的例子是len()函数,它统计某种容器对象(如字典或列表)中的项目数。您以前见过:
>>> len([1,2,3,4])
4为什么这些对象没有长度属性,而不需要调用函数?从技术上讲,他们是这样做的。len()将应用的大多数对象都有一个名为__len__()的方法,该方法返回相同的值。所以len(myobj)似乎在叫myobj.__len__()。
为什么我们应该使用len()函数而不是__len__方法?显然,__len__是一种特殊的双下划线方法,这表明我们不应该直接调用它。对此必须有一个解释。Python 开发人员不会轻易做出这样的设计决策。
主要原因是效率。当我们对一个对象调用__len__时,该对象必须在其名称空间中查找该方法,并且,如果在该对象上定义了特殊__getattribute__方法(每次访问对象上的属性或方法时都会调用该方法),则也必须调用该方法。此外,__getattribute__因为那个特定的方法可能被写来做一些令人讨厌的事情,比如拒绝让我们使用__len__这样的特殊方法!len()函数不会遇到这些问题。它实际上调用基础类上的__len__函数,因此len(myobj)映射到MyObj.__len__(myobj)。
另一个原因是可维护性。在未来,Python 开发人员可能希望更改len(),以便它可以计算没有__len__的对象的长度,例如,通过计算迭代器中返回的项目数。他们只需要改变一个函数,而不是一刀切地改变无数的__len__方法。
len()作为一个外部函数还有一个非常重要且经常被忽略的原因:向后兼容性。这在文章中经常被引用为“出于历史原因”,这是一个略带轻蔑的短语,作者会用它来表示事情是这样的,因为很久以前就犯了一个错误,而我们却被它缠住了。严格地说,len()不是一个错误,这是一个设计决策,但这个决策是在一个不太面向对象的时间内做出的。它经受了时间的考验,并且有一些好处,所以一定要习惯它。
reversed()函数将任何序列作为输入,并以相反顺序返回该序列的副本。当我们想从后面到前面循环项目时,通常在for循环中使用。
与len类似,reversed为参数调用类上的__reversed__()函数。如果该方法不存在,reversed使用对__len__和__getitem__的调用构建反向序列本身,这两个调用用于定义序列。如果我们想以某种方式定制或优化流程,我们只需要覆盖__reversed__:
normal_list=[1,2,3,4,5]
class CustomSequence():
def __len__(self):
return 5
def __getitem__(self, index):
return "x{0}".format(index)
class FunkyBackwards():
def __reversed__(self):
return "BACKWARDS!"
for seq in normal_list, CustomSequence(), FunkyBackwards():
print("\n{}: ".format(seq.__class__.__name__), end="")
for item in reversed(seq):
print(item, end=", ")末尾的for循环打印正常列表的反向版本,以及两个自定义序列的实例。输出结果显示reversed对这三个方面都有效,但当我们定义__reversed__时,结果却截然不同:
list: 5, 4, 3, 2, 1,
CustomSequence: x4, x3, x2, x1, x0,
FunkyBackwards: B, A, C, K, W, A, R, D, S, !,当我们反转CustomSequence时,每个项目都会调用__getitem__方法,它只是在索引前插入一个x。对于FunkyBackwards,__reversed__ 方法返回一个字符串,其每个字符在for循环中单独输出。
前面的两个类不是很好的序列,因为它们没有定义正确版本的__iter__,所以在它们上面的前向for循环永远不会结束。
有时,当我们在for循环中的容器上进行循环时,我们希望访问正在处理的当前项的索引(列表中的当前位置)。for循环没有为我们提供索引,但enumerate函数给了我们更好的东西:它创建了一个元组序列,每个元组中的第一个对象是索引,第二个是原始项。
如果我们需要直接使用索引号,这很有用。考虑一些简单的代码,输出文件中每行的行数:
import sys
filename = sys.argv[1]
with open(filename) as file:
for index, line in enumerate(file):
print("{0}: {1}".format(index+1, line), end='')使用自己的文件名作为输入文件运行此代码将显示其工作原理:
1: import sys
2: filename = sys.argv[1]
3:
4: with open(filename) as file:
5: for index, line in enumerate(file):
6: print("{0}: {1}".format(index+1, line), end='')enumerate函数返回一系列元组,我们的for循环将每个元组拆分为两个值,print语句将它们一起格式化。它为每个行号添加一个索引,因为enumerate与所有序列一样,是基于零的。
我们只讨论了一些更重要的 Python 内置函数。正如您所看到的,它们中的许多调用了面向对象的概念,而另一些则支持纯粹的函数或过程范式。标准库中还有许多其他的库;一些更有趣的例子包括:
all和any,接受一个 iterable 对象,如果所有或任何项的计算结果为 true,则返回True(例如非空字符串或列表、非零数字、非None的对象或文字True。eval、exec和compile,它们在解释器内以字符串作为代码执行。小心这些东西;它们是不安全的,所以不要执行未知用户提供给您的代码(通常,假设所有未知用户都是恶意的、愚蠢的,或者两者兼而有之)。hasattr、getattr、setattr和delattr,它们允许对象上的属性通过其字符串名称进行操作。zip,它获取两个或更多序列并返回一个新的元组序列,其中每个元组包含每个序列中的单个值。- 还有更多!有关
dir(__builtins__)中列出的每个功能,请参阅解释器帮助文档。
到目前为止,我们的示例触摸文件系统完全是在文本文件上运行的,没有太多考虑引擎盖下发生了什么。然而,操作系统实际上将文件表示为字节序列,而不是文本。我们将在第 8 章、字符串和序列化中深入探讨字节和文本之间的关系。现在,请注意,从文件中读取文本数据是一个相当复杂的过程。Python,特别是 Python3,在幕后为我们处理了大部分工作。我们不是很幸运吗?
文件的概念早在有人创造面向对象编程这个术语之前就已经存在了。然而,Python 将操作系统提供的接口封装在一个甜美的抽象中,允许我们处理文件(或类似文件的 vis-ávis-duck 类型)对象。
open()内置函数用于打开文件并返回文件对象。为了从文件中读取文本,我们只需要将文件名传递到函数中。将打开文件进行读取,并使用平台默认编码将字节转换为文本。
当然,我们并不总是想读取文件;我们经常希望向它们写入数据!要打开文件进行写入,需要传递一个mode参数作为第二个位置参数,其值为"w":
contents = "Some file contents"
file = open("filename", "w")
file.write(contents)
file.close()我们还可以提供值"a"作为模式参数,以附加到文件末尾,而不是完全覆盖现有文件内容。
这些带有内置包装器的文件很好,可以将字节转换为文本,但是如果我们要打开的文件是图像、可执行文件或其他二进制文件,这将非常不方便,不是吗?
为了打开一个二进制文件,我们修改模式字符串以附加'b'。所以,'wb'会打开一个文件来写入字节,而'rb'允许我们读取字节。它们的行为类似于文本文件,但不自动将文本编码为字节。当我们读取这样一个文件时,它将返回bytes对象而不是str,当我们写入它时,如果我们试图传递文本对象,它将失败。
这些用于控制文件打开方式的模式字符串相当神秘,既不是 pythonic 也不是面向对象的。然而,它们与几乎所有其他编程语言都是一致的。文件 I/O 是操作系统必须处理的基本任务之一,所有编程语言都必须使用相同的系统调用与操作系统通信。很高兴 Python 返回了一个带有有用方法的文件对象,而不是大多数主要操作系统用来识别文件句柄的整数!
一旦打开一个文件进行读取,我们就可以调用read、readline或readlines方法来获取该文件的内容。read方法将文件的全部内容返回为str或bytes对象,具体取决于模式中是否存在'b'。注意不要在大文件上使用没有参数的方法。如果您试图将那么多数据加载到内存中,您不想知道会发生什么!
还可以从文件中读取固定数量的字节;我们将一个整数参数传递给read方法,该方法描述我们要读取的字节数。对read的下一次调用将加载下一个字节序列,依此类推。我们可以在while循环中执行此操作,以可管理的块读取整个文件。
readline方法从文件返回一行(每行以换行符、回车符或两者结尾,具体取决于创建文件的操作系统)。我们可以反复呼叫它以获得额外的线路。复数readlines方法返回文件中所有行的列表。像read方法一样,在非常大的文件上使用它是不安全的。这两种方法甚至可以在bytes模式下打开文件时工作,但只有在解析在合理位置有换行符的文本数据时才有意义。例如,图像或音频文件中不会有换行符(除非换行字节恰好代表某个像素或声音),因此应用readline没有意义。
为了提高可读性,避免将大文件一次读入内存,最好直接在文件对象上使用for循环。对于文本文件,它将读取每一行,一次一行,我们可以在循环体中处理它。对于二进制文件,最好使用read()方法读取固定大小的数据块,并传递一个用于读取最大字节数的参数。
写入文件也同样容易;文件对象上的write方法将字符串(或字节,用于二进制数据)对象写入文件。可以重复调用它,一个接一个地写入多个字符串。writelines方法接受字符串序列,并将每个迭代值写入文件。writelines方法不会在序列中的每个项目后追加新行。它基本上是一个命名不好的便利函数,用于编写字符串序列的内容,而不必使用for循环显式地对其进行迭代。
最后,我的意思是最后,我们来讨论close方法。当我们完成文件的读取或写入时,应该调用此方法,以确保任何缓冲写入都写入磁盘,文件已正确清理,并且与文件相关的所有资源都释放回操作系统。从技术上讲,这将在脚本退出时自动发生,但最好是显式的,并在我们之后进行清理,特别是在长时间运行的流程中。
当我们处理完文件后,需要关闭这些文件,这会使我们的代码非常难看。因为在文件 I/O 过程中可能随时发生异常,所以我们应该将对文件的所有调用包装在try中。。。finally条款。无论 I/O 是否成功,都应在finally子句中关闭该文件。这不是很像蟒蛇。当然,还有一种更优雅的方式。
如果我们在类似文件的对象上运行dir,我们会看到它有两个特殊的方法,分别名为__enter__和__exit__。这些方法将文件对象转换为所谓的上下文管理器。基本上,如果我们使用一种称为with语句的特殊语法,这些方法将在执行嵌套代码之前和之后被调用。在文件对象上,__exit__方法确保关闭文件,即使引发异常。我们不再需要显式地管理文件的关闭。以下是with声明在实践中的表现:
with open('filename') as file:
for line in file:
print(line, end='')open调用返回一个 file 对象,该对象有__enter__和__exit__方法。返回的对象由as子句分配给名为file的变量。我们知道,当代码返回到外部缩进级别时,文件将被关闭,即使引发异常,也会发生这种情况。
with语句用于标准库中需要执行启动或清理代码的几个地方。例如,urlopen调用返回一个对象,该对象可以在with语句中使用,以在完成后清理套接字。线程模块中的锁可以在执行语句时自动释放锁。
最有趣的是,因为with语句可以应用于任何具有适当特殊方法的对象,所以我们可以在我们自己的框架中使用它。例如,请记住字符串是不可变的,但有时需要从多个部分构建字符串。为了提高效率,这通常是通过将组件字符串存储在列表中并在末尾连接它们来实现的。让我们创建一个简单的上下文管理器,它允许我们构造一个字符序列,并在退出时自动将其转换为字符串:
class StringJoiner(list):
def __enter__(self):
return self
def __exit__(self, type, value, tb):
self.result = "".join(self)此代码将上下文管理器所需的两个特殊方法添加到其继承的list类中。__enter__方法执行任何必需的设置代码(在本例中,没有设置代码),然后返回将分配给with语句中as之后的变量的对象。通常,正如我们在这里所做的,这只是上下文管理器对象本身。__exit__方法接受三个参数。在正常情况下,这些都给出了一个值None。但是,如果在with块内发生异常,它们将被设置为与异常的类型、值和回溯相关的值。这允许__exit__方法执行任何可能需要的清理代码,即使发生异常。在我们的示例中,我们采用不负责任的路径,通过连接字符串中的字符来创建结果字符串,而不管是否引发异常。
虽然这是我们可以编写的最简单的上下文管理器之一,其有用性值得怀疑,但它确实与with语句一起工作。看看它的实际效果:
import random, string
with StringJoiner() as joiner:
for i in range(15):
joiner.append(random.choice(string.ascii_letters))
print(joiner.result)这段代码构造了一个由 15 个随机字符组成的字符串。它使用从list继承的append方法将这些附加到StringJoiner。当with语句超出范围(返回到外部缩进级别)时,调用__exit__方法,并且result属性在 joiner 对象上变为可用。我们打印此值以查看随机字符串。
许多面向对象编程语言的一个突出特点是一个名为方法重载的工具。方法重载只是指有多个同名的方法接受不同的参数集。例如,在静态类型语言中,如果我们想要一个接受整数或字符串的方法,这是非常有用的。在非面向对象语言中,我们可能需要两个函数,称为add_s和add_i,以适应这种情况。在静态类型的面向对象语言中,我们需要两个方法,都称为add,一个接受字符串,另一个接受整数。
在 Python 中,我们只需要一个方法,它接受任何类型的对象。它可能需要对对象类型进行一些测试(例如,如果它是字符串,则将其转换为整数),但只需要一个方法。
但是,当我们希望具有相同名称的方法接受不同数量或参数集时,方法重载也很有用。例如,电子邮件方法可能有两个版本,其中一个版本接受“发件人”电子邮件地址的参数。另一种方法可能会查找默认的“发件人”电子邮件地址。Python 不允许使用相同名称的多个方法,但它提供了不同的、同样灵活的接口。
在前面的示例中,我们已经看到了一些向方法和函数发送参数的可能方法,但现在我们将介绍所有细节。最简单的函数不接受任何参数。我们可能不需要一个例子,但这里有一个完整的例子:
def no_args():
pass以下是它的名称:
no_args()接受参数的函数将在逗号分隔的列表中提供这些参数的名称。只需提供每个参数的名称。
调用函数时,必须按顺序指定这些位置参数,不能遗漏或跳过任何位置参数。这是我们在前面的示例中指定参数的最常见方式:
def mandatory_args(x, y, z):
pass称之为:
mandatory_args("a string", a_variable, 5)任何类型的对象都可以作为参数传递:对象、容器、原语,甚至函数和类。前面的调用显示一个硬编码字符串、一个未知变量和一个传递到函数中的整数。
如果我们想要使一个参数成为可选的,而不是创建具有不同参数集的第二个方法,我们可以使用等号在单个方法中指定默认值。如果调用代码不提供此参数,将为其指定默认值。但是,调用代码仍然可以选择通过传递不同的值来覆盖默认值。通常,默认值None或空字符串或列表是合适的。
以下是带有默认参数的函数定义:
def default_arguments(x, y, z, a="Some String", b=False):
pass前三个参数仍然是必需的,必须由调用代码传递。最后两个参数提供了默认参数。
有几种方法可以调用此函数。我们可以按顺序提供所有参数,就像所有参数都是位置参数一样:
default_arguments("a string", variable, 8, "", True)或者,我们可以按顺序提供强制参数,将关键字参数指定为其默认值:
default_arguments("a longer string", some_variable, 14)我们还可以在调用函数时使用等号语法以不同的顺序提供值,或者跳过我们不感兴趣的默认值。例如,我们可以跳过第一个关键字参数并提供第二个关键字参数:
default_arguments("a string", variable, 14, b=True)令人惊讶的是,我们甚至可以使用等号语法来混淆位置参数的顺序,只要提供了所有这些参数:
>>> default_arguments(y=1,z=2,x=3,a="hi")
3 1 2 hi False有这么多选项,可能很难选择一个,但是如果你认为位置参数是一个有序的列表,关键字参数有点像字典,你会发现正确的布局往往是合适的。如果需要要求调用方指定参数,请将其设置为强制参数;如果您有一个合理的默认值,那么将其设置为关键字参数。选择如何调用该方法通常会自行处理,具体取决于需要提供哪些值以及哪些值可以保留为默认值。
关键字参数需要注意的一点是,我们作为默认参数提供的任何内容都是在首次解释函数时计算的,而不是在调用函数时计算的。这意味着我们不能动态生成默认值。例如,以下代码的行为与预期不符:
number = 5
def funky_function(number=number):
print(number)
number=6
funky_function(8)
funky_function()
print(number)如果我们运行这段代码,它会先输出数字 8,然后为调用输出数字 5,不带任何参数。我们已经将变量设置为数字 6,正如输出的最后一行所证明的那样,但是当调用函数时,会打印数字 5;默认值是在定义函数时计算的,而不是在调用函数时计算的。
对于空容器,如列表、集合和字典,这是很棘手的。例如,通常要求调用代码提供函数将要处理的列表,但该列表是可选的。我们希望将空列表作为默认参数。我们不能这样做;当首次构造代码时,它将只创建一个列表:
>>> def hello(b=[]):
... b.append('a')
... print(b)
...
>>> hello()
['a']
>>> hello()
['a', 'a']哎呀,这可不是我们所期望的!解决这个问题的通常方法是设置默认值None,然后在方法内部使用习惯用法iargument = argument if argument else []。密切注意!
仅默认值不允许我们获得方法重载的所有灵活好处。使 Python 真正灵巧的是,它能够编写接受任意数量的位置参数或关键字参数的方法,而无需显式命名它们。我们还可以将任意列表和字典传递到此类函数中。
例如,接受链接或链接列表并下载网页的函数可以使用这种可变参数,或varargs。我们可以接受任意数量的参数,其中每个参数都是不同的链接,而不是接受一个链接列表的值。我们通过在函数定义中指定*运算符来实现:
def get_pages(*links):
for link in links:
#download the link with urllib
print(link)*links参数表示“我将接受任意数量的参数,并将它们全部放在名为links的列表中”。如果我们只提供一个参数,它将是一个包含一个元素的列表;如果我们不提供参数,它将是一个空列表。因此,所有这些函数调用都是有效的:
get_pages()
get_pages('http://www.archlinux.org')
get_pages('http://www.archlinux.org',
'http://ccphillips.net/')我们也可以接受任意关键字参数。它们作为一本词典发挥作用。它们在函数声明中用两个星号指定(如**kwargs中所示)。此工具通常用于配置设置。下面的类允许我们使用默认值指定一组选项:
class Options:
default_options = {
'port': 21,
'host': 'localhost',
'username': None,
'password': None,
'debug': False,
}
def __init__(self, **kwargs):
self.options = dict(Options.default_options)
self.options.update(kwargs)
def __getitem__(self, key):
return self.options[key]本课程中所有有趣的内容都发生在__init__方法中。我们在类级别有一个默认选项和值的字典。__init__方法做的第一件事就是复制这本词典。我们这样做,而不是直接修改字典,以防实例化两个单独的选项集。(记住,类级别的变量在类的实例之间共享。)然后,__init__使用新字典上的update方法将任何非默认值更改为作为关键字参数提供的值。__getitem__方法只允许我们使用索引语法使用新类。下面是一个演示课堂活动的课程:
>>> options = Options(username="dusty", password="drowssap",
debug=True)
>>> options['debug']
True
>>> options['port']
21
>>> options['username']
'dusty'我们可以使用字典索引语法访问 options 实例,字典包括默认值和使用关键字参数设置的值。
关键字参数语法可能很危险,因为它可能会打破“显式优于隐式”规则。在前面的示例中,可以将任意关键字参数传递给Options初始值设定项,以表示默认字典中不存在的选项。这可能不是一件坏事,这取决于类的用途,但这使得使用该类的人很难发现可用的有效选项。它还可以很容易地输入令人困惑的输入错误(“例如,Debug”而不是“Debug”),在只有一个选项的地方添加两个选项。
当我们需要接受任意参数以传递给第二个函数时,关键字参数也非常有用,但我们不知道这些参数是什么。我们在第 3 章中看到了这一点,当对象相似时,当我们构建对多重继承的支持时。当然,我们可以在一个函数调用中组合变量参数和变量关键字参数语法,也可以使用普通的位置参数和默认参数。下面的示例有些做作,但演示了四种类型的操作:
import shutil
import os.path
def augmented_move(target_folder, *filenames,
verbose=False, **specific):
'''Move all filenames into the target_folder, allowing
specific treatment of certain files.'''
def print_verbose(message, filename):
'''print the message only if verbose is enabled'''
if verbose:
print(message.format(filename))
for filename in filenames:
target_path = os.path.join(target_folder, filename)
if filename in specific:
if specific[filename] == 'ignore':
print_verbose("Ignoring {0}", filename)
elif specific[filename] == 'copy':
print_verbose("Copying {0}", filename)
shutil.copyfile(filename, target_path)
else:
print_verbose("Moving {0}", filename)
shutil.move(filename, target_path)此示例将处理任意文件列表。第一个参数是目标文件夹,默认行为是将所有剩余的非关键字参数文件移动到该文件夹中。然后有一个只包含关键字的参数verbose,它告诉我们是否要打印处理过的每个文件的信息。最后,我们可以提供一个字典,其中包含对特定文件名执行的操作;默认行为是移动文件,但如果在关键字参数中指定了有效的字符串操作,则可以忽略或复制该操作。注意函数中参数的顺序;首先指定位置参数,然后指定*filenames列表,然后指定任何特定的只包含关键字的参数,最后指定一个**specific字典来保存剩余的关键字参数。
我们创建了一个内部助手函数print_verbose,只有设置了verbose键,它才会打印消息。此函数通过将此功能封装到单个位置来保持代码可读性。
在常见情况下,假设存在相关文件,此函数可调用为:
>>> augmented_move("move_here", "one", "two")此命令将文件one和two移动到move_here目录中,假设它们存在(函数中没有错误检查或异常处理,因此如果文件或目标目录不存在,它将异常失败)。移动将在没有任何输出的情况下发生,因为默认情况下verbose是False。
如果我们想查看输出,可以通过以下方式调用:
>>> augmented_move("move_here", "three", verbose=True)
Moving three这会移动一个名为three的文件,并告诉我们它在做什么。注意,在本例中,不可能将verbose指定为位置参数;我们必须传递一个关键字参数。否则,Python 会认为它是*filenames列表中的另一个文件名。
如果要复制或忽略列表中的某些文件,而不是移动它们,则可以传递其他关键字参数:
>>> augmented_move("move_here", "four", "five", "six",
four="copy", five="ignore")这将移动第六个文件并复制第四个文件,但不会显示任何输出,因为我们没有指定verbose。当然,我们也可以这样做,关键字参数可以按任意顺序提供:
>>> augmented_move("move_here", "seven", "eight", "nine",
seven="copy", verbose=True, eight="ignore")
Copying seven
Ignoring eight
Moving nine还有一个更巧妙的技巧,涉及变量参数和关键字参数。我们已经在前面的一些例子中使用了它,但是解释它永远不会太迟。给定一个值列表或字典,我们可以将这些值传递到函数中,就像它们是普通的位置参数或关键字参数一样。请查看以下代码:
def show_args(arg1, arg2, arg3="THREE"):
print(arg1, arg2, arg3)
some_args = range(3)
more_args = {
"arg1": "ONE",
"arg2": "TWO"}
print("Unpacking a sequence:", end=" ")
show_args(*some_args)
print("Unpacking a dict:", end=" ")
show_args(**more_args)下面是我们运行它时的外观:
Unpacking a sequence: 0 1 2
Unpacking a dict: ONE TWO THREE该函数接受三个参数,其中一个具有默认值。但是当我们有一个包含三个参数的列表时,我们可以在函数调用中使用*操作符将其解压为三个参数。如果我们有一个参数字典,我们可以使用**语法将其解包为关键字参数的集合。
将从用户输入或外部源(例如,Internet 页面或文本文件)收集的信息映射到函数或方法调用时,这通常非常有用。
还记得我们前面的示例吗?它使用文本文件中的标题和行来创建包含联系人信息的词典列表?不只是将字典添加到列表中,我们可以使用关键字解包将参数传递给一个特殊构建的接受相同参数集的Contact对象上的__init__方法。看看你是否能修改这个例子使之生效。
过分强调面向对象原则的编程语言往往不喜欢非方法的函数。在这样的语言中,您需要创建一个对象来包装所涉及的单个方法。在很多情况下,我们希望传递一个小对象,这个小对象只是被调用来执行一个操作。这在事件驱动编程中最常见,如图形工具包或异步服务器;我们将在第 10 章、Python 设计模式一和第 11 章、Python 设计模式二中看到一些使用它的设计模式。
在 Python 中,我们不需要将这些方法包装到对象中,因为函数已经是对象了!我们可以在函数上设置属性(虽然这不是一个常见的活动),我们可以将它们传递给以后调用。它们甚至有一些可以直接访问的特殊属性。下面是另一个人为的例子:
def my_function():
print("The Function Was Called")
my_function.description = "A silly function"
def second_function():
print("The second was called")
second_function.description = "A sillier function."
def another_function(function):
print("The description:", end=" ")
print(function.description)
print("The name:", end=" ")
print(function.__name__)
print("The class:", end=" ")
print(function.__class__)
print("Now I'll call the function passed in")
function()
another_function(my_function)
another_function(second_function)如果我们运行这段代码,我们可以看到我们能够将两个不同的函数传递到第三个函数中,并为每个函数获得不同的输出:
The description: A silly function
The name: my_function
The class: <class 'function'>
Now I'll call the function passed in
The Function Was Called
The description: A sillier function.
The name: second_function
The class: <class 'function'>
Now I'll call the function passed in
The second was called我们在函数上设置了一个属性,名为description(诚然,不是很好的描述)。我们还能够看到函数的__name__属性,并访问它的类,证明函数确实是一个具有属性的对象。然后我们使用可调用语法(括号)调用该函数。
函数是顶级对象这一事实最常被用来传递它们,以便在以后执行,例如,当某个条件得到满足时。让我们构建一个事件驱动计时器,它只执行以下操作:
import datetime
import time
class TimedEvent:
def __init__(self, endtime, callback):
self.endtime = endtime
self.callback = callback
def ready(self):
return self.endtime <= datetime.datetime.now()
class Timer:
def __init__(self):
self.events = []
def call_after(self, delay, callback):
end_time = datetime.datetime.now() + \
datetime.timedelta(seconds=delay)
self.events.append(TimedEvent(end_time, callback))
def run(self):
while True:
ready_events = (e for e in self.events if e.ready())
for event in ready_events:
event.callback(self)
self.events.remove(event)
time.sleep(0.5)在生产中,这段代码肯定会有使用 docstring 的额外文档!call_after方法至少应该提到delay参数以秒为单位,callback函数应该接受一个参数:进行调用的计时器。
我们这里有两门课。TimedEvent类实际上并不意味着其他类可以访问;它所做的只是存储endtime和callback。我们甚至可以在这里使用一个tuple或namedtuple,但因为给对象一个行为来告诉我们事件是否准备好运行是很方便的,所以我们使用一个类来代替。
Timer类只存储即将发生的事件的列表。它有一个call_after方法来添加新事件。此方法接受表示在执行回调之前等待的秒数的delay参数和callback函数本身:在正确时间执行的函数。此callback函数应接受一个参数。
run方法非常简单;它使用一个生成器表达式过滤掉任何时间已到的事件,并按顺序执行它们。然后,计时器循环无限期地继续,因此必须通过键盘中断(Ctrl+C或Ctrl+中断来中断计时器循环)。每次迭代后,我们都会睡眠半秒钟,以免系统陷入停顿。
这里需要注意的重要事项是涉及回调函数的行。函数像任何其他对象一样传递,计时器永远不知道或关心函数的原始名称是什么,也不关心函数的定义位置。当调用该函数时,计时器只对存储的变量应用括号语法。
下面是一组测试计时器的回调:
from timer import Timer
import datetime
def format_time(message, *args):
now = datetime.datetime.now().strftime("%I:%M:%S")
print(message.format(*args, now=now))
def one(timer):
format_time("{now}: Called One")
def two(timer):
format_time("{now}: Called Two")
def three(timer):
format_time("{now}: Called Three")
class Repeater:
def __init__(self):
self.count = 0
def repeater(self, timer):
format_time("{now}: repeat {0}", self.count)
self.count += 1
timer.call_after(5, self.repeater)
timer = Timer()
timer.call_after(1, one)
timer.call_after(2, one)
timer.call_after(2, two)
timer.call_after(4, two)
timer.call_after(3, three)
timer.call_after(6, three)
repeater = Repeater()
timer.call_after(5, repeater.repeater)
format_time("{now}: Starting")
timer.run()此示例允许我们查看多个回调如何与计时器交互。第一个函数是format_time函数。它使用 stringformat方法将当前时间添加到消息中,并说明了变量参数的作用。format_time方法将使用变量参数语法接受任意数量的位置参数,然后将这些参数作为位置参数转发给字符串的format方法。在此之后,我们创建了三个简单的回调方法,它们只输出当前时间和一条短消息,告诉我们哪个回调被触发。
Repeater类演示了方法也可以用作回调,因为它们实际上只是函数。它还显示了回调函数的timer参数有用的原因:我们可以从当前运行的回调中向计时器添加一个新的定时事件。然后,我们创建一个计时器,并向其中添加几个事件,这些事件在不同的时间后被调用。最后,我们启动定时器运行;输出显示事件按预期顺序运行:
02:53:35: Starting
02:53:36: Called One
02:53:37: Called One
02:53:37: Called Two
02:53:38: Called Three
02:53:39: Called Two
02:53:40: repeat 0
02:53:41: Called Three
02:53:45: repeat 1
02:53:50: repeat 2
02:53:55: repeat 3
02:54:00: repeat 4Python3.4 引入了类似于此的通用事件循环体系结构。稍后我们将在第 13 章、并发中讨论。
函数作为对象的一个有趣效果是,它们可以设置为其他对象上的可调用属性。可以向实例化对象添加或更改函数:
class A:
def print(self):
print("my class is A")
def fake_print():
print("my class is not A")
a = A()
a.print()
a.print = fake_print
a.print()这段代码创建了一个非常简单的类,其中包含一个print方法,它不会告诉我们任何我们不知道的事情。然后我们创建一个新函数,它告诉我们一些我们不相信的东西。
当我们对A类的实例调用print时,它的行为与预期一致。如果我们将print方法设置为指向一个新函数,它会告诉我们一些不同的信息:
my class is A
my class is not A也可以替换类而不是对象上的方法,尽管在这种情况下,我们必须将self参数添加到参数列表中。这将更改该对象的所有实例的方法,即使是已经实例化的实例。显然,替换这样的方法既危险又容易混淆。阅读代码的人会看到调用了一个方法,并在原始类中查找该方法。但是原始类上的方法不是调用的方法。弄清楚到底发生了什么可能会成为一个棘手、令人沮丧的调试过程。
但它确实有它的用途。通常,在自动测试中使用在运行时替换或添加方法(称为monkey patching)。如果测试客户机-服务器应用程序,我们可能不希望在测试客户机时实际连接到服务器;这可能会导致资金的意外转移,或将令人尴尬的测试电子邮件发送给真实的人。相反,我们可以设置测试代码来替换向服务器发送请求的对象上的一些关键方法,因此它只记录调用了这些方法。
Monkey patching 还可以用于修复我们正在与之交互的第三方代码中的错误或添加功能,并且其行为方式与我们需要的方式不完全相同。然而,它应该谨慎地应用;这几乎总是一个“混乱的黑客”。但有时,这是使现有图书馆适应我们需要的唯一途径。
正如函数是可以设置属性的对象,也可以创建一个可以像函数一样调用的对象。
任何对象都可以通过简单地给它一个接受所需参数的__call__方法来调用。让我们通过使Repeater类成为一个可调用的类,使我们的Repeater类从计时器示例中变得更易于使用:
class Repeater:
def __init__(self):
self.count = 0
def __call__(self, timer):
format_time("{now}: repeat {0}", self.count)
self.count += 1
timer.call_after(5, self)
timer = Timer()
timer.call_after(5, Repeater())
format_time("{now}: Starting")
timer.run()这个例子与前面的课程没有太大区别;我们所做的只是将repeater函数的名称更改为__call__,并将对象本身作为可调用对象传递。注意,当我们进行call_after调用时,我们传递参数Repeater()。这两个括号正在创建该类的新实例;它们没有显式地调用该类。这将在计时器内稍后发生。如果我们想在一个新实例化的对象上执行__call__方法,我们会使用一种非常奇怪的语法:Repeater()()。第一组括号构成对象;第二组执行__call__方法。如果我们发现自己在这样做,我们可能没有使用正确的抽象。如果要将对象视为函数,则仅在对象上实现__call__函数。
为了将本章介绍的一些原则结合起来,让我们构建一个邮件列表管理器。经理将跟踪分类为命名组的电子邮件地址。当需要发送邮件时,我们可以选择一个组并将邮件发送到分配给该组的所有电子邮件地址。
现在,在我们开始这个项目之前,我们应该有一个安全的方法来测试它,而不必向一群真实的人发送电子邮件。幸运的是,Python 有我们的支持;与测试 HTTP 服务器一样,它有一个内置的简单邮件传输协议(SMTP)服务器,我们可以指示它捕获我们发送的任何邮件,而不实际发送它们。我们可以使用以下命令运行服务器:
python -m smtpd -n -c DebuggingServer localhost:1025在命令提示符下运行此命令将启动本地计算机上端口 1025 上运行的 SMTP 服务器。但我们已经指示它使用DebuggingServer类(它附带内置 SMTP 模块),它不向预期的收件人发送邮件,而是在接收邮件时在终端屏幕上打印邮件。整洁,嗯?
现在,在编写邮件列表之前,让我们编写一些实际发送邮件的代码。当然,Python 在标准库中也支持这一点,但这是一个有点奇怪的接口,因此我们将编写一个新函数来清晰地包装它:
import smtplib
from email.mime.text import MIMEText
def send_email(subject, message, from_addr, *to_addrs,
host="localhost", port=1025, **headers):
email = MIMEText(message)
email['Subject'] = subject
email['From'] = from_addr
for header, value in headers.items():
email[header] = value
sender = smtplib.SMTP(host, port)
for addr in to_addrs:
del email['To']
email['To'] = addr
sender.sendmail(from_addr, addr, email.as_string())
sender.quit()我们不会将此方法中的代码覆盖得太彻底;标准库中的文档可以为您提供有效使用smtplib和email模块所需的所有信息。
我们在函数调用中使用了变量参数和关键字参数语法。变量参数列表允许我们在具有单个to地址的默认情况下提供单个字符串,并允许在需要时提供多个地址。任何额外的关键字参数都映射到电子邮件头。这是变量参数和关键字参数令人兴奋的用法,但对于调用函数的人来说,它并不是一个很好的接口。事实上,它使程序员想做的许多事情变得不可能。
传递到函数中的头表示可以附加到方法的辅助头。此类标题可能包括Reply-To、Return-Path或X-几乎任何东西。但为了成为 Python 中的有效标识符,名称不能包含-字符。通常,该字符表示减法。因此,不可能使用Reply-To = my@email.com调用函数。我们似乎太渴望使用关键字参数了,因为它们是我们在本章中刚刚了解的新工具。
我们必须将参数更改为普通字典;这将起作用,因为任何字符串都可以用作字典中的键。默认情况下,我们希望此字典为空,但不能将默认参数设置为空字典。因此,我们必须设置默认参数None,然后在方法的开头设置字典:
def send_email(subject, message, from_addr, *to_addrs,
host="localhost", port=1025, headers=None):
headers = {} if headers is None else headers如果我们在一个终端上运行调试 SMTP 服务器,我们可以在 Python 解释器中测试此代码:
>>> send_email("A model subject", "The message contents",
"from@example.com", "to1@example.com", "to2@example.com")然后,如果我们检查调试 SMTP 服务器的输出,我们会得到以下结果:
---------- MESSAGE FOLLOWS ----------
Content-Type: text/plain; charset="us-ascii"
MIME-Version: 1.0
Content-Transfer-Encoding: 7bit
Subject: A model subject
From: from@example.com
To: to1@example.com
X-Peer: 127.0.0.1
The message contents
------------ END MESSAGE ------------
---------- MESSAGE FOLLOWS ----------
Content-Type: text/plain; charset="us-ascii"
MIME-Version: 1.0
Content-Transfer-Encoding: 7bit
Subject: A model subject
From: from@example.com
To: to2@example.com
X-Peer: 127.0.0.1
The message contents
------------ END MESSAGE ------------很好,它已将我们的电子邮件“发送”到两个预期地址,其中包括主题和消息内容。现在我们可以发送消息了,让我们来研究一下电子邮件组管理系统。我们需要一个能够以某种方式将电子邮件地址与其所在的组匹配的对象。由于这是一种多对多关系(任何一个电子邮件地址都可以在多个组中;任何一个组都可以与多个电子邮件地址关联),因此我们研究的数据结构似乎都不太理想。我们可以尝试将组名字典与相关电子邮件地址列表相匹配,但这会导致电子邮件地址重复。我们还可以尝试使用与组匹配的电子邮件地址字典,从而导致组的重复。两者似乎都不是最优的。让我们试试后一个版本,尽管直觉告诉我,组到电子邮件地址的解决方案会更简单。
因为我们字典中的值总是唯一电子邮件地址的集合,所以我们可能应该将它们存储在set容器中。我们可以使用defaultdict来确保每把钥匙都有一个set容器:
from collections import defaultdict
class MailingList:
'''Manage groups of e-mail addresses for sending e-mails.'''
def __init__(self):
self.email_map = defaultdict(set)
def add_to_group(self, email, group):
self.email_map[email].add(group)现在,让我们添加一个方法,允许我们收集一个或多个组中的所有电子邮件地址。这可以通过将组列表转换为集合来实现:
def emails_in_groups(self, *groups):
groups = set(groups)
emails = set()
for e, g in self.email_map.items():
if g & groups:
emails.add(e)
return emails首先,看看我们在迭代什么:self.email_map.items()。当然,这个方法为字典中的每个项返回键值对的元组。这些值是表示组的字符串集。我们将其分为两个变量,分别命名为e和g,是电子邮件和组的缩写。仅当传入的组与电子邮件地址组相交时,我们才将电子邮件地址添加到返回值集中。g & groups语法是g.intersection(groups)的快捷方式;set类通过实现调用intersection的特殊__and__方法来实现这一点。
使用集合理解可以使代码更简洁,我们将在第 9 章、迭代器模式中讨论。
现在,有了这些构建块,我们可以简单地向MailingList类添加一个方法,向特定组发送消息:
def send_mailing(self, subject, message, from_addr,
*groups, headers=None):
emails = self.emails_in_groups(*groups)
send_email(subject, message, from_addr,
*emails, headers=headers)此函数依赖于变量参数列表。作为输入,它将组列表作为变量参数。它获取指定组的电子邮件列表,并将其作为变量参数传递到send_email中,以及传递到此方法中的其他参数。
通过确保 SMTP 调试服务器在一个命令提示符下运行,并在第二个提示符下使用以下命令加载代码,可以测试该程序:
python -i mailing_list.py使用以下内容创建一个MailingList对象:
>>> m = MailingList()然后创建几个假电子邮件地址和组,大致如下:
>>> m.add_to_group("friend1@example.com", "friends")
>>> m.add_to_group("friend2@example.com", "friends")
>>> m.add_to_group("family1@example.com", "family")
>>> m.add_to_group("pro1@example.com", "professional")最后,使用如下命令向特定组发送电子邮件:
>>> m.send_mailing("A Party",
"Friends and family only: a party", "me@example.com", "friends",
"family", headers={"Reply-To": "me2@example.com"})发送到指定组中每个地址的电子邮件应显示在 SMTP 服务器的控制台中。
邮件列表虽然可以正常工作,但有点没用;一旦我们退出程序,我们的信息数据库就丢失了。让我们对其进行修改,以添加两个方法来加载和保存文件中的电子邮件组列表。
一般来说,在磁盘上存储结构化数据时,最好考虑如何存储结构化数据。无数数据库系统存在的原因之一是,如果其他人将此思想应用于数据的存储方式,您就不必这样做。我们将在下一章中介绍一些数据序列化机制,但是对于这个示例,让我们保持简单,并使用第一个可能有效的解决方案。
我心目中的数据格式是存储每个电子邮件地址,后跟一个空格,后跟一个逗号分隔的组列表。这种格式似乎合理,我们将继续使用它,因为数据格式不是本章的主题。然而,为了说明为什么您需要认真考虑如何格式化磁盘上的数据,让我们强调一下格式化的几个问题。
首先,从技术上讲,空格字符在电子邮件地址中是合法的。大多数电子邮件提供商都禁止它(有很好的理由),但定义电子邮件地址的规范说,如果电子邮件中有引号,则可以包含空格。如果我们要在数据格式中将某个空间用作哨兵,那么从技术上讲,我们应该能够区分该空间和作为电子邮件一部分的空间。为了简单起见,我们将假装这不是真的,但现实生活中的数据编码充满了这样愚蠢的问题。其次,考虑逗号分隔的组列表。如果有人决定在组名中加逗号,会发生什么?如果我们决定在组名中使用逗号,我们应该添加验证以确保我们的add_to_group方法中使用逗号。为了教学清晰,我们也将忽略这个问题。最后,我们需要考虑许多安全问题:在电子邮件地址中添加假逗号会不会让自己进入错误的组?如果解析器遇到无效文件,它会做什么?
本讨论的要点是尝试使用经过现场测试的数据存储方法,而不是设计自己的数据序列化协议。您可能会忽略很多奇怪的边缘情况,最好使用已经遇到并修复这些边缘情况的代码。
但请忘记这一点,让我们编写一些基本代码,用不健康的一厢情愿来假装这种简单的数据格式是安全的:
email1@mydomain.com group1,group2
email2@mydomain.com group2,group3执行此操作的代码如下所示:
def save(self):
with open(self.data_file, 'w') as file:
for email, groups in self.email_map.items():
file.write(
'{} {}\n'.format(email, ','.join(groups))
)
def load(self):
self.email_map = defaultdict(set)
try:
with open(self.data_file) as file:
for line in file:
email, groups = line.strip().split(' ')
groups = set(groups.split(','))
self.email_map[email] = groups
except IOError:
pass在save方法中,我们在上下文管理器中打开文件,并将文件作为格式化字符串写入。记住换行符;Python 并没有为我们添加这一点。load方法首先重置字典(如果它包含以前调用load的数据),然后使用for。。。in语法,循环文件中的每一行。同样,换行符包含在 line 变量中,因此我们必须调用.strip()将其去掉。在下一章中,我们将了解有关此类字符串操作的更多信息。
在使用这些方法之前,我们需要确保对象具有self.data_file属性,这可以通过修改__init__来完成:
def __init__(self, data_file):
self.data_file = data_file
self.email_map = defaultdict(set)我们可以在解释器中测试这两种方法,如下所示:
>>> m = MailingList('addresses.db')
>>> m.add_to_group('friend1@example.com', 'friends')
>>> m.add_to_group('family1@example.com', 'friends')
>>> m.add_to_group('family1@example.com', 'family')
>>> m.save()生成的addresses.db文件如预期的那样包含以下行:
friend1@example.com friends
family1@example.com friends,family我们还可以成功地将此数据加载回MailingList对象:
>>> m = MailingList('addresses.db')
>>> m.email_map
defaultdict(<class 'set'>, {})
>>> m.load()
>>> m.email_map
defaultdict(<class 'set'>, {'friend2@example.com': {'friends\n'}, 'family1@example.com': {'family\n'}, 'friend1@example.com': {'friends\n'}})正如您所看到的,我忘记了执行load命令,而且可能很容易忘记save命令。为了让任何想在自己的代码中使用我们的MailingListAPI 的人都能更轻松地完成这项工作,让我们提供支持上下文管理器的方法:
def __enter__(self):
self.load()
return self
def __exit__(self, type, value, tb):
self.save()这些简单的方法只是将它们的工作委托给加载和保存,但我们现在可以在交互式解释器中编写这样的代码,并且知道以前存储的所有地址都是代表我们加载的,完成后整个列表将保存到文件中:
>>> with MailingList('addresses.db') as ml:
... ml.add_to_group('friend2@example.com', 'friends')
... ml.send_mailing("What's up", "hey friends, how's it going", 'me@example.com', 'friends')如果您以前没有遇到过with语句和上下文管理器,我建议您像往常一样仔细检查您的旧代码,找到打开文件的所有位置,并确保使用with语句安全地关闭它们。寻找可以编写自己的上下文管理器的地方。丑陋的或重复的try。。。finally从句是一个很好的起点,但您可能会发现它们在上下文中任务之前和/或之后的任何时候都很有用。
您以前可能使用过许多基本的内置函数。我们介绍了其中的几个,但没有详细介绍。玩enumerate、zip、reversed、any和all,直到你知道当它们是适合工作的工具时,你会记得使用它们。enumerate功能尤为重要;因为不使用它会导致一些非常难看的代码。
还可以探索一些将函数作为可调用对象传递的应用程序,以及使用__call__方法使您自己的对象可调用的应用程序。通过将属性附加到函数或在对象上创建__call__方法,可以获得相同的效果。在哪种情况下,您会使用一种语法,什么时候使用另一种更合适?
如果要发送大量电子邮件,我们的邮件列表对象可能会淹没电子邮件服务器。试着重构它,这样你就可以为不同的目的使用不同的send_email函数。一个这样的函数可以是我们在这里使用的版本。不同的版本可能会将电子邮件放入队列中,由不同线程或进程中的服务器发送。第三个版本只需将数据输出到终端,就不需要虚拟 SMTP 服务器。你能用回调构造邮件列表吗?send_mailing函数使用传入的任何内容?如果未提供回调,则默认为当前版本。
参数、关键字参数、变量参数和变量关键字参数之间的关系可能有点混乱。当我们谈到多重继承时,我们看到了它们之间的交互是多么痛苦。设计一些其他的例子,看看他们如何能很好地合作,以及了解他们什么时候不能。
在本章中,我们涵盖了大量的主题。每一个都代表了 Python 中流行的一个重要的非面向对象特性。仅仅因为我们可以使用面向对象的原则并不总是意味着我们应该这样做!
然而,我们还看到 Python 通常通过提供传统面向对象语法的语法快捷方式来实现这些特性。了解这些工具背后的面向对象原则可以让我们在自己的类中更有效地使用它们。
我们讨论了一系列内置函数和文件 I/O 操作。在使用参数、关键字参数和变量参数列表调用函数时,我们可以使用大量不同的语法。上下文管理器对于将一段代码夹在两个方法调用之间的常见模式非常有用。甚至函数也是对象,相反,任何普通对象都可以被调用。
在下一章中,我们将学习更多关于字符串和文件操作的知识,甚至花一些时间学习标准库中最不面向对象的主题之一:正则表达式。