Python 入门指南(六)(2)https://developer.aliyun.com/article/1507435
函数也是对象
过分强调面向对象原则的编程语言往往不赞成不是方法的函数。在这样的语言中,你应该创建一个对象来包装涉及的单个方法。有许多情况下,我们希望传递一个简单的对象,只需调用它执行一个动作。这在事件驱动编程中最常见,比如图形工具包或异步服务器;我们将在第二十二章 Python 设计模式 I 和第二十三章 Python 设计模式 II 中看到一些使用它的设计模式。
在 Python 中,我们不需要将这样的方法包装在对象中,因为函数本身就是对象!我们可以在函数上设置属性(尽管这不是常见的活动),并且我们可以传递它们以便在以后的某个日期调用它们。它们甚至有一些可以直接访问的特殊属性。这里是另一个刻意的例子:
def my_function(): print("The Function Was Called") my_function.description = "A silly function" def second_function(): print("The second was called") second_function.description = "A sillier function." def another_function(function): print("The description:", end=" ") print(function.description) print("The name:", end=" ") print(function.__name__) print("The class:", end=" ") print(function.__class__) print("Now I'll call the function passed in") function() another_function(my_function) another_function(second_function)
如果我们运行这段代码,我们可以看到我们能够将两个不同的函数传递给我们的第三个函数,并为每个函数获得不同的输出:
The description: A silly function The name: my_function The class: <class 'function'> Now I'll call the function passed in The Function Was Called The description: A sillier function. The name: second_function The class: <class 'function'> Now I'll call the function passed in The second was called
我们在函数上设置了一个属性,名为 description
(诚然不是很好的描述)。我们还能看到函数的 __name__
属性,并访问它的类,证明函数确实是一个带有属性的对象。然后,我们使用可调用语法(括号)调用了函数。
函数是顶级对象的事实最常用于传递它们以便在以后的某个日期执行,例如,当某个条件已满足时。让我们构建一个事件驱动的定时器,就是这样做的:
import datetime import time class TimedEvent: def __init__(self, endtime, callback): self.endtime = endtime self.callback = callback def ready(self): return self.endtime <= datetime.datetime.now() class Timer: def __init__(self): self.events = [] def call_after(self, delay, callback): end_time = datetime.datetime.now() + datetime.timedelta( seconds=delay ) self.events.append(TimedEvent(end_time, callback)) def run(self): while True: ready_events = (e for e in self.events if e.ready()) for event in ready_events: event.callback(self) self.events.remove(event) time.sleep(0.5)
在生产中,这段代码肯定应该使用文档字符串进行额外的文档化!call_after
方法至少应该提到 delay
参数是以秒为单位的,并且 callback
函数应该接受一个参数:调用者定时器。
我们这里有两个类。TimedEvent
类实际上并不是其他类可以访问的;它只是存储 endtime
和 callback
。我们甚至可以在这里使用 tuple
或 namedtuple
,但是为了方便给对象一个行为,告诉我们事件是否准备好运行,我们使用了一个类。
Timer
类简单地存储了一个即将到来的事件列表。它有一个 call_after
方法来添加一个新事件。这个方法接受一个 delay
参数,表示在执行回调之前等待的秒数,以及 callback
函数本身:在正确的时间执行的函数。这个 callback
函数应该接受一个参数。
run
方法非常简单;它使用生成器表达式来过滤出任何时间到达的事件,并按顺序执行它们。定时器 循环然后无限继续,因此必须使用键盘中断(Ctrl + C,或 Ctrl + Break)来中断。我们在每次迭代后睡眠半秒,以免使系统停滞。
这里需要注意的重要事情是涉及回调函数的行。函数像任何其他对象一样被传递,定时器从不知道或关心函数的原始名称是什么,或者它是在哪里定义的。当该函数被调用时,定时器只是将括号语法应用于存储的变量。
这是一组测试定时器的回调:
def format_time(message, *args): now = datetime.datetime.now() print(f"{now:%I:%M:%S}: {message}") def one(timer): format_time("Called One") def two(timer): format_time("Called Two") def three(timer): format_time("Called Three") class Repeater: def __init__(self): self.count = 0 def repeater(self, timer): format_time(f"repeat {self.count}") self.count += 1 timer.call_after(5, self.repeater) timer = Timer() timer.call_after(1, one) timer.call_after(2, one) timer.call_after(2, two) timer.call_after(4, two) timer.call_after(3, three) timer.call_after(6, three) repeater = Repeater() timer.call_after(5, repeater.repeater) format_time("Starting") timer.run()
这个例子让我们看到多个回调是如何与定时器交互的。第一个函数是 format_time
函数。它使用格式字符串语法将当前时间添加到消息中;我们将在下一章中了解它们。接下来,我们创建了三个简单的回调方法,它们只是输出当前时间和一个简短的消息,告诉我们哪个回调已经被触发。
Repeater
类演示了方法也可以用作回调,因为它们实际上只是绑定到对象的函数。它还展示了回调函数中的timer
参数为什么有用:我们可以在当前运行的回调内部向计时器添加新的定时事件。然后,我们创建一个计时器,并向其添加几个在不同时间后调用的事件。最后,我们启动计时器;输出显示事件按预期顺序运行:
02:53:35: Starting 02:53:36: Called One 02:53:37: Called One 02:53:37: Called Two 02:53:38: Called Three 02:53:39: Called Two 02:53:40: repeat 0 02:53:41: Called Three 02:53:45: repeat 1 02:53:50: repeat 2 02:53:55: repeat 3 02:54:00: repeat 4
Python 3.4 引入了类似于这种通用事件循环架构。
使用函数作为属性
函数作为对象的一个有趣效果是它们可以被设置为其他对象的可调用属性。可以向已实例化的对象添加或更改函数,如下所示:
class A: def print(self): print("my class is A") def fake_print(): print("my class is not A") a = A() a.print() a.print = fake_print a.print()
这段代码创建了一个非常简单的类,其中包含一个不告诉我们任何新信息的print
方法。然后,我们创建了一个告诉我们一些我们不相信的新函数。
当我们在A
类的实例上调用print
时,它的行为符合预期。如果我们将print
方法指向一个新函数,它会告诉我们一些不同的东西:
my class is A my class is not A
还可以替换类的方法而不是对象的方法,尽管在这种情况下,我们必须将self
参数添加到参数列表中。这将更改该对象的所有实例的方法,即使已经实例化了。显然,这样替换方法可能既危险又令人困惑。阅读代码的人会看到已调用一个方法,并查找原始类上的该方法。但原始类上的方法并不是被调用的方法。弄清楚到底发生了什么可能会变成一个棘手而令人沮丧的调试过程。
尽管如此,它确实有其用途。通常,在运行时替换或添加方法(称为monkey patching)在自动化测试中使用。如果测试客户端-服务器应用程序,我们可能不希望在测试客户端时实际连接到服务器;这可能导致意外转账或向真实人发送尴尬的测试电子邮件。相反,我们可以设置我们的测试代码,以替换发送请求到服务器的对象上的一些关键方法,以便它只记录已调用这些方法。
Monkey-patching 也可以用于修复我们正在交互的第三方代码中的错误或添加功能,并且不会以我们需要的方式运行。但是,应该谨慎使用;它几乎总是一个混乱的黑客。不过,有时它是适应现有库以满足我们需求的唯一方法。
可调用对象
正如函数是可以在其上设置属性的对象一样,也可以创建一个可以像函数一样被调用的对象。
通过简单地给它一个接受所需参数的__call__
方法,任何对象都可以被调用。让我们通过以下方式使我们的计时器示例中的Repeater
类更易于使用:
class Repeater: def __init__(self): self.count = 0 def __call__(self, timer): format_time(f"repeat {self.count}") self.count += 1 timer.call_after(5, self) timer = Timer() timer.call_after(5, Repeater()) format_time("{now}: Starting") timer.run()
这个例子与之前的类并没有太大不同;我们只是将repeater
函数的名称更改为__call__
,并将对象本身作为可调用对象传递。请注意,当我们进行call_after
调用时,我们传递了参数Repeater()
。这两个括号创建了一个类的新实例;它们并没有显式调用该类。这发生在稍后,在计时器内部。如果我们想要在新实例化的对象上执行__call__
方法,我们将使用一个相当奇怪的语法:Repeater()()
。第一组括号构造对象;第二组执行__call__
方法。如果我们发现自己这样做,可能没有使用正确的抽象。只有在对象需要被视为函数时才实现__call__
函数。
案例研究
为了将本章介绍的一些原则联系起来,让我们构建一个邮件列表管理器。该管理器将跟踪分类为命名组的电子邮件地址。当发送消息时,我们可以选择一个组,并将消息发送到分配给该组的所有电子邮件地址。
在我们开始这个项目之前,我们应该有一个安全的方法来测试它,而不是向一群真实的人发送电子邮件。幸运的是,Python 在这方面有所帮助;就像测试 HTTP 服务器一样,它有一个内置的简单邮件传输协议(SMTP)服务器,我们可以指示它捕获我们发送的任何消息,而不实际发送它们。我们可以使用以下命令运行服务器:
$python -m smtpd -n -c DebuggingServer localhost:1025
在命令提示符下运行此命令将在本地机器上的端口 1025 上启动运行 SMTP 服务器。但我们已经指示它使用DebuggingServer
类(这个类是内置 SMTP 模块的一部分),它不是将邮件发送给预期的收件人,而是在接收到邮件时简单地在终端屏幕上打印它们。
现在,在编写我们的邮件列表之前,让我们编写一些实际发送邮件的代码。当然,Python 也支持这一点在标准库中,但它的接口有点奇怪,所以我们将编写一个新的函数来清晰地包装它,如下面的代码片段所示:
import smtplib from email.mime.text import MIMEText def send_email( subject, message, from_addr, *to_addrs, host="localhost", port=1025, **headers ): email = MIMEText(message) email["Subject"] = subject email["From"] = from_addr for header, value in headers.items(): email[header] = value sender = smtplib.SMTP(host, port) for addr in to_addrs: del email["To"] email["To"] = addr sender.sendmail(from_addr, addr, email.as_string()) sender.quit()
我们不会过分深入讨论此方法内部的代码;标准库中的文档可以为您提供使用smtplib
和email
模块所需的所有信息。
在函数调用中使用了变量参数和关键字参数语法。变量参数列表允许我们在默认情况下提供单个to
地址的字符串,并允许在需要时提供多个地址。任何额外的关键字参数都映射到电子邮件标头。这是变量参数和关键字参数的一个令人兴奋的用法,但实际上并不是对调用函数的人来说一个很好的接口。事实上,它使程序员想要做的许多事情都变得不可能。
传递给函数的标头表示可以附加到方法的辅助标头。这些标头可能包括Reply-To
、Return-Path
或X-pretty-much-anything。但是为了在 Python 中成为有效的标识符,名称不能包括-
字符。一般来说,该字符表示减法。因此,不可能使用Reply-To``=``my@email.com
调用函数。通常情况下,我们太急于使用关键字参数,因为它们是我们刚学会的一个闪亮的新工具。
我们将不得不将参数更改为普通字典;这将起作用,因为任何字符串都可以用作字典中的键。默认情况下,我们希望这个字典是空的,但我们不能使默认参数为空字典。因此,我们将默认参数设置为None
,然后在方法的开头设置字典,如下所示:
def send_email(subject, message, from_addr, *to_addrs, host="localhost", port=1025, headers=None): headers = headers if headers else {}
如果我们在一个终端中运行我们的调试 SMTP 服务器,我们可以在 Python 解释器中测试这段代码:
>>> send_email("A model subject", "The message contents", "from@example.com", "to1@example.com", "to2@example.com")
然后,如果我们检查调试 SMTP 服务器的输出,我们会得到以下结果:
---------- MESSAGE FOLLOWS ---------- Content-Type: text/plain; charset="us-ascii" MIME-Version: 1.0 Content-Transfer-Encoding: 7bit Subject: A model subject From: from@example.com To: to1@example.com X-Peer: 127.0.0.1 The message contents ------------ END MESSAGE ------------ ---------- MESSAGE FOLLOWS ---------- Content-Type: text/plain; charset="us-ascii" MIME-Version: 1.0 Content-Transfer-Encoding: 7bit Subject: A model subject From: from@example.com To: to2@example.com X-Peer: 127.0.0.1 The message contents ------------ END MESSAGE ------------
很好,它已经发送了我们的电子邮件到两个预期地址,并包括主题和消息内容。现在我们可以发送消息了,让我们来完善电子邮件组管理系统。我们需要一个对象,以某种方式将电子邮件地址与它们所在的组匹配起来。由于这是多对多的关系(任何一个电子邮件地址可以在多个组中;任何一个组可以与多个电子邮件地址相关联),我们学习过的数据结构似乎都不太理想。我们可以尝试一个将组名与相关电子邮件地址列表匹配的字典,但这样会重复电子邮件地址。我们也可以尝试一个将电子邮件地址与组匹配的字典,这样会重复组。两者都不太理想。出于好玩,让我们尝试后一种版本,尽管直觉告诉我,将组与电子邮件地址的解决方案可能更加直接。
由于字典中的值始终是唯一电子邮件地址的集合,我们可以将它们存储在一个 set
容器中。我们可以使用 defaultdict
来确保每个键始终有一个 set
容器可用,如下所示:
from collections import defaultdict class MailingList: """Manage groups of e-mail addresses for sending e-mails.""" def __init__(self): self.email_map = defaultdict(set) def add_to_group(self, email, group): self.email_map[email].add(group)
现在,让我们添加一个方法,允许我们收集一个或多个组中的所有电子邮件地址。这可以通过将组列表转换为集合来完成:
def emails_in_groups(self, *groups): groups = set(groups) emails = set() for e, g in self.email_map.items(): if g & groups: emails.add(e) return emails
首先,看一下我们正在迭代的内容:self.email_map.items()
。当然,这个方法返回字典中每个项目的键值对元组。值是表示组的字符串集合。我们将这些拆分成两个变量,命名为 e
和 g
,分别代表电子邮件和组。只有当传入的组与电子邮件地址的组相交时,我们才将电子邮件地址添加到返回值的集合中。g``&``groups
语法是 g.intersection(groups)
的快捷方式;set
类通过实现特殊的 __and__
方法来调用 intersection
。
使用集合推导式可以使这段代码更加简洁,我们将在第二十一章 迭代器模式 中讨论。
现在,有了这些基本组件,我们可以轻松地向我们的 MailingList
类添加一个发送消息到特定组的方法:
def send_mailing( self, subject, message, from_addr, *groups, headers=None ): emails = self.emails_in_groups(*groups) send_email( subject, message, from_addr, *emails, headers=headers )
这个函数依赖于可变参数列表。作为输入,它接受可变参数作为组的列表。它获取指定组的电子邮件列表,并将它们作为可变参数传递到 send_email
中,以及传递到这个方法中的其他参数。
可以通过确保 SMTP 调试服务器在一个命令提示符中运行,并在第二个提示符中使用以下命令加载代码来测试程序:
$python -i mailing_list.py
使用以下命令创建一个 MailingList
对象:
>>> m = MailingList()
然后,创建一些虚假的电子邮件地址和组,如下所示:
>>> m.add_to_group("friend1@example.com", "friends") >>> m.add_to_group("friend2@example.com", "friends") >>> m.add_to_group("family1@example.com", "family") >>> m.add_to_group("pro1@example.com", "professional")
最后,使用以下命令发送电子邮件到特定组:
>>> m.send_mailing("A Party", "Friends and family only: a party", "me@example.com", "friends", "family", headers={"Reply-To": "me2@example.com"})
指定组中的每个地址的电子邮件应该显示在 SMTP 服务器的控制台上。
邮件列表目前运行良好,但有点无用;一旦我们退出程序,我们的信息数据库就会丢失。让我们修改它,添加一些方法来从文件中加载和保存电子邮件组的列表。
一般来说,当将结构化数据存储在磁盘上时,最好仔细考虑它的存储方式。存在众多数据库系统的原因之一是,如果其他人已经考虑过数据的存储方式,那么你就不必再去考虑。我们将在下一章中研究一些数据序列化机制,但在这个例子中,让我们保持简单,选择可能有效的第一个解决方案。
我心目中的数据格式是存储每个电子邮件地址,后跟一个空格,再跟着一个逗号分隔的组列表。这个格式看起来是合理的,我们将采用它,因为数据格式化不是本章的主题。然而,为了说明为什么你需要认真考虑如何在磁盘上格式化数据,让我们强调一下这种格式的一些问题。
首先,空格字符在技术上是电子邮件地址中合法的。大多数电子邮件提供商禁止它(有充分的理由),但定义电子邮件地址的规范说,如果在引号中,电子邮件可以包含空格。如果我们要在我们的数据格式中使用一个空格作为标记,我们应该在技术上能够区分该空格和电子邮件中的空格。为了简单起见,我们将假装这不是真的,但是现实生活中的数据编码充满了这样的愚蠢问题。
其次,考虑逗号分隔的组列表。如果有人决定在组名中放一个逗号会发生什么?如果我们决定在组名中将逗号设为非法字符,我们应该添加验证来强制在我们的add_to_group
方法中执行这样的命名。为了教学上的清晰,我们也将忽略这个问题。最后,我们需要考虑许多安全性问题:有人是否可以通过在他们的电子邮件地址中放一个假逗号来将自己放入错误的组?如果解析器遇到无效文件会怎么做?
从这次讨论中得出的要点是,尽量使用经过现场测试的数据存储方法,而不是设计我们自己的数据序列化协议。你可能会忽视很多奇怪的边缘情况,最好使用已经遇到并解决了这些边缘情况的代码。
但是忘了这些。让我们只写一些基本的代码,使用大量的一厢情愿来假装这种简单的数据格式是安全的,如下所示:
email1@mydomain.com group1,group2 email2@mydomain.com group2,group3
执行此操作的代码如下:
def save(self): with open(self.data_file, "w") as file: for email, groups in self.email_map.items(): file.write("{} {}\n".format(email, ",".join(groups))) def load(self): self.email_map = defaultdict(set) with suppress(IOError): with open(self.data_file) as file: for line in file: email, groups = line.strip().split(" ") groups = set(groups.split(",")) self.email_map[email] = groups
在save
方法中,我们在上下文管理器中打开文件并将文件写为格式化字符串。记住换行符;Python 不会为我们添加它。load
方法首先重置字典(以防它包含来自先前调用load
的数据)。它添加了对标准库suppress
上下文管理器的调用,可用作from contextlib import suppress
。这个上下文管理器捕获任何 I/O 错误并忽略它们。这不是最好的错误处理,但比 try…finally…pass 更美观。
然后,load 方法使用for
…in
语法,循环遍历文件中的每一行。同样,换行符包含在行变量中,所以我们必须调用.strip()
来去掉它。我们将在下一章中学习更多关于这种字符串操作的知识。
在使用这些方法之前,我们需要确保对象有一个self.data_file
属性,可以通过修改__init__
来实现:
def __init__(self, data_file): self.data_file = data_file self.email_map = defaultdict(set)
我们可以在解释器中测试这两种方法:
>>> m = MailingList('addresses.db') >>> m.add_to_group('friend1@example.com', 'friends') >>> m.add_to_group('family1@example.com', 'friends') >>> m.add_to_group('family1@example.com', 'family') >>> m.save()
生成的addresses.db
文件包含如下行,如预期的那样:
friend1@example.com friends family1@example.com friends,family
我们也可以成功地将这些数据加载回MailingList
对象中:
>>> m = MailingList('addresses.db') >>> m.email_map defaultdict(<class 'set'>, {}) >>> m.load() >>> m.email_map defaultdict(<class 'set'>, {'friend2@example.com': {'friends\n'}, 'family1@example.com': {'family\n'}, 'friend1@example.com': {'friends\n'}})
正如你所看到的,我忘记了添加load
命令,也可能很容易忘记save
命令。为了让任何想要在自己的代码中使用我们的MailingList
API 的人更容易一些,让我们提供支持上下文管理器的方法:
def __enter__(self): self.load() return self def __exit__(self, type, value, tb): self.save()
这些简单的方法只是将它们的工作委托给加载和保存,但是现在我们可以在交互式解释器中编写这样的代码,并知道以前存储的所有地址都已经被加载,当我们完成时整个列表将被保存到文件中:
>>> with MailingList('addresses.db') as ml: ... ml.add_to_group('friend2@example.com', 'friends') ... ml.send_mailing("What's up", "hey friends, how's it going", 'me@example.com', 'friends')
练习
如果你之前没有遇到with
语句和上下文管理器,我鼓励你像往常一样,浏览你的旧代码,找到所有打开文件的地方,并确保它们使用with
语句安全关闭。还要寻找编写自己的上下文管理器的地方。丑陋或重复的try
…finally
子句是一个很好的起点,但你可能会发现在任何需要在上下文中执行之前和/或之后任务的地方都很有用。
你可能之前已经使用过许多基本的内置函数。我们涵盖了其中几个,但没有详细讨论。尝试使用enumerate
、zip
、reversed
、any
和all
,直到你记住在合适的时候使用它们为止。enumerate
函数尤其重要,因为不使用它会导致一些非常丑陋的while
循环。
还要探索一些将函数作为可调用对象传递的应用,以及使用__call__
方法使自己的对象可调用。您可以通过将属性附加到函数或在对象上创建__call__
方法来实现相同的效果。在哪种情况下会使用一种语法,什么时候更适合使用另一种语法呢?
如果有大量邮件需要发送,我们的邮件列表对象可能会压倒邮件服务器。尝试重构它,以便你可以为不同的目的使用不同的send_email
函数。其中一个函数可能是我们在这里使用的版本。另一个版本可能会将邮件放入队列,由不同的线程或进程发送。第三个版本可能只是将数据输出到终端,从而避免了需要虚拟的 SMTP 服务器。你能构建一个带有回调的邮件列表,以便send_mailing
函数使用传入的任何内容吗?如果没有提供回调,它将默认使用当前版本。
参数、关键字参数、可变参数和可变关键字参数之间的关系可能有点令人困惑。当我们涵盖多重继承时,我们看到它们如何痛苦地相互作用。设计一些其他示例,看看它们如何很好地协同工作,以及了解它们何时不起作用。
总结
在本章中,我们涵盖了一系列主题。每个主题都代表了 Python 中流行的重要非面向对象的特性。仅仅因为我们可以使用面向对象的原则,并不总是意味着我们应该这样做!
然而,我们也看到 Python 通常通过提供语法快捷方式来实现这些功能,以传统的面向对象语法。了解这些工具背后的面向对象原则使我们能够更有效地在自己的类中使用它们。
我们讨论了一系列内置函数和文件 I/O 操作。在调用带参数、关键字参数和可变参数列表的函数时,我们有许多不同的语法可用。上下文管理器对于在两个方法调用之间夹入一段代码的常见模式非常有用。甚至函数本身也是对象,反之亦然,任何普通对象都可以被调用。
在下一章中,我们将学习更多关于字符串和文件操作的知识,甚至花一些时间来了解标准库中最不面向对象的主题之一:正则表达式。
第二十一章:迭代器模式
我们已经讨论了 Python 的许多内置功能和习语,乍一看似乎违反了面向对象的原则,但实际上在幕后提供了对真实对象的访问。在本章中,我们将讨论for
循环,它似乎如此结构化,实际上是一组面向对象原则的轻量级包装。我们还将看到一系列扩展到这种语法,自动创建更多类型的对象。我们将涵盖以下主题:
- 设计模式是什么
- 迭代器协议-最强大的设计模式之一
- 列表、集合和字典推导
- 生成器和协程
简要介绍设计模式
当工程师和建筑师决定建造一座桥、一座塔或一座建筑时,他们遵循某些原则以确保结构完整性。桥梁有各种可能的设计(例如悬索和悬臂),但如果工程师不使用标准设计之一,并且没有一个杰出的新设计,那么他/她设计的桥梁可能会坍塌。
设计模式是试图将同样的正确设计结构的正式定义引入到软件工程中。有许多不同的设计模式来解决不同的一般问题。设计模式通常解决开发人员在某些特定情况下面临的特定常见问题。然后,设计模式是对该问题的理想解决方案的建议,从面向对象设计的角度来看。
了解设计模式并选择在软件中使用它并不保证我们正在创建一个正确的解决方案。1907 年,魁北克大桥(至今仍是世界上最长的悬臂桥)在建设完成之前坍塌,因为设计它的工程师严重低估了用于建造它的钢材重量。同样,在软件开发中,我们可能会错误地选择或应用设计模式,并创建在正常操作情况下或在超出原始设计限制时崩溃的软件。
任何一个设计模式都提出了一组以特定方式相互作用的对象,以解决一般问题。程序员的工作是识别何时面临这样一个特定版本的问题,然后选择和调整通用设计以满足其精确需求。
在本章中,我们将介绍迭代器设计模式。这种模式如此强大和普遍,以至于 Python 开发人员提供了多种语法来访问该模式的基础面向对象原则。我们将在接下来的两章中介绍其他设计模式。其中一些具有语言支持,而另一些则没有,但没有一个像迭代器模式那样成为 Python 程序员日常生活中的固有部分。
迭代器
在典型的设计模式术语中,迭代器是一个具有next()
方法和done()
方法的对象;后者如果序列中没有剩余项目,则返回True
。在没有内置迭代器支持的编程语言中,迭代器将像这样循环:
while not iterator.done(): item = iterator.next() # do something with the item
在 Python 中,迭代是一种特殊的特性,因此该方法得到了一个特殊的名称__next__
。可以使用内置的next(iterator)
来访问此方法。Python 的迭代器协议不是使用done
方法,而是引发StopIteration
来通知循环已完成。最后,我们有更易读的foriteminiterator
语法来实际访问迭代器中的项目,而不是使用while
循环。让我们更详细地看看这些。
迭代器协议
Iterator
抽象基类在collections.abc
模块中定义了 Python 中的迭代器协议。正如前面提到的,它必须有一个__next__
方法,for
循环(以及其他支持迭代的功能)可以调用它来从序列中获取一个新元素。此外,每个迭代器还必须满足Iterable
接口。任何提供__iter__
方法的类都是可迭代的。该方法必须返回一个Iterator
实例,该实例将覆盖该类中的所有元素。
这可能听起来有点混乱,所以看看以下示例,但请注意,这是解决这个问题的一种非常冗长的方式。它清楚地解释了迭代和所讨论的两个协议,但在本章的后面,我们将看到几种更易读的方法来实现这种效果:
class CapitalIterable: def __init__(self, string): self.string = string def __iter__(self): return CapitalIterator(self.string) class CapitalIterator: def __init__(self, string): self.words = [w.capitalize() for w in string.split()] self.index = 0 def __next__(self): if self.index == len(self.words): raise StopIteration() word = self.words[self.index] self.index += 1 return word def __iter__(self): return self
这个例子定义了一个CapitalIterable
类,其工作是循环遍历字符串中的每个单词,并输出它们的首字母大写。这个可迭代对象的大部分工作都交给了CapitalIterator
实现。与这个迭代器互动的规范方式如下:
>>> iterable = CapitalIterable('the quick brown fox jumps over the lazy dog') >>> iterator = iter(iterable) >>> while True: ... try: ... print(next(iterator)) ... except StopIteration: ... break ... The Quick Brown Fox Jumps Over The Lazy Dog
这个例子首先构造了一个可迭代对象,并从中检索了一个迭代器。这种区别可能需要解释;可迭代对象是一个可以循环遍历的对象。通常,这些元素可以被多次循环遍历,甚至可能在同一时间或重叠的代码中。另一方面,迭代器代表可迭代对象中的特定位置;一些项目已被消耗,一些尚未被消耗。两个不同的迭代器可能在单词列表中的不同位置,但任何一个迭代器只能标记一个位置。
每次在迭代器上调用next()
时,它都会按顺序从可迭代对象中返回另一个标记。最终,迭代器将被耗尽(不再有任何元素返回),在这种情况下会引发Stopiteration
,然后我们跳出循环。
当然,我们已经知道了一个更简单的语法,用于从可迭代对象构造迭代器:
>>> for i in iterable: ... print(i) ... The Quick Brown Fox Jumps Over The Lazy Dog
正如你所看到的,for
语句,尽管看起来并不像面向对象,实际上是一种显而易见的面向对象设计原则的快捷方式。在讨论理解时,请记住这一点,因为它们似乎是面向对象工具的完全相反。然而,它们使用与for
循环完全相同的迭代协议,只是另一种快捷方式。
理解
理解是一种简单但强大的语法,允许我们在一行代码中转换或过滤可迭代对象。结果对象可以是一个完全正常的列表、集合或字典,也可以是一个生成器表达式,可以在保持一次只有一个元素在内存中的情况下高效地消耗。
列表理解
列表理解是 Python 中最强大的工具之一,所以人们倾向于认为它们是高级的。事实并非如此。事实上,我已经在以前的例子中使用了理解,假设你会理解它们。虽然高级程序员确实经常使用理解,但并不是因为它们很高级。而是因为它们很简单,并处理了软件开发中最常见的一些操作。
让我们来看看其中一个常见操作;即,将一个项目列表转换为相关项目列表。具体来说,假设我们刚刚从文件中读取了一个字符串列表,现在我们想将其转换为整数列表。我们知道列表中的每个项目都是整数,并且我们想对这些数字进行一些操作(比如计算平均值)。以下是一种简单的方法:
input_strings = ["1", "5", "28", "131", "3"] output_integers = [] for num in input_strings: output_integers.append(int(num))
这个方法很好用,而且只有三行代码。如果你不习惯理解,你可能不会觉得它看起来很丑陋!现在,看看使用列表理解的相同代码:
input_strings = ["1", "5", "28", "131", "3"] output_integers = [int(num) for num in input_strings]
我们只剩下一行,而且,对于性能来说很重要的是,我们已经放弃了列表中每个项目的append
方法调用。总的来说,即使你不习惯推导式语法,也很容易理解发生了什么。
方括号表示,我们正在创建一个列表。在这个列表中是一个for
循环,它遍历输入序列中的每个项目。唯一可能令人困惑的是在列表的左大括号和for
循环开始之间发生了什么。这里发生的事情应用于输入列表中的每个项目。所讨论的项目由循环中的num
变量引用。因此,它对每个元素调用int
函数,并将结果整数存储在新列表中。
这就是基本列表推导式的全部内容。推导式是高度优化的 C 代码;当循环遍历大量项目时,列表推导式比for
循环要快得多。如果仅仅从可读性的角度来看,不能说服你尽可能多地使用它们,那么速度应该是一个令人信服的理由。
将一个项目列表转换为相关列表并不是列表推导式唯一能做的事情。我们还可以选择通过在推导式中添加if
语句来排除某些值。看一下:
output_integers = [int(num) for num in input_strings if len(num) < 3]
这个例子和前面的例子唯一不同的地方是if len(num) < 3
部分。这个额外的代码排除了任何超过两个字符的字符串。if
语句应用于在int
函数之前的每个元素,因此它测试字符串的长度。由于我们的输入字符串在本质上都是整数,它排除了任何超过 99 的数字。
列表推导式用于将输入值映射到输出值,并在途中应用过滤器以包括或排除满足特定条件的任何值。
任何可迭代对象都可以成为列表推导式的输入。换句话说,任何我们可以放入for
循环中的东西也可以放入推导式中。例如,文本文件是可迭代的;对文件的迭代器每次调用__next__
都会返回文件的一行。我们可以使用zip
函数将第一行是标题行的制表符分隔文件加载到字典中:
import sys filename = sys.argv[1] with open(filename) as file: header = file.readline().strip().split("\t") contacts = [ dict( zip(header, line.strip().split("\t"))) for line in file ] for contact in contacts: print("email: {email} -- {last}, {first}".format(**contact))
这一次,我添加了一些空白以使其更易读(列表推导式不一定要放在一行上)。这个例子从压缩的标题和分割行中创建了一个字典列表,对文件中的每一行进行了处理。
嗯,什么?如果那段代码或解释没有意义,不要担心;它很令人困惑。一个列表推导式在这里做了一堆工作,代码很难理解、阅读,最终也很难维护。这个例子表明,列表推导式并不总是最好的解决方案;大多数程序员都会同意,for
循环比这个版本更可读。
记住:我们提供的工具不应该被滥用!始终选择适合工作的正确工具,这总是编写可维护代码。
集合和字典推导式
理解并不局限于列表。我们也可以使用类似的语法来创建集合和字典。让我们从集合开始。创建集合的一种方法是将列表推导式放入set()
构造函数中,将其转换为集合。但是,为什么要浪费内存在一个被丢弃的中间列表上,当我们可以直接创建一个集合呢?
这是一个使用命名元组来模拟作者/标题/流派三元组的例子,然后检索写作特定流派的所有作者的集合:
from collections import namedtuple Book = namedtuple("Book", "author title genre") books = [ Book("Pratchett", "Nightwatch", "fantasy"), Book("Pratchett", "Thief Of Time", "fantasy"), Book("Le Guin", "The Dispossessed", "scifi"), Book("Le Guin", "A Wizard Of Earthsea", "fantasy"), Book("Turner", "The Thief", "fantasy"), Book("Phillips", "Preston Diamond", "western"), Book("Phillips", "Twice Upon A Time", "scifi"), ] fantasy_authors = {b.author for b in books if b.genre == "fantasy"}
与演示数据设置相比,突出显示的集合推导式确实很短!如果我们使用列表推导式,特里·普拉切特当然会被列出两次。事实上,集合的性质消除了重复项,我们最终得到了以下结果:
>>> fantasy_authors {'Turner', 'Pratchett', 'Le Guin'}
仍然使用大括号,我们可以引入冒号来创建字典理解。这将使用键:值对将序列转换为字典。例如,如果我们知道标题,可能会很快地在字典中查找作者或流派。我们可以使用字典理解将标题映射到books
对象:
fantasy_titles = {b.title: b for b in books if b.genre == "fantasy"}
现在,我们有了一个字典,并且可以使用正常的语法按标题查找书籍。
总之,理解不是高级的 Python,也不是应该避免使用的非面向对象工具。它们只是一种更简洁和优化的语法,用于从现有序列创建列表、集合或字典。
Python 入门指南(六)(4)https://developer.aliyun.com/article/1507444