原文:Pro Python 3
一、原则和哲学
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
的模块。
>>> 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 对关键字参数的关注,这有助于澄清难以理解的函数调用。考虑以下两种编写相同代码的可能方式,并考虑哪种方式看起来更漂亮:
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 程序员试图避免这种类型的隐式行为,以支持更具可读性的代码。
因为不同的应用有不同的声明意图的方式,所以没有一个通用的解释适用于所有情况。相反,这条指导方针将在整本书中频繁出现,阐明它将如何应用于各种情况。
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
简单比复杂好
这是一个相当具体的指导方针,主要涉及到框架和库的接口设计。这里的目标是保持界面尽可能简单,尽可能利用程序员对现有界面的了解。例如,缓存框架可以使用与标准字典相同的接口,而不是发明一套全新的方法调用。
当然,这条规则还有许多其他的应用,比如利用这样一个事实,即大多数表达式可以在没有显式测试的情况下评估为真或假。例如,下面两行代码对于字符串来说在功能上是相同的,但是请注意它们之间的复杂性差异:
if value is not None and value != ":
if value:
如您所见,第二个选项更容易阅读和理解。第一个例子中涉及的所有情况无论如何都将评估为假,因此更简单的测试同样有效。它还有另外两个好处:它运行得更快,需要执行的测试更少,而且它还适用于更多情况,因为单个对象可以定义自己的方法来确定它们应该评估为 true 还是 false。
这似乎是一个令人费解的例子,但这只是经常出现的事情。通过依赖更简单的接口,您通常可以利用优化和增加的灵活性,同时生成更可读的代码。
复杂总比复杂好
然而,有时为了完成工作,需要一定程度的复杂性。例如,数据库适配器不能奢侈地使用简单的字典式接口,而是需要一组广泛的对象和方法来涵盖它们的所有特性。在这种情况下,重要的是要记住,复杂并不一定需要复杂。
很明显,这个问题的棘手之处在于区分这两者。每个术语的字典定义经常引用另一个术语,这在很大程度上模糊了两者之间的界限。出于这一准则的考虑,大多数情况下倾向于对这两个术语采取以下观点:
-
复杂的:由许多相互联系的部分组成的
-
复杂的:复杂到难以理解的
因此,面对一个需要跟踪大量事物的界面,尽可能保持简单就更加重要了。这可以采取将方法合并到更少的对象上的形式,也许将对象分组到更符合逻辑的排列中,或者甚至只是确保使用有意义的名称,而不必钻研代码来理解它们。
扁平的比嵌套的好
这个指导方针起初可能看起来没有意义,但是它是关于结构如何被布置的。正在讨论的结构可以是对象及其属性、包及其包含的模块,甚至是函数中的代码块。目标是尽可能保持同龄人的关系,而不是父母和孩子的关系。例如,取下面的代码片段:
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.")
在这个例子中,很难跟踪到底发生了什么,因为代码块的嵌套性质要求您跟踪多个级别的条件。考虑以下编写相同代码的替代方法,将其展平:
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 根据情况需要使用了一系列的if
、elif
、elif
、else
。已经有 pep 建议包括开关型结构;然而,都没有成功。
警告
可能不太明显的是,这个例子的重构最终测试了x > 0
两次,而之前只执行了一次。如果那个测试是一个昂贵的操作,比如数据库查询,以这种方式重构它会降低程序的性能,所以不值得。这将在后面的指南“实用性胜过纯粹性”中详细介绍
在包布局的情况下,平面结构通常允许单个导入,以使整个包在单个名称空间下可用。否则,程序员需要知道完整的结构才能找到所需的特定类或函数。有些包非常复杂,嵌套结构将有助于减少每个名称空间的混乱,但是最好从平面开始,只有在出现问题时才嵌套。
稀疏比密集好
这个原则很大程度上适用于 Python 源代码的视觉外观,支持使用空白来区分代码块。目标是将高度相关的代码片段放在一起,同时将它们与后续的或不相关的代码分开,而不是简单地让所有代码一起运行,以节省磁盘上的几个字节。熟悉 JAVA、C++和其他使用{ }表示语句块的语言的人也知道,只要语句块在大括号内,空白或缩进就只有可读性,对代码执行没有影响。
在现实世界中,有很多具体的问题需要解决,比如如何分离模块级的类或者处理单行的if
块。虽然没有一套规则适用于所有项目,但是 PEP 8 2 确实指定了源代码布局的许多方面来帮助你坚持这个原则。它提供了许多关于如何格式化导入语句、类、函数甚至许多类型的表达式的提示。
有趣的是,PEP 8 特别包含了许多关于表达式的规则,这些规则特别鼓励避免额外的空格。以下面的例子为例,这些例子直接来自 PEP 8:
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 函数为例,寻找任何可能被打破的承诺:
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
键的存在来正常工作。然而,所有其他假设应该保持不变,并且在被违反时应该抛出各自的异常。这是更改后的样子。
def validate(data):
if 'username' in data and data['username'].startswith('_'):
raise ValueError("Username must not begin with an underscore.")
就这样,去掉了一个假设,函数现在可以很好地运行,不需要在data
字典中提供用户名。或者,您现在可以显式检查缺失的用户名,并在真正需要时引发更具体的异常。如何处理剩余的异常取决于调用validate()
的代码的需求,并且有一个互补的原则来处理这种情况。
除非明确沉默
像任何其他支持异常的语言一样,Python 允许触发异常的代码捕获它们并以不同的方式处理它们。在前面的验证示例中,很可能应该以比完全回溯更好的方式向用户显示验证错误。考虑一个小型命令行程序,它接受用户名作为参数,并根据前面定义的规则对其进行验证:
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 的创造者和“仁慈的终身独裁者”的祖国吉多·范·罗苏姆的致敬。然而,更重要的是,这承认了并非每个人都以同样的方式看待事物。对一个人来说似乎显而易见的东西对另一个人来说可能完全陌生,尽管这些差异有许多原因,但没有一个是错的。不同的人是不同的,就是这样。
克服这些差异的最简单的方法是正确地记录您的工作,这样即使代码不明显,您的文档也可以指明方向。您可能仍然需要回答文档之外的问题,因此与用户进行更直接的交流通常是有用的,比如邮件列表。最终目标是给用户一个简单的方法,让他们知道你打算如何使用你的代码。为了您和您的用户的利益,对单行注释使用#号,对块注释使用"“” “”"三引号。
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 社区普遍高度重视的许多理念,但它们只有在实际代码中应用于实际设计决策时才有价值。本书的其余部分将经常引用这一章,解释这些决定如何进入所描述的代码。在下一章中,我将研究一些更基本的技术,你可以在这些技术的基础上把这些原则运用到你的代码中。
参见新闻组 http://propython.com/comp-lang-python
。
2
参见“PEP 8—Python 代码样式指南”, http://propython.com/pep-8
。
3
http://propython.com/rfc-761
见【标题】。
****
二、高级基础
像任何其他关于编程的书一样,这本书的其余部分依赖于读者可能会或可能不会认为很平常的一些特性。读者应该对 Python 和编程有很好的了解,但是有许多很少使用的特性在整本书展示的许多技术的操作中非常有用。
因此,尽管看起来不同寻常,这一章还是集中在高级基础的概念上。本章中的工具和技术不一定是常识,但它们为后续更高级的实现奠定了坚实的基础。让我们从 Python 开发中经常出现的一些通用概念开始。
一般概念
在进入更具体的细节之前,重要的是先了解一下隐藏在本章后面的细节背后的概念。这些不同于第一章中讨论的原则和哲学,因为它们更关注实际的编程技术,而之前讨论的是更通用的设计目标。
将第一章视为设计指南,而本章介绍的概念更像是实现指南。当然,像这样具体的描述不会陷入太多的细节中,所以这一节将遵从本书其余章节中更详细的信息。
循环
尽管 Python 代码中可能会出现几乎无限多种不同类型的序列——在本章后面和第五章中会有更多介绍——但大多数使用它们的代码都可以归为两类:实际使用整个序列的代码和只需要序列中的项目的代码。大多数函数以不同的方式使用这两种方法,但是为了理解 Python 提供了什么工具以及应该如何使用它们,这种区别是很重要的。
从纯面向对象的角度来看,与函数式编程的角度相反,很容易理解如何处理代码实际需要使用的序列。您将拥有一个具体的条目,比如一个列表、集合或字典,它不仅拥有与之相关联的数据,还拥有允许访问和修改这些数据的方法。您可能需要多次迭代它,无序地访问单个项,或者从其他方法返回它以供其他代码使用,所有这些都适用于更传统的对象用法。
同样,你可能实际上不需要把整个序列作为一个整体来处理;你可能只对其中的每一项感兴趣。例如,当在一系列数字上循环时,经常会出现这种情况,因为重要的是让循环中的每个数字都可用,而不是让整个数字列表都可用。
这两种方法的区别主要在于意图,但也有技术上的影响。并不是所有的序列都需要加载到内存中,很多甚至根本不需要有一个有限的上限,比如网络流。这一类别包括正奇数集、整数平方和斐波纳契数列,所有这些都是无限长的,很容易计算。因此,它们最适合纯迭代,不需要预先填充列表,这也节省了一点内存。
这样做的主要好处是内存分配。设计用来打印斐波纳契数列的整个范围的程序,在任何给定的时间只需要在内存中保存几个变量,因为数列中的每个值都可以通过前面两个值计算出来。填充一个值列表,即使长度有限,也需要在迭代之前将所有包含的值加载到内存中。如果整个列表永远不会作为一个整体来执行,那么简单地在需要时生成每个项目,并在不再需要时丢弃它,以便生成新的项目,这样效率会高得多。
Python 作为一种语言,提供了几种不同的实现方式来迭代一个序列,而不需要一次将所有的值都放入内存。在其标准库中,Python 在其提供的许多特性中使用了这些技术,这有时可能会导致混淆。Python 允许您毫无问题地编写一个for
循环,但是许多序列没有您可能期望在列表中看到的方法和属性。要查看两种类型的循环,请尝试以下操作:
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 文档缓存的细节超出了本书的范围,但是它是缓存一般如何工作的一个很好的例子:
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)。
本书的其余部分包含了许多不同的内省技术,在这些信息可用的地方。对于那些数据确实应该受到保护的罕见情况,第三章和第四章展示了数据如何显示隐私意图或被完全隐藏。
控制流
一般来说,程序的控制流就是程序在执行过程中所走的路径。控制流更常见的例子,当然包括序列结构,是if
、for,
和while
块,它们用于管理你的代码可能需要的最基本的分支。这些块也是 Python 程序员首先要学习的一些东西,因此本节将关注一些较少使用和利用不足的控制流机制。
捕捉异常
第一章解释了 Python 哲学是如何鼓励在违背预期的情况下使用异常的,但是预期在不同的使用中会有所不同。当一个应用或模块依赖于另一个时,这种情况尤其常见,但在单个应用中也很常见。本质上,每当一个函数调用另一个函数时,它可以在被调用函数已经处理的异常之上添加自己的期望。
使用关键字raise
用简单的语法引发异常,但是捕获它们稍微复杂一些,因为它使用了关键字的组合。try
关键字在您认为可能会发生异常的地方开始一个块,而except
关键字标记一个在出现异常时要执行的块。第一部分很简单,因为try
没有任何附加信息,最简单的except
也不需要任何附加信息:
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
块中的代码。就目前的情况而言,这并没有对可能出现的许多不同的异常做出任何区分;无论发生什么,函数总是返回一个数字。然而,实际上您很少想这样做,因为许多异常实际上应该传播到调用者——错误不应该无声无息地传递。一些著名的例子是SystemExit
和KeyboardInterrupt
,这两者通常都会导致程序停止运行。
为了考虑那些你的代码不应该干涉的异常,关键字except
可以接受一个或多个应该被显式捕获的异常类型。任何其他的牌都会被简单地加注,就好像你根本没有try
牌一样。这使得except
块只关注那些应该明确处理的情况,所以你的代码只需要处理它应该管理的事情。对您刚才尝试的内容做一些小的更改,如下所示,看看效果如何:
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
,都将在这个函数之外被引发,由调用堆栈中的其他代码处理。
如果您需要捕获多种异常类型,有两种方法。第一个也是最简单的方法是简单地捕获一些基类,所有必需的异常都是从这个基类派生出来的。因为异常处理与指定的类及其所有子类相匹配,所以当您需要捕捉的所有类型都有一个公共基类时,这种方法非常有效。在行计数的例子中,您可能会遇到IOError
或OSError
,它们都是EnvironmentError
的后代:
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
注意
尽管我们只对IOError
和OSError
感兴趣,但是EnvironmentError
的所有子类也会被捕获。在这种情况下,这很好,因为它们是EnvironmentError
的唯一子类,但是一般来说,您会希望确保没有捕捉到太多的异常。
其他时候,您可能希望捕获不共享公共基类的多个异常类型,或者可能将其限制在较小的类型列表中。在这些情况下,您需要单独指定每种类型,用逗号分隔。在count_lines()
的情况下,如果传入的文件名不是有效的字符串,也有可能引发TypeError
:
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 )来实现,该子句将被绑定到异常对象:
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
实际上存储了一个名为ValueError
的TypeError
对象!
为了解决这种情况,添加了关键字as
,并成为存储异常对象的唯一方式。尽管这消除了模糊性,但为了清晰起见,多个异常仍然必须包装在元组中。
可以使用多个except
子句,允许您以不同的方式处理不同的异常类型。例如,EnvironmentError
OSError 的构造函数可选地接受两个参数,一个错误代码和一个错误消息,它们组合起来形成完整的字符串表示。为了只记录这种情况下的错误消息,但仍然正确处理TypeError
的情况,可以使用两个except
子句:
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
显式实现,也可以通过作为处理的一部分执行的其他代码隐式实现。无论哪种方式,这种情况都带来了一个问题,即哪个异常足够重要,足以将其自身呈现给应用的其余部分。这个问题的确切答案取决于代码的布局,所以让我们看一个简单的例子,其中异常处理代码打开并写入一个日志文件:
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
是一个只读文件:
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
关键字:
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
块中并检查异常的类型,这很容易测试:
try:
validate(False, validator)
except Exception as e:
print(type(e))
<class 'ValueError'>
当一切顺利的时候
另一方面,您可能会发现您有一个复杂的代码块,您需要捕捉其中一部分可能突然出现的异常,但是该部分之后的代码应该不进行任何错误处理。显而易见的方法是简单地将代码添加到try
/ except
块之外。下面是我们如何调整count_lines()
函数,将产生错误的代码包含在try
块中,同时在处理完异常后进行行计数:
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
关键字提供了这个特性,它定义了一个单独的块:
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
块,每当相关的try
、except
和else
块完成时,就会执行这个块。因为count_lines()
打开了一个文件,最佳实践建议它也显式地关闭该文件,而不是等待垃圾回收来处理它。使用finally
提供了一种确保文件总是被关闭的方法。
还有一点要考虑。到目前为止,count_lines()
只预测了在试图打开文件时可能发生的异常,尽管在读取文件时会出现一个常见的异常:UnicodeDecodeError
。第七章介绍了一点 Unicode 以及 Python 如何处理它,但是现在,只需要知道它经常出现。为了捕捉这个新的异常,有必要将readlines ()
调用移回到try
块中,但是我们仍然可以将行计数留在else
块中:
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
语句:
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()
可能看起来像这样:
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()
来利用这一切的方法:
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
组合来完成,如下所示:
def test_value(value):
if value < 100:
return 'The value is just right.'
else:
return 'The value is too big!'
print(test_value(55))
不要把它写成四行,可以用一个条件表达式把它压缩成一行。通过将if
和else
块转换成表达式中的子句,Python 更简洁地实现了同样的效果:
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
表达式。取而代之的是,许多程序员依赖于and
和or
操作符的行为,可以让它们做一些非常类似的事情。下面是如何仅使用这些运算符来重写前面的示例:
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.'
在这个过程中,代码如下所示:
return 'The value is ' + ('just right.' or 'too big!')
从这里开始,or
操作符的工作方式类似于and
,检查它左边的值,看它的计算结果是否为真。不同之处在于,如果值为 true,则返回该值,甚至根本不计算运算符右侧的值。看看这里的压缩代码,很明显,or
会返回字符串,'just right.'
相比之下,如果传递给test_value()
函数的值是 150,行为就会改变。因为150 < 100
的计算结果为假,所以and
操作符返回该值,而不计算右边的值。在这种情况下,结果表达式如下:
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()
函数,它似乎可以迭代给定范围内的整数:
>>>for x in range(5):
print(x)
0
1
2
3
4
乍一看,range()
似乎返回了一个包含适当值的列表,但事实并非如此。如果您单独检查它的返回值,而不对它进行迭代,就会显示:
>>>range(5)
>>>range(0, 5)
>>>list(range(5))
[0, 1, 2, 3, 4]
range
对象本身不包含序列中的任何值。相反,它在迭代过程中按需一次生成一个。如果你真的想要一个可以添加或删除条目的列表,你可以通过将range
对象传递给一个新的list
对象来转换它。这就像一个for
循环一样进行内部迭代,所以生成的列表使用与迭代range
本身时相同的值。
第五章展示了如何编写自己的可迭代对象,其工作方式类似于range()
。除了提供可迭代对象之外,在不同的情况下,出于不同的目的,有许多方法可以迭代这些对象。for
循环是最明显的技术,但是 Python 也提供了其他形式的语法,这将在本节中概述。
序列解包
一般来说,你可以一次给一个变量赋一个值,所以当你有一个序列时,你可以把整个序列赋给一个变量。当序列很小时,并且您知道序列中有多少项以及每项将是什么,这是相当有限的,因为您通常最终只是单独访问每项,而不是作为序列来处理它们。
这在处理元组时尤其常见,其中序列通常具有固定的长度,并且序列中的每一项都具有预定的含义。这种类型的元组也是从一个函数返回多个值的首选方式,这使得必须将它们作为一个序列来处理更加麻烦。理想情况下,在获取函数的返回值时,您应该能够直接将它们作为单独的项进行检索。
为此,Python 支持一种称为序列解包的特殊语法。您可以在=
操作符的左侧指定多个名称作为元组,而不是指定一个名称来赋值。这将导致 Python 解包操作符右侧的序列,将每个值分配给左侧的相关名称:
>>> '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
:
>>> domain, *path = 'propython.com/example/url'.split('/')
>>> domain
'propython.com'
>>> path
['example', 'url']
注意
第三章展示了类似的语法如何应用于函数参数。
列表理解
当序列中的项目多于实际需要的项目时,生成一个新列表并只添加那些符合特定标准的项目通常会很有用。有几种方法可以做到这一点,最明显的是使用一个简单的for
循环,依次添加每个项目:
>>> output = []
>>> for value in range(10):
... if value > 5:
... output.append(str(value))
...
>>> output
['6', '7', '8', '9']
不幸的是,这给代码增加了四行和两级缩进,尽管这是一种非常常用的模式。相反,Python 为这种情况提供了更简洁的语法,允许您将代码的三个主要方面表达在一行中:
-
要从中检索值的序列
-
用于确定是否应包含某个值的表达式
-
用于为新列表提供值的表达式
这些都被组合成一种叫做列表理解的语法。下面是前面的例子在使用这个结构重写后的样子。为清晰起见,突出显示了该表单的三个基本部分:
>>> output = [str(value) for value in range(10) if value > 5]
>>> output
['6', '7', '8', '9']
正如您所看到的,整个表单的三个部分已经稍微进行了重新安排,首先是最终值的表达式,然后是迭代,最后是决定包含哪些项的条件。你也可以把包含新列表的变量看作是表单的第四部分,但是因为理解实际上只是一个表达式,所以不需要给它赋值。它可以很容易地用于将一个列表输入到一个函数中:
>>> min([value for value in range(10) if value > 5])
6
当然,这似乎违背了之前指出的迭代的全部要点。毕竟,理解返回一个完整的列表,只是在min()
处理这些值时被丢弃。对于这些情况,Python 提供了不同的选项:生成器表达式。
生成器表达式
与其根据特定的标准创建一个完整的列表,还不如利用迭代的力量来完成这个过程。不要将压缩放在括号中,这表示创建了一个合适的列表,而是可以将它放在括号中,这将创建一个生成器。下面是它的实际效果:
>>> 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 知道您的意图。
集合理解
集合——在“集合”一节中有更详细的描述——在结构上与列表非常相似,所以你可以用与列表基本相同的方式使用理解来构建集合。两者之间唯一的显著区别是使用了花括号,而不是表达式两边的括号:
>>> {str(value) for value in range(10) if value > 5}
{'6', '7', '8', '9'}
注意
与序列不同,集合是无序的,因此不同的平台可能以不同的顺序显示项目。唯一的保证是相同的项目将出现在集合中,而不管平台如何。
词典释义
当然有一个主题随着不同类型的理解的构建而发展,而且仅限于一维序列。字典也可以是序列的一种形式,但是每个条目实际上是一对键和它的值。这反映在文字形式中,通过使用冒号将每个键与其值分开。
因为冒号是区分字典和集合的语法的因素,所以同一个冒号也是区分字典理解和集合理解的因素。在通常包含单个值的地方,只需提供一个键/值对,用冒号分隔。其余的理解遵循与其他类型相同的规则:
>>> {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:
>>> 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()
函数在需要时提供该功能:
>>> 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 配对,以构建字典:
>>> 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 中由元组和列表表示,但是集合提供了另一种处理相同数据的方式。本质上,集合的工作方式很像列表,但是不允许有任何重复,这使得它对于识别集合中的唯一对象很有用。例如,下面是一个简单函数如何使用集合来确定给定字符串中使用的字母:
>>> def unique_letters(word):
... return set(word.lower())
...
>>> unique_letters('spam')
{'a', 'p', 's', 'm'}
>>> unique_letters('eggs')
{'s', 'e', 'g'}
请注意以下几点:
-
首先,内置的
set
类型将一个序列作为它的参数,用在该序列中找到的所有唯一元素填充集合。这对于任何序列都是有效的,例如示例中所示的字符串以及列表、元组、字典键或自定义可迭代对象。 -
第二,集合中的项并没有按照它们在原始字符串中出现的方式排序。集合只关心成员资格。他们跟踪集合中的项目,没有任何排序的概念。这似乎是一个限制,但如果你需要订购,你可能需要一个清单。当您只需要知道一个项目是否是集合的成员,而不考虑它在集合中的位置或者它出现了多少次时,集合是非常有效的。
-
第三,在交互式外壳中显示集合时显示的表示。因为这些表示的格式与您在源文件中输入的格式相同,所以这表明了在代码中将集合声明为文本的语法。它看起来非常类似于字典,但是没有任何与键相关联的值。这实际上是一个相当准确的类比,因为集合的工作方式非常类似于字典中键的集合。
因为集合是为不同于序列和字典的目的而设计的,所以可用的操作和方法与您可能习惯的稍有不同。然而,首先让我们看看集合相对于其他类型的行为方式。也许集合最常见的用途是确定成员资格,这是列表和字典经常需要完成的任务。本着符合期望的精神,这里使用了其他类型中常见的in
关键字:
>>> example = {1, 2, 3, 4, 5}
>>> 4 in example
True
>>> 6 in example
False
此外,以后还可以在器械包中添加或删除物品。list 的append()
方法不适用于集合,因为追加一个项目就是将它添加到末尾,这意味着集合中项目的顺序很重要。因为集合根本不关心排序,而是使用add()
方法,它只是确保指定的项目出现在集合中。如果它已经存在,add()
什么也不做;否则,它会将项目添加到集合中,这样就不会有任何重复项:
>>> example.add(6)
>>> example
{1, 2, 3, 4, 5, 6}
>>>
>>> example
{1, 2, 3, 4, 5, 6}
字典有一个有用的update()
方法,它将新字典的内容添加到已经存在的字典中。集合也有一个update()
方法,执行相同的任务:
>>> example.update({6, 7, 8, 9})
>>> example
{1, 2, 3, 4, 5, 6, 7, 8, 9}
从器械包中移除物品有几种不同的方式,每种方式都有不同的需求。对add()
最直接的补充是remove()
方法,它从集合中删除一个特定的项目。如果该项目一开始就不在集合中,它会引发一个KeyError
:
>>> example.remove(9)
>>> example.remove(9)
Traceback (most recent call last):
...
KeyError: 9
>>> example
{1, 2, 3, 4, 5, 6, 7, 8}
然而,很多时候,该项目是否已经在集合中并不重要;你可能只关心当你用完它的时候它不在布景里。为此,集合也有一个discard()
方法,它的工作方式类似于remove()
,但是如果指定的项目不在集合中,它不会引发异常:
>>> example.discard(8)
>>> example.discard(8)
>>> example
{1, 2, 3, 4, 5, 6, 7}
当然,remove()
和discard()
都是假设你已经知道要从集合中移除什么对象。要简单地从集合中删除任何项目,可以使用pop()
方法,该方法也是从 list API 中借用的,但略有不同。因为集合没有明确地排序,所以对于要弹出的项目来说,集合没有真正的结尾。相反,集合的pop()
方法选择一个,不可预测地返回它供集合外使用:
>>> example.pop()
1
>>> example
{2, 3, 4, 5, 6, 7}
最后,集合还提供了一种一次性移除所有项目的方法,可以将其重置为空状态。clear()
方法用于此目的:
>>> example.clear()
>>> example
set()
注意
空集的表示是set()
,而不是{}
,因为 Python 需要保持集合和字典之间的区别。为了保持与引入集合文字之前编写的旧代码的兼容性,空花括号仍然专用于字典,因此集合使用它们的名称。
除了就地修改内容的方法之外,集合还提供了两个集合以某种方式组合返回一个新集合的操作。其中最常见的是联合,在这种联合中,两个集合的内容被结合在一起,因此产生的新集合包含了两个原始集合中的所有项目。这与使用update()
方法本质上是一样的,只是没有改变任何原始设置。
两个集合的并集很像是按位的 or 运算,所以 Python 用管道字符(|
)来表示它,这与按位的 OR(比较每个字节)的用法相同。此外,集合使用union()
方法提供相同的功能,可以从涉及的任一集合调用该方法:
>>> {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()
方法,它执行相同的任务:
>>> {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()
方法来执行这个操作:
>>> {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 运算,所以集合使用相同的运算符和方法:
>>> {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
,第一个项目肯定是第二个项目的子集,测试超集就像交换操作中的两个集合一样简单。使用这些方法可以避免创建一个新的集合,只是将它简化为一个布尔值:
>>> {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 类本身的名称,但不幸的是,第二个参数没有这么简单。它接受一个属性名字符串,由空格或逗号分隔:
>>> 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
类。这提供了字典的所有功能,但具有可靠的键排序:
>>> 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()
方法来实现,如果没有找到键,该方法可以返回合适的默认值。这种模式的一个例子是使用字典来跟踪每个单词在一些文本中出现的次数:
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
:
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 更改了其导入位置:
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
赋给包含导入模块的变量:
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 提升为浮点值一样处理它:
>>> 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 导入的一个很少使用的特性是能够将名称空间从一个模块导入到另一个模块。这是通过使用星号作为要导入的模块部分来实现的:
>>> from itertools import *
>>> list(chain([1, 2, 3], [4, 5, 6]))
[1, 2, 3, 4, 5, 6]
一般来说,这只是获取导入模块的名称空间中不以下划线开头的所有条目,并将它们转储到当前模块的名称空间中。它可以节省大量使用导入模块的模块中的一些输入,因为它使您不必在每次访问它的一个属性时都包含模块名。
然而,有时以这种方式使每个对象都可用是没有意义的。特别是,框架通常包括许多在框架模块中有用的实用函数和类,但是当导出到外部代码时就没什么意义了。为了控制像这样导入模块时导出什么对象,您可以在模块中的某个地方指定__all__
。
您所需要做的就是提供一个列表——或者其他序列——其中包含在使用星号导入模块时应该导入的对象的名称。额外的对象仍然可以通过直接导入名称或者只导入模块本身而不是模块内部的任何东西来导入。下面是一个示例模块如何提供它的__all__
选项:
__all__ = ['public_func']
def public_func():
pass
def utility_func():
pass
当然,在现实世界中,这两个函数都会有有用的代码。不过,为了便于说明,这里有一个导入该模块的不同方法的简要介绍,我们称之为example
:
>>> 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
导入,那么下面两种导入模式是相同的:
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__()
返回的模块中获得每个属性层。这里有一个简单的函数可以完成这项工作:
>>> 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 自己的模块缓存机制来消除额外的处理:
>>> 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__()
返回第一个模块。由于这一功能,上一节中添加的额外处理变得完全没有必要,因此这是一种更好的使用方法:
>>> 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__
变量,它保存了最初用于导入当前模块的模块路径:
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 最基本、最谦逊的特性之一:函数。
参见 http://propython.com/whats-new
的“最新消息”页面。
2
NIST,《NIST 随机性灯塔》, https://www.nist.gov/programs-projects/nist-randomness-beacon
,2018 年 5 月 22 日访问。
*
三、函数
任何编程语言的核心都是函数的概念,但我们往往认为它们是理所当然的。当然,有一个显而易见的事实,函数允许将代码封装到单独的单元中,这些单元可以重用,而不是到处复制。但是 Python 超越了某些语言所允许的概念,函数是成熟的对象,可以在数据结构中传递,包装在其他函数中,或者完全被新的实现所取代。
事实上,Python 为函数提供了足够的灵活性,实际上有几种不同类型的函数,反映了各种形式的声明和目的。理解每一种类型的函数将有助于您在使用自己的代码时决定哪种函数适合您遇到的每种情况。本章依次解释了它们,以及各种各样的特性,您可以利用这些特性来扩展您创建的每个函数的值,而不管它是什么类型。
从本质上讲,所有的函数都是平等的,不管它们属于以下哪一部分。内置的function
类型构成了它们的基础,包含了 Python 理解如何使用它们所需的所有属性:
>>> def example():
... pass
...
>>> type(example)
<type 'function'>
>>> example
<function example at 0x...>
当然,仍然有许多不同类型的函数和许多不同的声明它们的方法。首先,让我们检查一下函数最普遍的一个方面。
争论
大多数函数都需要一些参数来做一些有用的事情。通常,这意味着在函数(声明的)签名中按顺序定义它们,然后在以后调用该函数时按相同的顺序提供它们。Python 支持这种模型,但也支持传递关键字参数,甚至是在调用函数之前不知道的参数。
Python 的关键字参数的一个最常见的优点是,可以以不同于函数中定义的顺序传递参数。您甚至可以完全跳过参数,只要它们定义了默认值。这种灵活性有助于鼓励使用支持大量带有默认值的参数的函数。
显性比隐性好
Python 的关键字参数鼓励显式的一种方式是,如果参数是通过关键字传递的,则只允许参数乱序传递。如果没有关键字,Python 需要使用实参的位置来知道函数运行时要绑定哪个参数名。因为关键字和位置一样显式,所以可以取消排序要求,而不会引入歧义。
事实上,在处理参数时,关键字甚至比位置更明确,因为函数调用记录了每个参数的用途。否则,您必须查找函数定义才能理解它的参数。有些参数在上下文中可能是可以理解的,但大多数可选参数看起来并不明显,所以用关键字传递它们有助于提高代码的可读性。
规划灵活性
规划参数名称、顺序和默认值对于那些不是由编写它们的人调用的函数来说尤其重要,比如那些分布式应用中的函数。如果您不知道最终将使用您的代码的用户的确切需求,最好将您可能有的任何假设转移到以后可以被覆盖的参数中。
举一个极其简单的例子,考虑一个向字符串追加前缀的函数:
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_'
前缀对于应用来说可能是有意义的,但是当其他东西想要使用它时会发生什么呢?现在,前缀被硬编码到函数体中,所以没有其他选择。将这一假设转移到参数中有助于以后定制函数:
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
参数的函数调用不需要改变,所以现有代码工作得很好。本章后面关于预加载参数的部分展示了前缀是如何被改变的,并且仍然被不知道它的代码使用。
当然,这个例子太简单了,不能提供太多的实际价值,但是本书其余部分中举例说明的函数将利用大量可选参数,显示它们在每种情况下的价值。
可变位置参数
大多数函数被设计为处理一组特定的参数,但是有些函数可以处理任意数量的参数,依次处理每个参数。这些可以作为元组、列表或其他可迭代对象传递到单个参数中。
以一个典型的购物车为例。向购物车添加商品可以一次添加一个,也可以分批添加。使用一个类的定义,里面有一个函数,下面是如何使用一个标准参数完成和的:
class ShoppingCart:
def add_to_cart(items):
self.items.extend(items)
这当然会成功,但是现在考虑一下这对所有必须调用它的代码意味着什么。常见的情况是只添加一个条目,但是由于该函数总是接受一个列表,所以它最终看起来会像这样:
cart.add_to_cart([item])
所以我们基本上是为了支持少数派而破坏多数派的案子。更糟糕的是,如果add_to_cart()
最初只支持一个项目,后来被修改为支持多个项目,那么这个语法会破坏任何现有的调用,需要您重写它们来避免一个TypeError
。
理想情况下,该方法应该支持单个参数的标准语法,同时仍然支持多个参数。通过在参数名称前添加一个星号,可以指定将所有剩余的位置参数收集到一个元组中,该元组绑定到以星号为前缀的参数,该参数之前没有赋值。在这种情况下,没有其他参数,因此可变位置参数可以构成整个参数列表:
def add_to_cart(*items):
self.items.extend(items)
现在,可以用任意数量的位置参数调用该方法,而不必先将这些参数分组到一个元组或列表中。在函数开始执行之前,额外的参数被自动捆绑在一个元组中。这清理了常见的情况,同时仍然可以根据需要启用更多的参数。以下是如何调用该方法的几个示例:
cart.add_to_cart(item)
cart.add_to_cart(item1, item2)
cart.add_to_cart(item1, item2, item3, item4, item5)
还有一种方法可以调用这个函数,它允许调用代码支持任意数量的项,但是它并不特定于设计为接受变量参数的函数。有关所有细节,请参见使用变量参数调用函数一节。
可变关键字参数
函数可能需要额外的配置选项,特别是如果将这些选项传递给其他库的话。显而易见的方法是接受一个字典,它可以将配置名称映射到它们的值:
class ShoppingCart:
def __init__(self, options):
self.options = options
不幸的是,这最终会导致一个类似于我们在上一节中描述的位置参数所遇到的问题。仅覆盖一两个值的简单情况变得相当复杂。根据偏好,函数调用可能有两种方式:
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 严格要求变量参数之后的所有内容只能通过关键字访问。函数中明确定义的位置参数值之外的值会直接进入变量参数,不管提供的是一个还是几十个。实现变得很容易解释,因为只有一种方法可以做到这一点,而且通过强制使用关键字,实现变得更加清晰。
这种行为的另一个特点是,仍然需要将显式参数放在变量位置参数之后。这两种类型的放置之间唯一真正的区别是使用关键字参数的要求;参数是否需要值仍然取决于您是否定义了默认参数:
>>> 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 的任何抱怨。调用函数时顺序并不重要,因此定义函数时顺序也不重要。考虑重写前面的示例,要求前缀作为关键字参数,同时使分隔符可选:
>>> 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 解包,并将其内容作为单独的参数传递。
相同的星号(*)符号用于指定变量参数,然后将变量参数扩展为函数调用,就好像所有参数都是直接指定的一样。一个星号指定位置参数,而两个星号指定关键字参数。这在将函数调用的返回值直接作为参数传递,而不先将其分配给单个变量时特别有用:
>>> value = 'ro ython'
>>> join_with_prefix(*value.split(' '), prefix="P")
这个例子看起来很明显,因为它是一个传递给变量参数的变量参数,但是同样的过程也适用于其他类型的函数。因为参数在传递给函数之前会被扩展,所以它可以用于任何函数,而不管它的参数是如何指定的。它甚至可以与内置函数和用 c 编写的扩展定义的函数一起使用。
注意
在函数调用中,只能传入一组可变位置参数和一组可变关键字参数。例如,如果您有两个位置参数列表,您需要自己将它们连接在一起,并将组合后的列表传递给函数,而不是试图分别使用这两个列表。
传递参数
当您开始向函数调用添加一些参数(其中许多是可选的)时,知道一些需要传递的参数值就变得很常见了,即使离函数真正被调用还有很长时间。与其在调用时传递所有参数,不如提前应用一些参数,这样以后应用的参数就更少了。
这个概念被官方称为函数的部分应用,但是这个函数还没有被调用,所以它实际上更多的是预先加载一些参数。当稍后调用预加载的函数时,传递的任何参数都会添加到先前提供的参数中。
Currying 呢?
如果你熟悉其他形式的函数式编程,你可能听说过curry,这可能看起来非常类似于预加载参数。一些框架甚至提供了名为curry()
的函数,可以预加载函数的参数,这导致了更多的混乱。这两者之间的区别是微妙但重要的。
对于一个真正的 curried 函数,你必须根据需要多次调用它来填充所有的参数。如果一个函数接受三个参数,而你只用一个参数调用它,你会得到一个接受两个以上参数的函数。如果您调用这个新函数,它仍然不会执行您的代码,而是会加载下一个参数并返回另一个采用最后一个剩余参数的函数。调用该函数将最终满足所有参数,因此实际的代码将被执行并返回一个有用的值。
部分应用返回一个函数,该函数在稍后被调用时,无论还有多少个参数,都至少会尝试执行代码。如果需要的参数还没有得到值,Python 会抛出一个TypeError
,就像你在其他时候用缺少的参数调用它一样。因此,尽管这两种技术之间肯定有相似之处,但理解它们的区别还是很重要的。
这个行为是作为内置的functools
模块的一部分,通过它的partial() function
来提供的。通过传入一个 callable 和任意数量的位置和关键字参数,它将返回一个新的 callable,稍后可以使用它来应用这些参数:
>>> 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
:仅关键字参数的缺省值字典 -
参数注释的字典,这将在本章后面解释
为了更好地说明元组的每个部分中存在什么值,下面是它如何映射到一个基本的函数声明:
>>> 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 函数调用之外的东西生成参数列表的系统中。一些例子包括来自模板语言的指令和解析文本输入的正则表达式。
不幸的是,位置参数带来了一点问题,因为它们的值不包括它们将被发送到的参数的名称。默认值也是一个问题,因为函数调用根本不需要包含任何值。因为日志应该包括将提供给函数的所有值,所以这两个问题都需要解决。
首先,简单的部分。由关键字传递的任何参数值都不需要手动匹配,因为参数名称是与值一起提供的。与其一开始就考虑日志记录,不如让我们从一个函数开始,获取字典中可以记录的所有参数。该函数接受一个函数、一组位置参数和一个关键字参数字典:
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()
来完成繁重的工作:
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}
既然已经处理了位置参数,让我们继续计算缺省值。如果有任何默认值没有被所提供的参数覆盖,这些默认值应该被添加到参数字典中,因为它们将被发送到函数:
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()
为这些值返回一个单独的字典:
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()
函数,首先从函数声明中取出默认值,然后用作为实际参数传入的任何值替换它们。这避免了许多必须进行的检查,以确保不会被意外覆盖。
第一步是获取默认值。因为如果没有指定默认值,参数规范的defaults
和kwonlydefaults
属性将被设置为None
,所以我们实际上必须从设置一个空字典来更新开始。然后可以添加位置参数的默认值。
因为这一次只需要更新一个字典,而不考虑字典中可能已经有什么,所以使用不同的技术来获得位置默认值会更容易一些。我们可以使用一个类似的zip()
来获得显式参数值,而不是使用一个很难阅读的复杂切片。通过首先颠倒参数列表和默认值,它们仍然从末尾开始匹配:
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()
,然后继续:
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()
函数中传递的,而不是首先被复制来形成参数字典:
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()
已经确保可选参数有它们的默认值。因此,任何没有值的参数都是必需的:
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
有了验证所有必需参数都有值的基础,下一步是确保函数知道如何处理所有提供的参数。任何没有在函数中定义的参数都应被视为错误:
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 只是简单的函数,设计的目的只有一个:接受一个函数,返回一个函数。返回的函数可以与传入的函数相同,也可以完全被其他函数替代。应用装饰器最常见的方式是使用专门为此目的设计的特殊语法。下面是如何应用一个装饰器来抑制函数执行过程中的任何错误:
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 中使用的过程,更容易理解幕后发生的事情:
#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 移到顶部,可读性得到了极大的增强。
旧的选项仍然可用,其行为与@
语法相同。唯一真正的区别是@
语法仅在源文件中定义函数时可用。如果你想装饰一个从别处导入的函数,你必须手动将它传递给装饰器,所以记住它的两种工作方式是很重要的:
from myapp import log_error, suppress_errors
log_error = suppress_errors(log_error)
为了理解像log_error()
这样的装饰器内部通常会发生什么,有必要首先研究一下 Python 和许多其他语言中最容易被误解和利用不足的特性之一:闭包。
关闭
尽管闭包很有用,但它似乎是一个令人生畏的话题。大多数解释都假设事先知道诸如词法范围、自由变量、上取值和变量范围之类的东西。此外,因为无需学习闭包就可以做很多事情,所以这个主题通常看起来神秘而不可思议,好像它是专家的领域,不适合我们其他人。幸运的是,闭包并不像术语所暗示的那样难以理解。
简而言之,闭包是一个在另一个函数内部定义的函数,但是它被传递到该函数之外,在那里它可以被其他代码使用。还有一些其他的细节需要学习,但是在这一点上它仍然是相当抽象的,所以这里有一个闭包的简单例子:
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 提示符下一行一行地输入前面的代码,下面的代码会让您知道这是如何工作的:
>>> times2 = multiply_by(2)
>>> times2(5)
10
>>> times2(10)
20
>>> times3 = multiply_by(3)
>>> times3(5)
15
>>> times2(times3(5))
30
这种行为看起来有点像functools.partial()
的参数预加载特性,但是你不需要一个函数同时接受两个参数。然而,关于这是如何工作的有趣部分是,内部函数不需要接受自己的factor
参数;它本质上继承了外部函数的参数。
当查看代码时,内部函数可以引用外部函数的值这一事实通常看起来非常正常,但是有一些关于它如何工作的规则可能不太明显。首先,内部函数必须定义在外部函数中;简单地将函数作为参数传入是行不通的:
def multiply(value):
return value * factor
def custom_operator(func, factor):
return func
multiply_by = functools.partial(custom_operator, multiply)
从表面上看,这几乎等同于前面展示的工作示例,但是增加了能够在运行时提供可调用的好处。毕竟,内部函数被放在外部函数中,并被返回供其他代码使用。问题是闭包只在内部函数实际定义在外部函数内部时才起作用,而不仅仅是传入的任何东西:
>>> 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()
装饰器背后的代码:
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 如何访问一个函数的某些属性,比如它的名字、文档字符串和参数列表。通过用包装器替换原始函数,我们实际上也替换了所有其他信息。为了找回一些,我们求助于名为wraps
的functools
模块中的装饰器。
在装饰器中使用装饰器可能看起来很奇怪,但它确实解决了和其他任何事情一样的问题:有一个共同的需求,不应该在它出现的任何地方都需要重复的代码。functools.wraps()
decorator 将名称、docstring 和其他一些信息复制到包装的函数中,因此至少有一部分得到保留。它不会复制参数列表,但总比什么都没有好:
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 函数来报告错误,而不是完全消除错误时的样子:
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()
,但是不带任何参数。
下面是一个示例函数,它处理给定目录中的更新文件。这是一项经常自动执行的任务,因此如果出现问题,它可以停止运行,并在下一个指定的时间再次尝试:
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
来做到这一点,但是因为我们知道位置参数列表只是一个固定的单个参数,所以将它作为第一个参数并使其可选更容易。然后,任何附加的关键字参数都可以放在它的后面。当然,它们都需要默认值,但是这里的要点是所有的参数都是可选的,所以这不是问题。
参数的区别已经消除,剩下的就是如果提供了参数,就分支到不同的代码块,而不是要修饰的函数。通过使用可选的第一个位置参数,我们可以简单地测试它的存在,以确定通过哪个分支:
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
——没错,你的函数已经消失了。假设您键入了上述函数,您可以在提示符下尝试以下操作:
>>> def print_logger(message):
... print(message)
...
>>> @suppress_errors(print_logger)
... def example():
... return variable_which_does_not_exist
...
<function example at 0x...>
>>> example
>>>
这是装饰器工作方式的一个副作用,除了记录它并确保在应用参数时总是指定关键字之外,几乎没什么可做的。
示例:记忆化
为了演示 decorators 如何将公共行为复制到您喜欢的任何函数中,请考虑如何提高确定性函数的效率。给定相同的参数集,确定性函数总是返回相同的结果,不管它们被调用多少次。给定这样一个函数,应该可以缓存给定函数调用的结果,这样,如果用相同的参数再次调用它,就可以查找结果,而不必再次调用该函数。
使用缓存,装饰器可以使用参数列表作为键来存储函数的结果。字典不能用作字典中的键,因此在填充缓存时只能考虑位置参数。幸运的是,大多数利用记忆化的函数都是简单的数学运算,无论如何通常都是用位置参数调用的:
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()
装饰器自动缓存其结果以备将来使用。下面是一些简单数学运算的工作原理。同样,假设您键入了上述存根,请尝试以下操作:
>>> @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 函数,就像他们装饰的那些一样,这是很有可能的。然而,和其他情况一样,有些事情需要考虑。在这种情况下,您定义为装饰器的函数需要区分用于装饰器的参数和用于它所装饰的函数的参数:
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()
装饰器:
>>> @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 对这种需求的回应是函数注释的概念。每个参数以及返回值都可以附加一个表达式,描述一个无法用其他方式表达的细节。这可以是简单的类型,例如int
或str
,类似于静态类型语言,如下面的示例存根所示:
def prepend_rows(rows:list, prefix:str) -> list:
return [prefix + row for row in rows]
这个例子和传统静态类型语言的最大区别不是语法问题;在 Python 中,注释可以是任何表达式,而不仅仅是类型或类。你可以用描述性的字符串、计算值,甚至内联函数来注释你的参数——详见本章关于 lambdas 的部分。如果用字符串作为附加文档进行注释,前面的示例可能是这样的:
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 类型,以便装饰器的其余部分正常工作:
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()
和一般类型和等级的更多信息可以在第四章中找到。
现在我们可以确定所有的注释都是有效的,是时候开始验证一些参数了。给定有多少种类型的论点,让我们一次一个。关键字参数是最容易开始的,因为它们已经将它们的名称和值捆绑在一起,所以少了一件需要担心的事情。有了名称,我们就可以获得相关的注释,并根据它来验证值。这也是开始分解一些东西的好时机,因为我们最终将不得不一遍又一遍地使用一些相同的东西。下面是包装器开始时的样子:
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()
将它们链接到同一个循环中:
第一部分:在此基础上添加第二部分,以脚本的形式查看它的运行情况:
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 是如何触发的,首先考虑在处理变量参数时会出现什么情况。因为我们只能将单个注释应用于变量参数名称本身,所以必须假设该注释应用于该变量参数下的所有参数,无论是按位置传递还是按关键字传递。如果没有对这种行为的明确支持,变量参数应该被忽略,但是下面是代码的实际情况:
第二部分:把这个放在你刚刚输入的脚本的末尾:
@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()
函数,以使代码在打印时更容易理解:
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
字典的副本中排除任何匹配项。然后,我们可以迭代剩余的内容,以考虑所有的变量关键字参数:
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)
这包括所有显式参数以及通过位置和关键字传入的变量参数。剩下的唯一事情就是验证目标函数返回的值。到目前为止,包装器只是直接调用原始函数,而不考虑它返回什么,但是到目前为止,应该很容易看出需要做什么:
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()
函数已经验证了注释是类型一样,函数的这一部分也能够验证所有提供的参数的默认值。因为变量参数不能有默认值,这比处理函数调用本身要简单得多:
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
中的函数将用于现有代码中的每种注释类型:
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()
装饰器,它的函数就像上一节中的那个一样:
@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
,就像我们的验证函数一样。
鲁棒性原则
这是稳健性原则更明显的应用之一。你的函数需要一个特定类型的参数,但是接受一些变量会更好,因为你知道在你的函数需要处理它们之前,它们可以被转换成正确的类型。同样,强制还有助于确保返回值始终是外部代码知道如何处理的一致类型。
前一节中介绍的装饰器为向新的装饰器添加这种行为提供了一个很好的起点,我们可以使用它来根据随它一起提供的注释修改传入的值。因为我们依靠类型构造函数来进行所有必要的类型检查并适当地引发异常,所以这个新的装饰器可以简单得多。事实上,它可以用一条实际指令来表达:
@annotation_decorator
def coerce_arguments(value, annotation):
return annotation(value)
这非常简单,甚至根本不需要注释是一种类型。任何返回对象的函数或类都可以正常工作,返回值将被传递给由coerce_arguments()
修饰的函数。还是会?如果您回头看看目前的annotation_decorator()
函数,会发现有一个小问题,它无法按照新装饰者需要的方式工作。
问题是在调用传入外部装饰器的process()
函数的行中,返回值被丢弃了。如果您尝试将coerce_arguments()
与现有的装饰器一起使用,您将得到的只是代码的异常引发方面,而不是值强制方面。所以,为了正常工作,我们需要回过头来给annotation_processor()
添加这个特性。
然而,总的来说,还有一些事情需要做。因为注释处理器将修改最终发送到修饰函数的参数,所以我们需要为位置参数建立一个新的列表,为关键字参数建立一个新的字典。然后我们必须拆分显式参数处理,这样我们就可以区分位置参数和关键字参数。否则,该函数将无法正确应用可变位置参数:
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()
没有返回值。如果类型检查令人满意,修复这个问题很简单,只需返回原始值,保持不变:
@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 可以堆叠在一个函数上,所以它已经有了管理多个框架的内置方式。
示例:将类型安全作为装饰器
为了说明基于装饰器的函数注释方法,让我们考虑前面的类型安全例子。它已经依赖于一个装饰器,所以我们可以扩展它来接受参数,使用之前注释提供的相同类型。本质上,它看起来像这样:
>>> @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.
它的工作方式几乎与真正的带注释版本完全一样,只是注释是直接提供给装饰者的。为了接受参数,我们将稍微修改一下代码的第一部分,这样我们就可以从参数中获取注释,而不是检查函数本身。
因为注释是通过修饰器的参数传入的,所以我们有一个新的外部包装器来接收它们。当下一层接收到要修饰的函数时,它可以将注释与函数的签名进行匹配,为任何按位置传递的注释提供名称。一旦所有可用的注释都被赋予了正确的名称,它们就可以被内部装饰器的其余部分使用,而无需任何进一步的修改:
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()
来提供参数装饰器,而不必费心检查返回值。下面是它的样子:
@typesafe(int, int)
def add(a, b):
return a + b
add.returns(int)
它仍然非常清晰,甚至可能比不管怎样都不起作用的语法更加明确。额外的好处是,支持它的代码非常简单,只需要在内部decorator()
函数的末尾添加几行代码:
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。否则,我们最终会比要求的多产生两个值:
第一部分:添加第二部分以查看实际操作:
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 的手迹,你可以确切地看到它是如何操作的。
第二部分:添加到前面代码的末尾并运行:
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
不幸的是,发电机的主要好处有时也是一种负担。因为在任何给定的时间内存中没有完整的序列,生成器总是必须从它们停止的地方重新开始。然而,大多数情况下,当你第一次迭代时,你会完全耗尽生成器,所以当你试图把它放入另一个循环时,你根本得不到任何回报。
将此添加到第二部分的末尾,然后运行:
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
对象,您可以按价格排序:
>>> def get_price(house):
... return house.price
...
>>> houses.sort(key=get_price)
不幸的是,这似乎有点浪费函数的能力,而且它需要几行额外的代码和一个在sort()
方法调用之外从来不会用到的名字。一个更好的方法是,如果您可以在方法调用中直接指定key
函数。这不仅使它更简洁,还将函数体放在了它将被使用的地方,因此对于这些类型的简单行为来说可读性更好。
在这些情况下,Python 的 lambda 形式非常有价值。Python 提供了一个单独的语法,由关键字lambda
标识。这允许您将一个没有名称的函数定义为一个表达式,具有更简单的特性集。在深入研究语法细节之前,先看一下房屋排序示例中的语法。把它想象成一行小函数。尝试以下方法:
>>> g=lambda x: x*x
>>> g(8) # which returns 8 * 8
如你所见,这是一个相当压缩的函数定义。关键字lambda
后面是一个参数列表,用逗号分隔。在排序示例中,只需要一个参数,可以随意命名,比如其他任何函数。如果需要,它们甚至可以使用与常规函数相同的语法来设置默认值。参数后面跟一个冒号,表示 lambda 主体的开始。如果不涉及参数,冒号可以直接放在关键字lambda
之后:
>>> 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>'
填充:
>>> def example():
... pass
...
>>> example.__name__
'example'
>>> (lambda: None).__name__
'<lambda>'
识别对象类型
Python 的动态特性有时会让人觉得很难确保获得正确类型的值,甚至很难知道它是什么类型的值。Python 确实提供了一些访问这些信息的选项,但是有必要认识到这是两个独立的任务,所以 Python 使用了两种不同的方法。
最显而易见的需求是识别您的代码被赋予了什么类型的对象。为此,Python 提供了其内置的type()
函数,该函数接受一个要识别的对象。返回值是用于创建给定对象的 Python 类,即使该创建是通过文字值隐式完成的:
>>> type('example')
<type 'str'>
>>> class Test:
... pass
...
>>> type(Test)
<type 'classobj'>
>>> type(Test())
<type 'instance'>
第四章详细解释了一旦你有了这个类对象,你可以做什么,但是更常见的情况是将一个对象与你期望得到的特定类型进行比较。这是一种不同的情况,因为对象的确切类型并不重要。只要值是正确类型的实例,您就可以对它的行为做出正确的假设。
有许多不同的实用函数可用于此目的,其中大部分在第四章中有所介绍。本节和下一章将会相当频繁地使用其中的一个,所以它值得在这里做一些解释。isinstance() function
接受两个参数:要检查的对象和您期望的类型。结果是一个简单的True
或False
,使其适用于 if 模块:
>>> 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__'
:
>>> def example():
... pass
...
>>> example
<function example at 0x...>
>>> example.__module__
'__main__'
文档字符串
因为可以在代码旁边包含文档字符串来记录函数,所以 Python 也将这些字符串存储为 function 对象的一部分。通过访问函数的__doc__
属性,您可以将文档字符串读入代码,这对于动态生成库的文档非常有用。考虑下面的例子,显示了对一个简单函数的简单 docstring 访问:
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:
>>> 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()
格式化:
>>> 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()
是如何处理它的:
>>> 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 安装熊猫。
-
在升级后的命令提示符下,键入: pip install pandas (enter)
这还将安装 NumPy 和 datautils,这是必需的。假设你没有错误,做一个文件,试着读一读,以确保它的工作。
-
类型:pip install matplotlib (enter)
制作数据的文本文件
首先,我们将创建一个包含一些假设数据的 CSV(逗号分隔值)文本文件。这可能是来自互联网或数据库等的数据。您可能有一个想要处理的数据的电子表格(例如 Excel 或 OpenOffice)。这些软件包可以很容易地“另存为”CSV 格式。现在,使用您最喜欢的文本编辑器。
- 启动记事本(Windows)并输入以下内容,以文本文件的形式保存到保存 Python 文件的同一文件夹中以供阅读。确保文本文件和 Python 文件在同一个文件夹中!
- 将文件另存为“students.csv ”,并确保文件名没有附加 txt 扩展名;完整的文件名只能是“students.csv”。
用熊猫来显示数据
现在,让我们测试一下,看看我们能否读取 CSV 数据并将其显示到屏幕上。一旦成功,我们就可以对数据进行一些处理。创建一个 Python 脚本并运行以下命令,为 Python 文件指定一个您自己选择的有效名称:
import pandas
data = pandas.read_csv('students.csv', sep=',', na_values=".")
print (data)
您的输出应该类似于以下内容:
使用 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())
各专业平均学生年龄输出。
函数将只显示给定数据列的唯一值。例如,使用我们的 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 文件或类似的文件中读取,这些文件可能是从互联网网站上获得的。我们将把数据可视化和熊猫结合起来。在下面的示例中,我们添加了几个函数,如 tick 和 title ,并从 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 的类是如何工作的。