Python3-高级编程-全-

Python3 高级编程(全)

原文:Pro Python 3

协议:CC BY-NC-SA 4.0

一、原则和哲学

350 多年前,著名的日本剑客宫本武藏写下了《五环之书》,讲述了他从 13 岁到 29 岁之间,在 60 多次决斗中获胜的经历。他的书可能与一本佛教禅宗剑术指导手册有关。在这封由五部分组成的信中,武藏概述了带领学生走向成功的一般思想、理想和哲学原则。

如果用一章哲学来开始一本编程书看起来很奇怪,那实际上就是这一章如此重要的原因。与武藏的方法相似,Python 被创造出来是为了体现和鼓励某种理念,这种理念已经帮助指导了其维护者和社区近 20 年的决策。理解这些概念将有助于你充分利用这门语言及其社区所提供的一切。

当然,我们这里不是在谈论柏拉图或尼采。Python 处理编程问题,其理念旨在帮助构建可靠、可维护的解决方案。其中一些理念已经正式融入 Python 环境,而另一些则是 Python 程序员普遍接受的指导原则,但所有这些都将帮助您编写功能强大、易于维护且其他程序员可以理解的代码。

本章的哲学可以从头到尾读一遍,但是不要指望一遍就能记住。本书的其余部分将通过举例说明哪些概念在各种情况下发挥作用来引用这一章。毕竟,哲学的真正价值在于理解如何在最重要的时候应用它。

至于实际惯例,在整本书中你会看到命令提示符、脚本和剪刀的图标。当您看到命令提示符图标时,代码显示为好像您要从命令提示符下尝试它(您应该这样做)。如果您看到脚本图标,请尝试将代码作为 Python 脚本。最后,剪刀只显示了一个需要额外代码片段才能运行的代码片段。唯一的其他约定是您已经安装了 Python 3.x,并且至少有一些计算机编程背景。

Python 的禅

也许最著名的 Python 哲学集是由 Tim Peters 编写的,他是该语言及其新闻组comp.lang.python的长期撰稿人。1Python 的这一理念将一些最常见的哲学问题浓缩到一个简短的列表中,该列表已被记录为它自己的 Python 增强提案(PEP)和 Python 本身。Python 有点像复活节彩蛋,包含一个名为this的模块。

img/330715_3_En_1_Figa_HTML.jpg

>>> import this
The Zen of Python, by Tim Peters

Beautiful is better than ugly.
Explicit is better than implicit.
Simple is better than complex.
Complex is better than complicated.
Flat is better than nested.
Sparse is better than dense.
Readability counts.
Special cases aren't special enough to break the rules.
Although practicality beats purity.
Errors should never pass silently.
Unless explicitly silenced.
In the face of ambiguity, refuse the temptation to guess.
There should be one -- and preferably only one -- obvious way to do it.
Although that way may not be obvious at first unless you're Dutch.
Now is better than never.
Although never is often better than *right* now.
If the implementation is hard to explain, it's a bad idea.
If the implementation is easy to explain, it may be a good idea.
Namespaces are one honking great idea -- let's do more of those!

这个列表主要是为了幽默地解释 Python 哲学,但是多年来,许多 Python 应用已经使用这些指南来极大地提高代码的质量、可读性和可维护性。然而,仅仅列出 Python 的禅是没有什么价值的,所以下面几节将更详细地解释每个习惯用法。

漂亮总比丑陋好

也许这第一个概念可以说是所有概念中最主观的。毕竟,情人眼里出西施,这个事实已经讨论了几个世纪了。它清楚地提醒我们,哲学远非绝对。尽管如此,把这样的东西写下来提供了一个奋斗的目标,这是所有这些理想的最终目的。

这种哲学的一个明显的应用是在 Python 自己的语言结构中,它最大限度地减少了标点符号的使用,而是在适当的时候更喜欢使用英语单词。另一个优势是 Python 对关键字参数的关注,这有助于澄清难以理解的函数调用。考虑以下两种编写相同代码的可能方式,并考虑哪种方式看起来更漂亮:

img/330715_3_En_1_Figb_HTML.jpg

is_valid = form != null && form.is_valid(true)
is_valid = form is not None and form.is_valid(include_hidden_fields=True)

第二个例子读起来有点像自然英语,并且显式地包含参数的名称可以更好地理解其目的。除了语言方面的考虑之外,编码风格还会受到类似美感概念的影响。例如,名称is_valid提出了一个简单的问题,然后该方法可以用它的返回值来回答。像validate这样的名字是不明确的,因为即使没有返回值,它也是一个准确的名字。

然而,过分依赖美作为设计决策的标准是危险的。如果已经考虑了其他的理想,而你仍然有两个可行的选择,当然要考虑把美也考虑进去,但是一定要确保首先考虑其他方面。在达到这一点之前,您可能会使用其他一些标准找到一个好的选择。

显性比隐性好

尽管这个概念看起来更容易理解,但它实际上是需要遵循的更棘手的准则之一。表面上看,这似乎很简单:不要做程序员没有明确命令的任何事情。除了 Python 本身之外,框架和库也有类似的责任,因为它们的代码会被其他程序员访问,而他们的目标并不总是事先知道的。

不幸的是,真正显式的代码必须考虑程序执行的每一个细微差别,从内存管理到显示例程。一些编程语言确实期望他们的程序员有那样的详细程度,但是 Python 没有。为了让程序员的工作更容易,让你专注于手头的问题,需要有一些权衡。

一般来说,Python 要求你明确地声明你的意图,而不是发出每一个必要的命令来实现你的意图。例如,当给一个变量赋值时,你不需要担心留出必要的内存,分配一个指向该值的指针,以及一旦不再使用就清理内存。内存管理是变量赋值的必要部分,所以 Python 在幕后负责。赋值足以明确声明意图来证明隐式行为的合理性。

相比之下,Perl 编程语言中的正则表达式会在找到匹配时自动为特殊变量赋值。不熟悉 Perl 处理这种情况的方式的人不会理解依赖于它的代码片段,因为变量似乎来自稀薄的空气,没有与它们相关的赋值。Python 程序员试图避免这种类型的隐式行为,以支持更具可读性的代码。

因为不同的应用有不同的声明意图的方式,所以没有一个通用的解释适用于所有情况。相反,这条指导方针将在整本书中频繁出现,阐明它将如何应用于各种情况。

img/330715_3_En_1_Figc_HTML.jpg

tax = .07  #make a variable named tax that is floating point
print (id(tax))  #shows identity number of tax
print("Tax now changing value and identity number")
tax = .08  #create a new variable, in a different location in memory
            # and mask the first one we created
print (id(tax))  # shows identity of tax
print("Now we switch tax back...")
tax = .07  #change tax back to .07 (mask the second one and reuse first
print (id(tax))  #now we see the original identity of tax

简单比复杂好

这是一个相当具体的指导方针,主要涉及到框架和库的接口设计。这里的目标是保持界面尽可能简单,尽可能利用程序员对现有界面的了解。例如,缓存框架可以使用与标准字典相同的接口,而不是发明一套全新的方法调用。

当然,这条规则还有许多其他的应用,比如利用这样一个事实,即大多数表达式可以在没有显式测试的情况下评估为真或假。例如,下面两行代码对于字符串来说在功能上是相同的,但是请注意它们之间的复杂性差异:

img/330715_3_En_1_Figd_HTML.jpg

if value is not None and value != ":
if value:

如您所见,第二个选项更容易阅读和理解。第一个例子中涉及的所有情况无论如何都将评估为假,因此更简单的测试同样有效。它还有另外两个好处:它运行得更快,需要执行的测试更少,而且它还适用于更多情况,因为单个对象可以定义自己的方法来确定它们应该评估为 true 还是 false。

这似乎是一个令人费解的例子,但这只是经常出现的事情。通过依赖更简单的接口,您通常可以利用优化和增加的灵活性,同时生成更可读的代码。

复杂总比复杂好

然而,有时为了完成工作,需要一定程度的复杂性。例如,数据库适配器不能奢侈地使用简单的字典式接口,而是需要一组广泛的对象和方法来涵盖它们的所有特性。在这种情况下,重要的是要记住,复杂并不一定需要复杂。

很明显,这个问题的棘手之处在于区分这两者。每个术语的字典定义经常引用另一个术语,这在很大程度上模糊了两者之间的界限。出于这一准则的考虑,大多数情况下倾向于对这两个术语采取以下观点:

  • 复杂的:由许多相互联系的部分组成的

  • 复杂的:复杂到难以理解的

因此,面对一个需要跟踪大量事物的界面,尽可能保持简单就更加重要了。这可以采取将方法合并到更少的对象上的形式,也许将对象分组到更符合逻辑的排列中,或者甚至只是确保使用有意义的名称,而不必钻研代码来理解它们。

扁平的比嵌套的好

这个指导方针起初可能看起来没有意义,但是它是关于结构如何被布置的。正在讨论的结构可以是对象及其属性、包及其包含的模块,甚至是函数中的代码块。目标是尽可能保持同龄人的关系,而不是父母和孩子的关系。例如,取下面的代码片段:

img/330715_3_En_1_Fige_HTML.jpg

if x > 0:
    if y > 100:
        raise ValueError("Value for y is too large.")
    else:
        return y
else:
    if x == 0:
        return False
    else:
        raise ValueError("Value for x cannot be negative.")

在这个例子中,很难跟踪到底发生了什么,因为代码块的嵌套性质要求您跟踪多个级别的条件。考虑以下编写相同代码的替代方法,将其展平:

img/330715_3_En_1_Figf_HTML.jpg

x=1
y=399  # change to 39 and run a second time

def checker(x,y):
    if x > 0 and y > 100:
        raise ValueError("Value for y is too large.")
    elif x > 0:
        return y
    elif x == 0:
        return False
    else:
        raise ValueError("Value for x cannot be negative.")

print(checker(x,y))

放入一个函数,并展平,您可以看到遵循第二个示例中的逻辑要容易得多,因为所有的条件都在同一级别。它甚至通过避免额外的else块节省了两行代码。虽然这种想法在一般编程中很常见,但这实际上是关键字elif存在的主要原因;Python 对缩进的使用意味着复杂的if块会很快失控。使用elif关键字,Python 中没有 C++或 VB.NET 中的开关选择 case 结构。为了处理需要多选结构的问题,Python 根据情况需要使用了一系列的ifelifelifelse。已经有 pep 建议包括开关型结构;然而,都没有成功。

警告

可能不太明显的是,这个例子的重构最终测试了x > 0两次,而之前只执行了一次。如果那个测试是一个昂贵的操作,比如数据库查询,以这种方式重构它会降低程序的性能,所以不值得。这将在后面的指南“实用性胜过纯粹性”中详细介绍

在包布局的情况下,平面结构通常允许单个导入,以使整个包在单个名称空间下可用。否则,程序员需要知道完整的结构才能找到所需的特定类或函数。有些包非常复杂,嵌套结构将有助于减少每个名称空间的混乱,但是最好从平面开始,只有在出现问题时才嵌套。

稀疏比密集好

这个原则很大程度上适用于 Python 源代码的视觉外观,支持使用空白来区分代码块。目标是将高度相关的代码片段放在一起,同时将它们与后续的或不相关的代码分开,而不是简单地让所有代码一起运行,以节省磁盘上的几个字节。熟悉 JAVA、C++和其他使用{ }表示语句块的语言的人也知道,只要语句块在大括号内,空白或缩进就只有可读性,对代码执行没有影响。

在现实世界中,有很多具体的问题需要解决,比如如何分离模块级的类或者处理单行的if块。虽然没有一套规则适用于所有项目,但是 PEP 8 2 确实指定了源代码布局的许多方面来帮助你坚持这个原则。它提供了许多关于如何格式化导入语句、类、函数甚至许多类型的表达式的提示。

有趣的是,PEP 8 特别包含了许多关于表达式的规则,这些规则特别鼓励避免额外的空格。以下面的例子为例,这些例子直接来自 PEP 8:

img/330715_3_En_1_Figg_HTML.jpg

      Yes: spam(ham[1], {eggs: 2})
      No:  spam( ham[ 1 ], { eggs: 2 } )

      Yes: if x == 4: print x, y; x, y = y, x
      No:  if x == 4 : print x , y ; x , y = y , x

      Yes: spam(1)
      No:  spam (1)

      Yes: dict['key'] = list[index]
      No:  dict ['key'] = list [index]

这种明显差异的关键是空白是一种宝贵的资源,应该负责任地分配。毕竟,如果每样东西都试图以某个特定的方式脱颖而出,那么没有什么东西真的会脱颖而出。如果像前面的表达式一样使用空格来分隔高度相关的代码,那么真正不相关的代码与其他代码没有任何不同。

这可能是这个原则最重要的部分,也是将它应用到代码设计的其他方面的关键。当编写库或框架时,通常最好定义一小组可以在应用中重用的独特类型的对象和接口,在适当的地方保持相似性,并区分其余的。

可读性计数

最后,我们有一个原则,Python 世界中的每个人都可以支持,但这主要是因为它是整个集合中最模糊的一个。在某种程度上,它以一种灵巧的笔触总结了整个 Python 哲学,但它也留下了太多未定义的东西,因此值得进一步研究。

可读性涵盖了广泛的问题,例如模块、类、函数和变量的名称。它包括单个代码块的风格和它们之间的空白。它甚至可以适用于多个功能或类之间的职责分离,如果这种分离是为了使它对人的眼睛来说更可读。

这才是真正的要点:代码不仅会被计算机读取,还会被维护它的人读取。这些人阅读现有代码的次数远远多于他们编写新代码的次数,而且这些代码通常是由别人编写的。可读性就是积极促进人类对代码的理解。

从长远来看,当每个人都可以简单地打开一个文件并很容易地理解其中发生的事情时,开发就会容易得多。这在高流动率的组织中似乎是理所当然的,新程序员必须定期阅读他们前任的代码,但即使对于那些必须在他们自己的代码编写几周、几个月甚至几年后才阅读的人来说也是如此。一旦我们失去了最初的思路,我们所要提醒我们的就是代码本身,所以花额外的时间让它易于阅读是很有价值的。另一个好的做法是在代码中添加注释和注解。当足够的时间过去了,以至于你不记得你尝试了什么或者你的意图是什么的时候,它不会伤害甚至肯定能帮助最初的程序员。

最棒的是它通常只需要很少的额外时间。它可以简单到在两个函数之间添加一个空行,或者用名词命名变量,用动词命名函数。然而,这与其说是一套规则,不如说是一种心态。对可读性的关注要求你总是像人一样看待你的代码,而不仅仅是像计算机一样。记住黄金法则:为他人做你希望他们为你做的事。可读性是散布在你的代码中的随机善举。

特例不足以特殊到打破规则

就像“可读性很重要”是我们应该如何处理代码的标语一样,这个原则是关于我们必须追求的信念。大多数情况下做对了当然很好,但是一段丑陋的代码就可以破坏所有的努力。

然而,这条规则最有趣的地方在于,它不仅仅与代码的可读性或任何其他方面有关。这真的只是关于支持你所做的决定的信念,不管这些决定是什么。如果您致力于向后兼容性、国际化、可读性或其他任何东西,不要因为一个新特性的出现使一些事情变得简单了就违背这些承诺。

实用性胜过纯粹性

这就是事情变得棘手的地方。前面的原则鼓励你总是做正确的事情,不管一种情况有多例外,这一条似乎允许在正确的事情变得困难时出现例外。然而,现实要复杂一些,值得讨论一下。

到目前为止,乍看起来似乎很简单:最快、最高效的代码可能并不总是可读性最好的,因此您可能不得不接受较差的性能,以获得更易于维护的代码。在许多情况下确实如此,而且 Python 的许多标准库在原始性能方面并不理想,而是选择可读性更好、更易于移植到其他环境的纯 Python 实现,如 Jython 或 IronPython。然而,从更大的范围来看,问题远不止于此。

在设计任何级别的系统时,很容易进入自下而上的模式,在这种模式下,您只关注手头的问题以及如何最好地解决它。这可能涉及算法、优化、接口方案,甚至重构,但它通常归结为在一件事情上努力工作,以至于你暂时没有看到更大的画面。在这种模式下,程序员通常做在当前上下文中看起来最好的事情,但是当为了更好的外观而后退一点时,这些决定与应用的其余部分不匹配。

在这一点上,不总是很容易知道该走哪条路。你会尝试优化应用的其余部分来匹配你刚刚编写的完美例程吗?你会重写原本完美的函数,希望得到一个更有凝聚力的整体吗?或者你只是不理会这种不一致,希望它不会绊倒任何人?像往常一样,答案取决于具体情况,但其中一个选项在上下文中似乎比其他选项更实用。

通常,最好以牺牲一些可能不太理想的小区域为代价来保持更大的整体一致性。同样,Python 的大多数标准库都使用这种方法,但也有例外。需要大量计算能力或在需要避免瓶颈的应用中使用的包通常用 C 编写,以提高性能,代价是可维护性。然后需要将这些包移植到其他环境中,并在不同的系统上进行更严格的测试,但是获得的速度比更纯的 Python 实现更实用。

错误不应该悄无声息地过去

Python 支持一个健壮的错误处理系统,提供了数十个现成的内置异常,但是人们经常怀疑什么时候应该使用这些异常,什么时候需要新的异常。Python 之禅的这一行提供的指导非常简单,但是和其他许多指导一样,在表面之下有更多的东西。

第一个任务是澄清错误和异常的定义。尽管这些词,像计算机世界中的许多其他词一样,经常被赋予额外的含义,但当它们被用在普通语言中时,还是有一定的价值的。考虑以下定义,如在韦氏词典中所见:

  • 无知或轻率地背离行为准则的行为或状态

  • 规则不适用的情况

这里省略了这些术语,以帮助说明这两个定义有多么相似。在现实生活中,观察到的这两个术语之间的最大差异是由偏离标准所导致的问题的严重性。异常通常被认为破坏性较小,因此更容易被接受,但异常和错误都意味着同一件事:违背某种期望。为了讨论的目的,术语“例外”将用于指任何这种偏离规范的情况。

注意

要认识到的一件重要的事情是,并不是所有的异常都是错误。有些用于增强代码流选项,比如使用StopIteration,这在第五章中有记载。在代码流使用中,异常提供了一种方法来指示函数内部发生了什么,即使该指示与其返回值没有关系。

这种解释使得不可能描述异常本身;它们必须放在一个可以被违背的期望的背景下。每当我们写一段代码,我们就承诺它会以特定的方式工作。例外打破了这个承诺,所以我们需要了解我们做出了什么类型的承诺,以及如何打破它们。以下面这个简单的 Python 函数为例,寻找任何可能被打破的承诺:

img/330715_3_En_1_Figh_HTML.jpg

def validate(data):
    if data['username'].startswith('_'):
        raise ValueError("Username must not begin with an underscore.")

这里最明显的承诺是validate()方法:如果传入的数据是有效的,函数将无声无息地返回。违反该规则的情况,比如以下划线开头的用户名,被明确地视为异常,这清楚地说明了不允许错误无声无息地通过的做法。引发异常会引起对这种情况的注意,并为调用此函数的代码提供足够的信息来了解发生了什么。

这里的棘手之处是要看到可能引发的其他异常。例如,如果data字典不包含username键,正如函数所期望的,Python 将引发一个KeyError。如果那个键确实存在,但是它的值不是一个字符串,Python 将在试图访问startswith()方法时引发一个AttributeError。如果data根本不是字典,Python 会抛出一个TypeError

这些假设中的大部分都是正确操作的真实要求,但也不一定都是。让我们假设这个验证函数可以从许多上下文中调用,其中一些甚至可能不需要用户名。在这些情况下,缺少用户名实际上根本不是异常,而只是需要考虑的另一个流。

考虑到这个新的需求,validate()可以稍微修改一下,不再依赖于username键的存在来正常工作。然而,所有其他假设应该保持不变,并且在被违反时应该抛出各自的异常。这是更改后的样子。

img/330715_3_En_1_Figi_HTML.jpg

def validate(data):
    if 'username' in data and data['username'].startswith('_'):
        raise ValueError("Username must not begin with an underscore.")

就这样,去掉了一个假设,函数现在可以很好地运行,不需要在data字典中提供用户名。或者,您现在可以显式检查缺失的用户名,并在真正需要时引发更具体的异常。如何处理剩余的异常取决于调用validate()的代码的需求,并且有一个互补的原则来处理这种情况。

除非明确沉默

像任何其他支持异常的语言一样,Python 允许触发异常的代码捕获它们并以不同的方式处理它们。在前面的验证示例中,很可能应该以比完全回溯更好的方式向用户显示验证错误。考虑一个小型命令行程序,它接受用户名作为参数,并根据前面定义的规则对其进行验证:

img/330715_3_En_1_Figj_HTML.jpg

import sys
def validate(data):
    if 'username' in data and data['username'].startswith('_'):
        raise ValueError("Username must not begin with an underscore.")
if __name__ == '__main__':
    username = sys.argv[1]
    try:
        validate({'username': username})
    except (TypeError, ValueError) as e:
        print (e)
        #out of range since username is empty and there is no
        #second [1] position

在本例中,用于捕获异常并将其存储为变量e的语法首次出现在 Python 3.0 中。在本例中,所有可能引发的异常都将被这段代码捕获,并且只向用户显示消息,而不是完整的回溯。这种形式的错误处理允许复杂的代码使用异常来指示违反了预期,而无需关闭整个程序。

显性比隐性好

简而言之,这个错误处理系统是前面的规则的一个简单例子,该规则支持显式声明而不是隐式行为。默认行为尽可能明显,因为异常总是向上传播到更高级别的代码,但是可以使用显式语法覆盖。

面对模棱两可,拒绝猜测的诱惑

有时,当在不同人编写的代码段之间使用或实现接口时,某些方面可能并不总是清楚的。例如,一种常见的做法是传递字节字符串,而不传递关于它们所依赖的编码的任何信息。这意味着,如果任何代码需要将这些字符串转换为 Unicode 或确保它们使用特定的编码,则没有足够的信息来完成这些工作。

在这种情况下,盲目地选择似乎是最常见的编码是很有诱惑力的。当然,它可以处理大多数情况,这对于任何现实世界的应用来说都足够了。唉,不。编码问题会在 Python 中引发异常,因此这些异常要么会导致应用崩溃,要么会被捕获并忽略,这可能会无意中导致应用的其他部分认为字符串得到了正确转换,而实际上它们并没有得到正确转换。

更糟糕的是,您的应用现在依赖于猜测。这是一个有根据的猜测,当然,也许你有优势,但现实生活中有一个讨厌的习惯,就是无视概率。你很可能会发现,当从真实的人那里得到真实的数据时,你认为最常见的事情实际上不太可能发生。不正确的编码不仅会导致应用出现问题,而且这些问题出现的频率可能比您意识到的要高得多。

更好的方法是只接受 Unicode 字符串,然后可以使用应用选择的任何编码将其写入字节字符串。这消除了所有的歧义,因此您的代码不必再猜测了。当然,如果您的应用不需要处理 Unicode,并且可以简单地传递未经转换的字节字符串,那么它应该只接受字节字符串,而不是为了产生字节字符串而必须猜测要使用的编码。

应该有一种——最好只有一种——显而易见的方法来做这件事

虽然与前面的原则相似,但这条原则通常只适用于库和框架的开发。当设计一个模块、类或函数时,实现许多入口点可能很有诱惑力,每个入口点负责一个稍微不同的场景。例如,在上一节的字节字符串示例中,您可以考虑用一个函数处理字节字符串,用另一个函数处理 Unicode 字符串。

这种方法的问题是,每个接口都给必须使用它的开发人员增加了负担。不仅要记住更多的东西;即使所有的选项都是已知的,也不总是清楚应该使用哪个函数。选择正确的选项通常可以归结为命名,而命名有时是一种猜测。

在前面的例子中,简单的解决方案是只接受 Unicode 字符串,这巧妙地避免了其他问题,但是对于这个原则,建议范围更广。尽可能坚持使用更简单、更通用的接口,比如第五章中说明的协议,只有当你有真正不同的任务要执行时才添加。

您可能已经注意到 Python 有时似乎违反了这条规则,最明显的是在其字典实现中。访问一个值的首选方法是使用括号语法my_dict['key'],但是字典也有一个get()方法,它似乎做完全相同的事情。在处理如此广泛的原则时,这样的冲突经常出现,但是如果你愿意考虑它们,通常有很好的理由。

在字典的例子中,它回到了当违反规则时引发异常的概念。当考虑违反规则时,我们必须检查这两种可用的访问方法所隐含的规则。括号语法遵循一个非常基本的规则:返回所提供的键引用的值。真的就这么简单。任何妨碍它的事情,比如无效的键、丢失的值或被覆盖的协议提供的一些附加行为,都会导致引发异常。

相比之下,get()方法遵循一套更复杂的规则。它检查字典中是否存在提供的键;如果是,则返回关联的值。如果关键字不在字典中,则返回一个替代值。默认情况下,替代值是None,但是可以通过提供第二个参数来覆盖它。

通过列出每种技术遵循的规则,为什么有两种不同的选择就变得更清楚了。括号语法是常见的用例,在除了最乐观的情况之外的所有情况下都会失败,而get()为那些需要它的情况提供了更多的灵活性。一个拒绝让错误悄无声息地过去,而另一个则明确地让它们沉默。本质上,提供两个选项允许字典满足这两个原则。

然而,更重要的是,这种哲学认为应该只有一种显而易见的方法来做这件事。即使在字典示例中,有两种获取值的方法,只有一种方法是显而易见的——括号语法。get()方法是可用的,但是它并不广为人知,当然也没有被宣传为使用字典的主要接口。提供多种方法来做一件事是可以的,只要它们是针对足够不同的用例,最常见的用例是显而易见的选择。

尽管这种方式一开始可能并不明显,除非你是荷兰人

这是对 Python 的创造者和“仁慈的终身独裁者”的祖国吉多·范·罗苏姆的致敬。然而,更重要的是,这承认了并非每个人都以同样的方式看待事物。对一个人来说似乎显而易见的东西对另一个人来说可能完全陌生,尽管这些差异有许多原因,但没有一个是错的。不同的人是不同的,就是这样。

克服这些差异的最简单的方法是正确地记录您的工作,这样即使代码不明显,您的文档也可以指明方向。您可能仍然需要回答文档之外的问题,因此与用户进行更直接的交流通常是有用的,比如邮件列表。最终目标是给用户一个简单的方法,让他们知道你打算如何使用你的代码。为了您和您的用户的利益,对单行注释使用#号,对块注释使用""" """三引号。

img/330715_3_En_1_Figk_HTML.jpg

print('Block comments')
"""
This
is
a'
block
comment """
print('Single line comments too!')
# bye for now!

现在总比没有好

我们都听说过这样一句话:“今天能做的事不要拖到明天。“这对我们所有人来说都是一个有效的教训,但在编程领域尤其如此。当我们开始做我们已经放在一边的事情时,我们可能早就忘记了我们需要的信息。做这件事的最佳时间是当它在我们脑海中的时候。

好的,这一部分很明显,但是作为 Python 程序员,这个反 procasting 子句对我们有特殊的意义。Python 作为一种语言,很大程度上是为了帮助你花时间解决实际问题,而不是为了让程序工作而与语言斗争。

这种关注非常适合迭代开发,允许您快速粗略地设计一个基本的实现,然后随着时间的推移对其进行改进。本质上,这是这个原则的另一个应用,因为它允许你快速开始工作,而不是试图提前计划好一切,可能永远不会实际编写任何代码。

虽然永远比不上现在的

****即使是迭代开发也需要时间。快速开始是有价值的,但是试图立即完成是非常危险的。花时间提炼和阐明一个想法对于正确理解它是必不可少的,不这样做通常会产生可以被描述为——充其量——平庸的代码。用户和其他开发人员没有你的工作总比有一些不符合标准的东西要好。

我们无法知道有多少原本有用的项目因为这种观念而从未公开。无论是在这种情况下,还是在一个糟糕的发布的情况下,结果本质上都是一样的:人们在为你试图解决的相同问题寻找解决方案时,将没有一个可行的选择。真正帮助别人的唯一方法是花时间去做正确的事情。

如果实现很难解释,这是一个坏主意

这是已经提到的另外两个规则的组合:简单比复杂好,复杂比复杂好。有趣的是,这种结合提供了一种方法来识别你何时跨越了从简单到复杂或者从复杂到复杂的界限。当有疑问时,让其他人来运行它,看看需要多少努力才能让他们同意你的实现。

这也加强了沟通对良好发展的重要性。在开源开发中,比如 Python,沟通是过程中显而易见的一部分,但它并不局限于公开贡献的项目。任何开发团队都可以提供更大的价值,如果其成员互相交流,交换想法,并帮助改进实现的话。单人开发团队有时会很成功,但是他们错过了只能由他人提供的关键编辑。

如果实现很容易解释,这可能是一个好主意

乍一看,这似乎只是前面原则的一个明显延伸,只是简单地将“难”和“坏”换成了“容易”和“好”。仔细研究发现,形容词并不是唯一发生变化的东西。一个动词也会改变它的形式:“是”变成了“可能是”这似乎是一个微妙的,无关紧要的变化,但它实际上非常重要。

虽然 Python 高度重视简单性,但许多非常糟糕的想法很容易解释。能够与你的同事交流你的想法是有价值的,但这只是导致真正讨论的第一步。同行评审最大的好处是不同的观点能够澄清和提炼观点,把好的东西变成伟大的东西。

当然,这并不是要贬低程序员个人的能力。毫无疑问,一个人可以独自创造奇迹。但是大多数有用的项目在某个时候都会涉及到其他人,即使只是你的用户。一旦其他人知道了,即使他们不能访问你的代码,也要准备好接受他们的反馈和批评。即使你可能认为你的想法很棒,但其他观点通常会给老问题带来新的见解,这只会让它成为一个更好的产品。

名称空间是一个非常棒的想法:让我们多做一些吧!

在 Python 中,名称空间有多种用途——从包和模块层次结构到对象属性——允许程序员选择函数和变量的名称,而不用担心与他人的选择冲突。名称空间避免了冲突,而不需要每个名称都包含某种唯一的前缀,否则这是必要的。

在很大程度上,您可以利用 Python 的名称空间处理,而无需真正做任何特殊的事情。如果你给一个对象添加属性或方法,Python 会为它处理名称空间。如果您向模块中添加函数或类,或者向包中添加模块,Python 会负责处理。但是您可以做出一些决定来明确地利用更好的名称空间。

一个常见的例子是将模块级函数封装到类中。这创建了一个层次结构,允许相似命名的功能和平共存。它还有一个好处,就是允许使用参数来定制这些类,这样可以影响单个方法的行为。否则,您的代码可能必须依赖由模块级函数修改的模块级设置,这限制了它的灵活性。

然而,并不是所有的函数集都需要包装成类。请记住,扁平比嵌套好,所以只要没有冲突或混乱,通常最好将它们留在模块级别。类似地,如果没有许多功能相似、名称重叠的模块,那么将它们拆分成一个包就没有什么意义了。

不要重复你自己

设计框架可能是一个非常复杂的过程;程序员经常被期望指定各种不同类型的信息。然而,有时可能需要向框架的多个不同部分提供相同的信息。这种情况发生的频率取决于所涉及的框架的性质,但是必须多次提供相同的信息总是一种负担,应该尽可能避免。

本质上,我们的目标是要求您的用户只提供一次配置和其他信息,然后使用 Python 的自省工具(在后面的章节中有详细描述)来提取这些信息,并在其他需要的领域中重用它们。一旦提供了这些信息,程序员的意图就显而易见了,所以仍然不需要任何猜测。

同样重要的是要注意,这并不局限于您自己的应用。例如,如果您的代码依赖于 Django web 框架,您可以访问使用 Django 所需的所有配置信息,这些信息通常非常广泛。你可能只需要让你的用户指出使用他们代码的哪一部分,并访问它的结构来得到你需要的任何东西。

除了配置细节之外,如果它们共享一些共同的行为,代码可以从一个函数复制到另一个函数。根据这一原则,最好将公共代码转移到一个单独的实用函数中。然后,需要该代码的每个函数都可以遵从实用函数,为将来需要相同行为的函数铺平道路。

这种类型的代码分解展示了一些避免重复的更实用的理由。可重用代码的明显优势是它减少了可能出现错误的地方的数量。更好的是,当您发现一个 bug 时,您可以在一个地方修复它,而不是担心找到所有可能出现相同 bug 的地方。也许最好的一点是,将代码隔离在一个单独的函数中可以更容易地以编程方式进行测试,有助于减少一开始就出现错误的可能性。第九章详细介绍了测试。

不要重复自己(干)也是最常见的缩写原则之一,因为它的首字母拼写一个单词非常清楚。有趣的是,根据上下文,它实际上可以有几种不同的用法。

  • 一个形容词:“哇,这个感觉很干!”

  • 一个名词:“此码违干。”

  • 一个动词:“我们把这个弄干一点,好吗?”

松耦合

较大的库和框架经常不得不将它们的代码分成不同职责的独立子系统。从维护的角度来看,这通常是有利的,因为每个部分都包含代码的不同方面。这里关心的是每个部分必须了解其他部分多少,因为这会对代码的可维护性产生负面影响。

这并不是说每个子系统完全不知道其他子系统,也不是说要避免它们相互作用。任何被写为被分离的的应用实际上都不能做任何感兴趣的事情。不与其他代码对话的代码是没有用的。相反,它更多的是关于每个子系统对其他子系统如何工作的依赖程度。

在某种程度上,您可以将每个子系统视为自己的完整系统,有自己的接口来实现。然后,每个子系统都可以调用其他子系统,只提供与被调用的函数相关的信息并获得结果,而不依赖于其他子系统在该函数中做什么。

这种行为有几个很好的理由,最明显的是它有助于使代码更容易维护。如果每个子系统只需要知道它自己的功能是如何工作的,那么对这些功能的更改应该足够本地化,以免对访问它们的其他子系统造成问题。您可以维护一个有限的公共可靠接口集合,同时允许其他任何东西随着时间的推移根据需要进行更改。

松耦合的另一个潜在优势是将一个子系统拆分成它自己的完整应用要容易得多,这个完整的应用随后可以包含在其他应用中。更好的是,像这样创建的应用通常可以发布到整个开发社区,如果您选择接受外部来源的补丁,允许其他人利用您的工作甚至扩展它。

武士原则

正如我在本章开始时所说的,古代日本的武士以遵守武士道而闻名,武士道规范了他们在战时的大部分行为。武士道的一个特别广为人知的方面是,战士应该从战斗中胜利归来,否则就别想回来。编程中的并行性,正如关键字 return、所指示的,是在过程中遇到任何异常时函数的行为。

在本章列出的概念中,这并不是一个独特的概念,而是错误不应该悄无声息地过去并应该避免歧义这一概念的延伸。如果在执行通常返回值的函数时出错,任何返回值都可能被误解为调用成功,而不是识别出发生了错误。所发生的事情的确切性质是非常模糊的,可能会在与真正出错的地方无关的代码中产生错误。

当然,不返回任何有趣内容的函数不会有二义性问题,因为没有任何东西依赖于返回值。不是允许这些函数返回而不引发异常,而是它们实际上是最需要异常的。毕竟,如果没有可以验证返回值的代码,就没有办法知道哪里出错了。

帕累托原则

1906 年,意大利经济学家维尔弗雷多·帕累托指出,意大利 80%的财富只掌握在 20%的公民手中。从那以后,这一想法在经济学以外的许多领域得到了检验,并且发现了类似的模式。确切的百分比可能会有所不同,但随着时间的推移,普遍的观察结果已经出现:在许多系统中,绝大多数的影响只是少数原因的结果。

在编程中,这一原则可以通过多种不同的方式体现出来。其中最常见的是关于早期优化。著名的计算机科学家 Donald Knuth 曾经说过,过早的优化是万恶之源,许多人认为这意味着应该避免优化,直到代码的所有其他方面都已经完成。

Knuth 指的是在过程中过早地只关注性能。除非你已经验证了一个程序确实做了它应该做的事情,否则试图调整它的速度是没有用的。帕累托原则告诉我们,一开始做一点点工作就能对绩效产生巨大影响。

取得这种平衡可能很困难,但是在设计程序时可以做一些简单的事情,这样可以不费吹灰之力处理大部分性能问题。一些这样的技术在本书的剩余部分被列在标有优化的边栏下。

Pareto 原则的另一个应用是在一个复杂的应用或框架中对特性进行优先排序。不要试图一次构建所有的东西,通常最好从能给用户带来最大好处的少数功能开始。这样做可以让您开始关注应用的核心,并将其提供给需要使用它的人,同时您可以根据反馈完善附加功能。

稳健性原则

在互联网的早期发展过程中,很明显,许多正在设计的协议必须由无数不同的程序来实现,并且它们必须协同工作才能高效。获得正确的规范很重要,但是让人们互操作地实现它们更重要。

1980 年,传输控制协议(TCP)用 RFC 761、 3 进行了更新,其中包括了协议设计中最重要的指导原则之一:做什么要保守;接受别人的东西要大方。它被称为“稳健性的一般原则”,但也被称为波斯特定律,以其作者乔恩·波斯特命名。

显而易见,当指导为互联网设计的协议的实现时,这个原则是多么有用。本质上,遵循这一原则的程序将能够更可靠地与不遵循这一原则的程序一起工作。通过在生成输出时遵守规则,不一定完全遵循规范的软件更有可能理解输出。同样,如果您考虑到传入数据中的一些变化,不正确的实现仍然可以向您发送您可以理解的数据。

除了协议设计,这个原则的一个明显应用是在函数中。如果您在接受什么值作为参数方面可以稍微自由一点,那么您可以与提供不同类型值的其他代码一起使用。一个常见的例子是接受浮点数的函数,当给定一个整数或十进制数时,它同样可以工作,因为它们都可以转换为浮点数。

返回值对于函数与调用它的代码的集成也很重要。当一个函数不能做它应该做的事情,因此不能产生一个有用的返回值时,这种情况就会发生。在这些情况下,一些程序员会选择返回None,但这取决于调用该函数的代码来识别并单独处理它。samurai 原则建议,在这些情况下,代码应该引发一个异常,而不是返回一个不可用的值。因为 Python 默认返回None,如果没有返回其他值,显式考虑返回值是很重要的。

不过,尝试寻找一些仍然满足需求的返回值总是有用的。例如,对于一个旨在查找一段文本中某个特定单词的所有实例的函数,如果根本找不到给定的单词,会发生什么情况呢?一种选择是返回None;另一种是引发一些WordNotFound异常。

如果函数应该返回所有的实例,那么它应该已经返回了一个列表或者一个迭代器,所以找不到单词提供了一个简单的解决方案:返回一个空列表或者一个不产生任何东西的迭代器。这里的关键是,调用代码总是可以期待某一类型的值,只要函数遵循健壮性原则,一切都会很好。

如果您不确定哪种方法是最好的,您可以提供两种不同的方法,每种方法都有不同的意图。在第五章中,我将解释字典是如何支持get()__getitem__()方法的,当一个指定的键不存在时,它们会有不同的反应。

除了代码交互之外,健壮性也适用于与使用软件的人打交道。如果你正在编写一个接受人类输入的程序,不管是基于文本的还是基于鼠标的,宽容对待给你的东西总是有帮助的。您可以允许不按顺序指定命令行参数,使按钮更大,允许传入的文件有轻微的格式错误,或者任何有助于人们使用软件而又不牺牲明确性的东西。

向后兼容性

编程本质上是迭代的,当您将代码分发给其他人在他们自己的项目中使用时,这一点最引人注目。每个新版本不仅带来了新的特性,也带来了现有特性以某种方式改变的风险,这将破坏依赖于其行为的代码。通过致力于向后兼容,您可以最小化用户的风险,让他们对您的代码更有信心。

不幸的是,在设计应用时,向后兼容性是一把双刃剑。一方面,您应该总是努力使您的代码尽可能地好,有时这涉及到修改在过程早期做出的决定。另一方面,一旦你做出了重大决定,你需要承诺长期坚持这些决定。双方背道而驰,所以这是一个相当平衡的行为。

也许你能给自己的最大好处就是区分公共接口和私有接口。然后,您可以承诺对公共接口的长期支持,而将私有接口留给更严格的改进和更改。一旦私有接口更加完善,它们就可以提升为公共 API,并为用户记录下来。

文档是公共接口和私有接口的主要区别之一,但是命名也起着重要的作用。以下划线开头的函数和属性通常被认为是私有的,即使没有文档。坚持这一点将有助于您的用户查看源代码,并决定他们想要使用哪些接口,如果他们选择使用私有接口,那么自己承担风险。

然而,有时甚至公共安全的接口也可能需要改变以适应新的特性。不过,通常最好等到主要版本号发生变化,并提前警告用户不兼容的变化将会发生。接下来,您可以致力于新接口的长期兼容性。这是 Python 在开发期待已久的 3.0 版本时采用的方法。

带着它

本章介绍的原则和哲学代表了 Python 社区普遍高度重视的许多理念,但它们只有在实际代码中应用于实际设计决策时才有价值。本书的其余部分将经常引用这一章,解释这些决定如何进入所描述的代码。在下一章中,我将研究一些更基本的技术,你可以在这些技术的基础上把这些原则运用到你的代码中。

****

二、高级基础

像任何其他关于编程的书一样,这本书的其余部分依赖于读者可能会或可能不会认为很平常的一些特性。读者应该对 Python 和编程有很好的了解,但是有许多很少使用的特性在整本书展示的许多技术的操作中非常有用。

因此,尽管看起来不同寻常,这一章还是集中在高级基础的概念上。本章中的工具和技术不一定是常识,但它们为后续更高级的实现奠定了坚实的基础。让我们从 Python 开发中经常出现的一些通用概念开始。

一般概念

在进入更具体的细节之前,重要的是先了解一下隐藏在本章后面的细节背后的概念。这些不同于第一章中讨论的原则和哲学,因为它们更关注实际的编程技术,而之前讨论的是更通用的设计目标。

将第一章视为设计指南,而本章介绍的概念更像是实现指南。当然,像这样具体的描述不会陷入太多的细节中,所以这一节将遵从本书其余章节中更详细的信息。

循环

尽管 Python 代码中可能会出现几乎无限多种不同类型的序列——在本章后面和第五章中会有更多介绍——但大多数使用它们的代码都可以归为两类:实际使用整个序列的代码和只需要序列中的项目的代码。大多数函数以不同的方式使用这两种方法,但是为了理解 Python 提供了什么工具以及应该如何使用它们,这种区别是很重要的。

从纯面向对象的角度来看,与函数式编程的角度相反,很容易理解如何处理代码实际需要使用的序列。您将拥有一个具体的条目,比如一个列表、集合或字典,它不仅拥有与之相关联的数据,还拥有允许访问和修改这些数据的方法。您可能需要多次迭代它,无序地访问单个项,或者从其他方法返回它以供其他代码使用,所有这些都适用于更传统的对象用法。

同样,你可能实际上不需要把整个序列作为一个整体来处理;你可能只对其中的每一项感兴趣。例如,当在一系列数字上循环时,经常会出现这种情况,因为重要的是让循环中的每个数字都可用,而不是让整个数字列表都可用。

这两种方法的区别主要在于意图,但也有技术上的影响。并不是所有的序列都需要加载到内存中,很多甚至根本不需要有一个有限的上限,比如网络流。这一类别包括正奇数集、整数平方和斐波纳契数列,所有这些都是无限长的,很容易计算。因此,它们最适合纯迭代,不需要预先填充列表,这也节省了一点内存。

这样做的主要好处是内存分配。设计用来打印斐波纳契数列的整个范围的程序,在任何给定的时间只需要在内存中保存几个变量,因为数列中的每个值都可以通过前面两个值计算出来。填充一个值列表,即使长度有限,也需要在迭代之前将所有包含的值加载到内存中。如果整个列表永远不会作为一个整体来执行,那么简单地在需要时生成每个项目,并在不再需要时丢弃它,以便生成新的项目,这样效率会高得多。

Python 作为一种语言,提供了几种不同的实现方式来迭代一个序列,而不需要一次将所有的值都放入内存。在其标准库中,Python 在其提供的许多特性中使用了这些技术,这有时可能会导致混淆。Python 允许您毫无问题地编写一个for循环,但是许多序列没有您可能期望在列表中看到的方法和属性。要查看两种类型的循环,请尝试以下操作:

img/330715_3_En_2_Figa_HTML.jpg

last_name='Smith'
count=0
for letter in last_name:
    print(letter,' ' ,count) # note a space between ' '
    count += 1

print('---and the second loop----')
count = 0
while (count<5):
    print(last_name[count], ' ', count)
    count += 1

本章后面关于迭代的部分介绍了创建可迭代序列的一些更常见的方法,以及当您确实需要将序列作为一个整体来操作时,将这些序列转换成列表的一种简单方法。然而,有时拥有一个在这两方面都起作用的对象是有用的,这就需要使用缓存。

贮藏

在计算之外,缓存是一个隐藏的集合,通常是太危险或太有价值而不能直接访问的项目。计算中的定义是相关的,缓存以不影响面向公众的接口的方式存储数据。也许现实世界中最常见的例子是 Web 浏览器,它在第一次被请求时从 Web 上下载文档,但保留该文档的副本。当用户稍后再次请求相同的文档时(如果文档没有改变),浏览器加载私有副本并将其显示给用户,而不是再次点击远程服务器。

在浏览器示例中,公共界面可以是地址栏、用户收藏夹中的条目或来自另一个网站的链接,其中用户永远不必指示文档是应该远程检索还是应该从本地缓存中访问。相反,只要文档不会快速更改,软件就会使用缓存来减少需要发出的远程请求的数量。Web 文档缓存的细节超出了本书的范围,但是它是缓存一般如何工作的一个很好的例子:

img/330715_3_En_2_Figb_HTML.jpg

import webbrowser
webbrowser.open_new('http://www.python.org/')
#more info at:  https://docs.python.org/3.4/library/webbrowser.html

更具体地说,缓存应该被视为一种节省时间或提高性能的实用工具,它不需要明确地存在才能使某个功能正常工作。如果缓存被删除或不可用,使用它的函数应该可以继续正常工作,也许性能会下降,因为它需要重新获取丢失的项目。这也意味着利用缓存的代码必须总是接受足够的信息,以便在不使用缓存的情况下生成有效的结果。

缓存的本质也意味着您需要小心确保缓存是最新的,以满足您的需求。在 Web 浏览器示例中,服务器可以指定浏览器在向服务器请求新文档之前应该保留文档的缓存副本多长时间。在简单的数学示例中,理论上可以永远缓存结果,因为给定相同的输入,结果应该总是相同的。第三章介绍了一种叫做记忆的技术,它就是这样做的。

一个有用的折衷办法是无限期地缓存一个值,但在值更新时立即更新它。这并不总是一个选项,特别是如果值是从外部源检索的,但是当值在应用中更新时,更新缓存是一个很容易包含的步骤,这样就省去了以后必须使缓存无效并从头开始检索值的麻烦。然而,这样做可能会导致性能下降,所以您必须权衡实时更新的优点和这样做可能会损失的时间。

透明度

无论是描述建筑材料,图像格式,还是政府行为,透明都是指看穿或看到事物内部的能力,它在编程中的使用也是如此。出于我们的目的,透明性指的是你的代码能够看到——在很多情况下,甚至能够编辑——计算机可以访问的几乎所有东西。

Python 不支持私有变量的概念,这种概念在许多其他编程语言中很常见,因此所有属性都可以被任何请求者访问。一些语言认为这种类型的开放性对可维护性是一种风险,而是允许实现一个对象的代码单独对该对象的数据负责。尽管这确实防止了一些偶然的内部数据结构滥用,但是 Python 并没有采取任何措施来限制对这些数据的访问。

尽管透明访问最明显的用途是在类实例属性中——这是许多其他语言允许更多隐私的地方——Python 允许您检查对象和实现它们的代码的许多方面。事实上,您甚至可以访问 Python 用来执行函数的已编译字节码。以下是运行时可用信息的几个例子:

  • 对象的属性

  • 对象可用属性的名称

  • 对象的类型

  • 定义类或函数的模块

  • 模块加载的位置(通常是文件名)

  • 函数对象的字节码

这些信息的大部分只在内部使用,但它是可用的,因为有一些潜在的用途在首次编写代码时没有考虑到。在运行时访问或检查这些信息被称为自省,这是实现 DRY(不要重复自己)等原则的系统中的常用策略。Hunt 和 Thomas 对 DRY 的定义是“每一项知识在一个系统中必须有一个单一的、明确的、权威的表示”(《实用主义程序员,2000,作者 A. Hunt 和 D. Thomas)。

本书的其余部分包含了许多不同的内省技术,在这些信息可用的地方。对于那些数据确实应该受到保护的罕见情况,第三章和第四章展示了数据如何显示隐私意图或被完全隐藏。

控制流

一般来说,程序的控制流就是程序在执行过程中所走的路径。控制流更常见的例子,当然包括序列结构,是iffor,while块,它们用于管理你的代码可能需要的最基本的分支。这些块也是 Python 程序员首先要学习的一些东西,因此本节将关注一些较少使用和利用不足的控制流机制。

捕捉异常

第一章解释了 Python 哲学是如何鼓励在违背预期的情况下使用异常的,但是预期在不同的使用中会有所不同。当一个应用或模块依赖于另一个时,这种情况尤其常见,但在单个应用中也很常见。本质上,每当一个函数调用另一个函数时,它可以在被调用函数已经处理的异常之上添加自己的期望。

使用关键字raise用简单的语法引发异常,但是捕获它们稍微复杂一些,因为它使用了关键字的组合。try关键字在您认为可能会发生异常的地方开始一个块,而except关键字标记一个在出现异常时要执行的块。第一部分很简单,因为try没有任何附加信息,最简单的except也不需要任何附加信息:

img/330715_3_En_2_Figc_HTML.jpg

def count_lines(filename):
    """
    Count the number of lines in a file. If the file can't be
    opened, it should be treated the same as if it was empty.
    """
    try:
        return len(open(filename, 'r').readlines())
    except:
         print('exception error reading the file or calculating lines!')
        # Something went wrong reading the file
        # or calculating the number of lines.
        return 0
myfile=input('Enter a file to open:  ')
print(count_lines(myfile))

任何时候在try块中出现异常,就会执行except块中的代码。就目前的情况而言,这并没有对可能出现的许多不同的异常做出任何区分;无论发生什么,函数总是返回一个数字。然而,实际上您很少想这样做,因为许多异常实际上应该传播到调用者——错误不应该无声无息地传递。一些著名的例子是SystemExitKeyboardInterrupt,这两者通常都会导致程序停止运行。

为了考虑那些你的代码不应该干涉的异常,关键字except可以接受一个或多个应该被显式捕获的异常类型。任何其他的牌都会被简单地加注,就好像你根本没有try牌一样。这使得except块只关注那些应该明确处理的情况,所以你的代码只需要处理它应该管理的事情。对您刚才尝试的内容做一些小的更改,如下所示,看看效果如何:

img/330715_3_En_2_Figd_HTML.jpg

def count_lines(file_name):
    """
    Count the number of lines in a file. If the file can't be
    opened, it should be treated the same as if it was empty.
    """
    try:
        return len(open(file_name, 'r').readlines())
    except IOError:
        # Something went wrong reading the file.
        return 0
my_file=input('Enter a file to open:  ')
print(count_lines(my_file))

通过更改代码来显式接受IOError,只有在从文件系统访问文件时出现问题时,except块才会执行。任何其他错误,比如甚至不是字符串的filename,都将在这个函数之外被引发,由调用堆栈中的其他代码处理。

如果您需要捕获多种异常类型,有两种方法。第一个也是最简单的方法是简单地捕获一些基类,所有必需的异常都是从这个基类派生出来的。因为异常处理与指定的类及其所有子类相匹配,所以当您需要捕捉的所有类型都有一个公共基类时,这种方法非常有效。在行计数的例子中,您可能会遇到IOErrorOSError,它们都是EnvironmentError的后代:

img/330715_3_En_2_Fige_HTML.jpg

def count_lines(file_name):
    """
    Count the number of lines in a file. If the file can't be
    opened, it should be treated the same as if it was empty.
    """
    try:
        return len(open(file_name, 'r').readlines())
    except EnvironmentError:
        # Something went wrong reading the file.
        return 0

注意

尽管我们只对IOErrorOSError感兴趣,但是EnvironmentError的所有子类也会被捕获。在这种情况下,这很好,因为它们是EnvironmentError的唯一子类,但是一般来说,您会希望确保没有捕捉到太多的异常。

其他时候,您可能希望捕获不共享公共基类的多个异常类型,或者可能将其限制在较小的类型列表中。在这些情况下,您需要单独指定每种类型,用逗号分隔。在count_lines()的情况下,如果传入的文件名不是有效的字符串,也有可能引发TypeError:

img/330715_3_En_2_Figf_HTML.jpg

def count_lines(file_name):
    """
    Count the number of lines in a file. If the file can't be
    opened, it should be treated the same as if it was empty.
    """
    try:
        return len(open(file_name, 'r').readlines())
    except (EnvironmentError, TypeError):
        # Something went wrong reading the file.
        return 0

如果您需要访问异常对象本身,也许是为了以后记录消息,您可以通过添加一个带有名称的 作为 子句(在下一个示例中为 作为 e )来实现,该子句将被绑定到异常对象:

img/330715_3_En_2_Figg_HTML.jpg

import logging

def count_lines(file_name):
    """
    Count the number of lines in a file. If the file can't be
    opened, it should be treated the same as if it was empty.
    """
    try:
        return len(open(file_name, 'r').readlines())
    except (EnvironmentError, TypeError) as e:
        # Something went wrong reading the file.
        logging.error(e)
        return 0

兼容性:3.0 之前

在 Python 3.0 中,捕捉异常的语法变得更加明确,减少了一些常见错误。在旧版本中,用逗号将异常类型与用于存储异常对象的变量分开。为了捕获多个异常类型,您需要显式地将这些类型用括号括起来,形成一个元组。

因此,当试图捕捉两个异常类型但不在任何地方存储值时,很容易不小心忘记括号。这不是语法错误,而是只捕捉第一类异常,将其值存储在第二类异常的名称下。使用except TypeError, ValueError实际上存储了一个名为ValueErrorTypeError对象!

为了解决这种情况,添加了关键字as,并成为存储异常对象的唯一方式。尽管这消除了模糊性,但为了清晰起见,多个异常仍然必须包装在元组中。

可以使用多个except子句,允许您以不同的方式处理不同的异常类型。例如,EnvironmentError OSError 的构造函数可选地接受两个参数,一个错误代码和一个错误消息,它们组合起来形成完整的字符串表示。为了只记录这种情况下的错误消息,但仍然正确处理TypeError的情况,可以使用两个except子句:

img/330715_3_En_2_Figh_HTML.jpg

import logging

def count_lines(file_name):
    """
    Count the number of lines in a file. If the file can't be
    opened, it should be treated the same as if it was empty.
    """
    try:
        return len(open(file_name, 'r').readlines())
    except TypeError as e:
        # The filename wasn't valid for use with the filesystem.
        logging.error(e)
        return 0
    except EnvironmentError as e:
        # Something went wrong reading the file.
        logging.error(e.args[1])
        return 0

异常链

有时,在处理一个异常时,可能会引发另一个异常。这可以通过关键字raise显式实现,也可以通过作为处理的一部分执行的其他代码隐式实现。无论哪种方式,这种情况都带来了一个问题,即哪个异常足够重要,足以将其自身呈现给应用的其余部分。这个问题的确切答案取决于代码的布局,所以让我们看一个简单的例子,其中异常处理代码打开并写入一个日志文件:

img/330715_3_En_2_Figi_HTML.jpg

def get_value(dictionary, name):
    try:
        return dictionary[name]
    except Exception as e:
        print("exception hit..writing to file")
        log = open('logfile.txt', 'w')
        log.write('%s\n' % e)
        log.close()
names={"Jack":113, "Jill":32,"Yoda":395}
print(get_value(names,"Jackz"))#change to Jack and it runs fine

如果在写入日志时出现任何问题,将会引发一个单独的异常。尽管这个新的异常很重要,但是已经有了一个不应该被忘记的异常。为了保留原始信息,文件异常获得了一个新属性,称为__context__,它保存原始异常对象。每个异常都可能相互引用,形成一个链,依次代表所有出错的地方。考虑当get_value()失败时会发生什么,但是logfile.txt是一个只读文件:

img/330715_3_En_2_Figj_HTML.jpg

 get_value({}, 'test')
Traceback (most recent call last):

KeyError: 'test'

During handling of the above exception, another exception occurred:

Traceback (most recent call last):

IOError: [Errno 13] Permission denied: 'logfile.txt'

这是一个隐含的链,因为异常只是通过在执行过程中遇到它们的方式联系起来的。有时您将自己生成一个异常,并且您可能需要包含一个在其他地方生成的异常。一个常见的例子是使用传入的函数来验证值。如第 3 和第四章所述,验证功能通常会产生一个ValueError,而不管错误是什么。

这是一个形成显式链的好机会,因此我们可以直接引发一个ValueError,同时在幕后保留实际的异常。Python 允许在raise语句的末尾包含from关键字:

img/330715_3_En_2_Figk_HTML.jpg

 def validate(value, validator):
     try:
         return validator(value)
     except Exception as e:
         raise ValueError('Invalid value: %s' % value) from e

 def validator(value):
     if len(value) > 10:
         raise ValueError("Value can't exceed 10 characters")

 validate('test', validator)
 validate(False, validator)
Traceback (most recent call last):

TypeError: object of type 'bool' has no len()

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  ValueError: invalid value: False

因为这将多个异常封装到一个对象中,所以对于哪个异常真正被传递似乎是不明确的。要记住的一个简单规则是,最近的异常是被引发的异常,其他任何异常都可以通过__context__属性获得。通过将这些函数中的一个封装在一个新的try块中并检查异常的类型,这很容易测试:

img/330715_3_En_2_Figl_HTML.jpg

 try:
     validate(False, validator)
 except Exception as e:
     print(type(e))

<class 'ValueError'>

当一切顺利的时候

另一方面,您可能会发现您有一个复杂的代码块,您需要捕捉其中一部分可能突然出现的异常,但是该部分之后的代码应该不进行任何错误处理。显而易见的方法是简单地将代码添加到try / except块之外。下面是我们如何调整count_lines()函数,将产生错误的代码包含在try块中,同时在处理完异常后进行行计数:

img/330715_3_En_2_Figm_HTML.jpg

import logging

def count_lines(file_name):
    """
    Count the number of lines in a file. If the file can't be
    opened, it should be treated the same as if it was empty.
    """
    try:
        file = open(file_name, 'r')
    except TypeError as e:
        # The filename wasn't valid for use with the filesystem.
        logging.error(e)
        return 0
    except EnvironmentError as e:
        # Something went wrong reading the file.
        logging.error(e.args[1])
        return 0
    return len(file.readlines())

在这种特殊情况下,函数将按预期工作,所以一切似乎都很好。不幸的是,由于这一特定案例的性质,它具有误导性。因为每个except块都显式地从函数返回一个值,所以只有在没有引发异常的情况下,才会到达错误处理后的代码。

注意

我们可以在文件打开后直接放置文件读取代码,但是如果在那里出现任何异常,它们将使用与文件打开相同的错误处理被捕获。将它们分开是更好地控制异常整体处理方式的一种方式。您可能还注意到,该文件在这里的任何地方都没有关闭。随着这个函数的不断扩展,我们将在后面的章节中处理这个问题。

然而,如果except块只是记录了错误并继续前进,Python 会尝试计算文件中的行数,即使没有打开任何文件。相反,我们需要一种方法来指定一个代码块应该只在根本没有出现异常的情况下运行,所以你的except块如何执行并不重要。Python 通过else关键字提供了这个特性,它定义了一个单独的块:

img/330715_3_En_2_Fign_HTML.jpg

import logging

def count_lines(filename):
    """
    Count the number of lines in a file. If the file can't be
    opened, it should be treated the same as if it was empty.
    """
    try:
        file = open(filename, 'r')
    except TypeError as e:
        # The filename wasn't valid for use with the filesystem.
        logging.error(e)
        return 0
    except EnvironmentError as e:
        # Something went wrong reading the file.
        logging.error(e.args[1])
        return 0
    else:
        return len(file.readlines())

警告

引发异常并不是告诉 Python 避免else块的唯一方法。如果函数在任何时候在try块中返回值,Python 将简单地按照指示返回值,完全跳过else块。

不考虑例外情况继续进行

许多函数执行某种类型的设置或资源分配,在将控制返回给外部代码之前,必须清理这些设置或资源分配。面对异常,清理代码可能不会总是被执行,这可能会使文件或套接字保持打开状态,或者在不再需要大对象时将它们留在内存中。

为了方便起见,Python 还允许使用一个finally块,每当相关的tryexceptelse块完成时,就会执行这个块。因为count_lines()打开了一个文件,最佳实践建议它也显式地关闭该文件,而不是等待垃圾回收来处理它。使用finally提供了一种确保文件总是被关闭的方法。

还有一点要考虑。到目前为止,count_lines()只预测了在试图打开文件时可能发生的异常,尽管在读取文件时会出现一个常见的异常:UnicodeDecodeError。第七章介绍了一点 Unicode 以及 Python 如何处理它,但是现在,只需要知道它经常出现。为了捕捉这个新的异常,有必要将readlines ()调用移回到try块中,但是我们仍然可以将行计数留在else块中:

img/330715_3_En_2_Figo_HTML.jpg

import logging

def count_lines(file_name):
    """
    Count the number of lines in a file. If the file can't be
    opened, it should be treated the same as if it was empty.
    """
    file = None  # file must always have a value
    try:
        file = open(file_name, 'r')
        lines = file.readlines()
    except TypeError as e:
        # The filename wasn't valid for use with the filesystem.
        logging.error(e)
        return 0
    except EnvironmentError as e:
        # Something went wrong reading the file.
        logging.error(e.args[1])
        return 0
    except UnicodeDecodeError as e:
        # The contents of the file were in an unknown encoding.
        logging.error(e)
        return 0
    else:
        return len(lines)
    finally:
        if file:
            file.close()

当然,在一个简单的行计数函数中不太可能有这么多的错误处理。毕竟,它的存在只是因为我们想在出错时返回 0。在现实世界中,您更有可能让异常在count_lines()之外运行,让其他代码负责如何处理它。

小费

使用一个with块可以使这种处理变得简单一点,这将在本章后面描述。

优化循环

因为某种循环在大多数类型的代码中非常常见,所以确保它们尽可能高效地运行是很重要的。本章后面的迭代部分涵盖了优化任何循环设计的各种方法,而第五章解释了如何控制for循环的行为。相反,本节将重点介绍while循环的优化。

典型地,while用于检查在循环过程中可能改变的条件,以便一旦条件评估为假,循环可以结束执行。当条件太复杂而无法提取到单个表达式中时,或者当循环预计会由于异常而中断时,保持while表达式始终为真并在适当的时候使用break语句结束循环更有意义。

尽管任何计算结果为 true 的表达式都会产生预期的功能,但是有一个特定的值可以让它变得更好。Python 知道True总会计算为 true,所以它在幕后做了一些额外的优化来加速循环。本质上,它甚至不需要每次都检查条件;它只是无限期地运行循环中的代码,直到遇到异常、break语句或return语句:

img/330715_3_En_2_Figp_HTML.jpg

def echo():
    """Returns everything you type until you press Ctrl-C"""

    while True:
        try:
            print(input'Type Something or CTRL C to exit: ')
        except KeyboardInterrupt:
            print()  # Make sure the prompt appears on a new line.
            print('bye for now...:')
            break
echo()

带有语句的

*本章前面的异常处理一节中提到的finally块是一种在函数之后进行清理的便捷方式,但有时这是首先使用try块的唯一原因。有时你不想让任何异常沉默,但是你仍然想确保清理代码执行,不管发生什么。单独处理异常,一个简单版本的count_lines()可能看起来像这样:

img/330715_3_En_2_Figq_HTML.jpg

def count_lines(file_name):
    """Count the number of lines in a file."""

    file = open(file_name, 'r')
    try:
        return len(file.readlines())
    finally:
        file.close()

如果文件无法打开,它甚至会在进入try块之前引发一个异常,而其他可能出错的事情会在 try 块中发生,这将导致finally块清理文件。不幸的是,仅仅为了这个而使用异常处理系统的能力是一种浪费。相反,Python 提供了另一种选择,这种选择也比异常处理有一些优势。

关键字with可以用来开始一个新的代码块,很像try,但是有一个非常不同的目的。通过使用with块,您定义了一个特定的上下文,块的内容应该在这个上下文中执行。然而,它的美妙之处在于,您在with语句中提供的对象决定了上下文的含义。

例如,您可以在with语句中使用open()来在该文件的上下文中运行一些代码。在这种情况下,with还提供了一个as子句,它允许一个对象在当前上下文中执行时被返回使用。下面是如何重写新版count_lines()来利用这一切的方法:

img/330715_3_En_2_Figr_HTML.jpg

def count_lines(file_name):
    """Count the number of lines in a file."""

    with open(file_name, 'r') as file:
        return len(file.readlines())

在切换到使用with语句之后,count_lines()就只剩下这些了。异常处理由管理with语句的代码完成,而文件关闭行为实际上是由文件本身通过上下文管理器提供的。上下文管理器是一些特殊的对象,它们知道with语句,并且能够准确地定义在它们的上下文中执行代码意味着什么。

简而言之,在with块执行之前,上下文管理器有机会运行自己的代码;完成后,它会运行更多的清理代码。在这些阶段中的每一个阶段究竟发生了什么会有所不同。在open()的情况下,它打开文件,并在块执行完毕时自动关闭文件。

对于文件,上下文显然总是围绕一个打开的文件对象,使用在as子句中给定的名称使其对块可用。然而,有时上下文完全是环境的,所以在执行期间没有这样的对象可以使用。为了支持这些情况,as子句是可选的。

事实上,在open()的情况下,您甚至可以省略as子句,而不会导致任何错误。当然,您的代码也无法使用该文件,所以它没什么用,但是 Python 中没有任何东西阻止您这样做。如果在使用不提供对象的上下文管理器时包含了一个as子句,那么您定义的变量将简单地用None填充,因为如果没有指定其他值,所有函数都返回None

Python 中有几个可用的上下文管理器,其中一些将在本书的其余部分详细介绍。此外,第五章展示了如何编写自己的上下文管理器,以便定制上下文行为来满足自己代码的需求。

条件表达式

通常,您可能会发现自己需要访问两个值中的一个,而使用哪个值取决于表达式的求值。例如,如果值超过特定值,则向用户显示一个字符串,否则显示另一个字符串,这是很常见的。通常,这将使用if / else组合来完成,如下所示:

img/330715_3_En_2_Figs_HTML.jpg

def test_value(value):
    if value < 100:
        return 'The value is just right.'
    else:
        return 'The value is too big!'
print(test_value(55))

不要把它写成四行,可以用一个条件表达式把它压缩成一行。通过将ifelse块转换成表达式中的子句,Python 更简洁地实现了同样的效果:

img/330715_3_En_2_Figt_HTML.jpg

def test_value(value):
    return 'The value is ' + ('just right.' if value < 100 else 'too big!')
print(test_value(55))

可读性计数

如果您已经习惯了其他编程语言的这种行为,Python 的排序初看起来可能不太寻常。其他语言,比如 C++,实现了类似于expression ? value_1 : value_2的形式。也就是说,首先是要测试的表达式,然后是表达式为真时要使用的值,最后是表达式为假时要使用的值。

相反,Python 试图使用一种更明确地描述实际情况的形式。预期是表达式在大多数情况下为真,所以首先是关联值,然后是表达式,最后是表达式为假时要使用的值。这将整个语句考虑在内,将更常见的值放在没有表达式的地方。例如,你最终会得到类似于return value ...x = value ...的东西。

因为表达式是随后添加的,所以它强调了表达式只是第一个值的限定的概念。"只要表达式为真,就使用该值;否则,使用另一个。”如果你习惯了另一种语言,这可能看起来有点奇怪,但是当你想到用简单的英语表达的时候,这是有意义的。

还有另一种方法,有时用于模拟本节中描述的条件表达式的行为。这经常在早期的 Python 安装中使用,那时还没有if / else表达式。取而代之的是,许多程序员依赖于andor操作符的行为,可以让它们做一些非常类似的事情。下面是如何仅使用这些运算符来重写前面的示例:

img/330715_3_En_2_Figu_HTML.jpg

def test_value(value):
    return 'The value is ' + (value < 100 and 'just right.' or 'too big!')

这使得组件的顺序更符合其他编程语言中使用的形式。这一事实可能会让习惯于使用这些语言的程序员感到更舒服,而且它肯定会保持与甚至更老版本的 Python 的兼容性。不幸的是,它伴随着一个隐藏的危险,这种危险通常不为人知,直到它在没有任何解释的情况下破坏了一个正常工作的程序。要了解原因,让我们来看看发生了什么。

在许多语言中,and操作符的工作方式类似于&&操作符,检查操作符左边的值是否为真。如果没有,and返回它左边的值;否则,计算并返回左侧的值。因此,如果值 50 被传入test_value(),左边的计算结果为真,and子句计算结果为字符串,'just right.'在这个过程中,代码如下所示:

img/330715_3_En_2_Figv_HTML.jpg

    return 'The value is ' + ('just right.' or 'too big!')

从这里开始,or操作符的工作方式类似于and,检查它左边的值,看它的计算结果是否为真。不同之处在于,如果值为 true,则返回该值,甚至根本不计算运算符右侧的值。看看这里的压缩代码,很明显,or会返回字符串,'just right.'

相比之下,如果传递给test_value()函数的值是 150,行为就会改变。因为150 < 100的计算结果为假,所以and操作符返回该值,而不计算右边的值。在这种情况下,结果表达式如下:

img/330715_3_En_2_Figw_HTML.jpg

    return 'The value is ' + (False or 'too big!')

因为False明显为假,or运算符反而将值返回到它右边,'too big!'这种行为导致很多人依赖and / or组合进行条件表达式。但是你注意到问题了吗?这里做的一个假设在很多情况下会导致整个事情失败。

and子句的左侧为真时,问题出在or子句中。在这种情况下,or子句的行为完全取决于操作符左边的值。在这里显示的例子中,它是一个非空的字符串,其值总是为 true,但是如果您向它提供一个空字符串,数字 0,或者最糟糕的是,一个包含在代码执行之前无法确定的值的变量,会发生什么呢?

本质上发生的是,and子句的左边计算为 true,但是右边计算为 false,所以该子句的最终结果是 false 值。然后,当or子句求值时,它的左边为 false,所以它将值返回到它的右边。最后,不管表达式开头的值是多少,表达式总是将项目返回到or操作符的右边。

因为没有引发异常,所以看起来代码中并没有出现任何问题。相反,它只是看起来像表达式中的第一个值是假的,因为在这种情况下,它将返回您所期望的值。这可能会导致您尝试调试定义值的任何代码,而不是查看真正的问题,即两个运算符之间的值。

最终,使它如此难以确定的是,你必须不信任你自己的代码,消除你对它应该如何工作的任何假设。你必须真正用 Python 的眼光看待它,而不是人类的眼光。

循环

通常有两种看待序列的方式:作为一个项目集合,或者作为一次访问一个项目的方式。这两者并不相互排斥,但是为了理解每种情况下可用的不同特性,将它们分开是有用的。将集合作为一个整体来处理要求所有的条目都在内存中,但是一次访问一个条目通常会更有效。

迭代指的是这种更有效的遍历集合的形式,一次只处理一个项目,然后继续处理下一个项目。对于任何类型的序列来说,迭代都是一个选项,但是真正的优势在于特殊类型的对象,它们不需要一次将所有内容都加载到内存中。这方面的典型例子是 Python 的内置range()函数,它似乎可以迭代给定范围内的整数:

img/330715_3_En_2_Figx_HTML.jpg

>>>for x in range(5):
    print(x)
0
1
2
3
4

乍一看,range()似乎返回了一个包含适当值的列表,但事实并非如此。如果您单独检查它的返回值,而不对它进行迭代,就会显示:

img/330715_3_En_2_Figy_HTML.jpg

>>>range(5)
>>>range(0, 5)
>>>list(range(5))
[0, 1, 2, 3, 4]

range对象本身不包含序列中的任何值。相反,它在迭代过程中按需一次生成一个。如果你真的想要一个可以添加或删除条目的列表,你可以通过将range对象传递给一个新的list对象来转换它。这就像一个for循环一样进行内部迭代,所以生成的列表使用与迭代range本身时相同的值。

第五章展示了如何编写自己的可迭代对象,其工作方式类似于range()。除了提供可迭代对象之外,在不同的情况下,出于不同的目的,有许多方法可以迭代这些对象。for循环是最明显的技术,但是 Python 也提供了其他形式的语法,这将在本节中概述。

序列解包

一般来说,你可以一次给一个变量赋一个值,所以当你有一个序列时,你可以把整个序列赋给一个变量。当序列很小时,并且您知道序列中有多少项以及每项将是什么,这是相当有限的,因为您通常最终只是单独访问每项,而不是作为序列来处理它们。

这在处理元组时尤其常见,其中序列通常具有固定的长度,并且序列中的每一项都具有预定的含义。这种类型的元组也是从一个函数返回多个值的首选方式,这使得必须将它们作为一个序列来处理更加麻烦。理想情况下,在获取函数的返回值时,您应该能够直接将它们作为单独的项进行检索。

为此,Python 支持一种称为序列解包的特殊语法。您可以在=操作符的左侧指定多个名称作为元组,而不是指定一个名称来赋值。这将导致 Python 解包操作符右侧的序列,将每个值分配给左侧的相关名称:

img/330715_3_En_2_Figz_HTML.jpg

>>> 'propython.com'.split('.')
['propython', 'com']
>>> components = 'propython.com'.split('.')
>>> components
['propython', 'com']
>>> domain, tld = 'propython.com'.split('.')
>>> domain
'propython'
>>> tld
'com'
>>> domain, tld = 'www.propython.com'.split('.')
Traceback (most recent call last):
  ...
ValueError: too many values to unpack

本例末尾显示的错误说明了这种方法的唯一重大限制:要分配的变量数量必须与序列中的项目数量相匹配。如果它们不匹配,Python 就不能正确地赋值。但是,如果您将元组视为类似于参数列表,那么还有另一个选项可用。

如果您在变量列表的最后一个名称前添加一个星号,Python 将保留一个列表,其中包含无法放入其他变量的任何值。结果列表存储在 final 变量中,因此您仍然可以分配一个序列,该序列包含的项比您拥有的显式变量要多。只有当序列中的项目多于要分配的变量时,这种方法才有效。如果情况相反,您仍然会遇到前面显示的TypeError:

img/330715_3_En_2_Figaa_HTML.jpg

>>> domain, *path = 'propython.com/example/url'.split('/')
>>> domain
'propython.com'
>>> path
['example', 'url']

注意

第三章展示了类似的语法如何应用于函数参数。

列表理解

当序列中的项目多于实际需要的项目时,生成一个新列表并只添加那些符合特定标准的项目通常会很有用。有几种方法可以做到这一点,最明显的是使用一个简单的for循环,依次添加每个项目:

img/330715_3_En_2_Figab_HTML.jpg

>>> output = []
>>> for value in range(10):
...     if value > 5:
...         output.append(str(value))
...
>>> output
['6', '7', '8', '9']

不幸的是,这给代码增加了四行和两级缩进,尽管这是一种非常常用的模式。相反,Python 为这种情况提供了更简洁的语法,允许您将代码的三个主要方面表达在一行中:

  • 要从中检索值的序列

  • 用于确定是否应包含某个值的表达式

  • 用于为新列表提供值的表达式

这些都被组合成一种叫做列表理解的语法。下面是前面的例子在使用这个结构重写后的样子。为清晰起见,突出显示了该表单的三个基本部分:

img/330715_3_En_2_Figac_HTML.jpg

>>> output = [str(value) for value in range(10) if value > 5]
>>> output
['6', '7', '8', '9']

正如您所看到的,整个表单的三个部分已经稍微进行了重新安排,首先是最终值的表达式,然后是迭代,最后是决定包含哪些项的条件。你也可以把包含新列表的变量看作是表单的第四部分,但是因为理解实际上只是一个表达式,所以不需要给它赋值。它可以很容易地用于将一个列表输入到一个函数中:

img/330715_3_En_2_Figad_HTML.jpg

>>> min([value for value in range(10) if value > 5])
6

当然,这似乎违背了之前指出的迭代的全部要点。毕竟,理解返回一个完整的列表,只是在min()处理这些值时被丢弃。对于这些情况,Python 提供了不同的选项:生成器表达式。

生成器表达式

与其根据特定的标准创建一个完整的列表,还不如利用迭代的力量来完成这个过程。不要将压缩放在括号中,这表示创建了一个合适的列表,而是可以将它放在括号中,这将创建一个生成器。下面是它的实际效果:

img/330715_3_En_2_Figae_HTML.jpg

>>> gen = (value for value in range(10) if value > 5)
>>> gen
<generator object <genexpr> at 0x...>
>>> min(gen)
6
>>> min(gen)
Traceback (most recent call last):
  ...
ValueError: min() arg is an empty sequence
>>> min(value for value in range(10) if value > 5)
6

这里发生了一些事情,但是一旦你看到了输出,就更容易理解了,这样你就有了一个参考框架。首先,生成器实际上只是一个可迭代的对象,您不必使用显式接口来创建它。第五章展示了如何手动创建迭代器,甚至如何更灵活地创建生成器,但是生成器表达式是处理它们最简单的方法。

当您创建一个发生器(无论是发生器表达式还是其他形式)时,您不能立即访问序列。生成器对象还不知道它需要迭代哪些值;它不会知道,直到它真正开始产生它们。因此,如果您没有对生成器进行迭代就查看或检查它,您将无法访问全部的值。

为了检索这些值,您需要做的就是像平常一样遍历生成器,它会很高兴地根据需要输出值。这一步在很多内置函数中隐式执行,比如min()。如果这些函数能够在不构建完整列表的情况下运行,那么您可以使用生成器来显著提高性能。如果他们不得不创建一个新的列表,延迟到函数真正需要创建它的时候,你也不会失去任何东西。

但是请注意,如果对生成器迭代两次会发生什么。第二次通过时,您得到一个错误,您试图在一个空序列中传递它。记住,一个生成器并不包含所有的值;当被要求这样做时,它只是迭代它们。一旦迭代完成并且不再有值需要迭代;发电机无法重启。相反,它只是在此后每次调用时返回一个空列表。

这种行为背后有两个主要原因。首先,如何重新开始这个序列并不总是显而易见的。一些可迭代的对象,比如range(),确实有一个明显的方法来重新启动它们自己,所以当迭代多次时它们会重新启动。不幸的是,因为有许多方法可以创建生成器——通常还有迭代器——所以由 iterable 本身来决定何时以及如何重置序列。第五章更详细地解释了这种行为,以及你如何根据自己的需要定制它。

第二,不是所有的序列一旦完成就应该被重置。例如,您可以实现一个接口,用于在一组活动用户之间循环,这些用户可能会随时间而变化。一旦您的代码完成了对可用用户的迭代,它就不应该简单地一次又一次地重置为相同的序列。不断变化的用户群意味着 Python 本身不可能猜测如何控制它。相反,这种行为由更复杂的迭代器控制。

关于生成器表达式,最后要指出的一点是:尽管它们必须总是用括号括起来,但是这些括号并不总是需要对表达式是唯一的。本节示例中的最后一个表达式简单地使用函数调用中的括号来括住生成器表达式,这也很好。

这种形式乍一看可能有点奇怪,但是在这个简单的例子中,它可以省去一组额外的括号。但是,如果生成器表达式只是多个参数中的一个,或者是更复杂的表达式的一部分,您仍然需要在生成器表达式本身周围包含显式括号,以确保 Python 知道您的意图。

集合理解

集合——在“集合”一节中有更详细的描述——在结构上与列表非常相似,所以你可以用与列表基本相同的方式使用理解来构建集合。两者之间唯一的显著区别是使用了花括号,而不是表达式两边的括号:

img/330715_3_En_2_Figaf_HTML.jpg

>>> {str(value) for value in range(10) if value > 5}
{'6', '7', '8', '9'}

注意

与序列不同,集合是无序的,因此不同的平台可能以不同的顺序显示项目。唯一的保证是相同的项目将出现在集合中,而不管平台如何。

词典释义

当然有一个主题随着不同类型的理解的构建而发展,而且仅限于一维序列。字典也可以是序列的一种形式,但是每个条目实际上是一对键和它的值。这反映在文字形式中,通过使用冒号将每个键与其值分开。

因为冒号是区分字典和集合的语法的因素,所以同一个冒号也是区分字典理解和集合理解的因素。在通常包含单个值的地方,只需提供一个键/值对,用冒号分隔。其余的理解遵循与其他类型相同的规则:

img/330715_3_En_2_Figag_HTML.jpg

>>> {value: str(value) for value in range(10) if value > 5}
{8: '8', 9: '9', 6: '6', 7: '7'}

注意

记住,字典是无序的,所以它们的键很像集合。如果您需要一个具有可靠排序键的字典,请参阅本章后面的“有序字典”一节。

将可重复项链接在一起

在大多数情况下,使用一个 iterable 就足够了,但是有时您需要一个接一个地访问一个 iterable,对每个 iterable 执行相同的操作。简单的方法是只使用两个独立的循环,为每个循环复制代码块。合乎逻辑的下一步是将代码重构为一个函数,但是现在您在混合中有了一个额外的函数调用,它实际上只需要在循环内部完成。

相反,Python 提供了chain()函数,作为其itertools模块的一部分。itertools模块包括许多不同的实用程序,其中一些将在下面的章节中介绍。特别是chain()函数,它接受任意数量的 iterabless 并返回一个新的生成器,该生成器将依次迭代每个 iterable:

img/330715_3_En_2_Figah_HTML.jpg

>>> import itertools
>>> list(itertools.chain(range(3), range(4), range(5)))
[0, 1, 2, 0, 1, 2, 3, 0, 1, 2, 3, 4]

将可重复项压缩在一起

另一个涉及多个可重复项的常见操作是将它们并排放在一起。来自每个 iterable 的第一个项目将组合在一起形成一个元组,作为新生成器返回的第一个值。所有第二个项目都成为生成器中第二个元组的一部分,依此类推。内置的zip()函数在需要时提供该功能:

img/330715_3_En_2_Figai_HTML.jpg

>>> list(zip(range(3), reversed(range(5))))
[(0, 4), (1, 3), (2, 2)]

请注意,尽管第二个 iterable 有五个值,但结果序列只包含三个值。当给定不同长度的迭代器时,zip()可以说是最小公分母。本质上,zip()确保结果序列中的每个元组的值正好与要连接在一起的迭代器的数量一样多。一旦最小的序列被用尽,zip()简单地停止寻找其他的。

这个功能在创建字典时特别有用,因为一个序列可以用来提供键,而另一个序列提供值。使用zip()可以将这些连接到正确的配对中,然后可以直接传递给新的dict()。在下一个例子中,ASCII 表中的 97 是小写的“a”,98 是“b”,直到(但不包括)指定的最后一个数字(102),所以 101 是“e”。map()函数迭代一组值;然后将它与来自values的索引值 zip 配对,以构建字典:

img/330715_3_En_2_Figaj_HTML.jpg

>>> keys = map(chr, range(97, 102))
>>> values = range(1, 6)
>>> dict(zip(keys, values))
{'a': 1, 'c': 3, 'b': 2, 'e': 5, 'd': 4}

收集

Python 发行版中有许多众所周知的标准对象,既可以作为所有模块的内置对象,也可以作为标准包库的一部分。诸如整数、字符串、列表、元组和字典之类的对象在几乎所有 Python 程序中都是常用的,但是其他对象,包括命名元组的集合和一些特殊类型的字典,使用得不太频繁,对于那些还没有需要发现它们的人来说可能并不熟悉。

其中一些是内置类型,总是可用于每个模块,而另一些是每个 Python 安装中包含的标准库的一部分。还有更多是由第三方应用提供的,其中一些已经相当普遍地安装了,但是本节将只讨论 Python 本身包含的那些。

设置

通常,对象集合在 Python 中由元组和列表表示,但是集合提供了另一种处理相同数据的方式。本质上,集合的工作方式很像列表,但是不允许有任何重复,这使得它对于识别集合中的唯一对象很有用。例如,下面是一个简单函数如何使用集合来确定给定字符串中使用的字母:

img/330715_3_En_2_Figak_HTML.jpg

>>> def unique_letters(word):
...     return set(word.lower())
...
>>> unique_letters('spam')
{'a', 'p', 's', 'm'}
>>> unique_letters('eggs')
{'s', 'e', 'g'}

请注意以下几点:

  • 首先,内置的set类型将一个序列作为它的参数,用在该序列中找到的所有唯一元素填充集合。这对于任何序列都是有效的,例如示例中所示的字符串以及列表、元组、字典键或自定义可迭代对象。

  • 第二,集合中的项并没有按照它们在原始字符串中出现的方式排序。集合只关心成员资格。他们跟踪集合中的项目,没有任何排序的概念。这似乎是一个限制,但如果你需要订购,你可能需要一个清单。当您只需要知道一个项目是否是集合的成员,而不考虑它在集合中的位置或者它出现了多少次时,集合是非常有效的。

  • 第三,在交互式外壳中显示集合时显示的表示。因为这些表示的格式与您在源文件中输入的格式相同,所以这表明了在代码中将集合声明为文本的语法。它看起来非常类似于字典,但是没有任何与键相关联的值。这实际上是一个相当准确的类比,因为集合的工作方式非常类似于字典中键的集合。

因为集合是为不同于序列和字典的目的而设计的,所以可用的操作和方法与您可能习惯的稍有不同。然而,首先让我们看看集合相对于其他类型的行为方式。也许集合最常见的用途是确定成员资格,这是列表和字典经常需要完成的任务。本着符合期望的精神,这里使用了其他类型中常见的in关键字:

img/330715_3_En_2_Figal_HTML.jpg

>>> example = {1, 2, 3, 4, 5}
>>> 4 in  example
True
>>> 6 in  example
False

此外,以后还可以在器械包中添加或删除物品。list 的append()方法不适用于集合,因为追加一个项目就是将它添加到末尾,这意味着集合中项目的顺序很重要。因为集合根本不关心排序,而是使用add()方法,它只是确保指定的项目出现在集合中。如果它已经存在,add()什么也不做;否则,它会将项目添加到集合中,这样就不会有任何重复项:

img/330715_3_En_2_Figam_HTML.jpg

>>> example.add(6)
>>> example
{1, 2, 3, 4, 5, 6}
>>>
>>> example
{1, 2, 3, 4, 5, 6}

字典有一个有用的update()方法,它将新字典的内容添加到已经存在的字典中。集合也有一个update()方法,执行相同的任务:

img/330715_3_En_2_Figan_HTML.jpg

>>> example.update({6, 7, 8, 9})
>>> example
{1, 2, 3, 4, 5, 6, 7, 8, 9}

从器械包中移除物品有几种不同的方式,每种方式都有不同的需求。对add()最直接的补充是remove()方法,它从集合中删除一个特定的项目。如果该项目一开始就不在集合中,它会引发一个KeyError:

img/330715_3_En_2_Figao_HTML.jpg

>>> example.remove(9)
>>> example.remove(9)
Traceback (most recent call last):
  ...
KeyError: 9
>>> example
{1, 2, 3, 4, 5, 6, 7, 8}

然而,很多时候,该项目是否已经在集合中并不重要;你可能只关心当你用完它的时候它不在布景里。为此,集合也有一个discard()方法,它的工作方式类似于remove(),但是如果指定的项目不在集合中,它不会引发异常:

img/330715_3_En_2_Figap_HTML.jpg

>>> example.discard(8)
>>> example.discard(8)
>>> example
{1, 2, 3, 4, 5, 6, 7}

当然,remove()discard()都是假设你已经知道要从集合中移除什么对象。要简单地从集合中删除任何项目,可以使用pop()方法,该方法也是从 list API 中借用的,但略有不同。因为集合没有明确地排序,所以对于要弹出的项目来说,集合没有真正的结尾。相反,集合的pop()方法选择一个,不可预测地返回它供集合外使用:

img/330715_3_En_2_Figaq_HTML.jpg

>>> example.pop()
1
>>> example
{2, 3, 4, 5, 6, 7}

最后,集合还提供了一种一次性移除所有项目的方法,可以将其重置为空状态。clear()方法用于此目的:

img/330715_3_En_2_Figar_HTML.jpg

>>> example.clear()
>>> example
set()

注意

空集的表示是set(),而不是{},因为 Python 需要保持集合和字典之间的区别。为了保持与引入集合文字之前编写的旧代码的兼容性,空花括号仍然专用于字典,因此集合使用它们的名称。

除了就地修改内容的方法之外,集合还提供了两个集合以某种方式组合返回一个新集合的操作。其中最常见的是联合,在这种联合中,两个集合的内容被结合在一起,因此产生的新集合包含了两个原始集合中的所有项目。这与使用update()方法本质上是一样的,只是没有改变任何原始设置。

两个集合的并集很像是按位的 or 运算,所以 Python 用管道字符(|)来表示它,这与按位的 OR(比较每个字节)的用法相同。此外,集合使用union()方法提供相同的功能,可以从涉及的任一集合调用该方法:

img/330715_3_En_2_Figas_HTML.jpg

>>> {1, 2, 3} | {4, 5, 6}
{1, 2, 3, 4, 5, 6}
>>> {1, 2, 3}.union({4, 5, 6})
{1, 2, 3, 4, 5, 6}

该操作的逻辑补充是交集,其结果是原始集合共有的所有项目的集合。同样,这类似于逐位操作,但这一次是逐位的,Python 使用&符号(&)来表示与集合相关的操作。集合也有一个intersection()方法,它执行相同的任务:

img/330715_3_En_2_Figat_HTML.jpg

>>> {1, 2, 3, 4, 5} & {4, 5, 6, 7, 8}
{4, 5}
>>> {1, 2, 3, 4, 5}.intersection({4, 5, 6, 7, 8})
{4, 5}

您还可以确定两个集合之间的差异,从而产生一个集合,其中包含存在于其中一个集合中但不存在于另一个集合中的所有项目。通过从一个集合中删除另一个集合的内容,这很像减法,所以 Python 使用减法运算符()和difference()方法来执行这个操作:

img/330715_3_En_2_Figau_HTML.jpg

>>> {1, 2, 3, 4, 5} – {2, 4, 6}
{1, 3, 5}
>>> {1, 2, 3, 4, 5}.difference({2, 4, 6})
{1, 3, 5}

除了这一基本差异,Python sets 使用symmetric_difference()方法提供了一种称为对称差异的变体。使用这种方法,得到的集合包含在任一集合中的所有项目,但不同时包含在中。这相当于逐位异或运算,通常称为 XOR。因为 Python 在别处使用插入符号(^)来表示 XOR 运算,所以集合使用相同的运算符和方法:

img/330715_3_En_2_Figav_HTML.jpg

>>> {1, 2, 3, 4, 5} ^ {4, 5, 6}
{1, 2, 3, 6}
>>> {1, 2, 3, 4, 5}.symmetric_difference({4, 5, 6})
{1, 2, 3, 6}

最后,可以确定一个集合中的所有项目是否也存在于另一个集合中。如果一个集合包含另一个集合的所有项目,则第一个集合被视为另一个集合的超集,即使第一个集合包含第二个集合中不存在的其他项目。相反,第一个集合中的所有项目都包含在第二个集合中,即使第二个集合包含更多项目,这意味着第一个集合是第二个集合的子集。

分别通过两种方法issubset()issuperset()来测试一个集合是另一个集合的子集还是超集。通过从一组中减去另一组并检查是否有任何项目剩余,可以手动执行相同的测试。如果没有留下任何项目,集合的计算结果为False,第一个项目肯定是第二个项目的子集,测试超集就像交换操作中的两个集合一样简单。使用这些方法可以避免创建一个新的集合,只是将它简化为一个布尔值:

img/330715_3_En_2_Figaw_HTML.jpg

>>> {1, 2, 3}.issubset({1, 2, 3, 4, 5})
True
>>> {1, 2, 3, 4, 5}.issubset({1, 2, 3})
False
>>> {1, 2, 3}.issuperset({1, 2, 3, 4, 5})
False
>>> {1, 2, 3, 4, 5}.issuperset({1, 2, 3})
True

>>> not ({1, 2, 3} – {1, 2, 3, 4, 5})
True
>>> not ({1, 2, 3, 4, 5} – {1, 2, 3})
False

注意

看看如何使用减法来确定子集和超集,您可能会注意到,两个相同的集合总是会减去一个空集,并且这两个集合的顺序是无关紧要的。这是正确的,因为{1, 2, 3} – {1, 2, 3}总是空的,所以每个集合都是另一个集合的子集和超集。

命名元组

字典非常有用,但是有时你可能有一组固定的可用键,所以你不需要那么大的灵活性。相反,Python 使用命名元组,它提供了一些相同的功能,但它们更有效,因为实例不需要包含任何键,只需要包含与它们相关的值。

命名元组是使用来自模块collections的工厂函数创建的,称为namedtuple()namedtuple()不是返回一个单独的对象,而是返回一个新的类,它是为一组给定的名字定制的。第一个参数是 tuple 类本身的名称,但不幸的是,第二个参数没有这么简单。它接受一个属性名字符串,由空格或逗号分隔:

img/330715_3_En_2_Figax_HTML.jpg

>>> from collections import namedtuple
>>> Point = namedtuple('Point', 'x y')
>>> point = Point(13, 25)
>>> point
Point(x=13, y=25)
>>> point.x, point.y
(13, 25)
>>> point[0], point[1]
(13, 25)

作为元组和字典之间的有效权衡,许多需要返回多个值的函数可以使用命名元组来尽可能地有用。不需要填充一个完整的字典,但是值仍然可以通过有用的名称而不是整数索引来引用。

有序词典

如果你曾经遍历过一个字典的键或者把它的内容打印到交互提示符下,就像本章前面所做的那样,你会注意到它的键并不总是遵循一个可预测的顺序。有时它们看起来像是按数字或字母顺序排序的,但有时看起来完全是随机的。

字典键和集合一样,被认为是无序的。尽管偶尔可能会出现模式,但这些只是实现的副产品,并没有正式定义。不仅不同字典之间的排序不一致,当使用不同的 Python 实现(如 Jython 或 IronPython)时,差异甚至更大。

大多数时候,您真正从字典中寻找的是一种将特定键映射到相关值的方法,因此键的顺序无关紧要。不过,有时以可靠的方式迭代这些键也是有用的。为了两全其美,Python 通过其collections模块提供了OrderedDict类。这提供了字典的所有功能,但具有可靠的键排序:

img/330715_3_En_2_Figay_HTML.jpg

>>> from collections import OrderedDict
>>> d = OrderedDict((value, str(value)) for value in range(10) if value > 5)
>>> d
OrderedDict([(6, '6'), (7, '7'), (8, '8'), (9, '9')])
>>> d[10] = '10'
>>> d
OrderedDict([(6, '6'), (7, '7'), (8, '8'), (9, '9'), (10, '10')])
>>> del d[7]
>>> d
OrderedDict([(6, '6'), (8, '8'), (9, '9'), (10, '10')])

如您所见,以前使用的相同结构现在产生了一个有序的字典,即使在添加和删除条目时也能做正确的事情。

警告

在这里给出的例子中,注意字典的值是使用生成器表达式提供的。如果您提供了一个标准字典,这意味着您提供的值在进入有序数据库之前是无序的,然后有序数据库将假设该顺序是有意的并保留它。如果您将值作为关键字参数提供,也会发生这种情况,因为这些值是作为常规字典在内部传递的。向OrderedDict()提供排序的唯一可靠方法是使用标准序列,比如列表或生成器表达式。

带有默认值的词典

使用字典的另一个常见模式是,如果在映射中找不到某个键,总是假设某个默认值。这种行为可以通过显式捕获访问键时引发的KeyError或者使用可用的get()方法来实现,如果没有找到键,该方法可以返回合适的默认值。这种模式的一个例子是使用字典来跟踪每个单词在一些文本中出现的次数:

img/330715_3_En_2_Figaz_HTML.jpg

def count_words(text):
    count = {}
    for word in text.split(' '):
        current = count.get(word, 0) # Make sure we always have a number
        count[word] = current + 1
    return count

不需要处理额外的get()调用,collections模块提供了一个defaultdict类,可以为您处理这一步。创建它时,可以将一个 callable 作为单个参数传入,当请求的键不存在时,它将用于创建一个新值。在大多数情况下,您可以只提供一个内置类型,这将提供一个有用的基本值。在count_words()的情况下,我们可以用int:

img/330715_3_En_2_Figba_HTML.jpg

from collections import defaultdict

def count_words(text):
    count = defaultdict(int)
    for word in text.split(' '):
        count[word] += 1
    return count

基本上任何可调用的都可以使用,但是内置类型倾向于为您需要处理的任何内容提供最佳默认值。使用list将给出一个空列表,str返回一个空字符串,int返回 0,dict返回一个空字典。如果您有更特殊的需求,任何可以不带任何参数使用的 callable 都可以。第三章介绍了 lambda 函数,对于这种情况很方便。

导入代码

复杂的 Python 应用通常由许多不同的模块组成,通常被分成包以提供更细粒度的名称空间。将代码从一个模块导入到另一个模块是一件简单的事情,但这只是事情的一部分。对于您可能遇到的更具体的情况,还有几个额外的功能。

后备导入

到目前为止,您已经看到了 Python 随时间变化的几个方面,有时是以向后不兼容的方式。偶尔会出现的一个特殊变化是当一个模块被移动或重命名时,但本质上仍然和以前做同样的事情。使您的代码使用它所需的唯一更新是更改到导入位置,但是您通常需要保持与更改前后版本的兼容性。

这个问题的解决方案利用 Python 的异常处理来确定模块是否存在于新位置。因为导入是在运行时处理的,所以像任何其他语句一样,您可以将它们包装在一个try块中,并捕捉一个ImportError,如果导入失败,就会引发这个事件。以下是在 Python 2.5 更改前后导入通用哈希算法的方法,Python 2.5 更改了其导入位置:

img/330715_3_En_2_Figbb_HTML.jpg

try:
    # Use the new library if available. Added in Python 2.5
    from hashlib import md5
except ImportError:
    # Compatible functionality provided prior to Python 2.5
    from md5 import new as md5

请注意,这里的导入优先选择较新的库。这是因为像这样的更改通常有一个宽限期,在此期间旧位置仍然可用,但已被废弃。如果您首先检查旧模块,您会在新模块可用后很久才发现它。通过首先检查新的功能,您可以利用任何更新的功能或添加的行为,只要它们可用,只在必要时回退到旧的功能。使用关键字as允许模块的其余部分简单地引用名字md5

这种技术适用于第三方模块,就像适用于 Python 自己的标准库一样,但是第三方应用通常需要不同的处理。通常需要区分应用是否可用,而不是决定使用哪个模块。这与前面的例子一样,通过将 import 语句包装在一个try块中来确定。

然而,接下来会发生什么取决于在模块不可用的情况下应用应该如何表现。有些模块是严格必需的,所以如果它丢失了,您应该直接在except ImportError块中引发一个异常,或者干脆完全放弃异常处理。其他时候,缺少第三方模块仅仅意味着功能的减少。在这种情况下,最常见的方法是将None赋给包含导入模块的变量:

img/330715_3_En_2_Figbc_HTML.jpg

try:
    import docutils  # Common Python-based documentation tools
except ImportError:
    docutils = None

然后,当您的代码需要利用导入模块中的特性时,它可以使用类似于if docutils的东西来查看该模块是否可用,而不必重新导入它。

从未来导入

Python 的发布时间表经常包含新的特性,但是凭空引入它们并不总是一个好主意。特别是,语法的增加和行为的改变可能会破坏现有的代码,所以通常有必要提供一点宽限期。在转换过程中,这些新特性通过一种特殊的导入方式变得可用,允许您选择为每个模块更新哪些特性。

特殊的__future__模块允许你命名你想在给定模块中使用的特定特性。这为您的模块提供了一个简单的兼容性路径,因为一些模块可以依赖新的特性,而其他模块可以使用现有的特性。通常,在特性被添加到__future__之后的下一个版本,它成为所有模块可用的标准特性。

举个简单的例子,Python 3.0 改变了整数除法的工作方式。在早期版本中,将一个整数除以另一个整数总会得到一个整数,如果结果通常会产生余数,这通常会导致精度损失。这对熟悉底层 C 实现的程序员来说是有意义的,但这与在标准计算器上执行相同的计算是不同的,所以这造成了很多混乱。

如果除法运算包含余数,则除法运算的行为将改为返回浮点值,从而与标准计算器的工作方式相匹配。然而,在对整个 Python 进行更改之前,division选项被添加到了__future__模块中,允许在必要时提前更改行为。下面是 Python 2.5 中交互式解释器会话的样子。然而,Python 3.x 在默认情况下会像在> > > 5 / 2.0 中将 1 提升为浮点值一样处理它:

img/330715_3_En_2_Figbd_HTML.jpg

>>> 5 / 2  # Python 2.5 uses integer-only division by default
2
>>> from __future__ import division  # This updates the behavior of division
>>> 5 / 2
2.5

__future__模块支持许多这样的特性,Python 的每个版本都添加了新的选项。在本书的其余部分,我将在所描述的特性足够新以至于需要在 Python 的旧版本(回到 Python 2.5)中进行__future__导入时提到它们,而不是试图在这里列出它们。关于这些特性变化的完整细节可以在 Python 文档的“新特性”页面上找到。 1

注意

如果你试图从__future__导入一个已经存在于你正在使用的 Python 版本中的特性,它不会做任何事情。这个特性已经可用了,所以不需要做任何修改,但是它也不会引发任何异常。

使用 all 自定义导入

Python 导入的一个很少使用的特性是能够将名称空间从一个模块导入到另一个模块。这是通过使用星号作为要导入的模块部分来实现的:

img/330715_3_En_2_Figbe_HTML.jpg

>>> from itertools import *
>>> list(chain([1, 2, 3], [4, 5, 6]))
[1, 2, 3, 4, 5, 6]

一般来说,这只是获取导入模块的名称空间中不以下划线开头的所有条目,并将它们转储到当前模块的名称空间中。它可以节省大量使用导入模块的模块中的一些输入,因为它使您不必在每次访问它的一个属性时都包含模块名。

然而,有时以这种方式使每个对象都可用是没有意义的。特别是,框架通常包括许多在框架模块中有用的实用函数和类,但是当导出到外部代码时就没什么意义了。为了控制像这样导入模块时导出什么对象,您可以在模块中的某个地方指定__all__

您所需要做的就是提供一个列表——或者其他序列——其中包含在使用星号导入模块时应该导入的对象的名称。额外的对象仍然可以通过直接导入名称或者只导入模块本身而不是模块内部的任何东西来导入。下面是一个示例模块如何提供它的__all__选项:

img/330715_3_En_2_Figbf_HTML.jpg

__all__ = ['public_func']

def public_func():
    pass

def utility_func():
    pass

当然,在现实世界中,这两个函数都会有有用的代码。不过,为了便于说明,这里有一个导入该模块的不同方法的简要介绍,我们称之为example:

img/330715_3_En_2_Figbg_HTML.jpg

>>> import example

>>> example.public_func
<function public_func at 0x...>
>>> example.utility_func
<function utility_func at 0x...>

>>> from example import *

>>> public_func
<function public_func at 0x...>
>>> utility_func
Traceback (most recent call last):
  ...
NameError: name 'utility_func' is not defined

>>> from example import utility_func

>>> utility_func
<function utility_func at 0x...>

注意,在最后一种情况下,只要显式指定,您仍然可以使用from语法直接导入它。只有当你使用星号时__all__才会起作用。因此,根据您是希望所有功能都可用,还是只希望一个功能可用,您有多种选择。

显性比隐性好

首先使用星号符号导入通常被认为是不好的形式;Python 风格指南 PEP 8 明确建议不要这么做。它的主要问题是,模块的内容来自哪里并不明显。如果你看到一个函数在没有模块命名空间的情况下被使用,你通常可以看看模块的顶部,看看它是否被导入;如果没有,您可以放心地假设它是在模块中定义的。如果它是用星号符号导入的,您必须扫描整个模块以查看它是否被定义,或者打开相关模块的源代码以查看它是否被定义。

有时,使用星号导入仍然有用,但是最好只在将它包装在另一个名称空间中时才这样做。如第十一章所示,你可以允许你的用户导入一个单一的根名称空间,这个名称空间包含了来自几个不同模块的对象。不必在每次添加新内容时都更新导入,您可以在主模块中使用星号导入,而不会在用户模块中引入任何模糊性。

相对进口

当开始一个项目时,你将花费大部分时间从外部包导入,所以每次导入都是绝对的;它的路径根植于你系统的PYTHONPATH。一旦您的项目开始增长到几个模块,您将定期从另一个模块导入。一旦建立了层次结构,您可能会意识到,当在树的相似部分的两个模块之间共享代码时,您不希望包含完整的导入路径。

Python 允许您指定想要导入的模块的相对路径,因此如果需要的话,您可以在整个包中移动,只需做最少的修改。这种方法的首选语法是用一个或多个句点指定模块路径的一部分,以指示在路径上要查找模块的位置。例如,如果acme.shopping.cart模块需要从acme.billing导入,那么下面两种导入模式是相同的:

img/330715_3_En_2_Figbh_HTML.jpg

from acme import billing
from .. import billing

单个句点允许您从当前包导入,因此acme.shopping.gallery可以作为from.import gallery导入。或者,如果您只是想从那个模块中导入一些东西,您可以简单地在模块路径前面加上必要的句点,然后像往常一样指定要导入的名称:from.gallery import Image

import()函数

您不必总是将导入放在模块的顶部。事实上,有时您可能根本无法提前编写一些导入。您可能会根据用户提供的设置来决定导入哪个模块,或者甚至允许用户直接指定模块。这些用户提供的设置是一种方便的方式,可以在不诉诸自动发现的情况下实现可扩展性。

为了支持这一功能,Python 允许您使用__import__()函数手动导入代码。它是一个内置函数,因此随处可用,但使用它需要一些解释,因为它不像 Python 提供的其他一些功能那样简单。您可以从五个参数中进行选择,以自定义模块的导入方式和检索内容:

  • name:唯一一个总是需要的参数,它接受应该加载的模块的名称。如果它是包的一部分,就用句点分隔路径的各个部分,就像使用import path.to.module时一样。

  • globals:命名空间字典,用于定义解析模块名的上下文。在标准的import情况下,内置globals()函数的返回值用于填充该参数。

  • locals:另一个名称空间字典,理想情况下用于帮助定义解析模块名称的上下文。然而,实际上,Python 的当前实现完全忽略了这一点。在未来支持的情况下,标准导入为该参数提供内置locals()函数的返回值。

  • fromlist:应该从模块导入的单个名称的列表,而不是导入整个模块。

  • level:一个整数,表示相对于调用__import__()的模块应该如何解析路径。值-1 允许绝对和隐式相对导入;0 只允许绝对导入;正值表示用于显式相对导入的路径级别。

尽管这看起来很简单,但返回值包含一些陷阱,可能会引起一些混乱。它总是返回一个模块对象,但是看到返回的是哪个模块以及该模块上有哪些可用的属性可能会令人惊讶。因为有许多不同的方式来导入模块,所以这些变化值得理解。首先,让我们看看不同类型的模块名如何影响返回值。

在最简单的情况下,您可以向__import__()传递一个模块名,返回值正是您所期望的:由所提供的名称引用的模块。该模块对象上可用的属性与您在代码中直接导入该名称时可用的属性相同:该模块代码中声明的整个名称空间。

但是,当您传入更复杂的模块路径时,返回值可能与预期不符。复杂的路径是使用源文件中使用的相同的点分隔语法提供的,所以例如,导入os.path可以通过传入"os.path"来实现。在这种情况下返回值是os,但是path属性允许您访问您真正寻找的模块。

这种变化的原因是__import__()模仿了 Python 源文件的行为,其中导入os.path使得os模块在那个名称下可用。您仍然可以访问os.path,但是进入主名称空间的模块是os。因为__import__()的工作方式本质上与标准导入相同,所以您在返回值中得到的就是您通常在主模块名称空间中得到的。

为了在模块路径的末端获得模块,可以采用几种不同的方法。最明显的,尽管不一定是直接的,是在句点上分割给定的模块名,使用路径的每个部分从由__import__()返回的模块中获得每个属性层。这里有一个简单的函数可以完成这项工作:

img/330715_3_En_2_Figbi_HTML.jpg

>>> def import_child(module_name):
...     module = __import__(module_name)
...     for layer in module_name.split('.')[1:]:
...         module = getattr(module, layer)
...     return module
...
>>> import_child('os.path')
<module 'ntpath' from 'C:\Python31\lib\ntpath.py'>
>>> import_child('os')
<module 'os' from 'C:\Python31\lib\os.py'>

注意

os.path引用的模块的确切名称将根据它被导入的操作系统而有所不同。例如,它在 Windows 上被称为ntpath,而大多数 Linux 系统使用posixpath。大多数内容都是相同的,但是根据操作系统的需要,它们的行为可能会稍有不同,并且每种内容都可能具有该环境特有的附加属性。

正如您所看到的,它适用于简单的情况,也适用于更复杂的情况,但是它仍然要完成比实际需要更多的工作。当然,与导入本身相比,花费在循环上的时间是微不足道的,但是如果模块已经被导入,那么我们的import_path()函数包含了大部分的过程。另一种方法是利用 Python 自己的模块缓存机制来消除额外的处理:

img/330715_3_En_2_Figbj_HTML.jpg

>>> import sys
>>> def import_child(module_name):
...     __import__(module_name)
...     return sys.modules[module_name]
...
>>> import_child('os.path')
<module 'ntpath' from 'C:\Python31\lib\ntpath.py'>
>>> import_child('os')
<module 'os' from 'C:\Python31\lib\os.py'>

sys.modules字典将导入路径映射到导入时生成的模块对象。通过在字典中查找模块,就没有必要再纠结于模块名称的细节了。

当然,这真的只适用于绝对进口。相对导入,不管它们是如何被引用的,都是相对于导入语句所在的模块进行解析的——或者在本例中,是相对于__import__()函数调用所在的模块进行解析的。因为最常见的情况是将import_path()放在一个公共位置,所以相对导入将相对于该位置进行解析,而不是调用import_path()的模块。这可能意味着导入完全错误的模块。

importlib 模块

为了解决直接使用__import__()带来的问题,Python 还包含了importlib模块,它提供了一个更直观的接口来导入模块。import_module()函数是实现与__import__()相同效果的一种简单得多的方法,但在某种程度上更符合预期。

对于绝对导入,import_module()接受模块路径,就像__import__()一样。然而,不同之处在于,import_module()总是返回路径中的最后一个模块,而__import__()返回第一个模块。由于这一功能,上一节中添加的额外处理变得完全没有必要,因此这是一种更好的使用方法:

img/330715_3_En_2_Figbk_HTML.jpg

>>> from importlib import import_module
>>> import_module('os.path')
<module 'ntpath' from 'C:\Python31\lib\ntpath.py'>
>>> import_module('os')
<module 'os' from 'C:\Python31\lib\os.py'>

此外,import_module()还通过接受一个package属性来考虑相对导入,该属性定义了应该从其解析相对路径的参考点。这在调用函数时很容易做到,只需传入一个始终全局的__name__变量,它保存了最初用于导入当前模块的模块路径:

img/330715_3_En_2_Figbl_HTML.jpg

import_module('.utils', package=__name__)

警告

相对导入不能直接在交互式解释器中工作。解释器运行的模块实际上不在文件系统中,所以没有相对路径可以使用。

令人兴奋的 Python 扩展:NIST 的随机数信标

大多数编程语言都实现了某种形式的随机数和伪随机数生成器。Python 也是如此;然而,生成这些随机数的基本算法没有其他地方的算法健壮。因此,国家标准和技术研究所(NIST)实施了一种随机信标,它每 60 秒向连接的用户发送一个真正的随机数。

从 2018 年 5 月开始,NIST 指出:“NIST 正在实施公共随机性的来源。该服务(在 https://beacon.nist.gov/home )使用两个独立的商用随机源,每个都有独立的硬件熵源和 SP 800-90 认可的组件。信标旨在提供不可预测性、自主性和一致性。不可预测性意味着用户无法在信息来源提供之前通过算法预测信息。自主性意味着信源可以抵抗外界改变随机比特分布的企图。一致性意味着一组用户可以以这样的方式访问源,他们确信他们都收到了相同的随机字符串。 2

对于需要随机值的应用(如游戏),您可以将随机性信标视为每 60 秒获得某种程度上可靠的随机性的好方法。这里使用了“某种程度上”一词,以说明 NIST 说不要将他们的服务用于加密需求,当然,许多人断言,由于 NIST 和国家安全局之间的连接,以及在 60 秒窗口内“随机性”可能会受到损害的事实,信标并不真正安全。然而,尽管如此,它仍然是一个有趣的,而且作者认为,有效的服务。要使用该服务,您需要先安装库所需的 https://www.nist.gov/programs-projects/nist-randomness-beacon 进行访问。

如何安装 NIST 灯塔图书馆

无论您使用什么平台,您都可以将 NIST 信标与 Python 一起使用。有关版本和更新的信息可以在 NIST 或 https://pypi.org/project/nistbeacon/0.9.2 找到。假设您使用的是 MS Windows,并且安装了 pip 并处于在线状态,安装起来就像下面这样简单:

pip install nistbeacon  (press enter)

假设您在安装过程中没有收到任何错误,尝试下面的几个例子来感受一下信标是如何工作的。

获取值的简单示例

在下面的例子中,从信标中获得 512 十六进制(基数 16)值,显示并转换为十进制。还获得并显示一个随机值,范围从 1 到 10。键入 记录。 在使用空闲或其他全功能 ide 时会显示许多其他功能选项。

#Get a 512 hex value from the beacon and display it
from nistbeacon import NistBeacon

record = NistBeacon.get_last_record()
v = record.output_value # 512 hex
r = record.pseudo_random # pick a pseudo random number
print ('Your random follows: ')
print (r.randint(1,10))  #print 1 - 10 random  #random())for floats .0 to 1.0
print()
print ('Hex original value:\n', v, '\n')
d=int(v,16) #convert to decimal
print ('Hex value converted to decimal:\n', d)

模拟滚动硬币翻转一定次数并显示正面或反面的示例

在这个例子中,每 66 秒获得一个记录,转换成十进制,然后与模数(整数除法的余数)进行比较,看它对于“偶数”是否是“奇数”,以模拟“正面”或“反面”:

#Coin flip-O-matic
from nistbeacon import NistBeacon
import time
print()
print ('Coin flip 0 or 1 tails or heads')
print()
print ('Run five times')
for count in range (5):
    time.sleep(66)  #wait for new beacon every 66 seconds
    h = NistBeacon.get_last_record()
    v = h.output_value #512 hex
    d=int(v,16) #convert to decimal
    coin = d% 2 #modulus of record (0 or 1)
    if coin == 0:
        print ('tails')
    else:
        print ('heads')

带着它

如果你愿意花时间学习这门语言,本章列出的特性只是 Python 所能提供的一点皮毛。本书的其余部分将在很大程度上依赖于这里所展示的内容,但是每一章都将为以后的章节增加一层内容。本着这种精神,让我们继续讨论您认为是 Python 最基本、最谦逊的特性之一:函数

*

三、函数

任何编程语言的核心都是函数的概念,但我们往往认为它们是理所当然的。当然,有一个显而易见的事实,函数允许将代码封装到单独的单元中,这些单元可以重用,而不是到处复制。但是 Python 超越了某些语言所允许的概念,函数是成熟的对象,可以在数据结构中传递,包装在其他函数中,或者完全被新的实现所取代。

事实上,Python 为函数提供了足够的灵活性,实际上有几种不同类型的函数,反映了各种形式的声明和目的。理解每一种类型的函数将有助于您在使用自己的代码时决定哪种函数适合您遇到的每种情况。本章依次解释了它们,以及各种各样的特性,您可以利用这些特性来扩展您创建的每个函数的值,而不管它是什么类型。

从本质上讲,所有的函数都是平等的,不管它们属于以下哪一部分。内置的function类型构成了它们的基础,包含了 Python 理解如何使用它们所需的所有属性:

img/330715_3_En_3_Figa_HTML.jpg

>>> def example():
...     pass
...
>>> type(example)
<type 'function'>
>>> example
<function example at 0x...>

当然,仍然有许多不同类型的函数和许多不同的声明它们的方法。首先,让我们检查一下函数最普遍的一个方面。

争论

大多数函数都需要一些参数来做一些有用的事情。通常,这意味着在函数(声明的)签名中按顺序定义它们,然后在以后调用该函数时按相同的顺序提供它们。Python 支持这种模型,但也支持传递关键字参数,甚至是在调用函数之前不知道的参数。

Python 的关键字参数的一个最常见的优点是,可以以不同于函数中定义的顺序传递参数。您甚至可以完全跳过参数,只要它们定义了默认值。这种灵活性有助于鼓励使用支持大量带有默认值的参数的函数。

显性比隐性好

Python 的关键字参数鼓励显式的一种方式是,如果参数是通过关键字传递的,则只允许参数乱序传递。如果没有关键字,Python 需要使用实参的位置来知道函数运行时要绑定哪个参数名。因为关键字和位置一样显式,所以可以取消排序要求,而不会引入歧义。

事实上,在处理参数时,关键字甚至比位置更明确,因为函数调用记录了每个参数的用途。否则,您必须查找函数定义才能理解它的参数。有些参数在上下文中可能是可以理解的,但大多数可选参数看起来并不明显,所以用关键字传递它们有助于提高代码的可读性。

规划灵活性

规划参数名称、顺序和默认值对于那些不是由编写它们的人调用的函数来说尤其重要,比如那些分布式应用中的函数。如果您不知道最终将使用您的代码的用户的确切需求,最好将您可能有的任何假设转移到以后可以被覆盖的参数中。

举一个极其简单的例子,考虑一个向字符串追加前缀的函数:

img/330715_3_En_3_Figb_HTML.jpg

def add_prefix(my_string):
    """Adds a 'pro_' prefix before the new string is returned."""
    return 'pro_' + my_string
final_string=input('Enter a string so we can put pro_ in front of it!:  ')
print(add_prefix(final_string))

这里的'pro_'前缀对于应用来说可能是有意义的,但是当其他东西想要使用它时会发生什么呢?现在,前缀被硬编码到函数体中,所以没有其他选择。将这一假设转移到参数中有助于以后定制函数:

img/330715_3_En_3_Figc_HTML.jpg

def add_prefix(my_string, prefix="pro_"):
    """Adds a 'pro_' prefix before the string provided, a default value."""
    return prefix + my_string
final_string=input("Enter a string so we can put pro_ in front of it!:  ")
print(add_prefix(final_string))

没有prefix参数的函数调用不需要改变,所以现有代码工作得很好。本章后面关于预加载参数的部分展示了前缀是如何被改变的,并且仍然被不知道它的代码使用。

当然,这个例子太简单了,不能提供太多的实际价值,但是本书其余部分中举例说明的函数将利用大量可选参数,显示它们在每种情况下的价值。

可变位置参数

大多数函数被设计为处理一组特定的参数,但是有些函数可以处理任意数量的参数,依次处理每个参数。这些可以作为元组、列表或其他可迭代对象传递到单个参数中。

以一个典型的购物车为例。向购物车添加商品可以一次添加一个,也可以分批添加。使用一个类的定义,里面有一个函数,下面是如何使用一个标准参数完成的:

img/330715_3_En_3_Figd_HTML.jpg

class ShoppingCart:
    def add_to_cart(items):
        self.items.extend(items)

这当然会成功,但是现在考虑一下这对所有必须调用它的代码意味着什么。常见的情况是只添加一个条目,但是由于该函数总是接受一个列表,所以它最终看起来会像这样:

img/330715_3_En_3_Fige_HTML.jpg

cart.add_to_cart([item])

所以我们基本上是为了支持少数派而破坏多数派的案子。更糟糕的是,如果add_to_cart()最初只支持一个项目,后来被修改为支持多个项目,那么这个语法会破坏任何现有的调用,需要您重写它们来避免一个TypeError

理想情况下,该方法应该支持单个参数的标准语法,同时仍然支持多个参数。通过在参数名称前添加一个星号,可以指定将所有剩余的位置参数收集到一个元组中,该元组绑定到以星号为前缀的参数,该参数之前没有赋值。在这种情况下,没有其他参数,因此可变位置参数可以构成整个参数列表:

img/330715_3_En_3_Figf_HTML.jpg

    def add_to_cart(*items):
        self.items.extend(items)

现在,可以用任意数量的位置参数调用该方法,而不必先将这些参数分组到一个元组或列表中。在函数开始执行之前,额外的参数被自动捆绑在一个元组中。这清理了常见的情况,同时仍然可以根据需要启用更多的参数。以下是如何调用该方法的几个示例:

img/330715_3_En_3_Figg_HTML.jpg

cart.add_to_cart(item)
cart.add_to_cart(item1, item2)
cart.add_to_cart(item1, item2, item3, item4, item5)

还有一种方法可以调用这个函数,它允许调用代码支持任意数量的项,但是它并不特定于设计为接受变量参数的函数。有关所有细节,请参见使用变量参数调用函数一节。

可变关键字参数

函数可能需要额外的配置选项,特别是如果将这些选项传递给其他库的话。显而易见的方法是接受一个字典,它可以将配置名称映射到它们的值:

img/330715_3_En_3_Figh_HTML.jpg

class ShoppingCart:
    def __init__(self, options):
        self.options = options

不幸的是,这最终会导致一个类似于我们在上一节中描述的位置参数所遇到的问题。仅覆盖一两个值的简单情况变得相当复杂。根据偏好,函数调用可能有两种方式:

img/330715_3_En_3_Figi_HTML.jpg

options = {'currency': 'USD'}
cart = ShoppingCart(options)

cart = ShoppingCart({'currency': 'USD'})

当然,这种方法比不上上一节的位置参数问题中提供的列表。此外,像前一个问题一样,这可能是有问题的。如果您正在使用的函数先前被设置为接受一些显式关键字参数,那么新的字典参数将破坏兼容性。

相反,Python 提供了传递可变数量的关键字参数的能力,方法是在接受它们的参数名称前添加两个星号。这允许更友好的关键字参数语法,同时还允许完全动态的函数调用。检查以下存根:

    def __init__(self, **options):
        self.options = options

现在考虑前面的相同存根函数看起来会是什么样子,假设该函数现在接受任意的关键字参数:

cart = ShoppingCart(currency='USD')

警告

当使用变量参数时,位置参数和关键字参数之间有一个区别会引起问题。位置参数被分组到一个不可变的元组中,而关键字参数被放入一个可变的字典中。

漂亮总比丑陋好

这里的第二个函数调用示例是一个经典的代码示例,许多 Python 程序员通常认为它很难看。大量的标点符号——键和值周围的引号,它们之间的冒号,以及整个内容周围的花括号——在已经必要的括号内,使得它非常混乱,很难一眼处理。

E.g. cart = ShoppingCart({'currency': 'USD'})

通过切换到关键字参数,如本节所示,代码的外观与 Python 的核心价值观和哲学相当一致。美本质上可能是主观的,但是某些主观的决定受到绝大多数程序员的称赞。

结合不同的论点

可变参数的这些选项与标准选项(如必需参数和可选参数)相结合。为了确保一切都很好,Python 有一些非常具体的规则来定义函数签名中的参数。只有四种类型的参数,这里按照它们在函数中出现的顺序列出:

  • 必需的参数

  • 可选参数

  • 可变数量的位置参数

  • 可变关键字参数

将必需的参数放在列表的第一位可以确保位置参数在进入可选参数之前满足必需的参数。可变参数只能选取不适合任何其他东西的值,所以它们自然会在最后被定义。下面是这个存根在典型函数定义中的样子:

def create_element(name, editable=True, *children, **attributes):

调用函数时也可以使用这种顺序,但是它有一个缺点。在本例中,您必须提供 editable 的值作为位置参数,才能传入任何子元素。最好能够在名称后面提供它们,避免大部分时间使用可选的可编辑参数。

为了支持这一点,Python 还允许将可变位置参数放在标准参数中。必需参数和可选参数都可以放在变量参数之后,但是现在它们必须通过关键字传递。所有的参数仍然可用,但是不常用的参数在不需要的时候变得更加可选,在有意义的时候变得更加明确。

面对模棱两可,拒绝猜测的诱惑

通过在显式参数列表的中间允许位置参数,Python 可能引入了相当大的模糊性。考虑一个定义为将命令传递给任意参数的函数:perform_action(action,*args,log_output=False)。通常,您可以提供足够的位置参数,甚至可以到达可选参数,但是在这种情况下,如果您提供三个或更多的值,会发生什么情况呢?

一种可能的解释是将第一个值赋给第一个参数,将最后一个值赋给最后一个参数,将所有其他值赋给变量参数。这可能行得通,但接下来就要猜测程序员发出调用的意图了。一旦你考虑一个在变量参数后面有更多参数的函数,可能的解释会变得非常多。

相反,Python 严格要求变量参数之后的所有内容只能通过关键字访问。函数中明确定义的位置参数值之外的值会直接进入变量参数,不管提供的是一个还是几十个。实现变得很容易解释,因为只有一种方法可以做到这一点,而且通过强制使用关键字,实现变得更加清晰。

这种行为的另一个特点是,仍然需要将显式参数放在变量位置参数之后。这两种类型的放置之间唯一真正的区别是使用关键字参数的要求;参数是否需要值仍然取决于您是否定义了默认参数:

img/330715_3_En_3_Figj_HTML.jpg

>>> def join_with_prefix(prefix, *segments, delimiter):
...     return delimiter.join(prefix + segment for segment in segments)
...
>>> join_with_prefix('P', 'ro', 'ython')
Traceback (most recent call last):
  ...
TypeError: join_with_prefix() needs keyword-only argument delimiter
>>> join_with_prefix('P', 'ro', 'ython', ' ')
Traceback (most recent call last):
  ...
TypeError: join_with_prefix() needs keyword-only argument delimiter
>>> join_with_prefix('P', 'ro', 'ython', delimiter=' ')
'Pro Python'

注意

如果您想接受只包含关键字的参数,但又不擅长使用变量位置参数,只需指定一个不带参数名的星号。这告诉 Python 星号后面的所有内容都是关键字,不接受可能很长的位置参数集。一个警告是,如果您还接受变量关键字参数,您必须提供至少一个显式关键字参数。否则,使用简单的星号符号真的没有意义,Python 会抛出一个SyntaxError

事实上,请记住,必需参数和可选参数的排序要求仅适用于位置参数的情况。有了将参数定义为仅关键字的能力,您现在可以自由地以任何顺序将它们定义为必需的和可选的,而不会受到 Python 的任何抱怨。调用函数时顺序并不重要,因此定义函数时顺序也不重要。考虑重写前面的示例,要求前缀作为关键字参数,同时使分隔符可选:

img/330715_3_En_3_Figk_HTML.jpg

>>> def join_with_prefix(*segments, delimiter=' ', prefix):
...     return delimiter.join(prefix + segment for segment in segments)

>>> join_with_prefix('ro', 'ython', prefix="P")
'Pro Python'

警告

利用这种级别的灵活性时要小心,因为与 Python 代码通常的编写方式相比,这不是很简单。这当然是可能的,但是它的运行与大多数 Python 程序员的期望相反,这使得它很难长期维护。

但是,在所有情况下,变量关键字参数必须位于列表的末尾,在所有其他类型的参数之后。

使用可变参数调用函数

除了能够定义可以接受任意数量的值的参数之外,相同的语法还可以用于将值传递给函数调用。这样做的最大好处是,它不局限于被定义为本质可变的参数。相反,您可以将变量参数传递给任何函数,不管它是如何定义的。*将 iterable 解包,并将其内容作为单独的参数传递。

相同的星号(*)符号用于指定变量参数,然后将变量参数扩展为函数调用,就好像所有参数都是直接指定的一样。一个星号指定位置参数,而两个星号指定关键字参数。这在将函数调用的返回值直接作为参数传递,而不先将其分配给单个变量时特别有用:

img/330715_3_En_3_Figl_HTML.jpg

>>> value = 'ro ython'
>>> join_with_prefix(*value.split(' '), prefix="P")

这个例子看起来很明显,因为它是一个传递给变量参数的变量参数,但是同样的过程也适用于其他类型的函数。因为参数在传递给函数之前会被扩展,所以它可以用于任何函数,而不管它的参数是如何指定的。它甚至可以与内置函数和用 c 编写的扩展定义的函数一起使用。

注意

在函数调用中,只能传入一组可变位置参数和一组可变关键字参数。例如,如果您有两个位置参数列表,您需要自己将它们连接在一起,并将组合后的列表传递给函数,而不是试图分别使用这两个列表。

传递参数

当您开始向函数调用添加一些参数(其中许多是可选的)时,知道一些需要传递的参数值就变得很常见了,即使离函数真正被调用还有很长时间。与其在调用时传递所有参数,不如提前应用一些参数,这样以后应用的参数就更少了。

这个概念被官方称为函数的部分应用,但是这个函数还没有被调用,所以它实际上更多的是预先加载一些参数。当稍后调用预加载的函数时,传递的任何参数都会添加到先前提供的参数中。

Currying 呢?

如果你熟悉其他形式的函数式编程,你可能听说过curry,这可能看起来非常类似于预加载参数。一些框架甚至提供了名为curry()的函数,可以预加载函数的参数,这导致了更多的混乱。这两者之间的区别是微妙但重要的。

对于一个真正的 curried 函数,你必须根据需要多次调用它来填充所有的参数。如果一个函数接受三个参数,而你只用一个参数调用它,你会得到一个接受两个以上参数的函数。如果您调用这个新函数,它仍然不会执行您的代码,而是会加载下一个参数并返回另一个采用最后一个剩余参数的函数。调用该函数将最终满足所有参数,因此实际的代码将被执行并返回一个有用的值。

部分应用返回一个函数,该函数在稍后被调用时,无论还有多少个参数,都至少会尝试执行代码。如果需要的参数还没有得到值,Python 会抛出一个TypeError,就像你在其他时候用缺少的参数调用它一样。因此,尽管这两种技术之间肯定有相似之处,但理解它们的区别还是很重要的。

这个行为是作为内置的functools模块的一部分,通过它的partial() function来提供的。通过传入一个 callable 和任意数量的位置和关键字参数,它将返回一个新的 callable,稍后可以使用它来应用这些参数:

img/330715_3_En_3_Figm_HTML.jpg

>>> import os
>>> def load_file(file, base_path='/', mode="rb"):
...     return open(os.path.join(base_path, file), mode)
...
>>> f = load_file('example.txt')
>>> f.mode
'rb'
>>> f.close()

>>> import functools
>>> load_writable = functools.partial(load_file, mode="w")
>>> f = load_writable('example.txt')
>>> f.mode
'w'
>>> f.close()

注意

预加载参数的技术对于partial()函数来说是正确的,但是将一个函数传递给另一个函数以获得新函数的技术通常被称为装饰器或高阶函数。正如你将在本章后面看到的,Decorators 在被调用时可以执行任意数量的任务;预加载参数只是一个例子。

这通常用于将一个更灵活的函数定制成更简单的函数,因此它可以被传递给一个不知道如何访问这种灵活性的 API。通过预先加载自定义参数,API 背后的代码可以使用它知道如何使用的参数来调用您的函数,但所有参数仍将发挥作用。

警告

当使用functools.partial()时,您将无法为那些先前加载的参数提供任何新值。当然,当您试图为单个参数提供多个值时,这是标准行为,但是当您没有在同一个函数调用中提供所有值时,这种情况会更常见。有关解决这个问题的另一种方法,请参阅本章的“装饰者”一节。

反省

Python 非常透明,允许代码在运行时检查对象的许多方面。因为函数和其他任何对象一样都是对象,所以您的代码可以从中收集到一些信息,包括指定参数的函数签名。直接获得一个函数的参数需要经历一组相当复杂的属性,这些属性描述了 Python 的字节码结构,但幸运的是 Python 还提供了一些函数来简化这一过程。

Python 的许多自省特性作为标准inspect模块的一部分是可用的,其getfullargspec()函数用于函数参数。它接受要检查的函数,并返回有关该函数参数的命名信息元组。返回的元组包含参数规范的每个方面的值:

  • args:显式参数名称列表

  • varargs:变量位置参数的名称

  • varkw:变量关键字参数的名称

  • defaults:显式参数的一组默认值

  • kwonlyargs:仅包含关键字的参数名称列表

  • kwonlydefaults:仅关键字参数的缺省值字典

  • 参数注释的字典,这将在本章后面解释

为了更好地说明元组的每个部分中存在什么值,下面是它如何映射到一个基本的函数声明:

img/330715_3_En_3_Fign_HTML.jpg

>>> def example(a=1, b=1, *c, d, e=2, **f) -> str:
...     pass
...
>>> import inspect
>>> inspect.getfullargspec(example)
FullArgSpec(args=['a', 'b'], varargs="c", varkw="f", defaults=(1,), kwonlyargs=[
'd', 'e'], kwonlydefaults={'e': 2}, annotations={'a': <class 'int'>, 'return': <
class 'str'>})

示例:识别参数值

有时,记录一个函数将接收哪些参数是有用的,不管它是哪个函数,也不管它的参数是什么样子。这种行为经常出现在基于 Python 函数调用之外的东西生成参数列表的系统中。一些例子包括来自模板语言的指令和解析文本输入的正则表达式。

不幸的是,位置参数带来了一点问题,因为它们的值不包括它们将被发送到的参数的名称。默认值也是一个问题,因为函数调用根本不需要包含任何值。因为日志应该包括将提供给函数的所有值,所以这两个问题都需要解决。

首先,简单的部分。由关键字传递的任何参数值都不需要手动匹配,因为参数名称是与值一起提供的。与其一开始就考虑日志记录,不如让我们从一个函数开始,获取字典中可以记录的所有参数。该函数接受一个函数、一组位置参数和一个关键字参数字典:

img/330715_3_En_3_Figo_HTML.jpg

def example(a=1, b=1, *c, d, e=2, **f) -> str:
      pass

def get_arguments(func, args, kwargs):
    """
    Given a function and a set of arguments, return a dictionary
    of argument values that will be sent to the function.
    We are modifying get_arguments by adding new parts to it.
    """

    arguments = kwargs.copy()
    return arguments

print(get_arguments(example, (1,), {'f': 4}))  #will yield a result of:  {'f': 4}

这真的很简单。该函数会复制关键字参数,而不是直接返回,因为我们很快就会向字典中添加条目。接下来,我们要处理位置论点。诀窍是识别哪些参数名称映射到位置参数值,以便可以用适当的名称将这些值添加到字典中。这就是inspect.getfullargspec()发挥作用的地方,使用zip()来完成繁重的工作:

img/330715_3_En_3_Figp_HTML.jpg

def example(a=1, b=1, *c, d, e=2, **f) -> str:
      pass

import inspect

def get_arguments(func, args, kwargs):
    """
    Given a function and a set of arguments, return a dictionary
    of argument values that will be sent to the function.
    """

    arguments = kwargs.copy()
    spec = inspect.getfullargspec(func)
    arguments.update(zip(spec.args, args))

    return arguments

print(get_arguments(example, (1,), {'f': 4}))  # will output {'a': 1, 'f': 4}

既然已经处理了位置参数,让我们继续计算缺省值。如果有任何默认值没有被所提供的参数覆盖,这些默认值应该被添加到参数字典中,因为它们将被发送到函数:

img/330715_3_En_3_Figq_HTML.jpg

import inspect
def example(a=1, b=1, *c, d, e=2, **f) -> str:
      pass
def get_arguments(func, args, kwargs):
    """
    Given a function and a set of arguments, return a dictionary
    of argument values that will be sent to the function.
    """

    arguments = kwargs.copy()
    spec = inspect.getfullargspec(func)
    arguments.update(zip(spec.args, args))

    if spec.defaults:
        for i, name in enumerate(spec.args[-len(spec.defaults):]):
            if name not in arguments:
                arguments[name] = spec.defaults[i]

    return arguments

print(get_arguments(example, (1,), {'f': 4})) # will output  {'a': 1, 'b': 1, 'f': 4}

因为可选参数必须跟在必需参数之后,所以这个加法使用defaults元组的大小来确定可选参数的名称。循环遍历它们,然后只分配那些还没有提供的值。不幸的是,这只是缺省值情况的一半。因为只有关键字的参数也可以接受默认值,getfullargspec()为这些值返回一个单独的字典:

img/330715_3_En_3_Figr_HTML.jpg

import inspect
def example(a=1, b=1, *c, d, e=2, **f) -> str:
      pass
def get_arguments(func, args, kwargs):
    """
    Given a function and a set of arguments, return a dictionary
    of argument values that will be sent to the function.
    """
    arguments = kwargs.copy()
    spec = inspect.getfullargspec(func)
    arguments.update(zip(spec.args, args))

    for i, name in enumerate(spec.args[-len(spec.defaults)]):
        if name not in arguments:
            arguments[name] = spec.defaults[i]

    if spec.kwonlydefaults:
        for name, value in spec.kwonlydefaults.items():
            if name not in arguments:
                arguments[name] = value

    return arguments

print(get_arguments(example, (1,), {'f': 4})) # will yield {'a': 1, 'b': 1, 'e': 2, 'f': 4}

因为只有关键字的参数的默认值也是以字典形式出现的,所以应用这些值要容易得多,因为参数名是预先知道的。有了这些内容,get_arguments()可以生成一个更完整的参数字典,并将它传递给函数。不幸的是,因为这返回了一个字典,而变量位置参数没有名字,所以没有办法将它们添加到字典中。这限制了它的有用性,但它对许多函数定义仍然有效。

示例:更简洁的版本

前面的例子当然是函数性的,但是它比实际需要的代码要多一点。特别是,当没有提供显式值时,提供默认值需要相当多的工作。然而,这不是很直观,因为我们通常反过来考虑默认值:它们首先被提供,然后被显式参数覆盖。

考虑到这一点,可以重写get_arguments()函数,首先从函数声明中取出默认值,然后用作为实际参数传入的任何值替换它们。这避免了许多必须进行的检查,以确保不会被意外覆盖。

第一步是获取默认值。因为如果没有指定默认值,参数规范的defaultskwonlydefaults属性将被设置为None,所以我们实际上必须从设置一个空字典来更新开始。然后可以添加位置参数的默认值。

因为这一次只需要更新一个字典,而不考虑字典中可能已经有什么,所以使用不同的技术来获得位置默认值会更容易一些。我们可以使用一个类似的zip()来获得显式参数值,而不是使用一个很难阅读的复杂切片。通过首先颠倒参数列表和默认值,它们仍然从末尾开始匹配:

img/330715_3_En_3_Figs_HTML.jpg

def example(a=1, b=1, *c, d, e=2, **f) -> str:
      pass
def get_arguments(func, args, kwargs):
    """
    Given a function and a set of arguments, return a dictionary
    of argument values that will be sent to the function.
    """

    arguments = {}
    spec = inspect.getfullargspec(func)

    if spec.defaults:
        arguments.update(zip(reversed(spec.args), reversed(spec.defaults)))

    return arguments

print(get_arguments(example, (1,), {'f': 4}))  # will output  {'b': 1}

为关键字参数添加默认值要容易得多,因为参数规范已经将它们作为字典提供了。我们可以直接把它传递给论点字典的一个update(),然后继续:

img/330715_3_En_3_Figt_HTML.jpg

def example(a=1, b=1, *c, d, e=2, **f) -> str:
      pass
def get_arguments(func, args, kwargs):
    """
    Given a function and a set of arguments, return a dictionary
    of argument values that will be sent to the function.
    """

    arguments = {}
    spec = inspect.getfullargspec(func)

    if spec.defaults:
        arguments.update(zip(reversed(spec.args), reversed(spec.defaults)))
    if spec.kwonlydefaults:
        arguments.update(spec.kwonlydefaults)

    return arguments

print(get_arguments(example, (1,), {'f': 4})) # will output {'b': 1, 'e': 2}

现在剩下的就是添加传入的显式参数值。在这个函数的早期版本中使用的相同技术在这里也可以工作,唯一的例外是关键字参数是在一个update()函数中传递的,而不是首先被复制来形成参数字典:

img/330715_3_En_3_Figu_HTML.jpg

def example(a=1, b=1, *c, d, e=2, **f) -> str:
      pass
def get_arguments(func, args, kwargs):
    """
    Given a function and a set of arguments, return a dictionary
    of argument values that will be sent to the function.
    """

    arguments = {}
    spec = inspect.getfullargspec(func)

    if spec.defaults:
        arguments.update(zip(reversed(spec.args), reversed(spec.defaults)))
    if spec.kwonlydefaults:
        arguments.update(spec.kwonlydefaults)
    arguments.update(zip(spec.args, args))
    arguments.update(kwargs)

    return arguments

print(get_arguments(example, (1,), {'f': 4}))  # will output {'a': 1, 'b': 1, 'e': 2, 'f': 4}

这样,我们就有了一个更简洁的函数,它以我们通常认为的默认参数值的方式工作。在您更加熟悉可用的高级技术之后,这种类型的重构相当常见。查看旧代码,看看是否有更简单、更直接的方法来完成手头的任务,这总是有用的。这通常会使你的代码更快,更易读,更易维护。现在我们将扩展我们的解决方案来验证参数。

示例:验证参数

不幸的是,这并不意味着由get_arguments()返回的参数能够无误地传递给函数。目前,get_arguments()假设提供的任何关键字参数实际上都是函数的有效参数,但情况并非总是如此。此外,任何未获得值的必需参数都会在调用函数时导致错误。理想情况下,我们也应该能够验证这些论点。

我们可以从get_arguments()开始,这样我们就有了一个将传递给函数的所有值的字典,然后我们有两个验证任务:确保所有参数都有值,并确保没有提供函数不知道的参数。函数本身可能会对参数值提出额外的要求,但是作为一个通用的工具,我们不能对所提供的任何值的内容做任何假设。

让我们首先确保提供了所有必需的值。这一次我们不必太担心必需或可选参数,因为get_arguments()已经确保可选参数有它们的默认值。因此,任何没有值的参数都是必需的:

img/330715_3_En_3_Figv_HTML.jpg

import itertools

def validate_arguments(func, args, kwargs):
    """
    Given a function and its arguments, return a dictionary
    with any errors that are posed by the given arguments.
    """

    arguments = get_arguments(func, args, kwargs)
    spec = inspect.getfullargspec(func)
    declared_args = spec.args[:]
    declared_args.extend(spec.kwonlyargs)
    errors = {}

    for name in declared_args:
        if name not in arguments:
            errors[name] = "Required argument not provided."

    return errors

有了验证所有必需参数都有值的基础,下一步是确保函数知道如何处理所有提供的参数。任何没有在函数中定义的参数都应被视为错误:

img/330715_3_En_3_Figw_HTML.jpg

import itertools

def validate_arguments(func, args, kwargs):
    """
    Given a function and its arguments, return a dictionary
    with any errors that are posed by the given arguments.
    """

    arguments = get_arguments(func, args, kwargs)
    spec = inspect.getfullargspec(func)
    declared_args = spec.args[:]
    declared_args.extend(spec.kwonlyargs)
    errors = {}

    for name in declared_args:
        if name not in arguments:
            errors[name] = "Required argument not provided."

    for name in arguments:
        if name not in declared_args:
            errors[name] = "Unknown argument provided."

    return errors

当然,因为这依赖于get_arguments(),所以它继承了变量位置参数的相同限制。这意味着validate_arguments()有时可能会返回一个不完整的错误字典。可变位置参数带来了这个函数无法解决的额外挑战。在函数注释一节中提供了更全面的解决方案。

装饰者

当处理一个大的代码库时,有一组需要由许多不同的函数执行的任务是很常见的,通常是在做一些更具体的函数之前或之后。这些任务的性质和使用它们的项目一样多种多样,但是这里有一些使用装饰器的更常见的例子:

  • 访问控制

  • 临时对象的清理

  • 错误处理

  • 贮藏

  • 记录

在所有这些情况下,都有一些样板代码需要在函数真正要做的事情之前或之后执行。与其将代码复制到每个函数中,不如编写一次,然后简单地应用到每个需要它的函数中。这就是装修工的用武之地。

从技术上来说,decorators 只是简单的函数,设计的目的只有一个:接受一个函数,返回一个函数。返回的函数可以与传入的函数相同,也可以完全被其他函数替代。应用装饰器最常见的方式是使用专门为此目的设计的特殊语法。下面是如何应用一个装饰器来抑制函数执行过程中的任何错误:

img/330715_3_En_3_Figx_HTML.jpg

import datetime
from myapp import suppress_errors

@suppress_errors
def log_error(message, log_file='errors.log'):
    """Log an error message to a file."""

    log = open(log_file, 'w')
    log.write('%s\t%s\n' % (datetime.datetime.now(), message))

这个语法告诉 Python 将log_error()函数作为参数传递给suppress_errors()函数,然后返回一个替代函数来使用。在 Python 2.4 中引入@语法之前,通过检查旧版本 Python 中使用的过程,更容易理解幕后发生的事情:

img/330715_3_En_3_Figy_HTML.jpg

 #Python 2.x example
import datetime
from myapp import suppress_errors

def log_error(message, log_file='errors.log'):
    """Log an error message to a file."""

    log = open(log_file, 'w')
    log.write('%s\t%s\n' % (datetime.datetime.now(), message))

log_error = suppress_errors(log_error)

不要重复/可读性很重要

当使用旧的修饰方法时,注意函数的名字写了三次。这不仅是一些看起来不必要的额外输入;如果你需要改变函数名,事情会变得复杂,而且你添加的装饰器越多,事情只会变得越糟。新的语法可以在不重复函数名的情况下应用 decorator,不管使用多少 decorator。

当然,@语法还有一个好处,这对它的引入有很大的帮助:它让 decorators 就在函数的签名附近。这使得一眼就能看出应用了哪些装饰器,从而更直接地传达了函数的总体行为。将它们放在函数的底部需要更多的努力来理解完整的行为,所以通过将 decorators 移到顶部,可读性得到了极大的增强。

旧的选项仍然可用,其行为与@语法相同。唯一真正的区别是@语法仅在源文件中定义函数时可用。如果你想装饰一个从别处导入的函数,你必须手动将它传递给装饰器,所以记住它的两种工作方式是很重要的:

img/330715_3_En_3_Figz_HTML.jpg

from myapp import log_error, suppress_errors

log_error = suppress_errors(log_error)

为了理解像log_error()这样的装饰器内部通常会发生什么,有必要首先研究一下 Python 和许多其他语言中最容易被误解和利用不足的特性之一:闭包。

关闭

尽管闭包很有用,但它似乎是一个令人生畏的话题。大多数解释都假设事先知道诸如词法范围、自由变量、上取值和变量范围之类的东西。此外,因为无需学习闭包就可以做很多事情,所以这个主题通常看起来神秘而不可思议,好像它是专家的领域,不适合我们其他人。幸运的是,闭包并不像术语所暗示的那样难以理解。

简而言之,闭包是一个在另一个函数内部定义的函数,但是它被传递到该函数之外,在那里它可以被其他代码使用。还有一些其他的细节需要学习,但是在这一点上它仍然是相当抽象的,所以这里有一个闭包的简单例子:

img/330715_3_En_3_Figaa_HTML.jpg

def multiply_by(factor):
    """Return a function that multiplies values by the given factor"""
    def multiply(value):
        """Multiply the given value by the factor already provided"""
        return value * factor
    return multiply
times2=multiply_by(2)
print(times2(2))

正如您所看到的,当您用一个值作为乘法因子调用multiply_by()时,内部的multiply()将被返回以供以后使用。下面是它的实际使用方法,这可能有助于解释它为什么有用。如果您在 Python 提示符下一行一行地输入前面的代码,下面的代码会让您知道这是如何工作的:

img/330715_3_En_3_Figab_HTML.jpg

>>> times2 = multiply_by(2)
>>> times2(5)
10
>>> times2(10)
20
>>> times3 = multiply_by(3)
>>> times3(5)
15
>>> times2(times3(5))
30

这种行为看起来有点像functools.partial()的参数预加载特性,但是你不需要一个函数同时接受两个参数。然而,关于这是如何工作的有趣部分是,内部函数不需要接受自己的factor参数;它本质上继承了外部函数的参数。

当查看代码时,内部函数可以引用外部函数的值这一事实通常看起来非常正常,但是有一些关于它如何工作的规则可能不太明显。首先,内部函数必须定义在外部函数中;简单地将函数作为参数传入是行不通的:

img/330715_3_En_3_Figac_HTML.jpg

def multiply(value):
    return value * factor

def custom_operator(func, factor):
    return func

multiply_by = functools.partial(custom_operator, multiply)

从表面上看,这几乎等同于前面展示的工作示例,但是增加了能够在运行时提供可调用的好处。毕竟,内部函数被放在外部函数中,并被返回供其他代码使用。问题是闭包只在内部函数实际定义在外部函数内部时才起作用,而不仅仅是传入的任何东西:

img/330715_3_En_3_Figad_HTML.jpg

>>> times2 = multiply_by(2)
>>> times2(5)
Traceback (most recent call last):
  ...
NameError: global name 'factor' is not defined

这几乎与functools.partial()的函数相矛盾,它的工作方式很像这里描述的custom_operator()函数,但是请记住,partial()在接受所有参数的同时,也接受将它们绑定在一起的可调用函数。它不会试图从任何地方引入任何论点。

封装器

闭包在包装器的构造中发挥了重要作用,包装器是装饰器最常见的用途。包装器是设计用来包含另一个函数的函数,在被包装的函数执行之前或之后添加一些额外的行为。在闭包讨论的上下文中,包装器是内部函数,而被包装的函数作为参数传递给外部函数。以下是上一节中显示的suppress_errors()装饰器背后的代码:

img/330715_3_En_3_Figae_HTML.jpg

def suppress_errors(func):
    """Automatically silence any errors that occur within a function"""

    def wrapper(*args, **kwargs):
        try:
            return func(*args, **kwargs)
        except Exception:
            pass

    return wrapper

这里有几件事情正在进行,但是大部分已经讨论过了。装饰器将一个函数作为其唯一的参数,直到内部包装函数执行时才执行。通过返回包装器而不是原始函数,我们形成了一个闭包,即使在完成了suppress_errors()之后,也允许使用相同的函数名。

因为包装器必须像原始函数一样被调用,不管该函数是如何定义的,它必须接受所有可能的参数组合。这是通过一起使用变量位置和关键字参数,并在内部将它们直接传递给原始函数来实现的。对于包装器来说,这是一种非常常见的做法,因为它允许最大的灵活性,而不关心它应用于什么类型的函数。

包装器中的实际工作非常简单:只需执行一个try / except块中的原始函数来捕捉任何引发的异常。如果出现任何错误,它只是愉快地继续,隐式返回None,而不是做任何有趣的事情。它还确保返回由原始函数返回的任何值,以便保留包装函数的所有有意义的内容。

在这种情况下,包装函数相当简单,但基本思想也适用于许多更复杂的情况。在调用原始函数之前和之后,可能都有几行代码,也许是关于是否调用它的一些决定。例如,如果授权由于任何原因失败,授权包装器通常会返回或引发异常,而不会调用包装的函数。

不幸的是,包装函数意味着一些潜在有用的信息会丢失。第五章展示了 Python 如何访问一个函数的某些属性,比如它的名字、文档字符串和参数列表。通过用包装器替换原始函数,我们实际上也替换了所有其他信息。为了找回一些,我们求助于名为wrapsfunctools模块中的装饰器。

在装饰器中使用装饰器可能看起来很奇怪,但它确实解决了和其他任何事情一样的问题:有一个共同的需求,不应该在它出现的任何地方都需要重复的代码。functools.wraps() decorator 将名称、docstring 和其他一些信息复制到包装的函数中,因此至少有一部分得到保留。它不会复制参数列表,但总比什么都没有好:

img/330715_3_En_3_Figaf_HTML.jpg

import functools

def suppress_errors(func):
    """Automatically silence any errors that occur within a function"""

    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        try:
            return func(*args, **kwargs)
        except Exception:
            pass

    return wrapper

这个结构最奇怪的地方在于functools.wraps()除了它所应用的函数之外还接受一个参数。在这种情况下,该参数是要从中复制属性的函数,它是在装饰器本身所在的行中指定的。这对于为特定任务定制装饰器非常有用,所以接下来我们将研究如何在您自己的装饰器中利用定制参数。

有争论的装饰者

通常装饰者只接受一个参数,即要装饰的函数。然而在幕后,Python 在将@行作为装饰器应用之前,先将其作为表达式进行评估。表达式的结果就是实际用作装饰器的内容。在简单的情况下,装饰表达式只是一个函数,所以很容易计算。在functools.wraps()使用的形式中添加参数使得整个语句的计算如下:

wrapper = functools.wraps(func)(wrapper)

这样看,解决方案就变得清晰了:一个函数返回另一个函数。第一个函数接受额外的参数并返回另一个函数,该函数用作装饰器。这使得在装饰器上实现参数变得更加复杂,因为它给整个过程增加了另一层,但是一旦在上下文中看到它,就很容易处理了。下面是你可能会看到的最长链条中所有东西是如何协同工作的:

  • 接受和验证参数的函数,同时返回修饰原始参数的函数

  • 接受用户定义函数的装饰器

  • 添加额外行为的包装器

  • 被修饰的原始函数

不是所有的事情都会发生在每个装饰者身上,但是这是最复杂场景的一般方法。任何更复杂的事情都只是这四个步骤之一的扩展。正如您所注意到的,四个中的三个已经被讨论过了,所以修饰参数所强加的额外的层实际上是剩下来唯一要讨论的。

这个新的最外层函数接受装饰器的所有参数,可选地验证它们,并返回一个新函数作为参数变量的闭包。这个新函数必须有一个参数,作为修饰函数。下面是suppress_errors() decorator 如果接受一个 logger 函数来报告错误,而不是完全消除错误时的样子:

img/330715_3_En_3_Figag_HTML.jpg

import functools

def suppress_errors(log_func=None):

    """Automatically silence any errors that occur within a function"""

    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            try:
                return func(*args, **kwargs)
            except Exception as e:
                if log_func is not None:
                    log_func(str(e))

        return wrapper

    return decorator

这种分层允许suppress_errors()在被用作装饰器之前接受参数,但是它不能在没有任何参数的情况下调用它。因为这是以前的行为,我们现在引入了向后不兼容。我们能得到的最接近原始语法的方法是首先实际调用suppress_errors(),但是不带任何参数。

下面是一个示例函数,它处理给定目录中的更新文件。这是一项经常自动执行的任务,因此如果出现问题,它可以停止运行,并在下一个指定的时间再次尝试:

img/330715_3_En_3_Figah_HTML.jpg

import datetime
import os
import time
from myapp import suppress_errors

@suppress_errors()

def process_updated_files(directory, process, since=None):
    """
    Processes any new files in a `directory` using the `process` function.
    If provided, `since` is a date after which files are considered updated.

    The process function passed in must accept a single argument: the absolute
    path to the file that needs to be processed.
    """

    if since is not None:
        # Get a threshold that we can compare to the modification time later
        threshold = time.mktime(since.timetuple()) + since.microsecond / 1000000
    else:
        threshold = 0

    for filename in os.listdir(directory):
        path = os.path.abspath(os.path.join(directory, filename))
        if os.stat(path).st_mtime > threshold:
            process(path)

不幸的是,这仍然是一个奇怪的情况,它看起来真的不像 Python 程序员习惯的任何事情。显然,我们需要一个更好的解决方案。

有或没有参数的装饰者

理想情况下,如果没有提供参数,带有可选参数的装饰器将能够在没有括号的情况下被调用,同时仍然能够在必要时提供参数。这意味着在一个装饰器中支持两个不同的流,如果不小心的话,这可能会变得很棘手。主要问题是,最外层的函数必须能够接受任意参数单个函数,并且它必须能够辨别这两者之间的差异并相应地进行操作。

这将我们带到第一个任务:确定调用外部函数时使用哪个流。一种选择是检查第一个位置参数,看它是否是一个函数,因为 decorators 总是将函数作为位置参数接收。

有趣的是,根据上一段简单提到的东西,可以做出一个很好的区分。装饰者总是接收被装饰的函数作为位置参数,所以我们可以用它作为区别因素。对于所有其他的参数,我们可以依赖于关键字参数,它们通常更明确,因此也更具可读性。

我们可以通过使用*args**kwargs来做到这一点,但是因为我们知道位置参数列表只是一个固定的单个参数,所以将它作为第一个参数并使其可选更容易。然后,任何附加的关键字参数都可以放在它的后面。当然,它们都需要默认值,但是这里的要点是所有的参数都是可选的,所以这不是问题。

参数的区别已经消除,剩下的就是如果提供了参数,就分支到不同的代码块,而不是要修饰的函数。通过使用可选的第一个位置参数,我们可以简单地测试它的存在,以确定通过哪个分支:

img/330715_3_En_3_Figai_HTML.jpg

import functools

def suppress_errors(func=None, log_func=None):
    """Automatically silence any errors that occur within a function"""

    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            try:
                return func(*args, **kwargs)
            except Exception as e:
                if log_func is not None:
                    log_func(str(e))

        return wrapper

    if func is None:
        return decorator
    else:
        return decorator(func)

这现在允许带或不带参数调用suppress_errors(),但是记住参数必须带关键字传递仍然很重要。这是一个参数看起来与被修饰的函数相同的例子。即使我们尝试了,也没有办法通过检查来区分它们。

如果 logger 函数是作为位置参数提供的,那么 decorator 会假设它是要被修饰的函数,所以它会立即执行 logger,把要被修饰的函数作为它的参数。本质上,您将最终记录您想要修饰的函数。更糟糕的是,在修饰函数之后,剩下的值实际上是来自记录器的返回值,而不是修饰器的。因为大多数记录器不返回任何东西,所以很可能是None——没错,你的函数已经消失了。假设您键入了上述函数,您可以在提示符下尝试以下操作:

img/330715_3_En_3_Figaj_HTML.jpg

>>> def print_logger(message):
...     print(message)
...
>>> @suppress_errors(print_logger)
... def example():
...     return variable_which_does_not_exist
...
<function example at 0x...>
>>> example
>>>

这是装饰器工作方式的一个副作用,除了记录它并确保在应用参数时总是指定关键字之外,几乎没什么可做的。

示例:记忆化

为了演示 decorators 如何将公共行为复制到您喜欢的任何函数中,请考虑如何提高确定性函数的效率。给定相同的参数集,确定性函数总是返回相同的结果,不管它们被调用多少次。给定这样一个函数,应该可以缓存给定函数调用的结果,这样,如果用相同的参数再次调用它,就可以查找结果,而不必再次调用该函数。

使用缓存,装饰器可以使用参数列表作为键来存储函数的结果。字典不能用作字典中的键,因此在填充缓存时只能考虑位置参数。幸运的是,大多数利用记忆化的函数都是简单的数学运算,无论如何通常都是用位置参数调用的:

img/330715_3_En_3_Figak_HTML.jpg

def memoize(func):
    """
    Cache the results of the function so it doesn't need to be called
    again, if the same arguments are provided a second time.
    """
    cache = {}

    @functools.wraps(func)
    def wrapper(*args):
        if args in cache:
            return cache[args]

        # This line is for demonstration only.
        # Remove it before using it for real.
        print('Calling %s()' % func.__name__)

        result = func(*args)
        cache[args] = result
        return result

    return wrapper

现在,无论何时定义一个确定性函数,都可以使用memoize()装饰器自动缓存其结果以备将来使用。下面是一些简单数学运算的工作原理。同样,假设您键入了上述存根,请尝试以下操作:

img/330715_3_En_3_Figal_HTML.jpg

>>> @memoize
... def multiply(x, y):
...     return x * y
...
>>> multiply(6, 7)
Calling multiply()
42
>>> multiply(6, 7)
42
>>> multiply(4, 3)
Calling multiply()
12
>>> @memoize
... def factorial(x):
...    result = 1
...    for i in range(x):
...        result *= i + 1
...    return result
...
>>> factorial(5)
Calling factorial()
120
>>> factorial(5)
120
>>> factorial(7)
Calling factorial()
5040

警告

记忆化最适合于具有几个参数的函数,这些函数调用时参数值的变化相对较小。使用大量参数调用的函数或者使用的参数值多种多样的函数会很快用缓存填满大量内存。这可能会降低整个系统的速度,唯一的好处是在少数情况下可以重用参数。此外,不真正确定的函数实际上会导致问题,因为函数不会每次都被调用。

示例:一个装饰者创建装饰者

敏锐的读者会注意到在对更复杂的装饰构造的描述中有一些矛盾。decorator 的目的是避免大量样板代码并简化函数,但是 decorator 本身最终变得相当复杂,仅仅是为了支持可选参数之类的特性。理想情况下,我们也可以将样板文件放入装饰器中,为新的装饰器简化流程。

因为装饰者是 Python 函数,就像他们装饰的那些一样,这是很有可能的。然而,和其他情况一样,有些事情需要考虑。在这种情况下,您定义为装饰器的函数需要区分用于装饰器的参数和用于它所装饰的函数的参数:

img/330715_3_En_3_Figam_HTML.jpg

def decorator(declared_decorator):
    """Create a decorator out of a function, which will be used as a wrapper."""

    @functools.wraps(declared_decorator)
    def final_decorator(func=None, **kwargs):
        # This will be exposed to the rest
        # of your application as a decorator

        def decorated(func):
            # This will be exposed to the rest
            # of your application as a decorated
            # function, regardless how it was called
            @functools.wraps(func)
            def wrapper(*a, **kw):
                # This is used when actually executing
                # the function that was decorated
                return declared_decorator(func, a, kw, **kwargs)

            return wrapper

        if func is None:
            # The decorator was called with arguments,
            # rather than a function to decorate
            return decorated
        else:
            # The decorator was called without arguments,
            # so the function should be decorated immediately
            return decorated(func)

    return final_decorator

有了这个,你就可以直接用包装函数来定义你的装饰器了;然后,只需应用这个装饰器来管理幕后的开销。现在,您声明的函数必须始终接受三个参数,除此之外还可以添加任何其他参数。下面的列表中显示了三个必需的参数:

  • 将被修饰的函数,如果合适,应该调用该函数

  • 提供给修饰函数的位置参数元组

  • 提供给修饰函数的关键字参数的字典

记住这些参数,下面是你如何定义本章前面描述的suppress_errors()装饰器:

img/330715_3_En_3_Figan_HTML.jpg

>>> @decorator
... def suppress_errors(func, args, kwargs, log_func=None):
...     try:
...        return func(*args, **kwargs)
...    except Exception as e:
...        if log_func is not None:
...           log_func(str(e))
...
>>> @suppress_errors
... def example():
...     return variable_which_does_not_exist
...
>>> example() # Doesn't raise any errors
>>> def print_logger(message):
...     print(message)
...
>>> @suppress_errors(log_func=print_logger)
... def example():
...     return variable_which_does_not_exist
...
>>> example()
global name 'variable_which_does_not_exist' is not defined

函数注释

一个函数通常有三个方面不涉及其中的代码:一个名称、一组参数和一个可选的 docstring。然而,有时这并不足以完全描述该函数是如何工作的或者应该如何使用它。静态类型语言——比如 Java——也包括关于每个参数允许什么类型的值,以及返回值可以是什么类型的详细信息。

Python 对这种需求的回应是函数注释的概念。每个参数以及返回值都可以附加一个表达式,描述一个无法用其他方式表达的细节。这可以是简单的类型,例如intstr,类似于静态类型语言,如下面的示例存根所示:

def prepend_rows(rows:list, prefix:str) -> list:
    return [prefix + row for row in rows]

这个例子和传统静态类型语言的最大区别不是语法问题;在 Python 中,注释可以是任何表达式,而不仅仅是类型或类。你可以用描述性的字符串、计算值,甚至内联函数来注释你的参数——详见本章关于 lambdas 的部分。如果用字符串作为附加文档进行注释,前面的示例可能是这样的:

img/330715_3_En_3_Figao_HTML.jpg

def prepend_rows(rows:"a list of strings to add to the prefix",
                 prefix:"a string to prepend to each row provided",
                 ) -> "a new list of strings prepended with the prefix":
    return [prefix + row for row in rows]

当然,这种灵活性可能会让您怀疑函数注释的预期用途,但是没有,这是故意的。官方说法是,注释背后的意图是鼓励在框架和其他第三方库中进行实验。这里展示的两个例子分别适用于类型检查和文档库。

示例:类型安全

为了说明库如何使用批注,请考虑一个类型安全库的基本实现,它可以理解和利用前面描述的函数。它期望参数注释为任何传入的参数指定有效的类型,而返回注释将能够验证函数返回的值。

因为类型安全包括在函数执行前后验证值,所以装饰器是实现的最合适的选择。此外,因为所有的类型提示信息都在函数声明中提供,所以我们不需要担心任何额外的参数,所以一个简单的装饰器就足够了。然而,第一个任务是验证注释本身,因为它们必须是有效的 Python 类型,以便装饰器的其余部分正常工作:

img/330715_3_En_3_Figap_HTML.jpg

import inspect

def typesafe(func):
    """
    Verify that the function is called with the right argument types and
    that it returns a value of the right type, according to its annotations
    """

    spec = inspect.getfullargspec(func)

    for name, annotation in spec.annotations.items():
        if not isinstance(annotation, type):
            raise TypeError("The annotation for '%s' is not a type." % name)

    return func

到目前为止,这并没有对函数做任何事情,但是它确实检查了所提供的每个注释是否是有效的类型,然后可以用它来验证注释所引用的参数的类型。这使用了isinstance(),它将一个对象与其预期的类型进行比较。关于isinstance()和一般类型和等级的更多信息可以在第四章中找到。

现在我们可以确定所有的注释都是有效的,是时候开始验证一些参数了。给定有多少种类型的论点,让我们一次一个。关键字参数是最容易开始的,因为它们已经将它们的名称和值捆绑在一起,所以少了一件需要担心的事情。有了名称,我们就可以获得相关的注释,并根据它来验证值。这也是开始分解一些东西的好时机,因为我们最终将不得不一遍又一遍地使用一些相同的东西。下面是包装器开始时的样子:

img/330715_3_En_3_Figaq_HTML.jpg

import functools

import inspect

def typesafe(func):
    """
    Verify that the function is called with the right argument types and
    that it returns a value of the right type, according to its annotations
    """

    spec = inspect.getfullargspec(func)
    annotations = spec.annotations

    for name, annotation in annotations.items():
        if not isinstance(annotation, type):
            raise TypeError("The annotation for '%s' is not a type." % name)

    error = "Wrong type for %s: expected %s, got %s."

    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        # Deal with keyword arguments
        for name, arg in kwargs.items():
            if name in annotations and not isinstance(arg, annotations[name]):
                raise TypeError(error % (name,
                                         annotations[name].__name__,
                                         type(arg).__name__))

        return func(*args, **kwargs)
    return wrapper

到目前为止,这应该是不言自明的。将检查提供的任何关键字参数,看是否有关联的注释。如果有,检查提供的值以确保它是在注释中找到的类型的实例。错误消息被剔除,因为在我们完成之前,它还会被重用几次。

接下来是处理位置参数。同样,我们可以依靠zip()将位置参数名称与所提供的值对齐。因为zip()的结果与字典的items()方法兼容,我们实际上可以使用来自itertools模块的chain()将它们链接到同一个循环中:

img/330715_3_En_3_Figar_HTML.jpg

第一部分:在此基础上添加第二部分,以脚本的形式查看它的运行情况:

import functools
import inspect

from itertools import chain

def typesafe(func):
    """
    Verify that the function is called with the right argument types and
    that it returns a value of the right type, according to its annotations
    """
    spec = inspect.getfullargspec(func)
    annotations = spec.annotations

    for name, annotation in annotations.items():
        if not isinstance(annotation, type):
            raise TypeError("The annotation for '%s' is not a type." % name)

    error = "Wrong type for %s: expected %s, got %s."

    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        # Deal with keyword arguments
        for name, arg in chain(zip(spec.args, args), kwargs.items()):
            if name in annotations and not isinstance(arg, annotations[name]):
                raise TypeError(error % (name,
                                         annotations[name].__name__,
                                         type(arg).__name__))

        return func(*args, **kwargs)
    return wrapper

尽管这考虑了位置和关键字参数,但并不是全部。因为变量参数也可以接受注释,所以我们必须考虑那些与定义的参数名不匹配的参数值。不幸的是,在我们能够在这方面有所作为之前,还有一些事情必须处理。

如果你真的非常关注,你可能会注意到代码中一个非常微妙的错误。为了使代码更容易理解,并考虑到由关键字传递的任何参数,包装器遍历整个的kwargs字典,检查相关的注释。不幸的是,这给我们留下了无意的名称冲突的可能性。

为了说明 bug 是如何触发的,首先考虑在处理变量参数时会出现什么情况。因为我们只能将单个注释应用于变量参数名称本身,所以必须假设该注释应用于该变量参数下的所有参数,无论是按位置传递还是按关键字传递。如果没有对这种行为的明确支持,变量参数应该被忽略,但是下面是代码的实际情况:

img/330715_3_En_3_Figas_HTML.jpg

第二部分:把这个放在你刚刚输入的脚本的末尾:

@typesafe
def example(*args:int, **kwargs:str):
    pass

print(example(spam='eggs'))  #fine
print(example(kwargs='spam'))  #fine
print(example(args='spam'))  # not fine!
# output will be:
#Traceback (most recent call last):
#TypeError: Wrong type for args: expected int, got str.

有趣的是,除非函数调用包含与变量位置参数同名的关键字参数,否则一切正常。尽管乍一看似乎不明显,但问题实际上出在包装器的唯一循环中要迭代的值集合上。它假设所有关键字参数的名称都与注释很好地对齐。

基本上,问题是用于变量参数的关键字参数最终与来自其他参数的注释相匹配。在大多数情况下,这是可以接受的,因为这三种类型的参数中的两种永远不会引起问题。用显式参数名匹配它只是重复 Python 已经做的事情,所以使用关联的注释是没问题的,并且匹配变量关键字参数名最终会使用我们计划使用的同一注释。

因此,只有当关键字参数与变量位置参数名称匹配时,问题才会出现,因为这种关联永远没有意义。有时,如果注释与变量关键字参数的注释相同,问题可能永远不会出现,但不管怎样,问题仍然存在。因为包装函数的代码仍然很少,所以不难看出问题出在哪里。

在主循环中,迭代链的第二部分是kwargs字典中的条目列表。这意味着通过关键字传递的所有内容都要对照命名注释进行检查,这显然并不总是我们想要的。相反,我们现在只想遍历显式参数,同时仍然支持位置和关键字。这意味着我们将不得不基于函数定义构建一个新的字典,而不是像我们现在这样走捷径,依赖于kwargs。这里的清单中已经删除了外层的typesafe()函数,以使代码在打印时更容易理解:

img/330715_3_En_3_Figat_HTML.jpg

    def wrapper(*args, **kwargs):
        # Populate a dictionary of explicit arguments passed positionally
        explicit_args = dict(zip(spec.args, args))

        # Add all explicit arguments passed by keyword
        for name in chain(spec.args, spec.kwonlyargs):
            if name in kwargs:
                explicit_args[name] = kwargs[name]

        # Deal with explicit arguments
        for name, arg in explicit_args.items():
            if name in annotations and not isinstance(arg, annotations[name]):
                raise TypeError(error % (name,
                                         annotations[name].__name__,
                                         type(arg).__name__))

        return func(*args, **kwargs)

有了这个 bug,我们就可以专注于正确地支持变量参数了。因为关键字参数有名字,而位置参数没有,所以我们不能像处理显式参数那样一次处理两种类型。这个过程与显式参数非常相似,但是在每种情况下迭代的值是不同的。然而,最大的区别是注释不是由参数的名称引用的。

为了只遍历真正可变的位置参数,我们可以简单地使用显式参数的数量作为位置参数元组中一个片的开始。如果只提供了显式参数,这将得到在显式参数之后提供的所有位置参数或一个空列表。

对于关键字参数,我们必须更有创造性。因为该函数已经在开头循环了所有显式声明的参数,所以我们可以使用相同的循环从kwargs字典的副本中排除任何匹配项。然后,我们可以迭代剩余的内容,以考虑所有的变量关键字参数:

img/330715_3_En_3_Figau_HTML.jpg

    def wrapper(*args, **kwargs):
        # Populate a dictionary of explicit arguments passed positionally
        explicit_args = dict(zip(spec.args, args))
        keyword_args = kwargs.copy()

        # Add all explicit arguments passed by keyword
        for name in chain(spec.args, spec.kwonlyargs):
            if name in kwargs:
                explicit_args[name] = keyword_args.pop(name)

        # Deal with explicit arguments
        for name, arg in explicit_args.items():
            if name in annotations and not isinstance(arg, annotations[name]):
                raise TypeError(error % (name,
                                         annotations[name].__name__,
                                         type(arg).__name__))

        # Deal with variable positional arguments
        if spec.varargs and spec.varargs in annotations:
            annotation = annotations[spec.varargs]
            for i, arg in enumerate(args[len(spec.args):]):
                if not isinstance(arg, annotation):
                    raise TypeError(error % ('variable argument %s' % (i + 1),
                                             annotation.__name__,
                                             type(arg).__name__))

        # Deal with variable keyword arguments
        if spec.varkw and spec.varkw in annotations:
            annotation = annotations[spec.varkw]
            for name, arg in keyword_args.items():
                if not isinstance(arg, annotation):
                    raise TypeError(error % (name,
                                             annotation.__name__,
                                             type(arg).__name__))

        return func(*args, **kwargs)

这包括所有显式参数以及通过位置和关键字传入的变量参数。剩下的唯一事情就是验证目标函数返回的值。到目前为止,包装器只是直接调用原始函数,而不考虑它返回什么,但是到目前为止,应该很容易看出需要做什么:

img/330715_3_En_3_Figav_HTML.jpg

    def wrapper(*args, **kwargs):
        # Populate a dictionary of explicit arguments passed positionally
        explicit_args = dict(zip(spec.args, args))
        keyword_args = kwargs.copy()

        # Add all explicit arguments passed by keyword
        for name in chain(spec.args, spec.kwonlyargs):
            if name in kwargs:
                explicit_args[name] = keyword_args(name)

        # Deal with explicit arguments
        for name, arg in explicit_args.items():
            if name in annotations and not isinstance(arg, annotations[name]):
                raise TypeError(error % (name,
                                         annotations[name].__name__,
                                         type(arg).__name__))

        # Deal with variable positional arguments
        if spec.varargs and spec.varargs in annotations:
            annotation = annotations[spec.varargs]
            for i, arg in enumerate(args[len(spec.args):]):
                if not isinstance(arg, annotation):
                    raise TypeError(error % ('variable argument %s' % (i + 1),
                                             annotation.__name__,
                                             type(arg).__name__))

        # Deal with variable keyword arguments
        if spec.varkw and spec.varkw in annotations:
            annotation = annotations[spec.varkw]
            for name, arg in keyword_args.items():
                if not isinstance(arg, annotation):
                    raise TypeError(error % (name,
                                             annotation.__name__,
                                             type(arg).__name__))

        r = func(*args, **kwargs)
        if 'return' in annotations and not isinstance(r, annotations['return']):
            raise TypeError(error % ('the return value',
                                     annotations['return'].__name__,
                                     type(r).__name__))
        return r

这样,我们就有了一个全函数的类型安全修饰器,它可以验证函数的所有参数及其返回值。然而,我们可以包括一个额外的安全措施来更快地发现错误。与外部的typesafe()函数已经验证了注释是类型一样,函数的这一部分也能够验证所有提供的参数的默认值。因为变量参数不能有默认值,这比处理函数调用本身要简单得多:

img/330715_3_En_3_Figaw_HTML.jpg

import functools
import inspect

from itertools import chain

def typesafe(func):
    """
    Verify that the function is called with the right argument types and
    that it returns a value of the right type, according to its annotations
    """
    spec = inspect.getfullargspec(func)
    annotations = spec.annotations

    for name, annotation in annotations.items():
        if not isinstance(annotation, type):
            raise TypeError("The annotation for '%s' is not a type." % name)

    error = "Wrong type for %s: expected %s, got %s."
    defaults = spec.defaults or ()
    defaults_zip = zip(spec.args[-len(defaults):], defaults)
    kwonlydefaults = spec.kwonlydefaults or {}

    for name, value in chain(defaults_zip, kwonlydefaults.items()):
        if name in annotations and not isinstance(value, annotations[name]):
            raise TypeError(error % ('default value of %s' % name,
                                     annotations[name].__name__,
                                     type(value).__name__))

    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        # Populate a dictionary of explicit arguments passed positionally
        explicit_args = dict(zip(spec.args, args))
        keyword_args = kwargs.copy()

        # Add all explicit arguments passed by keyword
        for name in chain(spec.args, spec.kwonlyargs):
            if name in kwargs:
                explicit_args[name] = keyword_args.pop(name)

        # Deal with explicit arguments
        for name, arg in explicit_args.items():
            if name in annotations and not isinstance(arg, annotations[name]):
                raise TypeError(error % (name,
                                         annotations[name].__name__,
                                         type(arg).__name__))

        # Deal with variable positional arguments
        if spec.varargs and spec.varargs in annotations:
            annotation = annotations[spec.varargs]
            for i, arg in enumerate(args[len(spec.args):]):
                if not isinstance(arg, annotation):
                    raise TypeError(error % ('variable argument %s' % (i + 1),
                                             annotation.__name__,
                                             type(arg).__name__))
        # Deal with variable keyword arguments
        if spec.varkw and spec.varkw in annotations:
            annotation = annotations[spec.varkw]
            for name, arg in keyword_args.items():
                if not isinstance(arg, annotation):
                    raise TypeError(error % (name,
                                             annotation.__name__,
                                             type(arg).__name__))

        r = func(*args, **kwargs)
        if 'return' in annotations and not isinstance(r, annotations['return']):
            raise TypeError(error % ('the return value',
                                     annotations['return'].__name__,
                                     type(r).__name__))
        return r
    return wrapper

剔除样板文件

仔细查看代码,您会发现有很多重复。每种形式的注释都做同样的事情:检查值是否合适,如果不合适就抛出异常。理想情况下,我们可以将它分解到一个单独的函数中,该函数可以专注于实际的验证任务。剩下的代码实际上只是样板文件,管理寻找不同类型注释的细节。

因为公共代码将进入一个新函数,所以将它与代码的其余部分联系起来的显而易见的方法是创建一个新的装饰器。这个新的装饰器将被放在一个函数上,这个函数将处理每个值的注释,所以我们称它为annotation_processor。传递到annotation_processor中的函数将用于现有代码中的每种注释类型:

img/330715_3_En_3_Figax_HTML.jpg

import functools
import inspect
from itertools import chain

def annotation_decorator(process):

    """
    Creates a decorator that processes annotations for each argument passed
    into its target function, raising an exception if there's a problem.
    """

    @functools.wraps(process)
    def decorator(func):
        spec = inspect.getfullargspec(func)
        annotations = spec.annotations

        defaults = spec.defaults or ()
        defaults_zip = zip(spec.args[-len(defaults):], defaults)
        kwonlydefaults = spec.kwonlydefaults or {}

        for name, value in chain(defaults_zip, kwonlydefaults.items()):
            if name in annotations:
                process(value, annotations[name])
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            # Populate a dictionary of explicit arguments passed positionally
            explicit_args = dict(zip(spec.args, args))
            keyword_args = kwargs.copy()

            # Add all explicit arguments passed by keyword
            for name in chain(spec.args, spec.kwonlyargs):
                if name in kwargs:
                    explicit_args[name] = keyword_args.pop(name)

            # Deal with explicit arguments
            for name, arg in explicit_args.items():
                if name in annotations:
                    process(arg, annotations[name])

            # Deal with variable positional arguments
            if spec.varargs and spec.varargs in annotations:
                annotation = annotations[spec.varargs]
                for arg in args[len(spec.args):]:
                    process(arg, annotation)

            # Deal with variable keyword arguments
            if spec.varkw and spec.varkw in annotations:
                annotation = annotations[spec.varkw]
                for name, arg in keyword_args.items():
                    process(arg, annotation)

            r = func(*args, **kwargs)
            if 'return' in annotations:
                process(r, annotations['return'])
            return r

        return wrapper

    return decorator

注意

因为我们把它变得更加通用,你会注意到装饰器的初始部分不再检查注释是否是有效类型。装饰器本身不再关心您对参数值应用什么逻辑,因为这些都是在被装饰的函数中完成的。

现在我们可以将这个新的装饰器应用到一个更简单的函数中,以提供一个新的typesafe()装饰器,它的函数就像上一节中的那个一样:

img/330715_3_En_3_Figay_HTML.jpg

@annotation_decorator
def typesafe(value, annotation):
    """
    Verify that the function is called with the right argument types and
    that it returns a value of the right type, according to its annotations
    """
    if not isinstance(value, annotation):
        raise TypeError("Expected %s, got %s." % (annotation.__name__,
                                                  type(value).__name__))

这样做的好处是,将来修改装饰者的行为要容易得多。此外,您现在可以使用annotation_processor()来创建新类型的装饰器,这些装饰器将注释用于不同的目的,比如类型强制。

示例:类型强制

另一种方法是将参数强制转换为函数内部所需的类型,而不是严格要求参数都是传递给函数时指定的类型。许多用于验证值的相同类型也可以用于将值直接强制转换为类型本身。此外,如果一个值不能被强制,它被传递到的类型会引发一个异常,通常是一个TypeError,就像我们的验证函数一样。

鲁棒性原则

这是稳健性原则更明显的应用之一。你的函数需要一个特定类型的参数,但是接受一些变量会更好,因为你知道在你的函数需要处理它们之前,它们可以被转换成正确的类型。同样,强制还有助于确保返回值始终是外部代码知道如何处理的一致类型。

前一节中介绍的装饰器为向新的装饰器添加这种行为提供了一个很好的起点,我们可以使用它来根据随它一起提供的注释修改传入的值。因为我们依靠类型构造函数来进行所有必要的类型检查并适当地引发异常,所以这个新的装饰器可以简单得多。事实上,它可以用一条实际指令来表达:

img/330715_3_En_3_Figaz_HTML.jpg

@annotation_decorator
def coerce_arguments(value, annotation):
    return annotation(value)

这非常简单,甚至根本不需要注释是一种类型。任何返回对象的函数或类都可以正常工作,返回值将被传递给由coerce_arguments()修饰的函数。还是会?如果您回头看看目前的annotation_decorator()函数,会发现有一个小问题,它无法按照新装饰者需要的方式工作。

问题是在调用传入外部装饰器的process()函数的行中,返回值被丢弃了。如果您尝试将coerce_arguments()与现有的装饰器一起使用,您将得到的只是代码的异常引发方面,而不是值强制方面。所以,为了正常工作,我们需要回过头来给annotation_processor()添加这个特性。

然而,总的来说,还有一些事情需要做。因为注释处理器将修改最终发送到修饰函数的参数,所以我们需要为位置参数建立一个新的列表,为关键字参数建立一个新的字典。然后我们必须拆分显式参数处理,这样我们就可以区分位置参数和关键字参数。否则,该函数将无法正确应用可变位置参数:

img/330715_3_En_3_Figba_HTML.jpg

        def wrapper(*args, **kwargs):
            new_args = []
            new_kwargs = {}
            keyword_args = kwargs.copy()

            # Deal with explicit arguments passed positionally
            for name, arg in zip(spec.args, args):
                if name in annotations:
                    new_args.append(process(arg, annotations[name]))

            # Deal with explicit arguments passed by keyword
            for name in chain(spec.args, spec.kwonlyargs):
                if name in kwargs and name in annotations:
                    new_kwargs[name] = process(keyword_args.pop(name),
                                               annotations[name])

            # Deal with variable positional arguments
            if spec.varargs and spec.varargs in annotations:
                annotation = annotations[spec.varargs]
                for arg in args[len(spec.args):]:
                    new_args.append(process(arg, annotation))

            # Deal with variable keyword arguments
            if spec.varkw and spec.varkw in annotations:
                annotation = annotations[spec.varkw]
                for name, arg in keyword_args.items():
                    new_kwargs[name] = process(arg, annotation)

            r = func(*new_args, **new_kwargs)
            if 'return' in annotations:
                r = process(r, annotations['return'])
            return r

有了这些变化,新的coerce_arguments()修饰器将能够动态地替换参数,将替换的参数传递给原始函数。不幸的是,如果您仍然使用以前的typesafe(),这种新的行为会导致问题,因为typesafe()没有返回值。如果类型检查令人满意,修复这个问题很简单,只需返回原始值,保持不变:

img/330715_3_En_3_Figbb_HTML.jpg

@annotation_decorator
def typesafe(value, annotation):
    """
    Verify that the function is called with the right argument types and
    that it returns a value of the right type, according to its annotations
    """
    if not isinstance(value, annotation):
        raise TypeError("Expected %s, got %s." % (annotation.__name__,
                                                  type(value).__name__))
    return value

用装饰者注释

自然要问的问题是:如果你想一起使用两个库会发生什么?一个人可能希望您提供有效的类型,而另一个人则希望您提供一个用于文档的字符串。它们彼此完全不兼容,这迫使你使用其中一个,而不是两个都用。此外,任何使用字典或其他组合数据类型来合并两者的尝试都必须得到两个库的同意,因为每个库都需要知道如何获得它所关心的信息。

一旦您考虑到有多少其他框架和库可能会利用这些注释,您就会看到官方函数注释崩溃的速度有多快。现在看哪些应用将真正使用它或者它们将如何协同工作还为时过早,但是考虑可以完全绕过这些问题的其他选项肯定是值得的。

因为装饰者可以接受他们自己的参数,所以可以使用它们为他们装饰的函数的参数提供注释。这样,注释与函数本身是分离的,并直接提供给理解它们的代码。因为多个 decorators 可以堆叠在一个函数上,所以它已经有了管理多个框架的内置方式。

示例:将类型安全作为装饰器

为了说明基于装饰器的函数注释方法,让我们考虑前面的类型安全例子。它已经依赖于一个装饰器,所以我们可以扩展它来接受参数,使用之前注释提供的相同类型。本质上,它看起来像这样:

img/330715_3_En_3_Figbc_HTML.jpg

>>> @typesafe(str, str)
... def combine(a, b):
...     return a + b
...
>>> combine('spam', 'alot')
'spamalot'
>>> combine('fail', 1)
Traceback (most recent call last):
  ...
TypeError: Wrong type for b: expected str, got int.

它的工作方式几乎与真正的带注释版本完全一样,只是注释是直接提供给装饰者的。为了接受参数,我们将稍微修改一下代码的第一部分,这样我们就可以从参数中获取注释,而不是检查函数本身。

因为注释是通过修饰器的参数传入的,所以我们有一个新的外部包装器来接收它们。当下一层接收到要修饰的函数时,它可以将注释与函数的签名进行匹配,为任何按位置传递的注释提供名称。一旦所有可用的注释都被赋予了正确的名称,它们就可以被内部装饰器的其余部分使用,而无需任何进一步的修改:

img/330715_3_En_3_Figbd_HTML.jpg

import functools
import inspect
from itertools import chain
def annotation_decorator(process):
    """
    Creates a decorator that processes annotations for each argument passed
    into its target function, raising an exception if there's a problem.
    """

    def annotator(*args, **kwargs):
        annotations = kwargs.copy()

        @functools.wraps(process)
        def decorator(func):
            spec = inspect.getfullargspec(func)
            annotations.update(zip(spec.args, args))

            defaults = spec.defaults or ()
            defaults_zip = zip(spec.args[-len(defaults):], defaults)
            kwonlydefaults = spec.kwonlydefaults or {}

            for name, value in chain(defaults_zip, kwonlydefaults.items()):
                if name in annotations:
                    process(value, annotations[name])

            @functools.wraps(func)
            def wrapper(*args, **kwargs):

                new_args = []
                new_kwargs = {}
                keyword_args = kwargs.copy()

                # Deal with explicit arguments passed positionally
                for name, arg in zip(spec.args, args):
                    if name in annotations:
                        new_args.append(process(arg, annotations[name]))

                # Deal with explicit arguments passed by keyword
                for name in chain(spec.args, spec.kwonlyargs):
                    if name in kwargs and name in annotations:
                        new_kwargs[name] = process(keyword_args.pop(name),
                                                   annotations[name])

                # Deal with variable positional arguments
                if spec.varargs and spec.varargs in annotations:
                    annotation = annotations[spec.varargs]
                    for arg in args[len(spec.args):]:
                        new_args.append(process(arg, annotation))

                # Deal with variable keyword arguments
                if spec.varkw and spec.varkw in annotations:
                    annotation = annotations[spec.varkw]
                    for name, arg in keyword_args.items():
                        new_kwargs[name] = process(arg, annotation)

                r = func(*new_args, **new_kwargs)
                if 'return' in annotations:
                    r = process(r, annotations['return'])
                return r

            return wrapper

        return decorator

    return annotator

这处理了大部分情况,但是还不能处理返回值。如果您试图使用正确的名称return提供返回值,您将会得到一个语法错误,因为它是一个保留的 Python 关键字。试图将它与其他注释一起提供将需要每个调用使用一个实际的字典来传递注释,在这里您可以提供返回注释而不会扰乱 Python 的语法。

相反,您需要在一个单独的函数调用中提供返回值注释,它可以是唯一的参数,没有任何保留名称问题。当使用大多数类型的 decorator 时,这很容易做到:只需创建一个新的 decorator 来检查返回值并完成它。不幸的是,由于您正在使用的最终装饰器是在我们代码的控制之外创建的,所以这并不容易。

如果将返回值处理与参数处理完全分离,实际编写类似于typesafe() decorator 的东西的程序员将不得不编写两次;一次创建参数处理装饰器,另一次创建返回值处理装饰器。因为这明显违反了 DRY,所以让我们尽可能多地重用他们的工作。

这就是一些设计发挥作用的地方。我们正在考虑超越一个简单的装饰,所以让我们弄清楚如何最好地接近它,以便它对那些必须使用它的人有意义。思考可用的选项,一个解决方案很快跃入脑海。如果我们可以添加额外的注释函数作为最终装饰器的一个属性,那么您就能够在与另一个装饰器相同的行上编写返回值注释器,但是就在后面,在它自己的函数调用中。如果你走这条路,它看起来可能是这样的:

@typesafe(int, int).returns(int)
def add(a, b):
    return a + b

不幸的是,这不是一个选项,原因是甚至不需要添加必要的代码来支持它就可以演示。问题是,这种格式不允许作为 Python 语法。如果没有任何参数,它会工作,但是不支持在一个装饰器中调用两个独立的函数。不要在装饰器本身中提供返回值注释,让我们看看别的地方。

另一种选择是使用生成的typesafe()装饰器将一个函数作为属性添加到add()函数周围的包装器中。这将返回值注释放在函数定义的末尾,更靠近指定返回值的位置。此外,它有助于澄清这样一个事实,即如果您愿意,您可以使用typesafe()来提供参数装饰器,而不必费心检查返回值。下面是它的样子:

img/330715_3_En_3_Figbe_HTML.jpg

@typesafe(int, int)
def add(a, b):
    return a + b
add.returns(int)

它仍然非常清晰,甚至可能比不管怎样都不起作用的语法更加明确。额外的好处是,支持它的代码非常简单,只需要在内部decorator()函数的末尾添加几行代码:

img/330715_3_En_3_Figbf_HTML.jpg

        def decorator(func):
            from itertools import chain

            spec = inspect.getfullargspec(func)
            annotations.update(zip(spec.args, args))

            defaults = spec.defaults or ()
            defaults_zip = zip(spec.args[-len(defaults):], defaults)
            kwonlydefaults = spec.kwonlydefaults or {}

            for name, value in chain(defaults_zip, kwonlydefaults.items()):
                if name in annotations:
                    process(value, annotations[name])

            @functools.wraps(func)
            def wrapper(*args, **kwargs):
                new_args = []
                new_kwargs = {}
                keyword_args = kwargs.copy()

                # Deal with explicit arguments passed positionally
                for name, arg in zip(spec.args, args):
                    if name in annotations:
                        new_args.append(process(arg, annotations[name]))

                # Deal with explicit arguments passed by keyword
                for name in chain(spec.args, spec.kwonlyargs):
                    if name in kwargs and name in annotations:
                        new_kwargs[name] = process(keyword_args.pop(name),
                                                   annotations[name])

                # Deal with variable positional arguments
                if spec.varargs and spec.varargs in annotations:
                    annotation = annotations[spec.varargs]
                    for arg in args[len(spec.args):]:
                        new_args.append(process(arg, annotation))

                # Deal with variable keyword arguments
                if spec.varkw and spec.varkw in annotations:
                    annotation = annotations[spec.varkw]
                    for name, arg in keyword_args.items():
                        new_kwargs[name] = process(arg, annotation)

                r = func(*new_args, **new_kwargs)
                if 'return' in annotations:
                    r = process(r, annotations['return'])
                return r

            def return_annotator(annotation):
                annotations['return'] = annotation
            wrapper.returns = return_annotator

            return wrapper

因为这个新的returns()函数将在最后一个typesafe()函数之前被调用,所以它可以简单地向现有的字典添加一个新的注释。然后,当typesafe()稍后被调用时,内部包装器可以像往常一样继续工作。这只是改变了返回值注释的提供方式,这是所需要的。

因为所有这些行为都被重构到一个单独的装饰器中,所以您可以将这个装饰器应用到coerce_arguments()或任何其他类似目的的函数中。最终的函数将与typesafe()的工作方式相同,只是用新装饰器需要做的事情替换掉了参数处理。

发电机

第二章介绍了生成器表达式的概念,并强调了迭代的重要性。尽管生成器表达式对于简单的情况很有用,但是您通常需要更复杂的逻辑来确定迭代应该如何工作。您可能需要对循环的持续时间、返回的项目、过程中可能触发的副作用或您可能关心的任何其他问题进行更细粒度的控制。

本质上,您需要一个真正的函数,但是要有适当迭代器的好处,并且没有自己创建迭代器的认知开销。这就是发电机的用武之地。通过允许您定义一个可以一次产生一个单独值的函数,而不仅仅是一个单独的返回值,您拥有了函数的额外的灵活性和迭代器的性能

生成器通过使用yield语句与其他函数分开。这有点类似于典型的return语句,除了yield不会导致函数完全停止执行。它从函数中推出一个值,由调用生成器的循环使用;然后,当这个循环重新开始时,发电机再次启动。它从停止的地方继续运行,直到找到另一个 yield 语句或函数执行完毕。

这个例子很好地说明了基本原理,所以考虑一个简单的生成器,它返回经典的斐波那契数列中的值。序列从 0 和 1 开始;后面的每个数字都是由序列中它前面的两个数字相加产生的。因此,无论序列有多高,该函数每次只需要在内存中保存两个数。然而,为了防止它永远继续下去,最好要求它应该返回的值的最大数量,总共要跟踪三个值。

很容易将前两个值设置为特例,甚至在开始返回序列其余部分的主循环之前,一次产生一个值。然而,这增加了一些额外的复杂性,使得意外引入无限循环变得非常容易。相反,我们将使用两个其他种子值–1 和 1,它们可以直接输入到主循环中。当应用循环逻辑时,它们将正确地生成 0 和 1。

接下来,我们可以为序列中所有剩余的值添加一个循环,直到达到计数。当然,到循环开始时,已经产生了两个值,所以我们必须在进入循环之前将count减 2。否则,我们最终会比要求的多产生两个值:

img/330715_3_En_3_Figbg_HTML.jpg

第一部分:添加第二部分以查看实际操作:

def fibonacci(count):
    # These seed values generate 0 and 1 when fed into the loop
    a, b = -1, 1

    while count > 0:
        # Yield the value for this iteration
        c = a + b
        yield c

        # Update values for the next iteration
        a, b = b, c
        count -= 1

有了生成器,您可以迭代它生成的值,只需像对待任何其他序列一样对待它。生成器是可自动迭代的,所以一个标准的for循环已经知道如何激活它并获取它的值。在你添加第二部分之前,通过你的结构做一个-1 和 1 的手迹,你可以确切地看到它是如何操作的。

img/330715_3_En_3_Figbh_HTML.jpg

第二部分:添加到前面代码的末尾并运行:

for x in fibonacci(3):
      print(x)
# output is
#0
#1
#1
for x in fibonacci(7):
      print(x)
#output is
#0
#1
#1
#2
#3
#5
#8

不幸的是,发电机的主要好处有时也是一种负担。因为在任何给定的时间内存中没有完整的序列,生成器总是必须从它们停止的地方重新开始。然而,大多数情况下,当你第一次迭代时,你会完全耗尽生成器,所以当你试图把它放入另一个循环时,你根本得不到任何回报。

img/330715_3_En_3_Figbi_HTML.jpg

将此添加到第二部分的末尾,然后运行:

fib = fibonacci(7)
print(list(fib)) # output [0, 1, 1, 2, 3, 5, 8]
print(list(fib)) # output []

这种行为起初看起来有点误导,但大多数时候,这是唯一有意义的行为。生成器通常用在整个序列事先都不知道或者迭代后可能会改变的地方。例如,您可以使用一个生成器来迭代当前访问系统的用户。一旦您确定了所有用户,生成器就会自动失效,您需要创建一个新的生成器,这会刷新用户列表。

注意

如果您经常使用内置的range()函数(或者 Python 3.0 之前的xrange()),您可能会注意到,如果被多次访问,它会自动重启。这种行为是通过在迭代过程中向下移动一个级别,通过显式实现迭代器协议来提供的。这不能用简单的生成器来实现,但是第五章表明你可以对你创建的对象的迭代有更大的控制。

希腊字母的第 11 个

除了自己提供特性之外,函数经常被调用来为其他特性提供一些额外的小函数。例如,在对列表进行排序时,可以通过提供一个函数来配置 Python 的行为,该函数接受列表项并返回一个用于比较的值。这样,例如,给定一列House对象,您可以按价格排序:

img/330715_3_En_3_Figbj_HTML.jpg

>>> def get_price(house):
...     return house.price
...
>>> houses.sort(key=get_price)

不幸的是,这似乎有点浪费函数的能力,而且它需要几行额外的代码和一个在sort()方法调用之外从来不会用到的名字。一个更好的方法是,如果您可以在方法调用中直接指定key函数。这不仅使它更简洁,还将函数体放在了它将被使用的地方,因此对于这些类型的简单行为来说可读性更好。

在这些情况下,Python 的 lambda 形式非常有价值。Python 提供了一个单独的语法,由关键字lambda标识。这允许您将一个没有名称的函数定义为一个表达式,具有更简单的特性集。在深入研究语法细节之前,先看一下房屋排序示例中的语法。把它想象成一行小函数。尝试以下方法:

img/330715_3_En_3_Figbk_HTML.jpg

>>> g=lambda x: x*x
>>> g(8)  # which returns 8 * 8

如你所见,这是一个相当压缩的函数定义。关键字lambda后面是一个参数列表,用逗号分隔。在排序示例中,只需要一个参数,可以随意命名,比如其他任何函数。如果需要,它们甚至可以使用与常规函数相同的语法来设置默认值。参数后面跟一个冒号,表示 lambda 主体的开始。如果不涉及参数,冒号可以直接放在关键字lambda之后:

img/330715_3_En_3_Figbl_HTML.jpg

>>> a = lambda: 'example'
>>> a
<function <lambda> at 0x. .>
>>> a()
'example'
>>> b = lambda x, y=3: x + y
>>> b()
Traceback (most recent call last):

TypeError: <lambda>() takes at least 1 positional argument (0 given)
>>> b(5)
8
>>> b(5, 1)
6

现在你可能已经发现,lambda 的主体实际上只是它的返回值。没有显式的 return 语句,所以整个函数体实际上只是一个用来返回值的表达式。这是 lambda 格式如此简洁、易读的一个重要原因,但这是有代价的:只允许一个表达式。不能使用任何控制结构,比如 try、with、while 块;不能在函数体内部赋值变量;如果不将它们绑定到同一个整体表达式,就无法执行多个操作。

这看起来非常有限,但是为了保持可读性,函数体必须尽可能简单。在需要额外的控制流特性的情况下,无论如何,您会发现在标准函数中指定它更具可读性。然后你可以把这个函数传入你可能会用到 lambda 的地方。或者,如果你的行为有一部分是由其他函数提供的,但不是全部,你可以随意调用其他函数作为表达式的一部分。

反省

Python 的主要优势之一是几乎所有东西都可以在运行时检查,从对象属性和模块内容到文档,甚至是生成的字节码。窥视这些信息被称为内省,它几乎渗透到 Python 的每个方面。以下部分定义了一些更通用的可用自省特性,而更具体的细节将在剩余的章节中给出。

可以检查的函数的最明显的属性是它的名字。这也是最简单的一个,在__name__属性中可用。返回的是用于定义函数的字符串。在没有名字的 lambdas 的情况下,__name__属性由标准字符串'<lambda>'填充:

img/330715_3_En_3_Figbm_HTML.jpg

>>> def example():
...     pass
...
>>> example.__name__
'example'
>>> (lambda: None).__name__
'<lambda>'

识别对象类型

Python 的动态特性有时会让人觉得很难确保获得正确类型的值,甚至很难知道它是什么类型的值。Python 确实提供了一些访问这些信息的选项,但是有必要认识到这是两个独立的任务,所以 Python 使用了两种不同的方法。

最显而易见的需求是识别您的代码被赋予了什么类型的对象。为此,Python 提供了其内置的type()函数,该函数接受一个要识别的对象。返回值是用于创建给定对象的 Python 类,即使该创建是通过文字值隐式完成的:

img/330715_3_En_3_Figbn_HTML.jpg

>>> type('example')
<type 'str'>
>>> class Test:
...     pass
...
>>> type(Test)
<type 'classobj'>
>>> type(Test())
<type 'instance'>

第四章详细解释了一旦你有了这个类对象,你可以做什么,但是更常见的情况是将一个对象与你期望得到的特定类型进行比较。这是一种不同的情况,因为对象的确切类型并不重要。只要值是正确类型的实例,您就可以对它的行为做出正确的假设。

有许多不同的实用函数可用于此目的,其中大部分在第四章中有所介绍。本节和下一章将会相当频繁地使用其中的一个,所以它值得在这里做一些解释。isinstance() function接受两个参数:要检查的对象和您期望的类型。结果是一个简单的TrueFalse,使其适用于 if 模块:

img/330715_3_En_3_Figbo_HTML.jpg

>>> def test(value):
...     if isinstance(value, int):
...         print('Found an integer!')
...
>>> test('0')
>>> test(0)
Found an integer!

模块和包

Python 中定义的函数和类放在模块中,而模块通常是包结构的一部分。在导入代码时访问这个结构非常容易,只需使用文档,甚至只需浏览一下磁盘上的源文件。然而,给定一段代码,识别它在源代码中的定义位置通常是有用的。

因此,所有的函数和类都有一个__module__属性,它包含定义代码的模块的导入位置。除了提供模块的名称,math.sin._module__还包括模块所在位置的完整路径。本质上,这些信息足以让你直接将它传递给第二章中显示的任何动态导入特性。

使用交互式解释器是一种特殊的情况,因为没有指定的源文件。在那里定义的任何函数或类都将具有从__module__属性返回的特殊名称'__main__':

img/330715_3_En_3_Figbp_HTML.jpg

>>> def example():
...     pass
...
>>> example
<function example at 0x...>
>>> example.__module__
'__main__'

文档字符串

因为可以在代码旁边包含文档字符串来记录函数,所以 Python 也将这些字符串存储为 function 对象的一部分。通过访问函数的__doc__属性,您可以将文档字符串读入代码,这对于动态生成库的文档非常有用。考虑下面的例子,显示了对一个简单函数的简单 docstring 访问:

img/330715_3_En_3_Figbq_HTML.jpg

def example():
    """This is just an example to illustrate docstring access."""
    pass
print(example.__doc__)  # which outputs This is just an example to illustrate docstring access.
Next, try the following from a prompt:

img/330715_3_En_3_Figbr_HTML.jpg

>>> def divide(x, y):
...     """
...     divide(integer, integer) -> floating point
...
...     This is a more complex example, with more comprehensive documentation.
...     """
...     return float(x) / y # Use float()for compatibility prior to 3.0
...
>>> divide.__doc__
'\n    divide(integer, integer) -> floating point\n\n    This is a more complex example, with more comprehensive documentation.\n    '
>>> print(divide.__doc__)

    divide(integer, integer) -> floating point

这是一个更复杂的例子,有更全面的文档。

如您所见,简单的文档字符串很容易处理,只需读入__doc__并根据需要使用它。不幸的是,更复杂的文档字符串将保留所有空格,包括换行符,这使得它们更难处理。更糟糕的是,如果不对某些字符进行扫描,您的代码就无法知道您正在查看哪种类型的 docstring。即使您只是将它打印到交互提示符下,在实际文档的前后还有一行额外的内容,以及与文件中相同的缩进。

为了更好地处理复杂的文档字符串,如示例中所示,前面提到的 inspect 模块还有一个getdoc()函数,用于检索和格式化文档字符串。它去掉了文档前后的空白,以及用于将文档字符串与其周围的代码对齐的任何缩进。这又是同一个 docstring,但是用inspect.getdoc()格式化:

img/330715_3_En_3_Figbs_HTML.jpg

>>> import inspect
>>> print(inspect.getdoc(divide))

divide(integer, integer) -> floating point
This is a more complex example, with more comprehensive documentation.

我们仍然必须在交互提示符下使用print(),因为换行符仍然保留在结果字符串中。所有的inspect.getdoc()去掉的是用于使 docstring 看起来就在函数代码旁边的空白。除了修剪 docstring 开头和结尾的空格,getdoc()还使用一种简单的技术来识别和删除用于缩进的空格。

本质上,getdoc()计算每行代码开头的空格数,即使答案是 0。然后,它会确定这些计数的最小值,并从每一行中删除前导和尾随空格后剩余的字符。这允许您保持 docstring 中的其他缩进不变,只要它大于您将文本与周围代码对齐所需的缩进量。这里有一个更复杂的 docstring 的例子,因此您可以看到inspect.getdoc()是如何处理它的:

img/330715_3_En_3_Figbt_HTML.jpg

>>> def clone(obj, count=1):
...     """
...    clone(obj, count=1) -> list of cloned objects
...
...    Clone an object a specified number of times, returning the cloned
...    objects as a list. This is just a shallow copy only.
...
...    obj
...        Any Python object
...    count
...        Number of times the object will be cloned
...
...      >>> clone(object(), 2)
...      [<object object at 0x12345678>, <object object at 0x87654321>]
...    """
...    import copy
...    return [copy.copy(obj) for x in count]
...
>>> print(inspect.getdoc(clone))
clone(obj, count=1) -> list of cloned objects

Clone an object a specified number of times, returning the cloned
objects as a list. This is just a shallow copy only.

obj
    Any Python object
count
    Number of times the object will be cloned

  >>> clone(object(), 2)
  [<object object at 0x12345678>, <object object at 0x87654321>]

注意每个参数的描述仍然缩进了四个空格,就像它们出现在函数定义中一样。最短的行在开始时总共只有四个空格,而那些行有八个,所以 Python 去掉了前四个,剩下的保持不变。同样,示例解释器会话缩进了两个额外的空格,因此结果字符串保持两个空格的缩进。

哦,现在还不要太担心。第六章详细描述了如何在必要时制作和管理对象的副本。

令人兴奋的 Python 扩展:统计

大多数从事统计分析的人可能不会将 Python 作为首选。由于 Python 是一种通用语言,而 R、SAS 或 SPSS 等其他语言是直接针对统计的,所以这是有意义的。然而,Python 通过其丰富的库集可能是一个不错的选择,特别是因为它是如此用户友好,并且可以轻松地处理数据获取。它与其他语言集成得很好。然而,让我们看看使用 Python 进行统计分析是多么容易。可以使用的一个库是 Pandas (Python 数据分析库)。

安装熊猫和 Matplotlib

使用 PIP 安装熊猫。

  1. 在升级后的命令提示符下,键入: pip install pandas (enter)

    这还将安装 NumPy 和 datautils,这是必需的。假设你没有错误,做一个文件,试着读一读,以确保它的工作。

  2. 类型:pip install matplotlib (enter)

制作数据的文本文件

首先,我们将创建一个包含一些假设数据的 CSV(逗号分隔值)文本文件。这可能是来自互联网或数据库等的数据。您可能有一个想要处理的数据的电子表格(例如 Excel 或 OpenOffice)。这些软件包可以很容易地“另存为”CSV 格式。现在,使用您最喜欢的文本编辑器。

  1. 启动记事本(Windows)并输入以下内容,以文本文件的形式保存到保存 Python 文件的同一文件夹中以供阅读。确保文本文件和 Python 文件在同一个文件夹中!

img/330715_3_En_3_Figbu_HTML.jpg

  1. 将文件另存为“students.csv ”,并确保文件名没有附加 txt 扩展名;完整的文件名只能是“students.csv”。

用熊猫来显示数据

现在,让我们测试一下,看看我们能否读取 CSV 数据并将其显示到屏幕上。一旦成功,我们就可以对数据进行一些处理。创建一个 Python 脚本并运行以下命令,为 Python 文件指定一个您自己选择的有效名称:

import pandas
data = pandas.read_csv('students.csv', sep=',', na_values=".")
print (data)

您的输出应该类似于以下内容:

img/330715_3_En_3_Figbv_HTML.jpg

使用 Pandas 读取 students.csv 数据文件的输出。

运行一些数据分析

在下一个例子中,我们来看看不同专业的学生的平均年龄。统计库使这变得简单,在这种情况下,函数是 mean()groupby() :

import pandas
data = pandas.read_csv('students.csv', sep=',', na_values=".")
print (data)
groupby_major = data.groupby('Major')
for major, student_age in groupby_major['Age']:
        print( 'The average age for', major, 'majors is: ', student_age.mean())

img/330715_3_En_3_Figbw_HTML.jpg

各专业平均学生年龄输出。

函数将只显示给定数据列的唯一值。例如,使用我们的 students.csv 文件,我们可以只列出数据集中的专业。请注意,列字段是区分大小写的,因此您可能希望显示或查看原始 CSV 文件,以确保大小写正确。在这种情况下,大调需要大写字母 M,否则它将不能正常工作:

import pandas
data = pandas.read_csv('students.csv', sep=',', na_values=".")
dif_majors = data.Major.unique()
print(dif_majors)

接下来,您可能只想访问特定的数据列。考虑以下情况,其中将只提取和显示主数据列和 GPA 数据列:

import pandas
data = pandas.read_csv('students.csv', sep=',', na_values=".")
major_gpa = data[['Major','GPA']].head(10)
print (major_gpa)

使用 Matplotlib 绘图

Matplotlib 库将允许您可视化您的数字数据,这对于尝试向大众传达信息非常重要。事实上,可视化数据甚至可以帮助数据专家从信息中找到隐藏的含义。尝试以下示例,了解以图形方式可视化一系列数据值是多么容易:

import matplotlib.pyplot as plt
plt.plot([1,8,2,9,6]) # x values
plt.ylabel('Data readings for five hours') #y values
plt.show()

图表的类型

有许多类型的图表可用。快速访问 Matplotlib。org 将展示 pyplot 库的新增内容和特性,它们正在快速发展。请考虑以下内容,以查看该库中可供您使用的多种图表类型中的几种:

#Pie chart example
import matplotlib.pyplot as plt
#Data sets 1 - 5
sets = 'D 1', 'D 2', 'D 3', 'D 4', 'D 5'
data = [5, 10, 15, 20, 50]
plt.pie(data, labels=sets)
plt.show()

还有许多其他的图表,如条形图、直方图、方框图、密度图、面积图、散点图和 XKCD 风格的图表(带有 Pythonish 式幽默的漫画网站)。格式类似于 pie。

将 Matplotlib 与熊猫结合

现在我们已经有了可视化数据的基础知识,让我们来可视化一个更大的数据集,这可能会更实际一些:您通常不会将每个值都键入到代码中,而是从 CSV 文件或类似的文件中读取,这些文件可能是从互联网网站上获得的。我们将把数据可视化和熊猫结合起来。在下面的示例中,我们添加了几个函数,如 ticktitle ,并从 students.csv 数据集中制作了学生年龄范围的直方图。Pandas 和带有 pyplot 的 Matplotlib 是结合使用的好工具:

import pandas
import matplotlib.pyplot as plt
data = pandas.read_csv('students.csv', sep=',', na_values=".")

age = data[['Age']]
print(age)
plt.hist(age)
plt.xticks(range(18,33))
plt.title('Ages of students')
plt.show()

当然,Pandas 和 Matplotlib 文档和主网站将描述其他可用的函数,但这将引导您使用 Pandas 函数,以便您可以根据需要轻松地将您可能需要的其他函数集成到您的应用中。

带着它

尽管 Python 函数表面上看起来很简单,但您现在知道如何以真正适合您需求的方式定义和管理它们。当然,您可能希望将函数合并到更全面的面向对象程序中,为此,我们需要了解 Python 的类是如何工作的。

四、类

在第三章中,你回顾了函数如何允许你定义可重用的代码。这允许通过不必重新键入代码的“块”来简化一般的代码。然而,将这些相同的功能组合成定义特定类型对象的行为和属性的逻辑组通常更有用。这是标准的面向对象(OO)编程,在 Python 中通过类型来实现。这些就像函数一样,表面上看起来很简单,但是在它们背后有相当大的力量可以利用。

类最基本的想法是它封装了对象的行为,而类实例代表了对象的数据。因此,即使数据可能会从一个实例变化到另一个实例,由底层类决定的行为在这些实例之间将保持不变。定义、扩展和改变该行为是本章的重点。

遗产

使用类的最简单的方法是为单一类型的对象定义一个单一的类。这对于许多简单的应用来说很好,但是您可能会发现需要对对象的行为进行更细粒度的控制。特别是,对于一个大的对象集合,通常只有一组通用的行为,但是您需要修改它们,或者为一个更小的更具体的对象集合添加新的行为。

为了方便起见,Python 允许每个类指定一个或多个提供基本行为的基类。然后,正在定义的新类可以添加新的行为或覆盖任何现有的行为。默认情况下,所有对象都继承自内置的object类型,尽管这本身并没有真正做任何有用的事情。它实际上只是一个支撑整个系统的基础类型,因为其他所有东西都是从它继承的。

像大多数面向对象的语言一样,Python 允许您为给定的类定义尽可能多的子类,并且您也可以对这些子类化,根据需要深入到尽可能多的层次。这种垂直的继承方法适用于大多数应用,因为它最大化了基类的有用性。当一个单独的、通常很大的行为集需要跨各种其他类重用时,垂直继承被证明非常有用。尝试一个带有显式构造函数的非常简单的 Python 类:

img/330715_3_En_4_Figa_HTML.jpg

class Contact:
    def __init__(self, lName, fName): # explicit constructor for class
        self.lastName = lName
        self.firstName = fName

worker1 = Contact("Smith", "James")
print(worker1.lastName, worker1.firstName)

Python 也有一些内置函数来修改你的类..这是对本章“属性”部分的前瞻,但是这些函数是 getattr(obj,name) 来访问对象的属性; setattr(obj,name,value) 设置一个对象的属性; hasattr(obj,name) 检查是否存在;最后, delattr(obj,name) 删除对象中的属性。当然,一旦创建了对象,就可以访问公共属性:

img/330715_3_En_4_Figb_HTML.jpg

class Contact:
    def __init__(self, lName, fName): # explicit constructor for class
        self.lastName = lName
        self.firstName = fName
worker1 = Contact('Smith', 'James')
print(worker1.lastName, worker1.firstName) # object.public_property
newLast=raw_input('Enter new last name: ')
setattr(worker1,'lastName',newLast) # set attribute with new value
print(worker1.lastName, worker1.firstName)
print(getattr(worker1, 'lastName')) # get existing attribute

作为又一示例,考虑涉及联系人管理应用的常见场景。在所有其他东西的基础上,你会有一个Contact类,因为,根据定义,应用中的所有东西都是一个联系人。它将有一组与之相关的字段和行为,根据应用的需要,这些字段和行为仅涵盖与所有联系人相关的内容:

img/330715_3_En_4_Figc_HTML.jpg

class Contact:
    name = TextField()
    email = EmailAddressField()
    phone = PhoneNumberField()

    def send_mail(self, message):
        # Email sending code would go here

现在,不要担心每个字段类来自哪里或它们在应用中如何工作的细节。如果你感兴趣,第十一章展示了一个可能的框架来编写这样的类。现在的关键是,每个字段都代表与手头的类相关的一个数据片段。值可能由用户输入、数据库查询的结果甚至随机值生成器提供;重要的是类的结构以及子类如何使用它。

即使只有一个联系人,您也可以基于这些核心字段和行为创建一个有用的应用。提供附加功能意味着增加对不同类型联系人的支持。例如,真实的人有名字、姓氏,也许还有手机,而公司通常只有一个名字和电话号码。同样,公司会在特定行业开展业务,这对于个人来说没有任何意义:

img/330715_3_En_4_Figd_HTML.jpg

class Person(Contact):
    first_name = TextField()
    last_name = TextField()
    name = ComputedString('%(last_name)s, %(first_name)s')
    cell_phone = PhoneNumberField()

class Company(Contact):
    industry = TextField()

现在我们已经有了一个基本的层次结构。人和公司不一样,每个人都有适合每个案例的不同领域。Python 的继承系统自动从Contact类中提取字段,并使它们在PersonCompany类中可用。您也可以对它们进行子类化,提供诸如EmployeeFriendFamilyMember这样的Person类型:

img/330715_3_En_4_Fige_HTML.jpg

class Employee(Person):
    employer = RelatedContact(Company)
    job_title = TextField()
    office_email = EmailAddressField()
    office_phone = PhoneNumberField()
    extension = ExtensionField()

class Friend(Person):
    relationship = TextField()

class FamilyMember(Person):
    relationship = TextField()
    birthday = DateField()

注意,尽管FriendFamilyMember都有彼此相同的关系字段,FamilyMember并不从Friend继承。家庭成员不一定会成为朋友,所以阶级结构反映了这一点。每个新的子类都被自动认为是它所扩展的类的一个更具体的例子,所以继承方案反映被编码的实际关系是很重要的。

这看起来像是哲学上的细节,但它在代码中也有实际的分支。正如在本章的“自省”一节中将要展示的,Python 代码可以查看类的继承结构,所以任何不匹配都会导致你的代码将一种类型的类与另一种类型的类混淆。避免这些问题的最好方法是考虑你所表示的对象实际上是如何相互关联的,并尝试在代码中重新创建这些关系。

多重继承

Python 也支持类继承的水平方法,允许一个子类一次定义多个基类。这样,一个类可以从许多不同的类中获得行为,而不必深入几个层次。当然,这意味着采用不同的逻辑方法,因为您不再通过增加特异性来定义类。相反,在多重继承的一些应用中,您实际上是将每个类构建成一组组件。

像这样构建类特别适合于这样的应用,在这些应用中,您的类共享一些共同的行为,但在其他方面没有以分层的方式相互关联。为了有意义,这通常需要从相当多的组件中构建大量的类。因为这不是大多数应用的组合方式,所以在野外很少使用这种方式。

相反,多重继承经常被调用来应用支持类,称为 mixins。Mixin 类本身不提供完整的功能;相反,它们只提供了一个很小的附加特性,这个特性在很多不同的类上都很有用。一个例子可能是 mixin,当您试图访问对象上不可用的任何属性时,它返回None,而不是引发AttributeError:

img/330715_3_En_4_Figf_HTML.jpg

class NoneAttributes:
    def __getattr__(self, name):
        return None

__getattr__()方法将在本章后面的“神奇方法”一节中详细介绍,每当请求对象上不可用的属性时,就会调用该方法。因为它作为一个后备,所以它是 mixin 的一个显而易见的选择;real 类提供了自己的功能,mixin 在适当的地方添加了这些功能:

img/330715_3_En_4_Figg_HTML.jpg

class Example(BaseClass, NoneAttributes):
      pass

e = Example()
e.does_not_exist

在典型的应用中,垂直层次将提供大部分功能,mixins 在必要的地方添加一些额外的功能。由于在访问属性时涉及到的类的潜在数量,完全理解 Python 如何决定每个被访问的属性和方法使用哪个类变得更加重要。换句话说,你需要知道 Python 解析使用哪个方法的顺序。

方法解析顺序

给定一个类层次结构,Python 需要确定在试图通过名称访问属性时使用哪个类。为了做到这一点,Python 有一些规则来管理在定义新类时如何对一组基类进行排序。对于类的大多数基本用法,你不需要知道它是如何工作的,但是如果你使用多级或多重继承,本节的细节将帮助你理解真正发生了什么。

在简单的仅垂直场景中,很容易想象如何创建方法分辨率顺序(MRO)。您实际使用的类将是第一个,接下来是它的基类,然后是基类的基类,依此类推,直到您返回到根object类型。

在这个链的每一步,Python 都会检查这个类是否有一个带有所请求名称的属性,如果有,那就是你得到的。如果没有,它会移动到下一个。用一个简单的例子就可以很容易看出这一点。在提示符下键入并尝试:

img/330715_3_En_4_Figh_HTML.jpg

>>> class Book:
...     def __init__(self, title):
...         self.title = title
...         self.page = 1
...     def read(self):
...         return 'There sure are a lot of words on page %s.' % self.page
...     def bookmark(self, page):
...         self.page = page
...
>>> class Novel(Book):
...     pass
...
>>> class Mystery(Novel):
...     def read(self):
...         return "Page %s and I still don't know who did it!" % self.page
...
>>> book1 = Book('Pro Python')
>>> book1.read()
'There sure are a lot of words on page 1.'
>>> book1.bookmark(page=52)
>>> book1.read()
'There sure are a lot of words on page 52.'
>>> book2 = Novel('Pride and Prejudice')
>>> book2.read()
'There sure are a lot of words on page 1.'
>>> book3 = Mystery('Murder on the Orient Express')
>>> book3.read()
"Page 1 and I still don't know who did it!"
>>> book3.bookmark(page=352)
>>> book3.read()
"Page 352 and I still don't know who did it!"

正如你所看到的,当在一个Mystery对象上调用read()时,你得到了直接在那个类上定义的方法,而在同一个类上使用bookmark()使用了来自Book的实现。同样,Novel本身并没有定义任何东西——它只是为了建立一个更有意义的层次结构——所以你可以访问的所有方法实际上都来自Book。说得更直接一点,Mystery的 MRO 就是[Mystery, Novel, Book],而Novel的 MRO 就是简单的[Novel, Book]

那么,当你采用使用多重继承的水平方法时会发生什么呢?为了简单起见,我们将从提供的每个基类的单一继承层开始,这样它就是一个纯粹的水平方法。在这种情况下,Python 从左到右,按照类被定义为基类的顺序。一旦我们添加了一个purchase() method,这将允许用户购买这本书的副本,这就是上一个例子的样子。如果您仍然打开先前的终端会话,请尝试在我们所做的基础上添加下一部分内容:

img/330715_3_En_4_Figi_HTML.jpg

>>> class Product:
...     def purchase(self):
...         return 'Wow, you must really like it!'
...
>>> class BookProduct(Book, Product):
...     pass
...
>>> class MysteryProduct(Mystery, Product):
...     def purchase(self):
...         return 'Whodunnit?'
...
>>> product1 = BookProduct('Pro Python')
>>> product1.purchase()
'Wow, you must really like it!'
>>> product2 = MysteryProduct('Murder on the Orient Express')
>>> product2.purchase()
'Whodunnit?'

到目前为止,每部 MRO 都非常简单易懂,即使你不知道幕后发生了什么。不幸的是,当你开始结合两种形式的继承时,事情变得更加复杂。它甚至没有举一个非常复杂的例子来说明问题;考虑一下,当您从一个拥有自己的基类和独立 mixin 的类继承时会发生什么:

img/330715_3_En_4_Figj_HTML.jpg

class A:
    def test(self):
        return 'A'

class B(A):
    pass

class C:
    def test(self):
        return 'C'

这很简单,但是如果你创建一个新的类,D,它同时继承了BC,如果你调用它的test() method,会发生什么呢?和往常一样,在交互式解释器中测试这一点很容易,你会发现答案取决于你先放哪一个。确保您在同一个会话中,并且键入了上述代码,然后尝试以下操作来查看结果:

img/330715_3_En_4_Figk_HTML.jpg

>>> class D(B, C):
...     pass
...
>>> D().test()
'A'
>>> class D(C, B):
...     pass
...
>>> D().test()
'C'

从表面上看,似乎很容易假设 Python 只是深度优先;它会查看第一个基类,并一直跟踪下去,寻找所请求的属性,只有在找不到所需的属性时,才会移动到下一个基类。这个观察对于这个和许多其他的例子来说当然是正确的,但是它仍然不是故事的全部。真正发生的事情会将整个继承方案考虑在内。

然而,在阐明完整的算法之前,让我们先弄清楚一件事。Python 关注的第一个名称空间总是实例对象。如果在那里找不到该属性,它将转到提供该对象行为的实际类。这两个名称空间总是首先被检查的两个名称空间,不管任何可能正在使用的继承结构。只有在没有找到属性的情况下,Python 才会尝试通过类继承来定位它。

Python 并没有把整个继承结构看作一棵树,而是试图把它展平成一个列表,每个类只出现一次。这是一个重要的区别,因为两个基类在链的更深处继承同一个类是可能的,但是查看那个类两次只会导致以后的混乱。为了解决这个和其他潜在的问题,需要一个单一的、平面的列表。

第一步是确定从一个类到它的最基本类的所有不同路径。因为两个原因,即使没有基类,也总会有至少一个路径。首先,给定类的 MRO 总是将类本身放在第一位。从前面的描述来看,这似乎是显而易见的,但是算法的其余部分将清楚地说明为什么明确地陈述这一点是重要的。此外,每个类都隐式继承自object,所以这是在每个 MRO 的末尾。

所以,对于一个简单的类,A,它不继承任何东西,它的 MRO 只是一个简单的两元素列表:[A, object]。如果你有另一个类,B,它的子类是A,它的 MRO 也变得相当明显,是[B, A, object]。一旦引入了一点多重继承,同一个类就有可能在整个树中出现不止一次,所以我们需要做一些额外的工作来整理 MRO。

考虑一个新的类,C,它继承自BA。现在A出现在两个不同的分支下,离新类C有两个不同的距离。

注意

这样做可能没有意义,因为B已经继承了A。但是,请记住,您可能并不总是预先知道基类在幕后做什么。你可以扩展从其他地方传入你的代码的类或者动态生成的类,就像本章后面将要展示的那样。Python 不知道你的类是如何布局的,所以它必须能够考虑所有的可能性。

img/330715_3_En_4_Figl_HTML.jpg

>>> class A:
...     pass
...
>>> class B(A):
...     pass
...
>>> class C(B, A):
...     pass
...

对象的 MRO 显然只是[object],正如你所料,A 已经显示为[A, object]B明明是[B, A, object],那C呢?首先看它的深度,一旦删除了重复的 A,你可能会猜到[C, B, A, object]。采用广度优先(先水平后垂直)的方法,您会想到[C, A, B, object]

那么 Python 到底走哪条路呢?事实是,这两种说法都不准确;Python 使用一种叫做 C3 的算法。这个算法考虑了所有的继承,一次减少一层,直到只剩下一个列表。在每个级别,C3 处理为该级别的所有父类别创建的类别列表。因此,它从最普通的类object开始,并从那里继续向外扩展。

有了C,我们终于可以详细了解算法是如何工作的了。到 Python 遇到C的时候,AB都已经被处理了,所以它们的 mro 是已知的。为了将它们结合起来,C3 查看每个父 MRO 中的第一个类,看它是否能找到一个候选项,以包含在C的 MRO 中。当然,这回避了一个问题,即什么才是有效的候选人。

用于识别候选类别的唯一标准是它是否仅存在于所考虑的任何 MRO 列表的第一位。不一定要全部都有,但是如果有,一定是列表中的第一个。如果它在任何列表中的任何其他位置,C3 将跳过它,直到它的下一次传递。一旦找到一个有效的条目,它就把它拉入新的 MRO,并使用相同的过程寻找下一个。

例如:C3 算法

因为算法实际上只是代码,所以让我们构建一个简单的 C3 函数来执行必要的线性化——将继承树简化为一个列表。然而,在深入完整的实现之前,让我们先看看函数调用是什么样子的,这样我们就知道它将处理什么数据。对于C,它看起来像这样:

C3(C, [B, A, object], [A, object], [B, A])

第一个参数是类本身,后面是其父类的已知 MRO 列表,按照它们在类中定义的顺序。然而,最后一个参数只是父类本身的列表,没有它们的完整 mro。正如稍后对C稍加修改后所显示的,这个额外的参数对于解决一些歧义是必要的。

与任何功能一样,在完成真正的繁重工作之前,有一些令人厌烦的细节需要落实到位。在 C3 的例子中,会有一些对 MRO 列表的修改,我们不希望这些修改影响到调用 C3 函数的代码,所以我们必须复制它们来使用。此外,我们需要建立一个新的列表来包含算法生成的最终 MRO:

img/330715_3_En_4_Figm_HTML.jpg

def C3(cls, *mro_lists):
    # Make a copy so we don't change existing content
    mro_lists = [list(mro_list[:]) for mro_list in mro_lists]

    # Set up the new MRO with the class itself
    mro = [cls]

    # The real algorithm goes here.

    return mro

我们不能在这里使用mro_list[:],因为它只是复制了外部列表。该列表中包含的所有其他列表都将保留,因此对它们的任何修改在函数外部都是可见的。通过使用列表理解并复制每个内部列表,我们得到了所有相关列表的副本,因此它们可以被安全地修改。

稳健性原则

如果你已经知道 Python 的复制模块——或者你已经跳到第六章了——你可能想知道为什么我们不用copy. d eepcopy(mro_list)来代替。至少,你可能想知道这个额外的列表(mro_list[:])是干什么用的,因为我们已经在传入列表了。通过将每个内部序列显式地转换为列表,并将其全部包装在一个列表理解中,我们可以允许函数接受任何有效的序列类型,包括元组,这些元组在创建后不能被修改(就像常量一样)。这使得 C3 函数在接受的内容上更加自由。

内务处理结束后,我们可以继续讨论主算法。因为我们事先不知道每个 MRO 中有多少个类,所以最好将主工作负载包装在一个简单的while True循环中,该循环将无限期执行,这样我们就可以使用breakcontinue来控制它的流程。当然,这意味着您不应该尝试执行这段代码,直到稍后,直到我们有了必要的控制代码。

该循环中的第一个任务是遍历每个 MRO 列表,获取它的第一个类,并查看它是否在任何其他列表中除了第一个之外的任何位置。如果是,那么这个类还不是一个有效的候选类,我们需要移动到下一个列表中的第一个类。下面是执行这些最初步骤所必需的循环:

img/330715_3_En_4_Fign_HTML.jpg

import itertools

def C3(cls, *mro_lists):
    # Make a copy so we don't change existing content
    mro_lists = [list(mro_list[:]) for mro_list in mro_lists]

    # Set up the new MRO with the class itself
    mro = [cls]

    while True:
        for mro_list in mro_lists:
            # Get the first item as a potential candidate for the MRO.
            candidate = mro_list[0]

            if candidate in itertools.chain(*(x[1:] for x in mro_lists)) :
                # The candidate was found in an invalid position, so we
                # move on to the next MRO list to get a new candidate.
                continue

    return mro

这里使用的链将所有 MRO 列表中的所有非第一类减少到一个列表中,因此更容易测试当前候选项是否有效。当然,当前代码只在候选无效时才响应。如果在那个链中没有找到,它就是一个有效的候选,可以马上提升到最终的 MRO。

此外,我们需要从发现该候选人的 MRO 名单中删除该候选人,以及可能发现该候选人的任何其他名单。由于我们知道它只能是任何列表中的第一项,并且不会出现在这一轮已经处理过的任何列表中,所以这变得稍微容易了一点。因此,我们可以只查看每个剩余的候选项,并删除被提升的类。无论如何,这一次不应该为新的候选人处理其他 MRO 列表,所以我们还需要添加一个continue:

img/330715_3_En_4_Figo_HTML.jpg

    while True:
    # Reset for the next round of tests
        candidate_found = False

        for mro_list in mro_lists:
            if not len(mro_list):
                # Any empty lists are of no use to the algorithm.
                continue

            # Get the first item as a potential candidate for the MRO.
            candidate = mro_list[0]

            if candidate_found:
                # Candidates promoted to the MRO are no longer of use.
                if candidate in mro:
                    mro_list.pop(0)
                # Don't bother checking any more candidates if one was found.
                continue

            if candidate in itertools.chain(*(x[1:] for x in mro_lists)) :
                # The candidate was found in an invalid position, so we
                # move on to the next MRO list to get a new candidate.
                continue

            else:
                # The candidate is valid and should be promoted to the MRO.
                mro.append(candidate)
                mro_list.pop(0)
                candidate_found = True

注意

既然我们要从 MRO 列表中删除项目,我们还必须添加额外的代码来处理其中一个列表被完全清空的情况。因为空列表中没有任何有价值的东西,所以循环会继续到下一个。

现在候选选择已经完成,剩下的事情就是告诉算法它的工作什么时候完成,它应该退出循环。按照现在的情况,它会完全清空列表,但是会一直循环下去,永远不会返回新的 MRO。识别这种情况的关键是它确实会清空所有的列表。因此,我们可以检查剩余的 MRO 列表,看看是否还有任何类。如果没有,就完成了,可以结束循环:

img/330715_3_En_4_Figp_HTML.jpg

    while True:
        # Reset for the next round of tests
        candidate_found = False

        for mro_list in mro_lists:
            if not len(mro_list):
                # Any empty lists are of no use to the algorithm.
                continue

            # Get the first item as a potential candidate for the MRO.
            candidate = mro_list[0]

            if candidate_found:
                # Candidates promoted to the MRO are no longer of use.
                if candidate in mro:
                    mro_list.pop(0)
                # Don't bother checking any more candidates if one was found.
                continue

            if candidate in itertools.chain(*(x[1:] for x in mro_lists)) :
                # The candidate was found in an invalid position, so we
                # move on to the next MRO list to get a new candidate.
                continue

            else:
                # The candidate is valid and should be promoted to the MRO.
                mro.append(candidate)
                mro_list.pop(0)
                candidate_found = True

        if not sum(len(mro_list) for mro_list in mro_lists):
            # There are no MROs to cycle through, so we're all done.
            # note any() returns false if no items so it could replace sum(len)
            break

这个循环,在已经提到的 C3 函数中,可以成功地为任何有效的 Python 继承模式创建一个 MRO。回到前面提到的对C类的函数调用,我们会得到下面的结果。请注意,我们在这里使用的是字符串而不是实际的类,以便于说明。无论如何,C3 算法实际上与类无关;这只是为了消除可能包含重复项的层次结构:

>>> C3('C', ['B', 'A', 'object'], ['A', 'object'], ['B', 'A'])
['C', 'B', 'A', 'object']

这很好,但是还有另一个相关的情况需要注意:当CB之前继承A时会发生什么?人们会从逻辑上假设在A上找到的任何属性都会在B上的属性之前使用,即使B的 MRO 将B放在A之前。这将违反类继承中的一个重要的一致性:MRO 中项目的顺序应该在它未来的所有子类中保持。

这些子类可以在它们的 MRO 中添加新的物件,甚至可以将它们插入到基类 MRO 中的物件之间,但是所有相关的 mro 仍然应该保持原来的顺序。所以当做类似于C(A, B)的事情时,正确的结果实际上会与用户的期望不一致。

这就是为什么 C3 算法要求将基类本身添加到传入的 mro 列表中。如果没有它们,我们可以用这个新的结构调用 C3 算法,得到与原始排序相同的结果:

>>> C3('C', ['B', 'A', 'object'], ['A', 'object'])
['C', 'B', 'A', 'object']
>>> C3('C', ['A', 'object'], ['B', 'A', 'object'])
['C', 'B', 'A', 'object']

尽管看起来这两个人应该做不同的事情,但实际上他们最终会做同样的事情。然而,通过在末尾添加额外的类列表,C3 的行为发生了一点变化。第一个候选人是A,在B的 MRO 的第二个位置,所以本轮跳过A。下一个候选项是B,它在最后一个参数中添加的列表中,所以也被跳过。当检查最终列表时,再次跳过 A。

这意味着 C3 完成了一个完整的循环,而没有找到任何有效的候选,这就是它如何检测像C(A, B)这样的不适当的构造。如果没有有效的候选项,则不会从任何列表中删除任何项目,并且主循环将使用完全相同的数据再次运行。没有对无效情况的任何额外处理,我们当前的 Python 实现将简单地无限期继续下去。最好提出一个例外。然而,首先让我们通过用C(A, B)检查 Python 自身的行为来验证这个假设。假设您键入了前面的示例,请尝试以下操作:

img/330715_3_En_4_Figq_HTML.jpg

>>> class A:
...     pass
...
>>> class B(A):
...     pass
...
>>> class C(A, B):
...     pass
...
Traceback (most recent call last):
  ...
TypeError:  Cannot create a consistent method resolution
order (MRO)  for bases B, A

果然,Python 的类系统不允许这种构造,以迫使开发人员只创建有意义的类。在我们自己的 C3 类中复制这个功能是相当容易的,因为我们知道如何识别无效的情况。我们所要做的就是在循环结束时进行检查,看看是否找到了有效的候选对象。如果没有,我们可以提出一个TypeError:

img/330715_3_En_4_Figr_HTML.jpg

import itertools

def C3(cls, *mro_lists):
    # Make a copy so we don't change existing content
    mro_lists = [list(mro_list[:]) for mro_list in mro_lists]

    # Set up the new MRO with the class itself
    mro = [cls]

    while True:
        # Reset for the next round of tests
        candidate_found = False

        for mro_list in mro_lists:
            if not len(mro_list):
                # Any empty lists are of no use to the algorithm.
                continue

            # Get the first item as a potential candidate for the MRO.
            candidate = mro_list[0]

            if candidate_found:
                # Candidates promoted to the MRO are no longer of use.
                if candidate in mro:
                    mro_list.pop(0)
                # Don't bother checking any more candidates if one was found.
                continue

            if candidate in itertools.chain(*(x[1:] for x in mro_lists)) :
                # The candidate was found in an invalid position, so we
                # move on to the next MRO list to get a new candidate.
                continue

            else:
                # The candidate is valid and should be promoted to the MRO.
                mro.append(candidate)
                mro_list.pop(0)
                candidate_found = True

        if not sum(len(mro_list) for mro_list in mro_lists):
            # There are no MROs to cycle through, so we're all done.
            break

        if not candidate_found:
            # No valid candidate was available, so we have to bail out.
            break
            raise TypeError("Inconsistent MRO")

    return mro

有了这最后一部分,我们的 C3 实现与 Python 自身的行为相匹配,涵盖了所有的基础。大多数任意的类继承结构都可以简化为一个有效的 MRO,所以你通常不需要太担心算法是如何工作的。然而,类有一个特性——super() function—广泛依赖于 MRO。

使用 super()将控制传递给其他类

创建子类的一个最常见的原因是覆盖一些现有方法的行为。这可能像每次调用方法时记录日志一样简单,也可能像用不同的实现完全替换其行为一样复杂。在前一种情况下,您只是简单地调整现有的行为,能够直接使用原始的实现是非常有用的,这样您就不必为了做一些小的更改而重新发明轮子。

为了实现这一点,Python 提供了内置的super() function,这一点经常被误解。对super()的常见解释是,它允许您在子类的被覆盖方法中调用基类的方法。这种描述在一定程度上是可行的,但在更全面地解释它之前,让我们先看看它在简单情况下的表现,看看这到底意味着什么:

img/330715_3_En_4_Figs_HTML.jpg

class A(object):
    def afunction(self):
        print('afunction from Class A')
class B(A):
    def __init__(self):
        print('B is constructed!!!') # constructor for B
    def afunction(self):
        return super(B, self).afunction()
sample1=B()
print(sample1.afunction())

在这个简单的例子中,super()返回方法的基类。基于我们刚刚读到的内容,super()查看 MRO 中的下一个类,在本例中是类 a。注意,我们说“被覆盖”,因为我们有两个名为 afunction 的函数。

接下来,考虑一个应用,该应用需要创建一个字典,为任何还没有关联值的键自动返回None。这与defaultdict相当类似,但它不必每次都创建一个新值;它刚刚返回None:

img/330715_3_En_4_Figt_HTML.jpg

>>> class NoneDictionary(dict):
...     def __getitem__(self, name):
...         try:
...             return super(NoneDictionary, self). __getitem__(name)
...         except KeyError:
...             return None
...
>>> d = NoneDictionary()
>>> d['example']
>>> d['example'] = True
>>> d['example']
True

在进一步深入之前,重要的是要认识到super()在这里真正在做什么。在一些语言中,super()仅仅是一种语言特性,被编译成一些特殊的代码来访问其他类的方法。然而,在 Python 中,super()返回一个实际的对象,该对象具有一组基于其使用位置的属性和方法。

从这个简单的例子来看,super()似乎只是提供了对基类上的一个方法的访问,但是请记住,可以包含任意数量的基类,在每个类上指定不止一个。鉴于某些继承结构的复杂性,现在应该很清楚 Python 将使用 MRO 来决定使用哪种方法。然而,可能不明显的是在查找方法时使用哪个 MRO。

仅仅看一下,你可能会认为 Python 使用了使用了super()的类的 MRO,在这里给出的例子中是NoneDictionary。因为大多数情况看起来非常像那个例子,所以这个假设足够准确,可以解释大多数情况。然而,更复杂的类层次结构提出了一个问题,当子类中的 MRO 发生变化时会发生什么。考虑下面的一组类;但是,开始一个新的 Python 会话,因为这些类定义与我们的第一个示例略有不同:

img/330715_3_En_4_Figu_HTML.jpg

>>> class A:
...     def test(self):
...         return 'A'
...
>>> class B(A):
...     def test(self):
...         return 'B->' + super(B, self). test()
...
>>> B().test()
'B->A'

在这个例子中,在B中使用super()指的是它的基类A,正如所料。它的test()方法包含一个对自身的引用,所以如果事情发生变化,我们将能够一路看到。除了B,我们可以定义另一个类C,它也是subclasses A。为了更好地说明未来的事情,C将实现它自己的test()方法,而不使用super():

img/330715_3_En_4_Figv_HTML.jpg

>>> class C(A):
...     def test(self):
...         return 'C'
...
>>> C().test()
'C'

当然,到目前为止,这没有什么不寻常或有问题的,因为它没有以任何方式与AB交互。当我们创建一个新类D时,事情变得有趣起来,这个新类又包含了BC两个子类。它不需要一个test() method,所以我们只是把它的主体留白,让它尽可能的简单。现在让我们看看test()会发生什么:

img/330715_3_En_4_Figw_HTML.jpg

>>> class D(B, C):
...     pass
...
>>> D().test()
'B->C'

现在我们终于可以看到发生了什么。我们可以看到test()B上被调用,导致其在输出中引用,但是当它调用super().test()时,引用的是C的方法,而不是A上的方法。如果 Python 只是使用了定义方法的类的 MRO,它将引用A,而不是C。相反,因为它使用了C,我们可以深入了解super()的实际工作原理。

在最常见的情况下,包括这里显示的用法,super()接受两个参数:一个类和该类的一个实例。正如我们的示例所示,实例对象决定了哪个 MRO 将用于解析结果对象的任何属性。所提供的类确定了 MRO 的子集,因为super()只使用 MRO 中出现在所提供的类之后的那些条目。

推荐的用法是提供将super()用作第一个参数的类,将标准 self 用作第二个参数。产生的对象将保留self的实例namespace字典,但是它只检索在 MRO 中比提供的类更晚的类上定义的属性。然而,从技术上讲,您可以传入不同的类并得到不同的结果:

img/330715_3_En_4_Figx_HTML.jpg

>>> class B(A):
...     def test(self):
...         return 'B->' + super(C, self). test()
...
>>> class D(B, C):
...     pass
...
>>> D().test()
'B->A'

在这个例子中,当B在对super()的调用中实际引用了C时,产生的 MRO 跳过C,直接移动到A,这通过再次调用test()来显示。然而,在通常的实践中,这是一件危险的事情,正如试图单独使用B时所示:

img/330715_3_En_4_Figy_HTML.jpg

>>> B().test()
Traceback (most recent call last):
  ...
TypeError:  super(type, obj):  obj must be an instance or subtype of type

因为在这种情况下 self 不是C的子类,C不在 MRO 的任何地方,所以super()不能确定它应该从哪里开始寻找属性。与其创建一个无用的对象,只为每件事抛出一个AttributeErrorsuper()在第一次被调用时会失败,提供一个更好的错误消息。

警告:小心你的论点

使用super()时一个常见的错误是在一个方法上使用它,这个方法在所有不同的类中不会总是有相同的签名。在我们这里的例子中,test()方法不接受任何参数,所以很容易确保它是一致的。许多其他情况,比如前面提到的__getitem__(),都是标准协议,它们的函数签名不应该被任何子类显著改变。第五章更详细地展示了这些案例。

不幸的是,你不能总是知道另一个类会做什么,所以使用 super()有时会给给定的类提供错误的参数,从而导致问题。当然,这与传入一个与另一个函数所期望的协议不同的对象并没有什么不同。

使用 super()值得注意的原因是,很容易假设您知道实际调用的是什么函数。如果没有对 mro 如何工作以及super()如何决定使用哪些属性的深刻理解,问题似乎会突然出现。然而,即使对这些主题有透彻的了解,对这类问题唯一真正的防御是所有涉及的类之间达成不改变方法签名的协议。

反省

考虑到所有可用的不同继承选项,Python 提供了许多工具来识别一个类使用什么结构是合适的。与类一起使用的最明显的自省任务是确定一个对象是否是给定类的实例。这个行为是使用内置的isinstance() function提供的,它将任意对象作为第一个参数,将一个 Python 类作为第二个参数。只有当给定的类在对象类的继承链中的任何位置时,才会isinstance() return True:

img/330715_3_En_4_Figz_HTML.jpg

>>> isinstance(10, int)
True
>>> isinstance('test', tuple)
False

isinstance()的一个自然补充是确定一个类在其继承链中是否有另一个类的能力。这个特性是由内置的subclass() function提供的,工作方式和isinstance()一样,除了它操作的是一个类而不是它的一个实例。如果第一个类在其继承链中包含第二个类,issubclass()返回True:

img/330715_3_En_4_Figaa_HTML.jpg

>>> issubclass(int, object)
True
>>> class A:
...     pass
...
>>> class B(A):
...     pass
...
>>> issubclass(B, A)
True
>>> issubclass(B, B)
True

最后一个例子可能看起来很奇怪,因为B显然不能成为自身的子类,但是这种行为是为了与isinstance()保持一致,如果所提供的对象的类型与所提供的类完全相同,那么它将返回True。简而言之,这两者之间的关系可以用一个简单的表达式来描述,这个表达式总是正确的:

isinstance(obj, cls) == issubclass(type(obj), cls)

如果您想了解特定类的继承结构的更多信息,有一些不同的工具可供您使用。如果您想知道为一个特定的类定义了哪些基类,只需访问它的__bases__属性,该属性将在一个元组中包含这些基类。但是,它只提供了直接基类,而没有任何比它扩展得更深的类:

img/330715_3_En_4_Figab_HTML.jpg

>>> B.__bases__
(<class '__main__.A'>,)

另一方面,每个类也有一个__subclasses__()方法,它返回你正在处理的类的所有子类的列表。像__bases__一样,这与你正在处理的类只差一级。任何进一步的子类都需要使用一些其他机制来跟踪子类,其中一些将在本书的后面讨论:

img/330715_3_En_4_Figac_HTML.jpg

>>> A.__subclasses__()
[<class '__main__.B'>]

如果您想要更多的信息和控制,每个类还有一个__mro__属性,它在一个元组中包含该类的完整 MRO。如前所述,这还包括您传入的实际类及其任何父类。您甚至可以在第一个例子中尝试使用前面使用的super():

img/330715_3_En_4_Figad_HTML.jpg

>>> B.__mro__
(<class '__main__.B'>, <class '__main__.A'>, <class 'object'>)

如何创建类

在 Python 中定义一个类的工作方式与在许多其他语言中不同,尽管差异并不总是显而易见的。这看起来很简单:你提供一个名字,可能是一个继承的基类,一些属性和一些方法。但是当 Python 遇到这种声明时,所发生的过程实际上与函数有着比您可能意识到的更多的共同点。

首先,类声明的主体是一个代码块。就像iffor,while一样,一个类块的主体可以包含任何有效的 Python 代码,这些代码将自顶向下执行。它将跟随函数调用,执行错误处理,读取文件,或者你要求它做的任何事情。事实上,if块在类声明中非常有用:

img/330715_3_En_4_Figae_HTML.jpg

>>> try:
...     import custom_library
... except ImportError:
...     custom_library = None
...
>>> class Custom:
...     if custom_library is not None:
...         has_library = True
...     else:
...         has_library = False
...
>>> Custom.has_library
False

小费

此示例仅用于演示目的。如果您希望获得这里显示的确切效果,那么简单地将表达式custom_library is not None直接分配给has_library属性要实用得多。不管怎样,它都会返回一个布尔值,所以最终结果是相同的,但这是一种更常见的处理手头任务的方法。

Python 执行完内部代码后,您会注意到has_library变成了类对象的一个属性,可供代码的其余部分使用。这是可能的,因为 Python 的类声明有点像函数。当发现一个新类时,Python 首先为其中的代码块创建一个新的名称空间。执行代码块时,任何赋值都是在新的命名空间中进行的。然后,创建的名称空间用于填充一个新的对象,该对象实现了新的类。

在运行时创建类

前一节提到了 Python 在执行代码、编译和解释时创建类型objects的事实。与运行时发生的几乎所有其他事情一样,您可以自己挂钩到该流程中,并利用它来获得优势。这样做利用了 Python 在遇到类时在幕后所做的事情。

真正重要的事情发生在类的内容被处理之后。此时,Python 获取类名称空间,并将其与其他一些信息一起传递给内置的type(),后者创建或“实例化”新的类对象。这意味着所有的类实际上都是type()的子类,它是所有类的基础。具体来说,type()使用三条信息来实例化一个类:

  • 声明的类的名称

  • 定义的类应该继承的基类

  • 执行类主体时填充的命名空间字典

这些信息是表示整个类所必需的,即使 Python 通过检查类声明自动获得这些信息,您也可以通过直接传入这些值来创建类型。

名字是最简单的,因为它只是一个带有类名的字符串。基类稍微复杂一些,但是它们仍然相当简单:只需提供一个包含新类应该继承的现有类对象的序列。名称空间字典就是这样:一个字典,恰好包含了应该通过名称附加到新类的所有内容。下面的例子说明了如何用两种不同的方法创建同一个类:

img/330715_3_En_4_Figaf_HTML.jpg

>>> class Example(int):
...     spam = 'eggs'
...
>>> Example
<class '__main__.Example'>

>>> Example = type('Example', (int,), {'spam': 'eggs'})

>>> Example
<class '__main__.Example'>

不要重复你自己

你会注意到这个例子最后不得不写名字Example两次,这似乎违反了 DRY 原则。但是,请记住,这里实际上有两件事情在进行,而且这两件事情并不相互关联。首先,正在创建类,这需要我们提供一个名称。其次,新类被绑定到名称空间中的一个名称。

这个示例对两个操作使用了相同的名称,部分是为了方便,部分是为了与上面的本机类声明兼容。然而,命名空间的分配与类的创建是完全分开的,因此可以使用任何名称。事实上,大多数时候你甚至不知道这个类的名字,所以在实践中你总是会使用不同的名字。

像大多数时候一样,您对一个通用特性type()的访问权限很低,这给了您很多制造问题的机会。type()的三个参数之一是要创建的类的名称,因此可以用相同的名称创建多个类。

此外,通过传递属性名称空间,您可以提供一个新的__module__属性来模拟它在不同模块中的存在。它实际上不会把类放在指定的模块中,但是它会欺骗任何稍后自省该模块的代码。拥有两个具有相同名称和模块的类可能会导致自省模块以确定其结构和层次的工具出现问题。

当然,即使不直接使用type()也有可能遇到这些问题。如果您创建一个类,给它指定一个不同的名称,然后创建一个与原始类同名的新类,您可能会遇到完全相同的命名问题。此外,Python 允许您在标准的类声明中提供一个__module__属性,因此您甚至可以在不受您控制的代码中创建冲突。

尽管不直接求助于type()也有可能遇到这些问题,但这里的警告是type()使得意外遇到问题变得更加容易。如果没有它,您将不得不编写专门利用上述要点的代码,以便产生命名冲突。然而,使用type(),提供的值可能来自用户输入、定制设置或任何其他地方,代码看起来不会有任何这种性质的问题。

不幸的是,对于这类问题没有真正的保障措施,但是你可以做一些事情来帮助降低风险。一种方法是将所有自定义类的创建封装在一个函数中,该函数跟踪哪些名称已被赋值,并在创建副本时做出适当的反应。一个更实用的选择是简单地确保任何自省代码能够处理遇到重复的情况。使用哪种方法将取决于代码的需求。

元类

到目前为止,类被定义为由内置类型处理,内置类型接受类名、基类和名称空间字典。但是type就像其他任何东西一样只是一个类;它的特殊之处在于它是一个用于创建类的类——一个metaclass。但是,像任何其他类一样,它可以被子类化,以便为我们的应用提供定制的行为。因为 Python 一遇到元类就接收完整的类声明,所以您可以解锁一些非常强大的特性。

通过子类化type,你可以创建自己的元类,它可以定制新类的创建,以更好地满足你的应用的需求。像任何基于类的定制一样,这是通过创建一个类型的子类并覆盖任何对手边的任务有意义的方法来完成的。在大多数情况下,这不是__new__()就是__init__()。本章后面的“魔术方法”一节将解释这两者之间的区别,但是对于这次讨论,我们将只使用__init__(),因为它更容易使用。

如前所述,type()有三个参数,所有这些参数都必须包含在子类中。从简单开始,考虑下面的元类,它打印出它遇到的每个类的名称:

img/330715_3_En_4_Figag_HTML.jpg

>>> class SimpleMetaclass(type):
...     def __init__(cls, name, bases, attrs):
...         print(name)
...         super(SimpleMetaclass, cls).__init__(name, bases, attrs)
...

仅此一点就足以捕获一个类声明。在这里使用super()确保任何其他必要的初始化也发生。即使type在它自己的__init__()中不做任何事情,记住在本章的前面,这个类可能是一个更大的继承结构的一部分。使用super()确保类被正确初始化,而不管在给定的上下文中“正确”的真正含义是什么。

为了将这个元类应用于一个新类并打印出它的名称,Python 允许类定义在父类旁边指定一个元类。它看起来像一个关键字参数,但这不是一个函数调用,所以它实际上是类声明语法的一部分。下面是我们的SimpleMetaclass如何工作的一个例子:

img/330715_3_En_4_Figah_HTML.jpg

>>> class Example(metaclass=SimpleMetaclass):
...     pass
...
>>> Example

这里所需要的只是在类定义中提供元类,Python 自动将该定义发送给元类进行处理。这与标准类定义的唯一区别是它使用了SimpleMetaclass而不是standard类型。

注意

元类上的__init__()方法的第一个参数通常称为 cls,尽管您可能认为它应该是 self,因为__init__()操作的是实例对象,而不是类。一般都是这样,这个案例其实也不例外。这里唯一的区别是实例本身是一个类对象,它是type的一个实例,所以使用 self 仍然是准确的。然而,由于类和对象之间的差异,我们仍然称类对象为cls,而不是self,所以它们保持很好的分离。

如果没有真实世界的例子来说明它们的用处,元类可能很难理解。让我们看看如何使用一个简单的元类来为注册和使用插件提供一个强大的框架。

示例:插件框架

随着应用的增长,灵活性变得越来越重要,所以注意力经常转向插件以及应用是否能适应这种模块化水平。实现插件系统和单个插件的方法有很多,但它们都有三个共同的核心特性。

首先,你需要一种方法来定义插件可以使用的地方。为了插上东西,需要有一个插座供插头插入。另外,一路上如何实现单个插件应该是非常明显的。最后,框架需要提供一个简单的方法来访问所有找到的插件,这样它们都可以被使用。其他功能可能会添加到上面,但这些是插件框架。

有几种方法可以满足这些需求,但是因为插件实际上是一种扩展形式,所以让它们扩展基类是有意义的。这使得第一个需求定义起来相当简单:插件可以连接自己的点是一个类。作为一个类,它利用了 Python 自身的扩展特性,不仅通过内置的子类语法,还通过允许基类提供一些方法来构成默认功能或为常见的插件需求提供帮助。下面是这样一个插件挂载点如何寻找一个验证用户输入的应用:

img/330715_3_En_4_Figai_HTML.jpg

class InputValidator:
    """
    A plugin mount for input validation.

    Supported plugins must provide a validate(self, input) method, which receives
    input as a string and raises a ValueError if the input was invalid. If the
    input was properly valid, it should just return without error. Any return
    value will be ignored.
    """

    def validate(self, input):
        # The default implementation raises a NotImplementedError
        # to ensure that any subclasses must override this method.
        raise NotImplementedError

即使没有任何使插件工作的框架级代码,这个例子也展示了可扩展系统的一个最重要的方面:文档。只有正确地记录插件安装,你才能期望插件作者正确地遵守它的期望。插件框架本身并不假设你的应用会有什么需求,所以由你来记录它们。

有了挂载点,只需编写已经存在的挂载点的子类,就可以很容易地创建单独的插件。通过提供新的或重写的方法来满足文档化的需求,他们可以将自己的一小部分功能添加到整个应用中。下面是一个示例验证器,它确保提供的输入只包含 ASCII 字符:

img/330715_3_En_4_Figaj_HTML.jpg

class ASCIIValidator(InputValidator):
    """
    Validate that the input only consists of valid ASCII characters.

    >>> v = ASCIIValidator()
    >>> v.validate('sombrero')
    >>> v.validate('jalapeño')
    Traceback (most recent call last):
      ...
    UnicodeDecodeError: 'ascii' codec can't decode character '\xf1' in position
    6: ordinal not in range(128)
    """

    def validate(self, input):
        # If the encoding operation fails, str.enc  ode() raises a
        # UnicodeDecodeError, which is a subclass of ValueError.
        input.encode('ascii')

小费

请注意,它还提供了自己的文档。因为插件本身也是类,它们可以被更专业的插件子类化。这使得即使在这一级也包含完整的文档非常重要,以帮助确保以后的正确使用。

既然我们已经解决了三个组件中的两个,那么在把它们结合在一起之前,剩下的唯一一件事就是演示如何访问任何已定义的插件。因为我们的代码已经知道了插件的挂载点,这就为访问它们提供了一个显而易见的位置,而且由于插件的数量可能从零到数百个不等,所以最好是对它们进行迭代,而不用关心有多少个插件。下面是一个示例函数,它使用任何和所有可用的插件来确定用户提供的某些输入是否有效:

img/330715_3_En_4_Figak_HTML.jpg

def is_valid(input):
    for plugin in InputValidator.plugins:
        try:
            plugin().validate(input)
        except ValueError:
            # A ValueError means invalidate input
            return False
    # All validators succeeded
    return True

拥有插件意味着你可以扩展这样一个简单函数的功能,而不需要再修改它的代码。简单地添加一个新插件,确保它被导入,剩下的工作由框架来完成。至此,我们终于开始解释这个框架,以及它是如何将所有这些部分联系在一起的。因为我们使用的类的定义不仅仅指定了它们的行为,所以元类将是一种理想的技术。

元类真正需要做的是识别插件挂载类和插件子类之间的区别,并在插件挂载列表中注册任何插件,以后可以在那里访问它们。如果这听起来太简单,那真的不是。事实上,整个框架可以用几行代码来表达,并且只需要插件挂载上的一行额外代码就可以激活整个框架:

img/330715_3_En_4_Figal_HTML.jpg

class PluginMount(type):
    """
    Place this metaclass on any standard Python class to turn it into a plugin
    mount point. All subclasses will be automatically registered as plugins.
    """
    def __init__(cls, name, bases, attrs):
        if not hasattr(cls, 'plugins'):
            # The class has no plugins list, so it must be a mount point,
            # so we add one for plugins to be registered in later.
            cls.plugins = []
        else:
            # Since the plugins attribute already exists, this is an
            # individual plugin, and it needs to be registered.
            cls.plugins.append(cls)

这就是提供整个插件框架所需的全部内容。当元类在插件挂载上被激活时,__init__()方法识别出 plugins 属性还不存在,所以它创建一个并返回,不做任何其他事情。当遇到插件子类时,plugins属性由于其父类而可用,因此元类将新类添加到现有列表中,从而注册它以备后用。

将这个功能添加到前面描述的inputValidator挂载点就像将元类添加到它的类定义中一样简单。

class InputValidator(metaclass=PluginMount):
    ...

单个插件仍然被定义为标准插件,不需要额外的努力。因为元类由所有子类继承,所以插件行为是自动添加的。

控制命名空间

元类也可以用来帮助控制 Python 如何处理类声明。另一种策略是在 Python 处理类的原始组件时处理它们,而不是等待类被创建后再处理。这是通过一个叫做__prepare__()的特殊元类实现的。

通过在元类上提供一个__prepare__()方法,您可以提前访问类声明。事实上,这发生得太早了,以至于类定义的主体还没有被处理。__prepare__()方法只接收类名及其基类的元组。__prepare__()负责返回字典本身,而不是获取名称空间字典作为参数。

当 Python 执行类定义的主体时,__prepare__()返回的字典被用作名称空间。这允许您在将每个属性分配给类时立即截取它,这样就可以立即处理它。通常,这用于返回一个有序字典,这样属性可以按照它们在类中声明的顺序存储。作为参考,看看元类如何在不使用__prepare__()的情况下工作:

img/330715_3_En_4_Figam_HTML.jpg

>>> from collections import OrderedDict
>>> class OrderedMeta(type):
...     def __init__(cls, name, bases, attrs):
...         print(attrs)
...
>>> class Example(metaclass=OrderedMeta):
...     b = 1
...     a = 2
...     c = 3
...
{'a': 2, '__module__': '__main__', 'b': 1, 'c': 3}

默认行为返回一个标准字典,它不跟踪键是如何添加的。添加一个简单的__prepare__()方法提供了在类被处理后保持顺序不变所需的一切:

img/330715_3_En_4_Figan_HTML.jpg

>>> class OrderedMeta(type):
...     @classmethod
...     def __prepare__(cls, name, bases):
...         return OrderedDict()
...     def __init__(cls, name, bases, attrs):
...         print(attrs)
...
>>> class Example(metaclass=OrderedMeta):
...     b = 1
...     a = 2
...     c = 3
...
OrderedDict([('__module__', '__main__'), ('B', 1), ('A', 2), ('c', 3)])

注意

属性位于属性列表的开头,因为它是在调用了__prepare__()之后,Python 开始处理类的主体之前添加的。

权力越大,责任越大

通过控制用于名称空间字典的对象,您可以对整个类声明的行为进行大量的控制。每当类中的一行引用一个变量或分配一个属性时,定制的名称空间可以调解并改变标准行为。一种可能性是提供在类中定义方法时可以使用的 decorators,而不需要单独的导入来使它们对类定义可用。同样,您可以通过更改属性的名称、将属性包装在辅助对象中或将其从名称空间中完全删除来控制属性的分配方式。

这种力量和灵活性很容易被滥用来提供一种在别处看不到的魔力。对于一个仅仅使用你的代码而没有完全理解它是如何实现的开发人员来说,这看起来就像 Python 本身是非常不一致的。更糟糕的是,您对类声明的行为所做的任何重大更改都可能会影响您的用户可能尝试与您的用户结合使用的其他工具的行为。第五章展示了如何通过扩展字典来启用这些特性,但是在这么做的时候要非常小心。

属性

一旦实例化了一个对象,与之相关的任何数据都保存在一个新的特定于该实例的名称空间字典中。对这个字典的访问由属性处理,这比使用字典键更容易访问。就像字典键一样,可以根据需要检索、设置和删除属性值。

通常,访问属性需要您事先知道属性的名称。在提供变量而不是文字方面,属性的语法不像字典键那样灵活,所以如果您需要用来自其他地方的名称来获取或设置属性,它看起来可能会受到限制。Python 没有提供以这种方式处理属性的特殊语法,而是提供了三个函数。

第一个是getattr(),在给定包含属性名称的变量的情况下,检索属性引用的值。下一个是setattr(),它接受属性的名称和它的值,并将该值附加到具有给定名称的属性上。最后,delattr()允许你删除一个给定名字作为参数的属性值。使用这些函数,您可以在编写代码时处理任何对象的任何属性,而无需知道属性名。

性能

属性不仅仅充当标准名称空间字典的代理,它还允许属性由能够访问 Python 全部功能的方法提供动力。通常,属性是使用内置的@property装饰函数定义的。应用于方法时,它会强制在将函数名作为属性名访问时调用该方法:

img/330715_3_En_4_Figao_HTML.jpg

>>> class Person:
...     def __init__(self, first_name, last_name):
...         self.first_name = first_name
...         self.last_name = last_name
...     @property
...     def name(self):
...         return '%s, %s' % (self.last_name, self.first_name)
...
>>> p = Person('Marty', 'Alchin')
>>> p.name
'Alchin, Marty'
>>> p.name = 'Alchin, Martin'  # Update it to be properly legal
Traceback (most recent call last):
  ...
AttributeError: can't set attribute

最后一个错误不是很好描述,但是基本上以这种方式定义的属性只检索属性值,而不是设置它们。函数调用只是一种方式,所以要设置值,我们需要添加另一种方法来处理这方面的事情。这个新方法将接受另一个变量:应该在属性上设置的值。

为了将新方法标记为属性的 setter,它被装饰得很像 getter 属性。不过,getter 没有使用内置的装饰器,而是获得了一个可以用来装饰新方法的setter属性。这符合 decorators 典型的基于名词的命名约定,同时也描述了哪个属性将被管理:

img/330715_3_En_4_Figap_HTML.jpg

>>> class Person:
...     def __init__(self, first_name, last_name):
...         self.first_name = first_name
...         self.last_name = last_name
...     @property
...     def name(self):
...         return '%s, %s' % (self.last_name, self.first_name)

...     @name.setter

...     def name(self, value):

...         return '%s, %s' % (self.last_name, self.first_name)

...
>>> p = Person('Marty', 'Alchin')
>>> p.name
'Alchin, Marty'
>>> p.name = 'Alchin, Martin'  # Update it to be properly legal

>>> p.name

'Alchin, Martin'

只要确保 setter 方法的名称与原始 getter 方法的名称相同,否则它将无法正常工作。原因是name.setter实际上并没有用 setter 方法更新原始属性。相反,它将 getter 复制到新的属性上,并将它们赋给 setter 方法的名称。这在幕后的确切含义将在下一节描述符中更好地解释。

除了获取和设置值,属性还可以使用类似于 setter 的装饰器删除当前值。通过将name.deleter应用到一个只接受通常自我的方法,您可以使用该方法从属性中删除值。对于这里显示的Person类,这意味着同时清除first_namelast_name:

img/330715_3_En_4_Figaq_HTML.jpg

>>> class Person:
...     def __init__(self, first_name, last_name):
...         self.first_name = first_name
...         self.last_name = last_name
...     @property
...     def name(self):
...         return '%s, %s' % (self.last_name, self.first_name)
...     @name.setter
...     def name(self, value):
...         return '%s, %s' % (self.last_name, self.first_name)

...     @name.deleter

...     def name(self):

...         del self.first_name

...         del self.last_name

...
>>> p = Person('Marty', 'Alchin')
>>> p.name
'Alchin, Marty'
>>> p.name = 'Alchin, Martin' # Update it to be properly legal
>>> p.name
'Alchin, Martin'

>>> del p.name

>>> p.name

Traceback (most recent call last):
  ...
AttributeError: 'Person' object has no attribute 'last_name'

描述符

属性的一个潜在问题是,它们要求所有方法都被定义为类定义的一部分。如果您自己能够控制类,那么为类添加功能是非常好的,但是当构建一个包含在其他代码中的框架时,我们将需要另一种方法。描述符允许你定义一个对象,这个对象的行为方式可以和它所赋给的任何类上的属性一样。

事实上,属性是作为描述符在幕后实现的,方法也是如此,这将在下一节中解释。这使得描述符可能是高级类行为的最基本的方面之一。它们通过实现三种可能的方法来处理获取、设置和删除值。

第一个是__get__(),管理属性值的检索,但是与属性不同,描述符可以管理对类及其实例的属性访问。为了识别差异,__get__()接收对象实例和它的所有者类作为参数。所有者类将总是被提供,但是如果描述符是直接在类上而不是在实例上被访问的,那么实例参数将是None

仅使用__get__()方法的简单描述符可用于在请求时始终提供最新值。一个明显的例子是返回当前日期和时间的对象,而不需要单独的方法调用:

img/330715_3_En_4_Figar_HTML.jpg

>>> import datetime
>>> class CurrentTime:
...     def __get__(self, instance, owner):
...         return datetime.datetime.now()
...
>>> class Example:
...     time = CurrentTime()
...
>>> Example().time
datetime.datetime(2009, 10, 31, 21, 27, 5, 236000)
>>> import time
>>> time.sleep(5 * 60) # Wait five minutes
>>> Example().time
datetime.datetime(2009, 10, 31, 21, 32, 15, 375000)

相关的__set__()方法管理在描述符管理的属性上设置一个值。与__get__()不同,该操作只能在实例对象上执行。如果您为类的给定名称赋值,实际上会用新值覆盖描述符,从类中删除它的所有功能。这是有意的,因为没有它,一旦描述符被分配给一个类,就没有办法修改或删除它。

因为不需要接受 owner 类,__set__()只接收实例对象和被赋值的值。不过,仍然可以通过访问所提供的实例对象上的__class__属性来确定该类,因此不会丢失任何信息。在描述符上定义了__get__()__set__()之后,我们可以做一些更有用的事情。例如,下面是一个基本描述符,它的行为就像一个属性,只是它会在每次值发生变化时进行记录:

img/330715_3_En_4_Figas_HTML.jpg

>>> import datetime
>>> class LoggedAttribute:
...     def __init__(self):
...         self.log = []
...         self.value_map = {}
...     def __set__(self, instance, value):
...         self.value_map[instance] = value
...         log_value = (datetime.datetime.now(), instance, value)
...         self.log.append(log_value)
...     def __get__(self, instance, owner):
...         if not instance:
...             return self # This way, the log is accessible
...         return self.value_map[instance]
...
>>> class Example:
...     value = LoggedAttribute()
...
>>> e = Example()
>>> e.value = 'testing'
>>> e.value
'testing'
>>> Example.value.log
[(datetime.datetime(2009, 10, 31, 21, 49, 59, 933000), <__main__.Example object a
t 0x...>, 'testing')]

在继续之前,这里有一些重要的事情需要注意。首先,当在描述符上设置一个值时,__set__()使用实例作为键,将它添加到自身的字典中。这样做的原因是描述符对象在它所附加的类的所有实例之间共享。如果您将该值设置为描述符自身的值,那么该值也将在所有这些实例之间共享。

注意

使用字典只是确保实例得到处理的一种方式,但这不是最好的方式。这里使用它是因为首选的方法是直接分配给实例的名称空间字典,这只是在您知道属性名称后的一个选项。描述符本身不能访问这个名称,所以这里使用字典。第十一章展示了一种基于元类解决这个问题的方法。

另外,请注意,如果没有传入实例,__get__()将返回 self。因为描述符基于设置值工作,所以当在类上调用它时,它没有额外的贡献值。大多数时候,当描述符处于这种情况时,更有意义的做法是抛出一个AttributeError来防止用户尝试一些没有意义的东西。在这里这样做意味着值日志永远不可用,所以描述符返回它自己。

除了获取和设置值,描述符还可以从属性或属性本身删除值。__delete__()方法管理这个行为,因为它只对实例起作用,不关心值,所以它接收实例对象作为它唯一的参数。

除了管理属性,描述符还用于实现面向对象编程的一个最重要的方面:方法。

方法

当一个函数被定义在一个类中时,它被认为是一个方法。尽管它总体上仍然像函数一样工作,但它有可用的类信息,因为函数实际上也是描述符。然而,在方法类别中,有两种截然不同的类型:绑定方法和未绑定方法。

未绑定方法

因为可以从类及其实例中访问描述符,所以也可以从这两者中访问方法。当访问一个类的函数时,它成为一个未绑定的方法。描述符接收类,但是方法通常需要实例,所以在没有实例的情况下访问时,它们被称为未绑定的。

将其称为非绑定方法实际上更像是一种命名约定,而不是任何正式声明。当访问类的方法时,您得到的只是函数对象本身:

img/330715_3_En_4_Figat_HTML.jpg

>>> class Example:
...     def method(self):
...         return 'done!'
...
>>> type(Example.method)
<class 'function'>
>>> Example.method
<function method at 0x...>

# self isn't passed automatically

>>> Example.method()
Traceback (most recent call last):
  ...
TypeError: method() takes exactly 1 position argument (0 given)

它仍然是可调用的,就像任何其他标准函数一样,但是它也携带关于它附加到什么类的信息。注意,未绑定方法中的self参数不是自动传递的,因为没有实例对象可以绑定到它。

绑定方法

一旦类被实例化,每个方法描述符返回一个绑定到该实例的函数。它仍然由相同的函数支持,并且原始的未绑定方法仍然在该类上可用,但是绑定方法现在自动接收实例对象作为它的第一个参数:

img/330715_3_En_4_Figau_HTML.jpg

>>> ex = Example()
>>> type(ex.method)
<class 'method'>
>>> ex.method
<bound method Example.method of <__main__.Example object at 0x...>>

# self gets passed automatically now

>>> ex.method()
'done!'

# And the underlying function is still the same

>>> Example.method is ex.method.__func__
True

# is and == have related yet different functionality and == could have replaced is in this instance, yet since is checks to see if two arguments refer to the same object versus == checks to see if two object have same value, is works better for our needs.

如您所见,绑定方法仍然由与未绑定方法相同的函数支持。唯一真正的区别是绑定方法有一个实例作为第一个参数接收。同样重要的是要认识到实例对象是作为位置参数传递的,所以参数名不需要为self才能正常工作,但是这是一个已经建立的标准,您应该尽可能地遵循。

小费

因为绑定方法接受实例作为第一个参数,所以可以通过显式提供实例作为未绑定方法的第一个参数来伪造方法绑定。对于该方法来说,这一切看起来都是一样的,并且在将函数作为回调传递时,这可能是一种有用的方法。

然而,有时方法不需要访问实例对象,不管类是否已经被实例化。这些方法分为两种不同的类型。

类方法

当一个方法只需要访问它所附加的类时,它被认为是一个类方法,Python 通过使用内置的@classmethod decorator 来支持它。这确保了该方法将总是接收类对象作为它的第一个位置参数,而不管它是作为类的属性还是它的实例之一被调用:

img/330715_3_En_4_Figav_HTML.jpg

>>> class Example:
...     @classmethod
...     def method(cls):
...         return cls
...
>>> Example.method()
<class __main__.Example at 0x...>
>>> Example().method()
<class __main__.Example at 0x...>

一旦应用了@classmethoddecorator——参见本章后面关于 decorator 的信息——method() method将永远不会收到一个Example的实例作为它的第一个参数,而将永远是类本身或它的一个子类。cls参数总是用来调用方法的类,而不仅仅是定义方法的类。

尽管从前面的例子中可能看不清楚,但类方法实际上是绑定的实例方法,就像前面几节中描述的那些方法一样。因为所有类实际上都是内置类型的实例,所以类方法被绑定到类本身:

img/330715_3_En_4_Figaw_HTML.jpg

>>> Example.method
<bound method type.method of <class '__main__.Example'>>

类方法也可以用另一种稍微间接的方式创建。因为所有的类实际上都只是元类的实例,所以可以在元类上定义一个方法。然后,所有实例类都可以将该方法作为标准绑定方法进行访问。不需要使用@classmethod装饰器,因为该方法已经使用前面描述的标准行为绑定到了类。它是这样工作的:

img/330715_3_En_4_Figax_HTML.jpg

>>> class ExampleMeta(type):
...     def method(cls):
...         return cls
...
>>> class Example(metaclass=ExampleMeta):
...     pass
...
>>> Example.method
<bound method ExampleMeta.method of <class '__main__.Example'>>
>>> Example.method()
<class __main__.Example at 0x...>

以这种方式构造的方法的实际行为在大多数方面与常规的类方法相同,因为它们在内部是以相同的方式构建的。它们可以从类本身调用,而不需要实例,并且它们总是接收类对象作为隐式的第一个参数。然而,不同之处在于类方法仍然可以从实例中调用,而绑定类方法只能从类本身调用。

这种行为的原因是该方法是在元类名称空间中定义的,这只能将其放在该元类实例的 MRO 中。所有引用该元类的类都可以访问该方法,但实际上并不在它们的定义中。用@classmethod修饰的方法被直接放在定义它们的类的名称空间中,这使得它们对于该类的实例也是可用的。

尽管这种可见性上的差异看起来像是基于元类的类方法只是标准修饰类方法的一个低级版本,但是有两个原因可以解释为什么它们对应用有益。首先,类方法通常被期望作为类的属性来调用,很少从实例对象中调用。这不是一个通用的规则,当然也不足以证明元类本身的使用,但这是值得注意的。

也许更重要的是,许多已经使用元类的应用还需要向使用该元类的任何类添加类方法。在这种情况下,只在现有元类上定义方法是有意义的,而不是使用单独的类来保存类方法。当额外的类本身没有任何有价值的东西可添加时,这尤其有用;如果元类是重要的部分,最好把所有东西都放在那里。

静态方法

有时,即使是类也比方法完成工作所需的信息多。静态方法就是这种情况,实现静态方法通常是为了给本来可以在模块级实现的函数建立一个名称空间。使用staticmethod装饰器,该方法在任何时候都不会接收任何隐式参数:

img/330715_3_En_4_Figay_HTML.jpg

>>> class Example:
...     @staticmethod
...     def method():
...         print('static!')
...
>>> Example.method
<function method at 0x...>
>>> Example.method()
static!

如您所见,静态方法看起来一点也不像方法。它们只是碰巧出现在一个类中的标准函数。下一节将展示如何利用 Python 的动态特性在实例上实现类似的效果。

将函数分配给类和实例

Python 允许简单地通过分配一个新值来覆盖大多数属性,这为方法提供了一个有趣的机会:

img/330715_3_En_4_Figaz_HTML.jpg

>>> def dynamic(obj):
...     return obj
...
>>> Example.method = dynamic
>>> Example.method()
Traceback (most recent call last):
  ...
TypeError: dynamic() takes exactly 1 positional argument (0 given)
>>> ex = Example()
>>> ex.method()
<__main__.Example object at 0x...>

请注意,分配给该类的函数仍然需要编写成接受一个实例作为它的第一个参数。一旦赋值,它就像一个常规的实例方法一样工作,所以参数需求根本不会改变。赋值给实例在语法上类似,但是因为函数永远不会被赋值给类,所以根本不涉及绑定。直接分配给实例属性的函数就像附加到类的静态方法一样工作:

img/330715_3_En_4_Figba_HTML.jpg

>>> def dynamic():
...     print('dynamic!')
...
>>> ex.method = dynamic
>>> ex.method()
dynamic!
>>> ex.method
<function dynamic at 0x...>

神奇的方法

Python 中的对象可以用许多不同的方式来创建、操作和销毁,并且大多数可用的行为可以通过在您自己的定制类上实现一些额外的方法来修改。一些更特殊的定制可以在第五章中找到,但是有一些特殊的方法对于所有类型的类都是通用的。这些方法可以根据它们处理的类的方面来分类,因此下面的章节将分别介绍一些不同的方法。

创建实例

从类到对象的转换称为实例化。实例只不过是对提供行为和名称空间字典的类的引用,该字典对于正在创建的实例是唯一的。当创建一个新对象而不覆盖任何特殊方法时,实例名称空间只是一个空字典,等待数据。

因此,大多数类实现的第一个方法是__init__(),目的是用一些有用的值初始化名称空间。有时这些只是占位符,直到更多有趣的数据到来,而在其他时候,有趣的数据以参数的形式直接进入方法。发生这种情况是因为传递给类实例化的任何参数都会一直传递给__init__():

img/330715_3_En_4_Figbb_HTML.jpg

>>> class Example:
...     def __init__(self):
...         self.initialized = True
...
>>> e = Example()
>>> e.initialized = True
>>> class Example2:
...     def __init__(self, name, value=“):
...         self.name = name
...         self.value = value
...
>>> e = Example2()
Traceback (most recent call last):
  ...
TypeError: __init__() takes at least 2 positional arguments (1 given)
>>> e = Example2('testing')
>>> e.name
'testing'
>>> e.value
"

像任何 Python 函数一样,您可以在__init__()中自由地做任何您想做的事情,但是请记住,这只是为了初始化对象,仅此而已。一旦__init__()完成执行,对象应该准备好用于更实际的目的,但是任何超出基本设置的事情应该被推迟到其他更明确的方法。

当然,初始化的真正定义可能对不同的对象有不同的含义。对于大多数对象,您只需要将一些属性设置为一些默认值或传递给__init__()的值,如前面的例子所示。其他时候,这些初始值可能需要计算,比如将不同的时间单位转换成秒,所以一切都是正常的。

在一些不太常见的情况下,初始化可能包括更复杂的任务,如数据验证、文件检索,甚至网络流量。例如,一个使用 web 服务的类可能将一个 API 令牌作为它对__init__()的唯一参数。然后,它可能会调用 web 服务,将该令牌转换为经过身份验证的会话,这将允许进行其他操作。所有其他操作都需要单独的方法调用,但是所有这些操作背后的认证可能发生在__init__()中。

__init__()中做得太多的主要问题是,除了文档之外,没有任何迹象表明发生了任何事情。不幸的是,无论你如何努力,有些用户就是不愿意阅读你的文档;他们可能仍然期望初始化是一个简单的操作,例如,如果他们没有有效的网络连接,他们可能会惊讶地看到错误。关于解决这个问题的一种方法,请参见下一节中的示例。

尽管__init__()可能是所有方法中最广为人知的魔法方法,但它并不是创建新对象时第一个被执行的方法。毕竟,记住__init__()是关于初始化一个对象,而不是创建它。对于后者,Python 提供了__new__()方法,该方法获得大部分相同的参数,但负责在初始化之前实际创建新对象。

与典型的实例对象self不同,__new__()的第一个参数实际上是被创建对象的类。这使它看起来很像一个类方法,但是你不需要使用任何装饰器来使它这样工作——这是 Python 中的一个特例。然而,从技术上讲,它是一个静态方法,所以如果你试图直接调用它,你总是需要提供类;它永远不会被隐式发送,就像它是一个真正的类方法一样。

在类参数之后——通常命名为 cls,就像常规的类方法一样——__new__()方法接收所有与__init__()相同的参数。当试图创建对象时,你传递给类的任何东西都将被传递给__new__()来帮助定义它。这些参数在根据当前需求定制新对象时通常很有用。

这通常不同于初始化,因为__new__()通常用于改变被创建对象的本质,而不仅仅是设置一些初始值。举例来说,考虑一个例子,其中对象的类可以根据创建它时传入的值而改变。

示例:自动子类

有些库由大量不同的类组成,其中大多数共享一组公共数据,但可能具有不同的行为或其他数据定制。这通常需要库的用户跟踪所有不同的类,并确定他们的数据的哪些特征对应于适当的类。

相反,提供一个用户可以实例化的类会更有帮助,该类实际上返回一个对象,该对象可以是不同的类,这取决于参数。使用__new__()定制新对象的创建,这可以相当简单地实现。确切的行为将取决于手边的应用,但是基本的技术很容易用一个通用的例子来说明。

考虑一个类,每当它被实例化为一个对象时,它随机选择一个子类。当然,这不是最实际的用途,但是它说明了这个过程是如何工作的。使用random.choice()从使用__subclasses__()得到的值中选择,然后实例化它找到的子类,而不是定义的子类:

img/330715_3_En_4_Figbc_HTML.jpg

>>> import random
>>> class Example:
...     def __new__(cls, *args, **kwargs):
...         cls = random.choice(cls.__subclasses__())
...         return super(Example, cls).__new__(cls, *args, **kwargs)
...
>>> class Spam(Example):
...     pass
...
>>> class Eggs(Example):
...     pass
...
>>> Example()
<__main__.Eggs object at 0x...>
>>> Example()
<__main__.Eggs object at 0x...>
>>> Example()
<__main__.Spam object at 0x...>
>>> Example()
<__main__.Eggs object at 0x...>
>>> Example()
<__main__.Spam object at 0x...>
>>> Example()
<__main__.Spam object at 0x...>

在另一个真实的例子中,您可以将文件的内容传递给一个单独的File类,并让它自动实例化一个子类,该子类的属性和方法是为所提供的文件格式构建的。这对于大类文件(如音乐或图像)尤其有用,这些文件表面上在大多数方面表现相似,但却有可以抽象掉的潜在差异。

处理属性

使用一个对象时,最常见的需求之一是与其属性进行交互。一般来说,这很简单,只要给定属性的名称,比如instance.attribute,就可以直接分配和访问属性。在少数情况下,这种类型的访问本身是不够的,因此您需要更多的控制。

如果您在编写应用时不知道属性的名称,那么如果您使用内置的getattr()函数,您可以为名称提供一个变量。例如,instance.attribute将变成getattr(instanceattribute_name),其中attribute_name的值可以从任何地方提供,只要它是一个字符串。

这种方法只处理给定一个字符串形式的名称,并且需要查找该名称引用的实例属性的情况。另一方面,你也可以告诉一个类如何处理它没有明确管理的属性。这种行为由__getattr__()方法控制。

如果您定义了这个方法,Python 将在您请求尚未定义的属性时调用它。它接收所请求的属性的名称,因此您的类可以决定应该如何处理它。一个常见的例子是字典,它允许您按属性检索值,而不只是使用标准的字典语法:

img/330715_3_En_4_Figbd_HTML.jpg

>>> class AttributeDict(dict):
...     def __getattr__(self, name):
...         return self[name]
...
>>> d = AttributeDict(spam='eggs')
>>> d['spam']
'eggs'
>>> d.spam
'eggs'

注意

__getattr__()的一个不太明显的特性是,它只被实际上不存在的属性调用。如果您直接设置属性,引用该属性将检索它而不调用__getattr__()。如果你需要捕捉每个属性,使用__getattribute__()代替。它采用与__getattr__()相同的参数和函数,除了即使属性已经在实例上,它也会被调用。

当然,如果属性是只读的,那么允许属性访问的字典就没什么用了。为了使画面完整,我们还应该支持在属性中存储值。除了这个简单的字典示例之外,当您为一个属性设置值时,还需要定制发生的事情。正如所料,Python 以__setattr__()方法的形式提供了一个并行。

这个新方法需要一个额外的参数,因为还有一个值需要管理。通过定义__setattr__(),您可以截取这些赋值,并根据您的应用的需要来处理它们。将这个应用到AttributeDict就像前面的例子一样简单:

img/330715_3_En_4_Figbe_HTML.jpg

>>> class AttributeDict(dict):
...     def __getattr__(self, name):
...         return self[name]

...     def __setattr__(self, name, value):

...         self[name] = value

...
>>> d = AttributeDict(spam='eggs')
>>> d['spam']
'eggs'
>>> d.spam
'eggs'
>>> d.spam = 'ham'

>>> d.spam

'ham'

小费

就像getattr()提供用变量代替硬编码名称来访问属性一样,Python 提供了setattr()来设置属性。它的参数与__setattr__()的参数相匹配,因为它接受对象、属性名和值。

尽管这看起来像是属性访问的完整画面,但仍然缺少一个组件。当您不再需要某个属性,并希望将其从对象中完全移除时,Python 提供了del语句。然而,当您使用由这些特殊方法管理的假属性时,del本身不起作用。

为了处理这种情况,Python 会挂钩到__delattr__()方法(如果有的话)。因为这个值不再相关,所以这个方法只接受属性名和标准的self。将它添加到现有的AttributeDict很容易:

img/330715_3_En_4_Figbf_HTML.jpg

>>> class AttributeDict(dict):
...     def __getattr__(self, name):
...         return self[name]
...     def __setattr__(self, name, value):
...         self[name] = value

...     def __delattr__(self, name):

...         del self[name]

...
>>> d = AttributeDict(spam='eggs')
>>> d['spam']
'eggs'
>>> d.spam
'eggs'
>>> d.spam = 'ham'
>>> d.spam
'ham'

>>> del d.spam

>>> d.spam

Traceback (most recent call last):

  ...

KeyError: 'spam'

警告:引发正确的异常

该错误消息提出了使用这些类型的被覆盖属性的重要一点。很容易忽略异常在函数内部是如何处理的,所以你最终可能会抛出一个没有任何意义的异常;如果一个属性不存在,你会合理地期望看到一个AttributeError,而不是一个KeyError

这似乎是一个随意的细节,但是请记住,大多数代码都显式地捕捉特定类型的异常,因此,如果您引发了错误的类型,您可能会导致其他代码采取错误的路径。因此,当遇到相当于丢失属性的东西时,一定要确保显式地抛出AttributeError。根据假属性的作用,它可能是一个KeyErrorIOError,或者甚至是一个UnicodeDecodeError

这将在本书和现实世界中的不同地方出现。第五章涵盖了各种协议,在这些协议中,获得正确的异常和参数一样重要。

字符串表示

在 Python 中所有可能的不同对象类型中,最常见的无疑是字符串。从读写文件到与 web 服务交互和打印文档,字符串支配着软件执行的许多方面。尽管我们的大部分数据以其他形式存在,但迟早大部分数据会被转换成字符串。

为了使这个过程尽可能简单,Python 提供了一个额外的钩子来将对象转换成它的字符串表示。当在一个类上实现时,__str__()方法允许使用内置的str() function将它的实例转换成一个字符串,当使用print()或字符串格式化时也会用到这个方法。关于这些特性和更多的细节可以在第七章中找到,但是现在,看看__str__()在一个简单的类中是如何工作的:

img/330715_3_En_4_Figbg_HTML.jpg

# First, without __str__()

>>> class Book:
...     def __init__(self, title):
...         self.title = title
...
>>> Book('Pro Python')
<__main__.Book object at 0x...>
>>> str(Book('Pro Python'))
'<__main__.Book object at 0x...>'

# And again, this time with __str__()

>>> class Book:
...     def __init__(self, title):
...         self.title = title

...     def __str__(self):

...         return self.title

...
>>> Book('Pro Python')
<__main__.Book object at 0x...>
>>> str(Book('Pro Python'))

'Pro Python'

添加的__str__()允许该类指定在将对象表示为字符串时应该显示对象的哪些方面。在这个例子中,它是一本书的标题,但是它也可以是一个人的名字、地理位置的纬度和经度,或者在一组同类对象中简洁地标识该对象的任何其他东西。它不必包含关于对象的所有内容,但需要足够的内容来区分不同的对象。

还要注意,当交互式解释器中的表达式不包含对str()的调用时,它不使用由__str__()返回的值。相反,解释器使用对象的不同表示,这是为了更准确地表示对象的代码性质。对于自定义类来说,这种表示是相当无用的,只显示了对象的类的名称和模块以及它在内存中的地址。

然而,对于其他类型,您会注意到这些表示在确定对象是什么时非常有用。事实上,这种表示的理想目标是呈现一个字符串,如果将该字符串键入控制台,将会重新创建该对象。这对于了解交互式控制台中的对象非常有用:

img/330715_3_En_4_Figbh_HTML.jpg

>>> dict(spam='eggs')
{'spam': 'eggs'}
>>> list(range(5))
[0, 1, 2, 3, 4]
>>> set(range(5))
{0, 1, 2, 3, 4}

>>> import datetime
>>> datetime.date.today()
datetime.date(2009, 10, 31)
>>> datetime.time(12 + 6, 30)
datetime.time(18, 30)

这种替代表示由__repr__()方法控制,主要用于这种情况,描述交互控制台中的对象。当在解释器中单独引用一个对象时,它被自动触发,有时在日志应用中使用,在那里__str__()通常不能提供足够的细节。

对于列表和字典这样的内置对象,表示是一个文字表达式,可以很容易地再现对象。对于其他不包含太多数据的简单对象,日期和时间示例表明,只需提供一个实例化调用就可以了。当然,datetime必须首先被导入,但是它完成了工作。

如果对象表示的数据太多,无法压缩成这样一个简单的表示,那么下一个最好的方法是提供一个用尖括号括起来的字符串,它以更合理的细节描述对象。这通常是显示类名和一些标识类名的数据。对于Book示例,它在现实世界中有更多的属性,看起来可能是这样的:

img/330715_3_En_4_Figbi_HTML.jpg

>>> class Book:
...     def __init__(self, title, author=None):
...         self.title = title
...         self.author = author
...     def __str__(self):
...         return self.title
...     def __repr__(self):
...         return '<%s by %s>' % (self.title, self.author or '<Unknown Author>')
...
>>> Book('Pro Python', author='Marty Alchin')
<Book: Pro Python by Marty Alchin>
>>> str(Book('Pro Python', author='Marty Alchin'))
'Pro Python'

令人兴奋的 Python 扩展:迭代器

迭代器是可以被迭代的对象;换句话说,你可以说它是一个“可迭代”或“可循环”的项目。列表、元组和字符串是可迭代的;它们容纳不止一个项目,因此是可迭代的容器。Python 中有两个迭代器对象。第一个是序列迭代器,适用于任意序列。第二个对象遍历可调用对象项,并带有结束该过程的 sentinel 值。让我们看看他们的行动,以便更好地理解这一点。

一个非常简单的例子是增强的 for 循环 ,它遍历容器中的所有条目(你必须有不止一个)。请考虑以下几点:

my_string=('Hello Python!')
for item in my_string:
            print(item)
my_list=[1,2,3,4]
for item in my_list:
            print (item, end=' ')
#Note newline after printing is replaced with space
print()
my_tuple='Fred','Wilma', 1, 3
for item in my_tuple:
            print (item)

现在,如果在 Python 脚本所在的同一文件夹中有一个文本文件,例如一个包含数据的 CSV 文件,您可以执行如下操作:

for the_line in open("file.csv”):
                   print (the_line)

使用 Python 迭代器,您还可以组合结构来增强功能。一定要保持可读性。请注意,我们正在遍历一个字符串,并对字母“b”的实例进行计数

#Combine control structures
my_string=('ababaaaabbbbaaaabb')
counter=0
for character in [char for char in my_string if char == 'b']:
            counter +=1
print('There were ', counter, ' letter b')

另一个例子可能是凯撒密码加密:

#Secret message Ceasar cipher!
my_string = input('Type secret message:  ')
print (my_string)
new_string = ' '
z=input('How much to Ceasar shift by?  ')
for letter in my_string:
            x=ord(letter)
            t=x+int(z)
            print (chr(t),)

现在我们来看看迭代协议下一个 函数从第一项开始迭代,一直到最后一项,但是当它试图打印列表中没有的第四项时,返回一个 StopIteration 错误:

# Simple iteration over a list
simple_list = [1, 3, 2]
simple_iter = iter(simple_list)
counter = 1
while counter <=4:
          print(next(simple_iter))
          counter +=1

现在,您可以添加 try 和 except 来保持运行,但这显示了一般情况下的工作方式。花时间使用迭代器会有很好的回报。

带着它

对类的透彻理解为您的应用打开了一个可能性的世界,无论它们是简单的个人项目还是为大规模分发而构建的大型框架。除了所有这些自由之外,还有一组已建立的协议,允许您的类像 Python 中一些最著名的类型一样工作。

五、通用协议

大多数情况下,您会希望根据应用的需求来定义高度定制的对象。这通常意味着提出自己的接口和 API,这些接口和 API 是您自己的代码所独有的。做到这一点的灵活性对于任何系统的扩展能力都是必不可少的,但这是有代价的。你发明的任何新东西都必须被记录下来,并被需要使用它的人所理解。

理解如何使用一个框架提供的各种类对于该框架的用户来说可能是一件相当麻烦的事情,即使有适当的文档。减轻用户负担的一个好方法是模仿他们已经熟悉的界面。有许多现有的类型是 Python 编程中的标准问题,其中大多数都有可以在自定义类中实现的接口。

方法是实现现有接口的最显而易见的方式,但是对于许多内置类型,大多数操作都是使用本机 Python 语法而不是显式方法调用来执行的。自然,这些语法特性是由幕后的实际方法支持的,所以它们可以被覆盖以提供定制的行为。

以下部分展示了如何在定制代码中模拟 Python 中使用的一些最常见类型的接口。这绝不是 Python 附带的所有类型的详尽列表,也不是每个方法都被表示出来。相反,这一章是对那些不太明显的方法的参考,因为它们被语法糖掩盖了。

基本操作

尽管 Python 中有各种各样的对象类型,但大多数都有一组共同的操作。这些被认为是某种核心特性集,代表了对象操作的一些最常见的高级方面,其中许多都适用于简单的数字,就像它们适用于许多其他对象一样。

包括 Python 在内的所有编程中最简单也是最常见的需求之一是将一个表达式计算为布尔值,这样就可以用它来做出简单的决策。通常这用于if模块,但这些决定也在使用while和布尔运算如andor时发挥作用。当 Python 遇到其中一种情况时,它依赖于__bool__()方法的行为来确定对象的布尔等价物。

如果实现了__bool__()方法,它只接受通常的self,并且必须返回TrueFalse。这允许任何对象使用任何适当的方法或属性来确定它在给定的表达式中应该被认为是真还是假:

img/330715_3_En_5_Figa_HTML.jpg

>>> bool(0)
False
>>> bool(1)
True
>>> bool(5)
True

作为另一个例子,考虑表示矩形的类可能使用它的面积来确定该矩形被认为是真还是假。因此,__bool__()只需检查是否存在非零宽度和非零高度,因为bool 0为假,而任何其他正值,通常为1,为真。这里我们使用内置的bool(),它使用__bool__()将值转换为布尔值:

img/330715_3_En_5_Figb_HTML.jpg

>>> class Rectangle:
...     def __init__(self, width, height):
...         self.width = width
...         self.height = height
...     def __bool__(self):
...         if self.width and self.height:
...             return True
...         return False
...
>>> bool(Rectangle(10, 15))
True
>>> bool(Rectangle(0, 0))
False
>>> bool(Rectangle(0, 15))
False

小费

__bool__()方法并不是定制 Python 布尔行为的唯一方法。相反,如果一个对象提供了一个__len__()方法,这将在本章后面的序列一节中描述,Python 将退回到这个方法,并认为任何非零长度为真,而零长度为假。

考虑到对象的真实性,你会自动控制andornot等操作者的行为。因此,为了自定义这些运算符,不需要重写单独的方法。

除了能够确定对象的真实性,Python 还在其他操作中提供了很大的灵活性。特别是,标准的数学运算可以被覆盖,因为它们中的许多可以应用于除数字之外的各种对象。

数学运算

一些最早的数学形式源于对我们周围世界的观察。因此,我们在小学学到的大部分数学知识很容易应用到其他类型的物体上,就像应用到数字上一样。例如,加法可以被看作是简单地将两个东西放在一起(连接),例如将两个字符串连接在一起形成一个更长的字符串。

如果你只从数学上来看,你可以说你实际上只是把两个长度加在一起,得到一个更长的长度。但是当你看看刚刚发生了什么,你现在有了一个全新的弦,它不同于最初进入它的两根弦。

这种类比也很容易扩展到 Python 字符串,可以使用标准加法连接,而不需要单独的命名方法。类似地,如果你需要多次写出同一个字符串,你可以简单地把它乘以一个普通的数。这些类型的操作在 Python 中非常常见,因为它们是实现常见任务的简单方法:

img/330715_3_En_5_Figc_HTML.jpg

>>> 2 + 2
4
>>> 'two' + 'two'
'twotwo'
>>> 2 * 2
4
>>> 'two' * 2
'twotwo'

__bool__()一样,这些行为都是由自己特殊的方法控制的。他们中的大多数都相当直接,接受通常的self以及other论点。这些方法被绑定到操作符左侧的对象,附加的other是右侧的对象。

Python 中使用标准运算符+-*/来表示四种基本算术运算——加、减、乘、除。在幕后,前三个由__add__()__sub__()__mul__()方法的实现驱动。除法有点复杂,我们稍后会讲到,但是现在,让我们看看这个操作符重载是如何工作的。

考虑一个作为值的简单代理的类。这种东西在现实世界中没有多大用处,但它是解释一些事情的好起点:

img/330715_3_En_5_Figd_HTML.jpg

>>> class Example:
...     def __init__(self, value):
...         self.value = value
...     def __add__(self, other):
...         return self.value + other
...
>>> Example(10) + 20
30

这只是您的代码可以自定义的一些基本算术运算的一个例子。在本章的剩余部分,你会发现更高级的操作细节;表 5-1 列出了这些基本算术运算符。

表 5-1

基本算术运算符

|

操作

|

操作员

|

自定义方法

|
| --- | --- | --- |
| 添加 | + | __add__() |
| 减法 | - | __sub__() |
| 增加 | * | __mul__() |
| 分开 | / | __truediv__() |

这就是事情变得有趣的地方,因为你会注意到除法的方法并不像你想象的那样是__div__()。这是因为分裂有两种不同的味道。当你使用计算器时得到的那种除法在 Python 中被称为真除法,它使用__truediv__()方法,正如你所期望的那样工作。

然而,真除法是唯一可以接受两个整数并返回一个非整数的算术运算。在某些应用中,总是返回一个整数是有用的。例如,如果你用百分比显示一个应用的进度,你不需要显示完整的浮点数。

取而代之的是另一种操作,叫做楼层分区;你可能也听说过它被称为整数除法。如果真除法的结果落在两个整数之间,则底数除法将只返回两个整数中较小的一个,因此它总是返回一个整数。如您所料,Floor division 是通过一个单独的__floordiv__()实现的,并使用//操作符进行访问:

img/330715_3_En_5_Fige_HTML.jpg

>>> 5 / 4
1.25
>>> 5 // 4
1

还有一个模运算,和除法有关。在除法运算会产生余数的情况下,使用模运算会返回余数,所以模运算只返回除法运算的余数。这使用了使用__mod__()实现的%操作符。字符串使用它来执行标准变量解释,即使这与除法无关:

img/330715_3_En_5_Figf_HTML.jpg

>>> 20 // 6
3
>>> 20 % 6
2
>>> 'test%s' % 'ing'
'testing'

实际上,您可以使用取整除法和模运算来获得除法运算的整数结果及其余数,余数保留了关于结果的所有信息。这有时比真除法更可取,真除法只会产生一个浮点数。例如,考虑一个需要几分钟的函数,它必须返回包含小时数和分钟数的字符串:

img/330715_3_En_5_Figg_HTML.jpg

>>> def hours_and_minutes(minutes):
...     return minutes // 60, minutes % 60
...
>>> hours_and_minutes(60)
(1, 0)
>>> hours_and_minutes(137)
(2, 17)
>>> hours_and_minutes(42)
(0, 42)

事实上,这个基本任务非常常见,Python 为它提供了自己的函数:divmod()。通过传入一个基值和一个被它除的值,可以同时得到地板除法和模运算的结果。然而,Python 将尝试调用一个__divmod__()方法,而不是简单地分别委托给这两个方法,这使得定制实现更加高效。

作为更有效实现的替代,可以使用与hours_and_minutes()函数相同的技术来说明__divmod__()方法。我们所要做的就是接受第二个参数,以便将硬编码的 60 从方法中去掉:

img/330715_3_En_5_Figh_HTML.jpg

>>> class Example:
...     def __init__(self, value):
...         self.value = value
...     def __divmod__(self, divisor):
...         return self.value // divisor, self.value % divisor
...
>>> divmod(Example(20), 6)
(3, 2)

乘法还有一个扩展,叫做指数运算,一个值要乘以它自身的次数。鉴于它与乘法的关系,Python 使用双星号**符号来执行运算。它是使用__pow__()方法实现的,因为现实世界的数学通常称之为将一个值提升到某个其他值的幂:

img/330715_3_En_5_Figi_HTML.jpg

>>> class Example:
...     def __init__(self, value):
...         self.value = value
...     def __pow__(self, power):
...         val = 1
...         for x in range(power):
...             val *= self.value
...         return val
...
>>> Example(5) ** 3
125

与其他操作不同,取幂也可以通过另一种方式执行,即通过内置的pow()函数。之所以有不同的操作符,是因为它允许传递额外的参数。这个额外的参数是一个值,应该用于在执行取幂运算之后执行模运算。这种额外的行为允许以更有效的方式来执行诸如查找素数之类的任务,这在密码学中很常用:

img/330715_3_En_5_Figj_HTML.jpg

>>> 5 ** 3
125
>>> 125 % 50
25
>>> 5 ** 3 % 50
25
>>> pow(5, 3, 50)
25

为了用__pow__()方法支持这种行为,您可以选择接受一个额外的参数,它将用于执行模运算。为了支持普通的**操作符,这个新参数必须是可选的。没有合理的缺省值可以盲目使用而不会导致标准幂运算的问题,所以它应该缺省为None来决定是否应该执行模运算:

img/330715_3_En_5_Figk_HTML.jpg

>>> class Example:
...     def __init__(self, value):
...         self.value = value
...     def __pow__(self, power, modulo=None):
...         val = 1
...         for x in range(power):
...             val *= self.value
...         if modulo is not None:
...             val %= modulo
...         return val
...
>>> Example(5) ** 3
125
>>> Example(5) ** 3 % 50
25
>>> pow(Example(5), 3, 50)
25

警告

和前面显示的__divmod__()实现一样,这个例子并不是解决问题的非常有效的方法。它确实会产生正确的值,但它仅用于说明。

位运算

位运算用于处理二进制文件、加密、编码、硬件驱动程序和网络协议的情况。因此,它们通常与低级编程相关联;然而,它们肯定不是专门为该领域保留的。通过位操作,一组独立的操作不直接作用于数值,而是作用于一系列单独的位。在这个层次上,有几种不同的操作值的方法,它们不仅适用于数字,也适用于其他类型的序列。最简单的按位操作是移位,将一个值中的位向右或向左移动,产生一个新值。

在二进制算术中,将位左移一位会使值乘以二。这就像在十进制数学中一样:如果你将一个数字中的所有数字都向左移动,并用一个零填充右边的空白,你实际上是将这个值乘以 10。这种行为存在于任何基数上,但是计算机是以二进制工作的,所以移位操作也是如此。

分别使用<<>>运算符向左和向右移动。运算符的右边表示这些位应该移位多少位。在内部,这些操作由__lshift__()__rshift__()方法支持,每个方法都接受要移动的位置数作为其唯一的附加参数:

img/330715_3_En_5_Figl_HTML.jpg

>>> 10 << 1
20
>>> 10 >> 1
5

除了打乱位的顺序之外,还有一些操作将每个值中的位相互比较,从而产生一个新值,该值表示两个单独值的某种组合。这四种按位比较操作是&|^~,分别指与、或、异或(异或)和求反。

只有当被比较的两个位都为 1 时,AND 比较才返回 1。如果是其他任何组合,结果都是 0。此行为通常用于创建位掩码,您可以通过应用 AND 将所有不相关的值重置为 0,其中每个有用的位为 1,其余位为 0。这将清除掉您不感兴趣的任何位,便于与二进制标志集进行比较。在代码中支持这种行为需要有一个__and__()方法。

如果被比较的任一位为 1,OR 比较返回 1。如果两个都是 1 也没关系;只要其中至少有一个是 1,结果就会是 1。这通常用于将多组二进制标志连接在一起,以便运算符两边的所有标志都设置在结果中。支持此功能所需的方法是__or__()

标准 or 运算符有时被称为包含 OR,与其表亲异或形成对比,后者通常缩写为 XOR。在 XOR 运算中,只有当其中一位为 1 而另一位不为 1 时,结果才为 1。如果两位都是 1 或都是 0,结果将是 0。__xor__()方法支持异或运算。

最后,Python 还提供了逐位反转,每一位都翻转为与当前值相反的值;1 变成 0,反之亦然。从数字上看,这在负值和正值之间交换,但它不仅仅改变符号。下面是一个使用~运算符反转数字时的反应示例:

img/330715_3_En_5_Figm_HTML.jpg

>>> ~42
-43
>>> ~-256
255

这种行为基于计算机处理有符号值的方式。最高有效位用于确定值是正还是负,因此翻转该位会改变符号。反转后绝对值的变化是由于缺少–0。当 0 反转时,它变成–1 而不是–0,因此所有其他值在此之后也跟着反转。

在自定义代码中,当您拥有一组已知的所有可能值以及这些值的各个子集时,反转通常是最有用的。反转这些子集将删除任何现有的值,并用主集中不在子集中的任何值替换它们。

这种行为可以通过在对象上提供一个__invert__()方法来实现。然而,与其他按位方法不同,__invert__()是一元的,所以它不接受标准self之外的任何额外参数。

注意

此处描述的反转行为对使用二进制补码方法编码的数字有效,该方法用于处理有符号数字。如果自定义数字类提供了__invert__()方法,还有其他选项 1 可用,它们的行为可能与这里显示的不同。默认情况下,Python 只使用二进制补码编码方法。

变化

除了正常的操作行为之外,还有几种不同的方法可以访问它们。最明显的问题是,这些方法通常绑定到运算符左侧的值。如果你的自定义对象被放在右边,那么左边的值很有可能不知道如何使用它,所以你会得到一个TypeError而不是一个可用的值。

这种行为是可以理解的,但是很不幸,因为如果自定义对象知道如何与另一个值交互,那么不管它们的位置如何,它都应该能够这样做。考虑到这一点,Python 给运算符右侧的值一个返回有效值的机会。

当表达式的左边没有产生值时,Python 会检查右边的值是否属于同一类型。如果是的话,就没有理由期望它能比第一次做得更好,所以 Python 简单地抛出了TypeError。但是,如果是不同的类型,Python 将调用右边值的方法,将左边的值作为参数传入。

这个过程交换参数,将方法绑定到右边的值。对于一些运算,比如减法和除法,值的顺序很重要,所以 Python 使用不同的方法来表示顺序的变化。这些独立方法的名称大多与左边的方法相同,但在前两个下划线后添加了一个r:

img/330715_3_En_5_Fign_HTML.jpg

>>> class Example:
...     def __init__(self, value):
...         self.value = value
...     def __add__(self, other):
...         return self.value + other
...
>>> Example(20) + 10
30
>>> 10 + Example(20)
Traceback (most recent call last):
  ...
TypeError: unsupported operand type(s) for +: 'int' and 'Example'
>>> class Example:
...     def __init__(self, value):
...         self.value = value
...     def __add__(self, other):
...         return self.value + other

...     def __radd__(self, other):

...         return self.value + other

...
>>> Example(20) + 10
30
>>> 10 + Example(20)

30

小费

在这种情况下,值的顺序不影响结果,实际上你可以把左边的方法赋给右边的方法。只是要记住,并不是所有的操作都是这样的,所以你不能在没有确保它有意义的情况下,盲目地将方法复制到两边。

使用这些运算符的另一种常见方式是修改现有值,并将结果赋回初始值。正如在本章前面没有解释的情况下已经证明的那样,一种替代的转让形式适合于这些修改。只需将=附加到所需的运算符上,就可以将运算结果赋给左边的值:

img/330715_3_En_5_Figo_HTML.jpg

>>> value = 5
>>> value *= 3
>>> value
15

默认情况下,这种形式的扩充赋值使用标准运算符方法,其方式与本章前面所述的方式相同。但是,这需要在操作后创建一个新值,然后用它来重新绑定一个现有的值。相反,只要您能够识别这种赋值何时发生,就地修改值有时会更有利。

像右边的方法一样,就地操作符使用与标准操作符基本相同的方法名,但是这次在下划线后面加上了一个i。然而,这个操作没有右边的等价操作,因为赋值总是在左边的变量上完成。考虑到一切,表 5-2 列出了可用的操作符,以及定制它们的行为所需的方法。

表 5-2

可用运算符

|

操作

|

操作员

|

左侧

|

右侧

|

直列式

|
| --- | --- | --- | --- | --- |
| 添加 | + | __add__() | __radd__() | __iadd__() |
| 减法 | - | __sub__() | __rsub__() | __isub__() |
| 增加 | * | __mul__() | __rmul__() | __imul__() |
| 真正的分裂 | / | __truediv__() | __rtruediv__() | __itruediv__() |
| 楼层划分 | // | __floordiv__() | __rfloordiv__() | __ifloordiv__() |
| 以…为模 | % | __mod__() | __rmod__() | __imod__() |
| 除法和模数 | divmod() | __divmod__() | __rdivmod__() | 不适用的 |
| 指数运算 | ** | __pow__() | __rpow__() | __ipow__() |
| 左二进制移位 | << | __lshift__() | __rlshift__() | __ilshift__() |
| 右二进制移位 | >> | __rshift__() | __rrshift__() | __irshift__() |
| 按位 AND | & | __and__() | __rand__() | __iand__() |
| 按位或 | &#124; | __or__() | __ror__() | __ior__() |
| 按位异或 | ^ | __xor__() | __rxor__() | __ixor__() |
| 逐位反转 | ~ | invert() | 不适用的 | 不适用的 |

注意

没有除法和模运算的内嵌方法,因为它不能作为支持赋值的运算符。它只被称为divmod()方法,没有内联功能。此外,按位反转是一元运算,因此没有右侧或内嵌方法可用。

尽管这些操作主要集中在数字上,但它们中的许多也适用于其他类型的对象。然而,还有另一组行为,它们只对数字和可以像数字一样运行的对象有意义。

民数记

从本质上讲,所有的计算机都与数字有关,所以它们在大多数应用中扮演重要角色是很自然的。除了上一节概述的操作之外,数字还表现出许多不太明显的行为。

自定义数字最基本的行为是让 Python 相信它实际上是一个数字。当试图使用一个对象作为序列中的索引时,这是必要的。Python 要求所有索引都是整数,因此需要一种方法将对象强制转换为整数,以便用作索引。对于这个 Python 使用了一个__index__()方法,如果它不存在或者它返回一个非整数的值,就抛出一个TypeError:

img/330715_3_En_5_Figp_HTML.jpg

>>> sequence = [1, 2, 3, 4, 5]
>>> sequence[3.14]
Traceback (most recent call last):
  ...
TypeError: list indices must be integers, not float
>>> class FloatIndex(float):
...     def __index__(self):
...         # For the sake of example, return just the integer portion
...         return int(self)
...
>>> sequence[FloatIndex(3.14)]
4
>>> sequence[3]
4

除了简单的索引访问之外,__index__()还用于为了切片而强制输入一个整数,并使用内置的bin()hex()oct()函数为转换生成一个起始值。当希望在其他情况下显式强制一个整数时,可以使用内置的int()函数使用的__int__()方法。使用__float__()支持float()__complex__()进行complex()的其他类型转换。

将一个数字转换为另一个数字时,最常见的操作之一是舍入。不像int()那样盲目地截断非整数的值的任何部分,舍入对最终得到的值的类型和保留的精度提供了更多的控制。

当您将一个十进制数或浮点数传递给int()时,效果本质上只是一个地板运算。与前面提到的除法一样,floor 运算接受两个整数之间的一个数,并返回两个数中较小的一个。math模块包含一个执行该操作的floor()函数。

如您所料,这依赖于定制对象上的__floor__()方法来执行 floor 操作。除了通常的self之外,它不需要任何参数,应该总是返回一个整数。然而,Python 实际上并没有对返回值强制任何要求,所以如果你正在处理整数的某个子类,你可以返回其中的一个。

相比之下,你可能需要两者中较高的一个,这将是一个上限操作。这是使用math.ceil()完成的,并通过__ceil__()方法实现。像__floor__()一样,它不需要任何额外的参数,返回一个整数。

更有可能的是,您需要将一个值四舍五入到特定的位数。这是使用round()函数实现的,它是一个内置函数,而不是位于math模块中。它最多接受两个参数,并使用定制对象上的__round__()方法来实现。

round()的第一个参数是__round__()将要绑定的对象,所以它作为标准的self出现。然而,第二个论点有点微妙。小数点右边的位数应该被认为是有效的,因此保留在结果中。如果没有提供,round()应该假设这些数字都不重要,并返回一个整数:

img/330715_3_En_5_Figq_HTML.jpg

>>> round(3.14, 1)
3.1
>>> round(3.14)
3
>>> round(3.14, 0)
3.0
>>> import decimal
>>> round(decimal.Decimal('3.14'), 1)
Decimal('3.1')
>>> round(decimal.Decimal('3.14'))
3

如你所见,传递第二个参数 0 和根本不传递参数是有区别的。返回值本质上是一样的,但是当不传入时,应该总是得到一个整数。当传入一个 0 时,您将得到您传入的任何类型,但是只包括有效数字。

除了将小数点右边的数字四舍五入,round()还可以作用于另一边。通过传入一个负数,您可以指定小数点左边应该四舍五入的位数,而保留其他位数:

img/330715_3_En_5_Figr_HTML.jpg

>>> round(256, -1)
260
>>> round(512, -2)
500

签名操作

还有一个一元运算选项,可用于调整值的符号。第一个是-,对符号求反,在正值和负值之间交换。通过提供一个__neg__()方法可以定制这种行为,该方法不接受超过self的额外参数。

为了补充负号,Python 还支持正号,使用+。因为数字通常被假定为正数,所以这个操作符实际上并不自己做任何事情;它只是返回不变的数字。然而,如果一个定制对象需要一个附加的实际行为,一个__pos__()方法可以提供它。

最后,一个数也可以有绝对值,一般定义为它离零的距离。符号无关紧要,所有值都变成正值。因此,将abs()应用于数字会移除负号(如果存在的话),但保持正值不变。这个行为由一个__abs__()方法修改。

比较操作

至此所示的操作涉及至少部分基于一个或多个现有值返回修改后的值。相比之下,比较运算符根据两个值之间的关系返回TrueFalse

最基本的比较操作符isis not,直接作用于每个对象的内部标识。因为标识通常是作为对象在内存中的地址实现的,Python 代码不能更改它,所以没有办法覆盖这种行为。它的用途通常保留给与已知常数的比较,如None

可用的运算符代表标准的数值比较,它检测一个值是高于、低于还是完全等于另一个值。最通用的是使用==测试相等性。它的通用性来自于这样一个事实,即它不局限于数值,因为许多其他类型可以有被认为彼此相等的对象。这个行为由一个__eq__()方法控制。

不平等在 Python 中由!=操作符表示,其行为正如您所料。然而,您可能没有想到的是,这个功能与==没有任何联系。Python 不是简单地调用__eq__()并反转其结果,而是依靠一个单独的__ne__()方法来处理不等式测试。因此,如果你实现了__eq__(),也要记得提供__ne__(),以确保一切按预期运行。

此外,您可以使用<>比较一个值是小于还是大于另一个值,这两个值分别使用__lt__()__gt__()实现。等式也可以与这些相结合,例如,一个值可以大于或等于另一个值。这些操作使用<=>=,并由__lte__()__gte__()支持。

这些比较通常用于主要由数字表示的对象,即使对象本身远不止这些。日期和时间是容易比较的对象的显著例子,因为它们本质上都是一系列数字,如果需要,可以单独进行比较:

img/330715_3_En_5_Figs_HTML.jpg

>>> import datetime
>>> a = datetime.date(2019, 10, 31)
>>> b = datetime.date(2017, 1, 1)
>>> a == b
False
>>> a < b
True

关于比较,字符串是一个有趣的例子。尽管从明显的意义上来说,字符串不是数字,但字符串中的每个字符都是数字的另一种表示,所以字符串比较也是有效的。这些比较驱动了字符串的排序特性。

可重复的

序列似乎是显而易见的下一个选择,但是首先要考虑更通用的形式。如果一个对象可以一次产生一个对象,那么它被认为是可迭代的,通常是在一个for循环中。这个定义是有意简单的,因为在高层次上,iterables 并没有超出这个范围。然而,Python 对 iterables 有更具体的定义。

特别是,如果将一个对象传递给内置的iter()函数返回一个迭代器,那么这个对象就是可迭代的。在内部,iter()检查传入的对象,首先寻找一个__iter__()方法。如果找到了这样的方法,它将被不带任何参数地调用,并返回一个迭代器。如果__iter__()不可用,还会发生另一个步骤,但是现在,让我们关注迭代器。

尽管这个对象被认为是可迭代的,但是迭代器做了所有真正的工作,但是实际上没有那么多。对于__init__()方法应该是什么样子没有要求,因为它在它的主对象的__iter__()方法中被实例化。所需的接口只包含两个方法。

第一种方法,也许令人惊讶,是__iter__()。迭代器本身也应该总是可迭代的,所以它们必须提供一个__iter__()方法。不过,通常没有理由在这个方法中做任何特殊的事情,所以它通常被实现为只返回self。如果不在迭代器上提供__iter__(),主对象在大多数情况下仍然是可迭代的,但是一些代码希望迭代器也可以独立使用。

更重要的是,迭代器必须始终提供一个__next__()方法,所有真正的工作都在这里进行。Python 将调用__next__()从迭代器中检索下一个值,该值将在任何称为迭代器的代码体中使用。当代码需要一个新值时,通常是为了循环中的下一遍,它会再次调用__next__()来获得一个新值。这一过程一直持续到几件事情之一发生。

如果 Python 遇到了导致循环完成的任何事情,而迭代器仍然有它可以产生的项,那么迭代器只是等待其他代码请求另一项。如果这种情况永远不会发生,最终将不会有更多的代码知道迭代器,所以 Python 将从内存中删除它。第六章更详细地介绍了这个垃圾收集过程。

在一些不同的情况下,迭代器可能没有机会完成。最明显的是一个break语句,它将停止循环,然后继续。此外,returnraise语句将隐式地跳出它所属的任何循环,因此迭代器保持与break发生时相同的状态。

然而,更常见的情况是,循环会让迭代器一直运行,直到它不再产生任何项。使用生成器时,当函数返回而没有产生新值时,会自动处理这种情况。对于迭代器,这种行为必须显式提供。

因为None是一个完全有效的对象,可以合理地从迭代器中产生,Python 不能只对__next__()没有返回值做出反应。相反,StopIteration异常为__next__()提供了一种方式来表明不再有条目。当引发此问题时,循环被视为完成,并且在循环结束后的下一行继续执行。

为了说明所有这些是如何结合在一起的,让我们看一下内置的range()函数的行为。它不是一个生成器,因为你可以多次迭代它。为了提供类似的功能,我们需要返回一个 iterable 对象,然后可以根据需要迭代多次:

img/330715_3_En_5_Figt_HTML.jpg

class Range:
    def __init__(self, count):
        self.count = count

    def __iter__(self):
        return RangeIter(self.count)

class RangeIter:
    def __init__(self, count):
        self.count = count
        self.current = 0

    def __iter__(self):
        return self

    def __next__(self):
        value = self.current
        self.current += 1
        if self.current > self.count:
            raise StopIteration
        return value

>>> def range_gen(count):
...     for x in range(count):
...         yield x
...
>>> r = range_gen(5)
>>> list(r)
[0, 1, 2, 3, 4]
>>> list(r)
[]
>>> r = Range(5)
>>> list(r)
[0, 1, 2, 3, 4]
>>> list(r)
[0, 1, 2, 3, 4]

迭代器是实现 iterable 的最强大、最灵活的方式,所以它们通常是首选,但是还有另一种方法可以达到类似的效果。对象可迭代的原因是iter()返回一个迭代器,所以值得注意的是iter()支持某种特殊情况。

如果一个对象没有一个__iter__()方法,但是包含一个__getitem__()方法,Python 可以在一个特殊的迭代器中使用这个方法来处理这种情况。我们将在下一节讨论序列的更多细节,但基本思想是__getitem__()接受一个索引,并被期望在那个位置返回项目。

如果 Python 找到的是__getitem__()而不是__iter__(),它会自动创建一个迭代器来使用它。这个新的迭代器多次调用__getitem__(),每次调用一系列数字中的一个值,从零开始,直到__getitem__()产生一个IndexError。因此,我们的自定义Range iterable 可以非常简单地重写:

img/330715_3_En_5_Figu_HTML.jpg

class Range:
    def __init__(self, count):
        self.count = count

    def __getitem__(self, index):
        if index < self.count:
            return index
        raise IndexError

>>> r = Range(5)
>>> list(r)
[0, 1, 2, 3, 4]
>>> list(r)
[0, 1, 2, 3, 4]

注意

只有当__iter__()不存在时,Python 才会使用这个__getitem__()行为。如果两者都在一个类中提供,那么__iter__()方法将被用来控制迭代行为。

示例:可重复生成器

在显式可迭代的对象类型中,多次迭代一个对象的能力非常普遍,但是使用生成器通常更方便。如果您需要一个生成器,它可以在每次迭代器被访问时自动重启,看起来您要么错过了这个功能,要么添加了一堆不必要的代码,这些代码的存在只是为了允许正确的迭代。

相反,像许多其他行为一样,我们可以依靠 Python 的标准方法来扩充函数,并将其分解到装饰器中。当应用于生成器函数时,这个新的装饰器可以处理创建 iterable 所需的一切,每次请求新的迭代器时,iterable 从头触发生成器:

img/330715_3_En_5_Figv_HTML.jpg

def repeatable(generator):
    """
    A decorator to turn a generator into an object that can be
    iterated multiple times, restarting the generator each time.
    """
    class RepeatableGenerator:
        def __init__(self, *args, **kwargs):
            self.args = args
            self.kwargs = kwargs

        def __iter__(self):
            return iter(generator(*self.args, **self.kwargs))

    return RepeatableGenerator

>>> @repeatable
... def generator(max):
...     for x in range(max):
...         yield x
...
>>> g = generator(5)
>>> list(g)
[0, 1, 2, 3, 4]
>>> list(g)
[0, 1, 2, 3, 4]

通过创建一个可以在调用生成器函数时实例化的新类,它的__iter__()方法将代替生成器的方法被调用。这样,每次新的循环开始时,都可以从头开始调用生成器,产生新的序列,而不是试图从它停止的地方开始,这通常意味着返回一个空序列。

警告

尽管大多数生成器每次都返回相似的序列,并且可以放心地重新启动,但并不是所有的生成器都这样。如果一个生成器根据它被调用的时间改变它的输出,在随后的调用中从它停止的地方继续,或者产生副作用,这个装饰器是不推荐的。通过改变行为,每次都显式地重新启动装饰器,新的生成器可能会产生不可预知的结果。

然而,目前的代码有一个问题。@repeatable decorator 接收一个函数,但返回一个类,这在提供的例子中工作得很好,但有一些非常麻烦的含义。首先,记住第三章中的包装函数有新的属性,这个问题可以用@functools.wraps装饰器来解决。

然而,在我们考虑使用另一个装饰器之前,我们必须解决一个更大的问题:我们正在返回一个与原始函数完全不同的类型。通过返回一个类而不是一个函数,我们会给任何期望它是一个函数的代码带来问题,包括其他装饰器。更糟糕的是,返回的类不能作为方法使用,因为它没有一个__get__()方法将它绑定到它的所有者类或它的一个实例。

为了解决这些问题,我们必须在类周围引入一个包装函数,它将实例化对象并返回它。这样,我们可以使用@functools.wraps尽可能多地保留原始装饰器。更好的是,我们还可以返回一个函数,它可以毫无困难地绑定到类和实例:

img/330715_3_En_5_Figw_HTML.jpg

import functools

def repeatable(generator):
    """
    A decorator to turn a generator into an object that can be
    iterated multiple times, restarting the generator each time.
    """
    class RepeatableGenerator:
        def __init__(self, *args, **kwargs):
            self.args = args
            self.kwargs = kwargs

        def __iter__(self):
            return iter(generator(*self.args, **self.kwargs))

    @functools.wraps(generator)
    def wrapper(*args, **kwargs):
        return RepeatableGenerator(*args, **kwargs)
    return wrapper

顺序

除了数字,序列可能是所有编程中最常用的数据结构,包括 Python。列表、元组甚至字符串都是共享一组公共特性的序列,它们实际上是迭代器的一种特殊类型。除了能够单独产生一系列项目之外,序列还具有额外的属性和行为,支持它们一次性了解整个项目集的事实。

这些额外的行为并不一定要求所有的条目同时被加载到内存中。通过迭代获得的效率收益对于序列和其他任何可迭代对象一样有效,所以行为不会改变。相反,添加的选项只是将集合作为一个整体,包括其长度和获取子集的能力,以及在不获取整个序列的情况下访问单个项目。

序列最明显的特征是能够确定它的长度。对于可以包含任意项目的对象,这需要知道——或者计数——所有这些项目。对于其他人来说,对象可以使用一些其他信息来达到相同的结果。这个行为的定制是通过提供一个__len__()方法来实现的,当对象被传递到内置的len()函数中时,这个方法在内部被调用。

继续前面的例子,下面是一个简单的替换Range类如何使用它的配置信息来返回长度,而不必产生一个值:

img/330715_3_En_5_Figx_HTML.jpg

class Range:
    def __init__(self, max):
        self.max = max

    def __iter__(self):
        for x in range(self.max):
            yield x

    def __len__(self):
        return self.max

因为序列包含固定的项目集合,所以它们不仅可以从头到尾迭代,还可以反向迭代。Python 提供了reversed()函数,该函数将一个序列作为其唯一的参数,并返回一个 iterable,该 iterable 从序列中反向生成项目。可能会有特别的效率提升,所以一个定制的序列对象可以提供一个__reversed__()方法来定制reversed()的内部行为。

将这个概念再次带到Range类,可以使用内置range()的替代形式来提供一个反向范围:

img/330715_3_En_5_Figy_HTML.jpg

class Range:
    def __init__(self, max):
        self.max = max

    def __iter__(self):
        for x in range(self.max):
            yield x

    def __reversed__(self):
        for x in range(self.max - 1, -1, -1):
            yield x

既然我们已经能够向前和向后遍历序列并报告其长度,下一步就是提供对单个项目的访问。在普通 iterable 中,只能通过在循环中一次检索一项来访问项。通过预先知道序列中的所有值,自定义类可以随时提供对任何项目的访问。

最明显的任务是在给定一个预先已知的索引的情况下检索一个项目。例如,如果自定义对象包含在命令行中传递的参数,应用将知道每个参数的具体含义,并且通常通过索引来访问它们,而不是简单地遍历整个序列。这使用标准的sequence[index]语法,其行为由__getitem__()方法控制。

使用__getitem__(),如果需要,可以从序列中挑选出单个项目,或者从其他数据结构中检索。继续讨论Range主题,__getitem__()可以计算出合适的值,而不需要在序列中循环。事实上,它甚至可以支持内置range()可用的所有参数:

img/330715_3_En_5_Figz_HTML.jpg

class Range:
    def __init__(self, a, b=None, step=1):
        """
        Define a range according to a starting value, an end value and a step.

        If only one argument is provided, it's taken to be the end value. If
        two arguments are passed in, the first becomes a start value, while the
        second is the end value. An optional step can be provided to control
        how far apart each value is from the next.
        """
        if b is not None:
            self.start = a
            self.end = b
        else:
            self.start = 0
            self.end = a
        self.step = step

    def __getitem__(self, key):
        value = self.step * key + self.start
        if value < self.end:
            return value
        else:
            raise IndexError("key outside of the given range")

>>> r = Range(5)
>>> list(r)
[0, 1, 2, 3, 4]
>>> r[3]
3
>>> r = Range(3, 17, step=4)
>>> list(r)
[3, 7, 11, 15]
>>> r[2]
11
>>> r[4]
Traceback (most recent call last):
  ...
IndexError: indexed value outside of the given range

如果传入的索引超出了可用项目的范围,__getitem__()应该引发一个IndexError。高度专业化的应用可以定义一个更具体的子类并提升它,但是大多数用例会简单地自己捕捉IndexError

除了符合大多数 Python 程序员的期望之外,适当提高IndexError对于允许一个序列在不实现__iter__()的情况下被用作 iterable 是至关重要的。Python 将简单地传入整数索引,直到__getitem__()方法引发一个IndexError,此时它将停止对序列的迭代。

除了一次只能访问一个项目,序列还可以通过切片的方式提供对其内容子集的访问。当使用切片语法时,__getitem__()接收一个特殊的slice对象,而不是一个整数索引。一个slice对象具有切片的开始、停止和步进部分的专用属性,这些属性可用于确定返回哪些项目。这是如何影响我们一直在检查的Range对象的:

img/330715_3_En_5_Figaa_HTML.jpg

class Range:
    def __init__(self, a, b=None, step=1):
        """
        Define a range according to a starting value, an end value and a step.

        If only one argument is provided, it's taken to be the end value. If
        two arguments are passed in, the first becomes a start value, while the
        second is the end value. An optional step can be provided to control
        how far apart each value is from the next.
        """
        if b is not None:
            self.start = a
            self.end = b
        else:
            self.start = 0
            self.end = a
        self.step = step

    def __getitem__(self, key):
        if isinstance(key, slice):
            r = range(key.start or 0, key.stop, key.step or 1)
            return [self.step * val + self.start for val in r]
        value = self.step * key + self.start
        if value < self.end:
            return value
        else:
            raise IndexError("key outside of the given range")

下一个逻辑步骤是允许序列中的单个项目根据其索引进行设置。这种就地赋值使用基本相同的sequence[index]语法,但作为赋值操作的目标。它由一个自定义对象在其__setitem__()方法中支持,该方法接受要访问的索引和要存储在该索引中的值。

然而,和__getitem__()一样,__setitem__()也可以接受一个slice对象作为它的索引,而不是一个整数。但是,因为切片定义了序列的子集,所以传递的值应该是另一个序列。然后,这个新序列中的值将取代切片引用的子集中的值。

然而,事情并不完全像它们看起来那样,因为分配给切片的序列实际上不需要与切片本身具有相同的项目数。事实上,它可以是任何大小,无论是大于还是小于它被分配到的片。__setitem__()的预期行为只是删除切片引用的项目,然后将新项目放入该间隙,根据需要扩展或收缩总列表的大小以容纳新值。

注意

__setitem__()方法仅用于替换序列中的现有值,而非严格意义上的添加新项目。为此,你还需要实现append()insert(),使用与标准列表相同的接口。

从列表中删除一个条目可以通过两种不同的方式来实现。对此的显式方法是remove()(例如 my_list(range(10,20))。remove(5)),它获取应该移除的项的索引。然后,位于已移除项目之后的剩余项目会向左移动,以填充间隙。使用del sequence[index]语句也可以实现同样的行为。

考虑到它是一个显式的方法调用,实现remove()非常简单。del的简单情况就像remove()一样,但是使用了一个__delitem__()方法。事实上,如果删除一个条目是最重要的,你可以简单地将一个现有的remove()方法分配给__delitem__属性,它会像预期的那样工作。不幸的是,切片使事情稍微复杂了一些。

从切片中删除项目就像__setitem__()的切片行为的第一部分一样。然而,不是用一个新的序列替换切片中的项目,而是该序列应该简单地移动它的项目以缩小差距。

在对序列内容进行更改的所有不同方法中,最后一个(但不是最不重要的)重要特性是测试一个项目是否是给定序列的一部分。默认情况下,Python 将简单地遍历序列——使用前面关于 iterables 一节中列出的技术——直到它找到被测试的项或者遍历完迭代器提供的所有值。这允许在任何类型的可迭代对象上执行成员测试,而不局限于全序列。

为了提高效率,序列也可以通过提供一个__contains__()方法来覆盖这种行为。它的签名看起来像__getitem__(),但是它不是接受一个索引,而是接受一个对象,如果给定的对象出现在序列中,则返回True,否则返回False。在之前检查的Range示例中,__contains__()的结果可以根据对象的配置动态计算:

img/330715_3_En_5_Figab_HTML.jpg

class Range:
    def __init__(self, a, b=None, step=1):
        """
        Define a range according to a starting value, an end value and a step.

        If only one argument is provided, it's taken to be the end value. If
        two arguments are passed in, the first becomes a start value, while the
        second is the end value. An optional step can be provided to control
        how far apart each value is from the next.
        """
        if b is not None:
            self.start = a
            self.end = b
        else:
            self.start = 0
            self.end = a
        self.step = step

    def __contains__(self, num):
        return self.start <= num < self.end and \
               not (num – self.start) % self.step

>>> list(range(5, 30, 7))
[5, 12, 19, 26]
>>> 5 in Range(5, 30, 7)
True
>>> 10 in Range(5, 30, 7)
False
>>> 33 in Range(5, 30, 7)
False

这里为序列介绍的许多方法也适用于下一个容器类型,它将一组键映射到相关的值。

绘图

序列是对象的连续集合,而映射的工作方式略有不同。在映射中,单个项目实际上是一对,由一个键和一个值组成。键不必排序,因为迭代它们通常不是重点。相反,目标是提供对给定键引用的值的快速访问。密钥通常是预先知道的,最常见的用法是期望它。

通过键访问值与在序列中使用索引使用相同的语法。事实上,Python 不知道也不关心你实现的是序列、映射还是完全不同的东西。不管使用哪种类型的对象,相同的方法__getitem__()__setitem__()__delitem__()都被重用来支持obj[key]语法。然而,这并不意味着这些方法的实现可以完全相同。

对于映射,键被用作索引。尽管两者在语法上没有区别,但键支持更广泛的允许对象。除了普通整数,关键字可以是任何可哈希的 Python 对象,比如日期、时间或字符串;其中,字符串是最常见的。然而,由您的应用来决定是否应该对接受什么键有任何限制。

Python 支持如此多的灵活性,事实上,您甚至可以使用标准切片语法,而不用考虑切片中涉及哪些值。Python 只是传递切片中引用的任何对象,因此由映射决定如何处理它们。默认情况下,列表通过显式查找整数来处理切片,如果有必要,使用__index__()将对象强制转换为整数。相比之下,对于字典来说,slice对象是不可散列的,所以字典不允许它们被用作键。

小费

在大多数情况下,您可以接受自定义词典中的任何内容,即使您打算只使用特定的类型,如字符串,作为您的键。只要它只在您自己的代码中使用,它就不会有任何不同,因为您控制着它的所有使用。如果您所做的修改被证明在您的应用之外是有用的,其他开发人员将利用它来满足他们自己的需求。因此,只有在真正需要时,才应该限制可用的键和值;否则,最好留有选择余地,即使是为你自己。

尽管这一章并没有涵盖任何作为公共接口的一部分被直接调用的方法,但是映射有三个方法提供了对内部组件的特别有用的访问,这三个方法应该总是被实现。这些方法是必要的,因为映射本质上包含两个独立的集合——键和值——然后通过关联连接在一起,而序列只包含一个集合。

这些额外方法中的第一个keys(),迭代映射中的所有键,而不考虑它们的值。默认情况下,键可以以任何顺序返回,但一些更专业的类可以选择为这些键提供显式顺序。这个相同的行为是由映射对象本身的迭代提供的,所以一定要提供一个与keys()做同样事情的__iter__()方法。

下一个方法values()是补充性的,它迭代映射的值端。像键一样,这些值通常不被假定为任何顺序。实际上,Python 的 C 实现使用了与键相同的顺序,但是顺序从来没有保证过,即使是在同一个对象的键和值之间。

为了可靠地获得所有相关对中的键和值,映射提供了一个items()方法。这将遍历整个集合,将每一对作为一个元组以(key, value)的形式产生。因为这通常比遍历键并使用mapping[key]来获取相关值更有效,所以所有映射都应该提供一个items()方法,并使其尽可能高效。

可召回商品

在 Python 中,函数和类都可以在任何时候被调用来执行代码,但是这些并不是唯一可以这样做的对象。事实上,任何 Python 类都可以通过简单地将一个额外的方法附加到类定义上而变得可调用。这个方法被恰当地命名为__call__(),它接受通常的self以及应该在方法调用中传递的任何参数。

对于什么参数__call__()可以接受没有特殊的要求,因为它在被调用时像任何其他方法一样工作。唯一的区别是,它还接收它所附加的对象作为第一个参数:

img/330715_3_En_5_Figac_HTML.jpg

>>> class CallCounter:
...     def __init__(self):
...         self.count = 0
...     def __call__(self, *args, **kwargs):
...         self.count += 1
...         return 'Number of calls so far: %s' % self.count
...     def reset(self):
...         self.count = 0
...
>>> counter = CallCounter()
>>> counter()
'Number of calls so far: 1'
>>> counter()
'Number of calls so far: 2'
>>> counter()
'Number of calls so far: 3'
>>> counter.reset()
>>> counter()
'Number of calls so far: 1'

警告

作为一个方法本身,__call__()也可以被修饰任意次,但是记住它仍然是一个方法,即使它是通过直接调用对象来调用的。作为一个方法,任何应用于它的 decorators 必须能够处理作为对象实例的第一个参数。

至于__call__()能做什么,天空才是极限。它的目的仅仅是允许一个对象是可调用的;通话过程中会发生什么完全取决于手头的需求。这个例子表明,它也可以像任何其他方法或函数一样,接受您可能需要的任何附加参数。然而,它最大的优点是,它允许您本质上提供一个可以自己定制的功能,而不需要任何装饰者。

上下文管理器

正如在第二章中简要提到的,对象也可以在with语句中用作上下文管理器。这允许一个对象定义在该对象的上下文中工作意味着什么,在执行包含的代码之前进行设置,在执行完成之后进行清理。

一个常见的例子是文件处理,因为在使用文件之前,必须为特定类型的访问打开文件。然后,当它不再被使用时也需要被关闭,以将任何挂起的更改刷新到磁盘。这确保了其他代码稍后可以打开同一文件,而不会与任何打开的引用发生冲突。这两个操作之间发生的事情被认为是在打开文件的上下文中执行的。

如上所述,上下文管理器要执行两个不同的步骤。首先,需要初始化上下文,以便在with块中执行的代码可以利用上下文提供的特性。就在执行内部代码块之前,Python 将调用对象上的__enter__()方法。这个方法不接收任何额外的参数,只接收实例对象本身。然后,它的职责是为代码块提供必要的初始化,无论这意味着修改对象本身还是进行全局更改。

如果with语句包含一个as子句,那么__enter__()方法的返回值将用于填充该子句中引用的变量。重要的是要认识到对象本身不一定是那个值,即使看起来是那样的with语句的语法。使用__enter__()的返回值允许上下文对象更加灵活,尽管该行为可以通过简单地返回self来实现。

一旦with块中的代码执行完毕,Python 将调用对象上的__exit__()方法。然后,这个方法负责清除在__enter__()期间所做的任何更改,将上下文返回到处理with语句之前的状态。在文件的情况下,这意味着关闭文件,但它实际上可以是任何东西。

当然,with块中的执行有几种方式可以完成。最明显的是如果代码简单地自己完成,没有任何问题或其他流控制。像returnyieldcontinuebreak这样的语句也可以停止代码块的执行,在这种情况下__exit__()仍然会被调用,因为清理仍然是必要的。事实上,即使出现异常,__exit__()仍然有机会撤销在__enter__()期间应用的任何更改。

为了识别代码是正常完成还是通过异常提前停止,__exit__()方法将被赋予三个额外的参数。第一个是引发的异常的类对象,接下来是该类的实例,这是代码中实际引发的。最后,__exit__()还将接收一个 traceback 对象,表示异常发生时的执行状态。

这三个参数总是被传入,所以任何__exit__()的实现都必须接受它们。如果执行完成而没有引发任何异常,参数仍然会被提供,但是它们的值只是None。访问异常和回溯允许您的__exit__()实现智能地对任何出错和导致问题的原因做出反应。

小费

__exit__()方法本身并不抑制任何异常。如果__exit__()在没有返回值的情况下完成,最初的异常(如果有的话)将被自动重新引发。如果您需要显式地捕捉发生在with块中的任何错误,只需从__exit__()返回True,而不是让它从末尾脱落,这将返回一个隐式的None

为了展示一个简单的例子,考虑一个使用上下文管理协议来抑制任何在with块中引发的异常的类。在这种情况下,__enter__()不需要做任何事情,因为异常处理将在__exit__()中完成:

img/330715_3_En_5_Figad_HTML.jpg

>>> class SuppressErrors:
...     def __init__(self, *exceptions):
...         if not exceptions:
...             exceptions = (Exception,)
...         self.exceptions = exceptions
...     def __enter__(self):
...         pass
...     def __exit__(self, exc_class, exc_instance, traceback):
...         if isinstance(exc_instance, self.exceptions):
...          return True
...         return False
...
>>> with SuppressErrors():
...     1 / 0  # Raises a ZeroDivisionError
...
>>> with SuppressErrors(IndexError):
...     a = [1, 2, 3]
...     print(a[4])
...
>>> with SuppressErrors(KeyError):
...     a = [1, 2, 3]
...     print(a[4])
...
Traceback (most recent call last):
  ...
IndexError: list index out of range

令人兴奋的 Python 扩展:Scrapy

如果你需要从互联网上提取数据,特别是理解网站上的数据,那么网络抓取工具将大有裨益。Scrapy 是一款开源的全功能网络抓取工具。如果你听说过“蜘蛛”或“网络爬行”,那么你已经熟悉网络抓取的其他术语,但它们都是一样的。从大的方面来说,网络抓取工具是处理大数据的一部分。Web scraping 允许您从互联网上挖掘信息,而其他工具允许您清理信息,其他工具允许您对获得的原始数据和清理后的数据进行分类。Python 使得构建 scaper 变得很容易。请继续阅读,了解如何使用 Scrapy 获取原始数据。

装置

首先你需要安装网页抓取工具 Scrapy 的库。为此,进入升级的命令提示符(Windows)并键入:

pip install scrapy (Enter)

MacOS 和 Linux 也会差不多;只是检查一下刺儿头。org 网站了解详情。

奔跑的斗志

您可以通过 run spider 命令直接运行 spider,也可以创建一个包含一个或多个 spider 的项目目录。对于快速工作,比如只运行一个蜘蛛,这只是一个简单的命令:scrapy run spider my _ spider . py。但是,有时您可能需要一个项目目录,以便有序地存储配置信息和多个蜘蛛。对于我们的目的,一只蜘蛛就足够了。

项目设置

最初的过程是查找和下载网页,然后根据给定的标准从网页中提取信息。要做到这一点,你会希望你的蜘蛛在你选择的文件夹中把所有的东西组织到一个区域。对于本例,在系统上创建一个可以从命令提示符轻松导航到的文件夹。例如,如果您的 C:驱动器的根目录位于 MS Windows 上:

md firstspider (Enter)

你把文件夹放在哪里并不重要,但是一定要确保你能够找到它。接下来,使用您的 Python IDLE IDE,编写以下非常基本的 spider 代码,并将该文件作为 scraper.py 保存到您刚刚创建的文件夹中:

import scrapy
# filename scraper.py
class QuotesSpider(scrapy.Spider):
        name = "quotes"

        def start_requests(self):
            urls = [ 'http://quotes.toscrape.com/page/1/' ]
            for url in urls:
                     yield scrapy.Request(url=url, callback=self.parse)

        def parse(self, response):
            print('\nURL we went to: ', response, '\n')

现在,运行前面提到的代码并不令人兴奋。出于我们的目的,Scrapy 将通过命令行使用 Scrapy 命令界面运行得更好。这与从命令行运行 Python 脚本的方式非常相似。对于 Python,它将是Pythonname _ of _ file . py而对于 Scrapy,它将是类似的,从您刚刚创建的文件夹以及您保存文件的位置:Scrapy run spiderscraper . py(Enter)。如果一切运行正常,您应该会看到类似下面的内容:

img/330715_3_En_5_Fig1_HTML.jpg

图 5-1

通过终端运行样品刮刀的屏幕截图

如果你收到任何错误,这可能是你的路径或搜索驱动器找到 Scrapy 没有设置。如果在 Windows 上,您收到 win32api 错误,您很可能需要安装 pypiwin32。如果需要,通过在升级的命令提示符下键入以下命令来完成此操作:

pip install pypiwin32api (Enter)

就其本身而言,这是令人兴奋的,因为(希望)没有错误,我们显示了我们访问的 URL。也就是说,现在让我们做一些更有成效的工作。

用 Scrapy 检索 Web 数据

Scrapy 有一个非常方便的命令行界面。当然你会用 Python 来写你的蜘蛛,但是 Scrapy shell 可以帮助你在你的蜘蛛代码中写些什么。考虑如何用 Scrapy 查看网页。

通过 Scrapy 查看网页

在升级后的命令提示符下, scrapy 视图 http://quotes.toscrape.com/page/1/ 将使 scrapy 在浏览器中加载您指定的 URL。这很方便,因为你可能想在 Scrapy 从一个站点提取数据之前检查它。记下页面的标题;我们将只提取下一个。

外壳选项

当然,你会知道什么 Scrapy 外壳选项可用。要查看它们,使用交互式 shell 并从命令行进入http://quotes.toscrape.com/page/1/。您现在可以看到选项。试着从命令行提示:response . CSS(' title ')。请注意,您仍然在 Scrapy 交互式 shell 中,并注意标题(来自 HTML 标记)被返回。使用 CTRL+Z 退出 shell。

**要使用 Python 以编程方式执行相同的操作,请考虑以下情况:

import scrapy
class QuotesSpider(scrapy.Spider):
    name = "quotes"
    def start_requests(self):
        urls = [
            'http://quotes.toscrape.com/page/1/'  ]
        for url in urls:
            yield scrapy.Request(url=url, callback=self.parse)

    def parse(self, response):
        print()
        print("Title will follow: \n")
        print(response.css("title"))
        print()

这将使用从页面中提取的带有标记标签的标题。

img/330715_3_En_5_Fig2_HTML.png

图 5-2

标题的 CLI 输出

现在,为了稍微清理一下,请更改该行:

print(response.css("title"))

致:

print(response.css("title").extract_first(),)

然后保存并重新运行蜘蛛程序,您会注意到 HTML 标签和标题的输出更加干净和有用。extract_first()方法返回第一个出现的字符串。

当然,这只是让你开始使用 Scrapy 的一点点。你可以用它做更多的事情;使用你所学的知识来扩展你的网络抓取技能。找到更多信息的最佳途径是查看文档。scrapy。更多关于 Scrapy 的方法和特性的信息。事实上,这个例子中使用的引用 URL 与 Scrapy 站点教程中使用的相同。

带着它

对于本章列出的所有协议,有一点也许是理解起来最重要的:它们并不相互排斥。在单个对象上实现多个协议是可能的,有时也非常有利。例如,一个序列也可以作为一个可调用的和一个上下文管理器,如果这两个行为对一个给定的类有意义的话。

本章主要讨论了对象的行为,由它们的类提供;下一章将介绍如何在工作代码中实例化这些对象及其数据。

**

六、对象管理

创建一个类的实例只是开始;一旦你有了一个对象,你可以用它做很多事情。当然,这是显而易见的,因为对象有方法和属性来控制它们的行为,但是这些是由每个类定义的。作为一个整体,对象有一组额外的功能,允许您以多种不同的方式管理它们。

为了理解这些特征,首先需要理解对象的实际构成。在高层次上,对象仅仅是数据和行为的产物,但是在内部,Python 认为对象是三种特定事物的组合(如果加上基类和属性,则为五种):

  • 身份:每一个物体都是独一无二的,有了身份就可以用来互相比较物体,而不需要查看任何其他细节。这种使用is操作符的比较是非常严格的,然而,没有涉及到第五章中概述的任何微妙之处。在实际的实现中,一个对象的标识就是它在内存中的地址,所以没有两个对象会有相同的标识。

  • 类型:前两章的主题,一个对象的类型是由它的类和任何支持它的基类定义的。与标识不同,类型在其所有实例之间共享;每个对象只包含对其类的引用。

  • Value :通过一个共享类型来提供行为,每个对象也有一个值,使它在它的同类中与众不同。该值由特定于给定对象的名称空间字典提供,在该字典中可以存储和检索其个性的任何方面。然而,这不同于标识,因为值被设计成与类型一起工作来做有用的事情;标识与类型完全无关,所以它与为该类指定的行为没有任何关系。

这三点可以参考,在某些情况下,可以根据应用的需要进行修改。一个对象的标识不能在任何时候被修改,所以它的值在对象的生命周期内是不变的。但是,一旦对象被销毁,它的身份可以(而且经常会)被未来的对象重新使用,然后保留该身份,直到该对象被销毁。

如果你想在任何时候检索一个身份,你可以把对象传入内置的id()函数,因为对象本身并不知道它的身份(ID 方法)。事实上,身份与任何特定于对象的东西无关;它的属性与它的身份没有任何关系。因此,如果你实例化一个相同的对象,你不会得到相同的标识。它也因可用内存而异,因此在一个会话中,位置(作为整数返回)很可能在另一个会话中有所不同。在前两章中已经全面介绍了类型,所以下一个明显的组件是值,它是通过名称空间字典实现的。

名称空间字典

如前所述,对象的名称空间被实现为一个字典,它是在每个新对象被实例化时为其创建的。然后用它来存储对象所有属性的值,这样就构成了整个对象的值。

然而,与 identity 不同,这个名称空间字典可以在运行时被访问和修改,因为它是作为对象的__dict__属性提供的。事实上,因为它是一个属性,它甚至可以被一个新的字典完全取代。这就是通常所说的博格模式的基础,博格模式是以《星际迷航》宇宙中的集体意识命名的。

示例:博格模式

就像它的名字一样,Borg 模式允许大量的实例共享一个名称空间。以这种方式,每个对象的身份保持不同,但是它的属性——以及它的行为——总是与它的所有同类相同。这主要是允许在应用中使用一个类,该类可以被实例化多次,每次都有可能对其进行修改。通过使用 Borg 模式,这些更改可以累积在一个名称空间中,因此每个实例反映了对每个对象所做的所有更改。

这是通过将一个字典附加到类上,然后在实例化时将该字典分配给每个对象的名称空间来实现的。正如第四章所演示的,这可以这样实现:__init__()__new__()。因为这两种方法都在对象的实例化过程中执行,所以它们看起来是同等可行的选择。但是,让我们来看看它们各自是如何工作的。

__init__()方法通常是开始的地方,因为它更好理解,也更广泛采用。该方法通常初始化实例属性,因此字典分配需要在任何其他初始化之前进行。然而,这很容易做到,只要把它放在方法的开始。这是如何工作的:

img/330715_3_En_6_Figa_HTML.jpg

>>> class Borg:
...     _namespace = {}
...     def __init__(self):
...         self.__dict__ = Borg._namespace
...         # Do more interesting stuff here.
...
>>> a = Borg()
>>> b = Borg()
>>> hasattr(a, 'attribute')
False
>>> b.attribute = 'value'
>>> hasattr(a, 'attribute')
True
>>> a.attribute
'value'
>>> Borg._namespace
{'attribute': 'value'}

这当然完成了工作,但是这种方法有一些陷阱,特别是当您开始使用继承时。所有的子类都需要确保它们使用了super(),以便从Borg类中调用初始化过程。如果任何子类没有这样做,它就不会使用共享的名称空间;它的任何子类也不会,即使它们使用了super()。此外,子类应该在自己做任何属性赋值之前使用super()。否则,这些赋值将被共享名称空间覆盖。

然而,这只适用于 Borg 应用于其他了解它的职业。当使用Borg作为 mixin 时,这个问题会更加突出,因为它会和不知道它的类一起被应用——它们不应该知道。但是因为它们无论如何都可以合并,所以有必要研究一下会发生什么:

img/330715_3_En_6_Figb_HTML.jpg

>>> class Base:
...     def __init__(self):
...         print('Base')
...
>>> class Borg:
...     _namespace = {}
...     def __init__(self, *args, **kwargs):
...         self.__dict__ = Borg._namespace
...         print('Borg')
...
>>> class Testing(Borg, Base):
...     pass
...
>>> Testing()
Borg
<__main__.Testing object at 0x...>
>>> class Testing(Base, Borg):
...     pass
...
>>> Testing()
Base
<__main__.Testing object at 0x...>

如您所见,这展示了不使用super()时的典型问题,其中基类的顺序可以完全排除它们中的一个或多个的行为。当然,解决方案是只使用super(),但是在 mixins 的情况下,您通常不能控制涉及的两个类。在Borg出现在它的对等体之前的情况下,添加super()就足够了,但是 mixins 通常在它们的对等体之后应用,所以它实际上没有多大帮助。

考虑到这些,值得考虑另一种方法__new__()。所有的方法都容易受到针对__init__()显示的相同类型的问题的攻击,但是至少我们可以减少导致这些问题的冲突的机会。因为__new__()方法很少被实现,所以遇到冲突实现的几率要小得多。

当用__new__()实现Borg模式时,必须沿途创建对象,通常通过调用基础object上的__new__()。然而,为了作为一个 mixin 与其他类很好地配合,在这里还是使用super()比较好。一旦创建了对象,我们可以用整个类的名称空间字典替换它的名称空间字典:

img/330715_3_En_6_Figc_HTML.jpg

>>> class Base:
...     def __init__(self):
...         print('Base')
...
>>> class Borg:
...     _namespace = {}
...     def __new__(cls, *args, **kwargs):
...         print('Borg')
...         obj = super(Borg, cls).__new__(cls, *args, **kwargs)
...         obj.__dict__ = cls._namespace
...         return obj
...
>>> class Testing(Borg, Base):
...     pass
...
>>> Testing()

Borg
Base
<__main__.Testing object at 0x...>
>>> class Testing(Base, Borg):
...     pass
...
>>> Testing()
Borg
Base
<__main__.Testing object at 0x...>
>>> a = Testing()
Borg
Base
>>> b = Testing()
Borg
Base
>>> a.attribute = 'value'
>>> b.attribute
'value'

现在,Borg出现在最常见的情况下,对与它们一起运行的任何类都没有任何不寻常的要求。然而,这个实现仍然有一个问题,在这个例子中并不明显。作为一个 mixin,Borg可以应用于任何类定义中,您可能会认为它的名称空间行为仅限于那个定义的类及其子类。

不幸的是,这不会发生。因为_namespace字典本身就在Borg上,所以它将在从Borg继承的所有类之间共享。为了打破这一限制,只将它应用于那些应用了Borg的类,一个稍微不同的技术是必要的。

因为__new__()方法接收类作为它的第一个位置参数,所以 Borg mixin 可以使用该对象作为它自己的名称空间,从而将托管字典分割成单独的名称空间,每个使用的类一个名称空间。简而言之,Borg.__new__()必须为它遇到的每个新类创建一个新字典,将它赋给现有的_namespace字典中的一个值,使用类对象作为它的键:

img/330715_3_En_6_Figd_HTML.jpg

>>> class Borg:
...     _namespace = {}
...     def __new__(cls, *args, **kwargs):
...         obj = super(Borg, cls).__new__(cls, *args, **kwargs)

...         obj.__dict__ = cls._namespace.setdefault(cls, {})

...         return obj
...
>>> class TestOne(Borg):
...     pass
...
>>> class TestTwo(Borg):
...     pass
...
>>> a = TestOne()
>>> b = TestOne()
>>> a.spam = 'eggs'
>>> b.spam
'eggs'
>>> c = TestTwo()
>>> c.spam
Traceback (most recent call last):
  ...
AttributeError: 'TestTwo' object has no attribute 'spam'
>>> c.spam = 'burger'
>>> d = TestTwo()
>>> d.spam
'burger'
>>> a.spam
'eggs'

如您所见,通过使用cls作为它自己的一种名称空间,我们可以在每个类的基础上划分托管值。所有的TestOne实例共享同一个名称空间,而所有的TestTwo实例共享一个单独的名称空间,所以两者之间不会有任何重叠。

示例:自缓存属性

尽管属性是访问一个对象的名称空间字典的主要手段,记住第四章中的属性访问可以使用特殊的方法定制,比如__getattr__()__setattr__()。这些方法是 Python 在访问属性时实际使用的方法,由这些方法在内部的名称空间字典中查找。如果您用纯 Python 来定义它们,它们看起来很像这样:

img/330715_3_En_6_Fige_HTML.jpg

class object:
    def __getattr__(self, name):
        try:
            return self.__dict__[name]
        except KeyError:
            raise AttributeError('%s object has no attribute named %s'
                % (self.__class__.__module__, name))

    def __setattr__(self, name, value):
        self.__dict__[name] = value

    def __delattr__(self, name):
        try:
            del self.__dict__[name]
        except KeyError:
            raise AttributeError('%s object has no attribute named %s'
                % (self.__class__.__module__, name))

如您所见,对属性的每次访问都在名称空间中执行查找,如果名称空间不存在,就会引发错误。这意味着为了检索一个属性,它的值必须已经被预先创建和存储。在大多数情况下,这种行为是适当的,但在某些情况下,属性值可能是一个创建成本很高的复杂对象,并且可能不会经常使用,因此将其与宿主对象一起创建并不是很有利。

这种情况的一个常见例子是位于应用代码和关系数据库之间的对象关系映射(ORM)。例如,当检索一个人的信息时,您会在 Python 中得到一个Person对象。那个人也可能有配偶、孩子、房子、雇主,甚至是装满衣服的衣柜,所有这些都可以在数据库中表示为与您检索到的人相关。

如果我们要以属性的形式访问所有这些信息,那么前面描述的简单方法需要在每次检索一个人时从数据库中提取所有这些数据。然后,所有这些数据必须被收集到单独的对象中,用于每种类型的数据:PersonHouseCompanyClothing,可能还有许多其他类型的数据。更糟糕的是,这些相关对象中的每一个都有其他可以作为属性访问的关系,这很快就好像每次进行查询时都需要加载整个数据库。

相反,显而易见的解决方案是仅在被请求时加载该信息。通过跟踪人员的唯一标识符,以及知道如何检索相关信息的一组查询,可以添加一些方法,以便在必要时能够检索该信息。

不幸的是,每次调用方法时,都应该执行它们的任务。例如,如果您需要此人的雇主,您必须调用一个Person.get_employer()方法,该方法将在数据库中进行查询并返回结果。如果您再次调用该方法,将会进行另一个查询,尽管这通常是不必要的。这可以通过将雇主存储为一个单独的变量来避免,该变量可以被重用,而不是再次调用该方法,但是一旦您开始将Person对象传递给可能有不同需求的不同函数,这种情况就不存在了。

相反,一个更好的解决方案是创建一个开始时信息尽可能少的属性——甚至可能没有信息。然后,当访问该属性时,进行数据库查询,返回适当的对象。然后,这个相关对象可以存储在主对象的名称空间字典中,以后可以直接访问它,而不必再次访问数据库。

实际上,在访问属性时查询数据库是一项相当简单的任务。将@property decorator 应用于一个方法将产生预期的效果,只要访问属性就调用该函数。然而,缓存它的返回值需要一点技巧,但这真的很简单:如果对象的名称空间中已经有一个值,就覆盖现有的值,否则就创建一个新值。

这可以简单地添加到现有属性的行为中,因为它只需要几行额外的代码来支持。以下是所有需要的:

img/330715_3_En_6_Figf_HTML.jpg

class Example:
    @property
    def attribute(self):
        if 'attribute' not in self.__dict__:
            # Do the real work of retrieving the value
            self.__dict__['attribute'] = value
        return self.__dict__['attribute']

警告

当像这样缓存属性值时,要注意检查计算出的值不应该根据其他属性的值而改变。例如,基于名字和姓氏计算全名是缓存的一个很差的候选,因为更改名字或姓氏也应该更改全名的值;缓存可以防止不正确的行为。

但是,请注意,这实际上只是在真正的代码之前和之后执行一点点工作,使它成为装饰者的理想任务。这就是那个装潢师的样子:

img/330715_3_En_6_Figg_HTML.jpg

import functools

def cachedproperty(name):
    def decorator(func):
        @property
        @functools.wraps(func)
        def wrapper(self):
            if name not in self.__dict__:
                self.__dict__[name] = func(self)
            return self.__dict__[name]
        return wrapper
    return decorator

一旦应用到函数中,cachedproperty()将像标准属性一样工作,但是会自动应用缓存行为。然而,您会注意到的一个区别是,除了命名您正在修饰的函数之外,您还必须提供属性的名称作为参数给cachedproperty()。假设您输入了前面的函数,下面是它的样子:

img/330715_3_En_6_Figh_HTML.jpg

>>> class Example:
...     @cachedproperty('attr')
...     def attr(self):
...         print('Getting the value!')
...         return 42
...
>>> e = Example()
>>> e.attr
Getting the value!
42
>>> e.attr
42

为什么必须提供两次名称?如前几章所述,问题在于描述符,包括属性,不能访问它们被赋予的名称。因为缓存的值根据属性的名称存储在对象名称空间中,所以我们需要一种方法将该名称传递给属性本身。然而,这显然违反了 DRY,所以让我们看看还有什么其他技术可用,以及它们的缺陷是什么。

一种选择是将字典直接存储在缓存的属性描述符上,使用对象实例作为键。每个描述符将获得一个唯一的字典,每个键将是一个唯一的对象,因此您可以存储与附加了属性的对象一样多的值:

img/330715_3_En_6_Figi_HTML.jpg

def cachedproperty(func):
    values = {}

    @property
    @functools.wraps(func)
    def wrapper(self):
        if self not in values:
            values[self] = func(self)
        return values[self]
    return wrapper

这个新的装饰器允许您缓存属性,而不必指定名称。但是,如果您对此持怀疑态度,您可能会想在不引用属性名称的情况下,将这些值存储在所有对象的单个字典中。毕竟,这似乎意味着,如果在一个对象上有多个缓存属性,它们的值会互相覆盖,这样就会产生各种各样的混乱。

然而,在这种情况下这不成问题,因为字典是在cachedproperty()函数中创建的,这意味着每个属性都有自己的字典名称值。这样,无论你在一个对象上放置多少缓存属性,都不会发生冲突。仅当您将现有属性指定给新名称而不重新定义它时,字典才会被共享。在这种情况下,第二个名字的行为应该总是与第一个名字完全一样,这里描述的缓存仍然会保持这种行为。

然而,这个属性还有一个可能不那么明显的问题。信不信由你,这包含了内存泄漏,如果在应用的很大一部分中使用而没有修复,这可能是非常有害的(稍后将更详细地讨论)。

在某些情况下,最好的解决办法是简单地回到本章描述的第一种形式,在这种形式中,属性的名称是显式提供的。因为名称没有提供给描述符,所以这种方法需要使用元类。当然,元类对于像这样的简单情况来说是多余的,但是在元类因为其他原因而被使用的情况下,拥有一个可用的名称是非常有用的。第十一章展示了一个使用元类方法的框架,效果很好。

为了避免使用元类,首先需要理解什么是内存泄漏,为什么会发生内存泄漏,以及如何避免内存泄漏。这一切都与 Python 如何在对象不再使用时将其从内存中移除有关,这个过程称为垃圾收集。

碎片帐集

不像 C 这样的低级语言,Python 不需要你管理自己的内存使用。当不再需要某个对象时,您不必为该对象分配一定数量的内存或取消对该内存的要求。事实上,您通常甚至不需要担心一个对象会占用多少内存,或者如何确定何时不再需要它。Python 在幕后处理这些棘手的细节。

垃圾收集很容易理解:Python 删除任何被识别为垃圾的对象,清除它们正在使用的任何内存,以便内存可供其他对象使用。如果没有这个过程,创建的每个对象都将永远留在内存中,并且您会慢慢地(或很快地)耗尽内存,这时一切都将嘎然而止。

您可能已经注意到,有效的垃圾收集首先需要能够可靠地将对象识别为垃圾。即使有从内存中清除垃圾的能力,未能识别垃圾也会导致内存泄漏蔓延到应用中。上一节中的最后一个例子包含了一个简单的情况,当一个对象变成垃圾时,它可能会导致 Python 没有注意到,所以我们需要研究这是如何确定的。重要的是要注意,因为 Python 不是一种强类型的语言(你不需要显式地声明变量类型),如果你在一个命令会话中用一个先前使用过的值重新声明变量,那么在该会话中被改变的变量将被重新引用。下一个终端提示示例通过显示变量在内存中的位置来说明这一点,正如您所注意到的,它会变回原来的值:

img/330715_3_En_6_Figj_HTML.jpg

>>> x=10
>>> type(x)
<class 'int'>
>>> id(x)  #location of x
1368047320
>>> x="foobar"
>>> type(x)
<class 'str'>
>>> id(x)  #location of x as a string instead of int
62523328
>>> x=10
>>> id(x)  #back to the original location of x as an int at 10
1368047320

引用计数

在高层次上,当一个对象不再被任何代码访问时,它就被认为是垃圾。为了确定一个对象是否可访问,Python 会计算在任何给定时间有多少数据结构引用该对象。

引用一个对象最明显的方法是在任何名称空间中分配它,包括模块、类、对象,甚至字典。其他类型的引用包括任何种类的容器对象,如列表、元组或集合。更不明显的是,每个函数都有自己的名称空间,可以包含对对象的引用,即使在闭包的情况下也是如此。本质上,任何提供对象访问的东西都会增加其引用计数。反过来,从这样的容器中移除对象会减少其引用计数。

为了说明这一点,下面是一些可能会创建新参考的情况示例:

img/330715_3_En_6_Figk_HTML.jpg

>>> a = [1, 2, 3]
>>> b = {'example': a}
>>> c = a

执行完这三行代码后,现在有三个对列表[1, 2, 3]的引用。其中两个相当明显,当时它被分配给a,后来又被重新分配给c。然而,b处的字典也引用了该列表,作为其'example'键的值。反过来,该字典只有一个引用,被指定为b的值。

del语句可能是删除对象引用的最明显的方法,但它不是唯一的选择。如果您将一个对象的引用替换为另一个对象的引用(重新绑定它),您也将隐式删除对第一个对象的引用。例如,如果我们要运行这两行代码,我们只用一个对列表的引用结束,如a所示:

img/330715_3_En_6_Figl_HTML.jpg

>>> del c
>>> a = None

即使在根名称空间中不再可用,该列表仍然可以作为字典的一部分使用,字典本身仍然可以作为b访问。因此,它们都只有一个引用,都不会被垃圾回收。如果您现在使用del b,字典的引用计数将变为零,并且可以进行垃圾收集。一旦被收集,列表的引用计数将减少到零,并作为垃圾被收集。

小费

默认情况下,Python 只是清除对象占用的内存。您不需要做任何事情来支持该行为,并且它在大多数情况下都工作得很好。在极少数情况下,一个对象在被删除时有一些特殊的需求需要解决,那么__del__()方法可以提供这种定制。

除了删除对象,您还可以对它们做许多其他的事情。下面是一个非常不同的情况,它可以改变引用计数的工作方式。

循环引用

考虑这样一个场景,其中有一个字典将一个列表作为它的一个值来引用。因为列表也是容器,所以实际上可以将字典作为值添加到列表中。最终得到的是一个循环引用,每个对象都引用另一个对象。为了扩展前面的例子,让我们看看这行代码会发生什么:

img/330715_3_En_6_Figm_HTML.jpg

>>> b['example'].append(b)

在此之前,字典和列表各有一个引用,但现在字典通过作为内部列表的成员而获得了另一个引用。这种情况在正常操作中可以很好地工作,但是当涉及到垃圾收集时,就出现了一个有趣的问题。

请记住,使用del b会将字典的引用计数减 1,但是现在列表也包含了对同一字典的引用,它的引用计数从 2 变为 1,而不是降到 0。如果引用计数大于零,字典就不会被认为是垃圾,它会和对列表的引用一起留在内存中。因此,该列表也有一个引用计数,将它保存在内存中。

这里有什么问题?在删除了变量b处的引用之后,这两个对象之间的引用现在是它们在整个 Python 解释器中仅有的引用。它们与任何将继续执行的代码完全隔离,但是因为垃圾收集使用引用计数,所以它们将永远留在内存中,除非做了其他事情。

为了解决这个问题,Python 的垃圾收集提供了代码,用于在这些结构出现时发现它们,因此也可以将它们从内存中删除。只要一组对象仅被该组中的其他对象引用,而不是从内存中的任何其他位置引用,它就会被标记为引用循环。这允许垃圾收集系统回收它正在使用的内存。

然而,当您实现__del__()时,事情开始变得非常棘手。通常,__del__()工作得很好,因为 Python 可以智能地计算出何时删除对象。因此,__del__()可以以可预测的方式执行,即使在短时间内删除多个对象。

当 Python 遇到任何其他代码都无法访问的引用循环时,它不知道在该循环中删除对象的顺序。这成为定制__del__()方法的一个问题,因为它也可以作用于相关对象。如果一个对象是孤立引用循环的一部分,任何相关对象也都计划删除,那么应该先触发哪个对象?

毕竟,周期中的每个对象都可以引用同一个周期中的一个或多个其他对象。如果没有首先考虑的对象,Python 只能简单地猜测它应该是哪一个。不幸的是,这导致行为不仅不可预测,而且在可能发生的许多次中是不可靠的。

因此,Python 必须采取两种可预测的、可靠的行动中的一种。一种选择是简单地忽略__del__()方法并删除对象,就像没有找到__del__()方法一样。不幸的是,这会根据对象控制之外的事情改变对象的行为。

Python 采用的另一种选择是将对象留在内存中。这就避免了在保持对象本身行为的同时试图排序各种__del__()方法的问题。然而,问题是,这实际上是一个内存泄漏,它只是因为 Python 无法对您的意图做出可靠的假设。

面对模棱两可,拒绝猜测的诱惑

循环引用中带有__del__()的情况是一个很好的模糊例子,因为没有明确的方法来处理这种情况。Python 通过简单地将对象留在内存中来回避它,而不是猜测。这不是解决问题的最节省内存的方法,但是在这种情况下,一致性要重要得多。尽管这可能意味着程序员要做更多的工作,但是这些额外的工作会产生更明确、更可靠的行为。

有三种方法可以避免这个问题。首先,您可以避免在任何循环引用中包含任何带有__del__()方法的对象。最简单的方法是完全避免使用__del__()方法。使用上下文管理器可以更好地处理定制对象拆卸的大多数常见原因。

在那些证明__del__()是必要的罕见情况下,第二种选择是简单地避免对象出现在引用循环中。然而,这并不总是容易做到的,因为它要求您完全控制对象可能被使用的所有方式。这可能对一些高度内部化的实现细节有用,但是如果它是公共接口的一部分,它可能不是一个选项。

最后,如果您不能防止循环被孤立,Python 确实提供了一种方法,您仍然可以检测它们,并有机会定期清理它们。一旦所有其他引用被移除,垃圾收集循环运行,Python 通过将每个相关对象放入一个特殊的列表来保持整个循环的活力,该列表在gc模块中可用。

gc模块提供了一些有助于深入垃圾收集系统内部的选项,但是这里需要考虑的是garbage属性。这个属性包含了原本无法到达的物体,但却是一个循环的一部分,这个循环包括了沿线某处的__del__()。作为gc.garbage的一部分访问它们允许你尝试打破事后的循环,这将允许他们的记忆被放弃。

考虑下面的例子,它也展示了gc.collect()的用法,这是一个模块级函数,它手动运行垃圾收集器,以便检测循环引用并相应地将其放入gc.garbage:

img/330715_3_En_6_Fign_HTML.jpg

>>> import gc
>>> class Example:
...     def __init__(self, value):
...         self.value = value
...     def __repr__(self):
...         return 'Example %s' % self.value
...     def __del__(self):
...         print('Deleting %r' % self)
...
>>> e = Example(1)
>>> e
Example 1
>>> del e
>>> gc.collect()
Deleting Example 1
0

# Now let's try it with a cyclical reference

>>> e = Example(2)
>>> e.attr = e
>>> del e
>>> gc.collect()
2
>>> gc.garbage

# From here, we can break the cycle and remove it from memory

>>> e = gc.garbage[0]
>>> del e.attr

>>> del e
>>> gc.collect()
0
>>> gc.garbage

# Don't forget to clear out gc.garbage as well

>>> gc.garbage[:] = []
Deleting Example 2
>>> gc.garbage
[]

然而,在现实世界中,__del__()很少被用到,遇到非常严重的循环引用问题更是罕见。然而,更常见的是需要调整引用本身是如何创建的,以及当您自己并不真正需要引用时该做什么。

弱引用

正如我们所看到的,给一个对象赋值会创建一个对它的引用,这些引用会使该对象在内存中保持活动状态。但是,当您需要访问一个对象,但却不想让它存活时,会发生什么呢?为此,Python 提供了弱引用的概念:在不增加引用计数的情况下获得对对象的引用。

通过在不增加对象引用计数的情况下获取引用,您可以对该对象执行操作,而不会妨碍它通常被删除的方式。这对于注册对象以备后用的应用来说非常重要。注册表本身保存了对所有已注册对象的引用,这些对象通常不会被删除,因为知道该对象的应用通常不知道注册系统的任何信息。

由于标准库中的weakref模块,创建弱引用相当简单。该模块中的ref()类创建了一个弱引用,指向传递给它的任何对象,允许稍后使用该引用。为了提供对原始对象的访问,弱引用是一个不接受参数并返回对象的可调用对象。

为了了解应该发生什么,我们必须首先在弱引用之外存储一个对该对象的引用。这样,我们不仅可以创建一个可以访问该对象的弱引用,还可以删除额外的引用来查看弱引用的行为:

img/330715_3_En_6_Figo_HTML.jpg

>>> import weakref
>>> class Example:
...     pass
...
>>> e = Example()
>>> e
<__main__.Example object at 0x...>
>>> ref = weakref.ref(e)
>>> ref
<weakref at ...; to 'Example' at ...>
>>> ref()
<__main__.Example object at 0x...>

>>> del e

>>> ref
<weakref at ...; dead>
>>> ref()
>>>

如您所见,只要至少有一个其他引用使对象保持活动,弱引用就可以轻松访问它。一旦对象在别处被删除,弱引用对象本身仍然可用,但它在被调用时只是返回None。我们还可以通过向弱引用直接传递一个新对象来简化这个例子:

img/330715_3_En_6_Figp_HTML.jpg

>>> ref = weakref.ref(Example())
>>> ref
<weakref at ...; dead>
>>> ref()
>>>

等等,刚刚发生了什么?Example对象去了哪里?这个简单的例子说明了弱引用最常见的问题之一。因为在调用ref()的过程中实例化了对象,所以为该对象创建的唯一引用在ref()内部。

一般来说,这是可以的,但是这个特定的引用并不能帮助保持对象的存活,所以这个对象会立即被标记为垃圾收集。弱引用只在有其他东西保持对象活动时才提供对对象的访问,所以在这种情况下,引用在被调用时只返回None。这种情况可能看起来很明显,但还有一些可能会在你最意想不到的时候出现。

可能出现的一种情况是在函数内部创建弱引用:

img/330715_3_En_6_Figq_HTML.jpg

>>> def example():
...     e = Example()
...     ref = weakref.ref(e)
...     return ref
...
>>> e = example()
>>> e
<weakref at ...; dead>
>>> e()
>>>

正如您所看到的,即使example()函数在自身内部存储了一个强引用,弱引用也会立即失效。这里的问题是,每个函数每次执行时都会获得一个全新的名称空间,当函数完成时它会被删除,因为执行是唯一让它保持活动的东西。

默认情况下,函数中的所有赋值都发生在这个名称空间中,所以一旦它被销毁,任何被赋值的对象也会被销毁,除非它们在其他地方存储了引用。在这种情况下,对Example对象的唯一其他引用是弱引用,因此一旦example()函数返回,该对象就会被销毁。

这里反复出现的主题是,弱引用在与任何类型的隐式引用移除一起使用时都会导致问题。我们已经讨论了两种情况,但是还有其他类似的情况。例如,for循环在每次循环开始时自动分配至少一个变量,覆盖任何先前分配给相同名称的值。因为这也破坏了对前一次迭代中使用的任何对象的引用,所以在循环中创建的弱引用不足以保持该对象存活。

酸洗

到目前为止,我们只讨论了如何在 Python 内部处理对象,但是经常需要与外部进程(如文件、数据库和网络协议)交换数据。大多数情况下,Python 之外的数据结构已经建立,因此您的应用需要遵循该结构。然而,其他时候,将数据发送到其他地方的唯一原因是将它存储一段时间,稍后再读取回 Python。 Pickle 命令用于将一个 Python 对象(如列表或字典)转换成一个持久的字符流,该字符流可以在以后重新加载,以重新创建该对象,供不同的 Python 应用使用。它用于在文件中序列化和反序列化 Python 对象。

在这种情况下,外部系统真的不关心你的数据是什么或者它是如何构造的。只要是系统能理解的数据类型,就应该是可用的。你应该注意到 def 函数和类不能被酸洗。因为最灵活和最受支持的数据类型是字符串,所以有必要将 Python 的数据结构导出为字符串。为此,Python 提供了pickle模块。PEP 3137 有一些关于 Guido 的字节类型和字符串的非常有趣的细节。

在现实世界中,腌制是保存食物的一种方式,因此可以储存很长时间,以后再食用。如果没有像腌制这样的保存技术,食物将不得不在生产出来后立即食用。对于数据来说也是如此:在数据产生后不久就可以很容易地消费掉,但是将它保存起来以备后用需要一些额外的工作。

通过使用pickle模块的dump()dumps()功能执行酸洗动作。这两个函数都可以将任何对象作为第一个参数,但是它们在输出表示该对象的字符串的位置上有所不同。在dump()的情况下,第二个必需的参数指定了一个可写的类似文件的对象,函数将用它作为 pickled 值的目的地。相比之下,dumps()函数只是直接返回字符串,让调用该函数的代码决定将它放在哪里。除此之外,这两个函数是相同的,本节其余部分的示例将使用dumps(),因为它更容易显示输出:

img/330715_3_En_6_Figr_HTML.jpg

>>> import pickle
>>> pickle.dumps(1)
b'\x80\x03K\x01.'
>>> pickle.dumps(42)
b'\x80\x03K*.'
>>> pickle.dumps('42')
b'\x80\x03X\x02\x00\x00\x0042q\x00.'

正如您所看到的,pickled 输出可以包含比原始 objects 值更多的信息,因为它也需要存储类型,所以对象可以在以后重新构造。

一旦对一个值进行了酸洗,就可以按照应用的要求存储或传递结果字符串。一旦需要将对象检索回 Python 中,pickle模块提供了两个附加函数,load()loads()。两者之间的区别类似于转储函数:load()接受一个可读的类似文件的对象,而loads()接受一个字符串:

img/330715_3_En_6_Figs_HTML.jpg

>>> pickled = pickle.dumps(42)
>>> pickled
b'\x80\x03K*.'
>>> pickle.loads(pickled)
42

然而,将对象转储到 pickled 字符串中并再次加载它们只是外部任务。与前面描述的许多协议一样,Python 允许单个对象控制它们如何被腌制和恢复。因为 pickledd 表示对象在被 pickle 时的一种快照,所以命名这些函数是为了引用对象在给定时间的状态。

要考虑的第一个方法是__getstate__(),它控制了包含在酸洗值中的内容。它不接受任何额外的参数,并返回 Python 应该包含在 pickled 输出中的任何值。对于复杂的对象,值通常是一个字典或者一个元组,但是完全由每个类来定义什么值与对象相关。

例如,货币转换类可能包含一个用作当前金额的数字以及一个指示所表示的货币的字符串。此外,它可能会访问当前汇率的字典,以便将金额转换为不同的货币。如果对字典引用放在对象本身上,Python 会把它放在一起:

img/330715_3_En_6_Figt_HTML.jpg

>>> class Money:
...     def __init__(self, amount, currency):
...         self.amount = amount
...         self.currency = currency
...         self.conversion = {'USD': 1, 'CAD': .95}
...     def __str__(self):
...         return '%.2f %s' % (self.amount, self.currency)
...     def __repr__(self):
...         return 'Money(%r, %r)' % (self.amount, self.currency)
...     def in_currency(self, currency):
...         ratio = self.conversion[currency] / self.conversion[self.currency]
...         return Money(self.amount * ratio, currency)
...
>>> us_dollar = Money(250, 'USD')
>>> us_dollar
Money(250, 'USD')
>>> us_dollar.in_currency('CAD')
Money(237.5, 'CAD')
>>> pickled = pickle.dumps(us_dollar)
>>> pickled
b'\x80\x03c__main__\nMoney\nq\x00)\x81q\x01}q\x02(X\x08\x00\x00\x00currencyq\x03
X\x03\x00\x00\x00USDq\x04X\x06\x00\x00\x00amountq\x05K\xfaX\n\x00\x00\x00convers
ionq\x06}q]\x07(h\x04Kx01X\x03\x00\x00\x00CADq\x08G?\xeeffffffuub.'

正如您所看到的,这已经是一个相当大的腌制值,而且字典中只存储了两种货币。因为货币转换值并不特定于手头的实例——而且它们会随时间变化——没有理由将它们存储在腌泡字符串中,所以我们可以使用__getstate__()来提供那些真正重要的值。

如果仔细观察现有的Money对象的输出,您会注意到属性名也被包含在内,因为 Python 不知道它们是否重要。代替来自__getstate__()的任何显式指令,它包括尽可能多的信息,以确保对象可以在以后被重新创建。因为我们已经知道只有两个值是必需的,所以我们可以将这两个值作为元组返回:

img/330715_3_En_6_Figu_HTML.jpg

>>> class Money:
...     def __init__(self, amount, currency):
...         self.amount = amount
...         self.currency = currency
...         self.conversion = {'USD': 1, 'CAD': .95}
...     def __str__(self):
...         return '%.2f %s' % (self.amount, self.currency)
...     def __repr__(self):
...         return 'Money(%r, %r)' % (self.amount, self.currency)

...     def __getstate__(self):

...         return self.amount, self.currency

...     def in_currency(self, currency):
...         ratio = self.conversion[currency] / self.conversion[self.currency]
...         return Money(self.amount * ratio, currency)
...
>>> us_dollar = Money(250, 'USD')
>>> us_dollar
Money(250, 'USD')
>>> us_dollar.in_currency('CAD')
Money(237.5, 'CAD')
>>> pickled = pickle.dumps(us_dollar)
>>> pickled
b'\x80\x03c__main__\nMoney\nq\x00)\x81q\x01K\xfaX\x03\x00\x00\x00USDq\x02\x86q\x
03b.'

正如你所看到的,这减少了腌制食物的产量,只有之前的三分之一多一点。除了效率更高之外,因为它不包含不必要的信息,所以更实用。其他应该避免被删除的属性是初始化值、特定于系统的详细信息以及其他与对象值简单相关而不是直接属于该值的瞬态信息。

然而,这只是等式的一半。一旦定制了对象的 pickled 输出,如果不定制这方面的内容,就无法将它检索回 Python 对象。毕竟,通过将值存储为元组,我们已经删除了 Python 用来重建对象的一些提示,所以我们必须提供一个替代方法。

你可能已经猜到了,__getstate__()的补码是__setstate__()__setstate__()方法只接受一个额外的参数:要恢复的对象的状态。因为__getstate__()可以返回任何表示状态的对象,所以没有特定的类型会被传入__setstate__()。然而,这一点也不随机;传递到__setstate__()的值将与从__getstate__()返回的值完全相同。

在我们的货币转换器中,状态由包含金额和货币的二元组表示:

img/330715_3_En_6_Figv_HTML.jpg

>>> class Money:
...     def __init__(self, amount, currency):
...         self.amount = amount
...         self.currency = currency
...         self.conversion = {'USD': 1, 'CAD': .95}
...     def __str__(self):
...         return '%.2f %s' % (self.amount, self.currency)
...     def __repr__(self):
...         return 'Money(%r, %r)' % (self.amount, self.currency)
...     def __getstate__(self):
...         return self.amount, self.currency

...     def __setstate__(self, state):

...         self.amount = state[0]

...         self.currency = state[1]

...     def in_currency(self, currency):
...         ratio = self.conversion[currency] / self.conversion[self.currency]
...         return Money(self.amount * ratio, currency)
...
>>> us_dollar = Money(250, 'USD')
>>> pickled = pickle.dumps(us_dollar)

>>> pickle.loads(pickled)

Money(250, 'USD')

有了这个,Money类现在可以完全控制它的值如何被删除和取消删除。这应该是结束了,对不对?为了保险起见,让我们再次测试这个in_currency()方法,因为这是它行为的一个重要方面:

img/330715_3_En_6_Figw_HTML.jpg

>>> us_dollar = pickle.loads(pickled)
>>> us_dollar
Money(250, 'USD')
>>> us_dollar.in_currency('CAD')
Traceback (most recent call last):
  ...
AttributeError: 'Money' object has no attribute 'conversion'

那么,为什么这没有奏效呢?当取消一个对象的拾取时,Python 不会一直调用__init__(),因为这一步应该只在设置新对象时发生。因为 pickled 对象在保存状态之前已经初始化过一次,所以再次尝试初始化它通常是错误的。相反,您可以在__setstate__()中包含类似的初始化行为,以确保一切都还在适当的位置:

img/330715_3_En_6_Figx_HTML.jpg

>>> class Money:
...     def __init__(self, amount, currency):
...         self.amount = amount
...         self.currency = currency

...         self.conversion = self.get_conversions()

...     def __str__(self):
...         return '%.2f %s' % (self.amount, self.currency)
...     def __repr__(self):
...         return 'Money(%r, %r)' % (self.amount, self.currency)
...     def __getstate__(self):
...         return self.amount, self.currency
...     def __setstate__(self, state):
...         self.amount = state[0]
...         self.currency = state[1]

...         self.conversion = self.get_conversions()

...     def get_conversions(self):

...         return {'USD': 1, 'CAD': .95}

...     def in_currency(self, currency):
...         ratio = self.conversion[currency] / self.conversion[self.currency]
...         return Money(self.amount * ratio, currency)
...
>>> us_dollar = Money(250, 'USD')
>>> pickled = pickle.dumps(us_dollar)
>>> pickle.loads(pickled)
Money(250, 'USD')

>>> us_dollar.in_currency('CAD')

Money(237.5, 'CAD')

当然,所有这些只有在复制一个要存储或发送给外部非 Python 消费者的对象时才有用。如果您需要做的只是在 Python 内部使用它,那么您可以简单地在内部复制该对象。

复制

可变对象有一个潜在的突出缺点:对一个对象的更改从对该对象的每个引用中都是可见的。由于 Python 引用对象的方式,所有可变对象都以这种方式工作,但这种行为并不总是最有用的。特别是,当处理作为参数传递给函数的对象时,调用该函数的代码通常希望对象保持不变。如果函数需要在工作过程中进行修改,您需要格外小心。

为了对一个对象进行更改而不在其他地方显示这些更改,您需要首先拷贝该对象。一些对象提供了开箱即用的机制。例如,列表支持切片,将列表中的项目检索到一个新列表中。该行为可用于一次获取所有项目,创建一个包含相同项目的新列表。只需省略起始值和结束值,切片就会自动复制列表:

img/330715_3_En_6_Figy_HTML.jpg

>>> a = [1, 2, 3]
>>> b = a[:]
>>> b
[1, 2, 3]
>>> b.append(4)
>>> b
[1, 2, 3, 4]
>>> a
[1, 2, 3]

类似地,字典有它们自己的方法来复制它们的内容,尽管不像列表那样使用语法。相反,字典提供了一个copy()方法,该方法返回一个具有所有相同键和值的新字典:

img/330715_3_En_6_Figz_HTML.jpg

>>> a = {1: 2, 3: 4}
>>> b = a.copy()
>>> b[5] = 6
>>> b
{1: 2, 3: 4, 5: 6}
>>> a
{1: 2, 3: 4}

并非所有对象都在内部包含这种类型的复制行为,但是 Python 允许您复制任何对象,即使它没有自己的复制机制。

浅显的副本

为了获得任意对象的副本,Python 提供了一个copy模块。该模块中可用的最简单的函数也被命名为copy(),它提供了与上一节中显示的技术相同的基本行为。不同之处在于,copy.copy()不是你想要复制的对象上的一个方法,而是允许你传入任何对象并得到它的一个浅层副本。您不仅可以复制更多种类的对象,而且无需了解对象本身的任何信息:

img/330715_3_En_6_Figaa_HTML.jpg

>>> import copy
>>> class Example:
...     def __init__(self, value):
...         self.value = value
...
>>> a = Example('spam')
>>> b = copy.copy(a)
>>> b.value = 'eggs'
>>> a.value
'spam'
>>> b.value
'eggs'

当然,这只是浅显的复制。记住从本章开始,一个对象实际上是三个部分的组合:一个身份,一个类型,一个值。当您复制一个对象时,您实际上是在创建一个具有相同类型的新对象,但具有新的标识和新的(但相同的)值。

对于可变对象,该值通常包含对其他对象的引用,例如列表中的项或字典中的键和值。复制对象的值可能有一个新的名称空间,但它包含所有相同的引用。因此,当您对复制对象的成员进行更改时,这些更改会反映在对该对象的所有其他引用中,就像任何其他名称空间一样。举例来说,考虑一个包含列表作为其值的字典:

img/330715_3_En_6_Figab_HTML.jpg

>>> a = {'a': [1, 2, 3], 'b': [4, 5, 6]}
>>> b = a.copy()
>>> a['a'].append(4)  #Copy to a and b
>>> b['b'].append(7)  #Copy to a and b
>>> a
{'a': [1, 2, 3, 4], 'b': [4, 5, 6, 7]}
>>> b
{'a': [1, 2, 3, 4], 'b': [4, 5, 6, 7]}

如您所见,该副本仅深入一层,因此被认为是“浅的”在对象自身的名称空间之外,只复制引用,而不是对象本身。这适用于所有类型的对象,不仅仅是这里显示的列表和字典。事实上,定制对象甚至可以通过提供一个__copy__()方法来定制这种行为。如果存在的话,copy()函数将不带参数地调用__copy__(),这样该方法就可以决定复制哪些值以及如何处理它们。

通常,当第一个图层是值中唯一需要更改的部分时,浅副本非常有用,特别是当保持对象的其余部分不变更有意义时。这种情况的基本示例是对列表进行排序,其中必须创建一个新列表来对项目进行排序,但是这些项目本身应该保持原样。

举例来说,考虑 Python 内置的sorted()方法的定制实现,它将项目排序到一个新的列表中,同时保持原来的列表不变:

img/330715_3_En_6_Figac_HTML.jpg

>>> def sorted(original_list, key=None):
...     copied_list = copy.copy(original_list)
...     copied_list.sort(key=key)
...     return copied_list
...
>>> a = [3, 2, 1]
>>> b = sorted(a)
>>> a
[3, 2, 1]
>>> b
[1, 2, 3]

当然,这仍然依赖于作为列表传入的对象,但是它说明了浅拷贝是多么有用。在其他情况下,你可能需要尽可能深地修改整个结构。

深层副本

为了解决一个特定的问题,算法经常需要在大的结构中重新组织数据。在这些更复杂的操作中,排序、索引、聚集和重新排列数据都是常见的任务。因为目标只是返回数据的一些分析,所以原始结构需要保持不变。我们需要一份比我们目前所检查的更深入的拷贝。

对于这些情况,Python 的copy模块还包含一个deepcopy()方法,它不仅复制原始结构,还复制它所引用的对象。事实上,它递归地在所有这些对象中查找任何其他对象,依次复制每个对象。这样,您可以随心所欲地修改副本,而不必担心修改原件或对原件的任何修改会反映在副本中:

img/330715_3_En_6_Figad_HTML.jpg

>>> original = [[1, 2, 3], [1, 2, 3]]
>>> shallow_copy = copy.copy(original)
>>> deep_copy = copy.deepcopy(original)
>>> original[0].append(4)
>>> shallow_copy
[[1, 2, 3, 4], [1, 2, 3]]
>>> deep_copy
[[1, 2, 3], [1, 2, 3]]

然而,这不是真正的递归,因为如果数据结构在任何时候都有对自身的引用,完全递归有时会导致无限循环。一旦复制了一个特定的对象,Python 就会记录下来,这样以后对同一对象的任何引用都可以简单地更改为引用新对象,而不是每次都创建一个全新的对象(deepcopy 函数)。

这不仅避免了递归地复制同一个对象,如果它是自身的成员的话;这也意味着,任何时候,同一个对象在结构中被多次发现,它将只被复制一次,并根据需要被多次引用。这意味着复制的结构在如何在被引用对象中反映更改方面将具有与原始结构相同的行为:

img/330715_3_En_6_Figae_HTML.jpg

>>> a = [1, 2, 3]
>>> b = [a, a]
>>> b
[[1, 2, 3], [1, 2, 3]]
>>> b[0].append(4)
>>> b
[[1, 2, 3, 4], [1, 2, 3, 4]]
>>> c = copy.deepcopy(b)
>>> c
[[1, 2, 3, 4], [1, 2, 3, 4]]
>>> c[0].append(5)
>>> c
[[1, 2, 3, 4, 5], [1, 2, 3, 4, 5]]

对于依赖于对象出现在结构的多个位置的算法来说,这是必须的。在这方面,每个副本的行为都与原始副本相同,因此不必担心在算法开始处理它之前它被复制了多少次。

深度拷贝可能带来的另一个问题是,Python 不知道什么是重要的,什么是不重要的,所以它复制了所有内容,这可能会远远超出您的需要。为了控制这种行为,自定义对象可以分别指定深层复制行为和浅层复制行为。

通过提供一个__deepcopy__()方法,一个对象可以指定哪些值与副本相关,就像__getstate__()如何为 pickle 工作一样。与__getstate__()以及__copy__()最大的不同在于__deepcopy__()还接受了第二个参数,这将是一个用于在复制过程中管理对象身份的字典。因为深度复制应该只复制每个对象一次,并且在使用该对象的任何其他时间使用引用,所以该标识命名空间提供了一种跟踪哪些对象确实相同的方法,因为它将它们的标识映射到对象本身。

令人兴奋的 Python 扩展:美丽的汤

Beautiful Soup 是处理 HTML 和 XML 文档的事实上的标准库。这是一个文件解析器或 screen-scraper,可以让您很好地控制文件的形状,以满足您的数据提取需求。在第五章中,你使用 Scrapy 进行网页抓取。你获得的文件可以很容易地用漂亮的汤清洗掉标记语言。这是一个很棒的库,可以与 Scrapy 等其他 Python 扩展结合使用。考虑一下,你会用 Scrapy 这样的工具获得数据,然后用漂亮的汤清洗它。Beautiful Soup 也有一些强大的搜索能力,但是让我们只关注解析能力。

安装美丽的汤

可从 https://www.crummy.com/software/BeautifulSoup 获得延期文件:

pip install beautifulsoup4 (Enter)

当然,对于其他操作系统,您可以使用适当的安装工具;以 Elementary 或 Ubuntu 为例,它会是 sudo apt-get 包名

使用美味的汤

通过从 Python 交互式提示符运行,确保您的安装首先正常工作:

from bs4 import BeautifulSoup (Enter)

如果没有出现错误,则安装您的库。如果您收到错误,请检查您没有安装其他 Python,比如 Anaconda 或路径问题。

作为美丽的汤的力量的一个例子,我们将使用 Scrapy 获取第五章中的 HTML 文件,并清理它,使它仅仅是一个文本文件,去掉了标记标签。这将创建一个更适合数据分析的文件,例如搜索关键词或事件。键入并运行以下代码,将我们在上一章创建的 quotes.html 文件放在同一个文件夹中,您将看到原始的 HTML 输出和经过美化的漂亮汤输出:

from bs4 import BeautifulSoup

path='quotes-1.html'

filedata=open(path,'r',errors='ignore')
page=filedata.read()

soup = BeautifulSoup(page, 'lxml')
print(soup.prettify()) # show raw HTML markup

print('\n\nAnd a cleaner version:\n')
print(soup.get_text()) # return plain text only

您应该看到的是原始的 HTML 文本,然后是通过 Beautiful Soup 清理的版本。注意,留下了一些额外的数据(但不多),我们无法通过循环结构来清理它们。接下来,让我们只搜索带有 HTML 'span '标签的项目,统计出现次数,并只打印那些选中项目的清晰输出:

from bs4 import BeautifulSoup

path='quotes-1.html'

filedata=open(path,'r',errors='ignore')
page=filedata.read()

soup = BeautifulSoup(page, 'lxml')

print('\nWe found this many span tags:  ',len(soup.find_all('span')))

print('\n\nShow only span tag items\n\n')

print(soup.find_all('span'))
print('------------------')
print('\nNow clean up the span tags\n\n')
for item in soup.find_all('span'):
    print(item.text)

在最后一个例子中,我们搜索了一个标签,然后用一个增强的代替来打印单个项目,标签通过 item.text 删除。当然,但是这应该作为一个很好的起点来进行更多的实验。

带着它

每个应用都知道如何在基本层面上处理对象,但是使用本章介绍的技术,您将能够继续管理大量的对象集合,跨越各种不同的类型。在下一章中,我们将从宏观层面的对象转移到微观层面的一种特定类型的对象:不起眼的字符串。

七、字符串

考虑到字符串在所有编程形式中的基本性质,Python 的字符串特性可以占据整个章节也就不足为奇了。无论是通过键盘输入的方式与用户互动,通过网络发送内容,分析大数据,还是参与图灵测试,字符串可以用于许多应用。

在强调字符串的同时,Python 确保包含各种各样的特性来支持它们。其中一些特性内置于 string 对象本身,而其他特性则由标准库中的模块提供,许多第三方库(如 Boost)甚至提供了更多选项。然而,本章将关注 Python 内置的字符串函数,而不是研究第三方应用。

关于 Python 字符串,首先要理解的是,实际上有两种不同的格式需要考虑:字节和 Unicode 字符串。让我们首先检查字节。

字节

在非常基本的层面上,字符串实际上只是一系列单独的字节。在这种一般意义上,字节用于计算机处理的每一段数据。数字、字符串和更复杂的对象在某种程度上都是以字节存储的,任何更结构化的东西都是建立在字节序列之上的。在一个字节串中,在 Python 中由一个bytes对象表示,每个字符恰好表示一个字节,因此很容易与文件和其他外部接口进行交互。

标准字符串(稍后在文本部分描述)被简单地用一对直单引号('example')标识为文字,而字节字符串文字在第一个引号前包含一个b。这用于源代码以及这些值的repr()输出:

img/330715_3_En_7_Figa_HTML.jpg

>>> b'example' #the keyword print is assumed since it is a command prompt statement versus a script.
b'example'

bytes的主要用途是传递非文本信息,比如数字、日期、标志集和许多其他东西。尽管 Python 并不直接知道如何处理这些特定的值,但是一个bytes对象将确保它们不变地通过,这样您自己的代码就可以适当地处理每种情况。没有任何关于数据意图的假设,bytes为您提供了最大的灵活性,但这意味着您将需要某种方式在字节和对您的应用更有意义的东西之间来回转换数据。

简单转换:chr()和 ord()

在基本层面上,一个字节实际上只是一个数字,恰好用某种字符来表示。Python 认为数字和字符是两种不同的东西,但是它们的值是等价的,所以它们之间的转换相当容易。给定一个字节,您可以将它传递给内置的ord(#)函数,该函数将返回其等价的整数值:

img/330715_3_En_7_Figb_HTML.jpg

>>> ord(b'A')
65
>>> ord(b'!')
33
>>> list(b'Example')
[69, 120, 97, 109, 112, 108, 101]

注意迭代一个字节序列时会发生什么。而不是一个字符的字节字符串,您实际上立即获得原始整数,完全消除了对ord()的需要。这在将单字节值从字节转换为数字时很有效,但是反过来需要内置的chr()函数。与ord()相反,它基于您传入的整数值返回单个字符:

img/330715_3_En_7_Figc_HTML.jpg

>>> chr(65)
'A'
>>> chr(33)
'!'
>>> [chr(o) for o in [69, 120, 97, 109, 112, 108, 101]]
['E', 'x', 'a', 'm', 'p', 'l', 'e']
>>> “.join(chr(o) for o in [69, 120, 97, 109, 112, 108, 101])
'Example'

这里有一件重要的事情需要注意:由chr()返回的字符串是一个常规字符串,而不是一个字节字符串,因为缺少前缀b。正如你将在本章后面的文本部分看到的,标准字符串的工作方式与字节字符串略有不同。然而,对于我们的目的来说,最大的问题是标准字符串并不总是直接等同于单个字节,所以有可能出错。为了让事情更可靠地工作,并在其上获得一些额外的特性,我们可以使用struct模块。

复杂转换:结构模块

除了chr()返回标准字符串的问题外,ord()/chr()组合的一个大问题是它只能在处理单个字节时可靠地使用。当把数字转换成字节时,它被限制在 0 到 255 之间。为了支持更广泛的值和一些其他有趣的特性,Python 提供了struct模块。

类似于chr()ord()如何表示一对在字节字符串和原生 Python 值之间转换的值,struct.pack()写出字节字符串,而struct.unpack()将这些值读回 Python。然而,与那些更简单的函数不同,struct模块使用一个格式字符串来指定值应该如何被转换。这种格式有自己的简单语法来控制使用什么类型的值以及它们如何工作。

因为我们来struct是为了克服chr()的一些困难,我们将从看看struct.pack()如何提供预期的功能开始。用于单个无符号字节的格式是B,下面是您在实践中如何使用它:

img/330715_3_En_7_Figd_HTML.jpg

>>> import struct
>>> struct.pack(b'B', 65)
b'A'
>>> struct.pack(b'B', 33)
b'!'
>>> struct.pack(b'BBBBBBB', 69, 120, 97, 109, 112, 108, 101)
b'Example'

如您所见,第一个参数是格式字符串本身,每个参数对应一个字符,应该转换为字节字符串。所有附加参数都用于提供应该转换的值。因此,对于每个格式说明符,您需要在等效的位置包含一个参数。

如上所述,B指定了一个无符号值,这意味着不能有负值。这样,您可以提供从 0 到 255 的值,但不能低于 0。相比之下,有符号值通过使用字节中八位中的一位来标识值是正还是负,从而允许负值。仍然有 256 个唯一的值,但是范围被移动了一点,使得一半的值在符号的每一侧。0 被视为正值,有符号字节可以包含从–128 到 127 的值。为了补充无符号字节,有符号字节的格式说明符是b:

img/330715_3_En_7_Fige_HTML.jpg

>>> struct.pack(b'b', 65)
b'A'
>>> struct.pack(b'Bb', 65, -23)
b'A\xe9'
>>> struct.pack(b'B', 130)
b'\x82'
>>> struct.pack(b'b', 130)
Traceback (most recent call last):
  ...
struct.error: byte format requires -128 <= number <= 127

当然,Bb只对单字节值有效,限制为 256 个总值。为了支持更大的数字,您可以将Hh用于双字节数字,最多允许 65,536 个值。就像单字节选项一样,大写格式假定一个无符号值,而小写格式假定一个有符号值:

img/330715_3_En_7_Figf_HTML.jpg

>>> struct.pack(b'Hh', 42, -137)
b'*\x00w\xff'

既然单个值可以跨越多个字节,那么接下来的问题是哪个字节先出现。两个字节中的一个包含 256 个最小值,而另一个包含值 0 到 256,但乘以 256。因此,将两者混为一谈会极大地影响存储或检索的值。快速看一下反函数就很容易明白这一点,struct.unpack():

img/330715_3_En_7_Figg_HTML.jpg

>>> struct.unpack(b'H', b'*\x00')
(42,)
>>> struct.unpack(b'H', b'\x00*')
(10752,)

如你所见,struct.unpack()的函数调用看起来与struct.pack()非常相似,但是有一些显著的不同。首先,unpack()总是只有两个参数,因为第二个参数是原始的字节字符串。这个字符串可以包含多个要提取的值,但是它仍然作为一个参数传递,不像pack()

相反,返回值是一个元组,可以包含多个值。所以,struct.unpack()struct.pack()的真逆;也就是说,您可以将结果从一个传递到对另一个的调用中,并获得与第一次传递的值相同的值。您只需要确保在每个单独的函数调用中使用相同的格式字符串:

img/330715_3_En_7_Figh_HTML.jpg

>>> struct.unpack(b'Hh', struct.pack(b'Hh', 42, -42))
(42, -42)
>>> struct.pack(b'Hh', *struct.unpack(b'Hh', b'*\x00\x00*'))
b'*\x00\x00*'

那么跨越多个字节的值有什么问题呢?毕竟,这些例子表明,值可以转换成字符串,也可以转换回来,而不用担心这些字符串是如何创建或解析的。不幸的是,这很容易,因为我们目前只在 Python 中工作,它有一个与自身一致的实现。如果您必须处理字符串,比如文件内容,而这些字符串需要在其他应用中使用,那么您需要确保与这些应用所期望的相匹配。

因此,struct格式也允许您显式地指定一个值的字节序。Endianness 是描述一个值的字节如何排序的术语;在一个大端值中,最重要的字节——提供数字最大部分的字节——首先被存储。对于小端值,首先存储最低有效字节。

为了区分这两者,格式规范可以带前缀。如果在格式前放置一个<,可以显式声明它是 little-endian。相反,使用>会将其标记为 big-endian。如果两个选项都没有提供,就像前面的例子一样,默认行为是使用与 Python 在其中执行的系统相同的字节顺序,这在现代系统中通常是小字节顺序。这允许您控制处理pack()unpack()值的方式,涵盖转换过程的两个方面:

img/330715_3_En_7_Figi_HTML.jpg

>>> struct.pack(b'<H', 42)
b'*\x00'
>>> struct.pack(b'>H', 42)
b'\x00*'
>>> struct.unpack(b'<H', b'*\x00')
(42,)
>>> struct.unpack(b'>H', b'*\x00')
(10752,)

既然可以控制多字节数的排序,那么使用更大的值就更容易了。除了前面讨论的单字节和双字节整数之外,struct还支持使用Ii的四字节值,而八字节值可以使用Qq来指定。与其他字母一样,大写字母表示无符号值,而小写字母表示有符号值。

然而,struct模块不仅仅是整数的转换。您还可以使用f格式来转换浮点值,或者甚至使用b格式来获得更高的精度。事实上,您也可以使用struct来处理字符串内部的字符串,这给了您额外的灵活性。使用s格式代码,结合数字前缀,来表示要读取或写入的字符串的大小:

img/330715_3_En_7_Figj_HTML.jpg

>>> struct.pack(b'7s', b'example')
b'example'
>>> struct.unpack(b'7s', b'example')
(b'example',)
>>> struct.pack(b'10s', b'example')
b'example\x00\x00\x00'

如您所见,pack()将添加空字节来填充尽可能多的字节,以匹配格式中提供的前缀。但是为什么要用struct把字符串变成字符串呢?好处是可以一次打包和解包多个值,所以字符串可能只是结构的一部分。考虑一个包含个人联系信息的简单字节字符串:

img/330715_3_En_7_Figk_HTML.jpg

>>>import struct
>>>first_name = 'Marty'
>>> last_name = 'Alchin'
>>> age = 28
>>> struct.pack(b'10s10sB', bytes(first_name, 'utf8'), bytes(last_name, 'utf8'), age)
>>> data
b'Alchin\x00\x00\x00\x00Marty\x00\x00\x00\x00\x00\x1c'

但是,如果您希望以这种方式处理字符串,那么您更有可能处理文本,在文本中,字符串作为一个整体有意义,而不是它的字符是一些其他类型的值的转换。在 Python 3.2 中,实现这一点的格式有所改变,所以现在必须将字符串(str)文本编码为字节字符串,通常是 utf8 编码。

文本

从概念上讲,文本是书面文字的集合。这是一个早在计算机出现之前就存在的语言学概念,但是一旦人们清楚地认识到计算机需要处理文本,就有必要确定如何在一个为数字设计的系统中表示文本。当编程还很年轻的时候,文本被限制为一组被称为美国信息交换标准码(ASCII)或 EBCDIC 或其他的字符。

注意提到“美国人”;这套 127 个字符(其中只有 95 个可打印)旨在满足英语语言的需求。ASCII 只覆盖每个字节的 7 位,所以有一些潜在的未来扩展空间,但即使再多 128 个值也不够。一些应用采用特殊的技巧,通过添加重音符号和其他标记来传达额外的字母,但该标准在范围上仍然非常有限。

统一码

为了解决这一限制,Unicode 标准作为一种替代标准出现了,它可以包含世界上绝大多数语言中使用的大多数字符。为了让 Unicode 支持它需要的尽可能多的代码点,每个代码点需要占用一个以上的字节,这与 ASCII 不同。当加载到内存中时,这不是问题,因为它只在 Python 中使用,Python 只有一种管理这些多字节值的方法。

注意

Unicode 标准实际上是由一百多万个单独的“代码点”而不是字符组成的。码位是一个数字,代表书写文本的某个方面,可以是常规字符、符号或修饰符,如重音字符。在引入 Unicode 之前,为了与使用中的系统兼容,有些字符甚至出现在多个代码点上。

默认情况下,Python 中的所有标准字符串都是 Unicode 的,在这个过程中支持多种语言。上一节中显示的字节字符串都需要使用一个b前缀,以区别于标准的 Unicode 字符串。

当将这些值写出到其他系统可以读取的字符串时,问题就来了,因为不是所有系统都使用相同的 Unicode 字符串内部表示。相反,有几种不同的编码可用于将 Unicode 字符串压缩成一系列字节,以便存储或分发。

编码

就像可以用多个字节来存储一个大于一个字节的数字一样,Unicode 文本可以用多字节格式存储。然而,与数字不同,文本通常包含大量的单个字符,因此将每个字符存储为最多四个字节意味着一段很长的文本可能会比它看起来要大得多。

为了尽可能高效地支持文本,很快就发现并非所有文本都需要完整的可用字符。例如,这本书是用英语写的,这意味着它的大部分内容都在 ASCII 范围内。因此,它的大部分可以从每个字符四个字节减少到只有一个字节。

ASCII 是文本编码的一个例子。在这种特殊情况下,一小部分可用字符被映射到从 0 到 127 的特定值。选择的字符旨在支持英语,因此它包含所有可用的大写和小写字母变体,所有 10 个数字和各种标点符号选项。任何只包含这些值的文本都可以使用 ASCII 编码转换成字节。

编码过程本身使用字符串的encode()方法来管理。只需传入编码的名称,它将返回一个表示给定编码中文本的字节字符串。在 ASCII 的情况下,字节字符串的表示看起来就像输入文本,因为每个字节正好映射到一个字符:

img/330715_3_En_7_Figl_HTML.jpg

>>> 'This is an example, with punctuation and UPPERCASE.'.encode('ascii')
b'This is an example, with punctuation and UPPERCASE.'

通过将每个字节映射到单个字符,ASCII 非常有效,但只有当源文本包含编码中指定的那些字符时,它才有效。必须做出某些假设,以确定哪些字符足够重要,可以包含在这么小的范围内。其他语言有自己的优先字符,所以它们使用不同的编码,以便像英语中的 ASCII 一样有效(UTF 8 是最流行的)。

包括汉语和日语在内的一些语言有太多的字符,以至于无法用一个字节来表示它们。这些语言的一些编码对每个字符使用两个字节,进一步突出了各种文本编码的差异。因此,为特定语言设计的编码通常不能用于该语言之外的文本。

为了解决这个问题,有一些更通用的 Unicode 编码。由于可用字符的数量庞大,这些编码使用可变长度的方法。在最常见的 UTF-8 中,一定范围内的字符可以用一个字节来表示。其他字符需要两个字节,而还有一些字符可以使用三个甚至四个字节。UTF-8 是理想的,因为它展示了一些特殊的特性:

  • 它可以支持任何可用的 Unicode 码位,即使它在实际文本中并不常见。这个特性并不是 UTF-8 独有的,但它确实使它有别于其他特定语言的编码,比如 ASCII。

  • 实际使用的字符越常见,其代码点占用的空间就越少。例如,在大部分是英文文档的集合中,UTF 8 几乎可以和 ASCII 一样有效。即使在对非英语文本进行编码时,大多数语言也共享某些通用字符,如空格和标点符号,这些字符可以用一个字节进行编码。当它必须使用两个字节时,它仍然比内存中的 Unicode 对象更有效。

  • 单字节范围与 ASCII 标准完全一致,使得 UTF-8 完全向后兼容 ASCII 文本。所有 ASCII 文本都可以不加修改地读作 UTF-8。同样,只包含 ASCII 中可用字符的文本可以使用 UTF-8 编码,并且仍然可以由只理解 ASCII 的应用访问。

由于这些原因,以及其他原因,UTF-8 已经成为需要支持多种语言的应用或在设计时不知道应用语言的应用的一种非常常见的编码(自 2008 年以来)。这似乎是一种奇怪的情况,但是在框架、库和其他大规模应用中经常出现。它们可以部署在地球上的任何环境中,所以它们应该尽可能地支持其他语言。第八章将更详细地描述应用支持多种语言的步骤。

根据应用的需要、使用的编码和传入的文本,使用错误编码或解码的后果会有所不同。例如,可以使用 UTF-8 毫无问题地解码 ASCII 文本,生成完全有效的 Unicode 字符串。反转这个过程并不总是那么容易,因为 Unicode 字符串可能包含有效 ASCII 范围之外的代码点:

img/330715_3_En_7_Figm_HTML.jpg

>>> ascii = 'This is a test'.encode('ascii')
>>> ascii
b'This is a test'
>>> ascii.decode('utf-8')
'This is a test'
>>> unicode = 'This is a test: \u20ac'  # A manually encoded Euro symbol
>>> unicode.encode('utf-8')
b'This is a test: \xe2\x82\xac'
>>> unicode.encode('ascii')
Traceback (most recent call last):
  ...
UnicodeEncodeError: 'ascii' codec can't encode character '\u20ac' in position 16
: ordinal not in range(128)

在其他时候,文本似乎可以正确地编码或解码,但结果却是乱码。然而,通常情况下,当升级应用以包含正确的 Unicode 支持,但现有数据的编码不一致时,就会出现这样的问题。从头开始构建 Unicode 应用并不能完全消除出现这些问题的可能性,但它有助于避免这些问题。

简单替换

有不同的方法来产生一个字符串,其中的信息只在运行时可用。也许最明显的方法是使用+操作符将多个字符串连接在一起,但是只有当所有的值都是字符串时才有效。Python 不会隐式地将其他值转换为要连接的字符串,所以您必须显式地转换它们,例如,首先将它们传递给str()函数。

作为替代,Python 字符串也支持将对象注入字符串的方式。它使用字符串中的占位符来表示对象应该放在哪里,以及应该填充它们的对象集合。这被称为字符串替换,使用%操作符,使用自定义的__mod__()方法来执行,如第五章所述。

占位符由一个百分号和一个转换格式组成,它们之间可以有一些修饰符来指定如何进行转换。这种方案允许字符串指定如何转换对象,而不必显式调用单独的函数。这些格式中最常见的是%s,相当于直接使用str()函数:

img/330715_3_En_7_Fign_HTML.jpg

>>> 'This object is %s' % 1
'This object is 1'
>>> 'This object is %s' % object()
'This object is <object object at 0x...>'

因为这相当于直接调用str(),所以放入字符串的值是调用对象的__str__()方法的结果。类似地,如果在替换字符串中使用%r占位符,Python 将调用对象的__repr__()方法。例如,这对于记录函数的参数非常有用。尝试将下一个示例作为脚本:

img/330715_3_En_7_Figo_HTML.jpg

def func(*args):
    for i, arg in enumerate(args):
        print('Argument %s: %r' % (i, arg))

func('example', {}, [1, 2, 3], object())
Your output will look like the following:
Argument 0: 'example'
Argument 1: {}
Argument 2: [1, 2, 3]
Argument 3: <object object at 0x...>

此示例还说明了如何通过将多个值包装在一个元组中来一次将它们放入字符串中。它们根据位置与字符串中的对应对象匹配,所以第一个对象放在第一个占位符中,依此类推。不幸的是,如果不小心的话,这个特性有时也会成为绊脚石。最常见的错误发生在试图将元组注入替换字符串时:

img/330715_3_En_7_Figp_HTML.jpg

>>> def log(*args):
...     print('Logging arguments: %r' % args)
...
>>> log('test')
"Logging arguments: 'test'"
>>> log('test', 'ing')
Traceback (most recent call last):
  ...
TypeError: not all arguments converted during string formatting

这里发生的事情是,Python 并不区分在源代码中这样编写的元组和仅仅从其他地方传递过来的元组。所以,字符串替换没有办法知道你的意图是什么。在这个例子中,只要只传入一个参数,替换就可以正常工作,因为字符串中只有一个占位符。一旦你传入一个以上的参数,它就会中断。

为了解决这个问题,您需要构建一个单项式元组来包含要放在字符串中的元组。这样,字符串替换总是获得一个元组,其中包含一个要放在单个占位符中的元组:

img/330715_3_En_7_Figq_HTML.jpg

>>> def log(*args):
...     print('Logging arguments: %r' % (args,))
...
>>> log('test')
"Logging arguments: ('test',)"
>>> log('test', 'ing')
"Logging arguments: ('test', 'ing')"

解决了元组的情况后,值得注意的是对象也可以通过关键字插入。这样做需要替换字符串包含括号中的关键字,紧跟在百分号之后。然后,要传入要注入的值,只需传入一个对象字典,而不是一个元组:

img/330715_3_En_7_Figr_HTML.jpg

>>> def log(*args):
...     for i, arg in enumerate(args):
...         print('Argument %(i)s: %(arg)r' % {'i': i, 'arg': arg})
...
>>> log('test')
Argument 0: 'test'
>>> log('test', 'ing')
Argument 0: 'test'
Argument 1: 'ing'

除了能够更容易地重新排列替换字符串中的占位符之外,此功能还允许您只包含那些重要的值。如果字典中的值多于字符串中需要的值,那么可以只引用需要的值。Python 会简单地忽略字符串中没有提到名字的任何值。这与 positional 选项相反,在 positional 选项中,提供比您在字符串中标记的更多的值将导致一个TypeError

格式化

对于上一节中描述的简单字符串替换,Python 还包括一个健壮的字符串格式化系统。字符串格式化不是依赖不太明显的操作符,而是对字符串使用显式的format()方法。此外,格式化字符串使用的语法与以前简单替换中使用的语法有很大不同。

format()不使用百分号和格式代码,而是希望它的占位符用花括号括起来。这些大括号中的内容取决于您打算如何传入这些值以及它们应该如何格式化。占位符的第一部分确定它应该查找位置参数还是关键字参数。对于位置参数,内容是一个数字,表示要使用的值的索引,而对于关键字参数,则提供引用适当值的键:

img/330715_3_En_7_Figs_HTML.jpg

>>> 'This is argument 0: {0}'.format('test')
'This is argument 0: test'
>>> 'This is argument key: {key}'.format(key='value')
'This is argument key: value'

这可能看起来很像旧的替代技术,但它已经有一个主要的优势。因为格式化是通过方法调用而不是运算符启动的,所以可以同时指定位置参数和关键字参数。这样,如果需要,您可以在格式字符串中混合和匹配索引和键,以任何顺序引用它们。

额外的好处是,这也意味着不是所有的位置参数都需要在字符串中引用才能正常工作。如果你提供的比你需要的多,format()会忽略任何没有占位符的东西。这使得将一个格式字符串传递给一个应用变得容易得多,该应用稍后将调用它的format(),参数可能来自另一个来源。一个这样的例子是在定制期间接受错误消息的可定制验证功能:

img/330715_3_En_7_Figt_HTML.jpg

>>> def exact_match(expected, error):
...     def validator(value):
...         if value != expected:
...             raise ValueError(error.format(value, expected))
...     return validator
...
>>> validate_zero = exact_match(0, 'Expected {1}, got {0}')
>>> validate_zero(0)
>>> validate_zero(1)
Traceback (most recent call last):
  ...
ValueError: Expected 0, got 1
>>> validate_zero = exact_match(0, '{0} != {1}')
>>> validate_zero(1)
Traceback (most recent call last):
  ...
ValueError: 1 != 0
>>> validate_zero = exact_match(0, '{0} is not the right value')
>>> validate_zero(1)
Traceback (most recent call last):
  ...
ValueError: 1 is not the right value

正如您所看到的,这个特性允许 validator 函数使用当时所有可用的信息来调用format(),让格式字符串来决定如何布局。对于另一个字符串替换,您将被迫使用关键字来实现相同的效果,因为位置参数的工作方式不同。

在对象中查找值

除了能够引用传入的对象之外,格式字符串语法还允许您具体引用这些对象的一部分。其语法看起来很像常规 Python 代码。要引用属性,请用句点将属性名称与对象引用分开。若要使用索引值或关键字值,请在方括号内提供索引或关键字。只是不要在关键字周围使用引号:

img/330715_3_En_7_Figu_HTML.jpg

>>> import datetime
>>> def format_time(time):
...     return '{0.minute} past {0.hour}'.format(time)
...
>>> format_time(datetime.time(8, 10))
'10 past 8'
>>> '{0[spam]}'.format({'spam': 'eggs'})
'eggs'

区分字符串的类型

您可能还记得,简单的替换需要您指定%s%r来指示是使用__str__()方法还是__repr__()方法来将对象转换为字符串,而到目前为止给出的示例还没有包含这样的提示。默认情况下,format()将使用__str__(),但是这个行为仍然可以作为格式字符串的一部分来控制。紧跟在对象引用之后,简单地包括一个感叹号,后跟sr:

img/330715_3_En_7_Figv_HTML.jpg

>>> validate_test = exact_match('test', 'Expected {1!r}, got {0!r}')
>>> validate_test('invalid')
Traceback (most recent call last):
  ...
ValueError: Expected 'test', got 'invalid'

标准格式规范

这种新的字符串格式与以前的替换特性的真正区别在于格式化对象输出的灵活性。在前面提到的字段引用和字符串类型之后,可以包含一个冒号,后跟一个控制被引用对象格式的字符串。这种格式规范有一个标准语法,通常适用于大多数对象。

第一个选项控制输出字符串的对齐方式,当您需要指定要输出的最小字符数时,会使用该选项。提供一个左尖括号(<)产生一个左对齐的值;右尖括号(>)向右对齐;并且插入符号(^)将值居中。总宽度可以指定为一个数字:

img/330715_3_En_7_Figw_HTML.jpg

>>> import os.path
>>> '{0:>20}{1}'.format(*os.path.splitext('contents.txt'))
'            contents.txt'
>>> for filename in ['contents.txt', 'chapter.txt', 'index.txt']:
...     print('{0:<10}{1}'.format(*os.path.splitext(filename)))
...
contents  .txt
chapter   .txt
index     .txt

请注意,长度规范的默认行为是用空格填充输出,以达到必要的长度。这也可以通过在对齐说明符前插入不同的字符来控制。例如,一些纯文本文档格式希望标题在等号或连字符的长度内居中。使用字符串格式很容易做到这一点:

img/330715_3_En_7_Figx_HTML.jpg

>>> def heading(text):
...     return '{0:=⁴⁰}'.format(text)
...
>>> heading('Standard Format Specification')
'=====Standard Format Specification======'
>>> heading('This is a longer heading, beyond 40 characters')
'This is a longer heading, beyond 40 characters'

这里的第二个调用演示了长度格式的一个重要属性;如果参数字符串长于指定的长度,format()将延长输出以匹配,而不是截断文本。然而,这给 heading 示例带来了一点问题,因为如果输入太长,输出根本不包含任何填充字符。这可以通过在字符串的开头和结尾各显式添加一个字符,并将占位符的长度减少两个来弥补:

img/330715_3_En_7_Figy_HTML.jpg

>>> def heading(text):
...     return '={0:=³⁸}='.format(text)
...
>>> heading('Standard Format Specification')
'=====Standard Format Specification======'
>>> heading('This is a longer heading, beyond 40 characters')
'=This is a longer heading, beyond 40 characters='

现在,标题将始终至少有 40 个字符宽,但在文本的每一边至少有一个等号,即使它很长。不幸的是,现在这样做需要在格式字符串中写三次等号,一旦我们考虑到有时填充字符将是连字符,这就变得有点麻烦了。

解决这个问题的一部分很简单:因为我们显式地对占位符进行编号,所以我们可以将填充字符作为一个参数传入,并在格式字符串中引用该参数两次;一次在开始,一次在结束。然而,仅仅这样并不能真正解决问题,因为它没有触及核心问题:如何替换文本的部分参数引用。

为了解决这个问题,格式规范还允许嵌套参数引用。在文本部分的占位符内,我们可以在为填充字符保留的位置添加另一个占位符;Python 会先评估一个,然后再评估另一个。当我们这样做时,这也允许我们控制输出将填充多少个字符:

img/330715_3_En_7_Figz_HTML.jpg

>>> def heading(text, padding='=', width=40):
...     return '{1}{0:{1}^{2}}{1}'.format(text, padding, width - 2)
...
>>> heading('Standard Format Specification')
'=====Standard Format Specification======'
>>> heading('This is a longer heading, beyond 40 characters')
'=This is a longer heading, beyond 40 characters='
>>> heading('Standard Format Specification', padding='-', width=60)
'---------------Standard Format Specification----------------'

示例:纯文本目录

虽然文档有多种形式,但纯文本可能是最常见的,因为它不需要任何额外的软件来查看。然而,由于缺少目录的链接或页码,浏览大块文档可能会很困难。可以使用行号来代替页码,但是维护一个格式正确的目录表仍然很繁琐。

考虑一个典型的目录,其中一节的标题是左对齐的,页码或行号是右对齐的,这两者由一行句点连接,以帮助将视线从一个引导到另一个。从这种格式中添加或删除行很简单,但是每次更改一个部分的名称或位置时,不仅要更改相关信息;您还需要更新中间的周期线,这不太理想。

字符串格式在这里很方便,因为您可以为一个字符串中的多个值指定对齐和填充选项。这样,您就可以设置一个简单的脚本来自动格式化目录。然而,做到这一点的关键是要意识到你在做什么。

从表面上看,目标就像前面提到的那样:左对齐部分标题,右对齐行号,并在两者之间放置一行句点。不幸的是,我们没有办法做到这一点,所以我们需要从不同的角度来看待它。通过让字符串的每一部分负责填充的一部分,很容易达到预期的效果:

img/330715_3_En_7_Figaa_HTML.jpg

>>> '{0:.<50}'.format('Example')
'Example...........................................'
>>> '{0:.<50}'.format('Longer Example')
'Longer Example....................................'
>>> '{0:.>10}'.format(20)
'........20'
>>> '{0:.>10}'.format(1138)
'......1138'

有了这两个部分,只需要将它们组合起来,就可以在目录中创建一个完整的行。许多纯文本文档被限制在一行 80 个字符以内,所以我们可以将其扩展一点,为更长的标题提供一些喘息的空间。此外,即使在非常长的文档中,10 位数字的行号也有点多,因此可以减少,以便为标题留出更多空间:

img/330715_3_En_7_Figab_HTML.jpg

>>> def contents_line(title, line_number=1):
...     return '{0:.<70}{1:.>5}'.format(title, line_number)
...
>>> contents_line('Installation', 20)
'Installation...........................................................20'
>>> contents_line('Usage', 112)
'Usage.................................................................112'

然而,从长远来看,一次一行地调用这个函数并不是一个现实的解决方案,所以我们将创建一个新的函数,它可以接受更有用的数据结构。它不需要很复杂,所以我们将只使用一个二元组序列,每个二元组由一个节标题及其对应的行号组成:

img/330715_3_En_7_Figac_HTML.jpg

>>> contents = (('Installation', 20), ('Usage', 112))

>>> def format_contents(contents):
...     for title, line_number in contents:
...         yield '{0:.<70}{1:.>5}'.format(title, line_number)
...
>>> for line in format_contents(contents):
...     print(line)
...
Installation.............................................................20
Usage...................................................................112

自定义格式规范

然而,新格式系统的真正优势在于,format()实际上并不控制前一节中描述的格式语法。与第四章中描述的许多特性一样,它将控制权委托给作为参数传入的对象上的一个方法。

这个方法__format__()接受一个参数,这个参数是写入放置对象的格式字符串中的格式规范。然而,它并没有得到整个括号中的表达式,只是得到了冒号后面的部分。这对于所有对象都是如此,正如您通过在一个全新的object实例上直接调用它所看到的。从 Python 3.3 及更高版本开始,这种格式发生了变化,因此在尝试下一个示例之前,请确保您使用的是 Python 3.3 或更高版本:

img/330715_3_En_7_Figad_HTML.jpg

>>> object().__format__(")

'=====<object object at 0x0209F158>======'

因此,上一节描述的标准格式规范选项并不是唯一的方法。如果您有自定义需求,可以通过替换您正在使用的类上的方法来覆盖该行为。您可以扩展现有的行为,或者编写一个全新的行为。

例如,您可以用一个类来表示一个动词,它可以有现在时或过去时。这个Verb类可以用一个单词实例化,用于每种时态,然后在表达式中使用,形成完整的句子:

img/330715_3_En_7_Figae_HTML.jpg

>>> class Verb:

...     def __init__(self, present, past=None):
...         self.present = present
...         self.past = past
...     def __format__(self, tense):
...         if tense == 'past':
...             return self.past
...         else:
...             return self.present
...
>>> format = Verb('format', past="formatted")
>>> message = 'You can {0:present} strings with {0:past} objects.'
>>> message.format(format)
'You can format strings with formatted objects.'
>>> save = Verb('save', past="saved")
>>> message.format(save)
'You can save strings with saved objects.'

在这个例子中,占位符字符串无法知道如何格式化一个过去时态的动词,所以它将这个责任委托给传入的动词。这样,字符串可以一次编写,并与不同的动词一起多次使用,而不会跳过一个节拍。

令人兴奋的 Python 扩展

feed parser(feed parser)

RSS 提要(Rich Site Summary)是博客、新闻和媒体等信息的已发布提要。也称为提要、web 提要或频道,它们可以包括摘要信息或标题。可以说,它们是在信息过载的世界中保持最新状态的第一步。Python feedparse r 库处理包括 Atom、RDF 和 RSS 在内的格式。从我们已经了解的情况来看,访问这些数据将会很方便,如果与 Beautiful Soup 或其他库结合使用,可以产生很多信息。

如何安装

使用 pip 安装库:

pip install feedparser (Enter)

确保您处于升级的 Windows 命令提示符下。Linux 和 Mac 也会类似。安装过程中没有出现错误,现在您可以使用 Feedparser 了。

如何使用

对于这个例子,我们将从“随时健身博客”中获取数据示例代码将提取标题、副标题、RSS 条目的数量及其名称。当然,您可以做更多的事情,您可以将数据写入一个文件,供另一个库提取关键数据时使用。尝试一下,看看有多简单:

img/330715_3_En_7_Figaf_HTML.jpg

#feedparser example

import feedparser
# main site is:  http://blog.anytimefitness.com/
c = feedparser.parse('http://feeds.feedburner.com/anytimefitnessofficial')
#all elements of the channel are now in container c
#print the title and subtitle and list # of elements of the feed
print (c['feed']['title'])
print (c['feed']['subtitle'])
print ("There are this many entries: ", len(c['entries']))
print()
for item in c['entries']:
    title = item.title
    print (title)

# try others such as item.summary, item.description, item.link, etc.
# write the data to a file for use with BeautifulSoup, etc.

在这个例子中 c 是一个命名条目的容器,例如 标题副标题 等等。此外,这个容器中有一个整数倍(len)。

带着它

因为字符串在各种编程中都很常见,所以您会发现自己有广泛的需求。这一章展示的特性将帮助你更好地使用你的琴弦,但是技巧的恰当组合是不能为你而写的。当您继续编写代码时,您需要对使用哪些技术保持开放的心态,以便您可以选择最适合您需求的技术。

到目前为止,这些章节已经关注了如何使用 Python 的各个方面来执行复杂而有用的任务,从而使您的应用更加强大。下一章将向您展示如何验证这些任务是否被正确执行。

八、文件

文档可以说是任何项目中最困难的部分。对于程序员来说,编写代码相当容易,但是文档需要一套不同的技能,因为受众完全是人类。项目和受众之间的差异程度可能会有很大的不同。有时,所有需要的只是一些示例代码,而其他主题可以占满整本书,仍然有足够多的内容要介绍。

文档语言和代码语言有很大的不同,所以很难同时精通两者。这导致许多程序员走上阻力最小的道路,选择从代码本身自动生成某种最小形式的文档的工具,以便将额外的工作保持在最低限度。虽然这看起来足够了,但是这些工具只能做这么多,因为它们受到代码本身所能告诉它们的内容的限制。JAVA 的 Javadoc 和 Python 的 Epydoc 就是这种工具的例子。

本章将展示有助于描述您的代码及其特性的工具,以便于人们理解。有几个选项可用,其中一些在代码本身旁边,而另一些在外部伴随它。这些可以单独使用,也可以组合使用,形成任何项目的全套文档。根据每个应用的需求,每种应用的必要程度会有所不同,但是每种应用都有其自己的位置。

本章的每一节都将强调如何用可用的工具记录你的代码,以及每种方法的优缺点。然而,关于文档,要记住的最重要的事情是,它都是关于呈现人们需要知道的关于你的应用和如何使用它。您必须始终考虑您的代码是如何工作的,以及您的用户需要知道什么来与它进行交互。只有这样,您才能选择最适合您需求的方法。

恰当的命名

最简单的文档形式是正确命名代码的各个方面。除了极少数例外,每一个类、函数和变量在定义时都有一个名字。因为这些名称已经是必需的了,所以只需要额外考虑一下,确保它们准确且易于理解。为了说明这一点的价值,我们来看看一个带有模糊、通用名称的函数签名,看看您是否能猜出它是做什么的:

img/330715_3_En_8_Figa_HTML.jpg

def action(var1, var2):

给定函数体中的一些代码,您也许能够很好地理解它的用途,但是签名本身没有任何帮助。事实上,正文中的代码更有用的唯一原因是,它通常会使用其他地方可用的更标准化的特性。例如,循环和切片很容易识别,常用对象的方法也是如此,比如字符串的format()方法。然而,这些只是帮助做出有根据的猜测的线索;命名应该显而易见:

img/330715_3_En_8_Figb_HTML.jpg

def find_words(text, word):

只需选择一些更具描述性的名称,就可以使函数的目的及其参数更加清晰。根据经验,类和变量应该用单数名词命名,比如BookPersonRestaurantindexfirst_name。相反,函数应该用动词来命名,比如find()insert()process_user()

PEP 8, 1 也作为附录包含在本书中,为命名各种类型的对象提供了一些更具体的指南。有关详细信息,请参见“命名约定”一节。一旦你进入了代码块,事情就不那么容易理解了,所以注释有助于澄清。

评论

在非常长或复杂的类和函数中,名字本身通常不足以表达代码正在做的所有事情。变量名当然会有所帮助,但这通常只能解释代码做了什么;解释为什么代码做它要做的事情通常更有用。这两个问题都可以通过在代码中添加注释来解决。

注释是程序员可以使用的最基本的文档形式之一,但也是最强大的。注释直接放在代码的其余部分旁边,这里最容易编写,也最有帮助。注释提供了一种在最相关的地方做小笔记的便捷方式,这有助于复杂的代码在以后更容易理解。

Python 的注释通过符号#与代码分开。该符号后面的所有文本都被视为注释,一直到行尾。这允许注释占据一整行或者附加到一行代码的末尾。与其他一些语言不同,Python 没有任何真正的多行注释语法,除非你使用一个 docstring 三重引号字符串,正如吉多·范·罗苏姆在 2011 年发布的推文。(稍后将对文档字符串进行更详细的讨论,所以请稍等片刻,以了解更多细节。)形式上对于字符串来说,较长注释的每一行前面都必须有一个#符号。注意这里的两种方法:

img/330715_3_En_8_Figc_HTML.jpg

def foo(): #example of a docstring comment
    """alkaj
    laksjf
    alkdfj"""

x=1
print (x) # shows value for x
foo() # does nothing

img/330715_3_En_8_Figd_HTML.jpg

# This function doesn't really do anything useful. It's only here to show

# how multi-line comments work in Python. Notice how each line has to have
# a separate # to indicate that it's a comment.

def example():
    pass

像命名约定一样,Python 风格指南对注释应该如何格式化有很多说明。详见 PEP 8 的“评论”标题。

也许注释的最大限制是它们只有在直接查看源文件时才可用。因为注释对代码的执行没有任何影响,所以没有自省工具可以在运行时读取它们。为此,我们求助于文档字符串。

文档字符串

在上一节,以及第三章和第四章中,我们简要提到了文档字符串以及它们在代码中的用法。docstring 放在模块、函数或类的开头。但是,您可以将字符串作为自己的语句,而不是将其赋给变量。只要它是代码块中的第一件事,Python 就会将其解释为 docstring:

img/330715_3_En_8_Fige_HTML.jpg

def find_words(text, word):

    """
    Locate all instances of a word in a given piece of text.
    Return a list of indexes where the words were found.
    If no instances of the word were found, return an empty list.

    text -- a block of text to search
    word -- an individual word to search for
    """

这些信息可以在一组注释中呈现,但是使用 docstrings 有一个主要优点:Python 使它们在代码中可用。为了保持透明的精神,可以在运行时通过模块、类和函数的__doc__属性访问 docstrings。也许这带来的最明显的好处是各种自动文档生成器可以获得更多的信息。更好的是,这些信息是专门为人类编写的,这可以大大提高最终输出的质量。

然而,具体怎么写完全取决于你。除了文档字符串可以放在代码中的什么地方,Python 对文档字符串内容的格式或结构没有任何假设或要求。PEP 257, 2 也作为附录提供,提供了一些建议,但最终决定权还是留给你。目标是帮助人们理解如何使用你的代码,但是,有一些细节是每个人都应该遵守的。

描述该函数的作用

虽然听起来很简单,但有时很难从代码的工作方式后退一步,简单地描述它做了什么。对于大多数功能,你应该能够用一句话来描述,最好是一行。常见的例子有“向集合中添加一项”和“缓存一个对象以备后用”代码如何实现这个目标的细节最好不要放在 docstring 中。

解释论点

参数名称限于一两个单词。这很好地提醒了他们的目的,但是首先通常需要更多的信息来理解他们的目的。这对于可选参数尤其重要,它们通常有助于控制函数的工作方式。即使参数名称是不言自明的,包含简短的描述也有助于保持文档的一致性。

不要忘记返回值

每当函数返回值时,都应该记录该值的性质。它应该包括返回值的类型以及关于对象如何形成的任何相关细节。例如,find_words()返回一个列表,但是该列表包含找到单词的索引,而不是返回单词本身,因此行为被记录下来。

此外,确保如果返回值因给定的输入或函数使用的其他条件而略有不同,则给出不同形式的返回值。例如,按名称检索对象的函数可能被赋予了与任何现有对象都不匹配的名称。在这种情况下,记录该函数是创建一个新对象还是引发一个异常是很重要的。

包括任何预期的异常

每段代码都包含引发异常的机会。有时候,这些异常实际上是代码预期功能的一部分,比如当查找一个名称不匹配的对象时。在这些情况下,应该在返回值旁边记录异常。这些显式异常经常被调用您的函数的代码捕获,因此有必要指出哪些将被引发,以及它们将在什么情况下被引发。

代码之外的文档

关于上一节中的建议,您会注意到一件事,那就是它们不是特定于 docstrings 的。您还应该在代码之外记录您的应用,并且该记录需要包括所有相同的细节。这个外部文档的不同之处在于信息是如何呈现的,它还包括代码本身没有包含的附加信息。

这类通用文档可以涵盖各种各样的主题,其中许多主题在代码中没有任何意义。毕竟,阅读你的代码的人很可能已经有了要寻找的东西。他们将寻找更多关于他们已经知道如何找到的特定模块、类或函数的信息。其他用户将有更广泛的需求,从安装和教程到更热门的参考资料,展示如何为某个目标组合多种功能。

安装和配置

在任何人可以使用你的软件之前,他们需要获得它并让它工作。这几乎不言而喻,但也不尽然。在用户使用您的代码之前,有许多问题需要解决,您需要确保尽可能彻底地解决这些问题。

获取代码是第一步。无论您选择如何分发代码,您都需要确保您的用户知道如何获得它。有时这可能是一个简单的一行命令,但在其他情况下,它可能需要首先获得其他应用,如版本控制软件,以获得最新的代码,而不必等待发布。第十章将描述一些更常见的分发代码的方法,以及你的选择对需要检索它的用户意味着什么。

教程

在得到一个应用后,许多用户想立即知道如何使用它。每个人都喜欢即时的满足感,所以你可以把他们对你的软件的第一次体验作为一个快速完成某事的机会。教程是引导用户了解应用最常见功能的好方法。

教程通常可以展示一个应用的最大优势,所以它也可以是你说服某人首先试用它的第一次机会。库和框架尤其如此,它们被设计成集成到其他代码中,而不是独立使用。如果你的观众能很快感受到你的方法如何帮助他们处理他们自己的代码,这将会留下一个持久的印象。

参考文件

一旦你的用户对你的应用如何帮助他们有了很好的想法,并且获得了一些经验,他们的需求又会改变。在这一点上,他们不再需要被说服去使用你的软件,他们已经准备好超越学习如何使用它。现在,他们需要提醒所有功能是如何工作的,这些功能是如何协同工作的,以及它们如何与他们真正要执行的任务相集成。

不同的读者会寻找不同形式的参考文献。有些人可能更喜欢方法级的参数和返回值,比如包含在 docstrings 中的参数和返回值,而其他人可能会从用简单语言编写的更广泛的概述中获得更多。一些读者,像你一样,甚至喜欢实体书,很容易拿起并随时翻阅。

有了所有这些不同的偏好,就不太可能写出适合所有人口味的参考文档。作为作者,确定哪种类型的文档最适合您的应用是您的工作。根据你自己的喜好选择你最喜欢阅读的文档类型,因为这很可能与你创建的软件的精神是一致的。就按你喜欢的方式写吧。喜欢你的文档的用户很可能就是喜欢你的软件的人。

注意

需要记住的一件重要事情是,您可能根本不需要参考文档。对于非常简单的应用,一个教程可能足以说明和解释所有可用的功能。

文档实用程序

文档的一些最具挑战性的方面与您的应用或您计划如何编写它毫无关系。除了这些问题之外,格式化、引用和呈现文档等任务会消耗相当多的时间和精力。你需要写的文档越多,这些任务就变得越难。第三方docutils3 提供了一套全面的工具,让这个过程更易管理。

这个docutils包的皇冠上的宝石是重组文本,通常被称为 ReST 或简称为 RST。reStructuredText 是一种为编写技术文档而设计的标记语言,采用了开发者所说的所见即所得(WYSIWYM)的方法。这与更传统的所见即所得(WYSIWYG)形成对比,后者基于文档的可视布局和格式进行编辑。

在 WYSIWYM 中,目标是表明文档的结构和意图,而不考虑它将如何精确地呈现。与 HTML 非常相似,将内容与表示分离可以让您专注于文档中真正重要的内容,而将视觉样式的细节留到以后。然而,reStructuredText 使用了比 HTML 更友好的文本方式,因此即使是无格式的文档也易于阅读。

可读性计数

与 Python 哲学相一致,reStructuredText 始终关注可读性,甚至在文档被格式化为最终格式之前。文档的结构和说明被设计成易于理解、易于记忆和格式化。

格式化

任何类型的文档最基本的单元是段落,所以 reStructuredText 使它们最容易处理。你所需要做的就是写一段文字,每一行文字都从它前面一行的正下方开始。只要给定段落中的任何文本行之间没有完全空白的行,行数和每行的长度就无关紧要。

空行是用来分隔段落和其他类型的内容的。这就形成了一个简单的区分段落的方法。如果您愿意,可以使用多个空行,但只需要一个。缩进一个段落表示引用了另一个文档中的段落,这通常也会在输出中缩进。举例来说,下面是为 reStructuredText 编写的几个简单段落:

The reStructuredText format is very simple when it comes down to it. It's all

about readability and flexibility. Common needs, such as paragraphs and inline
formatting, are simple to write, read and maintain. More complex features are
possible, and they use a simple, standardized syntax.
After all, the Zen of Python says:

    Simple is better than complex.
    Complex is better than complicated.

大多数应用文档还会包含代码块和常规文本。这对于教程来说特别有用,在教程中,一个代码块可以分段构建,中间有解释。区分段落和代码块是基于普通段落末尾的双冒号,后面是缩进的代码块。这将以冒号结束第一段,并将缩进的文本格式化为代码:

The reStructuredText format is very simple when it comes down to it. It's all

about readability and flexibility. Common needs, such as paragraphs and inline
formatting, are simple to write, read and maintain. More complex features are
possible, and they use a simple, standardized syntax.
After all, the Zen of Python says::

    Simple is better than complex.
    Complex is better than complicated.

注意

您会注意到这里显示的例子实际上并不是代码。双冒号格式从技术上区分了预格式化的文本块。这可以防止 reStructuredText 解析器对该块进行任何额外的处理。因此,尽管它最适用于在文档中包含代码,但它也可以用于任何已经有自己的格式且应该保持不变的内容。

在一个单独的段落中,您还可以按照您期望的所有方式格式化文本。这种格式要求在要格式化的文本前后使用额外的标点,而不是直接标记为斜体或粗体。用星号将单词或短语括起来表示强调,通常以斜体显示。除此之外,使用一对额外的星号将表示强烈的强调,通常呈现为粗体。

链接

当处理大量文档时,您可以提供的最重要的功能之一是将多个文档链接在一起。reStructuredText 格式提供了几种不同的方法来链接附加信息,无论是脚注、同一文档中的其他部分还是完全不同的文档。您可以包含的最简单的链接形式是 URL,它将在呈现文档时被转换为链接。其他类型的链接需要更多的格式。

链接采用下划线的形式,跟在应该用作链接的文本后面。链接的目标以不同的方式指定,这取决于该目标的位置。在最常见的情况下,文档链接到某个外部网页,链接目标被放在看似是它自己的段落中,其结构告诉解析器这是一个链接,而不是一个实际的段落:

img/330715_3_En_8_Figf_HTML.jpg

This paragraph shows the basics of how a link is formed in reStructuredText.

You can find additional information in the official documentation_.

.. _documentation: http://docutils.sf.net/docs/

这将导致单词“documentation”被用作链接本身,引用底线上给出的目标。您通常需要为链接的文本使用多个单词,但是这并没有提供一种方法来指定应该包含多少文本。要做到这一点,您需要用反斜杠(```py)将文本括起来。然后下划线会出现在外壳之外,紧跟在第二个反勾号之后:

img/330715_3_En_8_Figg_HTML.jpg

This paragraph shows the basics of how a link is formed in reStructuredText.

You can find additional information in the `official documentation`_.

.. _official documentation: http://docutils.sf.net/docs/

```py

在这种情况下,链接目标被指定在应该放置链接的段落的正下方。这种特殊的情况可以通过创建一个匿名链接来简化,它不再需要重写下面的链接文本。为了区别于普通链接,你需要在链接文本后使用两个下划线,而不是一个。然后,指定链接目标时,仅在行首使用两个下划线:

![img/330715_3_En_8_Figh_HTML.jpg](https://gitee.com/OpenDocCN/vkdoc-python-zh/raw/master/docs/pro-py3/img/330715_3_En_8_Figh_HTML.jpg)

This paragraph shows the basics of how a link is formed in reStructuredText.

You can find additional information in the official documentation__.

__ http://docutils.sf.net/docs/


### 可读性计数

还有另一种更节省空间的指定外部链接的方法:将链接目标直接放在链接文本旁边,在段落内部。以这种方式格式化的链接仍然使用反勾号来将链接与文本的其余部分分开,但是链接目标在用尖括号括起来之后也在反勾号内部。为了区分它是一个链接,仍然使用两个下划线,因此它被解析为一个匿名链接—例如,``Pro Python <` [`http://propython.com`](http://propython.com) `/>`__`。

这种方法的问题是,当阅读文档的源代码时,将 URL 放在段落中会非常分散注意力,即使目标在最终输出中是隐藏的。此外,命名链接目标都可以放在文档的末尾,因此它们甚至不必中断从一个段落到另一个段落的流动。

除了引用外部文档,您还可以在同一文档的末尾或附加的参考书目中包含脚注。定义这种类型的链接很像标准链接,只是链接文本用方括号隔开。在括号之间,文本可以是一个数字,也可以是一小段文本,用于引用其他地方的相关信息。

然后,在文档的末尾,引用的信息可以以类似于命名链接目标的格式包含在内。本文前面的参考文本再次用方括号括起来,而不是用下划线来表示。之后,简单地在段落中写上相关的文字。这可用于参考传统出版物,如书籍,也可用于进一步阐明正文的次要补充内容:

![img/330715_3_En_8_Figi_HTML.jpg](https://gitee.com/OpenDocCN/vkdoc-python-zh/raw/master/docs/pro-py3/img/330715_3_En_8_Figi_HTML.jpg)

The reStructuredText format isn't part of Python itself, but it's popular enough

that even published books [1]_ reference it as an integral part of the Python
development process.

.. [1] Alchin, Marty. Pro Python. Apress, 2010.


除了这些选项,`docutils`允许 reStructuredText 被扩展以提供其他特性。Sphinx 是一个提供一些额外特性的应用。

### 狮身人面像

reStructuredText 提供的基本功能旨在处理单个文档。尽管引用其他文档很容易,但这些引用必须明确包含在每个文档中。如果您编写一个需要多个文档的复杂应用,每个文档都需要知道所有文档的完整结构,以便引用它们。

Sphinx <sup>4</sup> 是一个试图通过将文档作为一个整体集合来处理的应用。在这方面,它有点类似于其他更流行的自动化系统,如 Javadoc 和 Doxygen,但 Sphinx 被设计为从专用文件中获取内容,而不是直接从代码本身获取内容。它也可以包含基于代码的内容,但主要目标是自己编写文档。

通过更有效地管理文档间的引用,Sphinx 可以一次生成整个文档包。这可以是一个充满链接的 HTML 文档的网站,甚至可以是一个单独的 PDF 文档,它将所有文档作为单独的部分包含在内。此外,Sphinx 提供了多种样式选择,其中许多已经由一个不断增长的社区提供。

## 令人兴奋的 Python 扩展:NumPy

正如 NumPy 的主站点所提到的,它是“使用 Python 进行科学计算的基础包”。因此,它为 Python 程序员提供了很大的能力。

NumPy 是用 Python 进行科学计算和数据操作的最基本的包。如果您需要在 Python 中使用标准数组,那么 Numpy 是不二之选。通常,它将与 *SciPy* 一起使用,并且是 SciPy 中的核心包之一。关于基本 Python 实现的一点是,它不像其他语言那样有标准的数组结构。“标准”是指保存类似数据的数组(例如,全是整数、全是字符等。).所以,去救援是 NumPy。然而,它做得更多。让我们尝试一下 NumPy 中一些有趣的特性。首先你需要安装它。

### 安装 NumPy

如果使用 Windows,请在升级的命令提示符下尝试:

pip install numpy (enter)


它应该响应它已正确安装或已经安装。

### 使用 NumPy

首先,标准的非 Python 数组很方便。Python 使用*列表、字典的*和*元组*;它们功能强大,但有时老式阵列正是解决问题的不二之选。NumPy 数组就像 C++或其他语言中使用的数组一样,它们包含相同类型的数据元素(每个都是 int、float、character 等)。).它的大小也不能更改,除非您删除它并重新创建一个更大的。有趣的是,就内存使用而言,NumPy 数组比存储为列表的相同结构要小。

Python 类似数组的结构和标准数组各有其独特的优势。因此,如果您需要一个标准数组,您可以使用 NumPy 轻松创建它们:

![img/330715_3_En_8_Figj_HTML.jpg](https://gitee.com/OpenDocCN/vkdoc-python-zh/raw/master/docs/pro-py3/img/330715_3_En_8_Figj_HTML.jpg)

尝试以下方法:

NumPy create a 1 dimensional numeric array from a list

import numpy as mynp
my_list = [1,2,3,4,5]
array1 = mynp.array(my_list)

Print array and its type

print (array1)
print(type(array1))


在前面的示例中,列表中的每一项都被视为一个数值。但是,如果将列表中的一个值更改为字母数字,则整个数组将变成一个字符数组:

![img/330715_3_En_8_Figk_HTML.jpg](https://gitee.com/OpenDocCN/vkdoc-python-zh/raw/master/docs/pro-py3/img/330715_3_En_8_Figk_HTML.jpg)

NumPy create a 1 dimensional character array from a list

import numpy as mynp
my_list = [1,2,3,'a',5]
array1 = mynp.array(my_list)

Print array and its type

print (array1)
print(type(array1))


因此,在这种转换中,如果您对数组中的值执行一些数学运算,效果不会很好,如下例所示:

![img/330715_3_En_8_Figl_HTML.jpg](https://gitee.com/OpenDocCN/vkdoc-python-zh/raw/master/docs/pro-py3/img/330715_3_En_8_Figl_HTML.jpg)

Add one to each value

import numpy as mynp
my_list = [1,2,3,4,5]
array1 = mynp.array(my_list)

Print array and its type

print (array1)
print('With one added two each: ')
for item in array1:
print (item + 1)


由于数组中的每一个都是一个数值,我们可以给它加一并显示结果。如果您想要指定数组类型,就像使用另一种语言(如 C++)一样,您可以执行以下操作:

![img/330715_3_En_8_Figm_HTML.jpg](https://gitee.com/OpenDocCN/vkdoc-python-zh/raw/master/docs/pro-py3/img/330715_3_En_8_Figm_HTML.jpg)

NumPy 1 dimensional array from a list as floating-point values

and make it a float array

import numpy as mynp
my_list = [1.1,2.1,3.1,4.1,5.1]
array1 = mynp.array(my_list, dtype="float")

Print the array

print (array1)


你也可以用 *astype* 从一种类型转换成另一种类型,比如在 *array1.astype('int')* 或者其他有效的数据类型比如*如 bool、str、*或者 *float* 。或者,可以用 array1.tolist()将数组转换回列表。

### 使用 NumPy 数组

您可以用与其他 Python 结构类似的方式对数组进行寻址。在下一个示例中,我们将提取一个元素,并根据数组中的每个元素找到问题的答案:

![img/330715_3_En_8_Fign_HTML.jpg](https://gitee.com/OpenDocCN/vkdoc-python-zh/raw/master/docs/pro-py3/img/330715_3_En_8_Fign_HTML.jpg)

NumPy create a 1 dimensional array from a list

and make it a float array

import numpy as mynp
my_list = [1.1,2.1,3.1,4.1,5.1]
array1 = mynp.array(my_list, dtype="float")

Print the array

print (array1)
print("Print second element of array")
print (array1[1])
print("Print if element is > 2")
print (array1 > 2)


### 统计措施

NumPy 内置了一些统计函数,比如标准的最小值、最大值和平均值。关于随机数(例如在研究或加密工作中随机选择参与者),NumPy 内置的随机库与 C++的随机库的增强功能非常相似。使用一个数字数组来尝试一下:

![img/330715_3_En_8_Figo_HTML.jpg](https://gitee.com/OpenDocCN/vkdoc-python-zh/raw/master/docs/pro-py3/img/330715_3_En_8_Figo_HTML.jpg)

NumPy stats functions

import numpy as mynp
my_list = [1,2,7,4,5]
array1 = mynp.array(my_list, dtype="int")
print ('Minimum:> ',array1.min())
print ('Max:> ',array1.max())
print ('Mean of all values:> ',array1.mean())

if you want only pseudo-randoms set a seed value

np.random.seed(100) # uncomment for pseudo-randoms

print('Random int between 1 and 100):> ',mynp.random.randint(0, 100))


## 带着它

这里显示的工具只是作为记录代码的实际工作的基础。真正的文档工作需要从代码本身后退一步,这样你就可以像你的用户和其他开发人员那样看待你的应用。记住这一点,阅读其他类似应用的文档通常是有用的。这将让你很好地了解你的用户习惯于看到什么,他们需要回答的问题类型,以及如何区分你的应用是现有选项的一个更好的替代。

另一方面,你也可以通过仔细检查你的代码来帮助你的用户。将您的代码置于最严格的审查之下将允许您编写测试。下一章将展示如何通过测试来验证你的应用是否正常工作,以及你的文档是否尽可能准确。

<aside class="FootnoteSection" epub:type="footnotes">Footnotes 1

参见《人教版 Python 代码的风格指南》、《??》、《??》、《??》。

  2

参见“PEP 257: Docstring Conventions”, [`http://www.python.org/dev/peps/pep-0257`](http://www.python.org/dev/peps/pep-0257) 。

  3

参见“Docutils:文档工具”, [`http://docutils.sourceforge.net`](http://docutils.sourceforge.net) 。

  4

参见“Sphinx: Python 文档生成器”, [`http://sphinx.pocoo.org`](http://sphinx.pocoo.org) 。

 </aside>

# 九、测试

编写应用只是过程的一部分;检查所有代码是否正常工作也很重要。您可以直观地检查代码,但是最好在现实世界中可能出现的各种情况下执行它,以确保它正常运行。这个过程被称为单元测试,因为目标是测试最小的可用执行单元。

通常,最小的单元是一个函数或方法,许多这样的单元组合起来形成一个完整的应用。通过将它分解成单独的单元,您可以最小化每个测试所负责的工作量。这样,任何特定单元的故障都不会涉及数百行代码,因此更容易准确地跟踪出了什么问题。

对于大型应用来说,测试每个单独的单元可能是一个漫长的过程,因为您可能需要考虑许多场景。您可以通过让您的代码完成繁重的工作来自动化这个过程,而不是试图手动完成所有的工作。编写测试套件允许您轻松地尝试代码可能采用的所有不同路径,验证每条路径的行为是否正常。

## 测试驱动开发

自动化测试的一个更极端的例子是测试驱动开发的实践,通常简称为 TDD。顾名思义,这种实践使用自动化测试来驱动开发过程。每当编写一个新特性时,首先编写该特性的测试——这些测试会立即失败。一旦测试就绪,您将编写代码来确保这些测试通过。

这种方法的一个价值是,它鼓励您在开始编写代码之前更彻底地理解期望的行为。例如,一个处理文本的函数可能有许多公共输入字符串,每个字符串都有一个期望的输出。首先编写测试鼓励您考虑每个可用输入字符串的输出字符串,而不考虑字符串在内部是如何处理的。通过从一开始就转移对代码的关注,更容易看到全局。关注接口(名称、函数、方法签名等)的好处。)不要低估早期,因为这里的更改比以后的实现更改更难。

然而,更明显的优点是,它确保了应用中的每一段代码都有一组与之相关的测试。当代码排在第一位时,手动运行几个基本场景,然后继续编写下一个特性就太容易了。测试可能会在混乱中丢失,即使它们对于项目的长期健康发展是必不可少的。养成先写测试的习惯是确保它们被写出来的好方法。

不幸的是,许多开发人员发现测试驱动的开发对于实际工作来说过于严格。然而,只要尽可能全面地编写测试,您的代码就会受益。最简单的方法之一是编写文档测试。

## 文档测试

文档主题在第八章中有所涉及,但是它的一个特殊方面对测试很有用。因为 Python 支持可以由代码而不仅仅是人来处理的文档字符串,所以这些字符串中的内容也可以用于执行基本的测试。

为了与常规文档一起发挥双重作用,文档测试必须看起来像文档,同时仍然可以被解析、执行和验证正确性。有一种格式非常适合这个要求,并且在本书中一直使用。文档测试被格式化为交互式解释器会话,其中已经包含了易于识别的输入和输出格式。

### 格式化代码

尽管 doctest 的整体格式与本书中显示的解释器会话完全相同,但是有一些特定的细节需要识别。要执行的每一行代码都以三个右尖括号(`>>>`)和一个空格开始,后面是代码本身:

![img/330715_3_En_9_Figa_HTML.jpg](https://gitee.com/OpenDocCN/vkdoc-python-zh/raw/master/docs/pro-py3/img/330715_3_En_9_Figa_HTML.jpg)

```py
>>> a = 2

就像交互式解释器一样,任何超出一行的代码都由以三个句点(...)而不是括号开头的新行来表示。为了完成多行结构,如列表和字典,以及函数和类定义,您可以根据需要包含尽可能多的这些内容:

img/330715_3_En_9_Figb_HTML.jpg

>>> b = ('example',

... 'value')
>>> def test():
...     return b * a

所有像这样以句点开头的行都与以尖括号开头的最后一行组合在一起,它们都被一起求值。这意味着如果有必要,你可以在结构中的任何地方,甚至在结构之后,留下额外的行。这对于模拟实际解释器会话的输出很有用,它需要一个空行来指示缩进结构(如函数或类)何时完成:

img/330715_3_En_9_Figc_HTML.jpg

>>> b = ('example',

...
... 'value')
>>> def test():
...     return b * a
...

表示输出

代码就绪后,我们只需要验证它的输出是否与预期相符。为了与解释器格式保持一致,输出显示在一行或多行输入代码的下面。输出的确切格式取决于正在执行的代码,但这与您在解释器中直接输入代码时看到的是一样的:

img/330715_3_En_9_Figd_HTML.jpg

>>> a

2
>>> b
('example', 'value')
>>> test()
('example', 'value', 'example', 'value')

在这些例子中,输出字符串相当于将表达式的返回值传递给内置的repr()函数。因此,字符串将总是被引用,并且许多特定类型将具有与直接打印它们不同的格式。测试str()的输出可以简单地通过在代码行中调用str()来实现。或者,也支持print()功能,其工作方式与您预期的一样:

img/330715_3_En_9_Fige_HTML.jpg

>>> for value in test():

...     print(value)
example
value
example
value

在像这样的例子中,输出的所有行都根据提供的代码实际返回或打印的内容进行检查。这提供了一种可读性很强的处理序列的方法,如下所示。对于更长的序列,以及允许输出从一个运行改变到另一个运行的情况,输出也可以包括三个省略号,指示应该忽略附加内容的位置:

img/330715_3_En_9_Figf_HTML.jpg

>>> for value in test():

...     print(value)
example
...
value

这种形式在测试异常时特别有用:解释器输出包括文件路径,这些路径几乎总是会从一个系统到另一个系统发生变化,并且与大多数测试无关。在这些情况下,重要的是测试是否引发了异常,异常的类型是否正确,以及异常的值(如果有)是否正确:

img/330715_3_En_9_Figg_HTML.jpg

>>> for value in test:

...     print(value)
Traceback (most recent call last):
  ...
TypeError: 'function' object is not iterable

正如这里的输出格式所示,doctest 将验证异常输出的第一行和最后一行,而忽略中间的整个回溯。因为追溯细节通常也与文档无关,所以这种格式可读性更好。

与文档集成

因为测试应该被构建到文档中,所以需要有一种方法来确保只执行测试。为了在不中断文档流的情况下区分这两者,测试仅仅通过额外的换行符而被搁置。您必须始终使用一个换行符来避免它们都出现在一行中,所以添加一个额外的换行符只会在两者之间留下一个空行:

img/330715_3_En_9_Figh_HTML.jpg

"""

This is an example of placing documentation alongside tests in a single string.

Additional documentation can be placed between snippets of code, and it won't

disturb the behavior or validity of the tests.

"""

print("Hello, world!")

运行测试

文档测试的实际执行由doctest模块提供。在最简单的形式中,您可以运行单个函数来测试整个模块。这在为已经编写好的文件编写一组测试时非常有用,因为您可以在编写新的测试后轻松地单独测试文件。只需导入doctest并运行它的testmod()函数来测试模块。下面是一个包含几种类型的文档测试的示例模块:

img/330715_3_En_9_Figi_HTML.jpg

def times2(value):

    """
    Multiplies the provided value by two. Because input objects can override
    the behavior of multiplication, the result can be different depending on
    the type of object passed in.

    >>> times2(5)
    10
    >>> times2('test')
    'testtest'
    >>> times2(('a', 1))
    ('a', 1, 'a', 1)
    """
    return value * 2

if __name__ == '__main__':
    import doctest
    doctest.testmod()

times2()函数中的 docstring 包含测试,因为它是模块级函数,所以testmod()可以看到它并执行测试。这个简单的构造允许您直接从命令行调用模块,并查看模块中所有文档测试的结果。例如,如果这个模块叫做times2.py,您可以从命令行调用它,如下所示:

img/330715_3_En_9_Figj_HTML.jpg

$ python times2.py

$

默认情况下,输出只包含错误和失败,所以如果所有测试都通过了,就不会有任何输出。失败在单个测试中报告,每个输入/输出组合被认为是一个独特的测试。这提供了关于所尝试的测试的性质以及它们如何失败的细粒度细节。如果示例 doctest 中的最后一行只显示('a', 1),将会发生以下情况:

img/330715_3_En_9_Figk_HTML.jpg

$ python times2.py

**********************************************************************
File "...", line 11, in __main__.times2
Failed example:
    times2((a, '1'))
Expected:
    (a, '1')
Got:
    (a, '1', a, '1')
**********************************************************************
1 items had failures:
   1 of   3 in __main__.times2
***Test Failed*** 1 failures.
$

然而,当处理更复杂的应用和框架时,doctests 的简单输入/输出范例很快就会崩溃。在这些情况下,Python 中有两个很好的测试:Pytest 和unittest模块。作为对 doctests 的一种替代,我们接下来将研究 unittest。

单元测试模块

与要求您的测试以非常特殊的方式格式化的 doctests 不同,unittest允许您用真正的 Python 代码编写测试,从而提供了更大的灵活性。通常情况下,这种额外的能力需要对如何定义测试进行更多的控制。在单元测试的情况下,这种控制是通过面向对象的 API 提供的,用于定义单独的测试、测试套件和测试使用的数据设备。

导入unittest模块后,首先要开始的是TestCase类,它构成了模块大部分特性的基础。你还应该检查一下 pytest。org ,但这个类应该首先考虑。unittest 模块本身并不做太多事情,但是当它被子类化时,它提供了一组丰富的工具来帮助定义和控制你的测试。这些工具是您可以用来执行单独测试的现有方法和您可以定义来控制测试工作方式的新方法的组合。这一切都从创建TestCase类的子类开始:

img/330715_3_En_9_Figl_HTML.jpg

import unittest

class MultiplicationTestCase(unittest.TestCase):
    pass

安装

大多数测试用例的起点是setUp()方法,您可以定义它在所有将在类上定义的测试开始时执行一些任务。常见的设置任务包括定义稍后将进行比较的静态值、打开数据库连接、打开文件以及加载数据进行分析。

这个方法没有参数,也不返回任何东西。如果您需要用任何参数来控制它的行为,您将需要以一种方式来定义这些参数,使得setUp()可以访问它们,而不用将它们作为参数传入。一种常见的技术是检查os.environ中影响测试行为的特定值。另一个选择是拥有可定制的设置模块,这些模块可以在setUp()中导入,然后可以修改测试行为。

同样地,setUp()为以后使用而定义的任何值都不能使用标准值返回。相反,它们可以存储在TestCase对象本身上,该对象将在运行setUp()之前被实例化。下一节将展示单独的测试被定义为同一个对象上的方法,因此在设置期间存储的任何属性在测试执行时都可供测试使用:

img/330715_3_En_9_Figm_HTML.jpg

import unittest

class MultiplicationTestCase(unittest.TestCase):
    def setUp(self):
        self.factor = 2

注意

如果你看 PEP 8(Python 代码的风格指南),你会注意到名字setUp()没有遵循标准的 Python 命名约定。这里的大写形式基于 Java 测试框架 JUnit。Python 的单元测试系统是从 Java 移植过来的,并且它的一些风格也延续了下来。一定要检查这个 PEP,因为它提供了一些关于代码可读性的非常重要的信息。

写作测试

设置就绪后,您可以编写一些测试来验证您正在处理的任何行为。像setUp()一样,这些是作为你的测试用例类上的定制方法实现的。然而,与setUp()不同,没有一个特定的方法必须实现所有的测试。相反,测试框架会在你的测试用例类中寻找名字以单词test开头的方法。

对于它找到的每个方法,测试框架在执行测试方法之前执行setUp()。这有助于确保每个方法都依赖于一致的环境,而不管有多少方法,它们各自做什么,或者它们执行的顺序如何。完全确保一致性还需要一个步骤,但这将在下一节中讨论。

当编写测试方法的主体时,TestCase类提供了一些实用方法来描述你的代码应该如何工作。这些都是以这样一种方式设计的,即每一个都代表一个必须为真才能继续的条件。有几种这样的方法,每一种都覆盖一种特定类型的断言。如果给定的断言通过,测试将继续到下一行代码;否则,测试会立即停止,并生成一条失败消息。每种方法都提供了在失败时使用的默认消息,但也接受一个参数来自定义该消息:

  • assertTrue(expr, msg=None):该方法测试给定表达式的计算结果是否为True。这是最简单的断言,反映了内置的assert关键字。然而,使用这种方法会将失败绑定到测试框架中,所以应该使用它。如果你喜欢使用assert关键字,这种方法也可以作为assert_()使用。

  • assertFalse(expr, msg=None):与assertTrue()相反,只有当提供的表达式计算结果为False时,该测试才会通过。

  • fail(msg=None):该方法显式生成失败消息。如果失败的条件比内置方法本身提供的条件更复杂,这是有用的。生成失败比引发异常更可取,因为它表明代码以测试可以理解的方式失败,而不是未知的方式。

这些函数本身就为您的其余测试提供了一个基本的调色板。要开始将早期的 doctest 转换为单元测试,我们可以通过提供一个testNumber()方法来模拟之前执行的第一个测试。像 doctests 一样,unittest模块也提供了一个简单的函数来运行在给定模块中找到的所有测试;这一次,它叫做main():

img/330715_3_En_9_Fign_HTML.jpg

import unittest

import times2

class MultiplicationTestCase(unittest.TestCase):
    def setUp(self):
        self.factor = 2

    def testNumber(self):
        self.assertTrue(times2.times2(5) == 10)

if __name__ == '__main__':

    unittest.main()

测试通常存储在一个名为tests.py的模块中。保存该文件后,我们可以像前面显示的 doctest 示例一样执行它:

img/330715_3_En_9_Figo_HTML.jpg

$ python tests.py

.
----------------------------------------------------------------------
Ran 1 test in 0.001s

与 doctests 不同,默认情况下,单元测试会显示一些统计数据。每个周期代表运行的一个测试,所以具有几十、几百甚至几千个测试的复杂应用可以很容易地用结果填满几个屏幕。这里还显示了故障和错误,使用E表示错误,使用F表示故障。此外,每次失败都会产生一个文本块来描述哪里出错了。看看当我们改变测试表达式时会发生什么:

img/330715_3_En_9_Figp_HTML.jpg

import unittest

import times2

class MultiplicationTestCase(unittest.TestCase):
    def setUp(self):
        self.factor = 2

    def testNumber(self):
        self.assertTrue(times2.times2(5) == 42)

if __name__ == '__main__':
    unittest.main()

img/330715_3_En_9_Figq_HTML.jpg

$ python tests.py

假设您在同一个终端会话中,并且键入了前面的函数,运行此代码的输出将是:

F
======================================================================
FAIL: testNumber (__main__.MultiplicationTests)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "tests.py", line 9, in testNumber
    self.assertTrue(times2(5) == 42)
AssertionError: False is not True
----------------------------------------------------------------------
Ran 1 test in 0.001s

FAILED (failures=1)

正如您所看到的,它准确地显示了哪个测试方法产生了失败,并通过回溯来帮助跟踪导致失败的代码流。此外,故障本身显示为一个AssertionError,并清楚地显示了断言。

然而,在这种情况下,失败消息并没有发挥应有的作用。它报告的只是False不是True。当然,这是一份正确的报告,但它并没有真正讲述事情的全部。为了更好地跟踪哪里出错了,知道函数实际返回了什么是有用的。

为了提供更多关于所涉及的值的信息,我们需要使用一个测试方法来分别识别不同的值。如果它们不相等,测试就会像标准断言一样失败,但是失败消息现在可以包含两个不同的值,这样您就可以看到它们的不同之处。这可能是一个有价值的工具,用于确定代码是如何以及在哪里出错的——毕竟,这是测试的全部目的:

  • assertEqual(obj1, obj2, msg=None):利用第五章中显示的比较特性,检查传入的两个对象是否相等。

  • assertNotEqual(obj1, obj2, msg=None):类似于assertEqual(),只是如果两个对象相等,这个方法会失败。

  • assertAlmostEqual(obj1, obj2, *, places=7, msg=None):特别是对于数值,这个方法在检查相等性之前将数值四舍五入到给定的小数位数。这有助于解决舍入误差和浮点运算引起的其他问题。

  • assertNotAlmostEqual(obj1, obj2, *, places=7, msg=None):与前一种方法相反,如果两个数字四舍五入到指定的位数,则测试失败。

有了assertEqual(),我们可以更改testNumber(),以便在断言失败时产生更有用的消息:

img/330715_3_En_9_Figr_HTML.jpg

import unittest

import times2

class MultiplicationTestCase(unittest.TestCase):
    def setUp(self):
        self.factor = 2

    def testNumber(self):
        self.assertEqual(times2.times2(5), 42)

if __name__ == '__main__':
    unittest.main()
F
======================================================================
FAIL: testNumber (__main__.MultiplicationTests)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "tests.py", line 9, in testNumber
    self.assertEqual(times2(5), 42)
AssertionError: 10 != 42
----------------------------------------------------------------------
Ran 1 test in 0.001s

FAILED (failures=1)

在幕后,assertEqual()做了一些有趣的事情,尽可能的灵活和强大。首先,通过使用==操作符,它可以使用对象本身定义的任何更有效的方法来比较两个对象。第二,可以通过提供自定义比较方法来配置输出的格式。在unittest模块中提供了几个定制方法:

  • assertSetEqual(set1, set2, msg=None):因为无序序列通常被实现为集合,所以这个方法是专门为集合设计的,使用第一个集合的difference()方法来确定两者之间是否有任何项目不同。

  • 这个方法是专门为字典设计的,目的是考虑它们的值和键。

  • assertListEqual(list1, list2, msg=None):类似于assertEqual(),这个方法是专门针对列表的。

  • assertTupleEqual(tuple1, tuple2, msg=None):和assertListEqual()一样,这是一个定制的等式检查,但是这次是为元组定制的。

  • 如果你没有使用列表、元组或者它们的子类,这个方法可以用来在任何作为序列的对象上做同样的工作。

除了这些现成的方法,您可以将自己的方法添加到测试框架中,这样assertEqual()就可以更有效地使用您自己的类型。通过向addTypeEqualityFunc()方法传递一个类型和一个比较函数,您可以注册它,以便稍后与assertEqual()一起使用。

有效地使用addTypeEqualityFunc()可能很棘手,因为它对整个测试用例类都有效,不管里面有多少测试。在setUp()方法中添加等式函数可能很诱人,但是请记住,对于在TestCase类中找到的每个测试方法,setUp()都会被调用一次。如果等式函数将被注册到该类的所有测试中,那么在每个测试之前注册它是没有意义的。

更好的解决方案是将addTypeEqualityFunc()调用添加到测试用例类的__init__()方法中。这还有一个额外的好处,那就是你可以子类化你自己的测试用例类,为其他测试提供一个更合适的基础。这一过程将在本章后面详细解释。

其他比较

除了简单的相等之外,unittest.TestCase还包括一些其他方法,可以用来比较两个值。主要针对数字,这些解决了测试值是小于还是大于预期值的问题:

  • assertGreater(obj1, obj2, msg=None):类似于相等测试,测试第一个对象是否大于第二个对象。像等式一样,如果适用的话,这也委托给两个对象上的方法。

  • assertGreaterEqual(obj1, obj2, msg=None):这就像assertGreater()一样,除了如果两个对象的比较结果相同,测试也通过。

  • assertLess(obj1, obj2, msg=None):如果第一个对象比第二个对象小,则测试通过。

  • assertLessEqual(obj1, obj2, msg=None):像assertLess()一样,这测试第一个对象是否小于第二个对象,但如果两者相等也通过。

测试字符串和其他序列内容

序列提出了一个有趣的挑战,因为它们由多个单独的值组成。序列中的任何值都可能决定给定测试的成功或失败,因此有必要使用工具专门处理它们。首先,有两种为字符串设计的方法,其中简单的等式可能并不总是足够的:

  • assertMultiLineEqual(obj1, obj2, msg=None):这是assertEqual()的一种特殊形式,为多行字符串设计。等式的工作方式类似于任何其他字符串,但是默认的失败消息经过了优化,以显示值之间的差异。

  • assertRegexpMatches(text, regexp, msg=None):测试给定的正则表达式是否与提供的文本匹配。

更一般地说,序列测试需要确保序列中存在某些项目才能通过。只有当整个序列必须相等时,前面显示的等式方法才有效。如果序列中的一些项目很重要,但其余的可能不同,我们将需要使用一些其他方法来验证:

  • assertIn(obj, seq, msg=None):测试对象是否出现在给定的序列中。

  • assertNotIn(obj, seq, msg=None):这与assertIn()类似,除了当对象作为给定序列的一部分存在时它会失败。

  • assertDictContainsSubset(dict1, dict2, msg=None):该方法采用了assertIn()的功能,并将其专门应用于字典。像assertDictEqual()方法一样,这种专门化允许它也考虑值,而不仅仅是键。

  • assertSameElements(seq1, seq2, msg=None):测试两个序列中的所有项目,仅当两个序列中的项目相同时通过。这只测试单个项目的存在,而不是它们在每个序列中的顺序。这也将接受两个字典,但会将其视为任何其他序列,因此它将只查看字典中的键,而不是它们的关联值。

测试异常

到目前为止,所有的测试方法都采用了积极的方法,即测试验证一个成功的结果确实是成功的。然而,验证不成功的结果也同样重要,因为它们仍然需要是可靠的。许多函数在某些情况下会引发异常,单元测试在验证这种行为时同样有用:

  • assertRaises (exception, callable, *args, **kwargs):这个方法不是检查一个特定的值,而是测试一个可调用函数,看它是否引发了一个特定的异常。除了异常类型和要测试的可调用对象之外,它还接受任意数量的位置和关键字参数。这些额外的参数将被传递给所提供的 callable,这样就可以测试多个流。

  • assertRaisesRegexp (exception, regex, callable, *args, **kwargs):这个方法比assertRaises()稍微具体一些,因为它也接受一个正则表达式,这个表达式必须匹配异常的字符串值才能通过。表达式可以作为字符串或编译后的正则表达式对象传入。

在我们的times2例子中,有许多类型的值不能乘以整数。这些情况可以是函数显式行为的一部分,只要它们得到一致的处理。典型的反应是引发一个TypeError,Python 默认就是这样做的。使用assertRaises()方法,我们也可以对此进行测试:

img/330715_3_En_9_Figs_HTML.jpg

import unittest

import times2

class MultiplicationTestCase(unittest.TestCase):
    def setUp(self):
        self.factor = 2

    def testNumber(self):
        self.assertEqual(times2.times2(5), 42)

    def testInvalidType(self):
        self.assertRaises(TypeError, times2.times2, {})

有些情况有点复杂,这会给测试带来困难。一个常见的例子是重写一个标准运算符的对象。您可以通过名称调用被覆盖的方法,但是简单地使用操作符本身会更具可读性。不幸的是,assertRaises()的标准形式需要一个可调用的,而不仅仅是一个表达式。

为了解决这个问题,这两种方法都可以使用with块作为上下文管理器。在这种形式中,你不需要提供一个可调用的或者参数,而是只传入异常类型和一个正则表达式(如果使用了assertRaisesRegexp())。然后,在with块的主体中,您可以添加必须引发给定异常的代码。这也比标准版本更具可读性,即使在不需要的情况下也是如此:

img/330715_3_En_9_Figt_HTML.jpg

import unittest

import times2

class MultiplicationTestCase(unittest.TestCase):
    def setUp(self):
        self.factor = 2

    def testNumber(self):
        self.assertEqual(times2.times2(5), 42)

    def testInvalidType(self):
        with self.assertRaises(TypeError):
            times2.times2({})

兼容性:3.1/2.7 之前

assertRaises()方法在 Python 2.5 之前就有了,所以它将在目前使用的大多数 Python 版本中可用。然而,正则表达式变体是在 Python 3.1 中添加的,并被移植到 Python 2.7 中。可以使用try / except组合来模拟相同的功能,以直接访问错误消息,其中可以使用正则表达式来验证其字符串值。

尽管with语句和上下文管理器都是在 Python 2.5 中引入的,但是assertRaises()直到版本 3.1 才支持上下文管理协议。因为在那个版本之前assertRaisesRegexp()方法也不存在,所以在早期版本中不支持上下文管理器。为了在没有上下文管理器的情况下达到同样的效果,您需要创建一个新的可调用函数——通常是 lambda 函数——来传递给测试方法。

测试身份

最后一组包含测试对象身份的方法。这些方法不只是检查它们的值是否相等,而是检查两个对象实际上是否相同。该测试的一个常见场景是当您的代码缓存值以供以后使用时。通过测试标识,您可以验证从缓存返回的值是否与最初放入缓存的值相同,而不仅仅是一个等效的副本:

  • assertIs(ob1, obj2, msg=None):这个方法检查两个参数是否都指向同一个对象。测试是使用对象的身份来执行的,因此,如果对象实际上不是同一个对象,则可能比较为相等的对象仍然会失败。

  • assertIsNot(obj1, obj2, msg=None):只有当两个参数指向两个不同的对象时,assertIs()的反转才会通过。即使它们在其他方面是相等的,这个测试也要求它们具有不同的身份。

  • assertIsNone(obj, msg=None):这是assertIs()常见情况的一个简单快捷方式,其中一个对象与内置的None对象进行比较。

  • assertIsNotNone(obj, msg=None):只有当提供的对象不是内置的None对象时,assertIsNone()的反转才会通过。

拆卸

正如setUp()在每个单独的测试执行之前被调用一样,TestCase对象也调用一个tearDown()方法在测试执行之后清除任何初始化的值。这在测试过程中需要在 Python 之外创建和存储信息的测试中经常使用。这种信息的例子有数据库行和临时文件。一旦测试完成,这些信息就不再需要了,所以在测试完成后进行清理是非常有意义的。

通常,一组处理文件的测试将不得不在过程中创建临时文件,以验证它们被正确地访问和修改。这些文件可以在setUp()中创建,在tearDown()中删除,确保每个测试运行时都有一个新的副本。数据库或其他数据结构也可以这样做。

注意

setUp()tearDown()的关键价值在于它们可以为每个单独的测试准备一个干净的环境。如果您需要为所有测试建立一个环境,以便在所有测试完成后共享或恢复一些更改,那么您需要在开始测试过程之前或之后这样做。

提供自定义测试类

因为unittest模块被设计成一个可以被覆盖的类,所以您可以在它上面编写您自己的类,供您的测试使用。这是一个不同于编写测试的过程,因为您为您的测试提供了更多的工具。您可以覆盖任何在TestCase上可用的现有方法,或者添加任何其他对您的代码有用的方法。

扩展TestCase有用性的最常见方式是添加新方法来测试不同于原始类的功能。文件处理框架可能包括额外的方法来测试给定文件的大小,或者关于其内容的一些细节。检索 Web 内容的框架可以包括检查 HTTP 状态代码或在 HTML 文档中查找单个标签的方法。可能性是无穷的。

改变测试行为

当创建一个测试类时,另一个可用的强大技术是改变测试本身执行方式的能力。最显而易见的方法是覆盖现有的断言方法,这可以改变这些测试的执行方式。还有一些其他方法可以改变标准行为,而不需要覆盖断言方法。

这些额外的覆盖可以在定制类的__init__()方法中管理,因为与setUp()不同,__init__()方法对于每个TestCase对象只被调用一次。这对于那些需要影响所有测试,但在测试运行时不会受到任何测试影响的定制来说是很好的。本章前面提到的一个例子是添加自定义相等比较方法的能力,这些方法用addTypeEqualityFunc()方法注册。

您可以对 test 类进行的另一个修改是定义用于识别失败的异常类型。通常情况下,所有测试失败都会在幕后引发一个AssertionError——当一个assert语句失败时使用相同的异常。如果出于某种原因需要更改,比如为了更好地与更高级别的测试框架集成,可以为failureException类属性分配一个新的异常类型。

作为使用failureException属性生成失败的副作用,您可以使用self.failureException显式地引发它来生成测试失败。这本质上与简单地调用self.fail()是一样的,但是在某些情况下,引发一个异常比调用一个方法更具可读性。

令人兴奋的 Python 扩展:Pillow

Pillow 库在处理图像时为 Python 程序员提供了强大的能力。

Pillow(或 PIL) Python 图像库为 Python 程序员处理图像提供了强大的能力。主网站python-pillow.org,提供了大量关于该图书馆的信息,包括图像存档、显示和处理三个主要功能。当然,PIL 图书馆提供的不止这些。

如何安装枕头(PIL)

在具有管理权限的命令提示符下,键入:

pip3 install pillow
(Enter)

现在您已经安装了它(如果 pip3 报告安装成功),让我们尝试一些特性。

图像显示:确定文件大小,类型,并显示它

使用您选择的 JPG 图像,尝试以下操作:

img/330715_3_En_9_Figu_HTML.jpg

#PIL example 1
from __future__ import print_function
from PIL import Image
my_image = Image.open("sleepy_sab.jpg")
#this image:  http://www.jbbrowning.com/user/pages/02.about/sleepy_sab.JPG
#show data about the image
print(my_image.format, ' Image format')
print(my_image.size, ' Image size')
print(my_image.mode, 'Color mode e.g. RGB, etc.')
#Display the image with the default image application
my_image.show()

重要的是要注意,PIL 将自动打开大多数标准的图像类型,没有任何提示,通过代码。

图像处理:裁剪图像的一部分

在本例中,我们将使用之前的 jpg 图像(因此,如果您使用不同的图像,需要调整设置)并显示原始图像,然后裁剪一点并显示新图像。此裁剪函数需要一个具有四个坐标点的元组,0,0 位于左上角:

img/330715_3_En_9_Figv_HTML.jpg

#PIL example 2
from __future__ import print_function
from PIL import Image

my_image = Image.open("sleepy_sab.jpg")

#Display the image with the default image application
my_image.show()

#Crop a portion of the image from the upper left to
#about halfway and display
#(3456, 2304) is the image size
#0,0 is upper left.  Crop wants a tuple so there are (())
region = my_image.crop((0,0,2000,2000))
region.show()

图像处理:更改图像方向

你也可以用两种不同的方式旋转图像(两种方式都一样)。在下一个示例中,我们将把图像旋转 90 度:

img/330715_3_En_9_Figw_HTML.jpg

#PIL example 3
from __future__ import print_function
from PIL import Image
my_image = Image.open("sleepy_sab.jpg")

#Rotate the image 90 degrees
turny=my_image.transpose(Image.ROTATE_90)
turny.show()

图像处理:滤镜

PIL 内置了许多滤镜,如模糊和增强。此外,还有其他用于颜色转换、像素查找等的滤镜。PIL 的主网站有当前版本的更新。要了解它们有多方便,请查看下面的示例,该示例对图像进行了浮雕处理:

img/330715_3_En_9_Figx_HTML.jpg

#PIL example 4
from PIL import Image
from PIL import ImageFilter
my_image = Image.open("sleepy_sab.jpg")

#Emboss the image
emmy=my_image.filter(ImageFilter.EMBOSS)
emmy.show()

如果你使用建议的图像,可怜的萨巴斯蒂安看起来像一块金属艺术品!你还能在 PIL 身上做更多的事情吗?没错。扩展您所学的内容,并尝试使用其他一些过滤器和处理工具。

带着它

本章描述的工具只是功能测试套件的基础。当你写一个应用时,你需要用你的代码应该如何工作的重要方面来填补空白。然而,永远记住,测试不仅仅是为你准备的。通过确保新代码不会破坏现有代码,一旦将代码发布给公众,就可以为用户提供更好的保证。下一章将展示如何让你的代码面向大众。

十、发布

一旦你有了一个可用的应用,下一步就是决定如何以及在哪里分发它。你可能是为自己写的,但最有可能的是你会有更广泛的读者,并有一个发布它的固定时间表。然而,在你这样做之前,有许多决定要做,任务要完成。这一过程主要包括包装和分销,但它始于许可。

批准

在向公众发布任何代码之前,您必须决定管理其使用的许可证。许可证将允许你向你的用户传达你打算如何使用你的代码,你期望其他人如何使用它,你从他们那里要求什么作为回报,以及你期望他们在与你的代码集成后授予他们自己的代码的用户什么权利。这些都是复杂的问题,不可能对每个项目都有一个通用的答案。相反,你需要考虑一些问题。

你自己的哲学起着关键作用,因为它会影响许多其他决定。有些人打算靠他们的代码谋生,这可能意味着源代码根本不会发布。相反,你的工作可以作为一种服务提供,客户可以付费使用。相比之下,你可能对帮助人们更好、更快、更容易或更可靠地学习做事感兴趣。也许最常见的许可证是 GPL。

通用公共许可证

当人们想到开源时,GNU 通用公共许可证(GPL) 1 往往是首先想到的。作为自由软件运动的先锋之一,它的主要目标是保护软件用户的某些自由。GPL 要求,如果你将你的程序分发给其他人,你也必须让他们可以使用该程序的源代码。这样,他们可以自由地对你的代码进行他们认为合适的修改,以便更好地支持他们自己的需求。

此外,GPL 的承诺是,任何修改你的代码的用户只能在 GPL 或至少保证同样自由的许可下发布他们的修改。通过这种方式,软件的用户可以确信,如果它不能让他们满意,他们有办法让它变得更好,不管它离原作者有多远。

因为 GPL 对原始代码和链接到它的代码的任何修改都有要求,所以它有时被称为“病毒性的”这不一定是一种侮辱;它只是指这样一个事实,GPL 对任何使用它的东西都强制使用相同的许可。换句话说,它通过软件传播,就像传统病毒一样。这不是 GPL 独有的特性,但是这是商业世界中很多人在想到 GPL 和开源时首先想到的特性。

因为 GPL 的目标是保护计算机用户的自由,它可以被视为限制程序员的自由。程序员在不泄露源代码的情况下发布应用的自由限制了用户修改代码的自由。在这两种相反的力量中,GPL 通过对程序员的行为设置一些限制来保护用户的自由。

GPL 和 Python

GPL 主要是为静态编译的语言编写的,比如 C 和 C++,所以它经常用“对象形式”的代码来表示,这些代码可能“静态链接”到其他代码。换句话说,当您创建一个 C++可执行文件时,编译器会插入您所引用的库中的代码,以生成一个独立的程序。这些术语是其词汇表的核心,但在应用于 Python 等动态语言时却不太容易理解。许多 Python 应用使用 GPL 是因为它的整体理念,但是它的术语还没有在 Python 应用的环境中经过法庭测试。

看起来这些细节并不重要,因为 Python 代码通常是作为源代码发布的。这里的术语一般都有例外,比如你用 py2exe 做了一个 Windows 编译的 Python 应用。毕竟,编译后的 Python 字节码并不与可能使用该代码的各种系统兼容。但是因为 GPL 也适用于任何其他使用该代码的应用,例如,如果一个静态编译的应用在内部为某些特性使用 GPL Python 代码,这些细节就变得很重要。这种使用是否会触发 GPL 对新应用源代码发布的要求还有待观察。

因为这些限制也必须传递给任何其他包含 GPL 代码的应用,所以可以使用的许可证是有限的。您可能考虑的任何其他许可证必须至少包括与 GPL 相同的限制,尽管如果必要的话可以添加额外的限制。这方面的一个例子是 AGPL。

Affero 通用公共许可证

随着互联网的发展,用户在没有直接获得软件拷贝的情况下与软件进行交互已经变得很普遍。因为 GPL 依赖于代码的分发来触发也分发源代码的要求,所以诸如网站和邮件系统之类的在线服务免除了这一要求。一些人认为,这些豁免利用了 GPL 条款中的漏洞,违反了 GPL 的精神。

为了弥补这个漏洞,Affero 通用公共许可证(AGPL)应运而生。本许可证包含 GPL 的所有限制以及附加功能,即任何与软件交互的用户,即使是通过网络,都将触发分发条款。这样,包含 AGPL 代码的网站必须公开他们所做的任何修改的源代码,以及与它共享公共内部数据结构的任何附加软件。虽然被大众接受有点慢,但是开放源码倡议(OSI)的批准无疑给了这个许可证重要的支持。

注意

尽管 AGPL 的术语和哲学与 GPL 非常相似,但它对 Python 的适用性更清楚一些。因为只要与软件交互就会触发许可条款,所以代码是从静态语言(如 C)编译而来还是从动态语言(如 Python)构建而来并不重要。然而,这也有待于在 Python 案件中进行法庭测试。

因为 AGPL 比 GPL 本身更加严格,所以使用 AGPL 的项目有可能合并最初用标准 GPL 许可的代码。GPL 的所有保护都保持不变,只是增加了一些额外的保护。还有一种限制更少的 GPL 变体,叫做 LGPL。

GNU 宽松通用公共许可证

因为 GPL 声明将一段代码静态链接到另一段代码会触发它的条款,所以许多小的实用程序库的使用频率比它们原本可能的要低。这些库通常不会自己构成一个完整的应用,但是因为它们的有用性需要与宿主应用紧密集成,所以许多开发人员避免使用它们,以避免他们自己的应用也绑定到 GPL。

GNU 宽松通用公共许可证(LGPL)就是通过删除静态链接条款来处理这些情况的。因此,在 LGPL 下发布的库可以在宿主应用中自由使用,而不需要宿主受 LGPL 或任何其他特定许可证的约束。即使是不打算发布任何源代码的专有商业应用也可以包含 LGPL 授权的代码。

但是,所有其他条款保持不变,因此,如果代码本身以任何方式分发,对 LGPL 代码的任何修改都必须作为源代码分发。出于这个原因,许多 LGPL 库都有非常灵活的接口,允许它们的宿主应用有尽可能多的选项,而不必直接修改代码。

本质上,LGPL 更倾向于使用开源的概念来培养一个更加开放的编程社区,而不是保护软件最终用户的权利。沿着这条路走下去是最自由的开源许可之一:BSD。

伯克利软件分发许可证

Berkeley Software Distribution(BSD)许可证提供了一种发布代码的方式,旨在促进尽可能多的采用。它通过对其他方使用、修改和分发代码施加相对较少的限制来做到这一点。事实上,许可证的整个文本仅由几个要点和一个免责声明组成。然而,将 BSD 称为单一许可证是用词不当,因为实际上有一些变体。在最初的形式中,许可证由四点组成:

  • 向程序分发源代码要求代码保留原始版权、许可证文本及其免责声明。

  • 将代码作为编译后的二进制程序分发时,需要将版权、许可文本和免责声明包含在随分发代码提供的文档或其他材料中。

  • 任何用于推广最终产品的广告必须注明 BSD 许可代码包含在产品中。

  • 未经许可本身以外的明确同意,不得使用开发软件的组织的名称或其任何贡献者的名称来专门认可该产品。

请注意,这根本不包含分发源代码的要求,即使是在分发编译后的代码时。相反,它只要求在任何时候都保留适当的归属,并且仍然明确涉及两个不同的当事方。这允许 BSD 许可的代码包含在专有的商业产品中,而不需要发布其背后的源代码,这使得它对大公司相当有吸引力。

然而,广告条款给试图使用 BSD 许可代码的组织带来了一些麻烦。主要问题是,由于代码本身易主,并由不同的组织维护,因此在任何广告材料中必须提到参与开发的每个组织的名称。在某些情况下,这可能是几十个不同的组织,占了广告空间的很大一部分,特别是当软件由于其他原因经常包含相当多的其他免责声明时。

为了解决这些问题,BSD 许可证的另一个版本被创建,没有广告条款。这个许可证被称为新的 BSD 许可证,它包含了原许可证的所有其他要求。广告条款的删除意味着 BSD 授权代码的管理变化对使用它的组织几乎没有影响,这大大扩展了它的吸引力。

BSD 许可证的另一个简化版本叫做简化 BSD 许可证。在这个版本中,甚至删除了非声明条款,只保留了包含许可证文本及其免责声明的要求。为了仍然避免不真实的认可,该版本中的免责声明包括一个额外的句子,该句子明确声明两个群体的观点是相互独立的。

其他许可证

这里列出的选项是一些更常用的选项,但是还有更多可用的选项。OSI 维护着一份开源许可清单【2】,这些许可已经被审查通过,被认为是维护了开源的理念。此外,自由软件基金会维护着它自己的许可证清单 3 这些许可证已经被批准为维护自由软件的理念。

注意

自由软件和开源软件之间的区别主要是哲学上的,但是也有一些现实世界的含义。简而言之,自由软件保留了该软件用户的自由,而开源软件关注的是软件开发模型。并非所有的许可证都被批准用于这两种用途,因此您可能需要决定哪一个对您更重要。

一旦你有了许可证,你就可以开始打包并把你的代码分发给其他可以使用它的人。

包装

单独分发一堆文件并不容易,所以你必须先把它们捆起来。这个过程被称为打包,但是它不应该与标准的 Python 包概念相混淆。传统上,一个包只是一个目录,其中有一个__init__.py文件,这个文件可以用作这个目录中包含的任何模块的名称空间。

出于分发的目的,软件包还包括文档、测试、许可证和安装说明。这些部件的排列方式使得单个部件可以很容易地取出并安装到合适的位置。通常,该结构如下所示:

AppName/
    LICENSE.txt
    README.txt
    MANIFEST.in
    setup.py
    app_name/
        __init__.py
        ...
    docs/
        ...
    tests/
        __init__.py
        ...

如您所见,实际的 Python 代码包是整个应用包的子目录,它与其文档和测试并列。包含在docs目录中的文档可以包含您喜欢的任何形式的文档,但通常是用 reStructuredText 格式化的纯文本文件,如第八章所述。tests目录包含第九章中描述的测试。LICENSE.txt文件包含您选择的许可证的副本,而README.txt提供了对您的应用、其用途和特性的介绍。

这个整体包中更有趣的特性是setup.pyMANIFEST.in,它们不是应用代码的一部分。

setup.py

在您的包中,setup.py是将您的代码实际安装到用户系统中适当位置的脚本。为了尽可能具有可移植性,这个脚本依赖于标准发行版中提供的distutils包。这个包包含一个setup()函数,它使用声明性的方法使这个过程更容易操作,也更通用。

位于distutils.core中的setup()函数接受大量的关键字参数,每个参数描述了包的一个特定特性。有些与整个软件包有关,而有些列出了软件包中包含的单个内容。这些参数中的三个是使用标准工具分发任何包所必需的:

  • name:这个字符串包含了包的公共名称,因为它将显示给那些寻找它的人。命名一个包可能是一项复杂而困难的任务,但是由于它非常主观,所以远远超出了本书的范围。

  • version:这是一个字符串,包含应用的用点分隔的版本号。第一次发布时通常会使用一个版本的'0.1'并从那里开始增加。第一个数字通常是表示兼容性承诺的主要版本。第二个是次要版本号,代表不破坏兼容性的错误修复或重要新功能的集合。第三种通常保留给没有引入新功能或其他错误修复的安全版本。

  • url:这个字符串引用了主网站,用户可以在这里了解更多关于应用的信息,找到更多的文档,请求支持,提交错误报告,或者执行其他任务。它通常充当围绕代码的信息和活动的中心枢纽。

除了这三个必需元素之外,还有几个可选参数可以提供关于应用的更多细节:

  • author:申请作者的姓名。

  • 一个可以直接联系到作者的电子邮件地址。

  • maintainer:如果原作者不再维护该应用,则该字段包含现在负责该应用的人的姓名。

  • maintainer_email:可以直接联系到维护人员的电子邮件地址。

  • description:该字符串提供了程序目的的简要描述。可以把它想象成一个单行的描述,可以和其他描述一起显示在一个列表中。

  • long_description:顾名思义,这是一个更长的应用描述。当用户请求关于特定应用的更多细节时,通常会显示这个选项,而不是在列表中使用。因为这都是在 Python 代码中指定的,所以许多发行版只是将README.txt的内容读入这个参数。

除了这些元数据,setup()函数还负责维护分发应用所需的所有文件的列表,包括所有 Python 模块、文档、测试和许可证。与其他信息一样,这些细节是使用附加的关键字参数提供的。这里列出的所有路径都是相对于setup.py本身所在的主包目录的:

  • license:这是一个文件的名称,它包含了程序分发所依据的许可证的全文。通常这个文件被称为LICENSE.txt,但是通过显式地将它作为一个参数传入,它可以被命名为您喜欢的任何名称。

  • packages:该参数接受实际代码所在的包名列表。与 license 不同,这些值是 Python 导入路径,使用句点沿路径分隔各个包。

  • package_dir:如果你的 Python 包和setup.py不在同一个目录下,这个参数提供了一种方式告诉setup()在哪里可以找到它们。它的值是一个字典,将包名映射到它在文件系统中的位置。您可以使用的一个特殊键是一个空字符串,它将使用相关的值作为根目录来查找任何没有指定显式路径的包。

  • package_data:如果你的包依赖于不是直接用 Python 写的数据文件,那些文件只有在这个参数中被引用时才会被安装。它接受一个将包名映射到其内容的字典,但是与package_dir不同,这个字典中的值是列表,列表中的每个值都是应该包含的文件的路径规范。这些路径可能包含星号,表示要匹配的广泛模式,类似于您可以在命令行上查询的内容。

对于更复杂的配置,还有其他选择,但这些应该涵盖大多数基础。更多信息,请查阅distutils文档。 4 一旦你把这些部分放好,你就会有一个看起来像这样的setup.py:

from distutils.core import setup

setup(name='MyApp',
      version='0.1',
      author='Marty Alchin',
      author_email='marty@propython.com',
      url='http://propython.com/',
      packages=['my_app', 'my_app.utils'],
)

MANIFEST.in

除了指定应该在用户系统上安装什么文件之外,软件包发行版还包括许多对用户有用的文件,而不需要直接安装。这些文件,比如文档,应该对用户可用,但是没有任何代码值,所以它们不应该安装在可执行位置。MANIFEST.in文件控制如何将这些文件添加到包中。

MANIFEST.in是一个纯文本文件,由一系列命令填充,这些命令告诉distutils包中包含哪些文件。这些命令中使用的文件名模式遵循与命令行相同的约定,允许星号作为各种文件名的通配符。例如,一个简单的MANIFEST.in可能包含包的docs目录中的任何文本文件:

include docs/*.txt

这个简单的指令将告诉disutils在 docs 目录中找到所有的文本文件,并将它们包含在最终的包中。通过用空格分隔图案,可以包括附加图案。有几个不同的命令可用,每个命令都有包含和排除版本:

  • include:最明显的选项,这个命令将查找所有匹配任何给定模式的文件,并将它们包含在包中。它们将被放在包中与它们在原始目录结构中相同的位置。

  • exclude:与include相反,它会告诉distutils忽略任何与这里给出的模式匹配的文件。这提供了一种避免包含某些文件的方法,而不必在一个include命令中明确列出每个包含的文件。一个常见的例子是将exclude TODO.txt放在一个专门包含所有文本文件的包中。

  • recursive-include:这个命令需要一个目录作为它的第一个参数,在任何文件名模式之前。然后,它在该目录及其任何子目录中查找匹配给定模式的任何文件。

  • recursive-exclude:和recursive-include一样,这个命令首先获取一个目录,然后是文件名模式。通过此命令找到的任何文件都不会包含在包中,即使它们是通过某个包含命令找到的。

  • global-include:该命令查找项目中的所有路径,不管它们在路径结构中的位置。通过查看目录内部,它的工作方式很像recursive-include,但是因为它查看所有目录,所以除了文件名模式之外,它不需要任何参数。

  • global-exclude:像global-include一样,它在源项目中的任何地方寻找匹配的文件,但是找到的文件被排除在最终的包之外。

  • 这个命令不是寻找匹配的文件,而是接受一组目录,这些目录只是完整地包含在包中。

  • prune:像graft一样,这个命令接受一组目录,但是它将它们完全从包中排除,即使包中有匹配的文件。

有了setup.pyMANIFEST.in之后,distutils提供了一种简单的打包和分发的方法。

sdist 命令

为了最终创建可发布的包,您的新setup.py实际上可以直接从命令行执行。因为这个脚本也用于以后安装软件包,所以您必须指定您希望它执行什么命令。稍后获得包的用户将使用install命令,但是要打包一个源代码发行版,命令是sdist:

$ python setup.py sdist
running sdist
...

该命令处理在setup.py中所做的声明以及来自MANIFEST.in的指令,以创建一个包含您指定要分发的所有文件的归档文件。默认情况下,你得到的存档文件的类型取决于你运行的系统,但是sdist提供了一些你可以明确指定的选项。只需将逗号分隔的格式列表传递给--format选项,即可生成特定的类型:

  • zip:Windows 机器上的默认,这种格式创建一个 zip 文件。

  • 在 Unix 机器上,包括 Mac OS,默认创建一个 gzipped tarball。要在 Windows 系统上创建这个归档文件,您需要安装一个tar的实现,比如可以通过 Cygwin 获得的那个。 5

  • bztar:这个命令在归档 tarball 上使用备用的 bzip 压缩。这也需要安装一个tar的实现。

  • ztar:这个使用更简单的compress算法来压缩 tarball。和其他的一样,使用这个选项需要一个tar的实现。

  • tar:如果有tar实用程序的实现,这个选项不使用压缩,而是简单地打包一个 tarball。

当您运行sdist命令时,您指定的每种格式的归档文件将被创建并放置在您的项目中一个新的dist目录中。每个档案的名称将简单地使用您在setup.py中提供的nameversion,用连字符隔开。前面提供的例子会产生类似于MyApp-0.1.zip的文件。

img/330715_3_En_10_Figa_HTML.jpg

让我们在一个例子中尝试所有前面的步骤。按照每个步骤创建您的 zip 包:

  1. 创建一个可以通过命令提示符(如 c:\test)轻松访问的文件夹。

  2. 在该文件夹中,创建以下两个名为 setup.py 和 MyApp.py 的文件:

    #setup.py
    from distutils.core import setup
    setup(name='MyApp',
          version='0.1',
          author='Alchin and Browning',
          author_email='authors@propython.com',
          url='http://www.propython.com/',
    )
    # MyApp.py
    print("Hello Burton and Marty!")
    gone=input("Enter to close: ")
    
    
  3. Shell 退出到命令提示符下,进入测试目录,执行命令:

    python setup.py sdist  (Enter)
    
    
  4. 按回车键。(如果它没有启动 Python,您将需要检查您的搜索路径,并确保您的系统可以找到 Python。)

这将在测试文件夹中创建一个 dist 目录,其中包含您的包的 zip 文件。

当然,这只是一个非常简单的概述,但是您可以灵活地添加清单文件、更改压缩选项等等。

分配

一旦你有了这些文件,你将需要一种方法来把它们分发给公众。一种选择是简单地托管自己的网站,并从那里提供文件。这通常是向广大读者推销您的代码的最佳方式,因为您有机会以更易读的方式将文档放在网上,展示它的使用示例,提供已经在使用它的人的评价,以及您能想到的任何其他东西。

简单地自己托管它的唯一问题是,使用自动化工具很难找到它。许多软件包将依赖于其他应用的存在,因此能够从脚本内部直接安装它们通常是有用的,而不必导航到网站并找到正确的下载链接。理想情况下,他们能够将一个唯一的包名翻译成一种无需帮助就可以下载并安装该包的方式。

这就是 Python 包索引(PyPI) 6 发挥作用的地方。PyPI 的秘密代号是“奶酪店”,暗指约翰·克立斯试图从麦克·帕林经营的商店购买奶酪的蒙蒂蟒蛇奶酪店。。。没有可用的。

PyPI 是 Python 包的在线集合,它们都遵循标准化的结构,因此更容易被发现。每个包都有一个惟一的名称,可以用来定位它,索引跟踪哪个版本是最新的,并引用该包的 URL。你所需要做的就是把你的包添加到索引中,这样你的用户会更容易使用它。

第一次上传到 PyPI 需要在网站上注册。PyPI 帐户将允许您稍后管理您的应用详细信息,并上传新版本和更新。一旦你有了一个帐户,你就可以运行python setup.py register在 PyPI 上为你的应用建立一个页面。这是一个交互式脚本,将为您注册帐户提供三个选项:

  • 使用现有的 PyPI 帐户。如果您已经在 PyPI 网站上创建了一个帐户,您可以在这里指定您的用户名和密码。

  • 注册一个新的 PyPI 帐户。如果您想在命令行创建一个帐户,您可以在这里输入您的详细信息,并在注册时创建帐户。

  • 生成一个新的 PyPI 帐户。如果你想采用一种更简单的方法,这个选项将采用你已经在操作系统中使用的用户名,自动生成一个密码,并为该组合注册一个帐户。

一旦你选择了你的选项,注册脚本将会在本地保存你的账户信息,这样你就不用每次都经历那个步骤了。有了帐户后,脚本将使用setup.py中的信息向 PyPI 注册应用。特别是,namelong_description字段将组合成一个简单的网页,其他细节显示在一个列表中。

有了保存应用的页面,最后一步是使用upload命令上传代码本身。这必须作为发行版构建的一部分来完成,即使您之前已经构建了一个发行版。这样,您就可以准确地指定您想发送给 PyPI 的发行版的类型。例如,您可以在一个步骤中为 Windows 和非 Windows 用户上传包:

$ python setup.py sdist --format=zip,gztar upload

发行版文件是根据应用的名称和发行版创建时的版本号来命名的。PyPI 中的条目还包含对版本号的引用,因此您不能多次上传相同版本的相同发行版类型。如果你尝试,你会从setup.py得到一个错误,表明你需要创建一个新的版本号来上传一个改变的发行版。

令人兴奋的 Python 扩展:秘密模块

Secrets 模块为 Python 程序员提供了一些方便的随机数和密码生成工具。它的主要特点是随机数算法的加密特性。

Python 3.6 中引入的 secrets 模块有许多可用的函数。一个是随机数生成。虽然其他一些库已经介绍了这一点,但研究一下仍然很有意思。

您的计算机操作系统会考虑所生成的随机数的确切性质,但通常对于加密工作,这个随机库会比 Python 中的其他随机数生成器做得更好。这种加密用途包括:密码、认证和令牌。请继续阅读,看看这个模块有多方便。

随机数

有相当多的随机令牌和随机数生成选项。为了了解它们是如何工作的,考虑下一个例子将在 0 到 100 之间选择一个随机数。

img/330715_3_En_10_Figb_HTML.jpg

#Secrets example 1
from secrets import *
x=1
while (x <= 10):
    print(randbelow(100))
    x+=1

在前面的例子中,我们从 1 到 100 中选择了 10 个随机值。不令人兴奋,但随机值的更好的加密表示。接下来,我们将考虑随机密码生成。

密码生成

在下一个示例中,我们将使用字符串库和机密库来生成包含 ASCII 字母、数字、标点符号和大写字母的密码:

img/330715_3_En_10_Figc_HTML.jpg

#Generate six digit passwd with letters, digits, punct, and upper
import string
from secrets import *
chars = string.ascii_letters + string.digits + string.punctuation + string.ascii_uppercase
password = ".join(choice(chars) for i in range(6))
print (password)

如果你需要一个用于加密工作的令牌,有包括 urlsafe 在内的选项。考虑以下示例:

img/330715_3_En_10_Figd_HTML.jpg

#Generate a token value which is URL-safe
from secrets import *
value = token_urlsafe(10)
print('token is: ',value)

这里我们使用的是选择,但是使用这个库,您可以尝试以下操作:

img/330715_3_En_10_Fige_HTML.jpg

#Generate a secrets random choice
from secrets import *
value = choice(['one', 'two', 'three'])
print (value)

最后,如果您想输入值并从中选择一个随机集,请尝试以下方法:

img/330715_3_En_10_Figf_HTML.jpg

#Generate a random choice based on only certain values
from secrets import *
foo=input('Enter 10 random values to choose from:  ')
wow=“.join([choice(foo) for i in range(3)])
print('These are three exciting choices at random:>   ',wow)

这里没有什么可以从僵尸的启示中拯救世界,但是这些例子仍然是 Python secrets 模块非常有趣的用法。

带着它

如您所见,使用 PyPI 打包和分发 Python 应用的过程实际上相当简单。除了 PyPI,建立一个专门的项目网站通常是一个好主意,在那里你可以更好地推广和支持你的代码。永远记住,分销不是最后一步。你的用户在使用你的代码时会期望一定的支持和互动,并希望改进它,所以最好找到一种能为你和你的用户支持这些目标的媒介。

所有不同规模、受众和目标的应用都是公平的分配对象。无论您是在编写一个小工具来帮助自动化常见任务,还是编写一个完整的框架来为其他用户的代码提供一组功能,都没有关系。下一章将向你展示如何从头到尾建立这样一个框架,建立在贯穿本书的许多技术之上。

十一、工作表:CSV 框架

当然,编程最重要的是程序。如果工具、技术、哲学和建议从来没有被应用于解决现实世界的问题,那么它们根本没有什么价值。有时那个问题非常具体,但其他时候它仅仅是一个更普遍问题的具体例子。这些一般性问题通常是库和框架的主题,它们可以为更具体的应用提供基础。

这使得框架处于一个有趣的位置,因为它们更关注于服务开发者的需求,而不是普通用户。目标是提供一个基础和一套工具来帮助其他人开发更具体的应用。支持更广泛的用途需要比通常直接解决问题更先进的技术。

然而,为了对其他开发人员有用,理想的目标是提供一种翻译服务,以便框架使用的高级技术允许其他开发人员使用更简单的技术来执行那些更高级的任务。在这方面,框架设计非常类似于其他形式的设计,但不是主要集中在视觉用户界面上,而是集中在应用的编程接口,即 API 上。

像这样看待框架是很重要的,因为如果你正在写一个框架,你的读者正在寻找一个工具来节省他们的时间和精力,这样他们就可以专注于他们独特的需求。框架应该提供一组特性,鼓励与其他类型的应用集成,因此有必要考虑其他应用应该如何工作。

已经有无数使用框架的例子,服务于各种各样的需求。它们都解决一类通用的问题,比如用于 Web 开发的 Django 1 ,用于数据库交互的 SQLAlchemy 2 ,以及用于网络协议的 Twisted 3 。每一种都采用不同的方式向开发人员展示接口的风格和形式,突出了框架可以操作的各种方式。

本章将展示一个框架,它使用的声明性语法类似于 Django 和 Elixir 中使用的语法。这种方法的选择在很大程度上是基于风格的,即使有其他方法可以使用,详细研究其中一种方法将突出许多在编写框架时必须做出的决定。您将看到本书中展示的所有技术结合起来形成一个单一的、内聚的整体,公开一个提供许多有用特性的公共 API。

本章要解决的一个特殊问题是需要处理以逗号分隔值的行存储信息的文件,通常称为 CSV 文件。这些方法可用于分隔一行中的值、分隔行本身以及对每行中的单个值进行编码等任务,这就是为什么它会成为一个非常复杂的主题。

Python 已经提供了一个csv模块来帮助处理 CSV 文件。 4 与其试图复制它的功能,不如使用csv在幕后完成大部分繁重的工作。相反,我们要做的是在csv之上构建一个层,使它更容易与其他应用一起工作和集成。本质上,我们只是在现有 API 的基础上提供了一个新的 API,希望我们能让它更友好一些。

构建声明性框架

使用类似于 Django 或 Elixir 的声明性语法构建框架需要几个步骤,但是这个过程本身并不那么困难。然而,在此过程中做出决策是事情变得棘手的地方。在这一章中,我们将概述建立这样一个框架所需的各个步骤,以及你必须做出的许多决定的例子。然而,每一个都必须为你自己的项目特别制作。

但你不会孤军奋战。过程中的每一个决策点都会概述各种选择的利弊,这样你就可以自信地做出明智的选择。从一开始就做出正确的决策将有助于确保您的框架能够经受住未来的升级,以及那些不同意您观点的人的批评。只要确保你的决定背后有有效的、真实的推理,你就会没事。

本章不会只给你留下理论,而是会一步一步地创建一个框架,这个框架简单到足以介绍基本概念,而不必花太多时间在特定于其目的的事情上。它还需要是一个很好的例子,说明什么时候应该使用声明性框架,这首先需要我们理解我们真正在看什么。单词 step 对你来说是一个重要的术语,因为后面的例子会被添加进去,为什么它们是脚本。

介绍声明式编程

从本质上来说,声明性框架是一个助手,可以使声明性编程变得更容易——或者在某些情况下,成为可能。当然,如果没有定义是什么使它成为声明性的,那么这个定义是没有用的,但是谢天谢地,几乎不需要什么介绍。毕竟,您已经看到了声明式编程的实际应用,并且可能已经使用了很长一段时间,甚至可能没有意识到这一点。

声明式编程是告诉程序你想要什么(声明),而不是告诉它做什么(指示)。这种区别实际上更多的是关于程序员而不是程序,因为通常没有特殊的语法、解析或处理规则,也没有单一的方法来定义什么合格什么不合格。它通常被定义为命令式编程的对立面,在命令式编程中,程序员被期望概述计算机需要执行的每一个步骤。

考虑到这一点,很容易注意到更高级别的解释型语言,如 Python,比它们的低级同类,如 c,更适合于声明性编程。不必声明一个内存位置,指定它的类型,然后在内存中的那个位置存储一个值,只需分配一个变量,剩下的工作由 Python 完成。下面的代码生成一个名为 foo 的字符串变量,其中存储了“bar ”:

img/330715_3_En_11_Figa_HTML.jpg

>>> foo = 'bar'

这只是声明式编程的一种形式,使用一种语法。然而,当我们谈论 Python 中的声明性框架时,它通常指的是使用类声明来配置框架,而不是一组又长又复杂的配置指令。这是否是满足您需求的正确方法,需要对利弊进行更多的讨论。

建还是不建?

在过去的几年中,声明性框架在 Python 世界中已经成为一种上升趋势,但是理解它们并不总是解决给定问题的最佳方法是很重要的。像其他事情一样,决定是否使用声明式框架需要理解它到底是什么,它做什么,以及它对您的需求意味着什么。

声明式框架很好地将许多复杂的行为包装到一个简单的类声明中。这可以节省大量的时间,但是看起来也很像魔术,这是 Python 社区一直在与之斗争的东西。这是好是坏完全取决于您的 API 与用户对类声明的期望有多接近,以及您对那些期望可能失败的领域的文档记录有多好。

通过将类作为将您的意图传达给框架的主要方法,期望实例有意义是合理的。大多数情况下,实例指的是符合类声明定义的格式的一组特定数据。如果您的应用只处理一组定义明确的数据,那么拥有单独的实例就没什么用了。

声明性类旨在使用相同的框架创建许多不同的配置,每个配置都是为特定的数据配置而设计的。如果你只有一种数据格式可以使用——即使你有大量的数据——编写一个为可配置性而构建的框架是没有意义的。只需针对您的数据类型编写一个解决方案并使用它。

在其他情况下,您可能无法提前描述数据集的结构,而是必须根据所提供的数据来调整结构。在这些情况下,提供类声明没有什么价值,因为没有一个声明能够满足您正在处理的数据的需要。

对象的主要价值是通过实例方法对其内容执行操作的能力。因为一个声明性的框架会产生生成单个实例的定制类,所以这些实例应该能够执行有用的任务,如果没有框架的帮助,这些任务会变得更加困难。这不仅增加了它们的有用性,而且有助于确保生成的实例符合用户的期望。

回顾一下,声明性框架是一种很有价值的方法,如果您有:

  • 许多可能的配置

  • 每个配置都是预先知道的

  • 任何给定配置的许多实例

  • 可以在给定实例上执行的操作

本章描述的 CSV 框架需要处理大量可能的列和结构配置,每种类型都有许多示例文件。加载和保存数据等操作是常见的,而其他操作则是特定配置所特有的。

一旦完成,该框架将允许应用将 CSV 配置指定为如下类,并使用自动附加到该类的方法与它们进行交互。

为了确保你有合适的库,去pypi.python.org/pypi/Sheets/下载 Sheets ZIP 文件。将其解压缩,并将所有文件夹和文件放入 Python 3.x Lib 目录中(或者使用 pip 来安装):

img/330715_3_En_11_Figb_HTML.jpg

import sheets

class EmployeeSheet(sheets.Row):
    first_name = sheets.StringColumn()
    last_name = sheets.StringColumn()
    hire_date = sheets.DateColumn()
    salary = sheets.FloatColumn()

所以让我们开始吧。

构建框架

任何声明性框架都有三个主要组件,尽管其中一个可能有不同的形式,或者根本没有:

  • 一个 基类:因为声明式框架都是关于声明类的,拥有一个公共基类来继承给了框架一个地方来挂接和处理 Python 遇到的声明。附加到这个基类的元类提供了必要的机制来在运行时检查声明并进行适当的调整。基类还负责表示框架封装的任何结构的实例,通常有各种方法附加到简单的公共过程。

  • 各种字段类型:在类声明中有许多属性,通常称为字段。对于某些应用,更具体地称呼它们可能更有意义,但是对于本讨论,字段就足够了。这些字段用于管理由框架表示的结构中的单个数据属性,通常有不同的风格,每种风格都适合不同的一般数据类型,如字符串、数字和日期。字段的另一个重要方面是它们必须能够知道它们被实例化的顺序,因此声明中指定的顺序与后面使用的顺序相同。

  • 一个 选项容器:严格来说,这不是一个必要的组件,大多数框架都使用某种类型的类级选项,这不应该在每个单独的字段上指定,因为那样不会很枯燥。因为子类化除了基类的选择之外不提供任何选项,所以必须使用一些其他的结构来管理这些选项。这些选项的声明和处理方式在不同的框架之间会有很大的不同;没有任何句法或语义标准。为了方便起见,这个容器通常还管理附加到类的字段。

作为一种语法辅助,大多数声明性框架还确保所有这三个组件都可以从一个位置导入。这允许最终用户代码有一个更简单的导入块,同时在一个可识别的名称空间中包含所有必要的组件。这个名称空间的名称应该有意义,以便在最终用户代码中易于阅读。框架本身的名称通常是一个理想的选择,但是描述性很重要,所以在阅读时要确保它是有意义的。

尽管可以在这个过程的后期决定如何命名这个框架,但是在早期就想好一个名字是很有帮助的,哪怕只是命名包含下面几节中描述的模块的包。使用类似于csv的占位符目前可以很好地工作,但是因为 Python 有自己的csv模块——我们也将依赖于它——重用这个名称会导致很多问题。因为 CSV 文件通常用于在电子表格应用之间交换数据,我们将把我们的小框架称为sheets

看起来我们的旅程应该从基类开始,但是实际上这三个组件中的任何一个都可以是一个合理的起点。这往往取决于哪一块最需要思考,做的工作最多,或者首先需要测试。对于这个讨论,我们将从选项容器开始,因为它的创建不依赖于其他组件的实现细节。这避免了留下太多尚未描述的功能的模糊引用。

管理选项

选项组件的主要目的是存储和管理给定类声明的选项。这些选项不特定于任何一个字段,而是应用于整个类,或者用作单个字段可以选择性覆盖的默认值。现在,我们将把如何声明这些选项的问题放在一边,只关注容器本身及其相关需求。

从表面上看,选项只是名称到值的映射,所以我们可以使用一个简单的字典。毕竟,Python 有一个很棒的字典实现,简单肯定比复杂好。然而,编写我们自己的类为我们提供了一些非常方便的额外特性。

首先,我们可以验证为给定类定义的选项。可以根据它们的单个值、它们与其他选项的组合、它们对于给定执行环境的适用性以及它们是否是已知选项来验证它们。使用字典,我们只能简单地允许任何选项有任何类型的值,即使这毫无意义。

选项中的错误只有在依赖它们的代码因为它们不正确或丢失而阻塞时才会被发现,而这种类型的错误通常不是非常具有描述性的。对自定义对象进行验证意味着我们可以向试图使用不正确或无效选项的用户提供更有用的消息。

使用自定义类还意味着我们添加自己的自定义方法来执行任务,这些任务虽然有用,但要么是重复的,要么不属于其他任何地方。验证方法可以验证所有包含的选项是否合适,如果不合适,则显示有用的消息。还要记住,选项容器经常管理字段,因此可以为此添加一些方法;这些将在本节稍后介绍。

事实上,通过结合这两个特性,options 类甚至可以在所提供选项的上下文中验证字段声明。试着用一本普通的字典做那件事。

因为它可能最终封装了相当多的功能,我们将为选项容器设置一个新的模块,明确地命名为options.py。像大多数类一样,大部分工作将在__init__()方法中完成。出于我们的目的,这将接受所有已知的选项,将它们作为属性存储起来,并设置一些其他的属性,这些属性将在以后被其他方法使用。验证通常只在主动定义选项时有用,所以它属于自己的方法,以免陷入这个方法。

因此,我们来到你的框架中的下一个决定:你应该接受什么选择?不同的框架显然会有不同的需求,在一开始就尽可能完整地将它们布局出来是很重要的。别急,随时可以补充更多;最好是让他们早一点到位,而不是晚一点。

一个有用的经验法则是,选项应该总是有默认值。要求你的用户不仅编写一个类和提供字段,而且每次都提供选项会令人沮丧,特别是如果必需的选项经常具有相同的值。一般来说,如果某些东西确实是必需的,并且没有合理的默认值,那么它应该作为一个参数提供给需要它的方法,而不是定义为类的一个选项。

我们正在构建一个与 CSV 文件接口的框架,因此有许多选项可用。也许最明显的是文件的字符编码,但是当文件以文本模式打开时,Python 已经将文件内容转换为 Unicode。open()函数接受一个encoding参数,该参数允许字符串的encode()方法使用所有相同的编码。它默认为 UTF-8,这应该足以满足大多数常见的需求。

注意

读取文件时使用的编码似乎是一个完美的选择,因此您可以覆盖默认的 UTF-8 行为。不幸的是,标准的 CSV 接口要求文件在传入时已经打开,所以如果我们的框架遵循相同的接口,我们无法控制编码。控制它的唯一方法是改变接口来接受一个文件名而不是一个打开的文件对象。

CSV 文件中的一个常见变化是它们是否包含标题行,包含各列的标题。因为我们稍后将在框架中将列定义为字段,所以我们并不真正需要那个标题行,所以我们可以跳过它。但前提是我们知道它在那里。一个简单的布尔值(在更常见的情况下默认为False)就能很好地完成这个任务:

img/330715_3_En_11_Figc_HTML.jpg

class Options:
    """
    A container for options that control how a CSV file should be handled when
    converting it to a set of objects.

    has_header_row
        A Boolean indicating whether the file has a row containing header
        values. If True, that row will be skipped when looking for data.
        Defaults to False.
    """

    def __init__(self, has_header_row=False):
        self.has_header_row = has_header_row

这里我们有一个简单但有用的选项容器。在这一点上,它相对于字典的唯一好处是,除了我们指定的选项之外,它会自动禁止任何其他选项。稍后我们会回来添加一个更严格的验证方法。

如果你熟悉 Python 的csv模块,你可能已经知道它包含了多种选项,作为对不同方言支持的一部分。因为sheets实际上将遵从该模块的大部分功能,所以除了我们自己的选项之外,支持所有相同的选项是有意义的。事实上,为了更好地反映已经在使用的词汇,重命名我们的OptionsDialect是有意义的。

然而,让我们采取一种更具前瞻性的方法,而不是单独列出所有受csv支持的选项。我们依赖于我们控制之外的代码,试图跟上代码在未来可能引入的任何变化有点麻烦。特别是,我们可以支持任何现有的选项以及任何未来的选项,只需将任何额外的选项直接传递给csv本身。

为了在不命名选项的情况下接受它们,我们转向 Python 对使用双星号语法的额外关键字参数的支持。这些额外的选项可以作为字典存储起来,稍后将被传递给csv函数。接受它们作为一组关键字参数而不是一个字典有助于统一所有的选项,这在我们实际解析类声明之外的选项时非常重要:

img/330715_3_En_11_Figd_HTML.jpg

class Dialect:
    """
    A container for dialect options that control how a CSV file should be
    handled when converting it to a set of objects.

    has_header_row
        A Boolean indicating whether the file has a row containing header
        values. If True, that row will be skipped when looking for data.
        Defaults to False.

    For a list of additional options that can be passed in, see documentation
    for the dialects and formatting parameters of Python's csv module at
    http://docs.python.org/library/csv.html#dialects-and-formatting-parameters
    """

    def __init__(self, has_header_row=False, **kwargs):
        self.has_header_row = has_header_row
        self.csv_dialect = kwargs

这个类以后会增加更多的特性,但这已经足够了。在我们结束之前,我们还会再讨论几次,但是现在,让我们继续讨论这个小框架中最重要的部分:字段。

定义字段

字段通常只是特定数据的容器。因为它是一个通用术语,不同的学科可能会用更具体的东西来指代同一个概念。在数据库中,它们被称为列。在表单中,它们通常被称为输入。当执行一个函数或程序时,它们被称为参数。为了保持这个框架之外的一些观点,本章将把所有这样的数据容器称为字段,尽管对于sheets本身,术语“列”在命名单个类时更有意义。

首先要定义的是一个基本字段类,它将描述字段的含义。没有任何特定数据类型的任何细节,这个基类管理字段如何与系统的其余部分相适应,它们将有什么 API,以及子类被期望如何行为。因为我们的框架称它们为列,我们将启动一个名为columns.py的新模块并开始工作。

字段是 Python 对象,作为类声明的一部分进行实例化,并作为类的属性进行赋值。因此,__init__()方法是进入字段功能的第一个入口点,也是唯一可以将字段配置为声明的一部分的地方。__init__()的参数可能因字段类型而异,但通常至少有几个参数适用于所有字段,因此可以由基类处理。

首先,每个字段可以有一个标题。这使得代码更具可读性和可理解性,同时也为其他工具自动记录字段提供了一种方法,这些工具不仅记录了字段的属性名称,还记录了更有用的信息。计划验证不会有什么坏处,所以我们还将添加一种方法来指示字段是否是必需的:

img/330715_3_En_11_Fige_HTML.jpg

class Column:
    """
    An individual column within a CSV file. This serves as a base for attributes
    and methods that are common to all types of columns. Subclasses of Column
    will define behavior for more specific data types.
    """

    def __init__(self, title=None, required=True):
        self.title = title
        self.required = required

注意标题是可选的。如果没有提供标题,可以从字段被分配到的属性名称中收集一个简单的标题。不幸的是,这个领域还不知道这个名称是什么,所以我们将不得不在以后回来使用这个功能。我们还假设大多数字段都是必需的,所以这是默认设置,可以基于每个字段被覆盖。

小费

必填字段可能看起来对 CSV 框架没有太大价值,因为数据来自文件而不是直接来自用户,但它们可能很有用。对于像sheets这样的东西,它最终可以验证传入的文件或者将要保存到传出文件的数据。对于任何框架来说,在一开始就包含这个特性,以支持以后可以添加的特性,通常是一个好的特性。

对于框架的字段,您可能已经有了其他的想法。如果是这样,现在可以按照相同的基本模式随意添加它们。不过,不要担心一开始就计划好一切;以后还有很多机会添加更多。下一步是将字段正确地连接到它们相关的类。

将字段附加到类

我们需要设置钩子来从字段被分配到的类中获取额外的数据,包括字段的名称。这个新的attach_to_class()方法——顾名思义——负责将字段附加到它被分配到的类。即使 Python 自动地将属性添加到它们被赋值的类中,这种赋值并没有向属性传递任何东西,所以我们必须在元类中这样做。

首先,我们需要决定属性需要知道哪些关于它是如何被赋值的信息。在前一节准备了标题之后,很明显属性需要知道在分配时它被赋予了什么名称。通过在代码中直接获得该名称,我们可以避免将名称作为属性实例化的参数单独写出的麻烦。

框架的长期灵活性还将依赖于为属性提供尽可能多的信息,以便它们可以通过自省它们所附加的类来轻松提供高级功能。不幸的是,名字本身并没有说明属性现在所在的类,所以我们也必须在元类中提供这个属性。

最后,之前定义的选项,比如encoding,会对属性的行为产生一些影响。与其期望属性必须根据传入的类来检索这些选项,不如简单地接受这些选项作为另一个参数。这留给我们一个看起来像这样的attach_to_class():

img/330715_3_En_11_Figf_HTML.jpg

class Column:
    """
    An individual column within a CSV file. This serves as a base for attributes
    and methods that are common to all types of columns. Subclasses of Column
    will define behavior for more specific data types.
    """

    def __init__(self, title=None, required=True):
        self.title = title
        self.required = required

    def attach_to_class(self, cls, name, options):
        self.cls = cls
        self.name = name
        self.options = options

仅这一点就允许属性对象的其他方法访问大量的信息,比如类名、在它上面声明的其他属性和方法、它是在哪个模块中定义的等等。然而,我们需要对这些信息执行的第一个任务要稍微平凡一些,因为我们仍然需要处理标题。如果在创建属性时没有指定标题,此方法可以使用名称来定义一个标题:

img/330715_3_En_11_Figg_HTML.jpg

class Column:
    """
    An individual column within a CSV file. This serves as a base for attributes
    and methods that are common to all types of columns. Subclasses of Column
    will define behavior for more specific data types.
    """

    def __init__(self, title=None, required=True):
        self.title = title
        self.required = required

    def attach_to_class(self, cls, name, options):
        self.cls = cls
        self.name = name
        self.options = options
        if self.title is None:
            # Check for None so that an empty string will skip this behavior
            self.title = name.replace('_', ' ')

这种添加接受带下划线的属性名,并使用多个单词将其转换为标题。我们可以强加其他约定,但是这足够简单,在大多数情况下是准确的,并且符合常见的命名约定。这种简单的方法将涵盖大多数用例,并且不难理解或维护。

正如评论所指出的,这个新特性的if测试违背了标准习惯用法,它明确地检查了None,而不是简单地让一个未指定的标题评估为False。在这里以“正确”的方式做事将会消除将空字符串指定为标题的能力,这可以明确地表示没有标题是必要的。

检查None允许空字符串仍然保留该字符串作为标题,而不是用属性名替换它。空标题有用的一个例子是作为一种方式来指示该列不需要在文件数据的显示中呈现。这也是一个很好的例子,说明注释对于理解一段代码的意图是至关重要的。

小费

尽管这个attach_to_class()方法不使用所提供的选项,但是将它包含在协议中通常是一个好主意。下一节将展示选项将作为类的一个属性可用,但是将它作为自己的参数传递会更清楚一些。如果您的框架需要将这些类级别的选项应用到单个字段,那么接受它作为参数比从类中提取它更容易。

添加元类

有了attach_to_class()方法,我们现在必须进入等式的另一边。毕竟attach_to_class()只能接收信息;元类负责提供这些信息。直到现在,我们甚至还没有开始研究这个框架的元类,所以我们需要从基础开始。

通过子类化type,所有的元类开始都是一样的。在这种情况下,我们还将添加一个__init__()方法,因为我们所需要的就是在 Python 完成它们之后处理类定义的内容。首先,元类需要识别类中定义的任何选项,并创建一个新的Dialect对象来保存它们。有几种方法可以解决这个问题。

最明显的选择是简单地将选项定义为类级别的属性。这将使以后定义单独的类变得容易,但是它会带来一些可能不太明显的问题。首先,它会搞乱主类名称空间。如果您试图创建一个类来处理包含编码文档信息的 CSV 文件,那么您可能有一个名为encoding的列。因为我们也有一个名为encoding的类选项,所以我们必须给我们的列起一个别的名字,以避免其中一个覆盖另一个并导致问题。

更实际的情况是,如果选项包含在它们自己的名称空间中,就更容易挑选出来。通过能够容易地识别哪些属性是选项,我们可以将它们作为参数传递给Dialect,并且立即知道是否有任何属性丢失或者是否指定了无效的名称。所以现在的任务是确定如何为选项提供新的名称空间,同时仍然将它们声明为主类的一部分。

最简单的解决方案是使用内部类。除了其他属性和方法,我们可以添加一个名为Dialect的新类来包含各种选项赋值。这样,我们可以让 Python 为我们创建和管理额外的名称空间,这样我们所要做的就是在属性列表中查找名称Dialect并将其提取出来。

小费

尽管内部的Dialect类与其他属性和方法一起驻留在主名称空间中,但是冲突的可能性要小得多,因为它只有一个名称,而不是几个。此外,我们使用以大写字母开头的名称,这对于属性和方法名称是不鼓励的,因此冲突的可能性更小。因为 Python 名称是区分大小写的,所以您可以自由地在类上定义一个名为dialect(注意小“d”)的属性,而不用担心会碰到这个Dialect类。

为了提取这个新的Dialect类,我们将转向这个框架中元类的第一个实现。因为这将有助于形成未来继承的基类,我们将把代码放入一个新的模块中,命名为base.py:

img/330715_3_En_11_Figh_HTML.jpg

from sheets import options

class RowMeta(type):
    def __init__(cls, name, bases, attrs):
        if 'Dialect' in attrs:
            # Filter out Python's own additions to the namespace
            items = attrs['Dialect'].__dict__.items()
            items = dict((k, v) for (k, v) in items if not k.startswith('__'))
        else:
            # No dialect options were explicitly defined
            items = {}
        dialect = options.Dialect(**items)

既然选项已经从类定义中提取出来,并且已经填充了一个Dialect对象,我们将需要对这个新对象做一些事情。我们从上一节中对attach_to_class()的定义中知道,它被传递给每个已定义的字段属性的方法,但是还有什么呢?

本着为以后保留尽可能多的信息的精神,我们将把它分配给类本身。但是因为大写的名字不如属性名好用,所以最好改名为更合适的名字。因为它还形成了框架内部工作的私有接口,所以我们可以在新名称前加上下划线,以进一步防止任何意外的名称冲突:

img/330715_3_En_11_Figi_HTML.jpg

from sheets import options

class RowMeta(type):
    def __init__(cls, name, bases, attrs):
        if 'Dialect' in attrs:
            # Filter out Python's own additions to the namespace
            items = attrs.pop('Dialect').__dict__.items()
            items = {k: v for k, v in items if not k.startswith('__')}
        else:
            # No dialect options were explicitly defined
            items = {}
        cls._dialect = options.Dialect(**items)

这个简单的更改将它从原来的类名称空间中移除,并以新名称_dialect插入。这两个名称都避免了与公共属性名称的冲突,但是这一更改使它使用了一个更标准的私有属性名称。以前,它使用标准样式命名一个类,因为这是它的定义方式。

至此,我们终于有了继续处理字段属性的所有内容。第一个任务是在类定义中找到它们,并对找到的任何一个调用attach_to_class()。这可以通过一个简单的属性循环轻松实现:

img/330715_3_En_11_Figj_HTML.jpg

from sheets import options

class RowMeta(type):
    def __init__(cls, name, bases, attrs):
        if 'Dialect' in attrs:
            # Filter out Python's own additions to the namespace
            items = attrs.pop('Dialect').__dict__.items()
            items = {k: v for k, v in items if not k.startswith('__')}
        else:
            # No dialect options were explicitly defined
            items = {}
        cls._dialect = options.Dialect(**items)

        for key, attr in attrs.items():
            if hasattr(attr, 'attach_to_class'):
                attr.attach_to_class(cls, key, cls._dialect)

这个简单的元类包含一个循环,该循环只检查每个属性,看它是否有一个attach_to_class()方法。如果是,则调用该方法,传入类对象和属性的名称。这样,所有的列都可以在过程的早期获得它们需要的信息。

鸭子打字

这个元类使用 hasattr()来检查 attach_to_class()方法的存在,而不是简单地检查属性是否是 Column 的实例。Column 的所有实例都应该有必要的方法,但是通过使用 hasattr(),我们可以为任何类型的对象打开它。您可以将 attach_to_class()添加到其他类型的属性、描述符甚至方法中,从而快速方便地访问更高级的功能。元类只检查它到底需要什么,其余的留给灵活性,这是 duck 类型化的主要好处。这个名字来自于众所周知的鸭子测试的应用概念,该测试声明“如果它像鸭子一样摇摇摆摆,像鸭子一样嘎嘎叫,那么它一定是一只鸭子,”以确定是否应该使用一个对象。

现在,填写剩下的base.py所需要的就是包含一个真正的基类,单个 CSV 定义可以继承这个基类。因为每个子类都是电子表格中的一行,所以我们可以将基类命名为Row来表示它的用途。目前它需要做的就是将RowMeta作为它的元类,它将自动获得必要的行为:

img/330715_3_En_11_Figk_HTML.jpg

#in base.py
class Row(metaclass=RowMeta):
    pass

将它整合在一起

从技术上讲,现在所有的部分都已经就绪,至少可以演示一个工作系统的基础,但是仍然有一个重要的部分需要处理。目前我们有三个不同的模块,每个模块都有一些需要在公共 API 中公开的部分。理想情况下,所有重要的部分都应该可以从一个中心导入获得,而不是三个,甚至更多。

如果您还没有创建模块,那么在与前面提到的其他脚本相同的目录中创建一个__init__.py模块。该文件可以是空的,并且仍然能够单独导入所有的包,但是只需一点努力,就可以更好地利用它。因为这是在简单地直接导入包名时导入的文件,所以我们可以使用它作为触发器,从所有其他文件中提取有用的部分:

img/330715_3_En_11_Figl_HTML.jpg

打开__init__.py,把这段代码放进去:

from sheets.base import *
from sheets.options import *

from sheets.columns import *

注意

通常,使用星号来导入所有内容是一个坏主意,因为它会使识别什么来自哪里变得更加困难。因为这个模块只是导入代码,并不做任何事情,所以这个问题并不存在。只要包是自己导入的,比如import sheets,就不会有对象来自哪里的困惑。因为我们不需要提到任何对象的名字,这也适用于我们可能添加到那些模块中的任何东西。

现在我们有足够的工作部件来表明框架可以工作,至少在非常基础的水平上。如果我们从框架代码本身向上创建一个example.py目录,那么sheetsPYTHONPATH上,我们现在可以创建一个类,它做一些非常简单的工作来显示它开始组合在一起:

img/330715_3_En_11_Figm_HTML.jpg

import sheets

class Example(sheets.Row):
    title = sheets.Column()
    description = sheets.Column()

if __name__ == '__main__':
    print(Example._dialect)
    print(Example.title)

然而,到目前为止,这实际上只是允许我们命名列。为了将它们与 CSV 文件中的数据对齐,我们需要知道字段在类中定义的顺序。

排序字段

目前,这些字段都可以作为类本身的属性使用。这允许您获得关于单个字段的一些信息,但前提是您知道字段的名称。如果没有名字,你就必须检查这个类的所有属性,并检查哪些属性是Column或者它的子类的实例。然而,即使您这样做了,您仍然不知道它们被定义的顺序,所以不可能用 CSV 文件中的数据来排列它们。

为了解决这两个问题,我们需要建立一个列列表,其中每个列都可以按照定义的顺序存储。但是首先,我们需要能够在运行时识别订单,而不需要询问开发人员。至少有三种不同的方法可以做到这一点,每种方法都有自己的好处。

宣布目标。__ _ _ _ 准备 _ _()

第四章展示了当 Python 处理组成类定义的代码块时,元类可以控制类名称空间的行为。通过在声明性元类上包含一个__prepare__()方法——在本例中是RowMeta——我们可以提供一个有序字典,然后它可以保持属性赋值本身的顺序。这就像导入一个有序字典实现并从一个定制的__prepare__()方法返回它一样简单:

img/330715_3_En_11_Fign_HTML.jpg

from collections import OrderedDict

from sheets import options

class RowMeta(type):
    def __init__(cls, name, bases, attrs):
        if 'Dialect' in attrs:
            # Filter out Python's own additions to the namespace
            items = attrs.pop('Dialect').__dict__.items()
            items = {k: v for k, v in items if not k.startswith('__')}
        else:
            # No dialect options were explicitly defined
            items = {}
        cls._dialect = options.Dialect(**items)

        for key, attr in attrs.items():
            if hasattr(attr, 'attach_to_class'):
                attr.attach_to_class(cls, key, cls._dialect)

    @classmethod
    def __prepare__(self, name, bases):
        return OrderedDict()

然而,这只是我们前进的一部分。现在名称空间字典包含了所有的类属性,并且知道它们的定义顺序,但是它没有解决只有 CSV 列的简单列表的问题。名称空间字典还将保存所有已定义的方法和其他各种属性,所以我们仍然需要从其中取出列,并将它们放入另一个列表中。

一个显而易见的方法是查看字典中的每个属性,检查它是否是列。这与本节前面提到的过程相同,但是现在的不同之处在于,您可以将复杂性隐藏在元类内部。

因为__init__()在整个主体被处理后运行,所以它的attrs参数将是一个包含所有属性的有序字典。剩下的工作就是循环遍历它们,找出找到的所有列。同样,本着鸭子打字的精神,我们将使用attach_to_class()来确定哪些属性是列。事实上,我们可以使用现有的循环,只需将新代码注入到内部的if块中。

为了在现实世界中使用它,需要将它放在更有用的地方,比如存储在类的_dialect属性中的Dialect对象。与其简单地从外部分配一个列表,不如让Dialect自己管理它,给它一个add_column()方法,我们可以从元类调用它:

img/330715_3_En_11_Figo_HTML.jpg

class Dialect:
    """
    A container for dialect options that control how a CSV file should be
    handled when converting it to a set of objects.

    has_header_row
        A Boolean indicating whether the file has a row containing header
        values. If True, that row will be skipped when looking for data.
        Defaults to False.

    For a list of additional options that can be passed in, see documentation
    for the dialects and formatting parameters of Python's csv module at
    http://docs.python.org/library/csv.html#dialects-and-formatting-parameters
    """

    def __init__(self, has_header_row=False, **kwargs):
        self.has_header_row = has_header_row
        self.csv_dialect = kwargs
        self.columns = []

    def add_column(self, column):
        self.columns.append(column)

既然Dialect知道如何保存字段记录,那么只需修改RowMeta就可以在发现列时将其添加到方言中。因为名称空间已经根据属性分配的时间进行了排序,所以我们可以确保它们以正确的顺序附加到类上。因此,我们可以简单地在列的attach_to_class()方法中添加对方言的add_column()的快速调用:

img/330715_3_En_11_Figp_HTML.jpg

class Column:
    """
    An individual column within a CSV file. This serves as a base for attributes
    and methods that are common to all types of columns. Subclasses of Column
    will define behavior for more specific data types.
    """

    def __init__(self, title=None, required=True):
        self.title = title
        self.required = required

    def attach_to_class(self, cls, name, dialect):
        self.cls = cls
        self.name = name
        self.dialect = dialect
        if self.title is None:
            # Check for None so that an empty string will skip this behavior
            self.title = name.replace('_', ' ')
        dialect.add_column(self)

注意

这个例子还将属性options的名称改为dialect,以与框架的其余部分保持一致。

现在,我们的代码有了一种简单的方法,可以按照原始顺序获取提供给类的列。然而,它有一个相当大的缺陷:__prepare__()技术只在 Python 版中可用。因为在此之前没有等效的功能,任何旧版本都需要使用完全不同的方法来解决这个问题。

我们可以利用 Python 的类处理的基本原则:类的主体作为代码块执行。这意味着每个列属性都是按照它们在类定义中的写入顺序进行实例化的。Column类已经有了一个在属性被实例化时运行的代码块,它可以被扩展一点以跟踪每个实例化。

专栏。init()

最明显的选择是我们已经有代码的地方:__init__()方法。当每个Column对象被实例化时,它被调用,因此它提供了一个方便的地方来跟踪这些对象被遇到的顺序。实际过程相当简单。它所需要的只是一个无论处理哪一列都可以在一个地方维护的计数器,以及每当发现一个新列就递增该计数器的一小段代码:

img/330715_3_En_11_Figq_HTML.jpg

class Column:
    """
    An individual column within a CSV file. This serves as a base for attributes
    and methods that are common to all types of columns. Subclasses of Column
    will define behavior for more specific data types.
    """

    # This will be updated for each column that's instantiated.
    counter = 0

    def __init__(self, title=None, required=True):
        self.title = title
        self.required = required
        self.counter = Column.counter
        Column.counter += 1

    def attach_to_class(self, cls, name, dialect):
        self.cls = cls
        self.name = name
        self.dialect = dialect
        if self.title is None:
            # Check for None so that an empty string will skip this behavior
            self.title = name.replace('_', ' ')
        dialect.add_column(self)

这段代码处理了部分问题。现在,每一列都有一个counter属性,表明它在其余列中的位置。

简单比复杂好

实际上,该计数器将跨所有的列维护,而不管它们被分配到哪个类。尽管这在技术上有点过分,但实际上不会伤害任何东西。每组列仍将在其对等列之间适当排序,因此它们可以正确排序而不会出现问题。更重要的是,重置每个类的计数器会使代码变得非常复杂。

首先,我们需要为每个可以附加列的类创建一个单独的计数器。在调用attach_to_class()之前,列不知道它们被分配到哪个类,所以我们必须在其中放一些代码来确定新类何时被处理。但是因为这是在计数器已经在__init__()中递增之后发生的,所以在将计数器分配给新类的新位置时,需要重置计数器。

为每个单独的类保留一个单独的计数器是完全可能的,但是这样做并不会给这个过程增加任何东西。因为更简单的形式在大多数情况下都是有效的,所以增加复杂性是不值得的。如果您有一个长期运行的进程,它定期动态地创建Row子类,那么计数器可能会溢出并导致问题。在这种情况下,您需要采取这些额外的步骤来确保一切继续正常工作。

下一步是使用该计数器强制对存储在Dialect对象上的列进行排序。在__prepare__()方法中,名称空间自己处理排序,所以没有其他事情要做。这里我们需要对字段列表进行显式排序,使用counter属性来确定顺序。

我们不能马上在__init__()中这样做,因为那样会得到所有属性的字典,而不仅仅是列。在使用它们的attach_to_class()方法进行处理之前,它不知道哪些属性是列。在用attach_to_class()处理完所有的列之后,对列表进行排序将会提供一个完整的列表,其中只有正确顺序的列。以下是您需要添加到RowMeta类的内容:

img/330715_3_En_11_Figr_HTML.jpg

from sheets import options

class RowMeta(type):
    def __init__(cls, name, bases, attrs):
        if 'Dialect' in attrs:
            # Filter out Python's own additions to the namespace
            items = attrs.pop('Dialect').__dict__.items()
            items = {k: v for k, v in items if not k.startswith('__')}
        else:
            # No dialect options were explicitly defined
            items = {}
        cls._dialect = options.Dialect(**items)
        for key, attr in attrs.items():
            if hasattr(attr, 'attach_to_class'):
                attr.attach_to_class(cls, key, cls._dialect)

        # Sort the columns according to their order of instantiation
        cls._dialect.columns.sort(key=lambda column: column.counter)

这个函数调用可能看起来比实际复杂一点。它只是调用一个标准的sort()操作,但是有一个函数将被调用来确定在排序项目时使用什么值。我们可以给Column添加一个方法,只返回计数器并使用它,但是因为它只在这里使用,一个lambda函数将内联做同样的工作。

简单比复杂好

另一种选择是在处理attach_to_class()的同时对列表进行排序。前面显示的默认attach_to_class()实现已经在提供的Dialect对象上调用了add_column(),所以这是一个很好的地方来完成这项工作。不幸的是,这样做需要一些额外的步骤。每次添加新列时尝试对整个列表进行排序是没有意义的,但是我们可以使用标准库中的bisect模块来更高效地保持顺序。

二等分模块提供了一个insort()方法,该方法将新项目插入到现有序列中,同时保留这些项目的有用顺序。然而,与标准的sort()不同,这个函数不接受关键参数,而是依赖于使用<操作符来比较两个项目。如果一个项目比另一个项目小,它在序列中会被放在更靠前的位置。这很有意义,但是如果不使用显式的key,我们需要在Column类上实现一个__lt__()方法来支持insort()

事后排序只需要一行额外的代码,而尝试从头到尾排序会引入另一个导入和另一个对Column类的方法。通过这种方式,我们得到的唯一好处是能够看到到目前为止已经处理过的所有列的顺序,但是因为新列可能被放置在该顺序内的任何位置,所以在所有列都被处理完之前,它实际上没有多大用处。因此,最好保持事情简单,然后只对列表进行一次排序。

这种方法中添加的大部分代码在__prepare__()不可用时都是必需的,不管其他偏好如何。我们真正有空间使用不同方法的唯一领域是更新计数器值的地方。有几种不同的方法来管理这个价值。

到目前为止,我们已经使用了Column类的__init__()方法,因为它总是在实例化期间被调用,而且它已经有了一个基本的实现。麻烦在于,许多__init__()方法仅用于将参数值作为属性保存在对象上,因此程序员已经开始期待类似的行为。除了管理计数器,我们自己的__init__()方法完全符合这一期望。

因此,如果程序员想编写一个新的列,它不使用任何与基类Column相同的参数,那么很容易编写一个不调用super()__init__()方法。如果不使用super()来触发最初的__init__()方法,新列将不会被正确排序。它的counter属性将始终与它之前处理的内容相同,因此sort()将无法可靠地确定它属于哪里。

你可能会说这里的问题在于程序员认为__init__()不做任何有价值的事情,但这不是解决问题的有效方法。如果有人忘记使用super(),我们仍然有一些方法可以让框架的用户变得更容易,有助于避免问题。

专栏。__ 新 _ _()

想想没有__init__()的实例化,下一个明确的选择是__new__(),它在流程的前面被调用。使用__new__()提供了一个不与__init__()竞争而做相同工作的机会,所以它们可以相互独立。对象的初始化仍然可以在__init__()中进行,让__new__()来管理计数器值:

img/330715_3_En_11_Figs_HTML.jpg

class Column:
    """
    An individual column within a CSV file. This serves as a base for attributes
    and methods that are common to all types of columns. Subclasses of Column
    will define behavior for more specific data types.
    """

    # This will be updated for each column that's instantiated.
    counter = 0

    def __new__(cls, *args, **kwargs):
        # Keep track of the order each column is instantiated
        obj = super(Column, cls).__new__(cls, *args, **kwargs)
        obj.counter = Column.counter
        Column.counter += 1
        return obj

    def __init__(self, title=None, required=True):
        self.title = title
        self.required = required

    def attach_to_class(self, cls, name, dialect):
        self.cls = cls
        self.name = name
        self.dialect = dialect
        if self.title is None:
            # Check for None so that an empty string will skip this behavior
            self.title = name.replace('_', ' ')
        dialect.add_column(self)

因为__new__()负责创建和返回新对象,所以__new__()中的代码比之前在__init__()中使用的有所增加。因此,我们需要在给对象分配计数器之前显式地创建对象。然后,该方法需要显式返回新对象,以便其他任何对象都可以访问它。

使用__new__()而不是__init__()仅仅是一种减少与定制实现冲突的方法。这可能不太可能,但是子类自己提供__new__()仍然是可能的,不使用super()这样做仍然会导致问题。还有一个选项可以进一步区分计数行为。

反击。call()

重要的是要明白,在实例化一个类时,还会调用另一个方法。从技术上讲,类对象本身是作为一个函数被调用的,这意味着在某个地方有一个__call__()方法会被调用。因为__call__()只作为一个实例方法执行,但是实例化发生在调用一个类的时候,我们需要把这个类看作其他东西的实例:一个元类

这意味着我们可以创建一个元类来完全在Column类之外支持计数器功能。一个简单的带有__call__()方法的CounterMeta类可以自己跟踪计数器,然后Column可以使用它作为它的元类。这个方法的主体看起来本质上就像__new__(),因为它被称为过程中几乎相同的部分。需要使用super()创建对象并显式返回:

img/330715_3_En_11_Figt_HTML.jpg

class CounterMeta(type):
    """
    A simple metaclass that keeps track of the order that each instance
    of a given class was instantiated.
    """

    counter = 0

    def __call__(cls, *args, **kwargs):
        obj = super(CounterMeta, cls).__call__(*args, **kwargs)
        obj.counter = CounterMeta.counter
        CounterMeta.counter += 1
        return obj

现在,所有这些功能都被隔离到一个元类中,Column类变得简单了一些。它可以去掉所有的计数器处理代码,包括整个__new__()方法。现在维护计数行为所需要的就是使用CounterMeta作为它的元类:

img/330715_3_En_11_Figu_HTML.jpg

class Column(metaclass=CounterMeta):
    """
    An individual column within a CSV file. This serves as a base for attributes
    and methods that are common to all types of columns. Subclasses of Column
    will define behavior for more specific data types.
    """

    def __init__(self, title=None, required=True):
        self.title = title
        self.required = required

    def attach_to_class(self, cls, name, dialect):
        self.cls = cls
        self.name = name
        self.dialect = dialect
        if self.title is None:
            # Check for None so that an empty string will skip this behavior
            self.title = name.replace('_', ' ')
        dialect.add_column(self)

事实上,这个CounterMeta现在能够为任何需要它的类提供这种计数行为。通过简单地应用元类,给定类的每个实例都有一个附加的counter属性。然后,您可以使用该计数器根据实例化的时间对实例进行排序,就像sheets框架中的列一样。

选择一个选项

在这里提供的选项中,决定选择哪一个并不总是容易的。随着每一层灵活性的增加,复杂性也随之增加,最好是尽可能保持简单。当在 Python 3.x 环境中工作时,__prepare__()无疑是最好的选择。它不需要任何额外的类来支持它;它不需要在事实之后对列列表进行排序;而且它根本不需要接触Column类就可以工作。

Python 2 . x 早期版本的选项更加主观。你选择哪一个很大程度上取决于你对你的目标读者的期望,以及你允许你的代码有多复杂。更简单的解决方案需要用户更加警惕,所以你需要决定什么是最重要的。

因为这本书是为 Python 3.x 设计的,所以剩下的代码示例将使用__prepare__()。当然,对一组字段进行排序的能力只有在您有一组要处理的字段时才有用。

建立野外图书馆

在大多数声明性框架中,字段的主要功能是在本地 Python 对象和一些其他数据格式之间转换数据。在我们的例子中,另一种格式是包含在 CSV 文件中的字符串,因此我们需要一种方法在这些字符串和字段表示的对象之间进行转换。在我们进入具体字段类型的细节之前,我们需要设置一些管理数据转换的方法。

第一个方法to_python(),从文件中获取一个字符串,并将该字符串转换成一个本地 Python 值。每次从文件中读入一行时,都会对每一列执行该步骤,以确保您可以在 Python 中使用正确类型的值。因为不同类型的行为会有所不同,委托给像to_python()这样的方法允许您在单个类上改变这种特定的行为,而不必在一个Column类上这样做。

第二个方法是to_string(),它是to_python()的逆方法,在保存带有 Python 中赋值的 CSV 文件时会被调用。因为默认情况下csv模块处理字符串,所以该方法用于提供特定 CSV 格式所需的任何特殊格式。委托给该方法意味着每一列都可以有自己的选项来适应属于该字段的数据。

尽管每种类型的数据行为不同,但默认情况下,基类Column可以支持一个简单的用例。csv模块只处理以文本模式打开的文件,所以 Python 自己的文件访问在读取数据时管理到 Unicode 的转换。这意味着来自csv的值已经是一个字符串,可以很容易地使用:

img/330715_3_En_11_Figv_HTML.jpg

class Column:
    """
    An individual column within a CSV file. This serves as a base for attributes
    and methods that are common to all types of columns. Subclasses of Column
    will define behavior for more specific data types.
    """

    def __init__(self, title=None, required=True):
        self.title = title
        self.required = required

    def attach_to_class(self, cls, name, dialect):
        self.cls = cls
        self.name = name
        self.dialect = dialect
        if self.title is None:
            # Check for None so that an empty string will skip this behavior
            self.title = name.replace('_', ' ')
        dialect.add_column(self)

    def to_python(self, value):
        """
        Convert the given string to a native Python object.
        """
        return value

    def to_string(self, value):
        """
        Convert the given Python object to a string.
        """
        return value

现在我们可以开始为单个数据类型实现它们了。

斯普林菲尔德

最明显的开始字段是字符串,因为它可以包含任意数量的更具体形式的数据。标题、姓名、地点、描述和评论只是这些字段中更具体的值的一些例子,但是从技术角度来看,它们都以相同的方式工作。sheets 框架不需要关心你将要处理什么形式的字符串,只需要知道它们实际上都是字符串。

csv模块自己提供了字符串,所以这个类实际上不需要做太多事情。事实上,to_python()to_string()根本不需要任何定制的实现,因为它们只需要返回给它们的东西。StringColumn提供的最重要的东西实际上是名称本身。

通过拥有根据与之交互的数据类型命名的属性,属性在某种程度上变得不言自明。不要仅仅使用一个通用的Column来描述字符串是如何来回传递的,你可以使用一个StringColumn来明确它是如何工作的:

img/330715_3_En_11_Figw_HTML.jpg

class StringColumn(Column):
    """
    A column that contains data formatted as generic strings.
    """
    pass

事实上,您甚至可以调用基类StringColumn而不仅仅是Column,因为它自己完成这项工作。不幸的是,这在子类化它的时候会引起混乱,因为需要像IntegerColumn这样的东西来子类化StringColumn。为了让事情更清楚,基类将保持Column,每个子类将只在它上面添加必要的特性,即使除了名字之外没有任何有用的东西可以添加。

整数列

下一个要添加的字段类型管理整数。数字在电子表格中使用得相当多,存储从年龄到销售数字到库存计数的一切。大多数情况下,这些数字是普通整数,可以使用内置的int()函数轻松转换:

img/330715_3_En_11_Figx_HTML.jpg

class IntegerColumn(Column):
    """
    A column that contains data in the form of numeric integers.
    """
    def to_python(self, value):
        return int(value)

IntegerColumn实际上并不需要实现一个to_string()方法,因为csv模块会自动调用str(),无论它被赋予什么值。因为这就是我们在to_string()方法中所做的一切,我们可以忽略它,让框架来处理这个任务。正如您将在其他列中看到的,当列可以指定更明确的格式时,to_string()最有用。简单地写出一个数字并不需要太多的灵活性。

浮动柱

电子表格中的许多数字比整数粒度更细,需要额外的信息来传递小数点后的值。浮点数是处理这些值的一种很好的方式,将它们作为一列来支持就像使用IntegerColumn一样简单。我们可以简单地用float替换int的所有实例,这样就完成了:

img/330715_3_En_11_Figy_HTML.jpg

class FloatColumn(Column):
    """
    A column that contains data in the form of floating point numbers.
    """
    def to_python(self, value):
        return float(value)

当然,在许多情况下,当查看浮点数或者将它们加在一起时,浮点数也有问题。这是由于小数点缺少定义的精度造成的:它根据给定值在代码中表示的好坏而浮动。为了更加明确和避免舍入误差之类的事情,我们求助于DecimalColumn

十进制柱

FloatColumn一样,它可以处理整数以外的数字。然而,DecimalColumn将依赖 Python 提供的decimal模块的功能,而不是使用浮点数。小数值尽可能多地保留原始数字的细节,这有助于防止舍入误差。这使得小数更适合用于货币电子表格。

在 Python 中,小数是使用decimal模块提供的,该模块提供了一个Decimal类来管理单个数字。因此,DecimalColumn需要将数字从 CSV 文件中的文本转换成 Python 中的Decimal对象,然后再转换回来。像浮点一样,Decimal本身已经可以很好地转换成字符串,所以DecimalColumn真正需要做的唯一转换是在读取值时从字符串转换成Decimal。因为 Decimal 是为处理字符串而设计的,所以它与迄今为止显示的其他列一样简单:

img/330715_3_En_11_Figz_HTML.jpg

import decimal

class DecimalColumn(Column):
    """
    A column that contains data in the form of decimal values,
    represented in Python by decimal.Decimal.
    """

    def to_python(self, value):
        return decimal.Decimal(value)

然而,这个方法与其他类中的方法有一点不同。其他每一个都有额外的副作用,如果值不能被正确转换,就会产生一个ValueError,我们稍后可以用它来支持验证。Decimal确实在实例化期间进行了验证,但是它从decimal模块InvalidOperation中引发了一个异常。为了与其他人的行为相匹配,我们需要抓住这一点,并将其提升为ValueError:

img/330715_3_En_11_Figaa_HTML.jpg

import decimal

class DecimalColumn(Column):
    """
    A column that contains data in the form of decimal values,
    represented in Python by decimal.Decimal.
    """

    def to_python(self, value):
        try:
            return decimal.Decimal(value)
        except decimal.InvalidOperation as e:
            raise ValueError(str(e))

尽管DecimalColumn支持更专门化的数据类型,但它背后的代码仍然相当简单。相反,支持日期需要一些额外的复杂性。

日期列

日期在电子表格文档中也非常常见,它存储了从员工发薪日和假期到会议议程和出席情况的所有内容。像十进制值一样,日期需要使用单独的类来提供原生 Python 数据类型,但有一个显著的区别:日期没有普遍接受的字符串表示。有一些标准已经建立得相当好了,但是仍然有很多变化,从日期组件的位置到用于分隔它们的标点符号。

为了支持必要的灵活性,新的DateColumn将需要在实例化期间接受一个格式字符串,该字符串可用于解析文件中的值以及构造要存储在文件中的字符串。Python 日期已经使用了灵活的格式字符串语法, 5 ,所以没有必要专门为sheets发明一个新的。然而,为了在实例化期间指定格式,我们需要覆盖__init__():

img/330715_3_En_11_Figab_HTML.jpg

class DateColumn(Column):
    """
    A column that contains data in the form of dates,
    represented in Python by datetime.date.

    format
        A strptime()-style format string.
        See http://docs.python.org/library/datetime.html for details
    """

    def __init__(self, *args, format='%Y-%m-%d', **kwargs):
        super(DateColumn, self).__init__(*args, **kwargs)
        self.format = format

请注意,format 对象有一个默认值,这使得它是可选的。通常最好为字段属性提供这样的默认值,以便用户可以快速启动并运行。之所以选择这里使用的默认值,是因为它相当常见,并且它按照从最不具体到最具体的顺序(分别从年到日)排列值。这有助于减少我们在日期格式不同的文化中可能遇到的歧义。但是,因为目标是处理现有数据,所以特定的Row类总是可以用给定文件使用的任何格式来覆盖这种行为。

既然格式在DateColumn对象上可用,下一步,就像对其他对象一样,是创建一个to_python()方法。Python 的datetime对象接受日期的每个组成部分作为一个单独的参数,但是因为to_python()只获得一个字符串,我们将需要另一种方法来完成它。另一种形式是名为strptime()datetime类方法。

strptime()方法接受一个字符串值作为第一个参数,一个格式字符串作为第二个参数。然后根据格式字符串解析该值,并返回一个datetime对象。然而,我们实际上并不需要完整的datetime,所以我们也可以使用该对象的date()方法,只返回值的日期部分作为date对象:

img/330715_3_En_11_Figac_HTML.jpg

import datetime

class DateColumn(Column):
    """
    A column that contains data in the form of dates,
    represented in Python by datetime.date.

    format
        A strptime()-style format string.
        See http://docs.python.org/library/datetime.html for details
    """

    def __init__(self, *args, format='%Y-%m-%d', **kwargs):
        super(DateColumn, self).__init__(*args, **kwargs)
        self.format = format

    def to_python(self, value):
        """
        Parse a string value according to self.format
        and return only the date portion.
        """
        return datetime.datetime.strptime(value, self.format).date()

注意

模块的名字和类的名字,这就是为什么它被写了两次。

然而,这里写的to_python()有一个微妙的问题。到目前为止,所有其他列类型都可以接受一个字符串和一个本机对象作为to_python()中的值,但是如果您传入一个date对象而不是一个字符串,strptime()将失败,并返回一个TypeError。为了在 Python 中构造一行并将其保存在文件中,我们需要能够在这里接受一个datetime对象,它将在以后保存时被转换为一个字符串。

因为to_python()应该返回一个本地对象,所以这是一个非常简单的任务。它只需要检查传入的值是否已经是一个date对象。如果是这样,to_python()可以简单地返回,而不做任何工作。否则,它可以继续进行转换:

img/330715_3_En_11_Figad_HTML.jpg

class DateColumn(Column):
    """
    A column that contains data in the form of dates,
    represented in Python by datetime.date.

    format
        A strptime()-style format string.
        See http://docs.python.org/library/datetime.html for details
    """

    def __init__(self, *args, format='%Y-%m-%d', **kwargs):
        super(DateColumn, self).__init__(*args, **kwargs)
        self.format = format

    def to_python(self, value):
        """
        Parse a string value according to self.format
        and return only the date portion.
        """
        if isinstance(value, datetime.date):
            return value
        return datetime.datetime.strptime(value, self.format).date()

编写to_python()方法实际上是DateColumn类中最麻烦的部分。将现有的date值转换成字符串更加简单,因为有一个实例方法strftime()可以完成这项工作。它只接受一种格式并返回一个包含格式化值的字符串:

img/330715_3_En_11_Figae_HTML.jpg

import datetime

class DateColumn(Column):
    """
    A column that contains data in the form of dates,
    represented in Python by datetime.date.

    format
        A strptime()-style format string.
        See http://docs.python.org/library/datetime.html for details
    """
    def __init__(self, *args, format='%Y-%m-%d', **kwargs):
        super(DateColumn, self).__init__(*args, **kwargs)
        self.format = format

    def to_python(self, value):
        """
        Parse a string value according to self.format
        and return only the date portion.
        """
        if isinstance(value, datetime.date):
            return value
        return datetime.datetime.strptime(value, self.format).date()

    def to_string(self, value):
        """
        Format a date according to self.format and return that as a string.
        """
        return value.strftime(self.format)

小费

记住这两个方法名之间区别的一个有用的方法是,p代表“解析”,f代表“格式”

我们可以继续添加越来越多的字段,但是这里显示的内容涵盖了大多数 CSV 文件中的基本数据形式,以及在声明性框架中构建自己的字段属性所需的大多数技术。接下来,我们需要设置 CSV 功能,以便将这些数据类型应用到生活中。

回到 CSV

到目前为止,这一章是相当通用的,展示了可以应用于各种声明类框架的工具和技术。为了将它们投入实际使用,我们需要回到解析 CSV 文件的问题上来。本节中完成的大部分工作也适用于其他框架,但是将以特定于 CSV 的方式呈现。

首先要做的是看看 Python 自己的csv模块是如何工作的。完全重新发明轮子是没有意义的。理解现有的接口很重要,这样我们才能尽可能地匹配它。csv模块的功能由两种基本的对象类型提供:读取器和写入器。

读取器和写入器的配置方式相似。它们都接受一个文件参数、一个可选的方言和任意数量的关键字参数,这些参数指定了单独的方言参数来覆盖主方言。读取器和写入器之间的主要区别在于,读取器要求打开文件进行读访问,而写入器要求进行写访问。

对于读者来说,文件参数通常是一个file对象,但实际上可能是任何可迭代的对象,每次迭代产生一个字符串。因为csv模块还处理更复杂的换行符用法,比如在一个值中编码的换行符,所以你应该总是用参数newline="打开文件,以确保 Python 自己的换行符处理不会碍事。在下一个示例中,请确保运行该程序的目录中有 example.csv 文件:

img/330715_3_En_11_Figaf_HTML.jpg

>>> import csv

>>> reader = csv.reader(open('example.csv', newline="))

一旦被实例化用于特定的文件和方言,CSV reader 对象就有了一个极其简单的接口:它是一个可迭代的对象。遍历一个阅读器将产生 CSV 文件中的每一行,作为一个可以在csv模块之外使用的数据结构。标准的csv.reader为每一行产生一个值列表,因为它唯一知道的是每一个值在行中的位置。

一个更高级的选项是csv.DictReader,它在实例化过程中也接受一系列列名,这样每一行都可以作为一个字典生成。我们的框架甚至更进一步,生成一个对象,将文件中的每个值转换为原生 Python 数据类型,并作为属性提供。

相比之下,编写器对象稍微复杂一些。因为简单迭代只允许读取值,而不是写入值,所以编写人员依靠两种方法来完成必要的工作。第一个是writerow(),顾名思义,向文件中写出一行。它的伙伴writerows()接受一系列行,这些行将按照它们在序列中的顺序写入文件。

根据所使用的编写器类型,行的具体构成会有所不同。与阅读器一样,csv模块提供了一些不同的选项。标准的csv.writer为每一行接受一个简单的值序列,将每一个值放在它在列表中找到的位置。更复杂的DictWriter接受一个字典,它使用实例化期间传入的列名序列来确定每个值应该写在行中的什么位置。

使用我们的框架的接口应该看起来尽可能像这些标准阅读器和编写器的接口。一个sheets阅读器应该是一个 iterable 对象,它产生自定义类的实例,所有的列属性都在这个类中定义。同样,作者应该接受同一个类的实例。在这两种情况下,类定义中列属性的顺序将用于确定值的去向。

然而,读者和作者的一个关键因素是行对象的概念。到目前为止,我们还没有任何这样的 sheets 框架对象,所以我们需要创建一个。作为一个基于类的框架,sheets已经准备好构建一个可以表示行的对象。列和方言已经在一个类中定义了,所以创建对象的理想方式是简单地用一组值实例化该类。这将引入前面几节中描述的方言和列类,以便生成一个可用的对象。

实现这种行为的明显地方是__init__(),但是从那里开始事情变得有点棘手。第一个问题是如何接受将填充属性的值。因为我们还不知道任何特定的Row子类的布局,我们将不得不接受所有的参数并处理__init__()方法本身的需求。

检查参数

与任何函数一样,__init__()的参数可以按位置或按关键字传递,但是这个决定在这里有特殊的影响,因为对象可以用两种方式之一进行实例化。当从一个 CSV 文件实例化时,正如下一节将显示的,最简单的方法是按位置传递值。然而,当手动构建实例时,通过关键字传递值也非常方便。因此,最好接受所有位置和关键字参数,并在内部管理它们。

两种无效参数的情况在一开始就很明显:太多的位置参数和关键字参数与任何列名都不匹配。每种情况都需要单独的代码来支持它,但是它们都很容易使用。对于位置的情况,我们可以简单地根据列数检查参数的数量:

img/330715_3_En_11_Figag_HTML.jpg

class Row(metaclass=RowMeta):
    def __init__(self, *args, **kwargs):
        # First, make sure the arguments make sense
        if len(args) > len(self._dialect.columns):
            msg = "__init__() takes at most %d arguments (%d given)"
            raise TypeError(msg % (len(self._dialect.columns), len(args)))

这处理了传入太多位置参数的情况,使用了显式定义参数时 Python 会发出的相同错误消息。下一步是确保所有提供的关键字参数都与现有的列名匹配。这很容易测试,方法是循环遍历关键字参数名称,并检查每个名称是否也出现在列名列表中。

因为方言只存储列的列表,而不是列名的列表,所以在测试它们之前,最简单的方法是在这里创建一个新的列名列表。稍后添加到__init__()的额外代码也将使用这个新列表,所以最好现在就创建它:

img/330715_3_En_11_Figah_HTML.jpg

class Row(metaclass=RowMeta):
    def __init__(self, *args, **kwargs):
        # First, make sure the arguments make sense
        column_names = [column.name for column in self._dialect.columns]

        if len(args) > len(column_names):
            msg = "__init__() takes at most %d arguments (%d given)"
            raise TypeError(msg % (len(column_names), len(args)))

        for name in kwargs:
            if name not in column_names:
                msg = "__init__() got an unexpected keyword argument '%s'"
                raise TypeError(msg % name)

这处理了明显的情况,但是还有一种情况没有涉及到:关键字参数的目标列也有位置参数。为了解决这个问题,我们来看看 Python 本身的行为。当遇到按位置和按关键字传递的参数时,Python 会抛出一个TypeError,而不是被迫决定使用两个值中的哪一个:

img/330715_3_En_11_Figai_HTML.jpg

>>> def example(x):
...     return x
...
>>> example(1)
1
>>> example(x=1)
1
>>> example(1, x=1)
Traceback (most recent call last):
  ...

TypeError: example() got multiple values for keyword argument 'x'

提供我们自己的__init__()的相同行为比前面的例子要复杂一点,但是仍然相当简单。我们只需要查看每个位置参数,并检查是否有关键字参数匹配相应的列名。

对于这种情况,一个有用的快捷方式是在列名数组上使用一个切片,只获取与位置参数一样多的名称。这样,我们不必查看不必要的名称,并且消除了在循环中通过索引查找列名的单独步骤:

img/330715_3_En_11_Figaj_HTML.jpg

class Row(metaclass=RowMeta):
    def __init__(self, *args, **kwargs):
        # First, make sure the arguments make sense
        column_names = [column.name for column in self._dialect.columns]

        if len(args) > len(column_names):
            msg = "__init__() takes at most %d arguments (%d given)"
            raise TypeError(msg % (len(column_names), len(args)))

        for name in kwargs:
            if name not in column_names:
                msg = "__init__() got an unexpected keyword argument '%s'"
                raise TypeError(msg % name)

        for name in column_names[:len(args)]:
            if name in kwargs:
                msg = "__init__() got multiple values for keyword argument '%s'"
                raise TypeError(msg % name)

检查完所有的参数后,__init__()可以确定没有提供无效的参数。从这里开始,我们可以使用这些参数来填充对象本身的值。

填充值

在对象上填充值实际上涉及到两个步骤。第一个是由于__init__()接受位置和关键字参数。通过提供这两个选项,我们现在有两个不同位置的争论:argskwargs。为了在一次传递中设置值,我们需要将它们组合成一个结构。

理想情况下,这个结构应该是一个字典,因为它结合了名称和值,所以我们需要将位置参数移动到已经由kwargs提供的字典中。为此,我们需要为每个按位置传入的值建立一个索引,并引用相应的列名,这样就可以将值赋给正确的名称。

上一节的最后一次检查已经提供了那个循环,所以我们可以重用那个块来给kwargs赋值。我们需要对循环进行的唯一更改是使用enumerate()来获取每一列的索引及其名称。然后,该索引可用于从args获取值:

img/330715_3_En_11_Figak_HTML.jpg

class Row(metaclass=RowMeta):
    def __init__(self, *args, **kwargs):
        # First, make sure the arguments make sense
        column_names = [column.name for column in self._dialect.columns]

        if len(args) > len(column_names):
            msg = "__init__() takes at most %d arguments (%d given)"
            raise TypeError(msg % (len(column_names), len(args)))

        for name in kwargs:
            if name not in column_names:
                msg = "__init__() got an unexpected keyword argument '%s'"
                raise TypeError(msg % name)

        for i, name in enumerate(column_names[:len(args)]):
            if name in kwargs:
                msg = "__init__() got multiple values for keyword argument '%s'"
                raise TypeError(msg % name)
            kwargs[name] = args[i]

现在,kwargs已经将所有值传递给了构造函数,每个值都映射到了适当的列名。接下来,在将这些值分配给对象之前,我们需要将它们转换成适当的 Python 值。要做到这一点,我们需要实际的列对象,而不仅仅是我们到目前为止一直在使用的名称列表。

还有一个小问题需要考虑。遍历列可以得到为该类定义的所有列,但是kwargs只包含传递给对象的值。我们需要决定如何处理没有可用值的列。当从 CSV 文件中提取数据时,这通常不是问题,因为文件中的每一行每一列都应该有一个条目。但是,当在 Python 中填充一个对象以便以后保存在文件中时,在实例化对象之后分配属性通常是有用的。

因此,这里最灵活的方法是简单地将None分配给任何没有值的列。检查必填字段可以作为一个单独的步骤来执行,当我们开始验证其他内容的字段时。现在,分配None就可以了:

img/330715_3_En_11_Figal_HTML.jpg

class Row(metaclass=RowMeta):
    def __init__(self, *args, **kwargs):
        # First, make sure the arguments make sense
        column_names = [column.name for column in self._dialect.columns]

        if len(args) > len(column_names):
            msg = "__init__() takes at most %d arguments (%d given)"
            raise TypeError(msg % (len(column_names), len(args)))

        for name in kwargs:
            if name not in column_names:
                msg = "__init__() got an unexpected keyword argument '%s'"
                raise TypeError(msg % name)

        for i, name in enumerate(column_names[:len(args)]):
            if name in kwargs:
                msg = "__init__() got multiple values for keyword argument '%s'"
                raise TypeError(msg % name)
            kwargs[name] = args[i]

        # Now populate the actual values on the object
        for column in self._dialect.columns:
            try:
                value = column.to_python(kwargs[column.name])
            except KeyError:
                # No value was provided
                value = None
            setattr(self, column.name, value)

有了这个功能,您就可以看到Row类自己运行了。它现在能够管理一组列,接受值作为输入,在加载时将它们转换为 Python 对象,并将这些值分配给适当的属性:

img/330715_3_En_11_Figam_HTML.jpg

>>> import sheets
>>> class Author(sheets.Row):
...     name = sheets.StringColumn()
...     birthdate = sheets.DateColumn()
...     age = sheets.IntegerColumn()
...
>>> ex = Author('Marty Alchin', birthdate='1981-12-17', age="28")
>>> ex.name
'Marty Alchin'
>>> ex.birthdate
datetime.date(1981, 12, 17)
>>> ex.age

28

现在我们终于可以实现与 CSV 文件交互的代码了。

读者

直接使用csv模块,通过实例化一个类并传入一个文件和必要的配置选项来获得一个读取器。sheets 框架允许每个定制的Row类直接在类上指定所有的列和方言参数,所以现在包含了我们需要的一切。与csv的直接类比是将一个文件和一个Row类传递给一个函数,然后该函数返回一个能够读取该文件的 reader 对象。

这种方法的问题在于,它需要任何想要使用读取器的代码来导入sheets模块,以便获得创建读取器对象的函数。相反,我们可以通过提供一个可以完成必要工作的类方法,只使用Row类本身。然后,方法需要接受的唯一参数是要读取的文件。为了匹配现有的csv命名约定,我们将调用这个新方法reader()

为了像标准阅读器一样工作,我们自己的reader()需要返回一个 iterable 对象,为每次迭代生成一行。这是一个需要满足的简单要求,甚至不需要任何新对象就可以完成。记住,当第一次调用生成器函数时,它们实际上会返回一个 iterable 对象。然后在循环的每次迭代中执行生成器的主体,这是支持 CSV 阅读器的理想方式。

为了从 CSV 文件中获取值,reader()可以依赖现有的csv模块自己的阅读器功能。标准的csv.reader为文件中的每一行返回一个列表,不管实际值是什么意思,也不管它们的名字应该是什么。因为 row 类已经可以处理存储在序列(如列表)中的参数,所以将两者绑定在一起非常简单:

img/330715_3_En_11_Figan_HTML.jpg

import csv

class Row(metaclass=RowMeta):
    def __init__(self, *args, **kwargs):
        # First, make sure the arguments make sense
        column_names = [column.name for column in self._dialect.columns]

        if len(args) > len(column_names):
            msg = "__init__() takes at most %d arguments (%d given)"
            raise TypeError(msg % (len(column_names), len(args)))

        for name in kwargs:
            if name not in column_names:
                msg = "__init__() got an unexpected keyword argument '%s'"
                raise TypeError(msg % name)

        for i, name in enumerate(column_names[:len(args)]):
            if name in kwargs:
                msg = "__init__() got multiple values for keyword argument '%s'"
                raise TypeError(msg % name)
            kwargs[name] = args[i]

        # Now populate the actual values on the object
        for column in self._dialect.columns:
            try:
                value = column.to_python(kwargs[column.name])
            except KeyError:
                # No value was provided
                value = None
            setattr(self, column.name, value)

    @classmethod
        def reader(cls, file):
           for values in csv.reader(file):
           yield cls(*values)

然而,这忽略了从 CSV 文件中读取的一个重要方面。在文件中存储值的方式有很多变化,您可能需要指定一些选项来控制文件的处理方式。早些时候,Dialect类提供了在Row类上指定这些选项的方法,所以现在我们需要在对csv.reader()的调用中传递一些选项。特别是,这些选项存储在方言的csv_dialect属性中:

img/330715_3_En_11_Figao_HTML.jpg

@classmethod
    def reader(cls, file):
        for values in csv.reader(file, **cls._dialect.csv_dialect):
            yield cls(*values)

这包括了csv模块已经知道的选项,但是记住我们自己的Dialect类允许另一个选项来指示文件是否有标题行。为了在阅读器中支持该特性,我们需要添加一些额外的代码,如果方言指示第一行将是标题,则跳过该行:

img/330715_3_En_11_Figap_HTML.jpg

@classmethod
    def reader(cls, file):
        csv_reader = csv.reader(file, **cls._dialect.csv_dialect)

        # Skip the first row if it's a header
        if cls._dialect.has_header_row:
            csv_reader.__next__()

        for values in csv_reader:
            yield cls(*values)

因为阅读器需要提供的只是一个为每个对象生成一行的 iterable,所以这个方法现在做了它需要做的一切。然而,这并不是很超前的想法。因为我们正在构建一个以后可能需要改进的框架,所以至少考虑未来的扩展总是一个好主意。

一种更灵活的方法是创建一个新的可迭代类来完成同样的工作,而不是仅仅依赖于一个生成器函数。正如我们将在下一节看到的,作者也需要一个单独的类,所以构建这个新的 iterable 将创建一对更容易理解的类。首先,reader()方法变得简单多了:

img/330715_3_En_11_Figaq_HTML.jpg

    @classmethod
      def reader(cls, file):
           return Reader(cls, file)

这将所有真正的工作委托给了一个新的Reader类,该类必须实现__iter__()__next__()才能起到迭代器的作用。然而,有一些东西需要先存储在__init__()中,包括可以创建每个实例的 row 类和实际读取文件的csv.reader对象:

img/330715_3_En_11_Figar_HTML.jpg

class Reader:
    def __init__(self, row_cls, file):
        self.row_cls = row_cls
        self.csv_reader = csv.reader(file, **row_cls._dialect.csv_dialect)

__iter__()方法很容易支持,因为Reader本身就是迭代器。因此,唯一需要做的就是归还self:

img/330715_3_En_11_Figas_HTML.jpg

class Reader:
    def __init__(self, row_cls, file):
        self.row_cls = row_cls
        self.csv_reader = csv.reader(file, **row_cls._dialect.csv_dialect)

    def __iter__(self):
        return self

因为每次迭代都会调用__next__(),所以对于返回单个行对象这一明显的任务来说,它的逻辑会简单一些。它需要做的就是在csv.reader的迭代器上调用__next__(),将值传递给存储在__init__()中的 row 类:

img/330715_3_En_11_Figat_HTML.jpg

class Reader:
    def __init__(self, row_cls, file):
        self.row_cls = row_cls
        self.csv_reader = csv.reader(file, **row_cls._dialect.csv_dialect)

    def __iter__(self):
        return self

    def __next__(self):
        return self.row_cls(*self.csv_reader.__next__())

你会从第五章中想起,当手动构建迭代器时,你必须小心引发StopIteration异常以避免无限循环。在这种情况下,我们不必直接这么做,因为csv.reader会自己这么做。一旦记录用完,我们自己的__next__()方法只需要让StopIteration过去而不被抓住。

要实现的最后一个特性是标题行,它稍微复杂一些。在前面展示的生成器函数中,很容易在进入真正的循环之前处理标题行。作为一个手动迭代器,我们必须单独管理它,因为对于每条记录,__next__()将从开始被调用。

为此,我们需要保留一个布尔属性来指示我们是否仍然需要跳过标题行。开始时,该属性将与方言的has_header_row属性相同,但是一旦跳过了标题行,就需要重置该属性,以便__next__()可以每隔一段时间生成一条有效记录:

img/330715_3_En_11_Figau_HTML.jpg

class Reader:
    def __init__(self, row_cls, file):
        self.row_cls = row_cls
        self.csv_reader = csv.reader(file, **row_cls._dialect.csv_dialect)
        self.skip_header_row = row_cls._dialect.has_header_row

    def __iter__(self):
        return self

    def __next__(self):
        # Skip the first row if it's a header
        if self.skip_header_row:
            self.csv_reader.__next__()
            self.skip_header_row = False

        return self.row_cls(*self.csv_reader.__next__())

您可以通过提供一个简单的 CSV 文件并读入它来测试它。考虑一个包含粗略目录的文件,其中一列是章节号,另一列是章节标题。下面是如何编写一个Row来表示该文件并解析其内容:

img/330715_3_En_11_Figav_HTML.jpg

>>> import sheets
>>> class Content(sheets.Row):
...     chapter = sheets.IntegerColumn()
...     title = sheets.StringColumn()
...
>>> file = open('contents.csv', newline=“)
>>> for entry in Content.reader(file):
...     print('%s: %s' % (entry.chapter, entry.title))
...
1: Principles and Philosophy
2: Advanced Basics
3: Functions
4: Classes
5: Protocols
6: Object Management
7: Strings
8: Documentation
9: Testing
10: Distribution

11: Sheets: A CSV Framework

这就完成了从 CSV 文件中的行到单个 Python 对象的转换。因为Content类实例中的每一行,您还可以定义您喜欢的任何其他方法,并在处理来自文件的条目时使用这些方法。对于框架的另一面,我们需要一个编写器将这些对象移回 CSV 文件。

作家

与阅读器不同,CSV 编写器的接口需要一些实例方法,因此实现稍微复杂一些。这一次,一个生成器方法不能解决这个问题,所以我们需要添加一个新的类来管理文件写入行为。我们仍然可以依靠csv模块自身的行为来完成大部分繁重的工作,所以这个新类只需要管理sheets框架的附加特性。

界面的第一部分很简单。为了反映读取器的可用性,应该可以从Row子类的方法中访问写入器。这个方法也将接受一个 file 对象,但是这一次它必须返回一个新的对象,而不是立即对那个文件做任何事情。这使得这个writer()方法的实现本身很简单:

img/330715_3_En_11_Figaw_HTML.jpg

    @classmethod
        def writer(cls, file):
            return Writer(file, cls._dialect)

注意

SheetWriter不能只处理文件,因为它与Row是分开的,否则无法访问任何方言选项。

然而,这显然没有做任何有用的事情,所以主要任务是创建并填充SheetWriter类。满足 writer 接口有两个必要的方法,writerow()writerows()。前者负责获取单个对象并将一行写到文件中,而后者接受一系列对象,将它们作为文件中单独的一行写出。

在开始使用这些方法之前,Writer需要一些基本的初始化。它需要访问的第一个显而易见的信息是类的列列表。除此之外,它还需要 CSV 选项,但这些选项只在使用csv模块本身创建编写器时才需要,就像 reader 一样。最后,它需要访问csv不知道自己的选项has_header_row:

img/330715_3_En_11_Figax_HTML.jpg

class Writer:
    def __init__(self, file, dialect):
        self.columns = dialect.columns
        self._writer = csv.writer(file, dialect.csv_dialect)
        self.needs_header_row = dialect.has_header_row

在进入最重要的writerow()方法之前,请注意标题行选项在分配给类时实际上被命名为needs_header_row。这允许writerow()使用该属性作为标志来指示标题行是否仍然需要被写入。如果一开始不需要任何行,那么它从False开始,但是如果它以True的形式出现,那么一旦文件头实际写入文件,它就可以翻转到False

为了写标题行本身,我们也可以使用csv.writer来写值行。csv模块不关心文件的整体结构是什么,所以我们可以传入一行标题值,它将像所有其他行一样被处理。这些头值来自类中每一列的title属性,但是我们可以使用字符串的title()方法使它们更友好一些:

img/330715_3_En_11_Figay_HTML.jpg

class Writer:
    def __init__(self, file, dialect):
        self.columns = dialect.columns
        self._writer = csv.writer(file, dialect.csv_dialect)
        self.needs_header_row = dialect.has_header_row

    def writerow(self, row):
        if self.needs_header_row:
            values = [column.title.title() for column in self.columns]
            self._writer.writerow(values)
            self.needs_header_row = False

有了这个标题,writerow()就可以继续写传递到方法中的实际行了。支持头部的代码已经列出了大部分需要做的事情。唯一的区别是,list comprehension 需要从传入的 row 对象中获取相应的值,而不是获取每一列的标题:

img/330715_3_En_11_Figaz_HTML.jpg

class Writer:
    def __init__(self, file, dialect):
        self.columns = dialect.columns
        self._writer = csv.writer(file, dialect.csv_dialect)
        self.needs_header_row = dialect.has_header_row

    def writerow(self, row):
        if self.needs_header_row:
            values = [column.title.title() for column in self.columns]
            self._writer.writerow(values)
            self.needs_header_row = False
        values = [getattr(row, column.name) for column in self.columns]
        self._writer.writerow(values)

最后,作者还需要一个writerows()方法,该方法可以获取一系列对象并将它们作为单独的行写出。困难的工作已经完成,所以所有的writerows()需要做的就是为每个传入序列的对象调用writerow():

img/330715_3_En_11_Figba_HTML.jpg

class Writer:
    def __init__(self, file, dialect):
        self.columns = dialect.columns
        self._writer = csv.writer(file, dialect.csv_dialect)
        self.needs_header_row = dialect.has_header_row

    def writerow(self, row):
        if self.needs_header_row:
            values = [column.title.title() for column in self.columns]
            self._writer.writerow(values)
            self.needs_header_row = False
        values = [getattr(row, column.name) for column in self.columns]
        self._writer.writerow(values)

    def writerows(self, rows):
        for row in rows:
            self.writerow(row)

有了 CSV 读取器和写入器,sheets框架就完成了。您可以添加更多的列类来支持额外的数据类型,或者根据您的具体需求添加更多的方言选项,但是框架总体上是完整的。您可以通过读取现有文件并将其写回新文件来验证全部功能。只要所有的方言参数都与文件的结构匹配,这两个文件的内容将是相同的:

img/330715_3_En_11_Figbb_HTML.jpg

>>> import sheets
>>> class Content(sheets.Row):
...     chapter = sheets.IntegerColumn()
...     title = sheets.StringColumn()
...
>>> input = open('contents.csv', newline=“)
>>> reader = Content.reader(input)
>>> output = open('compare.csv', 'w', newline=“)
>>> writer = Content.writer(output)
>>> writer.writerows(reader)
>>> input.close()
>>> output.close()
>>> open('contents.csv').read() == open('compare.csv').read()

True

带着它

在本章中,你已经看到了如何使用 Python 提供的许多工具来规划、构建和定制一个框架。原本需要重复多次的复杂任务已经简化为可重用和可扩展的工具。然而,这只是本书中的技术如何结合起来完成如此复杂的任务的一个例子。剩下的就看你的了。

posted @ 2024-08-09 17:40  绝不原创的飞龙  阅读(9)  评论(0编辑  收藏  举报