精通-Python-面向对象(全)

精通 Python 面向对象(全)

原文:zh.annas-archive.org/md5/71D8E1561B9B007B1EB71F3D91586378

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

本书将向您介绍 Python 编程语言的更高级特性。重点是创建尽可能高质量的 Python 程序。这通常意味着创建具有最高性能或最易维护性的程序。这意味着探索设计替代方案,并确定哪种设计在解决的问题中提供了最佳性能。

本书大部分内容将探讨给定设计的多种替代方案。有些性能更好。有些看起来更简单,或者对于问题领域来说是更好的解决方案。找到最佳算法和最佳数据结构,以最少的计算机处理来创造最大价值是至关重要的。时间就是金钱,节省时间的程序将为其用户创造更多价值。

Python 使许多内部特性直接可用于我们的应用程序。这意味着我们的程序可以与现有的 Python 功能紧密集成。通过确保我们的 OO 设计良好地集成,我们可以利用许多 Python 功能。

我们经常专注于一个特定的问题,并检查该问题的几种不同解决方案。当我们研究不同的算法和数据结构时,我们将看到不同的内存和性能替代方案。通过研究替代解决方案,以便正确优化最终应用程序,这是一项重要的 OO 设计技能。

本书的一个更重要的主题是,对于任何问题,都没有单一的最佳方法。有许多不同属性的替代方法。

在编程风格方面,风格的主题引起了令人惊讶的兴趣。敏锐的读者会注意到,示例并没有在每一个细节上都严格遵循 PEP-8 的名称选择或标点符号。

随着我们朝着掌握面向对象的 Python 的目标迈进,我们将花费大量时间阅读来自各种来源的 Python 代码。我们将观察到即使在 Python 标准库模块中也存在广泛的差异。与其呈现所有完全一致的示例,我们选择了一些不一致,这种不一致将更好地符合在野外遇到的各种开源项目中所见的代码。

本书涵盖的内容

我们将在一系列章节中涵盖三个高级 Python 主题,深入探讨细节。

  • 一些准备工作,涵盖了一些初步主题,如 unittest,doctest,docstrings 和一些特殊方法名称。

第一部分,通过特殊方法创建 Pythonic 类:本部分更深入地探讨了面向对象编程技术,以及如何更紧密地将应用程序的类定义与 Python 的内置功能集成。它包括以下九章:

  • 第一章方法"),init()方法,为我们提供了对__init__()方法的详细描述和实现。我们将研究简单对象的不同初始化形式。从中,我们可以研究涉及集合和容器的更复杂对象。

  • 第二章,与 Python 基本特殊方法无缝集成,将详细解释如何扩展简单的类定义以添加特殊方法。我们需要查看从对象继承的默认行为,以便了解何时需要覆盖以及何时实际上需要覆盖。

  • 第三章,属性访问、属性和描述符,详细介绍了默认处理的工作原理。我们需要决定何时以及在哪里覆盖默认行为。我们还将探讨描述符,并更深入地了解 Python 内部的工作原理。

  • 第四章,一致设计的抽象基类,总体上看抽象基类在collections.abc模块中的抽象基类。我们将研究我们可能想要修改或扩展的各种容器和集合背后的一般概念。同样,我们将研究我们可能想要实现的数字背后的概念。

  • 第五章,使用可调用对象和上下文,通过contextlib中的工具来创建上下文管理器的几种方法。我们将展示一些可调用对象的变体设计。这将向您展示为什么有状态的可调用对象有时比简单函数更有用。我们还将研究如何使用一些现有的 Python 上下文管理器,然后再深入编写我们自己的上下文管理器。

  • 第六章,创建容器和集合,着重介绍容器类的基础知识。我们将回顾涉及成为容器并提供容器所提供的各种特性的各种特殊方法。我们将讨论扩展内置容器以添加功能。我们还将研究包装内置容器并通过包装器委托方法到底层容器。

  • 第七章,创建数字,涵盖了这些基本算术运算符:+-*///%**。我们还将研究这些比较运算符:<><=>===!=。最后,我们将总结一些扩展或创建新数字时涉及的设计考虑。

  • 第八章,装饰器和混入-横切面,涵盖了简单的函数装饰器,带参数的函数装饰器,类装饰器和方法装饰器。

第二部分,持久性和序列化:持久对象已经序列化到存储介质。也许它被转换为 JSON 并写入文件系统。ORM 层可以将对象存储在数据库中。这部分将研究处理持久性的替代方案。本节包括以下五章:

  • 第九章,序列化和保存-JSON,YAML,Pickle,CSV 和 XML,涵盖了使用专注于各种数据表示的库进行简单持久化,如 JSON,YAML,pickle,XML 和 CSV。

  • 第十章,通过 Shelve 存储和检索对象,解释了使用 Python 模块进行基本数据库操作,比如shelve(和dbm)。

  • 第十一章,通过 SQLite 存储和检索对象,转向更复杂的 SQL 和关系数据库世界。因为 SQL 特性与面向对象编程特性不匹配,我们有一个阻抗不匹配问题。一个常见的解决方案是使用 ORM 允许我们持久化大量对象域。

  • 第十二章,传输和共享对象,看一下 HTTP 协议,JSON,YAML 和 XML 表示来传输对象。

  • 第十三章,配置文件和持久性,涵盖了 Python 应用程序可以处理配置文件的各种方式。

第三部分, 测试、调试、部署和维护:我们将向您展示如何收集数据以支持和调试高性能程序。这将包括有关创建最佳文档以减少支持的混乱和复杂性的信息。本节包含最后五章,如下所示:

  • 第十四章, 日志和警告模块,介绍了使用loggingwarning模块创建审计信息以及调试。我们将迈出使用print()函数的重要一步。

  • 第十五章, 可测试性设计,涵盖了可测试性设计以及我们如何使用unittestdoctest

  • 第十六章, 处理命令行,介绍了使用argparse模块解析选项和参数。我们将进一步使用命令设计模式来创建可以组合和扩展的程序组件,而无需编写 shell 脚本。

  • 第十七章, 模块和包设计,涵盖了模块和包设计。这是一组更高级的考虑。我们将研究模块中的相关类和包中的相关模块。

  • 第十八章, 质量和文档,介绍了我们如何记录我们的设计,以建立对我们的软件正确性和正确实施的信任。

本书所需的内容

为了编译和运行本书中提到的示例,您需要以下软件:

本书的受众

这是高级 Python。您需要对 Python 3 非常熟悉。您还将受益于解决较大或复杂的问题。

如果您是其他语言的熟练程序员,并且想要转换到 Python,您可能会发现本书有所帮助。本书不介绍语法或其他基础概念。

高级 Python 2 程序员在切换到 Python 3 时可能会发现这很有帮助。我们不会涵盖任何转换实用程序(例如从版本 2 到 3)或任何共存库(例如 six)。本书侧重于完全在 Python 3 中发生的新开发。

约定

在本书中,您将找到一些区分不同类型信息的文本样式。以下是一些示例及其含义的解释。

文本中的代码单词显示如下:“我们可以通过使用import语句访问其他 Python 模块。”

代码块设置如下:

   class Friend(Contact):
       def __init__(self, name, email, phone):
           self.name = name
           self.email = email
           self.phone = phone

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

   class Friend(Contact):
       def __init__(self, name, email, phone):
           self.name = name
           **self.email = email
           self.phone = phone

任何命令行输入或输出都以以下形式编写:

>>> e = EmailableContact("John Smith", "jsmith@example.net")
>>> Contact.all_contacts

新术语重要单词以粗体显示。屏幕上看到的单词,比如菜单或对话框中的单词,会以这样的方式出现在文本中:"我们使用这个功能来每次点击Roll!按钮时更新标签为一个新的随机值"。

注意

警告或重要说明会显示在这样的框中。

提示

提示和技巧会显示为这样。

第一章:一些准备工作

为了使本书其余部分的设计问题更加清晰,我们需要看一些我们的动机问题。其中之一是二十一点游戏。具体来说,我们对模拟玩二十一点的策略感兴趣。我们不想支持赌博。事实上,稍微研究一下就会发现这个游戏对玩家的不利程度非常高。这应该揭示出大多数赌场赌博只不过是对不识数的人的一种税收。

然而,模拟是面向对象编程的早期问题领域之一。这是一个面向对象编程特别优雅的领域。有关更多信息,请参见en.wikipedia.org/wiki/Simula。还请参见Rob Pooley的《Simula 编程简介》。

本章将介绍一些编写完整 Python 程序和包所必需的工具。我们将在后面的章节中使用这些工具。

我们将利用timeit模块来比较各种面向对象设计,以查看哪种性能更好。重要的是要权衡客观证据和代码如何反映问题领域的主观考虑。

我们将研究unittestdoctest模块的面向对象使用。这些是编写已知实际有效的软件的基本要素。

一个良好的面向对象设计应该清晰易懂。为了确保它被正确理解和使用以及维护,编写 Pythonic 文档是必不可少的。模块、类和方法中的文档字符串非常重要。我们将在这里简要介绍 RST 标记,并在第十八章质量和文档中深入讨论。

除此之外,我们将讨论集成开发环境IDE)的问题。一个常见的问题是关于 Python 开发的最佳IDE。

最后,我们将介绍 Python 特殊方法名称背后的概念。特殊方法的主题填满了前七章。在这里,我们将提供一些背景知识,可能有助于理解第一部分通过特殊方法创建 Python 类

我们将尽量避免陷入 Python 面向对象编程的基础知识。我们假设您已经阅读了Packt Publishing的《Python 3 面向对象编程》。我们不想重复已经在其他地方详细说明的事情。在本书中,我们将专注于 Python 3。

我们将涉及一些常见的面向对象设计模式。我们将尽量避免重复 Packt 的《学习 Python 设计模式》中的内容。

关于赌场二十一点

如果您对赌场二十一点游戏不熟悉,这里有一个概述。

目标是接受来自庄家的牌,以创建一个点数总和介于庄家总和和 21 之间的手。

数字牌(2 到 10)的点数等于数字本身。花牌(J、Q 和 K)值为 10 点。A 可以是 11 点或 1 点。当 A 作为 11 点时,手的值是soft。当 A 作为 1 点时,值是hard

因此,一手有一张 A 和一张 7 的牌,硬总数为 8,软总数为 18。

有四种两张牌的组合总数为 21。尽管其中只有一种组合涉及到 J,但它们都被称为blackjack

玩游戏

赌场二十一点游戏可能因赌场而异,但大纲是相似的。玩法的机制如下:

  • 首先,玩家和庄家各发两张牌。玩家当然知道他们两张牌的价值。在赌场里,它们是正面朝上发的。

  • 庄家的一张牌是正面朝上的,另一张是正面朝下的。因此,玩家对庄家的手有一些了解,但并非全部。

  • 如果庄家展示了一张 A 牌,那么隐藏的牌价值为 10 且庄家有 21 点的概率是 4:13。玩家可以选择额外投保。

  • 接下来,玩家可以选择要么接收牌要么停止接收牌。这两个最常见的选择被称为拿牌停牌

  • 还有一些额外的选择。如果玩家的牌匹配,手可以分开。这是一个额外的赌注,两手分开玩。

  • 最后,玩家可以在拿最后一张牌之前加倍下注。这被称为加倍。如果玩家的牌总数为 10 或 11,这是一个常见的赌注。

手的最终评估如下:

  • 如果玩家超过 21 点,手就爆了,玩家输了,庄家的脸朝下的牌就不重要了。

  • 如果玩家的总数是 21 或以下,那么庄家根据一个简单的固定规则拿牌。庄家必须打一个小于 18 的手。庄家必须站在一个总数为 18 或更多的手上。这里有一些小的变化,我们暂时可以忽略。

  • 如果庄家爆了,玩家赢了。

  • 如果庄家和玩家都是 21 或以下,那么比较手来看玩家是赢了还是输了。

最终赔偿的金额现在并不太重要。为了更准确地模拟各种玩法和投注策略,赔偿将非常重要。

21 点玩家策略

在 21 点的情况下(这与轮盘等游戏不同),玩家实际上必须使用两种策略,如下:

  • 决定游戏玩法的策略:投保、拿牌、停牌、分牌或加倍。

  • 决定下注金额的策略。一个常见的统计谬误导致玩家提高和降低他们的赌注,试图保留他们的赢利并最小化他们的损失。任何模拟赌场游戏的软件也必须模拟这些更复杂的投注策略。这些是有趣的算法,通常是有状态的,并导致学习一些高级的 Python 编程技术。

这两套策略是策略设计模式的主要示例。

用于模拟 21 点的对象设计

我们将使用玩家手和卡作为对象建模的示例。但我们不会设计整个模拟。我们将专注于这个游戏的元素,因为它们有一些微妙之处,但并不是非常复杂。

我们有一个简单的容器:一个手对象将包含零个或多个卡对象。

我们将查看Card的子类NumberCardFaceCardAce。我们将查看定义这个简单类层次结构的各种方法。因为层次结构如此之小(和简单),我们可以很容易地尝试多种实现替代方案。

我们将看一些实现玩家手的方法。这是一个简单的卡片集合,带有一些额外的功能。

我们还需要把玩家作为一个整体来看。玩家将有一系列手,以及一个投注策略和一个 21 点游戏策略。这是一个相当复杂的复合对象。

我们还将快速查看洗牌和发牌的牌组。

性能- timeit 模块

我们将利用timeit模块来比较不同面向对象设计和 Python 构造的实际性能。timeit模块包含许多函数。我们将专注的是名为timeit的函数。这个函数为某个语句创建一个Timer对象。它还可以包括一些设置代码,准备环境。然后调用Timertimeit()方法来执行设置一次,目标语句重复执行。返回值是运行语句所需的时间。

默认计数为 100,000。这提供了一个有意义的时间,可以平均出计算机上执行测量的其他操作系统级活动。对于复杂或长时间运行的语句,较低的计数可能更为谨慎。

以下是与timeit的简单交互:

>>> timeit.timeit( "obj.method()", """
... class SomeClass:
...     def method(self):
...        pass
... obj= SomeClass()
""")
0.1980541350058047

提示

下载示例代码

您可以从您在www.packtpub.com的帐户中购买的所有 Packt Publishing 图书下载示例代码文件。如果您在其他地方购买了这本书,您可以访问www.packtpub.com/support并注册,以便直接将文件发送到您的电子邮件。

语句obj.method()被提供给timeit()作为一个字符串。设置是类定义,也被提供为一个字符串。重要的是要注意语句所需的一切都必须在设置中。这包括所有的导入以及所有的变量定义和对象创建。一切。

完成设置可能需要几次尝试。在使用交互式 Python 时,我们经常会忘记全局变量和已经滚动到终端窗口顶部的导入。这个例子表明,执行 100,000 次什么都不做的方法调用需要 0.198 秒。

以下是另一个使用timeit的例子:

>>> timeit.timeit( "f()","""
... def f():
...     pass
... """ )
0.13721893899491988

这告诉我们,一个无用的函数调用比一个无用的方法调用稍微便宜一些。在这种情况下的开销几乎是 44%。

在某些情况下,操作系统开销可能是性能的一个可测量的组成部分。这些往往会根据难以控制的因素而变化。在这种情况下,我们可以使用这个模块中的repeat()函数来代替timeit()函数。它将收集基本时间的多个样本,以便进一步分析操作系统对性能的影响。

对于我们的目的,timeit()函数将提供我们需要客观衡量各种面向对象设计考虑的反馈。

测试-单元测试和 doctest

单元测试绝对是必不可少的。如果没有自动化测试来展示特定元素的功能,那么这个功能实际上并不存在。换句话说,直到有一个测试表明它已经完成,它才算完成。

我们将间接地涉及测试。如果我们要深入研究每个面向对象设计特性的测试,这本书将比现在的两倍还要大。省略测试的细节的缺点是,它使得良好的单元测试看起来是可选的。它们绝对不是可选的。

提示

单元测试是必不可少的

如果有疑问,首先设计测试。将代码适应测试用例。

Python 提供了两个内置的测试框架。大多数应用程序和库都会同时使用这两个框架。所有测试的通用包装器是unittest模块。此外,许多公共 API 文档字符串都会有可以通过doctest模块找到并使用的示例。此外,unittest可以包含doctest模块。

一个崇高的理想是每个类和函数至少有一个单元测试。更重要的是,可见的类、函数和模块也将有doctest。还有其他崇高的理想:100%的代码覆盖率,100%的逻辑路径覆盖率等等。

从实际角度来看,有些类不需要测试。例如,由namedtuple()创建的类实际上不需要单元测试,除非您不信任namedtuple()的实现。如果您不信任您的 Python 实现,那么您实际上不能用它来编写应用程序。

通常,我们希望首先开发测试用例,然后编写符合这些测试用例的代码。测试用例为代码规范化了 API。这本书将揭示许多编写具有相同接口的代码的方法。这很重要。一旦我们定义了一个接口,仍然有许多候选实现适合这个接口。一组测试应该适用于几种不同的面向对象设计。

使用unittest工具的一般方法是为项目创建至少三个并行目录,如下所示:

  • myproject:这个目录是最终将安装在lib/site-packages中的包或应用程序。它有一个__init__.py包,我们将把我们的文件放在这里的每个模块中。

  • 测试:此目录包含测试脚本。在某些情况下,脚本将与模块并行。在某些情况下,脚本可能比模块本身更大更复杂。

  • doc:这个目录有其他文档。我们将在下一节以及第十八章中涉及到这个。质量和文档

在某些情况下,我们希望在多个候选类上运行相同的测试套件,以确保每个候选类都有效。对于实际上不起作用的代码进行timeit比较是没有意义的。

单元测试和技术尖峰

作为面向对象设计的一部分,我们经常会创建类似于本节中所示代码的技术尖峰模块。我们将其分为三个部分。首先,我们有以下总体抽象测试:

import types
import unittest

class TestAccess( unittest.TestCase ):
    def test_should_add_and_get_attribute( self ):
        self.object.new_attribute= True
        self.assertTrue( self.object.new_attribute )
    def test_should_fail_on_missing( self ):
        self.assertRaises( AttributeError, lambda: self.object.undefined )

这个抽象的TestCase子类定义了一些我们期望一个类通过的测试。被测试的实际对象被省略了。它被引用为self.object,但没有提供定义,使得这个TestCase子类是抽象的。每个具体子类都需要一个setUp()方法。

以下是三个具体的TestAccess子类,它们将测试三种不同类型的对象:

class SomeClass:
    pass
class Test_EmptyClass( TestAccess ):
    def setUp( self ):
       self.object= SomeClass()
class Test_Namespace( TestAccess ):
    def setUp( self ):
       self.object= types.SimpleNamespace()
class Test_Object( TestAccess ):
    def setUp( self ):
       self.object= object()

TestAccess类的子类分别提供了所需的setUp()方法。每个方法构建了不同类型的对象进行测试。一个是一个否则为空的类的实例。第二个是types.SimpleNamespace的实例。第三个是object的实例。

为了运行这些测试,我们需要构建一个套件,该套件不允许我们运行TestAccess抽象测试。

以下是剩下的尖峰:

def suite():
    s= unittest.TestSuite()
    s.addTests( unittest.defaultTestLoader.loadTestsFromTestCase(Test_EmptyClass) )
    s.addTests( unittest.defaultTestLoader.loadTestsFromTestCase(Test_Namespace) )
    s.addTests( unittest.defaultTestLoader.loadTestsFromTestCase(Test_Object) )
    return s

if __name__ == "__main__":
    t= unittest.TextTestRunner()
    t.run( suite() )

我们现在有了具体证据,即object类不能像types.SimpleNamespace类一样使用。此外,我们有一个简单的测试类,我们可以用它来演示其他有效(或无效)的设计。例如,测试表明types.SimpleNamespace的行为类似于一个否则为空的类。

我们省略了许多潜在的单元测试案例的细节。我们将在第十五章中深入研究测试,可测试性设计

文档字符串 - RST 标记和文档工具

所有 Python 代码都应该在模块、类和方法级别有文档字符串。并不是每个方法都需要有文档字符串。有些方法名选择得非常好,对它们几乎不需要多说。然而,大多数情况下,文档对于清晰度是必不可少的。

Python 文档通常使用ReStructured TextRST)标记编写。

然而,在本书的代码示例中,我们将省略文档字符串。这样可以使书的大小保持在合理范围内。这种做法的缺点是,它使得文档字符串看起来是可选的。它们绝对不是可选的。

我们将再次强调。文档字符串是必不可少的

Python 使用文档字符串的材料有以下三种方式:

  • 内部的help()函数显示文档字符串

  • doctest工具可以在文档字符串中找到示例并将其作为测试用例运行

  • 外部工具如Sphinxepydoc可以生成优雅的文档摘录

由于 RST 的相对简单,编写良好的文档字符串非常容易。然而,我们将在第十八章中详细介绍文档和预期的标记,质量和文档。但是,现在,我们将提供一个文档字符串可能看起来像的快速示例:

def factorial( n ):
    """Compute n! recursively.

    :param n: an integer >= 0
    :returns: n!

    Because of Python's stack limitation, this won't
    compute a value larger than about 1000!.

    >>> factorial(5)
    120
    """
    if n == 0: return 1
    return n*factorial(n-1)

这显示了参数和返回值的 RST 标记。它包括了一个关于深刻限制的额外说明。它还包括了可以用来验证实现的doctest工具的doctest输出。有许多标记特性可以用来提供额外的结构和语义信息。

IDE 问题

一个常见的问题是关于 Python 开发的最佳IDE。简短的答案是,IDE 的选择一点都不重要。支持 Python 的开发环境数量是庞大的。

本书中的所有示例都显示了 Python >>>提示符下的交互式示例。交互式运行示例发出了一个深刻的声明。写得好的 Python 应该足够简单,可以从命令行运行。

注意

我们应该能够在>>>提示符下展示一个设计。

>>>提示符下运行代码是 Python 设计复杂性的一个重要质量测试。如果类或函数太复杂,那么就没有简单的方法可以从>>>提示符下运行它。对于一些复杂的类,我们可能需要提供适当的模拟对象,以便轻松地进行交互使用。

关于特殊方法名

Python 有多个实现层。我们只对其中的两个感兴趣。

在表面上,我们有 Python 的源文本。这个源文本是传统面向对象的符号和过程式函数调用符号的混合。后缀面向对象的符号包括object.method()object.attribute构造。前缀符号涉及function(object)构造,这更典型于过程式编程语言。我们还有中缀符号,比如object+other。当然,还有一些语句,比如forwith,调用对象方法。

function(object)前缀构造的存在导致一些程序员质疑 Python 的对象导向的“纯度”。不清楚是否严格遵守object.method()符号是必要的,甚至有帮助。Python 使用了前缀和后缀符号的混合。前缀符号是特殊方法后缀符号的替身。前缀、中缀和后缀符号的存在是基于表达和美学的选择。写得好的 Python 的一个目标是它应该读起来更像英语。在底层,语法变化是由 Python 的特殊方法一致地实现的。

Python 中的一切都是对象。这与 Java 或 C++不同,那里有避免对象范式的“原始”类型。每个 Python 对象都提供了一系列特殊方法,用于提供语言表面特性的实现细节。例如,我们可能在应用程序中写str(x)。这个前缀表面符号是作为x.__str__()在底层实现的。

诸如a+b这样的构造可能被实现为a.__add__(b)b.__radd__(a),这取决于内置到对象ab的类定义中的兼容性规则。

表面语法和特殊方法的实现之间的映射绝对不是从function(x)x.__function__()的简单重写。有许多语言特性具有有趣的特殊方法来支持该特性。一些特殊方法具有从基类object继承的默认实现,而其他特殊方法没有默认实现,并将引发异常。

在第一部分中,通过特殊方法实现 Pythonic 类,我们将介绍特殊方法,并展示如何实现这些特殊方法,以提供 Python 和我们的类定义之间的无缝集成。

总结

我们已经看过我们的一个示例问题领域:21 点赌场游戏。我们喜欢它是因为它具有一定的算法复杂性,但并不太复杂或玄妙。我们还介绍了三个重要的模块,这些模块将贯穿整本书的使用。

  • timeit模块是我们将用来比较替代实现性能的工具。

  • unittestdoctest模块将被用来确认我们的软件是否正确工作。

我们还看了一些我们将如何为我们的 Python 程序添加文档的方式。我们将在模块、类和函数中使用文档字符串。为了节省空间,不是每个示例都会显示文档字符串。尽管如此,它们应该被视为必不可少的。

集成开发环境IDE)并非必需。任何适合您的 IDE 或文本编辑器都可以用于高级 Python 开发。

接下来的八章将涉及特殊方法名称的不同子集。这些方法是关于我们如何创建与内置库模块无缝集成的 Python 编程。

在下一章中,我们将专注于__init__()方法以及我们可以使用它的各种方式。__init__()方法很重要,因为初始化是对象生命周期中的第一步;每个对象必须被正确初始化才能正常工作。比这更重要的是,__init__()的参数值可以采用多种形式。我们将探讨多种设计__init__()的方式。

第一部分:通过特殊方法创建 Pythonic 类

init()方法

与 Python 无缝集成 - 基本特殊方法

属性访问、属性和描述符

一致设计的 ABCs

使用可调用对象和上下文

创建容器和集合

创建数字

装饰器和混合 - 横切方面

通过特殊方法创建 Pythonic 类

Python 通过其特殊方法名称暴露了大量的内部机制。这个想法在整个 Python 中都是普遍的。例如,len()这样的函数将利用类的__len__()特殊方法。

这意味着我们有一个整洁的、通用的公共接口(len(x)),可以在任何类型的类上使用。Python 的多态性部分基于任何类都可以实现__len__()方法;任何这样的类的对象都将响应len()函数。

当我们定义一个类时,我们可以(也应该)包括这些特殊方法,以改进我们的类与 Python 其他部分之间的集成。部分 1,通过特殊方法创建 Pythonic 类,将扩展基本的面向对象编程技术,以创建更Pythonic的类。任何类都应该能够与 Python 的其他部分无缝集成。与 Python 的其他部分紧密结合将使我们能够使用许多语言和标准库功能,我们包和模块的客户端将更有信心地使用它们,并更成功地维护和扩展它们。

在某种程度上,我们的类可以看作是 Python 的扩展。我们希望我们的类非常像本机 Python 类,以至于语言、标准库和我们的应用之间的区别被最小化。

Python 语言使用了大量的特殊方法名称。它们可以分为以下几个离散的类别:

  • 属性访问:这些特殊方法实现了我们在表达式中看到的object.attribute,在赋值的左侧看到的object.attribute,以及在del语句中看到的object.attribute

  • 可调用对象:这个特殊方法实现了我们所看到的作用于参数的函数,就像内置的len()函数一样。

  • 集合:这些特殊方法实现了集合的许多特性。这涉及到诸如sequence[index]mapping[key]some_set|another_set等方法。

  • 数字:这些特殊方法提供了算术运算符和比较运算符。我们可以使用这些方法来扩展 Python 处理的数字的领域。

  • 上下文:有两个特殊方法,我们将使用它们来实现一个与with语句一起工作的上下文管理器。

  • 迭代器:有一些特殊方法定义了迭代器。这并不是必需的,因为生成器函数如此优雅地处理了这个特性。然而,我们将看看如何设计我们自己的迭代器。

一些这些特殊方法名称已经在《Python 3 面向对象编程》中介绍过。我们将回顾这些主题,并介绍一些适合于一种基本类别的额外特殊方法名称。

即使在这个基本类别中,我们还有更深入的主题要探讨。我们将从真正基本的特殊方法开始。有一些相当高级的特殊方法被归类为基本类别,因为它们似乎不属于其他任何地方。

__init__()方法允许在提供对象的初始值时具有很大的灵活性。对于不可变对象,这是实例的基本定义,清晰度变得非常重要。在第一章中,我们将回顾该方法的众多设计替代方案。

第一章:__init__()方法

__init__()方法有两个重要原因。初始化是对象生命周期中的第一步;每个对象必须得到适当的初始化才能正常工作。第二个原因是__init__()的参数值可以采用许多形式。

因为有很多种方法可以向__init__()提供参数值,所以有很多对象创建的用例。我们将看几种。我们希望最大限度地提高清晰度,因此需要定义一个正确描述问题域的初始化。

然而,在我们可以进入__init__()方法之前,我们需要看一下 Python 中的隐式类层次结构,简要地看一下名为object的类。这将为比较默认行为与我们自己类的不同行为奠定基础。

在本章中,我们将研究简单对象(例如扑克牌)的不同初始化形式。之后,我们可以研究更复杂的对象,例如涉及集合的手和涉及策略和状态的玩家。

隐式超类 - object

每个 Python 类定义都有一个隐式的超类:object。这是一个非常简单的类定义,几乎什么都不做。我们可以创建object的实例,但我们不能对它们做太多事情,因为许多特殊方法只是简单地引发异常。

当我们定义自己的类时,object是超类。以下是一个简单扩展object的新名称的示例类定义:

class X:
    pass

以下是与我们的类的一些交互:

>>> X.__class__
<class 'type'>
>>> X.__class__.__base__
<class 'object'>

我们可以看到一个类是名为type的类的对象,我们新类的基类是名为object的类。

在我们查看每种方法时,我们还会看一下从object继承的默认行为。在某些情况下,超类特殊方法的行为恰好是我们想要的。在其他情况下,我们需要覆盖特殊方法。

基类对象__init__()方法

对象的生命周期的创建、初始化和销毁是基本的。我们将把创建和销毁推迟到更高级特殊方法的后一章,现在只关注初始化。

所有类的超类object具有__init__()的默认实现,相当于pass。我们不需要实现__init__()。如果我们不实现它,那么在创建对象时不会创建任何实例变量。在某些情况下,这种默认行为是可以接受的。

我们可以始终向object的子类添加属性。考虑以下类,它需要两个实例变量,但没有初始化它们:

class Rectangle:
    def area( self ):
        return self.length * self.width

Rectangle类有一个使用两个属性返回值的方法。这些属性在任何地方都没有被初始化。这是合法的 Python。避免明确设置属性有点奇怪,但是是有效的。

以下是与Rectangle类的交互:

>>> r= Rectangle()
>>> r.length, r.width = 13, 8
>>> r.area()
104

虽然这是合法的,但这可能是深度混淆的潜在来源,这是避免的一个很好的理由。

然而,这种设计方式提供了灵活性,因此可能有时我们不需要在__init__()方法中设置所有属性。我们在这里走了一条细线。可选属性是一种未正式声明为正确子类的子类。我们正在以一种可能导致混乱和不恰当使用复杂的if语句的方式创建多态性。虽然未初始化的属性可能有用,但它们可能是糟糕设计的症状。

Python 之禅诗(import this)提供以下建议:

“明确胜于含蓄。”

__init__()方法应该使实例变量明确。

提示

相当差的多态性

在灵活性和愚蠢之间有一条细微的界限。

一旦我们感到有必要编写:

if 'x' in self.__dict__:

或者:

try:
    self.x
except AttributeError:

是时候重新考虑 API 并添加一个公共方法或属性了。重构比添加if语句更好。

在超类中实现 init()

我们通过实现__init__()方法来初始化对象。当对象被创建时,Python 首先创建一个空对象,然后调用该新对象的__init__()方法。这个方法通常创建对象的实例变量并执行任何其他一次性处理。

以下是Card类层次结构的一些示例定义。我们将定义一个Card超类和三个子类,这些子类是Card基本主题的变体。我们有两个实例变量,它们直接从参数值设置,还有两个变量,它们是通过初始化方法计算得出的:

class Card:
    def  __init__( self, rank, suit ):
        self.suit= suit
        self.rank= rank
        self.hard, self.soft = self._points()
class NumberCard( Card ):
    def _points( self ):
        return int(self.rank), int(self.rank)
class AceCard( Card ):
    def _points( self ):
        return 1, 11
class FaceCard( Card ):
    def _points( self ):
        return 10, 10

在这个例子中,我们将__init__()方法分解到超类中,以便超类Card中的通用初始化适用于所有三个子类NumberCardAceCardFaceCard

这显示了一个常见的多态设计。每个子类提供了_points()方法的独特实现。所有子类具有相同的签名:它们具有相同的方法和属性。这三个子类的对象可以在应用程序中互换使用。

如果我们简单地使用字符表示花色,我们就可以创建Card实例,如下面的代码片段所示:

cards = [ AceCard('A', '♠'), NumberCard('2','♠'), NumberCard('3','♠'), ]

我们在列表中为几张卡片枚举了类、等级和花色。从长远来看,我们需要一个更聪明的工厂函数来构建Card实例;以这种方式枚举所有 52 张卡片是乏味且容易出错的。在我们开始工厂函数之前,我们先看一下其他一些问题。

使用 init()创建显式常量

我们可以为我们的卡片花色定义一个类。在 21 点游戏中,花色并不重要,一个简单的字符字符串就可以工作。

我们以创建常量对象的方式作为示例来使用花色构造。在许多情况下,我们的应用程序将具有可以由一组常量定义的对象的小领域。静态对象的小领域可能是实现策略状态设计模式的一部分。

在某些情况下,我们可能会有一个在初始化或配置文件中创建的常量对象池,或者我们可能会根据命令行参数创建常量对象。我们将在第十六章中详细讨论初始化设计和启动设计的细节,处理命令行

Python 没有一个简单的正式机制来定义对象为不可变的。我们将在第三章中查看确保不可变性的技术,属性访问、属性和描述符。在这个例子中,使花色的属性不可变可能是有意义的。

以下是我们将用来构建四个显式常量的类:

class Suit:
    def __init__( self, name, symbol ):
        self.name= name
        self.symbol= symbol

以下是围绕这个类构建的“常量”领域:

Club, Diamond, Heart, Spade = Suit('Club','♣'), Suit('Diamond','♦'), Suit('Heart','♥'), Suit('Spade','♠')

我们现在可以创建cards,如下面的代码片段所示:

cards = [ AceCard('A', Spade), NumberCard('2', Spade), NumberCard('3', Spade), ]

对于这么小的一个例子,这种方法并没有比单个字符的花色代码有多大的改进。在更复杂的情况下,可能会有一系列短的策略或状态对象可以像这样创建。这可以通过重用来自一个小的静态常量池的对象,使策略或状态设计模式能够高效地工作。

我们必须承认,在 Python 中,这些对象在技术上并不是常量;它们是可变的。做额外的编码使这些对象真正不可变可能会有一些好处。

提示

不可变性的无关性

不可变性可能成为一个有吸引力的麻烦。有时,这是由神秘的“恶意程序员”来证明的,他们修改了他们的应用程序中的常量值。作为设计考虑,这是愚蠢的。这个神秘的、恶意的程序员不能通过这种方式停止。在 Python 中,没有简单的方法来“防傻”代码。恶意的程序员可以访问源代码,并且可以像编写代码来修改常量一样轻松地调整它。

最好不要花太多时间来定义不可变对象的类。在第三章 属性访问、属性和描述符中,我们将展示实现不可变性的方法,为有错误的程序提供适当的诊断信息。

通过工厂函数利用 init()

我们可以通过一个工厂函数构建一副完整的扑克牌。这比枚举所有 52 张牌要好。在 Python 中,我们有两种常见的工厂方法,如下所示:

  • 我们定义一个创建所需类的对象的函数。

  • 我们定义一个具有创建对象方法的类。这是完整的工厂设计模式,如设计模式书籍中所述。在诸如 Java 之类的语言中,需要工厂类层次结构,因为该语言不支持独立函数。

在 Python 中,一个类并不是必需的。当相关的工厂很复杂时,使用类层次结构只是一个好主意。Python 的一个优点是,当一个简单的函数可能同样有效时,我们并不被迫使用类层次结构。

注意

虽然这是一本关于面向对象编程的书,但函数确实很好。这是常见的,符合惯例的 Python。

如果需要,我们总是可以重写一个函数成为一个适当的可调用对象。从可调用对象,我们可以将其重构为我们的工厂类层次结构。我们将在第五章 使用可调用对象和上下文中讨论可调用对象。

一般来说,类定义的优势是通过继承实现代码重用。工厂类的作用是包装一些目标类层次结构和对象构造的复杂性。如果有一个工厂类,我们可以在扩展目标类层次结构时将子类添加到工厂类中。这给我们多态工厂类;不同的工厂类定义具有相同的方法签名,并且可以互换使用。

这种类级别的多态性对于静态编译语言(如 Java 或 C++)非常有帮助。编译器在生成代码时可以解析类和方法的细节。

如果替代工厂定义实际上没有重用任何代码,那么在 Python 中类层次结构将不会有帮助。我们可以简单地使用具有相同签名的函数。

以下是我们各种Card子类的工厂函数:

def card( rank, suit ):
    if rank == 1: return AceCard( 'A', suit )
    elif 2 <= rank < 11: return NumberCard( str(rank), suit )
    elif 11 <= rank < 14:
        name = { 11: 'J', 12: 'Q', 13: 'K' }[rank]
        return FaceCard( name, suit )
    else:
        raise Exception( "Rank out of range" )

这个函数从一个数字rank和一个suit对象构建一个Card类。现在我们可以更简单地构建卡片。我们将构造问题封装到一个单一的工厂函数中,允许应用程序构建而不需要精确知道类层次结构和多态设计的工作原理。

以下是一个使用这个工厂函数构建一副牌的示例:

deck = [card(rank, suit)
    for rank in range(1,14)
        for suit in (Club, Diamond, Heart, Spade)]

这枚举了所有的等级和花色,以创建一副完整的 52 张牌。

错误的工厂设计和模糊的 else 子句

注意card()函数中if语句的结构。我们没有使用一个通用的else子句来进行任何处理;我们只是引发了一个异常。使用通用的else子句是一个小小的争论点。

一方面,可以说属于else子句的条件不应该被省略,因为它可能隐藏了微妙的设计错误。另一方面,一些else子句条件确实是显而易见的。

避免模糊的else子句是很重要的。

考虑一下对这个工厂函数定义的以下变体:

def card2( rank, suit ):
    if rank == 1: return AceCard( 'A', suit )
    elif 2 <= rank < 11: return NumberCard( str(rank), suit )
    else:
        name = { 11: 'J', 12: 'Q', 13: 'K' }[rank]
        return FaceCard( name, suit )

以下是当我们尝试构建一副牌时会发生的情况:

deck2 = [card2(rank, suit) for rank in range(13) for suit in (Club, Diamond, Heart, Spade)]

它有效吗?如果if条件更复杂会怎么样?

有些程序员可以一眼看出这个if语句。其他人将努力确定所有情况是否都是正确排他的。

对于高级 Python 编程,我们不应该让读者推断适用于else子句的条件。条件要么对最新的 n00bz 是显而易见的,要么应该是明确的。

提示

何时使用 catch-all else

很少。只有在条件明显时才使用。如果有疑问,要明确使用else来引发异常。

避免模糊的else子句。

使用elif序列的简单和一致性

我们的工厂函数card()是两种非常常见的工厂设计模式的混合:

  • 一个if-elif序列

  • 一个映射

为了简单起见,最好专注于这些技术中的一个,而不是两者都关注。

我们总是可以用elif条件替换映射。(是的,总是。反之则不然;将elif条件转换为映射可能具有挑战性。)

以下是一个没有映射的Card工厂:

def card3( rank, suit ):
    if rank == 1: return AceCard( 'A', suit )
    elif 2 <= rank < 11: return NumberCard( str(rank), suit )
    elif rank == 11:
        return FaceCard( 'J', suit )
    elif rank == 12:
        return FaceCard( 'Q', suit )
    elif rank == 13:
        return FaceCard( 'K', suit )
    else:
        raise Exception( "Rank out of range" )

我们重写了card()工厂函数。映射被转换为额外的elif子句。这个函数的优点是它比以前的版本更一致。

使用映射和类对象的简单性

在某些情况下,我们可以使用映射来代替一系列elif条件。可能会发现条件非常复杂,以至于一系列elif条件是表达它们的唯一明智的方式。然而,对于简单的情况,映射通常效果更好,而且易于阅读。

由于class是一个一流对象,我们可以很容易地从rank参数映射到必须构造的类。

以下是一个只使用映射的Card工厂:

def card4( rank, suit ):
    class_= {1: AceCard, 11: FaceCard, 12: FaceCard,
        13: FaceCard}.get(rank, NumberCard)
    return class_( rank, suit )

我们将rank对象映射到一个类。然后,我们将该类应用于ranksuit值,以构建最终的Card实例。

我们也可以使用defaultdict类。但是,对于一个微不足道的静态映射来说,它并不更简单。代码如下所示:

defaultdict( lambda: NumberCard, {1: AceCard, 11: FaceCard, 12: FaceCard, 12: FaceCard} )

请注意defaultdict类的default必须是零参数的函数。我们使用了lambda构造来创建必要的函数包装器,围绕一个常量。然而,这个函数有一个严重的缺陷。它缺少从1A13K的转换,这是我们在以前版本中有的。当我们尝试添加该功能时,我们遇到了问题。

我们需要改变映射,以提供Card子类以及rank对象的字符串版本。对于这种两部分映射,我们可以做什么?有四种常见的解决方案:

  • 我们可以做两个并行映射。我们不建议这样做,但我们将展示它以强调它的不可取之处。

  • 我们可以映射到一个二元组。这也有一些缺点。

  • 我们可以映射到partial()函数。partial()函数是functools模块的一个特性。

  • 我们还可以考虑修改我们的类定义,以更容易地适应这种映射。我们将在下一节关于将__init__()推入子类定义中看看这种替代方案。

我们将用一个具体的例子来看看每一个。

两个并行映射

以下是两个并行映射解决方案的本质:

class_= {1: AceCard, 11: FaceCard, 12: FaceCard, 13: FaceCard }.get(rank, NumberCard)
rank_str= {1:'A', 11:'J', 12:'Q', 13:'K'}.get(rank,str(rank))
return class_( rank_str, suit )

这是不可取的。它涉及到映射键1111213序列的重复。重复是不好的,因为并行结构在软件更新后似乎永远不会保持不变。

提示

不要使用并行结构

两个并行结构应该被替换为元组或某种适当的集合。

映射到一个值的元组

以下是映射到二元组的本质:

class_, rank_str= {
    1:  (AceCard,'A'),
    11: (FaceCard,'J'),
    12: (FaceCard,'Q'),
    13: (FaceCard,'K'),
    }.get(rank, (NumberCard, str(rank)))
return class_( rank_str, suit )

这是相当愉快的。要解决纸牌的特殊情况并不需要太多的代码。我们将看到如果需要修改或扩展Card类层次结构以添加Card的其他子类,它可能会如何被修改或扩展。

rank值映射到class对象,并将其中一个参数映射到该类初始化程序似乎有些奇怪。将 rank 映射到一个简单的类或函数对象,而不提供一些(但不是全部)参数,似乎更合理。

部分函数解决方案

我们可以创建一个partial()函数,而不是映射到函数和一个参数的二元组。这是一个已经提供了一些(但不是全部)参数的函数。我们将使用functools库中的partial()函数来创建一个带有rank参数的部分。

以下是从rank到可以用于对象构造的partial()函数的映射:

from functools import partial
part_class= {
    1:  partial(AceCard,'A'),
    11: partial(FaceCard,'J'),
    12: partial(FaceCard,'Q'),
    13: partial(FaceCard,'K'),
    }.get(rank, partial(NumberCard, str(rank)))
return part_class( suit )

该映射将rank对象与分配给part_classpartial()函数相关联。然后可以将此partial()函数应用于suit对象以创建最终对象。使用partial()函数是函数式编程的一种常见技术。它在这种特定情况下起作用,其中我们有一个函数而不是对象方法。

总的来说,partial()函数对大多数面向对象编程并不是很有帮助。与其创建partial()函数,我们可以简单地更新类的方法,以接受不同组合的参数。partial()函数类似于为对象构造创建流畅的接口。

工厂的流畅 API

在某些情况下,我们设计一个类,其中方法的使用顺序是有定义的。顺序评估方法非常类似于创建partial()函数。

在对象表示法中,我们可能会有x.a().b()。我们可以将其视为工厂的流畅 APIx.a()函数是一种等待b()partial()函数。我们可以将其视为工厂的流畅 API

这里的想法是,Python 为我们提供了两种管理状态的替代方案。我们可以更新对象,也可以创建一个(在某种程度上)有状态的partial()函数。由于这种等价性,我们可以将partial()函数重写为流畅的工厂对象。我们使rank对象的设置成为返回self的流畅方法。设置suit对象实际上将创建Card实例。

以下是一个流畅的Card工厂类,其中有两个必须按特定顺序使用的方法函数:

class CardFactory:
    def rank( self, rank ):
        self.class_, self.rank_str= {
            1:(AceCard,'A'),
            11:(FaceCard,'J'),
            12:(FaceCard,'Q'),
            13:(FaceCard,'K'),
            }.get(rank, (NumberCard, str(rank)))
        return self
    def suit( self, suit ):
        return self.class_( self.rank_str, suit )

rank()方法更新构造函数的状态,而suit()方法实际上创建最终的Card对象。

这个工厂类可以如下使用:

card8 = CardFactory()
deck8 = [card8.rank(r+1).suit(s) for r in range(13) for s in (Club, Diamond, Heart, Spade)]

首先,我们创建一个工厂实例,然后使用该实例创建Card实例。这并不会实质性地改变Card类层次结构中__init__()本身的工作方式。但是,它改变了我们的客户端应用程序创建对象的方式。

在每个子类中实现__init__()

当我们查看用于创建Card对象的工厂函数时,我们看到了Card类的一些替代设计。我们可能希望重构排名数字的转换,使其成为Card类本身的责任。这将初始化推入到每个子类中。

这通常需要对超类进行一些常见的初始化,以及子类特定的初始化。我们需要遵循不要重复自己DRY)原则,以防止代码被克隆到每个子类中。

以下是一个示例,其中初始化是每个子类的责任:

class Card:
    pass
class NumberCard( Card ):
    def  __init__( self, rank, suit ):
        self.suit= suit
        self.rank= str(rank)
        self.hard = self.soft = rank
class AceCard( Card ):
    def  __init__( self, rank, suit ):
        self.suit= suit
        self.rank= "A"
        self.hard, self.soft =  1, 11
class FaceCard( Card ):
    def  __init__( self, rank, suit ):
        self.suit= suit
        self.rank= {11: 'J', 12: 'Q', 13: 'K' }[rank]
        self.hard = self.soft = 10

这仍然显然是多态的。然而,缺乏一个真正共同的初始化导致了一些令人不快的冗余。这里令人不快的是suit的重复初始化。这必须被提升到超类中。我们可以让每个__init__()子类对超类进行显式引用。

这个Card类的版本在超类级别有一个初始化器,每个子类都使用它,如下面的代码片段所示:

class Card:
    def __init__( self, rank, suit, hard, soft ):
        self.rank= rank
        self.suit= suit
        self.hard= hard
        self.soft= soft
class NumberCard( Card ):
    def  __init__( self, rank, suit ):
        super().__init__( str(rank), suit, rank, rank )
class AceCard( Card ):
    def  __init__( self, rank, suit ):
        super().__init__( "A", suit, 1, 11 )
class FaceCard( Card ):
    def  __init__( self, rank, suit ):
        super().__init__( {11: 'J', 12: 'Q', 13: 'K' }[rank], suit, 10, 10 )

我们在子类和超类级别都提供了__init__()。这有一个小优点,就是它简化了我们的工厂函数,如下面的代码片段所示:

def card10( rank, suit ):
    if rank == 1: return AceCard( rank, suit )
    elif 2 <= rank < 11: return NumberCard( rank, suit )
    elif 11 <= rank < 14: return FaceCard( rank, suit )
    else:
        raise Exception( "Rank out of range" )

简化工厂函数不应该是我们的重点。从这个变化中我们可以看到,我们为了在工厂函数中稍微改进而创建了相当复杂的__init__()方法。这是一个常见的权衡。

提示

工厂函数封装复杂性

在复杂的__init__()方法和工厂函数之间存在一个权衡。通常更好的做法是坚持使用更直接但不太友好的__init__()方法,并将复杂性推入工厂函数。如果你希望包装和封装构造复杂性,工厂函数会起作用。

简单的复合对象

一个复合对象也可以被称为容器。我们将看一个简单的复合对象:一叠单独的卡片。这是一个基本的集合。事实上,它是如此基本,以至于我们可以不费吹灰之力地使用一个简单的list作为一叠卡片。

在设计一个新的类之前,我们需要问这个问题:使用一个简单的list是否合适?

我们可以使用random.shuffle()来洗牌,使用deck.pop()来发牌到玩家的Hand中。

有些程序员急于定义新的类,好像使用内置类违反了某些面向对象设计原则。避免新类会使我们得到以下代码片段中所示的东西:

d= [card6(r+1,s) for r in range(13) for s in (Club, Diamond, Heart, Spade)]
random.shuffle(d)
hand= [ d.pop(), d.pop() ]

如果这么简单,为什么要写一个新的类呢?

答案并不完全清晰。一个优点是,一个类提供了一个简化的、无实现的接口给对象。正如我们之前提到的,在讨论工厂时,一个类在 Python 中并不是必需的。

在前面的代码中,这个牌组只有两个简单的用例,而且一个类定义似乎并没有简化事情太多。它的优点在于隐藏了实现的细节。但是这些细节如此微不足道,以至于暴露它们似乎没有太大的成本。在本章中,我们主要关注__init__()方法,所以我们将看一些设计来创建和初始化一个集合。

为了设计一个对象的集合,我们有以下三种一般的设计策略:

  • 包装:这个设计模式是一个现有的集合定义。这可能是外观设计模式的一个例子。

  • 扩展:这个设计模式是一个现有的集合类。这是普通的子类定义。

  • 发明:这是从头开始设计的。我们将在第六章中看到这个,创建容器和集合

这三个概念是面向对象设计的核心。在设计一个类时,我们必须始终做出这个选择。

包装一个集合类

以下是一个包含内部集合的包装设计:

class Deck:
    def __init__( self ):
        self._cards = [card6(r+1,s) for r in range(13) for s in (Club, Diamond, Heart, Spade)]
        random.shuffle( self._cards )
    def pop( self ):
        return self._cards.pop()

我们已经定义了Deck,使得内部集合是一个list对象。Deckpop()方法只是委托给了包装的list对象。

然后我们可以用以下代码创建一个Hand实例:

d= Deck()
hand= [ d.pop(), d.pop() ]

通常,外观设计模式或包装类包含了简单地委托给底层实现类的方法。这种委托可能会变得啰嗦。对于一个复杂的集合,我们可能最终会委托大量的方法给包装对象。

扩展一个集合类

包装的另一种选择是扩展内置类。通过这样做,我们不必重新实现pop()方法,我们可以直接继承它。

pop()方法的优点在于它可以创建一个类,而不需要编写太多的代码。在这个例子中,扩展list类的缺点在于它提供了比我们实际需要的更多的函数。

以下是扩展内置listDeck定义:

class Deck2( list ):
    def __init__( self ):
        super().__init__( card6(r+1,s) for r in range(13) for s in (Club, Diamond, Heart, Spade) )
        random.shuffle( self )

在某些情况下,我们的方法将不得不明确地使用超类方法,以便具有适当的类行为。我们将在接下来的章节中看到其他例子。

我们利用超类的__init__()方法来用一副初始的牌组填充我们的list对象。然后我们洗牌。pop()方法只是从list继承而来,完美地工作。其他从list继承的方法也可以工作。

更多要求和另一个设计

在赌场里,牌通常是从一个混合了半打牌组的鞋子中发出的。这个考虑使我们必须建立我们自己的Deck版本,而不仅仅使用一个朴素的list对象。

此外,赌场鞋子并不是完全发牌。相反,会插入一个标记牌。由于标记牌,一些牌实际上被搁置不用于游戏。

以下是包含多副 52 张牌组的Deck定义:

class Deck3(list):
    def __init__(self, decks=1):
        super().__init__()
        for i in range(decks):
            self.extend( card6(r+1,s) for r in range(13) for s in (Club, Diamond, Heart, Spade) )
        random.shuffle( self )
        burn= random.randint(1,52)
        for i in range(burn): self.pop()

在这里,我们使用__init__()超类来构建一个空集合。然后,我们使用self.extend()来将多副 52 张牌的牌组附加到鞋子上。我们也可以使用super().extend(),因为在这个类中我们没有提供覆盖的实现。

我们还可以通过super().__init__()使用更深层嵌套的生成器表达式来完成整个任务,如下面的代码片段所示:

( card6(r+1,s) for r in range(13) for s in (Club, Diamond, Heart, Spade) for d in range(decks) )

这个类为我们提供了一系列Card实例,我们可以用它来模拟从鞋子中发牌的赌场 21 点。

在赌场里有一个奇怪的仪式,他们会揭示被烧毁的牌。如果我们要设计一个计牌玩家策略,我们可能也想模拟这种细微差别。

复杂的复合对象

以下是一个适合模拟玩法策略的 21 点Hand描述的例子:

class Hand:
    def __init__( self, dealer_card ):
        self.dealer_card= dealer_card
        self.cards= []
    def hard_total(self ):
        return sum(c.hard for c in self.cards)
    def soft_total(self ):
        return sum(c.soft for c in self.cards)

在这个例子中,我们有一个基于__init__()方法的参数的实例变量self.dealer_card。然而,self.cards实例变量并不基于任何参数。这种初始化方式创建了一个空集合。

要创建Hand的一个实例,我们可以使用以下代码:

d = Deck()
h = Hand( d.pop() )
h.cards.append( d.pop() )
h.cards.append( d.pop() )

这种初始化方式的缺点是使用了一长串陈述来构建Hand对象的实例。使用这种初始化方式,将Hand对象序列化并重新构建会变得困难。即使我们在这个类中创建了一个显式的append()方法,初始化集合仍然需要多个步骤。

我们可以尝试创建一个流畅的接口,但这并不能真正简化事情;它只是改变了构建Hand对象的语法方式。流畅的接口仍然会导致多个方法的评估。当我们在第二部分中查看对象的序列化时,持久性和序列化我们希望一个单一的类级函数接口,理想情况下是类构造函数。我们将在第九章中深入研究,序列化和保存 - JSON、YAML、Pickle、CSV 和 XML

还要注意,这里显示的硬总和和软总和方法函数并不完全遵循 21 点的规则。我们将在第二章中回到这个问题,与 Python 无缝集成 - 基本特殊方法

完整的复合对象初始化

理想情况下,__init__()初始化方法将创建一个完整的对象实例。当创建一个包含内部其他对象集合的容器的完整实例时,这会更加复杂。如果我们可以一次性构建这个复合体将会很有帮助。

通常会有一个逐步累积项目的方法,以及一个可以一次性加载所有项目的初始化特殊方法。

例如,我们可能有一个类,如下面的代码片段:

class Hand2:
    def __init__( self, dealer_card, *cards ):
        self.dealer_card= dealer_card
        self.cards = list(cards)
    def hard_total(self ):
        return sum(c.hard for c in self.cards)
    def soft_total(self ):
        return sum(c.soft for c in self.cards)

这种初始化可以一次性设置所有实例变量。其他方法只是前一个类定义的副本。我们可以以两种方式构建Hand2对象。第一个例子是一次将一张牌加载到Hand2对象中:

d = Deck()
P = Hand2( d.pop() )
p.cards.append( d.pop() )
p.cards.append( d.pop() )

这个第二个例子使用*cards参数一次性加载一个Cards类的序列:

d = Deck()
h = Hand2( d.pop(), d.pop(), d.pop() )

对于单元测试,以这种方式一次性构建一个复合对象通常是有帮助的。更重要的是,下一部分的一些序列化技术将受益于一种在单个简单评估中构建复合对象的方法。

没有__init__()的无状态对象

以下是一个不需要__init__()方法的退化类的示例。这是策略对象的常见设计模式。策略对象被插入到主对象中以实现算法或决策。它可能依赖于主对象中的数据;策略对象本身可能没有任何数据。我们经常设计策略类遵循享元设计模式:我们避免在Strategy对象中存储内部数据。所有值都作为方法参数值提供给StrategyStrategy对象本身可以是无状态的。它更像是一组方法函数而不是其他任何东西。

在这种情况下,我们为Player实例提供游戏玩法决策。以下是一个(愚蠢的)选择牌和拒绝其他赌注的策略示例:

class GameStrategy:
    def insurance( self, hand ):
        return False
    def split( self, hand ):
        return False
    def double( self, hand ):
        return False
    def hit( self, hand ):
        return sum(c.hard for c in hand.cards) <= 17

每种方法都需要当前的“手牌”作为参数值。决策是基于可用信息的;也就是说,基于庄家的牌和玩家的牌。

我们可以构建这种策略的单个实例,供各种Player实例使用,如下面的代码片段所示:

dumb = GameStrategy()

我们可以想象创建一系列相关的策略类,每个类使用不同的规则来决定玩家在二十一点中所面临的各种决策。

一些额外的类定义

如前所述,玩家有两种策略:一种是下注策略,一种是打牌策略。每个Player实例都与一个更大的模拟引擎有一系列的交互。我们将更大的引擎称为Table类。

Table类需要Player实例按以下顺序进行一系列事件:

  • 玩家必须根据下注策略下注初始赌注。

  • 然后玩家将收到一手牌。

  • 如果手牌可以分牌,玩家必须根据玩法策略决定是否要分牌。这可能会创建额外的Hand实例。在一些赌场,额外的手牌也可以分牌。

  • 对于每个Hand实例,玩家必须根据玩法策略决定是否要叫牌、加倍或站立。

  • 然后玩家将收到赔付,他们必须根据胜负更新他们的投注策略。

从中我们可以看到Table类有许多 API 方法来接收赌注,创建一个Hand对象,提供分牌,解决每一手牌,并支付赌注。这是一个大对象,用一组Players跟踪游戏状态。

以下是处理赌注和牌的Table类的开始部分:

class Table:
    def __init__( self ):
        self.deck = Deck()
    def place_bet( self, amount ):
        print( "Bet", amount )
    def get_hand( self ):
        try:
            self.hand= Hand2( d.pop(), d.pop(), d.pop() )
            self.hole_card= d.pop()
        except IndexError:
            # Out of cards: need to shuffle.
            self.deck= Deck()
            return self.get_hand()
        print( "Deal", self.hand )
        return self.hand
    def can_insure( self, hand ):
        return hand.dealer_card.insure

Table类被Player类用于接受赌注,创建Hand对象,并确定这手牌是否有保险赌注。Player类可以使用其他方法来获取牌并确定赔付。

get_hand()中显示的异常处理并不是赌场游戏的精确模型。这可能导致轻微的统计不准确。更准确的模拟需要开发一个在空时重新洗牌而不是引发异常的牌组。

为了正确交互并模拟真实的游戏,Player类需要一个下注策略。下注策略是一个有状态的对象,确定初始下注的水平。各种下注策略通常根据游戏是赢还是输来改变下注。

理想情况下,我们希望有一系列的下注策略对象。Python 有一个带有装饰器的模块,允许我们创建一个抽象超类。创建策略对象的非正式方法是为必须由子类实现的方法引发异常。

我们已经定义了一个抽象超类以及一个特定的子类,如下所示,以定义一个平级下注策略:

class BettingStrategy:
    def bet( self ):
        raise NotImplementedError( "No bet method" )
    def record_win( self ):
        pass
    def record_loss( self ):
        pass

class Flat(BettingStrategy):
    def bet( self ):
        return 1

超类定义了具有方便的默认值的方法。抽象超类中的基本bet()方法引发异常。子类必须覆盖bet()方法。其他方法可以保留以提供默认值。鉴于前一节中的游戏策略以及这里的下注策略,我们可以看看围绕Player类的更复杂的__init__()技术。

我们可以利用abc模块来规范化一个抽象超类定义。它看起来像以下代码片段:

import abc
class BettingStrategy2(metaclass=abc.ABCMeta):
    @abstractmethod
    def bet( self ):
        return 1
    def record_win( self ):
        pass
    def record_loss( self ):
        pass

这样做的好处是,它使得创建BettingStrategy2的实例,或者任何未能实现bet()的子类的实例都是不可能的。如果我们尝试使用未实现的抽象方法创建此类的实例,它将引发异常而不是创建对象。

是的,抽象方法有一个实现。可以通过super().bet()访问它。

多策略 init()

我们可能有从各种来源创建的对象。例如,我们可能需要克隆一个对象作为创建备忘录的一部分,或者冻结一个对象,以便将其用作字典的键或放入集合中;这就是setfrozenset内置类背后的思想。

有几种整体设计模式可以构建对象的多种方式。一个设计模式是复杂的__init__(),称为多策略初始化。还有多个类级(静态)构造方法。

这些方法是不兼容的。它们具有根本不同的接口。

提示

避免克隆方法

在 Python 中很少需要不必要地复制对象的克隆方法。使用克隆可能表明未能理解 Python 中可用的面向对象设计原则。

克隆方法将对象创建的知识封装在错误的位置。被克隆的源对象不能了解从克隆创建的目标对象的结构。然而,反过来(目标了解源)是可以接受的,如果源提供了一个相当封装良好的接口。

我们在这里展示的示例实际上是克隆,因为它们非常简单。我们将在下一章中扩展它们。然而,为了展示这些基本技术被用于不仅仅是微不足道的克隆的方式,我们将看看如何将可变的Hand对象转换为冻结的不可变的Hand对象。

以下是可以用两种方式构建的Hand对象的示例:

class Hand3:
    def __init__( self, *args, **kw ):
        if len(args) == 1 and isinstance(args[0],Hand3):
            # Clone an existing hand; often a bad idea
            other= args[0]
            self.dealer_card= other.dealer_card
            self.cards= other.cards
        else:
            # Build a fresh, new hand.
            dealer_card, *cards = args
            self.dealer_card=  dealer_card
            self.cards= list(cards)

在第一种情况下,从现有的Hand3对象构建了一个Hand3实例。在第二种情况下,从单独的Card实例构建了一个Hand3对象。

这与从单个项目或现有set对象构建frozenset对象的方式相似。我们将在下一章中更多地了解创建不可变对象。从现有的Hand创建一个新的Hand使我们能够使用以下代码片段创建Hand对象的备忘录:

h = Hand( deck.pop(), deck.pop(), deck.pop() )
memento= Hand( h )

我们将Hand对象保存在memento变量中。这可以用来比较最终手牌和原始发牌的手牌,或者我们可以将其冻结以供在集合或映射中使用。

更复杂的初始化替代方案

为了编写多策略初始化,我们经常不得不放弃特定的命名参数。这种设计的优点是灵活,但缺点是参数名是不透明的,没有意义的。它需要大量的文档来解释各种用例。

我们可以扩展我们的初始化来拆分Hand对象。拆分Hand对象的结果只是另一个构造函数。以下代码片段显示了拆分Hand对象可能是什么样子:

class Hand4:
    def __init__( self, *args, **kw ):
        if len(args) == 1 and isinstance(args[0],Hand4):
            # Clone an existing handl often a bad idea
            other= args[0]
            self.dealer_card= other.dealer_card
            self.cards= other.cards
        elif len(args) == 2 and isinstance(args[0],Hand4) and 'split' in kw:
            # Split an existing hand
            other, card= args
            self.dealer_card= other.dealer_card
            self.cards= [other.cards[kw['split']], card]
        elif len(args) == 3:
            # Build a fresh, new hand.
            dealer_card, *cards = args
            self.dealer_card=  dealer_card
            self.cards= list(cards)
        else:
            raise TypeError( "Invalid constructor args={0!r} kw={1!r}".format(args, kw) )
    def __str__( self ):
        return ", ".join( map(str, self.cards) )

这种设计涉及获取额外的牌来构建适当的拆分手牌。当我们从另一个Hand4对象创建一个Hand4对象时,我们提供一个拆分关键字参数,该参数使用原始Hand4对象的Card类的索引。

以下代码片段显示了我们如何使用这个方法来拆分一手牌:

d = Deck()
h = Hand4( d.pop(), d.pop(), d.pop() )
s1 = Hand4( h, d.pop(), split=0 )
s2 = Hand4( h, d.pop(), split=1 )

我们创建了一个Hand4的初始实例h,并将其拆分成另外两个Hand4实例s1s2,并为每个实例发了一张额外的Card类。二十一点的规则只允许在初始手牌有两张相同等级的牌时才能这样做。

虽然这个__init__()方法相当复杂,但它的优点是可以并行地创建fronzenset对象。缺点是需要一个大的文档字符串来解释所有这些变化。

初始化静态方法

当我们有多种创建对象的方式时,有时使用静态方法来创建和返回实例比复杂的__init__()方法更清晰。

也可以使用类方法作为替代初始化程序,但是将类作为方法的参数并没有太大的优势。在冻结或拆分Hand对象的情况下,我们可能希望创建两个新的静态方法来冻结或拆分Hand对象。在构造中使用静态方法作为替代构造函数是一个微小的语法变化,但在组织代码时有巨大的优势。

下面是一个带有静态方法的Hand版本,可以用来从现有的Hand实例构建新的Hand实例:

class Hand5:
    def __init__( self, dealer_card, *cards ):
        self.dealer_card= dealer_card
        self.cards = list(cards)
    @staticmethod
    def freeze( other ):
        hand= Hand5( other.dealer_card, *other.cards )
        return hand
    @staticmethod
    def split( other, card0, card1 ):
        hand0= Hand5( other.dealer_card, other.cards[0], card0 )
        hand1= Hand5( other.dealer_card, other.cards[1], card1 )
        return hand0, hand1
    def __str__( self ):
        return ", ".join( map(str, self.cards) )

一种方法是冻结或创建一个备忘录版本。另一种方法是拆分一个Hand5实例,创建两个新的Hand5子实例。

这样做会更加可读,并保留参数名来解释接口。

以下代码片段显示了如何使用这个类的版本拆分Hand5实例:

d = Deck()
h = Hand5( d.pop(), d.pop(), d.pop() )
s1, s2 = Hand5.split( h, d.pop(), d.pop() )

我们创建了一个Hand5的初始实例h,将其拆分成另外两个手牌s1s2,并为每个实例发了一张额外的Card类。split()静态方法比通过__init__()实现的等效功能要简单得多。然而,它不遵循从现有set对象创建fronzenset对象的模式。

更多的 init()技术

我们将看一些其他更高级的__init__()技术。这些技术并不像前面的技术那样普遍有用。

以下是使用两个策略对象和一个table对象的Player类的定义。这显示了一个看起来不太好的__init__()方法:

class Player:
    def __init__( self, table, bet_strategy, game_strategy ):
 **self.bet_strategy = bet_strategy
 **self.game_strategy = game_strategy
 **self.table= table
    def game( self ):
        self.table.place_bet( self.bet_strategy.bet() )
        self.hand= self.table.get_hand()
        if self.table.can_insure( self.hand ):
            if self.game_strategy.insurance( self.hand ):
                self.table.insure( self.bet_strategy.bet() )
        # Yet more... Elided for now

Player__init__()方法似乎只是做一些簿记工作。我们只是将命名参数传递给同名的实例变量。如果有很多参数,简单地将参数传递到内部变量中将会产生大量看起来多余的代码。

我们可以像这样使用这个Player类(和相关对象):

table = Table()
flat_bet = Flat()
dumb = GameStrategy()
p = Player( table, flat_bet, dumb )
p.game()

通过将关键字参数值直接传递到内部实例变量中,我们可以提供一个非常简短和非常灵活的初始化。

以下是使用关键字参数值构建Player类的方法:

class Player2:
    def __init__( self, **kw ):
        """Must provide table, bet_strategy, game_strategy."""
        self.__dict__.update( kw )
    def game( self ):
        self.table.place_bet( self.bet_strategy.bet() )
        self.hand= self.table.get_hand()
        if self.table.can_insure( self.hand ):
            if self.game_strategy.insurance( self.hand ):
                self.table.insure( self.bet_strategy.bet() )
        # etc.

这牺牲了很多可读性以换取简洁性。它跨越了潜在的晦涩领域。

由于__init__()方法被简化为一行,它从方法中删除了一定程度的“啰嗦”。然而,这种啰嗦转移到了每个单独的对象构造表达式。由于我们不再使用位置参数,我们必须向对象初始化表达式添加关键字,如下面的代码片段所示:

p2 = Player2( table=table, bet_strategy=flat_bet, game_strategy=dumb )

为什么这样做?

这确实有一个潜在的优势。像这样定义的类非常容易扩展。我们只需担心一些特定的问题,就可以向构造函数提供额外的关键字参数。

以下是预期的用例:

>>> p1= Player2( table=table, bet_strategy=flat_bet, game_strategy=dumb)
>>> p1.game()

以下是一个额外的用例:

>>> p2= Player2( table=table, bet_strategy=flat_bet, game_strategy=dumb, log_name="Flat/Dumb" )
>>> p2.game()

我们添加了一个log_name属性,而没有触及类定义。这可以用作更大的统计分析的一部分。Player2.log_name属性可用于注释日志或其他收集的数据。

我们在添加内容方面受到限制;我们只能添加与类内已使用的名称冲突的参数。需要一些了解类实现的知识才能创建一个不滥用已使用的关键字集的子类。由于**kw参数提供的信息很少,我们需要仔细阅读。在大多数情况下,我们宁愿相信类能够正常工作,而不是审查实现细节。

这种基于关键字的初始化可以在超类定义中完成,以使超类更容易实现子类。当子类的独特特性涉及简单的新实例变量时,我们可以避免在每个子类中编写额外的__init__()方法。

这种方法的缺点是,我们有一些不是通过子类定义正式记录的晦涩实例变量。如果只有一个小变量,那么为了向类添加一个变量,整个子类可能是太多的编程开销。然而,一个小变量往往会导致第二个和第三个。不久之后,我们会意识到,与极其灵活的超类相比,一个子类会更明智。

我们可以(也应该)将其与混合位置和关键字实现相结合,如下面的代码片段所示:

class Player3( Player ):
    def __init__( self, table, bet_strategy, game_strategy, **extras ):
        self.bet_strategy = bet_strategy
        self.game_strategy = game_strategy
        self.table= table
        self.__dict__.update( extras )

这比完全开放的定义更明智。我们已经将必需的参数作为位置参数。我们将任何非必需的参数留作关键字。这澄清了传递给__init__()方法的任何额外关键字参数的用法。

这种灵活的、基于关键字的初始化取决于我们是否有相对透明的类定义。这种开放性需要一些小心,以避免由于关键字参数名称是开放式的而导致调试名称冲突。

带有类型验证的初始化

类型验证很少是一个明智的要求。在某种程度上,这可能是对 Python 的不完全理解。概念上的目标是验证所有参数都是适当类型的。尝试这样做的问题在于,适当的定义通常太狭窄,以至于真正有用。

这与验证对象是否符合其他标准是不同的。例如,数字范围检查可能是必要的,以防止无限循环。

可能会出问题的是尝试在__init__()方法中做以下操作:

class ValidPlayer:
    def __init__( self, table, bet_strategy, game_strategy ):
        assert isinstance( table, Table )
        assert isinstance( bet_strategy, BettingStrategy )
        assert isinstance( game_strategy, GameStrategy )

        self.bet_strategy = bet_strategy
        self.game_strategy = game_strategy
        self.table= table

isinstance()方法检查规避了 Python 正常的鸭子类型

我们编写一个赌场游戏模拟,以便尝试对GameStrategy进行无尽的变化。这些变化如此简单(仅仅四种方法),以至于从超类继承几乎没有真正的好处。我们可以独立定义这些类,没有整体的超类。

在这个例子中所示的初始化错误检查将迫使我们创建子类,仅仅是为了通过错误检查。没有从抽象超类继承可用的代码。

最大的鸭子类型问题之一涉及数字类型。不同的数字类型将在不同的上下文中起作用。尝试验证参数的类型可能会阻止一个完全合理的数字类型正常工作。在尝试验证时,我们在 Python 中有以下两种选择:

  • 我们进行验证,以便允许相对狭窄的类型集合,并且有一天,代码会因为禁止了本来可以合理工作的新类型而出错

  • 我们避免验证,以便允许广泛的类型集合,并且有一天,代码会因为使用了不合理的类型而出错

请注意,两者本质上是一样的。代码可能会在某一天出现问题。它可能会因为某种类型被阻止使用而导致问题,即使它是合理的,或者使用了一种不太合理的类型。

提示

只是允许它

通常,被认为更好的 Python 风格是允许任何类型的数据被使用。

我们将在第四章一致设计的 ABC中回到这一点。

问题是:为什么要限制潜在的未来用例?

通常的答案是没有好的理由限制潜在的未来用例。

与其阻止一个合理的,但可能是意想不到的用例,我们可以提供文档、测试和调试日志,以帮助其他程序员理解可以处理的类型的任何限制。我们必须提供文档、日志和测试用例,所以涉及的额外工作很少。

以下是一个提供类期望的示例文档字符串:

class Player:
    def __init__( self, table, bet_strategy, game_strategy ):
        """Creates a new player associated with a table, and configured with proper betting and play strategies

        :param table: an instance of :class:`Table`
        :param bet_strategy: an instance of :class:`BettingStrategy`
        :param  game_strategy: an instance of :class:`GameStrategy`
        """
        self.bet_strategy = bet_strategy
        self.game_strategy = game_strategy
        self.table= table

使用这个类的程序员已经被警告了类型限制是什么。允许使用其他类型。如果类型与预期类型不兼容,那么事情将会出错。理想情况下,我们将使用像unittestdoctest这样的工具来发现错误。

初始化、封装和隐私

关于隐私的一般 Python 政策可以总结如下:我们都是成年人

面向对象设计明确区分了接口和实现。这是封装思想的一个结果。一个类封装了一个数据结构、一个算法、一个外部接口或者其他有意义的东西。这个想法是让胶囊将基于类的接口与实现细节分开。

然而,没有一种编程语言能反映出每一个设计细微之处。通常情况下,Python 不会将所有设计考虑作为显式代码实现。

一个没有完全体现在代码中的类设计方面是对象的私有(实现)和公共(接口)方法或属性之间的区别。在支持它的语言中(C++或 Java 是两个例子),隐私的概念已经相当复杂。这些语言包括私有、受保护和公共等设置,以及“未指定”,这是一种半私有。私有关键字经常被错误地使用,使得子类定义变得不必要地困难。

Python 对隐私的概念很简单,如下所示:

  • 它们都是基本上公开的。源代码是可用的。我们都是成年人。没有什么是真正隐藏的。

  • 通常情况下,我们会以不太公开的方式对待一些名称。它们通常是可能会在没有通知的情况下发生变化的实现细节,但没有正式的私有概念。

_开头的名称在 Python 的某些部分被认为是不太公开的。help()函数通常会忽略这些方法。像 Sphinx 这样的工具可以将这些名称从文档中隐藏起来。

Python 的内部名称以__开头(和结尾)。这是 Python 内部与上面的应用程序功能不发生冲突的方式。这些内部名称的集合完全由语言参考定义。此外,尝试使用__在我们的代码中创建“超级私有”属性或方法没有任何好处。所有会发生的是,如果 Python 的一个版本开始使用我们为内部目的选择的名称,我们就会创建潜在的未来问题。此外,我们很可能会违反对这些名称应用的内部名称混淆。

Python 名称的可见性规则如下:

  • 大多数名称都是公开的。

  • _开头的名称稍微不太公开。将它们用于真正可能会发生变化的实现细节。

  • __开头和结尾的名称是 Python 内部的。我们从不自己创造这些名字;我们使用语言参考中定义的名字。

一般来说,Python 的方法是使用文档和精心选择的名称来注册方法(或属性)的意图。通常,接口方法将有详细的文档,可能包括doctest示例,而实现方法将有更简略的文档,可能没有doctest示例。

对于刚接触 Python 的程序员来说,隐私并没有被广泛使用有时会令人惊讶。对于有经验的 Python 程序员来说,令人惊讶的是有多少大脑能量被用来解决并不真正有帮助的私有和公共声明,因为方法名称和文档的意图是显而易见的。

总结

在本章中,我们已经回顾了__init__()方法的各种设计替代方案。在下一章中,我们将研究特殊方法,以及一些高级方法。

第二章:与 Python 基本特殊方法无缝集成

有许多特殊方法允许我们的类与 Python 之间进行紧密集成。标准库参考将它们称为基本。更好的术语可能是基础必要。这些特殊方法为构建与其他 Python 功能无缝集成的类奠定了基础。

例如,我们需要给定对象值的字符串表示。基类object有一个__repr__()__str__()的默认实现,提供对象的字符串表示。遗憾的是,这些默认表示非常不具信息性。我们几乎总是希望覆盖其中一个或两个默认定义。我们还将研究__format__(),它更复杂一些,但具有相同的目的。

我们还将研究其他转换,特别是__hash__()__bool__()__bytes__()。这些方法将对象转换为数字、真/假值或字节字符串。例如,当我们实现__bool__()时,我们可以在if语句中使用我们的对象:if someobject:

然后,我们可以查看实现比较运算符__lt__()__le__()__eq__()__ne__()__gt__()__ge__()的特殊方法。

这些基本特殊方法几乎总是需要在类定义中。

我们将最后查看__new__()__del__(),因为这些方法的用例相当复杂。我们不需要这些方法的频率与我们需要其他基本特殊方法的频率一样高。

我们将详细研究如何扩展简单的类定义以添加这些特殊方法。我们需要查看从对象继承的默认行为,以便了解需要覆盖哪些以及何时实际需要覆盖。

__repr__()__str__()方法

Python 有两种对象的字符串表示。这些与内置函数repr()str()print()string.format()方法密切相关。

  • 一般来说,对象的str()方法表示通常被期望对人更友好。这是通过对象的__str__()方法构建的。

  • repr()方法的表示通常会更加技术性,甚至可能是一个完整的 Python 表达式来重建对象。文档中说:

对于许多类型,此函数尝试返回一个字符串,当传递给eval()时将产生相同值的对象。

这是通过对象的__repr__()方法构建的。

  • print()函数将使用str()来准备对象进行打印。

  • 字符串的format()方法也可以访问这些方法。当我们使用{!r}{!s}格式化时,我们分别请求__repr__()__str__()

让我们先看看默认实现。

以下是一个简单的类层次结构:

class Card:
    insure= False
    def  __init__( self, rank, suit ):
        self.suit= suit
        self.rank= rank
        self.hard, self.soft = self._points()
class NumberCard( Card ):
    def _points( self ):
        return int(self.rank), int(self.rank)

我们已经定义了两个简单的类,每个类中有四个属性。

以下是与这些类中的一个对象的交互:

>>> x=NumberCard( '2', '♣')
>>> str(x)
'<__main__.NumberCard object at 0x1013ea610>'
>>> repr(x)
'<__main__.NumberCard object at 0x1013ea610>'
>>> print(x)
<__main__.NumberCard object at 0x1013ea610>

我们可以从这个输出中看到,__str__()__repr__()的默认实现并不是非常有信息性的。

当我们重写__str__()__repr__()时,有两种广泛的设计情况:

  • 非集合对象:一个“简单”的对象不包含其他对象的集合,通常也不涉及对该集合的非常复杂的格式化

  • 集合对象:包含集合的对象涉及到更复杂的格式化

非集合 str()和 repr()

正如我们之前看到的,__str__()__repr__()的输出并不是非常有信息性的。我们几乎总是需要重写它们。以下是在没有涉及集合时重写__str__()__repr__()的方法。这些方法属于之前定义的Card类:

    def __repr__( self ):
        return "{__class__.__name__}(suit={suit!r}, rank={rank!r})".format(
            __class__=self.__class__, **self.__dict__)
    def __str__( self ):
        return "{rank}{suit}".format(**self.__dict__)

这两种方法依赖于将对象的内部实例变量字典__dict__传递给format()函数。这对于使用__slots__的对象不合适;通常,这些是不可变对象。在格式规范中使用名称使得格式更加明确。它还使格式模板变得更长。在__repr__()的情况下,我们传递了内部的__dict__加上对象的__class__作为关键字参数值传递给format()函数。

模板字符串使用了两种格式规范:

  • {__class__.__name__}模板也可以写为{__class__.__name__!s},以更明确地提供类名的简单字符串版本

  • {suit!r}{rank!r}模板都使用!r格式规范来生成属性值的repr()方法

__str__()的情况下,我们只传递了对象的内部__dict__。格式化使用了隐式的{!s}格式规范来生成属性值的str()方法。

集合 str()和 repr()

当涉及到集合时,我们需要格式化集合中的每个单独项目以及这些项目的整体容器。以下是一个具有__str__()__repr__()方法的简单集合:

class Hand:
    def __init__( self, dealer_card, *cards ):
        self.dealer_card= dealer_card
        self.cards= list(cards)
    def __str__( self ):
        return ", ".join( map(str, self.cards) )
    def __repr__( self ):
        return "{__class__.__name__}({dealer_card!r}, {_cards_str})".format(
        __class__=self.__class__,
        _cards_str=", ".join( map(repr, self.cards) ),
        **self.__dict__ )

__str__()方法是一个简单的配方,如下所示:

  1. str()映射到集合中的每个项目。这将创建一个迭代器,遍历生成的字符串值。

  2. 使用, .join()`将所有项目字符串合并成一个单一的长字符串。

__repr__()方法是一个多部分的配方,如下所示:

  1. repr()映射到集合中的每个项目。这将创建一个迭代器,遍历生成的字符串值。

  2. 使用, .join()`将所有项目字符串合并在一起。

  3. 使用__class__、集合字符串和__dict__中的各种属性创建一组关键字。我们将集合字符串命名为_cards_str,以避免与现有属性冲突。

  4. 使用"{__class__.__name__}({dealer_card!r}, {_cards_str})".format()来组合类名和项目值的长字符串。我们使用!r格式化来确保属性也使用repr()转换。

在某些情况下,这可以被优化并变得更简单。使用位置参数进行格式化可以在一定程度上缩短模板字符串。

format()方法

__format__()方法被string.format()format()内置函数使用。这两个接口都用于获取给定对象的可呈现字符串版本。

以下是参数将被呈现给__format__()的两种方式:

  • someobject.__format__(""):当应用程序执行format(someobject)或类似于"{0}".format(someobject)时会发生这种情况。在这些情况下,提供了一个零长度的字符串规范。这应该产生一个默认格式。

  • someobject.__format__(specification):当应用程序执行format(someobject, specification)或类似于"{0:specification}".format(someobject)时会发生这种情况。

请注意,类似于"{0!r}".format()"{0!s}".format()并不使用__format__()方法。这些直接使用__repr__()__str__()

使用""作为规范,一个明智的响应是return str(self)。这提供了一个明显的一致性,使对象的各种字符串表示之间保持一致。

格式规范将是格式字符串中":"后面的所有文本。当我们写"{0:06.4f}"时,06.4f是应用于要格式化的参数列表中的项目0的格式规范。

Python 标准库文档的 6.1.3.1 节定义了一个复杂的数值规范,它是一个九部分的字符串。这是格式规范迷你语言。它具有以下语法:

[[fill]align][sign][#][0][width][,][.precision][type]

我们可以使用正则表达式RE)来解析这些标准规范,如下面的代码片段所示:

re.compile(
r"(?P<fill_align>.?[\<\>=\^])?"
"(?P<sign>[-+ ])?"
"(?P<alt>#)?"
"(?P<padding>0)?"
"(?P<width>\d*)"
"(?P<comma>,)?"
"(?P<precision>\.\d*)?"
"(?P<type>[bcdeEfFgGnosxX%])?" )

这个 RE 将规范分成八组。第一组将包括原始规范中的fillalignment字段。我们可以使用这些组来解决我们定义的类的数字数据的格式化问题。

然而,Python 的格式规范迷你语言可能不太适用于我们定义的类。因此,我们可能需要定义自己的规范迷你语言,并在我们的类__format__()方法中处理它。如果我们正在定义数值类型,我们应该坚持预定义的迷你语言。然而,对于其他类型,没有理由坚持预定义的语言。

例如,这是一个使用字符%r来显示等级和字符%s来显示花色的微不足道的语言。结果字符串中的%%字符变成%。所有其他字符都被字面重复。

我们可以扩展我们的Card类,如下面的代码片段所示:

    def __format__( self, format_spec ):
        if format_spec == "":
            return str(self)
        rs= format_spec.replace("%r",self.rank).replace("%s",self.suit)
        rs= rs.replace("%%","%")
        return rs

这个定义检查格式规范。如果没有规范,则使用str()函数。如果提供了规范,则进行一系列替换,将等级、花色和任何%字符折叠到格式规范中,将其变成输出字符串。

这使我们能够按照以下方式格式化卡片:

print( "Dealer Has {0:%r of %s}".format( hand.dealer_card) )

格式规范("%r of %s")作为format参数传递给我们的__format__()方法。使用这个,我们能够为我们定义的类的对象的呈现提供一个一致的接口。

或者,我们可以定义如下:

    default_format= "some specification"
    def __str__( self ):
        return self.__format__( self.default_format )
    def __format__( self, format_spec ):
        if format_spec == "":  format_spec = self.default_format
       # process the format specification.

这样做的好处是将所有字符串表示都放入__format__()方法中,而不是在__format__()__str__()之间分散。这有一个缺点,因为我们并不总是需要实现__format__(),但我们几乎总是需要实现__str__()

嵌套格式规范

string.format()方法可以处理{}的嵌套实例,以执行简单的关键字替换到格式规范中。这种替换是为了创建最终的格式字符串,该字符串传递给我们的类__format__()方法。这种嵌套替换简化了一些相对复杂的数值格式化,通过对本来是通用规范的参数化。

以下是一个示例,我们已经在format参数中使width易于更改:

width=6
for hand,count in statistics.items():
    print( "{hand} {count:{width}d}".format(hand=hand,count=count,width=width) )

我们定义了一个通用格式,"{hand:%r%s} {count:{width}d}",它需要一个width参数来将其转换为一个适当的格式规范。

提供给format()方法的width=参数的值用于替换{width}嵌套规范。一旦替换了这个值,整个最终格式将提供给__format__()方法。

集合和委托格式规范

在格式化包含集合的复杂对象时,我们有两个格式化问题:如何格式化整个对象以及如何格式化集合中的项目。例如,当我们看Hand时,我们看到我们有一个由单个Cards类组成的集合。我们希望Hand将一些格式化细节委托给Hand集合中的单个Card实例。

以下是适用于Hand__format__()方法:

    def __format__( self, format_specification ):
        if format_specification == "":
            return str(self)
        return ", ".join( "{0:{fs}}".format(c, fs=format_specification)
            for c in self.cards )

format_specification参数将用于Hand集合中的每个Card实例。"{0:{fs}}"的格式规范使用了嵌套格式规范技术,将format_specification字符串推送到创建适用于每个Card实例的格式中。有了这种方法,我们可以格式化一个Hand对象player_hand如下:

"Player: {hand:%r%s}".format(hand=player_hand)

这将对Hand对象的每个Card实例应用%r%s格式规范。

__hash__()方法

内置的hash()函数调用给定对象的__hash__()方法。这个哈希是一个计算,它将一个(可能复杂的)值减少到一个小的整数值。理想情况下,哈希反映了源值的所有位。其他哈希计算——通常用于加密目的——可以产生非常大的值。

Python 包括两个哈希库。加密质量的哈希函数在hashlib中。zlib模块有两个高速哈希函数:adler32()crc32()。对于相对简单的值,我们不使用这两者。对于大型、复杂的值,这些算法可以提供帮助。

hash()函数(以及相关的__hash__()方法)用于创建一个小整数键,用于处理诸如setfrozensetdict之类的集合。这些集合使用不可变对象的哈希值来快速定位集合中的对象。

这里的不可变性很重要;我们会多次提到它。不可变对象不会改变它们的状态。例如,数字3不会改变状态。它始终是3。同样,更复杂的对象也可以具有不可变的状态。Python 字符串是不可变的,因此它们可以用作映射和集合的键。

从对象继承的默认__hash__()实现返回基于对象内部 ID 值的值。可以使用id()函数来查看这个值,如下所示:

>>> x = object()
>>> hash(x)
269741571
>>> id(x)
4315865136
>>> id(x) / 16
269741571.0

从这个例子中,我们可以看到在作者特定的系统上,哈希值是对象的id//16。这个细节可能因平台而异。例如,CPython 使用可移植的C库,而 Jython 依赖于 Java JVM。

重要的是内部 ID 和默认的__hash__()方法之间有很强的相关性。这意味着默认行为是每个对象都是可哈希的,而且即使它们看起来具有相同的值,它们也是完全不同的。

如果我们想要将具有相同值的不同对象合并为单个可散列对象,我们需要修改这个。我们将在下一节中看一个例子,我们希望单个Card实例的两个实例被视为同一个对象。

决定要哈希什么

并非每个对象都应该提供哈希值。特别是,如果我们正在创建一个有状态、可变对象的类,该类应该永远不返回哈希值。__hash__的定义应该是None

另一方面,不可变对象可能合理地返回一个哈希值,以便该对象可以用作字典的键或集合的成员。在这种情况下,哈希值需要与相等测试的方式相对应。拥有声称相等但具有不同哈希值的对象是不好的。相反,具有相同哈希值但实际上不相等的对象是可以接受的。

__eq__()方法,我们也将在比较运算符部分进行讨论,与哈希密切相关。

有三个层次的相等比较:

  • 相同的哈希值:这意味着两个对象可能是相等的。哈希值为我们提供了一个快速检查可能相等的方法。如果哈希值不同,这两个对象不可能相等,也不可能是同一个对象。

  • 比较为相等:这意味着哈希值也必须相等。这是==运算符的定义。对象可能是同一个对象。

  • 相同的 IDD:这意味着它们是同一个对象。它们也比较为相等,并且将具有相同的哈希值。这是is运算符的定义。

哈希基本定律FLH)是这样的:比较为相等的对象具有相同的哈希值。

我们可以将哈希比较视为相等测试的第一步。

然而,反之不成立。对象可以具有相同的哈希值,但比较为不相等。这是有效的,并且在创建集合或字典时会导致一些预期的处理开销。我们无法可靠地从更大的数据结构中创建不同的 64 位哈希值。将会有不相等的对象被减少为巧合相等的哈希值。

巧合的是,当使用setsdicts时,相等的哈希值是预期的开销。这些集合具有内部算法,以在哈希冲突发生时使用备用位置。

通过__eq__()__hash__()方法函数定义相等测试和哈希值有三种用例:

  • 不可变对象:这些是无状态对象,如元组、命名元组和不可变集合的类型,不能被更新。我们有两种选择:

  • 既不定义__hash__()也不定义__eq__()。这意味着什么都不做,使用继承的定义。在这种情况下,__hash__()返回对象的 ID 值的一个微不足道的函数,__eq__()比较 ID 值。默认的相等测试有时可能令人费解。我们的应用程序可能需要两个Card(1,Clubs)实例测试为相等并计算相同的哈希;这不会默认发生。

  • 同时定义__hash__()__eq__()。请注意,对于不可变对象,我们期望同时定义两者。

  • 可变对象:这些是可以在内部修改的有状态对象。我们有一个设计选择:

  • 定义__eq__()但将__hash__设置为None。这些不能用作dict键或sets中的项目。

请注意,还有一个额外的可能组合:定义__hash__()但使用默认的__eq__()定义。这只是浪费代码,因为默认的__eq__()方法与is运算符相同。默认的__hash__()方法将涉及编写更少的代码以实现相同的行为。

我们将详细讨论这三种情况。

继承不可变对象的定义

让我们看看默认定义是如何运作的。以下是一个使用__hash__()__eq__()的默认定义的简单类层次结构:

class Card:
    insure= False
    def __init__( self, rank, suit, hard, soft ):
        self.rank= rank
        self.suit= suit
        self.hard= hard
        self.soft= soft
    def __repr__( self ):
        return "{__class__.__name__}(suit={suit!r}, rank={rank!r})".format(__class__=self.__class__, **self.__dict__)
    def __str__( self ):
        return "{rank}{suit}".format(**self.__dict__)

class NumberCard( Card ):
    def  __init__( self, rank, suit ):
        super().__init__( str(rank), suit, rank, rank )

class AceCard( Card ):
    def  __init__( self, rank, suit ):
        super().__init__( "A", suit, 1, 11 )

class FaceCard( Card ):
    def  __init__( self, rank, suit ):
        super().__init__( {11: 'J', 12: 'Q', 13: 'K' }[rank], suit, 10, 10 )

这是一个哲学上不可变对象的类层次结构。我们没有注意实现特殊方法来防止属性被更新。我们将在下一章中讨论属性访问。

让我们看看当我们使用这个类层次结构时会发生什么:

>>> c1 = AceCard( 1, '♣' )
>>> c2 = AceCard( 1, '♣' )

我们定义了两个看起来是相同的Card实例。我们可以检查id()值,如下面的代码片段所示:

>>> print( id(c1), id(c2) )
4302577232 4302576976

它们有不同的id()编号;它们是不同的对象。这符合我们的期望。

我们可以使用is运算符来检查它们是否相同,如下面的代码片段所示:

>>> c1 is c2
False

“is 测试”是基于id()编号的;它显示它们确实是不同的对象。

我们可以看到它们的哈希值彼此不同:

>>> print( hash(c1), hash(c2) )
268911077 268911061

这些哈希值直接来自id()值。这是我们对继承方法的期望。在这个实现中,我们可以从id()函数中计算哈希,如下面的代码片段所示:

>>> id(c1) / 16
268911077.0
>>> id(c2) / 16
268911061.0

由于哈希值不同,它们不应该相等。这符合哈希和相等的定义。然而,这违反了我们对这个类的期望。以下是一个相等性检查:

>>> print( c1 == c2 )
False

我们使用相同的参数创建了它们。它们并不相等。在某些应用中,这可能不太好。例如,在累积有关庄家牌的统计计数时,我们不希望因为模拟使用了 6 副牌而为一张牌有六个计数。

我们可以看到它们是适当的不可变对象,因为我们可以将它们放入一个集合中:

>>> print( set( [c1, c2] ) )
{AceCard(suit='♣', rank=1), AceCard(suit='♣', rank=1)}

这是来自标准库参考文档的记录行为。默认情况下,我们将得到一个基于对象 ID 的__hash__()方法,以便每个实例看起来都是唯一的。然而,这并不总是我们想要的。

覆盖不可变对象的定义

以下是一个简单的类层次结构,为我们提供了__hash__()__eq__()的定义:

class Card2:
    insure= False
    def __init__( self, rank, suit, hard, soft ):
        self.rank= rank
        self.suit= suit
        self.hard= hard
        self.soft= soft
    def __repr__( self ):
        return "{__class__.__name__}(suit={suit!r}, rank={rank!r})".format(__class__=self.__class__, **self.__dict__)
    def __str__( self ):
        return "{rank}{suit}".format(**self.__dict__)
    def __eq__( self, other ):
        return self.suit == other.suit and self.rank == other.rank
    def __hash__( self ):
        return hash(self.suit) ^ hash(self.rank)
class AceCard2( Card2 ):
    insure= True
    def  __init__( self, rank, suit ):
        super().__init__( "A", suit, 1, 11 )

这个对象原则上是不可变的。没有正式的机制使其不可变。我们将在第三章中看到如何防止属性值的更改,属性访问、属性和描述符

另外,注意到前面的代码省略了两个子类,它们与前一个示例没有显著变化。

__eq__()方法函数比较了这两个基本值:suitrank。它不比较硬值和软值;它们是从rank派生出来的。

二十一点的规则使得这个定义有些可疑。在二十一点中,花色实际上并不重要。我们应该只比较等级吗?我们应该定义一个额外的方法只比较等级吗?或者,应该依赖应用程序正确比较等级?这些问题没有最佳答案;这些只是权衡。

__hash__()方法函数使用异或运算符从这两个基本值中计算出一个位模式。使用^运算符是一种快速而简单的哈希方法,通常效果很好。对于更大更复杂的对象,可能需要更复杂的哈希方法。在发明可能存在错误的东西之前,先从ziplib开始。

让我们看看这些类的对象的行为。我们期望它们比较相等,并且在集合和字典中表现正常。这里有两个对象:

>>> c1 = AceCard2( 1, '♣' )
>>> c2 = AceCard2( 1, '♣' )

我们定义了两个看起来是相同的卡片实例。我们可以检查 ID 值以确保它们是不同的对象:

>>> print( id(c1), id(c2) )
4302577040 4302577296
>>> print( c1 is c2 )
False

它们有不同的id()编号。当我们使用is运算符进行测试时,我们发现它们是不同的。

让我们比较一下哈希值:

>>> print( hash(c1), hash(c2) )
1259258073890 1259258073890

哈希值是相同的。这意味着它们可能是相等的。

相等运算符显示它们确实比较相等:

>>> print( c1 == c2 )
True

由于它们是不可变的,我们可以将它们放入一个集合中,如下所示:

>>> print( set( [c1, c2] ) )
{AceCard2(suit='♣', rank='A')}

这符合我们对复杂不可变对象的期望。我们必须重写两个特殊方法才能获得一致且有意义的结果。

覆盖可变对象的定义

这个例子将继续使用Cards类。可变卡片的概念是奇怪的,甚至可能是错误的。然而,我们希望对先前的示例进行一点小调整。

以下是一个类层次结构,为可变对象提供了__hash__()__eq__()的定义:

class Card3:
    insure= False
    def __init__( self, rank, suit, hard, soft ):
        self.rank= rank
        self.suit= suit
        self.hard= hard
        self.soft= soft
    def __repr__( self ):
        return "{__class__.__name__}(suit={suit!r}, rank={rank!r})".format(__class__=self.__class__, **self.__dict__)
    def __str__( self ):
        return "{rank}{suit}".format(**self.__dict__)
    def __eq__( self, other ):
        return self.suit == other.suit and self.rank == other.rank
        # and self.hard == other.hard and self.soft == other.soft
    __hash__ = None
class AceCard3( Card3 ):
    insure= True
    def  __init__( self, rank, suit ):
        super().__init__( "A", suit, 1, 11 )

让我们看看这些类的对象如何行为。我们期望它们相等,但不能与集合或字典一起使用。我们将创建两个对象如下:

>>> c1 = AceCard3( 1, '♣' )
>>> c2 = AceCard3( 1, '♣' )

我们已经定义了两个看起来相同的卡片实例。

我们将查看它们的 ID 值,以确保它们确实是不同的。

>>> print( id(c1), id(c2) )
4302577040 4302577296

这里没有什么意外。我们将看看是否可以获得哈希值:

>>> print( hash(c1), hash(c2) )
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: unhashable type: 'AceCard3'

由于__hash__设置为None,这些Card3对象无法被哈希,也无法为hash()函数提供值。这是预期的行为。

我们可以执行相等性比较,如下面的代码片段所示:

>>> print( c1 == c2 )
True

相等性测试正常工作,允许我们比较卡片。它们只是不能插入到集合中或用作字典的键。

当我们尝试时会发生以下情况:

>>> print( set( [c1, c2] ) )
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: unhashable type: 'AceCard3'

当尝试将它们放入集合时,我们会得到一个适当的异常。

显然,这不是对像卡片这样在现实生活中是不可变的东西的正确定义。这种定义方式更适合于状态对象,比如Hand,其中手牌的内容总是在变化。我们将在下一节为您提供另一个状态对象的示例。

从可变手牌制作冻结手牌

如果我们想对特定的Hand实例执行统计分析,我们可能希望创建一个将Hand实例映射到计数的字典。我们不能使用可变的Hand类作为映射中的键。但是,我们可以并行设计setfrozenset,并创建两个类:HandFrozenHand。这允许我们通过FrozenHand“冻结”Hand类;冻结版本是不可变的,可以用作字典中的键。

以下是一个简单的Hand定义:

class Hand:
     def __init__( self, dealer_card, *cards ):
        self.dealer_card= dealer_card
        self.cards= list(cards)
    def __str__( self ):
        return ", ".join( map(str, self.cards) )
    def __repr__( self ):
        return "{__class__.__name__}({dealer_card!r}, {_cards_str})".format(
        __class__=self.__class__,
        _cards_str=", ".join( map(repr, self.cards) ),
        **self.__dict__ )
    def __eq__( self, other ):
        return self.cards == other.cards and self.dealer_card == other.dealer_card
    __hash__ = None

这是一个可变对象(__hash__None),它具有适当的相等性测试,可以比较两手牌。

以下是Hand的冻结版本:

import sys
class FrozenHand( Hand ):
    def __init__( self, *args, **kw ):
        if len(args) == 1 and isinstance(args[0], Hand):
            # Clone a hand
            other= args[0]
            self.dealer_card= other.dealer_card
            self.cards= other.cards
        else:
            # Build a fresh hand
            super().__init__( *args, **kw )
    def __hash__( self ):
        h= 0
        for c in self.cards:
            h = (h + hash(c)) % sys.hash_info.modulus
        return h

冻结版本有一个构造函数,可以从另一个Hand类构建一个Hand类。它定义了一个__hash__()方法,该方法对限制为sys.hash_info.modulus值的卡片哈希值求和。在大多数情况下,这种基于模的计算方法对于计算复合对象的哈希值是合理的。

现在我们可以使用这些类进行以下操作:

stats = defaultdict(int)

d= Deck()
h = Hand( d.pop(), d.pop(), d.pop() )
h_f = FrozenHand( h )
stats[h_f] += 1

我们初始化了一个统计字典stats,作为一个可以收集整数计数的defaultdict字典。我们也可以使用collections.Counter对象来实现这一点。

通过冻结Hand类,我们可以将其用作字典中的键,收集实际发放的每个手牌的计数。

bool()方法

Python 对虚假的定义很愉快。参考手册列出了许多值,这些值将测试为等同于False。这包括诸如False0''()[]{}等内容。大多数其他对象将测试为等同于True

通常,我们希望通过以下简单语句检查对象是否“非空”:

if some_object:
    process( some_object )

在幕后,这是bool()内置函数的工作。这个函数依赖于给定对象的__bool__()方法。

默认的__bool__()方法返回True。我们可以通过以下代码看到这一点:

>>> x = object()
>>> bool(x)
True

对于大多数类来说,这是完全有效的。大多数对象不应该是False。然而,对于集合来说,这是不合适的。空集合应该等同于False。非空集合可以返回True。我们可能希望为我们的Deck对象添加一个类似的方法。

如果我们包装一个列表,我们可能会有以下代码片段中显示的内容:

def __bool__( self ):
    return bool( self._cards )

这将布尔函数委托给内部的_cards集合。

如果我们扩展一个列表,可能会有以下内容:

def __bool__( self ):
    return super().__bool__( self )

这将委托给超类定义的__bool__()函数。

在这两种情况下,我们都在专门委托布尔测试。在 wrap 情况下,我们委托给集合。在 extend 情况下,我们委托给超类。无论哪种方式,wrap 或 extend,空集合都将是False。这将让我们有办法看到Deck对象是否已经完全发放并且为空。

我们可以按照以下代码片段所示的方式进行操作:

d = Deck()
while d:
    card= d.pop()
    # process the card

这个循环将处理所有的卡片,而不会在牌堆用尽时出现IndexError异常。

__bytes__()方法

很少有机会将对象转换为字节。我们将在第二部分中详细讨论持久性和序列化

在最常见的情况下,应用程序可以创建一个字符串表示,Python IO 类的内置编码功能将用于将字符串转换为字节。这对几乎所有情况都有效。主要的例外情况是当我们定义一种新类型的字符串时。在这种情况下,我们需要定义该字符串的编码。

bytes()函数根据参数执行各种操作:

  • bytes(integer): 返回一个具有给定数量的0x00值的不可变字节对象。

  • bytes(string): 这将把给定的字符串编码为字节。编码和错误处理的额外参数将定义编码过程的细节。

  • bytes(something): 这将调用something.__bytes__()来创建一个字节对象。这里不会使用编码或错误参数。

基本的object类没有定义__bytes__()。这意味着我们的类默认情况下不提供__bytes__()方法。

有一些特殊情况,我们可能有一个需要直接编码成字节的对象,然后再写入文件。使用字符串并允许str类型为我们生成字节通常更简单。在处理字节时,重要的是要注意,没有简单的方法从文件或接口解码字节。内置的bytes类只会解码字符串,而不是我们独特的新对象。我们可能需要解析从字节解码的字符串。或者,我们可能需要显式地使用struct模块解析字节,并从解析的值创建我们独特的对象。

我们将研究将Card编码和解码为字节。由于只有 52 张卡片,每张卡片可以打包成一个字节。然而,我们选择使用一个字符来表示suit和一个字符来表示rank。此外,我们需要正确重建Card的子类,因此我们需要编码几件事情:

  • Card的子类(AceCardNumberCardFaceCard

  • 子类定义的__init__()的参数

请注意,我们的一些替代__init__()方法会将数字等级转换为字符串,从而丢失原始的数值。为了可逆的字节编码,我们需要重建这个原始的数字等级值。

以下是__bytes__()的实现,它返回Cards类,ranksuitUTF-8编码:

    def __bytes__( self ):
        class_code= self.__class__.__name__[0]
        rank_number_str = {'A': '1', 'J': '11', 'Q': '12', 'K': '13'}.get( self.rank, self.rank )
        string= "("+" ".join([class_code, rank_number_str, self.suit,] ) + ")"
        return bytes(string,encoding="utf8")

这通过创建Card对象的字符串表示,然后将字符串编码为字节来实现。这通常是最简单和最灵活的方法。

当我们得到一堆字节时,我们可以解码字符串,然后将字符串解析成一个新的Card对象。以下是一个可以用来从字节创建Card对象的方法:

def card_from_bytes( buffer ):
    string = buffer.decode("utf8")
    assert string[0 ]=="(" and string[-1] == ")"
    code, rank_number, suit = string[1:-1].split()
    class_ = { 'A': AceCard, 'N': NumberCard, 'F': FaceCard }[code]
    return class_( int(rank_number), suit )

在上述代码中,我们已经将字节解码为字符串。然后我们解析了字符串成单独的值。从这些值中,我们可以找到类并构建原始的Card对象。

我们可以按照以下方式构建Card对象的字节表示:

b= bytes(someCard)

我们可以按照以下方式从字节重建Card对象:

someCard = card_from_bytes(b)

重要的是要注意,外部字节表示通常很难设计。我们正在创建对象状态的表示。Python 已经有了许多适合我们类定义的表示。

通常最好使用 picklejson 模块,而不是发明一个对象的低级字节表示。这是第九章 序列化和保存 – JSON、YAML、Pickle、CSV 和 XML 的主题。

比较操作符方法

Python 有六个比较操作符。这些操作符有特殊的方法实现。根据文档,映射如下:

  • x<y 调用 x.__lt__(y)

  • x<=y 调用 x.__le__(y)

  • x==y 调用 x.__eq__(y)

  • x!=y 调用 x.__ne__(y)

  • x>y 调用 x.__gt__(y)

  • x>=y 调用 x.__ge__(y)

我们将在第七章 创建数字 中再次讨论比较操作符。

这里还有一个关于实际实现了哪些操作符的额外规则。这些规则基于这样一个想法,即左边的对象类定义了所需的特殊方法。如果没有定义,Python 可以尝试通过改变顺序来尝试替代操作。

提示

以下是两个基本规则

首先,检查左边的操作数是否有操作符实现:A<B 意味着 A.__lt__(B)

其次,检查右边的操作数是否有反向操作符实现:A<B 意味着 B.__gt__(A)

这种情况的罕见例外是右操作数是左操作数的子类;然后,首先检查右操作数以允许子类覆盖超类。

我们可以通过定义一个只有一个操作符定义的类,然后用它进行其他操作来看看它是如何工作的。

以下是一个我们可以使用的部分类:

class BlackJackCard_p:
    def __init__( self, rank, suit ):
        self.rank= rank
        self.suit= suit
    def __lt__( self, other ):
        print( "Compare {0} < {1}".format( self, other ) )
        return self.rank < other.rank
    def __str__( self ):
        return "{rank}{suit}".format( **self.__dict__ )

这遵循了二十一点比较规则,其中花色无关紧要。我们省略了比较方法,以查看 Python 在操作符缺失时会如何回退。这个类将允许我们执行 < 比较。有趣的是,Python 也可以使用这个类来执行 > 比较,方法是交换参数顺序。换句话说,x<y≡y>x。这是镜像反射规则;我们将在第七章 创建数字 中再次看到它。

当我们尝试评估不同的比较操作时,我们会看到这一点。我们将创建两个 Cards 类并以各种方式进行比较,如下面的代码片段所示:

>>> two = BlackJackCard_p( 2, '♠' )
>>> three = BlackJackCard_p( 3, '♠' )
>>> two < three
Compare 2♠ < 3♠
True
>>> two > three
Compare 3♠ < 2♠
False
>>> two == three
False
>>> two <= three
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: unorderable types: BlackJackCard_p() <= BlackJackCard_p()

从中我们可以看到 two < three 映射到 two.__lt__(three)

然而,对于 two > three,没有定义 __gt__() 方法;Python 使用 three.__lt__(two) 作为备用计划。

默认情况下,__eq__() 方法是从 object 继承的;它比较对象的 ID;对象参与 ==!= 测试如下:

>>> two_c = BlackJackCard_p( 2, '♣' )
>>> two == two_c
False

我们可以看到结果并不完全符合我们的预期。我们经常需要覆盖 __eq__() 的默认实现。

此外,这些操作符之间没有逻辑连接。从数学上讲,我们可以从其中两个推导出所有必要的比较。Python 不会自动执行这一点。相反,Python 默认处理以下四个简单的反射对:

比较操作符方法比较操作符方法比较操作符方法比较操作符方法

这意味着我们必须至少提供四对中的一个。例如,我们可以提供 __eq__()__ne__()__lt__()__le__()

@functools.total_ordering 装饰器克服了默认限制,并从 __eq__() 和其中一个 __lt__()__le__()__gt__()__ge__() 推断出其余的比较。我们将在第七章 创建数字 中重新讨论这个问题。

设计比较

在定义比较运算符时有两个考虑因素:

  • 如何比较同一类的两个对象的明显问题

  • 如何比较不同类的对象的不太明显的问题

对于一个具有多个属性的类,当查看比较运算符时,我们经常会有一个深刻的模糊。我们可能不清楚我们要比较什么。

考虑谦卑的纸牌(再次!)。诸如card1 == card2的表达显然是用来比较ranksuit的。对吗?或者这总是正确的吗?毕竟,在二十一点中suit并不重要。

如果我们想要决定一个Hand对象是否可以分割,我们必须看看哪个代码片段更好。以下是第一个代码片段:

if hand.cards[0] == hand.cards[1]

以下是第二个代码片段:

if hand.cards[0].rank == hand.cards[1].rank

虽然一个更短,简洁并不总是最好的。如果我们定义相等只考虑rank,我们将很难定义单元测试,因为一个简单的TestCase.assertEqual()方法将容忍各种各样的卡片,而单元测试应该专注于完全正确的卡片。

诸如card1 <= 7的表达显然是用来比较rank的。

我们是否希望一些比较比较卡片的所有属性,而其他比较只比较rank?我们如何按suit对卡片进行排序?此外,相等比较必须与哈希计算相对应。如果我们在哈希中包含了多个属性,我们需要在相等比较中包含它们。在这种情况下,似乎卡片之间的相等(和不等)必须是完全的Card比较,因为我们正在对Card值进行哈希以包括ranksuit

然而,Card之间的排序比较应该只是rank。同样,对整数的比较也应该只是rank。对于检测分割的特殊情况,hand.cards[0].rank == hand.cards[1].rank将很好,因为它明确了分割的规则。

实现相同类的对象的比较

我们将通过查看一个更完整的BlackJackCard类来看一个简单的同类比较:

 class BlackJackCard:
    def __init__( self, rank, suit, hard, soft ):
        self.rank= rank
        self.suit= suit
        self.hard= hard
        self.soft= soft
    def __lt__( self, other ):
        if not isinstance( other, BlackJackCard ): return NotImplemented
        return self.rank < other.rank

    def __le__( self, other ):
        try:
            return self.rank <= other.rank
        except AttributeError:
            return NotImplemented
    def __gt__( self, other ):
        if not isinstance( other, BlackJackCard ): return NotImplemented
        return self.rank > other.rank
    def __ge__( self, other ):
        if not isinstance( other, BlackJackCard ): return NotImplemented
        return self.rank >= other.rank
    def __eq__( self, other ):
        if not isinstance( other, BlackJackCard ): return NotImplemented
        return self.rank == other.rank and self.suit == other.suit
    def __ne__( self, other ):
        if not isinstance( other, BlackJackCard ): return NotImplemented
        return self.rank != other.rank and self.suit != other.suit
    def __str__( self ):
        return "{rank}{suit}".format( **self.__dict__ )

我们现在已经定义了所有六个比较运算符。

我们向您展示了两种类型检查:显式隐式。显式类型检查使用isinstance()。隐式类型检查使用try:块。使用try:块有一个微小的概念优势:它避免了重复类的名称。完全有可能有人想要发明一个与BlackJackCard的定义兼容但未定义为正确子类的卡的变体。使用isinstance()可能会阻止一个否则有效的类正常工作。

try:块可能允许一个偶然具有rank属性的类工作。这变成一个难以解决的问题的风险为零,因为该类在此应用中的其他任何地方都可能失败。此外,谁会将Card的实例与一个具有等级排序属性的金融建模应用程序的类进行比较?

在以后的示例中,我们将专注于try:块。isinstance()方法检查是 Python 的成语,并且被广泛使用。我们明确返回NotImplemented以通知 Python 这个运算符对于这种类型的数据没有实现。Python 可以尝试颠倒参数顺序,看看另一个操作数是否提供了实现。如果找不到有效的运算符,那么将引发TypeError异常。

我们省略了三个子类定义和工厂函数card21()。它们留作练习。

我们还省略了类内比较;我们将在下一节中保存。有了这个类,我们可以成功地比较卡片。以下是一个我们创建和比较三张卡片的示例:

>>> two = card21( 2, '♠' )
>>> three = card21( 3, '♠' )
>>> two_c = card21( 2, '♣' )

鉴于这些Cards类,我们可以执行许多比较,如下面的代码片段所示:

>>> two == two_c
False
>>> two.rank == two_c.rank
True
>>> two < three
True
>>> two_c < three
True

定义似乎按预期工作。

混合类对象的比较实现

我们将使用BlackJackCard类作为一个例子,看看当我们尝试比较两个不同类的操作数时会发生什么。

以下是我们可以与int值进行比较的Card实例:

>>> two = card21( 2, '♣' )
>>> two < 2
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: unorderable types: Number21Card() < int()
>>> two > 2
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: unorderable types: Number21Card() > int()

这是我们预期的:BlackJackCard的子类Number21Card没有提供所需的特殊方法,因此会有一个TypeError异常。

然而,考虑以下两个例子:

>>> two == 2
False
>>> two == 3
False

为什么这些提供了响应?当遇到NotImplemented值时,Python 会颠倒操作数。在这种情况下,整数值定义了一个int.__eq__()方法,容忍意外类的对象。

硬总数、软总数和多态性

让我们定义Hand,以便它执行有意义的混合类比较。与其他比较一样,我们必须确定我们要比较的内容。

对于Hands之间的相等比较,我们应该比较所有的卡。

对于Hands之间的排序比较,我们需要比较每个Hand对象的属性。对于与int字面值的比较,我们应该将Hand对象的总数与字面值进行比较。为了得到一个总数,我们必须解决 Blackjack 游戏中硬总数和软总数的微妙之处。

当手中有一张 ace 时,接下来有两个候选总数:

  • 软总数将 ace 视为 11。如果软总数超过 21,那么这个 ace 的版本必须被忽略。

  • 硬总数将 ace 视为 1。

这意味着手的总数不是卡的简单总和。

我们首先必须确定手中是否有 ace。有了这个信息,我们可以确定是否有一个有效的(小于或等于 21)软总数。否则,我们将退回到硬总数。

相当差的多态性的一个症状是依赖isinstance()来确定子类成员资格。一般来说,这是对基本封装的违反。一个良好的多态子类定义应该完全等同于相同的方法签名。理想情况下,类定义是不透明的;我们不需要查看类定义。一个糟糕的多态类集使用了大量的isinstance()测试。在某些情况下,isinstance()是必要的。这可能是因为使用了内置类。我们不能事后向内置类添加方法函数,而且可能不值得为了添加一个多态辅助方法而对其进行子类化。

在一些特殊方法中,有必要使用isinstance()来实现跨多个对象类的操作,其中没有简单的继承层次结构。我们将在下一节中展示isinstance()在不相关类中的惯用用法。

对于我们的卡类层次结构,我们希望有一个方法(或属性)来识别一个 ace,而不必使用isinstance()。这是一个多态辅助方法。它确保我们可以区分否则相等的类。

我们有两个选择:

  • 添加一个类级别的属性

  • 添加一个方法

由于保险赌注的工作方式,我们有两个原因要检查 ace。如果庄家的卡是 ace,它会触发一个保险赌注。如果庄家的手(或玩家的手)有一个 ace,就会有一个软总数与硬总数的计算。

硬总数和软总数总是由 ace 的card.soft-card.hard值不同。我们可以查看AceCard的定义,看到这个值是 10。然而,查看实现会通过深入查看类的实现来破坏封装。

我们可以将BlackjackCard视为不透明,并检查card.soft-card.hard!=0是否为真。如果是真的,这就足够了解手的硬总数与软总数。

以下是使用软与硬差值的total方法的版本:

def total( self ):
    delta_soft = max( c.soft-c.hard for c in self.cards )
    hard = sum( c.hard for c in self.cards )
    if hard+delta_soft <= 21: return hard+delta_soft
    return hard

我们将计算硬总数和软总数之间的最大差值作为delta_soft。对于大多数卡片,差值为零。对于一张 A 牌,差值将不为零。

给定硬总和delta_soft,我们可以确定要返回的总数。如果hard+delta_soft小于或等于 21,则值为软总数。如果软总数大于 21,则恢复到硬总数。

我们可以考虑在类中将值 21 作为一个显式常量。有时,有意义的名称比字面量更有帮助。由于 21 点的规则,21 点不太可能会改变为其他值。很难找到比字面量 21 更有意义的名称。

混合类比较示例

给定Hand对象的总数定义,我们可以有意义地定义Hand实例之间的比较以及Handint之间的比较。为了确定我们正在进行哪种比较,我们被迫使用isinstance()

以下是对Hand进行比较的部分定义:

class Hand:
    def __init__( self, dealer_card, *cards ):
        self.dealer_card= dealer_card
        self.cards= list(cards)
    def __str__( self ):
        return ", ".join( map(str, self.cards) )
    def __repr__( self ):
        return "{__class__.__name__}({dealer_card!r}, {_cards_str})".format(
        __class__=self.__class__,
        _cards_str=", ".join( map(repr, self.cards) ),
        **self.__dict__ )

    def __eq__( self, other ):
        if isinstance(other,int):
            return self.total() == other
        try:
            return (self.cards == other.cards 
                and self.dealer_card == other.dealer_card)
        except AttributeError:
            return NotImplemented
    def __lt__( self, other ):
        if isinstance(other,int):
            return self.total() < other
        try:
            return self.total() < other.total()
        except AttributeError:
            return NotImplemented
    def __le__( self, other ):
        if isinstance(other,int):
            return self.total() <= other
        try:
            return self.total() <= other.total()
        except AttributeError:
            return NotImplemented
    __hash__ = None
    def total( self ):
        delta_soft = max( c.soft-c.hard for c in self.cards )
        hard = sum( c.hard for c in self.cards )
        if hard+delta_soft <= 21: return hard+delta_soft
        return hard

我们定义了三种比较,而不是全部六种。

为了与Hands交互,我们需要一些Card对象:

>>> two = card21( 2, '♠' )
>>> three = card21( 3, '♠' )
>>> two_c = card21( 2, '♣' )
>>> ace = card21( 1, '♣' )
>>> cards = [ ace, two, two_c, three ]

我们将使用这组卡片来查看两个不同的hand实例。

这个第一个Hands对象有一个无关紧要的庄家Card对象和之前创建的四张Card的集合。其中一张Card是一张 A 牌。

>>> h= Hand( card21(10,'♠'), *cards )
>>> print(h)
A♣, 2♠, 2♣, 3♠
>>> h.total()
18

软总数是 18,硬总数是 8。

以下是一个具有额外Card对象的第二个Hand对象:

>>> h2= Hand( card21(10,'♠'), card21(5,'♠'), *cards )
>>> print(h2)
5♠, A♣, 2♠, 2♣, 3♠
>>> h2.total()
13

硬总数是 13。没有软总数,因为它会超过 21。

Hands之间的比较非常好,如下面的代码片段所示:

>>> h < h2
False
>>> h > h2
True

我们可以根据比较运算符对Hands进行排名。

我们也可以将Hands与整数进行比较,如下所示:

>>> h == 18
True
>>> h < 19
True
>>> h > 17
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: unorderable types: Hand() > int()

与整数的比较只要 Python 不被迫尝试回退就可以工作。前面的例子向我们展示了当没有__gt__()方法时会发生什么。Python 检查了反射操作数,整数 17 对于Hand也没有适当的__lt__()方法。

我们可以添加必要的__gt__()__ge__()函数,使Hand与整数正常工作。

__del__()方法

__del__()方法有一个相当模糊的用例。

意图是在对象从内存中删除之前给对象一个机会做任何清理或最终处理。上下文管理器对象和with语句可以更清晰地处理这种用例。这是第五章的主题,使用可调用和上下文。创建上下文比处理__del__()和 Python 垃圾收集算法更可预测。

在 Python 对象具有相关 OS 资源的情况下,__del__()方法是从 Python 应用程序中干净地释放资源的最后机会。例如,隐藏打开文件、挂载设备或者子进程的 Python 对象都可能受益于在__del__()处理中释放资源。

__del__()方法不会在任何易于预测的时间被调用。当对象被del语句删除时,并不总是调用它,当命名空间被删除时,也不总是调用它。__del__()方法的文档描述了不稳定的情况,并在异常处理上提供了额外的注意事项:执行期间发生的异常将被忽略,并在sys.stderr上打印警告。

出于这些原因,上下文管理器通常比实现__del__()更可取。

引用计数和销毁

对于 CPython 实现,对象有一个引用计数。当对象被分配给一个变量时,引用计数会增加,当变量被移除时,引用计数会减少。当引用计数为零时,对象不再需要,可以被销毁。对于简单对象,__del__()将被调用并且对象将被移除。

对于具有对象之间循环引用的复杂对象,引用计数可能永远不会降为零,并且__del__()不能轻易被调用。

以下是一个我们可以用来查看发生了什么的类:

class Noisy:
    def __del__( self ):
        print( "Removing {0}".format(id(self)) )

我们可以创建(并查看移除)这些对象,如下所示:

>>> x= Noisy()
>>> del x
Removing 4313946640

我们创建并移除了一个Noisy对象,几乎立即就看到了__del__()方法的消息。这表明当删除x变量时,引用计数正确地降为零。一旦变量消失,对Noisy实例的引用也就不存在了,它也可以被清理了。

以下是一个经常出现的涉及经常创建的浅拷贝的情况:

>>> ln = [ Noisy(), Noisy() ]
>>> ln2= ln[:]
>>> del ln

对于这个del语句没有响应。Noisy对象的引用计数还没有降为零;它们仍然在某个地方被引用,如下面的代码片段所示:

>>> del ln2
Removing 4313920336
Removing 4313920208

ln2变量是ln列表的浅拷贝。Noisy对象被两个列表引用。只有当这两个列表都被移除,将引用计数降为零后,它们才能被销毁。

还有许多其他创建浅拷贝的方法。以下是一些创建对象浅拷贝的方法:

a = b = Noisy()
c = [ Noisy() ] * 2

关键在于我们经常会被对象的引用数量所困扰,因为浅拷贝在 Python 中很常见。

循环引用和垃圾回收

这是一个涉及循环引用的常见情况。一个类Parent包含一个子类的集合。每个Child实例都包含对Parent类的引用。

我们将使用这两个类来检查循环引用:

class Parent:
    def __init__( self, *children ):
        self.children= list(children)
        for child in self.children:
            child.parent= self
    def __del__( self ):
        print( "Removing {__class__.__name__} {id:d}".format( __class__=self.__class__, id=id(self)) )

class Child:
    def __del__( self ):
        print( "Removing {__class__.__name__} {id:d}".format( __class__=self.__class__, id=id(self)) )

Parent实例有一个简单的list作为子类的集合。

每个Child实例都引用包含它的Parent类。这个引用是在初始化时创建的,当子类被插入到父类的内部集合中。

我们使这两个类都很吵闹,这样我们就可以看到对象何时被移除。以下是发生的情况:

>>>> p = Parent( Child(), Child() )
>>> id(p)
4313921808
>>> del p

Parent和两个初始的Child实例都无法被移除。它们彼此之间都包含引用。

我们可以创建一个没有子类的Parent实例,如下面的代码片段所示:

>>> p= Parent()
>>> id(p)
4313921744
>>> del p
Removing Parent 4313921744

这被删除了,如预期的那样。

由于相互引用或循环引用,Parent实例及其Child实例的列表无法从内存中移除。如果我们导入垃圾收集器接口gc,我们可以收集并显示这些不可移除的对象。

我们将使用gc.collect()方法来收集所有具有__del__()方法的不可移除对象,如下面的代码片段所示:

>>> import gc
>>> gc.collect()
174
>>> gc.garbage
[<__main__.Parent object at 0x101213910>, <__main__.Child object at 0x101213890>, <__main__.Child object at 0x101213650>, <__main__.Parent object at 0x101213850>, <__main__.Child object at 0x1012130d0>, <__main__.Child object at 0x101219a10>, <__main__.Parent object at 0x101213250>, <__main__.Child object at 0x101213090>, <__main__.Child object at 0x101219810>, <__main__.Parent object at 0x101213050>, <__main__.Child object at 0x101213210>, <__main__.Child object at 0x101219f90>, <__main__.Parent object at 0x101213810>, <__main__.Child object at 0x1012137d0>, <__main__.Child object at 0x101213790>]

我们可以看到我们的Parent对象(例如,ID 为4313921808 = 0x101213910)在不可移除的垃圾列表中很显眼。为了将引用计数降为零,我们需要更新垃圾列表中的每个Parent实例以移除子类,或者更新列表中的每个Child实例以移除对Parent实例的引用。

请注意,我们不能通过在__del__()方法中放置代码来打破循环引用。__del__()方法在循环引用被打破并且引用计数已经为零之后才会被调用。当存在循环引用时,我们不能再依赖简单的 Python 引用计数来清除未使用对象的内存。我们必须显式地打破循环引用,或者使用weakref引用来进行垃圾回收。

循环引用和 weakref 模块

在需要循环引用但又希望__del__()正常工作的情况下,我们可以使用弱引用。循环引用的一个常见用例是相互引用:一个父对象有一组子对象;每个子对象都有一个指向父对象的引用。如果Player类有多个手,那么Hand对象包含对拥有Player类的引用可能会有所帮助。

默认的对象引用可以称为强引用;然而,直接引用是一个更好的术语。它们被 Python 中的引用计数机制使用,并且如果引用计数无法移除对象,它们可以被垃圾回收器发现。它们不能被忽视。

对对象的强引用直接跟随。考虑以下语句:

当我们说:

a= B()

a变量直接引用了创建的B类的对象。B的实例的引用计数至少为 1,因为a变量有一个引用。

弱引用涉及两步过程来找到关联的对象。弱引用将使用x.parent(),调用弱引用作为可调用对象来跟踪实际的父对象。这两步过程允许引用计数或垃圾回收器移除引用的对象,使弱引用悬空。

weakref模块定义了许多使用弱引用而不是强引用的集合。这使我们能够创建字典,例如,允许对否则未使用的对象进行垃圾回收。

我们可以修改我们的ParentChild类,使用从ChildParent的弱引用,允许更简单地销毁未使用的对象。

下面是一个修改过的类,它使用从ChildParent的弱引用:

import weakref
class Parent2:
    def __init__( self, *children ):
        self.children= list(children)
        for child in self.children:
            child.parent= weakref.ref(self)
    def __del__( self ):
        print( "Removing {__class__.__name__} {id:d}".format( __class__=self.__class__, id=id(self)) )

我们已经将子到父的引用更改为weakref对象引用。

Child类内部,我们必须通过两步操作来定位parent对象:

p = self.parent()
if p is not None:
    # process p, the Parent instance
else:
    # the parent instance was garbage collected.

我们可以明确检查确保找到了引用的对象。有可能引用被悬空。

当我们使用这个新的Parent2类时,我们看到引用计数变为零,对象被移除:

>>> p = Parent2( Child(), Child() )
>>> del p
Removing Parent2 4303253584
Removing Child 4303256464
Removing Child 4303043344

weakref引用失效(因为引用对象被销毁)时,我们有三种潜在的响应:

  • 重新创建引用。可能从数据库重新加载。

  • 使用warnings模块在低内存情况下写入调试信息,其中垃圾回收器意外地移除了对象。

  • 忽略这个问题。

通常,weakref引用已经失效,因为对象已被移除:变量已经超出作用域,命名空间不再使用,应用程序正在关闭。因此,第三种响应是非常常见的。试图创建引用的对象可能也即将被移除。

__del__()close()方法

__del__()最常见的用途是确保文件被关闭。

通常,打开文件的类定义将具有类似以下代码的内容:

__del__ = close

这将确保__del__()方法也是close()方法。

比这更复杂的任何事情最好使用上下文管理器来完成。有关上下文管理器的更多信息,请参见第五章,使用可调用对象和上下文

__new__()方法和不可变对象

__new__()方法的一个用例是初始化否则不可变的对象。__new__()方法是我们的代码可以构建未初始化对象的地方。这允许在调用__init__()方法设置对象的属性值之前进行处理。

__new__()方法用于扩展不可变类,其中__init__()方法不能轻松地被覆盖。

以下是一个不起作用的类。我们将定义一个携带有关单位信息的float的版本:

class Float_Fail( float ):
    def __init__( self, value, unit ):
        super().__init__( value )
        self.unit = unit

我们正在(不当地)初始化一个不可变对象。

当我们尝试使用这个类定义时会发生什么,以下是发生的情况:

>>> s2 = Float_Fail( 6.5, "knots" )
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: float() takes at most 1 argument (2 given)

由此可见,我们无法轻松地覆盖内置的不可变float类的__init__()方法。我们在所有其他不可变类中也会遇到类似的问题。我们无法设置不可变对象self的属性值,因为这就是不可变性的定义。我们只能在对象构造期间设置属性值。在此之后进入__new__()方法。

__new__()方法自动成为一个静态方法。这是真的,即使没有使用@staticmethod装饰器。它不使用self变量,因为它的工作是创建最终将分配给self变量的对象。

对于这种用例,方法签名是__new__(cls, *args, **kw)cls参数是必须创建实例的类。对于下一节中的元类用例,args值序列比这里显示的更复杂。

__new__()的默认实现只是这样做:return super().__new__(cls)。它将操作委托给超类。最终工作被委托给object.__new__(),它构建了一个简单的、空的所需类的对象。__new__()的参数和关键字,除了cls参数,将作为标准 Python 行为的一部分传递给__init__()

除了两个显著的例外,这正是我们想要的。以下是例外情况:

  • 当我们想要对一个不可变的类定义进行子类化时。我们稍后会深入讨论这一点。

  • 当我们需要创建一个元类时。这是下一节的主题,因为它与创建不可变对象的方法有根本的不同。

在创建内置不可变类型的子类时,我们必须在创建时通过覆盖__new__()来调整对象,而不是覆盖__init__()。以下是一个示例类定义,向我们展示了扩展float的正确方法:

class Float_Units( float ):
    def __new__( cls, value, unit ):
       obj= super().__new__( cls, value )
       obj.unit= unit
       return obj

在前面的代码中,我们在创建对象时设置了一个属性的值。

以下代码片段为我们提供了一个带有附加单位信息的浮点值:

>>> speed= Float_Units( 6.5, "knots" )
>>> speed
6.5
>>> speed * 10
65.0
>>> speed.unit
'knots'

请注意,诸如speed * 10这样的表达式不会创建一个Float_Units对象。这个类定义继承了float的所有操作符特殊方法;float算术特殊方法都会创建float对象。创建Float_Units对象是第七章创建数字的主题。

__new__()方法和元类

__new__()方法作为元类的一部分的另一个用例是控制类定义的构建方式。这与__new__()控制构建不可变对象的方式不同,前面已经展示过。

元类构建一个类。一旦类对象被构建,类对象就被用来构建实例。所有类定义的元类都是typetype()函数用于创建类对象。

此外,type()函数可以作为一个函数来显示对象的类。

以下是一个用type()直接构建一个新的、几乎无用的类的愚蠢示例:

Useless= type("Useless",(),{})

一旦我们创建了这个类,我们就可以创建这个Useless类的对象。但是,它们不会做太多事情,因为它们没有方法或属性。

我们可以使用这个新创建的Useless类来创建对象,尽管价值不大。以下是一个例子:

>>> Useless()
<__main__.Useless object at 0x101001910>
>>> u=_
>>> u.attr= 1    
>>> dir(u)
['__class__', '__delattr__', '__dict__', '__dir__', '__doc__', '__eq__', '__format__', '__ge__', '__getattribute__', '__gt__', '__hash__', '__init__', '__le__', '__lt__', '__module__', '__ne__', '__new__', '__reduce__', '__reduce_ex__', '__repr__', '__setattr__', '__sizeof__', '__str__', '__subclasshook__', '__weakref__', 'attr']

我们可以向这个类的对象添加属性。它确实可以作为一个对象工作。

这几乎等同于使用types.SimpleNamespace或者以下方式定义一个类:

class Useless:
    pass

这引出了一个重要的问题:为什么我们要首先搞乱类的定义方式呢?

答案是,一些类的默认特性并不完全适用于一些边缘情况。我们将讨论四种情况,我们可能想要引入一个元类:

  • 我们可以使用元类来保留关于类的源文本的一些信息。由内置的type构建的类使用dict来存储各种方法和类级属性。由于dict是无序的,属性和方法没有特定的顺序。它们极不可能按照源代码中最初的顺序出现。我们将在第一个示例中展示这一点。

  • 元类用于创建抽象基类ABC),我们将在第四章到第七章中介绍。ABC 依赖于元类__new__()方法来确认具体子类是否完整。我们将在第四章中介绍这一点,一致设计的 ABC

  • 元类可以用来简化对象序列化的一些方面。我们将在第九章中介绍这一点,序列化和保存-JSON、YAML、Pickle、CSV 和 XML

  • 作为最后一个相当简单的例子,我们将看一个类内的自引用。我们将设计引用master类的类。这不是一个超类-子类关系。这是一组同级子类,但与同级群体中的一个类有关联,作为主类。为了与同级保持一致,主类需要引用自身,这是不可能的,没有元类。这将是我们的第二个例子。

元类示例 1-有序属性

这是《Python 语言参考》第 3.3.3 节“自定义类创建”的典型示例。这个元类将记录属性和方法函数定义的顺序。

这个配方有以下三个部分:

  1. 创建一个元类。该元类的__prepare__()__new__()函数将改变目标类的构建方式,用OrderedDict类替换了普通的dict类。

  2. 创建一个基于元类的抽象超类。这个抽象类简化了其他类的继承。

  3. 创建从抽象超类派生的子类,这些子类受益于元类。

以下是将保留属性创建顺序的示例元类:

import collections
class Ordered_Attributes(type):
    @classmethod
    def __prepare__(metacls, name, bases, **kwds):
        return collections.OrderedDict()
    def __new__(cls, name, bases, namespace, **kwds):
        result = super().__new__(cls, name, bases, namespace)
        result._order = tuple(n for n in namespace if not n.startswith('__'))
        return result

这个类扩展了内置的默认元类type,使用了__prepare__()__new__()的新版本。

__prepare__()方法在创建类之前执行;它的工作是创建初始的命名空间对象,其中将添加定义。这个方法可以在处理类体之前的任何其他准备工作上工作。

__new__()静态方法在类体元素被添加到命名空间后执行。它接收类对象、类名、超类元组和完全构建的命名空间映射对象。这个例子很典型:它将__new__()的真正工作委托给了超类;元类的超类是内置的type;我们使用type.__new__()来创建可以调整的默认类对象。

在这个示例中,__new__()方法向类定义中添加了一个名为_order的属性,显示了属性的原始顺序。

我们可以在定义新的抽象超类时使用这个元类,而不是type

class Order_Preserved( metaclass=Ordered_Attributes ):
    pass

然后我们可以将这个新的抽象类作为我们定义的任何新类的超类,如下所示:

class Something( Order_Preserved ):
    this= 'text'
    def z( self ):
        return False
    b= 'order is preserved'
    a= 'more text'

当我们查看Something类时,我们会看到以下代码片段:

>>> Something._order
>>> ('this', 'z', 'b', 'a')

我们可以考虑利用这些信息来正确序列化对象或提供与原始源定义相关的调试信息。

元类示例 2-自引用

我们将看一个涉及单位转换的例子。例如,长度单位包括米、厘米、英寸、英尺和许多其他单位。管理单位转换可能是具有挑战性的。表面上,我们需要一个包含所有各种单位之间所有可能转换因子的矩阵。英尺到米,英尺到英寸,英尺到码,米到英寸,米到码,等等-每一种组合。

实际上,如果我们定义一个长度的标准单位,我们可以做得更好。我们可以将任何单位转换为标准单位,将标准单位转换为任何其他单位。通过这样做,我们可以轻松地执行任何可能的转换作为一个两步操作,消除了所有可能转换的复杂矩阵:英尺到标准单位,英寸到标准单位,码到标准单位,米到标准单位。

在下面的例子中,我们不会以任何方式对floatnumbers.Number进行子类化。我们不会将单位绑定到值,而是允许每个值保持一个简单的数字。这是享元设计模式的一个例子。这个类不定义包含相关值的对象。对象只包含转换因子。

另一种方法(将单位绑定到值)会导致相当复杂的维度分析。虽然有趣,但相当复杂。

我们将定义两个类:UnitStandard_Unit。我们可以很容易确保每个Unit类都有一个指向其适当Standard_Unit的引用。我们如何确保每个Standard_Unit类都有一个指向自身的引用?在类定义内部进行自引用是不可能的,因为类还没有被定义。

以下是我们的Unit类定义:

class Unit:
    """Full name for the unit."""
    factor= 1.0
    standard= None # Reference to the appropriate StandardUnit
    name= "" # Abbreviation of the unit's name.
    @classmethod
    def value( class_, value ):
        if value is None: return None
        return value/class_.factor
    @classmethod
    def convert( class_, value ):
        if value is None: return None
        return value*class_.factor

我们的意图是Unit.value()将把给定单位的值转换为标准单位。Unit.convert()方法将把标准值转换为给定单位。

这使我们能够使用单位,如下面的代码片段所示:

>>> m_f= FOOT.value(4)
>>> METER.convert(m_f)
1.2191999999999998

创建的值是内置的float值。对于温度,需要重写value()convert()方法,因为简单的乘法不起作用。

对于Standard_Unit,我们想做如下的事情:

class INCH:
    standard= INCH

然而,这样做是行不通的。INCHINCH的主体内部还没有被定义。在定义之后类才存在。

作为备选方案,我们可以这样做:

class INCH:
    pass
INCH.standard= INCH

然而,这相当丑陋。

我们可以定义一个装饰器如下:

@standard
class INCH:
    pass

这个装饰器函数可以调整类定义以添加一个属性。我们将在第八章装饰器和混入-横切面中回到这个问题。

相反,我们将定义一个元类,可以将循环引用插入类定义,如下所示:

class UnitMeta(type):
    def __new__(cls, name, bases, dict):
        new_class= super().__new__(cls, name, bases, dict)
        new_class.standard = new_class
        return new_class

这迫使类变量标准进入类定义。

对于大多数单位,SomeUnit.standard引用TheStandardUnit类。与此同时,我们还将有TheStandardUnit.standard引用TheStandardUnit类。UnitStandard_Unit子类之间的这种一致结构可以帮助编写文档并自动化单位转换。

以下是Standard_Unit类:

class Standard_Unit( Unit, metaclass=UnitMeta ):
    pass

Unit继承的单位转换因子是 1.0,因此这个类对提供的值没有任何作用。它包括特殊的元类定义,以便它将有一个自引用,澄清这个类是这个特定测量维度的标准。

作为一种优化,我们可以重写value()convert()方法以避免乘法和除法。

以下是一些单位的样本类定义:

class INCH( Standard_Unit ):
    """Inches"""
    name= "in"

class FOOT( Unit ):
    """Feet"""
    name= "ft"
    standard= INCH
    factor= 1/12

class CENTIMETER( Unit ):
    """Centimeters"""
    name= "cm"
    standard= INCH
    factor= 2.54

class METER( Unit ):
    """Meters"""
    name= "m"
    standard= INCH
    factor= .0254

我们将INCH定义为标准单位。其他单位的定义将转换为英寸和从英寸转换。

我们为每个单位提供了一些文档:在文档字符串中是全名,在name属性中是简称。转换因子是通过从Unit继承的convert()value()函数自动应用的。

这些定义允许我们的应用程序进行以下类型的编程:

>>> x_std= INCH.value( 159.625 )
>>> FOOT.convert( x_std )
13.302083333333332
>>> METER.convert( x_std )
4.054475
>>> METER.factor
0.0254

我们可以从给定的英寸值中设置特定的测量,并以任何其他兼容的单位报告该值。

元类的作用是允许我们从单位定义类中进行这样的查询:

>>> INCH.standard.__name__
'INCH'
>>> FOOT.standard.__name__
'INCH'

这些引用可以让我们跟踪给定维度的所有各种单位。

总结

我们已经看过一些基本特殊方法,这些是我们设计的任何类的基本特性。这些方法已经是每个类的一部分,但我们从对象继承的默认值可能不符合我们的处理要求。

我们几乎总是需要覆盖__repr__()__str__()__format__()。这些方法的默认实现并不是很有帮助。

我们很少需要覆盖__bool__(),除非我们正在编写自己的集合。这是第六章的主题,创建容器和集合

我们经常需要覆盖比较和__hash__()方法。这些定义适用于简单的不可变对象,但对于可变对象则完全不合适。我们可能不需要编写所有的比较运算符;我们将在第八章中看到@functools.total_ordering装饰器,装饰器和混合 - 横切方面

另外两个基本特殊方法名称__new__()__del__()是用于更专门的目的。使用__new__()来扩展不可变类是这种方法函数的最常见用例。

这些基本特殊方法,以及__init__(),几乎会出现在我们编写的每个类定义中。其余的特殊方法是为更专门的目的而设计的;它们分为六个离散的类别:

  • 属性访问:这些特殊方法实现了我们在表达式中看到的object.attribute,在赋值的左侧看到的object.attribute,以及在del语句中看到的object.attribute

  • 可调用:一个特殊方法实现了我们所看到的作为应用于参数的函数,就像内置的len()函数一样。

  • 集合:这些特殊方法实现了集合的许多特性。这涉及诸如sequence[index]mapping[key]set | set等内容。

  • 数字:这些特殊方法提供了算术运算符和比较运算符。我们可以使用这些方法来扩展 Python 处理的数字域。

  • 上下文:有两个特殊方法,我们将使用它们来实现一个与with语句一起工作的上下文管理器。

  • 迭代器:有一些特殊的方法来定义迭代器。这并非必要,因为生成器函数如此优雅地处理了这个特性。然而,我们将看看如何设计我们自己的迭代器。

在下一章中,我们将讨论属性、属性和描述符。

第三章:属性访问、属性和描述符

对象是一组特性,包括方法和属性。object类的默认行为涉及设置、获取和删除命名属性。我们经常需要修改这种行为,以改变对象中可用的属性。

本章将重点介绍属性访问的以下五个层次:

  • 我们将研究内置属性处理,这是最简单但最不复杂的选项。

  • 我们将回顾@property装饰器。属性扩展了属性的概念,包括在方法函数中定义的处理。

  • 我们将研究如何利用控制属性访问的低级特殊方法:__getattr__()__setattr__()__delattr__()。这些特殊方法允许我们构建更复杂的属性处理。

  • 我们还将看一下__getattribute__()方法,它可以更精细地控制属性。这可以让我们编写非常不寻常的属性处理。

  • 最后,我们将看一下描述符。这些用于访问属性,但它们涉及更复杂的设计决策。描述符在 Python 底层被大量使用,用于实现属性、静态方法和类方法。

在本章中,我们将详细了解默认处理的工作原理。我们需要决定何时以及在何处覆盖默认行为。在某些情况下,我们希望我们的属性不仅仅是实例变量。在其他情况下,我们可能希望阻止添加属性。我们可能有更复杂的行为属性。

此外,当我们探索描述符时,我们将更深入地了解 Python 内部的工作原理。我们通常不需要显式使用描述符。但我们经常隐式使用它们,因为它们是实现许多 Python 特性的机制。

基本属性处理

默认情况下,我们创建的任何类都将允许以下四种属性行为:

  • 通过设置其值来创建一个新属性

  • 设置现有属性的值

  • 获取属性的值

  • 删除属性

我们可以使用以下简单的代码进行实验。我们可以创建一个简单的通用类和该类的一个对象:

>>> class Generic:
...     pass
...     
>>> g= Generic()

上述代码允许我们创建、获取、设置和删除属性。我们可以轻松地创建和获取属性。以下是一些示例:

>>> g.attribute= "value"
>>> g.attribute
'value'
>>> g.unset
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AttributeError: 'Generic' object has no attribute 'unset'
>>> del g.attribute
>>> g.attribute
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AttributeError: 'Generic' object has no attribute 'attribute'

我们可以添加、更改和删除属性。如果尝试获取未设置的属性或删除尚不存在的属性,将会引发异常。

这样做的一个稍微更好的方法是使用types.SimpleNamespace类的一个实例。功能集是相同的,但我们不需要创建额外的类定义。我们可以创建SimpleNamespace类的对象,如下所示:

>>> import types
>>> n = types.SimpleNamespace()

在下面的代码中,我们可以看到相同的用例适用于SimpleNamespace类:

>>> n.attribute= "value"
>>> n.attribute
'value'
>>> del n.attribute
>>> n.attribute
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AttributeError: 'namespace' object has no attribute 'attribute'

我们可以为此对象创建属性。任何尝试使用未定义的属性都会引发异常。SimpleNamespace类的行为与我们创建对象类的实例时所看到的不同。对象类的简单实例不允许创建新属性;它缺少 Python 存储属性和值的内部__dict__结构。

属性和__init__()方法

大多数情况下,我们使用类的__init__()方法创建一组初始属性。理想情况下,我们在__init__()中为所有属性提供默认值。

__init__()方法中需要提供所有属性。因此,属性的存在或不存在可以作为对象状态的一部分。

可选属性推动了类定义的边界。一个类拥有一组明确定义的属性是非常合理的。通过创建子类或超类,可以更清晰地添加(或删除)属性。

因此,可选属性意味着一种非正式的子类关系。因此,当我们使用可选属性时,我们会遇到相当差的多态性。

考虑一个只允许单次分牌的二十一点游戏。如果一手被分牌,就不能再次分牌。我们可以用几种方式来建模:

  • 我们可以从Hand.split()方法创建一个SplitHand的子类。我们不会详细展示这一点。

  • 我们可以在名为Hand的对象上创建一个状态属性,该对象可以从Hand.split()方法创建。理想情况下,这是一个布尔值,但我们也可以将其实现为可选属性。

以下是Hand.split()的一个版本,它可以通过可选属性检测可分割与不可分割的手:

def  split( self, deck ):
    assert self.cards[0].rank == self.cards[1].rank
    try:
        self.split_count
        raise CannotResplit
    except AttributeError:
        h0 = Hand( self.dealer_card, self.cards[0], deck.pop() )
        h1 = Hand( self.dealer_card, self.cards[1], deck.pop() )
        h0.split_count= h1.split_count= 1
        return h0, h1

实际上,split()方法测试是否存在split_count属性。如果存在这个属性,则这是一个分牌手,该方法会引发异常。如果split_count属性不存在,则这是一个初始发牌,允许分牌。

可选属性的优势在于使__init__()方法相对不那么混乱,没有状态标志。它的缺点是模糊了对象状态的某些方面。使用try:块来确定对象状态可能会非常令人困惑,应该避免。

创建属性

属性是一个方法函数,从语法上看,它是一个简单的属性。我们可以像获取、设置和删除属性值一样获取、设置和删除属性值。这里有一个重要的区别。属性实际上是一个方法函数,可以处理引用另一个对象,而不仅仅是保留引用。

除了复杂程度之外,属性和属性之间的另一个区别是,我们不能轻松地将新属性附加到现有对象;但是,我们可以轻松地向对象添加属性,默认情况下。在这一点上,属性与简单属性并不相同。

创建属性有两种方法。我们可以使用@property装饰器,也可以使用property()函数。区别纯粹是语法上的。我们将专注于装饰器。

我们将看看两种属性的基本设计模式:

  • 急切计算:在这种设计模式中,当我们通过属性设置值时,其他属性也会被计算

  • 懒惰计算:在这种设计模式中,计算被推迟到通过属性请求时

为了比较前面两种属性的方法,我们将Hand对象的一些常见特性拆分为一个抽象的超类,如下所示:

class Hand:
    def __str__( self ):
        return ", ".join( map(str, self.card) )
    def __repr__( self ):
        return "{__class__.__name__}({dealer_card!r}, {_cards_str})".format(
        __class__=self.__class__,
        _cards_str=", ".join( map(repr, self.card) ),
        **self.__dict__ )

在前面的代码中,我们只定义了一些字符串表示方法,没有别的。

以下是Hand的一个子类,其中total是一个懒惰计算的属性,只有在需要时才计算:

class Hand_Lazy(Hand):
    def __init__( self, dealer_card, *cards ):
        self.dealer_card= dealer_card
        self._cards= list(cards)
    @property
    def total( self ):
        delta_soft = max(c.soft-c.hard for c in self._cards)
        hard_total = sum(c.hard for c in self._cards)
        if hard_total+delta_soft <= 21: return hard_total+delta_soft
        return hard_total
    @property
    def card( self ):
        return self._cards
    @card.setter
    def card( self, aCard ):
        self._cards.append( aCard )
    @card.deleter
    def card( self ):
        self._cards.pop(-1)

Hand_Lazy类使用Cards对象的列表初始化一个Hand对象。total属性是一个方法,只有在请求时才计算总数。此外,我们定义了一些其他属性来更新手中的卡片集合。card属性可以获取、设置或删除手中的卡片。我们将在设置器和删除器属性部分查看这些属性。

我们可以创建一个Hand对象,total看起来就像一个简单的属性:

>>> d= Deck()
>>> h= Hand_Lazy( d.pop(), d.pop(), d.pop() )
>>> h.total
19
>>> h.card= d.pop()
>>> h.total
29

总数是通过每次请求总数时重新扫描手中的卡片来进行懒惰计算的。这可能是一个昂贵的开销。

急切计算的属性

以下是Hand的一个子类,其中total是一个简单属性,每添加一张卡片都会急切计算:

class Hand_Eager(Hand):
    def __init__( self, dealer_card, *cards ):
        self.dealer_card= dealer_card
        self.total= 0
        self._delta_soft= 0
        self._hard_total= 0
        self._cards= list()
        for c in cards:
            self.card = c
    @property
    def card( self ):
        return self._cards
    @card.setter
    def card( self, aCard ):
        self._cards.append(aCard)
        self._delta_soft = max(aCard.soft-aCard.hard, self._delta_soft)
        self._hard_total += aCard.hard
        self._set_total()
    @card.deleter
    def card( self ):
        removed= self._cards.pop(-1)
        self._hard_total -= removed.hard
        # Issue: was this the only ace?
        self._delta_soft = max( c.soft-c.hard for c in self._cards )
        self._set_total()
    def _set_total( self ):
        if self._hard_total+self._delta_soft <= 21:
            self.total= self._hard_total+self._delta_soft
        else:
            self.total= self._hard_total

在这种情况下,每次添加一张卡片时,total属性都会更新。

另一个card属性——删除器——在删除卡片时急切地更新total属性。我们将在下一节详细介绍删除器。

客户端在这两个子类(Hand_Lazy()Hand_Eager())中看到相同的语法:

d= Deck()
h1= Hand_Lazy( d.pop(), d.pop(), d.pop() )
print( h1.total )
h2= Hand_Eager( d.pop(), d.pop(), d.pop() )
print( h2.total )

在这两种情况下,客户端软件只是使用total属性。

使用属性的优势在于,当实现更改时,语法不必更改。我们也可以对 getter/setter 方法函数提出类似的要求。但是,getter/setter 方法函数涉及的额外语法并不是很有帮助,也不具有信息性。以下是两个示例,一个使用了 setter 方法,另一个使用了赋值运算符:

obj.set_something(value)
obj.something = value

赋值运算符(=)的存在使意图非常明显。许多程序员发现查找赋值语句比查找 setter 方法函数更清晰。

设置器和删除器属性

在前面的示例中,我们定义了card属性,将额外的卡片分发到Hand类的对象中。

由于 setter(和 deleter)属性是从 getter 属性创建的,我们必须始终首先使用以下代码定义 getter 属性:

    @property
    def card( self ):
        return self._cards
    @card.setter
    def card( self, aCard ):
        self._cards.append( aCard )
    @card.deleter
    def card( self ):
        self._cards.pop(-1)

这使我们能够通过以下简单的语句将一张卡添加到手中:

h.card= d.pop()

前面的赋值语句有一个缺点,因为它看起来像是用一张卡替换了所有的卡。另一方面,它也有一个优点,因为它使用简单的赋值来更新可变对象的状态。我们可以使用__iadd__()特殊方法来更清晰地做到这一点。但是,我们将等到第七章创建数字,来介绍其他特殊方法。

对于我们目前的示例,没有强制使用 deleter 属性的理由。即使没有强制使用的理由,deleter 仍然有一些用途。然而,我们可以利用它来移除最后一张发出的卡片。这可以作为分割手牌过程的一部分使用。

我们将考虑一个类似以下代码的split()版本:

    def split( self, deck ):
        """Updates this hand and also returns the new hand."""
        assert self._cards[0].rank == self._cards[1].rank
        c1= self._cards[-1]
        del self.card
        self.card= deck.pop()
        h_new= self.__class__( self.dealer_card, c1, deck.pop() )
        return h_new

前面的方法更新了给定的手牌并返回一个新的手牌。以下是手牌分割的一个示例:

>>> d= Deck()
>>> c= d.pop()
>>> h= Hand_Lazy( d.pop(), c, c ) # Force splittable hand
>>> h2= h.split(d)
>>> print(h)
2♠, 10♠
>>> print(h2)
2♠, A♠

一旦我们有了两张卡,我们就可以使用split()来产生第二手牌。一张卡从初始手牌中被移除。

这个split()版本肯定可行。然而,似乎更好的是让split()方法返回两个全新的Hand对象。这样,旧的、预分割的Hand实例可以用作收集统计信息的备忘录。

使用特殊方法进行属性访问

我们将研究三种用于属性访问的经典特殊方法:__getattr__()__setattr__()__delattr__()。此外,我们将使用__dir__()方法来显示属性名称。我们将在下一节中讨论__getattribute__()

第一节中显示的默认行为如下:

  • __setattr__()方法将创建并设置属性。

  • __getattr__()方法将执行两件事。首先,如果属性已经有一个值,__getattr__()就不会被使用;属性值会直接返回。其次,如果属性没有值,那么__getattr__()就有机会返回一个有意义的值。如果没有属性,它必须引发AttributeError异常。

  • __delattr__()方法删除一个属性。

  • __dir__()方法返回属性名称的列表。

__getattr__()方法函数只是更大过程中的一步;只有在属性否则未知时才会使用它。如果属性是已知属性,则不会使用此方法。__setattr__()__delattr__()方法没有内置处理。这些方法不与其他处理交互。

我们有许多设计选择来控制属性访问。这些选择遵循我们的三个基本设计选择:扩展、包装或发明。设计选择如下:

  • 我们可以通过覆盖__setattr__()__delattr__()来扩展一个类,使其几乎不可变。我们还可以用__slots__替换内部的__dict__

  • 我们可以包装一个类,并将属性访问委托给被包装的对象(或对象的组合)。这可能涉及覆盖这三种方法。

  • 我们可以在类中实现类似属性的行为。使用这些方法,我们可以确保所有属性处理都是集中的。

  • 我们可以创建延迟属性,直到需要它们时才计算值。我们可能有一个属性,直到从文件、数据库或网络中读取时才有值。这是__getattr__()的常见用法。

  • 我们可以有急切属性,其中设置一个属性会自动创建其他属性的值。这是通过对__setattr__()进行覆盖来实现的。

我们不会考虑所有这些替代方案。相反,我们将专注于两种最常用的技术:扩展和包装。我们将创建不可变对象,并研究急切计算属性值的其他方法。

使用 slots 创建不可变对象

如果我们无法设置属性或创建新属性,那么对象就是不可变的。以下是我们希望在交互式 Python 中看到的:

>>> c= card21(1,'♠')
>>> c.rank= 12
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<stdin>", line 30, in __setattr__
TypeError: Cannot set rank
>>> c.hack= 13
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<stdin>", line 31, in __setattr__
AttributeError: 'Ace21Card' has no attribute 'hack'

上面的代码表明,我们不允许更改此对象的属性或添加属性。

我们需要对类定义进行两处更改才能实现这一点。我们将省略大部分类,只关注使对象不可变的三个特性,如下所示:

class BlackJackCard:
    """Abstract Superclass"""
    __slots__ = ( 'rank', 'suit', 'hard', 'soft' )
    def __init__( self, rank, suit, hard, soft ):
        super().__setattr__( 'rank', rank )
        super().__setattr__( 'suit', suit )
        super().__setattr__( 'hard', hard )
        super().__setattr__( 'soft', soft )
    def __str__( self ):
        return "{0.rank}{0.suit}".format( self )
    def __setattr__( self, name, value ):
        raise AttributeError( "'{__class__.__name__}' has no attribute '{name}'".format( __class__= self.__class__, name= name ) )

我们做了三个重大改变:

  • 我们将__slots__设置为只允许属性的名称。这将关闭对象的内部__dict__功能,并限制我们只能使用属性,没有更多。

  • 我们定义了__setattr__()来引发异常,而不是做任何有用的事情。

  • 我们定义了__init__()来使用超类版本的__setattr__(),以便在这个类中没有有效的__setattr__()方法的情况下正确设置值。

经过一些小心翼翼的处理,我们可以绕过不可变特性。

object.__setattr__(c, 'bad', 5)

这就带来了一个问题。“我们如何防止一个‘邪恶’的程序员绕过不可变特性?”这个问题很愚蠢。我们无法阻止邪恶的程序员。另一个同样愚蠢的问题是,“为什么一些邪恶的程序员要写那么多代码来规避不可变性?”我们无法阻止邪恶的程序员做坏事。

如果这个虚构的程序员不喜欢类中的不可变性,他们可以修改类的定义,以删除对__setattr__()的重新定义。这样一个不可变对象的目的是保证__hash__()返回一个一致的值,而不是阻止人们编写糟糕的代码。

提示

不要滥用 slots

__slots__特性主要是为了通过限制属性的数量来节省内存。

将不可变对象创建为 tuple 子类

我们还可以通过将我们的Card属性设置为tuple的子类,并覆盖__getattr__()来创建一个不可变对象。在这种情况下,我们将__getattr__(name)请求转换为self[index]请求。正如我们将在第六章中看到的那样,创建容器和集合self[index]是由__getitem__(index)实现的。

以下是对内置的tuple类的一个小扩展:

class BlackJackCard2( tuple ):
    def __new__( cls, rank, suit, hard, soft ):
        return super().__new__( cls, (rank, suit, hard, soft) )
    def __getattr__( self, name ):
        return self[{'rank':0, 'suit':1, 'hard':2 , 'soft':3}[name]]
    def __setattr__( self, name, value ):
        raise AttributeError

在这个例子中,我们只是引发了一个简单的AttributeError异常,而没有提供详细的错误消息。

当我们使用上面的代码时,我们会看到以下类型的交互:

>>> d = BlackJackCard2( 'A', '♠', 1, 11 )
>>> d.rank
'A'
>>> d.suit
'♠'
>>> d.bad= 2
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<stdin>", line 7, in __setattr__AttributeError

我们无法轻易更改卡的值。但是,我们仍然可以调整d.__dict__来引入额外的属性。

注意

这真的有必要吗?

也许,这样做太麻烦了,只是为了确保对象不会被意外滥用。实际上,我们更感兴趣的是异常和回溯提供的诊断信息,而不是一个超级安全的不可变类。

急切计算的属性

我们可以定义一个对象,在设置值后尽快计算属性。这个对象通过一次计算来优化访问,并将结果留下来供多次使用。

我们可以定义一些属性设置器来实现这一点。然而,对于复杂的计算,每个属性设置器都会计算多个属性,这可能会变得很啰嗦。

我们可以集中处理属性。在下面的例子中,我们将使用一些调整来扩展 Python 内部的dict类型。扩展dict的优势在于它与字符串的format()方法很好地配合。此外,我们不必担心设置额外的属性值,否则这些值会被忽略。

我们希望看到类似下面的代码:

>>> RateTimeDistance( rate=5.2, time=9.5 )
{'distance': 49.4, 'time': 9.5, 'rate': 5.2}
>>> RateTimeDistance( distance=48.5, rate=6.1 )
{'distance': 48.5, 'time': 7.950819672131148, 'rate': 6.1}

我们可以设置这个RateTimeDistance对象中的值。只要有足够的数据,就会计算额外的属性。我们可以像之前展示的那样一次性完成,也可以像下面的代码一样分阶段完成:

>>> rtd= RateTimeDistance()
>>> rtd.time= 9.5
>>> rtd   
{'time': 9.5}
>>> rtd.rate= 6.24
>>> rtd
{'distance': 59.28, 'time': 9.5, 'rate': 6.24}

以下是内置 dict 的扩展。我们扩展了 dict 实现的基本映射,以计算缺少的属性:

class RateTimeDistance( dict ):
    def __init__( self, *args, **kw ):
        super().__init__( *args, **kw )
        self._solve()
    def __getattr__( self, name ):
        return self.get(name,None)
    def __setattr__( self, name, value ):
        self[name]= value
        self._solve()
    def __dir__( self ):
        return list(self.keys())
    def _solve(self):
        if self.rate is not None and self.time is not None:
            self['distance'] = self.rate*self.time
        elif self.rate is not None and self.distance is not None:
            self['time'] = self.distance / self.rate
        elif self.time is not None and self.distance is not None:
            self['rate'] = self.distance / self.time

dict 类型使用 __init__() 来填充内部字典,然后尝试解决是否有足够的数据。它使用 __setattr__() 来向字典添加新项目。每次设置一个值时,它也会尝试解决方程。

__getattr__() 中,我们使用 None 来表示方程中的缺失值。这允许我们将属性设置为 None 来表示它是一个缺失值,并且这将迫使解决方案寻找这个值。例如,我们可能基于用户输入或网络请求进行这样的操作,其中所有参数都被赋予一个值,但一个变量被设置为 None

我们可以像下面这样使用它:

>>> rtd= RateTimeDistance( rate=6.3, time=8.25, distance=None )
>>> print( "Rate={rate}, Time={time}, Distance={distance}".format( **rtd ) )
Rate=6.3, Time=8.25, Distance=51.975

注意

请注意,我们不能轻松地在这个类定义内设置属性值。

让我们考虑以下代码行:

self.distance = self.rate*self.time

如果我们编写上述代码片段,__setattr__()_solve() 之间将会产生无限递归。在示例中,当我们使用 self['distance'] 时,我们避免了对 __setattr__() 的递归调用。

还要注意的是,一旦所有三个值都被设置,这个对象就不能轻松地提供新的解决方案了。

我们不能简单地设置 rate 的新值并计算 time 的新值,同时保持 distance 不变。为了调整这个模型,我们需要清除一个变量并为另一个变量设置一个新值:

>>> rtd.time= None
>>> rtd.rate= 6.1
>>> print( "Rate={rate}, Time={time}, Distance={distance}".format( **rtd ) )
Rate=6.1, Time=8.25, Distance=50.324999999999996

在这里,我们清除了 time 并改变了 rate 以使用已建立的 distance 值来获得 time 的新解决方案。

我们可以设计一个跟踪变量设置顺序的模型;这个模型可以避免我们在重新计算相关结果之前清除一个变量然后设置另一个变量。

getattribute() 方法

更低级的属性处理是 __getattribute__() 方法。默认实现尝试在内部 __dict__(或 __slots__)中查找值作为现有属性。如果找不到属性,则调用 __getattr__() 作为后备。如果找到的值是一个描述符(见下面的 创建描述符 部分),那么它会处理描述符。否则,值将被简单地返回。

通过覆盖这个方法,我们可以执行以下任何一种任务:

  • 我们可以有效地阻止对属性的访问。通过引发异常而不是返回一个值,这种方法可以使属性比仅仅使用前导下划线 (_) 标记一个名称作为实现私有更加保密。

  • 我们可以像 __getattr__() 一样发明新的属性。然而,在这种情况下,我们可以绕过默认查找由默认版本的 __getattribute__() 完成的查找。

  • 我们可以使属性执行独特和不同的任务。这可能会使程序非常难以理解或维护。这也可能是一个可怕的主意。

  • 我们可以改变描述符的行为方式。虽然在技术上是可能的,但改变描述符的行为听起来像是一个可怕的主意。

当我们实现 __getattribute__() 方法时,重要的是要注意方法体中不能有任何内部属性访问。如果我们尝试获取 self.name 的值,将导致无限递归。

注意

__getattribute__() 方法不能简单地给出任何 self.name 属性访问;这将导致无限递归。

为了在 __getattribute__() 方法内获取属性值,我们必须明确地引用在 object 中定义的基本方法,如下所示:

object.__getattribute__(self, name)

例如,我们可以修改我们的不可变类,使用 __getattribute__() 并阻止对内部 __dict__ 属性的访问。以下是一个隐藏所有以下划线字符 (_) 开头的名称的类:

class BlackJackCard3:
    """Abstract Superclass"""
    def __init__( self, rank, suit, hard, soft ):
        super().__setattr__( 'rank', rank )
        super().__setattr__( 'suit', suit )
        super().__setattr__( 'hard', hard )
        super().__setattr__( 'soft', soft )
    def __setattr__( self, name, value ):
        if name in self.__dict__:
            raise AttributeError( "Cannot set {name}".format(name=name) )
        raise AttributeError( "'{__class__.__name__}' has no attribute '{name}'".format( __class__= self.__class__, name= name ) )
    def __getattribute__( self, name ):
        if name.startswith('_'): raise AttributeError
        return object.__getattribute__( self, name )

我们已经重写了__getattribute__(),以便在私有名称和 Python 的内部名称上引发属性错误。这比之前的例子有微小的优势:我们根本不允许对对象进行调整。我们将看到与这个类的实例交互的一个例子。

以下是这个类的对象被改变的一个例子:

>>> c = BlackJackCard3( 'A', '♠', 1, 11 )
>>> c.rank= 12
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<stdin>", line 9, in __setattr__
  File "<stdin>", line 13, in __getattribute__
AttributeError
>>> c.__dict__['rank']= 12
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<stdin>", line 13, in __getattribute__
AttributeError

作为一般建议,擅自修改__getattribute__()通常不是一个好主意。默认方法非常复杂,几乎我们需要的一切都可以作为属性或者作为对__getattr__()的更改而获得。

创建描述符

描述符是一个调解属性访问的类。描述符类可以用来获取、设置或删除属性值。描述符对象是在类定义时内部构建的。

描述符设计模式有两个部分:一个所有者类属性描述符本身。所有者类使用一个或多个描述符来定义其属性。描述符类定义了一些组合的获取、设置和删除方法。描述符类的实例将成为所有者类的属性。

属性是基于所有者类的方法函数。与属性不同,描述符是一个与拥有类不同的类的实例。因此,描述符通常是可重用的、通用的属性。拥有类可以有每个描述符类的多个实例,以管理具有类似行为的属性。

与其他属性不同,描述符是在类级别创建的。它们不是在__init__()初始化中创建的。虽然可以在初始化期间设置描述符的值,但通常描述符是作为类的一部分在任何方法函数之外构建的。

每个描述符对象在定义所有者类时将成为绑定到不同类级属性名称的描述符类的实例。

要被识别为描述符,一个类必须实现以下三种方法的任意组合。

  • Descriptor.__get__( self, instance, owner ) → object:在这个方法中,instance参数是被访问的对象的self变量。owner参数是拥有类的对象。如果在类上下文中调用这个描述符,instance参数将得到一个None值。这必须返回描述符的值。

  • Descriptor.__set__( self, instance, value ):在这个方法中,instance参数是被访问的对象的self变量。value参数是描述符需要设置的新值。

  • Descriptor.__delete__( self, instance ):在这个方法中,instance参数是被访问的对象的self变量。这个描述符的方法必须删除这个属性的值。

有时,描述符类还需要一个__init__()方法函数来初始化描述符的内部状态。

根据定义的方法,描述符有两种类型:

  • 非数据描述符:这种描述符定义了__set__()或者__delete__()或者两者。它不能定义__get__()。非数据描述符对象通常作为更大表达式的一部分使用。它可能是一个可调用对象,或者它可能有自己的属性或方法。不可变的非数据描述符必须实现__set__()但可以简单地引发AttributeError。这些描述符设计起来稍微简单一些,因为接口更加灵活。

  • 数据描述符:这种描述符至少定义了__get__()。通常,它同时定义了__get__()__set__()来创建一个可变对象。描述符不能定义这个对象的任何其他属性或方法,因为描述符基本上是不可见的。对于值为数据描述符的属性的引用将被委托给描述符的__get__()__set__()__delete__()方法。这些可能很难设计,所以我们将在第二个例子中看一下它们。

描述符有各种用途。在内部,Python 使用描述符有几个原因:

  • 在底层,类的方法被实现为描述符。这些是应用方法函数到对象和各种参数值的非数据描述符。

  • 通过创建命名属性的数据描述符来实现property()函数。

  • 类方法或静态方法被实现为描述符;这适用于类而不是类的实例。

当我们在第十一章中查看对象关系映射时,通过 SQLite 存储和检索对象,我们会看到许多 ORM 类定义大量使用描述符将 Python 类定义映射到 SQL 表和列。

当我们考虑描述符的目的时,我们还必须检查描述符处理的数据的三种常见用例:

  • 描述符对象具有或获取数据。在这种情况下,描述符对象的self变量是相关的,描述符是有状态的。对于数据描述符,__get__()方法返回这个内部数据。对于非数据描述符,描述符有其他方法或属性来访问这些数据。

  • 拥有者实例包含数据。在这种情况下,描述符对象必须使用instance参数来引用拥有对象中的值。对于数据描述符,__get__()方法从实例中获取数据。对于非数据描述符,描述符的其他方法访问实例数据。

  • 拥有者类包含相关数据。在这种情况下,描述符对象必须使用owner参数。当描述符实现适用于整个类的静态方法或类方法时,通常会使用这种方法。

我们将详细查看第一种情况。我们将查看如何使用__get__()__set__()方法创建数据描述符。我们还将查看如何创建一个没有__get__()方法的非数据描述符。

第二种情况(拥有实例中的数据)展示了@property装饰器的作用。描述符相对于常规属性可能具有的优势是,它将计算移到描述符类中,而不是拥有者类。这往往会导致类设计的碎片化,可能不是最佳的方法。如果计算确实非常复杂,那么策略模式可能更好。

第三种情况展示了@staticmethod@classmethod装饰器的实现。我们不需要重新发明这些轮子。

使用非数据描述符

我们经常有一些与少量紧密绑定的属性值的小对象。例如,我们将查看与度量单位绑定的数值。

以下是一个简单的没有__get__()方法的非数据描述符类:

class UnitValue_1:
    """Measure and Unit combined."""
    def __init__( self, unit ):
        self.value= None
        self.unit= unit
        self.default_format= "5.2f"
    def __set__( self, instance, value ):
        self.value= value
    def __str__( self ):
        return "{value:{spec}} {unit}".format( spec=self.default_format, **self.__dict__)
    def __format__( self, spec="5.2f" ):
        #print( "formatting", spec )
        if spec == "": spec= self.default_format
        return "{value:{spec}} {unit}".format( spec=spec, **self.__dict__)

这个类定义了一对简单的值,一个是可变的(值),另一个是有效不可变的(单位)。

当访问此描述符时,描述符对象本身可用,并且可以使用描述符的其他方法或属性。我们可以使用此描述符创建管理与物理单位相关的测量和其他数字的类。

以下是一个急切地进行速率-时间-距离计算的类:

class RTD_1:
    rate= UnitValue_1( "kt" )
    time= UnitValue_1( "hr" )
    distance= UnitValue_1( "nm" )
    def __init__( self, rate=None, time=None, distance=None ):
        if rate is None:
            self.time = time
            self.distance = distance
            self.rate = distance / time
        if time is None:
            self.rate = rate
            self.distance = distance
            self.time = distance / rate
        if distance is None:
            self.rate = rate
            self.time = time
            self.distance = rate * time
    def __str__( self ):
        return "rate: {0.rate} time: {0.time} distance: {0.distance}".format(self)

一旦对象被创建并且属性被加载,缺失的值就会被计算。一旦计算完成,就可以检查描述符以获取值或单位的名称。此外,描述符对str()和格式化请求有一个方便的响应。

以下是描述符和RTD_1类之间的交互:

>>> m1 = RTD_1( rate=5.8, distance=12 )
>>> str(m1)
'rate:  5.80 kt time:  2.07 hr distance: 12.00 nm'
>>> print( "Time:", m1.time.value, m1.time.unit )
Time: 2.0689655172413794 hr

我们使用ratedistance参数创建了RTD_1的一个实例。这些参数用于评估ratedistance描述符的__set__()方法。

当我们要求str(m1)时,这将评估RTD_1的整体__str__()方法,而RTD_1又使用了速率、时间和距离描述符的__format__()方法。这为我们提供了附加单位的数字。

我们还可以访问描述符的各个元素,因为非数据描述符没有__get__(),也不返回它们的内部值。

使用数据描述符

数据描述符的设计有些棘手,因为它的接口非常有限。它必须有一个__get__()方法,而且只能有__set__()__delete__()。这就是整个接口:从一个到三个这些方法,没有其他方法。引入额外的方法意味着 Python 将不会将该类识别为适当的数据描述符。

我们将设计一个过于简化的单位转换模式,使用描述符可以在它们的__get__()__set__()方法中执行适当的转换。

以下是一个单位描述符的超类,将执行到标准单位的转换:

class Unit:
    conversion= 1.0
    def __get__( self, instance, owner ):
        return instance.kph * self.conversion
    def __set__( self, instance, value ):
        instance.kph= value / self.conversion

这个类执行简单的乘法和除法,将标准单位转换为其他非标准单位,反之亦然。

有了这个超类,我们可以定义一些从标准单位的转换。在前面的情况下,标准单位是 KPH(每小时公里)。

以下是两个转换描述符:

class Knots( Unit ):
    conversion= 0.5399568
class MPH( Unit ):
    conversion= 0.62137119

继承的方法非常有用。唯一改变的是转换因子。这些类可以用于处理涉及单位转换的值。我们可以互换地使用 MPH 或节。以下是一个标准单位,每小时公里的单位描述符:

class KPH( Unit ):
    def __get__( self, instance, owner ):
        return instance._kph
    def __set__( self, instance, value ):
        instance._kph= value

这个类代表一个标准,所以它不做任何转换。它在实例中使用一个私有变量来保存以 KPH 为单位的速度的标准值。避免任何算术转换只是一种优化技术。避免引用公共属性之一是避免无限递归的关键。

以下是一个为给定测量提供多种转换的类:

class Measurement:
    kph= KPH()
    knots= Knots()
    mph= MPH()
    def __init__( self, kph=None, mph=None, knots=None ):
        if kph: self.kph= kph
        elif mph: self.mph= mph
        elif knots: self.knots= knots
        else:
            raise TypeError
    def __str__( self ):
        return "rate: {0.kph} kph = {0.mph} mph = {0.knots} knots".format(self)

每个类级属性都是不同单位的描述符。各种描述符的获取和设置方法将执行适当的转换。我们可以使用这个类来在各种单位之间转换速度。

以下是与Measurement类交互的一个示例:

>>> m2 = Measurement( knots=5.9 )
>>> str(m2)
'rate: 10.92680006993152 kph = 6.789598762345432 mph = 5.9 knots'
>>> m2.kph
10.92680006993152
>>> m2.mph
6.789598762345432

我们通过设置各种描述符创建了一个Measurement类的对象。在第一种情况下,我们设置了 knots 描述符。

当我们将值显示为一个大字符串时,每个描述符的__get__()方法都被使用了。这些方法从拥有对象中获取内部的kph属性值,应用转换因子,然后返回结果值。

kph属性也使用了一个描述符。这个描述符不执行任何转换;然而,它只是返回拥有对象中缓存的私有值。KPHKnots描述符要求拥有类实现一个kph属性。

摘要、设计考虑和权衡

在本章中,我们看了几种处理对象属性的方法。我们可以使用object类的内置功能来获取和设置属性值。我们可以定义属性来修改属性的行为。

如果我们想要更复杂的功能,我们可以调整__getattr__()__setattr__()__delattr__()__getattribute__()的基础特殊方法实现。这允许我们对属性行为进行非常精细的控制。当我们触及这些方法时,我们要小心,因为我们可能会对 Python 的行为进行根本性(和令人困惑的)更改。

在内部,Python 使用描述符来实现方法函数、静态方法函数和属性等功能。描述符的许多酷用例已经是语言的一流特性。

来自其他语言(特别是 Java 和 C++)的程序员通常有冲动尝试将所有属性设为私有,并编写大量的 getter 和 setter 函数。这种编码在静态编译类型定义的语言中是必要的。

在 Python 中,将所有属性视为公共属性要简单得多。这意味着以下内容:

  • 它们应该有很好的文档。

  • 它们应该正确地反映对象的状态;它们不应该是临时或瞬时值。

  • 在属性具有潜在混乱(或脆弱)值的罕见情况下,单个下划线字符(_)标记名称为“不是定义接口的一部分”。它实际上并不是私有的。

将私有属性视为一种麻烦是很重要的。封装并没有因为语言中缺乏复杂的隐私机制而被破坏;它是因为糟糕的设计而被破坏。

属性与属性之间的区别

在大多数情况下,可以在类外部设置属性而不会产生不良后果。我们的Hand类的示例就表明了这一点。对于类的许多版本,我们可以简单地追加到hand.cards,属性的延迟计算将完美地工作。

在属性的更改应导致其他属性的相关更改的情况下,需要更复杂的类设计:

  • 方法函数可能会澄清状态变化。当需要多个参数值时,这将是必要的。

  • 属性设置器可能比方法函数更清晰。当需要单个值时,这将是一个明智的选择。

  • 我们也可以使用就地操作符。我们将把这个推迟到第七章,“创建数字”。

没有严格的规定。在这种情况下,当我们需要设置单个参数值时,方法函数和属性之间的区别完全是 API 语法以及它如何传达意图的问题。

对于计算值,属性允许延迟计算,而属性需要急切计算。这归结为一个性能问题。延迟计算与急切计算的好处基于预期的用例。

使用描述符进行设计

许多描述符的示例已经是 Python 的一部分。我们不需要重新发明属性、类方法或静态方法。

创建新描述符的最具说服力的案例与 Python 和非 Python 之间的映射有关。例如,对象关系数据库映射需要非常小心,以确保 Python 类具有正确的属性顺序,以匹配 SQL 表和列。此外,当映射到 Python 之外的内容时,描述符类可以处理数据的编码和解码,或者从外部来源获取数据。

在构建 Web 服务客户端时,我们可能会考虑使用描述符来发出 Web 服务请求。__get__()方法,例如,可能会变成 HTTP GET 请求,__set__()方法可能会变成 HTTP PUT 请求。

在某些情况下,单个请求可能会填充多个描述符的数据。在这种情况下,__get__()方法会在发出 HTTP 请求之前检查实例缓存并返回该值。

许多数据描述符操作可以通过属性更简单地处理。这为我们提供了一个起点:首先编写属性。如果属性处理变得过于繁琐或复杂,那么我们可以切换到描述符来重构类。

展望未来

在下一章中,我们将仔细研究我们将在第 5、6 和 7 章中利用的ABC(抽象基类)。这些 ABC 将帮助我们定义与现有 Python 功能良好集成的类。它们还将允许我们创建强制一致设计和扩展的类层次结构。

第四章:一致设计的 ABC

Python 标准库为多个容器特性提供了抽象基类。它为内置的容器类(如listmapset)提供了一致的框架。

此外,该库还为数字提供了抽象基类。我们可以使用这些类来扩展 Python 中可用的数字类套件。

我们将总体上看一下collections.abc模块中的抽象基类。从那里,我们可以专注于一些用例,这些用例将成为未来章节中详细检查的主题。

我们有三种设计策略:包装、扩展和发明。我们将总体上看一下我们可能想要包装或扩展的各种容器和集合背后的概念。同样,我们将研究我们可能想要实现的数字背后的概念。

我们的目标是确保我们的应用程序类与现有的 Python 特性无缝集成。例如,如果我们创建一个集合,那么通过实现__iter__()来创建一个迭代器是合适的。实现__iter__()的集合将与for语句无缝协作。

抽象基类

抽象基类ABC)的核心定义在一个名为abc的模块中。这包含了创建抽象的所需装饰器和元类。其他类依赖于这些定义。

在 Python 3.2 中,集合的抽象基类被隐藏在collections中。然而,在 Python 3.3 中,抽象基类已经被拆分成一个名为collections.abc的单独子模块。

我们还将研究numbers模块,因为它包含了数字类型的 ABC。io模块中也有用于 I/O 的抽象基类。

我们将专注于 Python 3.3 版本。这些定义在 Python 3.2 中也会非常类似,但import语句会略有变化以反映更扁平的库结构。

抽象基类具有以下特点:

  • 抽象意味着这些类并不包含完全工作所需的所有方法定义。为了使其成为一个有用的子类,我们需要提供一些方法定义。

  • 基类意味着其他类将使用它作为超类。

  • 抽象类为方法函数提供了一些定义。最重要的是,抽象基类为缺失的方法函数提供了签名。子类必须提供正确的方法来创建一个符合抽象类定义的接口的具体类。

抽象基类的特点包括以下几点:

  • 我们可以使用它们来为 Python 内部类和我们定制的应用程序类定义一致的基类集。

  • 我们可以使用它们来创建一些常见的可重用的抽象,可以在我们的应用程序中使用。

  • 我们可以使用它们来支持对类的适当检查,以确定它的功能。这允许库类和我们应用程序中的新类更好地协作。为了进行适当的检查,有必要有诸如“容器”和“数字”之类的概念的正式定义。

没有抽象基类(也就是在“旧的不好的日子里”),一个容器可能会或可能不会一致地提供Sequence类的所有特性。这经常导致一个类几乎成为一个序列或类似序列。这反过来导致了奇怪的不一致和笨拙的解决方法,因为一个类并没有完全提供序列的所有特性。

有了抽象基类,你可以确保应用程序给定的类将具有宣传的特性。如果缺少某个特性,未定义的抽象方法的存在将使该类无法用于构建对象实例。

我们将在几种情况下使用 ABC,如下:

  • 我们将在定义自己的类时使用 ABC 作为超类

  • 我们将在方法中使用 ABC 来确认一个操作是否可能

  • 我们将在诊断消息或异常中使用 ABC 来指示为什么操作无法工作

对于第一个用例,我们可以编写以下代码样式的模块:

import collections.abc
class SomeApplicationClass( collections.abc.Callable ):
    pass

我们的SomeApplicationClass被定义为一个Callable类。然后,它必须实现Callable所需的特定方法,否则我们将无法创建实例。

函数是Callable类的一个具体示例。抽象是定义__call__()方法的类。我们将在下一节和第五章中查看Callables类,使用可调用和上下文

对于第二个用例,我们可以编写以下代码样式的方法:

def some_method( self, other ):
    assert isinstance(other, collections.abc.Iterator)

我们的some_method()要求other参数是Iterator的子类。如果other参数无法通过此测试,我们会得到一个异常。assert的一个常见替代方案是引发TypeErrorif语句,这可能更有意义。我们将在下一节中看到这一点。

对于第三个用例,我们可能会有以下内容:

try:
    some_obj.some_method( another )
except AttributeError:
    warnings.warn( "{0!r} not an Iterator, found {0.__class__.__bases__!r}".format(another) )
    raise

在这种情况下,我们编写了一个诊断警告,显示给定对象的基类。这可能有助于调试应用程序设计中的问题。

基类和多态性

在本节中,我们将探讨相当差的多态性的概念。检查参数值是一种应该只用于少数特殊情况的 Python 编程实践。

良好的多态性遵循有时被称为里氏替换原则的原则。多态类可以互换使用。每个多态类具有相同的属性套件。有关更多信息,请访问en.wikipedia.org/wiki/Liskov_substitution_principle

过度使用isinstance()来区分参数类型可能会导致程序变得不必要复杂(和缓慢)。实例比较一直在进行,但错误通常只是通过软件维护引入的。单元测试是发现编程错误的更好方法,而不是在代码中进行冗长的类型检查。

具有大量isinstance()方法的方法函数可能是多态类设计不良(或不完整)的症状。与在类定义之外具有特定于类型的处理相比,通常最好扩展或包装类,使它们更适当地多态化,并在类定义内封装特定于类型的处理。

isinstance()方法的一个很好的用途是创建诊断消息。一个简单的方法是使用assert语句:

assert isinstance( some_argument, collections.abc.Container ), "{0!r} not a Container".format(some_argument)

这将引发AssertionError异常以指示存在问题。这有一个优点,即它简短而直接。但是,它有两个缺点:断言可能会被消除,并且最好引发TypeError。以下示例可能更好:

if not isinstance(some_argument, collections.abc.Container):
    raise TypeError( "{0!r} not a Container".format(some_argument) )

前面的代码有一个优点,即它引发了正确的错误。但是,它的缺点是冗长。

更 Pythonic 的方法总结如下:

“宁愿请求宽恕,也不要请求许可。”

这通常被理解为我们应该尽量减少对参数的前期测试(请求许可),以查看它们是否是正确的类型。参数类型检查很少有任何实际好处。相反,我们应该适当地处理异常(请求宽恕)。

最好的方法是在不太可能发生不适当类型的情况下,将诊断信息与异常结合在一起,并通过单元测试进入操作。

通常情况下会做以下操作:

try:
    found = value in some_argument
except TypeError:
    if not isinstance(some_argument, collections.abc.Container):
        warnings.warn( "{0!r} not a Container".format(some_argument) )
    raise

isinstance()方法假定some_argumentcollections.abc.Container类的一个适当实例,并且将响应in运算符。

在极少数情况下,如果有人更改了应用程序,并且some_argument现在是错误的类,应用程序将写入诊断消息,并因TypeError异常而崩溃。

可调用对象

Python 对可调用对象的定义包括使用def语句创建的明显函数定义。

它还包括,非正式地,任何具有__call__()方法的类。我们可以在Python 3 面向对象编程Dusty PhillipsPackt Publishing中看到几个例子。为了更正式,我们应该使每个可调用类定义成为collections.abc.Callable的适当子类。

当我们查看任何 Python 函数时,我们会看到以下行为:

>>> abs(3)
3
>>> isinstance(abs, collections.abc.Callable)
True

内置的abs()函数是collections.abc.Callable的一个正确实例。我们定义的函数也是如此。以下是一个例子:

>>> def test(n):
...     return n*n
...
>>> isinstance(test, collections.abc.Callable)
True

每个函数都报告自己是Callable。这简化了对参数值的检查,并有助于编写有意义的调试消息。

我们将在第五章使用可调用对象和上下文中详细讨论可调用对象。

容器和集合

collections模块定义了许多超出内置容器类的集合。容器类包括namedtuple()dequeChainMapCounterOrderedDictdefaultdict。所有这些都是基于 ABC 定义的类的示例。

以下是一个快速交互,展示了我们如何检查集合以查看它们将支持的方法:

>>> isinstance( {}, collections.abc.Mapping )
True
>>> isinstance( collections.defaultdict(int), collections.abc.Mapping )
True

我们可以检查简单的dict类,看它是否遵循基本的映射协议并支持所需的方法。

我们可以检查defaultdict集合以确认它也是一个映射。

创建新类型的容器时,我们可以不正式地进行。我们可以创建一个具有所有正确特殊方法的类。但是,我们并不需要正式声明它是某种类型的容器。

使用适当的 ABC 作为应用程序类的基类更清晰(也更可靠)。额外的形式性具有以下两个优点:

  • 它向阅读(可能使用或维护)我们的代码的人宣传了我们的意图。当我们创建collections.abc.Mapping的子类时,我们对该类将如何使用做出了非常强烈的声明。

  • 它创建了一些诊断支持。如果我们以某种方式未能正确实现所有所需的方法,我们就无法创建抽象基类的实例。如果我们无法运行单元测试,因为无法创建对象的实例,那么这表明存在需要修复的严重问题。

内置容器的整个家族树反映在抽象基类中。较低级别的特性包括ContainerIterableSized。这些是更高级别的构造的一部分;它们需要一些特定的方法,特别是__contains__()__iter__()__len__()

更高级别的特性包括以下特征:

  • SequenceMutableSequence:这些是具体类listtuple的抽象。具体的序列实现还包括bytesstr

  • MutableMapping:这是dict的抽象。它扩展了Mapping,但没有内置的具体实现。

  • SetMutableSet:这些是具体类frozensetset的抽象。

这使我们能够构建新类或扩展现有类,并与 Python 的其他内置特性保持清晰和正式的集成。

我们将在第六章创建容器和集合中详细讨论容器和集合。

数字

在创建新的数字(或扩展现有数字)时,我们会转向numbers模块。这个模块包含了 Python 内置数值类型的抽象定义。这些类型形成了一个高高瘦瘦的层次结构,从最简单到最复杂。在这种情况下,简单(和复杂)指的是可用的方法集合。

有一个名为numbers.Number的抽象基类,它定义了所有的数字和类似数字的类。我们可以通过以下交互来看到这一点:

>>> import numbers
>>> isinstance( 42, numbers.Number )
True
>>> 355/113            
3.1415929203539825
>>> isinstance( 355/113, numbers.Number )
True

显然,整数和浮点数值是抽象numbers.Number类的子类。

子类包括numbers.Complexnumbers.Realnumbers.Rationalnumbers.Integral。这些定义大致上是对各种数字类的数学思考。

然而,decimal.Decimal类并不非常适合这个层次结构。我们可以使用issubclass()方法来检查关系,如下所示:

>>> issubclass( decimal.Decimal, numbers.Number )
True
>>> issubclass( decimal.Decimal, numbers.Integral )
False
>>> issubclass( decimal.Decimal, numbers.Real )
False
>>> issubclass( decimal.Decimal, numbers.Complex )
False
>>> issubclass( decimal.Decimal, numbers.Rational )
False

Decimal不太适合已建立的数字类型并不太令人惊讶。对于numbers.Rational的具体实现,请查看fractions模块。我们将在第七章中详细讨论各种数字,创建数字

一些额外的抽象

我们将看一些其他有趣的 ABC 类,这些类的扩展范围较小。并不是这些抽象较少被使用。更多的是具体实现很少需要扩展或修订。

我们将看一下由collections.abc.Iterator定义的迭代器。我们还将看一下与上下文管理器无关的概念。这并没有像其他 ABC 类那样正式定义。我们将在第五章中详细讨论这一点,使用可调用和上下文

迭代器抽象

当我们使用for语句与可迭代容器一起使用时,迭代器会隐式创建。我们很少关心迭代器本身。而我们确实关心迭代器的几次,我们很少想要扩展或修订类定义。

我们可以通过iter()函数暴露 Python 使用的隐式迭代器。我们可以以以下方式与迭代器交互:

>>> x = [ 1, 2, 3 ]
>>> iter(x)
<list_iterator object at 0x1006e3c50>
>>> x_iter = iter(x)
>>> next(x_iter)
1
>>> next(x_iter)
2
>>> next(x_iter)
3
>>> next(x_iter)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
StopIteration
>>> isinstance( x_iter, collections.abc.Iterator )
True

我们创建了一个列表对象上的迭代器,然后使用next()函数逐步遍历该迭代器中的值。

最后的isinstance()表达式确认了这个迭代器对象是collections.abc.Iterator的一个实例。

大多数时候,我们将使用由集合类自己创建的迭代器。然而,当我们扩展集合类或构建自己的集合类时,我们可能也需要构建一个独特的迭代器。我们将在第六章中详细讨论迭代器,创建容器和集合

上下文和上下文管理器

上下文管理器与with语句一起使用。当我们写类似以下内容时,我们正在使用上下文管理器:

with function(arg) as context:
    process( context )

在前面的情况下,function(arg)创建了上下文管理器。

一个非常常用的上下文管理器是文件。当我们打开一个文件时,我们应该定义一个上下文,这样也会自动关闭文件。因此,我们几乎总是以以下方式使用文件:

with open("some file") as the_file:
    process( the_file )

with语句的末尾,我们可以确保文件会被正确关闭。contextlib模块提供了几种构建正确上下文管理器的工具。这个库并没有提供抽象基类,而是提供了装饰器,可以将简单函数转换为上下文管理器,以及一个contextlib.ContextDecorator基类,可以用来扩展构建一个上下文管理器的类。

我们将在第五章中详细讨论上下文管理器,使用可调用和上下文

abc 模块

创建 ABC 的核心方法在abc模块中定义。这个模块包括提供几个特性的ABCMeta类。

首先,ABCMeta类确保抽象类不能被实例化。然而,提供了所有必需定义的子类可以被实例化。元类将调用抽象类的特殊方法__subclasshook__(),作为处理__new__()的一部分。如果该方法返回NotImplemented,那么将引发异常,以显示该类没有定义所有必需的方法。

其次,它为__instancecheck__()__subclasscheck__()提供了定义。这些特殊方法实现了isinstance()issubclass()内置函数。它们提供了确认对象(或类)属于适当 ABC 的检查。这包括一个子类的缓存,以加快测试速度。

abc模块还包括许多装饰器,用于创建必须由抽象基类的具体实现提供的抽象方法函数。其中最重要的是@abstractmethod装饰器。

如果我们想创建一个新的抽象基类,我们会使用类似以下的东西:

from abc import ABCMeta, abstractmethod
class AbstractBettingStrategy(metaclass=ABCMeta):
    __slots__ = ()
    @abstractmethod
    def bet(self, hand):
        return 1
    @abstractmethod
    def record_win(self, hand):
        pass
    @abstractmethod
    def record_loss(self, hand):
        pass
    @classmethod
    def __subclasshook__(cls, subclass):
        if cls is Hand:
            if (any("bet" in B.__dict__ for B in subclass.__mro__)
            and any("record_win" in B.__dict__ for B in subclass.__mro__)
            and any("record_loss" in B.__dict__ for B in subclass.__mro__)
            ):
                return True
        return NotImplemented

这个类包括ABCMeta作为它的元类;它还使用__subclasshook__()方法,检查完整性。这些提供了抽象类的核心特性。

这个抽象使用abstractmethod装饰器来定义三个抽象方法。任何具体的子类必须定义这些方法,以便成为抽象基类的完整实现。

__subclasshook__方法要求子类提供所有三个抽象方法。这可能有些过分,因为一个超级简单的投注策略不应该必须提供计算赢和输的方法。

子类挂钩依赖于 Python 类定义的两个内部特性:__dict__属性和__mro__属性。__dict__属性是记录类定义的方法名和属性名的地方。这基本上是类的主体。__mro__属性是方法解析顺序。这是这个类的超类的顺序。由于 Python 使用多重继承,可以有许多超类,这些超类的顺序决定了解析名称的优先顺序。

以下是一个具体类的例子:

class Simple_Broken(AbstractBettingStrategy):
    def bet( self, hand ):
        return 1

前面的代码无法构建,因为它没有为所有三种方法提供必要的实现。

当我们尝试构建它时会发生以下情况:

>>> simple= Simple_Broken()
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: Can't instantiate abstract class Simple_Broken with abstract methods record_loss, record_win

错误消息表明具体类是不完整的。以下是一个更好的具体类,通过了完整性测试:

class Simple(AbstractBettingStrategy):
    def bet( self, hand ):
        return 1
    def record_win(self, hand):
        pass
    def record_loss(self, hand):
        pass

我们可以构建这个类的一个实例,并将其用作我们模拟的一部分。

正如我们之前指出的,bet()方法可能应该是唯一必需的方法。其他两个方法应该允许默认为单个语句pass

总结、设计考虑和权衡

在本章中,我们看了抽象基类的基本要素。我们看到了每种抽象的一些特性。

我们还学到了一个好的类设计规则是尽可能继承。我们在这里看到了两种广泛的模式。我们还看到了这个规则的常见例外。

一些应用类的行为与 Python 的内部特性没有重叠。从我们的二十一点示例中,Card与数字、容器、迭代器或上下文并不相似。它只是一张扑克牌。在这种情况下,我们通常可以发明一个新的类,因为没有任何内置特性可以继承。

然而,当我们看Hand时,我们会发现手显然是一个容器。正如我们在第一章方法")和第二章中注意到的,以下是三种基本的设计策略:

  • 包装现有的容器

  • 扩展现有的容器

  • 发明一种全新的容器类型

大多数情况下,我们将包装或扩展现有的容器。这符合我们尽可能继承的规则。

当我们扩展一个现有的类时,我们的应用类将很好地适应类层次结构。对内置的list的扩展已经是collections.abc.MutableSequence的一个实例。

然而,当我们包装一个现有的类时,我们必须仔细考虑我们想要支持原始接口的哪些部分,以及我们不想支持哪些部分。在前几章的例子中,我们只想暴露我们包装的列表对象的pop()方法。

因为包装类不是一个完整的可变序列实现,它有很多事情做不了。另一方面,扩展类参与了一些可能会变得有用的用例。例如,扩展list的手将变得可迭代。

如果我们发现扩展一个类不能满足我们的要求,我们可以建立一个全新的集合。ABC 定义提供了大量关于需要哪些方法才能与 Python 宇宙的其余部分无缝集成的指导。我们将在第六章中详细介绍如何发明一个集合的例子。

展望未来

在接下来的章节中,我们将广泛使用本章讨论的这些抽象基类。在第五章中,我们将研究可调用和容器的相对简单的特性。在第六章中,我们将研究可用的容器和集合。我们还将在本章中构建一种独特的新型容器。最后,在第七章中,我们将研究各种数字类型以及如何创建我们自己的数字类型。

第五章:使用可调用和上下文

我们可以利用collections.abc.Callable ABC 并采用一种称为记忆化的技术来创建行为类似函数但执行非常快的对象,因为它们能够缓存先前的结果。在某些情况下,记忆化对于创建在合理时间内完成的算法是必不可少的。

上下文概念允许我们创建优雅、可靠的资源管理。with语句定义了一个上下文,并创建了一个上下文管理器来控制在该上下文中使用的资源。Python 文件通常是上下文管理器;当在with语句中使用时,它们会被正确关闭。

我们将使用contextlib模块中的工具来创建几种上下文管理器的方法。

在 Python 3.2 中,抽象基类位于collections模块中。

在 Python 3.3 中,抽象基类位于一个名为collections.abc的单独子模块中。在本章中,我们将专注于 Python 版本 3.3。基本定义对于 Python 3.2 也是正确的,但import语句会改变。

我们将展示一些可调用对象的变体设计。这将向我们展示为什么有状态的可调用对象有时比简单的函数更有用。我们还将看看如何在深入编写自己的上下文管理器之前使用一些现有的 Python 上下文管理器。

使用 ABC 可调用对象进行设计

有两种简单的方法可以在 Python 中创建可调用对象,如下所示:

  • 使用def语句创建一个函数

  • 通过创建一个使用collections.abc.Callable作为基类的类的实例

我们还可以将lambda形式分配给一个变量。lambda 是一个由一个表达式组成的小型匿名函数。我们不太愿意强调将 lambda 保存在变量中,因为这会导致混乱的情况,即我们有一个类似函数的可调用对象,但没有使用def语句定义。以下是从类创建的一个简单的可调用对象:

import collections.abc
class Power1( collections.abc.Callable ):
    def __call__( self, x, n ):
        p= 1
        for i in range(n):
            p *= x
        return p
pow1= Power1()

前面的可调用对象有三个部分,如下所示:

  • 我们将类定义为abc.Callable的子类

  • 我们定义了__call__()方法

  • 我们创建了一个类的实例,pow1()

是的,这个算法看起来效率低下。我们将解决这个问题。

显然,这是如此简单,以至于真的不需要一个完整的类定义。为了展示各种优化,从可调用对象开始要比将函数变异为可调用对象稍微简单一些。

现在我们可以像使用其他函数一样使用pow1()函数。以下是如何在 Python 命令行中使用pow1()函数:

>>> pow1( 2, 0 )
1
>>> pow1( 2, 1 )
2
>>> pow1( 2, 2 )
4
>>> pow1( 2, 10 )
1024

我们已经用各种参数值评估了可调用对象。将可调用对象作为abc.Callable的子类并不是必需的。但是,它有助于调试。

考虑这个有缺陷的定义:

class Power2( collections.abc.Callable ):
    def __call_( self, x, n ):
        p= 1
        for i in range(n):
            p *= x
        return p

前面的类定义有一个错误,并且不符合可调用对象的定义。

找到错误了吗?如果没有,那么错误就在章节的末尾。

当我们尝试创建这个类的实例时,会发生以下情况:

>>> pow2= Power2()
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: Can't instantiate abstract class Power2 with abstract methods __call__

可能并不明显出了什么问题,但我们有一个很大的机会来调试这个问题。如果我们没有将collections.abc.Callable作为子类,我们将有一个更加神秘的问题需要调试。

以下是更神秘的问题会是什么样子。我们将跳过Power3的实际代码。它与Power2相同,只是没有将collections.abc.Callable作为子类。它以class Power3开始;其他方面都是相同的。

当我们尝试将Power3用作不符合可调用对象期望并且也不是abc.Callable的子类的类时,会发生以下情况:

>>> pow3= Power3()
>>> pow3( 2, 5 )
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: 'Power3' object is not callable

这个错误提供的指导不够明确,无法解释Power3类定义有什么问题。Power2错误对问题的性质更加明确。

提高性能

我们将看看Power3类的两种性能调整。

首先是更好的算法。然后是与记忆化相结合的更好的算法,其中涉及缓存;因此,函数变得有状态。这就是可调用对象的优势所在。

第一个修改是使用分而治之的设计策略。之前的版本将提高性能分成n步;循环执行n个单独的乘法操作。如果我们能找到一种方法将问题分成两个相等的部分,问题就会分解成提高性能步。给定pow1(2,1024)Power1可调用对象执行了 1024 次乘以 2 的计算。我们可以将这个优化到 10 次乘法,显著提高速度。

而不是简单地乘以一个固定值,我们将使用“快速指数”算法。它使用三个计算提高性能的基本规则,如下所示:

  • 如果提高性能提高性能,结果就是 1。

  • 如果n为奇数且提高性能,结果是提高性能。这涉及到提高性能的递归计算。这仍然进行了乘法,但并非真正的优化。

  • 如果n是偶数且提高性能,结果是提高性能。这涉及到提高性能的递归计算。这将乘法次数减半。

以下是递归可调用对象:

class Power4( abc.Callable ):
    def __call__( self, x, n ):
        if n == 0: return 1
        elif n % 2 == 1:
            return self.__call__(x, n-1)*x
        else: # n % 2 == 0:
            t= self.__call__(x, n//2)
            return t*t

pow4= Power4()

我们对输入值应用了三条规则。如果n为零,我们将返回 1。如果n为奇数,我们将进行递归调用并返回提高性能。如果n为偶数,我们将进行递归调用并返回提高性能

执行时间大大加快。我们可以使用timeit模块来查看性能上的差异。有关使用timeit的信息,请参阅一些准备工作。当我们比较运行pow1(2,1024)pow4(2,1024) 10000 次时,我们会看到先前版本大约需要 183 秒,而这个版本只需要 8 秒。然而,我们可以通过备忘录做得更好。

以下是我们如何使用timeit收集性能数据:

import timeit
iterative= timeit.timeit( "pow1(2,1024)","""
import collections.abc
class Power1( collections.abc.Callable ):
    def __call__( self, x, n ):
        p= 1
        for i in range(n):
            p *= x
        return p

pow1= Power1()
""", number=100000 ) # otherwise it takes 3 minutes
print( "Iterative", iterative )

我们导入了timeit模块。timeit.timeit()函数将在定义的上下文中评估给定的语句。在这种情况下,我们的表达式是简单的pow1(2,1024)表达式。这个语句的上下文是pow1()函数的定义;它包括导入、类定义和实例的创建。

请注意,我们提供了number=100000来加快速度。如果我们使用了默认值进行迭代,可能需要将近 2 分钟。

使用备忘录或缓存

备忘录的理念是缓存先前的结果,以避免重新计算它们。我们将使用更多的内存,但也可以通过避免计算来大大提高性能。

普通函数没有地方来缓存先前的结果。不希望函数有状态。然而,可调用对象可以有状态。它可以包括一个缓存的先前结果。

以下是我们的Power可调用对象的备忘录版本:

class Power5( collections.abc.Callable ):
    def __init__( self ):
        self.memo = {}
    def __call__( self, x, n ):
        if (x,n) not in self.memo:
            if n == 0:
                self.memo[x,n]= 1
            elif n % 2 == 1:
                self.memo[x,n]= self.__call__(x, n-1) * x
            elif n % 2 == 0:
                t= self.__call__(x, n//2)
                self.memo[x,n]= t*t
            else:
                raise Exception("Logic Error")
        return self.memo[x,n]
pow5= Power5()

我们修改了我们的算法以适应self.memo缓存。

如果之前已经请求过使用备忘录或缓存的值,那么将返回该结果,不进行计算。这就是我们之前所说的巨大加速。

否则,必须计算并保存使用备忘录或缓存的值在备忘录缓存中。使用三条规则来计算快速指数,以获取和放置缓存中的值。这确保了未来的计算将能够利用缓存的值。

备忘录的重要性不言而喻。计算的减少可能是巨大的。通常是通过用可调用对象替换一个慢、昂贵的函数来完成的。

使用 functools 进行备忘录

Python 库中包括了functools模块中的备忘录装饰器。我们可以使用这个模块而不是创建我们自己的可调用对象。

我们可以这样使用:

from functools import lru_cache
@lru_cache(None)
def pow6( x, n ):
    if n == 0: return 1
    elif n % 2 == 1:
        return pow6(x, n-1)*x
    else: # n % 2 == 0:
        t= pow6(x, n//2)
        return t*t

这定义了一个函数pow6(),它被装饰为最近最少使用LRU)缓存。先前的请求被存储在备忘录缓存中。请求在缓存中被跟踪,并且大小是有限的。LRU 缓存的理念是最近做出的请求被保留,而最不常做出的请求则被悄悄清除。

使用timeit,我们可以看到pow5()的 10000 次迭代大约需要 1 秒,而pow6()的迭代大约需要 8 秒。

这也表明,timeit的一个微不足道的用法可能会误导记忆算法的性能。timeit模块的请求应该更加复杂,以反映更现实的用例,以正确地混合缓存命中和缓存未命中。简单的随机数并不总是适用于所有问题领域。

使用可调用的 API 来追求简单

可调用对象的背后思想是,我们有一个专注于单个方法的 API。

一些对象有多个相关方法。例如,一个二十一点Hand必须添加卡片并产生总数。一个二十一点Player必须下注,接受手牌,并做出打牌决定(例如,要牌、停牌、分牌、投保、加倍下注等)。这些是更复杂的接口,不适合作为可调用对象。

然而,赌注策略是一个可调用的候选对象。

赌注策略可以实现为几种方法(一些设置器和一个获取器方法),或者它可以是一个可调用接口,具有一些公共属性。

以下是直接的赌注策略。它总是相同的:

class BettingStrategy:
    def __init__( self ):
       self.win= 0
       self.loss= 0
    def __call__( self ):
        return 1
bet=  BettingStrategy()

这个 API 的想法是,Player对象将通知赌注策略的赢得金额和损失金额。Player对象可能有以下方法来通知赌注策略有关结果的情况:

    def win( self, amount ):
        self.bet.win += 1
        self.stake += amount
    def loss( self, amount ):
         self.bet.loss += 1
         self.stake -= amount

这些方法通知一个赌注策略对象(self.bet对象)手牌是赢还是输。当是下注的时候,Player将执行类似以下操作来获取当前的下注水平:

    def initial_bet( self ):
        return self.bet()

这是一个非常简短的 API。毕竟,赌注策略除了封装一些相对简单的规则之外,并没有做太多事情。

这个接口的简短性是可调用对象的一个优雅特性。我们没有太多的方法名,也没有复杂的语法用于一个简单的事情。

复杂性和可调用 API

让我们看看当我们的处理变得更加复杂时,这个 API 能否经得起考验。以下是每次损失都加倍的策略(也称为马丁尼赌注系统):

class BettingMartingale( BettingStrategy ):
    def __init__( self ):
        self._win= 0
        self._loss= 0
        self.stage= 1
    @property
    def win(self): return self._win
    @win.setter
    def win(self, value):
        self._win = value
        self.stage= 1
    @property
    def loss(self): return self._loss
    @loss.setter
    def loss(self, value):
        self._loss = value
        self.stage *= 2
    def __call__( self ):
       return self.stage

每次损失都会将赌注加倍,将阶段乘以二。这将持续下去,直到我们赢得并收回我们的损失,达到桌子限制,或者破产并不能再下注。赌场通过施加桌子限制来防止这种情况。

每当我们赢得时,赌注就会重置为基本赌注。阶段被重置为一个值为一的值。

为了保持属性接口——例如bet.win += 1这样的代码,我们需要创建属性,以便根据赢和输正确地进行状态更改。我们只关心设置器属性,但我们必须定义获取器属性,以便清楚地创建设置器属性。

我们可以看到这个类的实际操作如下:

>>> bet= BettingMartingale()
>>> bet()
1
>>> bet.win += 1
>>> bet()
1
>>> bet.loss += 1
>>> bet()
2

API 仍然非常简单。我们可以计算赢得次数并将赌注重置为基本赌注,或者我们可以计算损失次数,赌注将加倍。

属性的使用使得类定义变得冗长且丑陋。我们真正感兴趣的只是设置器而不是获取器,因此我们可以使用__setattr__()来简化类定义,如下面的代码所示:

class BettingMartingale2( BettingStrategy ):
    def __init__( self ):
        self.win= 0
        self.loss= 0
        self.stage= 1
    def __setattr__( self, name, value ):
        if name == 'win':
            self.stage = 1
        elif name == 'loss':
            self.stage *= 2
        super().__setattr__( name, value )
    def __call__( self ):
       return self.stage

我们使用__setattr__()来监视对winloss的更新。除了使用super().__setattr__()设置实例变量之外,我们还更新了赌注金额的内部状态。

这是一个更好看的类定义,并且保留了一个具有两个属性的简单 API 作为可调用对象。

管理上下文和with语句

上下文和上下文管理器在 Python 中的几个地方使用。我们将看一些例子来建立基本术语。

上下文由with语句定义。以下程序是一个小例子,它解析日志文件,创建一个有用的日志 CSV 摘要。由于有两个打开的文件,我们期望看到嵌套的with上下文。示例使用了一个复杂的正则表达式format_1_pat。我们很快就会定义这个。

我们可能会在应用程序中看到以下内容:

import gzip
import csv
with open("subset.csv", "w") as target:
    wtr= csv.writer( target )
    **with gzip.open(path) as source:
        line_iter= (b.decode() for b in source)
        match_iter = (format_1_pat.match( line ) for line in line_iter)
        wtr.writerows( (m.groups() for m in match_iter if m is not None) )

在这个示例中强调了两个上下文和两个上下文管理器。

最外层的上下文从with open("subset.csv", "w") as target开始。内置的open()函数打开一个文件,同时也是一个上下文管理器,并将其分配给target变量以供进一步使用。

内部上下文从with gzip.open(path, "r") as source开始。这个gzip.open()函数的行为与open()函数类似,它打开一个文件,同时也是一个上下文管理器。

with语句结束时,上下文退出,文件被正确关闭。即使在with上下文的主体中出现异常,上下文管理器的退出也将被正确处理,文件将被关闭。

提示

始终在文件周围使用 with

由于文件涉及操作系统资源,确保应用程序和操作系统之间的纠缠在不再需要时被释放是很重要的。with语句确保资源被正确使用。

为了完成示例,以下是用于解析 Apache HTTP 服务器日志文件的通用日志格式的正则表达式:

import re
format_1_pat= re.compile(
    r"([\d\.]+)\s+" # digits and .'s: host
    r"(\S+)\s+"     # non-space: logname
    r"(\S+)\s+"     # non-space: user
    r"\[(.+?)\]\s+" # Everything in []: time
    r'"(.+?)"\s+'   # Everything in "": request
    r"(\d+)\s+"     # digits: status
    r"(\S+)\s+"     # non-space: bytes
    r'"(.*?)"\s+'   # Everything in "": referrer
    r'"(.*?)"\s*'   # Everything in "": user agent
)

在前面的示例中找到了用于前面示例中使用的各种日志格式字段。

使用十进制上下文

经常使用的另一个上下文是十进制上下文。这个上下文定义了decimal.Decimal计算的许多属性,包括用于舍入或截断值的量化规则。

我们可能会看到以下样式的应用程序编程:

import decimal
PENNY=decimal.Decimal("0.00")

price= decimal.Decimal('15.99')
rate= decimal.Decimal('0.0075')
print( "Tax=", (price*rate).quantize(PENNY), "Fully=", price*rate )

with decimal.localcontext() as ctx:
    ctx.rounding= decimal.ROUND_DOWN
    tax= (price*rate).quantize(PENNY)
    print( "Tax=", tax )

前面的示例显示了默认上下文以及局部上下文。默认上下文具有默认的舍入规则。然而,局部上下文显示了如何通过为特定计算设置十进制舍入来确保一致的操作。

with语句用于确保在局部更改后恢复原始上下文。在此上下文之外,将应用默认舍入。在此上下文中,将应用特定的舍入。

其他上下文

还有一些其他常见的上下文。几乎所有与基本输入/输出操作相关的模块都会创建一个上下文以及类似文件的对象。

上下文还与锁定和数据库事务相关联。我们可能会获取和释放外部锁,比如信号量,或者我们可能希望数据库事务在成功提交时正确提交,或者在失败时回滚。这些都是 Python 中定义上下文的事情。

PEP 343 文档提供了with语句和上下文管理器可能被使用的其他一些示例。还有其他地方我们可能想要使用上下文管理器。

我们可能需要创建仅仅是上下文管理器的类,或者我们可能需要创建可以具有多种用途的类之一是作为上下文管理器。file()对象类似。我们将研究一些上下文的设计策略。

我们将在第八章中再次讨论这个问题,装饰器和混入-横切面,在那里我们可以涵盖创建具有上下文管理器功能的类的更多方法。

定义__enter__()__exit__()方法

上下文管理器的定义特征是它具有两个特殊方法:__enter__()__exit__()。这些方法由with语句用于进入和退出上下文。我们将使用一个简单的上下文,以便看到它们是如何工作的。

我们经常使用上下文管理器进行瞬时全局更改。这可能是对数据库事务状态或锁定状态的更改,我们希望在事务完成时撤消。

在这个示例中,我们将全局更改随机数生成器。我们将创建一个上下文,在这个上下文中,随机数生成器使用一个固定和已知的种子,提供一个固定的值序列。

以下是上下文管理器类的定义:

import random
class KnownSequence:
    def __init__(self, seed=0):
        self.seed= 0
    def __enter__(self):
        self.was= random.getstate()
        random.seed(self.seed, version=1)
        return self
    def __exit__(self, exc_type, exc_value, traceback):
        random.setstate(self.was)

我们定义了所需的__enter__()__exit__()方法。__enter__()方法将保存随机模块的先前状态,然后将种子重置为给定值。__exit__()方法将恢复随机数生成器的原始状态。

请注意,__enter__() 返回 self。这对于已添加到其他类定义中的mixin上下文管理器是常见的。我们将在第八章中讨论 mixin 的概念,装饰器和 Mixin-横切方面

__exit__()方法的参数在正常情况下将具有None的值。除非我们有特定的异常处理需求,通常会忽略参数值。我们将在下面的代码中讨论异常处理。

以下是使用上下文的示例:

print( tuple(random.randint(-1,36) for i in range(5)) )
with KnownSequence():
    print( tuple(random.randint(-1,36) for i in range(5)) )
print( tuple(random.randint(-1,36) for i in range(5)) )
with KnownSequence():
    print( tuple(random.randint(-1,36) for i in range(5)) )
print( tuple(random.randint(-1,36) for i in range(5)) )

每次我们创建KnownSequence的实例时,我们都会修改random模块的工作方式。在with语句的上下文中,我们将获得一系列固定的值。在上下文之外,随机种子将被恢复,我们将获得随机值。

输出通常如下所示:

(12, 0, 8, 21, 6)
(23, 25, 1, 15, 31)
(6, 36, 1, 34, 8)
(23, 25, 1, 15, 31)
(9, 7, 13, 22, 29)

部分输出取决于机器。虽然确切的值可能有所不同,但第二行和第四行将匹配,因为种子由上下文固定。其他行不一定匹配,因为它们依赖于random模块自己的随机化特性。

处理异常

在一个块中出现的异常将传递给上下文管理器的__exit__()方法。异常的标准部分-类、参数和回溯堆栈-都将作为参数值提供。

__exit__()方法可以对异常信息执行以下两种操作之一:

  • 通过返回一些True值来消除异常。

  • 通过返回任何其他False值来允许异常正常上升。返回什么也不同于返回None,这是一个False值;这允许异常传播。

异常也可以用于更改上下文管理器在退出时的操作。例如,我们可能必须对可能出现的某些类型的 OS 错误进行特殊处理。

上下文管理器作为工厂

我们可以创建一个上下文管理器类,它是应用程序对象的工厂。这使我们在不使应用程序类混杂上下文管理特性的情况下,愉快地分离设计考虑。

假设我们想要一个确定性的用于 21 点的Deck。这并不像听起来那么有用。对于单元测试,我们将需要一个完全模拟的具有特定卡片序列的牌组。这样做的好处是上下文管理器可以与我们已经看到的类一起使用。

我们将扩展之前显示的简单上下文管理器,以创建一个可以在with语句上下文中使用的Deck

以下是一个工厂Deck并调整random模块的类:

class Deterministic_Deck:
    def __init__( self, *args, **kw ):
        self.args= args
        self.kw= kw
    def __enter__( self ):
        self.was= random.getstate()
        random.seed( 0, version=1 )
        return Deck( *self.args, **self.kw )
    def __exit__( self, exc_type, exc_value, traceback ):
        random.setstate( self.was )

前面的上下文管理器类保留参数值,以便它可以使用给定的参数创建一个Deck

__enter__()方法保留了旧的随机数状态,然后设置了random模块的模式,以提供一系列固定的值。这用于构建和洗牌牌组。

请注意,__enter__()方法返回一个新创建的Deck对象,以便在with语句上下文中使用。这是通过with语句中的as子句分配的。

我们也可以以另一种方式提供类似的功能。我们可以在Deck类中创建random.Random(x=seed)的实例。虽然这也很有效,但它倾向于使Deck类混杂了仅用于演示的代码。

以下是使用此工厂上下文管理器的方法:

with Deterministic_Deck( size=6 ) as deck:
    h = Hand( deck.pop(), deck.pop(), deck.pop() )

上面的代码示例保证了我们可以用于演示目的的特定顺序的卡片。

在上下文管理器中清理

在本节中,我们将讨论一个更复杂的上下文管理器,在出现问题时尝试进行一些清理。

这解决了我们想要保存正在重写的文件的备份副本的常见问题。我们希望能够做类似以下的事情:

with Updating( "some_file" ):
    with open( "some_file", "w" ) as target:
        process( target )

意图是将原始文件重命名为some_file copy。如果上下文正常工作——没有异常——那么备份副本可以被删除或重命名为some_file old

如果上下文不能正常工作——有一个异常——我们希望将新文件重命名为some_file error,并将旧文件重命名为some_file,将原始文件放回异常发生之前的状态。

我们将需要一个如下的上下文管理器:

import os
class Updating:
    def __init__( self, filename ):
        self.filename= filename
    def __enter__( self ):
        try:
            self.previous= self.filename+" copy"
            os.rename( self.filename, self.previous )
        except FileNotFoundError:
            # Never existed, no previous copy
            self.previous= None
    def __exit__( self, exc_type, exc_value, traceback ):
        if exc_type is not None:
            try:
                os.rename( self.filename, self.filename+ " error" )
            except FileNotFoundError:
                pass # Never even got created?
            if self.previous:
                os.rename( self.previous, self.filename )

这个上下文管理器的__enter__()方法将尝试保留已命名文件的先前副本。如果它不存在,就没有什么可以保留的了。

__exit__()方法将提供有关上下文中发生的任何异常的信息。如果没有异常,它将简单地返回任何先前存在的文件,保留了在上下文中创建的文件也将存在。如果有异常,那么__exit__()方法将尝试保留输出(带有后缀"error")以进行调试,它还将把文件的任何先前版本放回原位。

这在功能上等同于try-except-finally块。但它的优势在于它将相关的应用处理与上下文管理分开。应用处理写在with语句中。上下文问题被放到一个单独的类中。

总结

我们看了类定义的三个特殊方法。__call__()方法用于创建可调用对象。可调用对象用于创建有状态的函数。我们的主要示例是一个记忆化先前结果的函数。

__enter__()__exit__()方法用于创建上下文管理器。上下文用于处理局部化到 with 语句体中的处理。我们的大多数示例包括输入输出处理。然而,Python 提供了许多其他情况,其中局部上下文可能会派上用场。将重点放在创建容器和集合上。

可调用设计考虑和权衡

在设计可调用对象时,我们需要考虑以下事项:

  • 第一个是对象的 API。如果对象需要具有类似函数的接口,那么可调用对象是一个明智的设计方法。使用collections.abc.Callable确保可调用 API 被正确构建,并且它告诉任何阅读代码的人类的意图是什么。

  • 第二个是函数的状态性。Python 中的普通函数没有滞后性——没有保存的状态。然而,可调用对象可以轻松保存状态。记忆化设计模式很好地利用了有状态的可调用对象。

可调用对象的唯一缺点是所需的语法量。普通函数定义更短,因此更不容易出错,更容易阅读。

将已定义的函数迁移到可调用对象很容易,如下所示:

def x(args):
    body

前面的函数可以转换为以下可调用对象:

class X(collections.abc.callable):
    def __call__(self, args):
        body
x= X()

这是在新形式中使函数通过单元测试所需的最小更改集。现有的主体将在新上下文中不经修改地工作。

一旦更改完成,就可以向可调用对象的函数版本添加功能。

上下文管理器设计考虑和权衡

上下文通常用于获取/释放、打开/关闭和锁定/解锁类型的操作对。大多数示例与文件 I/O 相关,Python 中的大多数类似文件的对象已经是适当的上下文管理器。

几乎总是需要上下文管理器来处理任何具有包围基本处理步骤的东西。特别是,任何需要最终close()方法的东西都应该被上下文管理器包装。

一些 Python 库具有打开/关闭操作,但对象不是适当的上下文。例如,shelve模块并不创建适当的上下文。

我们可以(也应该)在shelve文件上使用contextllib.closing()上下文。我们将在第九章中展示这一点,序列化和保存 - JSON,YAML,Pickle,CSV 和 XML

对于我们自己的需要close()方法的类,我们可以使用closing()函数。当面对具有任何类型获取/释放生命周期的类时,我们希望在__init__()或类级open()方法中获取资源,并在close()中释放资源。这样,我们的类就可以很好地与这个closing()函数集成。

以下是一个需要close()函数的类的示例:

with contextlib.closing( MyClass() ) as my_object:
    process( my_object )

contextllib.closing()函数将调用作为参数给定的对象的close()方法。我们可以保证my_object将评估其close()方法。

展望未来

在接下来的两章中,我们将研究用于创建容器和数字的特殊方法。在第六章中,创建容器和集合,我们将研究标准库中的容器和集合。我们还将研究构建一种独特的新类型的容器。在第七章中,创建数字,我们将研究各种数字类型以及如何创建我们自己的数字类型。

第六章:创建容器和集合

我们可以扩展多个 ABC 来创建新类型的集合。ABC 为我们提供了扩展内置容器的设计指南。这些允许我们微调特性或根本定义更精确地适应我们问题域的新数据结构。

我们将研究容器类的 ABC 基础知识。有相当多的抽象用于组装 Python 内置类型,如listtupledictsetfrozenset

我们将回顾涉及成为容器并提供各种容器特性的各种特殊方法。我们将这些分为核心容器方法,与更专门的序列,映射和集合方法分开。

我们将讨论扩展内置容器以添加特性。我们还将研究包装内置容器并通过包装器委托方法到底层容器。

最后,我们将研究构建全新的容器。这是一个具有挑战性的领域,因为 Python 标准库中已经存在着大量有趣和有用的集合算法。为了避免深入的计算机科学研究,我们将构建一个相当无聊的集合。在开始真正的应用程序之前,有必要仔细研究 Cormen,Leiserson,Rivest 和 Stein 的《算法导论》。

最后,我们将总结一些设计考虑因素,这些因素涉及扩展或创建新集合。

集合的 ABC

collections.abc模块提供了丰富的抽象基类,将集合分解为多个离散的特性集。

我们可以成功地使用list类,而不需要深入思考各种特性以及它们与set类或dict类的关系。然而,一旦我们开始研究 ABC,我们就可以看到这些类有一些微妙之处。通过分解每个集合的方面,我们可以看到重叠的领域,这些领域表现为即使在不同的数据结构之间也有一种优雅的多态性。

在基类的底部有一些“一招鲜”的定义。这些是需要一个特殊方法的基类:

  • Container基类要求具体类实现__contains__()方法。这个特殊方法实现了in运算符。

  • Iterable基类需要__iter__()。这个特殊方法被for语句和生成器表达式以及iter()函数使用。

  • Sized基类需要__len__()。这个方法被len()函数使用。实现__bool__()也是明智的,但这不是这个抽象基类所要求的。

  • Hashable基类需要__hash__()。这是hash()函数使用的。如果实现了这个方法,这意味着对象是不可变的。

这些抽象类都用于构建我们应用程序中可以使用的更高级别的、复合的结构的定义。这些复合结构包括SizedIterableContainer的较低级别基类。以下是我们可能在应用程序中使用的一些复合基类:

  • SequenceMutableSequence类基于基础并折叠方法,如index()count()reverse()extend()remove()

  • MappingMutableMapping类折叠方法,如keys()items()values()get(),等等。

  • SetMutableSet类比较和算术运算符来执行集合操作。

如果我们更深入地研究内置集合,我们可以看到 ABC 类定义如何组织我们需要编写或修改的特殊方法。

特殊方法的例子

当查看一个黑杰克Hand对象时,我们对包含有一个有趣的特殊情况。我们经常想知道手中是否有一张王牌。如果我们将Hand定义为list的扩展,那么我们不能要求一个通用的王牌。我们只能要求特定的卡片。我们不想写这样的东西:

any( card(1,suit) for suit in Suits )

这似乎是一个冗长的寻找一手牌中的王牌的方式。

这是一个更好的例子,但可能仍然不太理想:

any( c.rank == 'A' for c in hand.cards )

所以,我们想要这样的东西:

'A' in hand.cards

这意味着我们正在修改Hand对象对list的“包含”含义。我们不是在寻找一个Card实例,我们只是在寻找Card对象的等级属性。我们可以重写__contains__()方法来实现这一点:

def __contains__( self, rank ):
    return any( c.rank==rank for rank in hand.cards )

这使我们可以在手中对给定等级进行更简单的in测试。

类似的设计考虑可以应用于__iter__()__len__()特殊方法。但是要小心。改变len()的语义或集合与for语句的交互方式可能是灾难性的。

使用标准库扩展

我们将看一些已经是标准库一部分的内置类的扩展。这些是扩展或修改内置集合的集合。这些大多数在《Python 3 面向对象编程》等书籍中以一种形式或另一种形式进行了讨论。

我们将看一下以下六个库集合:

  • namedtuple()函数创建具有命名属性的元组子类。我们可以使用这个来代替定义一个完整的类,仅仅为属性值分配名称。

  • deque(注意不寻常的拼写)是一个双端队列,一个类似列表的集合,可以在任一端执行快速的附加和弹出操作。这个类的一部分特性将创建单端堆栈或队列。

  • 在某些情况下,我们可以使用ChainMap来代替合并映射。这是多个映射的视图。

  • OrderedDict集合是一个维护原始键入顺序的映射。

  • defaultdict(注意不寻常的拼写)是一个dict子类,它使用一个工厂函数来为缺失的键提供值。

  • Counter是一个dict子类,可用于计算对象以创建频率表。但实际上,它是一种称为multisetbag的更复杂的数据结构。

我们将看到前述每个集合的示例。从研究库集合中可以学到两个重要的教训:

  • 已经存在且不需要重新发明的东西

  • 如何扩展 ABCs 以向语言添加有趣和有用的结构

此外,阅读库的源代码很重要。源代码将展示给我们许多 Python 面向对象编程技术。除了这些基础知识外,还有更多的模块。它们如下:

  • heapq模块是一组函数,它在现有的list对象上施加了一个堆队列结构。堆队列不变式是在堆中维护的那些项目的集合,以便允许按升序快速检索。如果我们在list结构上使用heapq方法,我们将永远不必显式对列表进行排序。这可能会带来显著的性能改进。

  • array模块是一种为某些类型的值优化存储的序列。这为潜在的大量简单值提供了类似列表的功能。

此外,当然还有支持这些各种数据结构定义的更深层次的计算机科学。

namedtuple()函数

namedtuple()函数从提供的参数创建一个新的类定义。这将有一个类名、字段名和一对可选关键字,用于定义所创建类的行为。

使用namedtuple()将类定义压缩成一个非常简短的简单不可变对象的定义。它使我们不必为了常见情况下想要命名一组固定属性而编写更长更复杂的类定义。

对于像扑克牌这样的东西,我们可能希望在类定义中插入以下代码:

from collections import namedtuple
BlackjackCard = namedtuple('BlackjackCard','rank,suit,hard,soft')

我们定义了一个新类,并提供了四个命名属性:ranksuithardsoft。由于这些对象都是不可变的,我们不必担心一个行为不端的应用程序试图更改BlackjackCard实例的等级。

我们可以使用工厂函数来创建这个类的实例,如下面的代码所示:

def card( rank, suit ):
    if rank == 1:
        return BlackjackCard( 'A', suit, 1, 11 )
    elif 2 <= rank < 11:
        return BlackjackCard( str(rank), suit, rank, rank )
    elif rank == 11:
        return BlackjackCard( 'J', suit, 10, 10 )
    elif rank == 12:
        return BlackjackCard( 'Q', suit, 10, 10 )
    elif rank == 13:
        return BlackjackCard( 'K', suit, 10, 10 )

这将使用正确设置硬和软总数的各种卡等级构建一个BlackjackCard实例。通过填写一个tuple子类的模板来创建一个名为namedtuple的新类,基本上,模板从这种代码开始:

class TheNamedTuple(tuple):
    __slots__ = ()
    _fields = {field_names!r}
    def __new__(_cls, {arg_list}):
        return _tuple.__new__(_cls, ({arg_list}))

模板代码扩展了内置的tuple类。没有什么令人惊讶的。

它将__slots__设置为空元组。管理实例变量有两种方法:__slots____dict__。通过设置__slots__,禁用了__dict__的替代方案,从而无法向该类的对象添加新的实例变量。此外,生成的对象保持在绝对最小的大小。

模板创建了一个名为_fields的类级变量,用于命名字段。{field_names!r}构造是模板文本填充了字段名列表的地方。

模板定义了一个__new__()方法,用于初始化不可变对象。{arg_list}构造是模板填充了用于构建每个实例的参数列表的地方。

还有其他几个方法函数,但这提供了一些关于namedtuple函数在幕后工作的提示。

当然,我们可以对namedtuple类进行子类化以添加功能。但是,我们必须谨慎尝试向namedtuple类添加属性。属性列表被编码在_fields中,以及__new__()的参数。

以下是一个对namedtuple类进行子类化的示例:

BlackjackCard = namedtuple('BlackjackCard','rank,suit,hard,soft')
class AceCard( BlackjackCard ):
    __slots__ = ()
    def __new__( self, rank, suit ):
        return super().__new__( AceCard, 'A', suit, 1, 11 )

我们使用__slots__来确保子类没有__dict__;我们不能添加任何新属性。我们重写了__new__(),这样我们就可以用只有两个值(ranksuit)构建实例,但是填充所有四个值。

deque 类

list对象旨在为容器中的任何元素提供统一的性能。某些操作会有性能惩罚。特别是,列表前端的任何操作(list.insert(0, item)list.pop(0))会产生一些开销,因为列表大小发生了变化,每个元素的位置也发生了变化。

deque——双端队列——旨在为列表的第一个和最后一个元素提供统一的性能。其设计思想是,追加和弹出的速度将比内置的list对象更快。

提示

拼写不规范

类名通常采用标题大小写。然而,deque类不是。

我们为一副牌的设计避免了list对象的潜在性能陷阱,始终从末尾弹出,而不是从开头弹出。

然而,由于我们几乎没有使用list对象的特性,也许像 deque 这样的结构更适合我们的问题。我们只存储卡片,以便可以对其进行洗牌和弹出。除了洗牌之外,我们的应用程序从不通过它们的索引位置引用列表中的元素。

虽然deque.pop()方法可能非常快,但洗牌可能会受到影响。洗牌将对容器进行随机访问,这是 deque 不设计的功能。

为了确认潜在的成本,我们可以使用timeit来比较listdeque的洗牌性能,如下所示:

>>> timeit.timeit('random.shuffle(x)',"""
... import random
... x=list(range(6*52))""")
597.951664149994
>>>
>>> timeit.timeit('random.shuffle(d)',"""
... from collections import deque
... import random
... d=deque(range(6*52))""")      
609.9636979339994

我们使用random.shuffle()调用了timeit。一个在list对象上工作,另一个在 deque 上工作。

这些结果表明,洗牌 deque 只比洗牌list对象慢一点点——大约慢 2%。这种区别微乎其微。我们可以有信心地尝试用deque对象替换list

这种变化的意义在于:

from collections import dequeue
class Deck(dequeue):
    def __init__( self, size=1 ):
        super().__init__()
        for d in range(size):
           cards = [ card(r,s) for r in range(13) for s in Suits ]
            super().extend( cards )
        random.shuffle( self )

我们在Deck的定义中用deque替换了list。否则,该类是相同的。

实际的性能差异是什么?让我们创建 10 万张卡片的牌组并发牌:

>>> timeit.timeit('x.pop()', "x=list(range(100000))", number=100000)
0.032304395994287916
>>> timeit.timeit('x.pop()', "from collections import deque; x=deque(range(100000))", number=100000)
0.013504189992090687

我们使用x.pop()调用了timeit。一个在list上工作,另一个在 deque 上工作。

发牌时间几乎减少了一半(实际上是 42%)。我们从数据结构的微小变化中获得了巨大的节省。

总的来说,选择最佳的数据结构对应用程序很重要。尝试几种变体可以向我们展示什么更有效。

ChainMap 的用例

将地图链接在一起的用例与 Python 的本地与全局定义概念很好地契合。当我们在 Python 中使用一个变量时,首先搜索本地命名空间,然后搜索全局命名空间,按照这个顺序。除了在两个命名空间中搜索变量之外,设置变量在本地命名空间中进行,而不会影响全局命名空间。这种默认行为(没有globalnonlocal语句)也是ChainMap的工作原理。

当我们的应用程序开始运行时,我们经常有来自命令行参数、配置文件、操作系统环境变量以及可能的全局设置的属性。我们希望将这些合并成一个类似字典的结构,以便我们可以轻松地找到一个设置。

我们可能有一个应用程序启动,将几个配置选项的来源组合在一起,例如:

import argparse
import json
import os
parser = argparse.ArgumentParser(description='Process some integers.')
parser.add_argument( "-c", "--configuration", type=open, nargs='?')
parser.add_argument( "-p", "--playerclass", type=str, nargs='?', default="Simple" )
cmdline= parser.parse_args('-p Aggressive'.split())

if cmdline.configuration:
    config_file= json.load( options.configuration )
    options.configuration.close()
else:
    config_file= {}

with open("defaults.json") as installation:
    defaults= json.load( installation )
# Might want to check ~/defaults.json and /etc/thisapp/defaults.json, also.

from collections import ChainMap
options = ChainMap(vars(cmdline), config_file, os.environ, defaults)

前面的代码向我们展示了来自多个来源的配置,例如以下内容:

  • 命令行参数。我们看到一个名为playerclass的令牌参数,但通常还有许多其他参数。

  • 其中一个参数configuration是配置文件的名称,其中包含额外的参数。预计这是以 JSON 格式,读取文件内容。

  • 另外,还有一个defaults.json文件,可以查找配置值的另一个地方。

从前面的来源中,我们可以构建一个单一的ChainMap对象用例,允许在列出的每个位置中查找参数。ChainMap实例用例将按顺序搜索每个映射,寻找给定值。这为我们提供了一个整洁、易于使用的运行时选项和参数来源。

我们将在第十三章 配置文件和持久性 中再次讨论这个问题,以及第十六章 应对命令行

有序字典集合

OrderedDict集合类巧妙地利用了两种存储结构。有一个底层的dict对象类型,将键映射到值。此外,还有一个维护插入顺序的键的双向链表。

OrderedDict的一个常见用途是处理 HTML 或 XML 文件,其中对象的顺序必须保留,但对象可能通过 ID 和 IDREF 属性具有交叉引用。我们可以通过使用 ID 作为字典键来优化对象之间的连接。我们可以使用OrderedDict结构保留源文档的顺序。

我们不想在这里过多地深入 XML 解析。这是第九章的主题,序列化和保存 - JSON、YAML、Pickle、CSV 和 XML

考虑一个 XML 文档的简短示例,其中有几个索引之间的引用网络相当复杂。我们将想象一个简单的微博文档,其中有按顺序排列的条目,这些条目具有 ID,索引具有对原始条目的 IDREFs。

我们将 XML 分成两部分:

<blog>
    <topics> … </topics> <indices> … </indices>
</blog>

将有一个关于主题的部分,另一个关于索引的部分。这是博客的主题部分:

    <topics>
        <entry ID="UUID98766"><title>first</title><body>more words</body></entry>
        <entry ID="UUID86543"><title>second</title><body>words</body></entry>
        <entry ID="UUID64319"><title>third</title><body>text</body></entry>
    </topics>

每个主题都有一系列条目。每个条目都有一个唯一的 ID。我们暗示它们可能属于通用唯一标识符UUID),但我们没有提供实际的例子。

这是博客的一个索引:

    <indices>
        <bytag>
            <tag text="#sometag">
                <entry IDREF="UUID98766"/>
                <entry IDREF="UUID86543"/>
            </tag>
            <tag text="#anothertag">
                <entry IDREF="UUID98766"/>
                <entry IDREF="UUID64319/>

            </tag>
        </bytag>
    </indices>

一个索引按标签呈现博客条目。我们可以看到每个标签都有一个条目列表。每个条目都有对原始微博条目的引用。

当我们解析这个 XML 文档时,我们需要保持主题的原始顺序。但是,我们还必须跟踪每个条目的 ID 作为键。

这是一个技术性的尖峰,将解析文档并构建一个OrderedDict集合:

from collections import OrderedDict
import xml.etree.ElementTree as etree

doc= etree.XML( source ) # **Parse

topics= OrderedDict() # **Gather
for topic in doc.findall( "topics/entry" ):
    topics[topic.attrib['ID']] = topic

for topic in topics: # **Display
    print( topic, topics[topic].find("title").text )

第一部分# Parse将解析 XML 源文档,创建一个ElementTree对象。

第二部分# Gather将遍历 XML 文档中主题部分的条目。每个主题都按 ID 加载到一个主题的OrderedDict集合中。原始顺序保留,以便可以按正确的顺序呈现材料。

最后一部分# Display向我们展示了条目的原始顺序和它们的 ID。

defaultdict子类

普通的dict类型在找不到键时会抛出异常。defaultdict集合类会评估给定的函数,并将该函数的值插入字典中。

提示

请注意拼写不规则

类名通常是 TitleCase。但是,defaultdict类不是。

defaultdict类的一个常见用例是为对象创建索引。当几个对象具有共同的键时,我们可以创建共享此键的对象列表。

这是一个技术性尖峰的一部分,向我们展示了如何累积由庄家明牌索引的结果列表:

outcomes = defaultdict(list)
self.play_hand( table, hand )
outcome= self.get_payout()
outcomes[hand.dealer_card.rank].append(outcome)

outcomes[rank]的每个值将是模拟支付的列表。我们可以对这些进行平均或总结,以总结支付。我们可以计算赢得与损失的次数,或执行其他定量分析,以确定最小化损失和最大化赢利的游戏策略。

在某些情况下,我们可能希望使用defaultdict集合类来提供一个常量值。我们希望写container[key]而不是写container.get(key,"N/A"),如果找不到键,则提供字符串常量值"N/A"。这样做的困难在于,defaultdict类是使用零参数函数创建默认值的。我们不能轻易使用一个常量。

我们可以创建一个零参数的lambda对象。这非常好用。下面是一个例子:

>>> from collections import defaultdict
>>> messages = defaultdict( lambda: "N/A" )
>>> messages['error1']= 'Full Error Text'
>>> messages['other']
'N/A'

默认值被返回,并且键(在这个例子中是'other')被添加到字典中。我们可以通过查找所有值为"N/A"的键来确定输入了多少个新值:

>>> [k for k in messages if messages[k] == "N/A"]
['other']

正如您在前面的输出中看到的,我们找到了被分配默认值"N/A"的键。这通常是正在累积的数据的一个有用的摘要。它向我们展示了所有与默认值相关联的键。

计数集合

defaultdict类最常见的用例之一是在累积事件计数时。我们可能会编写类似这样的代码:

frequency = defaultdict(int)
for k in some_iterator():
    frequency[k] += 1

我们正在计算每个键值ksome_iterator()值序列中出现的次数。

这种用例是如此常见,以至于defaultdict主题有一个变体执行与前面代码中显示的相同操作的Counter。然而,Counter集合比简单的defaultdict类要复杂得多。考虑确定最常见值的附加用例,统计学家称之为模式

我们需要重新构造defaultdict对象中的值以找到模式。这并不困难,但可能会让人恼火,因为这是一个样板代码。它看起来像这样:

by_value = defaultdict(list)
for k in frequency:
    by_value[ frequency[k] ].append(k)

我们创建了第二个字典。这个新的by_value字典的键是频率值。每个键与出现此频率的所有原始some_iterator()值相关联。

然后,我们可以使用以下处理来定位并按出现频率的顺序显示最常见的值:

for freq in sorted(by_value, reverse=True):
    print( by_value[freq], freq )

这将创建一种频率直方图,显示具有给定频率的键值列表和所有这些键值共享的频率计数。

所有这些特性已经是Counter集合的一部分。下面是一个例子,从某些数据源创建一个频率直方图:

from collections import Counter
frequency = Counter(some_iterator())
for k,freq in frequency.most_common():
    print( k, freq )

这个例子向我们展示了如何通过向Counter提供任何可迭代的项目来轻松收集统计数据。它将收集该可迭代项目中值的频率数据。在这种情况下,我们提供了一个名为some_iterator()的可迭代函数。我们可能提供了一个序列或其他集合。

然后,我们可以按照受欢迎程度的降序显示结果。但等等!这还不是全部。

Counter集合不仅仅是defaultdict集合的简单变体。这个名字是误导的。Counter对象实际上是一个"多重集",有时被称为"袋子"。

它是一个类似集合的集合,但允许袋子中的值重复。它不是一个由索引或位置标识的序列;顺序并不重要。它不是一个具有键和值的映射。它就像一个集合,其中的项代表它们自己,顺序并不重要。但它不像一个集合,因为在这种情况下,元素可以重复。

由于元素可以重复,Counter对象用整数计数表示多次出现。因此,它被用作频率表。但它不仅仅是这样。由于一个袋子就像一个集合,我们可以比较两个袋子的元素来创建一个并集或交集。

让我们创建两个袋子:

>>> bag1= Counter("aardwolves")
>>> bag2= Counter("zymologies")
>>> bag1
Counter({'a': 2, 'o': 1, 'l': 1, 'w': 1, 'v': 1, 'e': 1, 'd': 1, 's': 1, 'r': 1})
>>> bag2
Counter({'o': 2, 'm': 1, 'l': 1, 'z': 1, 'y': 1, 'g': 1, 'i': 1, 'e': 1, 's': 1})

我们通过检查一系列字母来构建每个袋子。对于出现多次的字符,有一个大于一的计数。

我们可以轻松地计算两个袋子的并集:

>>> bag1+bag2
Counter({'o': 3, 's': 2, 'l': 2, 'e': 2, 'a': 2, 'z': 1, 'y': 1, 'w': 1, 'v': 1, 'r': 1, 'm': 1, 'i': 1, 'g': 1, 'd': 1})

这向我们展示了两个字符串之间的整套字母。o有三个实例。毫不奇怪,其他字母不那么受欢迎。

我们也可以轻松地计算两个袋子之间的差异:

>>> bag1-bag2
Counter({'a': 2, 'w': 1, 'v': 1, 'd': 1, 'r': 1})
>>> bag2-bag1
Counter({'o': 1, 'm': 1, 'z': 1, 'y': 1, 'g': 1, 'i': 1})

第一个表达式向我们展示了bag1中不在bag2中的字符。

第二个表达式向我们展示了bag2中不在bag1中的字符。请注意,字母obag2中出现了两次,在bag1中出现了一次。差异只移除了bag1中的一个o字符。

创建新类型的集合

我们将看一下我们可能对 Python 内置容器类进行的一些扩展。尽管我们不会展示扩展每个容器的例子。如果这样做,这本书的大小将变得不可控。

我们将选择一个扩展特定容器的例子,并看看这个过程是如何工作的:

  1. 定义需求。这可能包括在维基百科上进行研究,通常从这里开始:en.wikipedia.org/wiki/Data_structure。数据结构的设计可能会很复杂,因为通常存在复杂的边缘情况。

  2. 如果必要,查看collections.abc模块,看看必须实现哪些方法来创建新的功能。

  3. 创建一些测试案例。这也需要仔细研究算法,以确保边缘情况得到适当的覆盖。

  4. 代码。

我们需要强调在尝试发明新类型的数据结构之前,研究基础知识的重要性。除了搜索网络上的概述和摘要外,还需要详细信息。参见 Cormen、Leiserson、Rivest 和 Stein 的《算法导论》,或 Aho、Ullman 和 Hopcroft 的《数据结构与算法》,或 Steven Skiena 的《算法设计手册》。

正如我们之前看到的,ABCs 定义了三种广义的集合:序列、映射和集合。我们有三种设计策略可以用来创建我们自己的新类型的集合:

  • 扩展:这是一个现有的序列。

  • 包装:这是一个现有的序列。

  • 发明:这是一个全新的序列。

原则上,我们可以给出多达九个例子——每种基本类型的集合与每种基本设计策略。我们不会像那样过分强调这个主题。我们将深入研究如何创建新类型的序列,学习如何扩展和包装现有序列。

由于有许多扩展映射(如ChainMapOrderedDictdefaultdictCounter),我们只会轻轻地涉及创建新类型的映射。我们还将深入研究创建一种新类型的有序多重集或袋子。

定义一种新类型的序列

进行统计分析时的一个常见要求是对一组数据进行基本均值、众数和标准偏差的计算。我们的二十一点模拟将产生必须进行统计分析的结果,以查看我们是否真的发明了更好的策略。

当我们模拟一种玩牌策略时,我们应该得到一些结果数据,这些数据将是一系列数字,显示了使用给定策略玩一系列手牌的最终结果。游戏速度从拥挤的桌子上每小时 50 手到独自与庄家时每小时 200 手不等。我们将假设 200 手相当于二小时的二十一点,然后需要休息一下。

我们可以将结果累积到内置的list类中。我们可以通过定义一种新类型的序列来计算均值,其中 N 是x中的元素数:

def mean( outcomes ):
    return sum(outcomes)/len(outcomes)

标准偏差可以通过定义一种新类型的序列来计算:

def stdev( outcomes ):
    n= len(outcomes)
    return math.sqrt( n*sum(x**2 for x in outcomes)-sum(outcomes)**2 )/n

这两个都是相对简单的计算函数,易于使用。然而,随着事情变得更加复杂,这些松散的函数变得不那么有用。面向对象编程的好处之一是将功能与数据绑定在一起。

我们的第一个示例不涉及重写list的任何特殊方法。我们只需对list进行子类化,以添加将计算统计信息的方法。这是一种非常常见的扩展。

我们将在第二个示例中重新审视这一点,以便我们可以修改和扩展特殊方法。这将需要对 ABC 特殊方法进行一些研究,以查看我们需要添加或修改什么,以便我们的新列表子类正确继承内置的list类的所有特性。

因为我们正在研究序列,所以我们还必须处理 Python 的slice表示法。我们将在使用__getitem____setitem____delitem__和切片部分中查看切片是什么以及它是如何在内部工作的。

第二个重要的设计策略是包装。我们将在列表周围创建一个包装器,并看看如何将方法委托给包装的列表。在对象持久性方面,包装具有一些优势,这是第九章的主题,序列化和保存–JSON、YAML、Pickle、CSV 和 XML

我们还可以看看需要从头开始发明新类型序列的事情。

统计列表

将均值和标准偏差特性直接合并到list的子类中是很有意义的。我们可以这样扩展list

class Statslist(list):
    @property
    def mean(self):
        return sum(self)/len(self)
    @property
    def stdev(self):
        n= len(self)
        return math.sqrt( n*sum(x**2 for x in self)-sum(self)**2 )/n

通过对内置的list类进行这种简单扩展,我们可以相对轻松地累积数据并报告统计信息。

我们可以想象一个整体的模拟脚本,看起来像这样。

for s in SomePlayStrategy, SomeOtherStrategy:
    sim = Simulator( s, SimpleBet() )
    data = sim.run( hands=200 )
    print( s.__class__.__name__, data.mean, data.stdev )

选择急切计算与懒惰计算

请注意,我们的计算是懒惰的;它们只在被请求时才执行。这也意味着每次请求时都会执行它们。这可能是一个相当大的开销,取决于这些类的对象在哪种上下文中使用。

将这些统计摘要转换为急切计算实际上是明智的,因为我们知道何时从列表中添加和删除元素。尽管需要更多的编程来创建这些函数的急切版本,但在累积大量数据时,它会提高性能。

急切统计计算的重点是避免计算总和的循环。如果我们急切地计算总和,那么在创建列表时,我们就避免了对数据的额外循环。

当我们查看Sequence类的特殊方法时,我们可以看到数据被添加到、从序列中移除和修改的所有地方。我们可以使用这些信息来重新计算所涉及的两个总和。我们从Python 标准库文档的collections.abc部分开始,8.4.1 节在docs.python.org/3.4/library/collections.abc.html#collections-abstract-base-classes

以下是MutableSequence类所需的方法:__getitem____setitem____delitem____len__insertappendreverseextendpopremove__iadd__。文档还提到了继承的序列方法。但是,由于这些方法适用于不可变序列,我们当然可以忽略它们。

以下是每种方法必须完成的详细信息:

  • __getitem__:没有任何变化,因为状态没有改变。

  • __setitem__:这会改变一个项目。我们需要从每个总和中取出旧项目,并将新项目折叠到每个总和中。

  • __delitem__:这会移除一个项目。我们需要从每个总和中取出旧项目。

  • __len__:这里也没有任何变化,因为状态没有改变。

  • insert:由于这会添加一个新项目,我们需要将其折叠到每个总和中。

  • append:由于这也添加了一个新项目,我们需要将其折叠到每个总和中。

  • reverse:这里也没有任何变化,因为均值或标准偏差的状态没有改变。

  • extend:这会添加许多新项目,例如__init__,因此我们需要在扩展列表之前处理每个项目。

  • pop:这将移除一个项目。我们需要从每个总和中取出旧项目。

  • remove:这也移除一个项目。我们需要从每个总和中取出旧项目。

  • __iadd__:这是+=增强赋值语句,就地加法。它实际上与extend关键字相同。

我们不会详细查看每个方法,因为实际上只有两种用例:

  • 折叠一个新值

  • 移除一个旧值

替换情况是移除和折叠操作的组合。

这是一个急切StatsList类的元素。我们将只看到insertpop

class StatsList2(list):
    """Eager Stats."""
    def __init__( self, *args, **kw ):
        self.sum0 = 0 # len(self)
        self.sum1 = 0 # sum(self)
        self.sum2 = 0 # sum(x**2 for x in self)
        super().__init__( *args, **kw )
        for x in self:
            self._new(x)
    def _new( self, value ):
        self.sum0 += 1
        self.sum1 += value
        self.sum2 += value*value
    def _rmv( self, value ):
        self.sum0 -= 1
        self.sum1 -= value
        self.sum2 -= value*value
    def insert( self, index, value ):
        super().insert( index, value )
        self._new(value)
    def pop( self, index=0 ):
        value= super().pop( index )
        self._rmv(value)
        return value

我们提供了三个内部变量,并附上快速注释,以显示这个类将维护它们的不变性。我们将这些称为“总和不变性”,因为它们每个都包含一种特定类型的总和,在每种状态变化后都保持不变(始终为真)。这种急切计算的本质是_rmv()_new()方法,它们根据列表的变化更新我们的三个内部总和,以确保关系真正保持不变。

当我们移除一个项目,也就是在成功的pop()操作之后,我们必须调整我们的总和。当我们添加一个项目(初始时或通过insert()方法),我们也必须调整我们的总和。我们需要实现的其他方法将利用这两种方法来确保这三个总和不变。我们保证 L.sum0 总是选择急切与懒惰的计算,sum1 总是选择急切与懒惰的计算,sum2 总是选择急切与懒惰的计算

其他方法,如append()extend()remove(),在许多方面与这些方法类似。我们没有展示它们,因为它们很相似。

有一个重要的部分缺失:通过list[index]= value进行单个项目替换。我们将在下一段深入讨论。

我们可以通过处理一些数据来看看这个列表是如何工作的:

>>> sl2 = StatsList2( [2, 4, 3, 4, 5, 5, 7, 9, 10] )
>>> sl2.sum0, sl2.sum1, sl2.sum2
(9, 49, 325)
>>> sl2[2]= 4
>>> sl2.sum0, sl2.sum1, sl2.sum2
(9, 50, 332)
>>> del sl2[-1]
>>> sl2.sum0, sl2.sum1, sl2.sum2
(8, 40, 232)
>>> sl2.insert( 0, -1 )
>>> sl2.pop()                            
-1
>>> sl2.sum0, sl2.sum1, sl2.sum2
(8, 40, 232)

我们可以创建一个列表,并且初始计算出总和。每个后续的变化都会急切地更新各种总和。我们可以更改、移除、插入和弹出一个项目;每个变化都会产生一组新的总和。

剩下的就是添加我们的均值和标准差计算,我们可以这样做:

    @property
    def mean(self):
        return self.sum1/self.sum0
    @property
    def stdev(self):
        return math.sqrt( self.sum0*self.sum2-self.sum1*self.sum1 )/self.sum0

这些利用了已经计算的总和。没有额外的循环来计算这两个统计数据。

使用 getitem(),setitem(),delitem()和切片

StatsList2示例没有显示__setitem__()__delitem__()的实现,因为它们涉及切片。在实现这些方法之前,我们需要查看切片的实现。

序列有两种不同的索引:

  • a[i]:这是一个简单的整数索引。

  • a[i:j]a[i:j:k]:这些是带有start:stop:step值的slice表达式。切片表达式可以非常复杂,有七种不同的变体,适用于不同种类的默认值。

这个基本的语法在三个上下文中都适用:

  • 在一个表达式中,依赖于__getitem__()来获取一个值

  • 在赋值的左侧,依赖于__setitem__()来设置一个值

  • del语句上,依赖于__delitem__()来删除一个值

当我们做类似seq[:-1]的操作时,我们写了一个slice表达式。底层的__getitem__()方法将得到一个slice对象,而不是一个简单的整数。

参考手册告诉我们一些关于切片的事情。一个slice对象将有三个属性:startstopstep。它还将有一个名为indices()的方法函数,它将正确计算切片的任何省略的属性值。

我们可以用一个扩展list的微不足道的类来探索slice对象:

class Explore(list):
    def __getitem__( self, index ):
        print( index, index.indices(len(self)) )
        return super().__getitem__( index )

这个类将输出slice对象和indices()函数结果的值。然后,使用超类实现,以便列表在其他方面表现正常。

有了这个类,我们可以尝试不同的slice表达式,看看我们得到了什么:

>>> x= Explore('abcdefg')
>>> x[:]
slice(None, None, None) (0, 7, 1)
['a', 'b', 'c', 'd', 'e', 'f', 'g']
>>> x[:-1]
slice(None, -1, None) (0, 6, 1)
['a', 'b', 'c', 'd', 'e', 'f']
>>> x[1:]
slice(1, None, None) (1, 7, 1)
['b', 'c', 'd', 'e', 'f', 'g']
>>> x[::2]
slice(None, None, 2) (0, 7, 2)
['a', 'c', 'e', 'g']

在上述的slice表达式中,我们可以看到slice对象有三个属性,这些属性的值直接来自 Python 语法。当我们向indices()函数提供适当的长度时,它会返回一个包含开始、停止和步长值的三元组值。

实现 getitem(),setitem()和 delitem()

当我们实现__getitem__()__setitem__()__delitem__()方法时,我们必须处理两种类型的参数值:intslice

当我们重载各种序列方法时,必须适当处理切片情况。

这是一个与切片一起使用的__setitem__()方法:

    def __setitem__( self, index, value ):
        if isinstance(index, slice):
            start, stop, step = index.indices(len(self))
            olds = [ self[i] for i in range(start,stop,step) ]
            super().__setitem__( index, value )
            for x in olds:
                self._rmv(x)
            for x in value:
                self._new(x)
        else:
            old= self[index]
            super().__setitem__( index, value )
            self._rmv(old)
            self._new(value)

上述方法有两种处理路径:

  • 如果索引是一个slice对象,我们将计算startstopstep值。然后,找到将被移除的所有旧值。然后,我们可以调用超类操作,并合并替换旧值的新值。

  • 如果索引是一个简单的int对象,旧值是一个单个项目,新值也是一个单个项目。

这是与切片一起使用的__delitem__()方法:

    def __delitem__( self, index ):
        # Index may be a single integer, or a slice
        if isinstance(index, slice):
            start, stop, step = index.indices(len(self))
            olds = [ self[i] for i in range(start,stop,step) ]
            super().__delitem__( index )
            for x in olds:
                self._rmv(x)
        else:
            old= self[index]
            super().__delitem__( index )
            self._rmv(old)

上述代码也扩展了切片,以确定可以删除哪些值。如果索引是一个简单的整数,那么就只删除一个值。

当我们向我们的StatsList2类引入适当的切片处理时,我们可以创建列表,它可以做到基本的list类所做的一切,还可以(快速)返回当前列表中的平均值和标准差。

注意

请注意,这些方法函数将分别创建一个临时列表对象olds;这涉及一些开销,可以消除。作为读者的练习,将_rmv()函数前移这些方法,以消除对olds变量的使用,这是有帮助的。

包装列表和委托

我们将看看如何包装 Python 的内置容器类之一。包装现有类意味着一些方法必须委托给底层容器。

由于任何内置集合中都有大量方法,包装集合可能需要相当多的代码。在创建持久类时,包装比扩展具有优势。这是第九章的主题,序列化和保存 - JSON、YAML、Pickle、CSV 和 XML。在某些情况下,我们希望公开内部集合,以避免编写大量委托给内部列表的序列方法。

统计数据类的一个常见限制是它们需要是“仅插入”的。我们将禁用一些方法函数。这是一种需要包装的重大变化。

我们可以设计一个仅支持append__getitem__的类,例如。它将包装一个list类。以下代码可用于从模拟中累积数据:

class StatsList3:
    def __init__( self ):
        self._list= list()
        self.sum0 = 0 # len(self), sometimes called "N"
        self.sum1 = 0 # sum(self)
        self.sum2 = 0 # sum(x**2 for x in self)
    def append( self, value ):
        self._list.append(value)
        self.sum0 += 1
        self.sum1 += value
        self.sum2 += value*value
    def __getitem__( self, index ):
        return self._list.__getitem__( index )
    @property
    def mean(self):
        return self.sum1/self.sum0
    @property
    def stdev(self):
        return math.sqrt( self.sum0*self.sum2-self.sum1*self.sum1 )/self.sum0

这个类有一个内部的_list对象,是底层列表。列表始终最初为空。由于我们只定义了append()作为更新列表的方法,我们可以轻松地维护各种和。我们需要小心地将工作委托给超类,以确保列表在我们的子类处理参数值之前实际更新。

我们可以直接将__getitem__()委托给内部列表对象,而不检查参数或结果。

我们可以像这样使用这个类:

>>> sl3= StatsList3()
>>> for data in 2, 4, 4, 4, 5, 5, 7, 9:
...     sl3.append(data)
...
>>> sl3.mean
5.0
>>> sl3.stdev   
2.0

我们创建了一个空列表,并向列表中添加了项目。由于我们在添加项目时保持和,我们可以非常快速地计算平均值和标准差。

我们并没有有意使我们的类可迭代。我们没有定义__iter__()

因为我们定义了__getitem__(),现在有几件事可以做。我们不仅可以获取项目,而且还会有一个默认实现,允许我们遍历值序列。

这是一个例子:

>>> sl3[0]
2
>>> for x in sl3:
...     print(x)
...
2
4
4
4
5
5
7
9

前面的输出告诉我们,一个围绕集合的最小包装通常足以满足许多用例。

请注意,我们没有使列表可伸缩。如果我们尝试获取大小,它将引发异常,如下所示:

>>> len(sl3)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: object of type 'StatsList3' has no len()

我们可能想要添加一个__len__()方法,将真正的工作委托给内部的_list对象。我们可能还想将__hash__设置为None,因为这是一个可变对象,这是明智的。

我们可能想要定义__contains__()并将此功能委托给内部的_list。这将创建一个提供容器的低级特性集的极简主义容器。

使用__iter__()创建迭代器

当我们的设计涉及包装现有类时,我们需要确保我们的类是可迭代的。当我们查看collections.abc.Iterable的文档时,我们看到我们只需要定义__iter__()来使对象可迭代。__iter__()方法可以返回一个适当的Iterator对象,也可以是一个生成器函数。

创建一个Iterator对象,虽然并不是非常复杂,但很少是必要的。创建生成器函数要简单得多。对于包装的集合,我们应该总是简单地将__iter__()方法委托给底层集合。

对于我们的StatsList3类,它看起来像这样:

    def __iter__(self):
        return iter(self._list)

这个方法函数将迭代委托给底层列表的Iterator

创建一种新的映射

Python 有一个名为dict的内置映射,以及许多库映射。除了collections模块对dict的扩展(defaultdictCounterOrderedDictChainMap)之外,还有几个其他库模块包含类似映射的结构。

shelve模块是另一个映射的重要示例。我们将在第十章中查看这一点,通过 Shelve 存储和检索对象dbm模块类似于shelve,它也将键映射到值。

mailbox模块和email.message模块都有提供类似于dict的接口的类,用于管理本地电子邮件的邮箱结构。

就设计策略而言,我们可以扩展或包装现有的映射,以添加更多功能。

我们可以升级Counter,将存储为频率分布的数据添加均值和标准差。事实上,我们还可以很容易地从这个类中计算中位数和众数。

这里是StatsCounterCounter的扩展,添加了一些统计函数:

from collections import Counter
class StatsCounter(Counter):
    @property
    def mean( self ):
        sum0= sum( v for k,v in self.items() )
        sum1= sum( k*v for k,v in self.items() )
        return sum1/sum0
    @property
    def stdev( self ):
        sum0= sum( v for k,v in self.items() )
        sum1= sum( k*v for k,v in self.items() )
        sum2= sum( k*k*v for k,v in self.items() )
        return math.sqrt( sum0*sum2-sum1*sum1 )/sum0

我们扩展了Counter类,增加了两个新方法,用于计算频率分布的均值和标准差。这些公式与之前在list对象上进行的急切计算的示例类似,尽管它们是在Counter对象上进行的懒惰计算。

我们使用sum0= sum( v for k,v in self.items() )来计算值v的总和,忽略k键。我们可以使用下划线(_)代替k,以强调我们正在忽略键。我们也可以使用sum( v for v in self.values() )来强调我们没有使用键。我们更喜欢sum0sum1的明显并行结构。

我们可以使用这个类来高效地收集统计数据,并对原始数据进行定量分析。我们可以运行多个模拟,使用Counter对象来收集结果。

这里是一个与样本数据列表的交互,代表真实结果:

>>> sc = StatsCounter( [2, 4, 4, 4, 5, 5, 7, 9] )
>>> sc.mean
5.0
>>> sc.stdev
2.0
>>> sc.most_common(1)
[(4, 3)]
>>> list(sorted(sc.elements()))
[2, 4, 4, 4, 5, 5, 7, 9]

most_common()的结果报告为两个元组的序列,包括模式值(4)和值出现的次数(3)。我们可能想要获取前三个值,以将模式与下两个不太流行的项进行比较。我们可以通过sc.most_common(3)这样的评估来获取几个流行的值。

elements()方法重建一个像原始数据一样的list,其中的项被适当地重复。

从排序的元素中,我们可以提取中位数,即中间的项:

    @property
    def median( self ):
        all= list(sorted(sc.elements()))
        return all[len(all)//2]

这种方法不仅是懒惰的,而且在内存上非常奢侈;它仅仅为了找到最中间的项就创建了整个可用值的序列。

虽然简单,但这通常是使用 Python 的一种昂贵的方式。

更聪明的方法是通过sum(self.values())//2来计算有效长度和中点。一旦知道了这一点,就可以按照这个顺序访问键,使用计数来计算给定键的位置范围。最终,将找到一个包含中点的范围的键。

代码看起来像下面这样:

    @property
    def median( self ):
        mid = sum(self.values())//2
        low= 0
        for k,v in sorted(self.items()):
            if low <= mid < low+v: return k
            low += v

我们逐步遍历键和它们出现的次数,以找到最中间的键。请注意,这使用了内部的sorted()函数,这并不是没有成本的。

通过timeit,我们可以得知奢侈版需要 9.5 秒;而更聪明的版本只需要 5.2 秒。

创建一种新的集合类型

创建一个全新的集合需要一些初步工作。我们需要有新的算法或新的内部数据结构,可以显著改进内置集合。在设计新的集合之前,进行彻底的“大 O”复杂度计算非常重要。在实施后使用timeit来确保新的集合确实是内置类的改进也很重要。

例如,我们可能想要创建一个二叉搜索树结构,以保持元素的正确顺序。由于我们希望这是一个可变的结构,我们将不得不执行以下类型的设计活动:

  • 设计基本的二叉树结构

  • 决定基础结构是MutableSequenceMutableMapping还是MutableSet

  • 查看Python 标准库文档的collections.abc部分中集合的特殊方法,第 8.4.1 节。

二叉搜索树有两个分支的节点:一个是“小于”这个节点的所有键的分支,另一个是“大于或等于”这个节点的键的分支。

我们需要检查我们的集合与 Python ABCs 之间的匹配:

  • 这不是一个很好的序列,因为我们通常不会在二叉树中使用索引。我们在搜索树中通常通过它们的键来引用元素。但是,我们可以不太困难地强制使用整数索引。

  • 它可以用于映射的键;这将保持键的排序顺序。这是二叉搜索树的常见用法。

  • 这是一个很好的选择,可以替代setCounter类,因为它可以容易地容纳多个项,使其类似于袋子。

我们将研究创建一个排序的多重集或者一个袋子。这可以包含对象的多个副本。它将依赖于对象之间相对简单的比较测试。

这是一个相当复杂的设计。有很多细节。要创建一个背景,重要的是阅读诸如en.wikipedia.org/wiki/Binary_search_tree这样的文章。在前面的维基百科页面的末尾有许多外部链接,可以提供更多信息。在书籍中学习基本算法也是非常重要的,比如 Cormen、Leiserson、Rivest 和 Stein 的算法导论,Aho、Ullman 和 Hopcroft 的数据结构与算法,或者 Steven Skiena 的算法设计手册

一些设计原理

我们将把集合分成两个类:TreeNodeTree

TreeNode类将包含项目以及morelessparent引用。我们还将把一些功能委托给这个类。

例如,搜索特定项目以使用__contains__()discard()将被委托给节点本身,使用简单的递归。算法的概要如下。

  • 如果目标项目等于自身项目,则返回self

  • 如果目标项目小于self.item,则递归使用less.find(target item)

  • 如果目标项目大于self.item,则递归使用more.find(target.item)

我们将使用类似的委托给 TreeNode 类来完成更多维护树结构的真正工作。

第二个类将是一个Facade,它定义了Tree本身。Facade 设计也可以称为Wrapper;其思想是为特定接口添加所需的功能。我们将提供MutableSet抽象基类所需的外部接口。

如果根节点为空并且始终比所有其他键值小,则算法可能会更简单。这在 Python 中可能会有挑战,因为我们事先不知道节点可能具有的数据类型;我们无法轻松地为根节点定义底部值。相反,我们将使用None的特殊情况值,并忍受检查根节点的if语句的开销。

定义树类

这是对MutableSet类的扩展核心,提供了最小的方法函数:

class Tree(collections.abc.MutableSet):
    def __init__( self, iterable=None ):
        self.root= TreeNode(None)
        self.size= 0
        if iterable:
            for item in iterable:
                self.root.add( item )
    def add( self, item ):
        self.root.add( item )
        self.size += 1
    def discard( self, item ):
        try:
            self.root.more.remove( item )
            self.size -= 1
        except KeyError:
            pass
    def __contains__( self, item ):
        try:
            self.root.more.find( item )
            return True
        except KeyError:
            return False
    def __iter__( self ):
        for item in iter(self.root.more):
            yield item
    def __len__( self ):
        return self.size

初始化类似于Counter对象;这个类将接受一个可迭代对象,并将元素加载到结构中。

add()discard()方法会跟踪整体大小。这样可以通过对树进行递归遍历来节省计算节点的数量。这些方法还将它们的工作委托给树根处的TreeNode对象。

__contains__()特殊方法执行递归查找。它将KeyError异常转换为False返回值。

__iter__()特殊方法是一个生成器函数。它还将真正的工作委托给TreeNode类内的递归迭代器。

我们定义了discard();可变集合要求在尝试丢弃缺失的键时保持沉默。抽象超类提供了remove()的默认实现,如果找不到键,则会引发异常。两种方法函数都必须存在;我们基于remove()定义了discard(),通过消除异常来保持沉默。在某些情况下,基于discard()定义remove()可能更容易,如果发现问题则引发异常。

定义 TreeNode 类

整个Tree类依赖于TreeNode类来处理添加、删除和遍历包中各种项目的详细工作。这个类相当大,所以我们将它分成三个部分呈现。

这是包括查找和遍历节点的第一部分:

import weakref
class TreeNode:
    def __init__( self, item, less=None, more=None, parent=None ):
        self.item= item
        self.less= less
        self.more= more
        if parent != None:
            self.parent = parent
    @property
    def parent( self ):
        return self.parent_ref()
    @parent.setter
    def parent( self, value ):
        self.parent_ref= weakref.ref(value)
    def __repr__( self ):
        return( "TreeNode({item!r},{less!r},{more!r})".format( **self.__dict__ ) )
    def find( self, item ):
        if self.item is None: # Root
            if self.more: return self.more.find(item)
        elif self.item == item: return self
        elif self.item > item and self.less: return self.less.find(item)
        elif self.item < item and self.more: return self.more.find(item)
        raise KeyError
    def __iter__( self ):
        if self.less:
            for item in iter(self.less):
                yield item
        yield self.item
        if self.more:
            for item in iter(self.more):
                yield item

我们定义了节点的基本初始化,有两种变体。我们可以提供尽可能少的项目;我们也可以提供项目、两个子树和父链接。

属性用于确保父属性实际上是一个类似强引用的weakref属性。有关弱引用的更多信息,请参见第二章,“与 Python 无缝集成-基本特殊方法”。我们在TreeNode父对象和其子对象之间有相互引用;这种循环可能使得难以删除TreeNode对象。使用weakref打破了这种循环。

我们看到了find()方法,它从树中执行递归搜索,通过适当的子树寻找目标项目。

__iter__()方法执行所谓的中序遍历,遍历这个节点及其子树。通常情况下,这是一个生成器函数,它从每个子树的迭代器中产生值。虽然我们可以创建一个与我们的Tree类相关联的单独的迭代器类,但当生成器函数可以满足我们的所有需求时,几乎没有什么好处。

这是这个类的下一部分,用于向树中添加一个新节点:

    def add( self, item ):
        if self.item is None: # Root Special Case
            if self.more:
                self.more.add( item )
            else:
                self.more= TreeNode( item, parent=self )
        elif self.item >= item:
            if self.less:
                self.less.add( item )
            else:
                self.less= TreeNode( item, parent=self )
        elif self.item < item:
            if self.more:
                self.more.add( item )
            else:
                self.more= TreeNode( item, parent=self )

这是递归搜索适当位置以添加新节点。这个结构与find()方法相似。

最后,我们有(更复杂的)处理从树中删除节点。这需要一些小心,以重新链接围绕缺失节点的树:

    def remove( self, item ):
        # Recursive search for node
        if self.item is None or item > self.item:
            if self.more:
                self.more.remove(item)
            else:
                raise KeyError
        elif item < self.item:
            if self.less:
                self.less.remove(item)
            else:
                raise KeyError
        else: # self.item == item
            if self.less and self.more: # Two children are present
                successor = self.more._least() 
                self.item = successor.item
                successor.remove(successor.item)
            elif self.less:   # One child on less
                self._replace(self.less)
            elif self.more:  # On child on more
                self._replace(self.more)
            else: # Zero children
                self._replace(None)
    def _least(self):
        if self.less is None: return self
        return self.less._least()
    def _replace(self,new=None):
        if self.parent:
            if self == self.parent.less:
                self.parent.less = new
            else:
                self.parent.more = new
        if new is not None:
            new.parent = self.parent

remove()方法有两部分。第一部分是递归搜索目标节点。

一旦找到节点,有三种情况需要考虑:

  • 当我们删除一个没有子节点的节点时,我们只需删除它,并更新父节点以用None替换链接。

  • 当我们删除一个只有一个子节点的节点时,我们可以将单个子节点上移,以替换父节点下的这个节点。

  • 当有两个子节点时,我们需要重构树。我们找到后继节点(more子树中的最小项)。我们可以用这个后继节点的内容替换要删除的节点。然后,我们可以删除重复的前任后继节点。

我们依赖于两个私有方法。_least()方法对给定树进行递归搜索,找到最小值节点。_replace()方法检查父节点,看它是否应该触及lessmore属性。

演示二叉树集合

我们构建了一个完整的新集合。ABC 定义自动包括了许多方法。这些继承方法可能并不特别高效,但它们被定义了,它们起作用,而且我们没有为它们编写代码。

>>> s1 = Tree( ["Item 1", "Another", "Middle"] )
>>> list(s1)
['Another', 'Item 1', 'Middle']
>>> len(s1)
3
>>> s2 = Tree( ["Another", "More", "Yet More"] )
>>>
>>> union= s1|s2
>>> list(union)
['Another', 'Another', 'Item 1', 'Middle', 'More', 'Yet More']
>>> len(union)
6
>>> union.remove('Another')
>>> list(union)
['Another', 'Item 1', 'Middle', 'More', 'Yet More']

这个例子向我们展示了集合对象的union运算符是如何正常工作的,即使我们没有为它专门提供代码。因为这是一个包,项目也被正确地复制了。

总结

在这一章中,我们看了一些内置类的定义。内置集合是大多数设计工作的起点。我们经常会从tuplelistdictset开始。我们可以利用namedtuple()创建的对tuple的扩展来创建应用程序的不可变对象。

除了这些类,我们还有其他标准库类在collections模式中可以使用:

  • deque

  • ChainMap

  • OrderedDict

  • defaultdict

  • Counter

我们也有三种标准的设计策略。我们可以包装任何这些现有类,或者我们可以扩展一个类。

最后,我们还可以发明一种全新的集合类型。这需要定义一些方法名和特殊方法。

设计考虑和权衡

在处理容器和集合时,我们有一个多步设计策略:

  1. 考虑序列、映射和集合的内置版本。

  2. 考虑集合模块中的库扩展,以及heapqbisectarray等额外内容。

  3. 考虑现有类定义的组合。在许多情况下,tuple对象的列表或dict的列表提供了所需的功能。

  4. 考虑扩展前面提到的类之一,以提供额外的方法或属性。

  5. 考虑将现有结构包装为提供额外方法或属性的另一种方式。

  6. 最后,考虑一个新颖的数据结构。通常情况下,有很多仔细的分析可用。从维基百科这样的文章开始:

en.wikipedia.org/wiki/List_of_data_structures

一旦设计替代方案被确定,剩下的评估部分有两个:

  • 接口与问题域的契合程度如何。这是一个相对主观的判断。

  • 数据结构的性能如何,可以通过timeit来衡量。这是一个完全客观的结果。

避免分析瘫痪是很重要的。我们需要有效地找到合适的集合。

在大多数情况下,最好对工作应用程序进行性能分析,以查看哪种数据结构是性能瓶颈。在某些情况下,考虑数据结构的复杂性因素将在开始实施之前揭示其适用于特定问题类型的适用性。

也许最重要的考虑是:“为了获得最佳性能,避免搜索”。

这就是为什么集合和映射需要可散列对象的原因。可散列对象几乎不需要处理就可以在集合或映射中找到。在列表中通过值(而不是索引)定位一个项目可能需要很长时间。

这是一个使用列表的不良类似集合的比较和使用集合的正确使用的比较:

>>> import timeit
>>> timeit.timeit( 'l.remove(10); l.append(10)', 'l = list(range(20))' )
0.8182099789992208
>>> timeit.timeit( 'l.remove(10); l.add(10)', 'l = set(range(20))' )
0.30278149300283985

我们从列表和集合中删除并添加了一个项目。

显然,滥用列表以执行类似集合的操作会使集合运行时间延长 2.7 倍。

作为第二个例子,我们将滥用列表使其类似映射。这是基于一个真实世界的例子,原始代码中有两个并行列表来模拟映射的键和值。

我们将比较一个适当的映射和两个并行列表,如下所示:

>>> timeit.timeit( 'i= k.index(10); v[i]= 0', 'k=list(range(20)); v=list(range(20))' )
0.6549435159977293
>>> timeit.timeit( 'm[10]= 0', 'm=dict(zip(list(range(20)),list(range(20))))' )
0.0764331009995658

我们使用一个列表来查找一个值,然后在第二个并行列表中设置该值。在另一种情况下,我们只是更新了一个映射。

显然,在两个并行列表上执行索引和更新是一个可怕的错误。通过list.index()定位某物所需的时间是定位映射和哈希值的 8.6 倍。

展望未来

在下一章中,我们将仔细研究内置数字以及如何创建新类型的数字。与容器一样,Python 提供了丰富多样的内置数字。创建新类型的数字时,我们将不得不定义许多特殊方法。

在查看数字之后,我们可以看一些更复杂的设计技术。我们将看看如何创建自己的装饰器,并使用它们来简化类定义。我们还将研究使用混合类定义,这类似于 ABC 定义。

第七章:创建数字

我们可以扩展numbers模块中的 ABC 抽象,以创建新类型的数字。我们可能需要这样做来创建比内置数字类型更精确地适应我们问题域的数字类型。

首先需要查看numbers模块中的抽象,因为它们定义了现有的内置类。在使用新类型的数字之前,了解现有数字是至关重要的。

我们将离题一下,看看 Python 的运算符到方法映射算法。这个想法是,二元运算符有两个操作数;任何一个操作数都可以定义实现该运算符的类。Python 定位相关类的规则对于决定要实现哪些特殊方法至关重要。

基本的算术运算符,如+-*///%**构成了数字操作的基础。还有其他运算符,包括^|&。这些用于整数的位运算处理。它们也用作集合之间的运算符。在这个类别中还有一些运算符,包括<<>>。比较运算符在第二章中已经介绍过,与 Python 基本特殊方法无缝集成。这些包括<><=>===!=。我们将在本章中复习并扩展对比较运算符的研究。

数字还有许多其他特殊方法。这些包括各种转换为其他内置类型。Python 还定义了"就地"赋值与运算符的组合。这些包括+=-=*=/=//=%=**=&=|=^=>>=<<=。这些更适用于可变对象而不是数字。最后,我们将总结一些扩展或创建新数字时涉及的设计考虑。

数字的 ABC

numbers包提供了一系列数字类型,它们都是numbers.Number的实现。此外,fractionsdecimal模块提供了扩展的数字类型:fractions.Fractiondecimal.Decimal

这些定义大致与数学上对各类数字的思考相一致。一篇文章在en.wikipedia.org/wiki/Number_theory上介绍了不同类型数字的基础知识。

重要的是计算机如何实现数学抽象。更具体地说,我们希望确保在数学的抽象世界中可以计算的任何东西都可以使用具体的计算机进行计算。这就是可计算性问题如此重要的原因。"图灵完备"编程语言的理念是它可以计算图灵机可以计算的任何东西。可以在en.wikipedia.org/wiki/Computability_theory找到一篇有用的文章。

Python 定义了以下抽象及其相关的实现类。此外,这些类形成了一个继承层次结构,其中每个抽象类都继承自上面的类。随着我们向下移动列表,类具有更多的特性。由于类很少,它形成了一个而不是一棵树。

  • numbers.Complexcomplex实现

  • numbers.Realfloat实现

  • numbers.Rationalfractions.Fraction实现

  • numbers.Integralint实现

此外,我们还有decimal.Decimal,它有点像float;它不是numbers.Real的适当子类,但有些类似。

提示

虽然这可能是显而易见的,但重复一遍float值仅仅是一个近似值。它不是精确的。

不要对这种事情感到惊讶。以下是使用数字的 ABC近似的一个例子:

>>> (3*5*7*11)/(11*13*17*23*29)

0.0007123135264946712

>>> _*13*17*23*29

105.00000000000001

原则上,我们沿着数字塔向下走,无穷的次序会变得更小。这可能是一个令人困惑的主题。虽然各种抽象定义的数字都是无穷的,但可以证明存在不同的无穷次序。这导致了一个观点,即在原则上,浮点数表示的数字比整数多。从实际上来看,64 位浮点数和 64 位整数具有相同数量的不同值。

除了数字类的定义之外,还有许多在各种类之间的转换。不可能从每种类型转换到其他每种类型,因此我们必须制定一个矩阵,显示可以进行的转换和不能进行的转换。以下是一个总结:

  • complex:这不能转换为任何其他类型。complex值可以分解为realimag部分,两者都是float

  • float:这可以显式转换为任何类型,包括decimal.Decimal。算术运算符不会将float值隐式转换为Decimal

  • Fractions.Fraction:这可以转换为任何其他类型,除了decimal.Decimal。要转换为decimal需要一个两部分的操作:(1)转换为float (2)转换为decimal.Decimal。这会导致近似值。

  • int:这可以转换为任何其他类型。

  • Decimal:这可以转换为任何其他类型。它不会通过算术运算隐式地强制转换为其他类型。

上下转换来自先前显示的数值抽象的塔。

决定使用哪些类型

由于转换,我们看到了以下四个数值处理的一般领域:

  • 复数:一旦我们涉及复杂的数学,我们将使用complexfloat以及cmath模块。我们可能根本不会使用FractionDecimal。然而,没有理由对数值类型施加限制;大多数数字将被转换为复数。

  • 货币:对于与货币相关的操作,我们绝对必须使用Decimal。通常,在进行货币计算时,没有理由将小数值与非小数值混合在一起。有时,我们会使用int值,但没有理由使用floatcomplexDecimal一起工作。记住,浮点数是近似值,在处理货币时是不可接受的。

  • 位操作:对于涉及位和字节处理的操作,我们通常只会使用int,只有int,仅仅是int

  • 传统:广泛而模糊的“其他一切”类别。对于大多数传统数学运算,intfloatFraction是可以互换的。事实上,一个写得很好的函数通常可以是适当的多态的;它可以很好地与任何数值类型一起使用。Python 类型,特别是floatint,将参与各种隐式转换。这使得为这些问题选择特定的数值类型有些无关紧要。

这些通常是问题领域的明显方面。通常很容易区分可能涉及科学或工程和复数的应用程序,以及涉及财务计算、货币和小数的应用程序。在应用程序中尽可能宽容地使用数值类型是很重要的。通过isinstance()测试不必要地缩小类型的领域通常是浪费时间和代码。

方法解析和反射运算符概念

算术运算符(+-*///%**等)都映射到特殊的方法名。当我们提供一个表达式,比如355+113,通用的+运算符将被映射到特定数值类的具体__add__()方法。这个例子将被计算,就好像我们写了355.__add__(113)一样。最简单的规则是,最左边的操作数决定了所使用的运算符的类。

但等等,还有更多!当我们有一个混合类型的表达式时,Python 可能会得到两个特殊方法的实现,每个类一个。考虑7-0.14作为一个表达式。使用左侧的int类,这个表达式将被尝试为7.__sub__(0.14)。这涉及到一个不愉快的复杂性,因为int运算符的参数是一个float0.14,将float转换为int可能会丢失精度。从int向上转换到complex的类型塔不会丢失精度。向下转换类型塔意味着可能会丢失精度。

然而,使用右侧的float版本,这个表达式将被尝试为:0.14.__rsub__(7)。在这种情况下,float运算符的参数是一个int7;将int转换为float不会(通常)丢失精度。(一个真正巨大的int值可能会丢失精度;然而,这是一个技术上的争论,而不是一个一般原则。)

__rsub__()操作是“反射减法”。X.__sub__(Y)操作是预期的方法解析和反射运算符概念A.__rsub__(B)操作是反射方法解析和反射运算符概念;实现方法来自右操作数的类。我们已经看到了以下两条规则:

  • 首先尝试左操作数的类。如果可以,很好。如果操作数返回NotImplemented作为值,那么使用规则 2。

  • 尝试反射运算符的右操作数。如果可以,很好。如果返回NotImplemented,那么它确实没有实现,因此必须引发异常。

值得注意的例外情况是当两个操作数恰好具有子类关系时。这个额外的规则适用于第一对规则之前作为特殊情况:

  • 如果右操作数是左操作数的子类,并且子类为运算符定义了反射特殊方法名称,则将尝试子类的反射运算符。这允许使用子类覆盖,即使子类操作数位于运算符的右侧。

  • 否则,使用规则 1 并尝试左侧。

想象一下,我们写了一个 float 的子类MyFloat。在2.0-MyFloat(1)这样的表达式中,右操作数是左操作数类的子类。由于这种子类关系,将首先尝试MyFloat(1).__rsub__(2.0)。这条规则的目的是给子类优先权。

这意味着一个类必须从其他类型进行隐式转换,必须实现前向和反射运算符。当我们实现或扩展一个数值类型时,我们必须确定我们的类型能够进行的转换。

算术运算符的特殊方法

总共有 13 个二元运算符及其相关的特殊方法。我们将首先关注明显的算术运算符。特殊方法名称与运算符(和函数)匹配,如下表所示:

方法 运算符
object.__add__(self, other) +
object.__sub__(self, other) -
object.__mul__(self, other) *
object.__truediv__(self, other) /
object.__floordiv__(self, other) //
object.__mod__(self, other) %
object.__divmod__(self, other) divmod()
object.__pow__(self, other[, modulo]) pow() 以及 **

是的,有趣的是,各种符号运算符都包括了两个函数。有许多一元运算符和函数,它们具有特殊的方法名称,如下表所示:

方法 运算符
object.__neg__(self) -
object.__pos__(self) +
object.__abs__(self) abs()
object.__complex__(self) complex()
object.__int__(self) int()
object.__float__(self) float()
object.__round__(self[, n]) round()
object.__trunc__(self[, n]) math.trunc()
object.__ceil__(self[, n]) math.ceil()
object.__floor__(self[, n]) math.floor()

是的,这个列表中也有很多函数。我们可以调整 Python 的内部跟踪,看看底层发生了什么。我们将定义一个简单的跟踪函数,它将为我们提供一点点关于发生了什么的可见性:

def trace( frame, event, arg ):
    if frame.f_code.co_name.startswith("__"):
        print( frame.f_code.co_name, frame.f_code.co_filename, event )

当与跟踪帧相关联的代码的名称以"__"开头时,此函数将转储特殊方法名称。我们可以使用以下代码将此跟踪函数安装到 Python 中:

import sys
sys.settrace(trace)

一旦安装,一切都通过我们的trace()函数。我们正在过滤特殊方法名称的跟踪事件。我们将定义一个内置类的子类,以便我们可以探索方法解析规则:

class noisyfloat( float ):
    def __add__( self, other ):
        print( self, "+", other )
        return super().__add__( other )
    def __radd__( self, other ):
        print( self, "r+", other )
        return super().__radd__( other )

这个类只重写了两个操作符的特殊方法名称。当我们添加noisyfloat值时,我们将看到操作的打印摘要。此外,跟踪将告诉我们发生了什么。以下是显示 Python 选择实现给定操作的类的交互:

>>> x = noisyfloat(2)
>>> x+3
__add__ <stdin> call
2.0 + 3
5.0
>>> 2+x
__radd__ <stdin> call
2.0 r+ 2
4.0
>>> x+2.3
__add__ <stdin> call
2.0 + 2.3
4.3
>>> 2.3+x
__radd__ <stdin> call
2.0 r+ 2.3
4.3

x+3,我们看到noisyfloat+int提供了int对象3__add__()方法。这个值被传递给了超类float,它处理了 3 到float的强制转换,并且也进行了加法。2+x展示了右侧noisyfloat版本的操作是如何被使用的。同样,int被传递给了处理float的超类。从x+2.3,我们知道noisyfloat+float使用了左侧的子类。另一方面,2.3+x展示了float+noisyfloat是如何使用右侧的子类和反射的__radd__()操作符。

创建一个数值类

我们将尝试设计一种新的数字类型。当 Python 已经提供了无限精度的整数、有理分数、标准浮点数和货币计算的十进制数时,这并不是一件容易的任务。我们将定义一类“缩放”数字。这些数字包括一个整数值和一个缩放因子。我们可以用这些来进行货币计算。对于世界上许多货币,我们可以使用 100 的比例,并进行最接近的分的所有计算。

缩放算术的优势在于可以通过使用低级硬件指令来非常简单地完成。我们可以将这个模块重写为一个 C 语言模块,并利用硬件速度操作。发明新的缩放算术的缺点在于,decimal包已经非常好地执行了精确的十进制算术。

我们将称这个类为FixedPoint类,因为它将实现一种固定的小数点数。比例因子将是一个简单的整数,通常是 10 的幂。原则上,一个 2 的幂作为缩放因子可能会更快,但不太适合货币。

缩放因子是 2 的幂可以更快的原因是,我们可以用value << scale替换value*(2**scale),用value >> scale替换value/(2**scale)。左移和右移操作通常是比乘法或除法快得多的硬件指令。

理想情况下,缩放因子是 10 的幂,但我们并没有明确强制执行这一点。跟踪缩放幂和与幂相关的比例因子是一个相对简单的扩展。我们可以将 2 存储为幂,并将Creating a numeric class存储为因子。我们已经简化了这个类的定义,只需跟踪因子。

定义 FixedPoint 初始化

我们将从初始化开始,包括将各种类型转换为FixedPoint值,如下所示:

import numbers
import math

class FixedPoint( numbers.Rational ):
    __slots__ = ( "value", "scale", "default_format" )
    def __new__( cls, value, scale=100 ):
        self = super().__new__(cls)
        if isinstance(value,FixedPoint):
            self.value= value.value
            self.scale= value.scale
        elif isinstance(value,int):
            self.value= value
            self.scale= scale
        elif isinstance(value,float):
            self.value= int(scale*value+.5) # Round half up
            self.scale= scale
        else:
            raise TypeError
        digits= int( math.log10( scale ) )
        self.default_format= "{{0:.{digits}f}}".format(digits=digits)
        return self
    def __str__( self ):
        return self.__format__( self.default_format )
    def __repr__( self ):
        return "{__class__.__name__:s}({value:d},scale={scale:d})".format( __class__=self.__class__, value=self.value, scale=self.scale )
    def __format__( self, specification ):
        if specification == "": specification= self.default_format
        return specification.format( self.value/self.scale ) # no rounding
    def numerator( self ):
        return self.value
    def denominator( self ):
        return self.scale

我们的FixedPoint类被定义为numbers.Rational的子类。我们将包装两个整数值,scalevalue,并遵循分数的一般定义。这需要大量的特殊方法定义。初始化是为了一个不可变的对象,所以它重写了__new__()而不是__init__()。它定义了有限数量的插槽,以防止添加任何额外的属性。初始化包括以下几种转换:

  • 如果我们得到另一个FixedPoint对象,我们将复制内部属性以创建一个新的FixedPoint对象,它是原始对象的克隆。它将有一个唯一的 ID,但我们可以确信它具有相同的哈希值并且比较相等,使得克隆在很大程度上是不可区分的。

  • 当给定整数或有理数值(intfloat的具体类),这些值被用来设置valuescale属性。

  • 我们可以添加处理decimal.Decimalfractions.Fraction的情况,以及解析输入字符串值。

我们定义了三个特殊方法来生成字符串结果:__str__()__repr__()__format__()。对于格式操作,我们决定利用格式规范语言的现有浮点特性。因为这是一个有理数,我们需要提供分子和分母方法。

请注意,我们也可以从现有的fractions.Fraction类开始。还要注意,我们在舍入规则上玩得很快。在将此类应用于特定问题域之前,这也应该以合理的方式定义。

定义 FixedPoint 二进制算术运算符

定义新类别数字的整个原因是为了重载算术运算符。每个FixedPoint对象有两部分:valuescale。我们可以这样说:定义 FixedPoint 二进制算术运算符

请注意,我们已经使用正确但低效的浮点表达式在下面的示例中解出了代数。我们将讨论稍微更有效的纯整数操作。

加法(和减法)的一般形式是这样的:定义 FixedPoint 二进制算术运算符。但它创建了一个有很多无用精度的结果。

想象一下添加 9.95 和 12.95。我们将(原则上)有 229000/10000。这可以正确地减少为 2290/100。问题是它也减少为 229/10,这不再是分。我们希望避免以一般方式减少分数,而尽可能坚持分或毫。

我们可以确定定义 FixedPoint 二进制算术运算符有两种情况:

  • 比例因子匹配:在这种情况下,总和是定义 FixedPoint 二进制算术运算符。当添加FixedPoint和普通整数时,这也可以工作,因为我们可以强制普通整数具有所需的比例因子。

  • 比例因子不匹配:正确的做法是产生一个具有两个输入值的最大比例因子的结果,定义 FixedPoint 二进制算术运算符。从这里,我们可以计算两个比例因子,定义 FixedPoint 二进制算术运算符定义 FixedPoint 二进制算术运算符。其中一个比例因子将是 1,另一个将小于 1。我们现在可以用一个公共值在分母上相加。代数上,它是定义 FixedPoint 二进制算术运算符。这可以进一步优化为两种情况,因为一个因子是 1,另一个是 10 的幂。

我们实际上不能优化乘法。它本质上是定义 FixedPoint 二进制算术运算符。当我们相乘FixedPoint值时,精度确实会增加。

除法是乘以倒数,定义 FixedPoint 二进制算术运算符。如果 A 和 B 具有相同的比例,这些值将取消,以便我们确实有一个方便的优化可用。然而,这将把比例从分变为整,这可能不合适。前向运算符,围绕类似的样板构建,看起来像这样:

    def __add__( self, other ):
        if not isinstance(other,FixedPoint):
            new_scale= self.scale
            new_value= self.value + other*self.scale
        else:
            new_scale= max(self.scale, other.scale)
            new_value= (self.value*(new_scale//self.scale)
            + other.value*(new_scale//other.scale))
        return FixedPoint( int(new_value), scale=new_scale )
    def __sub__( self, other ):
        if not isinstance(other,FixedPoint):
            new_scale= self.scale
            new_value= self.value - other*self.scale
        else:
            new_scale= max(self.scale, other.scale)
            new_value= (self.value*(new_scale//self.scale)
            - other.value*(new_scale//other.scale))
        return FixedPoint( int(new_value), scale=new_scale )
    def __mul__( self, other ):
        if not isinstance(other,FixedPoint):
            new_scale= self.scale
            new_value= self.value * other
        else:
            new_scale= self.scale * other.scale
            new_value= self.value * other.value
        return FixedPoint( int(new_value), scale=new_scale )
    def __truediv__( self, other ):
        if not isinstance(other,FixedPoint):
            new_value= int(self.value / other)
        else:
            new_value= int(self.value / (other.value/other.scale))
        return FixedPoint( new_value, scale=self.scale )
    def __floordiv__( self, other ):
        if not isinstance(other,FixedPoint):
            new_value= int(self.value // other)
        else:
            new_value= int(self.value // (other.value/other.scale))
        return FixedPoint( new_value, scale=self.scale )
    def __mod__( self, other ):
        if not isinstance(other,FixedPoint):
            new_value= (self.value/self.scale) % other
        else:
            new_value= self.value % (other.value/other.scale)
        return FixedPoint( new_value, scale=self.scale )
    def __pow__( self, other ):
        if not isinstance(other,FixedPoint):
            new_value= (self.value/self.scale) ** other
        else:
            new_value= (self.value/self.scale) ** (other.value/other.scale)
        return FixedPoint( int(new_value)*self.scale, scale=self.scale )

对于简单的加法、减法和乘法情况,我们提供了可以优化以消除一些相对较慢的浮点中间结果的版本。

对于两个除法,__mod__()__pow__()方法,我们没有进行任何优化来尝试消除通过浮点除法引入的噪音。相反,我们提供了一个可用于一套单元测试的工作 Python 实现,作为优化和重构的基础。

重要的是要注意,除法操作可以正确地减少比例因子。但这可能是不希望的。在进行货币工作时,我们可能会将货币汇率(美元)除以非货币值(小时)以获得每小时美元的结果。正确的答案可能没有相关的小数位,这将是 1 的比例,但我们可能希望强制该值具有以分为单位的比例为 100。该实现确保左操作数决定所需的小数位数。

定义 FixedPoint 一元算术运算符

以下是一元运算符方法函数:

    def __abs__( self ):
        return FixedPoint( abs(self.value), self.scale )
    def __float__( self ):
        return self.value/self.scale
    def __int__( self ):
        return int(self.value/self.scale)
    def __trunc__( self ):
        return FixedPoint( math.trunc(self.value/self.scale), self.scale )
    def __ceil__( self ):
        return FixedPoint( math.ceil(self.value/self.scale), self.scale )
    def __floor__( self ):
        return FixedPoint( math.floor(self.value/self.scale), self.scale )
    def __round__( self, ndigits ):
        return FixedPoint( round(self.value/self.scale, ndigits=0), self.scale )
    def __neg__( self ):
        return FixedPoint( -self.value, self.scale )
    def __pos__( self ):
        return self

对于__round__()__trunc__()__ceil__()__floor__()运算符,我们已将工作委托给 Python 库函数。有一些潜在的优化,但我们采取了创建浮点近似值并使用它来创建所需结果的懒惰方式。这一系列方法确保我们的FixedPoint对象将与许多算术函数一起使用。是的,Python 中有很多运算符。这并不是整套。我们还没有涵盖比较或位操作符。

实现 FixedPoint 反射运算符

反射运算符在以下两种情况下使用:

  • 右操作数是左操作数的子类。在这种情况下,首先尝试反射运算符,以确保子类覆盖父类。

  • 左操作数的类没有实现所需的特殊方法。在这种情况下,将使用右操作数的反射特殊方法。

以下表格显示了反射特殊方法和运算符之间的映射关系。

方法 运算符
object.__radd__(self, other) +
object.__rsub__(self, other) -
object.__rmul__(self, other) *
object.__rtruediv__(self, other) /
object.__rfloordiv__(self, other) //
object.__rmod__(self, other) %
object.__rdivmod__(self, other) divmod()
object.__rpow__(self, other[, modulo]) pow() 以及 **

这些反射操作特殊方法也围绕一个常见的样板构建。由于这些是反射的,减法、除法、模数和幂运算中的操作数顺序很重要。对于加法和乘法等可交换操作,顺序并不重要。以下是反射运算符:

    def __radd__( self, other ):
        if not isinstance(other,FixedPoint):
            new_scale= self.scale
            new_value= other*self.scale + self.value
        else:
            new_scale= max(self.scale, other.scale)
            new_value= (other.value*(new_scale//other.scale)
            + self.value*(new_scale//self.scale))
        return FixedPoint( int(new_value), scale=new_scale )
    def __rsub__( self, other ):
        if not isinstance(other,FixedPoint):
            new_scale= self.scale
            new_value= other*self.scale - self.value
        else:
            new_scale= max(self.scale, other.scale)
            new_value= (other.value*(new_scale//other.scale)
            - self.value*(new_scale//self.scale))
        return FixedPoint( int(new_value), scale=new_scale )
    def __rmul__( self, other ):
        if not isinstance(other,FixedPoint):
            new_scale= self.scale
            new_value= other*self.value
        else:
            new_scale= self.scale*other.scale
            new_value= other.value*self.value
        return FixedPoint( int(new_value), scale=new_scale )
    def __rtruediv__( self, other ):
        if not isinstance(other,FixedPoint):
            new_value= self.scale*int(other / (self.value/self.scale))
        else:
            new_value= int((other.value/other.scale) / self.value)
        return FixedPoint( new_value, scale=self.scale )
    def __rfloordiv__( self, other ):
        if not isinstance(other,FixedPoint):
            new_value= self.scale*int(other // (self.value/self.scale))
        else:
            new_value= int((other.value/other.scale) // self.value)
        return FixedPoint( new_value, scale=self.scale )
    def __rmod__( self, other ):
        if not isinstance(other,FixedPoint):
            new_value= other % (self.value/self.scale)
        else:
            new_value= (other.value/other.scale) % (self.value/self.scale)
        return FixedPoint( new_value, scale=self.scale )
    def __rpow__( self, other ):
        if not isinstance(other,FixedPoint):
            new_value= other ** (self.value/self.scale)
        else:
            new_value= (other.value/other.scale) ** self.value/self.scale
        return FixedPoint( int(new_value)*self.scale, scale=self.scale )

我们尝试使用与正向运算符相同的数学。这样做的想法是以简单的方式交换操作数。这是最常见的情况。使正向和反向方法的文本相匹配可以简化代码检查。

与正向运算符一样,我们保持除法、模数和幂运算符的简单,以避免优化。这里显示的版本可以通过将其转换为浮点近似值然后转回FixedPoint值来引入噪音。

实现 FixedPoint 比较运算符

以下是六个比较运算符和实现它们的特殊方法:

方法 运算符
object.__lt__(self, other) <
object.__le__(self, other) <=
object.__eq__(self, other) ==
object.__ne__(self, other) !=
object.__gt__(self, other) >
object.__ge__(self, other) >=

is运算符比较对象 ID。我们无法有意义地覆盖它,因为它独立于任何特定的类。in比较运算符由object.__contains__( self, value )实现。这对于数值没有意义。

请注意,相等性测试是一个微妙的问题。由于浮点数是近似值,我们必须非常小心,避免直接使用浮点值进行相等性测试。我们真的需要比较值是否在一个小范围内,即 epsilon。它永远不应该被写成 a == b。比较浮点近似值的一般方法应该是 abs(a-b) <= eps。或者更正确地说,abs(a-b)/a <= eps

在我们的 FixedPoint 类中,比例表示两个值需要多接近,以便将一个 float 值视为相等。对于比例为 100,epsilon 可以是 0.01。我们实际上会更保守一些,当比例为 100 时,使用 0.005 作为比较的基础。

此外,我们必须决定 FixedPoint(123, 100) 是否应该等于 FixedPoint(1230, 1000)。虽然它们在数学上是相等的,但一个值是以分为单位,另一个值是以毫为单位。这可以被视为关于两个数字不同精度的声明;额外的有效数字的存在可能表明它们不应该简单地看起来相等。如果我们遵循这种方法,那么我们需要确保哈希值也是不同的。

我们认为区分比例对于我们的应用程序不合适。我们希望 FixedPoint(123, 100) 等于 FixedPoint(1230, 1000)。这也是推荐的 __hash__() 实现的假设。以下是我们的 FixedPoint 类比较的实现:

    def __eq__( self, other ):
        if isinstance(other, FixedPoint):
            if self.scale == other.scale:
                return self.value == other.value
            else:
                return self.value*other.scale//self.scale == other.value
        else:
            return abs(self.value/self.scale - float(other)) < .5/self.scale
    def __ne__( self, other ):
        return not (self == other)
    def __le__( self, other ):
        return self.value/self.scale <= float(other)
    def __lt__( self, other ):
        return self.value/self.scale <  float(other)
    def __ge__( self, other ):
        return self.value/self.scale >= float(other)
    def __gt__( self, other ):
        return self.value/self.scale >  float(other)

每个比较函数都容忍一个不是 FixedPoint 值的值。唯一的要求是另一个值必须有一个浮点表示。我们已经为 FixedPoint 对象定义了一个 __float__() 方法,所以当比较两个 FixedPoint 值时,比较操作将完全正常工作。

我们不需要写所有六个比较。@functools.total_ordering 装饰器可以从只有两个 FixedPoint 值生成缺失的方法。我们将在第八章中看到这一点,装饰器和混入 - 横切面方面

计算数值哈希

我们确实需要正确定义 __hash__() 方法。有关计算数值类型的哈希值的信息,请参阅 Python 标准库 第 4.4.4 节。该部分定义了一个 hash_fraction() 函数,这是我们正在做的事情的推荐解决方案。我们的方法如下:

    def __hash__( self ):
        P = sys.hash_info.modulus
        m, n = self.value, self.scale
        # Remove common factors of P.  (Unnecessary if m and n already coprime.)
        while m % P == n % P == 0:
            m, n = m // P, n // P
        if n % P == 0:
            hash_ = sys.hash_info.inf
        else:
            # Fermat's Little Theorem: pow(n, P-1, P) is 1, so
            # pow(n, P-2, P) gives the inverse of n modulo P.
            **hash_ = (abs(m) % P) * pow(n, P - 2, P) % P
        if m < 0:
            hash_ = -hash_
        if hash_ == -1:
            hash_ = -2
        return hash_

这将一个两部分的有理分数值减少到一个单一的标准化哈希。这段代码是从参考手册复制过来的,稍作修改。计算的核心部分,即高亮部分,将分子乘以分母的倒数。实际上,它执行了分子除以分母的 mod P。我们可以优化这个,使它更具体化到我们的问题域。

首先,我们可以(也应该)修改我们的 __new__() 方法,以确保比例不为零,消除了对 sys.hash_info.inf 的任何需求。其次,我们应该明确限制比例因子的范围小于 sys.hash_info.modulus(通常为 64 位计算机)。我们可以消除去除 P 的常见因素的需要。这将使哈希简化为 hash_ = (abs(m) % P) * pow(n, P - 2, P) % P,符号处理和特殊情况 -1 映射到 -2。

最后,我们可能希望记住任何哈希计算的结果。这需要一个额外的插槽,只有在第一次请求哈希时才填充。pow(n, P - 2, P) 表达式相对昂贵,我们不希望计算它的频率超过必要的次数。

设计更有用的四舍五入

我们在四舍五入的演示中进行了截断。我们定义了round()trunc()的必需函数,没有进一步的解释。这些定义是抽象超类的最低要求。然而,这些定义对我们的目的来说还不够。

要处理货币,我们经常会有类似以下的代码:

>>> price= FixedPoint( 1299, 100 )
>>> tax_rate= FixedPoint( 725, 1000 )
>>> price * tax_rate
FixedPoint(941775,scale=100000)

然后,我们需要将这个值四舍五入到100的比例,得到一个值为942。我们需要一些方法,将一个数字四舍五入(以及截断)到一个新的比例因子。以下是一个四舍五入到特定比例的方法:

    def round_to( self, new_scale ):
        f = new_scale/self.scale
        return FixedPoint( int(self.value*f+.5), scale=new_scale )

以下代码允许我们正确地重新调整值:

>>> price= FixedPoint( 1299, 100 )
>>> tax_rate= FixedPoint( 725, 1000 )
>>> tax= price * tax_rate
>>> tax.round_to(100)
FixedPoint(942,scale=100)

这表明我们有一组最小的函数来计算货币。

实现其他特殊方法

除了核心算术和比较运算符,我们还有一组额外的运算符(通常)仅为numbers.Integral值定义。由于我们不定义整数值,我们可以避免这些特殊方法:

方法 运算符
object.__lshift__(self, other) <<
object.__rshift__(self, other) >>
object.__and__(self, other) &
object.__xor__(self, other) ^
object.__or__(self, other) `

此外,还有这些运算符的反射版本:

方法 运算符
object.__rlshift__(self, other) <<
object.__rrshift__(self, other) >>
object.__rand__(self, other) &
object.__rxor__(self, other) ^
object.__ror__(self, other) `

此外,还有一个用于值的按位取反的一元运算符:

方法 运算符
object.__invert__(self) ~

有趣的是,其中一些运算符也适用于集合,以及整数。它们不适用于我们的有理数值。定义这些运算符的原则与其他算术运算符相同。

使用就地运算符进行优化

通常,数字是不可变的。然而,数字运算符也用于可变对象。例如,列表和集合对一些定义的增强赋值运算符做出响应。作为一种优化,一个类可以包括所选运算符的就地版本。这些方法实现了可变对象的增强赋值语句。请注意,这些方法预期以return self结尾,以便与普通赋值兼容。

方法 运算符
object.__iadd__(self, other) +=
object.__isub__(self, other) -=
object.__imul__(self, other) *=
object.__itruediv__(self, other) /=
object.__ifloordiv__(self, other) //=
object.__imod__(self, other) %=
object.__ipow__(self, other[, modulo]) **=
object.__ilshift__(self, other) <<=
object.__irshift__(self, other) >>=
object.__iand__(self, other) &=
object.__ixor__(self, other) ^=
object.__ior__(self, other) `

由于我们的FixedPoint对象是不可变的,我们不应该定义这些方法。暂时离开这个例子,我们可以看到就地运算符的更典型的用法。我们可以很容易地为我们的 Blackjack Hand对象定义一些就地运算符。我们可能希望将此定义添加到Hand中,如下所示:

    def __iadd__( self, aCard ):
        self._cards.append( aCard )
        return self

这使我们能够使用以下代码处理hand

player_hand += deck.pop()

这似乎是一种优雅的方式来表明hand已经更新为另一张牌。

总结

我们已经看过内置的数字类型。我们还看过了发明新的数字类型所需的大量特殊方法。与 Python 的其余部分无缝集成的专门的数字类型是该语言的核心优势之一。这并不意味着工作容易。只是在正确完成时使其优雅和有用。

设计考虑和权衡

在处理数字时,我们有一个多步设计策略:

  1. 考虑complexfloatint的内置版本。

  2. 考虑库扩展,如decimalfractions。对于财务计算,必须使用decimal;没有其他选择。

  3. 考虑使用以上类之一扩展额外的方法或属性。

  4. 最后,考虑一个新颖的数字。这是特别具有挑战性的,因为 Python 提供的可用数字种类非常丰富。

定义新数字涉及几个考虑:

  • 完整性和一致性:新数字必须执行完整的操作集,并且在所有类型的表达式中表现一致。这实际上是一个问题,即正确实现这种新类型的可计算数字的形式数学定义。

  • 与问题域的契合:这个数字真的适合吗?它是否有助于澄清解决方案?

  • 性能:与其他设计问题一样,我们必须确保我们的实现足够高效,以至于值得编写所有这些代码。例如,本章中的示例使用了一些效率低下的浮点运算,可以通过进行更多的数学运算而不是编码来进行优化。

展望未来

下一章是关于使用装饰器和混入来简化和规范类设计。我们可以使用装饰器来定义应该存在于多个类中的特性,这些特性不在简单的继承层次结构中。同样,我们可以使用混入类定义来从组件类定义中创建完整的应用程序类。有一个有助于定义比较运算符的装饰器是@functools.total_ordering装饰器。

第八章:装饰器和混入-横切面

软件设计通常具有适用于多个类、函数或方法的方面。我们可能有一个技术方面,例如日志记录、审计或安全,必须一致地实现。在面向对象编程中,重用功能的一般方法是通过类层次结构进行继承。然而,继承并不总是奏效。软件设计的一些方面与类层次结构正交。这些有时被称为“横切关注点”。它们横跨类,使设计变得更加复杂。

装饰器提供了一种定义功能的方式,该功能不受继承层次结构的约束。我们可以使用装饰器来设计应用程序的一个方面,然后将装饰器应用于类、方法或函数。

此外,我们可以有纪律性地使用多重继承来创建横切面。我们将考虑基类加上混入类定义来引入特性。通常,我们将使用混入类来构建横切面。

重要的是要注意,横切面很少与手头的应用程序有关。它们通常是通用的考虑因素。日志记录、审计和安全的常见示例可以被视为与应用程序细节分开的基础设施。

Python 带有许多装饰器,我们可以扩展这个标准的装饰器集。有几种不同的用例。我们将研究简单的函数装饰器、带参数的函数装饰器、类装饰器和方法装饰器。

类和含义

对象的一个基本特征是它们可以被分类。每个对象都属于一个类。这是对象与类之间的简单直接关系,具有简单的单继承设计。

使用多重继承,分类问题可能变得复杂。当我们看真实世界的对象,比如咖啡杯,我们可以将它们分类为容器而不会遇到太多困难。毕竟,这是它们的主要用例。它们解决的问题是容纳咖啡。然而,在另一个情境中,我们可能对其他用例感兴趣。在装饰性的陶瓷马克杯收藏中,我们可能更感兴趣的是尺寸、形状和釉面,而不是杯子的容纳咖啡的方面。

大多数对象与一个类有一个直接的is-a关系。在我们的咖啡杯问题领域中,桌子上的杯子既是咖啡杯,也是一个容器。对象还可以与其他类有几个acts-as关系。杯子作为一个陶瓷艺术品,具有尺寸、形状和釉面属性。杯子作为一个纸张重量,具有质量和摩擦属性。通常,这些其他特性可以被视为混入类,并为对象定义了额外的接口或行为。

在进行面向对象设计时,通常会确定is-a类和该类定义的基本方面。其他类可以混入对象也将具有的接口或行为。我们将看看类是如何构建和装饰的。我们将从函数定义和装饰开始,因为这比类构建要简单一些。

构建函数

我们分两个阶段构建函数。第一阶段是使用原始定义的def语句。

提示

从技术上讲,可以使用 lambda 和赋值来构建函数;我们将避免这样做。

def语句提供了名称、参数、默认值、docstring、代码对象和其他一些细节。函数是由 11 个属性组成的集合,在Python 语言参考的第 3.2 节中定义了标准类型层次结构。参见docs.python.org/3.3/reference/datamodel.html#the-standard-type-hierarchy

第二阶段是将装饰器应用于原始定义。当我们将装饰器(@d)应用于函数(F)时,效果就好像我们创建了一个新函数,构建函数。名称是相同的,但功能可能会有所不同,具体取决于已添加、删除或修改的功能类型。然后,我们将编写以下代码:

@decorate
def function():
    pass

装饰器紧跟在函数定义的前面。发生的情况是:

def function():
    pass
function= decorate( function )

装饰器修改函数定义以创建一个新函数。以下是函数的属性列表:

属性 内容
__doc__ 文档字符串,或None
__name__ 函数的原始名称。
__module__ 函数定义所在的模块的名称,或None
__qualname__ 函数的完全限定名称,__module__.__name__
__defaults__ 默认参数值,如果没有默认值则为 none。
__kwdefaults__ 关键字参数的默认值。
__code__ 代表编译函数体的代码对象。
__dict__ 函数属性的命名空间。
__annotations__ 参数的注释,包括返回注释'return'
__globals__ 函数定义所在模块的全局命名空间;这用于解析全局变量,是只读的。
__closure__ 函数的自由变量的绑定,或者没有。它是只读的。

除了__globals____closure__之外,装饰器可以改变这些属性中的任何一个。然而,我们将在后面看到,深度修改这些属性是不切实际的。

实际上,装饰通常涉及定义一个包装现有函数的新函数。可能需要复制或更新一些先前的属性。这为装饰器可以做什么以及应该做什么定义了一个实际的限制。

构建类

类构造是一组嵌套的两阶段过程。使类构造更复杂的是类方法的引用方式,涉及到多步查找。对象的类将定义一个方法解析顺序MRO)。这定义了如何搜索基类以定位属性或方法名称。MRO 沿着继承层次结构向上工作;这意味着子类名称会覆盖超类名称。这种实现方法搜索符合我们对继承含义的期望。

类构造的第一阶段是原始定义的class语句。这个阶段涉及到元类的评估,然后在class内部执行一系列赋值和def语句。类内的每个def语句都会扩展为一个嵌套的两阶段函数构造,如前面所述。装饰器可以应用到每个方法函数上,作为构建类的过程的一部分。

类构造的第二阶段是将整体类装饰器应用于类定义。通常,decorator函数可以添加特性。通常更常见的是添加属性而不是添加方法。然而,我们会看到一些装饰器也可以添加方法函数。

从超类继承的特性显然不能通过装饰器进行修改,因为它们是通过方法解析查找惰性解析的。这导致了一些重要的设计考虑。我们通常通过类和混合类来引入方法。我们只能通过装饰器或混合类定义来引入属性。以下是一些为类构建的属性列表。一些额外的属性是元类的一部分;它们在下表中描述:

属性 内容
__doc__ 类的文档字符串,如果未定义则为None
__name__ 类名
__module__ 定义类的模块名称
__dict__ 包含类命名空间的字典
__bases__ 一个元组(可能为空或单个元素),包含基类,按照它们在基类列表中的出现顺序;它用于计算方法解析顺序
__class__ 这个类的超类,通常是type

一些作为类的一部分的额外方法函数包括__subclasshook____reduce____reduce_ex__,它们是pickle接口的一部分。

一些类设计原则

在定义一个类时,我们有以下三个属性和方法的来源:

  • 类语句

  • 应用于类定义的装饰器

  • 最后给出的基类与混合类

我们需要意识到每个属性的可见级别。class语句是最明显的属性和方法来源。混合类和基类比类体稍微不那么明显。确保基类名称澄清其作为基本的角色是有帮助的。我们试图将基类命名为现实世界的对象。

混合类通常会定义类的额外接口或行为。清楚地了解混合类如何用于构建最终的类定义是很重要的。虽然docstring类是其中的重要部分,但整个模块docstring也很重要,可以展示如何从各个部分组装一个合适的类。

在编写class语句时,基本的超类最后列出,混合类在其之前列出。这不仅仅是约定。最后列出的类是基本的is-a类。将装饰器应用于类会导致一些更加晦涩的特性。通常,装饰器的作用相对较小。对一个或几个特性的强调有助于澄清装饰器的作用。

面向方面的编程

面向方面的编程(AOP)的部分内容与装饰器相关。我们在这里的目的是利用一些面向方面的概念来帮助展示 Python 中装饰器和混合类的目的。横切关注的概念对 AOP 至关重要。以下是一些额外的背景信息:en.wikipedia.org/wiki/Cross-cutting_concern。以下是一些常见的横切关注的例子:

  • 日志记录:我们经常需要在许多类中一致地实现日志记录功能。我们希望确保记录器的命名一致,并且日志事件以一致的方式遵循类结构。

  • 可审计性:日志主题的一个变种是提供一个审计跟踪,显示可变对象的每个转换。在许多面向商业的应用程序中,交易是代表账单或付款的业务记录。业务记录处理过程中的每个步骤都需要进行审计,以显示处理过程中没有引入错误。

  • 安全性:我们的应用程序经常会有安全方面的需求,涵盖每个 HTTP 请求和网站下载的每个内容。其目的是确认每个请求都涉及经过身份验证的用户,该用户被授权进行请求。必须一致使用 Cookie、安全套接字和其他加密技术,以确保整个 Web 应用程序的安全。

一些语言和工具对 AOP 有深入的正式支持。Python 借鉴了一些概念。Python 对 AOP 的方法涉及以下语言特性:

  • 装饰器:使用装饰器,我们可以在函数的两个简单连接点之一建立一致的方面实现。我们可以在现有函数之前或之后执行方面的处理。我们不能轻易地在函数的代码内部找到连接点。对于装饰器来说,最容易的方法是通过包装函数或方法来转换它并添加额外的功能。

  • 混合类:使用混合类,我们可以定义一个存在于单个类层次结构之外的类。混合类可以与其他类一起使用,以提供横切面方面的一致实现。为了使其工作,混合 API 必须被混合到的类使用。通常,混合类被认为是抽象的,因为它们不能有实际意义地实例化。

使用内置装饰器

Python 有几个内置装饰器,它们是语言的一部分。@property@classmethod@staticmethod装饰器用于注释类的方法。@property装饰器将一个方法函数转换为一个描述符。我们使用这个来给一个方法函数提供一个简单属性的语法。应用到方法的属性装饰器还创建了一个额外的属性对,可以用来创建setterdeleter属性。我们在第三章中看到了这一点,属性访问、属性和描述符

@classmethod@staticmethod装饰器将一个方法函数转换为一个类级函数。装饰后的方法现在可以从一个类中调用,而不是一个对象。在静态方法的情况下,没有对类的明确引用。另一方面,对于类方法,类是方法函数的第一个参数。以下是一个包括@staticmethod和一些@property定义的类的示例:

class Angle( float ):
    __slots__ = ( "_degrees", )
    @staticmethod
    def from_radians( value ):
        return Angle(180*value/math.pi)
    def __new__( cls, value ):
        self = super().__new__(cls)
        self._degrees= value
        return self
    @property
    def radians( self ):
        return math.pi*self._degrees/180
    @property
    def degrees( self ):
        return self._degrees

这个类定义了一个可以用度或弧度表示的“角度”。构造函数期望度数。然而,我们还定义了一个from_radians()方法函数,它发出类的一个实例。这个函数不适用于实例变量;它适用于类本身,并返回类的一个实例。__new__()方法隐式地是一个类方法。没有使用装饰器。

此外,我们提供了degrees()radians()方法函数,它们已经被装饰为属性。在底层,这些装饰器创建了一个描述符,以便访问属性名degreesradians将调用命名的方法函数。我们可以使用static方法创建一个实例,然后使用property方法访问一个方法函数:

>>> b=Angle.from_radians(.227)
>>> b.degrees
13.006141949469686

静态方法实际上是一个函数,因为它不与self实例变量绑定。它的优势在于它在语法上绑定到类;使用Angle.from_radians可能比使用名为angle_from_radians的函数微小地更有帮助。使用这些装饰器可以确保实现正确和一致。

使用标准库装饰器

标准库有许多装饰器。像contextlibfunctoolsunittestatexitimportlibreprlib这样的模块包含了优秀的跨切面软件设计的装饰器示例。例如,functools库提供了total_ordering装饰器来定义比较运算符。它利用__eq__()__lt__()__le__()__gt__()__ge__()来创建一个完整的比较套件。以下是定义了两个比较的Card类的变化:

import functools
@functools.total_ordering
class Card:
    __slots__ = ( "rank", "suit" )
    def __new__( cls, rank, suit ):
        self = super().__new__(cls)
        self.rank= rank
        self.suit= suit
        return self
    def __eq__( self, other ):
        return self.rank == other.rank
    def __lt__( self, other ):
        return self.rank < other.rank

我们的类被一个类级装饰器@functools.total_ordering包装。这个装饰器创建了缺失的方法函数。我们可以使用这个类来创建可以使用所有比较运算符进行比较的对象,即使只定义了两个。以下是我们定义的比较的示例,以及我们没有定义的比较:

>>> c1= Card( 3, '♠' )
>>> c2= Card( 3, '♥' )
>>> c1 == c2
True
>>> c1 < c2
False
>>> c1 <= c2
True
>>> c1 >= c2
True

这个交互显示了我们能够进行未在原始类中定义的比较。装饰器为原始类定义添加了方法函数。

使用标准库混合类

标准库使用了混合类定义。有几个模块包含了示例,包括iosocketserverurllib.requestcontextlibcollections.abc

当我们基于collections.abc抽象基类定义自己的集合时,我们利用混合来确保容器的交叉切面方面得到一致的定义。顶层集合(SetSequenceMapping)都是由多个混合构建的。非常重要的是要查看Python 标准库的第 8.4 节,看看混合如何贡献特性,因为整体结构是由各个部分构建起来的。

仅看一行,Sequence的摘要,我们看到它继承自SizedIterableContainer。这些混合类导致了__contains__()__iter__()__reversed__()index()count()方法。

使用上下文管理器混合类

当我们在第五章中看上下文管理器时,使用可调用和上下文,我们忽略了ContextDecorator混合,而是专注于特殊方法本身。使用混合可以使定义更清晰。

提示

在之前的示例版本中,我们创建了一个改变全局状态的上下文管理器;它重置了随机数种子。我们将重新设计该设计,使得一副牌可以成为自己的上下文管理器。当作为上下文管理器使用时,它可以生成一系列固定的牌。这并不是测试一副牌的最佳方式。然而,这是上下文管理器的一个简单用法。

将上下文管理定义为应用类的混合需要一些小心。我们可能需要重新设计初始化方法以去除一些假设。我们的应用类可以以以下两种不同的方式使用:

  • 当在with语句之外使用时,__enter__()__exit__()方法将不会被评估

  • 当在with语句中使用时,__enter__()__exit__()方法将被评估

在我们的情况下,我们不能假设在__init__()处理期间评估shuffle()方法是有效的,因为我们不知道上下文管理器方法是否会被使用。我们不能将洗牌推迟到__enter__(),因为它可能不会被使用。这种复杂性可能表明我们提供了太多的灵活性。要么我们必须懒洋洋地洗牌,就在第一次pop()之前,要么我们必须提供一个可以被子类关闭的方法函数。以下是一个扩展list的简单Deck定义:

class Deck( list ):
    def __init__( self, size=1 ):
        super().__init__()
        self.rng= random.Random()
        for d in range(size):
            cards = [ card(r,s) for r in range(13) for s in Suits ]
            super().extend( cards )
        self._init_shuffle()
    def _init_shuffle( self ):
        self.rng.shuffle( self )

我们已经定义了这个牌组有一个可移除的_init_shuffle()方法。子类可以重写这个方法来改变何时洗牌完成。Deck的子类可以在洗牌之前设置随机数生成器的种子。这个版本的类可以避免在创建时洗牌。以下是包含contextlib.ContextDecorator混入的Deck的子类:

class TestDeck( ContextDecorator, Deck ):
    def __init__( self, size= 1, seed= 0 ):
        super().__init__( size=size )
        self.seed= seed
    def _init_shuffle( self ):
        """Don't shuffle during __init__."""
        pass
    def __enter__( self ):
        self.rng.seed( self.seed, version=1 )
        self.rng.shuffle( self )
        return self
    def __exit__( self, exc_type, exc_value, traceback ):
        pass

这个子类通过重写_init_shuffle()方法来防止初始化期间的洗牌。因为它混入了ContextDecorator,所以它还必须定义__enter__()__exit__()Deck的这个子类可以在with上下文中工作。在with语句中使用时,随机数种子被设置,洗牌将使用已知的序列。如果在with语句之外使用,那么洗牌将使用当前的随机数设置,并且不会进行__enter__()评估。

这种编程风格的目的是将类的真正基本特征与Deck实现的其他方面分开。我们已经将一些随机种子处理与Deck的其他方面分开。显然,如果我们坚持要求使用上下文管理器,我们可以大大简化事情。这并不是open()函数的典型工作方式。然而,这可能是一个有用的简化。我们可以使用以下示例来查看行为上的差异:

for i in range(3):
    d1= Deck(5)
    print( d1.pop(), d1.pop(), d1.pop() )

这个例子展示了Deck如何单独使用来生成随机洗牌。这是使用Deck生成洗牌卡片的简单用法。下一个例子展示了带有给定种子的TestDeck

for i in range(3):
    with TestDeck(5, seed=0) as d2:
        print( d2.pop(), d2.pop(), d2.pop() )

这展示了TestDeckDeck的子类,它被用作上下文管理器来生成一系列已知的卡片。每次调用它,我们都会得到相同的卡片序列。

关闭类功能

我们通过重新定义一个方法函数的主体为pass来关闭初始化期间的洗牌功能。这个过程可能看起来有点冗长,以便从子类中删除一个功能。在子类中删除功能的另一种方法是将方法名称设置为None。我们可以在TestDeck中这样做来移除初始洗牌:

_init_shuffle= None

前面的代码需要在超类中进行一些额外的编程来容忍缺失的方法,这在以下片段中显示:

try:
    self._init_shuffle()
except AttributeError, TypeError:
    pass

这可能是在子类定义中删除一个功能的一种更加明确的方式。这表明该方法可能丢失,或者已经被故意设置为None。另一种替代设计是将对_init_shuffle()的调用从__init__()移动到__enter__()方法。这将需要使用上下文管理器来使对象正常工作。如果清楚地记录下来,这并不是太繁琐的负担。

编写一个简单的函数装饰器

decorator函数是一个返回新函数的函数(或可调用对象)。最简单的情况涉及一个参数:要装饰的函数。装饰器的结果是一个已经包装过的函数。基本上,额外的功能要么放在原始功能之前,要么放在原始功能之后。这是函数中两个可用的连接点。

当我们定义一个装饰器时,我们希望确保装饰的函数具有原始函数的名称和docstring。这些属性应该由装饰器设置,我们将使用它们来编写装饰的函数。使用functools.wraps编写新装饰器可以简化我们需要做的工作,因为这部分繁琐的工作已经为我们处理了。

为了说明功能可以插入的两个位置,我们可以创建一个调试跟踪装饰器,它将记录函数的参数和返回值。这将在调用函数之前和之后放置功能。以下是我们想要包装的一些定义的函数,some_function

logging.debug( "function(", args, kw, ")" )
result= some_function( *args, **kw )
logging.debug( "result = ", result )
return result

这段代码显示了我们将有新的处理来包装原始函数。

通过戳击底层的__code__对象很难在定义的函数中插入处理。在极少数情况下,似乎有必要在函数中间注入一个方面时,将函数重写为可调用对象要容易得多,通过将功能分解为多个方法函数。然后,我们可以使用混合和子类定义,而不是复杂的代码重写。以下是一个调试装饰器,在函数评估之前和之后插入日志记录:

def debug( function ):
    @functools.wraps( function )
    def logged_function( *args, **kw ):
        logging.debug( "%s( %r, %r )", function.__name__, args, kw, )
        result= function( *args, **kw )
        logging.debug( "%s = %r", function.__name__, result )
        return result
    return logged_function

我们已经使用functools.wraps装饰器来确保原始函数的名称和docstring在结果函数中得到保留。现在,我们可以使用我们的装饰器来产生嘈杂、详细的调试。例如,我们可以这样做,将装饰器应用于某个函数ackermann()

@debug
def ackermann( m, n ):
    if m == 0: return n+1
    elif m > 0 and n == 0: return ackermann( m-1, 1 )
    elif m > 0 and n > 0: return ackermann( m-1, ackermann( m, n-1 ) )

此定义使用日志模块将调试信息写入root记录器,以调试ackermann()函数。我们配置记录器以生成以下调试输出:

logging.basicConfig(stream=sys.stderr, level=logging.DEBUG)

我们将在第十四章中详细讨论日志记录,日志和警告模块。当我们评估ackermann(2,4)时,我们将看到这种结果:

DEBUG:root:ackermann( (2, 4), {} )
DEBUG:root:ackermann( (2, 3), {} )
DEBUG:root:ackermann( (2, 2), {} )
.
.
.
DEBUG:root:ackermann( (0, 10), {} )
DEBUG:root:ackermann = 11
DEBUG:root:ackermann = 11
DEBUG:root:ackermann = 11

创建单独的记录器

作为日志优化,我们可能希望为每个包装的函数使用特定的记录器,而不是过度使用根记录器进行此类调试输出。我们将在第十四章中返回记录器,日志和警告模块。以下是我们的装饰器的一个版本,它为每个单独的函数创建一个单独的记录器:

def debug2( function ):
    @functools.wraps( function )
    def logged_function( *args, **kw ):
        log= logging.getLogger( function.__name__ )
        log.debug( "call( %r, %r )", args, kw, )
        result= function( *args, **kw )
        log.debug( "result %r", result )
        return result
    return logged_function

这个版本修改了输出,看起来像这样:

DEBUG:ackermann:call( (2, 4), {} )
DEBUG:ackermann:call( (2, 3), {} )
DEBUG:ackermann:call( (2, 2), {} )
.
.
.
DEBUG:ackermann:call( (0, 10), {} )
DEBUG:ackermann:result 11
DEBUG:ackermann:result 11
DEBUG:ackermann:result 11

函数名现在是记录器名称。这可以用于微调调试输出。我们现在可以为单个函数启用日志记录。我们不能轻易更改装饰器并期望装饰的函数也会改变。

我们需要将修改后的装饰器应用于函数。这意味着无法从>>>交互提示符轻松地进行调试和实验。我们必须在调整装饰器定义后重新加载函数定义。这可能涉及大量的复制和粘贴,或者可能涉及重新运行定义装饰器、函数,然后运行测试或演示脚本以展示一切都按预期工作。

给装饰器加参数

有时我们想要为装饰器提供更复杂的参数。想法是我们将要定制包装函数。当我们这样做时,装饰变成了一个两步过程。

当我们编写以下代码时,我们为函数定义提供了一个带参数的装饰器:

@decorator(arg)
def func( ):
    pass

装饰器的使用是以下代码的简写:

def func( ):
    pass
func= decorator(arg)(func)

这两个示例都做了以下三件事:

  • 定义了一个函数,func

  • 将抽象装饰器应用于其参数,以创建具体装饰器,decorator(arg)

  • 将定义的函数应用具体装饰器,以创建函数的装饰版本,decorator(arg)(func)

这意味着带有参数的装饰器将需要间接构造最终函数。让我们再次微调我们的调试装饰器。我们想要做以下事情:

@debug("log_name")
def some_function( args ):
    pass

这种代码允许我们明确命名调试输出将要进入的日志。我们不使用根记录器,也不默认为每个函数使用不同的记录器。参数化装饰器的概要如下所示:

def decorator(config):
    def concrete_decorator(function):
        @functools.wraps( function )
        def wrapped( *args, **kw ):
            return function( *args, ** kw )
        return wrapped
    return concrete_decorator

在查看示例之前,让我们先剥开这个装饰器的层层皮。装饰器定义(def decorator(config))显示了我们在使用装饰器时将提供的参数。其中的具体装饰器是返回的具体装饰器。具体装饰器(def concrete_decorator(function))是将应用于目标函数的装饰器。这与前一节中显示的简单函数装饰器一样。它构建了包装函数(def wrapped(*args, **kw)),然后返回它。以下是我们命名的调试版本:

def debug_named(log_name):
    def concrete_decorator(function):
        @functools.wraps( function )
        def wrapped( *args, **kw ):
            log= logging.getLogger( log_name )
            log.debug( "%s( %r, %r )", function.__name__, args, kw, )
            result= function( *args, **kw )
            log.debug( "%s = %r", function.__name__, result )
            return result
        return wrapped
    return concrete_decorator

这个decorator函数接受一个参数,即要使用的日志的名称。它创建并返回一个具体装饰器函数。当这个函数应用于一个函数时,具体装饰器返回给定函数的包装版本。当函数以以下方式使用时,装饰器会添加嘈杂的调试行。它们将输出到名为recursion的日志中,如下所示:

@debug_named("recursion")
def ackermann( m, n ):
    if m == 0: return n+1
    elif m > 0 and n == 0: return ackermann( m-1, 1 )
    elif m > 0 and n > 0: return ackermann( m-1, ackermann( m, n-1 ) )

创建方法函数装饰器

类定义的方法函数的装饰器与独立函数的装饰器是相同的。它只是在不同的上下文中使用。这种不同上下文的一个小后果是,我们经常必须明确命名self变量。

方法函数装饰的一个应用是为对象状态变化产生审计跟踪。业务应用程序通常创建有状态记录;通常情况下,这些记录在关系数据库中表示为行。我们将在第九章中查看对象表示,序列化和保存 - JSON、YAML、Pickle、CSV 和 XML,第十章,通过 Shelve 存储和检索对象,和第十一章,通过 SQLite 存储和检索对象

注意

当我们有有状态的记录时,状态变化需要进行审计。审计可以确认记录已经进行了适当的更改。为了进行审计,每个记录的之前和之后版本必须在某个地方可用。有状态的数据库记录是一个长期的传统,但并不是必需的。不可变的数据库记录是一个可行的设计替代方案。

当我们设计有状态的类时,我们编写的任何 setter 方法都会导致状态变化。这些 setter 方法通常使用@property装饰器,以便它们看起来像简单的属性。如果我们这样做,我们可以加入一个@audit装饰器,用于跟踪对象的更改,以便我们有一个正确的更改记录。我们将通过logging模块创建审计日志。我们将使用__repr__()方法函数生成一个完整的文本表示,用于检查更改。以下是一个审计装饰器:

def audit( method ):
    @functools.wraps(method)
    def wrapper( self, *args, **kw ):
        audit_log= logging.getLogger( 'audit' )
        before= repr(self)
        try:
            result= method( self, *args, **kw )
            after= repr(self)
        except Exception as e:
           audit_log.exception(
               '%s before %s\n after %s', method.__qualname__, before, after )
           raise
        audit_log.info(
               '%s before %s\n after %s', method.__qualname__, before, after )
        return result
    return wrapper

我们已经创建了对象的之前版本的文本备忘录。然后,我们应用了原始方法函数。如果出现异常,我们将生成包含异常详细信息的审计日志。否则,我们将在日志中产生一个INFO条目,其中包含方法的限定名称、更改前备忘录和更改后备忘录。以下是显示如何使用此装饰器的Hand类的修改:

class Hand:
    def __init__( self, *cards ):
        self._cards = list(cards)
    **@audit
    def __iadd__( self, card ):
        self._cards.append( card )
        return self
    def __repr__( self ):
        cards= ", ".join( map(str,self._cards) )
        return "{__class__.__name__}({cards})".format(__class__=self.__class__, cards=cards)

这个定义修改了__iadd__()方法函数,使得添加一张卡变成了一个可审计的事件。这个装饰器将执行审计操作,保存Hand操作前后的文本备忘录。

这种方法装饰器的使用正式声明了一个特定的方法函数已经进行了重大的状态改变。我们可以轻松地使用代码审查来确保所有适当的方法函数都被标记为像这样的审核。一个悬而未决的问题是审核对象的创建。并不十分清楚对象的创建是否需要审核记录。可以争论说对象的创建不是状态改变。

如果我们想要审核创建,我们不能在__init__()方法函数上使用这个audit装饰器。因为在执行__init__()之前没有之前的图像。我们可以采取两种补救措施,如下所示:

  • 我们可以添加一个__new__()方法,确保将一个空的_cards属性作为一个空集合添加到类中

  • 我们可以调整audit()装饰器以容忍AttributeError,这将在处理__init__()时出现

第二个选项更加灵活。我们可以这样做:

try:
    before= repr(self)
except AttributeError as e:
    before= repr(e)

这将记录一条消息,例如AttributeError: 'Hand' object has no attribute '_cards',用于初始化期间的前状态。

创建一个类装饰器

类似于装饰函数,我们可以编写一个类装饰器来为类定义添加功能。基本规则是相同的。装饰器是一个函数(或可调用对象)。它接收一个类对象作为参数,并返回一个类对象作为结果。

在整个类定义中,我们有有限的连接点。在大多数情况下,类装饰器会将额外的属性合并到类定义中。从技术上讲,可以创建一个包装原始类定义的新类。这是具有挑战性的,因为包装类必须非常通用。还可以创建一个是装饰类定义的子类的新类。这可能会让装饰器的用户感到困惑。还可以从类定义中删除功能,这似乎非常糟糕。

之前展示了一个复杂的类装饰器。functools.Total_Ordering装饰器将一些新的方法函数注入到类定义中。这个实现中使用的技术是创建 lambda 对象并将它们分配给类的属性。

我们将看一个稍微简单的装饰器。在调试和记录日志期间,我们可能会遇到一个小问题,即创建专注于我们的类的记录器。通常,我们希望每个类都有一个唯一的记录器。我们经常被迫做类似以下的事情:

class UglyClass1:
    def __init__( self ):
        self.logger= logging.getLogger(self.__class__.__qualname__)
        self.logger.info( "New thing" )
    def method( self, *args ):
        self.logger.info( "method %r", args )

这个类的缺点是它创建了一个logger实例变量,它实际上并不是类的操作的一部分,而是类的一个独立方面。我们希望避免用这个额外的方面来污染类。而且,尽管logging.getLogger()非常高效,但成本是非零的。我们希望避免在每次创建UglyClass1的实例时都产生这种额外的开销。

以下是一个稍微更好的版本。记录器被提升为类级实例变量,并且与类的每个单独对象分开:

class UglyClass2:
    logger= logging.getLogger("UglyClass2")
    def __init__( self ):
        self.logger.info( "New thing" )
    def method( self, *args ):
        self.logger.info( "method %r", args )

这样做的好处是只实现了一次logging.getLogger()。然而,它存在一个严重的 DRY 问题。我们无法在类定义中自动设置类名。因为类还没有被创建,所以我们被迫重复类名。通过一个小装饰器解决了 DRY 问题,如下所示:

def logged( class_ ):
    class_.logger= logging.getLogger( class_.__qualname__ )
    return class_

这个装饰器调整了类定义,将logger引用作为类级属性添加进去。现在,每个方法都可以使用self.logger来生成审计或调试信息。当我们想要使用这个功能时,我们可以在整个类上使用@logged装饰器。以下是一个已记录的类SomeClass的示例:

@logged
class SomeClass:
    def __init__( self ):
        self.logger.info( "New thing" )
    def method( self, *args ):
        self.logger.info( "method %r", args )

现在,我们的类有一个logger属性,可以被任何方法使用。日志记录器的值不是对象的特性,这使得这个方面与类的其余方面分离开来。这个属性的附加好处是它在模块导入期间创建了日志记录器实例,稍微减少了日志记录的开销。让我们将其与UglyClass1进行比较,其中logging.getLogger()在每个实例创建时都会被评估。

向类添加方法函数

类装饰器通过两步过程创建新的方法函数:创建方法函数,然后将其插入类定义中。这通常比通过装饰器更好地通过混入类来完成。混入的明显和预期的用途是插入方法。以另一种方式插入方法不太明显,可能会令人惊讶。

Total_Ordering装饰器的例子中,插入的确切方法函数是灵活的,并且取决于已经提供的内容。这是一种典型但非常聪明的特殊情况。

我们可能想要定义一个标准的memento()方法。我们希望在各种类中包含这个标准方法函数。我们将看一下装饰器和混入版本的设计。以下是添加标准方法的装饰器版本:

def memento( class_ ):
    def memento( self ):
        return "{0.__class__.__qualname__}({0!r})".format(self)
    class_.memento= memento
    return class_

这个装饰器包括一个插入到类中的方法函数定义。以下是我们如何使用@memento装饰器向类添加方法函数:

@memento
class SomeClass:
    def __init__( self, value ):
        self.value= value
    def __repr__( self ):
        return "{0.value}".format(self)

装饰器将一个新方法memento()合并到装饰类中。然而,这有以下缺点:

  • 我们不能覆盖memento()方法函数的实现以处理特殊情况。它是在类的定义之后构建的。

  • 我们不能轻易地扩展装饰器函数。我们必须升级为可调用对象以提供扩展或特殊化。如果我们要升级为可调用对象,我们应该放弃这种方法,并使用混入来添加方法。

以下是添加标准方法的混入类:

class Memento:
    def memento( self ):
        return "{0.__class__.__qualname__}({0!r})".format(self)

以下是我们如何使用Memento混入类来定义一个应用程序类:

class SomeClass2( Memento ):
    def __init__( self, value ):
        self.value= value
    def __repr__( self ):
        return "{0.value}".format(self)

混入提供了一个新方法memento();这是混入的预期和典型目的。我们可以更容易地扩展Memento混入类以添加功能。此外,我们可以覆盖memento()方法函数以处理特殊情况。

使用装饰器进行安全性

软件充满了横切关注点,需要一致地实现,即使它们在不同的类层次结构中。试图在横切关注点周围强加一个类层次结构通常是错误的。我们已经看过一些例子,比如日志记录和审计。

我们不能合理地要求每个可能需要写入日志的类也是某个loggable超类的子类。我们可以设计一个loggable混入或一个loggable装饰器。这些不会干扰我们需要设计的正确继承层次结构,以使多态性正常工作。

一些重要的横切关注点围绕着安全性。在 Web 应用程序中,安全问题有两个方面,如下所示:

  • 认证:我们知道谁在发出请求吗?

  • 授权:经过认证的用户是否被允许发出请求?

一些 Web 框架允许我们使用安全要求装饰我们的请求处理程序。例如,Django 框架有许多装饰器,允许我们为视图函数或视图类指定安全要求。其中一些装饰器如下:

  • user_passes_test:这是一个低级别的装饰器,非常通用,用于构建其他两个装饰器。它需要一个测试函数;与请求相关联的已登录的User对象必须通过给定的函数。如果User实例无法通过给定的测试,它们将被重定向到登录页面,以便用户提供所需的凭据来发出请求。

  • login_required:这个装饰器基于user_passes_test。它确认已登录用户已经通过身份验证。这种装饰器用于适用于所有访问站点的人的 Web 请求。例如,更改密码或注销登录等请求不应需要更具体的权限。

  • permission_required:这个装饰器与 Django 内部定义的数据库权限方案一起工作。它确认已登录用户(或用户组)与给定权限相关联。这种装饰器用于需要特定管理权限才能发出请求的 Web 请求。

其他包和框架也有表达 Web 应用程序的这种横切方面的方法。在许多情况下,Web 应用程序可能会有更严格的安全考虑。我们可能有一个 Web 应用程序,用户功能是基于合同条款和条件有选择地解锁的。也许,额外的费用将解锁一个功能。我们可能需要设计一个像下面这样的测试:

def user_has_feature( feature_name ):
    def has_feature( user ):
        return feature_name in (f.name for f in user.feature_set())
    return user_passes_test( has_feature )

我们已经定义了一个函数,检查已登录的Userfeature_set集合,以查看命名的功能是否与User相关联。我们使用了我们的has_feature()函数与 Django 的user_passes_test装饰器来创建一个可以应用于相关view函数的新装饰器。然后我们可以创建一个view函数如下:

@user_has_feature( 'special_bonus' )
def bonus_view( request ):
    pass

这确保安全性问题将一致地应用于许多view函数。

总结

我们已经看过使用装饰器来修改函数和类定义。我们还看过混入,它允许我们将一个较大的类分解为组件,然后将它们组合在一起。

这两种技术的想法都是将特定于应用程序的特性与安全性、审计或日志记录等通用特性分开。我们将区分类的固有特性和不固有但是额外关注的方面。固有特性是显式设计的一部分。它们是继承层次结构的一部分;它们定义了对象是什么。其他方面可以是混入或装饰;它们定义了对象可能还会起到的作用。

设计考虑和权衡

在大多数情况下,行为之间的区分是非常明确的。固有特征是整体问题域的一部分。当谈论模拟 21 点游戏时,诸如卡片、手牌、下注、要牌和停牌等内容显然是问题域的一部分。类似地,数据收集和结果的统计分析是解决方案的一部分。其他事情,如日志记录、调试和审计,不是问题域的一部分,而是与解决方案技术相关的。

虽然大多数情况都很明确,固有和装饰方面之间的分界线可能很微妙。在某些情况下,这可能会变成审美判断。一般来说,在编写不专注于特定问题的框架和基础设施类时,决策变得困难。一般策略如下:

  • 首先,与问题相关的方面将直接导致类定义。许多类是问题的固有部分,并形成适当的类层次结构,以便多态性能够正常工作。

  • 其次,某些方面会导致混合类定义。当存在多维方面时,这种情况经常发生。我们可能会有独立的设计轴或维度。每个维度都可以提供多态的选择。当我们看 21 点游戏时,有两种策略:玩牌策略和下注策略。这些是独立的,可以被视为整体玩家设计的混合元素。

当我们定义单独的混合元素时,可以为混合元素定义单独的继承层次结构。对于 21 点下注策略,我们可以定义一个与玩牌策略的多态层次结构无关的多态层次结构。然后我们可以定义玩家,其混合元素来自两个层次结构。

方法通常是从类定义中创建的。它们可以是主类的一部分,也可以是混合类的一部分。如上所述,我们有三种设计策略:包装、扩展和发明。我们可以通过“包装”一个类来引入功能。在某些情况下,我们发现自己不得不暴露大量方法,这些方法只是委托给底层类。在这种情况下,我们有一个模糊的边界,我们委托过多;装饰器或混合类定义可能是更好的选择。在其他情况下,包装一个类可能比引入混合类定义更清晰。

与问题正交的方面通常可以通过装饰器定义来处理。装饰器可以用来引入不属于对象与其类之间is-a关系的特性。

展望未来

接下来的章节将改变方向。我们已经了解了几乎所有 Python 的特殊方法名称。接下来的五章将专注于对象持久化和序列化。我们将从将对象序列化和保存到各种外部表示法开始,包括 JSON、YAML、Pickle、CSV 和 XML。

序列化和持久化为我们的类引入了更多的面向对象设计考虑。我们将研究对象关系以及它们的表示方式。我们还将研究序列化和反序列化对象的成本复杂性,以及与从不可信来源反序列化对象相关的安全问题。

第二部分:持久性和序列化

序列化和保存-JSON、YAML、Pickle、CSV 和 XML

通过 Shelve 存储和检索对象

通过 SQLite 存储和检索对象

传输和共享对象

配置文件和持久性

持久性和序列化

持久对象是已经写入某种存储介质的对象。可以从存储中检索对象并在 Python 应用程序中使用。也许对象以 JSON 形式表示并写入文件系统。也许一个对象关系映射ORM)层已经将对象表示为 SQL 表中的行,以将对象存储在数据库中。

序列化对象有两个目的。我们对对象进行序列化是为了使它们在本地文件系统中持久化。我们还对对象进行序列化,以在进程或应用程序之间交换对象。虽然重点不同,但持久性通常包括序列化;因此,一个良好的持久性技术也将适用于数据交换。我们将看看 Python 处理序列化和持久性的几种方式。本部分的章节组织如下:

  • 第九章,“序列化和保存-JSON、YAML、Pickle、CSV 和 XML”,涵盖了使用专注于各种数据表示的库进行简单持久化:JSON、YAML、pickle、XML 和 CSV。这些是 Python 数据的常见、广泛使用的格式。它们适用于持久性以及数据交换。它们更多地关注单个对象,而不是大量对象的持久性。

  • 第十章,“通过 Shelve 存储和检索对象”,涵盖了使用 Python 模块(如 Shelve 和 dBm)进行基本数据库操作。这些提供了 Python 对象的简单存储,并专注于多个对象的持久性。

  • 第十一章,“通过 SQLite 存储和检索对象”,转向更复杂的 SQL 和关系数据库世界。由于 SQL 特性与面向对象编程特性不匹配,我们面临阻抗不匹配问题。一个常见的解决方案是使用对象关系映射来允许我们持久化大量对象。

  • 对于 Web 应用程序,我们经常使用表述状态转移REST)。第十二章,“传输和共享对象”,将研究 HTTP 协议,JSON,YAML 和 XML 表示传输对象。

  • 最后,第十三章,“配置文件和持久性”,将涵盖 Python 应用程序可以使用配置文件的各种方式。有许多格式,每种格式都有一些优点和缺点。配置文件只是一组可以轻松被人类用户修改的持久对象。

在本部分中经常出现的重要主题是在更高级别的抽象中使用的设计模式。我们将这些称为架构模式,因为它们描述了应用程序的整体架构,将其分成层或层。我们被迫将应用程序分解成片段,以便我们可以实践通常被表述为关注点分离的原则。我们需要将持久性与其他功能(如应用程序的核心处理和向用户呈现数据)分开。精通面向对象的设计意味着要查看更高级别的架构设计模式。

第九章:序列化和保存-JSON、YAML、Pickle、CSV 和 XML

要使 Python 对象持久,我们必须将其转换为字节并将字节写入文件。我们将其称为序列化;它也被称为编组、压缩或编码。我们将研究几种将 Python 对象转换为字符串或字节流的方法。

这些序列化方案中的每一个也可以称为物理数据格式。每种格式都有一些优点和缺点。没有最佳格式来表示对象。我们必须区分逻辑数据格式,它可能是简单的重新排序或更改空格使用方式,而不改变对象的值,但改变字节序列。

重要的是要注意(除了 CSV),这些表示法偏向于表示单个 Python 对象。虽然单个对象可以是对象列表,但它仍然是固定大小的列表。为了处理其中一个对象,整个列表必须被反序列化。有方法可以执行增量序列化,但这需要额外的工作。与摆弄这些格式以处理多个对象相比,有更好的方法来处理第十章中的许多不同对象的方法,通过 Shelve 存储和检索对象,第十一章,通过 SQLite 存储和检索对象,以及第十二章,传输和共享对象

由于每个方案都专注于单个对象,我们受限于适合内存的对象。当我们需要处理大量不同的项目,而不是所有项目一次性放入内存时,我们无法直接使用这些技术;我们需要转移到更大的数据库、服务器或消息队列。我们将研究以下序列化表示:

  • JavaScript 对象表示法(JSON):这是一种广泛使用的表示法。有关更多信息,请参见www.json.orgjson模块提供了在此格式中加载和转储数据所需的类和函数。在Python 标准库中,查看第十九部分Internet Data Handling,而不是第十二部分Persistencejson模块专注于 JSON 表示,而不是 Python 对象持久性的更一般问题。

  • YAML 不是标记语言(YAML):这是对 JSON 的扩展,可以简化序列化输出。有关更多信息,请参见yaml.org。这不是 Python 库的标准部分;我们必须添加一个模块来处理这个问题。具体来说,PyYaml包具有许多 Python 持久性特性。

  • picklepickle模块具有其自己的 Python 特定的数据表示形式。由于这是 Python 库的一部分,我们将仔细研究如何以这种方式序列化对象。这的缺点是它不适合与非 Python 程序交换数据。这是第十章,“通过 Shelve 存储和检索对象”的shelve模块以及第十二章,“传输和共享对象”中的消息队列的基础。

  • 逗号分隔值(CSV)模块:这对于表示复杂的 Python 对象来说可能不方便。由于它被广泛使用,我们需要想办法以 CSV 表示法序列化 Python 对象。有关参考,请查看《Python 标准库》第十四部分“文件格式”,而不是第十二部分“持久性”,因为它只是一个文件格式,没有更多内容。CSV 允许我们对无法放入内存的 Python 对象集合进行递增表示。

  • XML:尽管存在一些缺点,但这是非常广泛使用的,因此能够将对象转换为 XML 表示法并从 XML 文档中恢复对象非常重要。XML 解析是一个庞大的主题。参考资料在《Python 标准库》第二十部分“结构化标记处理工具”中。有许多模块用于解析 XML,每个都有不同的优点和缺点。我们将重点关注ElementTree

除了这些简单的类别,我们还可能遇到混合问题。一个例子是用 XML 编码的电子表格。这意味着我们有一个包裹在 XML 解析问题中的行列数据表示问题。这导致了更复杂的软件,以解开被扁平化为类似 CSV 的行的各种数据,以便我们可以恢复有用的 Python 对象。在第十二章,“传输和共享对象”,以及第十三章,“配置文件和持久性”中,我们将重新讨论这些主题,因为我们使用 RESTful web 服务与序列化对象以及可编辑的序列化对象用于配置文件。

理解持久性、类、状态和表示形式

主要地,我们的 Python 对象存在于易失性计算机内存中。它们只能存在于 Python 进程运行的时间。它们甚至可能活不了那么久;它们可能只能活到它们在命名空间中有引用的时间。如果我们想要一个超出 Python 进程或命名空间寿命的对象,我们需要使其持久化。

大多数操作系统以文件系统的形式提供持久存储。这通常包括磁盘驱动器、闪存驱动器或其他形式的非易失性存储。这似乎只是将字节从内存传输到磁盘文件的问题。

复杂性的原因在于我们的内存中的 Python 对象引用其他对象。一个对象引用它的类。类引用它的元类和任何基类。对象可能是一个容器,并引用其他对象。对象的内存版本是一系列引用和关系。由于内存位置不固定,尝试简单地转储和恢复内存字节而不将地址重写为某种位置无关的键将会破坏这些关系。

引用网络中的许多对象在很大程度上是静态的——例如类定义变化非常缓慢,与变量相比。理想情况下,类定义根本不会改变。但是,我们可能有类级实例变量。更重要的是,我们需要升级我们的应用软件,改变类定义,从而改变对象特性。我们将这称为模式迁移问题,管理数据模式(或类)的变化。

Python 为对象的实例变量和类的其他属性之间给出了正式的区别。我们的设计决策利用了这一区别。我们定义对象的实例变量来正确显示对象的动态状态。我们使用类级属性来存储该类的对象将共享的信息。如果我们只能持久化对象的动态状态——与类和类定义的引用网络分开——那将是一种可行的序列化和持久化解决方案。

实际上,我们不必做任何事情来持久化我们的类定义;我们已经有一个完全独立且非常简单的方法来做到这一点。类定义主要存在于源代码中。易失性内存中的类定义是从源代码(或源代码的字节码版本)中每次需要时重新构建的。如果我们需要交换类定义,我们交换 Python 模块或包。

常见的 Python 术语

Python 术语往往侧重于转储加载这两个词。我们将要使用的大多数各种类都将定义以下方法:

  • dump(object, file): 这将把给定的对象转储到给定的文件中

  • dumps(object): 这将转储一个对象,并返回一个字符串表示

  • load(file): 这将从给定的文件加载一个对象,并返回构造的对象

  • loads(string): 这将从一个字符串表示中加载一个对象,并返回构造的对象

没有标准;方法名并不是由任何正式的 ABC 继承或混合类定义保证的。然而,它们被广泛使用。通常,用于转储或加载的文件可以是任何类似文件的对象。加载需要一些方法,如read()readline(),但我们不需要更多。因此,我们可以使用io.StringIO对象以及urllib.request对象作为加载的来源。同样,转储对数据源的要求很少。我们将在下一节中深入探讨这些文件对象的考虑。

文件系统和网络考虑因素

由于操作系统文件系统(和网络)以字节为单位工作,我们需要将对象的实例变量的值表示为序列化的字节流。通常,我们会使用两步转换为字节;我们将对象的状态表示为一个字符串,并依赖于 Python 字符串提供标准编码的字节。Python 内置的将字符串编码为字节的功能很好地解决了问题的这一部分。

当我们查看操作系统文件系统时,我们会看到两类广泛的设备:块模式设备和字符模式设备。块模式设备也可以称为可寻址,因为操作系统支持可以以任意顺序访问文件中的任何字节的寻址操作。字符模式设备不可寻址;它们是以串行方式传输字节的接口。寻址将涉及向后移动时间。

字符和块模式之间的这种区别可能会影响我们如何表示复杂对象或对象集合的状态。本章中我们将要讨论的序列化重点是最简单的常见特性集:有序的字节流;这些格式不使用可寻址设备;它们将将字节流保存到字符模式或块模式文件中。

然而,在第十章和第十一章中我们将要讨论的格式,通过 Shelve 存储和检索对象通过 SQLite 存储和检索对象,将需要块模式存储以便编码更多的对象,而不是可能适合内存的对象。shelve模块和SQLite数据库广泛使用可寻址文件。

一个小的令人困惑的因素是操作系统将块和字符模式设备统一到一个单一的文件系统隐喻中的方式。Python 标准库的一些部分实现了块和字符设备之间的最低公共特性集。当我们使用 Python 的 urllib.request 时,我们可以访问网络资源,以及本地文件的数据。当我们打开一个本地文件时,这个模块必须对一个本来是可寻址的文件施加有限的字符模式接口。

定义支持持久性的类

在我们可以处理持久性之前,我们需要一些我们想要保存的对象。与持久性相关的有几个设计考虑,所以我们将从一些简单的类定义开始。我们将看一个简单的微博和该博客上的帖子。这里是 Post 的一个类定义:

import datetime
class Post:
    def __init__( self, date, title, rst_text, tags ):
        self.date= date
        self.title= title
        self.rst_text= rst_text
        self.tags= tags
    def as_dict( self ):
        return dict(
            date= str(self.date),
            title= self.title,
            underline= "-"*len(self.title),
            rst_text= self.rst_text,
            tag_text= " ".join(self.tags),
        )

实例变量是每个微博帖子的属性:日期、标题、一些文本和一些标签。我们的属性名称为我们提供了一个提示,即文本应该是 RST 标记,尽管这对于数据模型的其余部分来说并不重要。

为了支持简单的替换到模板中,as_dict() 方法返回一个值的字典,这些值已经转换为字符串格式。我们稍后会看一下使用 string.Template 进行模板处理。

此外,我们添加了一些值来帮助创建 RST 输出。tag_text 属性是标签值元组的扁平文本版本。underline 属性生成一个与标题字符串长度相匹配的下划线字符串;这有助于 RST 格式化工作得很好。我们还将创建一个博客作为帖子的集合。我们将通过包括标题的附加属性使这个集合不仅仅是一个简单的列表。我们有三种选择用于集合设计:包装、扩展或发明一个新的类。我们将通过提供这个警告来避免一些混淆:如果你打算使它持久化,不要扩展一个 list

提示

扩展可迭代对象可能会令人困惑

当我们扩展一个序列时,可能会混淆一些内置的序列化算法。内置算法可能会绕过我们在序列的子类中放入的扩展特性。包装序列通常比扩展序列更好。

这迫使我们考虑包装或发明。这是一个简单的序列,为什么要发明新的东西呢?包装是我们将强调的设计策略。这里有一系列微博帖子。我们已经包装了一个列表,因为扩展列表并不总是有效的:

from collections import defaultdict
class Blog:
    def __init__( self, title, posts=None ):
        self.title= title
        self.entries= posts if posts is not None else []
    def append( self, post ):
        self.entries.append(post)
    def by_tag(self):
        tag_index= defaultdict(list)
        for post in self.entries:
            for tag in post.tags:
                tag_index[tag].append( post.as_dict() )
        return tag_index
    def as_dict( self ):
        return dict(
            title= self.title,
            underline= "="*len(self.title),
            entries= [p.as_dict() for p in self.entries],
        )

除了包装列表,我们还包括了一个微博的标题属性。初始化程序使用了一种常见的技术,以避免提供可变对象作为默认值。我们为 posts 提供了 None 作为默认值。如果 postsNone,我们使用一个新创建的空列表 []。否则,我们使用给定的 posts 值。

此外,我们定义了一个按标签索引帖子的方法。在生成的 defaultdict 中,每个键都是一个标签的文本。每个值都是共享给定标签的帖子的列表。

为了简化使用 string.Template,我们添加了另一个 as_dict() 方法,将整个博客简化为一个简单的字符串和字典的字典。这里的想法是只产生具有简单字符串表示的内置类型。接下来我们将展示模板渲染过程。这里是一些示例数据:

travel = Blog( "Travel" )
travel.append(
    Post( date=datetime.datetime(2013,11,14,17,25),
        title="Hard Aground",
        rst_text="""Some embarrassing revelation. Including ☹ and ⎕""",
        tags=("#RedRanger", "#Whitby42", "#ICW"),
        )
)
travel.append(
    Post( date=datetime.datetime(2013,11,18,15,30),
        title="Anchor Follies",
        rst_text="""Some witty epigram. Including < & > characters.""",,
        tags=("#RedRanger", "#Whitby42", "#Mistakes"),
        )
)

我们已将 BlogPost 序列化为 Python 代码。这并不是一个完全糟糕的表示博客的方式。有一些用例中,Python 代码是对象的一个完全合适的表示。在第十三章 配置文件和持久性 中,我们将更仔细地看一下简单地使用 Python 编码数据。

渲染博客和帖子

为了完整起见,这里有一种将博客呈现为 RST 的方法。从这个输出文件中,docutils 的rst2html.py工具可以将 RST 输出转换为最终的 HTML 文件。这样我们就不必深入研究 HTML 和 CSS 了。此外,我们将使用 RST 来编写第十八章中的文档,质量和文档有关 docutils 的更多信息,请参见一些准备工作

我们可以使用string.Template类来做到这一点。然而,这很笨拙和复杂。有许多附加的模板工具可以在模板本身内执行更复杂的替换,包括循环和条件处理。这里有一些替代方案:wiki.python.org/moin/Templating。我们将向您展示一个使用 Jinja2 模板工具的示例。请参阅pypi.python.org/pypi/Jinja2。这是一个使用模板在 RST 中呈现这些数据的脚本:

from jinja2 import Template
blog_template= Template( """
{{title}}
{{underline}}

{% for e in entries %}
{{e.title}}
{{e.underline}}

{{e.rst_text}}

:date: {{e.date}}

:tags: {{e.tag_text}}
{% endfor %}

Tag Index
=========
{% for t in tags %}

*   {{t}}
    {% for post in tags[t] %}

    -   `{{post.title}}`_
    {% endfor %}
{% endfor %}
""")
print( blog_template.render( tags=travel.by_tag(), **travel.as_dict() ) )

{{title}}{{underline}}元素(以及所有类似的元素)向我们展示了如何将值替换为模板文本。使用render()方法调用**travel.as_dict(),以确保属性(如titleunderline)将成为关键字参数。

{%for%}{%endfor%}构造向我们展示了 Jinja 如何遍历BlogPost条目的序列。在此循环的主体中,变量e将是从每个Post创建的字典。我们从字典中为每个帖子挑选了特定的键:{{e.title}}{{e.rst_text}}等。

我们还遍历了Blogtags集合。这是一个字典,其中包含每个标签的键和该标签的帖子。循环将访问每个键,分配给t。循环的主体将遍历字典值中的帖子,即tags[t]

``{{post.title}}_构造是一个 RST 标记,它生成一个链接到文档中具有该标题的部分。这种非常简单的标记是 RST 的优势之一。我们已经将博客标题用作索引中的部分和链接。这意味着标题必须是唯一的,否则我们将获得 RST 呈现错误。

因为这个模板遍历给定的博客,它将以一种平稳的动作呈现所有的帖子。内置于 Python 的string.Template不能进行迭代。这使得呈现Blog的所有Posts变得更加复杂。

使用 JSON 进行转储和加载

JSON 是什么?来自www.json.org网页的一节指出:

JSON(JavaScript 对象表示)是一种轻量级的数据交换格式。人类很容易阅读和书写。机器很容易解析和生成。它基于 JavaScript 编程语言的一个子集,标准 ECMA-262 第 3 版-1999 年 12 月。JSON 是一种完全与语言无关的文本格式,但使用了熟悉 C 系列语言的程序员的约定,包括 C、C++、C#、Java、JavaScript、Perl、Python 等。这些特性使 JSON 成为一种理想的数据交换语言。

这种格式被广泛用于各种语言和框架。诸如 CouchDB 之类的数据库将其数据表示为 JSON 对象,简化了应用程序之间的数据传输。JSON 文档具有类似 Python listdict文字值的优势。它们易于阅读和手动编辑。

json模块与内置的 Python 类型一起使用。它不适用于我们定义的类,直到我们采取一些额外的步骤。接下来我们将看看这些扩展技术。对于以下 Python 类型,有一个映射到 JSON 使用的 JavaScript 类型:

Python 类型 JSON
dict object
list, tuple array
str string
int, float number
True true
False false
None null

其他类型不受支持,必须通过我们可以插入到 dump 和 load 函数中的扩展函数来强制转换为这些类型之一。我们可以通过将我们的微博对象转换为更简单的 Pythonlistsdicts来探索这些内置类型。当我们查看我们的PostBlog类定义时,我们已经定义了as_dict()方法,将我们的自定义类对象减少为内置的 Python 对象。以下是生成我们博客数据的 JSON 版本所需的代码:

import json
print( json.dumps(travel.as_dict(), indent=4) )

以下是输出:

{
    "entries": [
        {
            "title": "Hard Aground",
            "underline": "------------",
            "tag_text": "#RedRanger #Whitby42 #ICW",
            "rst_text": "Some embarrassing revelation. Including \u2639 and \u2693",
            "date": "2013-11-14 17:25:00"
        },
        {
            "title": "Anchor Follies",
            "underline": "--------------",
            "tag_text": "#RedRanger #Whitby42 #Mistakes",
            "rst_text": "Some witty epigram. Including < & > characters.",
            "date": "2013-11-18 15:30:00"
        }
    ],
    "title": "Travel"
}

前面的输出向我们展示了各种对象是如何从 Python 转换为 JSON 表示的。这种方法的优雅之处在于我们的 Python 对象已经被写入了一个标准化的表示法。我们可以与其他应用程序共享它们。我们可以将它们写入磁盘文件并保存它们。JSON 表示的一些不愉快特性有:

  • 我们不得不将我们的 Python 对象重写为字典。更好的方法是以更简单的方式转换 Python 对象,而不需要显式创建额外的字典。

  • 当我们加载这个 JSON 表示时,我们无法轻松地重建我们原来的BlogPost对象。当我们使用json.load()时,我们得到的不是BlogPost对象,而是dict和列表对象。我们需要提供一些额外的提示来重建BlogPost对象。

  • 对象的__dict__中有一些值我们不想持久化,比如Post的下划线文本。

我们需要比内置的 JSON 编码更复杂的东西。

在我们的类中支持 JSON

为了正确支持 JSON,我们需要通知 JSON 编码器和解码器关于我们的类。为了将我们的对象编码为 JSON,我们需要提供一个函数,将我们的对象减少为 Python 原始类型。这被称为默认函数;它为未知类的对象提供默认编码。

为了从 JSON 中解码我们的对象,我们需要提供一个函数,将 Python 原始类型的字典转换回适当类的对象。这被称为对象钩子函数;它用于将dict转换为自定义类的对象。

json模块文档建议我们可能希望使用类提示。Python 文档包括对 JSON-RPC 版本 1 规范的引用。参见json-rpc.org/wiki/specification。这个建议是将自定义类的实例编码为以下的字典:

{"__jsonclass__": ["class name", [param1,...]] }

"__jsonclass__"键关联的建议值是一个包含两个项目的列表:类名和创建该类实例所需的参数列表。规范允许更多的特性,但它们与 Python 无关。

从 JSON 字典中解码对象时,我们可以查找"__jsonclass__"键作为提示,表明我们需要构建一个类,而不是一个内置的 Python 对象。类名可以映射到一个类对象,并且参数序列可以用来构建实例。

当我们查看其他复杂的 JSON 编码器(比如 Django Web 框架自带的编码器)时,我们可以看到它们提供了更复杂的自定义类编码。它们包括类、数据库主键和属性值。我们将看看如何实现自定义编码和解码。规则被表示为简单的函数,这些函数被插入到 JSON 编码和解码函数中。

自定义 JSON 编码

对于类提示,我们将提供三个信息。我们将包括一个__class__键,命名目标类。__args__键将提供一个位置参数值的序列。__kw__键将提供一个关键字参数值的字典。这将涵盖__init__()的所有选项。以下是遵循这种设计的编码器:

def blog_encode( object ):
    if isinstance(object, datetime.datetime):
        return dict(
            __class__= "datetime.datetime",
            __args__= [],
            __kw__= dict(
                year= object.year,
                month= object.month,
                day= object.day,
                hour= object.hour,
                minute= object.minute,
                second= object.second,
            )
        )
    elif isinstance(object, Post):
        return dict(
            __class__= "Post",
            __args__= [],
            __kw__= dict(
                date= object.date,
                title= object.title,
                rst_text= object.rst_text,
                tags= object.tags,
            )
        )
    elif isinstance(object, Blog):
        return dict(
            __class__= "Blog",
            __args__= [
                object.title,
                object.entries,
            ],
            __kw__= {}
        )
    else:
        return json.JSONEncoder.default(o)

这个函数展示了三个类的两种不同风格的对象编码:

  • 我们将一个datetime.datetime对象编码为一个单独字段的字典

  • 我们还将一个Post实例编码为一个单独字段的字典

  • 我们将一个Blog实例编码为标题和文章条目的序列

如果我们无法处理这个类,我们会调用现有编码器的默认编码。这将处理内置类。我们可以使用这个函数进行编码,如下所示:

text= json.dumps(travel, indent=4, default=blog_encode)

我们将我们的函数blog_encode()作为json.dumps()函数的default=关键字参数提供。这个函数被 JSON 编码器用来确定对象的编码。这个编码器导致的 JSON 对象看起来像下面的代码:

{
    "__args__": [
        "Travel",
        [
            {
                "__args__": [],
                "__kw__": {
                    "tags": [
                        "#RedRanger",
                        "#Whitby42",
                        "#ICW"
                    ],
                    "rst_text": "Some embarrassing revelation. Including \u2639 and \u2693",
                    "date": {
                        "__args__": [],
                        "__kw__": {
                            "minute": 25,
                            "hour": 17,
                            "day": 14,
                            "month": 11,
                            "year": 2013,
                            "second": 0
                        },
                        "__class__": "datetime.datetime"
                    },
                    "title": "Hard Aground"
                },
                "__class__": "Post"
            },
.
.
.
    "__kw__": {},
    "__class__": "Blog"
}

我们删除了第二个博客条目,因为输出太长了。现在,Blog对象用一个提供类和两个位置参数值的dict包装起来。同样,Postdatetime对象也用类名和关键字参数值包装起来。

自定义 JSON 解码

为了解码一个 JSON 对象,我们需要在 JSON 解析的结构内工作。我们定制的类定义的对象被编码为简单的dicts。这意味着每个被 JSON 解码的dict 可能 是我们定制的类之一。或者,dict可能只是一个dict

JSON 解码器的“对象钩子”是一个函数,它会为每个dict调用,以查看它是否表示一个定制对象。如果dict不被hook函数识别,那么它只是一个字典,应该原样返回。这是我们的对象钩子函数:

def blog_decode( some_dict ):
    if set(some_dict.keys()) == set( ["__class__", "__args__", "__kw__"] ):
        class_= eval(some_dict['__class__'])
        return class_( *some_dict['__args__'], **some_dict['__kw__'] )
    else:
        return some_dict

每次调用此函数时,它都会检查定义对象编码的键。如果存在这三个键,那么将使用给定的参数和关键字调用该函数。我们可以使用这个对象钩子来解析 JSON 对象,如下所示:

blog_data= json.loads(text, object_hook= blog_decode)

这将解码一块以 JSON 表示的文本,使用我们的blog_decode()函数将dict转换为正确的BlogPost对象。

安全和 eval()问题

一些程序员会反对在我们的blog_decode()函数中使用eval()函数,声称这是一个普遍存在的安全问题。可笑的是声称eval()是一个普遍存在的问题。如果恶意代码被写入 JSON 对象的表示中,那么它就是一个潜在的安全问题,这是一个本地的 EGP 可以访问 Python 源代码。为什么要去微调 JSON 文件?为什么不直接编辑 Python 源代码呢?

作为一个实际问题,我们必须考虑通过互联网传输 JSON 文档;这是一个实际的安全问题。然而,这并不是一般情况下对eval()的控诉。

必须考虑一种情况,即一个不可信的文档被中间人攻击篡改。在这种情况下,一个 JSON 文档在通过包括一个不可信的服务器作为代理的网络接口时被篡改。SSL 通常是防止这个问题的首选方法。

如果有必要,我们可以用一个从名称到类的映射字典来替换eval()。我们可以将eval(some_dict['__class__'])改为{"Post":Post, "Blog":Blog, "datetime.datetime":datetime.datetime

}[some_dict['__class__']]

这将防止在通过非 SSL 编码连接传递 JSON 文档时出现问题。这也导致了一个维护要求,即每当应用程序设计发生变化时,都需要微调这个映射。

重构编码函数

理想情况下,我们希望重构我们的编码函数,专注于每个定义类的正确编码的责任。我们不想把所有的编码规则堆积到一个单独的函数中。

要使用诸如datetime之类的库类来做到这一点,我们需要为我们的应用程序扩展datetime.datetime。如果我们这样做了,我们需要确保我们的应用程序使用我们扩展的datetime而不是datetime库。这可能会变得有点头疼,以避免使用内置的datetime类。通常,我们必须在我们定制的类和库类之间取得平衡。以下是将创建 JSON 可编码类定义的两个类扩展。我们可以向Blog添加一个属性:

    @property
    def _json( self ):
        return dict( __class__= self.__class__.__name__,
            __kw__= {},
            __args__= [ self.title, self.entries ]
        )

这个属性将提供初始化参数,可供我们的解码函数使用。我们可以将这两个属性添加到Post中:

    @property
    def _json( self ):
        return dict(
            __class__= self.__class__.__name__,
            __kw__= dict(
                date= self.date,
                title= self.title,
                rst_text= self.rst_text,
                tags= self.tags,
            ),
            __args__= []
        )

Blog一样,这个属性将提供初始化参数,可供我们的解码函数使用。我们可以修改编码器,使其变得更简单一些。以下是修订后的版本:

def blog_encode_2( object ):
    if isinstance(object, datetime.datetime):
        return dict(
            __class__= "datetime.datetime",
            __args__= [],
            __kw__= dict(
                year= object.year,
                month= object.month,
                day= object.day,
                hour= object.hour,
                minute= object.minute,
                second= object.second,
            )
        )
    else:
        try:
            encoding= object._json()
        except AttributeError:
            encoding= json.JSONEncoder.default(o)
        return encoding

我们仍然受到使用库datetime模块的选择的限制。在这个例子中,我们选择不引入子类,而是将编码处理为特殊情况。

标准化日期字符串

我们对日期的格式化没有使用广泛使用的 ISO 标准文本日期格式。为了与其他语言更兼容,我们应该正确地对datetime对象进行标准字符串编码和解析标准字符串。

由于我们已经将日期视为特殊情况,这似乎是对该特殊情况处理的合理扩展。这可以在不太改变我们的编码和解码的情况下完成。考虑对编码进行的这个小改变:

    if isinstance(object, datetime.datetime):
        fmt= "%Y-%m-%dT%H:%M:%S"
        return dict(
            __class__= "datetime.datetime.strptime",
            __args__= [ object.strftime(fmt), fmt ],
            __kw__= {}
        )

编码输出命名了静态方法datetime.datetime.strptime(),并提供了编码的参数datetime以及要用于解码的格式。现在,帖子的输出看起来像以下代码片段:

            {
                "__args__": [],
                "__class__": "Post_J",
                "__kw__": {
                    "title": "Anchor Follies",
                    "tags": [
                        "#RedRanger",
                        "#Whitby42",
                        "#Mistakes"
                    ],
                    "rst_text": "Some witty epigram.",
                    "date": {
                        "__args__": [
                            "2013-11-18T15:30:00",
                            "%Y-%m-%dT%H:%M:%S"
                        ],
                        "__class__": "datetime.datetime.strptime",
                        "__kw__": {}
                    }
                }
            }

这向我们表明,现在我们有一个 ISO 格式的日期,而不是单独的字段。我们还摆脱了使用类名创建对象的方式。__class__值扩展为类名或静态方法名。

将 JSON 写入文件

当我们写 JSON 文件时,我们通常会这样做:

with open("temp.json", "w", encoding="UTF-8") as target:
    json.dump( travel3, target, separators=(',', ':'), default=blog_j2_encode )

我们使用所需的编码打开文件。我们将文件对象提供给json.dump()方法。当我们读取 JSON 文件时,我们将使用类似的技术:

with open("some_source.json", "r", encoding="UTF-8") as source:objects= json.load( source, object_hook= blog_decode)

这个想法是将 JSON 表示作为文本与生成文件上的字节转换分开。JSON 中有一些可用的格式选项。我们展示了缩进四个空格,因为这似乎产生了漂亮的 JSON。作为替代,我们可以通过留下缩进选项使输出更紧凑。通过使分隔符更简洁,我们甚至可以使其更加紧凑。以下是在temp.json中创建的输出:

{"__class__":"Blog_J","__args__":["Travel",[{"__class__":"Post_J","__args__":[],"__kw__":{"rst_text":"Some embarrassing revelation.","tags":["#RedRanger","#Whitby42","#ICW"],"title":"Hard Aground","date":{"__class__":"datetime.datetime.strptime","__args__":["2013-11-14T17:25:00","%Y-%m-%dT%H:%M:%S"],"__kw__":{}}}},{"__class__":"Post_J","__args__":[],"__kw__":{"rst_text":"Some witty epigram.","tags":["#RedRanger","#Whitby42","#Mistakes"],"title":"Anchor Follies","date":{"__class__":"datetime.datetime.strptime","__args__":["2013-11-18T15:30:00","%Y-%m-%dT%H:%M:%S"],"__kw__":{}}}}]],"__kw__":{}}

使用 YAML 进行转储和加载

yaml.org网页指出:

YAML™(与“骆驼”押韵)是一种人性化的、跨语言的、基于 Unicode 的数据序列化语言,旨在围绕敏捷编程语言的常见本机数据类型设计。

json模块的 Python 标准库文档指出:

JSON 是 YAML 1.2 的子集。此模块的默认设置(特别是默认分隔符值)生成的 JSON 也是 YAML 1.0 和 1.1 的子集。因此,该模块也可以用作 YAML 序列化器。

从技术上讲,我们可以使用json模块准备 YAML 数据。但是,json模块无法用于反序列化更复杂的 YAML 数据。YAML 的两个好处。首先,它是一种更复杂的表示法,允许我们对我们的对象编码更多的细节。其次,PyYAML 实现与 Python 有深度集成,使我们能够非常简单地创建 Python 对象的 YAML 编码。YAML 的缺点是它没有像 JSON 那样被广泛使用。我们需要下载和安装一个 YAML 模块。可以在pyyaml.org/wiki/PyYAML找到一个好的模块。安装了包之后,我们可以以 YAML 表示法转储我们的对象:

import yaml
text= yaml.dump(travel2)
print( text )

这是我们微博的 YAML 编码:

!!python/object:__main__.Blog
entries:
- !!python/object:__main__.Post
  date: 2013-11-14 17:25:00
  rst_text: Some embarrassing revelation. Including ☹ and ⎕
  tags: !!python/tuple ['#RedRanger', '#Whitby42', '#ICW']
  title: Hard Aground
- !!python/object:__main__.Post
  date: 2013-11-18 15:30:00
  rst_text: Some witty epigram. Including < & > characters.
  tags: !!python/tuple ['#RedRanger', '#Whitby42', '#Mistakes']
  title: Anchor Follies

输出相对简洁,但也非常完整。此外,我们可以轻松编辑 YAML 文件以进行更新。类名使用 YAML !!标记进行编码。YAML 包含 11 个标准标记。yaml模块包括十几个特定于 Python 的标记,以及五个复杂的 Python 标记。

Python 类名由定义模块限定。在我们的情况下,该模块碰巧是一个简单的脚本,因此类名是__main__.Blog__main__.Post。如果我们从另一个模块导入这些类,类名将反映定义类的模块。

列表中的项目以块序列形式显示。每个项目以-序列开头;其余项目缩进两个空格。当listtuple足够小,它可以流到一行。如果它变得更长,它将换行到多行。要从 YAML 文档加载 Python 对象,我们可以使用以下代码:

copy= yaml.load(text)

这将使用标记信息来定位类定义,并将在 YAML 文档中找到的值提供给类构造函数。我们的微博对象将被完全重建。

在文件上格式化 YAML 数据

当我们写 YAML 文件时,我们通常会做这样的事情:

with open("some_destination.yaml", "w", encoding="UTF-8") as target:
    yaml.dump( some_collection, target )

我们以所需的编码打开文件。我们将文件对象提供给yaml.dump()方法;输出将写入那里。当我们读取 YAML 文件时,我们将使用类似的技术:

with open("some_source.yaml", "r", encoding="UTF-8") as source:objects= yaml.load( source )

将 YAML 表示作为文本与结果文件上的字节转换分开的想法。我们有几种格式选项来创建更漂亮的 YAML 表示我们的数据。以下表格显示了一些选项:

explicit_start 如果为true,在每个对象之前写入一个---标记。
explicit_end 如果为true,在每个对象之后写入一个...标记。如果我们将一系列 YAML 文档转储到单个文件并且需要知道一个结束和下一个开始时,我们可能会使用这个或explicit_start
version 给定一对整数(x,y),在开头写入%YAML x.y指令。这应该是version=(1,2)
tags 给定一个映射,它会发出一个带有不同标记缩写的 YAML %TAG指令。
canonical 如果为true,则在每个数据片段上包括一个标记。如果为 false,则假定一些标记。
indent 如果设置为一个数字,改变用于块的缩进。
width 如果设置为一个数字,改变长项换行到多个缩进行的宽度。
allow_unicode 如果设置为true,允许完全使用 Unicode 而无需转义。否则,ASCII 子集之外的字符将被应用转义。
line_break 使用不同的换行符;默认为换行符。

在这些选项中,explicit_endallow_unicode可能是最有用的。

扩展 YAML 表示

有时,我们的类之一具有整洁的表示,比默认的 YAML 转储属性值更好。例如,我们的 Blackjack Card类定义的默认 YAML 将包括一些我们不需要保留的派生值。

yaml模块包括为类定义添加representerconstructor的规定。representer 用于创建 YAML 表示,包括标记和值。构造函数用于从给定值构建 Python 对象。这是另一个Card类层次结构:

class Card:
    def __init__( self, rank, suit, hard=None, soft=None ):
        self.rank= rank
        self.suit= suit
        self.hard= hard or int(rank)
        self.soft= soft or int(rank)
    def __str__( self ):
        return "{0.rank!s}{0.suit!s}".format(self)

class AceCard( Card ):
    def __init__( self, rank, suit ):
        super().__init__( rank, suit, 1, 11 )

class FaceCard( Card ):
    def __init__( self, rank, suit ):
        super().__init__( rank, suit, 10, 10 )

我们使用了数字卡的超类,并为 A 和面值卡定义了两个子类。在先前的示例中,我们广泛使用了工厂函数来简化构建。工厂处理了从 1 到AceCar类的等级的映射,以及从 11、12 和 13 等级到FaceCard类的映射。这是必不可少的,这样我们就可以轻松地使用简单的range(1,14)来构建一副牌。

从 YAML 加载时,类将通过 YAML!!标记完全拼写出来。唯一缺少的信息将是与卡片的每个子类关联的硬值和软值。硬点和软点有三种相对简单的情况,可以通过可选的初始化参数来处理。当我们将这些对象转储到 YAML 格式时,它看起来是这样的:

- !!python/object:__main__.AceCard {hard: 1, rank: A, soft: 11, suit: ♣}
- !!python/object:__main__.Card {hard: 2, rank: '2', soft: 2, suit: ♥}
- !!python/object:__main__.FaceCard {hard: 10, rank: K, soft: 10, suit: ♦}

这些是正确的,但对于像扑克牌这样简单的东西来说可能有点啰嗦。我们可以扩展yaml模块,以便为这些简单对象生成更小、更专注的输出。我们将为Card子类定义表示和构造函数。以下是三个函数和注册:

def card_representer(dumper, card):
    return dumper.represent_scalar('!Card',
    "{0.rank!s}{0.suit!s}".format(card) )
def acecard_representer(dumper, card):
    return dumper.represent_scalar('!AceCard',
    "{0.rank!s}{0.suit!s}".format(card) )
def facecard_representer(dumper, card):
    return dumper.represent_scalar('!FaceCard',
    "{0.rank!s}{0.suit!s}".format(card) )

yaml.add_representer(Card, card_representer)
yaml.add_representer(AceCard, acecard_representer)
yaml.add_representer(FaceCard, facecard_representer)

我们已将每个Card实例表示为一个简短的字符串。YAML 包括一个标记,显示应从字符串构建哪个类。所有三个类使用相同的格式字符串。这恰好与__str__()方法匹配,从而导致潜在的优化。

我们需要解决的另一个问题是从解析的 YAML 文档构造Card实例。为此,我们需要构造函数。以下是三个构造函数和注册:

def card_constructor(loader, node):
    value = loader.construct_scalar(node)
    rank, suit= value[:-1], value[-1]
    return Card( rank, suit )

def acecard_constructor(loader, node):
    value = loader.construct_scalar(node)
    rank, suit= value[:-1], value[-1]
    return AceCard( rank, suit )

def facecard_constructor(loader, node):
    value = loader.construct_scalar(node)
    rank, suit= value[:-1], value[-1]
    return FaceCard( rank, suit )

yaml.add_constructor('!Card', card_constructor)
yaml.add_constructor('!AceCard', acecard_constructor)
yaml.add_constructor('!FaceCard', facecard_constructor)

当解析标量值时,标记将用于定位特定的构造函数。然后构造函数可以分解字符串并构建Card实例的适当子类。这是一个快速演示,演示了每个类的一张卡片:

deck = [ AceCard('A','♣',1,11), Card('2','♥',2,2), FaceCard('K','♦',10,10) ]
text= yaml.dump( deck, allow_unicode=True )

以下是输出:

[!AceCard 'A♣', !Card '2♥', !FaceCard 'K♦']

这给我们提供了可以用来重建 Python 对象的卡片的简短而优雅的 YAML 表示。

我们可以使用以下简单语句重新构建我们的 3 张牌组:

cards= yaml.load( text )

这将解析表示,使用构造函数,并构建预期的对象。因为构造函数确保适当的初始化完成,硬值和软值的内部属性将被正确重建。

安全和安全加载

原则上,YAML 可以构建任何类型的对象。这允许对通过互联网传输 YAML 文件的应用程序进行攻击,而不需要适当的 SSL 控制。

YAML 模块提供了一个safe_load()方法,拒绝执行任意 Python 代码作为构建对象的一部分。这严重限制了可以加载的内容。对于不安全的数据交换,我们可以使用yaml.safe_load()来创建仅包含内置类型的 Pythondictlist对象。然后我们可以从dictlist实例构建我们的应用程序类。这与我们使用 JSON 或 CSV 交换必须用于创建正确对象的dict的方式有些相似。

更好的方法是为我们自己的对象使用yaml.YAMLObject混合类。我们使用这个类来设置一些类级别的属性,为yaml提供提示,并确保对象的安全构建。以下是我们如何定义用于安全传输的超类:

class Card2( yaml.YAMLObject ):
    yaml_tag = '!Card2'
    yaml_loader= yaml.SafeLoader

这两个属性将警告yaml,这些对象可以安全加载,而不会执行任意和意外的 Python 代码。Card2的每个子类只需设置将要使用的唯一 YAML 标记:

class AceCard2( Card2 ):
    yaml_tag = '!AceCard2'

我们添加了一个属性,警告yaml,这些对象仅使用此类定义。这些对象可以安全加载;它们不执行任意不可信代码。

通过对类定义进行这些修改,我们现在可以在 YAML 流上使用yaml.safe_load(),而不必担心文档在不安全的互联网连接上插入恶意代码。对我们自己的对象使用yaml.YAMLObject混合类以及设置yaml_tag属性具有几个优点。它导致文件稍微更紧凑。它还导致更美观的 YAML 文件——长而通用的!!python/object:__main__.AceCard标记被更短的!AceCard2标记替换。

使用 pickle 进行转储和加载

pickle模块是 Python 的本机格式,用于使对象持久化。

Python 标准库对pickle的描述如下:

pickle 模块可以将复杂对象转换为字节流,并且可以将字节流转换为具有相同内部结构的对象。对这些字节流最明显的用途可能是将它们写入文件,但也可以想象将它们发送到网络或存储在数据库中。

pickle的重点是 Python,仅限于 Python。这不是诸如 JSON、YAML、CSV 或 XML 之类的数据交换格式,可以与其他语言编写的应用程序一起使用。

pickle模块与 Python 紧密集成在各种方式。例如,类的__reduce__()__reduce_ex__()方法存在以支持pickle处理。

我们可以轻松地将我们的微博 pickle 如下:

import pickle
with open("travel_blog.p","wb") as target:
    pickle.dump( travel, target )

将整个travel对象导出到给定文件。该文件以原始字节形式写入,因此open()函数使用"wb"模式。

我们可以通过以下方式轻松恢复一个 picked 对象:

with open("travel_blog.p","rb") as source:
    copy= pickle.load( source )

由于 pickled 数据是以字节形式写入的,因此文件必须以"rb"模式打开。pickled 对象将正确绑定到适当的类定义。底层的字节流不是为人类消费而设计的。它在某种程度上是可读的,但它不像 YAML 那样设计用于可读性。

设计一个可靠的 pickle 处理类

类的__init__()方法实际上并不用于取消封存对象。通过使用__new__()并将 pickled 值直接设置到对象的__dict__中,__init__()方法被绕过。当我们的类定义包括__init__()中的一些处理时,这一区别很重要。例如,如果__init__()打开外部文件,创建 GUI 界面的某个部分,或者对数据库执行某些外部更新,则在取消封存时不会执行这些操作。

如果我们在__init__()处理期间计算一个新的实例变量,就没有真正的问题。例如,考虑一个 BlackjackHand对象,在创建Hand时计算Card实例的总数。普通的pickle处理将保留这个计算出的实例变量。在取消封存对象时,不会重新计算它。先前计算出的值将被简单地取消封存。

依赖于__init__()期间处理的类必须特别安排以确保此初始处理将正确进行。我们可以做两件事:

  • 避免在__init__()中进行急切的启动处理。相反,进行一次性的初始化处理。例如,如果有外部文件操作,必须推迟到需要时才执行。

  • 定义__getstate__()__setstate__()方法,这些方法可以被 pickle 用来保存状态和恢复状态。然后,__setstate__()方法可以调用与__init__()在普通 Python 代码中执行一次性初始化处理的相同方法。

我们将看一个例子,其中由__init__()方法记录为审计目的加载到Hand中的初始Card实例。以下是在取消封存时无法正常工作的Hand版本:

class Hand_x:
    def __init__( self, dealer_card, *cards ):
        self.dealer_card= dealer_card
        self.cards= list(cards)
        **for c in self.cards:
 **audit_log.info( "Initial %s", c )
    def append( self, card ):
        self.cards.append( card )
        **audit_log.info( "Hit %s", card )
    def __str__( self ):
        cards= ", ".join( map(str,self.cards) )
        return "{self.dealer_card} | {cards}".format( self=self, cards=cards )

这有两个记录位置:在__init__()append()期间。__init__()处理在初始对象创建和取消封存以重新创建对象之间不能一致工作。以下是用于查看此问题的日志设置:

import logging,sys
audit_log= logging.getLogger( "audit" )
logging.basicConfig(stream=sys.stderr, level=logging.INFO)

此设置创建日志并确保日志级别适合查看审计信息。以下是一个快速脚本,用于构建、pickle 和 unpickleHand

h = Hand_x( FaceCard('K','♦'), AceCard('A','♣'), Card('9','♥') )
data = pickle.dumps( h )
h2 = pickle.loads( data )

当我们执行这个时,我们发现在处理__init__()时写入的日志条目在反拾取Hand时没有被写入。为了正确地为反拾取编写审计日志,我们可以在这个类中放置延迟日志测试。例如,我们可以扩展__getattribute__()以在从这个类请求任何属性时写入初始日志条目。这导致了有状态的日志记录和每次手对象执行操作时执行的if语句。一个更好的解决方案是利用pickle保存和恢复状态的方式。

class Hand2:
    def __init__( self, dealer_card, *cards ):
        self.dealer_card= dealer_card
        self.cards= list(cards)
        for c in self.cards:
            audit_log.info( "Initial %s", c )
    def append( self, card ):
        self.cards.append( card )
        audit_log.info( "Hit %s", card )
    def __str__( self ):
        cards= ", ".join( map(str,self.cards) )
        return "{self.dealer_card} | {cards}".format( self=self, cards=cards )
    def __getstate__( self ):
        return self.__dict__
    def __setstate__( self, state ):
        self.__dict__.update(state)
        for c in self.cards:
            audit_log.info( "Initial (unpickle) %s", c )

__getstate__() 方法在拾取时用于收集对象的当前状态。这个方法可以返回任何东西。例如,对于具有内部记忆缓存的对象,缓存可能不会被拾取以节省时间和空间。这个实现使用内部的__dict__而没有任何修改。

__setstate__() 方法在反拾取时用于重置对象的值。这个版本将状态合并到内部的__dict__中,然后写入适当的日志条目。

安全和全局问题

在反拾取期间,pickle 流中的全局名称可能导致任意代码的评估。一般来说,全局名称是类名或函数名。然而,可能包括一个函数名是ossubprocess等模块中的全局名称。这允许对试图通过互联网传输拾取对象的应用程序进行攻击,而没有强大的 SSL 控制。这对于完全本地文件来说并不是问题。

为了防止执行任意代码,我们必须扩展pickle.Unpickler类。我们将覆盖find_class()方法以替换为更安全的内容。我们必须考虑几个反拾取问题,例如:

  • 我们必须防止使用内置的exec()eval()函数。

  • 我们必须防止使用可能被认为是不安全的模块和包。例如,应该禁止使用sysos

  • 我们必须允许使用我们的应用程序模块。

以下是一个施加一些限制的示例:

import builtins
class RestrictedUnpickler(pickle.Unpickler):
    def find_class(self, module, name):
        if module == "builtins":
            if name not in ("exec", "eval"):
                 return getattr(builtins, name)
        elif module == "__main__":
            return globals()[name]
        # elif module in any of our application modules...
        raise pickle.UnpicklingError(
        "global '{module}.{name}' is forbidden".format(module=module, name=name))

这个Unpickler类的版本将帮助我们避免由篡改的 pickle 流可能引起的大量潜在问题。它允许使用除了exec()eval()之外的任何内置函数。它允许仅在__main__中定义的类的使用。在所有其他情况下,它会引发异常。

使用 CSV 进行转储和加载

csv模块将简单的listdict实例编码和解码为 CSV 符号。与之前讨论的json模块一样,这并不是一个非常完整的持久性解决方案。然而,由于 CSV 文件的广泛采用,通常需要在 Python 对象和 CSV 之间进行转换。

处理 CSV 文件涉及我们的对象和 CSV 结构之间的手动映射。我们需要仔细设计映射,注意 CSV 符号的限制。这可能很困难,因为对象的表达能力与 CSV 文件的表格结构之间存在不匹配。

CSV 文件的每一列的内容—根据定义—都是纯文本。从 CSV 文件加载数据时,我们需要将这些值转换为更有用的类型。这种转换可能会受到电子表格执行意外类型强制转换的影响。例如,我们可能有一个电子表格,其中美国邮政编码已被电子表格应用程序更改为浮点数。当电子表格保存为 CSV 时,邮政编码可能会变成看起来奇怪的数值。

因此,我们可能需要使用转换,比如('00000'+row['zip'])[-5:]来恢复前导零。另一种情况是必须使用类似"{0:05.0f}".format(float(row['zip']))来恢复前导零。另外,不要忘记文件可能包含 ZIP 和 ZIP+4 邮政编码的混合,这使得这个过程更具挑战性。

为了更复杂地处理 CSV 文件,我们必须意识到它们经常被手动操作,并且由于人为调整,它们经常不兼容。软件在面对现实世界中出现的不规则性时保持灵活是很重要的。

当我们有相对简单的类定义时,我们通常可以将每个实例转换为简单的扁平数据值行。通常情况下,namedtuple是 CSV 源文件和 Python 对象之间的良好匹配。反过来,如果我们的应用程序将数据保存在 CSV 符号中,我们可能需要围绕namedtuples设计我们的 Python 类。

当我们有容器类时,通常很难确定如何在扁平的 CSV 行中表示结构化容器。这是对象模型和用于 CSV 文件或关系数据库的扁平规范化表结构之间的阻抗不匹配。阻抗不匹配没有好的解决方案;它需要仔细设计。我们将从简单的扁平对象开始,向您展示一些 CSV 映射。

将简单序列转储到 CSV

理想的映射是namedtuple实例和 CSV 文件中的行之间的映射。每一行代表一个不同的namedtuple。考虑以下 Python 类:

from collections import namedtuple
GameStat = namedtuple( "GameStat", "player,bet,rounds,final" )

我们已经定义了对象为简单的扁平属性序列。数据库架构师称之为第一范式。没有重复的组,每个项目都是原子数据。我们可能会从一个看起来像以下代码的模拟中产生这些对象:

def gamestat_iter( player, betting, limit=100 ):
    for sample in range(30):
        b = Blackjack( player(), betting() )
        b.until_broke_or_rounds(limit)
        yield GameStat( player.__name__, betting.__name__, b.rounds, b.betting.stake )

这个迭代器将创建具有给定玩家和投注策略的二十一点模拟。它将执行游戏,直到玩家破产或者在 100 个独立的游戏回合中坐在桌子旁。在每个会话结束时,它将产生一个带有玩家策略、投注策略、回合数和最终赌注的GameStat对象。这将允许我们为每个玩法或投注策略或组合计算统计数据。以下是我们如何将其写入文件以供以后分析:

import csv
with open("blackjack.stats","w",newline="") as target:
    writer= csv.DictWriter( target, GameStat._fields )
    writer.writeheader()
    for gamestat in gamestat_iter( Player_Strategy_1, Martingale_Bet ):
        writer.writerow( gamestat._asdict() )

创建 CSV 写入器有三个步骤:

  1. 打开一个带有 newline 选项设置为""的文件。这将支持 CSV 文件的(可能)非标准行结束。

  2. 创建 CSV writer对象。在这个例子中,我们创建了DictWriter实例,因为它允许我们轻松地从字典对象创建行。

  3. 在文件的第一行放一个标题。这样做可以通过提供一些关于 CSV 文件中内容的提示,使数据交换稍微简单一些。

一旦writer对象准备好了,我们可以使用 writer 的writerow()方法将每个字典写入 CSV 文件。我们可以在一定程度上通过使用writerows()方法稍微简化这个过程。这个方法期望一个迭代器而不是一个单独的行。以下是我们如何使用writerows()与一个迭代器:

data = gamestat_iter( Player_Strategy_1, Martingale_Bet )
with open("blackjack.stats","w",newline="") as target:
    writer= csv.DictWriter( target, GameStat._fields )
    writer.writeheader()
    writer.writerows( g._asdict() for g in data )

我们将迭代器分配给一个变量data。对于writerows()方法,我们从迭代器产生的每一行得到一个字典。

从 CSV 加载简单序列

我们可以使用类似以下代码的循环从 CSV 文件中加载简单的顺序对象:

with open("blackjack.stats","r",newline="") as source:
    reader= csv.DictReader( source )
    for gs in **( GameStat(**r) for r in reader )**:
        print( gs )

我们为文件定义了一个reader对象。由于我们知道文件有一个适当的标题,我们可以使用DictReader。这将使用第一行来定义属性名称。现在我们可以从 CSV 文件中的行构造GameStat对象。我们使用了一个生成器表达式来构建行。

在这种情况下,我们假设列名与我们的GameStat类定义的属性名匹配。如果必要,我们可以通过比较reader.fieldnamesGameStat._fields来确认文件是否与预期格式匹配。由于顺序不必匹配,我们需要将每个字段名称列表转换为集合。以下是我们如何检查列名:

assert set(reader.fieldnames) == set(GameStat._fields)

我们忽略了从文件中读取的值的数据类型。当我们从 CSV 文件中读取时,两个数值列将最终成为字符串值。因此,我们需要进行更复杂的逐行转换,以创建正确的数据值。以下是执行所需转换的典型工厂函数:

def gamestat_iter(iterator):
    for row in iterator:
        yield GameStat( row['player'], row['bet'], int(row['rounds']), int(row['final']) )

我们已经将int函数应用于应该具有数值的列。在文件具有正确的标题但数据不正确的罕见情况下,我们将从失败的“int()”函数中获得普通的ValueError。我们可以使用这个生成器函数如下:

with open("blackjack.stats","r",newline="") as source:
    reader= csv.DictReader( source )
    assert set(reader.fieldnames) == set(GameStat._fields)
    for gs in gamestat_iter(reader):
        print( gs )

这个版本的读取器通过对数值进行转换,正确重建了GameStat对象。

处理容器和复杂类

当我们回顾我们的微博示例时,我们有一个包含许多Post实例的Blog对象。我们设计Blog作为list的包装器,以便Blog包含一个集合。在处理 CSV 表示时,我们必须设计从复杂结构到表格表示的映射。我们有三种常见的解决方案:

  • 我们可以创建两个文件:一个博客文件和一个帖子文件。博客文件只包含Blog实例。在我们的示例中,每个Blog都有一个标题。然后,每个Post行可以引用帖子所属的Blog行。我们需要为每个Blog添加一个键。然后,每个Post将具有对Blog键的外键引用。

  • 我们可以在单个文件中创建两种类型的行。我们将有Blog行和Post行。我们的写入器纠缠了各种类型的数据;我们的读取器必须解开数据类型。

  • 我们可以在各种行之间执行关系数据库连接,重复在每个Post子行上的Blog父信息。

在这些选择中没有最佳解决方案。我们必须设计一个解决扁平 CSV 行和更结构化的 Python 对象之间的阻抗不匹配的解决方案。数据的用例将定义一些优点和缺点。

创建两个文件需要我们为每个Blog创建某种唯一标识符,以便Post可以正确地引用Blog。我们不能轻易使用 Python 内部 ID,因为这些 ID 不能保证在每次 Python 运行时保持一致。

一个常见的假设是Blog标题是一个唯一的键;由于这是Blog的属性,它被称为自然主键。这很少能奏效;我们不能更改Blog标题而不更新所有引用BlogPosts。一个更好的计划是发明一个唯一标识符,并更新类设计以包括该标识符。这被称为代理键。Python 的uuid模块可以为此目的提供唯一标识符。

使用多个文件的代码几乎与先前的示例相同。唯一的变化是为Blog类添加适当的主键。一旦定义了键,我们就可以像以前一样创建写入器和读取器来处理BlogPost实例到它们各自的文件中。

在 CSV 文件中转储和加载多种行类型

在单个文件中创建多种类型的行使格式变得更加复杂。列标题必须成为所有可用列标题的并集。由于各种行类型之间可能存在名称冲突的可能性,我们可以通过位置访问行,防止我们简单地使用csv.DictReader,或者我们必须发明一个更复杂的列标题,结合类和属性名称。

如果我们为每一行提供一个额外的列作为类别鉴别器,那么这个过程就会更简单。这个额外的列告诉我们行代表的是什么类型的对象。对象的类名会很好地起作用。以下是我们可能使用两种不同的行格式将博客和帖子写入单个 CSV 文件的方法:

with open("blog.csv","w",newline="") as target:
    wtr.writerow(['__class__','title','date','title','rst_text','tags'])
    wtr= csv.writer( target )
    for b in blogs:
        wtr.writerow(['Blog',b.title,None,None,None,None])
        for p in b.entries:
            wtr.writerow(['Post',None,p.date,p.title,p.rst_text,p.tags])

我们在文件中创建了两种行的变体。一些行在第一列中有'Blog',只包含Blog对象的属性。其他行在第一列中有'Post',只包含Post对象的属性。

我们没有使标题唯一,因此无法使用字典读取器。像这样按位置分配列时,每行都会根据它必须共存的其他类型的行来分配未使用的列。这些额外的列填充为None。随着不同行类型的数量增加,跟踪各个位置列的分配可能变得具有挑战性。

此外,单独的数据类型转换可能有些令人困惑。特别是,我们忽略了时间戳和标签的数据类型。我们可以尝试通过检查行鉴别器来重新组装我们的BlogsPosts

with open("blog.csv","r",newline="") as source:
    rdr= csv.reader( source )
    header= next(rdr)
    assert header == ['__class__','title','date','title','rst_text','tags']
    blogs = []
    for r in rdr:
        if r[0] == 'Blog':
 **blog= Blog( *r[1:2] )
            blogs.append( blog )
        if r[0] == 'Post':
 **post= post_builder( r )
            blogs[-1].append( post )

这段代码将构建一个Blog对象列表。每个'Blog'行使用slice(1,2)中的列来定义Blog对象。每个'Post'行使用slice(2,6)中的列来定义Post对象。这要求每个Blog后面都跟着相关的Post实例。外键不用于将这两个对象联系在一起。

我们对 CSV 文件中的列做了两个假设,即它们的顺序和类型与类构造函数的参数相同。对于Blog对象,我们使用了blog= Blog( *r[1:2] ),因为唯一的列是文本,这与类构造函数匹配。在处理外部提供的数据时,这个假设可能是无效的。

为了构建Post实例,我们使用了一个单独的函数来从列映射到类构造函数。以下是映射函数:

import ast
def builder( row ):
    return Post(
        date=datetime.datetime.strptime(row[2], "%Y-%m-%d %H:%M:%S"),
        title=row[3],
        rst_text=row[4],
        tags=ast.literal_eval(row[5]) )

这将从文本行正确构建一个Post实例。它将datetime的文本和标签的文本转换为它们正确的 Python 类型。这有一个使映射明确的优点。

在这个例子中,我们使用ast.literal_eval()来解码更复杂的 Python 文字值。这允许 CSV 数据包括一个字符串值的元组:"('#RedRanger', '#Whitby42', '#ICW')"。

使用迭代器过滤 CSV 行

我们可以重构先前的加载示例,通过迭代Blog对象而不是构建Blog对象的列表。这使我们能够浏览大型 CSV 文件并定位只有相关的BlogPost行。这个函数是一个生成器,分别产生每个单独的Blog实例:

def blog_iter(source):
    rdr= csv.reader( source )
    header= next(rdr)
    assert header == ['__class__','title','date','title','rst_text','tags']
    blog= None
    for r in rdr:
        if r[0] == 'Blog':
            if blog:
                **yield blog
            blog= Blog( *r[1:2] )
        if r[0] == 'Post':
            post= post_builder( r )
            blog.append( post )
    if blog:
        **yield blog

这个blog_iter()函数创建Blog对象并附加Post对象。每当出现一个Blog标题时,前一个Blog就完成了并且可以被产出。最后,最终的Blog对象也必须被产出。如果我们想要大量的Blog实例列表,我们可以使用以下代码:

with open("blog.csv","r",newline="") as source:
    blogs= list( blog_iter(source) )

这将使用迭代器在极少数情况下构建一个Blogs列表,实际上我们确实希望整个序列保存在内存中。我们可以使用以下方法逐个处理每个Blog,将其呈现为创建 RST 文件:

with open("blog.csv","r",newline="") as source:
    for b in blog_iter(source):
        with open(blog.title+'.rst','w') as rst_file:
            render( blog, rst_file )

我们使用blog_iter()函数来读取每个博客。读取后,它可以呈现为一个 RST 格式文件。一个单独的进程可以运行rst2html.py将每个博客转换为 HTML。

我们可以轻松地添加一个过滤器来处理只选择的Blog实例。我们可以添加一个if语句来决定应该呈现哪些Blogs,而不仅仅是呈现所有的Blog实例。

在 CSV 文件中转储和加载连接的行

将对象连接在一起意味着每一行都是一个子对象,与所有父对象连接在一起。这会导致每个子对象重复父对象的属性。当存在多层容器时,这可能导致大量重复的数据。

这种重复的优势在于每行都是独立的,不属于由其上面的行定义的上下文。我们不需要类鉴别器,因为父值为每个子对象重复。

这对于形成简单层次结构的数据效果很好;每个子对象都添加了一些父属性。当数据涉及更复杂的关系时,简单的父子模式就会崩溃。在这些例子中,我们将Post标签合并到一个文本列中。如果我们尝试将标签分成单独的列,它们将成为每个Post的子对象,这意味着Post的文本可能会重复出现。显然,这不是一个好主意!

列标题必须成为所有可用列标题的并集。由于各种行类型之间可能存在名称冲突的可能性,我们将用类名限定每个列名。这将导致列标题,如'Blog.title''Post.title',从而避免名称冲突。这允许使用DictReaderDictWriter而不是列的位置赋值。然而,这些有资格的名称并不会简单地匹配类定义的属性名称;这会导致更多的文本处理来解析列标题。以下是我们如何编写一个包含父属性和子属性的联合行:

with open("blog.csv","w",newline="") as target:
    wtr= csv.writer( target )
    wtr.writerow(['Blog.title','Post.date','Post.title', 'Post.tags','Post.rst_text'])
    for b in blogs:
        for p in b.entries:
            wtr.writerow([b.title,p.date,p.title,p.tags,p.rst_text])

我们看到了有资格的列标题。在这种格式中,每一行现在包含了Blog属性和Post属性的并集。这样更容易准备,因为不需要用None填充未使用的列。由于每个列名都是唯一的,我们也可以很容易地切换到DictWriter。以下是从 CSV 行重构原始容器的方法:

def blog_iter2( source ):
    rdr= csv.DictReader( source )
    assert set(rdr.fieldnames) == set(['Blog.title','Post.date','Post.title', 'Post.tags','Post.rst_text'])
    row= next(rdr)
    blog= Blog(row['Blog.title'])
    post= post_builder5( row )
    blog.append( post )
    for row in rdr:
        if row['Blog.title'] != blog.title:
            yield blog
            blog= Blog( row['Blog.title'] )
        post= post_builder5( row )
        blog.append( post )
    yield blog

第一行数据用于构建Blog实例和该Blog中的第一个Post。随后的循环不变条件假设存在一个合适的Blog对象。拥有一个有效的Blog实例使得处理逻辑变得简单得多。Post实例是用以下函数构建的:

import ast
def post_builder5( row ):
    return Post(
        date=datetime.datetime.strptime(
            row['Post.date'], "%Y-%m-%d %H:%M:%S"),
        title=row['Post.title'],
        rst_text=row['Post.rst_text'],
        tags=ast.literal_eval(row['Post.tags']) )

我们通过将每行中的单独列映射到类构造函数的参数来映射。这使得所有的转换都是显式的。它正确处理了从 CSV 文本到 Python 对象的所有类型转换。

我们可能想要将Blog构建器重构为一个单独的函数。但是,它非常小,遵循 DRY 原则似乎有点麻烦。因为列标题与参数名称匹配,我们可以尝试使用以下代码构建每个对象:

    def make_obj( row, class_=Post, prefix="Post" ):
        column_split = ( (k,)+tuple(k.split('.')) for k in row )
        kw_args = dict( (attr,row[key])
            for key,classname,attr in column_split if classname==prefix )
        return class( **kw_args )

我们在这里使用了两个生成器表达式。第一个生成器表达式将列名拆分为类和属性,并构建一个包含完整键、类名和属性名的 3 元组。第二个生成器表达式过滤了所需目标类的类;它构建了一个包含属性和值对的 2 元组序列,可以用来构建字典。

这并不处理Posts的数据转换。单个列映射并不通用。当与post_builder5()函数相比时,向此添加大量处理逻辑并不是很有帮助。

如果我们有一个空文件,即有标题行但没有Blog条目的文件,初始的row=next(rdr)函数将引发StopIteration异常。由于这个生成器函数没有处理异常,它将传播到评估blog_iter2()的循环;这个循环将被正确终止。

使用 XML 进行转储和加载

Python 的xml包包括许多解析 XML 文件的模块。还有一个文档对象模型DOM)实现,可以生成 XML 文档。与之前的json模块一样,这对于 Python 对象来说并不是一个非常完整的持久性解决方案。然而,由于广泛采用 XML 文件,通常需要在 Python 对象和 XML 文档之间进行转换。

处理 XML 文件涉及我们的对象和 XML 结构之间的手动映射。我们需要仔细设计映射,同时要意识到 XML 符号的约束。这可能很困难,因为对象的表达能力与 XML 文档的严格分层性质之间存在不匹配。

XML 属性或标记的内容是纯文本。在加载 XML 文档时,我们需要将这些值转换为我们应用程序内部更有用的类型。在某些情况下,XML 文档可能包括属性或标记以指示预期的类型。

如果我们愿意忍受一些限制,我们可以使用plistlib模块将一些内置的 Python 结构发出为 XML 文档。我们将在第十三章中详细介绍这个模块,配置文件和持久性,在那里我们将使用它来加载配置文件。

注意

json模块提供了将 JSON 编码扩展到包括我们自定义类的方法;plistlib模块没有提供此额外的钩子。

当我们考虑将 Python 对象转储为 XML 文档时,有三种常见的构建文本的方法:

  • 在我们的类设计中包含 XML 输出方法。在这种情况下,我们的类发出可以组装成 XML 文档的字符串。

  • 使用xml.etree.ElementTree构建ElementTree节点并返回此结构。这可以呈现为文本。

  • 使用外部模板并将属性填充到该模板中。除非我们有一个复杂的模板工具,否则这样做效果不佳。标准库中的string.Template类仅适用于非常简单的对象。

有一些通用的 Python XML 序列化器示例。尝试创建通用序列化器的问题在于 XML 非常灵活;每个 XML 应用似乎都有独特的XML 模式定义XSD)或文档类型定义DTD)要求。

一个开放的设计问题是如何编码原子值。有很多选择。我们可以在标记的属性中使用特定类型的标记:<int name="the_answer">42</int>。另一种可能性是在标记的属性中使用特定类型的标记:<the_answer type="int">42</the_answer>。我们还可以使用嵌套标记:<the_answer><int>42</int></the_answer>。或者,我们可以依赖于单独的模式定义,建议the_answer应该是一个整数,并仅将值编码为文本:<the_answer>42</the_answer>。我们还可以使用相邻的标记:<key>the_answer</key><int>42</int>。这并不是一个详尽的列表;XML 为我们提供了很多选择。

当从 XML 文档中恢复 Python 对象时,我们受到解析器 API 的限制。通常,我们必须解析文档,然后检查 XML 标记结构,从可用数据中组装 Python 对象。

一些 Web 框架,如 Django,包括 Django 定义类的 XML 序列化。这不是任意 Python 对象的通用序列化。序列化由 Django 的数据建模组件严格定义。此外,还有诸如dexmllxmlpyxser等软件包,作为 Python 对象和 XML 之间的替代绑定。请参阅pythonhosted.org/dexml/api/dexml.htmllxml.decoder.cl/products/pyxser/。以下是候选软件包的更长列表:wiki.python.org/moin/PythonXml

使用字符串模板转储对象

将 Python 对象序列化为 XML 的一种方法是创建 XML 文本。这是一种手动映射,通常实现为一个方法函数,该函数发出与 Python 对象对应的 XML 片段。对于复杂对象,容器必须获取容器内每个项目的 XML。以下是我们的微博类结构的两个简单扩展,添加了文本的 XML 输出功能:

class Blog_X( Blog ):
    def xml( self ):
        children= "\n".join( c.xml() for c in self.entries )
        return """\
<blog><title>{0.title}</title>
<entries>
{1}
<entries></blog>""".format(self,children)

class Post_X( Post ):
    def xml( self ):
        tags= "".join( "<tag>{0}</tag>".format(t) for t in self.tags )
        return """\
<entry>
    <title>{0.title}</title>
    <date>{0.date}</date>
    <tags>{1}</tags>
    <text>{0.rst_text}</text>
</entry>""".format(self,tags)

我们编写了一些高度特定于类的 XML 输出方法。这些方法将发出包装在 XML 语法中的相关属性。这种方法不太通用。Blog_X.xml()方法发出带有标题和条目的<blog>标记。Post_X.xml()方法发出带有各种属性的<post>标记。在这两种方法中,使用"".join()"\n".join()创建了较短字符串元素的较长字符串。当我们将Blog对象转换为 XML 时,结果如下:

<blog><title>Travel</title>
<entries>
<entry>
    <title>Hard Aground</title>
    <date>2013-11-14 17:25:00</date>
    <tags><tag>#RedRanger</tag><tag>#Whitby42</tag><tag>#ICW</tag></tags>
    <text>Some embarrassing revelation. Including ☹ and ⚓</text>
</entry>
<entry>
    <title>Anchor Follies</title>
    <date>2013-11-18 15:30:00</date>
    <tags><tag>#RedRanger</tag><tag>#Whitby42</tag><tag>#Mistakes</tag></tags>
    <text>Some witty epigram.</text>
</entry>
<entries></blog>

这种方法有两个缺点:

  • 我们忽略了 XML 命名空间。这是发出标记的文字的一个小改变。

  • 每个类还需要正确转义<&>"字符为 XML 实体&lt;&gt;&amp;&quot;html模块包括html.escape()函数来执行此操作。

这确实发出了正确的 XML;可以依赖它工作;它不太优雅,也不太通用。

使用 xml.etree.ElementTree 转储对象

我们可以使用xml.etree.ElementTree模块构建可以作为 XML 发出的Element结构。使用xml.domxml.minidom进行这项工作是具有挑战性的。DOM API 需要一个顶级文档,然后构建单独的元素。当尝试序列化具有多个属性的简单类时,必要的上下文对象的存在会导致混乱。我们必须首先创建文档,然后序列化文档的所有元素,并将文档上下文作为参数提供。

通常,我们希望设计中的每个类都构建一个顶级元素并返回。大多数顶级元素将具有一系列子元素。我们可以为构建的每个元素分配文本以及属性。我们还可以分配一个tail,即跟在封闭标记后面的多余文本。在某些内容模型中,这只是空白。由于名称很长,可能有助于以以下方式导入ElementTree

import xml.etree.ElementTree as XML

以下是我们的微博类结构的两个扩展,将 XML 输出功能添加为Element实例。我们向Blog类添加了以下方法:

    def xml( self ):
        blog= XML.Element( "blog" )
        title= XML.SubElement( blog, "title" )
        title.text= self.title
        title.tail= "\n"
        entities= XML.SubElement( blog, "entities" )
        entities.extend( c.xml() for c in self.entries )
        blog.tail= "\n"
        return blog

我们向Post类添加了以下方法:

    def xml( self ):
        post= XML.Element( "entry" )
        title= XML.SubElement( post, "title" )
        title.text= self.title
        date= XML.SubElement( post, "date" )
        date.text= str(self.date)
        tags= XML.SubElement( post, "tags" )
        for t in self.tags:
            tag= XML.SubElement( tags, "tag" )
            tag.text= t
        text= XML.SubElement( post, "rst_text" )
        text.text= self.rst_text
        post.tail= "\n"
        return post

我们编写了高度特定于类的 XML 输出方法。这些方法将构建具有适当文本值的Element对象。

注意

没有用于构建子元素的流畅快捷方式。我们必须逐个插入每个文本项。

blog方法中,我们能够执行Element.extend()将所有单独的帖子条目放在<entry>元素内。这使我们能够灵活而简单地构建 XML 结构。这种方法可以优雅地处理 XML 命名空间。我们可以使用QName类为 XML 命名空间构建合格的名称。ElementTree模块正确地将命名空间限定符应用于 XML 标记。这种方法还可以将<&>"字符正确转义为 XML 实体&lt;&gt;&amp;&quot;。这些方法生成的 XML 输出大部分将与上一节相匹配。空格将不同。

加载 XML 文档

从 XML 文档加载 Python 对象是一个两步过程。首先,我们需要解析 XML 文本以创建文档对象。然后,我们需要检查文档对象以生成 Python 对象。正如前面所述,XML 符号的巨大灵活性意味着没有单一的 XML 到 Python 序列化。

遍历 XML 文档的一种方法涉及进行类似 XPath 的查询,以定位解析的各种元素。以下是一个遍历 XML 文档的函数,从可用的 XML 中发出BlogPost对象:

    import ast
    doc= XML.parse( io.StringIO(text.decode('utf-8')) )
    xml_blog= doc.getroot()
    blog= Blog( xml_blog.findtext('title') )
    for xml_post in xml_blog.findall('entries/entry'):
        tags= [t.text for t in xml_post.findall( 'tags/tag' )]
        post= Post(
            date= datetime.datetime.strptime(
                xml_post.findtext('date'), "%Y-%m-%d %H:%M:%S"),
            title=xml_post.findtext('title'),
            tags=tags,
            rst_text= xml_post.findtext('rst_text')
         )
        blog.append( post )
    render( blog )

这段代码遍历了一个<blog> XML 文档。它定位了<title>标记,并收集该元素内的所有文本,以创建顶层的Blog实例。然后,它定位了<entries>元素内找到的所有<entry>子元素。这些用于构建每个Post对象。Post对象的各种属性被单独转换。<tags>元素内每个单独的<tag>元素的文本被转换为文本值列表。日期从其文本表示中解析出来。每个Post对象都被追加到整体的Blog对象中。这种从 XML 文本到 Python 对象的手动映射是解析 XML 文档的一个重要特性。

摘要

我们已经看过了多种序列化 Python 对象的方法。我们可以在各种符号中对我们的类定义进行编码,包括 JSON、YAML、pickle、XML 和 CSV。每种符号都有各种优点和缺点。

这些不同的库模块通常围绕着从外部文件加载对象或将对象转储到文件的想法。这些模块并不完全一致,但它们非常相似,允许我们应用一些常见的设计模式。

使用 CSV 和 XML 往往会暴露出最困难的设计问题。我们在 Python 中的类定义可以包括在 CSV 或 XML 符号中没有很好表示的对象引用。

设计考虑和权衡

有许多方法可以序列化和持久化 Python 对象。我们还没有看到它们的全部。本节中的格式侧重于两个基本用例:

  • 与其他应用程序的数据交换:我们可能会为其他应用程序发布数据或接受其他应用程序的数据。在这种情况下,我们通常受到其他应用程序接口的限制。通常,其他应用程序和框架使用 JSON 和 XML 作为其首选的数据交换形式。在某些情况下,我们将使用 CSV 来交换数据。

  • 我们自己应用程序的持久数据:在这种情况下,我们通常会选择pickle,因为它是完整的,并且已经是 Python 标准库的一部分。然而,YAML 的一个重要优势是它的可读性;我们可以查看、编辑甚至修改文件。

在处理这些格式时,我们有许多设计考虑。首先,这些格式偏向于序列化单个 Python 对象。它可能是其他对象的列表,但本质上是单个对象。例如,JSON 和 XML 具有在序列化对象之后编写的结束分隔符。对于从较大域中持久化单个对象,我们可以查看第十章中的shelvesqlite3通过 Shelve 存储和检索对象和第十一章中的shelvesqlite3通过 SQLite 存储和检索对象

JSON 是一个广泛使用的标准。它不方便表示复杂的 Python 类。在使用 JSON 时,我们需要意识到我们的对象如何被简化为与 JSON 兼容的表示形式。JSON 文档是人类可读的。JSON 的限制使其在通过互联网传输对象时可能更安全。

YAML 并不像 JSON 那样广泛使用,但它解决了序列化和持久性中的许多问题。YAML 文档是人类可读的。对于可编辑的配置文件,YAML 是理想的。我们可以使用 safe-load 选项使 YAML 安全。

Pickle 非常适合于 Python 对象的简单,快速的本地持久性。它是从 Python 到 Python 的传输的紧凑表示。CSV 是一个广泛使用的标准。在 CSV 表示中为 Python 对象制定表示形式是具有挑战性的。在 CSV 表示中共享数据时,我们经常在应用程序中使用namedtuples。我们必须设计一个从 Python 到 CSV 和从 CSV 到 Python 的映射。

XML 是另一种广泛使用的序列化数据的表示形式。XML 非常灵活,导致了多种在 XML 表示中编码 Python 对象的方式。由于 XML 用例,我们经常有外部规范,如 XSD 或 DTD。解析 XML 以创建 Python 对象的过程总是相当复杂的。

因为每个 CSV 行在很大程度上独立于其他行,CSV 允许我们编码或解码极大的对象集合。因此,CSV 通常用于编码和解码无法放入内存的巨大集合。

在某些情况下,我们面临混合设计问题。在阅读大多数现代电子表格文件时,我们遇到了 CSV 行列问题和 XML 解析问题。例如,OpenOffice.org。ODS 文件是压缩存档。存档中的一个文件是content.xml文件。使用 XPath 搜索body/spreadsheet/table元素将定位电子表格文档的各个选项卡。在每个表格中,我们会找到通常映射到 Python 对象的table-row元素。在每行中,我们会找到包含构建对象属性的单个值的table-cell元素。

模式演变

在处理持久对象时,我们必须解决模式演变的问题。我们的对象具有动态状态和静态类定义。我们可以轻松地保存动态状态。我们的类定义是持久数据的模式。然而,类并非绝对静态。当类发生变化时,我们需要提供加载由应用程序的先前版本转储的数据的方法。

最好考虑外部文件兼容性,以区分主要和次要发布版本号。主要发布应意味着文件不再兼容,必须进行转换。次要发布应意味着文件格式兼容,升级不涉及数据转换。

一种常见的方法是在文件扩展名中包含主版本号。我们可能会有以.json2.json3结尾的文件名,以指示涉及哪种数据格式。支持持久文件格式的多个版本通常变得相当复杂。为了提供无缝升级路径,应用程序应能够解码先前的文件格式。通常,最好将数据持久化在最新和最好的文件格式中,即使其他格式也支持输入。

在接下来的章节中,我们将讨论不专注于单个对象的序列化。shelvesqlite3模块为我们提供了序列化一系列不同对象的方法。之后,我们将再次使用这些技术来进行表述状态转移REST)以将对象从一个进程传输到另一个进程。此外,我们还将再次使用这些技术来处理配置文件。

展望未来

在第十章和第十一章中,我们将看到两种常见的方法来创建更大的持久对象集合。这两章向我们展示了创建 Python 对象数据库的不同方法。

在第十二章中,传输和共享对象,我们将把这些序列化技术应用到使对象在另一个进程中可用的问题上。我们将专注于 RESTful web 服务作为在进程之间传输对象的简单和流行的方式。

在第十三章中,配置文件和持久化,我们将再次应用这些序列化技术。在这种情况下,我们将使用 JSON 和 YAML 等表示形式来编码应用程序的配置信息。

第十章:通过 Shelve 存储和检索对象

有许多应用程序需要单独持久化对象。我们在第九章中看到的技术,序列化和保存-JSON、YAML、Pickle、CSV 和 XML,偏向于处理单个对象。有时,我们需要持久化来自更大领域的单独对象。

具有持久对象的应用程序可能展示四种用例,总结为CRUD 操作:创建、检索、更新和删除。在一般情况下,这些操作中的任何一个都可以应用于域中的任何对象;这导致需要比单一的加载或转储到文件更复杂的持久化机制。除了浪费内存外,简单的加载和转储通常比精细的、逐个对象的存储效率低。

使用更复杂的存储将使我们更加关注责任的分配。各种关注点为我们提供了应用软件架构的整体设计模式。这些更高级别的设计模式之一是三层架构

  • 表示层:这可能是 Web 浏览器或移动应用程序,有时两者都有。

  • 应用层:这通常部署在应用服务器上。应用层应该被细分为应用层和数据模型层。处理层涉及体现应用行为的类。数据模型层定义了问题域的对象模型。

  • 数据层:这包括访问层和持久化层。访问层提供对持久对象的统一访问。持久化层将对象序列化并将其写入持久存储。

这个模型可以应用于单个 GUI 应用程序。表示层是 GUI;应用层是相关的处理器和数据模型;访问层是持久性模块。它甚至适用于命令行应用程序,其中表示层仅仅是一个选项解析器以及print()函数。

shelve模块定义了一个类似映射的容器,我们可以在其中存储对象。每个存储的对象都被 pickled 并写入文件。我们还可以从文件中 unpickle 并检索任何对象。shelve模块依赖于dbm模块来保存和检索对象。

本节将重点关注从应用程序层获取的数据模型以及从数据层获取的访问和持久性。这两个层之间的接口可以简单地是单个应用程序内的类接口。或者,它可以是一个更复杂的网络接口。在本章中,我们将重点关注简单的类与类接口。我们将在第十二章中,使用 REST,传输和共享对象,关注基于网络的接口。

分析持久对象的用例

我们在第九章中看到的持久性机制,序列化和保存 - JSON、YAML、Pickle、CSV 和 XML,侧重于读取和写入一个序列化对象的紧凑文件。如果我们想要更新文件的任何部分,我们被迫替换整个文件。这是使用紧凑表示法的后果;很难到达文件中对象的位置,如果大小发生变化,替换对象也很困难。我们并没有用巧妙、复杂的算法来解决这些困难,而是简单地对对象进行了序列化和写入。当我们有一个更大的领域,有许多持久的、可变的对象时,我们引入了一些额外的深度到用例中。以下是一些额外的考虑:

  • 我们可能不想一次将所有对象加载到内存中。对于许多大数据应用程序,一次性加载所有对象可能是不可能的。

  • 我们可能只更新来自对象领域的小子集或单个实例。加载然后转储所有对象以更新一个对象是相对低效的处理。

  • 我们可能不会一次性转储所有对象;我们可能会逐渐累积对象。一些格式,如 YAML 和 CSV,允许我们以很少的复杂性将自己附加到文件上。其他格式,如 JSON 和 XML,有终止符,使得简单地附加到文件变得困难。

我们可能还想要更多的功能。将序列化、持久性以及并发更新或写访问混为一谈,统称为数据库是很常见的。shelve模块本身并不是一个全面的数据库解决方案。shelve使用的底层dbm模块并不直接处理并发写。它也不处理多操作事务。可以使用低级别的操作系统文件锁定来容忍并发更新,但这往往是高度依赖操作系统的。对于并发写访问,最好使用适当的数据库或 RESTful 数据服务器。参见第十一章,通过 SQLite 存储和检索对象,以及第十二章,传输和共享对象

ACID 属性

我们的设计必须考虑ACID 属性如何适用于我们的shelve数据库。我们的应用程序通常会对相关操作进行捆绑更改,这些操作应该将数据库从一个一致的状态更改到下一个一致的状态。改变数据库的一系列操作可以称为事务。

多操作事务的一个例子可能涉及更新两个对象,以保持总和不变。我们可能会从一个财务账户中扣除并存入另一个账户。整体余额必须保持恒定,以使数据库处于一致的有效状态。ACID 属性表征了我们希望数据库事务作为一个整体的行为。有四条规则定义了我们的期望:

  • 原子性:事务必须是原子的。如果事务中有多个操作,要么所有操作都完成,要么都不完成。不应该可能查看一个部分完成的事务的架子。

  • 一致性:事务必须保证一致性。它将把数据库从一个有效状态改变为另一个有效状态。事务不应该损坏数据库或在并发用户之间创建不一致的视图。所有用户看到已完成事务的相同净效果。

  • 隔离性:每个事务应该像完全隔离一样正常运行。我们不能让两个并发用户干扰彼此的尝试更新。我们必须能够将并发访问转换为(可能更慢的)串行访问,并且数据库更新将产生相同的结果。

  • 持久性:对数据库的更改是持久的;它们在文件系统中正确地持久存在。

当我们使用内存中的 Python 对象时,显然,我们得到了ACI,但没有得到D。内存中的对象根据定义是不持久的。如果我们尝试在几个并发进程中使用shelve模块而没有锁定或版本控制,我们可能只得到 D,但失去 ACI 属性。

shelve模块不直接支持原子性;它没有处理由多个操作组成的事务的方法。如果我们有多个操作的事务并且需要原子性,我们必须确保它们全部成功或全部失败。这可能涉及到相当复杂的try:语句,必须在失败的情况下恢复数据库的先前状态。

shelve模块不保证对所有种类的更改都是持久的。如果我们将一个可变对象放到架子上,然后在内存中更改对象,架子文件上的持久版本将不会自动更改。如果我们要改变架子上的对象,我们的应用程序必须明确地更新架子。我们可以要求架子对象通过写回模式跟踪更改,但使用这个特性可能会导致性能不佳。

创建一个架子

创建架子的第一部分是使用模块级函数shelve.open()来创建一个持久的架子结构。第二部分是正确关闭文件,以便所有更改都被写入底层文件系统。我们稍后会在一个更完整的例子中看到这一点。

在幕后,shelve模块使用dbm模块来进行真正的工作,打开文件并从键到值的映射。dbm模块本身是一个围绕底层 DBM 兼容库的包装器。因此,shelve功能有许多潜在的实现。好消息是,dbm实现之间的差异在很大程度上是无关紧要的。

shelve.open()模块函数需要两个参数:文件名和文件访问模式。通常,我们希望使用'c'的默认模式来打开一个现有的架子,如果不存在则创建一个。专门情况下的替代方案有:

  • 'r'是一个只读的架子

  • 'w'是一个读写的架子,必须存在,否则将引发异常

  • 'n'是一个新的、空的架子;任何以前的版本都将被覆盖

关闭架子以确保它被正确地持久化到磁盘是绝对必要的。架子本身不是上下文管理器,但contextlib.closing()函数可以用来确保架子被关闭。有关上下文管理器的更多信息,请参见第五章,“使用可调用和上下文”。

在某些情况下,我们可能还希望显式地将架子与磁盘同步,而不关闭文件。shelve.sync()方法将在关闭之前持久化更改。理想的生命周期看起来像以下代码:

import shelve
from contextlib import closing
with closing( shelve.open('some_file') ) as shelf:
    process( shelf )

我们打开了一个架子,并将打开的架子提供给一些执行我们应用程序真正工作的函数。当这个过程完成时,上下文将确保架子被关闭。如果process()函数引发异常,架子仍将被正确关闭。

设计可架架对象

如果我们的对象相对简单,那么将它们放在架子上将是微不足道的。对于不是复杂容器或大型集合的对象,我们只需要解决键到值的映射。对于更复杂的对象——通常包含其他对象的对象——我们必须就访问的粒度和对象之间的引用做出一些额外的设计决策。

我们将首先看一个简单的情况,我们只需要设计用于访问我们对象的键。然后,我们将看一些更复杂的情况,其中粒度和对象引用起作用。

为我们的对象设计键

shelve(和dbm)的重要特性是可以立即访问任意大的对象宇宙中的任何对象。shelve模块与类似字典的映射一起工作。架子映射存在于持久存储上,因此我们放在架子上的任何对象都将被序列化和保存。pickle模块用于执行实际的序列化。

我们必须用某种键来标识我们的架子对象,这个键将映射到对象。与字典一样,键是经过哈希处理的,这是一个非常快速的计算。这很快是因为键被限制为字节字符串;哈希是这些字节的模和。由于 Python 字符串可以轻松编码为字节,这意味着字符串值是键的常见选择。这与内置的dict不同,其中任何不可变对象都可以用作键。

由于键定位值,这意味着键必须是唯一的。这对我们的类施加了一些设计考虑,以提供适当的唯一键。在某些情况下,问题域将具有一个明显的唯一键属性。在这种情况下,我们可以简单地使用该属性来构造这个键:shelf[object.key_attribute]= object。这是最简单的情况,但不太通用。

在其他情况下,我们的应用问题没有提供适当的唯一键。例如,当对象的每个属性都可能是可变的或潜在的非唯一时,就会经常出现这个问题。例如,在处理美国公民时,社会安全号码并不是唯一的;它们可以被社会安全管理局重新使用。此外,一个人可能会错误报告社会安全号码,应用程序可能需要更改它;因为它可以更改,这是它不适合作为主键的第二个原因。

我们的应用程序可能有候选或主键的非字符串值。例如,我们可能有一个datetime对象、一个数字,甚至一个元组作为唯一标识符。在所有这些情况下,我们可能希望将值编码为字节或字符串。

在没有明显主键的情况下,我们可以尝试找到一组值的组合,创建一个唯一的复合键。这并不总是一个非常好的主意,因为现在键不是原子的,对键的任何部分的更改都会创建数据更新问题。

遵循一种称为代理键的设计模式通常是最简单的。这个键不依赖于对象内部的数据;它是对象的代理。这意味着对象的任何属性都可以更改而不会导致复杂或限制。Python 的内部对象 ID 就是一种代理键的例子。架子键的字符串表示可以遵循这种模式:class:oid

键字符串包括与对象实例的唯一标识符配对的对象类。我们可以使用这种形式的键轻松地将各种类的对象存储在单个架子中。即使我们认为架子中只会有一种类型的对象,这种格式仍然有助于为索引、管理元数据和未来扩展保存命名空间。

当我们有一个合适的自然键时,我们可以这样做来将对象持久化到架子中:self[object.__class__.__name__+":"+object.key_attribute]= object

这为我们提供了一个独特的类名,以及一个简单的标识符作为每个对象的唯一键值。对于代理键,我们需要为键定义某种生成器。

为对象生成代理键

我们将使用整数计数器生成唯一的代理键。为了确保我们正确更新这个计数器,我们将把它与我们的其他数据一起存储在架子中。尽管 Python 有一个内部对象 ID,但我们不应该使用 Python 的内部标识符作为代理键。Python 的内部 ID 号没有任何保证。

由于我们将向我们的架子中添加一些管理对象,我们必须给这些对象分配具有独特前缀的唯一键。我们将使用_DB。这将是我们架子中对象的一个虚假类。这些管理对象的设计决策与应用程序对象的设计类似。我们需要选择存储的粒度。我们有两种选择:

  • 粗粒度:我们可以创建一个带有所有代理键生成的管理开销的单个dict对象。一个单一的键,比如_DB:max可以标识这个对象。在这个dict中,我们可以将类名映射到使用的最大标识符值。每次创建一个新对象,我们都会从这个映射中分配 ID,然后在架子中替换映射。我们将在下一节展示粗粒度解决方案。

  • 细粒度:我们可以向数据库添加许多项目,每个项目都具有不同类的对象的最大键值。这些额外的键项中的每一个都具有形式_DB:max:class。每个键的值只是一个整数,迄今为止为给定类分配的最大顺序标识符。

这里的一个重要考虑因素是,我们已经将应用程序类的键设计与类设计分开。我们可以(也应该)尽可能简单地设计我们的应用程序对象。我们应该添加足够的开销,使shelve正常工作,但不要过多。

设计一个带有简单键的类

shelve键存储为存储对象的属性是有帮助的。将键保留在对象中使得删除或替换对象更容易。显然,在创建对象时,我们将从不带键的对象开始,直到它存储在架子上。一旦存储,Python 对象需要设置一个键属性,以便内存中的每个对象都包含正确的键。

在检索对象时,有两种用例。我们可能需要一个已知键的特定对象。在这种情况下,架子将键映射到对象。我们可能还需要一组相关对象,不是通过它们的键而是通过其他属性的值来识别。在这种情况下,我们将通过某种搜索或查询来发现对象的键。我们将在下一节中讨论搜索算法。

为了支持在对象中保存架子键,我们将为每个对象添加一个_id属性。它将在每个放入架子或从架子中检索的对象中保留架子键。这将简化需要在架子中替换或移除的对象的管理。我们有以下选择来将其添加到类中:

  • 不:这对于课程并不重要;这只是持久性机制的开销

  • 是的:这是重要的数据,我们应该在__init__()中正确初始化它

我们建议不要在__init__()方法中定义代理键;它们并不重要,只是持久性实现的一部分。例如,代理键不会有任何方法函数,它永远不会成为应用程序层或表示层的处理层的一部分。这是一个整体Blog的定义:

class Blog:
    def __init__( self, title, *posts ):
        self.title= title
    def as_dict( self ):
        return dict(
            title= self.title,
            underline= "="*len(self.title),
        )

我们只提供了一个title属性和一点点更多。Blog.as_dict()方法可以与模板一起使用,以 RST 表示法提供字符串值。我们将把博客中的个别帖子的考虑留给下一节。

我们可以以以下方式创建一个Blog对象:

>>> b1= Blog( title="Travel Blog" )

当我们将这个简单对象存储在架子上时,我们可以做这样的事情:

>>> import shelve
>>> shelf= shelve.open("blog")
>>> b1._id= 'Blog:1'
>>> shelf[b1._id]= b1

我们首先打开了一个新的架子。文件名为“blog”。我们在我们的Blog实例b1中放入了一个键“'Blog:1'”。我们使用_id属性中给定的键将该Blog实例存储在架子中。

我们可以这样从架子上取回物品:

>>> shelf['Blog:1']
<__main__.Blog object at 0x1007bccd0>
>>> shelf['Blog:1'].title
'Travel Blog'
>>> shelf['Blog:1']._id
'Blog:1'
>>> list(shelf.keys())
['Blog:1']
>>> shelf.close()

当我们引用shelf['Blog:1']时,它将从架子中获取我们原始的Blog实例。我们只在架子上放了一个对象,正如我们从键列表中看到的那样。因为我们关闭了架子,对象是持久的。我们可以退出 Python,重新启动,打开架子,看到对象仍然在架子上,使用分配的键。之前,我们提到了检索的第二个用例:在不知道键的情况下定位项目。这是一个查找,找到所有标题为给定标题的博客:

>>> shelf= shelve.open('blog')
>>> results = ( shelf[k] for k in shelf.keys() if k.startswith('Blog:') and shelf[k].title == 'Travel Blog' )
>>> list(results)                                                               [<__main__.Blog object at 0x1007bcc50>]
>>> r0= _[0]
>>> r0.title
'Travel Blog'
>>> r0._id
'Blog:1'

我们打开了架子以访问对象。results生成器表达式检查架子中的每个项目,以找到那些键以'Blog:'开头,并且对象的标题属性是字符串'Travel Blog'的项目。

重要的是,键'Blog:1'存储在对象本身内。_id属性确保我们对应用程序正在处理的任何项目都有正确的键。现在我们可以改变对象并使用其原始键将其替换到架子中。

为容器或集合设计类

当我们有更复杂的容器或集合时,我们需要做出更复杂的设计决策。第一个问题是关于包含范围。我们必须决定我们架子上的对象的粒度。

当我们有一个容器时,我们可以将整个容器作为单个复杂对象持久化到我们的架子上。在某种程度上,这可能会破坏首先在架子上有多个对象的目的。存储一个大容器给我们粗粒度的存储。如果我们更改一个包含的对象,整个容器必须被序列化和存储。如果我们最终在单个容器中有效地将整个对象宇宙进行 pickle,为什么要使用shelve?我们必须找到一个适合应用需求的平衡点。

另一种选择是将集合分解为单独的个体项目。在这种情况下,我们的顶级Blog对象将不再是一个适当的 Python 容器。父对象可能使用键的集合引用每个子对象。每个子对象可以通过键引用父对象。这种使用键的方式在面向对象设计中是不寻常的。通常,对象只包含对其他对象的引用。在使用shelve(或其他数据库)时,我们必须使用键的间接引用。

每个子对象现在将有两个键:它自己的主键,加上一个外键,这个外键是父对象的主键。这导致了一个关于表示父对象和子对象的键字符串的第二个设计问题。

通过外键引用对象

我们用来唯一标识一个对象的键是它的主键。当子对象引用父对象时,我们需要做出额外的设计决策。我们如何构造子对象的主键?基于对象类之间的依赖关系的类型,有两种常见的子键设计策略:

  • "Child:cid": 当我们有子对象可以独立于拥有父对象存在时,我们将使用这个。例如,发票上的项目指的是一个产品;即使没有产品的发票项目,产品也可以存在。

  • "Parent:pid:Child:cid": 当子对象不能没有父对象存在时,我们将使用这个。例如,顾客地址没有顾客的话就不存在。当子对象完全依赖于父对象时,子对象的键可以包含拥有父对象的 ID 以反映这种依赖关系。

与父类设计一样,如果我们保留主键和与每个子对象关联的所有外键,那么最容易。我们建议不要在__init__()方法中初始化它们,因为它们只是持久性的特征。这是BlogPost的一般定义:

import datetime
class Post:
    def __init__( self, date, title, rst_text, tags ):
        self.date= date
        self.title= title
        self.rst_text= rst_text
        self.tags= tags
    def as_dict( self ):
        return dict(
            date= str(self.date),
            title= self.title,
            underline= "-"*len(self.title),
            rst_text= self.rst_text,
            tag_text= " ".join(self.tags),
        )

我们为每个微博帖子提供了几个属性。Post.as_dict()方法可以与模板一起使用,以 RST 格式提供字符串值。我们避免提及Post的主键或任何外键。以下是两个Post实例的示例:

p2= Post( date=datetime.datetime(2013,11,14,17,25),
        title="Hard Aground",
        rst_text="""Some embarrassing revelation. Including ☹ and ⎕""",
        tags=("#RedRanger", "#Whitby42", "#ICW"),
        )

p3= Post( date=datetime.datetime(2013,11,18,15,30),
        title="Anchor Follies",
        rst_text="""Some witty epigram. Including < & > characters.""",
        tags=("#RedRanger", "#Whitby42", "#Mistakes"),
        )

我们现在可以通过设置属性和分配键来将这些与它们拥有的博客关联起来。我们将通过几个步骤来做到这一点:

  1. 我们将打开架子并取出一个父Blog对象。我们将称之为owner
>>> import shelve
>>> shelf= shelve.open("blog")
>>> owner= shelf['Blog:1']

我们使用主键来定位拥有者项目。实际应用可能会使用搜索来通过标题定位这个项目。我们可能还创建了一个索引来优化搜索。我们将在下面看一下索引和搜索。

  1. 现在,我们可以将这个拥有者的键分配给每个Post对象并持久化这些对象:
>>> p2._parent= owner._id
>>> p2._id= p2._parent + ':Post:2'
>>> shelf[p2._id]= p2

>>> p3._parent= owner._id
>>> p3._id= p3._parent + ':Post:3'
>>> shelf[p3._id]= p3

我们将父信息放入每个Post中。我们使用父信息来构建主键。对于这种依赖类型的键,_parent属性值是多余的;它可以从键中推断出来。然而,如果我们对Posts使用独立键设计,_parent就不会在键中重复。当我们查看键时,我们可以看到Blog加上两个Post实例:

>>> list(shelf.keys())
['Blog:1:Post:3', 'Blog:1', 'Blog:1:Post:2']

当我们获取任何子Post时,我们将知道每个帖子的正确父Blog

>>> p2._parent
'Blog:1'
>>> p2._id
'Blog:1:Post:2'

从父Blog到子Post的键的反向跟踪会更加复杂。我们将单独讨论这个,因为我们经常希望通过索引优化从父对象到子对象的路径。

设计复杂对象的 CRUD 操作

当我们将一个更大的集合分解为多个独立的细粒度对象时,我们将在架子上有多个类别的对象。因为它们是独立的对象,它们将导致每个类别的对象有独立的 CRUD 操作集合。在某些情况下,这些对象是独立的,对一个类别的对象的操作不会影响到其他对象。

然而,在我们的例子中,BlogPost对象存在依赖关系。Post对象是父Blog的子对象;子对象不能没有父对象存在。当存在这些依赖关系时,我们需要设计更加复杂的操作集合。以下是一些考虑因素:

  • 独立(或父)对象上的 CRUD 操作:

  • 我们可以创建一个新的空父对象,为这个对象分配一个新的主键。我们以后可以将子对象分配给这个父对象。例如,shelf['parent:'+object._id]= object这样的代码将创建父对象。

  • 我们可以更新或检索此父级,而不会对子级产生任何影响。我们可以在赋值的右侧执行shelf['parent:'+some_id]来检索父级。一旦我们有了对象,我们可以执行shelf['parent:'+object._id]= object来保存更改。

  • 删除父级可能导致两种行为之一。一种选择是级联删除以包括所有引用父级的子级。或者,我们可以编写代码来禁止删除仍具有子级引用的父级。这两种选择都是合理的,选择取决于问题域所施加的要求。

  • 对依赖(或子级)对象进行 CRUD 操作:

  • 我们可以创建一个引用现有父级的新子级。我们必须解决键设计问题,以决定我们想要为子级使用什么样的键。

  • 我们可以在父级之外更新、检索或删除子级。这甚至可以包括将子级分配给不同的父级。

由于替换对象的代码与更新对象的代码相同,因此 CRUD 处理的一半通过简单的赋值语句处理。删除使用del语句完成。删除与父级关联的子级可能涉及检索以定位子级。然后剩下的是检索处理的检查,这可能会更复杂一些。

搜索、扫描和查询

不要惊慌;这些只是同义词。我们将交替使用这些词

在查看数据库搜索时,我们有两种设计选择。我们可以返回键序列,也可以返回对象序列。由于我们的设计强调在每个对象中存储键,因此从数据库获取对象序列就足够了,因此我们将专注于这种设计。

搜索本质上是低效的。我们更希望有更有针对性的索引。我们将在下一节中看看如何创建更有用的索引。然而,蛮力扫描的备用计划总是有效的。

当子类具有独立风格的键时,我们可以轻松地使用简单的迭代器扫描所有某个Child类的实例的架子。以下是一个定位所有子级的生成器表达式:

children = ( shelf[k] for k in shelf.keys() if key.startswith("Child:") )

这会查看架子中的每个键,以选择以"Child:"开头的子集。我们可以在此基础上应用更多条件,使用更复杂的生成器表达式:

children_by_title = ( c for c in children if c.title == "some title" )

我们使用了嵌套的生成器表达式来扩展初始的children查询,添加条件。这样的嵌套生成器表达式在 Python 中非常高效。这不会使数据库进行两次扫描。这是一个带有两个条件的单次扫描。内部生成器的每个结果都会传递给外部生成器以构建结果。

当子类具有依赖风格的键时,我们可以使用更复杂的匹配规则的迭代器在架子中搜索特定父级的子级。以下是一个定位给定父级所有子级的生成器表达式:

children_of = ( shelf[k] for k in shelf.keys() if key.startswith(parent+":Child:") )

这种依赖风格的键结构使得在简单循环中特别容易删除父级和所有子级:

for obj in (shelf[k] for k in shelf.keys() if key.startswith(parent)):
    del obj

在使用分层"Parent: pid :Child: cid "键时,我们在将父级与子级分开时必须小心。使用这种多部分键,我们会看到许多以"Parent:pid"开头的对象键。其中一个键将是正确的父级,简单地"Parent: pid"。其他键将是带有"Parent: pid :Child: cid"的子级。我们经常使用这三种条件进行蛮力搜索:

  • key.startswith("Parent:pid") 找到父级和子级的并集;这不是常见的要求。

  • key.startswith("Parent:pid:Child:") 找到给定父级的子级。我们可以使用正则表达式,如r"^(Parent:\d+):(Child:\d+)$"来匹配键。

  • key.startswith("Parent:pid")":Child:" 键仅找到父级,不包括子级。我们可以使用正则表达式,如r"^Parent:\d+$"来匹配键。

所有这些查询都可以通过构建索引来优化。

为架子设计访问层

这是应用程序如何使用shelve的方式。我们将查看编辑和保存微博帖子的应用程序的各个部分。我们将应用程序分为两个层:应用程序层和数据层。在应用程序层中,我们将区分两个层:

  • 应用程序处理:这些对象不是持久的。这些类将体现整个应用程序的行为。这些类响应用户选择的命令、菜单项、按钮和其他处理元素。

  • 问题域数据模型:这些对象将被写入架子。这些对象体现了整个应用程序的状态。

先前显示的博客和帖子的定义之间没有正式的关联。这些类是独立的,因此我们可以在架子上分别处理它们。我们不想通过将Blog转换为集合类来创建一个单一的大容器对象。

在数据层中,可能会有许多功能,这取决于数据存储的复杂性。我们将专注于两个功能:

  • 访问:这些组件提供对问题域对象的统一访问。我们将定义一个Access类,它提供对BlogPost实例的访问。它还将管理定位架子中的BlogPost对象的键。

  • 持久性:这些组件将问题域对象序列化并写入持久存储。这是shelve模块。

我们将Access类分成三个独立的部分。这是第一部分,包括文件打开和关闭的各个部分:

import shelve
class Access:
    def new( self, filename ):
        self.database= shelve.open(filename,'n')
        self.max= { 'Post': 0, 'Blog': 0 }
        self.sync()
    def open( self, filename ):
        self.database= shelve.open(filename,'w')
        self.max= self.database['_DB:max']
    def close( self ):
        if self.database:
            self.database['_DB:max']= self.max
            self.database.close()
        self.database= None
    def sync( self ):
        self.database['_DB:max']= self.max
        self.database.sync()
    def quit( self ):
        self.close()

对于Access.new(),我们将创建一个新的空架子。对于Access.open(),我们将打开一个现有的架子。在关闭和同步时,我们确保将当前最大键值的小词典发布到架子中。

我们还没有解决诸如实现“另存为...”方法以复制文件的事情。我们也没有解决不保存退出以恢复到数据库文件的上一个版本的选项。这些附加功能涉及使用os模块来管理文件副本。我们为您提供了close()quit()方法。这可以使设计 GUI 应用程序稍微简单一些。以下是更新架子中的BlogPost对象的各种方法:

def add_blog( self, blog ):
        self.max['Blog'] += 1
        key= "Blog:{id}".format(id=self.max['Blog'])
        blog._id= key
        **self.database[blog._id]= blog
return blog
    def get_blog( self, id ):
        return self.database[id]
    def add_post( self, blog, post ):
        self.max['Post'] += 1
        try:
            key= "{blog}:Post:{id}".format(blog=blog._id,id=self.max['Post'])
        except AttributeError:
            raise OperationError( "Blog not added" )
        post._id= key
        post._blog= blog._id
        **self.database[post._id]= post
return post
    def get_post( self, id ):
        return self.database[id]
    def replace_post( self, post ):
        **self.database[post._id]= post
return post
    def delete_post( self, post ):
        del self.database[post._id]

我们提供了一组最小的方法,将Blog与其关联的Post实例放入架子中。当我们添加Blog时,add_blog()方法首先计算一个新的键,然后更新Blog对象的键,最后将Blog对象持久化在架子中。我们已经突出显示了改变架子内容的行。简单地在架子中设置一个项目,类似于在字典中设置一个项目,将使对象持久化。

当我们添加一个帖子时,我们必须提供父Blog,以便两者在架子上正确关联。在这种情况下,我们获取Blog键,创建一个新的Post键,然后更新Post的键值。这个更新的Post可以持久化在架子上。add_post()中的突出行使对象在架子中持久化。

在极少数情况下,如果我们尝试添加Post而没有先前添加父Blog,我们将会出现属性错误,因为Blog._id属性将不可用。

我们提供了代表性的方法来替换Post和删除Post。还有一些其他可能的操作;我们没有包括替换Blog或删除Blog的方法。当我们编写删除Blog的方法时,我们必须解决防止在仍然有Posts时删除或级联删除以包括Posts的问题。最后,还有一些搜索方法,作为迭代器来查询BlogPost实例:

    def __iter__( self ):
        for k in self.database:
            if k[0] == "_": continue
            yield self.database[k]
    def blog_iter( self ):
        for k in self.database:
            if not k.startswith("Blog:"): continue
            if ":Post:" in k: continue # Skip children
            yield self.database[k]
    def post_iter( self, blog ):
        key= "{blog}:Post:".format(blog=blog._id)
        for k in self.database:
            if not k.startswith(key): continue
            yield self.database[k]
    def title_iter( self, blog, title ):
        return ( p for p in self.post_iter(blog) if p.title == title )

我们已经定义了默认迭代器 __iter__(),它过滤掉了以 _ 开头的内部对象。到目前为止,我们只定义了一个这样的键 _DB:max,但这个设计给我们留下了发明其他键的空间。

blog_iter() 方法遍历 Blog 条目。由于 BlogPost 条目都以 "Blog:" 开头,我们必须明确丢弃 Blog 的子级 Post 条目。一个专门构建的索引对象通常是一个更好的方法。我们将在下一节中讨论这个问题。

post_iter() 方法遍历属于特定博客的帖子。title_iter() 方法检查与特定标题匹配的帖子。这会检查架子中的每个键,这可能是一个低效的操作。

我们还定义了一个迭代器,它定位在给定博客中具有请求标题的帖子。这是一个简单的生成器函数,它使用 post_iter() 方法函数,并且只返回匹配的标题。

编写演示脚本

我们将使用技术尖峰来向您展示一个应用程序如何使用这个 Access 类来处理微博对象。尖峰脚本将保存一些 BlogPost 对象到数据库中,以展示应用程序可能使用的一系列操作。这个演示脚本可以扩展为单元测试用例。更完整的单元测试将向我们展示所有功能是否存在并且是否正确工作。这个小的尖峰脚本向我们展示了 Access 的工作方式:

from contextlib import closing
with closing( Access() ) as access:
    access.new( 'blog' )
    access.add_blog( b1 )
    # b1._id is set.
    for post in p2, p3:
        access.add_post( b1, post )
        # post._id is set
    b = access.get_blog( b1._id )
    print( b._id, b )
    for p in access.post_iter( b ):
        print( p._id, p )
    access.quit()

我们已经在访问层上创建了 Access 类,以便它被包装在上下文管理器中。目标是确保访问层被正确关闭,无论可能引发的任何异常。

通过 Access.new(),我们创建了一个名为 'blog' 的新架子。这可能是通过导航到文件 | 新建来完成的。我们将新的博客 b1 添加到了架子中。Access.add_blog() 方法将更新 Blog 对象及其架子键。也许有人在页面上填写了一些空白,并在他们的 GUI 应用程序上点击了新建博客

一旦我们添加了 Blog,我们可以向其添加两篇帖子。父 Blog 条目的键将用于构建每个子 Post 条目的键。同样,这个想法是用户填写了一些字段,并在他们的 GUI 上点击了新建帖子

还有一组最终的查询,从架子中转储键和对象。这向我们展示了这个脚本的最终结果。我们可以执行 Access.get_blog() 来检索创建的博客条目。我们可以使用 Access.post_iter() 遍历属于该博客的帖子。最后的 Access.quit() 确保了用于生成唯一键的最大值被记录下来,并且架子被正确关闭。

创建索引以提高效率

效率的一个规则是避免搜索。我们之前使用架子中键的迭代器的例子是低效的。更明确地说,搜索定义了低效。我们将强调这一点。

提示

Brute-force search 可能是处理数据的最糟糕的方式。我们必须始终设计基于子集或映射的索引来提高性能。

为了避免搜索,我们需要创建列出我们想要的项目的索引。这样可以避免通过整个架子来查找项目或子集。架子索引不能引用 Python 对象,因为那样会改变对象存储的粒度。架子索引只能列出键值。这使得对象之间的导航间接,但仍然比在架子中搜索所有项目要快得多。

作为索引的一个例子,我们可以在架子中为每个 Blog 关联的 Post 键保留一个列表。我们可以很容易地修改 add_blog()add_post()delete_post() 方法来更新相关的 Blog 条目。以下是这些博客更新方法的修订版本:

class Access2( Access ):
    def add_blog( self, blog ):
        self.max['Blog'] += 1
        key= "Blog:{id}".format(id=self.max['Blog'])
        blog._id= key
        **blog._post_list= []
        self.database[blog._id]= blog
        return blog

    def add_post( self, blog, post ):
        self.max['Post'] += 1
        try:
            key= "{blog}:Post:{id}".format(blog=blog._id,id=self.max['Post'])
        except AttributeError:
            raise OperationError( "Blog not added" )
        post._id= key
        post._blog= blog._id
        self.database[post._id]= post
        **blog._post_list.append( post._id )
        **self.database[blog._id]= blog
        return post
    def delete_post( self, post ):
        del self.database[post._id]
        blog= self.database[blog._id]
        **blog._post_list.remove( post._id )
 **self.database[blog._id]= blog

add_blog()方法确保每个Blog都有一个额外的属性_post_list。其他方法将更新这个属性,以维护属于Blog的每个Post的键列表。请注意,我们没有添加Posts本身。如果这样做,我们将整个Blog合并为一个 shelf 中的单个条目。通过只添加键信息,我们保持了BlogPost对象的分离。

add_post()方法将Post添加到 shelf。它还将Post._id附加到Blog级别维护的键列表中。这意味着任何Blog对象都将具有提供子帖子键序列的_post_list

这个方法对 shelf 进行了两次更新。第一次只是保存了Post对象。第二次更新很重要。我们没有试图简单地改变 shelf 中存在的Blog对象。我们有意将对象存储到 shelf 中,以确保对象以其更新后的形式持久化。

同样,delete_post()方法通过从所属博客的_post_list中移除一个未使用的帖子来保持索引的最新状态。与add_post()一样,对 shelf 进行了两次更新:del语句删除了Post,然后更新了Blog对象以反映索引的变化。

这个改变深刻地改变了我们对Post对象的查询方式。这是搜索方法的修订版本:

    def __iter__( self ):
        for k in self.database:
            if k[0] == "_": continue
            yield self.database[k]
    def blog_iter( self ):
        for k in self.database:
            if not k.startswith("Blog:"): continue
            if ":Post:" in k: continue # Skip children
            yield self.database[k]
    **def post_iter( self, blog ):
 **for k in blog._post_list:
 **yield self.database[k]
    def title_iter( self, blog, title ):
        return ( p for p in self.post_iter(blog) if p.title == title )

我们能够用更高效的操作替换post_iter()中的扫描。这个循环将根据在Blog_post_list属性中保存的键快速产生Post对象。我们可以考虑用生成器表达式替换这个for语句:

return (self.database[k] for k in blog._post_list)

post_iter()方法的这种优化的重点是消除对匹配键的所有键的搜索。我们用适当的相关键序列的简单迭代替换了搜索所有键。一个简单的时间测试,交替更新BlogPost并将Blog呈现为 RST,向我们展示了以下结果:

Access2: 14.9
Access: 19.3

如预期的那样,消除搜索减少了处理Blog及其各个Posts所需的时间。这个变化是巨大的;几乎 25%的处理时间都浪费在搜索上。

创建顶层索引

我们为每个Blog添加了一个定位属于该BlogPosts的索引。我们还可以为 shelf 添加一个顶层索引,以定位所有Blog实例。基本设计与之前展示的类似。对于要添加或删除的每个博客,我们必须更新一个索引结构。我们还必须更新迭代器以正确使用索引。这是另一个用于调解访问我们对象的类设计:

class Access3( Access2 ):
    def new( self, *args, **kw ):
        super().new( *args, **kw )
        **self.database['_DB:Blog']= list()

    def add_blog( self, blog ):
        self.max['Blog'] += 1
        key= "Blog:{id}".format(id=self.max['Blog'])
        blog._id= key
        blog._post_list= []
        self.database[blog._id]= blog
        **self.database['_DB:Blog'].append( blog._id )
        return blog

    **def blog_iter( self ):
 **return ( self.database[k] for k in self.database['_DB:Blog']** )

在创建新数据库时,我们添加了一个管理对象和一个索引,键为"_DB:Blog"。这个索引将是一个列表,我们将在其中存储每个Blog条目的键。当我们添加一个新的Blog对象时,我们还将使用修订后的键列表更新这个"_DB:Blog"对象。我们没有展示删除的实现。这应该是不言自明的。

当我们遍历Blog的帖子时,我们使用索引列表,而不是在数据库中对键进行蛮力搜索。以下是性能结果:

Access3: 4.0
Access2: 15.1
Access: 19.4

从中我们可以得出结论,大部分的处理时间都浪费在对数据库中键的蛮力搜索上。这应该加强这样一个观念,即我们尽可能地避免搜索,将极大地提高程序的性能。

添加更多的索引维护

显然,shelf 的索引维护方面可能会增长。对于我们简单的数据模型,我们可以很容易地为Posts的标签、日期和标题添加更多的顶层索引。这里是另一个访问层实现,为Blogs定义了两个索引。一个索引简单地列出了Blog条目的键。另一个索引根据Blog的标题提供键。我们假设标题不是唯一的。我们将分三部分介绍这个访问层。这是 CRUD 处理的创建部分:

class Access4( Access2 ):
    def new( self, *args, **kw ):
        super().new( *args, **kw )
        self.database['_DB:Blog']= list()
        self.database['_DB:Blog_Title']= defaultdict(list)

    def add_blog( self, blog ):
        self.max['Blog'] += 1
        key= "Blog:{id}".format(id=self.max['Blog'])
        blog._id= key
        blog._post_list= []
        self.database[blog._id]= blog
        self.database['_DB:Blog'].append( blog._id )
        **blog_title= self.database['_DB:Blog_Title']
 **blog_title[blog.title].append( blog._id )
 **self.database['_DB:Blog_Title']= blog_title
        return blog

我们添加了两个索引:Blog键的简单列表加上defaultdict,它为给定标题字符串提供了一个键列表。如果每个标题都是唯一的,那么列表都将是单例的。如果标题不唯一,那么每个标题将有一个Blog键列表。

当我们添加一个Blog实例时,我们还会更新两个索引。通过追加新键并将其保存到架子上来更新Blog键的简单列表。标题索引要求我们从架子上获取现有的defaultdict,将其追加到映射到Blog标题的键列表中,然后将defaultdict放回架子上。下一节向我们展示了 CRUD 处理的更新部分:

    def update_blog( self, blog ):
        """Replace this Blog; update index."""
        self.database[blog._id]= blog
        blog_title= self.database['_DB:Blog_Title']
        # Remove key from index in old spot.
        empties= []
        for k in blog_title:
            if blog._id in blog_title[k]:
                blog_title[k].remove( blog._id )
                if len(blog_title[k]) == 0: empties.append( k )
        # Cleanup zero-length lists from defaultdict.
        for k in empties:
            del blog_title[k]
        # Put key into index in new spot.
        **blog_title[blog.title].append( blog._id )
 **self.database['_DB:Blog_Title']= blog_title

当我们更新Blog对象时,可能会更改Blog属性的标题。如果我们的模型有更多属性和更多索引,我们可能希望将修订后的值与架子上的值进行比较,以确定哪些属性已更改。对于这个简单的模型——只有一个属性——不需要比较来确定哪些属性已更改。

操作的第一部分是从索引中删除Blog的键。由于我们没有缓存Blog.title属性的先前值,所以不能简单地根据旧标题删除键。相反,我们被迫搜索与Blog关联的键,并从任何与其关联的标题中删除键。

注意

博客具有唯一标题将使标题的键列表为空。我们也应该清理未使用的标题。

一旦与旧标题关联的键从索引中删除,我们就可以使用新标题将键追加到索引中。这最后两行与创建Blog时使用的代码相同。以下是一些检索处理的示例:

    def blog_iter( self ):
        return ( self.database[k] for k in self.database['_DB:Blog'] )

    def blog_title_iter( self, title ):
        blog_title= self.database['_DB:Blog_Title']
        return ( self.database[k] for k in blog_title[title] )

blog_iter()方法函数通过从架子上获取索引对象来迭代所有的博客。blog_title_iter()方法函数使用索引来获取所有具有给定标题的博客。当有许多单独的博客时,这应该可以很快地按标题找到一个博客。

索引更新的写回替代方案

我们可以要求使用writeback=True打开一个架子。这将通过保持每个对象的缓存版本来跟踪可变对象的更改。与负担shelve模块跟踪所有访问的对象以检测和保留更改不同,这里显示的设计将更新可变对象,并明确强制架子更新对象的持久版本。

这是运行时性能的一个小变化。例如,add_post()操作变得稍微更昂贵,因为它还涉及更新Blog条目。如果添加了多个Posts,这些额外的Blog更新将成为一种开销。然而,通过避免对架子键进行漫长的搜索来跟踪给定博客的帖子,这种成本可能会得到平衡。这里显示的设计避免了创建一个在应用程序运行期间可能无限增长的writeback缓存。

模式演变

在使用shelve时,我们必须解决模式演变的问题。我们的对象具有动态状态和静态类定义。我们可以很容易地持久化动态状态。我们的类定义是持久化数据的模式。然而,类并不是绝对静态的。如果我们更改类定义,我们将如何从架子上获取对象?一个好的设计通常涉及以下技术的某种组合。

方法函数和属性的更改不会改变持久化对象的状态。我们可以将这些分类为次要更改,因为架子上的数据仍然与更改后的类定义兼容。新软件发布可以有一个新的次要版本号,用户应该有信心它将可以正常工作。

属性的更改将改变持久化的对象。我们可以称这些为重大变化,而存储的数据将不再与新的类定义兼容。这种改变不应该通过修改类定义来进行。这种改变应该通过定义一个新的子类,并提供一个更新的工厂函数来创建任何版本的类的实例。

我们可以灵活地支持多个版本,或者我们可以使用一次性转换。为了灵活,我们必须依赖于工厂函数来创建对象的实例。一个灵活的应用程序将避免直接创建对象。通过使用工厂函数,我们可以确保应用程序的所有部分可以一致地工作。我们可能会这样做来支持灵活的模式更改:

def make_blog( *args, **kw ):
    version= kw.pop('_version',1)
    if version == 1: return Blog( *args, **kw )
    elif version == 2: return Blog2( *args, **kw )
    else: raise Exception( "Unknown Version {0}".format(version) )

这种工厂函数需要一个_version关键字参数来指定使用哪个Blog类定义。这允许我们升级模式以使用不同的类,而不会破坏我们的应用程序。Access层可以依赖这种函数来实例化正确版本的对象。我们还可以创建一个类似这样的流畅工厂:

class Blog:
    @staticmethod
    def version( self, version ):
        self.version= version
    @staticmethod
    def blog( self, *args, **kw ):
        if self.version == 1: return Blog1( *args, **kw )
        elif self.version == 2: return Blog2( *args, **kw )
        else: raise Exception( "Unknown Version {0}".format(self.version) )

我们可以如下使用这个工厂:

blog= Blog.version(2).blog( title=this, other_attribute=that )

一个架子应该包括模式版本信息,可能作为一个特殊的__version__键。这将为访问层提供信息,以确定应该使用哪个类的版本。应用程序在打开架子后应该首先获取这个对象,并在模式版本错误时快速失败。

对于这种灵活性的替代方案是一次性转换。应用程序的这个特性将使用旧的类定义获取所有存储的对象,转换为新的类定义,并以新格式存储回架子。对于 GUI 应用程序,这可能是打开文件或保存文件的一部分。对于 Web 服务器,这可能是由管理员作为应用程序发布的一部分运行的脚本。

摘要

我们已经了解了如何使用shelve模块的基础知识。这包括创建一个架子,并设计键来访问我们放在架子上的对象。我们还看到了需要一个访问层来执行架子上的低级 CRUD 操作的需求。这个想法是我们需要区分专注于我们应用程序的类定义和支持持久性的其他管理细节。

设计考虑和权衡

shelve模块的一个优点是允许我们持久化不同的项目。这给我们带来了一个设计负担,即识别项目的适当粒度。粒度太细,我们会浪费时间从它们的部分组装容器。粒度太粗,我们会浪费时间获取和存储不相关的项目。

由于架子需要一个键,我们必须为我们的对象设计适当的键。我们还必须管理我们各种对象的键。这意味着使用额外的属性来存储键,可能创建额外的键集合来充当架子上项目的索引。

用于访问shelve数据库中项目的键就像weakref;它是一个间接引用。这意味着需要额外的处理来跟踪和访问引用的项目。有关weakref的更多信息,请参见第二章,“与 Python 基本特殊方法无缝集成”。

一个键的选择是定位一个属性或属性组合,这些属性是适当的主键,不能被更改。另一个选择是生成不能被更改的代理键;这允许所有其他属性被更改。由于shelve依赖于pickle来表示架子上的项目,我们有一个高性能的 Python 对象的本机表示。这减少了设计将放置到架子上的类的复杂性。任何 Python 对象都可以被持久化。

应用软件层

由于使用shelve时可用的相对复杂性,我们的应用软件必须更加合理地分层。通常,我们将研究具有以下层次结构的软件架构:

  • 表示层:顶层用户界面,可以是 Web 演示或桌面 GUI。

  • 应用层:使应用程序工作的内部服务或控制器。这可以称为处理模型,与逻辑数据模型不同。

  • 业务层或 问题域模型层:定义业务领域或问题空间的对象。有时被称为逻辑数据模型。我们已经看过如何对这些对象建模,使用微博BlogPost的示例。

  • 基础设施:通常包括几个层次以及其他横切关注点,如日志记录、安全性和网络访问。

  • 数据访问层。这些是访问数据对象的协议或方法。我们已经研究了设计类来从shelve存储中访问我们的应用对象。

  • 持久层。这是文件存储中看到的物理数据模型。shelve模块实现了持久性。

当查看本章和第十一章通过 SQLite 存储和检索对象时,清楚地看到,掌握面向对象编程涉及一些更高级的设计模式。我们不能简单地孤立设计类,而是需要考虑类将如何组织成更大的结构。最后,最重要的是,蛮力搜索是一件可怕的事情。必须避免。

展望未来

下一章将与本章大致平行。我们将研究使用 SQLite 而不是 shelve 来持久保存我们的对象。复杂之处在于 SQL 数据库没有提供存储复杂 Python 对象的方法,导致阻抗不匹配问题。我们将研究在使用关系数据库(如 SQLite)时解决这个问题的两种方法。

第十二章传输和共享对象将把焦点从简单的持久性转移到传输和共享对象。这将依赖于我们在本部分看到的持久性;它将在网络协议中添加。

第十一章:通过 SQLite 存储和检索对象

有许多应用程序需要单独持久化对象。我们在第九章序列化和保存 - JSON、YAML、Pickle、CSV 和 XML中所研究的技术偏向于处理单个的、整体的对象。有时,我们需要持久化来自更大领域的单独的对象。我们可能会在单个文件结构中保存博客条目、博客帖子、作者和广告。

在第十章通过 Shelve 存储和检索对象中,我们研究了在shelve数据存储中存储不同的 Python 对象。这使我们能够对大量对象实现 CRUD 处理。任何单个对象都可以在不加载和转储整个文件的情况下进行创建、检索、更新或删除。

在本章中,我们将研究将 Python 对象映射到关系数据库;具体来说,是与 Python 捆绑在一起的sqlite3数据库。这将是三层架构设计模式的另一个示例

在这种情况下,SQLite 数据层比 Shelve 更复杂。SQLite 可以通过锁定允许并发更新。SQLite 提供了基于 SQL 语言的访问层。它通过将 SQL 表保存到文件系统来实现持久性。Web 应用程序是数据库用于处理对单个数据池的并发更新而不是简单文件持久性的一个例子。RESTful 数据服务器也经常使用关系数据库来提供对持久对象的访问。

为了可扩展性,可以使用独立的数据库服务器进程来隔离所有数据库事务。这意味着它们可以分配给一个相对安全的主机计算机,与 Web 应用服务器分开,并位于适当的防火墙后面。例如,MySQL 可以作为独立的服务器进程实现。SQLite 不是独立的数据库服务器;它必须作为主机应用程序的一部分存在;对于我们的目的,Python 是主机。

SQL 数据库,持久性和对象

在使用 SQLite 时,我们将使用基于 SQL 语言的关系数据库访问层。SQL 语言是来自对象导向编程稀有时代的遗留。SQL 语言在很大程度上偏向于过程式编程,从而创建了所谓的关系数据模型和对象数据模型之间的阻抗不匹配。在 SQL 数据库中,我们通常关注三个数据建模层,如下所示:

  • 概念模型:这些是由 SQL 模型隐含的实体和关系。在大多数情况下,这些可以映射到 Python 对象,并应该与应用程序层的数据模型层对应。这是对象关系映射层有用的地方。

  • 逻辑模型:这些是似乎存在于 SQL 数据库中的表、行和列。我们将在我们的 SQL 数据操作语句中处理这些实体。我们说这些似乎存在是因为它们由一个物理模型实现,这个物理模型可能与数据库模式中定义的表、行和列有些不同。例如,SQL 查询的结果看起来像表,但可能不涉及与任何定义的表的存储相平行的存储。

  • 物理模型:这些是持久物理存储的文件、块、页、位和字节。这些实体由管理 SQL 语句定义。在一些更复杂的数据库产品中,我们可以对数据的物理模型行使一定的控制,以进一步调整性能。然而,在 SQLite 中,我们几乎无法控制这一点。

在使用 SQL 数据库时,我们面临许多设计决策。也许最重要的一个是决定如何处理阻抗不匹配。我们如何处理 SQL 的传统数据模型与 Python 对象模型之间的映射?有三种常见的策略:

  • 不映射到 Python:这意味着我们不从数据库中获取复杂的 Python 对象,而是完全在独立的原子数据元素和处理函数的 SQL 框架内工作。这种方法将避免对持久数据库对象的面向对象编程的深度强调。这将限制我们使用四种基本的 SQLite 类型 NULL、INTEGER、REAL 和 TEXT,以及 Python 的datetime.datedatetime.datetime的添加。

  • 手动映射:我们定义一个访问层,用于在我们的类定义和 SQL 逻辑模型之间进行映射,包括表、列、行和键。

  • ORM 层:我们下载并安装一个 ORM 层来处理类和 SQL 逻辑模型之间的映射。

我们将在以下示例中查看所有三种选择。在我们可以查看从 SQL 到对象的映射之前,我们将详细查看 SQL 逻辑模型,并在此过程中涵盖无映射选项。

SQL 数据模型 - 行和表

SQL 数据模型基于具有命名列的命名表。表包含多行数据。每一行都有点像可变的namedtuple。整个表就像list

当我们定义一个 SQL 数据库时,我们定义表及其列。当我们使用 SQL 数据库时,我们操作表中的数据行。在 SQLite 的情况下,我们有一个狭窄的数据类型领域,SQL 将处理这些数据类型。SQLite 处理NULLINTEGERREALTEXTBLOB数据。Python 类型Noneintfloatstrbytes被映射到这些 SQL 类型。同样,当从 SQLite 数据库中获取这些类型的数据时,这些项目将被转换为 Python 对象。

我们可以通过向 SQLite 添加更多的转换函数来调解这种转换。sqlite3模块以这种方式添加了datetime.datedatetime.datetime扩展。我们将在下一节中介绍手动映射。

SQL 语言可以分为三个子语言:数据定义语言DDL),数据操作语言DML)和数据控制语言DCL)。DDL 用于定义表、它们的列和索引。例如,我们可能以以下方式定义一些表:

CREATE TABLE BLOG(
    ID INTEGER PRIMARY KEY AUTOINCREMENT,
    TITLE TEXT );
CREATE TABLE POST(
    ID INTEGER PRIMARY KEY AUTOINCREMENT,
    DATE TIMESTAMP,
    TITLE TEXT,
    RST_TEXT TEXT,
    BLOG_ID INTEGER REFERENCES BLOG(ID)  );
CREATE TABLE TAG(
    ID INTEGER PRIMARY KEY AUTOINCREMENT,
    PHRASE TEXT UNIQUE ON CONFLICT FAIL );
CREATE TABLE ASSOC_POST_TAG(
  POST_ID INTEGER REFERENCES POST(ID),
  TAG_ID INTEGER REFERENCES TAG(ID) );

我们创建了四个表来表示微博应用程序的BlogPost对象。有关 SQLite 处理的 SQL 语言的更多信息,请参阅www.sqlite.org/lang.html。对于 SQL 的更广泛背景,像Creating your MySQL Database: Practical Design Tips and Techniques这样的书籍将介绍 MySQL 数据库上下文中的 SQL 语言。SQL 语言是不区分大小写的。出于没有好的理由,我们更喜欢看到 SQL 全部大写,以区别于周围的 Python 代码。

BLOG表定义了一个带有AUTOINCREMENT选项的主键;这将允许 SQLite 分配键值,使我们不必在代码中生成键。TITLE列是博客的标题。我们将其定义为TEXT。在一些数据库产品中,我们必须提供最大大小;在 SQLite 中,这是不需要的,所以我们将避免混乱。

POST表定义了一个主键,以及日期,标题和 RST 文本作为帖子正文。请注意,在此表定义中我们没有引用标签。我们将在后续 SQL 表所需的设计模式中返回。然而,POST表包括一个正式的REFERENCES子句,以向我们显示这是对拥有BLOG的外键引用。TAG表定义了单个标签文本项,没有其他内容。

最后,我们有一个POSTTAG之间的关联表。这个表只有两个外键。它关联标签和帖子,允许每个帖子有无限数量的标签,以及无限数量的帖子共享一个公共标签。这种关联表是处理这种关系的常见 SQL 设计模式。我们将在下一节中看一些其他 SQL 设计模式。我们可以执行上述定义来创建我们的数据库:

import sqlite3
database = sqlite3.connect('p2_c11_blog.db')
database.executescript( sql_ddl )

所有数据库访问都需要一个连接,使用模块函数sqlite3.connect()创建。我们提供了要分配给我们的数据库的文件名。我们将在单独的部分中查看此函数的其他参数。

DB-API 假设我们的应用程序进程连接到一个单独的数据库服务器进程。在 SQLite 的情况下,实际上并没有单独的进程。但是,为了符合标准,我们使用connect()函数。

sql_ddl变量只是一个长字符串变量,其中包含四个CREATE TABLE语句。如果没有错误消息,那么表结构已经定义。

Connection.executescript()方法在 Python 标准库中被描述为非标准快捷方式。从技术上讲,数据库操作涉及cursor。以下是一种标准化的方法:

crsr = database.cursor()
for stmt in sql_ddl.split(";"):
    crsr.execute(stmt)

由于我们专注于 SQLite,我们将大量使用非标准快捷方式。如果我们关心对其他数据库的可移植性,我们将把重点转移到更严格地遵守 DB-API。在下一节中,当查看查询时,我们将回到游标对象的性质。

通过 SQL DML 语句进行 CRUD 处理

以下四个经典的 CRUD 操作直接映射到 SQL 语句:

  • 创建是通过INSERT语句完成的

  • 检索是通过SELECT语句完成的

  • 更新是通过UPDATE语句以及REPLACE语句(如果支持)来完成的

  • 删除是通过DELETE语句完成的。

我们必须注意,有一种字面的 SQL 语法,以及带有绑定变量占位符而不是字面值的语法。字面的 SQL 语法适用于脚本;然而,因为值始终是字面的,它对应用程序编程来说非常糟糕。在应用程序中构建字面的 SQL 语句涉及无休止的字符串操作和著名的安全问题。请参阅xkcd.com/327/,了解组装字面 SQL 的特定安全问题。我们将专注于带有绑定变量的 SQL。

字面 SQL 被广泛使用,这是一个错误。

注意

永远不要使用字符串操作构建字面的 SQL DML 语句。

Python DB-API 接口,Python Enhancement ProposalPEP)249,www.python.org/dev/peps/pep-0249/,定义了将应用程序变量绑定到 SQL 语句中的几种方法。SQLite 可以使用带有?的位置绑定或带有:name的命名绑定。我们将向您展示这两种绑定变量的样式。

我们使用INSERT语句来创建一个新的BLOG行,如下面的代码片段所示:

create_blog= """
INSERT INTO BLOG(TITLE) VALUES(?)
"""
database.execute(create_blog, ("Travel Blog",))

我们创建了一个带有位置绑定变量?的 SQL 语句,用于BLOG表的TITLE列。然后,在将一组值绑定到绑定变量后,执行该语句。只有一个绑定变量,所以元组中只有一个值。执行完语句后,数据库中就有一行数据。

我们清楚地将 SQL 语句与周围的 Python 代码分开,使用三引号的长字符串文字。在一些应用程序中,SQL 被存储为单独的配置项。将 SQL 保持分开最好是作为从语句名称到 SQL 文本的映射来处理。例如,我们可以将 SQL 保存在 JSON 文件中。这意味着我们可以使用SQL=json.load("sql_config.json")来获取所有 SQL 语句。然后,我们可以使用SQL["some statement name"]来引用特定 SQL 语句的文本。这可以通过将 SQL 从 Python 编程中分离出来,简化应用程序的维护。

DELETEUPDATE语句需要WHERE子句来指定将更改或删除哪些行。要更改博客的标题,我们可以这样做:

update_blog="""
UPDATE BLOG SET TITLE=:new_title WHERE TITLE=:old_title
"""
database.execute( "BEGIN" )
database.execute( update_blog,
    dict(new_title="2013-2014 Travel", old_title="Travel Blog") )
database.commit()

UPDATE语句有两个命名绑定变量::new_title:old_title。此事务将更新BLOG表中具有给定旧标题的所有行,将标题设置为新标题。理想情况下,标题是唯一的,只有一个行受到影响。SQL 操作被定义为对一组行进行操作。确保所需行是集合的内容是数据库设计的问题。因此,建议为每个表设置唯一的主键。

在实现删除操作时,我们总是有两种选择。我们可以在子项仍然存在时禁止删除父项,或者我们可以级联删除父项以同时删除相关的子项。我们将看一下BlogPost和标签关联的级联删除。以下是DELETE语句的序列:

delete_post_tag_by_blog_title= """
DELETE FROM ASSOC_POST_TAG
WHERE POST_ID IN (
    SELECT DISTINCT POST_ID
    FROM BLOG JOIN POST ON BLOG.ID = POST.BLOG_ID
    WHERE BLOG.TITLE=:old_title)
"""
delete_post_by_blog_title= """
DELETE FROM POST WHERE BLOG_ID IN (
    SELECT ID FROM BLOG WHERE TITLE=:old_title)
"""
delete_blog_by_title="""
DELETE FROM BLOG WHERE TITLE=:old_title
"""
try:
    with database:
        title= dict(old_title="2013-2014 Travel")
        database.execute( delete_post_tag_by_blog_title, title )
        database.execute( delete_post_by_blog_title, title )
        database.execute( delete_blog_by_title, title )
    print( "Delete finished normally." )
except Exception as e:
    print( "Rolled Back due to {0}".format(e) )

我们进行了一个三步删除操作。首先,我们根据标题从给定的Blog中删除了ASSOC_POST_TAG的所有行。注意嵌套查询;我们将在下一节中讨论查询。在 SQL 构造中,表之间的导航是一个常见的问题。在这种情况下,我们必须查询BLOG-POST关系以定位将被移除的POST ID;然后,我们可以删除与将被移除的博客相关联的帖子的ASSOC_POST_TAG行。接下来,我们删除了属于特定博客的所有帖子。这也涉及到一个嵌套查询,以定位基于标题的博客的 ID。最后,我们可以删除博客本身。

这是一个显式级联删除设计的示例,我们需要将操作从BLOG表级联到另外两个表。我们将所有删除操作包装在with上下文中,以便它作为一个单独的事务提交。在失败的情况下,它将回滚部分更改,使数据库保持原样。

使用 SQL SELECT 语句查询行

单单关于SELECT语句就可以写一本大部头的书。我们将跳过除了SELECT最基本的特性之外的所有内容。我们的目的是只涵盖足够的 SQL 来存储和检索数据库中的对象。

之前,我们提到,从技术上讲,在执行 SQL 语句时,我们应该使用游标。对于 DDL 和其他 DML 语句,游标的存在与否并不太重要。我们将使用显式创建游标,因为它极大地简化了 SQL 编程。

然而,对于查询来说,游标对于从数据库中检索行是必不可少的。要通过标题查找博客,我们可以从以下简单的代码开始:

"SELECT * FROM BLOG WHERE TITLE=?"

我们需要获取结果行对象的集合。即使我们期望作为响应的是一行,但在 SQL 世界中,一切都是一个集合。通常,从SELECT查询的每个结果集看起来都像是由SELECT语句定义的行和列的表,而不是任何CREATE TABLE DDL。

在这种情况下,使用SELECT *意味着我们避免了枚举预期结果列。这可能导致检索到大量列。以下是使用 SQLite 快捷方式进行此操作的常见优化:

query_blog_by_title= """
SELECT * FROM BLOG WHERE TITLE=?
"""
for blog in database.execute( query_blog_by_title, ("2013-2014 Travel",) ):
    print( blog[0], blog[1] )

SELECT语句中,*是所有可用列的简写。它只对涉及单个表的简单查询真正有用。

我们将请求的博客标题绑定到SELECT语句中的"?"参数。execute()函数的结果是一个游标对象。游标是可迭代的;它将产生结果集中的所有行和匹配WHERE子句中选择条件的所有行。

为了完全符合 Python DB-API 标准,我们可以将其分解为以下步骤:

crsr= database.cursor()
crsr.execute( query_blog_by_title, ("2013-2014 Travel",) )
for blog in crsr.fetchall():
    print( blog[0], blog[1] )

这向我们展示了如何使用连接来创建一个游标对象。然后我们可以使用游标对象执行查询语句。一旦我们执行了查询,我们就可以获取结果集中的所有行。每一行都将是来自SELECT子句的值的元组。在这种情况下,由于SELECT子句是*,这意味着将使用原始CREATE TABLE语句中的所有列。

SQL 事务和 ACID 属性

正如我们所见,SQL DML 语句映射到 CRUD 操作。在讨论 SQL 事务的特性时,我们将看到INSERTSELECTUPDATEDELETE语句的序列。

SQL DML 语句都在 SQL 事务的上下文中工作。在事务中执行的 SQL 语句是一个逻辑工作单元。整个事务可以作为一个整体提交,或者作为一个整体回滚。这支持原子性属性。

SQL DDL 语句(即CREATEDROP)不能在事务中工作。它们隐式结束了任何先前的正在进行的事务。毕竟,它们正在改变数据库的结构;它们是一种不同类型的语句,事务概念不适用。

ACID 属性是原子性、一致性、隔离性和持久性。这些是由多个数据库操作组成的事务的基本特性。有关更多信息,请参见第十章通过 Shelve 存储和检索对象

除非在特殊的读取未提交模式下工作,否则对数据库的每个连接都只能看到包含已提交事务结果的一致版本的数据。未提交的事务通常对其他数据库客户端进程不可见,支持一致性属性。

SQL 事务还支持隔离属性。SQLite 支持几种不同的隔离级别设置。隔离级别定义了 SQL DML 语句在多个并发进程中的交互方式。这是基于锁的使用方式以及进程的 SQL 请求等待锁的方式。从 Python 中,隔离级别在连接到数据库时设置。

每个 SQL 数据库产品对隔离级别和锁定采取不同的方法。没有单一的模型。

在 SQLite 的情况下,有四个隔离级别定义了锁定和事务的性质。有关详细信息,请参见www.sqlite.org/isolation.html。以下是隔离级别:

  • isolation_level=None:这是默认值,也称为自动提交模式。在这种模式下,每个单独的 SQL 语句在执行时都会提交到数据库。这会破坏原子性,除非出现一些奇怪的巧合,所有事务都只涉及单个 SQL 语句。

  • isolation_level='DEFERRED':在这种模式下,锁在事务中尽可能晚地获取。例如,BEGIN语句不会立即获取任何锁。其他读操作(即SELECT语句)将获取共享锁。写操作将获取保留锁。虽然这可以最大程度地提高并发性,但也可能导致竞争进程之间的死锁。

  • isolation_level='IMMEDIATE':在这种模式下,事务BEGIN语句获取一个阻止所有写入的锁。但读取将继续进行。

  • isolation_level='EXCLUSIVE':在这种模式下,事务BEGIN语句获取一个阻止几乎所有访问的锁。对于处于特殊读取未提交模式的连接,它们忽略锁定有一个例外。

对于所有已提交的事务,持久性属性是得到保证的。数据被写入数据库文件。

SQL 规则要求我们执行BEGIN TRANSACTIONCOMMIT TRANSACTION语句来框定一系列步骤。在出现错误的情况下,需要执行ROLLBACK TRANSACTION语句来撤销潜在的更改。Python 接口简化了这一过程。我们可以执行BEGIN语句。其他语句作为sqlite3.Connection对象的函数提供;我们不执行 SQL 语句来结束事务。我们可能会编写诸如以下代码来明确表示:

database = sqlite3.connect('p2_c11_blog.db', isolation_level='DEFERRED')
try:
    database.execute( 'BEGIN' )
    database.execute( "some statement" )
    database.execute( "another statement" )
    database.commit()
except Exception as e:
    database.rollback()
    raise e

我们在建立数据库连接时选择了DEFERRED的隔离级别。这导致我们需要明确开始和结束每个事务。一个典型的场景是将相关的 DML 包装在try块中,如果事情顺利,则提交事务,或者在出现问题的情况下回滚事务。我们可以通过使用sqlite3.Connection对象作为上下文管理器来简化这个过程:

database = sqlite3.connect('p2_c11_blog.db', isolation_level='DEFERRED')
with database:
    database.execute( "some statement" )
    database.execute( "another statement" )

这与先前的例子类似。我们以相同的方式打开了数据库。我们没有执行显式的BEGIN语句,而是进入了一个上下文;上下文为我们处理了Begin

with上下文的末尾,database.commit()将自动完成。在发生异常时,将执行database.rollback(),并且异常将由with语句引发。

设计主键和外键

SQL 表不需要特定的主键。然而,对于给定表的行,省略主键是相当糟糕的设计。正如我们在第十章中所指出的,通过 Shelve 存储和检索对象,可能有一个属性(或属性的组合)可以成为适当的主键。也完全有可能没有属性适合作为主键,我们必须定义代理键。

之前的例子使用了 SQLite 创建的代理键。这可能是最简单的设计,因为它对数据施加了最少的约束。一个约束是主键不能被更新;这成为应用程序编程必须强制执行的规则。在某些情况下,例如在主键值中纠正错误时,我们需要以某种方式更新主键。做到这一点的一种方法是删除并重新创建约束。另一种方法是删除有错误的行,并重新插入具有更正键的行。当存在级联删除时,用于纠正主键的事务可能变得非常复杂。使用代理键可以防止这类问题。

所有表之间的关系都是通过主键和外键引用来完成的。关系有两种极为常见的设计模式。前面的表向我们展示了这两种主要的设计模式。关系有三种设计模式,如下符号列表所示:

  • 一对多:这种关系是一个父博客和许多子帖子之间的关系。REFERENCES子句向我们展示了POST表中的许多行将引用BLOG表中的一行。如果从子到父的方向来看,它将被称为多对一关系。

  • 多对多:这种关系是许多帖子和许多标签之间的关系。这需要在POSTTAG表之间有一个中间关联表;中间表有两个(或更多)外键。多对多关联表也可以有自己的属性。

  • 一对一:这种关系是一种较少见的设计模式。与一对多关系没有技术上的区别;零行或一行的基数是应用程序必须管理的约束。

在数据库设计中,关系可能会有约束:关系可能被描述为可选或强制性;关系可能有基数限制。有时,这些可选性和基数约束会用简短的描述来总结,比如“0:m”表示“零到多个”或“可选的一对多”。可选性和基数约束是应用程序编程逻辑的一部分;在 SQLite 数据库中没有正式的方法来说明这些约束。基本表关系可以以以下一种或两种方式在数据库中实现:

  • 显式:我们可以称这些为声明,因为它们是数据库的 DDL 声明的一部分。理想情况下,它们由数据库服务器强制执行,不遵守关系约束可能会导致某种错误。这些关系也将在查询中重复。

  • 隐式:这些关系仅在查询中说明;它们不是 DDL 的正式部分。

请注意,我们的表定义实现了博客和该博客中各个条目之间的一对多关系。我们在编写的各种查询中使用了这些关系。

使用 SQL 处理应用程序数据

前几节中的示例向我们展示了我们可以称之为过程式SQL 处理。我们避免了从问题域对象中使用任何面向对象的设计。我们不是使用BlogPost对象,而是使用 SQLite 可以处理的数据元素:字符串、日期、浮点数和整数值。我们主要使用了过程式风格的编程。

我们可以看到一系列查询可以用来定位一个博客,所有属于该博客的帖子,以及与与博客相关联的帖子相关联的所有标签。处理看起来像下面的代码:

query_blog_by_title= """
SELECT * FROM BLOG WHERE TITLE=?
"""
query_post_by_blog_id= """
SELECT * FROM POST WHERE BLOG_ID=?
"""
query_tag_by_post_id= """
SELECT TAG.*
FROM TAG JOIN ASSOC_POST_TAG ON TAG.ID = ASSOC_POST_TAG.TAG_ID
WHERE ASSOC_POST_TAG.POST_ID=?
"""
for blog in database.execute( query_blog_by_title, ("2013-2014 Travel",) ):
    print( "Blog", blog )
    for post in database.execute( query_post_by_blog_id, (blog[0],) ):
        print( "Post", post )
        for tag in database.execute( query_tag_by_post_id, (post[0],) ):
            print( "Tag", tag )

我们定义了三个 SQL 查询。第一个将按标题获取博客。对于每个博客,我们获取属于该博客的所有帖子。最后,我们获取与给定帖子相关联的所有标签。

第二个查询隐含地重复了POST表和BLOG表之间的REFERENCES定义。我们正在查找特定博客父级的子帖子;我们需要在查询过程中重复一些表定义。

第三个查询涉及ASSOC_POST_TAG表的行和TAG表之间的关系连接。JOIN子句重述了表定义中的外键引用。WHERE子句也重复了表定义中的REFERENCES子句。

因为第三个查询中连接了多个表,使用SELECT *将产生所有表的列。我们实际上只对TAG表的属性感兴趣,所以我们使用SELECT TAG.*只产生所需的列。

这些查询为我们提供了数据的所有单独的部分。然而,这些查询并没有为我们重建 Python 对象。如果我们有更复杂的类定义,我们必须从检索到的单个数据片段构建对象。特别是,如果我们的 Python 类定义有重要的方法函数,我们需要更好的 SQL 到 Python 映射来利用更完整的 Python 类定义。

在纯 SQL 中实现类似类的处理

让我们看一个更复杂的Blog类的定义。这个定义是从第九章中重复的,我们突出显示了一个感兴趣的方法函数:

from collections import defaultdict
class Blog:
    def __init__( self, title, *posts ):
        self.title= title
        self.entries= list(posts)
    def append( self, post ):
        self.entries.append(post)
    **def by_tag(self):
 **tag_index= defaultdict(list)
 **for post in self.entries:
 **for tag in post.tags:
 **tag_index[tag].append( post )
 **return tag_index
    def as_dict( self ):
        return dict(
            title= self.title,
            underline= "="*len(self.title),
            entries= [p.as_dict() for p in self.entries],
        )

博客的Blog.by_tag()功能将成为一个相当复杂的 SQL 查询。作为面向对象的编程,它只是遍历Post实例的集合,创建defaultdict,将每个标签映射到共享该标签的Posts序列。以下是一个产生类似结果的 SQL 查询:

query_by_tag="""
SELECT TAG.PHRASE, POST.TITLE, POST.ID
FROM TAG JOIN ASSOC_POST_TAG ON TAG.ID = ASSOC_POST_TAG.TAG_ID
JOIN POST ON POST.ID = ASSOC_POST_TAG.POST_ID
JOIN BLOG ON POST.BLOG_ID = BLOG.ID
WHERE BLOG.TITLE=?
"""

这个查询的结果集是一个类似表的行序列,有三个属性:TAG.PHRASEPOST.TITLEPOST.ID。每个POST标题和POST ID 都将与所有相关的TAG短语重复。为了将其转换为一个简单的、HTML 友好的索引,我们需要将所有具有相同TAG.PHRASE的行分组到一个辅助列表中,如下面的代码所示:

tag_index= defaultdict(list)
for tag, post_title, post_id in database.execute( query_by_tag, ("2013-2014 Travel",) ):
    tag_index[tag].append( (post_title, post_id) )
print( tag_index )

这个额外的处理将POST标题和POST ID 的两元组分组成一个有用的结构,可以用来生成 RST 和 HTML 输出。SQL 查询加上相关的 Python 处理非常长 - 比本地面向对象的 Python 更长。

更重要的是,SQL 查询与表定义是分离的。SQL 不是一种面向对象的编程语言。没有整洁的类来捆绑数据和处理在一起。像这样使用 SQL 的过程式编程有效地关闭了面向对象的编程。从严格的面向对象编程的角度来看,我们可以将其标记为“失败”。

有一种观点认为,这种 SQL-heavy、无对象编程对于某些问题比 Python 更合适。通常,这些问题涉及 SQL 的GROUP BY子句。虽然在 SQL 中很方便,但 Python 的defaultdictCounter也实现得非常有效。Python 版本通常如此有效,以至于使用defaultdict查询大量行的小程序可能比使用GROUP BY的数据库服务器更快。如果有疑问,请测量。当数据库管理员力主 SQL 魔法般更快时,请测量。

将 Python 对象映射到 SQLite BLOB 列

我们可以将 SQL 列映射到类定义,以便我们可以从数据库中的数据创建适当的 Python 对象实例。SQLite 包括一个二进制大对象BLOB)数据类型。我们可以将我们的 Python 对象进行 pickle 并将其存储在 BLOB 列中。我们可以计算出我们的 Python 对象的字符串表示(例如,使用 JSON 或 YAML 表示法)并使用 SQLite 文本列。

这种技术必须谨慎使用,因为它实际上破坏了 SQL 处理。BLOB 列不能用于 SQL DML 操作。我们不能对其进行索引或在 DML 语句的搜索条件中使用它。

SQLite BLOB 映射应该保留给那些可以对周围 SQL 处理不透明的对象。最常见的例子是媒体对象,如视频、静态图像或声音片段。SQL 偏向于文本和数字字段。它通常不处理更复杂的对象。

如果我们处理财务数据,我们的应用程序应该使用decimal.Decimal值。我们可能希望使用这种数据在 SQL 中进行查询或计算。由于decimal.Decimal不受 SQLite 直接支持,我们需要扩展 SQLite 以处理这种类型的值。

这有两个方向:转换和适应。我们需要适应Python 数据到 SQLite,我们需要转换SQLite 数据回到 Python。以下是两个函数和注册它们的请求:

import decimal

def adapt_currency(value):
    return str(value)
sqlite3.register_adapter(decimal.Decimal, adapt_currency)

def convert_currency(bytes):
    return decimal.Decimal(bytes.decode())
sqlite3.register_converter("DECIMAL", convert_currency)

我们编写了一个adapt_currency()函数,它将decimal.Decimal对象调整为适合数据库的形式。在这种情况下,我们只是简单地将其转换为字符串。我们注册了适配器函数,以便 SQLite 的接口可以使用注册的适配器函数转换decimal.Decimal类的对象。

我们还编写了一个convert_currency()函数,它将 SQLite 字节对象转换为 Python 的decimal.Decimal对象。我们注册了converter函数,以便DECIMAL类型的列将被正确转换为 Python 对象。

一旦我们定义了适配器和转换器,我们就可以将DECIMAL作为一个完全支持的列类型。为了使其正常工作,我们必须通过在建立数据库连接时设置detect_types=sqlite3.PARSE_DECLTYPES来通知 SQLite。以下是使用我们的新列数据类型的表定义:

CREATE TABLE BUDGET(
    year INTEGER,
    month INTEGER,
    category TEXT,
    amount DECIMAL
)

我们可以像这样使用我们的新列定义:

database= sqlite3.connect( 'p2_c11_blog.db', detect_types=sqlite3.PARSE_DECLTYPES )
database.execute( decimal_ddl )

insert_budget= """
INSERT INTO BUDGET(year, month, category, amount) VALUES(:year, :month, :category, :amount)
"""
database.execute( insert_budget,
    dict(year=2013, month=1, category="fuel", amount=decimal.Decimal('256.78')) )
database.execute( insert_budget,
    dict(year=2013, month=2, category="fuel", amount=decimal.Decimal('287.65')) )

query_budget= """
SELECT * FROM BUDGET
"""
for row in database.execute( query_budget ):
    print( row )

我们创建了一个需要通过转换器函数映射声明类型的数据库连接。一旦我们有了连接,我们可以使用新的DECIMAL列类型创建我们的表。

当我们向表中插入行时,我们使用适当的decimal.Decimal对象。当我们从表中获取行时,我们会发现我们从数据库中得到了适当的decimal.Decimal对象。以下是输出:

(2013, 1, 'fuel', Decimal('256.78'))
(2013, 2, 'fuel', Decimal('287.65'))

这向我们表明我们的decimal.Decimal对象已经被正确存储和从数据库中恢复。我们可以为任何 Python 类编写适配器和转换器。我们需要发明适当的字节表示。由于字符串很容易转换为字节,创建字符串通常是最简单的方法。

手动将 Python 对象映射到数据库行

我们可以将 SQL 行映射到类定义,以便我们可以从数据库中的数据创建适当的 Python 对象实例。如果我们对数据库和类定义小心,这并不是不可能的复杂。然而,如果我们粗心大意,我们可能会创建 SQL 表示非常复杂的 Python 对象。复杂性的一个后果是在对象和数据库行之间的映射中涉及大量查询。挑战在于在面向对象设计和 SQL 数据库施加的约束之间取得平衡。

我们将不得不修改我们的类定义,使其更加了解 SQL 实现。我们将对第十章中显示的BlogPost类设计进行几处修改,通过 Shelve 存储和检索对象

以下是Blog类的定义:

from collections import defaultdict
class Blog:
    def __init__( self, **kw ):
        """Requires title"""
        self.id= kw.pop('id', None)
        self.title= kw.pop('title', None)
        if kw: raise TooManyValues( kw )
        **self.entries= list() # ???
    def append( self, post ):
        self.entries.append(post)
    def by_tag(self):
        tag_index= defaultdict(list)
        **for post in self.entries: # ???
            for tag in post.tags:
                tag_index[tag].append( post )
        return tag_index
    def as_dict( self ):
        return dict(
            title= self.title,
            underline= "="*len(self.title),
            entries= [p.as_dict() for p in self.entries],
        )

我们允许数据库 ID 作为对象的一部分。此外,我们已经修改了初始化,使其完全基于关键字。每个关键字值都从kw参数中弹出。任何额外的值都会引发TooManyValues异常。

我们有两个之前未回答的问题。我们如何处理与博客相关联的帖子列表?我们将修改以下类以添加此功能。以下是Post类定义:

import datetime
class Post:
    def __init__( self, **kw ):
        """Requires date, title, rst_text."""
        self.id= kw.pop('id', None)
        self.date= kw.pop('date', None)
        self.title= kw.pop('title', None)
        self.rst_text= kw.pop('rst_text', None)
        self.tags= list()
        if kw: raise TooManyValues( kw )
    def append( self, tag ):
        self.tags.append( tag )
    def as_dict( self ):
        return dict(
            date= str(self.date),
            title= self.title,
            underline= "-"*len(self.title),
            rst_text= self.rst_text,
            tag_text= " ".join(self.tags),
        )

Blog一样,我们允许数据库 ID 作为对象的一部分。此外,我们已经修改了初始化,使其完全基于关键字。以下是异常类定义:

class TooManyValues( Exception ):
    pass

一旦我们有了这些类定义,我们就可以编写一个访问层,将这些类的对象和数据库之间的数据移动。访问层实现了将 Python 类转换和适应为数据库表中的行的更复杂版本。

为 SQLite 设计访问层

对于这个小的对象模型,我们可以在一个类中实现整个访问层。这个类将包括对每个持久类执行 CRUD 操作的方法。在更大的应用程序中,我们可能需要将访问层分解为每个持久类的单独策略类。然后,我们将统一所有这些类在一个单一的访问层FacadeWrapper下。

这个例子不会痛苦地包括完整访问层的所有方法。我们将向您展示重要的方法。我们将把这分解成几个部分来处理BlogsPosts和迭代器。这是我们访问层的第一部分:

class Access:
    get_last_id= """
    SELECT last_insert_rowid()
    """
    def open( self, filename ):
        self.database= sqlite3.connect( filename )
        self.database.row_factory = sqlite3.Row
    def get_blog( self, id ):
        query_blog= """
        SELECT * FROM BLOG WHERE ID=?
        """
        row= self.database.execute( query_blog, (id,) ).fetchone()
        blog= Blog( id= row['ID'], title= row['TITLE'] )
        return blog
    def add_blog( self, blog ):
        insert_blog= """
        INSERT INTO BLOG(TITLE) VALUES(:title)
        """
        self.database.execute( insert_blog, dict(title=blog.title) )
        row = self.database.execute( get_last_id ).fetchone()
        blog.id= row[0]
        return blog

这个类将Connection.row_factory设置为使用sqlite3.Row类,而不是简单的元组。Row类允许通过数字索引和列名访问。

get_blog()方法从获取的数据库行构造一个Blog对象。因为我们使用sqlite3.Row对象,我们可以通过名称引用列。这澄清了 SQL 和 Python 类之间的映射。

add_blog()方法根据Blog对象向BLOG表中插入一行。这是一个两步操作。首先,我们创建新行。然后,我们执行 SQL 查询以获取分配给该行的行 ID。

请注意,我们的表定义使用INTEGER PRIMARY KEY AUTOINCREMENT。因此,表的主键将匹配行 ID,并且分配的行 ID 将通过last_insert_rowid()函数可用。这允许我们检索分配的行 ID;然后我们可以将其放入 Python 对象以供将来参考。以下是我们如何从数据库中检索单个Post对象:

    def get_post( self, id ):
        query_post= """
        SELECT * FROM POST WHERE ID=?
        """
        row= self.database.execute( query_post, (id,) ).fetchone()
        post= Post( id= row['ID'], title= row['TITLE'],
            date= row['DATE'], rst_text= row['RST_TEXT'] )
        query_tags= """
        SELECT TAG.*
        FROM TAG JOIN ASSOC_POST_TAG ON TAG.ID = ASSOC_POST_TAG.TAG_ID
        WHERE ASSOC_POST_TAG.POST_ID=?
        """
        results= self.database.execute( query_tags, (id,) )
        for id, tag in results:
            post.append( tag )
        return post

为了构建Post,我们有两个查询:首先,我们从POST表中获取一行,以构建Post对象的一部分。然后,我们获取与TAG表中的行连接的关联行。这用于构建Post对象的标签列表。

当我们保存Post对象时,它将有几个部分。必须向POST表添加一行。此外,还需要向ASSOC_POST_TAG表添加行。如果标签是新的,则可能需要向TAG表添加行。如果标签存在,则我们只是将帖子与现有标签的 ID 关联。这是add_post()方法函数:

    def add_post( self, blog, post ):
        insert_post="""
        INSERT INTO POST(TITLE, DATE, RST_TEXT, BLOG_ID)
            VALUES(:title, :date, :rst_text, :blog_id)
        """
        query_tag="""
        SELECT * FROM TAG WHERE PHRASE=?
        """
        insert_tag= """
        INSERT INTO TAG(PHRASE) VALUES(?)
        """
        insert_association= """
        INSERT INTO ASSOC_POST_TAG(POST_ID, TAG_ID) VALUES(:post_id, :tag_id)
        """
        with self.database:
            self.database.execute( **insert_post**,
                dict(title=post.title, date=post.date,
                    rst_text=post.rst_text, blog_id=blog.id) )
            row = self.database.execute( **get_last_id** ).fetchone()
            post.id= row[0]
            for tag in post.tags:
                tag_row= self.database.execute( **query_tag**, (tag,) ).fetchone()
                if tag_row is not None:
                    tag_id= tag_row['ID']
                else:
                    self.database.execute(**insert_tag**, (tag,))
                    row = self.database.execute( **get_last_id** ).fetchone()
                    tag_id= row[0]
                self.database.execute(**insert_association**,
                    dict(tag_id=tag_id,post_id=post.id))
        return post

在数据库中创建完整帖子的过程涉及几个 SQL 步骤。我们使用insert_post语句在POST表中创建行。我们还将使用通用的get_last_id查询返回新POST行的分配的主键。

query_tag语句用于确定数据库中是否存在标签。如果查询的结果不是None,则意味着找到了TAG行,我们有该行的 ID。否则,必须使用insert_tag语句创建一行;必须使用get_last_id查询确定分配的 ID。

每个POST都通过向ASSOC_POST_TAG表插入行与相关标签相关联。insert_association语句创建必要的行。这里有两种迭代器样式查询来定位BlogsPosts

    def blog_iter( self ):
        query= """
        SELECT * FROM BLOG
        """
        results= self.database.execute( query )
        for row in results:
            blog= Blog( id= row['ID'], title= row['TITLE'] )
            yield blog
    def post_iter( self, blog ):
        query= """
        SELECT ID FROM POST WHERE BLOG_ID=?
        """
        results= self.database.execute( query, (blog.id,) )
        for row in results:
            yield self.get_post( row['ID'] )

blog_iter()方法函数定位所有BLOG行并从这些行构建Blog实例。

post_iter()方法函数定位与BLOG ID 相关联的POST ID。POST ID 与get_post()方法一起用于构建Post实例。由于get_post()将对POST表执行另一个查询,因此在这两种方法之间可能存在优化。

实现容器关系

我们对Blog类的定义包括两个需要访问该博客中包含的所有帖子的特性。Blog.entries属性和Blog.by_tag()方法函数都假定博客包含Post实例的完整集合。

为了使其工作,Blog类必须知道Access对象,以便它可以使用Access.post_iter()方法来实现Blog.entries。我们对此有两种整体设计模式:

  • 全局Access对象简单且工作得很好。我们必须确保全局数据库连接适当打开,这可能是全局Access对象的一个挑战。

  • Access对象注入到每个要持久化的Blog对象中。这有点复杂,因为我们必须调整与数据库关联的每个对象。

由于每个与数据库相关的对象都应该由Access类创建,因此Access类将适合工厂模式。我们可以对这个工厂进行三种改变。这些将确保博客或帖子知道活动的Access对象:

  • 每个return blog都需要扩展为blog._access= self; return blog。这发生在get_blog()add_blog()blog_iter()中。

  • 每个return post都需要扩展为post._access= self; return post。这发生在get_post()add_post()post_iter()中。

  • 修改add_blog()方法以接受构建Blog对象的参数,而不是接受在Access工厂之外构建的BlogPost对象。定义看起来会像下面这样:def add_blog( self, title ):

  • 修改add_post()方法以接受一个博客和构建Post对象的参数。定义看起来会像这样:def add_post( self, blog, title, date, rst_text, tags ):

一旦我们将_access属性注入到每个Blog实例中,我们就可以这样做:

@property
def entries( self ):return self._access.post_iter( self )

这将返回属于博客对象的一系列帖子对象。这使我们能够定义类定义中的方法,这些方法将处理子对象或父对象,就好像它们包含在对象中一样。

通过索引提高性能

改善 SQLite 等关系数据库性能的一种方法是加快连接操作。这样做的理想方式是包含足够的索引信息,以便不需要进行缓慢的搜索操作来查找匹配的行。没有索引,必须读取整个表才能找到引用的行。有了索引,只需读取相关的行子集。

当我们定义一个可能在查询中使用的列时,我们应该考虑为该列构建一个索引。这意味着在我们的表定义中添加更多的 SQL DDL 语句。

索引是一个单独的存储,但与特定的表和列相关联。SQL 看起来像以下代码:

CREATE INDEX IX_BLOG_TITLE ON BLOG( TITLE );

这将在Blog表的title列上创建一个索引。不需要做其他任何事情。SQL 数据库在执行基于索引列的查询时将使用该索引。当数据被创建、更新或删除时,索引将自动调整。

索引涉及存储和计算开销。很少使用的索引可能会因为创建和维护成本而成为性能障碍,而不是帮助。另一方面,一些索引非常重要,可以带来显著的性能改进。在所有情况下,我们无法直接控制正在使用的数据库算法;我们所能做的就是创建索引并测量性能的影响。

在某些情况下,将列定义为键可能会自动包括添加索引。这方面的规则通常在数据库的 DDL 部分中清楚地说明。例如,SQLite 表示:

在大多数情况下,唯一和主键约束是通过在数据库中创建唯一索引来实现的。

它接着列出了两个例外。其中一个是整数主键例外,这是我们一直在使用的设计模式,用于强制数据库为我们创建代理键。因此,我们的整数主键设计不会创建任何额外的索引。

添加 ORM 层

有相当多的 Python ORM 项目。这些项目的列表可以在这里找到:wiki.python.org/moin/HigherLevelDatabaseProgramming

我们将选择其中一个作为示例。我们将使用 SQLAlchemy,因为它为我们提供了许多功能,并且相当受欢迎。与许多事物一样,并没有最佳;其他 ORM 层具有不同的优势和劣势。

由于使用关系数据库支持 Web 开发的流行,Web 框架通常包括 ORM 层。Django 有自己的 ORM 层,web.py 也有。在某些情况下,我们可以从更大的框架中分离出 ORM。但是,与独立的 ORM 一起工作似乎更简单。

SQLAlchemy 的文档、安装指南和代码可在www.sqlalchemy.org找到。在安装时,如果不需要高性能优化,使用--without-cextensions可以简化流程。

重要的是要注意,SQLAlchemy 可以完全用一流的 Python 构造替换应用程序的所有 SQL 语句。这具有深远的优势,可以让我们使用单一语言 Python 编写应用程序,即使在数据访问层中使用了第二种语言 SQL。这可以节省一些开发和调试的复杂性。

然而,这并不消除理解底层 SQL 数据库约束以及我们的设计如何适应这些约束的义务。ORM 层并不能神奇地消除设计考虑。它只是将实现语言从 SQL 更改为 Python。

设计 ORM 友好的类

使用 ORM 时,我们将根本改变设计和实现持久类的方式。我们将扩展类定义的语义,具有三个不同的层次含义:

  • 该类将是一个 Python 类,可以用来创建 Python 对象。方法函数被这些对象使用。

  • 该类还将描述一个 SQL 表,并可以被 ORM 用来创建构建和维护数据库结构的 SQL DDL。

  • 该类还将定义 SQL 表和 Python 类之间的映射。它将成为将 Python 操作转换为 SQL DML 并从 SQL 查询构建 Python 对象的工具。

大多数 ORM 都是设计成我们将使用描述符来正式定义类的属性。我们不只是在__init__()方法中定义属性。有关描述符的更多信息,请参见第三章,属性访问、属性和描述符

SQLAlchemy 要求我们构建一个声明基类。这个基类为我们应用程序的类定义提供了一个元类。它还作为我们为数据库定义的元数据的存储库。如果我们遵循默认设置,很容易将这个类称为Base

以下是可能有用的导入列表:

from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy import Column, Table
from sqlalchemy import BigInteger, Boolean, Date, DateTime, Enum, \
    Float, Integer, Interval, LargeBinary, Numeric, PickleType, \
    SmallInteger, String, Text, Time, Unicode, UnicodeText ForeignKey
from sqlalchemy.orm import relationship, backref

我们导入了一些必要的定义来创建表的列,列和创建不特定地映射到 Python 类的稀有表,Table。我们导入了所有通用列类型定义。我们只会使用其中的一些列类型。SQLAlchemy 不仅定义了这些通用类型,还定义了 SQL 标准类型,还为各种支持的 SQL 方言定义了特定于供应商的类型。似乎很容易坚持使用通用类型,并允许 SQLAlchemy 在通用、标准和供应商类型之间进行映射。

我们还导入了两个助手来定义表之间的关系,relationshipbackref。SQLAlchemy 的元类是由declarative_base()函数构建的:

Base = declarative_base()

创建的Base对象必须是我们要定义的任何持久类的元类。我们将定义三个映射到 Python 类的表。我们还将定义第四个表,这个表仅仅是 SQL 实现多对多关系所需的。

这是Blog类:

class Blog(Base):
    __tablename__ = "BLOG"
    id = Column(Integer, primary_key=True)
    title = Column(String)
    def as_dict( self ):
        return dict(
            title= self.title,
            underline= '='*len(self.title),
            entries= [ e.as_dict() for e in self.entries ]
        )

我们的Blog类映射到一个名为"BLOG"的表。我们在这个表中包含了两个列的描述符。id列被定义为Integer主键。隐式地,这将是一个自动增量字段,以便为我们生成代理键。

标题列被定义为通用字符串。我们可以使用TextUnicode甚至UnicodeText。底层引擎可能对这些不同类型有不同的实现。在我们的情况下,SQLite 将几乎相同地处理所有这些。还要注意,SQLite 不需要对列的长度设置上限;其他数据库引擎可能需要对String的大小设置上限。

as_dict()方法函数指的是一个entries集合,在这个类中显然没有定义。当我们查看Post类的定义时,我们将看到entries属性是如何构建的。这是Post类的定义:

class Post(Base):
    __tablename__ = "POST"
    id = Column(Integer, primary_key=True)
    title = Column(String)
    date = Column(DateTime)
    rst_text = Column(UnicodeText)
    blog_id = Column(Integer, ForeignKey('BLOG.id'))
    blog = relationship( 'Blog', backref='entries' )
    tags = relationship('Tag', secondary=assoc_post_tag, backref='posts')
    def as_dict( self ):
        return dict(
            title= self.title,
            underline= '-'*len(self.title),
            date= self.date,
            rst_text= self.rst_text,
            tags= [ t.phrase for t in self.tags],
        )

这个类有五个属性,两个关系和一个方法函数。id属性是一个整数主键;这将是一个默认的自动增量值。title属性是一个简单的字符串。date属性将是一个DateTime列;rst_text被定义为UnicodeText,以强调我们对该字段中任何 Unicode 字符的期望。

blog_id是一个外键引用,指向包含此帖子的父博客。除了外键列的定义,我们还包括了帖子和父博客之间的显式relationship定义。这个relationship定义成为我们可以用于从帖子导航到父博客的属性。

backref选项包括一个将被添加到Blog类中的反向引用。Blog类中的这个引用将是包含在Blog中的Posts的集合。backref选项将在Blog类中命名新属性,以引用子Posts

tags属性使用relationship定义;这个属性将通过一个关联表导航,以定位与帖子相关联的所有Tag实例。我们将看看下面的关联表。这也使用backref来在Tag类中包含一个属性,引用Post实例的相关集合。

as_dict()方法利用tags属性来定位与此Post相关联的所有Tags。以下是Tag类的定义:

class Tag(Base):
    __tablename__ = "TAG"
    id = Column(Integer, primary_key=True)
    phrase = Column(String, unique=True)

我们定义了一个主键和一个String属性。我们包括了一个约束,以确保每个标签都是明确唯一的。尝试插入重复的标签将导致数据库异常。Post类定义中的关系意味着将在这个类中创建额外的属性。

根据 SQL 的要求,我们需要一个关联表来处理标签和帖子之间的多对多关系。这个表纯粹是 SQL 中的技术要求,不需要映射到 Python 类:

assoc_post_tag = Table('ASSOC_POST_TAG', Base.metadata,
    Column('POST_ID', Integer, ForeignKey('POST.id') ),
    Column('TAG_ID', Integer, ForeignKey('TAG.id') )
)

我们必须显式地将其绑定到Base.metadata集合。这种绑定自动成为使用Base作为元类的类的一部分。我们定义了一个包含两个Column实例的表。每个列都是我们模型中另一个表的外键。

使用 ORM 层构建模式

为了连接到数据库,我们需要创建一个引擎。引擎的一个用途是使用我们的表声明构建数据库实例。引擎的另一个用途是管理会话中的数据,这是我们稍后会看到的。以下是一个我们可以用来构建数据库的脚本:

from sqlalchemy import create_engine
engine = create_engine('sqlite:///./p2_c11_blog2.db', echo=True)
Base.metadata.create_all(engine)

当我们创建一个Engine实例时,我们使用类似 URL 的字符串,其中包含了命名供应商产品的名称以及创建与该数据库的连接所需的所有附加参数。在 SQLite 的情况下,连接是一个文件名。在其他数据库产品的情况下,可能会有服务器主机名和身份验证凭据。

一旦我们有了引擎,我们就完成了一些基本的元数据操作。我们已经执行了create_all(),它会构建所有的表。我们也可以执行drop_all(),它会删除所有的表,丢失所有的数据。当然,我们也可以创建或删除单个模式项。

如果我们在软件开发过程中更改表定义,它不会自动改变 SQL 表定义。我们需要显式地删除并重建表。在某些情况下,我们可能希望保留一些操作数据,从旧表中创建和填充新表可能会导致潜在的复杂手术。

echo=True选项会写入生成的 SQL 语句的日志条目。这有助于确定声明是否完整并创建了预期的数据库设计。以下是生成的输出片段:

CREATE TABLE "BLOG" (
  id INTEGER NOT NULL,
  title VARCHAR,
  PRIMARY KEY (id)
)
CREATE TABLE "TAG" (
  id INTEGER NOT NULL,
  phrase VARCHAR,
  PRIMARY KEY (id),
  UNIQUE (phrase)
)

CREATE TABLE "POST" (
  id INTEGER NOT NULL,
  title VARCHAR,
  date DATETIME,
  rst_text TEXT,
  blog_id INTEGER,
  PRIMARY KEY (id),
  FOREIGN KEY(blog_id) REFERENCES "BLOG" (id)
)

CREATE TABLE "ASSOC_POST_TAG" (
  "POST_ID" INTEGER,
  "TAG_ID" INTEGER,
  FOREIGN KEY("POST_ID") REFERENCES "POST" (id),
  FOREIGN KEY("TAG_ID") REFERENCES "TAG" (id)
)

这显示了基于我们的类定义创建的CREATE TABLE语句。

数据库建立后,我们可以创建、检索、更新和删除对象。为了处理数据库对象,我们需要创建一个作为 ORM 管理对象缓存的会话。

使用 ORM 层操作对象

为了使用对象,我们需要一个会话缓存。这与一个引擎绑定在一起。我们将新对象添加到会话缓存中。我们还将使用会话缓存来查询数据库中的对象。这确保了所有需要持久存在的对象都在缓存中。以下是创建一个工作会话的方法:

from sqlalchemy.orm import sessionmaker
Session= sessionmaker(bind=engine)
session= Session()

我们使用 SQLAlchemy 的sessionmaker()函数来创建一个Session类。这个类绑定到我们之前创建的数据库引擎。然后我们使用Session类来构建一个session对象,我们可以用它来执行数据操作。通常需要一个会话来处理对象。

通常,我们会创建一个sessionmaker类以及引擎。然后我们可以使用那个sessionmaker类来为我们的应用程序处理构建多个会话。

对于简单的对象,我们创建它们并将它们加载到会话中,如下所示的代码:

blog= Blog( title="Travel 2013" )
session.add( blog )

这将一个新的Blog对象放入名为session的会话中。Blog对象不一定会被写入数据库。在执行数据库写入之前,我们需要提交会话。为了满足原子性要求,我们将在提交会话之前完成构建一个帖子。

首先,我们将在数据库中查找Tag实例。如果它们不存在,我们将创建它们。如果它们存在,我们将使用在数据库中找到的标签:

tags = [ ]
for phrase in "#RedRanger", "#Whitby42", "#ICW":
    try:
        tag= session.query(Tag).filter(Tag.phrase == phrase).one()
    except sqlalchemy.orm.exc.NoResultFound:
        tag= Tag(phrase=phrase)
        session.add(tag)
    tags.append(tag)

我们使用session.query()函数来检查给定类的实例。每个filter()函数都会向查询中添加一个条件。one()函数确保我们找到了一行。如果引发异常,那么意味着Tag不存在。我们需要构建一个新的Tag并将其添加到会话中。

一旦我们找到或创建了Tag实例,我们可以将其附加到一个名为tags的本地列表中;我们将使用这个Tag实例列表来创建Post对象。以下是我们如何构建一个Post

p2= Post( date=datetime.datetime(2013,11,14,17,25),
    title="Hard Aground",
    rst_text="""Some embarrassing revelation. Including ☹ and ⎕""",
    blog=blog,
    tags=tags
    )
session.add(p2)
blog.posts= [ p2 ]

这包括对父博客的引用。它还包括我们构建的(或在数据库中找到的)Tag实例的列表。

Post.blog属性在类定义中被定义为一个关系。当我们分配一个对象时,SQLAlchemy 会提取出正确的 ID 值,以创建 SQL 数据库用来实现关系的外键引用。

Post.tags属性也被定义为一个关系。Tag对象通过关联表引用。SQLAlchemy 正确跟踪 ID 值,以为我们构建 SQL 关联表中必要的行。

为了将PostBlog关联起来,我们将利用Blog.posts属性。这也被定义为一个关系。当我们将Post对象列表分配给这个关系属性时,ORM 将在每个Post对象中构建适当的外键引用。这是因为我们在定义关系时提供了backref属性。最后,我们提交会话:

session.commit()

数据库插入都是在自动生成的 SQL 中处理的。对象仍然保留在会话中的缓存中。如果我们的应用程序继续使用这个会话实例,那么对象池将保持可用,而不一定执行任何针对数据库的实际查询。

另一方面,如果我们希望确保其他并发进程写入的任何更新都包含在查询中,我们可以为该查询创建一个新的空会话。当我们丢弃一个会话并使用一个空会话时,对象必须从数据库中获取以刷新会话。

我们可以编写一个简单的查询来检查并打印所有的Blog对象:

session= Session()
for blog in session.query(Blog):
    print( "{title}\n{underline}\n".format(**blog.as_dict()) )
    for p in blog.entries:
        print( p.as_dict() )

这将检索所有的Blog实例。Blog.as_dict()方法将检索博客中的所有帖子。Post.as_dict()方法将检索所有标签。SQL 查询将由 SQLAlchemy 自动生成并自动执行。

我们没有包括来自第九章的基于模板的格式的其余部分。它没有改变。我们能够从Blog对象通过entries列表导航到Post对象,而不需要编写复杂的 SQL 查询。将导航转换为查询是 SQLAlchemy 的工作。对于 SQLAlchemy 来说,使用 Python 迭代器就足以生成正确的查询来刷新缓存并返回预期的对象。

如果我们为Engine实例定义了echo=True,那么我们将能够看到执行检索BlogPostTag实例的 SQL 查询序列。这些信息可以帮助我们了解应用程序对数据库服务器进程的工作负载。

给定一个标签字符串查询帖子对象

关系数据库的一个重要好处是我们能够遵循对象之间的关系。使用 SQLAlchemy 的查询功能,我们可以从TagPost的关系,并找到所有共享给定Tag字符串的Posts

查询是会话的一个特性。这意味着已经在会话中的对象不需要从数据库中获取,这可能节省时间。不在会话中的对象被缓存在会话中,以便在提交时处理更新或删除。

为了收集所有具有特定标签的帖子,我们需要使用中间关联表以及PostTag表。我们将使用会话的查询方法来指定我们希望得到的对象类型。我们将使用流畅接口来加入各种中间表和我们希望的最终表以及选择条件。看起来是这样的:

for post in session.query(Post).join(assoc_post_tag).join(Tag).filter(
    Tag.phrase == "#Whitby42" ):
    print( post.blog.title, post.date, post.title, [t.phrase for t in post.tags] )

session.query()方法指定了我们想要查看的表。如果我们只是这样做,我们会看到每一行。join()方法标识必须匹配的附加表。因为我们在类定义中提供了关系信息,SQLAlchemy 可以计算出使用主键和外键匹配行所需的 SQL 细节。最终的filter()方法为所需子集的行提供了选择条件。这是生成的 SQL:

SELECT "POST".id AS "POST_id", "POST".title AS "POST_title", "POST".date AS "POST_date", "POST".rst_text AS "POST_rst_text", "POST".blog_id AS "POST_blog_id"
FROM "POST" JOIN "ASSOC_POST_TAG" ON "POST".id = "ASSOC_POST_TAG"."POST_ID"
JOIN "TAG" ON "TAG".id = "ASSOC_POST_TAG"."TAG_ID"
WHERE "TAG".phrase = ?

Python 版本稍微更容易理解,因为关键匹配的细节可以被省略。print()函数使用post.blog.titlePost实例导航到相关的博客并显示title属性。如果博客在会话缓存中,这种导航会很快完成。如果博客不在会话缓存中,它将从数据库中获取。

这种导航行为也适用于[t.phrase for t in post.tags]。如果对象在会话缓存中,它就会被简单地使用。在这种情况下,与帖子相关的Tag对象的集合可能会导致复杂的 SQL 查询:

SELECT "TAG".id AS "TAG_id", "TAG".phrase AS "TAG_phrase"
FROM "TAG", "ASSOC_POST_TAG"
WHERE ? = "ASSOC_POST_TAG"."POST_ID"
AND "TAG".id = "ASSOC_POST_TAG"."TAG_ID"

在 Python 中,我们只需通过post.tags进行导航。SQLAlchemy 为我们生成并执行了 SQL。

通过索引提高性能

改善关系数据库(如 SQLite)性能的一种方法是加快连接操作。我们不希望 SQLite 读取整个表来查找匹配的行。通过在特定列上建立索引,SQLite 可以检查索引并仅从表中读取相关行。

当我们定义可能在查询中使用的列时,我们应该考虑为该列建立索引。这是一个简单的过程,使用 SQLAlchemy。我们只需用index=True注释类的属性。

我们可以对我们的Post表进行相当小的更改,例如添加索引:

class Post(Base):
    __tablename__ = "POST"
    id = Column(Integer, primary_key=True)
    title = Column(String, index=True)
    date = Column(DateTime, index=True)
    blog_id = Column(Integer, ForeignKey('BLOG.id'), index=True)

为标题和日期添加两个索引通常会加快按标题或日期查询帖子的速度。并不一定保证性能会有所改善。关系数据库的性能涉及许多因素。重要的是要在有索引和没有索引的情况下测量现实工作负载的性能。

通过blog_id添加索引,同样,可能会加快在BlogPost表中行之间的连接操作。数据库引擎也可能使用一种不受此索引影响的算法。

索引涉及存储和计算开销。很少使用的索引可能创建和维护的成本如此之高,以至于它成为一个问题,而不是解决方案。另一方面,一些索引非常重要,可以带来显著的性能改进。在所有情况下,我们无法直接控制正在使用的数据库算法;我们能做的就是创建索引并测量性能影响。

模式演变

在处理 SQL 数据库时,我们必须解决模式演变的问题。我们的对象具有动态状态和静态类定义。我们可以轻松地持久化动态状态。我们的类定义是持久数据的模式的一部分;我们还有对正式 SQL 模式的映射。无论是类还是 SQL 模式都不是绝对静态的。

如果我们更改了类定义,我们如何从数据库中获取对象?如果数据库必须更改,我们如何升级 Python 映射并仍然映射数据?一个好的设计通常涉及几种技术的组合。

Python 类的方法函数和属性的更改不会改变与 SQL 行的映射。这些可以称为次要更改,因为数据库中的表仍与更改后的类定义兼容。新软件发布可以有一个新的次要版本号。

Python 类属性的更改不一定会改变持久化对象的状态。在将数据类型从数据库转换为 Python 对象时,SQL 可能会有些灵活。ORM 层可以增加灵活性。在某些情况下,我们可以进行一些类或数据库更改,并称其为次要版本更新,因为现有的 SQL 模式仍将与新的类定义一起工作。例如,我们可以将 SQL 表从整数更改为字符串,而不会因为 SQL 和 ORM 转换而出现重大破坏。

对 SQL 表定义的更改将明显修改持久化对象。当现有数据库行不再与新类定义兼容时,这些可以称为重大更改。这些类型的更改不应该通过修改Python 类定义来进行。这些类型的更改应该通过定义一个新的子类,并提供一个更新的工厂函数来创建旧类或新类的实例。

在处理持久的 SQL 数据时,可以通过以下两种方式之一进行模式更改:

  • 使用 SQL 的ALTER语句对现有模式进行更改。某些类型的更改可以逐步对 SQL 模式进行。对所允许的更改有许多约束和限制。这并不具有很好的泛化性;应该将其视为一种可能适用于较小更改的特殊情况。

  • 创建新表和删除旧表。一般来说,SQL 模式更改将足够重要,以至于我们需要从旧表创建新版本的表,对数据结构进行深刻的更改。

SQL 数据库模式更改通常涉及运行一次性转换脚本。此脚本将使用旧模式查询现有数据,将其转换为新数据,并使用新模式将新数据插入数据库。当然,这必须在用户首选的实时操作数据库之前在备份数据库上进行测试。一旦完成模式更改,就可以安全地忽略旧模式,并稍后删除以释放存储空间。

这种转换可以在单个数据库中使用不同的表名或不同的模式名(对于支持命名模式的数据库)。如果我们将旧数据和新数据并排放置,我们就可以从旧应用程序灵活地升级到新应用程序。这对于试图提供全天候可用性的网站尤为重要。

在某些情况下,有必要向模式添加表,其中仅包含纯粹的管理细节,例如模式版本的标识。应用程序可以在建立数据库连接后首先查询此表,并在模式版本错误时快速失败。

总结

我们以三种方式查看了使用 SQLite 的基础知识:直接使用、通过访问层、以及通过 SQLAlchemy ORM。我们必须创建 SQL DDL 语句;我们可以直接在我们的应用程序中或在访问层中进行此操作。我们还可以通过 SQLAlchemy 类定义来构建 DDL。为了操作数据,我们将使用 SQL DML 语句;我们可以以过程化风格直接进行此操作,或者我们可以使用我们自己的访问层或 SQLAlchemy 来创建 SQL。

设计考虑和权衡

sqlite3模块的一个优点是它允许我们持久化不同的项目。由于我们使用支持并发写入的数据库,我们可以有多个进程更新数据,依靠 SQLite 通过其内部锁定处理并发。

使用关系数据库会施加许多限制。我们必须考虑如何将我们的对象映射到数据库表的行:

  • 我们可以直接使用 SQL,仅使用支持的 SQL 列类型,并在很大程度上避免面向对象的类

  • 我们可以使用手动映射来扩展 SQLite 以处理我们的对象作为 SQLite BLOB 列

  • 我们可以编写自己的访问层来适应和转换我们的对象和 SQL 行

  • 我们可以使用 ORM 层来实现行到对象的映射。

映射替代方案

混合 Python 和 SQL 的问题在于可能会产生一种我们可以称之为“全能 SQL”解决方案的冲动。这里的想法是关系数据库在某种程度上是理想的平台,而 Python 通过注入不必要的面向对象特性来破坏这一点。

有时,全 SQL、无对象的设计策略被证明更适合某些类型的问题。具体来说,支持者会指出使用 SQL 的GROUP BY子句对大量数据进行汇总是 SQL 的理想用途。

这是由 Python 的defaultdictCounter非常有效地实现的。Python 版本通常如此有效,以至于一个小型的 Python 程序查询大量行并使用defaultdict累积摘要可能比使用GROUP BY执行 SQL 的数据库服务器更快。

如果有疑问,就进行测量。SQL 数据库支持者会说一些无稽之谈。当面对 SQL 应该神奇地比 Python 更快的说法时,收集证据。这种数据收集不仅限于一次性的初始技术尖峰情况。随着使用量的增长和变化,SQL 数据库与 Python 的相对优点也会发生变化。

自制的访问层往往会对问题域高度特定。这可能具有高性能和相对透明的从行到对象的映射的优势。但每当类发生变化或数据库实现发生变化时,维护可能会很烦人。

一个成熟的 ORM 项目可能需要一些初始努力来学习 ORM 的特性,但长期的简化是重要的好处。学习 ORM 层的特性可能既涉及初始工作,也涉及重新工作,因为经验教训。首次尝试设计具有良好对象特性并仍适合 SQL 框架的设计将不得不重新进行,因为应用程序的权衡和考虑变得更加清晰。

键和关键设计

因为 SQL 依赖于键,我们必须小心设计和管理各种对象的键。我们必须设计从对象到将用于标识该对象的键的映射。一种选择是找到适当的主键属性(或属性组合),并且不能更改。另一种选择是生成不能更改的代理键;这允许所有其他属性被更改。

大多数关系数据库可以为我们生成代理键。这通常是最好的方法。对于其他唯一属性或候选键属性,我们可以定义 SQL 索引以提高处理性能。

我们还必须考虑对象之间的外键关系。有几种常见的设计模式:一对多,多对一,多对多和可选的一对一。我们需要知道 SQL 如何使用键来实现这些关系,以及 SQL 查询将用于填充 Python 集合。

应用软件层

由于使用sqlite3时相对复杂,我们的应用软件必须更加合理地分层。通常,我们将查看具有类似以下层的软件架构:

  • 表示层:这是顶层用户界面,可以是 Web 演示或桌面 GUI。

  • 应用层:这是使应用程序工作的内部服务或控制器。这可以称为处理模型,与逻辑数据模型不同。

  • 业务层或问题域模型层:这些是定义业务领域或问题空间的对象。有时被称为逻辑数据模型。我们看了如何使用微博博客和帖子示例来对这些对象进行建模。

  • 基础设施:这通常包括几个层,以及其他横切关注点,如日志记录、安全性和网络访问:

  • 数据访问层:这些是访问数据对象的协议或方法。通常是 ORM 层。我们已经看过 SQLAlchemy。还有许多其他选择。

  • 持久性层:这是在文件存储中看到的物理数据模型。sqlite3模块实现了持久性。当使用诸如 SQLAlchemy 之类的 ORM 层时,我们只在创建引擎时引用 SQLite。

在本章中查看sqlite3和第十章中的shelve通过 Shelve 存储和检索对象,清楚地表明掌握面向对象编程涉及一些更高级别的设计模式。我们不能简单地孤立设计类,而是需要考虑如何将类组织成更大的结构。

展望未来

在下一章中,我们将研究如何使用 REST 传输和共享对象。这种设计模式向我们展示了如何管理状态的表示以及如何将对象状态从一个进程传输到另一个进程。我们将利用许多持久性模块来表示正在传输的对象的状态。

在第十三章中,配置文件和持久性,我们将研究配置文件。我们将研究利用持久性数据的几种方法,以控制应用程序。

第十二章:传输和共享对象

我们将扩展我们在第九章中展示的对象表示的序列化技术,序列化和保存 - JSON、YAML、Pickle、CSV 和 XML。当我们需要传输一个对象时,我们执行某种表述性状态转移REST)。当我们序列化一个对象时,我们正在创建对象状态的表示。这种表示可以传输到另一个进程(通常在另一台主机上);然后,另一个进程可以根据状态的表示和本地类的定义构建原始对象的版本。

我们可以以多种方式执行 REST 处理。其中之一是我们可以使用的状态表示。另一个方面是控制传输的协议。我们不会涵盖所有这些方面的组合。相反,我们将专注于两种组合。

对于互联网传输,我们将利用 HTTP 协议来实现创建-检索-更新-删除CRUD)处理操作。这通常被称为 REST Web 服务器。我们还将研究提供 RESTful Web 服务。这将基于 Python 的Web 服务网关接口WSGI)参考实现,即wsgiref包。

对于在同一主机上的进程之间的本地传输,我们将研究multiprocessing模块提供的本地消息队列。有许多复杂的队列管理产品。我们将专注于标准库提供的内容。

这种处理建立在使用 JSON 或 XML 来表示对象的基础上。对于 WSGI,我们将添加 HTTP 协议和一组设计模式来定义 Web 服务器中的事务。对于多处理,我们将添加一个处理池。

在处理 REST 传输时,还有一个额外的考虑因素:源或数据可能不可信。我们必须实施一些安全措施。在使用常用表示形式 JSON 和 XML 时,几乎没有安全考虑。YAML 引入了一个安全问题,并支持安全加载操作;有关更多信息,请参见第九章中的内容。由于安全问题,pickle模块还提供了一个受限制的反序列化器,可以信任不导入异常模块并执行有害代码。

类、状态和表示

在某些情况下,我们可能正在创建一个将向远程客户端提供数据的服务器。在其他情况下,我们可能希望从远程计算机消耗数据。我们可能有一个混合情况,即我们的应用既是远程计算机的客户端,又是移动应用程序的服务器。有许多情况下,我们的应用程序与远程持久化的对象一起工作。

我们需要一种方法来从一个进程传输对象到另一个进程。我们可以将更大的问题分解为两个较小的问题。互联网协议可以帮助我们将字节从一个主机上的一个进程传输到另一个主机上的一个进程。序列化可以将我们的对象转换为字节。

与对象状态不同,我们通过一个完全独立且非常简单的方法传输类定义。我们通过源代码交换类定义。如果我们需要向远程主机提供类定义,我们将向该主机发送 Python 源代码。代码必须被正确安装才能有用;这通常是由管理员手动执行的操作。

我们的网络传输字节。因此,我们需要将对象实例变量的值表示为字节流。通常,我们将使用两步转换为字节;我们将对象的状态表示为字符串,并依赖于字符串以标准编码之一提供字节。

使用 HTTP 和 REST 传输对象

超文本传输协议HTTP)是通过一系列请求评论RFC)文档定义的。我们不会审查所有细节,但我们将触及三个重点。

HTTP 协议包括请求和响应。请求包括方法、统一资源标识符URI)、一些标头和可选附件。标准中定义了许多可用的方法。大多数浏览器专注于进行GETPOST请求。标准浏览器包括GETPOSTPUTDELETE请求,这些是我们将利用的,因为它们对应于 CRUD 操作。我们将忽略大部分标头,关注 URI 的路径部分。

响应包括状态码数字和原因、标头和一些数据。有各种各样的状态码数字。其中,我们只对其中的一些感兴趣。200状态码是服务器的通用OK响应。201状态码是已创建响应,可能适合显示我们的帖子已经成功并且数据已经发布。204状态码是无内容响应,可能适合DELETE400状态码是错误请求401状态码是未经授权404状态码是未找到。这些状态码通常用于反映无法执行或无效的操作。

大多数2xx成功的响应将包括一个编码的对象或对象序列。4xx错误响应可能包括更详细的错误消息。

HTTP 被定义为无状态的。服务器不应该记得先前与客户端的交互。我们有许多候选的解决方法来解决这个限制。对于交互式网站,使用 cookie 来跟踪事务状态并改善应用程序行为。然而,对于 Web 服务,客户端不会是一个人;每个请求都可以包括认证凭据。这进一步要求保护连接。对于我们的目的,我们将假设服务器将使用安全套接字层SSL)并在端口 443 上使用 HTTPS 连接,而不是在端口 80 上使用 HTTP。

通过 REST 实现 CRUD 操作

我们将讨论 REST 协议背后的三个基本理念。第一个理念是使用任何方便的文本序列化对象状态。其次,我们可以使用 HTTP 请求 URI 来命名一个对象;URI 可以包括任何级别的细节,包括模式、模块、类和统一格式的对象标识。最后,我们可以使用 HTTP 方法来映射到 CRUD 规则,以定义对命名对象执行的操作。

将 HTTP 用于 RESTful 服务推动了 HTTP 请求和响应的原始定义的边界。这意味着一些请求和响应语义是开放的,正在进行讨论。我们不会呈现所有的替代方案,每个替代方案都有独特的优点,我们将建议一个单一的方法。我们的重点是 Python 语言,而不是设计 RESTful Web 服务的更一般的问题。REST 服务器通常通过以下五个基本用例支持 CRUD 操作:

  • 创建:我们将使用HTTP POST请求来创建一个新对象,并提供仅提供类信息的 URI。例如//host/app/blog/这样的路径可能命名类。响应可能是一个包含对象副本的 201 消息,该对象最终被保存。返回的对象信息可能包括 RESTful 服务器为新创建的对象分配的 URI,或者构建 URI 的相关键。POST请求预期通过创建新的东西来改变 RESTful 资源。

  • 检索-搜索:这是一个可以检索多个对象的请求。我们将使用HTTP GET请求和提供搜索条件的 URI,通常是在?字符之后的查询字符串的形式。URI 可能是//host/app/blog/?title="Travel 2012-2013"。请注意,GET永远不会改变任何 RESTful 资源的状态。

  • 检索-实例:这是一个请求单个对象的请求。我们将使用HTTP GET请求和在 URI 路径中命名特定对象的 URI。URI 可能是//host/app/blog/id/。虽然预期的响应是一个单一对象,但它可能仍然被包装在列表中,以使其与搜索响应兼容。由于此响应是GET,因此状态没有变化。

  • 更新:我们将使用HTTP PUT请求和标识要替换的对象的 URI。URI 可能是//host/app/blog/id/。响应可能是一个包含修订对象副本的 200 消息。显然,这预计会对 RESTful 资源进行更改。使用 200 以外的其他状态响应是有充分理由的。我们将在这里的示例中坚持使用 200。

  • 删除:我们将使用HTTP DELETE请求和类似//host/app/blog/id/的 URI。响应可能是一个简单的204 NO CONTENT,在响应中不提供任何对象细节。

由于 HTTP 协议是无状态的,没有提供登录和注销的功能。每个请求必须单独进行身份验证。我们经常使用 HTTP Authorization头来提供用户名和密码凭据。在这样做时,我们绝对必须使用 SSL 来保护Authorization头的内容。还有更复杂的替代方案,利用单独的身份管理服务器提供身份验证令牌而不是凭据。

实施非 CRUD 操作

一些应用程序将具有无法轻松归类为 CRUD 的操作。例如,我们可能有一个远程过程调用RPC)风格的应用程序,执行复杂的计算。计算的参数通过 URI 提供,因此在服务器状态中没有 RESTful 的变化。

大多数情况下,这些以计算为重点的操作可以实现为GET请求,因为状态没有变化。然而,如果我们要保留请求和回复的日志作为不可否认方案的一部分,我们可能会考虑将它们作为POST请求。这在收费网站中尤为重要。

REST 协议和 ACID

ACID 属性在第十章中定义,通过 Shelve 存储和检索对象。这些属性是原子性、一致性、隔离性和持久性。这些是由多个数据库操作组成的事务的基本特征。这些属性不会自动成为 REST 协议的一部分。我们必须考虑当我们确保满足 ACID 属性时 HTTP 是如何工作的。

每个 HTTP 请求都是原子的;因此,我们应该避免设计一个应用程序,该应用程序进行一系列相关的POST请求,希望这些请求变得原子。相反,我们应该寻找一种将所有信息捆绑成一个单一请求的方法。此外,我们必须意识到请求通常会从各种客户端交错进行;因此,我们没有一种干净的方法来处理交错请求序列之间的隔离。如果我们有一个适当的多层设计,我们应该将持久性委托给一个单独的持久性模块。

为了实现 ACID 属性,一个常见的技术是定义包含所有相关信息的POSTPUTDELETE请求。通过提供单个复合对象,应用程序可以在单个 REST 请求中执行所有操作。这些更大的对象成为文档,可能包含更复杂交易的几个部分。

当查看我们的博客和帖子关系时,我们发现我们可能希望处理两种HTTP POST请求来创建一个新的Blog实例。这两个请求如下:

  • 只有标题没有额外帖子条目的博客:对于这个,我们可以很容易地实现 ACID 属性,因为它只是一个单一的对象。

  • 一个复合对象,即博客加上一系列帖子条目:我们需要序列化博客和所有相关的Post实例。这需要作为一个单独的POST请求发送。然后,我们可以通过创建博客、相关帖子,并在整个对象集合变得持久时返回单个201 Created状态来实现 ACID 属性。这可能涉及支持 RESTful web 服务器的数据库中的复杂多语句事务。

选择一种表示形式 - JSON、XML 或 YAML

没有一个很好的理由来选择单一的表示;支持多种表示相对容易。客户端应该被允许要求一种表示。客户端可以在几个地方指定表示:

  • 我们可以使用查询字符串的一部分,https://host/app/class/id/?form=XML

  • 我们可以使用 URI 的一部分:https://host/app;XML/class/id/。在这个例子中,我们使用了一个子分隔符来标识所需的表示。app;XML语法命名了应用程序app和格式XML

  • 我们可以使用片段标识符,https://host/app/class/id/#XML

  • 我们可以在头部提供它。例如,Accept头可以用来指定表示形式。

这些都没有明显的优势。与现有的 RESTful web 服务的兼容性可能会建议特定的格式。框架解析 URI 模式的相对容易可能会建议一种格式。

JSON 被许多 JavaScript 表示层所偏爱。其他表示形式,如 XML 或 YAML,对其他表示层或其他类型的客户端也可能有帮助。在某些情况下,可能会有另一种表示形式。例如,特定客户端应用程序可能需要 MXML 或 XAML。

实现 REST 服务器 - WSGI 和 mod_wsgi

由于 REST 是建立在 HTTP 之上的,因此 REST 服务器是对 HTTP 服务器的扩展。为了进行强大、高性能、安全的操作,通常的做法是在诸如Apache httpdnginx之类的服务器上构建。这些服务器默认不支持 Python;它们需要一个扩展模块来与 Python 应用程序进行接口。

在 Web 服务器和 Python 之间广泛使用的接口是 WSGI。有关更多信息,请参见www.wsgi.org。Python 标准库包括一个 WSGI 参考实现。请参阅 PEP 3333,www.python.org/dev/peps/pep-3333/,了解这个参考实现在 Python 3 中的工作方式。

WSGI 背后的理念是围绕一个相对简单和可扩展的 Python API 标准化 HTTP 请求-响应处理。这使我们能够从相对独立的组件中构建复杂的 Python 解决方案。目标是创建一个嵌套的应用程序系列,对请求进行增量处理。这创建了一种管道,其中每个阶段都向请求环境添加信息。

每个 WSGI 应用程序必须具有此 API:

result = application(environ, start_response)

environ变量必须是包含环境信息的dict。必须使用start_response函数来开始准备向客户端发送响应;这是发送响应状态码和标头的方式。返回值必须是一个字符串的可迭代对象;也就是说,响应的正文。

在 WSGI 标准中,术语应用程序被灵活地使用。一个单一的服务器可能有许多 WSGI 应用程序。WSGI 的目的不是鼓励或要求在符合 WSGI 的应用程序的低级别进行编程。其目的是使用更大、更复杂的 Web 框架。所有的 Web 框架都会使用 WSGI API 定义来确保兼容性。

WSGI 参考实现不打算成为公共面向的 Web 服务器。此服务器不直接处理 SSL;需要一些工作来使用适当的 SSL 加密包装套接字。为了访问端口 80(或端口 443),进程必须以setuid模式执行,使用特权用户 ID。一种常见的做法是在 Web 服务器中安装 WSGI 扩展模块或使用支持 WSGI API 的 Web 服务器。这意味着 Web 请求通过标准 WSGI 接口从 Web 服务器路由到 Python。这允许 Web 服务器提供静态内容。通过 WSGI 接口可用的 Python 应用程序将提供动态内容。

以下是一些要么用 Python 编写,要么具有 Python 插件的 Web 服务器的列表,wiki.python.org/moin/WebServers。这些服务器(或插件)旨在提供强大、安全的、面向公众的 Web 服务器。

另一种选择是构建一个独立的 Python 服务器,并使用重定向将请求从面向公众的服务器转移到单独的 Python 守护程序。在使用 Apache httpd 时,可以通过mod_wsgi模块创建一个单独的 Python 守护程序。由于我们专注于 Python,我们将避免 nginx 或 Apache httpd 的细节。

创建一个简单的 REST 应用程序和服务器

我们将编写一个非常简单的 REST 服务器,提供轮盘赌的旋转。这是一个对简单请求做出响应的服务的示例。我们将专注于 Python 中的 RESTful web 服务器编程。还需要一些额外的细节来将此软件插入到较大的 Web 服务器中,例如 Apache httpd 或 nginx。

首先,我们将定义一个简化的轮盘赌轮:

class Wheel:
    """Abstract, zero bins omitted."""
    def __init__( self ):
        self.rng= random.Random()
        self.bins= [
            {str(n): (35,1),
            self.redblack(n): (1,1),
            self.hilo(n): (1,1),
            self.evenodd(n): (1,1),
            } for n in range(1,37)
        ]
    @staticmethod
    def redblack(n):
        return "Red" if n in (1, 3, 5, 7, 9,  12, 14, 16, 18,
            19, 21, 23, 25, 27,  30, 32, 34, 36) else "Black"
    @staticmethod
    def hilo(n):
        return "Hi" if n >= 19 else "Lo"
    @staticmethod
    def evenodd(n):
        return "Even" if n % 2 == 0 else "Odd"
    def spin( self ):
        return self.rng.choice( self.bins )

Wheel类是一个箱子的列表。每个箱子都是dict;键是如果球落在该箱子中将获胜的赌注。箱子中的值是支付比例。我们只向您展示了一个简短的赌注列表。可用的轮盘赌赌注的完整列表相当庞大。

此外,我们省略了零或双零箱。有两种不同类型的常用轮子。以下是定义常用轮子不同类型的两个混合类:

class Zero:
    def __init__( self ):
        super().__init__()
        self.bins += [ {'0': (35,1)} ]

class DoubleZero:
    def __init__( self ):
        super().__init__()
        self.bins += [ {'00': (35,1)} ]

Zero mixin 包括单个零的初始化。DoubleZero mixin 包括双零。这些是相对简单的箱子;只有在对数字本身下注时才会有回报。

我们在这里使用混合类,因为我们将在以下一些示例中调整Wheel的定义。通过使用混合类,我们可以确保对基类Wheel的每个扩展都能保持一致。有关混合样式设计的更多信息,请参见第八章,装饰器和混合类-横切面

以下是定义常用轮子不同类型的两个子类:

class American( Zero, DoubleZero, Wheel ):
    pass

class European( Zero, Wheel ):
    pass

这两个定义使用混合类扩展了基本的Wheel类,这些混合类将为每种类型的轮子正确初始化箱子。Wheel的这些具体子类可以如下使用:

american = American()
european = European()
print( "SPIN", american.spin() )

spin()的每次评估都会产生一个简单的字典,如下所示:

{'Even': (1, 1), 'Lo': (1, 1), 'Red': (1,   1), '12': (35, 1)}

这个dict中的键是赌注名称。值是一个包含支付比例的两元组。前面的例子向我们展示了红色 12 作为赢家;它也是低和偶数。如果我们在 12 上下注,我们的赢利将是我们的赌注的 35 倍,支付比例为 35 比 1。其他赌注的支付比例为 1 比 1:我们会翻倍赢钱。

我们将定义一个 WSGI 应用程序,使用简单的路径来确定使用哪种类型的轮子。例如http://localhost:8080/european/这样的 URI 将使用欧洲轮盘。任何其他路径将使用美式轮盘。

以下是使用Wheel实例的 WSGI 应用程序:

import sys
import wsgiref.util
import json
def wheel(environ, start_response):
    request= wsgiref.util.shift_path_info(environ) # 1\. Parse.
    print( "wheel", request, file=sys.stderr ) # 2\. Logging.
    if request.lower().startswith('eu'): # 3\. Evaluate.
        winner= european.spin()
    else:
        winner= american.spin()
    status = '200 OK' # 4\. Respond.
    headers = [('Content-type', 'application/json; charset=utf-8')]
    start_response(status, headers)
    return [ json.dumps(winner).encode('UTF-8') ]

这向我们展示了 WSGI 应用程序中的一些基本要素。

首先,我们使用wsgiref.util.shift_path_info()函数来检查environ['PATH_INFO']的值。这将解析请求中路径信息的一个级别;它将返回找到的字符串值,或者在没有提供路径的情况下返回None

其次,日志行告诉我们,如果我们想生成日志,必须写入sys.stderr。写入sys.stdout的任何内容都将被用作 WSGI 应用程序的响应的一部分。在调用start_response()之前打印的任何内容都将导致异常,因为状态和标头尚未发送。

第三,我们评估请求以计算响应。我们使用两个全局变量europeanamerican,以提供一致随机化的响应序列。如果我们尝试为每个请求创建一个唯一的Wheel实例,那么我们就不恰当地使用了随机数生成器。

第四,我们用适当的状态码和 HTTP 标头制定了一个响应。响应的主体是一个 JSON 文档,我们使用 UTF-8 进行编码,以生成符合 HTTP 要求的适当字节流。

我们可以使用以下函数启动此服务器的演示版本:

from wsgiref.simple_server import make_server
def roulette_server(count=1):
    httpd = make_server('', 8080, wheel)
    if count is None:
        httpd.serve_forever()
    else:
        for c in range(count):
            httpd.handle_request()

wsgiref.simple_server.make_server()函数创建服务器对象。该对象将调用可调用的wheel()来处理每个请求。我们使用本地主机名''和非特权端口8080。使用特权端口80需要setuid权限,并且最好由Apache httpd服务器处理。

构建服务器后,它可以自行运行;这是httpd.serve_forever()方法。然而,对于单元测试,通常最好处理有限数量的请求,然后停止服务器。

我们可以在终端窗口的命令行中运行此函数。一旦我们运行该函数,我们可以使用浏览器查看我们向http://localhost:8080/发出请求时的响应。这在创建技术性的突发情况或调试时非常有帮助。

实现 REST 客户端

在查看更智能的 REST 服务器应用程序之前,我们将看一下编写 REST 客户端。以下是一个将向 REST 服务器发出简单的GET请求的函数:

import http.client
import json
def json_get(path="/"):
    rest= http.client.HTTPConnection('localhost', 8080)
    rest.request("GET", path)
    response= rest.getresponse()
    print( response.status, response.reason )
    print( response.getheaders() )
    raw= response.read().decode("utf-8")
    if response.status == 200:
        document= json.loads(raw)
        print( document )
    else:
        print( raw )

这向我们展示了使用 RESTful API 的本质。http.client模块有一个四步过程:

  • 通过HTTPConnection()建立连接

  • 发送带有命令和路径的请求

  • 获取响应

  • 要读取响应中的数据

请求可以包括附加的文档(用于 POST)以及其他标头。在此函数中,我们打印了响应的几个部分。在此示例中,我们读取了状态码和原因文本,并将其打印出来。大多数情况下,我们期望状态码为 200,原因为OK。我们还读取并打印了所有标头。

最后,我们将整个响应读入临时字符串raw。如果状态码为 200,我们使用json模块从响应字符串中加载对象。这将恢复从服务器发送的任何 JSON 编码对象。

如果状态码不是 200,我们只需打印可用文本。这可能是一个错误消息或其他有用于调试的信息。

演示和单元测试 RESTful 服务

进行 RESTful 服务器的突发演示相对较容易。我们可以导入服务器类和函数定义,并从终端窗口运行服务器函数。我们可以连接到http://localhost:8080来查看响应。

为了进行适当的单元测试,我们希望客户端和服务器之间进行更正式的交换。对于受控的单元测试,我们希望启动然后停止服务器进程。然后我们可以对服务器进行测试,并检查客户端的响应。

我们可以使用concurrent.futures模块创建一个单独的子进程来运行服务器。以下是一个代码片段,展示了可以成为单元测试用例一部分的处理方式:

    import concurrent.futures
    import time
    with concurrent.futures.ProcessPoolExecutor() as executor:
        executor.submit( roulette_server, 4 )
        time.sleep(2) # Wait for the server to start
        json_get()
        json_get()
        json_get("/european/")
        json_get("/european/")

我们通过创建concurrent.futures.ProcessPoolExecutor的实例来创建一个单独的进程。然后,我们可以提交一个函数到这个服务器,带有适当的参数值。

在这种情况下,我们执行了我们的json_get()客户端函数来读取默认路径/两次。然后我们在"/european/"路径上执行了两次GET操作。

executor.submit()函数使进程池评估roulette_server(4)函数。这将处理四个请求,然后终止。因为ProcessPoolExecutor是一个上下文管理器,我们可以确保所有资源都会被正确清理。单元测试的输出日志以以下方式分组:

wheel 'european'
127.0.0.1 - - [08/Dec/2013 09:32:08] "GET /european/ HTTP/1.1" 200 62
200 OK
[('Date', 'Sun, 08 Dec 2013 14:32:08 GMT'), ('Server', 'WSGIServer/0.2 CPython/3.3.3'), ('Content-type', 'application/json; charset=utf-8'), ('Content-Length', '62')]
{'20': [35, 1], 'Even': [1, 1], 'Black': [1, 1], 'Hi': [1, 1]}

wheel 'european'行是我们的wheel()WSGI 应用程序的日志输出。127.0.0.1 - - [08/Dec/2013 09:32:08] "GET /european/ HTTP/1.1" 200 62日志行是默认从 WSGI 服务器写入的,它告诉我们请求已经完全处理,没有错误。

客户端json_get()函数编写了接下来的三行。200 OK行是第一个print()函数。这些行是作为服务器响应的一部分发送的标头。最后,我们向您展示了从服务器发送到客户端的解码字典对象。在这种情况下,赢家是 20 黑。

另外,请注意,我们的原始元组在 JSON 编码和解码过程中被转换为列表。我们原始的字典是'20': (35, 1)。在编码和解码后的结果是'20': [35, 1]

请注意,正在测试的模块将由ProcessPool服务器导入。这个导入将找到命名函数roulette_server()。因为服务器将导入被测试的模块,被测试的模块必须正确使用__name__ == "__main__"保护,以确保在导入期间不会执行任何额外的处理;它只能提供定义。我们必须确保在定义服务器的脚本中使用这种构造:

if __name__ == "__main__":
    roulette_server()

使用 Callable 类来实现 WSGI 应用程序

我们可以将 WSGI 应用程序实现为Callable对象,而不是独立的函数。这允许我们在 WSGI 服务器中进行有状态的处理,而不会造成全局变量的混乱。在我们之前的例子中,get_spin()WSGI 应用程序依赖于两个全局变量,americaneuropean。应用程序和全局变量之间的绑定可能是神秘的。

定义类的目的是将处理和数据封装到一个单一的包中。我们可以使用Callable对象以更好的方式封装我们的应用程序。这可以使有状态的Wheel和 WSGI 应用程序之间的绑定更清晰。这是对Wheel类的扩展,使其成为一个可调用的 WSGI 应用程序:

from collections.abc import Callable
class Wheel2( Wheel, Callable ):
    def __call__(self, environ, start_response):
        winner= self.spin() # 3\. Evaluate.
        status = '200 OK' # 4\. Respond.
        headers = [('Content-type', 'application/json; charset=utf-8')]
        start_response(status, headers)
        return [ json.dumps(winner).encode('UTF-8') ]

我们扩展了基本的Wheel类,以包括 WSGI 接口。这不会对请求进行任何解析;WSGI 处理已经被简化为只有两个步骤:评估和响应。我们将在更高级别的包装应用程序中处理解析和日志记录。这个Wheel2应用程序只是选择一个结果并将其编码为结果。

请注意,我们已经为Wheel2类添加了一个独特的设计特性。这是一个不属于Wheelis-a定义的关注点的例子。这更像是一个acts-as特性。这可能应该被定义为一个 mixin 或装饰器,而不是类定义的一流特性。

这里有两个子类,实现了轮盘的美式和欧式变体:

class American2( Zero, DoubleZero, Wheel2 ):
    pass

class European2( Zero, Wheel2 ):
    pass

这两个子类依赖于超类中的__call__()方法函数。与前面的例子一样,我们使用 mixin 来向轮盘添加适当的零箱。

我们已经将轮子从一个简单的对象变成了一个 WSGI 应用程序。这意味着我们的高级包装应用程序可以更简单一些。高级应用程序不是评估其他对象,而是简单地将请求委托给对象。下面是一个修改后的包装应用程序,它选择要旋转的轮子并委托请求:

class Wheel3( Callable ):
    def __init__( self ):
        self.am = American2()
        self.eu = European2()
    def __call__(self, environ, start_response):
        request= wsgiref.util.shift_path_info(environ) # 1\. Parse
        print( "Wheel3", request, file=sys.stderr ) # 2\. Logging
        if request.lower().startswith('eu'): # 3\. Evaluate
            response= self.eu(environ,start_response)
        else:
            response= self.am(environ,start_response)
        return response # 4\. Respond

创建这个Wheel3类的实例时,它将创建两个轮子。每个轮子都是一个 WSGI 应用程序。

当处理请求时,Wheel3 WSGI 应用程序将解析请求。然后将这两个参数(environstart_response函数)传递给另一个应用程序来执行实际的评估并计算响应。在许多情况下,这种委托还包括从请求路径或标头解析的参数和参数更新environ变量。最后,这个Wheel3.__call__()函数将返回被调用的另一个应用程序的响应。

这种委托方式是 WSGI 应用程序的特点。这就是 WSGI 应用程序如此优雅地嵌套在一起的原因。请注意,包装应用程序有两个地方可以注入处理:

  • 在调用另一个应用程序之前,它将调整环境以添加信息。

  • 调用另一个应用程序后,它可以调整响应文档

通常,我们喜欢在包装应用程序中调整环境。然而,在这种情况下,没有真正需要使用额外信息更新环境,因为请求是如此微不足道。

设计 RESTful 对象标识符

对象序列化涉及为每个对象定义某种标识符。对于shelvesqlite,我们需要为每个对象定义一个字符串键。RESTful web 服务器也提出了相同的要求,以定义一个可用于明确跟踪对象的可行键。

一个简单的替代键也可以用于 RESTful web 服务标识符。它可以轻松地与shelvesqlite使用的键并行。

重要的是要明白“酷的 URI 不会改变”的概念。参见www.w3.org/Provider/Style/URI.html

对我们来说,定义一个永远不会改变的 URI 是很重要的。重要的是对象的有状态方面永远不要作为 URI 的一部分。例如,微博应用程序可能支持多个作者。如果我们按作者将博客帖子组织成文件夹,就会为共享作者身份创建问题,当一个作者接管另一个作者的内容时,就会产生更大的问题。我们不希望 URI 在纯粹的管理功能(如所有权)发生变化时切换。

RESTful 应用程序可能提供许多索引或搜索条件。然而,资源或对象的基本标识不应随索引的更改或重新组织而改变。

对于相对简单的对象,我们通常可以找到某种标识符,通常是数据库替代键。对于博客帖子,通常使用发布日期(因为它不会改变)和标题的版本,标点和空格用_字符替换。其目的是创建一个标识符,无论网站如何重新组织,都不会改变。添加或更改索引不会改变微博帖子的基本标识。

对于更复杂的容器对象,我们必须决定可以引用这些更复杂对象的粒度。继续微博示例,我们有整个博客,其中包含许多个别的帖子。

博客的 URI 可以是这样简单的:

/microblog/blog/bid/

最顶层的名称(微博)是整个应用程序。然后,我们有资源类型(博客),最后是特定实例的 ID。

然而,帖子的 URI 名称有几种选择:

/microblog/post/title_string/
/microblog/post/bid/title_string/
/microblog/blog/bid/post/title_string/

当不同的博客有相同标题的帖子时,第一个 URI 效果不佳。在这种情况下,作者可能会看到他们的标题被添加了额外的_2或其他装饰,以强制标题变得唯一。这通常是不可取的。

第二个 URI 使用博客 ID(bid)作为上下文或命名空间,以确保在博客的上下文中将Post标题视为唯一的。这种技术通常被扩展以包括额外的细分,比如日期,以进一步缩小搜索空间。

第三个示例在两个级别上使用了显式的类/对象命名:blog/bidpost/title_string。这样做的缺点是路径更长,但它的优点是允许一个复杂的容器在不同的内部集合中有多个项目。

请注意,REST 服务的效果是定义持久存储的 API。实际上,URI 类似于接口方法的名称。它们必须选择得清晰、有意义和耐用。

多层 REST 服务

这是一个更智能、多层次的 REST 服务器应用程序。我们将分段展示给你。首先,我们需要用一个 Roulette 桌子来补充我们的Wheel类:

from collections import defaultdict
class Table:
    def __init__( self, stake=100 ):
        self.bets= defaultdict(int)
        self.stake= stake
    def place_bet( self, name, amount ):
        self.bets[name] += amount
    def clear_bets( self, name ):
        self.bets= defaultdict(int)
    def resolve( self, spin ):
        """spin is a dict with bet:(x:y)."""
        details= []
        while self.bets:
            bet, amount= self.bets.popitem()
            if bet in spin:
                x, y = spin[bet]
                self.stake += amount*x/y
                details.append( (bet, amount, 'win') )
            else:
                self.stake -= amount
                details.append( (bet, amount, 'lose') )
        return details

Table类跟踪来自单个匿名玩家的赌注。每个赌注都是轮盘桌上一个空间的字符串名称和一个整数金额。在解决赌注时,Wheel类提供了一个单次旋转给resolve()方法。下注与旋转中的获胜赌注进行比较,并且随着赌注的赢得或失去,玩家的赌注会进行调整。

我们将定义一个 RESTful 的 Roulette 服务器,它展示了通过HTTP POST方法实现的有状态事务。我们将把 Roulette 游戏分成三个 URI:

  • /player/

  • 向这个 URI 发送GET请求将检索一个 JSON 编码的dict,其中包含有关玩家的信息,包括他们的赌注和迄今为止玩的轮数。未来的扩展将是定义一个适当的Player对象并返回一个序列化的实例。

  • 未来的扩展将处理POST以创建额外的下注玩家。

  • /bet/

  • 向这个 URI 发送POST请求将包括一个 JSON 编码的dict或一个创建赌注的字典列表。每个赌注字典将有两个键:betamount

  • GET将返回一个 JSON 编码的dict,显示迄今为止下注和金额。

  • /wheel/

  • 向这个 URI 发送没有数据的POST请求将旋转并计算支付。这是作为POST实现的,以加强它正在对可用的赌注和玩家进行有状态的更改的感觉。

  • GET可能会重复之前的结果,显示上次旋转,上次支付和玩家的赌注。这可能是非否认方案的一部分;它返回旋转收据的额外副本。

以下是我们 WSGI 应用程序系列的两个有用的类定义:

class WSGI( Callable ):
    def __call__( self, environ, start_response ):
        raise NotImplementedError

class RESTException( Exception ):
    pass

我们对Callable进行了简单的扩展,以明确表示我们将定义一个 WSGI 应用程序类。我们还定义了一个异常,我们可以在 WSGI 应用程序中使用它来发送与wsgiref实现提供的 Python 错误不同的错误状态代码。这是 Roulette 服务器的顶层:

class Roulette( WSGI ):
    def __init__( self, wheel ):
        self.table= Table(100)
        self.rounds= 0
        self.wheel= wheel
    def __call__( self, environ, start_response ):
        #print( environ, file=sys.stderr )
        app= wsgiref.util.shift_path_info(environ)
        try:
            if app.lower() == "player":
                return self.player_app( environ, start_response )
            elif app.lower() == "bet":
                return self.bet_app( environ, start_response )
            elif app.lower() == "wheel":
                return self.wheel_app( environ, start_response )
            else:
                raise RESTException("404 NOT_FOUND",
                    "Unknown app in {SCRIPT_NAME}/{PATH_INFO}".format_map(environ))
        except RESTException as e:
            status= e.args[0]
            headers = [('Content-type', 'text/plain; charset=utf-8')]
            start_response( status, headers, sys.exc_info() )
            return [ repr(e.args).encode("UTF-8") ]

我们定义了一个 WSGI 应用程序,它包装了其他应用程序。wsgiref.util.shift_path_info()函数将解析路径,在/上断开以获取第一个单词。基于此,我们将调用另外三个 WSGI 应用程序中的一个。在这种情况下,每个应用程序将是类定义内的一个方法函数。

我们提供了一个总体异常处理程序,它将把任何RESTException实例转换为适当的 RESTful 响应。我们没有捕获的异常将转换为wsgiref提供的通用状态码 500 错误。这是player_app方法函数:

    def player_app( self, environ, start_response ):
        if environ['REQUEST_METHOD'] == 'GET':
            details= dict( stake= self.table.stake, rounds= self.rounds )
            status = '200 OK'
            headers = [('Content-type', 'application/json; charset=utf-8')]
            start_response(status, headers)
            return [ json.dumps( details ).encode('UTF-8') ]
        else:
            raise RESTException("405 METHOD_NOT_ALLOWED",
                "Method '{REQUEST_METHOD}' not allowed".format_map(environ))

我们创建了一个响应对象details。然后我们将这个对象序列化为一个 JSON 字符串,并进一步使用 UTF-8 编码该字符串为字节。

在极少数情况下,尝试对/player/路径进行 Post(或 Put 或 Delete)将引发异常。这将在顶层__call__()方法中捕获,并转换为错误响应。

这是bet_app()函数:

    def bet_app( self, environ, start_response ):
        if environ['REQUEST_METHOD'] == 'GET':
            details = dict( self.table.bets )
        elif environ['REQUEST_METHOD'] == 'POST':
            size= int(environ['CONTENT_LENGTH'])
            raw= environ['wsgi.input'].read(size).decode("UTF-8")
            try:
                data = json.loads( raw )
                if isinstance(data,dict): data= [data]
                for detail in data:
                    self.table.place_bet( detail['bet'], int(detail['amount']) )
            except Exception as e:
                raise RESTException("403 FORBIDDEN",
                 Bet {raw!r}".format(raw=raw))
            details = dict( self.table.bets )
        else:
            raise RESTException("405 METHOD_NOT_ALLOWED",
                "Method '{REQUEST_METHOD}' not allowed".format_map(environ))
        status = '200 OK'
        headers = [('Content-type', 'application/json; charset=utf-8')]
        start_response(status, headers)
        return [ json.dumps(details).encode('UTF-8') ]

这做了两件事,取决于请求方法。当使用GET请求时,结果是当前下注的字典。当使用POST请求时,必须有一些数据来定义下注。当尝试任何其他方法时,将返回错误。

POST情况下,下注信息作为附加到请求的数据流提供。我们必须执行几个步骤来读取和处理这些数据。第一步是使用environ['CONTENT_LENGTH']的值来确定要读取多少字节。第二步是解码字节以获得发送的字符串值。

我们使用了请求的 JSON 编码。这绝对不是浏览器或 Web 应用程序服务器处理来自 HTML 表单的POST数据的方式。当使用浏览器从 HTML 表单发布数据时,编码是urllib.parse模块实现的一组简单的转义。urllib.parse.parse_qs()模块函数将解析带有 HTML 数据的编码查询字符串。

对于 RESTful Web 服务,有时会使用POST兼容数据,以便基于表单的处理与 RESTful 处理非常相似。在其他情况下,会使用单独的编码,如 JSON,以创建比 Web 表单产生的引号数据更容易处理的数据结构。

一旦我们有了字符串raw,我们使用json.loads()来获取该字符串表示的对象。我们期望两类对象中的一个。一个简单的dict对象将定义一个单独的下注。一系列dict对象将定义多个下注。作为一个简单的概括,我们将单个dict转换为单例序列。然后,我们可以使用一般的dict实例序列来放置所需的下注。

请注意,我们的异常处理将保留一些下注,但会发送一个总体的403 Forbidden消息。更好的设计是遵循Memento设计模式。下注时,我们还会创建一个可以撤销任何下注的备忘录对象。备忘录的一个实现是使用Before Image设计模式。备忘录可以包括在应用更改之前的所有下注的副本。在发生异常时,我们可以删除损坏的版本并恢复以前的版本。当处理可变对象的嵌套容器时,这可能会很复杂,因为我们必须确保复制任何可变对象。由于此应用程序仅使用不可变的字符串和整数,因此table.bets的浅复制将非常有效。

对于POSTGET方法,响应是相同的。我们将table.bets字典序列化为 JSON 并发送回 REST 客户端。这将确认已下注的预期下注。

这节课的最后一部分是wheel_app()方法:

    def wheel_app( self, environ, start_response ):
        if environ['REQUEST_METHOD'] == 'POST':
            size= environ['CONTENT_LENGTH']
            if size != '':
                raw= environ['wsgi.input'].read(int(size))
                raise RESTException("403 FORBIDDEN",
                    "Data '{raw!r}' not allowed".format(raw=raw))
            spin= self.wheel.spin()
            payout = self.table.resolve( spin )
            self.rounds += 1
            details = dict( spin=spin, payout=payout,
                stake= self.table.stake, rounds= self.rounds )
            status = '200 OK'
            headers = [('Content-type', 'application/json; charset=utf-8')]
            start_response(status, headers)
            return [ json.dumps( details ).encode('UTF-8') ]
        else:
            raise RESTException("405 METHOD_NOT_ALLOWED",
                "Method '{REQUEST_METHOD}' not allowed".format_map(environ))

该方法首先检查它是否被调用以提供没有数据的post。为了确保套接字被正确关闭,所有数据都被读取并忽略。这可以防止一个编写不良的客户端在套接字关闭时崩溃。

一旦这些琐事处理完毕,剩下的处理就是执行新的轮盘旋转,解决各种下注,并生成包括旋转、支付、玩家赌注和回合数的响应。这份报告被构建为一个dict对象。然后将其序列化为 JSON,编码为 UTF-8,并发送回客户端。

请注意,我们已经避免处理多个玩家。这将添加一个类和另一个/player/路径下的POST方法。这将增加一些定义和簿记。创建新玩家的POST处理将类似于下注处理。这是一个有趣的练习,但它并没有引入任何新的编程技术。

创建轮盘服务器

一旦我们有了可调用的Roulette类,我们可以按照以下方式创建一个 WSGI 服务器:

def roulette_server_3(count=1):
    from wsgiref.simple_server import make_server
    from wsgiref.validate import validator
    wheel= American()
    roulette= Roulette(wheel)
    debug= validator(roulette)
    httpd = make_server('', 8080, debug)
    if count is None:
        httpd.serve_forever()
    else:
        for c in range(count):
            httpd.handle_request()

此函数创建我们的 Roulette WSGI 应用程序roulette。它使用wsgiref.simple_server.make_server()创建一个服务器,该服务器将对每个请求使用roulette可调用。

在这种情况下,我们还包括了wsgiref.validate.validator() WSGI 应用程序。该应用程序验证了轮盘应用程序使用的接口;它使用 assert 语句装饰各种 API 以提供一些诊断信息。它还在 WSGI 应用程序出现更严重的编程问题时生成稍微更易读的错误消息。

创建轮盘客户端

定义一个具有 RESTful 客户端 API 的模块是常见做法。通常,客户端 API 将具有专门针对所请求服务的函数。

我们将定义一个通用的客户端函数,而不是定义一个专门的客户端,该函数将与各种 RESTful 服务器一起工作。这可能成为一个特定于 Roulette 的客户端的基础。以下是一个通用的客户端函数,它将与我们的Roulette服务器一起工作:

def roulette_client(method="GET", path="/", data=None):
    rest= http.client.HTTPConnection('localhost', 8080)
    if data:
        header= {"Content-type": "application/json; charset=utf-8'"}
        params= json.dumps( data ).encode('UTF-8')
        rest.request(method, path, params, header)
    else:
        rest.request(method, path)
    response= rest.getresponse()
    raw= response.read().decode("utf-8")
    if 200 <= response.status < 300:
        document= json.loads(raw)
        return document
    else:
        print( response.status, response.reason )
        print( response.getheaders() )
        print( raw )

此客户端进行GETPOST请求,并将POST请求的数据编码为 JSON 文档。请注意,请求数据的 JSON 编码绝对不是浏览器处理 HTML 表单的POST数据的方式。浏览器使用urllib.parse.urlencode()模块函数实现的编码。

我们的客户端函数在半开范围内解码 JSON 文档并返回它,这些是成功的状态代码。我们可以按以下方式操作我们的客户端和服务器:

    with concurrent.futures.ProcessPoolExecutor() as executor:
        executor.submit( roulette_server_3, 4 )
        time.sleep(3) # Wait for the server to start
        print( roulette_client("GET", "/player/" ) )
        print( roulette_client("POST", "/bet/", {'bet':'Black', 'amount':2}) )
        print( roulette_client("GET", "/bet/" ) )
        print( roulette_client("POST", "/wheel/" ) )

首先,我们创建ProcessPool作为练习的上下文。我们向该服务器提交一个请求;实际上,请求是roulette_server_3(4)。一旦服务器启动,我们就可以操作该服务器。

在这种情况下,我们进行了四次请求。我们检查玩家的状态。我们下注然后检查下注。最后,我们转动轮盘。在每个步骤中,我们打印 JSON 响应文档。

日志如下:

127.0.0.1 - - [09/Dec/2013 08:21:34] "GET /player/ HTTP/1.1" 200 27
{'stake': 100, 'rounds': 0}
127.0.0.1 - - [09/Dec/2013 08:21:34] "POST /bet/ HTTP/1.1" 200 12
{'Black': 2}
127.0.0.1 - - [09/Dec/2013 08:21:34] "GET /bet/ HTTP/1.1" 200 12
{'Black': 2}
127.0.0.1 - - [09/Dec/2013 08:21:34] "POST /wheel/ HTTP/1.1" 200 129
{'stake': 98, 'payout': [['Black', 2, 'lose']], 'rounds': 1, 'spin': {'27': [35, 1], 'Odd': [1, 1], 'Red': [1, 1], 'Hi': [1, 1]}}

这向我们展示了我们的服务器如何响应请求,如何在桌子上下注,如何随机旋转轮盘,并如何正确地更新玩家的结果。

创建安全的 REST 服务

我们可以将应用程序安全性分解为两个考虑因素:身份验证和授权。我们需要知道用户是谁,并且我们需要确保用户被授权执行特定的 WSGI 应用程序。这是相对简单地使用 HTTP Authorization头来处理,以确保这些凭据的加密传输。

如果我们使用 SSL,我们可以简单地使用 HTTP 基本授权模式。Authorization头的这个版本可以在每个请求中包含用户名和密码。对于更复杂的措施,我们可以使用 HTTP 摘要授权,它需要与服务器交换以获取一个称为nonce的数据片段,用于以更安全的方式创建摘要。

通常,我们会尽早在流程中处理身份验证。这意味着一个前端 WSGI 应用程序会检查Authorization头并更新环境或返回错误。理想情况下,我们将使用一个提供此功能的复杂 Web 框架。有关这些 Web 框架考虑的更多信息,请参见下一节。

关于安全性的最重要的建议可能是以下内容:

注意

永远不要存储密码

唯一可以存储的是密码加盐的重复加密哈希。密码本身必须是不可恢复的;完全研究加盐密码哈希或下载一个可信的库。永远不要存储明文密码或加密密码。

这是一个示例类,向我们展示了加盐密码哈希的工作原理:

from hashlib import sha256
import os
class Authentication:
    iterations= 1000
    def __init__( self, username, password ):
        """Works with bytes. Not Unicode strings."""
        self.username= username
        self.salt= os.urandom(24)
        self.hash= self._iter_hash( self.iterations, self.salt, username, password )
    @staticmethod
    def _iter_hash( iterations, salt, username, password ):
        seed= salt+b":"+username+b":"+password
        for i in range(iterations):
            seed= sha256( seed ).digest()
        return seed
    def __eq__( self, other ):
        return self.username == other.username and self.hash == other.hash
    def __hash__( self, other ):
        return hash(self.hash)
    def __repr__( self ):
        salt_x= "".join( "{0:x}".format(b) for b in self.salt )
        hash_x= "".join( "{0:x}".format(b) for b in self.hash )
        return "{username} {iterations:d}:{salt}:{hash}".format(
            username=self.username, iterations=self.iterations,
            salt=salt_x, hash=hash_x)
    def match( self, password ):
        test= self._iter_hash( self.iterations, self.salt, self.username, password )
        return self.hash == test # **Constant Time is Best

这个类为给定的用户名定义了一个Authentication对象。该对象包含用户名、每次设置或重置密码时创建的唯一随机盐,以及盐加上密码的最终哈希。这个类还定义了一个match()方法,确定给定的密码是否会产生与原始密码相同的哈希。

请注意,密码没有被存储。只有密码的哈希值被保留。我们在比较函数上提供了一个注释(“# Constant Time is Best”)。一个在恒定时间内运行的算法——并且不是特别快——对于这种比较是理想的。我们还没有实现它。

我们还包括了一个相等测试和一个哈希测试,以强调这个对象是不可变的。我们不能调整任何值。当用户更改密码时,我们只能丢弃并重建整个Authentication对象。另一个设计特性是使用__slots__来保存存储空间。

请注意,这些算法使用的是字节字符串,而不是 Unicode 字符串。我们要么使用字节,要么使用 Unicode 用户名或密码的 ASCII 编码。下面是我们可能创建一个用户集合的方法:

class Users( dict ):
    def __init__( self, *args, **kw ):
        super().__init__( *args, **kw )
        # Can never match -- keys are the same.
        self[""]= Authentication( b"__dummy__", b"Doesn't Matter" )
    def add( self, authentication ):
        if authentication.username == "":
            raise KeyError( "Invalid Authentication" )
        self[authentication.username]= authentication
    def match( self, username, password ):
        if username in self and username != "":
            return self[username].match(password)
        else:
            return self[""].match(b"Something which doesn't match")

我们创建了一个dict的扩展,引入了一个add()方法来保存一个Authentication实例和一个匹配方法,确定用户是否在这个字典中,以及他们的凭证是否匹配。

请注意,我们的匹配需要是一个恒定时间的比较。我们为一个未知的用户名提供了一个额外的虚拟用户。通过对虚拟用户进行匹配,执行时间不会提供太多关于凭证错误的提示。如果我们简单地返回False,那么不匹配的用户名会比不匹配的密码响应更快。

我们明确禁止设置用户名为""的身份验证,或匹配用户名为""。这将确保虚拟用户名永远不会被更改为可能匹配的有效条目,任何尝试匹配它都将失败。下面是我们构建的一个示例用户:

users = Users()
users.add( Authentication(b"Aladdin", b"open sesame") )

只是为了看看这个类里面发生了什么,我们可以手动创建一个用户:

>>> al= Authentication(b"Aladdin", b"open sesame")
>>> al
b'Aladdin' 1000:16f56285edd9326282da8c6aff8d602a682bbf83619c7f:9b86a2ad1ae0345029ae11de402ba661ade577df876d89b8a3e182d887a9f7

盐是一个由 24 个字节组成的字符串,在用户的密码被创建或更改时被重置。哈希是用户名、密码和盐的重复哈希。

WSGI 身份验证应用程序

一旦我们有了存储用户和凭证的方法,我们就可以检查请求中的Authentication头部。下面是一个检查头部并更新验证用户环境的 WSGI 应用程序:

import base64
class Authenticate( WSGI ):
    def __init__( self, users, target_app ):
        self.users= users
        self.target_app= target_app
    def __call__( self, environ, start_response ):
        if 'HTTP_AUTHORIZATION' in environ:
            scheme, credentials = environ['HTTP_AUTHORIZATION'].split()
            if scheme == "Basic":
                username, password= base64.b64decode( credentials ).split(b":")
                if self.users.match(username, password):
                    environ['Authenticate.username']= username
                    return self.target_app(environ, start_response)
        status = '401 UNAUTHORIZED'
        headers = [('Content-type', 'text/plain; charset=utf-8'),
            ('WWW-Authenticate', 'Basic realm="roulette@localhost"')]
        start_response(status, headers)
        return [ "Not authorized".encode('utf-8') ]

这个 WSGI 应用程序包含一个用户池,还有一个目标应用程序。当我们创建这个Authenticate类的实例时,我们将提供另一个 WSGI 应用程序作为target_app;这个包装应用程序只会看到经过身份验证的用户的请求。当调用Authenticate应用程序时,它会执行几个测试,以确保请求来自经过身份验证的用户:

  • 必须有一个 HTTPAuthorization头。这个头部保存在environ字典中的HTTP_AUTHORIZATION键中

  • 头部必须使用Basic作为认证方案

  • 基本方案中的凭证必须是username+b":"+password的 base 64 编码;这必须与定义的用户的凭证匹配

如果所有这些测试都通过了,我们可以使用经过身份验证的用户名更新environ字典。然后,目标应用程序可以被调用。

然后,包装应用程序可以处理授权细节,知道用户已经通过身份验证。这种关注点的分离是 WSGI 应用程序的一个优雅特性。我们把身份验证放在了一个地方。

使用 Web 应用程序框架实现 REST

由于 REST web 服务器是一个 Web 应用程序,我们可以利用任何流行的 Python Web 应用程序框架。从头开始编写 RESTful 服务器是在证明框架提供的问题不可接受之后可以采取的一步。在许多情况下,使用框架进行技术性的尝试可以帮助澄清任何问题,并允许与不使用框架编写的 REST 应用程序进行详细比较。

一些 Python Web 框架包括一个或多个 REST 组件。在某些情况下,RESTful 功能几乎完全内置。在其他情况下,附加项目可以帮助以最少的编程定义 RESTful Web 服务。

这是 Python Web 框架的列表:wiki.python.org/moin/WebFrameworks。这些项目的目的是提供一个相对完整的环境来构建 Web 应用程序。

这是 Python Web 组件软件包的列表:wiki.python.org/moin/WebComponents。这些都是可以用来支持 Web 应用程序开发的部分和片段。

在 PyPI,pypi.python.org,搜索 REST 将会找到大量的软件包。显然,已经有许多可用的解决方案。

花时间搜索、下载和学习一些现有的框架可以减少一些开发工作。特别是安全性方面是具有挑战性的。自制的安全算法通常存在严重的缺陷。使用他人验证过的安全工具可能有一些优势。

使用消息队列传输对象

multiprocessing模块也使用对象的序列化和传输。我们可以使用队列和管道对对象进行序列化,然后将其传输到其他进程。有许多外部项目可以提供复杂的消息队列处理。我们将专注于multiprocessing队列,因为它内置于 Python 并且运行良好。

对于高性能应用程序,可能需要更快的消息队列。可能还需要使用比 pickling 更快的序列化技术。在本章中,我们只关注 Python 设计问题。multiprocessing模块依赖于pickle来编码对象。有关更多信息,请参见第九章,“序列化和保存 - JSON、YAML、Pickle、CSV 和 XML”。我们无法轻松地提供受限制的 unpickler;因此,该模块为我们提供了一些相对简单的安全措施,以防止 unpickle 问题。

在使用multiprocessing时有一个重要的设计考虑:通常最好避免多个进程(或多个线程)尝试更新共享对象。同步和锁定问题是如此深刻(并且容易出错),以至于标准笑话是,

当程序员面对问题时,他会想:“我会使用多个线程。”

通过 RESTful Web 服务或multiprocessing使用进程级同步可以防止同步问题,因为没有共享对象。基本的设计原则是将处理视为离散步骤的管道。每个处理步骤都将有一个输入队列和一个输出队列;该步骤将获取一个对象,执行一些处理,并写入该对象。

multiprocessing的哲学与将 POSIX 概念写成process1 | process2 | process3的 shell 管道相匹配。这种 shell 管道涉及三个相互连接的并发进程。重要的区别在于,我们不需要使用 STDIN、STDOUT 和对象的显式序列化。我们可以相信multiprocessing模块来处理操作系统级的基础设施。

POSIX shell 管道有限,每个管道只有一个生产者和一个消费者。Python 的multiprocessing模块允许我们创建包括多个消费者的消息队列。这使我们能够创建一个从一个源进程到多个目标进程的扇出流水线。一个队列也可以有多个消费者,这使我们能够构建一个流水线,其中多个源进程的结果可以由单个目标进程组合。

为了最大化计算机系统的吞吐量,我们需要有足够的待处理工作,以便没有处理器或核心会闲置。当任何给定的操作系统进程正在等待资源时,至少应该有另一个进程准备好运行。

例如,当我们观察我们的赌场游戏模拟时,我们需要通过多次执行玩家策略或投注策略(或两者)来收集具有统计学意义的模拟数据。我们的目标是创建一个处理请求队列,以便我们计算机的处理器(和核心)完全参与处理我们的模拟。

每个处理请求可以是一个 Python 对象。multiprocessing模块将对该对象进行 pickle 处理,以便通过队列传输到另一个进程。

我们将在第十四章中重新讨论这个问题,当我们看看logging模块如何使用multiprocessing队列为单独的生产者进程提供一个集中的日志时。在这些示例中,从一个进程传输到另一个进程的对象将是logging.LogRecord实例。

定义进程

我们必须将每个处理步骤设计为一个简单的循环,从队列中获取请求,处理该请求,并将结果放入另一个队列。这将大问题分解为多个形成流水线的阶段。由于每个阶段都将同时运行,系统资源使用将被最大化。此外,由于这些阶段涉及简单的从独立队列获取和放置,所以没有复杂的锁定或共享资源问题。一个进程可以是一个简单的函数或可调用对象。我们将专注于将进程定义为multiprocessing.Process的子类。这给了我们最大的灵活性。

对于我们赌场游戏的模拟,我们可以将模拟分解为三个步骤的流水线:

  1. 一个总体驱动程序将模拟请求放入处理队列。

  2. 一组模拟器将从处理队列获取请求,执行模拟,并将统计数据放入结果队列。

  3. 汇总器将从结果队列获取结果,并创建最终的结果汇总。

使用进程池允许我们同时运行尽可能多的模拟,以便我们的 CPU 可以处理。模拟器池可以配置,以确保模拟尽快运行。

以下是模拟器进程的定义:

import multiprocessing
class Simulation( multiprocessing.Process ):
    def __init__( self, setup_queue, result_queue ):
        self.setup_queue= setup_queue
        self.result_queue= result_queue
        super().__init__()
    def run( self ):
        """Waits for a termination"""
        print( self.__class__.__name__, "start" )
        item= self.setup_queue.get()
        while item != (None,None):
            table, player = item
            self.sim= Simulate( table, player, samples=1 )
            results= list( self.sim )
            self.result_queue.put( (table, player, results[0]) )
            item= self.setup_queue.get()
        print( self.__class__.__name__, "finish" )

我们已经扩展了multiprocessing.Process。这意味着我们必须做两件事才能正确地使用多进程:我们必须确保执行super().__init__(),并且我们必须重写run()

run()的主体内,我们使用了两个队列。setup_queue队列实例将包含TablePlayer对象的两元组。进程将使用这两个对象来运行模拟。它将把结果放入result_queue队列实例中。Simulate类的 API 如下:

class Simulate:
    def __init__( self, table, player, samples ):
    def __iter__( self ): yields summaries

迭代器将产生请求的数量samples的统计摘要。我们已经包括了一个sentinel 对象通过setup_queue到达。这个对象将被用来优雅地关闭处理。如果我们不使用一个 sentinel 对象,我们将被迫终止进程,这可能会破坏锁定和其他系统资源。以下是摘要过程:

class Summarize( multiprocessing.Process ):
    def __init__( self, queue ):
        self.queue= queue
        super().__init__()
    def run( self ):
        """Waits for a termination"""
        print( self.__class__.__name__, "start" )
        count= 0
        item= self.queue.get()
        while item != (None, None, None):
            print( item )
            count += 1
            item= self.queue.get()
        print( self.__class__.__name__, "finish", count )

这也扩展了multiprocessing.Process。在这种情况下,我们从队列中获取项目并简单地对其进行计数。一个更有用的进程可能会使用多个collection.Counter对象来累积更有趣的统计数据。

Simulation类一样,我们还将检测到一个标记并优雅地关闭处理。使用标记对象可以让我们在进程完成工作后立即关闭处理。在一些应用中,子进程可以无限期地运行。

构建队列和提供数据

构建队列涉及创建multiprocessing.Queue的实例或其子类的实例。对于这个例子,我们可以使用以下内容:

setup_q= multiprocessing.SimpleQueue()
results_q= multiprocessing.SimpleQueue()

我们创建了两个定义处理流水线的队列。当我们将模拟请求放入setup_q时,我们期望Simulation进程会接收请求对并运行模拟。这应该在results_q队列中生成一个包含表、玩家和结果的三元组。这个结果三元组应该进一步导致Summarize进程进行工作。以下是如何启动单个Summarize进程的方法:

result= Summarize( results_q )
result.start()

以下是如何创建四个并发模拟进程的方法:

    simulators= []
    for i in range(4):
        sim= Simulation( setup_q, results_q )
        sim.start()
        simulators.append( sim )

四个并发模拟器将竞争工作。每个模拟器都将尝试从待处理请求的队列中获取下一个请求。一旦所有四个模拟器都忙于工作,队列将开始填充未处理的请求。一旦队列和进程都在等待,驱动函数就可以开始将请求放入setup_q队列。以下是一个将生成大量请求的循环:

table= Table( decks= 6, limit= 50, dealer=Hit17(),
    split= ReSplit(), payout=(3,2) )
for bet in Flat, Martingale, OneThreeTwoSix:
    player= Player( SomeStrategy, bet(), 100, 25 )
    for sample in range(5):
        setup_q.put( (table, player) )

我们创建了一个Table对象。对于三种投注策略,我们创建了一个Player对象,然后排队一个模拟请求。Simulation对象将从队列中获取 pickled 的两元组,然后对其进行处理。为了有序终止,我们需要为每个模拟器排队标记对象:

    for sim in simulators:
        setup_q.put( (None,None) )

    for sim in simulators:
        sim.join()

对于每个模拟器,我们将一个标记对象放入队列中以供消耗。一旦所有模拟器都消耗了标记对象,我们就可以等待进程完成执行并重新加入到父进程中。

一旦Process.join()操作完成,将不会再创建模拟数据。我们也可以将一个标记对象放入模拟结果队列中:

results_q.put( (None,None,None) )
result.join()

一旦结果标记对象被处理,Summarize进程将停止接受输入,我们也可以join()它。

我们使用多进程将对象从一个进程传输到另一个进程。这为我们提供了一个相对简单的方法来创建高性能的多处理数据流水线。multiprocessing模块使用pickle,因此对可以通过流水线推送的对象的性质几乎没有限制。

总结

我们研究了使用 RESTful web 服务和wsgiref模块以及multiprocessing模块来传输和共享对象,这两种架构都提供了通信对象状态表示的方式。在multiprocessing的情况下,使用 pickle 来表示状态。在构建 RESTful web 服务的情况下,我们必须选择要使用的表示形式。在这里使用的示例中,我们专注于 JSON,因为它被广泛使用并且具有简单的实现。许多框架也会提供 XML 的简单实现。

使用 WSGI 应用程序框架执行 RESTful web 服务规范化了接收 HTTP 请求、反序列化任何对象、执行请求的处理、序列化任何结果和提供响应的过程。由于 WSGI 应用程序具有简单、标准化的 API,我们可以轻松地创建复合应用程序和编写包装应用程序。我们通常可以利用包装应用程序以简单、一致的方式处理安全性的身份验证元素。

我们还研究了使用multiprocessing来对共享队列中的消息进行入队和出队操作。使用消息队列的美妙之处在于我们可以避免与共享对象的并发更新相关的锁定问题。

设计考虑和权衡

我们还必须决定要提供什么级别的对象以及如何使用明智的 URI 标识这些对象。对于较大的对象,我们可以轻松实现 ACID 属性。然而,我们可能也会上传和下载过多的数据以满足我们应用程序的用例。在某些情况下,我们需要提供替代级别的访问:大对象以支持 ACID 属性,小对象以在客户端应用程序需要数据子集时快速响应。

为了实现更加本地化的处理,我们可以利用multiprocessing模块。这更侧重于在受信任的主机或主机网络中构建高性能处理管道。

在某些情况下,这两种设计模式结合在一起,以便一个 RESTful 请求由多进程管道处理。传统的 Web 服务器(如 Apache HTTPD)通过mod_wsgi扩展可以使用多进程技术,通过命名管道将请求从 Apache 前端传递到 WSGI 应用程序后端。

模式演变

在处理面向公众的 RESTful 服务的 API 时,我们必须解决模式演变问题。如果我们更改类定义,我们将如何更改响应消息?如果外部 RESTful API 必须更改以与其他程序兼容,我们如何升级 Python Web 服务以支持不断变化的 API?

通常,我们必须在我们的 API 中提供一个主要的发布版本号。这可能是作为路径的一部分明确提供,或者隐含地通过包括在POSTPUTDELETE请求中的数据字段提供。

我们需要区分不会改变 URI 路径或响应的更改和将改变 URI 或响应的更改。对功能的较小更改不会改变 URI 或响应的结构。

对 URI 或响应结构的更改可能会破坏现有的应用程序。这些是重大变化。使应用程序通过模式升级优雅地工作的一种方法是在 URI 路径中包含版本号。例如,/roulette_2/wheel/明确指定了轮盘服务器的第二个版本。

应用软件层

由于使用sqlite3时相对复杂,我们的应用软件必须更加合理地分层。对于 REST 客户端,我们可能会考虑具有层的软件架构。

当我们构建一个 RESTful 服务器时,表示层变得大大简化。它被简化为基本的请求-响应处理。它解析 URI 并以 JSON 或 XML(或其他表示形式)的文档进行响应。这一层应该被简化为对较低级别功能的薄 RESTful 外观。

在一些复杂情况下,人类用户所看到的最前端应用涉及来自几个不同来源的数据。整合来自不同来源的数据的一种简单方法是将每个来源包装在 RESTful API 中。这为我们提供了对数据不同来源的统一接口。它允许我们编写应用程序以统一的方式收集这些不同类型的数据。

展望未来

在下一章中,我们将使用持久化技术来处理配置文件。可由人类编辑的文件是配置数据的主要要求。如果我们使用一个知名的持久化模块,那么我们的应用程序可以在较少的编程下解析和验证配置数据。

第十三章:配置文件和持久性

配置文件是一种对象持久化的形式。它包含了应用程序或服务器的一些默认状态的序列化、可编辑表示。我们将扩展我们在第九章中展示的对象表示的序列化技术,序列化和保存 - JSON、YAML、Pickle、CSV 和 XML 来创建配置文件。

除了拥有一个纯文本可编辑的配置文件,我们还必须设计我们的应用程序是可配置的。此外,我们必须定义一种应用程序可以使用的配置对象(或集合)。在许多情况下,我们将有一系列包括系统范围默认值和用户特定覆盖的默认值。我们将研究配置数据的六种表示:

  • INI 文件使用的格式是 Windows 的一部分。它之所以受欢迎,部分原因是它是一种现有的格式,许多其他配置文件可能使用这种表示法。

  • PY 文件是普通的 Python 代码。这有很多优势,因为人们熟悉并且简单地使用它。

  • JSON 或 YAML 都设计成人性化和易于编辑。

  • 属性文件经常在 Java 环境中使用。它们相对容易使用,也设计成人性化。

  • XML 文件很受欢迎,但有时很啰嗦,有时很难正确编辑。Mac OS 使用一种基于 XML 的格式,称为属性列表或.plist文件。

每种形式都为我们提供了一些优势和一些劣势。没有一种技术是最好的。在许多情况下,选择是基于与其他软件的兼容性或用户社区中对其他格式的熟悉程度。

配置文件的用例

有两种配置文件的用例。有时,我们可以稍微扩展定义,添加第三种用例。前两种应该是相当清楚的:

  • 一个人需要编辑一个配置文件

  • 软件将读取配置文件并利用选项和参数来调整其行为

配置文件很少是应用程序的主要输入。一个大的例外是模拟,其中配置可能是主要输入。在大多数其他情况下,配置不是主要输入。例如,Web 服务器的配置文件可能调整服务器的行为,但 Web 请求是一个主要输入,数据库或文件系统是另一个主要输入。在 GUI 应用程序的情况下,用户的交互事件是一个输入,文件或数据库可能是另一个输入;配置文件可以微调应用程序。

在主要输入和配置输入之间存在模糊的边界。理想情况下,一个应用程序的行为应该与配置细节无关。然而,从实用的角度来看,配置可能会引入额外的策略或状态到现有的应用程序中,从而改变其行为。在这种情况下,配置可以跨越界限,成为代码的一部分,而不仅仅是固定代码库的配置。

可能的第三种用例是在应用程序更新后将配置保存回文件。这种使用持久状态对象的方式是不典型的,因为配置文件已经变成了主要输入,程序正在保存其操作状态。这种用例可能表明两件事已经融合成一个文件:配置参数和持久操作状态。最好将其设计为使用人类可读格式的持久状态。

配置文件可以为应用程序提供多种参数和参数值。我们需要更深入地研究一些这些不同类型的数据,以决定如何最好地表示它们。

  • 默认值

  • 设备名称,可能与文件系统的位置重叠

  • 文件系统位置和搜索路径

  • 限制和边界

  • 消息模板和数据格式规范

  • 消息文本,可能已经翻译成国际化

  • 网络名称、地址和端口号

  • 可选行为

  • 安全密钥、令牌、用户名、密码

  • 值域:

这些值是相对常见类型的值:字符串、整数和浮点数。所有这些值都有一个整洁的文本表示,对于人来说相对容易编辑。它们对我们的 Python 应用程序来说也很容易解析人类输入。

在某些情况下,我们可能会有值的列表。例如,值域或路径可能是更简单类型的集合。通常,这是一个简单的序列或元组序列。类似字典的映射通常用于消息文本,以便将应用程序的软件密钥映射到定制的自然语言措辞。

还有一个不是简单类型的额外配置值,它没有整洁的文本表示。我们可以将这个项目添加到前面的列表中:

  • 代码的附加功能、插件和扩展:

这是具有挑战性的,因为我们不一定向应用程序提供一个简单的字符串值。配置提供了一个应用程序将使用的对象。当插件有更多的 Python 代码时,我们可以提供已安装的 Python 模块的路径,就像在import语句中使用这个点名一样:'package.module.object'。然后应用程序可以执行预期的'from package.module import object'代码并使用给定的类或函数。

对于非 Python 代码,我们有另外两种技术来导入代码,以便可以使用它:

  • 对于不是适当的可执行程序的二进制文件,我们可以尝试使用ctypes模块调用定义的 API 方法

  • 对于可执行程序的二进制文件,subprocess模块为我们提供了执行它们的方法

这两种技术都不是特定于 Python 的,并且推动了本章的边界。我们将专注于获取参数或参数值的核心问题。这些值的使用是一个非常大的话题。

表示、持久性、状态和可用性

查看配置文件时,我们正在查看一个或多个对象状态的人性化版本。当我们编辑配置文件时,我们正在更改对象的持久状态,当应用程序启动(或重新启动)时将重新加载。我们有两种常见的查看配置文件的方式:

  • 从参数名称到值的映射或一组映射

  • 一个序列化的对象,不仅仅是一个简单的映射

当我们试图将配置文件简化为映射时,我们可能会限制配置中可能存在的关系范围。在简单映射中,一切都必须通过名称引用,并且我们必须解决与第十章中讨论的shelve和第十一章中讨论的sqlite的键设计问题相同的键设计问题。我们在配置的一部分提供一个唯一的名称,以便其他部分可以正确引用它。

查看logging配置的示例有助于理解如何配置复杂系统可能非常具有挑战性。Python 日志对象之间的关系——记录器、格式化程序、过滤器和处理程序——必须全部绑定在一起才能创建可用的记录器。标准库参考的第 16.8 节向我们展示了日志配置文件的两种不同语法。我们将在第十四章中查看日志,日志和警告模块

在某些情况下,将复杂的 Python 对象序列化或者使用 Python 代码直接作为配置文件可能更简单。如果配置文件增加了太多的复杂性,那么它可能并没有真正的价值。

应用程序配置设计模式

应用程序配置有两种核心设计模式:

  • 全局属性映射:一个全局对象将包含所有的配置参数。这可以是一个 name:value 对的映射,也可以是一个属性值的大型命名空间对象。这可能遵循单例设计模式,以确保只有一个实例存在。

  • 对象构造:我们将定义一种工厂工厂集合,使用配置数据来构建应用程序的对象。在这种情况下,配置信息在程序启动时使用一次,以后再也不使用。配置信息不会作为全局对象保留。

全局属性映射设计非常受欢迎,因为它简单且可扩展。我们可能会有一个如下代码简单的对象:

class Configuration:
    some_attribute= "default_value"

我们可以使用前面的类定义作为属性的全局容器。在初始化过程中,我们可能会在解析配置文件的一部分中有类似以下的内容:

Configuration.some_attribute= "user-supplied value"

在程序的其他地方,我们可以使用 Configuration.some_attribute 的值。这个主题的一个变体是制作一个更正式的单例对象设计模式。这通常是通过全局模块来完成的,因为这样可以很容易地导入,从而为我们提供一个可访问的全局定义。

我们可能有一个名为 configuration.py 的模块。在那个文件中,我们可以有以下定义:

settings= dict()

现在,应用程序可以使用 configuration.settings 作为应用程序所有设置的全局存储库。一个函数或类可以解析配置文件,加载这个字典与应用程序将使用的配置值。

在一个二十一点模拟中,我们可能会看到类似以下的代码:

shoe= Deck( configuration.settings['decks'] )

或者,我们可能会看到类似以下的代码:

If bet > configuration.settings['limit']: raise InvalidBet()

通常,我们会尽量避免使用全局变量。因为全局变量隐式地存在于任何地方,所以它可能会被忽视。我们可以通过对象构造来更整洁地处理配置,而不是使用全局变量。

通过对象构造进行配置

在通过对象构造配置应用程序时,目标是构建所需的对象。实际上,配置文件定义了将要构建的对象的各种初始化参数。

我们经常可以将这种初始对象构造的大部分集中在一个单一的 main() 函数中。这将创建应用程序的真正工作的对象。我们将在第十六章 处理命令行中重新讨论并扩展这些设计问题。

考虑一下二十一点玩法和投注策略的模拟。当我们运行模拟时,我们想要收集特定组合的独立变量的性能。这些变量可能包括一些赌场政策,包括牌组数量、桌面限制和庄家规则。这些变量可能包括玩家的游戏策略,例如何时要牌、停牌、分牌和加倍。它还将包括玩家的投注策略,如平注、马丁尼投注或更复杂的拜占庭投注系统。我们的基线代码开始如下所示:

import csv
def simulate_blackjack():
    dealer_rule= Hit17()
    split_rule= NoReSplitAces()
    table= Table( decks=6, limit=50, dealer=dealer_rule,
        split=split_rule, payout=(3,2) )
    player_rule= SomeStrategy()
    betting_rule= Flat()
    player= Player( play=player_rule, betting=betting_rule, rounds=100, stake=50 )

    simulator= Simulate( table, player, 100 )
    with open("p2_c13_simulation.dat","w",newline="") as results:
        wtr= csv.writer( results )
        for gamestats in simulator:
            wtr.writerow( gamestats )

这是一种技术飞跃,它已经硬编码了所有的对象类和初始值。我们需要添加配置参数来确定对象的类和它们的初始值。

Simulate 类有一个 API,看起来像以下代码:

class Simulate:
    def __init__( self, table, player, samples ):
        """Define table, player and number of samples."""
        self.table= table
        self.player= player
        self.samples= samples
    def __iter__( self ):
        """Yield statistical samples."""

这使我们能够使用一些适当的初始化参数构建Simulate()对象。一旦我们建立了Simulate()的实例,我们可以通过该对象进行迭代,以获得一系列统计摘要对象。

有趣的部分是使用配置参数而不是类名。例如,某些参数应该用于决定dealer_rule值是创建Hit17还是Stand17实例。同样,split_rule值应该是在几个类中选择,这些类体现了赌场中使用的几种不同的分牌规则。

在其他情况下,应该使用参数来为类的__init__()方法提供参数。例如,牌组数量、庄家下注限制和二十一点赔付值是用于创建Table实例的配置值。

一旦对象建立,它们通过Simulate.run()方法正常交互以产生统计输出。不再需要全局参数池:参数值通过它们的实例变量绑定到对象中。

对象构造设计并不像全局属性映射那样简单。它避免了全局变量的优势,也使参数处理在一些主要工厂函数中变得集中和明显。

在使用对象构造时添加新参数可能会导致重构应用程序以公开参数或关系。这可能会使其看起来比从名称到值的全局映射更复杂。

这种技术的一个重要优势是消除了应用程序深处的复杂if语句。使用Strategy设计模式倾向于将决策推进到对象构造中。除了简化处理外,消除if语句还可以提高性能。

实施配置层次结构

我们通常有几种选择来放置配置文件。有五种常见选择,我们可以使用所有五种来创建参数的一种继承层次结构:

  • 应用程序的安装目录:实际上,这类似于基类定义。这里有两个子选择。较小的应用程序可以安装在 Python 的库结构中;初始化文件也可以安装在那里。较大的应用程序通常会有自己的用户名,拥有一个或多个安装目录树。

  • Python 安装目录:我们可以使用模块的__file__属性找到模块的安装位置。从这里,我们可以使用os.path.split()来定位配置文件:

	>>> import this
	>>> this.__file__
	'/Library/Frameworks/Python.framework/Versions/3.3/lib/python3.3/this.py'
  • 应用程序安装目录:这将基于拥有的用户名,因此我们可以使用~theapp/os.path.expanduser()来跟踪配置默认值。

  • 系统范围的配置目录:这通常存在于/etc中。在 Windows 上,这可以转换为C:\etc。其他选择包括os.environ['WINDIR']os.environ['ALLUSERSPROFILE']的值。

  • 当前用户的主目录:通常可以使用os.path.expanduser()~/转换为用户的主目录。对于 Windows,Python 将正确使用%HOMEDRIVE%%HOMEPATH%环境变量。

  • 当前工作目录:该目录通常称为./,尽管os.path.curdir更具可移植性。

  • 在命令行参数中命名的文件:这是一个明确命名的文件,不应进一步处理名称。

应用程序可以从基类(首先列出)到命令行选项中集成所有这些来源的配置选项。通过这种方式,安装默认值是最通用且最不特定于用户的;这些值可以被更具体和不那么通用的值覆盖。

这意味着我们经常会有一系列文件,如以下代码所示:

import os
config_name= "someapp.config"
config_locations = (
  os.path.expanduser("~thisapp/"), # or thisapp.__file__,
  "/etc",
  os.path.expanduser("~/"),
  os.path.curdir,
)
candidates = ( os.path.join(dir,config_name)
    for dir in config_locations )
config_names = [ name for name in candidates if os.path.exists(name) ]

我们取了一个备用文件目录的元组,并通过将目录与配置文件名连接起来创建了一个候选文件名列表。

一旦我们有了这个配置文件名列表,我们可以使用以下代码将通过命令行参数提供的任何文件名附加到列表的末尾:

config_names.append(command_line_option)

这给了我们一个可以检查以定位配置文件或配置默认值的位置列表。

将配置存储在 INI 文件中

INI 文件格式起源于早期的 Windows 操作系统。解析这些文件的模块是configparser

有关 INI 文件的更多细节,请参阅维基百科文章:en.wikipedia.org/wiki/INI_file

INI 文件有各个部分和每个部分内的属性。我们的示例主程序有三个部分:表配置,玩家配置和整体模拟数据收集。

我们可以想象一个看起来像以下代码的 INI 文件:

; Default casino rules
[table]
    dealer= Hit17
    split= NoResplitAces
    decks= 6
    limit= 50
    payout= (3,2)

; Player with SomeStrategy
; Need to compare with OtherStrategy
[player]
    play= SomeStrategy
    betting= Flat
    rounds= 100
    stake= 50

[simulator]
    samples= 100
    outputfile= p2_c13_simulation.dat

我们将参数分为三个部分。在每个部分中,我们提供了一些命名参数,这些参数对应于我们前面模型应用初始化中显示的类名和初始化值。

一个文件可以非常简单地解析:

import configparser
config = configparser.ConfigParser()
config.read('blackjack.ini')

我们创建了一个解析器的实例,并将目标配置文件名提供给该解析器。解析器将读取文件,定位各个部分,并定位每个部分内的各个属性。

如果我们想要支持文件的多个位置,我们可以使用config.read(config_names)。当我们将文件名列表提供给ConfigParser.read()时,它将按顺序读取文件。我们希望从最通用的文件到最具体的文件提供文件。软件安装中的通用配置文件将首先被解析以提供默认值。用户特定的配置将稍后被解析以覆盖这些默认值。

一旦我们解析了文件,我们需要利用各种参数和设置。这是一个根据解析配置文件创建的配置对象构建我们对象的函数。我们将其分为三部分。这是构建Table实例的部分:

def main_ini( config ):
    dealer_nm= config.get('table','dealer', fallback='Hit17')
    dealer_rule= {'Hit17': Hit17(),
        'Stand17': Stand17()}.get(dealer_nm, Hit17())
    split_nm= config.get('table','split', fallback='ReSplit')
    split_rule= {'ReSplit': ReSplit(),
        'NoReSplit': NoReSplit(),
        'NoReSplitAces': NoReSplitAces()}.get(split_nm, ReSplit())
    decks= config.getint('table','decks', fallback=6)
    limit= config.getint('table','limit', fallback=100)
    payout= eval( config.get('table','payout', fallback='(3,2)') )
    table= Table( decks=decks, limit=limit, dealer=dealer_rule,
        split=split_rule, payout=payout )

我们使用了 INI 文件中[table]部分的属性来选择类名并提供初始化值。这里有三种广泛的情况:

  • 将字符串映射到类名:我们使用映射来根据字符串类名查找对象。这是为了创建dealer_rulesplit_rule。如果这是一个需要大量更改的地方,我们可能能够将这个映射提取到一个单独的工厂函数中。

  • 获取 ConfigParser 可以为我们解析的值:该类可以直接处理strintfloatbool。该类具有从字符串到布尔值的复杂映射,使用各种常见代码和TrueFalse的同义词。

  • 评估非内置内容:在payout的情况下,我们有一个字符串值,'(3,2)',这不是ConfigParser的直接支持的数据类型。我们有两种选择来处理这个问题。我们可以尝试自己解析它,或者坚持该值是有效的 Python 表达式,并让 Python 来处理。在这种情况下,我们使用了eval()。一些程序员称这是一个安全问题。下一节将处理这个问题。

这是这个示例的第二部分,它使用了 INI 文件中[player]部分的属性来选择类和参数值:

    player_nm= config.get('player','play', fallback='SomeStrategy')
    player_rule= {'SomeStrategy': SomeStrategy(),
        'AnotherStrategy': AnotherStrategy()}.get(player_nm,SomeStrategy())
    bet_nm= config.get('player','betting', fallback='Flat')
    betting_rule= {'Flat': Flat(),
        'Martingale': Martingale(),
        'OneThreeTwoSix': OneThreeTwoSix()}.get(bet_nm,Flat())
    rounds= config.getint('player','rounds', fallback=100)
    stake= config.getint('player','stake', fallback=50)
    player= Player( play=player_rule, betting=betting_rule,
        rounds=rounds, stake=stake )

这使用了字符串到类的映射以及内置数据类型。它初始化了两个策略对象,然后从这两个策略加上两个整数配置值创建了Player

这是最后一部分;这创建了整体模拟器:

    outputfile= config.get('simulator', 'outputfile', fallback='blackjack.csv')
    samples= config.getint('simulator', 'samples', fallback=100)
    simulator= Simulate( table, player, samples )
    with open(outputfile,"w",newline="") as results:
        wtr= csv.writer( results )
        for gamestats in simulator:
            wtr.writerow( gamestats )

我们从[simulator]部分使用了两个参数,这些参数超出了对象创建的狭窄范围。outputfile属性用于命名文件;samples属性作为方法函数的参数提供。

通过 eval()变体处理更多文字

配置文件可能具有没有简单字符串表示的类型的值。例如,一个集合可以作为tuplelist文字提供;一个映射可以作为dict文字提供。我们有几种选择来处理这些更复杂的值。

选择解决了一个问题,即转换能够容忍多少 Python 语法。对于一些类型(intfloatboolcomplexdecimal.Decimalfractions.Fraction),我们可以安全地将字符串转换为文字值,因为这些类型的对象__init__()处理字符串值而不容忍任何额外的 Python 语法。

然而,对于其他类型,我们不能简单地进行字符串转换。我们有几种选择来继续进行:

  • 禁止这些数据类型,并依赖于配置文件语法加上处理规则,从非常简单的部分组装复杂的 Python 值。这很繁琐,但可以做到。

  • 使用ast.literal_eval(),因为它处理许多 Python 文字值的情况。这通常是理想的解决方案。

  • 使用eval()来简单评估字符串并创建预期的 Python 对象。这将解析比ast.literal_eval()更多种类的对象。这种广泛性真的有必要吗?

使用ast模块来编译和审查结果代码对象。这个审查过程可以检查import语句以及使用一些允许的模块。这非常复杂;如果我们有效地允许代码,也许我们应该设计一个框架,而不是一个带有配置文件的应用程序。

在我们通过网络执行 RESTful 传输 Python 对象的情况下,绝对不能信任对结果文本的eval()。参见第九章 - 序列化和保存 - JSON、YAML、Pickle、CSV 和 XML

然而,在读取本地配置文件的情况下,eval()可能是可用的。在某些情况下,Python 代码和配置文件一样容易修改。当基本代码可以被调整时,担心eval()可能并不有用。

以下是我们如何使用ast.literal_eval()而不是eval()

>>> import ast
>>> ast.literal_eval('(3,2)')
(3, 2)

这扩大了配置文件中可能值的领域。它不允许任意对象,但允许广泛的文字值。

将配置存储在 PY 文件中

PY 文件格式意味着使用 Python 代码作为配置文件以及实现应用程序的语言。我们将有一个配置文件,它只是一个模块;配置是用 Python 语法编写的。这消除了解析模块的需要。

使用 Python 给我们带来了许多设计考虑。我们有两种策略来使用 Python 作为配置文件:

  • 顶层脚本:在这种情况下,配置文件只是最顶层的主程序

  • exec()导入:在这种情况下,我们的配置文件提供参数值,这些值被收集到模块全局变量中

我们可以设计一个顶层脚本文件,看起来像以下代码:

from simulator import *
def simulate_SomeStrategy_Flat():
    dealer_rule= Hit17()
    split_rule= NoReSplitAces()
    table= Table( decks=6, limit=50, dealer=dealer_rule,
        split=split_rule, payout=(3,2) )
    player_rule= SomeStrategy()
    betting_rule= Flat()
    player= Player( play=player_rule, betting=betting_rule, rounds=100, stake=50 )
    simulate( table, player, "p2_c13_simulation3.dat", 100 )

if __name__ == "__main__":
    simulate_SomeStrategy_Flat()

这显示了我们用来创建和初始化对象的各种配置参数。我们只是直接将配置参数写入代码中。我们将处理过程分解到一个单独的函数simulate()中。

使用 Python 作为配置语言的一个潜在缺点是 Python 语法的复杂性。出于两个原因,这通常是一个无关紧要的问题。首先,通过一些精心设计,配置的语法应该是简单的赋值语句,带有一些(),。其次,更重要的是,其他配置文件有其自己的复杂语法,与 Python 语法不同。使用单一语言和单一语法是减少复杂性的一种方式。

simulate()函数是从整个simulator应用程序中导入的。这个simulate()函数可能看起来像以下代码:

import csv
def simulate( table, player, outputfile, samples ):
    simulator= Simulate( table, player, samples )
    with open(outputfile,"w",newline="") as results:
        wtr= csv.writer( results )
        for gamestats in simulator:
            wtr.writerow( gamestats )

这个函数是关于表、玩家、文件名和样本数量的通用函数。

这种配置技术的困难在于缺乏方便的默认值。顶层脚本必须完整:所有配置参数必须存在。提供所有值可能会很烦人;为什么要提供很少更改的默认值呢?

在某些情况下,这并不是一个限制。在需要默认值的情况下,我们将看看如何解决这个限制。

通过类定义进行配置

有时我们在顶层脚本配置中遇到的困难是缺乏方便的默认值。为了提供默认值,我们可以使用普通的类继承。以下是我们如何使用类定义来构建一个具有配置值的对象:

import simulation
class Example4( simulation.Default_App ):
    dealer_rule= Hit17()
    split_rule= NoReSplitAces()
    table= Table( decks=6, limit=50, dealer=dealer_rule,
        split=split_rule, payout=(3,2) )
    player_rule= SomeStrategy()
    betting_rule= Flat()
    player= Player( play=player_rule, betting=betting_rule, rounds=100, stake=50 )
    outputfile= "p2_c13_simulation4.dat"
    samples= 100

这允许我们使用默认配置定义Default_App。我们在这里定义的类可以简化为仅提供来自Default_App版本的覆盖值。

我们还可以使用 mixin 来将定义分解为可重用的部分。我们可以将我们的类分解为表、玩家和模拟组件,并通过 mixin 组合它们。有关 mixin 类设计的更多信息,请参见第八章,装饰器和 Mixin-横切面

在两个小方面,这种类定义的使用推动了边界。没有方法定义;我们只会使用这个类来定义一个实例。然而,这是一种非常整洁的方式,可以将一小块代码打包起来,以便赋值语句填充一个小的命名空间。

我们可以修改我们的simulate()函数来接受这个类定义作为参数:

def simulate_c( config ):
    simulator= Simulate( config.table, config.player, config.samples )
    with open(config.outputfile,"w",newline="") as results:
        wtr= csv.writer( results )
        for gamestats in simulator:
            wtr.writerow( gamestats )

这个函数从整体配置对象中挑选出相关的值,并用它们构建一个Simulate实例并执行该实例。结果与之前的simulate()函数相同,但参数结构不同。以下是我们如何将这个类的单个实例提供给这个函数:

if __name__ == "__main__":
    simulation.simulate_c(Example4())

这种方法的一个小缺点是它与argparse不兼容,无法收集命令行参数。我们可以通过使用types.SimpleNamespace对象来解决这个问题。

通过 SimpleNamespace 进行配置

使用types.SimpleNamespace对象允许我们根据需要简单地添加属性。这类似于使用类定义。在定义类时,所有赋值语句都局限于类。在创建SimpleNamespace对象时,我们需要明确地使用NameSpace对象来限定每个名称,我们正在填充的NameSpace对象。理想情况下,我们可以创建类似以下代码的SimpleNamespace

>>> import types
>>> config= types.SimpleNamespace( 
...     param1= "some value",
...     param2= 3.14,
... )
>>> config
namespace(param1='some value', param2=3.14)

如果所有配置值彼此独立,则这种方法非常有效。然而,在我们的情况下,配置值之间存在一些复杂的依赖关系。我们可以通过以下两种方式之一来处理这个问题:

  • 我们可以只提供独立的值,让应用程序构建依赖的值

  • 我们可以逐步构建命名空间中的值

只创建独立值,我们可以做如下操作:

import types
config5a= types.SimpleNamespace(
  dealer_rule= Hit17(),
  split_rule= NoReSplitAces(),
  player_rule= SomeStrategy(),
  betting_rule= Flat(),
  outputfile= "p2_c13_simulation5a.dat",
  samples= 100,
  )

config5a.table= Table( decks=6, limit=50, dealer=config5a.dealer_rule,
        split=config5a.split_rule, payout=(3,2) )
config5a.player= Player( play=config5a.player_rule, betting=config5a.betting_rule,
        rounds=100, stake=50 )

在这里,我们使用六个独立值创建了SimpleNamespace的配置。然后,我们更新配置以添加另外两个值,这些值依赖于四个独立值。

config5a对象几乎与前面示例中通过评估Example4()创建的对象相同。基类不同,但属性及其值的集合是相同的。以下是另一种方法,在顶层脚本中逐步构建配置:

import types
config5= types.SimpleNamespace()
config5.dealer_rule= Hit17()
config5.split_rule= NoReSplitAces()
config5.table= Table( decks=6, limit=50, dealer=config5.dealer_rule,
        split=config5.split_rule, payout=(3,2) )
config5.player_rule= SomeStrategy()
config5.betting_rule= Flat()
config5.player= Player( play=config5.player_rule, betting=config5.betting_rule,
        rounds=100, stake=50 )
config5.outputfile= "p2_c13_simulation5.dat"
config5.samples= 100

与之前显示的simulate_c()函数相同,可以用于这种类型的配置。

遗憾的是,这与通过顶层脚本进行配置的问题相同。没有方便的方法为配置对象提供默认值。我们可能希望有一个可以导入的工厂函数,它使用适当的默认值创建SimpleNamespace

From simulation import  make_config
config5= make_config()

如果我们使用类似上面的代码,那么默认值可以由工厂函数make_config()分配。然后每个用户提供的配置只需提供对默认值的必要覆盖。

我们的默认提供make_config()函数将具有以下类型的代码:

def make_config( ):
    config= types.SimpleNamespace()
    # set the default values
    config.some_option = default_value
    return config

make_config()函数将通过一系列赋值语句构建默认配置。然后应用程序只能设置有趣的覆盖值:

config= make_config()
config.some_option = another_value
simulate_c( config )

这使应用程序能够构建配置,然后以相对简单的方式使用它。主脚本非常简短且简洁。如果使用关键字参数,我们可以很容易地使其更加灵活:

 def make_config( **kw ):
    config= types.SimpleNamespace()
    # set the default values
    config.some_option = kw.get("some_option", default_value)
    return config

这使我们能够创建包括覆盖的配置,如下所示:

config= make_config( some_option= another_value )
simulate_c( config )

这略短一些,似乎保留了前面示例的清晰度。

所有来自第一章方法")的技术,init()方法,都适用于定义这种类型的配置工厂函数。如果需要,我们可以构建出很大的灵活性。这有一个优点,它很好地符合argparse模块解析命令行参数的方式。我们将在第十六章中扩展这一点,处理命令行

使用 Python 和 exec()进行配置

当我们决定使用 Python 作为配置的表示时,我们可以使用exec()函数在受限制的命名空间中评估一块代码。我们可以想象编写看起来像以下代码的配置文件:

# SomeStrategy setup

# Table
dealer_rule= Hit17()
split_rule= NoReSplitAces()
table= Table( decks=6, limit=50, dealer=dealer_rule,
        split=split_rule, payout=(3,2) )

# Player
player_rule= SomeStrategy()
betting_rule= Flat()
player= Player( play=player_rule, betting=betting_rule,
        rounds=100, stake=50 )

# Simulation
outputfile= "p2_c13_simulation6.dat"
samples= 100

这是一组愉快、易于阅读的配置参数。它类似于 INI 文件和属性文件,我们将在下一节中进行讨论。我们可以评估此文件,使用exec()函数创建一种命名空间:

with open("config.py") as py_file:
    code= compile(py_file.read(), 'config.py', 'exec')
config= {}
exec( code, globals(), config  )
simulate( config['table'], config['player'],
    config['outputfile'], config['samples'])

在这个例子中,我们决定使用compile()函数显式构建代码对象。这不是必需的;我们可以简单地将文件的文本提供给exec()函数,它将编译代码。

exec()的调用提供了三个参数:代码、应该用于解析任何全局名称的字典,以及将用于创建任何局部变量的字典。当代码块完成时,赋值语句将用于在局部字典中构建值;在这种情况下,是config变量。键将是变量名。

然后我们可以使用这个在程序初始化期间构建对象。我们将必要的对象传递给simulate()函数来执行模拟。config变量将获得所有局部赋值,并将具有类似以下代码的值:

{'betting_rule': <__main__.Flat object at 0x101828510>,
 'dealer_rule': <__main__.Hit17 object at 0x101828410>,
 'outputfile': 'p2_c13_simulation6.dat',
 'player': <__main__.Player object at 0x101828550>,
 'player_rule': <__main__.SomeStrategy object at 0x1018284d0>,
 'samples': 100,
 'split_rule': <__main__.NoReSplitAces object at 0x101828450>,
 'table': <__main__.Table object at 0x101828490>}

但是,初始化必须是一个书面的字典表示法:config['table']config['player']

由于字典表示法不方便,我们将使用基于第三章,“属性访问、属性和描述符”中的想法的设计模式。这是一个根据字典键提供命名属性的类:

class AttrDict( dict ):
    def __getattr__( self, name ):
        return self.get(name,None)
    def __setattr__( self, name, value ):
        self[name]= value
    def __dir__( self ):
        return list(self.keys())

这个类只有在键是合适的 Python 变量名时才能工作。有趣的是,这是exec()函数初始化config变量的方式:

config= AttrDict()

然后,我们可以使用更简单的属性表示法,config.tableconfig.player,来进行初始对象构建和初始化。在复杂的应用程序中,这种少量的语法糖可能会有所帮助。另一种方法是定义这个类:

class Configuration:
    def __init__( self, **kw ):
        self.__dict__.update(kw)

然后我们可以将简单的dict转换为具有愉快的命名属性的对象:

config= Configuration( **config )

这将把dict转换为一个具有易于使用的属性名称的对象。当然,这只适用于字典键已经是 Python 变量名的情况。它也仅限于结构是平面的情况。对于我们在其他格式中看到的嵌套字典结构,这种方法是行不通的。

为什么exec()不是问题?

前一节讨论了eval()。相同的考虑也适用于exec()

通常,可用的globals()集合是受严格控制的。通过从提供给exec()的全局变量中删除它们来消除对os模块或__import__()函数的访问。

如果你有一个邪恶的程序员,他会巧妙地破坏配置文件,那么请记住,他们可以完全访问所有的 Python 源代码。当他们可以直接改变应用程序代码本身时,为什么要浪费时间巧妙地调整配置文件呢?

一个常见的问题是:“如果有人认为他们可以通过强制新代码进入配置文件来猴子补丁一个损坏的应用程序怎么办?”这个人很可能以同样聪明/疯狂的方式破坏应用程序。避免 Python 配置文件不会阻止不道德的程序员通过做一些不明智的事情来破坏事物。有无数潜在的弱点;不必要地担心exec()可能不会有益。

在某些情况下,可能需要改变整体理念。一个高度可定制的应用程序实际上可能是一个通用框架,而不是一个整洁的成品应用程序。

使用 ChainMap 进行默认值和覆盖

我们经常会有一个配置文件层次结构。之前,我们列出了可以安装配置文件的几个位置。例如,configparser模块旨在按顺序读取多个文件,并通过后续文件覆盖先前文件的值来集成设置。

我们可以使用collections.ChainMap类实现优雅的默认值处理。有关此类的一些背景,请参阅第六章,“创建容器和集合”。我们需要将配置参数保留为dict实例,这在使用exec()来评估 Python 语言初始化文件时非常有效。

使用这种方法需要我们将配置参数设计为一组平面值的字典。对于从多个来源集成的大量复杂配置值的应用程序来说,这可能有点麻烦。我们将向您展示一种合理的方式来展平名称。

首先,我们将根据标准位置构建一个文件列表:

from collections import ChainMap
import os
config_name= "config.py"
config_locations = (
  os.path.expanduser("~thisapp/"), # or thisapp.__file__,
  "/etc",
  os.path.expanduser("~/"),
  os.path.curdir,
)
candidates = ( os.path.join(dir,config_name)
    for dir in config_locations )
config_names = ( name for name in candidates if os.path.exists(name) )

我们从一个目录列表开始:安装目录、系统全局目录、用户的主目录和当前工作目录。我们将配置文件名放入每个目录,然后确认文件实际存在。

一旦我们有了候选文件的名称,我们就可以通过折叠每个文件来构建ChainMap

config = ChainMap()
for name in config_names:
    config= config.new_child()
    exec(name, globals(), config)
simulate( config.table, config.player, config.outputfile, config.samples)

每个文件都涉及创建一个新的空映射,可以使用本地变量进行更新。exec()函数将文件的本地变量添加到new_child()创建的空映射中。每个新子代都更加本地化,覆盖先前加载的配置。

ChainMap中,通过搜索映射序列来解析每个名称以查找值。当我们将两个配置文件加载到ChainMap中时,我们将得到以下结构的代码:

ChainMap(
    {'player': <__main__.Player object at 0x10101a710>, 'outputfile': 'p2_c13_simulation7a.dat', 'player_rule': <__main__.AnotherStrategy object at 0x10101aa90>},
    {'dealer_rule': <__main__.Hit17 object at 0x10102a9d0>, 'betting_rule': <__main__.Flat object at 0x10101a090>, 'split_rule': <__main__.NoReSplitAces object at 0x10102a910>, 'samples': 100, 'player_rule': <__main__.SomeStrategy object at 0x10102a8d0>, 'table': <__main__.Table object at 0x10102a890>, 'outputfile': 'p2_c13_simulation7.dat', 'player': <__main__.Player object at 0x10101a210>},
    {})

我们有一系列映射;第一个映射是最后定义的最本地变量。这些是覆盖。第二个映射具有应用程序默认值。还有第三个空映射,因为ChainMap始终至少有一个映射;当我们构建config的初始值时,必须创建一个空映射。

唯一的缺点是初始化将使用字典表示法,config['table']config['player']。我们可以扩展ChainMap()以实现属性访问以及字典项访问。

这是ChainMap的一个子类,如果我们发现getitem()字典表示法太麻烦,我们可以使用它:

class AttrChainMap( ChainMap ):
    def __getattr__( self, name ):
        if name == "maps":
            return self.__dict__['maps']
        return super().get(name,None)
    def __setattr__( self, name, value ):
        if name == "maps":
            self.__dict__['maps']= value
            return
        self[name]= value

现在我们可以使用config.table而不是config['table']。这揭示了我们对ChainMap的扩展的一个重要限制:我们不能将maps用作属性。maps键是父ChainMap类的一级属性。

将配置存储在 JSON 或 YAML 文件中

我们可以相对轻松地将配置值存储在 JSON 或 YAML 文件中。语法设计得用户友好。我们可以在 YAML 中表示各种各样的事物。在 JSON 中,我们受到更窄的对象类别的限制。我们可以使用类似以下代码的 JSON 配置文件:

{
    "table":{
        "dealer":"Hit17",
        "split":"NoResplitAces",
        "decks":6,
        "limit":50,
        "payout":[3,2]
    },
    "player":{
        "play":"SomeStrategy",
        "betting":"Flat",
        "rounds":100,
        "stake":50
    },
    "simulator":{
        "samples":100,
        "outputfile":"p2_c13_simulation.dat"
    }
}

JSON 文档看起来像字典的字典。这正是在加载此文件时将构建的对象。我们可以使用以下代码加载单个配置文件:

import json
config= json.load( "config.json" )

这使我们可以使用config['table']['dealer']来查找用于荷官规则的特定类。我们可以使用config['player']['betting']来定位玩家特定的投注策略类名。

与 INI 文件不同,我们可以轻松地将tuple编码为值序列。因此,config['table']['payout']值将是一个正确的两元素序列。严格来说,它不会是tuple,但它足够接近,我们可以在不必使用ast.literal_eval()的情况下使用它。

以下是我们将如何使用此嵌套结构。我们只会向您展示main_nested_dict()函数的第一部分:

def main_nested_dict( config ):
    dealer_nm= config.get('table',{}).get('dealer', 'Hit17')
    dealer_rule= {'Hit17':Hit17(),
        'Stand17':Stand17()}.get(dealer_nm, Hit17())
    split_nm= config.get('table',{}).get('split', 'ReSplit')
    split_rule= {'ReSplit':ReSplit(),
        'NoReSplit':NoReSplit(),
        'NoReSplitAces':NoReSplitAces()}.get(split_nm, ReSplit())
    decks= config.get('table',{}).get('decks', 6)
    limit= config.get('table',{}).get('limit', 100)
 **payout= config.get('table',{}).get('payout', (3,2))
    table= Table( decks=decks, limit=limit, dealer=dealer_rule,
        split=split_rule, payout=payout )

这与之前显示的main_ini()函数非常相似。当我们将其与之前的使用configparser的版本进行比较时,很明显复杂性几乎相同。命名略微简单。我们使用config.get('table',{}).get('decks')代替config.getint('table','decks')

最大的区别显示在突出显示的行中。JSON 格式为我们提供了正确解码的整数值和正确的值序列。我们不需要使用eval()ast.literal_eval()来解码元组。其他部分,构建Player和配置Simulate对象,与main_ini()版本类似。

使用展平的 JSON 配置

如果我们想通过集成多个配置文件来提供默认值,我们不能同时使用ChainMap和类似这样的嵌套字典。我们必须要么展平程序的参数,要么寻找合并来自不同来源的参数的替代方法。

我们可以通过在名称之间使用简单的.分隔符来轻松地展平名称。我们的 JSON 文件可能看起来像以下代码:

{
"player.betting": "Flat",
"player.play": "SomeStrategy",
"player.rounds": 100,
"player.stake": 50,
"table.dealer": "Hit17",
"table.decks": 6,
"table.limit": 50,
"table.payout": [3, 2],
"table.split": "NoResplitAces",
"simulator.outputfile": "p2_c13_simulation.dat",
"simulator.samples": 100
}

这有利于我们使用ChainMap从各种来源累积配置值。它还略微简化了定位特定参数值的语法。给定配置文件名列表config_names,我们可能会这样做:

config = ChainMap( *[json.load(file) for file in reversed(config_names)] )

反向配置文件名列表构建一个适当的ChainMap。为什么是反向的?我们必须反转列表,因为我们希望列表从最具体的开始到最一般的结束。这与configparser使用列表的方式相反,也与我们通过将子项添加到映射列表的前面来逐步构建ChainMap的方式相反。在这里,我们只是将一系列dict加载到ChainMap中,第一个dict将是被键首先搜索的。

我们可以使用类似这样的方法来利用ChainMap。我们只会展示第一部分,构建Table实例:

def main_cm( config ):
    dealer_nm= config.get('table.dealer', 'Hit17')
    dealer_rule= {'Hit17':Hit17(),
        'Stand17':Stand17()}.get(dealer_nm, Hit17())
    split_nm= config.get('table.split', 'ReSplit')
    split_rule= {'ReSplit':ReSplit(),
        'NoReSplit':NoReSplit(),
        'NoReSplitAces':NoReSplitAces()}.get(split_nm, ReSplit())
    decks= int(config.get('table.decks', 6))
    limit= int(config.get('table.limit', 100))
    **payout= config.get('table.payout', (3,2))
    table= Table( decks=decks, limit=limit, dealer=dealer_rule,
        split=split_rule, payout=payout )

其他部分,构建Player和配置Simulate对象,与main_ini()版本类似。

当我们将其与使用configparser的先前版本进行比较时,很明显复杂性几乎相同。命名稍微简单。在这里,我们使用int(config.get('table.decks'))而不是config.getint('table','decks')

加载 YAML 配置

由于 YAML 语法包含 JSON 语法,前面的例子也可以用 YAML 和 JSON 加载。这是从 JSON 文件中的嵌套字典技术的版本:

player:
  betting: Flat
  play: SomeStrategy
  rounds: 100
  stake: 50
table:
  dealer: Hit17
  decks: 6
  limit: 50
  payout: [3, 2]
  split: NoResplitAces
simulator: {outputfile: p2_c13_simulation.dat, samples: 100}

这是比纯 JSON 更好的文件语法;更容易编辑。对于配置主要由字符串和整数控制的应用程序,这有很多优势。加载此文件的过程与加载 JSON 文件的过程相同:

import yaml
config= yaml.load( "config.yaml" )

这与嵌套字典具有相同的限制。除非我们展平名称,否则我们没有处理默认值的简单方法。

然而,当我们超越简单的字符串和整数时,我们可以尝试利用 YAML 编码类名和创建我们定制类的实例的能力。这是一个 YAML 文件,将直接构建我们模拟所需的配置对象:

# Complete Simulation Settings
table: !!python/object:__main__.Table
  dealer: !!python/object:__main__.Hit17 {}
  decks: 6
  limit: 50
  payout: !!python/tuple [3, 2]
  split: !!python/object:__main__.NoReSplitAces {}
player: !!python/object:__main__.Player
  betting:  !!python/object:__main__.Flat {}
  init_stake: 50
  max_rounds: 100
  play: !!python/object:__main__.SomeStrategy {}
  rounds: 0
  stake: 63.0
samples: 100
outputfile: p2_c13_simulation9.dat

我们已经在 YAML 中编码了类名和实例构造,允许我们定义TablePlayer的完整初始化。我们可以像这样使用这个初始化文件:

import yaml
if __name__ == "__main__":
    config= yaml.load( yaml1_file )
    simulate( config['table'], config['player'],
        config['outputfile'], config['samples'] )

这向我们展示了 YAML 配置文件可以用于人工编辑。YAML 为我们提供了与 Python 相同的功能,但具有不同的语法。对于这种类型的示例,Python 配置脚本可能比 YAML 更好。

将配置存储在属性文件中

属性文件通常与 Java 程序一起使用。我们没有理由不使用它们与 Python 一起使用。它们相对容易解析,并允许我们以方便、易于使用的格式编码配置参数。有关格式的更多信息,请参阅:en.wikipedia.org/wiki/.properties。属性文件可能如下所示:

# Example Simulation Setup

player.betting: Flat
player.play: SomeStrategy
player.rounds: 100
player.stake: 50

table.dealer: Hit17
table.decks: 6
table.limit: 50
table.payout: (3,2)
table.split: NoResplitAces

simulator.outputfile = p2_c13_simulation8.dat
simulator.samples = 100

这在简单性方面有一些优势。section.property限定名称通常被使用。这些在非常复杂的配置文件中可能会变得很长。

解析属性文件

Python 标准库中没有内置的属性解析器。我们可以从 Python 包索引(pypi.python.org/pypi)下载属性文件解析器。然而,这不是一个复杂的类,这是一个很好的高级面向对象编程练习。

我们将类分解为顶层 API 函数和较低级别的解析函数。以下是一些整体 API 方法:

import re
class PropertyParser:
    def read_string( self, data ):
        return self._parse(data)
    def read_file( self, file ):
        data= file.read()
        return self.read_string( data )
    def read( self, filename ):
        with open(filename) as file:
            return self.read_file( file )

这里的基本特性是它将解析文件名、文件或一块文本。这遵循了configparser的设计模式。一个常见的替代方法是减少方法的数量,并使用isinstance()来确定参数的类型,还确定要对其执行什么处理。

文件名是字符串。文件本身通常是io.TextIOBase的实例。一块文本也是一个字符串。因此,许多库使用load()来处理文件或文件名,使用loads()来处理简单的字符串。类似这样的东西会回显json的设计模式:

    def load( self, file_or_name ):
        if isinstance(file_or_name, io.TextIOBase):
            self.loads(file_or_name.read())
        else:
            with open(filename) as file:
                self.loads(file.read())
    def loads( self, string ):
        return self._parse(data)

这些方法也可以处理文件、文件名或文本块。这些额外的方法名称为我们提供了一个可能更容易使用的替代 API。决定因素是在各种库、包和模块之间实现一致的设计。这是_parse()方法:

    key_element_pat= re.compile(r"(.*?)\s*(?<!\\)[:=\s]\s*(.*)")
    def _parse( self, data ):
        logical_lines = (line.strip()
            for line in re.sub(r"\\\n\s*", "", data).splitlines())
        non_empty= (line for line in logical_lines
            if len(line) != 0)
        non_comment= (line for line in non_empty
            if not( line.startswith("#") or line.startswith("!") ) )
        for line in non_comment:
            ke_match= self.key_element_pat.match(line)
            if ke_match:
                key, element = ke_match.group(1), ke_match.group(2)
            else:
                key, element = line, ""
            key= self._escape(key)
            element= self._escape(element)
            yield key, element

这个方法从三个生成器表达式开始,处理属性文件中物理行和逻辑行的一些整体特性。生成器表达式的优势在于它们被惰性执行;直到它们被for line in non_comment语句评估时,这些表达式才会创建中间结果。

第一个表达式赋给logical_lines,合并以\结尾的物理行,以创建更长的逻辑行。前导(和尾随)空格被去除,只留下行内容。正则表达式REr"\\\n\s*"旨在匹配行尾的\和下一行的所有前导空格。

第二个表达式赋给non_empty,只会迭代长度非零的行。空行将被这个过滤器拒绝。

第三,non_comment表达式只会迭代不以#!开头的行。以#!开头的行将被这个过滤器拒绝。

由于这三个生成器表达式,for line in non_comment循环只会迭代非注释、非空白、逻辑行,这些行已经合并并去除了空格。循环的主体将剩下的每一行分开,以分隔键和元素,然后应用self._escape()函数来扩展任何转义序列。

键-元素模式key_element_pat寻找非转义的显式分隔符:, =或由空白包围的空格。这个模式使用否定的后行断言,一个(?<!\\)的 RE,表示接下来的 RE 必须是非转义的;接下来的模式前面不能有\。这意味着(?<!\\)[:=\s]是非转义的:,或=, 或空格。

如果找不到键-元素模式,就没有分隔符。我们解释这种缺乏匹配模式表示该行是一个只有键的退化情况;没有提供值。

由于键和元素形成了一个 2 元组的序列,这个序列可以很容易地转换成一个字典,提供一个配置映射,就像我们看到的其他配置表示方案一样。它们也可以保留为一个序列,以显示文件的原始内容。最后一部分是一个小的方法函数,将转义转换为它们的最终字符:

    def _escape( self, data ):
        d1= re.sub( r"\\([:#!=\s])", lambda x:x.group(1), data )
        d2= re.sub( r"\\u([0-9A-Fa-f]+)", lambda x:chr(int(x.group(1),16)), d1 )
        return d2

这个_escape()方法函数执行两次替换。第一次替换将转义的标点符号替换为它们的纯文本版本:\:, \#, \!, \=, 和 \都去掉了\。对于 Unicode 转义,使用数字字符串创建一个适当的 Unicode 字符,替换\uxxxx序列。十六进制数字被转换为整数,然后转换为替换的字符。

这两个替换可以合并成一个单独的操作,以节省创建一个只会被丢弃的中间字符串。这将提高性能。可能看起来像以下代码:

        d2= re.sub( r"\\([:#!=\s])|\\u([0-9A-Fa-f]+)",
            lambda x:x.group(1) if x.group(1) else chr(int(x.group(2),16)), data )

更好性能的好处可能会被正则表达式和替换函数的复杂性所抵消。

使用属性文件

我们在如何使用属性文件上有两种选择。我们可以遵循configparser的设计模式,解析多个文件以创建一个从各种值的并集中得到的单一映射。或者,我们可以遵循ChainMap模式,为每个配置文件创建一个属性映射序列。

ChainMap处理相当简单,并为我们提供了所有必需的功能:

config= ChainMap(
    *[dict( pp.read(file) )
        for file in reversed(candidate_list)] )

我们按照相反的顺序列出了列表:最具体的设置将首先出现在内部列表中;最一般的设置将是最后一个。一旦ChainMap被加载,我们就可以使用这些属性来初始化和构建我们的PlayerTableSimulate实例。

这似乎比从几个来源更新单个映射更简单。此外,这遵循了处理 JSON 或 YAML 配置文件的模式。

我们可以使用类似这样的方法来利用ChainMap。这与之前显示的main_cm()函数非常相似。我们只会向您展示构建Table实例的第一部分:

import ast
def main_cm_str( config ):
    dealer_nm= config.get('table.dealer', 'Hit17')
    dealer_rule= {'Hit17':Hit17(),
        'Stand17':Stand17()}.get(dealer_nm, Hit17())
    split_nm= config.get('table.split', 'ReSplit')
    split_rule= {'ReSplit':ReSplit(),
        'NoReSplit':NoReSplit(),
        'NoReSplitAces':NoReSplitAces()}.get(split_nm, ReSplit())
    decks= int(config.get('table.decks', 6))
    limit= int(config.get('table.limit', 100))
    **payout= ast.literal_eval(config.get('table.payout', '(3,2)'))
    table= Table( decks=decks, limit=limit, dealer=dealer_rule,
        split=split_rule, payout=payout )

这个版本与main_cm()函数的区别在于处理支付元组的方式。在以前的版本中,JSON(和 YAML)可以解析元组。当使用属性文件时,所有值都是简单的字符串。我们必须使用eval()ast.literal_eval()来评估给定的值。这个main_cm_str()函数的其他部分与main_cm()是相同的。

将配置存储在 XML 文件中 - PLIST 和其他文件

正如我们在第九章中所指出的,序列化和保存 - JSON、YAML、Pickle、CSV 和 XML,Python 的xml包包括许多解析 XML 文件的模块。由于 XML 文件的广泛采用,通常需要在 XML 文档和 Python 对象之间进行转换。与 JSON 或 YAML 不同,从 XML 的映射并不简单。

在 XML 中表示配置数据的一种常见方式是.plist文件。有关.plist格式的更多信息,请参阅:developer.apple.com/documentation/Darwin/Reference/ManPages/man5/plist.5.html

Macintosh 用户可以执行man plist来查看这个 man 页面。.plist格式的优点是它使用了少量非常通用的标签。这使得创建和解析.plist文件变得容易。这是我们配置参数的示例.plist文件。

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
  <key>player</key>
  <dict>
    <key>betting</key>
    <string>Flat</string>
    <key>play</key>
    <string>SomeStrategy</string>
    <key>rounds</key>
    <integer>100</integer>
    <key>stake</key>
    <integer>50</integer>
  </dict>
  <key>simulator</key>
  <dict>
    <key>outputfile</key>
    <string>p2_c13_simulation8.dat</string>
    <key>samples</key>
    <integer>100</integer>
  </dict>
  <key>table</key>
  <dict>
    <key>dealer</key>
    <string>Hit17</string>
    <key>decks</key>
    <integer>6</integer>
    <key>limit</key>
    <integer>50</integer>
    <key>payout</key>
    <array>
      <integer>3</integer>
      <integer>2</integer>
    </array>
    <key>split</key>
    <string>NoResplitAces</string>
  </dict>
</dict>
</plist>

在这个例子中,我们展示了嵌套的字典结构。有许多与 XML 标签编码兼容的 Python 类型。

Python 类型 Plist 标签
str <string>
float <real>
int <integer>
datetime <date>
boolean <true/> 或 <false/>
bytes <data>
list <array>
dict <dict>

正如前面的例子所示,字典<key>的值是字符串。这使得 plist 成为我们模拟应用程序参数的非常愉快的编码方式。我们可以相对容易地加载.plist

import plistlib
print( plistlib.readPlist(plist_file) )

这将重建我们的配置参数。然后,我们可以在 JSON 配置文件的前一节中使用main_nested_dict()函数显示的嵌套字典结构。

使用单个模块函数来解析文件使.plist格式非常吸引人。对于任何自定义的 Python 类定义的支持不足,这使得它等同于 JSON 或属性文件。

自定义 XML 配置文件

对于更复杂的 XML 配置文件,请参阅wiki.metawerx.net/wiki/Web.xml。这些文件包含特定用途的标签和通用标签的混合。这些文档可能很难解析。有两种一般的方法:

  • 编写一个文档处理类,使用 XPath 查询来定位文档中包含有趣数据的标签。在这种情况下,我们将编写能够在 XML 文档结构中定位请求信息的属性(或方法)。

  • 将 XML 文档解开成 Python 数据结构。这是之前展示的plist模块所采用的方法。

根据web.xml文件的示例,我们将设计我们自己的定制 XML 文档来配置我们的模拟应用程序:

<?xml version="1.0" encoding="UTF-8"?>
<simulation>
    <table>
        <dealer>Hit17</dealer>
        <split>NoResplitAces</split>
        <decks>6</decks>
        <limit>50</limit>
        <payout>(3,2)</payout>
    </table>
    <player>
        <betting>Flat</betting>
        <play>SomeStrategy</play>
        <rounds>100</rounds>
        <stake>50</stake>
    </player>
    <simulator>
        <outputfile>p2_c13_simulation11.dat</outputfile>
        <samples>100</samples>
    </simulator>
</simulation>

这是一个专门的 XML 文件。我们没有提供 DTD 或 XSD,因此没有正式的方法来根据模式验证 XML。但是,这个文件很小,易于调试,并且与其他示例初始化文件相似。这里有一个Configuration类,可以使用 XPath 查询从这个文件中检索信息:

import xml.etree.ElementTree as XML
class Configuration:
    def read_file( self, file ):
        self.config= XML.parse( file )
    def read( self, filename ):
        self.config= XML.parse( filename )
    def read_string( self, text ):
        self.config= XML.fromstring( text )
    def get( self, qual_name, default ):
        section, _, item = qual_name.partition(".")
        query= "./{0}/{1}".format( section, item )
        node= self.config.find(query)
        if node is None: return default
        return node.text
    def __getitem__( self, section ):
        query= "./{0}".format(section)
        parent= self.config.find(query)
        return dict( (item.tag, item.text) for item in parent )

我们实现了三种方法来加载 XML 文档:read()read_file()read_string()。这些方法只是将自己委托给xml.etree.ElementTree类的现有方法函数。这与configparserAPI 相似。我们也可以使用load()loads(),因为它们会将自己委托给parse()fromstring()

为了访问配置数据,我们实现了两种方法:get()__getitem__()get()方法允许我们使用这样的代码:stake= int(config.get('player.stake', 50))__getitem__()方法允许我们使用这样的代码:stake= config['player']['stake']

解析比.plist文件稍微复杂一些。但是,XML 文档比等效的.plist文档简单得多。

我们可以使用前一节中显示的main_cm_str()函数来处理属性文件上的配置。

总结

我们研究了许多表示配置参数的方法。其中大多数是基于我们在第九章中看到的更一般的序列化技术,序列化和保存-JSON、YAML、Pickle、CSV 和 XMLconfigparser模块提供了一个额外的格式,对一些用户来说更舒适。

配置文件的关键特征是内容可以轻松地由人类编辑。因此,pickle 文件不建议作为良好的表示。

设计考虑和权衡

配置文件可以简化运行应用程序或启动服务器。这可以将所有相关参数放在一个易于阅读和易于修改的文件中。我们可以将这些文件放在配置控制下,跟踪更改历史,并通常使用它们来提高软件的质量。

对于这些文件,我们有几种替代格式,所有这些格式都相对友好,易于编辑。它们在解析的难易程度以及可以编码的 Python 数据的任何限制方面有所不同:

  • INI 文件:这些文件易于解析,仅限于字符串和数字。

  • Python 代码(PY 文件):这些文件使用主脚本进行配置。没有解析,没有限制。它们使用exec()文件。易于解析,没有限制。

  • JSON 或 YAML 文件:这些文件易于解析。它们支持字符串,数字,字典和列表。YAML 可以编码 Python,但为什么不直接使用 Python 呢?

  • 属性文件:这些文件需要一个特殊的解析器。它们仅限于字符串。

  • XML 文件

  • .plist 文件:这些文件易于解析。它们支持字符串,数字,字典和列表。

  • 定制 XML:这些文件需要一个特殊的解析器。它们仅限于字符串。

与其他应用程序或服务器的共存通常会确定配置文件的首选格式。如果我们有其他应用程序使用.plist或 INI 文件,那么我们的 Python 应用程序应该做出更符合用户使用习惯的选择。

从可以表示的对象的广度来看,我们有四个广泛的配置文件类别:

  • 只包含字符串的简单文件:定制 XML,属性文件。

  • 简单文件,只包含简单的 Python 文字:INI 文件。

  • 更复杂的文件,包含 Python 文字,列表和字典:JSON,YAML,.plist和 XML。

  • 任何东西。Python:我们可以使用 YAML,但当 Python 有更清晰的语法时,这似乎有些愚蠢。

创建共享配置

当我们在第十七章中查看模块设计考虑时,模块和包设计,我们将看到模块如何符合单例设计模式。这意味着我们只能导入一个模块,并且单个实例是共享的。

因此,通常需要在一个独立的模块中定义配置并导入它。这允许单独的模块共享一个公共配置。每个模块都将导入共享配置模块;配置模块将定位配置文件并创建实际的配置对象。

模式演变

配置文件是公共 API 的一部分。作为应用程序设计者,我们必须解决模式演变的问题。如果我们改变一个类的定义,我们将如何改变配置?

因为配置文件通常具有有用的默认值,它们通常非常灵活。原则上,内容是完全可选的。

当软件经历主要版本更改时,改变 API 或数据库模式的更改,配置文件也可能经历重大更改。配置文件的版本号可能必须包含以消除旧版配置参数和当前发布参数之间的歧义。

对于次要版本更改,配置文件,如数据库、输入和输出文件以及 API,应保持兼容。任何配置参数处理都应具有适当的默认值,以应对次要版本更改。

配置文件是应用程序的一流输入。它不是事后想法或变通方法。它必须像其他输入和输出一样经过精心设计。当我们在第十四章中查看更大的应用程序架构设计时,日志和警告模块和第十六章,处理命令行,我们将扩展解析配置文件的基础知识。

展望未来

在接下来的章节中,我们将看到更大规模的设计考虑。第十四章,日志和警告模块,将介绍如何使用loggingwarnings模块来创建审计信息以及调试。我们将探讨为可测试性设计以及在第十五章中如何使用unittestdoctest。第十六章,处理命令行,将介绍如何使用argparse模块来解析选项和参数。我们将进一步使用命令设计模式来创建可以组合和扩展的程序组件,而不必编写 shell 脚本。在第十七章,模块和包设计,我们将探讨模块和包的设计。在第十八章,质量和文档,我们将探讨如何记录我们的设计以创建对我们的软件正确性和正确实现的信任。

第三部分:测试、调试、部署和维护

日志和警告模块

测试设计

应对命令行

模块和包设计

质量和文档

测试、调试、部署和维护

应用程序开发涉及许多技能,超出了在 Python 中进行面向对象设计和编程。我们将看一些额外的主题,帮助我们从仅仅编程向解决用户问题迈进:

  • 第十四章日志和警告模块将介绍如何使用loggingwarnings模块来创建审计信息以及调试。我们将迈出一个重要的步骤,超越使用print()函数。logging模块为我们提供了许多功能,使我们能够以简单和统一的方式生成审计、调试和信息消息。由于这是高度可配置的,我们可以提供有用的调试以及冗长的处理选项。

  • 我们将研究测试设计以及我们如何在第十五章测试设计中使用unittestdoctest。自动化测试应被视为绝对必要。在没有自动化单元测试提供充分证据表明代码有效之前,编程不应被视为完成。

  • 我们的程序的命令行界面为我们提供了选项和参数。这主要适用于小型、面向文本的程序以及长时间运行的应用程序服务器。然而,即使是 GUI 应用程序也可以使用命令行选项进行配置。第十六章应对命令行将介绍如何使用argparse模块来解析选项和参数。我们将进一步采用命令设计模式来创建可以组合和扩展的程序组件,而无需编写 shell 脚本。

  • 在第十七章模块和包设计中,我们将研究模块和包设计。这是一个比我们迄今为止所看到的类设计主题更高级别的考虑。模块和类设计重复了 Wrap、Extend 或 Invent 的策略。我们不是在看相关数据和操作,而是在看模块中相关的类和包中相关的模块。

  • 在第十八章质量和文档中,我们将看一下如何记录我们的设计,以建立我们的软件是正确的并且是正确实现的信任。

本部分强调使用这些附加模块来提高软件质量的方法。与第一部分中的通过特殊方法创建 Pythonic 类和第二部分中的持久性和序列化不同,这些工具和技术并不狭隘地专注于解决特定问题。这些主题更广泛地适用于掌握面向对象的 Python。

第十四章:日志和警告模块

有一些基本的日志技术,我们可以用于调试以及应用程序的操作支持。特别是,一个良好的日志可以帮助证明应用程序满足其安全性和可审计性要求。

有时我们会有多个具有不同类型信息的日志。我们可能会将安全、审计和调试分开成单独的日志。在某些情况下,我们可能需要一个统一的日志。我们将看一些做这件事的例子。

我们的用户可能希望获得详细的输出,以确认程序的正确运行。这与调试输出不同;最终用户正在检查程序如何解决他们的问题。例如,他们可能希望更改他们的输入或以不同方式处理您程序的输出。设置详细程度会产生一个满足用户需求的日志。

warnings模块可以为开发人员和用户提供有用的信息。对于开发人员,我们可以使用警告来告诉您某个 API 已被弃用。对于用户,我们可能想告诉您结果是有问题的,但严格来说并不是错误的。可能存在有问题的假设或可能令用户困惑的默认值,应该向用户指出。

软件维护人员需要启用日志记录以进行有用的调试。我们很少希望全面调试输出:结果日志可能会难以阅读。我们经常需要有针对性的调试来追踪特定问题,以便我们可以修改单元测试用例并修复软件。

在尝试解决程序崩溃问题时,我们可能希望创建一个小的循环队列来捕获最近的几个事件。我们可以使用这个来隔离问题,而不必筛选大型日志文件。

创建一个基本日志

日志记录有两个必要的步骤:

  • 使用logging.getLogger()函数获取一个logging.Logger实例。

  • 使用该Logger创建消息。有许多方法,如warn()info()debug()error()fatal(),可以创建具有不同重要性级别的消息。

然而,这两个步骤还不足以给我们任何输出。还有第三步,只有在需要查看输出时才会执行。有些日志记录是为了调试目的,不一定总是需要查看日志。可选的步骤是配置logging模块的处理程序、过滤器和格式化程序。我们可以使用logging.basicConfig()函数来实现这一点。

甚至可以跳过第一步。我们可以使用logging模块顶层函数中的默认记录器。我们在第八章中展示了这一点,装饰器和混入-横切面,因为重点是装饰,而不是记录。我们建议您不要使用默认的根记录器。我们需要一些背景知识才能理解为什么最好避免使用根记录器。

Logger的实例由名称标识。这些名称是由.分隔的字符串,形成一个层次结构。有一个名称为""的根记录器-空字符串。所有其他Loggers都是这个根Logger的子记录器。

由于这个命名为Loggers的树,我们通常使用根Logger来配置整个树。当找不到适当命名的Logger时,我们也会使用它。如果我们还使用根Logger作为特定模块的第一类日志,那么只会造成混乱。

除了名称外,Logger还可以配置一个处理程序列表,确定消息写入的位置,以及一个Filters列表,确定传递或拒绝哪种类型的消息。记录器是记录日志的基本 API:我们使用记录器来创建LogRecords。然后这些记录被路由到FiltersHandlers,通过的记录被格式化,最终被存储在本地文件或通过网络传输。

最佳实践是为我们的每个类或模块创建一个独立的记录器。由于Logger名称是由.分隔的字符串,Logger名称可以与类或模块名称平行;我们应用程序的组件定义层次结构将有一个平行的记录器层次结构。我们可能有一个类似以下代码的类:

import logging
class Player:
    def __init__( self, bet, strategy, stake ):
        self.logger= logging.getLogger( self.__class__.__qualname__ )
        self.logger.debug( "init bet {0}, strategy {1}, stake {2}".format(
            bet, strategy, stake) )

这将确保用于此类的Logger对象将具有与类的限定名称相匹配的名称。

创建一个共享的类级别的记录器

正如我们在第八章中指出的,装饰器和混合 - 横切面方面,通过定义一个在类定义本身之外创建记录器的装饰器,可以使创建类级别的记录器变得更加清晰。这是我们定义的装饰器:

def logged( class_ ):
    class_.logger= logging.getLogger( class_.__qualname__ )
    return class_

这将创建logger作为类的一个特性,由所有实例共享。现在,我们可以定义一个类,如以下代码:

@logged
class Player:
    def __init__( self, bet, strategy, stake ):
        self.logger.debug( "init bet {0}, strategy {1}, stake {2}".format(
            bet, strategy, stake) )

这将向我们保证该类具有预期名称的记录器。然后,我们可以在各种方法中使用self.logger,并确信它将是logging.Logger的有效实例。

当我们创建Player的实例时,我们将激活记录器。默认情况下,我们看不到任何东西。logging模块的初始配置不包括产生任何输出的处理程序或级别。我们需要更改logging配置才能看到任何内容。

logging模块工作的最重要的好处是,我们可以在我们的类和模块中包含日志记录功能,而不必担心整体配置。默认行为将是静默的,并且引入的开销非常小。因此,我们可以在我们定义的每个类中始终包含日志记录功能。

配置记录器

我们需要提供两个配置细节才能看到我们日志中的输出:

  • 我们正在使用的记录器需要与产生显着输出的处理程序相关联

  • 处理程序需要一个将传递我们的日志消息的日志级别

logging包有各种配置方法。我们将在这里展示logging.basicConfig()。我们将单独查看logging.config.dictConfig()

logging.basicConfig()方法允许使用一些参数创建一个单一的logging.handlers.StreamHandler来记录输出。在许多情况下,这就是我们所需要的:

import logging
import sys
logging.basicConfig( stream=sys.stderr, level=logging.DEBUG )

这将配置一个StreamHandler实例,它将写入sys.stderr。它将传递具有大于或等于给定级别的消息。通过使用logging.DEBUG,我们确保看到所有消息。默认级别是logging.WARN

执行此配置后,我们将看到我们的调试消息:

>>> p= Player( 1, 2, 3 )
DEBUG:Player:init bet 1, strategy 2, stake 3

默认格式显示了级别(DEBUG),记录器的名称(Player)和我们生成的字符串。LogRecord中还有更多的属性可以显示。通常,这种默认格式是可以接受的。

启动和关闭日志系统

logging模块以一种避免手动管理全局状态信息的方式定义。全局状态在logging模块内部处理。我们可以将应用程序写成单独的部分,并确信这些组件将通过logging接口正确合作。例如,我们可以在一些模块中包含logging,而在其他模块中完全省略它,而不必担心兼容性或配置。

最重要的是,我们可以在整个应用程序中包含日志记录请求,而无需配置任何处理程序。顶层主脚本可以完全省略import logging。在这种情况下,日志记录代码不会出现任何错误或问题。

由于日志记录的分散性质,很容易在应用程序的顶层仅配置一次。我们应该只在应用程序的if __name__ == "__main__":部分内部配置logging。我们将在第十六章中更详细地讨论这个问题,处理命令行

我们的许多日志处理程序涉及缓冲。在大多数情况下,缓冲区将在正常事件过程中刷新。虽然我们可以忽略日志记录如何关闭,但使用logging.shutdown()稍微更可靠,以确保所有缓冲区都刷新到设备。

处理顶层错误和异常时,我们有两种明确的技术来确保所有缓冲区都被写入。一种技术是在try:块上使用finally子句:

import sys
if __name__ == "__main__":
    logging.config.dictConfig( yaml.load("log_config.yaml") )
    try:
        application= Main()
        status= application.run()
    except Exception as e:
        logging.exception( e )
        status= 2
    finally:
        logging.shutdown()
    sys.exit(status)

这个例子向我们展示了如何尽早配置logging,并尽可能晚地关闭logging。这确保了尽可能多的应用程序被正确配置的记录器所包围。这包括一个异常记录器;在一些应用程序中,main()函数处理所有异常,使得这里的 except 子句多余。

另一种方法是包含一个atexit处理程序来关闭logging

import atexit
import sys
if __name__ == "__main__":
    logging.config.dictConfig( yaml.load("log_config.yaml") )
    atexit.register(logging.shutdown)
    try:
        application= Main()
        status= application.run()
    except Exception as e:
        logging.exception( e )
        status= 2
    sys.exit(status)

这个版本向我们展示了如何使用atexit处理程序来调用logging.shutdown()。当应用程序退出时,给定的函数将被调用。如果异常在main()函数内得到了正确处理,try:块可以被更简单的status= main(); sys.exit(status)所替代。

还有第三种技术,使用上下文管理器来控制日志记录。我们将在第十六章中看到这种替代方法,处理命令行

命名记录器

使用logging.getLogger()为我们的Loggers命名有四种常见用例。我们经常选择与我们应用程序架构相对应的名称:

  • 模块名称:对于包含大量小函数或创建大量对象的类的模块,我们可能会有一个模块全局的Logger实例。例如,当我们扩展tuple时,我们不希望每个实例中都有对Logger的引用。我们经常会在全局范围内这样做,通常这个记录器的创建会保持在模块的前面。在这个例子中,就在导入之后:
import logging
logger= logging.getLogger( __name__ )
  • 对象实例:这是之前显示的,当我们在__init__()方法中创建Logger时。这个Logger将是实例唯一的;仅使用合格的类名可能会产生误导,因为类的实例会有多个。更好的设计是在记录器的名称中包含一个唯一的实例标识符:
def __init__( self, player_name )
    self.name= player_name
    self.logger= logging.getLogger( "{0}.{1}".format(
        self.__class__.__qualname__, player_name ) )
  • 类名称:这是之前显示的,当我们定义一个简单的装饰器时。我们可以使用__class__.__qualname__作为Logger的名称,并将Logger分配给整个类。它将被类的所有实例共享。

  • 函数名称:对于经常使用的小函数,我们经常会使用模块级别的日志,如前面所示。对于很少使用的大型函数,我们可能会在函数内部创建一个日志:

def main():
    log= logging.getLogger("main")

这里的想法是确保我们的Logger名称与软件架构匹配。这为我们提供了最透明的日志记录,简化了调试。

然而,在某些情况下,我们可能会有一个更复杂的Loggers集合。我们可能有来自一个类的几种不同类型的信息消息。两个常见的例子是财务审计日志和安全访问日志。我们可能希望有几个并行的Loggers层次结构:一个以audit.开头的名称,另一个以security.开头的名称。一个类可能有更专业的Loggers,名称如audit.module.Classsecurity.module.Class

self.audit_log= logging.getLogger( "audit." + self.__class__.__qualname__ )

在类中有多个日志记录器对象可用,这使我们能够精细地控制输出的类型。我们可以配置每个Logger具有不同的handlers。我们将在下一节中使用更高级的配置来将输出定向到不同的目的地。

扩展日志记录级别

logging模块有五个预定义的重要级别。每个级别都有一个全局变量(或两个)与级别数字对应。重要性级别代表了从调试消息(很少重要到足够显示)到关键或致命错误(总是重要)的可选性范围。

Logging 模块变量
DEBUG 10
INFO 20
WARNING or WARN 30
ERROR 40
CRITICAL or FATAL 50

我们可以添加额外的级别,以更精细地控制传递或拒绝哪些消息。例如,一些应用程序支持多个详细级别。同样,一些应用程序包括多个调试详细级别。

对于普通的静默输出,我们可以将日志级别设置为logging.WARNING,这样只会显示警告和错误。对于第一个冗长级别,我们可以将日志级别设置为logging.INFO以查看信息消息。对于第二个冗长级别,我们可能希望添加一个值为 15 的级别,并将根记录器设置为包括这个新级别。

我们可以使用这个来定义我们的新的冗长消息级别:

logging.addLevelName(15, "VERBOSE")
logging.VERBOSE= 15

我们可以通过Logger.log()方法使用我们的新级别,该方法将级别编号作为参数:

self.logger.log( logging.VERBOSE, "Some Message" )

虽然添加这样的级别几乎没有额外开销,但它们可能会被滥用。微妙之处在于级别将多个概念——可见性和错误行为——合并为一个单一的数字代码。级别应该局限于简单的可见性或错误范围。任何更复杂的操作必须通过Logger名称或实际的Filter对象来完成。

为多个目的地定义处理程序

我们有几种用例可以将日志输出发送到多个目的地,这些用例显示在以下项目列表中:

  • 我们可能希望复制日志以提高操作的可靠性。

  • 我们可能会使用复杂的Filter对象来创建不同的消息子集。

  • 我们可能会为每个目的地定义不同的级别。我们可以使用这个来将调试消息与信息消息分开。

  • 我们可能会根据Logger名称使用不同的处理程序来表示不同的焦点。

当然,我们也可以将它们组合起来创建相当复杂的场景。为了创建多个目的地,我们必须创建多个Handlers。每个Handler可能包含一个定制的Formatter;它可以包含一个可选级别和一个可选的过滤器列表,可以应用。

一旦我们有了多个Handlers,我们就可以将Loggers绑定到所需的HandlersLoggers形成一个适当的层次结构;这意味着我们可以使用高级或低级名称将Loggers绑定到Handlers。由于Handlers具有级别过滤器,我们可以有多个处理程序,根据级别显示不同组的消息。此外,如果需要更复杂的过滤,我们还可以明确使用Filter对象。

虽然我们可以通过logging模块 API 进行配置,但通常更清晰的做法是在配置文件中定义大部分日志记录细节。处理这个问题的一种优雅方式是使用配置字典的 YAML 表示法。然后,我们可以使用相对简单的logging.config.dictConfig(yaml.load(somefile))来加载字典。

YAML 表示法比configparser接受的表示法更紧凑。Python 标准库中的logging.config文档使用 YAML 示例,因为它们更清晰。我们将遵循这种模式。

以下是一个配置文件示例,其中包含两个处理程序和两个日志记录器系列:

version: 1
handlers:
  console:
    class: logging.StreamHandler
    stream: ext://sys.stderr
    formatter: basic
  audit_file:
    class: logging.FileHandler
    filename: p3_c14_audit.log
    encoding: utf-8
    formatter: basic
formatters:
  basic:
    style: "{"
    format: "{levelname:s}:{name:s}:{message:s}"
loggers:
  verbose:
    handlers: [console]
    level: INFO
  audit:
    handlers: [audit_file]
    level: INFO

我们定义了两个处理程序:consoleaudit_fileconsole是一个StreamHandler,它被发送到sys.stderr。请注意,我们必须使用ext://sys.stderr的 URI 样式语法来命名外部Python 资源。在这种情况下,外部意味着配置文件之外的外部。默认假设是值是一个简单的字符串,而不是对对象的引用。audit_file是一个FileHandler,将写入给定的文件。默认情况下,文件以a模式打开以进行追加。

我们还定义了格式化程序,名为basic,以生成我们从basicConfig()中获得的日志格式。如果我们不使用这个,我们的消息将使用略有不同的默认格式,只包含消息文本。

最后,我们定义了两个顶级记录器:verboseauditverbose实例将被所有具有verbose顶级名称的记录器使用。然后,我们可以使用verbose.example.SomeClass这样的Logger名称来创建一个是verbose子级的实例。每个记录器都有一个处理程序列表;在这种情况下,每个列表中只有一个元素。此外,我们还为每个记录器指定了日志级别。

这是我们如何加载这个配置文件的方法:

import logging.config
import yaml
config_dict= yaml.load(config)
logging.config.dictConfig(config_dict)

我们将 YAML 文本解析为dict,然后使用dictConfig()函数使用给定的字典配置日志记录。以下是获取记录器和编写消息的一些示例:

verbose= logging.getLogger( "verbose.example.SomeClass" )
audit= logging.getLogger( "audit.example.SomeClass" )
verbose.info( "Verbose information" )
audit.info( "Audit record with before and after" )

我们创建了两个Logger对象,一个在verbose家族树下,另一个在audit家族树下。当我们写入verbose日志记录时,我们将在控制台上看到输出。然而,当我们写入audit日志记录时,我们在控制台上将看不到任何内容;记录将会被发送到配置中命名的文件中。

当我们查看logging.handlers模块时,我们会看到许多处理程序可以利用。默认情况下,logging模块使用旧式的%样式格式规范。这些与str.format()方法的格式规范不同。当我们定义格式化参数时,我们使用了{样式格式化,这与str.format()一致。

管理传播规则

Loggers的默认行为是使日志记录从命名的Logger通过所有父级Loggers传播到根Logger。我们可能有具有特殊行为的低级别Loggers和定义所有Loggers的默认行为的根Logger

由于日志记录会传播,根级别记录器还将处理我们定义的低级别Loggers的任何日志记录。如果子记录器产生输出并允许传播,这将导致重复的输出:首先是子记录器,然后是父记录器。如果我们希望在子记录器产生输出时避免重复,我们必须关闭低级别记录器的传播。

我们之前的示例没有配置根级别Logger。如果我们应用程序的某个部分创建了名称不以audit.verbose.开头的记录器,则该附加记录器将不会与Handler关联。要么我们需要更多的顶级名称,要么我们需要配置一个捕获所有的根级别记录器。

如果我们添加一个根级别记录器来捕获所有这些其他名称,那么我们必须小心传播规则。以下是对配置文件的修改:

loggers:
  verbose:
    handlers: [console]
    level: INFO
    propagate: False # Added
  audit:
    handlers: [audit_file]
    level: INFO
    propagate: False # Added
root: # Added
  handlers: [console]
  level: INFO

我们关闭了两个低级别日志记录器verboseaudit的传播。我们添加了一个新的根级别日志记录器。由于此记录器没有名称,因此这是作为一个单独的顶级字典root:loggers:条目并列完成的。

如果我们没有关闭两个低级别记录器的传播,每个verboseaudit记录都会被处理两次。在审计日志的情况下,双重处理实际上可能是可取的。审计数据将会同时发送到控制台和审计文件。

logging模块的重要之处在于,我们不必对应用程序进行任何更改来完善和控制日志记录。我们几乎可以通过配置文件实现所需的任何操作。由于 YAML 是一种相对优雅的表示法,我们可以非常简单地编码许多功能。

配置陷阱

日志的basicConfig()方法会小心地保留在配置之前创建的任何记录器。然而,logging.config.dictConfig()方法的默认行为是禁用在配置之前创建的任何记录器。

在组装一个大型复杂的应用程序时,我们可能会在import过程中创建模块级别的记录器。主脚本导入的模块可能在创建logging.config之前创建记录器。此外,任何全局对象或类定义可能在配置之前创建记录器。

我们经常不得不在我们的配置文件中添加这样一行:

disable_existing_loggers: False

这将确保在配置之前创建的所有记录器仍然传播到配置创建的根记录器。

专门为控制、调试、审计和安全而记录日志

有许多种类的日志记录;我们将专注于这四种:

  • 错误和控制:应用程序的基本错误和控制会导致一个主要的日志,帮助用户确认程序确实在做它应该做的事情。这将包括足够的错误信息,用户可以根据这些信息纠正问题并重新运行应用程序。如果用户启用了详细日志记录,它将通过附加用户友好的细节放大这个主要的错误和控制日志。

  • 调试:这是由开发人员和维护人员使用的;它可能包括相当复杂的实现细节。我们很少希望启用全面的调试,但通常会为特定模块或类启用调试。

  • 审计:这是一个正式的确认,跟踪应用于数据的转换,以便我们可以确保处理是正确的。

  • 安全:这可以用来显示谁已经经过身份验证;它可以帮助确认授权规则是否被遵循。它还可以用于检测涉及重复密码失败的某些攻击。

我们经常对这些种类的日志有不同的格式和处理要求。此外,其中一些是动态启用和禁用的。主要的错误和控制日志通常是由非 DEBUG 消息构建的。我们可能有一个结构如下代码的应用程序:

from collections import Counter
class Main:
    def __init__( self ):
        self.balance= Counter()
        self.log= logging.getLogger( self.__class__.__qualname__ )
    def run( self ):
        self.log.info( "Start" )

        # Some processing
        self.balance['count'] += 1
        self.balance['balance'] += 3.14

        self.log.info( "Counts {0}".format(self.balance) )

        for k in self.balance:
            self.log.info( "{0:.<16s} {1:n}".format(
                k, self.balance[k]) )

我们创建了一个与类的限定名称(Main)匹配的记录器。我们已经向这个记录器写入了信息消息,以向您展示我们的应用程序正常启动和正常完成。在这种情况下,我们使用Counter来累积一些可以用来确认处理了正确数据量的余额信息。

在某些情况下,我们可能会在处理结束时显示更正式的余额信息。我们可能会这样做,以提供一个稍微易于阅读的显示:

    for k in balance:
        self.log.info( "{0:.<16s} {1:n}".format(k, balance[k]) )

这个版本将在日志中将键和值显示在单独的行上。错误和控制日志通常使用最简单的格式;它可能只显示消息文本,几乎没有额外的上下文。可以使用这样的记录Formatter对象:

formatters:
  control:
    style: "{"
    format: "{levelname:s}:{message:s}"

这将配置格式化程序以显示级别名称(INFOWARNINGERRORCRITICAL)以及消息文本。这消除了许多细节,只提供了用户所需的基本事实。我们称这个格式化程序为控制

在下面的代码中,我们已将控制格式化程序与控制处理程序关联起来:

handlers:
  console:
    class: logging.StreamHandler
    stream: ext://sys.stderr
    formatter: control

这将使用控制 格式化程序控制 处理程序

创建调试日志

调试日志通常由开发人员启用,用于监视正在开发的程序。它通常专注于特定的功能、模块或类。因此,我们通常会通过名称启用和禁用记录器。配置文件可能将一些记录器的级别设置为DEBUG,将其他记录器设置为INFO,或者甚至是WARNING级别。

我们经常会在我们的类中设计调试信息。事实上,我们可能会将调试能力作为类设计的一个特定质量特征。这可能意味着引入一系列丰富的记录请求。例如,我们可能有一个复杂的计算,其中类状态是必要的信息:

@logged
class OneThreeTwoSix( BettingStrategy ):
    def __init__( self ):
        self.wins= 0
    def _state( self ):
        return dict( wins= self.wins )
    def bet( self ):
        bet= { 0: 1, 1: 3, 2: 2, 3: 6 }[self.wins%4]
        self.logger.debug( "Bet {1}; based on {0}".format(self._state(), bet) )
    def record_win( self ):
        self.wins += 1
        self.logger.debug( "Win: {0}".format(self._state()) )
    def record_loss( self ):
        self.wins = 0
        self.logger.debug( "Loss: {0}".format(self._state()) )

在这个类定义中,我们创建了一个_state()方法,它公开了相关的内部状态。这个方法只用于支持调试。我们避免使用self.__dict__,因为这通常包含的信息太多,不够有帮助。然后我们可以在方法函数中的几个地方审计对这个状态信息的更改。

调试输出通常是通过编辑配置文件来选择性地启用的,以在某些地方启用和禁用调试。我们可能会对日志配置文件进行如下更改:

loggers:
    betting.OneThreeTwoSix:
       handlers: [console]
       level: DEBUG
       propagate: False

我们根据类的限定名称确定了特定类的记录器。这个例子假设已经定义了一个名为console的处理程序。此外,我们关闭了传播,以防止调试消息被复制到根记录器中。

在这个设计中隐含的是,调试不是我们希望仅通过简单的-D选项或--DEBUG选项从命令行启用的东西。为了进行有效的调试,我们经常希望通过配置文件启用选定的记录器。我们将在第十六章 处理命令行中讨论命令行问题。

创建审计和安全日志

审计和安全日志经常在两个处理程序之间重复:主控制处理程序加上一个用于审计和安全审查的文件处理程序。这意味着我们将做以下事情:

  • 为审计和安全定义额外的记录器

  • 为这些记录器定义多个处理程序

  • 可选地,为审计处理程序定义额外的格式

如前所示,我们经常会创建“审计”或“安全”日志的单独层次结构。创建单独的记录器层次结构比尝试通过新的日志级别引入审计或安全要简单得多。添加新级别是具有挑战性的,因为这些消息本质上是INFO消息;它们不属于INFO一侧的WARNING,因为它们不是错误,也不属于INFO一侧的DEBUG,因为它们不是可选的。

这是一个装饰器,可以用来构建包括审计的类:

def audited( class_ ):
    class_.logger= logging.getLogger( class_.__qualname__ )
    class_.audit= logging.getLogger( "audit." + class_.__qualname__ )
    return class_

这创建了两个记录器。一个记录器的名称仅基于类的限定名称。另一个记录器使用限定名称,但带有一个前缀,将其放在“审计”层次结构中。以下是我们如何使用这个装饰器:

@audited
class Table:
    def bet( self, bet, amount ):
        self.audit.info( "Bet {0} Amount {1}".format(bet, amount) )

我们创建了一个类,它将在“审计”层次结构中的记录器上生成记录。我们可以配置日志记录以处理这些额外的记录器层次结构。我们将看看我们需要的两个处理程序:

handlers:
  console:
    class: logging.StreamHandler
    stream: ext://sys.stderr
    formatter: basic
  audit_file:
    class: logging.FileHandler
    filename: p3_c14_audit.log
    encoding: utf-8
    formatter: detailed

console处理程序具有使用basic格式的面向用户的日志条目。 audit_file处理程序使用名为“详细”的更复杂的格式化程序。以下是这些“处理程序”引用的两个“格式化程序”:

formatters:
  basic:
    style: "{"
    format: "{levelname:s}:{name:s}:{message:s}"
  detailed:
    style: "{"
    format: "{levelname:s}:{name:s}:{asctime:s}:{message:s}"
    datefmt: "%Y-%m-%d %H:%M:%S"

basic格式只显示消息的三个属性。 “详细”格式规则有些复杂,因为日期格式化是单独完成的。 datetime模块使用%样式格式化。我们使用{样式格式化整体消息。以下是两个Logger定义:

loggers:
  audit:
    handlers: [console,audit_file]
    level: INFO
    propagate: True
root:
  handlers: [console]
  level: INFO

我们为“审计”层次结构定义了一个记录器。所有“审计”的子记录器都会将它们的消息写入console Handleraudit_file Handler。根记录器将定义所有其他记录器仅使用控制台。现在我们将看到审计消息的两种形式。

控制台可能包含这样的行:

INFO:audit.Table:Bet One Amount 1
INFO:audit.Table:Bet Two Amount 2

审计文件可能如下所示:

INFO:audit.Table:2013-12-29 10:24:57:Bet One Amount 1
INFO:audit.Table:2013-12-29 10:24:57:Bet Two Amount 2

这种重复使我们能够在主控制台日志的上下文中获得审计信息,以及在单独的日志中获得专注的审计跟踪,可以保存以供以后分析。

使用警告模块

面向对象的开发通常涉及对类或模块进行重大重构。第一次编写应用程序时,很难完全正确地获得 API。事实上,为了完全正确地获得 API 所需的设计时间可能会被浪费:Python 的灵活性允许我们在更多了解问题领域和用户需求时进行更改。

支持设计演变的工具之一是warnings模块。warnings有两个明确的用例和一个模糊的用例:

  • 警告开发人员 API 更改,通常是弃用或即将弃用的功能。弃用和即将弃用的警告默认是静默的。运行unittest模块时,这些消息不会静默;这有助于我们确保我们正确使用了升级的库包。

  • 提醒用户配置问题。例如,可能有几种模块的替代实现:当首选实现不可用时,我们可能希望提供警告,表明未使用最佳实现。

  • 我们可能通过警告用户计算结果可能存在其他问题来推动应用的边界。我们的应用程序可能以多种方式表现出来。

对于前两种用例,我们通常会使用 Python 的warnings模块来向您显示可纠正的问题。对于第三种模糊的用例,我们可能会使用logger.warn()方法来警告用户可能存在的问题。我们不应该依赖warnings模块,因为默认行为是只显示一次警告。

我们可能在应用程序中看到以下任何行为:

  • 理想情况下,我们的应用程序正常完成并且一切正常。结果是明确有效的。

  • 应用程序产生警告消息,但正常完成;警告消息意味着结果不可信。任何输出文件都将可读,但质量或完整性可能有问题。这可能会让用户感到困惑;我们将在下一节中漫游在这些特定模糊性的泥沼中,显示可能的软件问题与警告部分。

  • 应用程序可能产生错误消息,但仍然得出有序的结论。很明显,结果是明显错误的,不应该用于除调试之外的任何其他用途。logging模块允许我们进一步细分错误。产生错误的程序可能仍然得出有序的结论。我们经常使用CRITICAL(或FATAL)错误消息来指示 Python 程序可能没有正确终止,任何输出文件可能已损坏。我们经常将CRITICAL消息保留给顶层的try:块。

  • 应用程序可能在操作系统级别崩溃。在这种情况下,Python 的异常处理或日志中可能没有消息。这也很明显,因为没有可用的结果。

可疑结果的第二种意义并不是一个好的设计。使用警告——无论是通过warnings模块还是logging中的WARN消息——并不能真正帮助用户。

显示警告的 API 更改

当我们更改模块、包或类的 API 时,我们可以通过warnings模块提供一个方便的标记。这将在被弃用或即将被弃用的方法中引发警告:

import warnings
class Player:
    __version__= "2.2"
    def bet( self ):
        warnings.warn( "bet is deprecated, use place_bet", DeprecationWarning, stacklevel=2 )
        etc.

当我们这样做时,应用程序中使用Player.bet()的任何部分都将收到DeprecationWarning。默认情况下,此警告是静默的。但是,我们可以调整warnings过滤器以查看消息,如下所示:

>>> warnings.simplefilter("always", category=DeprecationWarning)
>>> p2= Player()
>>> p2.bet()
__main__:4: DeprecationWarning: bet is deprecated, use place_bet

这种技术使我们能够找到我们的应用程序必须因 API 更改而进行更改的所有位置。如果我们的单元测试用例覆盖了接近 100%的代码,这种简单的技术很可能会揭示所有弃用方法的用法。

由于这对规划和管理软件更改非常有价值,我们有三种方法可以确保我们在应用程序中看到所有警告:

  • 命令行-Wd选项将为所有警告设置操作为default。这将启用通常静默的弃用警告。当我们运行python3.3 -Wd时,我们将看到所有弃用警告。

  • 使用unittest,它总是以warnings.simplefilter('default')模式执行。

  • 在我们的应用程序中包括warnings.simplefilter('default')。这也将对所有警告应用default操作;这相当于-Wd命令行选项。

显示警告的配置问题

对于给定的类或模块,我们可能有多个实现。我们通常会使用配置文件参数来决定哪个实现是合适的。有关此技术的更多信息,请参见第十三章,“配置文件和持久性”。

然而,在某些情况下,应用程序可能悄悄地依赖于其他软件包是否属于 Python 安装的一部分。一个实现可能是最佳的,另一个实现可能是备用计划。一个常见的技术是尝试多个import替代项来定位已安装的软件包。我们可以生成警告,显示可能的配置困难。以下是管理此替代实现导入的方法:

import warnings
try:
    import simulation_model_1 as model
except ImportError as e:
    warnings.warn( e )
if 'model' not in globals():
    try:
        import simulation_model_2 as model
    except ImportError as e:
        warnings.warn( e )
if 'model' not in globals():
    raise ImportError( "Missing simulation_model_1 and simulation_model_2" )

我们尝试导入一个模块。如果失败,我们将尝试另一个导入。我们使用if语句来减少异常的嵌套。如果有两个以上的选择,嵌套异常可能会导致异常看起来非常复杂。通过使用额外的if语句,我们可以使长序列的选择变得扁平,以便异常不会嵌套。

我们可以通过更改消息的类来更好地管理此警告消息。在前面的代码中,这将是UserWarning。这些默认显示,为用户提供了一些证据表明配置不是最佳的。

如果我们将类更改为ImportWarning,它将默认保持静默。这在用户对软件包的选择无关紧要的情况下提供了通常的静默操作。运行-Wd选项的典型开发人员技术将显示ImportWarning消息。

要更改警告的类,我们更改对warnings.warn()的调用:

warnings.warn( e, ImportWarning )

这将把警告更改为默认情况下保持静默的类。消息仍然可以对应该使用-Wd选项的开发人员可见。

显示可能的软件问题与警告

面向最终用户的警告的概念有点模糊:应用程序是工作还是失败了?警告真正意味着什么?用户应该做出不同的反应吗?

由于潜在的模棱两可性,用户界面中的警告不是一个好主意。为了真正可用,程序应该要么正确工作,要么根本不工作。当出现错误时,错误消息应包括用户对问题的响应建议。我们不应该让用户承担评估输出质量并确定其适用性的负担。我们将强调这一点。

提示

程序应该要么正确工作,要么根本不工作

端用户警告的一个潜在明确的用途是警告用户输出不完整。例如,应用程序可能在完成网络连接时出现问题。基本结果是正确的,但其中一个数据源未能正常工作。

有些情况下,应用程序正在执行的操作与用户请求的操作不同,输出是有效且有用的。在网络问题的情况下,使用了默认行为而不是基于网络资源的行为。通常,用正确的东西替换有问题的东西,但不完全符合用户请求的行为是警告的一个很好的候选。这种警告最好使用logging在 WARN 级别进行,而不是使用warnings模块。警告模块产生一次性消息;我们可能需要向用户提供更多细节。以下是我们如何使用简单的“Logger.warn()”消息在日志中描述问题:

try:
    with urllib.request.urlopen("http://host/resource/", timeout= 30 ) as resource:
        content= json.load(resource)
except socket.timeout as e:
    self.log.warn("Missing information from  http://host/resource")
    content= []

如果发生超时,将向日志写入警告消息,并且程序将继续运行。资源的内容将设置为空列表。每次都会写入日志消息。通常,warnings模块警告只会从程序中的给定位置显示一次,之后就会被抑制。

高级日志 - 最后几条消息和网络目的地

我们将研究另外两种高级技术,可以帮助提供有用的调试信息。其中之一是日志尾巴:这是在某个重要事件之前的最后几条日志消息的缓冲区。这个想法是有一个小文件,可以读取以查看应用程序死机之前的最后几条日志消息。这有点像自动应用于完整日志输出的操作系统tail命令。

第二种技术使用日志框架的一个特性,将日志消息通过网络发送到集中的日志处理服务。这可以用于 consoli 日志来自多个并行 web 服务器。我们需要为日志创建发送方和接收方。

构建自动尾部缓冲区

日志尾缓冲区是logging框架的扩展。我们将扩展MemoryHandler以略微改变其行为。内置的MemoryHandler行为包括三种写入用例:当达到容量时,它将写入另一个handler;当logging关闭时,它将写入任何缓冲消息;最重要的是,当记录了给定级别的消息时,它将写入整个缓冲区。

我们将略微更改第一个用例。我们不会在缓冲区满时写入,而是只删除最旧的消息,保留缓冲区中的其他消息。其他两个用例将保持不变。这将导致在关闭之前倾倒最后几条消息,以及在错误之前倾倒最后几条消息。

我们经常会配置内存处理程序,直到记录了大于或等于错误级别的消息之前,才会缓冲消息。这将导致以错误结束的倾倒缓冲区。

要理解这个示例,重要的是要找到您的 Python 安装位置,并详细查看logging.handlers模块。

这个对MemoryHandler的扩展将保留最后几条消息,基于在创建TailHandler类时定义的容量:

class TailHandler(logging.handlers.MemoryHandler):
    def shouldFlush(self, record):
        """
        Check for buffer full or a record at the flushLevel or higher.
        """
        if record.levelno >= self.flushLevel: return True
        while len(self.buffer) >= self.capacity:
            self.acquire()
            try:
                del self.buffer[0]
            finally:
                self.release()

我们扩展了MemoryHandler,以便它将累积日志消息直到达到给定的容量。当达到容量时,旧消息将被删除,新消息将被添加。请注意,我们必须锁定数据结构以允许多线程记录。

如果接收到具有适当级别的消息,则整个结构将被发送到目标处理程序。通常,目标是FileHandler,用于将日志写入尾文件以进行调试和支持。

此外,当logging关闭时,最后几条消息也将写入尾文件。这应该表明正常终止,不需要任何调试或支持。

通常,我们会将DEBUG级别的消息发送到这种处理程序,以便在崩溃情况下获得大量细节。配置应明确将级别设置为DEBUG,而不是允许级别默认设置。

以下是使用此TailHandler的配置:

version: 1
disable_existing_loggers: False
handlers:
  console:
    class: logging.StreamHandler
    stream: ext://sys.stderr
    formatter: basic
  tail:
    (): __main__.TailHandler
    target: cfg://handlers.console
    capacity: 5
formatters:
  basic:
    style: "{"
    format: "{levelname:s}:{name:s}:{message:s}"
loggers:
  test:
    handlers: [tail]
    level: DEBUG
    propagate: False
root:
  handlers: [console]
  level: INFO

TailHandler的定义向我们展示了logging配置的几个附加特性。它向我们展示了类引用以及配置文件的其他元素。

我们在配置中引用了自定义类定义。标签“()”指定该值应解释为模块和类名。在这种情况下,它是我们的__main__.TailHandler类的一个实例。而不是“()”的class标签使用了logging包中的模块和类。

我们在配置中引用了另一个记录器。在前面的配置文件中,cgf://handlers.console文本指的是配置文件中handlers部分中定义的console处理程序。为了演示目的,我们使用了一个使用sys.stderrStreamHandler作为 tail 目标。如前所述,另一种设计可能是使用一个将目标定位到调试文件的FileHandler

我们创建了使用我们的tail处理程序的test日志记录器层次结构。写入这些记录器的消息将被缓冲,并且只在错误或关闭时显示。

以下是演示脚本:

logging.config.dictConfig( yaml.load(config8) )
log= logging.getLogger( "test.demo8" )

print( "Last 5 before error" )
for i in range(20):
    log.debug( "Message {:d}".format(i) )
log.error( "Error causes dump of last 5" )

print( "Last 5 before shutdown" )
for i in range(20,40):
    log.debug( "Message {:d}".format(i) )
logging.shutdown()

在错误之前,我们生成了 20 条消息。然后,在关闭日志记录并刷新缓冲区之前,我们生成了 20 条消息。这将产生以下输出:

Last 5 before error
DEBUG:test.demo8:Message 16
DEBUG:test.demo8:Message 17
DEBUG:test.demo8:Message 18
DEBUG:test.demo8:Message 19
ERROR:test.demo8:Error causes dump of last 5
Last 5 before shutdown
DEBUG:test.demo8:Message 36
DEBUG:test.demo8:Message 37
DEBUG:test.demo8:Message 38
DEBUG:test.demo8:Message 39

中间消息被tail处理程序静默丢弃。由于容量设置为五,因此在错误(或关闭)之前的最后五条消息将被显示。

将日志消息发送到远程进程

一种高性能的设计模式是拥有一组进程,用于解决单个问题。我们可能有一个应用程序分布在多个应用程序服务器或多个数据库客户端上。对于这种类型的架构,我们经常希望在所有各种进程之间有一个集中的日志。

创建统一日志的一种技术是包括准确的时间戳,然后将来自多个日志文件的记录排序到单个统一日志中。这种排序和合并是额外的处理,可以通过从多个并发生产者进程远程记录到单个消费者进程来避免。

我们的共享日志解决方案利用了multiprocessing模块中的共享队列。有关多进程的更多信息,请参见第十二章,“传输和共享对象”。

构建多进程应用程序的三个步骤:

  • 首先,我们将创建共享队列对象,以便日志消费者可以对消息应用过滤器。

  • 其次,我们将创建消费者进程,从队列中获取日志记录。

  • 其次,我们将创建源进程池,这些进程将执行我们应用程序的实际工作,并将日志记录生成到共享队列中。

ERRORFATAL消息可以通过短信或电子邮件立即通知相关用户。消费者还可以处理与旋转日志文件相关的(相对)较慢的处理。

创建生产者和消费者的整体父应用程序大致类似于启动各种操作系统级进程的 Linuxinit程序。如果我们遵循init设计模式,那么父应用程序可以监视各种生产者子进程,以查看它们是否崩溃,并且可以记录相关错误,甚至尝试重新启动它们。

以下是消费者进程的定义:

import collections
import logging
import multiprocessing
class Log_Consumer_1(multiprocessing.Process):
    """In effect, an instance of QueueListener."""
    def __init__( self, queue ):
        self.source= queue
        super().__init__()
        logging.config.dictConfig( yaml.load(consumer_config) )
        self.combined= logging.getLogger(
            "combined." + self.__class__.__qualname__ )
        self.log= logging.getLogger( self.__class__.__qualname__  )
        self.counts= collections.Counter()
    def run( self ):
        self.log.info( "Consumer Started" )
        while True:
            log_record= self.source.get()
            if log_record == None: break
            self.combined.handle( log_record )
            words= log_record.getMessage().split()
            self.counts[words[1]] += 1
        self.log.info( "Consumer Finished" )
        self.log.info( self.counts )

这个过程是multiprocessing.Process的子类。我们将使用“start()”方法启动它;超类将 fork 一个执行“run()”方法的子进程。

在进程运行时,它将从队列中获取日志记录,然后将它们路由到一个记录器实例。在这种情况下,我们将创建一个名为combined.的特殊记录器,这将为来自源进程的每条记录提供一个父名称。

此外,我们将根据每条消息的第二个单词提供一些计数。在这个例子中,我们设计了应用程序,使得第二个单词将是消息文本中的进程 ID 号。这些计数将显示我们正确处理了多少条消息。

这是用于此过程的logging配置文件:

version: 1
disable_existing_loggers: False
handlers:
  console:
    class: logging.StreamHandler
    stream: ext://sys.stderr
    formatter: basic
formatters:
  basic:
    style: "{"
    format: "{levelname:s}:{name:s}:{message:s}"
loggers:
  combined:
    handlers: [ console ]
    formatter: detail
    level: INFO
    propagate: False
root:
  handlers: [ console ]
  level: INFO

我们定义了一个简单的控制台Logger,具有基本格式。我们还定义了以combined.开头的名称的日志记录器层次结构的顶层。这些记录器将用于显示各个生产者的组合输出。

这是日志生产者:

class Log_Producer(multiprocessing.Process):
    handler_class= logging.handlers.QueueHandler
    def __init__( self, proc_id, queue ):
        self.proc_id= proc_id
        self.destination= queue
        super().__init__()
 **self.log= logging.getLogger(
 **"{0}.{1}".format(self.__class__.__qualname__, self.proc_id) )
 **self.log.handlers = [ self.handler_class( self.destination ) ]
 **self.log.setLevel( logging.INFO )
    def run( self ):
        self.log.info( "Producer {0} Started".format(self.proc_id) )
        for i in range(100):
            self.log.info( "Producer {:d} Message {:d}".format(self.proc_id, i) )
        self.log.info( "Producer {0} Finished".format(self.proc_id) )

生产者在配置方面并没有做太多。它只是获取一个用于限定类名和实例标识符(self.proc_id)的记录器。它设置要包裹在Queue实例周围的QueueHandler的处理程序列表。此记录器的级别设置为INFO

我们将handler_class作为类定义的属性,因为我们计划对其进行更改。对于第一个示例,它将是logging.handlers.QueueHandler。对于以后的示例,我们将更改为另一个类。

实际执行此工作的过程使用记录器创建日志消息。这些消息将被加入队列以供集中消费者处理。在这种情况下,该过程只是尽可能快地向队列中注入 102 条消息。

这是我们如何启动消费者和生产者的方法。我们将分步显示。首先,我们创建队列:

import multiprocessing
queue= multiprocessing.Queue(100)

这个队列太小了,无法处理 10 个生产者在一秒钟内发送 102 条消息。小队列的想法是看看当消息丢失时会发生什么。这是我们启动消费者进程的方法:

consumer = Log_Consumer_1( queue )
consumer.start()

这是如何启动一系列生产者进程的方法:

producers = []
for i in range(10):
    proc= Log_Producer( i, queue )
    proc.start()
    producers.append( proc )

如预期的那样,10 个并发生产者将使队列溢出。每个生产者将收到一些异常的队列,以向我们显示消息已丢失。

这是如何清理完成处理的方法:

for p in producers:
    p.join()
queue.put( None )
consumer.join()

首先,我们等待每个生产者进程完成,然后重新加入父进程。然后,我们将一个标记对象放入队列,以便消费者能够干净地终止。最后,我们等待消费者进程完成并重新加入父进程。

防止队列溢出

日志模块的默认行为是使用Queue.put_nowait()方法将消息放入队列。这样做的好处是允许生产者在不受日志记录延迟的情况下运行。这样做的缺点是,如果队列太小无法处理最坏情况下的日志记录消息突发,消息将会丢失。

我们有两种选择来优雅地处理这些消息的突发情况:

  • 我们可以从Queue切换到SimpleQueueSimpleQueue的大小是不确定的。由于它具有稍微不同的 API,我们需要扩展QueueHandler以使用Queue.put()而不是Queue.put_nowait()

  • 在罕见情况下,如果队列已满,我们可以减慢生产者的速度。这是对QueueHandler的一个小改动,使用Queue.put()而不是Queue.put_nowait()

有趣的是,相同的 API 更改对QueueSimpleQueue都适用。这是更改:

class WaitQueueHandler( logging.handlers.QueueHandler ):
    def enqueue(self, record):
        self.queue.put( record )

我们替换了enqueue()方法的主体,以使用Queue的不同方法。现在,我们可以使用SimpleQueueQueue。如果我们使用Queue,它将在队列满时等待,防止日志消息丢失。如果我们使用SimpleQueue,队列将悄悄地扩展以容纳所有消息。

这是修改后的生产者类:

class Log_Producer_2(Log_Producer):
    handler_class= WaitQueueHandler

这个类使用我们的新WaitQueueHandler。否则,生产者与之前的版本相同。

创建Queue和启动消费者的其余脚本是相同的。生产者是Log_Producer_2的实例,但启动和加入的脚本与第一个示例相同。

这种变化运行速度更慢,但永远不会丢失消息。我们可以通过创建更大的队列容量来提高性能。如果我们创建一个容量为 1,020 条消息的队列,那么对于这个示例来说,性能是最大化的。找到最佳队列容量需要仔细的实验。

总结

我们看到了如何使用日志记录模块与更高级的面向对象设计技术。我们创建了与模块、类、实例和函数相关的日志。我们使用装饰器来创建日志记录,作为跨多个类定义的一致性横切方面。

我们看到了如何使用warnings模块来显示配置或弃用方法存在问题。我们可以使用警告来进行其他用途,但我们需要谨慎使用警告,并避免创建模糊的情况,以便不清楚应用程序是否正常工作。

设计考虑和权衡

logging模块支持可审计性和调试能力,以及一些安全要求。我们可以使用日志记录作为保留处理步骤记录的简单方式。通过选择性地启用和禁用日志记录,我们可以支持试图了解处理真实世界数据时代码实际操作的开发人员。

warnings模块支持调试能力以及可维护性特性。我们可以使用警告来警告开发人员有关 API 问题、配置问题和其他潜在的错误来源。

在使用logging模块时,我们经常会创建大量不同的日志记录器,这些记录器会向少数handlers提供信息。我们可以利用Logger名称的分层性质来引入新的或专门的日志消息集合。一个类可以有两个日志记录器:一个用于审计,一个用于更通用的调试,这并没有什么不可以。

我们可以引入新的日志级别数字,但这应该是勉强的。级别往往会混淆开发人员关注的内容(调试、信息、警告)和用户关注的内容(信息、错误、致命)。从不需要对致命错误消息进行静默处理的调试消息到致命错误消息之间存在一种可选性的谱系。我们可以添加一个用于详细信息或可能的详细调试的级别,但这就是级别应该做的全部。

logging模块允许我们为不同的目的提供多个配置文件。作为开发人员,我们可以使用一个设置日志级别为DEBUG并启用特定模块下的日志记录器的配置文件。对于最终部署,我们可以提供一个将日志级别设置为INFO并提供不同处理程序以支持更正式审计或安全审查需求的配置文件。

我们将包括一些来自Python 之禅的思考:

错误永远不应该悄悄地传递。除非明确地被消除。

warningslogging模块直接支持这个想法。

这些模块更多地面向整体质量,而不是解决问题的具体解决方案。它们允许我们通过相当简单的编程提供一致性。随着我们的面向对象设计变得越来越大和复杂,我们可以更多地专注于解决的问题,而不是浪费时间在基础设施考虑上。此外,这些模块允许我们定制输出,以提供开发人员或用户所需的信息。

展望未来

在接下来的章节中,我们将看看如何设计可测试性以及如何使用unittestdoctest。自动化测试是必不可少的;除非有自动化单元测试提供充分的证据表明代码有效,否则编程不应被认为是完成的。我们将研究使软件更易于测试的面向对象设计技术。

第十五章:可测试性设计

高质量的程序都有自动化测试。我们需要利用一切手段来确保我们的软件工作正常。黄金法则是:要交付的特性必须有单元测试

没有自动化单元测试,特性就不能被信任,也不应该被使用。根据肯特·贝克在《极限编程解释》中的说法:

“任何没有自动化测试的程序特性都不存在。”

关于程序特性的自动化测试有两个关键点:

  • 自动化:这意味着没有人为判断。测试涉及一个脚本,比较实际响应和预期响应。

  • 特性:这些是被单独测试以确保它们单独工作的。这是单元测试,其中每个“单元”都有足够的软件来实现给定的特性。理想情况下,它是一个小单元,比如一个类。但是,它也可以是一个更大的单元,比如一个模块或包。

Python 有两个内置的测试框架,可以轻松编写自动化单元测试。我们将研究如何使用doctestunittest进行自动化测试。我们将研究一些必要的设计考虑因素,以使测试变得实用。

想了解更多,请阅读Ottinger 和 LangrFIRST单元测试属性:快速隔离可重复自我验证及时。在很大程度上,可重复和自我验证需要一个自动化测试框架。及时意味着测试是在被测试的代码之前编写的。请参阅pragprog.com/magazines/2012-01/unit-tests-are-first

定义和隔离用于测试的单元

由于测试是必不可少的,可测试性是一个重要的设计考虑因素。我们的设计也必须支持测试和调试,因为一个看似工作的类是没有价值的。一个有证据证明它工作的类更有价值。

理想情况下,我们希望有一个测试的层次结构。在基础层是单元测试。在这里,我们测试每个类或函数,以确保它满足 API 的契约义务。每个类或函数都是一个单独的被测试单元。在这之上是集成测试。一旦我们知道每个类和函数都单独工作,我们就可以测试类的组和集群。我们也可以测试整个模块和整个包。在集成测试工作之后,我们可以看看完整应用的自动化测试。

这并不是测试类型的详尽列表。我们也可以进行性能测试或安全漏洞测试。然而,我们将专注于自动化单元测试,因为它对所有应用程序都至关重要。这种测试层次结构揭示了一个重要的复杂性。对于单个类或类组的测试用例可以非常狭义地定义。当我们将更多的单元引入集成测试时,输入的领域就会增长。当我们尝试测试整个应用程序时,整个人类行为的范围都成为候选输入;这包括在测试中关闭设备、拔掉插头,以及将东西从桌子上推下去,看看它们在从硬木地板上掉下三英尺后是否仍然工作。行为领域的巨大使得完全自动化应用程序测试变得困难。

我们将专注于那些最容易自动化测试的事物。一旦单元测试工作,更大的、聚合的系统更有可能工作。

最小化依赖关系

当我们设计一个类时,我们必须考虑该类周围的依赖网络:它所依赖的类和依赖它的类。为了简化对类定义的测试,我们需要将其与周围的类隔离开来。

一个例子是Deck类依赖于Card类。我们可以很容易地单独测试Card,但是当我们想要测试Deck类时,我们需要将其从Card的定义中分离出来。

这是我们之前看过的Card的一个(多个)先前定义:

class Card:
    def __init__( self, rank, suit, hard=None, soft=None ):
        self.rank= rank
        self.suit= suit
        self.hard= hard or int(rank)
        self.soft= soft or int(rank)
    def __str__( self ):
        return "{0.rank!s}{0.suit!s}".format(self)

class AceCard( Card ):
    def __init__( self, rank, suit ):
        super().__init__( rank, suit, 1, 11 )

class FaceCard( Card ):
    def __init__( self, rank, suit ):
        super().__init__( rank, suit, 10, 10 )

我们可以看到这些类中的每一个都有一个简单的继承依赖关系。每个类都可以独立测试,因为只有两个方法和四个属性。

我们可以(误)设计一个Deck类,其中存在一些问题的依赖关系:

Suits = '♣', '♦', '♥', '♠'
class Deck1( list ):
    def __init__( self, size=1 ):
        super().__init__()
        self.rng= random.Random()
        for d in range(size):
            for s in Suits:
                cards = ([AceCard(1, s)]
                + [Card(r, s) for r in range(2, 12)]
                + [FaceCard(r, s) for r in range(12, 14)])
                super().extend( cards )
        self.rng.shuffle( self )

这个设计有两个缺陷。首先,它与Card类层次结构中的三个类紧密相关。我们无法将DeckCard隔离以进行独立的单元测试。其次,它依赖于随机数生成器,这使得创建可重复的测试变得困难。

一方面,Card是一个非常简单的类。我们可以测试Deck的这个版本,同时保留Card。另一方面,我们可能希望重用具有不同行为的扑克牌或皮诺克尔牌的Deck,而不是黑杰克牌。

理想情况是使Deck独立于任何特定的Card实现。如果我们做得好,那么我们不仅可以独立于任何Card实现测试Deck,还可以使用任何CardDeck定义的组合。

这是我们首选的分离依赖项的方法。我们可以使用一个工厂函数:

def card( rank, suit ):
    if rank == 1: return AceCard( rank, suit )
    elif 2 <= rank < 11: return Card( rank, suit )
    elif 11 <= rank < 14: return FaceCard( rank, suit )
    else: raise Exception( "LogicError" )

card()函数将根据请求的等级构建Card的适当子类。这允许Deck类使用此函数,而不是直接构建Card类的实例。我们通过插入一个中间函数来分离这两个类定义。

我们有其他技术来将Card类与Deck类分离。我们可以重构工厂函数成为Deck的一个方法。我们还可以通过类级属性或初始化方法参数使类名成为一个单独的绑定。

以下是一个避免使用工厂函数的示例,而是在初始化方法中使用更复杂的绑定:

class Deck2( list ):
    def __init__( self, size=1,
        random=random.Random(),
        ace_class=AceCard, card_class=Card, face_class=FaceCard ):
        super().__init__()
        self.rng= random
        for d in range(size):
            for s in Suits:
                cards =  ([ace_class(1, s)]
                + [ card_class(r, s) for r in range(2, 12) ]
                + [ face_class(r, s) for r in range(12, 14) ] )
                super().extend( cards )
        self.rng.shuffle( self )

虽然这种初始化方式很啰嗦,但Deck类并没有与Card类层次结构或特定的随机数生成器紧密绑定。为了测试目的,我们可以提供一个具有已知种子的随机数生成器。我们还可以用其他类(如tuple)替换各种Card类定义,以简化我们的测试。

在接下来的部分中,我们将专注于Deck类的另一种变体。这将使用card()工厂函数。该工厂函数封装了Card层次结构绑定和将卡类按等级分离的规则,使其成为一个可测试的位置。

创建简单的单元测试

我们将创建一些Card类层次结构和card()工厂函数的简单单元测试。

由于Card类非常简单,没有理由进行过于复杂的测试。总是有可能在不必要的复杂性方面出错。在测试驱动的开发过程中,盲目地进行开发似乎需要为一个只有少量属性和方法的类编写相当多而不是很有趣的单元测试。

重要的是要理解,测试驱动的开发是建议,而不是质量守恒定律之类的自然法则。它也不是一个必须毫无思考地遵循的仪式。

关于命名测试方法有几种不同的观点。我们将强调一种包括描述测试条件和预期结果的命名风格。以下是这个主题的三种变体:

  • 我们可以使用由_should_分隔的两部分名称,例如StateUnderTest_should_ExpectedBehavior。我们总结状态和响应。我们将专注于这种形式的名称。

  • 我们可以使用when__should_的两部分名称,例如when_StateUnderTest_should_ExpectedBehavior。我们仍然总结状态和响应,但我们提供了更多的语法。

  • 我们可以使用三部分名称,UnitOfWork_StateUnderTest_ExpectedBehavior。这包括正在测试的单元,这可能有助于阅读测试输出日志。

有关更多信息,请阅读osherove.com/blog/2005/4/3/naming-standards-for-unit-tests.html

可以配置unittest模块以使用不同的模式来发现测试方法。我们可以将其更改为查找when_。为了保持简单,我们将依赖于测试方法名称以test开头的内置模式。

例如,这是对Card类的一个测试:

class TestCard( unittest.TestCase ):
    def setUp( self ):
        self.three_clubs= Card( 3, '♣' )
    def test_should_returnStr( self ):
        self.assertEqual( "3♣", str(self.three_clubs) )
    def test_should_getAttrValues( self ):
        self.assertEqual( 3, self.three_clubs.rank )
        self.assertEqual( "♣", self.three_clubs.suit )
        self.assertEqual( 3, self.three_clubs.hard )
        self.assertEqual( 3, self.three_clubs.soft )

我们定义了一个测试setUp()方法,它创建了一个正在测试的类的对象。我们还对这个对象定义了两个测试。由于这里没有真正的交互,测试名称中没有正在测试的状态:它们是简单的通用行为,应该始终有效。

有些人问,这种测试是否过多,因为测试比应用程序代码更多。答案是否定的;这并不过多。没有法律规定应该有更多的应用程序代码而不是测试代码。事实上,比较测试和应用程序代码的数量是没有意义的。最重要的是,即使一个小的类定义仍然可能存在错误。

简单地测试属性的值似乎并不能测试这个类中的处理。在测试属性值时有两种观点,如下例所示:

  • 黑盒视角意味着我们忽略了实现。在这种情况下,我们需要测试所有属性。这些属性可能是属性,它们必须经过测试。

  • 白盒视角意味着我们可以检查实现细节。在进行这种风格的测试时,我们可以更加谨慎地决定测试哪些属性。例如,suit属性并不值得太多测试。然而,hardsoft属性确实需要测试。

有关更多信息,请参阅en.wikipedia.org/wiki/White-box_testingen.wikipedia.org/wiki/Black-box_testing

当然,我们需要测试Card类层次结构的其余部分。我们将只展示AceCard测试用例。在这个例子之后,FaceCard测试用例应该很清楚:

class TestAceCard( unittest.TestCase ):
    def setUp( self ):
        self.ace_spades= AceCard( 1, '♠' )
    def test_should_returnStr( self ):
        self.assertEqual( "A♠", str(self.ace_spades) )
    def test_should_getAttrValues( self ):
        self.assertEqual( 1, self.ace_spades.rank )
        self.assertEqual( "♠", self.ace_spades.suit )
        self.assertEqual( 1, self.ace_spades.hard )
        self.assertEqual( 11, self.ace_spades.soft )

这个测试用例还设置了一个特定的Card实例,以便我们可以测试字符串输出。它检查了这张固定卡的各种属性。

创建一个测试套件

正式定义测试套件通常是有帮助的。unittest包默认可以发现测试。当从多个测试模块聚合测试时,有时最好在每个测试模块中创建一个测试套件。如果每个模块定义了一个suite()函数,我们可以用每个模块导入suite()函数来替换测试发现。此外,如果我们自定义了TestRunner,我们必须使用一个测试套件。我们可以按照以下方式执行我们的测试:

def suite2():
    s= unittest.TestSuite()
    load_from= unittest.defaultTestLoader.loadTestsFromTestCase
    s.addTests( load_from(TestCard) )
    s.addTests( load_from(TestAceCard) )
    s.addTests( load_from(TestFaceCard) )
    return s

我们从三个TestCases类定义中构建了一个测试套件,然后将该套件提供给unittest.TextTestRunner()实例。我们使用了unittest中的默认TestLoader。这个TestLoader检查TestCase类以定位所有测试方法。TestLoader.testMethodPrefix的值是test,这是类中标识测试方法的方式。加载器使用每个方法名来创建一个单独的测试对象。

使用TestLoaderTestCase的适当命名方法构建测试实例是使用TestCases的两种方法之一。在后面的部分中,我们将看看如何手动创建TestCase的实例;我们不会依赖TestLoader来进行这些示例。我们可以像以下代码一样运行这个测试套件:

if __name__ == "__main__":
    t= unittest.TextTestRunner()
    t.run( suite2() )

我们将看到以下代码的输出:

...F.F
======================================================================
FAIL: test_should_returnStr (__main__.TestAceCard)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "p3_c15.py", line 80, in test_should_returnStr
    self.assertEqual( "A♠", str(self.ace_spades) )
AssertionError: 'A♠' != '1♠'
- A♠
+ 1♠
======================================================================
FAIL: test_should_returnStr (__main__.TestFaceCard)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "p3_c15.py", line 91, in test_should_returnStr
    self.assertEqual( "Q♥", str(self.queen_hearts) )
AssertionError: 'Q♥' != '12♥'
- Q♥
+ 12♥

----------------------------------------------------------------------
Ran 6 tests in 0.001s

FAILED (failures=2)

TestLoader类从每个TestCase类创建了两个测试。这给了我们总共六个测试。测试名称是以test开头的方法名称。

显然,我们有一个问题。我们的测试提供了一个预期结果,而我们的类定义并不符合。为了通过这个简单的单元测试套件,我们需要为Card类做更多的开发工作。修复方法应该很清楚,我们将把它留给读者作为练习。

包括边界和角落的情况

当我们转向作为一个整体测试Deck类时,我们需要确认一些事情:它是否产生了所有必需的Cards类,以及它是否真正正确地洗牌。我们不需要测试它是否正确发牌,因为我们依赖于listlist.pop()方法;由于这些方法是 Python 的一流部分,它们不需要额外的测试。

我们想要独立于任何特定的Card类层次结构来测试Deck类的构造和洗牌。正如前面所述,我们可以使用一个工厂函数使两个DeckCard定义独立。引入工厂函数会引入更多的测试。考虑到以前在Card类层次结构中发现的错误,这并不是一件坏事。

这是一个工厂函数的测试:

class TestCardFactory( unittest.TestCase ):
    def test_rank1_should_createAceCard( self ):
        c = card( 1, '♣' )
        self.assertIsInstance( c, AceCard )
    def test_rank2_should_createCard( self ):
        c = card( 2, '♦' )
        self.assertIsInstance( c, Card )
    def test_rank10_should_createCard( self ):
        c = card( 10, '♥' )
        self.assertIsInstance( c, Card )
    def test_rank10_should_createFaceCard( self ):
        c = card( 11, '♠' )
        self.assertIsInstance( c, Card )
    def test_rank13_should_createFaceCard( self ):
        c = card( 13, '♣' )
        self.assertIsInstance( c, Card )
    def test_otherRank_should_exception( self ):
        with self.assertRaises( LogicError ):
            c = card(14, '♦')
        with self.assertRaises( LogicError ):
            c = card(0, '♦')

我们没有测试所有 13 个等级,因为 2 到 10 应该是相同的。相反,我们遵循了Boris Beizer的建议:

“错误潜伏在角落里,聚集在边界上。”

测试用例涉及每个卡片范围的边界值。因此,我们对值 1、2、10、11 和 13 进行了测试,以及非法值 0 和 14。我们用最小值、最大值、最小值下面的一个值和最大值上面的一个值来对每个范围进行了分组。

当运行时,这个测试用例也会报告问题。最大的问题之一将是一个未定义的异常,LogicError。这只是Exception的一个子类,它定义了异常仍然不足以使测试用例通过。其余的修复工作留给读者作为练习。

模拟依赖进行测试

为了测试Deck,我们有两种选择来处理依赖关系:

  • 模拟:我们可以为Card类创建一个模拟(或替代)类,以及一个模拟的card()工厂函数,用于生成模拟类。使用模拟对象的优势在于,我们可以真正确信被测试的单元不受另一个类中的变通方法的影响;这可以弥补另一个类中的错误。一个很少见的潜在缺点是,我们可能需要调试超复杂模拟类的行为,以确保它是真实类的有效替代品。

  • 集成:如果我们相信Card类层次结构有效,并且card()工厂函数有效,我们可以利用它们来测试Deck。这偏离了纯单元测试的高道路,在纯单元测试中,所有依赖关系都被剔除以进行测试。然而,实际上这可能效果很好,因为通过了所有单元测试的类可以和模拟类一样可信。在非常复杂的有状态 API 的情况下,应用类可能比模拟类更可信。这样做的缺点是,如果一个基础类出现问题,将导致所有依赖它的类出现大量测试失败。此外,很难对非模拟类进行 API 符合性的详细测试。模拟类可以跟踪调用历史,从而可以跟踪调用次数和使用的参数。

unittest包括unittest.mock模块,可用于为测试目的修补现有类。它还可用于提供完整的模拟类定义。

当我们设计一个类时,我们必须考虑必须为单元测试进行模拟的依赖关系。在Deck的情况下,我们有三个依赖关系需要模拟:

  • Card 类:这个类非常简单,我们可以为这个类创建一个模拟,而不是基于现有实现。由于Deck类的行为不依赖于Card的任何特定特性,我们的模拟对象可以很简单。

  • card()工厂函数:这个函数需要被替换为一个模拟函数,以便我们可以确定Deck是否正确调用了这个函数。

  • random.Random.shuffle()方法:为了确定方法是否使用了正确的参数值进行调用,我们可以提供一个模拟对象来跟踪使用情况,而不是实际进行任何洗牌。

这是一个使用card()工厂函数的Deck版本:

class Deck3( list ):
    def __init__( self, size=1,
        random=random.Random(),
        card_factory=card ):
        super().__init__()
        self.rng= random
        for d in range(size):
            super().extend(
                 card_factory(r,s) for r in range(1,13) for s in Suits )
        self.rng.shuffle( self )
    def deal( self ):
        try:
            return self.pop(0)
        except IndexError:
            raise DeckEmpty()

这个定义有两个特别指定为__init__()方法参数的依赖项。它需要一个随机数生成器random和一个卡片工厂card_factory。它具有合适的默认值,因此可以在应用程序中非常简单地使用。它也可以通过提供模拟对象来进行测试,而不是使用默认对象。

我们包含了一个deal()方法,通过弹出一张卡片对对象进行更改。如果牌组为空,deal()方法将引发一个DeckEmpty异常。

这是一个测试用例,用来展示牌组是否被正确构建:

import unittest
import unittest.mock

class TestDeckBuid( unittest.TestCase ):
    def setUp( self ):
        self.test_card= unittest.mock.Mock( return_value=unittest.mock.sentinel )
        self.test_rng= random.Random()
        self.test_rng.shuffle= unittest.mock.Mock( return_value=None )
    def test_deck_1_should_build(self):
        d= Deck3( size=1, random=self.test_rng, card_factory= self.test_card )
        self.assertEqual( 52*[unittest.mock.sentinel], d )
        self.test_rng.shuffle.assert_called_with( d )
        self.assertEqual( 52, len(self.test_card.call_args_list) )
        expected = [
            unittest.mock.call(r,s)
                for r in range(1,14)
                    for s in ('♣', '♦', '♥', '♠') ]
        self.assertEqual( expected, self.test_card.call_args_list )

在这个测试用例的setUp()方法中,我们创建了两个模拟对象。模拟卡片工厂函数test_card是一个Mock函数。定义的返回值是一个mock.sentinel对象,而不是一个Card实例。这个 sentinel 是一个独特的对象,可以让我们确认创建了正确数量的实例。它与所有其他 Python 对象都不同,因此我们可以区分没有正确的return语句返回None的函数。

我们创建了一个random.Random()生成器的实例,但我们用一个返回None的模拟函数替换了shuffle()方法。这为我们提供了一个适当的方法返回值,并允许我们确定shuffle()方法是否使用了正确的参数值进行调用。

我们的测试创建了一个Deck类,其中包含了两个模拟对象。然后我们可以对这个Deck实例d进行多个断言:

  • 创建了 52 个对象。这些预期是 52 个mock.sentinel的副本,表明只有工厂函数被用来创建对象。

  • shuffle()方法被调用时使用了Deck实例作为参数。这向我们展示了模拟对象如何跟踪它的调用。我们可以使用assert_called_with()来确认在调用shuffle()时参数值是否符合要求。

  • 工厂函数被调用了 52 次。

  • 工厂函数被调用时使用了特定的预期等级和花色值列表。

Deck类定义中有一个小错误,所以这个测试没有通过。修复留给读者作为练习。

使用更多的模拟对象来测试更多的行为

前面的模拟对象用于测试Deck类是如何构建的。有 52 个相同的 sentinels 使得确认Deck是否正确发牌变得困难。我们将定义一个不同的模拟对象来测试发牌功能。

这是第二个测试用例,以确保Deck类正确发牌:

class TestDeckDeal( unittest.TestCase ):
    def setUp( self ):
        self.test_card= unittest.mock.Mock( side_effect=range(52) )
        self.test_rng= random.Random()
        self.test_rng.shuffle= unittest.mock.Mock( return_value=None )
    def test_deck_1_should_deal( self ):
        d= Deck3( size=1, random=self.test_rng, card_factory= self.test_card )
        dealt = []
        for c in range(52):
            c= d.deal()
            dealt.append(c)
        self.assertEqual( dealt, list(range(52)) )
    def test_empty_deck_should_exception( self ):
        d= Deck3( size=1, random=self.test_rng, card_factory= self.test_card )
        for c in range(52):
            c= d.deal()
        self.assertRaises( DeckEmpty, d.deal )

这个卡片工厂函数的模拟使用了side_effect参数来创建Mock()。当提供一个可迭代对象时,它会在每次调用时返回可迭代对象的另一个值。

我们模拟了shuffle()方法,以确保卡片实际上没有被重新排列。我们希望它们保持原始顺序,这样我们的测试就有可预测的预期值。

第一个测试(test_deck_1_should_deal)将 52 张卡片的发牌结果累积到一个变量dealt中。然后断言这个变量具有原始模拟卡片工厂的 52 个预期值。

第二个测试(test_empty_deck_should_exception)从一个Deck实例中发出了所有的卡片。然而,它多做了一个 API 请求。断言是在发出所有卡片后,Deck.deal()方法将引发适当的异常。

由于Deck类的相对简单,可以将TestDeckBuildTestDeckDeal合并为一个更复杂的模拟。虽然这在这个例子中是可能的,但重构测试用例使其更简单既不是必要的,也不一定是可取的。事实上,对测试的过度简化可能会忽略 API 功能。

使用 doctest 定义测试用例

doctest模块为我们提供了比unittest模块更简单的测试形式。有许多情况下,可以在文档字符串中显示简单的交互,并且可以通过doctest自动化测试。这将把文档和测试用例合并成一个整洁的包。

doctest用例被写入模块、类、方法或函数的文档字符串中。doctest用例向我们展示了交互式 Python 提示符>>>、语句和响应。doctest模块包含一个应用程序,用于查找这些示例在文档字符串中。它运行给定的示例,并将文档字符串中显示的预期结果与实际输出进行比较。

对于更大更复杂的类定义,这可能是具有挑战性的。在某些情况下,我们可能会发现简单的可打印结果难以处理,我们需要更复杂的比较从unittest中提供。

通过精心设计 API,我们可以创建一个可以与之交互的类。如果可以与之交互,那么可以从该交互构建一个doctest示例。

事实上,一个设计良好的类的两个属性是它可以与之交互,并且在文档字符串中有doctest示例。许多内置模块包含 API 的doctest示例。我们可能选择下载的许多其他软件包也将包含doctest示例。

通过一个简单的函数,我们可以提供以下文档:

def ackermann( m, n ):
    """Ackermann's Function
    ackermann( m, n ) -> 2↑^{m-2}(n+3) - 3

    See http://en.wikipedia.org/wiki/Ackermann_function and
    http://en.wikipedia.org/wiki/Knuth%27s_up-arrow_notation.

    >>> from p3_c15 import ackermann
    >>> ackermann(2,4)
    11
    >>> ackermann(0,4)
    5
    >>> ackermann(1,0)
    2
    >>> ackermann(1,1)
    3

    """
    if m == 0: return n+1
    elif m > 0 and n == 0: return ackermann( m-1, 1 )
    elif m > 0 and n > 0: return ackermann( m-1, ackermann( m, n-1 ) )

我们定义了 Ackermann 函数的一个版本,其中包括文档字符串注释,其中包括来自交互式 Python 的五个示例响应。第一个示例输出是import语句,不应产生任何输出。其他四个示例输出向我们展示了函数的不同值。

在这种情况下,结果都是正确的。没有留下任何隐藏的错误供读者练习。我们可以使用doctest模块运行这些测试。当作为程序运行时,命令行参数是应该被测试的文件。doctest程序会定位所有文档字符串,并查找这些字符串中的交互式 Python 示例。重要的是要注意,doctest文档提供了有关用于定位字符串的正则表达式的详细信息。在我们的例子中,我们在最后一个doctest示例之后添加了一个难以看到的空行,以帮助doctest解析器。

我们可以从命令行运行doctest

python3.3 -m doctest p3_c15.py

如果一切正确,这是无声的。我们可以通过添加-v选项来显示一些细节:

python3.3 -m doctest -v p3_c15.py

这将为我们提供从文档字符串解析出的每个详细信息和从文档字符串中获取的每个测试用例。

这将显示各种类、函数和方法以及没有测试的组件,以及具有测试的组件。这些可以确认我们的测试在文档字符串中被正确格式化。

在某些情况下,我们的输出不会轻松匹配交互式 Python。在这些情况下,我们可能需要在文档字符串中添加一些注释,修改测试用例和预期结果的解析方式。

有一个特殊的注释字符串,我们可以用于更复杂的输出。我们可以附加以下两个命令中的任何一个来启用(或禁用)可用的各种指令。以下是第一个命令:

# doctest: +DIRECTIVE

以下是第二个命令:

# doctest: -DIRECTIVE

有十几种修改方法可以处理预期结果的方式。其中大多数是关于间距和应该如何比较实际值和预期值的罕见情况。

doctest文档强调精确匹配原则

“doctest 在要求期望输出的精确匹配方面是严肃的。”

提示

如果甚至有一个字符不匹配,测试就会失败。您需要在一些预期输出中构建灵活性。如果构建灵活性变得太复杂,这就暗示unittest可能是一个更好的选择。

以下是一些特定情况,doctest的预期值和实际值不容易匹配:

  • Python 不保证字典键的顺序。使用类似sorted(some_dict.items())而不是some_dict的构造。

  • 方法函数id()repr()涉及物理内存地址;Python 不能保证它们是一致的。如果显示id()repr(),请使用#doctest: +ELLIPSIS指令,并在示例输出中用...替换 ID 或地址。

  • 浮点结果可能在不同的平台上不一致。始终使用格式化或四舍五入显示浮点数,以减少无意义的数字的数量。使用"{:.4f}".format(value)round(value,4)来确保忽略不重要的数字。

  • Python 不保证集合的顺序。使用类似sorted(some_set)而不是some_set的构造。

  • 当前日期或时间当然不能使用,因为那不会是一致的。涉及时间或日期的测试需要强制使用特定的日期或时间,通常是通过模拟timedatetime

  • 操作系统的详细信息,如文件大小或时间戳,可能会有所不同,不应该在没有省略号的情况下使用。有时,在doctest脚本中包含一个有用的设置或拆卸来管理 OS 资源是可能的。在其他情况下,模拟os模块是有帮助的。

这些考虑意味着我们的doctest模块可能包含一些不仅仅是 API 一部分的额外处理。我们可能在交互式 Python 提示符下做了一些类似这样的事情:

>>> sum(values)/len(values)
3.142857142857143

这向我们展示了特定实现的完整输出。我们不能简单地将其复制粘贴到文档字符串中;浮点结果可能会有所不同。我们需要做一些类似以下代码的事情:

>>> round(sum(values)/len(values),4)
3.1429

这是一个值,不应该在不同的实现之间变化。

结合 doctest 和 unittest

doctest模块中有一个钩子,可以从文档字符串注释中创建一个适当的unittest.TestSuite。这使我们可以在一个大型应用程序中同时使用doctestunittest

我们要做的是创建一个doctest.DocTestSuite()的实例。这将从模块的文档字符串构建一个套件。如果我们不指定一个模块,那么当前正在运行的模块将用于构建套件。我们可以使用一个如下的模块:

    import doctest
    suite5= doctest.DocTestSuite()
    t= unittest.TextTestRunner(verbosity=2)
    t.run( suite5 )

我们从当前模块的doctest字符串构建了一个套件suite5。我们在这个套件上使用了unittestTextTestRunner。作为替代,我们可以将doctest套件与其他TestCases组合,创建一个更大、更完整的套件。

创建一个更完整的测试包

对于较大的应用程序,每个应用程序模块都可以有一个并行模块,其中包括该模块的TestCases。这可以形成两个并行的包结构:一个src结构,其中包含应用程序模块,一个test结构,其中包含测试模块。以下是两个并行目录树,显示了模块的集合:

src
    __init__.py
    __main__.py
    module1.py
    module2.py
    setup.py
test
    __init__.py
    module1.py
    module2.py
    all.py

显然,并行性并不是精确的。我们通常不会为setup.py编写自动化单元测试。一个设计良好的__main__.py可能不需要单独的单元测试,因为它不应该包含太多代码。我们将看一些设计__main__.py的方法,在第十六章中,处理命令行

我们可以创建一个顶级的test/all.py模块,其中包含一个构建所有测试的主体套件:

import module1
import module2
import unittest
import doctest
all_tests= unittest.TestSuite()
for mod in module1, module2:
    all_tests.addTests( mod.suite() )    
    all_tests.addTests( doctest.DocTestSuite(mod) )
t= unittest.TextTestRunner()
t.run( all_tests )

我们从其他测试模块中的套件构建了一个单一套件all_tests。这为我们提供了一个方便的脚本,可以运行作为分发的一部分可用的所有测试。

也有办法使用unittest模块的测试发现功能来做到这一点。我们可以从命令行执行包范围的测试,类似以下代码:

python3.3 -m unittest test/*.py

这将使用unittest的默认测试发现功能来定位给定文件中的TestCases。这有一个缺点,即依赖于 shell 脚本功能而不是纯 Python 功能。通配符文件规范有时会使开发变得更加复杂,因为可能会测试不完整的模块。

使用设置和拆卸

unittest模块有三个级别的设置和拆卸。以下是三种不同的测试范围:方法,类和模块。

  • 测试用例 setUp()和 tearDown()方法:这些方法确保TestCase类中的每个单独测试方法都有适当的设置和拆卸。通常,我们会使用setUp()方法来创建单元对象和所需的任何模拟对象。我们不希望做一些昂贵的事情,比如创建整个数据库,因为这些方法在每个测试方法之前和之后都会被使用。

  • 测试用例 setUpClass()和 tearDownClass()方法:这些方法在TestCase类中的所有测试周围执行一次性设置(和拆卸)。这些方法将每个方法的setUp()-testMethod()-tearDown()序列括在一起。这是一个创建和销毁测试数据或数据库中的测试模式的好地方。

  • 模块 setUpModule()和 tearDownModule()函数:这些独立的函数为模块中所有的TestCase类提供了一次性设置。这是在运行一系列TestCase类之前创建和销毁整个测试数据库的好地方。

我们很少需要定义所有这些setUp()tearDown()方法。有几种测试场景将成为我们的可测试性设计的一部分。这些场景之间的基本区别是涉及的集成程度。正如前面所述,我们的测试层次结构中有三个层次:孤立的单元测试、集成测试和整体应用程序测试。这些测试层次与各种设置和拆卸功能一起工作的方式有几种。

  • 没有集成-没有依赖:一些类或函数没有外部依赖;它们不依赖文件、设备、其他进程或其他主机。其他类有一些可以模拟的外部资源。当TestCase.setUp()方法的成本和复杂性较小时,我们可以在那里创建所需的对象。如果模拟对象特别复杂,类级TestCase.setUpClass()可能更适合分摊在多个测试方法中重新创建模拟对象的成本。

  • 内部集成-一些依赖:类或模块之间的自动集成测试通常涉及更复杂的设置情况。我们可能有一个复杂的类级setUpClass()甚至是模块级setUpModule()来为集成测试准备环境。在第十章和第十一章中处理数据库访问层时,我们经常进行包括我们的类定义以及我们的访问层的集成测试。这可能涉及向测试数据库或架子中添加适当的数据进行测试。

  • 外部集成:我们可以对应用程序的更大更复杂的部分进行自动集成测试。在这些情况下,我们可能需要启动外部进程或创建数据库并填充数据。在这种情况下,我们可以使用setUpModule()来为模块中的所有TestCase类准备一个空数据库。在第十二章中使用 RESTful web 服务,或者在第十七章中测试大规模编程(PITL),这种方法可能会有所帮助。

请注意,单元测试的概念并没有定义被测试的单元是什么。单元可以是一个类、一个模块、一个包,甚至是一个集成的软件组件集合。它只需要与其环境隔离开来,就可以成为被测试的单元。

在设计自动集成测试时,重要的是要意识到要测试的组件。我们不需要测试 Python 库;它们有自己的测试。同样,我们不需要测试操作系统。集成测试必须专注于测试我们编写的代码,而不是我们下载和安装的代码。

使用操作系统资源进行设置和拆卸

在许多情况下,测试用例可能需要特定的操作系统环境。在处理文件、目录或进程等外部资源时,我们可能需要在测试之前创建或初始化它们。我们可能还需要在测试之前删除这些资源。我们可能需要在测试结束时拆除这些资源。

假设我们有一个名为rounds_final()的函数,它应该处理给定的文件。我们需要测试函数在文件不存在的罕见情况下的行为。通常会看到TestCases具有以下结构:

import os
class Test_Missing( unittest.TestCase ):
    def setUp( self ):
        try:
            os.remove( "p3_c15_sample.csv" )
        except OSError as e:
            pass
    def test_missingFile_should_returnDefault( self ):
        self.assertRaises( FileNotFoundError, rounds_final,  "p3_c15_sample.csv", )

我们必须处理尝试删除根本不存在的文件的可能异常。这个测试用例有一个setUp()方法,确保所需的文件确实丢失。一旦setUp()确保文件真的消失了,我们就可以执行带有缺失文件“p3_c15_sample.csv”参数的rounds_final()函数。我们期望这会引发FileNotFoundError错误。

请注意,引发FileNotFoundError是 Python 的open()方法的默认行为。这可能根本不需要测试。这引出了一个重要的问题:为什么要测试内置功能?如果我们进行黑盒测试,我们需要测试外部接口的所有功能,包括预期的默认行为。如果我们进行白盒测试,我们可能需要测试rounds_final()函数正文中的异常处理try:语句。

p3_c15_sample.csv 文件名在测试的正文中重复出现。有些人认为 DRY 原则应该适用于测试代码。在编写测试时,这种优化的价值是有限的。以下是建议:

提示

测试代码变得脆弱是可以接受的。如果对应用程序的微小更改导致测试失败,这确实是一件好事。测试应该重视简单和清晰,而不是健壮性和可靠性。

使用数据库进行设置和拆卸

在使用数据库和 ORM 层时,我们经常需要创建测试数据库、文件、目录或服务器进程。我们可能需要在测试通过后拆除测试数据库,以确保其他测试可以运行。我们可能不希望在测试失败后拆除数据库;我们可能需要保留数据库,以便我们可以检查结果行以诊断测试失败。

在复杂的、多层次的架构中管理测试范围非常重要。回顾第十一章,通过 SQLite 存储和检索对象,我们不需要专门测试 SQLAlchemy ORM 层或 SQLite 数据库。这些组件在我们的应用程序测试之外有自己的测试程序。但是,由于 ORM 层从我们的代码中创建数据库定义、SQL 语句和 Python 对象的方式,我们不能轻易地模拟 SQLAlchemy 并希望我们正确地使用它。我们需要测试我们的应用程序如何使用 ORM 层,而不是陷入测试 ORM 层本身。

其中一个更复杂的测试用例设置情况将涉及创建一个数据库,然后为给定的测试用例填充适当的示例数据。在处理 SQL 时,这可能涉及运行相当复杂的 SQL DDL 脚本来创建必要的表,然后运行另一个 SQL DML 脚本来填充这些表。相关的拆卸将是另一个复杂的 SQL DDL 脚本。

这种测试用例可能会变得冗长,所以我们将其分为三个部分:一个有用的函数来创建数据库和模式,setUpClass()方法,以及其余的单元测试。

这是创建数据库的函数:

from p2_c11 import Base, Blog, Post, Tag, assoc_post_tag
import datetime

import sqlalchemy.exc
from sqlalchemy import create_engine

def build_test_db( name='sqlite:///./p3_c15_blog.db' ):
    engine = create_engine(name, echo=True)
    Base.metadata.drop_all(engine)
    Base.metadata.create_all(engine)
    return engine

这通过删除与 ORM 类相关的所有表并重新创建这些表来构建一个新的数据库。其目的是确保一个新的、空的数据库,符合当前的设计,无论这个设计自上次运行单元测试以来有多大的变化。

在这个例子中,我们使用文件构建了一个 SQLite 数据库。我们可以使用内存SQLite 数据库功能使测试运行速度更快。使用内存数据库的缺点是我们没有持久的数据库文件,无法用于调试失败的测试。

这是我们在TestCase子类中使用的方式:

from sqlalchemy.orm import sessionmaker
class Test_Blog_Queries( unittest.TestCase ):
    @staticmethod
    def setUpClass():
        engine= build_test_db()
 **Test_Blog_Queries.Session = sessionmaker(bind=engine)
        session= Test_Blog_Queries.Session()

        tag_rr= Tag( phrase="#RedRanger" )
        session.add( tag_rr )
        tag_w42= Tag( phrase="#Whitby42" )
        session.add( tag_w42 )
        tag_icw= Tag( phrase="#ICW" )
        session.add( tag_icw )
        tag_mis= Tag( phrase="#Mistakes" )
        session.add( tag_mis )

        blog1= Blog( title="Travel 2013" )
        session.add( blog1 )
        b1p1= Post( date=datetime.datetime(2013,11,14,17,25),
            title="Hard Aground",
            rst_text="""Some embarrassing revelation. Including ☹ and ⎕""",
            blog=blog1,
            tags=[tag_rr, tag_w42, tag_icw],
            )
        session.add(b1p1)
        b1p2= Post( date=datetime.datetime(2013,11,18,15,30),
            title="Anchor Follies",
            rst_text="""Some witty epigram. Including ☺ and ☀""",
            blog=blog1,
            tags=[tag_rr, tag_w42, tag_mis],
            )
        session.add(b1p2)

        blog2= Blog( title="Travel 2014" )
        session.add( blog2 )
        session.commit()

我们定义了setUpClass(),以便在运行这个类的测试之前创建一个数据库。这允许我们定义一些将共享一个公共数据库配置的测试方法。数据库建立后,我们可以创建一个会话并添加数据。

我们将会话制造者对象放入类中作为一个类级属性,Test_Blog_Queries.Session = sessionmaker(bind=engine)。然后可以在setUp()和单独的测试方法中使用这个类级对象。

这是setUp()和两个单独的测试方法:

    def setUp( self ):
        self.session= Test_Blog_Queries.Session()

    def test_query_eqTitle_should_return1Blog( self ):
        results= self.session.query( Blog ).filter(
            Blog.title == "Travel 2013" ).all()
        self.assertEqual( 1, len(results) )
        self.assertEqual( 2, len(**results[0].entries**) )

    def test_query_likeTitle_should_return2Blog( self ):
        results= self.session.query( Blog ).filter(
            Blog.title.like("Travel %") ).all()
        self.assertEqual( 2, len(results) )

setUp()方法创建一个新的空会话对象。这将确保每个查询都必须生成 SQL 并从数据库中获取数据。

query_eqTitle_should_return1Blog()测试将找到请求的Blog实例,并通过entries关系导航到Post实例。请求的filter()部分并不真正测试我们的应用程序定义;它是对 SQLAlchemy 和 SQLite 的练习。最终断言中的results[0].entries测试是对我们类定义的有意义的测试。

query_likeTitle_should_return2Blog()测试几乎完全是对 SQLAlchemy 和 SQLite 的测试。除了在Blog中存在名为title的属性之外,它并没有真正对我们的应用程序做出有意义的使用。这些测试通常是在创建初始技术性探索时留下的。即使它们作为测试用例并没有太多价值,它们可以帮助澄清应用程序 API。

这里还有两个测试方法:

    def test_query_eqW42_tag_should_return2Post( self ):
        results= self.session.query(Post)\
        .join(assoc_post_tag).join(Tag).filter(
            Tag.phrase == "#Whitby42" ).all()
        self.assertEqual( 2, len(results) )
    def test_query_eqICW_tag_should_return1Post( self ):
        results= self.session.query(Post)\
        .join(assoc_post_tag).join(Tag).filter(
            Tag.phrase == "#ICW" ).all()
        self.assertEqual( 1, len(results) )
        self.assertEqual( "Hard Aground", results[0].title )
        self.assertEqual( "Travel 2013", **results[0].blog.title** )
        self.assertEqual( set(["#RedRanger", "#Whitby42", "#ICW"]), **set(t.phrase for t in results[0].tags)** )

query_eqW42_tag_should_return2Post()测试执行更复杂的查询,以定位具有给定标签的帖子。这涉及到类中定义的多个关系。

类似地,query_eqICW_tag_should_return1Post()测试涉及复杂的查询。它测试从Post到拥有Blog的导航,通过results[0].blog.title。它还测试从PostTags的关联集合的导航,通过set(t.phrase for t in results[0].tags)。我们必须使用显式的set(),因为在 SQL 中结果的顺序不能保证。

Test_Blog_Queries的重要之处在于它通过setUpClass()方法创建了数据库模式和一组特定的定义行。这种测试设置对于数据库应用程序很有帮助。它可能变得相当复杂,并且通常通过从文件或 JSON 文档加载示例行来补充,而不是在 Python 中编写行。

TestCase 类层次结构

继承在TestCase类之间起作用。理想情况下,每个TestCase都是唯一的。实际上,测试用例之间可能存在共同特征。TestCase类可能重叠的三种常见方式:

  • 常见的 setUp():我们可能有一些数据在多个TestCases中使用。没有理由重复数据。只定义setUp()tearDown()而没有测试方法的TestCase类是合法的,但可能会导致混乱的日志,因为没有涉及任何测试。

  • 常见的 tearDown():通常需要对涉及操作系统资源的测试进行常见的清理。我们可能需要删除文件和目录或者终止子进程。

  • 常见的结果检查:对于算法复杂的测试,我们可能有一个结果检查方法来验证结果的一些属性。

回顾第三章,属性访问、属性和描述符,例如,考虑RateTimeDistance类。该类根据另外两个值填充字典中的缺失值:

class RateTimeDistance( dict ):
    def __init__( self, *args, **kw ):
        super().__init__( *args, **kw )
        self._solve()
    def __getattr__( self, name ):
        return self.get(name,None)
    def __setattr__( self, name, value ):
        self[name]= value
        self._solve()
    def __dir__( self ):
        return list(self.keys())
    def _solve(self):
        if self.rate is not None and self.time is not None:
            self['distance'] = self.rate*self.time
        elif self.rate is not None and self.distance is not None:
            self['time'] = self.distance / self.rate
        elif self.time is not None and self.distance is not None:
            self['rate'] = self.distance / self.time

每个单元测试方法可以包括以下代码:

self.assertAlmostEqual( object.distance, object.rate * object.time )

如果我们使用多个TestCase子类,我们可以将此有效性检查作为单独的方法继承:

def validate( self, object ):self.assertAlmostEqual( object.distance, object.rate * object.time )

这样,每个测试只需要包括self.validate(object),以确保所有测试提供一致的正确定义。

unittest模块定义的一个重要特性是测试用例是合适的类,具有合适的继承。我们可以像应用程序类一样,精心设计TestCase类层次结构。

使用外部定义的预期结果

对于一些应用程序,用户可以阐述描述软件行为的处理规则。在其他情况下,分析员或设计师的工作是将用户的愿望转化为软件的过程描述。

在许多情况下,用户更容易提供预期结果的具体示例。对于一些面向业务的应用程序,用户可能更习惯创建一个显示示例输入和预期结果的电子表格。从用户提供的具体示例数据中开发软件可以简化开发过程。

尽可能让真实用户产生正确结果的具体示例。创建过程描述或软件规范非常困难。从示例创建具体示例并从示例概括到软件规范的过程不那么复杂和混乱。此外,它符合测试用例驱动开发的风格。给定一套测试用例,我们有一个完成的具体定义。跟踪软件开发项目状态会问今天有多少测试用例,其中有多少通过。

给定具体示例的电子表格,我们需要将每一行转换为TestCase实例。然后我们可以从这些对象构建一个测试套件。

在本章的前面例子中,我们从基于TestCase的类中加载了测试用例。我们使用unittest.defaultTestLoader.loadTestsFromTestCase来定位所有以test开头的方法。加载器从每个方法创建一个测试对象,并将它们组合成一个测试套件。实际上,加载器创建的每个对象都是通过使用测试用例名称调用类构造函数创建的离散对象:SomeTestCase("test_method_name")。传递给SomeTestCase__init__()方法的参数将是用于定义类的方法名称。每个方法都被单独详细说明为一个测试用例。

对于这个例子,我们将使用另一种方法来构建测试用例实例。我们将定义一个只有一个测试的类,并将多个这个TestCase类的实例加载到一个测试套件中。这样做时,TestCase类必须只定义一个测试,并且默认情况下,该方法的名称应该是runTest()。我们不会使用加载器来创建测试对象;我们将直接从外部提供的数据行创建它们。

让我们来看一个我们需要测试的具体函数。这是来自第三章,“属性访问、属性和描述符”:

from p1_c03 import RateTimeDistance

这是一个在初始化时急切计算多个属性的类。这个简单函数的用户向我们提供了一些测试用例,我们从中提取了 CSV 文件。有关 CSV 文件的更多信息,请参见第九章,“序列化和保存 - JSON、YAML、Pickle、CSV 和 XML”。我们需要将每一行转换为TestCase

rate_in,time_in,distance_in,rate_out,time_out,distance_out
2,3,,2,3,6
5,,7,5,1.4,7
,11,13,1.18,11,13

以下是我们可以用来从 CSV 文件的每一行创建测试实例的测试用例:

def float_or_none( text ):
    if len(text) == 0: return None
    return float(text)

class Test_RTD( unittest.TestCase ):
    def __init__( self, rate_in,time_in,distance_in,
        rate_out,time_out,distance_out ):
        super().__init__()
        self.args = dict( rate=float_or_none(rate_in),
            time=float_or_none(time_in),
            distance=float_or_none(distance_in) )
        self.result= dict( rate=float_or_none(rate_out),
            time=float_or_none(time_out),
            distance=float_or_none(distance_out) )
    def shortDescription( self ):
        return "{0} -> {1}".format(self.args, self.result)
    def setUp( self ):
        self.rtd= RateTimeDistance( **self.args )
    def runTest( self ):
        self.assertAlmostEqual( self.rtd.distance, self.rtd.rate*self.rtd.time )
        self.assertAlmostEqual( self.rtd.rate, self.result['rate'] )
        self.assertAlmostEqual( self.rtd.time, self.result['time'] )
        self.assertAlmostEqual( self.rtd.distance, self.result['distance'] )

float_or_none()函数是处理 CSV 源数据的常用方法。它将单元格的文本转换为float值或None

Test_RTD类做了三件事:

  • __init__()方法将电子表格的一行解析为两个字典:输入值self.args和预期输出值self.result

  • setUp()方法创建了一个RateTimeDistance对象并提供了输入参数值

  • runTest()方法可以通过检查结果与用户提供的值进行比较来简单验证输出

我们还为您提供了一个shortDescription()方法,返回测试的简要摘要。这可以帮助调试。我们可以按以下方式构建一个测试套件:

import csv
def suite9():
    suite= unittest.TestSuite()
    with open("p3_c15_data.csv","r",newline="") as source:
        rdr= csv.DictReader( source )
        for row in rdr:
            suite.addTest( Test_RTD(**row) )
    return suite

我们打开了 CSV 文件,并将该文件的每个测试用例行读取为一个dict对象。如果 CSV 列标题与Test_RTD.__init__()方法的期望匹配,那么每行就成为一个测试用例对象,并可以添加到测试套件中。如果 CSV 列标题不匹配,我们将会有一个KeyError异常;我们需要修复电子表格以匹配Test_RTD类。我们按以下方式运行测试:

    t= unittest.TextTestRunner()
    t.run( suite9() )

输出如下:

..F
======================================================================
FAIL: runTest (__main__.Test_RTD)
{'rate': None, 'distance': 13.0, 'time': 11.0} -> {'rate': 1.18, 'distance': 13.0, 'time': 11.0}
----------------------------------------------------------------------
Traceback (most recent call last):
  File "p3_c15.py", line 504, in runTest
    self.assertAlmostEqual( self.rtd.rate, self.result['rate'] )
AssertionError: 1.1818181818181819 != 1.18 within 7 places

----------------------------------------------------------------------
Ran 3 tests in 0.000s

FAILED (failures=1)

用户提供的数据有一个小问题;用户提供了一个只舍入到两位小数的值。要么样本数据需要提供更多位数,要么我们的测试断言需要处理舍入。

让用户提供精确的示例数据可能不会很好。如果用户不能更精确,那么我们的测试断言需要包括一些基于用户输入的四舍五入。这可能是有挑战性的,因为电子表格以一种精确的十进制值显示数据,而实际上是一个舍入和格式化的浮点近似值。在许多情况下,可以使用一个统一的舍入假设,而不是试图通过反向工程电子表格来解析用户的意图。

自动集成或性能测试

我们可以使用unittest包执行不专注于单个孤立类定义的测试。如前所述,我们可以使用unittest自动化来测试由多个组件集成的单元。这种测试只能在已经通过孤立组件的单元测试的软件上执行。当组件的单元测试未正确工作时,尝试调试失败的集成测试是没有意义的。

性能测试可以在几个集成级别进行。对于大型应用程序,使用整个构建进行性能测试可能并不完全有帮助。一个传统观点是,程序花费 90%的时间执行仅有 10%的可用代码。因此,我们通常不需要优化整个应用程序;我们只需要找到代表真正性能瓶颈的程序的一小部分。

在某些情况下,很明显我们有一个涉及搜索的数据结构。我们知道去除搜索将极大地提高性能。正如我们在第五章中看到的,通过实现记忆化,可以通过避免重新计算来实现显著的性能改进。

为了进行适当的性能测试,我们需要遵循一个三步工作循环:

  1. 使用设计审查和代码分析的组合来找到可能是性能问题的应用程序部分。Python 标准库中有两个分析模块。除非有更复杂的要求,cProfile将定位需要关注的应用程序部分。

  2. 使用unittest创建一个自动化测试场景,以展示任何实际的性能问题。使用timeittime.perf_counter()收集性能数据。

  3. 优化选定测试用例的代码,直到性能可接受。

重点是尽可能自动化,并避免模糊地调整事物,希望性能会有所改善。大多数情况下,必须替换中心数据结构或算法(或两者),从而进行大规模重构。自动化单元测试使大规模重构成为可能。

当性能测试缺乏具体的通过-失败标准时,可能会出现尴尬的情况。可能需要使某些东西更快,而没有足够快的明确定义。当有可衡量的性能目标时,情况总是更简单;正式的自动化测试可用于断言结果既正确,获取这些结果所花费的时间也是可接受的。

对于性能测试,我们可能会使用以下类似的代码:

import unittest
import timeit
class Test_Performance( unittest.TestCase ):
    def test_simpleCalc_shouldbe_fastEnough( self ):
        t= timeit.timeit(
        stmt="""RateTimeDistance( rate=1, time=2 )""",
        setup="""from p1_c03 import RateTimeDistance"""
        )
        print( "Run time", t )
        self.assertLess( t, 10, "run time {0} >= 10".format(t) )

使用unittest进行自动化性能测试。由于timeit模块执行给定语句 100 万次,这应该最大程度地减少来自进行测试的计算机后台工作的测量变异性。

在前面的例子中,RTD 构造函数的每次执行都需要少于 1/100,000 秒。一百万次执行应该少于 10 秒。

总结

我们看了如何使用unittestdoctest创建自动化单元测试。我们还看了如何创建一个测试套件,以便可以将测试集合打包以便重用,并将其聚合到具有更大范围的套件中,而不依赖于自动化测试发现过程。

我们看了如何创建模拟对象,以便我们可以独立测试软件单元。我们还看了各种设置和拆卸功能。这些允许我们编写具有复杂初始状态或持久结果的测试。

单元测试的FIRST属性与doctestunittest都很匹配。FIRST 属性如下:

  • 快速:除非我们编写了极其糟糕的测试,否则doctestunitest的性能应该非常快。

  • 隔离unittest包为我们提供了一个可以用来隔离类定义的模拟模块。此外,我们可以在设计中小心处理,以确保我们的组件彼此隔离。

  • 可重复:使用doctestunittest进行自动化测试可以确保可重复性。

  • 自我验证doctestunittest都将测试结果与测试用例条件绑定,确保测试中不涉及主观判断。

  • 及时:我们可以在类、函数或模块的框架出现后立即编写和运行测试用例。一个只有pass的类主体就足以运行测试脚本。

对于项目管理的目的,编写的测试数量和通过的测试数量有时是非常有用的状态报告。

设计考虑和权衡

在创建软件时,测试用例是必需的交付内容。任何没有自动化测试的功能实际上就是不存在。如果没有测试,就不能相信功能是正确的。如果不能信任,就不应该使用。

唯一真正的权衡问题是是否使用doctestunittest或两者兼用。对于简单的编程,doctest可能非常合适。对于更复杂的情况,将需要unittest。对于需要包含示例的 API 文档的框架,组合效果很好。

在某些情况下,简单地创建一个充满TestCase类定义的模块可能就足够了。TestLoader类和测试发现功能可能完全足够定位所有测试。

更一般地,unittest涉及使用TestLoader从每个TestCase子类中提取多个测试方法。我们将测试方法打包到一个单一的类中,根据它们可以共享类级别的setUp(),可能还有setUpClass()方法。

我们还可以在没有TestLoader的情况下创建TestCase实例。在这种情况下,默认的runTest()方法被定义为具有测试用例断言。我们可以从这种类的实例创建一个测试套件。

最困难的部分可能是为可测试性进行设计。消除依赖关系,使单元可以独立测试,有时会感觉增加软件设计复杂性的程度。在大多数情况下,暴露依赖关系所花费的时间是投入到创建更易维护和更灵活的软件中的时间。

一般规则是:类之间的隐式依赖关系是糟糕的设计

可测试的设计具有明确的依赖关系;这些可以很容易地用模拟对象替换。

展望未来

下一章将讨论从命令行启动的编写完整应用程序。我们将探讨在 Python 应用程序中处理启动选项、环境变量和配置文件的方法。

在第十七章中,模块和包设计,我们将扩展应用程序设计。我们将增加将应用程序组合成更大的应用程序以及将应用程序分解成更小部分的能力。

第十六章:应对命令行

命令行启动选项、环境变量和配置文件对许多应用程序都很重要,特别是服务器的实现。处理程序启动和对象创建有许多方法。在本章中,我们将讨论两个问题:参数解析和应用程序的总体架构。

本章将从第十三章中的配置文件和持久性中扩展配置文件处理技术,涉及命令行程序和服务器的顶层。它还将扩展第十四章中的一些日志设计特性,日志和警告模块

在下一章中,我们将扩展这些原则,继续研究一种我们将称之为大规模编程的架构设计。我们将使用命令设计模式来定义可以聚合而不依赖外壳脚本的软件组件。这在编写应用服务器使用的后台处理组件时特别有帮助。

操作系统接口和命令行

通常,外壳启动应用程序时会提供 OS API 的几个信息:

  • 外壳为每个应用程序提供了其环境变量的集合。在 Python 中,可以通过os.environ来访问这些变量。

  • 外壳准备了三个标准文件。在 Python 中,这些文件映射到sys.stdinsys.stdoutsys.stderr。还有一些其他模块,如fileinput,可以提供对sys.stdin的访问。

  • 外壳将命令行解析为单词。命令行的部分在sys.argv中可用。Python 将提供一些原始命令行;我们将在以下部分中查看细节。对于 POSIX 操作系统,外壳可能会替换外壳环境变量和通配符文件名。在 Windows 中,简单的cmd.exe外壳不会为我们进行通配符文件名。

  • 操作系统还维护上下文设置,如当前工作目录、用户 ID 和组。这些可以通过os模块获得。它们不是作为命令行参数提供的。

操作系统期望应用程序在终止时提供一个数字状态码。如果我们想返回一个特定的数字代码,我们可以在我们的应用程序中使用sys.exit()。如果我们的程序正常终止,Python 将返回零。

外壳的操作是 OS API 的重要部分。给定一行输入,外壳执行多个替换,取决于(相当复杂的)引用规则和替换选项。然后将生成的行解析为以空格分隔的单词。第一个单词必须是内置的外壳命令(如cdset),或者必须是文件的名称。外壳会在其定义的PATH中搜索这个文件。

命令的第一个单词必须具有执行(x)权限。外壳命令chmod +x somefile.py将文件标记为可执行。与之匹配但不可执行的文件会得到一个OS 权限被拒绝错误

可执行文件的前几个字节有一个魔术数字,外壳用它来决定如何执行该文件。一些魔术数字表示文件是一个二进制可执行文件;外壳可以分叉一个子外壳并执行它。其他魔术数字,特别是b'#!',表示文件是一个文本脚本,需要一个解释器。这种文件的第一行的其余部分是解释器的名称。

我们经常使用这样的一行:

#!/usr/bin/env python3.3

如果一个 Python 文件有执行权限,并且作为第一行,那么外壳将运行env程序。env程序的参数(python3.3)将导致它设置一个环境并运行 Python3.3 程序,Python 文件作为第一个位置参数。

实际上,从 OS 外壳通过可执行脚本到 Python 的概念上的步骤序列如下:

  1. 外壳解析了ourapp.py -s someinput.csv这一行。第一个单词是ourapp.py。这个文件在外壳的PATH上,并且具有x可执行权限。外壳打开文件并找到#!字节。外壳读取这一行的其余部分,并找到一个新的命令:/usr/bin/env python3.3

  2. 外壳解析了新的/usr/bin/env命令,这是一个二进制可执行文件。因此,外壳启动了这个程序。这个程序又启动了python3.3。原始命令行的单词序列作为 OS API 的一部分提供给 Python。

  3. Python 将从原始命令行中解析这个单词序列,以提取在第一个参数之前的任何选项。这些第一个选项由 Python 使用。第一个参数是要运行的 Python 文件名。这个文件名参数和行上剩下的所有单词将分别保存在sys.argv中。

  4. Python 根据找到的选项进行正常的启动。根据-s选项,可能会使用site模块来设置导入路径sys.path。如果我们使用了-m选项,Python 将使用runpy模块启动我们的应用程序。给定的脚本文件可能会(重新)编译为字节码。

  5. 我们的应用程序可以利用sys.argv来使用argparse模块解析选项和参数。我们的应用程序可以在os.environ中使用环境变量。它还可以解析配置文件;有关此主题的更多信息,请参见第十三章,配置文件和持久性

如果缺少文件名,Python 解释器将从标准输入读取。如果标准输入是控制台(在 Linux 术语中称为 TTY),那么 Python 将进入读取-执行-打印循环(REPL)并显示>>>提示符。虽然我们作为开发人员使用这种模式,但我们通常不会将这种模式用于成品应用程序。

另一种可能性是标准输入是一个重定向的文件;例如,python <some_filesome_app | python。这两种都是有效的,但可能会令人困惑。

参数和选项

为了运行程序,shell 将命令行解析为单词。这个单词序列对所有启动的程序都是可用的。通常,这个序列的第一个单词是 shell 对命令的理解。命令行上剩下的单词被理解为选项和参数。

有一些处理选项和参数的指导方针。基本规则如下:

  • 选项首先出现。它们之前有---。有两种格式:-letter--word。有两种类型的选项:没有参数的选项和带参数的选项。没有参数的选项的示例是使用-V显示版本或使用--version显示版本。带参数选项的示例是-m module,其中-m选项必须跟着模块名。

  • 没有选项参数的短格式(单字母)选项可以在单个-后面分组。我们可以使用-bqv来组合-b -q -v选项以方便使用。

  • 参数放在最后。它们没有前导---。有两种常见的参数:

  • 对于位置参数,位置在语义上是重要的。我们可能有两个位置参数:输入文件名和输出文件名。顺序很重要,因为输出文件将被修改。当文件将被覆盖时,通过位置进行简单区分需要小心以防止混淆。

  • 一系列参数的列表,它们在语义上是等价的。我们可能有所有输入文件名称的参数。这与 shell 执行文件名通配符的方式非常匹配。当我们说process.py *.html时,shell 将*.html命令扩展为成为位置参数的文件名。(这在 Windows 中不起作用,因此必须使用glob模块。)

还有更多细节。有关命令行选项的更多信息,请参见pubs.opengroup.org/onlinepubs/9699919799/basedefs/V1_chap12.html#tag_12_02。Python 命令行有大约 12 个选项,可以控制 Python 行为的一些细节。有关这些选项的更多信息,请参见Python 设置和用法文档。Python 命令的位置参数是要运行的脚本的名称;这将是我们应用程序的顶层文件。

使用 argparse 解析命令行

使用argparse的一般方法包括四个步骤。

  1. 创建ArgumentParser。我们可以在这里提供关于命令行界面的整体信息。这可能包括描述、显示选项和参数的格式更改,以及-h是否是“帮助”选项。通常,我们只需要提供描述;其余选项都有合理的默认值。

  2. 定义命令行选项和参数。这是通过使用ArgumentParser.add_argument()方法函数添加参数来完成的。

  3. 解析sys.argv命令行以创建一个详细描述选项、选项参数和整体命令行参数的命名空间对象。

  4. 使用生成的命名空间对象来配置应用程序并处理参数。有许多替代方法来优雅地处理这个问题。这可能涉及解析配置文件,以及命令行选项。我们将看几种设计。

argparse的一个重要特性是它为我们提供了对选项和参数的统一视图。两者之间的主要区别在于它们出现的次数。选项是可选的,可以出现零次或一次。参数通常出现一次或多次。

我们可以像下面的代码一样轻松地创建一个解析器:

parser = argparse.ArgumentParser( description="Simulate Blackjack" )

我们提供了描述,因为对于这个没有很好的默认值。以下是定义应用程序的命令行 API 的一些常见模式:

  • 一个简单的开关选项:我们经常会将其表示为-v--verbose选项

  • 带参数的选项:这可能是一个-s ','–separator '|'选项

  • 一个位置参数:当我们有一个输入文件和一个输出文件作为命令行参数时,可以使用这个选项

  • 所有其他参数:当我们有一个输入文件列表时,我们会使用这些参数

  • --version:这是一个特殊选项,用于显示版本号并退出

  • --help:此选项将显示帮助信息并退出。这是一个默认选项,我们不需要做任何事情来实现它

一旦参数被定义,我们就可以解析它们并使用它们。以下是我们如何解析它们的方法:

config= parser.parse_args()

config对象是一个argparse.Namespace对象;该类类似于types.SimpleNamespace。它将具有许多属性,我们可以很容易地向该对象添加更多属性。

我们将分别查看这六种常见类型的参数。ArgumentParser类中提供了许多聪明和复杂的解析选项。其中大多数超出了常见的命令行参数处理的简单指南。一般来说,我们应该避免那种像find这样的程序所特有的超级复杂选项。当选项变得非常复杂时,我们可能已经开始在 Python 之上创建领域特定语言。为什么不直接使用 Python 呢?

一个简单的开关选项

我们将使用一个字母的短名称定义一个简单的开关选项,我们也可以提供一个更长的名称;我们还应该提供一个明确的动作。如果我们省略了更长的名称或更长的名称作为 Python 变量不好,我们可能需要提供一个目标变量:

parser.add_argument( '-v', '--verbose', action='store_true', default=False )

这将定义命令行选项的长版本和短版本。如果选项存在,它将将verbose选项设置为True。如果选项不存在,verbose 选项将默认为False。以下是这个主题的一些常见变化:

  • 我们可以将动作更改为'store_false',默认值为True

  • 有时,我们的默认值可能是None,而不是TrueFalse

  • 有时,我们会使用'store_const'的动作,并陦加一个const=参数。这使我们能够超越简单的布尔值,并存储诸如日志级别或其他对象之类的东西。

  • 我们可能还会使用'count'的动作,这允许选项重复出现,增加计数。在这种情况下,默认值通常为零。

如果我们正在使用记录器,我们可以定义一个调试选项,如下面的代码:

parser.add_argument( '--debug', action='store_const', const=logging.DEBUG, default=logging.INFO, dest="logging_level" )

我们将操作更改为store_const,它存储一个常量值并提供logging.DEBUG的特定常量值。这意味着生成的选项对象将直接提供配置根记录器所需的值。然后我们可以简单地使用config.logging_level配置记录器,而无需进一步的映射或条件处理。

带参数的选项

我们将定义一个带有长名称和可选短名称的参数的选项。我们将提供一个存储提供的参数值的操作。我们还可以提供类型转换,以防我们想要floatint值而不是字符串:

parser.add_argument( "-b", "--bet", action="store", default="Flat", choices=["Flat", "Martingale", "OneThreeTwoSix"], dest='betting_rule')
parser.add_argument( "-s", "--stake", action="store", default=50, type=int )

第一个例子将定义命令行语法的两个版本,长的和短的。在解析命令行参数值时,一个字符串值必须跟在选项后面,并且必须来自可用的选择。目标名称betting_rule将接收选项的参数字符串。

第二个例子还定义了命令行语法的两个版本;它包括类型转换。在解析参数值时,这将存储跟在选项后面的整数值。长名称stake将成为解析器创建的选项对象中的值。

在某些情况下,可能会有与参数相关联的值列表。在这种情况下,我们可以提供一个nargs="+"选项,以便将由空格分隔的多个值收集到一个列表中。

位置参数

我们使用没有"-"修饰的名称定义位置参数。在我们有固定数量的位置参数的情况下,我们将适当地将它们添加到解析器中:

parser.add_argument( "input_filename", action="store" )
parser.add_argument( "output_filename", action="store" )

在解析参数值时,两个位置参数字符串将被存储到最终的命名空间对象中。我们可以使用config.input_filenameconfig.output_filename来处理这些参数值。

所有其他参数

我们使用没有-修饰的名称和nargs参数中的一条建议来定义参数列表。如果规则是一个或多个参数值,我们指定nargs="+"。如果规则是零个或多个参数值,我们指定nargs="*"。如果规则是可选的,我们指定nargs="?"。这将把所有其他参数值收集到结果命名空间中的一个序列中:

parser.add_argument( "filenames", action="store", nargs="*", metavar="file..." )

当文件名列表是可选的时,通常意味着如果没有提供特定的文件名,将使用STDINSTDOUT

如果我们指定了nargs=,那么结果将变成一个列表。如果我们指定了nargs=1,那么结果对象将是一个单元素列表。如果我们省略了nargs,那么结果就是提供的单个值。

创建一个列表(即使它只有一个元素)很方便,因为我们可能希望以这种方式处理参数:

for filename in config.filenames:
    process( filename )

在某些情况下,我们可能希望提供一个包括STDIN的输入文件序列。这种情况的常见约定是将文件名-作为参数。我们将不得不在我们的应用程序中处理这个,使用以下代码之类的东西:

for filename in config.filenames:
    if filename == '-':
        process(sys.stdin)
    else:
        with open(filename) as input:
            process(input)

这向我们展示了一个循环,将尝试处理多个文件名,可能包括-以显示何时在文件列表中处理标准输入。try:块可能应该在with语句周围使用。

--version 显示并退出

显示版本号的选项是如此常见,以至于有一个特殊的快捷方式来显示版本信息:

parser.add_argument( "-V", "--version", action="version", version=__version__ )

这个例子假设我们在文件的某个地方有一个全局模块__version__= "3.3.2"。这个特殊的action="version"将在显示版本信息后退出程序。

--help 显示并退出

显示帮助的选项是argparse的默认功能。另一个特殊情况允许我们更改help选项的默认设置为-h--help。这需要两件事。首先,我们必须使用add_help=False创建解析器。这将关闭内置的-h--help功能。之后,我们将添加我们想要使用的参数(例如'-?'),并使用action="help"。这将显示帮助文本并退出。

集成命令行选项和环境变量

环境变量的一般政策是它们是配置输入,类似于命令行选项和参数。在大多数情况下,我们使用环境变量来设置很少更改的设置。我们经常通过.bashrc.bash_profile文件设置它们,以便每次登录时都适用这些值。我们还可以在/etc/bashrc文件中更全局地设置环境变量,以便它们适用于所有用户。我们也可以在命令行上设置环境变量,但这些设置只在会话登录期间持续。

在某些情况下,我们所有的配置设置都可以在命令行上提供。在这种情况下,环境变量可以用作慢慢变化的变量的备用语法。

在其他情况下,我们提供的配置值可能被分隔成由环境变量提供的设置和由命令行选项提供的设置。我们可能需要从环境中获取一些值,并合并来自命令行的值。

我们可以利用环境变量来设置配置对象中的默认值。我们希望在解析命令行参数之前收集这些值。这样,命令行参数可以覆盖环境变量。有两种常见的方法:

  • 在定义命令行选项时明确设置值:这样做的好处是默认值会显示在帮助消息中。这仅适用于与命令行选项重叠的环境变量。我们可以这样做,使用SIM_SAMPLES环境变量提供一个可以被覆盖的默认值:
parser.add_argument( "--samples", action="store",
    **default=int(os.environ.get("SIM_SAMPLES",100)),
    type=int, help="Samples to generate" )
  • 隐式设置值作为解析过程的一部分:这样可以简单地将环境变量与命令行选项合并为单个配置。我们可以用默认值填充命名空间,然后用来自命令行解析的值覆盖它。这为我们提供了三个级别的选项值:解析器中定义的默认值,种子到命名空间的覆盖值,最后是命令行提供的任何覆盖值。
config4= argparse.Namespace()
config4.samples= int(os.environ.get("SIM_SAMPLES",100))
config4a= parser.parse_args( namespace=config4 )

注意

参数解析器可以执行非简单字符串的值的类型转换。然而,收集环境变量并不自动涉及类型转换。对于具有非字符串值的选项,我们必须在应用程序中执行类型转换。

提供更多可配置的默认值

我们可以将配置文件与环境变量和命令行选项一起使用。这为我们提供了三种向应用程序提供配置的方式:

  • 配置文件的层次结构可以提供默认值。参见第十三章,“配置文件和持久性”,有关如何执行此操作的示例。

  • 环境变量可以提供对配置文件的覆盖。这可能意味着从环境变量命名空间转换为配置命名空间。

  • 命令行选项定义了最终的覆盖。

同时使用这三种方式可能过于繁琐。如果有太多地方可以搜索,查找设置可能变得混乱。关于配置的最终决定通常取决于与整体应用程序和框架的一致性。我们应该努力使我们的编程与其他组件无缝配合。

我们将看两个与此主题有关的小变化。第一个示例向我们展示了如何使用环境变量覆盖配置文件设置。第二个示例向我们展示了配置文件覆盖全局环境变量设置。

使用环境变量覆盖配置文件设置

我们将使用一个三阶段过程来整合环境变量,并认为它们比配置文件设置更重要。首先,我们将从环境变量构建一些默认设置:

env_values= [
    ("attribute_name", os.environ.get( "SOMEAPP_VARNAME", None )),
    ("another_name", os.environ.get( "SOMEAPP_OTHER", None )),
    etc.
]

创建这样的映射会将外部环境变量名称(SOMEAPP_VARNAME)重写为内部配置名称(attribute_name),这将与我们应用程序的配置属性匹配。对于未定义的环境变量,我们将得到None作为默认值。我们稍后会将这些过滤掉。

接下来,我们将解析一系列配置文件的层次结构,以收集背景配置:

config_name= "someapp.yaml"
config_locations = (
        os.path.curdir,
        os.path.expanduser("~/"),
        "/etc",
        os.path.expanduser("~thisapp/"), # or thisapp.__file__,
)
candidates = ( os.path.join(dir,config_name)
   for dir in config_locations )
config_names = ( name for name in candidates if os.path.exists(name) )
files_values = [yaml.load(file) for file in config_names]

我们按优先顺序构建了一个位置列表,从最重要的(用户拥有的)到最不重要的(安装的一部分)。对于每个实际存在的文件,我们解析内容以创建从名称到值的映射。我们依赖于 YAML 表示法,因为它灵活且易于人们使用。

我们可以从这些来源构建ChainMap对象的实例:

defaults= ChainMap( dict( (k,v) for k,v in env_values if v is not None ), *files_values )

我们将各种映射组合成一个单一的ChainMap。首先搜索环境变量。当值存在时,首先从用户的配置文件中查找这些值,然后查找其他配置,如果用户配置文件没有提供值。

我们可以使用以下代码来解析命令行参数并更新这些默认值:

config= parser.parse_args( namespace=argparse.Namespace( **defaults ) )

我们将我们的配置文件设置的ChainMap转换为argparse.Namespace对象。然后,我们解析命令行选项以更新该命名空间对象。由于环境变量在ChainMap中排在第一位,它们会覆盖任何配置文件。

使用配置文件覆盖环境变量

一些应用程序使用环境变量作为可以被配置文件覆盖的基础默认值。在这种情况下,我们将改变构建ChainMap的顺序。在先前的示例中,我们首先放置了环境变量。我们可以将env_config放在defaults.maps的最后,使其成为最终的后备:

defaults= ChainMap( *files_values )
defaults.maps.append( dict( (k,v) for k,v in env_values if v is not None ) )

最后,我们可以使用以下代码来解析命令行参数并更新这些默认值:

config= parser.parse_args( namespace=argparse.Namespace( **defaults ) )

我们将我们的配置文件设置的ChainMap转换为argparse.Namespace对象。然后,我们解析命令行选项以更新该命名空间对象。由于环境变量在ChainMap中排在最后,它们提供了配置文件中缺失的任何值。

使配置了解None

设置环境变量的这个三阶段过程包括许多常见的参数和配置设置来源。我们并不总是需要环境变量、配置文件和命令行选项。一些应用程序可能只需要这些技术的子集。

我们经常需要类型转换,以保留None值。保留None值将确保我们可以知道环境变量未设置时。以下是一个更复杂的类型转换,它是None-aware的:

def nint( x ):
    if x is None: return x
    return int(x)

我们可以在以下上下文中使用nint()转换来收集环境变量:

env_values= [
    ('samples', nint(os.environ.get("SIM_SAMPLES", None)) ),
    ('stake', nint(os.environ.get( "SIM_STAKE", None )) ),
    ('rounds', nint(os.environ.get( "SIM_ROUNDS", None )) ),
]

如果环境变量未设置,将使用默认值None。如果环境变量已设置,则将值转换为整数。在后续处理步骤中,我们可以依赖None值来仅从正确的值构建字典,而不是None

自定义帮助输出

以下是直接来自默认argparse.print_help()代码的一些典型输出:

usage: p3_c16.py [-v] [--debug] [--dealerhit {Hit17,Stand17}]
                 [--resplit {ReSplit,NoReSplit,NoReSplitAces}] [--decks DECKS]
                 [--limit LIMIT] [--payout PAYOUT]
                 [-p {SomeStrategy,AnotherStrategy}]
                 [-b {Flat,Martingale,OneThreeTwoSix}] [-r ROUNDS] [-s STAKE]
                 [--samples SAMPLES] [-V] [-?]
                 output

Simulate Blackjack

positional arguments:
  output

optional arguments:
  -v, --verbose
  --debug
  --dealerhit {Hit17,Stand17}
  --resplit {ReSplit,NoReSplit,NoReSplitAces}
  --decks DECKS
  --limit LIMIT
  --payout PAYOUT
  -p {SomeStrategy,AnotherStrategy}, --playerstrategy {SomeStrategy,AnotherStrategy}
  -b {Flat,Martingale,OneThreeTwoSix}, --bet {Flat,Martingale,OneThreeTwoSix}
  -r ROUNDS, --rounds ROUNDS
  -s STAKE, --stake STAKE
  --samples SAMPLES
  -V, --version         show program's version number and exit
  -?, --help

默认帮助文本是从我们的解析器定义中的四个部分构建的:

  • 使用行是选项的摘要。我们可以用我们自己的使用文本替换默认的计算,省略不常用的细节。

  • 接着是描述。默认情况下,我们提供的文本会被清理一下。在这个例子中,我们提供了一个简陋的两个词的描述,所以没有明显的清理。

  • 然后显示参数。首先是位置参数,然后是选项,按照我们定义的顺序。

  • 之后显示可选的结语文本。

在某些情况下,这种简洁的提醒是足够的。然而,在其他情况下,我们可能需要提供更多的细节。我们有三个支持更详细帮助的层次:

  • 在参数定义中添加 help=:这是定制帮助细节的起点

  • 使用其他帮助格式化类之一,创建更美观的输出:这是在构建ArgumentParser时使用formatter_class=参数完成的。请注意,ArgumentDefaultsHelpFormatter需要为参数定义添加help=;它将在我们提供的帮助文本中添加默认值。

  • 扩展 ArgumentParser 类并覆盖 print_usage()和 print_help()方法:我们也可以编写一个新的帮助格式化程序。如果我们的选项太复杂,需要这样做,也许我们已经走得太远。

我们的目标是提高可用性。即使我们的程序工作正常,我们也可以通过提供命令行支持来建立信任,使我们的程序更易于使用。

创建一个顶层 main()函数

在第十三章中,配置文件和持久性,我们提出了两种应用程序配置设计模式:

  • 全局属性映射:在前面的例子中,我们使用ArgumentParser创建的Namespace对象实现了全局属性映射。

  • 对象构建:对象构建的想法是从配置参数构建所需的对象实例,有效地将全局属性映射降级为main()函数内的本地属性映射,并不保存属性。

在前一节中,我们向您展示了使用本地Namespace对象收集所有参数。从这里,我们可以构建必要的应用程序对象,这些对象将完成应用程序的实际工作。这两种设计模式并不是二分法;它们是互补的。我们使用Namespace积累了一致的值集,然后基于该命名空间中的值构建了各种对象。

这引导我们设计一个顶层函数。在看实现之前,我们需要考虑一个合适的函数名称;有两种方法来命名函数:

  • 将其命名为main(),因为这是应用程序整体起点的常用术语

  • 不要将其命名为main(),因为main()太模糊,从长远来看没有意义

我们认为这也不是一个二分法,我们应该做两件事。定义一个名为verb_noun()的顶层函数,描述操作。添加一行main= verb_noun来提供一个main()函数,帮助其他开发人员了解应用程序的工作方式。

这个两部分的实现让我们通过扩展来改变main()的定义。我们可以添加函数并重新分配名称main。旧的函数名称保留在原地,作为一个稳定的、不断增长的 API 的一部分。

这是一个顶层应用程序脚本,它从配置Namespace对象构建对象。

import ast
import csv
def simulate_blackjack( config ):
    dealer_rule= {'Hit17': Hit17, 'Stand17': Stand17,
  }[config.dealer_rule]()
    split_rule= {'ReSplit': ReSplit,
  'NoReSplit': NoReSplit, 'NoReSplitAces':NoReSplitAces,
  }[config.split_rule]()
    try:
       payout= ast.literal_eval( config.payout )
       assert len(payout) == 2
    except Exception as e:
       raise Exception( "Invalid payout {0}".format(config.payout) ) from e
    table= Table( decks=config.decks, limit=config.limit, dealer=dealer_rule,
        split=split_rule, payout=payout )
    player_rule= {'SomeStrategy': SomeStrategy,
  'AnotherStrategy': AnotherStrategy,
  }[config.player_rule]()
    betting_rule= {"Flat":Flat,
  "Martingale":Martingale, "OneThreeTwoSix":OneThreeTwoSix,
  }[config.betting_rule]()
    player= Player( play=player_rule, betting=betting_rule,
        rounds=config.rounds, stake=config.stake )
    simulate= Simulate( table, player, config.samples )
    with open(config.outputfile, "w", newline="") as target:
        wtr= csv.writer( target )
        **wtr.writerows( simulate )

这个函数依赖于外部提供的带有配置属性的Namespace对象。它没有命名为main(),这样我们可以进行未来更改,改变main的含义。

我们构建了各种对象——TablePlayerSimulate——这是必需的。我们根据配置参数的初始值配置了这些对象。

我们实际上已经完成了真正的工作。在所有对象构造之后,实际工作是一行突出显示的代码:wtr.writerows( simulate )。程序的 90%时间将在这里度过,生成样本并将其写入所需的文件。

GUI 应用程序也适用类似的模式。它们进入一个主循环来处理 GUI 事件。这种模式也适用于进入主循环以处理请求的服务器。

我们依赖于将配置对象作为参数传递。这符合我们最小化依赖关系的测试策略。这个顶层的simulate_blackjack()函数不依赖于配置是如何创建的细节。然后我们可以在应用程序脚本中使用这个函数:

if __name__ == "__main__":
        logging.config.dictConfig( yaml.load("logging.config") )
    config5= gather_configuration()
    simulate_blackjack( config5 )
    logging.shutdown()

这代表了关注点的分离。我们将应用程序的工作嵌套到了两个封装级别中。

外层封装由日志定义。我们在所有其他应用程序组件之外配置日志,以确保各种顶层模块、类或函数之间没有冲突尝试配置日志。如果应用程序的特定部分尝试配置日志,那么进行更改可能会导致冲突。特别是当我们考虑将应用程序合并到更大的复合处理中时,我们需要确保两个被合并的应用程序不会产生冲突的日志配置。

内层封装由应用程序的配置定义。我们不希望在单独的应用程序组件之间发生冲突。我们希望允许我们的命令行 API 与我们的应用程序分开发展。我们希望能够将应用程序处理嵌入到单独的环境中,可能由multiprocessing或 RESTful web 服务器定义。

确保配置的 DRY

我们在构建参数解析器和使用参数配置应用程序之间存在潜在的 DRY 问题。我们使用了一些重复的键来构建参数。

我们可以通过创建一些全局内部配置来消除这种重复。例如,我们可以定义这个全局变量如下所示:

dealer_rule_map = { "Hit17": Hit17, "Stand17", Stand17 }

我们可以用它来创建参数解析器:

parser.add_argument( "--dealerhit", action="store", default="Hit17", choices=dealer_rule_map.keys(), dest='dealer_rule')

我们可以用它来创建工作对象:

dealer_rule= dealer_rule_map[config.dealer_rule]()

这消除了重复。它允许我们在应用程序发展的过程中在一个地方添加新的类定义和参数键映射。它还允许我们缩写或以其他方式重写外部 API,如下所示:

dealer_rule_map = { "H17": Hit17, "S17": Stand17 }

有四种从命令行(或配置文件)字符串到应用程序类的映射。使用这些内部映射简化了simulate_blackjack()函数。

管理嵌套的配置上下文

在某种程度上,嵌套上下文的存在意味着顶层脚本应该看起来像以下代码:

if __name__ == "__main__":
    with Logging_Config():
        with Application_Config() as config:
            simulate_blackjack( config )

我们添加了两个上下文管理器。有关更多信息,请参见第五章,使用可调用和上下文。这里有两个上下文管理器:

class Logging_Config:
    def __enter__( self, filename="logging.config" ):
       logging.config.dictConfig( yaml.load(filename) )
    def __exit__( self, *exc ):
       logging.shutdown()

class Application_Config:
    def __enter__( self ):
        # Build os.environ defaults.
        # Load files.
        # Build ChainMap from environs and files.
        # Parse command-line arguments.
        return namespace
    def __exit__( self, *exc ):
        pass

Logging_Config上下文管理器配置日志。它还确保在应用程序完成时正确关闭日志。

Application_Config上下文管理器可以从多个文件以及命令行参数中收集配置。在这种情况下,使用上下文管理器并非必要。然而,它为扩展留下了空间。

这种设计模式可能会澄清围绕应用程序启动和关闭的各种关注点。虽然对于大多数应用程序来说可能有点多,但这种设计符合 Python 上下文管理器的哲学,似乎在应用程序增长和扩展时会有所帮助。

当我们面对一个不断增长和扩展的应用程序时,我们经常会进行大规模编程。因此,将可变的应用程序处理与不太可变的处理上下文分离开来非常重要。

大规模编程

让我们为我们的二十一点模拟添加一个功能:结果分析。我们有几种方法来实现这个新增功能。我们的考虑有两个方面,导致了大量的组合。我们考虑的一个方面是如何设计新功能:

  • 添加一个函数

  • 使用命令设计模式

另一个方面是如何打包新功能:

  • 编写一个新的顶层脚本文件。我们会有基于文件的新命令,文件名类似simulate.pyanalyze.py

  • 向应用程序添加一个参数,允许一个脚本执行模拟或分析。我们会有类似app.py simulateapp.py analyze的命令。

这四种组合都是实现这一点的合理方式。我们将专注于使用命令设计模式。首先,我们将修改现有的应用程序以使用命令设计模式。然后,我们将通过添加功能来扩展我们的应用程序。

设计命令类

许多应用程序涉及隐式的命令设计模式。毕竟,我们正在处理数据。为了做到这一点,必须至少有一个定义应用程序如何转换、创建或消耗数据的主动语态动词。一个非常简单的应用程序可能只有一个动词,实现为一个函数。使用命令类设计模式可能没有帮助。

更复杂的应用程序将具有多个相关的动词。GUI 和 Web 服务器的关键特性之一是它们可以执行多个操作,从而导致多个命令。在许多情况下,GUI 菜单选项定义了应用程序的动词域。

在某些情况下,应用程序的设计源自对更大、更复杂的动词的分解。我们可以将整体处理分解为几个较小的命令步骤,然后将它们组合成最终的应用程序。

当我们看应用程序的演变时,我们经常看到一种模式,即新功能的堆积。在这些情况下,每个新功能都可以成为应用程序类层次结构中添加的一种独立的命令子类。

这是命令的抽象超类:

class Command:
    def set_config( self, config ):
        self.__dict__.update( config.__dict__ )
    config= property( fset=set_config )
    def run( self ):
        pass

我们通过将config属性设置为types.SimpleNamespaceargparse.Namespace,甚至另一个Command实例来配置这个Command类。这将从namespace对象中填充实例变量。

一旦对象被配置,我们可以通过调用run()方法来让它执行命令的工作。这个类实现了一个相对简单的用例:

    main= SomeCommand()
    main.config= config
    main.run()

这是一个实现二十一点模拟的具体子类:

class Simulate_Command( Command ):
    dealer_rule_map = {"Hit17": Hit17, "Stand17": Stand17}
    split_rule_map = {'ReSplit': ReSplit,
        'NoReSplit': NoReSplit, 'NoReSplitAces': NoReSplitAces}
    player_rule_map = {'SomeStrategy': SomeStrategy,
        'AnotherStrategy': AnotherStrategy}
    betting_rule_map = {"Flat": Flat,
        "Martingale": Martingale, "OneThreeTwoSix": OneThreeTwoSix}

    def run( self ):
        dealer_rule= self.dealer_rule_map[self.dealer_rule]()
        split_rule= self.split_rule_map[self.split_rule]()
        try:
           payout= ast.literal_eval( self.payout )
           assert len(payout) == 2
        except Exception as e:
           raise Exception( "Invalid payout {0}".format(self.payout) ) from e
        table= Table( decks=self.decks, limit=self.limit, dealer=dealer_rule,
            split=split_rule, payout=payout )
        player_rule= self.player_rule_map[self.player_rule]()
        betting_rule= self.betting_rule_map[self.betting_rule]()
        player= Player( play=player_rule, betting=betting_rule,
            rounds=self.rounds, stake=self.stake )
        simulate= Simulate( table, player, self.samples )
        with open(self.outputfile, "w", newline="") as target:
            wtr= csv.writer( target )
            wtr.writerows( simulate )

这个类实现了配置各种对象然后执行模拟的基本顶层函数。我们将之前显示的simulate_blackjack()函数包装起来,以创建Command类的具体扩展。这可以在主脚本中像下面的代码一样使用:

if __name__ == "__main__":
    with Logging_Config():
        with Application_Config() as config:    
            main= Simulate_Command()
            main.config= config
            main.run()

虽然我们可以将这个命令变成Callable,并使用main()而不是main.run(),但使用可调用对象可能会令人困惑。我们明确地分离了三个设计问题:

  • 构造:我们特意保持了初始化为空。在后面的部分,我们将向您展示一些 PITL 的示例,我们将从较小的组件命令构建一个更大的复合命令。

  • 配置:我们通过property setter 设置了配置,与构造和控制隔离开来。

  • 控制:这是命令在构建和配置后的真正工作。

当我们看一个可调用对象或函数时,构造是定义的一部分。配置和控制合并到函数调用本身中。如果我们尝试定义一个可调用对象,我们会牺牲一点灵活性。

添加分析命令子类

我们将通过添加分析功能来扩展我们的应用程序。由于我们使用了命令设计模式,我们可以为分析添加另一个子类。

这是我们的分析功能:

class Analyze_Command( Command ):
    def run( self ):
        with open(self.outputfile, "r", newline="") as target:
            rdr= csv.reader( target )
            outcomes= ( float(row[10]) for row in rdr )
            first= next(outcomes)
            sum_0, sum_1 = 1, first
            value_min = value_max = first
            for value in outcomes:
                sum_0 += 1 # value**0
                sum_1 += value # value**1
                value_min= min( value_min, value )
                value_max= max( value_max, value )
            mean= sum_1/sum_0
            print(
        "{4}\nMean = {0:.1f}\nHouse Edge = {1:.1%}\nRange = {2:.1f} {3:.1f}".format(
        mean, 1-mean/50, value_min, value_max, self.outputfile) )

这并不是太有统计意义,但重点是向您展示使用配置命名空间来执行与我们的模拟相关的工作的第二个命令。我们使用outputfile配置参数来命名要读取以执行一些统计分析的文件。

向应用程序添加和打包更多功能

先前,我们注意到支持多个功能的一种常见方法。一些应用程序使用多个顶级主程序,分别在单独的.py脚本文件中。

当我们想要组合在不同文件中的命令时,我们被迫编写一个 shell 脚本来创建一个更高级的复合程序。引入另一个工具和另一种语言来进行 PITL 似乎并不是最佳选择。

创建单独的脚本文件的一个稍微更灵活的替代方法是使用位置参数来选择特定的顶级Command对象。对于我们的示例,我们想要选择模拟或分析命令。为此,我们将在命令行参数解析以下代码中添加一个参数:

parser.add_argument( "command", action="store", default='simulate', choices=['simulate', 'analyze'] )
parser.add_argument( "outputfile", action="store", metavar="output" )

这将改变命令行 API 以向命令行添加顶级动词。我们可以轻松地将我们的参数值映射到类名:

{'simulate': Simulate_Command, 'analyze': Analyze_Command}[options.command]

这使我们能够创建更高级的复合功能。例如,我们可能希望将模拟和分析合并为单个整体程序。此外,我们希望这样做而不使用 shell。

设计一个更高级的复合命令

以下是我们如何设计一个由其他命令构建的复合命令。我们有两种设计策略:对象组合和类组合。

如果我们使用对象组合,那么我们的复合命令是基于内置的listtuple。我们可以扩展或包装现有序列之一。我们将创建复合Command对象作为其他Command对象的实例集合。我们可能考虑编写以下代码:

    simulate_and_analyze = [Simulate(), Analyze()]

这样做的缺点是我们没有为我们独特的复合命令创建一个新的类。我们创建了一个通用的复合命令,并用实例填充了它。如果我们想创建更高级的组合,我们将不得不解决低级Command类和基于内置序列类的更高级复合Command对象之间的不对称性。

我们更希望复合命令也是command的子类。如果我们使用类组合,那么我们将为我们的低级命令和更高级的复合命令拥有更一致的结构。

这是一个实现其他命令序列的类:

class Command_Sequence(Command):
    sequence = []
    def __init__( self ):
        self._sequence = [ class_() for class_ in self.sequence ]
    def set_config( self, config ):
        for step in self._sequence:
            step.config= config
    config= property( fset=set_config )
    def run( self ):
        for step in self._sequence:
            step.run()

我们定义了一个类级变量sequence,用于包含一系列命令类。在对象初始化期间,__init__()将使用self.sequence中命名类的对象构造内部实例变量_sequence

当配置设置时,它将被推送到每个组成对象中。当通过run()执行复合命令时,它将被委托给复合命令中的每个组件。

这是一个由其他两个Command子类构建的Command子类:

class Simulate_and_Analyze(Command_Sequence):
    sequence = [Simulate_Command, Analyze_Command]

我们现在可以创建一个包含单个步骤序列的类。由于这是Command类本身的子类,它具有必要的多态 API。我们现在可以使用这个类创建组合,因为它与Command的所有其他子类兼容。

我们现在可以对参数解析进行非常小的修改,以将此功能添加到应用程序中:

parser.add_argument( "command", action="store", default='simulate', choices=['simulate', 'analyze', 'simulate_analyze'] )

我们只是在参数选项值中添加了另一个选择。我们还需要微调从参数选项字符串到类的映射:

{'simulate': Simulate_Command, 'analyze': Analyze_Command, 'simulate_analyze': Simulate_and_Analyze}[options.command]

请注意,我们不应该使用模糊的名称,比如both来组合两个命令。如果我们避免模糊,我们就有机会扩展或修改我们的应用程序。使用命令设计模式使添加功能变得愉快。我们可以定义复合命令,或者我们可以将更大的命令分解为更小的子命令。

打包和实现可能涉及添加选项选择并将该选择映射到类名。如果我们使用更复杂的配置文件(参见第十三章,“配置文件和持久性”),我们可以直接在配置文件中提供类名,并保存从选项字符串到类的映射。

其他复合命令设计模式

我们可以识别出许多复合设计模式。在前面的例子中,我们设计了一个序列复合。为了获得灵感,我们可以看一下 bash shell 的复合运算符:;&|,以及()用于分组。除此之外,我们还有 shell 中的ifforwhile循环。

我们在Command_Sequence类定义中看到了序列运算符(;)。这种序列的概念是如此普遍,以至于许多编程语言(如 shell 和 Python)不需要显式的运算符;语法简单地使用行尾作为隐含的序列运算符。

shell 中的&运算符创建了两个同时运行而不是顺序运行的命令。我们可以创建一个Command_Concurrent类定义,其中包含一个使用multiprocessing创建两个子进程并等待两者都完成的run()方法。

shell 中的|运算符创建了一个管道:一个命令的输出缓冲区是另一个命令的输入缓冲区;这些命令同时运行。在 Python 中,我们需要创建一个队列以及两个进程来读取和写入该队列。这是一个更复杂的情况;它涉及将队列对象填充到各个子对象的配置中。第十二章,“传输和共享对象”中有一些使用multiprocessing和队列在并发进程之间传递对象的示例。

shell 中的if命令有许多用例。然而,没有必要提供比Command子类中的方法更多的东西。创建一个复杂的Command类来模仿 Python 的if-elif-else处理是没有帮助的。我们可以——也应该——只使用 Python。

shell 中的whilefor命令同样不是我们需要在更高级别的Command子类中定义的东西。我们可以在 Python 中的一个方法中简单地编写这个。

以下是一个应用现有命令到集合中所有值的for-all类定义的示例:

class ForAllBets_Simulate( Command ):
    def run( self ):
        for bet_class in "Flat", "Martingale", "OneThreeTwoSix":
            self.betting_rule= bet_class
            self.outputfile= "p3_c16_simulation7_{0}.dat".format(bet_class)
            sim= Simulate_Command()
            sim.config= self
            sim.run()

我们列举了模拟中的三种投注类别。对于这些类别中的每一个,我们调整了配置,创建了一个模拟,并执行了该模拟。

请注意,这个for-all类与之前定义的Analyze_Command类不兼容。我们不能简单地创建反映不同工作范围的复合。Analyze_Command类运行单个模拟,但ForAllBets_Simulate类运行一系列模拟。我们有两种选择来创建兼容的工作范围:我们可以创建一个Analyze_All命令或ForAllBets_Sim_and_Analyze命令。设计决策取决于用户的需求。

与其他应用程序集成

在与其他应用程序集成时,我们可以使用 Python 的几种方法。由于有许多应用程序,每个应用程序都具有独特的特点,因此很难提供全面的概述。我们可以向您展示一些广泛的设计模式:

  • Python 可能是应用的脚本语言。对于许多示例,这里有一个包含 Python 作为添加功能的主要方法的应用程序列表:wiki.python.org/moin/AppsWithPythonScripting

  • Python 模块可以实现应用程序的 API。许多应用程序包括提供与应用程序 API 绑定的 Python 模块的 Python 模块。在一种语言中工作的应用程序开发人员通常会为其他语言提供 API 库,包括 Python。

  • 我们可以使用ctypes模块直接在 Python 中实现另一个应用程序的 API。这在针对 C 或 C ++的应用程序库的情况下效果很好。

  • 我们可以使用STDINSTDOUT来创建一个与另一个应用程序连接的 shell 级管道。在构建与 shell 兼容的应用程序时,我们可能还需要查看fileinput模块。

  • 我们可以使用subprocess模块来访问应用程序的命令行界面。这可能还涉及连接到应用程序的 stdin 和 stdout 以正确地与其交互。

  • 我们还可以用 C 或 C ++编写自己的 Python 兼容模块。在这种情况下,我们可以在 C 中实现外部应用程序的 API,提供 Python 应用程序可以利用的类或函数。这可能比使用ctypes API 性能更好。由于这需要编译 C 或 C ++,因此工具更加密集。

这种灵活性意味着我们经常使用 Python 作为集成框架或粘合剂,从较小的应用程序创建更大的复合应用程序。在使用 Python 进行集成时,我们经常会有 Python 类和对象,这些类和对象与另一个应用程序中的定义相对应。

还有一些额外的设计考虑,我们将在第十七章中保存,模块和包设计。这些是更高级别的架构设计考虑,超出了处理命令行的范围。

总结

我们看了如何使用argparseos.environ来收集命令行参数和配置参数。这是在第十三章中展示的技术的基础上构建的,配置文件和持久性

我们可以使用argparse实现许多常见的命令行功能。这包括常见功能,例如显示版本号并退出或显示帮助文本并退出。

我们研究了使用命令设计模式来创建可以扩展或重构以提供新功能的应用程序。我们的目标是明确地使顶级主函数的主体尽可能小。

设计考虑和权衡

命令行 API 是成品应用程序的重要部分。虽然我们的大部分设计工作集中在程序运行时的操作上,但我们确实需要解决两个边界状态:启动和关闭。启动应用程序时,必须易于配置。此外,必须优雅地关闭,正确地刷新所有输出缓冲区并释放所有操作系统资源。

在使用面向公众的 API 时,我们必须解决模式演变问题的变化。随着我们的应用程序的发展以及我们对用户的了解的发展,我们将修改命令行 API。这可能意味着我们将拥有遗留功能或遗留语法。这也可能意味着我们需要打破与遗留命令行设计的兼容性。

在许多情况下,我们需要确保主版本号是应用程序名称的一部分。我们不应该编写名为someapp的顶级模块。我们应该考虑从someapp1开始,以便数字始终是应用程序名称的一部分。我们不应该通过添加版本号作为新后缀来更改命令行 API;从someapp1开始,可以预期可能过渡到someapp2

展望未来

在下一章中,我们将扩展一些这些顶层设计思想,并研究模块和包的设计。一个小的 Python 应用程序也可以是一个模块;它可以被导入到一个更大的应用程序中。一个复杂的 Python 应用程序可能是一个包。它可能包括其他应用程序模块,并且可能被包含到更大规模的应用程序中。

第十七章:模块和包设计

Python 为我们提供了几个更高级的构造来组织软件。在第一部分中,通过特殊方法创建 Python 类,我们研究了使用类定义的高级技术,以正确地将结构和行为绑定在一起。在本章中,我们将研究模块来封装类、函数和全局对象。在模块分组之上,我们还有包作为一种设计模式,将相关模块组合在一起。

Python 非常容易创建简单的模块。每当我们创建一个 Python 文件时,我们就创建了一个模块。随着我们设计的范围变得更大和更复杂,使用包对于保持模块之间的清晰组织变得更加重要。

我们还有一些专门的模块。对于一个更大的应用程序,我们可以实现一个__main__模块。这个模块必须被设计为将操作系统命令行界面暴露给应用程序。它还必须以一种不会阻碍简单重用应用程序来创建更大的复合应用程序的方式进行定义。

我们在安装模块时也有一些灵活性。我们可以使用默认的工作目录、环境变量设置、.pth文件,以及 Python 的lib/site-packages目录。每种方法都有优缺点。

我们将避免分发 Python 代码的更复杂问题。有许多技术可以为 Python 项目创建源代码分发。各种分发技术超出了面向对象的设计。《Python 标准库》的第三十章解决了一些物理文件打包问题。《分发 Python 模块》文档提供了有关创建代码分发的信息。

设计一个模块

模块是 Python 实现和重用的单位。所有的 Python 编程都是在模块级别提供的。类是面向对象设计和编程的基础。模块——一组类——是一个更高级别的分组,也是 Python 中重用的单位。我们不能轻松地单独重用一个类。一个经过正确设计的模块可以被重用。

Python 模块是一个文件。文件名的扩展名必须是.py.py前面的文件名必须是一个有效的 Python 名称。《Python 语言参考》的 2.3 节为我们提供了名称的完整定义。在这个定义中的一个条款是:在 ASCII 范围内(U+0001..U+007F),标识符的有效字符是大写和小写字母 A 到 Z、下划线 _ 和除第一个字符外的数字 0 到 9

操作系统文件名允许使用 ASCII 范围内的更多字符,而不是 Python 名称;必须忽略这种额外的操作系统复杂性。文件名(不带.py)就是模块名。

每次我们创建一个.py文件,我们就创建了一个模块。通常,我们会创建一个 Python 文件,而不会做太多的设计工作。在本章中,我们将研究一些设计考虑因素,以创建可重用的模块。

提示

Python 也可能为自己的私有目的创建.pyc.pyo文件;最好是简单地忽略这些文件。许多程序员试图利用.pyc文件作为一种编译的对象代码,以便代替.py文件,以某种方式保持源代码的机密性,这样做是浪费脑力。.pyc文件可以很容易地反编译;它们不会保持任何机密性。如果您需要防止应用程序的逆向工程,您可能需要考虑使用其他语言。

一些模块设计模式

Python 模块通常有三种常见的设计模式:

  • 纯库模块:这些模块是用于导入的。它们包含类、函数的定义,也许还有一些赋值语句来创建一些全局变量。它们不执行任何真正的工作,因此可以被导入而不用担心导入操作的副作用。我们将看一下两种用例:

  • 整个模块:一些模块被设计为整体导入,创建包含所有项目的模块命名空间

  • 项目集合:一些模块被设计成可以导入单独的项目,而不是创建一个模块对象

  • 主脚本模块:这些模块是用于从命令行执行的。它们包含的不仅仅是类和函数定义。它们将包括执行真正工作的语句;它们可能有副作用;它们不能被有意义地导入,因为有惊人的副作用。如果尝试导入主脚本模块,它实际上会执行——执行工作,可能更新文件,或者在运行时模块设计的任何其他操作。

  • 条件脚本模块:这些模块有两种用例:它们可以被导入,也可以从命令行运行。这些模块将具有主导入开关,如Python 标准库第 28.4 节所述,main—顶层脚本环境

以下是库文档中简化的条件脚本开关:

if __name__ == "__main__":
    main()

main()函数执行脚本的工作。这种设计支持两种用例:runimport。当模块从命令行运行时,它会评估main()并执行预期的工作。当模块被导入时,函数不会被评估,导入只会提供定义而不执行任何真正的工作。

我们建议使用更复杂的方法,如第十六章中所示,处理命令行

if __name__ == "__main__":
    with Logging_Config():
        with Application_Config() as config:    
            main= Simulate_Command()
            main.config= config
            main.run()

我们的观点是要重申以下重要的设计提示:

提示

导入模块应该有很少的副作用。

创建一些模块级变量是导入的可接受副作用。真正的工作——访问网络资源、打印输出、更新文件和其他类型的副作用——在导入模块时不应该发生。

没有__name__ == "__main__"部分的主脚本模块通常是一个坏主意,因为它不能被导入和重用。除此之外,文档工具很难处理主脚本模块,并且很难进行测试。文档工具倾向于导入模块,导致意外执行工作。同样,测试需要小心避免在测试设置的一部分导入模块。

模块与类

模块和类定义之间有许多相似之处:

  • 模块和类都有一个 Python 名称。模块通常以小写字母开头;类通常以大写字母开头。

  • 模块和类定义是包含其他对象的命名空间。

  • 模块是全局命名空间sys.modules中的单例对象。类定义在命名空间中是唯一的,可以是全局命名空间__main__或某个局部命名空间。类不是一个合适的单例;定义可以被替换。一旦被导入,模块就不会再次被导入,除非它被删除。

  • 类或模块的定义被作为一系列语句在一个命名空间中进行评估。

  • 在模块中定义的函数类似于类定义中的静态方法。

  • 在模块中定义的类类似于在另一个类中定义的类。

模块和类之间有两个重要的区别:

  • 我们不能创建模块的实例;它总是一个单例。我们可以创建多个类的实例。

  • 模块中的赋值语句创建了一个在模块命名空间中全局的变量;它可以在模块内部使用而不需要限定符。类定义中的赋值语句创建了一个属于类命名空间的变量,需要限定符来区分它与全局变量。

提示

模块就像一个类。模块、包和类可以用来封装数据和处理——属性和操作——成一个整洁的对象。

模块和类之间的相似之处意味着在它们之间进行选择是一个具有权衡和替代方案的设计决策。在大多数情况下,对实例的需求是决定因素。模块的单例特性意味着我们将使用一个模块(或包)来包含只扩展一次的类和函数定义,即使被多次导入。

然而,有一些模块可能非常类似于类。例如,logging模块经常被多个其他模块导入。单例特性意味着日志配置只需进行一次,就会应用于所有其他模块。

类似地,配置模块可能会在多个地方被导入。模块的单例特性确保了配置可以被任何模块导入,但确实是真正的全局的。

在编写与单个连接的数据库一起工作的应用程序时,具有多个函数的模块将类似于单例类。数据库访问层可以在整个应用程序中导入,但将是一个单一的、共享的全局对象。

模块的预期内容

Python 模块有一个典型的组织结构。在某种程度上,这是由 PEP 8 定义的,www.python.org/dev/peps/pep-0008/

模块的第一行可以是#!注释;典型的版本看起来像以下代码:

#!/usr/bin/env python3.3

这用于帮助bash等 OS 工具定位可执行脚本文件的 Python 解释器。对于 Windows,这一行可能是#!C:\Python3\python.exe之类的。

旧的 Python 模块可能包括一个编码注释来指定其余文本的编码。这可能看起来像以下代码:

# -*- coding: utf-8 -*-

Python 3 通常不需要编码注释;操作系统编码信息就足够了。旧的 Python 实现假定文件是用 ASCII 编码的;对于不是 ASCII 编码的文件,需要编码注释。

模块的下一行应该是一个三引号的模块文档字符串,定义了模块文件的内容。与其他 Python 文档字符串一样,文本的第一段应该是一个摘要。这之后应该是对模块内容、目的和用法的更完整的定义。这可能包括 RST 标记,以便文档工具可以从文档字符串中产生优雅的结果。我们将在第十八章中讨论这个问题,质量和文档

在文档字符串之后,我们可以包含任何版本的控制信息。例如,我们可能有以下代码:

__version__ = "2.7.18"

这是一个全局模块,我们可能会在应用程序的其他地方使用它来确定模块的版本号。这是在文档字符串之后但在模块的主体之前。在这之后是模块的import语句。按照惯例,它们通常在模块的开头以一个大块的形式出现。

import语句之后是模块的各种类和函数定义。这些按照需要的顺序呈现,以确保它们能够正确工作,并对阅读代码的人有意义。

提示

Java 和 C++倾向于一个文件一个类。

这是一个愚蠢的限制。它不适用于 Python,也不是宇宙的自然法则。

如果文件有很多类,我们可能会发现模块有点难以理解。如果我们发现自己使用大量注释块来将模块分成几个部分,这表明我们所写的可能比单个模块更复杂。我们肯定有多个模块;我们可能有一个包。

一些模块的常见特征是在模块的命名空间内创建对象。类级别属性等有状态的模块变量并不是一个好主意。这些变量的可见性是一个潜在的混淆区域。

有时,全局模块很方便。logging模块大量使用这一点。另一个例子是random模块创建Random类的默认实例的方式。这允许一些模块级函数提供一个简单的随机数 API。我们不必创建random.Random的实例。

整个模块与模块项目

库模块的内容有两种方法。一些模块是一个整体,有些更像是一组不太相关的项目。当我们将模块设计为一个整体时,它通常会有一些类或函数,它们是模块的公共 API。当我们将模块设计为一组松散相关的项目时,每个单独的类或函数往往是独立的。

我们经常在导入和使用模块的方式中看到这种区别。我们将看到三种变化:

  • 使用import some_module命令

some_module.py模块文件被评估,并且生成的对象被收集到一个名为some_module的单一命名空间中。这要求我们对模块中的所有对象使用限定名称。我们必须使用some_module.thissome_module.that。这种使用限定名称的方式使模块成为一个整体。

  • 使用from some_module import this命令

some_module.py模块文件被评估,只有命名对象被创建在当前本地命名空间中。通常,这是全局命名空间。现在我们可以在不加限定的情况下使用thisthat。这种使用不加限定的名称声称该模块看起来像是一组不相关的对象。

  • 使用from math import sqrt, sin, cos命令

这将为我们提供一些数学函数,我们可以在不加限定的情况下使用。

  • 使用from some_module import *命令

默认行为是使所有非私有名称成为命名空间的一部分。私有名称以_开头。我们可以通过在模块中提供一个__all__列表来显式限制导入的名称数量。这是一个字符串对象名称的列表;这些名称是由import *语句扩展的名称。

我们可以使用__all__变量来隐藏构建模块的实用函数,但不是 API 的一部分,这些函数提供给模块的客户端。

当我们回顾我们设计的卡牌组的设计时,我们可以选择将花色作为默认情况下不导入的实现细节。如果我们有一个cards.py模块,我们可以包含以下代码:

__all__ = ["Deck", "Shoe"]
class Suit:
    etc.
suits = [ Suit("♣"), Suit("♢"), Suit("♡"), Suit("♠") ]
class Card:
    etc.
def card( rank, suit ):
    etc.
class Deck:
    etc.
class Shoe( Deck ):
    etc.

使用__all__变量使SuitCard类的类定义,card()函数和suits变量作为默认情况下不导入的实现细节。例如,当我们执行以下代码时:

from cards import *	

这个语句只会在应用程序脚本中创建DeckShoe,因为它们是__all__变量中唯一明确给出的名称。

当我们执行以下命令时,它将导入模块,但不会将任何名称放入全局命名空间:

import cards

即使它没有被导入到命名空间,我们仍然可以访问合格的cards.card()方法来创建一个Card类。

每种技术都有优点和缺点。整个模块需要使用模块名称作为限定符;这使得对象的来源变得明确。从模块导入项目会缩短它们的名称,这可以使复杂的编程更紧凑和更易理解。

设计一个包

设计包的一个重要考虑是不要Python 之禅诗(也称为import this)包括这一行:

"平铺胜于嵌套"

我们可以在 Python 标准库中看到这一点。库的结构相对平铺;嵌套模块很少。深度嵌套的包可能被过度使用。我们应该对过度嵌套持怀疑态度。

包本质上是一个带有额外文件__init__.py的目录。目录名称必须是一个合适的 Python 名称。操作系统名称包括许多 Python 名称中不允许的字符。

我们经常看到三种包的设计模式:

  • 简单的包是一个带有空的__init__.py文件的目录。这个包名称成为内部模块名称的限定符。我们将使用以下代码:
import package.module
  • 模块包可以有一个__init__.py文件,实际上是一个模块定义。这可以从包目录中导入其他模块。或者,它可以作为包括顶层模块和合格子模块的更大设计的一部分。我们将使用以下代码:
import package
  • 目录是__init__.py文件在替代实现中进行选择的地方。我们将使用以下代码:
import package

第一种包相对简单。我们添加一个__init__.py文件,就创建了一个包。另外两种包稍微复杂一些;我们将详细看看这些。

设计模块包混合体

在某些情况下,设计会演变成一个非常复杂的模块,以至于单个文件变得不合适。我们可能需要将这个复杂的模块重构为一个包,其中包含几个较小的模块。

在这种情况下,包可以简单到以下结构。这是一个名为blackjack的包目录中的__init__.py文件:

"""Blackjack package"""
from blackjack.cards import Shoe
from blackjack.player import Strategy_1, Strategy_2
from blackjack.casino import ReSplit, NoReSplit, NoReSplitAces,  Hit17, Stand17
from blackjack.simulator import Table, Player, Simulate
from betting import Flat, Martingale, OneThreeTwoSix

这向我们展示了如何构建一个类似模块的包,实际上是从其他子模块导入的部分组装。然后整体应用程序可以这样做:

from blackjack import *
table= Table( decks=6, limit=500, dealer=Hit17(),
        split=NoReSplitAces(), payout=(3,2)  )
player= Player( play=Strategy_1(),  betting=Martingale(), rounds=100, stake=100 )
simulate= Simulate( table, player, 100 )
for result in simulate:
    print( result )

这段代码向我们展示了如何使用from blackjack import *来创建许多类定义,这些类定义源自其他包中的模块。具体来说,有一个名为blackjack的整体包,其中包含以下模块:

  • blackjack.cards包含CardDeckShoe的定义

  • blackjack.player包含各种玩法策略

  • blackjack.casino包含定制赌场规则的多个类

  • blackjack.simulator包含顶层模拟工具

  • betting包也被应用程序用来定义不仅适用于 21 点游戏的各种投注策略

这个包的架构可能简化我们的设计升级。如果每个模块都更小更专注,那么它更易读和更易理解。单独更新每个模块可能更简单。

设计具有替代实现的包

在某些情况下,我们会有一个顶层__init__.py文件,它在包目录中选择一些替代实现。决定可能基于平台、CPU 架构或操作系统库的可用性。

对于具有替代实现的包,有两种常见的设计模式和一种不太常见的设计模式:

  • 检查platformsys以确定实现的细节,并决定使用if语句导入什么。

  • 尝试import并使用try块异常处理来解决配置细节。

  • 作为一个不太常见的替代方案,应用程序可以检查配置参数以确定应该导入什么。这有点复杂。我们在导入应用程序配置和根据配置导入其他应用程序模块之间存在排序问题。直接导入而不需要这种潜在复杂的步骤序列要简单得多。

这是一个some_algorithm包的__init__.py,它根据平台信息选择一个实现:

import platform
bits, linkage = platform.architecture()
if bits == '64bit':
    from some_algorithm.long_version import *
else:
    from some_algorithm.short_version import *

这使用platform模块来确定平台架构的详细信息。这里存在一种排序依赖性,但依赖于标准库模块优于更复杂的应用程序配置模块。

我们将在some_algorithm包中提供两个模块,long_version模块提供适用于 64 位架构的实现;short_version模块提供替代实现。设计必须具有模块同构性;这类似于类同构性。两个模块必须包含具有相同名称和相同 API 的类和函数。

如果两个文件都定义了一个名为SomeClass的类,那么我们可以在应用程序中编写以下代码:

import some_algorithm
process= some_algorithm.SomeClass()

我们可以导入some_algorithm包,就像它是一个模块一样。该包会找到一个合适的实现,并提供所需的类和函数定义。

if语句的替代方案是使用try语句来定位候选实现。当存在不同的分发时,这种技术效果很好。通常,特定于平台的分发可能包含特定于该平台的文件。

在第十四章中,《日志和警告模块》,我们向您展示了在配置错误或问题发生时提供警告的设计模式。在某些情况下,追踪变体配置并不值得警告,因为变体配置是一种设计特性。

这是一个some_algorithm包的__init__.py,它根据包内模块文件的可用性选择一个实现:

try:
    from some_algorithm.long_version import *
except ImportError as e:
    from some_algorithm.short_version import *

这取决于有两个不同的分发,其中一个将包括some_algorithm/long_version.py文件,另一个将包括some_algorithm/short_version.py文件。如果未找到some_algorithm.long_version模块,则将导入short_version

这不适用于超过两到三个替代实现。随着选择数量的增加,except块将变得非常深层嵌套。另一种选择是将每个try包装在if中,以创建一个更扁平的设计。

设计一个主脚本和__main__模块

顶层主脚本将执行我们的应用程序。在某些情况下,我们可能有多个主脚本,因为我们的应用程序会做一些事情。我们有三种编写顶层主脚本的一般方法:

  • 对于非常小的应用程序,我们可以使用python3.3 some_script.py运行应用程序。这是我们在大多数示例中向您展示的风格。

  • 对于一些较大的应用程序,我们会有一个或多个文件,我们使用操作系统的chmod +x命令将其标记为可执行文件。我们可以将这些可执行文件放入 Python 的scripts目录,并与我们的setup.py安装一起使用。我们可以在命令行中使用some_script.py运行这些应用程序。

  • 对于复杂的应用程序,我们可能会在应用程序包中添加一个__main__.py模块。为了提供一个整洁的接口,标准库提供了runpy模块和-m命令行选项,将使用这个特别命名的模块。我们可以使用python3.3 -m some_app运行这个。

我们将详细讨论最后两个选项。

创建一个可执行脚本文件

要使用可执行脚本文件,我们有一个两步实现:使其可执行并包含一个#!("shebang")行。我们将看一下细节。

我们使用chmod +x some_script.py标记脚本为可执行。然后,我们包含一个#! shebang 行。

#!/usr/bin/env python3.3

这一行将指示操作系统使用指定的程序来执行脚本文件。在这种情况下,我们使用/usr/bin/env程序来定位python3.3程序来运行脚本。Python3.3 程序将被给定脚本文件作为其输入。

一旦脚本文件被标记为可执行,并包含#!行,我们就可以在命令行上使用some_script.py来运行脚本。

对于更复杂的应用程序,这个顶层脚本可能会导入其他模块和包。这些顶层可执行脚本文件应尽可能简单。我们强调了顶层可执行脚本文件的设计。

提示

  • 尽量保持脚本模块尽可能小。

  • 脚本模块不应该有新的或独特的代码。它应该总是导入现有的代码。

  • 没有一个程序是独立的。

我们的设计目标必须始终包括复合、大规模编程的概念。在 Python 库中有一些部分,而在脚本目录中有其他部分,这是很尴尬的。主脚本文件应尽可能简短。这是我们的例子:

import simulation
with simulation.Logging_Config():
   with simulation.Application_Config() as config:    
       main= simulation.Simulate_Command()
       main.config= config
       main.run()

所有相关的工作代码都是从一个名为simulation的模块中导入的。在这个模块中没有引入任何独特或独特的新代码。

创建一个 main 模块

为了使用runpy接口,我们有一个简单的实现。我们在应用程序的顶层包中添加了一个小的__main__.py模块。我们强调了这个顶层可执行脚本文件的设计。

我们应该始终允许重构应用程序以构建更大、更复杂的复合应用程序。如果__main__.py中有功能隐藏,我们需要将其提取到一个具有清晰可导入名称的模块中,以便其他应用程序可以使用它。

一个__main__.py模块应该是一些小的东西,就像以下代码:

import simulation
with simulation.Logging_Config():
   with simulation.Application_Config() as config:    
       main= simulation.Simulate_Command()
       main.config= config
       main.run()

我们已经做了最少的工作来为我们的应用程序创建工作环境。所有真正的处理都是从包中导入的。此外,我们假设这个__main__.py模块永远不会被导入。

这就是__main__模块中应该有的全部内容。我们的目标是最大化应用程序的重用潜力。

大规模编程

这里有一个例子,说明为什么我们不应该将独特的工作代码放入__main__.py模块中。我们将向您展示一个快速的假设性例子,以扩展现有的包。

想象一下,我们有一个名为stats的通用统计包,其中有一个顶层__main__.py模块。这个模块实现了一个命令行接口,用于计算给定 CSV 文件的描述性统计信息。这个应用程序有一个命令行 API,如下所示:

python3.3 -m stats -c 10 some_file.csv

这个命令使用-c选项来指定要分析的列。输入文件名作为命令行上的位置参数提供。

让我们进一步假设,我们有一个糟糕的设计问题。我们在stats/__main__.py模块中定义了一个高级函数analyze()

我们的目标是将其与我们的 Blackjack 模拟结合起来。由于我们有一个设计错误,这不会很顺利。我们可能认为我们可以这样做:

import stats
import simulation
import types
def sim_and_analyze():
    with simulation.Application_Config() as config_sim:
        config_sim.outputfile= "some_file.csv" s = simulation.Simulate()
        s.run()
    config_stats= types.SimpleNamespace( column=10, input="some_file.csv" )
    stats.analyze( config_stats )

我们尝试使用stats.analyze(),假设有用的高级接口是包的一部分,而不是__main__.py的一部分。这种简单的组合被__main__中定义的函数不必要地复杂化了。

我们希望避免被迫这样做:

def analyze( column, filename ):
    import subprocess
    subprocess.check_call( "python3.3 -m stats -c {0} {1}".format(
        column, filename) )

我们不应该需要通过命令行 API 创建复合的 Python 应用程序。为了创建现有应用程序的合理组合,我们可能被迫重构stats/__main__.py,将该模块中的任何定义移除,并将其推送到整个包中。

设计长时间运行的应用程序

长时间运行的应用程序服务器将从某种队列中读取请求并对这些请求进行响应。在许多情况下,我们利用 HTTP 协议并将应用程序服务器构建到 Web 服务器框架中。有关如何按照 WSGI 设计模式实现 RESTful Web 服务的详细信息,请参见第十二章。

桌面 GUI 应用程序与服务器有许多共同特点。它从包括鼠标和键盘操作的队列中读取事件。它处理每个事件并给出某种 GUI 响应。在某些情况下,响应可能是对文本部件的小更新。在其他情况下,文件可能会被打开或关闭,菜单项的状态可能会改变。

在这两种情况下,应用程序的核心特性是一个永远运行的循环,处理事件或请求。由于这些循环很简单,它们通常是框架的一部分。对于 GUI 应用程序,我们可能会有以下代码的循环:

root= Tkinter.Tk()
app= Application(root)
root.mainloop()

对于Tkinter应用程序,顶层部件的mainloop()接收每个 GUI 事件并将其交给适当的框架组件进行处理。当处理事件的对象——在本例中是顶层部件root——执行quit()方法时,循环将被优雅地终止。

对于基于 WSGI 的 Web 服务器框架,我们可能会有以下代码的循环:

httpd = make_server('', 8080, debug)
httpd.serve_forever()

在这种情况下,服务器的serve_forever()方法接收每个请求并将其交给应用程序——在本例中是debug——进行处理。当应用程序执行服务器的shutdown()方法时,循环将被优雅地终止。

我们经常有一些额外的要求,区分长时间运行的应用程序:

  • 健壮的:在某种意义上,这种要求是多余的;所有软件都应该工作。然而,当处理外部操作系统或网络资源时,必须成功地应对超时和其他错误。允许插件和扩展的应用程序框架可能存在一个扩展组件隐藏错误的可能性,整体框架必须优雅地处理。Python 的普通异常处理非常适合编写健壮的服务器。

  • 可审计的:一个简单的集中日志并不总是足够的。在第十四章中,日志和警告模块,我们讨论了创建多个日志以支持安全或财务审计要求的技术。

  • 可调试的:普通的单元测试和集成测试减少了对复杂调试工具的需求。然而,外部资源和软件插件或扩展创建了可能难以处理的复杂性,因此可能需要提供一些调试支持。更复杂的日志记录可能会有所帮助。

  • 可配置的:除了简单的技术尖峰外,我们希望能够启用或禁用应用程序功能。例如,启用或禁用调试日志是一种常见的配置更改。在某些情况下,我们希望进行这些更改而不完全停止和重新启动应用程序。在第十三章中,配置文件和持久性,我们研究了一些配置应用程序的技术。在第十六章中,处理命令行,我们扩展了这些技术。

  • 可控制的:一个简单的长时间运行的服务器可以简单地被杀死以使用不同的配置重新启动。为了确保缓冲区被正确刷新并且操作系统资源被正确释放,最好使用除SIGKILL之外的信号来强制终止。Python 在signal模块中提供了信号处理功能。

这最后两个要求——动态配置和干净的关闭——导致我们将主要事件或请求输入与次要的控制输入分开。这个控制输入可以提供额外的配置或关闭请求。

我们有多种方法通过额外的通道提供异步输入:

  • 最简单的方法之一是使用multiprocessing模块创建一个队列。在这种情况下,一个简单的管理客户端可以与这个队列交互,以控制或询问服务器或 GUI。有关multiprocessing的更多示例,请参见第十二章传输和共享对象。我们可以在管理客户端和服务器之间传输控制或状态对象。

  • 较低级别的技术在Python 标准库第十八章中定义。这些模块也可以用于与长时间运行的服务器或 GUI 应用程序协调。它们不像通过multiprocessing创建队列或管道那样复杂。

通常情况下,我们使用multiprocessing提供的高级 API 会更加成功。较低级别的技术(socketsignalmmapasyncoreasynchat)相对较为原始,并提供的功能较少。它们应该被视为高级模块(如multiprocessing)的内部支持。

将代码组织成 src、bin 和 test

正如我们在前一节中所指出的,没有必要复杂的目录结构。简单的 Python 应用程序可以在一个简单的、扁平的目录中构建。我们可以包括应用程序模块、测试模块,以及setup.pyREADME。这样做非常简单,易于操作。

然而,当模块和包变得更加复杂时,我们通常需要更加有结构化。对于复杂的应用程序,一个常见的方法是将 Python 代码分成三个包。为了使示例具体化,让我们假设我们的应用程序叫做my_app。这是我们可能创建的典型目录:

  • my_app/my_app:这个目录包含了所有工作应用程序代码。所有各种模块和包都在这里。一个名为src的模糊命名的目录没有提供信息。这个my_app目录应该包括一个空的__init__.py文件,以便应用程序也可以作为一个包。

  • my_app/binmy_spp/scripts:这个目录可以包含形成操作系统级命令行 API 的任何脚本。这些脚本可以通过setup.py复制到 Python 的scripts目录中。如前所述,这些脚本应该像__main__.py模块一样;它们应该非常简短,并且可以被视为 Python 代码的操作系统文件名别名。

  • my_app/test:这个目录可以有各种unittest模块。这个目录也应该包括一个空的__init__.py文件,以便它可以作为一个包。它还可以包括__main__.py来运行整个包中的所有测试。

顶级目录名称my_app可能会增加一个版本号,以允许不混淆地使用版本。我们可能会将my_app-v1.1作为顶级目录名称。该顶级目录中的应用程序必须有一个合适的 Python 名称,因此我们会看到my_app-v1.1/my_app作为应用程序的路径。

顶级目录应该包含setup.py文件,以将应用程序安装到 Python 的标准库结构中。有关更多信息,请参见分发 Python 模块。此外,当然,README文件应该放在这个目录中。

当应用程序模块和测试模块位于不同的目录中时,在运行测试时,我们需要将应用程序作为已安装的模块进行引用。我们可以使用PYTHONPATH环境变量来实现这一点。我们可以像以下代码一样运行测试套件:

PYTHONPATH=my_app python3.3 -m test

我们在执行命令的同一行上设置环境变量。这可能令人惊讶,但这是bash shell 的一流特性。这使我们能够对PYTHONPATH环境变量进行非常局部的覆盖。

安装 Python 模块

我们有几种技术来安装 Python 模块或包:

  • 我们可以编写setup.py并使用分发工具模块distutils将包安装到 Python 的lib/site-packages目录中。参见分发 Python 模块

  • 我们可以设置PYTHONPATH环境变量以包括我们的包和模块。我们可以在 shell 中临时设置它,或者通过编辑我们的~/.bash_profile或系统的/etc/profile来更加永久地设置它。我们稍后将更深入地研究这个问题。

  • 我们可以包含.pth文件以将目录添加到导入路径。这些文件可以位于本地目录或lib/site-packages中,以提供对模块或包的间接引用。有关更多信息,请参阅Python 标准库中的site模块文档。

  • 本地目录也是一个包。它始终位于sys.path列表的第一位。在简单的单模块 Python 应用程序中,这非常方便。在更复杂的应用程序中,当前工作目录可能会随着编辑不同文件而更改,这使得它成为一个不好的选择。

设置环境变量可以是临时的或持久的。我们可以在交互式会话中使用以下代码来设置它:

export PYTHONPATH=~/my_app-v1.2/my_app

这将PYTHONPATH设置为在搜索模块时包括指定目录。通过这种简单的环境更改,模块实际上被安装。没有写入 Python 的lib/site-packages

这是一个临时设置,当我们结束终端会话时可能会丢失。另一种选择是更新我们的~/.bash_profile以包含对环境的更加永久的更改。我们只需将export行附加到.bash_profile中,这样每次登录时都会使用该包。

对于共享服务器上的用户,我们可以在/etc/profile中包含环境设置,这样他们就不必更改他们的~/.bash_profile。对于个人工作站上的用户,提供基于distutilssetup.py可能比调整系统设置更简单。

对于 Web 应用程序,Apache 配置可能需要更新以包括对必要的 Python 模块的访问。为了支持应用程序变更的快速部署,通常不需要为大型复杂应用程序使用setup.py。相反,我们经常使用一系列应用程序目录和简单的.pth更改或PYTHONPATH更改来转移到新版本。

我们可能拥有以下类型的目录,由一个虚拟用户myapp拥有:

/Users/myapp/my_app-v1.2/my_app
/Users/myapp/my_app-v1.3/my_app

这使我们能够与现有版本并行构建新版本。我们可以通过将PYTHONPATH更改为引用/Users/myapp/my_app-v1.3/my_app来从版本 1.2 切换到版本 1.3。

总结

我们研究了设计模块和包的许多考虑因素。模块和单例类之间的相似之处很深。当我们设计一个模块时,封装结构和处理的基本问题与类设计一样相关。

当我们设计一个包时,我们需要对深度嵌套结构的需求持怀疑态度。当存在变体实现时,我们需要使用包;我们研究了处理这种变化的多种方法。我们还可能需要定义一个包,将多个模块组合成一个类似模块的包。我们研究了__init__.py如何从包内导入。

设计考虑和权衡

我们有一个深层次的打包技术层次结构。我们可以简单地将功能组织成定义的函数。我们可以将定义的函数及其相关数据组合成一个类。我们可以将相关类组合成一个模块。我们可以将相关模块组合成一个包。

当我们把软件看作是捕捉知识和表示的语言时,我们必须考虑类或模块的含义。模块是 Python 软件构建、分发、使用和重用的单位。除了少数例外,模块必须围绕重用的可能性进行设计。

在大多数情况下,我们会使用类,因为我们期望有多个类的实例对象。通常情况下,一个类会有有状态的实例变量。

当我们看到只有一个实例的类时,不太清楚是否真的需要一个类。独立的函数可能和单实例类一样有意义。在某些情况下,一个由单独函数组成的模块可能是一个合适的设计,因为模块本质上是单例的。

有状态的模块——比如有状态的类——是一般的期望。模块是一个带有可以修改的局部变量的命名空间。

虽然我们可以创建不可变的类(使用__slots__,扩展tuple,或者重写属性设置方法),但我们不能轻易地创建一个不可变的模块。似乎没有不可变模块对象的用例。

一个小应用可能是一个单一模块。一个更大的应用通常会是一个包。与模块设计一样,包应该被设计用于重用。一个更大的应用程序包应该正确地包含一个__main__模块。

期待

在下一章中,我们将整合许多我们的 OO 设计技术。我们将审视我们设计和实现的整体质量。一个考虑是确保他人信任我们的软件。可信赖软件的一个方面是连贯、易于使用的文档。

第十八章:质量和文档

优秀的软件不是偶然发生的;它是精心制作的。可交付的产品包括可读的、准确的文档。我们将介绍两种从代码生成文档的工具:pydoc和 Sphinx。如果我们使用一种轻量级标记语言编写文档,Sphinx 工具将得到增强。我们将描述一些ReStructured TextRST)的特性,以帮助我们的文档更易读。

文档是软件的一个重要质量方面;它是建立信任的一个方面。测试用例是建立信任的另一种方式。使用doctest编写测试用例可以同时解决这两个质量方面的问题。

我们还将简要介绍文学编程技术。其思想是编写一个愉快、易于理解的文档,其中包含整个源代码主体以及解释性注释和设计细节。文学编程并不简单,但它可以产生良好的代码,同时产生一个非常清晰和完整的文档。

为 help()函数编写文档字符串

Python 提供了许多包含文档的地方。包、模块、类或函数的定义都有一个字符串的位置,其中包含了正在定义的对象的描述。在本书中,我们避免在每个示例中展示文档字符串,因为我们的重点是 Python 编程细节,而不是正在交付的整体软件产品。

当我们超越高级 OO 设计,看整体可交付产品时,文档字符串成为交付的重要部分。文档字符串可以为我们提供一些关键信息:

  • API:参数、返回值和引发的异常。

  • 期待的描述。

  • 可选地,doctest测试结果。更多信息,请参见第十五章,可测试性设计

当然,我们可以在文档字符串中写更多内容。我们可以提供有关设计、架构和要求的更多细节。在某个时候,这些更抽象、更高层次的考虑并不直接与 Python 代码相关。这种更高层次的设计和要求并不适合于代码或文档字符串。

help()函数提取并显示文档字符串。它对文本进行了一些最小的格式化。help()函数由site包安装到交互式 Python 环境中。该函数实际上是在pydoc包中定义的。原则上,我们可以导入并扩展此包以自定义help()输出。

编写适用于help()的文档相对简单。以下是help(round)的典型输出示例。

round(...)
    round(number[, ndigits]) -> number

    Round a number to a given precision in decimal digits (default 0 digits).
    This returns an int when called with one argument, otherwise the
    same type as the number. ndigits may be negative.

这向我们展示了所需的元素:摘要、API 和描述。 API 和摘要是第一行:function( parameters ) -> results

描述文本定义了函数的功能。更复杂的函数可能描述了可能对这个函数重要或独特的异常或边缘情况。例如,round()函数并没有详细说明可能引发的TypeError等情况。

help()导向的文档字符串预期是纯文本,没有标记。我们可以添加一些 RST 标记,但help()不会使用它。

要使help()起作用,我们只需提供文档字符串。因为它如此简单,没有理由不这样做。每个函数或类都需要一个文档字符串,以便help()显示出有用的内容。

使用 pydoc 进行文档编写

我们使用库模块pydoc从 Python 代码生成 HTML 文档。事实证明,当我们在交互式 Python 中评估help()函数时,我们正在使用它。此函数生成没有标记的文本模式文档。

当我们使用pydoc来生成文档时,我们将以以下三种方式之一使用它:

  • 准备文本模式文档文件,并使用诸如moreless等命令行工具查看它们

  • 准备 HTML 文档并保存文件以供以后浏览

  • 启动 HTTP 服务器并根据需要创建 HTML 文件进行浏览

我们可以运行以下命令行工具来准备模块的基于文本的文档:

pydoc somemodule

我们还可以使用以下代码:

python3.3 -m pydoc somemodule

任一命令都将基于 Python 代码创建文本文档。输出将显示在诸如less(在 Linux 或 Mac OS X 上)或more(在 Windows 上)等分页长输出流的程序中。

通常,pydoc假设我们提供了要导入的模块名称。这意味着模块必须在 Python 路径上进行普通导入。作为替代,我们可以通过包括路径分隔符字符/(在 Linux 或 Mac OS X 上)或\(在 Windows 上)和.py文件扩展名来指定物理文件名。例如pydoc ./mymodule.py将可以选择一个不在导入路径上的文件。

要查看 HTML 文档,我们使用-w选项。这将在本地目录中写入一个 HTML 文件:

python3.3 -m pydoc -w somemodule

然后我们可以在浏览器中打开somemodule.html来阅读给定模块的文档。第三个选项是启动一个专用的 Web 服务器来浏览包或模块的文档。除了简单地启动服务器外,我们还可以结合启动服务器和启动默认浏览器。以下是一种简单启动端口 8080 上服务器的方法:

python3.3 -m pydoc -p 8080

这将启动一个 HTTP 服务器,查看当前目录中的代码。如果当前目录是一个合适的包(即它有一个__init__.py文件),那么将会有一个很好的顶级模块索引。

一旦我们启动了服务器,我们可以将浏览器指向http://localhost:8080来查看文档。我们还可以使用重写规则将本地 Apache 服务器指向这个pydoc服务器,以便团队可以在 Web 服务器上共享文档。

我们还可以同时启动本地服务器和浏览器:

python3.3 -m pydoc -b

这将找到一个未使用的端口,启动服务器,然后启动默认浏览器指向服务器。注意使用python3.3命令;这在旧版本的 Python 中不起作用。

定制pydoc的输出并不容易。各种样式和颜色实际上都是硬编码到类定义中的。修改和扩展pydoc以使用外部 CSS 样式将是一个有趣的练习。

通过 RST 标记提供更好的输出

如果我们使用更复杂的工具集,我们的文档可以更加美观。有几件事情我们希望能够做,比如以下:

  • 微调呈现以包括加粗、斜体或颜色等强调。

  • 为参数、返回值、异常和 Python 对象之间的交叉引用提供语义标记。

  • 提供查看源代码的链接。

  • 过滤包含或拒绝的代码。我们可以微调此过滤器,以包括或排除许多组件和成员:标准库模块、以__开头的私有成员、以__开头的系统成员或超类成员。

  • 调整 CSS 以为生成的 HTML 页面提供不同的样式。

我们可以通过 docstrings 中更复杂的标记来满足前两个要求;我们需要使用 RST 标记语言。我们需要另一个工具来满足最后三个要求。

一旦我们开始使用更复杂的标记,我们可以扩展到超出 HTML,包括 LaTeX 以获得更美观的文档。这使我们还可以从单一源生成除 HTML 之外的 PostScript 或 PDF 输出。

RST 是一种简单、轻量级的标记语言。与 Python docutils项目相关的教程和摘要有很多。详情请参见docutils.sourceforge.net

这里有一个快速概述:docutils.sourceforge.net/docs/user/rst/quickstart.html

docutils工具集的要点是,一个非常智能的解析器允许我们使用非常简单的标记。HTML 和 XML 依赖于一个相对不太复杂的解析器,并将复杂的标记的负担放在人类(或编辑工具)身上。虽然 XML 和 HTML 允许各种用例,但docutils解析器更专注于自然语言文本。由于狭窄的焦点,docutils能够根据空行和一些 ASCII 标点字符推断我们的意图。

对于我们的目的,docutils解析器识别以下三个基本事物:

  • 文本块:段落、标题、列表、块引用、代码示例和doctest块。这些都是由空行分隔的。

  • 内联标记可以出现在文本块内。这涉及使用简单的标点符号来标记文本块内的字符。有两种内联标记;我们将在后面的部分详细讨论。

  • 指令也是文本块,但它们以..作为行的前两个字符开始。指令是开放的,可以扩展以向 docutils 添加功能。

文本块

文本块就是一个段落,由一个空行与其他段落分隔开。这是 RST 标记的基本单位。RST 识别了许多种类的段落,基于遵循的模式。这是一个标题的例子:

This Is A Heading
=================

这被识别为标题,因为它用一系列特殊字符下划线标记。

docutils解析器完全基于它们的使用推断标题下划线的层次结构。我们必须在标题和它们的嵌套上保持一致。选择一个标准并坚持下去是有帮助的。保持文档相对平坦而不是复杂的、嵌套的标题也是有帮助的。通常只需要三个级别;这意味着我们可以使用====----~~~~来表示三个级别。

项目符号列表项以特殊字符开头;内容也必须缩进。由于 Python 使用 4 个空格缩进,这在 RST 中也很常见。然而,几乎任何一致的缩进都可以工作:

Bullet Lists

-   Leading Special Character.

-   Consistent Indent.

注意段落之间的空行。对于某些简单的项目符号列表,空行不是必需的。一般来说,空行是个好主意。

数字列表以数字或字母和罗马数字开头。要自动生成数字,可以使用#作为列表项:

Number Lists

1\.  Leading digit or letter.

2\.  Auto-numbering with #.

#.  Looks like this.

我们可以使用缩进规则在列表中创建列表。这可能会很复杂,docutils RST 解析器通常会弄清楚你的意思。

块引用只是缩进的文本:

Here's a paragraph with a cool quote.

    Cool quotes might include a tip.

Here's another paragraph.

代码示例用双冒号::表示;它们是缩进的,并以空行结束。虽然::可以在行尾或单独一行,但将::放在单独一行上会使查找代码示例稍微容易一些。

这是一个代码示例:

::

    x = Deck()
    first_card= x.pop()

This shows two lines of code. It will be distinguished from surrounding text.

docutils解析器还会找到doctest材料并将其放在一边进行特殊格式化,类似于代码块。它们以>>>开头,并以空行结尾。

这是一些doctest的示例输出:

>>> x= Unsorted_Deck()
>>> x.pop()
'A♣'

测试输出末尾的空行是必不可少的,而且很容易被忽视。

RST 内联标记

在大多数文本块中,我们可以包含内联标记。我们不能在代码示例或doctest块中包含内联标记。请注意,我们也不能嵌套内联标记。

RST 内联标记包括各种常见的 ASCII 文本处理。例如,我们有*emphasis***strong emphasis**,通常会分别产生斜体和粗体。我们可能想要强调文本块中的代码段;我们使用`literal`来强制使用等宽字体。

我们还可以将交叉引用包含在内联标记中。尾部的_表示一个引用,并指向外部;前面的_表示一个目标,并指向内部。例如,我们可能有`some phrase`_作为一个引用。然后我们可以使用_`some phrase`作为该引用的目标。我们不需要为节标题提供显式目标:我们可以引用`This Is A Heading`_,因为所有的节标题都已经定义为目标。对于 HTML 输出,这将生成预期的<a>标签。对于 PDF 输出,将生成文本链接。

我们不能嵌套内联标记。嵌套内联标记几乎没有必要;使用太多的排版技巧会导致视觉混乱。如果我们的写作对排版非常敏感,我们可能应该直接使用 LaTeX。

内联标记也可以有显式的角色指示符。这是:role:后面跟着text。简单的 RST 具有相对较少的角色。我们可以使用:code:some code``来更明确地表示文本中存在代码示例。当我们使用 Sphinx 时,会有许多角色指示符。使用显式角色可以提供大量的语义信息。

在做一些更复杂的数学运算时,我们可能会使用 LaTeX 数学排版功能。这使用了:math:角色;它看起来像这样::math:a=\pi r²``。

角色是开放式的。我们可以提供一个配置给 docutils,以添加新的角色。

RST 指令

RST 还包括指令。指令是以..开头的块写的;它可能有缩进的内容。它也可能有参数。RST 有大量的指令,我们可以使用它们来创建更复杂的文档。对于文档准备,我们很少会使用到可用指令的大部分。指令是开放式的;诸如 Sphinx 之类的工具将添加指令以生成更复杂的文档。

三个常用的指令是imagecsv-tablemath。如果我们有一个应该包含在文档中的图像,我们可以这样包含它:

..  image:: media/some_file.png
    :width: 6in

我们将文件命名为media/some_file.png。我们还提供了一个width参数,以确保我们的图像适合我们的文档页面布局。我们可以使用许多其他参数来调整图像的呈现方式。

  • :align: 我们可以提供关键字,如topmiddlebottomleftcenterright。这个值将提供给 HTML <img>标签的align属性。

  • :alt: 这是图像的替代文本。这个值将提供给 HTML <img>标签的alt属性。

  • :height: 这是图像的高度。

  • :scale: 这是一个比例因子,可以代替高度和宽度。

  • :width: 这是图像的宽度。

  • :target: 这是图像的目标超链接。这可以是完整的 URI,也可以是``name_形式的 RST 引用。

对于高度和宽度,可以使用 CSS 中可用的任何长度单位。这些包括em(元素的字体高度),ex(字母“x”的高度),px(像素),以及绝对尺寸:incmmmpt(点),和pc(pica)。

我们可以以以下方式在我们的文档中包含一个表格:

..  csv-table:: Suits
    :header: symbol, name

    "'♣'", Clubs
    "'♦'", Diamonds
    "'♥'", Hearts
    "'♠'", Spades

这使我们能够准备数据,将其转换为简单的 CSV 符号,成为一个复杂的 HTML 表。我们可以使用math指令来使用更复杂的公式:

..  math::
    c = 2 \pi r

这使我们能够编写更大的 LaTeX 数学公式,这将成为一个单独的方程。这些可以进行编号和交叉引用。

学习 RST

学习 RST 的一种方法是安装docutils并使用rst2html.py脚本解析 RST 文档并将其转换为 HTML 页面。一个简单的练习文档可以很容易地展示给我们各种 RST 特性。

一个项目的所有需求、架构和文档都可以使用 RST 编写,并转换为 HTML 或 LaTeX。在 RST 中编写用户故事并将这些文件放入一个可以组织和重组的目录中相对廉价。更复杂的工具可能并不比docutils更有价值。

使用纯文本文件和 RST 标记的优势在于,我们可以轻松地与源代码并行管理我们的文档。我们不使用专有的文字处理文件格式。我们不使用冗长的 HTML 或 XML 标记,必须压缩才能实用。我们只是存储更多的文本以及源代码。

如果我们正在使用 RST 创建文档,我们还可以使用rst2latex.py脚本创建一个.tex文件,然后可以通过 LaTeX 工具集运行它来创建 postscript 或 PDF 文档。这需要一个 LaTeX 工具集;通常,TeXLive发行版用于此。请参阅www.tug.org/texlive/,了解将 TeX 转换为优雅的最终文档的全面工具集。TeXLive 包括 pdfTeX 工具,可用于将 LaTeX 输出转换为 PDF 文件。

编写有效的文档字符串

在编写文档字符串时,我们需要专注于受众需要的基本信息。当我们使用一个库模块时,我们需要知道什么?无论我们问什么问题,其他程序员通常也会有类似的问题。当我们编写文档字符串时,我们应该留在两个边界内:

  • 最好避免抽象的概述、高层需求、用户故事或与代码直接无关的背景。我们应该把文档字符串的重点放在代码本身上。我们应该在单独的文档中提供背景信息。像 Sphinx 这样的工具可以将背景材料和代码合并到一个文档中。

  • 最好也避免过于详细的工作原理实现细节。代码是 readily available 的,因此在文档中重复代码是没有意义的。如果代码太难理解,也许应该重新编写以使其更清晰。

也许开发人员最想要的是如何使用 Python 对象的工作示例。RST ::文字块是这些示例的支柱。

我们经常以以下方式编写代码示例:

Here's an example::

    d= Deck()
    c= d.pop()

双冒号::在缩进块之前。RST 解析器识别缩进块为代码,并将其直接传递到最终文档。

除了示例,正式的 API 也很重要。我们将在后面的部分看一下几种 API 定义技术。这些依赖于 RST field list语法。它非常简单,这使得它非常灵活。

一旦我们完成了示例和 API,还有许多其他事情争夺第三名。我们需要写什么取决于上下文。似乎有三种情况:

  • 文件(包括包和模块):在这些情况下,我们提供了对模块、类或函数定义集合的概述或介绍。我们需要提供文件中各个元素的简单路线图或概述。在模块相对较小的情况下,我们可能会在这个级别提供 doctest 和代码示例。

  • 类(包括方法函数):这通常是我们提供代码示例和doctest块来解释类 API 的地方。因为类可能是有状态的,可能有相对复杂的 API,我们可能需要提供相当长的文档。单独的方法函数通常会有详细的文档。

  • 函数:我们可能会提供代码示例和doctest块来解释函数。因为函数通常是无状态的,我们可能有一个相对简单的 API。在某些情况下,我们可能会避免更复杂的 RST 标记,而专注于help()函数的文档。

我们将详细研究这些广泛的、模糊的文档上下文。

编写文件级别的文档字符串,包括模块和包

包或模块的目的是包含一些元素。包含模块、类、全局变量和函数。模块包含类、全局变量和函数。这些容器的顶层文档字符串可以充当路线图,解释包或模块的一般特性。细节委托给各个类或函数。

我们可能有一个模块文档字符串,看起来像下面的代码:

Blackjack Cards and Decks
=========================

This module contains a definition of ``Card``, ``Deck`` and ``Shoe`` suitable for Blackjack.

The ``Card`` class hierarchy
-----------------------------

The ``Card`` class hierarchy includes the following class definitions.

``Card`` is the superclass as well as being the class for number cards.
``FaceCard`` defines face cards: J, Q and K.
``AceCard`` defines the Ace. This is special in Blackjack because it creates a soft total for a hand.

We create cards using the ``card()`` factory function to create the proper
``Card`` subclass instances from a rank and suit.

The ``suits`` global variable is a sequence of Suit instances.
>>> import cards
>>> ace_clubs= cards.card( 1, cards.suits[0] )
>>> ace_clubs
'A♣'
>>> ace_diamonds= cards.card( 1, cards.suits[1] )
>>> ace_clubs.rank ==  ace_diamonds.rank
True

The ``Deck`` and ``Shoe`` class hierarchy
-------------------------------------------

The basic ``Deck`` creates a single 52-card deck. The ``Shoe`` subclass creates a given number of decks. A ``Deck`` can be shuffled before the cards can be extracted with the ``pop()`` method. A ``Shoe`` must be shuffled and *burned*. The burn operation sequesters a random number of cards based on a mean and standard deviation. The mean is a number of cards (52 is the default.) The standard deviation for the burn is also given as a number of cards (2 is the default.)

这个文档字符串中的大部分文本提供了对该模块内容的路线图。它描述了类层次结构,使得定位相关类稍微容易一些。

文档字符串包括基于doctestcard()工厂函数的简单示例。这将该函数作为整个模块的重要特性进行宣传。也许有必要提供Shoe类的doctest解释,因为这可能是该模块最重要的部分。

这个文档字符串包括一些内联的 RST 标记,将类名放入等宽字体中。章节标题用===---线下划线。RST 解析器可以确定用===下划线划线划线划线的标题是用---下划线划线划线划线的标题的父标题。

我们将在后面的部分看一下使用 Sphinx 生成文档。Sphinx 将利用 RST 标记生成漂亮的 HTML 文档。

用 RST 标记编写 API 细节

使用 RST 标记的好处之一是我们可以提供正式的 API 文档。API 参数和返回值使用 RST field list格式化。通常,字段列表的形式如下:

:field1: some value
:field2: another value

字段列表是一系列字段标签(如:label:)和与该标签相关联的值。标签通常很短,值可以很长。字段列表也用于为指令提供参数。

当字段列表的文本出现在 RST 文档中时,docutils 工具可以创建一个看起来不错的、类似表格的显示。在 PDF 中,它可能看起来像下面的代码:

field1	some value
field2	another value

我们将使用扩展的 RST 字段列表语法来编写 API 文档。我们将扩展字段名称以成为多部分项目。我们将添加带有关键字的前缀,如paramtype。前缀后面跟着参数的名称。

有几个字段前缀。我们可以使用其中任何一个:paramparameterargargumentkeykeyword。例如,我们可能会写下以下代码:

:param rank: Numeric rank of the card
:param suit: Suit of the card

我们通常使用param(或parameter)表示位置参数,使用key(或keyword)表示关键字参数。我们建议您不要使用argargument来记录 Python 代码,因为它们不符合 Python 语法类别。这些前缀可以用于记录其他语言的 shell 脚本或 API。

这些字段列表定义将被收集到一个缩进的部分中。Sphinx 工具还将比较文档中的名称与函数参数列表中的名称,以确保它们匹配。

我们还可以使用type作为前缀来定义参数的类型:

:type rank: integer in the range 1-13.

由于 Python 的灵活性,这可能是一个不必要的细节。在许多情况下,参数值只需要是数字,简单的:param somearg:可以在描述中包含通用类型信息。我们在之前的示例中展示了这种风格:卡片的数字排名

对于返回值的函数,我们应该描述结果。我们可以使用returnsreturn字段标签总结返回值。我们还可以使用rtype正式指定返回值的类型。我们可能会写下以下代码:

:returns: soft total for this card
:rtype: integer

此外,我们还应该包括关于此函数特有的异常信息。我们有四个别名:raisesraiseexceptexception。我们会写下以下代码:

:raises TypeError: rank value not in range(1, 14).

我们还可以描述类的属性。为此,我们可以使用varivarcvar。我们可能会写下以下代码:

:ivar soft: soft points for this card; usually hard points, except for aces.
:ivar hard: hard points for this card; usually the rank, except for face cards.

我们应该使用ivar表示实例变量,使用cvar表示类变量。然而,在最终的 HTML 输出中没有明显的区别。

这些字段列表结构用于准备类、类方法和独立函数的文档字符串。我们将在后面的部分中查看每种情况。

编写类和方法函数文档字符串

一个类通常包含许多元素,包括属性和方法函数。一个有状态的类可能还具有相对复杂的 API。对象将被创建,状态将发生变化,并且可能在生命周期结束时被垃圾回收。我们可能希望在类文档字符串或方法函数文档字符串中描述一些(或全部)这些状态变化。

我们将使用字段列表技术来记录整体类文档字符串中的类变量。这将主要侧重于使用:ivar variable::cvar variable::var variable:字段列表项。

每个单独的方法函数也将使用字段列表来定义参数、返回值和每个方法函数引发的异常。以下是我们可能开始编写包含类和方法函数文档字符串的类的方式:

class Card:
    """Definition of a numeric rank playing card.
    Subclasses will define ``FaceCard`` and ``AceCard``.

    :ivar rank: Rank
    :ivar suit: Suit
    :ivar hard: Hard point total for a card
    :ivar soft: Soft total; same as hard for all cards except Aces.
    """
    def __init__( self, rank, suit, hard, soft=None ):
        """Define the values for this card.

        :param rank: Numeric rank in the range 1-13.
        :param suit: Suit object (often a character from '♣♡♢♠')
        :param hard: Hard point total (or 10 for FaceCard or 1 for AceCard)
        :param soft: The soft total for AceCard, otherwise defaults to hard.
        """
        self.rank= rank
        self.suit= suit
        self.hard= hard
        self.soft= soft if soft is not None else hard

当我们在文档字符串中包含这种 RST 标记时,像 Sphinx 这样的工具可以格式化非常漂亮的 HTML 输出。我们为您提供了实例变量的类级文档以及一个方法函数的参数的方法级文档。

当我们使用help()查看时,RST 是可见的。这并不是太令人反感,因为它在语义上是有意义的,而且并不太令人困惑。这指出了我们可能需要在help()文本和 Sphinx 文档之间取得平衡。

编写函数文档字符串

函数文档字符串可以使用字段列表格式化,以定义参数、返回值和引发的异常。以下是包含文档字符串的函数的示例:

def card( rank, suit ):
    """Create a ``Card`` instance from rank and suit.

    :param rank: Numeric rank in the range 1-13.
    :param suit: Suit object (often a character from '♣♡♢♠')
    :returns: Card instance
    :raises TypeError: rank out of range.

    >>> import p3_c18
    >>> p3_c18.card( 3, '♡' )
    3♡
    """
    if rank == 1: return AceCard( rank, suit, 1, 11 )
    elif 2 <= rank < 11: return Card( rank, suit, rank )
    elif 11 <= rank < 14: return FaceCard( rank, suit, 10 )
    else:
        raise TypeError( 'rank out of range' )

这个函数的文档字符串包括参数定义、返回值和引发的异常。有四个单独的字段列表项,形式化了 API。我们已经包含了一个doctest序列。当我们在 Sphinx 中记录这个模块时,我们将得到非常漂亮的 HTML 输出。此外,我们可以使用doctest工具来确认函数是否与简单的测试用例匹配。

更复杂的标记技术

还有一些额外的标记技术可以使文档更容易阅读。特别是,我们经常希望在类定义之间有有用的交叉引用。我们可能还希望在文档内的章节和主题之间进行交叉引用。

RST(即没有 Sphinx 的情况下),我们需要提供正确的 URL 来引用文档的不同部分。我们有三种引用方式:

  • 对章节标题的隐式引用:我们可以使用``Some Heading_来引用Some Heading部分。这对 docutils 识别的所有标题都适用。

  • 对目标的明确引用:我们可以使用target_来引用文档中_target的位置。

  • 文档间引用:我们必须创建一个完整的 URL,明确引用文档中的一个章节标题。Docutils 会将章节标题转换为全小写,并用-替换标点符号。这使我们能够创建对外部文档中的章节标题的引用,如下所示:``Design <file:build.py.html#design>_

当我们使用 Sphinx 时,我们甚至可以获得更多的文档间交叉引用能力。这些能力使我们能够避免尝试编写详细的 URL。

使用 Sphinx 生成文档

Sphinx 工具可以以多种格式生成非常漂亮的文档。它可以轻松地将源代码的文档与额外的设计说明、需求或背景文件结合在一起。

Sphinx 工具可以在sphinx-doc.org找到。下载可能会变得复杂,因为 Sphinx 依赖于其他几个项目。首先安装setuptools可能更容易,其中包括easy_install脚本,然后使用它来安装 Sphinx。这可以帮助我们跟踪必须首先安装的其他项目的细节。

请参阅pypi.python.org/pypi/setuptools获取有关setuptools的帮助。

一些开发人员更喜欢使用pip进行这种安装。请参阅pypi.python.org/pypi/pip获取有关pip的信息。

Sphinx 教程非常出色。从那里开始,确保你可以使用sphinx-quickstartsphinx-build。通常,通过make程序来运行sphinx-build,这稍微简化了 Sphinx 的命令行使用。

使用 Sphinx 快速入门

sphinx-quickstart的方便之处在于,它通过交互式问答会话填充了相当复杂的config.py文件。

这是一个这样的会话的一部分,显示了对话的外观;我们已经突出显示了一些响应,在这些响应中,默认值似乎不是最佳的。

对于更复杂的项目,将文档与工作代码分开在长远来看更简单。在整体项目树中创建一个doc目录通常是一个好主意:

Enter the root path for documentation.
> Root path for the documentation [.]: doc

对于非常小的文档,将源代码和 HTML 交错使用是可以的。对于较大的文档,特别是可能需要生成 LaTeX 和 PDF 的文档,将这些文件与文档的 HTML 版本分开是很方便的:

You have two options for placing the build directory for Sphinx output.
Either, you use a directory "_build" within the root path, or you separate
"source" and "build" directories within the root path.
> Separate source and build directories (y/N) [n]: y

下一批问题确定了特定的附加组件;它以以下说明开始:

Please indicate if you want to use one of the following Sphinx extensions:

我们将建议一组最适用于一般 Python 开发的附加组件。对于第一次使用 Sphinx 的用户来说,这将足以开始并生成出色的文档。显然,特定项目的需求和目标将覆盖这些通用建议。

我们几乎总是希望包括autodoc功能来从文档字符串生成文档。如果我们在 Python 编程之外使用 Sphinx 来生成文档,也许我们可以关闭autodoc

> autodoc: automatically insert docstrings from modules (y/N) [n]: y

如果我们有doctest示例,我们可以让 Sphinx 为我们运行 doctest。对于小型项目,大部分测试是通过doctest完成的,这可能非常方便。对于较大的项目,我们通常会有一个包含 doctest 的单元测试脚本。通过 Sphinx 以及正式的单元测试执行 doctest 仍然是一个好主意:

> doctest: automatically test code snippets in doctest blocks (y/N) [n]: y

成熟的开发工作可能有许多相关的项目;这可能有多个相关的 Sphinx 文档目录:

> intersphinx: link between Sphinx documentation of different projects (y/N) [n]:

todo扩展允许我们在文档字符串中包含一个.. todo::指令。然后我们可以添加一个特殊的.. todolist::指令来创建官方的待办事项列表在文档中:

> todo: write "todo" entries that can be shown or hidden on build (y/N) [n]:

覆盖报告可能是一个方便的质量保证指标:

> coverage: checks for documentation coverage (y/N) [n]:

对于涉及任何数学的项目,拥有 LaTeX 工具集使我们能够将数学漂亮地排版为图像,并包含到 HTML 中。它还保留了 LaTeX 输出中的原始数学。MathJax 是一个基于 Web 的 JavaScript 库,也以以下方式工作:

> pngmath: include math, rendered as PNG images (y/N) [n]: y
> mathjax: include math, rendered in the browser by MathJax (y/N) [n]:

对于非常复杂的项目,我们可能需要生成变体文档:

> ifconfig: conditional inclusion of content based on config values (y/N) [n]:

大多数应用程序文档描述了一个 API。我们应该包括autodocviewcode功能。viewcode选项允许读者查看源代码,以便他们可以详细了解实现:

> viewcode: include links to the source code of documented Python objects (y/N) [n]: y

autodocdoctest功能意味着我们可以专注于在我们的代码中编写文档字符串。我们只需要编写非常小的 Sphinx 文档文件来提取文档字符串信息。对于一些开发人员来说,专注于代码减少了与编写文档相关的恐惧因素。

编写 Sphinx 文档

软件开发项目有两个常见的起始点:

  • 已经创建了一些起始文档,应该保留这些文档

  • 没有;起始从空白状态开始

在项目以一些遗留文档开始的情况下,这可能包括需求、用户故事或架构说明。它还可能包括组织政治、过时的预算和时间表等技术上不相关的材料。

理想情况下,这些起始文档已经是文本文件。如果不是,它们可能是以某种文字处理格式保存为文本的。当我们有面向文本的起始文档时,相对容易添加足够的 RST 标记来显示大纲结构,并将这些文本文件组织成一个简单的目录结构。

没有理由将内容保留为文字处理文档。一旦它成为软件开发项目的技术内容的一部分,RST 允许更灵活地使用起始信息。

一个困难的情况是,起始文档是使用 Keynote、PowerPoint 或类似工具构建的幻灯片。这些不容易转换为以文本为中心的 RST,因为图表和图像是内容的重要部分。在这些情况下,最好有时将演示文稿导出为 HTML 文档,并将其放入 Sphinx doc/source/_static目录中。这将允许我们通过简单的 RST 链接将原始材料集成到 Sphinx 中,形式为``Inception <_static/inception_doc/index.html>_

当使用交互式的基于 Web 的工具来管理项目或用户故事时,起始和背景文档需要通过简单的 URL 引用来处理:``Background http://someservice/path/to/page.html_.

通常最容易的方法是从文档的占位符大纲开始,随着软件开发的进行,文档将不断积累。可能有用的一个结构是基于体系结构的 4+1 视图。最初的文档通常是 4+1 视图中的场景或用户故事的一部分。有时,最初的文档是开发或物理部署的一部分。

有关更多信息,请参阅:

en.wikipedia.org/wiki/4%2B1_architectural_view_model

我们可以在index.html根目录下创建五个顶级文档:user_storieslogicalprocessimplementationphysical。这些文档都必须有一个 RST 标题,但文件中不需要其他内容。

然后,我们可以更新 Sphinx index.rst文件中默认生成的.. toctree::指令:

.. Mastering OO Python documentation master file, created by
   sphinx-quickstart on Fri Jan 31 09:21:55 2014.
   You can adapt this file completely to your liking, but it should at least
   contain the root `toctree` directive.

Welcome to Mastering OO Python's documentation!
===============================================

Contents:

.. toctree::
   :maxdepth: 2

   user_stories
 logical
 process
 implementation
 physical

Indices and tables
==================
* :ref:`genindex`
* :ref:`modindex`
* :ref:`search`

一旦我们有了一个顶层结构,我们可以使用make命令来构建我们的文档:

make doctest html

这将运行我们的 doctests;如果所有测试都通过,它将创建 HTML 文档。

填写文档的 4+1 视图

随着开发的进行,4+1 视图可以用来组织积累的细节。这用于属于文档字符串狭窄焦点之外的信息。

user_stories.rst文档是我们收集用户故事、需求和其他高层背景注释的地方。如果用户故事变得复杂,这可能会演变成一个目录树。

logical.rst文档将收集我们的类、模块和包的初始 OO 设计。这应该是我们设计思想的起源。它可能包含备选方案、注释、数学背景、正确性证明以及逻辑软件设计的图表。对于相对简单的项目——设计相对清晰的项目——这可能保持为空。对于复杂的项目,这可能描述一些复杂的分析和设计,作为实施的背景或理由。

最终的 OO 设计将是属于implementation.rst文件的 Python 模块和类。我们将更详细地看一下这一点,因为这将成为我们的 API 文档。这部分将直接基于我们的 Python 代码和 RST 标记的文档字符串。

process.rst文档可以收集有关动态运行时行为的信息。这将包括诸如并发性、分布和集成等主题。它还可能包含有关性能和可伸缩性的信息。网络设计和使用的协议可能在这里描述。

对于较小的应用程序,应该放入过程文档的材料并不十分清晰。这个文档可能与逻辑设计和整体架构信息重叠。如果有疑问,我们必须努力实现基于受众对信息需求的清晰度。对于一些用户,许多小文档是有帮助的。对于其他用户,一个大型文档更受欢迎。

physical.rst文件是记录部署细节的地方。配置细节的描述将放在这里:环境变量、配置文件格式细节、可用的记录器名称以及管理和支持所需的其他信息。这可能还包括配置信息,如服务器名称、IP 地址、帐户名称、目录路径和相关注释。在一些组织中,管理员可能认为其中一些细节不适合一般软件文档。

编写实施文档

implementation.rst文档可以基于使用automodule来创建文档。以下是implementation.rst文档可能开始的方式。

Implementation
================

Here's a reference to the `inception document <_static/inception_doc/index.html>`_

The p3_c18 module
-----------------------

..  automodule:: p3_c18
    :members:
    :undoc-members:
    :special-members:

The simulation_model module
--------------------------------

..  automodule:: simulation_model
    :members:
    :undoc-members:
    :special-members:

我们使用了两种 RST 标题:一个顶级标题和两个子标题。RST 推断出父级和子级之间的关系。在这个例子中,我们使用"==="作为父标题(也是标题)的下划线,"---"作为子标题。

我们为您提供了对一个文档的显式引用,该文档被复制到_static目录中,名为inception_doc。我们从inception document这些词到实际文档的index.html文件创建了一个复杂的 RST 链接。

在两个子标题中,我们使用了 Sphinx 的.. automodule::指令从两个模块中提取文档字符串。我们为 automodule 指令提供了三个参数:

  • :members::这包括模块的所有成员。我们可以列出显式成员类和函数,而不是列出所有成员。

  • :undoc-members::这包括缺乏适当文档字符串的成员。这在开始开发时很方便;我们仍然会得到一些 API 信息,但它将是最小的。

  • :undoc-members::这包括特殊方法名成员,默认情况下不包含在 Sphinx 文档中。

这给了我们一个相对完整的视图,有时太完整了。如果我们省略所有这些参数,:undoc-members::special-members:,我们将得到一个更小、更集中的文档。

我们的implementation.rst文件可以随着项目的发展而发展。我们将在模块完成时添加automodule引用。

.. automodule::指令的组织可以为我们提供一个有用的路线图或对复杂模块或包集合的概述。花一点时间组织演示,以便向我们展示软件组件如何协同工作,比大量的废话更有价值。重点不是创造出色的叙述文学;重点是为其他开发人员提供指导。

创建 Sphinx 交叉引用

Sphinx 通过 RST 扩展了可用的交叉引用技术。最重要的一组交叉引用能力是直接引用特定的 Python 代码特性。这些使用内联 RST 标记使用:role:text``语法。在这种情况下,大量额外的角色是 Sphinx 的一部分。

我们有以下类型的交叉引用角色可用:

  • :py:mod:some_module``语法将生成一个链接到此模块或包的定义。

  • :py:func:some_function``语法将生成一个链接到函数的定义。可以使用module.functionpackage.module.function的限定名称。

  • :py:data:variable和`:py:const:`variable语法将生成一个链接到使用.. py:data:: variable指令定义的模块变量。常量只是一个不应该被更改的变量。

  • :py:class:some_class``语法将链接到类定义。可以使用module.class等限定名称。

  • :py:meth:class.method``语法将链接到方法定义。

  • :py:attr:class.attribute``语法将链接到使用.. py:attribute:: name指令定义的属性。

  • :py:exc:exception``语法将链接到已定义的异常。

  • :py:obj:some_object``语法可以创建一个指向对象的通用链接。

如果我们在文档字符串中使用`pySomeClass`,我们将以等宽字体获得类名。如果我们使用:py:class:`SomeClass`,我们将得到一个指向类定义的正确链接,这通常更有帮助。

每个角色上的:py:前缀是因为 Sphinx 可以用来撰写关于其他语言的文档,而不仅仅是 Python。通过在每个角色上使用这个:py:前缀,Sphinx 可以提供适当的语法补充和高亮。

这是一个包含对其他类和异常的显式交叉引用的文档字符串:

def card( rank, suit ):
    """Create a :py:class:`Card` instance from rank and suit.

    :param rank: Numeric rank in the range 1-13.
    :param suit: Suit object (often a character from '♣♡♢♠')
    :returns: :py:class:`Card` instance
    :raises :py:exc:`TypeError`: rank out of range.
    Etc.
    """

通过使用:py:class:`Card`而不是`pyCard`,我们能够在这个注释块和Card类的定义之间创建明确的链接。同样,我们使用:py:exc:`TypeError`来允许对这个异常的定义进行明确的链接。

此外,我们可以通过.._some-name::定义一个链接目标,并在 Sphinx 文档树中的任何文档中使用:ref:`some-name`引用该标签。名称some-name必须是全局唯一的。为了确保这一点,通常最好定义一种层次结构,使得名称从文档到部分再到主题成为一种路径。

将 Sphinx 文件重构为目录

对于更大的项目,我们需要使用目录而不是简单的文件。在这种情况下,我们将执行以下步骤将文件重构为目录:

  1. 将目录添加到implementation,例如。

  2. 将原始的implementation.rst文件移动到implementation/index.rst

  3. 更改原始的index.rst文件。将.. toctree::指令更改为引用implementation/index而不是implementation

然后我们可以在implementation目录中使用implementation/index.rst文件中的.. toctree::指令来包含该目录中的其他文件。

当我们的文档被分成简单的文本文件的简单目录时,我们可以编辑小而专注的文件。个别开发人员可以做出重大贡献,而不会遇到在尝试编辑大型文字处理文档时出现的文件共享冲突。

编写文档

软件质量的重要部分来自于意识到产品不仅仅是针对编译器或解释器的代码。正如我们在第十五章中所指出的,可测试性设计,不能信任的代码是不能使用的。在那一章中,我们建议测试是建立信任的关键。我们想要概括一下。除了详细的测试之外,还有几个其他的质量属性使得代码可用,可信任性就是其中之一。

我们在以下情况下信任代码:

  • 我们理解使用案例

  • 我们理解数据模型和处理模型

  • 我们理解测试用例

当我们看更多技术质量属性时,我们会发现这些实际上是关于理解的。例如,调试似乎意味着我们可以确认我们对应用程序工作原理的理解。可审计性也似乎意味着我们可以通过查看特定示例来确认我们对处理的理解,以展示它们按预期工作。

文档创建信任。有关软件质量的更多信息,请从这里开始:en.wikipedia.org/wiki/Software_quality。关于软件质量有很多东西要学习;这是一个非常庞大的主题,这只是其中一个小方面。

文学编程

文档代码分开的想法可以被视为一种人为的区分。从历史上看,我们之所以在代码之外编写文档,是因为编程语言相对不透明,并且更倾向于高效编译而不是清晰的表达。已经尝试了不同的技术来减少工作代码和关于代码的文档之间的距离。例如,嵌入更复杂的注释是一个长期的传统。Python 通过在包、模块、类和函数中包含正式的文档字符串进一步推进了这一步骤。

软件开发中的文学编程方法是由唐纳德·克努斯首创的。其理念是单一的源文件可以生成高效的代码以及漂亮的文档。对于面向机器的汇编语言和 C 等语言,移向强调翻译的标记语言的另一个好处是,可以产生强调清晰表达的文档。此外,一些文学编程语言充当更高级的编程语言;这可能适用于 C 或 Pascal,但对于 Python 来说并不是很有帮助。

文学编程是为了促进对代码的更深入理解。对于 Python 来说,源代码本身就非常易读。对于 Python 来说,并不需要复杂的文学编程来使程序易懂。事实上,文学编程对于 Python 的主要好处在于以一种比简单的 Unicode 文本更易读的形式携带更深层次的设计和用例信息的理念。

有关更多信息,请参见www.literateprogramming.comxml.coverpages.org/xmlLitProg.html。唐纳德·克努斯的书籍Literate Programming是这个主题的开创性著作。

文学编程的用例

创建文学程序时有两个基本目标:

  • 一个工作程序:这是从源文件中提取的代码,为编译器或解释器准备的。

  • 易读的文档:这是解释加上代码加上任何为演示准备的有用标记。这个文档可以是 HTML 格式,准备好被查看。或者它可以是 RST 格式,我们可以使用 docutils 的rst2html.py将其转换为 HTML。或者,它可以是 LaTeX 格式,我们可以通过 LaTeX 处理器将其转换为 PDF 文档。

工作程序目标意味着我们的文学编程文档将涵盖整个源代码文件套件。虽然这看起来令人生畏,但我们必须记住,良好组织的代码片段不需要复杂的手势;在 Python 中,代码本身可以清晰而有意义。

易读的文档目标意味着我们要生成一个使用单一字体以外的东西的文档。虽然大多数代码都是用等宽字体编写的,但这对我们的眼睛来说并不是最容易的。基本的 Unicode 字符集也不包括粗体或斜体等有用的字体变体。这些额外的显示细节(字体变化、大小变化和样式变化)经过几个世纪的发展,使文档更易读。

在许多情况下,我们的 Python IDE 会对 Python 源代码进行颜色编码。这也是有帮助的。书面交流的历史包括许多可以增强可读性的特性,这些特性在简单的 Python 源代码中都是不可用的。

此外,文档应该围绕问题和解决方案进行组织。在许多语言中,代码本身不能遵循清晰的组织,因为它受到纯粹技术上的语法和编译顺序的限制。

我们的两个目标归结为两个技术用例:

  • 将原始源文本转换为代码

  • 将原始源文本转换为最终文档

我们可以在某种程度上以一些深刻的方式重构这两个用例。例如,我们可以从代码中提取文档。这就是pydoc模块所做的,但它并不很好地处理标记。

代码和最终文档两者可以是同构的。这是 PyLit 项目采用的方法。最终文档可以完全嵌入到 Python 代码中,通过 docstrings 和#注释。代码可以完全嵌入到 RST 文档中,使用::文字块。

使用文学编程工具

有许多文学编程LP)工具可用。从工具到工具变化的基本要素是将解释与代码分开的高级标记语言。

我们写的源文件将包含以下三件事:

  • 带有标记的文本是解释和描述

  • 代码

  • 高级标记来分隔文本(带有标记)和代码

由于 XML 的灵活性,这可以用作文学编程的高级标记。然而,这并不容易写。有一些工具可以使用基于原始 Web(以及后来的 CWeb)工具的类似 LaTeX 的标记。也有一些工具可以使用 RST 作为高级标记。

选择工具的关键步骤是看一下所使用的高级标记。如果我们发现标记很容易写,我们就可以放心地使用它来生成源文件。

Python 提出了一个有趣的挑战。因为我们有基于 RST 的工具,比如 Sphinx,我们可以有非常文学的 docstrings。这使我们有了两个层次的文档:

  • 文学编程的解释和背景在代码之外。这应该是太一般化,不集中在代码本身的背景材料。

  • 嵌入在 docstrings 中的参考和 API 文档。

这导致了一种愉快的、渐进的文学编程方法:

  • 最初,我们可以通过将 RST 标记嵌入我们的 docstrings 中,使得 Sphinx 生成的文档看起来不错,并为实现选择提供整洁的解释。

  • 我们可以超越狭窄的 docstring 焦点,创建背景文档。这可能包括关于设计决策、架构、需求和用户故事的信息。特别是非功能性质量要求的描述应该在代码之外。

  • 一旦我们开始正式化这个高级设计文档,我们就可以更容易地选择一个 LP 工具。然后,这个工具将决定我们如何将文档和代码结合成一个整体的文档结构。我们可以使用 LP 工具来提取代码并生成文档。一些 LP 工具也可以用来运行测试套件。

我们的目标是创建不仅设计良好,而且值得信赖的软件。正如之前所述,我们通过多种方式建立信任,包括提供整洁、清晰的解释为什么我们的设计是好的。

如果我们使用 PyLit 这样的工具,我们可能会创建类似以下代码的 RST 文件:

#############
Combinations
#############

..  contents::

Definition
==========

For some deeper statistical calculations,
we need the number of combinations of *n* things
taken *k* at a time, :math:`\binom{n}{k}`.

..  math::

    \binom{n}{k} = \dfrac{n!}{k!(n-k)!}

The function will use an internal ``fact()`` function because
we don't need factorial anywhere else in the application.

We'll rely on a simplistic factorial function without memoization.

Test Case
=========

Here are two simple unit tests for this function provided
as doctest examples.

>>> from combo import combinations
>>> combinations(4,2)
6
>>> combinations(8,4)
70

Implementation
===============
Here's the essential function definition, with docstring:
::

  def combinations( n, k ):
      """Compute :math:`\binom{n}{k}`, the number of
      combinations of *n* things taken *k* at a time.

      :param n: integer size of population
      :param k: groups within the population
      :returns: :math:`\binom{n}{k}`
      """

An important consideration here is that someone hasn't confused
the two argument values.
::

      assert k <= n

Here's the embedded factorial function. It's recursive. The Python
stack limit is a limitation on the size of numbers we can use.
::

      def fact(a):
          if a == 0: return 1
          return a*fact(a-1)

Here's the final calculation. Note that we're using integer division.
Otherwise, we'd get an unexpected conversion to float.
::

      return fact(n)//( fact(k)*fact(n-k) )

这是一个完全用 RST 标记编写的文件。它包含一些解释性文本,一些正式的数学,甚至一些测试用例。这些为我们提供了支持相关代码部分的额外细节。由于 PyLit 的工作方式,我们将文件命名为combo.py.txt。我们可以对这个文件做三件事:

  • 我们可以使用 PyLit 以以下方式从这个文本文件中提取代码:
python3.3 -m pylit combo.py.txt

这将从combo.py.txt创建combo.py。这是一个准备好使用的 Python 模块。

  • 我们还可以使用 docutils 将这个 RST 格式化为 HTML 页面,以便我们更容易阅读比原始的单一字体文本。
rst2html.py combo.py.txt combo.py.html

这将创建combo.py.html,准备浏览。docutils 将使用mathjax包来排版数学部分,从而产生非常漂亮的输出。

  • 此外,我们可以使用 PyLit 来运行doctest并确认这个程序确实有效。
python3.3 -m pylit --doctest combo.py.txt

这将从代码中提取doctest块并通过doctest工具运行它们。我们会看到三个测试(导入和两个函数评估)都产生了预期的结果。

由此产生的最终网页将类似于以下的屏幕截图:

使用文学编程工具

我们的目标是创建可信赖的软件。对我们的设计为何好的整洁、清晰的解释是这种信任的重要部分。通过在单一源文本中并排编写软件和文档,我们可以确保我们的文档是完整的,并提供对设计决策和软件整体质量的合理审查。一个简单的工具可以从单一源中提取工作代码和文档,使我们能够轻松创建软件和文档。

总结

我们看了以下四种创建可用文档的方法:

  • 我们可以将信息合并到软件的文档字符串中。

  • 我们可以使用pydoc从我们的软件中提取 API 参考信息。

  • 我们可以使用 Sphinx 来创建更复杂和精心制作的文档

  • 此外,我们可以使用文学编程工具来创建更深入和更有意义的文档

设计考虑和权衡

文档字符串应被视为 Python 源代码的任何其他部分一样重要。这确保了help()函数和pydoc的正确工作。与单元测试用例一样,这应被视为软件的强制要素。

Sphinx 创建的文档可能非常漂亮;它往往会与 Python 文档平行。我们一直以来的目标是与 Python 的其他功能无缝集成。使用 Sphinx 往往会为文档源和构建引入额外的目录结构。

在设计我们的类时,如何描述设计的问题几乎和最终设计本身一样重要。不能快速和清晰地解释的软件将被视为不可信任。

花时间写解释可能会发现隐藏的复杂性或不规则性。在这些情况下,我们可能不是为了纠正错误或改进性能而重构设计,而是为了更容易解释。解释的能力是一种具有巨大价值的质量因素。

posted @ 2024-05-04 21:31  绝不原创的飞龙  阅读(6)  评论(0编辑  收藏  举报