Python 真实世界的数据科学(三)
八、Python 面向对象的快捷方式
Python 的许多方面似乎比面向对象的编程更让人联想到结构或功能编程。 尽管在过去的二十年中,面向对象的编程一直是最明显的范例,但是旧模型却在最近复苏了。 与 Python 的数据结构一样,大多数这些工具都是基于底层面向对象实现的语法糖。 我们可以将它们视为基于(已经抽象的)面向对象范式之上的进一步抽象层。 在本章中,我们将介绍并非严格面向对象的 Python 功能:
- 内置功能可一键处理常见任务
- 文件 I / O 和上下文管理器
- 方法重载的替代方法
- 作为对象
Python 内置函数
Python 中有个函数,它们可以对某些类型的对象执行任务或计算结果,而无需作为基础类的方法。 他们通常抽象适用于多种类型的类的通用计算。 这是最好的鸭子打字方式。 这些函数接受具有某些属性或方法的对象,并能够使用这些方法执行常规操作。 其中许多(但不是全部)是特殊的双下划线方法。 我们已经使用了许多内置函数,但是让我们快速浏览重要的函数,并逐步学习一些巧妙的技巧。
len()函数
最简单的示例是len()函数,该函数计算某种容器对象(例如字典或列表)中的项目数。 您之前看过:
>>> len([1,2,3,4])
4
为什么这些对象不具有 length 属性,而不必在其上调用函数? 从技术上讲,他们做到了。 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与所有序列一样,都是从零开始的,所以它会为每个行号的索引加 1。
我们只涉及了一些更重要的 Python 内置函数。 如您所见,其中许多调用了面向对象的概念,而另一些则订阅了纯粹的功能或过程范例。 标准库中还有许多其他内容。 一些更有趣的包括:
all和any,它们接受一个可迭代的对象并返回True,如果所有或任何一项的计算结果为 true(例如,非空字符串或列表,非零数字,不是None的对象 ]或文字True)。eval,exec和compile,它们在解释器内部将字符串作为代码执行。 小心这些。 它们不安全,因此请不要执行未知用户提供给您的代码(通常,假定所有未知用户都是恶意的,愚蠢的,或两者兼有)。hasattr,getattr,setattr和delattr,它们可以通过对象的字符串名称来操纵对象上的属性。zip,它接受两个或更多序列,并返回一个新的元组序列,其中每个元组都包含来自每个序列的单个值。- 还有很多! 有关
dir(__builtins__)中列出的每个功能,请参见解释器帮助文档。
文件 I / O
到目前为止,我们的示例已完全在文本文件上操作了触摸文件系统,而没有过多考虑幕后情况。 但是,操作系统实际上将文件表示为字节序列,而不是文本。 请注意,从文件读取文本数据是一个相当复杂的过程。 Python,尤其是 Python 3,在幕后为我们完成了大部分工作。 我们不是很幸运吗?
文件的概念早在任何人创造面向对象编程这个术语之前就已经存在。 但是,Python 将操作系统提供的接口包装成了一个甜美的抽象,使我们能够处理文件(或类似文件,相对于鸭子输入)对象。
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 是操作系统必须处理的基本工作之一,并且所有编程语言都必须使用相同的系统调用与 OS 进行通信。 很高兴 Python 返回具有有用方法的文件对象,而不是大多数主要操作系统用来标识文件句柄的整数!
打开文件以供阅读后,我们可以调用read,readline或readlines方法来获取文件的内容。 read方法根据模式中是否存在'b',将文件的全部内容作为str或bytes对象返回。 注意不要在没有大文件的情况下使用此方法。 您不想弄清楚如果尝试将那么多数据加载到内存中会发生什么!
也可以从文件中读取固定数量的字节。 我们将一个整数参数传递给read方法,以描述要读取的字节数。 下次调用read将加载下一个字节序列,依此类推。 我们可以在while循环中执行此操作,以可管理的块形式读取整个文件。
readline方法从文件返回一行(其中每一行以换行符结尾,回车符或两者都结束,这取决于在其上创建文件的操作系统)。 我们可以反复调用它以获得更多行。 复数readlines方法返回文件中所有行的列表。 像read方法一样,在非常大的文件上使用也不安全。 这两种方法甚至都可以在bytes模式下打开文件时起作用,但是只有当我们分析在适当位置具有换行符的类似文本的数据时,这才有意义。 例如,图像或音频文件中将没有换行符(除非换行字节恰好代表某个像素或声音),因此应用readline毫无意义。
为了提高可读性,并避免一次将大文件读入内存,通常最好直接在文件对象上使用for循环。 对于文本文件,它将一次读取每一行,我们可以在循环体内对其进行处理。 对于二进制文件,最好使用read()方法读取固定大小的数据块,并传递最大读取字节数的参数。
写入文件同样容易。 文件对象上的write方法将字符串(或字节,对于二进制数据)对象写入文件。 可以反复调用以写入多个字符串,一个接一个。 writelines方法接受字符串序列,并将每个迭代值写入文件。 writelines方法不会而不是在序列中的每个项目之后添加新行。 从根本上来说,方便的函数是一个不好命名的函数,它可以编写字符串序列的内容,而不必使用for循环对其进行显式迭代。
最后,我的意思是,最后,我们来谈谈close方法。 当我们完成文件的读取或写入后,应调用此方法,以确保将所有缓冲的写操作写入磁盘,已正确清理了文件以及与该文件关联的所有资源都释放回了操作系统。 。 从技术上讲,这将在脚本退出时自动发生,但最好是显式并自行清理,尤其是在长时间运行的进程中。
将其放在上下文中
需要在完成文件处理后关闭文件,这会使我们的代码很难看。 由于在文件 I / O 期间随时可能发生异常,因此我们应该将对文件的所有调用包装在try ... finally子句中。 不管 I / O 是否成功,都应在finally子句中关闭该文件。 这不是非常 Pythonic。 当然,还有一种更优雅的方法。
如果在类似文件的对象上运行dir,我们会看到它有两个名为__enter__和__exit__的特殊方法。 这些方法将文件对象转换为所谓的上下文管理器。 基本上,如果我们使用称为with语句的特殊语法,则将在执行嵌套代码之前和之后调用这些方法。 在文件对象上,__exit__方法可确保关闭文件,即使引发异常也是如此。 我们不再需要显式管理文件的关闭。 实际上,这是with语句的样子:
with open('filename') as file:
for line in file:
print(line, end='')
open调用返回一个文件对象,该对象具有__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属性在连接器对象上变为可用。 我们打印该值以查看随机字符串。
方法重载的替代方法
许多面向对象编程语言中的一个突出特征是称为方法重载的工具。 方法重载只是指具有多个名称相同的方法,它们接受不同的参数集。 在静态类型的语言中,如果我们想要一个可以接受整数或字符串的方法,这将非常有用。 在非面向对象的语言中,我们可能需要两个函数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;否则,将输出 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初始化程序,以表示默认字典中不存在的选项。 根据类的目的,这可能不是一件坏事,但这使使用该类的人很难发现可用的有效选项。 这也使输入混乱的错字(例如,“ Debug”而不是“ debug”)变得容易,该错字在只有一个应有的地方添加了两个选项。
当我们需要接受任意参数以传递给第二个函数时,关键字参数也非常有用,但是我们不知道这些参数将是什么。 当然,我们可以在一个函数调用中结合使用变量参数和变量关键字参数语法,并且也可以使用普通的位置参数和默认参数。 下面的示例有些人为设计,但演示了四种实际的类型:
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__方法。 看看您是否可以改编该示例以完成这项工作。
函数也是对象
过分强调面向对象原理的编程语言往往对那些不是方法的函数不满意。 在这种语言中,您应该创建一个对象来包装所涉及的单个方法。 在很多情况下,我们都希望传递一个被称为执行动作的小对象。 这通常在事件驱动的编程中完成,例如图形工具箱或异步服务器。
在 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)
在生产中,此代码肯定应该有使用 docstrings 的额外文档! call_after方法至少应提及delay参数以秒为单位,并且callback函数应接受一个参数:计时器进行调用。
我们在这里有两个课程。 TimedEvent类并不是真的要被其他类访问; 它所做的只是存储endtime和callback。 我们甚至可以在此处使用tuple或namedtuple,但是由于为对象赋予行为以告知我们事件是否准备就绪的行为很方便,因此我们使用一个类。
Timer类仅存储即将发生的事件的列表。 它具有call_after方法来添加新事件。 此方法接受表示执行回调之前要等待的秒数的delay参数,以及callback函数本身:将在正确的时间执行的函数。 此callback函数应接受一个参数。
run方法非常简单; 它使用生成器表达式过滤掉时间到了的所有事件,并按顺序执行它们。 然后,计时器循环会无限期地继续,因此必须通过键盘中断来中断( Ctrl +C或 Ctrl + Break ) 。 每次迭代后,我们睡半秒钟,以免使系统停顿下来。
这里要注意的重要事项是涉及回调函数的行。 该函数像任何其他对象一样被传递,并且计时器从不知道或不在乎函数的原始名称是什么或定义它的位置。 该调用该函数时,计时器只需将括号语法应用于所存储的变量。
这是一组测试计时器的回调:
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功能。 它使用字符串format方法将当前时间添加到消息中,并说明了正在使用的可变参数。 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 4
Python 3.4 引入了类似于的通用事件循环架构。
使用函数作为属性
函数作为对象的有趣的效果之一是,可以将它们设置为其他对象上的可调用属性。 可以向实例化对象添加或更改函数:
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参数添加到参数列表中。 这将更改该对象的所有实例的方法,甚至是已经实例化的实例。 显然,替换这样的方法既危险又难以维护。 读过代码的人会看到已经调用了一个方法,并在原始类上查找了该方法。 但是原始类上的方法不是被调用的方法。 弄清实际发生的情况可能会成为棘手的,令人沮丧的调试会话。
它确实有其用途。 在自动化测试中,通常在运行时替换或添加方法(称为猴子补丁)。 如果要测试客户端服务器应用,我们可能不希望在测试客户端时实际连接到服务器。 这可能会导致资金意外转移或发送给真实人的尴尬测试电子邮件。 相反,我们可以设置测试代码来替换将请求发送到服务器的对象上的某些关键方法,因此它仅记录该方法已被调用。
猴子修补程序也可用于修复错误或在我们正在与之交互的第三方代码中添加功能,并且行为方式与我们所需的不完全相同。 但是,应谨慎使用; 它几乎总是“杂乱无章”。 但是有时候,这是适应现有库以满足我们需求的唯一方法。
可调用对象
正如函数是可以在其上设置属性的对象一样,可以创建一个可以作为函数调用的对象。
通过简单地给它一个接受所需参数的__call__方法,就可以使任何对象成为可调用的。 让我们通过计时器示例使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类通过实现特殊的__and__方法来调用intersection来实现此目的。
现在,借助这些构建块,我们可以轻松地向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方法首先使用for ... in语法(在文件的每一行上循环)来重置字典(如果字典包含来自先前调用load的数据)。 同样,换行符包含在 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命令。 为了使想要在自己的代码中使用我们的MailingList API 的用户更轻松一些,让我们提供支持上下文管理器的方法:
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')
九、字符串和序列化
在涉及更高级别的设计模式之前,让我们深入研究 Python 最常见的对象之一:字符串。 我们将看到字符串不仅仅满足您的需求,而且还涵盖了在字符串中搜索模式以及对数据进行序列化以进行存储或传输的内容。
特别是,我们将访问:
- 字符串,字节和字节数组的复杂性
- 字符串格式化的来龙去脉
- 序列化数据的几种方法
- 神秘的正则表达式
字符串
字符串是 Python 中的基本原语; 到目前为止,我们几乎在每个示例中都使用了它们。 他们所做的只是代表一个不变的字符序列。 但是,尽管您以前可能没有考虑过,但是“字符”这个词有点模棱两可; Python 字符串可以表示重音字符序列吗? 中国文字? 那希腊文,西里尔文或波斯文呢?
在 Python 3 中,答案是肯定的。 Python 字符串全部以 Unicode 表示,Unicode 是一种字符定义标准,实际上可以表示地球上任何语言的任何字符(以及某些组合语言和随机字符)。 在大多数情况下,这是无缝完成的。 因此,让我们将 Python 3 字符串视为 Unicode 字符的不可变序列。 那么我们如何处理这个不变的序列呢? 在前面的示例中,我们介绍了许多可操作字符串的方法,但让我们在一个地方快速介绍一下:字符串理论速成班!
字符串操作
如您所知,可以在 Python 中通过用单引号或双引号引起来的字符串序列来创建字符串。 可以使用三个引号字符轻松创建多行字符串,并且可以通过将多个硬编码字符串并排放置在一起来将它们串联在一起。 这里有些例子:
a = "hello"
b = 'world'
c = '''a multiple
line string'''
d = """More
multiple"""
e = ("Three " "Strings "
"Together")
解释器自动将最后一个字符串组成单个字符串。 也可以使用+运算符连接字符串(与"hello " + "world"一样)。 当然,不必对字符串进行硬编码。 它们也可以来自各种外部来源,例如文本文件,用户输入或在网络上编码。
注意
当缺少逗号时,相邻字符串的自动串联会导致一些搞笑的错误。 但是,当需要在函数调用中放置一个长字符串而不超过 Python 样式指南建议的 79 个字符的行长限制时,它非常有用。
像其他序列一样,可以对字符串进行迭代(逐个字符),进行索引,切片或连接。 语法与列表相同。
str类上有许多方法可以简化字符串的操作。 Python 解释器中的dir和help命令可以告诉我们如何使用所有这些命令。 我们将直接考虑一些更常见的问题。
几种布尔便捷方法可帮助我们识别字符串中的字符是否与特定模式匹配。 这是这些方法的摘要。 其中大多数,例如isalpha,isupper / islower和startswith / endswith都有明显的解释。 isspace方法也相当明显,但请记住,考虑了所有空白字符(包括制表符,换行符),而不仅仅是空格字符。
如果每个单词的首字符大写且所有其他字符均为小写,则istitle方法返回True。 请注意,它没有严格执行标题格式的英语语法定义。 例如,即使不是所有单词都大写,Leigh Hunt 的诗《手套和狮子》也应该是有效的标题。 即使最后一个单词的中间有一个大写字母,Robert Service 的“ Sam McGee 的火化”也应该是一个有效的标题。
使用isdigit,isdecimal和isnumeric方法时要小心,因为它们比您期望的要细微得多。 除了我们惯用的十位数字外,许多 Unicode 字符还被视为数字。 更糟糕的是,我们用来从字符串构造浮点数的句点字符不视为十进制字符,因此'45.2'.isdecimal()返回False。 实际的十进制字符由 Unicode 值 0660 表示,如 45.2(或45\u06602)所示。 此外,这些方法不验证字符串是否为有效数字;这些方法不适用。 对于所有三种方法,“ 127.0.0.1”都返回True。 我们可能认为应该为所有数值使用该十进制字符而不是句点,但是将该字符传递到float()或 i nt()构造函数中会将该十进制字符转换为零:
>>> float('45\u06602')
4502.0
对于模式匹配有用的其他方法不返回布尔值。 count方法告诉我们给定子字符串出现在字符串中的次数,而find,index,rfind和rindex告诉我们给定子字符串在原始字符串中的位置。 两种'r'(用于'right'或'reverse')方法从字符串的末尾开始搜索。 如果找不到子字符串,则find方法返回-1,而在这种情况下index会引发ValueError。 看看其中一些有效的方法:
>>> s = "hello world"
>>> s.count('l')
3
>>> s.find('l')
2
>>> s.rindex('m')
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
ValueError: substring not found
其余大多数字符串方法都返回字符串的转换。 upper,lower,capitalize和title方法创建具有给定格式的所有字母字符的新字符串。 translate方法可以使用词典将任意输入字符映射到指定的输出字符。
对于所有这些方法,请注意,输入字符串保持不变。 而是返回一个全新的str实例。 如果需要处理结果字符串,则应将其分配给新变量,如new_value = value.capitalize()所示。 通常,一旦执行了转换,就不再需要旧值,因此常见的习惯用法是将其分配给相同的变量,如value = value.title()中所示。
最后,几个字符串方法返回或对列表进行操作。 split方法接受一个子字符串,并将该字符串拆分为一个字符串列表,无论该子字符串出现在何处。 您可以将数字作为第二个参数来限制结果字符串的数量。 如果您不限制字符串的数量,则rsplit的行为与split相同,但是如果您提供限制,则它会从字符串的末尾开始拆分。 partition和rpartition 方法仅在子字符串的第一次出现或最后一次出现时拆分字符串,并返回三个值的元组:子字符串之前的字符,子字符串本身以及子字符串之后的字符。
作为split的逆函数,join方法接受字符串列表,并通过将原始字符串放在它们之间来返回所有组合在一起的字符串。 replace方法接受两个参数,并返回一个字符串,其中第一个参数的每个实例都已被第二个替换。 以下是一些有效的方法:
>>> s = "hello world, how are you"
>>> s2 = s.split(' ')
>>> s2
['hello', 'world,', 'how', 'are', 'you']
>>> '#'.join(s2)
'hello#world,#how#are#you'
>>> s.replace(' ', '**')
'hello**world,**how**are**you'
>>> s.partition(' ')
('hello', ' ', 'world, how are you')
在那里,对str类上最常见的方法进行了旋风之旅! 现在,让我们看一下 Python 3 的构成字符串和变量以创建新字符串的方法。
字符串格式
Python 3 具有强大的字符串格式化和模板化机制,可让我们构造由硬编码文本和散置变量组成的字符串。 我们在许多先前的示例中都使用过它,但是它比我们使用的简单格式说明符具有更多的用途。
通过调用format()方法,可以将任何字符串转换为格式字符串。 此方法返回一个新字符串,其中输入字符串中的特定字符已替换为作为传递给函数的参数和关键字参数提供的值。 format方法不需要一组固定的参数。 在内部,它使用我们在第 7 章和 Python 面向对象的快捷方式中讨论的*args和**kwargs语法。
在格式化的字符串中替换的特殊字符是大括号字符{和}。 我们可以在字符串中插入这些对,然后将它们按顺序替换为传递给str.format方法的任何位置参数:
template = "Hello {}, you are currently {}."
print(template.format('Dusty', 'writing'))
如果我们运行这些语句,它将用变量替换大括号,顺序是:
Hello Dusty, you are currently writing.
如果我们要重用一个字符串中的变量或决定在其他位置使用它们,则此基本语法并不是非常有用。 我们可以将零索引的整数放在花括号内,以告诉格式化程序哪个位置变量将插入字符串中的给定位置。 让我们重复这个名字:
template = "Hello {0}, you are {1}. Your name is {0}."
print(template.format('Dusty', 'writing'))
如果使用这些整数索引,则必须在所有变量中使用它们。 我们不能将空括号与位置索引混合使用。 例如,此代码失败,并带有适当的ValueError异常:
template = "Hello {}, you are {}. Your name is {0}."
print(template.format('Dusty', 'writing'))
逃脱括号
括号字符除格式化外,在字符串中通常也很有用。 我们需要一种在我们希望它们作为自己显示而不是被替换的情况下逃避它们的方法。 这可以通过将花括号加倍来完成。 例如,我们可以使用 Python 格式化基本的 Java 程序:
template = """
public class {0} {{
public static void main(String[] args) {{
System.out.println("{1}");
}}
}}"""
print(template.format("MyClass", "print('hello world')"));
无论我们在模板中的何处看到{{或}}序列,即包围 Java 类和方法定义的花括号,我们都知道format方法将用单个花括号代替它们,而不是将某些参数传递给 format方法。 这是输出:
public class MyClass {
public static void main(String[] args) {
System.out.println("print('hello world')");
}
}
类的名称和输出内容已替换为两个参数,而双括号已替换为单括号,从而为我们提供了有效的 Java 文件。 原来,这是关于打印最简单的 Java 程序的最简单的 Python 程序,而 Java 程序可以打印最简单的 Python 程序!
关键字参数
如果我们正在格式化复杂的字符串,那么如果我们选择插入新的参数,记住参数的顺序或更新模板可能会变得很乏味。 因此,format方法允许我们在花括号内指定名称而不是数字。 然后将已命名的变量作为关键字参数传递给format方法:
template = """
From: <{from_email}>
To: <{to_email}>
Subject: {subject}
{message}"""
print(template.format(
from_email = "a@example.com",
to_email = "b@example.com",
message = "Here's some mail for you. "
" Hope you enjoy the message!",
subject = "You have mail!"
))
我们还可以混合使用索引参数和关键字参数(与所有 Python 函数调用一样,关键字参数必须位于位置参数之后)。 我们甚至可以将未标记的位置括号与关键字参数混合使用:
print("{} {label} {}".format("x", "y", label="z"))
如预期的那样,此代码输出:
x z y
容器查找
我们不限于将简单的字符串变量传递给format方法。 可以打印任何基元,例如整数或浮点数。 更有趣的是,可以使用复杂的对象,包括列表,元组,字典和任意对象,并且我们可以从format字符串中访问这些对象的索引和变量(但不能访问方法)。
例如,如果由于某种原因,我们的电子邮件将“从”和“到”电子邮件地址分组为一个元组,并将主题和消息放在字典中(也许是因为这是现有send_mail函数所需要的输入 我们想要使用),我们可以像这样格式化它:
emails = ("a@example.com", "b@example.com")
message = {
'subject': "You Have Mail!",
'message': "Here's some mail for you!"
}
template = """
From: <{0[0]}>
To: <{0[1]}>
Subject: {message[subject]}
{message[message]}"""
print(template.format(emails, message=message))
模板字符串中括号内的变量看起来有些奇怪,所以让我们看一下它们在做什么。 我们传递了一个参数作为基于位置的参数,将一个参数作为关键字参数。 这两个电子邮件地址由0[x]查找,其中x为0或1。 与其他基于位置的参数一样,初始零表示传递给format(在这种情况下为emails元组)的第一个位置参数。
内部带有数字的方括号与我们在常规 Python 代码中看到的索引查找类型相同,因此0[0]映射到emails元组中的emails[0]。 索引语法可用于任何可索引的对象,因此,当我们访问message[subject]时,我们会看到类似的行为,只是这次我们正在字典中查找字符串键。 注意,与 Python 代码不同,我们不需要在字典查找中在字符串两边加上引号。
如果我们有嵌套的数据结构,我们甚至可以执行多级查找。 我建议不要经常这样做,因为模板字符串很快变得难以理解。 如果我们有一个包含元组的字典,我们可以这样做:
emails = ("a@example.com", "b@example.com")
message = {
'emails': emails,
'subject': "You Have Mail!",
'message': "Here's some mail for you!"
}
template = """
From: <{0[emails][0]}>
To: <{0[emails][1]}>
Subject: {0[subject]}
{0[message]}"""
print(template.format(message))
对象查找
索引使format查找功能强大,但是我们还没有完成! 我们还可以将任意对象作为参数传递,并使用点符号在这些对象上查找属性。 让我们再次将电子邮件数据更改为一次类:
class EMail:
def __init__(self, from_addr, to_addr, subject, message):
self.from_addr = from_addr
self.to_addr = to_addr
self.subject = subject
self.message = message
email = EMail("a@example.com", "b@example.com",
"You Have Mail!",
"Here's some mail for you!")
template = """
From: <{0.from_addr}>
To: <{0.to_addr}>
Subject: {0.subject}
{0.message}"""
print(template.format(email))
该示例中的模板可能比以前的示例更具可读性,但是创建电子邮件类的开销增加了 Python 代码的复杂性。 为了明确地将对象包括在模板中而创建一个类是愚蠢的。 通常,如果我们要格式化的对象已经存在,我们将使用这种查找。 在所有示例中都是正确的; 如果有元组,列表或字典,则将其直接传递到模板中。 否则,我们将只创建一组简单的位置和关键字参数。
使其看起来正确
能够在模板字符串中包含变量是很好的,但是有时变量需要一点强制性才能使它们在输出中看起来正确。 例如,如果我们使用货币进行计算,则可能会以不希望显示在模板中的长十进制结尾:
subtotal = 12.32
tax = subtotal * 0.07
total = subtotal + tax
print("Sub: ${0} Tax: ${1} Total: ${total}".format(
subtotal, tax, total=total))
如果我们运行以下格式代码,则输出看起来不太像正确的货币:
Sub: $12.32 Tax: $0.8624 Total: $13.182400000000001
注意
从技术上讲,我们永远不要在这样的货币计算中使用浮点数; 我们应该改为构造decimal.Decimal()对象。 浮点数很危险,因为它们的计算固有地超出了特定的精度水平。 但是我们只看字符串而不是浮点数,货币是格式化的一个很好的例子!
要修复前面的format字符串,我们可以在花括号内包含一些其他信息,以调整参数的格式。 我们可以自定义很多东西,但是花括号中的基本语法是相同的。 首先,我们使用较早的布局(位置,关键字,索引,属性访问)中的任何一种都适合于指定要放置在模板字符串中的变量。 我们在此之后加上一个冒号,然后是格式化的特定语法。 这是一个改进的版本:
print("Sub: ${0:0.2f} Tax: ${1:0.2f} "
"Total: ${total:0.2f}".format(
subtotal, tax, total=total))
冒号后面的0.2f格式说明符基本上从左到右说:对于小于 1 的值,请确保小数点的左侧显示零;对于小于 1 的值,请确保零。 小数点后两位; 将输入值格式化为浮点型。
我们还可以通过在精度的句点前放置一个值,来指定每个数字在屏幕上应占据特定数量的字符。 这对于输出表格数据很有用,例如:
orders = [('burger', 2, 5),
('fries', 3.5, 1),
('cola', 1.75, 3)]
print("PRODUCT QUANTITY PRICE SUBTOTAL")
for product, price, quantity in orders:
subtotal = price * quantity
print("{0:10s}{1: ^9d} ${2: <8.2f}${3: >7.2f}".format(
product, quantity, price, subtotal))
好的,这是一个看起来很吓人的格式字符串,因此在将其分解为可理解的部分之前,让我们看看它是如何工作的:
PRODUCT QUANTITY PRICE SUBTOTAL
burger 5 $2.00 $ 10.00
fries 1 $3.50 $ 3.50
cola 3 $1.75 $ 5.25
好漂亮! 那么,这实际上是怎么发生的呢? 在for循环的每一行中,我们有四个正在格式化的变量。 第一个变量是字符串,并使用{0:10s}格式化。 s表示它是一个字符串变量,10表示它应包含十个字符。 默认情况下,对于字符串,如果字符串短于指定的字符数,则会在字符串的右侧添加空格以使其足够长(但是请注意:如果原始字符串太长,则不会 被截断!)。 我们可以更改此行为(以填充其他字符或更改格式字符串中的对齐方式),就像对下一个值quantity一样。
quantity值的格式为{1: ^9d}。 d代表整数值。 9告诉我们该值应占用 9 个字符。 但是默认情况下,使用整数而不是空格,多余的字符为零。 看起来有点奇怪。 因此,我们明确指定一个空格(紧接在冒号之后)作为填充字符。 尖号^告诉我们,数字应在此可用填充的中心对齐; 这使该列看起来更加专业。 说明符的顺序必须正确,尽管它们都是可选的:首先填充,然后对齐,然后是大小,最后是类型。
我们对价格和小计的说明符执行类似的操作。 对于price,我们使用{2: <8.2f};对于subtotal,我们使用{3: >7.2f}。 在这两种情况下,我们都指定一个空格作为填充字符,但是我们分别使用<和>符号来表示数字应在最小 8 个空格内向左或向右对齐。 七个字符。 此外,每个浮点数应格式化为两位小数。
不同类型的“类型”字符也会影响格式输出。 我们已经看到s,d和f类型,用于字符串,整数和浮点数。 其他大多数格式说明符都是这些说明符的替代版本; 例如,o代表八进制格式,X代表十六进制整数。 n类型说明符可用于以当前语言环境的格式格式化整数分隔符。 对于浮点数,%类型将乘以 100,并将浮点数格式化为百分比。
虽然这些标准格式器适用于大多数内置对象,但其他对象也可以定义非标准说明符。 例如,如果将datetime对象传递给format,则可以使用datetime.strftime函数中使用的说明符,如下所示:
import datetime
print("{0:%Y-%m-%d %I:%M%p }".format(
datetime.datetime.now()))
甚至可以为我们自己创建的对象编写自定义格式器,但这超出了本模块的范围。 如果需要在代码中执行此操作,请研究重写__format__特殊方法。 可以在 PEP 3101 中找到最详尽的说明,尽管细节有些枯燥。 您可以使用网络搜索找到更多易消化的教程。
Python 格式化语法非常灵活,但是很难记住。 我每天都在使用它,但仍然偶尔需要在文档中查找被遗忘的概念。 它也不足以满足严重的模板需求,例如生成网页。 如果您需要做的不仅仅是一些字符串的基本格式设置,那么您可以研究几种第三方模板库。
字符串为 Unicode
在本节的开头,我们将字符串定义为不可变 Unicode 字符的集合。 实际上,这有时会使事情变得非常复杂,因为 Unicode 实际上并不是一种存储格式。 例如,如果您从文件或套接字获取一串字节,则它们将不会采用 Unicode。 实际上,它们将是内置类型bytes。 字节是……字节的不可变序列。 字节是计算中最低级别的存储格式。 它们代表 8 位,通常描述为 0 到 255 之间的整数,或 0 到 FF 之间的十六进制等值。 字节不代表任何特定内容; 字节序列可以存储编码字符串的字符或图像中的像素。
如果我们打印一个字节对象,则映射到 ASCII 表示形式的任何字节都将被打印为其原始字符,而非 ASCII 字节(无论是二进制数据还是其他字符)将被打印为\x转义序列转义的十六进制代码。 。 您可能会发现奇怪的是,以整数表示的字节可以映射到 ASCII 字符。 但是 ASCII 实际上只是一种代码,其中每个字母由不同的字节模式表示,因此由不同的整数表示。 字符“ a”由与整数 97 相同的字节表示,整数是十六进制数字 0x61。 具体地说,所有这些都是对二进制模式 01100001 的解释。
即使字节对象引用了文本数据,许多 I / O 操作也只知道如何处理bytes。 因此,至关重要的是要知道如何在bytes和 Unicode 之间进行转换。
问题是有很多方法可以将bytes映射到 Unicode 文本。 字节是机器可读的值,而文本是人类可读的格式。 位于两者之间的是一种编码,它将给定的字节序列映射到给定的文本字符序列。
但是,有多种此类编码(ASCII 只是其中之一)。 当使用不同的编码映射时,相同的字节序列表示完全不同的文本字符! 因此,必须使用与编码相同的字符集对bytes进行解码。 不知道如何解码字节就不可能从字节中获取文本。 如果我们收到不带指定编码的未知字节,那么我们最好的办法就是猜测它们的编码格式,这可能是错误的。
将字节转换为文本
如果从某个位置有一个bytes数组,则可以使用bytes类上的.decode方法将其转换为 Unicode。 此方法接受一个字符串作为字符编码的名称。 这样的名字有很多。 西方语言常用的语言包括 ASCII,UTF-8 和 latin-1。
字节序列(以十六进制表示)63 6c 69 63 68 e9 实际上代表了 latin-1 编码中的单词 cliché的字符。 以下示例将对字节序列进行编码,并使用 latin-1 编码将其转换为 Unicode 字符串:
characters = b'\x63\x6c\x69\x63\x68\xe9'
print(characters)
print(characters.decode("latin-1"))
第一行创建一个bytes对象; 字符串前面的b字符表明我们正在定义bytes对象,而不是普通的 Unicode 字符串。 在字符串中,每个字节使用十六进制数指定(在这种情况下)。 \x字符在字节字符串中转义,并且每个字符都说:“接下来的两个字符使用十六进制数字表示一个字节”。
假设我们使用的是可理解 latin-1 编码的外壳,则两个print调用将输出以下字符串:
b'clich\xe9'
cliché
第一个print语句将 ASCII 字符的字节本身呈现出来。 未知字符(ASCII 未知)就是转义的十六进制格式。 该输出在行首包含b字符,以提醒我们它是bytes表示形式,而不是字符串。
下一个调用使用 latin-1 编码对字符串进行解码。 decode方法返回带有正确字符的普通(Unicode)字符串。 但是,如果我们使用西里尔字母“ iso8859-5”编码对同一字符串进行了解码,那么最终将得到字符串'clichщ'! 这是因为\xe9字节映射到两种编码中的不同字符。
将文本转换为字节
如果我们需要将传入的字节转换为 Unicode,显然我们还会遇到将传出的 Unicode 转换为字节序列的情况。 这是通过str类上的encode方法完成的,该方法与decode方法一样,需要一个字符集。 以下代码创建一个 Unicode 字符串并将其编码为不同的字符集:
characters = "cliché"
print(characters.encode("UTF-8"))
print(characters.encode("latin-1"))
print(characters.encode("CP437"))
print(characters.encode("ascii"))
前三种编码为重音字符创建了一组不同的字节。 第四个甚至不能处理该字节:
b'clich\xc3\xa9'
b'clich\xe9'
b'clich\x82'
Traceback (most recent call last):
File "1261_10_16_decode_unicode.py", line 5, in <module>
print(characters.encode("ascii"))
UnicodeEncodeError: 'ascii' codec can't encode character '\xe9' in position 5: ordinal not in range(128)
您了解现在编码的重要性吗? 对于每种编码,重音字符表示为不同的字节; 如果在将字节解码为文本时使用错误的字符,则会得到错误的字符。
在最后一种情况下,异常并非始终是所需的行为。 在某些情况下,我们希望以不同的方式处理未知字符。 encode方法采用名为errors的可选字符串参数,该参数可以定义如何处理此类字符。 该字符串可以是以下之一:
strictreplaceignorexmlcharrefreplace
strict替换策略是我们刚刚看到的默认策略。 当遇到在请求的编码中没有有效表示形式的字节序列时,将引发异常。 使用replace策略时,该字符将替换为其他字符; 在 ASCII 中,它是一个问号; 其他编码可能使用不同的符号,例如一个空框。 ignore策略只是丢弃不了解的任何字节,而xmlcharrefreplace策略创建代表 Unicode 字符的xml实体。 当转换未知字符串以用于 XML 文档时,此功能很有用。 以下是每种策略如何影响我们的样词:
战略
|
“cliché” .encode(“ ascii”,策略)
|
| --- | --- |
| replace | b'clich?' |
| ignore | b'clich' |
| xmlcharrefreplace | b'cliché' |
可以在不传递编码字符串的情况下调用str.encode和bytes.decode方法。 该编码将被设置为当前平台的默认编码。 这将取决于当前的操作系统和区域设置或区域设置; 您可以使用sys.getdefaultencoding()功能进行查找。 不过,通常通常最好明确指定编码,因为平台的默认编码可能会更改,或者该程序有一天可能会扩展为可处理来自多种来源的文本。
如果您要编码文本,但不知道要使用哪种编码,则最好使用 UTF-8 编码。 UTF-8 可以代表任何 Unicode 字符。 在现代软件中,它是一种事实上的标准编码,可确保以任何语言(甚至多种语言)交换文档。 各种其他可能的编码对于遗留文档或默认情况下仍使用不同字符集的区域很有用。
UTF-8 编码使用 1 个字节表示 ASCII 和其他常见字符,使用 4 个字节表示更复杂的字符。 UTF-8 是特殊的,因为它与 ASCII 向后兼容。 使用 UTF-8 编码的任何 ASCII 文档将与原始 ASCII 文档相同。
注意
我永远都不记得要使用encode还是decode从二进制字节转换为 Unicode。 我一直希望这些方法分别命名为“ to_binary”和“ from_binary”。 如果您有相同的问题,请尝试将“ code”一词替换为“ binary”; “ enbinary”和“ debinary”非常接近“ to_binary”和“ from_binary”。 自从设计此助记符以来,通过不查找方法帮助文件节省了很多时间。
可变字节字符串
像str一样, bytes类型是不可变的。 我们可以在bytes对象上使用索引和切片符号,并搜索特定的字节序列,但不能扩展或修改它们。 在处理 I / O 时,这可能非常不方便,因为通常需要缓冲传入或传出的字节,直到准备好发送它们为止。 例如,如果我们正在从套接字接收数据,则在接收到完整的消息之前可能要进行几次recv调用。
这是内置bytearray的。此类型的行为类似于列表,但只包含字节。 该类的构造函数可以接受bytes对象来对其进行初始化。 extend方法可用于将另一个bytes对象附加到现有阵列(例如,当更多数据来自套接字或其他 I / O 通道时)。
切片符号可以在bytearray上用于内联修改项目。 例如,此代码从bytes对象构造一个bytearray,然后替换两个字节:
b = bytearray(b"abcdefgh")
b[4:6] = b"\x15\xa3"
print(b)
输出如下:
bytearray(b'abcd\x15\xa3gh')
当心; 如果我们要操作bytearray中的单个元素,它将期望我们传递一个介于 0 和 255 之间的整数作为值。 该整数表示特定的bytes模式。 如果我们尝试传递一个字符或bytes对象,它将引发异常。
可以使用ord(序数的缩写)功能将单字节字符转换为整数。 此函数返回单个字符的整数表示形式:
b = bytearray(b'abcdef')
b[3] = ord(b'g')
b[4] = 68
print(b)
输出如下:
bytearray(b'abcgDf')
在构造完数组之后,我们用字节 103 替换了索引3处的字符(第四个字符,因为索引从0开始,与列表一样)。此整数由ord函数返回,并且是 小写字母g的 ASCII 字符。 为了说明,我们还用字节号68替换了下一个字符,该字节号映射为大写D的 ASCII 字符。
bytearray类型的方法可以使其表现得像列表(例如,我们可以在其中附加整数字节),也可以像bytes对象一样; 我们可以像在bytes或str对象上使用相同的方式使用count和find之类的方法。 区别在于bytearray是可变类型,可用于从特定输入源建立复杂的字节序列。
正则表达式
您知道使用面向对象的原理实际上很难做什么? 解析字符串以匹配任意模式,就是这样。 有很多学术论文都使用面向对象设计来设置字符串解析,但是结果总是很冗长且难以阅读,并且在实践中并未得到广泛使用。
在现实世界中,大多数编程语言中的字符串解析都是由正则表达式处理的。 这些不是冗长的,但是,至少在您学习语法之前,它们还是很难阅读的。 即使正则表达式不是面向对象的,Python 正则表达式库也提供了一些类和对象,可用于构造和运行正则表达式。
正则表达式用于解决一个常见问题:给定一个字符串,确定该字符串是否与给定的模式匹配,并有选择地收集包含相关信息的子字符串。 它们可用于回答以下问题:
- 该字符串是有效的网址吗?
- 日志文件中所有警告消息的日期和时间是什么?
/etc/passwd中的哪些用户在给定组中?- 访问者键入的 URL 要求提供什么用户名和文档?
在中有许多类似的情况,其中正则表达式是正确的答案。 许多程序员错误地实现了复杂而脆弱的字符串解析库,因为他们不了解或不会学习正则表达式。 在本节中,我们将获得足够的正则表达式知识,不会犯此类错误!
匹配样式
正则表达式是复杂的迷你语言。 它们依靠特殊字符来匹配未知字符串,但让我们从始终与自身匹配的文字字符开始,例如字母,数字和空格字符。 让我们看一个基本的例子:
import re
search_string = "hello world"
pattern = "hello world"
match = re.match(pattern, search_string)
if match:
print("regex matches")
用于正则表达式的 Python 标准库模块称为re。 我们导入它并设置搜索字符串和模式以进行搜索; 在这种情况下,它们是相同的字符串。 由于搜索字符串与给定的模式匹配,因此条件传递通过并执行print语句。
请记住,match函数将模式匹配到字符串的开头。 因此,如果模式为"ello world",则不会找到匹配项。 由于存在混乱的不对称性,解析器在找到匹配项后立即停止搜索,因此模式"hello wo"成功匹配。 让我们构建一个小的示例程序来演示这些差异并帮助我们学习其他正则表达式语法:
import sys
import re
pattern = sys.argv[1]
search_string = sys.argv[2]
match = re.match(pattern, search_string)
if match:
template = "'{}' matches pattern '{}'"
else:
template = "'{}' does not match pattern '{}'"
print(template.format(search_string, pattern))
这是,只是先前示例的通用版本,它从命令行接受模式和搜索字符串。 我们可以看到模式的开头必须如何匹配,但是在以下命令行交互中找到匹配项后,将立即返回一个值:
$ python regex_generic.py "hello worl" "hello world"
'hello world' matches pattern 'hello worl'
$ python regex_generic.py "ello world" "hello world"
'hello world' does not match pattern 'ello world'
在接下来的几节中,我们将使用此脚本。 尽管始终使用命令行python regex_generic.py "<pattern>" "<string>"调用脚本,但为了节省空间,我们仅在以下示例中显示输出。
如果您需要控制项目是在行的开头还是结尾(或者如果字符串中没有换行,则在字符串的开头和结尾),可以使用^和$字符来控制 分别代表字符串的开头和结尾。 如果您想要一个模式来匹配整个字符串,则最好将这两个都包括在内:
'hello world' matches pattern '^hello world$'
'hello worl' does not match pattern '^hello world$'
匹配所选字符
让我们从匹配任意字符的开始。 在正则表达式模式中使用句点字符时,可以匹配任何单个字符。 在字符串中使用句点意味着您不必关心字符是什么,只需要在那里有一个字符即可。 例如:
'hello world' matches pattern 'hel.o world'
'helpo world' matches pattern 'hel.o world'
'hel o world' matches pattern 'hel.o world'
'helo world' does not match pattern 'hel.o world'
注意最后一个示例如何不匹配,因为模式中句点的位置没有字符。
很好,但是如果我们只想匹配几个特定的字符怎么办? 我们可以将一组字符放在方括号内,以匹配这些字符中的任何一个。 因此,如果我们在正则表达式模式中遇到字符串[abc],我们知道这五个字符(包括两个方括号)将仅与要搜索的字符串中的一个字符匹配,此外,这个字符将是 a,b或c。 看几个例子:
'hello world' matches pattern 'hel[lp]o world'
'helpo world' matches pattern 'hel[lp]o world'
'helPo world' does not match pattern 'hel[lp]o world'
这些方括号集应被命名为字符集,但是它们通常被称为字符类。 通常,我们希望在这些集合中包含大范围的字符,而全部键入它们可能是单调且容易出错的。 幸运的是,正则表达式设计师想到了这一点,并给了我们捷径。 字符集中的破折号将创建一个范围。 如果要按以下方式匹配“所有小写字母”,“所有字母”或“所有数字”,这将特别有用:
'hello world' does not match pattern 'hello [a-z] world'
'hello b world' matches pattern 'hello [a-z] world'
'hello B world' matches pattern 'hello [a-zA-Z] world'
'hello 2 world' matches pattern 'hello [a-zA-Z0-9] world'
还有其他匹配或排除单个字符的方法,但是如果要查找它们是什么,则需要通过网络搜索找到更全面的教程!
转义字符
如果在模式中将的句点字符与任何任意字符匹配,我们如何只匹配字符串中的句点? 一种方法可能是将句点放在方括号内以创建字符类,但更通用的方法是使用反斜杠对其进行转义。 这是一个正则表达式,用于匹配介于 0.00 和 0.99 之间的两位十进制数字:
'0.05' matches pattern '0\.[0-9][0-9]'
'005' does not match pattern '0\.[0-9][0-9]'
'0,05' does not match pattern '0\.[0-9][0-9]'
对于此模式,两个字符\.与单个.字符匹配。 如果句号字符丢失或为其他字符,则不匹配。
反斜杠转义序列用于正则表达式中的各种特殊字符。 您可以使用\[插入方括号而不启动字符类,并且可以使用\(插入括号,稍后我们还将看到这也是一个特殊字符。
更有趣的是,我们还可以使用转义符号后跟一个字符来表示特殊字符,例如换行符(\n)和制表符(\t)。 此外,可以使用转义字符串更简洁地表示某些字符类。 \s代表空格字符,\w代表字母,数字和下划线,\d代表数字:
'(abc]' matches pattern '\(abc\]'
' 1a' matches pattern '\s\d\w'
'\t5n' does not match pattern '\s\d\w'
'5n' matches pattern '\s\d\w'
匹配多个字符
使用此信息,我们可以匹配大多数已知长度的字符串,但是大多数时候我们不知道在一个模式中要匹配多少个字符。 正则表达式也可以解决这一问题。 我们可以通过添加几个难以记住的标点符号之一来匹配多个字符来修改模式。
星号(*)字符表示先前的模式可以匹配零次或多次。 这听起来可能很愚蠢,但这是最有用的重复字符之一。 在探讨原因之前,请考虑一些愚蠢的示例,以确保我们了解它的作用:
'hello' matches pattern 'hel*o'
'heo' matches pattern 'hel*o'
'helllllo' matches pattern 'hel*o'
因此,模式中的*字符表示先前的模式(l字符)是可选的,并且如果存在,则可以重复多次以匹配该模式。 其余字符(h,e和o)必须恰好出现一次。
多次匹配一个字母是很少见的,但是如果我们将星号与匹配多个字符的模式结合使用,就会变得更加有趣。 例如,.*将匹配任何字符串,而[a-z]*则匹配任何小写单词集合,包括空字符串。
例如:
'A string.' matches pattern '[A-Z][a-z]* [a-z]*\.'
'No .' matches pattern '[A-Z][a-z]* [a-z]*\.'
'' matches pattern '[a-z]`.`'
模式中的加号(+)的行为类似于星号。 它指出前一个模式可以重复一次或多次,但是与星号不同,它不是可选的。 问号(?)可确保图案显示正确的次数为零或一,但不能多次。 让我们通过玩数字来探索其中的一些(记住\d与[0-9]匹配相同的字符类:
'0.4' matches pattern '\d+\.\d+'
'1.002' matches pattern '\d+\.\d+'
'1.' does not match pattern '\d+\.\d+'
'1%' matches pattern '\d?\d%'
'99%' matches pattern '\d?\d%'
'999%' does not match pattern '\d?\d%'
将样式分组在一起
到目前为止,我们已经看到了如何重复多次模式,但是我们在可以重复哪些模式方面受到限制。 如果我们想重复单个字符,我们已经介绍了,但是如果我们想重复一个字符序列怎么办? 将任何模式集括在括号中,可以在应用重复操作时将它们视为单个模式。 比较这些模式:
'abccc' matches pattern 'abc{3}'
'abccc' does not match pattern '(abc){3}'
'abcabcabc' matches pattern '(abc){3}'
结合复杂的模式,此分组功能大大扩展了我们的模式匹配功能。 这是一个匹配简单英语句子的正则表达式:
'Eat.' matches pattern '[A-Z][a-z]*( [a-z]+)*\.$'
'Eat more good food.' matches pattern '[A-Z][a-z]*( [a-z]+)*\.$'
'A good meal.' matches pattern '[A-Z][a-z]*( [a-z]+)*\.$'
第一个单词以大写字母开头,然后是零个或多个小写字母。 然后,我们输入一个与单个空格匹配的括号,后跟一个或多个小写字母的单词。 整个括号被重复零次或更多次,并且该模式以句点终止。 句点后不能有任何其他字符,如$与字符串结尾匹配。
我们已经看到了许多最基本的模式,但是正则表达式语言支持更多。 头几年,我每次使用需要执行某些操作时都使用正则表达式来查找语法。 值得为re模块的 Python 文档添加书签,并经常对其进行复审。 正则表达式几乎没有什么不匹配的,它们应该是解析字符串时要使用的第一个工具。
从正则表达式获取信息
现在让我们将集中在 Python 方面。 正则表达式语法是面向对象编程中最详尽的东西。 但是,Python 的re模块提供了一个面向对象的接口,可以进入正则表达式引擎。
我们一直在检查re.match函数是否返回有效对象。 如果模式不匹配,则该函数返回None。 但是,如果匹配,它将返回一个有用的对象,我们可以对其进行内省以获取有关模式的信息。
到目前为止,我们的正则表达式已经回答了诸如“此字符串与该模式匹配吗?”之类的问题。 匹配模式很有用,但是在许多情况下,一个更有趣的问题是:“如果此字符串与该模式匹配,则相关子字符串的值是多少?” 如果使用组来标识稍后要引用的模式部分,则可以使它们脱离匹配返回值,如以下示例所示:
pattern = "^[a-zA-Z.]+@([a-z.]*\.[a-z]+)$"
search_string = "some.user@example.com"
match = re.match(pattern, search_string)
if match:
domain = match.groups()[0]
print(domain)
描述有效电子邮件地址的规范非常复杂,并且精确匹配所有可能性的正则表达式过长。 因此,我们作弊并制作了一个与某些常用电子邮件地址匹配的简单正则表达式; 关键是我们要访问域名(在@符号之后),以便我们可以连接到该地址。 通过将模式的该部分包装在括号中并在 match 返回的对象上调用groups()方法,可以轻松完成此操作。
groups方法返回模式内匹配的所有组的元组,您可以对其进行索引以访问特定值。 这些组从左到右排序。 但是,请记住,组可以嵌套,这意味着您可以在另一个组中包含一个或多个组。 在这种情况下,将按最左括号的顺序返回这些组,因此最外面的组将在其内部匹配的组之前返回。
除了匹配功能之外,re模块还提供了几个其他有用的功能search和findall。 search函数查找匹配模式的第一个实例,从而放宽了模式从字符串的第一个字母开始的限制。 请注意,通过使用 match 并将^.*字符放在模式的前面以匹配字符串开头和要查找的模式之间的任何字符,您可以获得类似的效果。
findall函数的行为与搜索类似,不同之处在于它会找到匹配模式的所有不重叠实例,而不仅仅是第一个实例。 基本上,它找到第一个匹配项,然后将搜索重置到该匹配字符串的末尾并找到下一个匹配项。
它不像您期望的那样返回匹配对象的列表,而是返回匹配字符串的列表。 或元组。 有时是字符串,有时是元组。 根本不是一个很好的 API! 与所有不良 API 一样,您必须记住差异,而不要凭直觉。 返回值的类型取决于正则表达式中带括号的组的数量:
- 如果模式中没有组,则
re.findall将返回一个字符串列表,其中每个值都是源字符串中与模式匹配的完整子字符串 - 如果模式中恰好有一组,
re.findall将返回一个字符串列表,其中每个值都是该组的内容 - 如果模式中有多个组,则
re.findall将返回一个元组列表,其中每个元组都包含一个匹配组中的值,按顺序
注意
在自己的 Python 库中设计函数调用时,请尝试使函数始终返回一致的数据结构。 设计可以接受任意输入并处理它们的函数通常是很好的,但是返回值不应根据输入从单个值切换为列表,或从值列表切换为元组列表。 让re.findall作为一个教训!
在下面的交互式会议中,示例将希望阐明这些差异:
>>> import re
>>> re.findall('a.', 'abacadefagah')
['ab', 'ac', 'ad', 'ag', 'ah']
>>> re.findall('a(.)', 'abacadefagah')
['b', 'c', 'd', 'g', 'h']
>>> re.findall('(a)(.)', 'abacadefagah')
[('a', 'b'), ('a', 'c'), ('a', 'd'), ('a', 'g'), ('a', 'h')]
>>> re.findall('((a)(.))', 'abacadefagah')
[('ab', 'a', 'b'), ('ac', 'a', 'c'), ('ad', 'a', 'd'), ('ag', 'a', 'g'), ('ah', 'a', 'h')]
使重复的正则表达式有效
每当调用正则表达式方法之一时,引擎就必须将模式字符串转换成内部结构,从而使搜索字符串快速。 这种转换耗时很短。 如果正则表达式模式将被多次重用(例如,在for或while循环内),则最好只执行一次此转换步骤。
使用re.compile方法可以做到这一点。 它返回正则表达式的面向对象版本,该正则表达式已被编译并具有我们已经探讨的方法(match,search和findall)。 在案例研究中,我们将看到一些示例。
这绝对是对正则表达式的简要介绍。 在这一点上,我们对基础知识有很好的感觉,并且会认识到何时需要进行进一步的研究。 如果我们遇到字符串模式匹配问题,则正则表达式几乎可以肯定会为我们解决它们。 但是,我们可能需要在该主题的更全面的介绍中查找新的语法。 但是现在我们知道要寻找什么! 让我们继续讨论另一个完全不同的主题:序列化数据以进行存储。
序列化对象
如今,我们具备将数据写入文件并在任意以后的日期取回数据的能力。 如此方便(想象一下如果我们什么也不能存储的计算状态!),我们经常发现自己将存储在一个好的对象或设计模式中的数据转换为某种笨拙的文本或二进制格式进行存储 ,通过网络传输或在远程服务器上进行远程调用。
Python pickle模块是一种面向对象的方法,可以直接以特殊的存储格式存储对象。 它实质上将一个对象(及其作为属性保存的所有对象)转换为可以存储或传输的字节序列,但是我们认为合适。
对于基础工作,pickle模块具有一个非常简单的界面。 它由四个基本功能组成,用于存储和加载数据。 两个用于操纵类似文件的对象,两个用于操纵bytes对象(后者只是类似文件的接口的快捷方式,因此我们不必自己创建BytesIO类似文件的对象)。
dump方法接受要写入的对象和类似文件的对象,以将序列化的字节写入其中。 该对象必须具有write方法(否则它不会像文件一样),并且该方法必须知道如何处理bytes参数(因此,为文本输出打开的文件将无效)。
load方法的作用恰恰相反。 它从类似文件的对象中读取序列化的对象。 该对象必须具有适当的类似文件的read和readline参数,当然每个参数都必须返回bytes。 pickle模块将从这些字节中加载对象,load方法将返回完全重建的对象。 这是一个在列表对象中存储然后加载一些数据的示例:
import pickle
some_data = ["a list", "containing", 5,
"values including another list",
["inner", "list"]]
with open("pickled_list", 'wb') as file:
pickle.dump(some_data, file)
with open("pickled_list", 'rb') as file:
loaded_data = pickle.load(file)
print(loaded_data)
assert loaded_data == some_data
此代码按公布的方式工作:将对象存储在文件中,然后从同一文件加载。 在每种情况下,我们都使用with语句打开文件,以使其自动关闭。 首先打开文件进行写入,然后第二次读取,具体取决于我们是存储还是加载数据。
如果新加载的对象不等于原始对象,则末尾的assert语句将引发错误。 平等并不意味着它们是同一对象。 确实,如果我们打印两个对象的id(),我们会发现它们是不同的。 但是,由于它们都是内容相同的列表,因此这两个列表也被视为相等。
dumps和loads函数的行为与文件类似的对象非常相似,不同之处在于它们返回或接受bytes而不是文件类对象。 dumps函数仅需要一个参数即要存储的对象,并且它返回一个序列化的bytes对象。 loads函数需要一个bytes对象并返回还原的对象。 方法名称中的's'字符是字符串的缩写; 它是 Python 古代版本的旧名称,其中使用str对象代替bytes。
两种dump方法都接受可选的protocol参数。 如果我们要保存和加载仅在 Python 3 程序中使用的腌制对象,则不需要提供此参数。 不幸的是,如果我们存储的对象可能由旧版本的 Python 加载,则必须使用较旧且效率较低的协议。 通常这不是问题。 通常,唯一会加载腌制对象的程序与存储该对象的程序相同。 Pickle 是一种不安全的格式,因此我们不希望将它不安全地通过 Internet 发送给未知的口译员。
提供的参数是整数版本号。 默认版本号为 3,表示 Python 3 酸洗使用的当前高效存储系统。 数字 2 是较旧的版本,它将存储一个对象,该对象可以在所有解释器中加载回 Python 2.3。 由于 2.6 是仍在野外广泛使用的最古老的 Python,因此版本 2 的酸洗通常就足够了。 较旧的解释器支持版本 0 和 1。 0 是 ASCII 格式,而 1 是二进制格式。 还有一个优化的版本 4,有一天可能会成为默认版本。
根据经验,如果您知道要酸洗的对象将仅由 Python 3 程序加载(例如,只有您的程序将被加载),请使用默认的酸洗协议。 如果它们可能是由未知的解释程序加载的,请传递协议值 2,除非您真的相信它们可能需要由 Python 的原始版本加载。
如果确实将协议传递给dump或dumps,则应使用关键字参数来指定它:pickle.dumps(my_object, protocol=2)。 这不是严格必要的,因为该方法仅接受两个参数,但是键入 full 关键字参数会提醒我们代码的读者,数字的用途是什么。 在方法调用中具有随机整数将很难读取。 两个什么? 存储对象的两个副本,也许吗? 请记住,代码应始终可读。 在 Python 中,较少的代码通常比较长的代码更具可读性,但并非总是如此。 要明确。
可以在一个打开的文件上多次调用dump或load。 每次对dump的调用都将存储一个对象(以及它所组成或包含的任何对象),而对load的调用将仅加载并返回一个对象。 因此,对于单个文件,在存储对象时每个对dump的单独调用在以后恢复时都应该具有对load的关联调用。
自定义泡菜
对于大多数通用 Python 对象,腌制“是可行的”。 基本的原语(例如整数,浮点数和字符串)可以被腌制,任何容器对象(例如列表或字典)也可以被腌制,只要这些容器的内容也可以被腌制。 而且,重要的是,任何对象都可以被腌制,只要它的所有属性也是可腌制的。
那么,什么使属性无法拾取呢? 通常,它与对时间敏感的属性有关,将来无法加载。 例如,如果我们将开放的网络套接字,开放的文件,正在运行的线程或数据库连接作为属性存储在对象上,则对这些对象进行腌制是没有意义的。 当我们稍后尝试重新加载它们时,很多操作系统状态都将消失。 我们不能仅仅假装线程或套接字连接存在并使其显示! 不,我们需要以某种方式自定义此类瞬态数据的存储和还原方式。
这是一个每小时都会加载一次网页内容的类,以确保它们保持最新状态。 它使用threading.Timer类安排下一次更新:
from threading import Timer
import datetime
from urllib.request import urlopen
class UpdatedURL:
def __init__(self, url):
self.url = url
self.contents = ''
self.last_updated = None
self.update()
def update(self):
self.contents = urlopen(self.url).read()
self.last_updated = datetime.datetime.now()
self.schedule()
def schedule(self):
self.timer = Timer(3600, self.update)
self.timer.setDaemon(True)
self.timer.start()
url,contents和last_updated都是可腌制的,但是如果我们尝试腌制该类的实例,则self.timer实例会有点生气:
>>> u = UpdatedURL("http://news.yahoo.com/")
>>> import pickle
>>> serialized = pickle.dumps(u)
Traceback (most recent call last):
File "<pyshell#3>", line 1, in <module>
serialized = pickle.dumps(u)
_pickle.PicklingError: Can't pickle <class '_thread.lock'>: attribute lookup lock on _thread failed
这不是一个非常有用的错误,但似乎我们正在尝试使不应该的内容腌制。 那将是Timer实例; 我们在 schedule 方法中存储了对self.timer的引用,并且该属性无法序列化。
当pickle尝试序列化一个对象时,它只是尝试存储该对象的__dict__属性; __dict__是将对象上的所有属性名称映射为其值的字典。 幸运的是,在检查__dict__之前,pickle检查是否存在__getstate__方法。 如果是这样,它将存储该方法的返回值,而不是__dict__。
让我们向UpdatedURL类添加__getstate__方法,该方法仅返回__dict__的副本而无需计时器:
def __getstate__(self):
new_state = self.__dict__.copy()
if 'timer' in new_state:
del new_state['timer']
return new_state
如果我们现在腌制对象,它将不再失败。 我们甚至可以使用loads成功还原该对象。 但是,还原的对象没有 timer 属性,因此它不会像设计的那样刷新内容。 解开对象后,我们需要以某种方式创建新的计时器(以替换缺少的计时器)。
正如我们可能期望的那样,可以实现一种互补的__setstate__方法来自定义解酸。 此方法接受一个参数,即__getstate__返回的对象。 如果我们同时实现这两种方法,则不需要__getstate__返回字典,因为__setstate__将知道如何处理选择__getstate__返回的任何对象。 在我们的例子中,我们只想还原__dict__,然后创建一个新计时器:
def __setstate__(self, data):
self.__dict__ = data
self.schedule()
pickle模块非常灵活,并在需要时提供其他工具来进一步自定义酸洗过程。 但是,这些超出了本模块的范围。 我们介绍的工具足以满足许多基本的酸洗任务。 通常,要腌制的对象是相对简单的数据对象。 例如,我们不会腌制整个正在运行的程序或复杂的设计模式。
序列化 Web 对象
从未知或不受信任的来源加载腌制的对象不是的好主意。 可以向腌制的文件中注入任意代码,以通过腌制恶意攻击计算机。 泡菜的另一个缺点是它们只能由其他 Python 程序加载,而不能轻易与其他语言编写的服务共享。
多年来,已经有许多格式用于此目的。 XML(可扩展标记语言)曾经非常流行,特别是在 Java 开发人员中。 YAML(又一种标记语言)是您偶尔会看到的另一种格式。 表格数据经常以 CSV(逗号分隔值)格式进行交换。 随着时间的流逝,其中许多变得越来越模糊,您还会遇到更多。 Python 对所有这些都有可靠的标准或第三方库。
在对不受信任的数据使用此类库之前,请确保对它们进行安全调查。 例如,XML 和 YAML 都具有晦涩的功能,这些功能被恶意使用,可以允许在主机上执行任意命令。 默认情况下,可能无法关闭这些功能。 做你的研究。
JavaScript 对象符号(JSON)是用于交换原始数据的人类可读格式。 JSON 是一种标准格式,可以由各种各样的异构客户端系统解释。 因此,JSON 对于在完全解耦的系统之间传输数据非常有用。 此外,JSON 不支持可执行代码,只能对数据进行序列化。 因此,向其中注入恶意语句更加困难。
因为 JSON 可以很容易地被 JavaScript 引擎解释,所以它通常用于将数据从 Web 服务器传输到支持 JavaScript 的 Web 浏览器。 如果提供数据的 Web 应用是用 Python 编写的,则需要一种将内部数据转换为 JSON 格式的方法。
有一个模块可以执行此操作,可预测的名称为json。 该模块提供与pickle模块类似的接口,具有dump,load,dumps和loads功能。 对这些函数的默认调用与pickle中的调用几乎相同,因此我们不再赘述。 有一些区别; 显然,这些调用的输出是有效的 JSON 表示法,而不是腌制的对象。 另外,json功能对str对象而不是bytes操作。 因此,在转储到文件或从文件加载时,我们需要创建文本文件,而不是二进制文件。
JSON 序列化程序不如pickle模块强大。 它只能序列化基本类型,例如整数,浮点数和字符串,以及简单的容器,例如字典和列表。 它们中的每一个都直接映射到 JSON 表示形式,但是 JSON 无法表示类,方法或函数。 无法以这种格式传输完整的对象。 因为我们转储为 JSON 格式的对象的接收者通常不是 Python 对象,所以无论如何,它将无法以与 Python 相同的方式理解类或方法。 尽管名称中使用 O for Object,但 JSON 是数据表示法; 您还记得,对象由数据和行为组成。
如果的对象仅要序列化数据,则始终可以序列化对象的__dict__属性。 或者,我们可以通过提供自定义代码从某些类型的对象创建或解析 JSON 可序列化字典来半自动化此任务。
在json模块中,对象存储和加载函数均接受可选参数以自定义行为。 dump和dumps方法接受名称不正确的cls(类的缩写,这是一个保留关键字)关键字参数。 如果通过,则它应该是JSONEncoder类的子类,并且default方法将被覆盖。 此方法接受任意对象,并将其转换为json可以消化的字典。 如果它不知道如何处理该对象,则应调用super()方法,以便它可以照常使用序列化基本类型。
load和loads方法也接受这样的cls参数,它可以是逆类JSONDecoder的子类。 但是,通常使用object_hook关键字参数将函数传递给这些方法就足够了。 该函数接受字典并返回一个对象; 如果它不知道如何处理输入字典,则可以不加修改地将其返回。
让我们来看一个例子。 假设我们有以下简单的联系人类要序列化:
class Contact:
def __init__(self, first, last):
self.first = first
self.last = last
@property
def full_name(self):
return("{} {}".format(self.first, self.last))
我们可以序列化__dict__属性:
>>> c = Contact("John", "Smith")
>>> json.dumps(c.__dict__)
'{"last": "Smith", "first": "John"}'
但是以这种方式访问特殊(双下划线)属性有点粗糙。 另外,如果接收代码(也许在网页上有一些 JavaScript)希望提供full_name属性该怎么办? 当然,我们可以手动构造字典,但是让我们创建一个自定义编码器:
import json
class ContactEncoder(json.JSONEncoder):
def default(self, obj):
if isinstance(obj, Contact):
return {'is_contact': True,
'first': obj.first,
'last': obj.last,
'full': obj.full_name}
return super().default(obj)
default方法基本上检查以查看我们要序列化的对象的种类。 如果是联系人,我们会手动将其转换为词典; 否则,我们让父类处理序列化(假设它是json知道如何处理的基本类型)。 请注意,我们传递了一个额外的属性来将该对象标识为联系人,因为在加载时将无法告知。 这只是一个惯例; 对于更通用的序列化机制,将字符串类型存储在字典中甚至是完整的类名(包括包和模块)可能更有意义。 请记住,字典的格式取决于接收端的代码。 必须就如何指定数据达成协议。
通过将类(而不是实例化的对象)传递给dump或dumps函数,我们可以使用此类对联系人进行编码:
>>> c = Contact("John", "Smith")
>>> json.dumps(c, cls=ContactEncoder)
'{"is_contact": true, "last": "Smith", "full": "John Smith",
"first": "John"}'
为了进行解码,我们可以编写一个接受字典并检查is_contact变量是否存在的函数,以确定是否将其转换为联系人:
def decode_contact(dic):
if dic.get('is_contact'):
return Contact(dic['first'], dic['last'])
else:
return dic
我们可以使用object_hook关键字参数将此函数传递给load或loads函数:
>>> data = ('{"is_contact": true, "last": "smith",'
'"full": "john smith", "first": "john"}')
>>> c = json.loads(data, object_hook=decode_contact)
>>> c
<__main__.Contact object at 0xa02918c>
>>> c.full_name
'john smith'
案例研究
让我们用 Python 构建一个基本的以正则表达式为动力的模板引擎。 该引擎将解析文本文件(例如 HTML 页面),并将某些指令替换为从输入到这些指令所计算出的文本。 这是我们要使用正则表达式执行的最复杂的任务。 实际上,对此的完整版本可能会使用适当的语言解析机制。
考虑以下输入文件:
/** include header.html **/
<h1>This is the title of the front page</h1>
/** include menu.html **/
<p>My name is /** variable name **/.
This is the content of my front page. It goes below the menu.</p>
<table>
<tr><th>Favourite Books</th></tr>
/** loopover module_list **/
<tr><td>/** loopvar **/</td></tr>
/** endloop **/
</table>
/** include footer.html **/
Copyright © Today
该文件包含/** <directive> <data> **/形式的“标签”,其中数据是可选的单个单词,伪指令为:
include:在此处复制另一个文件的内容variable:在此处插入变量的内容loopover:对作为列表的变量重复循环的内容endloop:表示循环文本结束loopvar:从要循环的列表中插入单个值
此模板将呈现一个不同的页面,具体取决于将哪些变量传递到其中。 这些变量将从所谓的上下文文件中传入。 这将被编码为json对象,其中的键代表所讨论的变量。 我的上下文文件可能看起来像这样,但是您将派生自己的文件:
{
"name": "Dusty",
"module_list": [
"Thief Of Time",
"The Thief",
"Snow Crash",
"Lathe Of Heaven"
]
}
在进行实际的字符串处理之前,让我们将一些面向对象的样板代码汇总在一起,以处理文件并从命令行获取数据:
import re
import sys
import json
from pathlib import Path
DIRECTIVE_RE = re.compile(
r'/\*\*\s*(include|variable|loopover|endloop|loopvar)'
r'\s*([^ *]*)\s*\*\*/')
class TemplateEngine:
def __init__(self, infilename, outfilename, contextfilename):
self.template = open(infilename).read()
self.working_dir = Path(infilename).absolute().parent
self.pos = 0
self.outfile = open(outfilename, 'w')
with open(contextfilename) as contextfile:
self.context = json.load(contextfile)
def process(self):
print("PROCESSING...")
if __name__ == '__main__':
infilename, outfilename, contextfilename = sys.argv[1:]
engine = TemplateEngine(infilename, outfilename, contextfilename)
engine.process()
这是的全部基本知识,我们创建一个类并使用在命令行中传递的一些变量对其进行初始化。
注意我们如何通过将正则表达式分为两行来使其更具可读性? 我们使用原始字符串(r 前缀),因此我们不必对所有反斜杠进行两次转义。 这在正则表达式中很常见,但仍然一团糟。 (正则表达式总是有用的,但它们通常是值得的。)
pos指示我们正在处理的内容中的当前字符; 我们稍后会看到更多。
现在,剩下的就是实现该处理方法了。 有几种方法可以做到这一点。 让我们以相当明确的方式进行操作。
处理方法必须找到与正则表达式匹配的每个指令,并对其进行适当的处理。 但是,它还必须注意在未修改输出文件的每个指令之前,之后和之间输出普通文本。
正则表达式的编译版本的一个好功能是,可以通过传递pos关键字参数来告诉search方法在特定位置开始搜索。 如果我们暂时将使用指令进行适当的工作定义为“忽略指令并将其从输出文件中删除”,则我们的过程循环看起来非常简单:
def process(self):
match = DIRECTIVE_RE.search(self.template, pos=self.pos)
while match:
self.outfile.write(self.template[self.pos:match.start()])
self.pos = match.end()
match = DIRECTIVE_RE.search(self.template, pos=self.pos)
self.outfile.write(self.template[self.pos:])
用英语来说,此函数在文本中查找与正则表达式匹配的第一个字符串,输出从当前位置到该匹配开始的所有内容,然后将位置前进到上述匹配的末尾。 一旦不匹配,它将输出自上一个位置以来的所有内容。
当然,在模板引擎中忽略该指令几乎是没有用的,因此让我们用该指令替换该位置前进行,该代码根据该指令委托给类上的其他方法:
def process(self):
match = DIRECTIVE_RE.search(self.template, pos=self.pos)
while match:
self.outfile.write(self.template[self.pos:match.start()])
directive, argument = match.groups()
method_name = 'process_{}'.format(directive)
getattr(self, method_name)(match, argument)
match = DIRECTIVE_RE.search(self.template, pos=self.pos)
self.outfile.write(self.template[self.pos:])
因此,我们从正则表达式中获取指令和单个参数。 该伪指令成为方法名称,我们在self对象上动态查找该方法名称(如果模板编写器提供了无效伪指令,此处会进行一些错误处理会更好)。 我们将 match 对象和参数传递给该方法,并假定该方法将适当处理所有事情,包括移动pos指针。
到目前为止,我们已经有了面向对象的体系结构,实现委托给它们的方法实际上非常简单。 include和variable指令非常简单:
def process_include(self, match, argument):
with (self.working_dir / argument).open() as includefile:
self.outfile.write(includefile.read())
self.pos = match.end()
def process_variable(self, match, argument):
self.outfile.write(self.context.get(argument, ''))
self.pos = match.end()
第一个简单地查找包含的文件并插入文件内容,第二个简单地在上下文字典中查找变量名(从__init__方法中的json加载),如果没有,则默认为空字符串 不存在。
三种处理循环的方法更为复杂,因为它们必须在三种方法之间共享状态。 为简单起见(我敢肯定您渴望看到本长长的章节的结尾,我们几乎已经到了!),我们将把它作为类本身的实例变量来处理。 作为练习,您可能想考虑更好的架构方法,尤其是在阅读了下三章之后。
def process_loopover(self, match, argument):
self.loop_index = 0
self.loop_list = self.context.get(argument, [])
self.pos = self.loop_pos = match.end()
def process_loopvar(self, match, argument):
self.outfile.write(self.loop_list[self.loop_index])
self.pos = match.end()
def process_endloop(self, match, argument):
self.loop_index += 1
if self.loop_index >= len(self.loop_list):
self.pos = match.end()
del self.loop_index
del self.loop_list
del self.loop_pos
else:
self.pos = self.loop_pos
当遇到loopover指令时,我们不必输出任何内容,但是必须在三个变量上设置初始状态。 假设loop_list变量是从上下文词典中提取的列表。 loop_index变量指示在循环的此迭代中应在该列表中的哪个位置输出,而loop_pos则被存储,因此我们知道到达循环末尾时要跳转到的位置。
loopvar伪指令输出loop_list变量当前位置的值,并跳至伪指令的末尾。 请注意,它不会增加循环索引,因为loopvar指令可以在循环内多次调用。
endloop指令更加复杂。 它确定loop_list中是否还有更多元素; 如果有的话,它只是跳回到循环的开始,增加索引。 否则,它将重置用于处理循环的所有变量,并跳转到指令的末尾,以便引擎可以继续进行下一个匹配。
请注意,这种特殊的循环机制非常脆弱。 如果模板设计者尝试嵌套循环或忘记了endloop调用,则对他们而言效果不佳。 我们将需要更多错误检查,并且可能想要存储更多循环状态才能使其成为生产平台。 但是我保证本章的结尾不远了,所以让我们在看一下示例模板及其上下文的呈现方式之后再开始练习:
<html>
<body>
<h1>This is the title of the front page</h1>
<a href="link1.html">First Link</a>
<a href="link2.html">Second Link</a>
<p>My name is Dusty.
This is the content of my front page. It goes below the menu.</p>
<table>
<tr><th>Favourite Books</th></tr>
<tr><td>Thief Of Time</td></tr>
<tr><td>The Thief</td></tr>
<tr><td>Snow Crash</td></tr>
<tr><td>Lathe Of Heaven</td></tr>
</table>
</body>
</html>
Copyright © Today
由于我们计划模板的方式,有些奇怪的换行效果,但是它按预期工作。
十、迭代器模式
我们已经讨论了多少 Python 的内置和习惯用法,乍一看似乎是非面向对象的,实际上是提供了对主要对象的访问。 在本章中,我们将讨论看起来如此结构化的for循环实际上是围绕一组面向对象原理的轻量级包装。 我们还将看到对该语法的各种扩展,这些扩展会自动创建更多类型的对象。 我们将介绍:
- 什么是设计模式
- 迭代器协议-最强大的设计模式之一
- 列表,集合和字典理解
- 发电机和协程
设计模式简介
当工程师和建筑师决定建造桥梁,塔楼或建筑物时,他们遵循某些原则以确保结构完整性。 桥梁有多种可能的设计(例如悬架或悬臂),但是如果工程师不使用一种标准设计,并且没有出色的新设计,则他/她设计的桥梁可能会 坍塌。
设计模式旨在将用于正确设计的结构的相同正式定义引入软件工程。 有许多不同的设计模式可以解决不同的一般问题。 创建设计模式的人首先会确定开发人员在各种情况下面临的常见问题。 然后,他们就面向对象设计提出了可能被认为是该问题的理想解决方案的建议。
但是,知道设计模式并选择在我们的软件中使用它并不能保证我们正在创建“正确的”解决方案。 1907 年,魁北克大桥(至今是世界上最长的悬臂桥)在施工完成之前倒塌,因为设计桥梁的工程师严重低估了用于建造它的钢材的重量。 同样,在软件开发中,我们可能会错误地选择或应用设计模式,并创建在正常操作情况下或在超出其原始设计限制的情况下会“崩溃”的软件。
任何一个设计模式都提出了一组以特定方式交互的对象,以解决一般问题。 程序员的职责是识别他们何时面对该问题的特定版本,并在其解决方案中适应常规设计。
在本章中,我们将介绍迭代器设计模式。 这种模式是如此强大和普遍,以至于 Python 开发人员提供了多种语法来访问该模式基础的面向对象原理。 在接下来的两章中,我们将介绍其他设计模式。 其中有些支持语言,有些则没有,但是它们都没有像迭代器模式那样固有地在 Python 编码器的日常生活中发挥作用。
迭代器
在典型的模式设计中,迭代器是使用next()方法和done()方法的对象; 如果序列中没有剩余项目,则后者返回True。 在没有内置支持迭代器的编程语言中,迭代器将像这样循环遍历:
while not iterator.done():
item = iterator.next()
# do something with the item
在 Python 中,迭代是一个特殊功能,因此该方法有一个特殊名称__next__。 可以使用内置的next(iterator)访问此方法。 迭代器协议使用StopIteration而不是done方法来通知循环它已完成。 最后,我们具有更具可读性的for item in iterator语法,可以实际访问迭代器中的项,而不必搞乱while循环。 让我们更详细地看看这些。
迭代器协议
collections.abc模块中的抽象基类Iterator定义了 Python 中的迭代器协议。 如前所述,它必须具有__next__方法,for循环(以及支持迭代的其他功能)可以调用该方法以从序列中获取新元素。 此外,每个迭代器还必须满足Iterable接口。 任何提供__iter__方法的类都是可迭代的。 该方法必须返回一个Iterator实例,该实例将覆盖该类中的所有元素。 由于迭代器已经在元素上循环,因此其__iter__函数通常会返回自身。
这听起来可能有些混乱,所以请看以下示例,但是请注意,这是解决此问题的非常冗长的方法。 它清楚地解释了迭代以及所涉及的两个协议,但是在本章后面,我们将研究几种更易读的方法来获得这种效果:
class CapitalIterable:
def __init__(self, string):
self.string = string
def __iter__(self):
return CapitalIterator(self.string)
class CapitalIterator:
def __init__(self, string):
self.words = [w.capitalize() for w in string.split()]
self.index = 0
def __next__(self):
if self.index == len(self.words):
raise StopIteration()
word = self.words[self.index]
self.index += 1
return word
def __iter__(self):
return self
本示例定义了一个CapitalIterable类,其工作是循环遍历字符串中的每个单词,并以首字母大写输出它们。 可迭代的大部分工作都传递给CapitalIterator实现。 与该迭代器进行交互的规范方法如下:
>>> iterable = CapitalIterable('the quick brown fox jumps over the lazy dog')
>>> iterator = iter(iterable)
>>> while True:
... try:
... print(next(iterator))
... except StopIteration:
... break
...
The
Quick
Brown
Fox
Jumps
Over
The
Lazy
Dog
此示例首先构造一个可迭代的对象,并从中检索一个迭代器。 区别可能需要解释; 可迭代的对象是具有可以循环的元素的对象。 通常,这些元素可以多次循环,甚至可以同时循环或以重叠代码循环。 另一方面,迭代器表示该迭代器中的特定位置。 有些物品已经消耗掉了,有些则没有。 两个不同的迭代器可能在单词列表中的不同位置,但是任何一个迭代器都只能标记一个位置。
每次在迭代器上调用next()时,它都会依次从迭代器中返回另一个令牌。 最终,迭代器将被耗尽(不再有其他要返回的元素),在这种情况下,将引发Stopiteration,我们就跳出了循环。
当然,我们已经知道从可迭代对象构造迭代器的语法要简单得多:
>>> for i in iterable:
... print(i)
...
The
Quick
Brown
Fox
Jumps
Over
The
Lazy
Dog
正如您所看到的,尽管for语句看上去并不十分面向对象,但实际上是某些明显的面向对象设计原则的捷径。 当我们讨论理解时,请记住这一点,因为它们也似乎与面向对象工具相反。 但是,它们使用与for循环完全相同的迭代协议,并且只是另一种捷径。
理解
理解是简单但功能强大的语法,使我们能够以最少的一行代码来转换或过滤可迭代的对象。 结果对象可以是完全正常的列表,集合或字典,也可以是可以一次性有效使用的生成器表达式。
列表理解
列表推导是 Python 中最强大的工具之一,因此人们倾向于将其视为高级工具。 他们不是。 确实,我已经自由地带着理解力乱扔先前的示例,并假设您会理解它们。 确实,高级程序员经常使用理解能力,但这并不是因为他们很先进,而是因为他们很琐碎,并且能够处理软件开发中最常见的一些操作。
让我们看一下其中的一种常见操作; 即,将项目列表转换为相关项目列表。 具体来说,假设我们只是从文件中读取字符串列表,现在我们想将其转换为整数列表。 我们知道列表中的每个项目都是整数,并且我们希望对这些数字进行一些活动(例如,计算平均值)。 这是一种简单的处理方法:
input_strings = ['1', '5', '28', '131', '3']
output_integers = []
for num in input_strings:
output_integers.append(int(num))
可以正常工作,并且只有三行代码。 如果您不习惯于理解,您甚至可能不会认为它看起来很丑! 现在,使用列表理解来查看相同的代码:
input_strings = ['1', '5', '28', '131', '3']output_integers = [int(num) for num in input_strings]
我们将简化为一行,并且为提高性能,重要的是,我们为列表中的每个项目都删除了append方法调用。 总体而言,即使您不习惯理解语法,也很容易知道发生了什么。
与以往一样,方括号表示我们正在创建列表。 该列表内部是一个for循环,该循环遍历输入序列中的每个项目。 唯一令人困惑的是列表的开头大括号与for循环的开始之间发生的情况。 输入列表中的每项都将在此处发生的任何情况应用于*。 有问题的项目由循环中的num变量引用。 因此,它将每个单独的元素转换为int数据类型。*
这就是基本列表理解的全部内容。 毕竟它们并不那么先进。 理解是高度优化的 C 代码; 遍历大量项目时,列表理解比for循环快得多。 如果仅凭可读性并不是说服人们尽可能多地使用它们的理由,那么应该提高速度。
将列表项转换为一个相关列表并不是我们对列表理解的唯一操作。 我们还可以通过在理解内添加if语句来选择排除某些值。 看一看:
output_ints = [int(n) for n in input_strings if len(n) < 3]
我将变量的名称从num缩短为n,并将结果变量缩短为output_ints,因此它仍然可以放在一行上。 除此之外,此示例与上一个示例的不同之处是if len(n) < 3部分。 此额外的代码排除了两个以上字符的字符串。 if语句在int函数之前应用,因此它正在测试字符串的长度。 由于我们的输入字符串本质上都是整数,因此它排除了 99 以上的任何数字。现在,所有列出的内容都将包含在内! 我们使用它们将输入值映射为输出值,并沿途应用过滤器以包含或排除任何满足特定条件的值。
任何可迭代的都可以作为列表理解的输入; 我们可以包装在for循环中的所有内容也可以放在理解内。 例如,文本文件是可迭代的。 文件的迭代器上对__next__的每次调用都将返回文件的一行。 我们可以使用zip函数将制表符分隔的文件(其中第一行是标题行)加载到字典中:
import sys
filename = sys.argv[1]
with open(filename) as file:
header = file.readline().strip().split('\t')
contacts = [
dict(
zip(header, line.strip().split('\t'))
) for line in file
]
for contact in contacts:
print("email: {email} -- {last}, {first}".format(
**contact))
这次,我添加了一些空格以使其更具可读性(列表理解的不能放在一行上)。 本示例从压缩的标题创建字典列表,并为文件中的每一行分割行。
嗯什么 如果该代码或解释没有意义,请不要担心。 这有点令人困惑。 列表理解在这里需要做大量工作,并且代码很难理解,阅读和维护。 这个例子表明列表理解并不总是最好的解决方案。 大多数程序员都同意for循环比该版本更具可读性。
提示
切记:请勿滥用我们提供的工具! 始终选择适合工作的正确工具,这总是编写可维护的代码。
集合和字典理解
理解力不仅限于列表。 我们可以使用带有大括号的类似语法来创建集合,并像一样创建字典。 让我们从集合开始。 创建集合的一种方法是将列表理解包装在set()构造函数中,该构造函数将其转换为集合。 但是,当我们可以直接创建一个集合时,为什么要在中间列表上浪费内存呢?
这是一个示例,该示例使用命名元组对作者/标题/类型三合会进行建模,然后检索以特定类型撰写的所有作者的集合:
from collections import namedtuple
Book = namedtuple("Book", "author title genre")
books = [
Book("Pratchett", "Nightwatch", "fantasy"),
Book("Pratchett", "Thief Of Time", "fantasy"),
Book("Le Guin", "The Dispossessed", "scifi"),
Book("Le Guin", "A Wizard Of Earthsea", "fantasy"),
Book("Turner", "The Thief", "fantasy"),
Book("Phillips", "Preston Diamond", "western"),
Book("Phillips", "Twice Upon A Time", "scifi"),
]
fantasy_authors = {
b.author for b in books if b.genre == 'fantasy'}
与演示数据设置相比,突出显示的设置理解力肯定很短! 如果我们使用列表推导,那么 Terry Pratchett 当然会被列出两次。按原样,集合的性质将删除重复项,最后得到:
>>> fantasy_authors
{'Turner', 'Pratchett', 'Le Guin'}
我们可以引入冒号来创建字典理解。 这将使用*键将值转换为字典:*对值。 例如,如果我们知道标题,则快速查找字典中的作者或体裁可能会很有用。 我们可以使用字典理解将标题映射到模块对象:
fantasy_titles = {
b.title: b for b in books if b.genre == 'fantasy'}
现在,我们有了字典,可以使用常规语法按标题查找书籍。
总之,理解不是高级 Python,也不是应避免的“非面向对象”工具。 它们只是从现有序列创建列表,集合或字典的一种更为简洁和优化的语法。
生成器表达式
有时我们想处理一个新序列而不将新列表,集合或字典放入系统内存中。 如果我们一次只遍历一个项目,而实际上并不在乎是否要创建最终的容器对象,那么创建该容器就是浪费内存。 一次处理一项时,我们只需要随时将当前对象存储在内存中。 但是,当我们创建一个容器时,所有对象都必须先存储在该容器中,然后才能开始处理它们。
例如,考虑一个处理日志文件的程序。 一个非常简单的日志可能包含以下格式的信息:
Jan 26, 2015 11:25:25 DEBUG This is a debugging message.
Jan 26, 2015 11:25:36 INFO This is an information method.
Jan 26, 2015 11:25:46 WARNING This is a warning. It could be serious.
Jan 26, 2015 11:25:52 WARNING Another warning sent.
Jan 26, 2015 11:25:59 INFO Here's some information.
Jan 26, 2015 11:26:13 DEBUG Debug messages are only useful if you want to figure something out.
Jan 26, 2015 11:26:32 INFO Information is usually harmless, but helpful.
Jan 26, 2015 11:26:40 WARNING Warnings should be heeded.
Jan 26, 2015 11:26:54 WARNING Watch for warnings.
流行的 Web 服务器,数据库或电子邮件服务器的日志文件可能包含许多 GB 的数据(我最近不得不从行为异常的系统中清除近 2 TB 的日志)。 如果要处理日志中的每一行,则不能使用列表推导。 它将创建一个包含文件中每一行的列表。 这可能不适合 RAM,并且可能会使计算机屈服,具体取决于操作系统。
如果在日志文件上使用for循环,则可以一次处理一行,然后再将下一行读入内存。 如果我们可以使用理解语法来达到相同的效果,那会不好吗?
这就是生成器表达式的用处。它们使用与理解相同的语法,但不会创建最终的容器对象。 要创建生成器表达式,请将理解内容包装在()中,而不是[]或{}中。
以下代码以以前介绍的格式解析日志文件,并输出仅包含WARNING行的新日志文件:
import sys
inname = sys.argv[1]
outname = sys.argv[2]
with open(inname) as infile:
with open(outname, "w") as outfile:
warnings = (l for l in infile if 'WARNING' in l)
for l in warnings:
outfile.write(l)
此程序在命令行上使用两个文件名,使用生成器表达式过滤掉警告(在这种情况下,它使用if语法,并且不修改该行),然后将警告输出到另一个 文件。 如果我们在示例文件中运行它,则输出如下所示:
Jan 26, 2015 11:25:46 WARNING This is a warning. It could be serious.
Jan 26, 2015 11:25:52 WARNING Another warning sent.
Jan 26, 2015 11:26:40 WARNING Warnings should be heeded.
Jan 26, 2015 11:26:54 WARNING Watch for warnings.
当然,使用这么短的输入文件,我们可以安全地使用列表推导,但是如果文件长数百万行,则生成器表达式将对内存和速度产生巨大影响。
生成器表达式通常在函数调用中最有用。 例如,我们可以在生成器表达式而不是列表上调用sum,min或max,因为这些函数一次处理一个对象。 我们只对结果感兴趣,而不对任何中间容器感兴趣。
通常,应尽可能使用生成器表达式。 如果我们实际上不需要列表,集合或字典,而只需要过滤或转换序列中的项目,则生成器表达式将是最有效的。 如果我们需要知道列表的长度,对结果进行排序,删除重复项或创建字典,则必须使用理解语法。
发电机
生成器表达式实际上也是一种理解。 他们将更高级的(这次确实更高级!)生成器语法压缩为一行。 更大的生成器语法看起来甚至比我们所见的面向对象要少,但是我们会再次发现,创建一种对象是一种简单的语法捷径。
让我们进一步介绍日志文件示例。 如果我们要从输出文件中删除WARNING列(由于它是多余的:此文件仅包含警告),我们有几种选项,具有不同的可读性。 我们可以使用生成器表达式来做到这一点:
import sys
inname, outname = sys.argv[1:3]
with open(inname) as infile:
with open(outname, "w") as outfile:
warnings = (l.replace('\tWARNING', '')
for l in infile if 'WARNING' in l)
for l in warnings:
outfile.write(l)
这是完全可读的,尽管我不想使表达式复杂得多。 我们也可以使用普通的for循环来完成此操作:
import sys
inname, outname = sys.argv[1:3]
with open(inname) as infile:
with open(outname, "w") as outfile:
for l in infile:
if 'WARNING' in l:
outfile.write(l.replace('\tWARNING', ''))
这是可以维持的,但是在很少的几行中缩进那么多的等级有点难看。 更令人震惊的是,如果我们想对这些行做一些不同的事情,而不仅仅是打印出来,我们还必须复制循环代码和条件代码。 现在让我们考虑一个没有任何捷径的真正面向对象的解决方案:
import sys
inname, outname = sys.argv[1:3]
class WarningFilter:
def __init__(self, insequence):
self.insequence = insequence
def __iter__(self):
return self
def __next__(self):
l = self.insequence.readline()
while l and 'WARNING' not in l:
l = self.insequence.readline()
if not l:
raise StopIteration
return l.replace('\tWARNING', '')
with open(inname) as infile:
with open(outname, "w") as outfile:
filter = WarningFilter(infile)
for l in filter:
outfile.write(l)
毫无疑问:它是如此丑陋且难以阅读,以至于您甚至无法分辨正在发生的事情。 我们创建了一个将文件对象作为输入的对象,并提供了像任何迭代器一样的__next__方法。
此__next__方法从文件中读取行,如果不是WARNING行,则将其丢弃。 当遇到WARNING行时,将其返回。 然后for循环将再次调用__next__以处理下一个WARNING行。 当我们的行数用完时,我们会举起StopIteration告诉循环我们已经完成了迭代。 与其他示例相比,它很丑陋,但功能也很强大。 现在我们有了一堂课,我们可以用它做任何想要的事情。
有了这些背景知识,我们终于可以看到发电机在起作用。 下一个示例将与上一个完全相同:它使用__next__方法创建一个对象,当该对象不在输入中时,该对象将引发StopIteration:
import sys
inname, outname = sys.argv[1:3]
def warnings_filter(insequence):
for l in insequence:
if 'WARNING' in l:
yield l.replace('\tWARNING', '')
with open(inname) as infile:
with open(outname, "w") as outfile:
filter = warnings_filter(infile)
for l in filter:
outfile.write(l)
好的,这很容易理解,也许……至少很短。 但是这里到底发生了什么,这毫无意义。 什么是yield?
实际上,yield是生成器的密钥。 当 Python 在函数中看到yield时,它将采用该函数并将其包装在一个对象中,这与前面的示例不同。 认为yield语句类似于return语句; 它退出函数并返回一行。 但是,与return不同的是,当再次调用该函数(通过next())时,它将在它停下的位置(在yield语句之后的行上)而不是在函数的开始处开始。 在此示例中,在yield语句之后没有行,因此它跳转到for循环的下一个迭代。 由于yield语句位于if语句内部,因此仅产生包含WARNING的行。
看起来这只是一个循环遍历的函数,它实际上是在创建一种特殊类型的对象,即生成器对象:
>>> print(warnings_filter([]))
<generator object warnings_filter at 0xb728c6bc>
我将一个空列表传递给该函数以充当迭代器。 该函数所做的全部工作就是创建并返回一个生成器对象。 该对象上具有__iter__和__next__方法,就像我们在上一个示例中创建的那样。 每当调用__next__时,生成器都会运行该函数,直到找到yield语句。 然后,它从yield返回值,下次调用__next__时,它将从中断处开始取值。
生成器的这种用法并不先进,但是如果您不知道该函数正在创建对象,那么它看起来就像魔术。 这个例子很简单,但是您可以通过在单个函数中多次调用yield来获得真正强大的效果。 生成器将仅在最近的yield处接收并继续到下一个。
从另一个可迭代项目获得收益
通常,当我们构建一个生成器函数时,我们最终会遇到以下情况:要从另一个可迭代对象中获取数据,可能是我们在生成器内部构造的列表理解或生成器表达式,或者可能是一些传递的外部项 进入功能。 通过循环遍历可迭代项并逐个生成每个项,这始终是可能的。 但是,在 Python 3.3 版中,Python 开发人员引入了新的语法,以使其更加优雅。
让我们稍微修改一下生成器示例,以便它接受文件名而不是接受行序列。 当将对象与特定范例联系在一起时,通常会对此感到不满意。 在可能的情况下,我们应该对迭代器进行操作作为输入。 这样,无论日志行来自文件,内存还是基于 Web 的日志聚合器,都可以使用相同的功能。 因此,出于教学原因,设计了以下示例。
此版本的代码说明,生成器可以在从另一个可迭代项(在这种情况下为生成器表达式)产生信息之前进行一些基本设置:
import sys
inname, outname = sys.argv[1:3]
def warnings_filter(infilename):
with open(infilename) as infile:
yield from (
l.replace('\tWARNING', '')
for l in infile
if 'WARNING' in l
)
filter = warnings_filter(inname)
with open(outname, "w") as outfile:
for l in filter:
outfile.write(l)
此代码将前面示例中的for循环组合到生成器表达式中。 注意,我如何将生成器表达式的三个子句(转换,循环和过滤器)放在单独的行上,以使其更具可读性。 还要注意,这种转换的作用还不够。 前面带有for循环的示例更具可读性。
因此,让我们考虑一个比其他示例更具可读性的示例。 构造一个可以从多个其他生成器产生数据的生成器可能很有用。 例如,itertools.chain函数从可迭代对象开始按顺序生成数据,直到它们全部用尽为止。 使用yield from语法可能太容易实现了,所以让我们考虑一个经典的计算机科学问题:走一棵普通的树。
通用树数据结构的常见实现是计算机的文件系统。 让我们在 Unix 文件系统中为一些文件夹和文件建模,以便我们可以使用yield from有效地遍历它们:
class File:
def __init__(self, name):
self.name = name
class Folder(File):
def __init__(self, name):
super().__init__(name)
self.children = []
root = Folder('')
etc = Folder('etc')
root.children.append(etc)
etc.children.append(File('passwd'))
etc.children.append(File('groups'))
httpd = Folder('httpd')
etc.children.append(httpd)
httpd.children.append(File('http.conf'))
var = Folder('var')
root.children.append(var)
log = Folder('log')
var.children.append(log)
log.children.append(File('messages'))
log.children.append(File('kernel'))
这个设置代码看起来需要很多工作,但是在实际的文件系统中,它将涉及更多的工作。 我们必须从硬盘读取数据并将其结构化为树。 然而,一旦进入内存,输出文件系统中每个文件的代码就会非常优雅:
def walk(file):
if isinstance(file, Folder):
yield file.name + '/'
for f in file.children:
yield from walk(f)
else:
yield file.name
如果此代码遇到目录,它将递归地要求walk()生成从属于其每个子级的所有文件的列表,然后生成所有这些数据以及其自己的文件名。 在遇到普通文件的简单情况下,它只是产生该名称。
顺便说一句,在不使用生成器的情况下解决上述问题非常棘手,以至于这个问题是常见的面试问题。 如果您按如下所示回答,请准备好让您的面试官印象深刻并有些恼怒,以至于您这么容易回答。 他们可能会要求您确切说明正在发生的事情。 当然,有了本章所遵循的原则,您将不会有任何问题。
在编写链接生成器时,yield from语法是的有用快捷方式,但更通常用于其他目的:通过协程传递数据。 我们将在第 13 章,“并发”中看到许多这样的示例,但是现在让我们发现协程是什么。
协程
协程是非常强大的构造,通常与生成器混淆。 许多作者不恰当地将协程描述为“带有一些额外语法的生成器”。 这是一个容易犯的错误,就像在 Python 2.5 中那样,当引入协程时,它们被表示为“我们在生成器语法中添加了send方法”。 当您在 Python 中创建协程时,返回的对象是生成器,这一事实使情况更加复杂。 实际上,差异更加细微了,在您看到一些示例后,这将变得更加有意义。
注意
虽然 Python 中的协程目前与生成器语法紧密相关,但它们只是与我们一直在讨论的迭代器协议表面上相关。 即将发布的 Python 3.5 发行版使协同程序成为一个真正的独立对象,并将提供与之协同工作的新语法。
要记住的另一件事是,协程很难理解。 它们并没有在野外经常使用,您可能会跳过本节而愉快地使用 Python 开发多年,而不会丢失甚至遇到它们。 有几个库广泛使用协程(主要用于并发或异步编程),但是通常编写它们的目的是使您可以在不真正了解协程工作的情况下使用协程! 因此,如果您在本节中迷路,请不要绝望。
但是,通过研究以下示例,您不会迷路。 这是最简单的协程之一。 它使我们能够保持可以通过任意值增加的运行记录:
def tally():
score = 0
while True:
increment = yield score
score += increment
这段代码看起来像黑魔法,可能行不通,因此在逐行描述之前,我们将看到它起作用。 这个简单的对象可以由棒球队的计分应用使用。 每个团队都可以保留单独的得分,并且每半局结束时,他们的得分可以增加累积的跑步次数。 看一下这个互动环节:
>>> white_sox = tally()
>>> blue_jays = tally()
>>> next(white_sox)
0
>>> next(blue_jays)
0
>>> white_sox.send(3)
3
>>> blue_jays.send(2)
2
>>> white_sox.send(2)
5
>>> blue_jays.send(4)
6
首先,我们构造两个tally对象,每个团队一个。 是的,它们看起来像函数,但是和上一节中的生成器对象一样,函数内部有yield语句这一事实告诉 Python 付出了很大的努力才能将简单的函数转换为对象。
然后,我们在每个协程对象上调用next()。 这与在任何生成器上调用 next 相同,也就是说,它将执行每一行代码,直到遇到yield语句,然后返回该点的值,然后暂停直到 下一个next()通话。
到目前为止,没有什么新的。 但是,请回顾一下协程中的yield语句:
increment = yield score
与生成器不同,此 yield 函数看起来应该返回值并将其分配给变量。 实际上,这就是正在发生的事情。 协程仍在yield语句处暂停,并等待再次调用next()再次激活。
或者更确切地说,正如您在交互式会话中看到的一样,是调用名为send()的方法。 除了将生成器前进到下一个yield语句之外,send()方法与next()的作用完全相同*。 它还允许您从生成器外部传递值。 此值分配给yield语句的左侧。*
对于许多人来说,真正令人困惑的是发生这种情况的顺序:
yield发生并且发生器暂停send()从功能外部出现,发生器唤醒- 发送的值分配给
yield语句的左侧 - 生成器继续处理,直到遇到另一个
yield语句为止
因此,在此特定示例中,在构造协程并通过调用next()将其推进到yield语句之后,每次对send()的后续调用都将一个值传递到协程中,该值将该值添加到其分数中 ,返回到while循环的顶部,并继续进行处理,直到达到yield语句为止。 yield语句返回一个值,该值成为对send的最新调用的返回值。 不要错过:send()方法不仅向生成器提交值,还像next()一样从即将到来的yield语句中返回值。 这就是我们定义生成器和协程之间的区别的方式:生成器仅产生值,而协程也可以消耗它们。
注意
next(i),i.__next__()和i.send(value)的行为和语法非常不直观且令人沮丧。 第一个是普通函数,第二个是特殊方法,最后一个是普通方法。 但是这三个都做同样的事情:使生成器前进,直到产生一个值并暂停。 此外,可以通过调用i.send(None)复制next()函数和相关方法。 在这里拥有两个不同的方法名称是有价值的,因为它可以帮助我们的代码阅读者轻松查看它们是与协程还是生成器进行交互。 我只是发现以下事实:在一种情况下,这是一个函数调用,而在另一种情况下,这是一种普通的方法,有些恼人。
返回日志解析
当然,可以使用几个整数变量并在其上调用x += increment轻松地对前面的示例进行编码。 让我们看一下第二个示例,其中协程实际上为我们节省了一些代码。 这个示例是我在实际工作中必须解决的问题的简化版本(出于教学原因)。 它在逻辑上是从前面有关处理日志文件的讨论中得出的,这完全是偶然的; 这些示例是为该模块的第一版编写的,而四年后出现了这个问题!
Linux 内核日志包含一些看上去有些象但并不完全象样的行,这与下面的行不同:
unrelated log messages
sd 0:0:0:0 Attached Disk Drive
unrelated log messages
sd 0:0:0:0 (SERIAL=ZZ12345)
unrelated log messages
sd 0:0:0:0 [sda] Options
unrelated log messages
XFS ERROR [sda]
unrelated log messages
sd 2:0:0:1 Attached Disk Drive
unrelated log messages
sd 2:0:0:1 (SERIAL=ZZ67890)
unrelated log messages
sd 2:0:0:1 [sdb] Options
unrelated log messages
sd 3:0:1:8 Attached Disk Drive
unrelated log messages
sd 3:0:1:8 (SERIAL=WW11111)
unrelated log messages
sd 3:0:1:8 [sdc] Options
unrelated log messages
XFS ERROR [sdc]
unrelated log messages
有一堆散布的内核日志消息,其中一些消息与硬盘有关。 硬盘消息可能会散布在其他消息中,但是它们以可预测的格式和顺序出现,其中具有已知序列号的特定驱动器与总线标识符(例如0:0:0:0)和块设备标识符相关联 (例如sda)与该总线关联。 最后,如果驱动器的文件系统损坏,它可能会因 XFS 错误而失败。
现在,在给定前面的日志文件的情况下,我们需要解决的问题是如何获取所有带有 XFS 错误的驱动器的序列号。 数据中心技术人员以后可能会使用该序列号来标识和更换驱动器。
我们知道可以使用正则表达式来识别各个行,但是我们在循环遍历这些行时必须更改正则表达式,因为我们将根据之前的发现寻找不同的东西。 另一个困难的一点是,如果我们找到一个错误字符串,则有关哪条总线包含该字符串以及该总线上的驱动器附带的序列号的信息已得到处理。 通过以相反的顺序遍历文件的行可以轻松解决此问题。
在查看此示例之前,请注意—基于协程的解决方案所需的代码量很少:
import re
def match_regex(filename, regex):
with open(filename) as file:
lines = file.readlines()
for line in reversed(lines):
match = re.match(regex, line)
if match:
regex = yield match.groups()[0]
def get_serials(filename):
ERROR_RE = 'XFS ERROR (\[sd[a-z]\])'
matcher = match_regex(filename, ERROR_RE)
device = next(matcher)
while True:
bus = matcher.send(
'(sd \S+) {}.*'.format(re.escape(device)))
serial = matcher.send('{} \(SERIAL=([^)]*)\)'.format(bus))
yield serial
device = matcher.send(ERROR_RE)
for serial_number in get_serials('EXAMPLE_LOG.log'):
print(serial_number)
此代码将作业整齐地分为两个单独的任务。 第一个任务是遍历所有行并吐出与给定正则表达式匹配的所有行。 第二项任务是与第一个任务进行交互,并提供有关在任何给定时间应该搜索哪个正则表达式的指导。
首先查看match_regex协程。 请记住,它在构造时不会执行任何代码; 相反,它只是创建一个协程对象。 一旦构建,协程外部的某个人最终将调用next()来开始运行代码,这时它将存储两个变量filename和regex的状态。 然后,它读取文件中的所有行,并以相反的顺序遍历它们。 将每一行与传入的正则表达式进行比较,直到找到匹配项。 找到匹配项后,协程将从正则表达式中产生第一组并等待。
在将来的某些点,其他代码将发送一个新的正则表达式进行搜索。 注意协程从不关心它要匹配的正则表达式。 它只是遍历行并将它们与正则表达式进行比较。 决定要提供什么正则表达式是其他人的责任。
在这种情况下,其他人是get_serials生成器。 它不在乎文件中的行,实际上它甚至不知道它们。 它要做的第一件事是从match_regex协程构造函数创建一个matcher对象,并为其提供默认的正则表达式进行搜索。 它将协程前进到其第一个yield,并存储它返回的值。 然后进入一个循环,该循环指示匹配器对象根据所存储的设备 ID 搜索总线 ID,然后根据该总线 ID 搜索序列号。
在指示匹配器找到另一个设备 ID 并重复该循环之前,它会闲置地将序列号输出到for外部。
基本上,协程(match_regex,因为它使用regex = yield语法)作业是在文件中搜索下一个重要行,而生成器的[get_serial,它使用yield语法而没有分配)。 决定哪条线很重要。 生成器具有有关此特定问题的信息,例如文件中将显示哪些订单行。 另一方面,协程可以插入任何需要在文件中搜索给定正则表达式的问题。
关闭协程并引发异常
普通生成器通过升高StopIteration发出信号从内部退出。 如果我们将多个生成器链接在一起(例如,通过从另一个生成器内部迭代一个生成器),则StopIteration异常将向外传播。 最终,它将到达for循环,该循环将看到异常并知道该退出循环了。
协程通常不遵循迭代机制。 通常不将数据拉入直到遇到异常,而是通常将数据压入其中(使用send)。 进行推送的实体通常是负责告知协程完成时间的实体。 它通过在相关协程上调用close()方法来实现。
调用时,close()方法会在协程为等待发送值时引发GeneratorExit异常。协程通常将其yield语句包装在try ... finally块,以便可以执行任何清理任务(例如关闭关联的文件或套接字)。
如果需要在协程内部引发异常,则可以类似的方式使用throw()方法。 它接受带有可选value和traceback参数的异常类型。 当我们在一个协程中遇到异常并希望在维持回溯的同时在相邻协程中发生异常时,后者非常有用。
如果您要构建强大的基于协程的库,那么这两个功能都是至关重要的,但是在日常编码工作中我们不太可能遇到它们。
协程,生成器和函数之间的关系
我们已经看到了协程的实际作用,所以现在让我们回到有关它们与生成器的关系的讨论。 在 Python 中(通常如此),区别非常模糊。 实际上,所有协程都是生成器对象,并且作者经常将这两个术语互换使用。 有时,他们将协程描述为生成器的子集(只有从 yield 返回值的生成器才被视为协程)。 正如我们在上一节中所见,在技术上,这在 Python 中是正确的。
但是,在理论计算机科学的更大范围内,协程被认为是更通用的原理,而生成器是协程的一种特定类型。 此外,正常功能是协程的另一个不同子集。
协程是一种例程,可以使数据在一个或多个点处传入并在一个或多个点处将其取出。 在 Python 中,数据传入和传出的点是yield语句。
函数或子例程是协程的最简单类型。 您可以在某一点传递数据,而在函数返回时在另一点获取数据。 一个函数可以有多个return语句,但是对于该函数的任何给定调用,只能调用其中一个。
最后,生成器是一种协程,可以在某一点传递数据,但可以在多个点传递数据。 在 Python 中,数据将在yield语句中传递出去,但是您不能传递回数据。如果调用send,则数据将被静默丢弃。
因此,从理论上讲,生成器是协程的类型,函数是协程的类型,并且有些协程既不是函数也不是生成器。 这很简单,是吗? 那么为什么在 Python 中感觉更复杂?
在 Python 中,生成器和协程都是使用类似于我们正在构造函数的语法构造的。 但是生成的对象根本不是一个函数。 这是完全不同的对象。 当然,功能也是对象。 但是它们具有不同的界面。 函数是可调用的并返回值,生成器使用next()提取数据,协程使用send推送数据。
案例研究
数据科学是 Python 在当今最流行的领域之一。 让我们实现基本的机器学习算法! 机器学习是一个巨大的话题,但是总体思路是通过使用从过去的数据中获得的知识来对未来的数据进行预测或分类。 此类算法的用途十分广泛,数据科学家每天都在寻找新的方法来应用机器学习。 一些重要的机器学习应用包括计算机视觉(例如图像分类或面部识别),产品推荐,识别垃圾邮件和语音识别。 我们来看一个更简单的问题:给定 RGB 颜色定义,人类将这个颜色标识为什么名字?
在标准 RGB 颜色空间中,有超过 1600 万种颜色,而人类只想出了其中一小部分的名字。 尽管有成千上万个名称(有些很荒谬;可以去任何汽车经销商或化妆品店),但让我们建立一个分类器,尝试将 RGB 空间划分为基本颜色:
- 红色的
- 紫色的
- 蓝色
- 绿色的
- 黄色的
- 橘子
- 灰色的
- 白色的
- 粉色的
我们需要的第一件事是训练我们的算法的数据集。 在生产系统中,您可能会刮擦颜色网站列表或调查数千人。 取而代之的是,我创建了一个简单应用,该应用呈现随机颜色并要求用户选择上述九个选项之一来对其进行分类。 该应用包含在kivy_color_classifier目录中的本章示例代码中,但是我们将不讨论此代码的细节,因为它的唯一目的是生成示例数据。
注意
Kivy 有一个设计精良的面向对象的 API,您可能需要自己研究一下。 如果您想开发在许多系统上运行的图形程序,从笔记本电脑到手机,您可能需要查看我的模块在 Kivy 中创建应用,O'Reilly。
就本案例研究而言,关于该应用的重要事项是输出,它是逗号分隔值(CSV)文件,每行包含四个值:红色 ,绿色和蓝色值(表示为介于零和一之间的浮点数),以及用户为该颜色分配的前九个名称之一。 数据集看起来像这样:
0.30928279150905513,0.7536768153744394,0.3244011790604804,Green
0.4991001855115986,0.6394567277907686,0.6340502030888825,Grey
0.21132621004927998,0.3307376167520666,0.704037576789711,Blue
0.7260420945787928,0.4025279573860123,0.49781705131696363,Pink
0.706469868610228,0.28530423638868196,0.7880240251003464,Purple
0.692243900051664,0.7053550777777416,0.1845069151913028,Yellow
0.3628979381122397,0.11079495501215897,0.26924540840045075,Purple
0.611273677646518,0.48798521783547677,0.5346130557761224,Purple
.
.
.
0.4014121109376566,0.42176706818252674,0.9601866228083298,Blue
0.17750449496124632,0.8008214961070862,0.5073944321437429,Green
在感到无聊之前,我做了 200 个数据点(其中有很多是不真实的),并决定是时候对该数据集进行机器学习了。 如果您想使用我的数据,这些数据点将随本章的示例一起提供(没有人告诉我我是色盲的,因此应该是合理的)。
我们将实现一种更简单的机器学习算法,称为 k 最近邻居。 该算法依赖于数据集中各点之间的某种“距离”计算(在我们的示例中,我们可以使用勾股定理的三维版本)。 给定一个新的数据点,它会找到一定数量的数据点(在 k 个最近的邻居中称为 k),该数据点在通过该距离计算进行测量时最接近它。 然后,它以某种方式组合这些数据点(平均值可能适用于线性计算;对于我们的分类问题,我们将使用该模式),并返回结果。
我们不会过多地介绍算法的作用。 相反,我们将重点介绍一些可以将迭代器模式或迭代器协议应用于此问题的方法。
现在让我们编写一个程序,该程序按顺序执行以下步骤:
- 从文件中加载样本数据并从中构建模型。
- 生成 100 种随机颜色。
- 对每种颜色进行分类,然后以与输入相同的格式将其输出到文件中。
有了第二个 CSV 文件后,另一个 Kivy 程序可以加载该文件并渲染每种颜色,要求人类用户确认或否认预测的准确性,从而告知我们算法和初始数据集的准确性如何。 。
第一步是一个相当简单的生成器,用于加载 CSV 数据并将其转换为适合我们需求的格式:
import csv
dataset_filename = 'colors.csv'
def load_colors(filename):
with open(filename) as dataset_file:
lines = csv.reader(dataset_file)
for line in lines:
yield tuple(float(y) for y in line[0:3]), line[3]
我们之前没有见过csv.reader功能。 它在文件中的各行上返回一个迭代器。 迭代器返回的每个值都是一个字符串列表。 在我们的情况下,我们可能会用逗号分开并且很好,但是csv.reader还负责管理引号和逗号分隔的值格式的各种其他细微差别。
然后,我们遍历这些行并将其转换为颜色和名称的元组,其中颜色是三个浮点值整数的元组。 该元组是使用生成器表达式构造的。 可能有更多可读的方法来构造该元组。 您是否认为代码简短和生成器表达式的速度值得混淆? 它没有返回颜色元组列表,而是一次生成一个颜色元组,从而构造了一个生成器对象。
现在,我们需要一百种随机颜色。 有很多方法可以做到这一点:
- 具有嵌套生成器表达式的列表推导:
[tuple(random() for r in range(3)) for r in range(100)] - 基本的生成器功能
- 实现
__iter__和__next__协议的类 - 通过协程管道推送数据
- 即使是基本的
for循环
生成器版本似乎是最易读的,因此让我们将该函数添加到程序中:
from random import random
def generate_colors(count=100):
for i in range(count):
yield (random(), random(), random())
注意我们如何参数化要生成的颜色数量。 现在,我们可以将来将此功能重新用于其他颜色生成任务。
现在,在进行分类步骤之前,我们需要一个函数来计算两种颜色之间的“距离”。 由于可以将颜色视为三维(例如,红色,绿色和蓝色可以映射到x,y和z轴) ,让我们使用一些基本的数学运算:
import math
def color_distance(color1, color2):
channels = zip(color1, color2)
sum_distance_squared = 0
for c1, c2 in channels:
sum_distance_squared += (c1 - c2) ** 2
return math.sqrt(sum_distance_squared)
这是一个非常基本的功能; 它看起来甚至没有使用迭代器协议。 没有yield功能,也没有理解力。 但是,有一个for循环,对zip函数的调用也正在执行一些实际的迭代(请记住zip从每个输入迭代器中生成包含一个元素的元组)。
但是请注意,在我们的 k 近邻算法中,该函数将被调用很多次。 如果我们的代码运行太慢,并且我们能够将该函数识别为瓶颈,则我们可能希望将其替换为可读性较低但更优化的生成器表达式:
def color_distance(color1, color2):
return math.sqrt(sum((x[0] - x[1]) ** 2 for x in zip(
color1, color2)))
但是,强烈建议您在证明可读版本太慢之前不要进行此类优化。
现在我们已经有了一些管道,让我们进行实际的 k 最近邻居实现。 这似乎是使用协程的好地方。 这里带有一些测试代码,以确保产生合理的值:
def nearest_neighbors(model_colors, num_neighbors):
model = list(model_colors)
target = yield
while True:
distances = sorted(
((color_distance(c[0], target), c) for c in model),
)
target = yield [
d[1] for d in distances[0:num_neighbors]
]
model_colors = load_colors(dataset_filename)
target_colors = generate_colors(3)
get_neighbors = nearest_neighbors(model_colors, 5)
next(get_neighbors)
for color in target_colors:
distances = get_neighbors.send(color)
print(color)
for d in distances:
print(color_distance(color, d[0]), d[1])
协程接受两个参数,即用作模型的颜色列表和要查询的邻居数。 它将模型转换为列表,因为它将多次迭代。 在协程的主体中,它使用yield语法接受 RGB 颜色值的元组。 然后,它将对sorted的调用与奇数生成器表达式组合在一起。 看看是否可以弄清楚该生成器表达式在做什么。
它为模型中的每种颜色返回一个元组(distance, color_data)。 请记住,模型本身包含(color, name)的元组,其中color是三个 RGB 值的元组。 因此,生成器将在看起来像这样的怪异数据结构上返回迭代器:
(distance, (r, g, b), color_name)
然后sorted调用按结果的第一个元素(即距离)对结果进行排序。 这是一段复杂的代码,根本不是面向对象的。 您可能需要将其分解为正常的for循环,以确保您了解生成器表达式在做什么。 想像一下如果要将键参数传递给已排序的函数而不是构造元组,则这段代码的外观可能是一个很好的练习。
yield语句稍微复杂一些; 它从前 k 个(distance, color_data)元组的每一个中提取第二个值。 更具体地说,它为具有最小距离的 k 值生成((r, g, b), color_name)元组。 或者,如果您更喜欢抽象术语,它会在给定模型中得出目标的 k 最近邻。
剩下的代码只是测试这种方法的样板。 它构建模型和颜色生成器,启动协程,并在for循环中打印结果。
剩下的两个任务是根据最近的邻居选择一种颜色,并将结果输出到 CSV 文件。 让我们再增加两个协程来完成这些任务。 让我们先进行输出,因为它可以独立测试:
def write_results(filename="output.csv"):
with open(filename, "w") as file:
writer = csv.writer(file)
while True:
color, name = yield
writer.writerow(list(color) + [name])
results = write_results()
next(results)
for i in range(3):
print(i)
results.send(((i, i, i), i * 10))
该协程将打开的文件保持为状态,并在使用send()发送时向其中写入代码行。 测试代码确保协程正常工作,因此现在我们可以将两个协程与第三个协程连接起来。
第二个协程使用了一些奇怪的技巧:
from collections import Counter
def name_colors(get_neighbors):
color = yield
while True:
near = get_neighbors.send(color)
name_guess = Counter(
n[1] for n in near).most_common(1)[0][0]
color = yield name_guess
该协程将现有协程接受作为其参数。 在这种情况下,它是nearest_neighbors的实例。 这段代码基本上代理了通过该nearest_neighbors实例发送给它的所有值。 然后,它将对结果进行一些处理,以从返回的值中获得最普通的颜色。 在这种情况下,修改原始协程以返回名称可能同样有意义,因为它没有被用于其他任何用途。 但是,在许多情况下,传递协程是有用的。 这是我们的做法。
现在我们要做的就是将这些协程和管道连接在一起,并通过一个函数调用启动该过程:
def process_colors(dataset_filename="colors.csv"):
model_colors = load_colors(dataset_filename)
get_neighbors = nearest_neighbors(model_colors, 5)
get_color_name = name_colors(get_neighbors)
output = write_results()
next(output)
next(get_neighbors)
next(get_color_name)
for color in generate_colors():
name = get_color_name.send(color)
output.send((color, name))
process_colors()
因此,与我们定义的几乎所有其他函数不同,该函数是完全正常的函数,没有任何yield语句。 它不会变成协程或生成器对象。 但是,它确实构造了一个生成器和三个协程。 注意get_neighbors协程如何传递到name_colors的构造函数中? 请注意通过调用next将所有三个协程推进到其第一个yield语句。
创建完所有管道后,我们使用for循环将生成的每种颜色发送到get_color_name协程,然后将该协程产生的每个值通过管道传递到输出协程,然后将其写入到 文件。
就是这样! 我创建了第二个 Kivy 应用,该应用加载生成的 CSV 文件并将颜色呈现给用户。 用户可以选择是或否,具体取决于他们是否认为机器学习算法做出的选择与他们会做出的选择相匹配。 这在科学上是不准确的(对于观察偏差已经成熟),但是足够玩了。 用我的眼睛,它成功了大约 84%,这比我 12 年级的平均水平要好。 对于我们的第一个机器学习体验来说还不错,是吗?
您可能想知道,“这与面向对象的编程有什么关系?此代码中甚至没有一个类!”。 在某些方面,您将是对的; 协程和生成器通常都不被认为是面向对象的。 但是,创建它们的函数将返回对象。 实际上,您可以将这些函数视为构造函数。 构造的对象具有适当的send()和__next__()方法。 基本上,协程/生成器语法是特定对象的语法快捷方式,如果没有该对象,创建起来将非常冗长。
此案例研究是自下而上设计的练习。 我们创建了执行特定任务的各种低级对象,并将它们最终挂在一起。 我发现使用协程开发时这是一种常见的做法。 自上而下的替代设计有时会导致更多的整体代码段,而不是唯一的单独代码段。 总的来说,我们希望在太大的方法和太小的方法之间找到一个快乐的中介,很难看到它们如何组合在一起。 当然,无论是否像在此那样使用迭代器协议,都是如此。