模块化编程是一种组织程序源代码的方式。通过将代码组织成模块(Python源文件)和包(模块集合),然后将这些模块和包导入到程序中,您可以保持程序的逻辑组织,并将潜在问题降至最低。
随着程序的增长和变化,您经常需要重写或扩展代码的某些部分。模块化编程技术有助于管理这些变化,最小化副作用,并控制代码。
当您使用模块化编程技术时,您将学习一些常见的使用模块和包的模式,包括编程的分而治之方法,抽象和封装的使用,以及编写可扩展模块的概念。
模块化编程技术也是共享代码的好方法,可以通过使其可供他人使用或在另一个程序中重用您的代码。使用流行工具如GitHub和Python包索引,您将学习如何发布您的代码,以及使用其他人编写的代码。
将所有这些技术结合起来,您将学习如何应用“模块化思维”来创建更好的程序。您将看到模块如何用于处理大型程序中的复杂性和变化,以及模块化编程实际上是良好编程技术的基础。
在本书结束时,您将对Python中的模块和包的工作原理有很好的理解,并且知道如何使用它们来创建高质量和健壮的软件,可以与他人共享。
第一章,“介绍模块化编程”,探讨了您可以使用Python模块和包来帮助组织程序的方式,为什么使用模块化技术很重要,以及模块化编程如何帮助您处理持续的编程过程。
第二章,“编写您的第一个模块化程序”,介绍了编程的“分而治之”方法,并将此技术应用于基于模块化编程原则构建库存控制系统的过程。
第三章,“使用模块和包”,涵盖了使用Python进行模块化编程的基础知识,包括嵌套包,包和模块初始化技术,相对导入,选择导入内容,以及如何处理循环引用。
第四章,“将模块用于实际编程”,使用图表生成库的实现来展示模块化技术如何以最佳方式处理不断变化的需求。
第五章,“使用模块模式”,探讨了一些与模块和包一起使用的标准模式,包括分而治之技术,抽象,封装,包装器,以及如何使用动态导入,插件和钩子编写可扩展模块。
第六章,“创建可重用模块”,展示了如何设计和创建旨在与其他人共享的模块和包。
第七章,“高级模块技术”,探讨了Python中模块化编程的一些更独特的方面,包括可选和本地导入,调整模块搜索路径,“要注意的事项”,如何使用模块和包进行快速应用程序开发,处理包全局变量,包配置和包数据文件。
第八章,“测试和部署模块”探讨了单元测试的概念,如何准备您的模块和包以供发布,如何上传和发布您的工作,以及如何使用其他人编写的模块和包。
第九章,“作为良好编程技术基础的模块化编程”展示了模块化技术如何帮助处理编程的持续过程,如何处理变化和管理复杂性,以及模块化编程技术如何帮助您成为更有效的程序员。
在本书中跟随示例所需的只是运行任何最新版本的Python的计算机。虽然所有示例都使用Python3,但它们可以很容易地适应Python2,只需进行少量更改。
本书面向初学者到中级水平的Python程序员,希望使用模块化编程技术创建高质量和组织良好的程序。读者必须了解Python的基础知识,但不需要先前的模块化编程知识。
在这本书中,您会发现一些文本样式,用于区分不同类型的信息。以下是这些样式的一些示例以及它们的含义解释。
文本中的代码词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟URL、用户输入和Twitter句柄显示如下:“这个一行程序将被保存在磁盘上的一个文件中,通常命名为hello.py”
代码块设置如下:
[default]exten=>s,1,Dial(Zap/1|30)exten=>s,2,Voicemail(u100)exten=>s,102,Voicemail(b100)exten=>i,1,Voicemail(s0)任何命令行输入或输出都以以下方式编写:
#cp/usr/src/asterisk-addons/configs/cdr_mysql.conf.sample**/etc/asterisk/cdr_mysql.conf新术语和重要单词以粗体显示。您在屏幕上看到的单词,例如菜单或对话框中的单词,会以这种方式出现在文本中:“单击下一步按钮会将您移至下一个屏幕。”
警告或重要说明显示在这样的框中。
提示和技巧会以这种方式出现。
在这一章中,我们将:
让我们开始学习模块和它们的工作原理。
对于大多数初学者程序员来说,他们的第一个Python程序是著名的HelloWorld程序的某个版本。这个程序可能看起来像这样:
print("HelloWorld!")这个一行程序将保存在磁盘上的一个文件中,通常命名为hello.py,并且通过在终端或命令行窗口中输入以下命令来执行:
pythonhello.py然后Python解释器将忠实地打印出您要求它打印的消息:
HelloWorld!这个hello.py文件被称为Python源文件。当您刚开始时,将所有程序代码放入单个源文件是组织程序的好方法。您可以定义函数和类,并在底部放置指令,当您使用Python解释器运行程序时,它会启动您的程序。将程序代码存储在Python源文件中可以避免每次想要告诉Python解释器该做什么时都需要重新输入它。
然而,随着您的程序变得更加复杂,您会发现越来越难以跟踪您定义的所有各种函数和类。您会忘记放置特定代码的位置,并且发现越来越难记住所有各种部分是如何组合在一起的。
模块化编程是一种组织程序的方式,随着程序变得更加复杂。您可以创建一个Python模块,一个包含Python源代码以执行某些有用功能的源文件,然后将此模块导入到您的程序中,以便您可以使用它。例如,您的程序可能需要跟踪程序运行时发生的各种事件的各种统计信息。最后,您可能想知道每种类型的事件发生了多少次。为了实现这一点,您可以创建一个名为stats.py的Python源文件,其中包含以下Python代码:
definit():global_stats_stats={}defevent_occurred(event):global_statstry:_stats[event]=_stats[event]+1exceptKeyError:_stats[event]=1defget_stats():global_statsreturnsorted(_stats.items())stats.pyPython源文件定义了一个名为stats的模块—正如您所看到的,模块的名称只是源文件的名称,不包括.py后缀。您的主程序可以通过导入它并在需要时调用您定义的各种函数来使用这个模块。以下是一个无聊的例子,展示了如何使用stats模块来收集和显示有关事件的统计信息:
importstatsstats.init()stats.event_occurred("meal_eaten")stats.event_occurred("snack_eaten")stats.event_occurred("meal_eaten")stats.event_occurred("snack_eaten")stats.event_occurred("meal_eaten")stats.event_occurred("diet_started")stats.event_occurred("meal_eaten")stats.event_occurred("meal_eaten")stats.event_occurred("meal_eaten")stats.event_occurred("diet_abandoned")stats.event_occurred("snack_eaten")forevent,num_timesinstats.get_stats():print("{}occurred{}times".format(event,num_times))当然,我们对记录餐点不感兴趣—这只是一个例子—但这里需要注意的重要事情是stats模块如何被导入,以及stats.py文件中定义的各种函数如何被使用。例如,考虑以下代码行:
stats.event_occurred("snack_eaten")因为event_occurred()函数是在stats模块中定义的,所以每当您引用这个函数时,都需要包括模块的名称。
有多种方法可以导入模块,这样你就不需要每次都包含模块的名称。我们将在第三章使用模块和包中看到这一点,当我们更详细地了解命名空间和import命令的工作方式时。
正如您所看到的,import语句用于加载一个模块,每当您看到模块名称后跟着一个句点,您就可以知道程序正在引用该模块中定义的某个东西(例如函数或类)。
就像Python模块允许您将函数和类组织到单独的Python源文件中一样,Python包允许您将多个模块组合在一起。
Python包是具有特定特征的目录。例如,考虑以下Python源文件目录:
这个Python包叫做animals,包含五个Python模块:cat、cow、dog、horse和sheep。还有一个名为__init__.py的特殊文件。这个文件被称为包初始化文件;这个文件的存在告诉Python系统这个目录包含一个包。包初始化文件还可以用于初始化包(因此得名),也可以用于使导入包变得更容易。
从Python3.3版本开始,包不总是需要包含初始化文件。然而,没有初始化文件的包(称为命名空间包)仍然相当罕见,只在非常特定的情况下使用。为了保持简单,我们将在本书中始终使用常规包(带有__init__.py文件)。
就像我们在调用模块内的函数时使用模块名称一样,当引用包内的模块时,我们使用包名称。例如,考虑以下代码:
importanimals.cowanimals.cow.speak()在此示例中,speak()函数是在cow.py模块中定义的,它本身是animals包的一部分。
下载示例代码
模块和包不仅仅是用来将Python代码分布在多个源文件和目录中的,它们还允许您组织您的代码以反映程序试图做什么的逻辑结构。例如,想象一下,您被要求创建一个Web应用程序来存储和报告大学考试成绩。考虑到您得到的业务需求,您为应用程序提出了以下整体结构:
该程序分为两个主要部分:一个网络界面,用于与用户交互(以及通过API与其他计算机程序交互),以及一个后端,用于处理将信息存储在数据库中的内部逻辑、生成报告和向学生发送电子邮件的逻辑。正如您所看到的,网络界面本身已被分解为四个部分:
在考虑应用程序的每个逻辑组件(即上图中的每个框)时,您也开始考虑每个组件将提供的功能。在这样做时,您已经在模块化方面进行思考。实际上,应用程序的每个逻辑组件都可以直接实现为Python模块或包。例如,您可以选择将程序分为两个主要包,命名为web和backend,其中:
正如您所看到的,上图中的每个阴影框都成为了一个Python模块,每个框的分组都成为了一个Python包。
一旦您决定要定义的包和模块集合,您就可以开始通过在每个模块中编写适当的函数集来实现每个组件。例如,backend.database模块可能有一个名为get_students_results()的函数,它返回给定科目和年份的单个学生的考试结果。
在实际的Web应用程序中,您的模块化结构可能实际上会有所不同。这是因为您通常使用诸如Django之类的Web应用程序框架来创建Web应用程序,该框架会对您的程序施加自己的结构。但是,在这个例子中,我们将模块化结构保持得尽可能简单,以展示业务功能如何直接转化为包和模块。
显然,这个例子是虚构的,但它展示了您如何以模块化的方式思考复杂的程序,将其分解为单独的组件,然后依次使用Python模块和包来实现这些组件中的每一个。
使用模块化设计技术的一大好处是,它们迫使您考虑程序应该如何结构化,并允许您定义一个随着程序发展而增长的结构。您的程序将是健壮的,易于理解,易于在程序范围扩大时重新构造,也易于其他人一起使用。
木匠有一句座右铭同样适用于模块化编程:每样东西都有其位置,每样东西都应该在其位置上。这是高质量代码的标志之一,就像是一个组织良好的木匠车间的标志一样。
要了解为什么模块化编程是如此重要的技能,请想象一下,如果在编写程序时没有应用模块化技术会发生什么。如果您将所有的Python代码放入单个源文件中,不尝试逻辑地排列您的函数和类,并且只是随机地将新代码添加到文件的末尾,您最终会得到一堆难以理解的糟糕代码。以下是一个没有任何模块化组织的程序的示例:
这个程序是意大利面编程的一个例子——编程中所有东西都混在一起,源代码没有整体组织。不幸的是,意大利面编程经常与其他使程序更难理解的编程习惯结合在一起。一些更常见的问题包括:
虽然模块化编程不能治愈所有这些问题,但它迫使你考虑程序的逻辑组织,这将帮助你避免它们。将代码组织成逻辑片段将有助于你构建程序,以便你知道每个部分应该放在哪里。考虑包和模块,以及每个模块包含什么,将鼓励你为程序的各个部分选择清晰和适当的名称。使用模块和包还使得在编写过程中自然地包含文档字符串来解释程序的每个部分的功能。最后,使用逻辑结构鼓励程序的每个部分执行一个特定的任务,减少了代码中副作用的可能性。
当然,像任何编程技术一样,模块化编程也可能被滥用,但如果使用得当,它将大大提高你编写的程序的质量。
想象一下,你正在编写一个计算海外购买价格的程序。你的公司位于英格兰,你需要计算以美元购买的物品的当地价格。其他人已经编写了一个Python模块,用于下载汇率,所以你的程序开始看起来像下面这样:
defcalc_local_price(us_dollar_amount):exchange_rate=get_exchange_rate("USD","EUR")local_amount=us_dollar_amount*exchange_ratereturnlocal_amount到目前为止一切都很好。你的程序包含在公司的在线订购系统中,代码投入生产。然而,两个月后,你的公司开始不仅从美国订购产品,还从中国、德国和澳大利亚订购产品。你匆忙更新你的程序以支持这些替代货币,并写下了以下内容:
有时,程序员和IT经理试图抑制变更,例如通过编写详细的规范,然后逐步实现程序的一部分(所谓的瀑布编程方法)。但变更是编程的一个组成部分,试图抑制它就像试图阻止风吹一样——最好的办法是接受您的程序将发生变更,并学会尽可能好地管理这个过程。
模块化技术是管理程序变更的一种绝佳方式。例如,随着程序的增长和发展,您可能会发现某个变更需要向程序添加一个新模块:
然后,您可以在程序的其他部分导入和使用该模块,以便使用这个新功能。
或者,您可能会发现一个新功能只需要您更改一个模块的内容:
这是模块化编程的主要好处之一——因为特定功能的实现细节在一个模块内部,您通常可以改变模块的内部实现而不影响程序的其他部分。您的程序的其余部分继续像以前一样导入和使用模块——只有模块的内部实现发生了变化。
最后,您可能会发现需要重构您的程序。这是您必须改变代码的模块化组织以改进程序运行方式的地方:
重构可能涉及将代码从一个模块移动到另一个模块,以及创建新模块、删除旧模块和更改模块的工作方式。实质上,重构是重新思考程序,使其运行得更好的过程。
在所有这些变更中,使用模块和包可以帮助您管理所做的变更。因为各个模块和包都执行着明确定义的任务,您确切地知道程序的哪些部分需要被改变,并且可以将变更的影响限制在受影响的模块和使用它们的系统部分之内。
模块化编程不会让变更消失,但它将帮助您处理变更——以及编程的持续过程——以最佳方式。
用来描述Python的一个流行词是它是一种“电池包含”的语言,也就是说,它带有丰富的内置模块和包的集合,称为Python标准库。如果您编写了任何非平凡的Python程序,几乎肯定会使用Python标准库中的模块。要了解Python标准库有多么庞大,以下是该库中的一些示例模块:
这些只是Python标准库中可用的300多个模块中的一小部分。正如你所看到的,提供了广泛的功能,所有这些都内置在每个Python发行版中。
既然我们已经看到了模块是什么以及它们如何被使用,让我们实现我们的第一个真正的Python模块。虽然这个模块很简单,但你可能会发现它是你编写的程序的一个有用的补充。
在计算机编程中,缓存是一种存储先前计算结果的方式,以便可以更快地检索它们。例如,想象一下,你的程序必须根据三个参数计算运费:
根据客户的位置计算运费可能会非常复杂。例如,你可能对本市内的送货收取固定费用,但对于外地订单,根据客户的距离收取溢价。你甚至可能需要向货运公司的API发送查询,看看运送给定物品会收取多少费用。
由于计算运费的过程可能非常复杂和耗时,使用缓存来存储先前计算的结果是有意义的。这允许你使用先前计算的结果,而不是每次都重新计算运费。为此,你需要将你的calc_shipping_cost()函数结构化为以下内容:
defcalc_shipping_cost(params):ifparamsincache:shipping_cost=cache[params]else:...calculatetheshippingcost.cache[params]=shipping_costreturnshipping_cost正如你所看到的,我们接受提供的参数(在这种情况下是重量、尺寸和客户位置),并检查是否已经有一个缓存条目与这些参数匹配。如果是,我们从缓存中检索先前计算的运费。否则,我们将经历可能耗时的过程来计算运费,使用提供的参数将其存储在缓存中,然后将运费返回给调用者。
请注意,前面伪代码中的cache变量看起来非常像Python字典——你可以根据给定的键在字典中存储条目,然后使用该键检索条目。然而,字典和缓存之间有一个关键区别:缓存通常对其包含的条目数量有一个限制,而字典没有这样的限制。这意味着字典将继续无限增长,可能会占用计算机的所有内存,而缓存永远不会占用太多内存,因为条目数量是有限的。
一旦缓存达到最大尺寸,每次添加新条目时都必须删除一个现有条目,以防缓存继续增长:
缓存在计算机程序中非常常见。事实上,即使你在编写程序时还没有使用缓存,你几乎肯定以前遇到过它们。有人曾经建议你清除浏览器缓存来解决浏览器问题吗?是的,浏览器使用缓存来保存先前下载的图像和网页,这样它们就不必再次检索,清除浏览器缓存的内容是修复浏览器问题的常见方法。
现在让我们编写自己的Python模块来实现一个缓存。在写之前,让我们考虑一下我们的缓存模块将需要的功能:
我们故意保持这个模块的实现相当简单。一个真正的缓存会使用Cache类来允许您同时使用多个缓存。它还将允许根据需要配置缓存的大小。然而,为了保持简单,我们将直接在一个模块中实现这些函数,因为我们想专注于模块化编程,而不是将其与面向对象编程和其他技术结合在一起。
继续创建一个名为cache.py的新Python源文件。这个文件将保存我们新模块的Python源代码。在这个模块的顶部,输入以下Python代码:
importdatetimeMAX_CACHE_SIZE=100我们将使用datetime标准库模块来计算缓存中最近未使用的条目。第二个语句定义了MAX_CACHE_SIZE,设置了我们缓存的最大尺寸。
请注意,我们遵循了使用大写字母定义常量的标准Python约定。这样可以使它们在源代码中更容易看到。
现在我们要为我们的缓存实现init()函数。为此,在模块的末尾添加以下内容:
definit():global_cache_cache={}#Mapskeyto(datetime,value)tuple.如你所见,我们创建了一个名为init()的新函数。这个函数的第一条语句global_cache定义了一个名为_cache的新变量。global语句使得这个变量作为模块级全局变量可用,也就是说,这个变量可以被cache.py模块的所有部分共享。
注意变量名开头的下划线字符。在Python中,前导下划线是指示名称为私有的约定。换句话说,_cache全局变量旨在作为cache.py模块的内部部分使用——下划线告诉你,你不应该在cache.py模块之外使用这个变量。
总之,调用init()函数的效果是在模块内创建一个私有的_cache变量,并将其设置为空字典。现在让我们编写set()函数,它将使用这个变量来存储缓存条目。
将以下内容添加到模块的末尾:
defset(key,value):global_cacheifkeynotin_cacheandlen(_cache)>=MAX_CACHE_SIZE:_remove_oldest_entry()_cache[key]=[datetime.datetime.now(),value]一次又一次,set()函数以global_cache语句开始。这使得_cache模块级全局变量可供函数使用。
if语句检查缓存是否将超过允许的最大大小。如果是,我们调用一个名为_remove_oldest_entry()的新函数,从缓存中删除最旧的条目。注意这个函数名也以下划线开头——再次说明这个函数是私有的,只应该被模块内部的代码使用。
现在实现get()函数。将以下内容添加到模块的末尾:
有了这些函数的实现,剩下的两个函数也应该很容易理解。将以下内容添加到模块的末尾:
defcontains(key):global_cachereturnkeyin_cachedefsize():global_cachereturnlen(_cache)这里不应该有任何意外。
只剩下一个函数需要实现:我们的私有_remove_oldest_entry()函数。将以下内容添加到模块的末尾:
def_remove_oldest_entry():global_cacheoldest=Noneforkeyin_cache.keys():ifoldest==None:oldest=keyelif_cache[key][0]<_cache[oldest][0]:oldest=keyifoldest!=None:del_cache[oldest]这完成了我们cache.py模块本身的实现,包括我们之前描述的五个主要函数,以及一个私有函数和一个私有全局变量,它们在内部用于帮助实现我们的公共函数。
现在让我们编写一个简单的测试程序来使用这个cache模块,并验证它是否正常工作。创建一个新的Python源文件,我们将其称为test_cache.py,并将以下内容添加到该文件中:
importrandomimportstringimportcachedefrandom_string(length):s=''foriinrange(length):s=s+random.choice(string.ascii_letters)returnscache.init()forninrange(1000):whileTrue:key=random_string(20)ifcache.contains(key):continueelse:breakvalue=random_string(20)cache.set(key,value)print("After{}iterations,cachehas{}entries".format(n+1,cache.size()))这个程序首先导入了三个模块:两个来自Python标准库,以及我们刚刚编写的cache模块。然后我们定义了一个名为random_string()的实用函数,它生成给定长度的随机字母字符串。之后,我们通过调用cache.init()来初始化缓存,然后生成1,000个随机条目添加到缓存中。在添加每个缓存条目后,我们打印出我们添加的条目数以及当前的缓存大小。
如果你运行这个程序,你会发现它按预期工作:
$pythontest_cache.pyAfter1iterations,cachehas1entriesAfter2iterations,cachehas2entriesAfter3iterations,cachehas3entries...After98iterations,cachehas98entriesAfter99iterations,cachehas99entriesAfter100iterations,cachehas**100entriesAfter101iterations,cachehas100entriesAfter102iterations,cachehas100entries...After998iterations,cachehas100entriesAfter999iterations,cachehas100entriesAfter1000iterations,cachehas100entries缓存会不断增长,直到达到100个条目,此时最旧的条目将被移除以为新条目腾出空间。这确保了缓存保持相同的大小,无论添加了多少新条目。
虽然我们可以在cache.py模块中做更多的事情,但这已足以演示如何创建一个有用的Python模块,然后在另一个程序中使用它。当然,你不仅仅局限于在主程序中导入模块,模块也可以相互导入。
在本章中,我们介绍了Python模块的概念,看到Python模块只是Python源文件,可以被另一个源文件导入和使用。然后我们看了Python包,发现这些是由一个名为__init__.py的包初始化文件标识的模块集合。
我们探讨了模块和包如何用于组织程序的源代码,以及为什么使用这些模块化技术对于大型系统的开发非常重要。我们还探讨了意大利面条式代码的样子,发现如果不对程序进行模块化,可能会出现一些其他陷阱。
接下来,我们将编程视为不断变化和发展的过程,以及模块化编程如何帮助以最佳方式处理不断变化的代码库。然后我们了解到Python标准库是大量模块和包的绝佳示例,并通过创建自己的简单Python模块来展示有效的模块化编程技术。在实现这个模块时,我们学会了模块如何使用前导下划线来标记变量和函数名称为模块的私有,同时使其余函数和其他定义可供系统的其他部分使用。
在下一章中,我们将应用模块化技术来开发一个更复杂的程序,由几个模块共同解决一个更复杂的编程问题。
在本章中,我们将使用模块化编程技术来实现一个非平凡的程序。在此过程中,我们将:
假设您被要求编写一个程序,允许用户跟踪公司的库存,即公司可供销售的各种物品。对于每个库存物品,您被要求跟踪产品代码和物品当前的位置。新物品将在收到时添加,已售出的物品将在售出后移除。您的程序还需要生成两种类型的报告:列出公司当前库存的报告,包括每种物品在每个位置的数量,以及用于在物品售出后重新订购库存物品的报告。
查看这些要求,很明显我们需要存储三种不同类型的信息:
运行程序时,最终用户应能执行以下操作:
虽然这个程序并不太复杂,但这里有足够的功能可以从模块化设计中受益,同时保持我们的讨论相对简洁。既然我们已经看了我们的程序需要做什么以及我们需要存储的信息,让我们开始应用模块化编程技术来设计我们的系统。
如果您退后一步,审查我们的库存控制程序的功能,您会发现这个程序需要支持三种基本类型的活动:
虽然这很笼统,但这种分解很有帮助,因为它提出了组织程序代码的可能方式。例如,负责存储信息的系统部分可以存储产品、位置和库存物品的列表,并在需要时提供这些信息。同样,负责与用户交互的系统部分可以提示用户选择要执行的操作,要求他们选择产品代码等。最后,负责生成报告的系统部分将能够生成所需类型的报告。
以这种方式思考系统,很明显,系统的这三个部分可以分别实现为单独的模块:
正如名称所示,每个模块都有特定的目的。除了这些专用模块,我们还需要系统的另一个部分:一个Python源文件,用户执行以启动和运行库存控制系统。因为这是用户实际运行的部分,我们将称其为主程序,通常存储在名为main.py的Python源文件中。
现在我们的系统有四个部分:三个模块加上一个主程序。每个部分都将有特定的工作要做,各个部分通常会相互交互以执行特定的功能。例如,报告生成器模块将需要从数据存储模块获取可用产品代码的列表。这些各种交互在下图中用箭头表示:
现在我们对程序的整体结构有了一个概念,让我们更仔细地看看这四个部分中的每一个是如何工作的。
这个模块将负责存储我们程序的所有数据。我们已经知道我们需要存储三种类型的信息:产品列表,位置列表和库存项目列表。
为了使我们的程序尽可能简单,我们将就数据存储模块做出两个重要的设计决定:
我们的库存控制系统的更复杂的实现会将这些信息存储在数据库中,并允许用户查看和编辑产品代码和位置列表。然而,在我们的情况下,我们更关心程序的整体结构,所以我们希望尽可能简单地实现。
虽然产品代码列表将被硬编码,但我们不一定希望将此列表构建到数据存储模块本身中。数据存储模块负责存储和检索信息,而不是定义产品代码列表的工作。因此,我们需要在数据存储模块中添加一个函数,用于设置产品代码列表。此函数将如下所示:
defset_products(products):...我们已经决定,对于每种产品,我们希望存储产品代码,描述和用户希望保留的物品数量。为了支持这一点,我们将定义产品列表(作为我们set_products()函数中的products参数提供)为(code,description,desired_number)元组的列表。例如,我们的产品列表可能如下所示:
[("CODE01","Product1",10),("CODE02","Product2",200),...]一旦产品列表被定义,我们可以提供一个函数根据需要返回此列表:
defproducts():...这将简单地返回产品列表,允许您的代码根据需要使用此列表。例如,您可以使用以下Python代码扫描产品列表:
forcode,description,desired_numberinproducts():...这两个函数允许我们定义(硬编码)产品列表,并在需要时检索此列表。现在让我们为位置列表定义相应的两个函数。
首先,我们需要一个函数来设置硬编码的位置列表:
defset_locations(locations):...locations列表中的每个项目将是一个(code,description)元组,其中code是位置的代码,description是描述位置的字符串,以便用户知道它在哪里。
然后我们需要一个函数根据需要检索位置列表:
deflocations():...再次返回位置列表,允许我们根据需要处理这些位置。
现在我们需要决定数据存储模块将如何允许用户存储和检索库存项目列表。库存项目被定义为产品代码加上位置代码。换句话说,库存项目是特定类型的产品在特定位置。
为了检索库存项目列表,我们将使用以下函数:
defitems():...遵循我们为products()和locations()函数使用的设计,items()函数将返回一个库存项目列表,其中每个库存项目都是一个(product_code,location_code)元组。
与产品和位置列表不同,库存项目列表不会被硬编码:用户可以添加和删除库存项目。为了支持这一点,我们需要两个额外的函数:
defadd_item(product_code,location_code):...defremove_item(product_code,location_code):...我们需要设计数据存储模块的最后一个部分:因为我们将在内存中存储库存项目列表,并根据需要将它们保存到磁盘,所以当程序启动时,我们需要一种方式将库存项目从磁盘加载到内存中。为了支持这一点,我们将为我们的模块定义一个初始化函数:
definit():...我们现在已经决定了数据存储模块的总共八个函数。这八个函数构成了我们模块的公共接口。换句话说,系统的其他部分将只能使用这八个函数与我们的模块进行交互:
注意我们在这里经历的过程:我们首先看了我们的模块需要做什么(在这种情况下,存储和检索信息),然后根据这些要求设计了模块的公共接口。对于前七个函数,我们使用业务需求来帮助我们设计接口,而对于最后一个函数init(),我们使用了我们对模块内部工作方式的知识来改变接口,以便模块能够完成其工作。这是一种常见的工作方式:业务需求和技术需求都将帮助塑造模块的接口以及它如何与系统的其他部分交互。
现在我们已经设计了我们的数据存储模块,让我们为系统中的其他模块重复这个过程。
用户界面模块将负责与用户进行交互。这包括向用户询问信息,以及在屏幕上显示信息。为了保持简单,我们将为我们的库存控制系统使用一个简单的基于文本的界面,使用print()语句来显示信息,使用input()来要求用户输入内容。
我们的库存控制系统的更复杂的实现将使用带有窗口、菜单和对话框的图形用户界面。这样做会使库存控制系统变得更加复杂,远远超出了我们在这里尝试实现的范围。然而,由于系统的模块化设计,如果我们重新编写用户界面以使用菜单、窗口等,我们只需要更改这一个模块,而系统的其他部分将不受影响。
这实际上是一个轻微的过度简化。用GUI替换基于文本的界面需要对系统进行许多更改,并且可能需要我们稍微更改模块的公共函数,就像我们不得不向数据存储模块添加init()函数以允许其内部工作方式一样。但是,由于我们正在设计系统的模块化方式,如果我们重写用户界面模块以使用GUI,其他模块将不受影响。
让我们从用户与系统交互的角度来考虑库存控制系统需要执行的各种任务:
让我们逐个解决这些交互:
defprompt_for_product():...用户将看到可用产品的列表,然后从列表中选择一个项目。如果他们取消,prompt_for_product()将返回None。否则,它将返回所选产品的产品代码。
同样,为了提示用户选择位置,我们将定义以下函数:
defprompt_for_location():...再次,这显示了可用位置的列表,用户可以从列表中选择一个位置。如果他们取消,我们返回None。否则,我们返回所选位置的位置代码。
使用这两个函数,我们可以要求用户标识新的库存项目,然后我们使用数据存储模块的add_item()函数将其添加到列表中。
defshow_report(report):...report参数将简单地是一个包含生成报告的字符串的列表。show_report()函数需要做的就是逐个打印这些字符串,以向用户显示报告的内容。
这完成了我们对用户界面模块的设计。我们需要为此模块实现四个公共函数。
报告生成器模块负责生成报告。由于我们需要能够生成两种类型的报告,所以我们只需在报告生成器模块中有两个公共函数,每种报告一个:
defgenerate_inventory_report():...defgenerate_reorder_report():...这些函数中的每一个都将生成给定类型的报告,将报告内容作为字符串列表返回。请注意,这些函数没有参数;因为我们尽可能保持简单,报告不会使用任何参数来控制它们的生成方式。
主程序不是一个模块。相反,它是一个标准的Python源文件,用户运行以启动系统。主程序将导入它需要的各种模块,并调用我们定义的函数来完成所有工作。在某种意义上,我们的主程序是将系统的所有其他部分粘合在一起的胶水。
在Python中,当一个源文件打算被运行(而不是被其他模块导入和使用,或者从Python命令行使用)时,通常使用以下结构的源文件:
defmain():...if__name__=="__main__":main()所有程序逻辑都写在main()函数内部,然后由文件中的最后两行调用。if__name__=="__main__"行是Python的一个魔术,基本上意味着如果正在运行这个程序。换句话说,如果用户正在运行这个程序,调用main()函数来完成所有工作。
我们可以将所有程序逻辑放在if__name__=="__main__"语句下面,但将程序逻辑放在一个单独的函数中有一些优点。通过使用单独的函数,我们可以在想要退出时简单地从这个函数返回。这也使得错误处理更容易,代码组织得更好,因为我们的主程序代码与检查我们是否实际运行程序的代码是分开的。
我们将使用这个设计作为我们的主程序,将所有实际功能放在一个名为main()的函数中。
我们的main()函数将执行以下操作:
步骤3和4将无限重复,直到用户退出。
现在我们对系统的整体结构有了一个很好的想法,我们的各种模块将是什么,它们将提供什么功能,是时候开始实施系统了。让我们从数据存储模块开始。
在一个方便的地方创建一个目录,可以在其中存储库存控制系统的源代码。您可能想将此目录命名为inventoryControl或类似的名称。
在这个目录中,我们将放置各种模块和文件。首先创建一个名为datastorage.py的新的空Python源文件。这个Python源文件将保存我们的数据存储模块。
我们已经知道我们将需要八个不同的函数来构成这个模块的公共接口,所以继续添加以下Python代码到这个模块中:
definit():passdefitems():passdefproducts():passdeflocations():passdefadd_item(product_code,location_code):passdefremove_item(product_code,location_code):passdefset_products(products):passdefset_locations(locations):passpass语句允许我们将函数留空-这些只是我们将要编写的代码的占位符。
现在让我们实现init()函数。这在系统运行时初始化数据存储模块。因为我们将库存物品列表保存在内存中,并在更改时将其保存到磁盘上,我们的init()函数将需要从磁盘上的文件中加载库存物品到内存中,以便在需要时可用。为此,我们将定义一个名为_load_items()的私有函数,并从我们的init()函数中调用它。
请记住,前导下划线表示某些内容是私有的。这意味着_load_items()函数不会成为我们模块的公共接口的一部分。
将init()函数的定义更改为以下内容:
definit():_load_items()_load_items()函数将从磁盘上的文件加载库存物品列表到一个名为_items的私有全局变量中。让我们继续实现这个函数,通过将以下内容添加到模块的末尾:
def_load_items():global_itemsifos.path.exists("items.json"):f=open("items.json","r")_items=json.loads(f.read())f.close()else:_items=[]请注意,我们将库存物品列表存储在名为items.json的文件中,并且我们正在使用json模块将_items列表从文本文件转换为Python列表。
JSON是保存和加载Python数据结构的绝佳方式,生成的文本文件易于阅读。由于json模块内置在Python标准库中,我们不妨利用它。
因为我们现在正在使用Python标准库中的一些模块,您需要将以下import语句添加到模块的顶部:
importjsonimportos.path趁热打铁,让我们编写一个函数将库存物品列表保存到磁盘上。将以下内容添加到模块的末尾:
def_save_items():global_itemsf=open("items.json","w")f.write(json.dumps(_items))f.close()由于我们已将库存物品列表加载到名为_items的私有全局变量中,我们现在可以实现items()函数以使这些数据可用。编辑items()函数的定义,使其看起来像下面这样:
defitems():global_itemsreturn_items现在让我们实现add_item()和remove_item()函数,让系统的其余部分操作我们的库存物品列表。编辑这些函数,使其看起来像下面这样:
defadd_item(product_code,location_code):global_items_items.append((product_code,location_code))_save_items()defremove_item(product_code,location_code):global_itemsforiinrange(len(_items)):prod_code,loc_code=_items[i]ifprod_code==product_codeandloc_code==location_code:del_items[i]_save_items()returnTruereturnFalse请注意,remove_item()函数如果成功移除该物品则返回True,否则返回False;这告诉系统的其余部分尝试移除库存物品是否成功。
由于我们知道我们将硬编码产品列表,set_products()函数将是微不足道的:
defset_products(products):global_products_products=products我们只需将产品列表存储在名为_products的私有全局变量中。然后,我们可以通过products()函数使这个列表可用:
defproducts():global_productsreturn_products同样,我们现在可以实现set_locations()函数来设置硬编码的位置列表:
defset_locations(locations):global_locations_locations=locations最后,我们可以实现locations()函数以使这些信息可用:
deflocations():global_locationsreturn_locations这完成了我们对datastorage模块的实现。
如前所述,用户界面模块将尽可能保持简单,使用print()和input()语句与用户交互。在这个系统的更全面的实现中,我们将使用图形用户界面(GUI)来显示并询问用户信息,但我们希望尽可能保持我们的代码简单。
有了这个想法,让我们继续实现我们的用户界面模块函数中的第一个。创建一个名为userinterface.py的新Python源文件来保存我们的用户界面模块,并将以下内容添加到此文件中:
defprompt_for_action():whileTrue:print()print("Whatwouldyouliketodo")print()print("A=addanitemtotheinventory.")print("R=removeanitemfromtheinventory.")print("C=generateareportofthecurrentinventorylevels.")print("O=generateareportoftheinventoryitemstore-order.")print("Q=quit.")print()action=input(">").strip().upper()ifaction=="A":return"ADD"elifaction=="R":return"REMOVE"elifaction=="C":return"INVENTORY_REPORT"elifaction=="O":return"REORDER_REPORT"elifaction=="Q":return"QUIT"else:print("Unknownaction!")正如您所看到的,我们提示用户输入与每个操作对应的字母,显示可用操作列表,并返回一个标识用户选择的操作的字符串。这不是实现用户界面的好方法,但它有效。
我们接下来要实现的函数是prompt_for_product(),它要求用户从可用产品代码列表中选择一个产品。为此,我们将不得不要求数据存储模块提供产品列表。将以下代码添加到你的userinterface.py模块的末尾:
defprompt_for_product():whileTrue:print()print("Selectaproduct:")print()n=1forcode,description,desired_numberindatastorage.products():print("{}.{}-{}".format(n,code,description))n=n+1s=input(">").strip()ifs=="":returnNonetry:n=int(s)exceptValueError:n=-1ifn<1orn>len(datastorage.products()):print("Invalidoption:{}".format(s))continueproduct_code=datastorage.products()[n-1][0]returnproduct_code在这个函数中,我们显示产品列表,并在每个产品旁边显示一个数字。然后用户输入所需产品的数字,我们将产品代码返回给调用者。如果用户没有输入任何内容,我们返回None——这样用户可以在不想继续的情况下按下Enter键而不输入任何内容。
趁热打铁,让我们实现一个相应的函数,要求用户确定一个位置:
defprompt_for_location():whileTrue:print()print("Selectalocation:")print()n=1forcode,descriptionindatastorage.locations():print("{}.{}-{}".format(n,code,description))n=n+1s=input(">").strip()ifs=="":returnNonetry:n=int(s)exceptValueError:n=-1ifn<1orn>len(datastorage.locations()):print("Invalidoption:{}".format(s))continuelocation_code=datastorage.locations()[n-1][0]returnlocation_code再次,这个函数显示每个位置旁边的数字,并要求用户输入所需位置的数字。然后我们返回所选位置的位置代码,如果用户取消,则返回None。
由于这两个函数使用了数据存储模块,我们需要在我们的模块顶部添加以下import语句:
importdatastorage我们只需要实现一个函数:show_report()函数。让我们现在这样做:
defshow_report(report):print()forlineinreport:print(line)print()由于我们使用文本界面来实现这个功能,这个函数几乎是荒谬地简单。不过它确实有一个重要的目的:通过将显示报告的过程作为一个单独的函数来实现,我们可以重新实现这个函数,以更有用的方式显示报告(例如,在GUI中的窗口中显示),而不会影响系统的其余部分。
报告生成器模块将有两个公共函数,一个用于生成每种类型的报告。话不多说,让我们实现这个模块,我们将把它存储在一个名为reportgenerator.py的Python源文件中。创建这个文件,并输入以下内容:
我们需要实现的系统的最后一部分是我们的主程序。创建另一个名为main.py的Python源文件,并将以下内容输入到这个文件中:
importdatastorageimportuserinterfaceimportreportgeneratordefmain():passif__name__=="__main__":main()这只是我们主程序的总体模板:我们导入我们创建的各种模块,定义一个main()函数,所有的工作都将在这里完成,并在程序运行时调用它。现在我们需要编写我们的main()函数。
我们的第一个任务是初始化其他模块并定义产品和位置的硬编码列表。让我们现在这样做,通过重写我们的main()函数,使其看起来像下面这样:
defmain():datastorage.init()datastorage.set_products([("SKU123","4mmflat-headwoodscrew",50),("SKU145","6mmflat-headwoodscrew",50),("SKU167","4mmcountersunkheadwoodscrew",10),("SKU169","6mmcountersunkheadwoodscrew",10),("SKU172","4mmmetalself-tappingscrew",20),("SKU185","8mmmetalself-tappingscrew",20),])datastorage.set_locations([("S1A1","Shelf1,Aisle1"),("S2A1","Shelf2,Aisle1"),("S3A1","Shelf3,Aisle1"),("S1A2","Shelf1,Aisle2"),("S2A2","Shelf2,Aisle2"),("S3A2","Shelf3,Aisle2"),("BIN1","StorageBin1"),("BIN2","StorageBin2"),])接下来,我们需要询问用户他们希望执行的操作,然后做出适当的响应。我们将从询问用户操作开始,使用while语句,以便可以重复执行这个操作:
whileTrue:action=userinterface.prompt_for_action()接下来,我们需要响应用户选择的操作。显然,我们需要针对每种可能的操作进行这样的操作。让我们从“退出”操作开始:
break语句将退出whileTrue语句,这样就会离开main()函数并关闭程序。
接下来,我们要实现“添加”操作:
ifaction=="QUIT":breakelifaction=="ADD":product=userinterface.prompt_for_product()ifproduct!=None:location=userinterface.prompt_for_location()iflocation!=None:datastorage.add_item(product,location)请注意,我们调用用户界面函数提示用户输入产品,然后输入位置代码,只有在函数没有返回None的情况下才继续。这意味着我们只有在用户没有取消的情况下才提示位置或添加项目。
现在我们可以实现“删除”操作的等效函数了:
elifaction=="REMOVE":product=userinterface.prompt_for_product()ifproduct!=None:location=userinterface.prompt_for_location()iflocation!=None:ifnotdatastorage.remove_item(product,location):pass#Whattodo这几乎与添加项目的逻辑完全相同,只有一个例外:datastorage.remove_item()函数可能会失败(返回False),如果该产品和位置代码没有库存项目。正如pass语句旁边的注释所建议的那样,当这种情况发生时,我们将不得不做一些事情。
我们现在已经达到了模块化编程过程中非常常见的一个点:我们设计了所有我们认为需要的功能,但后来发现漏掉了一些东西。当用户尝试移除一个不存在的库存项目时,我们希望显示一个错误消息,以便用户知道出了什么问题。因为所有用户交互都发生在userinterface.py模块中,我们希望将这个功能添加到该模块中。
现在让我们这样做。回到编辑userinterface.py模块,并在末尾添加以下函数:
defshow_error(err_msg):print()print(err_msg)print()再次强调,这是一个令人尴尬的简单函数,但它让我们可以将所有用户交互保持在userinterface模块中(并且允许以后重写我们的程序以使用GUI)。现在让我们用适当的错误处理代码替换main.py程序中的pass语句:
...ifnotdatastorage.remove_item(product,location):**userinterface.show_error(**"Thereisnoproductwith"+**"thatcodeatthatlocation!")不得不回去更改模块的功能是非常常见的。幸运的是,模块化编程使这个过程更加自包含,因此在这样做时,您不太可能出现副作用和其他错误。
现在用户可以添加和移除库存项目,我们只需要实现另外两个操作:INVENTORY_REPORT操作和REORDER_REPORT操作。对于这两个操作,我们只需要调用适当的报告生成器函数来生成报告,然后调用用户界面模块的show_report()函数来显示结果。现在让我们通过将以下代码添加到我们的main()函数的末尾来实现这一点:
elifaction=="INVENTORY_REPORT":report=reportgenerator.generate_inventory_report()userinterface.show_report(report)elifaction=="REORDER_REPORT":report=reportgenerator.generate_reorder_report()userinterface.show_report(report)这完成了我们main()函数的实现,实际上也完成了我们整个库存控制系统的实现。继续运行它。尝试输入一些库存项目,移除一两个库存项目,并生成两种类型的报告。如果您按照本书中提供的代码输入或下载了本章的示例代码,程序应该可以正常工作,为您提供一个简单但完整的库存控制系统,更重要的是,向您展示如何使用模块化编程技术实现程序。
在本章中,我们设计并实现了一个非平凡的程序来跟踪公司的库存。使用分而治之的方法,我们将程序分成单独的模块,然后查看每个模块需要提供的功能。这使我们更详细地设计了每个模块内的函数,并且我们随后能够一步一步地实现整个系统。我们发现一些功能被忽视了,需要在设计完成后添加,并且看到模块化编程如何使这些类型的更改不太可能破坏您的系统。最后,我们快速测试了库存控制系统,确保它可以正常工作。
在下一章中,我们将更多地了解Python中模块和包的工作原理。
要能够在Python程序中使用模块和包,您需要了解它们的工作原理。在本章中,我们将研究模块和包在Python中是如何定义和使用的。特别是,我们将:
到目前为止,您应该已经相当熟悉如何将您的Python代码组织成模块,然后在其他模块和程序中导入和使用这些模块。然而,这只是一个小小的尝试。在深入了解它们如何工作之前,让我们简要回顾一下Python模块和包是什么。
正如我们所看到的,模块只是一个Python源文件。您可以使用import语句导入模块:
importmy_module完成此操作后,您可以通过在项目名称前面添加模块名称来引用模块中的任何函数、类、变量和其他定义,例如:
my_module.do_something()print(my_module.variable)在第一章中,介绍模块化编程,我们了解到Python的包是一个包含名为__init__.py的特殊文件的目录。这被称为包初始化文件,并将目录标识为Python包。该包通常还包含一个或多个Python模块,例如:
要导入此包中的模块,您需要在模块名称的开头添加包名称。例如:
importmy_package.my_modulemy_package.my_module.do_something()您还可以使用import语句的另一种版本来使您的代码更易于阅读:
frommy_packageimportmy_modulemy_module.do_something()注意我们将在本章后面的如何导入任何内容部分中查看您可以使用import语句的各种方式。
就像您可以在目录中有子目录一样,您也可以在其他包中有包。例如,想象一下,我们的my_package目录包含另一个名为my_sub_package的目录,它本身有一个__init__.py文件:
正如您所期望的那样,您可以通过在包含它的包的名称前面添加来导入子包中的模块:
frommy_package.my_sub_packageimportmy_modulemy_module.do_something()您可以无限嵌套包,但实际上,如果包含太多级别的包中包,它会变得有些难以管理。更有趣的是,各种包和子包形成了一个树状结构,这使您可以组织甚至最复杂的程序。例如,一个复杂的商业系统可能会被安排成这样:
显然,这是一个极端的例子。大多数程序——甚至非常复杂的程序——都不会这么复杂。但是您可以看到Python包如何使您能够保持程序的良好组织,无论它变得多么庞大和复杂。
当一个模块被导入时,该模块中的任何顶层代码都会被执行。这会使你在模块中定义的各种函数、变量和类对调用者可用。为了看看这是如何工作的,创建一个名为test_module.py的新Python源文件,并输入以下代码到这个模块中:
deffoo():print("infoo")defbar():print("inbar")my_var=0print("importingtestmodule")现在,打开一个终端窗口,cd到存储test_module.py文件的目录,并输入python启动Python解释器。然后尝试输入以下内容:
%importtest_module当你这样做时,Python解释器会打印以下消息:
importingtestmodule这是因为模块中的所有顶层Python语句——包括def语句和我们的print语句——在模块被导入时都会被执行。然后你可以通过在名称前加上my_module来调用foo和bar函数,并访问my_var全局变量:
%my_module.foo()infoo%my_module.bar()inbar%print(my_module.my_var)0%my_module.my_var=1%print(my_module.my_var)1因为模块被导入时会执行所有顶层的Python语句,所以你可以通过直接在模块中包含初始化语句来初始化一个模块,就像我们测试模块中设置my_var为零的语句一样。这意味着当模块被导入时,模块将自动初始化。
请注意,一个模块只会被导入一次。如果两个模块导入了同一个模块,第二个import语句将简单地返回对已经导入的模块的引用,因此你不会导入(和初始化)两次相同的模块。
这种隐式初始化是有效的,但不一定是一个好的实践。Python语言设计者提倡的指导方针之一是显式优于隐式。换句话说,让一个模块自动初始化并不总是一个好的编码实践,因为从代码中并不总是清楚哪些内容被初始化了,哪些没有。
为了避免这种混乱,并且为了遵循Python的指导方针,明确地初始化你的模块通常是一个好主意。按照惯例,这是通过定义一个名为init()的顶层函数来完成模块的所有初始化。例如,在我们的test_module中,我们可以用以下代码替换my_var=0语句:
definit():globalmy_varmy_var=0这会显得有点啰嗦,但它使初始化变得明确。当然,你还必须记得在使用模块之前调用test_module.init(),通常是在主程序中调用。
显式模块初始化的主要优势之一是你可以控制各个模块初始化的顺序。例如,如果模块A的初始化包括调用模块B中的函数,并且这个函数需要模块B已经被初始化,如果两个模块的导入顺序错误,程序将崩溃。当模块导入其他模块时,情况会变得特别困难,因为模块导入的顺序可能会非常令人困惑。为了避免这种情况,最好使用显式模块初始化,并让你的主程序在调用A.init()之前调用B.init()。这是一个很好的例子,说明为什么通常最好为你的模块使用显式初始化函数。
要初始化一个包,你需要将Python代码放在包的__init__.py文件中。这段代码将在包被导入时执行。例如,假设你有一个名为test_package的包,其中包含一个__init__.py文件和一个名为test_module.py的模块:
你可以在__init__.py文件中放置任何你喜欢的代码,当包(或包内的模块)第一次被导入时,该代码将被执行。
你可能想知道为什么要这样做。初始化一个模块是有道理的,因为一个模块包含了可能需要在使用之前初始化的各种函数(例如,通过将全局变量设置为初始值)。但为什么要初始化一个包,而不仅仅是包内的一个模块?
答案在于当你导入一个包时发生了什么。当你这样做时,你在包的__init__.py文件中定义的任何东西都可以在包级别使用。例如,想象一下,你的__init__.py文件包含了以下Python代码:
defsay_hello():print("hello")然后你可以通过以下方式从主程序中访问这个函数:
importmy_packagemy_package.say_hello()你不需要在包内的模块中定义say_hello()函数,它就可以很容易地被访问。
作为一个一般原则,向__init__.py文件添加代码并不是一个好主意。它可以工作,但是查看包源代码的人会期望包的代码被定义在模块内,而不是在包初始化文件中。另外,整个包只有一个__init__.py文件,这使得在包内组织代码变得更加困难。
更好的使用包初始化文件的方法是在包内的模块中编写代码,然后使用__init__.py文件导入这些代码,以便在包级别使用。例如,你可以在test_module模块中实现say_hello()函数,然后在包的__init__.py文件中包含以下内容:
fromtest_package.test_moduleimportsay_hello使用你的包的程序仍然可以以完全相同的方式调用say_hello()函数。唯一的区别是,这个函数现在作为test_module模块的一部分实现,而不是被整个包的__init__.py文件包含在一起。
这是一个非常有用的技术,特别是当你的包变得更加复杂,你有很多函数、类和其他定义想要提供。通过向包初始化文件添加import语句,你可以在任何模块中编写包的部分,然后选择哪些函数、类等在包级别可用。
使用__init__.py文件的一个好处是,各种import语句告诉包的用户他们应该使用哪些函数和类;如果你没有在包初始化文件中包含一个模块或函数,那么它可能被排除是有原因的。
在包初始化文件中使用import语句还告诉包的用户复杂包的各个部分的位置——__init__.py文件充当了包源代码的一种索引。
总之,虽然你可以在包的__init__.py文件中包含任何你喜欢的Python代码,但最好限制自己只使用import语句,并将真正的包代码放在其他地方。
到目前为止,我们已经使用了import语句的两种不同版本:
importmathprint(math.pi)frommathimportpiprint(pi)然而,import语句非常强大,我们可以用它做各种有趣的事情。在本节中,我们将看看你可以使用import语句以及它们的内容将模块和包导入到你的程序中的不同方式。
每当你创建一个全局变量或函数时,Python解释器都会将该变量或函数的名称添加到所谓的全局命名空间中。全局命名空间包含了你在全局级别定义的所有名称。要查看这是如何工作的,输入以下命令到Python解释器中:
>>>print(globals())globals()内置函数返回一个带有全局命名空间当前内容的字典:
{'__package__':None,'__doc__':None,'__name__':'__main__','__builtins__':
现在,让我们定义一个新的顶级函数:
>>>deftest():...print("Hello")...>>>如果我们现在打印全局名称的字典,我们的test()函数将被包括在内:
>>>print(globals()){...'test':
如您所见,名称test已添加到我们的全局命名空间中。
再次,不要担心与test名称关联的值;这是Python存储您定义的函数的内部方式。
当某物在全局命名空间中时,您可以通过程序中的任何位置的名称访问它:
>>>test()Hello注意请注意,还有第二个命名空间,称为局部命名空间,其中保存了当前函数中定义的变量和其他内容。虽然局部命名空间在变量范围方面很重要,但我们将忽略它,因为它通常不涉及导入模块。
现在,当您使用import语句时,您正在向全局命名空间添加条目:
>>>importstring>>>print(globals()){...'string':
>>>print(string.capwords("thisisatest"))ThisIsATest同样,如果您使用import语句的from...import版本,您导入的项目将直接添加到全局命名空间中:
>>>fromstringimportcapwords>>>print(globals()){...'capwords':
既然我们已经看到了import语句的作用,让我们来看看Python提供的import语句的不同版本。
我们已经看到了import语句的两种最常见形式:
使用第一种形式时,您不限于一次导入一个模块。如果愿意,您可以一次导入多个模块,就像这样:
importstring,math,datetime,random同样,您可以一次从模块或包中导入多个项目:
frommathimportpi,radians,sin如果要导入的项目比一行所能容纳的要多,您可以使用行继续字符(\)将导入扩展到多行,或者用括号括起要导入的项目列表。例如:
frommathimportpi,degrees,radians,sin,cos,\tan,hypot,asin,acos,atan,atan2frommathimport(pi,degrees,radians,sin,cos,tan,hypot,asin,acos,atan,atan2)当您导入某物时,您还可以更改所导入项目的名称:
importmathasmath_ops在这种情况下,您正在将math模块导入为名称math_ops。math模块将使用名称math_ops添加到全局命名空间中,您可以使用math_ops名称访问math模块的内容:
print(math_ops.pi)有两个原因可能要使用import...as语句来更改导入时的名称:
frompackage1importutilsasutils1frompackage2importutilsasutils2注意请注意,您可能应该谨慎使用import...as语句。每次更改某物的名称时,您(以及任何阅读您代码的人)都必须记住X是Y的另一个名称,这增加了复杂性,并意味着您在编写程序时需要记住更多的事情。import...as语句当然有合法的用途,但不要过度使用它。
当然,您可以将from...import语句与import...as结合使用:
fromreportsimportcustomersascustomer_reportfromdatabaseimportcustomersascustomer_data最后,您可以使用通配符导入一次性从模块或包中导入所有内容:
frommathimport*这将所有在math模块中定义的项目添加到当前全局命名空间。如果您从包中导入,则将导入包的__init__.py文件中定义的所有项目。
默认情况下,模块(或包)中以下划线字符开头的所有内容都将被通配符导入。这确保了私有变量和函数不会被导入。然而,如果你愿意,你可以通过使用__all__变量来改变通配符导入中包含的内容;这将在本章后面的控制导入内容部分中讨论。
到目前为止,每当我们导入东西时,我们都使用了要从中导入的模块或包的完整名称。对于简单的导入,比如frommathimportpi,这是足够的。然而,有时这种类型的导入可能会相当繁琐。
例如,考虑我们在本章前面的包内包部分中看到的复杂包树。假设我们想要从program.gui.widgets.editor包内导入名为slider.py的模块:
你可以使用以下Python语句导入这个模块:
fromprogram.gui.widgets.editorimportsliderimport语句中的program.gui.widgets.editor部分标识了slider模块所在的包。
虽然这样可以工作,但它可能会相当笨拙,特别是如果你需要导入许多模块,或者如果包的某个部分需要从同一个包内导入多个其他模块。
为了处理这种情况,Python支持相对导入的概念。使用相对导入,你可以确定相对于包树中当前模块位置的位置导入你想要的内容。例如,假设slider模块想要从program.gui.widgets.editor包内导入另一个模块:
为此,你用.字符替换包名:
from.importslider.字符是当前包的简写。
类似地,假设你有一个在program.gui.widgets包内的模块想要从editor子包内导入slider模块:
在这种情况下,你的import语句将如下所示:
from.editorimportslider.字符仍然指的是当前位置,editor是相对于当前位置的包的名称。换句话说,你告诉Python在当前位置查找名为editor的包,然后导入该包内的名为slider的模块。
让我们考虑相反的情况。假设slider模块想要从widgets目录中导入一个模块:
在这种情况下,你可以使用两个.字符来表示向上移动一个级别:
from..importcontrols正如你所想象的那样,你可以使用三个.字符来表示向上移动两个级别,依此类推。你也可以结合这些技术以任何你喜欢的方式在包层次结构中移动。例如,假设slider模块想要从gui.dialogs.errors包内导入名为errDialog的模块:
使用相对导入,slider模块可以以以下方式导入errDialog模块:
from...dialogs.errorsimporterrDialog如你所见,你可以使用这些技术来选择树状包结构中任何位置的模块或包。
使用相对导入有两个主要原因:
就像任何东西一样,相对导入可能会被滥用。因为import语句的含义取决于当前模块的位置,相对导入往往违反了“显式优于隐式”的原则。如果你尝试从命令行运行一个模块,也会遇到麻烦,这在本章后面的“从命令行运行模块”部分有描述。因此,除非有充分的理由,你应该谨慎使用相对导入,并坚持在import语句中完整列出整个包层次结构。
当你导入一个模块或包,或者使用通配符导入,比如frommy_moduleimport*,Python解释器会将给定模块或包的内容加载到你的全局命名空间中。如果你从一个模块导入,所有顶层函数、常量、类和其他定义都会被导入。当从一个包导入时,包的__init__.py文件中定义的所有顶层函数、常量等都会被导入。
默认情况下,这些导入会从给定的模块或包中加载所有内容。唯一的例外是通配符导入会自动跳过任何以下划线开头的函数、常量、类或其他定义——这会导致通配符导入排除私有定义。
虽然这种默认行为通常运行良好,但有时你可能希望更多地控制导入的内容。为此,你可以使用一个名为__all__的特殊变量。
为了看看__all__变量是如何工作的,让我们看一下以下模块:
A=1B=2C=3__all__=["A","B"]如果你导入这个模块,只有A和B会被导入。虽然模块定义了变量C,但这个定义会被跳过,因为它没有包含在__all__列表中。
在一个包内,__all__变量的行为方式相同,但有一个重要的区别:你还可以包括你希望在导入包时包含的模块和子包的名称。例如,一个包的__init__.py文件可能只包含以下内容:
__all__=["module_1","module_2","sub_package"]在这种情况下,__all__变量控制要包含的模块和包;当你导入这个包时,这两个模块和子包将被自动导入。
注意,前面的__init.py__文件等同于以下内容:
importmodule1importmodule2importsub_package__init__.py文件的两个版本都会导致包中包含这两个模块和子包。
虽然你不一定需要使用它,__all__变量可以完全控制你的导入。__all__变量也可以是向模块和包的用户指示他们应该使用你代码的哪些部分的有用方式:如果某些东西没有包含在__all__列表中,那么它就不打算被外部代码使用。
在使用模块时,你可能会遇到的一个令人讨厌的问题是所谓的循环依赖。要理解这些是什么,考虑以下两个模块:
#module_1.pyfrommodule_2importcalc_markupdefcalc_total(items):total=0foriteminitems:total=total+item['price']total=total+calc_markup(total)returntotal#module_2.pyfrommodule_1importcalc_totaldefcalc_markup(total):returntotal*0.1defmake_sale(items):total_price=calc_total(items)...虽然这是一个假设的例子,你可以看到module_1从module_2导入了一些东西,而module_2又从module_1导入了一些东西。如果你尝试运行包含这两个模块的程序,当导入module_1时,你会看到以下错误:
ImportError:cannotimportnamecalc_total如果你尝试导入module_2,你会得到类似的错误。以这种方式组织代码,你就陷入了困境:你无法导入任何一个模块,因为它们都相互依赖。
为了解决这个问题,你需要重新构建你的模块,使它们不再相互依赖。在这个例子中,你可以创建一个名为module_3的第三个模块,并将calc_markup()函数移动到该模块中。这将使module_1依赖于module_3,而不是module_2,从而打破了循环依赖。
还有其他一些技巧可以避免循环依赖错误,例如将import语句放在一个函数内部。然而,一般来说,循环依赖意味着你的代码设计有问题,你应该重构你的代码以完全消除循环依赖。
在第二章编写你的第一个模块化程序中,我们看到你系统的主程序通常被命名为main.py,并且通常具有以下结构:
defmain():...if__name__=="__main__":main()当用户运行你的程序时,Python解释器会将__name__全局变量设置为值"__main__"。这会在程序运行时调用你的main()函数。
main.py程序并没有什么特别之处;它只是另一个Python源文件。你可以利用这一点,使你的Python模块能够从命令行运行。
例如,考虑以下模块,我们将其称为double.py:
defdouble(n):returnn*2if__name__=="__main__":print("double(3)=",double(3))这个模块定义了一些功能,比如一个名为double()的函数,然后使用if__name__=="__main__"的技巧来演示和测试模块在从命令行运行时的功能。让我们尝试运行这个模块,看看它是如何工作的:
%pythondouble.py**double(3)=6可运行模块的另一个常见用途是允许最终用户直接从命令行访问模块的功能。要了解这是如何工作的,创建一个名为funkycase.py的新模块,并输入以下内容到这个文件中:
deffunky_case(s):letters=[]capitalize=Falseforletterins:ifcapitalize:letters.append(letter.upper())else:letters.append(letter.lower())capitalize=notcapitalizereturn"".join(letters)funky_case()函数接受一个字符串,并将每第二个字母大写。如果你愿意,你可以导入这个模块,然后在你的程序中访问这个函数:
fromfunkycaseimportfunky_cases=funky_case("TestString")虽然这很有用,但我们也希望让用户直接运行funkycase.py模块作为一个独立的程序,直接将提供的字符串转换为funky-case并打印出来给用户看。为了做到这一点,我们可以使用if__name__=="__main__"的技巧以及sys.argv来提取用户提供的字符串。然后我们可以调用funky_case()函数来将这个字符串转换为funky-case并打印出来。为此,将以下代码添加到你的funkycase.py模块的末尾:
if__name__=="__main__":iflen(sys.argv)!=2:print("Youmustsupplyexactlyonestring!")else:s=sys.argv[1]print(funky_case(s))另外,将以下内容添加到你的模块顶部:
importsys现在你可以直接运行这个模块,就像它是一个独立的程序一样:
%pythonfunkycase.py"Thequickbrownfox"tHeqUiCkbRoWnfOx通过这种方式,funkycase.py充当了一种变色龙模块。对于其他的Python源文件,它看起来就像是可以导入和使用的另一个模块,而对于最终用户来说,它看起来像是一个可以从命令行运行的独立程序。
请注意,如果你想让一个模块能够从命令行运行,你不仅仅可以使用sys.argv来接受和处理用户提供的参数。Python标准库中的优秀argparse模块允许你编写接受用户各种输入和选项的Python程序(和模块)。如果你以前没有使用过这个模块,一定要试试。
当你创建一个可以从命令行运行的模块时,有一个需要注意的问题:如果你的模块使用相对导入,当你直接使用Python解释器运行时,你的导入将会失败,并出现尝试相对导入非包的错误。这个错误是因为当模块从命令行运行时,它会忘记它在包层次结构中的位置。只要你的模块不使用任何命令行参数,你可以通过使用Python的-m命令行选项来解决这个问题,就像这样:
python-mmy_module.py然而,如果您的模块确实接受命令行参数,那么您将需要替换相对导入,以避免出现这个问题。虽然有解决方法,但它们很笨拙,不建议一般使用。
在本章中,我们深入了解了Python模块和包的工作原理。我们看到模块只是使用import语句导入的Python源文件,而包是由名为__init__.py的包初始化文件标识的Python源文件目录。我们了解到包可以定义在其他包内,形成嵌套包的树状结构。我们看了模块和包如何初始化,以及import语句如何以各种方式导入模块和包及其内容到您的程序中。
然后,我们看到了相对导入如何用于相对于包层次结构中的当前位置导入模块,以及__all__变量如何用于控制导入的内容。
然后,我们了解了循环依赖以及如何避免它们,最后学习了变色龙模块,它可以作为可导入的模块,也可以作为可以从命令行运行的独立程序。
在下一章中,我们将应用所学知识来设计和实现一个更复杂的程序,我们将看到对这些技术的深入理解将使我们能够构建一个健壮的系统,并能够根据不断变化的需求进行更新。
在本章中,我们将使用模块化编程技术来实现一个有用的现实世界系统。特别是,我们将:
让我们首先看一下我们将要实现的Python图表生成包,我们将其称为Charter。
Charter将是一个用于生成图表的Python库。开发人员将能够使用Charter将原始数字转换为漂亮的折线图和条形图,然后将其保存为图像文件。以下是Charter库将能够生成的图表类型的示例:
Charter库将支持折线图和条形图。虽然我们将通过仅支持两种类型的图表来保持Charter相对简单,但该包将被设计为您可以轻松添加更多的图表类型和其他图表选项。
当您查看前一节中显示的图表时,您可以识别出所有类型的图表中使用的一些标准元素。这些元素包括标题、x轴和y轴,以及一个或多个数据系列:
要使用Charter包,程序员将创建一个新图表并设置标题、x轴和y轴,以及要显示的数据系列。然后程序员将要求Charter生成图表,并将结果保存为磁盘上的图像文件。通过以这种方式组合和配置各种元素,程序员可以创建任何他们希望生成的图表。
更复杂的图表库将允许添加其他元素,例如右侧的y轴、轴标签、图例和多个重叠的数据系列。但是,对于Charter,我们希望保持代码简单,因此我们将忽略这些更复杂的元素。
让我们更仔细地看看程序员如何与Charter库进行交互,然后开始思考如何实现它。
我们希望程序员能够通过导入charter包并调用各种函数来与Charter进行交互。例如:
importcharterchart=charter.new_chart()要为图表设置标题,程序员将调用set_title()函数:
charter.set_title(chart,"WildParrotDeathsperYear")提示请注意,我们的Charter库不使用面向对象的编程技术。使用面向对象的技术,图表标题将使用类似chart.set_title("每年野生鹦鹉死亡数量")的语句进行设置。但是,面向对象的技术超出了本书的范围,因此我们将为Charter库使用更简单的过程式编程风格。
要为图表设置x和y轴,程序员必须提供足够的信息,以便Charter可以生成图表并显示这些轴。为了了解这可能是如何工作的,让我们想一想轴是什么样子。
对于某些图表,轴可能代表一系列数值:
在这种情况下,通过计算数据点沿轴的位置来显示数据点。例如,具有x=35的数据点将显示在该轴上30和40点之间的中间位置。
我们将把这种类型的轴称为连续轴。请注意,对于这种类型的轴,标签位于刻度线下方。将其与以下轴进行比较,该轴被分成多个离散的“桶”:
在这种情况下,每个数据点对应一个单独的桶,标签将出现在刻度标记之间的空间中。这种类型的轴将被称为离散轴。
注意,对于连续轴,标签显示在刻度标记上,而对于离散轴,标签显示在刻度标记之间。此外,离散轴的值可以是任何值(在本例中是月份名称),而连续轴的值必须是数字。
对于Charter库,我们将使x轴成为离散轴,而y轴将是连续的。理论上,你可以为x和y轴使用任何类型的轴,但我们保持这样做是为了使库更容易实现。
知道这一点,我们现在可以看一下在创建图表时如何定义各种轴。
为了定义x轴,程序员将调用set_x_axis()函数,并提供用于离散轴中每个桶的标签列表:
charter.set_x_axis(chart,["2009","2010","2011","2012","2013","2014","2015"])列表中的每个条目对应轴中的一个桶。
对于y轴,我们需要定义将显示的值的范围以及这些值将如何标记。为此,我们需要向set_y_axis()函数提供最小值、最大值和标签值:
charter.set_y_axis(chart,minimum=0,maximum=700,labels=[0,100,200,300,400,500,600,700])注意为了保持简单,我们将假设y轴使用线性刻度。我们可能会支持其他类型的刻度,例如实现对数轴,但我们将忽略这一点,因为这会使Charter库变得更加复杂。
现在我们知道了轴将如何定义,我们可以看一下数据系列将如何指定。首先,我们需要程序员告诉Charter要显示什么类型的数据系列:
charter.set_series_type(chart,"bar")正如前面提到的,我们将支持线图和条形图。
然后程序员需要指定数据系列的内容。由于我们的x轴是离散的,而y轴是连续的,我们可以将数据系列定义为一个y轴值的列表,每个离散的x轴值对应一个y轴值:
charter.set_series(chart,[250,270,510,420,680,580,450])这完成了图表的定义。一旦定义好了,程序员就可以要求Charter库生成图表:
charter.generate_chart(chart,"chart.png")将所有这些放在一起,这是一个完整的程序,可以生成本章开头显示的条形图:
importcharterchart=charter.new_chart()charter.set_title(chart,"WildParrotDeathsperYear")charter.set_x_axis(chart,["2009","2010","2011","2012","2013","2014","2015"])charter.set_y_axis(chart,minimum=0,maximum=700,labels=[0,100,200,300,400,500,600,700])charter.set_series(chart,[250,270,510,420,680,580,450])charter.set_series_type(chart,"bar")charter.generate_chart(chart,"chart.png")因为Charter是一个供程序员使用的库,这段代码为Charter库的API提供了一个相当完整的规范。从这个示例程序中很清楚地可以看出应该发生什么。现在让我们看看如何实现这一点。
我们知道Charter库的公共接口将由许多在包级别访问的函数组成,例如charter.new_chart()。然而,使用上一章介绍的技术,我们知道我们不必在包初始化文件中定义库的API,以使这些函数在包级别可用。相反,我们可以在其他地方定义这些函数,并将它们导入到__init__.py文件中,以便其他人可以使用它们。
让我们从创建一个目录开始,用来保存我们的charter包。创建一个名为charter的新目录,在其中创建一个空的包初始化文件__init__.py。这为我们提供了编写库的基本框架:
根据我们的设计,我们知道生成图表的过程将涉及以下三个步骤:
为了保持我们的代码组织良好,我们将分开生成图表的过程和创建和定义图表的过程。为此,我们将有一个名为chart的模块,负责图表的创建和定义,以及一个名为generator的单独模块,负责图表的生成。
继续创建这两个新的空模块,将它们放在charter包中:
现在我们已经为我们的包建立了一个整体结构,让我们为我们知道我们将不得不实现的各种函数创建一些占位符。编辑chart.py模块,并在该文件中输入以下内容:
defnew_chart():passdefset_title(chart,title):passdefset_x_axis(chart,x_axis):passdefset_y_axis(chart,minimum,maximum,labels):passdefset_series_type(chart,series_type):passdefset_series(chart,series):pass同样,编辑generator.py模块,并在其中输入以下内容:
defgenerate_chart(chart,filename):pass这些是我们知道我们需要为Charter库实现的所有函数。但是,它们还没有放在正确的位置上——我们希望用户能够调用charter.new_chart(),而不是charter.chart.new_chart()。为了解决这个问题,编辑__init__.py文件,并在该文件中输入以下内容:
from.chartimport*from.generatorimport*正如你所看到的,我们正在使用相对导入将所有这些模块中的函数加载到主charter包的命名空间中。
我们的Charter库开始成形了!现在让我们依次处理这两个模块。
由于我们在Charter库的实现中避免使用面向对象的编程技术,我们不能使用对象来存储有关图表的信息。相反,new_chart()函数将返回一个图表值,各种set_XXX()函数将获取该图表并向其添加信息。
存储图表信息的最简单方法是使用Python字典。这使得我们的new_chart()函数的实现非常简单;编辑chart.py模块,并用以下内容替换new_chart()的占位符:
defnew_chart():return{}一旦我们有一个将保存图表数据的字典,就很容易将我们想要的各种值存储到这个字典中。例如,编辑set_title()函数的定义,使其如下所示:
defset_title(chart,title):chart['title']=title以类似的方式,我们可以实现set_XXX()函数的其余部分:
defset_x_axis(chart,x_axis):chart['x_axis']=x_axisdefset_y_axis(chart,minimum,maximum,labels):chart['y_min']=minimumchart['y_max']=maximumchart['y_labels']=labelsdefset_series_type(chart,series_type):chart['series_type']=series_typedefset_series(chart,series):chart['series']=series这完成了我们的chart.py模块的实现。
不幸的是,实现generate_chart()函数将更加困难,这就是为什么我们将这个函数移到了一个单独的模块中。生成图表的过程将涉及以下步骤:
因为生成图表的过程需要我们使用图像,所以我们需要找到一个允许我们生成图像文件的库。现在让我们来获取一个。
PythonImagingLibrary(PIL)是一个古老的用于生成图像的库。不幸的是,PIL不再得到积极的开发。然而,有一个名为Pillow的更新版本的PIL,它继续得到支持,并允许我们创建和保存图像文件。
通过查看Pillow文档,我们发现可以使用以下代码创建一个空图像:
fromPILimportImageimage=Image.new("RGB",(CHART_WIDTH,CHART_HEIGHT),"#7f00ff")这将创建一个新的RGB(红色,绿色,蓝色)图像,宽度和高度由给定的颜色填充。
#7f00ff是紫色的十六进制颜色代码。每对十六进制数字代表一个颜色值:7f代表红色,00代表绿色,ff代表蓝色。
为了绘制这个图像,我们将使用ImageDraw模块。例如:
fromPILimportImageDrawdrawer=ImageDraw.Draw(image)drawer.line(50,50,150,200,fill="#ff8010",width=2)图表绘制完成后,我们可以以以下方式将图像保存到磁盘上:
image.save("image.png",format="png")这个对Pillow库的简要介绍告诉我们如何实现我们之前描述的图表生成过程的第1步和第6步。它还告诉我们,对于第2到第5步,我们将使用ImageDraw模块来绘制各种图表元素。
当我们绘制图表时,我们希望能够选择要绘制的元素。例如,我们可能根据用户想要显示的数据系列的类型在"bar"和"line"元素之间进行选择。一个非常简单的方法是将我们的绘图代码结构化如下:
ifchart['series_type']=="bar":...drawthedataseriesusingbarselifchart['series_type']=="line":...drawthedataseriesusinglines然而,这并不是很灵活,如果绘图逻辑变得复杂,或者我们向库中添加更多的图表选项,代码将很快变得难以阅读。为了使Charter库更加模块化,并支持今后的增强,我们将使用渲染器模块来实际进行绘制。
在计算机图形学中,渲染器是程序的一部分,用于绘制某些东西。其思想是你可以选择适当的渲染器,并要求它绘制你想要的元素,而不必担心该元素将如何被绘制的细节。
使用渲染器模块,我们的绘图逻辑看起来会像下面这样:
fromrenderersimportbar_series,line_seriesifchart['series_type']=="bar":bar_series.draw(chart,drawer)elifchart['series_type']=="line":line_series.draw(chart,drawer)这意味着我们可以将每个元素的实际绘制细节留给渲染器模块本身,而不是在我们的generate_chart()函数中充斥着大量详细的绘制代码。
为了跟踪我们的渲染器模块,我们将创建一个名为renderers的子包,并将所有渲染器模块放在这个子包中。让我们现在创建这个子包。
在主charter目录中创建一个名为renderers的新目录,并在其中创建一个名为__init__.py的新文件,作为包初始化文件。这个文件可以为空,因为我们不需要做任何特殊的初始化来初始化这个子包。
我们将需要五个不同的渲染器模块来完成Charter库的工作:
继续在charter.renderers目录中创建这五个文件,并在每个文件中输入以下占位文本:
defdraw(chart,drawer):pass这给了我们渲染器模块的整体结构。现在让我们使用这些渲染器来实现我们的generate_chart()函数。
编辑generate.py模块,并用以下内容替换generate_chart()函数的占位符定义:
defgenerate_chart(chart,filename):image=Image.new("RGB",(CHART_WIDTH,CHART_HEIGHT),"#ffffff")drawer=ImageDraw.Draw(image)title.draw(chart,drawer)x_axis.draw(chart,drawer)y_axis.draw(chart,drawer)ifchart['series_type']=="bar":bar_series.draw(chart,drawer)elifchart['series_type']=="line":line_series.draw(chart,drawer)image.save(filename,format="png")正如你所看到的,我们创建了一个Image对象来保存我们生成的图表,使用十六进制颜色代码#ffffff将其初始化为白色。然后我们使用ImageDraw模块来定义一个drawer对象来绘制图表,并调用各种渲染器模块来完成所有工作。最后,我们调用image.save()将图像文件保存到磁盘上。
为了使这个函数工作,我们需要在我们的generator.py模块的顶部添加一些import语句:
fromPILimportImage,ImageDrawfrom.renderersimport(title,x_axis,y_axis,bar_series,line_series)还有一件事我们还没有处理:当我们创建图像时,我们使用了两个常量,告诉Pillow要创建的图像的尺寸:
image=Image.new("RGB",(**CHART_WIDTH,CHART_HEIGHT**),"#ffffff")我们需要在某个地方定义这两个常量。
事实证明,我们需要定义更多的常量并在整个Charter库中使用它们。为此,我们将创建一个特殊的模块来保存我们的各种常量。
在顶层charter目录中创建一个名为constants.py的新文件。在这个模块中,添加以下值:
CHART_WIDTH=600CHART_HEIGHT=400然后,在你的generator.py模块中添加以下import语句:
from.constantsimport*测试代码虽然我们还没有实现任何渲染器,但我们已经有足够的代码来开始测试。为此,创建一个名为test_charter.py的空文件,并将其放在包含charter包的目录中。然后,在此文件中输入以下内容:
importcharterchart=charter.new_chart()charter.set_title(chart,"WildParrotDeathsperYear")charter.set_x_axis(chart,["2009","2010","2011","2012","2013","2014","2015"])charter.set_y_axis(chart,minimum=0,maximum=700,labels=[0,100,200,300,400,500,600,700])charter.set_series(chart,[250,270,510,420,680,580,450])charter.set_series_type(chart,"bar")charter.generate_chart(chart,"chart.png")这只是我们之前看到的示例代码的副本。这个脚本将允许您测试Charter库;打开一个终端或命令行窗口,cd到包含test_charter.py文件的目录,并输入以下内容:
pythontest_charter.py一切顺利的话,程序应该在没有任何错误的情况下完成。然后,您可以查看chart.png文件,这应该是一个填充有白色背景的空图像文件。
接下来,我们需要实现各种渲染器模块,从图表的标题开始。编辑renderers/title.py文件,并用以下内容替换draw()函数的占位符定义:
defdraw(chart,drawer):font=ImageFont.truetype("Helvetica",24)text_width,text_height=font.getsize(chart['title'])left=CHART_WIDTH/2-text_width/2top=TITLE_HEIGHT/2-text_height/2drawer.text((left,top),chart['title'],"#4040a0",font)这个渲染器首先获取一个用于绘制标题的字体。然后计算标题文本的大小(以像素为单位)和用于标签的位置,以便它在图表上居中显示。请注意,我们使用一个名为TITLE_HEIGHT的常量来指定用于图表标题的空间量。
该函数的最后一行使用指定的位置和字体将标题绘制到图表上。字符串#4040a0是用于文本的十六进制颜色代码,这是一种深蓝色。
由于这个模块使用ImageFont模块加载字体,以及我们的constants.py模块中的一些常量,我们需要在我们的模块顶部添加以下import语句:
fromPILimportImageFontfrom..constantsimport*请注意,我们使用..从父包中导入constants模块。
最后,我们需要将TITLE_HEIGHT常量添加到我们的constants.py模块中:
TITLE_HEIGHT=50如果现在运行您的test_charter.py脚本,您应该会看到生成的图像中出现图表的标题:
如果您记得,*x*轴是一个离散轴,标签显示在每个刻度之间。为了绘制这个,我们将不得不计算轴上每个“桶”的宽度,然后绘制表示轴和刻度线的线,以及绘制每个“桶”的标签。
首先,编辑renderers/x_axis.py文件,并用以下内容替换您的占位符draw()函数:
defdraw(chart,drawer):font=ImageFont.truetype("Helvetica",12)label_height=font.getsize("Test")[1]avail_width=CHART_WIDTH-Y_AXIS_WIDTH-MARGINbucket_width=avail_width/len(chart['x_axis'])axis_top=CHART_HEIGHT-X_AXIS_HEIGHTdrawer.line([(Y_AXIS_WIDTH,axis_top),(CHART_WIDTH-MARGIN,axis_top)],"#4040a0",2)#Drawmainaxisline.left=Y_AXIS_WIDTHforbucket_numinrange(len(chart['x_axis'])):drawer.line([(left,axis_top),(left,axis_top+TICKMARK_HEIGHT)],"#4040a0",1)#Drawtickmark.label_width=font.getsize(chart['x_axis'][bucket_num])[0]label_left=max(left,left+bucket_width/2-label_width/2)label_top=axis_top+TICKMARK_HEIGHT+4drawer.text((label_left,label_top),chart['x_axis'][bucket_num],"#000000",font)left=left+bucket_widthdrawer.line([(left,axis_top),(left,axis_top+TICKMARK_HEIGHT)],"#4040a0",1)#Drawfinaltickmark.您还需要在模块顶部添加以下import语句:
fromPILimportImageFontfrom..constantsimport*最后,您应该将以下定义添加到您的constants.py模块中:
X_AXIS_HEIGHT=50Y_AXIS_WIDTH=50MARGIN=20TICKMARK_HEIGHT=8这些定义了图表中固定元素的大小。
如果现在运行您的test_charter.py脚本,您应该会看到*x*轴显示在图表底部:
正如您所看到的,生成的图像开始看起来更像图表了。由于这个包的目的是展示如何构建代码结构,而不是这些模块是如何实现的细节,让我们跳过并添加剩下的渲染器而不再讨论。
首先,编辑您的renderers/y_axis.py文件,使其如下所示:
fromPILimportImageFontfrom..constantsimport*defdraw(chart,drawer):font=ImageFont.truetype("Helvetica",12)label_height=font.getsize("Test")[1]axis_top=TITLE_HEIGHTaxis_bottom=CHART_HEIGHT-X_AXIS_HEIGHTaxis_height=axis_bottom-axis_topdrawer.line([(Y_AXIS_WIDTH,axis_top),(Y_AXIS_WIDTH,axis_bottom)],"#4040a0",2)#Drawmainaxisline.fory_valueinchart['y_labels']:y=((y_value-chart['y_min'])/(chart['y_max']-chart['y_min']))y_pos=axis_top+(axis_height-int(y*axis_height))drawer.line([(Y_AXIS_WIDTH-TICKMARK_HEIGHT,y_pos),(Y_AXIS_WIDTH,y_pos)],"#4040a0",1)#Drawtickmark.label_width,label_height=font.getsize(str(y_value))label_left=Y_AXIS_WIDTH-TICKMARK_HEIGHT-label_width-4label_top=y_pos-label_height/2drawer.text((label_left,label_top),str(y_value),"#000000",font)接下来,编辑renderers/bar_series.py,使其如下所示:
fromPILimportImageFontfrom..constantsimport*defdraw(chart,drawer):avail_width=CHART_WIDTH-Y_AXIS_WIDTH-MARGINbucket_width=avail_width/len(chart['x_axis'])max_top=TITLE_HEIGHTbottom=CHART_HEIGHT-X_AXIS_HEIGHTavail_height=bottom-max_topleft=Y_AXIS_WIDTHfory_valueinchart['series']:bar_left=left+MARGIN/2bar_right=left+bucket_width-MARGIN/2y=((y_value-chart['y_min'])/(chart['y_max']-chart['y_min']))bar_top=max_top+(avail_height-int(y*avail_height))drawer.rectangle([(bar_left,bar_top),(bar_right+1,bottom)],fill="#e8e8f4",outline="#4040a0")left=left+bucket_width最后,编辑renderers.line_series.py,使其如下所示:
fromPILimportImageFontfrom..constantsimport*defdraw(chart,drawer):avail_width=CHART_WIDTH-Y_AXIS_WIDTH-MARGINbucket_width=avail_width/len(chart['x_axis'])max_top=TITLE_HEIGHTbottom=CHART_HEIGHT-X_AXIS_HEIGHTavail_height=bottom-max_topleft=Y_AXIS_WIDTHprev_y=Nonefory_valueinchart['series']:y=((y_value-chart['y_min'])/(chart['y_max']-chart['y_min']))cur_y=max_top+(avail_height-int(y*avail_height))ifprev_y!=None:drawer.line([(left-bucket_width/2,prev_y),(left+bucket_width/2),cur_y],fill="#4040a0",width=1)prev_y=cur_yleft=left+bucket_width这完成了我们对Charter库的实现。
如果运行test_charter.py脚本,您应该会看到一个完整的条形图:
当然,没有什么是真正完成的。假设你写了图书馆并且已经忙着扩展它好几个月,添加了更多的数据系列类型和大量的选项。该库正在公司的几个重大项目中使用,输出效果很棒,每个人似乎都对此很满意——直到有一天你的老板走进来说:“太模糊了。你能把模糊去掉吗?”
你问他是什么意思,他说他一直在一台高分辨率激光打印机上打印图表。结果对他来说还不够好,不能用在公司的报告中。他拿出一份打印件指着标题。仔细看,你明白了他的意思:
果然,文本是像素化的,即使线条在高分辨率打印时看起来也有点锯齿状。你尝试增加生成图表的大小,但仍然不够好——当你尝试将大小增加到公司高分辨率激光打印机的每英寸1200点时,你的程序崩溃了。
“但这个程序从来没有为此设计过,”你抱怨道。“我们编写它是为了在屏幕上显示图表。”
“我不在乎,”你的老板说。“我希望你生成矢量格式的输出。那样打印效果很好,一点都不模糊。”
以防你以前没有遇到过,存储图像数据有两种根本不同的方式:位图图像,由像素组成;矢量图像,其中保存了单独的绘图指令(例如,“写一些文字”,“画一条线”,“填充一个矩形”等),然后每次显示图像时都会遵循这些指令。位图图像会出现像素化或“模糊”,而矢量图像即使放大或以高分辨率打印时看起来也很棒。
你进行了快速的谷歌搜索,并确认Pillow库无法保存矢量格式的图像;它只能处理位图数据。你的老板并不同情,“只需使其以矢量格式工作,同时保存为PDF和PNG,以满足那些已经在使用它的人。”
心情沉重,你想知道自己怎么可能满足这些新的要求。整个Charter库都是从头开始构建的,用于生成位图PNG图像。难道你不得不从头开始重写整个东西吗?
由于图书馆现在需要将图表保存为矢量格式的PDF文件,我们需要找到一个替代PythonImagingLibrary的支持写入PDF文件的库。其中一个明显的选择是ReportLab。
在许多方面,ReportLab的工作方式与PythonImagingLibrary相同:你初始化一个文档(在ReportLab中称为画布),调用各种方法将元素绘制到画布上,然后使用save()方法将PDF文件保存到磁盘上。
然而,还有一个额外的步骤:因为PDF文件格式支持多页,你需要在保存文档之前调用showPage()函数来呈现当前页面。虽然我们不需要Charter库的多个页面,但我们可以通过在绘制每个页面后调用showPage(),然后在完成时调用save()来创建多页PDF文档并将文件保存到磁盘。
现在我们有了一个工具,可以生成PDF文件,让我们看看如何重新构建Charter包,以支持PNG或PDF文件格式的渲染。
generate_chart()函数似乎是用户应该能够选择输出格式的逻辑点。实际上,我们可以根据文件名自动检测格式——如果filename参数以.pdf结尾,那么我们应该生成PDF格式的图表,而如果filename以.png结尾,那么我们应该生成PNG格式的文件。
更一般地说,我们的渲染器存在一个问题:它们都设计为与PythonImagingLibrary一起工作,并使用ImageDraw模块将每个图表绘制为位图图像。
由于这个原因,以及每个渲染器模块内部的代码复杂性,将这些渲染器保持不变,并编写使用ReportLab生成PDF格式图表元素的新渲染器是有意义的。为此,我们需要对我们的渲染代码进行重构。
在我们着手进行更改之前,让我们考虑一下我们想要实现什么。我们将需要每个渲染器的两个单独版本——一个用于生成PNG格式的元素,另一个用于生成相同的元素的PDF格式:
由于所有这些模块都做同样的事情——在图表上绘制一个元素,因此最好有一个单独的函数,调用适当的渲染器模块的draw()函数以在所需的输出格式中绘制给定的图表元素。这样,我们的其余代码只需要调用一个函数,而不是根据所需的元素和格式选择十个不同的draw()函数。
为此,我们将在renderers包内添加一个名为renderer.py的新模块,并将调用各个渲染器的工作留给该模块。这将极大简化我们的设计。
最后,我们的generate_chart()函数将需要创建一个ReportLab画布以生成PDF格式的图表,然后在图表生成后保存这个画布,就像它现在为位图图像所做的那样。
这意味着,虽然我们需要做一些工作来实现我们的渲染器模块的新版本,创建一个新的renderer.py模块并更新generate_chart()函数,但系统的其余部分将保持完全相同。我们不需要从头开始重写一切,而我们的其余模块——特别是现有的渲染器——根本不需要改变。哇!
我们将通过将现有的PNG渲染器移动到名为renderers.png的新子包中来开始我们的重构。在renderers目录中创建一个名为png的新目录,并将title.py、x_axis.py、y_axis.py、bar_series.py和line_series.py模块移动到该目录中。然后,在png目录内创建一个空的包初始化文件__init__.py,以便Python可以识别它为一个包。
我们将不得不对现有的PNG渲染器进行一个小改动:因为每个渲染器模块使用相对导入导入constants.py模块,我们需要更新这些模块,以便它们仍然可以从新位置找到constants模块。为此,依次编辑每个PNG渲染器模块,并找到以下类似的行:
from..constantsimport*在这些行的末尾添加一个额外的.,使它们看起来像这样:
from...constantsimport*我们的下一个任务是创建一个包来容纳我们的PDF格式渲染器。在renderers目录中创建一个名为pdf的子目录,并在该目录中创建一个空的包初始化文件,使其成为Python包。
接下来,我们要实现前面提到的renderer.py模块,以便我们的generate_chart()函数可以专注于绘制图表元素,而不必担心每个元素定义在哪个模块中。在renderers目录中创建一个名为renderer.py的新文件,并将以下代码添加到该文件中:
from.pngimporttitleastitle_pngfrom.pngimportx_axisasx_axis_pngfrom.pngimporty_axisasy_axis_pngfrom.pngimportbar_seriesasbar_series_pngfrom.pngimportline_seriesasline_series_pngrenderers={'png':{'title':title_png,'x_axis':x_axis_png,'y_axis':y_axis_png,'bar_series':bar_series_png,'line_series':line_series_png},}defdraw(format,element,chart,output):renderers[format][element].draw(chart,output)这个模块正在做一些棘手的事情,这可能是你以前没有遇到过的:在使用import...as导入每个PNG格式的渲染器模块之后,我们将导入的模块视为Python变量,将每个模块的引用存储在renderers字典中。然后,我们的draw()函数使用renderers[format][element]从该字典中选择适当的模块,并调用该模块内部的draw()函数来进行实际绘制。
这个Python技巧为我们节省了大量的编码工作——如果没有它,我们将不得不编写一整套基于所需元素和格式调用适当模块的if...then语句。以这种方式使用字典可以节省我们大量的输入,并使代码更容易阅读和调试。
我们也可以使用Python标准库的importlib模块按名称加载渲染器模块。这将使我们的renderer模块更短,但会使代码更难理解。使用import...as和字典来选择所需的模块是复杂性和可理解性之间的良好折衷。
接下来,我们需要更新我们的generate_report()函数。如前一节所讨论的,我们希望根据正在生成的文件的文件扩展名选择输出格式。我们还需要更新此函数以使用我们的新renderer.draw()函数,而不是直接导入和调用渲染器模块。
编辑generator.py模块,并用以下代码替换该模块的内容:
有了这些更改,您应该能够使用更新后的Charter包来生成PNG格式文件。PDF文件还不能工作,因为我们还没有编写PDF渲染器,但PNG格式输出应该可以工作。继续运行test_charter.py脚本进行测试,以确保您没有输入任何拼写错误。
现在我们已经完成了重构现有代码,让我们添加PDF渲染器。
我们将逐个处理各种渲染器模块。首先,在pdf目录中创建titles.py模块,并将以下代码输入到该文件中:
from...constantsimport*defdraw(chart,canvas):text_width=canvas.stringWidth(chart['title'],"Helvetica",24)text_height=24*1.2left=CHART_WIDTH/2-text_width/2bottom=CHART_HEIGHT-TITLE_HEIGHT/2+text_height/2canvas.setFont("Helvetica",24)canvas.setFillColorRGB(0.25,0.25,0.625)canvas.drawString(left,bottom,chart['title'])在某些方面,这段代码与该渲染器的PNG版本非常相似:我们计算文本的宽度和高度,并使用这些来计算标题应该绘制的图表位置。然后,我们使用24点的Helvetica字体以深蓝色绘制标题。
然而,也有一些重要的区别:
话不多说,让我们实现剩下的PDF渲染模块。x_axis.py模块应该如下所示:
defdraw(chart,canvas):label_height=12*1.2avail_width=CHART_WIDTH-Y_AXIS_WIDTH-MARGINbucket_width=avail_width/len(chart['x_axis'])axis_top=X_AXIS_HEIGHTcanvas.setStrokeColorRGB(0.25,0.25,0.625)canvas.setLineWidth(2)canvas.line(Y_AXIS_WIDTH,axis_top,CHART_WIDTH-MARGIN,axis_top)left=Y_AXIS_WIDTHforbucket_numinrange(len(chart['x_axis'])):canvas.setLineWidth(1)canvas.line(left,axis_top,left,axis_top-TICKMARK_HEIGHT)label_width=canvas.stringWidth(chart['x_axis'][bucket_num],"Helvetica",12)label_left=max(left,left+bucket_width/2-label_width/2)label_bottom=axis_top-TICKMARK_HEIGHT-4-label_heightcanvas.setFont("Helvetica",12)canvas.setFillColorRGB(0.0,0.0,0.0)canvas.drawString(label_left,label_bottom,chart['x_axis'][bucket_num])left=left+bucket_widthcanvas.setStrokeColorRGB(0.25,0.25,0.625)canvas.setLineWidth(1)canvas.line(left,axis_top,left,axis_top-TICKMARK_HEIGHT)同样,y_axis.py模块应该实现如下:
from...constantsimport*defdraw(chart,canvas):label_height=12*1.2axis_top=CHART_HEIGHT-TITLE_HEIGHTaxis_bottom=X_AXIS_HEIGHTaxis_height=axis_top-axis_bottomcanvas.setStrokeColorRGB(0.25,0.25,0.625)canvas.setLineWidth(2)canvas.line(Y_AXIS_WIDTH,axis_top,Y_AXIS_WIDTH,axis_bottom)fory_valueinchart['y_labels']:y=((y_value-chart['y_min'])/(chart['y_max']-chart['y_min']))y_pos=axis_bottom+int(y*axis_height)canvas.setLineWidth(1)canvas.line(Y_AXIS_WIDTH-TICKMARK_HEIGHT,y_pos,Y_AXIS_WIDTH,y_pos)label_width=canvas.stringWidth(str(y_value),"Helvetica",12)label_left=Y_AXIS_WIDTH-TICKMARK_HEIGHT-label_width-4label_bottom=y_pos-label_height/4canvas.setFont("Helvetica",12)canvas.setFillColorRGB(0.0,0.0,0.0)canvas.drawString(label_left,label_bottom,str(y_value))对于bar_series.py模块,输入以下内容:
from...constantsimport*defdraw(chart,canvas):avail_width=CHART_WIDTH-Y_AXIS_WIDTH-MARGINbucket_width=avail_width/len(chart['x_axis'])bottom=X_AXIS_HEIGHTmax_top=CHART_HEIGHT-TITLE_HEIGHTavail_height=max_top-bottomleft=Y_AXIS_WIDTHfory_valueinchart['series']:bar_left=left+MARGIN/2bar_width=bucket_width-MARGINy=((y_value-chart['y_min'])/(chart['y_max']-chart['y_min']))bar_height=int(y*avail_height)canvas.setStrokeColorRGB(0.25,0.25,0.625)canvas.setFillColorRGB(0.906,0.906,0.953)canvas.rect(bar_left,bottom,bar_width,bar_height,stroke=True,fill=True)left=left+bucket_width最后,line_series.py模块应该如下所示:
from...constantsimport*defdraw(chart,canvas):avail_width=CHART_WIDTH-Y_AXIS_WIDTH-MARGINbucket_width=avail_width/len(chart['x_axis'])bottom=X_AXIS_HEIGHTmax_top=CHART_HEIGHT-TITLE_HEIGHTavail_height=max_top-bottomleft=Y_AXIS_WIDTHprev_y=Nonefory_valueinchart['series']:y=((y_value-chart['y_min'])/(chart['y_max']-chart['y_min']))cur_y=bottom+int(y*avail_height)ifprev_y!=None:canvas.setStrokeColorRGB(0.25,0.25,0.625)canvas.setLineWidth(1)canvas.line(left-bucket_width/2,prev_y,left+bucket_width/2,cur_y)prev_y=cur_yleft=left+bucket_width正如你所看到的,这些模块看起来与它们的PNG版本非常相似。只要我们考虑到这两个库工作方式的差异,我们可以用ReportLab做任何PythonImagingLibrary能做的事情。
这使我们只需要做一个更改,就能完成对Charter库的新实现:我们需要更新renderer.py模块,以使这些新的PDF渲染模块可用。为此,将以下import语句添加到这个模块的顶部:
from.pdfimporttitleastitle_pdffrom.pdfimportx_axisasx_axis_pdffrom.pdfimporty_axisasy_axis_pdffrom.pdfimportbar_seriesasbar_series_pdffrom.pdfimportline_seriesasline_series_pdf然后,在这个模块的部分中,我们定义了renderers字典,通过向你的代码添加以下突出显示的行,为字典创建一个新的pdf条目:
renderers={...**'pdf':{**'title':title_pdf,**'x_axis':x_axis_pdf,**'y_axis':y_axis_pdf,**'bar_series':bar_series_pdf,**'line_series':line_series_pdf**}}完成这些工作后,你已经完成了重构和重新实现Charter模块。假设你没有犯任何错误,你的库现在应该能够生成PNG和PDF格式的图表。
为了确保你的程序正常工作,编辑你的test_charter.py程序,并将输出文件的名称从chart.png更改为chart.pdf。然后运行这个程序,你应该会得到一个包含你的图表高质量版本的PDF文件:
注意图表出现在页面底部,而不是顶部。这是因为PDF文件将y=0位置放在页面底部。你可以通过计算页面的高度(以点为单位)并添加适当的偏移量,轻松地将图表移动到页面顶部。如果你愿意,可以实现这一点,但现在我们的任务已经完成。
如果你放大,你会发现图表的文本看起来仍然很好:
这是因为我们现在生成的是矢量格式的PDF文件,而不是位图图像。这个文件可以在高质量激光打印机上打印,而不会出现像素化。更好的是,你库的现有用户仍然可以要求PNG版本的图表,他们不会注意到任何变化。
恭喜你——你做到了!
虽然Charter库只是Python模块化编程的一个例子,你并没有一个坚持要求你生成PDF格式图表的老板,但这些例子被选中是因为问题一点也不简单,你需要做出的改变也非常具有挑战性。回顾我们所取得的成就,你可能会注意到几件事情:
总体教训很明显:与其抵制对需求的变化,不如接受它们。最终的结果是一个更好的系统——更健壮,更可扩展,通常也更有组织。当然,前提是你要做对。
在这一章中,我们使用模块化编程技术来实现一个名为Charter的虚构图表生成包。我们看到图表由标准元素组成,以及如何将这种组织转化为程序代码。成功创建了一个能够将图表渲染为位图图像的工作图表生成库后,我们看到了需求上的根本变化起初似乎是一个问题,但实际上是重构和改进代码的机会。
通过这个虚构的例子,我们重构了Charter库以处理PDF格式的图表。在这样做的过程中,我们了解到使用模块化技术来应对需求的重大变化可以帮助隔离需要进行的更改,并且重构我们的代码通常会导致一个比起始状态更有组织、更可扩展和更健壮的系统。
在下一章中,我们将学习如何使用标准的模块化编程“模式”来处理各种编程挑战。
在前几章中,我们详细讨论了Python模块和包的工作原理,并学习了如何在程序中使用它们。在使用模块化编程技术时,你会发现模块和包的使用方式往往遵循标准模式。在本章中,我们将研究使用模块和包处理各种编程挑战的一些常见模式。特别是,我们将:
让我们从分而治之的原则开始。
分而治之是将问题分解为较小部分的过程。你可能不知道如何解决一个特定的问题,但通过将其分解为较小的部分,然后依次解决每个部分,然后解决原始问题。
当然,这是一个非常普遍的技术,并不仅适用于模块和包的使用。然而,模块化编程有助于你通过分而治之的过程:当你分解问题时,你会发现你需要程序的一部分来执行特定的任务或一系列任务,而Python模块(和包)是组织这些任务的完美方式。
在本书中,我们已经做过几次这样的事情。例如,当面临创建图表生成库的挑战时,我们使用了分而治之的技术,提出了可以绘制单个图表元素的渲染器的概念。然后我们意识到我们需要几个不同的渲染器,这完美地转化为包含每个渲染器单独模块的renderers包。
抽象是另一个非常普遍的编程模式,适用于不仅仅是模块化编程。抽象本质上是隐藏复杂性的过程:将你想要做的事情与如何做它分开。
抽象对所有的计算机编程都是绝对基础的。例如,想象一下,你必须编写一个计算两个平均数然后找出两者之间差异的程序。这个程序的简单实现可能看起来像下面这样:
values_1=[...]values_2=[...]total_1=0forvalueinvalues_1:total=total+valueaverage_1=total/len(values_1)total_2=0forvalueinvalues_2:total=total+valueaverage_2=total/len(values_2)difference=abs(total_1-total-2)print(difference)正如你所看到的,计算列表平均数的代码重复了两次。这是低效的,所以你通常会写一个函数来避免重复。可以通过以下方式实现:
values_1=[...]values_2=[...]defaverage(values):total=0forvalueinvalues:total=total+valuereturn=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)在这个模块之外,你的代码可以专注于它想要做的事情,而不需要知道这些各种任务是如何执行的。通过这样做,你正在使用抽象模式将这些颜色计算的复杂性隐藏起来,使其不影响程序的其他部分。
抽象是设计和编写模块和包的基本技术。例如,我们在上一章中使用的Pillow库提供了各种模块,允许你加载、操作、创建和保存图像。我们可以使用这个库而不需要知道这些各种操作是如何执行的。例如,我们可以调用drawer.line((x1,y1),(x2,y2),color,width)而不必担心设置图像中的单个像素的细节。
应用抽象模式的一个伟大之处在于,当你开始实现代码时,通常并不知道某事物的复杂程度。例如,想象一下,你正在为酒店酒吧编写一个销售点系统。系统的一部分需要计算顾客点酒时应收取的价格。我们可以使用各种公式来计算这个价格,根据数量、使用的酒类等。但其中一个具有挑战性的特点是需要支持欢乐时光,即在此期间饮料将以折扣价提供。
起初,你被告知欢乐时光是每天晚上五点到六点之间。因此,使用良好的模块化技术,你在代码中添加了以下函数:
defis_happy_hour():ifdatetime.datetime.now().hour==17:#5pm.returnTrueelse:returnFalse然后你可以使用这个函数来分离计算欢乐时光的方法和欢乐时光期间发生的事情。例如:
ifis_happy_hour():price=price*0.5到目前为止,这还相当简单,你可能会想要完全绕过创建is_happy_hour()函数。然而,当你发现欢乐时光不适用于星期日时,这个函数很快就变得更加复杂。因此,你必须修改is_happy_hour()函数以支持这一点:
defis_happy_hour():ifdatetime.date.today().weekday()==6:#Sunday.returnFalseelifdatetime.datetime.now().hour==17:#5pm.returnTrueelse:returnFalse但是你随后发现,欢乐时光不适用于圣诞节或耶稣受难日。虽然圣诞节很容易计算,但计算复活节在某一年的日期所使用的逻辑要复杂得多。如果你感兴趣,本章的示例代码包括is_happy_hour()函数的实现,其中包括对圣诞节和耶稣受难日的支持。不用说,这个实现相当复杂。
请注意,随着我们的is_happy_hour()函数的不断发展,它变得越来越复杂-起初我们以为它会很简单,但是添加的要求使它变得更加复杂。幸运的是,因为我们已经将计算快乐时光的细节从需要知道当前是否是快乐时光的代码中抽象出来,只需要更新一个函数来支持这种增加的复杂性。
封装是另一种经常适用于模块和包的编程模式。使用封装,你有一个东西-例如,颜色、客户或货币-你需要存储关于它的数据,但是你将这些数据的表示隐藏起来,不让系统的其他部分知道。而不是直接提供这个东西,你提供设置、检索和操作这个东西数据的函数。
为了看到这是如何工作的,让我们回顾一下我们在上一章中编写的一个模块。我们的chart.py模块允许用户定义一个图表并设置有关它的各种信息。这是我们为这个模块编写的代码的一个副本:
defnew_chart():return{}defset_title(chart,title):chart['title']=titledefset_x_axis(chart,x_axis):chart['x_axis']=x_axisdefset_y_axis(chart,minimum,maximum,labels):chart['y_min']=minimumchart['y_max']=maximumchart['y_labels']=labelsdefset_series_type(chart,series_type):chart['series_type']=series_typedefset_series(chart,series):chart['series']=series正如你所看到的,new_chart()函数创建了一个新的“图表”,而不清楚地告诉系统如何存储有关图表的信息-我们在这里使用了一个字典,但我们也可以使用一个对象、一个base64编码的字符串,或者其他任何东西。系统的其他部分并不关心,因为它只是调用chart.py模块中的各种函数来设置图表的各个值。
不幸的是,这并不是封装的一个完美的例子。我们的各种set_XXX()函数充当设置器-它们让我们设置图表的各种值-但我们只是假设我们的图表生成函数可以直接从图表的字典中访问有关图表的信息。如果这将是封装的一个纯粹的例子,我们还将编写相应的获取器函数,例如:
defget_title(chart):returnchart['title']defget_x_axis(chart):returnchart['x_axis']defget_y_axis(chart):return(chart['y_min'],chart['y_max'],chart['y_labels'])defget_series_type(chart):returnchart['series_type']defget_series(chart):returnchart['series']通过将这些获取器函数添加到我们的模块中,我们现在有了一个完全封装的模块,可以存储和检索关于图表的信息。charter包的其他部分想要使用图表时,将调用获取器函数来检索该图表的数据,而不是直接访问它。
在模块中编写设置器和获取器函数的这些示例有点牵强;封装通常是使用面向对象编程技术来完成的。然而,正如你所看到的,当编写只使用模块化编程技术的代码时,完全可以使用封装。
也许你会想知道为什么有人会想要使用封装。为什么不直接写charts.get_title(chart),而不是简单地写chart['title']?第二个版本更短。它还避免了调用函数,因此速度会更快。为什么要使用封装呢?
在程序中使用封装有两个原因。首先,通过使用获取器和设置器函数,你隐藏了信息存储的细节。这使你能够更改内部表示而不影响程序的任何其他部分-并且在编写程序时你几乎可以肯定的一件事是,你将不断添加更多的信息和功能。这意味着你的数据的内部表示将发生变化。通过将存储的内容与存储方式分离,你的系统变得更加健壮,你可以进行更改而无需重写大量代码。这是一个良好模块化设计的标志。
使用封装的第二个主要原因是允许您的代码在用户设置特定值时执行某些操作。例如,如果用户更改订单的数量,您可以立即重新计算该订单的总价格。设置器经常做的另一件事是将更新后的值保存到磁盘或数据库中。您还可以在设置器中添加错误检查和其他逻辑,以便捕获可能很难跟踪的错误。
让我们详细看一下使用封装模式的Python模块。例如,假设我们正在编写一个用于存储食谱的程序。用户可以创建一个喜爱食谱的数据库,并在需要时显示这些食谱。
让我们创建一个Python模块来封装食谱的概念。在这个例子中,我们将食谱存储在内存中,以保持简单。对于每个食谱,我们将存储食谱的名称、食谱产生的份数、配料列表以及制作食谱时用户需要遵循的指令列表。
创建一个名为recipes.py的新Python源文件,并输入以下内容到此文件中:
defnew():return{'name':None,'num_servings':1,'instructions':[],'ingredients':[]}defset_name(recipe,name):recipe['name']=namedefget_name(recipe):returnrecipe['name']defset_num_servings(recipe,num_servings):recipe['num_servings']=num_servingsdefget_num_servings(recipe):returnrecipe['num_servings']defset_ingredients(recipe,ingredients):recipe['ingredients']=ingredientsdefget_ingredients(recipe):returnrecipe['ingredients']defset_instructions(recipe,instructions):recipe['instructions']=instructionsdefget_instructions(recipe):returnrecipe['instructions']defadd_instruction(recipe,instruction):recipe['instructions'].append(instruction)defadd_ingredient(recipe,ingredient,amount,units):recipe['ingredients'].append({'ingredient':ingredient,'amount':amount,'units':units})正如您所见,我们再次使用Python字典来存储我们的信息。我们可以使用Python类或Python标准库中的namedtuple。或者,我们可以将信息存储在数据库中。但是,在这个例子中,我们希望尽可能简化我们的代码,字典是最简单的解决方案。
创建新食谱后,用户可以调用各种设置器和获取器函数来存储和检索有关食谱的信息。我们还有一些有用的函数,让我们一次添加一条指令和配料,这对我们正在编写的程序更方便。
请注意,当向食谱添加配料时,调用者需要提供三条信息:配料的名称、所需数量以及衡量此数量的单位。例如:
recipes.add_ingredient(recipe,"Milk",1,"cup")到目前为止,我们已经封装了食谱的概念,允许我们存储所需的信息,并在需要时检索它。由于我们的模块遵循了封装原则,我们可以更改存储食谱的方式,向我们的模块添加更多信息和新行为,而不会影响程序的其余部分。
让我们再添加一个有用的函数到我们的食谱中:
defto_string(recipe,num_servings):multiplier=num_servings/recipe['num_servings']s=[]s.append("Recipefor{},{}servings:".format(recipe['name'],num_servings))s.append("")s.append("Ingredients:")s.append("")foringredientinrecipe['ingredients']:s.append("{}-{}{}".format(ingredient['ingredient'],ingredient['amount']*multiplier,ingredient['units']))s.append("")s.append("Instructions:")s.append("")fori,instructioninenumerate(recipe['instructions']):s.append("{}.{}".format(i+1,instruction))returns该函数返回一个字符串列表,可以打印出来以总结食谱。注意num_servings参数:这允许我们为不同的份数定制食谱。例如,如果用户创建了一个三份食谱并希望将其加倍,可以使用to_string()函数,并将num_servings值设为6,正确的数量将包含在返回的字符串列表中。
让我们看看这个模块是如何工作的。打开终端或命令行窗口,使用cd命令转到创建recipes.py文件的目录,并输入python启动Python解释器。然后,尝试输入以下内容以创建披萨面团的食谱:
importrecipesrecipe=recipes.new("PizzaDough",num_servings=1)recipes.add_ingredient(recipe,"GreekYogurt",1,"cup")recipes.add_ingredient(recipe,"Self-RaisingFlour",1.5,"cups")recipes.add_instruction(recipe,"Combineyogurtand2/3oftheflourinabowlandmixwithabeateruntilcombined")recipes.add_instruction(recipe,"Slowlyaddadditionalflouruntilitformsastiffdough")recipes.add_instruction(recipe,"Turnoutontoaflouredsurfaceandkneaduntildoughistacky")recipes.add_instruction(recipe,"Rolloutintoacircleofthedesiredthicknessandplaceonagreasedandlinedbakingtray")到目前为止一切顺利。现在让我们使用to_string()函数打印出食谱的详细信息,并将其加倍到两份:
forsinrecipes.to_string(recipe,num_servings=2):**prints一切顺利的话,食谱应该已经打印出来了:
RecipeforPizzaDough,2servings:Ingredients:**GreekYogurt-2cup**Self-risingFlour-3.0cupsInstructions:1\.Combineyogurtand2/3oftheflourinabowlandmixwithabeateruntilcombined2\.Slowlyaddadditionalflouruntilitformsastiffdough3\.Turnoutontoaflouredsurfaceandkneaduntildoughistacky4\.Rolloutintoacircleofthedesiredthicknessandplaceonagreasedandlinedbakingtray正如您所见,有一些次要的格式问题。例如,所需的希腊酸奶数量列为2cup而不是2cups。如果您愿意,您可以很容易地解决这个问题,但要注意的重要事情是recipes.py模块已经封装了食谱的概念,允许您(和您编写的其他程序)处理食谱而不必担心细节。
作为练习,你可以尝试修复to_string()函数中数量的显示。你也可以尝试编写一个新的函数,从食谱列表中创建一个购物清单,在两个或更多食谱使用相同的食材时自动合并数量。如果你完成了这些练习,你很快就会注意到实现可能会变得非常复杂,但通过将细节封装在一个模块中,你可以隐藏这些细节,使其对程序的其余部分不可见。
包装器本质上是一组调用其他函数来完成工作的函数:
包装器用于简化接口,使混乱或设计不良的API更易于使用,将数据格式转换为更方便的形式,并实现跨语言兼容性。包装器有时也用于向现有API添加测试和错误检查代码。
让我们看一个包装器模块的真实应用。想象一下,你在一家大型银行工作,并被要求编写一个程序来分析资金转账,以帮助识别可能的欺诈行为。你的程序实时接收有关每笔银行间资金转账的信息。对于每笔转账,你会得到:
你可以从决定如何表示一天的总转账开始。因为你需要跟踪每个分支和目标银行的转账总额,所以将这些总额存储在一个二维数组中是有意义的:
在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=[]forbranchinbranch_ids:branch_totals=[]forbankinbank_codes:branch_totals.append(0)totals.append(branch_totals)fortransferintransfers_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%的条目。
为了使你的程序更快,你需要找到一种更好的处理大量数字数组的方法。幸运的是,有一个专门设计来做这件事的库:NumPy。
NumPy是一个出色的数组处理库。你可以创建巨大的数组,并使用一个函数调用对数组执行复杂的操作。不幸的是,NumPy也是一个密集和晦涩的库。它是为数学深度理解的人设计和编写的。虽然有许多教程可用,你通常可以弄清楚如何使用它,但使用NumPy的代码通常很难理解。例如,要计算多个矩阵的平均值将涉及以下操作:
daily_totals=[]fortotalsintotals_to_average:daily_totals.append(totals)average=numpy.mean(numpy.array(daily_totals),axis=0)弄清楚最后一行的作用需要查阅NumPy文档。由于使用NumPy的代码的复杂性,这是一个使用包装模块的完美例子:包装模块可以为NumPy提供一个更易于使用的接口,这样你的代码就可以使用它,而不会被复杂和令人困惑的函数调用所淹没。
为了确保NumPy正常工作,启动你的Python解释器并输入以下内容:
importnumpya=numpy.array([[1,2],[3,4]])print(a)一切顺利的话,你应该看到一个2x2的矩阵显示出来:
[[12]**[34]]现在我们已经安装了NumPy,让我们开始编写我们的包装模块。创建一个新的Python源文件,命名为numpy_wrapper.py,并输入以下内容到这个文件中:
importnumpy就这些了;我们将根据需要向这个包装模块添加函数。
接下来,创建另一个Python源文件,命名为detect_unusual_transfers.py,并输入以下内容到这个文件中:
importrandomimportnumpy_wrapperasnpwBANK_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=[]foriinrange(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文件,并在这个模块的末尾添加以下内容:
defnew(num_rows,num_cols):returnnumpy.zeros((num_rows,num_cols),dtype=numpy.int32)现在,我们可以通过调用我们的包装函数(npw.new())来创建一个新的数组,而不必担心NumPy的工作细节。我们已经简化了NumPy的特定方面的接口:
现在让我们使用我们的包装函数来创建我们需要的八个数组,每天一个。在detect_unusual_transfers.py文件的末尾添加以下内容:
transfers_by_day={}fordayindays: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脚本的末尾:
forday,bank_code,branch_id,amountintransfers: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和目标银行代码的组合,我们需要做以下工作:
当然,我们需要对我们的数组中的每一行和每一列都这样做,这将非常慢;这就是为什么我们使用NumPy的原因。因此,我们需要计算多个数字数组的平均值,然后将平均值数组乘以1.5,最后,将乘以后的数组与第八天的数据数组进行比较。幸运的是,这些都是NumPy可以为我们做的事情。
我们将首先收集我们需要平均的七个数组,以及第八天的数组。为此,将以下内容添加到你的程序的末尾:
latest_day=max(days)transfers_to_average=[]fordayindays:ifday!=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模块的末尾添加以下代码:
defaverage(arrays_to_average):returnnumpy.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模块,并在文件末尾添加以下内容:
defget_indices(array):returnnumpy.transpose(array.nonzero())这个函数返回一个列表(实际上是一个数组),其中包含数组中所有True条目的(行,列)值。回到我们的detect_unusual_activity.py文件,我们可以使用这个函数快速识别不寻常的活动:
如果你运行你的程序,你应该会看到类似这样的输出:
Branch125000371transferred$24,729,847tobankWFBIUS6S,average=$14,954,617Branch125000402transferred$26,818,710tobankCERYUS33,average=$16,338,043Branch125001067transferred$27,081,511tobankEQTYUS44,average=$17,763,644因为我们在金融数据中使用随机数,所以输出也将是随机的。尝试运行程序几次;如果没有生成可疑的随机值,则可能根本没有输出。
当然,我们并不真正关心检测可疑的金融活动——这个例子只是一个借口,用来处理NumPy。更有趣的是我们创建的包装模块,它隐藏了NumPy接口的复杂性,使得我们程序的其余部分可以集中精力完成工作。
如果我们继续开发我们的异常活动检测器,毫无疑问,我们会在numpy_wrapper.py模块中添加更多功能,因为我们发现了更多想要封装的NumPy函数。
这只是包装模块的一个例子。正如我们之前提到的,简化复杂和混乱的API只是包装模块的一个用途;它们还可以用于将数据从一种格式转换为另一种格式,向现有API添加测试和错误检查代码,并调用用其他语言编写的函数。
请注意,根据定义,包装器始终是薄的——虽然包装器中可能有代码(例如,将参数从对象转换为字典),但包装器函数最终总是调用另一个函数来执行实际工作。
大多数情况下,模块提供的功能是预先知道的。模块的源代码实现了一组明确定义的行为,这就是模块的全部功能。然而,在某些情况下,您可能需要一个模块,在编写时模块的行为并不完全定义。系统的其他部分可以以各种方式扩展模块的行为。设计为可扩展的模块称为可扩展模块。
Python的一个伟大之处在于它是一种动态语言。您不需要在运行之前定义和编译所有代码。这使得使用Python创建可扩展模块变得很容易。
在本节中,我们将看一下模块可以被扩展的三种不同方式:通过使用动态导入,编写插件,以及使用钩子。
在上一章中,我们创建了一个名为renderers.py的模块,它选择了一个适当的渲染器模块,以使用给定的输出格式绘制图表元素。以下是该模块源代码的摘录:
from.pngimporttitleastitle_pngfrom.pngimportx_axisasx_axis_pngfrom.pdfimporttitleastitle_pdffrom.pdfimportx_axisasx_axis_pdfrenderers={'png':{'title':title_png,'x_axis':x_axis_png,},'pdf':{'title':title_pdf,'x_axis':x_axis_pdf,}}defdraw(format,element,chart,output):renderers[format][element].draw(chart,output)这个模块很有趣,因为它以有限的方式实现了可扩展性的概念。请注意,renderer.draw()函数调用另一个模块内的draw()函数来执行实际工作;使用哪个模块取决于所需的图表格式和要绘制的元素。
这个模块并不真正可扩展,因为可能的模块列表是由模块顶部的import语句确定的。然而,可以通过使用importlib将其转换为完全可扩展的模块。这是Python标准库中的一个模块,它使开发人员可以访问用于导入模块的内部机制;使用importlib,您可以动态导入模块。
要理解这是如何工作的,让我们看一个例子。创建一个新的目录来保存您的源代码,在这个目录中,创建一个名为module_a.py的新模块。将以下代码输入到这个模块中:
defsay_hello():print("Hellofrommodule_a")现在,创建一个名为module_b.py的此模块的副本,并编辑say_hello()函数以打印Hellofrommodule_b。然后,重复这个过程来创建module_c.py。
我们现在有三个模块,它们都实现了一个名为say_hello()的函数。现在,在同一个目录中创建另一个Python源文件,并将其命名为load_module.py。然后,输入以下内容到这个文件中:
importimportlibmodule_name=input("Loadmodule:")ifmodule_name!="":module=importlib.import_module(module_name)module.say_hello()该程序提示用户使用input()语句输入一个字符串。然后,我们调用importlib.import_module()来导入具有该名称的模块,并调用该模块的say_hello()函数。
尝试运行这个程序,当提示时,输入module_a。你应该会看到以下消息显示:
Hellofrommodule_a尝试用其他模块重复这个过程。如果输入一个不存在的模块名称,你会得到一个ImportError。
当然,importlib并不仅限于导入与当前模块相同目录中的模块;如果需要,你可以包括包名。例如:
module=importlib.import_module("package.sub_package.module")使用importlib,你可以动态地导入一个模块——在编写程序时不需要知道模块的名称。我们可以使用这个来重写上一章的renderer.py模块,使其完全可扩展:
fromimportlibimportimport_moduledefdraw(format,element,chart,output):renderer=import_module("{}.{}.{}".format(__package__,format,element))renderer.draw(chart,output)注意注意到了特殊的__package__变量的使用。它保存了包含当前模块的包的名称;使用这个变量允许我们相对于renderer.py模块所属的包导入模块。
动态导入的好处是,在创建程序时不需要知道所有模块的名称。使用renderer.py的例子,你可以通过创建新的渲染器模块来添加新的图表格式或元素,系统将在请求时导入它们,而无需对renderer.py模块进行任何更改。
插件是用户(或其他开发人员)编写并“插入”到你的程序中的模块。插件在许多大型系统中很受欢迎,如WordPress、JQuery、GoogleChrome和AdobePhotoshop。插件用于扩展现有程序的功能。
在Python中,使用我们在上一节讨论过的动态导入机制很容易实现插件。唯一的区别是,不是导入已经是程序源代码一部分的模块,而是设置一个单独的目录,用户可以将他们想要添加到程序中的插件放在其中。这可以简单地创建一个plugins目录在程序的顶层,或者你可以将插件存储在程序源代码之外的目录中,并修改sys.path以便Python解释器可以在该目录中找到模块。无论哪种方式,你的程序都将使用importlib.import_module()来加载所需的插件,然后像访问任何其他Python模块中的函数和其他定义一样访问插件中的函数和其他定义。
本章提供的示例代码包括一个简单的插件加载器,展示了这种机制的工作方式。
钩子是允许外部代码在程序的特定点被调用的一种方式。钩子通常是一个函数——你的程序会检查是否定义了一个钩子函数,如果是,就会在适当的时候调用这个函数。
钩子有一些需要注意的事项:
Hooks是向您的模块添加特定可扩展性点的绝佳方式。它们易于实现和使用,与动态导入和插件不同,它们不要求您将代码放入单独的模块中。这意味着hooks是以非常精细的方式扩展您的模块的理想方式。
在本章中,我们看到模块和包的使用方式往往遵循标准模式。我们研究了分而治之的模式,这是将问题分解为较小部分的过程,并看到这种技术如何帮助构建程序结构并澄清您对要解决的问题的思考。
接下来,我们看了抽象模式,这是通过将您想要做的事情与如何做它分开来隐藏复杂性的过程。然后我们研究了封装的概念,即存储有关某些事物的数据,但隐藏该数据的表示方式的细节,使用getter和setter函数来访问该数据。
然后我们转向包装器的概念,并看到包装器如何用于简化复杂或令人困惑的API的接口,转换数据格式,实现跨语言兼容性,并向现有API添加测试和错误检查代码。
最后,我们了解了可扩展模块,并看到我们可以使用动态模块导入、插件和hooks的技术来创建一个模块,它可以做的不仅仅是您设计它要做的事情。我们看到Python的动态特性使其非常适合创建可扩展模块,其中您的模块的行为在编写时并不完全定义。
在下一章中,我们将学习如何设计和实现可以在其他程序中共享和重用的模块。
模块化编程不仅是一种为自己编写程序的好技术,也是一种为其他程序员编写的程序的绝佳方式。在本章中,我们将看看如何设计和实现可以在其他程序中共享和重用的模块和包。特别是,我们将:
无论您编写的Python源代码是什么,您创建的代码都会执行某种任务。也许您的代码分析一些数据,将一些信息存储到文件中,或者提示用户从列表中选择一个项目。您的代码是什么并不重要——最终,您的代码会做某事。
通常,这是非常具体的。例如,您可能有一个计算复利、生成维恩图或向用户显示警告消息的函数。一旦您编写了这段代码,您就可以在自己的程序中随时使用它。这就是前一章中描述的简单抽象模式:您将想要做什么与如何做分开。
一旦您编写了函数,您就可以在需要执行该任务时调用它。例如,您可以在需要向用户显示警告时调用您的display_warning()函数,而不必担心警告是如何显示的细节。
然而,这个假设的display_warning()函数不仅在您当前编写的程序中有用。其他程序可能也想执行相同的任务——无论是您将来编写的程序还是其他人可能编写的程序。与其每次重新发明轮子,通常更有意义的是重用您的代码。
虽然所有这些选项都可以工作,但并非所有选项都是理想的。让我们更仔细地看看每一个:
如果你已经将源模块或包复制到新程序中,那么如果原始模块发生更改,你将需要手动更新它。这并不理想,但由于你替换了整个文件,这并不太困难。另一方面,如果你的新程序使用存储在其他位置的模块,那么就没有需要更新的内容——对原始模块所做的任何更改将立即应用于使用该模块的任何程序。
status=os.system("pythonother_program.py
实际上,可以在运行的程序之间传输Python数据结构,但涉及的过程非常复杂,不值得考虑。
正如你所看到的,代码片段、模块/包导入和独立程序形成一种连续体:代码片段非常小且细粒度,模块和包导入支持更大的代码块,同时仍然易于使用和更新,独立程序很大,但在与其交互的方式上受到限制。
在这三种方法中,使用模块和包导入来共享代码似乎是最合适的:它们可以用于大量代码,易于使用和交互,并且在必要时非常容易更新。这使得模块和包成为共享Python源代码的理想机制——无论是与自己共享,用于将来的项目,还是与其他人共享。
为了使模块或包可重用,它必须满足以下要求:
如果一个模块或包不满足这三个要求,要在其他程序中重用它将非常困难,甚至不可能。现在让我们依次更详细地看看这些要求。
ImportError:Nomodulenamed'hash_utils'encryption模块可能已经被共享,但它依赖于原始程序中的另一个模块(hash_utils.py),而这个模块没有被共享,因此encryption模块本身是无用的。
解决这个问题的方法是将你想要共享的模块与它可能依赖的任何其他模块结合起来,将这些模块放在一个包中。然后共享这个包,而不是单独的模块。以下插图展示了如何做到这一点:
在这个例子中,我们创建了一个名为encryptionlib的新包,并将encryption.py和hash_utils.py文件移动到了这个包中。当然,这需要你重构程序的其余部分,以适应这些模块的新位置,但这样做可以让你在其他程序中重用你的加密逻辑。
虽然以这种方式重构你的程序可能有点麻烦,但结果几乎总是对原始程序的改进。将依赖模块放在一个包中有助于改善代码的整体组织。
继续上一节的例子,想象一下你想要将你的新的encryptionlib包作为另一个程序的一部分,但不想将其作为单独的包公开。在这种情况下,你可以简单地将整个encryptionlib目录包含在你的新系统源代码中。然而,如果你的模块不使用相对导入,就会遇到问题。例如,如果你的encryption模块依赖于hash_utils模块,那么encryption模块将包含一个引用hash_utils模块的import语句。然而,如果encryption模块以以下任何一种方式导入hash_utils,则生成的包将无法重用:
importhash_utilsfrommy_program.libimporthash_utilsfromhash_utilsimport*所有这些导入语句都会失败,因为它们假设hash_utils.py文件在程序源代码中的特定固定位置。对于依赖模块在程序源代码中位置的任何假设都会限制包的可重用性,因为你不能将包移动到不同的位置并期望它能够工作。考虑到新项目的要求,你经常需要将包和模块存储在与它们最初开发的位置不同的地方。例如,也许encryptionlib包需要安装在thirdparty包中,与所有其他重用的库一起。使用绝对导入,你的包将失败,因为其中的模块位置已经改变。
如果你发布你的包然后将其安装到Python的site-packages目录中,这个规则就不适用了。然而,有许多情况下你不想将可重用的包安装到site-packages目录中,因此你需要小心相对导入。
为了解决这个问题,请确保包内的任何import语句引用同一包内的其他模块时始终使用相对导入。例如:
from.importhash_utils这将使你的包能够在Python源树的任何位置运行。
想象一下,我们的新的encryptionlib包利用了我们在上一章中遇到的NumPy库。也许hash_utils导入了一些来自NumPy的函数,并使用它们来快速计算数字列表的二进制哈希。即使NumPy作为原始程序的一部分安装了,你也不能假设新程序也是如此:如果你将encryptionlib包安装到一个新程序中并运行它,最终会出现以下错误:
ImportError:Nomodulenamed'numpy'为了防止发生这种情况,重要的是任何想要重用您的模块的人都知道对第三方模块的依赖,并且清楚地知道为了使您的模块或软件包正常运行需要安装什么。包含这些信息的理想位置是您共享的模块或软件包的README文件或其他文档。
如果您使用诸如setuptools或pip之类的自动部署系统,这些工具有其自己的方式来识别您的软件包的要求。然而,将要求列在文档中仍然是一个好主意,这样您的用户在安装软件包之前就会意识到这些要求。
在前一节中,我们看了可重用模块的最低要求。现在让我们来看看可重用性的理想要求。一个完美的可重用模块会是什么样子?
优秀的可重用模块与糟糕的模块有三个区别:
让我们更仔细地看看这些要点。
通常在编程时,您会发现自己需要执行特定的任务,因此编写一个函数来执行此任务。例如,考虑以下情况:
FEATURE_ID|FEATURE_NAME|FEATURE_CLASS|...1397658|Ester|PopulatedPlace|...1397926|Afognak|PopulatedPlace|...为此,您创建一个load_placenames()函数,从该文件中读取数据。
1customer8customers消息使用customer还是customers取决于提供的数量。为了处理这个问题,您创建一个pluralize_customers()函数,根据提供的数量返回相应的复数形式的消息。
defpluralize(n,singular_name,plural_name=None):尽管这只是三个具体的例子,但您可以看到,通过将您共享的代码泛化,可以使其适用于更广泛的任务。通常,泛化函数所需的工作量很少,但结果将受到使用您创建的代码的人们的极大赞赏。
举个实际的例子,考虑以下代码片段:
shapefile=ogr.Open("...")layer=shapefile.GetLayer(0)foriinrange(layer.GetFeatureCount()):feature=layer.GetFeature(i)shape=shapely.loads(feature.GetGeometryRef().ExportToWkt())ifshape.contains(target_zone):...这段代码利用了两个库:Shapely库,用于执行计算几何,以及OGR库,用于读写地理空间数据。Shapely库遵循使用小写字母命名函数和方法的标准Python约定:
shapely.loads(...)shape.contains(...)虽然这些库的细节相当复杂,但这些函数和方法的命名易于记忆和使用。然而,与之相比,OGR库将每个函数和方法的第一个字母大写:
ogr.Open(...)layer.GetFeatureCount()使用这两个库时,你必须不断地记住OGR将每个函数和方法的第一个字母大写,而Shapely则不会。这使得使用OGR比必要更加麻烦,并导致生成的代码中出现相当多的错误,需要进行修复。
如果OGR库简单地遵循了与Shapely相同的命名约定,所有这些问题都可以避免。
虽然编码约定是个人偏好的问题,你当然不必盲目遵循Python风格指南中的指示,但这样做(至少在影响你的代码用户方面)将使其他人更容易使用你的可重用模块和包——就像OGR库的例子一样,你不希望用户在想要导入和使用你的代码时不断记住一个不寻常的命名风格。
即使你编写了完美的模块,解决了一系列通用问题,并忠实地遵循了Python风格指南,如果没有人知道如何使用它,你的模块也是无用的。不幸的是,作为程序员,我们经常对我们的代码太过了解:我们很清楚我们的代码是如何工作的,所以我们陷入了假设其他人也应该很清楚的陷阱。此外,程序员通常讨厌编写文档——我们更愿意编写一千行精心编写的Python代码,而不是写一段描述它如何工作的话。因此,我们共享的代码的文档通常是勉强写的,甚至根本不写。
问题是,高质量的可重用模块或包将始终包括文档。这份文档将解释模块的功能和工作原理,并包括示例,以便读者可以立即看到如何在他们自己的程序中使用这个模块或包。
每个模块、类、函数和方法都有清晰的文档,包括示例和详细的注释,以帮助这个模块的用户。
作为可重用模块的开发人员,您不必达到这些高度。Python标准库是一个庞大的协作努力,没有一个人编写了所有这些文档。但这是您应该追求的文档类型的一个很好的例子:包含大量示例的全面文档。
虽然您可以在文字处理器中创建文档,或者使用类似Sphinx系统的复杂文档生成系统来构建Python文档,但有两种非常简单的方法可以在最少的麻烦下编写文档:创建README文件或使用文档字符串。
README文件只是一个文本文件,它与组成您的模块或包的各种源文件一起包含在内。它通常被命名为README.txt,它只是一个普通的文本文件。您可以使用用于编辑Python源代码的相同编辑器创建此文件。
README文件可以是尽可能广泛或最小化的。通常有助于包括有关如何安装和使用模块的信息,任何许可问题,一些使用示例以及如果您的模块或包包含来自他人的代码,则包括致谢。
文档字符串是附加到模块或函数的Python字符串。这专门用于文档目的,有一个非常特殊的Python语法用于创建文档字符串:
"""my_module.pyThisisthedocumentationforthemy_modulemodule."""defmy_function():"""Thisisthedocumentationforthemy_function()function.Asyoucansee,thedocumentationcanspanmorethanoneline."""...在Python中,您可以使用三个引号字符标记跨越Python源文件的多行的字符串。这些三引号字符串可以用于各种地方,包括文档字符串。如果一个模块以三引号字符串开头,那么这个字符串将用作整个模块的文档。同样,如果任何函数以三引号字符串开头,那么这个字符串将用作该函数的文档。
同样适用于Python中的其他定义,例如类、方法等。
文档字符串通常用于描述模块或函数的功能,所需的参数以及返回的信息。还应包括模块或函数的任何值得注意的方面,例如意外的副作用、使用示例等。
文档字符串(和README文件)不必非常广泛。您不希望花费数小时来撰写关于模块中只有三个人可能会使用的某个晦涩函数的文档。但是写得很好的文档字符串和README文件是出色且易于使用的模块或包的标志。
撰写文档是一种技能;像所有技能一样,通过实践可以变得更好。要创建可以共享的高质量模块和包,您应该养成创建文档字符串和README文件的习惯,以及遵循编码约定并尽可能地泛化您的代码,正如我们在本章的前几节中所描述的那样。如果您的目标是从一开始就产生高质量的可重用代码,您会发现这并不难。
Python包索引非常庞大,但也非常有用:所有最成功的包和模块都包含在其中。让我们更仔细地看一些更受欢迎的可重用包。
以下示例代码显示了requests库如何允许您发送复杂的HTTP请求并轻松处理响应:
requests库非常容易安装(在大多数情况下,您可以简单地使用pipinstallrequests)。它有很好的文档,包括用户指南、社区指南和详细的API文档,并且完全符合Python样式指南。它还提供了一套非常通用的功能,通过HTTP协议处理与外部网站和系统的各种通信。有了这些优点,难怪requests是整个Python包索引中第三受欢迎的包。
以下示例代码计算复活节星期五的日期,比我们在上一章中用于快乐时光计算的形式要简单得多:
以下示例代码显示了如何使用lxml快速生成XML格式数据:
fromlxmlimportetreemovies=etree.Element("movie")movie=etree.SubElement(movies,"movie")movie.text="TheWizardofOz"movie.set("year","1939")movie=etree.SubElement(movies,"movie")movie.text="MaryPoppins"movie.set("year","1964")movie=etree.SubElement(movies,"movie")movie.text="Chinatown"movie.set("year","1974")print(etree.tostring(movies,pretty_print=True))这将打印出一个包含三部经典电影信息的XML格式文档:
lxml网站包括优秀的文档,包括教程、如何安装包以及完整的API参考。对于它解决的特定任务,lxml非常吸引人且易于使用。难怪这是Python包索引中非常受欢迎的包。
现在让我们将学到的知识应用到一个有用的Python包的设计和实现中。在上一章中,我们讨论了使用Python模块封装食谱的概念。每个食谱的一部分是成分的概念,它有三个部分:
如果我们想要处理成分,我们需要能够正确处理单位。例如,将1.5千克加上750克不仅仅是加上数字1.5和750——您必须知道如何将这些值从一个单位转换为另一个单位。
在食谱的情况下,有一些相当不寻常的转换需要我们支持。例如,你知道三茶匙的糖等于一汤匙的糖吗?为了处理这些类型的转换,让我们编写一个单位转换库。
我们的单位转换器将需要了解烹饪中使用的所有标准单位。这些包括杯、汤匙、茶匙、克、盎司、磅等。我们的单位转换器将需要一种表示数量的方式,比如1.5千克,并且能够将数量从一种单位转换为另一种单位。
除了表示和转换数量,我们希望我们的图书馆能够显示数量,自动使用适当的单位名称的单数或复数形式,例如,6杯,1加仑,150克等。
由于我们正在显示数量,如果我们的图书馆能够解析数量,将会很有帮助。这样,用户就可以输入像3汤匙这样的值,我们的图书馆就会知道用户输入了三汤匙的数量。
我们越想这个图书馆,它似乎越像一个有用的工具。我们是在考虑我们的处理食谱程序时想到的这个,但似乎这可能是一个理想的可重用模块或包的候选者。
根据我们之前看过的指南,让我们考虑如何尽可能地概括我们的图书馆,使其在其他程序和其他程序员中更有用。
现在让我们更详细地设计我们的Quantities图书馆。我们希望我们的图书馆的用户能够很容易地创建一个新的数量。例如:
q=quantities.new(5,"kilograms")我们还希望能够将字符串解析为数量值,就像这样:
q=quantities.parse("3tbsp")然后我们希望能够以以下方式显示数量:
print(q)我们还希望能够知道一个数量代表的是什么类型的值,例如:
>>>print(quantities.kind(q))weight这将让我们知道一个数量代表重量、长度或距离等。
我们还可以获取数量的值和单位:
>>>print(quantities.value(q))3>>>print(quantities.units(q))tablespoon我们还需要能够将一个数量转换为不同的单位。例如:
>>>q=quantities.new(2.5,"cups")>>>print(quantities.convert(q,"liter"))0.59147059125liters最后,我们希望能够获得我们的图书馆支持的所有单位种类的列表以及每种单位的个体单位:
>>>forkindinquantities.supported_kinds():>>>forunitinquantities.supported_units(kind):>>>print(kind,unit)weightgramweightkilogramweightounceweightpoundlengthmillimeter...我们的Quantities图书馆还需要支持一个最终功能:本地化单位和数量的能力。不幸的是,某些数量的转换值会根据你是在美国还是其他地方而有所不同。例如,在美国,一茶匙的体积约为4.93立方厘米,而在世界其他地方,一茶匙被认为有5立方厘米的体积。还有命名约定要处理:在美国,米制系统的基本长度单位被称为米,而在世界其他地方,同样的单位被拼写为metre。我们的单位将不得不处理不同的转换值和不同的命名约定。
为了做到这一点,我们需要支持区域设置的概念。当我们的图书馆被初始化时,调用者将指定我们的模块应该在哪个区域下运行:
quantities.init("international")这将影响库使用的转换值和拼写:
这三个模块将合并为一个名为quantities的单个Python包。
请注意,我们在设计时故意使用术语库来指代系统;这确保我们没有通过将其视为单个模块或包来预先设计。现在才清楚我们将要编写一个Python包。通常,你认为是模块的东西最终会变成一个包。偶尔也会发生相反的情况。对此要保持灵活。
现在我们对Quantities库有了一个很好的设计,知道它将做什么,以及我们想要如何构建它,让我们开始写一些代码。
本节包含大量源代码。请记住,你不必手动输入所有内容;本章的示例代码中提供了quantities包的完整副本,可以下载。
虽然你不需要理解面向对象的编程技术来阅读本书,但这是我们需要使用面向对象编程的地方。这是因为我们希望用户能够直接打印一个数量,而在Python中唯一的方法就是使用对象。不过别担心,这段代码非常简单,我们会一步一步来。
在quantity.py模块中,输入以下Python代码:
classQuantity(object):def__init__(self,value,units):self.value=valueself.units=units我们在这里做的是定义一个称为Quantity的新对象类型。第二行看起来非常像一个函数定义,只是我们正在定义一种特殊类型的函数,称为方法,并给它一个特殊的名称__init__。当创建新对象时,这个方法用于初始化新对象。self参数指的是正在创建的对象;正如你所看到的,我们的__init__函数接受两个额外的参数,命名为value和units,并将这两个值存储到self.value和self.units中。
有了我们定义的新Quantity对象,我们可以创建新对象并检索它们的值。例如:
q=Quantity(1,"inch")print(q.value,q.units)第一行使用Quantity类创建一个新对象,为value参数传递1,为units参数传递"inch"。然后__init__方法将这些存储在对象的value和units属性中。正如你在第二行看到的,当我们需要时很容易检索这些属性。
我们几乎完成了quantity.py模块的实现。只剩最后一件事要做:为了能够打印Quantity值,我们需要向我们的Quantity类添加另一个方法;这个方法将被称为__str__,并且在我们需要打印数量时将被使用。为此,请在quantity.py模块的末尾添加以下Python代码:
def__str__(self):return"{}{}".format(self.value,self.units)确保def语句的缩进与之前的def__init__()语句相同,这样它就是我们正在创建的类的一部分。这将允许我们做一些如下的事情:
>>>q=Quantity(1,"inch")>>>print(q)1inchPython的print()函数调用特别命名的__str__方法来获取要显示的数量的文本。我们的__str__方法返回值和单位,用一个空格分隔,这样可以得到一个格式良好的数量摘要。
这完成了我们的quantity.py模块。正如您所看到的,使用对象并不像看起来那么困难。
我们的下一个任务是收集关于我们的包将支持的各种单位的存储信息。因为这里有很多信息,我们将把它放入一个单独的模块中,我们将称之为units.py。
在您的quantities包中创建units.py模块,并首先输入以下内容到这个文件中:
UNITS={}UNITS字典将把单位类型映射到该类型定义的单位列表。例如,所有长度单位将放入UNITS['length']列表中。
对于每个单位,我们将以字典的形式存储关于该单位的信息,具有以下条目:
正如我们在前一节中讨论的,我们需要能够本地化我们的各种单位和数量。为此,所有这些字典条目都可以有单个值或将每个语言环境映射到一个值的字典。例如,liter单位可以使用以下Python字典来定义:
{'name':{'us':"liter",'international':"litre"},'plural':{'us':"liters",'international':"litres"},'abbreviation':"l",'num_units':1000}这允许我们在不同的语言环境中拥有不同的liter拼写。其他单位可能会有不同数量的单位或不同的缩写,这取决于所选择的语言环境。
现在我们知道了如何存储各种单位定义,让我们实现units.py模块的下一部分。为了避免重复输入大量单位字典,我们将创建一些辅助函数。在您的模块末尾添加以下内容:
defby_locale(value_for_us,value_for_international):return{"us":value_for_us,"international":value_for_international}此函数将返回一个将us和international语言环境映射到给定值的字典,使得创建一个特定语言环境的字典条目更容易。
接下来,在您的模块中添加以下函数:
defunit(*args):iflen(args)==3:abbreviation=args[0]name=args[1]ifisinstance(name,dict):plural={}forkey,valueinname.items():plural[key]=value+"s"else:plural=name+"s"num_units=args[2]eliflen(args)==4:abbreviation=args[0]name=args[1]plural=args[2]num_units=args[3]else:raiseRuntimeError("Badargumentstounit():{}".format(args))return{'abbreviation':abbreviation,'name':name,'plural':plural,'num_units':num_units}这个看起来复杂的函数为单个单位创建了字典条目。它使用特殊的*args参数形式来接受可变数量的参数;调用者可以提供缩写、名称和单位数量,或者提供缩写、名称、复数名称和单位数量。如果没有提供复数名称,它将通过在单位的单数名称末尾添加s来自动计算。
请注意,这里的逻辑允许名称可能是一个区域特定名称的字典;如果名称是本地化的,那么复数名称也将根据区域逐个地计算。
最后,我们定义一个简单的辅助函数,使一次性定义一个单位列表变得更容易:
defunits(kind,*units_to_add):ifkindnotinUNITS:UNITS[kind]=[]forunitinunits_to_add:UNITS[kind].append(unit)有了所有这些辅助函数,我们很容易将各种单位添加到UNITS字典中。在您的模块末尾添加以下代码;这定义了我们的包将支持的各种基于重量的单位:
units("weight",unit("g","gram",1),unit("kg","kilogram",1000))unit("oz","ounce",28.349523125),unit("lb","pound",453.59237))接下来,添加一些基于长度的单位:
units("length",unit("cm",by_locale("centimeter","centimetre"),1),unit("m",by_locale("meter","metre",100),unit("in","inch","inches",2.54)unit("ft","foot","feet",30.48))正如您所看到的,我们使用by_locale()函数基于用户当前的语言环境创建了单位名称和复数名称的不同版本。我们还为inch和foot单位提供了复数名称,因为这些名称不能通过在名称的单数版本后添加s来计算。
现在让我们添加一些基于面积的单位:
units("area",unit("sqm",by_locale("squaremeter","squaremetre"),1),unit("ha","hectare",10000),unit("a","acre",4046.8564224))最后,我们将定义一些基于体积的单位:
units("volume",unit("l",by_locale("liter","litre"),1000),unit("ml",by_locale("milliliter","millilitre"),1),unit("c","cup",localize(236.5882365,250)))对于"cup"单位,我们本地化的是单位的数量,而不是名称。这是因为在美国,一杯被认为是236.588毫升,而在世界其他地方,一杯被测量为250毫升。
为了保持代码清单的合理大小,这些单位列表已经被缩写。本章示例代码中包含的quantities包版本具有更全面的单位列表。
这完成了我们的单位定义。为了使我们的代码能够使用这些各种单位,我们将在units.py模块的末尾添加两个额外的函数。首先是一个函数,用于选择单位字典中值的适当本地化版本:
deflocalize(value,locale):ifisinstance(value,dict):returnvalue.get(locale)else:returnvalue如您所见,我们检查value是否为字典;如果是,则返回提供的locale的字典中的条目。否则,直接返回value。每当我们需要从单位的字典中检索名称、复数名称、缩写或值时,我们将使用此函数。
我们接下来需要的第二个函数是一个函数,用于搜索存储在UNITS全局变量中的各种单位。我们希望能够根据其单数或复数名称或缩写找到单位,允许拼写特定于当前区域。为此,在units.py模块的末尾添加以下代码:
deffind_unit(s,locale):s=s.lower()forkindinUNITS.keys():forunitinUNITS[kind]:if(s==localize(unit['abbreviation'],locale).lower()ors==localize(unit['name'],locale).lower()ors==localize(unit['plural'],locale).lower()):#Success!return(kind,unit)return(None,None)#Notfound.请注意,我们在检查之前使用s.lower()将字符串转换为小写。这确保我们可以找到inch单位,例如,即使用户将其拼写为Inch或INCH。完成后,我们的find_units()函数将返回找到的单位的种类和单位字典,或者(None,None)如果找不到单位。
这完成了units.py模块。现在让我们创建interface.py模块,它将保存我们quantities包的公共接口。
我们可以直接将所有这些代码放入包初始化文件__init__.py中,但这可能会有点令人困惑,因为许多程序员不希望在__init__.py文件中找到代码。相反,我们将在interface.py模块中定义所有公共函数,并将该模块的内容导入__init__.py中。
创建interface.py模块,将其放置到units.py和quantities.py旁边的quantities包目录中。然后,在该模块的顶部添加以下import语句:
from.unitsimportUNITS,localize,find_unitfrom.quantityimportQuantity如您所见,我们使用相对导入语句从units.py模块加载UNITS全局变量以及localize()和find_unit()函数。然后,我们使用另一个相对导入来加载我们在quantity.py模块中定义的Quantity类。这使得这些重要的函数、类和变量可供我们的代码使用。
现在我们需要实现本章前面识别出的各种函数。我们将从init()开始,该函数初始化整个quantities包。将以下内容添加到您的interface.py模块的末尾:
definit(locale):global_locale_locale=locale调用者将提供区域的名称(应为包含us或international的字符串,因为这是我们支持的两个区域),我们将其存储到名为_locale的私有全局变量中。
我们要实现的下一个函数是new()。这允许用户通过提供值和所需单位的名称来定义新的数量。我们将使用find_unit()函数来确保单位存在,然后创建并返回一个新的带有提供的值和单位的Quantity对象:
defnew(value,units):global_localekind,unit=find_unit(units,_locale)ifkind==None:raiseValueError("Unknownunit:{}".format(units))returnQuantity(value,localize(unit['name'],_locale))因为单位的名称可能会根据区域而变化,我们使用_locale私有全局变量来帮助找到具有提供的名称、复数名称或缩写的单位。找到单位后,我们使用该单位的官方名称创建一个新的Quantity对象,然后将其返回给调用者。
除了通过提供值和单位来创建一个新的数量之外,我们还需要实现一个parse()函数,将一个字符串转换为Quantity对象。现在让我们来做这个:
defparse(s):global_localesValue,sUnits=s.split("",maxsplit=1)value=float(sValue)kind,unit=find_unit(sUnits,_locale)ifkind==None:raiseValueError("Unknownunit:{}".format(sUnits))returnQuantity(value,localize(unit['name'],_locale))我们在第一个空格处拆分字符串,将第一部分转换为浮点数,并搜索一个名称或缩写等于字符串第二部分的单位。
接下来,我们需要编写一些函数来返回有关数量的信息。让我们通过在您的interface.py模块的末尾添加以下代码来实现这些函数:
请注意,用户也可以通过直接访问Quantity对象内的属性来检索这两个值,例如print(q.value)。我们无法阻止用户这样做,但是因为我们没有将其实现为面向对象的包,所以我们不想鼓励这样做。
我们已经快完成了。我们的下一个函数将把一个单位转换为另一个单位,如果转换不可能则返回ValueError。以下是执行此操作所需的代码:
defconvert(q,units):global_localesrc_kind,src_units=find_unit(q.units,_locale)dst_kind,dst_units=find_unit(units,_locale)ifsrc_kind==None:raiseValueError("Unknownunits:{}".format(q.units))ifdst_kind==None:raiseValueError("Unknownunits:{}".format(units))ifsrc_kind!=dst_kind:raiseValueError("It'simpossibletoconvert{}into{}!".format(localize(src_units['plural'],_locale),localize(dst_units['plural'],_locale)))num_units=(q.value*src_units['num_units']/dst_units['num_units'])returnQuantity(num_units,localize(dst_units['name'],_locale))我们需要实现的最后两个函数返回我们支持的不同单位种类的列表和给定种类的各个单位的列表。以下是我们interface.py模块的最后两个函数:
defsupported_kinds():returnlist(UNITS.keys())defsupported_units(kind):global_localeunits=[]forunitinUNITS.get(kind,[]):units.append(localize(unit['name'],_locale))returnunits现在我们已经完成了interface.py模块的实现,只剩下最后一件事要做:为我们的quantities包创建包初始化文件__init__.py,并将以下内容输入到此文件中:
from.interfaceimport*这使得我们在interface.py模块中定义的所有函数都可以供我们包的用户使用。
现在我们已经编写了代码(或者下载了代码),让我们来看看这个包是如何工作的。在终端窗口中,将当前目录设置为包含您的quantities包目录的文件夹,并键入python以启动Python解释器。然后,输入以下内容:
>>>importquantities如果您在输入源代码时没有犯任何错误,解释器应该会在没有任何错误的情况下返回。如果您有任何拼写错误,您需要在继续之前先修复它们。
接下来,我们必须通过提供我们想要使用的区域设置来初始化我们的quantities包:
>>>quantities.init("international")如果你在美国,可以随意将值international替换为us,这样你就可以获得本地化的拼写和单位。
让我们创建一个简单的数量,然后要求Python解释器显示它:
>>>q=quantities.new(24,"km")>>>>print(q)24kilometre正如你所看到的,国际拼写单词kilometer会自动使用。
让我们尝试将这个单位转换成英寸:
>>>print(quantities.convert(q,"inch"))944881.8897637795inch还有其他函数我们还没有测试,但我们已经可以看到我们的quantities包解决了一个非常普遍的问题,符合Python风格指南,并且易于使用。它还不是一个完全理想的可重用模块,但已经很接近了。以下是我们可以做的一些事情来改进它:
如果你愿意,可以随意扩展quantities包并提交它;这只是本书的一个例子,但它确实有潜力成为一个通用(和流行的)可重用的Python包。
在本章中,我们讨论了可重用模块或包的概念。我们看到可重用的包和模块如何用于与其他人共享代码。我们了解到,可重用的模块或包需要作为一个独立的单元进行操作,最好使用相对导入,并应注意它可能具有的任何外部依赖关系。理想情况下,可重用的包或模块还将解决一个通用问题而不是特定问题,遵循标准的Python编码约定,并具有良好的文档。然后,我们看了一些好的可重用模块的例子,然后编写了我们自己的模块。
在下一章中,我们将看一些更高级的内容,涉及在Python中使用模块和包的工作。
在本章中,我们将研究一些更高级的模块和包的工作技术。特别是,我们将:
尝试打开Python交互解释器并输入以下命令:
importnonexistent_module解释器将返回以下错误消息:
ImportError:Nomodulenamed'nonexistent_module'这对你来说不应该是个惊喜;如果在import语句中打错字,甚至可能在你自己的程序中看到这个错误。
这个错误的有趣之处在于它不仅适用于你打错字的情况。你也可以用它来测试这台计算机上是否有某个模块或包,例如:
try:importnumpyhas_numpy=TrueexceptImportError:has_numpy=False然后可以使用这个来让你的程序利用模块(如果存在),或者如果模块或包不可用,则执行其他操作,就像这样:
ifhas_numpy:array=numpy.zeros((num_rows,num_cols),dtype=numpy.int32)else:array=[]forrowinnum_rows:array.append([])在这个例子中,我们检查numpy库是否已安装,如果是,则使用numpy.zeros()创建一个二维数组。否则,我们使用一个列表的列表。这样,你的程序可以利用NumPy库的速度(如果已安装),同时如果这个库不可用,仍然可以工作(尽管速度较慢)。
请注意,这个例子只是虚构的;你可能无法直接使用一个列表的列表而不是NumPy数组,并且在不做任何更改的情况下使你的程序的其余部分工作。但是,如果模块存在,则执行一项操作,如果不存在,则执行另一项操作的概念是相同的。
像这样使用可选导入是一个很好的方法,让你的模块或包利用其他库,同时如果它们没有安装也可以工作。当然,你应该在包的文档中始终提到这些可选导入,这样你的用户就会知道如果这些可选模块或包被安装会发生什么。
在第三章中,使用模块和包,我们介绍了全局命名空间的概念,并展示了import语句如何将导入的模块或包的名称添加到全局命名空间。这个描述实际上是一个轻微的过度简化。事实上,import语句将导入的模块或包添加到当前命名空间,这可能是全局命名空间,也可能不是。
在Python中,有两个命名空间:全局命名空间和本地命名空间。全局命名空间是存储源文件中所有顶层定义的地方。例如,考虑以下Python模块:
importrandomimportstringdefset_length(length):global_length_length=lengthdefmake_name():global_lengthletters=[]foriinrange(length):letters.append(random.choice(string.letters))return"".join(letters)当你导入这个Python模块时,你将向全局命名空间添加四个条目:random、string、set_length和make_name。
Python解释器还会自动向全局命名空间添加几个其他条目。我们现在先忽略这些。
如果你然后调用set_length()函数,这个函数顶部的global语句将向模块的全局命名空间添加另一个条目,名为_length。make_name()函数也包括一个global语句,允许它在生成随机名称时引用全局_length值。
到目前为止一切都很好。可能不那么明显的是,在每个函数内部,还有一个称为本地命名空间的第二个命名空间,其中包含所有不是全局的变量和其他定义。在make_name()函数中,letters列表以及for语句使用的变量i都是本地变量——它们只存在于本地命名空间中,当函数退出时它们的值就会丢失。
本地命名空间不仅用于本地变量:你也可以用它来进行本地导入。例如,考虑以下函数:
defdelete_backups(dir):importosimportos.pathforfilenameinos.listdir(dir):iffilename.endswith(".bak"):remove(os.path.join(dir,filename))注意os和os.path模块是在函数内部导入的,而不是在模块或其他源文件的顶部。因为这些模块是在函数内部导入的,所以os和os.path名称被添加到本地命名空间而不是全局命名空间。
在大多数情况下,你应该避免使用本地导入:将所有的import语句放在源文件的顶部(使所有的导入语句都是全局的)可以更容易地一眼看出你的源文件依赖于哪些模块。然而,有两种情况下本地导入可能会有用:
作为一般规则,你应该坚持使用全局导入,尽管在特殊情况下,本地导入也可以非常有用。
当你使用import命令时,Python解释器必须搜索你想要导入的模块或包。它通过查找模块搜索路径来实现,这是一个包含各种目录的列表,模块或包可以在其中找到。模块搜索路径存储在sys.path中,Python解释器将依次检查此列表中的目录,直到找到所需的模块或包。
当Python解释器启动时,它会使用以下目录初始化模块搜索路径:
site-packages目录用于保存各种第三方模块和包。例如,如果你使用Python包管理器pip来安装Python模块或包,那么该模块或包通常会放在site-packages目录中。
这些目录在sys.path中出现的顺序很重要,因为一旦找到所需名称的模块或包,搜索就会停止。
如果你愿意,你可以打印出你的模块搜索路径的内容,尽管列表可能会很长,而且很难理解,因为通常有许多包含Python标准库各个部分的目录,以及任何你可能安装的第三方包使用的其他目录:
>>>importsys>>>print(sys.path)['','/usr/local/lib/python3.3/site-packages','/Library/Frameworks/SQLite3.framework/Versions/B/Python/3.3','/Library/Python/3.3/site-packages/numpy-override','/Library/Python/3.3/site-packages/pip-1.5.6-py3.3.egg','/usr/local/lib/python3.3.zip','/usr/local/lib/python3.3','/usr/local/lib/python3.3/plat-darwin','/usr/local/lib/python3.3/lib-dynload','/Library/Frameworks/Python.framework/Versions/3.3/lib/python3.3','/Library/Frameworks/Python.framework/Versions/3.3/lib/python3.3/plat-darwin']重要的是要记住,这个列表是按顺序搜索的,直到找到匹配项为止。一旦找到具有所需名称的模块或包,搜索就会停止。
现在,sys.path不仅仅是一个只读列表。如果您更改此列表,例如通过添加新目录,您将更改Python解释器搜索模块的位置。
实际上,有一些模块是内置到Python解释器中的;这些模块总是直接导入,忽略模块搜索路径。要查看已内置到您的Python解释器中的模块,可以执行以下命令:
importsysprint(sys.builtin_module_names)如果尝试导入这些模块之一,无论您对模块搜索路径做了什么,始终会使用内置版本。
虽然您可以对sys.path进行任何更改,例如删除或重新排列此列表的内容,但最常见的用法是向列表添加条目。例如,您可能希望将您创建的各种模块和包存储在一个特殊的目录中,然后可以从任何需要它的Python程序中访问。例如,假设您在/usr/local/shared-python-libs目录中有一个包含您编写的几个模块和包的目录,您希望在多个不同的Python程序中使用。在该目录中,假设您有一个名为utils.py的模块和一个名为approxnums的包,您希望在程序中使用。虽然简单的importutils会导致ImportError,但您可以通过以下方式使shared-python-libs目录的内容可用于程序:
importsyssys.path.append("/usr/local/shared-python-libs")importutils,approxnums提示您可能想知道为什么不能只将共享模块和包存储在site-packages目录中。这有两个原因:首先,因为site-packages目录通常受保护,只有管理员才能写入,这使得在该目录中创建和修改文件变得困难。第二个原因是,您可能希望将自己的共享模块与您安装的其他第三方模块分开。
在前面的例子中,我们通过将我们的shared-python-libs目录附加到此列表的末尾来修改了sys.path。虽然这样做有效,但要记住,模块搜索路径是按顺序搜索的。如果在模块搜索路径上的任何目录中有任何其他模块命名为utils.py,那么该模块将被导入,而不是您的shared-python-libs目录中的模块。因此,与其附加,您通常会以以下方式修改sys.path:
sys.path.insert(1,"/usr/local/shared-python-libs")请注意,我们使用的是insert(1,...)而不是insert(0,...)。这会将新目录添加为sys.path中的第二个条目。由于模块搜索路径中的第一个条目通常是包含当前执行脚本的目录,将新目录添加为第二个条目意味着程序的目录将首先被搜索。这有助于避免混淆的错误,其中您在程序目录中定义了一个模块,却发现导入了一个同名的不同模块。因此,当向sys.path添加目录时,使用insert(1,...)是一个良好的做法。
请注意,与任何其他技术一样,修改sys.path可能会被滥用。如果您的可重用模块或包修改了sys.path,您的代码用户可能会因为您更改了模块搜索路径而困惑,从而出现微妙的错误。一般规则是,您应该只在主程序中而不是在可重用模块中更改模块搜索路径,并始终清楚地记录您所做的工作,以免出现意外。
假设您正在编写一个使用Python标准库的程序。例如,您可能会使用random模块来执行以下操作:
importrandomprint(random.choice(["yes","no"]))您的程序一直正常工作,直到您决定主脚本中有太多数学函数,因此对其进行重构,将这些函数移动到一个单独的模块中。您决定将此模块命名为math.py,并将其存储在主程序的目录中。一旦这样做,之前的代码将会崩溃,并显示以下错误:
Traceback(mostrecentcalllast):**File"main.py",line5,in
要理解这里发生了什么,您需要记住,默认情况下,模块搜索路径包括当前程序目录作为第一个条目——在指向Python标准库各个部分的其他条目之前。通过在程序中创建一个名为math.py的新模块,您已经使得Python解释器无法从Python标准库加载math.py模块。这不仅适用于您编写的代码,还适用于模块搜索路径上的任何模块或包,它们可能尝试从Python标准库加载此模块。在这个例子中,失败的是random模块,但它可能是任何依赖于math库的模块。
这被称为名称屏蔽,是一个特别阴险的问题。为了避免这种情况,您在选择程序中顶层模块和包的名称时,应该始终小心,以确保它们不会屏蔽Python标准库中的模块,无论您是否使用该模块。
避免名称屏蔽的一种简单方法是利用包来组织您在程序中编写的模块和包。例如,您可以创建一个名为lib的顶层包,并在lib包内创建各种模块和包。由于Python标准库中没有名为lib的模块或包,因此无论您为lib包内的模块和包选择什么名称,都不会有屏蔽标准库模块的风险。
importrepattern=input("RegularExpression:")s=input("String:")results=re.search(pattern,s)print(results.group(),results.span())这个程序可能会帮助您弄清楚re模块的作用,但如果您将此脚本保存为re.py,当运行程序时会出现一个神秘的错误:
$pythonre.pyRegularExpression:[0-9]+String:test123abcTraceback(mostrecentcalllast):...File"./re.py",line9,in
让脚本导入自身作为模块也可能导致意外问题;我们马上就会看到这一点。
这个问题的解决方法很简单:永远不要使用Python标准库模块的名称作为脚本的名称。而是将你的测试脚本命名为类似re_test.py的东西。
一个常见的陷阱是将包目录添加到sys.path。让我们看看当你这样做时会发生什么。
创建一个目录来保存一个测试程序,并在这个主目录中创建一个名为package的子目录。然后,在package目录中创建一个空的包初始化(__init__.py)文件。同时,在同一个目录中创建一个名为module.py的模块。然后,将以下内容添加到module.py文件中:
print("###Initializingmodule.py###")当导入模块时,这会打印出一条消息。接下来,在你的最顶层目录中创建一个名为good_imports.py的Python源文件,并输入以下Python代码到这个文件中:
print("Callingimportpackage.module...")importpackage.moduleprint("Callingimportpackage.moduleasmodule...")importpackage.moduleasmoduleprint("Callingfrompackageimportmodule...")frompackageimportmodule保存这个文件后,打开一个终端或命令行窗口,并使用cd命令将当前目录设置为你最外层的目录(包含你的good_imports.py脚本的目录),然后输入pythongood_imports.py来运行这个程序。你应该会看到以下输出:
$pythongood_imports.pyCallingimportpackage.module...###Initializingmodule.py###Callingimportpackage.moduleasmodule...Callingfrompackageimportmodule...正如你所看到的,第一个import语句加载了模块,导致打印出###Initializingmodule.py###的消息。对于后续的import语句,不会发生初始化——相反,已经导入的模块副本会被使用。这是我们想要的行为,因为它确保我们只有一个模块的副本。这对于那些在全局变量中保存信息的模块非常重要,因为拥有不同副本的模块,其全局变量中的值不同,可能会导致各种奇怪和令人困惑的行为。
不幸的是,如果我们将一个包或包的子目录添加到sys.path中,我们可能会得到这样的结果。要看到这个问题的实际情况,创建一个名为bad_imports.py的新顶级脚本,并输入以下内容到这个文件中:
importos.pathimportsyscur_dir=os.path.abspath(os.path.dirname(__file__))package_dir=os.path.join(cur_dir,"package")sys.path.insert(1,package_dir)print("Callingimportpackage.moduleasmodule...")importpackage.moduleasmoduleprint("Callingimportmodule...")importmodule这个程序将package_dir设置为package目录的完整目录路径,然后将这个目录添加到sys.path中。然后,它进行了两个单独的import语句,一个是从名为package的包中导入module,另一个是直接导入module。这两个import语句都可以工作,因为模块可以以这两种方式访问。然而,结果并不是你可能期望的:
$pythonbad_imports.pyCallingimportpackage.moduleasmodule...###Initializingmodule.py###Callingimportmodule...###Initializingmodule.py###正如你所看到的,模块被导入了两次,一次是作为package.module,另一次是作为module。你最终会得到两个独立的模块副本,它们都被初始化,并作为两个不同的模块出现在Python系统中。
拥有两个模块副本可能会导致各种微妙的错误和问题。这就是为什么你永远不应该直接将Python包或Python包的子目录添加到sys.path中。
当然,将包含包的目录添加到sys.path是可以的;只是不要添加包目录本身。
另一个更微妙的双重导入问题的例子是,如果您执行一个Python源文件,然后导入同一个文件,就好像它是一个模块一样。要了解这是如何工作的,请创建一个目录来保存一个新的示例程序,并在该目录中创建一个名为test.py的新的Python源文件。然后,输入以下内容到这个文件中:
importhelpersdefdo_something(n):returnn*2if__name__=="__main__":helpers.run_test()当这个文件作为脚本运行时,它调用helpers.run_test()函数来开始运行一个测试。这个文件还定义了一个函数do_something(),执行一些有用的功能。现在,在同一个目录中创建第二个名为helpers.py的Python源文件,并输入以下内容到这个文件中:
importtestdefrun_test():print(test.do_something(10))正如你所看到的,helpers.py模块正在将test.py作为模块导入,然后调用do_something()函数作为运行测试的一部分。换句话说,即使test.py作为脚本执行,它也会作为模块被导入(间接地)作为该脚本的执行的一部分。
让我们看看当你运行这个程序时会发生什么:
$pythontest.py20到目前为止一切顺利。程序正在运行,尽管模块导入复杂,但似乎工作正常。但让我们更仔细地看一下;在你的test.py脚本顶部添加以下语句:
print("Initializingtest.py")就像我们之前的例子一样,我们使用print()语句来显示模块何时被加载。这给了模块初始化的机会,我们期望只看到初始化发生一次,因为内存中应该只有每个模块的一个副本。
然而,在这种情况下,情况并非如此。尝试再次运行程序:
$pythontest.pyInitializingtest.pyInitializingtest.py20正如你所看到的,模块被初始化了两次——一次是当它作为脚本运行时,另一次是当helpers.py导入该模块时。
为了避免这个问题,请确保你编写的任何脚本只用作脚本。将任何其他代码(例如我们之前示例中的do_something()函数)从你的脚本中移除,这样你就永远不需要导入它们。
请注意,这并不意味着你不能有变色龙模块,既可以作为模块又可以作为脚本,正如第三章中所描述的那样,使用模块和包。只是要小心,你执行的脚本只使用模块本身定义的函数。如果你开始从同一个包中导入其他模块,你可能应该将所有功能移动到一个不同的模块中,然后将其导入到你的脚本中,而不是让它们都在同一个文件中。
除了从Python脚本中调用模块和包,直接从Python交互解释器中调用它们通常也很有用。这是使用Python编程的快速应用开发(RAD)技术的一个很好的方法:你对Python模块或包进行某种更改,然后立即通过从Python交互解释器调用该模块或包来看到你的更改的结果。
然而,还有一些限制和问题需要注意。让我们更仔细地看看你如何使用交互解释器来加快模块和包的开发;我们也会看到不同的方法可能更适合你。
首先创建一个名为stringutils.py的新Python模块,并将以下代码输入到这个文件中:
importredefextract_numbers(s):pattern=r'[+-]\d+(:\.\d+)'numbers=[]formatchinre.finditer(pattern,s):number=s[match.start:match.end+1]numbers.append(number)returnnumbers这个模块代表我们第一次尝试编写一个从字符串中提取所有数字的函数。请注意,它还没有工作——如果你尝试使用它,extract_numbers()函数将崩溃。它也不是特别高效(一个更简单的方法是使用re.findall()函数)。但我们故意使用这段代码来展示你如何将快速应用开发技术应用到你的Python模块中,所以请耐心等待。
这个函数使用re(正则表达式)模块来找到与给定表达式模式匹配的字符串部分。复杂的pattern字符串用于匹配数字,包括可选的+或-在前面,任意数量的数字,以及可选的小数部分在末尾。
使用re.finditer()函数,我们找到与我们的正则表达式模式匹配的字符串部分。然后提取字符串的每个匹配部分,并将结果附加到numbers列表中,然后将其返回给调用者。
这就是我们的函数应该做的事情。让我们来测试一下。
打开一个终端或命令行窗口,并使用cd命令切换到包含stringutils.py模块的目录。然后,输入python启动Python交互解释器。当Python命令提示符出现时,尝试输入以下内容:
>>>importstringutils>>>print(stringutils.extract_numbers("Tes1t123.543-10.65"))Traceback(mostrecentcalllast):**File"
number=s[match.start:match.end+1]错误消息表明您正在尝试将内置函数(在本例中为match.end)添加到一个数字(1),这当然是行不通的。match.start和match.end值应该是字符串的开始和结束的索引,但是快速查看re模块的文档显示match.start和match.end是函数,而不是简单的数字,因此我们需要调用这些函数来获取我们想要的值。这样做很容易;只需编辑您的文件的第7行,使其看起来像下面这样:
number=s[match.start():match.end()+1]现在我们已经更改了我们的模块,让我们看看会发生什么。我们将从重新执行print()语句开始,看看是否有效:
>>>print(stringutils.extract_numbers("Tes1t123.543-10.65"))提示您知道您可以按键盘上的上箭头和下箭头键来浏览您之前在Python交互解释器中键入的命令历史记录吗?这样可以避免您不得不重新键入命令;只需使用箭头键选择您想要的命令,然后按Return执行它。
您将立即看到与之前看到的相同的错误消息-没有任何变化。这是因为您将模块导入Python解释器;一旦导入了模块或包,它就会保存在内存中,磁盘上的源文件将被忽略。
为了使您的更改生效,您需要重新加载模块。要做到这一点,请在Python解释器中键入以下内容:
importimportlibimportlib.reload(stringutils)提示如果您使用的是Python2.x,则无法使用importlib模块。相反,只需键入reload(stringutils)。如果您使用的是Python3.3版本,则使用imp而不是importlib。
现在尝试重新执行print()语句:
>>>stringutils.extract_numbers("Hell1o123.543-10.65there")['1o','123.543','-10.6','5']这好多了-我们的程序现在可以正常运行了。然而,我们还需要解决一个问题:当我们提取组成数字的字符时,我们提取了一个多余的字符,所以数字1被返回为1o等等。要解决这个问题,请从源文件的第7行中删除+1:
number=s[match.start():match.end()]然后,再次重新加载模块并重新执行您的print()语句。您应该会看到以下内容:
['1','123.543','-10.6','5']完美!如果您愿意,您可以使用float()函数将这些字符串转换为浮点数,但对于我们的目的,这个模块现在已经完成了。
让我们退一步,回顾一下我们所做的事情。我们有一个有错误的模块,并使用Python交互解释器来帮助识别和修复这些问题。我们反复测试我们的程序,注意到一个错误,并修复它,使用RAD方法快速找到和纠正我们模块中的错误。
在开发模块和包时,通常有助于在交互解释器中进行测试,以便在进行过程中找到并解决问题。您只需记住,每次对Python源文件进行更改时,您都需要调用importlib.reload()来重新加载受影响的模块或包。
以这种方式使用Python交互解释器也意味着您可以使用完整的Python系统进行测试。例如,您可以使用Python标准库中的pprint模块来漂亮地打印复杂的字典或列表,以便您可以轻松地查看一个函数返回的信息。
然而,在importlib.reload()过程中存在一些限制:
customers=[]customers.append("MikeWallis")cusotmers.append("JohnSmith")这个模块将被导入,但由于变量名拼写错误,它将在初始化期间引发异常。如果发生这种情况,您首先需要在Python交互解释器中使用import命令使模块可用,然后使用imp.reload()来加载更新后的源代码。
因此,最好使用交互式解释器来修复特定问题或帮助您快速开发特定的小代码片段。当测试变得复杂或者需要与多个模块一起工作时,自定义编写的脚本效果更好。
我们已经看到如何使用全局变量在模块内的不同函数之间共享信息。我们已经看到如何在模块内将全局变量定义为顶级变量,导致它们在导入模块时首次初始化,并且我们还看到如何在函数内使用global语句允许该函数访问和更改全局变量的值。
在本节中,我们将进一步学习如何在模块之间共享全局变量。在创建包时,通常需要定义可以被该包内任何模块访问或更改的变量。有时,还需要将变量提供给包外的Python代码。让我们看看如何实现这一点。
创建一个名为globtest的新目录,并在此目录中创建一个空的包初始化文件,使其成为Python包。然后,在此目录中创建一个名为globals.py的文件,并输入以下内容到此文件中:
language=Nonecurrency=None在这个模块中,我们已经定义了两个全局变量,我们希望在我们的包中使用,并为每个变量设置了默认值None。现在让我们在另一个模块中使用这些全局变量。
在globtest目录中创建另一个名为test.py的文件,并输入以下内容到此文件中:
from.importglobalsdeftest():globals.language="EN"globals.currency="USD"print(globals.language,globals.currency)要测试您的程序,请打开终端或命令行窗口,使用cd命令移动到包含您的globtest包的目录,并输入python启动Python交互解释器。然后,尝试输入以下内容:
>>>****fromglobtestimporttest>>>test.test()ENUSD如您所见,我们已成功设置了存储在我们的globals模块中的language和currency全局变量的值,然后再次检索这些值以打印它们。因为我们将这些全局变量存储在一个单独的模块中,所以您可以在当前包内的任何地方或者甚至在导入您的包的其他代码中检索或更改这些全局变量。使用单独的模块来保存包的全局变量是管理包内全局变量的一种绝佳方式。
然而,需要注意一点:要使全局变量在模块之间共享,必须导入包含该全局变量的模块,而不是变量本身。例如,以下内容不起作用:
随着您开发更复杂的模块和包,通常会发现您的代码在使用之前需要以某种方式配置。例如,想象一下,您正在编写一个使用数据库的包。为了做到这一点,您的包需要知道要使用的数据库引擎,数据库的名称,以及用于访问该数据库的用户名和密码。
你可以将这些信息硬编码到程序的源代码中,但这样做是一个非常糟糕的主意,有两个原因:
这些数据库访问凭据是包配置的一个例子——在你的包运行之前需要的信息,但你不希望将其构建到包的源代码中。
如果你正在构建一个应用程序而不是一个独立的模块或包,那么你的配置任务就简单得多了。Python标准库中有一些模块可以帮助配置,例如configparser、shlex和json。使用这些模块,你可以将配置设置存储在磁盘上的文件中,用户可以编辑。当你的程序启动时,你将这些设置加载到内存中,并根据需要访问它们。因为配置设置是存储在应用程序外部的,用户不需要编辑你的源代码来配置程序,如果你的源代码被发布或共享,你也不会暴露敏感信息。
然而,当编写模块和包时,基于文件的配置方法就不那么方便了。没有明显的地方来存储包的配置文件,要求配置文件位于特定位置会使你的模块或包更难以作为不同程序的一部分进行重用。
相反,模块或包的配置通常是通过向模块或包的初始化函数提供参数来完成的。我们在上一章中看到了一个例子,在那里quantities包在初始化时需要你提供一个locale值:
quantities.init("us")这将配置的工作交给了周围的应用程序;应用程序可以利用配置文件或任何其他喜欢的配置方案,并且是应用程序在包初始化时提供包的配置设置:
这对包开发者来说更加方便,因为包所需要做的就是记住它所得到的设置。
虽然quantities包只使用了一个配置设置(区域的名称),但是包通常会使用许多设置。为包提供配置设置的一个非常方便的方式是使用Python字典。例如:
mypackage.init({'log_errors':True,'db_password':"test123",...})使用字典这种方式可以很容易地支持包的配置设置的默认值。以下Python代码片段展示了一个包的init()函数如何接受配置设置,提供默认值,并将设置存储在全局变量中,以便在需要时可以访问:
definit(settings):globalconfigconfig={}config['log_errors']=settings.get("log_errors",False)config['db_password']=settings.get("db_password","")...使用dict.get()这种方式,如果已经提供了设置,你就可以检索到该设置,同时提供一个默认值以供在未指定设置时使用。这是处理Python模块或包中配置的理想方式,使得模块或包的用户可以根据需要配置它,同时仍然将配置设置的存储方式和位置的细节留给应用程序。
软件包可能包含的不仅仅是Python源文件。有时,您可能还需要包含其他类型的文件。例如,一个软件包可能包括一个或多个图像文件,一个包含美国所有邮政编码列表的大型文本文件,或者您可能需要的任何其他类型的数据。如果您可以将某些东西存储在文件中,那么您可以将此文件包含为Python软件包的一部分。
通常,您会将软件包数据放在软件包目录中的一个单独的子目录中。要访问这些文件,您的软件包需要知道在哪里找到这个子目录。虽然您可以将该目录的位置硬编码到您的软件包中,但如果您的软件包要被重用或移动,这种方法将行不通。这也是不必要的,因为您可以使用以下代码轻松找到模块所在的目录:
cur_dir=os.path.abspath(os.path.dirname(__file__))这将为您提供包含当前模块的完整路径。使用os.path.join()函数,然后可以访问包含数据文件的子目录,并以通常的方式打开它们:
接下来,我们将看看如何使用Python交互式解释器作为一种快速应用程序开发(RAD)工具,快速查找和修复模块和软件包中的问题,以及importlib.reload()命令允许您在更改底层源代码后重新加载模块
我们通过学习如何定义在整个软件包中使用的全局变量,如何处理软件包配置以及如何在软件包中存储和访问数据文件来完成了对高级模块技术的调查。
在本章中,我们将进一步探讨共享模块的概念。在您共享模块或包之前,您需要对其进行测试,以确保其正常工作。您还需要准备您的代码并了解如何部署它。为了学习这些内容,我们将涵盖以下主题:
测试是编程的正常部分:您测试代码以验证其是否正常工作并识别任何错误或其他问题,然后您可以修复。然后,您继续测试,直到您满意您的代码正常工作为止。
然而,程序员经常只进行临时测试:他们启动Python交互解释器,导入他们的模块或包,并进行各种调用以查看发生了什么。在上一章中,我们使用importlib.reload()函数进行了一种临时测试形式,以支持您的代码的RAD开发。
临时测试很有用,但并不是唯一的测试形式。如果您与他人共享您的模块和包,您将希望您的代码没有错误,并临时测试无法保证这一点。一个更好和更系统的方法是为您的模块或包创建一系列单元测试。单元测试是Python代码片段,用于测试代码的各个方面。由于测试是由Python程序完成的,因此您可以在需要测试代码时运行程序,并确保每次运行测试时都会测试所有内容。单元测试是确保在进行更改时错误不会进入您的代码的绝佳方法,并且您可以在需要共享代码时运行它们,以确保其正常工作。
以下是一个非常简单的单元测试示例:
importmathassertmath.floor(2.6197)==2assert语句检查其后的表达式。如果此表达式不计算为True,则会引发AssertionError。这使您可以轻松检查给定函数是否返回您期望的结果;在此示例中,我们正在检查math.floor()函数是否正确返回小于或等于给定浮点数的最大整数。
因为模块或包最终只是一组Python函数(或方法,它们只是分组到类中的函数),因此很可能编写一系列调用您的函数并检查返回值是否符合预期的assert语句。
当然,这是一个简化:通常调用一个函数的结果会影响另一个函数的输出,并且您的函数有时可以执行诸如与远程API通信或将数据存储到磁盘文件中等相当复杂的操作。然而,在许多情况下,您仍然可以使用一系列assert语句来验证您的模块和包是否按您的预期工作。
虽然您可以将您的assert语句放入Python脚本中并运行它们,但更好的方法是使用Python标准库中的unittest模块。该模块允许您将单元测试分组为测试用例,在运行测试之前和之后运行额外的代码,并访问各种不同类型的assert语句,以使您的测试更加容易。
让我们看看如何使用unittest模块为我们在第六章中实现的quantities包实施一系列单元测试。将此包的副本放入一个方便的目录中,并在同一目录中创建一个名为test_quantities.py的新的Python源文件。然后,将以下代码添加到此文件中:
importunittestimportquantitiesclassTestQuantities(unittest.TestCase):defsetUp(self):quantities.init("us")deftest_new(self):q=quantities.new(12,"km")self.assertEqual(quantities.value(q),12)self.assertEqual(quantities.units(q),"kilometer")deftest_convert(self):q1=quantities.new(12,"km")q2=quantities.convert(q1,"m")self.assertEqual(quantities.value(q2),12000)self.assertEqual(quantities.units(q2),"meter")if__name__=="__main__":unittest.main()提示请记住,您不需要手动输入此程序。所有这些源文件,包括quantities包的完整副本,都作为本章的示例代码的一部分可供下载。
然后,我们定义了两个单元测试,我们称之为test_new()和test_convert()。它们分别测试quantities.new()和quantities.convert()函数。您通常会为需要测试的每个功能单独创建一个单元测试。您可以随意命名您的单元测试,只要方法名以test开头即可。
在我们的test_new()单元测试中,我们创建一个新的数量,然后调用self.assertEqual()方法来确保已创建预期的数量。正如您所见,我们不仅仅局限于使用内置的assert语句;您可以调用几十种不同的assertXXX()方法来以各种方式测试您的代码。如果断言失败,所有这些方法都会引发AssertionError。
我们测试脚本的最后部分在脚本执行时调用unittest.main()。这个函数会查找您定义的任何unittest.TestCase子类,并依次运行每个测试用例。对于每个测试用例,如果存在,将调用setUp()方法,然后调用您定义的各种testXXX()方法,最后,如果存在,将调用teardown()方法。
让我们尝试运行我们的单元测试。打开一个终端或命令行窗口,使用cd命令将当前目录设置为包含您的test_quantities.py脚本的目录,并尝试输入以下内容:
pythontest_quantities.py一切顺利的话,您应该会看到以下输出:
..---------------------------------------------------------------Ran2testsin0.000sOK默认情况下,unittest模块不会显示有关已运行的测试的详细信息,除了它已经无问题地运行了您的单元测试。如果您需要更多细节,您可以增加测试的详细程度,例如通过在测试脚本中的unittest.main()语句中添加参数:
unittest.main(verbosity=2)或者,您可以使用-v命令行选项来实现相同的结果:
pythontest_quantities.py-v设计您的单元测试单元测试的目的是检查您的代码是否正常工作。一个很好的经验法则是为包中的每个公共可访问模块单独编写一个测试用例,并为该模块提供的每个功能单独编写一个单元测试。单元测试代码应该至少测试功能的通常操作,以确保其正常工作。如果需要,您还可以选择在单元测试中编写额外的测试代码,甚至额外的单元测试,以检查代码中特定的边缘情况。
举个具体的例子,在我们在前一节中编写的test_convert()方法中,您可能希望添加代码来检查如果用户尝试将距离转换为重量,则是否会引发适当的异常。例如:
q=quantities.new(12,"km")withself.assertRaises(ValueError):quantities.convert(q,"kg")问题是:您应该为多少边缘情况进行测试?有数百种不同的方式可以使用您的模块不正确。您应该为这些每一种编写单元测试吗?
一般来说,不值得尝试测试每种可能的边缘情况。当然,您可能希望测试一些主要可能性,只是为了确保您的模块能够处理最明显的错误,但除此之外,编写额外的测试可能不值得努力。
覆盖率是您的单元测试测试了您的代码多少的度量。要理解这是如何工作的,请考虑以下Python函数:
[1]defcalc_score(x,y):[2]ifx==1:[3]score=y*10[4]elifx==2:[5]score=25+y[6]else:[7]score=y[8][9]returnscore注意我们已经在每一行的开头添加了行号,以帮助我们计算代码覆盖率。
现在,假设我们为我们的calc_score()函数创建以下单元测试代码:
assertcalc_score(1,5)==50assertcalc_score(2,10)==35我们的单元测试覆盖了calc_score()函数的多少?我们的第一个assert语句调用calc_score(),x为1,y为5。如果您按照行号,您会发现使用这组参数调用此函数将导致执行第1、2、3和9行。类似地,第二个assert语句调用calc_score(),x为2,y为10,导致执行第1、4、5和9行。
总的来说,这两个assert语句导致执行第1、2、3、4、5和9行。忽略空行,我们的测试没有包括第6和第7行。因此,我们的单元测试覆盖了函数中的八行中的六行,给我们一个代码覆盖率值为6/8=75%。
我们在这里看的是语句覆盖率。还有其他更复杂的衡量代码覆盖率的方法,我们在这里不会深入讨论。
代码覆盖的基本概念是,您希望您的测试覆盖所有您的代码。无论您是否使用诸如coverage之类的工具来衡量代码覆盖率,编写单元测试以尽可能包含接近100%的代码是一个好主意。
当我们考虑测试Python代码的想法时,值得提到测试驱动开发的概念。使用测试驱动开发,您首先选择您希望您的模块或包执行的操作,然后编写单元测试以确保模块或包按照您的期望工作—在您编写它之前。这样,单元测试充当了模块或包的一种规范;它们告诉您您的代码应该做什么,然后您的任务是编写代码以使其通过所有测试。
测试驱动开发可以是实现模块和包的有用方式。当然,您是否使用它取决于您,但是如果您有纪律写单元测试,测试驱动开发可以是确保您正确实现了代码的一个很好的方式,并且您的模块在代码增长和变化的过程中继续按照您的期望工作。
如果您的模块或包调用外部API或执行其他复杂、昂贵或耗时的操作,您可能希望在Python标准库中调查unittest.mock包。Mocking是用程序中的虚拟函数替换某些功能的过程,该虚拟函数立即返回适合测试的数据。
现在我们已经介绍了单元测试的概念,看了一下unittest标准库模块的工作原理,并研究了编写单元测试的一些更复杂但重要的方面,现在让我们看看单元测试如何可以用来辅助开发和测试您的模块和包。
首先,您应该至少为您的模块或包定义的主要函数编写单元测试。从测试最重要的函数开始,并为更明显的错误条件添加测试,以确保错误被正确处理。您可以随时为代码中更隐晦的部分添加额外的测试。
如果您为单个模块编写单元测试,您应该将测试代码放在一个单独的Python脚本中,例如命名为tests.py,并将其放在与您的模块相同的目录中。下面的图片展示了在编写单个模块时组织代码的好方法:
如果您在同一个目录中有多个模块,您可以将所有模块的单元测试合并到tests.py脚本中,或者将其重命名为类似test_my_module.py的名称,以明确测试的是哪个模块。
对于一个包,确保将tests.py脚本放在包所在的目录中,而不是包内部:
如果您将test.py脚本放在包目录中,当您的单元测试尝试导入包时,您可能会遇到问题。
您的tests.py脚本应该为包中每个公开可访问的模块定义一个unittest.TestCase对象,并且这些对象中的每一个都应该有一个testXXX()方法,用于定义模块中的每个函数或主要功能。
这样做可以通过执行以下命令简单地测试您的模块或包:
在第六章创建可重用模块中,我们看了一些使模块或包适合重用的东西:
我们还确定了三个有助于创建优秀可重用模块或包的东西:
准备您的模块或包以供发布的第一步是确保您至少遵循了这些准则中的前三条,最好是所有六条。
第二步是确保您至少编写了一些单元测试,并且您的模块或包通过了所有这些测试。最后,您需要决定如何发布您的代码。
在深入讨论GitHub的具体内容之前,让我们先看看源代码管理系统是如何工作的,以及为什么你可能想要使用它。
想象一下,你正在编写一个复杂的模块,并在文本编辑器中打开了你的模块进行一些更改。在进行这些更改的过程中,你不小心选择了100行代码,然后按下了删除键。在意识到自己做了什么之前,你保存并关闭了文件。太迟了:那100行文本已经消失了。
当然,你可能(并且希望)有一个备份系统,定期备份你的源文件。但如果你在过去几分钟内对一些丢失的代码进行了更改,那么你很可能已经丢失了这些更改。
你不仅仅局限于让一个人来工作在一个模块或包上。人们可以fork你的源代码仓库,创建他们自己的私人副本,然后使用这个私人副本来修复错误和添加新功能。一旦他们这样做了,他们可以向你发送一个pullrequest,其中包括他们所做的更改。然后你可以决定是否将这些更改合并到你的项目中。
不要太担心这些细节,源代码管理是一个复杂的话题,使用GitHub等工具可以执行许多复杂的技巧来管理源代码。要记住的重要事情是,创建一个存储库来保存模块或软件包的源代码的主要副本,将代码提交到这个存储库中,然后每次修复错误或添加新功能时都要继续提交。以下插图总结了这个过程:
源代码管理系统的诀窍是定期提交-每次添加新功能或修复错误时,您都应立即提交更改。这样,存储库中一个版本和下一个版本之间的差异只是添加了一个功能或修复了一个问题的代码。如果在提交之前对源代码进行了多次更改,存储库将变得不那么有用。
git--version一切顺利的话,您应该看到已安装的git命令行工具的版本号。
要设置存储库,请输入test-package作为存储库的名称,并从添加.gitignore下拉菜单中选择Python。.gitignore文件用于从存储库中排除某些文件;为Python使用.gitignore文件意味着Python创建的临时文件不会包含在存储库中。
最后,点击创建存储库按钮创建新存储库。
确保不要选择使用README初始化此存储库选项。您不希望在此阶段创建一个README文件;很快就会清楚原因。
现在GitHub上已经创建了存储库,我们的下一个任务是克隆该存储库的副本到您计算机的硬盘上。为此,创建一个名为test-package的新目录来保存存储库的本地副本,打开终端或命令行窗口,并使用cd命令移动到您的新test-package目录。然后,输入以下命令:
因为存储库目前是空的,您在目录中看不到任何内容。但是,有一些隐藏文件git用来跟踪您对存储库的本地副本。要查看这些隐藏文件,您可以从终端窗口使用ls命令:
$ls-aldrwxr-xr-x@7erikstaff23819Feb21:28.drwxr-xr-x@7erikstaff23819Feb14:35..drwxr-xr-x@14erikstaff47619Feb21:28.git-rw-r--r--@1erikstaff84419Feb15:09.gitignore.git目录包含有关您的新GitHub存储库的信息,而.gitignore文件包含您要求GitHub为您设置的忽略Python临时文件的指令。
现在我们有了一个(最初为空的)存储库,让我们在其中创建一些文件。我们需要做的第一件事是为我们的包选择一个唯一的名称。因为我们的包将被提交到Python包索引,所以名称必须是真正唯一的。为了实现这一点,我们将使用您的GitHub用户名作为我们包名称的基础,就像这样:
现在我们有了一个包的名称,让我们创建一个描述这个包的README文件。在您的test-package目录中创建一个名为README.rst的新文本文件,并将以下内容放入此文件中:
虽然GitHub可以支持reStructuredText,但默认情况下它使用一种名为Markdown的不同文本格式。Markdown和reStructuredText是两种竞争格式,不幸的是,PyPI需要reStructuredText,而GitHub默认使用Markdown。这就是为什么我们告诉GitHub在设置存储库时不要创建README文件的原因;如果我们这样做了,它将以错误的格式存在。
当用户在GitHub上查看您的存储库时,他们将看到此文件的内容按照reStructuredText规则整齐地格式化:
现在我们已经为我们的包设置了README文件,让我们创建包本身。在test-package内创建另一个名为
importstringimportrandomdefrandom_name():chars=[]foriinrange(random.randrange(3,10)):chars.append(random.choice(string.ascii_letters))return"".join(chars)defrun():foriinrange(10):print(random_name())这只是一个例子,当然。调用test.run()函数将导致显示十个随机名称。更有趣的是,我们现在已经为我们的测试包定义了初始内容。但是,我们所做的只是在我们的本地计算机上创建了一些文件;这并不会影响GitHub,如果您在GitHub中重新加载存储库页面,您的新文件将不会显示出来。
要使我们的更改生效,我们需要提交更改到存储库。我们将首先查看我们的本地副本与存储库中的副本有何不同。为此,请返回到您的终端窗口,cd进入test-package目录,并键入以下命令:
gitstatus您应该看到以下输出:
#Onbranchmaster#Untrackedfiles:#(use"gitadd
gitaddREADME.rstgitadd
#Onbranchmaster#Changestobecommitted:#(use"gitresetHEAD
现在我们已经包含了我们的新文件,让我们将更改提交到存储库。键入以下命令:
gitcommit-a-m'Initialcommit.'这将向您的存储库的本地副本提交一个新更改。-a选项告诉GitHub自动包括任何更改的文件,-m选项允许您输入一个简短的消息,描述您所做的更改。在这种情况下,我们的提交消息设置为值"Initialcommit."。
现在我们已经提交了更改,我们需要从本地计算机上传到GitHub存储库。为此,请键入以下命令:
gitpush您将被提示输入您的GitHub密码以进行身份验证,并且您提交的更改将存储到GitHub上的存储库中。
GitHub将commit命令与push命令分开,因为您可能需要在更改程序时进行多次提交,而不一定在线上。例如,如果您在长途飞行中,可以在本地工作,每次更改时进行提交,然后在降落并再次拥有互联网访问时一次性推送所有更改。
现在您的更改已推送到服务器,您可以在GitHub上重新加载页面,您新创建的软件包将出现在存储库中:
您还将看到您的README.rst文件的内容显示在文件列表下面,描述了您的新软件包及其使用方法。
每当您对软件包进行更改时,请确保按照以下步骤保存更改到存储库中:
当然,使用GitHub还有很多内容,还有许多命令和选项,一旦您开始使用,您无疑会想要探索,但这已经足够让您开始了。
为了使这个过程更加简单,并使您的软件包可以被更广泛的用户搜索到,您应该考虑将您的软件包提交到Python软件包索引。接下来我们将看看涉及到这样做的步骤。
您需要选择一个用户名和密码,并提供一个电子邮件地址。记住您输入的用户名和密码,因为您很快就会需要它。当您提交表单时,您将收到一封包含链接的电子邮件,您需要点击该链接以完成注册。
在将项目提交到PyPI之前,您需要添加两个文件,一个是setup.py脚本,用于打包和上传您的软件包,另一个是LICENSE.txt文件,用于描述您的软件包可以使用的许可证。现在让我们添加这两个文件。
在您的test-package目录中创建一个名为setup.py的文件,并输入以下内容:
请注意,此版本的setup.py脚本使用了Distutils软件包。Distutils是Python标准库的一部分,是创建和分发代码的简单方法。还有一个名为Setuptools的替代库,许多人更喜欢它,因为它是一个功能更多、更现代的库,并且通常被视为Distutils的继任者。但是,Setuptools目前不是Python标准库的一部分。由于它更容易使用并且具有我们需要的所有功能,我们在这里使用Distutils来尽可能简化这个过程。如果您熟悉使用它,请随时使用Setuptools而不是Distutils,因为对于我们在这里所做的事情,两者是相同的。
最后,我们需要创建一个名为LICENSE.txt的新文本文件。该文件将保存您发布软件包的软件许可证。包含许可证非常重要,以便人们准确知道他们可以和不能做什么,您不能提交一个没有提供许可证的软件包。
有了这两个文件,您最终可以将您的新软件包提交到Python软件包索引。要做到这一点,请在您的终端或命令行窗口中键入以下命令:
pythonsetup.pyregister此命令将尝试使用Python软件包索引注册您的新软件包。您将被要求输入您的PyPI用户名和密码,并有机会存储这些信息,以便您不必每次都重新输入。一旦软件包成功注册,您可以通过输入以下命令上传软件包内容:
pythonsetup.pysdistupload在将您的软件包上传到PyPI之前,您会看到一些警告,您可以安全地忽略这些警告。然后,您可以转到PyPI网站,您将看到您的新软件包已列出:
如你所见,HomePage链接指向你在GitHub上的项目页面,并且有一个直接下载链接,用于你的包的1.0版本。然而,不幸的是,这个下载链接还不起作用,因为你还没有告诉GitHub你的包的1.0版本是什么样子。为了做到这一点,你必须在GitHub中创建一个与你的系统版本1.0相对应的标签;GitHub将会创建一个与该标签匹配的可下载版本的你的包。
在创建1.0版本之前,你应该提交你对仓库所做的更改。这本来就是一个好习惯,所以让我们看看如何做:首先输入gitstatus,查看已添加或更改的文件,然后使用gitadd逐个添加每个未跟踪的文件。完成后,输入gitcommit-a-m'PreparingforPyPIsubmission'将你的更改提交到仓库。最后,输入gitpush将你提交的更改发送到GitHub。
完成所有这些后,你可以通过输入以下命令创建与你的包的1.0版本相对应的标签:
gittag1.0-m'Version1.0ofthe
如果你的新包出现在Python包索引中,并且你可以通过Download链接成功下载你的包的1.0版本,那么你应该得到表扬。恭喜!这是一个复杂的过程,但它将为你的可重用模块和包提供尽可能多的受众。
在本书的第四章和第五章中,我们使用了pip,Python包管理器,来安装我们想要使用的各种库。正如我们在第七章中所学到的,pip通常会将一个包安装到Python的site-packages目录中。由于这个目录在模块搜索路径中列出,你新安装的模块或包就可以被导入和在你的代码中使用。
现在让我们使用pip来安装我们在上一节中创建的测试包。由于我们知道我们的包已经被命名为
pipinstall
sudopipinstall
一切顺利的话,你应该看到各种命令被运行,因为你新创建的包被下载和安装。假设这成功了,你可以开始你的Python解释器,并访问你的新包,就像它是Python标准库的一部分一样。例如:
>>>from
除了一些例外情况,您可以使用pip从Python软件包索引安装任何软件包。默认情况下,pip将安装软件包的最新可用版本;要指定特定版本,您可以在安装软件包时提供版本号,就像这样:
pipinstall
pipinstall--upgrade
piplist还有一个pip的功能需要注意。您可以创建一个要求文件,列出您想要的所有软件包,并一次性安装它们。典型的要求文件看起来可能是这样的:
Django==1.8.2Pillow==3.0.0reportlab==3.2.0要求文件列出了您想要安装的各种软件包及其关联的版本号。
按照惯例,要求文件的名称为requirements.txt,并放置在项目的顶层目录中。要求文件非常有用,因为它们使得通过一个命令轻松地重新创建Python开发环境成为可能,包括程序所依赖的所有软件包。这是通过以下方式完成的:
pipinstall-rrequirements.txt由于要求文件存储在程序源代码旁边,通常会在源代码存储库中包含requirements.txt文件。这意味着您可以克隆存储库到新计算机,并且只需一个命令,重新安装程序所依赖的所有模块和包。
虽然您可以手动创建一个要求文件,但通常会使用pip为您创建此文件。安装所需的模块和软件包后,您可以使用以下命令创建requirements.txt文件:
pipfreeze>requirements.txt这个命令的好处是,您可以在任何时候重新运行它,以满足您的要求变化。如果您发现您的程序需要使用一个新的模块或软件包,您可以使用pipinstall来安装新的模块或软件包,然后立即调用pipfreeze来创建一个包含新依赖项的更新要求文件。
在安装和使用模块和软件包时,还有一件事需要注意:有时,您需要安装不同版本的模块或软件包。例如,也许您想运行一个需要Django软件包1.6版本的特定程序,但您只安装了1.4版本。如果您更新Django到1.6版本,可能会破坏依赖于它的其他程序。
为了避免这种情况,您可能会发现在您的计算机上设置一个虚拟环境非常有用。虚拟环境就像一个单独的Python安装,拥有自己安装的模块和软件包。您可以为每个项目创建一个单独的虚拟环境,这样每个项目都可以有自己的依赖关系,而不会干扰您可能在计算机上安装的其他项目的要求。
当您想要使用特定的虚拟环境时,您必须激活它。然后,您可以使用pipinstall将各种软件包安装到该环境中,并使用您安装的软件包运行程序。当您想要完成对该环境的工作时,您可以停用它。这样,您可以根据需要在不同项目上工作时在虚拟环境之间切换。
在本章中,我们了解了各种测试Python模块和包的方法。我们了解了单元测试以及Python标准库中的unittest包如何更容易地编写和使用你开发的模块和包的单元测试。我们看到单元测试如何使用assert语句(或者如果你使用unittest.TestCase类,则使用各种assertXXX()方法)来在特定条件未满足时引发AssertionError。通过编写各种单元测试,你可以确保你的模块和包按照你的期望工作。
我们接着看了准备模块或包进行发布的过程,并了解了GitHub如何提供一个优秀的存储库来存储和管理你的模块和包的源代码。
在创建了我们自己的测试包之后,我们通过了将该包提交到PythonPackageIndex的过程。最后,我们学会了如何使用pip,Python包管理器,将一个包从PyPI安装到系统的site-packages目录中,然后看了一下使用要求文件或虚拟环境来帮助管理程序依赖的方法。
在本书的最后一章中,我们将看到模块化编程如何更普遍地作为良好编程技术的基础。
在本书中,我们已经走了很长的路。从学习Python中模块和包的工作原理,以及如何使用它们更好地组织代码,我们发现了许多常见的实践,用于应用模块化模式来解决各种编程问题。我们已经看到模块化编程如何允许我们以最佳方式处理现实世界系统中的变化需求,并学会了使模块或包成为在新项目中重复使用的合适候选者的条件。我们已经看到了许多Python中处理模块和包的更高级技术,以及避免在这一过程中可能遇到的陷阱的方法。
最后,我们看了测试代码的方法,如何使用源代码管理系统来跟踪您对代码的更改,以及如何将您的模块或包提交到Python包索引(PyPI),以便其他人可以找到并使用它。
在本章中,我们将使用一个实际的例子来展示模块和包远不止于组织代码:它们有助于更有效地处理编程的过程。我们将看到模块对于任何大型系统的设计和开发是至关重要的,并演示使用模块化技术创建健壮、有用和编写良好的模块是成为一名优秀程序员的重要组成部分。
回到第四章用于真实世界编程的模块,我们看了一个面临变化需求挑战的示例程序。我们看到模块化设计如何使我们能够在程序的范围远远超出最初设想的情况下最小化需要重写的代码量。
>>>counter.reset()>>>counter.add("sheep")>>>counter.add("cow")>>>counter.add("sheep")>>>counter.add("rabbit")>>>counter.add("cow")>>>print(counter.totals())[("cow",2),("rabbit",1),("sheep",2)]这是一个简单的包,但它为我们提供了一个很好的目标,可以应用我们在前几章学到的一些更有用的技术。特别是,我们将利用文档字符串来记录我们包中每个函数的功能,并编写一系列单元测试来确保我们的包按照我们的预期工作。
让我们开始创建一个目录来保存我们的新项目,我们将其称为Counter。在方便的地方创建一个名为counter的目录,然后在该目录中添加一个名为README.rst的新文件。由于我们希望最终将这个包上传到Python包索引,我们将使用reStructuredText格式来编写我们的README文件。在该文件中输入以下内容:
counter.reset()然后当您识别到特定颜色的汽车时,您将进行以下调用:
forcolor,num_occurrencesincounter.totals():print(color,num_occurrences)然后计数器可以被重置以开始计算另一组值。
现在让我们实现这个包。在我们的counter目录中,创建另一个名为counter的目录来保存我们包的源代码,并在这个最里层的counter目录中创建一个包初始化文件(__init__.py)。我们将按照之前使用的模式,在一个名为interface.py的模块中定义我们包的公共函数,然后将其导入__init__.py文件中,以便在包级别提供各种函数。为此,编辑__init__.py文件,并在该文件中输入以下内容:
from.interfaceimport*我们的下一个任务是实现interface模块。在counter包目录中创建interface.py文件,并在该文件中输入以下内容:
defreset():passdefadd(value):passdeftotals():pass这些只是我们counter包的公共函数的占位符;我们将逐一实现这些函数,从reset()函数开始。
遵循使用文档字符串记录每个函数的推荐做法,让我们从描述这个函数做什么开始。编辑现有的reset()函数定义,使其看起来像以下内容:
defreset():"""Resetourcounter.Thisshouldbecalledbeforewestartcounting."""pass请记住,文档字符串是一个三引号字符串(跨越多行的字符串),它“附加”到一个函数上。文档字符串通常以对函数做什么的一行描述开始。如果需要更多信息,这将后跟一个空行,然后是一行或多行更详细描述函数的信息。正如您所看到的,我们的文档字符串包括一行描述和一行额外提供有关函数的更多信息。
现在我们需要实现这个函数。由于我们的计数器包需要跟踪每个唯一值出现的次数,将这些信息存储在一个将唯一值映射到出现次数的字典中是有意义的。我们可以将这个字典存储为一个私有全局变量,由我们的reset()函数初始化。知道了这一点,我们可以继续实现我们reset()函数的其余部分:
defreset():"""Resetourcounter.Thisshouldbecalledbeforewestartcounting."""global_counts_counts={}#Mapsvaluetonumberofoccurrences.有了私有的_counts全局变量定义,我们现在可以实现add()函数。这个函数记录给定值的出现次数,并将结果存储到_counts字典中。用以下代码替换add()函数的占位实现:
defadd(value):"""Addthegivenvaluetoourcounter."""global_countstry:_counts[value]+=1exceptKeyError:_counts[value]=1这里不应该有任何意外。我们的最终函数totals()返回了添加到_counts字典中的值,以及每个值出现的次数。以下是必要的代码,应该替换您现有的totals()函数的占位符:
deftotals():"""Returnthenumberoftimeseachvaluehasoccurred.Wereturnalistof(value,num_occurrences)tuples,oneforeachuniquevalueincludedinthecount."""global_countsresults=[]forvalueinsorted(_counts.keys()):results.append((value,_counts[value]))returnresults这完成了我们对counter包的第一个实现。我们将尝试使用我们在上一章学到的临时测试技术来测试它:打开一个终端或命令行窗口,使用cd命令将当前目录设置为最外层的counter目录。然后,输入python启动Python交互解释器,并尝试输入以下命令:
importcountercounter.reset()counter.add(1)counter.add(2)counter.add(1)print(counter.totals())一切顺利的话,您应该会看到以下输出:
[(1,2),(2,1)]这告诉您值1出现了两次,值2出现了一次——这正是您对add()函数的调用所表明的。
现在我们的软件包似乎正在工作,让我们创建一些单元测试,以便更系统地测试我们的软件包。在最外层的counter目录中创建一个名为tests.py的新文件,并将以下代码输入到这个文件中:
importunittestimportcounterclassCounterTestCase(unittest.TestCase):"""Unittestsforthe``counter``package."""deftest_counter_totals(self):counter.reset()counter.add(1)counter.add(2)counter.add(3)counter.add(1)self.assertEqual(counter.totals(),[(1,2),(2,1),(3,1)])deftest_counter_reset(self):counter.reset()counter.add(1)counter.reset()counter.add(2)self.assertEqual(counter.totals(),[(2,1)])if__name__=="__main__":unittest.main()如您所见,我们编写了两个单元测试:一个用于检查我们添加的值是否反映在计数器的总数中,另一个用于确保reset()函数正确地重置计数器,丢弃了在调用reset()之前添加的任何值。
要运行这些测试,退出Python交互解释器,按下Control+D,然后在命令行中输入以下内容:
pythontests.py一切顺利的话,您应该会看到以下输出,表明您的两个单元测试都没有出现错误:
..---------------------------------------------------------------------Ran2testsin0.000sOK不可避免的变化在这个阶段,我们现在有一个完全正常工作的counter软件包,具有良好的文档和单元测试。然而,想象一下,您的软件包的要求现在发生了变化,对您的设计造成了重大问题:现在不再是简单地计算唯一值的数量,而是需要支持值的范围。例如,您的软件包的用户可能会定义从0到5、5到10和10到15的值范围;每个范围内的值都被分组在一起进行计数。以下插图显示了如何实现这一点:
为了使您的软件包支持范围,您需要更改接口以接受可选的范围值列表。例如,要计算0到5、5到10和10到15之间的值,可以使用以下参数调用reset()函数:
counter.reset([0,5,10,15])如果没有参数传递给counter.reset(),那么整个软件包应该继续像现在一样工作,记录唯一值而不是范围。
让我们实现这个新功能。首先,编辑reset()函数,使其看起来像下面这样:
defreset(ranges=None):"""Resetourcounter.If'ranges'issupplied,thegivenlistofvalueswillbeusedasthestartandendofeachrangeofvalues.Inthiscase,thetotalswillbecalculatedbasedonarangeofvaluesratherthanindividualvalues.Thisshouldbecalledbeforewestartcounting."""global_rangesglobal_counts_ranges=ranges_counts={}#If_rangesisNone,mapsvaluetonumberof#occurrences.Otherwise,maps(min_value,#max_value)tonumberofoccurrences.这里唯一的区别,除了更改文档,就是我们现在接受一个可选的ranges参数,并将其存储到私有的_ranges全局变量中。
现在让我们更新add()函数以支持范围。更改您的源代码,使得这个函数看起来像下面这样:
defadd(value):"""Addthegivenvaluetoourcounter."""global_rangesglobal_countsif_ranges==None:key=valueelse:foriinrange(len(_ranges)-1):ifvalue>=_ranges[i]andvalue<_ranges[i+1]:key=(_ranges[i],_ranges[i+1])breaktry:_counts[key]+=1exceptKeyError:_counts[key]=1这个函数的接口没有变化;唯一的区别在于,在幕后,我们现在检查我们是否正在计算值范围的总数,如果是的话,我们将键设置为标识范围的(min_value,max_value)元组。这段代码有点混乱,但它可以很好地隐藏这个函数的使用代码中的复杂性。
我们需要更新的最后一个函数是totals()函数。如果我们使用范围,这个函数的行为将会改变。编辑接口模块的副本,使totals()函数看起来像下面这样:
deftotals():"""Returnthenumberoftimeseachvaluehasoccurred.Ifwearecurrentlycountingrangesofvalues,wereturnalistof(min_value,max_value,num_occurrences)tuples,oneforeachrange.Otherwise,wereturnalistof(value,num_occurrences)tuples,oneforeachuniquevalueincludedinthecount."""global_rangesglobal_countsif_ranges!=None:results=[]foriinrange(len(_ranges)-1):min_value=_ranges[i]max_value=_ranges[i+1]num_occurrences=_counts.get((min_value,max_value),0)results.append((min_value,max_value,num_occurrences))returnresultselse:results=[]forvalueinsorted(_counts.keys()):results.append((value,_counts[value]))returnresults这段代码有点复杂,但我们已经更新了函数的文档字符串,以描述新的行为。现在让我们测试我们的代码;启动Python解释器,尝试输入以下指令:
importcountercounter.reset([0,5,10,15])counter.add(5.7)counter.add(4.6)counter.add(14.2)counter.add(0.3)counter.add(7.1)counter.add(2.6)print(counter.totals())一切顺利的话,您应该会看到以下输出:
[(0,5,3),(5,10,2),(10,15,1)]这对应于您定义的三个范围,并显示有三个值落入第一个范围,两个值落入第二个范围,只有一个值落入第三个范围。
在这个阶段,似乎您更新后的软件包是成功的。就像我们在第六章中看到的例子一样,创建可重用模块,我们能够使用模块化编程技术来限制需要支持软件包中一个重大新功能所需的更改数量。我们进行了一些测试,更新后的软件包似乎正在正常工作。
然而,我们不会止步于此。由于我们向我们的包添加了一个重要的新功能,我们应该添加一些单元测试来确保这个功能的正常工作。编辑您的tests.py脚本,并将以下新的测试用例添加到此模块:
classRangeCounterTestCase(unittest.TestCase):"""Unittestsfortherange-basedfeaturesofthe``counter``package."""deftest_range_totals(self):counter.reset([0,5,10,15])counter.add(3)counter.add(9)counter.add(4.5)counter.add(12)counter.add(19.1)counter.add(14.2)counter.add(8)self.assertEqual(counter.totals(),[(0,5,2),(5,10,2),(10,15,2)])这与我们用于临时测试的代码非常相似。保存更新后的tests.py脚本后,运行它。这应该会显示出一些非常有趣的东西:您的新包突然崩溃了:
ERROR:test_range_totals(__main__.RangeCounterTestCase)-----------------------------------------------------------------Traceback(mostrecentcalllast):File"tests.py",line35,intest_range_totalscounter.add(19.1)File"/Users/erik/ProjectSupport/Work/Packt/PythonModularProg/FirstDraft/Chapter9/code/counter-ranges/counter/interface.py",line36,inadd_counts[key]+=1UnboundLocalError:localvariable'key'referencedbeforeassignment我们的test_range_totals()单元测试失败,因为我们的包在尝试将值19.1添加到我们的范围计数器时会出现UnboundLocalError。稍加思考就会发现问题所在:我们定义了三个范围,0-5,5-10和10-15,但现在我们试图将值19.1添加到我们的计数器中。由于19.1超出了我们设置的范围,我们的包无法为这个值分配一个范围,因此我们的add()函数崩溃了。
很容易解决这个问题;将以下突出显示的行添加到您的add()函数中:
defadd(value):"""Addthegivenvaluetoourcounter."""global_rangesglobal_countsif_ranges==None:key=valueelse:**key=Noneforiinrange(len(_ranges)-1):ifvalue>=_ranges[i]andvalue<_ranges[i+1]:key=(_ranges[i],_ranges[i+1])break**ifkey==None:**raiseRuntimeError("Valueoutofrange:{}".format(value))try:_counts[key]+=1exceptKeyError:_counts[key]=1这会导致我们的包在用户尝试添加超出我们设置的范围的值时返回RuntimeError。
不幸的是,我们的单元测试仍然崩溃,只是现在以RuntimeError的形式失败。为了解决这个问题,从test_range_totals()单元测试中删除counter.add(19.1)行。我们仍然希望测试这种错误情况,但我们将在单独的单元测试中进行。在您的RangeCounterTestCase类的末尾添加以下内容:
deftest_out_of_range(self):counter.reset([0,5,10,15])withself.assertRaises(RuntimeError):counter.add(19.1)这个单元测试专门检查我们之前发现的错误情况,并确保包在提供的值超出请求的范围时正确返回RuntimeError。
注意,我们现在为我们的包定义了四个单独的单元测试。我们仍在测试包,以确保它在没有范围的情况下运行,以及测试我们所有基于范围的代码。因为我们已经实施(并开始充实)了一系列针对我们的包的单元测试,我们可以确信,为了支持范围所做的任何更改都不会破坏不使用新基于范围的功能的任何现有代码。
正如您所看到的,我们使用的模块化编程技术帮助我们最大限度地减少了对代码所需的更改,并且我们编写的单元测试有助于确保更新后的代码继续按我们期望的方式工作。通过这种方式,模块化编程技术的使用使我们能够以最有效的方式处理不断变化的需求和编程的持续过程。
要了解这些模块化技术和技术有多么重要,只需想一想,如果在开发一个大型、复杂和不断变化的系统时不使用它们,你将会陷入多么混乱的境地。没有模块化设计技术和标准模式的应用,比如分而治之、抽象和封装,你会发现自己编写了结构混乱的意大利面代码,带来许多意想不到的副作用,并且新功能和变化散布在你的源代码中。没有单元测试,你将无法确保你的代码在进行更改时仍然能够正常工作。最后,缺乏嵌入式文档将使跟踪系统的各个部分变得非常困难,导致错误和没有经过深思熟虑的更改,因为你继续开发和扩展你的代码。
出于这些原因,很明显模块化编程技术对于任何大型系统的设计和开发至关重要,因为它们帮助你以最佳方式处理复杂性。
既然你已经看到模块化编程技术有多么有用,你可能会想知道为什么会有人不想使用它们。除了缺乏理解之外,为什么程序员会避开模块化原则和技术呢?
Python语言从头开始就被设计为支持良好的模块化编程技术,并且通过优秀的工具(如Python标准库、单元测试和文档字符串)的添加,它鼓励你将这些技术应用到你的日常编程实践中。同样,使用缩进来定义代码的结构自动鼓励你编写格式良好的源代码,其中代码的缩进反映了程序的逻辑组织。这些都不是随意的选择:Python在每一步都鼓励良好的编程实践。
当然,就像你可以使用Python编写结构混乱和难以理解的意大利面代码一样,你也可以在开发程序时避免使用模块化技术和实践。但你为什么要这样呢?
问题是,一次性代码有一个有趣的习惯,就是变成永久的,并发展成为一个更大的复杂系统。经常情况下,最初的一次性代码成为一个大型和复杂系统的基础。你六个月前写的代码可能会在新程序中被找到和重用。最终,你永远不知道什么是一次性代码,什么不是。
在本章,甚至整本书中,我们已经看到模块化编程技术的应用如何帮助你以最有效的方式处理编程的过程。你不是在回避变化,而是能够管理它,使得你的代码能够持续工作,并且通过新的需求不断改进。
我们已经看到了另一个需要根据不断扩大的需求进行更改的程序的例子,并且已经看到了模块化技术的应用,包括使用文档字符串和单元测试,有助于编写健壮且易于理解的代码,随着不断的开发和更改而不断改进。
希望你觉得这个关于模块化编程世界的介绍有用,并且现在开始将模块化技术和模式应用到你自己的编程中。我鼓励你继续尽可能多地了解围绕良好的模块化编程实践的各种工具,比如使用文档字符串和Sphinx库来为你的包自动生成文档,以及使用virtualenv来设置和使用虚拟环境来管理你程序的包依赖关系。你继续使用模块化实践和技术,它将变得更容易,你作为程序员也将变得更有效率。愉快的编程!