Python-模块化编程(全)

Python 模块化编程(全)

原文:zh.annas-archive.org/md5/253F5AD072786A617BB26982B7C4733F

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

模块化编程是一种组织程序源代码的方式。通过将代码组织成模块(Python 源文件)和包(模块集合),然后将这些模块和包导入到程序中,您可以保持程序的逻辑组织,并将潜在问题降至最低。

随着程序的增长和变化,您经常需要重写或扩展代码的某些部分。模块化编程技术有助于管理这些变化,最小化副作用,并控制代码。

当您使用模块化编程技术时,您将学习一些常见的使用模块和包的模式,包括编程的分而治之方法,抽象和封装的使用,以及编写可扩展模块的概念。

模块化编程技术也是共享代码的好方法,可以通过使其可供他人使用或在另一个程序中重用您的代码。使用流行工具如 GitHub 和 Python 包索引,您将学习如何发布您的代码,以及使用其他人编写的代码。

将所有这些技术结合起来,您将学习如何应用“模块化思维”来创建更好的程序。您将看到模块如何用于处理大型程序中的复杂性和变化,以及模块化编程实际上是良好编程技术的基础。

在本书结束时,您将对 Python 中的模块和包的工作原理有很好的理解,并且知道如何使用它们来创建高质量和健壮的软件,可以与他人共享。

本书涵盖内容

第一章,“介绍模块化编程”,探讨了您可以使用 Python 模块和包来帮助组织程序的方式,为什么使用模块化技术很重要,以及模块化编程如何帮助您处理持续的编程过程。

第二章,“编写您的第一个模块化程序”,介绍了编程的“分而治之”方法,并将此技术应用于基于模块化编程原则构建库存控制系统的过程。

第三章,“使用模块和包”,涵盖了使用 Python 进行模块化编程的基础知识,包括嵌套包,包和模块初始化技术,相对导入,选择导入内容,以及如何处理循环引用。

第四章,“将模块用于实际编程”,使用图表生成库的实现来展示模块化技术如何以最佳方式处理不断变化的需求。

第五章,“使用模块模式”,探讨了一些与模块和包一起使用的标准模式,包括分而治之技术,抽象,封装,包装器,以及如何使用动态导入,插件和钩子编写可扩展模块。

第六章,“创建可重用模块”,展示了如何设计和创建旨在与其他人共享的模块和包。

第七章,“高级模块技术”,探讨了 Python 中模块化编程的一些更独特的方面,包括可选和本地导入,调整模块搜索路径,“要注意的事项”,如何使用模块和包进行快速应用程序开发,处理包全局变量,包配置和包数据文件。

第八章,“测试和部署模块”探讨了单元测试的概念,如何准备您的模块和包以供发布,如何上传和发布您的工作,以及如何使用其他人编写的模块和包。

第九章,“作为良好编程技术基础的模块化编程”展示了模块化技术如何帮助处理编程的持续过程,如何处理变化和管理复杂性,以及模块化编程技术如何帮助您成为更有效的程序员。

您需要什么来阅读本书

在本书中跟随示例所需的只是运行任何最新版本的 Python 的计算机。虽然所有示例都使用 Python 3,但它们可以很容易地适应 Python 2,只需进行少量更改。

本书适合对象

本书面向初学者到中级水平的 Python 程序员,希望使用模块化编程技术创建高质量和组织良好的程序。读者必须了解 Python 的基础知识,但不需要先前的模块化编程知识。

约定

在这本书中,您会发现一些文本样式,用于区分不同类型的信息。以下是这些样式的一些示例以及它们的含义解释。

文本中的代码词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 句柄显示如下:“这个一行程序将被保存在磁盘上的一个文件中,通常命名为hello.py

代码块设置如下:

def init():
    global _stats
    _stats = {}

当我们希望引起您对代码块的特定部分的注意时,相关的行或项目会以粗体显示:

[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 模块和包,将为您提供成功的工具,使您能够成为快速变化的编程领域的专业人士。

在这一章中,我们将:

  • 查看模块化编程的基本方面

  • 看看 Python 模块和包如何被用来组织您的代码

  • 了解当不使用模块化编程技术时会发生什么

  • 了解模块化编程如何帮助您掌握开发过程

  • 以 Python 标准库为例,看看模块化编程是如何使用的

  • 创建一个简单的程序,使用模块化技术构建,以了解它在实践中是如何工作的

让我们开始学习模块和它们的工作原理。

介绍 Python 模块

对于大多数初学者程序员来说,他们的第一个 Python 程序是著名的Hello World程序的某个版本。这个程序可能看起来像这样:

print("Hello World!")

这个一行程序将保存在磁盘上的一个文件中,通常命名为hello.py,并且通过在终端或命令行窗口中输入以下命令来执行:

python hello.py

然后 Python 解释器将忠实地打印出您要求它打印的消息:

Hello World!

这个hello.py文件被称为Python 源文件。当您刚开始时,将所有程序代码放入单个源文件是组织程序的好方法。您可以定义函数和类,并在底部放置指令,当您使用 Python 解释器运行程序时,它会启动您的程序。将程序代码存储在 Python 源文件中可以避免每次想要告诉 Python 解释器该做什么时都需要重新输入它。

然而,随着您的程序变得更加复杂,您会发现越来越难以跟踪您定义的所有各种函数和类。您会忘记放置特定代码的位置,并且发现越来越难记住所有各种部分是如何组合在一起的。

模块化编程是一种组织程序的方式,随着程序变得更加复杂。您可以创建一个 Python 模块,一个包含 Python 源代码以执行某些有用功能的源文件,然后将此模块导入到您的程序中,以便您可以使用它。例如,您的程序可能需要跟踪程序运行时发生的各种事件的各种统计信息。最后,您可能想知道每种类型的事件发生了多少次。为了实现这一点,您可以创建一个名为stats.py的 Python 源文件,其中包含以下 Python 代码:

def init():
    global _stats
    _stats = {}

def event_occurred(event):
    global _stats
    try:
        _stats[event] = _stats[event] + 1
    except KeyError:
        _stats[event] = 1

def get_stats():
    global _stats
    return sorted(_stats.items())

stats.py Python 源文件定义了一个名为stats的模块—正如您所看到的,模块的名称只是源文件的名称,不包括.py后缀。您的主程序可以通过导入它并在需要时调用您定义的各种函数来使用这个模块。以下是一个无聊的例子,展示了如何使用stats模块来收集和显示有关事件的统计信息:

import stats

stats.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")

for event,num_times in stats.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 源文件目录:

介绍 Python 包

这个 Python 包叫做animals,包含五个 Python 模块:catcowdoghorsesheep。还有一个名为__init__.py的特殊文件。这个文件被称为包初始化文件;这个文件的存在告诉 Python 系统这个目录包含一个包。包初始化文件还可以用于初始化包(因此得名),也可以用于使导入包变得更容易。

注意

从 Python 3.3 版本开始,包不总是需要包含初始化文件。然而,没有初始化文件的包(称为命名空间包)仍然相当罕见,只在非常特定的情况下使用。为了保持简单,我们将在本书中始终使用常规包(带有__init__.py文件)。

就像我们在调用模块内的函数时使用模块名称一样,当引用包内的模块时,我们使用包名称。例如,考虑以下代码:

import animals.cow
animals.cow.speak()

在此示例中,speak()函数是在cow.py模块中定义的,它本身是animals包的一部分。

包是组织更复杂的 Python 程序的一种很好的方式。您可以使用它们将相关的模块分组在一起,甚至可以在包内定义包(称为嵌套包)以保持程序的超级组织。

请注意,import语句(以及相关的from...import语句)可以以各种方式用于加载包和模块到您的程序中。我们在这里只是浅尝辄止,向您展示了 Python 中模块和包的样子,以便您在程序中看到它们时能够识别出来。我们将在第三章 使用模块和包 中更深入地研究模块和包的定义和导入方式。

提示

下载示例代码

本书的代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/Modular-Programming-with-Python。我们还有来自丰富书籍和视频目录的其他代码包,可在github.com/PacktPublishing/上找到。快去看看吧!

使用模块和包来组织程序

模块和包不仅仅是用来将 Python 代码分布在多个源文件和目录中的,它们还允许您组织您的代码以反映程序试图做什么的逻辑结构。例如,想象一下,您被要求创建一个 Web 应用程序来存储和报告大学考试成绩。考虑到您得到的业务需求,您为应用程序提出了以下整体结构:

使用模块和包来组织程序

该程序分为两个主要部分:一个网络界面,用于与用户交互(以及通过 API 与其他计算机程序交互),以及一个后端,用于处理将信息存储在数据库中的内部逻辑、生成报告和向学生发送电子邮件的逻辑。正如您所看到的,网络界面本身已被分解为四个部分:

  • 一个用户认证部分,处理用户注册、登录和退出

  • 一个用于查看和输入考试结果的网络界面

  • 一个用于生成报告的网络界面

  • 一个 API,允许其他系统根据请求检索考试结果

在考虑应用程序的每个逻辑组件(即上图中的每个框)时,您也开始考虑每个组件将提供的功能。在这样做时,您已经在模块化方面进行思考。实际上,应用程序的每个逻辑组件都可以直接实现为 Python 模块或包。例如,您可以选择将程序分为两个主要包,命名为webbackend,其中:

  • web包中有名为authenticationresultsreportsapi的模块

  • backend包中有名为databasereportgeneratoremailer的模块

正如您所看到的,上图中的每个阴影框都成为了一个 Python 模块,每个框的分组都成为了一个 Python 包。

一旦您决定要定义的包和模块集合,您就可以开始通过在每个模块中编写适当的函数集来实现每个组件。例如,backend.database模块可能有一个名为get_students_results()的函数,它返回给定科目和年份的单个学生的考试结果。

注意

在实际的 Web 应用程序中,您的模块化结构可能实际上会有所不同。这是因为您通常使用诸如 Django 之类的 Web 应用程序框架来创建 Web 应用程序,该框架会对您的程序施加自己的结构。但是,在这个例子中,我们将模块化结构保持得尽可能简单,以展示业务功能如何直接转化为包和模块。

显然,这个例子是虚构的,但它展示了您如何以模块化的方式思考复杂的程序,将其分解为单独的组件,然后依次使用 Python 模块和包来实现这些组件中的每一个。

为什么要使用模块化编程技术?

使用模块化设计技术的一大好处是,它们迫使您考虑程序应该如何结构化,并允许您定义一个随着程序发展而增长的结构。您的程序将是健壮的,易于理解,易于在程序范围扩大时重新构造,也易于其他人一起使用。

木匠有一句座右铭同样适用于模块化编程:每样东西都有其位置,每样东西都应该在其位置上。这是高质量代码的标志之一,就像是一个组织良好的木匠车间的标志一样。

要了解为什么模块化编程是如此重要的技能,请想象一下,如果在编写程序时没有应用模块化技术会发生什么。如果您将所有的 Python 代码放入单个源文件中,不尝试逻辑地排列您的函数和类,并且只是随机地将新代码添加到文件的末尾,您最终会得到一堆难以理解的糟糕代码。以下是一个没有任何模块化组织的程序的示例:

import configparser

def load_config():
    config = configparser.ConfigParser()
    config.read("config.ini")
    return config['config']

def get_data_from_user():
    config = load_config()
    data = []
    for n in range(config.getint('num_data_points')):
        value = input("Data point {}: ".format(n+1))
        data.append(value)
    return data

def print_results(results):
    for value,num_times in results:
        print("{} = {}".format(value, num_times))

def analyze_data():
    data = get_data_from_user()
    results = {}
    config = load_config()
    for value in data:
        if config.getboolean('allow_duplicates'):
            try:
                results[value] = results[value] + 1
            except KeyError:
                results[value] = 1
        else:
            results[value] = 1
    return results

def sort_results(results):
    sorted_results = []
    for value in results.keys():
        sorted_results.append((value, results[value]))
    sorted_results.sort()
    return sorted_results

if __name__ == "__main__":
    results = analyze_data()
    sorted_results = sort_results(results)
    print_results(sorted_results)

这个程序旨在提示用户输入多个数据点并计算每个数据点出现的次数。它确实有效,并且函数和变量名称确实有助于解释程序的每个部分的功能——但它仍然是一团糟。仅仅看源代码,就很难弄清楚这个程序做什么。函数只是在文件的末尾添加,因为作者决定实现它们,即使对于一个相对较小的程序,也很难跟踪各个部分。想象一下,如果一个有 1 万行代码的程序像这样,试图调试或维护它会有多困难!

这个程序是意大利面编程的一个例子——编程中所有东西都混在一起,源代码没有整体组织。不幸的是,意大利面编程经常与其他使程序更难理解的编程习惯结合在一起。一些更常见的问题包括:

  • 选择不当的变量和函数名称,不能暗示每个变量或函数的用途。一个典型的例子是一个程序使用诸如abcd这样的变量名。

  • 完全没有任何解释代码应该做什么的文档。

  • 具有意外副作用的函数。例如,想象一下,如果我们示例程序中的print_results()函数在打印时修改了results数组。如果你想要两次打印结果或在打印后使用结果,你的程序将以一种最神秘的方式失败。

虽然模块化编程不能治愈所有这些问题,但它迫使你考虑程序的逻辑组织,这将帮助你避免它们。将代码组织成逻辑片段将有助于你构建程序,以便你知道每个部分应该放在哪里。考虑包和模块,以及每个模块包含什么,将鼓励你为程序的各个部分选择清晰和适当的名称。使用模块和包还使得在编写过程中自然地包含文档字符串来解释程序的每个部分的功能。最后,使用逻辑结构鼓励程序的每个部分执行一个特定的任务,减少了代码中副作用的可能性。

当然,像任何编程技术一样,模块化编程也可能被滥用,但如果使用得当,它将大大提高你编写的程序的质量。

作为一个过程的编程

想象一下,你正在编写一个计算海外购买价格的程序。你的公司位于英格兰,你需要计算以美元购买的物品的当地价格。其他人已经编写了一个 Python 模块,用于下载汇率,所以你的程序开始看起来像下面这样:

def calc_local_price(us_dollar_amount):
    exchange_rate = get_exchange_rate("USD", "EUR")
    local_amount = us_dollar_amount * exchange_rate
    return local_amount

到目前为止一切都很好。你的程序包含在公司的在线订购系统中,代码投入生产。然而,两个月后,你的公司开始不仅从美国订购产品,还从中国、德国和澳大利亚订购产品。你匆忙更新你的程序以支持这些替代货币,并写下了以下内容:

def calc_local_price(foreign_amount, from_country):
    if from_country == "United States":
        exchange_rate = get_exchange_rate("USD", "EUR")
    elif from_country == "China":
        exchange_rate = get_exchange_rate("CHN", "EUR")
    elif from_country == "Germany":
        exchange_rate = get_exchange_rate("EUR", "EUR")
    elif from_country = "Australia":
        exchange_rate = get_exchange_rate("AUS", "EUR")
    else:
        raise RuntimeError("Unsupported country: " + from_country)
    local_amount = us_dollar_amount * exchange_rate
    return local_amount

这个程序再次投入生产。六个月后,又添加了另外 14 个国家,并且项目经理还决定添加一个新功能,用户可以看到产品价格随时间的变化。作为负责这段代码的程序员,你现在必须为这 14 个国家添加支持,并且还要添加支持历史汇率的功能。

当然,这只是一个刻意构造的例子,但它确实展示了程序通常是如何演变的。程序代码不是您写一次然后永远留下的东西。您的程序在不断地变化和发展,以响应新的需求、新发现的错误和意想不到的后果。有时,一个看似简单的变更可能并非如此。例如,考虑一下在我们之前的例子中编写get_exchange_rate()函数的可怜程序员。这个函数现在不仅需要支持任意货币对的当前汇率,还需要返回到任意所需时间点的历史汇率。如果这个函数是从一个不支持历史汇率的来源获取信息,那么整个函数可能需要从头开始重写以支持替代数据来源。

有时,程序员和 IT 经理试图抑制变更,例如通过编写详细的规范,然后逐步实现程序的一部分(所谓的瀑布编程方法)。但变更是编程的一个组成部分,试图抑制它就像试图阻止风吹一样——最好的办法是接受您的程序发生变更,并学会尽可能好地管理这个过程。

模块化技术是管理程序变更的一种绝佳方式。例如,随着程序的增长和发展,您可能会发现某个变更需要向程序添加一个新模块:

编程作为一个过程

然后,您可以在程序的其他部分导入和使用该模块,以便使用这个新功能。

或者,您可能会发现一个新功能只需要您更改一个模块的内容:

编程作为一个过程

这是模块化编程的主要好处之一——因为特定功能的实现细节在一个模块内部,您通常可以改变模块的内部实现而不影响程序的其他部分。您的程序的其余部分继续像以前一样导入和使用模块——只有模块的内部实现发生了变化。

最后,您可能会发现需要重构您的程序。这是您必须改变代码的模块化组织以改进程序运行方式的地方:

编程作为一个过程

重构可能涉及将代码从一个模块移动到另一个模块,以及创建新模块、删除旧模块和更改模块的工作方式。实质上,重构是重新思考程序,使其运行得更好的过程。

在所有这些变更中,使用模块和包可以帮助您管理所做的变更。因为各个模块和包都执行着明确定义的任务,您确切地知道程序的哪些部分需要被改变,并且可以将变更的影响限制在受影响的模块和使用它们的系统部分之内。

模块化编程不会让变更消失,但它将帮助您处理变更——以及编程的持续过程——以最佳方式。

Python 标准库

用来描述 Python 的一个流行词是它是一种“电池包含”的语言,也就是说,它带有丰富的内置模块和包的集合,称为Python 标准库。如果您编写了任何非平凡的 Python 程序,几乎肯定会使用 Python 标准库中的模块。要了解 Python 标准库有多么庞大,以下是该库中的一些示例模块:

模块 描述
datetime 定义用于存储和计算日期和时间值的类
tempfile 定义一系列函数来处理临时文件和目录
csv 支持读写 CSV 格式文件
hashlib 实现了密码安全哈希
logging 允许你编写日志消息和管理日志文件
threading 支持多线程编程
html 一组用于解析和生成 HTML 文档的模块(即包)
unittest 用于创建和运行单元测试的框架
urllib 一组用于从 URL 读取数据的模块

这些只是 Python 标准库中可用的 300 多个模块中的一小部分。正如你所看到的,提供了广泛的功能,所有这些都内置在每个 Python 发行版中。

由于提供的功能范围非常广泛,Python 标准库是模块化编程的一个很好的例子。例如,math 标准库模块提供了一系列数学函数,使得更容易处理整数和浮点数。如果你查看这个模块的文档(docs.python.org/3/library/math.html),你会发现一个大量的函数和常量,都在 math 模块中定义,执行几乎任何你能想象到的数学运算。在这个例子中,各种函数和常量都在一个单独的模块中定义,这样在需要时很容易引用它们。

相比之下,xmlrpc 包允许你进行使用 XML 协议发送和接收数据的远程过程调用。xmlrpc 包由两个模块组成:xmlrpc.serverxmlrpc.client,其中 server 模块允许你创建 XML-RPC 服务器,而 client 模块包括访问和使用 XML-RPC 服务器的代码。这是一个使用模块层次结构来逻辑地将相关功能组合在一起的例子(在这种情况下,在 xmlrpc 包中),同时使用子模块来分离包的特定部分。

如果你还没有这样做,值得花一些时间查看 Python 标准库的文档。可以在 docs.python.org/3/library/ 找到。值得研究这些文档,看看 Python 是如何将如此庞大的功能集合组织成模块和包的。

Python 标准库并不完美,但随着时间的推移得到了改进,如今的库是模块化编程技术应用到了一个全面的库中,涵盖了广泛的功能和函数的一个很好的例子。

创建你的第一个模块

既然我们已经看到了模块是什么以及它们如何被使用,让我们实现我们的第一个真正的 Python 模块。虽然这个模块很简单,但你可能会发现它是你编写的程序的一个有用的补充。

缓存

在计算机编程中,缓存是一种存储先前计算结果的方式,以便可以更快地检索它们。例如,想象一下,你的程序必须根据三个参数计算运费:

  • 已订购商品的重量

  • 已订购商品的尺寸

  • 客户的位置

根据客户的位置计算运费可能会非常复杂。例如,你可能对本市内的送货收取固定费用,但对于外地订单,根据客户的距离收取溢价。你甚至可能需要向货运公司的 API 发送查询,看看运送给定物品会收取多少费用。

由于计算运费的过程可能非常复杂和耗时,使用缓存来存储先前计算的结果是有意义的。这允许你使用先前计算的结果,而不是每次都重新计算运费。为此,你需要将你的 calc_shipping_cost() 函数结构化为以下内容:

def calc_shipping_cost(params):
    if params in cache:
        shipping_cost = cache[params]
    else:
        ...calculate the shipping cost.
        cache[params] = shipping_cost
    return shipping_cost

正如你所看到的,我们接受提供的参数(在这种情况下是重量、尺寸和客户位置),并检查是否已经有一个缓存条目与这些参数匹配。如果是,我们从缓存中检索先前计算的运费。否则,我们将经历可能耗时的过程来计算运费,使用提供的参数将其存储在缓存中,然后将运费返回给调用者。

请注意,前面伪代码中的cache变量看起来非常像 Python 字典——你可以根据给定的键在字典中存储条目,然后使用该键检索条目。然而,字典和缓存之间有一个关键区别:缓存通常对其包含的条目数量有一个限制,而字典没有这样的限制。这意味着字典将继续无限增长,可能会占用计算机的所有内存,而缓存永远不会占用太多内存,因为条目数量是有限的。

一旦缓存达到最大尺寸,每次添加新条目时都必须删除一个现有条目,以防缓存继续增长:

缓存

虽然有各种各样的选择要删除的条目的方法,但最常见的方法是删除最近未使用的条目,也就是最长时间未使用的条目。

缓存在计算机程序中非常常见。事实上,即使你在编写程序时还没有使用缓存,你几乎肯定以前遇到过它们。有人曾经建议你清除浏览器缓存来解决浏览器问题吗?是的,浏览器使用缓存来保存先前下载的图像和网页,这样它们就不必再次检索,清除浏览器缓存的内容是修复浏览器问题的常见方法。

编写一个缓存模块

现在让我们编写自己的 Python 模块来实现一个缓存。在写之前,让我们考虑一下我们的缓存模块将需要的功能:

  • 我们将限制我们的缓存大小为 100 个条目。

  • 我们将需要一个init()函数来初始化缓存。

  • 我们将有一个set(key, value)函数来在缓存中存储一个条目。

  • get(key)函数将从缓存中检索条目。如果没有该键的条目,此函数应返回None

  • 我们还需要一个contains(key)函数来检查给定的条目是否在缓存中。

  • 最后,我们将实现一个size()函数,它返回缓存中的条目数。

注意

我们故意保持这个模块的实现相当简单。一个真正的缓存会使用Cache类来允许您同时使用多个缓存。它还将允许根据需要配置缓存的大小。然而,为了保持简单,我们将直接在一个模块中实现这些函数,因为我们想专注于模块化编程,而不是将其与面向对象编程和其他技术结合在一起。

继续创建一个名为cache.py的新 Python 源文件。这个文件将保存我们新模块的 Python 源代码。在这个模块的顶部,输入以下 Python 代码:

import datetime

MAX_CACHE_SIZE = 100

我们将使用datetime标准库模块来计算缓存中最近未使用的条目。第二个语句定义了MAX_CACHE_SIZE,设置了我们缓存的最大尺寸。

提示

请注意,我们遵循了使用大写字母定义常量的标准 Python 约定。这样可以使它们在源代码中更容易看到。

现在我们要为我们的缓存实现init()函数。为此,在模块的末尾添加以下内容:

def init():
    global _cache
    _cache = {} # Maps key to (datetime, value) tuple.

如你所见,我们创建了一个名为init()的新函数。这个函数的第一条语句global _cache定义了一个名为_cache的新变量。global语句使得这个变量作为模块级全局变量可用,也就是说,这个变量可以被cache.py模块的所有部分共享。

注意变量名开头的下划线字符。在 Python 中,前导下划线是指示名称为私有的约定。换句话说,_cache全局变量旨在作为cache.py模块的内部部分使用——下划线告诉你,你不应该在cache.py模块之外使用这个变量。

init()函数中的第二条语句将_cache全局设置为空字典。注意我们添加了一个解释说明字典将如何被使用的注释;向你的代码中添加这样的注释是一个好习惯,这样其他人(以及你,在长时间处理其他事情后再看这段代码时)可以轻松地看到这个变量的用途。

总之,调用init()函数的效果是在模块内创建一个私有的_cache变量,并将其设置为空字典。现在让我们编写set()函数,它将使用这个变量来存储缓存条目。

将以下内容添加到模块的末尾:

def set(key, value):
    global _cache
    if key not in _cache and len(_cache) >= MAX_CACHE_SIZE:
        _remove_oldest_entry()
    _cache[key] = [datetime.datetime.now(), value]

一次又一次,set()函数以global _cache语句开始。这使得_cache模块级全局变量可供函数使用。

if语句检查缓存是否将超过允许的最大大小。如果是,我们调用一个名为_remove_oldest_entry()的新函数,从缓存中删除最旧的条目。注意这个函数名也以下划线开头——再次说明这个函数是私有的,只应该被模块内部的代码使用。

最后,我们将条目存储在_cache字典中。注意我们存储了当前日期和时间以及缓存中的值;这将让我们知道缓存条目上次被使用的时间,这在我们必须删除最旧的条目时很重要。

现在实现get()函数。将以下内容添加到模块的末尾:

def get(key):
    global _cache
    if key in _cache:
        _cache[key][0] = datetime.datetime.now()
        return _cache[key][1]
    else:
        return None

你应该能够弄清楚这段代码的作用。唯一有趣的部分是在返回相关值之前更新缓存条目的日期和时间。这样我们就知道缓存条目上次被使用的时间。

有了这些函数的实现,剩下的两个函数也应该很容易理解。将以下内容添加到模块的末尾:

def contains(key):
    global _cache
    return key in _cache

def size():
    global _cache
    return len(_cache)

这里不应该有任何意外。

只剩下一个函数需要实现:我们的私有_remove_oldest_entry()函数。将以下内容添加到模块的末尾:

def _remove_oldest_entry():
    global _cache
    oldest = None
    for key in _cache.keys():
        if oldest == None:
            oldest = key
        elif _cache[key][0] < _cache[oldest][0]:
            oldest = key
    if oldest != None:
        del _cache[oldest]

这完成了我们cache.py模块本身的实现,包括我们之前描述的五个主要函数,以及一个私有函数和一个私有全局变量,它们在内部用于帮助实现我们的公共函数。

使用缓存

现在让我们编写一个简单的测试程序来使用这个cache模块,并验证它是否正常工作。创建一个新的 Python 源文件,我们将其称为test_cache.py,并将以下内容添加到该文件中:

import random
import string
import cache

def random_string(length):
    s = ''
    for i in range(length):
        s = s + random.choice(string.ascii_letters)
    return s

cache.init()

for n in range(1000):
    while True:
        key = random_string(20)
        if cache.contains(key):
            continue
        else:
            break
    value = random_string(20)
    cache.set(key, value)
    print("After {} iterations, cache has {} entries".format(n+1, cache.size()))

这个程序首先导入了三个模块:两个来自 Python 标准库,以及我们刚刚编写的cache模块。然后我们定义了一个名为random_string()的实用函数,它生成给定长度的随机字母字符串。之后,我们通过调用cache.init()来初始化缓存,然后生成 1,000 个随机条目添加到缓存中。在添加每个缓存条目后,我们打印出我们添加的条目数以及当前的缓存大小。

如果你运行这个程序,你会发现它按预期工作:

$ python test_cache.py
After 1 iterations, cache has 1 entries
After 2 iterations, cache has 2 entries
After 3 iterations, cache has 3 entries
...
After 98 iterations, cache has 98 entries
After 99 iterations, cache has 99 entries
After 100 iterations, cache has
 **100 entries
After 101 iterations, cache has 100 entries
After 102 iterations, cache has 100 entries
...
After 998 iterations, cache has 100 entries
After 999 iterations, cache has 100 entries
After 1000 iterations, cache has 100 entries

缓存会不断增长,直到达到 100 个条目,此时最旧的条目将被移除以为新条目腾出空间。这确保了缓存保持相同的大小,无论添加了多少新条目。

虽然我们可以在cache.py模块中做更多的事情,但这已足以演示如何创建一个有用的 Python 模块,然后在另一个程序中使用它。当然,你不仅仅局限于在主程序中导入模块,模块也可以相互导入。

总结

在本章中,我们介绍了 Python 模块的概念,看到 Python 模块只是 Python 源文件,可以被另一个源文件导入和使用。然后我们看了 Python 包,发现这些是由一个名为__init__.py的包初始化文件标识的模块集合。

我们探讨了模块和包如何用于组织程序的源代码,以及为什么使用这些模块化技术对于大型系统的开发非常重要。我们还探讨了意大利面条式代码的样子,发现如果不对程序进行模块化,可能会出现一些其他陷阱。

接下来,我们将编程视为不断变化和发展的过程,以及模块化编程如何帮助以最佳方式处理不断变化的代码库。然后我们了解到 Python 标准库是大量模块和包的绝佳示例,并通过创建自己的简单 Python 模块来展示有效的模块化编程技术。在实现这个模块时,我们学会了模块如何使用前导下划线来标记变量和函数名称为模块的私有,同时使其余函数和其他定义可供系统的其他部分使用。

在下一章中,我们将应用模块化技术来开发一个更复杂的程序,由几个模块共同解决一个更复杂的编程问题。

第二章:编写您的第一个模块化程序

在本章中,我们将使用模块化编程技术来实现一个非平凡的程序。在此过程中,我们将:

  • 了解程序设计的“分而治之”方法

  • 检查我们的程序需要执行的任务

  • 查看我们的程序需要存储的信息

  • 应用模块化技术,将我们的程序分解为各个部分

  • 弄清楚每个部分如何可以作为单独的 Python 模块实现

  • 查看各个模块如何协同工作以实现我们程序的功能

  • 按照这个过程实现一个简单但完整的库存控制系统

  • 了解模块化技术如何允许您向程序添加功能,同时最小化需要进行的更改

库存控制系统

假设您被要求编写一个程序,允许用户跟踪公司的库存,即公司可供销售的各种物品。对于每个库存物品,您被要求跟踪产品代码和物品当前的位置。新物品将在收到时添加,已售出的物品将在售出后移除。您的程序还需要生成两种类型的报告:列出公司当前库存的报告,包括每种物品在每个位置的数量,以及用于在物品售出后重新订购库存物品的报告。

查看这些要求,很明显我们需要存储三种不同类型的信息:

  1. 公司出售的不同类型的产品清单。对于每种产品类型,我们需要知道产品代码(有时称为 SKU 编号)、描述以及公司应该在库存中拥有的该产品类型的所需数量。

  2. 库存物品可以存放的位置清单。这些位置可能是单独的商店、仓库或储藏室。或者,位置可能标识商店内的特定货架或过道。对于每个位置,我们需要有位置代码和标识该位置的描述。

  3. 最后,公司当前持有的库存物品清单。每个库存物品都有产品代码和位置代码;这些标识产品类型以及物品当前所在的位置。

运行程序时,最终用户应能执行以下操作:

  • 向库存中添加新物品

  • 从库存中移除物品

  • 生成当前库存物品的报告

  • 生成需要重新订购的库存物品的报告

  • 退出程序

虽然这个程序并不太复杂,但这里有足够的功能可以从模块化设计中受益,同时保持我们的讨论相对简洁。既然我们已经看了我们的程序需要做什么以及我们需要存储的信息,让我们开始应用模块化编程技术来设计我们的系统。

设计库存控制系统

如果您退后一步,审查我们的库存控制程序的功能,您会发现这个程序需要支持三种基本类型的活动:

  • 存储信息

  • 与用户交互

  • 生成报告

虽然这很笼统,但这种分解很有帮助,因为它提出了组织程序代码的可能方式。例如,负责存储信息的系统部分可以存储产品、位置和库存物品的列表,并在需要时提供这些信息。同样,负责与用户交互的系统部分可以提示用户选择要执行的操作,要求他们选择产品代码等。最后,负责生成报告的系统部分将能够生成所需类型的报告。

以这种方式思考系统,很明显,系统的这三个部分可以分别实现为单独的模块:

  • 负责存储信息的系统部分可以称为数据存储模块

  • 负责与用户交互的系统部分可以称为用户界面模块

  • 负责生成报告的系统部分可以称为报告生成器模块

正如名称所示,每个模块都有特定的目的。除了这些专用模块,我们还需要系统的另一个部分:一个 Python 源文件,用户执行以启动和运行库存控制系统。因为这是用户实际运行的部分,我们将称其为主程序,通常存储在名为main.py的 Python 源文件中。

现在我们的系统有四个部分:三个模块加上一个主程序。每个部分都将有特定的工作要做,各个部分通常会相互交互以执行特定的功能。例如,报告生成器模块将需要从数据存储模块获取可用产品代码的列表。这些各种交互在下图中用箭头表示:

设计库存控制系统

现在我们对程序的整体结构有了一个概念,让我们更仔细地看看这四个部分中的每一个是如何工作的。

数据存储模块

这个模块将负责存储我们程序的所有数据。我们已经知道我们需要存储三种类型的信息:产品列表,位置列表和库存项目列表。

为了使我们的程序尽可能简单,我们将就数据存储模块做出两个重要的设计决定:

  • 产品和位置列表将被硬编码到我们的程序中

  • 我们将在内存中保存库存项目列表,并在列表更改时将其保存到磁盘上

我们的库存控制系统的更复杂的实现会将这些信息存储在数据库中,并允许用户查看和编辑产品代码和位置列表。然而,在我们的情况下,我们更关心程序的整体结构,所以我们希望尽可能简单地实现。

虽然产品代码列表将被硬编码,但我们不一定希望将此列表构建到数据存储模块本身中。数据存储模块负责存储和检索信息,而不是定义产品代码列表的工作。因此,我们需要在数据存储模块中添加一个函数,用于设置产品代码列表。此函数将如下所示:

def set_products(products):
    ...

我们已经决定,对于每种产品,我们希望存储产品代码描述和用户希望保留的物品数量。为了支持这一点,我们将定义产品列表(作为我们set_products()函数中的products参数提供)为(code, description, desired_number)元组的列表。例如,我们的产品列表可能如下所示:

[("CODE01", "Product 1", 10),
 ("CODE02", "Product 2", 200), ...
]

一旦产品列表被定义,我们可以提供一个函数根据需要返回此列表:

def products():
    ...

这将简单地返回产品列表,允许您的代码根据需要使用此列表。例如,您可以使用以下 Python 代码扫描产品列表:

for code,description,desired_number in products():
    ...

这两个函数允许我们定义(硬编码)产品列表,并在需要时检索此列表。现在让我们为位置列表定义相应的两个函数。

首先,我们需要一个函数来设置硬编码的位置列表:

def set_locations(locations):
    ...

locations列表中的每个项目将是一个(code, description)元组,其中code是位置的代码,description是描述位置的字符串,以便用户知道它在哪里。

然后我们需要一个函数根据需要检索位置列表:

def locations():
    ...

再次返回位置列表,允许我们根据需要处理这些位置。

现在我们需要决定数据存储模块将如何允许用户存储和检索库存项目列表。库存项目被定义为产品代码加上位置代码。换句话说,库存项目是特定类型的产品在特定位置。

为了检索库存项目列表,我们将使用以下函数:

def items():
    ...

遵循我们为products()locations()函数使用的设计,items()函数将返回一个库存项目列表,其中每个库存项目都是一个(product_code, location_code)元组。

与产品和位置列表不同,库存项目列表不会被硬编码:用户可以添加和删除库存项目。为了支持这一点,我们需要两个额外的函数:

def add_item(product_code, location_code):
    ...

def remove_item(product_code, location_code):
    ...

我们需要设计数据存储模块的最后一个部分:因为我们将在内存中存储库存项目列表,并根据需要将它们保存到磁盘,所以当程序启动时,我们需要一种方式将库存项目从磁盘加载到内存中。为了支持这一点,我们将为我们的模块定义一个初始化函数

def init():
    ...

我们现在已经决定了数据存储模块的总共八个函数。这八个函数构成了我们模块的公共接口。换句话说,系统的其他部分将只能使用这八个函数与我们的模块进行交互:

数据存储模块

注意我们在这里经历的过程:我们首先看了我们的模块需要做什么(在这种情况下,存储和检索信息),然后根据这些要求设计了模块的公共接口。对于前七个函数,我们使用业务需求来帮助我们设计接口,而对于最后一个函数init(),我们使用了我们对模块内部工作方式的知识来改变接口,以便模块能够完成其工作。这是一种常见的工作方式:业务需求和技术需求都将帮助塑造模块的接口以及它如何与系统的其他部分交互。

现在我们已经设计了我们的数据存储模块,让我们为系统中的其他模块重复这个过程。

用户界面模块

用户界面模块将负责与用户进行交互。这包括向用户询问信息,以及在屏幕上显示信息。为了保持简单,我们将为我们的库存控制系统使用一个简单的基于文本的界面,使用print()语句来显示信息,使用input()来要求用户输入内容。

我们的库存控制系统的更复杂的实现将使用带有窗口、菜单和对话框的图形用户界面。这样做会使库存控制系统变得更加复杂,远远超出了我们在这里尝试实现的范围。然而,由于系统的模块化设计,如果我们重新编写用户界面以使用菜单、窗口等,我们只需要更改这一个模块,而系统的其他部分将不受影响。

注意

这实际上是一个轻微的过度简化。用 GUI 替换基于文本的界面需要对系统进行许多更改,并且可能需要我们稍微更改模块的公共函数,就像我们不得不向数据存储模块添加init()函数以允许其内部工作方式一样。但是,由于我们正在设计系统的模块化方式,如果我们重写用户界面模块以使用 GUI,其他模块将不受影响。

让我们从用户与系统交互的角度来考虑库存控制系统需要执行的各种任务:

  1. 用户需要能够选择要执行的操作。

  2. 当用户想要添加新的库存项目时,我们需要提示用户输入新项目的详细信息。

  3. 当用户想要移除库存项目时,我们需要提示用户输入要移除的库存项目的详细信息。

  4. 当用户希望生成报告时,我们需要能够向用户显示报告的内容。

让我们逐个解决这些交互:

  1. 要选择要执行的操作,我们将有一个prompt_for_action()函数,它返回一个标识用户希望执行的操作的字符串。让我们定义此函数可以返回的代码,以执行用户可以执行的各种操作:
操作 操作代码
添加库存项目 ADD
移除库存项目 REMOVE
生成当前库存项目的报告 INVENTORY_REPORT
生成需要重新订购的库存项目报告 REORDER_REPORT
退出程序 QUIT
  1. 要添加库存项目,用户需要提示输入新项目的详细信息。因为库存项目被定义为给定位置的给定产品,实际上我们需要提示用户选择新项目的产品和位置。为了提示用户选择产品,我们将使用以下函数:
def prompt_for_product():
    ...

用户将看到可用产品的列表,然后从列表中选择一个项目。如果他们取消,prompt_for_product()将返回None。否则,它将返回所选产品的产品代码。

同样,为了提示用户选择位置,我们将定义以下函数:

def prompt_for_location():
    ...

再次,这显示了可用位置的列表,用户可以从列表中选择一个位置。如果他们取消,我们返回None。否则,我们返回所选位置的位置代码。

使用这两个函数,我们可以要求用户标识新的库存项目,然后我们使用数据存储模块的add_item()函数将其添加到列表中。

  1. 因为我们正在实现这个简单的基于文本的系统,删除库存项目的过程几乎与添加项目的过程相同:用户将被提示输入产品和位置,然后将删除该位置的库存项目。因此,我们不需要任何额外的函数来实现这个功能。

  2. 要生成报告,我们将简单地调用报告生成器模块来完成工作,然后将生成的报告显示给用户。为了保持简单,我们的报告不会带任何参数,并且生成的报告将以纯文本格式显示。因此,我们唯一需要的用户界面函数是一个函数,用于显示报告的纯文本内容:

def show_report(report):
    ...

report参数将简单地是一个包含生成报告的字符串的列表。show_report()函数需要做的就是逐个打印这些字符串,以向用户显示报告的内容。

这完成了我们对用户界面模块的设计。我们需要为此模块实现四个公共函数。

报告生成器模块

报告生成器模块负责生成报告。由于我们需要能够生成两种类型的报告,所以我们只需在报告生成器模块中有两个公共函数,每种报告一个:

def generate_inventory_report():
    ...

def generate_reorder_report():
    ...

这些函数中的每一个都将生成给定类型的报告,将报告内容作为字符串列表返回。请注意,这些函数没有参数;因为我们尽可能保持简单,报告不会使用任何参数来控制它们的生成方式。

主程序

主程序不是一个模块。相反,它是一个标准的 Python 源文件,用户运行以启动系统。主程序将导入它需要的各种模块,并调用我们定义的函数来完成所有工作。在某种意义上,我们的主程序是将系统的所有其他部分粘合在一起的胶水。

在 Python 中,当一个源文件打算被运行(而不是被其他模块导入和使用,或者从 Python 命令行使用)时,通常使用以下结构的源文件:

def main():
    ...

if __name__ == "__main__":
    main()

所有程序逻辑都写在main()函数内部,然后由文件中的最后两行调用。if __name__ == "__main__"行是 Python 的一个魔术,基本上意味着如果正在运行这个程序。换句话说,如果用户正在运行这个程序,调用main()函数来完成所有工作。

注意

我们可以将所有程序逻辑放在if __name__ == "__main__"语句下面,但将程序逻辑放在一个单独的函数中有一些优点。通过使用单独的函数,我们可以在想要退出时简单地从这个函数返回。这也使得错误处理更容易,代码组织得更好,因为我们的主程序代码与检查我们是否实际运行程序的代码是分开的。

我们将使用这个设计作为我们的主程序,将所有实际功能放在一个名为main()的函数中。

我们的main()函数将执行以下操作:

  1. 调用需要初始化的各个模块的init()函数。

  2. 提供产品和位置的硬连线列表。

  3. 要求用户界面模块提示用户输入命令。

  4. 响应用户输入的命令。

步骤 3 和 4 将无限重复,直到用户退出。

实施库存控制系统

现在我们对系统的整体结构有了一个很好的想法,我们的各种模块将是什么,它们将提供什么功能,是时候开始实施系统了。让我们从数据存储模块开始。

实施数据存储模块

在一个方便的地方创建一个目录,可以在其中存储库存控制系统的源代码。您可能想将此目录命名为inventoryControl或类似的名称。

在这个目录中,我们将放置各种模块和文件。首先创建一个名为datastorage.py的新的空 Python 源文件。这个 Python 源文件将保存我们的数据存储模块。

注意

在为我们的模块选择名称时,我们遵循 Python 使用所有小写字母的惯例。起初你可能会觉得有点笨拙,但很快就会变得容易阅读。有关这些命名约定的更多信息,请参阅www.python.org/dev/peps/pep-0008/#package-and-module-names

我们已经知道我们将需要八个不同的函数来构成这个模块的公共接口,所以继续添加以下 Python 代码到这个模块中:

def init():
    pass

def items():
    pass

def products():
    pass

def locations():
    pass

def add_item(product_code, location_code):
    pass

def remove_item(product_code, location_code):
    pass

def set_products(products):
    pass

def set_locations(locations):
    pass

pass语句允许我们将函数留空-这些只是我们将要编写的代码的占位符。

现在让我们实现init()函数。这在系统运行时初始化数据存储模块。因为我们将库存物品列表保存在内存中,并在更改时将其保存到磁盘上,我们的init()函数将需要从磁盘上的文件中加载库存物品到内存中,以便在需要时可用。为此,我们将定义一个名为_load_items()的私有函数,并从我们的init()函数中调用它。

提示

请记住,前导下划线表示某些内容是私有的。这意味着_load_items()函数不会成为我们模块的公共接口的一部分。

init()函数的定义更改为以下内容:

def init():
    _load_items()

_load_items()函数将从磁盘上的文件加载库存物品列表到一个名为_items的私有全局变量中。让我们继续实现这个函数,通过将以下内容添加到模块的末尾:

def _load_items():
    global _items
    if os.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语句添加到模块的顶部:

import json
import os.path

趁热打铁,让我们编写一个函数将库存物品列表保存到磁盘上。将以下内容添加到模块的末尾:

def _save_items():
    global _items
    f = open("items.json", "w")
    f.write(json.dumps(_items))
    f.close()

由于我们已将库存物品列表加载到名为_items的私有全局变量中,我们现在可以实现items()函数以使这些数据可用。编辑items()函数的定义,使其看起来像下面这样:

def items():
    global _items
    return _items

现在让我们实现add_item()remove_item()函数,让系统的其余部分操作我们的库存物品列表。编辑这些函数,使其看起来像下面这样:

def add_item(product_code, location_code):
    global _items
    _items.append((product_code, location_code))
    _save_items()

def remove_item(product_code, location_code):
    global _items
    for i in range(len(_items)):
        prod_code,loc_code = _items[i]
        if prod_code == product_code and loc_code == location_code:
            del _items[i]
            _save_items()
            return True
    return False

请注意,remove_item()函数如果成功移除该物品则返回True,否则返回False;这告诉系统的其余部分尝试移除库存物品是否成功。

我们现在已经实现了datastorage模块中与库存物品相关的所有函数。接下来,我们将实现与产品相关的函数。

由于我们知道我们将硬编码产品列表,set_products()函数将是微不足道的:

def set_products(products):
    global _products
    _products = products

我们只需将产品列表存储在名为_products的私有全局变量中。然后,我们可以通过products()函数使这个列表可用:

def products():
    global _products
    return _products

同样,我们现在可以实现set_locations()函数来设置硬编码的位置列表:

def set_locations(locations):
    global _locations
    _locations = locations

最后,我们可以实现locations()函数以使这些信息可用:

def locations():
    global _locations
    return _locations

这完成了我们对datastorage模块的实现。

实现用户界面模块

如前所述,用户界面模块将尽可能保持简单,使用print()input()语句与用户交互。在这个系统的更全面的实现中,我们将使用图形用户界面(GUI)来显示并询问用户信息,但我们希望尽可能保持我们的代码简单。

有了这个想法,让我们继续实现我们的用户界面模块函数中的第一个。创建一个名为userinterface.py的新 Python 源文件来保存我们的用户界面模块,并将以下内容添加到此文件中:

def prompt_for_action():
    while True:
        print()
        print("What would you like to do?")
        print()
        print("  A = add an item to the inventory.")
        print("  R = remove an item from the inventory.")
        print("  C = generate a report of the current inventory levels.")
        print("  O = generate a report of the inventory items to re-order.")
        print("  Q = quit.")
        print()
        action = input("> ").strip().upper()
        if   action == "A": return "ADD"
        elif action == "R": return "REMOVE"
        elif action == "C": return "INVENTORY_REPORT"
        elif action == "O": return "REORDER_REPORT"
        elif action == "Q": return "QUIT"
        else:
            print("Unknown action!")

正如您所看到的,我们提示用户输入与每个操作对应的字母,显示可用操作列表,并返回一个标识用户选择的操作的字符串。这不是实现用户界面的好方法,但它有效。

我们接下来要实现的函数是prompt_for_product(),它要求用户从可用产品代码列表中选择一个产品。为此,我们将不得不要求数据存储模块提供产品列表。将以下代码添加到你的userinterface.py模块的末尾:

def prompt_for_product():
    while True:
        print()
        print("Select a product:")
        print()
        n = 1
        for code,description,desired_number in datastorage.products():
            print("  {}. {} - {}".format(n, code, description))
            n = n + 1

        s = input("> ").strip()
        if s == "": return None

        try:
            n = int(s)
        except ValueError:
            n = -1

        if n < 1 or n > len(datastorage.products()):
            print("Invalid option: {}".format(s))
            continue

        product_code = datastorage.products()[n-1][0]
        return product_code

在这个函数中,我们显示产品列表,并在每个产品旁边显示一个数字。然后用户输入所需产品的数字,我们将产品代码返回给调用者。如果用户没有输入任何内容,我们返回None——这样用户可以在不想继续的情况下按下Enter键而不输入任何内容。

趁热打铁,让我们实现一个相应的函数,要求用户确定一个位置:

def prompt_for_location():
    while True:
        print()
        print("Select a location:")
        print()
        n = 1
        for code,description in datastorage.locations():
            print("  {}. {} - {}".format(n, code, description))
            n = n + 1

        s = input("> ").strip()
        if s == "": return None

        try:
            n = int(s)
        except ValueError:
            n = -1

        if n < 1 or n > len(datastorage.locations()):
            print("Invalid option: {}".format(s))
            continue

        location_code = datastorage.locations()[n-1][0]
        return location_code

再次,这个函数显示每个位置旁边的数字,并要求用户输入所需位置的数字。然后我们返回所选位置的位置代码,如果用户取消,则返回None

由于这两个函数使用了数据存储模块,我们需要在我们的模块顶部添加以下import语句:

import datastorage

我们只需要实现一个函数:show_report()函数。让我们现在这样做:

def show_report(report):
    print()
    for line in report:
        print(line)
    print()

由于我们使用文本界面来实现这个功能,这个函数几乎是荒谬地简单。不过它确实有一个重要的目的:通过将显示报告的过程作为一个单独的函数来实现,我们可以重新实现这个函数,以更有用的方式显示报告(例如,在 GUI 中的窗口中显示),而不会影响系统的其余部分。

实现报告生成器模块

报告生成器模块将有两个公共函数,一个用于生成每种类型的报告。话不多说,让我们实现这个模块,我们将把它存储在一个名为reportgenerator.py的 Python 源文件中。创建这个文件,并输入以下内容:

import datastorage

def generate_inventory_report():
    product_names = {}
    for product_code,name,desired_number in datastorage.products():
        product_names[product_code] = name

    location_names = {}
    for location_code,name in datastorage.locations():
        location_names[location_code] = name

    grouped_items = {}
    for product_code,location_code in datastorage.items():
        if product_code not in grouped_items:
            grouped_items[product_code] = {}

        if location_code not in grouped_items[product_code]:
            grouped_items[product_code][location_code] = 1
        else:
            grouped_items[product_code][location_code] += 1

    report = []
    report.append("INVENTORY REPORT")
    report.append("")

    for product_code in sorted(grouped_items.keys()):
        product_name = product_names[product_code]
        report.append("Inventory for product: {} - {}"
                      .format(product_code, product_name))
        report.append("")

        for location_code in sorted(grouped_items[product_code].keys()):
            location_name = location_names[location_code]
            num_items = grouped_items[product_code][location_code]
            report.append("  {} at {} - {}"
                          .format(num_items,
                                  location_code,
                                  location_name))
        report.append("")

    return report

def generate_reorder_report():
    product_names   = {}
    desired_numbers = {}

    for product_code,name,desired_number in datastorage.products():
        product_names[product_code] = name
        desired_numbers[product_code] = desired_number

    num_in_inventory = {}
    for product_code,location_code in datastorage.items():
        if product_code in num_in_inventory:
            num_in_inventory[product_code] += 1
        else:
            num_in_inventory[product_code] = 1

    report = []
    report.append("RE-ORDER REPORT")
    report.append("")

    for product_code in sorted(product_names.keys()):
        desired_number = desired_numbers[product_code]
        current_number = num_in_inventory.get(product_code, 0)
        if current_number < desired_number:
            product_name = product_names[product_code]
            num_to_reorder = desired_number - current_number
            report.append("  Re-order {} of {} - {}"
                          .format(num_to_reorder,
                                  product_code,
                                  product_name))
    report.append("")

    return report

不要太担心这些函数的细节。正如你所看到的,我们从数据存储模块获取库存项目列表、产品列表和位置列表,并根据这些列表的内容生成一个简单的基于文本的报告。

实现主程序

我们需要实现的系统的最后一部分是我们的主程序。创建另一个名为main.py的 Python 源文件,并将以下内容输入到这个文件中:

import datastorage
import userinterface
import reportgenerator

def main():
    pass

if __name__ == "__main__":
    main()

这只是我们主程序的总体模板:我们导入我们创建的各种模块,定义一个main()函数,所有的工作都将在这里完成,并在程序运行时调用它。现在我们需要编写我们的main()函数。

我们的第一个任务是初始化其他模块并定义产品和位置的硬编码列表。让我们现在这样做,通过重写我们的main()函数,使其看起来像下面这样:

def main():
    datastorage.init()

    datastorage.set_products([
        ("SKU123", "4 mm flat-head wood screw",        50),
        ("SKU145", "6 mm flat-head wood screw",        50),
        ("SKU167", "4 mm countersunk head wood screw", 10),
        ("SKU169", "6 mm countersunk head wood screw", 10),
        ("SKU172", "4 mm metal self-tapping screw",    20),
        ("SKU185", "8 mm metal self-tapping screw",    20),
    ])

    datastorage.set_locations([
        ("S1A1", "Shelf 1, Aisle 1"),
        ("S2A1", "Shelf 2, Aisle 1"),
        ("S3A1", "Shelf 3, Aisle 1"),
        ("S1A2", "Shelf 1, Aisle 2"),
        ("S2A2", "Shelf 2, Aisle 2"),
        ("S3A2", "Shelf 3, Aisle 2"),
        ("BIN1", "Storage Bin 1"),
        ("BIN2", "Storage Bin 2"),
    ])

接下来,我们需要询问用户他们希望执行的操作,然后做出适当的响应。我们将从询问用户操作开始,使用while语句,以便可以重复执行这个操作:

    while True:
        action = userinterface.prompt_for_action()

接下来,我们需要响应用户选择的操作。显然,我们需要针对每种可能的操作进行这样的操作。让我们从“退出”操作开始:

break语句将退出while True语句,这样就会离开main()函数并关闭程序。

接下来,我们要实现“添加”操作:

        if action == "QUIT":
            break
        elif action == "ADD":
            product = userinterface.prompt_for_product()
            if product != None:
                location = userinterface.prompt_for_location()
                if location != None:
                    datastorage.add_item(product, location)

请注意,我们调用用户界面函数提示用户输入产品,然后输入位置代码,只有在函数没有返回None的情况下才继续。这意味着我们只有在用户没有取消的情况下才提示位置或添加项目。

现在我们可以实现“删除”操作的等效函数了:

        elif action == "REMOVE":
            product = userinterface.prompt_for_product()
            if product != None:
                location = userinterface.prompt_for_location()
                if location != None:
                    if not datastorage.remove_item(product,
                                                   location):
                        pass # What to do?

这几乎与添加项目的逻辑完全相同,只有一个例外:datastorage.remove_item()函数可能会失败(返回False),如果该产品和位置代码没有库存项目。正如pass语句旁边的注释所建议的那样,当这种情况发生时,我们将不得不做一些事情。

我们现在已经达到了模块化编程过程中非常常见的一个点:我们设计了所有我们认为需要的功能,但后来发现漏掉了一些东西。当用户尝试移除一个不存在的库存项目时,我们希望显示一个错误消息,以便用户知道出了什么问题。因为所有用户交互都发生在userinterface.py模块中,我们希望将这个功能添加到该模块中。

现在让我们这样做。回到编辑userinterface.py模块,并在末尾添加以下函数:

def show_error(err_msg):
    print()
    print(err_msg)
    print()

再次强调,这是一个令人尴尬的简单函数,但它让我们可以将所有用户交互保持在userinterface模块中(并且允许以后重写我们的程序以使用 GUI)。现在让我们用适当的错误处理代码替换main.py程序中的pass语句:

                    ...
                    if not datastorage.remove_item(product,
                                                   location):
 **userinterface.show_error(
 **"There is no product with " +
 **"that code at that location!")

不得不回去更改模块的功能是非常常见的。幸运的是,模块化编程使这个过程更加自包含,因此在这样做时,您不太可能出现副作用和其他错误。

现在用户可以添加和移除库存项目,我们只需要实现另外两个操作:INVENTORY_REPORT操作和REORDER_REPORT操作。对于这两个操作,我们只需要调用适当的报告生成器函数来生成报告,然后调用用户界面模块的show_report()函数来显示结果。现在让我们通过将以下代码添加到我们的main()函数的末尾来实现这一点:

        elif action == "INVENTORY_REPORT":
            report = reportgenerator.generate_inventory_report()
            userinterface.show_report(report)
        elif action == "REORDER_REPORT":
            report = reportgenerator.generate_reorder_report()
            userinterface.show_report(report)

这完成了我们main()函数的实现,实际上也完成了我们整个库存控制系统的实现。继续运行它。尝试输入一些库存项目,移除一两个库存项目,并生成两种类型的报告。如果您按照本书中提供的代码输入或下载了本章的示例代码,程序应该可以正常工作,为您提供一个简单但完整的库存控制系统,更重要的是,向您展示如何使用模块化编程技术实现程序。

总结

在本章中,我们设计并实现了一个非平凡的程序来跟踪公司的库存。使用分而治之的方法,我们将程序分成单独的模块,然后查看每个模块需要提供的功能。这使我们更详细地设计了每个模块内的函数,并且我们随后能够一步一步地实现整个系统。我们发现一些功能被忽视了,需要在设计完成后添加,并且看到模块化编程如何使这些类型的更改不太可能破坏您的系统。最后,我们快速测试了库存控制系统,确保它可以正常工作。

在下一章中,我们将更多地了解 Python 中模块和包的工作原理。

第三章:使用模块和包

要能够在 Python 程序中使用模块和包,您需要了解它们的工作原理。在本章中,我们将研究模块和包在 Python 中是如何定义和使用的。特别是,我们将:

  • 回顾 Python 模块和包的定义

  • 查看如何在其他包中创建包

  • 发现模块和包如何初始化

  • 了解更多关于导入过程

  • 探索相对导入的概念

  • 学习如何控制导入的内容

  • 了解如何处理循环依赖

  • 查看模块如何可以直接从命令行运行,以及为什么这很有用

模块和包

到目前为止,您应该已经相当熟悉如何将您的 Python 代码组织成模块,然后在其他模块和程序中导入和使用这些模块。然而,这只是一个小小的尝试。在深入了解它们如何工作之前,让我们简要回顾一下 Python 模块和包是什么。

正如我们所看到的,模块只是一个 Python 源文件。您可以使用import语句导入模块:

import my_module

完成此操作后,您可以通过在项目名称前面添加模块名称来引用模块中的任何函数、类、变量和其他定义,例如:

my_module.do_something()
print(my_module.variable)

在第一章中,介绍模块化编程,我们了解到 Python 的是一个包含名为__init__.py的特殊文件的目录。这被称为包初始化文件,并将目录标识为 Python 包。该包通常还包含一个或多个 Python 模块,例如:

模块和包

要导入此包中的模块,您需要在模块名称的开头添加包名称。例如:

import my_package.my_module
my_package.my_module.do_something()

您还可以使用import语句的另一种版本来使您的代码更易于阅读:

from my_package import my_module
my_module.do_something()

注意

我们将在本章后面的如何导入任何内容部分中查看您可以使用import语句的各种方式。

包含包的包

就像您可以在目录中有子目录一样,您也可以在其他包中有包。例如,想象一下,我们的my_package目录包含另一个名为my_sub_package的目录,它本身有一个__init__.py文件:

包含包的包

正如您所期望的那样,您可以通过在包含它的包的名称前面添加来导入子包中的模块:

from my_package.my_sub_package import my_module
my_module.do_something()

您可以无限嵌套包,但实际上,如果包含太多级别的包中包,它会变得有些难以管理。更有趣的是,各种包和子包形成了一个树状结构,这使您可以组织甚至最复杂的程序。例如,一个复杂的商业系统可能会被安排成这样:

包含包的包

正如您所看到的,这被称为树状结构,因为包中的包看起来像树的扩展分支。这样的树状结构使您可以将程序的逻辑相关部分组合在一起,同时确保在需要时可以找到所有内容。例如,使用前面插图描述的结构,您将使用program.logic.data.customers包访问客户数据,并且程序中的各种菜单将由program.gui.widgets.menus包定义。

显然,这是一个极端的例子。大多数程序——甚至非常复杂的程序——都不会这么复杂。但是您可以看到 Python 包如何使您能够保持程序的良好组织,无论它变得多么庞大和复杂。

初始化模块

当一个模块被导入时,该模块中的任何顶层代码都会被执行。这会使你在模块中定义的各种函数、变量和类对调用者可用。为了看看这是如何工作的,创建一个名为test_module.py的新 Python 源文件,并输入以下代码到这个模块中:

def foo():
    print("in foo")

def bar():
    print("in bar")

my_var = 0

print("importing test module")

现在,打开一个终端窗口,cd到存储test_module.py文件的目录,并输入python启动 Python 解释器。然后尝试输入以下内容:

% import test_module

当你这样做时,Python 解释器会打印以下消息:

importing test module

这是因为模块中的所有顶层 Python 语句——包括def语句和我们的print语句——在模块被导入时都会被执行。然后你可以通过在名称前加上my_module来调用foobar函数,并访问my_var全局变量:

% my_module.foo()
in foo
% my_module.bar()
in bar
% 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语句:

def init():
    global my_var
    my_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 代码:

def say_hello():
    print("hello")

然后你可以通过以下方式从主程序中访问这个函数:

import my_package
my_package.say_hello()

你不需要在包内的模块中定义say_hello()函数,它就可以很容易地被访问。

作为一个一般原则,向__init__.py文件添加代码并不是一个好主意。它可以工作,但是查看包源代码的人会期望包的代码被定义在模块内,而不是在包初始化文件中。另外,整个包只有一个__init__.py文件,这使得在包内组织代码变得更加困难。

更好的使用包初始化文件的方法是在包内的模块中编写代码,然后使用__init__.py文件导入这些代码,以便在包级别使用。例如,你可以在test_module模块中实现say_hello()函数,然后在包的__init__.py文件中包含以下内容:

from test_package.test_module import say_hello

使用你的包的程序仍然可以以完全相同的方式调用say_hello()函数。唯一的区别是,这个函数现在作为test_module模块的一部分实现,而不是被整个包的__init__.py文件包含在一起。

这是一个非常有用的技术,特别是当你的包变得更加复杂,你有很多函数、类和其他定义想要提供。通过向包初始化文件添加import语句,你可以在任何模块中编写包的部分,然后选择哪些函数、类等在包级别可用。

使用__init__.py文件的一个好处是,各种import语句告诉包的用户他们应该使用哪些函数和类;如果你没有在包初始化文件中包含一个模块或函数,那么它可能被排除是有原因的。

在包初始化文件中使用import语句还告诉包的用户复杂包的各个部分的位置——__init__.py文件充当了包源代码的一种索引。

总之,虽然你可以在包的__init__.py文件中包含任何你喜欢的 Python 代码,但最好限制自己只使用import语句,并将真正的包代码放在其他地方。

如何导入任何东西

到目前为止,我们已经使用了import语句的两种不同版本:

  • 导入一个模块,然后使用模块名来访问在该模块中定义的东西。例如:
import math
print(math.pi)
  • 从模块中导入某些东西,然后直接使用那个东西。例如:
from math import pi
print(pi)

然而,import语句非常强大,我们可以用它做各种有趣的事情。在本节中,我们将看看你可以使用import语句以及它们的内容将模块和包导入到你的程序中的不同方式。

导入语句实际上是做什么?

每当你创建一个全局变量或函数时,Python 解释器都会将该变量或函数的名称添加到所谓的全局命名空间中。全局命名空间包含了你在全局级别定义的所有名称。要查看这是如何工作的,输入以下命令到 Python 解释器中:

>>> print(globals())

globals()内置函数返回一个带有全局命名空间当前内容的字典:

{'__package__': None, '__doc__': None, '__name__': '__main__', '__builtins__': <module 'builtins' (built-in)>, '__loader__': <class '_frozen_importlib.BuiltinImporter'>}

提示

不要担心各种奇怪命名的全局变量,例如__package__;这些是 Python 解释器内部使用的。

现在,让我们定义一个新的顶级函数:

>>> def test():
...     print("Hello")
...
>>>

如果我们现在打印全局名称的字典,我们的test()函数将被包括在内:

>>> print(globals())
{...'test': <function test at 0x1028225f0>...}

注意

globals()字典中还有其他几个条目,但从现在开始,我们只会显示我们感兴趣的项目,以便这些示例不会太令人困惑。

如您所见,名称test已添加到我们的全局命名空间中。

提示

再次,不要担心与test名称关联的值;这是 Python 存储您定义的函数的内部方式。

当某物在全局命名空间中时,您可以通过程序中的任何位置的名称访问它:

>>> test()
Hello

注意

请注意,还有第二个命名空间,称为局部命名空间,其中保存了当前函数中定义的变量和其他内容。虽然局部命名空间在变量范围方面很重要,但我们将忽略它,因为它通常不涉及导入模块。

现在,当您使用import语句时,您正在向全局命名空间添加条目:

>>> import string
>>> print(globals())
{...'string': <module 'string' from '/Library/Frameworks/Python.framework/Versions/3.3/lib/python3.3/string.py'>...}

正如您所看到的,您导入的模块已添加到全局命名空间中,允许您通过名称访问该模块,例如像这样:

>>> print(string.capwords("this is a test"))
This Is A Test

同样,如果您使用import语句的from...import版本,您导入的项目将直接添加到全局命名空间中:

>>> from string import capwords
>>> print(globals())
{...'capwords': <function capwords at 0x1020fb7a0>...}

现在您知道import语句的作用:它将您要导入的内容添加到全局命名空间,以便您可以访问它。

使用导入语句

既然我们已经看到了import语句的作用,让我们来看看 Python 提供的import语句的不同版本。

我们已经看到了import语句的两种最常见形式:

  • import <something>

  • from <somewhere> import <something>

使用第一种形式时,您不限于一次导入一个模块。如果愿意,您可以一次导入多个模块,就像这样:

import string, math, datetime, random

同样,您可以一次从模块或包中导入多个项目:

from math import pi, radians, sin

如果要导入的项目比一行所能容纳的要多,您可以使用行继续字符(\)将导入扩展到多行,或者用括号括起要导入的项目列表。例如:

from math import pi, degrees, radians, sin, cos, \
                 tan, hypot, asin, acos, atan, atan2

from math import (pi, degrees, radians, sin, cos, 
                  tan, hypot, asin, acos, atan, atan2)

当您导入某物时,您还可以更改所导入项目的名称:

import math as math_ops

在这种情况下,您正在将math模块导入为名称math_opsmath模块将使用名称math_ops添加到全局命名空间中,您可以使用math_ops名称访问math模块的内容:

print(math_ops.pi)

有两个原因可能要使用import...as语句来更改导入时的名称:

  1. 为了使长名称或难以处理的名称更容易输入。

  2. 为了避免命名冲突。例如,如果您使用了两个都定义了名为utils的模块的包,您可能希望使用import...as语句,以便名称不同。例如:

from package1 import utils as utils1
from package2 import utils as utils2

注意

请注意,您可能应该谨慎使用import...as语句。每次更改某物的名称时,您(以及任何阅读您代码的人)都必须记住XY的另一个名称,这增加了复杂性,并意味着您在编写程序时需要记住更多的事情。import...as语句当然有合法的用途,但不要过度使用它。

当然,您可以将from...import语句与import...as结合使用:

from reports import customers as customer_report
from database import customers as customer_data

最后,您可以使用通配符导入一次性从模块或包中导入所有内容:

from math import *

这将所有在math模块中定义的项目添加到当前全局命名空间。如果您从包中导入,则将导入包的__init__.py文件中定义的所有项目。

默认情况下,模块(或包)中以下划线字符开头的所有内容都将被通配符导入。这确保了私有变量和函数不会被导入。然而,如果你愿意,你可以通过使用__all__变量来改变通配符导入中包含的内容;这将在本章后面的控制导入内容部分中讨论。

相对导入

到目前为止,每当我们导入东西时,我们都使用了要从中导入的模块或包的完整名称。对于简单的导入,比如from math import pi,这是足够的。然而,有时这种类型的导入可能会相当繁琐。

例如,考虑我们在本章前面的包内包部分中看到的复杂包树。假设我们想要从program.gui.widgets.editor包内导入名为slider.py的模块:

相对导入

你可以使用以下 Python 语句导入这个模块:

from program.gui.widgets.editor import slider

import语句中的program.gui.widgets.editor部分标识了slider模块所在的包。

虽然这样可以工作,但它可能会相当笨拙,特别是如果你需要导入许多模块,或者如果包的某个部分需要从同一个包内导入多个其他模块。

为了处理这种情况,Python 支持相对导入的概念。使用相对导入,你可以确定相对于包树中当前模块位置的位置导入你想要的内容。例如,假设slider模块想要从program.gui.widgets.editor包内导入另一个模块:

相对导入

为此,你用.字符替换包名:

from . import slider

.字符是当前包的简写。

类似地,假设你有一个在program.gui.widgets包内的模块想要从editor子包内导入slider模块:

相对导入

在这种情况下,你的import语句将如下所示:

from .editor import slider

.字符仍然指的是当前位置,editor是相对于当前位置的包的名称。换句话说,你告诉 Python 在当前位置查找名为editor的包,然后导入该包内的名为slider的模块。

让我们考虑相反的情况。假设slider模块想要从widgets目录中导入一个模块:

相对导入

在这种情况下,你可以使用两个.字符来表示向上移动一个级别

from .. import controls

正如你所想象的那样,你可以使用三个.字符来表示向上移动两个级别,依此类推。你也可以结合这些技术以任何你喜欢的方式在包层次结构中移动。例如,假设slider模块想要从gui.dialogs.errors包内导入名为errDialog的模块:

相对导入

使用相对导入,slider模块可以以以下方式导入errDialog模块:

from ...dialogs.errors import errDialog

如你所见,你可以使用这些技术来选择树状包结构中任何位置的模块或包。

使用相对导入有两个主要原因:

  1. 它们是使你的import语句更短、更易读的好方法。在slider模块中,你不必再输入from program.gui.widgets.editor import utils,而是可以简单地输入from . import utils

  2. 当你为他人编写一个包时,你可以让包内的不同模块相互引用,而不必担心用户安装包的位置。例如,我可能会拿到你写的一个包并将其放入另一个包中;使用相对导入,你的包将继续工作,而无需更改所有import语句以反映新的包结构。

就像任何东西一样,相对导入可能会被滥用。因为import语句的含义取决于当前模块的位置,相对导入往往违反了“显式优于隐式”的原则。如果你尝试从命令行运行一个模块,也会遇到麻烦,这在本章后面的“从命令行运行模块”部分有描述。因此,除非有充分的理由,你应该谨慎使用相对导入,并坚持在import语句中完整列出整个包层次结构。

控制导入的内容

当你导入一个模块或包,或者使用通配符导入,比如from my_module import *,Python 解释器会将给定模块或包的内容加载到你的全局命名空间中。如果你从一个模块导入,所有顶层函数、常量、类和其他定义都会被导入。当从一个包导入时,包的__init__.py文件中定义的所有顶层函数、常量等都会被导入。

默认情况下,这些导入会从给定的模块或包中加载所有内容。唯一的例外是通配符导入会自动跳过任何以下划线开头的函数、常量、类或其他定义——这会导致通配符导入排除私有定义。

虽然这种默认行为通常运行良好,但有时你可能希望更多地控制导入的内容。为此,你可以使用一个名为__all__的特殊变量。

为了看看__all__变量是如何工作的,让我们看一下以下模块:

A = 1
B = 2
C = 3
__all__ = ["A", "B"]

如果你导入这个模块,只有AB会被导入。虽然模块定义了变量C,但这个定义会被跳过,因为它没有包含在__all__列表中。

在一个包内,__all__变量的行为方式相同,但有一个重要的区别:你还可以包括你希望在导入包时包含的模块和子包的名称。例如,一个包的__init__.py文件可能只包含以下内容:

__all__ = ["module_1", "module_2", "sub_package"]

在这种情况下,__all__变量控制要包含的模块和包;当你导入这个包时,这两个模块和子包将被自动导入。

注意

注意,前面的__init.py__文件等同于以下内容:

import module1
import module2
import sub_package

__init__.py文件的两个版本都会导致包中包含这两个模块和子包。

虽然你不一定需要使用它,__all__变量可以完全控制你的导入。__all__变量也可以是向模块和包的用户指示他们应该使用你代码的哪些部分的有用方式:如果某些东西没有包含在__all__列表中,那么它就不打算被外部代码使用。

循环依赖

在使用模块时,你可能会遇到的一个令人讨厌的问题是所谓的循环依赖。要理解这些是什么,考虑以下两个模块:

# module_1.py

from module_2 import calc_markup

def calc_total(items):
    total = 0
    for item in items:
        total = total + item['price']
    total = total + calc_markup(total)
    return total

# module_2.py

from module_1 import calc_total

def calc_markup(total):
    return total * 0.1

def make_sale(items):
    total_price = calc_total(items)
    ...

虽然这是一个假设的例子,你可以看到module_1module_2导入了一些东西,而module_2又从module_1导入了一些东西。如果你尝试运行包含这两个模块的程序,当导入module_1时,你会看到以下错误:

ImportError: cannot import name calc_total

如果你尝试导入module_2,你会得到类似的错误。以这种方式组织代码,你就陷入了困境:你无法导入任何一个模块,因为它们都相互依赖。

为了解决这个问题,你需要重新构建你的模块,使它们不再相互依赖。在这个例子中,你可以创建一个名为module_3的第三个模块,并将calc_markup()函数移动到该模块中。这将使module_1依赖于module_3,而不是module_2,从而打破了循环依赖。

提示

还有其他一些技巧可以避免循环依赖错误,例如将import语句放在一个函数内部。然而,一般来说,循环依赖意味着你的代码设计有问题,你应该重构你的代码以完全消除循环依赖。

从命令行运行模块

在第二章编写你的第一个模块化程序中,我们看到你系统的主程序通常被命名为main.py,并且通常具有以下结构:

def main():
    ...

if __name__ == "__main__":
    main()

当用户运行你的程序时,Python 解释器会将__name__全局变量设置为值"__main__"。这会在程序运行时调用你的main()函数。

main.py程序并没有什么特别之处;它只是另一个 Python 源文件。你可以利用这一点,使你的 Python 模块能够从命令行运行。

例如,考虑以下模块,我们将其称为double.py

def double(n):
    return n * 2

if __name__ == "__main__":
    print("double(3) =", double(3))

这个模块定义了一些功能,比如一个名为double()的函数,然后使用if __name__ == "__main__"的技巧来演示和测试模块在从命令行运行时的功能。让我们尝试运行这个模块,看看它是如何工作的:

% python double.py** 
double(3) = 6

可运行模块的另一个常见用途是允许最终用户直接从命令行访问模块的功能。要了解这是如何工作的,创建一个名为funkycase.py的新模块,并输入以下内容到这个文件中:

def funky_case(s):
    letters = []
    capitalize = False
    for letter in s:
        if capitalize:
            letters.append(letter.upper())
        else:
            letters.append(letter.lower())
        capitalize = not capitalize
    return "".join(letters)

funky_case() 函数接受一个字符串,并将每第二个字母大写。如果你愿意,你可以导入这个模块,然后在你的程序中访问这个函数:

from funkycase import funky_case
s = funky_case("Test String")

虽然这很有用,但我们也希望让用户直接运行funkycase.py模块作为一个独立的程序,直接将提供的字符串转换为 funky-case 并打印出来给用户看。为了做到这一点,我们可以使用if __name__ == "__main__"的技巧以及sys.argv来提取用户提供的字符串。然后我们可以调用funky_case()函数来将这个字符串转换为 funky-case 并打印出来。为此,将以下代码添加到你的funkycase.py模块的末尾:

if __name__ == "__main__":
    if len(sys.argv) != 2:
        print("You must supply exactly one string!")
    else:
        s = sys.argv[1]
        print(funky_case(s))

另外,将以下内容添加到你的模块顶部:

import sys

现在你可以直接运行这个模块,就像它是一个独立的程序一样:

% python funkycase.py "The quick brown fox"
tHe qUiCk bRoWn fOx

通过这种方式,funkycase.py 充当了一种变色龙模块。对于其他的 Python 源文件,它看起来就像是可以导入和使用的另一个模块,而对于最终用户来说,它看起来像是一个可以从命令行运行的独立程序。

提示

请注意,如果你想让一个模块能够从命令行运行,你不仅仅可以使用sys.argv来接受和处理用户提供的参数。Python 标准库中的优秀argparse模块允许你编写接受用户各种输入和选项的 Python 程序(和模块)。如果你以前没有使用过这个模块,一定要试试。

当你创建一个可以从命令行运行的模块时,有一个需要注意的问题:如果你的模块使用相对导入,当你直接使用 Python 解释器运行时,你的导入将会失败,并出现尝试相对导入非包的错误。这个错误是因为当模块从命令行运行时,它会忘记它在包层次结构中的位置。只要你的模块不使用任何命令行参数,你可以通过使用 Python 的-m命令行选项来解决这个问题,就像这样:

python -m my_module.py

然而,如果您的模块确实接受命令行参数,那么您将需要替换相对导入,以避免出现这个问题。虽然有解决方法,但它们很笨拙,不建议一般使用。

总结

在本章中,我们深入了解了 Python 模块和包的工作原理。我们看到模块只是使用import语句导入的 Python 源文件,而包是由名为__init__.py的包初始化文件标识的 Python 源文件目录。我们了解到包可以定义在其他包内,形成嵌套包的树状结构。我们看了模块和包如何初始化,以及import语句如何以各种方式导入模块和包及其内容到您的程序中。

然后,我们看到了相对导入如何用于相对于包层次结构中的当前位置导入模块,以及__all__变量如何用于控制导入的内容。

然后,我们了解了循环依赖以及如何避免它们,最后学习了变色龙模块,它可以作为可导入的模块,也可以作为可以从命令行运行的独立程序。

在下一章中,我们将应用所学知识来设计和实现一个更复杂的程序,我们将看到对这些技术的深入理解将使我们能够构建一个健壮的系统,并能够根据不断变化的需求进行更新。

第四章:使用模块进行现实世界编程

在本章中,我们将使用模块化编程技术来实现一个有用的现实世界系统。特别是,我们将:

  • 设计和实现一个用于生成图表的 Python 包

  • 看看不断变化的需求如何成为成功系统的崩溃

  • 发现模块化编程技术如何帮助您以最佳方式处理不断变化的需求

  • 了解不断变化的需求可能是好事,因为它们给您重新思考程序的机会,从而产生更健壮和设计良好的代码

让我们首先看一下我们将要实现的 Python 图表生成包,我们将其称为Charter

介绍 Charter

Charter 将是一个用于生成图表的 Python 库。开发人员将能够使用 Charter 将原始数字转换为漂亮的折线图和条形图,然后将其保存为图像文件。以下是 Charter 库将能够生成的图表类型的示例:

介绍 Charter

Charter 库将支持折线图和条形图。虽然我们将通过仅支持两种类型的图表来保持 Charter 相对简单,但该包将被设计为您可以轻松添加更多的图表类型和其他图表选项。

设计 Charter

当您查看前一节中显示的图表时,您可以识别出所有类型的图表中使用的一些标准元素。这些元素包括标题、x轴和y轴,以及一个或多个数据系列:

设计 Charter

要使用 Charter 包,程序员将创建一个新图表并设置标题、x轴和y轴,以及要显示的数据系列。然后程序员将要求 Charter 生成图表,并将结果保存为磁盘上的图像文件。通过以这种方式组合和配置各种元素,程序员可以创建任何他们希望生成的图表。

更复杂的图表库将允许添加其他元素,例如右侧的y轴、轴标签、图例和多个重叠的数据系列。但是,对于 Charter,我们希望保持代码简单,因此我们将忽略这些更复杂的元素。

让我们更仔细地看看程序员如何与 Charter 库进行交互,然后开始思考如何实现它。

我们希望程序员能够通过导入charter包并调用各种函数来与 Charter 进行交互。例如:

import charter
chart = charter.new_chart()

要为图表设置标题,程序员将调用set_title()函数:

charter.set_title(chart, "Wild Parrot Deaths per Year")

提示

请注意,我们的 Charter 库不使用面向对象的编程技术。使用面向对象的技术,图表标题将使用类似chart.set_title("每年野生鹦鹉死亡数量")的语句进行设置。但是,面向对象的技术超出了本书的范围,因此我们将为 Charter 库使用更简单的过程式编程风格。

要为图表设置xy轴,程序员必须提供足够的信息,以便 Charter 可以生成图表并显示这些轴。为了了解这可能是如何工作的,让我们想一想轴是什么样子。

对于某些图表,轴可能代表一系列数值:

设计 Charter

在这种情况下,通过计算数据点沿轴的位置来显示数据点。例如,具有x = 35的数据点将显示在该轴上3040点之间的中间位置。

我们将把这种类型的轴称为连续轴。请注意,对于这种类型的轴,标签位于刻度线下方。将其与以下轴进行比较,该轴被分成多个离散的“桶”:

设计 Charter

在这种情况下,每个数据点对应一个单独的桶,标签将出现在刻度标记之间的空间中。这种类型的轴将被称为离散轴

注意,对于连续轴,标签显示在刻度标记上,而对于离散轴,标签显示在刻度标记之间。此外,离散轴的值可以是任何值(在本例中是月份名称),而连续轴的值必须是数字。

对于 Charter 库,我们将使 x 轴成为离散轴,而 y 轴将是连续的。理论上,你可以为 xy 轴使用任何类型的轴,但我们保持这样做是为了使库更容易实现。

知道这一点,我们现在可以看一下在创建图表时如何定义各种轴。

为了定义 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")

将所有这些放在一起,这是一个完整的程序,可以生成本章开头显示的条形图:

import charter
chart = charter.new_chart()
charter.set_title(chart, "Wild Parrot Deaths per Year")
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。这为我们提供了编写库的基本框架:

实施图表

根据我们的设计,我们知道生成图表的过程将涉及以下三个步骤:

  1. 通过调用 new_chart() 函数创建一个新的图表。

  2. 通过调用各种 set_XXX() 函数来定义图表的内容和外观。

  3. 通过调用 generate_chart() 函数生成图表并将其保存为图像文件。

为了保持我们的代码组织良好,我们将分开生成图表的过程和创建和定义图表的过程。为此,我们将有一个名为chart的模块,负责图表的创建和定义,以及一个名为generator的单独模块,负责图表的生成。

继续创建这两个新的空模块,将它们放在charter包中:

实现 Charter

现在我们已经为我们的包建立了一个整体结构,让我们为我们知道我们将不得不实现的各种函数创建一些占位符。编辑chart.py模块,并在该文件中输入以下内容:

def new_chart():
    pass

def set_title(chart, title):
    pass

def set_x_axis(chart, x_axis):
    pass

def set_y_axis(chart, minimum, maximum, labels):
    pass

def set_series_type(chart, series_type):
    pass

def set_series(chart, series):
    pass

同样,编辑generator.py模块,并在其中输入以下内容:

def generate_chart(chart, filename):
    pass

这些是我们知道我们需要为 Charter 库实现的所有函数。但是,它们还没有放在正确的位置上——我们希望用户能够调用charter.new_chart(),而不是charter.chart.new_chart()。为了解决这个问题,编辑__init__.py文件,并在该文件中输入以下内容:

from .chart     import *
from .generator import *

正如你所看到的,我们正在使用相对导入将所有这些模块中的函数加载到主charter包的命名空间中。

我们的 Charter 库开始成形了!现在让我们依次处理这两个模块。

实现 chart.py 模块

由于我们在 Charter 库的实现中避免使用面向对象的编程技术,我们不能使用对象来存储有关图表的信息。相反,new_chart()函数将返回一个图表值,各种set_XXX()函数将获取该图表并向其添加信息。

存储图表信息的最简单方法是使用 Python 字典。这使得我们的new_chart()函数的实现非常简单;编辑chart.py模块,并用以下内容替换new_chart()的占位符:

def new_chart():
    return {}

一旦我们有一个将保存图表数据的字典,就很容易将我们想要的各种值存储到这个字典中。例如,编辑set_title()函数的定义,使其如下所示:

def set_title(chart, title):
    chart['title'] = title

以类似的方式,我们可以实现set_XXX()函数的其余部分:

def set_x_axis(chart, x_axis):
    chart['x_axis'] = x_axis

def set_y_axis(chart, minimum, maximum, labels):
    chart['y_min']    = minimum
    chart['y_max']    = maximum
    chart['y_labels'] = labels

def set_series_type(chart, series_type):
    chart['series_type'] = series_type

def set_series(chart, series):
    chart['series'] = series

这完成了我们的chart.py模块的实现。

实现 generator.py 模块

不幸的是,实现generate_chart()函数将更加困难,这就是为什么我们将这个函数移到了一个单独的模块中。生成图表的过程将涉及以下步骤:

  1. 创建一个空图像来保存生成的图表。

  2. 绘制图表的标题。

  3. 绘制x轴。

  4. 绘制y轴。

  5. 绘制数据系列。

  6. 将生成的图像文件保存到磁盘上。

因为生成图表的过程需要我们使用图像,所以我们需要找到一个允许我们生成图像文件的库。现在让我们来获取一个。

Pillow 库

Python Imaging LibraryPIL)是一个古老的用于生成图像的库。不幸的是,PIL 不再得到积极的开发。然而,有一个名为Pillow的更新版本的 PIL,它继续得到支持,并允许我们创建和保存图像文件。

Pillow 库的主要网站可以在python-pillow.org/找到,文档可以在pillow.readthedocs.org/找到。

让我们继续安装 Pillow。最简单的方法是使用pip install pillow,尽管安装指南(pillow.readthedocs.org/en/3.0.x/installation.html)为您提供了各种选项,如果这种方法对您不起作用。

通过查看 Pillow 文档,我们发现可以使用以下代码创建一个空图像:

from PIL import Image
image = Image.new("RGB", (CHART_WIDTH, CHART_HEIGHT), "#7f00ff")

这将创建一个新的 RGB(红色,绿色,蓝色)图像,宽度和高度由给定的颜色填充。

注意

#7f00ff是紫色的十六进制颜色代码。每对十六进制数字代表一个颜色值:7f代表红色,00代表绿色,ff代表蓝色。

为了绘制这个图像,我们将使用ImageDraw模块。例如:

from PIL import ImageDraw
drawer = 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"元素之间进行选择。一个非常简单的方法是将我们的绘图代码结构化如下:

if chart['series_type'] == "bar":
    ...draw the data series using bars
elif chart['series_type'] == "line":
    ...draw the data series using lines

然而,这并不是很灵活,如果绘图逻辑变得复杂,或者我们向库中添加更多的图表选项,代码将很快变得难以阅读。为了使 Charter 库更加模块化,并支持今后的增强,我们将使用渲染器模块来实际进行绘制。

在计算机图形学中,渲染器是程序的一部分,用于绘制某些东西。其思想是你可以选择适当的渲染器,并要求它绘制你想要的元素,而不必担心该元素将如何被绘制的细节。

使用渲染器模块,我们的绘图逻辑看起来会像下面这样:

from renderers import bar_series, line_series

if chart['series_type'] == "bar":
    bar_series.draw(chart, drawer)
elif chart['series_type'] == "line":
    line_series.draw(chart, drawer)

这意味着我们可以将每个元素的实际绘制细节留给渲染器模块本身,而不是在我们的generate_chart()函数中充斥着大量详细的绘制代码。

为了跟踪我们的渲染器模块,我们将创建一个名为renderers的子包,并将所有渲染器模块放在这个子包中。让我们现在创建这个子包。

在主charter目录中创建一个名为renderers的新目录,并在其中创建一个名为__init__.py的新文件,作为包初始化文件。这个文件可以为空,因为我们不需要做任何特殊的初始化来初始化这个子包。

我们将需要五个不同的渲染器模块来完成 Charter 库的工作:

  • title.py

  • x_axis.py

  • y_axis.py

  • bar_series.py

  • line_series.py

继续在charter.renderers目录中创建这五个文件,并在每个文件中输入以下占位文本:

def draw(chart, drawer):
    pass

这给了我们渲染器模块的整体结构。现在让我们使用这些渲染器来实现我们的generate_chart()函数。

编辑generate.py模块,并用以下内容替换generate_chart()函数的占位符定义:

def generate_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)
    if chart['series_type'] == "bar":
        bar_series.draw(chart, drawer)
    elif chart['series_type'] == "line":
        line_series.draw(chart, drawer)

    image.save(filename, format="png")

正如你所看到的,我们创建了一个Image对象来保存我们生成的图表,使用十六进制颜色代码#ffffff将其初始化为白色。然后我们使用ImageDraw模块来定义一个drawer对象来绘制图表,并调用各种渲染器模块来完成所有工作。最后,我们调用image.save()将图像文件保存到磁盘上。

为了使这个函数工作,我们需要在我们的generator.py模块的顶部添加一些import语句:

from PIL import Image, ImageDraw
from .renderers import (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  = 600
CHART_HEIGHT = 400

然后,在你的generator.py模块中添加以下import语句:

from .constants import *

测试代码

虽然我们还没有实现任何渲染器,但我们已经有足够的代码来开始测试。为此,创建一个名为test_charter.py的空文件,并将其放在包含charter包的目录中。然后,在此文件中输入以下内容:

import charter
chart = charter.new_chart()
charter.set_title(chart, "Wild Parrot Deaths per Year")
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文件的目录,并输入以下内容:

python test_charter.py

一切顺利的话,程序应该在没有任何错误的情况下完成。然后,您可以查看chart.png文件,这应该是一个填充有白色背景的空图像文件。

渲染标题

接下来,我们需要实现各种渲染器模块,从图表的标题开始。编辑renderers/title.py文件,并用以下内容替换draw()函数的占位符定义:

def draw(chart, drawer):
    font = ImageFont.truetype("Helvetica", 24)
    text_width,text_height = font.getsize(chart['title'])

    left = CHART_WIDTH/2 - text_width/2
    top  = TITLE_HEIGHT/2 - text_height/2

    drawer.text((left, top), chart['title'], "#4040a0", font)

这个渲染器首先获取一个用于绘制标题的字体。然后计算标题文本的大小(以像素为单位)和用于标签的位置,以便它在图表上居中显示。请注意,我们使用一个名为TITLE_HEIGHT的常量来指定用于图表标题的空间量。

该函数的最后一行使用指定的位置和字体将标题绘制到图表上。字符串#4040a0是用于文本的十六进制颜色代码,这是一种深蓝色。

由于这个模块使用ImageFont模块加载字体,以及我们的constants.py模块中的一些常量,我们需要在我们的模块顶部添加以下import语句:

from PIL import ImageFont
from ..constants import *

请注意,我们使用..从父包中导入constants模块。

最后,我们需要将TITLE_HEIGHT常量添加到我们的constants.py模块中:

TITLE_HEIGHT = 50

如果现在运行您的test_charter.py脚本,您应该会看到生成的图像中出现图表的标题:

渲染标题

渲染 x 轴

如果您记得,* x *轴是一个离散轴,标签显示在每个刻度之间。为了绘制这个,我们将不得不计算轴上每个“桶”的宽度,然后绘制表示轴和刻度线的线,以及绘制每个“桶”的标签。

首先,编辑renderers/x_axis.py文件,并用以下内容替换您的占位符draw()函数:

def draw(chart, drawer):
    font = ImageFont.truetype("Helvetica", 12)
    label_height = font.getsize("Test")[1]

    avail_width = CHART_WIDTH - Y_AXIS_WIDTH - MARGIN
    bucket_width = avail_width / len(chart['x_axis'])

    axis_top = CHART_HEIGHT - X_AXIS_HEIGHT
    drawer.line([(Y_AXIS_WIDTH, axis_top),
                 (CHART_WIDTH - MARGIN, axis_top)],
                "#4040a0", 2) # Draw main axis line.

    left = Y_AXIS_WIDTH
    for bucket_num in range(len(chart['x_axis'])):
        drawer.line([(left, axis_top),
                     (left, axis_top + TICKMARK_HEIGHT)],
                    "#4040a0", 1) # Draw tickmark.

        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 + 4

        drawer.text((label_left, label_top),
                    chart['x_axis'][bucket_num], "#000000", font)

        left = left + bucket_width

    drawer.line([(left, axis_top),
                 (left, axis_top + TICKMARK_HEIGHT)],
                "#4040a0", 1) # Draw final tickmark.

您还需要在模块顶部添加以下import语句:

from PIL import ImageFont
from ..constants import *

最后,您应该将以下定义添加到您的constants.py模块中:

X_AXIS_HEIGHT   = 50
Y_AXIS_WIDTH    = 50
MARGIN          = 20
TICKMARK_HEIGHT = 8

这些定义了图表中固定元素的大小。

如果现在运行您的test_charter.py脚本,您应该会看到* x *轴显示在图表底部:

渲染 x 轴

剩下的渲染器

正如您所看到的,生成的图像开始看起来更像图表了。由于这个包的目的是展示如何构建代码结构,而不是这些模块是如何实现的细节,让我们跳过并添加剩下的渲染器而不再讨论。

首先,编辑您的renderers/y_axis.py文件,使其如下所示:

from PIL import ImageFont

from ..constants import *

def draw(chart, drawer):
    font = ImageFont.truetype("Helvetica", 12)
    label_height = font.getsize("Test")[1]

    axis_top    = TITLE_HEIGHT
    axis_bottom = CHART_HEIGHT - X_AXIS_HEIGHT
    axis_height = axis_bottom - axis_top

    drawer.line([(Y_AXIS_WIDTH, axis_top),
                 (Y_AXIS_WIDTH, axis_bottom)],
                "#4040a0", 2) # Draw main axis line.

    for y_value in chart['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) # Draw tickmark.

        label_width,label_height = font.getsize(str(y_value))
        label_left = Y_AXIS_WIDTH-TICKMARK_HEIGHT-label_width-4
        label_top = y_pos - label_height / 2

        drawer.text((label_left, label_top), str(y_value),
                    "#000000", font)

接下来,编辑renderers/bar_series.py,使其如下所示:

from PIL import ImageFont
from ..constants import *

def draw(chart, drawer):
    avail_width  = CHART_WIDTH - Y_AXIS_WIDTH - MARGIN
    bucket_width = avail_width / len(chart['x_axis'])

    max_top      = TITLE_HEIGHT
    bottom       = CHART_HEIGHT - X_AXIS_HEIGHT
    avail_height = bottom - max_top

    left = Y_AXIS_WIDTH
    for y_value in chart['series']:

        bar_left = left + MARGIN / 2
        bar_right = left + bucket_width - MARGIN / 2

        y = ((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,使其如下所示:

from PIL import ImageFont
from ..constants import *

def draw(chart, drawer):
    avail_width  = CHART_WIDTH - Y_AXIS_WIDTH - MARGIN
    bucket_width = avail_width / len(chart['x_axis'])

    max_top      = TITLE_HEIGHT
    bottom       = CHART_HEIGHT - X_AXIS_HEIGHT
    avail_height = bottom - max_top

    left   = Y_AXIS_WIDTH
    prev_y = None
    for y_value in chart['series']:
        y = ((y_value - chart['y_min']) /
             (chart['y_max'] - chart['y_min']))

        cur_y = max_top + (avail_height - int(y * avail_height))

        if prev_y != None:
            drawer.line([(left - bucket_width / 2, prev_y),
                         (left + bucket_width / 2), cur_y],
                        fill="#4040a0", width=1)
        prev_y = cur_y
        left = left + bucket_width

这完成了我们对 Charter 库的实现。

测试 Charter

如果运行test_charter.py脚本,您应该会看到一个完整的条形图:

测试 Charter

显然,我们可以在 Charter 库中做更多的事情,但即使在当前状态下,它也运行良好。如果您愿意,您可以使用它为各种数据生成线条和条形图。对于我们的目的,我们可以声明 Charter 库已经完成,并开始将其作为我们生产系统的一部分使用。

变化的需求中的一块砂糖

当然,没有什么是真正完成的。假设你写了图书馆并且已经忙着扩展它好几个月,添加了更多的数据系列类型和大量的选项。该库正在公司的几个重大项目中使用,输出效果很棒,每个人似乎都对此很满意——直到有一天你的老板走进来说:“太模糊了。你能把模糊去掉吗?”

你问他是什么意思,他说他一直在一台高分辨率激光打印机上打印图表。结果对他来说还不够好,不能用在公司的报告中。他拿出一份打印件指着标题。仔细看,你明白了他的意思:

瓶中之蝇——需求变更

果然,文本是像素化的,即使线条在高分辨率打印时看起来也有点锯齿状。你尝试增加生成图表的大小,但仍然不够好——当你尝试将大小增加到公司高分辨率激光打印机的每英寸 1200 点时,你的程序崩溃了。

“但这个程序从来没有为此设计过,”你抱怨道。“我们编写它是为了在屏幕上显示图表。”

“我不在乎,”你的老板说。“我希望你生成矢量格式的输出。那样打印效果很好,一点都不模糊。”

注意

以防你以前没有遇到过,存储图像数据有两种根本不同的方式:位图图像,由像素组成;矢量图像,其中保存了单独的绘图指令(例如,“写一些文字”,“画一条线”,“填充一个矩形”等),然后每次显示图像时都会遵循这些指令。位图图像会出现像素化或“模糊”,而矢量图像即使放大或以高分辨率打印时看起来也很棒。

你进行了快速的谷歌搜索,并确认 Pillow 库无法保存矢量格式的图像;它只能处理位图数据。你的老板并不同情,“只需使其以矢量格式工作,同时保存为 PDF 和 PNG,以满足那些已经在使用它的人。”

心情沉重,你想知道自己怎么可能满足这些新的要求。整个 Charter 库都是从头开始构建的,用于生成位图 PNG 图像。难道你不得不从头开始重写整个东西吗?

重新设计图书馆

由于图书馆现在需要将图表保存为矢量格式的 PDF 文件,我们需要找到一个替代 Python Imaging Library 的支持写入 PDF 文件的库。其中一个明显的选择是ReportLab

ReportLab 是一个商业 PDF 生成器,也以开源许可发布。你可以在www.reportlab.com/opensource/找到有关 ReportLab 工具包的更多信息。安装 ReportLab 的最简单方法是使用pip install reportlab。如果这对你不起作用,请查看bitbucket.org/rptlab/reportlab上的安装说明以获取更多详细信息。ReportLab 工具包的文档可以在www.reportlab.com/docs/reportlab-userguide.pdf找到。

在许多方面,ReportLab 的工作方式与 Python Imaging Library 相同:你初始化一个文档(在 ReportLab 中称为画布),调用各种方法将元素绘制到画布上,然后使用save()方法将 PDF 文件保存到磁盘上。

然而,还有一个额外的步骤:因为 PDF 文件格式支持多页,你需要在保存文档之前调用showPage()函数来呈现当前页面。虽然我们不需要 Charter 库的多个页面,但我们可以通过在绘制每个页面后调用showPage(),然后在完成时调用save()来创建多页 PDF 文档并将文件保存到磁盘。

现在我们有了一个工具,可以生成 PDF 文件,让我们看看如何重新构建 Charter 包,以支持 PNG 或 PDF 文件格式的渲染。

generate_chart() 函数似乎是用户应该能够选择输出格式的逻辑点。实际上,我们可以根据文件名自动检测格式——如果 filename 参数以 .pdf 结尾,那么我们应该生成 PDF 格式的图表,而如果 filename.png 结尾,那么我们应该生成 PNG 格式的文件。

更一般地说,我们的渲染器存在一个问题:它们都设计为与 Python Imaging Library 一起工作,并使用 ImageDraw 模块将每个图表绘制为位图图像。

由于这个原因,以及每个渲染器模块内部的代码复杂性,将这些渲染器保持不变,并编写使用 ReportLab 生成 PDF 格式图表元素的新渲染器是有意义的。为此,我们需要对我们的渲染代码进行重构

在我们着手进行更改之前,让我们考虑一下我们想要实现什么。我们将需要每个渲染器的两个单独版本——一个用于生成 PNG 格式的元素,另一个用于生成相同的元素的 PDF 格式:

重新设计 Charter

由于所有这些模块都做同样的事情——在图表上绘制一个元素,因此最好有一个单独的函数,调用适当的渲染器模块的 draw() 函数以在所需的输出格式中绘制给定的图表元素。这样,我们的其余代码只需要调用一个函数,而不是根据所需的元素和格式选择十个不同的 draw() 函数。

为此,我们将在 renderers 包内添加一个名为 renderer.py 的新模块,并将调用各个渲染器的工作留给该模块。这将极大简化我们的设计。

最后,我们的 generate_chart() 函数将需要创建一个 ReportLab 画布以生成 PDF 格式的图表,然后在图表生成后保存这个画布,就像它现在为位图图像所做的那样。

这意味着,虽然我们需要做一些工作来实现我们的渲染器模块的新版本,创建一个新的 renderer.py 模块并更新 generate_chart() 函数,但系统的其余部分将保持完全相同。我们不需要从头开始重写一切,而我们的其余模块——特别是现有的渲染器——根本不需要改变。哇!

重构代码

我们将通过将现有的 PNG 渲染器移动到名为 renderers.png 的新子包中来开始我们的重构。在 renderers 目录中创建一个名为 png 的新目录,并将 title.pyx_axis.pyy_axis.pybar_series.pyline_series.py 模块移动到该目录中。然后,在 png 目录内创建一个空的包初始化文件 __init__.py,以便 Python 可以识别它为一个包。

我们将不得不对现有的 PNG 渲染器进行一个小改动:因为每个渲染器模块使用相对导入导入 constants.py 模块,我们需要更新这些模块,以便它们仍然可以从新位置找到 constants 模块。为此,依次编辑每个 PNG 渲染器模块,并找到以下类似的行:

from ..constants import *

在这些行的末尾添加一个额外的 .,使它们看起来像这样:

from ...constants import *

我们的下一个任务是创建一个包来容纳我们的 PDF 格式渲染器。在 renderers 目录中创建一个名为 pdf 的子目录,并在该目录中创建一个空的包初始化文件,使其成为 Python 包。

接下来,我们要实现前面提到的renderer.py模块,以便我们的generate_chart()函数可以专注于绘制图表元素,而不必担心每个元素定义在哪个模块中。在renderers目录中创建一个名为renderer.py的新文件,并将以下代码添加到该文件中:

from .png import title       as title_png
from .png import x_axis      as x_axis_png
from .png import y_axis      as y_axis_png
from .png import bar_series  as bar_series_png
from .png import line_series as line_series_png

renderers = {
    'png' : {
        'title'       : title_png,
        'x_axis'      : x_axis_png,
        'y_axis'      : y_axis_png,
        'bar_series'  : bar_series_png,
        'line_series' : line_series_png
    },
}

def draw(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模块,并用以下代码替换该模块的内容:

from PIL import Image, ImageDraw
from reportlab.pdfgen.canvas import Canvas

from .constants import *
from .renderers import renderer

def generate_chart(chart, filename):

    # Select the output format.

    if filename.lower().endswith(".pdf"):
        format = "pdf"
    elif filename.lower().endswith(".png"):
        format = "png"
    else:
        print("Unsupported file format: " + filename)
        return

    # Prepare the output file based on the file format.

    if format == "pdf":
        output = Canvas(filename)
    elif format == "png":
        image  = Image.new("RGB", (CHART_WIDTH, CHART_HEIGHT),
                           "#ffffff")
        output = ImageDraw.Draw(image)

    # Draw the various chart elements.

    renderer.draw(format, "title",  chart, output)
    renderer.draw(format, "x_axis", chart, output)
    renderer.draw(format, "y_axis", chart, output)
    if chart['series_type'] == "bar":
        renderer.draw(format, "bar_series", chart, output)
    elif chart['series_type'] == "line":
        renderer.draw(format, "line_series", chart, output)

    # Finally, save the output to disk.

    if format == "pdf":
        output.showPage()
        output.save()
    elif format == "png":
        image.save(filename, format="png")

这个模块中有很多代码,但注释应该有助于解释发生了什么。正如你所看到的,我们使用提供的文件名将format变量设置为"pdf""png"。然后,我们准备output变量来保存生成的图像或 PDF 文件。接下来,我们依次调用renderer.draw()来绘制每个图表元素,传入formatoutput变量,以便渲染器可以完成其工作。最后,我们将输出保存到磁盘,以便将图表保存到适当的 PDF 或 PNG 格式文件中。

有了这些更改,您应该能够使用更新后的 Charter 包来生成 PNG 格式文件。PDF 文件还不能工作,因为我们还没有编写 PDF 渲染器,但 PNG 格式输出应该可以工作。继续运行test_charter.py脚本进行测试,以确保您没有输入任何拼写错误。

现在我们已经完成了重构现有代码,让我们添加 PDF 渲染器。

实现 PDF 渲染器模块

我们将逐个处理各种渲染器模块。首先,在pdf目录中创建titles.py模块,并将以下代码输入到该文件中:

from ...constants import *

def draw(chart, canvas):
    text_width  = canvas.stringWidth(chart['title'],
                                     "Helvetica", 24)
    text_height = 24 * 1.2

    left   = CHART_WIDTH/2 - text_width/2
    bottom = CHART_HEIGHT - TITLE_HEIGHT/2 + text_height/2

    canvas.setFont("Helvetica", 24)
    canvas.setFillColorRGB(0.25, 0.25, 0.625)
    canvas.drawString(left, bottom, chart['title'])

在某些方面,这段代码与该渲染器的 PNG 版本非常相似:我们计算文本的宽度和高度,并使用这些来计算标题应该绘制的图表位置。然后,我们使用 24 点的 Helvetica 字体以深蓝色绘制标题。

然而,也有一些重要的区别:

  • 我们计算文本的宽度和高度的方式不同。对于宽度,我们调用画布的stringWidth()函数,而对于高度,我们将文本的字体大小乘以 1.2。默认情况下,ReportLab 在文本行之间留下字体大小的 20%的间隙,因此将字体大小乘以 1.2 是计算文本行高的准确方式。

  • 用于计算页面上元素位置的单位不同。ReportLab 使用 而不是像素来测量所有位置和大小。一个点大约是一英寸的 1/72。幸运的是,一个点与典型计算机屏幕上的像素大小相当接近;这使我们可以忽略不同的测量系统,使得 PDF 输出看起来仍然很好。

  • PDF 文件使用与 PNG 文件不同的坐标系统。在 PNG 格式文件中,图像的顶部 y 值为零,而对于 PDF 文件,y=0 在图像底部。这意味着我们在页面上的所有位置都必须相对于页面底部计算,而不是像 PNG 渲染器中所做的那样相对于图像顶部计算。

  • 颜色是使用 RGB 颜色值指定的,其中颜色的每个分量都表示为介于零和一之间的数字。例如,颜色值 (0.25,0.25,0.625) 相当于十六进制颜色代码 #4040a0

话不多说,让我们实现剩下的 PDF 渲染模块。x_axis.py 模块应该如下所示:

def draw(chart, canvas):
    label_height = 12 * 1.2

    avail_width  = CHART_WIDTH - Y_AXIS_WIDTH - MARGIN
    bucket_width = avail_width / len(chart['x_axis'])

    axis_top = X_AXIS_HEIGHT
    canvas.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_WIDTH
    for bucket_num in range(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_height

        canvas.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_width

    canvas.setStrokeColorRGB(0.25, 0.25, 0.625)
    canvas.setLineWidth(1)
    canvas.line(left, axis_top, left, axis_top - TICKMARK_HEIGHT)

同样,y_axis.py 模块应该实现如下:

from ...constants import *

def draw(chart, canvas):
    label_height = 12 * 1.2

    axis_top    = CHART_HEIGHT - TITLE_HEIGHT
    axis_bottom = X_AXIS_HEIGHT
    axis_height = axis_top - axis_bottom

    canvas.setStrokeColorRGB(0.25, 0.25, 0.625)
    canvas.setLineWidth(2)
    canvas.line(Y_AXIS_WIDTH, axis_top, Y_AXIS_WIDTH, axis_bottom)

    for y_value in chart['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-4
        label_bottom = y_pos - label_height/4

        canvas.setFont("Helvetica", 12)
        canvas.setFillColorRGB(0.0, 0.0, 0.0)
        canvas.drawString(label_left, label_bottom, str(y_value))

对于 bar_series.py 模块,输入以下内容:

from ...constants import *

def draw(chart, canvas):
    avail_width  = CHART_WIDTH - Y_AXIS_WIDTH - MARGIN
    bucket_width = avail_width / len(chart['x_axis'])

    bottom       = X_AXIS_HEIGHT
    max_top      = CHART_HEIGHT - TITLE_HEIGHT
    avail_height = max_top - bottom

    left = Y_AXIS_WIDTH
    for y_value in chart['series']:
        bar_left  = left + MARGIN / 2
        bar_width = bucket_width - MARGIN

        y = ((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 ...constants import *

def draw(chart, canvas):
    avail_width  = CHART_WIDTH - Y_AXIS_WIDTH - MARGIN
    bucket_width = avail_width / len(chart['x_axis'])

    bottom       = X_AXIS_HEIGHT
    max_top      = CHART_HEIGHT - TITLE_HEIGHT
    avail_height = max_top - bottom

    left   = Y_AXIS_WIDTH
    prev_y = None
    for y_value in chart['series']:
        y = ((y_value - chart['y_min']) /
             (chart['y_max'] - chart['y_min']))

        cur_y = bottom + int(y * avail_height)

        if prev_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_y
        left = left + bucket_width

正如你所看到的,这些模块看起来与它们的 PNG 版本非常相似。只要我们考虑到这两个库工作方式的差异,我们可以用 ReportLab 做任何 Python Imaging Library 能做的事情。

这使我们只需要做一个更改,就能完成对 Charter 库的新实现:我们需要更新 renderer.py 模块,以使这些新的 PDF 渲染模块可用。为此,将以下 import 语句添加到这个模块的顶部:

from .pdf import title       as title_pdf
from .pdf import x_axis      as x_axis_pdf
from .pdf import y_axis      as y_axis_pdf
from .pdf import bar_series  as bar_series_pdf
from .pdf import line_series as line_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 中,我们可以利用所有现有的渲染器模块,只需对源代码进行轻微更改。我们只需要重写一个函数(generate_chart()函数),并添加一个新的renderer模块来简化对渲染器的访问,然后编写每个渲染器的新 PDF 版本。通过这种方式,模块化编程技术的使用有助于将更改隔离到程序的受影响部分。

  • 通常情况下,最终的系统比我们开始时的系统更好。与其将我们的程序变成意大利面代码,支持 PDF 生成的需求导致了一个更模块化、更有结构的库。特别是,renderer模块处理了以各种格式渲染各种图表元素的复杂性,使得系统的其余部分只需调用renderer.draw()来完成工作,而无需直接导入和使用大量模块。由于这种改变,我们可以很容易地添加更多的图表元素或更多的输出格式,而对我们的代码进行最小的进一步更改。

总体教训很明显:与其抵制对需求的变化,不如接受它们。最终的结果是一个更好的系统——更健壮,更可扩展,通常也更有组织。当然,前提是你要做对。

总结

在这一章中,我们使用模块化编程技术来实现一个名为 Charter 的虚构图表生成包。我们看到图表由标准元素组成,以及如何将这种组织转化为程序代码。成功创建了一个能够将图表渲染为位图图像的工作图表生成库后,我们看到了需求上的根本变化起初似乎是一个问题,但实际上是重构和改进代码的机会。

通过这个虚构的例子,我们重构了 Charter 库以处理 PDF 格式的图表。在这样做的过程中,我们了解到使用模块化技术来应对需求的重大变化可以帮助隔离需要进行的更改,并且重构我们的代码通常会导致一个比起始状态更有组织、更可扩展和更健壮的系统。

在下一章中,我们将学习如何使用标准的模块化编程“模式”来处理各种编程挑战。

第五章:使用模块模式

在前几章中,我们详细讨论了 Python 模块和包的工作原理,并学习了如何在程序中使用它们。在使用模块化编程技术时,你会发现模块和包的使用方式往往遵循标准模式。在本章中,我们将研究使用模块和包处理各种编程挑战的一些常见模式。特别是,我们将:

  • 了解分而治之技术如何帮助你解决编程问题

  • 看看抽象原则如何帮助你将要做的事情与如何做它分开

  • 了解封装如何允许你隐藏信息表示的细节

  • 看到包装器是调用其他模块以简化或改变模块使用方式的模块

  • 学习如何创建可扩展的模块

让我们从分而治之的原则开始。

分而治之

分而治之是将问题分解为较小部分的过程。你可能不知道如何解决一个特定的问题,但通过将其分解为较小的部分,然后依次解决每个部分,然后解决原始问题。

当然,这是一个非常普遍的技术,并不仅适用于模块和包的使用。然而,模块化编程有助于你通过分而治之的过程:当你分解问题时,你会发现你需要程序的一部分来执行特定的任务或一系列任务,而 Python 模块(和包)是组织这些任务的完美方式。

在本书中,我们已经做过几次这样的事情。例如,当面临创建图表生成库的挑战时,我们使用了分而治之的技术,提出了可以绘制单个图表元素的渲染器的概念。然后我们意识到我们需要几个不同的渲染器,这完美地转化为包含每个渲染器单独模块的renderers包。

分而治之的方法不仅建议了代码的可能模块化结构,也可以反过来使用。当你考虑程序的设计时,你可能会想到一个与你要解决的问题相关的模块或包的概念。你甚至可能会规划出每个模块和包提供的各个函数。尽管你还不知道如何解决整个问题,但这种模块化设计有助于澄清你对问题的思考,从而使使用分而治之的方法更容易解决问题的其余部分。换句话说,模块和包帮助你在分而治之的过程中澄清你的思路。

抽象

抽象是另一个非常普遍的编程模式,适用于不仅仅是模块化编程。抽象本质上是隐藏复杂性的过程:将你想要做的事情与如何做它分开。

抽象对所有的计算机编程都是绝对基础的。例如,想象一下,你必须编写一个计算两个平均数然后找出两者之间差异的程序。这个程序的简单实现可能看起来像下面这样:

values_1 = [...]
values_2 = [...]

total_1 = 0
for value in values_1:
    total = total + value
average_1 = total / len(values_1)

total_2 = 0
for value in values_2:
    total = total + value
average_2 = total / len(values_2)

difference = abs(total_1 - total-2)
print(difference)

正如你所看到的,计算列表平均数的代码重复了两次。这是低效的,所以你通常会写一个函数来避免重复。可以通过以下方式实现:

values_1 = [...]
values_2 = [...]

def average(values):
    total = 0
    for value in values:
        total = total + value
    return = total / len(values)

average_1 = average(values_1)
average_2 = average(values_2)
difference = abs(total_1 - total-2)
print(difference)

当然,每次编程时你都在做这种事情,但实际上这是一个非常重要的过程。当你创建这样一个函数时,函数内部处理如何做某事,而调用该函数的代码只知道做什么,以及函数会去做。换句话说,函数隐藏了任务执行的复杂性,使得程序的其他部分只需在需要执行该任务时调用该函数。

这种过程称为抽象。使用这种模式,你可以抽象出某事物的具体细节,这样你的程序的其他部分就不需要担心这些细节。

抽象不仅适用于编写函数。隐藏复杂性的一般原则也适用于函数组,而模块是将函数组合在一起的完美方式。例如,你的程序可能需要使用颜色,因此你编写了一个名为colors的模块,其中包含各种函数,允许你创建和使用颜色值。colors模块中的各种函数了解颜色值及如何使用它们,因此你的程序的其他部分不需要担心这些。使用这个模块,你可以做各种有趣的事情。例如:

purple = colors.new_color(1.0, 0.0, 1.0)
yellow = colors.new_color(1.0, 1.0, 0.0)
dark_purple = colors.darken(purple, 0.3)
color_range = colors.blend(yellow, dark_purple, num_steps=20)
dimmed_yellow = colors.desaturate(yellow, 0.8)

在这个模块之外,你的代码可以专注于它想要做的事情,而不需要知道这些各种任务是如何执行的。通过这样做,你正在使用抽象模式将这些颜色计算的复杂性隐藏起来,使其不影响程序的其他部分。

抽象是设计和编写模块和包的基本技术。例如,我们在上一章中使用的 Pillow 库提供了各种模块,允许你加载、操作、创建和保存图像。我们可以使用这个库而不需要知道这些各种操作是如何执行的。例如,我们可以调用drawer.line((x1, y1), (x2, y2), color, width)而不必担心设置图像中的单个像素的细节。

应用抽象模式的一个伟大之处在于,当你开始实现代码时,通常并不知道某事物的复杂程度。例如,想象一下,你正在为酒店酒吧编写一个销售点系统。系统的一部分需要计算顾客点酒时应收取的价格。我们可以使用各种公式来计算这个价格,根据数量、使用的酒类等。但其中一个具有挑战性的特点是需要支持欢乐时光,即在此期间饮料将以折扣价提供。

起初,你被告知欢乐时光是每天晚上五点到六点之间。因此,使用良好的模块化技术,你在代码中添加了以下函数:

def is_happy_hour():
    if datetime.datetime.now().hour == 17: # 5pm.
        return True
    else:
        return False

然后你可以使用这个函数来分离计算欢乐时光的方法和欢乐时光期间发生的事情。例如:

if is_happy_hour():
    price = price * 0.5

到目前为止,这还相当简单,你可能会想要完全绕过创建is_happy_hour()函数。然而,当你发现欢乐时光不适用于星期日时,这个函数很快就变得更加复杂。因此,你必须修改is_happy_hour()函数以支持这一点:

def is_happy_hour():
    if datetime.date.today().weekday() == 6: # Sunday.
        return False
    elif datetime.datetime.now().hour == 17: # 5pm.
        return True
    else:
        return False

但是你随后发现,欢乐时光不适用于圣诞节或耶稣受难日。虽然圣诞节很容易计算,但计算复活节在某一年的日期所使用的逻辑要复杂得多。如果你感兴趣,本章的示例代码包括is_happy_hour()函数的实现,其中包括对圣诞节和耶稣受难日的支持。不用说,这个实现相当复杂。

请注意,随着我们的is_happy_hour()函数的不断发展,它变得越来越复杂 - 起初我们以为它会很简单,但是添加的要求使它变得更加复杂。幸运的是,因为我们已经将计算快乐时光的细节从需要知道当前是否是快乐时光的代码中抽象出来,只需要更新一个函数来支持这种增加的复杂性。

封装

封装是另一种经常适用于模块和包的编程模式。使用封装,你有一个东西 - 例如,颜色、客户或货币 - 你需要存储关于它的数据,但是你将这些数据的表示隐藏起来,不让系统的其他部分知道。而不是直接提供这个东西,你提供设置、检索和操作这个东西数据的函数。

为了看到这是如何工作的,让我们回顾一下我们在上一章中编写的一个模块。我们的chart.py模块允许用户定义一个图表并设置有关它的各种信息。这是我们为这个模块编写的代码的一个副本:

def new_chart():
    return {}

def set_title(chart, title):
    chart['title'] = title

def set_x_axis(chart, x_axis):
    chart['x_axis'] = x_axis

def set_y_axis(chart, minimum, maximum, labels):
    chart['y_min']    = minimum
    chart['y_max']    = maximum
    chart['y_labels'] = labels

def set_series_type(chart, series_type):
    chart['series_type'] = series_type

def set_series(chart, series):
    chart['series'] = series

正如你所看到的,new_chart()函数创建了一个新的“图表”,而不清楚地告诉系统如何存储有关图表的信息 - 我们在这里使用了一个字典,但我们也可以使用一个对象、一个 base64 编码的字符串,或者其他任何东西。系统的其他部分并不关心,因为它只是调用chart.py模块中的各种函数来设置图表的各个值。

不幸的是,这并不是封装的一个完美的例子。我们的各种set_XXX()函数充当设置器 - 它们让我们设置图表的各种值 - 但我们只是假设我们的图表生成函数可以直接从图表的字典中访问有关图表的信息。如果这将是封装的一个纯粹的例子,我们还将编写相应的获取器函数,例如:

def get_title(chart):
    return chart['title']

def get_x_axis(chart):
    return chart['x_axis']

def get_y_axis(chart):
    return (chart['y_min'], chart['y_max'], chart['y_labels'])

def get_series_type(chart):
    return chart['series_type']

def get_series(chart):
    return chart['series']

通过将这些获取器函数添加到我们的模块中,我们现在有了一个完全封装的模块,可以存储和检索关于图表的信息。charter包的其他部分想要使用图表时,将调用获取器函数来检索该图表的数据,而不是直接访问它。

提示

在模块中编写设置器和获取器函数的这些示例有点牵强;封装通常是使用面向对象编程技术来完成的。然而,正如你所看到的,当编写只使用模块化编程技术的代码时,完全可以使用封装。

也许你会想知道为什么有人会想要使用封装。为什么不直接写charts.get_title(chart),而不是简单地写chart['title']?第二个版本更短。它还避免了调用函数,因此速度会更快。为什么要使用封装呢?

在程序中使用封装有两个原因。首先,通过使用获取器和设置器函数,你隐藏了信息存储的细节。这使你能够更改内部表示而不影响程序的任何其他部分 - 并且在编写程序时你几乎可以肯定的一件事是,你将不断添加更多的信息和功能。这意味着你的数据的内部表示发生变化。通过将存储的内容与存储方式分离,你的系统变得更加健壮,你可以进行更改而无需重写大量代码。这是一个良好模块化设计的标志。

使用封装的第二个主要原因是允许您的代码在用户设置特定值时执行某些操作。例如,如果用户更改订单的数量,您可以立即重新计算该订单的总价格。设置器经常做的另一件事是将更新后的值保存到磁盘或数据库中。您还可以在设置器中添加错误检查和其他逻辑,以便捕获可能很难跟踪的错误。

让我们详细看一下使用封装模式的 Python 模块。例如,假设我们正在编写一个用于存储食谱的程序。用户可以创建一个喜爱食谱的数据库,并在需要时显示这些食谱。

让我们创建一个 Python 模块来封装食谱的概念。在这个例子中,我们将食谱存储在内存中,以保持简单。对于每个食谱,我们将存储食谱的名称、食谱产生的份数、配料列表以及制作食谱时用户需要遵循的指令列表。

创建一个名为recipes.py的新 Python 源文件,并输入以下内容到此文件中:

def new():
    return {'name'         : None,
            'num_servings' : 1,
            'instructions' : [],
            'ingredients'  : []}

def set_name(recipe, name):
    recipe['name'] = name

def get_name(recipe):
    return recipe['name']

def set_num_servings(recipe, num_servings):
    recipe['num_servings'] = num_servings

def get_num_servings(recipe):
    return recipe['num_servings']

def set_ingredients(recipe, ingredients):
    recipe['ingredients'] = ingredients

def get_ingredients(recipe):
    return recipe['ingredients']

def set_instructions(recipe, instructions):
    recipe['instructions'] = instructions

def get_instructions(recipe):
    return recipe['instructions']

def add_instruction(recipe, instruction):
    recipe['instructions'].append(instruction)

def add_ingredient(recipe, ingredient, amount, units):
    recipe['ingredients'].append({'ingredient' : ingredient,
                                  'amount'     : amount,
                                  'units'      : units})

正如您所见,我们再次使用 Python 字典来存储我们的信息。我们可以使用 Python 类或 Python 标准库中的namedtuple。或者,我们可以将信息存储在数据库中。但是,在这个例子中,我们希望尽可能简化我们的代码,字典是最简单的解决方案。

创建新食谱后,用户可以调用各种设置器和获取器函数来存储和检索有关食谱的信息。我们还有一些有用的函数,让我们一次添加一条指令和配料,这对我们正在编写的程序更方便。

请注意,当向食谱添加配料时,调用者需要提供三条信息:配料的名称、所需数量以及衡量此数量的单位。例如:

recipes.add_ingredient(recipe, "Milk", 1, "cup")

到目前为止,我们已经封装了食谱的概念,允许我们存储所需的信息,并在需要时检索它。由于我们的模块遵循了封装原则,我们可以更改存储食谱的方式,向我们的模块添加更多信息和新行为,而不会影响程序的其余部分。

让我们再添加一个有用的函数到我们的食谱中:

def to_string(recipe, num_servings):
    multiplier = num_servings / recipe['num_servings']
    s = []
    s.append("Recipe for {}, {} servings:".format(recipe['name'],
                                                  num_servings))
    s.append("")
    s.append("Ingredients:")
    s.append("")
    for ingredient in recipe['ingredients']:
        s.append("    {} - {} {}".format(
                     ingredient['ingredient'],
                     ingredient['amount'] * multiplier,
                     ingredient['units']))
    s.append("")
    s.append("Instructions:")
    s.append("")
    for i,instruction in enumerate(recipe['instructions']):
        s.append("{}. {}".format(i+1, instruction))

    return s

该函数返回一个字符串列表,可以打印出来以总结食谱。注意num_servings参数:这允许我们为不同的份数定制食谱。例如,如果用户创建了一个三份食谱并希望将其加倍,可以使用to_string()函数,并将num_servings值设为6,正确的数量将包含在返回的字符串列表中。

让我们看看这个模块是如何工作的。打开终端或命令行窗口,使用cd命令转到创建recipes.py文件的目录,并输入python启动 Python 解释器。然后,尝试输入以下内容以创建披萨面团的食谱:

import recipes
recipe = recipes.new("Pizza Dough", num_servings=1)
recipes.add_ingredient(recipe, "Greek Yogurt", 1, "cup")
recipes.add_ingredient(recipe, "Self-Raising Flour", 1.5, "cups")
recipes.add_instruction(recipe, "Combine yogurt and 2/3 of the flour in a bowl and mix with a beater until combined")
recipes.add_instruction(recipe, "Slowly add additional flour until it forms a stiff dough")
recipes.add_instruction(recipe, "Turn out onto a floured surface and knead until dough is tacky")
recipes.add_instruction(recipe, "Roll out into a circle of the desired thickness and place on a greased and lined baking tray")

到目前为止一切顺利。现在让我们使用to_string()函数打印出食谱的详细信息,并将其加倍到两份:

for s in recipes.to_string(recipe, num_servings=2):
 **print s

一切顺利的话,食谱应该已经打印出来了:

Recipe for Pizza Dough, 2 servings:

Ingredients:

 **Greek Yogurt - 2 cup
 **Self-rising Flour - 3.0 cups

Instructions:

1\. Combine yogurt and 2/3 of the flour in a bowl and mix with a beater until combined
2\. Slowly add additional flour until it forms a stiff dough
3\. Turn out onto a floured surface and knead until dough is tacky
4\. Roll out into a circle of the desired thickness and place on a greased and lined baking tray

正如您所见,有一些次要的格式问题。例如,所需的希腊酸奶数量列为2 cup而不是2 cups。如果您愿意,您可以很容易地解决这个问题,但要注意的重要事情是recipes.py模块已经封装了食谱的概念,允许您(和您编写的其他程序)处理食谱而不必担心细节。

作为练习,你可以尝试修复to_string()函数中数量的显示。你也可以尝试编写一个新的函数,从食谱列表中创建一个购物清单,在两个或更多食谱使用相同的食材时自动合并数量。如果你完成了这些练习,你很快就会注意到实现可能会变得非常复杂,但通过将细节封装在一个模块中,你可以隐藏这些细节,使其对程序的其余部分不可见。

包装器

包装器本质上是一组调用其他函数来完成工作的函数:

包装器

包装器用于简化接口,使混乱或设计不良的 API 更易于使用,将数据格式转换为更方便的形式,并实现跨语言兼容性。包装器有时也用于向现有 API 添加测试和错误检查代码。

让我们看一个包装器模块的真实应用。想象一下,你在一家大型银行工作,并被要求编写一个程序来分析资金转账,以帮助识别可能的欺诈行为。你的程序实时接收有关每笔银行间资金转账的信息。对于每笔转账,你会得到:

  • 转账金额

  • 转账发生的分支的 ID

  • 资金被发送到的银行的识别码

你的任务是分析随时间变化的转账,以识别异常的活动模式。为此,你需要计算过去八天的每个分支和目标银行的所有转账总值。然后,你可以将当天的总额与前七天的平均值进行比较,并标记任何日总额超过平均值 50%以上的情况。

你可以从决定如何表示一天的总转账开始。因为你需要跟踪每个分支和目标银行的转账总额,所以将这些总额存储在一个二维数组中是有意义的:

包装器

在 Python 中,这种二维数组的类型被表示为一个列表的列表:

totals = [[0, 307512, 1612, 0, 43902, 5602918],
          [79400, 3416710, 75, 23508, 60912, 5806],
          ...
         ]

然后你可以保留一个单独的分支 ID 列表,每行一个,另一个列表保存每列的目标银行代码:

branch_ids = [125000249, 125000252, 125000371, ...]
bank_codes = ["AMERUS33", "CERYUS33", "EQTYUS44", ...]

使用这些列表,你可以通过处理特定日期发生的转账来计算给定日期的总额:

totals = []
for branch in branch_ids:
    branch_totals = []
    for bank in bank_codes:
        branch_totals.append(0)
    totals.append(branch_totals)

for transfer in transfers_for_day:
    branch_index = branch_ids.index(transfer['branch'])
    bank_index   = bank_codes.index(transfer['dest_bank'])
    totals[branch_index][bank_index] += transfer['amount']

到目前为止一切顺利。一旦你得到了每天的总额,你可以计算平均值,并将其与当天的总额进行比较,以识别高于平均值 150%的条目。

假设你已经编写了这个程序并设法让它工作。但当你开始使用它时,你立即发现了一个问题:你的银行有超过 5000 个分支,而你的银行可以向全球超过 15000 家银行转账,这总共需要为 7500 万种组合保留总额,因此,你的程序计算总额的时间太长了。

为了使你的程序更快,你需要找到一种更好的处理大量数字数组的方法。幸运的是,有一个专门设计来做这件事的库:NumPy

NumPy 是一个出色的数组处理库。你可以创建巨大的数组,并使用一个函数调用对数组执行复杂的操作。不幸的是,NumPy 也是一个密集和晦涩的库。它是为数学深度理解的人设计和编写的。虽然有许多教程可用,你通常可以弄清楚如何使用它,但使用 NumPy 的代码通常很难理解。例如,要计算多个矩阵的平均值将涉及以下操作:

daily_totals = []
for totals in totals_to_average:
    daily_totals.append(totals)
average = numpy.mean(numpy.array(daily_totals), axis=0)

弄清楚最后一行的作用需要查阅 NumPy 文档。由于使用 NumPy 的代码的复杂性,这是一个使用包装模块的完美例子:包装模块可以为 NumPy 提供一个更易于使用的接口,这样你的代码就可以使用它,而不会被复杂和令人困惑的函数调用所淹没。

为了通过这个例子,我们将从安装 NumPy 库开始。NumPy (www.numpy.org) 可以在 Mac OS X、Windows 和 Linux 机器上运行。你安装它取决于你使用的操作系统:

  • 对于 Mac OS X,你可以从www.kyngchaos.com/software/python下载安装程序。

  • 对于 MS Windows,你可以从www.lfd.uci.edu/~gohlke/pythonlibs/#numpy下载 NumPy 的 Python“wheel”文件。选择与你的操作系统和所需的 Python 版本匹配的 NumPy 的预构建版本。要使用 wheel 文件,使用pip install命令,例如,pip install numpy-1.10.4+mkl-cp34-none-win32.whl

注意

有关安装 Python wheel 的更多信息,请参阅pip.pypa.io/en/latest/user_guide/#installing-from-wheels

  • 如果你的计算机运行 Linux,你可以使用你的 Linux 软件包管理器来安装 NumPy。或者,你可以下载并构建 NumPy 的源代码形式。

为了确保 NumPy 正常工作,启动你的 Python 解释器并输入以下内容:

import numpy
a = numpy.array([[1, 2], [3, 4]])
print(a)

一切顺利的话,你应该看到一个 2 x 2 的矩阵显示出来:

[[1 2]
 **[3 4]]

现在我们已经安装了 NumPy,让我们开始编写我们的包装模块。创建一个新的 Python 源文件,命名为numpy_wrapper.py,并输入以下内容到这个文件中:

import numpy

就这些了;我们将根据需要向这个包装模块添加函数。

接下来,创建另一个 Python 源文件,命名为detect_unusual_transfers.py,并输入以下内容到这个文件中:

import random
import numpy_wrapper as npw

BANK_CODES = ["AMERUS33", "CERYUS33", "EQTYUS44",
              "LOYDUS33", "SYNEUS44", "WFBIUS6S"]

BRANCH_IDS = ["125000249", "125000252", "125000371",
              "125000402", "125000596", "125001067"]

正如你所看到的,我们正在为我们的例子硬编码银行和分行代码;在一个真实的程序中,这些值将从某个地方加载,比如文件或数据库。由于我们没有可用的数据,我们将使用random模块来创建一些。我们还将更改numpy_wrapper模块的名称,以便更容易从我们的代码中访问。

现在让我们使用random模块创建一些要处理的资金转账数据:

days = [1, 2, 3, 4, 5, 6, 7, 8]
transfers = []

for i in range(10000):
    day       = random.choice(days)
    bank_code = random.choice(BANK_CODES)
    branch_id = random.choice(BRANCH_IDS)
    amount    = random.randint(1000, 1000000)

    transfers.append((day, bank_code, branch_id, amount))

在这里,我们随机选择一天、一个银行代码、一个分行 ID 和一个金额,将这些值存储在transfers列表中。

我们的下一个任务是将这些信息整理成一系列数组。这样可以让我们计算每天的转账总额,按分行 ID 和目标银行分组。为此,我们将为每一天创建一个 NumPy 数组,其中每个数组中的行代表分行,列代表目标银行。然后我们将逐个处理转账列表中的转账。以下插图总结了我们如何依次处理每笔转账:

Wrappers

首先,我们选择发生转账的那一天的数组,然后根据目标银行和分行 ID 选择适当的行和列。最后,我们将转账金额添加到当天数组中的那个项目中。

现在让我们实现这个逻辑。我们的第一个任务是创建一系列 NumPy 数组,每天一个。在这里,我们立即遇到了一个障碍:NumPy 有许多不同的选项用于创建数组;在这种情况下,我们想要创建一个保存整数值并且其内容初始化为零的数组。如果我们直接使用 NumPy,我们的代码将如下所示:

array = numpy.zeros((num_rows, num_cols), dtype=numpy.int32)

这并不是很容易理解,所以我们将这个逻辑移到我们的 NumPy 包装模块中。编辑numpy_wrapper.py文件,并在这个模块的末尾添加以下内容:

def new(num_rows, num_cols):
    return numpy.zeros((num_rows, num_cols), dtype=numpy.int32)

现在,我们可以通过调用我们的包装函数(npw.new())来创建一个新的数组,而不必担心 NumPy 的工作细节。我们已经简化了 NumPy 的特定方面的接口:

包装器

现在让我们使用我们的包装函数来创建我们需要的八个数组,每天一个。在detect_unusual_transfers.py文件的末尾添加以下内容:

transfers_by_day = {}
for day in days:
    transfers_by_day[day] = npw.new(num_rows=len(BANK_CODES),
                                    num_cols=len(BRANCH_IDS))

现在我们有了 NumPy 数组,我们可以像使用嵌套的 Python 列表一样使用它们。例如:

array[row][col] = array[row][col] + amount

我们只需要选择适当的数组,并计算要使用的行和列号。以下是必要的代码,你应该将其添加到你的detect_unusual_transfers.py脚本的末尾:

for day,bank_code,branch_id,amount in transfers:
    array = transfers_by_day[day]
    row = BRANCH_IDS.index(branch_id)
    col = BANK_CODES.index(bank_code)
    array[row][col] = array[row][col] + amount

现在我们已经将转账整理成了八个 NumPy 数组,我们希望使用所有这些数据来检测任何不寻常的活动。对于每个分行 ID 和目标银行代码的组合,我们需要做以下工作:

  1. 计算前七天活动的平均值。

  2. 将计算出的平均值乘以 1.5。

  3. 如果第八天的活动大于平均值乘以 1.5,那么我们认为这种活动是不寻常的。

当然,我们需要对我们的数组中的每一行和每一列都这样做,这将非常慢;这就是为什么我们使用 NumPy 的原因。因此,我们需要计算多个数字数组的平均值,然后将平均值数组乘以 1.5,最后,将乘以后的数组与第八天的数据数组进行比较。幸运的是,这些都是 NumPy 可以为我们做的事情。

我们将首先收集我们需要平均的七个数组,以及第八天的数组。为此,将以下内容添加到你的程序的末尾:

latest_day = max(days)

transfers_to_average = []
for day in days:
    if day != latest_day:
        transfers_to_average.append(transfers_by_day[day])

current = transfers_by_day[latest_day]

要计算一组数组的平均值,NumPy 要求我们使用以下函数调用:

average = numpy.mean(numpy.array(arrays_to_average), axis=0)

由于这很令人困惑,我们将把这个函数移到我们的包装器中。在numpy_wrapper.py模块的末尾添加以下代码:

def average(arrays_to_average):
    return numpy.mean(numpy.array(arrays_to_average), axis=0)

这让我们可以使用一个调用我们的包装函数来计算七天活动的平均值。为此,将以下内容添加到你的detect_unusual_transfers.py脚本的末尾:

average = npw.average(transfers_to_average)

正如你所看到的,使用包装器使我们的代码更容易理解。

我们的下一个任务是将计算出的平均值数组乘以 1.5,并将结果与当天的总数进行比较。幸运的是,NumPy 使这变得很容易:

unusual_transfers = current > average * 1.5

因为这段代码如此清晰,所以为它创建一个包装器函数没有任何优势。结果数组unusual_transfers的大小与我们的currentaverage数组相同,数组中的每个条目都是TrueFalse

包装器

我们几乎完成了;我们的最后任务是识别数组中值为True的条目,并告诉用户有不寻常的活动。虽然我们可以扫描每一行和每一列来找到True条目,但使用 NumPy 会快得多。以下的 NumPy 代码将给我们一个包含数组中True条目的行和列号的列表:

indices = numpy.transpose(array.nonzero())

不过,这段代码很难理解,所以它是另一个包装器函数的完美候选者。回到你的numpy_wrapper.py模块,并在文件末尾添加以下内容:

def get_indices(array):
    return numpy.transpose(array.nonzero())

这个函数返回一个列表(实际上是一个数组),其中包含数组中所有True条目的(行,列)值。回到我们的detect_unusual_activity.py文件,我们可以使用这个函数快速识别不寻常的活动:

    for row,col in npw.get_indices(unusual_transfers):
        branch_id   = BRANCH_IDS[row]
        bank_code   = BANK_CODES[col]
        average_amt = int(average[row][col])
        current_amt = current[row][col]

        print("Branch {} transferred ${:,d}".format(branch_id,
                                                    current_amt) +
              " to bank {}, average = ${:,d}".format(bank_code,
                                                     average_amt))

正如你所看到的,我们使用BRANCH_IDSBANK_CODES列表来将行和列号转换回相关的分行 ID 和银行代码。我们还检索了可疑活动的平均值和当前金额。最后,我们打印出这些信息,警告用户有不寻常的活动。

如果你运行你的程序,你应该会看到类似这样的输出:

Branch 125000371 transferred $24,729,847 to bank WFBIUS6S, average = $14,954,617
Branch 125000402 transferred $26,818,710 to bank CERYUS33, average = $16,338,043
Branch 125001067 transferred $27,081,511 to bank EQTYUS44, average = $17,763,644

因为我们在金融数据中使用随机数,所以输出也将是随机的。尝试运行程序几次;如果没有生成可疑的随机值,则可能根本没有输出。

当然,我们并不真正关心检测可疑的金融活动——这个例子只是一个借口,用来处理 NumPy。更有趣的是我们创建的包装模块,它隐藏了 NumPy 接口的复杂性,使得我们程序的其余部分可以集中精力完成工作。

如果我们继续开发我们的异常活动检测器,毫无疑问,我们会在numpy_wrapper.py模块中添加更多功能,因为我们发现了更多想要封装的 NumPy 函数。

这只是包装模块的一个例子。正如我们之前提到的,简化复杂和混乱的 API 只是包装模块的一个用途;它们还可以用于将数据从一种格式转换为另一种格式,向现有 API 添加测试和错误检查代码,并调用用其他语言编写的函数。

请注意,根据定义,包装器始终是的——虽然包装器中可能有代码(例如,将参数从对象转换为字典),但包装器函数最终总是调用另一个函数来执行实际工作。

可扩展模块

大多数情况下,模块提供的功能是预先知道的。模块的源代码实现了一组明确定义的行为,这就是模块的全部功能。然而,在某些情况下,您可能需要一个模块,在编写时模块的行为并不完全定义。系统的其他部分可以以各种方式扩展模块的行为。设计为可扩展的模块称为可扩展模块

Python 的一个伟大之处在于它是一种动态语言。您不需要在运行之前定义和编译所有代码。这使得使用 Python 创建可扩展模块变得很容易。

在本节中,我们将看一下模块可以被扩展的三种不同方式:通过使用动态导入,编写插件,以及使用钩子

动态导入

在上一章中,我们创建了一个名为renderers.py的模块,它选择了一个适当的渲染器模块,以使用给定的输出格式绘制图表元素。以下是该模块源代码的摘录:

from .png import title  as title_png
from .png import x_axis as x_axis_png

from .pdf import title  as title_pdf
from .pdf import x_axis as x_axis_pdf

renderers = {
    'png' : {
        'title'  : title_png,
        'x_axis' : x_axis_png,
    },
    'pdf' : {
        'title'  : title_pdf,
        'x_axis' : x_axis_pdf,
    }
}

def draw(format, element, chart, output):
    renderers[format][element].draw(chart, output)

这个模块很有趣,因为它以有限的方式实现了可扩展性的概念。请注意,renderer.draw()函数调用另一个模块内的draw()函数来执行实际工作;使用哪个模块取决于所需的图表格式和要绘制的元素。

这个模块并不真正可扩展,因为可能的模块列表是由模块顶部的import语句确定的。然而,可以通过使用importlib将其转换为完全可扩展的模块。这是 Python 标准库中的一个模块,它使开发人员可以访问用于导入模块的内部机制;使用importlib,您可以动态导入模块。

要理解这是如何工作的,让我们看一个例子。创建一个新的目录来保存您的源代码,在这个目录中,创建一个名为module_a.py的新模块。将以下代码输入到这个模块中:

def say_hello():
    print("Hello from module_a")

现在,创建一个名为module_b.py的此模块的副本,并编辑say_hello()函数以打印Hello from module_b。然后,重复这个过程来创建module_c.py

我们现在有三个模块,它们都实现了一个名为say_hello()的函数。现在,在同一个目录中创建另一个 Python 源文件,并将其命名为load_module.py。然后,输入以下内容到这个文件中:

import importlib

module_name = input("Load module: ")
if module_name != "":
    module = importlib.import_module(module_name)
    module.say_hello()

该程序提示用户使用input()语句输入一个字符串。然后,我们调用importlib.import_module()来导入具有该名称的模块,并调用该模块的say_hello()函数。

尝试运行这个程序,当提示时,输入module_a。你应该会看到以下消息显示:

Hello from module_a

尝试用其他模块重复这个过程。如果输入一个不存在的模块名称,你会得到一个ImportError

当然,importlib并不仅限于导入与当前模块相同目录中的模块;如果需要,你可以包括包名。例如:

module = importlib.import_module("package.sub_package.module")

使用importlib,你可以动态地导入一个模块——在编写程序时不需要知道模块的名称。我们可以使用这个来重写上一章的renderer.py模块,使其完全可扩展:

from importlib import import_module

def draw(format, element, chart, output):
    renderer = import_module("{}.{}.{}".format(__package__,
                                               format,
                                               element))
    renderer.draw(chart, output)

注意

注意到了特殊的__package__变量的使用。它保存了包含当前模块的包的名称;使用这个变量允许我们相对于renderer.py模块所属的包导入模块。

动态导入的好处是,在创建程序时不需要知道所有模块的名称。使用renderer.py的例子,你可以通过创建新的渲染器模块来添加新的图表格式或元素,系统将在请求时导入它们,而无需对renderer.py模块进行任何更改。

插件

插件是用户(或其他开发人员)编写并“插入”到你的程序中的模块。插件在许多大型系统中很受欢迎,如 WordPress、JQuery、Google Chrome 和 Adobe Photoshop。插件用于扩展现有程序的功能。

在 Python 中,使用我们在上一节讨论过的动态导入机制很容易实现插件。唯一的区别是,不是导入已经是程序源代码一部分的模块,而是设置一个单独的目录,用户可以将他们想要添加到程序中的插件放在其中。这可以简单地创建一个plugins目录在程序的顶层,或者你可以将插件存储在程序源代码之外的目录中,并修改sys.path以便 Python 解释器可以在该目录中找到模块。无论哪种方式,你的程序都将使用importlib.import_module()来加载所需的插件,然后像访问任何其他 Python 模块中的函数和其他定义一样访问插件中的函数和其他定义。

本章提供的示例代码包括一个简单的插件加载器,展示了这种机制的工作方式。

钩子

钩子是允许外部代码在程序的特定点被调用的一种方式。钩子通常是一个函数——你的程序会检查是否定义了一个钩子函数,如果是,就会在适当的时候调用这个函数。

让我们看一个具体的例子。假设你有一个程序,其中包括记录用户登录和退出的功能。你的程序的一部分可能包括以下模块,我们将其称为login_module.py

cur_user = None

def login(username, password):
    if is_password_correct(username, password):
        cur_user = username
        return True
    else:
        return False

def logout():
    cur_user = None

现在,想象一下,你想要添加一个钩子,每当用户登录时都会被调用。将这个功能添加到你的程序中将涉及对这个模块的以下更改:

cur_user = None
login_hook = None

def set_login_hook(hook):
 **login_hook = hook

def login(username, password):
    if is_password_correct(username, password):
        cur_user = username
 **if login_hook != None:
 **login_hook(username)
        return True
    else:
        return False

def logout():
    cur_user = None

有了这段代码,系统的其他部分可以通过设置自己的登录钩子函数来连接到你的登录过程,这样每当用户登录时就会执行一些操作。例如:

def my_login_hook(username):
    if user_has_messages(username):
        show_messages(username)

login_module.set_login_hook(my_login_hook)

通过实现这个登录钩子,你扩展了登录过程的行为,而不需要修改登录模块本身。

钩子有一些需要注意的事项:

  • 根据你为其实现钩子的行为,钩子函数返回的值可能会被用来改变你的代码的行为。例如,如果登录钩子返回False,用户可能会被阻止登录。这并不适用于每个钩子,但这是一个让钩子函数对程序中发生的事情有更多控制的非常有用的方式。

  • 在这个例子中,我们只允许为每个 hook 定义一个 hook 函数。另一种实现方式是拥有一个注册的 hook 函数列表,并让您的程序根据需要添加或删除 hook 函数。这样,您可以有几个 hook 函数,每当发生某些事情时依次调用它们。

Hooks 是向您的模块添加特定可扩展性点的绝佳方式。它们易于实现和使用,与动态导入和插件不同,它们不要求您将代码放入单独的模块中。这意味着 hooks 是以非常精细的方式扩展您的模块的理想方式。

总结

在本章中,我们看到模块和包的使用方式往往遵循标准模式。我们研究了分而治之的模式,这是将问题分解为较小部分的过程,并看到这种技术如何帮助构建程序结构并澄清您对要解决的问题的思考。

接下来,我们看了抽象模式,这是通过将您想要做的事情与如何做它分开来隐藏复杂性的过程。然后我们研究了封装的概念,即存储有关某些事物的数据,但隐藏该数据的表示方式的细节,使用 getter 和 setter 函数来访问该数据。

然后我们转向包装器的概念,并看到包装器如何用于简化复杂或令人困惑的 API 的接口,转换数据格式,实现跨语言兼容性,并向现有 API 添加测试和错误检查代码。

最后,我们了解了可扩展模块,并看到我们可以使用动态模块导入、插件和 hooks 的技术来创建一个模块,它可以做的不仅仅是您设计它要做的事情。我们看到 Python 的动态特性使其非常适合创建可扩展模块,其中您的模块的行为在编写时并不完全定义。

在下一章中,我们将学习如何设计和实现可以在其他程序中共享和重用的模块。

第六章:创建可重用模块

模块化编程不仅是一种为自己编写程序的好技术,也是一种为其他程序员编写的程序的绝佳方式。在本章中,我们将看看如何设计和实现可以在其他程序中共享和重用的模块和包。特别是,我们将:

  • 看看模块和包如何被用作分享您编写的代码的一种方式

  • 看看为重用编写模块与为作为一个程序的一部分使用编写模块有何不同

  • 发现什么使一个模块适合重用

  • 看一下成功可重用模块的例子

  • 设计一个可重用的包

  • 实现一个可重用的包

让我们首先看一下如何使用模块和包与其他人分享您的代码。

使用模块和包来分享你的代码

无论您编写的 Python 源代码是什么,您创建的代码都会执行某种任务。也许您的代码分析一些数据,将一些信息存储到文件中,或者提示用户从列表中选择一个项目。您的代码是什么并不重要——最终,您的代码会做某事

通常,这是非常具体的。例如,您可能有一个计算复利、生成维恩图或向用户显示警告消息的函数。一旦您编写了这段代码,您就可以在自己的程序中随时使用它。这就是前一章中描述的简单抽象模式:您将想要做什么如何做分开。

一旦您编写了函数,您就可以在需要执行该任务时调用它。例如,您可以在需要向用户显示警告时调用您的display_warning()函数,而不必担心警告是如何显示的细节。

然而,这个假设的display_warning()函数不仅在您当前编写的程序中有用。其他程序可能也想执行相同的任务——无论是您将来编写的程序还是其他人可能编写的程序。与其每次重新发明轮子,通常更有意义的是重用您的代码。

要重用您的代码,您必须分享它。有时,您可能会与自己分享代码,以便在不同的程序中使用它。在其他时候,您可能会与其他开发人员分享代码,以便他们在自己的程序中使用它。

当然,您不仅仅出于慈善目的与他人分享代码。在一个较大的组织中,您经常需要分享代码以提高同事的生产力。即使您是独自工作,通过使用其他人分享的代码,您也会受益,并且通过分享自己的代码,其他人可以帮助找到错误并解决您自己无法解决的问题。

无论您是与自己(在其他项目中)分享代码还是与他人(在您的组织或更广泛的开发社区中)分享代码,基本过程是相同的。有三种主要方式可以分享您的代码:

  1. 您可以创建一个代码片段,然后将其复制并粘贴到新程序中。代码片段可以存储在一个名为“代码片段管理器”的应用程序中,也可以存储在一个文本文件夹中,甚至可以作为博客的一部分发布。

  2. 您可以将要分享的代码放入一个模块或包中,然后将此模块或包导入新程序。该模块或包可以被物理复制到新程序的源代码中,可以放置在您的 Python 安装的site-packages目录中,或者您可以修改sys.path以包括可以找到模块或包的目录。

  3. 或者,您可以将您的代码转换为一个独立的程序,然后使用os.system()从其他代码中调用这个程序。

虽然所有这些选项都可以工作,但并非所有选项都是理想的。让我们更仔细地看看每一个:

  • 代码片段非常适合形成函数的代码的一部分。然而,它们非常糟糕,无法跟踪代码的最终位置。因为你已经将代码复制并粘贴到新程序的中间,所以很容易修改它,因为没有简单的方法可以区分粘贴的代码和你编写的程序的其余部分。此外,如果原始代码片段需要修改,例如修复错误,你将不得不找到在程序中使用代码片段的位置并更新以匹配。所有这些都相当混乱且容易出错。

  • 导入模块或包的技术具有与较大代码块很好地配合的优势。你要分享的代码可以包括多个函数,甚至可以使用 Python 包将其拆分成多个源文件。由于源代码存储在单独的文件中,你也不太可能意外修改导入的模块。

如果你已经将源模块或包复制到新程序中,那么如果原始模块发生更改,你将需要手动更新它。这并不理想,但由于你替换了整个文件,这并不太困难。另一方面,如果你的新程序使用存储在其他位置的模块,那么就没有需要更新的内容——对原始模块所做的任何更改将立即应用于使用该模块的任何程序。

  • 最后,将代码组织成独立的程序意味着你的新程序必须执行它。可以通过以下方式完成:
status = os.system("python other_program.py <params>")
if status != 0:
    print("The other_program failed!")

正如你所看到的,可以运行另一个 Python 程序,等待其完成,然后检查返回的状态码,以确保程序成功运行。如果需要,还可以向运行的程序传递参数。但是,你可以传递给程序和接收的信息非常有限。例如,如果你有一个解析 XML 文件并将该文件的摘要保存到磁盘上的不同文件的程序,这种方法将起作用,但你不能直接传递 Python 数据结构给另一个程序进行处理,也不能再次接收 Python 数据结构。

注意

实际上,可以在运行的程序之间传输 Python 数据结构,但涉及的过程非常复杂,不值得考虑。

正如你所看到的,代码片段、模块/包导入和独立程序形成一种连续体:代码片段非常小且细粒度,模块和包导入支持更大的代码块,同时仍然易于使用和更新,独立程序很大,但在与其交互的方式上受到限制。

在这三种方法中,使用模块和包导入来共享代码似乎是最合适的:它们可以用于大量代码,易于使用和交互,并且在必要时非常容易更新。这使得模块和包成为共享 Python 源代码的理想机制——无论是与自己共享,用于将来的项目,还是与其他人共享。

什么使模块可重用?

为了使模块或包可重用,它必须满足以下要求:

  • 它必须作为一个独立的单元运行

  • 如果你的包意图作为另一个系统的源代码的一部分被包含,你必须使用相对导入来加载包内的其他模块。

  • 任何外部依赖关系都必须明确说明

如果一个模块或包不满足这三个要求,要在其他程序中重用它将非常困难,甚至不可能。现在让我们依次更详细地看看这些要求。

作为独立单元运行

想象一下,你决定分享一个名为encryption的模块,它使用公钥/私钥对执行文本加密。然后,另一个程序员将此模块复制到他们的程序中。然而,当他们尝试使用它时,他们的程序崩溃,并显示以下错误消息:

ImportError: No module named 'hash_utils'

encryption模块可能已经被共享,但它依赖于原始程序中的另一个模块(hash_utils.py),而这个模块没有被共享,因此encryption模块本身是无用的。

解决这个问题的方法是将你想要共享的模块与它可能依赖的任何其他模块结合起来,将这些模块放在一个包中。然后共享这个包,而不是单独的模块。以下插图展示了如何做到这一点:

作为独立单元运行

在这个例子中,我们创建了一个名为encryptionlib的新包,并将encryption.pyhash_utils.py文件移动到了这个包中。当然,这需要你重构程序的其余部分,以适应这些模块的新位置,但这样做可以让你在其他程序中重用你的加密逻辑。

注意

虽然以这种方式重构你的程序可能有点麻烦,但结果几乎总是对原始程序的改进。将依赖模块放在一个包中有助于改善代码的整体组织。

使用相对导入

继续上一节的例子,想象一下你想要将你的新的encryptionlib包作为另一个程序的一部分,但不想将其作为单独的包公开。在这种情况下,你可以简单地将整个encryptionlib目录包含在你的新系统源代码中。然而,如果你的模块不使用相对导入,就会遇到问题。例如,如果你的encryption模块依赖于hash_utils模块,那么encryption模块将包含一个引用hash_utils模块的import语句。然而,如果encryption模块以以下任何一种方式导入hash_utils,则生成的包将无法重用:

import hash_utils
from my_program.lib import hash_utils
from hash_utils import *

所有这些导入语句都会失败,因为它们假设hash_utils.py文件在程序源代码中的特定固定位置。对于依赖模块在程序源代码中位置的任何假设都会限制包的可重用性,因为你不能将包移动到不同的位置并期望它能够工作。考虑到新项目的要求,你经常需要将包和模块存储在与它们最初开发的位置不同的地方。例如,也许encryptionlib包需要安装在thirdparty包中,与所有其他重用的库一起。使用绝对导入,你的包将失败,因为其中的模块位置已经改变。

注意

如果你发布你的包然后将其安装到 Python 的site-packages目录中,这个规则就不适用了。然而,有许多情况下你不想将可重用的包安装到site-packages目录中,因此你需要小心相对导入。

为了解决这个问题,请确保包内的任何import语句引用同一包内的其他模块时始终使用相对导入。例如:

from . import hash_utils

这将使你的包能够在 Python 源树的任何位置运行。

注意外部依赖

想象一下,我们的新的encryptionlib包利用了我们在上一章中遇到的NumPy库。也许hash_utils导入了一些来自 NumPy 的函数,并使用它们来快速计算数字列表的二进制哈希。即使 NumPy 作为原始程序的一部分安装了,你也不能假设新程序也是如此:如果你将encryptionlib包安装到一个新程序中并运行它,最终会出现以下错误:

ImportError: No module named 'numpy'

为了防止发生这种情况,重要的是任何想要重用您的模块的人都知道对第三方模块的依赖,并且清楚地知道为了使您的模块或软件包正常运行需要安装什么。包含这些信息的理想位置是您共享的模块或软件包的README文件或其他文档。

注意

如果您使用诸如 setuptools 或 pip 之类的自动部署系统,这些工具有其自己的方式来识别您的软件包的要求。然而,将要求列在文档中仍然是一个好主意,这样您的用户在安装软件包之前就会意识到这些要求。

什么是一个好的可重用模块?

在前一节中,我们看了可重用模块的最低要求。现在让我们来看看可重用性的理想要求。一个完美的可重用模块会是什么样子?

优秀的可重用模块与糟糕的模块有三个区别:

  • 它试图解决一个一般性问题(或一系列问题),而不仅仅是执行一个特定的任务

  • 它遵循标准约定,使得在其他地方使用模块更容易

  • 该模块有清晰的文档,以便其他人可以轻松理解和使用它

让我们更仔细地看看这些要点。

解决一个一般性问题

通常在编程时,您会发现自己需要执行特定的任务,因此编写一个函数来执行此任务。例如,考虑以下情况:

  • 您需要将英寸转换为厘米,因此编写一个inch_to_cm()函数来执行此任务。

  • 您需要从文本文件中读取地名列表,该文件使用垂直条字符(|)作为字段之间的分隔符:

FEATURE_ID|FEATURE_NAME|FEATURE_CLASS|...
1397658|Ester|Populated Place|...
1397926|Afognak|Populated Place|...

为此,您创建一个load_placenames()函数,从该文件中读取数据。

  • 您需要向用户显示客户数量:
1 customer
8 customers

消息使用customer还是customers取决于提供的数量。为了处理这个问题,您创建一个pluralize_customers()函数,根据提供的数量返回相应的复数形式的消息。

在所有这些例子中,您都在解决一个具体的问题。很多时候,这样的函数最终会成为一个模块的一部分,您可能希望重用或与他人分享。然而,这三个函数inch_to_cm()load_placenames()pluralize_customers()都非常特定于您尝试解决的问题,因此对新程序的适用性有限。这三个函数都迫切需要更加通用化:

  • 不要编写inch_to_cm()函数,而是编写一个将任何英制距离转换为公制的函数,然后创建另一个函数来执行相反的操作。

  • 不要编写一个仅加载地名的函数,而是实现一个load_delimited_text()函数,该函数适用于任何类型的分隔文本文件,并且不假定特定的列名或分隔符是垂直条字符。

  • 不要仅仅将客户名称变为复数形式,而是编写一个更通用的pluralize()函数,该函数将为程序中可能需要的所有名称变为复数形式。由于英语的种种变化,您不能仅仅假定所有名称都可以通过在末尾添加s来变为复数形式;您需要一个包含人/人们、轴/轴等的例外词典,以便该函数可以处理各种类型的名称。为了使这个函数更加有用,您可以选择接受名称的复数形式,如果它不知道您要变为复数的单位类型的话:

def pluralize(n, singular_name, plural_name=None):

尽管这只是三个具体的例子,但您可以看到,通过将您共享的代码泛化,可以使其适用于更广泛的任务。通常,泛化函数所需的工作量很少,但结果将受到使用您创建的代码的人们的极大赞赏。

遵循标准约定

虽然你可以按照自己的喜好编写代码,但如果你想与他人分享你的代码,遵循标准的编码约定是有意义的。这样可以使其他人在不必记住你的库特定风格的情况下更容易使用你的代码。

举个实际的例子,考虑以下代码片段:

shapefile = ogr.Open("...")
layer = shapefile.GetLayer(0)
for i in range(layer.GetFeatureCount()):
  feature = layer.GetFeature(i)
  shape = shapely.loads(feature.GetGeometryRef().ExportToWkt())
  if shape.contains(target_zone):
    ...

这段代码利用了两个库:Shapely 库,用于执行计算几何,以及 OGR 库,用于读写地理空间数据。Shapely 库遵循使用小写字母命名函数和方法的标准 Python 约定:

shapely.loads(...)
shape.contains(...)

虽然这些库的细节相当复杂,但这些函数和方法的命名易于记忆和使用。然而,与之相比,OGR 库将每个函数和方法的第一个字母大写:

ogr.Open(...)
layer.GetFeatureCount()

使用这两个库时,你必须不断地记住 OGR 将每个函数和方法的第一个字母大写,而 Shapely 则不会。这使得使用 OGR 比必要更加麻烦,并导致生成的代码中出现相当多的错误,需要进行修复。

如果 OGR 库简单地遵循了与 Shapely 相同的命名约定,所有这些问题都可以避免。

幸运的是,对于 Python 来说,有一份名为Python 风格指南www.python.org/dev/peps/pep-0008/)的文件,提供了一套清晰的建议,用于格式化和设计你的代码。函数和方法名称使用小写字母的惯例来自于这份指南,大多数 Python 代码也遵循这个指南。从如何命名变量到何时在括号周围放置空格,这份文件中都有描述。

虽然编码约定是个人偏好的问题,你当然不必盲目遵循 Python 风格指南中的指示,但这样做(至少在影响你的代码用户方面)将使其他人更容易使用你的可重用模块和包——就像 OGR 库的例子一样,你不希望用户在想要导入和使用你的代码时不断记住一个不寻常的命名风格。

清晰的文档

即使你编写了完美的模块,解决了一系列通用问题,并忠实地遵循了 Python 风格指南,如果没有人知道如何使用它,你的模块也是无用的。不幸的是,作为程序员,我们经常对我们的代码太过了解:我们很清楚我们的代码是如何工作的,所以我们陷入了假设其他人也应该很清楚的陷阱。此外,程序员通常讨厌编写文档——我们更愿意编写一千行精心编写的 Python 代码,而不是写一段描述它如何工作的话。因此,我们共享的代码的文档通常是勉强写的,甚至根本不写。

问题是,高质量的可重用模块或包将始终包括文档。这份文档将解释模块的功能和工作原理,并包括示例,以便读者可以立即看到如何在他们自己的程序中使用这个模块或包。

对于一个出色文档化的 Python 模块或包的例子,我们无需去看Python 标准库docs.python.org/3/library/)之外的地方。每个模块都有清晰的文档,包括详细的信息和示例,以帮助程序员进行指导。例如,以下是datetime.timedelta类的文档的简化版本:

清晰的文档

每个模块、类、函数和方法都有清晰的文档,包括示例和详细的注释,以帮助这个模块的用户。

作为可重用模块的开发人员,您不必达到这些高度。Python 标准库是一个庞大的协作努力,没有一个人编写了所有这些文档。但这是您应该追求的文档类型的一个很好的例子:包含大量示例的全面文档。

虽然您可以在文字处理器中创建文档,或者使用类似 Sphinx 系统的复杂文档生成系统来构建 Python 文档,但有两种非常简单的方法可以在最少的麻烦下编写文档:创建 README 文件或使用文档字符串。

README文件只是一个文本文件,它与组成您的模块或包的各种源文件一起包含在内。它通常被命名为README.txt,它只是一个普通的文本文件。您可以使用用于编辑 Python 源代码的相同编辑器创建此文件。

README 文件可以是尽可能广泛或最小化的。通常有助于包括有关如何安装和使用模块的信息,任何许可问题,一些使用示例以及如果您的模块或包包含来自他人的代码,则包括致谢。

文档字符串是附加到模块或函数的 Python 字符串。这专门用于文档目的,有一个非常特殊的 Python 语法用于创建文档字符串:

""" my_module.py

    This is the documentation for the my_module module.
"""
def my_function():
    """ This is the documentation for the my_function() function.

        As you can see, the documentation can span more than
        one line.
    """
    ...

在 Python 中,您可以使用三个引号字符标记跨越 Python 源文件的多行的字符串。这些三引号字符串可以用于各种地方,包括文档字符串。如果一个模块以三引号字符串开头,那么这个字符串将用作整个模块的文档。同样,如果任何函数以三引号字符串开头,那么这个字符串将用作该函数的文档。

注意

同样适用于 Python 中的其他定义,例如类、方法等。

文档字符串通常用于描述模块或函数的功能,所需的参数以及返回的信息。还应包括模块或函数的任何值得注意的方面,例如意外的副作用、使用示例等。

文档字符串(和 README 文件)不必非常广泛。您不希望花费数小时来撰写关于模块中只有三个人可能会使用的某个晦涩函数的文档。但是写得很好的文档字符串和 README 文件是出色且易于使用的模块或包的标志。

撰写文档是一种技能;像所有技能一样,通过实践可以变得更好。要创建可以共享的高质量模块和包,您应该养成创建文档字符串和 README 文件的习惯,以及遵循编码约定并尽可能地泛化您的代码,正如我们在本章的前几节中所描述的那样。如果您的目标是从一开始就产生高质量的可重用代码,您会发现这并不难。

可重用模块的示例

您不必走得很远才能找到可重用模块的示例;Python 包索引pypi.python.org/pypi)提供了一个庞大的共享模块和包的存储库。您可以按名称或关键字搜索包,也可以按主题、许可证、预期受众、开发状态等浏览存储库。

Python 包索引非常庞大,但也非常有用:所有最成功的包和模块都包含在其中。让我们更仔细地看一些更受欢迎的可重用包。

requests

requests库(docs.python-requests.org/en/master/)是一个 Python 包,它可以轻松地向远程服务器发送 HTTP 请求并处理响应。虽然 Python 标准库中包含的urllib2包允许您发出 HTTP 请求,但往往难以使用并以意想不到的方式失败。requests包更容易使用和更可靠;因此,它变得非常受欢迎。

以下示例代码显示了requests库如何允许您发送复杂的 HTTP 请求并轻松处理响应:

import requests

response = requests.post("http://server.com/api/login",
                         {'username' : username,
                          'password' : password})
if response.status_code == 200: # OK
    user = response.json()
    if user['logged_in']:
        ...

requests库会自动对要发送到服务器的参数进行编码,优雅地处理超时,并轻松检索 JSON 格式的响应。

requests库非常容易安装(在大多数情况下,您可以简单地使用 pip install requests)。它有很好的文档,包括用户指南、社区指南和详细的 API 文档,并且完全符合 Python 样式指南。它还提供了一套非常通用的功能,通过 HTTP 协议处理与外部网站和系统的各种通信。有了这些优点,难怪requests是整个 Python 包索引中第三受欢迎的包。

python-dateutil

dateutil包(github.com/dateutil/dateutil)扩展了 Python 标准库中包含的datetime包,添加了对重复日期、时区、复杂相对日期等的支持。

以下示例代码计算复活节星期五的日期,比我们在上一章中用于快乐时光计算的形式要简单得多:

from dateutil.easter import easter
easter_friday = easter(today.year) - datetime.timedelta(days=2)

dateutil提供了大量示例的优秀文档,使用pip install python-dateutil很容易安装,遵循 Python 样式指南,对解决各种与日期和时间相关的挑战非常有用。它是 Python 包索引中另一个成功和受欢迎的包的例子。

lxml

lxml工具包(lxml.de)是一个非常成功的 Python 包的例子,它作为两个现有的 C 库的包装器。正如其写得很好的网站所说,lxml简化了读取和写入 XML 和 HTML 格式文档的过程。它是在 Python 标准库中现有库(ElementTree)的基础上建模的,但速度更快,功能更多,并且不会以意想不到的方式崩溃。

以下示例代码显示了如何使用lxml快速生成 XML 格式数据:

from lxml import etree

movies = etree.Element("movie")
movie = etree.SubElement(movies, "movie")
movie.text = "The Wizard of Oz"
movie.set("year", "1939")

movie = etree.SubElement(movies, "movie")
movie.text = "Mary Poppins"
movie.set("year", "1964")

movie = etree.SubElement(movies, "movie")
movie.text = "Chinatown"
movie.set("year", "1974")

print(etree.tostring(movies, pretty_print=True))

这将打印出一个包含三部经典电影信息的 XML 格式文档:

<movie>
 **<movie year="1939">The Wizard of Oz</movie>
 **<movie year="1964">Mary Poppins</movie>
 **<movie year="1974">Chinatown</movie>
</movie>

当然,lxml可以做的远不止这个简单的示例所展示的。它可以用于解析文档以及以编程方式生成庞大而复杂的 XML 文件。

lxml网站包括优秀的文档,包括教程、如何安装包以及完整的 API 参考。对于它解决的特定任务,lxml非常吸引人且易于使用。难怪这是 Python 包索引中非常受欢迎的包。

设计可重用的包

现在让我们将学到的知识应用到一个有用的 Python 包的设计和实现中。在上一章中,我们讨论了使用 Python 模块封装食谱的概念。每个食谱的一部分是成分的概念,它有三个部分:

  • 成分的名称

  • 成分所需的数量

  • 成分的计量单位

如果我们想要处理成分,我们需要能够正确处理单位。例如,将 1.5 千克加上 750 克不仅仅是加上数字 1.5 和 750——您必须知道如何将这些值从一个单位转换为另一个单位。

在食谱的情况下,有一些相当不寻常的转换需要我们支持。例如,你知道三茶匙的糖等于一汤匙的糖吗?为了处理这些类型的转换,让我们编写一个单位转换库。

我们的单位转换器将需要了解烹饪中使用的所有标准单位。这些包括杯、汤匙、茶匙、克、盎司、磅等。我们的单位转换器将需要一种表示数量的方式,比如 1.5 千克,并且能够将数量从一种单位转换为另一种单位。

除了表示和转换数量,我们希望我们的图书馆能够显示数量,自动使用适当的单位名称的单数或复数形式,例如,6 杯1 加仑150 克等。

由于我们正在显示数量,如果我们的图书馆能够解析数量,将会很有帮助。这样,用户就可以输入像3 汤匙这样的值,我们的图书馆就会知道用户输入了三汤匙的数量。

我们越想这个图书馆,它似乎越像一个有用的工具。我们是在考虑我们的处理食谱程序时想到的这个,但似乎这可能是一个理想的可重用模块或包的候选者。

根据我们之前看过的指南,让我们考虑如何尽可能地概括我们的图书馆,使其在其他程序和其他程序员中更有用。

与其只考虑在食谱中可能找到的各种数量,不如改变我们的图书馆的范围,以处理任何类型的数量。它可以处理重量、长度、面积、体积,甚至可能处理时间、力量、速度等单位。

这样想,我们的图书馆不仅仅是一个单位转换器,而是一个处理数量的图书馆。数量是一个数字及其相关的单位,例如,150 毫米,1.5 盎司,或 5 英亩。我们将称之为 Quantities 的图书馆将是一个用于解析、显示和创建数量的工具,以及将数量从一种单位转换为另一种单位。正如你所看到的,我们对图书馆的最初概念现在只是图书馆将能够做的事情之一。

现在让我们更详细地设计我们的 Quantities 图书馆。我们希望我们的图书馆的用户能够很容易地创建一个新的数量。例如:

q = quantities.new(5, "kilograms")

我们还希望能够将字符串解析为数量值,就像这样:

q = quantities.parse("3 tbsp")

然后我们希望能够以以下方式显示数量:

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.59147059125 liters

最后,我们希望能够获得我们的图书馆支持的所有单位种类的列表以及每种单位的个体单位:

>>> for kind in quantities.supported_kinds():
>>>     for unit in quantities.supported_units(kind):
>>>         print(kind, unit)
weight gram
weight kilogram
weight ounce
weight pound
length millimeter
...

我们的 Quantities 图书馆还需要支持一个最终功能:本地化单位和数量的能力。不幸的是,某些数量的转换值会根据你是在美国还是其他地方而有所不同。例如,在美国,一茶匙的体积约为 4.93 立方厘米,而在世界其他地方,一茶匙被认为有 5 立方厘米的体积。还有命名约定要处理:在美国,米制系统的基本长度单位被称为,而在世界其他地方,同样的单位被拼写为metre。我们的单位将不得不处理不同的转换值和不同的命名约定。

为了做到这一点,我们需要支持区域设置的概念。当我们的图书馆被初始化时,调用者将指定我们的模块应该在哪个区域下运行:

quantities.init("international")

这将影响库使用的转换值和拼写:

鉴于我们 Quantities 库的复杂性,试图把所有这些内容都挤入一个单独的模块是没有意义的。相反,我们将把我们的库分成三个单独的模块:一个units模块,定义我们支持的所有不同类型的单位,一个interface模块,实现我们包的各种公共函数,以及一个quantity模块,封装了数量作为值及其相关单位的概念。

这三个模块将合并为一个名为quantities的单个 Python 包。

注意

请注意,我们在设计时故意使用术语来指代系统;这确保我们没有通过将其视为单个模块或包来预先设计。现在才清楚我们将要编写一个 Python 包。通常,你认为是模块的东西最终会变成一个包。偶尔也会发生相反的情况。对此要保持灵活。

现在我们对 Quantities 库有了一个很好的设计,知道它将做什么,以及我们想要如何构建它,让我们开始写一些代码。

实现可重用的包

提示

本节包含大量源代码。请记住,你不必手动输入所有内容;本章的示例代码中提供了quantities包的完整副本,可以下载。

首先创建一个名为quantities的目录来保存我们的新包。在这个目录中,创建一个名为quantity.py的新文件。这个模块将保存我们对数量的实现,即值和其相关单位。

虽然你不需要理解面向对象的编程技术来阅读本书,但这是我们需要使用面向对象编程的地方。这是因为我们希望用户能够直接打印一个数量,而在 Python 中唯一的方法就是使用对象。不过别担心,这段代码非常简单,我们会一步一步来。

quantity.py模块中,输入以下 Python 代码:

class Quantity(object):
    def __init__(self, value, units):
        self.value = value
        self.units = units

我们在这里做的是定义一个称为Quantity的新对象类型。第二行看起来非常像一个函数定义,只是我们正在定义一种特殊类型的函数,称为方法,并给它一个特殊的名称__init__。当创建新对象时,这个方法用于初始化新对象。self参数指的是正在创建的对象;正如你所看到的,我们的__init__函数接受两个额外的参数,命名为valueunits,并将这两个值存储到self.valueself.units中。

有了我们定义的新Quantity对象,我们可以创建新对象并检索它们的值。例如:

q = Quantity(1, "inch")
print(q.value, q.units)

第一行使用Quantity类创建一个新对象,为value参数传递1,为units参数传递"inch"。然后__init__方法将这些存储在对象的valueunits属性中。正如你在第二行看到的,当我们需要时很容易检索这些属性。

我们几乎完成了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)
1 inch

Python 的print()函数调用特别命名的__str__方法来获取要显示的数量的文本。我们的__str__方法返回值和单位,用一个空格分隔,这样可以得到一个格式良好的数量摘要。

这完成了我们的quantity.py模块。正如您所看到的,使用对象并不像看起来那么困难。

我们的下一个任务是收集关于我们的包将支持的各种单位的存储信息。因为这里有很多信息,我们将把它放入一个单独的模块中,我们将称之为units.py

在您的quantities包中创建units.py模块,并首先输入以下内容到这个文件中:

UNITS = {}

UNITS字典将把单位类型映射到该类型定义的单位列表。例如,所有长度单位将放入UNITS['length']列表中。

对于每个单位,我们将以字典的形式存储关于该单位的信息,具有以下条目:

字典条目 描述
name 此单位的名称,例如,inch
abbreviation 此单位的官方缩写,例如,in
plural 此单位的复数名称。当有多个此单位时使用的名称,例如,inches
num_units 在这些单位和同类型的其他单位之间进行转换所需的单位数量。例如,如果centimeter单位的num_units值为1,那么inch单位的num_units值将为2.54,因为 1 英寸等于 2.54 厘米。

正如我们在前一节中讨论的,我们需要能够本地化我们的各种单位和数量。为此,所有这些字典条目都可以有单个值或将每个语言环境映射到一个值的字典。例如,liter单位可以使用以下 Python 字典来定义:

{'name' : {'us'            : "liter",
           'international' : "litre"},
 'plural' : {'us'            : "liters",
             'international' : "litres"},
 'abbreviation' : "l",
 'num_units' : 1000}

这允许我们在不同的语言环境中拥有不同的liter拼写。其他单位可能会有不同数量的单位或不同的缩写,这取决于所选择的语言环境。

现在我们知道了如何存储各种单位定义,让我们实现units.py模块的下一部分。为了避免重复输入大量单位字典,我们将创建一些辅助函数。在您的模块末尾添加以下内容:

def by_locale(value_for_us, value_for_international):
    return {"us"            : value_for_us,
            "international" : value_for_international}

此函数将返回一个将usinternational语言环境映射到给定值的字典,使得创建一个特定语言环境的字典条目更容易。

接下来,在您的模块中添加以下函数:

def unit(*args):
    if len(args) == 3:
        abbreviation = args[0]
        name         = args[1]

        if isinstance(name, dict):
            plural = {}
            for key,value in name.items():
                plural[key] = value + "s"
        else:
            plural = name + "s"

        num_units = args[2]
    elif len(args) == 4:
        abbreviation = args[0]
        name         = args[1]
        plural       = args[2]
        num_units    = args[3]
    else:
        raise RuntimeError("Bad arguments to unit(): {}".format(args))

    return {'abbreviation' : abbreviation,
            'name'         : name,
            'plural'       : plural,
            'num_units'    : num_units}

这个看起来复杂的函数为单个单位创建了字典条目。它使用特殊的*args参数形式来接受可变数量的参数;调用者可以提供缩写、名称和单位数量,或者提供缩写、名称、复数名称和单位数量。如果没有提供复数名称,它将通过在单位的单数名称末尾添加s来自动计算。

请注意,这里的逻辑允许名称可能是一个区域特定名称的字典;如果名称是本地化的,那么复数名称也将根据区域逐个地计算。

最后,我们定义一个简单的辅助函数,使一次性定义一个单位列表变得更容易:

def units(kind, *units_to_add):
    if kind not in UNITS:
        UNITS[kind] = []

    for unit in units_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()函数基于用户当前的语言环境创建了单位名称和复数名称的不同版本。我们还为inchfoot单位提供了复数名称,因为这些名称不能通过在名称的单数版本后添加s来计算。

现在让我们添加一些基于面积的单位:

units("area",
      unit("sq m", by_locale("square meter", "square metre"), 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模块的末尾添加两个额外的函数。首先是一个函数,用于选择单位字典中值的适当本地化版本:

def localize(value, locale):
    if isinstance(value, dict):
        return value.get(locale)
    else:
        return value

如您所见,我们检查value是否为字典;如果是,则返回提供的locale的字典中的条目。否则,直接返回value。每当我们需要从单位的字典中检索名称、复数名称、缩写或值时,我们将使用此函数。

我们接下来需要的第二个函数是一个函数,用于搜索存储在UNITS全局变量中的各种单位。我们希望能够根据其单数或复数名称或缩写找到单位,允许拼写特定于当前区域。为此,在units.py模块的末尾添加以下代码:

def find_unit(s, locale):
    s = s.lower()
    for kind in UNITS.keys():
        for unit in UNITS[kind]:
            if (s == localize(unit['abbreviation'],
                              locale).lower() or
                s == localize(unit['name'],
                              locale).lower() or
                s == localize(unit['plural'],
                              locale).lower()):
                # Success!
                return (kind, unit)

    return (None, None) # Not found.

请注意,我们在检查之前使用s.lower()将字符串转换为小写。这确保我们可以找到inch单位,例如,即使用户将其拼写为InchINCH。完成后,我们的find_units()函数将返回找到的单位的种类和单位字典,或者(None,None)如果找不到单位。

这完成了units.py模块。现在让我们创建interface.py模块,它将保存我们quantities包的公共接口。

提示

我们可以直接将所有这些代码放入包初始化文件__init__.py中,但这可能会有点令人困惑,因为许多程序员不希望在__init__.py文件中找到代码。相反,我们将在interface.py模块中定义所有公共函数,并将该模块的内容导入__init__.py中。

创建interface.py模块,将其放置到units.pyquantities.py旁边的quantities包目录中。然后,在该模块的顶部添加以下import语句:

from .units import UNITS, localize, find_unit
from .quantity import Quantity

如您所见,我们使用相对导入语句从units.py模块加载UNITS全局变量以及localize()find_unit()函数。然后,我们使用另一个相对导入来加载我们在quantity.py模块中定义的Quantity类。这使得这些重要的函数、类和变量可供我们的代码使用。

现在我们需要实现本章前面识别出的各种函数。我们将从init()开始,该函数初始化整个quantities包。将以下内容添加到您的interface.py模块的末尾:

def init(locale):
    global _locale
    _locale = locale

调用者将提供区域的名称(应为包含usinternational的字符串,因为这是我们支持的两个区域),我们将其存储到名为_locale的私有全局变量中。

我们要实现的下一个函数是new()。这允许用户通过提供值和所需单位的名称来定义新的数量。我们将使用find_unit()函数来确保单位存在,然后创建并返回一个新的带有提供的值和单位的Quantity对象:

def new(value, units):
    global _locale
    kind,unit = find_unit(units, _locale)
    if kind == None:
        raise ValueError("Unknown unit: {}".format(units))

    return Quantity(value, localize(unit['name'], _locale))

因为单位的名称可能会根据区域而变化,我们使用_locale私有全局变量来帮助找到具有提供的名称、复数名称或缩写的单位。找到单位后,我们使用该单位的官方名称创建一个新的Quantity对象,然后将其返回给调用者。

除了通过提供值和单位来创建一个新的数量之外,我们还需要实现一个parse()函数,将一个字符串转换为Quantity对象。现在让我们来做这个:

def parse(s):
    global _locale

    sValue,sUnits = s.split(" ", maxsplit=1)
    value = float(sValue)

    kind,unit = find_unit(sUnits, _locale)
    if kind == None:
        raise ValueError("Unknown unit: {}".format(sUnits))

    return Quantity(value, localize(unit['name'], _locale))

我们在第一个空格处拆分字符串,将第一部分转换为浮点数,并搜索一个名称或缩写等于字符串第二部分的单位。

接下来,我们需要编写一些函数来返回有关数量的信息。让我们通过在您的interface.py模块的末尾添加以下代码来实现这些函数:

def kind(q):
    global _locale
    kind,unit = find_unit(q.units, _locale)
    return kind

def value(q):
    return q.value

def units(q):
    return q.units

这些函数允许我们的包的用户识别与给定数量相关的单位种类(例如长度、重量或体积),并检索数量的值和单位。

注意

请注意,用户也可以通过直接访问Quantity对象内的属性来检索这两个值,例如print(q.value)。我们无法阻止用户这样做,但是因为我们没有将其实现为面向对象的包,所以我们不想鼓励这样做。

我们已经快完成了。我们的下一个函数将把一个单位转换为另一个单位,如果转换不可能则返回ValueError。以下是执行此操作所需的代码:

def convert(q, units):
    global _locale

    src_kind,src_units = find_unit(q.units, _locale)
    dst_kind,dst_units = find_unit(units, _locale)

    if src_kind == None:
        raise ValueError("Unknown units: {}".format(q.units))
    if dst_kind == None:
        raise ValueError("Unknown units: {}".format(units))

    if src_kind != dst_kind:
        raise ValueError(
                "It's impossible to convert {} into {}!".format(
                      localize(src_units['plural'], _locale),
                      localize(dst_units['plural'], _locale)))

    num_units = (q.value * src_units['num_units'] /
                 dst_units['num_units'])
    return Quantity(num_units, localize(dst_units['name'],
                                        _locale))

我们需要实现的最后两个函数返回我们支持的不同单位种类的列表和给定种类的各个单位的列表。以下是我们interface.py模块的最后两个函数:

def supported_kinds():
    return list(UNITS.keys())

def supported_units(kind):
    global _locale

    units = []
    for unit in UNITS.get(kind, []):
        units.append(localize(unit['name'], _locale))
    return units

现在我们已经完成了interface.py模块的实现,只剩下最后一件事要做:为我们的quantities包创建包初始化文件__init__.py,并将以下内容输入到此文件中:

from .interface import *

这使得我们在interface.py模块中定义的所有函数都可以供我们包的用户使用。

测试我们可重用的包

现在我们已经编写了代码(或者下载了代码),让我们来看看这个包是如何工作的。在终端窗口中,将当前目录设置为包含您的quantities包目录的文件夹,并键入python以启动 Python 解释器。然后,输入以下内容:

>>> import quantities

如果您在输入源代码时没有犯任何错误,解释器应该会在没有任何错误的情况下返回。如果您有任何拼写错误,您需要在继续之前先修复它们。

接下来,我们必须通过提供我们想要使用的区域设置来初始化我们的quantities包:

>>> quantities.init("international")

如果你在美国,可以随意将值international替换为us,这样你就可以获得本地化的拼写和单位。

让我们创建一个简单的数量,然后要求 Python 解释器显示它:

>>> q = quantities.new(24, "km")
>>>> print(q)
24 kilometre

正如你所看到的,国际拼写单词kilometer会自动使用。

让我们尝试将这个单位转换成英寸:

>>> print(quantities.convert(q, "inch"))
944881.8897637795 inch

还有其他函数我们还没有测试,但我们已经可以看到我们的quantities包解决了一个非常普遍的问题,符合 Python 风格指南,并且易于使用。它还不是一个完全理想的可重用模块,但已经很接近了。以下是我们可以做的一些事情来改进它:

  • 重新构建我们的包以更符合面向对象的方式。例如,用户可以简单地说q.convert("inch"),而不是调用quantities.convert(q, "inch")

  • 改进__str__()函数的实现,以便在值大于 1 时将单位名称显示为复数。此外,更改代码以避免浮点舍入问题,这可能会在打印出某些数量值时产生奇怪的结果。

  • 添加函数(或方法)来添加、减去、乘以和除以数量。

  • 为我们的包源代码添加文档字符串,然后使用诸如Sphinxwww.sphinx-doc.org)之类的工具将文档字符串转换为我们包的 API 文档。

  • quantities包的源代码上传到GitHubgithub.com)以便更容易获取。

  • 创建一个网站(可能是作为 GitHub 存储库中的简单 README 文件),以便人们可以了解更多关于这个包的信息。

  • 将包提交到 PyPI,以便人们可以找到它。

如果你愿意,可以随意扩展quantities包并提交它;这只是本书的一个例子,但它确实有潜力成为一个通用(和流行的)可重用的 Python 包。

摘要

在本章中,我们讨论了可重用模块或包的概念。我们看到可重用的包和模块如何用于与其他人共享代码。我们了解到,可重用的模块或包需要作为一个独立的单元进行操作,最好使用相对导入,并应注意它可能具有的任何外部依赖关系。理想情况下,可重用的包或模块还将解决一个通用问题而不是特定问题,遵循标准的 Python 编码约定,并具有良好的文档。然后,我们看了一些好的可重用模块的例子,然后编写了我们自己的模块。

在下一章中,我们将看一些更高级的内容,涉及在 Python 中使用模块和包的工作。

第七章:高级模块技术

在本章中,我们将研究一些更高级的模块和包的工作技术。特别是,我们将:

  • 检查import语句可以使用的更不寻常的方式,包括可选导入、本地导入,以及通过更改sys.path来调整导入工作方式的方法

  • 简要检查与导入模块和包相关的一些“陷阱”

  • 看看如何使用 Python 交互解释器来帮助更快地开发你的模块和包

  • 学习如何在模块或包内使用全局变量

  • 看看如何配置一个包

  • 了解如何将数据文件包含为 Python 包的一部分。

可选导入

尝试打开 Python 交互解释器并输入以下命令:

import nonexistent_module

解释器将返回以下错误消息:

ImportError: No module named 'nonexistent_module'

这对你来说不应该是个惊喜;如果在import语句中打错字,甚至可能在你自己的程序中看到这个错误。

这个错误的有趣之处在于它不仅适用于你打错字的情况。你也可以用它来测试这台计算机上是否有某个模块或包,例如:

try:
    import numpy
    has_numpy = True
except ImportError:
    has_numpy = False

然后可以使用这个来让你的程序利用模块(如果存在),或者如果模块或包不可用,则执行其他操作,就像这样:

if has_numpy:
    array = numpy.zeros((num_rows, num_cols), dtype=numpy.int32)
else:
    array = []
    for row in num_rows:
        array.append([])

在这个例子中,我们检查numpy库是否已安装,如果是,则使用numpy.zeros()创建一个二维数组。否则,我们使用一个列表的列表。这样,你的程序可以利用 NumPy 库的速度(如果已安装),同时如果这个库不可用,仍然可以工作(尽管速度较慢)。

注意

请注意,这个例子只是虚构的;你可能无法直接使用一个列表的列表而不是 NumPy 数组,并且在不做任何更改的情况下使你的程序的其余部分工作。但是,如果模块存在,则执行一项操作,如果不存在,则执行另一项操作的概念是相同的。

像这样使用可选导入是一个很好的方法,让你的模块或包利用其他库,同时如果它们没有安装也可以工作。当然,你应该在包的文档中始终提到这些可选导入,这样你的用户就会知道如果这些可选模块或包被安装会发生什么。

本地导入

在第三章中,使用模块和包,我们介绍了全局命名空间的概念,并展示了import语句如何将导入的模块或包的名称添加到全局命名空间。这个描述实际上是一个轻微的过度简化。事实上,import语句将导入的模块或包添加到当前命名空间,这可能是全局命名空间,也可能不是。

在 Python 中,有两个命名空间:全局命名空间和本地命名空间。全局命名空间是存储源文件中所有顶层定义的地方。例如,考虑以下 Python 模块:

import random
import string

def set_length(length):
    global _length
    _length = length

def make_name():
    global _length

    letters = []
    for i in range(length):
        letters.append(random.choice(string.letters))
    return "".join(letters)

当你导入这个 Python 模块时,你将向全局命名空间添加四个条目:randomstringset_lengthmake_name

注意

Python 解释器还会自动向全局命名空间添加几个其他条目。我们现在先忽略这些。

如果你然后调用set_length()函数,这个函数顶部的global语句将向模块的全局命名空间添加另一个条目,名为_lengthmake_name()函数也包括一个global语句,允许它在生成随机名称时引用全局_length值。

到目前为止一切都很好。可能不那么明显的是,在每个函数内部,还有一个称为本地命名空间的第二个命名空间,其中包含所有不是全局的变量和其他定义。在make_name()函数中,letters列表以及for语句使用的变量i都是本地变量——它们只存在于本地命名空间中,当函数退出时它们的值就会丢失。

本地命名空间不仅用于本地变量:你也可以用它来进行本地导入。例如,考虑以下函数:

def delete_backups(dir):
    import os
    import os.path
    for filename in os.listdir(dir):
        if filename.endswith(".bak"):
            remove(os.path.join(dir, filename))

注意osos.path模块是在函数内部导入的,而不是在模块或其他源文件的顶部。因为这些模块是在函数内部导入的,所以osos.path名称被添加到本地命名空间而不是全局命名空间。

在大多数情况下,你应该避免使用本地导入:将所有的import语句放在源文件的顶部(使所有的导入语句都是全局的)可以更容易地一眼看出你的源文件依赖于哪些模块。然而,有两种情况下本地导入可能会有用:

  1. 如果你要导入的模块或包特别大或初始化速度慢,使用本地导入而不是全局导入将使你的模块更快地导入。导入模块时的延迟只会在调用函数时显示出来。如果函数只在某些情况下被调用,这将特别有用。

  2. 本地导入是避免循环依赖的好方法。如果模块 A 依赖于模块 B,模块 B 又依赖于模块 A,那么如果两组导入都是全局的,你的程序将崩溃。然而,将一组导入更改为本地导入将打破相互依赖,因为导入直到调用函数时才会发生。

作为一般规则,你应该坚持使用全局导入,尽管在特殊情况下,本地导入也可以非常有用。

使用 sys.path 调整导入

当你使用import命令时,Python 解释器必须搜索你想要导入的模块或包。它通过查找模块搜索路径来实现,这是一个包含各种目录的列表,模块或包可以在其中找到。模块搜索路径存储在sys.path中,Python 解释器将依次检查此列表中的目录,直到找到所需的模块或包。

当 Python 解释器启动时,它会使用以下目录初始化模块搜索路径:

  • 包含当前执行脚本的目录,或者如果你在终端窗口中运行 Python 交互解释器,则为当前目录

  • PYTHONPATH环境变量中列出的任何目录

  • 解释器的site-packages目录中的内容,包括site-packages目录中路径配置文件引用的任何模块

注意

site-packages目录用于保存各种第三方模块和包。例如,如果你使用 Python 包管理器pip来安装 Python 模块或包,那么该模块或包通常会放在site-packages目录中。

  • 包含组成 Python 标准库的各种模块和包的多个目录

这些目录在sys.path中出现的顺序很重要,因为一旦找到所需名称的模块或包,搜索就会停止。

如果你愿意,你可以打印出你的模块搜索路径的内容,尽管列表可能会很长,而且很难理解,因为通常有许多包含 Python 标准库各个部分的目录,以及任何你可能安装的第三方包使用的其他目录:

>>> import sys
>>> 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 解释器中的模块,可以执行以下命令:

import sys
print(sys.builtin_module_names)

如果尝试导入这些模块之一,无论您对模块搜索路径做了什么,始终会使用内置版本。

虽然您可以对sys.path进行任何更改,例如删除或重新排列此列表的内容,但最常见的用法是向列表添加条目。例如,您可能希望将您创建的各种模块和包存储在一个特殊的目录中,然后可以从任何需要它的 Python 程序中访问。例如,假设您在/usr/local/shared-python-libs目录中有一个包含您编写的几个模块和包的目录,您希望在多个不同的 Python 程序中使用。在该目录中,假设您有一个名为utils.py的模块和一个名为approxnums的包,您希望在程序中使用。虽然简单的import utils会导致ImportError,但您可以通过以下方式使shared-python-libs目录的内容可用于程序:

import sys
sys.path.append("/usr/local/shared-python-libs")
import utils, 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模块来执行以下操作:

import random
print(random.choice(["yes", "no"]))

您的程序一直正常工作,直到您决定主脚本中有太多数学函数,因此对其进行重构,将这些函数移动到一个单独的模块中。您决定将此模块命名为math.py,并将其存储在主程序的目录中。一旦这样做,之前的代码将会崩溃,并显示以下错误:

Traceback (most recent call last):
 **File "main.py", line 5, in <module>
 **import random
 **File "/Library/Frameworks/Python.framework/Versions/3.3/lib/python3.3/random.py", line 41, in <module>
 **from math import log as _log, exp as _exp, pi as _pi, e as _e, ceil as _ceil
ImportError: cannot import name log

这到底是怎么回事?原本正常运行的代码现在崩溃了,尽管您没有对其进行更改。更糟糕的是,回溯显示它在程序导入 Python 标准库的模块时崩溃!

要理解这里发生了什么,您需要记住,默认情况下,模块搜索路径包括当前程序目录作为第一个条目——在指向 Python 标准库各个部分的其他条目之前。通过在程序中创建一个名为math.py的新模块,您已经使得 Python 解释器无法从 Python 标准库加载math.py模块。这不仅适用于您编写的代码,还适用于模块搜索路径上的任何模块或包,它们可能尝试从 Python 标准库加载此模块。在这个例子中,失败的是random模块,但它可能是任何依赖于math库的模块。

这被称为名称屏蔽,是一个特别阴险的问题。为了避免这种情况,您在选择程序中顶层模块和包的名称时,应该始终小心,以确保它们不会屏蔽 Python 标准库中的模块,无论您是否使用该模块。

避免名称屏蔽的一种简单方法是利用包来组织您在程序中编写的模块和包。例如,您可以创建一个名为lib的顶层包,并在lib包内创建各种模块和包。由于 Python 标准库中没有名为lib的模块或包,因此无论您为lib包内的模块和包选择什么名称,都不会有屏蔽标准库模块的风险。

将 Python 脚本命名为模块或包

名称屏蔽的一个更微妙的例子可能发生在您有一个 Python 脚本,其名称与 Python 标准库中的一个模块相同。例如,假设您想弄清楚re模块(docs.python.org/3.3/library/re.html)的工作原理。如果您之前没有使用过正则表达式,这个模块可能会有点令人困惑,因此您可能决定编写一个简单的测试脚本来了解它的工作原理。这个测试脚本可能包括以下代码:

import re

pattern = input("Regular Expression: ")
s = input("String: ")

results = re.search(pattern, s)

print(results.group(), results.span())

这个程序可能会帮助您弄清楚re模块的作用,但如果您将此脚本保存为re.py,当运行程序时会出现一个神秘的错误:

$ python re.py
Regular Expression: [0-9]+
String: test123abc
Traceback (most recent call last):
...
File "./re.py", line 9, in <module>
 **results = re.search(pattern, s)
AttributeError: 'module' object has no attribute 'search'

你能猜到这里发生了什么吗?答案再次在于模块搜索路径。您的脚本名称re.py屏蔽了 Python 标准库中的re模块,因此当您的程序尝试导入re模块时,实际上加载的是脚本本身。您在这里看到AttributeError,是因为脚本成功地将自身作为模块加载,但该模块并没有您期望的search()函数。

注意

让脚本导入自身作为模块也可能导致意外问题;我们马上就会看到这一点。

这个问题的解决方法很简单:永远不要使用 Python 标准库模块的名称作为脚本的名称。而是将你的测试脚本命名为类似re_test.py的东西。

将包目录添加到 sys.path

一个常见的陷阱是将包目录添加到sys.path。让我们看看当你这样做时会发生什么。

创建一个目录来保存一个测试程序,并在这个主目录中创建一个名为package的子目录。然后,在package目录中创建一个空的包初始化(__init__.py)文件。同时,在同一个目录中创建一个名为module.py的模块。然后,将以下内容添加到module.py文件中:

print("### Initializing module.py ###")

当导入模块时,这会打印出一条消息。接下来,在你的最顶层目录中创建一个名为good_imports.py的 Python 源文件,并输入以下 Python 代码到这个文件中:

print("Calling import package.module...")
import package.module
print("Calling import package.module as module...")
import package.module as module
print("Calling from package import module...")
from package import module

保存这个文件后,打开一个终端或命令行窗口,并使用cd命令将当前目录设置为你最外层的目录(包含你的good_imports.py脚本的目录),然后输入python good_imports.py来运行这个程序。你应该会看到以下输出:

$ python good_imports.py
Calling import package.module...
### Initializing module.py ###
Calling import package.module as module...
Calling from package import module...

正如你所看到的,第一个import语句加载了模块,导致打印出### Initializing module.py ###的消息。对于后续的import语句,不会发生初始化——相反,已经导入的模块副本会被使用。这是我们想要的行为,因为它确保我们只有一个模块的副本。这对于那些在全局变量中保存信息的模块非常重要,因为拥有不同副本的模块,其全局变量中的值不同,可能会导致各种奇怪和令人困惑的行为。

不幸的是,如果我们将一个包或包的子目录添加到sys.path中,我们可能会得到这样的结果。要看到这个问题的实际情况,创建一个名为bad_imports.py的新顶级脚本,并输入以下内容到这个文件中:

import os.path
import sys

cur_dir = os.path.abspath(os.path.dirname(__file__))
package_dir = os.path.join(cur_dir, "package")

sys.path.insert(1, package_dir)

print("Calling import package.module as module...")
import package.module as module
print("Calling import module...")
import module

这个程序将package_dir设置为package目录的完整目录路径,然后将这个目录添加到sys.path中。然后,它进行了两个单独的import语句,一个是从名为package的包中导入module,另一个是直接导入module。这两个import语句都可以工作,因为模块可以以这两种方式访问。然而,结果并不是你可能期望的:

$ python bad_imports.py
Calling import package.module as module...
### Initializing module.py ###
Calling import module...
### Initializing module.py ###

正如你所看到的,模块被导入了两次,一次是作为package.module,另一次是作为module。你最终会得到两个独立的模块副本,它们都被初始化,并作为两个不同的模块出现在 Python 系统中。

拥有两个模块副本可能会导致各种微妙的错误和问题。这就是为什么你永远不应该直接将 Python 包或 Python 包的子目录添加到sys.path中。

提示

当然,将包含包的目录添加到sys.path是可以的;只是不要添加包目录本身。

执行和导入相同的模块

另一个更微妙的双重导入问题的例子是,如果您执行一个 Python 源文件,然后导入同一个文件,就好像它是一个模块一样。要了解这是如何工作的,请创建一个目录来保存一个新的示例程序,并在该目录中创建一个名为test.py的新的 Python 源文件。然后,输入以下内容到这个文件中:

import helpers

def do_something(n):
    return n * 2

if __name__ == "__main__":
    helpers.run_test()

当这个文件作为脚本运行时,它调用helpers.run_test()函数来开始运行一个测试。这个文件还定义了一个函数do_something(),执行一些有用的功能。现在,在同一个目录中创建第二个名为helpers.py的 Python 源文件,并输入以下内容到这个文件中:

import test

def run_test():
    print(test.do_something(10))

正如你所看到的,helpers.py模块正在将test.py作为模块导入,然后调用do_something()函数作为运行测试的一部分。换句话说,即使test.py作为脚本执行,它也会作为模块被导入(间接地)作为该脚本的执行的一部分。

让我们看看当你运行这个程序时会发生什么:

$ python test.py
20

到目前为止一切顺利。程序正在运行,尽管模块导入复杂,但似乎工作正常。但让我们更仔细地看一下;在你的test.py脚本顶部添加以下语句:

print("Initializing test.py")

就像我们之前的例子一样,我们使用print()语句来显示模块何时被加载。这给了模块初始化的机会,我们期望只看到初始化发生一次,因为内存中应该只有每个模块的一个副本。

然而,在这种情况下,情况并非如此。尝试再次运行程序:

$ python test.py
Initializing test.py
Initializing test.py
20

正如你所看到的,模块被初始化了两次——一次是当它作为脚本运行时,另一次是当helpers.py导入该模块时。

为了避免这个问题,请确保你编写的任何脚本只用作脚本。将任何其他代码(例如我们之前示例中的do_something()函数)从你的脚本中移除,这样你就永远不需要导入它们。

提示

请注意,这并不意味着你不能有变色龙模块,既可以作为模块又可以作为脚本,正如第三章中所描述的那样,使用模块和包。只是要小心,你执行的脚本只使用模块本身定义的函数。如果你开始从同一个包中导入其他模块,你可能应该将所有功能移动到一个不同的模块中,然后将其导入到你的脚本中,而不是让它们都在同一个文件中。

使用 Python 交互解释器的模块和包

除了从 Python 脚本中调用模块和包,直接从 Python 交互解释器中调用它们通常也很有用。这是使用 Python 编程的快速应用开发RAD)技术的一个很好的方法:你对 Python 模块或包进行某种更改,然后立即通过从 Python 交互解释器调用该模块或包来看到你的更改的结果。

然而,还有一些限制和问题需要注意。让我们更仔细地看看你如何使用交互解释器来加快模块和包的开发;我们也会看到不同的方法可能更适合你。

首先创建一个名为stringutils.py的新 Python 模块,并将以下代码输入到这个文件中:

import re

def extract_numbers(s):
    pattern = r'[+-]?\d+(?:\.\d+)?'
    numbers = []
    for match in re.finditer(pattern, s):
        number = s[match.start:match.end+1]
        numbers.append(number)
    return numbers

这个模块代表我们第一次尝试编写一个从字符串中提取所有数字的函数。请注意,它还没有工作——如果你尝试使用它,extract_numbers()函数将崩溃。它也不是特别高效(一个更简单的方法是使用re.findall()函数)。但我们故意使用这段代码来展示你如何将快速应用开发技术应用到你的 Python 模块中,所以请耐心等待。

这个函数使用re(正则表达式)模块来找到与给定表达式模式匹配的字符串部分。复杂的pattern字符串用于匹配数字,包括可选的+-在前面,任意数量的数字,以及可选的小数部分在末尾。

使用re.finditer()函数,我们找到与我们的正则表达式模式匹配的字符串部分。然后提取字符串的每个匹配部分,并将结果附加到numbers列表中,然后将其返回给调用者。

这就是我们的函数应该做的事情。让我们来测试一下。

打开一个终端或命令行窗口,并使用cd命令切换到包含stringutils.py模块的目录。然后,输入python启动 Python 交互解释器。当 Python 命令提示符出现时,尝试输入以下内容:

>>> import stringutils
>>> print(stringutils.extract_numbers("Tes1t 123.543 -10.6 5"))
Traceback (most recent call last):
 **File "<stdin>", line 1, in <module>
 **File "./stringutils.py", line 7, in extract_numbers
 **number = s[match.start:match.end+1]
TypeError: unsupported operand type(s) for +: 'builtin_function_or_method' and 'int'

正如你所看到的,我们的模块还没有工作——我们在其中有一个 bug。更仔细地看,我们可以看到问题在我们的stringutils.py模块的第 7 行:

        number = s[match.start:match.end+1]

错误消息表明您正在尝试将内置函数(在本例中为match.end)添加到一个数字(1),这当然是行不通的。match.startmatch.end值应该是字符串的开始和结束的索引,但是快速查看re模块的文档显示match.startmatch.end是函数,而不是简单的数字,因此我们需要调用这些函数来获取我们想要的值。这样做很容易;只需编辑您的文件的第 7 行,使其看起来像下面这样:

        number = s[match.start():match.end()+1]

现在我们已经更改了我们的模块,让我们看看会发生什么。我们将从重新执行print()语句开始,看看是否有效:

>>> print(stringutils.extract_numbers("Tes1t 123.543 -10.6 5"))

提示

您知道您可以按键盘上的上箭头和下箭头键来浏览您之前在 Python 交互解释器中键入的命令历史记录吗?这样可以避免您不得不重新键入命令;只需使用箭头键选择您想要的命令,然后按Return执行它。

您将立即看到与之前看到的相同的错误消息-没有任何变化。这是因为您将模块导入 Python 解释器;一旦导入了模块或包,它就会保存在内存中,磁盘上的源文件将被忽略。

为了使您的更改生效,您需要重新加载模块。要做到这一点,请在 Python 解释器中键入以下内容:

import importlib
importlib.reload(stringutils)

提示

如果您使用的是 Python 2.x,则无法使用importlib模块。相反,只需键入reload(stringutils)。如果您使用的是 Python 3.3 版本,则使用imp而不是importlib

现在尝试重新执行print()语句:

>>> stringutils.extract_numbers("Hell1o 123.543 -10.6 5 there")
['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()过程中存在一些限制:

  • 想象一下,您有两个模块 A 和 B。模块 A 使用from B import...语句从模块 B 加载功能。如果您更改了模块 B,那么模块 A 将不会使用更改后的功能,除非您也重新加载该模块。

  • 如果您的模块在初始化时崩溃,它可能会处于奇怪的状态。例如,想象一下,您的模块包括以下顶层代码,它应该初始化一个客户列表:

customers = []
customers.append("Mike Wallis")
cusotmers.append("John Smith")

这个模块将被导入,但由于变量名拼写错误,它将在初始化期间引发异常。如果发生这种情况,您首先需要在 Python 交互解释器中使用import命令使模块可用,然后使用imp.reload()来加载更新后的源代码。

  • 因为您必须手动输入命令或从 Python 命令历史记录中选择命令,所以反复运行相同的代码可能会变得乏味,特别是如果您的测试涉及多个步骤。在使用交互式解释器时,很容易错过某个步骤。

因此,最好使用交互式解释器来修复特定问题或帮助您快速开发特定的小代码片段。当测试变得复杂或者需要与多个模块一起工作时,自定义编写的脚本效果更好。

处理全局变量

我们已经看到如何使用全局变量在模块内的不同函数之间共享信息。我们已经看到如何在模块内将全局变量定义为顶级变量,导致它们在导入模块时首次初始化,并且我们还看到如何在函数内使用global语句允许该函数访问和更改全局变量的值。

在本节中,我们将进一步学习如何在模块之间共享全局变量。在创建包时,通常需要定义可以被该包内任何模块访问或更改的变量。有时,还需要将变量提供给包外的 Python 代码。让我们看看如何实现这一点。

创建一个名为globtest的新目录,并在此目录中创建一个空的包初始化文件,使其成为 Python 包。然后,在此目录中创建一个名为globals.py的文件,并输入以下内容到此文件中:

language = None
currency = None

在这个模块中,我们已经定义了两个全局变量,我们希望在我们的包中使用,并为每个变量设置了默认值None。现在让我们在另一个模块中使用这些全局变量。

globtest目录中创建另一个名为test.py的文件,并输入以下内容到此文件中:

from . import globals

def test():
    globals.language = "EN"
    globals.currency = "USD"
    print(globals.language, globals.currency)

要测试您的程序,请打开终端或命令行窗口,使用cd命令移动到包含您的globtest包的目录,并输入python启动 Python 交互解释器。然后,尝试输入以下内容:

>>>** **from globtest import test
>>> test.test()
EN USD

如您所见,我们已成功设置了存储在我们的globals模块中的languagecurrency全局变量的值,然后再次检索这些值以打印它们。因为我们将这些全局变量存储在一个单独的模块中,所以您可以在当前包内的任何地方或者甚至在导入您的包的其他代码中检索或更改这些全局变量。使用单独的模块来保存包的全局变量是管理包内全局变量的一种绝佳方式。

然而,需要注意一点:要使全局变量在模块之间共享,必须导入包含该全局变量的模块,而不是变量本身。例如,以下内容不起作用:

from .test import language

这个声明的作用是将language变量的副本导入到当前模块的全局命名空间中,而不是原始全局命名空间。这意味着全局变量不会与其他模块共享。要使变量在模块之间共享,需要导入globals模块,而不是其中的变量。

包配置

随着您开发更复杂的模块和包,通常会发现您的代码在使用之前需要以某种方式配置。例如,想象一下,您正在编写一个使用数据库的包。为了做到这一点,您的包需要知道要使用的数据库引擎,数据库的名称,以及用于访问该数据库的用户名和密码。

你可以将这些信息硬编码到程序的源代码中,但这样做是一个非常糟糕的主意,有两个原因:

  • 不同的计算机和不同的操作系统将使用不同的数据库设置。由于用于访问数据库的信息会因计算机而异,任何想要使用你的包的人都必须直接编辑源代码以输入正确的数据库详细信息,然后才能运行包。

  • 用于访问数据库的用户名和密码是非常敏感的信息。如果你与其他人分享你的包,甚至只是将你的包源代码存储在 GitHub 等公共仓库上,那么其他人就可以发现你的数据库访问凭据。这是一个巨大的安全风险。

这些数据库访问凭据是包配置的一个例子——在你的包运行之前需要的信息,但你不希望将其构建到包的源代码中。

如果你正在构建一个应用程序而不是一个独立的模块或包,那么你的配置任务就简单得多了。Python 标准库中有一些模块可以帮助配置,例如configparsershlexjson。使用这些模块,你可以将配置设置存储在磁盘上的文件中,用户可以编辑。当你的程序启动时,你将这些设置加载到内存中,并根据需要访问它们。因为配置设置是存储在应用程序外部的,用户不需要编辑你的源代码来配置程序,如果你的源代码被发布或共享,你也不会暴露敏感信息。

然而,当编写模块和包时,基于文件的配置方法就不那么方便了。没有明显的地方来存储包的配置文件,要求配置文件位于特定位置会使你的模块或包更难以作为不同程序的一部分进行重用。

相反,模块或包的配置通常是通过向模块或包的初始化函数提供参数来完成的。我们在上一章中看到了一个例子,在那里quantities包在初始化时需要你提供一个locale值:

quantities.init("us")

这将配置的工作交给了周围的应用程序;应用程序可以利用配置文件或任何其他喜欢的配置方案,并且是应用程序在包初始化时提供包的配置设置:

包配置

这对包开发者来说更加方便,因为包所需要做的就是记住它所得到的设置。

虽然quantities包只使用了一个配置设置(区域的名称),但是包通常会使用许多设置。为包提供配置设置的一个非常方便的方式是使用 Python 字典。例如:

mypackage.init({'log_errors'  : True,
                'db_password' : "test123",
                ...})

使用字典这种方式可以很容易地支持包的配置设置的默认值。以下 Python 代码片段展示了一个包的init()函数如何接受配置设置,提供默认值,并将设置存储在全局变量中,以便在需要时可以访问:

def init(settings):
    global config

    config = {}
    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()函数,然后可以访问包含数据文件的子目录,并以通常的方式打开它们:

phone_numbers = []
cur_dir = os.path.abspath(os.path.dirname(__file__))
file = open(os.path.join(cur_dir, "data", "phone_numbers.txt"))
for line in file:
    phone_numbers.append(line.strip())
file.close()

将数据文件包含在软件包中的好处是,数据文件实际上是软件包源代码的一部分。当您分享软件包或将其上传到 GitHub 等源代码存储库时,数据文件将自动包含在软件包的其余部分中。这使得更容易跟踪软件包使用的数据文件。

总结

在本章中,我们看了一些与在 Python 中使用模块和软件包相关的更高级方面。我们看到try..except语句如何用于实现可选导入,以及如何将import语句放在函数内,以便在执行该函数时仅导入模块。然后我们了解了模块搜索路径以及如何修改sys.path以改变 Python 解释器查找模块和软件包的方式。

然后,我们看了一些与使用模块和软件包相关的陷阱。我们了解了名称屏蔽,其中您定义了与 Python 标准库中的模块或软件包相同名称的模块或软件包,这可能导致意外的失败。我们看了一下,给 Python 脚本与标准库模块相同的名称也可能导致名称屏蔽问题,以及如何将软件包目录或子目录添加到sys.path可能导致模块被加载两次,从而导致该模块中的全局变量出现微妙的问题。我们看到执行一个模块然后导入它也会导致该模块被加载两次,这可能再次导致问题。

接下来,我们将看看如何使用 Python 交互式解释器作为一种快速应用程序开发(RAD)工具,快速查找和修复模块和软件包中的问题,以及importlib.reload()命令允许您在更改底层源代码后重新加载模块

我们通过学习如何定义在整个软件包中使用的全局变量,如何处理软件包配置以及如何在软件包中存储和访问数据文件来完成了对高级模块技术的调查。

在下一章中,我们将看一些您可以测试、部署和分享 Python 模块和软件包的方式。

第八章:测试和部署模块

在本章中,我们将进一步探讨共享模块的概念。在您共享模块或包之前,您需要对其进行测试,以确保其正常工作。您还需要准备您的代码并了解如何部署它。为了学习这些内容,我们将涵盖以下主题:

  • 了解单元测试如何用于确保您的模块或包正常工作

  • 了解如何准备模块或包以供发布

  • 了解 GitHub 如何用于与他人共享您的代码

  • 审查提交代码到 Python 包索引所涉及的步骤

  • 了解如何使用 pip 安装和使用其他人编写的包

测试模块和包

测试是编程的正常部分:您测试代码以验证其是否正常工作并识别任何错误或其他问题,然后您可以修复。然后,您继续测试,直到您满意您的代码正常工作为止。

然而,程序员经常只进行临时测试:他们启动 Python 交互解释器,导入他们的模块或包,并进行各种调用以查看发生了什么。在上一章中,我们使用importlib.reload()函数进行了一种临时测试形式,以支持您的代码的 RAD 开发。

临时测试很有用,但并不是唯一的测试形式。如果您与他人共享您的模块和包,您将希望您的代码没有错误,并临时测试无法保证这一点。一个更好和更系统的方法是为您的模块或包创建一系列单元测试。单元测试是 Python 代码片段,用于测试代码的各个方面。由于测试是由 Python 程序完成的,因此您可以在需要测试代码时运行程序,并确保每次运行测试时都会测试所有内容。单元测试是确保在进行更改时错误不会进入您的代码的绝佳方法,并且您可以在需要共享代码时运行它们,以确保其正常工作。

注意

单元测试并不是您可以进行的唯一一种程序化测试。集成测试结合各种模块和系统,以确保它们正确地一起工作,GUI 测试用于确保程序的用户界面正常工作。然而,单元测试对于测试模块和包是最有用的,这也是我们将在本章中重点关注的测试类型。

以下是一个非常简单的单元测试示例:

import math
assert math.floor(2.6197) == 2

assert语句检查其后的表达式。如果此表达式不计算为True,则会引发AssertionError。这使您可以轻松检查给定函数是否返回您期望的结果;在此示例中,我们正在检查math.floor()函数是否正确返回小于或等于给定浮点数的最大整数。

因为模块或包最终只是一组 Python 函数(或方法,它们只是分组到类中的函数),因此很可能编写一系列调用您的函数并检查返回值是否符合预期的assert语句。

当然,这是一个简化:通常调用一个函数的结果会影响另一个函数的输出,并且您的函数有时可以执行诸如与远程 API 通信或将数据存储到磁盘文件中等相当复杂的操作。然而,在许多情况下,您仍然可以使用一系列assert语句来验证您的模块和包是否按您的预期工作。

使用 unittest 标准库模块进行测试

虽然您可以将您的assert语句放入 Python 脚本中并运行它们,但更好的方法是使用 Python 标准库中的unittest模块。该模块允许您将单元测试分组为测试用例,在运行测试之前和之后运行额外的代码,并访问各种不同类型的assert语句,以使您的测试更加容易。

让我们看看如何使用unittest模块为我们在第六章中实现的quantities包实施一系列单元测试。将此包的副本放入一个方便的目录中,并在同一目录中创建一个名为test_quantities.py的新的 Python 源文件。然后,将以下代码添加到此文件中:

import unittest
import quantities

class TestQuantities(unittest.TestCase):
    def setUp(self):
        quantities.init("us")

    def test_new(self):
        q = quantities.new(12, "km")
        self.assertEqual(quantities.value(q), 12)
        self.assertEqual(quantities.units(q), "kilometer")

    def test_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包的完整副本,都作为本章的示例代码的一部分可供下载。

让我们更仔细地看看这段代码做了什么。首先,TestQuantities类用于保存多个相关的单元测试。通常,您会为需要执行的每个主要单元测试组定义一个单独的unittest.TestCase子类。在我们的TestQuantities类中,我们定义了一个setUp()方法,其中包含需要在运行测试之前执行的代码。如果需要,我们还可以定义一个tearDown()方法,在测试完成后执行。

然后,我们定义了两个单元测试,我们称之为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脚本的目录,并尝试输入以下内容:

python test_quantities.py

一切顺利的话,您应该会看到以下输出:

..
---------------------------------------------------------------
Ran 2 tests in 0.000s

OK

默认情况下,unittest模块不会显示有关已运行的测试的详细信息,除了它已经无问题地运行了您的单元测试。如果您需要更多细节,您可以增加测试的详细程度,例如通过在测试脚本中的unittest.main()语句中添加参数:

    unittest.main(verbosity=2)

或者,您可以使用-v命令行选项来实现相同的结果:

python test_quantities.py -v

设计您的单元测试

单元测试的目的是检查您的代码是否正常工作。一个很好的经验法则是为包中的每个公共可访问模块单独编写一个测试用例,并为该模块提供的每个功能单独编写一个单元测试。单元测试代码应该至少测试功能的通常操作,以确保其正常工作。如果需要,您还可以选择在单元测试中编写额外的测试代码,甚至额外的单元测试,以检查代码中特定的边缘情况

举个具体的例子,在我们在前一节中编写的test_convert()方法中,您可能希望添加代码来检查如果用户尝试将距离转换为重量,则是否会引发适当的异常。例如:

q = quantities.new(12, "km")
with self.assertRaises(ValueError):
    quantities.convert(q, "kg")

问题是:您应该为多少边缘情况进行测试?有数百种不同的方式可以使用您的模块不正确。您应该为这些每一种编写单元测试吗?

一般来说,不值得尝试测试每种可能的边缘情况。当然,您可能希望测试一些主要可能性,只是为了确保您的模块能够处理最明显的错误,但除此之外,编写额外的测试可能不值得努力。

代码覆盖

覆盖率是您的单元测试测试了您的代码多少的度量。要理解这是如何工作的,请考虑以下 Python 函数:

[1] def calc_score(x, y):
[2]     if x == 1:
[3]         score = y * 10
[4]     elif x == 2:
[5]         score = 25 + y
[6]     else:
[7]         score = y
[8]
[9]     return score

注意

我们已经在每一行的开头添加了行号,以帮助我们计算代码覆盖率。

现在,假设我们为我们的calc_score()函数创建以下单元测试代码:

assert calc_score(1, 5) == 50
assert calc_score(2, 10) == 35

我们的单元测试覆盖了calc_score()函数的多少?我们的第一个assert语句调用calc_score()x1y5。如果您按照行号,您会发现使用这组参数调用此函数将导致执行第 1、2、3 和 9 行。类似地,第二个assert语句调用calc_score()x2y10,导致执行第 1、4、5 和 9 行。

总的来说,这两个 assert 语句导致执行第 1、2、3、4、5 和 9 行。忽略空行,我们的测试没有包括第 6 和第 7 行。因此,我们的单元测试覆盖了函数中的八行中的六行,给我们一个代码覆盖率值为 6/8 = 75%。

注意

我们在这里看的是语句覆盖率。还有其他更复杂的衡量代码覆盖率的方法,我们在这里不会深入讨论。

显然,您不会手动计算代码覆盖率。有一些出色的工具可以计算 Python 测试代码的代码覆盖率。例如,看看coverage包(pypi.python.org/pypi/coverage)。

代码覆盖的基本概念是,您希望您的测试覆盖所有您的代码。无论您是否使用诸如coverage之类的工具来衡量代码覆盖率,编写单元测试以尽可能包含接近 100%的代码是一个好主意。

测试驱动开发

当我们考虑测试 Python 代码的想法时,值得提到测试驱动开发的概念。使用测试驱动开发,您首先选择您希望您的模块或包执行的操作,然后编写单元测试以确保模块或包按照您的期望工作—在您编写它之前。这样,单元测试充当了模块或包的一种规范;它们告诉您您的代码应该做什么,然后您的任务是编写代码以使其通过所有测试。

测试驱动开发可以是实现模块和包的有用方式。当然,您是否使用它取决于您,但是如果您有纪律写单元测试,测试驱动开发可以是确保您正确实现了代码的一个很好的方式,并且您的模块在代码增长和变化的过程中继续按照您的期望工作。

Mocking

如果您的模块或包调用外部 API 或执行其他复杂、昂贵或耗时的操作,您可能希望在 Python 标准库中调查unittest.mock包。Mocking是用程序中的虚拟函数替换某些功能的过程,该虚拟函数立即返回适合测试的数据。

模拟是一个复杂的过程,要做对可能需要一些时间,但如果您想要对本来会太慢、每次运行都会花费金钱或依赖外部系统运行的代码运行单元测试,这种技术绝对是值得的。

为您的模块和包编写单元测试

现在我们已经介绍了单元测试的概念,看了一下unittest标准库模块的工作原理,并研究了编写单元测试的一些更复杂但重要的方面,现在让我们看看单元测试如何可以用来辅助开发和测试您的模块和包。

首先,您应该至少为您的模块或包定义的主要函数编写单元测试。从测试最重要的函数开始,并为更明显的错误条件添加测试,以确保错误被正确处理。您可以随时为代码中更隐晦的部分添加额外的测试。

如果您为单个模块编写单元测试,您应该将测试代码放在一个单独的 Python 脚本中,例如命名为tests.py,并将其放在与您的模块相同的目录中。下面的图片展示了在编写单个模块时组织代码的好方法:

为您的模块和包编写单元测试

如果您在同一个目录中有多个模块,您可以将所有模块的单元测试合并到tests.py脚本中,或者将其重命名为类似test_my_module.py的名称,以明确测试的是哪个模块。

对于一个包,确保将tests.py脚本放在包所在的目录中,而不是包内部:

为您的模块和包编写单元测试

如果您将test.py脚本放在包目录中,当您的单元测试尝试导入包时,您可能会遇到问题。

您的tests.py脚本应该为包中每个公开可访问的模块定义一个unittest.TestCase对象,并且这些对象中的每一个都应该有一个testXXX()方法,用于定义模块中的每个函数或主要功能。

这样做可以通过执行以下命令简单地测试您的模块或包:

python test.py

每当您想要检查您的模块是否工作时,特别是在上传或与其他人分享您的模块或包之前,您应该运行单元测试。

准备模块或包以供发布

在第六章创建可重用模块中,我们看了一些使模块或包适合重用的东西:

  • 它必须作为一个独立的单元运行

  • 一个包应该理想地使用相对导入

  • 您的模块或包中的任何外部依赖关系必须清楚地注明

我们还确定了三个有助于创建优秀可重用模块或包的东西:

  • 它应该解决一个普遍的问题

  • 您的代码应该遵循标准的编码约定

  • 您的模块或包应该有清晰的文档

准备您的模块或包以供发布的第一步是确保您至少遵循了这些准则中的前三条,最好是所有六条。

第二步是确保您至少编写了一些单元测试,并且您的模块或包通过了所有这些测试。最后,您需要决定如何发布您的代码。

如果你想与朋友或同事分享你的代码,或者写一篇博客文章并附上你的代码链接,那么最简单的方法就是将其上传到 GitHub 等源代码仓库中。我们将在下一节中看看如何做到这一点。除非你将其设为私有,否则任何拥有正确链接的人都可以访问你的代码。人们可以在线查看你的源代码(包括文档),下载你的模块或包用于他们自己的程序,并且“fork”你的代码,创建他们自己的私人副本,然后进行修改。

如果你想与更广泛的受众分享你的代码,最好的方法是将其提交到Python Package IndexPyPI)。这意味着其他人可以通过在 PyPI 索引中搜索来找到你的模块或包,并且任何人都可以使用pip,Python 包管理器来安装它。本章的后续部分将描述如何将你的模块或包提交到 PyPI,以及如何使用 pip 来下载和使用模块和包。

将你的工作上传到 GitHub。

GitHub(github.com/)是一个流行的基于 Web 的存储和管理源代码的系统。虽然有几种替代方案,但 GitHub 在编写和分享开源 Python 代码的人中特别受欢迎,这也是我们在本书中将使用的源代码管理系统。

在深入讨论 GitHub 的具体内容之前,让我们先看看源代码管理系统是如何工作的,以及为什么你可能想要使用它。

想象一下,你正在编写一个复杂的模块,并在文本编辑器中打开了你的模块进行一些更改。在进行这些更改的过程中,你不小心选择了 100 行代码,然后按下了删除键。在意识到自己做了什么之前,你保存并关闭了文件。太迟了:那 100 行文本已经消失了。

当然,你可能(并且希望)有一个备份系统,定期备份你的源文件。但如果你在过去几分钟内对一些丢失的代码进行了更改,那么你很可能已经丢失了这些更改。

现在考虑这样一种情况:你与同事分享了一个模块或包,他们决定做一些更改。也许有一个需要修复的错误,或者他们想要添加一个新功能。他们改变了你的代码,并在附有说明的情况下将其发送回给你。不幸的是,除非你比较原始版本和修改后的源文件中的每一行,否则你无法确定你的同事对你的文件做了什么。

源代码管理系统解决了这些问题。你不仅仅是在硬盘上的一个目录中拥有你的模块或包的副本,而是在像 GitHub 这样的源代码管理系统中创建一个仓库,并将你的源代码提交到这个仓库中。然后,当你对文件进行更改,修复错误和添加功能时,你将每个更改都提交回仓库。源代码仓库跟踪了你所做的每一次更改,允许你准确地查看随时间发生的变化,并在必要时撤消先前所做的更改。

你不仅仅局限于让一个人来工作在一个模块或包上。人们可以fork你的源代码仓库,创建他们自己的私人副本,然后使用这个私人副本来修复错误和添加新功能。一旦他们这样做了,他们可以向你发送一个pull request,其中包括他们所做的更改。然后你可以决定是否将这些更改合并到你的项目中。

不要太担心这些细节,源代码管理是一个复杂的话题,使用 GitHub 等工具可以执行许多复杂的技巧来管理源代码。要记住的重要事情是,创建一个存储库来保存模块或软件包的源代码的主要副本,将代码提交到这个存储库中,然后每次修复错误或添加新功能时都要继续提交。以下插图总结了这个过程:

将您的工作上传到 GitHub

源代码管理系统的诀窍是定期提交 - 每次添加新功能或修复错误时,您都应立即提交更改。这样,存储库中一个版本和下一个版本之间的差异只是添加了一个功能或修复了一个问题的代码。如果在提交之前对源代码进行了多次更改,存储库将变得不那么有用。

既然我们已经了解了源代码管理系统的工作原理,让我们实施一个真实的示例,看看如何使用 GitHub 来管理您的源代码。首先,转到 GitHub 的主要网站(github.com/)。如果您没有 GitHub 帐户,您需要注册,选择一个唯一的用户名,并提供联系电子邮件地址和密码。如果您以前使用过 GitHub,可以使用已设置的用户名和密码登录。

请注意,注册和使用 GitHub 是免费的;唯一的限制是您创建的每个存储库都将是公开的,因此任何希望的人都可以查看您的源代码。如果您想要,您可以设置私有存储库,但这些会产生月费。但是,由于我们使用 GitHub 与他人分享我们的代码,拥有私有存储库是没有意义的。只有在您想要与一组特定的人分享代码并阻止其他人访问时,才需要私有(付费)存储库。如果您处于必须这样做的位置,支付私有存储库是您最不用担心的事情。

登录 GitHub 后,您的下一个任务是安装Git的命令行工具。Git 是 GitHub 使用的基础源代码管理工具包;您将使用git命令从命令行处理您的 GitHub 存储库。

要安装所需的软件,请转到git-scm.com/downloads并下载适用于您特定操作系统的安装程序。下载完成后,运行安装程序,并按照安装git命令行工具的说明进行操作。完成后,打开终端或命令行窗口,尝试输入以下命令:

git --version

一切顺利的话,您应该看到已安装的git命令行工具的版本号。

完成这些先决条件后,让我们使用 GitHub 创建一个示例存储库。返回github.com/网页,点击绿色高亮显示的+新存储库按钮。您将被要求输入要创建的存储库的详细信息:

将您的工作上传到 GitHub

要设置存储库,请输入test-package作为存储库的名称,并从添加.gitignore下拉菜单中选择Python.gitignore文件用于从存储库中排除某些文件;为 Python 使用.gitignore文件意味着 Python 创建的临时文件不会包含在存储库中。

最后,点击创建存储库按钮创建新存储库。

提示

确保不要选择使用 README 初始化此存储库选项。您不希望在此阶段创建一个 README 文件;很快就会清楚原因。

现在 GitHub 上已经创建了存储库,我们的下一个任务是克隆该存储库的副本到您计算机的硬盘上。为此,创建一个名为test-package的新目录来保存存储库的本地副本,打开终端或命令行窗口,并使用cd命令移动到您的新test-package目录。然后,输入以下命令:

git clone https://<username>@github.com/<username>/test-package.git .

确保您在上述命令中替换<username>的两个实例为您的 GitHub 用户名。您将被提示输入 GitHub 密码以进行身份验证,并且存储库的副本将保存到您的新目录中。

因为存储库目前是空的,您在目录中看不到任何内容。但是,有一些隐藏文件git用来跟踪您对存储库的本地副本。要查看这些隐藏文件,您可以从终端窗口使用ls命令:

$ ls -al
drwxr-xr-x@  7 erik  staff   238 19 Feb 21:28 .
drwxr-xr-x@  7 erik  staff   238 19 Feb 14:35 ..
drwxr-xr-x@ 14 erik  staff   476 19 Feb 21:28 .git
-rw-r--r--@  1 erik  staff   844 19 Feb 15:09 .gitignore

.git目录包含有关您的新 GitHub 存储库的信息,而.gitignore文件包含您要求 GitHub 为您设置的忽略 Python 临时文件的指令。

现在我们有了一个(最初为空的)存储库,让我们在其中创建一些文件。我们需要做的第一件事是为我们的包选择一个唯一的名称。因为我们的包将被提交到 Python 包索引,所以名称必须是真正唯一的。为了实现这一点,我们将使用您的 GitHub 用户名作为我们包名称的基础,就像这样:

<username>-test-package

例如,由于我的 GitHub 用户名是"erikwestra",我将为这个包使用erikwestra-test-package。确保您根据您的 GitHub 用户名选择一个名称,以确保包名称是真正唯一的。

现在我们有了一个包的名称,让我们创建一个描述这个包的 README 文件。在您的test-package目录中创建一个名为README.rst的新文本文件,并将以下内容放入此文件中:

<username>-test-package
-----------------------

This is a simple test package. To use it, type::

    from <username>_test_package import test
    test.run()

确保您用您的 GitHub 用户名替换每个<username>的出现。这个文本文件是以reStructuredText 格式。reStructuredText 是 PyPI 用来显示格式化文本的格式语言。

注意

虽然 GitHub 可以支持 reStructuredText,但默认情况下它使用一种名为Markdown的不同文本格式。Markdown 和 reStructuredText 是两种竞争格式,不幸的是,PyPI 需要 reStructuredText,而 GitHub 默认使用 Markdown。这就是为什么我们告诉 GitHub 在设置存储库时不要创建 README 文件的原因;如果我们这样做了,它将以错误的格式存在。

当用户在 GitHub 上查看您的存储库时,他们将看到此文件的内容按照 reStructuredText 规则整齐地格式化:

将您的工作上传到 GitHub

如果您想了解更多关于 reStructuredText 的信息,您可以在docutils.sourceforge.net/rst.html上阅读所有相关内容。

现在我们已经为我们的包设置了 README 文件,让我们创建包本身。在test-package内创建另一个名为<username>_test_package的目录,将空的包初始化文件(__init__.py)放入此目录。然后,在<username>_test_package目录内创建另一个名为test.py的文件,并将以下内容放入此文件:

import string
import random

def random_name():
    chars = []
    for i in range(random.randrange(3, 10)):
        chars.append(random.choice(string.ascii_letters))
    return "".join(chars)

def run():
    for i in range(10):
        print(random_name())

这只是一个例子,当然。调用test.run()函数将导致显示十个随机名称。更有趣的是,我们现在已经为我们的测试包定义了初始内容。但是,我们所做的只是在我们的本地计算机上创建了一些文件;这并不会影响 GitHub,如果您在 GitHub 中重新加载存储库页面,您的新文件将不会显示出来。

要使我们的更改生效,我们需要提交更改到存储库。我们将首先查看我们的本地副本与存储库中的副本有何不同。为此,请返回到您的终端窗口,cd进入test-package目录,并键入以下命令:

git status

您应该看到以下输出:

# On branch master
# Untracked files:
#   (use "git add <file>..." to include in what will be committed)
#
#  README.rst
#  <username>_test_package/
nothing added to commit but untracked files present (use "git add" to track)

描述可能有点令人困惑,但并不太复杂。基本上,GitHub 告诉您有一个新文件README.rst和一个新目录,名为<username>_test_package,它不知道(或者在 GitHub 的说法中是“未跟踪”)。让我们将这些新条目添加到我们的存储库中:

git add README.rst
git add <username>_test_package

确保您将<username>替换为您的 GitHub 用户名。如果您现在键入git status,您将看到我们创建的文件已添加到存储库的本地副本中:

# On branch master
# Changes to be committed:
#   (use "git reset HEAD <file>..." to unstage)
#
#  new file:   README.rst
#  new file:   <username>_test_package/__init__.py
#  new file:   <username>_test_package/test.py

每当您向项目添加新目录或文件时,您需要使用git add命令将其添加到存储库中。随时可以通过键入git status命令并查找“未跟踪”文件来查看是否漏掉了任何文件。

现在我们已经包含了我们的新文件,让我们将更改提交到存储库。键入以下命令:

git commit -a -m 'Initial commit.'

这将向您的存储库的本地副本提交一个新更改。-a选项告诉 GitHub 自动包括任何更改的文件,-m选项允许您输入一个简短的消息,描述您所做的更改。在这种情况下,我们的提交消息设置为值"Initial commit."。

现在我们已经提交了更改,我们需要从本地计算机上传到 GitHub 存储库。为此,请键入以下命令:

git push

您将被提示输入您的 GitHub 密码以进行身份验证,并且您提交的更改将存储到 GitHub 上的存储库中。

注意

GitHub 将commit命令与push命令分开,因为您可能需要在更改程序时进行多次提交,而不一定在线上。例如,如果您在长途飞行中,可以在本地工作,每次更改时进行提交,然后在降落并再次拥有互联网访问时一次性推送所有更改。

现在您的更改已推送到服务器,您可以在 GitHub 上重新加载页面,您新创建的软件包将出现在存储库中:

将您的工作上传到 GitHub

您还将看到您的README.rst文件的内容显示在文件列表下面,描述了您的新软件包及其使用方法。

每当您对软件包进行更改时,请确保按照以下步骤保存更改到存储库中:

  1. 使用git status命令查看发生了什么变化。如果您添加了需要包含在存储库中的任何文件,请使用git add将它们添加进去。

  2. 使用git commit -a -m '<commit message>'命令将更改提交到您的 GitHub 存储库的本地副本。确保输入适当的提交消息来描述您所做的更改。

  3. 当您准备好这样做时,请使用git push命令将提交的更改发送到 GitHub。

当然,使用 GitHub 还有很多内容,还有许多命令和选项,一旦您开始使用,您无疑会想要探索,但这已经足够让您开始了。

一旦您为您的 Python 模块或软件包设置了 GitHub 存储库,就可以轻松地与其他人共享您的代码。您只需要分享您的 GitHub 存储库的链接,其他人就可以下载他们想要的文件。

为了使这个过程更加简单,并使您的软件包可以被更广泛的用户搜索到,您应该考虑将您的软件包提交到 Python 软件包索引。接下来我们将看看涉及到这样做的步骤。

提交到 Python 软件包索引

要将您的 Python 软件包提交到 Python 软件包索引,您首先必须在pypi.python.org/pypi免费注册一个帐户。单击页面右上角框中的注册链接:

提交到 Python 软件包索引

您需要选择一个用户名和密码,并提供一个电子邮件地址。记住您输入的用户名和密码,因为您很快就会需要它。当您提交表单时,您将收到一封包含链接的电子邮件,您需要点击该链接以完成注册。

在将项目提交到 PyPI 之前,您需要添加两个文件,一个是setup.py脚本,用于打包和上传您的软件包,另一个是LICENSE.txt文件,用于描述您的软件包可以使用的许可证。现在让我们添加这两个文件。

在您的test-package目录中创建一个名为setup.py的文件,并输入以下内容:

from distutils.core import setup

setup(name="<username>-test-package",
      packages=["<username>_test_package"],
      version="1.0",
      description="Test Package",
      author="<your name>",
      author_email="<your email address>",
      url="https://github.com/<username>/test-package",
      download_url="https://github.com/<username>/test-package/tarball/1.0",
      keywords=["test", "python"],
      classifiers=[])

确保将每个<username>替换为您的 GitHub 用户名,并将<your name><your email address>替换为相关值。因为这只是一个测试,我们为此软件包使用名称<username>-test-package;对于真实项目,我们将为我们的软件包使用一个更有意义(但仍然是唯一的)名称。

注意

请注意,此版本的setup.py脚本使用了Distutils软件包。Distutils 是 Python 标准库的一部分,是创建和分发代码的简单方法。还有一个名为Setuptools的替代库,许多人更喜欢它,因为它是一个功能更多、更现代的库,并且通常被视为 Distutils 的继任者。但是,Setuptools 目前不是 Python 标准库的一部分。由于它更容易使用并且具有我们需要的所有功能,我们在这里使用 Distutils 来尽可能简化这个过程。如果您熟悉使用它,请随时使用 Setuptools 而不是 Distutils,因为对于我们在这里所做的事情,两者是相同的。

最后,我们需要创建一个名为LICENSE.txt的新文本文件。该文件将保存您发布软件包的软件许可证。包含许可证非常重要,以便人们准确知道他们可以和不能做什么,您不能提交一个没有提供许可证的软件包。

虽然您可以在LICENSE.txt文件中放入任何您喜欢的内容,但通常应使用现有的软件许可证之一。例如,您可能想使用opensource.org/licenses/MIT提供的 MIT 许可证——该许可证使您的代码可供他人任何目的使用,同时确保您不会对其使用中可能出现的任何问题负责。

有了这两个文件,您最终可以将您的新软件包提交到 Python 软件包索引。要做到这一点,请在您的终端或命令行窗口中键入以下命令:

python setup.py register

此命令将尝试使用 Python 软件包索引注册您的新软件包。您将被要求输入您的 PyPI 用户名和密码,并有机会存储这些信息,以便您不必每次都重新输入。一旦软件包成功注册,您可以通过输入以下命令上传软件包内容:

python setup.py sdist upload

在将您的软件包上传到 PyPI 之前,您会看到一些警告,您可以安全地忽略这些警告。然后,您可以转到 PyPI 网站,您将看到您的新软件包已列出:

提交到 Python 软件包索引

如你所见,Home Page链接指向你在 GitHub 上的项目页面,并且有一个直接下载链接,用于你的包的 1.0 版本。然而,不幸的是,这个下载链接还不起作用,因为你还没有告诉 GitHub 你的包的 1.0 版本是什么样子。为了做到这一点,你必须在 GitHub 中创建一个与你的系统版本 1.0 相对应的标签;GitHub 将会创建一个与该标签匹配的可下载版本的你的包。

在创建 1.0 版本之前,你应该提交你对仓库所做的更改。这本来就是一个好习惯,所以让我们看看如何做:首先输入git status,查看已添加或更改的文件,然后使用git add逐个添加每个未跟踪的文件。完成后,输入git commit -a -m 'Preparing for PyPI submission'将你的更改提交到仓库。最后,输入git push将你提交的更改发送到 GitHub。

完成所有这些后,你可以通过输入以下命令创建与你的包的 1.0 版本相对应的标签:

git tag 1.0 -m 'Version 1.0 of the <username>_test_package.'

确保你用你的 GitHub 用户名替换<username>,以便包名正确。最后,使用以下git push命令的变体将新创建的标签复制到 GitHub 服务器:

git push --tags

再次,你将被要求输入你的 GitHub 密码。当这个命令完成时,你将在https://github.com/<username>/test-package/tarball/1.0上找到你的包的 1.0 版本可供下载,其中<username>是你的 GitHub 用户名。如果你现在去 PyPI 寻找你的测试包,你将能够点击Download URL链接下载你的 1.0 包的副本。

如果你的新包出现在 Python 包索引中,并且你可以通过Download链接成功下载你的包的 1.0 版本,那么你应该得到表扬。恭喜!这是一个复杂的过程,但它将为你的可重用模块和包提供尽可能多的受众。

使用 pip 下载和安装模块和包

在本书的第四章和第五章中,我们使用了pip,Python 包管理器,来安装我们想要使用的各种库。正如我们在第七章中所学到的,pip 通常会将一个包安装到 Python 的site-packages目录中。由于这个目录在模块搜索路径中列出,你新安装的模块或包就可以被导入和在你的代码中使用。

现在让我们使用 pip 来安装我们在上一节中创建的测试包。由于我们知道我们的包已经被命名为<username>_test_package,其中<username>是你的 GitHub 用户名,你可以通过在终端或命令行窗口中输入以下命令,直接将这个包安装到你的site-packages目录中:

pip install <username>_test_package

确保你用你的 GitHub 用户名替换<username>。请注意,如果你没有权限写入 Python 安装的site-packages目录,你可能需要在这个命令的开头添加sudo

sudo pip install <username>_test_package

如果你这样做,你将被提示在运行pip命令之前输入你的管理员密码。

一切顺利的话,你应该看到各种命令被运行,因为你新创建的包被下载和安装。假设这成功了,你可以开始你的 Python 解释器,并访问你的新包,就像它是 Python 标准库的一部分一样。例如:

>>> from <username>_test_package import test
>>> test.run()
IFIbH
AAchwnW
qVtRUuSyb
UPF
zXkY
TMJEAZm
wRJCqgomV
oMzmv
LaDeVg
RDfMqScM

当然,不仅你可以做到这一点。其他 Python 开发人员也可以以完全相同的方式访问你的新包。这使得开发人员非常容易地下载和使用你的包。

除了一些例外情况,您可以使用 pip 从 Python 软件包索引安装任何软件包。默认情况下,pip 将安装软件包的最新可用版本;要指定特定版本,您可以在安装软件包时提供版本号,就像这样:

pip install <username>_test_package == 1.0

这将安装您的测试软件包的 1.0 版本。如果您已经安装了一个软件包,并且有一个更新的版本可用,您可以使用--upgrade命令行选项将软件包升级到更新的版本:

pip install --upgrade <username>_test_package

您还可以使用list命令获取已安装的软件包列表:

pip list

还有一个 pip 的功能需要注意。您可以创建一个要求文件,列出您想要的所有软件包,并一次性安装它们。典型的要求文件看起来可能是这样的:

Django==1.8.2
Pillow==3.0.0
reportlab==3.2.0

要求文件列出了您想要安装的各种软件包及其关联的版本号。

按照惯例,要求文件的名称为requirements.txt,并放置在项目的顶层目录中。要求文件非常有用,因为它们使得通过一个命令轻松地重新创建 Python 开发环境成为可能,包括程序所依赖的所有软件包。这是通过以下方式完成的:

pip install -r requirements.txt

由于要求文件存储在程序源代码旁边,通常会在源代码存储库中包含requirements.txt文件。这意味着您可以克隆存储库到新计算机,并且只需一个命令,重新安装程序所依赖的所有模块和包。

虽然您可以手动创建一个要求文件,但通常会使用 pip 为您创建此文件。安装所需的模块和软件包后,您可以使用以下命令创建requirements.txt文件:

pip freeze > requirements.txt

这个命令的好处是,您可以在任何时候重新运行它,以满足您的要求变化。如果您发现您的程序需要使用一个新的模块或软件包,您可以使用pip install来安装新的模块或软件包,然后立即调用pip freeze来创建一个包含新依赖项的更新要求文件。

在安装和使用模块和软件包时,还有一件事需要注意:有时,您需要安装不同版本的模块或软件包。例如,也许您想运行一个需要 Django 软件包 1.6 版本的特定程序,但您只安装了 1.4 版本。如果您更新 Django 到 1.6 版本,可能会破坏依赖于它的其他程序。

为了避免这种情况,您可能会发现在您的计算机上设置一个虚拟环境非常有用。虚拟环境就像一个单独的 Python 安装,拥有自己安装的模块和软件包。您可以为每个项目创建一个单独的虚拟环境,这样每个项目都可以有自己的依赖关系,而不会干扰您可能在计算机上安装的其他项目的要求。

当您想要使用特定的虚拟环境时,您必须激活它。然后,您可以使用pip install将各种软件包安装到该环境中,并使用您安装的软件包运行程序。当您想要完成对该环境的工作时,您可以停用它。这样,您可以根据需要在不同项目上工作时在虚拟环境之间切换。

虚拟环境是处理不同且可能不兼容的软件包要求的项目的非常强大的工具。您可以在docs.python-guide.org/en/latest/dev/virtualenvs/找到有关虚拟环境的更多信息。

总结

在本章中,我们了解了各种测试 Python 模块和包的方法。我们了解了单元测试以及 Python 标准库中的unittest包如何更容易地编写和使用你开发的模块和包的单元测试。我们看到单元测试如何使用assert语句(或者如果你使用unittest.TestCase类,则使用各种assertXXX()方法)来在特定条件未满足时引发AssertionError。通过编写各种单元测试,你可以确保你的模块和包按照你的期望工作。

我们接着看了准备模块或包进行发布的过程,并了解了 GitHub 如何提供一个优秀的存储库来存储和管理你的模块和包的源代码。

在创建了我们自己的测试包之后,我们通过了将该包提交到 Python Package Index 的过程。最后,我们学会了如何使用 pip,Python 包管理器,将一个包从 PyPI 安装到系统的site-packages目录中,然后看了一下使用要求文件或虚拟环境来帮助管理程序依赖的方法。

在本书的最后一章中,我们将看到模块化编程如何更普遍地作为良好编程技术的基础。

第九章:模块化编程作为良好编程技术的基础

在本书中,我们已经走了很长的路。从学习 Python 中模块和包的工作原理,以及如何使用它们更好地组织代码,我们发现了许多常见的实践,用于应用模块化模式来解决各种编程问题。我们已经看到模块化编程如何允许我们以最佳方式处理现实世界系统中的变化需求,并学会了使模块或包成为在新项目中重复使用的合适候选者的条件。我们已经看到了许多 Python 中处理模块和包的更高级技术,以及避免在这一过程中可能遇到的陷阱的方法。

最后,我们看了测试代码的方法,如何使用源代码管理系统来跟踪您对代码的更改,以及如何将您的模块或包提交到 Python 包索引(PyPI),以便其他人可以找到并使用它。

利用我们迄今为止学到的知识,您将能够熟练应用模块化技术到您的 Python 编程工作中,创建健壮且编写良好的代码,可以在各种程序中重复使用。您还可以与其他人分享您的代码,无论是在您的组织内部还是更广泛的 Python 开发者社区内。

在本章中,我们将使用一个实际的例子来展示模块和包远不止于组织代码:它们有助于更有效地处理编程的过程。我们将看到模块对于任何大型系统的设计和开发是至关重要的,并演示使用模块化技术创建健壮、有用和编写良好的模块是成为一名优秀程序员的重要组成部分。

编程的过程

作为程序员,我们往往过于关注程序的技术细节。也就是说,我们关注产品而不是编程的过程。解决特定编程问题的困难是如此之大,以至于我们忘记了问题本身会随着时间的推移而发生变化。无论我们多么努力避免,变化都是不可避免的:市场的变化、需求的变化和技术的变化。作为程序员,我们需要能够有效地应对这种变化,就像我们需要能够实施、测试和调试我们的代码一样。

回到第四章用于真实世界编程的模块,我们看了一个面临变化需求挑战的示例程序。我们看到模块化设计如何使我们能够在程序的范围远远超出最初设想的情况下最小化需要重写的代码量。

现在我们已经更多地了解了模块化编程和相关技术,可以帮助使其更加有效,让我们再次通过这个练习。这一次,我们将选择一个简单的包,用于计算某个事件或对象的发生次数。例如,想象一下,您需要记录在农场散步时看到每种动物的数量。当您看到每种动物时,通过将其传递给计数器来记录其存在,最后,计数器将告诉您每种动物您看到了多少只。例如:

>>> 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 文件。在该文件中输入以下内容:

About the ``counter`` package
-----------------------------

``counter`` is a package designed to make it easy to keep track of the number of times some event or object occurs.  Using this package, you **reset** the counter, **add** the various values to the counter, and then retrieve the calculated **totals** to see how often each value occurred.

让我们更仔细地看看这个包可能如何使用。假设您想要统计在给定时间范围内观察到的每种颜色的汽车数量。您将首先进行以下调用:

    counter.reset()

然后当您识别到特定颜色的汽车时,您将进行以下调用:

    counter.add(color)

最后,一旦时间结束,您将以以下方式获取各种颜色及其出现次数:

    for color,num_occurrences in counter.totals():
        print(color, num_occurrences)

然后计数器可以被重置以开始计算另一组值。

现在让我们实现这个包。在我们的counter目录中,创建另一个名为counter的目录来保存我们包的源代码,并在这个最里层的counter目录中创建一个包初始化文件(__init__.py)。我们将按照之前使用的模式,在一个名为interface.py的模块中定义我们包的公共函数,然后将其导入__init__.py文件中,以便在包级别提供各种函数。为此,编辑__init__.py文件,并在该文件中输入以下内容:

from .interface import *

我们的下一个任务是实现interface模块。在counter包目录中创建interface.py文件,并在该文件中输入以下内容:

def reset():
    pass

def add(value):
    pass

def totals():
    pass

这些只是我们counter包的公共函数的占位符;我们将逐一实现这些函数,从reset()函数开始。

遵循使用文档字符串记录每个函数的推荐做法,让我们从描述这个函数做什么开始。编辑现有的reset()函数定义,使其看起来像以下内容:

def reset():
    """ Reset our counter.

        This should be called before we start counting.
    """
    pass

请记住,文档字符串是一个三引号字符串(跨越多行的字符串),它“附加”到一个函数上。文档字符串通常以对函数做什么的一行描述开始。如果需要更多信息,这将后跟一个空行,然后是一行或多行更详细描述函数的信息。正如您所看到的,我们的文档字符串包括一行描述和一行额外提供有关函数的更多信息。

现在我们需要实现这个函数。由于我们的计数器包需要跟踪每个唯一值出现的次数,将这些信息存储在一个将唯一值映射到出现次数的字典中是有意义的。我们可以将这个字典存储为一个私有全局变量,由我们的reset()函数初始化。知道了这一点,我们可以继续实现我们reset()函数的其余部分:

def reset():
    """ Reset our counter.

        This should be called before we start counting.
    """
    global _counts
    _counts = {} # Maps value to number of occurrences.

有了私有的_counts全局变量定义,我们现在可以实现add()函数。这个函数记录给定值的出现次数,并将结果存储到_counts字典中。用以下代码替换add()函数的占位实现:

def add(value):
    """ Add the given value to our counter.
    """
    global _counts

    try:
        _counts[value] += 1
    except KeyError:
        _counts[value] = 1

这里不应该有任何意外。我们的最终函数totals()返回了添加到_counts字典中的值,以及每个值出现的次数。以下是必要的代码,应该替换您现有的totals()函数的占位符:

def totals():
    """ Return the number of times each value has occurred.

        We return a list of (value, num_occurrences) tuples, one
        for each unique value included in the count.
    """
    global _counts

    results = []
    for value in sorted(_counts.keys()):
        results.append((value, _counts[value]))
    return results

这完成了我们对counter包的第一个实现。我们将尝试使用我们在上一章学到的临时测试技术来测试它:打开一个终端或命令行窗口,使用cd命令将当前目录设置为最外层的counter目录。然后,输入python启动 Python 交互解释器,并尝试输入以下命令:

import counter
counter.reset()
counter.add(1)
counter.add(2)
counter.add(1)
print(counter.totals())

一切顺利的话,您应该会看到以下输出:

[(1, 2), (2, 1)]

这告诉您值1出现了两次,值2出现了一次——这正是您对add()函数的调用所表明的。

现在我们的软件包似乎正在工作,让我们创建一些单元测试,以便更系统地测试我们的软件包。在最外层的counter目录中创建一个名为tests.py的新文件,并将以下代码输入到这个文件中:

import unittest
import counter

class CounterTestCase(unittest.TestCase):
    """ Unit tests for the ``counter`` package.
    """
    def test_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)])

    def test_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,然后在命令行中输入以下内容:

python tests.py

一切顺利的话,您应该会看到以下输出,表明您的两个单元测试都没有出现错误:

..
---------------------------------------------------------------------
Ran 2 tests in 0.000s

OK

不可避免的变化

在这个阶段,我们现在有一个完全正常工作的counter软件包,具有良好的文档和单元测试。然而,想象一下,您的软件包的要求现在发生了变化,对您的设计造成了重大问题:现在不再是简单地计算唯一值的数量,而是需要支持值的范围。例如,您的软件包的用户可能会定义从 0 到 5、5 到 10 和 10 到 15 的值范围;每个范围内的值都被分组在一起进行计数。以下插图显示了如何实现这一点:

不可避免的变化

为了使您的软件包支持范围,您需要更改接口以接受可选的范围值列表。例如,要计算 0 到 5、5 到 10 和 10 到 15 之间的值,可以使用以下参数调用reset()函数:

counter.reset([0, 5, 10, 15])

如果没有参数传递给counter.reset(),那么整个软件包应该继续像现在一样工作,记录唯一值而不是范围。

让我们实现这个新功能。首先,编辑reset()函数,使其看起来像下面这样:

def reset(ranges=None):
    """ Reset our counter.

        If 'ranges' is supplied, the given list of values will be
        used as the start and end of each range of values.  In
        this case, the totals will be calculated based on a range
        of values rather than individual values.

        This should be called before we start counting.
    """
    global _ranges
    global _counts

    _ranges = ranges
    _counts = {} # If _ranges is None, maps value to number of
                 # occurrences.  Otherwise, maps (min_value,
                 # max_value) to number of occurrences.

这里唯一的区别,除了更改文档,就是我们现在接受一个可选的ranges参数,并将其存储到私有的_ranges全局变量中。

现在让我们更新add()函数以支持范围。更改您的源代码,使得这个函数看起来像下面这样:

def add(value):
    """ Add the given value to our counter.
    """
    global _ranges
    global _counts

    if _ranges == None:
        key = value
    else:
        for i in range(len(_ranges)-1):
            if value >= _ranges[i] and value < _ranges[i+1]:
                key = (_ranges[i], _ranges[i+1])
                break

    try:
        _counts[key] += 1
    except KeyError:
        _counts[key] = 1

这个函数的接口没有变化;唯一的区别在于,在幕后,我们现在检查我们是否正在计算值范围的总数,如果是的话,我们将键设置为标识范围的(min_value, max_value)元组。这段代码有点混乱,但它可以很好地隐藏这个函数的使用代码中的复杂性。

我们需要更新的最后一个函数是totals()函数。如果我们使用范围,这个函数的行为将会改变。编辑接口模块的副本,使totals()函数看起来像下面这样:

def totals():
    """ Return the number of times each value has occurred.

        If we are currently counting ranges of values, we return a
        list of  (min_value, max_value, num_occurrences) tuples,
        one for each range.  Otherwise, we return a list of
        (value, num_occurrences) tuples, one for each unique value
        included in the count.
    """
    global _ranges
    global _counts

    if _ranges != None:
        results = []
        for i in range(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))
        return results
    else:
        results = []
        for value in sorted(_counts.keys()):
            results.append((value, _counts[value]))
        return results

这段代码有点复杂,但我们已经更新了函数的文档字符串,以描述新的行为。现在让我们测试我们的代码;启动 Python 解释器,尝试输入以下指令:

import counter
counter.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脚本,并将以下新的测试用例添加到此模块:

class RangeCounterTestCase(unittest.TestCase):
    """ Unit tests for the range-based features of the
        ``counter`` package.
    """
    def test_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 (most recent call last):
  File "tests.py", line 35, in test_range_totals
    counter.add(19.1)
  File "/Users/erik/Project Support/Work/Packt/PythonModularProg/First Draft/Chapter 9/code/counter-ranges/counter/interface.py", line 36, in add
    _counts[key] += 1
UnboundLocalError: local variable 'key' referenced before assignment

我们的test_range_totals()单元测试失败,因为我们的包在尝试将值19.1添加到我们的范围计数器时会出现UnboundLocalError。稍加思考就会发现问题所在:我们定义了三个范围,0-55-1010-15,但现在我们试图将值19.1添加到我们的计数器中。由于19.1超出了我们设置的范围,我们的包无法为这个值分配一个范围,因此我们的add()函数崩溃了。

很容易解决这个问题;将以下突出显示的行添加到您的add()函数中:

def add(value):
    """ Add the given value to our counter.
    """
    global _ranges
    global _counts

    if _ranges == None:
        key = value
    else:
 **key = None
        for i in range(len(_ranges)-1):
            if value >= _ranges[i] and value < _ranges[i+1]:
                key = (_ranges[i], _ranges[i+1])
                break
 **if key == None:
 **raise RuntimeError("Value out of range: {}".format(value))

    try:
        _counts[key] += 1
    except KeyError:
        _counts[key] = 1

这会导致我们的包在用户尝试添加超出我们设置的范围的值时返回RuntimeError

不幸的是,我们的单元测试仍然崩溃,只是现在以RuntimeError的形式失败。为了解决这个问题,从test_range_totals()单元测试中删除counter.add(19.1)行。我们仍然希望测试这种错误情况,但我们将在单独的单元测试中进行。在您的RangeCounterTestCase类的末尾添加以下内容:

    def test_out_of_range(self):
        counter.reset([0, 5, 10, 15])
        with self.assertRaises(RuntimeError):
            counter.add(19.1)

这个单元测试专门检查我们之前发现的错误情况,并确保包在提供的值超出请求的范围时正确返回RuntimeError

注意,我们现在为我们的包定义了四个单独的单元测试。我们仍在测试包,以确保它在没有范围的情况下运行,以及测试我们所有基于范围的代码。因为我们已经实施(并开始充实)了一系列针对我们的包的单元测试,我们可以确信,为了支持范围所做的任何更改都不会破坏不使用新基于范围的功能的任何现有代码。

正如您所看到的,我们使用的模块化编程技术帮助我们最大限度地减少了对代码所需的更改,并且我们编写的单元测试有助于确保更新后的代码继续按我们期望的方式工作。通过这种方式,模块化编程技术的使用使我们能够以最有效的方式处理不断变化的需求和编程的持续过程。

处理复杂性

无法逃避计算机程序是复杂的这一事实。事实上,随着对包的要求发生变化,这种复杂性似乎只会随着时间的推移而增加——程序很少在进行过程中变得更简单。模块化编程技术是处理这种复杂性的一种极好方式。通过应用模块化技术和技术,您可以:

  • 使用模块和包来保持您的代码组织良好,无论它变得多么复杂

  • 使用模块化设计的标准模式,包括分而治之技术、抽象和封装,将这种复杂性降至最低

  • 将单元测试技术应用于确保在更改和扩展模块或包的范围时,您的代码仍然按预期工作。

  • 编写模块和函数级别的文档字符串,清楚地描述代码的每个部分所做的工作,以便在程序增长和更改时能够跟踪一切。

要了解这些模块化技术和技术有多么重要,只需想一想,如果在开发一个大型、复杂和不断变化的系统时不使用它们,你将会陷入多么混乱的境地。没有模块化设计技术和标准模式的应用,比如分而治之、抽象和封装,你会发现自己编写了结构混乱的意大利面代码,带来许多意想不到的副作用,并且新功能和变化散布在你的源代码中。没有单元测试,你将无法确保你的代码在进行更改时仍然能够正常工作。最后,缺乏嵌入式文档将使跟踪系统的各个部分变得非常困难,导致错误和没有经过深思熟虑的更改,因为你继续开发和扩展你的代码。

出于这些原因,很明显模块化编程技术对于任何大型系统的设计和开发至关重要,因为它们帮助你以最佳方式处理复杂性。

成为一名有效的程序员

既然你已经看到模块化编程技术有多么有用,你可能会想知道为什么会有人不想使用它们。除了缺乏理解之外,为什么程序员会避开模块化原则和技术呢?

Python 语言从头开始就被设计为支持良好的模块化编程技术,并且通过优秀的工具(如 Python 标准库、单元测试和文档字符串)的添加,它鼓励你将这些技术应用到你的日常编程实践中。同样,使用缩进来定义代码的结构自动鼓励你编写格式良好的源代码,其中代码的缩进反映了程序的逻辑组织。这些都不是随意的选择:Python 在每一步都鼓励良好的编程实践。

当然,就像你可以使用 Python 编写结构混乱和难以理解的意大利面代码一样,你也可以在开发程序时避免使用模块化技术和实践。但你为什么要这样呢?

程序员有时在编写他们认为是“一次性”的代码时会采取捷径。例如,也许你正在编写一个小程序,你只打算使用一次,然后再也不需要使用了。为什么要花额外的时间将推荐的模块化编程实践应用到这个一次性的程序上呢?

问题是,一次性代码有一个有趣的习惯,就是变成永久的,并发展成为一个更大的复杂系统。经常情况下,最初的一次性代码成为一个大型和复杂系统的基础。你六个月前写的代码可能会在新程序中被找到和重用。最终,你永远不知道什么是一次性代码,什么不是。

基于这些原因,无论代码有多大或多小,始终应该应用模块化编程实践。虽然你可能不想花很多时间为一个简单的一次性脚本编写大量的文档字符串和单元测试,但你仍然可以应用基本的模块化技术来帮助保持代码的组织。不要只把模块化编程技术留给你的“大”项目。

幸运的是,Python 实现的模块化编程方式非常容易使用,过一段时间后,你开始在编写一行代码之前就以模块化的方式思考。我认为这是一件好事,因为模块化编程技术是成为一名优秀程序员的重要组成部分,你应该在编程时练习这些技术。

总结

在本章,甚至整本书中,我们已经看到模块化编程技术的应用如何帮助你以最有效的方式处理编程的过程。你不是在回避变化,而是能够管理它,使得你的代码能够持续工作,并且通过新的需求不断改进。

我们已经看到了另一个需要根据不断扩大的需求进行更改的程序的例子,并且已经看到了模块化技术的应用,包括使用文档字符串和单元测试,有助于编写健壮且易于理解的代码,随着不断的开发和更改而不断改进。

我们已经看到了模块化技术的应用是处理程序复杂性的重要部分,而这种复杂性随着时间的推移只会增加。我们已经了解到,正因为如此,使用模块化编程技术是成为优秀程序员的重要组成部分。最后,我们已经看到,模块化技术是每次你坐下来编程时都可以使用的东西,即使是简单的一次性脚本,而不是要为你的“大”项目保留的东西。

希望你觉得这个关于模块化编程世界的介绍有用,并且现在开始将模块化技术和模式应用到你自己的编程中。我鼓励你继续尽可能多地了解围绕良好的模块化编程实践的各种工具,比如使用文档字符串和 Sphinx 库来为你的包自动生成文档,以及使用virtualenv来设置和使用虚拟环境来管理你程序的包依赖关系。你继续使用模块化实践和技术,它将变得更容易,你作为程序员也将变得更有效率。愉快的编程!

posted @ 2024-05-04 21:28  绝不原创的飞龙  阅读(244)  评论(0编辑  收藏  举报