Python 真实世界的数据科学(四)
十一、Python 设计模式 I
在上一章中,我们对设计模式进行了简要介绍,并介绍了迭代器模式,该模式非常有用,通用,已被抽象为编程语言本身的核心。 在本章中,我们将回顾其他常见模式,以及它们如何在 Python 中实现。 与迭代一样,Python 经常提供替代语法来简化此类问题的处理。 我们将介绍“传统”设计以及这些模式的 Python 版本。 总而言之,我们将看到:
- 多种特定模式
- Python 中每个模式的规范实现
- Python 语法来替换某些模式
装饰器模式
装饰器模式允许我们“包装”一个提供核心功能的对象以及其他更改此功能的对象。 任何使用修饰对象的对象都将以与未修饰对象完全相同的方式与其进行交互(也就是说,修饰对象的接口与核心对象的接口相同)。
装饰器模式有两种主要的用法:
- 增强组件将数据发送到第二个组件时的响应
- 支持多种可选行为
第二种选择通常是替代多继承。 我们可以构造一个核心对象,然后围绕该核心创建一个装饰器。 由于装饰器对象与核心对象具有相同的接口,因此我们甚至可以将新对象包装在其他装饰器中。 这是在 UML 中的外观:
在此,核心 和所有装饰器都实现特定的接口。 装饰器通过合成维护对接口的另一个实例的引用。 调用时,装饰器在调用其包装的接口之前或之后进行一些附加处理。 包装的对象可以是另一个装饰器,也可以是核心功能。 尽管多个装饰器可以互相包裹,但是所有这些装饰器“中心”的对象提供了核心功能。
装饰器示例
让我们来看一个来自网络编程的例子。 我们将使用 TCP 套接字。 socket.send()方法采用一串输入字节,并将它们输出到另一端的接收套接字。 有许多库可以接受套接字并访问此功能以在流上发送数据。 让我们创建一个这样的对象; 它是一个交互式外壳程序,它等待来自客户端的连接,然后提示用户输入字符串响应:
import socket
def respond(client):
response = input("Enter a value: ")
client.send(bytes(response, 'utf8'))
client.close()
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server.bind(('localhost',2401))
server.listen(1)
try:
while True:
client, addr = server.accept()
respond(client)
finally:
server.close()
respond函数接受套接字参数,并提示要发送的数据作为答复,然后发送。 为了使用它,我们构造了一个服务器套接字,并告诉它侦听本地计算机上的端口2401(我随机选择了该端口)。 当客户端连接时,它将调用respond函数,该函数以交互方式请求数据并做出适当的响应。 需要注意的重要一点是respond功能仅关心套接字接口的两种方法:send和close。 为了测试这一点,我们可以编写一个非常简单的客户端,该客户端连接到相同的端口并在退出之前输出响应:
import socket
client = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
client.connect(('localhost', 2401))
print("Received: {0}".format(client.recv(1024)))
client.close()
要使用这些程序:
- 在一个终端中启动服务器。
- 打开第二个终端窗口并运行客户端。
- 在输入值:在服务器窗口中的提示符下,键入一个值,然后按 Enter。
- 客户端将收到您键入的内容,将其打印到控制台,然后退出。 再次运行客户端; 服务器将提示您输入第二个值。
现在,再次查看我们的服务器代码,我们看到两个部分。 respond函数将数据发送到套接字对象。 剩下的脚本负责创建该套接字对象。 我们将创建一对装饰器,它们可以自定义套接字行为,而不必扩展或修改套接字本身。
让我们从一个“记录”装饰器开始。 该对象将任何发送到服务器控制台的数据输出,然后再发送给客户端:
class LogSocket:
def __init__(self, socket):
self.socket = socket
def send(self, data):
print("Sending {0} to {1}".format(
data, self.socket.getpeername()[0]))
self.socket.send(data)
def close(self):
self.socket.close()
该类装饰一个套接字对象,并向客户端套接字提供send和close接口。 一个更好的装饰器还将实现(并可能自定义)所有其余的套接字方法。 它也应该正确实现send的所有参数(实际上接受一个可选的 flags 参数),但让我们的示例保持简单! 每当在此对象上调用send时,它都会使用原始套接字将输出记录到屏幕,然后再将数据发送到客户端。
我们只需要在原始代码中更改一行即可使用此装饰器。 而不是使用套接字调用respond,而是使用修饰的套接字来调用它:
respond(LogSocket(client))
尽管这很简单,但是我们必须问自己为什么不仅仅扩展套接字类并覆盖send方法。 登录后,我们可以调用super().send进行实际发送。 这种设计也没有错。
面对装饰器和继承之间的选择时,仅当我们需要根据某些条件动态修改对象时,才应使用装饰器。 例如,如果服务器当前处于调试模式,我们可能只想启用日志记录装饰器。 当我们有多个可选行为时,装饰器还会击败多重继承。 例如,我们可以编写第二个装饰器,每当调用send时,该装饰器便会使用gzip压缩来压缩数据:
import gzip
from io import BytesIO
class GzipSocket:
def __init__(self, socket):
self.socket = socket
def send(self, data):
buf = BytesIO()
zipfile = gzip.GzipFile(fileobj=buf, mode="w")
zipfile.write(data)
zipfile.close()
self.socket.send(buf.getvalue())
def close(self):
self.socket.close()
此版本中的send方法在将传入数据发送到客户端之前先对其进行压缩。
现在我们有了这两个装饰器,我们可以编写在响应时在它们之间动态切换的代码。 这个示例并不完整,但是它说明了混合和匹配装饰器可能遵循的逻辑:
client, addr = server.accept()
if log_send:
client = LoggingSocket(client)
if client.getpeername()[0] in compress_hosts:
client = GzipSocket(client)
respond(client)
此代码检查名为log_send的假设配置变量。 如果启用,则将套接字包装在LoggingSocket装饰器中。 同样,它检查已连接的客户端是否在已知接受压缩内容的地址列表中。 如果是这样,它将客户端包装在GzipSocket装饰器中。 请注意,取决于配置和连接客户端,两个装饰器中的一个或两个都不能启用。 尝试使用多重继承编写此代码,看看您有多困惑!
Python 中的装饰器
装饰器模式在 Python 中很有用,但还有其他选择。 例如,我们也许可以使用在第 7 章和 Python 面向对象的快捷方式中讨论过的猴子补丁来获得类似的效果。 单一继承(可以使用一种大方法进行“可选”计算)可以作为一种选择,并且多重继承不应该被注销,因为它不适用于前面提到的特定示例!
在 Python 中,在函数上使用此模式非常普遍。 正如我们在上一章中所看到的,函数也是对象。 实际上,函数修饰是如此普遍,以至于 Python 提供了一种特殊的语法来简化将此类修饰符应用于函数的过程。
例如,我们可以以更一般的方式查看日志记录示例。 除了记录日志外,仅在套接字上发送调用,我们可能会发现将所有调用记录到某些函数或方法中很有帮助。 以下示例实现了一个装饰器,该装饰器可以做到这一点:
import time
def log_calls(func):
def wrapper(*args, **kwargs):
now = time.time()
print("Calling {0} with {1} and {2}".format(
func.__name__, args, kwargs))
return_value = func(*args, **kwargs)
print("Executed {0} in {1}ms".format(
func.__name__, time.time() - now))
return return_value
return wrapper
def test1(a,b,c):
print("\ttest1 called")
def test2(a,b):
print("\ttest2 called")
def test3(a,b):
print("\ttest3 called")
time.sleep(1)
test1 = log_calls(test1)
test2 = log_calls(test2)
test3 = log_calls(test3)
test1(1,2,3)
test2(4,b=5)
test3(6,7)
装饰器功能与我们之前探讨的示例非常相似; 在这种情况下,装饰器将创建一个类似于套接字的对象,并创建一个类似于套接字的对象。 这次,我们的装饰器获取一个函数对象并返回一个新的函数对象。 该代码包含三个单独的任务:
- 一个函数
log_calls,它接受另一个函数 - 此函数(内部)定义了一个名为
wrapper的新函数,该函数在调用原始函数之前会做一些额外的工作 - 返回此新功能
三个示例函数演示了正在使用的装饰器。 第三个包括一个睡眠呼叫以演示时序测试。 我们将每个函数传递给装饰器,该装饰器返回一个新函数。 我们将此新函数分配给原始变量名,从而有效地将原始函数替换为修饰后的函数。
这种语法使我们能够像套接字示例一样动态地构建装饰的函数对象。 如果不替换名称,我们甚至可以在不同情况下保留经过修饰和未经修饰的版本。
通常,这些装饰器是对各种功能永久应用的常规修改。 在这种情况下,Python 支持一种特殊的语法以在定义函数时应用装饰器。 当我们讨论property装饰器时,我们已经看到了这种语法。 现在,让我们了解它是如何工作的。
除了在方法定义之后应用装饰器函数之外,我们可以使用@decorator语法一次完成所有操作:
@log_calls
def test1(a,b,c):
print("\ttest1 called")
此语法的主要好处是,我们可以轻松地看到在定义函数时已经对它进行了修饰。 如果稍后应用装饰器,则读取代码的人可能会完全忘记该功能已被更改。 回答诸如“为什么我的程序日志记录功能调用控制台?”之类的问题? 会变得更加困难! 但是,该语法只能应用于我们定义的函数,因为我们无法访问其他模块的源代码。 如果我们需要装饰别人的第三方库中的功能,则必须使用较早的语法。
装饰器语法比我们在这里看到的更多。 我们这里没有足够的空间来介绍高级主题,因此请查看 Python 参考手册或其他教程以获取更多信息。 装饰器可以创建为可调用对象,而不仅仅是返回函数的函数。 班级也可以装饰。 在这种情况下,装饰器将返回一个新类而不是一个新函数。 最后,装饰器可以接受参数以根据功能自定义它们。
观察者模式
观察者模式对于状态监视和事件处理情况很有用。 这种模式允许给定的对象由未知且动态的“观察者”对象组监视。
只要核心对象上的值发生更改,它就会通过调用update()方法让所有观察者对象知道发生了更改。 每当核心对象发生变化时,每个观察者可能负责不同的任务; 核心对象不知道或不在乎这些任务是什么,观察者通常也不知道或不在乎其他观察者在做什么。
在这里,它在 UML 中:
观察者的例子
观察者模式在冗余备份系统中可能有用。 我们可以编写一个维护某些值的核心对象,然后让一个或多个观察者创建该对象的序列化副本。 例如,这些副本可以存储在数据库中,远程主机上或本地文件中。 让我们使用属性来实现核心对象:
class Inventory:
def __init__(self):
self.observers = []
self._product = None
self._quantity = 0
def attach(self, observer):
self.observers.append(observer)
@property
def product(self):
return self._product
@product.setter
def product(self, value):
self._product = value
self._update_observers()
@property
def quantity(self):
return self._quantity
@quantity.setter
def quantity(self, value):
self._quantity = value
self._update_observers()
def _update_observers(self):
for observer in self.observers:
observer()
该对象具有两个属性,设置这些属性后,它们将自行调用_update_observers方法。 此方法所做的全部工作就是遍历可用的观察者,并让每个人都知道某些更改。 在这种情况下,我们直接调用观察者对象。 该对象将必须实现__call__以处理更新。 在许多面向对象的编程语言中这是不可能的,但这是 Python 中有用的快捷方式,可以帮助使我们的代码更具可读性。
现在,让我们实现一个简单的观察者对象; 这将只是向控制台输出一些状态:
class ConsoleObserver:
def __init__(self, inventory):
self.inventory = inventory
def __call__(self):
print(self.inventory.product)
print(self.inventory.quantity)
这里没有什么令人兴奋的事情。 被观察的对象是在初始化器中设置的,当观察者被调用时,我们做“某事”。 我们可以在交互式控制台中测试观察者:
>>> i = Inventory()
>>> c = ConsoleObserver(i)
>>> i.attach(c)
>>> i.product = "Widget"
Widget
0
>>> i.quantity = 5
Widget
5
将观察者附加到库存对象后,只要我们更改两个观察到的属性之一,便会调用该观察者并调用其动作。 我们甚至可以添加两个不同的观察者实例:
>>> i = Inventory()
>>> c1 = ConsoleObserver(i)
>>> c2 = ConsoleObserver(i)
>>> i.attach(c1)
>>> i.attach(c2)
>>> i.product = "Gadget"
Gadget
0
Gadget
0
这次更改产品时,有两组输出,每个观察者一组。 这里的关键思想是,我们可以轻松地添加完全不同类型的观察者,这些观察者可以同时备份文件,数据库或 Internet 应用中的数据。
观察者模式将正在观察的代码与进行观察的代码分离。 如果我们不使用这种模式,我们将不得不在每个属性中放置代码以处理可能出现的不同情况。 登录到控制台,更新数据库或文件等。 这些任务中的每一个的代码都将与观察到的对象混合在一起。 维护它将是一场噩梦,而日后添加新的监视功能将很痛苦。
策略模式
策略模式是面向对象编程中抽象的常见演示。 该模式对单个问题实施不同的解决方案,每个解决方案都在不同的对象中。 然后,客户端代码可以在运行时动态选择最合适的实现。
通常,不同的算法会有不同的权衡。 一种可能比另一种更快,但是会使用更多的内存,而当存在多个 CPU 或提供分布式系统时,第三种算法可能是最合适的。 这是 UML 中的策略模式:
连接到策略模式的用户代码仅需要知道它正在处理抽象接口。 选择的实际实现执行相同的任务,但是方式不同。 无论哪种方式,界面都是相同的。
一个策略示例
策略模式的典范示例是排序例程。 多年来,发明了许多算法来对一组对象进行分类。 快速排序,合并排序和堆排序都是具有不同功能的快速排序算法,每种算法本身都有用,具体取决于输入的大小和类型,输入的混乱程度以及系统的要求。
如果我们有需要对集合进行排序的客户端代码,则可以使用sort()方法将其传递给对象。 该对象可以是QuickSorter或MergeSorter对象,但是两种情况下的结果都是相同的:排序列表。 用于进行排序的策略是从调用代码中抽象出来的,从而使其具有模块化和可替换性。
当然,在 Python 中,我们通常只调用sorted函数或list.sort方法,并相信它将以接近最佳的方式进行排序。 因此,我们确实需要看一个更好的例子。
让我们考虑一个桌面墙纸管理器。 在桌面背景上显示图像时,可以通过不同方式将其调整为屏幕尺寸。 例如,假设图像小于屏幕,则可以将其平铺在屏幕上,居中放置或缩放以适合屏幕。 也可以使用其他更复杂的策略,例如缩放到最大高度或宽度,将其与纯色,半透明或渐变背景色组合,或进行其他操作。 虽然我们可能以后想添加这些策略,但让我们从基本策略开始。
我们的策略对象需要两个输入。 要显示的图像,以及屏幕的宽度和高度的元组。 它们每个都返回一个屏幕大小的新图像,并根据给定的策略对图像进行调整以适合显示。 您需要在pip3 install pillow中安装pillow模块,此示例才能正常工作:
from PIL import Image
class TiledStrategy:
def make_background(self, img_file, desktop_size):
in_img = Image.open(img_file)
out_img = Image.new('RGB', desktop_size)
num_tiles = [
o // i + 1 for o, i in
zip(out_img.size, in_img.size)
]
for x in range(num_tiles[0]):
for y in range(num_tiles[1]):
out_img.paste(
in_img,
(
in_img.size[0] * x,
in_img.size[1] * y,
in_img.size[0] * (x+1),
in_img.size[1] * (y+1)
)
)
return out_img
class CenteredStrategy:
def make_background(self, img_file, desktop_size):
in_img = Image.open(img_file)
out_img = Image.new('RGB', desktop_size)
left = (out_img.size[0] - in_img.size[0]) // 2
top = (out_img.size[1] - in_img.size[1]) // 2
out_img.paste(
in_img,
(
left,
top,
left+in_img.size[0],
top + in_img.size[1]
)
)
return out_img
class ScaledStrategy:
def make_background(self, img_file, desktop_size):
in_img = Image.open(img_file)
out_img = in_img.resize(desktop_size)
return out_img
在这里,我们有三种策略,每种策略都使用PIL执行其任务。 个别策略具有make_background方法,该方法接受相同的参数集。 选择之后,可以调用适当的策略来创建正确大小的桌面映像版本。 TiledStrategy循环遍历适合图像宽度和高度的输入图像数量,并将其重复复制到每个位置。 CenteredStrategy计算出需要在图像的四个边缘上留出多少空间以使其居中。 ScaledStrategy强制图像达到输出尺寸(忽略宽高比)。
考虑在没有策略模式的情况下如何在这些选项之间进行切换。 我们需要将所有代码放入一个很大的方法中,并使用笨拙的if语句选择所需的代码。 每次我们想添加新策略时,都必须使该方法变得更加笨拙。
Python 中的策略
尽管在大多数面向对象的库中很常见,但是策略模式的之前的规范实现在 Python 编程中很少见。
这些类各自表示只提供单个功能的对象,它们什么也不做。 我们可以轻松地调用该函数__call__并使对象直接可调用。 由于没有其他与该对象相关联的数据,因此我们只需要创建一组顶层函数并将其作为我们的策略传递即可。
因此,反对设计模式哲学的人会说:“因为 Python 具有一流的功能,所以不需要策略模式”。 实际上,Python 的一流功能使我们能够以更直接的方式实现策略模式。 知道存在模式仍然可以帮助我们为程序选择正确的设计,但是可以使用可读性更高的语法来实现它。 当我们需要允许客户端代码或最终用户从同一接口的多个实现中进行选择时,应使用策略模式或其顶层功能实现。
状态模式
状态模式在结构上类似于策略模式,但是其意图和目的有很大不同。 状态模式的目标是表示状态转换系统:即明显可以使对象处于特定状态并且某些活动可能将其驱动到不同状态的系统。
为了使这项工作有效,我们需要提供一个用于切换状态的接口的管理器或上下文类。 在内部,该类包含一个指向当前状态的指针。 每个州都知道允许其进入的其他州,并将根据对其执行的操作转换到那些州。
因此,我们有两种类型的类,上下文类和多个状态类。 上下文类维护当前状态,并将动作转发到状态类。 状态类通常对调用上下文的任何其他对象隐藏; 它就像一个黑匣子,恰好在内部执行状态管理。 这是在 UML 中的外观:
状态示例
为了说明的状态模式,让我们构建一个 XML 解析工具。 上下文类将是解析器本身。 它将以字符串作为输入,并将工具置于初始解析状态。 各种解析状态将吃掉字符,寻找特定的值,找到该值后,将其更改为其他状态。 目标是为每个标签及其内容创建一个节点对象树。 为了使事情易于管理,我们将仅解析 XML 的子集-标签和标签名称。 我们将无法处理标签上的属性。 它将解析标签的文本内容,但是不会尝试解析在文本内部具有标签的“混合”内容。 这是一个我们可以解析的示例“简化的 XML”文件:
<module>
<author>Dusty Phillips</author>
<publisher>Packt Publishing</publisher>
<title>Python 3 Object Oriented Programming</title>
<content>
<chapter>
<number>1</number>
<title>Object Oriented Design</title>
</chapter>
<chapter>
<number>2</number>
<title>Objects In Python</title>
</chapter>
</content>
</module>
在查看状态和解析器之前,让我们考虑一下该程序的输出。 我们知道我们想要Node对象的树,但是Node是什么样的? 好吧,很显然,它需要知道它正在解析的标签的名称,并且由于它是一棵树,因此它可能应该维护一个指向父节点的指针以及该节点的子节点的顺序。 一些节点具有文本值,但不是全部。 让我们首先看一下Node类:
class Node:
def __init__(self, tag_name, parent=None):
self.parent = parent
self.tag_name = tag_name
self.children = []
self.text=""
def __str__(self):
if self.text:
return self.tag_name + ": " + self.text
else:
return self.tag_name
此类在初始化时设置默认属性值。 我们提供__str__方法来帮助我们看完树结构。
现在,在查看示例文档时,我们需要考虑解析器可以处于什么状态。显然,它将以尚未处理任何节点的状态开始。 我们需要一个状态来处理开始标签和结束标签。 而且,当我们在带有文本内容的标记中时,也必须将其作为单独的状态进行处理。
切换状态可能很棘手。 我们如何知道下一个节点是开始标记,结束标记还是文本节点? 我们可以在每个状态中加入一点逻辑来解决这个问题,但是创建一个新状态实际上更有意义,其唯一目的是弄清楚我们将要切换到下一个状态。 如果我们将此过渡状态称为 ChildNode,则最终将具有以下状态:
- FirstTag
- ChildNode
- OpenTag
- CloseTag
- Text
FirstTag 状态将切换到 ChildNode,该子节点负责确定要切换到其他三种状态中的哪一种; 这些状态完成时,它们将切换回 ChildNode。 以下状态转换图显示了可用的状态更改:
各州负责处理“字符串的剩余部分”,处理尽可能多的字符串,然后告诉解析器处理其余部分。 让我们首先构造Parser类:
class Parser:
def __init__(self, parse_string):
self.parse_string = parse_string
self.root = None
self.current_node = None
self.state = FirstTag()
def process(self, remaining_string):
remaining = self.state.process(remaining_string, self)
if remaining:
self.process(remaining)
def start(self):
self.process(self.parse_string)
初始化程序在类中设置了一些变量,各个状态将访问该变量。 parse_string实例变量是我们尝试解析的文本。 root节点是 XML 结构中的“顶部”节点。 current_node实例变量是我们当前要添加子代的变量。
该解析器的重要功能是process方法,该方法接受剩余的字符串,并将其传递到当前状态。 解析器(self参数)也传递到该状态的处理方法中,以便该状态可以对其进行操作。 当状态完成处理时,期望状态返回未解析的字符串的其余部分。 然后,解析器对该剩余的字符串递归调用process方法,以构造树的其余部分。
现在,让我们看一下FirstTag状态:
class FirstTag:
def process(self, remaining_string, parser):
i_start_tag = remaining_string.find('<')
i_end_tag = remaining_string.find('>')
tag_name = remaining_string[i_start_tag+1:i_end_tag]
root = Node(tag_name)
parser.root = parser.current_node = root
parser.state = ChildNode()
return remaining_string[i_end_tag+1:]
此状态在第一个标签上找到打开和关闭尖括号的索引(i_代表索引)。 您可能会认为这种状态是不必要的,因为 XML 要求在开始标记之前不能有文本。 但是,可能需要使用空白。 这就是为什么我们搜索开括号的原因,而不是假设它是文档中的第一个字符。 请注意,此代码假定输入文件有效。 正确的实现将是严格测试无效输入,并尝试恢复或显示极具描述性的错误消息。
该方法提取标记的名称,并将其分配给解析器的根节点。 它还将它分配给current_node,因为这是我们将在下一个添加子项的那个。
接下来是重要的部分:该方法将解析器对象上的当前状态更改为ChildNode状态。 然后,它返回字符串的其余部分(在开始标记之后)以允许对其进行处理。
ChildNode状态看似非常复杂,但实际上只需要一个简单的条件即可:
class ChildNode:
def process(self, remaining_string, parser):
stripped = remaining_string.strip()
if stripped.startswith("</"):
parser.state = CloseTag()
elif stripped.startswith("<"):
parser.state = OpenTag()
else:
parser.state = TextNode()
return stripped
strip()调用从字符串中删除空格。 然后,解析器确定下一个项目是开始标记还是结束标记,还是文本字符串。 根据发生哪种可能性,它将解析器设置为特定状态,然后告诉它解析字符串的其余部分。
OpenTag状态类似于FirstTag状态,不同之处在于它将新创建的节点添加到先前的current_node对象的children并将其设置为新的current_node。 继续执行以下操作之前,它将处理器置于ChildNode状态:
class OpenTag:
def process(self, remaining_string, parser):
i_start_tag = remaining_string.find('<')
i_end_tag = remaining_string.find('>')
tag_name = remaining_string[i_start_tag+1:i_end_tag]
node = Node(tag_name, parser.current_node)
parser.current_node.children.append(node)
parser.current_node = node
parser.state = ChildNode()
return remaining_string[i_end_tag+1:]
CloseTag状态基本上相反。 它将解析器的current_node设置回父节点,以便将外部标记中的任何其他子代添加到它:
class CloseTag:
def process(self, remaining_string, parser):
i_start_tag = remaining_string.find('<')
i_end_tag = remaining_string.find('>')
assert remaining_string[i_start_tag+1] == "/"
tag_name = remaining_string[i_start_tag+2:i_end_tag]
assert tag_name == parser.current_node.tag_name
parser.current_node = parser.current_node.parent
parser.state = ChildNode()
return remaining_string[i_end_tag+1:].strip()
这两个assert语句有助于确保解析字符串一致。 方法末尾的if语句可确保处理器在完成时终止。 如果节点的父节点是None,则意味着我们正在根节点上工作。
最后,TextNode状态非常简单地提取下一个关闭标签之前的文本,并将其设置为当前节点上的值:
class TextNode:
def process(self, remaining_string, parser):
i_start_tag = remaining_string.find('<')
text = remaining_string[:i_start_tag]
parser.current_node.text = text
parser.state = ChildNode()
return remaining_string[i_start_tag:]
现在我们只需在我们创建的解析器对象上设置初始状态。 初始状态为FirstTag对象,因此只需将以下内容添加到__init__方法中:
self.state = FirstTag()
为了测试该类,让我们添加一个主脚本,该脚本从命令行打开一个文件,进行解析并打印节点:
if __name__ == "__main__":
import sys
with open(sys.argv[1]) as file:
contents = file.read()
p = Parser(contents)
p.start()
nodes = [p.root]
while nodes:
node = nodes.pop(0)
print(node)
nodes = node.children + nodes
此代码打开文件,加载内容,然后解析结果。 然后按顺序打印每个节点及其子节点。 我们最初在节点类上添加的__str__方法负责格式化节点以进行打印。 如果我们在前面的示例中运行脚本,则其输出树如下:
module
author: Dusty Phillips
publisher: Packt Publishing
title: Python 3 Object Oriented Programming
content
chapter
number: 1
title: Object Oriented Design
chapter
number: 2
title: Objects In Python
将其与进行比较,原始的简化 XML 文档告诉我们解析器正在工作。
国家与战略
状态模式看起来与策略模式非常相似; 实际上,两者的 UML 图是相同的。 实现也相同。 我们甚至可以将状态写为一等函数,而不是像策略建议那样将它们包装在对象中。
虽然这两种模式具有相同的结构,但它们可以解决完全不同的问题。 策略模式用于在运行时选择算法。 通常,将仅针对特定用例选择这些算法中的一种。 另一方面,状态模式设计为允许随着某些过程的发展动态地在不同状态之间切换。 在代码中,主要区别在于策略模式通常不了解其他策略对象。 在状态模式中,状态或上下文都需要知道可以切换到的其他状态。
作为协程的状态转换
状态模式是状态转换问题的经典面向对象解决方案。 但是,此模式的语法相当冗长。 通过将对象构造为协程,可以获得类似的效果。 还记得我们在第 9 章,“迭代器模式”中内置的正则表达式日志文件解析器吗? 那是变相的国家过渡问题。 该实现与定义状态模式中使用的所有对象(或函数)的实现之间的主要区别在于,协程解决方案允许我们在语言构造中对更多样板进行编码。 有两种实现方式,但是没有一种在本质上优于另一种实现,但是对于给定的“可读”定义,您可能会发现协程更具可读性(首先必须了解协程的语法!)。
单例模式
单例模式是最具争议的模式之一; 许多人指责它是“反模式”,应该避免这种模式,而不应提倡这种模式。 在 Python 中,如果有人使用单例模式,则几乎可以肯定他们做错了事,这可能是因为它们来自限制性更强的编程语言。
那么为什么要讨论它呢? Singleton 是所有设计模式中最著名的之一。 它在面向对象的语言中很有用,并且是传统的面向对象编程的重要组成部分。 更相关的是,即使我们在 Python 中以完全不同的方式实现该想法,单例背后的想法也很有用。
单例模式背后的基本思想是允许某个对象的确切一个实例存在。 通常,该对象是一种管理器类,类似于我们在第 5 章,“何时使用面向对象编程”中所讨论的类。 此类对象通常需要由各种其他对象引用,并且将对管理器对象的引用传递给需要它们的方法和构造函数会使代码难以阅读。
相反,当使用单例时,单独的对象会从类中请求管理器对象的单个实例,因此无需传递对其的引用。 UML 图没有完全描述它,但是这里是为了完整性:
在大多数编程环境中,通过将构造函数设为私有(因此没有人可以创建它的其他实例),然后提供一种静态方法来检索单个实例来实施单例。 此方法在首次调用时创建一个新实例,然后在每次再次调用时返回相同的实例。
单例实现
Python 没有私有的构造函数,但是为此,它有更好的东西。 我们可以使用__new__类方法来确保仅创建一个实例:
class OneOnly:
_singleton = None
def __new__(cls, *args, **kwargs):
if not cls._singleton:
cls._singleton = super(OneOnly, cls
).__new__(cls, *args, **kwargs)
return cls._singleton
调用__new__时,通常会构造该类的新实例。 覆盖它时,我们首先检查是否已创建单例实例; 如果不是,我们使用super调用创建它。 因此,每当我们在OneOnly上调用构造函数时,我们总是得到完全相同的实例:
>>> o1 = OneOnly()
>>> o2 = OneOnly()
>>> o1 == o2
True
>>> o1
<__main__.OneOnly object at 0xb71c008c>
>>> o2
<__main__.OneOnly object at 0xb71c008c>
两个对象相等,并且位于相同的地址。 因此,它们是同一对象。 这个特定的实现不是很透明,因为创建单个对象并不明显。 每当我们调用构造函数时,我们都期望该对象有一个新实例; 在这种情况下,该合同被违反。 也许,如果我们真的认为我们需要一个单例,那么在类上使用良好的文档字符串可以缓解此问题。
但是我们不需要它。 Python 编码人员不愿强迫用户使用特定的思维方式。 我们可能认为只需要一个类的实例,但是其他程序员可能有不同的想法。 例如,单例可能会干扰分布式计算,并行编程和自动化测试。 在所有这些情况下,即使一个“正常”操作可能永远不需要一个实例,拥有一个特定对象的多个或替代实例也可能非常有用。
模块变量可以模仿单例
通常,在 Python 中,可以使用模块级变量充分模仿单例模式。 人们可以随时重新分配这些变量并不像单例的“安全”,但是正如我们在第 2 章,“Python 中的对象”中讨论的私有变量一样,这是 在 Python 中可以接受。 如果有人有正当理由要更改这些变量,为什么我们应该停止它们? 它也不会阻止人们实例化该对象的多个实例,但是,如果他们有正当的理由,那么为什么还要进行干预呢?
理想情况下,我们应该为他们提供一种机制来访问“默认单例”值,同时还允许他们在需要时创建其他实例。 尽管从技术上讲根本不是单例,但它为类单例行为提供了最 Pythonic 的机制。
要使用模块级变量而不是单例,我们在定义类后实例化该类的实例。 我们可以改善状态模式以使用单例。 无需每次更改状态都创建新对象,而是可以创建始终可访问的模块级变量:
class FirstTag:
def process(self, remaining_string, parser):
i_start_tag = remaining_string.find('<')
i_end_tag = remaining_string.find('>')
tag_name = remaining_string[i_start_tag+1:i_end_tag]
root = Node(tag_name)
parser.root = parser.current_node = root
parser.state = child_node
return remaining_string[i_end_tag+1:]
class ChildNode:
def process(self, remaining_string, parser):
stripped = remaining_string.strip()
if stripped.startswith("</"):
parser.state = close_tag
elif stripped.startswith("<"):
parser.state = open_tag
else:
parser.state = text_node
return stripped
class OpenTag:
def process(self, remaining_string, parser):
i_start_tag = remaining_string.find('<')
i_end_tag = remaining_string.find('>')
tag_name = remaining_string[i_start_tag+1:i_end_tag]
node = Node(tag_name, parser.current_node)
parser.current_node.children.append(node)
parser.current_node = node
parser.state = child_node
return remaining_string[i_end_tag+1:]
class TextNode:
def process(self, remaining_string, parser):
i_start_tag = remaining_string.find('<')
text = remaining_string[:i_start_tag]
parser.current_node.text = text
parser.state = child_node
return remaining_string[i_start_tag:]
class CloseTag:
def process(self, remaining_string, parser):
i_start_tag = remaining_string.find('<')
i_end_tag = remaining_string.find('>')
assert remaining_string[i_start_tag+1] == "/"
tag_name = remaining_string[i_start_tag+2:i_end_tag]
assert tag_name == parser.current_node.tag_name
parser.current_node = parser.current_node.parent
parser.state = child_node
return remaining_string[i_end_tag+1:].strip()
first_tag = FirstTag()
child_node = ChildNode()
text_node = TextNode()
open_tag = OpenTag()
close_tag = CloseTag()
我们已经完成的所有工作是创建可重复使用的各种状态类的实例。 请注意,即使在定义变量之前,如何访问类中的这些模块变量? 这是因为直到调用该方法后,类中的代码才会执行,并且到此为止,整个模块都已定义。
此示例的区别在于,我们不是在浪费内存以创建一堆必须进行垃圾回收的新实例,而是为每个状态重用了一个状态对象。 即使同时运行多个解析器,也只需要使用这些状态类。
当我们最初创建基于状态的解析器时,您可能想知道为什么我们没有在每个单独的状态下将解析器对象传递给__init__,而不是像我们那样将其传递给 process 方法。 该状态于是可以被称为self.parser。 这是状态模式的完美有效实现,但不允许利用单例模式。 如果状态对象维护对解析器的引用,则不能同时使用它们来引用其他解析器。
注意
请记住,这是两种不同的模式,具有不同的用途。 Singleton 的目的对于实现状态模式可能有用的事实并不意味着这两个模式是相关的。
模板模式
模板模式对于删除重复代码非常有用。 它是支持不要重复自己原理的一种实现,我们在第 5 章,“何时使用面向对象编程”中讨论了该原理。 它设计用于以下情况:我们要完成几个不同的任务,这些任务有一些但不是全部的相同步骤。 通用步骤在基类中实现,而不同的步骤在子类中被覆盖以提供自定义行为。 在某些方面,这类似于通用策略模式,只是使用基类共享算法的相似部分。 这是 UML 格式:
模板示例
让我们以为例创建一个汽车销售记者。 我们可以将销售记录存储在 SQLite 数据库表中。 SQLite 是一个简单的基于文件的数据库引擎,允许我们使用 SQL 语法存储记录。 Python 3 在其标准库中包括 SQLite,因此不需要额外的模块。
我们需要执行两个常见任务:
- 选择所有新车销售并将其以逗号分隔的格式输出到屏幕
- 输出以逗号分隔的所有销售人员及其销售总额的列表,并将其保存到可以导入电子表格的文件中
这些任务看起来似乎完全不同,但是它们具有一些共同的特征。 在这两种情况下,我们都需要执行以下步骤:
- 连接到数据库。
- 构造查询新车或销售总额。
- 发出查询。
- 将结果格式化为以逗号分隔的字符串。
- 将数据输出到文件或电子邮件。
这两个任务的查询构造和输出步骤不同,但是其余步骤相同。 我们可以使用模板模式将常用步骤放在基类中,将不同步骤放在两个子类中。
在开始之前,让我们创建一个数据库,并使用几行 SQL 将一些示例数据放入其中:
import sqlite3
conn = sqlite3.connect("sales.db")
conn.execute("CREATE TABLE Sales (salesperson text, "
"amt currency, year integer, model text, new boolean)")
conn.execute("INSERT INTO Sales values"
" ('Tim', 16000, 2010, 'Honda Fit', 'true')")
conn.execute("INSERT INTO Sales values"
" ('Tim', 9000, 2006, 'Ford Focus', 'false')")
conn.execute("INSERT INTO Sales values"
" ('Gayle', 8000, 2004, 'Dodge Neon', 'false')")
conn.execute("INSERT INTO Sales values"
" ('Gayle', 28000, 2009, 'Ford Mustang', 'true')")
conn.execute("INSERT INTO Sales values"
" ('Gayle', 50000, 2010, 'Lincoln Navigator', 'true')")
conn.execute("INSERT INTO Sales values"
" ('Don', 20000, 2008, 'Toyota Prius', 'false')")
conn.commit()
conn.close()
希望即使您不了解 SQL,也可以看到这里发生的事情。 我们创建了一个表来保存数据,并使用六个插入语句来添加销售记录。 数据存储在名为sales.db的文件中。 现在我们有了一个样本,可以用来开发模板模式。
由于我们已经概述了模板必须执行的步骤,因此我们可以从定义包含步骤的基类开始。 每个步骤都有其自己的方法(以使其易于选择性地覆盖任何一个步骤),并且我们还有一个管理方法可以依次调用这些步骤。 如果没有任何方法内容,则可能是这样:
class QueryTemplate:
def connect(self):
pass
def construct_query(self):
pass
def do_query(self):
pass
def format_results(self):
pass
def output_results(self):
pass
def process_format(self):
self.connect()
self.construct_query()
self.do_query()
self.format_results()
self.output_results()
process_format方法是外部客户端调用的主要方法。 它确保每个步骤均按顺序执行,但并不关心该步骤是在此类中还是在子类中实现的。 对于我们的示例,我们知道两个类之间的三个方法将完全相同:
import sqlite3
class QueryTemplate:
def connect(self):
self.conn = sqlite3.connect("sales.db")
def construct_query(self):
raise NotImplementedError()
def do_query(self):
results = self.conn.execute(self.query)
self.results = results.fetchall()
def format_results(self):
output = []
for row in self.results:
row =[str(i) for i in row]
output.append(", ".join(row))
self.formatted_results = "\n".join(output)
def output_results(self):
raise NotImplementedError()
为了帮助实现子类,未指定的两个方法将引发NotImplementedError。 当抽象基类看起来过于沉重时,这是在 Python 中指定抽象接口的常用方法。 这些方法可能具有空的实现(使用pass),或者可能尚未完全指定。 然而,提高NotImplementedError可以帮助程序员理解该类是要被子类化的,并且这些方法将被覆盖。 空方法或不存在的方法很难识别为需要实现,如果我们忘记实现它们,则很难调试。
现在,我们有了一个模板类,该模板类负责处理无聊的细节,但具有足够的灵活性以允许执行和格式化各种查询。 最好的部分是,如果我们想将数据库引擎从 SQLite 更改为另一个数据库引擎(例如 py-postgresql),则只需在此模板类中执行此操作,而不必触摸 我们可能已经编写了两个(或两百个)子类。
现在让我们看一下具体的类:
import datetime
class NewVehiclesQuery(QueryTemplate):
def construct_query(self):
self.query = "select * from Sales where new='true'"
def output_results(self):
print(self.formatted_results)
class UserGrossQuery(QueryTemplate):
def construct_query(self):
self.query = ("select salesperson, sum(amt) " +
" from Sales group by salesperson")
def output_results(self):
filename = "gross_sales_{0}".format(
datetime.date.today().strftime("%Y%m%d")
)
with open(filename, 'w') as outfile:
outfile.write(self.formatted_results)
考虑到它们在做什么,实际上这两个类很短:连接到数据库,执行查询,格式化结果并输出。 超类负责重复性工作,但让我们轻松指定在任务之间有所不同的那些步骤。 此外,我们还可以轻松更改基类中提供的步骤。 例如,如果我们要输出除逗号分隔的字符串以外的内容(例如:要上传到网站的 HTML 报告),我们仍然可以覆盖format_results。
十二、Python 设计模式 II
在本章中,我们将介绍更多的设计模式。 再次,我们将介绍规范示例以及 Python 中的所有常见替代实现。 我们将讨论:
- 适配器模式
- 外墙图案
- 延迟初始化和 flyweight 模式
- 命令模式
- 抽象工厂模式
- 构图模式
适配器模式
与中的大多数模式不同,我们在第 8 章,“字符串和序列化”中介绍了该适配器模式,旨在与现有代码进行交互。 我们不会设计实现适配器模式的全新对象集。 适配器用于允许两个预先存在的对象一起工作,即使它们的接口不兼容也是如此。 就像允许 VGA 投影仪插入 HDMI 端口的显示适配器一样,适配器对象位于两个不同的接口之间,可以在它们之间进行即时转换。 适配器对象的唯一目的是执行此转换作业。 适应可能需要执行各种任务,例如将参数转换为不同的格式,重新排列参数的顺序,调用名称不同的方法或提供默认参数。
在结构上,适配器模式类似于简化的装饰器模式。 装饰器通常提供与其替换的相同接口,而适配器在两个不同的接口之间映射。 这是 UML 形式:
在这里,Interface1 是,它希望调用称为 **make_action(some,arguments)**的方法。 我们已经有了这个完美的 Interface2 类,它可以完成我们想要的所有事情(并且为了避免重复,我们不想重写它!),但是它提供了一种名为 **different_action(other ,参数)**代替。 适配器类实现 make_action 接口,并将参数映射到现有接口。
这样做的好处是,从一个接口映射到另一个接口的代码都在一个地方。 替代方案确实非常丑陋; 每当我们需要访问此代码时,我们都必须在多个位置执行翻译。
例如,假设我们有以下预先存在的类,该类采用格式为“ YYYY-MM-DD”的字符串日期并计算某天的年龄:
class AgeCalculator:
def __init__(self, birthday):
self.year, self.month, self.day = (
int(x) for x in birthday.split('-'))
def calculate_age(self, date):
year, month, day = (
int(x) for x in date.split('-'))
age = year - self.year
if (month,day) < (self.month,self.day):
age -= 1
return age
这是一个非常简单的类,可以完成预期的工作。 但是我们不得不怀疑程序员在想什么,使用特定格式的字符串而不是使用 Python 极其有用的内置datetime库。 作为尽职尽责的程序员,他们尽可能地重用代码,因此我们编写的大多数程序都将与datetime对象(而不是字符串)进行交互。
我们有个选项来解决这种情况; 我们可以重写该类以接受datetime对象,无论如何这可能会更准确。 但是,如果此类是由第三方提供的,并且我们不知道或无法更改其内部结构,则需要尝试其他方法。 我们可以按原样使用该类,并且每当要计算datetime.date对象的年龄时,都可以调用datetime.date.strftime('%Y-%m-%d')将其转换为正确的格式。 但是这种转换会在很多地方发生,更糟糕的是,如果我们将%m输入错误的名称为%M,它将为我们提供当前分钟而不是输入的月份! 试想一下,如果您在十几个不同的地方写过这篇文章,那么当您意识到自己的错误时,只需回头修改它即可。 它不是可维护的代码,并且破坏了 DRY 原理。
相反,我们可以编写一个适配器,该适配器允许将正常日期插入到正常的AgeCalculator类中:
import datetime
class DateAgeAdapter:
def _str_date(self, date):
return date.strftime("%Y-%m-%d")
def __init__(self, birthday):
birthday = self._str_date(birthday)
self.calculator = AgeCalculator(birthday)
def get_age(self, date):
date = self._str_date(date)
return self.calculator.calculate_age(date)
该适配器将datetime.date和datetime.time(它们与strftime具有相同的接口)转换为我们原始AgeCalculator可以使用的字符串。 现在,我们可以在新界面中使用原始代码。 我将方法签名更改为get_age,以说明调用接口也可能正在寻找其他方法名称,而不仅仅是其他类型的参数。
创建类作为适配器是实现此模式的常用方法,但是与往常一样,在 Python 中还有其他方法可以实现。 继承和多重继承可用于向类添加功能。 例如,我们可以在date类上添加一个适配器,使其与原始AgeCalculator类一起使用:
import datetime
class AgeableDate(datetime.date):
def split(self, char):
return self.year, self.month, self.day
像这样的代码让人怀疑 Python 是否应该合法。 我们在子类中添加了split方法,该方法采用单个参数(我们将其忽略)并返回年,月和日的元组。 这对于原始的AgeCalculator类来说是完美无缺的,因为代码在特殊格式的字符串上调用strip,在这种情况下,strip返回年,月和日的元组。 AgeCalculator代码仅关心strip是否存在并返回可接受的值; 不管我们是否真的传入了一个字符串。 真的行:
>>> bd = AgeableDate(1975, 6, 14)
>>> today = AgeableDate.today()
>>> today
AgeableDate(2015, 8, 4)
>>> a = AgeCalculator(bd)
>>> a.calculate_age(today)
40
它有效,但这是一个愚蠢的想法。 在这种特定情况下,这样的适配器将难以维护。 我们很快就会忘记为什么我们需要向date类添加strip方法。 方法名称不明确。 这可能是适配器的本性,但是显式创建适配器而不是使用继承通常可以阐明其目的。
除了继承之外,有时我们还可以使用猴子修补将方法添加到现有类中。 它不能与datetime对象一起使用,因为它不允许在运行时添加属性,但是在普通类中,我们只能添加一个新方法,该方法提供调用代码所需的已适配接口。 或者,我们可以扩展AgeCalculator本身或对其进行猴子补丁,以更适合我们需求的方式替换calculate_age方法。
最后,通常可以将函数用作适配器。 这显然不适合适配器模式的实际设计,但是如果我们回想起函数本质上是使用__call__方法的对象,则它将成为适配器的明显改编。
外墙图案
外观模式旨在为复杂的组件系统提供简单的界面。 对于复杂的任务,我们可能需要直接与这些对象进行交互,但是对于系统而言,通常不需要使用“典型”的操作,而无需进行这些复杂的交互。 外观模式允许我们定义一个新对象,该对象封装了系统的这种典型用法。 每当我们想要访问通用功能时,我们都可以使用单个对象的简化接口。 如果项目的另一部分需要访问更复杂的功能,则它仍然能够直接与系统进行交互。 外观模式的 UML 图实际上取决于子系统,但是以一种模糊的方式,它看起来像这样:
在许多方面,外墙就像一个适配器。 主要区别在于,外观正试图从复杂的接口中抽象出一个更简单的接口,而适配器仅试图将一个现有接口映射到另一个接口。
让我们为电子邮件应用编写一个简单的外观。 正如在第 7 章和 Python 面向对象的快捷方式中所看到的那样,用于在 Python 中发送电子邮件的低级库非常复杂。 用于接收消息的两个库更糟。
最好有一个简单的类,该类允许我们发送一封电子邮件,并列出 IMAP 或 POP3 连接上收件箱中当前的电子邮件。 为了使我们的示例简短,我们将继续使用 IMAP 和 SMTP:碰巧处理电子邮件的两个完全不同的子系统。 我们的外观仅执行两项任务:向特定地址发送电子邮件,以及检查 IMAP 连接上的收件箱。 它对连接进行了一些常见的假设,例如 SMTP 和 IMAP 的主机位于同一地址,两者的用户名和密码相同,并且它们使用标准端口。 这涵盖了许多电子邮件服务器的情况,但是如果程序员需要更大的灵活性,则他们总是可以绕过立面并直接访问两个子系统。
该类用电子邮件服务器的主机名,用户名和登录密码初始化:
import smtplib
import imaplib
class EmailFacade:
def __init__(self, host, username, password):
self.host = host
self.username = username
self.password = password
send_email方法格式化电子邮件地址和消息,然后使用smtplib发送。 这不是一项复杂的任务,但是需要花很多时间来调整传递给外观的“自然”输入参数的正确格式,以使smtplib能够发送消息:
def send_email(self, to_email, subject, message):
if not "@" in self.username:
from_email = "{0}@{1}".format(
self.username, self.host)
else:
from_email = self.username
message = ("From: {0}\r\n"
"To: {1}\r\n"
"Subject: {2}\r\n\r\n{3}").format(
from_email,
to_email,
subject,
message)
smtp = smtplib.SMTP(self.host)
smtp.login(self.username, self.password)
smtp.sendmail(from_email, [to_email], message)
该方法开头的if语句正在捕获username是整个“发件人”电子邮件地址还是只是@符号左侧的部分; 不同的主机以不同的方式处理登录详细信息。
最后,获取当前收件箱中消息的代码是一片混乱。 IMAP 协议经过了过度的精心设计,imaplib标准库只是该协议的薄层:
def get_inbox(self):
mailbox = imaplib.IMAP4(self.host)
mailbox.login(bytes(self.username, 'utf8'),
bytes(self.password, 'utf8'))
mailbox.select()
x, data = mailbox.search(None, 'ALL')
messages = []
for num in data[0].split():
x, message = mailbox.fetch(num, '(RFC822)')
messages.append(message[0][1])
return messages
现在,如果将全部加在一起,我们将拥有一个简单的 Facade 类,该类可以以相当直接的方式发送和接收消息,这比必须直接与这些复杂的库进行交互要简单得多。
尽管在 Python 社区中很少使用,但是外观模式是 Python 生态系统不可或缺的一部分。 由于 Python 强调语言的可读性,因此语言及其库都倾向于为复杂任务提供易于理解的界面。 例如,for循环,list理解和生成器都是更复杂的迭代器协议的基础。 defaultdict实现是一个立面,可以抽象化字典中不存在键时烦人的角落情况。 第三方请求库是 HTTP 请求的可读性较弱的库的强大基础。
轻量化模式
轻量级模式是内存优化模式。 假定内置垃圾收集器会照顾好它们,那么 Python 新手往往会忽略内存优化。 这通常是完全可以接受的,但是当开发具有许多相关对象的大型应用时,关注内存问题可以带来巨大的回报。
flyweight 模式基本上确保了共享状态的对象可以为该共享状态使用相同的内存。 它通常仅在程序显示出内存问题后才实施。 在某些情况下,从一开始就设计最佳配置可能很有意义,但请记住,过早的优化是创建过于复杂而难以维护的程序的最有效方法。
让我们看一下 flyweight 模式的 UML 图:
每个 Flyweight 都没有没有特定状态; 每当需要在 SpecificState 上执行操作时,该状态就需要通过调用代码传递到 Flyweight 中。 传统上,返回轻量级的工厂是一个单独的对象。 它的目的是为给定密钥返回一个飞行重量,以标识该飞行重量。 它的工作方式类似于我们在第 10 章,“Python 设计模式 I” 中讨论的单例模式; 如果存在飞重,我们将其退还; 否则,我们将创建一个新的。 在许多语言中,工厂不是作为单独的对象而是作为Flyweight类本身的静态方法实现的。
考虑一下汽车销售的库存系统。 每辆汽车都有特定的序列号和特定的颜色。 但是,关于该车的大多数细节对于特定型号的所有车都是相同的。 例如,本田飞度 DX 车型是功能不多的裸车。 LX 型号具有 A / C,倾斜,巡航,电动窗和锁。 Sport 车型配备花式轮毂,USB 充电器和扰流板。 如果没有飞重模式,则每个单独的汽车对象都必须存储一长串它具有和不具有的功能。 考虑到本田一年销售的汽车数量,这将增加大量的内存浪费。 使用 flyweight 模式,我们可以使共享对象成为与模型关联的功能列表,然后简单地为单个车辆引用该模型以及序列号和颜色。 在 Python 中,flyweight 工厂通常使用时髦的__new__构造函数来实现,类似于我们对 singleton 模式所做的事情。 与 Singleton 不同,后者仅需要返回该类的一个实例,而我们需要能够根据键返回不同的实例。 我们可以将项目存储在字典中,然后根据关键字进行查找。 但是,这种解决方案是有问题的,因为只要该项在字典中,它就会一直保留在内存中。 如果我们售罄 LX 型号 Fits,则不再需要 Fit 配重,但仍将在字典中。 当然,每当我们卖出一辆车时,我们都可以清理一下,但这不是垃圾收集器的作用吗?
我们可以利用 Python 的weakref模块来解决此问题。 这个模块提供了一个WeakValueDictionary对象,该对象基本上允许我们将项目存储在字典中,而无需垃圾收集器关心它们。 如果某个值位于弱引用的字典中,并且在应用中的任何地方都没有存储对该对象的其他引用(也就是说,我们售罄了 LX 模型),那么垃圾收集器最终将为我们清理数据。
让我们首先为我们的汽车轻量化建立工厂:
import weakref
class CarModel:
_models = weakref.WeakValueDictionary()
def __new__(cls, model_name, *args, **kwargs):
model = cls._models.get(model_name)
if not model:
model = super().__new__(cls)
cls._models[model_name] = model
return model
基本上,每当我们使用给定名称构造一个新的 flyweight 时,我们首先会在弱引用字典中查找该名称; 如果存在,我们返回该模型; 如果没有,我们创建一个新的。 无论哪种方式,我们都知道每次都会调用 flyweight 上的__init__方法,无论它是新对象还是现有对象。 因此,我们的__init__方法如下所示:
def __init__(self, model_name, air=False, tilt=False,
cruise_control=False, power_locks=False,
alloy_wheels=False, usb_charger=False):
if not hasattr(self, "initted"):
self.model_name = model_name
self.air = air
self.tilt = tilt
self.cruise_control = cruise_control
self.power_locks = power_locks
self.alloy_wheels = alloy_wheels
self.usb_charger = usb_charger
self.initted=True
if语句确保我们仅在首次调用__init__时初始化对象。 这意味着我们稍后可以仅使用模型名称来调用工厂,并返回相同的 flyweight 对象。 但是,由于如果没有外部引用来控制 flyweight,则必须对其进行垃圾回收,因此我们必须注意不要意外创建具有空值的新 flyweight。
让我们在飞行重量中添加一种方法,该方法假设在特定型号的车辆上查找序列号,并确定该序列号是否涉及任何事故。 此方法需要访问汽车的序列号,每个序列号各不相同; 它不能与飞行重量一起存储。 因此,此数据必须通过调用代码传递到方法中:
def check_serial(self, serial_number):
print("Sorry, we are unable to check "
"the serial number {0} on the {1} "
"at this time".format(
serial_number, self.model_name))
我们可以定义一个类,该类存储附加信息以及对 flyweight 的引用:
class Car:
def __init__(self, model, color, serial):
self.model = model
self.color = color
self.serial = serial
def check_serial(self):
return self.model.check_serial(self.serial)
我们还可以跟踪可用模型以及批次中的各个汽车:
>>> dx = CarModel("FIT DX")
>>> lx = CarModel("FIT LX", air=True, cruise_control=True,
... power_locks=True, tilt=True)
>>> car1 = Car(dx, "blue", "12345")
>>> car2 = Car(dx, "black", "12346")
>>> car3 = Car(lx, "red", "12347")
现在,让我们演示工作中的弱引用:
>>> id(lx)
3071620300
>>> del lx
>>> del car3
>>> import gc
>>> gc.collect()
0
>>> lx = CarModel("FIT LX", air=True, cruise_control=True,
... power_locks=True, tilt=True)
>>> id(lx)
3071576140
>>> lx = CarModel("FIT LX")
>>> id(lx)
3071576140
>>> lx.air
True
id函数告诉我们对象的唯一标识符。 当我们第二次调用它时,在删除所有对 LX 模型的引用并强制进行垃圾回收之后,我们看到 ID 已更改。 CarModel __new__工厂字典中的值已删除,并创建了一个新值。 但是,如果我们然后尝试构造第二个CarModel实例,它将返回相同的对象(ID 相同),并且即使我们在第二个调用中未提供任何参数,air变量仍 设置为True。 这意味着对象没有像我们设计的那样第二次初始化。
显然,使用 flyweight 模式可能比仅将特征存储在单个汽车类上更为复杂。 我们什么时候应该选择使用它? 轻量级模式旨在节省内存; 如果我们有成千上万个相似的对象,则将相似的属性组合成一个 flyweight 会对内存消耗产生巨大影响。 优化 CPU,内存或磁盘空间的编程解决方案通常比未优化的同类导致更复杂的代码。 因此,在决定代码的可维护性与优化之间权衡取舍很重要。 在选择优化时,请尝试使用诸如 flyweight 之类的模式,以确保优化引入的复杂性仅限于代码的单个(详细记录)部分。
命令模式
命令模式通常在以后的时间中,在必须执行的动作与调用这些动作的对象之间增加了一个抽象级别。 在命令模式中,客户端代码创建一个Command对象,该对象可以在以后执行。 该对象知道一个接收者对象,当在其上执行命令时,该对象将管理自己的内部状态。 Command对象实现特定的接口(通常具有execute或do_action方法,并跟踪执行该操作所需的任何参数。最后,一个或多个Invoker对象在该对象处执行命令)。 正确的时间。
这是 UML 图:
命令模式的常见示例是图形窗口上的操作。 通常,可以通过菜单栏上的菜单项,键盘快捷键,工具栏图标或上下文菜单来调用动作。 这些都是Invoker对象的示例。 实际发生的动作,例如Exit,Save或Copy是CommandInterface的实现。 可能会显示Receivers的示例包括一个用于接收退出的 GUI 窗口,一个用于接收保存的文档以及一个用于接收复制命令的ClipboardManager。
让我们实现一个简单的命令模式,该模式为Save和Exit操作提供命令。 我们将从一些适度的接收器类开始:
import sys
class Window:
def exit(self):
sys.exit(0)
class Document:
def __init__(self, filename):
self.filename = filename
self.contents = "This file cannot be modified"
def save(self):
with open(self.filename, 'w') as file:
file.write(self.contents)
这些模拟类为在工作环境中可能会做更多工作的对象建模。 该窗口将需要处理鼠标移动和键盘事件,而文档将需要处理字符的插入,删除和选择。 但是对于我们的示例,这两个类将满足我们的需求。
现在让我们定义一些调用程序类。 这些将对可能发生的工具栏,菜单和键盘事件进行建模。 同样,它们实际上并没有连接任何东西,但是我们可以看到如何将它们与命令,接收器和客户端代码分离:
class ToolbarButton:
def __init__(self, name, iconname):
self.name = name
self.iconname = iconname
def click(self):
self.command.execute()
class MenuItem:
def __init__(self, menu_name, menuitem_name):
self.menu = menu_name
self.item = menuitem_name
def click(self):
self.command.execute()
class KeyboardShortcut:
def __init__(self, key, modifier):
self.key = key
self.modifier = modifier
def keypress(self):
self.command.execute()
注意各种操作方法如何分别在各自的命令上调用execute方法吗? 此代码未显示在每个对象上设置的command属性。 可以将它们传递到__init__函数中,但是由于可以更改它们(例如,使用可自定义的键绑定编辑器),因此以后在对象上设置属性更加有意义。
现在,让我们自己连接命令:
class SaveCommand:
def __init__(self, document):
self.document = document
def execute(self):
self.document.save()
class ExitCommand:
def __init__(self, window):
self.window = window
def execute(self):
self.window.exit()
这些命令很简单; 它们演示了基本模式,但是必须注意,必要时我们可以使用命令存储状态和其他信息。 例如,如果我们有一个插入字符的命令,我们可以保持当前插入字符的状态。
现在,我们要做的就是连接一些客户端和测试代码以使命令起作用。 对于基本测试,我们可以在脚本末尾添加以下内容:
window = Window()
document = Document("a_document.txt")
save = SaveCommand(document)
exit = ExitCommand(window)
save_button = ToolbarButton('save', 'save.png')
save_button.command = save
save_keystroke = KeyboardShortcut("s", "ctrl")
save_keystroke.command = save
exit_menu = MenuItem("File", "Exit")
exit_menu.command = exit
首先,我们创建两个接收器和两个命令。 然后,我们创建几个可用的调用程序,并在每个调用程序上设置正确的命令。 为了进行测试,我们可以使用python3 -i filename.py并运行exit_menu.click()这样的代码,该代码将结束程序,或者运行save_keystroke.keystroke()这样的代码,该代码将保存虚假文件。
不幸的是,前面的示例并没有让人感觉很 Python 化。 他们有很多“样板代码”(什么都不做,只为模式提供结构的代码),并且Command类彼此之间都非常相似。 也许我们可以创建一个将函数作为回调的通用命令对象?
其实为什么要打扰? 我们可以仅对每个命令使用函数或方法对象吗? 代替使用execute()方法的对象,我们可以编写一个函数并将其直接用作命令。 这是 Python 中命令模式的常见范例:
import sys
class Window:
def exit(self):
sys.exit(0)
class MenuItem:
def click(self):
self.command()
window = Window()
menu_item = MenuItem()
menu_item.command = window.exit
现在看起来更像 Python。 乍一看,好像我们已经完全删除了命令模式,并且我们已经紧密连接了menu_item和Window类。 但是,如果我们仔细观察,就会发现根本没有紧密的耦合。 像以前一样,可以在MenuItem上将任何可调用对象设置为命令。 并且Window.exit方法可以附加到任何调用程序。 命令模式的大多数灵活性都得到了保留。 为了牺牲可读性,我们已经牺牲了完全的去耦,但是在我看来,与许多 Python 程序员相比,此代码比完全抽象的版本更具可维护性。
当然,由于我们可以向任何对象添加__call__方法,因此我们不仅限于函数。 当被调用的方法不必维护状态时,上一个示例是一个有用的快捷方式,但是在更高级的用法中,我们也可以使用以下代码:
class Document:
def __init__(self, filename):
self.filename = filename
self.contents = "This file cannot be modified"
def save(self):
with open(self.filename, 'w') as file:
file.write(self.contents)
class KeyboardShortcut:
def keypress(self):
self.command()
class SaveCommand:
def __init__(self, document):
self.document = document
def __call__(self):
self.document.save()
document = Document("a_file.txt")
shortcut = KeyboardShortcut()
save_command = SaveCommand(document)
shortcut.command = save_command
在中,我们有一些看起来像第一个命令模式,但有点惯用语。 如您所见,使用 execute 方法使调用者调用可调用对象而不是命令对象,并没有以任何方式限制我们。 实际上,它给了我们更多的灵活性。 我们可以在函数正常工作时直接链接到函数,但是当情况需要时,我们可以构建一个完整的可调用命令对象。
命令模式通常被扩展为支持不可撤销的命令。 例如,文本程序不仅可以使用execute方法将每个插入内容包装在单独的命令中,还可以使用undo方法来删除该插入内容。 图形程序可以在具有undo方法的命令中包装每个绘图动作(矩形,直线,手绘像素等),该方法会将像素重置为其原始状态。 在这种情况下,命令模式的解耦更加有用,因为每个动作必须保持足够的状态才能在以后撤消该动作。
抽象工厂模式
当我们有多个可能依赖于某些配置或平台问题的系统实现时,通常使用抽象工厂模式。 调用代码从抽象工厂请求一个对象,而不确切知道将返回什么类的对象。 返回的基础实现可能取决于多种因素,例如当前的语言环境,操作系统或本地配置。
抽象工厂模式的常见示例包括独立于操作系统的工具包,数据库后端以及特定于国家/地区的格式化程序或计算器的代码。 独立于操作系统的 GUI 工具包可能使用抽象的工厂模式,该模式将返回 Windows 下的 WinForm 小部件,Mac 下的 Cocoa 小部件,Gnome 下的 GTK 小部件以及 KDE 下的 QT 小部件。 Django 提供了一个抽象工厂,该工厂根据当前站点的配置设置返回一组对象关系类,以与特定的数据库后端(MySQL,PostgreSQL,SQLite 等)进行交互。 如果应用需要部署在多个地方,则每个人可以通过仅更改一个配置变量来使用不同的数据库后端。 不同的国家/地区使用不同的系统来计算零售商品的税金,小计和总计; 抽象工厂可以返回特定的税收计算对象。
没有特定的示例,很难理解用于抽象工厂模式的 UML 类图,因此让我们先回过头来创建一个具体的示例。 我们将创建一组格式化程序,这些格式化程序取决于特定的语言环境,并帮助我们格式化日期和货币。 将有一个抽象工厂类来选择特定工厂,以及几个示例混凝土工厂,一个用于法国,一个用于美国。 这些中的每一个都会为日期和时间创建格式化程序对象,可以查询这些对象以格式化特定的值。 如下图:
将该图像与较早的较简单文本进行比较,可以发现一张图片并不总是值一千个字,尤其是考虑到我们甚至在这里甚至都不允许使用工厂选择代码。
当然,在 Python 中,我们无需实现任何接口类,因此可以丢弃DateFormatter,CurrencyFormatter和FormatterFactory。 格式类本身非常简单,如果冗长的话:
class FranceDateFormatter:
def format_date(self, y, m, d):
y, m, d = (str(x) for x in (y,m,d))
y = '20' + y if len(y) == 2 else y
m = '0' + m if len(m) == 1 else m
d = '0' + d if len(d) == 1 else d
return("{0}/{1}/{2}".format(d,m,y))
class USADateFormatter:
def format_date(self, y, m, d):
y, m, d = (str(x) for x in (y,m,d))
y = '20' + y if len(y) == 2 else y
m = '0' + m if len(m) == 1 else m
d = '0' + d if len(d) == 1 else d
return("{0}-{1}-{2}".format(m,d,y))
class FranceCurrencyFormatter:
def format_currency(self, base, cents):
base, cents = (str(x) for x in (base, cents))
if len(cents) == 0:
cents = '00'
elif len(cents) == 1:
cents = '0' + cents
digits = []
for i,c in enumerate(reversed(base)):
if i and not i % 3:
digits.append(' ')
digits.append(c)
base = ''.join(reversed(digits))
return "{0}€{1}".format(base, cents)
class USACurrencyFormatter:
def format_currency(self, base, cents):
base, cents = (str(x) for x in (base, cents))
if len(cents) == 0:
cents = '00'
elif len(cents) == 1:
cents = '0' + cents
digits = []
for i,c in enumerate(reversed(base)):
if i and not i % 3:
digits.append(',')
digits.append(c)
base = ''.join(reversed(digits))
return "${0}.{1}".format(base, cents)
这些类使用一些基本的字符串操作来尝试将各种可能的输入(整数,不同长度的字符串等)转换为以下格式:
| |美国
|
法国
| | --- | --- | --- | | 日期 | mm-dd-yyyy | dd / mm / yyyy | | 货币 | $14,500.50 | 14 500€50 |
可能显然会在此代码中的输入上得到更多验证,但在此示例中,让我们保持其简单明了。
现在我们已经设置了格式化程序,我们只需要创建格式化程序工厂即可:
class USAFormatterFactory:
def create_date_formatter(self):
return USADateFormatter()
def create_currency_formatter(self):
return USACurrencyFormatter()
class FranceFormatterFactory:
def create_date_formatter(self):
return FranceDateFormatter()
def create_currency_formatter(self):
return FranceCurrencyFormatter()
现在,我们设置了选择适当格式器的代码。 由于这种事情只需要设置一次,因此我们可以将其设置为单例-除非单例在 Python 中不是很有用。 让我们仅将当前格式化程序设为模块级变量:
country_code = "US"
factory_map = {
"US": USAFormatterFactory,
"FR": FranceFormatterFactory}
formatter_factory = factory_map.get(country_code)()
在此示例中,我们对当前国家/地区代码进行了硬编码。 实际上,它可能会自省区域设置,操作系统或配置文件以选择代码。 本示例使用字典将国家/地区代码与工厂类别相关联。 然后我们从字典中获取正确的类并实例化它。
当我们想增加对更多国家的支持时,很容易看出需要做什么:创建新的格式化程序类和抽象工厂本身。 请记住,Formatter类可能会被重用; 例如,加拿大的货币格式与美国相同,但其日期格式比其南部邻国更明智。
抽象工厂通常返回一个单例对象,但这不是必需的。 在我们的代码中,每次调用时都会返回每个格式化程序的新实例。 没有理由不能将格式化程序存储为实例变量,并且为每个工厂返回相同的实例。
回顾这些示例,我们再次看到,似乎有很多工厂的样板代码,在 Python 中根本不需要。 通常,通过为每种工厂类型(例如:美国和法国)使用单独的模块,然后确保在工厂模块中访问正确的模块,可以更轻松地满足可能需要抽象工厂的要求。 此类模块的软件包结构可能如下所示:
localize/
__init__.py
backends/
__init__.py
USA.py
France.py
…
诀窍在于,localize软件包中的__init__.py可以包含将所有请求重定向到正确后端的逻辑。 有很多种方法可以做到这一点。
如果我们知道后端永远不会动态更改(即不重新启动),则可以在__init__.py中放置一些if语句来检查当前国家/地区代码,并使用通常不可接受的from .backends.USA import *语法 从适当的后端导入所有变量。 或者,我们可以导入每个后端并设置current_backend变量以指向特定模块:
from .backends import USA, France
if country_code == "US":
current_backend = USA
根据我们选择的解决方案,我们的客户代码必须调用localize.format_date或localize.current_backend.format_date才能以当前国家/地区的语言环境设置日期格式。 最终结果比原始的抽象工厂模式具有更多的 Python 风格,并且在典型用法中同样灵活。
复合图案
复合模式允许从简单的组件构建复杂的树状结构。 这些组件称为复合对象,它们的行为类似于容器,而类似于变量,取决于它们是否具有子组件。 复合对象是容器对象,其中内容实际上可能是另一个复合对象。
传统上,复合对象中的每个组件都必须是叶节点(不能包含其他对象)或复合节点。 关键是复合节点和叶节点都可以具有相同的接口。 UML 图非常简单:
但是,这种简单的模式允许我们创建元素的复杂排列,所有元素都满足组件对象的接口。 这是这种复杂安排的具体实例:
复合模式通常在类似文件/文件夹的树中有用。 无论树中的节点是普通文件还是文件夹,它都仍会受到诸如移动,复制或删除节点之类的操作的影响。 我们可以创建一个支持这些操作的组件接口,然后使用一个复合对象表示文件夹,并使用叶节点表示普通文件。
当然,在 Python 中,我们再次可以利用鸭子类型来隐式提供接口,因此我们只需要编写两个类。 让我们首先定义这些接口:
class Folder:
def __init__(self, name):
self.name = name
self.children = {}
def add_child(self, child):
pass
def move(self, new_path):
pass
def copy(self, new_path):
pass
def delete(self):
pass
class File:
def __init__(self, name, contents):
self.name = name
self.contents = contents
def move(self, new_path):
pass
def copy(self, new_path):
pass
def delete(self):
pass
对于每个文件夹(复合)对象,我们维护一个儿童字典。 通常,列表就足够了,但是在这种情况下,词典对于按名称查找孩子很有用。 我们的路径将被指定为由/字符分隔的节点名称,类似于 Unix shell 中的路径。
考虑到所涉及的方法,我们可以看到移动或删除节点的行为类似,无论它是文件节点还是文件夹节点。 但是,复制必须对文件夹节点进行递归复制,而复制文件节点则是微不足道的操作。
为了利用的类似操作优势,我们可以将一些常用方法提取到父类中。 让我们以废弃的Component接口并将其更改为基类为例:
class Component:
def __init__(self, name):
self.name = name
def move(self, new_path):
new_folder =get_path(new_path)
del self.parent.children[self.name]
new_folder.children[self.name] = self
self.parent = new_folder
def delete(self):
del self.parent.children[self.name]
class Folder(Component):
def __init__(self, name):
super().__init__(name)
self.children = {}
def add_child(self, child):
pass
def copy(self, new_path):
pass
class File(Component):
def __init__(self, name, contents):
super().__init__(name)
self.contents = contents
def copy(self, new_path):
pass
root = Folder('')
def get_path(path):
names = path.split('/')[1:]
node = root
for name in names:
node = node.children[name]
return node
在Component类上创建了,move和delete方法。 他们两个都访问我们尚未设置的神秘parent变量。 move方法使用模块级get_path函数,该函数从给定路径的预定义根节点中查找节点。 所有文件将被添加到该根节点或该节点的子节点。 对于move方法,目标应该是当前存在的文件夹,否则我们会收到错误消息。 与技术书籍中的许多示例一样,严重缺乏错误处理,以帮助重点关注所考虑的原理。
让我们先设置一个神秘的parent变量; 在文件夹的add_child方法中会发生这种情况:
def add_child(self, child):
child.parent = self
self.children[child.name] = child
好吧,这很容易。 让我们看看我们的复合文件层次结构是否正常工作:
$ python3 -i 1261_09_18_add_child.py
>>> folder1 = Folder('folder1')
>>> folder2 = Folder('folder2')
>>> root.add_child(folder1)
>>> root.add_child(folder2)
>>> folder11 = Folder('folder11')
>>> folder1.add_child(folder11)
>>> file111 = File('file111', 'contents')
>>> folder11.add_child(file111)
>>> file21 = File('file21', 'other contents')
>>> folder2.add_child(file21)
>>> folder2.children
{'file21': <__main__.File object at 0xb7220a4c>}
>>> folder2.move('/folder1/folder11')
>>> folder11.children
{'folder2': <__main__.Folder object at 0xb722080c>, 'file111': <__main__.File object at 0xb72209ec>}
>>> file21.move('/folder1')
>>> folder1.children
{'file21': <__main__.File object at 0xb7220a4c>, 'folder11': <__main__.Folder object at 0xb722084c>}
是的,我们可以创建文件夹,将文件夹添加到其他文件夹,将文件添加到文件夹,然后移动它们! 在文件层次结构中,我们还能要求什么?
好吧,我们可以要求复制,但是为了保护树木,让我们将其保留为练习。
复合模式对于多种树状结构非常有用,包括 GUI 小部件层次结构,文件层次结构,树集,图形和 HTML DOM。 如先前的示例所示,当按照传统实现来实现时,这可能是 Python 中有用的模式。 有时,如果仅创建一棵浅树,我们可以摆脱列表列表或词典字典,而无需实现自定义组件,叶和复合类。 在其他时候,我们可以只实现一个复合类,而将叶子和复合对象作为一个类来处理。 另外,只要它们具有正确的接口,Python 的鸭子类型可以轻松地将其他对象添加到复合层次结构中。
十三、测试面向对象的程序
熟练的 Python 程序员同意测试是软件开发最重要的方面之一。 即使本章位于模块的末尾,也不是事后的想法。 到目前为止,我们学习的所有内容都将在编写测试时为我们提供帮助。 我们将研究:
- 单元测试和测试驱动开发的重要性
- 标准
unittest模块 py.test自动化测试套件mock模块- 代码覆盖率
- 使用
tox进行跨平台测试
为什么要测试?
大量的程序员已经知道测试他们的代码有多重要。 如果您在其中,请随意浏览本节。 您会在下一节中看到更多令人眼花。乱的内容-我们实际上在其中看到了如何在 Python 中进行测试。 如果您不相信测试的重要性,那么我保证您的代码已损坏,您只是不知道。 继续阅读!
有人认为测试在 Python 代码中更为重要,因为它具有动态特性。 诸如 Java 和 C ++ 之类的编译语言有时被认为是“安全的”,因为它们在编译时会进行类型检查。 但是,Python 测试很少检查类型。 他们正在检查值。 他们要确保在正确的时间设置正确的属性,或确保序列具有正确的长度,顺序和值。 这些更高层次的内容需要使用任何语言进行测试。 Python 程序员比其他语言的程序员进行更多测试的真正原因是,使用 Python 进行测试是如此容易!
但是为什么要测试? 我们真的需要测试吗? 如果我们不测试怎么办? 要回答这些问题,请从头开始编写井字游戏,而无需进行任何测试。 在完全编写之前不要运行它,请开始完成。 如果您将两个玩家都变成了人类玩家(没有人工智能),则井字游戏的实施相当简单。 您甚至不必尝试计算谁是赢家。 现在运行您的程序。 并修复所有错误。 那里有几个? 我在井字游戏实现中录制了八个,但不确定是否全部抓住了。 你是否?
我们需要测试我们的代码以确保它可以工作。 像我们刚才那样运行程序,并修复错误是测试的一种粗略形式。 Python 程序员能够编写几行代码并运行该程序,以确保这些行符合他们的期望。 但是更改几行代码会影响开发人员未意识到的程序部分,这些部分会受到更改的影响,因此不会对其进行测试。 此外,随着程序的增长,解释器可以通过该代码采用的各种路径也随之增长,并且迅速无法手动测试所有路径。
为了解决这个问题,我们编写了自动化测试。 这些是通过其他程序或程序的一部分自动运行某些输入的程序。 我们可以在几秒钟内运行这些测试程序,并涵盖了比每个程序员每次更改时都认为要测试的输入情况更多的输入情况。
编写测试的主要原因有四个:
- 确保代码按开发人员认为的方式工作
- 为了确保代码在我们进行更改时能够继续工作
- 确保开发人员了解要求
- 为了确保我们正在编写的代码具有可维护的接口
第一点实际上并不能证明编写测试所花的时间。 我们可以直接在交互式解释器中直接测试代码。 但是,当我们必须多次执行相同的测试操作序列时,只需较少的时间就可以自动执行这些步骤,然后在必要时运行它们。 无论何时在初始开发或维护版本中更改代码,最好都运行测试。 当我们拥有一套全面的自动化测试时,我们可以在代码更改后运行它们,并且知道我们没有无意间破坏了所有测试内容。
最后两个点更有趣。 当我们编写代码测试时,它可以帮助我们设计代码所需要的 API,接口或模式。 因此,如果我们误解了需求,编写测试可以帮助突出这种误解。 另一方面,如果我们不确定如何设计一个类,可以编写一个与该类交互的测试,这样我们就可以知道最自然的测试方法。 实际上,在编写要测试的代码之前编写测试通常是有益的。
测试驱动的开发
“首先编写测试”是测试驱动开发的口头禅。 测试驱动的开发将“未经测试的代码就是破坏的代码”的概念进一步向前发展,并建议只应对未经编写的代码进行未经测试的测试。 在为该代码编写测试之前,请勿编写任何代码。 因此,第一步是编写一个测试以证明代码可以工作。 显然,由于尚未编写代码,因此测试将失败。 然后编写确保测试通过的代码。 然后为下一段代码编写另一个测试。
测试驱动的开发很有趣。 它使我们可以解决一些难题。 然后,我们实现代码以解决难题。 然后,我们制作了一个更复杂的难题,并编写了解决新难题而又不解决前一个难题的代码。
测试驱动的方法有两个目标。 首先是要确保测试确实得到编写。 在我们编写代码之后,这样非常容易地说:“嗯,它似乎可以工作。我不必为此编写任何测试。这只是一个很小的变化,没有什么坏的。” 如果在编写代码之前已经编写了测试,那么我们将确切知道它的工作时间(因为测试将通过),并且将来我们会知道它是否被我们或其他人所做的更改破坏了 。
其次,编写测试首先迫使我们仔细考虑代码将如何与之交互。 它告诉我们对象需要具有哪些方法以及如何访问属性。 它帮助我们将最初的问题分解为较小的,可测试的问题,然后将经过测试的解决方案重新组合为也经过测试的较大的解决方案。 因此,编写测试可以成为设计过程的一部分。 通常,如果我们正在为新对象编写测试,则会发现设计中的异常迫使我们考虑软件的新方面。
作为一个具体示例,想象一下编写使用对象关系映射器将对象属性存储在数据库中的代码。 在此类对象中通常使用自动分配的数据库 ID。 我们的代码可能出于各种目的使用此 ID。 如果我们正在编写针对此类代码的测试,则在编写之前,我们可能会意识到我们的设计有问题,因为对象只有在将它们保存到数据库后才具有这些 ID。 如果我们想操作一个对象而不将其保存在测试中,则在基于错误前提编写代码之前,它将突出显示此问题。
测试使软件更好。 在我们发布软件之前编写测试可以使最终用户更好地了解或购买有缺陷的版本(我曾在那些依靠“用户可以测试”这一理念而 thr 壮成长的公司工作。这不是一个健康的商业模型!)。 在我们编写软件之前编写测试可以使它在首次编写时变得更好。
单元测试
让我们从 Python 的内置测试库开始探索。 该库为单元测试提供了通用接口。 单元测试专注于测试任何一种测试中可能的最少代码量。 每个测试仅测试可用代码总量的一个单位。
毫不奇怪,为此,Python 库称为unittest。 它提供了一些用于创建和运行单元测试的工具,其中最重要的是TestCase类。 此类提供了一组方法,使我们可以比较值,设置测试以及在完成后进行清理。
当我们要为特定任务编写一组单元测试时,我们创建TestCase的子类,并编写各个方法来进行实际测试。 这些方法都必须以名称test开头。 遵循此约定后,测试将作为测试过程的一部分自动运行。 通常,测试会在一个对象上设置一些值,然后运行一个方法,并使用内置的比较方法来确保计算出正确的结果。 这是一个非常简单的示例:
import unittest
class CheckNumbers(unittest.TestCase):
def test_int_float(self):
self.assertEqual(1, 1.0)
if __name__ == "__main__":
unittest.main()
此代码只是TestCase类的子类,并添加了一个调用TestCase.assertEqual方法的方法。 此方法将成功还是引发异常,具体取决于两个参数是否相等。 如果运行此代码,则unittest中的main函数将为我们提供以下输出:
.
--------------------------------------------------------------
Ran 1 test in 0.000s
OK
您知道浮点数和整数可以相等吗? 让我们添加一个失败的测试:
def test_str_float(self):
self.assertEqual(1, "1")
此代码的输出更加险恶,因为整数和字符串不视为相等:
.F
============================================================
FAIL: test_str_float (__main__.CheckNumbers)
--------------------------------------------------------------
Traceback (most recent call last):
File "simplest_unittest.py", line 8, in test_str_float
self.assertEqual(1, "1")
AssertionError: 1 != '1'
--------------------------------------------------------------
Ran 2 tests in 0.001s
FAILED (failures=1)
第一行上的点表示第一个测试(我们之前编写的测试)成功通过; 后面的字母F表明第二次测试失败。 然后,最后,它提供了一些信息输出,告诉我们测试失败的方式和地点,以及失败次数的摘要。
我们可以根据需要在一个TestCase类上拥有尽可能多的测试方法。 只要方法名称以test开头,测试运行程序将执行每个测试作为单独的测试。 每个测试应完全独立于其他测试。 先前测试的结果或计算对当前测试没有影响。 编写好的单元测试的关键是使每种测试方法都尽可能短,并在每个测试用例中测试一小部分代码。 如果您的代码似乎没有自然地分解成这样的可测试单元,则可能表明您的设计需要重新考虑。
断言方法
测试用例的总体布局是将某些变量设置为已知值,运行一个或多个函数,方法或过程,然后“证明”使用TestCase返回或计算出正确的预期结果 ]断言方法。
有几种不同的断言方法可用于确认已实现特定结果。 我们刚刚看到assertEqual,如果两个参数未通过相等性检查,将导致测试失败。 如果两个参数确实比较相等,则assertNotEqual相反。 assertTrue和assertFalse方法均接受单个表达式,并且如果该表达式未通过if测试,则会失败。 这些测试不检查布尔值True或False。 相反,它们测试的条件与使用if语句的条件相同:False,None,0或空列表,字典,字符串,集合或元组都会将调用传递给assertFalse方法,而非零数字,其中包含值的容器或值True在调用assertTrue方法时将成功。
assertRaises方法可用于确保特定的函数调用引发特定的异常,或者可以选择将其用作上下文管理器来包装内联代码。 如果with语句中的代码引发适当的异常,则测试通过。 否则,它将失败。 这是两个版本的示例:
import unittest
def average(seq):
return sum(seq) / len(seq)
class TestAverage(unittest.TestCase):
def test_zero(self):
self.assertRaises(ZeroDivisionError,
average,
[])
def test_with_zero(self):
with self.assertRaises(ZeroDivisionError):
average([])
if __name__ == "__main__":
unittest.main()
上下文管理器允许我们以正常编写代码的方式编写代码(通过调用函数或直接执行代码),而不必将函数调用包装在另一个函数调用中。
下表总结了其他几种断言方法:
|方法
|
描述
|
| --- | --- |
| assertGreater``assertGreaterEqual``assertLess``assertLessEqual | 接受两个可比较的对象,并确保指定的不等式成立。 |
| assertIn``assertNotIn | 确保元素是(或不是)容器对象中的元素。 |
| assertIsNone``assertIsNotNone | 确保一个元素是(或不是)确切的值None(但不是另一个假值)。 |
| assertSameElements | 确保两个容器对象具有相同的元素,忽略顺序。 |
| assertSequenceEqualassertDictEqual``assertSetEqual``assertListEqual``assertTupleEqual | 确保两个容器以相同的顺序具有相同的元素。 如果出现故障,请显示代码差异比较两个列表,以查看它们之间的区别。 最后四种方法还可以测试列表的类型。 |
每个断言方法都接受一个名为msg.的可选参数(如果提供),如果断言失败,它将包含在错误消息中。 这对于阐明期望的结果或解释可能在何处发生错误以导致断言失败的位置很有用。
减少样板并清理
在编写了一些小测试之后,我们经常发现我们必须对几个相关测试执行相同的设置代码。 例如,以下list子类具有三种用于统计计算的方法:
from collections import defaultdict
class StatsList(list):
def mean(self):
return sum(self) / len(self)
def median(self):
if len(self) % 2:
return self[int(len(self) / 2)]
else:
idx = int(len(self) / 2)
return (self[idx] + self[idx-1]) / 2
def mode(self):
freqs = defaultdict(int)
for item in self:
freqs[item] += 1
mode_freq = max(freqs.values())
modes = []
for item, value in freqs.items():
if value == mode_freq:
modes.append(item)
return modes
显然,我们将要使用这三种输入非常相似的方法来测试情况。 我们将想看看空列表,包含非数字值的列表或包含普通数据集的列表会发生什么。 我们可以使用TestCase类上的setUp方法对每个测试进行初始化。 此方法不接受任何参数,并允许我们在运行每个测试之前进行任意设置。 例如,我们可以在整数相同的列表上测试所有三种方法,如下所示:
from stats import StatsList
import unittest
class TestValidInputs(unittest.TestCase):
def setUp(self):
self.stats = StatsList([1,2,2,3,3,4])
def test_mean(self):
self.assertEqual(self.stats.mean(), 2.5)
def test_median(self):
self.assertEqual(self.stats.median(), 2.5)
self.stats.append(4)
self.assertEqual(self.stats.median(), 3)
def test_mode(self):
self.assertEqual(self.stats.mode(), [2,3])
self.stats.remove(2)
self.assertEqual(self.stats.mode(), [3])
if __name__ == "__main__":
unittest.main()
如果我们运行,则表明所有测试均通过。 首先请注意,从未在三个test_*方法中显式调用setUp方法。 测试套件代表我们执行此操作。 更重要的是,请注意test_median如何通过向列表添加附加的4来更改列表,但是当调用test_mode时,列表已返回setUp中指定的值(如果没有,则将返回 列表中的两个四分之一,mode方法将返回三个值)。 这表明setUp在每次测试之前被单独调用,以确保测试类以干净的状态开始。 测试可以以任何顺序执行,并且一个测试的结果不应依赖于任何其他测试。
除了setUp方法之外,TestCase还提供了无参数的tearDown方法,该方法可用于在运行该类的每个测试之后清除。 如果清理除了让对象被垃圾回收外还需要其他任何操作,这将很有用。 例如,如果我们正在测试执行文件 I / O 的代码,则我们的测试可能会创建新文件,这是测试的一个副作用。 tearDown方法可以删除这些文件,并确保系统与测试运行之前的状态相同。 测试用例永远都不应有副作用。 通常,我们根据测试方法的共同设置代码将测试方法分组为单独的TestCase子类。 几项要求相同或相似设置的测试将被放在一个类中,而要求不相关设置的测试将被放在另一类中。
组织和运行测试
单元测试的集合不需要很大的时间即可变得非常庞大且笨拙。 一次加载和运行所有测试很快变得很复杂。 这是单元测试的主要目标。 在我们的程序上运行所有测试并迅速回答“我最近的更改是否破坏了任何现有测试?”这个问题的答案很简单。
Python 的discover模块基本上在当前文件夹或子文件夹中查找名称以字符test开头的任何模块。 如果在这些模块的中找到任何TestCase对象,则将执行测试。 这是确保我们不会错过任何测试的无痛方法。 要使用它,请确保您的测试模块名为test_<something>.py,然后运行命令python3 -m unittest discover。
忽略测试失败
有时,已知测试失败,但是我们不希望测试套件报告失败。 这可能是因为已损坏或未完成的功能已编写了测试,但是我们目前并未专注于改进它。 之所以会发生这种情况,是因为某个功能仅在特定平台,Python 版本或特定库的高级版本上可用。 Python 为我们提供了一些装饰器,以将测试标记为预期失败或在已知条件下被跳过。
装饰者是:
expectedFailure()skip(reason)skipIf(condition, reason)skipUnless(condition, reason)
这些是使用 Python 装饰器语法应用的。 第一个不接受任何参数,只是告诉测试运行器在失败时不要将测试记录为失败。 skip方法更进一步,甚至不必费心进行测试。 它期望一个字符串参数描述为什么跳过测试。 其他两个装饰器接受两个参数,一个是指示是否应运行测试的布尔表达式,另一个是类似的描述。 在使用中,这三个装饰器可能像这样应用:
import unittest
import sys
class SkipTests(unittest.TestCase):
@unittest.expectedFailure
def test_fails(self):
self.assertEqual(False, True)
@unittest.skip("Test is useless")
def test_skip(self):
self.assertEqual(False, True)
@unittest.skipIf(sys.version_info.minor == 4,
"broken on 3.4")
def test_skipif(self):
self.assertEqual(False, True)
@unittest.skipUnless(sys.platform.startswith('linux'),
"broken unless on linux")
def test_skipunless(self):
self.assertEqual(False, True)
if __name__ == "__main__":
unittest.main()
第一次测试失败,但报告为预期失败; 第二项测试永远不会运行。 其他两个测试可能会运行,也可能不会运行,具体取决于当前的 Python 版本和操作系统。 在运行 Python 3.4 的 Linux 系统上,输出如下所示:
xssF
=============================================================
FAIL: test_skipunless (__main__.SkipTests)
--------------------------------------------------------------
Traceback (most recent call last):
File "skipping_tests.py", line 21, in test_skipunless
self.assertEqual(False, True)
AssertionError: False != True
--------------------------------------------------------------
Ran 4 tests in 0.001s
FAILED (failures=1, skipped=2, expected failures=1)
第一行的x表示预期失败; 这两个s字符表示已跳过测试,F表示实际失败,因为在我的系统上skipUnless的条件为True。
使用 py.test 进行测试
Python unittest模块需要大量样板代码来设置和初始化测试。 它基于非常流行的 Java JUnit 测试框架。 它甚至使用相同的方法名称(您可能已经注意到它们不符合 PEP-8 命名标准,该标准建议使用下划线而不是 CamelCase 来分隔方法名称中的单词)和测试布局。 尽管这对于用 Java 进行测试是有效的,但不一定是进行 Python 测试的最佳设计。
由于 Python 程序员喜欢其代码简洁明了,因此已经开发了标准库之外的其他测试框架。 最受欢迎的两个是py.test和nose。 前者更强大,并且对 Python 3 的支持时间更长,因此我们将在这里讨论。
由于py.test不属于标准库,因此您需要自己下载并安装; 您可以从 PyTest 主页获取。 该网站提供了适用于各种解释器和平台的全面安装说明,但通常可以使用更通用的 python 软件包安装程序 pip。 只需在命令行中输入pip install pytest,您就可以顺利进行了。
py.test具有与unittest模块完全不同的布局。 它不需要测试用例是类。 相反,它利用了 Python 函数是对象这一事实,并允许任何正确命名的函数表现得像测试一样。 它没有提供一堆用于声明相等性的自定义方法,而是使用assert语句来验证结果。 这使测试更具可读性和可维护性。 当我们运行py.test时,它将在当前文件夹中启动,并在该文件夹或子包中搜索名称以字符test_开头的任何模块。 如果此模块中的任何功能也以test开头,它们将作为单独的测试执行。 此外,如果模块中有任何名称以Test开头的类,则该类上以test_开头的任何方法也将在测试环境中执行。
让我们移植一下我们先前写给py.test的最简单的unittest示例:
def test_int_float():
assert 1 == 1.0
对于完全相同的测试,与第一个unittest示例中的六行相比,我们编写了两行更具可读性的代码。
但是,我们不禁止编写基于类的测试。 类对于将相关测试分组在一起或对需要访问该类上相关属性或方法的测试很有用。 此示例显示了具有通过测试和失败测试的扩展类; 我们将看到错误输出比unittest模块提供的错误输出更全面:
class TestNumbers:
def test_int_float(self):
assert 1 == 1.0
def test_int_str(self):
assert 1 == "1"
请注意,该类无需扩展任何特殊对象即可作为测试(尽管py.test可以正常运行标准unittest TestCases)。 如果运行py.test <filename>,则输出如下所示:
============== test session starts ==============
python: platform linux2 -- Python 3.4.1 -- pytest-2.6.4
test object 1: class_pytest.py
class_pytest.py .F
=================== FAILURES====================
___________ TestNumbers.test_int_str ____________
self = <class_pytest.TestNumbers object at 0x85b4fac>
def test_int_str(self):
> assert 1 == "1"
E assert 1 == '1'
class_pytest.py:7: AssertionError
====== 1 failed, 1 passed in 0.10 seconds =======
输出从有关平台和解释器的一些有用信息开始。 这对于在不同系统之间共享错误很有用。 第三行告诉我们要测试的文件的名称(如果拾取了多个测试模块,它们将全部显示),然后是我们在unittest模块中看到的熟悉的.F; .字符表示通过测试,而字母F表示失败。
运行所有测试后,将显示每个测试的错误输出。 它提供了局部变量的摘要(此示例中只有一个:self参数传递到函数中),发生错误的源代码以及错误消息的摘要。 另外,如果引发了AssertionError以外的异常,py.test将向我们提供完整的追溯,包括源代码引用。
默认情况下,如果测试成功,则py.test抑制print语句的输出。 这对于测试调试很有用; 当测试失败时,我们可以在测试中添加print语句,以在测试运行时检查特定变量和属性的值。 如果测试失败,则输出这些值以帮助诊断。 但是,一旦测试成功,就不会显示print语句输出,并且可以很容易地忽略它们。 我们不必通过删除print语句来“清理”输出。 如果测试由于将来的更改而再次失败,则调试输出将立即可用。
一种进行设置和清除的方法
py.test支持设置和拆卸方法,类似于unittest中使用的方法,但它提供了更大的灵活性。 由于它们很熟悉,因此我们将对其进行简要讨论,但是由于py.test为我们提供了强大的 funcargs 工具,因此它们在unittest模块中的使用并不广泛,我们将在下一节中对其进行讨论。
如果我们正在编写基于类的测试,则可以使用与unittest中调用setUp和tearDown基本上相同的方式使用称为setup_method和teardown_method的两种方法。 在类中的每个测试方法之前和之后调用它们,以执行设置和清除任务。 与unittest方法有一个区别。 两种方法都接受一个参数:表示要调用的方法的函数对象。
此外,py.test提供了其他设置和拆卸功能,使我们可以更好地控制何时执行设置和清除代码。 预计setup_class和teardown_class方法是类方法; 它们接受一个表示所讨论类的参数(没有self参数)。
最后,我们具有setup_module和teardown_module函数,它们在该模块中的所有测试(在函数或类中)之前和之后立即运行。 这些对于“一次性”设置非常有用,例如创建将由模块中的所有测试使用的套接字或数据库连接。 请小心这一点,因为如果要设置的对象存储状态,它可能会意外在测试之间引入依赖关系。
简短的描述并不能很好地解释何时调用这些方法,因此让我们看一个示例,确切说明何时发生这些方法:
def setup_module(module):
print("setting up MODULE {0}".format(
module.__name__))
def teardown_module(module):
print("tearing down MODULE {0}".format(
module.__name__))
def test_a_function():
print("RUNNING TEST FUNCTION")
class BaseTest:
def setup_class(cls):
print("setting up CLASS {0}".format(
cls.__name__))
def teardown_class(cls):
print("tearing down CLASS {0}\n".format(
cls.__name__))
def setup_method(self, method):
print("setting up METHOD {0}".format(
method.__name__))
def teardown_method(self, method):
print("tearing down METHOD {0}".format(
method.__name__))
class TestClass1(BaseTest):
def test_method_1(self):
print("RUNNING METHOD 1-1")
def test_method_2(self):
print("RUNNING METHOD 1-2")
class TestClass2(BaseTest):
def test_method_1(self):
print("RUNNING METHOD 2-1")
def test_method_2(self):
print("RUNNING METHOD 2-2")
BaseTest类的唯一目的是提取在其他方面与测试类相同的四个方法,并使用继承来减少重复代码的数量。 因此,从py.test的角度来看,这两个子类不仅具有两种测试方法,而且还具有两种设置和两种拆卸方法(一种在类级别,一种在方法级别)。
如果我们在禁用print函数输出抑制的情况下(通过传递-s或--capture=no标志)使用py.test运行这些测试,则它们将向我们展示与测试本身相关的各种功能的调用时间:
py.test setup_teardown.py -s
setup_teardown.py
setting up MODULE setup_teardown
RUNNING TEST FUNCTION
.setting up CLASS TestClass1
setting up METHOD test_method_1
RUNNING METHOD 1-1
.tearing down METHOD test_method_1
setting up METHOD test_method_2
RUNNING METHOD 1-2
.tearing down METHOD test_method_2
tearing down CLASS TestClass1
setting up CLASS TestClass2
setting up METHOD test_method_1
RUNNING METHOD 2-1
.tearing down METHOD test_method_1
setting up METHOD test_method_2
RUNNING METHOD 2-2
.tearing down METHOD test_method_2
tearing down CLASS TestClass2
tearing down MODULE setup_teardown
模块的设置和拆卸方法在会话的开始和结束时执行。 然后运行单独的模块级测试功能。 接下来,执行第一类的设置方法,然后执行该类的两个测试。 这些测试分别单独包装在setup_method和teardown_method调用中。 执行完测试后,将调用类拆解方法。 对于第二类,在最终调用teardown_module方法之前,只发生了一次相同的序列。
设置变量的完全不同的方法
各种设置和拆卸功能最常见的用途之一是在运行每种测试方法之前,确保某些类或模块变量具有已知值。
py.test提供了一种完全不同的方法,可以使用称为 funcargs 的函数,这是函数参数的缩写。 Funcargs 基本上是在测试配置文件中预定义的命名变量。 这使我们能够将配置与测试的执行分开,并允许真菌类在多个类和模块中使用。
要使用它们,我们向测试函数添加参数。 参数的名称用于在特殊命名的函数中查找特定的参数。 例如,如果我们要测试演示unittest时使用的StatsList类,我们将再次想要重复测试有效整数列表。 但是我们可以像这样编写测试,而不是使用设置方法:
from stats import StatsList
def pytest_funcarg__valid_stats(request):
return StatsList([1,2,2,3,3,4])
def test_mean(valid_stats):
assert valid_stats.mean() == 2.5
def test_median(valid_stats):
assert valid_stats.median() == 2.5
valid_stats.append(4)
assert valid_stats.median() == 3
def test_mode(valid_stats):
assert valid_stats.mode() == [2,3]
valid_stats.remove(2)
assert valid_stats.mode() == [3]
三种测试方法中的每一种都接受名为valid_stats的参数; 通过调用文件顶部定义的pytest_funcarg__valid_stats函数来创建此参数。 如果多个模块需要使用 funcarg,也可以在名为conftest.py的文件中定义它。 py.test会解析conftest.py文件,以加载任何“全局”测试配置; 它是一种用于定制py.test体验的综合功能。
与其他py.test功能一样,返还 Funcarg 的工厂名称很重要。 funcargs 是名为pytest_funcarg__<identifier>的函数,其中<identifier>是有效的变量名,可以在测试函数中用作参数。 该函数接受一个神秘的request参数,并将要作为参数传递的对象返回到各个测试函数中。 每次调用单个测试函数都会重新创建 funcarg。 例如,这使我们能够在一个测试中更改列表,并知道在下一个测试中它将重置为原始值。
Funcargs 除了返回基本变量外,还可以做更多的事情。 传递给 funcarg 工厂的request对象提供了一些极其有用的方法和属性来修改 funcarg 的行为。 module,cls和function属性使我们可以准确地看到哪个测试正在请求 funcarg。 config属性使我们可以检查命令行参数和其他配置数据。
更有趣的是,请求对象提供的方法使我们可以对真菌进行额外的清除,或在测试之间重用它,否则这些活动将被归类为特定范围的设置和拆卸方法。
request.addfinalizer方法接受一个回调函数,该函数在使用 funcarg 的每个测试函数被调用后执行清除。 这提供了等效的拆卸方法,使我们可以清理文件,关闭连接,清空列表或重置队列。 例如,以下代码通过创建临时目录funcarg来测试os.mkdir功能:
import tempfile
import shutil
import os.path
def pytest_funcarg__temp_dir(request):
dir = tempfile.mkdtemp()
print(dir)
def cleanup():
shutil.rmtree(dir)
request.addfinalizer(cleanup)
return dir
def test_osfiles(temp_dir):
os.mkdir(os.path.join(temp_dir, 'a'))
os.mkdir(os.path.join(temp_dir, 'b'))
dir_contents = os.listdir(temp_dir)
assert len(dir_contents) == 2
assert 'a' in dir_contents
assert 'b' in dir_contents
funcarg 为要在其中创建文件的文件创建了一个新的空临时目录。然后,在测试完成后,它添加了一个终结器调用以删除该目录(使用shutil.rmtree,该递归删除目录和其中的任何内容)。 然后,文件系统保持与启动时相同的状态。
我们可以使用request.cached_setup方法来创建函数参数变量,其持续时间超过一个测试。 这在设置可以由多个测试重用的昂贵操作时非常有用,只要资源重用不会破坏测试的原子或单元性质(这样,一个测试就不会依赖并且不受先前测试的影响) 一)。 例如,如果我们要测试以下回显服务器,则可能只想在单独的进程中运行服务器的一个实例,然后将多个测试连接到该实例:
import socket
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind(('localhost',1028))
s.listen(1)
while True:
client, address = s.accept()
data = client.recv(1024)
client.send(data)
client.close()
这些代码所做的全部工作就是侦听特定端口并等待来自客户端套接字的输入。 收到输入后,它将回传相同的值。 为了对此进行测试,我们可以在一个单独的进程中启动服务器,并缓存结果以供多个测试使用。 这是测试代码的外观:
import subprocess
import socket
import time
def pytest_funcarg__echoserver(request):
def setup():
p = subprocess.Popen(
['python3', 'echo_server.py'])
time.sleep(1)
return p
def cleanup(p):
p.terminate()
return request.cached_setup(
setup=setup,
teardown=cleanup,
scope="session")
def pytest_funcarg__clientsocket(request):
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect(('localhost', 1028))
request.addfinalizer(lambda: s.close())
return s
def test_echo(echoserver, clientsocket):
clientsocket.send(b"abc")
assert clientsocket.recv(3) == b'abc'
def test_echo2(echoserver, clientsocket):
clientsocket.send(b"def")
assert clientsocket.recv(3) == b'def'
我们在这里创建了两个 funcarg。 第一个在单独的进程中运行回显服务器,然后返回进程对象。 第二个实例化每个测试的新套接字对象,并在测试完成时使用addfinalizer将其关闭。 第一个功能是我们当前感兴趣的功能。它看起来很像传统的单元测试设置和拆卸。 我们创建一个setup函数,该函数不接受任何参数并返回正确的参数; 在这种情况下,测试实际上忽略了一个过程对象,因为它们仅关心服务器是否正在运行。 然后,我们创建一个cleanup函数(该函数的名称是任意的,因为它只是我们传递给另一个函数的对象),该函数接受一个参数:setup返回的参数。 此清除代码终止该过程。
父函数不是直接返回 funcarg,而是返回对request.cached_setup的调用结果。 它接受setup和teardown函数(我们刚刚创建)的两个参数,以及scope参数。 最后一个参数应该是三个字符串“ function”,“ module”或“ session”之一; 它确定参数将被缓存多长时间。 在此示例中,我们将其设置为“会话”,因此在整个py.test运行期间将其缓存。 在运行所有测试之前,该过程不会终止或重新启动。 当然,“模块”作用域仅将其缓存用于该模块中的测试,而“功能”作用域将对象更像是普通的 funcarg,因为在运行每个测试功能后将其重置。
使用 py.test 跳过测试
与 unittest模块一样,出于各种原因,经常有必要跳过py.test中的测试:尚未编写被测试的代码,该测试仅在某些解释器或操作系统上运行 ,否则测试会很耗时,并且只能在某些情况下运行。
我们可以使用py.test.skip函数在代码的任何位置跳过测试。 它接受一个参数:一个字符串,说明为什么它被跳过了。 可以在任何地方调用此函数。 如果在测试函数中调用它,则将跳过测试。 如果我们在模块级别调用它,则将跳过该模块中的所有测试。 如果我们在 funcarg 函数中调用它,则所有调用该 funcarg 的测试都将被跳过。
当然,在所有这些位置,通常都希望仅在满足或不满足某些条件时才跳过测试。 由于我们可以在 Python 代码中的任何位置执行skip函数,因此我们可以在if语句内执行它。 因此,我们可以编写一个如下所示的测试:
import sys
import py.test
def test_simple_skip():
if sys.platform != "fakeos":
py.test.skip("Test works only on fakeOS")
fakeos.do_something_fake()
assert fakeos.did_not_happen
确实,这是一些非常愚蠢的代码。 没有名为fakeos的 Python 平台,因此此测试将在所有操作系统上跳过。 它显示了我们如何可以有条件地跳过,并且由于if语句可以检查任何有效的条件,因此当跳过测试时,我们具有很大的控制权。 通常,我们检查sys.version_info来检查 Python 解释器版本,检查sys.platform来检查操作系统,或者检查some_library.__version__来检查我们是否具有给定 API 的最新版本。
由于基于某个条件跳过单个测试方法或功能是测试跳过的最常见用途之一,因此py.test提供了一种方便的修饰符,使可以在一行中执行此操作。 装饰器接受单个字符串,该字符串可以包含任何评估为布尔值的可执行 Python 代码。 例如,以下测试将仅在 Python 3 或更高版本上运行:
import py.test
@py.test.mark.skipif("sys.version_info <= (3,0)")
def test_python3():
assert b"hello".decode() == "hello"
py.test.mark.xfail装饰器的行为类似,不同之处在于,它将测试标记为预期失败,类似于unittest.expectedFailure()。 如果测试成功,则将记录为失败;否则,将记录为失败。 如果失败,则将其报告为预期行为。 对于xfail,条件参数是可选的; 如果未提供,则将在所有情况下将测试标记为预期失败。
模仿昂贵的物品
有时,我们想要测试要求提供昂贵或难以构造的对象的代码。 虽然这可能意味着您的 API 需要重新考虑以具有更可测试的接口(通常意味着更可用的接口),但有时我们发现自己编写的测试代码具有大量样板来设置仅与下面的代码相关的对象 测试。
例如,假设我们有一些代码在键值存储区(例如redis或memcache)中跟踪航班状态,从而可以存储时间戳和最新状态。 此类代码的基本版本可能如下所示:
import datetime
import redis
class FlightStatusTracker:
ALLOWED_STATUSES = {'CANCELLED', 'DELAYED', 'ON TIME'}
def __init__(self):
self.redis = redis.StrictRedis()
def change_status(self, flight, status):
status = status.upper()
if status not in self.ALLOWED_STATUSES:
raise ValueError(
"{} is not a valid status".format(status))
key = "flightno:{}".format(flight)
value = "{}|{}".format(
datetime.datetime.now().isoformat(), status)
self.redis.set(key, value)
使用change_status方法,我们需要测试很多。 如果传入了错误的状态,我们应该检查它是否引发了适当的错误。我们需要确保将状态转换为大写。 我们可以看到,在redis对象上调用set()方法时,键和值具有正确的格式。
但是,我们不必在单元测试中检查的一件事是redis对象正确存储了数据。 绝对应该在集成或应用测试中对此进行测试,但是在单元测试级别,我们可以假设 py-redis 开发人员已经测试了他们的代码,并且该方法可以完成我们想要的工作。 通常,单元测试应该是独立的,并且不应依赖外部资源(例如正在运行的 Redis 实例)的存在。
相反,我们只需要测试set()方法被调用了适当的次数并带有适当的参数即可。 我们可以在测试中使用Mock()对象,将麻烦的方法替换为我们可以自省的对象。 以下示例说明了模拟的用法:
from unittest.mock import Mock
import py.test
def pytest_funcarg__tracker():
return FlightStatusTracker()
def test_mock_method(tracker):
tracker.redis.set = Mock()
with py.test.raises(ValueError) as ex:
tracker.change_status("AC101", "lost")
assert ex.value.args[0] == "LOST is not a valid status"
assert tracker.redis.set.call_count == 0
使用py.test语法编写的该测试断言,传入不适当的参数时会引发正确的异常。此外,它会为set方法创建一个模拟对象,并确保从不对其进行调用。 如果是这样,则意味着我们的异常处理代码中存在错误。
在这种情况下,简单地替换该方法就可以了,因为被替换的对象最终被破坏了。 但是,我们通常只想在测试期间替换函数或方法。 例如,如果要在模拟方法中测试时间戳格式,则需要确切知道datetime.datetime.now()将返回什么。 但是,此值随运行而变化。 我们需要某种方法将其固定为特定值,以便可以确定性地对其进行测试。
还记得猴子打补丁吗? 临时将库函数设置为特定值是一种很好的用法。 模拟库提供了一个修补程序上下文管理器,使我们可以使用模拟对象替换现有库上的属性。 当上下文管理器退出时,将自动还原原始属性,以免影响其他测试用例。 这是一个例子:
from unittest.mock import patch
def test_patch(tracker):
tracker.redis.set = Mock()
fake_now = datetime.datetime(2015, 4, 1)
with patch('datetime.datetime') as dt:
dt.now.return_value = fake_now
tracker.change_status("AC102", "on time")
dt.now.assert_called_once_with()
tracker.redis.set.assert_called_once_with(
"flightno:AC102",
"2015-04-01T00:00:00|ON TIME")
在此示例中,我们首先构造一个名为fake_now的值,将其设置为datetime.datetime.now函数的返回值。 在修补datetime.datetime之前,我们必须先构造此对象,因为否则,在构造它之前,我们将调用已修补的now函数!
with语句邀请修补程序用模拟对象替换datetime.datetime模块,该对象作为值dt返回。 关于模拟对象的整洁之处在于,每当您访问该对象的属性或方法时,它都会返回另一个模拟对象。 因此,当我们访问dt.now时,它为我们提供了一个新的模拟对象。 我们将该对象的return_value设置为我们的fake_now对象; 这样,每当调用datetime.datetime.now函数时,它将返回我们的对象而不是新的模拟对象。
然后,在使用已知值调用change_status方法之后,我们使用模拟类的assert_called_once_with函数来确保now函数确实确实被调用了一次而没有任何参数。 然后,我们再次调用它来证明redis.set方法是用我们期望的格式格式化的参数调用的。
前面的示例很好地说明了编写测试如何指导我们的 API 设计。 乍一看,FlightStatusTracker对象看起来很明智; 我们在构造对象时构造一个redis连接,并在需要时调用它。 但是,当我们为该代码编写测试时,我们发现即使在FlightStatusTracker上模拟了self.redis变量,仍然必须构造redis连接。 如果没有 Redis 服务器正在运行,则此调用实际上会失败,并且我们的测试也会失败。
我们可以通过模拟redis.StrictRedis类以在setUp方法中返回模拟来解决此问题。 但是,一个更好的主意可能是重新考虑我们的示例。 与其在__init__内部构造redis实例,不如让我们允许用户传入一个实例,如以下示例所示:
def __init__(self, redis_instance=None):
self.redis = redis_instance if redis_instance else redis.StrictRedis()
这允许我们在测试时传递模拟,因此StrictRedis方法永远不会被构造。 但是,它也允许与FlightStatusTracker对话的任何客户端代码传入其自己的redis实例。 他们可能要这样做有多种原因。 他们可能已经为代码的其他部分构造了一个。 他们可能已经创建了redis API 的优化实现。 也许他们有一个将指标记录到其内部监视系统中。 通过编写单元测试,我们发现了一个用例,该用例从一开始就使我们的 API 更加灵活,而不必等待客户要求我们支持他们的特殊需求。
这是对模拟代码奇迹的简要介绍。 自 Python 3.3 起,模仿是标准unittest库的一部分,但是从这些示例中可以看出,它们也可以与py.test和其他库一起使用。 Mocks 具有其他更高级的功能,随着代码变得越来越复杂,您可能需要利用这些功能。 例如,您可以使用spec参数邀请模拟模仿现有的类,以便在代码尝试访问模仿的类中不存在的属性时引发模拟错误。 您还可以构造一个模拟方法,该方法通过将列表作为side_effect参数传递来在每次调用时返回不同的参数。 side_effect参数用途广泛。 您还可以使用它在调用模拟程序时执行任意函数或引发异常。
总的来说,我们对模拟应该相当谨慎。 如果发现自己在给定的单元测试中模拟了多个元素,则最终可能会测试模拟框架,而不是实际的代码。 这毫无用处。 毕竟,模拟游戏已经过了充分的测试! 如果我们的代码正在执行很多操作,则可能另一个迹象表明我们正在测试的 API 设计不良。 在被测代码和它们所连接的库之间的边界应存在模拟。 如果这没有发生,我们可能需要更改 API,以便在其他位置重绘边界。
多少测试就足够了?
我们已经确定未经测试的代码就是损坏的代码。 但是,我们如何知道我们的代码测试得如何呢? 我们如何知道实际测试了多少代码,有多少被破坏了? 第一个问题是更重要的一个,但很难回答。 即使我们知道已经测试了应用中的每一行代码,我们也不知道我们已经正确测试了它。 例如,如果我们编写一个统计测试仅检查提供整数列表时会发生什么情况,那么如果将其用于浮点数,字符串或自制对象的列表,它可能仍然会失败。 设计完整的测试套件的责任仍然在于程序员。
第二个问题-我们实际测试了多少代码-易于验证。 代码覆盖率实质上是对程序执行的代码行数的估计。 如果我们知道该数字和程序中的行数,则可以估算出实际测试或覆盖的代码百分比。 如果我们还有一个指标可以指示未测试的行,则可以更轻松地编写新的测试以确保这些行的中断较少。
令人难忘的是,用于测试代码覆盖率的最流行的工具被称为coverage.py。 可以像其他大多数第三方库一样使用命令pip install coverage安装。
我们没有足够的空间来覆盖 coverage API 的所有细节,因此我们只看几个典型的例子。 如果我们有一个 Python 脚本可以为我们运行所有单元测试(例如,使用unittest.main,自定义测试运行程序或discover),则可以使用以下命令执行覆盖率分析:
coverage run coverage_unittest.py
该命令将正常退出,但是会创建一个名为.coverage的文件,其中包含运行中的数据。 现在,我们可以使用coverage report命令来分析代码覆盖率:
>>> coverage report
输出如下:
Name Stmts Exec Cover
--------------------------------------------------
coverage_unittest 7 7 100%
stats 19 6 31%
--------------------------------------------------
TOTAL 26 13 50%
这个基本的报告列出了已执行的文件(我们的单元测试和导入的模块)。 还列出了每个文件中的代码行数以及测试执行的代码数。 然后将这两个数字组合起来以估计代码覆盖率。 如果我们将-m选项传递给 report 命令,它将另外添加如下所示的列:
Missing
-----------
8-12, 15-23
此处列出的行范围表示stats模块中在测试运行期间未执行的行。
我们刚刚使用本章前面创建的 stats 模块运行代码覆盖率工具的示例。 但是,它故意使用单个测试,该测试无法测试文件中的许多代码。 这是测试:
from stats import StatsList
import unittest
class TestMean(unittest.TestCase):
def test_mean(self):
self.assertEqual(StatsList([1,2,2,3,3,4]).mean(), 2.5)
if __name__ == "__main__":
unittest.main()
该代码不测试中值或众数函数,它们对应于 coverage 输出告诉我们的行号缺失。
文本报告就足够了,但是如果使用命令coverage html,我们可以获得甚至更漂亮的交互式 HTML 报告,可以在 Web 浏览器中查看。 该网页甚至突出显示了源代码中的哪些行已经过测试,没有经过测试。 外观如下:
我们也可以将与py.test一起使用coverage.py模块。 我们需要使用pip install pytest-coverage安装py.test插件以覆盖代码。 该插件为py.test添加了几个命令行选项,其中最有用的是--cover-report,可以将其设置为html,report或annotate(后者实际上是修改源代码以突出显示任何行) 未涵盖的内容)。
不幸的是,如果我们能以某种方式在本章的这一部分运行一份覆盖率报告,我们会发现我们没有涵盖有关代码覆盖率的大部分知识! 可以使用 coverage API 从我们自己的程序(或测试套件)中管理代码的覆盖范围,并且coverage.py接受许多我们没有涉及的配置选项。 我们也没有讨论语句覆盖率和分支覆盖率之间的区别(后者更为有用,并且是coverage.py的最新版本中的默认设置)或其他样式的代码覆盖率。
请记住,虽然我们所有人都应该争取 100%的代码覆盖率这一崇高目标,但 100%的覆盖率还不够! 仅仅因为对一条语句进行了测试并不意味着就对所有可能的输入都对其进行了正确的测试。
案例研究
让我们通过编写一个经过测试的小型加密应用来逐步进行测试驱动的开发。 不用担心,您将不需要了解复杂的现代加密算法(例如 Threefish 或 RSA)背后的数学原理。 相反,我们将实现一种称为 Vigenère 密码的 16 世纪算法。 给定编码关键字,应用仅需要能够使用此密码对消息进行编码和解码。
首先,我们需要了解如果手动(没有计算机)应用密码,密码是如何工作的。 我们从这样的表开始:
A B C D E F G H I J K L M N O P Q R S T U V W X Y Z
B C D E F G H I J K L M N O P Q R S T U V W X Y Z A
C D E F G H I J K L M N O P Q R S T U V W X Y Z A B
D E F G H I J K L M N O P Q R S T U V W X Y Z A B C
E F G H I J K L M N O P Q R S T U V W X Y Z A B C D
F G H I J K L M N O P Q R S T U V W X Y Z A B C D E
G H I J K L M N O P Q R S T U V W X Y Z A B C D E F
H I J K L M N O P Q R S T U V W X Y Z A B C D E F G
I J K L M N O P Q R S T U V W X Y Z A B C D E F G H
J K L M N O P Q R S T U V W X Y Z A B C D E F G H I
K L M N O P Q R S T U V W X Y Z A B C D E F G H I J
L M N O P Q R S T U V W X Y Z A B C D E F G H I J K
M N O P Q R S T U V W X Y Z A B C D E F G H I J K L
N O P Q R S T U V W X Y Z A B C D E F G H I J K L M
O P Q R S T U V W X Y Z A B C D E F G H I J K L M N
P Q R S T U V W X Y Z A B C D E F G H I J K L M N O
Q R S T U V W X Y Z A B C D E F G H I J K L M N O P
R S T U V W X Y Z A B C D E F G H I J K L M N O P Q
S T U V W X Y Z A B C D E F G H I J K L M N O P Q R
T U V W X Y Z A B C D E F G H I J K L M N O P Q R S
U V W X Y Z A B C D E F G H I J K L M N O P Q R S T
V W X Y Z A B C D E F G H I J K L M N O P Q R S T U
W X Y Z A B C D E F G H I J K L M N O P Q R S T U V
X Y Z A B C D E F G H I J K L M N O P Q R S T U V W
Y Z A B C D E F G H I J K L M N O P Q R S T U V W X
Z A B C D E F G H I J K L M N O P Q R S T U V W X Y
给定关键字 TRAIN,我们可以对消息编码为 PYTHON 进行如下编码:
-
一起重复关键字和消息,以便轻松地将字母从一个字母映射到另一个字母:
E N C O D E D I N P Y T H O N T R A I N T R A I N T R A I N -
对于纯文本中的每个字母,请在表中找到以该字母开头的行。
-
查找包含与所选明文字母的关键字字母关联的字母的列。
-
编码的字符在此行和列的交点处。
例如,以 E 开头的行与以 T 开头的列在字符 X 处相交。因此,密文中的第一个字母为 X。以 N 开头的行与以 R 开头的列在字符 E 处相交,从而导致 密文 XE。 C 在 C 处与 A 相交,O 在 W 处与 I 相交。D 和 N 映射到 Q,而 E 和 T 映射到 X。完整的编码消息是 XECWQXUIVCRKHWA。
解码基本上遵循相反的过程。 首先,找到带有共享关键字字符的行(T 行),然后在该行中找到编码字符(X)所在的位置。 明文字符在该行(E)的列的顶部。
实施
我们的程序将需要一个encode方法,该方法采用关键字和明文并返回密文,而decode方法将接受关键字和密文并返回原始消息。
但是,我们不只是编写这些方法,而是遵循测试驱动的开发策略。 我们将使用py.test进行单元测试。 我们需要encode方法,我们知道它必须做什么; 让我们首先为该方法编写一个测试:
def test_encode():
cipher = VigenereCipher("TRAIN")
encoded = cipher.encode("ENCODEDINPYTHON")
assert encoded == "XECWQXUIVCRKHWA"
自然,该测试失败了,因为我们没有在任何地方导入VigenereCipher类。 让我们创建一个新模块来容纳该类。
让我们从下面的VigenereCipher类开始:
class VigenereCipher:
def __init__(self, keyword):
self.keyword = keyword
def encode(self, plaintext):
return "XECWQXUIVCRKHWA"
如果我们在测试类的顶部添加 from vigenere_cipher import VigenereCipher行并运行py.test,则前面的测试将通过! 我们已经完成了第一个测试驱动的开发周期。
显然,返回一个硬编码的字符串不是密码类的最明智的实现,因此让我们添加另一个测试:
def test_encode_character():
cipher = VigenereCipher("TRAIN")
encoded = cipher.encode("E")
assert encoded == "X"
啊,现在测试失败了。 看来我们将不得不更加努力。 但是我只是想到了什么:如果有人尝试用空格或小写字符编码一个字符串怎么办? 在开始实现编码之前,让我们为这些情况添加一些测试,以免我们忘记它们。 预期的行为是删除空格,并将小写字母转换为大写字母:
def test_encode_spaces():
cipher = VigenereCipher("TRAIN")
encoded = cipher.encode("ENCODED IN PYTHON")
assert encoded == "XECWQXUIVCRKHWA"
def test_encode_lowercase():
cipher = VigenereCipher("TRain")
encoded = cipher.encode("encoded in Python")
assert encoded == "XECWQXUIVCRKHWA"
如果我们运行新的测试套件,则会发现新测试通过了(他们期望使用相同的硬编码字符串)。 但是如果我们忘记考虑这些情况,它们应该在以后失败。
现在我们有了一些测试用例,让我们考虑如何实现我们的编码算法。 可以像以前的手动算法中那样使用表来编写代码,但是考虑到每一行只是一个由一定数量的字符旋转的字母,因此看起来很复杂。 事实证明(我问维基百科),我们可以使用模运算来组合字符,而不是进行表查找。 给定纯文本和关键字字符,如果我们将两个字母转换为它们的数值(A 为 0,Z 为 25),将它们加在一起,然后取余数 mod 26,就得到密文字符! 这是一个简单的计算,但是由于它是逐字符进行的,因此我们可能应该将其放在自己的函数中。 在此之前,我们应该为新功能编写一个测试:
from vigenere_cipher import combine_character
def test_combine_character():
assert combine_character("E", "T") == "X"
assert combine_character("N", "R") == "E"
现在我们可以编写代码以使此功能正常工作。 老实说,我必须多次运行测试才能完全正确地执行此功能。 首先我返回了一个整数,然后我忘记了将字符从从零开始的比例转换回普通的 ASCII 比例。 有了测试,就可以轻松测试和调试这些错误。 这是测试驱动开发的另一个好处。
def combine_character(plain, keyword):
plain = plain.upper()
keyword = keyword.upper()
plain_num = ord(plain) - ord('A')
keyword_num = ord(keyword) - ord('A')
return chr(ord('A') + (plain_num + keyword_num) % 26)
现在combine_characters已经过测试,我想我们已经准备好实现encode功能。 但是,我们要在该函数中首先想到的是关键字字符串的重复版本,该版本与纯文本一样长。 让我们首先实现一个功能。 糟糕,我的意思是让我们先实施测试!
def test_extend_keyword():
cipher = VigenereCipher("TRAIN")
extended = cipher.extend_keyword(16)
assert extended == "TRAINTRAINTRAINT"
在编写此测试之前,我希望将extend_keyword编写为接受关键字和整数的独立函数。 但是,当我开始起草测试时,我意识到将其用作VigenereCipher类的辅助方法更有意义。 这表明测试驱动的开发如何帮助设计更合理的 API。 这是方法的实现:
def extend_keyword(self, number):
repeats = number // len(self.keyword) + 1
return (self.keyword * repeats)[:number]
再次,此进行了几次测试以确保正确。 最后,我添加了第二个版本的测试,一个版本包含 15 个字母,另一个版本包含 16 个字母,以确保整数除法具有偶数时可以正常工作。
现在,我们终于可以编写我们的encode方法了:
def encode(self, plaintext):
cipher = []
keyword = self.extend_keyword(len(plaintext))
for p,k in zip(plaintext, keyword):
cipher.append(combine_character(p,k))
return "".join(cipher)
看起来很正确。 我们的测试套件现在应该通过了,对吧?
实际上,如果运行它,我们会发现两个测试仍然失败。 我们完全忘记了空格和小写字符! 很高兴我们编写了这些测试来提醒我们。 我们必须在方法的开头添加以下行:
plaintext = plaintext.replace(" ", "").upper()
注意
如果在实现某些东西的过程中对某个极端情况有一个想法,我们可以创建一个描述该想法的测试。 我们甚至不必执行测试; 我们可以运行assert False来提醒我们稍后实施。 失败的测试永远不会让我们忘记极端的情况,并且不能像提交任务那样被忽略。 如果花点时间解决该实现问题,我们可以将测试标记为预期的失败。
现在,所有测试均成功通过。 本章篇幅很长,因此我们将压缩示例以进行解码。 这是一些测试:
def test_separate_character():
assert separate_character("X", "T") == "E"
assert separate_character("E", "R") == "N"
def test_decode():
cipher = VigenereCipher("TRAIN")
decoded = cipher.decode("XECWQXUIVCRKHWA")
assert decoded == "ENCODEDINPYTHON"
这是separate_character函数:
def separate_character(cypher, keyword):
cypher = cypher.upper()
keyword = keyword.upper()
cypher_num = ord(cypher) - ord('A')
keyword_num = ord(keyword) - ord('A')
return chr(ord('A') + (cypher_num - keyword_num) % 26)
和 decode方法:
def decode(self, ciphertext):
plain = []
keyword = self.extend_keyword(len(ciphertext))
for p,k in zip(ciphertext, keyword):
plain.append(separate_character(p,k))
return "".join(plain)
这些方法与用于编码的方法有很多相似之处。 编写并通过所有这些测试的最大好处是,我们现在可以返回并修改我们的代码,因为它仍然可以安全地通过测试。 例如,如果我们用这些重构方法替换现有的encode和decode方法,我们的测试仍会通过:
def _code(self, text, combine_func):
text = text.replace(" ", "").upper()
combined = []
keyword = self.extend_keyword(len(text))
for p,k in zip(text, keyword):
combined.append(combine_func(p,k))
return "".join(combined)
def encode(self, plaintext):
return self._code(plaintext, combine_character)
def decode(self, ciphertext):
return self._code(ciphertext, separate_character)
这是测试驱动开发的最终好处,也是最重要的。 编写测试后,我们可以根据需要尽可能地改进代码,并确信我们所做的更改不会破坏我们一直在测试的内容。 此外,我们确切地知道重构何时完成:何时所有测试通过。
当然,我们的测试可能无法全面测试我们需要的所有内容; 维护或代码重构仍会导致无法诊断的错误,这些错误不会在测试中显示。 自动化测试并非万无一失。 但是,如果确实发生错误,仍然可以遵循测试驱动的计划; 第一步是编写一个(或多个)测试来重复或“证明”该问题正在发生。 当然,这将失败。 然后编写代码以使测试停止失败。 如果测试是全面的,则该错误将得到修复,我们将在运行测试套件后立即知道是否再次发生该错误。
最后,我们可以尝试确定我们的测试在此代码上的运行情况。 安装py.test coverage 插件后,py.test –coverage-report=report告诉我们我们的测试套件具有 100%的代码覆盖率。 这是一个很棒的统计数据,但是我们不应该对此过于自大。 在对带有数字的消息进行编码时,我们的代码尚未经过测试,因此未定义此类输入的行为。