在前面的章节中,我们详细介绍了 Python 模块和包是如何工作的,并了解了如何在程序中使用它们。当使用模块化编程技术时,您会发现模块和包的使用方式往往遵循标准模式。在本章中,我们将研究使用模块和包来处理一系列编程挑战的一些常见模式。特别是,我们将:
- 了解分而治之技术如何帮助您解决编程问题
- 了解抽象原理如何帮助您将您想做的事情与如何做的事情区分开来
- 了解封装如何允许您对系统的其余部分隐藏信息表示方式的详细信息
- 请注意,包装器是调用其他模块以简化或更改模块使用方式的模块
- 了解如何创建可扩展模块
让我们从分而治之的原则开始。
分而治之是将问题分解成更小部分的过程。您可能不知道如何解决某个特定的问题,但通过将其分解为更小的部分,您可以依次解决每个部分,从而解决原始问题。
当然,这是一种非常通用的技术,不仅仅适用于模块和包的使用。然而,模块化编程可以帮助您完成分而治之的过程:当您分解问题时,您会发现您的程序需要一部分来执行给定的任务或一系列任务,而 Python 模块(和包)是组织这些任务的完美方式。
在这本书中,我们已经做了好几次了。例如,当面临创建图表生成库的挑战时,我们使用分治技术提出了可以绘制单个图表元素的渲染器的概念。然后,我们意识到我们需要几个不同的渲染器,这些渲染器完美地转换为renderers包,其中包含每个渲染器的单独模块。
分而治之的方法不仅为您的代码提供了一种可能的模块化结构,它还以另一种方式工作。当您考虑程序的设计时,您可能会想到一个模块或包的概念,该模块或包与您试图解决的问题相关。您甚至可以映射出每个模块和包提供的各个函数。尽管您还不知道如何解决整个问题,但这种模块化设计有助于澄清您对问题的想法,从而更容易使用分而治之的方法来解决问题的其余部分。换句话说,模块和软件包帮助您在分而治之的过程中阐明您的想法。
抽象是另一种非常通用的编程模式,它不仅仅适用于模块化编程。抽象本质上是隐藏复杂性的过程:将你想做什么与如何做区分开来。
抽象是所有计算机编程的基础。例如,假设您必须编写一个程序来计算两个平均值,然后计算出两者之间的差异。此程序的简单实现可能如下所示:
values_1 = [...]
values_2 = [...]
total_1 = 0
for value in values_1:
total = total + value
average_1 = total / len(values_1)
total_2 = 0
for value in values_2:
total = total + value
average_2 = total / len(values_2)
difference = abs(total_1 - total-2)
print(difference)如您所见,计算数字列表平均值的代码重复了两次。这是低效的,因此您通常会编写一个函数来避免重复。这可以通过以下方式完成:
values_1 = [...]
values_2 = [...]
def average(values):
total = 0
for value in values:
total = total + value
return = total / len(values)
average_1 = average(values_1)
average_2 = average(values_2)
difference = abs(total_1 - total-2)
print(difference)当然,每次编程时都要做这类事情,但实际上这是一个相当重要的过程。当您创建这样一个函数时,函数中的代码处理如何做某事,而调用该函数的代码只知道必须做什么以及函数将做什么。换句话说,函数隐藏了任务执行方式的复杂性,允许程序的其他部分在需要执行该任务时调用该函数。
这种类型的过程称为抽象。使用这种模式,您可以抽象出如何完成某件事情的细节,这样程序的其余部分就不必担心了。
抽象不仅仅适用于编写函数。隐藏复杂性的一般原则也适用于函数组,该模块是将函数分组在一起的完美方式。例如,您的程序可能需要处理颜色,因此您编写了一个名为colors的模块,其中包含各种函数,允许您创建和处理颜色值。colors模块中的各种函数都知道颜色值以及如何使用它们,因此程序的其余部分不需要担心。使用这个模块,你可以用颜色做各种有趣的事情。例如:
purple = colors.new_color(1.0, 0.0, 1.0)
yellow = colors.new_color(1.0, 1.0, 0.0)
dark_purple = colors.darken(purple, 0.3)
color_range = colors.blend(yellow, dark_purple, num_steps=20)
dimmed_yellow = colors.desaturate(yellow, 0.8)在这个模块之外,您的代码可以简单地专注于它想要做的事情,而丝毫不知道这些不同的任务是如何执行的。通过这样做,您可以使用抽象模式对程序的其余部分隐藏这些颜色计算的复杂性。
抽象是设计和编写模块和包的基本技术。例如,我们在前一章中使用的枕头库提供了一系列模块,允许您加载、操作、创建和保存图像。我们可以使用这个库,而不知道这些不同的操作是如何执行的。例如,我们可以调用drawer.line((x1, y1), (x2, y2), color, width),而不必担心在图像中设置单个像素的细节。
应用抽象模式的一个好处是,当您第一次开始实现代码时,您通常不知道事情有多复杂。例如,假设您正在为酒店酒吧编写销售点系统。您的系统的一部分将需要计算价格,以便向客户收取他们订购的饮料的费用。我们可以根据数量、所用白酒的类型等,使用各种公式来计算价格。但其中一个具有挑战性的特点是需要支持欢乐时光,即在一段时间内,饮料将以折扣价提供。
起初,你被告知快乐时光是每天晚上五点到六点之间。因此,使用良好的模块化技术,您可以在代码中添加以下函数:
def is_happy_hour():
if datetime.datetime.now().hour == 17: # 5pm.
return True
else:
return False然后,您可以使用此函数将“快乐时光”的计算方式与“快乐时光”中发生的事情分开。例如:
if is_happy_hour():
price = price * 0.5到目前为止,这非常简单,您可能会试图完全绕过is_happy_hour()函数的创建。然而,当你发现快乐时光不适用于周日时,这个功能很快就会变得更加复杂。因此,您必须修改is_happy_hour()函数以支持此功能:
def is_happy_hour():
if datetime.date.today().weekday() == 6: # Sunday.
return False
elif datetime.datetime.now().hour == 17: # 5pm.
return True
else:
return False但是你会发现快乐时光并不适用于圣诞节或耶稣受难节。虽然圣诞节很容易计算,但计算复活节在某一年的逻辑要复杂得多。如果您感兴趣,本章的示例代码包括is_happy_hour()函数的实现,其中包括对圣诞节和耶稣受难日的支持。不用说,实现相当复杂。
注意,我们的is_happy_hour()功能变得越来越复杂,因为我们一开始认为它会非常简单,但增加的需求使它变得更加复杂。幸运的是,因为我们已经从需要知道当前是否是快乐时光的代码中抽象出了快乐时光的计算细节,所以只需要更新一个函数来支持这种增加的复杂性。
封装是另一种经常应用于模块和包的编程模式。使用封装,您有一个对象——例如,您需要存储数据的颜色、客户或货币,但您对系统的其余部分隐藏了该数据的表示。您提供了设置、检索和操作对象数据的函数,而不是直接使对象可用。
为了了解这是如何工作的,让我们回顾一下上一章中编写的模块。我们的chart.py模块允许用户定义一个图表,并设置关于它的各种信息。以下是我们为本模块编写的代码副本:
def new_chart():
return {}
def set_title(chart, title):
chart['title'] = title
def set_x_axis(chart, x_axis):
chart['x_axis'] = x_axis
def set_y_axis(chart, minimum, maximum, labels):
chart['y_min'] = minimum
chart['y_max'] = maximum
chart['y_labels'] = labels
def set_series_type(chart, series_type):
chart['series_type'] = series_type
def set_series(chart, series):
chart['series'] = series正如您所看到的,函数创建了一个新的“图表”,而没有向系统的其他部分说明如何存储图表信息。我们在这里使用的是字典,但我们也可以很容易地使用对象、base64 编码字符串或任何东西。系统的其余部分并不关心,因为它只是调用chart.py模块中的各种函数来设置图表的各种值。
不幸的是,这不是一个完美的封装示例。我们的各种set_XXX()函数充当设置器——它们允许我们设置图表的各种值,但我们只是假设我们的图表生成函数可以直接从图表的字典中访问有关图表的信息。如果这是一个纯粹的封装示例,我们还将编写等效的getter函数,例如:
def get_title(chart):
return chart['title']
def get_x_axis(chart):
return chart['x_axis']
def get_y_axis(chart):
return (chart['y_min'], chart['y_max'], chart['y_labels'])
def get_series_type(chart):
return chart['series_type']
def get_series(chart):
return chart['series']通过将这些 getter 函数添加到我们的模块中,我们现在有了一个完全封装的模块,允许我们存储和检索有关图表的信息。charter包中希望使用图表的其他部分将调用 getter 函数来检索该图表的数据,而不是直接访问它。
在一个模块中编写 setter 和 getter 函数的例子有些做作;封装通常使用面向对象编程技术来完成。然而,正如您所看到的,在编写只使用模块化编程技术的代码时,完全可以使用封装。
您可能想知道,究竟为什么会有人想使用封装。与其写charts.get_title(chart),为什么不干脆写chart['title']?第二个版本较短。它还可以避免调用函数,因此速度会无限快。为什么还要费心封装呢?
在程序中使用封装有两个原因。首先,通过使用 getter 和 setter 函数,可以隐藏信息存储方式的细节。这允许您在不影响程序任何其他部分的情况下更改内部表示,并且在编写程序时,您可以保证的一点是,您将在编写过程中添加更多信息和功能。这意味着您的数据的内部表示将更改。通过将存储内容与存储方式分离,您的系统变得更加健壮,您可以在不重写大量代码的情况下进行更改。这是良好的模块化设计的标志。
使用封装的第二个主要原因是允许您的代码在用户设置特定值时执行某些操作。例如,如果用户更改订单数量,则可以立即重新计算该订单的总价。setter 经常做的另一件事是将更新后的值保存到磁盘或数据库中。您还可以向 setter 中添加错误检查和其他逻辑,以便捕获可能难以追踪的 bug。
让我们详细地看看使用封装模式的 Python 模块。在本例中,让我们假设我们正在编写一个存储食谱的程序。用户可以创建一个收藏食谱的数据库,并在需要时显示这些食谱。
让我们创建一个 Python 模块来封装配方的概念。在本例中,我们将把配方存储在内存中以保持简单。对于每个配方,我们将存储名称、配方制作的份数、配料列表以及用户制作配方时需要遵循的说明列表。
创建一个名为recipes.py的新 Python 源文件,并在此文件中输入以下内容:
def new():
return {'name' : None,
'num_servings' : 1,
'instructions' : [],
'ingredients' : []}
def set_name(recipe, name):
recipe['name'] = name
def get_name(recipe):
return recipe['name']
def set_num_servings(recipe, num_servings):
recipe['num_servings'] = num_servings
def get_num_servings(recipe):
return recipe['num_servings']
def set_ingredients(recipe, ingredients):
recipe['ingredients'] = ingredients
def get_ingredients(recipe):
return recipe['ingredients']
def set_instructions(recipe, instructions):
recipe['instructions'] = instructions
def get_instructions(recipe):
return recipe['instructions']
def add_instruction(recipe, instruction):
recipe['instructions'].append(instruction)
def add_ingredient(recipe, ingredient, amount, units):
recipe['ingredients'].append({'ingredient' : ingredient,
'amount' : amount,
'units' : units})正如你所看到的,我们再次使用 Python 字典来存储我们的信息。我们可以使用 Python 类或 Python 标准库中的namedtuple。或者,我们可以将信息存储在数据库中。然而,对于本例,我们希望代码尽可能简单,字典是最简单的解决方案。
创建新配方后,用户可以调用各种 setter 和 getter 函数来存储和检索有关配方的信息。我们还有一些有用的函数,让我们一次添加一个指令和成分,这对我们正在编写的程序来说更方便。
请注意,在向配方中添加配料时,调用者需要提供三条信息:配料名称、所需数量以及计量该数量的单位。例如:
recipes.add_ingredient(recipe, "Milk", 1, "cup")到目前为止,我们已经封装了配方的概念,允许我们存储所需的信息,并在需要时检索它。因为我们的模块遵循封装原则,所以我们可以更改配方的存储方式,向模块添加更多信息和新行为,而不会影响程序的其余部分。
让我们在配方中添加一个更有用的功能:
def to_string(recipe, num_servings):
multiplier = num_servings / recipe['num_servings']
s = []
s.append("Recipe for {}, {} servings:".format(recipe['name'],
num_servings))
s.append("")
s.append("Ingredients:")
s.append("")
for ingredient in recipe['ingredients']:
s.append(" {} - {} {}".format(
ingredient['ingredient'],
ingredient['amount'] * multiplier,
ingredient['units']))
s.append("")
s.append("Instructions:")
s.append("")
for i,instruction in enumerate(recipe['instructions']):
s.append("{}. {}".format(i+1, instruction))
return s此函数返回一个字符串列表,这些字符串可以打印出来以汇总配方。注意num_servings参数:这允许我们定制不同份数的配方。例如,如果用户创建了一个三份的配方,并希望将其加倍,则可以使用num_servings值6调用to_string()函数,正确的数量将包含在返回的字符串列表中。
让我们来看看这个模块是如何工作的。打开终端或命令行窗口,使用cd命令进入创建recipes.py文件的目录,键入python启动 Python 解释器。然后,尝试键入以下内容来创建比萨饼面团的配方:
import recipes
recipe = recipes.new("Pizza Dough", num_servings=1)
recipes.add_ingredient(recipe, "Greek Yogurt", 1, "cup")
recipes.add_ingredient(recipe, "Self-Raising Flour", 1.5, "cups")
recipes.add_instruction(recipe, "Combine yogurt and 2/3 of the flour in a bowl and mix with a beater until combined")
recipes.add_instruction(recipe, "Slowly add additional flour until it forms a stiff dough")
recipes.add_instruction(recipe, "Turn out onto a floured surface and knead until dough is tacky")
recipes.add_instruction(recipe, "Roll out into a circle of the desired thickness and place on a greased and lined baking tray")到现在为止,一直都还不错。现在让我们使用to_string()功能打印出配方的详细信息,将其加倍为两份:
for s in recipes.to_string(recipe, num_servings=2):
print s一切顺利,配方应该为您打印出来:
Recipe for Pizza Dough, 2 servings:
Ingredients:
Greek Yogurt - 2 cup
Self-rising Flour - 3.0 cups
Instructions:
1\. Combine yogurt and 2/3 of the flour in a bowl and mix with a beater until combined
2\. Slowly add additional flour until it forms a stiff dough
3\. Turn out onto a floured surface and knead until dough is tacky
4\. Roll out into a circle of the desired thickness and place on a greased and lined baking tray正如你所看到的,有一些小的格式问题。例如,希腊酸奶的所需数量列为2 cup而不是2 cups。如果您愿意,您可以很容易地解决这个问题,但需要注意的重要一点是,recipes.py模块封装了配方的概念,允许您(以及您编写的其他程序)使用配方,而不必担心细节。
作为练习,您可能希望尝试修复to_string()函数中数量的显示。您还可以尝试编写一个新函数,从食谱列表创建一个购物列表,当两个或多个食谱使用相同的配料时,自动组合数量。如果您完成了这些练习,您很快就会注意到实现可能会变得非常复杂,但是通过将细节封装在模块中,您可以对程序的其余部分隐藏这些细节。
包装器本质上是一组调用其他函数来完成工作的函数:
包装器用于简化接口,使混乱或设计糟糕的 API 更易于使用,将数据格式转换为更方便的格式,以及实现跨语言兼容性。包装器有时还用于向现有 API 添加测试和错误检查代码。
让我们来看看包装模块的真实世界应用。假设你在一家大银行工作,被要求编写一个程序来分析资金转账,以帮助识别可能的欺诈行为。您的程序实时接收有关发生的每笔银行间资金转账的信息。对于每次转账,您将获得:
- 转让金额
- 发生转移的分支的 ID
- 将资金发送到的银行的识别码
您的任务是分析一段时间内的转移,以确定不寻常的活动模式。要做到这一点,您需要计算过去八天中每个分行和目的地银行的所有转账总额。然后,您可以将当天的总计与前七天的平均值进行比较,并标记超出平均值 50%以上的任何每日总计。
您首先决定如何表示一天的总转账。因为您需要跟踪每个分行和目的地银行的情况,所以将这些总计存储在二维数组中是有意义的:
在 Python 中,这种类型的二维数组表示为列表列表:
totals = [[0, 307512, 1612, 0, 43902, 5602918],
[79400, 3416710, 75, 23508, 60912, 5806],
...
]然后您可以为每一行保留分行 ID 的单独列表,并为每一列保留另一个包含目的银行代码的列表:
branch_ids = [125000249, 125000252, 125000371, ...]
bank_codes = ["AMERUS33", "CERYUS33", "EQTYUS44", ...]使用这些列表,您可以通过处理特定日期发生的转账来计算给定日期的总计:
totals = []
for branch in branch_ids:
branch_totals = []
for bank in bank_codes:
branch_totals.append(0)
totals.append(branch_totals)
for transfer in transfers_for_day:
branch_index = branch_ids.index(transfer['branch'])
bank_index = bank_codes.index(transfer['dest_bank'])
totals[branch_index][bank_index] += transfer['amount']到现在为止,一直都还不错。一旦你有了每天的总数,你就可以计算平均数,并将其与当天的总数进行比较,以确定高于平均数 150%的条目。
让我们想象一下,您已经编写了这个程序,并设法使其工作。但是,当你开始使用它时,你会立即发现一个问题:你的银行有 5000 多家分行,全世界有 15000 多家银行可以向你的银行转账,总共有 7500 万个组合,你需要保留总数,因此,你的程序计算总数的时间太长了。
为了使程序更快,您需要找到更好的方法来处理大型数字数组。幸运的是,有一个库专门用来做这件事:NumPy。
NumPy 是一个优秀的数组处理库。您可以创建巨大的阵列,并通过单个函数调用对阵列执行复杂的操作。不幸的是,NumPy 也是一个密集且难以穿透的库。它是为对数学有深刻理解的人设计和编写的。虽然有很多可用的教程,您通常可以了解如何使用它,但使用 NumPy 的代码通常很难理解。例如,计算多个矩阵的平均值将涉及以下内容:
daily_totals = []
for totals in totals_to_average:
daily_totals.append(totals)
average = numpy.mean(numpy.array(daily_totals), axis=0)要弄清楚最后一行是做什么的,需要访问 NumPy 文档。由于使用 NumPy 的代码的复杂性,这是一个可以使用包装器模块的完美例子:包装器模块可以为 NumPy 提供一个更易于使用的接口,因此您的代码可以使用它,而不会被复杂和混乱的函数调用所干扰。
为了完成这个示例,我们将从安装 NumPy 库开始。NumPy(http://www.numpy.org )在 Mac OS X、Windows 和 Linux 机器上运行。安装方式取决于您使用的操作系统:
-
对于 Mac OS X,您可以从下载安装程序 http://www.kyngchaos.com/software/python 。
-
For MS Windows, you can download a Python "wheel" file for NumPy from http://www.lfd.uci.edu/~gohlke/pythonlibs/#numpy. Choose the pre-built version of NumPy that matches your operating system and the desired version of Python. To use the wheel file, use the
pip installcommand, for example,pip install numpy-1.10.4+mkl-cp34-none-win32.whl.有关安装 Python 控制盘的更多信息,请参阅https://pip.pypa.io/en/latest/user_guide/#installing-来自车轮。
-
如果您的计算机运行 Linux,则可以使用 Linux 软件包管理器安装 NumPy。或者,您可以下载并构建源代码形式的 NumPy。
要确保 NumPy 正常工作,请启动 Python 解释器并输入以下内容:
import numpy
a = numpy.array([[1, 2], [3, 4]])
print(a)一切顺利,您应该会看到显示的 2 x 2 矩阵:
[[1 2]
[3 4]]现在我们已经安装了 NumPy,让我们开始处理包装器模块。创建一个名为numpy_wrapper.py的新 Python 源文件,并在此文件中输入以下内容:
import numpy现在就这些;我们将根据需要向这个包装器模块添加函数。
接下来,创建另一个名为detect_unusual_transfers.py的 Python 源文件,并在该文件中输入以下内容:
import random
import numpy_wrapper as npw
BANK_CODES = ["AMERUS33", "CERYUS33", "EQTYUS44",
"LOYDUS33", "SYNEUS44", "WFBIUS6S"]
BRANCH_IDS = ["125000249", "125000252", "125000371",
"125000402", "125000596", "125001067"]如您所见,我们正在为示例硬连接银行和分行代码;在实际程序中,这些值将从某个地方加载,例如文件或数据库。由于我们没有任何可用的数据,我们将使用random模块创建一些。我们还更改了numpy_wrapper模块的名称,以便于从我们的代码访问。
现在我们使用random模块创建一些要处理的资金转账数据:
days = [1, 2, 3, 4, 5, 6, 7, 8]
transfers = []
for i in range(10000):
day = random.choice(days)
bank_code = random.choice(BANK_CODES)
branch_id = random.choice(BRANCH_IDS)
amount = random.randint(1000, 1000000)
transfers.append((day, bank_code, branch_id, amount))在这里,我们随机选择一天、一个银行代码、一个分行 ID 和一个金额,将这些值存储在transfers列表中。
我们的下一个任务是将这些信息整理成一系列数组。这允许我们计算每天转账的总值,按分行 ID 和目的银行分组。为此,我们将为每天创建一个 NumPy 数组,其中每个数组中的行表示分支,列表示目标银行。然后我们将浏览传输列表,逐一处理它们。下图总结了我们如何依次处理每次转账:
首先,我们为转账发生的当天选择数组,然后根据目的银行和分行 ID 选择适当的行和列。最后,我们将转账金额添加到当天数组中的该项目中。
让我们实现这个逻辑。我们的第一个任务是创建一系列 NumPy 阵列,每天一个。在这里,我们立即遇到了一个障碍:NumPy 有许多不同的创建阵列的选项;在本例中,我们希望创建一个包含整数值并将其内容初始化为零的数组。如果我们直接使用 NumPy,我们的代码如下所示:
array = numpy.zeros((num_rows, num_cols), dtype=numpy.int32)这并不容易理解,所以我们将把这个逻辑转移到 NumPy 包装器模块中。编辑numpy_wrapper.py文件,并在本模块末尾添加以下内容:
def new(num_rows, num_cols):
return numpy.zeros((num_rows, num_cols), dtype=numpy.int32)现在,我们可以通过调用包装函数(npw.new()来创建一个新数组,而不必担心 NumPy 如何工作的细节。我们简化了 NumPy 这一特定方面的接口:
现在让我们使用包装器函数创建我们需要的八个数组,每天一个。在detect_unusual_transfers.py文件末尾添加以下内容:
transfers_by_day = {}
for day in days:
transfers_by_day[day] = npw.new(num_rows=len(BANK_CODES),
num_cols=len(BRANCH_IDS))现在我们有了 NumPy 数组,我们可以像使用嵌套 Python 列表一样使用它们。例如:
array[row][col] = array[row][col] + amount我们只需要选择适当的数组,并计算要使用的行数和列数。以下是必要的代码,您应该将其添加到detect_unusual_transfers.py脚本的末尾:
for day,bank_code,branch_id,amount in transfers:
array = transfers_by_day[day]
row = BRANCH_IDS.index(branch_id)
col = BANK_CODES.index(bank_code)
array[row][col] = array[row][col] + amount现在我们已经将传输整理成八个 NumPy 数组,我们希望使用所有这些数据来检测任何异常活动。对于分行 ID 和目的地银行代码的每个组合,我们需要执行以下操作:
- 计算前七天活动的平均值。
- 将计算出的平均值乘以 1.5。
- 如果第八天的活动大于平均乘以 1.5,那么我们认为这一活动是不寻常的。
当然,我们需要对数组中的每一行和每一列执行此操作,这将非常缓慢;这就是我们使用 NumPy 的原因。因此,我们需要计算多个数字数组的平均值,然后将平均值数组乘以 1.5,最后,将乘法数组中的值与第八天数据的数组进行比较。幸运的是,这些都是 NumPy 可以为我们做的事情。
我们将首先收集需要求平均值的七个数组,以及第八天的数组。为此,请在程序末尾添加以下内容:
latest_day = max(days)
transfers_to_average = []
for day in days:
if day != latest_day:
transfers_to_average.append(transfers_by_day[day])
current = transfers_by_day[latest_day]要计算数组列表的平均值,NumPy 要求我们使用以下函数调用:
average = numpy.mean(numpy.array(arrays_to_average), axis=0)由于这很混乱,我们将把这个函数移到包装器中。在numpy_wrapper.py模块末尾添加以下代码:
def average(arrays_to_average):
return numpy.mean(numpy.array(arrays_to_average), axis=0)这使我们能够使用对包装器函数的单个调用来计算七天活动的平均值。为此,请在detect_unusual_transfers.py脚本末尾添加以下内容:
average = npw.average(transfers_to_average)正如您所看到的,使用包装器使我们的代码更容易理解。
我们的下一个任务是将计算出的平均值数组乘以 1.5,并将结果与当天的总数进行比较。幸运的是,NumPy 让这变得简单:
unusual_transfers = current > average * 1.5因为这段代码非常清晰,所以为它创建包装函数没有任何好处。结果数组unusual_transfers将与我们的current和average数组大小相同,其中数组中的每个条目都是True或False:
我们差不多完成了;我们的最终任务是识别值为True的数组条目,并告诉用户异常活动。虽然我们可以扫描每一行和每一列来找到True条目,但使用 NumPy 要快得多。以下 NumPy 代码将为我们提供一个列表,其中包含数组中True项的行号和列号:
indices = numpy.transpose(array.nonzero())尽管如此,这段代码很难理解,所以它是另一个包装器函数的完美候选者。返回您的numpy_wrapper.py模块,并在文件末尾添加以下内容:
def get_indices(array):
return numpy.transpose(array.nonzero())此函数返回数组中所有True项的(row,col)值列表(实际上是一个数组)。回到我们的detect_unusual_activity.py文件中,我们可以使用此功能快速识别异常活动:
for row,col in npw.get_indices(unusual_transfers):
branch_id = BRANCH_IDS[row]
bank_code = BANK_CODES[col]
average_amt = int(average[row][col])
current_amt = current[row][col]
print("Branch {} transferred ${:,d}".format(branch_id,
current_amt) +
" to bank {}, average = ${:,d}".format(bank_code,
average_amt))正如您所见,我们使用BRANCH_IDS和BANK_CODES列表将行和列编号转换回相关的分行 ID 和银行代码。我们还检索可疑活动的平均金额和当前金额。最后,我们打印出这些信息来警告用户异常活动。
如果运行程序,您应该会看到如下输出:
Branch 125000371 transferred $24,729,847 to bank WFBIUS6S, average = $14,954,617
Branch 125000402 transferred $26,818,710 to bank CERYUS33, average = $16,338,043
Branch 125001067 transferred $27,081,511 to bank EQTYUS44, average = $17,763,644因为我们的财务数据使用随机数,所以输出也将是随机的。试着运行程序几次;如果随机生成的值都不可疑,则可能根本得不到任何输出。
当然,我们对发现可疑的金融活动并不感兴趣。这个例子只是与 NumPy 合作的一个借口。更有趣的是我们创建的包装器模块,它隐藏了 NumPy 接口的复杂性,以便程序的其余部分可以专注于要完成的工作。
如果我们继续开发不寻常的活动检测器,毫无疑问,我们会在numpy_wrapper.py模块中添加更多功能,因为我们发现了更多想要包装的 NumPy 函数。
这只是包装器模块的一个示例。正如我们前面提到的,简化复杂而混乱的 API 只是包装器模块的一种用途;它们还可以用于将数据从一种格式转换为另一种格式,向现有 API 添加测试和错误检查代码,以及调用用不同语言编写的函数。
请注意,根据定义,包装器总是薄——虽然包装器中可能有代码(例如,将对象中的参数转换为字典),但包装器函数总是调用另一个函数来完成实际工作。
大多数情况下,模块提供的功能是预先知道的。模块的源代码实现了一组定义良好的行为,这就是模块所做的一切。但是,在某些情况下,您可能需要一个在编写时没有完全定义模块行为的模块。系统的其他部分可以以各种方式扩展模块的行为。设计用于扩展的模块称为可扩展模块。
Python 的一大优点是它是一种动态语言。在运行之前,不需要定义和编译所有代码。这使得使用 Python 创建可扩展模块变得容易。
在本节中,我们将研究三种不同的方式来扩展模块:通过使用动态导入,通过编写插件,以及使用钩子。
在上一章中,我们创建了一个名为renderers.py的模块,该模块选择了一个合适的渲染器模块,以使用给定的输出格式绘制图表元素。以下是本模块源代码的缩写:
from .png import title as title_png
from .png import x_axis as x_axis_png
from .pdf import title as title_pdf
from .pdf import x_axis as x_axis_pdf
renderers = {
'png' : {
'title' : title_png,
'x_axis' : x_axis_png,
},
'pdf' : {
'title' : title_pdf,
'x_axis' : x_axis_pdf,
}
}
def draw(format, element, chart, output):
renderers[format][element].draw(chart, output)这个模块很有趣,因为它以有限的方式实现了可扩展性的概念。注意renderer.draw()函数调用另一个模块中的draw()函数来完成实际工作;使用哪个模块取决于所需的图表格式和要绘制的元素。
此模块不是真正可扩展的,因为可能的模块列表由模块顶部的import语句确定。但是,可以通过使用importlib将其转换为完全可扩展的模块。这是 Python 标准库中的一个模块,开发人员可以访问用于导入模块的内部机制;使用importlib可以动态导入模块。
为了理解这是如何工作的,让我们看一个例子。创建一个新目录来保存您的源代码,并在此目录中创建一个名为module_a.py的新模块。在此模块中输入以下代码:
def say_hello():
print("Hello from module_a")现在,创建一个名为module_b.py的模块副本,并编辑say_hello()函数以打印来自模块 _b 的Hello。然后,重复该过程创建module_c.py。
我们现在有三个模块,它们都实现了一个名为say_hello()的函数。现在,在同一目录中创建另一个 Python 源文件,并将其命名为load_module.py。然后,在此文件中输入以下内容:
import importlib
module_name = input("Load module: ")
if module_name != "":
module = importlib.import_module(module_name)
module.say_hello()此程序提示用户使用input()语句输入字符串。然后我们调用importlib.import_module()以导入具有该名称的模块,并调用该模块的say_hello()函数。
尝试运行此程序,出现提示时,键入module_a。您应该会看到显示以下消息:
Hello from module_a尝试对其他模块重复此过程。如果您输入一个不存在的模块的名称,您将得到一个ImportError。
当然,importlib并不局限于导入当前模块所在目录下的模块;如果需要,可以包括包名称。例如:
module = importlib.import_module("package.sub_package.module")使用importlib,您可以动态导入模块,而无需在编写程序时知道模块的名称。我们可以用它重写上一章中的renderer.py模块,使其完全可扩展:
from importlib import import_module
def draw(format, element, chart, output):
renderer = import_module("{}.{}.{}".format(__package__,
format,
element))
renderer.draw(chart, output)注意特殊的__package__变量的使用。它包含包含当前模块的包的名称;使用此选项,我们可以导入与renderer.py模块所属包相关的模块。
动态导入的好处在于,在创建程序时,您不需要知道所有模块都是什么。使用renderer.py示例,您可以通过创建新的渲染器模块来添加新的图表格式或元素,系统将在请求时导入它们,而无需对renderer.py模块进行任何更改。
插件是用户(或其他开发人员)编写并“插入”到程序中的模块。插件在许多大型系统中都很流行,如 WordPress、JQuery、Google Chrome 和 Adobe Photoshop。插件用于扩展现有程序的功能。
在 Python 中,使用我们在上一节中讨论过的相同的动态导入机制来实现插件非常容易。唯一的区别是,您没有导入已经是程序源代码一部分的模块,而是设置了一个单独的目录,用户可以在其中放置他们想要添加到程序中的插件。这可以简单到在程序的顶层创建一个plugins目录,或者您可以将插件存储在程序源代码之外的目录中,并修改sys.path,以便 Python 解释器可以在该目录中找到模块。无论哪种方式,您的程序都将使用importlib.import_module()加载所需的插件,然后访问插件中的函数和其他定义,就像您访问任何其他 Python 模块中的函数和其他定义一样。
本章提供的示例代码包括一个简单的插件加载器,它显示了这种机制的工作原理。
钩子是一种方式,允许在程序中的特定点调用外部代码。钩子通常是程序检查是否定义了钩子函数的函数,如果定义了钩子函数,程序会在适当的时间调用该函数。
让我们看一个具体的例子。假设您有一个程序,其中包括用户登录和注销的功能。您的部分程序可能包括以下模块,我们将其称为login_module.py:
cur_user = None
def login(username, password):
if is_password_correct(username, password):
cur_user = username
return True
else:
return False
def logout():
cur_user = None现在,假设您想要添加一个钩子,每当用户登录时就会调用它。将此添加到您的程序将涉及此模块的以下更改:
cur_user = None
login_hook = None
def set_login_hook(hook):
login_hook = hook
def login(username, password):
if is_password_correct(username, password):
cur_user = username
if login_hook != None:
login_hook(username)
return True
else:
return False
def logout():
cur_user = None有了这个代码,您的系统的其他部分可以通过设置自己的登录钩子功能来钩住您的登录过程,这会在用户登录时执行一些操作。例如:
def my_login_hook(username):
if user_has_messages(username):
show_messages(username)
login_module.set_login_hook(my_login_hook)通过实现这个登录钩子,您在不改变登录模块本身的情况下扩展了登录过程的行为。
使用挂钩时需要注意以下几点:
- 根据实现钩子的行为,钩子函数返回的值可能用于更改代码的行为。例如,如果登录钩子返回
False,则可能会阻止用户登录。这并不适用于每一个钩子,但它是一种非常有用的方法,可以让钩子函数更好地控制程序中发生的事情。 - 在这个例子中,我们只允许为每个钩子定义一个钩子函数。实现这一点的另一种方法是拥有一个已注册的钩子函数列表,并让您的程序根据需要添加或删除钩子函数。通过这种方式,您可以拥有多个钩子函数,每当发生某些事情时,这些钩子函数会被一个接一个地调用。
挂钩是向模块添加特定扩展点的一种极好的方式。它们易于实现和使用,与动态导入和插件不同,它们不需要您将代码放入单独的模块中。这意味着挂钩是以非常细粒度的方式扩展模块的理想方式。
在本章中,我们看到模块和包的使用方式往往遵循标准模式。我们研究了分而治之的模式,即将问题分解为更小的部分的过程,并了解了这种技术如何有助于构建程序并澄清您对试图解决的问题的想法。
接下来我们研究了抽象模式,这是一个通过分离您想做什么和如何做来隐藏复杂性的过程。然后,我们研究了封装的概念,即存储关于某个内容的数据,但对系统的其余部分隐藏数据表示方式的细节,并使用 getter 和 setter 函数提供对该数据的访问。
然后,我们转向包装器的概念,并了解如何使用包装器将接口简化为复杂或混乱的 API,转换数据格式,实现跨语言兼容性,以及向现有 API 添加测试和错误检查代码。
最后,我们学习了可扩展模块,并了解了如何使用动态模块导入、插件和挂钩技术来创建一个比您设计的模块做得更多的模块。我们看到,Python 的动态特性使其非常适合创建可扩展模块,而在编写模块时,模块的行为尚未完全定义。
在下一章中,我们将学习如何设计和实现可在其他程序中共享和重用的模块。




