在这本书中,我们取得了长足的进步。通过学习模块和包在 Python 中如何工作,以及如何使用它们更好地组织代码,我们发现了许多用于应用模块模式来解决一系列编程问题的常见做法。我们已经了解了模块化编程如何使我们能够以最好的方式处理现实系统中不断变化的需求,并了解了是什么使模块或包成为新项目中重用的合适候选者。我们已经看到了许多在 Python 中使用模块和包的更高级技术,以及避免过程中可能遇到的陷阱的方法。
最后,我们研究了测试代码的方法,如何使用源代码管理系统跟踪您在一段时间内对代码所做的更改,以及如何将模块或包提交给 Python 包索引(PyPI),以便其他人能够找到并使用它。
使用到目前为止我们所学到的知识,您将能够熟练地将模块化技术应用到 Python 编程工作中,创建可在各种程序中重用的健壮且编写良好的代码。您还可以在组织内部和更广泛的 Python 开发人员社区内与其他人共享代码。
在最后一章中,我们将使用一个实际的例子来展示模块和包如何不仅仅是组织代码:它们有助于更有效地处理编程的过程。我们将了解模块对于任何大型系统的设计和开发是如何至关重要,并演示如何使用模块化技术来创建健壮、有用且编写良好的模块,这是成为一名优秀程序员的重要部分。
作为程序员,我们经常关注程序的技术细节。也就是说,我们关注的是产品,而不是编程的过程。解决一个特定编程问题的困难是如此之大,以至于我们忘记了问题本身会随着时间的推移而改变。无论我们如何努力避免,变化都是不可避免的:不断变化的市场、不断变化的需求和不断变化的技术。作为程序员,我们需要能够有效地应对这种变化,就像我们需要能够实现、测试和调试代码一样。
回到第 4 章使用模块进行现实编程,我们看了一个面临需求变化挑战的示例程序。我们看到了模块化设计如何使我们能够在程序范围远远超出最初设想的范围时,最小化必须重写的代码量。
现在,我们已经了解了更多关于模块化编程和相关技术的知识,这些知识有助于提高模块化编程的效率,让我们再次完成这个练习。这一次,我们将选择一个简单的包来计算某些事件或对象的出现次数。例如,假设您需要记录在穿过农场时看到的每种动物的数量。当你看到每一种动物时,你把它递给柜台,记录下它的存在,最后,柜台会告诉你你看到了多少种动物。例如:
>>> 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)]这是一个简单的包,但它为我们提供了一个很好的目标,用于应用我们在前几章中学习的一些更有用的技术。特别是,我们将使用docstrings来记录我们包中的每个函数都做了什么,并且我们将编写一系列单元测试来确保我们的包按照我们期望的方式工作。
让我们首先创建一个目录来保存我们的新项目,我们将其称为 Counter。在方便的地方创建一个名为counter的目录,然后在此目录中添加一个名为README.rst的新文件。由于我们希望最终将此包上载到 Python 包索引,因此我们将对自述文件使用 StructuredText 格式。在此文件中输入以下内容:
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()函数开始,一次实现一个。
按照使用 docstring 记录每个函数的推荐实践,让我们从描述此函数的功能开始。编辑reset()函数的现有定义,使其如下所示:
def reset():
""" Reset our counter.
This should be called before we start counting.
"""
pass请记住,docstring 是“附加”到函数的三引号字符串(跨多行的字符串)。docstring 通常以函数功能的单行描述开始。如果需要更多信息,则后面会有一个空行,后面会有一行或多行更详细地描述功能。正如您所看到的,我们的 docstring 由一行描述和一行附加内容组成,提供了有关我们函数的更多信息。
我们现在需要来实现这个功能。由于我们的计数器包需要跟踪每个唯一值出现的次数,因此将此信息存储在将唯一值映射到出现次数的字典中是有意义的。我们可以将该字典存储为私有全局变量,该变量由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()之前添加的任何值。
要运行这些测试,请按Control+D退出 Python 交互式解释器,然后在命令行中键入以下内容:
python tests.py一切进展顺利,您应该看到以下输出,表明您的两个单元测试运行时没有任何错误:
..
---------------------------------------------------------------------
Ran 2 tests in 0.000s
OK在这个阶段,我们现在有了一个正常工作的counter包,包含良好的文档和单元测试。然而,想象一下,您的包的需求现在发生了变化,这给您的设计带来了重大问题:您现在需要支持范围的值,而不是简单地计算唯一值的数量。例如,包的用户可以定义 0 到 5、5 到 10 以及 10 到 15 的值范围;为便于计数,将每个范围内的值分组在一起。下图显示了如何执行此操作:
要使您的软件包支持范围,您需要更改reset()函数的接口,以接受范围值的可选列表。例如,要计算 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此功能的接口无变化;唯一的区别是在幕后,我们现在检查是否正在计算值范围的总数,如果是,我们将_counts字典中的键设置为识别范围的(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这段代码有点复杂,但我们已经更新了函数的 docstring 来描述新的行为。现在让我们测试我们的代码;启动 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)]这对应于您定义的三个范围,并显示有三个值落在第一个范围内,两个值落在第二个范围内,只有一个值落在第三个范围内。
在这个阶段,您的更新包似乎是成功的。就像我们在第 6 章中看到的创建可重用模块的例子一样,我们能够使用模块化编程技术来限制支持包中主要新功能所需的更改数量。我们已经进行了一些测试,更新后的软件包似乎正常工作。
然而,我们不会就此止步。因为我们在包中添加了一个主要的新特性,所以我们应该添加一些单元测试,以确保该特性正常工作。编辑您的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-5、5-10和10-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。
请注意,我们现在为包定义了四个独立的单元测试。我们仍在测试这个包,以确保它在没有范围的情况下运行,并测试所有基于范围的代码。因为我们已经为我们的包实现了(并开始充实)一系列单元测试,所以我们可以确信,我们对支持范围所做的任何更改都不会破坏任何不使用新的基于范围的特性的现有代码。
正如您所看到的,我们使用的模块化编程技术帮助我们最小化代码所需的更改,而我们编写的单元测试有助于确保更新后的代码继续按预期工作。通过这种方式,模块化编程技术的使用使我们能够以最有效的方式处理不断变化的需求和正在进行的编程过程。
计算机程序是复杂的,这是不可避免的。事实上,随着软件包需求的变化,这种复杂性似乎只会随着时间的推移而增加,随着时间的推移,程序很少变得简单。模块化编程技术是处理这种复杂性的极好方法。通过应用模块化技术和技术,您可以:
- 无论代码变得多么复杂,都要使用模块和包来保持代码的良好组织
- 使用模块化设计的标准模式,包括分而治之技术、抽象和封装,将这种复杂性降至最低
- 应用单元测试技术,确保您的代码在更改和扩展模块或包的范围时继续正常工作
- 编写模块级和函数级的 docstring,以清楚地描述代码的每一部分的功能,以便在程序增长和更改时跟踪所有内容。
要了解这些模块化技术和技术的重要性,只需想一想,如果在开发大型、复杂且不断变化的系统时不使用它们,最终会造成多大的混乱。如果没有模块化设计技术和标准模式(如分而治之、抽象和封装)的应用,您将发现自己编写的杂乱无章的意大利面代码具有许多意想不到的副作用,并且新特性和更改遍布整个源代码。如果没有单元测试,您将无法确保代码在进行更改时继续正常工作。最后,缺乏嵌入式文档将使您很难跟踪系统的所有不同部分,从而导致在继续开发和扩展代码时出现错误和考虑不周的更改。
由于所有这些原因,很明显,模块化编程技术对于任何大型系统的设计和开发都至关重要,因为它们可以帮助您以最佳方式处理复杂性。
既然您已经了解了模块化编程技术是多么有用,您可能会想知道为什么有人不想使用它们。除了缺乏理解,程序员为什么要回避模块化原则和技术?
Python 语言从一开始就被设计为支持良好的模块化编程技术,加上诸如 Python 标准库、单元测试和 DocString 等优秀工具,它鼓励您将这些技术应用到日常编程实践中。类似地,使用缩进定义代码的结构会自动鼓励您编写格式良好的源代码,其中代码的缩进反映了程序的逻辑组织。这些不是随机的选择:Python 鼓励每一步都有良好的编程实践。
当然,正如您可以使用 Python 编写结构糟糕、难以理解的意大利面代码一样,在开发程序时也可以避免使用模块化技术和实践。但你为什么要这么做?
程序员在编写他们认为是“一次性”代码的程序时有时会采取捷径。例如,您可能正在编写一个小程序,希望只使用一次,然后再也不需要使用。为什么要花额外的时间将推荐的模块化编程实践应用于这个一次性程序?
问题是,一次性代码有一个有趣的习惯,它会变成永久性的,并发展成更大的东西。通常,以一次性代码开头的内容会成为大型复杂系统的基础。您六个月前编写的代码可以在新程序中找到并重用。最后,你永远不知道什么是一次性代码,什么不是。
出于这些原因,始终将模块化编程实践应用于代码是一个好主意,无论代码大小。虽然您不想花费大量时间为一个简单的一次性脚本编写大量的 docstring 和单元测试,但您仍然可以应用基本的模块化技术来帮助保持代码的条理化。不要只为你的“大”项目保存模块化编程技术。
幸运的是,Python 实现模块化编程的方式使其非常易于使用,过了一段时间,您甚至在开始编写一行代码之前就开始以模块化的方式思考。我相信这是一件好事,因为模块化编程技术是成为一名优秀程序员必不可少的一部分,每当你坐下来编程时,你都应该练习这些技术。
在本章中,甚至在整本书中,我们已经了解了模块化编程技术的应用如何帮助您以最有效的方式处理编程的过程。您不需要避免更改,而是能够对其进行管理,从而使您的代码能够继续工作,并随着时间的推移,随着新需求的提出而得到改进。
我们已经看到了另一个需要更改以满足不断扩展的需求的程序示例,并且已经看到了模块化技术(包括使用 docstring 和单元测试)如何帮助编写健壮且易于理解的代码,并随着代码的不断开发和更改而不断改进。
我们已经看到,模块化技术的应用是如何处理程序复杂性的一个重要部分,并且这种复杂性只会随着时间的推移而增加。我们已经了解到,正因为如此,模块化编程技术的使用是成为一名优秀程序员的重要组成部分。最后,我们已经看到,模块化技术可以在您每次坐下来编程时使用,即使对于简单的一次性脚本也是如此,而不是保存在“大型”项目中。
我希望您已经发现这个模块化编程世界的介绍很有用,并且现在开始将模块化技术和模式应用到您自己的编程中。我鼓励您继续尽可能多地了解围绕良好模块化编程实践的各种工具,例如使用 docstrings 和 Sphinx 库为您的包自动生成文档,以及使用virtualenv设置和使用虚拟环境来管理程序的包依赖关系。您继续使用模块化实践和技术的次数越多,它就会变得越容易,作为一名程序员,您的效率也就越高。快乐编码!
