现代-Python-秘籍-全-
现代 Python 秘籍(全)
原文:
zh.annas-archive.org/md5/185a6e8218e2ea258a432841b73d4359译者:飞龙
前言
Python 是开发人员、工程师、数据科学家和爱好者的首选。它是一种强大的脚本语言,可以为您的应用程序提供强大的速度、安全性和可扩展性。通过将 Python 公开为一系列简单的配方,您可以深入了解特定上下文中的语言特性。具体的上下文有助于更容易理解语言或标准库特性。
本书采用了基于配方的方法,每个配方都解决特定的问题和问题。
本书涵盖的内容
第一章,数字、字符串和元组,将介绍不同类型的数字,处理字符串,使用元组,并使用 Python 中的基本内置类型。我们还将充分利用 Unicode 字符集的全部功能。
第二章,语句和语法,将首先涵盖创建脚本文件的一些基础知识。然后,我们将继续研究一些复杂的语句,包括 if、while、for、try、with 和 raise。
第三章,函数定义,将介绍一些函数定义技术。我们还将研究 Python 3.5 的 typing 模块,看看如何为我们的函数创建更正式的注释。
第四章,内置数据结构-列表、集合、字典,将概述可用的各种结构及其解决的问题。从那里,我们可以详细了解列表、字典和集合,还可以研究一些与 Python 如何处理对象引用相关的更高级主题。
第五章,用户输入和输出,将解释如何使用 print()函数的不同特性。我们还将研究用于提供用户输入的不同函数。
第六章,类和对象的基础,将创建实现多个统计公式的类。
第七章,更高级的类设计,将更深入地研究 Python 类。我们将结合之前学到的一些特性来创建更复杂的对象。
第八章,函数式和响应式编程特性,为我们提供了编写小型、表达力强的函数来执行所需数据转换的方法。接下来,您将了解响应式编程的概念,即在输入可用或更改时评估处理规则。
第九章,输入/输出、物理格式、逻辑布局,将使用不同的文件格式,如 JSON、XML 和 HTML。
第十章,统计编程和线性回归,将介绍我们可以使用 Python 内置库和数据结构进行的一些基本统计计算。我们将讨论相关性、随机性和零假设的问题。
第十一章,测试,将详细描述 Python 中使用的不同测试框架。
第十二章,Web Services,将介绍创建 RESTful web 服务和提供静态或动态内容的一些方法。
第十三章 ,应用集成 ,将探讨我们可以设计应用程序的方式,以便组合创建更大、更复杂的复合应用程序。我们还将研究复合应用程序可能出现的复杂性,以及需要集中一些功能,例如命令行解析的需求。
您需要为这本书做些什么
您只需要一台运行任何最新版本的 Python 的计算机,就可以按照本书中的示例进行操作。虽然所有示例都使用 Python 3,但只需进行少量更改即可使其适用于 Python 2。
这本书是为谁准备的
这本书适用于 Web 开发人员,程序员,企业程序员,工程师和大数据科学家。如果您是初学者,Python Cookbook 将帮助您入门。如果您有经验,它将扩展您的知识基础。对编程的基本了解会有所帮助。
约定
在本书中,您将找到一些文本样式,用于区分不同类型的信息。以下是这些样式的一些示例及其含义的解释。
文本中的代码词,数据库表名,文件夹名,文件名,文件扩展名,路径名,虚拟 URL,用户输入和 Twitter 句柄显示如下:“我们可以通过使用包含指令来包含其他上下文。”
代码块设置如下:
if distance is None:
distance = rate * time
elif rate is None:
rate = distance / time
elif time is None:
time = distance / rate
任何命令行输入或输出都写成如下形式:
**>>> circumference_diameter_ratio = 355/113**
**>>> target_color_name = 'FireBrick'**
**>>> target_color_rgb = (178, 34, 34)**
新术语和重要单词以粗体显示。
注意
警告或重要说明显示在这样的框中。
提示
提示和技巧显示如下。
第一章:数字,字符串和元组
我们将介绍这些食谱来介绍基本的 Python 数据类型:
-
创建有意义的名称并使用变量
-
处理大整数和小整数
-
在浮点数,小数和分数之间进行选择
-
在真除法和地板除法之间进行选择
-
重写不可变字符串
-
使用正则表达式解析字符串
-
使用“template”。格式()构建复杂的字符串
-
从字符列表构建复杂的字符串
-
使用不在键盘上的 Unicode 字符
-
编码字符串-创建 ASCII 和 UTF-8 字节
-
解码字节-如何从一些字节中获取正确的字符
-
使用项目的元组
介绍
本章将介绍 Python 对象的一些中心类型。我们将研究不同类型的数字,处理字符串和使用元组。我们首先研究这些,因为它们是 Python 处理的最简单的数据类型。在后面的章节中,我们将研究数据集合。
这些大多数食谱假设初学者对 Python 3 有一定的理解。我们将研究如何使用 Python 中提供的基本内置类型-数字,字符串和元组。Python 有各种各样的数字和两种不同的除法运算符,因此我们需要仔细研究可用的选择。
在处理字符串时,有几个重要的常见操作。我们将探讨字节(由操作系统文件使用)和字符串(由 Python 使用)之间的一些区别。我们将看看如何利用 Unicode 字符集的全部功能。
在本章中,我们将展示食谱,就好像我们是从交互式 Python 的>>>提示符中工作一样。这有时被称为读取-求值-打印循环(REPL)。在后面的章节中,我们将更仔细地研究编写脚本文件。目标是鼓励交互式探索,因为这是学习语言的好方法。
创建有意义的名称并使用变量
我们如何确保我们的程序是有意义的?制作表达性代码的核心元素之一是使用有意义的名称。但什么算是有意义的?在这个食谱中,我们将回顾一些创建有意义的 Python 名称的常见规则。
我们还将研究一些 Python 的赋值语句变体。例如,我们可以在单个语句中分配多个变量。
做好准备
创建名称时的核心问题是问自己一个问题这是什么东西?对于软件,我们希望一个描述对象的名称。显然,像x这样的名称并不是很描述性,它似乎并不指代实际的东西。
在一些编程中,模糊的,不具描述性的名称是令人沮丧的常见现象。当我们使用它们时,对他人并不有帮助。描述性的名称有助于每个人。
在命名事物时,将问题域-我们真正想要实现的目标-与解决方案域分开也很重要。解决方案域包括 Python,操作系统和互联网的技术细节。任何阅读代码的人都可以看到解决方案;它不需要深入解释。然而,问题域可能会被技术细节所掩盖。我们的工作是使问题清晰可见。选择恰当的名称将有所帮助。
如何做...
我们将首先看名称。然后我们将转向赋值。
明智地选择名称
从纯技术层面上讲,Python 名称必须以字母开头。它们可以包括任意数量的字母,数字和 _ 字符。Python 3 基于 Unicode,因此字母不限于拉丁字母表。虽然 A-Z 拉丁字母表通常被使用,但并非必需。
在创建描述性变量时,我们希望创建既具体又能表达程序中事物之间关系的名称。一个广泛使用的技术是创建更长的名称,风格从具体到一般。
选择名称的步骤如下:
-
名称的最后部分是对这个事物的一个非常广泛的总结。在一些情况下,这可能是我们所需要的全部;上下文会提供其余的部分。我们稍后会提出一些典型的广泛总结类别。
-
使用前缀来围绕你的应用程序或问题域缩小这个名称。
-
如果需要,可以在这个名称上加上更窄和专业的前缀,以澄清它与其他类、模块、包、函数和其他对象的区别。如果对于加前缀感到犹豫,可以回想一下域名是如何工作的。想想
mail.google.com——名称从具体到一般。命名的三个级别并没有什么神奇之处,但通常情况下会按照这种方式进行。 -
根据 Python 中的使用方式来格式化名称。我们将给出三种我们会给名称的东西的广泛类别,如下所示:
-
类:类有一个总结了属于该类的对象的名称。这些名称通常会使用
CapitalizedCamelCase。类名的第一个字母大写是为了强调它是一个类,而不是类的实例。类通常是一个通用的概念,很少是一个具体的东西的描述。 -
对象:对象的名称通常使用
snake_case——所有小写,单词之间用多个_字符分隔。在 Python 中,这包括变量、函数、模块、包、参数、对象的属性、类的方法,几乎所有其他东西。 -
脚本和模块文件:这些实际上是 Python 所看到的操作系统资源。因此,文件名应该遵循 Python 对象的约定,使用字母、
_字符,并以.py扩展名结尾。技术上可以有相当狂野和自由的文件名。不遵循 Python 规则的文件名可能很难作为模块或包使用。
我们如何选择名称的广泛类别部分?一般的类别取决于我们是在谈论一个事物还是一个事物的属性。虽然世界充满了事物,但我们可以创建一些有用的广泛分类。一些例子是 Document、Enterprise、Place、Program、Product、Process、Person、Asset、Rule、Condition、Plant、Animal、Mineral 等等。
然后我们可以用限定词来缩小这些范围:
FinalStatusDocument
ReceivedInventoryItemName
第一个例子是一个名为Document的类。我们通过添加前缀稍微缩小了它,称其为StatusDocument。我们甚至进一步缩小了它,称其为FinalStatusDocument。第二个例子是一个Name,我们通过指定它是一个ReceivedInventoryItemName来缩小了它。这个例子需要一个四级名称来澄清这个类。
一个对象通常有属性。这些属性有一个基于所表示信息类型的分解。一些应该作为完整名称的一部分的术语的例子是 amount、code、identifier、name、text、date、time、datetime、picture、video、sound、graphic、value、rate、percent、measure 等等。
思路是先放窄、更详细的描述,然后是广泛的信息类型:
measured_height_value
estimated_weight_value
scheduled_delivery_date
location_code
在第一个例子中,height缩小了更一般的表示术语value。而measured_height_value进一步缩小了这个范围。根据这个名称,我们可以期待看到关于 height 的其他变化。类似的思路也适用于weight_value,delivery_date和location_code。每个名称都有一个缩小的前缀或两个。
注意
一些需要避免的事情:
不要使用编码的前缀或后缀包含详细的技术类型信息。这通常被称为匈牙利命名法;我们不使用f_measured_height_value,其中f应该表示浮点数。像measured_height_value这样的变量可以是任何数字类型,Python 会进行所有必要的转换。技术装饰对于阅读我们的代码的人并没有太多帮助,因为类型规范可能是误导性的,甚至是错误的。
不要浪费大量精力让名称看起来像是属于一起的。我们不需要让SpadesCardSuit,ClubsCardSuit等看起来像是属于一起的。Python 有许多不同类型的命名空间,包括包、模块和类,以及命名空间对象来将相关名称聚集在一起。如果你将这些名称组合在一个CardSuit类中,你可以使用CardSuit.Spades,这样可以使用类作为命名空间将这些名称与其他类似的名称分开。
给对象命名
Python 不使用静态变量定义。当一个名称被赋予一个对象时,变量就被创建了。重要的是要把对象看作是我们处理的中心,而变量只是标识对象的便利贴。以下是我们如何使用基本的赋值语句:
-
创建一个对象。在许多例子中,我们将创建对象作为文字。我们将使用
355或113作为 Python 中整数对象的文字表示。我们可能会使用像FireBrick这样的字符串,或者像(178, 34, 34)这样的元组。 -
写下以下类型的陈述:变量 = 对象。以下是一些例子:
**>>> circumference_diameter_ratio = 355/113**
**>>> target_color_name = 'FireBrick'**
**>>> target_color_rgb = (178, 34, 34)**
我们已经创建了一些对象并将它们分配给变量。第一个对象是计算的结果。接下来的两个对象是简单的文字。通常,对象是通过涉及函数或类的表达式创建的。
这种基本的语句并不是唯一一种赋值方式。我们可以使用一种重复赋值的方式将单个对象分配给多个变量,就像这样:
**>>> target_color_name = first_color_name = 'FireBrick'**
这创建了同一个字符串对象的两个名称。我们可以通过检查 Python 使用的内部 ID 值来确认这一点:
**>>> id(target_color_name) == id(first_color_name)**
**True**
这种比较告诉我们,这两个对象的内部标识符是相同的。
注意
相等的测试使用==。简单的赋值使用=。
当我们查看数字和集合时,我们会发现我们可以将赋值与运算符结合起来。我们可以做这样的事情:
**>>> total_count = 0**
**>>> total_count += 5**
**>>> total_count += 6**
**>>> total_count**
**11**
我们已经使用运算符增强了赋值。total_count += 5与total_count = total_count + 5是一样的。这种技术的优点是更短。
它是如何工作的...
创建名称的这种方法遵循了首先使用狭窄、更具体的限定词,然后是更广泛、不太具体的类别的模式。这遵循了用于域名和电子邮件地址的常见约定。
例如,像mail.google.com这样的域名具有特定的服务,更一般的企业,最后是一个非常普遍的域。这遵循了由狭窄到更广泛的原则。
作为另一个例子,service@packtpub.com 以特定的目的地名称开头,具有更一般的企业,最后是一个非常普遍的域。甚至目的地的名称(PacktPub)也是一个由狭窄的企业名称(Packt)和更广泛的行业(Pub,缩写为publishing)组成的两部分名称。(我们不同意那些认为它代表公共场所的人。)
赋值语句是将名称放在对象上的唯一方法。我们注意到我们可以为同一个基础对象有两个名称。目前这并不太有用。但是在第四章中,内置数据结构-列表、集合、字典,我们将看到单个对象的多个名称的一些有趣的后果。
还有更多...
我们将尝试在所有的食谱中展示描述性的名称。
提示
我们必须对不遵循这种模式的现有软件做出例外。与强加新规则相比,与遗留软件保持一致通常更好,即使新规则更好。
几乎每个例子都涉及对变量的赋值。这对于有状态的面向对象编程至关重要。
我们将在第六章中查看类和类名,类和对象的基础;我们将在第十三章中查看模块,应用集成。
另请参阅
描述性命名的主题是持续研究和讨论的来源。有两个方面——语法和语义。关于 Python 语法的思考的起点是著名的Python Enhancement Proposal number 8(PEP-8)。这导致使用CamelCase和snake_case名称。
另外,一定要这样做:
**>>> import this**
这将更多地了解 Python 的理想。
注意
有关语义的信息,请参阅传统的 UDEF 和 NIEM 命名和设计规则标准(www.opengroup.org/udefinfo/AboutTheUDEF.pdf)。ISO11179 中还有更多详细信息(en.wikipedia.org/wiki/ISO/IEC_11179),其中详细讨论了元数据和命名。
使用大和小整数
许多编程语言区分整数、字节和长整数。一些语言包括有符号和无符号整数的区别。我们如何将这些概念映射到 Python 呢?
简单的答案是我们不需要。Python 以统一的方式处理所有大小的整数。从字节到数百位数的巨大数字,对 Python 来说都只是整数。
准备工作
想象一下,你需要计算一个非常大的东西。例如,计算 52 张牌的牌组的排列方式。数字 52! = 52 × 51 × 50 × ... × 2 × 1,是一个非常非常大的数字。我们可以在 Python 中做到这一点吗?
如何做...
别担心。真的。Python 的行为就好像它有一种通用类型的整数,这涵盖了从字节到填满所有内存的数字的所有基础。以下是使用整数的步骤:
-
写下你需要的数字。以下是一些小数字:355,113。没有实际的上限。
-
创建一个非常小的值——一个字节——看起来像这样:
**>>> 2**
**2**
或者,如果你想使用 16 进制,可能是这样:
**>>> 0xff**
**255**
在后续的示例中,我们将看到一个字节序列,其中只有一个值:
**>>> b'\xfe'**
**b'\xfe'**
从技术上讲,这不是一个整数。它有一个b'的前缀,表明它是一个 1 字节序列。
- 使用计算创建一个非常大的数字可能看起来像这样:
**>>> 2**2048**
**323...656**
这个数字有 617 位。我们没有展示所有的位数。
它是如何工作的...
在内部,Python 使用两种类型的数字。这两者之间的转换是无缝和自动的。
对于小整数,Python 通常会使用 4 或 8 字节的整数值。细节被埋在 CPython 的内部,并且取决于用于构建 Python 的 C 编译器的功能。
对于大整数,超过sys.maxsize,Python 将切换到大整数数字,这些数字是数字序列。在这种情况下,数字通常意味着 30 位值。
我们可以对标准的 52 张牌的牌组进行多少种排列?答案是 52! ≈ 8 × 10⁶⁷。以下是我们如何计算这个大数字。我们将使用math模块中的阶乘函数,如下所示:
**>>> import math**
**>>> math.factorial(52)**
**80658175170943878571660636856403766975289505440883277824000000000000**
是的,这些巨大的数字完美地工作。
我们计算 52!(从 52×51×50×...到约 42)的前几部分可以完全使用较小的整数。之后,计算的其余部分必须切换到较大的整数。我们看不到切换;我们只看到结果。
关于整数的内部细节,我们可以看看这个:
**>>> import sys**
**>>> import math**
**>>> math.log(sys.maxsize, 2)**
**63.0**
**>>> sys.int_info**
**sys.int_info(bits_per_digit=30, sizeof_digit=4)**
sys.maxsize值是小整数值中最大的值。我们计算了以 2 为底的对数,以找出这个数字需要多少位。
这告诉我们,我们的 Python 对于小整数使用 63 位值。小整数的范围是从-2⁶⁴ ... 2⁶³ - 1。在此范围之外,将使用大整数。
sys.int_info中的值告诉我们,大整数是使用 30 位数字的数字序列,每个数字占据 4 个字节。
像 52!这样的大值由 8 个这样的 30 位大小的数字组成。将一个数字表示为需要 30 位来表示可能有点令人困惑。与用于表示十进制数的 10 个符号不同,我们需要2**30个不同的符号来表示这些大数字的每个数字。
涉及大量大整数值的计算可能会消耗大量内存。那么小数字呢?Python 如何管理跟踪像 1 和 0 这样的许多小数字?
对于常用的数字(-5 到 256),Python 实际上创建了一个秘密的对象池来优化内存管理。当您检查整数对象的id()值时,您可以看到这一点:
**>>> id(1)**
**4297537952**
**>>> id(2)**
**4297537984**
**>>> a=1+1**
**>>> id(a)**
**4297537984**
我们展示了整数1和整数2的内部id。当我们计算一个值时,结果对象实际上是在池中找到的相同整数2对象。
当您尝试这样做时,您的id()值可能会有所不同。但是,每次使用2的值时,它都将是相同的对象;在作者的笔记本电脑上,它的 id = 4297537984。这样可以节省很多 2 对象的副本占用内存。
这是一个查看一个数字有多大的小技巧:
**>>> len(str(2**2048))**
**617**
我们从一个计算出的数字创建了一个字符串。然后我们询问字符串的长度。响应告诉我们这个数字有 617 位。
还有更多...
Python 为我们提供了一套广泛的算术运算符:+,-,*,/,//,%和**。/和//用于除法;我们将在一个名为在真除法和地板除法之间进行选择的单独配方中查看这些。**将一个数提高到幂。
对于处理单个位,我们有一些额外的操作。我们可以使用&,^,|,<<和>>。这些运算符在整数的内部二进制表示上逐位运算。这些分别计算二进制AND,二进制异或,或,左移和右移。
虽然这些将适用于非常大的整数,但在个别字节的世界之外,它们实际上并没有太多意义。一些二进制文件和网络协议将涉及查看数据的单个字节中的位。
我们可以通过使用bin()函数来玩弄这些运算符,看看发生了什么。
这是一个快速的例子:
**>>> xor = 0b0011 ^ 0b0101**
**>>> bin(xor)**
**'0b110'**
我们使用了0b0011和0b0101作为我们的两个比特字符串。这有助于准确地澄清这两个数字的二进制表示。我们对这两个比特序列应用了异或(^)运算符。我们使用bin()函数将结果作为比特字符串查看。我们可以仔细地对齐比特,看看运算符做了什么。
我们可以将一个字节分解成几部分。假设我们想要将最左边的两位与其他六位分开。一种方法是使用这样的位操作表达式:
**>>> composite_byte = 0b01101100**
**>>> bottom_6_mask = 0b00111111**
**>>> bin(composite_byte >> 6)**
**'0b1'**
**>>> bin(composite_byte & bottom_6_mask)**
**'0b101100'**
我们定义了一个复合字节,其中最高的两位是01,最低的六位是101100。我们使用>>移位运算符将该值向右移动六个位置,删除最低有效位并保留最高的两位。我们使用了一个掩码的&运算符。掩码中有 1 位时,结果中保留了位置的值,掩码中有0位时,结果位置设置为0。
另请参阅
-
我们将在在真除法和地板除法之间进行选择配方中查看两个除法运算符
-
我们将在在浮点数、十进制和分数之间进行选择配方中查看其他类型的数字
-
有关整数处理的详细信息,请参见
www.python.org/dev/peps/pep-0237/
在浮点数、十进制和分数之间进行选择
Python 为我们提供了几种处理有理数和无理数近似的方法。我们有三种基本选择:
-
浮点数
-
十进制
-
分数
有了这么多选择,我们什么时候使用每种选择呢?
准备就绪
确定我们的核心数学期望是很重要的。如果我们不确定我们有什么样的数据,或者我们想要得到什么样的结果,我们真的不应该编码。我们需要退一步,用铅笔和纸重新审视事情。
涉及超出整数的数字的数学有三种一般情况,即:
-
货币:美元、分或欧元。货币通常有固定的小数位数。有舍入规则用于确定 7.25%的$2.95 是多少。
-
有理数或分数:当我们使用英尺和英寸的美国单位,或者使用杯和液体盎司的烹饪度量时,我们经常需要使用分数。例如,当我们将为八个人的食谱缩小到五个人时,我们正在使用 5/8 的缩放因子进行分数运算。我们如何将这应用到 2/3 杯米饭,并且仍然得到一个适合美国厨房工具的度量?
-
无理数:这包括所有其他类型的计算。重要的是要注意,数字计算机只能近似这些数字,我们偶尔会看到这种近似的奇怪小瑕疵。浮点数近似非常快,但有时会遇到截断问题。
当我们有前两种情况之一时,我们应该避免使用浮点数。
如何做...
我们将分别查看这三种情况。首先,我们将看一下使用货币进行计算。然后我们将看一下有理数,最后是无理数或浮点数。最后,我们将看一下在这些各种类型之间进行明确转换。
进行货币计算
在处理货币时,我们应该始终使用decimal模块。如果我们尝试使用 Python 内置的float值,我们将遇到舍入和截断数字的问题。
- 要处理货币,我们将这样做。从
decimal模块导入Decimal类:
**>>> from decimal import Decimal**
- 从字符串或整数创建
Decimal对象:
**>>> from decimal import Decimal**
**>>> tax_rate = Decimal('7.25')/Decimal(100)**
**>>> purchase_amount = Decimal('2.95')**
**>>> tax_rate * purchase_amount**
**Decimal('0.213875')**
我们从两个Decimal对象创建了tax_rate。一个是基于字符串的,另一个是基于整数的。我们可以使用Decimal('0.0725')而不是显式地进行除法。
结果是超过$0.21。它被正确计算到了完整的小数位数。
- 如果您尝试从浮点数值创建十进制对象,您将看到浮点数近似的不愉快的副作用。避免混合
Decimal和float。要舍入到最接近的一分钱,创建一个penny对象:
**>>> penny=Decimal('0.01')**
- 使用这个 penny 对象对数据进行量化:
**>>> total_amount = purchase_amount + tax_rate*purchase_amount**
**>>> total_amount.quantize(penny)**
**Decimal('3.16')**
这显示了我们如何使用ROUND_HALF_EVEN的默认舍入规则。
每个财务巫师都有不同的舍入风格。Decimal模块提供了每种变化。例如,我们可能会做这样的事情:
**>>> import decimal**
**>>> total_amount.quantize(penny, decimal.ROUND_UP)**
**Decimal('3.17')**
这显示了使用不同的舍入规则的后果。
分数计算
当我们进行具有精确分数值的计算时,我们可以使用fractions模块。这为我们提供了方便的有理数,我们可以使用。要处理分数,我们将这样做:
- 从
fractions模块导入Fraction类:
**>>> from fractions import Fraction**
- 从字符串、整数或整数对创建
Fraction对象。如果从浮点数值创建分数对象,可能会看到浮点数近似的不愉快的副作用。当分母是 2 的幂时,事情可能会完全解决:
**>>> from fractions import Fraction**
**>>> sugar_cups = Fraction('2.5')**
**>>> scale_factor = Fraction(5/8)**
**>>> sugar_cups * scale_factor**
**Fraction(25, 16)**
我们从一个字符串2.5创建了一个分数。我们从一个浮点计算5/8创建了第二个分数。因为分母是 2 的幂,这完全解决了。
结果 25/16 是一个看起来复杂的分数。附近可能更简单的分数是什么?
**>>> Fraction(24,16)**
**Fraction(3, 2)**
我们可以看到,我们将使用将近一杯半来将为八个人的食谱缩小到五个人。
浮点数近似
Python 的内置float类型能够表示各种各样的值。这里的权衡是,float 通常涉及近似值。在某些情况下——特别是在涉及 2 的幂的除法时,它可以像fraction一样精确。在所有其他情况下,可能会有一些小的差异,揭示了float的实现和无理数的数学理想之间的差异。
- 要使用
float,我们经常需要四舍五入值使其看起来合理。请认识到所有计算都是近似值:
**>>> (19/155)*(155/19)**
**0.9999999999999999**
- 从数学上讲,该值应为
1。由于float的近似值,答案并不精确。它并不错得很多,但是错了。当我们适当四舍五入时,该值更有用:
**>>> answer= (19/155)*(155/19)**
**>>> round(answer, 3)
1.0**
- 了解误差项。在这种情况下,我们知道应该是什么精确答案,所以我们可以将我们的计算与已知的正确答案进行比较。这给了我们可能会渗入浮点数的一般误差值:
**>>> 1-answer**
**1.1102230246251565e-16**
对于大多数浮点错误,这是典型值——约为 10^(-16)。Python 有巧妙的规则,通过一些自动四舍五入来隐藏这种错误。然而,对于这个计算,错误并没有被隐藏。
这是一个非常重要的结果。
提示
不要将浮点值进行精确相等的比较。
当我们看到使用浮点数之间的精确==测试的代码时,当近似值相差一个位时,就会出现问题。
将数字从一种类型转换为另一种类型
我们可以使用float()函数从另一个值创建一个float值。它看起来像这样:
**>>> float(total_amount)**
**3.163875**
**>>> float(sugar_cups * scale_factor)**
**1.5625**
在第一个示例中,我们将Decimal值转换为float。在第二个示例中,我们将Fraction值转换为float。
正如我们刚才看到的,我们永远无法将float转换为Decimal或Fraction:
**>>> Fraction(19/155)**
**Fraction(8832866365939553, 72057594037927936)**
**>>> Decimal(19/155)**
**Decimal('0.12258064516129031640279123394066118635237216949462890625')**
在第一个示例中,我们进行了整数之间的计算,以创建一个已知截断问题的float值。当我们从该截断的float值创建一个Fraction时,我们得到了一些暴露了截断细节的可怕数字。
同样,第二个示例试图从float创建一个Decimal值。
它是如何工作的...
对于这些数字类型,Python 为我们提供了各种运算符:+,-,*,/,//,%和**。这些是用于加法、减法、乘法、真除法、截断除法、模数和乘方的。我们将在选择真除法和地板除法的示例中查看这两个除法运算符。
Python 擅长在各种类型之间转换数字。我们可以混合int和float值;整数将被提升为浮点以提供尽可能准确的答案。同样,我们可以混合int和Fraction,结果将是Fractions。我们也可以混合int和Decimal。我们不能随意混合Decimal和float或Fraction;我们需要提供显式转换。
注意
重要的是要注意,float值实际上是近似值。Python 语法允许我们将数字写成小数值;这不是它们在内部处理的方式。
我们可以在 Python 中写出这样的值,使用普通的十进制值:
**>>> 8.066e+67**
**8.066e+67**
内部使用的实际值将涉及我们写的十进制值的二进制近似值。
这个例子的内部值8.066e+67是这样的:
**>>> 6737037547376141/2**53*2**226**
**8.066e+67**
分子是一个大数,6737037547376141。分母始终是2⁵³。由于分母是固定的,结果分数只能有 53 个有意义的数据位。由于没有更多的位可用,值可能会被截断。这导致我们理想化抽象和实际数字之间的微小差异。指数(2²²⁶)是将分数放大到适当范围所必需的。
从数学上讲,6737037547376141 * 2²²⁶ /2⁵³。
我们可以使用math.frexp()来查看数字的这些内部细节:
**>>> import math**
**>>> math.frexp(8.066E+67)**
**(0.7479614202861186, 226)**
这两部分被称为尾数和指数。如果我们将尾数乘以2⁵³,我们总是得到一个整数,这是二进制分数的分子。
注意
我们之前注意到的误差非常匹配:10^(-16) ≈ 2^(-53)。
与内置的float不同,Fraction是两个整数值的精确比率。正如我们在使用大整数和小整数配方中看到的,Python 中的整数可以非常大。我们可以创建涉及具有大量数字的整数的比率。我们不受固定分母的限制。
类似地,Decimal值是基于一个非常大的整数值和一个缩放因子来确定小数点的位置。这些数字可以非常庞大,并且不会受到奇怪的表示问题的影响。
注意
为什么使用浮点数?两个原因:
并非所有可计算的数字都可以表示为分数。这就是为什么数学家引入(或者也许是发现)无理数的原因。内置的浮点类型是我们可以接近数学抽象的无理数的方式。例如,像√2 这样的值不能表示为分数。
此外,浮点值非常快。
还有更多...
Python 的math模块包含了许多专门用于处理浮点数值的函数。这个模块包括了常见的函数,比如平方根、对数和各种三角函数。它还有一些其他函数,比如 gamma、阶乘和高斯误差函数。
math模块包括几个函数,可以帮助我们进行更准确的浮点计算。例如,math.fsum()函数将比内置的sum()函数更谨慎地计算浮点和。它不太容易受到近似问题的影响。
我们还可以利用math.isclose()函数来比较两个浮点值,看它们是否几乎相等:
**>>> (19/155)*(155/19) == 1.0**
**False**
**>>> math.isclose((19/155)*(155/19), 1)**
**True**
这个函数为我们提供了一种有意义地比较浮点数的方法。
Python 还提供了复数数据。这涉及到一个实部和一个虚部。在 Python 中,我们写3.14+2.78j来表示复数 3.14 + 2.78 √-1。Python 可以很舒适地在浮点数和复数之间转换。我们有一组通常的操作符可用于复数。
为了支持复数,有一个cmath包。例如,cmath.sqrt()函数将返回一个复数值,而不是在提取负数的平方根时引发异常。这里有一个例子:
**>>> math.sqrt(-2)**
**Traceback (most recent call last):**
**File "<stdin>", line 1, in <module>**
**ValueError: math domain error**
**>>> cmath.sqrt(-2)**
**1.4142135623730951j**
这在处理复数时是必不可少的。
另请参阅
-
我们将在在真除法和地板除法之间进行选择配方中更多地讨论浮点数和分数
在真除法和地板除法之间进行选择
Python 为我们提供了两种类型的除法运算符。它们是什么,我们如何知道该使用哪一个?我们还将看一下 Python 的除法规则以及它们如何适用于整数值。
准备工作
有几种一般情况可以进行除法:
-
一个div-mod对:我们想要两部分——商和余数。当我们将值从一种基数转换为另一种基数时,我们经常使用这种方法。当我们将秒转换为小时、分钟和秒时,我们将进行div-mod类型的除法。我们不想要准确的小时数,我们想要截断的小时数,余数将被转换为分钟和秒。
-
真实值:这是典型的浮点值——它将是商的一个很好的近似值。例如,如果我们计算几个测量的平均值,我们通常希望结果是浮点数,即使输入值都是整数。
-
一个有理分数值:这在使用英尺、英寸和杯的美国单位时经常需要。对于这一点,我们应该使用
Fraction类。当我们除以Fraction对象时,我们总是得到精确的答案。
我们需要决定哪些情况适用,这样我们就知道要使用哪个除法运算符。
如何做...
我们将分别查看三种情况。首先我们将查看截断的地板除法。然后我们将查看真正的浮点除法。最后,我们将查看分数的除法。
进行地板除法
当我们进行div-mod类型的计算时,我们可能会使用地板除法,//,和模数,%。或者,我们可以使用divmod()函数。
- 我们将把秒数除以 3600 得到
小时的值;余数可以分别转换为分钟和秒:
**>>> total_seconds = 7385**
**>>> hours = total_seconds//3600**
**>>> remaining_seconds = total_seconds % 3600**
- 再次使用剩余值,我们将把秒数除以 60 得到
分钟;余数是小于 60 的秒数:
**>>> minutes = remaining_seconds//60**
**>>> seconds = remaining_seconds % 60**
**>>> hours, minutes, seconds**
**(2, 3, 5)**
这是另一种方法,使用divmod()函数:
- 同时计算商和余数:
**>>> total_seconds = 7385**
**>>> hours, remaining_seconds = divmod(total_seconds, 3600)**
- 再次计算商和余数:
**>>> minutes, seconds = divmod(remaining_seconds, 60)**
**>>> hours, minutes, seconds**
**(2, 3, 5)**
进行真正的除法
真值计算给出一个浮点近似值。例如,7386 秒大约是多少小时?使用真正的除法运算符进行除法:
**>>> total_seconds = 7385**
**>>> hours = total_seconds / 3600**
**>>> round(hours,4)**
**2.0514**
注意
我们提供了两个整数值,但得到了一个浮点数的精确结果。与我们之前使用浮点数值的配方一致,我们四舍五入了结果,以避免查看微小的误差值。
这种真正的除法是 Python 3 的一个特性。我们将在接下来的章节中从 Python 2 的角度来看这一点。
有理数分数计算
我们可以使用Fraction对象和整数进行除法。这将强制结果成为一个数学上精确的有理数:
- 创建至少一个
Fraction值:
**>>> from fractions import Fraction**
**>>> total_seconds = Fraction(7385)**
- 在计算中使用
Fraction值。任何整数都将被提升为Fraction:
**>>> hours = total_seconds / 3600**
**>>> hours**
**Fraction(1477, 720)**
- 如果必要,将精确分数转换为浮点数近似值:
**>>> round(float(hours),4)**
**2.0514**
首先,我们为总秒数创建了一个Fraction对象。当我们对分数进行算术运算时,Python 会将任何整数提升为分数;这种提升意味着尽可能精确地进行数学运算。
它是如何工作的...
Python 3 有两个除法运算符。
-
/真正的除法运算符总是尝试产生一个真正的浮点结果。即使两个操作数是整数,它也会这样做。在这方面,这是一个不寻常的运算符。所有其他运算符都试图保留数据的类型。真正的除法操作-当应用于整数时-产生一个float结果。 -
//截断除法运算符总是尝试产生一个截断的结果。对于两个整数操作数,这是截断的商。对于两个浮点操作数,这是一个截断的浮点结果:
**>>> 7358.0 // 3600.0**
**2.0**
默认情况下,Python 2 只有一个除法运算符。对于仍在使用 Python 2 的程序员,我们可以开始使用这些新的除法运算符:
**>>> from __future__ import division**
这个导入将安装 Python 3 除法规则。
另请参阅
-
有关浮点数和分数之间的选择,请参阅在浮点数、小数和分数之间进行选择配方
重写一个不可变的字符串
如何重写一个不可变的字符串?我们无法改变字符串内部的单个字符:
**>>> title = "Recipe 5: Rewriting, and the Immutable String"**
**>>> title[8]= ''**
**Traceback (most recent call last):**
**File "<stdin>", line 1, in <module>**
**TypeError: 'str' object does not support item assignment**
由于这不起作用,我们如何对字符串进行更改?
准备好
假设我们有这样一个字符串:
**>>> title = "Recipe 5: Rewriting, and the Immutable String"**
我们想做两个转换:
-
删除
:之前的部分 -
用
_替换标点符号,并将所有字符转换为小写
由于我们无法替换字符串对象中的字符,我们必须想出一些替代方案。以下是一些常见的事情,如下所示:
-
使用切片和连接字符串的组合来创建一个新的字符串。
-
在缩短时,我们经常使用
partition()方法。 -
我们可以用
replace()方法替换一个字符或子字符串。 -
我们可以将字符串扩展为字符列表,然后再次将字符串连接成单个字符串。这是一个单独配方的主题,使用字符列表构建复杂字符串。
如何做...
由于我们无法直接更新字符串,因此必须用每个修改后的结果替换字符串变量的对象。我们将使用类似于以下的语句:
some_string = some_string.method()
或者我们甚至可以使用:
some_string = some_string[:chop_here]
我们将看一些特定变体的这个一般主题。我们将切片字符串的一部分,我们将替换字符串中的单个字符,并且我们将应用像使字符串小写之类的全面转换。我们还将看看如何删除出现在最终字符串中的额外的_。
切片字符串的一部分
以下是我们如何通过切片缩短字符串:
- 找到边界:
**>>> colon_position = title.index(':')**
索引函数定位特定的子字符串并返回该子字符串的位置。如果子字符串不存在,它会引发一个异常。这总是true的结果title[colon_position] == ':'。
- 选择子字符串:
**>>> discard_text, post_colon_text = title[:colon_position], title[colon_position+1:]**
**>>> discard_text**
**'Recipe 5'**
**>>> post_colon_text**
**' Rewriting, and the Immutable String'**
我们使用切片表示法显示要选择的字符的start:end。我们还使用多重赋值从两个表达式中分配两个变量discard_text和post_colon_text。
我们可以使用partition()以及手动切片。找到边界并分区:
**>>> pre_colon_text, _, post_colon_text = title.partition(':')**
**>>> pre_colon_text**
**'Recipe 5'**
**>>> post_colon_text**
**' Rewriting, and the Immutable String'**
partition函数返回三个东西:目标之前的部分,目标和目标之后的部分。我们使用多重赋值将每个对象分配给不同的变量。我们将目标分配给一个名为_的变量,因为我们将忽略结果的这一部分。这是一个常见的习惯用法,用于我们必须提供一个变量的地方,但我们不关心使用该对象。
使用替换更新字符串
我们可以使用replace()来删除标点符号。在使用replace切换标点符号时,将结果保存回原始变量。在这种情况下,post_colon_text:
**>>> post_colon_text = post_colon_text.replace(' ', '_')**
**>>> post_colon_text = post_colon_text.replace(',', '_')**
**>>> post_colon_text**
**'_Rewriting__and_the_Immutable_String'**
这已经用所需的_字符替换了两种标点符号。我们可以将其推广到所有标点符号。这利用了for语句,我们将在第二章中进行讨论,语句和语法。
我们可以遍历所有标点字符:
**>>> from string import whitespace, punctuation**
**>>> for character in whitespace + punctuation:**
**... post_colon_text = post_colon_text.replace(character, '_')**
**>>> post_colon_text**
**'_Rewriting__and_the_Immutable_String'**
当每种标点符号字符被替换时,我们将最新和最好的字符串版本分配给post_colon_text变量。
使字符串全部小写
另一个转换步骤是将字符串更改为全部小写。与前面的例子一样,我们将结果分配回原始变量。使用lower()方法,将结果分配给原始变量:
**>>> post_colon_text = post_colon_text.lower()**
删除额外的标点符号
在许多情况下,我们可能会遵循一些额外的步骤。我们经常希望删除前导和尾随的_字符。我们可以使用strip()来实现这一点:
**>>> post_colon_text = post_colon_text.strip('_')**
在某些情况下,我们可能会有多个_字符,因为我们有多个标点符号。最后一步将是这样清理多个_字符:
**>>> while '__' in post_colon_text:**
**... post_colon_text = post_colon_text.replace('__', '_')**
这是另一个例子,我们一直在使用相同的模式来修改字符串。这取决于while语句,我们将在第二章中进行讨论,语句和语法。
它是如何工作的...
从技术上讲,我们不能直接修改字符串。字符串的数据结构是不可变的。但是,我们可以将新的字符串赋回原始变量。这种技术的行为与直接修改字符串相同。
当变量的值被替换时,先前的值不再有任何引用,并且被垃圾回收。我们可以通过使用id()函数跟踪每个单独的字符串对象来看到这一点:
**>>> id(post_colon_text)**
**4346207968**
**>>> post_colon_text = post_colon_text.replace('_','-')**
**>>> id(post_colon_text)**
**4346205488**
您的实际 ID 号可能不同。重要的是,分配给post_colon_text的原始字符串对象具有一个 ID。分配给post_colon_text的新字符串对象具有不同的 ID。这是一个新的字符串对象。
当旧字符串没有更多引用时,它会自动从内存中删除。
我们使用了切片表示法来分解字符串。切片有两部分:[start:end]。切片始终包括起始索引。字符串索引始终从零开始作为第一项。它永远不包括结束索引。
提示
切片中的项目从start到end-1具有索引。有时这被称为半开放区间。
想象一下切片是这样的:所有的字符都在索引i的范围内start ≤ i < end。
我们简要提到我们可以省略开始或结束索引。我们实际上可以两者都省略。以下是各种可用的选项:
-
title[colon_position]:一个单独的项目,我们使用title.index(':')找到的:。 -
title[:colon_position]:省略了开始。它从第一个位置开始,索引为零。 -
title[colon_position+1:]:省略了结束。它以字符串的结束结束,就好像我们说len(title)一样。 -
title[:]:由于开始和结束都被省略了,这是整个字符串。实际上,这是整个字符串的副本。这是复制字符串的快速简单方法。
还有更多...
Python 集合中的索引有更多特性,如字符串。正常索引从左端以 0 开始。我们有一组使用负名称的替代索引,从字符串的右端开始工作。
-
title[-1]是标题中的最后一个字符,g -
title[-2]是倒数第二个字符,n -
title[-6:]是最后六个字符,String
我们有很多方法可以从字符串中选择片段和部分。
Python 提供了几十种修改字符串的方法。Python 标准库的第 4.7 节描述了我们可以使用的不同类型的转换。字符串方法有三大类。我们可以询问字符串,我们可以解析字符串,我们可以转换字符串。例如,isnumeric()等方法告诉我们字符串是否全是数字。
这里有一个例子:
**>>> 'some word'.isnumeric()**
**False**
**>>> '1298'.isnumeric()**
**True**
我们已经使用partition()方法进行了解析。我们已经使用lower()方法进行了转换。
另请参阅
-
我们将在从字符列表构建复杂字符串的示例中使用字符串作为列表的技术来修改字符串。
-
有时我们的数据只是一串字节。为了理解它,我们需要将其转换为字符。这是解码字节-如何从一些字节中获取适当的字符的主题。
使用正则表达式进行字符串解析
我们如何分解复杂字符串?如果我们有复杂的、棘手的标点符号怎么办?或者更糟的是,如果我们没有标点符号,而必须依靠数字模式来定位有意义的信息怎么办?
准备工作
分解复杂字符串的最简单方法是将字符串泛化为模式,然后编写描述该模式的正则表达式。
正则表达式可以描述的模式有限。当我们面对像 HTML、XML 或 JSON 这样的深度嵌套的文档时,我们经常遇到问题,无法使用正则表达式。
re模块包含了我们需要创建和使用正则表达式的各种类和函数。
假设我们想要从食谱网站分解文本。每行看起来像这样:
**>>> ingredient = "Kumquat: 2 cups"**
我们想要将成分与测量分开。
如何做...
要编写和使用正则表达式,我们经常这样做:
- 将示例泛化。在我们的情况下,我们有一些可以泛化的东西:
**(ingredient words): (amount digits) (unit words)**
- 我们用两部分摘要替换了文字:它的含义和它的表示方式。例如,成分表示为单词,数量表示为数字。导入
re模块:
**>>> import re**
- 将模式重写为正则表达式(RE)表示法:
**>>> pattern_text = r'(?P<ingredient>\w+):\s+(?P<amount>\d+)\s+(?P<unit>\w+)'**
我们已经用\w+替换了单词等表示提示。我们用\d+替换了数字。我们用\s+替换了单个空格,以允许使用一个或多个空格作为标点符号。我们保留了冒号,因为在正则表达式符号中,冒号与自身匹配。
对于数据的每个字段,我们使用了?P<name>来提供一个标识我们想要提取的数据的名称。我们没有在冒号或空格周围这样做,因为我们不想要这些字符。
REs 使用了很多\字符。为了使其在 Python 中工作得很好,我们几乎总是使用原始字符串。r'前缀告诉 Python 不要查看\字符,也不要用我们键盘上没有的特殊字符替换它们。
- 编译模式:
**>>> pattern = re.compile(pattern_text)**
- 根据输入文本匹配模式。如果输入与模式匹配,我们将得到一个显示匹配详细信息的匹配对象:
**>>> match = pattern.match(ingredient)**
**>>> match is None**
**False**
**>>> match.groups()**
**('Kumquat', '2', 'cups')**
这本身就很酷:我们有一个元组,其中包含字符串中的不同字段。我们将在名为使用元组的食谱中再次使用元组。
- 从匹配对象中提取命名组的字符:
**>>> match.group('ingredient')**
**'Kumquat'**
**>>> match.group('amount')**
**'2'**
**>>> match.group('unit')**
**'cups'**
每个组都由我们在 RE 的(?P<name>...)部分中使用的名称标识。
它是如何工作的...
我们可以用 RE 描述许多不同类型的字符串模式。
我们展示了许多字符类:
-
\w匹配任何字母数字字符(a 到 z,A 到 Z,0 到 9) -
\d匹配任何十进制数字 -
\s匹配任何空格或制表符
这些类也有反义:
-
\W匹配任何不是字母或数字的字符 -
\D匹配任何不是数字的字符 -
\S匹配任何不是某种空格或制表符的字符
许多字符与自身匹配。然而,一些字符具有特殊含义,我们必须使用\来逃离这种特殊含义:
-
我们看到
+作为后缀意味着匹配一个或多个前面的模式。\d+匹配一个或多个数字。要匹配一个普通的+,我们需要使用\+。 -
我们还有
*作为后缀,它匹配零个或多个前面的模式。\w*匹配零个或多个字符。要匹配一个*,我们需要使用\*。 -
我们有
?作为后缀,它匹配前面表达式的零次或一次。这个字符在其他地方也有用,意义略有不同。我们在(?P<name>...)中看到它,其中它在()内定义了分组的特殊属性。 -
.匹配任何单个字符。要匹配一个.,我们需要使用\。
我们可以使用[]来创建我们自己独特的字符集,以括起集合的元素。我们可能会有这样的东西:
(?P<name>\w+)\s*[=:]\s*(?P<value>.*)
这里有一个\w+来匹配任意数量的字母数字字符。这将被收集到一个名为name的组中。
它使用\s*来匹配可选的空格序列。
它匹配集合[=:]中的任何字符。这个集合中的两个字符之一必须存在。
它再次使用\s*来匹配一个可选的空格序列。
最后,它使用.*来匹配字符串中的其他所有内容。这被收集到一个名为value的组中。
我们可以用这个来解析这样的字符串:
size = 12
weight: 14
通过在标点符号上灵活使用,我们可以使程序更容易使用。我们将容忍任意数量的空格,并且=或:作为分隔符。
还有更多...
一个很长的正则表达式可能很难阅读。我们有一个巧妙的 Python 技巧,可以以更容易阅读的方式呈现表达式:
**>>> ingredient_pattern = re.compile(**
**... r'(?P<ingredient>\w+):\s+' # name of the ingredient up to the ":"**
**... r'(?P<amount>\d+)\s+' # amount, all digits up to a space**
**... r'(?P<unit>\w+)' # units, alphanumeric characters**
**... )**
这利用了三个语法规则:
-
直到
()字符匹配完成,语句才算结束 -
相邻的字符串文字会被静默连接成一个长字符串
-
#和行尾之间的任何内容都是注释,会被忽略
我们在正则表达式的重要子句后面放置了 Python 注释。这可以帮助我们理解我们做了什么,也许帮助我们以后诊断问题。
另请参阅
-
解码字节-如何从一些字节中获取正确的字符食谱
-
有许多关于正则表达式和特别是 Python 正则表达式的书籍,比如掌握 Python 正则表达式(
www.packtpub.com/application-development/mastering-python-regular-expressions)
使用"template".format()构建复杂的字符串
创建复杂的字符串,在许多方面,与解析复杂的字符串截然相反。通常,我们会使用带有替换规则的模板,将数据放入更复杂的格式中。
准备好了
假设我们有一些数据需要转换为格式良好的消息。我们可能有包括以下内容的数据:
**>>> id = "IAD"**
**>>> location = "Dulles Intl Airport"**
**>>> max_temp = 32**
**>>> min_temp = 13**
**>>> precipitation = 0.4**
我们想要一行看起来像这样的:
**IAD : Dulles Intl Airport : 32 / 13 / 0.40**
如何做到...
- 从结果中创建一个模板字符串,用
{}占位符替换所有数据项。在每个占位符内,放入数据项的名称。
**'{id} : {location} : {max_temp} / {min_temp} / {precipitation}'**
- 对于每个数据项,在模板字符串的占位符后附加
:数据类型信息。基本数据类型代码有:
-
s代表字符串 -
d代表十进制数 -
浮点数的
f
它会看起来像这样:
**'{id:s} : {location:s} : {max_temp:d} / {min_temp:d} / {precipitation:f}'**
- 在必要的地方添加长度信息。长度并不总是必需的,在某些情况下甚至是不可取的。但在这个例子中,长度信息确保每条消息具有一致的格式。对于字符串和十进制数,前缀格式为长度,如
19s或3d。对于浮点数,请使用两部分前缀,如5.2f,以指定总长度为五个字符,小数点右边为两个。这是整个格式:
**'{id:3d} : {location:19s} : {max_temp:3d} / {min_temp:3d} / {precipitation:5.2f}'**
- 使用此字符串的
format()方法创建最终字符串:
**>>> '{id:3s} : {location:19s} : {max_temp:3d} / {min_temp:3d} / {precipitation:5.2f}'.format(**
**... id=id, location=location, max_temp=max_temp,**
**... min_temp=min_temp, precipitation=precipitation**
**... )**
**'IAD : Dulles Intl Airport : 32 / 13 / 0.40'**
我们已经按名称在模板字符串的format()方法中提供了所有变量。这可能会变得乏味。在某些情况下,我们可能想要构建一个带有变量的字典对象。在这种情况下,我们可以使用format_map()方法:
**>>> data = dict(**
**... id=id, location=location, max_temp=max_temp,**
**... min_temp=min_temp, precipitation=precipitation**
**... )**
**>>> '{id:3s} : {location:19s} : {max_temp:3d} / {min_temp:3d} / {precipitation:5.2f}'.format_map(data)**
**'IAD : Dulles Intl Airport : 32 / 13 / 0.40'**
我们将在第四章中返回到字典,内置数据结构 - 列表,集合,字典。
内置的vars()函数为我们构建了所有本地变量的字典:
**>>> '{id:3s} : {location:19s} : {max_temp:3d} / {min_temp:3d} / {precipitation:5.2f}'.format_map(**
**... vars()**
**... )**
**'IAD : Dulles Intl Airport : 32 / 13 / 0.40'**
vars()函数非常方便,可以自动构建字典。
它是如何工作的...
字符串format()和format_map()方法可以为我们执行相对复杂的字符串组装。
基本功能是根据关键字参数的名称或字典中的键将数据插入到字符串中。变量也可以按位置插入 - 我们可以提供位置数字而不是名称。我们可以使用格式规范,如{0:3s}来使用format()的第一个位置参数。
我们已经看到了三种格式转换 - s,d,f - 还有许多其他的。详细信息请参阅Python 标准库的第 6.1.3 节。这里是我们可能使用的一些格式转换:
-
b代表二进制,基数为 2。 -
c代表 Unicode 字符。值必须是一个数字,它将被转换为一个字符。通常,我们使用十六进制数,所以你可能想尝试一些有趣的值,比如0x2661到0x2666。 -
d代表十进制数。 -
E和e代表科学计数法。6.626E-34或6.626e-34取决于使用了哪个 E 或 e 字符。 -
F和f代表浮点数。对于非数字,f格式显示小写nan;F格式显示大写NAN。 -
G和g代表通用。这会自动在E和F(或e和f)之间切换,以保持输出在给定的大小字段中。对于格式为20.5G,将使用F格式显示最多 20 位数字。较大的数字将使用E格式。 -
n代表特定于区域设置的十进制数。这将根据当前区域设置插入,或.字符。默认区域设置可能没有定义千位分隔符。有关更多信息,请参见locale模块。 -
o代表八进制,基数为 8。 -
s代表字符串。 -
X和x用于十六进制,基数 16。数字包括大写A-F和小写a-f,具体取决于使用哪个X或x格式字符。 -
%是用于百分比的。数字乘以 100 并包括%。
我们有许多前缀可以用于这些不同类型。最常见的是长度。我们可能会使用{name:5d}来输入一个 5 位数。前面类型有几个前缀:
-
填充和对齐:我们可以指定特定的填充字符(默认为空格)和对齐方式。数字通常右对齐,字符串左对齐。我们可以使用
<,>或^来改变这一点。这会强制左对齐、右对齐或居中。有一个奇特的=对齐方式,用于在前导符号后放置填充。 -
符号:默认规则是在需要时使用前导负号。我们可以使用
+在所有数字上加上符号,使用-只在负数上加上符号,使用空格代替正数的加号。在科学输出中,我们必须使用{value: 5.3f}。空格确保留有空间放置符号,确保所有小数点排列得很好。 -
备用形式:我们可以使用
#来获得备用形式。我们可能会有类似{0:#x},{0:#o},{0:#b}的东西,以获得十六进制、八进制或二进制值的前缀。有了前缀,数字看起来像0xnnn,0onnn或0bnnn。默认情况下省略了两个字符的前缀。 -
前导零:我们可以包括
0来获得前导零以填充数字的前部。类似{code:08x}将产生一个十六进制值,前面有前导零以将其填充到八个字符。 -
宽度和精度:对于整数值和字符串,我们只提供宽度。对于浮点值,我们经常提供
width.precision。
有时我们不会使用{name:format}规范。有时我们需要使用{name!conversion}规范。只有三种转换可用。
-
{name!r}显示了由repr(name)产生的表示 -
{name!s}显示了由str(name)产生的字符串值 -
{name!a}显示了由ascii(name)产生的 ASCII 值
在第六章,类和对象的基础中,我们将利用{name!r}格式规范的想法来简化显示有关相关对象的信息。
还有更多...
一个方便的调试技巧:
**print("some_variable={some_variable!r}".format_map(vars()))**
vars()函数——没有参数——将所有局部变量收集到一个映射中。我们为format_map()提供该映射。格式模板可以使用许多{variable_name!r}来显示有关我们在局部变量中拥有的各种对象的详细信息。
在类定义内部,我们可以使用诸如vars(self)的技术。这预示着第六章,类和对象的基础:
**>>> class Summary:**
**... def __init__(self, id, location, min_temp, max_temp, precipitation):**
**... self.id= id**
**... self.location= location**
**... self.min_temp= min_temp**
**... self.max_temp= max_temp**
**... self.precipitation= precipitation**
**... def __str__(self):**
**... return '{id:3s} : {location:19s} : {max_temp:3d} / {min_temp:3d} / {precipitation:5.2f}'.format_map(**
**... vars(self)**
**... )**
**>>> s= Summary('IAD', 'Dulles Intl Airport', 13, 32, 0.4)**
**>>> print(s)**
**IAD : Dulles Intl Airport : 32 / 13 / 0.40**
我们的类定义包括一个__str__()方法。这个方法依赖于vars(self)来创建一个有用的对象属性字典。
另请参阅
- Python 标准库,第 6.1.3 节中有关字符串格式方法的所有细节。
从字符列表构建复杂字符串
我们如何对不可变字符串进行非常复杂的更改?我们可以从单个字符组装一个字符串吗?
在大多数情况下,我们已经看到的配方为我们创建和修改字符串提供了许多工具。我们还有更多的方法来解决字符串操作问题。我们将使用列表对象。这将与第四章中的一些配方相契合,内置数据结构-列表、集合、字典。
准备工作
这是一个我们想重新排列的字符串:
**>>> title = "Recipe 5: Rewriting an Immutable String"**
我们想做两个转换:
-
删除
:之前的部分 -
用
_替换标点符号,并将所有字符转换为小写
我们将利用string模块:
**>>> from string import whitespace, punctuation**
这有两个重要的常数:
-
string.whitespace列出了所有常见的空白字符,包括空格和制表符 -
string.punctuation列出了常见的 ASCII 标点符号。Unicode 有一个更大的标点符号列表;也可以根据您的区域设置使用
如何做...
我们可以处理分解为列表的字符串。我们将在第四章中更深入地研究列表,内置数据结构-列表、集合、字典。
- 将字符串分解为
列表对象:
**>>> title_list = list(title)**
- 找到分区字符。列表的
index()方法与列表的index()方法具有相同的语义。它定位具有给定值的位置:
**>>> colon_position = title_list.index(':')**
- 删除不再需要的字符。
del语句可以从列表中删除项目。列表是可变数据结构:
**>>> del title_list[:colon_position+1]**
我们不需要仔细处理原始字符串的有用部分。我们可以从列表中删除项目。
- 通过遍历每个位置来替换标点符号。在这种情况下,我们将使用
for语句访问字符串中的每个索引:
**>>> for position in range(len(title_list)):**
**... if title_list[position] in whitespace+punctuation:**
**... title_list[position]= '_'**
- 表达式
range(len(title_list))生成0和len(title_list)-1之间的所有值。这确保了位置的值将是列表中每个值的索引。连接字符列表以创建新字符串。当将字符串连接在一起时,使用零长度字符串''作为分隔符似乎有点奇怪。但是,它完美地工作:
**>>> title = ''.join(title_list)**
**>>> title**
**'_Rewriting_an_Immutable_String'**
我们将结果字符串分配回原始变量。原始字符串对象,该对象已被该变量引用,不再需要:它已从内存中删除。新的字符串对象替换了变量的值。
它是如何工作的...
这是一种表示变化的技巧。由于字符串是不可变的,我们无法更新它。但是,我们可以将其转换为可变形式;在这种情况下,是列表。我们可以对可变列表对象进行任何所需的更改。完成后,我们可以将表示从列表更改回字符串。
字符串提供了一些列表没有的功能。相反,字符串提供了列表没有的一些功能。我们无法像转换字符串那样将列表转换为小写。
这里有一个重要的权衡:
-
字符串是不可变的,这使它们非常快。字符串专注于 Unicode 字符。当我们查看映射和集合时,我们可以使用字符串作为映射的键和集合中的项目,因为该值是不可变的。
-
列表是可变的。操作速度较慢。列表可以容纳任何类型的项目。我们不能使用列表作为映射的键或集合中的项目,因为值可能会改变。
字符串和列表都是特殊类型的序列。因此,它们具有许多共同的特征。基本的项目索引和切片功能是共享的。同样,列表使用与字符串相同类型的负索引值:list[-1]是列表对象中的最后一个项目。
我们将在第四章中再次使用可变数据结构,内置数据结构-列表、集合、字典。
有更多
一旦我们开始处理字符列表而不是字符串,我们就不再具有字符串处理方法。我们有许多可用的列表处理技术。除了能够从列表中删除项目外,我们还可以附加项目,用另一个列表扩展列表,并将字符插入列表中。
我们也可以稍微改变我们的观点,看看字符串列表而不是字符列表。当我们有一个字符串列表时,做''.join(list)的技巧也会起作用。例如,我们可能会这样做:
**>>> title_list.insert(0, 'prefix')**
**>>> ''.join(title_list)**
**'prefix_Rewriting_an_Immutable_String'**
我们的title_list对象将被改变为一个包含六个字符的字符串前缀,以及 30 个单独字符的列表。
另请参阅
-
我们还可以使用字符串的内部方法来处理字符串。有关更多技术,请参见重写不可变字符串配方。
-
有时,我们需要构建一个字符串,然后将其转换为字节。查看编码字符串 - 创建 ASCII 和 UTF-8 字节配方,了解我们可以如何做到这一点。
-
其他时候,我们需要将字节转换为字符串。参见解码字节 - 如何从一些字节中获取正确的字符配方。
使用不在键盘上的 Unicode 字符
一个大键盘可能有近 100 个单独的键。其中不到 50 个是字母、数字和标点符号。至少有十几个是功能键,除了简单地插入字母到文档之外还可以做其他事情。一些键是不同类型的修饰符,意味着要与另一个键一起使用——我们可能有Shift,Ctrl,Option 和Command。
大多数操作系统都接受简单的键组合,可以创建大约 100 个左右的字符。更复杂的键组合可能会创建另外大约 100 个不太受欢迎的字符。这甚至无法涵盖世界各种语言的百万字符。我们的计算机字体中还有图标、表情符号和特殊符号。我们如何才能获得所有这些字形?
准备工作
Python 使用 Unicode。有数百万个可用的 Unicode 字符。
我们可以在en.wikipedia.org/wiki/List_of_Unicode_characters 和 http://www.unicode.org/charts/上看到所有可用的字符。
我们需要 Unicode 字符编号。我们可能还需要 Unicode 字符名称。
我们计算机上的某个字体可能没有设计为提供所有这些字符的字形。特别是,Windows 计算机字体可能无法显示其中一些字符。有时需要使用 Windows 命令更改到代码页 65001:
**chcp 65001**
Linux 和 Mac OS X 很少出现 Unicode 字符的问题。
如何做...
Python 使用转义序列来扩展我们可以输入的普通字符,以涵盖 Unicode 字符的广阔空间。转义序列以\字符开头。下一个字符准确告诉 Unicode 字符将如何表示。找到所需的字符。获取名称或数字。数字总是以十六进制、16 进制给出。它们通常写为U+2680。名称可能是DIE FACE-1。使用\unnnn,最多使用四位数字。或使用\N{name}与拼写的名称。如果数字超过四位数,使用\Unnnnnnnn,数字填充到八位数:

是的,我们可以在 Python 输出中包含各种字符。要在字符串中放置\字符,我们需要使用\\。例如,我们可能需要这个用于 Windows 文件名。
工作原理...
Python 在内部使用 Unicode。我们可以直接使用键盘输入的 128 个左右字符都有方便的内部 Unicode 编号。
当我们写:
**'HELLO'**
Python 将其视为此的简写:
**'\u0048\u0045\u004c\u004c\u004f'**
一旦我们超出键盘上的字符,剩下的数百万个字符只能通过它们的编号来识别。
当 Python 编译字符串时,\uxx,\Uxxxxxxxx 和\N{name}都将被正确的 Unicode 字符替换。如果我们有一些语法错误,例如\N{name没有闭合},我们将立即从 Python 的内部语法检查中获得错误。
回到使用正则表达式解析字符串配方,我们注意到正则表达式使用了很多\字符,我们特别不希望 Python 的正常编译器去处理它们;我们在正则表达式字符串上使用r'前缀,以防止\被视为转义并可能转换为其他内容。
如果我们需要在正则表达式中使用 Unicode?我们需要在正则表达式中到处使用\\。我们可能会看到这个'\\w+[\u2680\u2681\u2682\u2683\u2684\u2685]\\d+'。我们省略了字符串的r'前缀。我们将用于正则表达式的\加倍。我们使用\uxxxx表示模式中的 Unicode 字符。Python 的内部编译器将用 Unicode 字符替换\uxxxx,并在内部用单个\替换\\。
注意
当我们在>>>提示符下查看一个字符串时,Python 会以规范形式显示字符串。Python 更喜欢使用'作为分隔符,尽管我们可以使用'或"作为字符串分隔符。Python 通常不显示原始字符串,而是将所有必要的转义序列放回字符串中:
>>> r"\w+"
'\\w+'
我们提供了一个原始形式的字符串。Python 以规范形式显示它。
另请参阅
-
在编码字符串 - 创建 ASCII 和 UTF-8 字节和解码字节 - 如何从一些字节中获取正确的字符中,我们将看看如何将 Unicode 字符转换为字节序列,以便将它们写入文件。我们将看看如何将文件中的字节(或从网站下载的字节)转换为 Unicode 字符,以便进行处理。
-
如果你对历史感兴趣,你可以在这里阅读 ASCII 和 EBCDIC 以及其他老式字符编码的相关内容
www.unicode.org/charts/。
编码字符串 - 创建 ASCII 和 UTF-8 字节
我们的计算机文件是字节。当我们上传或下载文件时,通信是以字节为单位的。一个字节只有 256 个不同的值。我们的 Python 字符是 Unicode。Unicode 字符远远超过 256 个。
如何将 Unicode 字符映射到字节以便写入文件或传输?
准备工作
从历史上看,一个字符占用 1 个字节。Python 利用旧的 ASCII 编码方案进行字节处理;这有时会导致字节和正确的 Unicode 字符之间的混淆。
Unicode 字符被编码为字节序列。我们有许多标准编码和许多非标准编码。
此外,我们还有一些只适用于小部分 Unicode 字符的编码。我们尽量避免这种情况,但有些情况下我们需要使用子集编码方案。
除非我们有一个非常好的理由,我们几乎总是使用 UTF-8 编码来处理 Unicode 字符。它的主要优势是它是拉丁字母表的紧凑表示,用于英语和一些欧洲语言。
有时,互联网协议需要 ASCII 字符。这是一个特殊情况,需要一些小心,因为 ASCII 编码只能处理 Unicode 字符的一个小子集。
如何做...
Python 通常会使用我们操作系统的默认编码进行文件和互联网通信。细节因操作系统而异:
- 我们可以使用
PYTHONIOENCODING环境变量进行一般设置。我们在 Python 之外设置这个变量,以确保在任何地方都使用特定的编码。设置环境变量如下:
**export PYTHONIOENCODING=UTF-8**
- 运行 Python:
**python3.5**
- 有时候我们需要在脚本中打开文件时进行特定的设置。我们将在第九章中返回这个问题,输入/输出、物理格式、逻辑布局。使用给定的编码打开文件。读取或写入 Unicode 字符到文件中:
**>>> with open('some_file.txt', 'w', encoding='utf-8') as output:**
**... print( 'You drew \U0001F000', file=output )**
**>>> with open('some_file.txt', 'r', encoding='utf-8') as input:**
**... text = input.read()**
**>>> text**
**'You drew �'**
在罕见的情况下,我们也可以手动编码字符,如果我们需要以字节模式打开文件;如果我们使用wb模式,我们需要手动编码:
**>>> string_bytes = 'You drew \U0001F000'.encode('utf-8')**
**>>> string_bytes**
**b'You drew \xf0\x9f\x80\x80'**
我们可以看到一系列字节(\xf0\x9f\x80\x80)被用来编码一个 Unicode 字符U+1F000,
。
工作原理...
Unicode 定义了许多编码方案。虽然 UTF-8 是最流行的,但还有 UTF-16 和 UTF-32。数字是每个字符的典型位数。一个包含 1000 个字符的 UTF-32 编码文件将是 4000 个 8 位字节。一个包含 1000 个字符的 UTF-8 编码文件可能只有 1000 个字节,具体取决于字符的确切混合。在 UTF-8 编码中,Unicode 编号大于U+007F的字符需要多个字节。
各种操作系统都有自己的编码方案。Mac OS X 文件通常以Mac Roman或Latin-1编码。Windows 文件可能使用CP1252编码。
所有这些方案的要点是有一个字节序列,可以映射到一个 Unicode 字符。而且-反过来-一种将每个 Unicode 字符映射到一个或多个字节的方法。理想情况下,所有的 Unicode 字符都被考虑在内。实际上,一些编码方案是不完整的。棘手的部分是避免写入比必要的更多的字节。
历史上的ASCII编码只能表示大约 250 个 Unicode 字符作为字节。很容易创建一个不能使用 ASCII 方案编码的字符串。
这就是错误的样子:
**>>> 'You drew \U0001F000'.encode('ascii')**
**Traceback (most recent call last):**
**File "<stdin>", line 1, in <module>**
**UnicodeEncodeError: 'ascii' codec can't encode character '\U0001f000' in position 9: ordinal not in range(128)**
当我们意外地用一个选择不当的编码打开文件时,我们可能会看到这种错误。当我们看到这个错误时,我们需要改变我们的处理方式,选择一个更有用的编码;理想情况下是 UTF-8。
注意
字节 vs 字符串
字节通常使用可打印字符显示。
我们会看到b'hello'作为一个五字节值的简写。这些字母是使用旧的 ASCII 编码方案选择的。大约从0x20到0xFE的许多字节值将显示为字符。
这可能会让人困惑。b'的前缀是我们正在看字节,而不是合适的 Unicode 字符。
另见
-
有许多构建数据字符串的方法。查看使用"template".format()构建复杂字符串和从字符列表构建复杂字符串配方,了解创建复杂字符串的示例。这个想法是我们可能有一个构建复杂字符串的应用程序,然后将其编码为字节。
-
有关 UTF-8 编码的更多信息,请参阅
en.wikipedia.org/wiki/UTF-8。 -
有关 Unicode 编码的一般信息,请参阅
unicode.org/faq/utf_bom.html。
解码字节-如何从一些字节中获得正确的字符
我们如何处理没有正确编码的文件?我们如何处理用 ASCII 编码写的文件?
从互联网下载的几乎总是字节,而不是字符。我们如何从字节流中解码字符?
此外,当我们使用subprocess模块时,操作系统命令的结果是字节。我们如何恢复正确的字符?
这个大部分也与第九章中的材料相关,输入/输出、物理格式、逻辑布局。我们在这里包含了这个配方,因为它是前一个配方的反向,编码字符串-创建 ASCII 和 UTF-8 字节。
准备好
假设我们对近海海洋天气预报感兴趣。也许是因为我们拥有一艘大帆船。或者是因为我们的好朋友拥有一艘大帆船,正在离开切萨皮克湾前往加勒比海。
来自国家气象局维吉尼亚州韦克菲尔德办公室的有没有特别的警告?
这里是我们可以得到警告的地方:www.nws.noaa.gov/view/national.php?prod=SMW&sid=AKQ。
我们可以使用 Python 的urllib模块下载这个。
**>>> import urllib.request**
**>>> warnings_uri= 'http://www.nws.noaa.gov/view/national.php?prod=SMW&sid=AKQ'**
**>>> with urllib.request.urlopen(warnings_uri) as source:**
**... warnings_text= source.read()**
或者,我们可以使用curl或wget等程序来获取这个。我们可以这样做:
**curl -O http://www.nws.noaa.gov/view/national.php?prod=SMW&sid=AKQ**
**mv national.php\?prod\=SMW AKQ.html**
由于curl给我们留下了一个尴尬的文件名,我们需要重命名文件。
forecast_text 值是一系列字节。它不是一个合适的字符串。我们可以知道这一点,因为它是这样开始的:
**>>> warnings_text[:80]**
**b'<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.or'**
并且提供了一段时间的细节。因为它以b'开头,所以它是字节,而不是合适的 Unicode 字符。它可能是用 UTF-8 编码的,这意味着一些字符可能具有奇怪的\xnn转义序列,而不是合适的字符。我们想要有合适的字符。
提示
字节与字符串
字节通常使用可打印字符显示。
我们将b'hello'看作是一个五字节值的简写。字母是使用旧的 ASCII 编码方案选择的。大约从0x20到0xFE的许多字节值将显示为字符。
这可能会让人感到困惑。b'的前缀是我们正在查看字节而不是合适的 Unicode 字符的提示。
通常,字节的行为有点像字符串。有时我们可以直接使用字节。大多数情况下,我们会想要解码字节并创建合适的 Unicode 字符。
如何做...
- 如果可能的话,确定编码方案。为了解码字节以创建合适的 Unicode 字符,我们需要知道使用了什么编码方案。当我们读取 XML 文档时,文档中提供了一个重要提示:
**<?xml version="1.0" encoding="UTF-8"?>**
在浏览网页时,通常会有包含此信息的页眉:
**Content-Type: text/html; charset=ISO-8859-4**
有时,HTML 页面可能包括这部分内容作为页眉的一部分:
**<meta http-equiv="Content-Type" content="text/html; charset=utf-8">**
在其他情况下,我们只能猜测。在美国天气数据的情况下,UTF-8 是一个很好的第一猜测。其他好的猜测包括 ISO-8859-1。在某些情况下,猜测将取决于语言。
- 第 7.2.3 节,Python 标准库列出了可用的标准编码。解码数据:
**>>> document = forecast_text.decode("UTF-8")**
**>>> document[:80]**
**'<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.or'**
b'前缀消失了。我们从字节流中创建了一个合适的 Unicode 字符字符串。
- 如果此步骤出现异常,则我们对编码的猜测是错误的。我们需要尝试另一种编码。解析生成的文档。
由于这是一个 HTML 文档,我们应该使用Beautiful Soup。请参见www.crummy.com/software/BeautifulSoup/。
然而,我们可以从这个文档中提取一条信息而不完全解析 HTML:
**>>> import re**
**>>> title_pattern = re.compile(r"\<h3\>(.*?)\</h3\>")**
**>>> title_pattern.search( document )**
**<_sre.SRE_Match object; span=(3438, 3489), match='<h3>There are no products active at this time.</h>**
这告诉我们我们需要知道的信息:目前没有警告。这并不意味着一帆风顺,但这意味着没有任何可能引起灾难的重大天气系统。
工作原理...
有关 Unicode 以及将 Unicode 字符编码为字节流的不同方式的更多信息,请参见编码字符串-创建 ASCII 和 UTF-8 字节示例。
在操作系统的基础上,文件和网络连接是由字节构建起来的。是我们的软件解码字节来发现内容。它可能是字符、图像或声音。在某些情况下,默认的假设是错误的,我们需要自己解码。
另请参见
-
一旦我们恢复了字符串数据,我们有许多解析或重写它的方法。参见使用正则表达式解析字符串示例。
-
有关编码的更多信息,请参见
en.wikipedia.org/wiki/UTF-8和unicode.org/faq/utf_bom.html。
使用项目的元组
如何最好地表示简单的(x, y)和(r, g, b)值组?我们如何将诸如纬度和经度之类的成对物品保持在一起?
准备工作
在使用正则表达式解析字符串示例中,我们跳过了一个有趣的数据结构。
我们有这样的数据:
**>>> ingredient = "Kumquat: 2 cups"**
我们使用类似于这样的正则表达式将其解析为有意义的数据:
**>>> import re**
**>>> ingredient_pattern = re.compile(r'(?P<ingredient>\w+):\s+(?P<amount>\d+)\s+(?P<unit>\w+)')**
**>>> match = ingredient_pattern.match( ingredient )**
**>>> match.groups()**
**('Kumquat', '2', 'cups')**
结果是一个包含三个数据片段的元组对象。有很多地方可以使用这种分组数据。
如何做...
我们将从两个方面来看这个问题:将事物放入元组中和从元组中取出事物。
创建元组
有很多地方,Python 会为我们创建数据的元组。在使用正则表达式解析字符串配方的准备就绪部分中,我们展示了正则表达式匹配对象将创建一个从字符串中解析出的文本元组。
我们也可以创建自己的元组。以下是步骤:
-
将数据括在
()中。 -
用
,分隔项目。
**>>> from fractions import Fraction**
**>>> my_data = ('Rice', Fraction(1/4), 'cups')**
对于单元素元组或单例,有一个重要的特殊情况。即使元组中只有一个项目,我们也必须包含一个额外的,。
**>>> one_tuple = ('item', )**
**>>> len(one_tuple)**
**1**
提示
()字符并不总是必需的。有几种情况下我们可以省略它们。省略它们并不是一个好主意,但当我们有一个额外的逗号时,我们可以看到有趣的事情:
>>> 355,
(355,)
在355后面的额外逗号将该值变成了一个单元素元组。
从元组中提取项目
元组的概念是作为一个包含一定数量项目的容器,这个数量由问题域确定:例如,(红色,绿色,蓝色)颜色编号。项目的数量始终是三个。
在我们的例子中,我们有一个成分、一个数量和一个单位。这必须是一个三个项目的集合。我们可以以两种方式查看单个项目:
- 按索引位置:位置从左边开始编号为零:
**>>> my_data[1]**
**Fraction(1, 4)**
- 使用多重赋值:
**>>> ingredient, amount, unit = my_data**
**>>> ingredient**
**'Rice'**
**>>> unit**
**'cups'**
元组——就像字符串一样——是不可变的。我们不能改变元组中的单个项目。当我们想要将数据保持在一起时,我们使用元组。
它是如何工作的...
元组是“序列”的更一般类别的一个例子。我们可以对序列做一些事情。
以下是一个我们可以使用的示例元组:
**>>> t = ('Kumquat', '2', 'cups')**
以下是我们可以在这个元组上执行的一些操作:
t中有多少个项目?
**>>> len(t)**
**3**
- 特定值在
t中出现了多少次?
**>>> t.count('2')**
**1**
- 哪个位置有特定的值?
**>>> t.index('cups')**
**2**
**>>> t[2]**
**'cups'**
- 当一个项目不存在时,我们会得到一个异常:
**>>> t.index('Rice')**
**Traceback (most recent call last):**
**File "<stdin>", line 1, in <module>**
**ValueError: tuple.index(x): x not in tuple**
- 特定值是否存在?
**>>> 'Rice' in t**
**False**
还有更多
元组,就像字符串一样,是一系列项目。对于字符串,它是一系列字符。对于元组,它是一系列许多东西。因为它们都是序列,它们有一些共同的特点。我们注意到我们可以通过它们的索引位置取出单个项目。我们可以使用index()方法来定位项目的位置。
相似之处就到此为止。字符串有许多方法来创建一个新的字符串,这是对字符串的转换,还有解析字符串的方法,以及确定字符串内容的方法。元组没有这些额外的功能。它可能是最简单的数据结构。
另请参阅...
-
我们还在从字符列表构建复杂字符串配方中查看了另一个序列,即列表。
-
我们还将在第四章中查看序列,内置数据结构-列表、元组、集合、字典
第二章:语句和语法
在本章中,我们将查看以下配方:
-
编写 Python 脚本和模块文件
-
编写长行代码
-
包括描述和文档
-
在文档字符串中更好的 RST 标记
-
设计复杂的 if...elif 链
-
设计一个终止的 while 语句
-
避免 break 语句可能出现的问题
-
利用异常匹配规则
-
避免 except:子句可能出现的问题
-
使用 raise from 语句链接异常
-
使用 with 语句管理上下文
介绍
Python 语法设计得非常简单。有一些规则;我们将查看语言中一些有趣的语句,以了解这些规则。仅仅看规则而没有具体的例子可能会令人困惑。
我们将首先介绍创建脚本文件的基础知识。然后我们将继续查看一些常用语句。Python 语言中只有大约二十种不同类型的命令语句。我们已经在第一章中看过两种语句,Numbers, Strings, and Tuples:赋值语句和表达式语句。
当我们写这样的东西时:
**>>> print("hello world")**
**hello world**
我们实际上执行的是一个只包含函数print()评估的语句。这种语句-在其中我们评估一个函数或对象的方法-是常见的。
我们已经看到的另一种语句是赋值语句。Python 在这个主题上有很多变化。大多数时候,我们将一个值赋给一个变量。然而,有时我们可能会同时给两个变量赋值,就像这样:
**quotient, remainder = divmod(355, 113)**
这些配方将查看一些更复杂的语句,包括if,while,for,try,with和raise。在探索不同的配方时,我们还将涉及其他一些。

编写 Python 脚本和模块文件-语法基础
为了做任何真正有用的事情,我们需要编写 Python 脚本文件。我们可以在交互>>>提示符下尝试语言。然而,对于真正的工作,我们需要创建文件。编写软件的整个目的是为我们的数据创建可重复的处理。
我们如何避免语法错误,并确保我们的代码与常用的代码匹配?我们需要查看一些style的常见方面-我们如何使用空白来澄清我们的编程。
我们还将研究一些更多的技术考虑因素。例如,我们需要确保以 UTF-8 编码保存我们的文件。虽然 Python 仍然支持 ASCII 编码,但对于现代编程来说,这是一个不好的选择。我们还需要确保使用空格而不是制表符。如果我们尽可能使用 Unix 换行符,我们也会发现事情稍微简单一些。
大多数文本编辑工具都可以正确处理 Unix(换行符)和 Windows 或 DOS(回车换行符)的行尾。任何不能处理这两种行尾的工具都应该避免使用。
准备好了
要编辑 Python 脚本,我们需要一个好的编程文本编辑器。Python 自带一个方便的编辑器,IDLE。它工作得相当不错。它让我们可以在文件和交互>>>提示之间来回跳转,但它不是一个很好的编程编辑器。
有数十种优秀的编程编辑器。几乎不可能只建议一个。所以我们将建议几个。
ActiveState 有非常复杂的 Komodo IDE。Komodo Edit 版本是免费的,并且与完整的 Komodo IDE 做了一些相同的事情。它可以在所有常见的操作系统上运行;这是一个很好的第一选择,因为无论我们在哪里编写代码,它都是一致的。
请参阅komodoide.com/komodo-edit/。
Notepad++适用于 Windows 开发人员。请参阅notepad-plus-plus.org。
BBEdit 非常适合 Mac OS X 开发人员。请参阅www.barebones.com/products/bbedit/。
对于 Linux 开发人员,有几个内置的编辑器,包括 VIM、gedit 或 Kate。这些都很好。由于 Linux 倾向于偏向开发人员,可用的编辑器都适合编写 Python。
重要的是,我们在工作时通常会打开两个窗口:
-
我们正在处理的脚本或文件。
-
Python 的
>>>提示(可能来自 shell,也可能来自 IDLE),我们可以尝试一些东西,看看什么有效,什么无效。我们可能会在 Notepad++中创建脚本,但使用 IDLE 来尝试数据结构和算法。
实际上我们这里有两个配方。首先,我们需要为我们的编辑器设置一些默认值。然后,一旦编辑器正确设置,我们就可以为我们的脚本文件创建一个通用模板。
如何做...
首先,我们将看一下我们首选编辑器中需要做的一般设置。我们将使用 Komodo 示例,但基本原则适用于所有编辑器。一旦我们设置了编辑首选项,我们就可以创建我们的脚本文件。
-
打开首选编辑器。查看首选项页面。
-
查找首选文件编码的设置。使用 Komodo Edit 首选项,它在国际化选项卡上。将其设置为UTF-8。
-
查找缩进设置。如果有一种方法可以使用空格而不是制表符,请检查此选项。使用 Komodo Edit,我们实际上是反过来做的——我们取消优先使用空格而不是制表符。
注意
规则是:我们想要空格;我们不想要制表符。
还要将每个缩进的空格设置为四个。这对于 Python 代码来说很典型。它允许我们有几个缩进级别,但仍然保持代码相当窄。
一旦我们确定我们的文件将以 UTF-8 编码保存,并且我们也确定我们使用空格而不是制表符,我们可以创建一个示例脚本文件:
- 大多数 Python 脚本文件的第一行应该是这样的:
#!/usr/bin/env python3
这将在你正在编写的文件和 Python 之间建立关联。
对于 Windows,文件名到程序的关联是通过 Windows 控制面板中的一个设置来完成的。在默认程序控制面板中,有一个设置关联面板。此控制面板显示.py文件绑定到 Python 程序。这通常由安装程序设置,我们很少需要更改它或手动设置它。
注意
Windows 开发人员可以无论如何包含序言行。这将使 Mac OS X 和 Linux 的人们从 GitHub 下载项目时感到高兴。
- 在序言之后,应该有一个三引号的文本块。这是我们要创建的文件的文档字符串(称为docstring)。这在技术上不是强制性的,但对于解释文件包含的内容至关重要。
'''
A summary of this script.
'''
因为 Python 的三引号字符串可以无限长,所以可以随意写入必要的内容。这应该是描述脚本或库模块的主要方式。这甚至可以包括它是如何工作的示例。
- 现在来到脚本的有趣部分:真正执行操作的部分。我们可以编写所有需要完成工作的语句。现在,我们将使用这个作为占位符:
print('hello world')
有了这个,我们的脚本就有了作用。在其他示例中,我们将看到许多其他用于执行操作的语句。通常会创建函数和类定义,并编写语句来使用函数和类执行操作。
在我们的脚本的顶层,所有语句必须从左边缘开始,并且必须在一行上完成。有一些复杂的语句,其中将嵌套在其中的语句块。这些内部语句块必须缩进。通常情况下,因为我们将缩进设置为四个空格,我们可以按Tab键进行缩进。
我们的文件应该是这样的:
#!/usr/bin/env python3
'''
My First Script: Calculate an important value.
'''
print(355/113)
它是如何工作的...
与其他语言不同,Python 中几乎没有样板。只有一行开销,甚至#!/usr/bin/env python3行通常是可选的。
为什么要将编码设置为 UTF-8?整个语言都是设计为仅使用最初的 128 个 ASCII 字符。
我们经常发现 ASCII 有限制。将编辑器设置为使用 UTF-8 编码更容易。有了这个设置,我们可以简单地使用任何有意义的字符。如果我们将程序保存在 UTF-8 编码中,我们可以将字符如µ用作 Python 变量。
如果我们将文件保存为 UTF-8,这是合法的 Python:
π=355/113
print(π)
注意
在 Python 中在选择空格和制表符之间保持一致是很重要的。它们都是几乎看不见的,混合它们很容易导致混乱。建议使用空格。
当我们设置编辑器使用四个空格缩进后,我们可以使用键盘上标有 Tab 的按钮插入四个空格。我们的代码将对齐,缩进将显示语句如何嵌套在彼此内。
初始的#!行是一个注释:从#到行尾的所有内容都会被忽略。像bash和ksh这样的操作系统 shell 程序会查看文件的第一行,以确定文件包含的内容。文件的前几个字节有时被称为魔术,因为 shell 程序正在窥视它们。Shell 程序会寻找#!这个两个字符的序列,以确定负责这些数据的程序。我们更喜欢使用/usr/bin/env来启动 Python 程序。我们可以利用这一点来通过env程序进行 Python 特定的环境设置。
还有更多...
Python 标准库文档部分源自模块文件中存在的文档字符串。在模块中编写复杂的文档字符串是常见做法。有一些工具,如 Pydoc 和 Sphinx,可以将模块文档字符串重新格式化为优雅的文档。我们将在单独的部分中学习这一点。
此外,单元测试用例可以包含在文档字符串中。像doctest这样的工具可以从文档字符串中提取示例并执行代码,以查看文档中的答案是否与运行代码找到的答案匹配。本书的大部分内容都是通过 doctest 验证的。
三引号文档字符串优于#注释。#和行尾之间的文本会被忽略,并被视为注释。由于这仅限于单行,因此使用得很少。文档字符串的大小可以是无限的;它们被广泛使用。
在 Python 3.5 中,我们有时会在脚本文件中看到这样的东西:
color = 355/113 # type: float
# type: float注释可以被类型推断系统用来确定程序实际执行时可能出现的各种数据类型。有关更多信息,请参阅Python Enhancement Proposal 484:www.python.org/dev/peps/pep-0484/。
有时文件中还包含另一个开销。VIM 编辑器允许我们在文件中保留编辑首选项。这被称为modeline。我们经常需要通过在我们的~/.vimrc文件中包含set modeline设置来启用 modelines。
一旦我们启用了 modelines,我们可以在文件末尾包含一个特殊的# vim注释来配置 VIM。
这是一个对 Python 有用的典型 modeline:
# vim: tabstop=8 expandtab shiftwidth=4 softtabstop=4
这将把 Unicode u+0009 TAB 字符转换为八个空格,当我们按下Tab键时,我们将移动四个空格。这个设置被保存在文件中;我们不需要进行任何 VIM 设置来将这些设置应用到我们的 Python 脚本文件中。
另请参阅
-
我们将在包括描述和文档和在文档字符串中编写更好的 RST 标记这两个部分中学习如何编写有用的文档字符串
-
有关建议的样式的更多信息,请参阅
www.python.org/dev/peps/pep-0008/
编写长行代码
有很多时候,我们需要编写非常长的代码行,以至于它们非常难以阅读。许多人喜欢将代码行的长度限制在 80 个字符或更少。这是一个众所周知的图形设计原则,即较窄的行更容易阅读;意见不一,但 65 个字符经常被认为是理想的长度。参见webtypography.net/2.1.2。
虽然较短的行更容易阅读,但我们的代码可能不遵循这个原则。长语句是一个常见的问题。我们如何将长的 Python 语句分解为更易处理的部分?
准备就绪
通常,我们会有一个语句,它非常长且难以处理。比如说我们有这样的东西:
**>>> import math**
**>>> example_value = (63/25) * (17+15*math.sqrt(5)) / (7+15*math.sqrt(5))**
**>>> mantissa_fraction, exponent = math.frexp(example_value)**
**>>> mantissa_whole = int(mantissa_fraction*2**53)**
**>>> message_text = 'the internal representation is {mantissa:d}/2**53*2**{exponent:d}'.format(mantissa=mantissa_whole, exponent=exponent)**
**>>> print(message_text)**
**the internal representation is 7074237752514592/2**53*2**2**
这段代码包括一个长公式和一个长格式字符串,我们要将值注入其中。这在书中排版时看起来很糟糕。在尝试编辑此脚本时,屏幕上看起来很糟糕。
我们不能简单地将 Python 语句分成块。语法规则明确指出语句必须在单个逻辑行上完成。
术语逻辑行是如何进行的一个提示。Python 区分逻辑行和物理行;我们将利用这些语法规则来分解长语句。
如何做...
Python 给了我们几种包装长语句使其更易读的方法。
-
我们可以在行尾使用
\继续到下一行。 -
我们可以利用 Python 的规则,即语句可以跨越多个逻辑行,因为
()、[]和{}字符必须平衡。除了使用()和\,我们还可以利用 Python 自动连接相邻字符串文字的方式,使其成为一个更长的文字;("a" "b")与ab相同。 -
在某些情况下,我们可以通过将中间结果分配给单独的变量来分解语句。
我们将在本教程的不同部分分别讨论每一个。
使用反斜杠将长语句分解为逻辑行
这个技巧的背景是:
**>>> import math**
**>>> example_value = (63/25) * (17+15*math.sqrt(5)) / (7+15*math.sqrt(5))**
**>>> mantissa_fraction, exponent = math.frexp(example_value)**
**>>> mantissa_whole = int(mantissa_fraction*2**53)**
Python 允许我们使用\并换行。
- 将整个语句写在一行上,即使它很混乱:
**>>> message_text = 'the internal representation is {mantissa:d}/2**53*2**{exponent:d}'.format(mantissa=mantissa_whole, exponent=exponent)**
- 如果有逻辑断点,在那里插入
\。有时,没有真正好的断点:
**>>> message_text = 'the internal representation is \**
**... {mantissa:d}/2**53*2**{exponent:d}'.\**
**... format(mantissa=mantissa_whole, exponent=exponent)**
**>>> message_text**
**'the internal representation is 7074237752514592/2**53*2**2'**
为了使其工作,\必须是行上的最后一个字符。我们甚至不能在\后有一个空格。这很难看出来;因此,我们不鼓励这样做。
尽管这有点难以理解,但\总是可以使用的。把它看作是使代码行更易读的最后手段。
使用()字符将长语句分解为合理的部分
- 将整个语句写在一行上,即使它很混乱:
**>>> import math**
**>>> example_value1 = (63/25) * (17+15*math.sqrt(5)) / (7+15*math.sqrt(5))**
- 添加额外的
()字符不改变值,但允许将表达式分解为多行:
**>>> example_value2 = (63/25) * ( (17+15*math.sqrt(5)) / (7+15*math.sqrt(5)) )**
**>>> example_value2 == example_value1**
**True**
- 在
()字符内部断开行:
**>>> example_value3 = (63/25) * (**
**... (17+15*math.sqrt(5))**
**... / ( 7+15*math.sqrt(5))**
**... )**
**>>> example_value3 == example_value1**
**True**
匹配()字符的技术非常强大,适用于各种情况。这是被广泛使用和强烈推荐的。
我们几乎总是可以找到一种方法向语句添加额外的()字符。在我们无法添加()字符或添加()字符无法改善情况的罕见情况下,我们可以退而使用\将语句分解为几个部分。
使用字符串文字连接
我们可以将()字符与另一条规则相结合,该规则结合字符串文字。这对于长而复杂的格式字符串特别有效:
-
用
()字符包装一个长字符串值。 -
将字符串分解为子字符串:
**>>> message_text = (**
**... 'the internal representation '**
**... 'is {mantissa:d}/2**53*2**{exponent:d}'**
**... ).format(**
**... mantissa=mantissa_whole, exponent=exponent)**
**>>> message_text**
**'the internal representation is 7074237752514592/2**53*2**2'**
我们总是可以将长字符串分解为相邻的片段。通常,当片段被()字符包围时,这是最有效的。然后我们可以使用尽可能多的物理行断开。这仅限于那些我们有特别长的字符串值的情况。
将中间结果分配给单独的变量
这个技巧的背景是:
**>>> import math**
**>>> example_value = (63/25) * (17+15*math.sqrt(5)) / (7+15*math.sqrt(5))**
我们可以将这分解为三个中间值。
- 识别整体表达式中的子表达式。将这些分配给变量:
**>>> a = (63/25)**
**>>> b = (17+15*math.sqrt(5))**
**>>> c = (7+15*math.sqrt(5))**
这通常相当简单。可能需要一点小心来进行代数运算,以找到合理的子表达式。
- 用创建的变量替换子表达式:
**>>> example_value = a * b / c**
这是对原始复杂子表达式的一个必要的文本替换,用一个变量来代替。
我们没有给这些变量起描述性的名称。在某些情况下,子表达式具有一些语义,我们可以用有意义的名称来捕捉。在这种情况下,我们没有理解表达式足够深刻,无法提供深层有意义的名称。相反,我们选择了简短的、任意的标识符。
它是如何工作的...
Python 语言手册对逻辑行和物理行进行了区分。逻辑行包含一个完整的语句。它可以通过称为行连接的技术跨越多个物理行。手册称这些技术为显式行连接和隐式行连接。
显式行连接的使用有时是有帮助的。因为很容易忽视,所以通常不受鼓励。这是最后的手段。
隐式行连接的使用可以在许多情况下使用。它通常在语义上与表达式的结构相吻合,因此是受鼓励的。我们可能需要()字符作为必需的语法。例如,我们已经将()字符作为print()函数的语法的一部分。我们可能这样做来分解一个长语句:
**>>> print(**
**... 'several values including',**
**... 'mantissa =', mantissa,**
**... 'exponent =', exponent**
**... )**
还有更多...
表达式广泛用于许多 Python 语句。任何表达式都可以添加()字符。这给了我们很大的灵活性。
然而,有一些地方可能有一个不涉及特定表达式的长语句。其中最显著的例子是import语句 - 它可能变得很长,但不使用可以加括号的任何表达式。
然而,语言设计者允许我们使用()字符,以便将一长串名称分解为多个逻辑行:
**>>> from math import (sin, cos, tan,**
**... sqrt, log, frexp)**
在这种情况下,()字符绝对不是表达式的一部分。()字符只是额外的语法,包括使语句与其他语句一致。
另请参阅
- 隐式行连接也适用于匹配的
[]字符和{}字符。这些适用于我们将在第四章中查看的集合数据结构,内置数据结构 - 列表、集合、字典。
包括描述和文档
当我们有一个有用的脚本时,我们经常需要为自己和其他人留下关于它的说明,它是如何解决某个特定问题的,以及何时应该使用它的笔记。
因为清晰很重要,有一些格式化的方法可以帮助使文档非常清晰。这个方法还包含了一个建议的大纲,以便文档会相当完整。
准备工作
如果我们使用编写 Python 脚本和模块文件 - 语法基础的方法来构建一个脚本文件,我们将在我们的脚本文件中放置一个小的文档字符串。我们将扩展这个文档字符串。
还有其他应该使用文档字符串的地方。我们将在第三章和第六章中查看这些额外的位置,函数定义和类和对象的基础知识。
我们有两种一般类型的模块,我们将编写摘要文档字符串:
-
库模块:这些文件将主要包含函数定义以及类定义。在这种情况下,文档字符串摘要可以侧重于模块是什么,而不是它做什么。文档字符串可以提供使用模块中定义的函数和类的示例。在第三章,函数定义,和第六章,类和对象的基础,我们将更仔细地研究这个函数包或类包的概念。
-
脚本:这些通常是我们期望能够完成一些实际工作的文件。在这种情况下,我们希望关注的是做而不是存在。文档字符串应该描述它的功能以及如何使用它。选项、环境变量和配置文件是这个文档字符串的重要部分。
我们有时会创建包含两者的文件。这需要一些仔细的编辑来在做和存在之间取得适当的平衡。在大多数情况下,我们将简单地提供两种文档。
如何做...
编写文档的第一步对于库模块和脚本是相同的:
- 写一个简要概述脚本或模块是什么或做什么。摘要不要深入介绍它的工作原理。就像报纸文章中的导语一样,它介绍了模块的谁、什么、何时、何地、如何和为什么。详细信息将在文档字符串的正文中提供。
工具如 sphinx 和 pydoc 显示信息的方式暗示了特定的样式提示。在这些工具的输出中,上下文是非常清晰的,因此在摘要句中通常可以省略主语。句子通常以动词开头。
例如,像这样的摘要:这个脚本下载并解码了当前的特殊海洋警告(SMW)有一个多余的这个脚本。我们可以去掉它,然后以动词短语下载并解码...开始。
我们可能会这样开始我们的模块文档字符串:
'''
Downloads and decodes the current Special Marine Warning (SMW)
for the area 'AKQ'.
'''
我们将根据模块的一般重点分开其他步骤。
为脚本编写文档字符串
当我们记录脚本时,我们需要关注将使用脚本的人的需求。
-
像之前展示的那样开始,创建一个摘要句。
-
勾勒出文档字符串的其余部分的大纲。我们将使用ReStructuredText(RST)标记。在一行上写出主题,然后在主题下面放一行
=,使它们成为一个适当的章节标题。记得在每个主题之间留下一个空行。
主题可能包括:
-
概要:如何运行这个脚本的摘要。如果脚本使用
argparse模块来处理命令行参数,那么argparse生成的帮助文本就是理想的摘要文本。 -
描述:这个脚本的更完整的解释。
-
选项:如果使用了
argparse,这是放置每个参数详细信息的地方。通常我们会重复argparse的帮助参数。 -
环境:如果使用了
os.environ,这是描述环境变量及其含义的地方。 -
文件:由脚本创建或读取的文件名称是非常重要的信息。
-
示例:始终有一些使用脚本的示例会很有帮助。
-
另请参阅:任何相关的脚本或背景信息。
其他可能有趣的主题包括退出状态,作者,错误,报告错误,历史或版权。在某些情况下,例如关于报告错误的建议,实际上并不属于模块的文档字符串,而是属于项目的 GitHub 或 SourceForge 页面的其他位置。
-
在每个主题下填写细节。准确性很重要。由于我们将这些文档嵌入到与代码相同的文件中,因此很容易在模块的其他地方检查内容是否正确和完整。
-
对于代码示例,我们可以使用一些很酷的 RST 标记。回想一下,所有元素都是由空行分隔的。在一个段落中,只使用
::。在下一个段落中,将代码示例缩进四个空格。
这是一个脚本的 docstring 示例:
'''
Downloads and decodes the current Special Marine Warning (SMW)
for the area 'AKQ'
SYNOPSIS
========
::
python3 akq_weather.py
DESCRIPTION
===========
Downloads the Special Marine Warnings
Files
=====
Writes a file, ``AKW.html``.
EXAMPLES
========
Here's an example::
slott$ python3 akq_weather.py
<h3>There are no products active at this time.</h3>
'''
在概要部分,我们使用::作为单独的段落。在示例部分,我们在段落末尾使用::。这两个版本都是对 RST 处理工具的提示,表明接下来的缩进部分应该被排版为代码。
为库模块编写 docstrings
当我们记录库模块时,我们需要关注的是那些将导入模块以在其代码中使用的程序员的需求。
-
为 docstring 的其余部分草拟一个大纲。我们将使用 RST 标记。在一行上写出主题。在每个主题下面加一行
=,使主题成为一个适当的标题。记得在每个段落之间留下一个空行。 -
如前所示开始,创建一个摘要句子。
-
描述:模块包含的内容以及模块的用途摘要。
-
模块内容:此模块中定义的类和函数。
-
示例:使用模块的示例。
-
为每个主题填写详细信息。模块内容可能是一个很长的类或函数定义列表。这应该是一个摘要。在每个类或函数内部,我们将有一个单独的 docstring,其中包含该项的详细信息。
-
有关代码示例,请参阅前面的示例。使用
::作为段落或段落结束。将代码示例缩进四个空格。
工作原理...
几十年来,man page的大纲已经发展成为 Linux 命令的有用摘要。这种撰写文档的一般方法被证明是有用和有韧性的。我们可以利用这一大量的经验,并结构化我们的文档以遵循 man page 模型。
这两种描述软件的方法都是基于许多单独页面文档的摘要。目标是利用众所周知的主题集。这使得我们的模块文档与常见做法相一致。
我们希望准备模块 docstrings,这些 docstrings 可以被 Sphinx Python 文档生成器使用(参见www.sphinx-doc.org/en/stable/)。这是用于生成 Python 文档文件的工具。Sphinx 中的autodoc扩展将读取我们的模块、类和函数上的 docstring 头,以生成最终的文档,看起来像 Python 生态系统中的其他模块。
还有更多...
RST 有一个简单的语法规则,即段落之间用空行分隔。
这条规则使得编写的文档可以被各种 RST 处理工具检查,并重新格式化得非常漂亮。
当我们想要包含一段代码块时,我们将有一些特殊的段落:
-
用空行将代码与文本分开。
-
代码缩进四个空格。
-
提供一个
::前缀。我们可以将其作为自己单独的段落,或者作为引导段落末尾的特殊双冒号:
Here's an example::
more_code()
::用于引导段落。
在软件开发中有创新和艺术的地方。文档并不是推动创新的地方。聪明的算法和复杂的数据结构可能是新颖和聪明的。
注意
对于只想使用软件的用户来说,独特的语气或古怪的表达并不有趣。在调试时,幽默的风格也不会有帮助。文档应该是平常和常规的。
编写良好的软件文档可能是具有挑战性的。在太少的信息和仅仅重复代码的文档之间存在着巨大的鸿沟。在某个地方,有一个很好的平衡。重要的是要专注于那些对软件或其工作原理了解不多的人的需求。为这些半知识用户提供他们需要描述软件做什么以及如何使用它的信息。
在许多情况下,我们需要解决用例的两个部分:
-
软件的预期用途
-
如何自定义或扩展软件
这可能是两个不同的受众。可能有用户与开发人员不同。每个人都有不同的观点,文档的不同部分需要尊重这两种观点。
另请参阅
-
我们将在在 docstrings 中编写更好的 RST 标记中查看其他技术。
-
如果我们使用了编写 python 脚本和模块文件-语法基础的方法,我们将在我们的脚本文件中放置一个文档字符串。当我们在第三章中构建函数时,函数定义,以及在第六章中构建类时,类和对象的基础,我们将看到其他可以放置文档字符串的地方。
-
有关 Sphinx 的更多信息,请参阅
www.sphinx-doc.org/en/stable/。 -
有关 man 页面大纲的更多背景信息,请参阅
en.wikipedia.org/wiki/Man_page。
在 docstrings 中编写更好的 RST 标记
当我们有一个有用的脚本时,通常需要留下关于它的功能、工作原理以及何时使用的注释。许多用于生成文档的工具,包括 Docutils,都使用 RST 标记。我们可以使用哪些 RST 功能来使文档更易读?
准备工作
在包括描述和文档的方法中,我们看到了将基本的文档放入模块中。这是编写我们的文档的起点。有许多 RST 格式规则。我们将看一些对于创建可读文档很重要的规则。
如何做...
- 一定要写下关键点的大纲。这可能会导致创建 RST 部分标题来组织材料。部分标题是一个两行的段落,标题后面跟着一个下划线,使用
=,-,^,~或其他 Docutils 字符来划线。
标题将看起来像这样。
Topic
=====
标题文本在一行上,下划线字符在下一行上。这必须被空行包围。下划线字符可以比标题字符多,但不能少。
RST 工具将推断我们使用下划线字符的模式。只要下划线字符一致使用,匹配下划线字符到期望标题的算法将检测到这种模式。这取决于一致性和对部分和子部分的清晰理解。
刚开始时,可以帮助制作一个明确的提醒便条,如下所示:
| 字符 | 级别 |
|---|---|
| = | 1 |
| - | 2 |
| ^ | 3 |
| ~ | 4 |
- 填写各种段落。用空行分隔段落(包括部分标题)。额外的空行不会有害。省略空行将导致 RST 解析器看到一个单一的长段落,这可能不是我们想要的。
我们可以使用内联标记来强调、加重强调、代码、超链接和内联数学等,还有其他一些东西。如果我们打算使用 Sphinx,那么我们将有一个更大的文本角色集合可以使用。我们将很快看到这些技术。
- 如果编程编辑器有拼写检查器,请使用。这可能会令人沮丧,因为我们经常会有包含拼写检查失败的缩写的代码示例。
工作原理...
docutils 转换程序将检查文档,寻找部分和正文元素。一个部分由一个标题标识。下划线用于将部分组织成正确嵌套的层次结构。推断这一点的算法相对简单,并具有以下规则:
-
如果之前已经看到了下划线字符,则已知级别
-
如果之前没有看到下划线字符,则必须缩进到前一个大纲级别的下一级
-
如果没有上一级,这就是第一级
一个正确嵌套的文档可能具有以下下划线字符序列:
====
-----
^^^^^^
^^^^^^
-----
^^^^^^
~~~~~~~~
^^^^^^
我们可以看到,第一个大纲字符=将是一级。接下来的-是未知的,但出现在一级之后,所以必须是二级。第三个标题有^,之前未知,必须是三级。下一个^仍然是三级。接下来的两个-和^分别是二级和三级。
当我们遇到新字符~时,它位于三级之下,因此必须是四级标题。
注意
从这个概述中,我们可以看到不一致会导致混乱。
如果我们在文档的中途改变主意,这个算法就无法检测到。如果出于莫名其妙的原因,我们决定跳过一个级别并尝试在二级部分内有一个四级标题,那是不可能的。
RST 解析器可以识别几种不同类型的正文元素。我们展示了一些。更完整的列表包括:
-
文本段落:这些可能使用内联标记来强调或突出不同种类的内容。
-
文字块:这些是用
::引入并缩进空格的。它们也可以用.. parsed-literal::指令引入。一个 doctest 块缩进四个空格,并包括 Python 的>>>提示符。 -
列表、表格和块引用:我们稍后会看到这些。这些可以包含其他正文元素。
-
脚注:这些是可以放在页面底部或章节末尾的特殊段落。这些也可以包含其他正文元素。
-
超链接目标、替换定义和 RST 注释:这些是专门的文本项目。
还有更多...
为了完整起见,我们在这里指出,RST 段落之间用空行分隔。RST 比这个核心规则要复杂得多。
在包括描述和文档配方中,我们看了几种不同类型的正文元素:
-
文本段落:这是由空行包围的文本块。在其中,我们可以使用内联标记来强调单词,或者使用字体来显示我们正在引用代码元素。我们将在使用内联标记配方中查看内联标记。
-
列表:这些是以看起来像数字或项目符号开头的段落。对于项目符号,使用简单的
-或*。也可以使用其他字符,但这些是常见的。我们可能有这样的段落。
有项目符号会有帮助,因为:
-
它们可以帮助澄清
-
它们可以帮助组织
-
编号列表:有各种被识别的模式。我们可能会使用这样的东西。
四种常见的编号段落:
-
数字后面跟着像
.或)这样的标点符号。 -
一个字母后面跟着像
.或)这样的标点符号。 -
一个罗马数字后面跟着标点符号。
-
一个特殊情况是使用与前面项目相同的标点符号的
#。这继续了前面段落的编号。 -
文字块:代码示例必须以文字形式呈现。这个文本必须缩进。我们还需要用
::前缀代码。::字符必须是一个单独的段落,或者是代码示例的引导结束。 -
指令:指令是一个段落,通常看起来像
.. directive::。它可能有一些内容,缩进以便包含在指令内。它可能看起来像这样:
.. important::
Do not flip the bozo bit.
.. important::段落是指令。这之后是一个缩进在指令内的短段落文字。在这种情况下,它创建了一个包含important警告的单独段落。
使用指令
Docutils 有许多内置指令。Sphinx 添加了许多具有各种功能的指令。
最常用的指令之一是警告指令:注意,小心,危险,错误,提示,重要,注意,提示,警告和通用警告。这些是复合主体元素,因为它们可以有多个段落和其中嵌套的指令。
我们可能有这样的东西来提供适当的强调:
.. note:: Note Title
We need to indent the content of an admonition.
This will set the text off from other material.
另一个常见的指令是parsed-literal指令。
.. parsed-literal::
any text
*almost* any format
the text is preserved
but **inline** markup can be used.
这对于提供代码示例非常方便,其中代码的某些部分被突出显示。这样的文字就是一个简单的主体元素,里面只能有文本。它不能有列表或其他嵌套结构。
使用内联标记
在段落中,我们可以使用几种内联标记技术:
-
我们可以用
*将单词或短语括起来以进行*强调*。 -
我们可以用
**将单词或短语括起来以进行**强调**。 -
我们用单个反引号(
`)包围引用。链接后面带有_。我们可以用`section title`_来指代文档中的特定章节。我们通常不需要在 URL 周围放置任何东西。Docutils 工具可以识别这些。有时我们希望显示一个单词或短语,隐藏 URL。我们可以用这个:`the Sphinx documentation <http://www.sphinx-doc.org/en/stable/>`_。
-
我们可以将代码相关的单词使用两个反引号括起来,使其看起来像:
``code``
还有一种更一般的技术叫做文本角色。角色看起来比简单地用*字符包装一个单词或短语要复杂一些。我们使用:word:作为角色名称,后面跟着适用的单词或短语在单个`反引号中。文本角色看起来像这样:strong:`this`。
有许多标准角色名称,包括:emphasis:、:literal:、:code:、:math:、:pep-reference:、:rfc-reference:、:strong:、:subscript:、:superscript:和:title-reference:。其中一些也可以用更简单的标记,如*emphasis*或**strong**。其余只能作为显式角色使用。
此外,我们可以使用一个简单的指令定义新角色。如果我们想要进行非常复杂的处理,我们可以为处理角色提供 docutils 的类定义,从而允许我们调整文档处理的方式。Sphinx 添加了大量角色以支持函数、方法、异常、类和模块之间的详细交叉引用。
另请参阅
-
有关 RST 语法的更多信息,请参阅
docutils.sourceforge.net。其中包括对 docutils 工具的描述。 -
有关Sphinx Python Documentation Generator的信息,请参阅
www.sphinx-doc.org/en/stable/。 -
Sphinx 工具添加了许多附加指令和文本角色到基本定义中。
设计复杂的 if...elif 链
大多数情况下,我们的脚本会涉及到一系列选择。有时选择很简单,我们可以通过查看代码来判断设计的质量。在其他情况下,选择更加复杂,很难确定我们的 if 语句是否正确设计以处理所有条件。
在最简单的情况下,我们有一个条件,C,和它的反义,C。这是if...else语句的两个条件。一个条件,¬C,在if子句中说明,另一个在else中暗示。
在本解释中,我们将使用 p ∨ q 表示 Python 的OR运算符。我们可以称这两个条件为完整,因为:
C ∨ C = ¬ T
我们称之为完全,因为没有其他条件可以存在。没有第三个选择。这就是排中律。这也是else子句背后的操作原则。if语句体被执行或else语句被执行。没有第三个选择。
在实际编程中,我们经常有复杂的选择。我们可能有一组条件,C = {C[1],C[2],C[3],...,C[n]}。
我们不希望简单地假设:
C[1] ∨ C[2] ∨ C[3] ∨ ... ∨ C[n] = T
我们可以使用
来表示与any(C)类似的含义,或者any([C_1, C_2, C_3, ..., C_n])。我们需要证明
;我们不能假设这是true。
下面是可能出错的情况——我们可能错过了一些条件,C[n+1],在逻辑混乱中丢失了。错过这个将意味着我们的程序将无法正确处理此案例。
我们如何确定我们没有漏掉什么?
准备就绪
让我们看一个具体的例子,一个if...elif链。在Craps赌场游戏中,有一些适用于两个骰子的投掷的规则。这些规则适用于游戏的第一次投掷,称为come out投掷:
-
2,3 或 12,是Craps,这对所有在通过线上下的所有赌注来说都是一个损失
-
7 或 11 对所有放在通过线上的赌注都是赢家
-
剩余数字确定了一个点
许多玩家把他们的赌注放在通过线上。还有一个don't pass线,这个线不常用。我们将使用这三个条件集作为例子来查看这个方法,因为它有一个可能模糊的子句。
如何做...
当我们写一个if语句时,即使看起来微不足道,我们也需要确保所有条件都被考虑到。
-
枚举我们所知道的备选方案。在我们的例子中,我们有三条规则:(2,3,12),(7,11),以及模糊的剩余数字。
-
确定所有可能条件的全集。对于这个例子,有 10 个条件:从 2 到 12 的数字。
-
将已知的备选方案与宇宙进行比较。这个条件集合C与所有可能条件的宇宙U之间有三种可能的比较结果:
已知的备选方案比宇宙中的条件还多;C ⊃ U 。这是一个巨大的设计问题。这需要从根本上重新思考设计。
已知条件和可能条件的宇宙之间存在差距;U \ C ≠ ∅。在某些情况下,很明显我们没有涵盖所有可能的条件。在其他情况下,需要进行一些仔细的推理。我们需要用更精确的东西替换任何模糊或定义不清的术语。
在这个例子中,我们有一个模糊的术语,我们可以用更具体的东西替换。术语剩余数字似乎是值的列表(4, 5, 6, 8, 9, 10)。提供这个列表消除了任何可能的空白和疑虑。
已知的备选方案与可能备选方案的宇宙相匹配;U ≡ C 。有两种常见情况:
-
我们有像C ∨ ¬ C这样简单的东西。我们可以使用一个单独的
if和else子句——我们不需要使用这个方法,因为我们可以很容易地推断出¬ C。 -
我们可能有更复杂的东西。因为我们知道了整个宇宙,我们可以展示
。我们需要使用这个方法来编写一系列的if和elif语句,每个条件一个子句。
区分并不总是清晰的。在我们的例子中,我们没有详细说明其中一个条件,但这个条件大致是清晰的。如果我们认为缺失的条件是显而易见的,我们可以使用一个else子句而不是明确地写出它。如果我们认为缺失的条件可能会被误解,我们应该将其视为模糊的,并使用这个方法。
- 编写覆盖所有已知条件的
if...elif...elif链。对于我们的例子,它会像这样:
dice = die_1 + die_2
if dice in (2, 3, 12):
game.craps()
elif dice in (7, 11):
game.winner()
elif dice in (4, 5, 6, 8, 9, 10):
game.point(die)
- 添加一个引发异常的
else子句,就像这样:
else:
raise Exception('Design Problem Here: not all conditions accounted for')
这个额外的 else 崩溃条件给了我们一种积极识别逻辑问题的方法。我们可以确信,我们所犯的任何错误都将导致一个引人注目的问题。
工作原理...
我们的目标是确保我们的程序始终正常运行。尽管测试有所帮助,但我们仍然可能在设计和测试用例中有错误的假设。
尽管严谨的逻辑是必不可少的,我们仍然可能犯错。此外,其他人可能尝试调整我们的代码并引入错误。更尴尬的是,我们可能对自己的代码进行更改导致程序崩溃。
else 崩溃选项迫使我们对每个条件都要明确。不做任何假设。正如我们之前指出的,我们逻辑中的任何错误都将在引发异常时被发现。
else 崩溃选项对性能影响不大。一个简单的 else 子句比带有条件的 elif 子句稍快一些。如果我们认为我们的应用程序性能在任何方面取决于单个表达式的成本,那么我们有更严重的设计问题要解决。评估单个表达式的成本很少是算法中最昂贵的部分。
在设计问题存在的情况下,遇到异常崩溃是一个明智的行为。按照写入警告消息到日志的设计模式并没有太多意义。如果存在这种逻辑漏洞,程序就已经严重出错了,发现问题后尽快找到并修复是很重要的。
还有更多...
在许多情况下,我们可以通过检查程序处理的某个点的期望后置条件来推导出一个 if...elif...elif 链。例如,我们可能需要一个陈述来建立像 m 是 a 或 b 中较大的一个这样简单的事情。
(为了通过逻辑,我们将避免 m = max(a, b) 。)
我们可以这样形式化最终条件:
(m = a ∨ m = b) ∧ *m > a * ∧ m > b
我们可以通过将目标写成一个断言语句来从最终条件开始逆向工作:
# do something
assert (m = a or m = b) and m > a and m > b
一旦我们陈述了目标,我们就可以确定导致该目标的陈述。显然,像 m = a 和 m = b 这样的赋值语句是合适的��但只在特定条件下。
这些陈述中的每一个都是解决方案的一部分,我们可以推导出一个前提条件,显示何时应该使用该陈述。每个赋值语句的前提条件是 if 和 elif 表达式。当 a >= b 时,我们需要使用 m = a ;当 b >= a 时,我们需要使用 m=b 。将逻辑重新排列成代码给出了这样:
if a >= b:
m = a
elif b >= a:
m = b
else: raise Exception( 'Design Problem')
assert (m = a or m = b) and m > a and m > b
请注意我们的条件宇宙,U = { a ≥ b, b ≥ a },是完整的;没有其他可能的关系。还要注意,在边界情况下的 a = b ,我们实际上并不关心使用哪个赋值语句。Python 将按顺序处理决策,并执行 m = a 。这个选择是一致的事实不应该对我们的 if...elif...elif 链的设计产生任何影响。我们应该总是写条件而不考虑子句的评估顺序。
另请参阅
-
这类似于悬挂 else的语法问题。参见
en.wikipedia.org/wiki/Dangling_else。 -
Python 的缩进消除了悬挂 else 语法问题。它并没有解决在复杂的
if...elif...elif链中确保所有条件都得到适当处理的语义问题。
设计一个正确终止的 while 语句
大多数情况下,Python 的for语句提供了我们需要的所有迭代控制。在许多情况下,我们可以使用内置函数如map(),filter()和reduce()来处理数据集合。
然而,有一些情况我们需要使用while语句。其中一些情况涉及到我们无法创建适当的迭代器来遍历项目的数据结构。其他情况涉及与人类用户的交互,我们在从这些人那里得到输入之前没有数据。
准备工作
假设我们要提示用户输入密码。我们将使用getpass模块,以便没有回显。
此外,为了确保他们已经正确输入了密码,我们将要求他们输入两次并比较结果。这是一个简单的for语句不会很好地处理的情况。它可以被迫服役,但结果代码看起来很奇怪:for语句有一个显式的上限;提示用户输入实际上没有一个上限。
如何做……
我们将介绍一个六步流程,概述了设计这种迭代算法核心的内容。这是当一个简单的for语句不能解决我们的问题时需要做的事情。
- 定义完成。在我们的情况下,我们将有两份密码,
password_text和confirming_password_text。循环后必须为true的条件是password_text == confirming_password_text。理想情况下,从人们(或文件)那里读取是一个有界的活动。最终,人们会输入匹配的值对。在他们输入匹配的值对之前,我们将无限迭代。
还有其他边界条件。例如,文件结束。或者我们允许人返回到先前的提示。一般来说,我们在 Python 中用异常处理这些其他条件。
当然,我们可以将这些额外条件添加到我们的完成定义中。我们可能需要一个复杂的终止条件,例如文件结尾或password_text == confirming_password_text。
在这个例子中,我们将选择异常处理,并假设将使用try:块。只在终止条件中有一个单一子句大大简化了设计。
我们可以这样勾画出循环的大致情况:
# initialize something
while # not terminated:
# do something
assert password_text == confirming_password_text
我们将我们的“完成定义”写成了最后的assert语句。我们已经为之后的迭代包含了注释,我们将在后续步骤中填写。
- 定义一个在循环迭代时为
true的条件。这被称为不变量,因为在循环处理的开始和结束时它总是true。通常通过泛化后置条件或引入另一个变量来创建它。
当从人(或文件)那里读取时,我们有一个隐含的状态改变,这是不变量的重要部分。我们可以称之为状态改变中的获取下一个输入。我们经常必须清楚地表达,我们的循环将从输入流中获取下一个值。
我们必须确保我们的循环能够正确获取下一个项目,尽管while语句体中存在复杂的逻辑。一个常见的错误是存在一个条件,下一个输入实际上没有被获取。这会导致程序挂起——在while语句体中的if语句路径中没有状态改变。不变量没有被正确重置,或者在设计循环时没有被正确表达。
在我们的情况下,不变量将使用一个概念上的new-input()条件。当我们使用getpass()函数读取新值时,这个条件为true。这是我们扩展的循环设计:
# initialize something
# assert the invariant new-input(password_text)
# and new-input(confirming_password_text)
while # not terminated:
# do something
# assert the invariant new-input(password_text)
# and new-input(confirming_password_text)
assert password_text == confirming_password_text
- 定义离开循环的条件。我们需要确保这个条件取决于不变量为
true。我们还需要确保,当这个终止条件最终为false时,目标状态将变为true。
在大多数情况下,循环条件是目标状态的逻辑否定。这里是扩展的设计:
# initialize something
# assert the invariant new-input(password_text)
# and new-input(confirming_password_text)
while password_text != confirming_password_text:
# do something
# assert the invariant new-input(password_text)
# and new-input(confirming_password_text)
assert password_text == confirming_password_text
- 定义初始化,确保不变量为
true,并且我们实际上可以测试终止条件。在这种情况下,我们需要为两个变量获取值。现在循环看起来像这样:
password_text= getpass()
confirming_password_text= getpass("Confirm: ")
# assert new-input(password_text)
# and new-input(confirming_password_text)
while password_text != confirming_password_text:
# do something
# assert new-input(password_text)
# and new-input(confirming_password_text)
assert password_text == confirming_password_text
- 编写循环体,将不变量重置为
true。我们需要编写最少的语句来实现这一点。对于这个示例循环,最少的语句是相当明显的——它们与初始化匹配。我们更新后的循环看起来像这样:
password_text= getpass()
confirming_password_text= getpass("Confirm: ")
# assert new-input(password_text)
# and new-input(confirming_password_text)
while password_text != confirming_password_text:
password_text= getpass()
confirming_password_text= getpass("Confirm: ")
# assert new-input(password_text)
# and new-input(confirming_password_text)
assert password_text == confirming_password_text
-
确定一个时钟——一个单调递减的函数,显示每次循环确实朝着终止条件取得进展。
当从人那里收集输入时,我们被迫做一个假设——最终他们会输入匹配的对。每次循环都使我们离匹配对更近一步。为了正确形式化,我们可以假设在它们匹配之前会有n个输入;我们必须展示每次循环减少剩余数量。
在复杂情况下,我们可能需要将用户的输入视为值列表。对于我们的示例,我们会将用户输入视为一系列对:[(p[1] , q[1] ),(p[2] , q[2] ),(p[3] , q[3] ),...,(p[n] , q[n] )]。通过有限的列表,我们可以更容易地推断我们的循环是否真正朝着完成进展。
因为我们基于目标最终条件构建了循环,所以我们可以绝对确定它做了我们想要的事情。如果我们的逻辑是合理的,循环将终止,并且将以预期的结果终止。这是所有编程的目标——让机器在给定一些初始状态的情况下达到期望的状态。
移除一些注释后,我们得到了我们的最终循环:
password_text= getpass()
confirming_password_text= getpass("Confirm: ")
while password_text != confirming_password_text:
password_text= getpass()
confirming_password_text= getpass("Confirm: ")
assert password_text == confirming_password_text
我们将最终的后置条件保留为一个assert语句。对于复杂的循环,它既是一个内置测试,也是一个解释循环工作原理的注释。
这个设计过程通常会产生一个看起来类似于基于直觉开发的循环。有逐步证明直觉设计的没什么问题。一旦我们这样做了几次,我们就可以更有信心地使用循环,因为我们可以证明设计是合理的。
在这种情况下,循环体和初始化碰巧是相同的代码。如果这是一个问题,我们可以定义一个小两行的函数来避免重复代码。我们将在第三章函数定义中讨论这个问题。
它的工作原理...
我们首先明确循环的目标。我们所做的一切都将确保编写的代码导致该目标条件。实际上,这就是所有软件设计背后的动机——我们始终试图编写导致给定目标状态的最少语句。我们通常是反向从目标到初始化。推理链中的每一步实质上都是陈述了某个语句S的最弱前置条件,该语句导致我们期望的结果条件。
鉴于后置条件,我们试图解决一个语句和一个前置条件。我们总是在构建这个模式:
assert pre-condition
S
assert post-condition
后置条件是我们的完成定义。我们需要假设一个导致完成的语句,S,以及该语句的前置条件。总是存在无限数量的替代语句;我们专注于最弱的前置条件——假设最少的那个。
在某个时刻——通常是在编写初始化语句时——我们发现前置条件仅仅是true:任何初始状态都可以作为语句的前置条件。这就是我们知道我们的程序可以从任何初始状态开始并按预期完成的方式。这是理想的。
在设计while语句时,我们在语句体内有一个嵌套的上下文。语句体应始终处于将不变条件重新设置为true的过程中。在我们的例子中,这意味着从用户那里读取更多输入。在其他例子中,我们可能正在处理字符串中的另一个字符,或者从一组数字中取另一个数字。
我们需要证明当不变量为true且循环条件为false时,我们的最终目标已经实现。当我们从最终目标出发并根据该最终目标创建不变量和循环条件时,这个证明会更容易。
重要的是要耐心地完成每一步,以确保我们的推理是坚实的。我们需要能够证明循环将正常工作。然后我们可以有信心地运行单元测试。
另请参阅
-
我们在避免使用 break 语句可能导致的问题配方中查看了高级循环设计的其他方面。
-
我们在设计复杂的 if...elif 链配方中也研究了这个概念。
-
关于这个话题的经典文章是由大卫·格里斯撰写的一篇论文,关于发展循环不变量和循环的标准策略的注释。参见
www.sciencedirect.com/science/article/pii/0167642383900151。 -
算法设计是一个大的主题。一本很好的介绍是由斯基耐纳撰写的 算法设计手册。参见
www3.cs.stonybrook.edu/~algorith/。
避免使用 break 语句可能导致的问题
理解for语句的常见方式是它创建了一个对于所有的条件。在语句结束时,我们可以断言,在集合中的所有项目都进行了一些处理。
这并不是for语句的唯一含义。当我们在for的主体内引入break语句时,我们将语义更改为存在。当break语句离开for(或while)语句时,我们只能断言至少存在一个导致语句结束的项目。
这里有一个次要问题。如果循环在不执行break的情况下结束了怎么办?我们被迫断言不存在即使一个触发了break的项目。德摩根定律告诉我们,不存在的条件可以重新陈述为对于所有的条件:¬∃[x]B(x) ≡ ∀[x]¬ B(x)。在这个公式中,B(x) 是包括break的if语句的条件。如果我们从未找到 B(x),那么对于所有的项目,¬ B(x) 都是true。这显示了典型的对于所有循环和包括break的存在循环之间的一些对称性。
离开for或while语句时为true的条件可能是模糊的。它是正常结束的吗?它执行了break吗?我们不能轻易地判断,所以我们将提供一套给出一些设计指导的配方。
当我们有多个带有各自条件的break语句时,这个问题可能变得更加严重。我们如何最小化由复杂的break条件带来的问题?
准备就绪
让我们找到字符串中第一个出现的:或=。这是对for语句的存在修改的一个很好的例子。我们不想处理所有字符,我们想知道最左边存在:或=的地方。
>>> sample_1 = "some_name = the_value"
>>> for position in range(len(sample_1)):
... if sample_1[position] in '=:':
... break
>>> print('name=', sample_1[:position],
... 'value=', sample_1[position+1:])
name= some_name value= the_value
这个边缘案例怎么处理?
>>> sample_2 = "name_only"
>>> for position in range(len(sample_2)):
... if sample_2[position] in '=:':
... break
>>> print('name=', sample_2[:position],
... 'value=', sample_2[position+1:])
name= name_onl value=
那太尴尬了。发生了什么?
如何做...
正如我们在设计正确终止的 while 语句配方中指出的,每个语句都建立了一个后置条件。在设计循环时,我们需要表达这个条件。在这种情况下,我们没有正确表达后置条件。
理想情况下,后置条件应该是像text[position] in '=:'这样简单的东西。但是,如果给定的文本中没有=或:,简单的后置条件就没有逻辑意义。当没有任何符合条件的字符存在时,我们无法对不在那里的字符的位置做出任何断言。
- 写出明显的后置条件。我们有时称之为幸运路径条件,因为当没有发生任何异常情况时,它是
true的。
text[position] in '=:'
-
为边界情况添加后置条件。在这个例子中,我们有两个额外的条件:
-
没有
=或:。 -
根本没有字符。
len()为零,循环实际上什么也没做。在这种情况下,位置变量将永远不会被创建。
-
(len(text) == 0
or not('=' in text or ':' in text)
or text[position] in '=:')
-
如果正在使用
while语句,请考虑重新设计为具有完成条件。这可以消除对break语句的需要。 -
如果正在使用
for语句,请确保进行适当的初始化,并在循环后的语句中添加各种终止条件。在x = 0后面跟着for x = ...可能看起来多余。在不执行break语句的循环中,这是必要的。
>>> position = -1 # If it's zero length
>>> for position in range(len(sample_2)):
... if sample_2[position] in '=:':
... break
...
>>> if position == -1:
... print("name=", None, "value=", None)
... elif not(text[position] == ':' or text[position] == '='):
... print("name=", sample_2, "value=", None)
... else:
... print('name=', sample_2[:position],
... 'value=', sample_2[position+1:])
name= name_only value= None
在for后的语句中,我们已经明确列出了所有的终止条件。最终输出,name= name_only value= None,确认我们已经正确处理了示例文本。
运作原理...
这种方法迫使我们仔细计算后置条件,以确保我们绝对确定知道循环终止的所有原因。
在更复杂的循环中——具有多个break语句——后置条件可能很难完全计算出来。循环的后置条件必须包括离开循环的所有原因——正常原因以及所有的break条件。
在许多情况下,我们可以重构循环以将处理推入循环体中。我们不仅断言position是=或:字符的索引,而且包括分配name和value值的下一个处理步骤。我们可能会有这样的东西:
if len(sample_2) > 0:
name, value = sample_2, None
else:
name, value = None, None
for position in range(len(sample_2)):
if sample_2[position] in '=:':
name, value = sample_2[:position], sample2[position:]
print('name=', name, 'value=', value)
这个版本基于先前评估的完整后置条件向前推进了一些处理。这种重构很常见。
思路是放弃任何假设或直觉。稍加纪律,我们可以确定任何语句的后置条件。
实际上,我们思考后置条件的次数越多,我们的软件就可以越精确。关于我们软件目标的目标一定要明确,并通过选择使目标变为true的最简单的语句来逆向工作。
还有更多...
我们也可以在for语句上使用else子句来确定循环是否正常结束或执行了break语句。我们可以像这样使用:
We can also use an else clause on a for statement to determine if the loop finished normally or a break statement was executed. We can use something like this:
for position in range(len(sample_2)):
if sample_2[position] in '=:':
name, value = sample_2[:position], sample_2[position+1:]
break
else:
if len(sample_2) > 0:
name, value = sample_2, None
else:
name, value = None, None
else条件有时会让人感到困惑,我们不建议使用。不清楚它是否比任何其他替代方案更好。很容易忘记else被执行的原因,因为它很少被使用。
另请参阅
- 这个主题的经典文章是由 David Gries 撰写的,关于开发循环不变量和循环的标准策略的注释。参见
www.sciencedirect.com/science/article/pii/0167642383900151。
利用异常匹配规则
try语句让我们捕获异常。当异常被引发时,我们有多种处理方式:
-
忽略它:如果我们什么都不做,程序会停止。我们可以通过两种方式实现这一点——首先不使用
try语句,或者在try语句中没有匹配的except子句。 -
记录日志:我们可以写一条消息并让其传播;通常这会导致程序停止。
-
从中恢复:我们可以编写一个
except子句来执行一些恢复操作,以撤消在try子句中部分完成的操作的影响。我们可以进一步将try语句包装在while语句中,并持续重试直到成功。 -
忽略它:如果我们什么都不做(即
pass),那么在try语句之后会恢复处理。这会消除异常。 -
重写它:我们可以引发一个不同的异常。原始异常成为新引发异常的上下文。
-
链式处理:我们将一个不同的异常链接到原始异常。我们将在使用
raise from语句链接异常这个示例中看到这一点。
嵌套上下文怎么办?在这种情况下,内部try可能会忽略异常,但外部上下文会处理异常。每个try上下文的基本选项都是相同的。软件的整体行为取决于嵌套定义。
我们设计try语句的方式取决于 Python 异常形成的类层次结构。详情请参见第 5.4 节,Python 标准库。例如,ZeroDivisionError也是ArithmeticError和Exception。再举一个例子,FileNotFoundError也是OSError和Exception。
如果我们试图处理详细的异常以及通用的异常,这种层次结构可能会导致混淆。
准备工作
假设我们将简单地使用shutil来将文件从一个地方复制到另一个地方。可能会引发的大多数异常都表示问题太严重,无法解决。然而,在罕见的FileExistsError事件中,我们希望尝试恢复操作。
这是我们想要做的大致概述:
from pathlib import Path
import shutil
import os
source_path = Path(os.path.expanduser(
'~/Documents/Writing/Python Cookbook/source'))
target_path = Path(os.path.expanduser(
'~/Dropbox/B05442/demo/'))
for source_file_path in source_path.glob('*/*.rst'):
source_file_detail = source_file_path.relative_to(source_path)
target_file_path = target_path / source_file_detail
shutil.copy( str(source_file_path), str(target_file_path
我们有两条路径,source_path和target_path。我们已经定位了source_path下所有具有*.rst文件的目录。
表达式source_file_path.relative_to(source_path)给出了文件名的尾部,即基本目录后的部分。我们使用这个来构建一个在target目录下的新路径。
虽然我们可以对许多普通路径处理使用pathlib.Path对象,但在 Python 3.5 中,像shutil这样的模块期望字符串文件名而不是Path对象;我们需要显式转换Path对象。我们只能希望 Python 3.6 会改变这一点。
处理shutil.copy()函数引发的异常会带来问题。我们需要一个try语句,以便我们能够从某些类型的错误中恢复过来。如果我们尝试运行以下代码,我们会看到这种类型的错误:
FileNotFoundError: [Errno 2]
No such file or directory:
'/Users/slott/Dropbox/B05442/demo/ch_01_numbers_strings_and_tuples/index.rst'
如何创建一个按正确顺序处理异常的try语句?
如何做到这一点...
- 在
try块中缩进写入我们想要使用的代码:
try:
shutil.copy( str(source_file_path), str(target_file_path) )
- 先包括最具体的异常类。在这种情况下,我们针对具体的
FileNotFoundError和更一般的OSError分别有不同的响应。
try:
shutil.copy( str(source_file_path), str(target_file_path) )
except FileNotFoundError:
os.makedir( target_file_path.parent )
shutil.copy( str(source_file_path), str(target_file_path) )
- 包括稍后的更一般的异常:
try:
shutil.copy( str(source_file_path), str(target_file_path) )
except FileNotFoundError:
os.makedirs( str(target_file_path.parent) )
shutil.copy( str(source_file_path), str(target_file_path) )
except OSError as ex:
print(ex)
我们先匹配最具体的异常,然后再匹配更通用的异常。
我们通过创建缺失的目录来处理`FileNotFoundError`,然后再次执行`copy()`,知道现在它会正常工作。
我们消除了其他任何`OSError`类的异常。例如,如果有权限问题,那么该错误将被简单地记录。我们的目标是尝试复制所有文件。任何导致问题的文件都将被记录,但复制过程将继续。
它是如何工作的...
Python 的异常匹配规则旨在保持简单:
-
按顺序处理
except子句 -
将实际异常与异常类(或异常类元组)进行匹配。匹配意味着实际异常对象(或异常对象的任何基类)是
except子句中给定类的对象。
这些规则说明了我们为什么要先放置最具体的异常类,然后是更一般的异常类。像Exception这样的通用异常类几乎匹配每一种类型的异常。我们不希望它首先出现,因为不会检查任何其他子句。我们必须始终将通用异常放在最后。
还有一个更通用的类,BaseException类。没有好理由来处理这个类的异常。如果我们这样做,我们将捕获SystemExit和KeyboardInterrupt异常,这会干扰杀死表现不良应用程序的能力。我们仅在定义存在于正常异常层次结构之外的新异常类时才使用BaseException类作为超类。
还有更多...
我们的示例包括一个嵌套上下文,在其中可能引发第二个异常。考虑到这个except子句:
except FileNotFoundError:
os.makedirs( str(target_file_path.parent) )
shutil.copy( str(source_file_path), str(target_file_path) )
如果os.makedirs()或shutil.copy()函数引发其他异常,这些异常将不会被这个try语句处理。在此引发的任何异常都将导致整个程序崩溃。我们有两种处理方法,都涉及嵌套的try语句。
我们可以重写这个以在恢复期间包含一个嵌套的try:
try:
shutil.copy( str(source_file_path), str(target_file_path) )
except FileNotFoundError:
try:
os.makedirs( str(target_file_path.parent) )
shutil.copy( str(source_file_path), str(target_file_path) )
except OSError as ex:
print(ex)
except OSError as ex:
print(ex)
在这个例子中,我们在两个地方重复了OSError处理。在我们的嵌套上下文中,我们将记录异常并让它传播,这可能会停止程序。在外部上下文中,我们将做同样的事情。
我们说可能会停止程序,因为这段代码可能在try语句中使用,该语句可能会处理这些异常。如果没有其他try上下文,那么这些未处理的异常将停止程序。
我们还可以重写我们的总体语句,使用嵌套的try语句将两种异常处理策略分成更局部和更全局的考虑。它会像这样:
try:
try:
shutil.copy( str(source_file_path), str(target_file_path) )
except FileNotFoundError:
os.makedirs( str(target_file_path.parent) )
shutil.copy( str(source_file_path), str(target_file_path) )
except OSError as ex:
print(ex)
在内部try语句中处理makedirs的复制只处理FileNotFoundError异常。任何其他异常都将传播到外部try语句。在这个例子中,我们将异常处理嵌套,使得通用处理包装特定处理。
另请参阅
-
在避免使用
except:子句可能会出现的问题的示例中,我们将看到在设计异常时的一些额外考虑因素。 -
在使用
raise from语句链接异常的示例中,我们将看到如何链接异常,以便单个异常类包装不同的详细异常。
避免使用except:子句可能会出现的问题
在异常处理中有一些常见的错误。这些错误可能会导致程序无响应。
我们可能会犯的错误之一是使用except:子句。如果我们对尝试处理的异常不谨慎,还有一些其他错误可能会发生。
这个示例将展示一些我们可以避免的常见异常处理错误。
准备就绪
在避免使用except:子句可能会出现的问题的示例中,我们看到了在设计异常处理时的一些考虑因素。在那个示例中,我们不建议使用BaseException,因为我们可能会干扰停止行为异常的 Python 程序。
我们将在这个示例中扩展不应该做什么的想法。
如何做...
使用except Exception:作为最通用的异常管理方式。
处理太多异常可能会干扰我们停止异常的 Python 程序的能力。当我们按下Ctrl + C,或通过kill -2发送SIGINT信号时,我们通常希望程序停止。我们很少希望程序写一条消息并继续运行,或者完全停止响应。
还有一些其他类别的异常,我们应该警惕尝试处理:
-
SystemError
-
RuntimeError
-
MemoryError
通常,这些异常意味着 Python 内部某处出现了问题。与其消除这些异常,或尝试进行一些恢复,我们应该允许程序失败,找出根本原因并修复它。
它是如何工作的...
有两种技术我们应该避免:
-
不要捕获
BaseException类 -
不要使用
except:而不指定异常类。这会匹配所有异常;这将包括我们应该避免尝试处理的异常。
使用except BaseException或不指定具体类的except可能会导致程序在我们需要停止它的时候变得无响应。
此外,如果我们捕获了其中任何异常,我们可能会干扰这些内部异常的处理方式:
-
SystemExit -
KeyboardInterrupt -
GeneratorExit
如果我们静默、包装或重写其中任何一个,我们可能已经制造了一个本不存在的问题。我们可能已经将一个简单的问题加剧成一个更大更神秘的问题。
注意
编写一个从不崩溃的程序是一种高贵的愿望。干扰 Python 的一些内部异常不会创建一个更可靠的程序。相反,它会创建一个清晰的失败被掩盖并成为模糊的神秘的程序。
另请参见
-
在利用异常匹配规则食谱中,我们将探讨设计异常时的一些考虑因素。
-
在使用
raise from语句链接异常食谱中,我们将看看如何链接异常,使得单一异常类包装不同的详细异常。
使用raise from语句链接异常
在某些情况下,我们可能希望将一些看似不相关的异常合并为一个单一的通用异常。一个复杂的模块通常定义一个适用于模块内部可能出现的许多情况的单一通用Error异常。
大多数情况下,通用异常就足够了。如果引发了模块的Error,则说明某些地方出了问题。
较少情况下,我们希望获取详细信息以进行调试或监视目的。我们可能希望将它们写入日志,或将详细信息包含在电子邮件中。在这种情况下,我们需要提供支持详细信息,以放大或扩展通用异常。我们可以通过从通用异常链接到根本原因异常来做到这一点。
准备就绪
假设我们正在编写一些复杂的字符串处理。我们希望将许多不同类型的详细异常视为单个通用错误,以使我们软件的用户免受实现细节的影响。我们可以将详细信息附加到通用错误。
如何做...
- 要创建一个新的异常,我们可以这样做:
class Error(Exception):
pass
这就足以定义一个新的异常类。
- 在处理异常时,我们可以使用
raise from语句将它们链接起来,就像这样:
try:
something
except (IndexError, NameError) as exception:
print("Expected", exception)
raise Error("something went wrong") from exception
except Exception as exception:
print("Unexpected", exception)
raise
在第一个`except`子句中,我们匹配了两种异常类。无论我们收到哪种类型的异常,我们都将从模块的通用`Error`异常类中引发一个新的异常。新的异常将链接到根本原因异常。
在第二个`except`子句中,我们匹配了通用的`Exception`类。我们写了一个日志消息并重新引发了异常。在这里,我们不是在链接异常,而是在另一个上下文中简单地继续异常处理。
工作原理...
Python 异常类都有一个记录异常原因的位置。我们可以使用raise Exception from Exception语句设置这个__cause__属性。
当引发此异常时,它的样子是这样的:
>>> class Error(Exception):
... pass
>>> try:
... 'hello world'[99]
... except (IndexError, NameError) as exception:
... raise Error("index problem") from exception
...
Traceback (most recent call last):
File "<doctest default[0]>", line 2, in <module>
'hello world'[99]
IndexError: string index out of range
刚刚我们看到的异常是以下异常的直接原因:
Traceback (most recent call last):
File "/Library/Frameworks/Python.framework/Versions/3.4/lib/python3.4/doctest.py", line 1318, in __run
compileflags, 1), test.globs)
File "<doctest default[0]>", line 4, in <module>
raise Error("index problem") from exception
Error: index problem
这显示了一个链接的异常。Traceback消息中的第一个异常是IndexError异常。这是直接原因。Traceback中的第二个异常是我们的通用Error异常。这是一个通用的摘要异常,被链接到原始原因。
应用程序将在try:语句中看到Error异常。我们可能会有这样的情况:
try:
some_function()
except Error as exception:
print(exception)
print(exception .__cause__)
这里我们展示了一个名为some_function()的函数,它可以引发通用的Error异常。如果此函数确实引发了异常,则except子句将匹配通用的Error异常。我们可以打印异常的消息exception,以及根本原因异常exception.__cause__。在许多应用程序中,exception.__cause__的值可能会被写入调试日志而不是显示给用户。
还有更多...
如果在异常处理程序内部引发异常,这也会创建一种链接的异常关系。这是上下文关系而不是原因关系。
上下文消息看起来相似。消息略有不同。它说在处理上述异常时,发生了另一个异常:。第一个Traceback将显示原始异常。第二个消息是抛出异常而不使用显式的 from 连接。
通常,上下文是一些未计划的东西,表明except处理块中存在错误。例如,我们可能会有这样的情况:
try:
something
except ValueError as exception:
print("Some message", exceotuib)
这将引发一个带有ValueError异常上下文的NameError异常。NameError异常源自将异常变量拼写为exceotuib。
另请参阅
-
在利用异常匹配规则配方中,我们考虑了一些在设计异常时的注意事项
-
在使用 except:子句避免潜在问题配方中,我们考虑了一些在设计异常时的额外注意事项
使用with语句管理上下文
有许多情况下,我们的脚本会与外部资源纠缠在一起。最常见的例子是磁盘文件和到外部主机的网络连接。一个常见的错误是永远保留这些纠缠,无用地捆绑这些资源。这些有时被称为内存泄漏,因为每次打开新文件而不关闭先前使用的文件时,可用内存都会减少。
我们希望隔离每个纠缠,以确保资源被正确获取和释放。想法是创建一个上下文,在其中我们的脚本使用外部资源。在上下文结束时,我们的程序不再绑定到资源,我们希望保证资源被释放。
准备工作
假设我们想要将数据行以 CSV 格式写入文件。完成后,我们希望确保文件被关闭,并且各种操作系统资源——包括缓冲区和文件句柄——被释放。我们可以在上下文管理器中实现这一点,这可以保证文件将被正确关闭。
由于我们将使用 CSV 文件,我们可以使用csv模块来处理格式的细节:
>>> import csv
我们还将使用pathlib模块来定位我们将要处理的文件:
>>> import pathlib
为了有写入内容,我们将使用这个愚蠢的数据源:
>>> some_source = [[2,3,5], [7,11,13], [17,19,23]]
这将为我们提供一个学习with语句的上下文。
如何做...
- 通过打开文件或使用
urllib.request.urlopen()创建网络连接来创建上下文。其他常见的上下文包括zip文件和tar文件:
target_path = pathlib.Path('code/test.csv')
with target_path.open('w', newline='') as target_file:
- 包括所有处理,缩进在
with语句内:
target_path = pathlib.Path('code/test.csv')
with target_path.open('w', newline='') as target_file:
writer = csv.writer(target_file)
writer.writerow(['column', 'data', 'headings'])
for data in some_source:
writer.writerow(data)
- 当我们将文件作为上下文管理器使用时,文件将在缩进的上下文块结束时自动关闭。即使引发异常,文件仍会被正确关闭。将在上下文完成并释放资源后执行的处理内容缩进:
target_path = pathlib.Path('code/test.csv')
with target_path.open('w', newline='') as target_file:
writer = csv.writer(target_file)
writer.writerow(['column', 'headings'])
for data in some_source:
writer.writerow(data)
print('finished writing', target_path)
with上下文之外的语句将在上下文关闭后执行。命名资源——由target_path.open()打开的文件——将被正确关闭。
即使with语句内部引发异常,文件仍会被正确关闭。上下文管理器会收到异常通知。它可以关闭文件并允许异常传播。
工作原理...
上下文管理器会收到代码块中两种退出的通知:
-
正常退出,没有异常
-
引发了异常
上下文管理器将在所有情况下将我们的程序与外部资源解开。文件可以关闭。网络连接可以断开。数据库事务可以提交或回滚。锁可以释放。
我们可以通过在with语句内部包含手动异常来进行实验。这可以显示文件已被正确关闭。
try:
target_path = pathlib.Path('code/test.csv')
with target_path.open('w', newline='') as target_file:
writer = csv.writer(target_file)
writer.writerow(['column', 'headings'])
for data in some_source:
writer.writerow(data)
raise Exception("Just Testing")
except Exception as exc:
print(target_file.closed)
print(exc)
print('finished writing', target_path)
在这个例子中,我们将真正的工作包装在try语句中。这样我们可以在向 CSV 文件写入第一个后引发异常。当异常被引发时,我们可以打印异常。此时,文件也将被关闭。输出仅为:
True
Just Testing
finished writing code/test.csv
这向我们表明文件已经正确关闭。它还向我们显示了与异常相关的消息,以确认它是我们手动引发的异常。输出的test.csv文件将只包含some_source变量的第一行数据。
还有更多...
Python 为我们提供了许多上下文管理器。我们注意到,打开的文件是一个上下文,urllib.request.urlopen()创建的打开网络连接也是一个上下文。
对于所有文件操作和所有网络连接,我们应该使用with语句作为上下文管理器。很难找到这个规则的例外。
事实证明,decimal模块使用上下文管理器来允许对十进制算术执行的方式进行本地化更改。我们可以使用decimal.localcontext()函数作为上下文管理器,以更改由with语句隔离的计算的舍入规则或精度。
我们也可以定义自己的上下文管理器。contextlib模块包含函数和装饰器,可以帮助我们在不明确提供上下文管理器的资源周围创建上下文管理器。
在处理锁时,with上下文是获取和释放锁的理想方式。请参阅docs.python.org/3/library/threading.html#with-locks了解由threading模块创建的锁对象与上下文管理器之间的关系。
另请参阅
- 请参阅
www.python.org/dev/peps/pep-0343/了解 with 语句的起源
第三章:函数定义
在本章中,我们将看一下以下配方:
-
设计带有可选参数的函数
-
使用超灵活的关键字参数
-
使用*分隔符强制关键字参数
-
在函数参数上写明确的类型
-
基于部分函数选择参数顺序
-
使用 RST 标记编写清晰的文档字符串
-
围绕 Python 的堆栈限制设计递归函数
-
使用脚本库开关编写可重用脚本
介绍
函数定义是将一个大问题分解为较小问题的一种方式。数学家们已经做了几个世纪了。这也是将我们的 Python 编程打包成智力可管理的块的一种方式。
在这些配方中,我们将看一些函数定义技术。这将包括处理灵活参数的方法以及根据一些更高级别的设计原则组织参数的方法。
我们还将看一下 Python 3.5 的 typing 模块以及如何为我们的函数创建更正式的注释。我们可以开始使用mypy项目,以对数据类型的使用进行更正式的断言。
设计带有可选参数的函数
当我们定义一个函数时,通常需要可选参数。这使我们能够编写更灵活的函数,并且可以在更多情况下使用。
我们也可以将这看作是创建一系列密切相关函数的一种方式,每个函数具有略有不同的参数集合 - 称为签名 - 但都共享相同的简单名称。许多函数共享相同的名称的想法可能有点令人困惑。因此,我们将更多地关注可选参数的概念。
可选参数的一个示例是int()函数。它有两种形式:
-
int(str): 例如,int('355')的值为355。在这种情况下,我们没有为可选的base参数提供值;使用了默认值10。 -
int(str, base):例如,int('0x163', 16)的值是355。在这种情况下,我们为base参数提供了一个值。
准备工作
许多游戏依赖于骰子的集合。赌场游戏Craps使用两个骰子。像Zilch(或Greed或Ten Thousand)这样的游戏使用六个骰子。游戏的变体可能使用更多。
拥有一个可以处理所有这些变化的掷骰子函数非常方便。我们如何编写一个骰子模拟器,可以处理任意数量的骰子,但是将使用两个作为方便的默认值?
如何做...
我们有两种方法来设计带有可选参数的函数:
-
一般到特定:我们首先设计最一般的解决方案,并为最常见的情况提供方便的默认值。
-
特定到一般:我们首先设计几个相关的函数。然后将它们合并为一个涵盖所有情况的一般函数,将原始函数中的一个单独出来作为默认行为。
从特定到一般的设计
在遵循特定到一般策略时,我们将设计几个单独的函数,并寻找共同的特征:
- 编写函数的一个版本。我们将从Craps游戏开始,因为它似乎最简单:
**>>> import random**
**>>> def die():**
**... return random.randint(1,6)**
**>>> def craps():**
**... return (die(), die())**
我们定义了一个方便的辅助函数die(),它封装了有时被称为标准骰子的基本事实。有五个可以使用的立体几何体,可以产生四面体、六面体、八面体、十二面体和二十面体骰子。六面骰子有着悠久的历史,最初是作为骰子骨头,很容易修剪成六面立方体。
这是底层die()函数的一个示例:
**>>> random.seed(113)**
**>>> die(), die()**
**(1, 6)**
我们掷了两个骰子,以展示值如何组合以掷更大堆的骰子。
我们的Craps游戏函数看起来是这样的:
**>>> craps()**
**(6, 3)**
**>>> craps()**
**(1, 4)**
这显示了Craps游戏的一些两个骰子投掷。
- 编写函数的另一个版本:
**>>> def zonk():**
**... return tuple(die() for x in range(6))**
我们使用了一个生成器表达式来创建一个有六个骰子的元组对象。我们将在第八章中深入研究生成器表达式,函数式和反应式编程特性。
我们的生成器表达式有一个变量x,它被忽略了。通常也可以看到这样写成tuple(die() for _ in range(6))。变量_是一个有效的 Python 变量名;这个名字可以作为一个提示,表明我们永远不想看到这个变量的值。
这是使用zonk()函数的一个例子:
**>>> zonk()**
**(5, 3, 2, 4, 1, 1)**
这显示了六个单独骰子的结果。有一个短顺(1-5)以及一对一。在游戏的某些版本中,这是一个很好的得分手。
- 找出两个函数中的共同特征。这可能需要对各种函数进行一些重写,以找到一个共同的设计。在许多情况下,我们最终会引入额外的变量来替换常数或其他假设。
在这种情况下,我们可以将两元组的创建概括化。我们可以引入一个基于range(2)的生成器表达式,它将两次评估die()函数:
**>>> def craps():**
**... return tuple(die() for x in range(2))**
这似乎比解决特定的两个骰子问题需要更多的代码。从长远来看,使用一个通用函数意味着我们可以消除许多特定的函数。
- 合并这两个函数。这通常涉及到暴露一个之前是常数或其他硬编码假设的变量:
**>>> def dice(n):**
**... return tuple(die() for x in range(n))**
这提供了一个通用函数,涵盖了Craps和Zonk的需求:
**>>> dice(2)**
**(3, 2)**
**>>> dice(6)**
**(5, 3, 4, 3, 3, 4)**
- 确定最常见的用例,并将其作为引入的任何参数的默认值。如果我们最常见的模拟是Craps,我们可能会这样做:
**>>> def dice(n=2):**
**... return tuple(die() for x in range(n))**
现在我们可以简单地在Craps中使用dice()。我们需要在Zonk中使用dice(6)。
从一般到特殊的设计
在遵循从一般到特殊的策略时,我们会首先确定所有的需求。我们通常会通过在需求中引入变量来做到这一点:
- 总结掷骰子的需求。我们可能有一个像这样的列表:
-
Craps:两个骰子。
-
Zonk中的第一次掷骰子:六个骰子。
-
Zonk中的后续掷骰子:一到六个骰子。
这个需求列表显示了掷n个骰子的一个共同主题。
- 用一个显式参数重写需求,代替任何字面值。我们将用参数n替换所有的数字,并展示我们引入的这个新参数的值:
-
Craps:n个骰子,其中n=2。
-
Zonk中的第一次掷骰子:n个骰子,其中n=6。
-
Zonk中的后续掷骰子:n个骰子,其中1≤n≤6。
这里的目标是确保所有的变化确实有一个共同的抽象。在更复杂的问题中,看似相似的东西可能没有一个共同的规范。
我们还希望确保我们已经正确地对各种函数进行了参数化。在更复杂的情况下,我们可能有一些不需要被参数化的值;它们可以保持为常数。
- 编写符合一般模式的函数:
**>>> def dice(n):**
**... return (die() for x in range(n))**
在第三种情况下——Zonk中的后续掷骰子——我们确定了一个1≤n≤6的约束。我们需要确定这是否是我们dice()函数的约束,还是这个约束是由使用dice函数的模拟应用所施加的。
在这种情况下,约束是不完整的。Zonk的规则要求没有被掷动的骰子形成某种得分模式。约束不仅仅是骰子的数量在一到六之间;约束与游戏状态有关。似乎没有充分的理由将dice()函数与游戏状态联系起来。
- 为最常见的用例提供一个默认值。如果我们最常见的模拟是Craps,我们可能会这样做:
**>>> def dice(n=2):**
**... return tuple(die() for x in range(n))**
现在我们可以简单地在Craps中使用dice()。我们需要在Zonk中使用dice(6)。
工作原理...
Python 提供参数值的规则非常灵活。有几种方法可以确保每个参数都有一个值。我们可以将其视为以下方式工作:
-
将每个参数设置为任何提供的默认值。
-
对于没有名称的参数,参数值是按位置分配给参数的。
-
对于具有名称的参数,例如
dice(n=2),参数值是使用名称分配的。通过位置和名称同时分配参数是错误的。 -
如果任何参数没有值,这是一个错误。
这些规则允许我们根据需要提供默认值。它们还允许我们混合位置值和命名值。默认值的存在是使参数可选的原因。
可选参数的使用源于两个考虑因素:
-
我们可以对处理进行参数化吗?
-
该参数的最常见参数值是什么?
在流程定义中引入参数可能是具有挑战性的。在某些情况下,有代码可以帮助我们用参数替换文字值(例如 2 或 6)。
然而,在某些情况下,文字值不需要被参数替换。它可以保留为文字值。我们并不总是想用参数替换每个文字值。例如,我们的die()函数有一个文字值为 6,因为我们只对标准的立方骰子感兴趣。这不是一个参数,因为我们不认为有必要制作更一般的骰子。
还有更多...
如果我们想非常彻底,我们可以编写专门的版本函数,这些函数是我们更通用的函数的专门版本。这些函数可以简化应用程序:
**>>> def craps():**
**... return dice(2)**
**>>> def zonk():**
**... return dice(6)**
我们的应用程序功能-craps()和zonk()-依赖于一个通用函数dice()。这又依赖于另一个函数die()。我们将在基于部分函数选择参数顺序食谱中重新讨论这个想法。
这个依赖堆栈中的每一层都引入了一个方便的抽象,使我们不必理解太多细节。这种分层抽象的想法有时被称为chunking。这是一种通过隔离细节来管理复杂性的方法。
这种设计模式的常见扩展是在这个函数层次结构中的多个级别提供参数。如果我们想要对die()函数进行参数化,我们将为dice()和die()提供参数。
对于这种更复杂的参数化,我们需要在我们的层次结构中引入更多具有默认值的参数。我们将从die()中添加一个参数开始。这个参数必须有一个默认值,这样我们就不会破坏我们现有的测试用例:
**>>> def die(sides=6):**
**... return random.randint(1,6)**
在引入这个参数到抽象堆栈的底部之后,我们需要将这个参数提供给更高级别的函数:
**>>> def dice(n=2, sides=6):**
**... return tuple(die(sides) for x in range(n))**
我们现在有很多种使用dice()函数的方法:
-
所有默认值:
dice()很好地覆盖了Craps。 -
所有位置参数:
dice(6, 6)将覆盖Zonk。 -
位置和命名参数的混合:位置值必须首先提供,因为顺序很重要。例如,
dice(2, sides=8)将覆盖使用两个八面体骰子的游戏。 -
所有命名参数:
dice(sides=4, n=4)这将处理我们需要模拟掷四个四面体骰子的情况。在使用所有命名参数时,顺序并不重要。
在这个例子中,我们的函数堆栈只有两层。在更复杂的应用程序中,我们可能需要在层次结构的许多层中引入参数。
另请参阅
-
我们将在基于部分函数选择参数顺序食谱中扩展一些这些想法。
-
我们使用了涉及不可变对象的可选参数。在这个配方中,我们专注于数字。在第四章中,内置数据结构-列表、集合、字典,我们将研究可变对象,它们具有可以更改的内部状态。在避免函数参数的可变默认值配方中,我们将研究一些重要的额外考虑因素,这些因素对于设计具有可变对象的可选值的函数非常重要。
使用超级灵活的关键字参数
一些设计问题涉及解决一个未知的简单方程,给定足够的已知值。例如,速率、时间和距离之间有一个简单的线性关系。我们可以解决任何一个,只要知道另外两个。以下是我们可以用作示例的三条规则:
-
d = r × t
-
r = d / t
-
t = d / r
在设计电路时,例如,基于欧姆定律使用了一组类似的方程。在这种情况下,方程将电阻、电流和电压联系在一起。
在某些情况下,我们希望提供一个简单、高性能的软件实现,可以根据已知和未知的情况执行三种不同的计算中的任何一种。我们不想使用通用的代数框架;我们想将三个解决方案捆绑到一个简单、高效的函数中。
准备工作
我们将构建一个单一函数,可以通过体现任意两个已知值的三个解来解决速率-时间-距离(RTD)计算。通过微小的变量名称更改,这适用于令人惊讶的许多现实世界问题。
这里有一个技巧。我们不一定想要一个单一的值答案。我们可以通过创建一个包含三个值的小 Python 字典来稍微概括这一点。我们将在第四章中更多地了解字典。
当出现问题时,我们将使用warnings模块而不是引发异常:
**>>> import warnings**
有时,产生一个有疑问的结果比停止处理更有帮助。
如何做...
解出每个未知数的方程。我们先前已经展示了这一点,例如d = r * t,RTD 计算:
- 这导致了三个单独的表达式:
-
距离=速率*时间
-
速率=距离/时间
-
时间=距离/速率
- 根据一个值为
None时未知的情况,将每个表达式包装在一个if语句中:
if distance is None:
distance = rate * time
elif rate is None:
rate = distance / time
elif time is None:
time = distance / rate
- 参考第二章中的设计复杂的 if...elif 链,语句和语法,以指导设计这些复杂的
if...elif链。包括else崩溃选项的变体:
else:
warnings.warning( "Nothing to solve for" )
- 构建生成的字典对象。在简单情况下,我们可以使用
vars()函数简单地将所有本地变量作为生成的字典发出。在某些情况下,我们可能有一些本地变量不想包括;在这种情况下,我们需要显式构建字典:
return dict(distance=distance, rate=rate, time=time)
- 使用关键字参数将所有这些包装为一个函数:
def rtd(distance=None, rate=None, time=None):
if distance is None:
distance = rate * time
elif rate is None:
rate = distance / time
elif time is None:
time = distance / rate
else:
warnings.warning( "Nothing to solve for" )
return dict(distance=distance, rate=rate, time=time)
我们可以像这样使用生成的函数:
**>>> def rtd(distance=None, rate=None, time=None):
... if distance is None:
... distance = rate * time
... elif rate is None:
... rate = distance / time
... elif time is None:
... time = distance / rate
... else:
... warnings.warning( "Nothing to solve for" )
... return dict(distance=distance, rate=rate, time=time)
>>> rtd(distance=31.2, rate=6)
{'distance': 31.2, 'time': 5.2, 'rate': 6}**
这告诉我们,以 6 节的速率行驶 31.2 海里将需要 5.2 小时。
为了得到格式良好的输出,我们可以这样做:
**>>> result= rtd(distance=31.2, rate=6)**
**>>> ('At {rate}kt, it takes '**
**... '{time}hrs to cover {distance}nm').format_map(result)**
**'At 6kt, it takes 5.2hrs to cover 31.2nm'**
为了打破长字符串,我们使用了第二章中的设计复杂的 if...elif 链。
工作原理...
因为我们为所有参数提供了默认值,所以我们可以为三个参数中的两个提供参数值,然后函数就可以解决第三个参数。这样可以避免我们编写三个单独的函数。
将字典作为最终结果返回并不是必要的。这只是方便。它允许我们无论提供了哪些参数值,都有一个统一的结果。
还有更多...
我们有另一种表述,涉及更多的灵活性。Python 函数有一个所有其他关键字参数,前缀为**。通常显示如下:
def rtd2(distance, rate, time, **keywords):
print(keywords)
任何额外的关键字参数都会被收集到提供给**keywords参数的字典中。然后我们可以用额外的参数调用这个函数。像这样评估这个函数:
rtd2(rate=6, time=6.75, something_else=60)
然后我们会看到keywords参数的值是一个带有{'something_else': 60}值的字典对象。然后我们可以对这个结构使用普通的字典处理技术。这个字典中的键和值是在函数被评估时提供的名称和值。
我们可以利用这一点,并坚持要求所有参数都提供关键字:
def rtd2(**keywords):
rate= keywords.get('rate', None)
time= keywords.get('time', None)
distance= keywords.get('distance', None)
etc.
这个版本使用字典get()方法在字典中查找给定的键。如果键不存在,则提供None的默认值。
(返回None的默认值是get()方法的默认行为。我们的示例包含一些冗余,以阐明处理过程。对于一些非常复杂的情况,我们可能有除None之外的默认值。)
这有可能具有稍微更灵活的优势。它可能的缺点是使实际参数名称非常难以辨别。
我们可以遵循使用 RST 标记编写清晰文档字符串的配方,并提供一个良好的文档字符串。然而,通过文档隐式地提供参数名称似乎更好一些。
另请参阅
- 我们将查看使用 RST 标记编写清晰文档字符串配方中函数的文档

使用*分隔符强制使用关键字参数
有些情况下,我们需要将大量的位置参数传递给函数。也许我们遵循了设计具有可选参数的函数的配方,这导致我们设计了一个参数如此之多的函数,以至于变得令人困惑。
从实用的角度来看,一个具有超过三个参数的函数可能会令人困惑。大量的传统数学似乎集中在一个和两个参数函数上。似乎没有太多常见的数学运算符涉及三个或更多的操作数。
当难以记住参数的所需顺序时,参数太多了。
准备工作
我们将查看一个具有大量参数的函数。我们将使用一个准备风冷表并将数据写入 CSV 格式输出文件的函数。
我们需要提供一系列温度、一系列风速以及我们想要创建的文件的信息。这是很多参数。
基本公式是这样的:
T[wc] ( T[a], V* ) = 13.12 + 0.6215 T[a] - 11.37 V ^(0.16) + 0.3965 T[a] V ^(0.16)
风冷温度,T[wc],基于空气温度,T[a],以摄氏度为单位,以及风速,V,以 KPH 为单位。
对于美国人来说,这需要一些转换:
-
从°F 转换为°C:C = 5( F -32) / 9
-
将风速从 MPH,V[m],转换为 KPH,V[k]:V[k] = V[m] × 1.609344
-
结果需要从°C 转换回°F:F = 32 + C (9/5)
我们不会将这些纳入这个解决方案。我们将把这留给读者作为一个练习。
创建风冷表的一种方法是创建类似于这样的东西:
import pathlib
def Twc(T, V):
return 13.12 + 0.6215*T - 11.37*V**0.16 + 0.3965*T*V**0.16
def wind_chill(start_T, stop_T, step_T,
start_V, stop_V, step_V, path):
"""Wind Chill Table."""
with path.open('w', newline='') as target:
writer= csv.writer(target)
heading = [None]+list(range(start_T, stop_T, step_T))
writer.writerow(heading)
for V in range(start_V, stop_V, step_V):
row = [V] + [Twc(T, V)
for T in range(start_T, stop_T, step_T)]
writer.writerow(row)
我们使用with上下文打开了一个输出文件。这遵循了第二章中的使用 with 语句管理上下文配方,语句和语法。在这个上下文中,我们为 CSV 输出文件创建了一个写入。我们将在第九章中更深入地研究这个问题,输入/输出、物理格式、逻辑布局。
我们使用表达式[None]+list(range(start_T, stop_T, step_T),创建了一个标题行。这个表达式包括一个列表文字和一个生成器表达式,用于构建一个列表。我们将在第四章中查看列表,内置数据结构-列表、集合、字典。我们将在第八章中查看生成器表达式,函数式和响应式编程特性。
同样,表格的每个单元格都是由一个生成器表达式构建的,[Twc(T, V) for T in range(start_T, stop_T, step_T)]。这是一个构建列表对象的理解。列表由风冷函数Twc()计算的值组成。我们根据表中的行提供风速。我们根据表中的列提供温度。
虽然细节涉及前瞻性部分,def行提出了一个问题。这个def行非常复杂。
这种设计的问题在于wind_chill()函数有七个位置参数。当我们尝试使用这个函数时,我们得到以下代码:
import pathlib
p=pathlib.Path('code/wc.csv')
wind_chill(0,-45,-5,0,20,2,p)
所有这些数字是什么?有没有什么可以帮助解释这行代码的意思?
如何做到...
当我们有大量参数时,使用关键字参数而不是位置参数会有所帮助。
在 Python 3 中,我们有一种强制使用关键字参数的技术。我们可以使用*作为两组参数之间的分隔符:
-
在
*之前,我们列出可以或按关键字命名的参数值。在这个例子中,我们没有这些参数。 -
在
*之后,我们列出必须使用关键字给出的参数值。对于我们的示例,这是所有的参数。
对于我们的示例,生成的函数如下:
def wind_chill(*, start_T, stop_T, step_T, start_V, stop_V, step_V, path):
当我们尝试使用令人困惑的位置参数时,我们会看到这个:
**>>> wind_chill(0,-45,-5,0,20,2,p)
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: wind_chill() takes 0 positional arguments but 7 were given**
我们必须按以下方式使用该函数:
wind_chill(start_T=0, stop_T=-45, step_T=-5,
start_V=0, stop_V=20, step_V=2,
path=p)
强制使用必填关键字参数的用法迫使我们每次使用这个复杂函数时都写出清晰的语句。
它是如何工作的...
*字符在函数定义中有两个含义:
-
它作为一个特殊参数的前缀,接收所有未匹配的位置参数。我们经常使用
*args将所有位置参数收集到一个名为args的单个参数中。 -
它被单独使用,作为可以按位置应用的参数和必须通过关键字提供的参数之间的分隔符。
print()函数就是一个例子。它有三个仅限关键字参数,用于输出文件、字段分隔符字符串和行结束字符串。
还有更多...
当然,我们可以将此技术与各种参数的默认值结合使用。例如,我们可以对此进行更改:
import sys
def wind_chill(*, start_T, stop_T, step_T, start_V, stop_V, step_V, output=sys.stdout):
现在我们可以以两种方式使用这个函数:
- 这是在控制台上打印表的方法:
wind_chill(
start_T=0, stop_T=-45, step_T=-5,
start_V=0, stop_V=20, step_V=2)
- 这是写入文件的方法:
path = pathlib.Path("code/wc.csv")
with path.open('w', newline='') as target:
wind_chill(output=target,
start_T=0, stop_T=-45, step_T=-5,
start_V=0, stop_V=20, step_V=2)
我们在这里改变了方法,稍微更加通用。这遵循了设计具有可选参数的函数配方。
另请参阅
- 查看基于部分函数选择参数顺序配方,了解此技术的另一个应用
在函数参数上写明确的类型
Python 语言允许我们编写完全与数据类型相关的函数(和类)。以这个函数为例:
def temperature(*, f_temp=None, c_temp=None):
if c_temp is None:
return {'f_temp': f_temp, 'c_temp': 5*(f_temp-32)/9}
elif f_temp is None:
return {'f_temp': 32+9*c_temp/5, 'c_temp': c_temp}
else:
raise Exception("Logic Design Problem")
这遵循了之前展示的三个配方:使用超灵活的关键字参数,使用本章的分隔符强制关键字参数,以及设计复杂的 if...elif 链来自第二章,语句和语法*。
这个函数将适用于任何数值类型的参数值。实际上,它将适用于任何实现+、-、*和/运算符的数据结构。
有时我们不希望我们的函数完全通用。在某些情况下,我们希望对数据类型做出更强的断言。虽然我们有时关心数据类型,但我们不想编写大量看起来像这样的代码:
from numbers import Number
def c_temp(f_temp):
assert isinstance(F, Number)
return 5*(f_temp-32)/9
这引入了额外的assert语句的性能开销。它还会用一个通常应该重申显而易见的语句来使我们的程序混乱。
此外,我们不能依赖文档字符串进行测试。这是推荐的风格:
def temperature(*, f_temp=None, c_temp=None):
"""Convert between Fahrenheit temperature and
Celsius temperature.
:key f_temp: Temperature in °F.
:key c_temp: Temperature in °C.
:returns: dictionary with two keys:
:f_temp: Temperature in °F.
:c_temp: Temperature in °C.
"""
文档字符串不允许进行任何自动化测试来确认文档实际上是否与代码匹配。两者可能不一致。
我们想要的是关于涉及的数据类型的提示,可以用于测试和确认,但不会影响性能。我们如何提供有意义的类型提示?
准备工作
我们将实现temperature()函数的一个版本。我们将需要两个模块,这些模块将帮助我们提供关于参数和返回值的数据类型的提示:
from typing import *
我们选择从typing模块导入所有名称。如果我们要提供类型提示,我们希望它们简洁。写typing.List[str]很尴尬。我们更喜欢省略模块名称。
我们还需要安装最新版本的mypy。这个项目正在快速发展。与其使用pip程序从 PyPI 获取副本,最好直接从 GitHub 存储库github.com/JukkaL/mypy下载最新版本。
说明中说,目前,PyPI 上的 mypy 版本与 Python 3.5 不兼容。如果你使用 Python 3.5,请直接从 git 安装。
**$ pip3 install git+git://github.com/JukkaL/mypy.git**
mypy工具可用于分析我们的 Python 程序,以确定类型提示是否与实际代码匹配。
如何做...
Python 3.5 引入了语言类型提示。我们可以在三个地方使用它们:函数参数、函数返回和类型提示注释:
- 为各种数字定义一个方便的类型:
from decimal import Decimal
from typing import *
Number = Union[int, float, complex, Decimal]
理想情况下,我们希望在 numbers 模块中使用抽象的Number类。目前,该模块没有可用的正式类型规范,因此我们将为Number定义自己的期望。这个定义是几种数字类型的联合。理想情况下,mypy或 Python 的未来版本将包括所需的定义。
- 像这样注释函数的参数:
def temperature(*,
f_temp: Optional[Number]=None,
c_temp: Optional[Number]=None):
我们在参数的一部分添加了:和类型提示。在这种情况下,我们使用我们自己的Number类型定义来声明任何数字都可以在这里。我们将其包装在Optional[]类型操作中,以声明参数值可以是Number或None。
- 函数的返回值可以这样注释:
def temperature(*,
f_temp: Optional[Number]=None,
c_temp: Optional[Number]=None) -> Dict[str, Number]:
我们为此函数的返回值添加了->和类型提示。在这种情况下,我们声明结果将是一个具有字符串键str和使用我们的Number类型定义的数字值的字典对象。
typing模块引入了类型提示名称,例如Dict,我们用它来解释函数的结果。这与实际构建对象的dict类不同。typing.Dict只是一个提示。
- 如果需要的话,我们可以在赋值和
with语句中添加类型提示作为注释。这些很少需要,但可能会澄清一长串复杂的语句。如果我们想要添加它们,注释可能看起来像这样:
result = {'c_temp': c_temp,
'f_temp': f_temp} # type: Dict[str, Number]
我们在构建最终字典对象的语句上添加了# type: Dict[str, Number]。
工作原理...
我们添加的类型信息称为提示。它们不是 Python 编译器以某种方式检查的要求。它们在运行时也不会被检查。
类型提示由一个名为mypy的独立程序使用。有关更多信息,请参见mypy-lang.org。
mypy程序检查 Python 代码,包括类型提示。它应用一些形式推理和推断技术,以确定各种类型提示是否对 Python 程序可以处理的任何数据为“真”。
对于更大更复杂的程序,mypy的输出将包括描述代码本身或装饰代码的类型提示可能存在问题的警告和错误。
例如,这是一个容易犯的错误。我们假设我们的函数返回一个单一的数字。然而,我们的返回语句与我们的期望不匹配:
def temperature_bad(*,
f_temp: Optional[Number]=None,
c_temp: Optional[Number]=None) -> Number:
if c_temp is None:
c_temp = 5*(f_temp-32)/9
elif f_temp is None:
f_temp = 32+9*c_temp/5
else:
raise Exception( "Logic Design Problem" )
result = {'c_temp': c_temp,
'f_temp': f_temp} # type: Dict[str, Number]
return result
当我们运行mypy时,我们会看到这个:
ch03_r04.py: note: In function "temperature_bad":
ch03_r04.py:37: error: Incompatible return value type:
expected Union[builtins.int, builtins.float, builtins.complex, decimal.Decimal],
got builtins.dict[builtins.str,
Union[builtins.int, builtins.float, builtins.complex, decimal.Decimal]]
我们可以看到我们的Number类型名称在错误消息中被扩展为Union[builtins.int, builtins.float, builtins.complex, decimal.Decimal]。更重要的是,我们可以看到在第 37 行,return语句与函数定义不匹配。
考虑到这个错误,我们需要修复返回值或定义,以确保期望的类型和实际类型匹配。目前不清楚哪个是“正确”的。以下任一种可能是意图:
-
计算并返回单个值:这意味着需要有两个
return语句,取决于计算了哪个值。在这种情况下,没有理由构建result字典对象。 -
返回字典对象:这意味着我们需要更正
def语句以具有正确的返回类型。更改这可能会对其他期望temperature返回Number实例的函数产生连锁变化。
参数和返回值的额外语法对运行时没有真正影响,只有在源代码首次编译成字节码时才会有很小的成本。它们毕竟只是提示。
还有更多...
在使用内置类型时,我们经常可以创建复杂的结构。例如,我们可能有一个字典,将三个整数的元组映射到字符串列表:
a = {(1, 2, 3): ['Poe', 'E'],
(3, 4, 5): ['Near', 'a', 'Raven'],
}
如果这是函数的结果,我们如何描述这个?
我们将创建一个相当复杂的类型表达式,总结每个结构层次:
Dict[Tuple[int, int, int], List[str]]
我们总结了一个将一个类型Tuple[int, int, int]映射为另一个类型List[str]的字典。这捕捉了几种内置类型如何组合以构建复杂的数据结构。
在这种情况下,我们将三个整数的元组视为一个匿名元组。在许多情况下,它不仅仅是一个通用元组,它实际上是一个被建模为元组的 RGB 颜色。也许字符串列表实际上是来自更长文档的一行文本,已经根据空格拆分成单词。
在这种情况下,我们应该做如下操作:
Color = Tuple[int, int, int]
Line = List[str]
Dict[Color, Line]
创建我们自己的应用程序特定类型名称可以极大地澄清使用内置集合类型执行的处理。
另请参阅
-
有关类型提示的更多信息,请参见
www.python.org/dev/peps/pep-0484/。 -
有关当前
mypy项目,请参见github.com/JukkaL/mypy。 -
有关
mypy如何与 Python 3 一起工作的文档,请参见www.mypy-lang.org。
基于部分函数选择参数顺序
当我们查看复杂的函数时,有时我们会看到我们使用函数的方式有一个模式。例如,我们可能多次评估一个函数,其中一些参数值由上下文固定,而其他参数值随着处理的细节而变化。
如果我们的设计反映了这一点,它可以简化我们的编程。我们希望提供一种使常见参数比不常见参数更容易处理的方法。我们也希望避免重复大上下文中的参数。
准备就绪
我们将看一个 haversine 公式的版本。这计算地球表面上点之间的距离,使用该点的纬度和经度坐标:

c = 2 arc sin(√a)
基本的计算得出了两点之间的中心角c。角度以弧度表示。我们通过将其乘以地球的平均半径来将其转换为距离。如果我们将角度c乘以半径为 3959 英里,距离,我们将角度转换为英里。
这是这个函数的一个实现。我们包括了类型提示:
from math import radians, sin, cos, sqrt, asin
MI= 3959
NM= 3440
KM= 6372
def haversine(lat_1: float, lon_1: float,
lat_2: float, lon_2: float, R: float) -> float:
"""Distance between points.
R is Earth's radius.
R=MI computes in miles. Default is nautical miles.
>>> round(haversine(36.12, -86.67, 33.94, -118.40, R=6372.8), 5)
2887.25995
"""
Δ_lat = radians(lat_2) - radians(lat_1)
Δ_lon = radians(lon_2) - radians(lon_1)
lat_1 = radians(lat_1)
lat_2 = radians(lat_2)
a = sin(Δ_lat/2)**2 + cos(lat_1)*cos(lat_2)*sin(Δ_lon/2)**2
c = 2*asin(sqrt(a))
return R * c
注意
关于 doctest 示例的说明:
示例中的 doctest 使用了一个额外的小数点,这在其他地方没有使用。这样做是为了使这个示例与在线上的其他示例匹配。
地球不是球形的。在赤道附近,更精确的半径是 6378.1370 公里。在极地附近,半径是 6356.7523 公里。我们在常数中使用常见的近似值。
我们经常遇到的问题是,我们通常在一个单一的上下文中工作,并且我们将始终为R提供相同的值。例如,如果我们在海洋环境中工作,我们将始终使用R = NM来获得海里。
提供参数的一致值有两种常见的方法。我们将看看两种方法。
如何做...
在某些情况下,一个整体的上下文将为参数建立一个变量。这个值很少改变。提供参数的一致值有几种常见的方法。这涉及将函数包装在另一个函数中。有几种方法:
-
在一个新函数中包装函数。
-
创建一个偏函数。这有两个进一步的改进:
-
我们可以提供关键字参数
-
或者我们可以提供位置参数
我们将在这个配方中分别看看这些不同的变化。
包装一个函数
我们可以通过将一个通用函数包装在一个特定上下文的包装函数中来提供上下文值:
- 使一些参数成为位置参数,一些参数成为关键字参数。我们希望上下文特征——很少改变的特征——成为关键字。更频繁更改的参数应该保持为位置参数。我们可以遵循使用分隔符强制关键字参数*的方法。
我们可能会将基本的 haversine 函数更改为这样:
def haversine(lat_1: float, lon_1: float,
lat_2: float, lon_2: float, *, R: float) -> float:
我们插入了*来将参数分成两组。第一组可以通过位置或关键字提供参数。第二组,- 在这种情况下是R - 必须通过关键字给出。
- 然后,我们可以编写一个包装函数,它将应用所有的位置参数而不加修改。它将作为长期上下文的一部分提供额外的关键字参数:
def nm_haversine(*args):
return haversine(*args, R=NM)
我们在函数声明中使用了*args构造来接受一个单独的元组args中的所有位置参数值。当评估haversine()函数时,我们还使用了*args来将元组扩展为该函数的所有位置参数值。
使用关键字参数创建一个偏函数
偏函数是一个有一些参数值被提供的函数。当我们评估一个偏函数时,我们将之前提供的参数与额外的参数混合在一起。一种方法是使用关键字参数,类似于包装一个函数:
- 我们可以遵循使用分隔符强制关键字参数*的方法。我们可能会将基本的 haversine 函数更改为这样:
def haversine(lat_1: float, lon_1: float,
lat_2: float, lon_2: float, *, R: float) -> float:
- 使用关键字参数创建一个偏函数:
from functools import partial
nm_haversine = partial(haversine, R=NM)
partial()函数从现有函数和一组具体的参数值中构建一个新函数。nm_haversine()函数在构建偏函数时提供了R的特定值。
我们可以像使用任何其他函数一样使用它:
**>>> round(nm_haversine(36.12, -86.67, 33.94, -118.40), 2)
1558.53**
我们得到了一个海里的答案,这样我们就可以进行与船只相关的计算,而不必每次使用haversine()函数时都要耐心地检查它是否有R=NM作为参数。
使用位置参数创建一个偏函数
部分函数是一个具有一些参数值的函数。当我们评估部分函数时,我们正在提供额外的参数。另一种方法是使用位置参数。
如果我们尝试使用带有位置参数的partial(),我们只能在部分定义中提供最左边的参数值。这让我们想到函数的前几个参数可能被部分函数或包装器隐藏。
- 我们可能会将基本的
haversine函数更改为这样:
def haversine(R: float, lat_1: float, lon_1: float,
lat_2: float, lon_2: float) -> float:
- 使用位置参数创建一个部分函数:
from functools import partial
nm_haversine = partial(haversine, NM)
partial()函数从现有函数和具体的参数值集构建一个新的函数。nm_haversine()函数在构建部分时为第一个参数R提供了一个特定的值。
我们可以像使用其他函数一样使用这个:
**>>> round(nm_haversine(36.12, -86.67, 33.94, -118.40), 2)
1558.53**
我们得到了一个海里的答案,这样我们就可以进行与航海有关的计算,而不必耐心地检查每次使用haversine()函数时是否有R=NM作为参数。
它是如何工作的...
部分函数本质上与包装函数相同。虽然它为我们节省了一行代码,但它有一个更重要的目的。我们可以在程序的其他更复杂的部分中自由构建部分函数。我们不需要使用def语句。
请注意,在查看位置参数的顺序时,创建部分函数会引起一些额外的考虑:
-
当我们使用
*args时,它必须是最后一个。这是语言要求。这意味着在它前面的参数可以被具体识别,其余的都变成了匿名的,并且可以被一次性传递给包装函数。 -
在创建部分函数时,最左边的位置参数最容易提供一个值。
这两个考虑让我们将最左边的参数视为更多的上下文:这些预计很少改变。最右边的参数提供细节并经常改变。
还有更多...
还有第三种包装函数的方法——我们也可以构建一个lambda对象。这也可以工作:
nm_haversine = lambda *args: haversine(*args, R=NM)
注意,lambda对象是一个被剥离了名称和主体的函数。它被简化为只有两个要素:
-
参数列表
-
一个单一的表达式是结果
lambda不能有任何语句。如果我们需要语句,我们需要使用def语句来创建一个包含名称和多个语句的定义。
另请参阅
- 我们还将在使用脚本库开关编写可重用脚本的配方中进一步扩展这个设计
使用 RST 标记编写清晰文档字符串
我们如何清楚地记录函数的作用?我们可以提供例子吗?当然可以,而且我们真的应该。在第二章中的包括描述和文档,语句和语法和使用 RST 标记编写清晰文档字符串的配方中,我们看到了一些基本的文档技术。这些配方介绍了ReStructuredText(RST)用于模块文档字符串。
我们将扩展这些技术,为函数文档字符串编写 RST。当我们使用 Sphinx 等工具时,我们函数的文档字符串将成为描述函数作用的优雅文档。
准备工作
在使用分隔符强制关键字参数*的配方中,我们看到了一个具有大量参数的函数和另一个只有两个参数的函数。
这是一个稍微不同版本的Twc()函数:
**>>> def Twc(T, V):
... """Wind Chill Temperature."""
... if V < 4.8 or T > 10.0:
... raise ValueError("V must be over 4.8 kph, T must be below 10°C")
... return 13.12 + 0.6215*T - 11.37*V**0.16 + 0.3965*T*V**0.16**
我们需要用更完整的文档来注释这个函数。
理想情况下,我们已经安装了 Sphinx 来看我们的劳动成果。请参阅www.sphinx-doc.org。
如何做...
通常我们会为函数描述写以下内容:
-
概要
-
描述
-
参数
-
返回
-
异常
-
测试案例
-
任何其他看起来有意义的东西
这是我们如何为一个函数创建良好文档的方法。我们可以应用类似的方法来为一个函数,甚至一个模块创建文档:
- 写概要:不需要一个适当的主题——我们不写 这个函数计算... ;我们从 计算... 开始。没有理由过分强调上下文:
def Twc(T, V):
"""Computes the wind chill temperature."""
- 用详细描述写:
def Twc(T, V):
"""Computes the wind chill temperature
The wind-chill, :math:`T_{wc}`, is based on
air temperature, T, and wind speed, V.
"""
在这种情况下,我们在描述中使用了一小块排版数学。:math: 解释文本角色使用 LaTeX 数学排版。如果你安装了 LaTeX,Sphinx 将使用它来准备一个带有数学的小.png文件。如果你愿意,Sphinx 可以使用 MathJax 或 JSMath 来进行 JavaScript 数学排版,而不是创建一个.png文件。
- 描述参数:对于位置参数,通常使用
:param name: description。Sphinx 将容忍许多变化,但这是常见的。
对于必须是关键字的参数,通常使用 :key name: description 。使用 key 而不是 param 显示它是一个仅限关键字的参数:
def Twc(T: float, V: float):
"""Computes the wind chill temperature
The wind-chill, :math:`T_{wc}`, is based on
air temperature, T, and wind speed, V.
:param T: Temperature in °C
:param V: Wind Speed in kph
"""
有两种包含类型信息的方法:
-
使用 Python 3 类型提示
-
使用 RST
:type name:标记
我们通常不会同时使用这两种技术。类型提示比 RST :type: 标记更好。
- 使用
:returns:描述返回值:
def Twc(T: float, V: float) -> float:
"""Computes the wind chill temperature
The wind-chill, :math:`T_{wc}`, is based on
air temperature, T, and wind speed, V.
:param T: Temperature in °C
:param V: Wind Speed in kph
:returns: Wind-Chill temperature in °C
"""
有两种包含返回类型信息的方法:
-
使用 Python 3 类型提示
-
使用 RST
:rtype:标记
我们通常不会同时使用这两种技术。RST :rtype: 标记已被类型提示取代。
- 确定可能引发的重要异常。使用
:raises exception:原因标记。有几种可能的变化,但:raises exception:似乎最受欢迎:
def Twc(T: float, V: float) -> float:
"""Computes the wind chill temperature
The wind-chill, :math:`T_{wc}`, is based on
air temperature, T, and wind speed, V.
:param T: Temperature in °C
:param V: Wind Speed in kph
:returns: Wind-Chill temperature in °C
:raises ValueError: for wind speeds under over 4.8 kph or T above 10°C
"""
- 如果可能的话,包括一个 doctest 测试用例:
def Twc(T: float, V: float) -> float:
"""Computes the wind chill temperature
The wind-chill, :math:`T_{wc}`, is based on
air temperature, T, and wind speed, V.
:param T: Temperature in °C
:param V: Wind Speed in kph
:returns: Wind-Chill temperature in °C
:raises ValueError: for wind speeds under over 4.8 kph or T above 10°C
>>> round(Twc(-10, 25), 1)
-18.8
"""
- 写任何其他附加说明和有用信息。我们可以将以下内容添加到文档字符串中:
See https://en.wikipedia.org/wiki/Wind_chill
.. math::
T_{wc}(T_a, V) = 13.12 + 0.6215 T_a - 11.37 V^{0.16} + 0.3965 T_a V^{0.16}
我们已经包含了一个维基百科页面的参考,该页面总结了风冷计算并链接到更详细的信息。
我们还包括了一个带有函数中使用的 LaTeX 公式的 .. math:: 指令。这将排版得很好,提供了代码的一个非常可读的版本。
它是如何工作的...
有关文档字符串的更多信息,请参见第二章中的包括描述和文档 配方,语句和语法。虽然 Sphinx 很受欢迎,但它并不是唯一可以从文档字符串注释中创建文档的工具。Python 标准库中的 pydoc 实用程序也可以从文档字符串注释中生成漂亮的文档。
Sphinx 工具依赖于docutils包中 RST 处理的核心功能。有关更多信息,请参见pypi.python.org/pypi/docutils。
RST 规则相对简单。这个配方中的大多数附加功能都利用了 RST 的解释文本角色。我们的每个 :param T: 、 :returns: 和 :raises ValueError: 结构都是一个文本角色。RST 处理器可以使用这些信息来决定内容的样式和结构。样式通常包括一个独特的字体。上下文可能是 HTML 定义列表格式。
还有更多...
在许多情况下,我们还需要在函数和类之间包含交叉引用。例如,我们可能有一个准备风冷表的函数。这个函数可能有包含对 Twc() 函数的引用的文档。
Sphinx 将使用特殊的 :func: 文本角色生成这些交叉引用:
def wind_chill_table():
"""Uses :func:`Twc` to produce a wind-chill
table for temperatures from -30°C to 10°C and
wind speeds from 5kph to 50kph.
"""
我们在 RST 文档中使用了 :func:Twc`` 来交叉引用一个函数。Sphinx 将把这些转换为适当的超链接。
另请参阅
- 有关 RST 工作的其他配方,请参见第二章中的包括描述和文档 和在文档字符串中编写更好的 RST 标记 配方。
围绕 Python 的堆栈限制设计递归函数
一些函数可以使用递归公式清晰而简洁地定义。有两个常见的例子:
阶乘函数:

计算斐波那契数的规则:

其中每个都涉及一个具有简单定义值的情况,以及涉及根据同一函数的其他值计算函数值的情况。
我们面临的问题是,Python 对这种递归函数定义的上限施加了限制。虽然 Python 的整数可以轻松表示1000!,但堆栈限制阻止我们随意这样做。
计算F[n]斐波那契数涉及一个额外的问题。如果我们不小心,我们会计算很多值超过一次:
F[5] = F[4] + F[3]
F[5] = (F[3] + F[2] ) + (F[2] + F[1] )
等等。
要计算F[5],我们将计算F[3]两次,F[2]三次。这是非常昂贵的。
准备工作
许多递归函数定义遵循阶乘函数设定的模式。这有时被称为尾递归,因为递归情况可以写在函数体的尾部:
def fact(n: int) -> int:
if n == 0:
return 1
return n*fact(n-1)
函数中的最后一个表达式引用了具有不同参数值的函数。
我们可以重新陈述这一点,避免 Python 中的递归限制。
如何做...
尾递归也可以被描述为归约。我们将从一组值开始,然后将它们减少到一个单一的值:
- 扩展规则以显示所有细节:
n! = n x (n- 1 ) × (n- 2 ) × (n- 3 )... × 1
- 编写一个循环,枚举所有的值:
N = { n, n- 1 , n- 2 , ..., 1}在 Python 中,它就是这样的:range(1, n+1)。然而,在某些情况下,我们可能需要对基本值应用一些转换函数:
N = { f(i): 1 ≤ i < n +1}如果我们必须执行某种转换,它在 Python 中可能看起来像这样:
N = (f(i) for i in range(1,n+1))
- 整合归约函数。在这种情况下,我们正在计算一个大的乘积,使用乘法。我们可以使用
x 表示这一点。对于这个例子,我们只对产品中计算的值施加了一个简单的边界:![如何做...]()
以下是 Python 中的实现:
def prod(int_iter):
p = 1
for x in int_iter:
p *= x
return p
我们可以将这个重新陈述为这样的解决方案。这使用了更高级的函数:
def fact(n):
return prod(range(1, n+1))
这很好地起作用。我们已经优化了将prod()和fact()函数合并为一个函数的第一个解决方案。事实证明,进行这种优化实际上并没有减少操作的时间。
这里是使用timeit模块运行的比较:
| 简单 | 4.7766 |
|---|---|
| 优化 | 4.6901 |
这是一个 2%的性能改进。并不是一个显著的改变。
请注意,Python 3 的range对象是惰性的——它不创建一个大的list对象,它会在prod()函数请求时返回值。这与 Python 2 不同,Python 2 中的range()函数急切地创建一个包含所有值的大的list对象,而xrange()函数是惰性的。
它是如何工作的...
尾递归定义很方便,因为它既简短又容易记忆。数学家喜欢这个,因为它可以帮助澄清函数的含义。
许多静态的编译语言都以类似于我们展示的技术进行了优化。这种优化有两个部分:
- 使用相对简单的代数规则重新排列语句,使递归子句实际上是最后一个。
if子句可以重新组织成不同的物理顺序,以便return fact(n-1) * n是最后一个。这种重新排列对于这样组织的代码是必要的:
def ugly_fact(n):
if n > 0:
return fact(n-1) * n
elif n == 0:
return 1
else:
raise Exception("Logic Error")
- 将一个特殊指令注入到虚拟机的字节码中 - 或者实际的机器码中 - 重新评估函数,而不创建新的堆栈帧。Python 没有这个特性。实际上,这个特殊指令将递归转换成一种
while语句:
p = n
while n != 1:
n = n-1
p *= n
这种纯机械的转换会导致相当丑陋的代码。在 Python 中,它也可能非常慢。在其他语言中,特殊的字节码指令的存在将导致代码运行速度快。
我们不喜欢做这种机械优化。首先,它会导致丑陋的代码。更重要的是 - 在 Python 中 - 它往往会创建比上面开发的替代方案更慢的代码。
还有更多...
斐波那契问题涉及两个递归。如果我们将其简单地写成递归,可能会像这样:
def fibo(n):
if n <= 1:
return 1
else:
return fibo(n-1)+fibo(n-2)
将一个简单的机械转换成尾递归是困难的。像这样具有多个递归的问题需要更加仔细的设计。
我们有两种方法来减少这个计算复杂度:
-
使用记忆化
-
重新阐述问题
记忆化技术在 Python 中很容易应用。我们可以使用functools.lru_cache()作为装饰器。这个函数将缓存先前计算过的值。这意味着我们只计算一次值;每一次,lru_cache都会返回先前计算过的值。
它看起来像这样:
from functools import lru_cache
@lru_cache(128)
def fibo(n):
if n <= 1:
return 1
else:
return fibo(n-1)+fibo(n-2)
添加一个装饰器是优化更复杂的多路递归的简单方法。
重新阐述问题意味着从新的角度来看待它。在这种情况下,我们可以考虑计算所有斐波那契数,直到F[n]。我们只想要这个序列中的最后一个值。我们计算所有的中间值,因为这样做更有效。这是一个执行此操作的生成器函数:
def fibo_iter():
a = 1
b = 1
yield a
while True:
yield b
a, b = b, a+b
这个函数是斐波那契数的无限迭代。它使用 Python 的yield,以便以懒惰的方式发出值。当客户函数使用这个迭代器时,每个数字被消耗时,序列中的下一个数字被计算。
这是一个函数,它消耗值,并对否则无限的迭代器施加一个上限:
def fibo(n):
"""
>>> fibo(7)
21
"""
for i, f_i in enumerate(fibo_iter()):
if i == n: break
return f_i
这个函数从fibo_iter()迭代器中消耗每个值。当达到所需的数字时,break语句结束for语句。
当我们回顾第二章中的设计一个正确终止的 while 语句配方时,我们注意到一个带有break的while语句可能有多个终止的原因。在这个例子中,结束for语句只有一种方法。
我们可以始终断言在循环结束时i == n。这简化了函数的设计。
另请参阅
- 请参阅第二章中的设计一个正确终止的 while 语句配方,语句和语法
使用脚本库开关编写可重用脚本
通常会创建一些小脚本,我们希望将它们组合成一个更大的脚本。我们不想复制和粘贴代码。我们希望将工作代码留在一个文件中,并在多个地方使用它。通常,我们希望从多个文件中组合元素,以创建更复杂的脚本。
我们遇到的问题是,当我们导入一个脚本时,它实际上开始运行。这通常不是我们导入一个脚本以便重用它时的预期行为。
我们如何导入文件中的函数(或类),而不让脚本开始执行某些操作?
准备好
假设我们有一个方便的 haversine 距离函数的实现,名为haversine(),并且它在一个名为ch03_r08.py的文件中。
最初,文件可能是这样的:
import csv
import pathlib
from math import radians, sin, cos, sqrt, asin
from functools import partial
MI= 3959
NM= 3440
KM= 6373
def haversine( lat_1: float, lon_1: float,
lat_2: float, lon_2: float, *, R: float ) -> float:
... and more ...
nm_haversine = partial(haversine, R=NM)
source_path = pathlib.Path("waypoints.csv")
with source_path.open() as source_file:
reader= csv.DictReader(source_file)
start = next(reader)
for point in reader:
d = nm_haversine(
float(start['lat']), float(start['lon']),
float(point['lat']), float(point['lon'])
)
print(start, point, d)
start= point
我们省略了haversine()函数的主体,只显示了...和更多...,因为它在基于部分函数选择参数顺序的配方中有所展示。我们专注于函数在 Python 脚本中的上下文,该脚本还打开一个名为wapypoints.csv的文件,并对该文件进行一些处理。
我们如何导入这个模块,而不让它打印出waypoints.csv文件中航点之间的距离?
如何做...
Python 脚本可以很容易编写。事实上,创建一个可工作的脚本通常太简单了。以下是我们如何将一个简单的脚本转换为可重用的库:
- 识别脚本的工作语句:我们将区分定义和动作。例如
import,def和class等语句显然是定义性的——它们支持工作但并不执行工作。几乎所有其他语句都是执行动作的。
在我们的例子中,有四个赋值语句更多地是定义而不是动作。区别完全是出于意图。所有语句,根据定义,都会执行一个动作。不过,这些动作更像是def语句的动作,而不像脚本后面的with语句的动作。
以下是通常的定义性语句:
MI= 3959
NM= 3440
KM= 6373
def haversine( lat_1: float, lon_1: float,
lat_2: float, lon_2: float, *, R: float ) -> float:
... and more ...
nm_haversine = partial(haversine, R=NM)
其余的语句明显是朝着产生打印结果的动作。
- 将动作封装成一个函数:
def analyze():
source_path = pathlib.Path("waypoints.csv")
with source_path.open() as source_file:
reader= csv.DictReader(source_file)
start = next(reader)
for point in reader:
d = nm_haversine(
float(start['lat']), float(start['lon']),
float(point['lat']), float(point['lon'])
)
print(start, point, d)
start= point
- 在可能的情况下,提取文字并将其转换为参数。这通常是将文字移到具有默认值的参数中。
从这里开始:
def analyze():
source_path = pathlib.Path("waypoints.csv")
到这里:
def analyze(source_name="waypoints.csv"):
source_path = pathlib.Path(source_name)
这使得脚本可重用,因为路径现在是一个参数而不是一个假设。
- 将以下内容作为脚本文件中唯一的高级动作语句包括:
if __name__ == "__main__":
analyze()
我们已经将脚本的动作封装为一个函数。顶层动作脚本现在被包裹在一个if语句中,以便在导入时不被执行。
它是如何工作的...
Python 的最重要规则是,导入模块实质上与运行模块作为脚本是一样的。文件中的语句按顺序从上到下执行。
当我们导入一个文件时,通常我们对执行def和class语句感兴趣。我们可能对一些赋值语句感兴趣。
当 Python 运行一个脚本时,它设置了一些内置的特殊变量。其中之一是__name__。这个变量有两个不同的值,取决于文件被执行的上下文:
-
从命令行执行的顶层脚本:在这种情况下,内置特殊名称
__name__的值设置为__main__。 -
由于导入语句而执行的文件:在这种情况下,
__name__的值是正在创建的模块的名称。
__main__的标准名称一开始可能有点奇怪。为什么不在所有情况下使用文件名?这个特殊名称是被分配的,因为 Python 脚本可以从多个来源之一读取。它可以是一个文件。Python 也可以从stdin管道中读取,或者可以在 Python 命令行中使用-c选项提供。
然而,当一个文件被导入时,__name__的值被设置为模块的名称。它不会是__main__。在我们的例子中,import处理期间__name__的值将是ch03_r08。
还有更多...
现在我们可以围绕一个可重用的库构建有用的工作。我们可能会创建几个看起来像这样的文件:
文件trip_1.py:
from ch03_r08 import analyze
analyze('trip_1.csv')
或者甚至更复杂一些:
文件all_trips.py:
from ch03_r08 import analyze
for trip in 'trip_1.csv', 'trip_2.csv':
analyze(trip)
目标是将实际解决方案分解为两个特性集合:
-
类和函数的定义
-
一个非常小的面向行动的脚本,使用定义来进行有用的工作
为了达到这个目标,我们经常会从一个混合了两组特性的脚本开始。这种脚本可以被视为一个尖峰解决方案。我们的尖峰解决方案应该在我们确信它有效之后逐渐演变成一个更精细的解决方案。
尖峰或者悬崖钉是一种可移动的登山装备,它并不能让我们在路线上爬得更高,但它能让我们安全地攀登。
另请参阅
- 在第六章中,类和对象的基础,我们将看一下类定义。这是另一种广泛使用的定义性语句。
第四章:内置数据结构 - 列表、集合、字典
在本章中,我们将研究以下内容:
-
选择数据结构
-
构建列表 - 文字、附加和理解
-
切片和切割列表
-
从列表中删除 - 删除、移除、弹出和过滤
-
反转列表的副本
-
使用集合方法和运算符
-
从集合中删除项目 - remove(),pop()和 difference
-
创建字典 - 插入和更新
-
从字典中删除 - pop()方法和 del 语句
-
控制字典键的顺序
-
在 doctest 示例中处理字典和集合
-
理解变量、引用和赋值
-
制作对象的浅层和深层副本
-
避免函数参数的可变默认值
介绍
Python 具有丰富的内置数据结构。这些内置结构通常用于进行大量有用的编程。这些集合涵盖了各种常见情况。
我们将概述可用的各种结构以及它们解决的问题。从那里,我们可以详细了解列表、字典和集合。
请注意,我们将内置的元组和字符串设置为与列表结构不同。它们有一些重要的相似之处,也有一些不同之处。在第一章中,数字、字符串和元组,我们强调了字符串和元组的行为更像不可变的数字,而不是可变的集合。
我们还将研究一些与 Python 处理对象引用相关的更高级的主题。我们还将研究与这些数据结构的可变性相关的一些问题。
选择数据结构
Python 提供了许多内置数据结构,帮助我们处理数据集合。确定哪种数据结构适合特定目的可能会令人困惑。
我们如何选择要使用的结构?列表、集合和字典有哪些特点?为什么有元组和冻结集?
准备就绪
在将数据放入集合之前,我们需要考虑如何收集数据,以及一旦我们拥有了集合,我们将如何处理它。最重要的问题始终是我们将如何识别集合中的特定项目。
我们将研究一些需要回答的关键问题。
如何做...
- 编程是否专注于执行成员资格测试?其中一个例子是有效输入值的集合。当用户输入集合中的内容时,他们的输入是有效的,否则是无效的。
简单成员资格建议使用set:
valid_inputs = {"yes", "y", "no", "n"}
answer = None
while answer not in valid_inputs:
answer = input("Continue? [y, n] ").lower()
set不按特定顺序保存项目。一旦项目是成员,我们就无法再次添加它:
**>>> valid_inputs = {"yes", "y", "no", "n"}
>>> valid_inputs.add("y")
>>> valid_inputs
{'no', 'y', 'n', 'yes'}**
我们创建了一个名为valid_inputs的集合,其中包含四个不同的字符串项目。我们不能向已包含y的集合中再添加y。集合的内容不会改变。
还要注意,集合中项目的顺序并不完全与我们最初提供它们的顺序相同。集合无法保持任何特定的项目顺序,它只能确定集合中是否存在某个项目。
- 我们是否将通过其在集合中的位置来识别项目?一个例子包括输入文件中的行 - 行号是其在集合中的位置。
当我们必须使用索引或位置来标识项目时,我们必须使用list:
**>>> month_name_list = ["Jan", "Feb", "Mar", "Apr",
... "May", "Jun", "Jul", "Aug",
... "Sep", "Oct", "Nov", "Dec"]
>>> month_name_list[8]
"Sep"
>>> month_name_list.index("Feb")
1**
我们创建了一个名为month_name_list的列表,其中包含 12 个字符串项目。我们可以通过提供其位置来选择一个项目。我们还可以使用index()方法来定位列表中项目的索引。
Python 中的列表始终从位置零开始。元组和字符串也是如此。
如果集合中的项目数量是固定的 - 例如 RGB 颜色有三个值 - 那么我们可能会考虑使用tuple而不是list。如果项目数量会增长和变化,那么list集合比tuple集合更好。
- 我们将通过一个不是项目位置的键来识别集合中的项目吗? 一个例子可能包括字符串之间的映射 - 单词和表示这些单词频率的整数之间的映射,或者颜色名称和该颜色的 RGB 元组之间的映射。
当我们必须使用非位置键标识项目时,我们使用某种映射。内置映射是dict。有几个扩展可以添加更多功能:
**>>> scheme = {"Crimson": (220, 14, 60),
... "DarkCyan": (0, 139, 139),
... "Yellow": (255, 255, 00)}
>>> scheme['Crimson']
(220, 14, 60)**
在这个字典中,scheme,我们创建了从颜色名称到 RGB 颜色元组的映射。当我们使用一个键,例如"Crimson",我们可以检索绑定到该键的值。
- 考虑
set集合中项目的可变性和dict集合中的键。集合中的每个项目必须是不可变对象。数字、字符串和元组都是不可变的,可以收集到集合中。由于list、dict或set对象是可变的,它们不能作为集合中的项目。例如,无法构建list项目的set。
而不是创建list项目的set,我们可以将每个list项目转换为不可变的tuple。我们可以创建不可变的tuple项目的set。
同样,字典键必须是不可变的。我们可以使用数字、字符串或元组作为字典键。我们不能使用list、set或另一个可变映射作为字典键。
它是如何工作的...
Python 的每个内置集合都提供一组特定的独特功能。这些集合还提供了大量重叠的功能。对于刚接触 Python 的程序员来说,挑战在于识别每个集合的独特功能。
事实证明,collections.abc模块提供了一种通过内置集合的路线图。collections.abc模块定义了支持我们使用的具体类的抽象基类(ABC)。我们将使用这组定义中的名称来指导我们了解这些功能。
从 ABC 中,我们可以看到实际上有六种不同类型的集合:
-
集合:独特的特点是项目要么是成员,要么不是。这意味着无法处理重复项:
-
可变集合:
set集合 -
不可变集合:
frozenset集合 -
序列:独特的特点是项目提供了索引位置:
-
可变序列:
list集合 -
不可变序列:
tuple集合 -
映射:独特的特点是每个项目都有一个指向值的键:
-
可变映射:
dict集合 -
不可变映射:有趣的是,没有内置的冻结映射
Python 的库提供了大量这些核心集合类型的附加实现。我们可以在Python 标准库中看到许多这些。
collections模块包含许多内置集合的变体。这些包括:
-
namedtuple:为元组中的每个项目提供名称的tuple。使用rgb_color.red比rgb_color[0]更清晰一些。 -
deque:双端队列。它是一个可变序列,具有从每一端推送和弹出的优化。我们可以使用list做类似的事情,但deque更有效。 -
defaultdict:可以为缺失的键提供默认值的dict。 -
Counter:旨在计算键出现次数的dict。有时被称为多重集或袋子。 -
OrderedDict:保留创建键的顺序的dict。 -
ChainMap:将几个字典组合成单个映射的dict。
在Python 标准库中还有更多。我们还可以使用heapq模块,该模块定义了优先级队列实现。bisect模块包括快速搜索排序列表的方法。这使得列表的性能更接近于字典的快速查找。
还有更多...
我们可以查看这样的数据结构列表:en.wikipedia.org/wiki/List_of_data_structures。
有一些重要的摘要是数据结构的一部分。文章的不同部分提供了略有不同的数据结构摘要。我们将快速浏览四个分类。
-
数组:有变体实现提供类似的功能。Python 的
list结构是典型的,并且提供了类似于数组的链表实现的性能。 -
树:通常,树结构可以用来创建集合、顺序列表或键值映射。我们可以将树看作是一种实现技术,而不是具有独特特征集的数据结构。
-
哈希:Python 使用哈希来实现字典和集合。这导致速度快但内存消耗大。
-
图表:Python 没有内置的图表数据结构。然而,我们可以用一个字典来轻松表示图表结构,其中每个节点都有一个相邻节点的列表。
我们可以——稍微聪明一点——在 Python 中实现几乎任何类型的数据结构。要么内置结构具有基本特征,要么我们可以找到一个内置结构,可以被利用起来。
另请参阅
- 有关高级图形操作,请参阅
networkx.github.io。
构建列表-文字,附加和推导
如果我们决定创建一个使用项目位置的集合-list,我们有几种构建这个结构的方法。我们将看一些我们可以从单个项目构建list对象的方法。
在某些情况下,我们需要一个列表,因为它允许重复的值。许多统计操作不需要知道项目的位置。对于这个,多重集将是有用的,但我们没有这个作为内置结构;使用list而不是多重集是非常常见的。
准备工作
假设我们需要对一些文件大小进行一些统计分析。下面是一个简短的脚本,将为我们提供一些文件的大小:
**>>> import pathlib
>>> home = pathlib.Path('source')
>>> for path in home.glob('*/index.rst'):
... print(path.stat().st_size, path.parent)
2353 source/ch_01_numbers_strings_and_tuples
2889 source/ch_02_statements_and_syntax
2195 source/ch_03_functions
3094 source/ch_04_built_in_data_structures_list_tuple_set_dict
725 source/ch_05_user_inputs_and_outputs
1099 source/ch_06_basics_of_classes_and_objects
690 source/ch_07_more_advanced_class_design
1207 source/ch_08_functional_programming_features
926 source/ch_09_input_output_physical_format_logical_layout
758 source/ch_10_statistical_programming_and_linear_regression
615 source/ch_11_testing
521 source/ch_12_web_services
1320 source/ch_13_application_integration**
我们使用了pathlib.Path对象来表示文件系统中的目录。glob()方法扩展与给定模式匹配的所有名称。在这种情况下,我们使用了一个模式'*/index.rst'。我们可以使用for语句从文件的 OSstat数据中显示大小。
我们想要累积一个具有各种文件大小的list对象。从中我们可以计算总大小和平均大小。我们可以寻找看起来太大或太小的文件。
我们有四种创建list对象的方法:
- 我们可以使用一系列值围绕在
[]字符中来创建list的文字显示。它看起来像这样:[value, ...]。Python 需要匹配[和]来看到一个完整的逻辑行,因此文字可以跨越物理行。有关更多信息,请参阅第二章中的编写长行代码配方,语句和语法。
**[2353, 2889, 2195, 3094, 725,
1099, 690, 1207, 926, 758,
615, 521, 1320]**
-
我们可以使用
list()函数将其他数据集转换为列表。我们可以转换set,或dict的键,或dict的值。我们将在Slicing and dicing a list配方中看到一个更复杂的例子。 -
我们有一些
list方法,允许我们一次构建一个list。这些方法包括append(),extend()和insert()。我们将在本配方的使用 append()方法构建列表部分中查看append()。我们将在本配方的还有更多...部分中查看其他方法。 -
我们有生成器表达式,可以用来构建
list对象。一种生成器是列表推导。
如何做...
使用 append()方法构建列表
- 创建一个空列表,
[]:
**>>> file_sizes = []**
- 通过一些数据源进行迭代。使用
append()方法将项目附加到列表中:
**>>> home = pathlib.Path('source')
>>> for path in home.glob('*/index.rst'):
... file_sizes.append(path.stat().st_size)
>>> print(file_sizes)
[2353, 2889, 2195, 3094, 725, 1099, 690,
1207, 926, 758, 615, 521, 1320]
>>> print(sum(file_sizes))
18392**
我们使用路径的glob()方法来查找与给定模式匹配的所有文件。路径的stat()方法提供了包括大小st_size在内的 OS stat数据结构,以字节为单位。
当我们打印list时,Python 会以文字表示法显示它。如果我们需要复制并粘贴列表到另一个脚本中,这很方便。
非常重要的是要注意,append()方法不返回值。append()方法改变了list对象,并且不返回任何东西。
提示
通常,任何改变对象的方法都没有返回值。像append(),extend(),sort()和reverse()这样的方法没有返回值。它们调整list对象本身的结构。
append()方法不返回值。
它会改变list对象。
令人惊讶的是,经常会看到错误的代码,像这样:a = ['some', 'data'] a = a.append('more data') 这是错误的。这将把a设置为None。
正确的方法是这样的陈述,没有任何额外的赋值:
a.append('more data')
编写一个列表推导
列表推导的目标是创建一个对象,其语法角色类似于列表文字:
-
编写包围列表对象的
[]括号。 -
编写数据的来源。这将包括目标变量。请注意,末尾没有
:,因为我们不是在写一个完整的语句:
for path in home.glob('*/index.rst')
- 在这个表达式之前加上要评估的目标变量的每个值。同样,由于这是一个简单的表达式,我们不能在这里使用复杂的语句:
path.stat().st_size
for path in home.glob('*/index.rst')
在某些情况下,我们需要添加一个过滤器。这是在for子句之后的if子句。我们可以使生成器表达式非常复杂。
这是整个list对象:
**>>> [path.stat().st_size
... for path in home.glob('*/index.rst')]
[2353, 2889, 2195, 3094, 725, 1099, 690, 1207, 926, 758, 615, 521, 1320]**
现在我们已经创建了一个list对象,我们可以将其分配给一个变量,并对数据进行其他计算和总结。
列表推导包括一个生成器表达式,称为语言手册中的推导。生成器表达式是附加到for子句的数据表达式。由于这个生成器是一个表达式,而不是一个完整的语句,它有一些限制。数据表达式会被重复评估,并由for子句控制。
使用生成器表达式的列表函数
我们将创建一个使用生成器表达式的list函数:
-
编写包围生成器表达式的
list()函数。 -
我们将重用列表推导版本的步骤二和步骤三来创建一个生成器表达式。这是生成器表达式:
path.stat().st_size
for path in home.glob('*/index.rst')
这是整个列表对象:
**>>> list(path.stat().st_size
... for path in home.glob('*/index.rst'))
[2353, 2889, 2195, 3094, 725, 1099, 690, 1207, 926, 758, 615, 521, 1320]**
工作原理...
Python 的list对象具有动态大小。当添加或插入项目,或者使用另一个list扩展list时,数组的边界会调整。同样,当弹出或删除项目时,边界会收缩。我们可以非常快速地访问任何项目,访问速度不取决于列表的大小。
在一些罕见的情况下,我们可能需要创建一个具有给定初始大小的list,然后分别设置项目的值。我们可以使用类似于这样的列表推导来实现:
some_list = [None for i in range(100)]
这将创建一个初始大小为 100 个项目的列表,每个项目都是None。尽管很少需要这样做,因为列表可以根据需要增长。
列表推导语法和list()函数都会从生成器中消耗项目并将它们附加到创建一个新的list对象。
还有更多...
我们创建list对象的目标是能够对其进行总结。我们可以使用各种 Python 函数来实现这一点。以下是一些例子:
**>>> sizes = list(path.stat().st_size
... for path in home.glob('*/index.rst'))
>>> sum(sizes)
18392
>>> max(sizes)
3094
>>> min(sizes)
521
>>> from statistics import mean
>>> round(mean(sizes), 3)
1414.769**
我们已经使用了内置的sum(),min()和max()来生成这些文档大小的一些描述性统计数据。这些索引文件中哪一个是最小的?我们想知道值列表中最小值的位置。我们可以使用index()方法来实现:
**>>> sizes.index(min(sizes))
11**
我们已经找到了最小值,然后使用index()方法来找到该最小值的位置。请记住,索引值从零开始,因此最小的文件是第十二章的文件。
其他扩展列表的方法
我们还可以扩展列表,以及在列表的中间或开头插入。我们有两种方法来扩展列表:我们可以使用+运算符,也可以使用extend()方法。以下是一个创建两个列表并使用+将它们放在一起的示例:
**>>> ch1 = list(path.stat().st_size
... for path in home.glob('ch_01*/*.rst'))
>>> ch2 = list(path.stat().st_size
... for path in home.glob('ch_02*/*.rst'))
>>> len(ch1)
13
>>> len(ch2)
12
>>> final = ch1 + ch2
>>> len(final)
25
>>> sum(final)
104898**
我们已经创建了一个包含名称为ch_01*/*.rst的文档大小的列表。然后我们创建了一个包含稍有不同名称模式ch_02*/*.rst的文档大小的第二个列表。然后我们将这两个列表合并成一个最终列表。
我们也可以使用extend()方法来做到这一点。我们将重复使用这两个列表,并从中构建一个新列表:
**>>> final_ex = []
>>> final_ex.extend(ch1)
>>> final_ex.extend(ch2)
>>> len(final_ex)
25
>>> sum(final_ex)
104898**
我们注意到append()不返回值。请注意,extend()也不返回值。extend()方法会改变list对象。
我们还可以在列表中的任何特定位置之前插入一个值。insert()方法接受一个项目的位置;新值将在给定位置之前:
**>>> p = [3, 5, 11, 13]
>>> p.insert(0, 2)
>>> p
[2, 3, 5, 11, 13]
>>> p.insert(3, 7)
>>> p
[2, 3, 5, 7, 11, 13]**
我们已经向list对象插入了两个新值。与append()和extend()一样,insert()也不返回值。它会改变list对象。
另请参阅
-
请参阅切片和切块列表的方法,了解复制列表和从列表中选择子列表的方法。
-
请参阅从列表中删除 - 删除、移除、弹出和过滤的方法,以了解从列表中删除项目的其他方法。
-
在反转列表的副本的方法中,我们将研究如何反转列表。
-
本文介绍了 Python 集合内部工作的一些见解:
wiki.python.org/moin/TimeComplexity。在查看表格时,重要的是要注意O(1)表示成本基本上是恒定的,而O(n)表示成本随着我们尝试处理的项目的索引而变化。这意味着成本随着集合的大小而增加。
切片和切块列表
有许多时候我们想从列表中挑选项目。最常见的一种处理方式是将列表的第一项视为特殊情况。这导致了一种头尾处理,我们将列表的头部与列表尾部的项目区别对待。
我们还可以使用这些技术来制作列表的副本。
准备就绪
我们有一个用于记录大型帆船燃油消耗的电子表格。它的行看起来像这样:
| 日期 | 发动机启动 | 燃油高度 |
|---|---|---|
| 发动机关闭 | ||
| 其他注意事项 | ||
| 10/25/2013 08:24 29 | ||
| 13:15 27 | ||
| 风平浪静 - 锚在所罗门岛 | ||
| 10/26/2013 09:12 27 | ||
| 18:25 22 | ||
| 颠簸 - 锚在杰克逊溪 |
燃油高度?是的。没有浮标传感器来估计油箱中的燃油水平。相反,有一个视觉量规,可以直接观察燃油。它以深度英寸为单位进行校准。在实际情况下,油箱是矩形的,因此显示的深度可以很容易地转换为体积 - 31 英寸的深度约为 75 加仑。
重要的是,电子表格数据没有得到适当的规范化。理想情况下,每行都遵循数据的第一正规形式,每行具有相同的内容,每个单元格只有原子值。
我们的数据没有得到适当的规范化。我们有四行标题。这是csv模块无法直接处理的。我们需要做一些切片来删除其他注意事项中的行。我们希望将每天旅行的两行合并在一起,以便更容易计算经过的时间和使用的英寸数。
我们可以这样读取数据:
**>>> from pathlib import Path
>>> import csv
>>> with Path('code/fuel.csv').open() as source_file:
... reader = csv.reader(source_file)
... log_rows = list(reader)
>>> log_rows[0]
['date', 'engine on', 'fuel height']
>>> log_rows[-1]
['', "choppy -- anchor in jackson's creek", '']**
我们使用了csv模块来读取日志详情。csv.reader()是一个可迭代对象。为了将项目收集到一个单独的列表中,我们应用了list()函数。我们查看了列表中的第一个和最后一个项目,以确认我们确实有一个列表的列表结构。
原始 CSV 文件的每一行都是一个列表。这些列表中的每一个都是一个三项子列表。
对于这个食谱,我们将使用列表索引表达式的扩展来从列表中切片项目。切片和索引一样,跟在[]字符后面。Python 为我们提供了几种切片表达式的变体。切片可以包括两个或三个值,用:字符分隔。我们可以写:stop,start:,start:stop,start:stop:step,或者其他几种变体。默认的步长值是一。默认的起始值是列表的开头,默认的停止值是列表的结尾。
这是我们如何切片和处理原始的行列表,以挑选出我们需要的行:
如何做...
- 我们需要做的第一件事是从行列表中删除四行标题。我们将使用两个部分切片表达式来在第四行处分割列表:
**>>> head, tail = log_rows[:4], log_rows[4:]
>>> head[0]
['date', 'engine on', 'fuel height']
>>> head[-1]
['', '', '']
>>> tail[0]
['10/25/13', '08:24:00 AM', '29']
>>> tail[-1]
['', "choppy -- anchor in jackson's creek", '']**
我们使用log_rows[:4]和log_rows[4:]将列表切片成两个部分。head变量将包含四行标题。我们实际上不想对头部进行任何处理,所以我们忽略了那个变量。然而,tail变量有我们关心的表的行。
- 我们将使用带步长的切片来挑选有趣的行。切片的
[start::step]版本将根据步长值选择行。在我们的情况下,我们将取两个切片。一个切片从第零行开始,另一个切片从第一行开始。
这里是每三行的一个切片,从第零行开始:
**>>> tail[0::3]
[['10/25/13', '08:24:00 AM', '29'],
['10/26/13', '09:12:00 AM', '27']]**
我们还想要每三行,从第一行开始:
**>>> tail[1::3]
[['', '01:15:00 PM', '27'], ['', '06:25:00 PM', '22']]**
- 然后这两个切片可以被合并在一起:
**>>> list( zip(tail[0::3], tail[1::3]) )
[(['10/25/13', '08:24:00 AM', '29'], ['', '01:15:00 PM', '27']),
(['10/26/13', '09:12:00 AM', '27'], ['', '06:25:00 PM', '22'])]**
我们将列表切片成了两个并行的组:
-
[0::3]切片从第一行开始,包括每三行。这将是第零行,第三行,第六行,第九行,依此类推。 -
[1::3]切片从第二行开始,包括每三行。这将是第一行,第四行,第七行,第十行,依此类推。
我们使用了zip()函数来交错这两个列表中的序列。这给了我们一个非常接近我们可以处理的三个元组的序列。
- 展平结果:
**>>> paired_rows = list( zip(tail[0::3], tail[1::3]) )
>>> [a+b for a,b in paired_rows]
[['10/25/13', '08:24:00 AM', '29', '', '01:15:00 PM', '27'],
['10/26/13', '09:12:00 AM', '27', '', '06:25:00 PM', '22']]**
我们使用了来自构建列表-文字,附加和理解食谱的列表理解,将每对行中的两个元素组合成一个单独的行。现在我们可以将日期和时间转换为单个的datetime值。然后我们可以计算时间差来得到船的运行时间,以及计算高度差来估算燃烧的燃料。
它是如何工作的...
切片操作符有几种不同的常见形式:
-
[:]:起始和结束被隐含。表达式S[:]将复制序列S。 -
[:stop]:这将从开头创建一个新的列表,直到停止值之前。 -
[start:]:这将从给定的起始位置创建一个新的列表,直到序列的末尾。 -
[start:stop]:这将从起始索引开始选择一个子列表,并在停止索引之前停止。Python 使用半开区间。起始值包括在内,结束值不包括在内。 -
[::step]:起始和结束被隐含,并包括整个序列。步长-通常不等于一-意味着我们将使用步长从起始位置跳过列表。对于给定的步长s和大小为|L|的列表,索引值为
。 -
[start::step]:给出了起始值,但结束值被隐含。这个想法是起始值是一个偏移量,步长适用于该偏移量。对于给定的起始值a,步长s,和大小为|L|的列表,索引值为
。 -
[:stop:step]:这用于防止处理列表中的最后几个项目。由于给定了步长,处理从零开始。 -
[start:stop:step]:这将从序列的子集中选择元素。开始之前和结束之后的项目将不会被使用。
切片技术适用于列表、元组、字符串和任何其他类型的序列。这不会导致对象被改变;这将复制项目。
还有更多...
在Reversing a copy of a list方法中,我们将看到对切片表达式的更复杂的使用。
这个复制被称为浅复制,因为我们将有两个包含对相同基础对象的引用的集合。我们将在Making shallow and deep copies of objects方法中详细讨论这一点。
对于这个特定的例子,我们有另一种将多行数据重组为单行数据的方法。我们可以使用一个生成器函数。我们将在第八章中看到函数式编程技术,函数式和反应式编程特性。
另请参阅
-
查看Building lists – literals, appending, and comprehensions方法以了解创建列表的方法
-
查看Deleting from a list – deleting, removing, popping, and filtering方法以了解从列表中移除项目的其他方法
-
在Reversing a copy of a list方法中,我们将看到对列表进行反转
从列表中删除项目 – 删除、移除、弹出和过滤
有很多时候我们想要从list集合中移除项目。我们可能会从列表中删除项目,然后处理剩下的项目。
删除不需要的项目会产生与使用filter()创建仅包含所需项目的副本类似的效果。区别在于,过滤后的副本将使用比从列表中删除项目更多的内存。我们将展示从列表中移除不需要的项目的这两种技术。
准备工作
我们有一个用于记录大型帆船燃油消耗的电子表格。它的行看起来像这样:
| 日期 | 引擎开启 | 燃油高度 |
|---|---|---|
| 引擎关闭 | ||
| 其他说明 | ||
| 10/25/2013 | 08:24 | 29 |
| 13:15 | 27 | |
| 平静的海域—锚所罗门岛 | ||
| 10/26/2013 | 09:12 | 27 |
| 18:25 | 22 | |
| 波涛汹涌—锚在杰克逊溪 |
有关此数据的更多背景信息,请参阅Slicing and dicing a list方法。
我们可以这样读取数据:
**>>> from pathlib import Path
>>> import csv
>>> with Path('code/fuel.csv').open() as source_file:
... reader = csv.reader(source_file)
... log_rows = list(reader)
>>> log_rows[0]
['date', 'engine on', 'fuel height']
>>> log_rows[-1]
['', "choppy -- anchor in jackson's creek", '']**
我们使用csv模块读取日志详情。csv.reader()是一个可迭代对象。为了将项目收集到一个单一列表中,我们应用了list()函数。我们查看了列表中的第一个和最后一个项目,以确认我们确实有一个列表的列表结构。
原始 CSV 文件的每一行都是一个列表。这些列表中的每一个都有三个项目。
如何做...
我们将看到从列表中删除项目的四种方法:
-
del语句 -
remove()方法 -
pop()方法 -
使用
filter()函数创建一个拒绝选定行的副本
从列表中删除项目
我们可以使用del语句从列表中移除项目。
为了方便在交互提示符下跟随示例,我们将复制列表。如果我们从原始的log_rows列表中删除行,后续的示例可能会难以跟随。在实际程序中,我们不会做这个额外的复制。我们也可以使用log_rows[:]来复制原始列表。
**>>> tail = log_rows.copy()**
del语句的样子如下:
**>>> del tail[:4]
>>> tail[0]
['10/25/13', '08:24:00 AM', '29']
>>> tail[-1]
['', "choppy -- anchor in jackson's creek", '']**
del语句从尾部删除了标题行,留下了我们真正需要处理的行。然后我们可以使用Slicing and dicing a list方法将它们合并并进行总结。
remove()方法
我们可以使用remove()方法从列表中移除项目。这会从列表中移除匹配的项目。
我们可能有一个看起来像这样的列表:
**>>> row = ['10/25/13', '08:24:00 AM', '29', '', '01:15:00 PM', '27']**
这里有一个无用的''字符串:
**>>> row.remove('')
>>> row
['10/25/13', '08:24:00 AM', '29', '01:15:00 PM', '27']**
请注意,remove()方法不返回值。它会直接改变列表。这是一个适用于可变对象的重要区别。
提示
remove()方法不返回值。
它改变了列表对象。
看到这样错误的代码实际上是非常常见的:
a = ['some', 'data']
a = a.remove('data')
这是绝对错误的。这将把a设置为None。
pop()方法
我们可以使用pop()方法从列表中删除项目。这将根据它们的索引从列表中删除项目。
我们可能有一个看起来像这样的列表:
**>>> row = ['10/25/13', '08:24:00 AM', '29', '', '01:15:00 PM', '27']**
这里有一个无用的''字符串:
**>>> target_position = row.index('')
>>> target_position
3
>>> row.pop(target_position)
''
>>> row
['10/25/13', '08:24:00 AM', '29', '01:15:00 PM', '27']**
请注意,pop()方法有两个作用:
-
它改变了
list对象 -
它返回被移除的值
filter()函数
我们还可以通过构建传递合适项目并拒绝不合适项目的副本来删除项目。以下是我们如何使用filter()函数来实现这一点。
- 识别我们希望通过或拒绝的项目的特征。
filter()函数期望通过数据的规则。该函数的逻辑反函数将拒绝数据。
在我们的情况下,我们希望的行在第二列中有一个数值。我们可以通过一个小的辅助函数最好地检测到这一点。
- 编写过滤测试函数。如果很简单,可以使用 lambda 对象。否则,编写一个单独的函数:
**>>> def number_column(row, column=2):
... try:
... float(row[column])
... return True
... except ValueError:
... return False**
我们使用内置的float()函数来查看给定的字符串是否是一个合适的数字。如果float()函数没有引发异常,则数据是有效的数字,我们希望通过这一行。如果引发了异常,则数据不是数字,我们将拒绝这一行。
- 使用
filter()函数中的数据测试函数(或 lambda):
**>>> tail_rows = list(filter(number_column, log_rows))
>>> len(tail_rows)
4
>>> tail_rows[0]
['10/25/13', '08:24:00 AM', '29']
>>> tail_rows[-1]
['', '06:25:00 PM', '22']**
我们提供了我们的测试,number_column()和原始数据,log_rows。filter()函数的输出是一个可迭代对象。为了从可迭代结果创建一个列表,我们将使用list()函数。结果只有我们想要的四行;其余的行被拒绝了。
我们并没有真正删除行。我们创建了一个省略这些行的副本。最终结果是一样的。
它是如何工作的...
因为列表是一个可变对象,我们可以从列表中删除项目。这种技术对于元组或字符串不起作用。这三个集合都是序列,但只有列表是可变的。
我们只能删除列表中存在的索引的项目。如果我们尝试删除超出允许范围的索引的项目,将会得到IndexError异常。
例如:
**>>> row = ['', '06:25:00 PM', '22']
>>> del row[3]
Traceback (most recent call last):
File "<pyshell#38>", line 1, in <module>
del row[3]
IndexError: list assignment index out of range**
还有更多...
有时这种方法不起作用。如果我们在for语句中使用列表,我们无法从列表中删除项目。
假设我们想要从列表中删除所有偶数项。以下是一个不正常工作的示例:
**>>> data_items = [1, 1, 2, 3, 5, 8, 10,
... 13, 21, 34, 36, 55]
>>> for f in data_items:
... if f%2 == 0: data_items.remove(f)
>>> data_items
[1, 1, 3, 5, 10, 13, 21, 36, 55]**
结果显然不正确。为什么列表中还有一些偶数值的项目?
让我们看看在处理值为八的项目时会发生什么。我们将执行remove()方法。该值将被移除,并且所有后续的值将向前滑动一个位置。10将被移动到以前由8占据的位置。列表的内部索引将向前移动到下一个位置,该位置将有一个13。10将永远不会被处理。
如果我们在列表的中间插入,在for循环中的驱动可迭代对象中也会发生不好的事情。在这种情况下,项目将被处理两次。
我们有两种方法可以避免跳过-删除问题:
- 制作列表的副本:
for f in data_items[:]:
- 使用
while循环和手动索引:
**>>> data_items = [1, 1, 2, 3, 5, 8, 10,
... 13, 21, 34, 36, 55]
>>> position = 0
>>> while position != len(data_items):
... f= data_items[position]
... if f%2 == 0:
... data_items.remove(f)
... else:
... position += 1
>>> data_items
[1, 1, 3, 5, 13, 21, 55]**
我们设计了一个循环,只有在项目为奇数时才增加位置。如果项目是偶数,则将其删除,并将其他项目向列表中的下一个位置移动。
另请参阅
-
有关创建列表的方法,请参阅构建列表-文字,附加和理解配方
-
有关从列表中复制列表和从列表中选择子列表的方法,请参阅切片和切块列表配方
-
在反转列表的副本配方中,我们将研究如何反转列表
反转列表的副本
偶尔,我们需要反转list集合中项目的顺序。例如,一些算法产生的结果是倒序的。我们将看看数字转换为特定基数时通常是如何从最低位到最高位生成的。我们通常希望以最高位数字优先显示值。这导致需要反转列表中数字的顺序。
我们有两种方法来反转一个列表。首先是reverse()方法。然后是这个方便的技巧。
准备工作
假设我们正在进行数字基数之间的转换。我们将看看一个数字在一个基数中是如何表示的,以及我们如何从一个数字计算出那个表示。
任何值v都可以定义为给定基数b中各个数字d[n]的多项式函数:
v = d[n] × b^n + d[n] [-1] × b^n ^(-1) + d[n] [-2] × b^n ^(-2) + ... + d [1] × b + d [0]
有理数有有限数量的数字。无理数将有无限系列的数字。
例如,数字0xBEEF是一个 16 进制值。数字是{B = 11, E = 14, F = 15},基数b = 16。
48879 = 11 × 16³ + 14 × 16² + 14 × 16 + 15
我们可以重新陈述这个形式,这样计算起来稍微更有效率一些:
v = (...(( d[n] × b + d[n] [-1] ) × b + d[n] [-2] ) × b + ... + d [1] ) × b + d [0]
有许多情况下,基数不是某个数字的一致幂。例如,ISO 日期格式涉及每周 7 天,每天 24 小时,每小时 60 分钟和每分钟 60 秒的混合基数。
给定一个周数、一周中的某一天、一个小时、一分钟和一秒,我们可以计算给定年份内的秒级时间戳t[s]。
t[s] = ((( w × 7 + d ) × 24 + h ) × 60 + m ) × 60 + s
例如:
**>>> week = 13
>>> day = 2
>>> hour = 7
>>> minute = 53
>>> second = 19
>>> t_s = (((week*7+day)*24+hour)*60+minute)*60+second
>>> t_s
8063599**
我们如何反转这个计算?我们如何从整体时间戳中获取各个字段?
我们需要使用divmod风格的除法。有关背景,请参阅选择真除法和地板除法之间的配方。
将秒级时间戳t[s]转换为单独的周、天和时间字段的算法如下:
t[m],s ← t[s] /60, t[s] mod 60
t[h],m ← t[m] /60, t[m] mod 60
t[d],h ← t[h] /60, t[h] mod 24
w,d ← t[d] /60, t[d] mod 7
这有一个方便的模式,可以导致一个非常简单的实现。它有一个产生值的顺序相反的后果:
**>>> t_s = 8063599
>>> fields = []
>>> for b in 60, 60, 24, 7:
... t_s, f = divmod(t_s, b)
... fields.append(f)
>>> fields.append(t_s)
>>> fields
[19, 53, 7, 2, 13]**
我们已经应用了divmod()函数四次,从以秒为单位给定的时间戳中提取秒、分钟、小时、天和周。这些顺序是错误的。我们如何将它们反转?
如何做...
我们有两种方法:我们可以使用reverse()方法,或者我们可以使用[::-1]切片表达式。这是reverse()方法:
**>>> fields_copy1 = fields.copy()
>>> fields_copy1.reverse()
>>> fields_copy1
[13, 2, 7, 53, 19]**
我们制作了原始列表的副本,这样我们就可以保留一个未改变的副本,以便与改变后的副本进行比较。这样更容易跟踪示例。我们应用了reverse()方法来反转列表的副本。
这将改变列表。与其他变异方法一样,它不会返回一个有用的值。使用类似这样的语句是错误的:a = b.reverse()。a的值将始终是None。
这是一个带有负步长的切片表达式:
**>>> fields_copy2 = fields[::-1]
>>> fields_copy2
[13, 2, 7, 53, 19]**
在这个例子中,我们做了一个切片[::-1],它使用了一个隐含的开始和结束,步长为-1。这会选择列表中所有项目的倒序来创建一个新列表。
这个slice操作绝对不会改变原始列表。这会创建一个副本。检查fields变量的值,看看它是否没有改变。
它是如何工作的...
正如我们在切片和切块列表配方中指出的,切片表示法非常复杂。使用负步长的切片将创建一个副本(或子集),其中的项目按从右到左的顺序处理,而不是默认的从左到右的顺序。
重要的是要区分这两种方法:
-
reverse()函数修改了list对象本身。与append()和remove()等方法一样,这个方法没有返回值。因为它改变了列表,所以不会返回值。 -
[::-1]切片表达式创建一个新的列表。这是原始列表的浅复制,顺序被颠倒。
另请参阅
-
有关浅复制和深复制对象的更多信息,请参阅制作浅复制和深复制对象食谱,了解浅复制是什么,以及为什么我们可能需要进行深复制。
-
有关创建列表的方法,请参阅构建列表-文字,附加和理解食谱
-
有关从列表中复制列表和选择子列表的方法,请参阅切片和切块列表食谱
-
有关从列表中删除项目的其他方法,请参阅从列表中删除-删除,移除,弹出和过滤食谱
使用集合方法和运算符
我们有几种方法来构建set集合。我们可以使用set()函数将现有集合转换为集合。我们可以使用add()方法将项目放入集合。我们还可以使用update()方法和并集运算符|来从其他集合创建一个更大的集合。
我们将展示一个使用set来显示我们是否已经从统计数据池中看到了完整值域的食谱。该食谱将在扫描样本时构建一个set集合。
在进行探索性数据分析时,我们需要回答一个问题:这些数据是随机的吗?许多数据集中的数据方差是普通噪音。重要的是不要浪费时间对随机数进行复杂的建模和分析。
对于离散或连续的数值数据,如水的深度(以米为单位)或文件的大小(以字节为单位),我们可以使用平均值和标准偏差来查看给定数据集是否是随机的。我们期望样本的均值在由标准偏差测量的边界内与总体均值相匹配。
对于分类数据,如客户 ID 号码或电话号码,我们无法计算平均值或标准偏差。这些值必须以不同的方式进行评估。
确定分类数据的随机性的一种技术是优惠券收集者测试。通过这个测试,我们将看到在找到完整的优惠券集之前必须检查多少项目。顾客访问的顺序是随机的吗?还是在访问顺序中有其他分布?如果数据不是随机的,那么我们可以投资更多的研究来了解原因。
Python 的set集合对于这个工作至关重要。我们将向set添加项目,直到我们至少见过每个客户一次。
如果客户随机到达,我们可以预测在企业至少见过每个客户之前的预期访问次数。整个域的预期到达时间是域中每个客户的到达时间之和。这等于客户数量n乘以第 n 个调和数H[n]:
E = n × H[n] = n × ((1/1) + (1/2) + (1/3) + (1/ n ))
这是所有客户被看到之前的预期平均访问次数。如果实际平均到达时间与预期相匹配,这意味着所有客户都在访问;我们不需要再浪费时间研究符合我们期望的数据。如果实际平均值与预期不符,则一些客户访问的频率不如其他客户频繁,我们需要深入研究原因。
准备就绪
我们将使用 Python 的set来表示优惠券的集合。我们需要一个可能(或可能不)具有正确分布的优惠券的数据集。我们将查看一个包含八个客户的集合。
这是一个模拟顾客以随机顺序到达的函数。顾客以半开区间[0,n]中的数字表示,我们可以说所有顾客c符合规则 0 ≤ c < n。
**>>> import random
>>> def arrival1(n=8):
... while True:
... yield random.randrange(n)**
arrival1()函数将产生一个无限序列的值。我们在末尾加上了1,这可能看起来像是拼写错误,但我们使用了1后缀,以便我们可以创建替代实现。
我们需要对生成的值的数量设置一个上限。以下是一个具有生成样本数量上限的函数:
**>>> def samples(limit, generator):
... for n, value in enumerate(generator):
... if n == limit: break
... yield value**
这个生成函数使用另一个生成器作为项目的来源。这个想法是我们将使用arrival1()函数。samples()函数枚举了来自更大集合的项目,并在看到足够的项目时停止。由于arrival1()函数是无限的,这个边界是必不可少的。
以下是我们如何使用这些函数来模拟顾客的到达。我们将产生一系列顾客 ID 号码:
**>>> random.seed(1)
>>> list(samples(10, arrival1()))
[2, 1, 4, 1, 7, 7, 7, 6, 3, 1]**
我们强制随机数生成器具有特定的种子值,以便我们可以产生一个已知的测试序列。我们将samples()函数应用于arrival1()函数,以产生一个包含 10 次顾客访问的序列。第七位顾客似乎有很多重复的业务。顾客零和五根本没有出现。
这只是数据的模拟。企业将使用销售收据来确定顾客访问。网站可能会在数据库中记录访问,或者可能会抓取网络日志来确定实际值的序列。
在我们看到所有八个顾客之前,预期的访问次数是多少?
**>>> from fractions import Fraction
>>> def expected(n=8):
... return n * sum(Fraction(1,(i+1)) for i in range(n))**
这个函数创建了一系列分数 1/1,1/2,直到 1/n。这些分数被求和并乘以n。
**>>> expected(8)
Fraction(761, 35)
>>> round(float(expected(8)))
22**
平均来说,我们需要 22 次顾客访问才能看到我们的八个顾客中的所有人一次。
我们如何使用set集合来统计在我们看到所有八个顾客之前的实际访问次数?
如何做...
当我们逐个顾客访问时,我们将把顾客 ID 放入一个set集合中。重复项不会保存在集合中。一旦顾客 ID 成为集合的成员,再次添加该值不会改变集合。我们将总结这个步骤,然后展示完整的函数:
-
从一个空的
set和一个零计数器开始。 -
开始一个
for循环来访问所有数据项。 -
将下一个项目添加到
set中。计数器加一。 -
如果
set已经完成,可以产生计数。这是需要看到完整集合的顾客数量。产生后,清空set并将计数器初始化为零,以准备下一个顾客。
以下是函数:
def coupon_collector(n, data):
count, collection = 0, set()
for item in data:
count += 1
collection.add(item)
if len(collection) == n:
yield count
count, collection = 0, set()
这将从零开始计数,并创建一个空集collection,我们将收集顾客 ID。我们将逐个遍历源数据值序列data中的每个项目。count的值显示了有多少访客。变量collection的值是不同访客的集合。
set的add()方法将改变集合以添加一个不同的值。如果该值已经在集合中,则内容不会发生变化。
当集合的大小等于我们的目标人口的大小时,我们就有了一个完整的优惠券集合。我们可以产生count的值。我们还重置了访问计数,并为我们的优惠券集合创建了一个新的空集。
工作原理...
由于这是一个生成器,我们需要通过从结果创建一个list对象来捕获数据。以下是我们如何使用coupon_collector()函数:
from statistics import mean
expected_time = float(expected(n))
data = samples(100, arrival1())
wait_times = list(coupon_collector(n, data))
average_time = mean(wait_times)
我们已经计算了看到所有n个顾客的预期时间。我们使用samples(100, arrival1())作为模拟来创建data变量,其中包含一系列访问。在现实生活中,我们会分析销售收据来收集这一系列访问。
我们对数据应用了收集者测试。这产生了一个值序列,显示了需要多少客户到达才能创建一个完整的 优惠券 或客户 ID 的集合。这个计数序列应该接近预期的访问次数。我们将这个序列分配给变量 wait_times,因为我们测量了在看到我们样本集中的所有客户之前需要等待的时间。
这让我们可以轻松地将实际数据与预期数据进行比较。我们刚刚看到的函数 arrival1() 产生的平均值与预期值非常接近。由于输入数据是随机的,模拟不会产生与预期完全匹配的值。
收集者测试依赖于收集一组优惠券。在这种情况下,术语 set 是用于精确的数学形式化来最好地表示数据。
给定的项目要么是集合的成员,要么不是。我们不能将其添加到集合中超过一次。例如,我们可以手动创建一个集合并向其添加一个项目:
**>>> collection = set()
>>> collection.add(1)
>>> collection
{1}**
当我们尝试再次添加这个项目时,set 的值不会改变。
**>>> collection.add(1)
>>> collection
{1}
>>> 1 in collection
True**
这是收集优惠券的完美数据表示。
请注意,add() 方法不会返回一个值。它改变了 set 对象。这类似于 list 集合的方法工作方式。通常,改变集合的方法不会返回一个值。唯一的例外是 pop() 方法,它既改变了 set 对象,又返回了弹出的值。
还有更多...
我们有几种方法可以向 set 添加项目:
-
示例使用了
add()方法。这适用于单个项目。 -
我们可以使用
union()方法。这类似于一个运算符——它创建一个新的结果set。它不会改变任何一个操作数集合。 -
我们可以使用
|并集运算符来计算两个集合的并集。 -
我们可以使用
update()方法从另一个集合中更新一个集合。这会改变一个集合,并且不会返回一个值。
对于大多数情况,我们需要从要添加的项目创建一个单例 set。以下是将单个项目 3 添加到一个集合中的示例:
**>>> collection
{1}
>>> item = 3
>>> collection.union( {item} )
{1, 3}
>>> collection
{1}**
在这里,我们从变量 item 的值创建了一个单例集合 {item}。然后我们使用了 union() 方法来计算一个新的集合,即 collection 和 {item} 的并集。
请注意,union() 返回一个结果对象,并且不会改变原始的 collection 集合。我们需要使用 collection = collection.union({item}) 来更新 collection 对象。
这是另一种使用并集运算符 | 的替代方法:
**>>> collection = collection | {item}
>>> collection
{1, 3}**
这与 {1, 3} ∪ {3} ≡ {1, 3} 的常见数学表示法相似。
我们也可以使用 update() 方法:
**>>> collection.update( {4} )
>>> collection
{1, 3, 4}**
这个方法改变了 set 对象。因为它改变了集合,所以它不会返回一个值。
Python 有许多集合运算符。这些是我们可以在复杂表达式中使用的普通运算符符号:
-
|用于并集,通常排版为 A ∪ B -
&用于交集,通常排版为 A ∩ B -
^用于对称差,通常排版为 A Δ B -
-用于减法,通常排版为 A - B
另请参阅
- 在 从集合中移除项目 - remove、pop 和 difference 配方中,我们将看看如何通过移除或替换项目来更新一个集合
从集合中移除项目 - remove()、pop() 和 difference
Python 给了我们几种方法来从 set 集合中移除项目。我们可以使用 remove() 方法来移除特定的项目。我们可以使用 pop() 方法来移除一个任意的项目。
此外,我们可以使用集合交集、差集和对称差运算符 &、- 和 ^ 来计算一个新的集合,这个新的集合是给定输入集合的子集。
准备工作
有时我们会有包含复杂和各种格式的行的日志文件。这是一个来自长而复杂的日志的小片段:
**>>> log = '''
... [2016-03-05T09:29:31-05:00] INFO: Processing ruby_block[print IP] action run (@recipe_files::/home/slott/ch4/deploy.rb line 9)
... [2016-03-05T09:29:31-05:00] INFO: Installed IP: 111.222.111.222
... [2016-03-05T09:29:31-05:00] INFO: ruby_block[print IP] called
...
... - execute the ruby block print IP
... [2016-03-05T09:29:31-05:00] INFO: Chef Run complete in 23.233811181 seconds
...
... Running handlers:
... [2016-03-05T09:29:31-05:00] INFO: Running report handlers
... Running handlers complete
... [2016-03-05T09:29:31-05:00] INFO: Report handlers complete
... Chef Client finished, 2/2 resources updated in 29.233811181 seconds
... '''**
我们需要在日志中找到 IP: 111.222.111.222 行。
这是我们将要做的:
**>>> import re
>>> pattern = re.compile(r"IP: \d+\.\d+\.\d+\.\d+")
>>> matches = set( pattern.findall(log) )
>>> matches
{'IP: 111.222.111.222'}**
较大日志文件的问题在于目标行中有真实信息的地方。这些与看似相似但只是示例的行混在一起。我们还会发现像IP: 1.2.3.4这样的行,这是无关紧要的输出。事实证明,有几种这些我们想要忽略的无关紧要的行。
这是集合交集和集合减法非常有帮助的地方。
如何做...
- 创建一个我们想要忽略的项目集:
**>>> to_be_ignored = {'IP: 0.0.0.0', 'IP: 1.2.3.4'}**
- 收集日志中的所有条目。我们将使用
re模块进行此操作,如前所示。假设我们的数据包括来自日志其他部分的良好地址以及虚拟和占位符地址:
**>>> matches = {'IP: 111.222.111.222', 'IP: 1.2.3.4'}**
- 使用集合减法形式从匹配集中删除项目。以下是两个示例:
**>>> matches - to_be_ignored
{'IP: 111.222.111.222'}
>>> matches.difference(to_be_ignored)
{'IP: 111.222.111.222'}**
请注意,这两者都是返回新集合作为其结果的运算符。这两者都不会改变基础集合对象。
我们经常在这样的语句中使用这些:
**>>> valid_matches = matches - to_be_ignored
>>> valid_matches
{'IP: 111.222.111.222'}**
这将把结果集分配给一个新变量valid_matches,这样我们就可以对这个新集合进行所需的处理。
在这种情况下,如果项目不在集合中,它不会引发KeyError异常。
它是如何工作的...
set对象仅跟踪成员资格。项目要么在set中,要么不在set中。我们指定要删除的项目。删除项目不依赖于索引位置或键值。
因为我们有set运算符,我们可以从目标set中删除任何set中的项目。我们不需要逐个处理项目。
还有更多...
我们有几种方法可以从集合中删除项目:
-
在示例中,我们使用了
difference()方法和-运算符。difference()方法的行为类似于运算符,并创建一个新的集合。 -
我们还可以使用
difference_update()方法。这将就地改变一个集合。它不返回值。 -
可以使用
remove()方法删除单个项目。 -
我们还可以使用
pop()方法删除任意项目。这在这个示例中并不适用得很好,因为我们无法控制弹出哪个项目。
difference_update()方法的外观如下:
**>>> valid_matches = matches.copy()
>>> valid_matches.difference_update( to_be_ignored )
>>> valid_matches
{'IP: 111.222.111.222'}**
首先,我们复制了原始的matches集合。这创建了一个新的集合,我们将其分配给valid_matches集合。然后,我们应用了difference_update()方法,从这个集合中删除了不需要的项目。
由于集合被改变,因此不会返回任何值。而且,由于集合是一个副本,这不会修改原始的matches集合。
我们可以这样使用remove()方法。请注意,如果集合中不存在项目,remove()将引发异常。
**>>> valid_matches = matches.copy()
>>> for item in to_be_ignored:
... if item in valid_matches:
... valid_matches.remove(item)
>>> valid_matches
{'IP: 111.222.111.222'}**
我们测试了一下valid_matches集合中是否存在该项目,然后再尝试删除它。这是避免引发KeyError异常的一种方法。另一种方法是使用try:语句来消除异常。
pop()方法删除一个任意项目。它既改变了集合,又返回了被删除的项目。如果我们尝试从空集合中弹出项目,我们将引发KeyError异常。
另请参见
- 在使用集合方法和运算符配方中,我们将看看创建集合的其他方法

创建字典-插入和更新
字典是 Python 映射的一种。内置类型dict类提供了许多常见功能。在collections模块中定义了这些功能的一些常见变体。
正如我们在选择数据结构配方中所指出的,当我们有一些需要映射到给定值的键时,我们将使用字典。例如,我们可能希望将单词映射到单词的长而复杂的定义。或者将某个值映射到数据集中该值出现的次数。
键和计数字典非常常见。我们将看一个详细的配方,展示如何初始化字典并更新计数器。
在 使用集合方法和运算符 配方中,我们研究了客户到达企业的情况。在那个配方中,我们使用了一个集合来确定企业在收集完整的访问集之前需要多少次访问。
准备工作
在这个配方中,我们将看看如何创建一个显示每个客户访问次数的直方图。为了创建一些有趣的数据,我们将修改在其他配方中使用的样本生成器。
在早期的例子中,我们使用了一个简单的均匀随机数生成器来选择客户的顺序。这是选择生成具有稍微不同分布的随机数的客户的另一种方法:
**>>> def arrival2(n=8):
... p = 0
... while True:
... step = random.choice([-1,0,+1])
... p += step
... yield abs(p) % n**
这使用了一种称为随机游走的技术来生成下一个客户 ID 号。它将从零开始,然后进行三种更改之一。它可能使用相同的客户或两个相邻的客户号中的一个。使用表达式 abs(p) % n 允许我们计算任何整数值,并将数字 p 映射到范围 0 ≤ p < n。
这是一个工具,可以生成一些数据,我们可以用来模拟客户到达:
>>> import random
>>> from ch04_r06 import samples, arrival2
>>> random.seed(1)
>>> list( samples(10, arrival2(8)) )
[1, 0, 1, 1, 2, 2, 2, 2, 1, 1]
这向我们展示了 arrival2() 函数如何模拟倾向于围绕客户零的起始值聚集的客户。如果我们在 使用集合方法和运算符 配方中使用这个来进行优惠券收集器测试,我们会发现这个生成器创建的样本数据在这个测试中表现得非常糟糕。这种聚集到达时间意味着我们必须在收集到所有八个不同的客户之前看到非常多的客户。
直方图统计每个客户的出现次数。我们将使用字典将客户 ID 映射到我们见过客户的次数。
如何做...
- 使用
{}创建一个空字典。我们也可以使用dict()创建一个空字典。由于我们将创建一个统计每个客户到达次数的直方图,我们将称其为histogram:
histogram = {}
-
对于每个客户号,如果是新的,则向字典添加一个空列表。我们可以使用
if语句来实现这一点,或者我们可以使用字典的setdefault()方法。我们将首先展示if语句版本。稍后,我们将看看setdefault()优化。 -
增加字典中的值。
以下是用于计算字典中出现次数的循环。它通过创建和更新项目来工作:
for customer in source:
if customer not in histogram:
histogram[customer]= 0
histogram[customer] += 1
完成后,我们将统计每个客户的模拟访问总数。
我们可以将其转换为一个方便的条形图来比较频率。我们可以计算一些基本的描述性统计数据,包括均值和标准差,以查看是否有任何客户被过度或不足地代表。
工作原理...
字典的核心特性是从不可变值到任何类型的对象的映射。在这种情况下,我们使用一个不可变的数字作为键,另一个数字作为值。当我们计数时,我们替换与键关联的值。
写起来可能有点不寻常:
histogram[customer] += 1
或者写成:
histogram[customer] = histogram[customer] + 1
并将字典中的值视为替换。当我们写出像 histogram[customer] + 1 这样的表达式时,我们正在从另外两个整数对象计算一个新的整数对象。这个新对象替换了字典中的旧值。
字典键对象的不可变性至关重要。我们不能使用 list 、set 或 dict 作为字典映射中的键。但是,我们可以将列表转换为不可变元组,或者将 set 转换为 frozenset ,以便我们可以使用其中一个更复杂的对象作为键。
还有更多...
我们不必使用 if 语句来添加缺少的键。我们可以使用字典的 setdefault() 方法。我们的循环将如下所示:
histogram = {}
for customer in source:
histogram.setdefault(customer, 0)
histogram[customer] += 1
如果键值 customer 不存在,则提供默认值。如果键存在,则 setdefault() 方法不起作用。
collections 模块提供了许多替代映射,可以用来代替默认的 dict 映射。
-
defaultdict:这个集合使我们免于明确编写第二步。我们在创建defaultdict时提供一个初始化函数。我们很快会看一个例子。 -
OrderedDict:这个集合保留了键最初创建的顺序。我们将把这个保存在控制字典键的顺序配方中。 -
Counter:这个集合在创建时执行整个键和计数算法。我们很快也会看到这个。
使用defaultdict类的版本如下:
from collections import defaultdict
def summarize_3(source):
histogram = defaultdict(int)
for item in source:
histogram[item] += 1
return histogram
我们创建了一个defaultdict实例,它将使用int()函数初始化任何未知的键值。我们将int-函数对象-提供给defaultdict构造函数。defaultdict将评估给定的函数对象以创建默认值。
这使我们可以简单地使用histogram[item] += 1。如果item属性的值先前在字典中,它将被递增。如果item属性的值尚未在字典中,将评估int函数,并成为默认值。
我们还可以通过创建一个Counter对象来做到这一点。我们需要导入Counter类,以便我们可以从原始数据构建Counter对象。
**>>> from collections import Counter
>>> def summarize_4(source):
... histogram = Counter(source)
... return histogram**
当我们从数据源创建一个Counter时,该类将扫描数据并计算不同的出现次数。这个类实现了整个配方。
结果如下:
**>>> import random
>>> from pprint import pprint
>>> random.seed(1)
>>> histogram = summarize_4(samples(1000, arrival2(8)))
>>> pprint(histogram)
Counter({1: 150, 0: 130, 2: 129, 4: 128, 5: 127, 6: 118, 3: 117, 7: 101})**
请注意,Counter对象以计数值的降序显示值。OrderedDict对象将按照键创建的顺序显示值。dict不保持顺序。
如果我们想对键施加顺序,我们可以使用:
**>>> for key in sorted(histogram):
... print(key, histogram[key])
0 130
1 150
2 129
3 117
4 128
5 127
6 118
7 101**
另请参阅
-
在从字典中删除- pop()方法和 del 语句配方中,我们将看看如何通过删除项目来修改字典
-
在控制字典键的顺序配方中,我们将看看如何控制字典中键的顺序
从字典中删除- pop()方法和 del 语句
字典的一个常见用例是作为关联存储:我们可以在键和值对象之间保持关联。这意味着我们可能在字典中对项目进行任何CRUD操作。
-
创建新的键值对
-
检索与键关联的值
-
更新与键关联的值
-
从字典中删除键(和值)
我们有两种常见的变体:
-
我们有内存中的字典
dict,以及collections模块中对这个主题的变体。这个集合只在我们的程序运行时存在。 -
我们还在
shelve和dbm模块中有持久存储。数据集合是文件系统中的持久文件。
这些非常相似,shelf.Shelf和dict对象之间的区别很小。这使我们可以在不对程序进行重大更改的情况下尝试dict并切换到Shelf。
服务器进程通常会有多个并发会话。当会话创建时,它们可以被放入dict或shelf中。当会话退出时,项目可以被删除或存档。
我们将模拟处理多个请求的服务概念。我们将定义一个在模拟环境中使用单个处理线程的服务。我们将避免并发和多处理考虑。
准备好了
在Craps赌场游戏中,玩家可以(并经常)在游戏中创建和删除多个赌注。规则可能非常复杂,但核心概念包括玩家可能进行的四种赌注:
-
过线投注:对于我们的目的,这是游戏开始时的购买方式。
-
过线赔率投注:这在赌场的游戏表面上没有标记,但这是一个真正的赌注。这个赌注的赔率与过线赌注不同,并且具有一些统计优势。它也可以被移除。
-
来线投注:这可以在游戏进行中放置。
-
come line odds赌注:这也是在游戏中下注的。这也可以被取消。
了解所有这些赌注选择的最佳方法是模拟游戏和玩家。游戏将需要跟踪玩家下注的所有赌注。这可以通过使用一个字典来完成,其中下注被插入,当它们得到回报时被移除,玩家将它们取消,或者游戏结束。
我们将简化模拟的部分,以便我们可以专注于正确使用字典。这最好作为一个类定义来处理,这样我们可以正确地将赌注和游戏规则与玩家规则隔离开来。有关类设计的更多信息,请参见第六章,类和对象的基础。
如何做...
- 创建整个字典对象:
working_bets = {}
-
为我们要插入字典的每个对象定义键和值。例如,键可以是赌注的描述:
come,pass,come odds或pass odds。值可以是赌注的金额。通常我们避免使用货币,而是使用桌面最低赌注的单位。通常这些都是简单的整数倍数,最常见的是整数值 1 来表示最低赌注。 -
在下注时输入值:
working_bets[bet_name] = bet_amount
具体例子,我们会有working_bets["pass"] = 1。
- 随着赌注得到回报或取消,删除值。我们可以使用
del语句或字典的pop()方法:
del working_bets['come odds']
如果键不存在,这将引发KeyError异常。
pop()方法既改变了字典,又返回与键相关联的值。如果键不存在,这将引发KeyError异常。
amount = working_bets.pop('come odds')
事实证明,pop()可以给出一个默认值。如果键不存在,它不会引发异常,而是返回默认值。
工作原理...
因为字典是一个可变对象,我们可以从字典中删除键。这将同时删除键和与键相关联的值对象。
如果我们尝试删除一个不存在的键,将引发KeyError异常。
我们可以用如下语句替换字典中的对象:
working_bets["come"] = 1
working_bets["come"] = None
键—come—仍然保留在字典中。旧值1不再需要,并将被新值None替换。这与删除项目不同。
还有更多...
我们只能删除字典的键。正如我们之前提到的,我们可以将值设置为None以删除该值,但保留字典中的键。
当我们在for语句中使用字典时,目标变量将被分配键值。例如:
for bet_name in working_bets:
print(bet_name, working_bets[bet_name])
这将打印出working_bets字典中与该赌注相关的所有键值,bet_name和赌注金额。
参见
-
在创建字典-插入和更新的示例中,我们将看看如何创建字典并填充它们的键和值
-
在控制字典键的顺序的示例中,我们将看看如何控制字典中键的顺序
控制字典键的顺序
在创建字典-插入和更新的示例中,我们看了一下创建字典对象的基础知识。在许多情况下,我们会将项目放入字典中,并从字典中单独获取项目。键的顺序甚至不会成为问题。
有些情况下,我们可能想要显示字典的内容。在这种情况下,我们通常希望对键施加一些顺序。例如,当我们使用 Web 服务时,消息通常是以 JSON 表示的字典。在许多情况下,我们希望保持特定顺序的键,以便在调试日志中显示消息时更容易理解。
另一个例子是,当我们使用csv模块读取数据时,电子表格中的每一行都可以表示为一个字典。在这种情况下,我们几乎总是希望保持键的顺序,以使字典遵循源文件的结构。
准备工作
字典是电子表格中的一行很好的模型。当电子表格有标题行和列标题时,这种模型特别有效。假设我们在电子表格中收集了一些数据,看起来像这样:
| final | least | most |
|---|---|---|
| 5 | 0 | 6 |
| -3 | -4 | 0 |
| -1 | -3 | 1 |
| 3 | 0 | 4 |
这显示了最终结果,玩家拥有的最少金额和最多金额。我们可以使用csv模块读取这些数据进行进一步分析:
**>>> from pathlib import Path
>>> import csv
>>> data_path = Path('code/craps.csv')
>>> with data_path.open() as data_file:
... reader = csv.DictReader(data_file)
... data = list(reader)
>>> for row in data:
... print(row)
{'most': '6', 'least': '0', 'final': '5'}
{'most': '0', 'least': '-4', 'final': '-3'}
{'most': '1', 'least': '-3', 'final': '-1'}
{'most': '4', 'least': '0', 'final': '3'}**
电子表格的每一行都是一个字典。然而,每一行都有一些奇怪的地方。虽然不明显,但行中键的顺序与原始.csv文件中键的顺序不匹配。
为什么?默认的dict结构不保证键的任何顺序。如果我们想按特定顺序显示键会怎样?
如何做...
我们有两种常见的方法来强制字典键的顺序:
-
创建一个
OrderedDict:这可以保持键的创建顺序 -
在键上使用
sorted():这会将键放入排序顺序
大多数情况下,我们可以简单地使用OrderedDict而不是dict()或{}来创建一个空字典。这将允许我们按所需的顺序创建键。
然而,有时我们不能轻松地用OrderedDict实例替换dict实例。我们选择了这个例子,因为我们不能轻易地替换由csv创建的dict类。
这是如何强制行的dict键遵循原始.csv文件中列的顺序的方法:
-
获取键的首选顺序。对于
DictReader,读取器对象的fieldnames属性具有正确的顺序信息。 -
使用生成器表达式按正确顺序创建字段。我们会有类似这样的东西:
((name, raw_row[name]) for name in reader.fieldnames)
- 从生成器创建一个
OrderedDict。整个顺序如下:
**>>> from collections import OrderedDict
>>> with data_path.open() as data_file:
... reader = csv.DictReader(data_file)
... for raw_row in reader:
... column_sequence = ((name, raw_row[name])
... for name in reader.fieldnames)
... good_row = OrderedDict(column_sequence)
... print(good_row)
OrderedDict([('final', '5'), ('least', '0'), ('most', '6')])
OrderedDict([('final', '-3'), ('least', '-4'), ('most', '0')])
OrderedDict([('final', '-1'), ('least', '-3'), ('most', '1')])
OrderedDict([('final', '3'), ('least', '0'), ('most', '4')])**
这样可以按特定顺序构建字典。
作为优化,我们可以将这两个步骤合并为一个步骤:
OrderedDict((name, raw_row[name]) for name in reader.fieldnames)
这将构建一个raw_row对象的有序版本。
它是如何工作的...
OrderedDict类保持键的创建顺序。这个类对于确保结构保持更容易理解的顺序非常方便。
当然,这会有一些性能成本。默认的dict类为每个键计算一个哈希值,并使用哈希值来定位字典中的空间。这倾向于使用更多内存,但执行速度非常快。
OrderedDict使用一些额外的存储来保持键的顺序。这在创建键时需要额外的时间。如果键的创建倾向于主导算法,我们会注意到减速。如果键的检索倾向于主导设计,那么使用OrderedDict时我们不会看到太多变化。
还有更多...
在一些包中——比如pymongo——有一些替代的有序字典实现。
请参阅api.mongodb.org/python/current/api/bson/son.html。
bson.son模块包括SON类,这是一个非常方便的有序字典。它专注于 Mongo 数据库的需求,但也非常适用于其他应用。
另请参阅
-
在创建字典-插入和更新的示例中,我们将看看如何创建字典。
-
在从字典中删除-使用 pop()方法和 del 语句的示例中,我们将看看如何通过删除项目来修改字典。
在 doctest 示例中处理字典和集合
我们将在这个示例中看一下编写正确测试的一个小方面。我们将在第十一章中看到整体测试,测试。在本章中的数据结构——dict和set——在编写正确测试时都包含一些复杂性。
由于dict键(和set成员)没有顺序,我们的测试结果会有问题。我们需要有可重复的结果,但没有办法保证集合的顺序。这可能导致测试结果与我们的期望不符。
假设我们的测试期望集合{"Poe", "E", "Near", "A", "Raven"}。由于集合没有定义的顺序,Python 可以以任何顺序显示这个集合:
**>>> {"Poe", "E", "Near", "A", "Raven"}
{'E', 'Poe', 'Raven', 'Near', 'A'}**
元素是相同的,但来自 Python 的整体输出并不相同。doctest包依赖于示例的文字输出与 Python 的 REPL 产生的输出完全相同。
我们如何确保我们的 doctest 示例真的有效?
准备工作
让我们看一个涉及“集合”对象的例子:
**>>> words = set(
... '''Beautiful is better than ugly.
... Explicit is better than implicit.
... Simple is better than complex.
... Complex is better than complicated.
... '''.replace('.', ' ').split())
>>> words
{'complicated', 'Simple', 'ugly', 'implicit', 'Beautiful',
'complex', 'is', 'Explicit', 'better', 'Complex', 'than'}**
这个例子很简单。然而,结果往往会在每次处理这个例子时有所不同。事实上,在处理安全算法时,让顺序变化被认为是很重要的。这被称为哈希随机化问题——当哈希值是可预测的时,它可能成为安全漏洞。
当我们使用doctest模块时,我们需要有完全一致的示例。正如我们将在第十一章中看到的,测试,doctest模块在定位示例方面很聪明,但在确保实际结果与预期结果匹配方面并不是一个天才。
问题主要是出现在集合和字典中。这两个集合中,由于哈希随机化,无法保证键的顺序。
如何做...
当我们需要确保集合或字典中的项目具有特定顺序时,我们可以将集合转换为排序序列。
我们有两种选择:
-
将集合转换为排序序列
-
将字典转换为排序的(key, value)两元组序列
这两个配方都很相似。这是我们需要做的事情,以将一个集合强制转换为一个规范化的结构:
**>>> list(sorted(words))
['Beautiful', 'Complex', 'Explicit', 'Simple', 'better',
'complex', 'complicated', 'implicit', 'is', 'than', 'ugly']**
对于字典,我们经常会使用这个:
list(sorted(some_dictionary.items()))
这将提取字典中的每个项目作为(key, value)两元组。元组将按键排序。生成的序列将被转换为列表,以便与预期结果进行比较。
它是如何工作的...
当面对一个不强加顺序的集合时,我们必须找到一个具有两个属性的集合:
-
相同的内容
-
某种一致的顺序
Python 的内置结构是三个主题的变体:
-
序列
-
集合
-
映射
由于唯一具有保证顺序的是序列,我们可以将集合和映射转换为序列。结果表明,使用sorted()函数很容易做到这一点。
对于集合,我们将对项目进行排序。对于映射,我们将对(key, value)两元组进行排序。这可以确保我们的示例输出恰好符合要求。
还有更多...
我们将在第十一章中看到几种数据的微小变化,测试:
-
浮点数
-
日期
-
对象 ID 和回溯
-
随机序列
所有这些都需要放入一个具有可预测输出的上下文中,以便测试能够重复工作。两种数据结构,“集合”和“字典”,是本章的主题。我们将在相关章节中涵盖其他变化。
理解变量、引用和赋值
变量真正是如何工作的?当我们将一个可变对象分配给两个变量时会发生什么?我们很容易有两个变量共享对一个公共对象的引用;当共享对象是可变的时,这可能导致潜在的混乱结果。规则很简单,后果通常是显而易见的。
我们将专注于这个规则:Python 共享引用。它不复制数据。
我们需要看看这个关于引用共享的规则意味着什么。
我们将创建两种数据结构,一种是可变的,一种是不可变的。我们将使用两种序列,尽管我们可以使用两种集合做类似的事情:
准备好了我们将创建两种数据结构,一种是可变的,一种是不可变的。我们将使用两种类型的序列,尽管我们也可以用两种类型的集合做类似的事情:
**>>> mutable = [1, 1, 2, 3, 5, 8]**
**>>> immutable = (5, 8, 13, 21)**
可变数据结构可以被改变和共享。不可变数据结构也可以被共享,但很难确定它是否被共享。
我们无法轻松地对映射进行这样的操作,因为 Python 没有提供方便的不可变映射。
如何做到...
- 将每个集合分配给一个额外的变量。这将创建两个对结构的引用:
**>>> mutable_b = mutable
>>> immutable_b = immutable**
现在我们有两个对列表[1, 1, 2, 3, 5, 8]的引用和两个对元组(5, 8, 13, 21)的引用。
我们可以使用is运算符来确认这一点。这确定了两个变量是否指向同一个基础对象:
**>>> mutable_b is mutable
True
>>> immutable_b is immutable
True**
- 对集合的两个引用中的一个进行更改。对于可变结构,我们有
append()或add()等方法:
**>>> mutable += [mutable[-2] + mutable[-1]]**
对于列表结构,+=赋值实际上是内部使用extend()方法。
我们可以用不可变结构做类似的事情:
**>>> immutable += (immutable[-2] + immutable[-1],)**
由于元组没有像extend()这样的方法,+=将构建一个新的元组对象,并用该新对象替换immutable的值。
- 看看结构的另一个引用:
**>>> mutable_b
[1, 1, 2, 3, 5, 8, 13]
>>> mutable is mutable_b
True
>>> immutable_b
(5, 8, 13, 21)
>>> immutable
(5, 8, 13, 21, 34)**
两个变量mutable和mutable_b指向同一个基础对象。因此,我们可以使用任一变量来改变对象,并看到改变反映在另一个变量的值中。
两个变量immutable_b和immutable最初指向同一个对象。因为对象无法就地突变,对一个变量的更改意味着一个新对象被分配给该变量。另一个变量仍然牢固地附着在原始对象上。
它是如何工作的...
在 Python 中,变量是附加到对象的标签。我们可以把它们想象成暂时贴在对象上的明亮颜色的粘贴纸。
变量是对基础对象的引用。当我们将一个对象分配给一个变量时,我们给基础对象的引用起了一个名字。当我们在表达式中使用一个变量时,Python 会定位变量所指的对象。
对于可变对象,对象的方法可以修改对象的状态。所有引用对象的变量将反映状态的改变,因为变量只是一个引用,而不是完全的副本。
当我们在赋值语句中使用一个变量时,有两种可能的操作:
-
对于提供适当赋值运算符定义的可变对象,赋值被转换为一个特殊方法;在这种情况下,是
__iadd__。这个特殊方法将改变对象的内部状态。 -
对于不提供
+=赋值定义的不可变对象,赋值被转换为=和+。+运算符建立了一个新对象,并将变量名附加到该新对象。先前引用被替换的对象的其他变量不受影响,它们继续引用旧对象。
Python 跟踪对象被引用的次数。当引用次数变为零时,对象不再被任何地方使用,可以从内存中删除。
还有更多...
像 C++或 Java 这样的语言除了对象之外还有原始类型。在这些语言中,+=语句利用了硬件指令或 Java 虚拟机的特性来调整原始类型的值。
Python 没有这种优化。数字是不可变对象。当我们做这样的事情时:
**>>> a = 355
>>> a += 113**
我们不是在调整对象355的内部状态。这不依赖于内部的__iadd__特殊方法。这的行为就像我们写了:
**>>> a = a + 113**
表达式a + 113被评估,一个新的不可变整数对象被创建。这个新对象被标记为a。以前分配给a的旧值不再需要。
另请参阅
- 在制作对象的浅复制和深复制中,我们将看看如何复制可变结构
制作对象的浅复制和深复制
在本章中,我们谈到了赋值语句共享对对象的引用。对象通常不会被复制。当我们写:
a = b
现在我们有两个对同一基础对象的引用。如果b是一个列表,a和b都是对同一个可变列表的引用。
正如我们在理解变量、引用和赋值中看到的,对a变量的更改会改变a和b都引用的列表对象。
大多数情况下,这是我们想要的行为。在极少数情况下,我们实际上希望从一个原始对象创建两个独立的对象。
当两个变量引用同一基础对象时,有两种方法可以断开连接:
-
制作结构的浅复制
-
深复制结构
准备工作
我们必须做特殊安排来复制一个对象。我们已经看到了几种用于复制的语法。
-
序列 -
list和tuple:我们可以使用sequence[:]通过使用空切片表达式来复制一个序列。我们也可以使用sequence.copy()来复制一个名为sequence的变量。 -
映射 -
dict:我们可以使用mapping.copy()来复制一个名为mapping的字典。 -
集合 -
set和frozenset:我们可以使用someset.copy()来克隆一个名为someset的集合。
重要的是这些都是浅复制。
浅意味着两个集合将包含对相同基础对象的引用。如果基础对象是不可变的数字或字符串,则这种区别并不重要。当我们无法改变集合中的项目时,项目将被简单地替换。
如果我们有a = [1, 1, 2, 3],我们无法对a[0]进行任何变异。a[0]中的数字1没有内部状态。我们只能替换对象。
然而,当涉及到可变对象的集合时,会出现问题。首先,我们将创建一个对象,然后我们将创建一个副本:
**>>> some_dict = {'a': [1, 1, 2, 3]}
>>> another_dict = some_dict.copy()**
我们必须对字典进行浅复制。这两个副本看起来是一样的,因为它们都包含对相同对象的引用。对于不可变字符串a有一个共享引用。对于可变列表[1, 1, 2, 3]也有一个共享引用。我们可以显示another_dict的值,看看它是否与some_dict相似。
**>>> another_dict
{'a': [1, 1, 2, 3]}**
当我们更新字典副本中的共享列表时会发生什么:
**>>> some_dict['a'].append(5)
>>> another_dict
{'a': [1, 1, 2, 3, 5]}**
我们对一个可变的list对象进行了更改,这个对象在some_dict和another_dict两个dict对象之间共享。
我们可以使用id()函数来查看项目是否共享:
**>>> id(some_dict['a']) == id(another_dict['a'])
True**
因为两个id()值相同,这些是同一个基础对象。与键a关联的值在some_dict和another_dict中是相同的可变列表。我们还可以使用is运算符来查看它们是否是同一个对象。
这种变异效果也适用于包含其他list对象的list集合:
**>>> some_list = [[2, 3, 5], [7, 11, 13]]
>>> another_list = some_list.copy()
>>> some_list is another_list
False
>>> some_list[0] is another_list[0]
True**
我们复制了一个对象some_list,并将其分配给变量another_list。顶层list对象是不同的,但list中的项目是共享引用。我们使用is运算符来显示每个列表中的第一个项目都是对同一基础对象的引用。
因为我们不能创建一个包含可变对象的set,所以我们不需要考虑制作共享项目的浅复制。
如果我们想要完全断开两个副本之间的连接怎么办?如何进行深复制而不是浅复制?
如何做...
Python 通常通过共享引用来工作。它只会勉强复制对象。默认行为是进行浅复制,共享集合内部项目的引用。这是我们如何进行深复制的方法:
- 导入
copy库:
**>>> import copy**
- 使用
copy.deepcopy()函数来复制一个对象以及该对象中包含的所有可变项目:
**>>> some_dict = {'a': [1, 1, 2, 3]}
>>> another_dict = copy.deepcopy(some_dict)**
这将创建没有共享引用的副本。对一个副本的可变内部项目的更改不会在其他任何地方产生任何影响:
**>>> some_dict['a'].append(5)
>>> some_dict
{'a': [1, 1, 2, 3, 5]}
>>> another_dict
{'a': [1, 1, 2, 3]}**
我们更新了some_dict中的一个项目,但它对another_dict中的副本没有产生影响。我们可以使用id()函数看到这些对象是不同的:
**>>> id(some_dict['a']) == id(another_dict['a'])
False**
由于id()值不同,这些是不同的对象。我们还可以使用is运算符来查看它们是不同的对象。
它是如何工作的...
制作浅拷贝相对容易。我们可以使用生成器表达式编写我们自己的算法版本:
**>>> copy_of_list = [item for item in some_list]
>>> copy_of_dict = {key:value for key, value in some_dict.items()}**
在list情况下,新list的项目是对源列表中项目的引用。同样,在dict情况下,键和值是对源字典键和值的引用。
deepcopy()函数使用递归算法来查看每个可变集合的内部。
对于list,概念上的算法大致如下:
immutable = (numbers.Number, tuple, str, bytes)
def deepcopy_list(some_list:
copy = []
for item in some_list:
if isinstance(item, immutable):
copy.append(item)
else:
copy.append(deepcopy(item))
实际的代码当然不是这样的。它在处理每个不同的 Python 类型的方式上更加聪明。然而,这确实提供了一些关于deepcopy()函数工作原理的提示。
事实证明还有一些额外的考虑。最重要的考虑是一个包含对自身引用的对象。
我们可以这样做:
a = [1, 2, 3]
a.append(a)
这是一个令人困惑但在技术上有效的 Python 构造。当尝试编写一个天真的递归操作来访问列表中的所有项目时,这将导致问题。为了克服这个问题,使用内部缓存,以便项目只被复制一次。之后,可以在缓存中找到内部引用。
另请参阅
- 在理解变量、引用和赋值配方中,我们将看看 Python 更喜欢创建对对象的引用。
避免函数参数的可变默认值
在第三章中,函数定义,我们看了 Python 函数定义的许多方面。在设计带有可选参数的函数配方中,我们展示了处理可选参数的配方。当时,我们没有深入讨论将对可变结构提供引用作为默认值的问题。我们将仔细研究函数参数的可变默认值的后果。
准备工作
让我们想象一个函数,它可以创建或更新一个可变的Counter对象。我们将其称为gather_stats()。
理想情况下,它可能看起来像这样:
**>>> from collections import Counter
>>> from random import randint, seed
>>> def gather_stats(n, samples=1000, summary=Counter()):
... summary.update(
... sum(randint(1,6) for d in range(n))
... for _ in range(samples))
... return summary**
这显示了一个具有两个故事的不好设计的函数。第一个故事没有参数集合。函数创建并返回一组统计数据。这是这个故事的例子:
**>>> seed(1)
>>> s1 = gather_stats(2)
>>> s1
Counter({7: 168, 6: 147, 8: 136, 9: 114, 5: 110, 10: 77, 11: 71, 4: 70, 3: 52, 12: 29, 2: 26})**
第二个故事允许我们提供一个显式的参数值,以便统计数据更新给定的对象。这是这个故事的例子:
**>>> seed(1)
>>> mc = Counter()
>>> gather_stats(2, summary=mc)
Counter...
>>> mc
Counter({7: 168, 6: 147, 8: 136, 9: 114, 5: 110, 10: 77, 11: 71, 4: 70, 3: 52, 12: 29, 2: 26})**
我们已经设置了随机数种子,以确保两个随机值序列是相同的。这样可以很容易地确认,如果我们提供一个Counter对象或使用默认的Counter对象,结果是相同的。在第二个例子中,我们向函数提供了一个显式的Counter对象,命名为mc。
gather_stats()函数返回一个值。在编写脚本时,我们只需忽略返回的值。在使用 Python 的交互式 REPL 时,输出会被打印出来。我们显示了Counter...而不是冗长的输出。
当我们在执行前两个操作后执行以下操作时,问题就出现了:
**>>> seed(1)
>>> s3 = gather_stats(2)
>>> s3
Counter({7: 336, 6: 294, 8: 272, 9: 228, 5: 220, 10: 154, 11: 142, 4: 140, 3: 104, 12: 58, 2: 52})**
请注意,计数加倍了。出现了问题。由于这仅在我们多次使用默认故事时发生,它可能通过单元测试套件并且看起来是正确的。
正如我们在制作对象的浅拷贝和深拷贝配方中看到的,Python 更喜欢共享引用。共享的一个后果是:
**>>> s1 is s3
True**
这意味着两个变量s1和s2都是对同一基础对象的引用。看起来我们已经更新了一些共享的集合。
这是否意味着s1的值改变了?
**>>> s1
Counter({7: 336, 6: 294, 8: 272, 9: 228, 5: 220, 10: 154, 11: 142, 4: 140, 3: 104, 12: 58, 2: 52})**
是的,这个 gather_stats() 函数的默认使用似乎在共享一个单一对象。我们如何避免这种情况?
如何做...
解决这个问题有两种方法:
-
提供一个不可变的默认值
-
改变设计
我们首先看看不可变的默认值。通常改变设计是一个更好的主意。为了看到为什么改变设计更好,我们将展示纯技术解决方案。
当我们为函数提供默认值时,默认对象只会被创建一次,并且永远共享。这里是替代方案:
- 用
None替换任何可变的默认参数值:
def gather_stats(n, samples=1000, summary=None):
- 添加一个
if语句来检查参数值是否为None,并将其替换为一个新的可变对象:
if summary is None: summary = Counter()
这将确保每次函数在没有参数值的情况下被评估时,我们都创建一个新的可变对象。我们将避免一次又一次地共享一个可变对象。
提供可变对象作为函数的默认值的很少有好理由。在大多数情况下,我们应该考虑改变设计,不要使用可变对象作为参数的默认值。在极少数情况下,如果我们真的有一个可以更新对象或创建新对象的复杂算法,我们应该考虑定义两个单独的函数。
我们将重构这个函数,使其看起来像这样:
def create_stats(n, samples=1000):
return update_stats(n, samples, Counter())
def update_stats(n, samples=1000, summary):
summary.update(
sum(randint(1,6) for d in range(n))
for _ in range(samples))
我们创建了两个单独的函数。这将分开两个故事,以避免混淆。可选的可变参数的想法本来就不是一个好主意。
它是如何工作的...
正如我们之前指出的,Python 更喜欢共享引用。它很少创建对象的副本。因此,函数参数值的默认值将是共享对象。Python 不容易创建新的对象。
这个规则非常重要,经常让刚接触 Python 的程序员感到困惑。
提示
不要在函数中使用可变默认值。
可变对象(set、list、dict)不应该是函数参数的默认值。
这个规则适用于核心语言。然而,它并不适用于整个标准库。有些情况下,有一些巧妙的替代方法。
还有更多...
在标准库中,有一些示例展示了一个很酷的技术,它展示了我们如何创建新的默认对象。一个广泛使用的例子是 defaultdict 集合。当我们创建一个 defaultdict 时,我们提供一个无参数函数,用于创建新的字典条目。
当字典中缺少一个键时,给定的函数将被评估以计算一个新的默认值。在 defaultdict(int) 的情况下,我们使用 int() 函数来创建一个不可变对象。正如我们所见,不可变对象的默认值不会引起任何问题,因为不可变对象没有内部状态。
当我们使用 defaultdict(list) 或 defaultdict(set) 时,我们可以看到这种设计模式的真正力量。当一个键缺失时,会创建一个新的空 list(或 set)。
defaultdict 使用的评估函数模式并不适用于函数本身的操作方式。大多数情况下,我们为函数参数提供的默认值是不可变对象,比如数字、字符串或元组。必须使用 lambda 来包装一个不可变对象,这当然是可能的,但令人讨厌,因为这是一个很常见的情况。
为了利用这种技术,我们需要修改我们示例函数的设计。我们将不再在函数中更新现有的计数器对象。我们将始终创建一个新的对象。我们可以修改创建的对象的类。
这是一个函数,允许我们在我们不想使用默认的 Counter 类时插入一个不同的类。
**>>> def gather_stats(n, samples=1000, summary_func=lambda x:Counter(x)):
... summary = summary_func(
... sum(randint(1,6) for d in range(n))
... for _ in range(samples))
... return summary**
对于这个版本,我们定义了一个初始化值,它是一个参数的函数。默认情况下,这个单参数函数将应用于随机样本的生成函数。我们可以用另一个单参数函数覆盖这个函数,这个函数将收集数据。这将使用任何可以收集数据的对象构建一个新的对象。
以下是使用list()的示例:
**>>> seed(1)
>>> gather_stats(2, 12, summary_func=list)
[7, 4, 5, 8, 10, 3, 5, 8, 6, 10, 9, 7]**
在这种情况下,我们提供了list()函数来创建一个包含各个随机样本的列表。
以下是一个没有参数值的示例。它将创建一个Counter对象:
**>>> seed(1)
>>> gather_stats(2, 12)
Counter({5: 2, 7: 2, 8: 2, 10: 2, 3: 1, 4: 1, 6: 1, 9: 1})**
在这种情况下,我们使用了默认值。该函数从随机样本创建了一个Counter()对象。
另请参阅
- 请参阅创建字典-插入和更新配方,其中显示了
defaultdict的工作原理
第五章:用户输入和输出
在本章中,我们将学习以下示例:
-
使用
print()函数的特性 -
使用 input()和 getpass()进行用户输入
-
使用"format".format_map(vars())进行调试
-
使用 argparse 获取命令行输入
-
使用 cmd 创建命令行应用程序
-
使用 OS 环境设置
介绍
软件的核心价值在于产生有用的输出。一种简单的输出类型是一些有用结果的文本显示。Python 通过print()函数支持这一点。
input()函数与print()函数有明显的相似之处。input()函数从控制台读取文本,允许我们向程序提供不同的值。
还有许多其他常见的提供输入的方式。解析命令行对于许多应用程序也是有帮助的。有时我们需要使用配置文件来提供有用的输入。数据文件和网络连接是提供输入的更多方式。每种方式都是独特的,需要单独考虑。在本章中,我们将专注于input()和print()的基础知识。
使用print()函数的特性
在许多情况下,print()函数是我们学习的第一个函数。第一个脚本通常是以下变体:
**print("Hello world.")**
我们很快就学会了print()函数可以显示多个值,包括有用的空格。
当我们写下这个:
**>>> count = 9973
>>> print("Final count", count)
Final count 9973**
我们看到在两个值之间包括了一个空格。此外,在函数提供的值后打印了一个换行符,通常用\n字符表示。
我们能控制这种格式吗?我们能改变提供的额外字符吗?
原来我们可以用print()做更多的事情。
准备工作
我们有一个用于记录大帆船燃油消耗的电子表格。它的行看起来像这样:
| 日期 | 10/25/13 | 10/26/13 | 10/28/13 |
|---|---|---|---|
| 发动机开启 | 08:24:00 | 09:12:00 | 13:21:00 |
| 燃油高度 | 29 | 27 | 22 |
| 发动机关闭 | 13:15:00 | 18:25:00 | 06:25:00 |
| 燃油高度关闭 | 27 | 22 | 14 |
有关这些数据的更多信息,请参阅第四章中的从集合中删除项目 - remove()、pop()和 difference和对列表进行切片和切块的示例,内置数据结构 - 列表、集合、字典。油箱内没有液位计。燃油的深度必须通过油箱侧面的视镜读取,这就是为什么燃油的容积被陈述为深度。油箱的完整深度约为 31 英寸,容积约为 72 加仑;可以将深度转换为容积。
以下是使用 CSV 数据的示例。此函数读取文件并返回从每行构建的字段列表:
**>>> from pathlib import Path
>>> import csv
>>> from collections import OrderedDict
>>> def get_fuel_use(source_path):
... with source_path.open() as source_file:
... rdr= csv.DictReader(source_file)
... od = (OrderedDict(
... [(column, row[column]) for column in rdr.fieldnames])
... for row in rdr)
... data = list(od)
... return data
>>> source_path = Path("code/fuel2.csv")
>>> fuel_use= get_fuel_use(source_path)
>>> fuel_use
[OrderedDict([('date', '10/25/13'), ('engine on', '08:24:00'),
('fuel height on', '29'), ('engine off', '13:15:00'),
('fuel height off', '27')]),
OrderedDict([('date', '10/26/13'), ('engine on', '09:12:00'),
('fuel height on', '27'), ('engine off', '18:25:00'),
('fuel height off', '22')]),
OrderedDict([('date', '10/28/13'), ('engine on', '13:21:00'),
('fuel height on', '22'), ('engine off', '06:25:00'),
('fuel height off', '14')])]**
我们使用了pathlib.Path对象来定义原始数据的位置。我们定义了一个名为get_fuel_use()的函数,它将打开并读取给定路径的文件。该函数从源电子表格创建了一行行的数据列表。每行数据都表示为一个OrderedDict对象。
该函数首先创建一个csv.DictReader对象来解析原始数据。读取器通常返回一个内置的dict对象,它不会对键强加特定的顺序。为了强制特定的键顺序,该函数使用生成器表达式为每行创建一个OrderedDict对象。读取器rdr的fieldnames属性用于将列强制为特定顺序。生成器表达式使用了一个嵌套的循环对:一个循环处理一行的每个字段,外部循环处理数据的每一行。
结果是一个包含OrderedDict对象的列表对象。这是我们可以用于打印的一致的数据源。每行都有基于第一行列名的五个字段。
如何做...
我们有两种方法来控制print()的格式:
-
设置字段间分隔符字符
sep,其默认值为一个空格 -
设置行尾字符
end,其默认值为\n字符
我们将展示几个更改 sep 和 end 的示例。每个都是一种一步到位的配方。
默认情况如下。这个例子没有改变 sep 或 end:
**>>> for leg in fuel_use:
... start = float(leg['fuel height on'])
... finish = float(leg['fuel height off'])
... print("On", leg['date'],
... 'from', leg['engine on'],
... 'to', leg['engine off'],
... 'change', start-finish, 'in.')
On 10/25/13 from 08:24:00 to 13:15:00 change 2.0 in.
On 10/26/13 from 09:12:00 to 18:25:00 change 5.0 in.
On 10/28/13 from 13:21:00 to 06:25:00 change 8.0 in.**
当我们查看输出时,我们可以看到每个项目之间插入了一个空格。每个数据项集合的末尾的 \n 字符意味着每个 print() 函数产生一个单独的行。
在准备数据时,我们可能希望使用类似于逗号分隔值的格式,可能使用不是简单逗号的列分隔符。这是一个使用 | 的示例:
**>>> print("date", "start", "end", "depth", sep=" | ")
date | start | end | depth
>>> for leg in fuel_use:
... start = float(leg['fuel height on'])
... finish = float(leg['fuel height off'])
... print(leg['date'], leg['engine on'],
... leg['engine off'], start-finish, sep=" | ")
10/25/13 | 08:24:00 | 13:15:00 | 2.0
10/26/13 | 09:12:00 | 18:25:00 | 5.0
10/28/13 | 13:21:00 | 06:25:00 | 8.0**
在这种情况下,我们可以看到每一列都有给定的分隔符字符串。由于 end 设置没有更改,每个 print() 函数产生一个不同的输出行。
最常见的情况似乎是我们想要完全抑制分隔符。这给了我们对输出的精细控制。
这是我们如何改变默认标点以强调字段名称和值。在这种情况下,我们已经更改了 end 设置:
**>>> for leg in fuel_use:
... start = float(leg['fuel height on'])
... finish = float(leg['fuel height off'])
... print('date', leg['date'], sep='=', end=', ')
... print('on', leg['engine on'], sep='=', end=', ')
... print('off', leg['engine off'], sep='=', end=', ')
... print('change', start-finish, sep="=")
date=10/25/13, on=08:24:00, off=13:15:00, change=2.0
date=10/26/13, on=09:12:00, off=18:25:00, change=5.0
date=10/28/13, on=13:21:00, off=06:25:00, change=8.0**
由于行尾字符串被更改为,,每次使用 print() 函数都不会产生单独的行。直到最后一个 print() 函数,它具有 end 的默认值,我们才得到正确的行尾。
显然,这种技术对于比这些简单示例更复杂的任何事情都可能变得非常复杂。对于简单的事情,我们可以调整分隔符或结尾。对于更复杂的事情,我们需要使用字符串的 format() 方法。
它是如何工作的...
在一般情况下,print() 函数是围绕 stdout.write() 的一个方便的包装器。这种关系可以被改变,我们将在下面看到。
我们可以想象 print() 有一个类似于这样的定义:
def print(*args, *, sep=None, end=None, file=sys.stdout):
if sep is None: sep = ' '
if end is None: end = '\n'
arg_iter= iter(args)
first = next(arg_iter)
sys.stdout.write(repr(first))
for value in arg_iter:
sys.stdout.write(sep)
sys.stdout.write(repr(value())
sys.stdout.write(end)
这为我们提供了关于分隔符字符串和行尾字符串如何包含在 print() 函数输出中的提示。如果没有提供值,则默认值为空格和换行符。该函数通过参数值进行迭代,将第一个值视为特殊值,因为它没有分隔符。这种方法确保分隔符字符串 sep 出现在值之间。
行尾字符串 end 出现在所有值之后。它总是被写入。我们可以通过将其设置为空字符串来有效地关闭它。
还有更多...
sys 模块定义了两个始终可用的标准输出文件:sys.stdout 和 sys.stderr。
我们可以使用 file= 关键字参数来写入标准错误文件,除了标准输出文件:
import sys
print("Red Alert!", file=sys.stderr)
我们已经导入了 sys 模块,以便我们可以访问标准错误文件。我们使用它来写入一个不会成为标准输出流的消息。
通常情况下,我们需要谨慎地在一个程序中打开太多的输出文件。操作系统的限制通常足够打开许多文件。然而,当一个程序创建大量文件时,可能会变得混乱。
通常情况下,使用操作系统文件重定向技术会很好用。程序的主要输出可以写入 sys.stdout;这在操作系统级别很容易重定向。用户可能输入类似这样的命令行:
**python3 myapp.py <input.dat >output.dat**
这将提供 input.dat 文件作为 sys.stdin 上的输入。当 Python 程序写入 sys.stdout 时,操作系统将输出重定向到 output.dat 对象。
在某些情况下,我们需要打开额外的文件。在这种情况下,我们可能会看到这样的编程:
from pathlib import Path
target_path = Path("somefile.dat")
with target_path.open('w', encoding='utf-8') as target_file:
print("Some output", file=target_file)
print("Ordinary log")
在这个例子中,我们打开了一个特定的输出路径,并使用with语句将打开的文件分配给target_file。 然后,我们可以将其用作print()函数中的file=值,以将其写入此文件。 因为文件是上下文管理器,离开with语句意味着文件将被正确关闭,并且所有 OS 资源将从应用程序中释放。 所有文件操作都应该包装在with语句上下文中,以确保资源得到正确释放。
另请参阅
-
请参阅使用"format".format_map(vars())进行调试配方
-
有关此示例中的输入数据的更多信息,请参阅第四章中的从集合中删除项目-remove()、pop()和 difference和切片和切块列表配方,内置数据结构-列表、集合、字典
-
有关一般文件操作的更多信息,请参阅第九章,输入/输出、物理格式、逻辑布局
使用 input()和 getpass()进行用户输入
一些 Python 脚本依赖于从用户那里收集输入。 有几种方法可以做到这一点。 一种常用的技术是使用控制台提示用户输入。
有两种相对常见的情况:
-
普通输入:我们使用
input()函数。 这将提供正在输入的字符的有用回显。 -
无回显输入:这通常用于密码。 输入的字符不会显示,提供了一定程度的隐私。 我们使用
getpass()模块中的getpass()函数。
input()和getpass()函数只是从控制台读取的两种实现选择。 结果表明,获取字符的字符串只是处理的第一步。 实际上,我们有单独的考虑层次:
-
与控制台的初始交互。 这是编写提示和读取输入的基础。 这必须正确处理数据以及键盘事件,例如用于编辑的退格键。 这也可能意味着适当处理文件结束。
-
验证输入以查看它是否属于预期值域。 我们可能正在寻找数字,是/否值或一周中的某一天。 在大多数情况下,验证层有两个部分:
-
我们检查输入是否适合某些一般域,例如数字。
-
我们检查输入是否适合某些更具体的子域。 例如,这可能包括检查数字是否大于或等于零。
- 在更大的上下文中验证输入,以确保它与其他输入一致。 例如,我们可以检查用户的出生日期是否在今天之前。
除了这些技术之外,我们将在使用 argparse 获取命令行输入配方中看到一些其他方法。
准备工作
我们将看一种从人那里读取复杂结构的技术。 在这种情况下,我们将使用年,月和日作为单独的项目来创建完整的日期。
这是一个快速的例子,省略了所有验证问题:
from datetime import date
def get_date():
year = int(input("year: "))
month = int(input("month [1-12]: "))
day = int(input("day [1-31]: "))
result = date(year, month, day)
return result
这说明了使用input()函数有多么容易。 我们经常需要将其包装在额外的处理中,以使其更有用。 日历很复杂,我们不愿意接受 2 月 32 日而不警告用户这不是一个正确的日期。
如何做...
- 检查输入是否为密码或同样受到保密的内容。 如果是,则使用
getpass.getpass()函数。 这意味着我们需要导入以下函数:
from getpass import getpass
否则,如果不需要输入,则使用input()函数。
- 确定将使用哪个提示。 这可能只是
>>>或更复杂的东西。 在某些情况下,我们可能会提供大量的上下文信息。
在我们的示例中,我们提供了一个字段名称和关于预期数据类型的提示作为提示字符串。提示字符串是input()或getpass()函数的参数:
year = int(input("year: "))
-
确定如何验证每个单独的项目。最简单的情况是一个单一值和一个涵盖所有内容的规则。在更复杂的情况下——比如这个——每个单独的元素都是一个带有范围约束的数字。在后续步骤中,我们将看看如何验证复合项目。
-
我们可能希望重新构造我们的输入,使其看起来像这样:
month = None
while month is None:
month_text = input("month [1-12]: ")
try:
month = int(month_text)
if 1 <= month <= 12:
pass
else:
raise ValueError("Month of range 1-12")
except ValueError as ex:
print(ex)
month = None
它将两个验证规则应用于输入:
-
它使用
int()函数检查月份是否是有效的整数 -
它使用
if语句检查整数是否在[1, 12]范围内,如果不在范围内则引发ValueError异常
对于错误的输入引发异常通常是最简单的方法。它允许我们最大的灵活性。我们可能会使用其他异常类,包括定义自定义数据验证异常。
由于我们将为复杂对象的每个字段使用几乎相同的循环,因此我们需要重新构造此输入并将验证序列转换为一个单独的函数。我们将其称为get_integer()。我们将在这里看到详细信息:
- 验证复合对象。在这种情况下,这也意味着我们的整体输入需要重新构造,以便在出现错误输入时进行重试:
input_date = None
while input_date is None:
year = get_integer("year: ", 1900, 2100)
month = get_integer("month [1-12]: ", 1, 12)
day = get_integer("day [1-31]: ", 1, 31)
try:
result = date(year, month, day)
except ValueError as ex:
print(ex)
input_date = None
# assert input_date is the valid date entered by the user
这个整体循环实现了复合日期对象的高级验证。
给定年份和月份,我们实际上可以确定一个稍微更窄的天数范围。复杂之处在于月份不仅有不同数量的天数,从 28 到 31 不等,而且二月的天数还取决于年份的类型。
- 与其模仿规则,不如使用
datetime模块来计算两个相邻月份的第一天,如下所示:
day_1_date = date(year, month, 1)
if month == 12:
next_year, next_month = year+1, 1
else:
next_year, next_month = year, month+1
day_end_date = date(next_year, next_month, 1)
这将正确计算给定月份的最后一天。该算法通过计算给定年份和月份的第一天,然后计算下个月的第一天。它正确地更改年份,以便year的一月跟随year的十二月。
这些日期之间的天数是给定月份的天数。我们可以使用表达式(day_end_date - day_1_date).days从timedelta对象中提取天数。
工作原理...
我们需要将输入问题分解成几个单独但密切相关的问题。在底层是与用户的初始交互。我们确定了两种常见的处理方式:
-
input():这只是提示和读取 -
getpass.getpass():这会提示并读取密码,而不会回显
我们希望能够使用退格字符编辑当前输入行。在某些环境中,有一个更复杂的编辑器可用。它体现在 Python 的readline模块中。如果存在该模块,它可以在准备输入行时添加大量编辑。该模块的主要特性是操作系统级的输入历史记录——我们可以使用上箭头键来恢复任何先前的输入。
我们已将输入验证分解成几个层次,以反映确认输入是否有效所需的编程类型:
-
通用域验证应该使用简单的转换函数,如
int()或float()。这些函数 tend to raise exceptions for invalid data.使用这些转换函数并处理异常要简单得多,而不是尝试编写匹配有效数值的正则表达式。 -
我们的子域验证必须使用
if语句来确定值是否符合施加的任何其他约束,例如范围。为了保持一致性,如果数据无效,这也应该引发异常。
可能会对值施加许多潜在的约束类型。例如,我们可能只想要有效的操作系统进程 ID,称为 PID。这需要在 Nanny Linux 系统上检查/proc/<pid>路径。
对于基于 BSD 的系统,如 Mac OS X,/proc文件系统不存在。相反,需要执行类似以下的操作来确定 PID 是否有效:
import subprocess
status = subprocess.check_output(
['ps',PID])
对于 Windows,命令如下:
status = subprocess.check_output(
['tasklist', '/fi', '"PID eq {PID}"'.format(PID=PID)])
这两个函数中的任何一个都需要成为输入验证的一部分,以确保用户输入正确的 PID 值。只有在整数的主要域得到保证时才能应用这一点。
最后,我们的整体输入函数还应该对无效输入引发异常。这可能在复杂性上有很大的变化。在示例中,我们创建了一个简单的日期对象。在其他情况下,我们可能需要进行更多的处理来确定复杂输入是否有效。
还有更多...
我们有几种用户输入的替代方法,涉及略有不同的方法。我们将详细讨论这两个主题:
-
输入字符串解析:这将涉及对
input()的简单使用和巧妙的解析 -
通过
cmd模块进行交互:这涉及更复杂的类,以及更简单的解析
输入字符串解析
简单的日期值需要三个单独的字段。包括与 UTC 的时区偏移的更复杂的日期时间将涉及七个单独的字段。通过读取和解析字符串而不是单独的字段,用户体验可能会得到改善。
对于简单的日期输入,我们可以使用以下方法:
raw_date_str = input("date [yyyy-mm-dd]: ")
input_date = datetime.strptime(raw_date_str, '%Y-%m-%d').date()
我们使用strptime()函数来解析给定格式的时间字符串。我们在input()函数中提供的提示中强调了预期的日期格式。
这种输入方式要求用户输入更复杂的字符串。由于它是一个包含日期所有细节的单个字符串,许多人发现它更容易和友好。
请注意,收集单独字段和处理复杂字符串这两种技术都依赖于底层的input()函数。
通过 cmd 模块进行交互
cmd模块包括Cmd类,可用于构建交互式界面。这对用户交互的概念采取了截然不同的方法。它不依赖于显式使用input()。
我们将在使用 cmd 创建命令行应用中仔细研究这一点。
另请参阅
在 SunOS 操作系统的参考资料中,现在由 Oracle 拥有,有一系列命令提示不同类型的用户输入:
docs.oracle.com/cd/E19683-01/816-0210/6m6nb7m5d/index.html
具体来说,所有以ck开头的这些命令都是用于收集和验证用户输入的。这可以用来定义输入验证规则的模块:
-
ckdate:提示并验证日期 -
ckgid:提示并验证组 ID -
ckint:显示提示,验证并返回整数值 -
ckitem:构建菜单,提示并返回菜单项 -
ckkeywd:提示并验证关键字 -
ckpath:显示提示,验证并返回路径名 -
ckrange:提示并验证整数 -
ckstr:显示提示,验证并返回字符串答案 -
cktime:显示提示,验证并返回一天中的时间 -
ckuid:提示并验证用户 ID -
ckyorn:提示并验证是/否
使用"format".format_map(vars())进行调试
在 Python 中,最重要的调试和设计工具之一是print()函数。有一些格式选项可用;我们在使用 print()函数的特性中看到了这些。
如果我们想要更灵活的输出怎么办?使用"string".format_map()方法可以提供更多的灵活性。这还不是全部。我们可以将其与vars()函数结合使用,创建出令人惊叹的东西!
准备工作
让我们看一个涉及一些中等复杂计算的多步过程。我们将计算一些样本数据的平均值和标准差。给定这些值,我们将定位所有比平均值高一个标准差以上的项目:
**>>> import statistics
>>> size = [2353, 2889, 2195, 3094,
... 725, 1099, 690, 1207, 926,
... 758, 615, 521, 1320]
>>> mean_size = statistics.mean(size)
>>> std_size = statistics.stdev(size)
>>> sig1 = round(mean_size + std_size, 1)
>>> [x for x in size if x > sig1]
[2353, 2889, 3094]**
这个计算有几个工作变量。mean_size,std_size和sig1变量都显示了过滤size列表的最终列表推导的元素。如果结果令人困惑甚至不正确,了解计算中间步骤是有帮助的。在这种情况下,因为它们是浮点值,我们经常希望四舍五入结果,使其更有意义。
如何做...
-
vars()函数从各种来源构建一个字典结构。 -
如果没有给出参数,默认情况下,
vars()函数将展开所有局部变量。这将创建一个可以与模板字符串的format_map()方法一起使用的映射。 -
使用映射允许我们将变量的名称插入格式模板中。它看起来像这样:
**>>> print(
... "mean={mean_size:.2f}, std={std_size:.2f}"
... .format_map(vars())
... )
mean=1414.77, std=901.10**
我们可以将任何局部变量放入格式字符串中。使用format_map(vars()),我们不需要更复杂的方式来选择要显示的变量。
它是如何工作的...
vars()函数从各种来源构建一个字典结构:
-
vars()表达式将展开所有局部变量,以创建一个可以与format_map()方法一起使用的映射。 -
vars(object)表达式将展开对象内部__dict__属性中的所有项目。这使我们能够公开类定义和对象的属性。当我们在第六章中查看对象时,我们将看到如何利用这一点。
format_map()方法期望一个参数,即映射。格式字符串使用{name}来引用映射中的键。我们可以使用{name:format}来提供格式规范。我们还可以使用{name!conversion}来使用repr(),str()或ascii()函数提供转换函数。
有关格式选项的更多背景信息,请参考第一章中的使用"template".format()构建复杂字符串配方,数字、字符串和元组。
还有更多...
format_map(vars())技术是显示变量值的一种简单方法。另一种方法是使用format(**vars())。这种替代方法可以给我们一些额外的灵活性。
例如,我们可以使用这种更灵活的格式来包括不仅仅是局部变量的额外计算:
**>>> print(
... "mean={mean_size:.2f}, std={std_size:.2f},"
... " limit2={sig2:.2f}"
... .format(sig2=mean_size+2*std_size, **vars())
... )
mean=1414.77, std=901.10, limit2=3216.97**
我们计算了一个新值sig2,它只出现在格式化的输出中。
另请参阅
-
参考第一章中的使用"template".format()构建复杂字符串配方,数字、字符串和元组,了解 format()方法可以做的更多事情
-
有关其他格式选项,请参考使用 print()函数的特性配方
使用 argparse 获取命令行输入
在某些情况下,我们希望从操作系统命令行获取用户输入,而不需要太多交互。我们更希望解析命令行参数值,然后执行处理或报告错误。
例如,在操作系统级别,我们可能想要运行这样的程序:
**slott$ python3 ch05_r04.py -r KM 36.12,-86.67 33.94,-118.40**
**From (36.12, -86.67) to (33.94, -118.4) in KM = 2887.35**
操作系统提示符是slott$。我们输入了一个命令python3 ch05_r04.py。这个命令有一个可选参数-r KM,和两个位置参数36.12,-86.67和33.94,-118.40。
该程序解析命令行参数并将结果写回控制台。这允许一种非常简单的用户交互方式。它使程序非常简单。它允许用户编写一个 shell 脚本来调用程序或将程序与其他 Python 程序合并以创建一个更高级的程序。
如果用户输入了不正确的内容,交互可能会像这样:
**slott$ python3 ch05_r04.py -r KM 36.12,-86.67 33.94,-118asd**
**usage: ch05_r04.py [-h] [-r {NM,MI,KM}] p1 p2**
**ch05_r04.py: error: argument p2: could not convert string to float: '-118asd'**
一个无效的参数值-118asd导致了一个错误消息。程序以错误状态码停止。在大多数情况下,用户可以按上箭头键获取上一个命令行,进行更改,然后再次运行程序。交互被委托给操作系统命令行。
程序的名称ch05_r04并不是太具有信息性。也许我们可以做得更好。位置参数是两个(纬度,经度)对。输出显示了给定单位下两者之间的距离。
我们如何从命令行解析参数值?
准备就绪
我们需要做的第一件事是重构我们的代码,创建两个单独的函数:
-
从命令行获取参数的函数。由于
argparse模块的工作方式,这个函数几乎总是会返回一个argparse.Namespace对象。 -
一个执行真正工作的函数。这个函数应该被设计成在任何情况下都不引用命令行选项。这意味着它可以在各种情境中被重复使用。
这是我们的真正工作函数,display():
from ch03_r05 import haversine, MI, NM, KM
def display(lat1, lon1, lat2, lon2, r):
r_float = {'NM': NM, 'KM': KM, 'MI': MI}[r]
d = haversine( lat1, lon1, lat2, lon2, r_float )
print( "From {lat1},{lon1} to {lat2},{lon2}"
"in {r} = {d:.2f}".format_map(vars()))
我们从另一个模块导入了核心计算haversine()。我们为这个函数提供了参数值,并使用format()来显示最终的结果消息。
我们基于第三章中的根据部分函数选择参数顺序食谱中的示例中显示的计算,函数定义:

基本计算产生了两点之间的中心角c,给定为(lat[1],lon[1])和(lat[2],lon[2])。角度以弧度表示。我们通过将其乘以地球的平均半径来将其转换为距离。如果我们将角度c乘以 3959 英里的半径,我们将得到以英里表示的角度距离。
请注意,我们期望距离转换因子r以字符串形式提供。然后,这个函数将字符串映射到实际的浮点值。
有关format()方法的详细信息,请注意我们正在使用“Debugging with "format".format_map(vars())”食谱的变体。
当它在 Python 中使用时,函数的样子如下:
**>>> from ch05_r04 import display
>>> display(36.12, -86.67, 33.94, -118.4, 'NM')
From 36.12,-86.67 to 33.94,-118.4 in NM = 1558.53**
这个函数有两个重要的设计特点。第一个特点是它避免了对由参数解析创建的argparse.Namespace对象的特性的引用。我们的目标是拥有一个可以在许多替代上下文中重复使用的函数。我们需要保持用户界面的输入和输出元素分开。
第二个设计特点是,这个函数显示了另一个函数计算出的值。这是一个有用的特性,因为它让我们分解问题。我们已经将用户体验与基本计算分开。
如何做…
- 定义整体参数解析函数:
def get_options():
- 创建“解析器”对象:
parser = argparse.ArgumentParser()
- 向“解析器”对象添加各种类型的参数。有时这很困难,因为我们仍在完善用户体验。很难想象人们会如何使用程序以及他们可能会有的所有问题。
对于我们的示例,我们有两个强制的位置参数和一个可选参数:
-
点 1 的纬度和经度
-
点 2 的纬度和经度
-
可选距离
我们可以使用海里作为一个方便的默认值,这样水手们就可以得到他们需要的答案:
parser.add_argument('-r', action='store',
choices=('NM', 'MI', 'KM'), default='NM')
parser.add_argument('p1', action='store', type=point_type)
parser.add_argument('p2', action='store', type=point_type)
我们添加了两种类型的参数。第一个是-r参数,以-开头标记为可选。有时,一个长名称会用--表示。在某些情况下,我们将提供这两种选择,如下所示:
add_argument('--radius', '-r'....)
动作是存储在命令行上跟在-r后面的值。我们列出了三种可能的选择并提供了默认值。解析器将验证输入,如果输入不是这三个值之一,将写入适当的错误。
强制参数不带-前缀。我们使用了store的操作;这是默认操作,实际上不需要声明。作为type参数提供的函数用于将源字符串转换为适当的 Python 对象。这也是验证复杂输入值的理想方式。我们将在本节中查看point_type()验证函数。
- 评估步骤 2 中创建的解析器对象的
parse_args()方法:
options = parser.parse_args()
默认情况下,这使用来自sys.argv的值,这些值是用户输入的命令行参数值。如果需要以某种方式修改用户提供的命令行,我们可以提供一个显式参数。
这是最终的函数:
def get_options():
parser = argparse.ArgumentParser()
parser.add_argument('-r', action='store',
choices=('NM', 'MI', 'KM'), default='NM')
parser.add_argument('p1', action='store', type=point_type)
parser.add_argument('p2', action='store', type=point_type)
options = parser.parse_args()
return options
这依赖于point_type()验证函数。这是因为默认输入类型由str()函数定义。这确保参数的值将是字符串对象。我们提供了type参数,以便我们可以注入类型转换。我们可以使用type = int或type = float进行转换为数字。
在我们的示例中,我们使用point_type()将字符串转换为(纬度,经度)二元组:
def point_type(string):
try:
lat_str, lon_str = string.split(',')
lat = float(lat_str)
lon = float(lon_str)
return lat, lon
except Exception as ex:
raise argparse.ArgumentTypeError from ex
此函数解析输入值。首先,它在,字符处分隔两个值。它尝试对每个部分进行浮点转换。如果float()函数都有效,则我们有一个有效的纬度和经度,可以将其作为一对浮点值返回。
如果出现任何问题,将引发异常。从这个异常中,我们将引发一个ArgumentTypeError异常。这是由argparse模块用于向用户报告错误。
这是结合选项解析器和输出显示函数的主要脚本:
if __name__ == "__main__":
options = get_options()
lat_1, lon_1 = options.p1
lat_2, lon_2 = options.p2
r = {'NM': NM, 'KM': KM, "MI": MI}[options.r]
display(lat_1, lon_1, lat_2, lon_2, r)
这个主要脚本做了一些事情,将用户输入连接到显示的输出:
-
解析命令行选项。这些都存在于选项对象中。
-
将
p1和p2(纬度,经度)二元组扩展为四个单独的变量。 -
评估
display()函数。
它是如何工作的...
参数解析器分为三个阶段:
-
通过创建
ArgumentParser的实例作为解析器对象来定义整体上下文。我们可以提供诸如整体程序描述之类的信息。我们还可以在这里提供格式化程序和其他选项。 -
使用
add_argument()方法添加单个参数。这些可以包括可选参数以及必需参数。每个参数都可以具有多种功能,以提供不同种类的语法。我们将在还有更多...部分中查看一些替代方案。 -
解析实际的命令行输入。解析器的
parse()方法将自动使用sys.argv。我们可以提供一个显式值,而不是sys.argv的值。提供覆盖值的最常见原因是允许进行更完整的单元测试。
一些简单的程序将具有一些可选参数。更复杂的程序可能有许多可选参数。
通常在位置参数中有一个文件名。当程序读取一个或多个文件时,文件名将在命令行上提供,如下所示:
**python3 some_program.py *.rst**
我们使用了 Linux shell 的globbing功能——*.rst字符串扩展为符合命名规则的所有文件的列表。可以使用以下参数定义的文件列表进行处理:
parser.add_argument('file', nargs='*')
命令行上所有不以-字符开头的名称都将被收集到解析器构建的对象的file值中。
然后我们可以使用以下内容:
for filename in options.file:
process(filename)
这将处理命令行中给定的每个文件。
对于 Windows 程序,shell 不进行 glob 操作,应用程序必须处理其中包含通配符模式的文件名。Python 的glob模块可以帮助解决这个问题。此外,pathlib模块可以创建包括 globbing 功能的Path对象。
我们可能需要进行更复杂的参数解析选项。非常复杂的应用程序可能有数十个单独的命令。例如,看看git版本控制程序;该应用程序使用数十个单独的命令,如git clone,git commit或git push。每个命令都有独特的参数解析要求。我们可以使用argparse来创建这些命令及其不同参数集的复杂层次结构。
还有更多...
我们可以处理什么样的参数?常见的使用中有很多参数样式。所有这些变化都是使用解析器的add_argument()方法来定义的:
-
简单选项:
-o或--option参数通常用于启用或禁用程序的功能。这些通常使用add_argument()参数action='store_true',default=False来实现。有时,如果应用程序使用action='store_false',default=True,实现会更简单。默认值和存储值的选择可能简化编程,但不会改变用户的体验。 -
带非平凡对象的简单选项:用户将其视为简单的
-o或--option参数。我们可能希望使用更复杂的对象来实现这一点,而不是简单的布尔常量。我们可以使用action='store_const',const=some_object,default=another_object。由于模块、类和函数也是对象,因此这里可以使用大量的复杂性。 -
带值的选项:我们展示了
-r unit作为接受单位名称的字符串的参数。我们使用action='store'来存储提供的字符串值。我们还可以使用type=function选项来提供验证或将输入转换为有用形式的函数。 -
增加计数器的选项:一种常见的技术是具有多个详细级别的调试日志。我们可以使用
action='count',default=0来计算给定参数出现的次数。用户可以提供-v以获得详细输出,-vv以获得非常详细的输出。参数解析器将-vv视为-v参数的两个实例,这意味着值将从初始值0增加到2。 -
累积列表的选项:我们可能有一个选项,用户可能希望提供多个值。例如,我们可以使用一个距离值列表。我们可以使用
action='append',default=[]的参数定义。这将允许用户说-r NM -r KM以便同时显示海里和公里。当然,这将需要对display()函数进行重大更改,以处理集合中的多个单位。 -
显示帮助文本:如果我们什么都不做,那么
-h和--help将显示帮助消息并退出。这将为用户提供有用的信息。如果需要,我们可以禁用此功能或更改参数字符串。这是一个广泛使用的惯例,所以最好什么都不做,这样它就成为我们程序的一个特性。 -
显示版本号:通常会有
--Version作为一个参数来显示版本号并退出。我们使用add_argument("--Version",action="version",version="v 3.14")来实现这一点。我们提供一个version动作和一个额外的关键字参数来设置要显示的版本。
这涵盖了大多数命令行参数处理的常见情况。通常,我们在编写自己的应用程序时会尝试利用这些常见的参数样式。如果我们努力使用简单、广泛使用的参数样式,我们的用户可能更容易理解我们的应用程序的工作方式。
还有一些 Linux 命令,其命令行语法甚至更复杂。一些 Linux 程序,如find或expr,具有argparse无法轻松处理的参数。对于这些边缘情况,我们需要直接使用sys.argv的值编写自己的解析器。
另请参阅
-
我们看了如何在使用 input()和 getpass()进行用户输入配方中获得交互式用户输入
-
我们将在使用 OS 环境设置配方中看到如何为此添加更多的灵活性
使用 cmd 创建命令行应用程序
有几种创建交互式应用程序的方法。使用 input()和 getpass()进行用户输入配方查看了诸如input()和getpass.getpass()之类的函数。使用 argparse 获取命令行输入配方展示了如何使用argparse创建可以从操作系统命令行与用户交互的应用程序。
我们有第三种方法来创建交互式应用程序,使用cmd模块。该模块将提示用户输入,然后调用我们提供的类的特定方法。
这与第七章中的内容相关,更高级的类设计。我们将添加功能到类定义中,以创建一个独特的子类。
交互将如下所示,我们已标记用户输入如下:“help”:
**Starting with 100**
**Roulette> **`help`****
**Documented commands (type help <topic>):**
**========================================**
bet help
Undocumented commands:
**======================**
**done spin stake**
**Roulette>**
help bet
**Bet <name> <amount>**
**Name is one of even, odd, red, black, high, or low**
**Roulette> **`bet black 1`****
**Roulette> **`bet even 1`****
**Roulette> **`spin`****
**Spin ('21', {'red', 'high', 'odd'})**
**Lose even**
**Lose black**
**... more interaction ...**
**Roulette> **`done`****
**Ending with 93**
应用程序有一个介绍性消息。它显示玩家的起始赌注,也就是他们有多少赌注。应用程序显示一个提示,Roulette>。用户可以输入五个可用命令中的任何一个。
当我们输入help作为命令时,我们会看到可用命令的显示。只有两个有任何文档。其他三个没有更多的详细信息可用。
当我们输入help bet时,我们会看到bet命令的详细文档。描述告诉我们要从可用的六个选择中提供一个赌注名称和一个赌注金额。
我们创建了两个赌注——一个在黑色上,一个在偶数上。然后我们输入spin命令来旋转轮盘。这显示了结果——数字21——是红色的,高的,奇数的。我们的两个赌注都输了。
我们省略了一些没有赢得太多的其他交互。当我们输入done命令时,最终的赌注会显示出来。如果模拟更详细,它可能还会显示一些有关旋转、赢和输的汇总统计数据。
准备工作
cmd.Cmd应用程序的核心特性是读取-求值-打印循环(REPL)。当有大量单独的状态更改和大量命令来进行这些状态更改时,这种应用程序运行良好。
我们将使用轮盘中一部分赌注的简单模拟作为示例。想法是允许用户创建一个或多个赌注,然后旋转模拟的轮盘。虽然正规的赌场轮盘有许多可能的赌注,但我们将只关注其中的六个:
-
红,黑
-
偶数,奇数
-
高,低
美式轮盘有 38 个箱子。1 到 36 号是红色和黑色的。还有两个箱子,0 和 00,是绿色的。这两个额外的箱子被定义为既不是偶数也不是奇数,也不是高也不是低。在零上下注的方式很少,但在数字上下注的方式很多。
我们将使用一些辅助函数来表示轮盘轮,这些函数构建了一个箱子集合。每个箱子都有一个显示数字的字符串和一组赢家的赌注名称。
我们可以定义一个通用的箱子,使用一些简单的规则来确定哪些赌注属于获胜集合:
red_bins = (1, 3, 5, 7, 9, 12, 14, 16, 18,
21, 23, 25, 27, 28, 30, 32, 34, 36)
def roulette_bin(i):
return str(i), {
'even' if i%2 == 0 else 'odd',
'low' if 1 <= i < 19 else 'high',
'red' if i in red_bins else 'black'
}
roulette_bin()函数返回一个包含箱子编号的字符串表示和一组三个获胜提议的双元组。
对于0和00,我们需要一些不同的东西:
def zero_bin():
return '0', set()
def zerozero_bin():
return '00', set()
zero_bin()函数返回一个字符串箱子编号和一个空集。zerozero_bin()函数返回一个特殊字符串来显示它是00,加上一个空集来显示没有定义的赌注是赢家。
我们可以结合这三个函数的结果来创建一个完整的轮盘轮。整个轮盘将被建模为一个箱子元组的列表:
def wheel():
b0 = [zero_bin()]
b00 = [zerozero_bin()]
b1_36 = [
roulette_bin(i) for i in range(1,37)
]
return b0+b00+b1_36
我们建立了一个简单的列表,其中包含完整的一组轮盘号码:一个零,一个双零,以及 1 到 36 的数字。现在我们可以使用random.choice()函数随机选择一个轮盘号码。这将告诉我们哪些赌注赢了,哪些输了。
如何做...
- 导入 cmd 模块:
import cmd
- 定义对
cmd.Cmd的扩展:
class Roulette(cmd.Cmd):
- 在
preloop()方法中定义所需的任何初始化:
def preloop(self):
self.bets = {}
self.stake = 100
self.wheel = wheel()
当处理开始时,preloop()方法只被评估一次。我们用它来初始化赌注和玩家的赌注的字典。我们还创建了一个轮盘集合的实例。self 参数是类内方法的要求。现在,它只是一个简单的必需语法。在第六章中,类和对象的基础,我们将更仔细地研究这个问题。
请注意,这是在class语句内缩进的。
初始化也可以在__init__()方法中完成。不过,这有点复杂,因为我们必须使用super()来确保首先完成Cmd类的初始化。
- 对于每个命令,创建一个
do_command()方法。方法的名称将是命令,前缀为do_。命令后用户输入的文本将作为参数值提供给方法。以下是bet命令和spin命令的两个示例:
def do_bet(self, arg_string):
pass
def do_spin(self, arg_string):
pass
- 解析和验证每个命令的参数。命令后用户输入的内容将作为方法的第一个位置参数的值提供。
如果参数无效,方法应该打印一条消息并返回。如果参数有效,方法可以继续通过验证步骤。
对于我们的例子,spin命令不需要任何输入。我们可以忽略参数字符串。为了更完整,我们可能希望在字符串非空时显示错误。
然而,bet命令确实有一个赌注,它必须是六个有效的赌注名称之一。我们可能想要检查重复的赌注。我们可能还想要检查缩写的赌注名称。六个赌注中的每一个都有一个独特的首字母。
作为扩展,赌注也可以有一个金额。我们在第一章中的使用正则表达式解析字符串一节中研究了解析字符串的方法。在这个例子中,我们将简单处理赌注的名称:
def do_spin(self, arg_string):
if len(self.bets) == 0:
print("No bets have been placed")
return
# Happy path: more goes here.
BET_NAMES = set(['even', 'odd', 'high', 'low', 'red', 'black'])
def do_bet(self, arg_string):
if arg_string not in BET_NAMES:
print("{0} is not a valid bet".format(arg_string))
return
# Happy path: more goes here.
- 为每个命令编写顺利路径处理。对于我们的例子,
spin命令将解决赌注。bet命令将累积另一个赌注。这是do_bet()的顺利路径:
self.bets[arg_string] = 1
我们已经将用户的赌注添加到self.bets映射中,并标明了金额。在这个例子中,我们将所有的赌注都视为具有相同的最小金额。
- 这是
do_spin()的顺利路径,解决了所有的赌注:
self.spin = random.choice(self.wheel)
print("Spin", self.spin)
label, winners = self.spin
for b in self.bets:
if b in winners:
self.stake += self.bets[b]
print("Win", b)
else:
self.stake -= self.bets[b]
print("Lose", b)
self.bets= {}
首先,我们旋转轮盘以获得一个获胜的赌注。然后,我们检查玩家的每个赌注,看看哪些与获胜的赌注匹配。如果玩家的赌注b在获胜的赌注集合中,我们将增加他们的赌注。否则,我们将减少他们的赌注。
在这个例子中,所有的赌注都是 1:1。如果我们想要扩展到其他类型的赌注,我们必须为各种赌注提供适当的赔率。
- 编写主脚本。这将创建该类的一个实例并执行
cmdloop()方法:
if __name__ == "__main__":
r = Roulette()
r.cmdloop()
我们创建了Cmd子类Roulette的一个实例。当我们执行cmdloop()方法时,该类将写入任何提供的介绍性消息,写入提示符,并读取命令。
它是如何工作的...
Cmd模块包含大量内置功能,用于显示提示符,从用户那里读取输入,然后根据用户的输入找到正确的方法。
例如,当我们输入bet black时,Cmd超类的内置方法将从输入中剥离第一个单词bet,将其前缀为do_,然后评估实现该命令的方法。
如果没有 do_bet() 方法,命令处理器将写入错误消息。这是自动完成的,我们不需要编写任何代码。
由于我们编写了一个 do_bet() 方法,这将被调用。在这种情况下,命令后的文本 black 将作为位置参数值提供。
一些方法,如 do_help() ,已经是应用程序的一部分。这些方法将总结其他 do_* 方法。当我们的方法有文档字符串时,这可以通过内置的帮助功能显示出来。
Cmd 类依赖于 Python 的内省功能。类的实例可以检查方法名称,以定位所有以 do_ 开头的方法。它们在类级别的 __dict__ 属性中可用。内省是一个高级主题,将在第七章中涉及,更高级的类设计。
还有更多...
Cmd 类有许多其他地方可以添加交互功能:
-
我们可以定义
help_*()方法,这些方法将成为杂项帮助主题的一部分。 -
当任何
do_*方法返回一个值时,循环将结束。我们可能想要添加一个do_quit()方法,其主体为return True。这将结束命令处理循环。 -
我们可能会提供一个名为
emptyline()的方法来响应空行。一种选择是安静地什么也不做。另一个常见选择是当用户不输入命令时采取默认操作。 -
当用户的输入与任何
do_*方法都不匹配时,将评估default()方法。这可能用于更高级的输入解析。 -
postloop()方法可用于在循环结束后进行一些处理。这将是写总结的好地方。这还需要一个返回值的do_*方法——任何非False值——来结束命令循环。
此外,我们还可以设置许多属性。这些是类级别的变量,将成为方法定义的对等体:
prompt属性是要写的提示字符串。对于我们的示例,我们可以这样做:
class Roulette(cmd.Cmd):
prompt="Roulette> "
-
intro属性是介绍性消息。 -
我们可以通过设置
doc_header、undoc_header、misc_header和ruler属性来定制帮助输出。这些都将改变帮助输出的外观。
目标是能够创建一个处理用户交互的整洁类,这种类的方式既简单又灵活。这个类创建了一个应用程序,它与 Python 的 REPL 有许多共同特征。它还具有许多命令行程序的特点,这些程序提示用户输入。
这些交互应用程序的一个例子是 Linux 中的命令行 FTP 客户端。它有一个提示符 ftp> ,并解析数十个单独的 FTP 命令。输入 help 将显示所有属于 FTP 交互的各种内部命令。
另请参阅
- 我们将在第六章和第七章中查看类定义,类和对象的基础和更高级的类设计。
使用操作系统环境设置
有几种方法可以查看用户输入的时间跨度:
-
交互数据:这是由用户在一种现在时间跨度内提供的。
-
程序启动时提供的命令行参数:这些值通常跨越程序的一个完整执行。
-
在操作系统级别设置的环境变量:这些可以在命令行中设置,使它们几乎与启动应用程序的命令一样交互。
-
它们可以在
.bashrc文件或.profile文件中为用户配置。这使它们比命令行更持久,稍微不那么交互。 -
在 Windows 中,有高级设置选项,允许某人设置长期配置。这些通常是多次执行程序的输入。
-
配置文件设置:这些因应用程序而异。其思想是编辑一个文件,并使这些选项或参数长时间可用。这些可能适用于多个用户,甚至适用于所有用户。配置文件通常具有最长的时间跨度。
在使用 input()和 getpass()进行用户输入和使用 cmd 创建命令行应用程序配方中,我们研究了与用户的交互。在使用 argparse 获取命令行输入配方中,我们研究了如何处理命令行参数。我们将在第十三章中研究配置文件,应用集成。
环境变量可通过os模块获得。我们如何可以基于这些操作系统级别的设置来配置应用程序?
准备工作
我们可能希望通过操作系统设置向程序提供各种类型的信息。这里存在一个深刻的限制:操作系统设置只能是字符串值。这意味着许多种设置将需要一些代码来解析值,并从字符串创建适当的 Python 对象。
当我们使用argparse解析命令行参数时,这个模块可以为我们做一些数据转换。当我们使用os处理环境变量时,我们将不得不自己实现转换。
在使用 argparse 获取命令行输入配方中,我们将haversine()函数包装在一个简单的应用程序中,解析命令行参数。
在操作系统级别上,我们创建了一个像这样工作的程序:
**slott$ python3 ch05_r04.py -r KM 36.12,-86.67 33.94,-118.40**
**From (36.12, -86.67) to (33.94, -118.4) in KM = 2887.35**
使用了一段时间后,我们发现我们经常使用海里来计算从我们的船锚定的地方到达的距离。我们真的希望为输入点和-r参数之一设置默认值。
由于船只可以停泊在各种地方,我们需要更改默认值,而无需调整实际代码。
我们将设置一个名为UNITS的操作系统环境变量,其中包含距离单位。我们可以设置另一个变量HOME_PORT,其中包含家庭点。我们希望能够执行以下操作:
**slott$ UNITS=NM**
**slott$ HOME_PORT=36.842952,-76.300171**
**slott$ python3 ch05_r06.py 36.12,-86.67**
**From 36.12,-86.67 to 36.842952,-76.300171 in NM = 502.23**
单位和家庭点值通过操作系统环境提供给应用程序。这可以在配置文件中设置,以便我们可以轻松进行更改。也可以手动设置,如示例所示。
如何做...
- 导入
os模块。操作系统环境可通过此模块获得:
import os
- 导入应用程序所需的任何其他类或对象:
from ch03_r05 import haversine, MI, NM, KM
- 定义一个函数,该函数将使用环境值作为可选命令行参数的默认值。要解析的默认参数集来自
sys.argv,因此还重要的是要导入sys模块:
def get_options(argv=sys.argv):
- 从操作系统环境设置中收集默认值。这包括任何所需的验证:
default_units = os.environ.get('UNITS', 'KM')
if default_units not in ('KM', 'NM', 'MI'):
sys.exit("Invalid value for UNITS, not KM, NM, or MI")
default_home_port = os.environ.get('HOME_PORT')
sys.exit()函数很好地处理了错误处理。它将打印消息并以非零状态代码退出。
- 创建
parser属性。为相关参数提供任何默认值。这取决于argparse模块,也必须导入:
parser = argparse.ArgumentParser()
parser.add_argument('-r', action='store',
choices=('NM', 'MI', 'KM'), default=default_units)
parser.add_argument('p1', action='store', type=point_type)
parser.add_argument('p2', nargs='?', action='store', type=point_type,
default=default_home_port)
options = parser.parse_args(argv[1:])
- 进行任何额外的验证以确保参数正确设置。在这个例子中,可能没有为
HOME_PORT设置值,也没有为第二个命令行参数提供值。这需要一个if语句和对sys.exit()的调用:
if options.p2 is None:
sys.exit("Neither HOME_PORT nor p2 argument provided.")
- 返回具有一组有效参数的
options对象:
return options
这将允许-r参数和第二个点完全是可选的。如果这些参数从命令行中省略,参数解析器将使用配置信息提供默认值。
使用使用 argparse 获取命令行输入配方来处理get_options()函数创建的选项的方法。
它是如何工作的...
我们使用操作系统环境变量创建可以被命令行参数覆盖的默认值。如果环境变量已设置,那么该字符串将作为参数定义的默认值。如果环境变量未设置,则使用应用程序级别的默认值。
在UNITS变量的情况下,如果未设置操作系统环境变量,则应用程序使用公里作为默认值。
这给我们三个层次的交互:
-
我们可以在
.bashrc文件中定义一个设置。或者,我们可以使用 Windows 的高级设置选项来进行持久性更改。这个值将在每次登录或创建新的命令窗口时使用。 -
我们可以在命令行上交互地设置操作系统环境。这将持续到我们的会话结束。当我们注销或关闭命令窗口时,这个值将丢失。
-
我们可以通过命令行参数每次运行程序时提供一个唯一的值。
请注意,从环境变量中检索的值没有内置或自动验证。我们需要验证这些字符串,以确保它们是有意义的。
还要注意,我们在几个地方重复列出了有效单位的列表。这违反了不要重复自己(DRY)原则。使用这个列表的全局变量是一个很好的改进。
还有更多...
使用 argparse 获取命令行输入示例展示了处理来自sys.argv的默认命令行参数的略有不同的方法。第一个参数是正在执行的 Python 应用程序的名称,通常与参数解析无关。
sys.argv的值将是以下字符串列表:
['ch05_r06.py', '-r', 'NM', '36.12,-86.67']
我们必须在处理过程中的某个时候跳过sys.argv[0]中的初始值。我们有两种选择:
-
在这个示例中,我们尽可能晚地在解析过程中丢弃多余的项目。当提供
sys.argv[1:]给解析器时,第一个项目将被跳过。 -
在前面的示例中,我们在处理过程中更早地丢弃了该值。
main()函数使用options = get_options(sys.argv[1:])向解析器提供了更短的列表。
一般来说,这两种方法之间唯一相关的区别取决于单元测试的数量和复杂性。这个示例将需要一个包含初始参数字符串的单元测试,在解析过程中将被丢弃。
另请参阅
- 我们将看到处理配置文件的多种方法在第十三章中,应用集成

第六章:类和对象的基础知识
在本章中,我们将研究以下配方:
-
使用类封装数据和处理
-
设计具有大量处理的类
-
设计具有少量独特处理的类
-
使用 slots 优化小对象
-
使用更复杂的集合
-
扩展集合-执行统计的列表
-
使用惰性属性
-
使用可设置属性来更新急切属性
介绍
计算的目的是处理数据。即使构建像交互式游戏这样的东西,游戏状态和玩家的行动也是数据;处理计算下一个游戏状态和显示更新。
一些游戏可能有相对复杂的内部状态。当我们考虑具有多个玩家和复杂图形的控制台游戏时,会有复杂的实时状态变化。
另一方面,当我们想到像Craps这样的赌场游戏时,游戏状态非常简单。可能没有建立点,或者 4、5、6、8、9 或 10 中的一个数字可能是已建立的点。转换相对简单,通常通过在赌场桌上移动标记和筹码来表示。数据包括当前状态、玩家行动和骰子的投掷。处理是游戏规则。
像Blackjack这样的游戏在每张牌被接受时有一个稍微复杂的内部状态变化。在手牌可以分开的游戏中,游戏状态可能会变得非常复杂。数据包括当前游戏状态、玩家的命令和从牌堆中抽出的牌。处理由游戏规则定义,这些规则可能会受到任何庄家规则的修改。
在craps的情况下,玩家可以下注。有趣的是,玩家的输入对游戏状态没有影响。游戏对象的内部状态完全由骰子的下一次投掷决定。这导致了一个相对容易可视化的类设计。
在本章中,我们将创建实现多个统计公式的类。一开始数学可能有点令人生畏。几乎所有东西都基于一系列值的总和,通常表示为∑ x。在许多情况下,这可以使用 Python 的sum()函数来实现。
使用类封装数据和处理
计算的基本思想是处理数据。当我们编写处理数据的函数时,这一点得到了体现。我们在第三章中已经看到了这一点,函数定义。
通常,我们希望有许多与共同数据结构一起工作的相关函数。这个概念是面向对象编程的核心。类定义将包含许多控制对象内部状态的方法。
类定义背后的统一概念通常被概括为分配给类的责任的摘要。我们如何有效地做到这一点?设计类的好方法是什么?
准备工作
让我们来看一个简单的、有状态的对象——一对骰子。这是一个模拟Craps赌场游戏的应用程序的背景。目标是利用结果的模拟来帮助发明更好的游戏策略。这将使我们在试图击败庄家优势时不会失去真钱。
类定义和类的实例之间有一个重要的区别,称为对象。我们将这个想法称为面向对象编程。我们的重点是编写类定义。我们的整体应用程序将创建类的实例。从实例的协作中产生的行为是设计过程的总体目标。
大部分设计工作都在类定义上。因此,面向对象编程这个名字可能会误导人。
新兴行为的概念是面向对象编程中的一个重要组成部分。我们不指定程序的每个行为。相反,我们将程序分解为对象,并通过对象的类定义对象的状态和行为。编程根据其责任和协作分解为类定义。
对象应该被视为一个东西——一个名词。类的行为应该被视为动词。这给了我们一个提示,关于我们如何可以继续设计有效工作的类。
当与有形的现实世界的事物相关联时,面向对象设计通常更容易理解。模拟一张纸牌的游戏往往比创建实现抽象数据类型的软件更容易。
在这个例子中,我们将模拟掷骰子。对于一些游戏,比如赌场游戏Craps,会使用两个骰子。我们将定义一个模拟一对骰子的类。为了确保例子是有形的,我们将在模拟赌场游戏的情境中模拟一对骰子。
如何做到这一点...
- 写下简单的句子,描述类的实例做什么。我们可以称这些为问题陈述。专注于简短的句子,并强调名词和动词是至关重要的:
-
Craps游戏有两个标准骰子。
-
每个骰子有六个面,点数从一到六。
-
玩家掷骰子。
-
骰子的总和改变了craps游戏的状态。然而,这些规则与骰子是分开的。
-
如果两个骰子匹配,这个数字是通过困难方式掷出的。如果两个骰子不匹配,这个数字是容易掷出的。一些赌注取决于这种困难和容易的区别。
-
识别句子中的所有名词。名词可能标识不同类的对象。这些是合作者。例如玩家和游戏。名词也可能标识所讨论对象的属性。例如面和点数。
-
识别句子中的所有动词。动词通常是所讨论的类的方法。例如,rolled 和 match。有时,它们是其他类的方法。一个例子是改变状态,这适用于Craps。
-
识别任何形容词。形容词是澄清名词的词或短语。在许多情况下,一些形容词显然是对象的属性。在其他情况下,形容词将描述对象之间的关系。在我们的例子中,诸如骰子的总和这样的短语就是一个介词短语作为形容词的例子。骰子的总和短语修改了名词骰子。总和是一对骰子的属性。
-
用
class语句开始编写类:
class Dice:
- 在
__init__方法中初始化对象的属性:
def __init__(self):
self.faces = None
我们将用self.faces属性来模拟骰子的内部状态。self变量是必需的,以确保我们引用的是类的给定实例的属性。对象由实例变量self的值来标识。
我们也可以在这里放一些其他属性。另一种选择是将属性实现为单独的方法。这种设计决策的细节将在本章后面的使用属性进行惰性属性中讨论。
- 根据不同的动词定义对象的方法。在我们的例子中,我们必须定义几种方法:
- 以下是我们如何实现玩家掷骰子的方法:
def roll(self):
self.faces = (random.randint(1,6), random.randint(1,6))
通过设置self.faces属性来更新骰子的内部状态。同样,self变量对于标识要更新的对象是至关重要的。
注意,这个方法改变了对象的内部状态。我们选择不返回一个值。这使得我们的方法有点像 Python 内置的集合类的方法。任何改变对象的方法都不返回一个值。
- 这种方法有助于实现骰子的总和改变了craps游戏的状态。游戏是一个独立的对象,但这个方法提供了一个符合句子的总和。
def total(self):
return sum(self.faces)
这两种方法有助于回答 hardways 和 easyways 的问题。
def hardway(self):
return self.faces[0] == self.faces[1]
def easyway(self):
return self.faces[0] != self.faces[1]
在赌场游戏中很少有一个具有简单逻辑反义的规则。更常见的是有一个罕见的第三种选择,它有一个非常糟糕的回报规则。在这种情况下,我们可以将easyway定义为返回not self.hardway()。
以下是使用该类的示例:
- 首先,我们将用一个固定值来初始化随机数生成器,这样我们就可以得到一个固定的结果序列。这是为这个类创建一个单元测试的一种方式:
**>>> import random
>>> random.seed(1)**
- 我们将创建一个
Dice对象,d1。然后我们可以用roll()方法设置它的状态。然后我们将查看total()方法来看看掷出了什么。我们可以通过查看faces属性来检查状态:
**>>> from ch06_r01 import Dice
>>> d1 = Dice()
>>> d1.roll()
>>> d1.total()
7
>>> d1.faces
(2, 5)**
- 我们将创建第二个
Dice对象,d2。然后我们可以用roll()方法设置它的状态。我们将查看total()方法的结果,以及hardway()方法。我们可以通过查看faces属性来检查状态:
**>>> d2 = Dice()
>>> d2.roll()
>>> d2.total()
4
>>> d2.hardway()
False
>>> d2.faces
(1, 3)**
- 由于这两个对象是
Dice类的独立实例,对d2的更改不会影响d1:
**>>> d1.total()
7**
它是如何工作的...
这里的核心思想是利用语法的普通规则——名词、动词和形容词——作为识别类的基本特征的一种方式。名词代表事物。一个好的描述性句子应该更多地关注有形的、现实世界的事物,而不是想法或抽象概念。
在我们的例子中,骰子是真实的事物。我们尽量避免使用抽象术语,比如随机器或事件生成器。更容易描述真实事物的有形特征,然后找到一个提供一些有形特征的抽象实现。
掷骰子的想法是一个我们可以用方法定义来模拟的物理动作的例子。显然,这个动作改变了对象的状态。在罕见的情况下——36 次中有一次——下一个状态恰好与上一个状态匹配。
形容词经常会引起混淆。以下是形容词操作最常见的方式的描述:
-
一些形容词,比如 first、last、least、most、next、previous 等,会有一个简单的解释。这些可以作为方法的懒惰实现,或作为属性值的急切实现。
-
一些形容词是更复杂的短语,比如骰子的总和。这是一个由名词(总和)和介词(of)构成的形容词短语。这也可以被视为一个方法或属性。
-
一些形容词涉及到在我们的软件中出现的其他名词。我们可能会有一个短语,比如Craps 游戏的状态,其中状态修改另一个对象,Craps游戏。这显然只是与骰子本身有关的间接关系。这可能反映了骰子和游戏之间的关系。
-
我们可以在问题陈述中添加一句话,比如骰子是游戏的一部分。这可以帮助澄清游戏和骰子之间的关系。例如,介词短语是...的一部分总是可以颠倒过来,从另一个对象的角度来创建陈述:例如游戏包含骰子。这可以帮助澄清对象之间的关系。
在 Python 中,对象的属性默认是动态的。我们不指定一个固定的属性列表。我们可以在类定义的__init__()方法中初始化一些(或全部)属性。由于属性不是静态的,我们在设计上有相当大的灵活性。
还有更多...
捕捉内部状态和导致状态改变的方法是良好类设计的第一步。我们可以使用缩写S.O.L.I.D总结一些有用的设计原则。:
-
单一责任原则:一个类应该有一个明确定义的责任。
-
开闭原则:一个类应该对扩展开放-通常通过继承,但对修改关闭。我们应该设计我们的类,以便我们不需要调整代码来添加或更改功能。
-
里氏替换原则:我们需要设计继承,使得子类可以替换父类。
-
接口隔离原则:在编写问题陈述时,我们希望确保协作类的依赖尽可能少。在许多情况下,这个原则会导致我们将大问题分解为许多小类定义。
-
依赖反转原则:一个类直接依赖于其他类并不理想。最好是一个类依赖于一个抽象,而具体的实现类替换抽象类。
目标是创建具有适当行为并遵守设计原则的类。
另请参见
-
参见使用属性进行延迟属性配方,我们将讨论急切属性和延迟属性之间的选择
-
在第七章 ,更高级的类设计,我们将更深入地研究类设计技术
-
参见第十一章 ,测试,了解如何为类编写适当的单元测试的方法
设计具有大量处理的类
大多数情况下,一个对象将包含定义其内部状态的所有数据。然而,这并不总是正确的。有些情况下,一个类并不真正需要保存数据,而是可以保存处理过程。
这种设计的一些典型例子是统计处理算法,这些算法通常在被分析的数据之外。数据可能在list或Counter对象中。处理可能是一个单独的类。
当然,在 Python 中,这种处理通常是使用函数实现的。有关更多信息,请参见第三章 ,函数定义。在某些语言中,所有代码必须采用类的形式,这会导致一些额外的复杂性。
我们如何设计一个利用 Python 的各种复杂内置集合的类?
准备工作
在第四章 ,内置数据结构-列表、集合、字典,特别是使用集合方法和运算符配方中,我们研究了一种称为优惠券收集器测试的统计过程。其概念是每次执行某个过程时,我们保存一个描述该过程的某个方面或参数的优惠券。问题是,在收集完整的优惠券之前,我需要执行多少次该过程?
如果我们根据客户的购买习惯将客户分配到不同的人口统计群体中,我们可能会问在我们看到每个群体的人之前我们需要进行多少次在线销售。如果这些群体的规模大致相同,那么预测在收集完整的优惠券之前我们遇到的平均客户数量是微不足道的。如果这些群体的规模不同,计算在收集完整的优惠券之前的预期时间就会更加复杂。
假设我们使用Counter对象收集了数据。有关各种集合的更多信息,请参见第四章 ,内置数据结构-列表、集合、字典,特别是使用集合方法和运算符和避免函数参数的可变默认值配方。在这种情况下,客户分为八个大致相等的类别。
数据看起来是这样的:
Counter({15: 7, 17: 5, 20: 4, 16: 3, ... etc., 45: 1})
关键是需要多少次访问才能获得完整的优惠券集。值是需要给定次数的访问次数。在前一行代码中,需要 15 次访问七次。需要 17 次访问五次。这有一个很长的尾巴。有一次,收集完整的八张优惠券需要 45 次单独的访问。
我们想对这个Counter进行一些统计。我们有两种整体策略来做到这一点:
-
扩展:我们可以扩展
Counter类定义以添加统计处理。这取决于我们想要引入的处理类型的复杂性。我们将在扩展集合 - 进行统计的列表食谱中详细介绍这一点,以及第七章中的更高级的类设计。 -
封装:我们可以将
Counter对象封装在另一个类中,该类仅提供我们需要的功能。不过,当我们这样做时,通常需要公开一些额外的方法,这些方法是 Python 的重要部分,但对于我们的应用程序并不重要。我们将在第七章中讨论这一点,更高级的类设计。
封装的变体是我们使用统计计算对象来封装内置集合中的对象。这通常会导致一个优雅的解决方案。
我们有两种设计处理的方式。这两种设计选择都适用于整体架构选择:
-
急切:这意味着我们将尽快计算统计数据。这些值可以成为类的属性。虽然这可以提高性能,但也意味着对数据收集的任何更改都将使急切计算的值无效。我们必须检查整体上下文,看看是否会发生这种情况。
-
懒惰:这意味着我们不会计算任何东西,直到通过方法函数或属性需要。我们将在使用属性进行延迟属性食谱中讨论这一点。
这两种设计的基本数学是相同的。唯一的问题是何时进行计算。
我们使用预期值的总和来计算平均值。预期值是值的频率乘以值。平均值μ就是这样的:

在这里,k是来自Counter的键,C,f[k]是来自Counter的给定键的频率值。
标准差σ取决于平均值μ。这还涉及计算一系列值的总和,每个值都由频率加权。以下是公式:

在这里,k是来自Counter的键,C,f[k]是来自Counter的给定键的频率值。Counter中的项目总数是
。这是频率的总和。
如何做到这一点...
- 用一个描述性的名称定义类:
class CounterStatistics:
- 编写
__init__方法以包括将连接到该对象的对象:
def __init__(self, raw_counter:Counter):
self.raw_counter = raw_counter
我们定义了一个方法函数,它以Counter对象作为参数值。这个Counter对象被保存为Counter_Statistics实例的一部分。
- 初始化可能有用的任何其他本地变量。由于我们将急切地计算值,最急切的可能时间是在创建对象时。我们将写一些尚未定义的函数的引用:
self.mean = self.compute_mean()
self.stddev = self.compute_stddev()
我们已经急切地从Counter对象计算了平均值和标准差,并将它们保存在两个实例变量中。
- 为各种值定义所需的方法。这是平均值的计算:
def compute_mean(self):
total, count = 0, 0
for value, frequency in self.raw_counter.items():
total += value*frequency
count += frequency
return total/count
- 这是我们如何计算标准差的方法:
def compute_stddev(self):
total, count = 0, 0
for value, frequency in self.raw_counter.items():
total += frequency*(value-self.mean)**2
count += frequency
return math.sqrt(total/(count-1))
请注意,这个计算要求首先计算平均值,并且self.mean实例变量已经被创建。
此外,这使用了math.sqrt()。确保在 Python 文件中添加所需的import math语句。
这是我们如何创建一些样本数据的方法:
**>>> from ch04_r06 import *
>>> from collections import Counter
>>> def raw_data(n=8, limit=1000, arrival_function=arrival1):
... expected_time = float(expected(n))
... data = samples(limit, arrival_function(n))
... wait_times = Counter(coupon_collector(n, data))
... return wait_times**
我们从ch04_r06模块导入了expected()、arrival1()和coupon_collector()等函数。我们还从标准库的collections模块导入了Counter集合。
我们定义了一个名为raw_data()的函数,它将生成一定数量的顾客访问。默认情况下,将会有 1,000 次访问。领域将包括八种不同类别的顾客;每个类别将有相同数量的成员。我们将使用coupon_collector()函数来遍历数据,输出收集到完整的八张优惠券所需的访问次数。
然后使用这些数据来组装一个Counter对象。这将包括获取完整一套优惠券所需的顾客数量。每个顾客数量还将有一个频率,显示该访问次数发生的频率。
这是我们如何分析Counter对象的方法:
**>>> import random
>>> from ch06_r02 import CounterStatistics
>>> random.seed(1)
>>> data = raw_data()
>>> stats = CounterStatistics(data)
>>> print("Mean: {0:.2f}".format(stats.mean))
Mean: 20.81
>>> print("Standard Deviation: {0:.3f}".format(stats.stddev))
Standard Deviation: 7.025**
首先,我们导入了random模块,以便我们可以选择一个已知的种子值。这样可以更容易地测试和演示应用程序,因为随机数是一致的。我们还从ch06_r02模块导入了CounterStatistics类。
一旦我们定义了所有的项目,我们就可以将seed强制设定为一个已知的值,并生成收集优惠券的测试结果。raw_data()函数将会生成一个我们称之为数据的Counter对象。
我们将使用Counter对象来创建CounterStatistics类的一个实例。我们将把这个实例分配给stats变量。创建这个实例也将计算一些摘要统计数据。这些值可以作为stats.mean属性和stats.stddev属性获得。
对于一组八张优惠券,理论平均值是21.7次访问以收集所有优惠券。看起来raw_data()的结果显示了与随机访问预期相匹配的行为。这有时被称为零假设——数据是随机的。
它是如何工作的...
这个类封装了两个复杂的算法,但不包括任何改变状态的数据。这种类不需要保留大量数据。相反,设计尽快执行所有计算。
我们为处理编写了一个高级规范,并将其放在__init__()方法中。然后我们编写了实现指定处理步骤的方法。我们可以设置所需的属性数量,使其成为一种非常灵活的方法。
这种设计的优点是属性值可以被重复使用。计算成本只需支付一次;每次使用属性值时,无需进一步计算。
这种设计的缺点是,对底层Counter对象的更改会使CounterStatistics对象过时。通常,当Counter不会改变时,我们使用这种设计。该示例创建了一个单一的静态Counter,用于创建CounterStatistics。
还有更多...
如果我们需要有状态的对象,我们可以添加更新方法,可以改变Counter对象。例如,我们可以引入一个方法,通过委托工作给相关的Counter来添加另一个值。这将把设计模式从计算和收集之间的简单连接转变为对集合的适当封装。
该方法可能如下所示:
def add(self, value):
self.raw_counter[value] += 1
self.mean = self.compute_mean()
self.stddev = self.compute_stddev()
首先,我们更新了Counter的状态。然后,我们重新计算了所有的派生值。这种处理可能会产生巨大的计算开销。需要有一个令人信服的理由,在每次值改变后重新计算均值和标准差。
还有更高效的解决方案。例如,如果我们保存两个中间和一个中间计数,我们可以通过高效地计算平均值和标准差来更新这些和计数。
为此,我们可能有一个看起来像这样的__init__()方法:
def __init__(self, counter:Counter=None):
if counter:
self.raw_counter = counter
self.count = sum(self.raw_counter[k] for k in self.raw_counter)
self.sum = sum(self.raw_counter[k]*k for k in self.raw_counter)
self.sum2 = sum(self.raw_counter[k]*k**2 for k in self.raw_counter)
self.mean = self.sum/self.count
self.stddev = math.sqrt((self.sum2-self.sum**2/self.count)/(self.count-1))
else:
self.raw_counter = Counter()
self.count = 0
self.sum = 0
self.sum2 = 0
self.mean = None
self.stddev = None
我们编写了这个方法,可以使用Counter或不使用Counter。如果没有提供数据,它将从一个空集合开始,并且各种总和的值为零。当计数为零时,均值和标准差没有有意义的值,因此提供None。
如果提供了Counter,那么将计算count,sum和平方和。这些可以很容易地进行增量调整,快速重新计算mean和标准差。
当添加一个新值时,以下方法将逐渐重新计算各种派生值:
def add(self, value):
self.raw_counter[value] += 1
self.count += 1
self.sum += value
self.sum2 += value**2
self.mean = self.sum/self.count
if self.count > 1:
self.stddev = math.sqrt(
(self.sum2-self.sum**2/self.count)/(self.count-1))
更新Counter对象,count,sum和平方和显然是必要的,以确保count,sum和平方和值始终与self.raw_counter集合匹配。由于我们知道count至少必须是1,因此均值很容易计算。标准差需要至少两个值,并且是从sum和平方和计算的。
这是标准差变体的公式:

这涉及计算两个总和。一个总和涉及频率乘以值的平方。另一个总和涉及频率和值,总和是平方的。我们用C来表示值的总数;这是频率的总和。
另请参阅
-
在扩展集合 - 进行统计的列表中,我们将看一个不同的设计方法,这些函数用于扩展类定义。
-
我们将在使用属性进行惰性属性中看到不同的方法。这种替代方法将使用属性,并根据需要计算属性。
-
在设计具有少量独特处理的类中,我们将看一个没有真正处理的类。它作为这个类的完全相反。
设计具有少量独特处理的类
在某些情况下,一个对象是相当复杂的数据的容器,但实际上并不对这些数据进行太多处理。事实上,在许多情况下,可以设计一个仅依赖于内置 Python 功能并且不需要任何独特方法函数的类。
在许多情况下,Python 的内置容器类可以几乎覆盖我们的各种用例。小问题是字典或列表的语法不像对象的属性语法那样优雅。
如何创建一个允许我们使用object.attribute语法而不是object['attribute']的类?
准备工作
对于任何类设计,实际上只有两种情况:
-
它是无状态的吗?它包含了许多属性,但从不改变吗?
-
它是有状态的吗?各种属性会发生状态变化吗?
有状态的设计略微更一般。我们总是可以使用有状态的实现,并避免对对象进行任何更改以支持无状态对象。然而,使用真正无状态的对象有一些重要的存储和性能优势。
我们将使用两种类来说明两种设计:
-
无状态:我们将定义一个类来描述简单的扑克牌,它有一个等级和一个花色。由于一张牌的等级和花色不会改变,我们将为此创建一个小的无状态类。
-
有状态:我们将定义一个类来描述Blackjack游戏中玩家当前状态,其中有一个庄家的手,玩家的手,以及一个可选的保险赌注。在每一手中,有许多方面的游戏都在增加。
如何做...
我们将先看无状态对象,然后是有状态对象。对于没有方法的有状态对象,我们有两个选择:我们可以使用一个新类,或者我们可以利用一个现有的类。这些选择导致三个小的配方。
无状态对象
- 我们将基于
collections.namedtuple来构建无状态对象。:
from collections import namedtuple
- 定义类名,将使用两次:
Card = namedtuple('Card',
- 定义对象的属性:
Card = namedtuple('Card', ('rank', 'suit'))
这是我们如何使用这个类定义来创建Card对象:
**>>> from collections import namedtuple
>>> Card = namedtuple('Card', ('rank', 'suit'))
>>> eight_hearts = Card(rank=8, suit='\N{White Heart Suit}')
>>> eight_hearts
Card(rank=8, suit='♡')
>>> eight_hearts.rank
8
>>> eight_hearts.suit
'♡'
>>> eight_hearts[0]
8**
我们已经创建了一个名为Card的新类,它有两个属性名称:rank和suit。在定义类之后,我们可以创建类的实例。我们创建了一个单个的卡片对象eight_hearts,它的 rank 是八,suit 是♡。
我们可以使用对象的名称或元组内的位置引用该对象的属性。当我们使用eight_hearts.rank或eight_hearts[0]时,我们将看到 rank 属性,因为它是在属性名称序列中首先定义的。
这种类定义相对较少见。它具有固定的、定义好的属性集。通常,Python 类定义具有动态属性。此外,对象是不可变的。以下是尝试更改实例属性的示例:
**>>> eight_hearts.suit = '\N{Black Spade Suit}'
Traceback (most recent call last):
File "/Library/Frameworks/Python.framework/Versions/3.4/lib/python3.4/doctest.py", line 1318, in __run
compileflags, 1), test.globs)
File "<doctest default[0]>", line 1, in <module>
eight_hearts.suit = '\N{Black Spade Suit}'
AttributeError: can't set attribute**
我们尝试更改对象的suit属性。这引发了一个AttributeError异常。
使用新类创建有状态对象
- 定义新类:
class Player:
pass
- 我们已经编写了一个空的类定义。可以使用类似以下的方式轻松创建此类的实例:
p = Player()
然后我们可以使用以下语句向对象添加属性:
p.stake = 100
虽然这可能效果很好,但通常有助于向类定义添加更多功能。通常,我们会添加方法,包括__init__()方法,以初始化对象的实例变量。
使用现有类的有状态对象
与其定义一个空类,我们也可以使用标准库中的模块。我们可以使用argparse模块或types模块来实现这一点:
- 导入模块。
argparse模块包括Namespace类,可以用来代替空的类定义:
from argparse import Namespace
我们还可以使用types模块中的SimpleNamespace。它看起来像这样:
from types import SimpleNamespace
- 将类创建为对
SimpleNamespace或Namespace的引用:
Player = SimpleNamespace
工作原理...
这些技术中的任何一种都将定义一个可以具有无限数量属性的类。然而,SimpleNamespace比定义我们自己的类具有更灵活的构造函数:
**>>> from types import SimpleNamespace
>>> Player = SimpleNamespace
>>> player_1 = Player(stake=100, hand=[], insurance=None, bet=None)
>>> player_1.bet = 10
>>> player_1.stake -= player_1.bet
>>> player_1.hand.append( eight_hearts )
>>> player_1
namespace(bet=10, hand=[Card(rank=8, suit='♡')], insurance=None, stake=90)**
我们已经创建了一个名为Player的新类。我们没有提供属性列表,因为它们是动态的。
当我们构建player_1对象时,我们提供了一个要作为该对象一部分创建的属性列表。创建对象后,我们可以对其进行状态更改;我们设置了player_1.bet值,更新了player_1.stake,还更新了player_1.hand。
当我们显示对象时,所有属性都会显示出来。通常,它们以字母顺序提供,这样稍微容易编写单元测试示例。
当我们使用namedtuple()函数时,我们正在创建一个类对象。我们提供一个类名作为字符串,以及与元组的位置值相对应的属性名称。结果对象需要分配给一个变量,并且最好确保作为nametuple()函数参数提供的类名和变量名相同。
namedtuple()创建的类对象与class语句创建的类对象是相同类型的类对象。实际上,如果您想要查看源代码,可以使用print(Card._source)来查看创建类时使用的确切内容。
namedtuple类本质上是一个具有命名属性的元组。与所有其他元组对象一样,它是不可变的——一旦构建,就无法更改。
当我们使用SimpleNamespace时,我们使用的是一个几乎没有方法的非常简单的类定义。因为属性通常是动态的,所以这个类允许我们自由地set,get和delete属性。
不是tuple的子类或使用__slots__(我们将在使用 slots 优化小对象中查看的主题)的类非常灵活。还有一些非常高级的技术可以改变属性行为的方式。这些依赖于对 Python 特殊方法名称如何工作的更深入了解。
还有更多...
在许多情况下,我们将把应用程序处理分解为两类类定义:
-
数据-集合和项目:我们将使用内置的集合类、标准库中的集合,甚至基于
namedtuple()、SimpleNamespace或其他似乎专注于通用数据集合的类定义的项目。 -
处理:我们将以与设计具有大量处理的类配方中所示的示例类似的方式定义类。这些处理类通常依赖于数据对象。
清晰地将数据与处理分离的想法符合几个 S.O.L.I.D.设计原则。特别是,它使我们的类与单一职责原则、开闭原则和接口隔离原则保持一致。我们可以创建具有狭窄焦点的类,这使得通过子类扩展变得相对简单。
另请参阅
- 在设计具有大量处理的类配方中,我们将看到一个完全处理而几乎没有数据的类。它充当了这个类的完全相反的极端。
使用__slots__优化小对象
对象的一般情况允许动态属性集合,每个属性都有动态值。基于tuple类的不可变对象有一个特殊情况。我们在设计具有少量独特处理的类配方中都看到了这两种情况。
有一个中间地带-一个具有固定数量属性的对象,但属性的值可以改变。通过将类从无限属性集合更改为固定属性集合,我们还可以节省内存和处理时间。
我们如何创建具有固定属性集的优化类?
准备工作
让我们来看看在Blackjack赌场游戏中一手扑克牌的概念。一手牌有两个部分:
-
牌
-
赌注
两者都具有动态值。但只有这两个东西。通常会获得更多的牌。也可能通过加倍下注来提高赌注。
分牌的想法将创建额外的手牌。每个分牌手是一个独立的对象,具有不同的牌集和独特的赌注。
如何做…
在创建类时,我们将利用__slots__特殊名称:
- 定义一个具有描述性名称的类:
class Hand:
- 定义属性名称列表:
__slots__ = ('hand', 'bet')
这标识了允许该类的实例的唯一两个属性。任何尝试添加其他属性的尝试都将引发AttributeError异常。
- 添加一个初始化方法:
def __init__(self, bet, hand=None):
self.hand= hand or []
self.bet= bet
一般来说,每手牌都以赌注开始。然后庄家向手牌发两张初始牌。但在某些情况下,我们可能想要从一系列Card实例重新构建一个Hand对象。我们使用了or运算符的一个特性。如果左侧操作数不是假值(即None),那么它就是or表达式的值。如果左侧操作数是假值,那么将评估右侧操作数。有关为什么这是必要的更多信息,请参阅第三章中的设计具有可选参数的函数配方,函数定义。
- 添加一个更新集合的方法。我们称之为
deal,因为它用于向Hand发牌:
def deal(self, card):
self.hand.append(card)
- 添加一个
__repr__()方法,以便可以轻松打印:
def __repr__(self):
return "{class_}({bet}, {hand})".format(
class_= self.__class__.__name__,
**vars(self)
)
这是我们如何使用这个类来构建一手牌的方法。我们将需要基于设计具有少量独特处理的类配方中的示例来定义Card类:
**>>> from ch06_r04 import Card, Hand
>>> h1 = Hand(2)
>>> h1.deal(Card(rank=4, suit='♣'))
>>> h1.deal(Card(rank=8, suit='♡'))
>>> h1
Hand(2, [Card(rank=4, suit='♣'), Card(rank=8, suit='♡')])**
我们已经导入了Card和Hand类的定义。我们创建了一个Hand的实例h1,赌注是桌面最低赌注的两倍。然后我们通过Hand类的deal()方法向手牌添加了两张牌。这展示了h1.hand值如何被改变。
这个示例还显示了h1的实例,以显示赌注和牌的顺序。__repr__()方法生成了 Python 语法的输出。
当玩家加倍下注时,我们还可以替换h1.bet的值(是的,在显示 12 时这是一个疯狂的事情):
**>>> h1.bet *= 2
>>> h1
Hand(4, [Card(rank=4, suit='♣'), Card(rank=8, suit='♡')])**
当我们显示Hand对象h1时,它显示bet属性已更改。
当我们尝试创建一个新属性时会发生什么:
**>>> h1.some_other_attribute = True
Traceback (most recent call last):
File "/Library/Frameworks/Python.framework/Versions/3.4/lib/python3.4/doctest.py", line 1318, in __run
compileflags, 1), test.globs)
File "<doctest default[0]>", line 1, in <module>
h1.some_other_attribute = True
AttributeError: 'Hand' object has no attribute 'some_other_attribute'**
我们尝试在Hand对象h1上创建一个名为some_other_attribute的属性。这引发了一个AttributeError异常。使用__slots__意味着不能向对象添加新属性。
工作原理...
当我们创建一个类定义时,行为部分由对象类和type()函数定义。隐式地,一个类被分配了一个特殊的__new__()方法,用于处理创建新对象所需的内部工作。
Python 有三条基本路径:
-
默认行为会在每个对象中构建一个
__dict__属性。因为对象的属性被保存在字典中,我们可以自由地添加、更改和删除属性。这种灵活性需要为字典对象使用相对较大的内存。 -
__slots__行为避免了__dict__属性。因为对象只有__slots__序列中命名的属性,我们不能添加或删除属性。我们只能更改已定义属性的值。这种缺乏灵活性意味着每个对象使用的内存更少。 -
tuple的子类行为。这些是不可变的对象。创建它们的最简单方法是使用namedtuple()。一旦创建,它们就不能被更改。在测量内存使用时,这些是所有对象类中最节俭的。
Python 中很少使用__slots__优化。默认的类行为提供了最大的灵活性,并且使得更改类变得容易。然而,在某些情况下,一个大型应用程序可能会受到内存使用量的限制,将一个类切换到__slots__可能会显著提高性能。
还有更多...
可以调整__new__()方法的工作方式,以替换默认的__dict__属性为不同类型的字典。这是一种相当高级的技术,因为它暴露了一些更多的类和对象的内部工作。
Python 依赖于元类来创建类的实例。默认的元类是type类。这个想法是元类提供了一些功能,用于创建对象。一旦空对象被创建,类的__init__()方法将初始化空对象。
通常,元类将提供__new__()的定义,也许还有__prepare__(),如果有必要自定义命名空间对象。Python 语言参考文档中有一个广泛使用的示例,调整了用于创建类的命名空间。
有关更多详细信息,请参见docs.python.org/3/reference/datamodel.html#metaclass-example。
另请参阅
- 不可变对象或完全灵活对象的更常见的情况在设计具有少量独特处理的类示例中有所涵盖。
使用更复杂的集合
Python 有各种各样的内置集合。在第四章中,我们仔细研究了它们。在选择数据结构的示例中,我们提供了一种决策树,以帮助从可用选择中找到适当的数据结构。
当我们将标准库整合进来时,我们有更多的选择,也有更多的决策要做。我们如何为我们的问题选择正确的数据结构?
准备工作
在将数据放入集合之前,我们需要考虑如何收集数据,以及一旦拥有集合后我们将如何处理它。最重要的问题始终是我们将如何识别集合中的特定项目。我们将看一下一些关键问题,以帮助选择适合我们需求的合适集合。
以下是备选集合的概述。它们在三个模块中。
collections模块包含许多内置集合的变体。其中包括以下内容:
-
deque:双端队列。它是一个可变序列,具有从每一端推送和弹出的优化。请注意,类名以小写字母开头;这在 Python 中是不典型的。 -
defaultdict:可以为缺失的键提供默认值的映射。请注意,类名以小写字母开头;这在 Python 中是不典型的。 -
Counter:旨在计算键的出现次数的映射。有时被称为多重集或袋子。 -
OrderedDict:保留创建键的顺序的映射。 -
ChainMap:将几个字典组合成单个映射的映射。
heapq模块包括优先队列实现。这是一种专门的序列,它以排序顺序维护项目。
bisect模块包括用于搜索排序列表的方法。这在字典功能和列表功能之间创建了一些重叠。
如何做...
有一些问题需要回答,以决定我们是否需要库数据集合而不是内置集合:
- 结构是否是生产者和消费者之间的缓冲?算法的某些部分是否产生数据项,另一部分是否消耗数据项?
生产者通常会以列表中累积项目,然后消费者从列表中处理项目的一种天真的方法。这种方法往往会构建一个大型的中间数据结构。改变焦点可以交错生产和消费,减少内存使用量。
-
队列用于先进先出(FIFO)处理。项目从一端插入,从另一端消耗。我们可以使用
list.append()和list.pop(0)来模拟这一过程,尽管collections.deque会更有效;我们可以使用deque.append()和deque.popleft()。 -
栈用于后进先出(LIFO)处理。项目从同一端插入和消耗。我们可以使用
list.append()和list.pop()来模拟这一过程,尽管collections.deque会更有效;我们可以使用deque.append()和deque.pop()。 -
优先队列(或堆队列)保持队列按某种顺序排序,与到达顺序不同。这通常用于优化工作,包括图搜索算法。我们可以通过使用
list.append(),list.sort(key=lambda x:x.priority)和list.pop(-1)来模拟这一过程。由于这涉及每次插入后的排序,效率非常低。使用heapq模块要高效得多。
- 我们希望如何处理字典中缺失的键?
-
引发异常。这是内置
dict类的工作方式。 -
创建默认项目。这就是
defaultdict的工作原理。我们必须提供一个返回默认值的函数。常见的例子包括defaultdict(int)和defaultdict(float)以使用默认值为零。我们还可以使用defauldict(list)和defauldict(set)来创建字典列表或字典集结构。 -
在某些情况下,我们需要提供不同的文字值作为默认值:
lookup = defaultdict(lambda:"N/A")
这使用 lambda 对象来定义一个没有名称并始终返回字符串N/A的非常小的函数。这将为缺失的键创建一个默认项目N/A。
defaultdict(int)用于计算项目是如此常见,Counter类正好做到了这一点。
- 我们希望如何处理字典中键的顺序?
-
顺序不重要;我们总是通过键设置和获取项目。这是内置
dict类的行为。键的排序取决于哈希随机化,因此是不可预测的。 -
我们希望保留插入顺序,同时快速使用它们的键找到项目。
OrderedDict类提供了这些独特的特性组合。它具有与内置dict类相同的接口,但保留了键的插入顺序。 -
我们希望按照正确的顺序对键进行排序。虽然排序列表可以做到这一点,但对于给定的键来说,查找时间相当慢。我们可以使用 bisect 模块来提供对排序列表中项目的快速访问。这需要一个三步算法:
-
构建列表,可能通过
append()或extend()。 -
对列表进行排序。
list.sort()就足够了。 -
从排序列表中检索,使用
bisect模块。 -
我们将如何构建字典?
-
我们有一个简单的算法来创建项目。在这种情况下,内置的 dict 可能就足够了。
-
我们有多个需要合并的字典。这可能发生在读取配置文件时。我们可能有一个单独的配置,一个系统范围的配置,以及一个需要合并的默认应用程序配置。
import json
user = json.load('~/app.json')
system = json.load('/etc/app.json')
application = json.load('/opt/app/default.json')
- 我们如何结合这些?
from collections import ChainMap
config = ChainMap(user, system, application)
生成的config对象将通过各种字典进行顺序搜索。它将在用户、系统和应用程序字典中查找给定的键。
它是如何工作的...
数据处理有两个主要的资源约束:
-
存储
-
时间
我们所有的编程都必须遵守这些约束。在大多数情况下,这两者是相互对立的:我们为了减少存储使用而做的任何事情往往会增加处理时间,而我们为了减少处理时间而做的任何事情会增加存储使用。
时间方面通过复杂度度量来形式化。对算法的复杂性进行了相当多的分析:
-
描述为O(1)的操作以恒定时间发生。在这种情况下,复杂性不随数据量的增加而改变。对于一些集合,实际的长期平均值几乎是O(1),只有少量例外。列表
append操作就是一个例子:它们的复杂性都差不多。不过,偶尔在幕后的内存管理操作会增加一些时间。 -
描述为O(n)的操作以线性时间发生。随着数据量的增加,成本也会增加。在列表中查找项目具有这种复杂性。在字典中查找项目更接近O(1),因为它的复杂性很低,无论字典有多大,都是(几乎)相同的。
-
描述为O(n log n)的操作增长速度比数据量快。
bisect模块包括具有这种复杂性的搜索算法。 -
甚至有更糟的情况:一些算法的复杂性是O(n²)甚至O(n!)。我们希望通过巧妙的设计和更智能的数据结构来避免这些情况。
各种数据结构反映了独特的时间和存储权衡。
还有更多...
作为一个具体而极端的例子,让我们来看看在 Web 日志文件中搜索特定事件序列。我们有两种总体设计策略:
-
将所有事件读入类似
file.read().splitlines()的列表结构中。然后我们可以使用for语句来遍历列表,寻找事件的组合。虽然初始读取可能需要一些时间,但搜索会非常快,因为日志都在内存中。 -
从日志文件中读取每个事件。如果事件是模式的一部分,只保存这个事件。我们可以使用
defaultdict,以 IP 地址作为键,以事件列表作为值。这将需要更长的时间来读取日志,但内存中的结果结构将会小得多。
第一个算法,将所有内容读入内存,通常是非常不切实际的。在大型 Web 服务器上,日志可能涉及数百 GB,甚至是 TB 级别的数据。这是无法容纳在任何计算机内存中的。
第二种方法有许多替代实现:
-
单个进程:这里大多数 Python 配方的一般方法假设我们正在创建一个作为单个进程运行的应用程序。
-
多个进程:我们可以将逐行搜索扩展为使用
multiprocessing或concurrent包的多进程应用程序。我们将创建一组工作进程,每个进程可以处理可用数据的子集,并将结果返回给组合结果的消费者。在现代多处理器、多核计算机上,这可以非常有效地利用资源。 -
多个主机:极端情况需要多个服务器,每个服务器处理数据的一个子集。这需要更复杂的协调来共享结果集。通常,这种处理需要像 Hadoop 这样的框架。
我们经常将大型搜索分解为映射和减少处理。映射阶段对集合中的每个项目应用一些处理或过滤。减少阶段将映射结果组合成摘要或聚合对象。在许多情况下,有一种复杂的MapReduce操作层次结构应用于先前 MapReduce 操作的结果。
参见
- 在第四章的选择数据结构配方中,内置数据结构 - 列表、集合、字典,有一组基本的决策,用于选择数据结构
扩展集合 - 进行统计的列表
在设计具有大量处理的类配方中,我们看了一种区分复杂算法和集合的方法。我们展示了如何将算法和数据封装到单独的类中。
另一种设计策略是将集合扩展到包含有用的算法。
我们如何扩展 Python 的内置集合?
准备工作
我们将创建一个复杂的列表,可以计算列表中项目的总和和平均值。这将要求我们的应用程序只将数字放入列表;否则,将会有ValueError异常。
如何做...
- 选择一个名称,也可以进行简单的统计。将类定义为内置
list类的扩展:
class StatsList(list):
这显示了定义内置类的扩展的语法。如果我们提供的主体只包含pass语句,那么新的StatsList类可以在任何使用list类的地方使用。
当我们写这个时,list类被称为StatsList的超类。
- 将附加处理定义为新方法。
self变量将是一个从超类继承了所有属性和方法的对象。这是一个sum()方法:
def sum(self):
return sum(v for v in self)
我们使用了生成器表达式,以清楚地表明sum()函数应用于列表中的每个项目。使用生成器表达式可以让我们非常容易地进行计算或引入过滤器。
- 这是我们经常应用于列表的另一种方法。这计算项目数:
def count(self):
return sum(1 for v in self)
这将计算列表中的项目数。我们选择使用生成器表达式,而不是使用len()函数,以防将来想要添加过滤功能。
- 这是
mean函数:
def mean(self):
return self.sum() / self.count()
- 以下是一些附加方法:
def sum2(self):
return sum(v**2 for v in self)
def variance(self):
return (self.sum2() - self.sum()**2/self.count())/(self.count()-1)
def stddev(self):
return math.sqrt(self.variance())
sum2()方法计算列表中值的平方和。这用于计算方差。然后使用方差来计算列表中值的标准差。
StatsList对象继承了list对象的所有特性。它通过我们添加的方法进行了扩展。以下是使用此集合的示例:
**>>> from ch06_r06 import StatsList
>>> subset1 = StatsList([10, 8, 13, 9, 11])
>>> data = StatsList([14, 6, 4, 12, 7, 5])
>>> data.extend(subset1)**
我们从对象的文字列表中创建了两个StatsList对象。我们使用extend()方法来合并这两个对象。以下是结果对象:
**>>> data
[14, 6, 4, 12, 7, 5, 10, 8, 13, 9, 11]**
以下是我们如何使用我们在此对象上定义的附加方法:
**>>> data.mean()
9.0
>>> data.variance()
11.0**
我们展示了mean()和variance()方法的结果。当然,内置list类的所有特性都存在于我们的扩展中:
**>>> data.sort()
>>> data[len(data)//2]
9**
我们使用内置的sort()方法,并使用索引功能从列表中提取一个项目。因为值的数量是奇数,这是中位数值。请注意,这会改变list对象的顺序。这不是这个算法的最佳实现。
它是如何工作的...
类定义的一个基本特征是继承的概念。当我们创建超类-子类关系时,子类继承了超类的所有特性。这有时被称为泛化-特化关系。超类是一个更一般化的类;子类更专业化,因为它添加或修改了特性。
所有内置类都可以扩展以添加特性。在这个例子中,我们添加了一些统计处理,创建了一个特殊类型的列表子类。
两种设计策略之间存在重要的紧张关系:
-
扩展:在这种情况下,我们扩展了一个类以添加特性。这些特性与这个单一数据结构紧密结合,我们不能轻易地将它们用于不同类型的序列。
-
包装:在设计具有大量处理的类时,我们将处理与集合分开。这会导致在操纵两个对象时更复杂一些。
很难建议其中一个在本质上优于另一个。在许多情况下,我们会发现包装可能具有优势,因为它似乎更符合 S.O.L.I.D.设计原则。然而,总会有一些情况,其中明显适合扩展内置集合。
还有更多...
泛化的概念可能导致超类是抽象的。抽象类是不完整的,需要一个子类来扩展它并提供缺失的实现细节。我们不能创建抽象类的实例,因为它会缺少使其有用的特性。
正如我们在第四章的选择数据结构配方中所指出的,内置数据结构-列表、集合、字典,所有内置集合都有抽象超类。我们可以从一个抽象基类开始设计,而不是从一个具体类开始。
例如,我们可以开始一个类定义如下:
from collections.abc import Mapping
class MyFancyMapping(Mapping):
etc.
为了完成这个类,我们需要为许多特殊方法提供实现:
-
__getitem__() -
__setitem__() -
__delitem__() -
__iter__() -
__len__()
这些方法中的每一个在抽象类中都是缺失的;它们在Mapping类中没有具体的实现。一旦我们为每个方法提供了可行的实现,我们就可以创建新子类的实例。
另请参阅
- 在设计具有大量处理的类配方中,我们采取了不同的方法。在那个配方中,我们将复杂的算法留在了一个单独的类中。
使用属性进行惰性属性
在设计具有大量处理的类配方中,我们定义了一个类,它急切地计算了集合中数据的许多属性。那里的想法是尽快计算值,以便属性不会有进一步的计算成本。
我们将这描述为急切处理,因为工作尽快完成。另一种方法是惰性处理,其中工作尽可能晚地完成。
如果我们有很少使用但计算成本很高的值,我们该怎么做来最小化前期计算,只在真正需要时计算值?
准备就绪...
假设我们使用Counter对象收集了数据。有关各种集合的更多信息,请参见第四章,内置数据结构 - 列表、集合、字典,特别是使用集合方法和运算符和避免函数参数的可变默认值配方。在这种情况下,客户分为八个大致相等的类别。
数据看起来像这样:
Counter({15: 7, 17: 5, 20: 4, 16: 3, ... etc., 45: 1})
在这个集合中,每个键都是获取完整优惠券所需的访问次数。值是发生访问的次数。在我们看到的先前数据中,有七次需要15次访问才能获得完整的优惠券。我们可以从样本数据中看到,有五次需要17次访问。这有一个长尾。只有一个点,需要45次单独访问才能收集到八张优惠券的完整集。
我们想要计算这个Counter上的一些统计数据。我们有两种总体策略可以做到这一点:
-
扩展:我们在扩展集合 - 进行统计的列表配方中详细介绍了这一点,我们将在第七章中介绍这一点,更高级的类设计。
-
包装:我们可以将
Counter对象包装在另一个类中,该类仅提供我们需要的功能。我们将在第七章中查看这一点,更高级的类设计。
包装的常见变体使用具有单独数据收集对象的统计计算对象。这种包装的变体通常会导致优雅的解决方案。
无论我们选择哪种类架构,我们都有两种设计处理的方式:
-
急切:这意味着我们将尽快计算统计数据。这是在设计具有大量处理的类配方中采用的方法。
-
懒惰:这意味着在需要通过方法函数或属性时才会计算任何东西。在扩展集合 - 进行统计的列表配方中,我们向集合类添加了方法。这些额外的方法是懒惰计算的例子。只有在需要时才计算统计值。
这两种设计的基本数学是相同的。唯一的问题是计算何时完成。
平均值μ是这样的:

在这里,k是来自Counter的键,C,f[k]是给定键的频率值来自Counter。
标准偏差σ取决于平均值μ。公式如下:

在这里,k是来自Counter的键,C,f[k]是给定键的频率值来自Counter。计数器中的项目总数是
。
如何做...
- 定义一个具有描述性名称的类:
class LazyCounterStatistics:
- 编写初始化方法以包括将连接到该对象的对象:
def __init__(self, raw_counter:Counter):
self.raw_counter = raw_counter
我们已经定义了一个方法函数,它以Counter对象作为参数值。这个counter对象被保存为Counter_Statistics实例的一部分。
- 定义一些有用的辅助方法。每个方法都使用
@property进行装饰,使其表现得像一个简单的属性:
@property
def sum(self):
return sum(f*v for v, f in self.raw_counter.items())
@property
def count(self):
return sum(f for v, f in self.raw_counter.items())
- 定义各种值所需的方法。这是平均值的计算。这也是用
@property装饰的。其他方法可以被引用,就像它们是属性一样,尽管它们是适当的方法函数:
@property
def mean(self):
return self.sum / self.count
- 这是我们如何计算标准偏差的方法:
@property
def sum2(self):
return sum(f*v**2 for v, f in self.raw_counter.items())
@property
def variance(self):
return (self.sum2 - self.sum**2/self.count)/(self.count-1)
@property
def stddev(self):
return math.sqrt(self.variance)
请注意,我们一直在使用math.sqrt()。确保在 Python 文件中添加所需的import math语句。
- 这是我们如何创建一些样本数据的方法:
**>>> from ch04_r06 import *
>>> from collections import Counter
>>> def raw_data(n=8, limit=1000, arrival_function=arrival1):
... expected_time = float(expected(n))
... data = samples(limit, arrival_function(n))
... wait_times = Counter(coupon_collector(n, data))
... return wait_times**
我们已经从ch04_r06模块导入了expected()、arrival1()和coupon_collector()等函数。我们从标准库collections模块导入了Counter集合。
我们定义了一个名为raw_data()的函数,它将生成一定数量的客户访问。默认情况下,将有 1,000 次访问。域将包括八种不同类别的客户;每个类别将有相等数量的成员。我们将使用coupon_collector()函数来遍历数据,发出收集完整八张优惠券所需的访问次数。
然后使用这些数据来组装一个Counter对象。这将显示获得完整一套优惠券所需的客户数量。每个客户数量还将显示该访问次数发生的频率。
- 这是我们如何分析
Counter对象的方法:
**>>> import random
>>> from ch06_r07 import LazyCounterStatistics
>>> random.seed(1)**
**>>> data = raw_data()
>>> stats = LazyCounterStatistics(data)
>>> print("Mean: {0:.2f}".format(stats.mean))
Mean: 20.81**
**>>> print("Standard Deviation: {0:.3f}".format(stats.stddev))
Standard Deviation: 7.025**
首先,我们导入了random模块,以便我们可以选择一个已知的seed值。这样做可以更容易地测试和演示应用程序,因为随机数是一致的。我们还从ch06_r07模块中导入了LazyCounterStatistics类。
一旦我们定义了所有的项目,我们可以强制将种子设为已知值,并生成收集器测试结果。raw_data()函数将发出一个Counter对象,我们称之为data。
我们将使用Counter对象来创建LazyCounterStatistics类的一个实例。我们将把这个实例分配给stats变量。当我们打印stats.mean属性和stats.stddev属性的值时,方法将被调用来做各种值的适当计算。
对于八张优惠券,理论平均值是 21.7 次访问以收集所有优惠券。看起来raw_data()的结果显示了与随机访问预期相匹配的行为。这有时被称为零假设——数据是随机的。
在这种情况下,数据确实是随机的。我们验证了我们的方法。现在我们可以相当有信心地在真实世界的数据上使用这个软件,因为它的行为是正确的。
它是如何工作的...
懒惰计算的想法在很少使用值的情况下效果很好。在这个例子中,计数在计算方差和标准差时被计算了两次。
这表明对于懒惰设计的天真看法在某些情况下可能不是最佳的。这是一个很容易修复的问题。我们总是可以创建额外的本地变量来保存中间结果。
为了使这个类看起来像执行急切计算的类,我们使用了@property装饰器。这使得一个方法函数看起来像一个属性。这只对没有参数值的方法函数起作用。
在所有情况下,急切计算的属性都可以被懒惰的属性替换。创建急切属性变量的主要原因是为了优化计算成本。在很少使用值的情况下,懒惰的属性可以避免昂贵的计算。
还有更多...
有一些情况下,我们可以进一步优化属性,以限制重新计算的数量。这需要仔细分析使用情况,以了解对底层数据的更新模式。
在加载数据并执行分析的情况下,我们可以缓存结果以节省第二次计算它们的成本。
我们可能会这样做:
def __init__(self, raw_counter:Counter):
self.raw_counter = raw_counter
self._count = None
@property
def count(self):
if self._count is None:
self._count = sum(f for v, f in self.raw_counter.items())
return self._count
这种技术使用一个属性来保存计数计算的副本。这个值可以计算一次,并在需要时无需重新计算地返回。
只有在raw_counter对象的状态永远不会改变的情况下,这种优化才有帮助。在更新底层Counter的应用程序中,这个缓存值将变得过时。这种应用程序需要在每次更新Counter时重新创建LazyCounterStatistics。
另请参阅...
- 在设计具有大量处理的类配方中,我们定义了一个急切计算多个属性的类。这代表了管理计算成本的不同策略。
使用可设置的属性来更新急切属性
在之前的几个示例中,我们已经看到了急切和懒惰计算之间的重要区别。请参阅设计具有大量处理的类示例,了解急切计算结果并设置对象属性的示例。请参阅使用属性进行懒惰属性示例,了解使用属性懒惰地计算结果的方法。
当对象具有状态时,属性值必须在对象的整个生命周期中进行更改。通常使用方法急切地计算属性更改,但这并不是必需的。
对于有状态的对象,我们有以下选择:
-
通过方法设置属性值:
-
急切地计算结果,将结果放在属性中
-
懒惰地计算结果,使用看起来像简单属性的属性
-
通过属性设置值:
-
如果结果是通过属性懒惰地计算的,那么新状态可以反映在这些计算中
如果我们想要使用类似属性的语法来设置值,但又想进行急切计算,我们可以怎么做?
这给了我们另一个变化:我们可以使用属性设置器来使用类似属性的语法。这种方法还可以对结果进行急切的计算。
例如,我们将使用一个外观相当复杂的对象,它有几个属性是从其他属性派生出来的。我们如何急切地计算属性更改的值?
准备好
考虑一个表示航程的腿的类。它有三个主要特征——速率、时间和距离。总的来说,可以从其他两个属性的变化中急切地计算任何一个值。
我们可以添加功能,使其变得更加复杂。例如,如果距离是从纬度和经度计算出来的,一般的方法必须稍作修改。如果我们使用特定的点而不是更灵活的距离,那么距离计算可能涉及速率、时间、起点和方位角之类的东西。这涉及到两个相互关联的计算。在这个例子中,我们不会走得那么远;我们将坚持更简单的速率-时间-距离计算。
由于必须设置两个属性才能计算第三个属性,对象将具有相当复杂的内部状态:
-
没有属性被设置:一切都是未知的。
-
已设置一个项目:还不能计算任何东西。
-
已经设置了两个不同的项目:现在可以计算第三个。
之后,最好支持额外的属性更改。基本规则是根据最近的两个不同的更改计算适当的新值:
-
如果速率,r,和时间,t,是最后更改的两个属性,计算距离,d。使用d = r * t。
-
如果速率,r,和距离,d,是最后更改的两个属性,计算时间,t。使用t = d/r。
-
如果时间,t,和距离,d,是最后更改的两个属性,计算速率,r。使用r = d/t。
我们希望对象的行为如下:
leg_1 = Leg()
leg_1.rate = 6.0 # knots
leg_1.distance = 35.6 # nautical miles
print("Cover {leg.distance:.1f}nm at {leg.rate:.2f}kt = {leg.time:.2f}hr".
format(leg=leg_1))
这有一个明显的优势,即为leg对象提供了一个非常简单的接口。应用程序只需设置任何两个属性,计算就会急切地执行,以为剩余的属性提供一个值。
如何做...
我们将把这分为两部分。首先是定义可设置属性的概述,然后是如何跟踪状态变化的细节:
-
定义一个有意义的类名。
-
提供隐藏属性。这些将被公开为属性:
class Leg:
def __init__(self):
self._rate= rate
self._time= time
self._distance= distance.
- 对于每个可获取的属性,提供一个计算属性值的方法。在许多情况下,这些方法将与隐藏属性并行:
@property
def rate(self):
return self._rate
- 对于每个可设置的属性,提供一个设置属性值的方法:
@rate.setter
def rate(self, value):
self._rate = value
self._calculate('rate')
设置方法具有基于获取方法名称的特殊属性装饰器。在这个例子中,@property装饰器在rate()方法上还创建了一个rate.setter装饰器,可以用来定义该属性的设置方法。
注意,getter 和 setter 的方法名称是相同的。@property和@rate.setter装饰区分了这两个方法。
在这个例子中,我们将值保存到隐藏属性self._rate中。然后,如果可能的话,使用_calculate()方法急切地计算所有隐藏属性。
- 这可以重复应用到所有其他属性上。在我们的例子中,时间和距离的代码是相似的:
@property
def time(self):
return self._time
@time.setter
def time(self, value):
self._time = value
self._calculate('time')
@property
def distance(self):
return self._distance
@distance.setter
def distance(self, value):
self._distance = value
self._calculate('distance')
跟踪状态更改的细节依赖于collections.deque类的一个特性。计算规则可以实现为两个元素的有界队列,其中包含不同的更改。当每个不同的字段被更改时,我们可以将字段名称入队。队列中的两个不同名称是最近更改的最后两个字段;第三个可以通过集合减法从中确定:
- 导入
deque类:
from collections import deque
- 在
__init__()方法中初始化队列:
self._changes= deque(maxlen=2)
- 入队每个不同的更改。确定队列中缺少什么,并计算出来:
def _calculate(self, change):
if change not in self._changes:
self._changes.append(change)
compute = {'rate', 'time', 'distance'} - set(self._changes)
if compute == {'distance'}:
self._distance = self._time * self._rate
elif compute == {'time'}:
self._time = self._distance / self._rate
elif compute == {'rate'}:
self._rate = self._distance / self._time
如果最新的更改尚未在队列中,它将被追加。由于队列有一个有界的大小,最老的项目,即最近更改的项目,将被悄悄地弹出以保持队列大小固定。
可用属性集和最近更改的属性集之间的差异是一个属性名称。这是最近设置的名称;这个值可以从更近设置的其他两个值计算出来。
它是如何工作的...
这是因为 Python 实现了一种称为描述符的类的属性。描述符类可以有获取值、设置值和删除值的方法。根据上下文,其中一个方法会被隐式使用:
-
当在表达式中使用描述符对象时,将使用
__get__方法 -
当一个描述符出现在赋值语句的左侧时,将使用
__set__方法 -
当描述符出现在
del语句中时,将使用__delete__方法
@property装饰器做了三件事:
-
修改后面的方法,将其包装成一个描述符对象。后面的方法被修改为描述符的
__get__方法。在表达式中使用时,它将计算值。 -
添加
method.setter装饰器。这个装饰器将修改后面的方法成为描述符的__set__方法。当名称在赋值语句的左侧使用时,给定的方法将被执行。 -
添加
method.deleter装饰器。这个装饰器将修改后面的方法成为描述符的__delete__方法。当名称在del语句中使用时,给定的方法将被执行。
这允许构建一个属性名称,可以用来提供值、设置值,甚至删除值。
还有更多...
对这个类还有一些更多的改进。我们将看看两种更高级的初始化和计算技术。
初始化
我们可以提供一种正确初始化实例的方法。这个改变使得可以做到以下几点:
**>>> from ch06_r08 import Leg
>>> leg_2 = Leg(distance=38.2, time=7)
>>> round(leg_2.rate, 2)
5.46
>>> leg_2.time=6.5
>>> round(leg_2.rate, 2)
5.88**
这个例子展示了如何通过帆船规划航行。如果要覆盖的距离是38.2海里,目标是在7小时内完成,船必须达到5.46节的速度。要缩短半个小时的行程需要达到5.88节的速度。
为了使其工作,需要更改__init__()方法。内部的dequeue对象必须立即构建。当设置每个属性时,必须使用内部的_calculate()方法来跟踪设置:
class Leg:
def __init__(self, rate=None, time=None, distance=None):
self._changes= deque(maxlen=2)
self._rate= rate
if rate: self._calculate('rate')
self._time= time
if time: self._calculate('time')
self._distance= distance
if distance: self._calculate('distance')
首先创建dequeue函数。当设置每个单独的字段值时,更改将被记录在更改属性的队列中。如果设置了两个字段,第三个将被计算。
如果设置了所有三个字段,那么最后两个更改——在这种情况下是时间和距离——将计算出rate的值。这将覆盖提供的值。
计算
目前,各种计算都隐藏在一个if语句中。这使得更改变得困难,因为子类将被迫提供整个方法,而不仅仅是提供计算更改。
我们可以使用内省技术来移除if语句。整体设计会更好,使用显式计算方法:
def calc_distance(self):
self._distance = self._time * self._rate
def calc_time(self):
self._time = self._distance / self._rate
def calc_rate(self):
self._rate = self._distance / self._time
以下版本的_calculate()利用了这些方法:
def _calculate(self, change):
if change not in self._changes:
self._changes.append(change)
compute = {'rate', 'time', 'distance'} - set(self._changes)
if len(compute) == 1:
name = compute.pop()
method = getattr(self, 'calc_'+name)
method()
当计算的值是一个单例集合时,使用pop()方法从集合中提取该值。在这个字符串前加上calc_会得到一个计算所需值的方法的名称。
getattr()函数进行查找以找到对象self的请求方法,然后将其作为绑定函数进行评估。它可以使用所需的结果更新属性。
将计算重构为单独的方法使得类更容易扩展。现在我们可以创建一个包括修订计算但保留类的整体特性的子类。
另请参阅
-
有关使用集合的更多信息,请参阅第四章中的使用集合方法和运算符配方,内置数据结构 - 列表,集合,字典。
-
dequeue实际上是一个针对追加和弹出操作进行了高度优化的列表。请参阅第四章中的从列表中删除 - 删除,移除,弹出和过滤配方,内置数据结构 - 列表,集合,字典。
第七章:更高级的类设计
在本章中,我们将看一下以下的配方:
-
在继承和扩展之间进行选择 - is-a 问题
-
通过多重继承分离关注点
-
利用 Python 的鸭子类型
-
管理全局和单例对象
-
使用更复杂的结构 - 映射列表
-
创建一个具有可排序对象的类
-
定义一个有序集合
-
从映射列表中删除
介绍
在第六章的类和对象的基础中,我们看了一些涵盖类设计基础的配方。在本章中,我们将更深入地了解 Python 类。
在第六章的设计具有大量处理的类和使用属性进行惰性属性中,我们确定了面向对象编程的一个设计选择,即包装与扩展的选择。可以通过扩展向类添加功能,也可以创建一个新的类,将现有类包装起来添加新功能。Python 中有许多扩展技术可供选择。
Python 类可以从多个超类继承特性。这可能会导致混乱,但一个简单的设计模式,即mixin,可以避免问题。
一个更大的应用程序可能需要一些全局数据,这些数据被许多类或模块广泛共享。这可能很难管理。然而,我们可以使用一个模块来管理全局对象并创建一个简单的解决方案。
在第四章的内置数据结构 - 列表,集合,字典中,我们看了核心的内置数据结构。现在是时候结合一些特性来创建更复杂的对象了。这也可以包括扩展内置数据结构以添加复杂性。
在继承和扩展之间进行选择 - is-a 问题
在第五章的使用 cmd 创建命令行应用程序和第六章的扩展集合 - 进行统计的列表中,我们看到了扩展类的方法。在这两种情况下,我们的类都是内置类的子类。
扩展的概念有时被称为泛化-特化关系。有时也被称为is-a 关系。
这里有一个重要的语义问题,我们也可以总结为包装与扩展问题:
-
我们真的是指子类是超类的一个例子吗?这就是 is-a 关系。Python 中的一个例子是内置的
Counter,它扩展了基类dict。 -
或者我们是指其他的东西吗?也许有一种关联,有时被称为has-a 关系。这在第六章的设计具有大量处理的类中有一个例子,其中
CounterStatistics包装了一个Counter对象。
有什么好方法来区分这两种技术吗?
准备工作
这个问题有点形而上学的哲学,特别关注本体论的思想。本体论是定义存在类别的一种方式。
当我们扩展一个对象时,我们必须问以下问题:
“这是一个新类的对象,还是现有类的对象的混合?”
我们将看两种模拟一副扑克牌的方法:
-
作为一个扩展内置
list类的新类对象 -
作为一个将内置的
list类与其他一些特性结合的包装器
一副牌是一组卡片。那么,核心成分就是底层的Card对象。我们将使用namedtuple()来非常简单地定义这个:
**>>> from collections import namedtuple
>>> Card = namedtuple('Card', ('rank', 'suit'))
>>> SUITS = '\u2660\u2661\u2662\u2663'
>>> Spades, Hearts, Diamonds, Clubs = SUITS
>>> Card(2, Spades)
Card(rank=2, suit='♣')**
我们使用namedtuple()创建了类定义Card。这创建了一个具有两个属性 - rank和suit的简单类。
我们还将各种花色SUITS定义为 Unicode 字符的字符串。为了更容易地创建特定花色的卡片,我们还将字符串分解为四个单个字符的子字符串。如果您的交互环境无法正确显示 Unicode 字符,您可能会遇到问题。可能需要更改操作系统环境变量PYTHONIOENCODING为“UTF-8”,以便进行正确的编码。
\u2660字符串是一个 Unicode 字符。您可以通过len(SUITS) == 4来确认这一点。如果长度不是 4,请检查是否有多余的空格。
我们将在本配方的其余部分使用这个Card类。在一些纸牌游戏中,使用一副 52 张卡片的牌组。在其他游戏中,使用发牌鞋。鞋子是一个允许荷官将多副牌洗在一起并方便地发牌的盒子。
重要的是各种集合 - 牌组、鞋子和内置列表在它们支持的功能种类上有相当大的重叠。它们都或多或少相关吗?还是它们基本上是不同的?
如何做...
我们将在第六章中的使用类封装数据和处理配方中,与此配方一起包装类和对象的基础:
-
使用原始故事或问题陈述中的名词和动词来识别所有的类。
-
寻找各种类的特征集中的重叠。在许多情况下,关系将直接来自问题陈述本身。在我们之前的例子中,游戏可以从一副牌中发牌,或者从一双鞋中发牌。在这种情况下,我们可能陈述这两种观点之一:
-
鞋子是一个专门的牌组,由 52 张卡片的多个副本开始
-
一副牌是一个专门的鞋子,只有 52 张卡片的一个副本
- 创建一个小本体,澄清类之间的关系。有几种关系。
一些类彼此独立。它们是为了实现用户故事而链接的。在我们的例子中,Card指的是花色的字符串。这两个对象彼此独立。许多卡片将共享一个常见的花色字符串。这些是对象之间的普通引用,没有特殊的设计考虑:
-
聚合:一些对象被绑定到集合中,但这些对象具有独立的存在。我们的
Card对象可能被聚合到一个Hand集合中。游戏结束时,Hand对象可以被删除,但Card对象仍然存在。我们可以创建一个引用内置list的Deck。 -
组合:一些对象被绑定到集合中,但没有独立的存在。在看牌游戏时,一手牌不能没有玩家而存在。我们可能会说
Player对象在某种程度上是由Hand组成的。如果一个Player被从游戏中淘汰,那么Hand对象也必须被移除。虽然这对于理解对象之间的关系很重要,但在下一节中我们将考虑一些实际的考虑。 -
是一个或继承:这是一个
Shoe是一个带有额外功能(或两个)的Deck的想法。这可能是我们设计的核心。我们将在本配方的扩展 - 继承部分详细研究这一点。
我们已经确定了几种实现关联的路径。聚合和组合案例都是包装技术。继承案例是扩展技术。我们将分别研究聚合和组合 - 包装技术和扩展技术。
包装 - 聚合和组合
包装是一种理解集合的方式。它可以是一个包含独立对象的类。它也是一个包装现有列表的组合,这意味着底层的Card对象将被list集合和Deck集合共享。
- 定义独立的集合。它可能是一个内置的集合,例如
set,list或dict。在这个例子中,它将是一个包含卡片的列表:
domain = [Card(r+1,s) for r in range(13) for s in SUITS]
- 定义聚合类。在这个例子中,名称带有
_W后缀。这不是一个推荐的做法;这里只是为了更清楚地区分类定义之间的区别。稍后,我们将看到对这种设计的稍微不同的变化:
class Deck_W:
- 使用这个类的
__init__()方法作为提供底层集合对象的一种方式。这也将初始化任何有状态的变量。我们可能会创建一个用于发牌的迭代器:
def __init__(self, cards:List[Card]):
self.cards = cards.copy()
self.deal_iter = iter(cards)
这使用了一个类型提示,List[Card]。typing模块提供了List的必要定义。
-
如果需要,提供其他方法来替换集合,或更新集合。这在 Python 中很少见,因为底层属性
cards可以直接访问。然而,提供一个替换self.cards值的方法可能是有帮助的。 -
提供适用于聚合对象的方法:
def shuffle(self):
random.shuffle(self.cards)
self.deal_iter = iter(self.cards)
def deal(self) -> Card:
return next(self.deal_iter)
shuffle()方法随机化内部列表对象self.cards。deal()对象创建一个迭代器,可以用来遍历self.cards列表。我们在deal()上提供了一个类型提示,以澄清它返回一个Card实例。
这是我们如何使用这个类的方法。我们将共享一个Card对象列表。在这种情况下,domain变量是从一个列表推导式中创建的,该推导式生成了 13 个等级和四种花色的 52 种组合:
**>>> domain = list(Card(r+1,s) for r in range(13) for s in SUITS)
>>> len(domain)
52**
我们可以使用这个集合中的项目domain来创建一个共享相同底层Card对象的第二个聚合对象。我们将从domain变量中的对象列表构建Deck_W对象:
**>>> import random
>>> from ch07_r01 import Deck_W
>>> d = Deck_W(domain)**
一旦Deck_W对象可用,就可以使用独特的功能:
**>>> random.seed(1)
>>> d.shuffle()
>>> [d.deal() for _ in range(5)]
[Card(rank=13, suit='♡'),
Card(rank=3, suit='♡'),
Card(rank=10, suit='♡'),
Card(rank=6, suit='♢'),
Card(rank=1, suit='♢')]**
我们已经种子化了随机数生成器,以强制卡片有一个定义的顺序。这样可以进行单元测试。之后,我们根据随机种子对牌组进行了洗牌。一旦种子被播下,结果就是一致的,这样单元测试就变得容易了。我们可以从牌组中发出五张牌。这展示了Deck_W对象d如何与domain列表共享相同的对象池。
我们可以删除Deck_W对象d,并从domain列表中创建一个新的牌组。这是因为Card对象不是组合的一部分。这些卡片与Deck_W集合有独立的存在。
扩展-继承
这是一种定义扩展对象集合的类的方法。我们将一个Deck定义为一个包装现有列表的聚合体。底层的Card对象将被列表和Deck共享:
- 将扩展类定义为内置集合的子类。在这个例子中,名称带有
_X后缀。这不是一个推荐的做法;这里只是为了更清楚地区分这个配方中两个类定义之间的区别:
class Deck_X(list):
这是一个清晰而正式的陈述——Deck是一个列表。
-
使用从
list类继承的__init__()方法。不需要代码。 -
使用
list类的其他方法来向Deck添加、更改或删除项目。不需要代码。 -
为扩展对象提供适当的方法:
def shuffle(self):
random.shuffle(self)
self.deal_iter = iter(self)
def deal(self) -> Card:
return next(self.deal_iter)
shuffle()方法将对象作为一个整体进行随机化,因为它是列表的扩展。deal()对象创建一个迭代器,可以用来遍历self.cards列表。我们在deal()上提供了一个类型提示,以澄清它返回一个Card实例。
这是我们如何使用这个类的方法。首先,我们将构建一副牌:
**>>> from ch07_r01 import Deck_X
>>> d2 = Deck_X(Card(r+1,s) for r in range(13) for s in SUITS)
>>> len(d2)
52**
我们使用生成器表达式构建了单独的Card对象。我们可以像使用list()类函数一样使用Deck_X()类函数。在这种情况下,我们从生成器表达式构建了一个Deck_X对象。我们也可以类似地构建一个list。
我们没有为内置的__len__()方法提供实现。这是从list类继承的,并且工作得很好。
对于这个实现,使用特定于牌组的特性看起来与另一个实现Deck_W完全相同:
**>>> random.seed(1)
>>> d2.shuffle()
>>> [d2.deal() for _ in range(5)]
[Card(rank=13, suit='♡'),
Card(rank=3, suit='♡'),
Card(rank=10, suit='♡'),
Card(rank=6, suit='♢'),
Card(rank=1, suit='♢')]**
我们已经初始化了随机数生成器,洗牌了牌组,并发了五张牌。扩展方法对Deck_X和Deck_W同样适用。shuffle()和deal()方法都能正常工作。
它是如何工作的...
Python 查找方法(或属性)的机制如下:
-
在类中搜索方法或属性。
-
如果在当前类中未定义名称,则在所有父类中搜索方法或属性。
这就是 Python 实现继承的方式。通过搜索父类,可以确保两件事:
-
任何超类中定义的方法都可用于所有子类
-
任何子类都可以重写一个方法来替换超类方法
因此,list类的子类继承了父类的所有特性。它是内置list类的专门变体。
这也意味着所有方法都有可能被子类重写。一些语言有方法可以锁定方法防止扩展。像 C++和 Java 这样的语言使用private关键字。Python 没有这个限制,子类可以重写任何方法。
要明确引用超类的方法,我们可以使用super()函数来强制搜索超类。这允许子类通过包装方法的超类版本来添加特性。我们可以像这样使用它:
def some_method(self):
# do something extra
super().some_method()
在这种情况下,some_method()对象将执行一些额外的操作,然后执行方法的超类版本。这使我们能够方便地扩展类的选定方法。我们可以保留超类的特性,同时添加子类独有的特性。
还有更多...
在设计类时,我们必须在几种基本技术之间进行选择:
-
包装:这种技术创建了一个新的类。必须定义所有必需的方法。这可能需要大量的代码来提供所需的方法。包装可以分解为两种广泛的实现选择:
-
聚合:被包装的对象与包装器具有独立的存在。
Deck_W示例展示了Card对象甚至牌组列表与类是独立的。当任何Deck_W对象被删除时,底层列表将继续存在。 -
组合:被包装的对象没有独立的存在;它们是组合的重要部分。这涉及到 Python 的引用计数的微妙难题。我们很快会详细看一下这个问题。
-
通过继承进行扩展:这是 is-a 关系。当扩展内置类时,许多方法都可以从超类中获得。
Deck_X示例通过创建一个作为内置list类扩展的牌组来展示了这种技术。
在查看对象的独立存在时,有一个重要的考虑因素。我们实际上并没有从内存中删除对象。相反,Python 使用一种称为引用计数的技术来跟踪对象被使用的次数。例如del deck这样的语句实际上并没有删除deck对象,而是删除了deck变量,这会减少底层对象的引用计数。如果引用计数为零,则对象未被使用,可以被删除。
考虑以下示例:
**>>> c_2s = Card(2, Spades)
>>> c_2s
Card(rank=2, suit='♠')
>>> another = c_2s
>>> another
Card(rank=2, suit='♠')**
此时,我们有一个对象Card(2, Spades),以及两个引用该对象的变量c_2s和another。
如果我们使用del语句删除其中一个变量,另一个变量仍然引用底层对象。直到两个变量都被删除,对象才能从内存中删除。
这一考虑使得聚合和组合的区别对于 Python 程序员来说基本上无关紧要。在不使用自动垃圾收集或引用计数器的语言中,组合变得重要,因为对象可能会消失。在 Python 中,对象不会意外消失。我们通常关注聚合,因为未使用的对象的删除是完全自动的。
另请参见
-
我们已经在第四章中查看了内置集合,内置数据结构-列表、集合、字典。此外,在第六章中,类和对象的基础知识,我们已经了解了如何定义简单的集合。
-
在设计具有大量处理的类配方中,我们研究了用一个处理处理细节的单独类包装一个类。我们可以将其与第六章中的使用属性进行延迟属性配方进行对比,类和对象的基础知识,在那里我们将复杂的计算作为属性放入类中;这种设计依赖于扩展。
通过多重继承分离关注点
在选择继承和扩展之间-是一个问题配方中,我们研究了定义一个Deck类的想法,它是扑克牌对象的组合。对于该示例,我们将每个Card对象简单地视为具有等级和花色。这产生了一些小问题:
-
卡片的显示总是显示数字等级。我们没有看到 J、Q 或 K。相反,我们看到 11、12 和 13。同样,Ace 显示为 1 而不是 A。
-
许多游戏,如Blackjack和Cribbage,为每个等级分配一个点值。通常,花牌有 10 点。对于 Blackjack,Ace 有两个不同的点值;取决于手中其他牌的总数,它可以值 1 点或 10 点。
我们如何处理卡牌游戏规则的所有变化?
准备好
Card类实际上是两个特征集的混合:
-
一些基本特性,如等级和花色。
-
一些特定于游戏的特性,如点数。对于Cribbage这样的游戏,无论上下文如何,点数都是一致的。然而,对于Blackjack,
Hand和Hand中的Card对象之间存在关系。
Python 允许我们定义一个具有多个父类的类。一个类可以同时拥有Card超类和GameRules超类。
为了理解这种设计,我们经常将各种类层次结构分为两组特征:
-
基本特征:这包括
rank和suit -
Mixin 特性:这些特性被混合到类定义中
这个想法是一个工作类定义将具有基本特征和 mixin 特征。
如何做…
- 定义基本类:
class Card:
__slots__ = ('rank', 'suit')
def __init__(self, rank, suit):
super().__init__()
self.rank = rank
self.suit = suit
def __repr__(self):
return "{rank:2d} {suit}".format(
rank=self.rank, suit=self.suit
)
我们已经定义了一个通用的Card类,适用于等级为 2 到 10。我们通过super().__init__()显式调用任何超类初始化。
- 定义任何子类来处理特殊化:
class AceCard(Card):
def __repr__(self):
return " A {suit}".format(
rank=self.rank, suit=self.suit
)
class FaceCard(Card):
def __repr__(self):
names = {11: 'J', 12: 'Q', 13: 'K'}
return " {name} {suit}".format(
rank=self.rank, suit=self.suit,
name=names[self.rank]
)
我们已经定义了Card类的两个子类。AceCard类处理 Ace 的特殊格式规则。FaceCard类处理 Jack、Queen 和 King 的其他格式规则。
- 定义一个标识将要添加的附加特征的 mixin 超类。在某些情况下,mixin 将全部继承自一个共同的抽象类。在这个例子中,我们将使用一个处理 Ace 到 10 的规则的具体类:
class CribbagePoints:
def points(self):
return self.rank
对于Cribbage游戏,大多数卡片的点数等于卡片的等级。
- 为各种特征定义具体的 mixin 子类:
class CribbageFacePoints(CribbagePoints):
def points(self):
return 10
对于三个花色的牌,点数总是 10。
- 创建结合基本类和混合类的类定义。虽然在这里技术上可以添加独特的方法定义,但这经常会导致混乱。目标是有两组简单合并以创建结果类定义的特性。
class CribbageAce(AceCard, CribbagePoints):
pass
class CribbageCard(Card, CribbagePoints):
pass
class CribbageFace(FaceCard, CribbageFacePoints):
pass
- 创建一个工厂函数(或工厂类)来根据输入参数创建适当的对象:
def make_card(rank, suit):
if rank == 1: return CribbageAce(rank, suit)
if 2 <= rank < 11: return CribbageCard(rank, suit)
if 11 <= rank: return CribbageFace(rank, suit)
- 我们可以使用这个函数来创建一副牌:
**>>> from ch07_r02 import make_card, SUITS
>>> import random
>>> random.seed(1)
>>> deck = [make_card(rank+1, suit) for rank in range(13) for suit in SUITS]
>>> random.shuffle(deck)
>>> len(deck)
52
>>> deck[:5]
[ K ♡, 3 ♡, 10 ♡, 6 ♢, A ♢]**
我们已经种子化了随机数生成器,以确保每次评估shuffle()函数时结果都是相同的。这使得单元测试成为可能。
我们使用列表推导来生成一个包含所有 13 个等级和四种花色的卡牌列表。这是 52 个单独的对象的集合。这些对象属于两个类层次结构。每个对象都是Card的子类,也是CribbagePoints的子类。这意味着所有对象都可以使用这两个特性集合。
例如,我们可以评估每个Card对象的points()方法:
**>>> sum(c.points() for c in deck[:5])
30**
手中有两张花色牌,加上三、六和 A,所以总点数是30。
它是如何工作的...
Python 查找方法(或属性)的机制如下:
-
在类中搜索方法或属性。
-
如果名称在当前类中未定义,则在所有父类中搜索该方法或属性。父类按照称为方法解析顺序(MRO)的顺序进行搜索。
当类被创建时,方法解析顺序被计算。使用的算法称为 C3。更多信息可在en.wikipedia.org/wiki/C3_linearization找到。该算法确保每个父类只被搜索一次。它还确保了超类的相对顺序被保留,以便所有子类在任何父类之前被搜索。
我们可以使用类的mro()方法来查看方法解析顺序。这里有一个例子:
**>>> c = deck[5]
>>> c
10 ♢
>>> c.__class__.__name__
'CribbageCard'
>>> c.__class__.mro()
[<class 'ch07_r02.CribbageCard'>, <class 'ch07_r02.Card'>,
<class 'ch07_r02.CribbagePoints'>, <class 'object'>]**
我们从牌堆中抽取了一张牌c。牌的__class__属性是对该类的引用。在这种情况下,类名是CribbageCard。这个类的mro()方法向我们展示了用于解析名称的顺序:
-
首先搜索类本身,
CribbageCard。 -
如果找不到,搜索
Card。 -
尝试在
CribbagePoints中找到它。 -
最后使用
object。
类定义通常使用内部的dict对象来存储方法定义。这意味着搜索是一个非常快速的哈希查找。额外的开销差异大约是搜索object(当在任何之前的类中找不到时)比搜索Card多 3%的时间。
如果我们进行一百万次操作,我们会看到以下数字:
Card.__repr__ 1.4413
object.__str__ 1.4789
我们比较了查找Card中定义的__repr__()和查找object中定义的__str__()的时间。在一百万次重复中额外的时间总和是 0.03 秒。
由于成本微乎其微,这种能力是构建类层次结构设计的重要方式。
还有更多...
有几种关注点,我们可以像这样分开:
-
持久性和状态的表示:我们可以添加方法来管理转换为一致的外部表示。
-
安全性:这可能涉及一个执行一致授权检查的混合类,这成为每个对象的一部分。
-
日志记录:创建一个跨多个类一致的记录器的混合类可能被定义。
-
事件信号和变更通知:在这种情况下,我们可能有一些产生状态变化通知的对象,以及将订阅这些通知的对象。这些有时被称为可观察者和观察者设计模式。GUI 小部件可能观察对象的状态;当对象发生变化时,它会通知 GUI 小部件,以便刷新显示。
举个小例子,我们可以添加一个 mixin 来引入日志记录。我们将定义这个类,以便它必须首先在超类列表中提供。由于它在 MRO 列表中很早,super()函数将找到后面类列表中定义的方法。
这个类将为每个类添加logger属性:
class Logged:
def __init__(self, *args, **kw):
self.logger = logging.getLogger(self.__class__.__name__)
super().__init__(*args, **kw)
def points(self):
p = super().points()
self.logger.debug("points {0}".format(p))
return p
请注意,我们已经使用super().__init__()来执行 MRO 中定义的任何其他类的__init__()方法。正如我们刚才指出的,通常最简单的方法是有一个类来定义对象的基本特征,所有其他 mixin 只是为该对象添加特性。
我们已经为points()提供了一个定义。这将在 MRO 列表中搜索其他类的points()实现。然后,它将记录另一个类的方法计算的结果。
以下是一些包含Logged mixin 特性的类:
class LoggedCribbageAce(Logged, AceCard, CribbagePoints):
pass
class LoggedCribbageCard(Logged, Card, CribbagePoints):
pass
class LoggedCribbageFace(Logged, FaceCard, CribbageFacePoints):
pass
这些类中的每一个都是由三个单独的类定义构建的。由于Logged类首先提供,我们可以确保所有类都具有一致的日志记录。我们还可以确保Logged中的任何方法都可以使用super()来定位在类定义中跟随它的超类列表中的实现。
要使用这些类,我们需要对应用程序进行一个小的改变:
def make_logged_card(rank, suit):
if rank == 1: return LoggedCribbageAce(rank, suit)
if 2 <= rank < 11: return LoggedCribbageCard(rank, suit)
if 11 <= rank: return LoggedCribbageFace(rank, suit)
我们需要使用这个函数来代替make_card()。这个函数将使用另一组类定义。
以下是我们如何使用这个函数来构建一副卡片实例:
deck = [make_logged_card(rank+1, suit)
for rank in range(13)
for suit in SUITS]
在创建一副牌时,我们用make_logged_card()替换了make_card()。一旦我们这样做,我们现在可以以一致的方式从多个类中获得详细的调试信息。
另请参阅
- 在考虑多重继承时,始终要考虑包装器是否是更好的设计。参见选择继承和扩展之间的选择-是一个问题食谱。
利用 Python 的鸭子类型
大多数情况下,设计涉及继承,从超类到一个或多个子类都有一个明确的关系。在本章的选择继承和扩展之间的选择-是一个问题食谱以及第六章中的扩展集合-进行统计的列表食谱中,我们已经看到了涉及适当子类-超类关系的扩展。
Python 没有正式的抽象超类机制。然而,标准库有一个abc模块,支持创建抽象类。
然而,这并不总是必要的。Python 依赖于鸭子类型来定位类中的方法。这个名字来自这句话:
"当我看到一只像鸭子一样走路、游泳和嘎嘎叫的鸟时,我就称那只鸟为鸭子。"
这句话最初来自詹姆斯·惠特科姆·赖利。有时被视为归纳推理的总结:我们从观察到一个更完整的理论,其中包括了那个观察。在 Python 类关系的情况下,如果两个对象具有相同的方法和属性,这与具有共同的超类具有相同的效果。即使除了object类之外没有共同的超类定义,它也可以工作。
我们可以称方法和属性的集合为类的签名。签名唯一标识了类的属性和行为。在 Python 中,签名是动态的,匹配只是在对象的命名空间中查找名称。
我们能利用这个吗?
准备好
通常很容易创建一个超类,并确保所有子类都扩展了这个类。但在某些情况下,这可能会很麻烦。例如,如果一个应用程序分布在几个模块中,可能很难因素出一个共同的超类,并将其单独放在一个单独的模块中,以便可以广泛包含它。
相反,有时更容易避免共同的超类,只需检查两个类是否等效,使用鸭子测试——两个类具有相同的方法和属性,因此,它们实际上是某个没有正式实现为 Python 代码的超类的成员。
我们将使用一对简单的类来展示这是如何工作的。这些类都将模拟掷一对骰子。虽然问题很简单,但我们可以轻松地创建各种实现。
如何做...
- 定义一个具有所需方法和属性的类。在这个例子中,我们将有一个属性
dice,它保留了上次掷骰子的结果,以及一个方法roll(),它改变了骰子的状态:
class Dice1:
def __init__(self, seed=None):
self._rng = random.Random(seed)
self.roll()
def roll(self):
self.dice = (self._rng.randint(1,6),
self._rng.randint(1,6))
return self.dice
- 定义其他具有相同方法和属性的类。以下是一个稍微复杂的定义,它创建了一个与
Dice1类具有相同签名的类:
class Die:
def __init__(self, rng):
self._rng= rng
def roll(self):
return self._rng.randint(1, 6)
class Dice2:
def __init__(self, seed=None):
self._rng = random.Random(seed)
self._dice = [Die(self._rng) for _ in range(2)]
self.roll()
def roll(self):
self.dice = tuple(d.roll() for d in self._dice)
return self.dice
这个类引入了一个额外的属性,_dice。这种实现上的改变并不会改变单个属性dice和方法roll()的公开接口。
在这一点上,这两个类可以自由交换:
def roller(dice_class, seed=None, *, samples=10):
dice = dice_class(seed)
for _ in range(samples):
yield dice.roll()
我们可以使用这个函数如下:
**>>> from ch07_r03 import roller, Dice1, Dice2
>>> list(roller(Dice1, 1, samples=5))
[(1, 3), (1, 4), (4, 4), (6, 4), (2, 1)]
>>> list(roller(Dice2, 1, samples=5))
[(1, 3), (1, 4), (4, 4), (6, 4), (2, 1)]**
从Dice1和Dice2构建的对象有足够的相似之处,以至于它们是无法区分的。
当然,我们可以推动边界,并寻找_dice属性作为区分两个类的方法。我们也可以使用__class__来区分这两个类。
它是如何工作的...
当我们编写形式为namespace.name的表达式时,Python 将在给定的命名空间中查找名称。算法的工作方式如下:
-
搜索对象的
self.__dict__集合以查找名称。一些类定义将节省空间,使用__slots__。有关此优化的更多信息,请参阅第六章中的使用 slots 优化小对象,类和对象的基础知识。这通常是如何找到属性值的。 -
搜索对象的
self.__class__.__dict__集合以查找名称。这通常是方法被找到的方式。 -
正如我们在选择继承和扩展之间的区别——is-a 问题和通过多重继承分离关注点中所指出的,搜索可以继续通过类的所有超类。这个搜索是按照定义的方法解析顺序进行的。
有两个基本的结果:
-
该值是一个不可调用的对象。这就是值。这是属性的典型特征。
-
属性的值是类的绑定方法。这对于普通方法和属性都是正确的。有关属性的更多信息,请参阅第六章中的使用属性进行惰性属性,类和对象的基础知识。绑定方法必须被评估。对于简单的方法,参数在方法名称后的
()中。对于属性,没有带有方法参数值的()。
注意
我们省略了一些关于如何使用描述符的细节。对于最常见的用例,描述符的存在并不重要。
这的本质是通过__dict__(或__slots__)名称集合进行搜索。如果对象有一个共同的超类,那么我们可以保证会找到匹配的名称。如果对象没有共同的超类,那么我们就没有同样的保证。我们必须依赖纪律性的设计和良好的测试覆盖率。
还有更多...
当我们查看 decimal 模块时,我们看到了一个与所有其他数值类型不同的数值类型的例子。为了使其正常工作,numbers 模块包括了将类注册为 Number 类层次结构的一部分的概念。这样可以在不使用继承的情况下将一个新类注入到层次结构中。
codecs 模块使用类似的技术来添加新的数据编码。我们可以定义一个新的编码并将其注册,而不使用 codecs 模块中定义的任何类。
之前,我们注意到类的方法的搜索涉及描述符的概念。在内部,Python 使用描述符对象来创建对象的可获取和可设置属性。
描述符对象必须实现一些特殊方法 __get__ 、__set__ 和 __delete__ 的组合。当属性出现在表达式中时,将使用 __get__ 来定位值。当属性出现在赋值的左侧时,将使用 __set__。在 del 语句中,将使用 __delete__ 方法。
描述符对象充当中介,以便一个简单的属性可以在各种上下文中使用。很少直接使用描述符。我们可以使用 @property 装饰器为我们构建描述符。
另请参阅
-
鸭子类型问题在选择继承和扩展之间——is-a 问题的示例中是隐含的;如果我们利用鸭子类型,我们也在声称两个类不是同一种东西。当我们绕过继承时,我们隐含地声称 is-a 关系不成立。
-
当查看通过多重继承分离关注点的示例时,我们还可以利用鸭子类型来创建可能没有简单继承层次结构的组合类。由于使用混合设计模式非常简单,很少需要使用鸭子类型。
管理全局和单例对象
Python 环境包含许多隐式全局对象。这些对象提供了一种方便的方式来处理其他对象的集合。由于集合是隐式的,我们不必写任何显式的初始化代码,从而避免了麻烦。
其中一个例子是 random 模块中的一个隐式随机数生成对象。当我们评估 random.random() 时,实际上是在使用 random 模块中隐式的 random.Random 类的一个实例。
其他例子包括以下内容:
-
可用的数值类型的集合。默认情况下,我们只有
int、float和complex。但是,我们可以添加更多的数值类型,并且它们将与现有类型无缝配合。有一个可用数值类型的全局注册表。 -
可用的数据编码/解码方法(编解码器)的集合。
codecs模块列出了可用的编码器和解码器。这也涉及到一个隐式注册。我们可以向这个注册表中添加编码和解码。 -
webbrowser模块有一个已知浏览器的注册表。在大多数情况下,操作系统默认浏览器是用户首选的浏览器,也是要使用的正确的浏览器,但应用程序也可以启动用户首选浏览器之外的浏览器。还可以注册一个新的浏览器,该浏览器是特定应用程序的唯一浏览器。
我们如何处理这种隐式全局对象?
准备工作
通常,隐式对象可能会引起一些混淆。想法是提供一套功能作为独立的函数,而不是对象的方法。然而,好处是允许独立的模块共享一个公共对象,而无需编写任何显式协调模块之间的代码。
举个简单的例子,我们将定义一个具有全局单例对象的模块。我们将在第十三章中更详细地了解模块,应用集成。
我们的全局对象将是一个计数器,我们可以用它来积累来自几个独立模块或对象的集中数据。我们将使用简单的函数来提供对这个对象的接口。
目标是能够编写类似这样的内容:
for row in source:
count('input')
some_processing()
print(counts())
这意味着会有两个函数引用一个全局计数器:
-
count():它将增加计数器并返回当前值 -
counts():它将提供所有不同的计数器值
如何做...
有两种处理全局状态信息的方法。一种技术使用模块全局变量,因为模块是单例对象。另一种使用类级(静态)变量,因为类定义也是单例对象,我们将展示这两种技术。
模块全局变量
-
创建一个模块文件。这将是一个
.py文件,其中包含定义。我们将其称为counter.py。 -
如果有必要,为全局单例定义一个类。在我们的例子中,我们可以使用这个定义:
from collections import Counter
在某些情况下,可能会使用types.SimpleNamespace。在其他情况下,可能需要一个更复杂的类,其中包括方法和属性。
- 定义全局单例对象的唯一实例:
_global_counter = Counter()
我们在名称中使用了一个前导_,使其稍微不太可见。它不是——技术上——私有的。然而,它被许多 Python 工具和实用程序优雅地忽略了。
- 定义任何包装函数:
def count(key, increment=1):
_global_counter[key] += increment
def counts():
return _global_counter.most_common()
我们定义了两个使用全局对象_global_counter的函数。这些函数封装了计数器的实现细节。
现在我们可以编写应用程序,在各种地方使用count()函数。然而,计数的事件完全集中在这个单一对象中。
我们可能有这样的代码:
**>>> from ch07_r04 import count, counts
>>> from ch07_r03 import Dice1
>>> d = Dice1(1)
>>> for _ in range(1000):
... if sum(d.roll()) == 7: count('seven')
... else: count('other')
>>> print(counts())
[('other', 833), ('seven', 167)]**
我们从一个中央模块导入了count()和counts()函数。我们还导入了Dice1对象作为一个方便的对象,我们可以用它来创建一系列事件。当我们创建Dice1的一个实例时,我们提供一个初始化来强制使用特定的随机种子。这可以得到可重复的结果。
然后我们可以使用对象d来创建随机事件。在这个演示中,我们将事件分类为两个简单的桶,标记为seven和other。count()函数使用了一个隐含的全局对象。
当模拟完成时,我们可以使用counts()函数来输出结果。这将访问模块中定义的全局对象。
这种技术的好处是,几个模块都可以共享ch07_r04模块中的全局对象。只需要一个import语句。不需要进一步的协调或开销。
类级静态变量
- 定义一个类并在
__init__方法之外提供一个变量。这个变量是类的一部分,而不是每个单独实例的一部分。它被所有类的实例共享:
from collections import Counter
class EventCounter:
_counts = Counter()
我们给类级变量加了一个前导下划线,使其不太公开。这是对使用类的任何人的一个提示,该属性是一个可能会改变的实现细节。它不是类的可见接口的一部分。
- 添加方法来更新和提取这个变量的数据:
def count(self, key, increment=1):
EventCounter._counts[key] += increment
def counts(self):
return EventCounter._counts.most_common()
我们在这个例子中没有使用self,是为了说明变量赋值和实例变量。当我们在赋值语句的右侧使用self.name时,名称可以由对象、类或任何超类解析。这是搜索类的普通规则。
当我们在赋值语句的左侧使用self.name时,那将创建一个实例变量。我们必须使用Class.name来确保更新类级变量,而不是创建一个实例变量。
各种应用程序组件可以创建对象,但所有对象都共享一个公共类级值:
>>> from ch07_r04 import EventCounter
>>> c1 = EventCounter()
>>> c1.count('input')
>>> c2 = EventCounter()
>>> c2.count('input')
>>> c3 = EventCounter()
>>> c3.counts()
[('input', 2)]
在这个示例中,我们创建了三个单独的对象,c1,c2和c3。由于所有三个对象共享EventCounter类中定义的一个公共变量,因此每个对象都可以用于增加该共享变量。这些对象可以是单独的模块、单独的类或单独的函数的一部分,但仍然共享一个共同的全局状态。
它是如何工作的...
Python 导入机制使用sys.modules来跟踪加载了哪些模块。一旦模块在这个映射中,它就不会再次加载。这意味着在模块内定义的任何变量都将是单例:只会有一个实例。
我们有两种方法来共享这些全局单例变量:
-
显式使用模块名称。我们本可以在模块中简单地创建
Counter的实例,并通过counter.counter共享它。这样可以工作,但它暴露了一个实现细节。 -
使用包装函数,如本示例所示。这需要更多的代码,但它允许在不破坏应用程序的其他部分的情况下进行实现的更改。
这些函数提供了一种识别全局变量相关特征的方式,同时封装了它的实现细节。这使我们有自由考虑改变实现细节的自由。只要包装函数具有相同的语义,实现就可以自由更改。
由于我们通常只提供一个类的定义,Python 导入机制倾向于向我们保证类定义是一个正确的单例对象。如果我们错误地复制一个类定义,并将其粘贴到单个应用程序使用的两个或更多模块中,我们将不会在这些类定义之间共享一个全局对象。这是一个容易避免的错误。
我们如何在这两种机制之间进行选择?选择是基于多个类共享全局状态所造成的混乱程度。如前面的示例所示,三个变量共享一个公共的Counter对象。隐式共享全局状态的存在可能会令人困惑。
还有更多...
共享全局状态在某种程度上与面向对象编程相反。面向对象编程的一个理想是将所有状态变化封装在各个对象中。当我们有一个共享的全局状态时,我们已经偏离了这个理想:
-
使用包装函数使共享对象变得隐式
-
使用类级变量隐藏了对象是共享的事实
当然,另一种选择是显式地创建一个全局对象,并以一种更明显的方式将其作为应用程序的一部分。这可能意味着将对象作为初始化参数提供给整个应用程序中的对象。在复杂的应用程序中,这可能是一个相当大的负担。
拥有一些共享的全局对象更具吸引力,因为应用程序变得更简单。当这些对象用于普遍特性,如审计、日志记录和安全性时,它们可能会有所帮助。
这是一种容易被滥用的技术。依赖过多全局对象的设计可能会令人困惑。它也可能存在微妙的错误,因为在类中封装对象可能难以辨别。它也可能使单元测试用例难以编写,因为对象之间的隐式关系。

使用更复杂的结构 - 列表的映射
在第四章中,内置数据结构 - 列表,集合,字典,我们看了 Python 中可用的基本数据结构。这些示例通常独立地查看了各种结构。
我们将看一下一个常见的组合结构 - 从一个键到一个列表的映射。这用于累积有关由给定键标识的对象的详细信息。这个示例将把详细信息的平面列表转换成一个结构,其中一个列包含来自其他列的值。
准备工作
我们将使用一个虚构的网络日志,它已经从原始网络格式转换为 CSV(逗号分隔值)格式。这种转换通常是通过使用正则表达式来选择各种句法组完成的。有关解析可能如何工作的信息,请参阅第一章中的使用正则表达式解析字符串配方,数字、字符串和元组。
原始数据看起来像这样:
**[2016-04-24 11:05:01,462] INFO in module1: Sample Message One**
**[2016-04-24 11:06:02,624] DEBUG in module2: Debugging**
**[2016-04-24 11:07:03,246] WARNING in module1: Something might have gone wrong**
文件中的每一行都有一个时间戳、一个严重级别、一个模块名称和一些文本。解析后,数据实际上是一个事件的平面列表。它看起来像这样:
**>>> data = [
('2016-04-24 11:05:01,462', 'INFO', 'module1', 'Sample Message One'),
('2016-04-24 11:06:02,624', 'DEBUG', 'module2', 'Debugging'),
('2016-04-24 11:07:03,246', 'WARNING', 'module1', 'Something might have gone wrong')
]**
我们想要检查日志,创建一个按模块组织的所有消息的列表,而不是按时间顺序。这种重组可以使分析更简单。
操作方法...
- 从
collections导入defaultdict:
from collections import defaultdict
- 使用
list函数作为defaultdict的默认值:
module_details = defaultdict(list)
- 通过数据进行迭代,将其附加到与每个键关联的列表中。
defaultdict对象将使用list()函数为每个新键构建一个空列表:
for row in data:
module_details[row[2]].append(row)
这将产生一个从模块到该模块名称的所有日志行的列表的字典。数据看起来像这样:
{
'module1': [
('2016-04-24 11:05:01,462', 'INFO', 'module1', 'Sample Message One'),
('2016-04-24 11:07:03,246', 'WARNING', 'module1', 'Something might have gone wrong')
],
'module2': [
('2016-04-24 11:06:02,624', 'DEBUG', 'module2', 'Debugging')
]
}
该映射的键是模块名称,映射中的值是该模块名称的行列表。现在我们可以专注于特定模块的分析。
工作原理...
当键未找到时,映射的行为有两种选择:
-
内置的
dict类在键丢失时会引发异常。 -
defaultdict类在键丢失时会评估一个创建默认值的函数。在许多情况下,该函数是int或float,用于创建默认的数值。在这种情况下,该函数是list,用于创建一个空列表。
我们可以想象使用set函数为缺少的键创建一个空的set对象。这适用于从键到共享该键的对象集的映射。
还有更多...
当我们考虑 Python 3.5 和进行类型推断的能力时,我们需要有一种描述这种结构的方法:
from typing import *
def summarize(data) -> Mapping[str, List]:
the body of the function.
这使用符号Mapping[str, List]来显示结果是从字符串键到字符串数据项列表的映射。
我们还可以构建一个作为内置dict类的扩展版本:
class ModuleEvents(dict):
def add_event(self, event):
if event[2] not in self:
self[event[2]] = list()
self[event[2]].append(row)
我们已经定义了一个对这个类独有的方法add_event()。如果字典中当前不存在event[2]中的模块名称的键,则将添加空列表。在if语句之后,可以添加一个后置条件来断言该键现在在字典中。
这使我们能够使用以下代码:
module_details = ModuleEvents()
for row in data:
module_details.add_event(row)
结果结构与defaultdict非常相似。
另请参阅
-
在第四章的创建字典 - 插入和更新配方中,我们看到了使用映射的基础知识
-
在第四章的避免函数参数的可变默认值配方中,我们看到了其他使用默认值的地方
-
在第六章的使用更复杂的集合配方中,我们看到了使用
defaultdict类的其他示例
创建一个具有可排序对象的类
在模拟纸牌游戏时,能够将Card对象排序到一个定义的顺序中通常是至关重要的。当卡片形成一个序列时,有时被称为顺子,这可能是评分手的重要方式。这是类似扑克牌、Cribbage 甚至 Pinochle 的游戏的一部分。
我们的大多数类定义都没有包括对将对象排序的必要特征。许多示例都将对象保留在基于__hash__()计算的内部哈希值的映射或集合中。
为了将项目保留在有序集合中,我们需要实现<、>、<=、>=、==和!=的比较方法。这些比较是基于每个对象的属性值。
我们如何创建可比较的对象?
准备工作
Pinochle 游戏通常涉及一副有 48 张牌的牌组。有六个等级——9、10、J、Q、K 和 A。有标准的四种花色。这 24 张牌中的每一张在牌组中都出现两次。我们必须小心使用诸如 dict 或 set 之类的结构,因为在 Pinochle 中卡片并不是唯一的;可能会有重复。
在通过多重继承分离关注点的示例中,我们使用了两个类定义来定义纸牌。Card类层次结构定义了每张牌的基本特征。第二组混合类为每张牌提供了特定于游戏的特征。
我们需要为这些牌添加特征,以创建可以正确排序的对象。为了支持定义有序集合的示例,我们将研究 Pinochle 游戏的牌。
以下是设计的前两个元素:
from ch07_r02 import AceCard, Card, FaceCard, SUITS
class PinochlePoints:
_points = {9: 0, 10:10, 11:2, 12:3, 13:4, 14:11}
def points(self):
return self._points[self.rank]
我们已经导入了现有的Card层次结构。我们还定义了在玩牌过程中计算每张牌得分的规则,PinochlePoints类。这个类有一个从卡片等级到每张卡片可能令人困惑的点数的映射。
10 点值 10 分,A 值 11 分,但 K、J 和 Q 分别值 4、3 和 2 分。这可能会让新玩家感到困惑。
因为 A 的排名在识别顺子的目的上高于 K,所以我们将 A 的排名设为 14。这在一定程度上简化了处理过程。
为了使用有序的卡片集合,我们需要为卡片添加另一个特征。我们需要定义比较操作。用于对象比较的有六个特殊方法。
如何做...
- 我们正在使用混合设计。因此,我们将创建一个新的类来保存比较特征:
class SortedCard:
这个类将加入Card层次结构的成员加上PinochlePoints,以创建最终的复合类定义。
- 定义六个比较方法:
def __lt__(self, other):
return (self.rank, self.suit) < (other.rank, other.suit)
def __le__(self, other):
return (self.rank, self.suit) <= (other.rank, other.suit)
def __gt__(self, other):
return (self.rank, self.suit) > (other.rank, other.suit)
def __ge__(self, other):
return (self.rank, self.suit) >= (other.rank, other.suit)
def __eq__(self, other):
return (self.rank, self.suit) == (other.rank, other.suit)
def __ne__(self, other):
return (self.rank, self.suit) != (other.rank, other.suit)
我们已经完整地写出了所有六个比较。我们将Card的相关属性转换为元组,并依赖于 Python 的内置元组比较来处理细节。
- 编写复合类定义,由一个基本类和两个混合类构建以提供额外特征:
class PinochleAce(AceCard, SortedCard, PinochlePoints):
pass
class PinochleFace(FaceCard, SortedCard, PinochlePoints):
pass
class PinochleNumber(Card, SortedCard, PinochlePoints):
pass
最终的类包含具有三个独立且大部分独立的特征集的元素:基本的Card特征,混合比较特征和混合 Pinochle 特定特征。
- 创建一个函数,从先前定义的类中创建单独的卡片对象:
def make_card(rank, suit):
if rank in (9, 10):
return PinochleNumber(rank, suit)
elif rank in (11, 12, 13):
return PinochleFace(rank, suit)
else:
return PinochleAce(rank, suit)
尽管点数规则非常复杂,但复杂性隐藏在PinochlePoints类中。构建复合类作为Card和PinochlePoints的基类子类会导致对牌的准确建模,而不会有太多明显的复杂性。
现在我们可以制作可以响应比较运算符的卡片:
**>>> from ch07_r06a import make_card
>>> c1 = make_card(9, '♡')
>>> c2 = make_card(10, '♡')
>>> c1 < c2
True
>>> c1 == c1
True
>>> c1 == c2
False
>>> c1 > c2
False**
这是一个构建 48 张牌的牌组的函数:
SUITS = '\u2660\u2661\u2662\u2663'
Spades, Hearts, Diamonds, Clubs = SUITS
def make_deck():
return [make_card(r, s) for _ in range(2)
for r in range(9, 15)
for s in SUITS]
SUITS的值是四个 Unicode 字符。我们本可以分别设置每个花色字符串,但这样似乎稍微简单一些。make_deck()函数内部的生成器表达式构建了每张牌的两份副本。只有六种等级和四种花色。
工作原理...
Python 为大量的事情使用特殊方法。语言中几乎每个可见的行为都归因于某些特殊方法名称。在这个示例中,我们利用了六个比较运算符。
写下以下内容:
c1 <= c2
前面的代码被评估为我们写了以下内容:
c1.__le__(c2)
这种转换适用于所有表达式运算符。
仔细研究Python 语言参考的第 3.3 节表明,特殊方法可以组织成几个不同的组:
-
基本定制
-
自定义属性访问
-
自定义类创建
-
自定义实例和子类检查
-
模拟可调用对象
-
模拟容器类型
-
模拟数值类型
-
使用语句上下文管理器
在这个配方中,我们只看了这些类别中的第一个。其他的遵循一些类似的设计模式。
当我们创建这个类层次结构的实例时,它看起来是这样的。第一个例子将创建一个 48 张牌的 Pinochle 牌组:
**>>> from ch07_r06a import make_deck
>>> deck = make_deck()
>>> len(deck)
48**
如果我们看一下前八张牌,我们可以看到它们是如何由所有等级和花色的组合构建而成的:
**>>> deck[:8]
[ 9 ♠, 9 ♡, 9 ♢, 9 ♣, 10 ♠, 10 ♡, 10 ♢, 10 ♣]**
如果我们看一下牌组的后半部分,我们会发现它与牌组的前半部分是一样的:
**>>> deck[24:32]
[ 9 ♠, 9 ♡, 9 ♢, 9 ♣, 10 ♠, 10 ♡, 10 ♢, 10 ♣]**
由于deck变量是一个简单的列表,我们可以打乱列表对象并选择十二张牌。
**>>> import random
>>> random.seed(4)
>>> random.shuffle(deck)
>>> sorted(deck[:12])
[ 9 ♣, 10 ♣, J ♠, J ♢, J ♢, Q ♠, Q ♣, K ♠, K ♠, K ♣, A ♡, A ♣]**
重要的部分是使用sorted()函数。因为我们已经定义了适当的比较运算符,我们可以对Card实例进行排序,并按预期顺序呈现。
还有更多...
一点形式逻辑表明,我们实际上只需要实现两种比较。对于任何两个,其他所有的都可以推导出来。例如,如果我们只能执行小于(__lt__() )和等于(__eq__() )的操作,我们可以相当容易地计算出其余的三个:
a ≤ b ≡ a < b ∨ a = b
a ≥ b ≡ a > b ∨ a = b
a ≠ b ≡ ¬(a = b)
Python 明确不会为我们执行任何这种高级代数。我们需要仔细进行代数运算,或者如果我们对逻辑不确定,可以完整地写出所有六个比较。
我们假设每个Card都与另一张卡进行比较。试试这个:
**>>> c1 = make_card(9, '♡')
>>> c1 == 9**
我们将得到一个AttributeError异常。
如果我们需要这个功能,我们将不得不修改比较运算符来处理两种比较:
-
Card对Card -
Card对int
这是通过使用isinstance()函数来区分参数类型来完成的。
我们的每个比较方法将被更改为这样:
def __lt__(self, other):
if isinstance(other, Card):
return (self.rank, self.suit) < (other.rank, other.suit)
else:
return self.rank < other
这处理了Card与Card之间的情况,使用等级和花色进行比较。对于所有其他情况,Python 使用普通的规则来将等级与其他值进行比较。如果由于某种隐晦的原因,另一个值是float,那么将在self.rank上使用float()转换。
另请参阅
- 查看依赖于对这些卡进行排序的定义有序集合配方
定义一个有序集合
在模拟纸牌游戏时,玩家的手可以被建模为一组牌或一叠牌。对于大多数传统的单副牌游戏,集合是很好的选择,因为任何给定的牌只有一个实例,并且集合类可以非常快速地执行操作来确认给定的牌是否在集合中(或不在)。
然而,在建模 Pinochle 时,我们面临一个具有挑战性的问题。Pinochle 牌组有 48 张牌;它有两张 9、10、J、Q、K 和 A。简单的集合对于这个不太适用;我们需要一个多重集或袋子。这是一个允许重复项的集合。
这些操作仍然仅限于成员测试。例如,我们可以多次添加对象Card(9,'♢')对象,然后多次删除它。
我们有多种方法来创建多重集:
-
我们可以使用列表。添加一个项目几乎是固定成本,被描述为O(1)。搜索项目的性能有严重问题。测试成员资格的复杂性往往随着集合大小的增长而增长。它变成了O(n)。
-
我们可以使用映射;值可以是重复元素出现的整数计数。这只需要映射中每个对象都有默认的
__hash__()方法。我们有三种实现这种方法的方式: -
定义我们自己的 dict 子类。
-
使用
defaultdict。请参阅使用更复杂的结构-列表映射示例,该示例使用defaultdict(list)为每个键创建一个值列表。该列表的len()是键出现的次数。实际上,这是一种多重集。 -
使用
Counter。这可以非常简单。我们已经在许多示例中看过Counter。请参阅第四章中的避免函数参数的可变默认值,内置数据结构-列表、集合、字典,以及第六章中的设计具有大量处理的类和使用属性进行惰性属性,类和对象的基础知识,以及本章的管理全局和单例对象示例。 -
我们可以使用有序列表。插入维护此排序顺序的项目比插入列表稍微昂贵,O ( n log [2] n )。然而,搜索比无序列表便宜;它是O (log [2] n )。
bisect模块提供了一组很好地执行此操作的函数。然而,这需要具有完整比较方法集的对象。
我们如何构建一个有序对象的有序集合?如何使用有序集合构建多重集或袋?
准备就绪
在创建具有可排序对象的类示例中,我们定义了可以排序的卡片。这对于使用bisect至关重要。该模块中的算法要求对象之间进行全面的比较。
我们将定义一个多重集,以保留 12 张 Pinochle 手牌。由于重复,同一等级和花色的卡片将会有多张。
为了将手牌视为一种集合,我们还需要在手牌对象上定义一些集合运算符。其思想是定义集合成员和子集运算符。
我们希望有 Python 代码等效于以下内容:
c ∈ H
这是针对一张卡片c和一手牌H={ c [1] , c [2] , c [3] ,... }。
我们还希望有与此等效的代码:
{ J, Q } ⊂ H
这是针对一对特定的卡片,称为 Pinochle,以及一手牌,H。
我们需要导入两样东西:
from ch07_r06a import *
import bisect
第一个导入将我们可排序的卡片定义从创建具有可排序对象的类示例中引入。第二个导入将我们将用来维护一个有序集合的各种 bisect 函数引入。
如何做...
- 定义一个类,其中初始化可以从任何可迭代的数据源加载集合:
class Hand:
def __init__(self, card_iter):
self.cards = list(card_iter)
self.cards.sort()
我们可以使用这个从列表或可能是生成器表达式构建一个Hand。如果列表不为空,我们需要将项目排序。self.cards列表的sort()方法将依赖于Card对象实现的各种比较运算符。
从技术上讲,我们只关心那些是SortedCard的子类的对象,因为这是定义比较方法的地方。
- 定义一个将卡片添加到手牌的方法:
def add(self, aCard: Card):
bisect.insort(self.cards, aCard)
我们使用bisect算法来确保卡片被正确插入到self.cards列表中。
- 定义一个查找给定卡片在手牌中位置的方法:
def index(self, aCard: Card):
i = bisect.bisect_left(self.cards, aCard)
if i != len(self.cards) and self.cards[i] == aCard:
return i
raise ValueError
我们使用bisect算法来定位给定的卡片。建议在bisect.bisect_left()的文档中使用额外的if测试来正确处理处理中的边缘情况。
- 定义实现
in运算符的特殊方法:
def __contains__(self, aCard: Card):
try:
self.index(aCard)
return True
except ValueError:
return False
当我们在 Python 中编写card in some_hand时,它会被计算为如果我们编写了some_hand.__contains__(card)。我们使用index()方法来查找卡片或引发异常。异常被转换为False的返回值。
- 定义手牌上的迭代器。这只是对
self.cards集合的简单委托:
def __iter__(self):
return iter(self.cards)
当我们在 Python 中编写iter(some_hand)时,它会被计算为如果我们编写了some_hand.__iter__()。
- 在两个手实例之间定义一个子集操作:
def __le__(self, other):
for card in self:
if card not in other:
return False
return True
Python 没有a⊂b或a⊆b符号,因此<和<=被用来比较集合。当我们写pinochle <= some_hand来查看手中是否包含特定的卡片组合时,它被评估为如果我们写了pinochle.__le__(some_hand)。子集是 self 实例变量,目标 Hand 是另一个参数值。
in 运算符由 contains()方法实现。这显示了简单的 Python 语法是如何由特殊方法实现的。
我们可以像这样使用这个 Hand 类:
**>>> from ch07_r06b import make_deck, make_card, Hand
>>> import random
>>> random.seed(4)
>>> deck = make_deck()
>>> random.shuffle(deck)
>>> h = Hand(deck[:12])
>>> h.cards
[ 9 ♣, 10 ♣, J ♠, J ♢, J ♢, Q ♠, Q ♣, K ♠, K ♠, K ♣, A ♡, A ♣]**
卡片在手中被正确排序。这是手的创建方式的结果。
以下是使用子集运算符<=将特定模式与整个手进行比较的示例:
**>>> pinochle = Hand([make_card(11,'♢'), make_card(12,'♠')])
>>> pinochle <= h
True**
手是一个集合,并支持迭代。我们可以使用引用整个手中的卡对象的生成器表达式:
**>>> sum(c.points() for c in h)
56**
它是如何工作的...
我们的 Hand 集合通过包装内部的 list 对象并对该对象应用重要的约束来工作。项目按排序顺序保留。这增加了插入新项目的成本,但减少了搜索项目的成本。
用于查找项目位置的核心算法是bisect模块的一部分,这样我们就不必编写(和调试)它们。这些算法实际上并不是非常复杂。但利用现有代码似乎更有效。
该模块的名称来自于对排序列表进行二分查找的想法。其本质是:
while lo < hi:
mid = (lo+hi)//2
if x < a[mid]: hi = mid
else: lo = mid+1
这将搜索列表a以查找给定值x。lo的值最初为零,hi的值最初为列表的大小len(a)。
首先,确定中点。如果目标值x小于中点值a[mid],那么它必须在列表的前半部分:将hi的值移动,以便只考虑前半部分。
如果目标值x大于或等于中点值a[mid],那么x必须在列表的后半部分:将lo的值移动,以便只考虑后半部分。
由于每次操作都会将列表减半,因此需要O(log[2]n)步才能使 lo 和 hi 的值收敛到应该具有目标值的位置。
如果我们有一个有 12 张卡的手,那么第一个比较会丢弃六张。下一个比较会再丢弃三张。下一个比较会丢弃最后三张中的一张。第四个比较将找到卡片应该占据的位置。
如果我们使用普通列表,卡片按到达的随机顺序存储,那么找到一张卡片将平均需要六次比较。最坏的情况意味着它是 12 张卡片中的最后一张,需要检查所有 12 张。
使用bisect,比较的次数始终是O(log[2]n)。这是平均值和最坏情况。
还有更多...
collections.abc模块为各种集合定义了抽象基类。如果我们希望我们的 Hand 表现得更像其他类型的集合,我们可以利用这些定义。
我们可以在这个类定义中添加许多集合运算符,使其更像内置的MutableSet抽象类定义。
MutableSet是Set的扩展。Set类是由三个类定义构建的复合类:Sized,Iterable和Container。这意味着它必须定义以下方法:
-
__contains__() -
__iter__() -
__len__() -
add()
-
discard()
我们还需要提供一些其他作为可变集合的方法:
-
clear(),pop():这些将从集合中删除项目。 -
remove():与discard()不同,当尝试删除缺失的项目时,这将引发异常。
为了具有唯一的集合特性,还需要一些额外的方法。我们提供了一个基于 le()的子集的示例。我们还需要提供以下子集比较:
-
__le__() -
__lt__() -
__eq__() -
__ne__() -
__gt__() -
__ge__() -
isdisjoint()
这些通常不是简单的一行定义。为了实现核心比较集,我们经常会写两个,然后使用逻辑来基于这两个构建其余部分。
由于__eq__()很简单,让我们假设我们已经为==和<=运算符定义了定义。其他的将定义如下:
x ≠ y ≡ ¬( x = y )
x < y ≡ ( x ≤ y ) ∧ ¬( x = y )
x > y ≡ ¬( x ≤ y )
x ≥ y ≡ ¬( x < y ) ≡ ¬( x ≤ y ) ∨ ( x = y )
为了进行集合操作,我们需要提供以下内容:
-
__and__()和__iand__()。这些方法实现了 Python 的&运算符和&=赋值语句。在两个集合之间,这是一个集合的交集,或者a ∩ b。 -
__or__()和__ior__()。这些方法实现了 Python 的|运算符和|=赋值语句。在两个集合之间,这是一个集合的并集,或者a ∪ b。 -
__sub__()和__isub__()。这些方法实现了 Python 的-运算符和-=赋值语句。在集合之间,这是一个集合的差,通常写作a - b。 -
__xor__()和__ixor__()。这些方法实现了 Python 的^运算符和^=赋值语句。当应用于两个集合之间时,这是对称差,通常写作a ∆ b。
抽象类允许每个运算符有两个版本。有两种情况:
-
如果我们提供了
__iand__(),那么语句A &= B将被计算为A.__iand__(B)。这可能会允许有效的实现。 -
如果我们不提供
__iand__(),那么语句A &= B将被计算为A = A.__and__(B)。这可能会有点不太高效,因为我们将创建一个新对象。新对象被标记为A,旧对象将从内存中删除。
几乎需要两打方法来为内置的集合类提供适当的替代。一方面,这是大量的代码。另一方面,Python 让我们以透明的方式扩展内置类,并使用相同的语义和操作符。
另请参阅
- 查看创建一个具有可排序对象的类配方,以获取定义 Pinochle 卡的伴侣配方
从映射列表中删除
从列表中删除项目会产生有趣的后果。具体来说,当删除项目list[x]时,将会发生以下两种情况之一:
-
项目
list[x+1]取代了list[x] -
项目
x+1 == len(list)取代了list[x],因为x是列表中的最后一个索引
这些是除了删除项目之外发生的副作用。因为列表中的项目可能会移动,所以一次删除多个项目变得更具挑战性。
当列表包含具有__eq__()特殊方法定义的项目时,列表remove()方法可以删除每个项目。当列表项没有简单的__eq__()测试时,从列表中删除多个项目就变得更具挑战性。
我们如何从列表中删除多个项目?
准备工作
我们将使用一个字典列表结构。在这种情况下,我们有一些包括歌曲名称、作者和持续时间的数据。数据看起来像这样:
**>>> source = [
... {'title': 'Eruption', 'writer': ['Emerson'], 'time': '2:43'},
... {'title': 'Stones of Years', 'writer': ['Emerson', 'Lake'], 'time': '3:43'},
... {'title': 'Iconoclast', 'writer': ['Emerson'], 'time': '1:16'},
... {'title': 'Mass', 'writer': ['Emerson', 'Lake'], 'time': '3:09'},
... {'title': 'Manticore', 'writer': ['Emerson'], 'time': '1:49'},
... {'title': 'Battlefield', 'writer': ['Lake'], 'time': '3:57'},
... {'title': 'Aquatarkus', 'writer': ['Emerson'], 'time': '3:54'}
... ]**
要使用这种数据结构,我们需要pprint函数:
**>>> from pprint import pprint**
我们可以很容易地使用for语句遍历值列表。问题是,我们如何删除选定的项目?
**>>> data = source.copy()
>>> for item in data:
... if 'Lake' in item['writer']:
... print("remove", item['title'])
remove Stones of Years
remove Mass
remove Battlefield**
我们不能简单地在这里使用语句del item,因为它对源集合data没有影响。这个语句只会通过删除item变量和相关对象来删除原始列表中项目的本地变量副本。
要正确地从列表中删除项目,我们必须使用列表中的索引位置。这是一个天真的方法,绝对行不通:
**>>> data = source.copy()
>>> for index in range(len(data)):
... if 'Lake' in data[index]['writer']:
... del data[index]
Traceback (most recent call last):
File "/Library/Frameworks/Python.framework/Versions/3.5/lib/python3.5/doctest.py", line 1320, in __run
compileflags, 1), test.globs)
File "<doctest __main__.__test__.chapter[5]>", line 2, in <module>
if 'Lake' in data[index]['writer']:
IndexError: list index out of range**
不能简单地使用range(len(data))基于列表的原始大小。随着项目的移除,列表变得更小。索引的值将被设置为一个太大的值。
当删除具有简单相等测试的简单项目时,我们将使用类似这样的东西:
while x in list:
list.remove(x)
问题在于我们没有一个__contains__()的实现,它可以识别item['writer']中带有Lake的项目。我们可以使用 dict 的子类来实现__eq__(),作为self['writer']中的字符串参数值。这显然违反了相等性的语义,因为它只检查一个字段。
我们不能扩展这些类的内置特性。这里的用例非常特定于问题域,而不是字典结构的一般特性。
为了与基本的while in...remove循环并行,我们需要写出类似这样的东西:
**>>> def index(data):
... for i in range(len(data)):
... if 'Lake' in data[i]['writer']:
... return i
>>> data = source.copy()
>>> position = index(data)
>>> while position:
... del data[position] # or data.pop(position)
... position = index(data)**
我们编写了一个名为index()的函数,它定位目标值的第一个实例。这个函数的结果是一个提供两种信息的单个值:
-
当返回的值不是
None时,该项目存在于列表中 -
返回值是列表中项目的正确索引
index()函数冗长且不灵活。如果我们有替代规则,我们需要编写多个index()函数,或者我们需要使测试更灵活。
更重要的是,考虑当目标值在n个项目的列表中出现x次时。这个循环将进行x次。每次循环平均检查列表中的O(x×n/2)次。最坏的情况是项目都在列表的末尾,导致处理迭代次数略少于O(x×n)。
我们可以做得更好。我们的首选解决方案建立在第二章的设计一个正确终止的 while 语句配方中的想法上,语句和语法,以设计一个适当的循环来从列表结构中删除复杂的项目。
如何做...
- 将索引值初始化为零。这建立了一个将遍历数据集合的变量:
i = 0
- 终止条件必须表明列表中的每个项目都已经被检查过了。此外,循环体需要删除所有符合目标条件的项目。这导致了一个不变条件,即
item[i]尚未被检查。项目被检查后,它可能被保留,这意味着索引i必须被递增以重置尚未被检查的不变条件。如果项目被移除,那么项目将向前移动,item[i]将自动满足尚未被检查的不变条件:
if 'Lake' in data[i]['writer']:
del data[i] # Remove
else:
i += 1 # Preserve
删除一个项目时,列表变短了一个,索引值i将指向一个新的未检查的项目。保留一个项目时,索引值i将被提前到下一个未检查的项目。
- 终止条件用于包装处理体:
while i != len(data):
在while语句结束时,i的值将表明所有项目都已经被检查过了。
这导致了以下结果:
**>>> i = 0
>>> while i != len(data):
... if 'Lake' in data[i]['writer']:
... del data[i]
... else:
... i += 1
>>> pprint(data)
[{'time': '2:43', 'title': 'Eruption', 'writer': ['Emerson']},
{'time': '1:16', 'title': 'Iconoclast', 'writer': ['Emerson']},
{'time': '1:49', 'title': 'Manticore', 'writer': ['Emerson']},
{'time': '3:54', 'title': 'Aquatarkus', 'writer': ['Emerson']}]**
这使得数据只经过一次,并且在不引发索引错误或跳过应该被删除的项目的情况下删除了请求的项目。
工作原理...
目标是确切地检查每个项目一次,并且要么删除它,要么跳过它。循环设计反映了 Python 列表项目移除的工作方式。当一个项目被移除时,所有后续项目都会在列表中向前移动。
基于range()和len()函数的天真过程将有两个问题:
-
当项目向前移动并且范围对象产生下一个值时,项目将被跳过。
-
索引可以超出列表结构的末尾,因为
len()只被用来获取原始大小,而不是当前大小
由于这两个问题,循环体中不变条件的设计非常重要。这反映了两种可能的状态变化:
-
如果一个项目被移除,索引就不会改变。列表本身将会改变。
-
如果一个项目被保留,索引必须改变。
我们可以说循环使数据通过一次,并且具有O(n)的复杂度。这里没有考虑的是每次删除的相对成本。从列表中删除项目0意味着每个剩余项目都向前移动一个位置。每次删除的成本实际上是O(n)。因此,复杂度更像是O(n × x),其中从n个项目的列表中移除x个项目。
即使这个算法也不是从列表中删除项目的最快方法。
还有更多...
如果我们放弃删除的想法,我们甚至可以做得更好。制作项目的浅拷贝比从列表中删除项目要快得多,但使用的存储空间更多。这是一种常见的时间与内存的权衡。
我们可以使用类似以下的生成器表达式:
**>>> data = [item for item in source if not('Lake' in item['writer'])]**
这将创建一个列表中我们想要保留的项目的浅拷贝。我们不想保留的项目将被忽略。有关浅拷贝的更多信息,请参阅第四章中的制作对象的浅拷贝和深拷贝配方,内置数据结构 - 列表、集合、字典。
我们还可以使用这样的高阶函数:
**>>> data = list(filter(lambda item: not('Lake' in item['writer']), source))**
filter()函数有两个参数:一个lambda对象和原始数据集。lambda对象是函数的一种退化情况:它有参数和一个单一表达式。在这种情况下,单一表达式用于决定要传递哪些项目。lambda为False的项目将被拒绝。
filter()函数是一个生成器。这意味着我们需要收集所有项目来创建最终的列表对象。for语句是处理生成器的所有结果的一种方法。list()和tuple()函数也会消耗生成器的所有项目。
我们可以实现这一点的第三种方法是编写自己的生成器函数,体现了过滤的概念。这将使用比生成器或filter()函数更多的语句,但可能更清晰。
这是一个生成器函数定义:
def writer_rule(iterable):
for item in iterable:
if 'Lake' in item['writer']:
continue
yield item
我们使用for语句来检查源列表中的每个项目。如果项目在作者列表中有'Lake',我们将继续for语句的处理过程,有效地拒绝这个项目。如果'Lake'不在作者列表中,我们将产生该项目。
当我们调用这个函数时,它将产生有趣的列表。我们可以像这样使用函数writer_rule():
**>>> from ch07_r07 import writer_rule
>>> data = list(writer_rule(source))
>>> pprint(data)
[{'time': '2:43', 'title': 'Eruption', 'writer': ['Emerson']},
{'time': '1:16', 'title': 'Iconoclast', 'writer': ['Emerson']},
{'time': '1:49', 'title': 'Manticore', 'writer': ['Emerson']},
{'time': '3:54', 'title': 'Aquatarkus', 'writer': ['Emerson']}]**
这将把有趣的行累积到一个新的结构中。由于这是一个浅拷贝,它不会浪费大量的存储空间。
另请参阅
-
这是基于第二章中的设计一个正确终止的 while 语句配方,语句和语法
-
我们还利用了另外两个配方:制作对象的浅拷贝和深拷贝和切片和切割列表在第四章,内置数据结构 - 列表、集合、字典。
第八章:功能和反应式编程特性
在本章中,我们将研究以下食谱:
-
使用 yield 语句编写生成器函数
-
使用堆叠的生成器表达式
-
将转换应用于集合
-
选择子集-三种过滤方式
-
总结集合-如何减少
-
组合映射和减少转换
-
实现“存在”处理
-
创建部分函数
-
使用不可变数据结构简化复杂算法
-
使用 yield from 语句编写递归生成器函数
介绍
函数式编程的理念是专注于编写执行所需数据转换的小型、表达力强的函数。组合函数通常可以创建比长串过程语句或复杂、有状态对象的方法更简洁和表达力更强的代码。Python 允许这三种编程方式。
传统数学将许多东西定义为函数。多个函数组合起来,从先前的转换中构建出复杂的结果。例如,我们可能有两个函数f(x)和g(y),需要组合起来创建一个有用的结果:
y = f(x)
z = g(y)
理想情况下,我们可以从这两个函数创建一个复合函数:
z = (g ∘ f)(x)
使用复合函数(g ∘ f)可以帮助澄清程序的工作方式。它允许我们将许多小细节组合成更大的知识块。
由于编程经常涉及数据集合,我们经常会将函数应用于整个集合。这与数学中的集合构建器或集合理解的概念非常契合。
有三种常见的模式可以将一个函数应用于一组数据:
-
映射:这将一个函数应用于集合中的所有元素{M(x): x∈C}。我们将一些函数M应用于较大集合C的每个项目x。
-
过滤:这使用一个函数从集合中选择元素。{x:c∈C if F(x)}。我们使用一个函数F来确定是否从较大的集合C中传递或拒绝项目x。
-
减少:这是对集合进行总结。细节各异,但最常见的减少之一是创建集合C中所有项目x的总和:
。
我们经常将这些模式结合起来创建更复杂的应用程序。这里重要的是小函数,如M(x)和F(x),通过映射和过滤等高阶函数进行组合。即使各个部分非常简单,组合操作也可以变得复杂。
反应式编程的理念是在输入可用或更改时评估处理规则。这符合惰性编程的理念。当我们定义类定义的惰性属性时,我们创建了反应式程序。
反应式编程与函数式编程契合,因为可能需要多个转换来对输入值的变化做出反应。通常,这最清晰地表达为组合或堆叠成响应变化的复合函数。在第六章类和对象的基础中查看使用惰性属性食谱,了解一些反应式类设计的示例。
使用 yield 语句编写生成器函数
我们看过的大多数食谱都是为了与单个集合中的所有项目一起使用而设计的。这种方法是使用for语句来遍历集合中的每个项目,要么将值映射到新项目,要么将集合减少到某种摘要值。
从集合中产生单个结果是处理集合的两种方式之一。另一种方式是产生增量结果,而不是单个结果。
这种方法在我们无法将整个集合放入内存的情况下非常有帮助。例如,分析庞大的网络日志文件最好是分批进行,而不是创建一个内存集合。
有没有办法将集合结构与处理函数分离?我们是否可以在每个单独的项目可用时立即产生处理结果?
准备工作
我们将查看一些具有日期时间字符串值的网络日志数据。我们需要解析这些数据以创建适当的datetime对象。为了保持本食谱的重点,我们将使用 Flask 生成的简化日志。
条目最初是这样的文本行:
**[2016-05-08 11:08:18,651] INFO in ch09_r09: Sample Message One**
**[2016-05-08 11:08:18,651] DEBUG in ch09_r09: Debugging**
**[2016-05-08 11:08:18,652] WARNING in ch09_r09: Something might have gone wrong**
我们已经看到了在第七章的使用更复杂的结构——列表的映射食谱中处理这种日志的其他示例,更高级的类设计。使用第一章的使用正则表达式进行字符串解析食谱中的 REs,数字、字符串和元组,我们可以将每行分解为以下行集合:
**>>> data = [
... ('2016-04-24 11:05:01,462', 'INFO', 'module1', 'Sample Message One'),
... ('2016-04-24 11:06:02,624', 'DEBUG', 'module2', 'Debugging'),
... ('2016-04-24 11:07:03,246', 'WARNING', 'module1', 'Something might have gone wrong')
... ]**
我们不能使用普通的字符串解析将复杂的日期时间戳转换为更有用的形式。但是,我们可以编写一个生成器函数,它可以处理日志的每一行,产生一个更有用的中间数据结构。
生成器函数是使用yield语句的函数。当一个函数有一个 yield 时,它会逐渐构建结果,以一种可以被客户端消耗的方式产生每个单独的值。消费者可能是一个for语句,也可能是另一个需要一系列值的函数。
如何做到...
- 这需要
datetime模块:
import datetime
- 定义一个处理源集合的函数:
def parse_date_iter(source):
我们在后缀_iter中包含了这个函数将是一个可迭代对象而不是一个简单集合的提醒。
- 包括一个
for语句,访问源集合中的每个项目:
for item in source:
for语句的主体可以将项目映射到一个新项目:
date = datetime.datetime.strptime(
item[0],
"%Y-%m-%d %H:%M:%S,%f")
new_item = (date,)+item[1:]
在这种情况下,我们将一个字段从字符串映射到datetime对象。变量date是从item[0]中的字符串构建的。
然后,我们将日志消息的三元组映射到一个新的元组,用正确的datetime对象替换日期字符串。由于项目的值是一个元组,我们创建了一个带有(date,)的单例元组,然后将其与item[1:]元组连接起来。
- 使用
yield语句产生新项目:
yield new_item
整个结构看起来是这样的,正确缩进:
import datetime
def parse_date_iter(source):
for item in source:
date = datetime.datetime.strptime(
item[0],
"%Y-%m-%d %H:%M:%S,%f")
new_item = (date,)+item[1:]
yield new_item
parse_date_iter()函数期望一个可迭代的输入对象。集合是可迭代对象的一个例子。然而更重要的是,其他生成器也是可迭代的。我们可以利用这一点构建处理来自其他生成器的数据的生成器堆栈。
这个函数不会创建一个集合。它会产生每个项目,以便可以单独处理这些项目。源集合会被分成小块进行处理,从而可以处理大量的数据。在一些示例中,数据将从内存集合开始。在后续的示例中,我们将处理来自外部文件的数据——处理外部文件最能从这种技术中获益。
以下是我们如何使用这个函数的方法:
**>>> from pprint import pprint
>>> from ch08_r01 import parse_date_iter
>>> for item in parse_date_iter(data):
... pprint(item)
(datetime.datetime(2016, 4, 24, 11, 5, 1, 462000),
'INFO',
'module1',
'Sample Message One')
(datetime.datetime(2016, 4, 24, 11, 6, 2, 624000),
'DEBUG',
'module2',
'Debugging')
(datetime.datetime(2016, 4, 24, 11, 7, 3, 246000),
'WARNING',
'module1',
'Something might have gone wrong')**
我们使用for语句遍历parse_date_iter()函数的结果,一次处理一个项目。我们使用pprint()函数显示每个项目。
我们也可以使用类似这样的方法将项目收集到一个适当的列表中:
**>>> details = list(parse_date_iter(data))**
在这个例子中,list()函数消耗了parse_date_iter()函数产生的所有项目。使用list()或for语句来消耗生成器中的所有项目是至关重要的。生成器是一个相对被动的结构——直到需要数据时,它不会做任何工作。
如果我们不主动消耗数据,我们会看到类似这样的情况:
**>>> parse_date_iter(data)
<generator object parse_date_iter at 0x10167ddb0>**
parse_date_iter()函数的值是一个生成器。它不是一个项目的集合,而是一个能够按需生成项目的函数。
工作原理...
编写生成器函数可以改变我们对算法的理解方式。有两种常见的模式:映射和归约。映射将每个项目转换为一个新项目,可能计算一些派生值。归约从源集合中累积一个摘要,比如总和、平均值、方差或哈希。这些可以分解为逐项转换或过滤,与处理集合的整体循环分开。
Python 有一个复杂的构造叫做迭代器,它是生成器和集合的核心。迭代器会从集合中提供每个值,同时进行所有必要的内部记录以维护进程的状态。生成器函数的行为就像一个迭代器-它提供一系列的值并维护自己的内部状态。
考虑下面这段常见的 Python 代码:
for i in some_collection:
process(i)
在幕后,类似以下的事情正在发生:
the_iterator = iter(some_collection)
try:
while True:
i = next(the_iterator)
process(i)
except StopIteration:
pass
Python 对集合上的iter()函数进行评估,以创建该集合的迭代器对象。迭代器绑定到集合并维护一些内部状态信息。代码在迭代器上使用next()来获取每个值。当没有更多的值时,迭代器会引发StopIteration异常。
Python 的每个集合都可以产生一个迭代器。Sequence或Set产生的迭代器会访问集合中的每个项。Mapping产生的迭代器会访问映射的每个键。我们可以使用映射的values()方法来迭代值而不是键。我们可以使用映射的items()方法来访问一个(key, value)两元组的序列。file的迭代器会访问文件中的每一行。
迭代器的概念也可以应用到函数上。带有yield语句的函数被称为生成器函数。它符合迭代器的模板。为了实现这一点,生成器在响应iter()函数时返回自身。在响应next()函数时,它会产生下一个值。
当我们对集合或生成器函数应用list()时,与for语句使用的相同的基本机制会得到各个值。iter()和next()函数被list()用来获取这些项。然后这些项被转换成一个序列。
评估生成器函数的next()是有趣的。生成器函数会被评估,直到它达到一个yield语句。这个值就是next()的结果。每次评估next()时,函数会在yield语句之后恢复处理,并继续到下一个yield语句。
这里有一个产生两个对象的小函数:
**>>> def gen_func():
... print("pre-yield")
... yield 1
... print("post-yield")
... yield 2**
当我们评估next()函数时会发生什么。在生成器上,这个函数会产生:
**>>> y = gen_func()
>>> next(y)
pre-yield
1
>>> next(y)
post-yield
2**
第一次评估next()时,第一个print()函数被评估,然后yield语句产生一个值。函数的处理被暂停,然后出现>>>提示符。第二次评估next()函数时,两个yield语句之间的语句被评估。函数再次被暂停,然后会显示一个>>>提示符。
接下来会发生什么?我们已经没有yield语句了:
**>>> next(y)
Traceback (most recent call last):
File "<pyshell...>", line 1, in <module>
next(y)
StopIteration**
在生成器函数的末尾会引发StopIteration异常。
还有更多...
生成器函数的核心价值在于能够将复杂的处理分解为两部分:
-
要应用的转换或过滤
-
要处理的源数据集
这是使用生成器来过滤数据的一个例子。在这种情况下,我们将过滤输入值,只保留质数,拒绝所有合数。
我们可以将处理写成一个 Python 函数,像这样:
def primeset(source):
for i in source:
if prime(i):
yield prime
对于源中的每个值,我们将评估prime()函数。如果结果为true,我们将产生源值。如果结果为false,源值将被拒绝。我们可以像这样使用primeset():
p_10 = set(primeset(range(2,2000000)))
primeset()函数将从源集合中产生单个素数值。源集合将是范围在 2 到 200 万之间的整数。结果是从提供的值构建的set对象。
这里唯一缺少的是prime()函数,用于确定一个数字是否为素数。我们将把这留给读者作为练习。
从数学上讲,常见的是使用集合生成器或集合推导符号来指定从另一个集合构建一个集合的规则。
我们可能会看到类似这样的东西:
P[10] = {i:i ∈ ℕ ∧ 2 ≤ 1 < 2,000,000 if P(i)}
这告诉我们P[10]是所有数字i的集合,在自然数集ℕ中,并且在 2 到 200 万之间,如果P(i)为true。这定义了一个构建集合的规则。
我们也可以用 Python 写出这个:
p_10 = {i for i in range(2,2000000) if prime(i)}
这是素数子集的 Python 表示。从数学抽象中略微重新排列了子句,但表达式的所有基本部分都存在。
当我们开始看这样的生成器表达式时,我们会发现很多编程都符合一些常见的整体模式:
-
Map:m(x):x ∈ S变为
(m(x) for x in S)。 -
Filter:x:x ∈ S if f(x)变为
(x for x in S if f(x))。 -
Reduce:这有点复杂,但常见的缩减包括求和和计数。
是sum(x for x in S)。其他常见的缩减包括查找一组数据的最大值或最小值。
我们也可以使用yield语句编写这些不同的高级函数。以下是通用映射的定义:
def map(m, S):
for s in S:
yield m(s)
此函数将某个其他函数m()应用于源集合S中的每个数据元素。映射函数的结果作为结果值的序列被产生。
我们可以为通用的filter函数编写类似的定义:
def filter(f, S):
for s in S:
if f(s):
yield s
与通用映射一样,我们将函数f()应用于源集合S中的每个元素。函数为true的地方,值被产生。函数为false的地方,值被拒绝。
我们可以像这样使用它来创建一个素数集:
p_10 = set(filter(prime, range(2,2000000)))
这将应用prime()函数到数据源范围。请注意,我们只写prime,不带()字符,因为我们是在命名函数,而不是在评估它。每个单独的值将由prime()函数检查。通过的值将被产生以组装成最终集合。那些是合数的值将被拒绝,不会出现在最终集合中。
另请参阅
-
在使用堆叠的生成器表达式的示例中,我们将结合生成器函数,从简单组件构建复杂的处理堆栈。
-
在对集合应用转换的示例中,我们将看到内置的
map()函数如何被用于从简单函数和可迭代的数据源创建复杂的处理。 -
在选择子集-三种过滤方式的示例中,我们将看到内置的
filter()函数也可以用于从简单函数和可迭代的数据源构建复杂的处理。 -
有关小于 200 万的素数的具有挑战性的问题,请参阅
projecteuler.net/problem=10。问题的部分似乎是显而易见的。然而,测试所有这些数字是否为素数可能很困难。
使用堆叠的生成器表达式
在使用 yield 语句编写生成器函数的示例中,我们创建了一个简单的生成器函数,对数据进行了单一的转换。实际上,我们经常有几个函数,我们希望将其应用于传入的数据。
我们如何堆叠或组合多个生成器函数以创建一个复合函数?
准备工作
我们有一个用于记录大帆船燃油消耗的电子表格。它的行看起来像这样:
| 日期 | 启动引擎 | 燃油高度 |
|---|---|---|
| 关闭引擎 | 燃油高度 | |
| 其他说明 | ||
| 10/25/2013 | 08:24 | 29 |
| 13:15 | 27 | |
| 平静的海域 - 锚在所罗门岛 | ||
| 10/26/2013 | 09:12 | 27 |
| 18:25 | 22 | |
| 波涛汹涌 - 锚在杰克逊溪 |
有关这些数据的更多背景信息,请参阅第四章的对列表进行切片和切块,内置数据结构 - 列表、集合、字典。
作为一个侧边栏,我们可以这样获取数据。我们将在第九章的使用 csv 模块读取分隔文件中详细讨论这个问题,输入/输出、物理格式和逻辑布局:
**>>> from pathlib import Path
>>> import csv
>>> with Path('code/fuel.csv').open() as source_file:
... reader = csv.reader(source_file)
... log_rows = list(reader)
>>> log_rows[0]
['date', 'engine on', 'fuel height']
>>> log_rows[-1]
['', "choppy -- anchor in jackson's creek", '']**
我们已经使用csv模块来读取日志详情。csv.reader()是一个可迭代对象。为了将项目收集到一个单一列表中,我们对生成器函数应用了list()函数。我们打印了列表中的第一个和最后一个项目,以确认我们确实有一个列表的列表结构。
我们想对这个列表的列表应用两个转换:
-
将日期和两个时间转换为两个日期时间值
-
将三行合并成一行,以便对数据进行简单的组织
如果我们创建一对有用的生成器函数,我们可以有这样的软件:
total_time = datetime.timedelta(0)
total_fuel = 0
for row in date_conversion(row_merge(source_data)):
total_time += row['end_time']-row['start_time']
total_fuel += row['end_fuel']-row['start_fuel']
组合的生成器函数date_conversion(row_merge(...))将产生一系列单行,其中包含起始信息、结束信息和注释。这种结构可以很容易地总结或分析,以创建简单的统计相关性和趋势。
如何做到这一点...
- 定义一个初始的减少操作,将行组合在一起。我们有几种方法可以解决这个问题。一种方法是总是将三行组合在一起。
另一种方法是注意到第零列在组的开头有数据;在组的下两行为空。这给了我们一个稍微更一般的方法来创建行的组。这是一种头尾合并算法。我们将收集数据,并在到达下一个组的头部时每次产生数据:
def row_merge(source_iter):
group = []
for row in source_iter:
if len(row[0]) != 0:
if group:
yield group
group = row.copy()
else:
group.extend(row)
if group:
yield group
这个算法使用len(row[0])来确定这是一个组的头部还是组的尾部中的一行。在头部行的情况下,任何先前的组都会被产生。在那之后被消耗后,group集合的值将被重置为头部行的列数据。
组的尾部行简单地附加到group集合上。当数据耗尽时,group变量中通常会有一个最终组。如果根本没有数据,那么group的最终值也将是一个长度为零的列表,应该被忽略。
我们稍后会讨论copy()方法。这是必不可少的,因为我们正在处理一个列表的列表数据结构,而列表是可变对象。我们可以编写处理改变数据结构的处理,使得一些处理难以解释。
- 定义将在合并后的数据上执行的各种映射操作。这些应用于原始行中的数据。我们将使用单独的函数来转换两个时间列,并将时间与日期列合并:
import datetime
def start_datetime(row):
travel_date = datetime.datetime.strptime(row[0], "%m/%d/%y").date()
start_time = datetime.datetime.strptime(row[1], "%I:%M:%S %p").time()
start_datetime = datetime.datetime.combine(travel_date, start_time)
new_row = row+[start_datetime]
return new_row
def end_datetime(row):
travel_date = datetime.datetime.strptime(row[0], "%m/%d/%y").date()
end_time = datetime.datetime.strptime(row[4], "%I:%M:%S %p").time()
end_datetime = datetime.datetime.combine(travel_date, end_time)
new_row = row+[end_datetime]
return new_row
我们将把第零列中的日期与第一列中的时间结合起来,创建一个起始datetime对象。同样,我们将把第零列中的日期与第四列中的时间结合起来,创建一个结束datetime对象。
这两个函数有很多重叠之处,可以重构为一个带有列号作为参数值的单个函数。然而,目前我们的目标是编写一些简单有效的东西。效率的重构可以稍后进行。
- 定义适用于派生数据的映射操作。第八和第九列包含日期时间戳:
for starting and ending.def duration(row):
travel_hours = round((row[10]-row[9]).total_seconds()/60/60, 1)
new_row = row+[travel_hours]
return new_row
我们使用start_datetime和end_datetime创建的值作为输入。我们计算了时间差,这提供了以秒为单位的结果。我们将秒转换为小时,这是这组数据更有用的时间单位。
- 合并任何需要拒绝或排除坏数据的过滤器。在这种情况下,我们必须排除一个标题行:
def skip_header_date(rows):
for row in rows:
if row[0] == 'date':
continue
yield row
这个函数将拒绝任何第一列中有date的行。continue语句恢复for语句,跳过体中的所有其他语句;它跳过yield语句。所有其他行将通过这个过程。输入是一个可迭代对象,这个生成器将产生没有以任何方式转换的行。
- 将操作组合起来。我们可以编写一系列生成器表达式,也可以使用内置的
map()函数。以下是使用生成器表达式的示例:
def date_conversion(source):
tail_gen = skip_header_date(source)
start_gen = (start_datetime(row) for row in tail_gen)
end_gen = (end_datetime(row) for row in start_gen)
duration_gen = (duration(row) for row in end_gen)
return duration_gen
这个操作由一系列转换组成。每个转换对原始数据集中的一个值进行小的转换。添加或更改操作相对简单,因为每个操作都是独立定义的:
-
tail_gen生成器在跳过源的第一行后产生行 -
start_gen生成器将一个datetime对象附加到每一行的末尾,起始时间是从字符串构建到源列中的 -
end_gen生成器将一个datetime对象附加到每一行的末尾,结束时间是从字符串构建到源列中的 -
duration_gen生成器将一个float对象附加到每个腿的持续时间
整体date_conversion()函数的输出是一个生成器。可以使用for语句消耗它,也可以从项目构建一个list。
工作原理...
当我们编写一个生成器函数时,参数值可以是一个集合,也可以是另一种可迭代对象。由于生成器函数是可迭代的,因此可以创建一种生成器函数的管道。
每个函数可以包含一个小的转换,改变输入的一个特征以创建输出。然后我们将每个小的转换包装在生成器表达式中。因为每个转换都相对独立于其他转换,所以我们可以对其中一个进行更改而不破坏整个处理流水线。
处理是逐步进行的。每个函数都会被评估,直到产生一个单一的值。考虑这个陈述:
for row in date_conversion(row_merge(data)):
print(row[11])
我们定义了几个生成器的组合。这个组合使用了各种技术:
-
row_merge()函数是一个生成器,将产生数据行。为了产生一行,它将从源中读取四行,组装成一个合并的行,并产生它。每次需要另一行时,它将再读取三行输入来组装输出行。 -
date_conversion()函数是由多个生成器构建的复杂生成器。 -
skip_header_date()旨在产生一个单一的值。有时它必须从源迭代器中读取两个值。如果输入行的第零列有date,则跳过该行。在这种情况下,它将读取第二个值,从row_merge()获取另一行;而row_merge()必须再读取三行输入来产生一个合并的输出行。我们将生成器分配给tail_gen变量。 -
start_gen、end_gen和duration_gen生成器表达式将对其输入的每一行应用相对简单的函数,例如start_datetime()和end_datetime(),产生具有更有用数据的行。
示例中显示的最终for语句将通过反复评估next()函数来从date_conversion()迭代器中收集值。以下是创建所需结果的逐步视图。请注意,这在一个非常小的数据量上运行——每个步骤都会做出一个小的改变:
-
date_conversion()函数的结果是duration_gen对象。为了返回一个值,它需要来自其源end_gen的一行。一旦有了数据,它就可以应用duration()函数并产生该行。 -
end_gen表达式需要来自其源start_gen的一行。然后它可以应用end_datetime()函数并产生该行。 -
start_gen表达式需要来自其源tail_gen的一行。然后它可以应用start_datetime()函数并产生该行。 -
tail_gen表达式只是生成器skip_header_date()。这个函数将从其源中读取所需的行,直到找到一行,其中第零列不是列标题date。它产生一个非日期行。其源是row_merge()函数的输出。 -
row_merge()函数将从其源中读取多行,直到可以组装符合所需模式的行集合。它将产生一个组合行,该行在第零列中有一些文本,后面是没有文本的行。其源是原始数据的列表集合。 -
行集合将由
row_merge()函数内的for语句处理。这个处理将隐式地为集合创建一个迭代器,以便row_merge()函数的主体根据需要产生每个单独的行。
数据的每个单独行将通过这些步骤的管道。管道的某些阶段将消耗多个源行以产生单个结果行,重组数据。其他阶段消耗单个值。
这个例子依赖于将项目连接成一个长序列的值。项目由位置标识。对管道中阶段顺序的小改动将改变项目的位置。有许多方法可以改进这一点,我们将在接下来看一下。
这个核心是只处理单独的行。如果源是一个庞大的数据集合,处理可以非常快速。这种技术允许一个小的 Python 程序快速而简单地处理大量的数据。
还有更多...
实际上,一组相互关联的生成器是一种复合函数。我们可能有几个函数,像这样分别定义:
y = f(x)
z = g(y)
我们可以通过将第一个函数的结果应用到第二个函数来将它们组合起来:
z = g(f(x))
随着函数数量的增加,这可能变得笨拙。当我们在多个地方使用这对函数时,我们违反了不要重复自己(DRY)原则。拥有多个这种复杂表达式的副本并不理想。
我们希望有一种方法来创建一个复合函数——类似于这样:
z = ( g ∘ f )( x )
在这里,我们定义了一个新函数(g ∘ f),将两个原始函数组合成一个新的、单一的复合函数。我们现在可以修改这个复合函数以添加或更改功能。
这个概念推动了复合 date_conversion() 函数的定义。这个函数由许多函数组成,每个函数都可以应用于集合的项。如果我们需要进行更改,我们可以轻松地编写更多简单的函数并将它们放入 date_conversion() 函数定义的管道中。
我们可以看到管道中的函数之间存在一些细微差异。我们有一些类型转换。然而,持续时间计算并不真正是一种类型转换。它是一种基于日期转换结果的独立计算。如果我们想要计算每小时的燃料使用量,我们需要添加几个计算。这些额外的摘要都不是日期转换的正确部分。
我们真的应该将高级 data_conversion() 分成两部分。我们应该编写另一个函数来进行持续时间和燃料使用计算,命名为 fuel_use()。然后这个其他函数可以包装 date_conversion()。
我们可能会朝着这样的目标努力:
for row in fuel_use(date_conversion(row_merge(data))):
print(row[11])
现在我们有一个非常复杂的计算,它由许多非常小的(几乎)完全独立的部分定义。我们可以修改一个部分而不必深入思考其他部分的工作方式。
命名空间而不是列表
一个重要的改变是停止避免使用简单的列表来存储数据值。对row[10]进行计算可能是一场潜在的灾难。我们应该适当地将输入数据转换为某种命名空间。
可以使用namedtuple。我们将在Simplifying complex algorithms with immutable data structures食谱中看到。
在某些方面,SimpleNamespace可以进一步简化这个处理过程。SimpleNamespace是一个可变对象,可以被更新。改变对象并不总是一个好主意。它的优点是简单,但对于可变对象的状态变化编写测试可能会稍微困难一些。
例如make_namespace()这样的函数可以提供一组名称而不是位置。这是一个必须在行合并后但在任何其他处理之前使用的生成器:
from types import SimpleNamespace
def make_namespace(merge_iter):
for row in merge_iter:
ns = SimpleNamespace(
date = row[0],
start_time = row[1],
start_fuel_height = row[2],
end_time = row[4],
end_fuel_height = row[5],
other_notes = row[7]
)
yield ns
这将产生一个允许我们写row.date而不是row[0]的对象。当然,这将改变其他函数的定义,包括start_datetime()、end_datetime()和duration()。
这些函数中的每一个都可以发出一个新的SimpleNamespace对象,而不是更新表示每一行的值列表。然后我们可以编写以下样式的函数:
def duration(row_ns):
travel_time = row_ns.end_timestamp - row_ns.start_timestamp
travel_hours = round(travel_time.total_seconds()/60/60, 1)
return SimpleNamespace(
**vars(row_ns),
travel_hours=travel_hours
)
这个函数处理行作为SimpleNamespace对象,而不是list对象。列具有清晰而有意义的名称,如row_ns.end_timestamp,而不是晦涩的row[10]。
构建新的SimpleNamespace的三部曲如下:
-
使用
vars()函数提取SimpleNamespace实例内部的字典。 -
使用
**vars(row_ns)对象基于旧命名空间构建一个新的命名空间。 -
任何额外的关键字参数,如
travel_hours = travel_hours,都提供了将加载新对象的额外值。
另一种选择是更新命名空间并返回更新后的对象:
def duration(row_ns):
travel_time = row_ns.end_timestamp - row_ns.start_timestamp
row_ns.travel_hours = round(travel_time.total_seconds()/60/60, 1)
return row_ns
这样做的优点是稍微简单。缺点是有时会让有状态的对象变得混乱。在修改算法时,可能会失败地按正确顺序设置属性,以便懒惰(或反应性)编程能够正常运行。
尽管有状态的对象很常见,但它们应该始终被视为两种选择之一。不可变的namedtuple可能比可变的SimpleNamespace更好。
另请参阅
-
在Writing generator functions with the yield statement食谱中,我们介绍了生成器函数
-
在第四章的Slicing and dicing a list食谱中,了解有关燃料消耗数据集的更多信息
-
在Combining map and reduce transformations食谱中,还有另一种组合操作的方法
对集合应用转换
在Writing generator functions with the yield statement食谱中,我们看到了编写生成器函数的例子。我们看到的例子结合了两个元素:转换和数据源。它们通常看起来像这样:
for item in source:
new_item = some transformation of item
yield new_item
编写生成器函数的这个模板并不是必需的。它只是一个常见的模式。在for语句中隐藏了一个转换过程。for语句在很大程度上是样板代码。我们可以重构这个代码,使转换函数明确且与for语句分离。
在Using stacked generator expressions食谱中,我们定义了一个start_datetime()函数,它从数据源集合的两个单独列中的字符串值计算出一个新的datetime对象。
我们可以在生成器函数的主体中使用这个函数,就像这样:
def start_gen(tail_gen):
for row in tail_gen:
new_row = start_datetime(row)
yield new_row
这个函数将start_datetime()函数应用于数据源tail_gen中的每个项目。每个生成的行都被产生,以便另一个函数或for语句可以消耗它。
在使用堆叠的生成器表达式的示例中,我们看了另一种将这些转换函数应用于更大的数据集的方法。在这个例子中,我们使用了一个生成器表达式。代码看起来像这样:
start_gen = (start_datetime(row) for row in tail_gen)
这将start_datetime()函数应用于数据源tail_gen中的每个项目。另一个函数或for语句可以消耗start_gen可迭代中可用的值。
完整的生成器函数和较短的生成器表达式本质上是相同的,只是语法略有不同。这两者都与数学上的集合构建器或集合推导的概念相似。我们可以用数学方式描述这个操作:
s = [ S ( r ): r ∈ T ]
在这个表达式中,S是start_datetime()函数,T是称为tail_gen的值序列。结果序列是S(r)的值,其中r的每个值是集合T的一个元素。
生成器函数和生成器表达式都有类似的样板代码。我们能简化这些吗?
准备好了...
我们将查看使用带有 yield 语句的生成器函数示例中的 web 日志数据。这里有一个date作为一个字符串,我们想要将其转换为一个合适的时间戳。
这是示例数据:
**>>> data = [
... ('2016-04-24 11:05:01,462', 'INFO', 'module1', 'Sample Message One'),
... ('2016-04-24 11:06:02,624', 'DEBUG', 'module2', 'Debugging'),
... ('2016-04-24 11:07:03,246', 'WARNING', 'module1', 'Something might have gone wrong')
... ]**
我们可以编写一个这样的函数来转换数据:
import datetime
def parse_date_iter(source):
for item in source:
date = datetime.datetime.strptime(
item[0],
"%Y-%m-%d %H:%M:%S,%f")
new_item = (date,)+item[1:]
yield new_item
这个函数将使用for语句检查源中的每个项目。第零列的值是一个date字符串,可以转换为一个合适的datetime对象。从datetime对象和从第一列开始的剩余项目构建一个新项目new_item。
因为函数使用yield语句产生结果,所以它是一个生成器函数。我们可以像这样使用它与for语句:
for row in parse_date_iter(data):
print(row[0], row[3])
这个语句将收集生成器函数产生的每个值,并打印两个选定的值。
parse_date_iter()函数将两个基本元素合并到一个函数中。大纲看起来像这样:
for item in source:
new_item = transformation(item)
yield new_item
for和yield语句在很大程度上是样板代码。transformation()函数是这个非常有用和有趣的部分。
如何做...
- 编写应用于数据单行的转换函数。这不是一个生成器,也不使用
yield语句。它只是修改集合中的单个项目:
def parse_date(item):
date = datetime.datetime.strptime(
item[0],
"%Y-%m-%d %H:%M:%S,%f")
new_item = (date,)+item[1:]
return new_item
这可以用三种方式:语句、表达式和map()函数。这是语句的显式for...yield模式:
for item in collection:
new_item = parse_date(item)
yield new_item
这使用了一个for语句来使用孤立的parse_date()函数处理集合中的每个项目。第二个选择是一个生成器表达式,看起来像这样:
(parse_date(item) for item in data)
这是一个生成器表达式,将parse_date()函数应用于每个项目。第三个选择是map()函数。
- 使用
map()函数将转换应用于源数据。
map(parse_date, data)
我们提供函数名parse_date,在名称后面没有任何()。我们此时不应用函数。我们提供对象名给map()函数,以将parse_date()函数应用于可迭代的数据源data。
我们可以这样使用:
for row in map(parse_date, data):
print(row[0], row[3])
map()函数创建一个可迭代对象,将parse_date()函数应用于数据可迭代中的每个项目。它产生每个单独的项目。它使我们不必编写生成器表达式或生成器函数。
工作原理...
map()函数替换了一些常见的样板代码。我们可以想象定义看起来像这样:
def map(f, iterable):
for item in iterable:
yield f(item)
或者,我们可以想象它看起来像这样:
def map(f, iterable):
return (f(item) for item in iterable)
这两个定义总结了map()函数的核心特性。它是一个方便的简写,可以消除一些样板代码,用于将函数应用于可迭代的数据源。
还有更多...
在这个例子中,我们使用了map()函数来将一个接受单个参数的函数应用到单个可迭代对象的每个项目上。原来map()函数可以做的事情比这更多。
考虑这个函数:
**>>> def mul(a, b):
... return a*b**
还有这两个数据来源:
**>>> list_1 = [2, 3, 5, 7]
>>> list_2 = [11, 13, 17, 23]**
我们可以将mul()函数应用于从每个数据源中提取的对:
**>>> list(map(mul, list_1, list_2))
[22, 39, 85, 161]**
这使我们能够使用不同类型的运算符合并两个值序列。例如,我们可以构建一个行为类似于内置的zip()函数的映射。
这是一个映射:
**>>> def bundle(*args):
... return args
>>> list(map(bundle, list_1, list_2))
[(2, 11), (3, 13), (5, 17), (7, 23)]**
我们需要定义一个小的辅助函数,bundle(),它接受任意数量的参数,并将它们创建为一个元组。
这里是zip函数进行比较:
**>>> list(zip(list_1, list_2))
[(2, 11), (3, 13), (5, 17), (7, 23)]**
另请参阅...
- 在使用堆叠的生成器表达式示例中,我们研究了堆叠生成器。我们从许多单独的映射操作中构建了一个复合函数,这些操作被编写为生成器函数。我们还在堆栈中包含了一个单一的过滤器。
选择子集-三种过滤方式
在使用堆叠的生成器表达式示例中,我们编写了一个生成器函数,它从一组数据中排除了一些行。我们定义了这样一个函数:
def skip_header_date(rows):
for row in rows:
if row[0] == 'date':
continue
yield row
当条件为true——row[0]是date——continue语句将跳过for语句体中的其余语句。在这种情况下,只有一个语句yield row。
有两个条件:
-
row[0] == 'date':yield语句被跳过;该行被拒绝进一步处理 -
row[0] != 'date':yield语句意味着该行将被传递给消耗数据的函数或语句
在四行代码中,这似乎有点冗长。for...if...yield模式显然是样板文件,只有条件在这种结构中才是真正的材料。
我们可以更简洁地表达这个吗?
准备好...
我们有一个用于记录大帆船燃料消耗的电子表格。它的行看起来像这样:
| 日期 | 引擎开启 | 燃料高度 |
|---|---|---|
| 关闭引擎 | 燃料高度 | |
| 其他说明 | ||
| 10/25/2013 | 08:24 | 29 |
| 13:15 | 27 | |
| 平静的海域 - 锚定所罗门岛 | ||
| 10/26/2013 | 09:12 | 27 |
| 18:25 | 22 | |
| 波涛汹涌 - 锚定在杰克逊溪 |
有关这些数据的更多背景信息,请参阅切片和切块列表示例。
在使用堆叠的生成器表达式示例中,我们定义了两个函数来重新组织这些数据。第一个将每个三行组合并为一个具有八列数据的单行:
def row_merge(source_iter):
group = []
for row in source_iter:
if len(row[0]) != 0:
if group:
yield group
group = row.copy()
else:
group.extend(row)
if group:
yield group
这是头尾算法的变体。当len(row[0]) != 0时,这是一个新组的标题行——任何先前完整的组都会被产生,然后group变量的工作值将根据这个标题行重置为一个新的、包含此标题行的列表。进行copy()操作,以便我们以后可以避免对列表对象进行变异。当len(row[0]) == 0时,这是组的尾部;该行被附加到group变量的工作值上。在数据源的末尾,通常有一个需要处理的完整组。有一个边缘情况,即根本没有数据;在这种情况下,也没有最终的组需要产生。
我们可以使用这个函数将数据从许多令人困惑的行转换为有用信息的单行:
**>>> from ch08_r02 import row_merge, log_rows
>>> pprint(list(row_merge(log_rows)))
[['date',
'engine on',
'fuel height',
'',
'engine off',
'fuel height',
'',
'Other notes',
''],
['10/25/13',
'08:24:00 AM',
'29',
'',
'01:15:00 PM',
'27',
'',
"calm seas -- anchor solomon's island",
''],
['10/26/13',
'09:12:00 AM',
'27',
'',
'06:25:00 PM',
'22',
'',
"choppy -- anchor in jackson's creek",
'']]**
我们看到第一行只是电子表格的标题。我们想跳过这一行。我们将创建一个生成器表达式来处理过滤,并拒绝这一额外的行。
如何做...
- 编写谓词函数,测试一个项目是否应该通过过滤器进行进一步处理。在某些情况下,我们将不得不从拒绝规则开始,然后编写反向规则,使其成为通过规则:
def pass_non_date(row):
return row[0] != 'date'
这可以用三种方式来使用:语句、表达式和filter()函数。这是一个显式的for...if...yield模式的语句示例,用于传递行:
for item in collection:
if pass_non_date(item):
yield item
这使用一个for语句来使用过滤函数处理集合中的每个项目。选择的项目被产生。其他项目被拒绝。
使用这个函数的第二种方式是在生成器表达式中使用它:
(item for item in data if pass_non_date(item))
这个生成器表达式应用了filter函数pass_non_date()到每个项目。第三种选择是filter()函数。
- 使用
filter()函数将函数应用于源数据:
filter(pass_non_date, data)
我们提供了函数名pass_non_date。我们在函数名后面不使用()字符,因为这个表达式不会评估函数。filter()函数将给定的函数应用于可迭代的数据源data。在这种情况下,data是一个集合,但它可以是任何可迭代的对象,包括以前生成器表达式的结果。pass_non_date()函数为true的每个项目将被过滤器传递;所有其他值都被拒绝。
我们可以这样使用:
for row in filter(pass_non_date, row_merge(data)):
print(row[0], row[1], row[4])
filter()函数创建一个可迭代对象,将pass_non_date()函数作为规则应用于row_merge(data)可迭代对象中的每个项目,它产生了在第零列中没有date的行。
它是如何工作的...
filter()函数替换了一些常见的样板代码。我们可以想象定义看起来像这样:
def filter(f, iterable):
for item in iterable:
if f(item):
yield f(item)
或者,我们可以想象它看起来像这样:
def filter(f, iterable):
return (item for item in iterable if f(item))
这两个定义总结了filter()函数的核心特性:一些数据被传递,一些数据被拒绝。这是一个方便的简写,消除了一些应用函数到可迭代数据源的样板代码。
还有更多...
有时候很难编写一个简单的规则来传递数据。如果我们编写一个拒绝数据的规则,可能会更清晰。例如,这可能更有意义:
def reject_date(row):
return row[0] == 'date'
我们可以以多种方式使用拒绝规则。这是一个for...if...continue...yield语句的模式。这将使用 continue 跳过被拒绝的行,并产生剩下的行:
for item in collection:
if reject_date(item):
continue
yield item
我们还可以使用这种变体。对于一些程序员来说,不拒绝的概念可能会变得令人困惑。这可能看起来像一个双重否定:
for item in collection:
if not reject_date(item):
yield item
我们也可以使用类似这样的生成器表达式:
(item for item in data if not reject_date(item))
然而,我们不能轻松地使用filter()函数来拒绝数据的规则。filter()函数只能用于传递规则。
我们在处理这种逻辑时有两种基本选择。我们可以将逻辑包装在另一个表达式中,或者使用itertools模块中的函数。当涉及到包装时,我们还有两种选择。我们可以包装一个拒绝函数以创建一个传递函数。我们可以使用类似这样的东西:
def pass_date(row):
return not reject_date(row)
这使得可以创建一个简单的拒绝规则,并在filter()函数中使用它。包装逻辑的另一种方法是创建一个lambda对象:
filter(lambda item: not reject_date(item), data)
lambda函数是一个小的匿名函数。它是一个被简化为只有两个元素的函数:参数列表和一个单一的表达式。我们用lambda对象包装了reject_date()函数,以创建一种not_reject_date函数。
在itertools模块中,我们使用filterfalse()函数。我们可以导入filterfalse()并使用它来代替内置的filter()函数。
另请参阅...
- 在使用堆叠的生成器表达式配方中,我们将这样的函数放在一堆生成器中。我们从许多单独的映射和过滤操作中构建了一个复合函数,这些操作被编写为生成器函数。
总结一个集合-如何减少
在本章的介绍中,我们注意到有三种常见的处理模式:映射、过滤和减少。我们在将变换应用到集合配方中看到了映射的例子,在挑选子集-三种过滤方式配方中看到了过滤的例子。很容易看出这些是如何变得非常通用的。
映射将一个简单的函数应用于集合的所有元素。{M(x): x ∈ C}将函数M应用于较大集合C的每个项目x。在 Python 中,它可以看起来像这样:
(M(x) for x in C)
或者,我们可以使用内置的map()函数来消除样板代码,并简化为这样:
map(M, c)
类似地,过滤使用一个函数从集合中选择元素。{x: x ∈ C if F(x)}使用函数F来确定是否传递或拒绝来自较大集合C的项目x。我们可以用各种方式在 Python 中表达这一点,其中之一就像这样:
filter(F, c)
这将一个谓词函数F()应用于集合c。
第三种常见的模式是缩减。在设计具有大量处理的类和扩展集合:执行统计的列表的示例中,我们看到了计算许多统计值的类定义。这些定义几乎完全依赖于内置的sum()函数。这是比较常见的缩减之一。
我们能否将求和概括为一种允许我们编写多种不同类型的缩减的方式?我们如何以更一般的方式定义缩减的概念?
准备就绪
最常见的缩减之一是求和。其他缩减包括乘积、最小值、最大值、平均值、方差,甚至是简单的值计数。
这是一种将数学定义的求和函数+应用于集合C中的值的方式:

我们通过在值序列C=c[0], c[1], c[2], ..., c[n]中插入+运算符来扩展了求和的定义。这种在+运算符中进行fold的想法捕捉了内置的sum()函数的含义。
类似地,乘积的定义如下:

在这里,我们对一系列值进行了不同的fold。通过 fold 扩展缩减涉及两个项目:一个二元运算符和一个基本值。对于求和,运算符是+,基本值是零。对于乘积,运算符是×,基本值是一。
我们可以定义一个通用的高级函数F[(⋄, ⊥)],它捕捉了 fold 的理想。fold 函数定义包括一个运算符⋄的占位符和一个基本值⊥的占位符。给定集合C的函数值可以用这个递归规则来定义:

如果集合C为空,则值为基本值⊥。当定义sum()时,基本值将为零。如果C不为空,则我们首先计算集合中除最后一个值之外的所有值的 fold,F◊, ⊥。然后我们将运算符(例如加法)应用于前一个 fold 结果和集合中的最后一个值C[n-][1]。对于sum(),运算符是+。
我们在 Pythonic 意义上使用了C[0..n]的符号。包括索引 0 到n-1 的值,但不包括索引n的值。这意味着C[0..0]=∅:这个范围C[0..0]中没有元素。
这个定义被称为fold left操作,因为这个定义的净效果是在集合中从左到右执行基础操作。这可以改为定义一个fold right操作。由于 Python 的reduce()函数是一个 fold left,我们将坚持使用它。
我们将定义一个prod()函数,用于计算阶乘值:

n的阶乘的值是 1 到n之间所有数字的乘积。由于 Python 使用半开区间,使用1 ≤ x < n + 1来定义范围更符合 Python 的风格。这个定义更适合内置的range()函数。
使用我们之前定义的 fold 操作符,我们有这个。我们使用乘法*的操作符和基本值为 1 来定义了一个 fold(或者 reduce):

折叠的概念是 Python 的reduce()概念的通用概念。我们可以将这应用于许多算法,可能简化定义。
如何做...
- 从
functools模块导入reduce()函数:
**>>> from functools import reduce**
- 选择运算符。对于求和,是
+。对于乘积,是*。这些可以以多种方式定义。这是长版本。其他定义必要的二进制运算符的方法将在后面展示:
**>>> def mul(a, b):
... return a * b**
- 选择所需的基值。对于求和,它是零。对于乘积,它是一。这使我们能够定义一个计算通用乘积的
prod()函数:
**>>> def prod(values):
... return reduce(mul, values, 1)**
- 对于阶乘,我们需要定义将被减少的数值序列:
**range(1, n+1)**
这是prod()函数的工作原理:
**>>> prod(range(1, 5+1))
120**
这是整个阶乘函数:
**>>> def factorial(n):
... return prod(range(1, n+1))**
这是一副 52 张牌的排列方式。这是值52!:
**>>> factorial(52)
80658175170943878571660636856403766975289505440883277824000000000000**
一副牌可以被洗牌的方式有很多种。
有多少种 5 张牌的可能手牌?二项式计算使用阶乘:

**>>> factorial(52)//(factorial(5)*factorial(52-5))
2598960**
对于任何给定的洗牌,大约有 260 万种不同的可能扑克手牌。(是的,这是一种计算二项式的非常低效的方法。)
它是如何工作的...
reduce()函数的行为就好像它有这个定义:
def reduce(function, iterable, base):
result = base
for item in iterable:
result = function(result, item)
return result
这将从左到右迭代数值。它将在可迭代集合中的前一组数值和下一个项目之间应用给定的二进制函数。
当我们看递归函数和 Python 的堆栈限制这个教程时,我们可以看到 fold 的递归定义可以优化为这个for语句。
还有更多...
在设计reduce()函数时,我们需要提供一个二进制运算符。有三种方法来定义必要的二进制运算符。我们使用了一个完整的函数定义,如下所示:
def mul(a, b):
return a * b
还有两个选择。我们可以使用lambda对象而不是完整的函数:
**>>> add = lambda a, b: a + b
>>> mul = lambda a, b: a * b**
lambda函数是一个匿名函数,只包含两个基本元素:参数和返回表达式。lambda 内部没有语句,只有一个表达式。在这种情况下,表达式只是使用所需的运算符。
我们可以像这样使用它:
**>>> def prod2(values):
... return reduce(lambda a, b: a*b, values, 1)**
这提供了乘法函数作为一个lambda对象,而不需要额外的函数定义开销。
我们还可以从operator模块导入定义:
from operator import add, mul
这对所有内置的算术运算符都适用。
请注意,使用逻辑运算符AND和OR的逻辑归约与其他算术归约有所不同。这些运算符会短路:一旦值为false,and-reduce就可以停止处理。同样,一旦值为True,or-reduce就可以停止处理。内置函数any()和all()很好地体现了这一点。使用内置的reduce()很难捕捉到这种短路特性。
最大值和最小值
我们如何使用reduce()来计算最大值或最小值?这更复杂一些,因为没有可以使用的平凡基值。我们不能从零或一开始,因为这些值可能超出被最小化或最大化的值范围。
此外,内置的max()和min()必须对空序列引发异常。这些函数无法完全适应sum()函数和reduce()函数的工作方式。
我们必须使用类似这样的东西来提供期望的功能集:
def mymax(sequence):
try:
base = sequence[0]
max_rule = lambda a, b: a if a > b else b
reduce(max_rule, sequence, base)
except IndexError:
raise ValueError
这个函数将从序列中选择第一个值作为基值。它创建了一个名为max_rule的lambda对象,它选择两个参数值中较大的那个。然后我们可以使用数据中的这个基值和lambda对象。reduce()函数将在非空集合中找到最大的值。我们捕获了IndexError异常,以便一个空集合会引发ValueError异常。
这个例子展示了我们如何发明一个更复杂或精密的最小值或最大值函数,它仍然基于内置的reduce()函数。这样做的好处是可以替换减少集合到单个值时的样板for语句。
滥用的潜力
请注意,折叠(或在 Python 中称为reduce())可能会被滥用,导致性能不佳。我们必须谨慎使用reduce()函数,仔细考虑最终算法可能是什么样子。特别是,被折叠到集合中的运算符应该是一个简单的过程,比如加法或乘法。使用reduce()会将O(1)操作的复杂性改变为O(n)。
想象一下,如果在减少过程中应用的运算符涉及对集合进行排序会发生什么。在reduce()中使用复杂的运算符-具有O(n log n)复杂度-会将整体reduce()的复杂度改变为O(n² log n)。
组合映射和减少转换
在本章的其他配方中,我们一直在研究映射、过滤和减少操作。我们分别研究了这三个操作:
-
对集合应用转换配方显示
map()函数 -
选择子集-三种过滤方法配方显示
filter()函数 -
总结集合-如何减少配方显示
reduce()函数
许多算法将涉及函数的组合。我们经常使用映射、过滤和减少来生成可用数据的摘要。此外,我们需要看一下使用迭代器和生成器函数的一个深刻限制。即这个限制:
提示
迭代器只能产生一次值。
如果我们从生成器函数和集合数据创建一个迭代器,那么迭代器只会产生数据一次。之后,它将看起来是一个空序列。
这是一个例子:
**>>> typical_iterator = iter([0, 1, 2, 3, 4])
>>> sum(typical_iterator)
10
>>> sum(typical_iterator)
0**
我们通过手动将iter()函数应用于文字列表对象来创建了一个值序列的迭代器。sum()函数第一次使用typical_iterator的值时,它消耗了所有五个值。下一次我们尝试将任何函数应用于typical_iterator时,将不会有更多的值被消耗-迭代器看起来是空的。
这种基本的一次性限制驱动了在使用多种类型的生成器函数与映射、过滤和减少一起工作时的一些设计考虑。我们经常需要缓存中间结果,以便我们可以对数据执行多次减少。
准备好
在使用堆叠的生成器表达式配方中,我们研究了需要多个处理步骤的数据。我们使用生成器函数合并了行。我们过滤掉了一些行,将它们从结果数据中删除。此外,我们对数据应用了许多映射,将日期和时间转换为更有用的信息。
我们想要通过两次减少来补充这一点,以获得一些平均值和方差信息。这些统计数据将帮助我们更充分地理解数据。
我们有一个用于记录大帆船燃料消耗的电子表格。它的行看起来像这样:
| 日期 | 引擎开启 | 燃料高度 |
|---|---|---|
| 关闭引擎 | 燃料高度 | |
| 其他说明 | ||
| 10/25/2013 | 08:24 | 29 |
| 13:15 | 27 | |
| 平静的海洋-锚所罗门岛 | ||
| 10/26/2013 | 09:12 | 27 |
| 18:25 | 22 | |
| 波涛汹涌-锚在杰克逊溪 |
最初的处理是一系列操作,改变数据的组织,过滤掉标题,并计算一些有用的值。
如何做到...
- 从目标开始。在这种情况下,我们想要一个可以像这样使用的函数:
**>>> round(sum_fuel(clean_data(row_merge(log_rows))), 3)
7.0**
这显示了这种处理的三步模式。这三步将定义我们创建减少的各个部分的方法:
-
首先,转换数据组织。有时这被称为数据规范化。在这种情况下,我们将使用一个名为
row_merge()的函数。有关此信息,请参阅使用堆叠的生成器表达式食谱。 -
其次,使用映射和过滤来清洁和丰富数据。这被定义为一个单一函数,
clean_data()。 -
最后,使用
sum_fuel()将数据减少到总和。还有各种其他减少的方法是有意义的。我们可能计算平均值,或其他值的总和。我们可能想应用很多减少。 -
如果需要,定义数据结构规范化函数。这几乎总是必须是一个生成器函数。结构性的改变不能通过
map()应用:
from ch08_r02 import row_merge
如使用堆叠的生成器表达式食谱所示,此生成器函数将把每次航行的三行数据重组为每次航行的一行数据。当所有列都在一行中时,数据处理起来更容易。
- 定义整体数据清洗和增强数据函数。这是一个由简单函数构建的生成器函数。它是一系列
map()和filter()操作,将从源字段派生数据:
def clean_data(source):
namespace_iter = map(make_namespace, source)
fitered_source = filter(remove_date, namespace_iter)
start_iter = map(start_datetime, fitered_source)
end_iter = map(end_datetime, start_iter)
delta_iter = map(duration, end_iter)
fuel_iter = map(fuel_use, delta_iter)
per_hour_iter = map(fuel_per_hour, fuel_iter)
return per_hour_iter
每个map()和filter()操作都涉及一个小函数,对数据进行单个转换或计算。
-
定义用于清洗和派生其他数据的单个函数。
-
将合并的数据行转换为
SimpleNamespace。这将允许我们使用名称,如start_time,而不是row[1]:
from types import SimpleNamespace
def make_namespace(row):
ns = SimpleNamespace(
date = row[0],
start_time = row[1],
start_fuel_height = row[2],
end_time = row[4],
end_fuel_height = row[5],
other_notes = row[7]
)
return ns
此函数从源数据的选定列构建一个SimpleNamspace。第三列和第六列被省略,因为它们始终是零长度的字符串,''。
- 这是由
filter()用于删除标题行的函数。如果需要,这可以扩展到从源数据中删除空行或其他不良数据。想法是尽快在处理中删除不良数据:
def remove_date(row_ns):
return not(row_ns.date == 'date')
- 将数据转换为可用形式。首先,我们将字符串转换为日期。接下来的两个函数依赖于这个
timestamp()函数,它将一个列中的date字符串加上另一个列中的time字符串转换为一个适当的datetime实例:
import datetime
def timestamp(date_text, time_text):
date = datetime.datetime.strptime(date_text, "%m/%d/%y").date()
time = datetime.datetime.strptime(time_text, "%I:%M:%S %p").time()
timestamp = datetime.datetime.combine(date, time)
return timestamp
这使我们能够根据datetime库进行简单的日期计算。特别是,减去两个时间戳将创建一个timedelta对象,其中包含任何两个日期之间的确切秒数。
这是我们将如何使用此函数为航行的开始和结束创建适当的时间戳:
def start_datetime(row_ns):
row_ns.start_timestamp = timestamp(row_ns.date, row_ns.start_time)
return row_ns
def end_datetime(row_ns):
row_ns.end_timestamp = timestamp(row_ns.date, row_ns.end_time)
return row_ns
这两个函数都将向SimpleNamespace添加一个新属性,并返回命名空间对象。这允许这些函数在map()操作的堆栈中使用。我们还可以重写这些函数,用不可变的namedtuple()替换可变的SimpleNamespace,并仍然保留map()操作的堆栈。
- 计算派生时间数据。在这种情况下,我们也可以计算持续时间。这是一个必须在前两个之后执行的函数:
def duration(row_ns):
travel_time = row_ns.end_timestamp - row_ns.start_timestamp
row_ns.travel_hours = round(travel_time.total_seconds()/60/60, 1)
return row_ns
这将把秒数差转换为小时值。它还会四舍五入到最接近的十分之一小时。比这更精确的信息基本上是噪音。出发和到达时间(通常)至少相差一分钟;它们取决于船长记得看手表的时间。在某些情况下,她可能已经估计了时间。
- 计算分析所需的其他指标。这包括创建转换为浮点数的高度值。最终的计算基于另外两个计算结果:
def fuel_use(row_ns):
end_height = float(row_ns.end_fuel_height)
start_height = float(row_ns.start_fuel_height)
row_ns.fuel_change = start_height - end_height
return row_ns
def fuel_per_hour(row_ns):
row_ns.fuel_per_hour = row_ns.fuel_change/row_ns.travel_hours
return row_ns
每小时燃料消耗量取决于整个前面的计算堆栈。旅行小时数来自分别计算的开始和结束时间戳。
它是如何工作的...
想法是创建一个遵循常见模板的复合操作:
-
规范化结构:这通常需要一个生成器函数,以在不同结构中读取数据并产生数据。
-
过滤和清洗:这可能涉及一个简单的过滤,就像这个例子中所示的那样。我们稍后会看到更复杂的过滤器。
-
通过映射或类定义的惰性属性派生数据:具有惰性属性的类是一个反应式对象。对源属性的任何更改都应该导致计算属性的更改。
在某些情况下,我们可能需要将基本事实与其他维度描述相结合。例如,我们可能需要查找参考数据,或解码编码字段。
一旦我们完成了初步步骤,我们就有了可用于各种分析的数据。很多时候,这是一个减少操作。初始示例计算了燃料使用量的总和。这里还有另外两个例子:
from statistics import *
def avg_fuel_per_hour(iterable):
return mean(row.fuel_per_hour for row in iterable)
def stdev_fuel_per_hour(iterable):
return stdev(row.fuel_per_hour for row in iterable)
这些函数将mean()和stdev()函数应用于丰富数据的每一行的fuel_per_hour属性。
我们可以这样使用它:
**>>> round(avg_fuel_per_hour(
... clean_data(row_merge(log_rows))), 3)
0.48**
我们使用clean_data(row_merge(log_rows))映射管道来清理和丰富原始数据。然后我们对这些数据应用了减少以获得我们感兴趣的值。
现在我们知道我们的 30 英寸高的油箱可以支持大约 60 小时的动力。以 6 节的速度,我们可以在满油箱的情况下行驶大约 360 海里。
还有更多...
正如我们所指出的,我们只能对可迭代的数据源执行一次减少。如果我们想要计算多个平均值,或者平均值和方差,我们将需要使用稍微不同的模式。
为了计算数据的多个摘要,我们需要创建一种可以重复进行摘要的序列对象:
data = tuple(clean_data(row_merge(log_rows)))
m = avg_fuel_per_hour(data)
s = 2*stdev_fuel_per_hour(data)
print("Fuel use {m:.2f} ±{s:.2f}".format(m=m, s=s))
在这里,我们从清理和丰富的数据中创建了一个tuple。这个tuple将产生一个可迭代对象,但与生成器函数不同,它可以多次产生这个可迭代对象。我们可以使用tuple对象计算两个摘要。
这个设计涉及大量的源数据转换。我们使用了一系列 map、filter 和 reduce 操作来构建它。这提供了很大的灵活性。
另一种方法是创建一个类定义。一个类可以设计为具有惰性属性。这将创建一种反应式设计,体现在单个代码块中。请参阅使用属性进行惰性属性配方,了解这方面的示例。
我们还可以在itertools模块中使用tee()函数进行这种处理:
from itertools import tee
data1, data2 = tee(clean_data(row_merge(log_rows)), 2)
m = avg_fuel_per_hour(data1)
s = 2*stdev_fuel_per_hour(data2)
我们使用tee()创建了clean_data(row_merge(log_rows))的可迭代输出的两个克隆。我们可以使用这两个克隆来计算平均值和标准差。
另请参阅
-
我们已经看过如何在使用堆叠的生成器表达式配方中结合映射和过滤。
-
我们在使用属性进行惰性属性配方中看过懒惰属性。此外,这个配方还涉及 map-reduce 处理的一些重要变化。
实现“存在”处理
我们一直在研究的处理模式都可以用量词对于所有来总结。这已经是所有处理定义的一个隐含部分:
-
映射:对于源中的所有项目,应用映射函数。我们可以使用量词来明确这一点:{ M ( x ) ∀ x : x ∈ C }
-
过滤:对于源中的所有项目,传递那些过滤函数为
true的项目。这里也使用了量词来明确这一点。如果某个函数F(x)为true,我们希望从集合C中获取所有值x:{ x ∀ x : x ∈ C if F ( x )} -
减少:对于源中的所有项目,使用给定的运算符和基本值来计算摘要。这个规则是一个递归,对于源集合或可迭代的所有值都清晰地适用:
。
我们在 Pythonic 意义上使用了C[0..n]的符号。索引位置为 0 和n-1的值是包括在内的,但索引位置为n的值不包括在内。这意味着这个范围内没有元素。
更重要的是C[0..n-1 ] ∪ C[n-1] = C 。也就是说,当我们从范围中取出最后一项时,不会丢失任何项——我们总是在处理集合中的所有项。此外,我们不会两次处理项C[n-1]。它不是C[0..n-1]范围的一部分,而是一个独立的项C[n-1]。
我们如何使用生成器函数编写一个进程,当第一个值匹配某个谓词时停止?我们如何避免对于所有并用存在量化我们的逻辑?
准备工作
我们可能需要另一个量词——存在,∃。让我们看一个存在性测试的例子。
我们可能想知道一个数是素数还是合数。我们不需要知道一个数的所有因子就能知道它不是素数。只要证明存在一个因子就足以知道一个数不是素数。
我们可以定义一个素数谓词P(n),如下所示:
P ( n ) = ¬∃ i : 2 ≤ i < n if n mod i = 0
一个数n,如果不存在一个值i(在 2 和这个数之间),能够整除这个数,那么它是素数。我们可以将否定移到周围,并重新表述如下:
¬P ( n ) = ∃ i : 2 ≤ i < n if n mod i = 0
一个数n,如果存在一个值i,在 2 和这个数本身之间,能够整除这个数,那么它是合数(非素数)。我们不需要知道所有这样的值。满足谓词的一个值的存在就足够了。
一旦我们找到这样的数字,我们可以从任何迭代中提前中断。这需要在for和if语句中使用break语句。因为我们不处理所有的值,所以我们不能轻易使用高阶函数,比如map()、filter()或reduce()。
如何做...
- 定义一个生成器函数模板,它将跳过项目,直到找到所需的项目。这将产生只有一个通过谓词测试的值:
def find_first(predicate, iterable):
for item in iterable:
if predicate(item):
yield item
break
- 定义一个谓词函数。对于我们的目的,一个简单的
lambda对象就可以了。此外,lambda 允许我们使用一个绑定到迭代的变量和一个自由于迭代的变量。这是表达式:
lambda i: n % i == 0
在这个 lambda 中,我们依赖一个非局部值n。这将是 lambda 的全局值,但仍然是整个函数的局部值。如果n % i是0,那么i是n的一个因子,n不是素数。
- 使用给定的范围和谓词应用该函数:
import math
def prime(n):
factors = find_first(
lambda i: n % i == 0,
range(2, int(math.sqrt(n)+1)) )
return len(list(factors)) == 0
如果factors可迭代对象有一个项,那么n是合数。否则,factors可迭代对象中没有值,这意味着n是一个素数。
实际上,我们不需要测试两个和n之间的每一个数字,以确定n是否是素数。只需要测试值i,使得2 ≤ i < √ n。
它是如何工作的...
在find_first()函数中,我们引入了一个break语句来停止处理源可迭代对象。当for语句停止时,生成器将到达函数的末尾,并正常返回。
从这个生成器中消耗值的进程将得到StopIteration异常。这个异常意味着生成器不会再产生值。find_first()函数会引发一个异常,但这不是一个错误。这是信号一个可迭代对象已经完成了输入值的处理的正常方式。
在这种情况下,信号意味着两种可能:
-
如果产生了一个值,那么这个值是
n的一个因子 -
如果没有产生值,那么
n是素数
从for语句中提前中断的这个小改变,使得生成器函数的含义发生了巨大的变化。与处理源的所有值不同,find_first()生成器将在谓词为true时停止处理。
这与过滤器不同,过滤器会消耗所有的源值。当使用break语句提前离开for语句时,一些源值可能不会被处理。
还有更多...
在itertools模块中,有一个替代find_first()函数的方法。takewhile()函数使用一个谓词函数来保持从输入中获取值。当谓词变为false时,函数停止处理值。
我们可以很容易地将 lambda 从lambda i: n % i == 0改为lambda i: n % i != 0。这将允许函数在它们不是因子时接受值。任何是因子的值都会通过结束takewhile()过程来停止处理。
让我们来看两个例子。我们将测试13是否为质数。我们需要检查范围内的数字。我们还将测试15是否为质数:
**>>> from itertools import takewhile
>>> n = 13
>>> list(takewhile(lambda i: n % i != 0, range(2, 4)))
[2, 3]
>>> n = 15
>>> list(takewhile(lambda i: n % i != 0, range(2, 4)))
[2]**
对于质数,所有的测试值都通过了takewhile()谓词。结果是给定数字n的非因子列表。如果非因子的集合与被测试的值的集合相同,那么n是质数。在13的情况下,两个值的集合都是[2, 3]。
对于合数,一些值通过了takewhile()谓词。在这个例子中,2不是15的因子。然而,3是一个因子;这不符合谓词。非因子的集合[2]与被测试的值的集合[2, 3]不同。
我们最终得到的函数看起来像这样:
def prime_t(n):
tests = set(range(2, int(math.sqrt(n)+1)))
non_factors = set(
takewhile(
lambda i: n % i != 0,
tests
)
)
return tests == non_factors
这创建了两个中间集合对象tests和non_factors。如果所有被测试的值都不是因子,那么这个数就是质数。之前展示的函数,基于find_first()只创建了一个中间列表对象。那个列表最多只有一个成员,使得数据结构更小。
itertools 模块
itertools模块中还有许多其他函数,我们可以用来简化复杂的映射-归约应用:
-
filterfalse():它是内置filter()函数的伴侣。它颠倒了filter()函数的谓词逻辑;它拒绝谓词为true的项目。 -
zip_longest():它是内置zip()函数的伴侣。内置的zip()函数在最短的可迭代对象耗尽时停止合并项目。zip_longest()函数将提供一个给定的填充值,以使短的可迭代对象与最长的可迭代对象匹配。 -
starmap():这是对基本map()算法的修改。当我们执行map(function, iter1, iter2)时,每个可迭代对象中的一个项目将作为给定函数的两个位置参数提供。starmap()期望一个可迭代对象提供一个包含参数值的元组。实际上:
map = starmap(function, zip(iter1, iter2))
还有其他一些我们可能也会用到的:
-
accumulate():这个函数是内置sum()函数的一个变体。它会产生在达到最终总和之前产生的每个部分总和。 -
chain():这个函数将按顺序合并可迭代对象。 -
compress():这个函数使用一个可迭代对象作为数据源,另一个可迭代对象作为选择器的数据源。当选择器的项目为 true 时,相应的数据项目被传递。否则,数据项目被拒绝。这是基于真假值的逐项过滤器。 -
dropwhile():只要这个函数的谓词为true,它就会拒绝值。一旦谓词变为false,它就会传递所有剩余的值。参见takewhile()。 -
groupby():这个函数使用一个键函数来控制组的定义。具有相同键值的项目被分组到单独的迭代器中。为了使结果有用,原始数据应该按键的顺序排序。 -
islice():这个函数类似于切片表达式,只不过它适用于可迭代对象,而不是列表。当我们使用list[1:]来丢弃列表的第一行时,我们可以使用islice(iterable, 1)来丢弃可迭代对象的第一个项目。 -
takewhile():只要谓词为true,这个函数就会传递值。一旦谓词变为false,就停止处理任何剩余的值。参见dropwhile()。 -
tee():这将单个可迭代对象分成多个克隆。然后可以单独消耗每个克隆。这是在单个可迭代数据源上执行多个减少的一种方法。
创建一个部分函数
当我们查看reduce()、sorted()、min()和max()等函数时,我们会发现我们经常有一些永久参数值。例如,我们可能会发现需要在几个地方写类似这样的东西:
reduce(operator.mul, ..., 1)
对于reduce()的三个参数,只有一个-要处理的可迭代对象-实际上会改变。运算符和基本值参数基本上固定为operator.mul和1。
显然,我们可以为此定义一个全新的函数:
def prod(iterable):
return reduce(operator.mul, iterable, 1)
然而,Python 有一些简化这种模式的方法,这样我们就不必重复使用样板def和return语句。
我们如何定义一个具有预先提供一些参数的函数?
请注意,这里的目标与提供默认值不同。部分函数不提供覆盖默认值的方法。相反,我们希望创建尽可能多的部分函数,每个函数都提前绑定了特定的参数。
准备工作
一些统计建模是用标准分数来完成的,有时被称为z 分数。其想法是将原始测量标准化到一个可以轻松与正态分布进行比较的值,并且可以轻松与以不同单位测量的相关数字进行比较。
计算如下:
z = ( x - μ)/σ
这里,x是原始值,μ是总体均值,σ是总体标准差。值z的均值为 0,标准差为 1。这使得它特别容易处理。
我们可以使用这个值来发现异常值-与均值相距甚远的值。我们期望我们的z值(大约)99.7%会在-3 和+3 之间。
我们可以定义一个这样的函数:
def standarize(mean, stdev, x):
return (x-mean)/stdev
这个standardize()函数将从原始分数x计算出 z 分数。这个函数有两种类型的参数:
-
mean和stdev的值基本上是固定的。一旦我们计算出总体值,我们将不断地将它们提供给standardize()函数。 -
x的值更加可变。
假设我们有一系列大块文本中的数据样本:
text_1 = '''10 8.04
8 6.95
13 7.58
...
5 5.68
'''
我们已经定义了两个小函数来将这些数据转换为数字对。第一个简单地将每个文本块分解为一系列行,然后将每行分解为一对文本项:
text_parse = lambda text: (r.split() for r in text.splitlines())
我们已经使用文本块的splitlines()方法创建了一系列行。我们将其放入生成器函数中,以便每个单独的行都可以分配给r。使用r.split()将每行中的两个文本块分开。
如果我们使用list(text_parse(text_1)),我们会看到这样的数据:
[['10', '8.04'],
['8', '6.95'],
['13', '7.58'],
...
['5', '5.68']]
我们需要进一步丰富这些数据,使其更易于使用。我们需要将字符串转换为适当的浮点值。在这样做的同时,我们将从每个项目创建SimpleNamespace实例:
from types import SimpleNamespace
row_build = lambda rows: (SimpleNamespace(x=float(x), y=float(y)) for x,y in rows)
lambda对象通过将float()函数应用于每行中的每个字符串项来创建SimpleNamespace实例。这给了我们可以处理的数据。
我们可以将这两个lambda对象应用于数据,以创建一些可用的数据集。之前,我们展示了text_1。我们假设我们有一个类似的第二组数据分配给text_2:
data_1 = list(row_build(text_parse(text_1)))
data_2 = list(row_build(text_parse(text_2)))
这样就创建了两个类似文本块的数据。每个都有数据点对。SimpleNamespace对象有两个属性,x和y,分配给数据的每一行。
请注意,这个过程创建了types.SimpleNamespace的实例。当我们打印它们时,它们将使用namespace类显示。这些是可变对象,因此我们可以用标准化的 z 分数更新每一个。
打印data_1看起来像这样:
[namespace(x=10.0, y=8.04), namespace(x=8.0, y=6.95),
namespace(x=13.0, y=7.58),
...,
namespace(x=5.0, y=5.68)]
例如,我们将计算x属性的标准化值。这意味着获取均值和标准差。然后我们需要将这些值应用于标准化我们两个集合中的数据。看起来是这样的:
import statistics
mean_x = statistics.mean(item.x for item in data_1)
stdev_x = statistics.stdev(item.x for item in data_1)
for row in data_1:
z_x = standardize(mean_x, stdev_x, row.x)
print(row, z_x)
for row in data_2:
z_x = standardize(mean_x, stdev_x, row.x)
print(row, z_x)
每次评估standardize()时提供mean_v1,stdev_v1值可能会使算法混乱,因为这些细节并不是非常重要。在一些相当复杂的算法中,这种混乱可能导致更多的困惑而不是清晰。
如何做...
除了简单地使用def语句创建具有部分参数值的函数之外,我们还有两种其他方法来创建部分函数:
-
使用
functools模块的partial()函数 -
创建
lambda对象
使用 functools.partial()
- 从
functools导入partial函数:
from functools import partial
- 使用
partial()创建对象。我们提供基本函数,以及需要包括的位置参数。在定义部分时未提供的任何参数在评估部分时必须提供:
z = partial(standardize, mean_x, stdev_x)
- 我们已为前两个位置参数
mean和stdev提供了值。第三个位置参数x必须在计算值时提供。
创建lambda对象
- 定义绑定固定参数的
lambda对象:
lambda x: standardize(mean_v1, stdev_v1, x)
- 使用
lambda创建对象:
z = lambda x: standardize(mean_v1, stdev_v1, x)
它是如何工作的...
这两种技术都创建了一个可调用对象——一个名为z()的函数,其值为mean_v1和stdev_v1已经绑定到前两个位置参数。使用任一方法,我们的处理看起来可能是这样的:
for row in data_1:
print(row, z(row.x))
for row in data_2:
print(row, z(row.x))
我们已将z()函数应用于每组数据。因为函数已经应用了一些参数,所以在这里使用看起来非常简单。
我们还可以这样做,因为每行都是一个可变对象:
for row in data_1:
row.z = z(row.v1)
for row in data_2:
row.z = z(row.v1)
我们已更新行,包括一个新属性z,其值为z()函数。在复杂的算法中,调整行对象可能是一个有用的简化。
创建z()函数的两种技术之间存在显着差异:
-
partial()函数绑定参数的实际值。对使用的变量进行的任何后续更改都不会改变创建的部分函数的定义。创建z = partial(standardize(mean_v1, stdev_v1))后,更改mean_v1或stdev_v1的值不会对部分函数z()产生影响。 -
lambda对象绑定变量名,而不是值。对变量值的任何后续更改都将改变 lambda 的行为方式。创建z = lambda x: standardize(mean_v1, stdev_v1, x)后,更改mean_v1或stdev_v1的值将改变lambda对象z()使用的值。
我们可以稍微修改 lambda 以绑定值而不是名称:
z = lambda x, m=mean_v1, s=stdev_v1: standardize(m, s, x)
这将提取mean_v1和stdev_v1的值以创建lambda对象的默认值。mean_v1和stdev_v1的值现在与lambda对象z()的正常操作无关。
还有更多...
在创建部分函数时,我们可以提供关键字参数值以及位置参数值。在许多情况下,这很好用。也有一些情况不适用。
特别是reduce()函数不能简单地转换为部分函数。参数的顺序不是创建部分的理想顺序。reduce()函数具有以下概念定义。这不是它的定义方式——这是它看起来的定义方式:
def reduce(function, iterable, initializer=None)
如果这是实际定义,我们可以这样做:
prod = partial(reduce(mul, initializer=1))
实际上,我们无法这样做,因为reduce()的定义比看起来更复杂一些。reduce()函数不允许命名参数值。这意味着我们被迫使用 lambda 技术:
**>>> from operator import mul
>>> from functools import reduce
>>> prod = lambda x: reduce(mul, x, 1)**
我们使用lambda对象定义了一个只有一个参数prod()函数。这个函数使用两个固定参数和一个可变参数与reduce()一起使用。
有了prod()的定义,我们可以定义依赖于计算乘积的其他函数。下面是factorial函数的定义:
**>>> factorial = lambda x: prod(range(2,x+1))
>>> factorial(5)
120**
factorial()的定义取决于prod()。prod()的定义是一种使用reduce()和两个固定参数值的部分函数。我们设法使用了一些定义来创建一个相当复杂的函数。
在 Python 中,函数是一个对象。我们已经看到了函数可以作为参数传递的多种方式。接受另一个函数作为参数的函数有时被称为高阶函数。
同样,函数也可以返回一个函数对象作为结果。这意味着我们可以创建一个像这样的函数:
def prepare_z(data):
mean_x = statistics.mean(item.x for item in data_1)
stdev_x = statistics.stdev(item.x for item in data_1)
return partial(standardize, mean_x, stdev_x)
我们已经定义了一个在一组(x,y)样本上的函数。我们计算了每个样本的x属性的均值和标准差。然后我们创建了一个可以根据计算出的统计数据标准化得分的部分函数。这个函数的结果是一个我们可以用于数据分析的函数:
z = prepare_z(data_1)
for row in data_2:
print(row, z(row.x))
当我们评估prepare_z()函数时,它返回了一个函数。我们将这个函数赋给一个变量z。这个变量是一个可调用对象;它是函数z(),它将根据样本均值和标准差标准化得分。
使用不可变数据结构简化复杂算法
有状态对象的概念是面向对象编程的一个常见特性。我们在第六章和第七章中看过与对象和状态相关的一些技术,类和对象的基础和更高级的类设计。面向对象设计的重点之一是创建能够改变对象状态的方法。
我们还在使用堆叠的生成器表达式、组合 map 和 reduce 转换和创建部分函数配方中看过一些有状态的函数式编程技术。我们使用types.SimpleNamespace,因为它创建了一个简单的、有状态的对象,具有易于使用的属性名称。
在大多数情况下,我们一直在处理具有 Python dict对象定义属性的对象。唯一的例外是使用 slots 优化小对象配方,其中属性由__slots__属性定义固定。
使用dict对象存储对象的属性有几个后果:
-
我们可以轻松地添加和删除属性。我们不仅仅局限于设置和获取已定义的属性;我们也可以创建新属性。
-
每个对象使用的内存量比最小必要量稍微大一些。这是因为字典使用哈希算法来定位键和值。哈希处理通常需要比其他结构(如
list或tuple)更多的内存。对于非常大量的数据,这可能会成为一个问题。
有状态的面向对象编程最重要的问题是有时很难对对象的状态变化写出清晰的断言。与其定义关于状态变化的断言,更容易的方法是创建完全新的对象,其状态可以简单地映射到对象的类型。这与 Python 类型提示结合使用,有时可以创建更可靠、更易于测试的软件。
当我们创建新对象时,数据项和计算之间的关系可以被明确捕获。mypy项目提供了工具,可以分析这些类型提示,以确认复杂算法中使用的对象是否被正确使用。
在某些情况下,我们也可以通过避免首先使用有状态对象来减少内存的使用量。我们有两种技术可以做到这一点:
-
使用带有
__slots__的类定义:有关此内容,请参阅使用 slots 优化小对象的示例。这些对象是可变的,因此我们可以使用新值更新属性。 -
使用不可变的
tuples或namedtuples:有关此内容,请参阅设计具有少量独特处理的类的示例。这些对象是不可变的。我们可以创建新对象,但无法更改对象的状态。整体内存的成本节约必须平衡创建新对象的额外成本。
不可变对象可能比可变对象稍快。更重要的好处是算法设计。在某些情况下,编写函数从旧的不可变对象创建新的不可变对象可能比处理有状态对象的算法更简单、更容易测试和调试。编写类型提示可以帮助这个过程。
准备工作
正如我们在使用堆叠的生成器表达式和实现“存在”处理的示例中所指出的,我们只能处理生成器一次。如果我们需要多次处理它,可迭代对象的序列必须转换为像列表或元组这样的完整集合。
这通常会导致一个多阶段的过程:
-
初始提取数据:这可能涉及数据库查询或读取
.csv文件。这个阶段可以被实现为一个产生行或甚至返回生成器函数的函数。 -
清洗和过滤数据:这可能涉及一系列生成器表达式,可以仅处理一次源。这个阶段通常被实现为一个包含多个映射和过滤操作的函数。
-
丰富数据:这也可能涉及一系列生成器表达式,可以一次处理一行数据。这通常是一系列的映射操作,用于从现有数据中创建新的派生数据。
-
减少或总结数据:这可能涉及多个摘要。为了使其工作,丰富阶段的输出需要是可以多次处理的集合对象。
在某些情况下,丰富和总结过程可能会交错进行。正如我们在创建部分函数示例中看到的,我们可能会先进行一些总结,然后再进行更多的丰富。
处理丰富阶段有两种常见策略:
-
可变对象:这意味着丰富处理会添加或设置属性的值。可以通过急切计算来完成,因为属性被设置。请参阅使用可设置属性更新急切属性的示例。也可以使用惰性属性来完成。请参阅使用惰性属性的示例。我们已经展示了使用
types.SimpleNamespace的示例,其中计算是在与类定义分开的函数中完成的。 -
不可变对象:这意味着丰富过程从旧对象创建新对象。不可变对象源自
tuple或由namedtuple()创建的类型。这些对象的优势在于非常小且非常快。此外,缺乏任何内部状态变化使它们非常简单。
假设我们有一系列大块文本中的数据样本:
text_1 = '''10 8.04
8 6.95
13 7.58
...
5 5.68
'''
我们的目标是一个包括get、cleanse和enrich操作的三步过程:
data = list(enrich(cleanse(get(text))))
get()函数从源获取数据;在这种情况下,它会解析大块文本。cleanse()函数将删除空行和其他无法使用的数据。enrich()函数将对清理后的数据进行最终计算。我们将分别查看此管道的每个阶段。
get()函数仅限于纯文本处理,尽量少地进行过滤:
from typing import *
def get(text: str) -> Iterator[List[str]]:
for line in text.splitlines():
if len(line) == 0:
continue
yield line.split()
为了编写类型提示,我们已导入了typing模块。这使我们能够对此函数的输入和输出进行明确声明。get()函数接受一个字符串str。它产生一个List[str]结构。输入的每一行都被分解为一系列值。
这个函数将生成所有非空数据行。这里有一个小的过滤功能,但它与数据序列化的一个小技术问题有关,而不是一个特定于应用程序的过滤规则。
cleanse()函数将生成命名元组的数据。这将应用一些规则来确保数据是有效的:
from collections import namedtuple
DataPair = namedtuple('DataPair', ['x', 'y'])
def cleanse(iterable: Iterable[List[str]]) -> Iterator[DataPair]:
for text_items in iterable:
try:
x_amount = float(text_items[0])
y_amount = float(text_items[1])
yield DataPair(x_amount, y_amount)
except Exception as ex:
print(ex, repr(text_items))
我们定义了一个namedtuple,名为DataPair。这个项目有两个属性,x和y。如果这两个文本值可以正确转换为float,那么这个生成器将产生一个有用的DataPair实例。如果这两个文本值无法转换,这将显示一个错误,指出有问题的对。
注意mypy项目类型提示中的技术细微之处。带有yield语句的函数是一个迭代器。由于正式关系,我们可以将其用作可迭代对象,这种关系表明迭代器是可迭代对象的一种。
这里可以应用额外的清洗规则。例如,assert语句可以添加到try语句中。任何由意外或无效数据引发的异常都将停止处理给定输入行。
这个初始的cleanse()和get()处理的结果如下:
list(cleanse(get(text)))
The output looks like this:
[DataPair(x=10.0, y=8.04),
DataPair(x=8.0, y=6.95),
DataPair(x=13.0, y=7.58),
...,
DataPair(x=5.0, y=5.68)]
在这个例子中,我们将按每对的y值进行排名。这需要首先对数据进行排序,然后产生排序后的值,并陦一个额外的属性值,即y排名顺序。
如何做...
- 定义丰富的
namedtuple:
RankYDataPair = namedtuple('RankYDataPair', ['y_rank', 'pair'])
请注意,我们特意在这个新的数据结构中将原始对作为数据项包含在内。我们不想复制各个字段;相反,我们将原始对象作为一个整体合并在一起。
- 定义丰富函数:
PairIter = Iterable[DataPair]
RankPairIter = Iterator[RankYDataPair]
def rank_by_y(iterable:PairIter) -> RankPairIter:
我们在这个函数中包含了类型提示,以清楚地表明这个丰富函数期望和返回的类型。我们单独定义了类型提示,这样它们会更短,并且可以在其他函数中重复使用。
- 编写丰富的主体。在这种情况下,我们将进行排名排序,因此我们需要使用原始
y属性进行排序。我们从旧对象创建新对象,因此函数会生成RankYDataPair的实例:
all_data = sorted(iterable, key=lambda pair:pair.y)
for y_rank, pair in enumerate(all_data, start=1):
yield RankYDataPair(y_rank, pair)
我们使用enumerate()为每个值创建排名顺序号。对于一些统计处理来说,起始值为1有时很方便。在其他情况下,默认的起始值0也能很好地工作。
整个函数如下:
def rank_by_y(iterable: PairIter) -> RankPairIter:
all_data = sorted(iterable, key=lambda pair:pair.y)
for y_rank, pair in enumerate(all_data, start=1):
yield RankYDataPair(y_rank, pair)
我们可以在一个更长的表达式中使用它来获取、清洗,然后排名。使用类型提示可以使这一点比涉及有状态对象的替代方案更清晰。在某些情况下,代码的清晰度可能会有很大的改进。
它是如何工作的...
rank_by_y()函数的结果是一个包含原始对象和丰富结果的新对象。这是我们如何使用这个堆叠的生成器序列的:rank_by_y(),cleanse()和get():
**>>> data = rank_by_y(cleanse(get(text_1)))
>>> pprint(list(data))
[RankYDataPair(y_rank=1, pair=DataPair(x=4.0, y=4.26)),
RankYDataPair(y_rank=2, pair=DataPair(x=7.0, y=4.82)),
RankYDataPair(y_rank=3, pair=DataPair(x=5.0, y=5.68)),
...,
RankYDataPair(y_rank=11, pair=DataPair(x=12.0, y=10.84))]**
数据按y值升序排列。我们现在可以使用这些丰富的数据值进行进一步的分析和计算。
在许多情况下,创建新对象可能更能表达算法,而不是改变对象的状态。这通常是一个主观的判断。
Python 类型提示最适合用于创建新对象。因此,这种技术可以提供强有力的证据,证明复杂的算法是正确的。使用mypy可以使不可变对象更具吸引力。
最后,当我们使用不可变对象时,有时会看到一些小的加速。这依赖于 Python 的三个特性之间的平衡才能有效:
-
元组是小型数据结构。使用它们可以提高性能。
-
Python 中对象之间的任何关系都涉及创建对象引用,这是一个非常小的数据结构。一系列相关的不可变对象可能比一个可变对象更小。
-
对象的创建可能是昂贵的。创建太多不可变对象会超过其好处。
前两个功能带来的内存节省必须与第三个功能带来的处理成本相平衡。当存在大量数据限制处理速度时,内存节省可以带来更好的性能。
对于像这样的小例子,数据量非常小,对象创建成本与减少内存使用量的任何成本节省相比都很大。对于更大的数据集,对象创建成本可能小于内存不足的成本。
还有更多...
这个配方中的get()和cleanse()函数都涉及到类似的数据结构:Iterable[List[str]]和Iterator[List[str]]。在collections.abc模块中,我们看到Iterable是通用定义,而Iterator是Iterable的特殊情况。
用于本书的mypy版本——mypy 0.2.0-dev——对具有yield语句的函数被定义为Iterator非常严格。未来的版本可能会放宽对子类关系的严格检查,允许我们在两种情况下使用同一定义。
typing模块包括namedtuple()函数的替代品:NamedTuple()。这允许对元组中的各个项目进行数据类型的指定。
看起来是这样的:
DataPair = NamedTuple('DataPair', [
('x', float),
('y', float)
]
)
我们几乎可以像使用collection.namedtuple()一样使用typing.NamedTuple()。属性的定义使用了一个两元组的列表,而不是名称的列表。两元组有一个名称和一个类型定义。
这个补充类型定义被mypy用来确定NamedTuple对象是否被正确填充。其他人也可以使用它来理解代码并进行适当的修改或扩展。
在 Python 中,我们可以用不可变对象替换一些有状态的对象。但是有一些限制。例如,列表、集合和字典等集合必须保持为可变对象。在其他编程语言中,用不可变的单子替换这些集合可能效果很好,但在 Python 中不是这样的。

使用 yield from 语句编写递归生成器函数
有许多算法可以清晰地表达为递归。在围绕 Python 的堆栈限制设计递归函数配方中,我们看了一些可以优化以减少函数调用次数的递归函数。
当我们查看一些数据结构时,我们发现它们涉及递归。特别是,JSON 文档(以及 XML 和 HTML 文档)可以具有递归结构。JSON 文档可能包含一个包含其他复杂对象的复杂对象。
在许多情况下,使用生成器处理这些类型的结构有很多优势。我们如何编写能够处理递归的生成器?yield from语句如何避免我们编写额外的循环?
准备工作
我们将看一种在复杂数据结构中搜索有序集合的所有匹配值的方法。在处理复杂的 JSON 文档时,我们经常将它们建模为字典-字典和字典-列表结构。当然,JSON 文档不是一个两级的东西;字典-字典实际上意味着字典-字典-字典...同样,字典-列表实际上意味着字典-列表-...这些都是递归结构,这意味着搜索必须遍历整个结构以寻找特定的键或值。
具有这种复杂结构的文档可能如下所示:
document = {
"field": "value1",
"field2": "value",
"array": [
{"array_item_key1": "value"},
{"array_item_key2": "array_item_value2"}
],
"object": {
"attribute1": "value",
"attribute2": "value2"
}
}
这显示了一个具有四个键field、field2、array和object的文档。每个键都有一个不同的数据结构作为其关联值。一些值是唯一的,一些是重复的。这种重复是我们的搜索必须在整个文档中找到所有实例的原因。
核心算法是深度优先搜索。这个函数的输出将是一个标识目标值的路径列表。每个路径将是一系列字段名或字段名与索引位置混合的序列。
在前面的例子中,值value可以在三个地方找到:
-
["array", 0, "array_item_key1"]:这个路径从名为array的顶级字段开始,然后访问列表的第零项,然后是一个名为array_item_key1的字段 -
["field2"]:这个路径只有一个字段名,其中找到了值 -
["object", "attribute1"]:这个路径从名为object的顶级字段开始,然后是该字段的子attribute1
find_value()函数在搜索整个文档寻找目标值时,会产生这两个路径。这是这个搜索函数的概念概述:
def find_path(value, node, path=[]):
if isinstance(node, dict):
for key in node.keys():
# find_value(value, node[key], path+[key])
# This must yield multiple values
elif isinstance(node, list):
for index in range(len(node)):
# find_value(value, node[index], path+[index])
# This will yield multiple values
else:
# a primitive type
if node == value:
yield path
在find_path()过程中有三种选择:
-
当节点是一个字典时,必须检查每个键的值。值可以是任何类型的数据,因此我们将对每个值递归使用
find_path()函数。这将产生一系列匹配。 -
如果节点是一个列表,必须检查每个索引位置的项目。项目可以是任何类型的数据,因此我们将对每个值递归使用
find_path()函数。这将产生一系列匹配。 -
另一种选择是节点是一个原始值。JSON 规范列出了可能出现在有效文档中的许多原始值。如果节点值是目标值,我们找到了一个实例,并且可以产生这个单个匹配。
处理递归有两种方法。一种是这样的:
for match in find_value(value, node[key], path+[key]):
yield match
对于这样一个简单的想法来说,这似乎有太多的样板。另一种方法更简单,也更清晰一些。
如何做...
- 写出完整的
for语句:
for match in find_value(value, node[key], path+[key]):
yield match
出于调试目的,我们可以在for语句的主体中插入一个print()函数。
- 一旦确定事情运行正常,就用
yield from语句替换这个:
yield from find_value(value, node[key], path+[key])
完整的深度优先find_value()搜索函数将如下所示:
def find_path(value, node, path=[]):
if isinstance(node, dict):
for key in node.keys():
yield from find_path(value, node[key], path+[key])
elif isinstance(node, list):
for index in range(len(node)):
yield from find_path(value, node[index], path+[index])
else:
if node == value:
yield path
当我们使用find_path()函数时,它看起来像这样:
**>>> list(find_path('array_item_value2', document))
[['array', 1, 'array_item_key2']]**
find_path()函数是可迭代的。它可以产生许多值。我们消耗了所有的结果来创建一个列表。在这个例子中,列表只有一个项目,['array', 1, 'array_item_key2']。这个项目有指向匹配项的路径。
然后我们可以评估document['array'][1]['array_item_key2']来找到被引用的值。
当我们寻找非唯一值时,我们可能会看到这样的列表:
**>>> list(find_value('value', document))
[['array', 0, 'array_item_key1'],
['field2'],
['object', 'attribute1']]**
结果列表有三个项目。每个项目都提供了指向目标值value的路径。
它是如何工作的...
yield from X语句是以下内容的简写:
for item in X:
yield item
这使我们能够编写一个简洁的递归算法,它将作为迭代器运行,并正确地产生多个值。
这也可以在不涉及递归函数的情况下使用。在涉及可迭代结果的任何地方使用yield from语句都是完全合理的。然而,对于递归函数来说,这是一个很大的简化,因为它保留了一个明确的递归结构。
还有更多...
另一种常见的定义风格是使用追加操作组装列表。我们可以将这个重写为迭代器,避免构建列表对象的开销。
当分解一个数字时,我们可以这样定义质因数集:

如果值x是质数,它在质因数集中只有自己。否则,必须存在某个质数n,它是x的最小因数。我们可以从n开始组装一个因数集,并包括x/n的所有因数。为了确保只找到质因数,n必须是质数。如果我们按升序搜索,我们会在找到复合因数之前找到质因数。
我们有两种方法在 Python 中实现这个:一种是构建一个列表,另一种是生成因数。这是一个构建列表的函数:
import math
def factor_list(x):
limit = int(math.sqrt(x)+1)
for n in range(2, limit):
q, r = divmod(x, n)
if r == 0:
return [n] + factor_list(q)
return [x]
这个factor_list()函数将搜索所有数字n,使得 2 ≤ n < √ x。找到x的第一个因子的数字将是最小的因子。它也将是质数。当然,我们会搜索一些复合值,浪费时间。例如,在测试了二和三之后,我们还将测试四和六这样的值,尽管它们是复合数,它们的所有因子都已经被测试过了。
这个函数构建了一个list对象。如果找到一个因子n,它将以该因子开始一个列表。它将从x // n添加因子。如果没有x的因子,那么这个值是质数,我们将返回一个只包含该值的列表。
我们可以通过用yield from替换递归调用来将其重写为迭代器。函数将看起来像这样:
def factor_iter(x):
limit = int(math.sqrt(x)+1)
for n in range(2, limit):
q, r = divmod(x, n)
if r == 0:
yield n
yield from factor_iter(q)
return
yield x
与构建列表版本一样,这将搜索数字n,使得。当找到一个因子时,函数将产生该因子,然后通过对factor_iter()的递归调用找到任何其他因子。如果没有找到因子,函数将只产生质数,没有其他东西。
使用迭代器可以让我们从因子构建任何类型的集合。我们不再局限于总是创建一个list,而是可以使用collection.Counter类创建一个多重集。它看起来像这样:
**>>> from collections import Counter
>>> Counter(factor_iter(384))
Counter({2: 7, 3: 1})**
这向我们表明:
384 = 2⁷ × 3
在某些情况下,这种多重集比因子列表更容易处理。
另请参阅
- 在围绕 Python 的堆栈限制设计递归函数的配方中,我们涵盖了递归函数的核心设计模式。这个配方提供了创建结果的另一种方法。
第九章:输入/输出、物理格式和逻辑布局
在本章中,我们将看以下配方:
-
使用 pathlib 处理文件名
-
使用上下文管理器读写文件
-
替换文件并保留先前版本
-
使用 CSV 模块读取分隔文件
-
使用正则表达式读取复杂格式
-
读取 JSON 文档
-
读取 XML 文档
-
读取 HTML 文档
-
从 DictReader 升级 CSV 到命名元组读取器
-
从 DictReader 升级 CSV 到命名空间读取器
-
使用多个上下文读写文件
介绍
术语文件有许多含义:
- 操作系统(OS)使用文件来组织数据的字节。字节可以表示图像、一些声音样本、单词,甚至可执行程序。所有这些截然不同的内容都被简化为一组字节。应用软件理解这些字节。
有两种常见的操作系统文件:
-
块文件存在于诸如磁盘或固态驱动器(SSD)等设备上。这些文件可以按字节块读取。操作系统可以随时在文件中寻找任何特定的字节。
-
字符文件是管理设备的一种方式,比如连接到计算机的网络连接或键盘。文件被视为一系列单独的字节流,这些字节在看似随机的时间点到达。在字节流中没有办法向前或向后寻找。
-
文件一词还定义了 Python 运行时使用的数据结构。Python 文件抽象包装了各种操作系统文件实现。当我们打开一个文件时,Python 抽象、操作系统实现和磁盘或其他设备上的字节集之间存在绑定。
-
文件也可以被解释为 Python 对象的集合。从这个角度来看,文件的字节表示 Python 对象,如字符串或数字。文本字符串文件非常常见且易于处理。Unicode 字符通常使用 UTF-8 编码方案编码为字节,但还有许多其他选择。Python 提供了诸如
shelve和pickle等模块,以将更复杂的 Python 对象编码为字节。
通常,我们会谈论对象是如何序列化的。当对象被写入文件时,Python 对象状态信息被转换为一系列字节。反序列化是从字节中恢复 Python 对象的反向过程。我们也可以称之为状态的表示,因为我们通常将每个单独对象的状态与类定义分开序列化。
当我们处理文件中的数据时,我们经常需要做两个区分:
-
数据的物理格式:这回答了文件中的字节编码的 Python 数据结构是什么。字节可以是 Unicode 文本。文本可以表示逗号分隔值(CSV)或 JSON 文档。物理格式通常由 Python 库处理。
-
数据的逻辑布局:布局查看数据中的各种 CSV 列或 JSON 字段的细节。在某些情况下,列可能带有标签,或者可能有必须按位置解释的数据。这通常是我们应用程序的责任。
物理格式和逻辑布局对解释文件中的数据至关重要。我们将看一些处理不同物理格式的方法。我们还将研究如何使我们的程序与逻辑布局的某些方面分离。
使用 pathlib 处理文件名
大多数操作系统使用分层路径来标识文件。以下是一个示例文件名:
**/Users/slott/Documents/Writing/Python Cookbook/code**
这个完整的路径名有以下元素:
-
前导
/表示名称是绝对的。它从文件系统的根目录开始。在 Windows 中,名称前面可以有一个额外的字母,比如C:,以区分每个存储设备上的文件系统。Linux 和 Mac OS X 将所有设备视为单个大文件系统。 -
Users,slott,Documents,Writing,Python Cookbook和code等名称代表文件系统的目录(或文件夹)。必须有一个顶层的Users目录。它必须包含slott子目录。对于路径中的每个名称都是如此。 -
在 Windows 中,操作系统使用
\来分隔路径上的项目。Python 使用/。Python 的标准/会被优雅地转换为 Windows 路径分隔符字符;我们通常可以忽略 Windows 的\。
无法确定名称code代表什么类型的对象。有许多种文件系统对象。名称code可能是一个命名其他文件的目录。它可能是一个普通的数据文件,或者是一个指向面向流的设备的链接。还有额外的目录信息显示这是什么类型的文件系统对象。
没有前导/的路径是相对于当前工作目录的。在 Mac OS X 和 Linux 中,cd命令设置当前工作目录。在 Windows 中,chdir命令执行此操作。当前工作目录是与操作系统的登录会话相关的特性。它由 shell 可见。
我们如何以与特定操作系统无关的方式处理路径名?我们如何简化常见操作,使它们尽可能统一?
准备工作
重要的是要区分两个概念:
-
标识文件的路径
-
文件的内容
路径提供了一个可选的目录名称序列和最终的文件名。它可能通过文件扩展名提供有关文件内容的一些信息。目录包括文件名,有关文件创建时间、所有者、权限、大小以及其他详细信息。文件的内容与目录信息和名称是分开的。
通常,文件名具有后缀,可以提供有关物理格式的提示。以.csv结尾的文件可能是可以解释为数据行和列的文本文件。名称和物理格式之间的绑定并不是绝对的。文件后缀只是一个提示,可能是错误的。
文件的内容可能有多个名称。多个路径可以链接到单个文件。提供文件内容的目录条目是使用链接(ln)命令创建的。Windows 使用mklink。这被称为硬链接,因为它是名称和内容之间的低级连接。
除了硬链接,我们还可以有软链接或符号链接(或连接点)。软链接是一种不同类型的文件,链接很容易被看作是对另一个文件的引用。操作系统的 GUI 呈现可能会将这些显示为不同的图标,并称其为别名或快捷方式以使其清晰可见。
在 Python 中,pathlib模块处理所有与路径相关的处理。该模块在路径之间进行了几个区分:
-
可能或可能不引用实际文件的纯路径
-
解析并引用实际文件的具体路径
这种区别使我们能够为我们的应用程序可能创建或引用的文件创建纯路径。我们还可以为实际存在于操作系统上的文件创建具体路径。应用程序可以解析纯路径以创建具体路径。
pathlib模块还区分 Linux 路径对象和 Windows 路径对象。这种区分很少需要;大多数情况下,我们不想关心路径的操作系统级细节。使用pathlib的一个重要原因是,我们希望处理的方式与底层操作系统无关。我们可能想要使用PureLinuxPath对象的情况很少。
本节中的所有迷你配方都将利用以下内容:
**>>> from pathlib import Path**
我们很少需要pathlib中的其他类定义。
我们假设使用argparse来收集文件或目录名称。有关argparse的更多信息,请参见第五章中的使用 argparse 获取命令行输入配方,用户输入和输出。我们将使用options变量,该变量具有配方处理的input文件名或目录名。
为了演示目的,通过提供以下Namespace对象显示了模拟参数解析:
**>>> from argparse import Namespace
>>> options = Namespace(
... input='/path/to/some/file.csv',
... file1='/Users/slott/Documents/Writing/Python Cookbook/code/ch08_r09.py',
... file2='/Users/slott/Documents/Writing/Python Cookbook/code/ch08_r10.py',
... )**
这个options对象有三个模拟参数值。input值是一个纯路径:它不一定反映实际文件。file1和file2值反映了作者计算机上存在的具体路径。这个对象的行为与argparse模块创建的选项相同。
如何做...
我们将展示一些常见的路径名操作作为单独的迷你配方。这将包括以下操作:
-
从输入文件名制作输出文件名
-
制作多个兄弟输出文件
-
创建一个目录和一些文件
-
比较文件日期以查看哪个更新
-
删除一个文件
-
查找所有与给定模式匹配的文件
通过更改输入后缀来制作输出文件名
执行以下步骤,通过更改输入后缀来生成输出文件名:
- 从输入文件名字符串创建
Path对象。Path类将正确解析字符串以确定路径的元素:
**>>> input_path = Path(options.input)
>>> input_path
PosixPath('/path/to/some/file.csv')**
在这个例子中,显示了PosixPath类,因为作者使用 Mac OS X。在 Windows 机器上,该类将是WindowsPath。
- 使用
with_suffix()方法创建输出Path对象:
**>>> output_path = input_path.with_suffix('.out')
>>> output_path
PosixPath('/path/to/some/file.out')**
所有的文件名解析都由Path类无缝处理。with_suffix()方法使我们不必手动解析文件名的文本。
制作具有不同名称的多个兄弟输出文件
执行以下步骤,制作具有不同名称的多个兄弟输出文件:
- 从输入文件名字符串创建
Path对象。Path类将正确解析字符串以确定路径的元素:
**>>> input_path = Path(options.input)
>>> input_path
PosixPath('/path/to/some/file.csv')**
在这个例子中,显示了PosixPath类,因为作者使用 Linux。在 Windows 机器上,该类将是WindowsPath。
- 从文件名中提取父目录和干部。干部是没有后缀的名称:
**>>> input_directory = input_path.parent
>>> input_stem = input_path.stem**
- 构建所需的输出名称。在这个例子中,我们将在文件名后附加
_pass。输入文件file.csv将产生输出file_pass.csv:
**>>> output_stem_pass = input_stem+"_pass"
>>> output_stem_pass
'file_pass'**
- 构建完整的
Path对象:
**>>> output_path = (input_directory / output_stem_pass).with_suffix('.csv')
>>> output_path
PosixPath('/path/to/some/file_pass.csv')**
/运算符从path组件组装一个新路径。我们需要将其放在括号中,以确保它首先执行并创建一个新的Path对象。input_directory变量具有父Path对象,output_stem_pass是一个简单的字符串。使用/运算符组装新路径后,使用with_suffix()方法来确保使用特定的后缀。
创建一个目录和一些文件
以下步骤是为了创建一个目录和一些文件:
- 从输入文件名字符串创建
Path对象。Path类将正确解析字符串以确定路径的元素:
**>>> input_path = Path(options.input)
>>> input_path
PosixPath('/path/to/some/file.csv')**
在这个例子中,显示了PosixPath类,因为作者使用 Linux。在 Windows 机器上,该类将是WindowsPath。
- 为输出目录创建
Path对象。在这种情况下,我们将创建一个output目录作为与源文件相同父目录的子目录:
**>>> output_parent = input_path.parent / "output"
>>> output_parent
PosixPath('/path/to/some/output')**
- 使用输出
Path对象创建输出文件名。在这个例子中,输出目录将包含一个与输入文件同名但具有不同后缀的文件:
**>>> input_stem = input_path.stem
>>> output_path = (output_parent / input_stem).with_suffix('.src')**
我们使用/运算符从父Path和基于文件名的干部的字符串组装一个新的Path对象。创建了Path对象后,我们可以使用with_suffix()方法为文件设置所需的后缀。
比较文件日期以查看哪个更新
以下是通过比较来查看更新文件日期的步骤:
- 从输入文件名字符串创建
Path对象。Path类将正确解析字符串以确定路径的元素:
**>>> file1_path = Path(options.file1)
>>> file1_path
PosixPath('/Users/slott/Documents/Writing/Python Cookbook/code/ch08_r09.py')
>>> file2_path = Path(options.file2)
>>> file2_path
PosixPath('/Users/slott/Documents/Writing/Python Cookbook/code/ch08_r10.py')**
- 使用每个
Path对象的stat()方法获取文件的时间戳。这个方法返回一个stat对象,在stat对象中,该对象的st_mtime属性提供了文件的最近修改时间:
**>>> file1_path.stat().st_mtime
1464460057.0
>>> file2_path.stat().st_mtime
1464527877.0**
这些值是以秒为单位测量的时间戳。我们可以轻松比较这两个值,看哪个更新。
如果我们想要一个对人们有意义的时间戳,我们可以使用datetime模块从中创建一个合适的datetime对象:
**>>> import datetime
>>> mtime_1 = file1_path.stat().st_mtime
>>> datetime.datetime.fromtimestamp(mtime_1)
datetime.datetime(2016, 5, 28, 14, 27, 37)**
我们可以使用strftime()方法格式化datetime对象,或者我们可以使用isoformat()方法提供一个标准化的显示。请注意,时间将隐含地应用于操作系统时间戳的本地时区偏移;根据操作系统的配置,笔记本电脑可能不会显示与创建它的服务器相同的时间,因为它们处于不同的时区。
删除文件
删除文件的 Linux 术语是unlinking。由于文件可能有许多链接,直到所有链接都被删除,实际数据才会被删除:
- 从输入文件名字符串创建
Path对象。Path类将正确解析字符串以确定路径的元素:
**>>> input_path = Path(options.input)
>>> input_path
PosixPath('/path/to/some/file.csv')**
- 使用这个
Path对象的unlink()方法来删除目录条目。如果这是数据的最后一个目录条目,那么空间可以被操作系统回收:
**>>> try:
... input_path.unlink()
... except FileNotFoundError as ex:
... print("File already deleted")
File already deleted**
如果文件不存在,将引发FileNotFoundError。在某些情况下,这个异常需要用pass语句来消除。在其他情况下,警告消息可能很重要。也有可能缺少文件代表严重错误。
此外,我们可以使用Path对象的rename()方法重命名文件。我们可以使用symlink_to()方法创建新的软链接。要创建操作系统级别的硬链接,我们需要使用os.link()函数。
查找所有与给定模式匹配的文件
以下是查找所有与给定模式匹配的文件的步骤:
- 从输入目录名称创建
Path对象。Path类将正确解析字符串以确定路径的元素:
**>>> directory_path = Path(options.file1).parent
>>> directory_path
PosixPath('/Users/slott/Documents/Writing/Python Cookbook/code')**
- 使用
Path对象的glob()方法来定位所有与给定模式匹配的文件。默认情况下,这不会递归遍历整个目录树:
**>>> list(directory_path.glob("ch08_r*.py"))
[PosixPath('/Users/slott/Documents/Writing/Python Cookbook/code/ch08_r01.py'),
PosixPath('/Users/slott/Documents/Writing/Python Cookbook/code/ch08_r02.py'),
PosixPath('/Users/slott/Documents/Writing/Python Cookbook/code/ch08_r06.py'),
PosixPath('/Users/slott/Documents/Writing/Python Cookbook/code/ch08_r07.py'),
PosixPath('/Users/slott/Documents/Writing/Python Cookbook/code/ch08_r08.py'),
PosixPath('/Users/slott/Documents/Writing/Python Cookbook/code/ch08_r09.py'),
PosixPath('/Users/slott/Documents/Writing/Python Cookbook/code/ch08_r10.py')]**
工作原理...
在操作系统内部,路径是一系列目录(文件夹是目录的一种表示)。在诸如/Users/slott/Documents/writing的名称中,根目录/包含一个名为Users的目录。这个目录包含一个子目录slott,其中包含Documents,其中包含writing。
在某些情况下,简单的字符串表示用于总结从根目录到目标目录的导航。然而,字符串表示使许多种路径操作变成复杂的字符串解析问题。
Path类定义简化了许多纯路径上的操作。纯Path可能反映实际的文件系统资源,也可能不反映。Path上的操作包括以下示例:
-
提取父目录,以及所有封闭目录名称的序列。
-
提取最终名称、最终名称的干部和最终名称的后缀。
-
用新后缀替换后缀或用新名称替换整个名称。
-
将字符串转换为
Path。还可以将Path转换为字符串。许多操作系统函数和 Python 的部分偏好使用文件名字符串。 -
使用
/运算符从现有Path连接的字符串构建一个新的Path对象。
具体的Path表示实际的文件系统资源。对于具体的Path,我们可以对目录信息进行许多额外的操作:
-
确定这是什么类型的目录项:普通文件、目录、链接、套接字、命名管道(或 fifo)、块设备或字符设备。
-
获取目录详细信息,包括时间戳、权限、所有权、大小等。我们也可以修改这些内容。
-
我们可以取消链接(或删除)目录项。
几乎可以使用pathlib模块对文件的目录项执行任何想要的操作。少数例外情况属于os或os.path模块的一部分。
还有更多...
当我们在本章的其余部分查看其他与文件相关的示例时,我们将使用Path对象来命名文件。目标是避免尝试使用字符串来表示路径。
pathlib模块在 Linux 纯Path对象和 Windows 纯Path对象之间做了一个小区别。大多数情况下,我们不关心路径的操作系统级细节。
有两种情况可以帮助为特定操作系统生成纯路径:
-
如果我们在 Windows 笔记本电脑上进行开发,但在 Linux 服务器上部署 Web 服务,可能需要使用
PureLinuxPath。这使我们能够在 Windows 开发机器上编写测试用例,反映出在 Linux 服务器上的实际使用意图。 -
如果我们在 Mac OS X(或 Linux)笔记本电脑上进行开发,但专门部署到 Windows 服务器,可能需要使用
PureWindowsPath。
我们可能会有类似这样的东西:
**>>> from pathlib import PureWindowsPath
>>> home_path = PureWindowsPath(r'C:\Users\slott')
>>> name_path = home_path / 'filename.ini'
>>> name_path
PureWindowsPath('C:/Users/slott/filename.ini')
>>> str(name_path)
'C:\\Users\\slott\\filename.ini'**
请注意,当显示WindowsPath对象时,/字符会从 Windows 标准化为 Python 表示法。使用str()函数检索适合 Windows 操作系统的路径字符串。
如果我们尝试使用通用的Path类,我们将得到一个适合用户环境的实现,这可能不是 Windows。通过使用PureWindowsPath,我们已经绕过了映射到用户实际操作系统的过程。
另请参阅
-
在替换文件并保留上一个版本示例中,我们将看到如何利用
Path的特性创建临时文件,然后将临时文件重命名以替换原始文件 -
在第五章的使用 argparse 获取命令行输入示例中,我们将看到获取用于创建
Path对象的初始字符串的一种非常常见的方法
使用上下文管理器读写文件
许多程序将访问外部资源,如数据库连接、网络连接和操作系统文件。对于一个可靠、行为良好的程序来说,可靠而干净地释放所有外部纠缠是很重要的。
引发异常并最终崩溃的程序仍然可以正确释放资源。这包括关闭文件并确保任何缓冲数据被正确写入文件。
这对于长时间运行的服务器尤为重要。Web 服务器可能会打开和关闭许多文件。如果服务器没有正确关闭每个文件,那么数据对象可能会留在内存中,减少可用于进行网络服务的空间。工作内存的丢失看起来像是一个缓慢的泄漏。最终服务器需要重新启动,降低可用性。
我们如何确保资源被正确获取和释放?我们如何避免资源泄漏?
准备就绪
昂贵和重要资源的一个常见例子是外部文件。已经打开进行写入的文件也是宝贵的资源;毕竟,我们运行程序来创建文件形式的有用输出。Python 应用程序必须清楚地释放与文件相关的操作系统级资源。我们希望确保无论应用程序内部发生什么,缓冲区都会被刷新,文件都会被正确关闭。
当我们使用上下文管理器时,我们可以确保我们的应用程序使用的文件得到正确处理。特别是,即使在处理过程中引发异常,文件也始终会被关闭。
例如,我们将使用一个脚本来收集关于目录中文件的一些基本信息。这可以用于检测文件更改,这种技术通常用于在文件被替换时触发处理。
我们将编写一个摘要文件,其中包含文件名、修改日期、大小以及从文件中的字节计算出的校验和。然后我们可以检查目录并将其与摘要文件中的先前状态进行比较。这个函数可以准备单个文件的详细描述:
from types import SimpleNamespace
import datetime
from hashlib import md5
def file_facts(path):
return SimpleNamespace(
name = str(path),
modified = datetime.datetime.fromtimestamp(
path.stat().st_mtime).isoformat(),
size = path.stat().st_size,
checksum = md5(path.read_bytes()).hexdigest()
)
这个函数从path参数中的给定Path对象获取相对文件名。我们还可以使用resolve()方法获取绝对路径名。Path对象的stat()方法返回一些操作系统状态值。状态的st_mtime值是最后修改时间。表达式path.stat().st_mtime获取文件的修改时间。这用于创建完整的datetime对象。然后,isoformat()方法提供了修改时间的标准化显示。
path.stat().st_size的值是文件的当前大小。path.read_bytes()的值是文件中的所有字节,这些字节被传递给md5类,使用 MD5 算法创建校验和。结果md5对象的hexdigest()函数给出了一个足够敏感的值,可以检测到文件中的任何单字节更改。
我们想将这个应用到目录中的多个文件。如果目录正在被使用,例如,文件经常被写入,那么我们的分析程序在尝试读取被另一个进程写入的文件时可能会崩溃并出现 I/O 异常。
我们将使用上下文管理器来确保程序即使在罕见的崩溃情况下也能提供良好的输出。
如何做...
- 我们将使用文件路径,因此重要的是导入
Path类:
from pathlib import Path
- 创建一个标识输出文件的
Path:
summary_path = Path('summary.dat')
with语句创建file对象,并将其分配给变量summary_file。它还将这个file对象用作上下文管理器:
with summary_path.open('w') as summary_file:
现在我们可以使用summary_file变量作为输出文件。无论with语句内部引发什么异常,文件都将被正确关闭,所有操作系统资源都将被释放。
以下语句将把当前工作目录中文件的信息写入打开的摘要文件。这些语句缩进在with语句内部:
base = Path(".")
for member in base.glob("*.py"):
print(file_facts(member), file=summary_file)
这将为当前工作目录创建一个Path,并将对象保存在base变量中。Path对象的glob()方法将生成与给定模式匹配的所有文件名。之前显示的file_facts()函数将生成一个具有有用信息的命名空间对象。我们可以将每个摘要打印到summary_file。
我们省略了将事实转换为更有用的表示。如果数据以 JSON 表示法序列化,可以稍微简化后续处理。
当with语句结束时,文件将被关闭。这将发生无论是否引发了任何异常。
工作原理...
上下文管理器对象和with语句一起工作,以管理宝贵的资源。在这种情况下,文件连接是一个相对昂贵的资源,因为它将操作系统资源与我们的应用程序绑定在一起。它也很珍贵,因为它是脚本的有用输出。
当我们写with x:时,对象x是上下文管理器。上下文管理器对象响应两种方法。这两种方法是由提供的对象上的with语句调用的。重要事件如下:
-
在上下文的开始时评估
x.__enter__()。 -
在上下文结束时评估
x.__exit__(*details)。__exit__()是无论上下文中是否引发了任何异常都会被保证执行的。异常细节会提供给__exit__()方法。如果有异常,上下文管理器可能会有不同的行为。
文件对象和其他几种对象都设计为与此对象管理器协议一起使用。
以下是描述上下文管理器如何使用的事件序列:
-
评估
summary_path.open('w')以创建一个文件对象。这保存在summary_file中。 -
在上下文开始时评估
summary_file.__enter__()。 -
在
with语句上下文中进行处理。这将向给定文件写入几行。 -
在
with语句结束时,评估summary_file.__exit__()。这将关闭输出文件,并释放所有操作系统资源。 -
如果在
with语句内引发了异常并且未处理,则现在重新引发该异常,因为文件已正确关闭。
文件关闭操作由with语句自动处理。它们总是执行,即使有异常被引发。这个保证对于防止资源泄漏至关重要。
有些人喜欢争论关于“总是”这个词:他们喜欢寻找上下文管理器无法正常工作的极少数情况。例如,有可能整个 Python 运行时环境崩溃;这将使所有语言保证失效。如果 Python 上下文管理器没有正确关闭文件,操作系统将关闭文件,但最终的数据缓冲区可能会丢失。甚至有可能整个操作系统崩溃,或者硬件停止,或者在僵尸启示录期间计算机被摧毁;上下文管理器在这些情况下也不会关闭文件。
还有更多...
许多数据库连接和网络连接也可以作为上下文管理器。上下文管理器保证连接被正确关闭并释放资源。
我们也可以为输入文件使用上下文管理器。最佳实践是对所有文件操作使用上下文管理器。本章中的大多数配方都将使用文件和上下文管理器。
在罕见的情况下,我们需要为一个对象添加上下文管理能力。contextlib包括一个名为closing()的函数,它将调用对象的close()方法。
我们可以使用这个来包装一个缺乏适当上下文管理器功能的数据库连接:
from contextlib import closing
with closing(some_database()) as database:
process(database)
这假设some_database()函数创建了与数据库的连接。这种连接不能直接用作上下文管理器。通过将连接包装在closing()函数中,我们添加了必要的功能,使其成为一个适当的连接管理器对象,以确保数据库被正确关闭。
另请参阅
- 有关多个上下文的更多信息,请参阅使用多个上下文读写文件配方
替换文件同时保留先前的版本
我们可以利用pathlib的强大功能来支持各种文件名操作。在使用 pathlib 处理文件名配方中,我们看了一些管理目录、文件名和文件后缀的最常见技术。
一个常见的文件处理要求是以安全失败的方式创建输出文件。也就是说,应用程序应该保留任何先前的输出文件,无论应用程序如何失败或者在何处失败。
考虑以下情景:
-
在时间t[0],有一个有效的
output.csv文件,是昨天使用long_complex.py应用程序的结果。 -
在时间t[1],我们开始运行
long_complex.py应用程序。它开始覆盖output.csv文件。预计在时间t[3]正常完成。 -
在时间t[2],应用程序崩溃。部分
output.csv文件是无用的。更糟糕的是,从时间t[0]开始的有效文件也不可用,因为它已经被覆盖。
显然,我们可以备份文件。这引入了一个额外的处理步骤。我们可以做得更好。创建一个安全失败的文件的好方法是什么?
准备工作
安全失败的文件输出通常意味着我们不覆盖先前的文件。相反,应用程序将使用临时名称创建一个新文件。如果文件成功创建,那么可以使用重命名操作替换旧文件。
目标是以这样的方式创建文件,以便在重命名之前的任何时间点,崩溃都会保留原始文件。在重命名之后的任何时间点,新文件都已经就位并且有效。
有几种方法可以解决这个问题。我们将展示一种使用三个单独文件的变体:
-
输出文件最终将被覆盖:
output.csv。 -
文件的临时版本:
output.csv.tmp。有各种命名这个文件的约定。有时会在文件名上加上~或#等额外字符,以表示它是一个临时工作文件。有时它会在/tmp文件系统中。 -
文件的先前版本:
name.out.old。任何先前的.old文件都将在最终输出时被删除。
如何做到...
- 导入
Path类:
**>>> from pathlib import Path**
- 为了演示目的,我们将通过提供以下
Namespace对象来模拟参数解析:
**>>> from argparse import Namespace
>>> options = Namespace(
... target='/Users/slott/Documents/Writing/Python Cookbook/code/output.csv'
... )**
我们为target命令行参数提供了一个模拟值。这个options对象的行为类似于argparse模块创建的选项。
- 为所需的输出文件创建纯
Path。这个文件还不存在,这就是为什么这是一个纯路径:
**>>> output_path = Path(options.target)
>>> output_path
PosixPath('/Users/slott/Documents/Writing/Python Cookbook/code/output.csv')**
- 创建一个临时输出文件的纯
Path。这将用于创建输出:
>>> output_temp_path = output_path.with_suffix('.csv.tmp')
- 将内容写入临时文件。当然,这是应用程序的核心。通常相当复杂。对于这个例子,我们将它缩短为只写一个字面字符串:
**>>> output_temp_path.write_text("Heading1,Heading2\r\n355,113\r\n")**
注意
这里的任何失败都不会影响原始输出文件;原始文件没有被触及。
- 删除任何先前的
.old 文件:
**>>> output_old_path = output_path.with_suffix('.csv.old')
>>> try:
... output_old_path.unlink()
... except FileNotFoundError as ex:
... pass # No previous file**
注意
此时的任何失败都不会影响原始输出文件。
- 如果存在文件,将其重命名为
.old 文件:
**>>> output_path.rename(output_old_path)**
在此之后的任何失败都会保留.old文件。这个额外的文件可以作为恢复过程的一部分重命名。
- 将临时文件重命名为新的输出文件:
**>>> output_temp_path.rename(output_path)**
- 此时,文件已经被重命名临时文件覆盖。一个
.old文件将保留下来,以防需要将处理回滚到先前的状态。
它是如何工作的...
这个过程涉及三个单独的操作系统操作,一个 unlink 和两个重命名。这导致了一个情况,即.old文件需要用来恢复先前的良好状态。
这是一个显示各种文件状态的时间表。我们已经将内容标记为版本 1(先前的内容)和版本 2(修订后的内容):
| 时间 | 操作 | .csv.old | .csv | .csv.tmp |
|---|---|---|---|---|
| t [0] | 版本 0 | 版本 1 | ||
| t[1] | 写入 | 版本 0 | 版本 1 | 进行中 |
| t [2] | 关闭 | 版本 0 | 版本 1 | 版本 2 |
| t [3] | unlink .csv.old |
版本 1 | 版本 2 | |
| t[4] | 将.csv重命名为.csv.old |
版本 1 | 版本 2 | |
| t [5] | 将.csv.tmp重命名为.csv |
版本 1 | 版本 2 |
虽然存在几种失败的机会,但是关于哪个文件有效没有任何歧义:
-
如果有
.csv文件,则它是当前的有效文件 -
如果没有
.csv文件,则.csv.old文件是备份副本,可用于恢复
由于这些操作都不涉及实际复制文件,因此它们都非常快速且非常可靠。
还有更多...
在许多情况下,输出文件涉及根据时间戳可选地创建目录。 这也可以通过pathlib模块优雅地处理。 例如,我们可能有一个存档目录,我们将在其中放入旧文件:
archive_path = Path("/path/to/archive")
我们可能希望创建日期戳子目录以保存临时或工作文件:
import datetime
today = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
然后我们可以执行以下操作来定义工作目录:
working_path = archive_path / today
working_path.mkdir(parents=True, exists_ok=True)
mkdir()方法将创建预期的目录。 包括parents=True参数,以确保还将创建所有父目录。 这在首次执行应用程序时非常方便。 exists_ok=True很方便,因此可以在不引发异常的情况下重用现有目录。
parents=True不是默认值。 使用parents=False的默认值时,当父目录不存在时,应用程序将崩溃,因为所需的文件不存在。
同样,exists_ok=True不是默认值。 默认情况下,如果目录存在,则会引发FileExistsError异常。 包括使操作在目录存在时保持安静的选项。
此外,有时适合使用tempfile模块创建临时文件。 该模块可以创建保证唯一的文件名。 这允许复杂的服务器进程创建临时文件,而不考虑文件名冲突。
另请参阅
-
在使用 pathlib 处理文件名配方中,我们研究了
Path类的基本原理。 -
在第十一章中,测试,我们将研究一些编写单元测试的技术,以确保其中的部分行为正常
使用 CSV 模块读取分隔文件
常用的数据格式之一是 CSV。 我们可以很容易地将逗号视为许多候选分隔符字符之一。 我们可能有一个使用|字符作为数据列之间分隔符的 CSV 文件。 这种泛化使 CSV 文件特别强大。
我们如何处理各种各样的 CSV 格式之一的数据?
准备就绪
文件内容的摘要称为模式。 必须区分模式的两个方面:
-
文件的物理格式:对于 CSV,这意味着文件包含文本。 文本被组织成行和列。 将有一个行分隔符字符(或字符); 也将有一个列分隔符字符。 许多电子表格产品将使用
,作为列分隔符和\r\n字符序列作为行分隔符。 其他格式也是可能的,而且很容易更改分隔列和行的标点符号。 特定的标点符号组合称为 CSV 方言。 -
文件中数据的逻辑布局:这是存在的数据列的顺序。 处理 CSV 文件中的逻辑布局有几种常见情况:
-
该文件有一行标题。 这是理想的,并且与 CSV 模块的工作方式非常匹配。 最好的标题是适当的 Python 变量名。
-
文件没有标题,但列位置是固定的。 在这种情况下,我们可以在打开文件时对文件施加标题。
-
如果文件没有标题并且列位置不固定,则通常会出现严重问题。 这很难解决。 需要额外的模式信息; 例如,列定义的单独列表可以使文件可用。
-
文件有多行标题。在这种情况下,我们必须编写特殊处理来跳过这些行。我们还必须用 Python 替换复杂的标题为更有用的内容。
-
更困难的情况是文件不符合第一范式(1NF)。在 1NF 中,每行都独立于所有其他行。当文件不符合这个正常形式时,我们需要添加一个生成器函数来将数据重新排列为 1NF。参见第四章中的切片和切块列表配方,内置数据结构-列表、集合、字典,以及第八章中的使用堆叠的生成器表达式配方,功能和响应式编程特性,了解其他规范化数据结构的配方。
我们将查看一个相对简单的 CSV 文件,其中包含从帆船日志记录的实时数据。这是waypoints.csv文件。数据如下所示:
lat,lon,date,time
32.8321666666667,-79.9338333333333,2012-11-27,09:15:00
31.6714833333333,-80.93325,2012-11-28,00:00:00
30.7171666666667,-81.5525,2012-11-28,11:35:00
这些数据有四列,需要重新格式化以创建更有用的信息。
如何做...
- 导入
csv模块和Path类:
import csv
- 从
pathlib导入Path检查数据以确认以下特性:
-
列分隔符字符:
','是默认值。 -
行分隔符字符:
'\r\n'在 Windows 和 Linux 中广泛使用。这可能是 Excel 的一个特性,但非常普遍。Python 的通用换行符功能意味着 Linux 标准的'\n'将与行分隔符一样有效。 -
单行标题的存在。如果不存在,可以单独提供此信息。
- 创建标识文件的
Path对象:
data_path = Path('waypoints.csv')
- 使用
Path对象在with语句中打开文件:
with data_path.open() as data_file:
有关 with 语句的更多信息,请参阅使用上下文管理器读写文件配方。
- 从打开文件对象创建 CSV 读取器。这在
with语句内缩进:
data_reader = csv.DictReader(data_file)
- 读取(和处理)各行数据。这在
with语句内正确缩进。对于此示例,我们将只打印它们:
for row in data_reader:
print(row)
输出是一系列如下的字典:
{'date': '2012-11-27',
'lat': '32.8321666666667',
'lon': '-79.9338333333333',
'time': '09:15:00'}
由于行已转换为字典,列键不是按原始顺序排列的。如果我们使用pprint()来自pprint模块,键往往会按字母顺序排序。现在我们可以通过引用row['date']来处理数据。使用列名称比按位置引用列更具描述性:row[0]难以理解。
工作原理...
csv模块处理物理格式工作,将行与行分开,并将每行内的列分开。默认规则确保每个输入行都被视为单独的行,并且列由","分隔。
当我们需要使用列分隔符字符作为数据的一部分时会发生什么?我们可能会有这样的数据:
lan,lon,date,time,notes
32.832,-79.934,2012-11-27,09:15:00,"breezy, rainy"
31.671,-80.933,2012-11-28,00:00:00,"blowing ""like stink"""
notes列在第一行中包含了","列分隔符字符的数据。CSV 的规则允许列的值被引号括起来。默认情况下,引号字符是"。在这些引号字符内,列和行分隔符字符被忽略。
为了在带引号的字符串中嵌入引号字符,需要加倍。第二个示例行显示了当在带引号的列内使用引号字符时,值"blowing "like stink""是如何通过加倍引号字符来编码的。这些引用规则意味着 CSV 文件可以表示任何组合的字符,包括行和列分隔符字符。
CSV 文件中的值始终为字符串。像7331这样的字符串值对我们来说可能看起来像一个数字,但在csv模块处理时,它只是文本。这使处理简单而统一,但对于人类用户来说可能有些尴尬。
一些 CSV 数据是从数据库或 Web 服务器等软件导出的。这些数据往往是最容易处理的,因为各行往往是一致地组织的。
当数据从手动准备的电子表格保存时,数据可能会显示桌面软件内部数据显示规则的怪癖。例如,通常会出现一个在桌面软件上显示为日期的数据列,在 CSV 文件中却显示为简单的浮点数。
日期作为数字的问题有两种解决方案。一种是在源电子表格中添加一列,以正确格式化日期为字符串。理想情况下,这是使用 ISO 规则完成的,以便日期以 YYYY-MM-DD 格式表示。另一种解决方案是将电子表格日期识别为某个纪元日期之后的秒数。纪元日期略有不同,但通常是 1900 年 1 月 1 日或 1904 年 1 月 1 日。
还有更多...
正如我们在组合映射和减少转换配方中所看到的,通常有一个包括源数据清洗和转换的处理流水线。在这个特定的例子中,没有额外需要消除的行。然而,每一列都需要转换成更有用的东西。
为了将数据转换为更有用的形式,我们将使用两部分设计。首先,我们将定义一个行级清洗函数。在这种情况下,我们将通过添加额外的类似列的值来更新行级字典对象:
import datetime
def clean_row(source_row):
source_row['lat_n']= float(source_row['lat'])
source_row['lon_n']= float(source_row['lon'])
source_row['ts_date']= datetime.datetime.strptime(
source_row['date'],'%Y-%m-%d').date()
source_row['ts_time']= datetime.datetime.strptime(
source_row['time'],'%H:%M:%S').time()
source_row['timestamp']= datetime.datetime.combine(
source_row['ts_date'],
source_row['ts_time']
)
return source_row
我们创建了新的列值lat_n和lon_n,它们具有适当的浮点值而不是字符串。我们还解析了日期和时间值,创建了datetime.date和datetime.time对象。我们还将日期和时间合并成一个单一的有用值,即timestamp列的值。
一旦我们有了一个用于清理和丰富数据的行级函数,我们就可以将这个函数映射到数据源中的每一行。我们可以使用map(clean_row, reader),或者我们可以编写一个体现这个处理循环的函数:
def cleanse(reader):
for row in reader:
yield clean_row(row)
这可以用来从每一行提供更有用的数据:
with data_path.open() as data_file:
data_reader = csv.DictReader(data_file)
clean_data_reader = cleanse(data_reader)
for row in clean_data_reader:
pprint(row)
我们注入了cleanse()函数来创建一个非常小的转换规则堆栈。堆栈以data_reader开始,只有另一个项目。这是一个很好的开始。随着应用软件扩展到更多的计算,堆栈将扩展。
这些清洁和丰富的行如下:
{'date': '2012-11-27',
'lat': '32.8321666666667',
'lat_n': 32.8321666666667,
'lon': '-79.9338333333333',
'lon_n': -79.9338333333333,
'time': '09:15:00',
'timestamp': datetime.datetime(2012, 11, 27, 9, 15),
'ts_date': datetime.date(2012, 11, 27),
'ts_time': datetime.time(9, 15)}
我们添加了诸如lat_n和lon_n这样的列,它们具有适当的数值而不是字符串。我们还添加了timestamp,它具有完整的日期时间值,可以用于简单计算航点之间的经过时间。
另请参阅
-
有关处理管道或堆栈概念的更多信息,请参阅组合映射和减少转换配方
-
有关处理不符合 1NF 的 CSV 文件的更多信息,请参阅第四章的切片和切块列表配方,以及第八章的使用堆叠的生成器表达式配方。
使用正则表达式阅读复杂格式
许多文件格式缺乏 CSV 文件的优雅规律。一个常见的文件格式,而且相当难以解析的是 Web 服务器日志文件。这些文件往往具有复杂的数据,没有单一的分隔符字符或一致的引用规则。
当我们在第八章的使用 yield 语句编写生成器函数配方中查看简化的日志文件时,我们看到行如下:
**[2016-05-08 11:08:18,651] INFO in ch09_r09: Sample Message One
[2016-05-08 11:08:18,651] DEBUG in ch09_r09: Debugging
[2016-05-08 11:08:18,652] WARNING in ch09_r09: Something might have gone wrong**
这个文件中使用了各种标点符号。csv模块无法处理这种复杂性。
我们如何以 CSV 文件的简洁简单方式处理这种类型的数据?我们能把这些不规则的行转换成更规则的数据结构吗?
准备好
解析具有复杂结构的文件通常涉及编写一个行为有点像csv模块中的reader()函数的函数。在某些情况下,创建一个行为像DictReader类的小类可能会稍微容易一些。
读取器的核心特性是一个函数,它将把一行文本转换成一个字典或一组单独的字段值。这项工作通常可以通过re包来完成。
在我们开始之前,我们需要开发(和调试)适当解析输入文件的每一行的正则表达式。有关更多信息,请参阅第一章中的使用正则表达式解析字符串配方,数字、字符串和元组。
对于这个例子,我们将使用以下代码。我们将定义一个模式字符串,其中包含一系列用于行的各个元素的正则表达式:
**>>> import re
>>> pattern_text = (r'\[(\d+-\d+-\d+ \d+:\d+:\d+,\d+)\]'
... '\s+(\w+)'
... '\s+in'
... '\s+([\w_\.]+):'
... '\s+(.*)')
>>> pattern = re.compile(pattern_text)**
日期时间戳是各种数字、连字符、冒号和逗号;它被[和]包围。我们不得不使用\[和\]来转义正则表达式中[和]的正常含义。日期戳后面是一个严重级别,它是一系列字符的单次运行。字符in可以被忽略;没有()来捕获匹配的数据。模块名称是一系列字母字符,由字符类\w总结,还包括_和.。模块名称后面还有一个额外的:字符,也可以被忽略。最后,有一条消息延伸到行的末尾。我们用()包装了有趣的数据字符串,以便在正则表达式处理中捕获每个字符串。
请注意,我们还包括了\s+序列,以静默地跳过任意数量的类似空格的字符。看起来样本数据都使用单个空格作为分隔符。然而,当吸收空白时,使用\s+似乎是一个稍微更一般化的方法,因为它允许额外的空格。
这是这种模式的工作方式:
**>>> sample_data = '[2016-05-08 11:08:18,651] INFO in ch09_r09: Sample Message One'
>>> match = pattern.match(sample_data)
>>> match.groups()
('2016-05-08 11:08:18,651', 'INFO', 'ch09_r09', 'Sample Message One')**
我们提供了一行样本数据。匹配对象match有一个groups()方法,返回每个有趣的字段。我们可以使用(?P<name>...)来为每个捕获命名字段,而不仅仅是(...),将其转换为字典。
如何做到这一点...
这个配方有两个部分-为单行定义一个解析函数,并使用解析函数处理每行输入。
定义解析函数
为定义解析函数执行以下步骤:
- 定义编译的正则表达式对象:
import re
pattern_text = (r'\[(?P<date>\d+-\d+-\d+ \d+:\d+:\d+,\d+)\]'
'\s+(?P<level>\w+)'
'\s+in\s+(?P<module>[\w_\.]+):'
'\s+(?P<message>.*)')
pattern = re.compile(pattern_text)
我们使用了(?P<name>...)正则表达式构造来为每个捕获的组提供名称。生成的字典将与csv.DictReader的结果相同。
- 定义一个接受文本行作为参数的函数:
def log_parser(source_line):
- 应用正则表达式创建匹配对象。我们将其分配给
match变量:
match = pattern.match(source_line)
- 如果匹配对象是
None,则该行与模式不匹配。这行可能会被静默地跳过。在某些应用中,应该以某种方式记录它,以提供有用于调试或增强应用的信息。对于无法解析的输入行,提出异常也可能是有意义的:
if match is None:
raise ValueError(
"Unexpected input {0!r}".format(source_line))
- 返回一个有用的数据结构,其中包含来自此输入行的各个数据片段:
return match.groupdict()
这个函数可以用来解析每一行输入。文本被转换成一个带有字段名和值的字典。
使用解析函数
- 导入
csv模块和Path类:
import csv
- 从
pathlib导入PathCreate,标识文件的Path对象:
data_path = Path('sample.log')
- 使用
Path对象在with语句中打开文件:
with data_path.open() as data_file:
注意
有关with语句的更多信息,请参阅使用上下文管理器读写文件配方。
- 从打开的文件对象
data_file创建日志文件解析器。在这种情况下,我们将使用map()将解析器应用于源文件的每一行:
data_reader = map(log_parser, data_file)
- 读取(和处理)各行数据。在这个例子中,我们将只是打印它们:
for row in data_reader:
pprint(row)
输出是一系列如下所示的字典:
{'date': '2016-05-08 11:08:18,651',
'level': 'INFO',
'message': 'Sample Message One',
'module': 'ch09_r09'}
{'date': '2016-05-08 11:08:18,651',
'level': 'DEBUG',
'message': 'Debugging',
'module': 'ch09_r09'}
{'date': '2016-05-08 11:08:18,652',
'level': 'WARNING',
'message': 'Something might have gone wrong',
'module': 'ch09_r09'}
我们可以对这些字典进行比对原始文本行更有意义的处理。这使我们能够按严重程度级别过滤数据,或者基于提供消息的模块创建Counter。
工作原理...
这个日志文件是典型的第一正规形式文件。数据组织成代表独立实体或事件的行。每行具有一致数量的属性或列,每列的数据是原子的或不能进一步有意义地分解。与 CSV 文件不同,该格式需要复杂的正则表达式来解析。
在我们的日志文件示例中,时间戳具有许多单独的元素——年、月、日、小时、分钟、秒和毫秒,但进一步分解时间戳没有太大价值。更有帮助的是将其用作单个datetime对象,并从该对象中派生详细信息(如一天中的小时),而不是将各个字段组装成新的复合数据。
在复杂的日志处理应用程序中,可能会有几种消息字段的变体。可能需要使用单独的模式解析这些消息类型。当我们需要这样做时,它揭示了日志中的各行在格式和属性数量上不一致,打破了第一正规形式的假设之一。
在数据不一致的情况下,我们将不得不创建更复杂的解析器。这可能包括复杂的过滤规则,以分离出可能出现在 Web 服务器日志文件中的各种信息。这可能涉及解析行的一部分,以确定必须使用哪个正则表达式来解析行的其余部分。
我们一直依赖使用map()高阶函数。这将log_parse()函数应用于源文件的每一行。这种直接的简单性提供了一些保证,即创建的数据对象数量将精确匹配日志文件中的行数。
我们通常遵循使用 cvs 模块读取分隔文件配方中的设计模式,因此读取复杂日志几乎与读取简单 CSV 文件相同。事实上,我们可以看到主要区别在于一行代码:
data_reader = csv.DictReader(data_file)
与之相比:
data_reader = map(log_parser, data_file)
这种并行结构允许我们在许多输入文件格式上重用分析函数。这使我们能够创建一个可以用于许多数据源的工具库。
还有更多...
在读取非常复杂的文件时,最常见的操作之一是将其重写为更易处理的格式。我们经常希望以 CSV 格式保存数据以供以后处理。
其中一些与使用 cvs 模块读取和写入多个上下文配方类似,该配方还显示了多个打开上下文。我们将从一个文件中读取并写入另一个文件。
文件写入过程如下所示:
import csv
data_path = Path('sample.log')
target_path = data_path.with_suffix('.csv')
with target_path.open('w', newline='') as target_file:
writer = csv.DictWriter(
target_file,
['date', 'level', 'module', 'message']
)
writer.writeheader()
with data_path.open() as data_file:
reader = map(log_parser, data_file)
writer.writerows(reader)
脚本的第一部分定义了给定文件的 CSV 写入器。输出文件的路径target_path基于输入名称data_path。后缀从原始文件名的后缀更改为.csv。
该文件使用newline=''选项关闭换行符打开。这允许csv.DictWriter类插入适合所需 CSV 方言的换行符。
创建了一个DictWriter对象来写入给定文件。提供了一系列列标题。这些标题必须与用于将每行写入文件的键匹配。我们可以看到这些标题与产生数据的正则表达式的(?P<name>...)部分匹配。
writeheader()方法将列名写为输出的第一行。这使得读取文件稍微容易,因为提供了列名。CSV 文件的第一行可以是一种显式模式定义,显示了存在哪些数据。
源文件如前面的配方所示打开。由于csv模块的写入器的工作方式,我们可以将reader()生成器函数提供给写入器的writerows()方法。writerows()方法将消耗reader()函数生成的所有数据。这将反过来消耗打开文件生成的所有行。
我们不需要编写任何显式的for语句来确保处理所有输入行。writerows()函数保证了这一点。
输出文件如下:
date,level,module,message
"2016-05-08 11:08:18,651",INFO,ch09_r09,Sample Message One
"2016-05-08 11:08:18,651",DEBUG,ch09_r09,Debugging
"2016-05-08 11:08:18,652",WARNING,ch09_r09,Something might have gone wrong
该文件已从相当复杂的输入格式转换为更简单的 CSV 格式。
另请参阅
-
在第八章的使用 yield 语句编写生成器函数配方中,功能和响应式编程特性显示了此日志格式的其他处理
-
在使用 CSV 模块读取分隔文件配方中,我们将研究此通用设计模式的其他应用
-
在从 Dictreader 升级 CSV 到命名元组读取器和从 Dictreader 升级 CSV 到命名空间读取器的配方中,我们将研究更复杂的处理技术
阅读 JSON 文档
用于序列化数据的 JSON 表示法非常受欢迎。有关详细信息,请参阅json.org。Python 包括json模块,用于在此表示法中序列化和反序列化数据。
JSON 文档被 JavaScript 应用广泛使用。使用 JSON 表示法在基于 Python 的服务器和基于 JavaScript 的客户端之间交换数据是很常见的。应用程序堆栈的这两个层通过 HTTP 协议发送的 JSON 文档进行通信。有趣的是,数据持久化层也可以使用 HTTP 协议和 JSON 表示法。
我们如何在 Python 中使用json模块解析 JSON 数据?
准备工作
我们已经收集了一些帆船比赛结果,保存在race_result.json中。该文件包含有关团队、航段以及各个团队完成比赛航段的顺序的信息。
在许多情况下,当船只没有启动,没有完成,或者被取消比赛资格时,会出现空值。在这些情况下,完成位置被分配一个比最后位置多一个的分数。如果有七艘船,那么团队将得到八分。这是一个相当大的惩罚。
数据具有以下模式。整个文档内有两个字段:
-
legs:显示起始港口和目的港口的字符串数组。 -
teams:包含有关每个团队的详细信息的对象数组。在每个团队对象内部,有几个数据字段: -
name:团队名称字符串。 -
position:包含位置的整数和空值的数组。此数组中项目的顺序与 legs 数组中项目的顺序相匹配。
数据如下:
{
"teams": [
{
"name": "Abu Dhabi Ocean Racing",
"position": [
1,
3,
2,
2,
1,
2,
5,
3,
5
]
},
...
],
"legs": [
"ALICANTE - CAPE TOWN",
"CAPE TOWN - ABU DHABI",
"ABU DHABI - SANYA",
"SANYA - AUCKLAND",
"AUCKLAND - ITAJA\u00cd",
"ITAJA\u00cd - NEWPORT",
"NEWPORT - LISBON",
"LISBON - LORIENT",
"LORIENT - GOTHENBURG"
]
}
我们只显示了第一个团队。在这场比赛中总共有七个团队。
JSON 格式的数据看起来像一个包含列表的 Python 字典。Python 语法和 JSON 语法之间的重叠可以被认为是一个幸运的巧合:它使得更容易可视化从 JSON 源文档构建的 Python 数据结构。
并非所有的 JSON 结构都只是 Python 对象。有趣的是,JSON 文档中有一个空项,它映射到 Python 的None对象。含义是相似的,但语法不同。
此外,其中一个字符串包含一个 Unicode 转义序列\u00cd,而不是实际的 Unicode 字符Í。这是一种常用的技术,用于编码超出 128 个 ASCII 字符的字符。
如何做...
- 导入
json模块:
**>>> import json**
- 定义一个标识要处理的文件的
Path对象:
**>>> from pathlib import Path
>>> source_path = Path("code/race_result.json")**
json模块目前不能直接处理Path对象。因此,我们将把内容读取为一个大文本块,并处理该文本对象。
- 通过解析 JSON 文档创建 Python 对象:
**>>> document = json.loads(source_path.read_text())**
我们使用了source_path.read_text()来读取由Path命名的文件。我们将这个字符串提供给json.loads()函数进行解析。
一旦我们解析文档创建了一个 Python 字典,我们就可以看到各种部分。例如,字段teams包含了每个团队的所有结果。它是一个数组,该数组中的第 0 项是第一个团队。
每个团队的数据将是一个带有两个键name和position的字典。我们可以组合各种键来获得第一个团队的名称:
**>>> document['teams'][0]['name']
'Abu Dhabi Ocean Racing'**
我们可以查看legs字段内的每条赛道的名称:
**>>> document['legs'][5]
'ITAJAÍ - NEWPORT'**
请注意,JSON 源文件包含了'\u00cd'的 Unicode 转义序列。这被正确解析,Unicode 输出显示了正确的Í字符。
工作原理...
JSON 文档是 JavaScript 对象表示法中的数据结构。JavaScript 程序可以轻松解析文档。其他语言必须多做一些工作来将 JSON 转换为本地数据结构。
一个 JSON 文档包含三种结构:
-
映射到 Python 字典的对象:JSON 的语法类似于 Python:
{"key": "value"}。与 Python 不同,JSON 只使用"作为字符串引号。JSON 表示对字典值末尾的额外,不容忍。除此之外,这两种表示法是相似的。 -
映射到 Python 列表的数组:JSON 语法使用
[item, ...],看起来像 Python。JSON 不容忍数组值末尾的额外,。 -
基本值:有五种值:字符串,数字,
true,false和null。字符串用"括起来,并使用各种\转义序列,这与 Python 的类似。数字遵循浮点值的规则。其他三个值是简单的文字;这些与 Python 的True,False和None相对应。
没有其他类型的数据规定。这意味着 Python 程序必须将复杂的 Python 对象转换为更简单的表示,以便它们可以以 JSON 表示法进行序列化。
相反,我们经常应用额外的转换来从简化的 JSON 表示中重建复杂的 Python 对象。json模块有一些地方可以应用额外的处理来创建更复杂的 Python 对象。
还有更多...
一般来说,一个文件包含一个单独的 JSON 文档。标准没有提供一种简单的方法在单个文件中编码多个文档。例如,如果我们想要分析网站日志,JSON 可能不是保留大量信息的最佳表示法。
我们经常需要解决的另外两个问题:
-
序列化复杂对象以便将它们写入文件
-
从从文件读取的文本中反序列化复杂对象
当我们将 Python 对象的状态表示为一串文本字符时,我们已经对对象进行了序列化。许多 Python 对象需要保存在文件中或传输到另一个进程。这些传输需要对象状态的表示。我们将分别查看序列化和反序列化。
序列化复杂数据结构
我们还可以从 Python 数据结构创建 JSON 文档。因为 Python 非常复杂和灵活,我们可以轻松地创建无法在 JSON 中表示的 Python 数据结构。
如果我们创建的 Python 对象仅限于简单的dict,list,str,int,float,bool和None值,那么将其序列化为 JSON 会得到最佳结果。如果我们小心谨慎,我们可以构建快速序列化并可以被不同语言编写的多个程序广泛使用的对象。
这些类型的值都不涉及 Pythonsets或其他类定义。这意味着我们经常被迫将复杂的 Python 对象转换为字典以在 JSON 文档中表示它们。
例如,假设我们已经分析了一些数据并创建了一个结果为Counter对象:
**>>> import random
>>> random.seed(1)
>>> from collections import Counter
>>> colors = (["red"]*18)+(["black"]*18)+(["green"]*2)
>>> data = Counter(random.choice(colors) for _ in range(100))
Because this data is - effectively - a dict, we can serialie this very easily into JSON:
>>> print(json.dumps(data, sort_keys=True, indent=2))
{
"black": 53,
"green": 7,
"red": 40
}**
我们已经以 JSON 表示法转储了数据,并将键排序为顺序。这确保了一致的输出。缩进为两个将显示每个{}对象和每个[]数组在视觉上缩进,以便更容易看到文档的结构。
我们可以通过一个相对简单的操作将其写入文件:
**output_path = Path("some_path.json")
output_path.write_text(
json.dumps(data, sort_keys=True, indent=2))**
当我们重新阅读这个文档时,我们将不会从 JSON 加载操作中得到一个Counter对象。我们只会得到一个字典实例。这是 JSON 简化为非常简单值的结果。
一个常用的数据结构,不容易序列化的是datetime.datetime对象。当我们尝试时会发生什么:
**>>> import datetime
>>> example_date = datetime.datetime(2014, 6, 7, 8, 9, 10)
>>> document = {'date': example_date}**
我们创建了一个简单的文档,其中只有一个字段。字段的值是一个datetime实例。当我们尝试将其序列化为 JSON 时会发生什么?
**>>> json.dumps(document)
Traceback (most recent call last):
...
TypeError: datetime.datetime(2014, 6, 7, 8, 9, 10) is not JSON serializable**
这表明无法序列化的对象将引发TypeError异常。避免此异常可以通过两种方式之一来完成。我们可以在构建文档之前转换数据,或者我们可以向 JSON 序列化过程添加一个钩子。
一种技术是在将其序列化为 JSON 之前将datetime对象转换为字符串:
**>>> document_converted = {'date': example_date.isoformat()}
>>> json.dumps(document_converted)
'{"date": "2014-06-07T08:09:10"}'**
这使用 ISO 日期格式创建一个可以序列化的字符串。读取此数据的应用程序然后可以将字符串转换回datetime对象。
序列化复杂数据的另一种技术是提供一个在序列化期间自动使用的默认函数。这个函数必须将一个复杂对象转换为可以安全序列化的东西。通常它会创建一个具有字符串和数值的简单字典。它还可能创建一个简单的字符串值。
**>>> def default_date(object):
... if isinstance(object, datetime.datetime):
... return example_date.isoformat()
... return object**
我们定义了一个函数default_date(),它将对datetime对象应用特殊的转换规则。这些将被转换为可以由json.dumps()函数序列化的字符串对象。
我们使用default参数将此函数提供给dumps()函数,如下所示:
**>>> document = {'date': example_date}
>>> print(
... json.dumps(document, default=default_date, indent=2))
{
"date": "2014-06-07T08:09:10"
}**
在任何给定的应用程序中,我们需要扩展这个函数,以处理我们可能想要以 JSON 表示的更复杂的 Python 对象。如果有大量非常复杂的数据结构,我们通常希望有一个比精心将每个对象转换为可序列化对象更一般的解决方案。有许多设计模式可以在对象状态的序列化细节中包含类型信息。
反序列化复杂数据结构
在将 JSON 反序列化为 Python 对象时,还有另一个钩子可以用于将数据从 JSON 字典转换为更复杂的 Python 对象。这称为object_hook,它在json.loads()处理期间用于检查每个复杂对象,以查看是否应该从该字典创建其他内容。
我们提供的函数要么创建一个更复杂的 Python 对象,要么只是保持字典不变:
**>>> def as_date(object):
... if 'date' in object:
... return datetime.datetime.strptime(
... object['date'], '%Y-%m-%dT%H:%M:%S')
... return object**
这个函数将检查解码的每个对象,看看对象是否有一个名为date的字段。如果有,整个对象的值将被替换为datetime对象。
我们向json.loads()函数提供一个函数,如下所示:
**>>> source= '''{"date": "2014-06-07T08:09:10"}'''
>>> json.loads(source, object_hook=as_date)
datetime.datetime(2014, 6, 7, 8, 9, 10)**
这解析了一个非常小的 JSON 文档,符合包含日期的标准。从 JSON 序列化中找到的字符串值构建了生成的 Python 对象。
在更大的上下文中,处理日期的这个特定示例并不理想。使用单个'date'字段表示日期对象可能会导致使用as_date()函数反序列化更复杂对象时出现问题。
一个更一般的方法要么寻找一些独特的、非 Python 的东西,比如'$date'。另一个特性是确认特殊指示符是对象的唯一键。当满足这两个标准时,对象可以被特殊处理。
我们还可能希望设计我们的应用程序类,以提供额外的方法来帮助序列化。一个类可能包括一个to_json()方法,以统一的方式序列化对象。这种方法可能提供类信息。它可以避免序列化任何派生属性或计算属性。同样,我们可能需要提供一个静态的from_json()方法,用于确定给定的字典对象实际上是给定类的实例。
另请参阅
- 阅读 HTML 文档的示例将展示我们如何从 HTML 源准备这些数据
阅读 XML 文档
XML 标记语言被广泛用于组织数据。有关详细信息,请参阅www.w3.org/TR/REC-xml/。Python 包括许多用于解析 XML 文档的库。
XML 被称为标记语言,因为感兴趣的内容是用<tag>和</tag>构造标记的,这些标记定义了数据的结构。整个文件包括内容和 XML 标记文本。
因为标记与我们的文本交织在一起,所以必须使用一些额外的语法规则。为了在我们的数据中包含<字符,我们将使用 XML 字符实体引用以避免混淆。我们使用<来在文本中包含<。类似地,>代替>,&代替&,"也用于嵌入属性值中的"。
因此,文档将包含以下项目:
<team><name>Team SCA</name><position>...</position></team>
大多数 XML 处理允许在 XML 中添加额外的\n和空格字符,以使结构更加明显:
<team>
<name>Team SCA</name>
<position>...</position>
</team>
一般来说,内容被标签包围。整个文档形成了一个大的、嵌套的容器集合。从另一个角度来看,文档形成了一个树,根标签包含了所有其他标签及其嵌入的内容。在标签之间,有额外的内容完全是空白的,在这个例子中将被忽略。
使用正则表达式非常困难。我们需要更复杂的解析器来处理嵌套的语法。
有两个可用于解析 XML-SAX 和 Expat 的二进制库。Python 包括xml.sax和xml.parsers.expat来利用这两个模块。
除此之外,在xml.etree包中还有一套非常复杂的工具。我们将专注于使用ElementTree模块来解析和分析 XML 文档。
我们如何使用xml.etree模块在 Python 中解析 XML 数据?
准备工作
我们已经收集了race_result.xml中的一些帆船比赛结果。该文件包含了关于团队、赛段以及各个团队完成每个赛段的顺序的信息。
在许多情况下,当船只没有起航,没有完成比赛或被取消资格时,会出现空值。在这些情况下,得分将比船只数量多一个。如果有七艘船,那么团队将得到八分。这是一个很大的惩罚。
根标签是<results>文档。这是以下模式:
-
<legs>标签包含命名每个赛段的单独的<leg>标签。赛段名称在文本中包含起始港口和终点港口。 -
<teams>标签包含一些<team>标签,其中包含每个团队的详细信息。每个团队都有用内部标签结构化的数据: -
<name>标签包含团队名称。 -
<position>标签包含一些<leg>标签,其中包含给定赛段的完成位置。每个赛段都有编号,编号与<legs>标签中的赛段定义相匹配。
数据如下所示:
<?xml version="1.0"?>
<results>
<teams>
<team>
<name>
Abu Dhabi Ocean Racing
</name>
<position>
<leg n="1">
1
</leg>
<leg n="2">
3
</leg>
<leg n="3">
2
</leg>
<leg n="4">
2
</leg>
<leg n="5">
1
</leg>
<leg n="6">
2
</leg>
<leg n="7">
5
</leg>
<leg n="8">
3
</leg>
<leg n="9">
5
</leg>
</position>
</team>
...
</teams>
<legs>
...
</legs>
</results>
我们只展示了第一个团队。在这场比赛中总共有七个团队。
在 XML 标记中,应用程序数据显示在两种地方。在标签之间;例如,<name>阿布扎比海洋赛艇</name>。标签是<name>,在<name>和</name>之间的文本是该标签的值。
此外,数据显示为标签的属性。例如,在<leg n="1">中。标签是<leg>;标签具有一个名为n的属性,其值为1。标签可以具有无限数量的属性。
<leg>标签包括作为属性n给出的腿编号,以及作为标签内文本给出的腿的位置。一般的方法是将重要数据放在标签内,将补充或澄清数据放在属性中。两者之间的界限非常模糊。
XML 允许混合内容模型。这反映了 XML 与文本混合的情况,XML 标记内外都会有文本。以下是混合内容的示例:
<p>This has <strong>mixed</strong> content.</p>
一些文本位于<p>标签内,一些文本位于<strong>标签内。<p>标签的内容是文本和带有更多文本的标签的混合。
我们将使用xml.etree模块来解析数据。这涉及从文件中读取数据并将其提供给解析器。生成的文档将会相当复杂。
我们没有为我们的示例数据提供正式的模式定义,也没有提供文档类型定义(DTD)。这意味着 XML 默认为混合内容模式。此外,XML 结构无法根据模式或 DTD 进行验证。
如何做...
- 我们需要两个模块—
xml.etree和pathlib:
**>>> import xml.etree.ElementTree as XML
>>> from pathlib import Path**
我们已将ElementTree模块名称更改为XML,以使其更容易输入。通常也会将其重命名为类似ET的名称。
- 定义一个定位源文档的
Path对象:
**>>> source_path = Path("code/race_result.xml")**
- 通过解析源文件创建文档的内部
ElementTree版本:
**>>> source_text = source_path.read_text(encoding='UTF-8')
>>> document = XML.fromstring(source_text)**
XML 解析器不太容易使用Path对象。我们选择从Path对象中读取文本,然后解析该文本。
一旦我们有了文档,就可以搜索其中的相关数据。在这个例子中,我们将使用find()方法来定位给定标签的第一个实例:
**>>> teams = document.find('teams')
>>> name = teams.find('team').find('name')
>>> name.text.strip()
'Abu Dhabi Ocean Racing'**
在这种情况下,我们定位了<teams>标签,然后找到该列表中第一个<team>标签的实例。在<team>标签内,我们定位了第一个<name>标签,以获取团队名称的值。
因为 XML 是混合内容模型,内容中的所有\n、\t和空格字符都会被完全保留。我们很少需要这些空白字符,因此在处理有意义的内容之前和之后使用strip()方法去除所有多余的字符是有意义的。
工作原理...
XML 解析器模块将 XML 文档转换为基于文档对象模型的相当复杂的对象。在etree模块的情况下,文档将由通常表示标签和文本的Element对象构建。
XML 还包括处理指令和注释。这些通常被许多 XML 处理应用程序忽略。
XML 的解析器通常具有两个操作级别。在底层,它们识别事件。解析器找到的事件包括元素开始、元素结束、注释开始、注释结束、文本运行和类似的词法对象。在更高的级别上,这些事件用于构建文档的各种元素。
每个Element实例都有一个标签、文本、属性和尾部。标签是<tag>内的名称。属性是跟在标签名称后面的字段。例如,<leg n="1">标签的标签名称是leg,属性名为n。在 XML 中,值始终是字符串。
文本包含在标签的开始和结束之间。因此,例如<name>SCA 团队</name>这样的标签,对于代表<name>标签的Element的text属性来说是"SCA 团队"。
注意,标签还有一个尾部属性:
<name>Team SCA</name>
<position>...</position>
在</name>标签关闭后和<position>标签打开前有一个\n字符。这是<name>标签的尾部。当使用混合内容模型时,尾部值可能很重要。在非混合内容模型中,尾部值通常是空白。
还有更多...
因为我们不能简单地将 XML 文档转换为 Python 字典,所以我们需要一种方便的方法来搜索文档内容。ElementTree模块提供了一种搜索技术,这是XML 路径语言(XPath)的部分实现,用于指定 XML 文档中的位置。XPath 表示法给了我们相当大的灵活性。
XPath 查询与find()和findall()方法一起使用。以下是我们如何找到所有的名称:
**>>> for tag in document.findall('teams/team/name'):
... print(tag.text.strip())
Abu Dhabi Ocean Racing
Team Brunel
Dongfeng Race Team
MAPFRE
Team Alvimedica
Team SCA
Team Vestas Wind**
我们已经查找了顶级的<teams>标签。在该标签内,我们想要<team>标签。在这些标签内,我们想要<name>标签。这将搜索所有这种嵌套标签结构的实例。
我们也可以搜索属性值。这可以方便地找到每个队伍在比赛的特定赛段上的表现。数据位于每个队伍的<position>标签内的<leg>标签中。
此外,每个<leg>都有一个属性值 n,显示它代表比赛的哪个赛段。以下是我们如何使用这个属性从 XML 文档中提取特定数据的方法:
**>>> for tag in document.findall("teams/team/position/leg[@n='8']"):
... print(tag.text.strip())
3
5
7
4
6
1
2**
这显示了每个队伍在比赛的第 8 赛段上的完赛位置。我们正在寻找所有带有<leg n="8">的标签,并显示该标签内的文本。我们必须将这些值与队名匹配,以查看 Team SCA 在这个赛段上第一名,而东风队在这个赛段上最后一名。
另请参阅
- 阅读 HTML 文档的示例展示了我们如何从 HTML 源准备这些数据
阅读 HTML 文档
网络上有大量使用 HTML 标记的内容。浏览器可以很好地呈现数据。我们如何解析这些数据,以从显示的网页中提取有意义的内容?
我们可以使用标准库html.parser模块,但这并不是有帮助的。它只提供低级别的词法扫描信息,但并不提供描述原始网页的高级数据结构。
我们将使用 Beautiful Soup 模块来解析 HTML 页面。这可以从Python 包索引(PyPI)中获得。请参阅pypi.python.org/pypi/beautifulsoup4。
这必须下载并安装才能使用。通常情况下,pip命令可以很好地完成这项工作。
通常情况下,这很简单,就像下面这样:
**pip install beautifulsoup4**
对于 Mac OS X 和 Linux 用户,需要使用sudo命令来提升用户的权限:
**sudo pip install beautifulsoup4**
这将提示用户输入密码。用户必须能够提升自己以获得根权限。
在极少数情况下,如果您有多个版本的 Python,请确保使用匹配的 pip 版本。在某些情况下,我们可能需要使用以下内容:
**sudo pip3.5 install beautifulsoup4**
使用与 Python 3.5 配套的pip。
准备工作
我们已经收集了一些帆船赛的结果,保存在Volvo Ocean Race.html中。这个文件包含了关于队伍、赛段以及各个队伍在每个赛段中的完成顺序的信息。它是从 Volvo Ocean Race 网站上抓取的,并且在浏览器中打开时看起来很棒。
HTML 标记非常类似于 XML。内容被<tag>标记包围,显示数据的结构和呈现方式。HTML 早于 XML,XHTML 标准调和了两者。浏览器必须能够容忍旧的 HTML 甚至结构不正确的 HTML。损坏的 HTML 的存在可能会使分析来自万维网的数据变得困难。
HTML 页面包含大量的开销。通常有大量的代码和样式表部分,以及不可见的元数据。内容可能被广告和其他信息包围。一般来说,HTML 页面具有以下整体结构:
<html>
<head>...</head>
<body>...</body>
</html>
在<head>标签中将会有指向 JavaScript 库的链接,以及指向层叠样式表(CSS)文档的链接。这些通常用于提供交互功能和定义内容的呈现。
大部分内容在<body>标签中。许多网页非常繁忙,提供了一个非常复杂的内容混合。网页设计是一门复杂的艺术,内容被设计成在大多数浏览器上看起来很好。在网页上跟踪相关数据可能很困难,因为重点是人们如何看待它,而不是自动化工具如何处理它。
在这种情况下,比赛结果在 HTML 的<table>标签中,很容易找到。我们看到页面中相关内容的整体结构如下:
<table>
<thead>
<tr>
<th>...</th>
...
</tr>
</thead>
<tbody>
<tr>
<td>...</td>
...
</tr>
...
</tbody>
</table>
<thead>标签包括表格的列标题。有一个单一的表格行标签<tr>,包含表头<th>标签,其中包含内容。内容有两部分;基本显示是比赛每条腿的编号。这是标签的内容。除了显示的内容,还有一个属性值,被一个 JavaScript 函数使用。当光标悬停在列标题上时,这个属性值会显示。JavaScript 函数会弹出腿部名称。
<tbody>标签包括团队名称和每场比赛的结果。表格行(<tr>)包含每个团队的详细信息。团队名称(以及图形和总体完成排名)显示在表格数据<td>的前三列中。表格数据的其余列包含比赛每条腿的完成位置。
由于帆船比赛的相对复杂性,一些表格数据单元格中包含了额外的注释。这些被包含为属性,用于提供关于单元格值原因的补充数据。在某些情况下,团队没有开始一条腿,或者没有完成一条腿,或者退出了一条腿。
这是 HTML 中典型的<tr>行:
<tr class="ranking-item">
<td class="ranking-position">3</td>
<td class="ranking-avatar">
<img src="..."> </td>
<td class="ranking-team">Dongfeng Race Team</td>
<td class="ranking-number">2</td>
<td class="ranking-number">2</td>
<td class="ranking-number">1</td>
<td class="ranking-number">3</td>
<td class="ranking-number" tooltipster data-></td>
<td class="ranking-number">1</td>
<td class="ranking-number">4</td>
<td class="ranking-number">7</td>
<td class="ranking-number">4</td>
<td class="ranking-number total">33<span class="asterix">*</span></td>
</tr>
<tr>标签具有一个类属性,用于定义此行的样式。CSS 为这个数据类提供了样式规则。此标签上的class属性帮助我们的数据收集应用程序定位相关内容。
<td>标签也有类属性,用于定义数据单元格的样式。在这种情况下,类信息澄清了单元格内容的含义。
其中一个单元格没有内容。该单元格具有data-title属性。这被一个 JavaScript 函数用来在单元格中显示额外信息。
如何做...
- 我们需要两个模块:bs4 和 pathlib:
**>>> from bs4 import BeautifulSoup
>>> from pathlib import Path**
我们只从bs4模块中导入了BeautifulSoup类。这个类将提供解析和分析 HTML 文档所需的所有功能。
- 定义一个命名源文档的
Path对象:
**>>> source_path = Path("code/Volvo Ocean Race.html")**
- 从 HTML 内容创建 soup 结构。我们将把它分配给一个变量
soup:
**>>> with source_path.open(encoding='utf8') as source_file:
... soup = BeautifulSoup(source_file, 'html.parser')**
我们使用上下文管理器来访问文件。作为替代,我们可以简单地使用source_path.read_text(encodig='utf8')来读取内容。这与为BeautifulSoup类提供一个打开的文件一样有效。
变量soup中的 soup 结构可以被处理,以定位各种内容。例如,我们可以提取腿部细节如下:
def get_legs(soup)
legs = []
thead = soup.table.thead.tr
for tag in thead.find_all('th'):
if 'data-title' in tag.attrs:
leg_description_text = clean_leg(tag.attrs['data-title'])
legs.append(leg_description_text)
return legs
表达式soup.table.thead.tr将找到第一个<table>标签。在其中,第一个<thead>标签;在其中,第一个<tr>标签。我们将这个<tr>标签分配给一个名为thead的变量,可能会误导。然后我们可以使用findall()来定位容器内的所有<th>标签。
我们将检查每个标签的属性,以定位data-title属性的值。这将包含腿部名称信息。腿部名称内容如下:
<th tooltipster data->LEG 1</th>
data-title属性值包括值内的一些额外的 HTML 标记。这不是 HTML 的标准部分,BeautifulSoup解析器不会在属性值内查找这个 HTML。
我们有一小段 HTML 需要解析,所以我们可以创建一个小的soup对象来解析这段文本:
def clean_leg(text):
leg_soup = BeautifulSoup(text, 'html.parser')
return leg_soup.text
我们从data-title属性的值创建一个小的BeautifulSoup对象。这个 soup 将包含关于标签<strong>和文本的信息。我们使用文本属性来获取所有文本,而不包含任何标签信息。
它是如何工作的...
BeautifulSoup类将 HTML 文档转换为基于文档对象模型(DOM)的相当复杂的对象。结果结构将由Tag、NavigableString和Comment类的实例构建。
通常,我们对包含网页内容的标签感兴趣。这些是Tag和NavigableString类的对象。
每个Tag实例都有一个名称、字符串和属性。名称是<和>之间的单词。属性是跟在标签名称后面的字段。例如,<td class="ranking-number">1</td>的标签名称是td,有一个名为class的属性。值通常是字符串,但在一些情况下,值可以是字符串列表。Tag对象的字符串属性是标签包围的内容;在这种情况下,它是一个非常短的字符串1。
HTML 是一个混合内容模型。这意味着标签可以包含除可导航文本之外的子标签。文本是混合的,它可以在任何子标签内部或外部。当查看给定标签的子级时,将会有一系列标签和文本自由混合。
HTML 的最常见特性之一是包含换行字符的可导航文本小块。当我们有这样的一段代码时:
<tr>
<td>Data</td>
</tr>
<tr>标签内有三个子元素。以下是该标签的子元素的显示:
**>>> example = BeautifulSoup('''
... <tr>
... <td>data</td>
... </tr>
... ''', 'html.parser')
>>> list(example.tr.children)
['\n', <td>data</td>, '\n']**
两个换行字符是<td>标签的同级,并且被解析器保留。这是包围子标签的可导航文本。
BeautifulSoup解析器依赖于另一个更低级的过程。较低级的过程可以是内置的html.parser模块。也有其他可安装的替代方案。html.parser是最容易使用的,覆盖了最常见的用例。还有其他可用的替代方案,Beautiful Soup 文档列出了可以用来解决特定网页解析问题的其他低级解析器。
较低级的解析器识别事件;这些事件包括元素开始、元素结束、注释开始、注释结束、文本运行和类似的词法对象。在更高的层次上,这些事件用于构建 Beautiful Soup 文档的各种对象。
还有更多...
Beautiful Soup 的Tag对象表示文档结构的层次结构。标签之间有几种导航方式:
-
除了特殊的根
[document]容器,所有标签都会有一个父级。顶级<html>标签通常是根文档容器的唯一子级。 -
parents属性是一个给定标签的所有父级的生成器。这是通过层次结构到达给定标签的路径。 -
所有
Tag对象都可以有子级。一些标签,如<img/>和<hr/>没有子级。children属性是一个生成器,产生标签的子级。 -
具有子级的标签可能有多个级别的标签。例如,整个
<html>标签具有整个文档作为后代。children属性具有直接子级;descendants属性生成所有子级的子级。 -
标签也可以有兄弟标签,这些标签位于同一个容器内。由于标签有一个定义好的顺序,所以有一个
next_sibling和previous_sibling属性来帮助遍历标签的同级。
在某些情况下,文档将具有一般直观的组织结构,通过id属性或class属性的简单搜索将找到相关数据。以下是对给定结构的典型搜索:
**>>> ranking_table = soup.find('table', class_="ranking-list")**
请注意,我们必须在 Python 查询中使用class_来搜索名为class的属性。鉴于整个文档,我们正在搜索任何<table class="ranking-list">标签。这将在网页中找到第一个这样的表。由于我们知道只会有一个这样的表,这种基于属性的搜索有助于区分网页上的任何其他表格数据。
这是这个<table>标签的父级:
**>>> list(tag.name for tag in ranking_table.parents)
['section', 'div', 'div', 'div', 'div', 'body', 'html', '[document]']**
我们只显示了上面给定的<table>的每个父级标签的标签名。请注意,有四个嵌套的<div>标签包裹着包含<table>的<section>。这些<div>标签中的每一个可能都有一个不同的 class 属性,以正确定义内容和内容样式。
[document]是包含各种标签的BeautifulSoup容器。这是以独特的方式显示出来,以强调它不是一个真正的标签,而是顶级<html>标签的容器。
另请参阅
- 读取 JSON 文档和读取 XML 文档配方都使用类似的数据。示例数据是通过使用这些技术从 HTML 页面抓取而为它们创建的。
从 DictReader 升级 CSV 到命名元组读取器
当我们从 CSV 格式文件中读取数据时,对于结果数据结构有两种一般选择:
-
当我们使用
csv.reader()时,每一行都变成了一个简单的列值列表。 -
当我们使用
csv.DictReader时,每一行都变成了一个字典。默认情况下,第一行的内容成为行字典的键。另一种方法是提供一个值列表,将用作键。
在这两种情况下,引用行内的数据都很笨拙,因为它涉及相当复杂的语法。当我们使用csv读取器时,我们必须使用row[2]:这个语义完全晦涩。当我们使用DictReader时,我们可以使用row['date'],这不那么晦涩,但仍然需要大量输入。
在一些现实世界的电子表格中,列名是不可能的长字符串。很难处理row['Total of all locations excluding franchisees']。
我们可以做些什么来用更简单的东西替换复杂的语法?
准备工作
改善处理电子表格的程序的可读性的一种方法是用namedtuple对象替换列的列表。这提供了由namedtuple定义的易于使用的名称,而不是.csv文件中可能杂乱无章的列名。
更重要的是,它允许更好的语法来引用各个列。除了row[0],我们还可以使用row.date来引用名为date的列。
列名(以及每列的数据类型)是给定数据文件的模式的一部分。在一些 CSV 文件中,列标题的第一行是文件的模式。这个模式是有限的,它只提供属性名称;数据类型是未知的,必须被视为字符串处理。
这指出了在电子表格的行上强加外部模式的两个原因:
-
我们可以提供有意义的名称
-
我们可以在必要时执行数据转换
我们将查看一个相对简单的 CSV 文件,其中记录了一艘帆船的日志中的一些实时数据。这是waypoints.csv文件,数据如下:
lat,lon,date,time
32.8321666666667,-79.9338333333333,2012-11-27,09:15:00
31.6714833333333,-80.93325,2012-11-28,00:00:00
30.7171666666667,-81.5525,2012-11-28,11:35:00
数据有四列。其中两列是航点的纬度和经度。它有一个包含日期和时间的列。这并不理想,我们将分别查看各种数据清洗步骤。
在这种情况下,列标题恰好是有效的 Python 变量名。这很少见,但可能会导致略微简化。我们将在下一节中看看其他选择。
最重要的一步是将数据收集为namedtuples。
如何做...
- 导入所需的模块和定义。在这种情况下,它们将来自
collections,csv和pathlib:
from collections import namedtuple
from pathlib import Path
import csv
- 定义与实际数据匹配的
namedtuple。在这种情况下,我们称之为Waypoint并为四列数据提供名称。在这个例子中,属性恰好与列名匹配;这不是必须的:
Waypoint = namedtuple('Waypoint', ['lat', 'lon', 'date', 'time'])
- 定义引用数据的
Path对象:
waypoints_path = Path('waypoints.csv')
- 为打开的文件创建处理上下文:
with waypoints_path.open() as waypoints_file:
- 为数据定义一个 CSV 读取器。我们将其称为原始读取器。从长远来看,我们将遵循第八章中的使用堆叠的生成器表达式配方,功能和响应式编程特性和第八章中的使用一堆生成器表达式配方,功能和响应式编程特性来清理和过滤数据:
raw_reader = csv.reader(waypoints_file)
- 定义一个生成器,从输入数据的元组构建
Waypoint对象:
waypoints_reader = (Waypoint(*row) for row in raw_reader)
现在我们可以使用waypoints_reader生成器表达式来处理行:
for row in waypoints_reader:
print(row.lat, row.lon, row.date, row.time)
waypoints_reader对象还将提供标题行,我们希望忽略它。我们将在下一节讨论过滤和转换。
表达式(Waypoint(*row) for row in raw_reader)会将row元组的每个值扩展为Waypoint函数的位置参数值。这是因为 CSV 文件中的列顺序与namedtuple定义中的列顺序匹配。
这种构造也可以使用itertools模块来执行。starmap()函数可以用作starmap(Waypoint, raw_reader)。这也将使raw_reader中的每个元组扩展为Waypoint函数的位置参数。请注意,我们不能使用内置的map()函数。map()函数假定函数接受单个参数值。我们不希望每个四项row元组都被用作Waypoint函数的唯一参数。我们需要将四个项目拆分为四个位置参数值。
它是如何工作的...
这个配方有几个部分。首先,我们使用csv模块对数据的行和列进行基本解析。我们利用了使用 cvs 模块读取分隔文件配方来处理数据的物理格式。
其次,我们定义了一个namedtuple(),为我们的数据提供了一个最小的模式。这并不是非常丰富或详细。它提供了一系列列名。它还简化了访问特定列的语法。
最后,我们将csv读取器包装在一个生成器函数中,为每一行构建namedtuple对象。这对默认处理来说是一个微小的改变,但它会导致后续编程的更好风格。
现在我们可以使用row.date而不是row[2]或row['date']来引用特定的列。这是一个可以简化复杂算法呈现的小改变。
还有更多...
处理输入的初始示例存在两个额外的问题。首先,标题行与有用的数据行混在一起;这个标题行需要通过某种过滤器被拒绝。其次,数据都是字符串,需要进行一些转换。我们将通过扩展配方来解决这两个问题。
有两种常见的技术可以丢弃不需要的标题行:
- 我们可以使用显式迭代器并丢弃第一项。总体思路如下:
with waypoints_path.open() as waypoints_file:
raw_reader = csv.reader(waypoints_file)
waypoints_iter = iter(waypoints_reader)
next(waypoints_iter) # The header
for row in waypoints_iter:
print(row)
这个片段展示了如何从原始 CSV 读取器创建一个迭代器对象waypoints_iter。我们可以使用next()函数从这个读取器中跳过一个项目。剩下的项目可以用来构建有用的数据行。我们也可以使用itertools.islice()函数来实现这一点。
- 我们可以编写一个生成器或使用
filter()函数来排除选定的行:
with waypoints_path.open() as waypoints_file:
raw_reader = csv.reader(waypoints_file)
skip_header = filter(lambda row: row[0] != 'lat', raw_reader)
waypoints_reader = (Waypoint(*row) for row in skip_header)
for row in waypoints_reader:
print(row)
这个例子展示了如何从原始 CSV 读取器创建过滤生成器skip_header。过滤器使用一个简单的表达式row[0] != 'lat'来确定一行是否是标题或者有用的数据。只有有用的行通过了这个过滤器。标题行被拒绝了。
我们还需要做的另一件事是将各种数据项转换为更有用的值。我们将遵循第八章中的Simplifying complex algorithms with immutable data structures配方的例子,从原始输入数据构建一个新的namedtuple:
Waypoint_Data = namedtuple('Waypoint_Data', ['lat', 'lon', 'timestamp'])
在大多数项目的这个阶段,很明显Waypoint namedtuple的原始名称选择不当。代码需要重构以更改名称以澄清原始Waypoint元组的角色。随着设计的演变,这种重命名和重构将多次发生。根据需要重命名是很重要的。我们不会在这里进行重命名:我们将把它留给读者重新设计名称。
为了进行转换,我们需要一个处理单个Waypoint字段的函数。这将创建更有用的值。它涉及对纬度和经度值使用float()。它还需要对日期值进行一些仔细的解析。
这是处理单独的日期和时间的第一部分。这是两个 lambda 对象-只有一个单一表达式的小函数,将日期或时间字符串转换为日期或时间值:
import datetime
parse_date = lambda txt: datetime.datetime.strptime(txt, '%Y-%m-%d').date()
parse_time = lambda txt: datetime.datetime.strptime(txt, '%H:%M:%S').time()
我们可以使用这些来从原始Waypoint对象构建一个新的Waypoint_data对象:
def convert_waypoint(waypoint):
return Waypoint_Data(
lat = float(waypoint.lat),
lon = float(waypoint.lon),
timestamp = datetime.datetime.combine(
parse_date(waypoint.date),
parse_time(waypoint.time)
)
)
我们应用了一系列函数,从现有的数据结构构建了一个新的数据结构。纬度和经度值使用float()函数进行转换。日期和时间值使用parse_date和parse_time lambda 与datetime类的combine()方法转换为datetime对象。
这个函数允许我们为源数据构建一个更完整的处理步骤堆栈:
with waypoints_path.open() as waypoints_file:
raw_reader = csv.reader(waypoints_file)
skip_header = filter(lambda row: row[0] != 'lat', raw_reader)
waypoints_reader = (Waypoint(*row) for row in skip_header)
waypoints_data_reader = (convert_waypoint(wp) for wp in waypoints_reader)
for row in waypoints_data_reader:
print(row.lat, row.lon, row.timestamp)
原始读取器已经补充了一个跳过标题的过滤函数,一个用于创建Waypoint对象的生成器,以及另一个用于创建Waypoint_Data对象的生成器。在for语句的主体中,我们有一个简单易用的数据结构,具有愉快的名称。我们可以引用row.lat而不是row[0]或row['lat']。
请注意,每个生成器函数都是惰性的,它不会获取比产生一些输出所需的更多输入。这个生成器函数堆栈使用的内存很少,可以处理无限大小的文件。
参见
- 从 dict reader 升级 CSV 到 namespace reader配方使用了可变的
SimpleNamespace数据结构
从 DictReader 升级 CSV 到命名空间读取器
当我们从 CSV 格式文件中读取数据时,我们有两种一般的选择结果数据结构:
-
当我们使用
csv.reader()时,每一行都变成了一个简单的列值列表。 -
当我们使用
csv.DictReader时,每一行都变成了一个字典。默认情况下,第一行的内容成为行字典的键。我们还可以提供一个值列表,将用作键。
在这两种情况下,引用行内的数据都很笨拙,因为它涉及相当复杂的语法。当我们使用读取器时,我们必须使用row[0],这个语义完全晦涩。当我们使用DictReader时,我们可以使用row['date'],这不那么晦涩,但是要输入很多。
在一些现实世界的电子表格中,列名是不可能很长的字符串。很难使用row['Total of all locations excluding franchisees']。
我们可以用什么简单的方法来替换复杂的语法?
准备工作
列名(以及每列的数据类型)是我们数据的模式。列标题是嵌入在 CSV 数据的第一行中的模式。这个模式只提供了属性名称;数据类型是未知的,必须被视为字符串。
这指出了在电子表格的行上强加外部模式的两个原因:
-
我们可以提供有意义的名称。
-
我们可以在必要时进行数据转换。
我们还可以使用模式来定义数据质量和清洗处理。这可能变得非常复杂。我们将限制使用模式来提供列名和数据转换。
我们将查看一个相对简单的 CSV 文件,其中记录了一艘帆船日志的实时数据。这是waypoints.csv文件。数据看起来像下面这样:
lat,lon,date,time
32.8321666666667,-79.9338333333333,2012-11-27,09:15:00
31.6714833333333,-80.93325,2012-11-28,00:00:00
30.7171666666667,-81.5525,2012-11-28,11:35:00
这个电子表格有四列。其中两列是航点的纬度和经度。它有一个包含日期和时间的列。这并不理想,我们将分别查看各种数据清洗步骤。
在这种情况下,列标题是有效的 Python 变量名。这导致了处理中的一个重要简化。在没有列名或列名不是 Python 变量的情况下,我们将不得不应用从列名到首选属性名的映射。
如何做...
- 导入所需的模块和定义。在这种情况下,它将是来自
types,csv和pathlib:
from types import SimpleNamespace
from pathlib import Path
- 导入
csv并定义一个指向数据的Path对象:
waypoints_path = Path('waypoints.csv')
- 为打开的文件创建处理上下文:
with waypoints_path.open() as waypoints_file:
- 为数据定义一个 CSV 读取器。我们将其称为原始读取器。从长远来看,我们将遵循第八章中的使用堆叠的生成器表达式,功能和响应式编程特性并使用多个生成器表达式来清理和过滤数据:
raw_reader = csv.DictReader(waypoints_file)
- 定义一个生成器,将这些字典转换为
SimpleNamespace对象:
ns_reader = (SimpleNamespace(**row) for row in raw_reader)
这使用了通用的SimpleNamespace类。当我们需要使用更具体的类时,我们可以用应用程序特定的类名替换SimpleNamespace。该类的__init__必须使用与电子表格列名匹配的关键字参数。
现在我们可以从这个生成器表达式中处理行:
for row in ns_reader:
print(row.lat, row.lon, row.date, row.time)
它是如何工作的...
这个食谱有几个部分。首先,我们使用了csv模块来对数据的行和列进行基本解析。我们利用了使用 cvs 模块读取分隔文件的方法来处理数据的物理格式。CSV 格式的想法是在每一行中有逗号分隔的文本列。有规则可以使用引号来允许列内的数据包含逗号。所有这些规则都在csv模块中实现,省去了我们编写解析器的麻烦。
其次,我们将csv读取器包装在一个生成器函数中,为每一行构建一个SimpleNamespace对象。这是对默认处理的微小扩展,但可以使后续编程风格更加优雅。现在我们可以使用row.date来引用特定列,而不是row[2]或row['date']。这是一个小改变,可以简化复杂算法的呈现。
还有更多...
我们可能有两个额外的问题要解决。是否需要这些取决于数据和数据的用途:
-
我们如何处理不是合适的 Python 变量的电子表格名称?
-
我们如何将数据从文本转换为 Python 对象?
事实证明,这两个需求都可以通过一个逐行转换数据的函数来优雅处理,并且还可以处理任何必要的列重命名:
def make_row(source):
return SimpleNamespace(
lat = float(source['lat']),
lon = float(source['lon']),
timestamp = make_timestamp(source['date'], source['time']),
)
这个函数实际上是原始电子表格的模式定义。这个函数中的每一行提供了几个重要的信息:
-
SimpleNamespace中的属性名称 -
从源数据转换
-
映射到最终结果的源列名称
目标是定义任何必要的辅助或支持函数,以确保转换函数的每一行与所示的行类似。该函数的每一行都是结果列的完整规范。作为额外的好处,每一行都是用 Python 符号表示的。
这个函数可以替换ns_reader语句中的SimpleNamespace。现在所有的转换工作都集中在一个地方:
ns_reader = (make_row(row) for row in raw_reader)
这一行变换函数依赖于make_timestamp()函数。该函数将两个源列转换为一个结果为datetime对象的函数。该函数如下所示:
import datetime
make_date = lambda txt: datetime.datetime.strptime(
txt, '%Y-%m-%d').date()
make_time = lambda txt: datetime.datetime.strptime(
txt, '%H:%M:%S').time()
def make_timestamp(date, time):
return datetime.datetime.combine(
make_date(date),
make_time(time)
)
make_timestamp()函数将时间戳的创建分为三个部分。前两部分非常简单,只需要一个 lambda 对象。这些是从文本转换为datetime.date或datetime.time对象。每个转换使用strptime()方法来解析日期或时间字符串,并返回适当的对象类。
第三部分也可以是 lambda,因为它也是一个单一表达式。但是,它是一个很长的表达式,将其包装为def语句似乎更清晰一些。这个表达式使用datetime的combine()方法将日期和时间组合成一个对象。
另请参阅
- 从字典读取器升级 CSV 到命名元组读取器的方法是使用不可变的
namedtuple数据结构,而不是SimpleNamespace
使用多个上下文来读写文件
通常需要将数据从一种格式转换为另一种格式。例如,我们可能有一个复杂的网络日志,我们希望将其转换为更简单的格式。
请参阅使用正则表达式读取复杂格式食谱以了解复杂的网络日志格式。我们希望只进行一次解析。
之后,我们希望使用更简单的文件格式,更像从字典读取器升级 CSV 到命名元组读取器或从字典读取器升级 CSV 到命名空间读取器的格式。CSV 格式的文件可以使用csv模块进行读取和解析,简化物理格式的考虑。
我们如何从一种格式转换为另一种格式?
准备工作
将数据文件从一种格式转换为另一种格式意味着程序需要有两个打开的上下文:一个用于读取,一个用于写入。Python 使这变得容易。使用with语句上下文确保文件被正确关闭,并且所有相关的操作系统资源都被完全释放。
我们将研究总结许多网络日志文件的常见问题。源代码格式与第八章中使用 yield 语句编写生成器函数食谱中看到的格式相同,也与本章中使用正则表达式读取复杂格式食谱中看到的格式相同。行如下所示:
[2016-05-08 11:08:18,651] INFO in ch09_r09: Sample Message One
[2016-05-08 11:08:18,651] DEBUG in ch09_r09: Debugging
[2016-05-08 11:08:18,652] WARNING in ch09_r09: Something might have gone wrong
这些很难处理。需要复杂的正则表达式来解析它们。对于大量的数据,它也相当慢。
以下是行中各个元素的正则表达式模式:
import re
pattern_text = (r'\[(?P<date>\d+-\d+-\d+ \d+:\d+:\d+,\d+)\]'
'\s+(?P<level>\w+)'
'\s+in\s+(?P<module>[\w_\.]+):'
'\s+(?P<message>.*)')
pattern = re.compile(pattern_text)
这个复杂的正则表达式有四个部分:
-
日期时间戳用
[ ]括起来,包含各种数字、连字符、冒号和逗号。它将被捕获并通过?P<date>前缀分配名称date给()组。 -
严重级别,这是一系列字符。这是通过下一个
()组的?P<level>前缀捕获并命名为 level。 -
该模块是一个包括
_和.的字符序列。它被夹在in和:之间。被分配名称module。 -
最后,有一条消息延伸到行尾。这是通过最后一个
()内的?P<message>分配给消息的。
模式还包括空白符的运行,\s+,它们不在任何()组中捕获。它们被静默忽略。
当我们使用这个正则表达式创建一个match对象时,该match对象的groupdict()方法将生成一个包含每行名称和值的字典。这与csv读取器的工作方式相匹配。它提供了处理复杂数据的通用框架。
我们将在迭代日志数据行的函数中使用这个。该函数将应用正则表达式,并生成组字典:
def extract_row_iter(source_log_file):
for line in source_log_file:
match = log_pattern.match(line)
if match is None:
# Might want to write a warning
continue
yield match.groupdict()
这个函数查看给定输入文件中的每一行。它将正则表达式应用于该行。如果该行匹配,它将捕获相关的数据字段。如果没有匹配,该行没有遵循预期的格式;这可能值得一个错误消息。没有有用的数据可以产生,所以continue语句跳过了for语句的其余部分。
yield语句产生匹配的字典。每个字典将有四个命名字段和从日志中捕获的数据。数据将仅为文本,因此额外的转换将需要分别应用。
我们可以使用csv模块中的DictWriter类来发出一个 CSV 文件,其中这些各种数据元素被整齐地分隔。一旦我们创建了一个 CSV 文件,我们就可以简单地处理数据,比原始日志行快得多。
如何做...
- 这个食谱将需要三个组件:
import re
from pathlib import Path
import csv
- 这是匹配简单 Flask 日志的模式。对于其他类型的日志,或者配置到 Flask 中的其他格式,将需要不同的模式:
log_pattern = re.compile(
r"\[(?P<timestamp>.*?)\]"
r"\s(?P<levelname>\w+)"
r"\sin\s(?P<module>[\w\._]+):"
r"\s(?P<message>.*)")
- 这是产生匹配行的字典的函数。这应用了正则表达式模式。不匹配的行将被静默跳过。匹配将产生一个项目名称及其值的字典:
def extract_row_iter(source_log_file):
for line in source_log_file:
match = log_pattern.match(line)
if match is None: continue
yield match.groupdict()
- 我们将为生成的日志摘要文件定义
Path对象:
summary_path = Path('summary_log.csv')
- 然后我们可以打开结果上下文。因为我们使用了
with语句,所以可以确保无论在脚本中发生什么,文件都会被正确关闭:
with summary_path.open('w') as summary_file:
- 由于我们正在基于字典编写 CSV 文件,我们将定义一个
csv.DictWriter。这是在with语句内缩进了四个空格。我们必须提供输入字典中的预期键。这将定义结果文件中列的顺序:
writer = csv.DictWriter(summary_file,
['timestamp', 'levelname', 'module', 'message'])
writer.writeheader()
- 我们将为包含日志文件的源目录定义
Path对象。在这种情况下,日志文件碰巧在脚本所在的目录中。这是罕见的,使用环境变量可能会更有用:
source_log_dir = Path('.')
我们可以想象使用os.environ.get('LOG_PATH', '/var/log')作为一个比硬编码路径更一般的解决方案。
- 我们将使用
Path对象的glob()方法来查找所有与所需名称匹配的文件:
for source_log_path in source_log_dir.glob('*.log'):
这也可以从环境变量或命令行参数中获取模式字符串。
- 我们将为每个源文件定义一个读取上下文。这个上下文管理器将确保输入文件被正确关闭并释放资源。请注意,这是在前面的
with和for语句内缩进,总共有八个空格。在处理大量文件时,这一点尤为重要:
with source_log_path.open() as source_log_file:
- 我们将使用写入器的
writerows()方法来从extract_row_iter()函数中写入所有有效行。这是在两个with语句以及for语句内缩进的。这是整个过程的核心:
writer.writerows(extract_row_iter(source_log_file) )
- 我们还可以编写一个摘要。这是在外部
with和for语句内缩进的。它总结了前面的with语句的处理:
print('Converted', source_log_path, 'to', summary_path)
工作原理...
Python 与多个上下文管理器很好地配合。我们可以轻松地有深度嵌套的with语句。每个with语句可以管理不同的上下文对象。
由于打开的文件是上下文对象,将每个打开的文件包装在with语句中是最合理的,以确保文件被正确关闭并且所有操作系统资源都从文件中释放。
我们使用Path对象来表示文件系统位置。这使我们能够根据输入名称轻松创建输出名称,或在处理后重命名文件。有关更多信息,请参阅使用 pathlib 处理文件名配方。
我们使用生成器函数来组合两个操作。首先,有一个从源文本到单独字段的映射。其次,有一个排除不匹配预期模式的源文本的过滤器。在许多情况下,我们可以使用map()和filter()函数来使这一点更清晰。
然而,在使用正则表达式匹配时,要分离操作的映射和过滤部分就不那么容易了。正则表达式可能不匹配一些输入行,这就成了一种捆绑到映射中的过滤。因此,生成器函数非常有效。
csv写入器有一个writerows()方法。这个方法接受一个迭代器作为参数值。这样很容易向写入器提供一个生成器函数。写入器将消耗生成器产生的对象。这种方式可以处理非常大的文件,因为不会将整个文件读入内存,只需读取足够的文件来创建完整的数据行。
还有更多...
通常需要对从每个源文件读取的日志文件行数、因为它们不匹配而被丢弃的行数以及最终写入摘要文件的行数进行摘要计数。
在使用生成器时,这是具有挑战性的。生成器产生大量数据行。它如何产生一个摘要呢?
答案是我们可以向生成器提供一个可变对象作为参数。理想的可变对象是collections.Counter的一个实例。我们可以用它来计算包括有效记录、无效记录,甚至特定数据值的出现次数。可变对象可以被生成器和整个主程序共享,以便主程序可以将计数信息打印到日志中。
以下是将文本转换为有用的字典对象的映射-过滤函数。我们编写了一个名为counting_extract_row_iter()的第二个版本,以强调额外的特性:
def counting_extract_row_iter(counts, source_log_file):
for line in source_log_file:
match = log_pattern.match(line)
if match is None:
counts['non-match'] += 1
continue
counts['valid'] += 1
yield match.groupdict()
我们提供了一个额外的参数counts。当我们发现不匹配正则表达式的行时,我们可以增加Counter中的non-match键。当我们发现正确匹配的行时,我们可以增加Counter中的valid键。这提供了一个摘要,显示了从给定文件中处理了多少行。
整体处理脚本如下所示:
summary_path = Path('summary_log.csv')
with summary_path.open('w') as summary_file:
writer = csv.DictWriter(summary_file,
['timestamp', 'levelname', 'module', 'message'])
writer.writeheader()
source_log_dir = Path('.')
for source_log_path in source_log_dir.glob('*.log'):
counts = Counter()
with source_log_path.open() as source_log_file:
writer.writerows(
counting_extract_row_iter(counts, source_log_file)
)
print('Converted', source_log_path, 'to', summary_path)
print(counts)
我们做了三个小改动:
-
在处理源日志文件之前,创建一个空的
Counter对象。 -
将
Counter对象提供给counting_extract_row_iter()函数。该函数在处理行时会更新计数器。 -
在处理文件后打印
counter的值。这种未加修饰的输出并不太美观,但它讲述了一个重要的故事。
我们可能会看到以下输出:
**Converted 20160612.log to summary_log.csv
Counter({'valid': 86400})
Converted 20160613.log to summary_log.csv
Counter({'valid': 86399, 'non-match': 1)**
这种输出方式向我们展示了summary_log.csv的大小,也显示了20160613.log文件中出现了问题。
我们可以很容易地扩展这一点,将所有单独的源文件计数器组合起来,在处理结束时产生一个单一的大输出。我们可以使用+运算符来组合多个Counter对象,以创建所有数据的总和。具体细节留给读者作为练习。
另请参阅
- 有关上下文的基础知识,请参阅使用上下文管理器读写文件配方
第十章:统计编程和线性回归
在本章中,我们将研究以下内容:
-
使用内置的统计库
-
计数器中值的平均值
-
计算相关系数
-
计算回归参数
-
计算自相关
-
确认数据是随机的-零假设
-
定位异常值
-
一次分析多个变量
介绍
数据分析和统计处理是复杂、现代编程语言非常重要的应用。这个领域非常广泛。Python 生态系统包括许多附加包,提供了复杂的数据探索、分析和决策功能。
我们将研究一些我们可以使用 Python 内置库和数据结构进行的基本统计计算。我们将研究相关性的问题以及如何创建回归模型。
我们还将讨论随机性和零假设的问题。确保数据集中确实存在可测量的统计效应是至关重要的。如果不小心的话,我们可能会浪费大量的计算周期来分析无关紧要的噪音。
我们还将研究一种常见的优化技术。它有助于快速产生结果。一个设计不良的算法应用于非常大的数据集可能是一种无效的时间浪费。

使用内置的统计库
大量的探索性数据分析(EDA)涉及到对数据的摘要。有几种可能有趣的摘要:
-
中心趋势:诸如均值、众数和中位数等值可以描述数据集的中心。
-
极值:最小值和最大值和一些数据的中心度量一样重要。
-
方差:方差和标准差用于描述数据的分散程度。大方差意味着数据分布广泛;小方差意味着数据紧密聚集在中心值周围。
如何在 Python 中获得基本的描述性统计信息?
准备就绪
我们将研究一些可以用于统计分析的简单数据。我们得到了一个原始数据文件,名为anscombe.json。它是一个 JSON 文档,其中包含四个(x,y)对的系列。
我们可以用以下方法读取这些数据:
**>>> from pathlib import Path
>>> import json
>>> from collections import OrderedDict
>>> source_path = Path('code/anscombe.json')
>>> data = json.loads(source_path.read_text(), object_pairs_hook=OrderedDict)**
我们已经定义了数据文件的Path。然后我们可以使用Path对象来从这个文件中读取文本。json.loads()使用这个文本从 JSON 数据构建 Python 对象。
我们已经包含了一个object_pairs_hook,这样这个函数将使用OrderedDict类而不是默认的dict类来构建 JSON。这将保留源文档中项目的原始顺序。
我们可以这样检查数据:
**>>> [item['series'] for item in data]
['I', 'II', 'III', 'IV']
>>> [len(item['data']) for item in data]
[11, 11, 11, 11]**
整个 JSON 文档是一个具有I和II等键的子文档序列。每个子文档有两个字段-series和data。在data值内,有一个我们想要描述的观察值列表。每个观察值都有一对值。
数据看起来是这样的:
[
{
"series": "I",
"data": [
{
"x": 10.0,
"y": 8.04
},
{
"x": 8.0,
"y": 6.95
},
...
]
},
...
]
这是一个典型的 JSON 文档的字典列表结构。每个字典都有一个系列名称,带有series键,并且一个数据值序列,带有data键。data中的列表是一系列项目,每个项目都有一个x和一个y值。
要在这个数据结构中找到特定的系列,我们有几种选择:
- 一个
for...if...return语句序列:
**>>> def get_series(data, series_name):
for s in data:
if s['series'] == series_name:
return s**
这个for语句检查数值序列中的每个系列。系列是一个带有系列名称的键为'series'的字典。if语句将系列名称与目标名称进行比较,并返回第一个匹配项。对于未知的系列名称,这将返回None。
- 我们可以这样访问数据:
**>>> series_1 = get_series(data, 'I')
>>> series_1['series']
'I'
>>> len(series_1['data'])
11**
- 我们可以使用一个过滤器来找到所有匹配项,然后选择第一个:
**>>> def get_series(data, series_name):
... name_match = lambda series: series['series'] == series_name
... series = list(filter(name_match, data))[0]
... return series**
这个filter()函数检查值序列中的每个系列。该系列是一个带有'series'键的字典,其中包含系列名称。name_match lambda 对象将比较系列的名称键与目标名称,并返回所有匹配项。这用于构建一个list对象。如果每个键都是唯一的,第一个项目就是唯一的项目。这将为未知的系列名称引发IndexError异常。
现在我们可以这样访问数据:
**>>> series_2 = get_series(data, 'II')
>>> series_2['series']
'II'
>>> len(series_2['data'])
11**
- 我们可以使用生成器表达式,类似于过滤器,找到所有匹配项。我们从结果序列中选择第一个:
**>>> def get_series(data, series_name):
... series = list(
... s for s in data
... if s['series'] == series_name
... )[0]
... return series**
这个生成器表达式检查值序列中的每个系列。该系列是一个带有'series'键的字典,其中包含系列名称。表达式s['series'] == series_name将比较系列的名称键与目标名称,并传递所有匹配项。这用于构建一个list对象,并返回列表中的第一个项目。这将为未知的系列名称引发IndexError异常。
现在我们可以这样访问数据:
**>>> series_3 = get_series(data, 'III')
>>> series_3['series']
'III'
>>> len(series_3['data'])
11**
- 在第八章的实现“存在”处理配方中有一些这种处理的示例,功能和响应式编程特性。一旦我们从数据中选择了一个系列,我们还需要从系列中选择一个变量。这可以通过生成器函数或生成器表达式来完成:
**>>> def data_iter(series, variable_name):
... return (item[variable_name] for item in series['data'])**
系列字典具有带有数据值序列的data键。每个数据值都是一个具有两个键x和y的字典。这个“data_iter()”函数将从数据中的每个字典中选择其中一个变量。这个函数将生成一系列值,可以用于详细分析:
**>>> s_4 = get_series(data, 'IV')
>>> s_4_x = list(data_iter(s_4, 'x'))
>>> len(s_4_x)
11**
在这种情况下,我们选择了系列IV。从该系列中,我们选择了每个观察的x变量。结果列表的长度向我们展示了该系列中有 11 个观察。
如何做...
- 要计算均值和中位数,使用
statistics模块:
**>>> import statistics
>>> for series_name in 'I', 'II', 'III', 'IV':
... series = get_series(data, series_name)
... for variable_name in 'x', 'y':
... samples = list(data_iter(series, variable_name))
... mean = statistics.mean(samples)
... median = statistics.median(samples)
... print(series_name, variable_name, round(mean,2), median)
I x 9.0 9.0
I y 7.5 7.58
II x 9.0 9.0
II y 7.5 8.14
III x 9.0 9.0
III y 7.5 7.11
IV x 9.0 8.0
IV y 7.5 7.04**
这使用get_series()和data_iter()从给定系列的一个变量中选择样本值。mean()和median()函数很好地处理了这个任务。有几种可用的中位数计算变体。
- 要计算
mode,使用collections模块:
**>>> import collections
>>> for series_name in 'I', 'II', 'III', 'IV':
... series = get_series(data, series_name)
... for variable_name in 'x', 'y':
... samples = data_iter(series, variable_name)
... mode = collections.Counter(samples).most_common(1)
... print(series_name, variable_name, mode)
I x [(4.0, 1)]
I y [(8.81, 1)]
II x [(4.0, 1)]
II y [(8.74, 1)]
III x [(4.0, 1)]
III y [(8.84, 1)]
IV x [(8.0, 10)]
IV y [(7.91, 1)]**
这使用get_series()和data_iter()从给定系列的一个变量中选择样本值。Counter对象非常优雅地完成了这项工作。实际上,我们从这个操作中得到了一个完整的频率直方图。most_common()方法的结果显示了值和它出现的次数。
我们还可以使用statistics模块中的mode()函数。该函数的优点是在没有明显模式时引发异常。这的缺点是没有提供任何额外的信息来帮助定位多模态数据。
- 极值是用内置的
min()和max()函数计算的:
**>>> for series_name in 'I', 'II', 'III', 'IV':
... series = get_series(data, series_name)
... for variable_name in 'x', 'y':
... samples = list(data_iter(series, variable_name))
... least = min(samples)
... most = max(samples)
... print(series_name, variable_name, least, most)
I x 4.0 14.0
I y 4.26 10.84
II x 4.0 14.0
II y 3.1 9.26
III x 4.0 14.0
III y 5.39 12.74
IV x 8.0 19.0
IV y 5.25 12.5**
这使用get_series()和data_iter()从给定系列的一个变量中选择样本值。内置的max()和min()函数提供了极值。
- 要计算方差(和标准差),我们也可以使用
statistics模块:
**>>> import statistics
>>> for series_name in 'I', 'II', 'III', 'IV':
... series = get_series(data, series_name)
... for variable_name in 'x', 'y':
... samples = list(data_iter(series, variable_name))
... mean = statistics.mean(samples)
... variance = statistics.variance(samples, mean)
... stdev = statistics.stdev(samples, mean)
... print(series_name, variable_name,
... round(variance,2), round(stdev,2))
I x 11.0 3.32
I y 4.13 2.03
II x 11.0 3.32
II y 4.13 2.03
III x 11.0 3.32
III y 4.12 2.03
IV x 11.0 3.32
IV y 4.12 2.03**
这使用get_series()和data_iter()从给定系列的一个变量中选择样本值。统计模块提供了计算感兴趣的统计量的variance()和stdev()函数。
它是如何工作的...
这些函数通常是 Python 标准库的一等部分。我们已经在三个地方寻找了有用的函数:
-
min()和max()函数是内置的。 -
collections模块有Counter类,可以创建频率直方图。我们可以从中获取众数。 -
statistics模块有mean()、median()、mode()、variance()和stdev(),提供各种统计量。
请注意,data_iter()是一个生成器函数。我们只能使用这个生成器的结果一次。如果我们只想计算单个统计摘要值,那将非常有效。
当我们想要计算多个值时,我们需要将生成器的结果捕获到一个集合对象中。在这些示例中,我们使用data_iter()来构建一个list对象,以便我们可以多次处理它。
还有更多...
我们的原始数据结构data是一系列可变字典。每个字典有两个键——series和data。我们可以用统计摘要更新这个字典。生成的对象可以保存以供以后分析或显示。
这是这种处理的起点:
def set_mean(data):
for series in data:
for variable_name in 'x', 'y':
samples = data_iter(series, variable_name)
series['mean_'+variable_name] = statistics.mean(samples)
对于每个数据系列,我们使用data_iter()函数提取单独的样本。我们对这些样本应用mean()函数。结果保存回series对象,使用由函数名称mean、_字符和variable_name组成的字符串键。
请注意,这个函数的大部分内容都是样板代码。整体结构需要重复用于中位数、众数、最小值、最大值等。将函数从mean()更改为其他内容时,可以看到这个样板代码中有两个变化的地方:
-
用于更新系列数据的键
-
对所选样本序列进行评估的函数
我们不需要提供函数的名称;我们可以从函数对象中提取名称,如下所示:
**>>> statistics.mean.__name__
'mean'**
这意味着我们可以编写一个高阶函数,将一系列函数应用到一组样本中:
def set_summary(data, function):
for series in data:
for variable_name in 'x', 'y':
samples = data_iter(series, variable_name)
series[function.__name__+'_'+variable_name] = function(samples)
我们用参数名function替换了特定的函数mean(),这个参数名可以绑定到任何 Python 函数。处理将应用给定的函数到data_iter()的结果。然后使用这个摘要来更新系列字典,使用函数的名称、_字符和variable_name。
这个更高级的set_summary()函数看起来像这样:
for function in statistics.mean, statistics.median, min, max:
set_summary(data, function)
这将基于mean()、median()、max()和min()更新我们的文档。我们可以使用任何 Python 函数,因此除了之前显示的函数之外,还可以使用sum()等函数。
因为statistics.mode()对于没有单一众数值的情况会引发异常,所以这个函数可能需要一个try:块来捕获异常,并将一些有用的结果放入series对象中。也可能适当地允许异常传播,以通知协作函数数据是可疑的。
我们修改后的文档将如下所示:
[
{
"series": "I",
"data": [
{
"x": 10.0,
"y": 8.04
},
{
"x": 8.0,
"y": 6.95
},
...
],
"mean_x": 9.0,
"mean_y": 7.500909090909091,
"median_x": 9.0,
"median_y": 7.58,
"min_x": 4.0,
"min_y": 4.26,
"max_x": 14.0,
"max_y": 10.84
},
...
]
我们可以将其保存到文件中,并用于进一步分析。使用pathlib处理文件名,我们可以做如下操作:
target_path = source_path.parent / (source_path.stem+'_stats.json')
target_path.write_text(json.dumps(data, indent=2))
这将创建一个与源文件相邻的第二个文件。名称将与源文件具有相同的词干,但词干将扩展为字符串_stats和后缀.json。
计数器中值的平均值
statistics模块有许多有用的函数。这些函数是基于每个单独的数据样本可用于处理。然而,在某些情况下,数据已经被分组到箱中。我们可能有一个collections.Counter对象,而不是一个简单的列表。现在我们不是值,而是(值,频率)对。
如何对(值,频率)对进行统计处理?
准备就绪
均值的一般定义是所有值的总和除以值的数量。通常写成这样:

我们已经将一些数据集C定义为一系列单独的值,C = {c[0], c[1], c[2], ... ,c[n]},等等。这个集合的平均值,μ[C],是值的总和除以值的数量n。
有一个微小的变化有助于概括这个定义:


S(C)的值是值的总和。 n(C)的值是使用每个值的代替的总和。 实际上,S(C)是c[i]¹的总和,n(C)是c[i]⁰的总和。 我们可以很容易地将这些实现为简单的 Python 生成器表达式。
我们可以在许多地方重用这些定义。 具体来说,我们现在可以这样定义均值,μ [C]:
μ [C] = S(C)/ n(C)
我们将使用这个一般想法对已经收集到箱中的数据进行统计计算。 当我们有一个Counter对象时,我们有值和频率。 数据结构可以描述如下:
F = { c [0] : f [0] , c [1] : f [1] , c [2] : f [2] , ... c[m] : f[m] }
值c[i]与频率f[i]配对。 这对执行类似的计算进行了两个小的更改:


我们已经定义了
使用频率和值的乘积。 类似地,我们已经定义了
使用频率。 我们在每个名称上都包含了帽子^,以清楚地表明这些函数不适用于简单值列表; 这些函数适用于(值,频率)对的列表。
这些需要在 Python 中实现。 例如,我们将使用以下Counter对象:
**>>> from collections import Counter
>>> raw_data = [8, 8, 8, 8, 8, 8, 8, 19, 8, 8, 8]
>>> series_4_x = Counter(raw_data)**
这些数据来自使用内置统计库配方。 Counter对象如下所示:
**>>> series_4_x
Counter({8: 10, 19: 1})**
这显示了样本集中的各个值以及每个不同值的频率。
如何做...
- 定义
Counter的总和:
**>>> def counter_sum(counter):
... return sum(f*c for c,f in counter.items())**
我们可以这样使用:
**>>> counter_sum(series_4_x)
99**
- 定义
Counter中值的总数:
**>>> def counter_len(counter):**
**... return sum(f for c,f in counter.items())**
我们可以这样使用:
**>>> counter_len(series_4_x)
11**
- 我们现在可以将这些组合起来计算已经放入箱中的数据的均值:
**>>> def counter_mean(counter):
... return counter_sum(counter)/counter_len(counter)
>>> counter_mean(series_4_x)
9.0**
它是如何工作的...
Counter是一个字典。 此字典的键是实际计数的值。 字典中的值是每个项目的频率。 这意味着items()方法将生成可以被我们的计算使用的值和频率信息。
我们已经将每个定义
和
转换为生成器表达式。 因为 Python 被设计为紧密遵循数学形式主义,所以代码以相对直接的方式遵循数学。
还有更多...
要计算方差(和标准偏差),我们需要对这个主题进行两个更多的变化。 我们可以定义频率分布的总体均值,μ [F]:

其中c[i]是Counter对象F的键,f[i]是Counter对象给定键的频率值。
方差,VAR [F],可以以依赖于均值,μ [F]的方式定义。 公式如下:

这计算了值c[i]与均值μ[F]之间的差异。 这是由该值出现的次数f[i]加权的。 这些加权差的总和除以计数,
,减去一。
标准偏差,σ [F],是方差的平方根:
σ [F] = √VAR [F]
这个标准偏差的版本在数学上非常稳定,因此更受青睐。 它需要对数据进行两次传递,但对于一些边缘情况,进行多次传递的成本要好于错误的结果。
计算的另一个变化不依赖于均值,μ [F]。 这不像以前的版本那样在数学上稳定。 这种变化分别计算值的平方和,值的总和以及值的计数:


这需要额外的一次求和计算。我们需要计算值的平方和,
:
**>>> def counter_sum_2(counter):
... return sum(f*c**2 for c,f in counter.items())**
鉴于这三个求和函数,
,
,和
,我们可以定义分箱摘要的方差,F:
**>>> def counter_variance(counter):
... n = counter_len(counter)
... return (counter_sum_2(counter)-(counter_sum(counter)**2)/n)/(n-1)**
counter_variance()函数非常接近数学定义。Python 版本将 1/( n - 1)项作为次要优化移动。
使用counter_variance()函数,我们可以计算标准差:
**>>> import math
>>> def counter_stdev(counter):
... return math.sqrt(counter_variance(counter))**
这使我们能够看到以下内容:
**>>> counter_variance(series_4_x)
11.0
>>> round(counter_stdev(series_4_x), 2)
3.32**
我们还可以利用Counter对象的elements()方法。虽然简单,但这将创建一个潜在的大型中间数据结构:
**>>> import statistics
>>> statistics.variance(series_4_x.elements())
11.0**
我们已经使用Counter对象的elements()方法创建了计数器中所有元素的扩展列表。我们可以计算这些元素的统计摘要。对于一个大的Counter,这可能会成为一个非常大的中间数据结构。
另请参阅
-
在第六章的设计具有大量处理的类配方中,我们从略微不同的角度看待了这个问题。在那个配方中,我们的目标只是隐藏一个复杂的数据结构。
-
本章中的一次性分析多个变量配方将解决一些效率方面的考虑。在该配方中,我们将探讨通过数据元素的单次遍历来计算多个求和的方法。
计算相关系数
在使用内置统计库和计数器中的值的平均值配方中,我们探讨了总结数据的方法。这些配方展示了如何计算中心值,以及方差和极值。
另一个常见的统计摘要涉及两组数据之间的相关程度。这不是 Python 标准库直接支持的。
相关性的一个常用度量标准称为皮尔逊相关系数。r -值是-1 到+1 之间的数字,表示数据值之间相关的概率。
零值表示数据是随机的。0.95的值表示 95%的值相关,5%的值不相关。-.95的值表示 95%的值具有反向相关性:一个变量增加时,另一个变量减少。
我们如何确定两组数据是否相关?
准备工作
皮尔逊r的一个表达式是这样的:

这依赖于数据集各个部分的大量单独求和。每个∑ z 运算符都可以通过 Python 的sum()函数实现。
我们将使用使用内置统计库配方中的数据。我们可以用以下方法读取这些数据:
**>>> from pathlib import Path
>>> import json
>>> from collections import OrderedDict
>>> source_path = Path('code/anscombe.json')
>>> data = json.loads(source_path.read_text(),
... object_pairs_hook=OrderedDict)**
我们已经定义了数据文件的Path。然后我们可以使用Path对象从该文件中读取文本。这个文本被json.loads()用来从 JSON 数据构建 Python 对象。
我们已经包含了一个object_pairs_hook,这样这个函数将使用OrderedDict类构建 JSON,而不是默认的dict类。这将保留源文档中项目的原始顺序。
我们可以这样检查数据:
**>>> [item['series'] for item in data]
['I', 'II', 'III', 'IV']
>>> [len(item['data']) for item in data]
[11, 11, 11, 11]**
整个 JSON 文档是一个具有I等键的子文档序列。每个子文档有两个字段—series和data。在data值中有一个我们想要描述的观察值列表。每个观察值都有一对值。
数据看起来是这样的:
[
{
"series": "I",
"data": [
{
"x": 10.0,
"y": 8.04
},
{
"x": 8.0,
"y": 6.95
},
...
]
},
...
]
这组数据有四个系列,每个系列都表示为字典列表结构。在每个系列中,各个项都是具有x和y键的字典。
如何做...
- 识别所需的各种求和。对于这个表达式,我们看到以下内容:
-
∑ x[i] , y[i]
-
∑ x[i]
-
∑ y[i]
-
∑ x[i] ²
-
∑ y[i] ²
-
![How to do it...]()
计数n可以真正定义为源数据集中每个数据的总和。这也可以被认为是x[i] ^∘或y[i] ^∘。
- 从
math模块导入sqrt()函数:
from math import sqrt
- 定义一个包装计算的函数:
def correlation(data):
- 使用内置的
sum()函数编写各种总和。这是在函数定义内缩进的。我们将使用data参数的值:给定系列的一系列值。输入数据必须有两个键,x和y:
sumxy = sum(i['x']*i['y'] for i in data)
sumx = sum(i['x'] for i in data)
sumy = sum(i['y'] for i in data)
sumx2 = sum(i['x']**2 for i in data)
sumy2 = sum(i['y']**2 for i in data)
n = sum(1 for i in data)
- 根据各种总和的最终计算r。确保缩进正确匹配。有关更多帮助,请参阅第三章,函数定义:
r = (
(n*sumxy - sumx*sumy)
/ (sqrt(n*sumx2-sumx**2)*sqrt(n*sumy2-sumy**2))
)
return r
我们现在可以使用这个来确定各个系列之间的相关程度:
for series in data:
r = correlation(series['data'])
print(series['series'], 'r=', round(r, 2))
输出如下所示:
I r= 0.82
II r= 0.82
III r= 0.82
IV r= 0.82
所有四个系列的相关系数大致相同。这并不意味着这些系列彼此相关。这意味着在每个系列中,82%的x值可以预测y值。这几乎正好是每个系列中的 11 个值中的 9 个。
它是如何工作的...
总体公式看起来相当复杂。但是,它可以分解为许多单独的总和和结合这些总和的最终计算。每个总和操作都可以用 Python 非常简洁地表示。
传统上,数学表示法可能如下所示:

这在 Python 中以非常直接的方式进行翻译:
sum(item['x'] for item in data)
最终的相关系数可以简化一些。当我们用稍微更 Pythonic 的S(x)替换更复杂的
时,我们可以更好地看到方程的整体形式:

虽然简单,但所示的实现并不是最佳的。它需要对数据进行六次单独的处理,以计算各种缩减。作为一种概念验证,这种实现效果很好。这种实现的优势在于证明了编程的可行性。它还可以作为创建单元测试和重构算法以优化处理的起点。
还有更多...
该算法虽然清晰,但效率低下。更有效的版本将一次处理数据。为此,我们将不得不编写一个明确的for语句,通过数据进行一次遍历。在for语句的主体内,计算各种总和。
优化的算法看起来像这样:
sumx = sumy = sumxy = sumx2 = sumy2 = n = 0
for item in data:
x, y = item['x'], item['y']
n += 1
sumx += x
sumy += y
sumxy += x * y
sumx2 += x**2
sumy2 += y**2
我们已经将许多结果初始化为零,然后从数据项的源data中累积值到这些结果中。由于这只使用了数据值一次,所以这将适用于任何可迭代的数据源。
从这些总和计算r的算法不会改变。
重要的是初始版本的算法和已经优化为一次性计算所有摘要的修订版本之间的并行结构。两个版本的明显对称性有助于验证两件事:
-
初始实现与相当复杂的公式匹配
-
优化后的实现与初始实现和复杂的公式匹配
这种对称性结合适当的测试用例,可以确保实现是正确的。
计算回归参数
一旦我们确定了两个变量之间存在某种关系,下一步就是确定一种估计因变量的方法,以便从自变量的值中估计。对于大多数现实世界的数据,会有许多小因素导致围绕中心趋势的随机变化。我们将估计一种最小化这些误差的关系。
在最简单的情况下,变量之间的关系是线性的。当我们绘制数据点时,它们会倾向于聚集在一条直线周围。在其他情况下,我们可以通过计算对数或将变量提高到幂来调整其中一个变量,从而创建一个线性模型。在更极端的情况下,需要多项式。
我们如何计算两个变量之间的线性回归参数?
准备工作
估计线的方程式是这样的:

给定自变量x,依赖变量y的估计或预测值
是通过α和β参数计算得到的。
目标是找到α和β的值,使得估计值
和y的实际值之间的总体误差最小。这里是β的计算:
β = r[xy] (σ [x] /σ [y] )
其中r[xy]是相关系数。参见计算相关系数配方。σ [x]的定义是x的标准偏差。这个值可以直接通过statistics模块得到。
这里是α的计算:
α = μ [y] - βμ [x]
其中μ [x]是x的均值。这也可以直接通过statistics模块得到。
我们将使用使用内置统计库配方中的数据。我们可以用以下方法读取这些数据:
**>>> from pathlib import Path
>>> import json
>>> from collections import OrderedDict
>>> source_path = Path('code/anscombe.json')
>>> data = json.loads(source_path.read_text(),
... object_pairs_hook=OrderedDict)**
我们已经定义了数据文件的Path。然后我们可以使用Path对象从这个文件中读取文本。这个文本被json.loads()用来从 JSON 数据构建一个 Python 对象。
我们已经包含了一个object_pairs_hook,这样这个函数将使用OrderedDict类来构建 JSON,而不是默认的dict类。这将保留源文档中项目的原始顺序。
我们可以像下面这样检查数据:
**>>> [item['series'] for item in data]
['I', 'II', 'III', 'IV']
>>> [len(item['data']) for item in data]
[11, 11, 11, 11]**
整个 JSON 文档是一个具有诸如I之类的键的子文档序列。每个子文档有两个字段:series和data。在data值内部有一个我们想要描述的观察值列表。每个观察值都有一对值。
数据看起来是这样的:
[
{
"series": "I",
"data": [
{
"x": 10.0,
"y": 8.04
},
{
"x": 8.0,
"y": 6.95
},
...
]
},
...
]
这组数据有四个系列,每个系列都表示为一个字典结构的列表。在每个系列中,各个项目都是一个带有x和y键的字典。
如何做...
- 导入
correlation()函数和statistics模块:
from ch10_r03 import correlation
import statistics
- 定义一个将产生回归模型的函数,
regression():
def regression(data):
- 计算所需的各种值:
m_x = statistics.mean(i['x'] for i in data)
m_y = statistics.mean(i['y'] for i in data)
s_x = statistics.stdev(i['x'] for i in data)
s_y = statistics.stdev(i['y'] for i in data)
r_xy = correlation(data)
- 计算β和α的值:
b = r_xy * s_y/s_x
a = m_y - b * m_x
return a, b
我们可以使用这个regression()函数来计算回归参数,如下所示:
for series in data:
a, b = regression(series['data'])
print(series['series'], 'y=', round(a, 2), '+', round(b,2), '*x')
输出显示了一个预测给定x值的期望y的公式。输出如下:
I y= 3.0 + 0.5 *x
II y= 3.0 + 0.5 *x
III y= 3.0 + 0.5 *x
IV y= 3.0 + 0.5 *x
在所有情况下,方程式都是
。这个估计似乎是实际y值的一个相当好的预测器。
它是如何工作的...
α和β的两个目标公式并不复杂。β的公式分解为使用两个标准偏差的相关值。α的公式使用β值和两个均值。这些都是以前配方的一部分。相关性计算包含了实际的复杂性。
核心设计技术是使用尽可能多的现有特征构建新特征。这样可以使测试用例分布到基础算法上,从而广泛使用(和测试)基础算法。
计算相关系数的性能分析很重要,在这里也适用。这个过程对数据进行了五次单独的遍历,以获得相关性以及各种均值和标准偏差。
作为概念验证,这个实现证明了算法是有效的。它也作为创建单元测试的起点。有了一个有效的算法,对代码进行重构以优化处理是有意义的。
还有更多...
之前显示的算法虽然清晰,但效率低下。为了处理数据一次,我们将不得不编写一个明确的for语句,通过数据进行一次遍历。在for语句的主体内,我们需要计算各种和。我们还需要计算一些从总和中派生的值,包括平均值和标准差:
sumx = sumy = sumxy = sumx2 = sumy2 = n = 0
for item in data:
x, y = item['x'], item['y']
n += 1
sumx += x
sumy += y
sumxy += x * y
sumx2 += x**2
sumy2 += y**2
m_x = sumx / n
m_y = sumy / n
s_x = sqrt((n*sumx2 - sumx**2)/(n*(n-1)))
s_y = sqrt((n*sumy2 - sumy**2)/(n*(n-1)))
r_xy = (n*sumxy - sumx*sumy) / (sqrt(n*sumx2-sumx**2)*sqrt(n*sumy2-sumy**2))
b = r_xy * s_y/s_x
a = m_y - b * m_x
我们已将一些结果初始化为零,然后从数据项源data中累积值到这些结果中。由于这只使用了数据值一次,因此这将适用于任何可迭代的数据源。
从这些总和中计算r_xy的计算与之前的示例没有变化。α或β值的计算也没有变化,a和b。由于这些最终结果与以前版本相同,我们有信心这种优化将计算出相同的答案,但只需对数据进行一次遍历。
计算自相关
在许多情况下,事件会以重复的周期发生。如果数据与自身相关,这被称为自相关。对于一些数据,间隔可能很明显,因为有一些可见的外部影响,比如季节或潮汐。对于一些数据,间隔可能很难辨别。
在计算相关系数配方中,我们看了一种测量两组数据之间相关性的方法。
如果我们怀疑我们有循环数据,我们能否利用以前的相关函数来计算自相关?
准备工作
自相关的核心概念是通过时间偏移 T 进行相关性的想法。这种测量有时被表达为r[xx](T):x和具有时间偏移 T 的x之间的相关性。
假设我们有一个方便的相关函数,R(x,y)。它比较两个序列,[x[0],x[1],x[2],...]和[y[0],y[1],y[2],...],并返回两个序列之间的相关系数:
r[xy] = R([x[0],x[1],x[2],...],[y[0],y[1],y[2],...])
我们可以通过使用索引值作为时间偏移来将其应用于自相关:
r[xx](T)= R([x[0],x[1],x[2],...],[x[0+T],x[1+T],x[2+T],...])
我们已经计算了相互偏移 T 的x值之间的相关性。如果 T = 0,我们将每个项目与自身进行比较,相关性为r[xx](0)= 1。
我们将使用一些我们怀疑其中有季节信号的数据。这是来自www.esrl.noaa.gov/gmd/ccgg/trends/的数据。我们可以访问 ftp://ftp.cmdl.noaa.gov/ccg/co2/trends/co2_mm_mlo.txt 来下载原始数据文件。
文件有一些以#开头的前言行。这些必须从数据中过滤掉。我们将使用第八章中的 Picking a subset – three ways to filter 配方,Functional and Reactive Programming Features,它将删除无用的行。
剩下的行有七列,值之间以空格分隔。我们将使用第九章中的 Reading delimited files with the CSV module 配方,Input/Output, Physical Format, and Logical Layout,来读取 CSV 数据。在这种情况下,CSV 中的逗号将是一个空格字符。结果将有点尴尬,因此我们将使用第九章中的 Upgrading CSV from Dictreader to namespace reader 配方,Input/Output, Physical Format, and Logical Layout,创建一个更有用的命名空间,其中值已经正确转换。在该配方中,我们导入了CSV模块:
import csv
以下是处理文件物理格式基本方面的两个函数。第一个是一个过滤器,用于拒绝注释行;或者,从另一个角度来看,传递非注释行:
def non_comment_iter(source):
for line in source:
if line[0] == '#':
continue
yield line
non_comment_iter()函数将遍历给定的源并拒绝以#开头的行。所有其他行将原样传递。
non_comment_iter()函数可用于构建处理有效数据行的 CSV 读取器。读取器需要一些额外的配置来定义数据列和涉及的 CSV 方言的细节:
def raw_data_iter(source):
header = ['year', 'month', 'decimal_date', 'average',
'interpolated', 'trend', 'days']
rdr = csv.DictReader(source,
header, delimiter=' ', skipinitialspace=True)
return rdr
raw_data_iter()函数定义了七个列标题。它还指定列分隔符是空格,并且可以跳过数据每列前面的额外空格。该函数的输入必须去除注释行,通常是通过使用non_comment_iter()等过滤函数。
该函数的结果是以字典形式的数据行,具有七个键。这些行看起来像这样:
[{'average': '315.71', 'days': '-1', 'year': '1958', 'trend': '314.62',
'decimal_date': '1958.208', 'interpolated': '315.71', 'month': '3'},
{'average': '317.45', 'days': '-1', 'year': '1958', 'trend': '315.29',
'decimal_date': '1958.292', 'interpolated': '317.45', 'month': '4'},
etc.
由于所有的值都是字符串,因此需要进行一次清洗和转换。这是一个可以在生成器表达式中使用的行清洗函数。这将构建一个SimpleNamespace对象,因此我们需要导入该定义:
from types import SimpleNamespace
def cleanse(row):
return SimpleNamespace(
year= int(row['year']),
month= int(row['month']),
decimal_date= float(row['decimal_date']),
average= float(row['average']),
interpolated= float(row['interpolated']),
trend= float(row['trend']),
days= int(row['days'])
)
该函数将通过将转换函数应用于字典中的值,将每个字典行转换为SimpleNamespace。大多数项目都是浮点数,因此使用float()函数。其中一些项目是整数,对于这些项目使用int()函数。
我们可以编写以下类型的生成器表达式,将此清洗函数应用于原始数据的每一行:
cleansed_data = (cleanse(row) for row in raw_data)
这将对数据的每一行应用cleanse()函数。通常,预期是行来自raw_data_iter()。
对每一行应用cleanse()函数将创建如下所示的数据:
[namespace(average=315.71, days=-1, decimal_date=1958.208,
interpolated=315.71, month=3, trend=314.62, year=1958),
namespace(average=317.45, days=-1, decimal_date=1958.292,
interpolated=317.45, month=4, trend=315.29, year=1958),
etc.
这些数据非常容易处理。可以通过简单的名称识别各个字段,并且数据值已转换为 Python 内部数据结构。
这些函数可以组合成一个堆栈,如下所示:
def get_data(source_file):
non_comment_data = non_comment_iter(source_file)
raw_data = raw_data_iter(non_comment_data)
cleansed_data = (cleanse(row) for row in raw_data)
return cleansed_data
get_data()生成器函数是一组生成器函数和生成器表达式。它返回一个迭代器,该迭代器将产生源数据的单独行。non_comment_iter()函数将读取足够的行以便产生单个非注释行。raw_data_iter()函数将解析 CSV 的一行并产生一个包含单行数据的字典。
cleansed_data生成器表达式将对原始数据的每个字典应用cleanse()函数。单独的行是方便的SimpleNamespace数据结构,可以在其他地方使用。
该生成器将所有单独的步骤绑定到一个转换管道中。当需要更改步骤时,这将成为更改的焦点。我们可以在这里添加过滤器,或者替换解析或清洗函数。
使用get_data()函数的上下文将如下所示:
source_path = Path('co2_mm_mlo.txt')
with source_path.open() as source_file:
for row in get_data(source_file):
print(row.year, row.month, row.average)
我们需要打开一个源文件。我们可以将文件提供给get_data()函数。该函数将以易于用于统计处理的形式发出每一行。
如何做...
- 从
ch10_r03模块导入correlation()函数:
from ch10_r03 import correlation
- 从源数据中获取相关的时间序列数据项:
co2_ppm = list(row.interpolated
for row in get_data(source_file))
在这种情况下,我们将使用插值数据。如果我们尝试使用平均数据,将会有报告间隙,这将迫使我们找到没有间隙的时期。插值数据有值来填补这些间隙。
我们已经从生成器表达式创建了一个list对象,因为我们将对其进行多个摘要操作。
- 对于多个时间偏移 T,计算相关性。我们将使用从
1到20期的时间偏移。由于数据是每月收集的,我们怀疑 T = 12 将具有最高的相关性:
for tau in range(1,20):
data = [{'x':x, 'y':y}
for x,y in zip(co2_ppm[:-tau], co2_ppm[tau:])]
r_tau_0 = correlation(data[:60])
print(tau, r_tau_0)
计算相关系数配方中的correlation()函数需要一个具有两个键的小字典:x和y。第一步是构建这些字典的数组。我们使用zip()函数来组合两个数据序列:
-
co2_ppm[:-tau] -
co2_ppm[tau:]
zip()函数将从data的每个切片中组合值。第一个切片从开头开始。第二个从序列的tau位置开始。通常,第二个序列会更短,zip()函数在序列耗尽时停止处理。
我们使用co2_ppm[:-tau]作为zip()函数的一个参数值,以清楚地表明我们跳过了序列末尾的一些项目。我们跳过的项目数量与从第二个序列的开头省略的项目数量相同。
我们只取了前 60 个值来计算具有不同时间偏移值的自相关性。数据是按月提供的。我们可以看到非常强烈的年度相关性。我们已经突出显示了输出的这一行:
r_{xx}(τ= 1) = 0.862
r_{xx}(τ= 2) = 0.558
r_{xx}(τ= 3) = 0.215
r_{xx}(τ= 4) = -0.057
r_{xx}(τ= 5) = -0.235
r_{xx}(τ= 6) = -0.319
r_{xx}(τ= 7) = -0.305
r_{xx}(τ= 8) = -0.157
r_{xx}(τ= 9) = 0.141
r_{xx}(τ=10) = 0.529
r_{xx}(τ=11) = 0.857
**r_{xx}(τ=12) = 0.981**
r_{xx}(τ=13) = 0.847
r_{xx}(τ=14) = 0.531
r_{xx}(τ=15) = 0.179
r_{xx}(τ=16) = -0.100
r_{xx}(τ=17) = -0.279
r_{xx}(τ=18) = -0.363
r_{xx}(τ=19) = -0.349
当时间偏移为12时,r[xx](12) = .981。几乎任何数据子集都可以获得类似引人注目的自相关性。这种高相关性证实了数据的年度周期。
整个数据集包含了近 58 年的将近 700 个样本。事实证明,季节变化信号在整个时间跨度上并不那么明显。这意味着有另一个更长的周期信号淹没了年度变化信号。
这种其他信号的存在表明正在发生更复杂的事情。这种效应的时间尺度长于五年。需要进一步分析。
它是如何工作的...
Python 的一个优雅特性是数组切片概念。在第四章的切片和切块列表配方中,我们看了列表切片的基础知识。在进行自相关计算时,数组切片为我们提供了一个非常简单的工具,用于比较数据的两个子集。
算法的基本要素总结如下:
data = [{'x':x, 'y':y}
for x,y in zip(co2_ppm[:-tau], co2_ppm[tau:])]
这些对是从co2_ppm序列的两个切片的a zip()构建的。这两个切片构建了用于创建临时对象data的预期(x,y)对。有了这个data对象,现有的correlation()函数计算了相关度量。
还有更多...
我们可以使用类似的数组切片技术在整个数据集中反复观察 12 个月的季节循环。在这个例子中,我们使用了这个:
r_tau_0 = correlation(data[:60])
前面的代码使用了可用的 699 个样本中的前 60 个。我们可以从各个地方开始切片,并使用不同大小的切片来确认周期在整个数据中都存在。
我们可以创建一个模型,展示 12 个月的数据是如何变化的。因为有一个重复的周期,正弦函数是最有可能的模型候选。我们将使用这个进行拟合:

正弦函数本身的平均值为零,因此K因子是给定 12 个月周期的平均值。函数f(x - φ)将月数转换为在-2π ≤ f(x - φ) ≤ 2π范围内的适当值。例如,f(x) = 2π(( x -6)/12)可能是合适的。最后,缩放因子A将数据缩放以匹配给定月份的最小值和最大值。
长期模型
虽然有趣,这种分析并没有找到掩盖年度振荡的长期趋势。为了找到这种趋势,有必要将每个 12 个月的样本序列减少到一个单一的年度中心值。中位数或平均值对此都很有效。
我们可以使用以下生成器表达式创建一个月平均值序列:
from statistics import mean, median
monthly_mean = [
{'x': x, 'y': mean(co2_ppm[x:x+12])}
for x in range(0,len(co2_ppm),12)
]
该生成器将构建一系列字典。每个字典都有回归函数使用的必需的x和y项。x值是一个简单的代表年份和月份的值:它是一个从零增长到 696 的数字。y值是 12 个月份值的平均值。
回归计算如下进行:
from ch10_r04 import regression
alpha, beta = regression(monthly_mean)
print('y=', alpha, '+x*', beta)
这显示了一个明显的线,方程如下:

x值是与数据集中的第一个月(1958 年 3 月)相偏移的月数。例如,1968 年 3 月的x值为 120。年均 CO[2]浓度为y=323.1。该年的实际平均值为 323.27。可以看出,这些值非常相似。
这个相关模型的r²值,显示了方程如何拟合数据,为 0.98。这个上升的斜率是长期主导季节性波动的信号。
另请参阅
-
计算相关系数配方显示了计算一系列值之间相关性的核心函数
-
计算回归参数配方显示了确定详细回归参数的额外背景
确认数据是随机的-零假设
一个重要的统计问题被构建为关于数据集的零假设和备择假设。假设我们有两组数据,S1和S2。我们可以对数据形成两种假设:
-
零假设:任何差异都是次要的随机效应,没有显著差异。
-
备用:这些差异在统计上是显著的。一般来说,这种可能性小于 5%。
我们如何评估数据,以查看它是否真正随机,还是存在一些有意义的变化?
准备工作
如果我们在统计学方面有很强的背景,我们可以利用统计理论来评估样本的标准差,并确定两个分布之间是否存在显著差异。如果我们在统计学方面薄弱,但在编程方面有很强的背景,我们可以进行一些编码,达到类似的结果而不需要理论。
我们可以通过各种方式比较数据集,以查看它们是否存在显著不同或差异是否是随机变化。在某些情况下,我们可能能够对现象进行详细的模拟。如果我们使用 Python 内置的随机数生成器,我们将得到与真正随机的现实世界事件基本相同的数据。我们可以将模拟与测量数据进行比较,以查看它们是否相同。
模拟技术只有在模拟相对完整时才有效。例如,赌场赌博中的离散事件很容易模拟。但是,网页交易中的某些离散事件,比如购物车中的商品,很难精确模拟。
在我们无法进行模拟的情况下,我们有许多可用的重采样技术。我们可以对数据进行洗牌,使用自助法,或者使用交叉验证。在这些情况下,我们将使用可用的数据来寻找随机效应。
我们将在计算自相关配方中比较数据的三个子集。这些是来自两个相邻年份和一个与其他两个年份相隔很远的第三年的数据值。每年有 12 个样本,我们可以轻松计算这些组的平均值:
**>>> from ch10_r05 import get_data
>>> from pathlib import Path
>>> source_path = Path('code/co2_mm_mlo.txt')
>>> with source_path.open() as source_file:
... all_data = list(get_data(source_file))
>>> y1959 = [r.interpolated for r in all_data if r.year == 1959]
>>> y1960 = [r.interpolated for r in all_data if r.year == 1960]
>>> y2014 = [r.interpolated for r in all_data if r.year == 2014]**
我们已经为三年的可用数据创建了三个子集。每个子集都是使用一个简单的筛选器创建的,该筛选器创建一个数值列表,其中年份与目标值匹配。我们可以按如下方式对这些子集进行统计:
**>>> from statistics import mean
>>> round(mean(y1959), 2)
315.97
>>> round(mean(y1960), 2)
316.91
>>> round(mean(y2014), 2)
398.61**
这三个平均值是不同的。我们的假设是,1959和1960之间的差异只是普通的随机变化,没有显著性。然而,1959和2014之间的差异在统计上是显著的。
排列或洗牌技术的工作原理如下:
-
对于汇总数据的每个排列:
-
1959 年数据和 1960 年数据之间的平均差异为316.91-315.97=0.94。我们可以称之为T[obs],观察到的测试测量。
-
创建两个子集,A和B
-
计算平均值之间的差异,T
-
计算差异的数量,T,大于T[obs]和小于T[obs]的值
这两个计数告诉我们我们观察到的差异如何与所有可能的差异相比。对于大型数据集,可能存在大量的排列组合。在我们的情况下,我们知道 24 个样本中每次取 12 个的组合数由以下公式给出:

我们可以计算n=24 和k=12 的值:
**>>> from ch03_r07 import fact_s
>>> def binom(n, k):
... return fact_s(n)//(fact_s(k)*fact_s(n-k))
>>> binom(24, 12)
2704156**
有略多于 2.7 百万个排列。我们可以使用itertools模块中的函数来生成这些。combinations()函数将发出各种子集。处理需要超过 5 分钟(320 秒)。
另一个计划是使用随机子集。使用 270,156 个随机样本大约需要 35 秒。使用仅 10%的组合提供了足够准确的答案,以确定两个样本是否在统计上相似,并且零假设成立,或者两个样本是否不同。
如何做...
- 我们将使用
random和statistics模块。shuffle()函数是随机化样本的核心。我们还将使用mean()函数:
import random
from statistics import mean
我们可以简单地计算样本之间观察到的差异以上和以下的值。相反,我们将创建一个Counter并在-0.001 到+0.001 的 2,000 个步骤中收集差异。这将提供一些信心,表明差异是正态分布的:
from collections import Counter
- 定义一个接受两组独立样本的函数。这些将被合并,并从集合中随机抽取子集:
def randomized(s1, s2, limit=270415):
- 计算平均值之间的观察到的差异,T[obs]:
T_obs = mean(s2)-mean(s1)
print( "T_obs = m_2-m_1 = {:.2f}-{:.2f} = {:.2f}".format(
mean(s2), mean(s1), T_obs)
)
- 初始化一个
Counter来收集详细信息:
counts = Counter()
- 创建样本的组合宇宙。我们可以连接这两个列表:
universe = s1+s2
- 使用
for语句进行大量的重新采样;270,415 个样本可能需要 35 秒。很容易扩展或收缩子集以平衡精度和计算速度的需求。大部分处理将嵌套在这个循环内:
for resample in range(limit):
- 洗牌数据:
random.shuffle(universe)
- 选择两个与原始数据大小匹配的子集:
a = universe[:len(s2)]
b = universe[len(s2):]
由于 Python 列表索引的工作方式,我们可以确保两个列表完全分开宇宙中的值。由于第一个列表中不包括结束索引值len(s2),这种切片清楚地分隔了所有项目。
- 计算平均值之间的差异。在这种情况下,我们将通过
1000进行缩放并转换为整数,以便我们可以累积频率分布:
delta = int(1000*(mean(a) - mean(b)))
counts[delta] += 1
创建 delta 值的直方图的替代方法是计算大于T[obs]和小于T[obs]的值。使用完整的直方图提供了数据在统计上是正常的信心。
- 在
for循环之后,我们可以总结counts,显示有多少个差异大于观察到的差异,有多少个差异小于观察到的差异。如果任一值小于 5%,这是一个统计学上显著的差异:
T = int(1000*T_obs)
below = sum(v for k,v in counts.items() if k < T)
above = sum(v for k,v in counts.items() if k >= T)
print( "below {:,} {:.1%}, above {:,} {:.1%}".format(
below, below/(below+above),
above, above/(below+above)))
当我们对来自 1959 年和 1960 年的数据运行randomized()函数时,我们看到以下内容:
print("1959 v. 1960")
randomized(y1959, y1960)
输出如下所示:
1959 v. 1960
T_obs = m_2-m_1 = 316.91-315.97 = 0.93
below 239,457 88.6%, above 30,958 11.4%
这表明 11%的数据高于观察到的差异,88%的数据低于观察到的差异。这完全在正常统计噪音的范围内。
当我们对来自1959和2014的数据运行此操作时,我们看到以下输出:
1959 v. 2014
T_obs = m_2-m_1 = 398.61-315.97 = 82.64
below 270,414 100.0%, above 1 0.0%
涉及的数据只有 270,415 个样本中的一个示例高于平均值之间的观察到的差异,T[obs]。从 1959 年到 2014 年的变化在统计上是显著的,概率为 3.7 x 10^(-6)。
工作原理...
计算所有 270 万个排列可以得到确切的答案。使用随机子集而不是计算所有可能的排列更快。Python 随机数生成器非常出色,它确保随机子集将被公平分布。
我们使用了两种技术来计算数据的随机子集:
-
用
random.shuffle(u)对整个值域进行洗牌 -
用类似
a, b = u[x:], u[:x]的代码对值域进行分区
两个分区的均值是用 statistics 模块完成的。我们可以定义更有效的算法,通过数据的单次遍历来进行洗牌、分区和均值计算。这种更有效的算法将省略创建排列差异的完整直方图。
前面的算法将每个差异转换为-1000 到+1000 之间的值,使用如下:
delta = int(1000*(mean(a) - mean(b)))
这使我们能够使用 Counter 计算频率分布。这将显示大多数差异实际上是零;这是对正态分布数据的预期。看到分布可以确保我们的随机数生成和洗牌算法中没有隐藏的偏差。
我们可以简单地计算上面和下面的值,而不是填充 Counter 。这种比较排列差异和观察差异 T[obs] 的最简单形式如下:
if mean(a) - mean(b) > T_obs:
above += 1
这计算了大于观察差异的重采样差异的数量。从中,我们可以通过 below = limit-above 计算出低于观察值的数量。这将给我们一个简单的百分比值。
还有更多...
我们可以通过改变计算每个随机子集的均值的方式来进一步加快处理速度。
给定一个数字池 P ,我们创建两个不相交的子集 A 和 B ,使得:
A ∪ B = P ∧ A ∩ B = ∅
A 和 B 子集的并集覆盖了整个值域 P 。没有缺失值,因为 A 和 B 之间的交集是一个空集。
整体总和 S[p] 只需计算一次:
S[P] = ∑ P
我们只需要计算一个子集 S[A] 的总和:
S[A] = ∑ A
这意味着另一个子集的总和只是一个减法。我们不需要一个昂贵的过程来计算第二个总和。
集合的大小,N[A] 和 N[B] ,同样是恒定的。均值,μ [A] 和 μ [B] ,可以快速计算:
μ [A] = ( S[A] / N[A] )
μ [B] = ( S[P] - S[A] )/ N[B]
这导致了重采样循环的轻微变化:
a_size = len(s1)
b_size = len(s2)
s_u = sum(universe)
for resample in range(limit):
random.shuffle(universe)
a = universe[:len(s1)]
s_a = sum(a)
m_a = s_a/a_size
m_b = (s_u-s_a)/b_size
delta = int(1000*(m_a-m_b))
counts[delta] += 1
通过仅计算一个总和 s_a ,我们可以节省随机重采样过程的处理时间。我们不需要计算另一个子集的总和,因为我们可以将其计算为整个值域的总和之间的差异。然后我们可以避免使用 mean() 函数,并直接从总和和固定计数计算均值。
这种优化使得很容易迅速做出统计决策。使用重采样意味着我们不需要依赖于复杂的统计理论知识;我们可以重采样现有数据以表明给定样本符合零假设或超出预期,并需要提出一些替代假设。
另请参阅
- 这个过程可以应用于其他统计决策程序。这包括 计算回归参数 和 计算自相关 配方。
查找异常值
当我们有统计数据时,我们经常发现可以描述为异常值的数据点。异常值偏离其他样本,可能表明坏数据或新发现。异常值根据定义是罕见事件。
异常值可能是数据收集中的简单错误。它们可能代表软件错误,或者可能是测量设备未正确校准。也许日志条目无法读取是因为服务器崩溃或时间戳错误是因为用户错误输入了数据。
异常值也可能是有趣的,因为存在一些难以检测的其他信号。它可能是新颖的,或者罕见的,或者超出了我们设备的准确校准。在 Web 日志中,它可能暗示了应用程序的新用例,或者标志着新类型的黑客攻击的开始。
我们如何定位和标记潜在的异常值?
准备工作
定位异常值的一种简单方法是将值标准化以使它们成为 Z 分数。Z 分数将测量值转换为测量值与均值之间的比率,以标准差为单位:
Z[i] = ( x[i] - μ [x] )/σ [x]
其中μ [x]是给定变量x的均值,σ [x]是该变量的标准差。我们可以使用statistics模块计算这些值。
然而,这可能有些误导,因为 Z 分数受涉及的样本数量限制。因此,NIST 工程和统计手册,1.3.5.17 节,建议使用以下规则来检测异常值:
准备工作
MAD(中位数绝对偏差)代替标准差。MAD 是每个样本x[i]与总体中位数x之间的偏差的绝对值的中位数:
准备工作
使用缩放因子0.6745来缩放这些分数,以便可以将大于 3.5 的M[i]值识别为异常值。请注意,这与计算样本方差是平行的。方差测量使用均值,而这个测量使用中位数。值 0.6745 在文献中被广泛用作定位异常值的适当值。
我们将使用一些来自使用内置统计库配方的数据,其中包括一些相对平滑的数据集和一些具有严重异常值的数据集。数据位于一个 JSON 文档中,其中包含四个( x , y )对的系列。
我们可以使用以下方法读取这些数据:
**>>> from pathlib import Path
>>> import json
>>> from collections import OrderedDict
>>> source_path = Path('code/anscombe.json')
>>> data = json.loads(source_path.read_text(),
... object_pairs_hook=OrderedDict)**
我们已经定义了数据文件的Path。然后,我们可以使用Path对象从该文件中读取文本。json.loads()使用这些文本从 JSON 数据构建 Python 对象。
我们已经包含了一个object_pairs_hook,以便该函数将使用OrderedDict类构建 JSON,而不是默认的dict类。这将保留源文档中项目的原始顺序。
我们可以检查以下数据:
**>>> [item['series'] for item in data]
['I', 'II', 'III', 'IV']
>>> [len(item['data']) for item in data]
[11, 11, 11, 11]**
整个 JSON 文档是具有诸如I和II等键的子文档序列。每个子文档有两个字段:series和data。data值是我们想要描述的观测值列表。每个观测值都是一对测量值。
如何做...
- 导入
statistics模块。我们将进行许多中位数计算。此外,我们可以使用itertools的一些功能,如compress()和filterfalse()。
import statistics
import itertools
- 定义
absdev()映射。这将使用给定的中位数或计算样本的实际中位数。然后返回一个生成器,提供所有相对于中位数的绝对偏差:
def absdev(data, median=None):
if median is None:
median = statistics.median(data)
return (
abs(x-median) for x in data
)
- 定义
median_absdev()缩减。这将定位绝对偏差值序列的中位数。这计算用于检测异常值的 MAD 值。这可以计算中位数,也可以给定已计算的中位数:
def median_absdev(data, median=None):
if median is None:
median = statistics.median(data)
return statistics.median(absdev(data, median=median))
- 定义修改后的 Z 分数映射,
z_mod()。这将计算数据集的中位数,并使用它来计算 MAD。然后使用偏差值来计算基于该偏差值的修改后的 Z 分数。返回的值是修改后的 Z 分数的迭代器。由于数据需要多次通过,输入不能是可迭代集合,因此必须是序列对象:
def z_mod(data):
median = statistics.median(data)
mad = median_absdev(data, median)
return (
0.6745*(x - median)/mad for x in data
)
在这个实现中,我们使用了一个常数0.6745。在某些情况下,我们可能希望将其作为参数。我们可以使用def z_mod(data, threshold=0.6745)来允许更改这个值。
有趣的是,MAD 值为零的可能性。当大多数值不偏离中位数时,这种情况可能发生。当超过一半的点具有相同的值时,中位绝对偏差将为零。
- 基于修改后的 Z 映射
z_mod()定义异常值过滤器。任何值超过 3.5 都可以被标记为异常值。然后可以计算包括和不包括异常值的统计摘要。itertools模块有一个compress()函数,可以使用布尔选择器值的序列根据z_mod()计算的结果从原始数据序列中选择项目:
def pass_outliers(data):
return itertools.compress(data, (z >= 3.5 for z in z_mod(data)))
def reject_outliers(data):
return itertools.compress(data, (z < 3.5 for z in z_mod(data)))
pass_outliers()函数仅传递异常值。reject_outliers()函数传递非异常值。通常,我们会显示两个结果——整个数据集和拒绝异常值的整个数据集。
大多数这些函数都多次引用输入数据参数,不能使用可迭代对象。这些函数必须给定一个Sequence对象。list或tuple是Sequence的例子。
我们可以使用pass_outliers()来定位异常值。这对于识别可疑的数据值很有用。我们可以使用reject_outliers()来提供已从考虑中移除异常值的数据。
工作原理...
转换堆栈可以总结如下:
-
减少总体以计算总体中位数。
-
将每个值映射到与总体中位数的绝对偏差。
-
减少绝对偏差以创建中位绝对偏差 MAD。
-
将每个值映射到使用总体中位数和 MAD 的修改 Z 得分。
-
根据修改后的 Z 得分过滤结果。
我们分别定义了这个堆栈中的每个转换函数。我们可以使用第八章中的示例,功能和响应式编程特性,创建更小的函数,并使用内置的map()和filter()函数来实现这个过程。
我们不能轻松地使用内置的reduce()函数来定义中位数计算。为了计算中位数,我们必须使用递归中位数查找算法,将数据分成越来越小的子集,其中一个子集具有中位数值。
以下是我们如何将其应用于给定的样本数据:
for series_name in 'I', 'II', 'III', 'IV':
print(series_name)
series_data = [series['data']
for series in data
if series['series'] == series_name][0]
for variable_name in 'x', 'y':
variable = [float(item[variable_name]) for item in series_data]
print(variable_name, variable, end=' ')
try:
print( "outliers", list(pass_outliers(variable)))
except ZeroDivisionError:
print( "Data Appears Linear")
print()
我们遍历了源数据中的每个系列。series_data的计算从源数据中提取了一个系列。每个系列都有两个变量x和y。在样本集中,我们可以使用pass_outliers()函数来定位数据中的异常值。
except子句处理ZeroDivisionError异常。这个异常是由z_mod()函数对一组特别病态的数据引发的。以下是显示这些奇怪数据的输出行:
x [8.0, 8.0, 8.0, 8.0, 8.0, 8.0, 8.0, 19.0, 8.0, 8.0, 8.0] Data Appears Linear
在这种情况下,至少一半的值是相同的。这个单一的多数值将被视为中位数。对于这个子集,与中位数的绝对偏差将为零。因此,MAD 将为零。在这种情况下,异常值的概念是可疑的,因为数据似乎不反映普通的统计噪声。
这些数据不符合一般模型,必须对这个变量应用不同类型的分析。由于数据的特殊性,可能必须拒绝异常值的概念。
还有更多...
我们使用itertools.compress()来传递或拒绝异常值。我们还可以以类似的方式使用filter()和itertools.filterfalse()函数。我们将研究一些compress()的优化以及使用filter()代替compress()的方法。
我们使用了两个看起来相似的函数定义pass_outliers和reject_outliers。这种设计存在对关键程序逻辑的不必要重复,违反了 DRY 原则。以下是这两个函数:
def pass_outliers(data):
return itertools.compress(data, (z >= 3.5 for z in z_mod(data)))
def reject_outliers(data):
return itertools.compress(data, (z < 3.5 for z in z_mod(data)))
pass_outliers()和reject_outliers()之间的区别很小,只是表达式的逻辑否定。一个版本中有>=,另一个版本中有<。这种代码差异并不总是容易验证。如果逻辑更复杂,执行逻辑否定是设计错误可能渗入代码的地方。
我们可以提取过滤规则的一个版本,创建类似以下内容:
outlier = lambda z: z >= 3.5
然后我们可以修改compress()函数的两个用法,使逻辑否定明确:
def pass_outliers(data):
return itertools.compress(data, (outlier(z) for z in z_mod(data)))
def reject_outliers(data):
return itertools.compress(data, (not outlier(z) for z in z_mod(data)))
将过滤规则公开为单独的 lambda 对象或函数定义有助于减少代码重复。否定更加明显。现在可以轻松比较这两个版本,以确保它们具有适当的语义。
如果我们想要使用filter()函数,我们必须对处理流水线进行根本性的转换。filter()高阶函数需要一个决策函数,为每个原始值创建一个真/假结果。处理这个将结合修改后的 Z 得分计算和决策阈值。决策函数必须计算这个:

它必须计算这个,以确定每个x[i]值的异常值状态。这个决策函数需要两个额外的输入——总体中位数,
,和 MAD 值。这使得过滤决策函数相当复杂。它会看起来像这样:
def outlier(mad, median_x, x):
return 0.6745*(x - median_x)/mad >= 3.5
这个outlier()函数可以与filter()一起用于传递异常值。它可以与itertools.filterfalse()一起用于拒绝异常值并创建一个没有错误值的子集。
为了使用这个outlier()函数,我们需要创建一个类似这样的函数:
def pass_outliers2(data):
population_median = median(data)
mad = median_absdev(data, population_median)
outlier_partial = partial(outlier, mad, population_median)
return filter(outlier_partial, data)
这计算了两个总体减少:population_median和mad。有了这两个值,我们可以创建一个偏函数outlier_partial()。这个函数将为前两个位置参数值mad和population_median绑定值。结果的偏函数只需要单独的数据值进行处理。
outlier_partial()和filter()处理等同于这个生成器表达式:
return (
x for x in data if outlier(mad, population_median, x)
)
目前尚不清楚这个表达式是否比itertools模块中的compress()函数具有明显优势。但是,对于更熟悉filter()的程序员来说,它可能会更清晰一些。
另请参阅
一次分析多个变量
在许多情况下,我们会有多个变量的数据需要分析。数据可以可视化为填充网格,每一行包含特定的结果。每个结果行在列中有多个变量。
我们可以遵循列主序的模式,并独立处理每个变量(从数据列中)。这将导致多次访问每一行数据。或者,我们可以使用行主序的模式,并一次处理所有变量的每一行数据。
关注每个变量的优势在于,我们可以编写一个相对简单的处理堆栈。我们将有多个堆栈,每个变量一个,但每个堆栈可以重用statistics模块中的公共函数。
这种关注的缺点是,处理非常大数据集的每个变量需要从操作系统文件中读取原始数据。这个过程的这一部分可能是最耗时的。事实上,读取数据所需的时间通常主导了进行统计分析所需的时间。I/O 成本如此之高,以至于专门的系统,如 Hadoop,已被发明用来尝试加速对极大数据集的访问。
我们如何通过一组数据进行一次遍历并收集一些描述性统计信息?
准备工作
我们可能想要分析的变量将分为多个类别。例如,统计学家经常将变量分成以下类别:
-
连续实值数据:这些变量通常由浮点值测量,它们有一个明确定义的测量单位,并且它们可以取得由测量精度限制的值。
-
离散或分类数据:这些变量取自有限域中选择的值。在某些情况下,我们可以预先枚举域。在其他情况下,必须发现域的值。
-
序数数据:这提供了一个排名或顺序。通常,序数值是一个数字,但除此数字外,没有其他统计摘要适用于此数字,因为它实际上不是一个测量;它没有单位。
-
计数数据:这个变量是个别离散结果的摘要。通过计算一个实值均值,它可以被视为连续的。
变量可能彼此独立,也可能依赖于其他变量。在研究的初期阶段,可能不知道依赖关系。在后期阶段,软件的一个目标是发现这些依赖关系。之后,软件可以用来建模这些依赖关系。
由于数据的多样性,我们需要将每个变量视为一个独立的项目。我们不能将它们都视为简单的浮点值。适当地承认这些差异将导致一系列类定义的层次结构。每个子类将包含变量的独特特征。
我们有两种总体设计模式:
-
急切:我们可以尽早计算各种摘要。在某些情况下,我们不必为此积累太多数据。
-
懒惰:我们尽可能晚地计算摘要。这意味着我们将积累数据,并使用属性来计算摘要。
对于非常大的数据集,我们希望有一个混合解决方案。我们将急切地计算一些摘要,并且还使用属性从这些摘要中计算最终结果。
我们将使用使用内置统计库食谱中的一些数据,其中包括一系列相似数据系列中的两个变量。这些变量被命名为x和y,都是实值变量。y变量应该依赖于x变量,因此相关性和回归模型适用于这里。
我们可以用以下命令读取这些数据:
**>>> from pathlib import Path
>>> import json
>>> from collections import OrderedDict
>>> source_path = Path('code/anscombe.json')
>>> data = json.loads(source_path.read_text(),
... object_pairs_hook=OrderedDict)**
我们已经定义了数据文件的路径。然后我们可以使用Path对象从该文件中读取文本。这些文本将被json.loads()使用,以从 JSON 数据构建 Python 对象。
我们已经包含了一个object_pairs_hook,以便该函数将使用OrderedDict类构建 JSON,而不是默认的dict类。这将保留源文档中项目的原始顺序。
我们可以按以下方式检查数据:
**>>> [item['series'] for item in data]
['I', 'II', 'III', 'IV']
>>> [len(item['data']) for item in data]
[11, 11, 11, 11]**
整个 JSON 文档是一个具有诸如'I'之类的键的子文档序列。每个子文档有两个字段:"series"和"data"。在"data"数组中,有一个我们想要描述的观察值列表。每个观察值都有一对值。
如何做...
- 定义一个类来处理变量的分析。这应该处理所有的转换和清洗。我们将使用混合处理方法:我们将在每个数据元素到达时更新总和和计数。直到请求这些属性时,我们才会计算最终的均值或标准差:
import math
class SimpleStats:
def __init__(self, name):
self.name = name
self.count = 0
self.sum = 0
self.sum_2 = 0
def cleanse(self, value):
return float(value)
def add(self, value):
value = self.cleanse(value)
self.count += 1
self.sum += value
self.sum_2 += value*value
@property
def mean(self):
return self.sum / self.count
@property
def stdev(self):
return math.sqrt(
(self.count*self.sum_2-self.sum**2)/(self.count*(self.count-1))
)
在这个例子中,我们已经为count、sum和平方和定义了摘要。我们可以扩展这个类以添加更多的计算。对于中位数或模式,我们将不得不积累个体值,并改变设计以完全懒惰。
- 定义实例来处理输入列。我们将创建我们的
SimpleStats类的两个实例。在这个示例中,我们选择了两个非常相似的变量,一个类就可以涵盖这两种情况:
x_stats = SimpleStats('x')
y_stats = SimpleStats('y')
- 定义实际列标题到统计计算对象的映射。在某些情况下,列可能不是通过名称标识的:我们可能使用列索引。在这种情况下,对象序列将与每行中的列序列匹配:
column_stats = {
'x': x_stats,
'y': y_stats
}
- 定义一个函数来处理所有行,使用每列的统计计算对象在每行内:
def analyze(series_data):
x_stats = SimpleStats('x')
y_stats = SimpleStats('y')
column_stats = {
'x': x_stats,
'y': y_stats
}
for item in series_data:
for column_name in column_stats:
column_stats[column_name].add(item[column_name])
return column_stats
外部for语句处理每一行数据。内部for语句处理每一行的每一列。处理显然是按行主要顺序进行的。
- 显示来自各个对象的结果或摘要:
column_stats = analyze(series_data)
for column_key in column_stats:
print(' ', column_key,
column_stats[column_key].mean,
column_stats[column_key].stdev)
我们可以将分析函数应用于一系列数据值。这将返回具有统计摘要的字典。
工作原理...
我们创建了一个处理特定类型列的清洁、过滤和统计处理的类。当面对各种类型的列时,我们将需要多个类定义。其思想是能够轻松创建相关类的层次结构。
我们为要分析的每个特定列创建了这个类的一个实例。在这个例子中,SimpleStats是为一个简单浮点值列设计的。其他设计可能适用于离散或有序数据。
该类的外部特性是add()方法。每个单独的数据值都提供给这个方法。mean和stdev属性计算摘要统计信息。
该类还定义了一个cleanse()方法,用于处理数据转换需求。这可以扩展到处理无效数据的可能性。它可能会过滤值,而不是引发异常。必须重写此方法以处理更复杂的数据转换。
我们创建了一组单独的统计处理对象。在这个例子中,集合中的两个项目都是SimpleStats的实例。在大多数情况下,将涉及多个类,并且统计处理对象的集合可能会相当复杂。
这些SimpleStats对象的集合应用于每行数据。for语句使用映射的键,这些键也是列名,将每列的数据与适当的统计处理对象相关联。
在某些情况下,统计摘要必须以惰性方式计算。例如,要发现异常值,我们需要所有数据。定位异常值的一种常见方法是计算中位数,计算与中位数的绝对偏差,然后计算这些绝对偏差的中位数。参见定位异常值配方。要计算模式,我们将所有数据值累积到Counter对象中。
还有更多...
在这种设计中,我们默认假设所有列都是完全独立的。在某些情况下,我们需要组合列来推导出额外的数据项。这将导致更复杂的类定义,可能包括对SimpleStats的其他实例的引用。确保按照依赖顺序处理列可能会变得相当复杂。
正如我们在第八章的使用堆叠的生成器表达式配方中看到的,功能和响应式编程特性,我们可能会有一个涉及增强和计算派生值的多阶段处理。这进一步限制了列处理规则之间的顺序。处理这种情况的一种方法是为每个分析器提供与相关其他分析器的引用。我们可能会有以下相当复杂的一组类定义。
首先,我们将定义两个类来分别处理日期列和时间列。然后我们将结合这些类来创建基于两个输入列的时间戳列。
以下是处理日期列的类:
class DateStats:
def cleanse(self, value):
return datetime.datetime.strptime(date, '%Y-%m-%d').date()
def add(self, value):
self.current = self.cleanse(value)
DateStats类只实现了add()方法。它清洗数据并保留当前值。我们可以为处理时间列定义类似的东西:
class TimeStats:
def cleanse(self, value):
return datetime.datetime.strptime(date, '%H:%M:%S').time()
def add(self, value):
self.current = self.cleanse(value)
TimeStats类类似于DateStats;它只实现了add()方法。这两个类都专注于清洗源数据并保留当前值。
这是一个依赖于前两个类的类。这将使用DateStats和TimeStats对象的current属性来获取每个对象当前可用的值:
class DateTimeStats:
def __init__(self, date_column, time_column):
self.date_column = date_column
self.time_column = time_column
def add(self, value=None):
date = self.date_column.current
time = self.time_column.current
self.current = datetime.datetime.combine(date, time)
DateTimeStats类结合了两个对象的结果。它需要一个DateStats类的实例和一个TimeStats类的实例。从这两个对象中,当前的清洗值作为current属性是可用的。
请注意,value参数在DateTimeStats实现的add()方法中未被使用。与接受value作为参数不同,值是从另外两个清洗对象中收集的。这要求在处理派生列之前,其他两列必须被处理。
为了确保值是可用的,需要对每一行进行一些额外的处理。基本的日期和时间处理映射到特定的列:
date_stats = DateStats()
time_stats = TimeStats()
column_stats = {
'date': date_stats,
'time': time_stats
}
这个column_stats映射可以用来对每行数据应用两个基础数据清洗操作。然而,我们还有派生数据,必须在基础数据完成后计算。
我们可能会有这样的情况:
datetime_stats = DateTimeStats(date_stats, time_stats)
derived_stats = {
'datetime': datetime_stats
}
我们建立了一个依赖于另外两个统计处理对象date_stats和time_stats的DateTimeStats实例。这个对象的add()方法将从另外两个对象中获取当前值。如果我们有其他派生列,我们可以将它们收集到这个映射中。
这个derived_stats映射可以用来应用统计处理操作,以创建和分析派生数据。整体处理循环现在有两个阶段:
for item in series_data:
for column_name in column_stats:
column_stats[column_name].add(item[column_name])
for column_name in derived_stats:
derived_stats[column_name].add()
我们已经为源数据中存在的列计算了统计数据。然后我们为派生列计算了统计数据。这个方法的一个令人愉快的特点是只使用了两个映射进行配置。我们可以通过更新column_stats和derived_stats映射来更改所使用的类。
使用 map()
我们使用显式的for语句将每个统计对象应用于相应的列数据。我们也可以使用一个生成器表达式。我们甚至可以尝试使用map()函数。在第八章的组合 map 和 reduce 转换一节中,可以了解到有关这种技术的一些额外背景。
另一个数据收集集合可能如下所示:
data_gathering = {
'x': lambda value: x_stats.add(value),
'y': lambda value: y_stats.add(value)
}
我们提供了一个应用对象的add()方法到给定数据值的函数,而不是对象本身。
有了这个集合,我们可以使用一个生成器表达式:
data_gathering[k for k in data_gathering)]
这将对每个值k在行中可用的data_gathering[k]函数进行应用。
另请参阅
- 在第六章的类的设计与大量处理和使用惰性属性一节中,还可以了解到一些适合这种整体方法的其他设计选择。
第十一章:测试
在本章中,我们将看以下配方:
-
使用文档字符串进行测试
-
测试引发异常的函数
-
处理常见的 doctest 问题
-
创建单独的测试模块和包
-
结合 unittest 和 doctest 测试
-
测试涉及日期或时间的事物
-
测试涉及随机性的事物
-
模拟外部资源
介绍
测试是创建可工作软件的核心。这是关于测试重要性的经典陈述:
任何没有自动化测试的程序功能都不存在。
这是肯特·贝克的书《极限编程解释:拥抱变化》中的内容。
我们可以区分几种测试:
-
单元测试:这适用于独立的软件单元:函数、类或模块。该单元被孤立测试以确认它是否正确工作。
-
集成测试:这将单元组合以确保它们正确集成。
-
系统测试:这测试整个应用程序或一组相互关联的应用程序,以确保软件组件的集合正常工作。这经常用于整体接受软件的使用。
-
性能测试:这确保一个单元满足性能目标。在某些情况下,性能测试包括对内存、线程或文件描述符等资源的研究。目标是确保软件适当地利用系统资源。
Python 有两个内置的测试框架。其中一个检查文档字符串中包含>>>提示的示例。这就是doctest工具。虽然这被广泛用于单元测试,但也可以用于简单的集成测试。
另一个测试框架使用了从unittest模块定义的类构建的定义。这个模块定义了一个TestCase类。这也主要用于单元测试,但也可以应用于集成和性能测试。
当然,我们希望结合这些工具。这两个模块都有特性允许共存。我们经常利用unittest包的测试加载协议来合并所有测试。
此外,我们可能会使用工具nose2或py.test来进一步自动化测试发现,并添加额外的功能,如测试用例覆盖率。这些项目通常对特别复杂的应用程序很有帮助。
有时使用 GIVEN-WHEN-THEN 测试用例命名风格来总结一个测试是有帮助的:
-
GIVEN一些初始状态或上下文
-
WHEN请求行为
-
THEN被测试的组件有一些预期的结果或状态变化
使用文档字符串进行测试
良好的 Python 包括每个模块、类、函数和方法内部的文档字符串。许多工具可以从文档字符串创建有用的、信息丰富的文档。
文档字符串的一个重要元素是示例。示例成为一种单元测试用例。一个示例通常符合 GIVEN-WHEN-THEN 测试模型,因为它显示了一个单元、一个请求和一个响应。
我们如何将示例转化为适当的测试用例?
准备就绪
我们将看一个简单的函数定义以及一个简单的类定义。每个都将包括包含示例的文档字符串,这些示例可以用作正式测试。
这是一个计算两个数字的二项式系数的简单函数。它显示了n个事物以k个大小的组合的数量。例如,一副 52 张的牌可以被分成 5 张牌的方式可以这样计算:

这定义了一个小的 Python 函数,我们可以这样写:
from math import factorial
def binom(n: int, k: int) -> int:
return factorial(n) // (factorial(k) * factorial(n-k))
这个函数进行了一个简单的计算并返回一个值。由于它没有内部状态,所以相对容易测试。这将是用于展示可用的单元测试工具的示例之一。
我们还将看一个简单的类,它具有均值和中位数的延迟计算。它使用一个内部的Counter对象,可以被询问以确定模式:
from statistics import median
from collections import Counter
class Summary:
def __init__(self):
self.counts = Counter()
def __str__(self):
return "mean = {:.2f}\nmedian = {:d}".format(
self.mean, self.median)
def add(self, value):
self.counts[value] += 1
@property
def mean(self):
s0 = sum(f for v,f in self.counts.items())
s1 = sum(v*f for v,f in self.counts.items())
return s1/s0
@property
def median(self):
return median(self.counts.elements())
add()方法改变了这个对象的状态。由于这种状态改变,我们需要提供更复杂的示例,展示Summary类的实例的行为方式。
如何做...
我们将在这个示例中展示两种变化。第一种是用于大部分无状态操作,比如计算binom()函数。第二种是用于有状态操作,比如Summary类。
-
将示例放入文档字符串中。
-
将 doctest 模块作为程序运行。有两种方法:
- 在命令提示符下:
**$ python3.5 -m doctest code/ch11_r01.py**
如果所有示例都通过,就不会有输出。使用-v选项会产生总结测试的详细输出。
- 通过包含一个
__name__ == '__main__'部分。这可以导入 doctest 模块并执行testmod()函数:
if __name__ == '__main__':
import doctest
doctest.testmod()
如果所有示例都通过,就不会有输出。要查看一些输出,可以使用testmod()函数的verbose=1参数创建更详细的输出。
为无状态函数编写示例
- 用摘要开始文档字符串:
'''Computes the binomial coefficient.
This shows how many combinations of
*n* things taken in groups of size *k*.
- 包括参数定义:
:param n: size of the universe
:param k: size of each subset
- 包括返回值定义:
:returns: the number of combinations
- 模拟一个在 Python 的
>>>提示下使用该函数的示例:
**>>> binom(52, 5)
2598960**
- 用适当的引号关闭长文档字符串:
'''
为有状态对象编写示例
- 用摘要编写类级别的文档字符串:
'''Computes summary statistics.
'''
我们留下了填写示例的空间。
- 使用摘要编写方法级别的文档字符串。这是
add()方法:
def add(self, value):
'''Adds a value to be summarized.
:param value: Adds a new value to the collection.
'''
self.counts[value] += 1
- 这是
mean()方法:
@property
def mean(self):
'''Computes the mean of the collection.
:return: mean value as a float
'''
s0 = sum(f for v,f in self.counts.items())
s1 = sum(v*f for v,f in self.counts.items())
return s1/s0
median()方法和其他写入的方法也需要类似的字符串。
- 扩展类级别的文档字符串具体示例。在这种情况下,我们将写两个。第一个示例显示
add()方法没有返回值,但改变了对象的状态。mean()方法显示了这个状态:
**>>> s = Summary()
>>> s.add(8)
>>> s.add(9)
>>> s.add(9)
>>> round(s.mean, 2)
8.67
>>> s.median
9**
我们将平均值的结果四舍五入,以避免显示一个长的浮点值,在所有平台上可能没有完全相同的文本表示。当我们运行 doctest 时,通常会得到一个静默的响应,因为测试通过了。
第二个示例显示了__str__()方法的多行结果:
**>>> print(str(s))
mean = 8.67
median = 9**
当某些事情不起作用时会发生什么?想象一下,我们将期望的输出更改为错误答案。当我们运行 doctest 时,我们将看到如下输出:
*************************************************************************
File "__main__", line ?, in __main__.Summary
**Failed example:**
**s.median**
**Expected:**
10
**Got:**
9
*************************************************************************
**1 items had failures:**
1 of 6 in __main__.Summary
*****Test Failed*** 1 failures.**
**TestResults(failed=1, attempted=9)**
这显示了错误的位置。它显示了测试示例的预期值和实际答案。
它是如何工作的...
doctest模块包括一个主程序,以及几个函数,它将扫描 Python 文件中的>>>示例。我们可以利用模块扫描函数testmod()来扫描当前模块。我们可以使用这个来扫描任何导入的模块。
扫描操作寻找具有>>>行特征模式的文本块,后面是显示命令响应的行。
doctest 解析器从提示行和响应文本块创建一个小的测试用例对象。有三种常见情况:
-
没有预期的响应文本:当我们为
Summary类的add()方法定义测试时,我们看到了这种模式。 -
单行响应文本:这在
binom()函数和mean()方法中得到了体现。 -
多行响应:响应由下一个
>>>提示或空行限定。这在Summary类的str()示例中得到了体现。
doctest 模块将执行每个带有>>>提示的代码行。它将实际结果与期望结果进行比较。比较是非常简单的文本匹配。除非使用特殊注释,否则输出必须精确匹配期望。
这种测试协议的简单性对软件设计提出了一些要求。函数和类必须设计为从>>>提示中工作。因为在文档字符串示例中创建非常复杂的对象可能会变得尴尬,所以设计必须保持足够简单,以便可以进行交互演示。保持软件足够简单,以便在>>>提示处进行演示通常是有益的。
结果的比较简单性可能会对显示的输出造成一些复杂性。例如,请注意,我们将平均值的值四舍五入到两位小数。这是因为浮点值的显示可能会因平台而异。
Python 3.5.1(在 Mac OS X 上)显示8.666666666666666,而 Python 2.6.9(同样在 Mac OS X 上)显示8.6666666666666661。这些值在小数点后 16 位相等。这大约是 48 位数据,这是浮点值的实际限制。
我们将在处理常见的 doctest 问题配方中详细讨论精确比较问题。
还有更多...
一个重要的测试考虑因素是边界情况。边界情况通常关注计算设计的极限。例如,二项式函数有两个边界:

我们可以很容易地将这些添加到示例中,以确保我们的实现是正确的;这将导致一个看起来像下面这样的函数:
def binom(n: int, k: int) -> int:
'''Computes the binomial coefficient.
This shows how many combinations of
*n* things taken in groups of size *k*.
:param n: size of the universe
:param k: size of each subset
:returns: the number of combinations
>>> binom(52, 5)
2598960
>>> binom(52, 0)
1
>>> binom(52, 52)
1
'''
return factorial(n) // (factorial(k) * factorial(n-k))
在某些情况下,我们可能需要测试超出有效值范围的值。这些情况并不适合放入文档字符串,因为它们会使本来应该发生的事情的解释变得混乱。
我们可以在一个名为__test__的全局变量中包含额外的文档字符串测试用例。这个变量必须是一个映射。映射的键是测试用例的名称,映射的值是 doctest 示例。这些示例需要是三引号字符串。
因为这些示例不在文档字符串内,所以在使用内置的help()函数时不会显示出来。当使用其他工具从源代码创建文档时,它们也不会显示出来。
我们可能会添加类似这样的内容:
__test__ = {
'GIVEN_binom_WHEN_0_0_THEN_1':
'''
>>> binom(0, 0)
1
''',
}
我们已经用没有缩进的键编写了映射。值已经缩进了四个空格,这样它们就会从键中脱颖而出,并且稍微容易发现。
Doctest 程序会找到这些测试用例,并将其包含在整体测试套件中。我们可以用这个来进行重要的测试,但并不真正有助于文档编制。
另请参阅
- 在测试引发异常的函数和处理常见的 doctest 问题配方中,我们将看到另外两种 doctest 技术。这是重要的,因为异常通常会包括一个回溯,其中可能包括每次运行程序时都会有所不同的对象 ID。
测试引发异常的函数
良好的 Python 在每个模块、类、函数和方法内部都包含文档字符串。许多工具可以从这些文档字符串中创建有用的、信息丰富的文档。
文档字符串的一个重要元素是示例。示例成为一种单元测试用例。Doctest 对期望输出与实际输出进行简单的、字面的匹配。
然而,当示例引发异常时,Python 的回溯消息并不总是相同的。它可能包括会改变的对象 ID 值或模块行号,这取决于执行测试的上下文。当涉及异常时,doctest 的字面匹配规则并不适用。
我们如何将异常处理和由此产生的回溯消息转化为正确的测试用例?
准备就绪
我们将看一个简单的函数定义以及一个简单的类定义。其中每一个都将包括包含示例的文档字符串,这些示例可以用作正式测试。
这是一个简单的函数,用于计算两个数字的二项式系数。它显示了n个东西在k组中取的组合数。例如,一个 52 张牌的牌组可以被分成 5 张牌的手的方式有多少种:

这定义了一个小的 Python 函数,我们可以这样写:
from math import factorial
def binom(n: int, k: int) -> int:
'''
Computes the binomial coefficient.
This shows how many combinations of
*n* things taken in groups of size *k*.
:param n: size of the universe
:param k: size of each subset
:returns: the number of combinations
>>> binom(52, 5)
2598960
'''
return factorial(n) // (factorial(k) * factorial(n-k))
这个函数进行简单的计算并返回一个值。我们想在__test__变量中包含一些额外的测试用例,以展示在给定超出预期范围的值时会发生什么。
如何做...
- 在模块中创建一个全局的
__test__变量:
__test__ = {
}
我们留下了空间来插入一个或多个测试用例。
- 对于每个测试用例,提供一个名称和一个示例的占位符:
__test__ = {
'GIVEN_binom_WHEN_wrong_relationship_THEN_error':
'''
example goes here.
''',
}
- 包括一个带有
doctest指令注释的调用,IGNORE_EXCEPTION_DETAIL。这将替换“示例在这里”:
**>>> binom(5, 52) # doctest: +IGNORE_EXCEPTION_DETAIL**
该指令以# doctest:开头。指令通过+启用,通过-禁用。
- 包括一个实际的回溯消息。这是示例在这里的一部分;它在
>>>语句之后显示预期的响应:
Traceback (most recent call last):
File "/Library/Frameworks/Python.framework/Versions/3.5/lib/python3.5/doctest.py", line 1320, in __run
compileflags, 1), test.globs)
File "<doctest __main__.__test__.GIVEN_binom_WHEN_wrong_relationship_THEN_error[0]>", line 1, in <module>
binom(5, 52)
File "/Users/slott/Documents/Writing/Python Cookbook/code/ch11_r01.py", line 24, in binom
return factorial(n) // (factorial(k) * factorial(n-k))
ValueError: factorial() not defined for negative values
- 以
File...开头的三行将被忽略。ValueError:行将被检查以确保测试产生了预期的异常。
总体语句看起来像这样:
__test__ = {
'GIVEN_binom_WHEN_wrong_relationship_THEN_error': '''
>>> binom(5, 52) # doctest: +IGNORE_EXCEPTION_DETAIL
Traceback (most recent call last):
File "/Library/Frameworks/Python.framework/Versions/3.5/lib/python3.5/doctest.py", line 1320, in __run
compileflags, 1), test.globs)
File "<doctest __main__.__test__.GIVEN_binom_WHEN_wrong_relationship_THEN_error[0]>", line 1, in <module>
binom(5, 52)
File "/Users/slott/Documents/Writing/Python Cookbook/code/ch11_r01.py", line 24, in binom
return factorial(n) // (factorial(k) * factorial(n-k))
ValueError: factorial() not defined for negative values
'''
}
现在我们可以使用这样的命令来测试整个模块的功能:
**python3.5 -R -m doctest ch11_r01.py**
它是如何工作的...
doctest 解析器有几个指令,可以用来修改测试行为。这些指令被包含为特殊注释,与执行测试操作的代码行一起。
我们有两种处理包含异常的测试的方法:
-
我们可以使用
# doctest: +IGNORE_EXCEPTION_DETAIL并提供完整的回溯错误消息。回溯的细节将被忽略,只有最终的异常行与预期值匹配。这使得很容易复制实际错误并将其粘贴到文档中。 -
我们可以使用
# doctest: +ELLIPSIS并用...替换回溯消息的部分。这也允许预期输出省略细节并专注于实际错误的最后一行。
对于这种第二种异常示例,我们可以包括一个像这样的测试用例:
'GIVEN_binom_WHEN_negative_THEN_exception':
'''
>>> binom(52, -5) # doctest: +ELLIPSIS
Traceback (most recent call last):
...
ValueError: factorial() not defined for negative values
''',
测试用例使用了+ELLIPSIS指令。错误回溯的细节已被替换为...。相关材料已被保留完整,以便实际异常消息与预期异常消息精确匹配。
Doctest 将忽略第一个Traceback...行和最后一个ValueError:...行之间的所有内容。通常,最后一行是测试的正确执行所关心的。中间文本取决于测试运行的上下文。
还有更多...
还有几个比较指令可以提供给单个测试。
-
+ELLIPSIS:这允许预期结果通过用...替换细节来概括。 -
+IGNORE_EXCEPTION_DETAIL:这允许预期值包括完整的回溯消息。大部分回溯将被忽略,只有最终的异常行会被检查。 -
+NORMALIZE_WHITESPACE:在某些情况下,预期值可能会被包裹到多行上以便于阅读。或者,它的间距可能与标准 Python 值略有不同。使用此标志允许预期值的空格有一定的灵活性。 -
+SKIP:测试被跳过。有时会为设计用于未来版本的测试而这样做。在功能完成之前可能会包括测试。测试可以保留在原位以供未来开发工作使用,但为了按时发布版本而被跳过。
-
+DONT_ACCEPT_TRUE_FOR_1:这涵盖了 Python 2 中常见的一种特殊情况。在True和False被添加到语言之前,值1和0被用来代替。与实际结果进行比较的 doctest 算法将通过匹配True和1来尊重这种较旧的方案。可以在命令行上使用-o DONT_ACCEPT_TRUE_FOR_1提供此指令。然后,这个改变将对所有测试全局有效。 -
+DONT_ACCEPT_BLANKLINE:通常,空行会结束一个示例。在示例输出包括空行的情况下,预期结果必须使用特殊语法<blankline>。使用这个语法可以显示预期的空行位置,并且示例不会在这个空行结束。在非常罕见的情况下,预期输出实际上会包括字符串<blankline>。这个指令确保<blankline>不是用来表示空行,而是代表它自己。在为文档测试模块本身编写测试时,这是有意义的。
在评估testmod()或testfile()函数时,这些也可以作为optionsflags参数提供。
另请参阅
-
查看使用文档字符串进行测试配方,了解文档测试的基础知识
-
查看处理常见的文档测试问题配方,了解其他需要文档测试指令的特殊情况
处理常见的文档测试问题
良好的 Python 包括每个模块、类、函数和方法内部的文档字符串。许多工具可以从完整的文档字符串中创建有用的、信息丰富的文档。
文档字符串的一个重要元素是示例。示例成为一种单元测试用例。文档测试对预期输出进行简单、字面的匹配。然而,有一些 Python 对象在每次引用它们时并不一致。
例如,所有对象哈希值都是随机的。这意味着集合中元素的顺序或字典中键的顺序可能会有所不同。我们有几种选择来创建测试用例示例输出:
-
编写可以容忍随机化的测试。通常通过转换为排序结构。
-
规定
PYTHONHASHSEED环境变量的值。 -
要求使用
-R选项运行 Python 以完全禁用哈希随机化。
除了集合中键或项的位置的简单变化之外,还有一些其他考虑因素。以下是一些其他问题:
-
id()和repr()函数可能会暴露内部对象 ID。对于这些值无法做出任何保证。 -
浮点值可能会因平台而异。
-
当前日期和时间在测试用例中没有实际意义。
-
使用默认种子的随机数很难预测。
-
操作系统资源可能不存在,或者可能不处于适当的状态。
在这个配方中,我们将使用一些文档测试技术来解决前两个问题。我们将在涉及日期或时间的测试和涉及随机性的测试配方中研究datetime和random。我们将在模拟外部资源配方中研究如何处理外部资源。
文档测试示例需要与文本完全匹配。我们如何编写处理哈希随机化或浮点实现细节的文档测试示例?
准备工作
在使用 CSV 模块读取分隔文件配方中,我们看到csv模块将读取数据,为每一行输入创建一个映射。在那个配方中,我们看到了一个CSV文件,其中记录了一艘帆船日志中的一些实时数据。这是waypoints.csv文件。
DictReader类生成的行如下所示:
{'date': '2012-11-27',
'lat': '32.8321666666667',
'lon': '-79.9338333333333',
'time': '09:15:00'}
这是一个文档测试的噩梦,因为哈希随机化确保这个字典中键的顺序很可能是不同的。
当我们尝试编写涉及字典的文档测试示例时,我们经常会遇到这样的问题:
Failed example:
next(row_iter)
Expected:
{'date': '2012-11-27', 'lat': '32.8321666666667',
'lon': '-79.9338333333333', 'time': '09:15:00'}
Got:
{'lon': '-79.9338333333333', 'time': '09:15:00',
'date': '2012-11-27', 'lat': '32.8321666666667'}
预期和实际行中的数据明显匹配。然而,字典值的字符串显示并不完全相同。键的顺序不一致。
我们还将研究一个小型的实值函数,以便我们可以处理浮点值:

这个函数是标准 z 分数的累积概率密度函数。对于标准化变量,该变量的 Z 分数值的平均值将为零,标准差将为一。有关标准化分数概念的更多信息,请参见第八章中的创建部分函数配方,功能和响应式编程特性。
这个函数Φ(n)告诉我们人口中有多少比例在给定的 z 分数下。例如,Φ(0) = 0.5:一半的人口的 z 分数低于零。
这个函数涉及一些相当复杂的处理。单元测试必须反映浮点精度问题。
如何操作...
我们将在一个配方中查看映射(和集合)排序。我们将单独查看浮点数。
为映射或集合值编写 doctest 示例
- 导入必要的库并定义函数:
import csv
def raw_reader(data_file):
"""
Read from a given, open file.
:param data_file: Open file, ready to be processed.
:returns: iterator over individual rows as dictionaries.
Example:
"""
data_reader = csv.DictReader(data_file)
for row in data_reader:
yield row
我们在文档字符串中包含了示例标题。
- 我们可以用
io包中的StringIO类的实例替换实际数据文件。这可以在示例内部使用,以提供固定的样本数据:
**>>> from io import StringIO
>>> mock_file = StringIO('''lat,lon,date,time
... 32.8321,-79.9338,2012-11-27,09:15:00
... ''')
>>> row_iter = iter(raw_reader(mock_file))**
- 从概念上讲,测试用例是这样的。这段代码将无法正常工作,因为键将被打乱。但是,可以很容易地重构它:
**>>> row = next(row_iter)
>>> row
{'time': '09:15:00', 'lat': '32.8321', etc. }**
我们省略了其余的输出,因为每次运行测试时都会有所不同:
代码必须这样编写,以强制将键按固定顺序排列:
**>>> sorted(row.items()) # doctest: +NORMALIZE_WHITESPACE
[('date', '2012-11-27'), ('lat', '32.8321'),
('lon', '-79.9338'), ('time', '09:15:00')]**
排序后的项目是按一致的顺序排列的。
为浮点值编写 doctest 示例
- 导入必要的库并定义函数:
from math import *
def phi(n):
"""
The cumulative distribution function for the standard normal
distribution.
:param n: number of standard deviations
:returns: cumulative fraction of values below n.
Examples:
"""
return (1+erf(n/sqrt(2)))/2
我们在文档字符串中留下了示例的空间。
- 对于每个示例,包括显式使用
round():
**>>> round(phi(0), 3)
0.399
>>> round(phi(-1), 3)
0.242
>>> round(phi(+1), 3)
0.242**
浮点值四舍五入,以便浮点实现细节的差异不会导致看似不正确的结果。
它是如何工作的...
由于哈希随机化,用于字典的哈希键是不可预测的。这是一个重要的安全特性,可以防止微妙的拒绝服务攻击。有关详细信息,请参见www.ocert.org/advisories/ocert-2011-003.html。
我们有两种方法可以处理没有定义顺序的字典键:
- 我们可以编写针对每个键具体的测试用例:
**>>> row['date']
'2012-11-27'
>>> row['lat']
'32.8321'
>>> row['lon']
'-79.9338'
>>> row['time']
'09:15:00'**
- 我们可以将其转换为一个具有固定顺序的数据结构。
row.items()的值是一个可迭代的键值对序列。顺序不是提前设置的,但我们可以使用以下方法来强制排序:
**>>> sorted(row.items())**
这将返回一个按顺序排列的键列表。这使我们能够创建一个一致的文字值,每次评估测试时都将是相同的。
大多数浮点实现都是相当一致的。然而,对于任何给定的浮点数的最后几位,很少有正式的保证。与其相信所有的 53 位都有完全正确的值,往往更容易将值四舍五入为与问题域相匹配的值。
对于大多数现代处理器,浮点值通常是 32 位或 64 位值。32 位值大约有七位小数。将值四舍五入,使值中不超过六位数字通常是最简单的方法。
将数字四舍五入到六位并不意味着使用round(x, 6)。round()函数不会保留数字的位数。这个函数四舍五入到小数点右边的位数;它不考虑小数点左边的位数。将一个数量级为 10¹²的数字四舍五入到小数点右边的六个位置会得到 18 位数字,对于 32 位值来说太多了。将一个数量级为 10^(-7)的数字四舍五入到小数点右边的六个位置会得到零。
还有更多...
在处理set对象时,我们还必须注意项目的顺序。我们通常可以使用sorted()将set转换为list并强加特定的顺序。
Python dict对象出现在令人惊讶的许多地方:
-
当我们编写一个使用
**来收集参数值字典的函数时。没有保证参数的顺序。 -
当我们使用诸如
vars()这样的函数从局部变量或对象的属性创建字典时,字典没有保证的顺序。 -
当我们编写依赖于类定义内省的程序时,方法是在类级别的字典对象中定义的。我们无法预测它们的顺序。
当存在不可靠的测试用例时,这一点变得明显。一个似乎随机通过或失败的测试用例可能是基于哈希随机化的结果。提取键并对其进行排序以克服这个问题。
我们也可以使用这个命令行选项来运行测试:
**python3.5 -R -m doctest ch11_r03.py**
这将关闭哈希随机化,同时在特定文件 ch11_r03.py 上运行 doctest。
另请参阅
-
涉及日期或时间的测试 配方,特别是 datetime 的
now()方法需要一些小心。 -
涉及随机性的测试 配方将展示如何测试涉及
random处理的过程。
创建单独的测试模块和包
我们可以在文档字符串示例中进行任何类型的单元测试。然而,有些事情如果用这种方式做会变得极其乏味。
unittest 模块允许我们超越简单的示例。这些测试依赖于测试用例类定义。TestCase 的子类可以用来编写非常复杂和复杂的测试;这些测试可以比作为 doctest 示例进行的相同测试更简单。
unittest 模块还允许我们在文档字符串之外打包测试。这对于特别复杂的边界情况的测试非常有帮助,当放在文档中时并不那么有用。理想情况下,doctest 用例说明了 happy path – 最常见的用例。通常使用 unittest 来进行不在 happy path 上的测试用例。
我们如何创建更复杂的测试?
准备工作
一个测试通常可以用一个三部分的 Given-When-Then 故事来总结:
-
GIVEN:处于初始状态或上下文中的某个单元
-
WHEN:请求一种行为
-
THEN:被测试的组件有一些预期的结果或状态变化
TestCase 类并不完全遵循这种三部分结构。它有两部分;必须做出一些设计选择,关于测试的三个部分应该分配到哪里:
-
一个实现测试用例的 Given 部分的
setUp()方法。它也可以处理 When 部分。 -
一个必须处理 Then 部分的
runTest()方法。这也可以处理 When 部分。 Then 条件通过一系列断言来确认。这些通常使用TestCase类的复杂断言方法。
在哪里实现 When 部分的选择与重用的问题有关。在大多数情况下,有许多替代的 When 条件,每个条件都有一个独特的 Then 来确认正确的操作。Given 可能是 setUp() 方法的共同部分,并被一些 TestCase 子类共享。每个子类都有一个独特的 runTest() 方法来实现 When 和 Then 部分。
在某些情况下,When 部分被分成一些常见部分和一些特定于测试用例的部分。在这种情况下,When 部分可能在 setUp() 方法中部分定义,部分在 runTest() 方法中定义。
我们将为一个设计用于计算一些基本描述性统计的类创建一些测试。我们希望提供的样本数据远远大于我们作为 doctest 示例输入的任何内容。我们希望使用成千上万的数据点而不是两三个。
这是我们想要测试的类定义的概要。我们只提供了方法和一些摘要。代码的大部分在使用文档字符串进行测试中显示。我们省略了所有的实现细节。这只是类的概要,提醒了方法的名称是什么:
from statistics import median
from collections import Counter
class Summary:
def __init__(self):
pass
def __str__(self):
'''Returns a multi-line text summary.'''
def add(self, value):
'''Adds a value to be summarized.'''
@property
def count(self):
'''Number of samples.'''
@property
def mean(self):
'''Mean of the collection.'''
@property
def median(self):
'''Median of the collection.'''
return median(self.counts.elements())
@property
def mode(self):
'''Returns the items in the collection in decreasing
order by frequency.
'''
因为我们没有关注实现细节,这是一种黑盒测试。代码是一个黑盒——内部是不透明的。为了强调这一点,我们从前面的代码中省略了实现细节。
我们希望确保当我们使用成千上万的样本时,这个类能够正确执行。我们也希望确保它能够快速工作;我们将把它作为整体性能测试的一部分,以及单元测试。
如何做...
- 我们将测试代码包含在与工作代码相同的模块中。这将遵循将测试和代码捆绑在一起的 doctest 模式。我们将使用
unittest模块来创建测试类:
import unittest
import random
我们还将使用random来打乱输入数据。
- 创建一个
unittest.TestCase的子类。为这个类提供一个显示测试意图的名称:
class GIVEN_Summary_WHEN_1k_samples_THEN_mean(unittest.TestCase):
GIVEN-WHEN-THEN的名称非常长。我们将依赖unittest来发现TestCase的所有子类,这样我们就不必多次输入这个类名。
- 在这个类中定义一个
setUp()方法,处理测试的Given方面。这将为测试处理创建一个上下文:
def setUp(self):
self.summary = Summary()
self.data = list(range(1001))
random.shuffle(self.data)
我们创建了一个包含1,001个样本的集合,值范围从0到1,000。平均值恰好是 500,中位数也是。我们将数据随机排序。
- 定义一个
runTest()方法,处理测试的When方面。这将执行状态变化:
def runTest(self):
for sample in self.data:
self.summary.add(sample)
- 添加断言来实现测试的Then方面。这将确认状态变化是否正常工作:
self.assertEqual(500, self.summary.mean)
self.assertEqual(500, self.summary.median)
- 为了使运行变得非常容易,添加一个主程序部分:
if __name__ == "__main__":
unittest.main()
有了这个,测试可以在命令提示符下运行。也可以从命令行运行。
它是如何工作的...
我们使用了unittest模块的几个部分:
TestCase类用于定义一个测试用例。这可以有一个setUp()方法来创建单元和可能的请求。这必须至少有一个runTest()来发出请求并检查响应。
我们可以在一个文件中有多个这样的类定义,以便构建一个适当的测试集。对于简单的类,可能只有几个测试用例。对于复杂的模块,可能有几十甚至几百个用例。
-
unittest.main()函数做了几件事: -
它创建一个空的
TestSuite,其中包含所有的TestCase对象。 -
它使用默认加载器来检查一个模块并找到所有的
TestCase实例。这些被加载到TestSuite中。这个过程是我们可能想要修改或扩展的。 -
然后运行
TestSuite并显示结果的摘要。
当我们运行这个模块时,我们会看到以下输出:
**.----------------------------------------------------------------------
Ran 1 test in 0.005s
OK**
每次通过一个测试,都会显示一个。。这表明测试套件正在取得进展。在-行之后是测试运行的摘要和时间。如果有失败或异常,计数将反映这一点。
最后,有一个OK的总结,显示所有测试是否都通过或者有任何测试失败。
如果我们稍微改变测试以确保它失败,我们会看到以下输出:
**F**
**======================================================================**
**FAIL: runTest (__main__.GIVEN_Summary_WHEN_1k_samples_THEN_mean)**
**----------------------------------------------------------------------**
**Traceback (most recent call last):**
**File "/Users/slott/Documents/Writing/Python Cookbook/code/ch11_r04.py", line 24, in runTest**
**self.assertEqual(501, self.summary.mean)**
**AssertionError: 501 != 500.0**
**----------------------------------------------------------------------**
**Ran 1 test in 0.004s**
**FAILED (failures=1)**
对于通过的测试,显示一个.,对于失败的测试,显示一个F。然后是断言失败的回溯。为了强制测试失败,我们将期望的平均值改为501,而不是计算出的平均值500.0。
最后有一个FAILED的总结。这包括套件作为一个整体失败的原因:(failures=1)。
还有更多...
在这个例子中,我们在runTest()方法中有两个Then条件。如果一个失败,测试就会停止作为一个失败,另一个条件就不会被执行。
这是这个测试设计的一个弱点。如果第一个测试失败,我们将得不到所有可能想要的诊断信息。我们应该避免在 runTest() 方法中独立收集断言。在许多情况下,一个测试用例可能涉及多个依赖断言;单个失败提供了所有所需的诊断信息。断言的聚类是简单性和诊断细节之间的设计权衡。
当我们需要更多的诊断细节时,我们有两个一般选择:
-
使用多个测试方法而不是
runTest()。编写多个以test_开头的方法。删除任何名为runTest()的方法。默认的测试加载器将在重新运行公共的setUp()方法后,分别执行每个test_方法。 -
使用
GIVEN_Summary_WHEN_1k_samples_THEN_mean类的多个子类,每个子类都有一个单独的条件。由于setUp()是公共的,这可以被继承。
按照第一种选择,测试类将如下所示:
class GIVEN_Summary_WHEN_1k_samples_THEN_mean_median(unittest.TestCase):
def setUp(self):
self.summary = Summary()
self.data = list(range(1001))
random.shuffle(self.data)
for sample in self.data:
self.summary.add(sample)
def test_mean(self):
self.assertEqual(500, self.summary.mean)
def test_median(self):
self.assertEqual(500, self.summary.median)
我们已经重构了 setUp() 方法,包括测试的 Given 和 When 条件。两个独立的 Then 条件被重构为它们自己单独的 test_mean() 和 test_median() 方法。没有 runTest() 方法。
由于每个测试是单独运行的,我们将看到计算均值或计算中位数的问题的单独错误报告。
一些其他断言
TestCase 类定义了许多断言,可以作为 Then 条件的一部分使用;以下是一些最常用的:
-
assertEqual()和assertNotEqual()使用默认的==运算符比较实际值和期望值。 -
assertTrue()和assertFalse()需要一个布尔表达式。 -
assertIs()和assertIsNot()使用is比较来确定两个参数是否是对同一个对象的引用。 -
assertIsNone()和assertIsNotNone()使用is来将给定值与None进行比较。 -
assertIsInstance()和assertNotIsInstance()使用isinstance()函数来确定给定值是否是给定类(或类元组)的成员。 -
assertAlmostEquals()和assertNotAlmostEquals()将给定值四舍五入到七位小数,以查看大部分数字是否相等。 -
assertRegex()和assertNotRegex()使用正则表达式比较给定的字符串。这使用正则表达式的search()方法来匹配字符串。 -
assertCountEqual()比较两个序列,看它们是否具有相同的元素,不考虑顺序。这对比较字典键和集合也很方便。
还有更多的断言方法。其中一些提供了检测异常、警告和日志消息的方法。另一组提供了更多类型特定的比较能力。
例如,Summary 类的模式特性产生一个列表。我们可以使用特定的 assertListEqual() 断言来比较结果:
class GIVEN_Summary_WHEN_1k_samples_THEN_mode(unittest.TestCase):
def setUp(self):
self.summary = Summary()
self.data = [500]*97
# Build 993 more elements each item n occurs n times.
for i in range(1,43):
self.data += [i]*i
random.shuffle(self.data)
for sample in self.data:
self.summary.add(sample)
def test_mode(self):
top_3 = self.summary.mode[:3]
self.assertListEqual([(500,97), (42,42), (41,41)], top_3)
首先,我们构建了一个包含 1000 个值的集合。其中,有 97 个是数字 500 的副本。剩下的 903 个元素是介于 1 和 42 之间的数字的副本。这些数字有一个简单的规则——频率就是值。这个规则使得确认结果更容易。
setUp() 方法将数据随机排序。然后使用 add() 方法构建 Summary 对象。
我们使用了一个 test_mode() 方法。这允许扩展到包括这个测试的其他 Then 条件。在这种情况下,我们检查了模式的前三个值,以确保它具有预期的值分布。assertListEqual() 比较两个 list 对象;如果任一参数不是列表,我们将得到一个更具体的错误消息,显示参数不是预期类型。
单独的测试目录
我们已经在被测试的代码的同一模块中显示了 TestCase 类的定义。对于小类来说,这可能是有帮助的。与类相关的一切都可以在一个模块文件中找到。
在较大的项目中,将测试文件隔离到一个单独的目录是常见做法。测试可能(而且通常)非常庞大。测试代码的数量可能比应用程序代码还要多,这并不是不合理的。
完成后,我们可以依赖unittest框架中的发现应用程序。该应用程序可以搜索给定目录的所有文件以寻找测试文件。通常,这些文件将是名称与模式test*.py匹配的文件。如果我们对所有测试模块使用简单、一致的名称,那么它们可以通过简单的命令定位并运行。
unittest加载器将在目录中搜索所有从TestCase类派生的类。这些类的集合在更大的模块集合中成为完整的TestSuite。我们可以使用os命令来做到这一点:
**$ python3 -m unittest discover -s tests**
这将在项目的tests目录中找到所有的测试。
另请参阅
- 我们将在结合 unittest 和 doctest 测试的示例中结合
unittest和doctest。我们将在模拟外部资源的示例中查看模拟外部对象。
结合 unittest 和 doctest 测试
在大多数情况下,我们将结合使用unittest和doctest测试用例。有关 doctest 的示例,请参阅使用文档字符串进行测试的示例。有关 unittest 的示例,请参阅创建单独的测试模块和包的示例。
doctest示例是模块、类、方法和函数的文档字符串的重要组成部分。unittest案例通常会在一个单独的tests目录中,文件的名称与模式test_*.py匹配。
我们如何将所有这些不同的测试组合成一个整洁的包呢?
准备工作
我们将回顾使用文档字符串进行测试的示例。这个示例为一个名为Summary的类创建了测试,该类执行一些统计计算。在那个示例中,我们在文档字符串中包含了示例。
该类开始如下:
class Summary:
'''Computes summary statistics.
>>> s = Summary()
>>> s.add(8)
>>> s.add(9)
>>> s.add(9)
>>> round(s.mean, 2)
8.67
>>> s.median
9
>>> print(str(s))
mean = 8.67
median = 9
'''
这里省略了方法,以便我们可以专注于文档字符串中提供的示例。
在创建单独的测试模块和包的示例中,我们编写了一些unittest.TestCase类来为这个类提供额外的测试。我们创建了类定义如下:
class GIVEN_Summary_WHEN_1k_samples_THEN_mean_median(unittest.TestCase):
def setUp(self):
self.summary = Summary()
self.data = list(range(1001))
random.shuffle(self.data)
for sample in self.data:
self.summary.add(sample)
def test_mean(self):
self.assertEqual(500, self.summary.mean)
def test_median(self):
self.assertEqual(500, self.summary.median)
这个测试创建了一个Summary对象;这是给定方面。然后向该Summary对象添加了许多值。这是测试的当方面。这两个test_方法实现了这个测试的两个然后方面。
通常可以看到一个项目文件夹结构,看起来像这样:
git-project-name/
statstools/
summary.py
tests/
test_summary.py
我们有一个顶层文件夹git-project-name,与源代码库中的项目名称匹配。我们假设正在使用 Git,但也可能使用其他工具。
在顶层目录中,我们将有一些对大型 Python 项目通用的开销。这将包括文件,如包含项目描述的README.rst,可以与pip一起使用的requirements.txt来安装额外的包,以及可能的setup.py来将包安装到标准库中。
目录statstools包含一个模块文件summary.py。这是我们提供有趣和有用功能的模块。该模块在代码中散布了文档字符串注释。
目录tests包含另一个模块文件test_summary.py。其中包含了unittest测试用例。我们选择了名称tests和test_*.py,以便它们与自动化测试发现很好地匹配。
我们需要将所有的测试组合成一个单一的、全面的测试套件。
我们将展示的示例使用ch11_r01而不是一些更酷的名称,比如summary。一个真实的项目通常有巧妙、有意义的名称。书籍内容非常庞大,名称设计得与整体章节和配方大纲相匹配。
如何做...
- 在本例中,我们假设 unittest 测试用例在与被测试代码分开的文件中。我们将有
ch11_r01和test_ch11_r01。
要使用 doctest 测试,导入doctest模块。我们将把 doctest 示例与TestCase类结合起来,创建一个全面的测试套件:
import unittest
import doctest
我们假设unittest的TestCase类已经就位,我们正在向测试套件中添加更多的测试。
- 导入正在测试的模块。这个模块将包含一些 doctests 的字符串:
import ch11_r01
- 要实现
load_tests协议,请在测试模块中包含以下函数:
def load_tests(loader, standard_tests, pattern):
return standard_tests
这个函数必须有这个名字才能被测试加载器找到。
- 要包含 doctest 测试,需要一个额外的加载器。我们将使用
doctest.DocTestSuite类来创建一个测试套件。这些测试将被添加到作为standard_tests参数值提供的测试套件中:
def load_tests(loader, standard_tests, pattern):
dt = doctest.DocTestSuite(ch11_r01)
standard_tests.addTests(dt)
return standard_tests
loader参数是当前正在使用的测试用例加载器。standard_tests值将是默认加载的所有测试。通常,这是所有TestCase的子类的测试套件。模式值是提供给加载器的值。
现在我们可以添加TestCase类和整体的unittest.main()函数,以创建一个包括 unittest TestCase和所有 doctest 示例的全面测试模块。
这可以通过包括以下代码来完成:
if __name__ == "__main__":
unittest.main()
这使我们能够运行模块并执行测试。
它是如何工作的...
当我们在这个模块中评估unittest.main()时,测试加载器的过程将被限制在当前模块中。加载器将找到所有扩展TestCase的类。这些是提供给load_tests()函数的标准测试。
我们将用doctest模块创建的测试来补充标准测试。通常,我们将能够导入被测试的模块,并使用DocTestSuite从导入的模块构建一个测试套件。
load_tests()函数会被unittest模块自动使用。这个函数可以对给定的测试套件执行各种操作。在这个例子中,我们用额外的测试补充了测试套件。
还有更多...
在某些情况下,一个模块可能非常复杂;这可能导致多个测试模块。可能会有几个测试模块,名称类似于tests/test_module_feature.py,或者类似的名称,以显示对一个复杂模块的多个功能进行了多次测试。
在其他情况下,我们可能有一个测试模块,其中包含对几个不同但密切相关的模块的测试。一个包可能被分解成多个模块。然而,一个单独的测试模块可能涵盖了被测试包中的所有模块。
当组合许多较小的模块时,可能会在load_tests()函数中构建多个测试套件。函数体可能如下所示:
def load_tests(loader, standard_tests, pattern):
for module in ch11_r01, ch11_r02, ch11_r03:
dt = doctest.DocTestSuite(module)
standard_tests.addTests(dt)
return standard_tests
这将包含来自多个模块的doctests。
另请参阅
- 有关 doctest 的示例,请参阅使用文档字符串进行测试配方。有关 unittest 的示例,请参阅创建单独的测试模块和包配方。

测试涉及日期或时间的事物
许多应用程序依赖于datetime.datetime.now()来创建时间戳。当我们在单元测试中使用它时,结果基本上是不可能预测的。我们在这里有一个依赖注入的问题,我们的应用程序依赖于一个我们希望只在测试时替换的类。
一个选择是避免使用now()和utcnow()。我们可以创建一个发出时间戳的工厂函数来代替直接使用这些函数。在测试目的中,这个函数可以被替换为产生已知结果的函数。在一个复杂的应用程序中避免使用now()方法似乎有些尴尬。
另一个选择是完全避免直接使用datetime类。这需要设计包装datetime类的类和模块。然后可以使用一个产生now()已知值的包装类进行测试。这也似乎是不必要的复杂。
我们如何处理datetime时间戳?
准备工作
我们将使用一个创建CSV文件的小函数。这个文件的名称将包括日期和时间。我们将创建类似于这样的名称的文件:
extract_20160704010203.json
这种文件命名约定可能会被长时间运行的服务器应用程序使用。该名称有助于匹配文件和相关的日志事件。它可以帮助跟踪服务器正在执行的工作。
我们将使用这样的函数来创建这些文件:
import datetime
import json
from pathlib import Path
def save_data(some_payload):
now_date = datetime.datetime.utcnow()
now_text = now_date.strftime('extract_%Y%m%d%H%M%S')
file_path = Path(now_text).with_suffix('.json')
with file_path.open('w') as target_file:
json.dump(some_payload, target_file, indent=2)
这个函数使用了utcnow()。从技术上讲,可以重新设计函数并将时间戳作为参数提供。在某些情况下,这种重新设计可能会有所帮助。还有一个方便的替代重新设计的方法。
我们将创建datetime模块的模拟版本,并修补测试上下文以使用模拟版本而不是实际版本。这个测试将包含datetime类的模拟类定义。在该类中,我们将提供一个模拟的utcnow()方法,该方法将提供预期的响应。
由于被测试的函数创建了一个文件,我们需要考虑这个操作系统的后果。当同名文件已经存在时应该发生什么?应该引发异常吗?文件名应该添加后缀吗?根据我们的设计决定,我们可能需要有两个额外的测试用例:
-
给出一个没有冲突的目录。在这种情况下,一个
setUp()方法来删除任何先前的测试输出。我们可能还想创建一个tearDown()方法来在测试后删除文件。 -
给出一个具有冲突名称的目录。在这种情况下,一个
setUp()方法将创建一个冲突的文件。我们可能还想创建一个tearDown()方法来在测试后删除文件。
对于这个示例,我们将假设重复的文件名并不重要。新文件应该简单地覆盖任何先前的文件,而不会发出警告或通知。这很容易实现,并且通常适用于现实世界的情况,即在不到 1 秒的时间内创建多个文件没有理由。
如何做...
- 对于这个示例,我们将假设
unittest测试用例与被测试的代码是同一个模块。导入unittest和unittest.mock模块:
import unittest
from unittest.mock import *
unittest模块只是被导入。要使用这个模块的特性,我们必须用unittest.来限定名称。从unittest.mock导入了所有名称,因此可以在没有任何限定符的情况下使用这些名称。我们将使用模拟模块的许多特性,而且长的限定名称很笨拙。
-
包括要测试的代码。这是之前显示的。
-
为测试创建以下骨架。我们提供了一个类定义,以及一个可以用来执行测试的主脚本:
class GIVEN_data_WHEN_save_data_THEN_file(unittest.TestCase):
def setUp(self):
'''GIVEN conditions for the test.'''
def runTest(self):
'''WHEN and THEN conditions for this test.''''
if __name__ == "__main__":
unittest.main()
我们没有定义load_tests()函数,因为我们没有任何文档字符串测试要包含。
setUp()方法将有几个部分:
- 要处理的示例数据:
self.data = {'primes': [2, 3, 5, 7, 11, 13, 17, 19]}
datetime模块的模拟对象。这个对象提供了被测试单元使用的精确特性。Mock模块包含了datetime类的一个单一Mock类定义。在该类中,它提供了一个单一的模拟方法utcnow(),它总是提供相同的响应:
self.mock_datetime = Mock(
datetime = Mock(
utcnow = Mock(
return_value = datetime.datetime(2017, 7, 4, 1, 2, 3)
)
)
)
- 给出上面显示的
datetime对象的预期文件名:
self.expected_name = 'extract_20170704010203.json'
- 需要进行一些额外的配置处理来建立Given条件。我们将删除要完全确保测试断言不使用来自先前测试运行的文件的任何先前版本:
self.expected_path = Path(self.expected_name)
if self.expected_path.exists():
self.expected_path.unlink()
runTest()方法将有两个部分:
- When处理。这将修补当前模块
__main__,以便将对datetime的引用替换为self.mock_datetime对象。然后在修补的上下文中执行请求:
with patch('__main__.datetime', self.mock_datetime):
save_data(self.data)
- Then处理。在这种情况下,我们将打开预期的文件,加载内容,并确认结果与源数据匹配。这将以必要的断言结束。如果文件不存在,这将引发
IOError异常:
with self.expected_path.open() as result_file:
result_data = json.load(result_file)
self.assertDictEqual(self.data, result_data)
它是如何工作的...
unittest.mock模块在这里有两个有价值的组件——Mock对象定义和patch()函数。
当我们创建Mock类的实例时,必须提供结果对象的方法和属性。当我们提供一个命名参数值时,这将被保存为结果对象的属性。简单的值成为对象的属性。基于Mock对象的值成为方法函数。
当我们创建一个提供return_value(或side_effect)命名参数值的Mock实例时,我们正在创建一个可调用的对象。这是一个行为像一个非常愚蠢的函数的模拟对象的例子:
**>>> from unittest.mock import *
>>> dumb_function = Mock(return_value=12)
>>> dumb_function(9)
12
>>> dumb_function(18)
12**
我们创建了一个模拟对象dumb_function,它将表现得像一个可调用的函数,只返回值12。对于单元测试来说,这可能非常方便,因为结果是简单和可预测的。
更重要的是Mock对象的这个特性:
**>>> dumb_function.mock_calls
[call(9), call(18)]**
dumb_function()跟踪了每次调用。然后我们可以对这些调用进行断言。例如,assert_called_with()方法检查历史记录中的最后一次调用:
**>>> dumb_function.assert_called_with(18)**
如果最后一次调用确实是dumb_function(18),那么这将悄无声息地成功。如果最后一次调用不符合断言,那么会引发一个AssertionError异常,unittest模块将捕获并注册为测试失败。
我们可以像这样看到更多细节:
**>>> dumb_function.assert_has_calls( [call(9), call(18)] )**
这个断言检查整个调用历史。它使用Mock模块的call()函数来描述函数调用中提供的参数。
patch()函数可以进入模块的上下文并更改该上下文中的任何引用。在这个例子中,我们使用patch()来调整__main__模块中的定义——当前正在运行的模块。在许多情况下,我们会导入另一个模块,并且需要对导入的模块进行修补。重要的是要到达对被测试模块有效的上下文并修补该引用。
还有更多...
在这个例子中,我们为datetime模块创建了一个模拟,它具有非常狭窄的功能集。
该模块只有一个元素,即Mock类的一个实例,名为datetime。对于单元测试,模拟的类通常表现得像一个返回对象的函数。在这种情况下,该类返回了一个Mock对象。
代替datetime类的Mock对象有一个属性utcnow()。我们在定义这个属性时使用了特殊的return_value关键字,以便它返回一个固定的datetime实例。我们可以扩展这种模式,并模拟多个属性以表现得像一个函数。这是一个模拟utcnow()和now()的例子:
self.mock_datetime = Mock(
datetime = Mock(
utcnow = Mock(
return_value = datetime.datetime(2017, 7, 4, 1, 2, 3)
),
now = Mock(
return_value = datetime.datetime(2017, 7, 4, 4, 2, 3)
)
)
)
两个模拟的方法,utcnow()和now(),分别创建了不同的datetime对象。这使我们能够区分这些值。我们可以更容易地确认单元测试的正确操作。
请注意,所有这些Mock对象的构造都是在setUp()方法中执行的。这是在patch()函数进行修补之前很久。在setUp()期间,datetime类是可用的。在with语句的上下文中,datetime类不可用,并且被Mock对象替换。
我们可以添加以下断言来确认utcnow()函数被单元测试正确使用:
self.mock_datetime.datetime.utcnow.assert_called_once_with()
这将检查self.mock_datetime模拟对象。它在这个对象中查看datetime属性,我们已经定义了一个utcnow属性。我们期望这个属性被调用一次,没有参数值。
如果save_data()函数没有正确调用utcnow(),这个断言将检测到失败。测试接口的两侧是至关重要的。这导致了测试的两个部分:
-
模拟的
datetime的结果被被测试的单元适当地使用 -
被测试的单元对模拟的
datetime对象发出了适当的请求
在某些情况下,我们可能需要确认一个已过时或不推荐使用的方法从未被调用。我们可能会有类似这样的内容来确认另一个方法没有被使用:
self.assertFalse( self.mock_datetime.datetime.called )
这种类型的测试在重构软件时使用。在这个例子中,之前的版本可能使用了now()方法。更改后,函数需要使用utcnow()方法。我们已经包含了一个测试,以确保不再使用now()方法。
另请参阅
- 创建单独的测试模块和包的配方中有关
unittest模块的基本使用的更多信息
测试涉及随机性的事物
许多应用程序依赖于random模块来创建随机值或将值随机排序。在许多统计测试中,会进行重复的随机洗牌或随机子集计算。当我们想要测试其中一个算法时,结果基本上是不可能预测的。
我们有两种选择来尝试使random模块足够可预测,以编写有意义的单元测试:
-
设置一个已知的种子值,这是常见的,在许多其他配方中我们已经大量使用了这个。
-
使用
unittest.mock来用一些不太随机的东西替换random模块。
如何对涉及随机性的算法进行单元测试?
准备工作
给定一个样本数据集,我们可以计算统计量,如均值或中位数。一个常见的下一步是确定这些统计量对于一些整体人口的可能值。这可以通过一种称为自助法的技术来完成。
这个想法是反复对初始数据集进行重采样。每个重采样提供了统计量的不同估计。这个整体的重采样指标集显示了整体人口的测量可能方差。
为了确保重采样算法有效,有助于从处理中消除随机性。我们可以使用random.choice()函数的非随机版本对精心策划的数据集进行重采样。如果这样可以正常工作,那么我们有理由相信真正的随机版本也会正常工作。
这是我们的候选重采样函数。我们需要验证这一点,以确保它正确地进行了带替换的抽样:
def resample(population, N):
for i in range(N):
sample = random.choice(population)
yield sample
我们通常会应用resample()函数来填充一个Counter对象,用于跟踪特定测量值的每个不同值,例如均值。整体的重采样过程如下:
mean_distribution = Counter()
for n in range(1000):
subset = list(resample(population, N))
measure = round(statistics.mean(subset), 1)
mean_distribution[measure] += 1
这评估了resample()函数1,000次。这将导致许多子集,每个子集可能具有不同的均值。这些值用于填充mean_distribution对象。
mean_distribution的直方图将为人口方差提供有意义的估计。这个方差的估计将有助于显示人口最可能的实际均值。
如何做...
- 定义整体测试类的大纲:
class GIVEN_resample_WHEN_evaluated_THEN_fair(unittest.TestCase):
def setUp(self):
def runTest(self):
if __name__ == "__main__":
unittest.main()
我们已经包含了一个主程序,这样我们就可以简单地运行模块来测试它。在使用诸如 IDLE 之类的工具时,这很方便;我们可以在进行更改后使用F5键来测试模块。
- 定义
random.choice()函数的模拟版本。我们将提供一个模拟数据集self.data,以及对choice()函数的模拟响应:
self.expected_resample_data.self.data = [2, 3, 5, 7, 11, 13, 17, 19]
self.expected_resample_data = [23, 29, 31, 37, 41, 43, 47, 53]
self.mock_random = Mock(
choice = Mock(
side_effect = self.expected_resample_data
)
)
我们使用side_effect属性定义了choice()函数。这将从给定序列中一次返回一个值。我们提供了八个模拟值,这些值与源序列不同,因此我们可以很容易地识别choice()函数的输出。
- 定义测试的When和Then方面。在这种情况下,我们将修补
__main__模块,以替换对random模块的引用。然后测试可以建立结果是否具有预期的值,并且choice()函数是否被多次调用:
with patch('__main__.random', self.mock_random):
resample_data = list(resample(self.data, 8))
self.assertListEqual(self.expected_resample_data, resample_data)
self.mock_random.choice.assert_has_calls( 8*[call(self.data)] )
工作原理...
当我们创建Mock类的实例时,必须提供生成对象的方法和属性。当Mock对象包括一个命名参数值时,这将被保存为生成对象的属性。
当我们创建一个提供side_effect命名参数值的Mock实例时,我们正在创建一个可调用对象。可调用对象将从side_effect列表中返回一个值,每次调用Mock对象时。
这是一个行为像一个非常愚蠢的函数的模拟对象的例子:
**>>> from unittest.mock import *
>>> dumb_function = Mock(side_effect=[11,13])
>>> dumb_function(23)
11
>>> dumb_function(29)
13
>>> dumb_function(31)
Traceback (most recent call last):
... (traceback details omitted)
StopIteration**
首先,我们创建了一个Mock对象,并将其分配给名称dumb_function。这个Mock对象的side_effect属性提供了一个将返回的两个不同值的短列表。
然后的例子使用两个不同的参数值两次评估dumb_function()。每次,下一个值从side_effect列表中返回。第三次尝试引发了一个StopIteration异常,导致了测试失败。
这种行为使我们能够编写一个测试,检测函数或方法的某些不当使用。如果函数被调用太多次,将引发异常。其他不当使用必须使用各种断言来检测可以用于Mock对象的各种类型。
还有更多...
我们可以轻松地用提供适当行为的模拟对象替换random模块的其他特性,而不实际上是随机的。例如,我们可以用一个提供已知顺序的函数替换shuffle()函数。我们可以像这样遵循上面的测试设计模式:
self.mock_random = Mock(
choice = Mock(
side_effect = self.expected_resample_data
),
shuffle = Mock(
return_value = self.expected_resample_data
)
)
这个模拟的shuffle()函数返回一组不同的值,可以用来确认某个过程是否正确使用了random模块。
另请参阅
-
在第四章中,内置数据结构-列表、集合、字典,使用集合方法和运算符,创建字典-插入和更新配方,以及第五章中的用户输入和输出,使用 cmd 创建命令行应用程序配方,展示了如何种子随机数生成器以创建可预测的值序列。
-
在第六章中,类和对象的基础,还有其他几个配方展示了另一种方法,例如使用类封装数据+处理,设计具有大量处理的类,使用 slots 优化小对象和使用惰性属性。
-
此外,在第七章中,更高级的类设计,请参阅选择继承和扩展之间的选择-是一个问题,通过多重继承分离关注,利用 Python 的鸭子类型,创建一个具有可排序对象的类和定义一个有序集合配方。
模拟外部资源
涉及日期或时间的测试和涉及随机性的测试配方展示了模拟相对简单对象的技术。在涉及日期或时间的测试配方中,被模拟的对象基本上是无状态的,一个返回值就可以很好地工作。在涉及随机性的测试配方中,对象有一个状态变化,但状态变化不依赖于任何输入参数。
在这些更简单的情况下,测试提供了一系列请求给一个对象。可以构建基于已知和精心计划的状态变化序列的模拟对象。测试用例精确地遵循对象的内部状态变化。这有时被称为白盒测试,因为需要定义测试序列和模拟对象的实现细节。
然而,在某些情况下,测试场景可能不涉及明确定义的状态更改序列。被测试的单元可能以难以预测的顺序发出请求。这有时是黑盒测试的结果,其中实现细节是未知的。
我们如何创建更复杂的模拟对象,这些对象具有内部状态并进行自己的内部状态更改?
准备工作
我们将研究如何模拟有状态的 RESTful Web 服务请求。在这种情况下,我们将使用弹性数据库的数据库 API。有关此数据库的更多信息,请参见www.elastic.co/。该数据库具有使用简单的 RESTful Web 服务的优势。这些可以很容易地模拟为简单、快速的单元测试。
对于这个配方,我们将测试一个使用 RESTful API 创建记录的函数。表述性状态转移(REST)是一种使用超文本传输协议(HTTP)在进程之间传输对象状态表示的技术。例如,要创建一个数据库记录,客户端将使用 HTTP POST请求将对象状态的表示传输到数据库服务器。在许多情况下,JSON 表示法用于表示对象状态。
测试这个函数将涉及模拟urllib.request模块的一部分。替换urlopen()函数将允许测试用例模拟数据库活动。这将允许我们测试依赖于 Web 服务的函数,而不实际进行可能昂贵或缓慢的外部请求。
在我们的应用软件中,有两种总体方法可以使用弹性搜索 API:
- 我们可以在我们的笔记本电脑或一些我们可以访问的服务器上安装弹性数据库。安装是一个两部分的过程,首先安装适当的Java 开发工具包(JDK),然后安装 ElasticSearch 软件。我们不会在这里详细介绍,因为我们有一个似乎更简单的替代方案。
在本地计算机上创建和访问对象的 URL 将如下所示:
http://localhost:9200/eventlog/event/
请求将在请求的正文中使用多个数据项。这些请求不需要任何 HTTP 头部用于安全或认证目的。
- 我们可以使用诸如
orchestrate.io之类的托管服务。这需要注册该服务以获取 API 密钥,而不是安装软件。API 密钥授予对定义应用程序的访问权限。在应用程序中,可以创建多个集合。由于我们不必安装额外的软件,这似乎是一个方便的方法。
在远程服务器上处理对象的 URL 将如下所示:
https://api.orchestrate.io/v0/eventlog/
请求还将使用多个 HTTP 头部向主机提供信息。接下来,我们将详细了解这项服务。
要创建的文档的数据有效载荷将如下所示:
{
"timestamp": "2016-06-15T17:57:54.715",
"levelname": "INFO",
"module": "ch09_r10",
"message": "Sample Message One"
}
这个 JSON 文档代表了一个日志条目。这是在之前的示例中使用的sample.log文件中提取的。这个文档可以被理解为将保存在数据库的eventlog索引中的事件类型的特定实例。该对象有四个属性,其值为字符串。
在第九章的使用正则表达式读取复杂格式配方中,输入/输出、物理格式和逻辑布局,展示了如何解析复杂的日志文件。在使用多个上下文读写文件的配方中,复杂的日志记录被写入了CSV文件。在这个例子中,我们将展示如何将日志记录放入使用弹性等数据库的基于云的存储中。
在 entrylog 集合中创建一个条目文档
我们将在数据库的entrylog集合中创建条目文档。使用 HTTP POST请求创建新项目。201 Created的响应将表明数据库创建了新事件。
要使用 orchestrate.io 数据库服务,每个请求都有一个基本 URL。我们可以用这样的字符串来定义它:
service = "https://api.orchestrate.io"
使用 https 方案是为了确保数据在客户端和服务器之间是私密的,使用 SSL 。主机名是 api.orchestrate.io。每个请求将基于这个基本服务定义的 URL。
每个请求的 HTTP 头将如下所示:
headers = {
'Accept': 'application/json',
'Content-Type': 'application/json',
'Authorization': basic_header(api_key, '')
}
Accept 头显示期望的响应类型。Content-Type 头显示内容所使用的文档表示类型。这两个头指示数据库使用 JSON 表示对象状态。
Authorization 头是 API 密钥的发送方式。这个头的值是一个相当复杂的字符串。最容易的方法是构建编码的 API 密钥字符串代码如下:
import base64
def basic_header(username, password):
combined_bytes = (username + ':' + password).encode('utf-8')
encoded_bytes = base64.b64encode(combined_bytes)
return 'Basic ' + encoded_bytes.decode('ascii')
这段代码将把用户名和密码组合成一个字符流,然后使用 UTF-8 编码方案将这些字符编码为字节流。base64 模块创建了第二个字节流。在这个输出流中,四个字节将包含构成三个输入字节的位。这些字节是从一个简化的字母表中选择的。然后将这个值与关键字 'Basic ' 转换回 Unicode 字符。这个值可以与 Authorization 头一起使用。
通过创建一个 Request 对象来使用 RESTful API 是最容易的。该类在 urllib.request 模块中定义。Request 对象结合了数据、URL 和头,并命名了特定的 HTTP 方法。以下是创建 Request 实例的代码:
data_document = {
"timestamp": "2016-06-15T17:57:54.715",
"levelname": "INFO",
"module": "ch09_r10",
"message": "Sample Message One"
}
headers={
'Accept': 'application/json',
'Content-Type': 'application/json',
'Authorization': basic_header(api_key, '')
}
request = urllib.request.Request(
url=service + '/v0/eventlog',
headers=headers,
method='POST',
data=json.dumps(data_document).encode('utf-8')
)
请求对象包括四个元素:
-
url参数的值是基本服务 URL 加上集合名称,/v0/eventlog。路径中的v0是必须在每个请求中提供的版本信息。 -
headers参数包括具有授权访问应用程序的 API 密钥的Authorization头。 -
POST方法将在数据库中创建一个新对象。 -
data参数是要保存的文档。我们已经将一个 Python 对象转换为 JSON 表示的字符串。然后使用UTF-8编码将 Unicode 字符编码为字节。
查看典型的响应
处理涉及发送请求和接收响应。urlopen() 函数接受 Request 对象作为参数;这构建了发送到数据库服务器的请求。来自数据库服务器的响应将包括三个元素:
-
状态。这包括一个数字代码和一个原因字符串。创建文档时,预期的响应代码是
201,字符串是CREATED。对于许多其他请求,代码是200,字符串是OK。 -
响应还将包括头信息。对于创建请求,这些将包括以下内容:
[
('Content-Type', 'application/json'),
('Location', '/v0/eventlog/12950a87ef024e43/refs/8e50b6bfc50b2dfa'),
('ETag', '"8e50b6bfc50b2dfa"'),
...
]
Content-Type 头告诉我们内容是以 JSON 编码的。Location 头提供了一个 URL,可以用来检索创建的对象。它还提供了一个 ETag 头,这是对象当前状态的哈希摘要;这有助于支持缓存对象的本地副本。其他头可能存在;我们在示例中只显示了 ... 。
- 响应可能有一个主体。如果存在,这将是从数据库检索到的一个 JSON 编码的文档(或文档)。必须使用响应的
read()方法来读取主体。主体可能非常大;Content-Length头提供了确切的字节数。
数据库访问的客户端类
我们将为数据库访问定义一个简单的类。一个类可以为多个相关操作提供上下文和状态信息。在使用 Elastic 数据库时,访问类可以只创建一次请求头字典,并在多个请求中重复使用。
这是数据库客户端类的本质。我们将在几个部分中展示这一点。首先是整个类的定义:
class ElasticClient:
service = "https://api.orchestrate.io"
这定义了一个类级别的变量service,带有方案和主机名。初始化方法__init__()可以构建各种数据库操作中使用的标头:
def __init__(self, api_key, password=''):
self.headers = {
'Accept': 'application/json',
'Content-Type': 'application/json',
'Authorization': ElasticClient.basic_header(api_key, password),
}
这个方法接受 API 密钥并创建一组依赖于 HTTP 基本授权的标头。密码不会被编排服务使用。但我们已经包含了它,因为用户名和密码用于示例单元测试用例。
这是方法:
@staticmethod
def basic_header(username, password=''):
"""
>>> ElasticClient.basic_header('Aladdin', 'OpenSesame')
'Basic QWxhZGRpbjpPcGVuU2VzYW1l'
"""
combined_bytes = (username + ':' + password).encode('utf-8')
encoded_bytes = base64.b64encode(combined_bytes)
return 'Basic ' + encoded_bytes.decode('ascii')
这个函数可以将用户名和密码组合起来,创建 HTTPAuthorization标头的值。orchestrate.io API 使用分配的 API 密钥作为用户名;密码是一个零长度的字符串''。当有人注册他们的服务时,API 密钥就被分配了。免费级别的服务允许合理数量的交易和一个舒适小的数据库。
我们已经包含了一个以文档字符串形式的单元测试用例。这提供了结果正确的证据。测试用例来自维基百科关于 HTTP 基本认证的页面。
最后一部分是一个将一个数据项加载到数据库的eventlog集合中的方法:
def load_eventlog(self, data_document):
request = urllib.request.Request(
url=self.service + '/v0/eventlog',
headers=self.headers,
method='POST',
data=json.dumps(data_document).encode('utf-8')
)
with urllib.request.urlopen(request) as response:
assert response.status == 201, "Insertion Error"
response_headers = dict(response.getheaders())
return response_headers['Location']
这个函数使用四个必需的信息构建一个Request对象——完整的 URL、HTTP 标头、方法字符串和编码数据。在这种情况下,数据被编码为 JSON 字符串,并使用UTF-8编码方案将 JSON 字符串编码为字节。
评估urlopen()函数会发送请求并检索一个响应对象。这个对象被用作上下文管理器。with语句确保即使在响应处理过程中引发异常,资源也会被正确释放。
POST方法应该以201状态响应。任何其他状态都是问题。在这段代码中,状态是通过assert语句进行检查的。最好提供一条消息,比如Expected 201 status, got {}.format(response.status)。
然后检查标头以获取Location标头。这提供了一个用于定位已创建对象的 URL 片段。
如何做...
-
创建数据库访问模块。这个模块将包含
ElasticClient类定义。它还将包含这个类需要的任何其他定义。 -
这个示例将使用
unittest和doctest来创建一个统一的测试套件。它将使用unittest.mock中的Mock类,以及json。由于这个模块是与被测试的单元分开的,它需要导入ch11_r08_load,该模块包含将被测试的类定义:
import unittest
from unittest.mock import *
import doctest
import json
import ch11_r08_load
- 这是一个测试用例的整体框架。我们将在下面填写这个测试的
setUp()和runTest()方法。名称显示了当我们调用load_eventlog()时,我们得到了一个ElasticClient实例,然后进行了一个正确的 RESTful API 请求:
class GIVEN_ElasticClient_WHEN_load_eventlog_THEN_request(unittest.TestCase):
def setUp(self):
def runTest(self):
setUp()方法的第一部分是一个模拟上下文管理器,提供类似于urlopen()函数的响应:
def setUp(self):
# The context manager object itself.
self.mock_context = Mock(
__exit__ = Mock(return_value=None),
__enter__ = Mock(
side_effect = self.create_response
),
)
# The urlopen() function that returns a context.
self.mock_urlopen = Mock(
return_value = self.mock_context,
)
当调用urlopen()时,返回值是一个行为像上下文管理器的响应对象。模拟这个的最佳方法是返回一个模拟上下文管理器。模拟上下文管理器的__enter__()方法执行真正的工作来创建响应对象。在这种情况下,side_effect属性标识了一个辅助函数,该函数将被调用来准备从调用__enter__()方法的结果。self.create_response还没有被定义。我们将使用一个函数,定义如下。
setUp()方法的第二部分是一些要加载的模拟数据:
# The test document.
self.document = {
"timestamp": "2016-06-15T17:57:54.715",
"levelname": "INFO",
"module": "ch09_r10",
"message": "Sample Message One"
}
在一个更复杂的测试中,我们可能想要模拟一个大型的可迭代文档集合。
- 这是一个
create_response()辅助方法,用于构建类似响应的对象。响应对象可能很复杂,因此我们定义了一个函数来创建它们:
def create_response(self):
self.database_id = hex(hash(self.mock_urlopen.call_args[0][0].data))[2:]
self.location = '/v0/eventlog/{id}'.format(id=self.database_id)
response_headers = [
('Location', self.location),
('ETag', self.database_id),
('Content-Type', 'application/json'),
]
return Mock(
status = 201,
getheaders = Mock(return_value=response_headers)
)
这个方法使用self.mock_urlopen.call_args来检查对这个Mock对象的最后一次调用。这个调用的参数是一个包含位置参数值和关键字参数的元组。第一个[0]索引从元组中选择位置参数值。第二个[0]索引选择第一个位置参数值。这将是要加载到数据库中的对象。hex()函数的值是一个包含0x前缀的字符串,我们将其丢弃。
在更复杂的测试中,可能需要这个方法来保持一个加载到数据库中的对象的缓存,以便更准确地模拟类似数据库的响应。
runTest()方法对被测试的模块进行了补丁。它定位了从ch11_r08_load到urllib.request和urlopen()函数的引用。这些引用被替换为mock_urlopen替代品:
def runTest(self):
with patch('ch11_r08_load.urllib.request.urlopen', self.mock_urlopen):
client = ch11_r08_load.ElasticClient('Aladdin', 'OpenSesame')
response = client.load_eventlog(self.document)
self.assertEqual(self.location, response)
call_request = self.mock_urlopen.call_args[0][0]
self.assertEqual(
'https://api.orchestrate.io/v0/eventlog', call_request.full_url)
self.assertDictEqual(
{'Accept': 'application/json',
'Authorization': 'Basic QWxhZGRpbjpPcGVuU2VzYW1l',
'Content-type': 'application/json'
},
call_request.headers)
self.assertEqual('POST', call_request.method)
self.assertEqual(
json.dumps(self.document).encode('utf-8'), call_request.data)
self.mock_context.__enter__.assert_called_once_with()
self.mock_context.__exit__.assert_called_once_with(None, None, None)
这个测试遵循ElasticClient首先创建一个客户端对象的要求。它不使用实际的 API 密钥,而是使用用户名和密码,这将为Authorization头创建一个已知的值。load_eventlog()的结果是一个类似响应的对象,可以检查它是否具有正确的值。
所有这些交互都将通过模拟对象完成。我们可以使用各种断言来确认是否创建了一个正确的请求对象。测试检查请求对象的四个属性,并确保上下文的使用是否正确。
- 我们还将定义一个
load_tests()函数,将这个unittest套件与ch11_r08_load的文档字符串中找到的任何测试示例结合起来:
def load_tests(loader, standard_tests, pattern):
dt = doctest.DocTestSuite(ch11_r08_load)
standard_tests.addTests(dt)
return standard_tests
- 最后,我们将提供一个整体的主程序来运行完整的测试套件。这样可以很容易地将测试模块作为独立的脚本运行:
if __name__ == "__main__":
unittest.main()
工作原理...
这个示例结合了许多unittest和doctest特性,创建了一个复杂的测试用例。这些特性包括:
-
创建上下文管理器
-
使用 side-effect 功能创建动态、有状态的测试
-
模拟复杂对象
-
使用加载测试协议来结合 doctest 和 unittest 案例
我们将分别查看这些特性。
创建上下文管理器
上下文管理器协议在对象外部包装了一个额外的间接层。有关此内容的更多信息,请参阅使用上下文管理器读写文件和使用多个上下文读写文件的示例。必须模拟的核心特性是__enter__()和__exit__()方法。
模拟上下文管理器的模式如下:
self.mock_context = Mock(
__exit__ = Mock(return_value=None),
__enter__ = Mock(
side_effect = self.create_response
# or
# return_value = some_value
),
)
上下文管理器对象有两个属性。__exit__()将被调用一次。True的返回值将使任何异常静音。None或False的返回值将允许异常传播。
__enter__()方法返回在with语句中分配的对象。在这个例子中,我们使用了side_effect属性并提供了一个函数,以便可以计算动态结果。
__enter__()方法的一个常见替代方法是使用固定的return_value属性,并每次提供相同的管理器对象。还可以使用side_effect提供一个序列;在这种情况下,每次调用该方法时,都会返回序列中的另一个对象。
创建动态、有状态的测试
在许多情况下,测试可以使用静态的、固定的对象集。模拟响应可以在setUp()方法中定义。然而,在某些情况下,对象的状态可能需要在复杂测试的操作过程中发生变化。在这种情况下,可以使用Mock对象的side_effect属性来跟踪状态变化。
在这个例子中,side_effect属性使用create_response()方法来构建动态响应。side_effect引用的函数可以做任何事情;这可以用来更新动态状态信息,用于计算复杂的响应。
这里有一个微妙的界限。一个复杂的测试用例可能会引入自己的错误。通常最好尽可能简单地保持测试用例,以避免不得不编写元测试来测试测试用例。
对于非平凡的测试,确保测试实际上可以失败很重要。有些测试涉及无意的同义反复。可能会创建一个人为的测试,其意义与self.assertEqual(4, 2+2)一样。为了确保测试实际上使用了被测试的单元,当代码缺失或注入了错误时,它应该失败。
模拟一个复杂对象
urlopen()的响应对象具有大量的属性和方法。对于我们的单元测试,我们只需要设置其中的一些特性。
我们使用了以下内容:
return Mock(
status = 201,
getheaders = Mock(return_value=response_headers)
)
这创建了一个具有两个属性的Mock对象:
-
status属性有一个简单的数值。 -
getheaders属性使用了一个Mock对象,具有return_value属性来创建一个方法函数。这个方法函数返回了动态的response_headers值。
response_headers的值是一个包含(key, value)对的两元组序列。这种响应头的表示可以很容易地转换成字典。
对象是这样构建的:
response_headers = [
('Location', self.location),
('ETag', self.database_id),
('Content-Type', 'application/json'),
]
这设置了三个头:Location,ETag和Content-Type。根据测试用例可能需要其他头。重要的是不要在测试用例中添加未使用的头部。这种混乱可能导致测试本身的错误。
数据库 id 和位置是基于以下计算:
hex(hash(self.mock_urlopen.call_args[0][0].data))[2:]
这使用了self.mock_urlopen.call_args来检查提供给测试用例的参数。call_args属性的值是一个包含位置参数和关键字参数值的二元组。位置参数也是一个元组。这意味着call_args[0]是位置参数,call_args[0][0]是第一个位置参数。这将是加载到数据库的文档。
许多 Python 对象都有哈希值。在这种情况下,预期对象是由json.dumps()函数创建的字符串。这个字符串的哈希值是一个大数。该数字的十六进制值将是一个带有0x前缀的字符串。我们将使用[2:]切片来忽略前缀。有关此信息,请参见第一章中的重写不可变字符串一节,数字、字符串和元组。
使用 load_tests 协议
一个复杂的模块将包括类和函数定义。整个模块需要一个描述性的文档字符串。每个类和函数都需要一个文档字符串。类中的每个方法也需要一个文档字符串。这将提供关于模块、类、函数和方法的基本信息。
此外,每个文档字符串都可以包含一个示例。这些示例可以通过doctest模块进行测试。有关示例的信息,请参见使用文档字符串进行测试一节。我们可以将文档字符串示例测试与更复杂的单元测试结合起来。有关如何执行此操作的更多信息,请参见结合 unittest 和 doctest 测试一节。
还有更多...
unittest模块也可以用于构建集成测试。集成测试的想法是避免模拟,实际上在测试模式下使用真实的外部服务。这可能会很慢或很昂贵;通常要避免集成测试,直到所有单元测试提供了软件可能正常工作的信心。
例如,我们可以使用orchestrate.io创建两个应用程序——真实应用程序和测试应用程序。这将为我们提供两个 API 密钥。测试密钥将被用于将数据库重置为初始状态,而不会为真实数据的实际用户创建问题。
我们可以使用unittest、setUpModule()和tearDownModule()函数来控制这一切。setUpModule()函数在给定模块文件中的所有测试之前执行。这是设置数据库为已知状态的一种方便方式。
我们还可以使用tearDownModule()函数来删除数据库。这对于删除测试创建的不必要的资源非常方便。有时为了调试目的,保留资源可能更有帮助。因此,tearDownModule()函数可能不像setUpModule()函数那样有用。
另请参阅
-
涉及日期或时间的测试和涉及随机性的测试配方展示了技巧。
-
在第九章的输入/输出、物理格式和逻辑布局中,使用正则表达式读取复杂格式配方展示了如何解析复杂的日志文件。在使用多个上下文读写文件配方中,复杂的日志记录被写入了一个
CSV文件。 -
有关如何切割字符串以替换部分内容的信息,请参阅重写不可变字符串配方。
-
这些内容的一部分可以通过
doctest模块进行测试。请参阅使用文档字符串进行测试配方以获取示例。将这些测试与任何 doctests 结合起来也很重要。有关如何执行此操作的更多信息,请参阅结合 unittest 和 doctest 测试配方。
第十二章:Web 服务
在本章中,我们将查看以下配方:
-
使用 WSGI 实现 Web 服务
-
使用 Flask 框架进行 RESTful API
-
解析请求中的查询字符串
-
使用 urllib 进行 REST 请求
-
解析 URL 路径
-
解析 JSON 请求
-
为 Web 服务实施身份验证
介绍
提供 Web 服务涉及解决几个相互关联的问题。必须遵循一些适用的协议,每个协议都有其独特的设计考虑。Web 服务的核心是定义 HTTP 的各种标准。
HTTP 涉及两方;客户端和服务器:
-
客户端向服务器发出请求
-
服务器向客户端发送响应
这种关系是高度不对称的。我们期望服务器处理来自多个客户端的并发请求。因为客户端请求是异步到达的,服务器不能轻易区分那些来自单个人类用户的请求。通过设计提供会话令牌(或 cookie)来跟踪人类当前状态的服务器来实现人类用户会话的概念。
HTTP 协议是灵活和可扩展的。HTTP 的一个流行用例是以网页的形式提供内容。网页通常被编码为 HTML 文档,通常包含指向图形、样式表和 JavaScript 代码的链接。我们已经在第九章的读取 HTML 文档中查看了解析 HTML 的信息,输入/输出、物理格式和逻辑布局。
提供网页内容进一步分解为两种内容:
-
静态内容本质上是文件的下载。诸如 GUnicorn、NGINGX 或 Apache HTTPD 之类的程序可以可靠地提供静态文件。每个 URL 定义了文件的路径,服务器将文件下载到浏览器。
-
动态内容是根据需要由应用程序构建的。在这种情况下,我们将使用 Python 应用程序响应请求构建唯一的 HTML(或可能是图形)。
HTTP 的另一个非常流行的用例是提供 Web 服务。在这种情况下,标准的 HTTP 请求和响应将以 HTML 以外的格式交换数据。编码信息的最流行格式之一是 JSON。我们已经在第九章的读取 JSON 文档中查看了处理 JSON 文档的信息,输入/输出、物理格式和逻辑布局。
Web 服务可以被视为使用 HTTP 提供动态内容的一种变体。客户端可以准备 JSON 文档。服务器包括一个创建 JSON 表示的 Python 应用程序。
在某些情况下,服务的焦点非常狭窄。将服务和数据库持久性捆绑到一个单一的包中是可能的。这可能涉及创建一个具有基于 NGINX 的 Web 界面以及使用 MongoDB 或 Elastic 的数据库的服务器。整个包 - Web 服务加持久性 - 可以称为微服务。
Web 服务交换的文档编码了对象状态的表示。JavaScript 中的客户端应用程序可能具有发送到服务器的对象状态。Python 中的服务器可能会将对象状态的表示传输给客户端。这被称为表述性状态转移(REST)。使用 REST 处理的服务通常被称为 RESTful。
处理 HTML 或 JSON 的 HTTP 可以设计为一系列转换函数。思路如下:
response = F(request, persistent state)
响应是通过某个函数F(r, s)从请求中构建的,该函数依赖于服务器上数据库中的请求加上一些持久状态。
这些函数形成了围绕核心服务的嵌套外壳或包装器。例如,核心处理可能被包装以确保发出请求的用户被授权更改数据库状态。我们可以总结如下:
response = auth(F(request, persistent state))
授权处理可能被包装在处理中,以验证用户的凭据。所有这些可能进一步包装在一个外壳中,以确保客户端应用程序软件期望以 JSON 表示形式进行响应。像这样使用多个层可以为许多不同的核心服务提供一致的操作。整个过程可能开始看起来像这样:
response = JSON( user( auth( F(request, persistent state) ) ) )
这种设计自然适用于一系列转换函数。这个想法为我们提供了一些指导,指导我们设计包括许多协议和创建有效响应的许多规则的复杂 Web 服务的方式。
一个良好的 RESTful 实现还应该提供关于服务的大量信息。提供此信息的一种方式是通过 OpenAPI 规范。有关 OpenAPI(Swagger)规范的信息,请参阅swagger.io/specification/。
OpenAPI 规范的核心是 JSON 模式规范。有关更多信息,请参阅json-schema.org。
这两个基本思想如下:
-
我们以 JSON 格式编写了发送到服务的请求和服务提供的响应的规范。
-
我们在固定的 URL 上提供规范,通常是
/swagger.json。客户端可以查询此 URL 以确定服务的详细信息。
创建 Swagger 文档可能具有挑战性。swagger-spec-validator项目可以提供帮助。请参阅pypi.python.org/pypi/swagger-spec-validator。这是一个 Python 包,我们可以使用它来确认 Swagger 规范是否符合 OpenAPI 要求。
在本章中,我们将探讨创建 RESTful Web 服务以及提供静态或动态内容的一些方法。
使用 WSGI 实现 Web 服务
许多 Web 应用程序将具有多个层。这些层通常可以总结为三种常见模式:
-
演示层可能在移动设备或网站上运行。这是可见的外部视图。
-
应用层通常实现为 Web 服务。该层对 Web 或移动演示进行处理。
-
持久层处理数据的保留和事务状态,以及来自单个用户的多个会话中的数据。这将支持应用程序层。
基于 Python 的网站或 Web 服务应用程序将遵守Web 服务网关接口(WSGI)标准。这为前端 Web 服务器(如 Apache HTTPD、NGINX 或 GUnicorn)提供了一种统一的方式来使用 Python 提供动态内容。
Python 有各种各样的 RESTful API 框架。在使用 Flask 框架创建 RESTful API的示例中,我们将看到 Flask。然而,在某些情况下,核心 WSGI 功能可能是我们所需要的。
我们如何创建支持遵循 WSGI 标准的分层组合的应用程序?
准备就绪
WSGI 标准定义了一个可组合的 Web 应用程序的总体框架。其背后的想法是定义每个应用程序,使其能够独立运行,并可以轻松连接到其他应用程序。整个网站是由一系列外壳或包装器构建的。
这是一种基本的 Web 服务器开发方法。WSGI 不是一个复杂的框架;它是一个最小的标准。我们将在使用 Flask 框架创建 RESTful API的示例中探讨一些简化设计的方法。
Web 服务的本质是 HTTP 请求和响应。服务器接收请求并创建响应。HTTP 请求包括几个数据部分:
-
资源的 URL。URL 可以像
http://www.example.com:8080/?query#fragment这样复杂。URL 有几个部分: -
方案
http:以:结束。 -
主机
www.example.com:这是以//为前缀的。它可能包括一个可选的端口号。在这种情况下,它是8080。 -
资源的路径:在本例中是
/字符。路径以某种形式是必需的。它通常比简单的/更复杂。 -
以
?为前缀的查询字符串:在本例中,查询字符串只是带有没有值的键query。 -
以
#为前缀的片段标识符:在本例中,片段是fragment。对于 HTML 文档,这可以是特定标签的id值;浏览器将滚动到命名标签。
几乎所有这些 URL 元素都是可选的。我们可以利用查询字符串(或片段)来提供有关请求的附加格式信息。
WSGI 标准要求解析 URL。各种片段放入环境中。每个片段将被分配一个单独的键:
-
方法:常见的 HTTP 方法包括
HEAD,OPTIONS,GET,POST,PUT和DELETE。 -
请求标头:标头是支持请求的附加信息。例如,标头用于定义可以接受的内容类型。
-
附加内容:请求可能包括来自 HTML 表单的输入,或要上传的文件。
HTTP 响应在许多方面类似于请求。它包含响应标头和响应正文。标头将包括诸如内容的编码,以便客户端可以正确地呈现它的细节。如果服务器提供 HTML 内容并维护服务器会话,那么 cookie 将作为每个请求和响应的一部分在标头中发送。
WSGI 旨在帮助创建可以用于构建更大更复杂应用程序的应用程序组件。WSGI 应用程序通常充当包装器,保护其他应用程序免受错误请求、未经授权的用户或未经身份验证的用户的影响。为了做到这一点,每个 WSGI 应用程序必须遵循一个共同的标准定义。每个应用程序必须是一个函数或可调用对象,并具有以下签名:
def application(environ, start_response):
start_response('200 OK', [('Content-Type', 'text/plain')])
return iterable_strings
environ参数是一个包含有关请求的信息的字典。这包括所有 HTTP 细节,加上操作系统上下文,加上 WSGI 服务器上下文。start_response参数是一个必须在返回响应正文之前调用的函数。这提供了响应的状态和标头。
WSGI 应用程序函数的返回值是 HTTP 响应正文。这通常是一系列字符串或字符串值的可迭代对象。这里的想法是,WSGI 应用程序可能是一个更大容器的一部分,该容器将从服务器向客户端流式传输响应,因为响应正在构建。
由于所有 WSGI 应用程序都是可调用函数,它们可以很容易地组合。一个复杂的网络服务器可能有几个 WSGI 组件来处理身份验证、授权、标准标头、审计日志、性能监控等细节。这些方面通常独立于底层内容;它们是所有网络应用程序或 RESTful 服务的通用特性。
我们将看一个相对简单的网络服务,它可以从牌组或鞋子中发出纸牌。我们将依赖于来自第六章类和对象的基础的使用 slots 优化小对象配方中的Card类定义。这是核心的Card类,带有等级和花色信息:
class Card:
__slots__ = ('rank', 'suit')
def __init__(self, rank, suit):
self.rank = int(rank)
self.suit = suit
def __repr__(self):
return ("Card(rank={self.rank!r}, "
"suit={self.suit!r})").format(self=self)
def to_json(self):
return {
"__class__": "Card",
'rank': self.rank,
'suit': self.suit}
我们为纸牌定义了一个小的基类。该类的每个实例都有两个属性,rank和suit。我们省略了哈希和比较方法的定义。要遵循第七章更高级的类设计中的创建具有可排序对象的类配方,这个类需要许多额外的特殊方法。这个配方将避免这些复杂性。
我们定义了一个to_json()方法,用于将这个复杂对象序列化为一致的 JSON 格式。该方法发出Card状态的字典表示。如果我们想要从 JSON 表示中反序列化Card对象,我们还需要创建一个object_hook函数。不过,对于这个示例,我们不需要它,因为我们不会接受Card对象作为输入。
我们还需要一个Deck类作为Card实例的容器。该类的一个实例可以创建Card实例,同时充当一个有状态的对象,可以发牌。以下是类定义:
import random
class Deck:
SUITS = (
'\N{black spade suit}',
'\N{white heart suit}',
'\N{white diamond suit}',
'\N{black club suit}',
)
def __init__(self, n=1):
self.n = n
self.create_deck(self.n)
def create_deck(self, n=1):
self.cards = [
Card(r,s)
for r in range(1,14)
for s in self.SUITS
for _ in range(n)
]
random.shuffle(self.cards)
self.offset = 0
def deal(self, hand_size=5):
if self.offset + hand_size > len(self.cards):
self.create_deck(self.n)
hand = self.cards[self.offset:self.offset+hand_size]
self.offset += hand_size
return hand
create_deck()方法使用生成器来创建所有 52 种组合的十三个等级和四种花色。每种花色由一个单字符定义:♣,♢,♡或♠。示例使用\N{}序列来拼写 Unicode 字符名称。
如果在创建Deck实例时提供了n的值,容器将创建 52 张牌的多个副本。这种多副牌鞋有时用于通过减少洗牌时间来加快游戏速度。一旦Card实例的序列被创建,就会使用random模块对其进行洗牌。对于可重复的测试用例,可以提供一个固定的种子。
deal()方法将使用self.offset的值来确定从哪里开始发牌。这个值从0开始,并在每发一手牌后递增。hand_size参数决定下一手牌有多少张。该方法通过递增self.offset的值来更新对象的状态,以便牌只被发一次。
以下是使用这个类创建Card对象的一种方法:
**>>> from ch12_r01 import deck_factory
>>> import random
>>> import json
>>> random.seed(2)
>>> deck = Deck()
>>> cards = deck.deal(5)
>>> cards
[Card(rank=4, suit='♠'), Card(rank=8, suit='♡'),
Card(rank=3, suit='♡'), Card(rank=6, suit='♡'),
Card(rank=2, suit='♣')]**
为了创建一个合理的测试,我们提供了一个固定的种子值。脚本使用Deck()创建了一副牌。然后我们可以从牌组中发出五张Card实例。
为了将其作为 Web 服务的一部分使用,我们还需要以 JSON 表示形式产生有用的输出。以下是一个示例,展示了这样的输出:
**>>> json_cards = list(card.to_json() for card in deck.deal(5))
>>> print(json.dumps(json_cards, indent=2, sort_keys=True))**
[
{
"__class__": "Card",
"rank": 2,
"suit": "\u2662"
},
{
"__class__": "Card",
"rank": 13,
"suit": "\u2663"
},
{
"__class__": "Card",
"rank": 7,
"suit": "\u2662"
},
{
"__class__": "Card",
"rank": 6,
"suit": "\u2662"
},
{
"__class__": "Card",
"rank": 7,
"suit": "\u2660"
}
]
我们使用deck.deal(5)来从牌组中发 5 张牌。表达式list(card.to_json() for card in deck.deal(5))将使用每个Card对象的to_json()方法来发出该对象的小字典表示。然后将字典结构的列表序列化为 JSON 表示形式。sort_keys=True选项对于创建可重复的测试用例很方便。对于 RESTful Web 服务通常不是必需的。
如何做...
- 导入所需的模块和对象。我们将使用
HTTPStatus类,因为它定义了常用的 HTTP 状态码。需要json模块来生成 JSON 响应。我们还将使用os模块来初始化随机数种子:
from http import HTTPStatus
import json
import os
import random
-
导入或定义底层类,
Card和Deck。通常,最好将这些定义为一个单独的模块。基本功能应该存在并在 Web 服务环境之外进行测试。这样做的想法是 Web 服务应该包装现有的、可工作的软件。 -
创建所有会话共享的对象。
deck的值是一个模块全局变量:
random.seed(os.environ.get('DEAL_APP_SEED'))
deck = Deck()
我们依赖os模块来检查环境变量。如果环境变量DEAL_APP_SEED被定义,我们将使用该字符串值来生成随机数。否则,我们将依赖random模块的内置随机化特性。
- 将目标 WSGI 应用程序定义为一个函数。该函数将通过发一手牌来响应请求,然后创建
Card信息的 JSON 表示形式:
def deal_cards(environ, start_response):
global deck
hand_size = int(environ.get('HAND_SIZE', 5))
cards = deck.deal(hand_size)
status = "{status.value} {status.phrase}".format(
status=HTTPStatus.OK)
headers = [('Content-Type', 'application/json;charset=utf-8')]
start_response(status, headers)
json_cards = list(card.to_json() for card in cards)
return [json.dumps(json_cards, indent=2).encode('utf-8')]
deal_cards()函数从deck中发牌下一组牌。操作系统环境可以定义HAND_SIZE环境变量来改变发牌的大小。全局deck对象用于执行相关处理。
响应的状态行是一个字符串,其中包含 HTTP 状态为OK的数值和短语。这可以跟随标头。这个例子包括Content-Type标头,向客户端提供信息;内容是一个 JSON 文档,这个文档的字节使用utf-8进行编码。最后,文档本身是这个函数的返回值。
- 出于演示和调试目的,构建一个运行 WSGI 应用程序的服务器是有帮助的。我们将使用
wsgiref模块的服务器。在 Werkzeug 中定义了良好的服务器。像 GUnicorn 这样的服务器甚至更好:
from wsgiref.simple_server import make_server
httpd = make_server('', 8080, deal_cards)
httpd.serve_forever()
服务器运行后,我们可以打开浏览器查看http://localhost:8080/。这将返回一批五张卡片。每次刷新,我们都会得到不同的一批卡片。
这是因为在浏览器中输入 URL 会执行一个带有最小一组标头的GET请求。由于我们的 WSGI 应用程序不需要任何特定的标头,并且对任何 HTTP 方法都有响应,它将返回一个结果。
结果是一个 JSON 文档,表示从当前牌组中发出的五张卡片。每张卡片都用一个类名rank和suit表示:
[
{
"__class__": "Card",
"suit": "\u2663",
"rank": 6
},
{
"__class__": "Card",
"suit": "\u2662",
"rank": 8
},
{
"__class__": "Card",
"suit": "\u2660",
"rank": 8
},
{
"__class__": "Card",
"suit": "\u2660",
"rank": 10
},
{
"__class__": "Card",
"suit": "\u2663",
"rank": 11
}
]
我们可以创建带有聪明的 JavaScript 程序的网页来获取一批卡片。这些网页和 JavaScript 程序可以用于动画处理,并包括卡片图像的图形。
工作原理...
WSGI 标准定义了 Web 服务器和应用程序之间的接口。这是基于 Apache HTTPD 的公共网关接口(CGI)。CGI 旨在运行 shell 脚本或单独的二进制文件。WSGI 是对这一传统概念的增强。
WSGI 标准使用环境字典定义了各种信息:
-
字典中的许多键反映了一些初步解析和数据转换后的请求。
-
REQUEST_METHOD:HTTP 请求方法,如GET或POST。 -
SCRIPT_NAME:请求 URL 路径的初始部分。这通常被视为整体应用程序对象或函数。 -
PATH_INFO:请求 URL 路径的其余部分,指定资源的位置。在这个例子中,不执行路径解析。 -
QUERY_STRING:请求 URL 中跟随?后的部分,如果有的话: -
CONTENT_TYPE:HTTP 请求中任何 Content-Type 标头值的内容。 -
CONTENT_LENGTH:HTTP 请求中任何 Content-Length 标头值的内容。 -
SERVER_NAME和SERVER_PORT:请求中的服务器名称和端口号。 -
SERVER_PROTOCOL:客户端用于发送请求的协议版本。通常情况下,这可能是类似于HTTP/1.0或HTTP/1.1的内容。 -
HTTP 标头:这些标头将以
HTTP_开头,并且以全部大写字母包含标头名称的键。
通常,请求的内容不是从服务器创建有意义的响应所需的唯一数据。通常需要额外的信息。这些信息通常包括另外两种类型的数据:
-
操作系统环境:在服务启动时存在的环境变量为服务器提供配置详细信息。这可能提供一个包含静态内容的目录路径。它可能提供用于验证用户的信息。
-
WSGI 服务器上下文:这些键以
wsgi.开头,始终为小写。值包括一些关于遵循 WSGI 标准的服务器内部状态的附加信息。有两个特别有趣的对象,用于上传文件和日志支持: -
wsgi.input:它是一个类似文件的对象。可以从中读取 HTTP 请求体字节。这通常需要根据Content-Type标头进行解码。 -
wsgi.errors:这是一个类似文件的对象,可以将错误输出写入其中。这是服务器的日志。
WSGI 函数的返回值可以是序列对象或可迭代对象。返回可迭代对象是构建非常大的文档并通过多个较小的缓冲区下载的方法。
此示例 WSGI 应用程序不检查请求路径。可以使用任何路径来检索一手牌。更复杂的应用程序可能会解析路径以确定有关所请求的手牌大小或应该从中发牌的牌组大小的信息。
还有更多...
Web 服务可以被视为连接到嵌套外壳或层中的一些常见部分。WSGI 应用程序的统一接口鼓励可重用功能的这种组合。
有许多常见的技术用于保护和生成动态内容。这些技术是 Web 服务应用程序的横切关注点。我们有以下几种选择:
-
我们可以在单个应用程序中编写许多
if语句。 -
我们可以提取常见的编程并创建一个将安全性问题与内容构建分离的通用包装器
包装器只是另一个不直接产生结果的 WSGI 应用程序。相反,包装器将产生结果的工作交给另一个 WSGI 应用程序。
例如,我们可能需要一个确认期望 JSON 响应的包装器。此包装器将区分人类为中心的 HTML 请求和面向应用程序的 JSON 请求。
为了创建更灵活的应用程序,通常使用可调用对象而不是简单的函数是有帮助的。这样做可以使各种应用程序和包装器的配置更加灵活。我们将将 JSON 过滤器的概念与可调用对象结合起来。
这个对象的概述如下:
class JSON_Filter:
def __init__(self, json_app):
self.json_app = json_app
def __call__(self, environ, start_response):
return json_app(environ, start_response)
通过提供另一个应用程序,json_app,我们将从这个类定义中创建一个可调用对象。
我们将像这样使用它:
json_wrapper = JSON_Filter(deal_cards)
这将包装原始的deal_cards()WSGI 应用程序。现在我们可以将复合json_wrapper对象用作 WSGI 应用程序。当服务器调用json_wrapper(environ, start_response)时,将调用对象的__call__()方法,在这个例子中,将请求传递给deal_cards()函数。
以下是更完整的包装器应用程序。此包装器将检查 HTTP Accept 标头中的字符"json"。它还将检查查询字符串以查看是否进行了?$format=json的 JSON 格式请求。此类的一个实例可以配置为引用deal_cards()WSGI 应用程序:
from urllib.parse import parse_qs
class JSON_Filter:
def __init__(self, json_app):
self.json_app = json_app
def __call__(self, environ, start_response):
if 'HTTP_ACCEPT' in environ:
if 'json' in environ['HTTP_ACCEPT']:
environ['$format'] = 'json'
return self.json_app(environ, start_response)
decoded_query = parse_qs(environ['QUERY_STRING'])
if '$format' in decoded_query:
if decoded_query['$format'][0].lower() == 'json':
environ['$format'] = 'json'
return self.json_app(environ, start_response)
status = "{status.value} {status.phrase}".format(status=HTTPStatus.BAD_REQUEST)
headers = [('Content-Type', 'text/plain;charset=utf-8')]
start_response(status, headers)
return ["Request doesn't include ?$format=json or Accept header".encode('utf-8')]
__call__()方法检查 Accept 标头以及查询字符串。如果 HTTP Accept 标头中的字符串json出现在任何位置,则调用给定的应用程序。环境将更新以包括此包装器使用的标头信息。
如果 HTTP Accept 标头不存在或不需要 JSON 响应,则会检查查询字符串。这种回退可能会有所帮助,因为很难更改浏览器发送的标头;使用查询字符串是 Accept 标头的浏览器友好替代方案。parse_qs()函数将查询字符串分解为键和值的字典。如果查询字符串中有$format作为键,则会检查其值是否包含'json'。如果是这样,则环境将使用查询字符串中找到的格式信息进行更新。
在这两种情况下,调用被包装的应用程序时会修改环境。被包装的函数只需要检查 WSGI 环境中的格式信息。这个包装器对象返回响应而不进行任何进一步的修改。
如果请求不要求 JSON,则会发送400 BAD REQUEST响应,并附带简单的文本消息。这将提供一些关于为什么查询不可接受的指导。
我们将使用JSON_Filter包装类定义如下:
json_wrapper = JSON_Filter(deal_cards)
httpd = make_server('', 8080, json_wrapper)
我们没有从deal_cards()创建服务器,而是创建了一个引用deal_cards()函数的JSON_Filter类的实例。这将几乎与之前显示的版本完全相同。重要的区别是这需要一个 Accept 头或者一个像这样的 URL:http://localhost:8080/?$format=json。
提示
这个示例有一个微妙的语义问题。GET方法改变了服务器的状态。这通常是一个坏主意。
因为我们在浏览器中查看,很难解决问题。这里几乎没有可用的调试支持。这意味着print()函数以及日志消息对于调试是必不可少的。由于 WSGI 的工作方式,将打印到sys.stderr是必不可少的。使用 Flask 更容易,我们将在使用 Flask 框架进行 RESTful API的示例中展示。
HTTP 支持许多方法,包括GET,POST,PUT和DELETE。通常,将这些方法映射到数据库CRUD操作是明智的;使用POST进行创建,使用GET进行检索,使用PUT进行更新,使用DELETE进行删除。这意味着GET操作不会改变数据库的状态。
这导致了一个观点,即 Web 服务的GET操作应该是幂等的。一系列GET操作而没有其他POST,PUT或DELETE操作应该每次返回相同的结果。在这个示例中,每个GET都返回不同的结果。这是使用GET来处理卡片的一个语义问题。
对于我们演示基础知识的目的,这个区别是微不足道的。在一个更大更复杂的 Web 应用程序中,这个区别是一个重要的考虑因素。由于发牌服务不是幂等的,有一种观点认为它应该使用POST方法访问。
为了方便在浏览器中进行探索,我们避免检查 WSGI 应用程序中的方法。
另请参阅
-
Python 有各种各样的 RESTful API 框架。在使用 Flask 框架进行 RESTful API的示例中,我们将看一下 Flask 框架。
-
有三个地方可以查找有关 WSGI 标准的详细信息:
-
PEP 3333:请参阅
www.python.org/dev/peps/pep-3333/。 -
Python 标准库:它包括
wsgiref模块。这是标准库中的参考实现。 -
Werkzeug 项目:请参阅
werkzeug.pocoo.org。这是一个具有众多 WSGI 实用程序的外部库。这被广泛用于实现适当的 WSGI 应用程序。 -
另请参阅
docs.oasis-open.org/odata/odata-json-format/v4.0/odata-json-format-v4.0.html以获取有关为 Web 服务格式化数据的 JSON 的更多信息。
使用 Flask 框架进行 RESTful API
在使用 WSGI 实现 Web 服务的示例中,我们看到了如何使用 Python 标准库中可用的 WSGI 组件构建 RESTful API 和微服务。这导致了大量的编程来处理许多常见情况。
我们如何简化所有常见的 Web 应用程序编程并消除样板代码?
准备工作
首先,我们需要将 Flask 框架添加到我们的环境中。这通常依赖于使用pip安装 Flask 的最新版本以及其他相关项目,itsdangerous,Jinja2,click,MarkupSafe和Werkzeug。
安装看起来像下面这样:
**slott$ sudo pip3.5 install flask**
**Password:**
**Collecting flask**
**Downloading Flask-0.11.1-py2.py3-none-any.whl (80kB)**
**100% |████████████████████████████████| 81kB 3.6MB/s**
**Collecting itsdangerous>=0.21 (from flask)**
**Downloading itsdangerous-0.24.tar.gz (46kB)**
**100% |████████████████████████████████| 51kB 8.6MB/s**
**Requirement already satisfied (use --upgrade to upgrade): Jinja2>=2.4 in /Library/Frameworks/Python.framework/Versions/3.5/lib/python3.5/site-packages (from flask)**
**Collecting click>=2.0 (from flask)**
**Downloading click-6.6.tar.gz (283kB)**
**100% |████████████████████████████████| 286kB 4.0MB/s**
**Collecting Werkzeug>=0.7 (from flask)**
**Downloading Werkzeug-0.11.10-py2.py3-none-any.whl (306kB)**
**100% |████████████████████████████████| 307kB 3.8MB/s**
**Requirement already satisfied (use --upgrade to upgrade): MarkupSafe in /Library/Frameworks/Python.framework/Versions/3.5/lib/python3.5/site-packages (from Jinja2>=2.4->flask)**
**Installing collected packages: itsdangerous, click, Werkzeug, flask**
**Running setup.py install for itsdangerous ... done**
**Running setup.py install for click ... done**
**Successfully installed Werkzeug-0.11.10 click-6.6 flask-0.11.1 itsdangerous-0.24**
我们可以看到Jinja2和MarkupSafe已经安装。缺少的元素被pip找到,下载并安装。Windows 用户不会使用sudo命令。
Flask 允许我们大大简化我们的网络服务应用程序。我们不需要创建一个大型且可能复杂的 WSGI 兼容函数或可调用对象,而是可以创建一个具有单独函数的模块。每个函数可以处理特定的 URL 路径模式。
我们将查看与使用 WSGI 实现网络服务食谱中相同的核心发牌功能。Card类定义了一个简单的扑克牌。Deck类定义了一副牌。
因为 Flask 为我们处理 URL 解析的细节,所以我们可以很容易地创建一个更复杂的网络服务。我们将定义一个路径,看起来像这样:
/dealer/hand/?cards=5。
这个路由有三个重要的信息:
-
路径的第一部分
/dealer/是整个网络服务。 -
路径的下一部分
hand/是一个特定的资源,一手牌。 -
查询字符串
?cards=5定义了查询的 cards 参数。这是请求的手牌大小。这限制在 1 到 52 张牌的范围内。超出范围的值将得到400状态码,因为查询无效。
如何做...
- 从
flask包中导入一些核心定义。Flask类定义了整个应用程序。request对象保存当前的 web 请求:
from flask import Flask, request, jsonify, abort
from http import HTTPStatus
jsonify()函数将从 Flask 视图函数返回一个 JSON 格式对象。abort()函数返回一个 HTTP 错误状态并结束请求的处理。
- 导入底层类
Card和Deck。理想情况下,这些应该从一个单独的模块中导入。应该可以在 web 服务环境之外测试所有功能:
from ch12_r01 import Card, Deck
为了正确洗牌,我们还需要random模块:
import random
- 创建
Flask对象。这是整个网络服务应用程序。我们将称 Flask 应用程序为dealer,并且还将将对象分配给全局变量dealer:
dealer = Flask('dealer')
- 创建应用程序中使用的任何对象。这些可以分配给
Flask对象dealer作为属性。确保创建一个不会与 Flask 的内部属性冲突的唯一名称。另一种方法是使用模块全局变量。
有状态的全局对象必须能够在多线程环境中工作,或者必须显式禁用线程:
import os
random.seed(os.environ.get('DEAL_APP_SEED'))
deck = Deck()
对于这个示例,Deck类的实现不是线程安全的,所以我们将依赖于单线程服务器。deal()方法应该使用threading模块中的Lock类来定义一个独占锁,以确保与并发线程的正确操作。
- 定义一个路由-到执行特定请求的视图函数的 URL 模式。这是一个装饰器,直接放在函数的前面。它将把函数绑定到 Flask 应用程序:
@dealer.route('/dealer/hand/')
- 定义视图函数,检索数据或更新应用程序状态。在这个例子中,函数两者都做:
def deal():
try:
hand_size = int(request.args.get('cards', 5))
assert 1 <= hand_size < 53
except Exception as ex:
abort(HTTPStatus.BAD_REQUEST)
cards = deck.deal(hand_size)
response = jsonify([card.to_json() for card in cards])
return response
Flask 解析 URL 中?后面的字符串-查询字符串-以创建request.args值。客户端应用程序或浏览器可以使用查询字符串设置此值,例如?cards=13。这将为桥牌发牌 13 张牌。
如果查询字符串中的手牌大小值不合适,abort()函数将结束处理并返回400的 HTTP 状态码。这表示请求不可接受。这是一个最小的响应,没有更详细的内容。
应用程序的真正工作是一个简单的语句,cards = dealer.deck.deal(hand_size)。这里的想法是在 web 框架中包装现有功能。可以在没有 web 应用程序的情况下测试这些功能。
响应由jsonify()函数处理:这将创建一个响应对象。响应的主体将是以 JSON 表示的 Python 对象。如果我们需要向响应添加标头,我们可以更新response.headers以包含其他信息。
- 定义运行服务器的主程序:
if __name__ == "__main__":
dealer.run(use_reloader=True, threaded=False, debug=True)
我们包含了debug=True选项,以在浏览器和 Flask 日志文件中提供丰富的调试信息。服务器运行后,我们可以打开浏览器查看http://localhost:5000/。这将返回一批五张卡片。每次刷新,我们都会得到不同的一批卡片。
这是因为在浏览器中输入 URL 会执行一个带有最小一组标头的GET请求。由于我们的 WSGI 应用程序不需要任何特定的标头,并且响应所有的 HTTP 方法,它将返回一个结果。
结果是一个包含五张卡片的 JSON 文档。每张卡片由一个类名、rank和suit信息表示:
[
{
"__class__": "Card",
"suit": "\u2663",
"rank": 6
},
{
"__class__": "Card",
"suit": "\u2662",
"rank": 8
},
{
"__class__": "Card",
"suit": "\u2660",
"rank": 8
},
{
"__class__": "Card",
"suit": "\u2660",
"rank": 10
},
{
"__class__": "Card",
"suit": "\u2663",
"rank": 11
}
]
要查看超过五张卡片,可以修改 URL。例如,这将返回一个桥牌手:http://127.0.0.1:5000/dealer/hand/?cards=13。
它是如何工作的...
Flask 应用程序由一个带有许多个别视图函数的应用程序对象组成。在这个食谱中,我们创建了一个单独的视图函数deal()。应用程序通常有许多函数。一个复杂的网站可能有许多应用程序,每个应用程序都有许多函数。
路由是 URL 模式和视图函数之间的映射。这使得可能有包含视图函数使用的参数的路由。
@flask.route装饰器是用于将每个路由和视图函数添加到整个 Flask 实例中的技术。视图函数根据路由模式绑定到整个应用程序中。
Flask对象的run()方法执行以下类型的处理。这并不完全是 Flask 的工作方式,但它提供了各种步骤的大致轮廓:
-
它等待 HTTP 请求。Flask 遵循 WSGI 标准,请求以字典的形式到达。有关 WSGI 的更多信息,请参阅使用 WSGI 实现 Web 服务食谱。
-
它从 WSGI 环境中创建一个 Flask
Request对象。request对象包含来自请求的所有信息,包括所有 URL 元素、查询字符串元素和任何附加的文档。 -
Flask 然后检查各种路由,寻找与请求路径匹配的路由。
-
如果找到路由,则执行视图函数。该函数创建一个
Response对象。这是视图函数的返回值。 -
如果找不到路由,则会自动发送
404 NOT FOUND响应。 -
遵循 WSGI 模式准备状态和标头以开始发送响应。然后提供从视图函数返回的
Response对象作为字节流。
Flask 应用程序可以包含许多方法,这使得提供 Web 服务非常容易。Flask 将其中一些方法公开为与请求或会话隐式绑定的独立函数。这使得编写视图函数稍微简单一些。
还有更多...
在使用 WSGI 实现 Web 服务食谱中,我们将应用程序包装在一个通用测试中,确认请求具有两个属性中的一个。我们使用了以下两条规则:
-
一个要求 JSON 的 Accept 标头
-
其中包含
$format=json的查询字符串
如果我们正在编写一个复杂的 RESTful 应用程序服务器,我们通常希望对所有视图函数应用这种类型的测试。我们不想重复这个测试的代码。
当然,我们可以将使用 WSGI 实现 Web 服务食谱中的 WSGI 解决方案与 Flask 应用程序结合起来构建一个复合应用程序。我们也可以完全在 Flask 中完成这个任务。纯 Flask 解决方案比 WSGI 解决方案稍微简单一些,因此更受欢迎。
我们已经看到了 Flask 的@flask.route装饰器。Flask 还有许多其他装饰器,可以用来定义请求和响应处理中的各个阶段。为了对传入的请求应用测试,我们可以使用@flask.before_request装饰器。所有带有此装饰的函数将在处理请求之前被调用:
@dealer.before_request
def check_json():
if 'json' in request.headers.get('Accept'):
return
if 'json' == request.args.get('$format'):
return
return abort(HTTPStatus.BAD_REQUEST)
当@flask.before_request装饰器未能返回值(或返回None)时,处理将继续。路由将被检查,并且将评估视图函数。
在这个例子中,如果接受头包括json或者$format查询参数是json,那么函数返回None。这意味着正常的视图函数将被找到来处理请求。
当@flask.before_request装饰器返回一个值时,这就是最终结果,处理停止。在这个例子中,check_json()函数可能返回一个abort()响应,这将停止处理。abort()响应成为 Flask 应用程序的最终响应。这使得返回错误消息非常容易。
现在我们可以使用浏览器的地址栏输入以下 URL:
http://127.0.0.1:5000/dealer/hand/?cards=13&$format=json
这将返回一个 13 张牌的手,并且请求现在明确要求以 JSON 格式返回结果。尝试其他值作为$format以及完全省略$format键也是有益的。
提示
这个例子有一个微妙的语义问题。GET方法改变了服务器的状态。这通常是一个坏主意。
HTTP 支持一些与数据库 CRUD 操作相对应的方法。创建使用POST,检索使用GET,更新使用PUT,删除映射到DELETE。
这个想法导致了 Web 服务GET操作应该是幂等的想法。一系列GET操作——没有其他POST,PUT或DELETE——应该每次返回相同的结果。在这个例子中,每个GET都返回不同的结果。由于发牌服务不是幂等的,应该使用POST方法访问它。
为了方便使用浏览器进行探索,我们避免在 Flask 路由中检查方法。理想情况下,路由装饰器应该如下所示:
@dealer.route('/dealer/hand/', methods=['POST'])
这样做会使得使用浏览器查看服务是否工作变得困难。在使用 urllib 进行 REST 请求中,我们将看到如何创建客户端,并切换到使用POST进行方法。
另见
-
有关 Web 服务的背景,请参阅使用 WSGI 实现 Web 服务。
-
有关 Flask 的详细信息,请参阅
flask.pocoo.org/docs/0.11/。 -
请参阅
www.packtpub.com/web-development/learning-flask-framework了解更多关于 Flask 框架的信息。另外,www.packtpub.com/web-development/mastering-flask有更多关于掌握 Flask 的信息。
解析请求中的查询字符串
URL 是一个复杂的对象。它至少包含六个单独的信息。可以通过可选元素包含更多信息。
例如http://127.0.0.1:5000/dealer/hand/?cards=13&$format=json的 URL 有几个字段:
-
http是方案。https用于使用加密套接字进行安全连接。 -
127.0.0.1可以称为授权,尽管网络位置更常用。这个特定的 IP 地址意味着本地主机,是本地主机的一种回环。本地主机的名称映射到这个 IP 地址。 -
5000是端口号,是授权的一部分。 -
/dealer/hand/是资源的路径。 -
cards=13&$format=json是一个查询字符串,它与路径由?字符分隔开。
查询字符串可能非常复杂。虽然不是官方标准,但查询字符串可能有重复的键。以下查询字符串是有效的,尽管可能令人困惑:
?cards=13&cards=5
我们重复了cards键。Web 服务将提供 13 张牌和 5 张牌。
[作者不知道有任何手牌大小不同的纸牌游戏。缺乏一个好的用户故事使得这个例子有些牵强。]
重复键的能力破坏了 URL 查询字符串和内置 Python 字典之间简单映射的可能性。这个问题有几种可能的解决方案:
-
字典中的每个键必须与包含所有值的
list相关联。对于最常见的情况,即键不重复的情况,这很麻烦;每个列表只有一个项目。这个解决方案是通过urllib.parse中的parse_qs()实现的。 -
每个键只保存一次,第一个(或最后一个)值被保留,其他值被丢弃。这太糟糕了。
-
不使用字典。相反,查询字符串可以解析为(键,值)对的列表。这也允许键重复。对于具有唯一键的常见情况,列表可以转换为字典。对于不常见的情况,可以以其他方式处理重复的键。这是由
urllib.parse中的parse_qsl()实现的。
有没有更好的方法来处理查询字符串?我们是否可以有一个更复杂的结构,行为类似于字典,对于常见情况具有单个值,并且对于罕见情况具有重复键和多个值的更复杂对象?
准备工作
Flask 依赖于另一个项目Werkzeug。当我们使用pip安装 Flask 时,要求将导致pip也安装 Werkzeug 工具包。Werkzeug 有一个数据结构,提供了处理查询字符串的绝佳方式。
我们将修改使用 Flask 框架进行 RESTful API配方中的示例,以使用更复杂的查询字符串。我们将添加一个第二个路由,用于发放多手牌。每手牌的大小将在允许重复键的查询字符串中指定。
如何做...
-
从使用 Flask 框架进行 RESTful API配方开始。我们将向现有 Web 应用程序添加一个新的视图函数。
-
定义一个路由——一个 URL 模式——到执行特定请求的视图函数。这是一个装饰器,直接放在函数前面。它将把函数绑定到 Flask 应用程序上:
@dealer.route('/dealer/hands/')
- 定义一个视图函数,响应发送到特定路由的请求:
def multi_hand():
-
在视图函数中,使用
get()方法提取唯一键的值,或者使用适用于内置 dict 类型的普通[]语法。这会返回单个值,而不会出现列表的复杂情况,其中列表只有一个元素的常见情况。 -
对于重复的键,使用
getlist()方法。这会将每个值作为列表返回。以下是一个查找查询字符串的视图函数,例如?card=5&card=5来发放两手五张牌:
try:
hand_sizes = request.args.getlist('cards', type=int)
if len(hand_sizes) == 0:
hand_sizes = [13,13,13,13]
assert all(1 <= hand_size < 53 for hand_size in hand_sizes)
except Exception as ex:
dealer.logger.exception(ex)
abort(HTTPStatus.BAD_REQUEST)
hands = [deck.deal(hand_size) for hand_size in hand_sizes]
response = jsonify(
[
{'hand':i,
'cards':[card.to_json() for card in hand]
} for i, hand in enumerate(hands)
]
)
return response
这个函数将从查询字符串中获取所有cards键。如果值都是整数,并且每个值都在 1 到 52 的范围内(包括 1 和 52),那么这些值就是有效的,视图函数将返回一个结果。如果查询中没有cards键值,那么将发放 13 张牌的四手牌。
响应将是每手牌的 JSON 表示,作为一个小字典,有两个键:手牌 ID 和手牌上的牌。
- 定义一个运行服务器的主程序:
if __name__ == "__main__":
dealer.run(use_reloader=True, threaded=False)
服务器运行后,我们可以打开浏览器查看这个 URL:
http://localhost:5000/?cards=5&cards=5&$format=json
结果是一个 JSON 文档,其中有两手五张牌。我们省略了一些细节,以强调响应的结构:
[
{
"cards": [
{
"__class__": "Card",
"rank": 11,
"suit": "\u2660"
},
{
"__class__": "Card",
"rank": 8,
"suit": "\u2662"
},
...
],
"hand": 0
},
{
"cards": [
{
"__class__": "Card",
"rank": 3,
"suit": "\u2663"
},
{
"__class__": "Card",
"rank": 9,
"suit": "\u2660"
},
...
],
"hand": 1
}
]
因为 Web 服务解析查询字符串,向查询字符串添加更复杂的手牌大小是微不足道的。示例包括基于使用 Flask 框架进行 RESTful API配方的$format=json。
如果实现了@dealer.before_request函数check_json()来检查 JSON,那么就需要$format。如果未实现@dealer.before_request函数check_json(),那么查询字符串中的附加信息将被忽略。
它是如何工作的...
Werkzeug 的Multidict类是一个非常方便的数据结构。这是内置字典的扩展。它允许为给定的键有多个不同的值。
我们可以使用collections模块中的defaultdict类构建类似的东西。定义将是defaultdict(list)。这个定义的问题是每个键的值都是一个列表,即使列表只有一个项目作为值。
Multidict类提供的优势是get()方法的变体。当一个键有多个副本时,get()方法返回第一个值,或者当键只出现一次时返回唯一的值。这也有一个默认参数。这个方法与内置的dict类的方法相对应。
然而,getlist()方法返回给定键的所有值的列表。这种方法是Multidict类的独特方法。我们可以使用这种方法来解析更复杂的查询字符串。
用于验证查询字符串的常见技术是在验证时弹出项目。这是通过pop()和poplist()方法完成的。这些方法将从Multidict类中删除键。如果在检查所有有效键后仍然存在键,则这些额外的键可以被视为语法错误,并且 Web 请求将被拒绝并显示abort(HTTPStatus.BAD_REQUEST)。
还有更多...
查询字符串使用相对简单的语法规则。使用=作为键和值之间的标点符号的一个或多个键值对。每对之间的分隔符是&字符。由于其他字符在解析 URL 时的含义,还有一个重要的规则——键和值必须被编码。
URL 编码规则要求用 HTML 实体替换某些字符。这种技术称为百分比编码。这意味着当我们将&放入查询字符串的值中时,它必须被编码为%26,下面是一个显示这种编码的示例:
**>>> from urllib.parse import urlencode
>>> urlencode( {'n':355,'d':113} )
'n=355&d=113'
>>> urlencode( {'n':355,'d':113,'note':'this&that'} )
'n=355&d=113¬e=this%26that'**
值this&that被编码为this%26that。
有一小部分字符必须应用%编码规则。这来自RFC 3986,参见第 2.2 节,保留字符。列表包括这些字符:
! * ' ( ) ; : @ & = + $ , / ? # [ ] %
通常,与网页关联的 JavaScript 代码将处理编码查询字符串。如果我们在 Python 中编写 API 客户端,我们需要使用urlencode()函数来正确编码查询字符串。Flask 会自动处理解码。
查询字符串有一个实际的大小限制。例如,Apache HTTPD 有一个LimitRequestLine配置参数,默认值为8190。这将限制整个 URL 的大小。
在 OData 规范(docs.oasis-open.org/odata/odata/v4.0/)中,建议查询选项使用几种类型的值。该规范建议我们的 Web 服务应支持以下类型的查询选项:
-
对于标识实体或实体集合的 URL,可以使用
$expand和$select选项。扩展结果意味着查询将提供额外的细节。选择查询将对集合施加额外的条件。 -
标识集合的 URL 应支持
$filter、$search、$orderby、$count、$skip和$top选项。这对于返回单个项目的 URL 没有意义。$filter和$search选项接受用于查找数据的复杂条件。$orderby选项定义了对结果施加的特定顺序。
$count选项从根本上改变了查询。它将返回项目的计数而不是项目本身。
$top和$skip选项用于浏览数据。如果计数很大,通常使用$top选项将结果限制为在网页上显示的特定数量。$skip选项的值确定将显示哪一页数据。例如,$top=20$skip=40将是结果的第 3 页-跳过 40 后的前 20 个。
通常,所有 URL 都应支持$format选项以指定结果的格式。我们一直专注于 JSON,但更复杂的服务可能提供 CSV 输出,甚至 XML。
另请参阅
-
请参阅使用 Flask 框架进行 RESTful API配方,了解如何使用 Flask 进行 Web 服务的基础知识。
-
在使用 urllib 进行 REST 请求配方中,我们将看看如何编写一个能够准备复杂查询字符串的客户端应用程序。
使用 urllib 进行 REST 请求
Web 应用程序有两个基本部分:
-
客户端:这可以是用户的浏览器,但也可能是移动设备应用程序。在某些情况下,Web 服务器可能是其他 Web 服务器的客户端。
-
服务器:这提供了我们一直在寻找的 Web 服务和资源,即使用 WSGI 实现 Web 服务,使用 Flask 框架进行 RESTful API和解析请求中的查询字符串配方,以及其他配方,如解析 JSON 请求和为 Web 服务实现身份验证。
基于浏览器的客户端通常是用 JavaScript 编写的。移动应用程序是用各种语言编写的,重点是 Android 设备的 Java 和 iOS 设备的 Objective-C 和 Swift。
有几个用户故事涉及用 Python 编写的 RESTful API 客户端。我们如何创建一个 Python 程序,作为 RESTful Web 服务的客户端?
准备就绪
我们假设我们有一个基于使用 Flask 框架进行 RESTful API或解析请求中的查询字符串配方的 Web 服务器。我们可以以以下方式为该服务器的行为编写正式规范:
{
"swagger": "2.0",
"info": {
"title": "dealer",
"version": "1.0"
},
"schemes": ["http"],
"host": "127.0.0.1:5000",
"basePath": "/dealer",
"consumes": ["application/json"],
"produces": ["application/json"],
"paths": {
"/hands": {
"get": {
"parameters": [
{
"name": "cards",
"in": "query",
"description": "number of cards in each hand",
"type": "array",
"items": {"type": "integer"},
"collectionFormat": "multi",
"default": [13, 13, 13, 13]
}
],
"responses": {
"200": {
"description":
"one hand of cards for each `hand` value in the query string"
}
}
}
},
"/hand": {
"get": {
"parameters": [
{
"name": "cards",
"in": "query",
"type": "integer",
"default": 5
}
],
"responses": {
"200": {
"description":
"One hand of cards with a size given by the `hand` value in the query string"
}
}
}
}
}
}
本文档为我们提供了如何使用 Python 的urllib模块来消耗这些服务的指导。它还描述了预期的响应应该是什么,为我们提供了如何处理这些响应的指导。
规范中的某些字段定义了基本 URL。特别是这三个字段提供了这些信息:
"schemes": ["http"],
"host": "127.0.0.1:5000",
"basePath": "/dealer",
produces和consumes字段提供了帮助构建和验证 HTTP 标头的信息。请求的Content-Type标头必须是服务器消耗的多用途互联网邮件扩展(MIME)类型。同样,请求的 Accept 标头必须指定服务器生成的 MIME 类型。在这两种情况下,我们将提供application/json。
详细的服务定义在规范的paths部分中提供。例如,/hands路径显示了如何请求多个手的详细信息。路径详细信息是basePath值的后缀。
当 HTTP 方法为get时,参数是在查询中提供的。查询中的cards参数提供了一个整数卡的数量,并且可以多次重复。
响应将至少包括所描述的响应。在这种情况下,HTTP 状态将是200,响应的正文具有最少的描述。可以为响应提供更正式的模式定义,我们将在此示例中省略。
如何做...
- 导入所需的
urllib组件。我们将发出 URL 请求,并构建更复杂的对象,如查询字符串。我们将需要urllib.request和urllib.parse模块来实现这两个功能。由于预期的响应是 JSON 格式,因此json模块也将很有用:
import urllib.request
import urllib.parse
import json
- 定义将要使用的查询字符串。在这种情况下,所有值恰好是固定的。在更复杂的应用程序中,一些值可能是固定的,而另一些可能基于用户输入:
query = {'hand': 5}
- 使用查询构建完整 URL 的各个部分:
full_url = urllib.parse.ParseResult(
scheme="http",
netloc="127.0.0.1:5000",
path="/dealer" + "/hand/",
params=None,
query=urllib.parse.urlencode(query),
fragment=None
)
在这种情况下,我们使用ParseResult对象来保存 URL 的相关部分。这个类对于缺少的项目并不优雅,所以我们必须为 URL 的未使用部分提供明确的None值。
我们可以在脚本中使用"http://127.0.0.1:5000/dealer/hand/?cards=5"。然而,这种紧凑的字符串很难更改。在发出请求时,它作为一个紧凑的消息很有用,但不太适合制作灵活、可维护和可测试的程序。
使用这个长构造函数的优点是为 URL 的每个部分提供明确的值。在更复杂的应用程序中,这些单独的部分是从先前显示的 JSON Swagger 规范文档的分析中构建的:
- 构建最终的
Request实例。我们将使用从各种部分构建的 URL。我们将明确提供一个 HTTP 方法(浏览器通常使用GET作为默认值)。此外,我们可以提供明确的头部:
request = urllib.request.Request(
url = urllib.parse.urlunparse(full_url),
method = "GET",
headers = {
'Accept': 'application/json',
}
)
我们已经提供了 HTTP Accept 头部来声明服务器将产生的 MIME 类型结果,并被客户端接受。我们已经提供了 HTTP Content-Type头部来声明服务器消耗的请求,并由我们的客户端脚本提供。
- 打开一个上下文来处理响应。
urlopen()函数发出请求,处理 HTTP 协议的所有复杂性。最终的result对象可用于作为响应进行处理:
with urllib.request.urlopen(request) as response:
- 一般来说,响应的三个属性特别重要:
print(response.status)
print(response.headers)
print(json.loads(response.read().decode("utf-8")))
status是最终的状态码。我们期望一个正常请求的 HTTP 状态码为200。headers包括响应的所有头部。例如,我们可能想要检查response.headers['Content-Type']是否真的是application/json。
response.read()的值是从服务器下载的字节。我们经常需要解码这些字节以获得正确的 Unicode 字符。utf-8编码方案非常常见。我们可以使用json.loads()从 JSON 文档创建一个 Python 对象。
当我们运行这个时,我们会看到以下输出:
**200
Content-Type: application/json
Content-Length: 367
Server: Werkzeug/0.11.10 Python/3.5.1
Date: Sat, 23 Jul 2016 19:46:35 GMT
[{'suit': '♠', 'rank': 4, '__class__': 'Card'},
{'suit': '♡', 'rank': 4, '__class__': 'Card'},
{'suit': '♣', 'rank': 9, '__class__': 'Card'},
{'suit': '♠', 'rank': 1, '__class__': 'Card'},
{'suit': '♠', 'rank': 2, '__class__': 'Card'}]**
初始的200是状态,显示一切都正常工作。服务器提供了四个头部。最后,内部 Python 对象是一组小字典,提供了有关已发牌的卡片的信息。
要重建Card对象,我们需要使用一个稍微聪明的 JSON 解析器。参见第九章中的读取 JSON 文档配方,输入/输出、物理格式和逻辑布局。
它是如何工作的...
我们通过几个明确的步骤构建了请求:
-
查询数据最初是一个简单的带有键和值的字典。
-
urlencode()函数将查询数据转换为查询字符串,正确编码。 -
整个 URL 最初作为
ParseResult对象中的各个组件开始。这使得每个部分都是可见的,并且可以更改。对于这个特定的 API,这些部分基本上是固定的。在其他 API 中,URL 的路径和查询部分可能都具有动态值。 -
整个请求是由 URL、方法和头部字典构建的。这个例子没有提供单独的文档作为请求的主体。如果发送复杂的文档,或者上传文件,也可以通过向
Request对象提供详细信息来完成。
逐步组装对于简单的应用程序并不是必需的。在简单的情况下,URL 的字面字符串值可能是可以接受的。在另一个极端,一个更复杂的应用程序可能会打印出中间结果作为调试辅助,以确保请求被正确构造。
这样详细说明的另一个好处是提供一个方便的单元测试途径。有关更多信息,请参见第十一章 ,测试。我们经常可以将 Web 客户端分解为请求构建和请求处理。可以仔细测试请求构建,以确保所有元素都设置正确。请求处理可以使用不涉及与远程服务器的实时连接的虚拟结果进行测试。
还有更多...
用户身份验证通常是 Web 服务的重要组成部分。对于基于 HTML 的网站——强调用户交互——人们希望服务器能够理解通过会话的长时间运行的事务序列。用户将进行一次身份验证(通常使用用户名和密码),服务器将使用这些信息直到用户注销或会话过期。
对于 RESTful Web 服务,很少有会话的概念。每个请求都是单独处理的,服务器不需要维护复杂的长时间运行的事务状态。这个责任转移到了客户端应用程序。客户端需要进行适当的请求来构建一个可以呈现为单个事务的复杂文档。
对于 RESTful API,每个请求可能包括身份验证信息。我们将在为 Web 服务实现身份验证配方中详细讨论这一点。现在,我们将通过标题提供额外的细节。这将与我们的 RESTful 客户端脚本很好地契合。
有许多提供身份验证信息给 Web 服务器的方法:
-
一些服务使用 HTTP 的
Authorization标题。当与基本机制一起使用时,客户端可以在每个请求中提供用户名和密码。 -
一些服务将发明一个全新的标题,名称为 API 密钥。该标题的值可能是一个复杂的字符串,其中包含有关请求者的编码信息。
-
一些服务将发明一个名为
X-Auth-Token的标题。这可能在多步操作中使用,其中用户名和密码凭据作为初始请求的一部分发送。结果将包括一个字符串值(令牌),可用于后续 API 请求。通常,令牌具有短暂的过期时间,并且必须更新。
通常,这些方法需要安全套接字层(SSL)协议。这可以作为https方案使用。为了处理 SSL 协议,服务器(有时也是客户端)必须具有适当的证书。这些证书用作客户端和服务器之间的协商的一部分,以建立加密套接字对。
所有这些身份验证技术都有一个共同的特点——它们依赖于在标题中发送附加信息。它们在使用的标题和发送的信息方面略有不同。在最简单的情况下,我们可能会有以下内容:
request = urllib.request.Request(
url = urllib.parse.urlunparse(full_url),
method = "GET",
headers = {
'Accept': 'application/json',
'X-Authentication': 'seekrit password',
}
)
这个假设的请求将是针对需要在X-Authentication标题中提供密码的 Web 服务。在为 Web 服务实现身份验证配方中,我们将向 Web 服务器添加身份验证功能。
OpenAPI(Swagger)规范
许多服务器将明确提供规范作为固定的标准 URL 路径/swagger.json的文件。OpenAPI 规范以前被称为Swagger,提供接口的文件名反映了这一历史。
如果提供,我们可以以以下方式获取网站的 OpenAPI 规范:
swagger_request = urllib.request.Request(
url = 'http://127.0.0.1:5000/dealer/swagger.json',
method = "GET",
headers = {
'Accept': 'application/json',
}
)
from pprint import pprint
with urllib.request.urlopen(swagger_request) as response:
swagger = json.loads(response.read().decode("utf-8"))
pprint(swagger)
一旦我们有了规范,我们可以使用它来获取服务或资源的详细信息。我们可以使用规范中的技术信息来构建 URL、查询字符串和标题。
将 Swagger 添加到服务器
对于我们的小型演示服务器,需要一个额外的视图函数来提供 OpenAPI Swagger 规范。我们可以更新ch12_r03.py模块以响应对swagger.json的请求。
有几种处理这些重要信息的方法:
- 一个单独的静态文件。这就是这个配方中显示的内容。这是提供所需内容的一种非常简单的方式。
这是一个我们可以添加的视图函数,它将发送一个文件。当然,我们还需要将规范放入命名文件中:
from flask import send_file
@dealer.route('/dealer/swagger.json')
def swagger():
response = send_file('swagger.json', mimetype='application/json')
return response
这种方法的缺点是规范与实现模块分开。
- 将规范嵌入模块中的大块文本。例如,我们可以将规范提供为模块本身的文档字符串。这提供了一个可见的地方来放置重要的文档,但这使得在模块级别包含文档字符串测试用例更加困难。
这个视图函数发送模块文档字符串,假设该字符串是一个有效的 JSON 文档:
from flask import make_response
@dealer.route('/dealer/swagger.json')
def swagger():
response = make_response(__doc__.encode('utf-8'))
response.headers['Content-Type'] = 'application/json'
return response
这种方法的缺点是需要检查文档字符串的语法以确保其是有效的 JSON。这除了验证模块实现实际上是否符合规范之外。
- 在适当的 Python 语法中创建一个 Python 规范对象。然后可以将其编码为 JSON 并传输。这个视图函数发送一个
specification对象。这将是一个有效的 Python 对象,可以序列化为 JSON 表示法:
from flask import make_response
import json
@dealer.route('/dealer/swagger.json')
def swagger3():
response = make_response(
json.dumps(specification, indent=2).encode('utf-8'))
response.headers['Content-Type'] = 'application/json'
return response
在所有情况下,拥有正式规范可用有几个好处:
-
客户端应用程序可以下载规范以微调其处理。
-
当包含示例时,规范成为客户端和服务器的一系列测试用例。
-
规范的各种细节也可以被服务器应用程序用来提供验证规则、默认值和其他细节。
另见
-
解析请求中的查询字符串 配方介绍了核心 Web 服务
-
为 Web 服务实现身份验证 配方将添加身份验证以使服务更安全
解析 URL 路径
URL 是一个复杂的对象。它至少包含六个单独的信息片段。可以包括更多作为可选值。
诸如 http://127.0.0.1:5000/dealer/hand/player_1?$format=json 的 URL 具有几个字段:
-
http是方案。https用于使用加密套接字进行安全连接。 -
127.0.0.1可以称为权限,尽管网络位置更常用。这个特定的 IP 地址意味着本地主机,是一种回环到本地主机的方式。localhost 映射到这个 IP 地址。 -
5000是端口号,是权限的一部分。 -
/dealer/hand/player_1 是资源的路径。
-
$format=json是一个查询字符串。
资源的路径可能非常复杂。在 RESTful Web 服务中,使用路径信息来标识资源组、单个资源甚至资源之间的关系是很常见的。
我们如何处理复杂的路径解析?
准备就绪
大多数 Web 服务提供对某种资源的访问。在使用 Flask 框架实现 RESTful API 和解析请求中的查询字符串 配方中,资源在 URL 路径上被标识为手或手。这在某种程度上是误导性的。
实际上,这些 Web 服务涉及两个资源:
-
一副牌,可以洗牌以产生一个或多个随机手
-
一只手,被视为对请求的瞬态响应
更让事情变得更加混乱的是,手资源是通过 GET 请求而不是更常见的 POST 请求创建的。这很令人困惑,因为不会预期 GET 请求改变服务器的状态。
对于简单的探索和技术尖刺,GET 请求是有帮助的。因为浏览器可以发出 GET 请求,这是探索 Web 服务设计某些方面的好方法。
重新设计可以提供对 Deck 类的随机实例的显式访问。牌组的一个特性将是牌的手。这与将 Deck 视为集合和 Hands 作为集合内资源的想法相一致:
-
/dealer/decks:POST请求将创建一个新的牌组对象。对这个请求的响应被用来标识唯一的牌组。 -
/dealer/deck/{id}/hands:对此的GET请求将从给定的牌组标识符获取一个手牌对象。查询字符串将指定多少张牌。查询字符串可以使用$top选项来限制返回多少手牌。它还可以使用$skip选项跳过一些手牌,并获取以后的手牌的牌。
这些查询将需要一个 API 客户端。它们不能轻松地从浏览器中完成。一个可能的方法是使用 Postman 作为 Chrome 浏览器的插件。我们将利用使用 urllib 进行 REST 请求的方法作为处理这些更复杂 API 的客户端的起点。
如何做...
我们将把这分解成两部分:服务器和客户端。
服务器
- 从解析请求中的查询字符串的模板开始,作为 Flask 应用程序的模板。我们将改变那个例子中的视图函数:
from flask import Flask, jsonify, request, abort, make_response
from http import HTTPStatus
dealer = Flask('dealer')
- 导入任何额外的模块。在这种情况下,我们将使用
uuid模块为洗牌后的牌组创建一个唯一的键:
import uuid
我们还将使用 Werkzeug 的BadRequest响应。这使我们能够提供详细的错误消息。这比对于错误请求使用abort(400)要好一点:
from werkzeug.exceptions import BadRequest
- 定义全局状态。这包括牌组的集合。它还包括随机数生成器。为了测试目的,有一种方法可以强制使用特定的种子值:
import os
import random
random.seed(os.environ.get('DEAL_APP_SEED'))
decks = {}
- 定义一个路由——到执行特定请求的视图函数的 URL 模式。这是一个装饰器,直接放在函数的前面。它将把函数绑定到 Flask 应用程序:
@dealer.route('/dealer/decks', methods=['POST'])
我们已经定义了牌组资源,并将路由限制为只处理HTTP POST请求。这缩小了这个特定端点的语义——POST请求通常意味着 URL 将在服务器上创建新的东西。在这个例子中,它在牌组集合中创建了一个新实例。
- 定义支持这个资源的视图函数:
def make_deck():
id = str(uuid.uuid1())
decks[id]= Deck()
response_json = jsonify(
status='ok',
id=id
)
response = make_response(response_json, HTTPStatus.CREATED)
return response
uuid1()函数将基于当前主机和随机种子序列生成器创建一个通用唯一 ID。这个字符串版本是一个长的十六进制字符串,看起来像93b8fc06-5395-11e6-9e73-38c9861bf556。
我们将使用这个字符串作为创建Deck的新实例的键。响应将是一个带有两个字段的小 JSON 文档:
-
status字段将是'ok',因为一切都正常。这使我们可以提供其他包括警告或错误的状态信息。 -
id字段具有刚刚创建的牌组的 ID 字符串。这允许服务器拥有多个并发游戏,每个游戏都由一个牌组 ID 区分。
响应是使用make_response()函数创建的,这样我们就可以提供201 CREATED的 HTTP 状态,而不是默认的200 OK。这种区别很重要,因为这个请求改变了服务器的状态。
- 定义一个需要参数的路由。在这种情况下,路由将包括要处理的特定牌组 ID:
@dealer.route('/dealer/decks/<id>/hands', methods=['GET'])
<id>使这成为一个路径模板,而不是一个简单的文字路径。Flask 将解析/字符并分隔<id>字段。
- 定义一个视图函数,其参数与模板匹配。由于模板包含
<id>,视图函数也有一个名为id的参数:
def get_hands(id):
if id not in decks:
dealer.logger.debug(id)
return make_response(
'ID {} not found'.format(id), HTTPStatus.NOT_FOUND)
try:
cards = int(request.args.get('cards',13))
top = int(request.args.get('$top',1))
skip = int(request.args.get('$skip',0))
assert skip*cards+top*cards <= len(decks[id].cards), \
"$skip, $top, and cards larger than the deck"
except ValueError as ex:
return BadRequest(repr(ex))
subset = decks[id].cards[skip*cards:(skip+top)*cards]
hands = [subset[h*cards:(h+1)*cards] for h in range(top)]
response = jsonify(
[
{'hand':i, 'cards':[card.to_json() for card in hand]}
for i, hand in enumerate(hands)
]
)
return response
如果id参数的值不是牌组集合的键之一,函数将生成404 NOT FOUND响应。这个函数使用BadRequest而不是abort()函数,以包括解释性的错误消息。我们也可以在 Flask 中使用make_response()函数。
此函数还从查询字符串中提取$top、$skip和cards的值。在此示例中,所有值都恰好是整数,因此对每个值使用int()函数。对查询参数执行了一个基本的合理性检查。实际上需要进行额外的检查,鼓励读者思考可能使用的所有可能的不良参数。
subset变量是正在发牌的牌组部分。我们已经对牌组进行了切片,以在skip组cards后开始;我们在这个切片中只包括top组cards。从该切片中,hands序列将子集分解为top数量的手牌,每个手牌中都有cards。通过jsonify()函数将此序列转换为 JSON,并返回。
默认状态是200 OK,这是合适的,因为此查询是幂等的GET请求。每次发送查询时,将返回相同的一组牌。
- 定义一个运行服务器的主程序:
if __name__ == "__main__":
dealer.run(use_reloader=True, threaded=False)
客户端
这将类似于使用 urllib 进行 REST 请求食谱中的客户端模块:
- 导入用于处理 RESTful API 的基本模块:
import urllib.request
import urllib.parse
import json
- 有一系列步骤来进行
POST请求,以创建一个新的洗牌牌组。首先通过手动创建ParseResult对象来定义 URL 的各个部分。稍后将将其合并为单个字符串:
full_url = urllib.parse.ParseResult(
scheme="http",
netloc="127.0.0.1:5000",
path="/dealer" + "/decks",
params=None,
query=None,
fragment=None
)
- 从 URL、方法和标头构建
Request对象:
request = urllib.request.Request(
url = urllib.parse.urlunparse(full_url),
method = "POST",
headers = {
'Accept': 'application/json',
}
)
默认方法是GET,这对于此 API 请求是不合适的。
- 发送请求并处理响应对象。出于调试目的,打印状态和标头信息可能会有所帮助。通常,我们只需要确保状态是预期的
201。
响应文档应该是 Python 字典的 JSON 序列化,具有两个字段,状态和 ID。此客户端在使用id字段中的值之前确认响应中的状态为ok:
with urllib.request.urlopen(request) as response:
# print(response.status)
assert response.status == 201
# print(response.headers)
document = json.loads(response.read().decode("utf-8"))
print(document)
assert document['status'] == 'ok'
id = document['id']
在许多 RESTful API 中,将会有一个位置标头,它提供了一个链接到创建的对象的 URL。
- 创建一个 URL,其中包括将 ID 插入 URL 路径以及提供一些查询字符串参数。这是通过创建一个模拟查询字符串的字典,然后使用
ParseResult对象构建 URL 来完成的:
query = {'$top': 4, 'cards': 13}
full_url = urllib.parse.ParseResult(
scheme="http",
netloc="127.0.0.1:5000",
path="/dealer" + "/decks/{id}/hands".format(id=id),
params=None,
query=urllib.parse.urlencode(query),
fragment=None
)
我们使用"/decks/{id}/hands/".format(id=id)将id值插入路径。另一种方法是使用"/".join(["", "decks", id, "hands", ""])。请注意,空字符串是强制"/"出现在开头和结尾的一种方法。
- 使用完整 URL、方法和标准标头创建
Request对象:
request = urllib.request.Request(
url = urllib.parse.urlunparse(full_url),
method = "GET",
headers = {
'Accept': 'application/json',
}
)
- 发送请求并处理响应。我们将确认响应为
200 OK。然后可以解析响应以获取所请求手牌的详细信息:
with urllib.request.urlopen(request) as response:
# print(response.status)
assert response.status == 200
# print(response.headers)
cards = json.loads(response.read().decode("utf-8"))
print(cards)
当我们运行此代码时,它将创建一个新的Deck实例。然后它将发出四手牌,每手 13 张牌。查询定义了每手的确切数量和每手中的牌数。
工作原理...
服务器定义了遵循集合和集合实例的常见模式的两个路由。通常使用复数名词decks来定义集合路径。使用复数名词意味着 CRUD 操作侧重于在集合内创建实例。
在这种情况下,使用POST方法的/dealer/decks路径实现了创建操作。通过编写一个额外的视图函数来处理/dealer/decks路径的GET方法,可以支持检索。这将公开牌组集合中的所有牌组实例。
如果支持删除,可以使用DELETE方法的/dealer/decks。更新(使用PUT方法)似乎不符合创建随机牌组的服务器的想法。
在 /dealer/decks 集合中,特定的牌堆由 /dealer/decks/<id> 路径标识。设计要求使用 GET 方法从给定的牌堆中获取几手牌。
剩下的 CRUD 操作——创建、更新和删除——对于这种类型的 Deck 对象并没有太多意义。一旦创建了 Deck 对象,客户端应用程序就可以查询各种手牌。
牌堆切片
发牌算法对一副牌进行了几次切片。这些切片是基于一副牌的大小,D ,必须包含足够的牌来满足手数,h ,以及每手的牌数,c 。手数和每手的牌数必须不大于牌的大小:
h × c ≤ D
发牌的社交仪式通常涉及切牌,这是由非发牌玩家进行的非常简单的洗牌。传统上,每隔 h 张牌分配给每个手牌 H [n] :
H[n] = { D[n] [+] [h] [×] [i] :0 ≤ i < c }
在前面的公式中,H[n=0] 手中有牌 H[0] = { D[0] , D[h] , D[2h] , ..., D[c×h ] } ,H[n=1] 手中有牌 H[1] = { D[1] , D[1+h] , D[1+2h] , ..., D[1+c×h] } ,依此类推。这种发牌方式看起来比简单地将每个玩家的下一批 c 张牌发给他们更公平。
这并不是真正必要的,我们的 Python 程序以稍微更容易用 Python 计算的批次发牌:
H[n] = { D [n × c +1] : 0 ≤ i < c }
Python 代码创建了手 H[n=0] ,其中有牌 H [0] = { D [0] , D [1] , D [2] , ..., D[c-] [1] },手 H[n=1] 有牌 H [0 ] = { D[c] , D[c+] [1] , D[c+] [2] , ..., D [2c- 1] },依此类推。对于一副随机的牌,这与任何其他分配牌的方式一样公平。在 Python 中,这稍微简单一些,因为它涉及到列表切片。有关切片的更多信息,请参阅第四章中的 切片和切块列表 配方,内置数据结构 – 列表、集合、字典 。
客户端
这个交易的客户端是一系列 RESTful 请求:
- 理想情况下,操作从
GET到swagger.json开始,以获取服务器的规范。根据服务器的不同,这可能会很简单:
with urllib.request.urlopen('http://127.0.0.1:5000/dealer/swagger.json') as response
swagger = json.loads(response.read().decode("utf-8"))
-
然后,有一个
POST来创建一个新的Deck实例。这需要创建一个Request对象,以便可以将方法设置为POST。 -
然后,有一个
GET来从牌堆实例中获取一些手牌。这可以通过调整 URL 作为字符串模板来完成。将 URL 作为一组单独字段而不是一个简单的字符串进行处理,稍微更一般化。
有两种处理 RESTful 应用程序错误的方法:
-
对于未找到的资源,使用
abort(HTTPStatus.NOT_FOUND)等简单的状态响应。 -
对于某种方式无效的请求,使用
make_response(message, HTTPStatus.BAD_REQUEST)。消息可以提供所需的详细信息。
对于一些其他状态码,比如 403 Forbidden ,我们可能不想提供太多细节。在授权问题的情况下,提供太多细节通常是一个坏主意。对于这种情况,abort(HTTPStatus.FORBIDDEN) 可能是合适的。
还有更多...
我们将看一些应该考虑添加到服务器的功能:
-
在接受标头中检查
JSON -
提供 Swagger 规范
使用标头来区分 RESTful API 请求和对服务器的其他请求是很常见的。接受标头可以提供一个 MIME 类型,用于区分对 JSON 内容的请求和对面向用户的内容的请求。
@dealer.before_request 装饰器可用于注入一个过滤每个请求的函数。这个过滤器可以根据以下要求区分适当的 RESTful API 请求:
-
接受标头包括一个包含
json的 MIME 类型。通常,完整的 MIME 字符串是application/json。 -
此外,我们可以为
swagger.json文件做一个例外。这可以被视为一个 RESTful API 请求,而不考虑任何其他指示。
这是实现这一点的额外代码:
@dealer.before_request
def check_json():
if request.path == '/dealer/swagger.json':
return
if 'json' in request.headers.get('Accept', '*/*'):
return
return abort(HTTPStatus.BAD_REQUEST)
这个过滤器将简单地返回一个不详细的400 BAD REQUEST响应。提供更明确的错误消息可能会泄露关于服务器实现的太多信息。然而,如果有帮助的话,我们可以用make_response()替换abort()来返回更详细的错误。
提供 Swagger 规范
一个行为良好的 RESTful API 为各种可用的服务提供了 OpenAPI 规范。这通常打包在/swagger.json路由中。这并不一定意味着有一个字面上的文件可用。相反,这个路径被用作一个重点,以提供遵循 Swagger 2.0 规范的详细接口规范的 JSON 表示。
我们已经定义了路由/swagger.json,并将函数swagger3()绑定到这个路由。这个函数将创建一个全局对象specification的 JSON 表示:
@dealer.route('/dealer/swagger.json')
def swagger3():
response = make_response(json.dumps(specification, indent=2).encode('utf-8'))
response.headers['Content-Type'] = 'application/json'
return response
specification对象的大纲如下。重要细节已被替换为...以强调整体结构。细节如下:
specification = {
'swagger': '2.0',
'info': {
'title': '''Python Cookbook\nChapter 12, recipe 5.''',
'version': '1.0'
},
'schemes': ['http'],
'host': '127.0.0.1:5000',
'basePath': '/dealer',
'consumes': ['application/json'],
'produces': ['application/json'],
'paths': {
'/decks': {...}
'/decks/{id}/hands': {...}
}
}
这两个路径对应于服务器中的两个@dealer.route装饰器。这就是为什么通常有助于从 Swagger 规范开始设计服务器,然后构建代码以满足规范。
注意小的语法差异。Flask 使用/decks/<id>/hands,而 OpenAPI Swagger 规范使用/decks/{id}/hands。这一小细节意味着我们不能在 Python 和 Swagger 文档之间轻松地复制和粘贴。
这是/decks路径。这显示了来自查询字符串的输入参数。它还显示了包含牌组 ID 信息的201响应的细节:
'/decks': {
'post': {
'parameters': [
{
'name': 'size',
'in': 'query',
'type': 'integer',
'default': 1,
'description': '''number of decks to build and shuffle'''
}
],
'responses': {
'201': {
'description': '''Create and shuffle a deck. Returns a unique deck id.''',
'schema': {
'type': 'object',
'properties': {
'status': {'type': 'string'},
'id': {'type': 'string'}
}
}
},
'400': {
'description': '''Request doesn't accept a JSON response'''
}
}
}
/decks/{id}/hands路径具有类似的结构。它定义了查询字符串中可用的所有参数。它还定义了各种响应;一个包含卡片的200响应,并在未找到 ID 值时定义了404响应。
我们省略了每个路径的参数的一些细节。我们还省略了关于牌组结构的细节。然而,大纲总结了 RESTful API:
-
swagger键必须设置为2.0。 -
info键可以提供大量信息。这个例子只有最低要求。 -
schemes、host和basePath字段定义了此服务使用的 URL 的一些常见元素。 -
consumes字段说明了请求的Content-Type应该包括什么。 -
produces字段同时说明了请求的 Accept 头必须说明什么,以及响应的Content-Type将是什么。 -
paths字段标识了在此服务器上提供响应的所有路径。这显示了/decks和/decks/{id}/hands路径。
swagger3()函数将这个 Python 对象转换为 JSON 表示法并返回它。这实现了似乎是下载swagger.json文件的功能。内容指定了 RESTful API 服务器提供的资源。
使用 Swagger 规范
在客户端编程中,我们使用简单的字面值来构建 URL。示例看起来像下面这样:
full_url = urllib.parse.ParseResult(
scheme="http",
netloc="127.0.0.1:5000",
path="/dealer" + "/decks",
params=None,
query=None,
fragment=None
)
其中的一部分可以来自 Swagger 规范。例如,我们可以使用specification['host']和specification['basePath']来代替netloc值和path值的第一部分。这种对 Swagger 规范的使用可以提供一点额外的灵活性。
Swagger 规范是为了供人们用于做设计决策的工具消费而设计的。其真正目的是驱动 API 的自动化测试。通常,Swagger 规范会包含详细的示例,可以帮助澄清如何编写客户端应用程序。
另请参阅
- 有关更多 RESTful web 服务示例,请参阅使用 urllib 进行 REST 请求和解析请求中的查询字符串配方
解析 JSON 请求
许多 web 服务涉及请求创建新的持久对象或对现有持久对象进行更新。为了执行这些操作,应用程序将需要来自客户端的输入。
RESTful web 服务通常会接受 JSON 文档形式的输入(和产生输出)。有关 JSON 的更多信息,请参阅第九章中的阅读 JSON 文档配方,输入/输出、物理格式和逻辑布局
我们如何解析来自 web 客户端的 JSON 输入?验证输入的简单方法是什么?
准备工作
我们将扩展 Flask 应用程序,从“解析请求中的查询字符串”配方中添加用户注册功能;这将添加一个玩家,然后玩家可以请求卡片。玩家是一个资源,将涉及基本的 CRUD 操作:
-
客户端可以对
/players路径执行POST以创建新玩家。这将包括描述玩家的文档有效负载。服务将验证文档,如果有效,创建一个新的持久Player实例。响应将包括分配给玩家的 ID。如果文档无效,将发送响应详细说明问题。 -
客户端可以对
/players路径执行GET以获取玩家列表。 -
客户端可以对
/players/<id>路径执行GET以获取特定玩家的详细信息。 -
客户端可以对
/players/<id>路径执行PUT以更新特定玩家的详细信息。与初始的POST一样,这需要验证有效负载文档。 -
客户端可以对
/players/<id>路径执行DELETE以删除玩家。
与“解析请求中的查询字符串”配方一样,我们将实现这些服务的客户端和服务器部分。服务器将处理基本的POST和GET操作。我们将把PUT和DELETE操作留给读者作为练习。
我们需要一个 JSON 验证器。请参阅pypi.python.org/pypi/jsonschema/2.5.1。这特别好。还有一个 Swagger 规范验证器也很有帮助。请参阅pypi.python.org/pypi/swagger-spec-validator。
如果我们安装swagger-spec-validator包,这也会安装jsonschema项目的最新副本。整个序列可能如下所示:
**MacBookPro-SLott:pyweb slott$ pip3.5 install swagger-spec-validator**
**Collecting swagger-spec-validator**
**Downloading swagger_spec_validator-2.0.2.tar.gz**
**Requirement already satisfied (use --upgrade to upgrade):**
**jsonschema in /Library/.../python3.5/site-packages**
**(from swagger-spec-validator)**
**Requirement already satisfied (use --upgrade to upgrade):**
**setuptools in /Library/.../python3.5/site-packages**
**(from swagger-spec-validator)**
**Requirement already satisfied (use --upgrade to upgrade):**
**six in /Library/.../python3.5/site-packages**
**(from swagger-spec-validator)**
**Installing collected packages: swagger-spec-validator**
**Running setup.py install for swagger-spec-validator ... done**
**Successfully installed swagger-spec-validator-2.0.2**
我们使用pip命令安装了swagger-spec-validator包。此安装还检查了jsonschema,setuptools和six是否已安装。
有一个关于使用--upgrade的提示。使用类似以下命令升级包可能有所帮助:pip install jsonschema --upgrade。如果jsonschema的版本低于 2.5.0,则可能需要这样做。
如何做...
我们将这分解为三部分:Swagger 规范、服务器和客户端。
Swagger 规范
- 以下是 Swagger 规范的概要:
specification = {
'swagger': '2.0',
'info': {
'title': '''Python Cookbook\nChapter 12, recipe 6.''',
'version': '1.0'
},
'schemes': ['http'],
'host': '127.0.0.1:5000',
'basePath': '/dealer',
'consumes': ['application/json'],
'produces': ['application/json'],
'paths': {
'/players': {...},
'/players/{id}': {...},
}
'definitions': {
'player: {..}
}
}
首先的字段是 RESTful web 服务的基本样板。paths和definitions将填入服务的 URL 和模式定义。
- 以下是用于验证新玩家的模式定义。这将放在整体规范的定义中:
'player': {
'type': 'object',
'properties': {
'name': {'type': 'string'},
'email': {'type': 'string', 'format': 'email'},
'year': {'type': 'integer'},
'twitter': {'type': 'string', 'format': 'uri'}
}
}
整体输入文档正式描述为对象类型。该对象有四个属性:
-
一个名字,这是一个字符串
-
一个电子邮件地址,这是一个特定格式的字符串
-
Twitter URL,这是一个特定格式的字符串
-
一年,这是一个数字
JSON 模式规范语言中有一些定义的格式。email和url格式被广泛使用。格式的完整列表包括date-time、hostname、ipv4、ipv6和uri。有关定义模式的详细信息,请参见json-schema.org/documentation.html。
- 这是用于创建新玩家或获取所有玩家集合的整体
players路径:
'/players': {
'post': {
'parameters': [
{
'name': 'player',
'in': 'body',
'schema': {'$ref': '#/definitions/player'}
},
],
'responses': {
'201': {'description': 'Player created', },
'403': {'description': 'Player is invalid or a duplicate'}
}
},
'get': {
'responses': {
'200': {'description': 'All of the players defined so far'},
}
}
},
该路径定义了两种方法——post和get。post方法有一个名为player的参数。这个参数是请求的主体,并且遵循定义部分提供的玩家模式。
get方法显示了没有任何参数或响应结构的正式定义。
- 这是一个用于获取有关特定玩家的详细信息的路径的定义:
'/players/{id}': {
'get': {
'parameters': [
{
'name': 'id',
'in': 'path',
'type': 'string'
}
],
'responses': {
'200': {
'description': 'The details of a specific player',
'schema': {'$ref': '#/definitions/player'}
},
'404': {'description': 'Player ID not found'}
}
}
},
该路径类似于解析 URL 路径配方中所示的路径。URL 中提供了player键。显示了当玩家 ID 有效时的响应细节。响应具有一个定义的模式,该模式还使用了定义部分中的玩家模式定义。
这个规范将成为服务器的一部分。它可以由在@dealer.route('/swagger.json')路由中定义的视图函数提供。通常最简单的方法是创建一个包含这个规范文档的文件。
服务器
- 以解析请求中的查询字符串配方作为 Flask 应用程序的模板开始。我们将改变视图函数:
from flask import Flask, jsonify, request, abort, make_response
from http import HTTPStatus
- 导入所需的额外库。我们将使用 JSON 模式进行验证。我们还将计算字符串的哈希值,以作为 URL 中有用的外部标识符:
from jsonschema import validate
from jsonschema.exceptions import ValidationError
import hashlib
- 创建应用程序和玩家数据库。我们将使用一个简单的全局变量。一个更大的应用程序可能会使用一个适当的数据库服务器来保存这些信息:
dealer = Flask('dealer')
players = {}
- 定义用于发布到整体
players集合的路由:
@dealer.route('/dealer/players', methods=['POST'])
- 定义将解析输入文档、验证内容,然后创建持久
player对象的函数:
def make_player():
document = request.json
player_schema = specification['definitions']['player']
try:
validate(document, player_schema)
except ValidationError as ex:
return make_response(ex.message, 403)
id = hashlib.md5(document['twitter'].encode('utf-8')).hexdigest()
if id in players:
return make_response('Duplicate player', 403)
players[id] = document
response = make_response(
jsonify(
status='ok',
id=id
),
201
)
return response
这个函数遵循一个常见的四步设计:
-
验证输入文档。模式被定义为整体 Swagger 规范的一部分。
-
创建一个密钥并确认它是唯一的。这是从数据中派生出来的一个密钥。我们也可以使用
uuid模块创建唯一的密钥。 -
将新文档持久化到数据库中。在这个例子中,它只是一个单一的语句,
players[id] = document。这遵循了 RESTful API 围绕已经提供了完整功能实现的类和函数构建的理念。 -
构建一个响应文档。
- 定义一个运行服务器的主程序:
if __name__ == "__main__":
dealer.run(use_reloader=True, threaded=False)
我们可以添加其他方法来查看多个玩家或单个玩家。这些将遵循解析 URL 路径配方的基本设计。我们将在下一节中看到这些。
客户端
这将类似于解析 URL 路径配方中的客户端模块:
- 导入用于处理 RESTful API 的基本模块:
import urllib.request
import urllib.parse
import json
- 通过手动创建
ParseResult对象来逐步创建 URL。稍后将把它合并成一个字符串:
full_url = urllib.parse.ParseResult(
scheme="http",
netloc="127.0.0.1:5000",
path="/dealer" + "/players",
params=None,
query=None,
fragment=None
)
- 创建一个可以序列化为 JSON 文档并发布到服务器的对象。研究
swagger.json可以了解这个文档的模式必须是什么样的。文档将包括必需的四个属性:
document = {
'name': 'Xander Bowers',
'email': 'x@example.com',
'year': 1985,
'twitter': 'https://twitter.com/PacktPub'
}
- 我们将结合 URL、文档、方法和标头来创建完整的请求。这将使用
urlunparse()将 URL 部分合并成一个字符串。Content-Type标头通知服务器我们将提供一个 JSON 格式的文本文档:
request = urllib.request.Request(
url = urllib.parse.urlunparse(full_url),
method = "POST",
headers = {
'Accept': 'application/json',
'Content-Type': 'application/json;charset=utf-8',
},
data = json.dumps(document).encode('utf-8')
)
我们已经包括了charset选项,它指定了用于从 Unicode 字符串创建字节的特定编码。由于utf-8编码是默认的,这是不需要的。在使用不同编码的罕见情况下,这显示了如何提供替代方案。
- 发送请求并处理
response对象。出于调试目的,打印status和headers信息可能会有所帮助。通常,我们只需要确保status是预期的201 CREATED:
with urllib.request.urlopen(request) as response:
# print(response.status)
assert response.status == 201
# print(response.headers)
document = json.loads(response.read().decode("utf-8"))
print(document)
assert document['status'] == 'ok'
id = document['id']
我们检查响应文档以确保它包含两个预期字段。
我们还可以在这个客户端中包含其他查询。我们可能想要检索所有玩家或检索特定的玩家。这些将遵循解析 URL 路径配方中所示的设计。
工作原理...
Flask 会自动检查传入的文档以解析它们。我们可以简单地使用request.json来利用 Flask 内置的自动 JSON 解析。
如果输入实际上不是 JSON,那么 Flask 框架将返回400 BAD REQUEST响应。当我们的服务器应用程序引用请求的json属性时,就会发生这种情况。我们可以使用try语句来捕获400 BAD REQUEST响应对象并对其进行更改,或者可能返回不同的响应。
我们使用jsonschema包来验证输入文档。这将检查 JSON 文档的许多特性:
-
它检查 JSON 文档的整体类型是否与模式的整体类型匹配。在这个例子中,模式要求一个对象,即
{}JSON 结构。 -
对于模式中定义的每个属性并且在文档中存在的属性,它确认文档中的值是否与模式定义匹配。这意味着该值符合定义的 JSON 类型之一。如果有其他验证规则,比如格式、范围规范或数组的元素数量,也会进行检查。这个检查会递归地通过模式的所有级别进行。
-
如果有一个必需的字段列表,它会检查这些字段是否实际上都存在于文档中。
在这个配方中,我们将模式的细节保持在最低限度。在这个例子中省略的一个常见特性是必需属性的列表。我们还可以提供更详细的属性描述。例如,年份可能应该有一个最小值为1900。
在这个例子中,我们尽量将数据库更新处理保持在最低限度。在某些情况下,数据库插入可能涉及一个更复杂的过程,其中数据库客户端连接用于执行改变数据库服务器状态的命令。理想情况下,数据库处理应尽量保持在最低限度——应用程序特定的细节通常从一个单独的模块导入,并呈现为 RESTful API 资源。
在一个更大的应用程序中,可能会有一个包含所有玩家数据库处理的player_db模块。该模块将定义所有的类和函数。这通常会为player对象提供详细的模式定义。RESTful API 服务将导入这些类、函数和模式规范,并将其暴露给外部消费者。
还有更多...
Swagger 规范允许响应文档的示例。这通常在几个方面很有帮助:
-
开始设计作为响应一部分的示例文档是很常见的。编写描述文档的模式规范可能很困难,模式验证功能有助于确保规范与文档匹配。
-
一旦规范完成,下一步就是编写服务器端编程。有利于编写利用模式示例文档的单元测试。
-
对于 Swagger 规范的用户,可以使用响应的具体示例来设计客户端,并为客户端编程编写单元测试。
我们可以使用以下代码来确认服务器是否具有有效的 Swagger 规范。如果出现异常,要么没有 Swagger 文档,要么文档不符合 Swagger 模式:
from swagger_spec_validator import validate_spec_url
validate_spec_url('http://127.0.0.1:5000/dealer/swagger.json')
位置标头
201 CREATED响应包含了一份包含一些状态信息的小文档。状态信息包括分配给新创建记录的键。
201 CREATED响应通常还会在响应中包含一个额外的位置标头。此标头将提供一个 URL,可用于检索创建的文档。对于此应用程序,位置将是一个 URL,如以下示例:http://127.0.0.1:5000/dealer/players/75f1bfbda3a8492b74a33ee28326649c。
位置标头可以被客户端保存。完整的 URL 比从 URL 模板和值创建 URL 稍微简单。
服务器可以构建此标头如下:
response.headers['Location'] = url_for('get_player', id=str(id))
这依赖于 Flask 的url_for()函数。此函数接受视图函数的名称和来自 URL 路径的任何参数。然后,它使用视图函数的路由来构造完整的 URL。这将包括当前运行服务器的所有信息。插入标头后,可以返回response对象。
其他资源
服务器应该能够响应玩家列表。以下是一个最小实现,只是将数据转换为一个大的 JSON 文档:
@dealer.route('/dealer/players', methods=['GET'])
def get_players():
response = make_response(jsonify(players))
return response
更复杂的实现将支持$top和$skip查询参数,以浏览玩家列表。此外,$filter选项可能有助于实现对玩家子集的搜索。
除了对所有玩家的通用查询外,我们还需要实现一个将返回单个玩家的方法。这种视图函数通常就像下面的代码一样简单:
@dealer.route('/dealer/players/<id>', methods=['GET'])
def get_player(id):
if id not in players:
return make_response("{} not found".format(id), 404)
response = make_response(
jsonify(
players[id]
)
)
return response
此函数确认给定的 ID 是数据库中的正确键值。如果键不在数据库中,则将数据库文档转换为 JSON 表示并返回。
查询特定玩家
以下是定位数据库中特定值所需的客户端处理。这涉及多个步骤:
- 首先,我们将为特定玩家创建 URL:
id = '75f1bfbda3a8492b74a33ee28326649c'
full_url = urllib.parse.ParseResult(
scheme="http",
netloc="127.0.0.1:5000",
path="/dealer" + "/players/{id}".format(id=id),
params=None,
query=None,
fragment=None
)
我们已经从信息片段构建了 URL。这被创建为一个ParseResult对象,具有单独的字段。
- 给定 URL 后,我们可以创建一个
Request对象:
request = urllib.request.Request(
url = urllib.parse.urlunparse(full_url),
method = "GET",
headers = {
'Accept': 'application/json',
}
)
- 一旦我们有了
request对象,我们就可以发出请求并检索响应。我们需要确认响应状态为200。如果是,我们就可以解析响应正文以获取描述给定玩家的 JSON 文档:
with urllib.request.urlopen(request) as response:
assert response.status == 200
player= json.loads(response.read().decode("utf-8"))
print(player)
如果玩家不存在,urlopen()函数将引发异常。我们可以将其放在try语句中,以捕获可能引发的403 NOT FOUND异常,如果玩家 ID 不存在。
异常处理
这是所有客户端请求的一般模式。这包括显式的try语句:
try:
with urllib.request.urlopen(request) as response:
# print(response.status)
assert response.status == 201
# print(response.headers)
document = json.loads(response.read().decode("utf-8"))
# process the document here.
except urllib.error.HTTPError as ex:
print(ex.status)
print(ex.headers)
print(ex.read())
实际上有两种一般类型的异常:
-
较低级别的异常:此异常表示无法联系服务器。
ConnectionError异常是此较低级别异常的常见示例。这是OSError异常的子类。 -
来自 urllib 模块的 HTTPError 异常:此异常表示整体 HTTP 协议运行正常,但来自服务器的响应不是成功的状态代码。成功通常是在
200到299范围内的值。 -
HTTPError异常具有与正确响应类似的属性。它包括状态、标头和正文。
在某些情况下,HTTPError异常可能是服务器的几种预期响应之一。它可能不表示错误或问题。它可能只是另一个有意义的状态代码。
另请参阅
-
请参阅解析 URL 路径配方,了解其他 URL 处理示例。
-
使用 urllib 进行 REST 请求配方显示了其他查询字符串处理的示例。
为 Web 服务实现身份验证
总的来说,安全性是一个普遍的问题。应用程序的每个部分都会有安全性考虑。安全实施的部分将涉及两个密切相关的问题:
-
身份验证:客户端必须提供一些关于自己的证据。这可能涉及签名证书,也可能涉及像用户名和密码这样的凭据。它可能涉及多个因素,例如发送短信到用户应该有访问权限的电话。Web 服务器必须验证此身份验证。
-
授权:服务器必须定义权限区域,并将其分配给用户组。此外,必须将个别用户定义为授权组的成员。
虽然从技术上讲可以基于个人基础定义授权,但随着站点或应用程序的增长和变化,这往往变得笨拙。更容易为组定义安全性。在某些情况下,一个组可能(最初)只有一个人。
应用软件必须实施授权决策。对于 Flask,授权可以成为每个视图函数的一部分。个人与组的连接以及组与视图函数的连接定义了任何特定用户可用的资源。
令人困惑的是,HTTP 标准使用 HTTP Authorization头提供身份验证凭据。这可能会导致一些混淆,因为头的名称并不完全反映其目的。
有多种方式可以从 Web 客户端提供身份验证详细信息到 Web 服务器。以下是一些替代方案:
-
证书:加密的证书包括数字签名以及对证书 颁发机构(CA)的引用:这些由安全套接字层(SSL)交换。在某些环境中,客户端和服务器都必须具有用于相互认证的证书。在其他环境中,服务器提供真实性证书,但客户端不提供。这在
https方案中很常见。服务器不验证客户端的证书。 -
静态 API 密钥或令牌:Web 服务可能提供一个简单的固定密钥。这可能会附带保密建议,就像密码一样。
-
用户名和密码:Web 服务器可能通过用户名和密码识别用户。用户身份可能进一步通过电子邮件或短信消息进行确认。
-
第三方身份验证:这可能涉及使用 OpenID 等服务。有关详细信息,请参见
openid.net。这将涉及回调 URL,以便 OpenID 提供者可以返回通知信息。
此外,还有一个问题,即用户信息如何加载到 Web 服务器中。有些网站是自助服务的,用户提供一些最小的联系信息并被授予访问内容的权限。
在许多情况下,网站不是自助服务的。在允许访问之前,用户可能会经过仔细审查。访问可能涉及合同和访问数据或服务的费用。在某些情况下,一家公司将为其员工购买许可证,为一组特定的 Web 服务提供访问权限的用户列表。
这个示例将展示一个自助服务应用程序,其中没有定义一组用户。这意味着必须有一个 Web 服务来创建不需要任何身份验证的新用户。所有其他服务将需要一个经过适当身份验证的用户。
准备就绪
我们将使用Authorization头实现基于 HTTP 的身份验证的版本。这个主题有两种变体:
-
HTTP 基本身份验证:这使用简单的用户名和密码字符串。它依赖于 SSL 层来加密客户端和服务器之间的流量。
-
HTTP 摘要身份验证:这使用了用户名、密码和服务器提供的一次性随机数的更复杂的哈希。服务器计算预期的哈希值。如果哈希值匹配,则使用相同的字节来计算哈希,密码必须是有效的。这不需要 SSL。
SSL 经常被 Web 服务器用来建立它们的真实性。因为这项技术如此普遍,这意味着可以使用 HTTP 基本身份验证。这在 RESTful API 处理中是一个巨大的简化,因为每个请求都将包括Authorization头,并且客户端和服务器之间将使用安全套接字。
配置 SSL
获取和配置证书的详细信息超出了 Python 编程的范围。OpenSSL 软件包提供了用于创建用于配置安全服务器的自签名证书的工具。像 Comodo Group 和 Symantec 这样的 CA 提供了被 OS 供应商广泛认可的受信任的证书,以及 Mozilla 基金会。
使用 OpenSSL 创建证书有两个部分:
- 创建一个私钥文件。通常使用以下 OS 级命令完成:
**slott$ openssl genrsa 1024 > ssl.key**
**Generating RSA private key, 1024 bit long modulus**
**.......++++++**
**..........................++++++**
**e is 65537 (0x10001)**
openssl genrsa 1024命令创建了一个私钥文件,保存在名为ssl.key的文件中。
- 使用密钥文件创建证书。以下命令是处理此事的一种方式:
**slott$ openssl req -new -x509 -nodes -sha1 -days 365 -key ssl.key > ssl.cert**
您即将被要求输入将被合并到您的证书请求中的信息。您即将输入的是所谓的Distinguished Name(DN)。有相当多的字段,但您可以留下一些空白。对于某些字段,将有一个默认值。如果输入.,该字段将被留空。
**Country Name (2 letter code) [AU]:US**
**State or Province Name (full name) [Some-State]:Virginia**
**Locality Name (eg, city) []:**
**Organization Name (eg, company) [Internet Widgits Pty Ltd]:ItMayBeAHack**
**Organizational Unit Name (eg, section) []:**
**Common Name (e.g. server FQDN or YOUR name) []:Steven F. Lott**
**Email Address []:**
openssl req -new -x509 -nodes -sha1 -days 365 -key ssl.key命令创建了私有证书文件,保存在ssl.cert中。这个证书是私下签署的,没有 CA。它只提供了有限的功能集。
这两个步骤创建了两个文件:ssl.cert和ssl.key。我们将在下面使用这些文件来保护服务器。
用户和凭据
为了让用户能够提供用户名和密码,我们需要在服务器上存储这些信息。关于用户凭据有一个非常重要的规则:
提示
永远不要存储凭据。永远不要。
显然,存储明文密码是对安全灾难的邀请。不太明显的是,我们甚至不能存储加密密码。当用于加密密码的密钥被破坏时,将导致所有用户身份的丢失。
如果我们不存储密码,如何检查用户的密码?
解决方案是存储哈希而不是密码。第一次创建密码时,服务器保存了哈希摘要。之后每次,用户的输入都会被哈希并与保存的哈希进行比较。如果两个哈希匹配,则密码必须是正确的。核心问题是从哈希中恢复密码的极端困难。
创建密码的初始哈希值有一个三步过程:
-
创建一个随机的
salt值。通常使用os.urandom()生成 16 字节。 -
使用
salt加上密码创建hash值。通常情况下,使用hashlib来实现。具体来说,使用hashlib.pbkdf2_hmac()。为此使用特定的摘要算法,例如md5或sha224。 -
保存摘要名称、
salt和哈希字节。通常这些被合并成一个看起来像md5$salt$hash的单个字符串。md5是一个文字。$分隔算法名称、salt和hash值。
当需要检查密码时,会遵循类似的过程:
-
根据用户名,找到保存的哈希字符串。这将具有摘要算法名称、保存的盐和哈希字节的三部分结构。这些元素可以用
$分隔。 -
使用保存的盐加上用户提供的候选密码创建计算的
hash值。 -
如果计算的哈希字节与保存的哈希字节匹配,我们知道摘要算法和盐匹配;因此,密码也必须匹配。
我们将定义一个简单的类来保留用户信息以及哈希密码。我们可以使用 Flask 的g对象在请求处理期间保存用户信息。
Flask 视图函数装饰器
有几种处理身份验证检查的替代方法:
-
如果每个路由都具有相同的安全要求,那么
@dealer.before_request函数可以用于验证所有Authorization标头。这将需要一些异常处理,用于/swagger.json路由和允许未经授权的用户创建其新用户名和密码凭据的自助服务路由。 -
当一些路由需要身份验证而另一些不需要时,引入需要身份验证的路由的装饰器是很好的。
Python 装饰器是一个包装另一个函数以扩展其功能的函数。核心技术看起来像这样:
from functools import wraps
def decorate(function):
@wraps(function)
def decorated_function(*args, **kw):
# processing before
result = function(*args, **kw)
# processing after
return result
return decorated_function
这个想法是用一个新函数decorated_function来替换给定的函数function。在装饰函数的主体内,它执行原始函数。在装饰的函数之前可以进行一些处理,在函数之后也可以进行一些处理。
在 Flask 上下文中,我们将在@route装饰器之后放置我们的装饰器:
@dealer.route('/path/to/resource')
@decorate
def view_function():
return make_result('hello world', 200)
我们用@decorate装饰器包装了view_function()。装饰器可以检查身份验证,以确保用户已知。我们可以在这些函数中进行各种处理。
如何做到这一点...
我们将这分解为四个部分:
-
定义
User类 -
定义视图装饰器
-
创建服务器
-
创建一个示例客户端
定义用户类
这个类定义提供了一个单独的User对象的定义示例:
- 导入所需的模块以创建和检查密码:
import hashlib
import os
import base64
其他有用的模块包括json,以便可以正确序列化User对象。
- 定义
User类:
class User:
- 由于我们将更改密码生成和检查的某些方面,因此我们将作为整体类定义的一部分提供两个常量:
DIGEST = 'sha384'
ROUNDS = 100000
我们将使用SHA-384摘要算法。这提供了 64 字节的摘要。我们将使用基于密码的密钥派生函数 2(PBKDF2)算法进行 100,000 轮。
- 大多数情况下,我们将从 JSON 文档创建用户。这将是一个可以使用
**转换为关键字参数值的字典:
def __init__(self, **document):
self.name = document['name']
self.year = document['year']
self.email = document['email']
self.twitter = document['twitter']
self.password = None
请注意,我们不希望直接设置密码。相反,我们将单独设置密码,而不是创建用户文档时。
我们省略了其他授权细节,例如用户所属的组列表。我们还省略了一个指示密码需要更改的指示器。
- 定义设置密码
hash值的算法:
def set_password(self, password):
salt = os.urandom(30)
hash = hashlib.pbkdf2_hmac(
self.DIGEST, password.encode('utf-8'), salt, self.ROUNDS)
self.password = '$'.join(
[self.DIGEST,
base64.urlsafe_b64encode(salt).decode('ascii'),
base64.urlsafe_b64encode(hash).decode('ascii')
]
)
我们使用os.urandom()构建了一个随机盐。然后,我们使用给定的摘要算法、密码和salt构建了完整的hash值。我们使用可配置的轮数。
请注意,哈希计算是按字节而不是 Unicode 字符进行的。我们使用utf-8编码将密码编码为字节。
我们使用摘要算法的名称、盐和编码的hash值组装了一个字符串。我们使用 URL 安全的base64编码字节,以便可以轻松显示完整的哈希密码值。它可以保存在任何类型的数据库中,因为它只使用A-Z,a-z,0-9,-和_。
请注意,urlsafe_b64encode()创建一个字节值的字符串。这些必须解码才能看到它们代表的 Unicode 字符。我们在这里使用 ASCII 编码方案,因为base64只使用六十四个标准 ASCII 字符。
- 定义检查密码哈希值的算法:
def check_password(self, password):
digest, b64_salt, b64_expected_hash = self.password.split('$')
salt = base64.urlsafe_b64decode(b64_salt)
expected_hash = base64.urlsafe_b64decode(b64_expected_hash)
computed_hash = hashlib.pbkdf2_hmac(
digest, password.encode('utf-8'), salt, self.ROUNDS)
return computed_hash == expected_hash
我们已经将密码哈希分解为digest、salt和expected_hash值。由于各部分都是base64编码的,因此必须对其进行解码以恢复原始字节。
请注意,哈希计算以字节而不是 Unicode 字符工作。我们使用utf-8编码将密码编码为字节。hashlib.pbkdf2_hmac()的计算结果与预期结果进行比较。如果它们匹配,那么密码必须是相同的。
这是这个类如何使用的演示:
**>>> details = {'name': 'xander', 'email': 'x@example.com',
... 'year': 1985, 'twitter': 'https://twitter.com/PacktPub' }
>>> u = User(**details)
>>> u.set_password('OpenSesame')
>>> u.check_password('opensesame')
False
>>> u.check_password('OpenSesame')
True**
这个测试用例可以包含在类 docstring 中。有关这种测试用例的更多信息,请参见第十一章中的使用 docstring 进行测试配方,测试。
在更复杂的应用程序中,可能还会有一个用户集合的定义。这通常使用某种数据库来定位用户和插入新用户。
定义视图装饰器
- 从
functools导入@wraps装饰器。这有助于通过确保新函数具有从被装饰的函数复制的原始名称和文档字符串来定义装饰器:
from functools import wraps
- 为了检查密码,我们需要
base64模块来帮助分解Authorization头的值。我们还需要报告错误,并使用全局g对象更新 Flask 处理上下文:
import base64
from flask import g
from http import HTTPStatus
- 定义装饰器。所有装饰器都有这个基本的轮廓。我们将在下一步中替换
这里处理部分:
def authorization_required(view_function):
@wraps(view_function)
def decorated_function(*args, **kwargs):
processing here
return decorated_function
- 以下是检查头的处理步骤。请注意,遇到的每个问题都会简单地中止处理,并将
401 UNAUTHORIZED作为状态码。为了防止黑客探索算法,尽管根本原因不同,但所有结果都是相同的:
if 'Authorization' not in request.headers:
abort(HTTPStatus.UNAUTHORIZED)
kind, data = request.headers['Authorization'].split()
if kind.upper() != 'BASIC':
abort(HTTPStatus.UNAUTHORIZED)
credentials = base64.decode(data)
username, _, password = credentials.partition(':')
if username not in user_database:
abort(HTTPStatus.UNAUTHORIZED)
if not user_database[username].check_password(password):
abort(HTTPStatus.UNAUTHORIZED)
g.user = user_database[username]
return view_function(*args, **kwargs)
必须成功通过一些条件:
-
必须存在
Authorization头 -
标题必须指定基本身份验证
-
该值必须包括使用
base64编码的username:password字符串 -
用户名必须是已知的用户名
-
从密码计算出的哈希值必须与预期的密码哈希值匹配
任何单个失败都会导致401 UNAUTHORIZED响应。
创建服务器
这与解析 JSON 请求配方中显示的服务器相似。有一些重要的修改:
-
创建本地自签名证书或从证书颁发机构购买证书。对于这个配方,我们假设两个文件名分别是
ssl.cert和ssl.key。 -
导入构建服务器所需的模块。还要导入
User类定义:
from flask import Flask, jsonify, request, abort, url_for
from ch12_r07_user import User
from http import HTTPStatus
-
包括
@authorization_required装饰器定义。 -
定义一个无需身份验证的路由。这将用于创建新用户。在解析 JSON 请求配方中定义了一个类似的视图函数。这个版本需要传入文档中的密码属性。这将是用于创建哈希的明文密码。明文密码不会保存在任何地方;只有哈希值会被保留:
@dealer.route('/dealer/players', methods=['POST'])
def make_player():
try:
document = request.json
except Exception as ex:
# Document wasn't even JSON. We can fine-tune
# the error message here.
raise
player_schema = specification['definitions']['player']
try:
validate(document, player_schema)
except ValidationError as ex:
return make_response(ex.message, 403)
id = hashlib.md5(document['twitter'].encode('utf-8')).hexdigest()
if id in user_database:
return make_response('Duplicate player', 403)
new_user = User(**document)
new_user.set_password(document['password'])
user_database[id] = new_user
response = make_response(
jsonify(
status='ok',
id=id
),
201
)
response.headers['Location'] = url_for('get_player', id=str(id))
return response
创建用户后,密码将单独设置。这遵循了一些应用程序设置的模式,其中用户是批量加载的。这个处理可能为每个用户提供一个临时密码,必须立即更改。
请注意,每个用户都被分配了一个神秘的 ID。分配的 ID 是从他们的 Twitter 句柄的十六进制摘要计算出来的。这是不寻常的,但它表明有很大的灵活性可用。
如果我们希望用户选择自己的用户名,我们需要将其添加到请求文档中。我们将使用该用户名而不是计算出的 ID 值。
- 为需要身份验证的路由定义路由。在解析 JSON 请求配方中定义了一个类似的视图函数。这个版本使用
@authorization_required装饰器:
@dealer.route('/dealer/players/<id>', methods=['GET'])
@authorization_required
def get_player(id):
if id not in user_database:
return make_response("{} not found".format(id), 404)
response = make_response(
jsonify(
players[id]
)
)
return response
大多数其他路由将具有类似的@authorization_required装饰器。一些路由,如/swagger.json路由,将不需要授权。
ssl模块定义了ssl.SSLContext类。上下文可以加载以前创建的自签名证书和私钥文件。然后 Flask 对象的run()方法使用该上下文。这将从http://127.0.01:5000的 URL 中更改方案为https://127.0.0.1:5000:
import ssl
ctx = ssl.SSLContext(ssl.PROTOCOL_SSLv23)
ctx.load_cert_chain('ssl.cert', 'ssl.key')
dealer.run(use_reloader=True, threaded=False, ssl_context=ctx)
创建一个示例客户端
- 创建一个与自签名证书一起使用的 SSL 上下文:
import ssl
context = ssl.create_default_context(ssl.Purpose.SERVER_AUTH)
context.check_hostname = False
context.verify_mode = ssl.CERT_NONE
这个上下文可以用于所有urllib请求。这将礼貌地忽略证书上缺少 CA 签名。
这是我们如何使用这个上下文来获取 Swagger 规范的方式:
with urllib.request.urlopen(swagger_request, context=context) as response:
swagger = json.loads(response.read().decode("utf-8"))
pprint(swagger)
- 创建用于创建新玩家实例的 URL。请注意,我们必须使用
https作为方案。我们已经构建了一个ParseResult对象,以便分别显示 URL 的各个部分:
full_url = urllib.parse.ParseResult(
scheme="https",
netloc="127.0.0.1:5000",
path="/dealer" + "/players",
params=None,
query=None,
fragment=None
)
- 创建一个 Python 对象,将被序列化为 JSON 文档。这个模式类似于解析 JSON 请求食谱中显示的示例。这包括一个额外的属性,即纯文本:
password.document = {
'name': 'Hannah Bowers',
'email': 'h@example.com',
'year': 1987,
'twitter': 'https://twitter.com/PacktPub',
'password': 'OpenSesame'
}
因为 SSL 层使用加密套接字,所以发送这样的纯文本密码是可行的。
- 我们将 URL、文档、方法和标头组合成完整的
Request对象。这将使用urlunparse()将 URL 部分合并为一个字符串。Content-Type标头通知服务器我们将以 JSON 表示法提供文本文档:
request = urllib.request.Request(
url = urllib.parse.urlunparse(full_url),
method = "POST",
headers = {
'Accept': 'application/json',
'Content-Type': 'application/json;charset=utf-8',
},
data = json.dumps(document).encode('utf-8')
)
- 我们可以发布此文档以创建新玩家:
try:
with urllib.request.urlopen(request, context=context) as response:
# print(response.status)
assert response.status == 201
# print(response.headers)
document = json.loads(response.read().decode("utf-8"))
print(document)
assert document['status'] == 'ok'
id = document['id']
except urllib.error.HTTPError as ex:
print(ex.status)
print(ex.headers)
print(ex.read())
快乐路径将收到201状态响应,并且用户将被创建。响应将包括分配的用户 ID 和多余的状态代码。
如果用户是重复的,或者文档不匹配模式,那么将会引发HTTPError异常。这可能会有有用的错误消息可以显示。
- 我们可以使用分配的 ID 和已知密码创建一个
Authorization标头:
import base64
credentials = base64.b64encode(b'75f1bfbda3a8492b74a33ee28326649c:OpenSesame')
Authorization标头有一个两个单词的值:b"BASIC " + credentials。单词BASIC是必需的。凭据必须是username:password字符串的base64编码。在这个例子中,用户名是在创建用户时分配的特定 ID。
- 这是一个查询所有玩家的 URL。我们已经构建了一个
ParseResult对象,以便分别显示 URL 的各个部分:
full_url = urllib.parse.ParseResult(
scheme="https",
netloc="127.0.0.1:5000",
path="/dealer" + "/players",
params=None,
query=None,
fragment=None
)
- 我们可以将 URL、方法和标头组合成一个单独的
Request对象。这包括Authorization标头,其中包含用户名和密码的base64编码:
request = urllib.request.Request(
url = urllib.parse.urlunparse(full_url),
method = "GET",
headers = {
'Accept': 'application/json',
'Authorization': b"BASIC " + credentials
}
)
Request对象可用于从服务器进行查询并使用urllib处理响应:
request.urlopen(request, context=context) as response:
assert response.status == 200
# print(response.headers)
players = json.loads(response.read().decode("utf-8"))
pprint(players)
预期状态是200。响应应该是一个已知players列表的 JSON 文档。
它是如何工作的...
这个食谱有三个部分:
-
使用 SSL 提供安全通道:这使得直接交换用户名和密码成为可能。我们可以使用更简单的 HTTP 基本身份验证方案,而不是更复杂的 HTTP 摘要身份验证。Web 服务使用各种其他身份验证方案;其中大多数需要 SSL。
-
使用最佳的密码哈希实践:以任何形式保存密码都是安全风险。我们不保存纯文本密码,甚至加密密码,而是只保存密码的计算哈希值和一个随机盐字符串。这确保我们几乎不可能从哈希值中逆向工程密码。
-
使用装饰器:它用于区分需要身份验证和不需要身份验证的路由。这允许在创建 Web 服务时具有很大的灵活性。
在所有路由都需要身份验证的情况下,我们可以将密码检查算法添加到@dealer.before_request函数中。这将集中所有身份验证检查。这也意味着需要一个单独的管理流程来定义用户和散列密码。
这里的关键是服务器上的安全检查是一个简单的@authorization_required装饰器。很容易确保它在所有视图函数中都存在。
还有更多...
这个服务器有一套相对简单的授权规则:
-
大多数路由需要有效用户。这是通过在视图函数中存在
@authorization_required装饰器来实现的。 -
对于
/dealer/swagger.json的GET和/dealer/players的POST不需要有效用户。这是通过缺少额外装饰器来实现的。
在许多情况下,我们将有一个更复杂的特权、组和用户配置。最小特权原则建议用户应该被分隔成组,并且每个组应该具有尽可能少的特权来实现他们的目标。
这通常意味着我们将有一个管理组来创建新用户,但没有其他访问权限来使用 RESTful Web 服务。用户可以访问 Web 服务,但无法创建任何其他用户。
这需要对我们的数据模型进行几处更改。我们应该定义用户组并将用户分配到这些组中:
class Group:
'''A collection of users.'''
pass
administrators = Group()
players = Group()
然后我们可以扩展User的定义以包括组成员资格:
class GroupUser(User):
def __init__(self, *args, **kw):
super().__init__(*args, **kw)
self.groups = set()
当我们创建GroupUser类的新实例时,我们也可以将它们分配到特定的组中:
u = GroupUser(**document)
u.groups = set(players)
现在我们可以扩展我们的装饰器来检查经过身份验证的用户的groups属性。带参数的装饰器比无参数的装饰器复杂一些:
def group_member(group_instance):
def group_member_decorator(view_function):
@wraps(view_function)
def decorated_view_function(*args, **kw):
# Check Password and determine user
if group_instance not in g.user.groups:
abort(HTTPStatus.UNAUTHORIZED)
return view_function(*args, **kw)
return decorated_view_function
return group_member_decorator
带参数的装饰器通过创建一个包含参数的具体装饰器来工作。具体装饰器group_member_decorator将包装给定的视图函数。这将解析Authorization头,找到GroupUser实例并检查组成员资格。
我们使用#Check Password and determine user作为一个重构函数来检查Authorization头的占位符。@authorization_required装饰器的核心功能需要被提取到一个独立的函数中,以便在多个地方使用。
然后我们可以使用这个装饰器如下:
@dealer.route('/dealer/players')
@group_member(administrators)
def make_player():
etc.
这缩小了每个单独视图函数的特权范围。它确保了 RESTful Web 服务遵循最小特权原则。
创建一个命令行界面
在与具有特殊管理员特权的站点一起工作时,我们经常需要提供一种创建初始管理用户的方式。然后,这个用户可以创建所有具有非管理特权的用户。这通常是通过在 Web 服务器上直接由管理用户运行的 CLI 应用程序来完成的。
Flask 支持使用装饰器定义必须在 RESTful Web 服务环境之外运行的命令。我们可以使用@dealer.cli.command()来定义一个从命令行运行的命令。例如,这个命令可以加载初始的管理用户。也可以创建一个命令来从列表中加载用户。
getpass模块是管理用户以不会在终端上回显的方式提供他们的初始密码的一种方式。这可以确保站点的凭据正在安全处理。
构建身份验证头
依赖于 HTTP 基本Authorization头的 Web 服务可以通过两种常见的方式来支持:
-
使用凭据构建
Authorization头,并在每个请求中包含它。为此,我们需要提供字符串username:password的正确base64编码。这种替代方法的优势在于相对简单。 -
使用
urllib功能自动提供Authorization头:
from urllib.request import HTTPBasicAuthHandler, HTTPPasswordMgrWithDefaultRealm
auth_handler = urllib.request.HTTPBasicAuthHandler(
password_mgr=HTTPPasswordMgrWithDefaultRealm)
auth_handler.add_password(
realm=None,
uri='https://127.0.0.1:5000/',
user='Aladdin',
passwd='OpenSesame')
password_opener = urllib.request.build_opener(auth_handler)
我们创建了一个HTTPBasicAuthHandler的实例。这个实例包含了可能需要的所有用户名和密码。对于从多个站点收集数据的复杂应用程序,可能需要向处理程序添加多组凭据。
现在,我们将使用with password_opener(request) as response:而不是with urllib.request.urlopen(request) as response:。password_opener对象会在请求中添加Authorization头。
这种替代方案的优势在于相对灵活。我们可以在不遇到任何困难的情况下切换到使用HTTPDigestAuthHandler。我们还可以添加额外的用户名和密码。
有时候领域信息会让人感到困惑。领域是多个 URL 的容器。当服务器需要身份验证时,它会响应401状态码。这个响应将包括一个Authenticate头,指定凭据必须属于的领域。由于领域包含多个站点 URL,领域信息往往非常静态。HTTPBasicAuthHandler使用领域和 URL 信息来选择在授权响应中提供哪些用户名和密码。
通常需要编写一个技术性的尝试连接的技术性尝试,并打印401响应中的头部,以查看领域字符串是什么。一旦领域已知,就可以构建HTTPBasicAuthHandler。另一种方法是使用一些浏览器中可用的开发者模式来检查头部并查看401响应的详细信息。
另请参阅
-
服务器的适当 SSL 配置通常涉及使用由 CA 签名的证书。这涉及一个以服务器为起点并包括为各种颁发证书的各种机构的证书链。
-
许多 Web 服务实现使用诸如 GUnicorn 或 NGINX 之类的服务器。这些服务器通常在我们的应用程序之外处理 HTTP 和 HTTPS 问题。它们还可以处理复杂的证书链和捆绑包。
-
有关详细信息,请参阅
docs.gunicorn.org/en/stable/settings.html#ssl和nginx.org/en/docs/http/configuring_https_servers.html。
第十三章:应用程序集成
在本章中,我们将探讨以下示例:
-
查找配置文件
-
使用 YAML 进行配置文件
-
使用 Python 进行配置文件
-
使用类作为命名空间进行配置值
-
为构图设计脚本
-
使用日志进行控制和审计输出
-
将两个应用程序合并为一个
-
使用命令设计模式组合多个应用程序
-
在复合应用程序中管理参数和配置
-
包装和组合 CLI 应用程序
-
包装程序并检查输出
-
控制复杂的步骤序列
介绍
Python 的可扩展库为我们提供了丰富的访问多种计算资源的途径。这使得 Python 程序特别擅长于集成组件以创建复杂的复合处理。
在第五章中的使用 argparse 获取命令行输入,使用 cmd 创建命令行应用程序和使用 OS 环境设置的示例中,展示了创建顶层(主要)应用程序脚本的特定技术。在第九章中,我们研究了文件系统的输入和输出。在第十二章中,我们研究了创建服务器,这些服务器是从客户端接收请求的主要应用程序。
所有这些示例展示了 Python 中的应用程序编程的一些方面。还有一些其他有用的技术:
-
从文件中处理配置。在第五章的使用 argparse 获取命令行输入中,我们展示了解析命令行参数的技术。在使用 OS 环境设置的示例中,我们涉及了其他类型的配置细节。在本章中,我们将探讨处理配置文件的多种方法。有许多文件格式可用于存储长期配置信息:
-
INI 文件格式由
configparser模块处理。 -
YAML 文件格式非常易于使用,但需要一个不是 Python 发行版的附加模块。我们将在使用 YAML 进行配置文件中进行讨论。
-
属性文件格式是 Java 编程的典型格式,可以在 Python 中处理而不需要编写太多代码。语法与 Python 脚本重叠。
-
对于 Python 脚本,具有赋值语句的文件看起来很像属性文件,并且非常容易使用
compile()和exec()方法进行处理。我们将在使用 Python 进行配置文件中进行讨论。 -
Python 模块与类定义是一种使用 Python 语法的变体,但将设置隔离到单独的类中。这可以通过
import语句进行处理。我们将在使用类作为命名空间进行配置中进行讨论。 -
在本章中,我们将探讨设计应用程序的方法,这些应用程序可以组合在一起创建更大、更复杂的复合应用程序。
-
我们将探讨由复合应用程序引起的复杂性以及需要集中一些功能(如命令行解析)的需求。
-
我们将扩展第六章和第七章中的一些概念,应用命令设计模式的想法到 Python 程序中。
查找配置文件
许多应用程序将具有配置选项的层次结构。可能会有内置于特定版本的默认值。可能会有服务器范围(或集群范围)的值。可能会有特定用户的值,或者甚至是特定程序调用的本地配置文件。
在许多情况下,这些配置参数将被写入文件中,以便更改。Linux 中的常见传统是将系统范围的配置放在 /etc 目录中。用户的个人更改将在其主目录中,通常命名为 ~username 。
我们如何支持丰富的配置文件位置层次结构?
准备工作
示例将是一个为用户提供卡牌的网络服务。该服务在 第十二章 中的多个配方中都有展示,网络服务 。我们将忽略服务的一些细节,以便专注于从各种文件系统位置获取配置参数。
我们将遵循 bash shell 的设计模式,该模式在几个地方寻找配置文件:
-
它始于
/etc/profile文件。 -
读取该文件后,它会按照以下顺序寻找其中一个文件:
-
~/.bash_profile。 -
~/.bash_login。 -
~/.profile。
在符合 POSIX 的操作系统中,shell 会将 ~ 扩展为已登录用户的主目录。这被定义为 HOME 环境变量的值。一般来说,Python 的 pathlib 模块可以自动处理这个问题。
有几种方法可以保存程序的配置参数:
-
使用类定义的优势在于极大的灵活性和相对简单的 Python 语法。它可以使用普通的继承来包含默认值。当参数有多个来源时,它的工作效果就不那么好,因为没有简单的方法来改变类定义。
-
对于映射参数,我们可以使用
collections模块中的ChainMap集合来搜索多个不同来源的字典。 -
对于
SimpleNamespace实例,types模块提供了这个可变的类,可以从多个来源进行更新。 -
argparse模块中的Namespace实例可能很方便,因为它反映了来自命令行的选项。
bash shell 的设计模式使用了两个单独的文件。当我们包含应用程序范围的默认值时,实际上有三个配置级别。这可以通过映射和 collections 模块中的 ChainMap 类来优雅地实现。
在后续的配方中,我们将探讨解析和处理配置文件的方法。在本配方中,我们将假设已定义了一个名为 load_config_file() 的函数,该函数将从文件内容中加载配置映射:
def load_config_file(config_file):
'''Loads a configuration mapping object with contents
of a given file.
:param config_file: File-like object that can be read.
:returns: mapping with configuration parameter values
'''
# Details omitted.
我们将分别研究实现此功能的方法。本章还涵盖了实现的变体,包括 使用 YAML 进行配置文件 和 使用 Python 进行配置文件 配方。
pathlib 模块可以帮助处理这个问题。该模块提供了 Path 类的定义,可以提供有关操作系统文件的复杂信息。有关更多信息,请参阅 第九章 中的 使用 pathlib 处理文件名 配方,输入/输出、物理格式、逻辑布局 。
为什么有这么多选择?
在讨论这种设计时,有时会出现一个侧边栏话题——为什么有这么多选择?为什么不明确指定两个地方?
答案取决于设计的上下文。当创建一个全新的应用程序时,选择可能被限制在两个选项之间。然而,当替换遗留应用程序时,通常会有一个新的位置,在某些方面比遗留位置更好,但仍然需要支持遗留位置。经过几次这样的演变变化后,通常会看到一些文件的替代位置。
此外,由于 Linux 发行版之间的差异,通常会看到对于一个发行版来说是典型的变化,但对于另一个发行版来说是非典型的变化。当处理 Windows 时,也会有独特于该平台的变体文件路径。
如何做...
- 导入
Path类和ChainMap类:
from pathlib import Path
from collections import ChainMap
- 定义一个获取配置文件的整体函数:
def get_config():
- 为各种位置创建路径。这些被称为纯路径,因为它们与文件系统没有关系。它们起初是潜在文件的名称:
system_path = Path('/etc/profile')
home_path = Path('~').expanduser()
local_paths = [home_path/'.bash_profile',
home_path/'.bash_login',
home_path/'.profile']
- 定义应用程序的内置默认值:
configuration_items = [
dict(
some_setting = 'Default Value',
another_setting = 'Another Default',
some_option = 'Built-In Choice',
)
]
-
每个单独的配置文件都是从键到值的映射。各种映射对象将形成一个列表;这将成为最终的
ChainMap配置映射。我们将通过追加项目来组装映射列表,然后在加载文件后反转顺序。 -
如果存在系统范围的配置文件,则加载该文件:
if system_path.exists():
with system_path.open() as config_file:
configuration_items.append(config_file)
- 遍历其他位置,寻找要加载的文件。这会加载它找到的第一个文件:
for config_path in local_paths:
if config_path.exists():
with config_path.open() as config_file:
configuration_items.append(config_file)
break
我们已经包含了if-break模式,以在找到第一个文件后停止。这修改了循环的默认语义,从 For All 变为 There Exists。有关更多信息,请参阅避免使用 break 语句配方中的潜在问题。
- 反转列表并创建最终的
ChainMap。需要反转列表,以便首先搜索本地文件,然后是系统设置,最后是应用程序默认设置:
configuration = ChainMap(*reversed(configuration_items))
- 返回最终的配置映射:
return configuration
一旦我们构建了configuration对象,我们就可以像使用简单映射一样使用最终的配置。这个对象支持所有预期的字典操作。
工作原理...
任何面向对象语言的最优雅的特性之一是能够创建简单的对象集合。在这种情况下,对象是文件系统Path对象。
如在第九章的使用 pathlib 处理文件名配方中所述,Path对象有一个resolve()方法,可以返回从纯Path构建的具体Path。在这个配方中,我们使用了exists()方法来确定是否可以构建一个具体路径。当用于读取文件时,open()方法将解析纯Path并打开相关文件。
在第四章的创建字典-插入和更新配方中,我们看了一下使用字典的基础知识。在这里,我们将几个字典合并成一个链。当一个键在链中的第一个字典中找不到时,会检查链中后面的字典。这是一种为映射中的每个键提供默认值的方便方法。
这里有一个手动创建ChainMap的示例:
**>>> from collections import ChainMap
>>> config = ChainMap(
... {'another_setting': 2},
... {'some_setting': 1},
... {'some_setting': 'Default Value',
... 'another_setting': 'Another Default',
... 'some_option': 'Built-In Choice'})**
config对象是从三个单独的映射构建而成的。第一个可能是来自本地文件的细节,比如~/.bash_login。第二个可能是来自/etc/profile文件的系统范围设置。第三个包含应用程序范围的默认值。
当我们查询这个对象的值时,我们会看到以下内容:
**>>> config['another_setting']
2
>>> config['some_setting']
1
>>> config['some_option']
'Built-In Choice'**
对于任何给定键的值都取自映射链中的第一个实例。这允许一种非常简单的方式来拥有覆盖系统范围值的本地值,覆盖内置默认值。
还有更多...
在第十一章的Mocking External Resources配方中,我们讨论了模拟外部资源的方法,以便我们可以编写一个单元测试,而不会意外删除文件。这个配方中的代码的测试需要通过模拟Path类来模拟文件系统资源。下面是单元测试的高级概述:
import unittest
from unittest.mock import *
class GIVEN_get_config_WHEN_load_THEN_overrides(unittest.TestCase):
def setUp(self):
def runTest(self):
这为单元测试提供了一个样板结构。由于涉及的不同对象数量,模拟Path变得相当复杂。以下是发生的各种对象创建的总结:
- 对
Path类的调用创建一个Path对象。测试过程将创建两个Path对象,因此我们可以使用side_effect特性返回每个对象。我们需要确保基于要测试的代码的正确顺序返回这些值:
self.mock_path = Mock(
side_effect = [self.mock_system_path, self.mock_home_path]
)
- 对于
system_path的值,将调用Path对象的exists()方法;这将确定具体文件是否存在。然后将调用打开文件并读取内容:
self.mock_path = Mock(
side_effect = [self.mock_system_path, self.mock_home_path]
)
- 对于
home_path的值,将调用expanduser()方法将~更改为正确的主目录:
self.mock_home_path = Mock(
expanduser = Mock(
return_value = self.mock_expanded_home_path
)
)
- 然后,使用
/运算符将扩展的home_path与三个备用目录一起创建:
self.mock_expanded_home_path = MagicMock(
__truediv__ = Mock(
side_effect = [self.not_exist, self.exist, self.exist]
)
)
- 为了进行单元测试,我们决定第一个要搜索的路径不存在。其他两个存在,但我们期望只有一个会被读取。第二个将被忽略:
- 对于不存在的模拟路径,我们可以使用这个:
self.not_exist = Mock(
exists = Mock(return_value=False) )
- 对于存在的模拟路径,我们将有更复杂的东西:
self.exist = Mock( exists = Mock(return_value=True), open = mock_open() )
我们还必须通过模拟模块中的mock_open()函数来处理文件的处理。这可以处理文件作为上下文管理器使用的各种细节,这变得相当复杂。with语句需要__enter__()和__exit__()方法,这由mock_open()处理。
我们必须按照相反的顺序组装这些模拟对象。这样可以确保每个变量在使用之前都已经创建好了。下面是整个setUp()方法,显示了对象的正确顺序:
def setUp(self):
self.mock_system_path = Mock(
exists = Mock(return_value=True),
open = mock_open()
)
self.exist = Mock(
exists = Mock(return_value=True),
open = mock_open()
)
self.not_exist = Mock(
exists = Mock(return_value=False)
)
self.mock_expanded_home_path = MagicMock(
__truediv__ = Mock(
side_effect = [self.not_exist, self.exist, self.exist]
)
)
self.mock_home_path = Mock(
expanduser = Mock(
return_value = self.mock_expanded_home_path
)
)
self.mock_path = Mock(
side_effect = [self.mock_system_path, self.mock_home_path]
)
self.mock_load = Mock(
side_effect = [{'some_setting': 1}, {'another_setting': 2}]
)
除了对Path操作的模拟之外,我们还添加了一个模拟模块。mock_load对象是未定义的load_config_file()的替身。我们希望将这个测试与路径处理分开,因此模拟对象使用side_effect属性返回两个单独的值,期望它将被调用两次。
以下是一些测试,将确认路径搜索是否按照广告进行。每个测试都从应用两个修补程序开始,以创建一个修改后的上下文,用于测试get_config()函数:
def runTest(self):
with patch('__main__.Path', self.mock_path), \
patch('__main__.load_config_file', self.mock_load):
config = get_config()
# print(config)
self.assertEqual(2, config['another_setting'])
self.assertEqual(1, config['some_setting'])
self.assertEqual('Built-In Choice', config['some_option'])
patch()的第一个用法是用self.mock_path替换Path类。patch()的第二个用法是用self.mock_load函数替换load_config_file()函数;这个函数将返回两个小的配置文档。在这两种情况下,被修补的上下文是当前模块,__name__的值为"__main__"。在单元测试位于一个单独的模块的情况下,将导入被测试的模块,并使用该模块的名称。
我们可以通过检查对self.mock_load的调用来验证load_config_file()是否被正确调用。在这种情况下,每个配置文件应该有一个调用:
self.mock_load.assert_has_calls(
[
call(self.mock_system_path.open.return_value.__enter__.return_value),
call(self.exist.open.return_value.__enter__.return_value)
]
)
我们确保首先检查self.mock_system_path文件。注意调用链——Path()返回一个Path对象。该对象的open()必须返回一个将被load_config_file()函数使用的值。上下文的__enter__()方法是load_config_file()函数将使用的对象。
我们确保另一个路径是exists()方法返回True的路径。这是构建文件名的检查:
self.mock_expanded_home_path.assert_has_calls(
[call.__truediv__('.bash_profile'),
call.__truediv__('.bash_login'),
call.__truediv__('.profile')]
)
/运算符由__truediv__()方法实现。每次调用都会构建一个单独的Path实例。我们可以确认,总体上,Path对象只使用了两次。一次是用于字面量'/etc/profile',一次是用于字面量'~':
self.mock_path.assert_has_calls(
[call('/etc/profile'), call('~')]
)
请注意,两个文件都对exists()方法返回True。然而,我们期望只有这两个文件中的一个会被检查。找到一个后,第二个文件将被忽略。以下是一个确认只有一个存在检查的测试:
self.exist.assert_has_calls( [call.exists()] )
为了完整起见,我们还检查了存在的文件是否会通过整个上下文管理序列:
self.exist.open.assert_has_calls(
[call(), call().__enter__(), call().__exit__(None, None, None)]
)
第一次调用是为了self.exist对象的open()方法。从这里返回的是一个上下文,将执行__enter__()方法以及__exit__()方法。在前面的代码中,我们检查了从__enter__()返回的值是否被读取以获取配置文件内容。
另请参阅
-
在使用 YAML 进行配置文件和使用 Python 进行配置文件的方法中,我们将研究实现
load_config_file()函数的方法。 -
在第十一章的模拟外部资源方法中,我们研究了如何测试与外部资源交互的函数,比如这个函数。
使用 YAML 进行配置文件
Python 提供了多种打包应用程序输入和配置文件的方式。我们将研究使用 YAML 符号写文件,因为它简洁而简单。
我们如何用 YAML 符号表示配置细节?
准备工作
Python 没有内置的 YAML 解析器。我们需要使用pip软件包管理系统将pyyaml项目添加到我们的库中。安装的步骤如下:
**MacBookPro-SLott:pyweb slott$ pip3.5 install pyyaml**
**Collecting pyyaml**
**Downloading PyYAML-3.11.zip (371kB)**
**100% |████████████████████████████████| 378kB 2.5MB/s**
**Installing collected packages: pyyaml
Running setup.py install for pyyaml ... done
Successfully installed pyyaml-3.11**
YAML 语法的优雅之处在于简单的缩进用于显示文档的结构。以下是我们可能在 YAML 中编码的一些设置的示例:
**query:
mz:
- ANZ532
- AMZ117
- AMZ080
url:
scheme: http
netloc: forecast.weather.gov
path: /shmrn.php
description: >
Weather forecast for Offshore including the Bahamas**
这个文档可以被看作是一系列相关的 URL 的规范,它们都类似于forecast.weather.gov/shmrn.php?mz=ANZ532。文档包含了有关从方案、网络位置、基本路径和几个查询字符串构建 URL 的信息。yaml.load()函数可以加载这个 YAML 文档;它将创建以下 Python 结构:
**{'description': 'Weather forecast for Offshore including the Bahamas\n',
'query': {'mz': ['ANZ532', 'AMZ117', 'AMZ080']},
'url': {'netloc': 'forecast.weather.gov',
'path': 'shmrn.php',
'scheme': 'http'}}**
这种字典-字典结构可以被应用程序用来定制其操作。在这种情况下,它指定了一个要查询的 URL 序列,以组装更大的天气简报。
我们经常使用查找配置文件的方法来检查各种位置以找到给定的配置文件。这种灵活性通常对于创建一个可以轻松在各种平台上使用的应用程序至关重要。
在这个方法中,我们将构建前一个示例中缺失的部分,即load_config_file()函数。以下是需要填写的模板:
def load_config_file(config_file) -> dict:
'''Loads a configuration mapping object with contents
of a given file.
:param config_file: File-like object that can be read.
:returns: mapping with configuration parameter values
'''
# Details omitted.
如何做…
- 导入
yaml模块:
import yaml
- 使用
yaml.load()函数加载 YAML 语法文档:
def load_config_file(config_file) -> dict:
'''Loads a configuration mapping object with contents
of a given file.
:param config_file: File-like object that can be read.
:returns: mapping with configuration parameter values
'''
document = yaml.load(config_file)
return document
工作原理…
YAML 语法规则在yaml.org中定义。YAML 的理念是提供具有更灵活、人性化语法的类似 JSON 的数据结构。JSON 是更一般的 YAML 语法的特例。
这里的权衡是,JSON 中的一些空格和换行不重要——有可见的标点来显示文档的结构。在一些 YAML 变体中,换行和缩进决定了文档的结构;使用空格意味着 YAML 文档中的换行很重要。
JSON 语法中可用的主要数据结构如下:
-
序列:
[item, item, ...] -
映射:
{key: value, key: value, ...} -
标量:
-
字符串:
"value" -
数字:
3.1415926 -
字面值:
true,false和null
JSON 语法是 YAML 的一种风格;它被称为流风格。在这种风格中,文档结构由显式指示符标记。语法要求使用{...}和[...]来显示结构。
YAML 提供的另一种选择是块样式。文档结构由换行和缩进定义。此外,长字符串标量值可以使用普通、带引号和折叠样式的语法。以下是替代 YAML 语法的工作方式:
- 块序列:我们用-在序列的每一行前面加上。这看起来像一个项目列表,很容易阅读。这是一个例子:
**zoneid:
- ANZ532
- AMZ117
- AMZ080**
加载后,这将在 Python 中创建一个带有字符串列表的字典:{zoneid: ['ANZ532', 'AMZ117', 'AMZ080']}。
- 块映射:我们可以使用简单的
key: value语法将键与简单的标量关联起来。我们可以单独在一行上使用key:;值缩进在下面的行上。这是一个例子:
**url:
scheme: http
netloc: marine.weather.gov**
这将创建一个嵌套字典,在 Python 中看起来像这样:{'url': {'scheme': 'http', 'netloc': 'marine.weather.gov'}}。
我们还可以使用显式的键和值标记?和:。当键特别长或对象更复杂时,这可能有所帮助:
**? scheme
: http
? netloc
: marine.weather.gov**
YAML 的一些更高级功能将利用键和值之间的显式分隔:
-
对于短字符串标量值,我们可以保持它们的普通状态,YAML 规则将简单地使用所有字符,并去除前导和尾随空格。这些示例都使用了这种假设的字符串值。
-
引号可以用于字符串,就像在 JSON 中一样。
-
对于较长的字符串,YAML 引入了
|前缀;在此之后的行将保留所有的间距和换行符。
它还引入了>前缀,将单词保留为一长串文本——任何换行符都被视为单个空格字符。这在连续文本中很常见。
在这两种情况下,缩进决定了文档的哪部分是文本的一部分。
- 在某些情况下,值可能是模棱两可的。例如,美国的邮政编码都是数字——
22102。尽管 YAML 规则会将其解释为数字,但应该理解为字符串。当然,引号可能会有所帮助。为了更明确,可以在值的前面使用!!str本地标签来强制指定数据类型。例如,!!str 22102确保数字将被视为字符串对象。
还有更多...
YAML 中有一些 JSON 中没有的其他功能:
-
注释以
#开头,一直延续到行尾。它们几乎可以放在任何地方。JSON 不允许注释。 -
文档开始,由新文档开头的
---行指示。这允许一个 YAML 文件包含许多单独的对象。JSON 限制每个文件只能有一个文档。一个文档-每个文件的替代方案是一个更复杂的解析算法。YAML 提供了显式的文档分隔符和一个非常简单的解析接口。 -
具有两个单独文档的 YAML 文件:
**>>> import yaml
>>> yaml_text = '''
... ---
... id: 1
... text: "Some Words."
... ---
... id: 2
... text: "Different Words."
... '''
>>> document_iterator = iter(yaml.load_all(yaml_text))
>>> document_1 = next(document_iterator)
>>> document_1['id']
1
>>> document_2 = next(document_iterator)
>>> document_2['text']
'Different Words.'**
-
yaml_text值包含两个 YAML 文档,每个文档都以---开头。load_all()函数是一个迭代器,一次加载一个文档。应用程序必须迭代处理流中的每个文档的结果。 -
文档结束。
...行是文档的结束。 -
映射的复杂键;JSON 映射键仅限于可用的标量类型——字符串、数字、
true、false和null。YAML 允许将映射键设置得更复杂。 -
重要的是,Python 要求映射键的哈希表是不可变的对象。这意味着复杂的键必须转换为不可变的 Python 对象,通常是
tuple。为了创建一个特定于 Python 的对象,我们需要使用更复杂的本地标签。以下是一个例子:
**>>> yaml.load('''
... ? !!python/tuple ["a", "b"]
... : "value"
... ''')
{('a', 'b'): 'value'}**
-
这个例子使用
?和:来标记映射的键和值。我们这样做是因为键是一个复杂对象。键value使用了一个本地标签!!python/tuple,来创建一个元组,而不是默认的list。键的文本使用了一个流类型的 YAML 值["a", "b"]。 -
JSON 没有集合的规定。YAML 允许我们使用
!!set标签来创建一个集合,而不是一个简单的序列。集合中的项目以?前缀标识,因为它们被认为是一个映射的键,没有值。 -
请注意,
!!set标签与集合中的值处于相同的缩进级别。它在data_values的字典键内缩进:
**>>> import yaml
>>> yaml_text = '''
... document:
... id: 3
... data_values:
... !!set
... ? some
... ? more
... ? words
... '''
>>> some_document = yaml.load(yaml_text)
>>> some_document['document']['id']
3
>>> some_document['document']['data_values'] == {'some', 'more', 'words'}
True**
-
!!set本地标签修改以下序列,使其成为一个set对象,而不是默认的列表对象。结果集等于预期的 Python 集合对象{'some', 'more', 'words'}。 -
Python 可变对象规则将被应用于集合的内容。无法构建一个包含
list对象的集合,因为列表实例没有哈希值。必须使用!!python/tuple本地标签来构建一个元组集合。 -
我们还可以创建一个 Python 的两元组列表序列,它实现了有序映射。
yaml模块不会为我们直接创建OrderedDict:
**>>> import yaml
>>> yaml_text = '''
... !!omap
... - key1: string value
... - numerator: 355
... - denominator: 113
... '''
>>> yaml.load(yaml_text)
[('key1', 'string value'), ('numerator', 355), ('denominator', 113)]**
- 请注意,很难在不指定大量细节的情况下,进一步创建
OrderedDict。以下是创建OrderedDict实例的 YAML。
!!python/object/apply:collections.OrderedDict
args:
- !!omap
- key1: string value
- numerator: 355
- denominator: 113
-
args关键字是必需的,以支持!!python/object/apply标签。只有一个位置参数,它是一个从键和值序列构建的 YAML!!omap。 -
几乎任何类的 Python 对象都可以使用 YAML 本地标签来构建。任何具有简单
__init__()方法的类都可以从 YAML 序列化中构建。
这是一个简单的类定义:
class Card:
def __init__(self, rank, suit):
self.rank = rank
self.suit = suit
def __repr__(self):
return "{rank} {suit}".format_map(vars(self))
我们定义了一个具有两个位置属性的类。以下是该对象的 YAML 描述:
!!python/object/apply:__main__.Card
kwds:
rank: 7
suit: ♣
我们使用kwds键为Card构造函数提供了两个基于关键字的参数值。Unicode♣字符很好用,因为 YAML 文件是使用 UTF-8 编码的文本。
- 除了以
!!开头的本地标签之外,YAML 还支持使用tag:方案的 URI 标签。这允许使用基于 URI 的类型规范,这些规范在全局范围内是唯一的。这可以使 YAML 文档在各种上下文中更容易处理。
标签包括权限名称、日期和具体细节,以/分隔的路径形式。例如,一个标签可能看起来像这样——!<tag:www.someapp.com,2016:rules/rule1>。
另请参阅
- 查看查找配置文件配方,了解如何在多个文件系统位置搜索配置文件。我们可以很容易地将应用程序默认值、系统范围的设置和个人设置构建到单独的文件中,并由应用程序组合。

使用 Python 进行配置文件
Python 提供了多种打包应用程序输入和配置文件的方式。我们将看一下使用 Python 符号写文件,因为它既优雅又简单。
许多包使用单独的模块中的赋值语句来提供配置参数。特别是 Flask 项目可以这样做。我们在使用 Flask 框架进行 RESTful API配方中看到了 Flask,以及第十二章中的一些相关配方,网络服务。
我们如何用 Python 模块符号表示配置细节?
准备工作
Python 赋值语句符号特别优雅。它非常简单,易于阅读,而且非常灵活。如果我们使用赋值语句,可以从一个单独的模块中导入配置细节。这个模块可以有一个名字,比如settings.py,以显示它专注于配置参数。
因为 Python 将每个导入的模块视为全局单例对象,所以我们可以让应用程序的几个部分都使用import settings语句来获得当前全局应用程序配置参数的一致视图。
然而,在某些情况下,我们可能希望选择几个备选的设置文件之一。在这种情况下,我们希望使用比import语句更灵活的技术来加载文件。
我们希望能够在文本文件中提供以下形式的定义:
'''Weather forecast for Offshore including the Bahamas
'''
query = {'mz': ['ANZ532', 'AMZ117', 'AMZ080']}
url = {
'scheme': 'http',
'netloc': 'forecast.weather.gov',
'path': '/shmrn.php'
}
这是 Python 语法。参数包括两个变量,query和url。query变量的值是一个带有单个键mz和一系列值的字典。
这可以看作是一系列相关的 URL 的规范,它们都类似于forecast.weather.gov/shmrn.php?mz=ANZ532。
我们经常使用查找配置文件配方来检查各种位置以找到给定的配置文件。这种灵活性通常对于创建一个在各种平台上易于使用的应用程序是至关重要的。
在这个配方中,我们将构建前面示例中缺失的部分,即load_config_file()函数。这是需要填写的模板:
def load_config_file(config_file) -> dict:
'''Loads a configuration mapping object with contents
of a given file.
:param config_file: File-like object that can be read.
:returns: mapping with configuration parameter values
'''
# Details omitted.
如何做...
这段代码替换了load_config_file()函数中的# Details omitted.行:
- 使用内置的
compile()函数编译配置文件中的代码。这个函数需要源文本以及从中读取文本的文件名。文件名对于创建有用和正确的回溯消息是必不可少的:
code = compile(config_file.read(), config_file.name, 'exec')
-
在罕见的情况下,代码不是来自文件时,一般的做法是提供一个名字,比如
<string>,而不是文件名。 -
执行
compile()函数创建的代码对象。这需要两个上下文。全局上下文提供了任何先前导入的模块,以及__builtins__模块。本地上下文是新变量将被创建的地方:
locals = {}
exec(code, {'__builtins__':__builtins__}, locals)
return locals
-
当代码在脚本文件的顶层执行时——通常在
if __name__ == "__main__"条件内部执行——它在全局和本地是相同的上下文中执行。当代码在函数、方法或类定义内部执行时,该上下文的本地变量与全局变量是分开的。 -
通过创建一个单独的
locals对象,我们确保导入的语句不会对任何其他全局变量进行意外更改。
它是如何工作的...
Python 语言的细节,语法和语义都体现在compile()和exec()函数中。当我们启动一个脚本时,过程基本上是这样的:
-
阅读文本。使用
compile()函数编译它以创建一个代码对象。 -
使用
exec()函数来执行该代码对象。
__pycache__目录保存代码对象,并保存未更改的文本文件的重新编译。这对处理没有实质影响。
exec()函数反映了 Python 处理全局和本地变量的方式。这个函数提供了两个命名空间。这些对于通过globals()和locals()函数运行的脚本是可见的。
我们提供了两个不同的字典:
-
全局对象的字典。这些变量可以通过
global语句访问。最常见的用法是提供对导入模块的访问,这些模块始终是全局的。例如,通常会提供__builtins__模块。在某些情况下,可能需要添加其他模块。 -
本地提供的字典会被每个赋值语句更新。这个本地字典允许我们捕获在
settings模块内创建的变量。
还有更多...
这个配方构建了一个配置文件,可以完全是一系列name = value赋值。这个语句直接由 Python 赋值语句语法支持。
此外,Python 编程的全部范围都是可用的。必须做出许多工程上的权衡。
配置文件中可以使用任何语句。但这可能会导致复杂性。如果处理变得太复杂,文件就不再是配置文件,而成为应用程序的一部分。非常复杂的功能应该通过修改应用程序编程来完成,而不是通过配置设置进行操作。由于 Python 应用程序包括源代码,这相对容易做到。
除了简单的赋值语句之外,使用if语句处理替代方案也是有道理的。文件可能提供一个特定运行时环境的独特特性部分。例如,platform包可以用于隔离特性。
可能包括类似于这样的内容:
import platform
if platform.system() == 'Windows':
tmp = Path(r"C:\TEMP")
else:
tmp = Path("/tmp")
为了使这个工作,全局变量应该包括platform和Path。这是对__builtins__的合理扩展。
简单地进行一些处理也是有道理的,以便更容易地组织一些相关的设置。例如,一个应用程序可能有一些相关的文件。编写这样的配置文件可能会有所帮助:
base = Path('/var/app/')
log = base/'log'
out = base/'out'
log和out的值被应用程序使用。base的值仅用于确保其他两个位置放置在同一个目录中。
这导致了对之前显示的load_config_file()函数的以下变化。这个版本包括一些额外的模块和全局类:
from pathlib import Path
import platform
def load_config_file_path(config_file) -> dict:
code = compile(config_file.read(), config_file.name, 'exec')
globals = {'__builtins__': __builtins__,
'Path': Path, 'platform': platform}
locals = {}
exec(code, globals, locals)
return locals
包括Path和platform意味着可以编写配置文件而无需import语句的开销。这可以使设置更容易准备和维护。
参见
- 参见查找配置文件配方,了解如何搜索多个文件系统位置以查找配置文件。
使用类作为命名空间进行配置
Python 提供了各种打包应用程序输入和配置文件的方法。我们将研究使用 Python 符号写文件,因为它既优雅又简单。
许多项目允许使用类定义来提供配置参数。类层次结构的使用意味着可以使用继承技术来简化参数的组织。特别是 Flask 包可以做到这一点。我们在配方使用 Flask 框架进行 RESTful API以及一些相关的配方中看到了 Flask。
我们如何在 Python 类符号中表示配置细节?
准备工作
Python 用于定义类属性的符号特别优雅。它非常简单,易于阅读,并且相当灵活。我们可以很容易地定义一个复杂的配置语言,允许某人快速可靠地更改 Python 应用程序的配置参数。
我们可以基于类定义构建这种语言。这允许我们在单个模块中打包多个配置选项。应用程序可以加载模块并从模块中选择相关的类定义。
我们希望能够提供以下类似的定义:
class Configuration:
"""
Weather forecast for Offshore including the Bahamas
"""
query = {'mz': ['ANZ532', 'AMZ117', 'AMZ080']}
url = {
'scheme': 'http',
'netloc': 'forecast.weather.gov',
'path': '/shmrn.php'
}
我们可以在单个settings模块中创建这个Configuration类。要使用配置,主应用程序将执行以下操作:
from settings import Configuration
这使用一个固定的文件和一个固定的类名。尽管看起来缺乏灵活性,但这通常比其他选择更有用。我们有两种额外的方法来支持复杂的配置文件:
-
我们可以使用
PYTHONPATH环境变量列出配置模块的多个位置。 -
使用多重继承和混合来将默认值、系统范围的设置和本地设置合并到配置类定义中
这些技术可能有所帮助,因为配置文件位置只需遵循 Python 查找模块的规则。我们不需要实现自己的搜索配置文件的方法。
在这个示例中,我们将构建前一个示例中缺失的部分,即load_config_file()函数。以下是需要填写的模板:
def load_config_file(config_file) -> dict:
'''Loads a configuration mapping object with contents
of a given file.
:param config_file: File-like object that can be read.
:returns: mapping with configuration parameter values
'''
# Details omitted.
操作步骤...
这段代码替换了load_config_file()函数中的# Details omitted.行:
- 使用内置的
compile()函数编译给定文件中的代码。这个函数需要源文本以及从中读取文本的文件名。文件名对于创建有用和正确的回溯消息是必不可少的:
code = compile(config_file.read(), config_file.name, 'exec')
- 执行
compile()方法创建的代码对象。我们需要提供两个上下文。全局上下文可以提供__builtins__模块,以及Path类和platform模块。本地上下文是新变量将被创建的地方:
globals = {'__builtins__':__builtins__,
'Path': Path,
'platform': platform}
locals = {}
exec(code, globals, locals)
return locals['Configuration']
- 这只会从执行模块设置的本地变量中返回定义的
Configuration类。任何其他变量都将被忽略。
工作原理...
Python 语言的细节——语法和语义——体现在compile()和exec()函数中。exec()函数反映了 Python 处理全局和本地变量的方式。这个函数提供了两个命名空间。全局namespace实例包括__builtins__以及可能在文件中使用的类和模块。
本地变量命名空间将有新创建的类。这个命名空间有一个__dict__属性,使其可以通过字典方法访问。因此,我们可以通过名称提取类。该函数返回类对象,供整个应用程序使用。
我们可以将任何类型的对象放入类的属性中。我们的示例展示了映射对象。在创建类级别的属性时,没有任何限制。
我们可以在class语句内进行复杂的计算。我们可以利用这一点创建从其他属性派生的属性。我们可以执行任何类型的语句,包括if语句和for语句来创建属性值。
还有更多...
使用类定义意味着我们可以利用继承来组织配置值。我们可以轻松创建Configuration的多个子类,其中一个将被选中用于应用程序。配置可能如下所示:
class Configuration:
"""
Generic Configuration
"""
url = {
'scheme': 'http',
'netloc': 'forecast.weather.gov',
'path': '/shmrn.php'
}
class Bahamas(Configuration):
"""
Weather forecast for Offshore including the Bahamas
"""
query = {'mz': ['AMZ117', 'AMZ080']}
class Cheaspeake(Configuration):
"""
Weather for Cheaspeake Bay
"""
query = {'mz': ['ANZ532']}
这意味着我们的应用程序必须从settings模块中的可用类中选择一个合适的类。我们可以使用操作系统环境变量或命令行选项来指定要使用的类名。这个想法是我们的程序是这样执行的:
**python3 some_app.py -c settings.Chesapeake**
这将在settings模块中找到Chesapeake类。然后处理将基于该特定配置类中的细节。这个想法导致了load_config_file()函数的扩展。
为了选择可用类中的一个,我们将提供一个额外的参数,其中包含类名:
import importlib
def load_config_module(name):
module_name, _, class_name = name.rpartition('.')
settings_module = importlib.import_module(module_name)
return vars(settings_module)[class_name]
我们没有手动编译和执行模块,而是使用了更高级别的importlib模块。该模块实现了import语句的语义。请求的模块被导入;编译和执行——并将生成的模块对象分配给变量名settings_module。
然后我们可以查看模块的变量并挑选出所请求的类。vars()内置函数将从模块、类甚至本地变量中提取内部字典。
现在我们可以按照以下方式使用这个函数:
**>>> configuration = load_config_module('settings.Chesapeake')
>>> configuration.__doc__.strip()
'Weather for Cheaspeake Bay'
>>> configuration.query
{'mz': ['ANZ532']}
>>> configuration.url['netloc']
'forecast.weather.gov'**
我们在settings模块中找到了Chesapeake配置类。
配置表示
使用类似这样的类的一个后果是,类的默认显示并不是太有信息。当我们尝试打印配置时,它看起来像这样:
**>>> print(configuration)
<class 'settings.Chesapeake'>**
这几乎没有用。它提供了一小部分信息,但这远远不够用于调试。
我们可以使用vars()函数查看更多细节。但是,这显示的是本地变量,而不是继承的变量:
**>>> pprint(vars(configuration))
mappingproxy({'__doc__': '\\n Weather for Cheaspeake Bay\\n ',
'__module__': 'settings',
'query': {'mz': ['ANZ532']}})**
这样做更好,但仍然不完整。
为了查看所有设置,我们需要更复杂的东西。有趣的是,我们不能简单地为这个类定义__repr__()。在类中定义的方法将适用于该类的实例,而不是类本身。
我们创建的每个类对象都是内置type类的实例。我们可以使用元类调整type类的行为方式,并实现一个稍微更好的__repr__()方法,该方法查找所有父类的属性。
我们将使用一个__repr__扩展内置类型,该类型在显示工作配置时做得更好一些:
class ConfigMetaclass(type):
def __repr__(self):
name = (super().__name__ + '('
+ ', '.join(b.__name__ for b in super().__bases__) + ')')
base_values = {n:v
for base in reversed(super().__mro__)
for n, v in vars(base).items()
if not n.startswith('_')}
values_text = [' {0} = {1!r}'.format(name, value)
for name, value in base_values.items()]
return '\n'.join(["class {}:".format(name)] + values_text)
类名可以从超类type中的__name__属性中获得。基类的名称也包括在内,以显示此配置类的继承层次结构。
base_values是从所有基类的属性构建的。每个类按照方法解析顺序(MRO)的相反顺序进行检查。按照反向 MRO 加载所有属性值意味着首先加载所有默认值,然后用子类值覆盖。
不包含_前缀的名称被包括在内。具有_前缀的名称被悄悄地忽略。
生成的值用于创建类似类定义的文本表示。这不是原始类源代码;这是原始类定义的净效果。
这是使用这个元类的Configuration类层次结构。基类Configuration包含元类,并提供默认定义。子类使用唯一于特定环境或上下文的值扩展这些定义:
class Configuration(metaclass=ConfigMetaclass):
unchanged = 'default'
override = 'default'
feature_override = 'default'
feature = 'default'
class Customized(Configuration):
override = 'customized'
feature_override = 'customized'
我们可以利用 Python 的多重继承的所有功能来构建Configuration类定义。这可以提供将单独特性的细节合并到单个配置对象中的能力。
另见
- 我们将在第六章,类和对象的基础和第七章,更高级的类设计中查看类定义
为组合设计脚本
许多大型应用实际上是由多个较小的应用程序组合而成的。在企业术语中,它们通常被称为包含单独命令行应用程序程序的应用系统。
一些大型复杂的应用程序包括许多命令。例如,Git 应用程序有许多单独的命令,如git pull,git commit和git push。这些也可以看作是整个 Git 应用程序系统的一部分的单独应用程序。
一个应用程序可能起初是一组单独的 Python 脚本文件。在其演变过程中的某个时刻,有必要重构脚本,将特性组合起来,并从较旧的不连贯脚本创建新的组合脚本。另一条路径也是可能的,一个大型应用程序可能被分解和重构为一个新的组织。
我们如何设计一个脚本,以便将来的组合和重构尽可能简单?
准备就绪
我们需要区分 Python 脚本的几个设计特性:
-
我们已经看到了收集输入的几个方面:
-
从命令行界面和环境变量获取高度动态的输入。请参阅第五章中的使用 argparse 获取命令行输入。
-
从文件中获取更改配置选项变得很慢。请参阅查找配置文件,使用 YAML 进行配置文件和使用 Python 进行配置文件。
-
阅读任何输入文件,请参阅第九章中的使用 CSV 模块读取分隔文件、使用正则表达式读取复杂格式、读取 JSON 文档、读取 XML 文档和读取 HTML 文档的示例,输入/输出、物理格式和逻辑布局。
-
产生输出有几个方面:
-
创建日志并提供其他支持审计、控制和监控的功能。我们将在使用日志进行控制和审计输出的示例中看到其中一些。
-
创建应用程序的主要输出。这可能会被打印或写入输出文件,使用与解析输入相同的库模块。
-
应用程序的真正工作。这些是基本功能,与各种输入解析和输出格式考虑分离。该算法专门使用 Python 数据结构。
这种关注点的分离表明,无论多么简单的应用程序都应设计为几个单独的函数。这些函数应组合成一个完整的脚本。这样我们就可以将输入和输出与核心处理分开。处理是我们经常想要重用的部分。输入和输出格式应该是可以轻松更改的东西。
作为一个具体的例子,我们将看一个创建骰子掷出序列的简单应用程序。每个序列都将遵循 craps 游戏的规则。以下是规则:
-
两个骰子的第一次掷出是come out掷:
-
两点、三点或十二点的掷出是立即输。该序列有一个单一值,例如,
[(1, 1)]。 -
七点或十一点的掷出是立即赢。这个序列也有一个单一值,例如,
[(3, 4)]。 -
任何其他数字都会确定一个点。序列从点值开始,直到掷出七点或点值:
-
最终的七点是输,例如,
[(3, 1), (3, 2), (1, 1), (5, 6), (4, 3)]。 -
原始点值的最终匹配是赢。至少会有两次掷骰子。游戏的长度没有上限,例如,
[(3, 1), (3, 2), (1, 1), (5, 6), (1, 3)]。
输出是具有不同结构的项目序列。有些会是短列表。有些会是长列表。这是使用 YAML 格式文件的理想场所。
这个输出可以由两个输入控制——要创建多少样本,以及是否要给随机数生成器设定种子。出于测试目的,固定种子可能有所帮助。
如何做...
-
将所有输出显示设计为两个广泛的领域:
-
不进行处理但显示结果对象的函数(或类)。
-
日志可能用于调试、监控、审计或其他控制。这是一个横切关注点,将嵌入到应用程序的其余部分中。
在这个例子中,有两个输出——序列的序列和一些额外信息,以确认处理是否正常工作。每个掷出的点数计数是确定模拟骰子是否公平的方便方法。
掷出的序列需要写入文件。这表明write_rolls()函数被给定一个迭代器作为参数。这是一个迭代并以 YAML 格式将值转储到文件的函数:
def write_rolls(output_path, roll_iterator):
face_count = Counter()
with output_path.open('w') as output_file:
for roll in roll_iterator:
output_file.write(
yaml.dump(
roll,
default_flow_style=True,
explicit_start=True))
for dice in roll:
face_count[sum(dice)] += 1
return face_count
监控和控制输出应显示用于控制处理的输入参数。它还应提供显示骰子公平的计数:
def summarize(configuration, counts):
print(configuration)
print(counts)
-
设计(或重构)应用程序的基本处理,使其看起来像一个单一函数:
-
所有输入都是参数。
-
所有输出都由
return或yield产生。使用return创建单一结果。使用yield生成多个结果的序列迭代。
在这个例子中,我们可以很容易地将核心功能设为一个发出值序列迭代的函数。输出函数可以使用这个迭代器:
def roll_iter(total_games, seed=None):
random.seed(seed)
for i in range(total_games):
sequence = craps_game()
yield sequence
此函数依赖于craps_game()函数生成请求的样本数量。每个样本都是一个完整的游戏,显示所有的骰子掷出。此函数提供face_count计数器给这个低级函数以累积一些总数以确认一切是否正常工作。
craps_game()函数实现了 crap 游戏规则以发出一个或多个掷骰子的单个序列。这包括了单个游戏中的所有掷骰子。我们稍后会看一下这个craps_game()函数。
- 将所有的输入收集重构为一个函数(或类),它收集各种输入源。这可以包括环境变量、命令行参数和配置文件。它还可以包括多个输入文件的名称:
def get_options(argv):
parser = argparse.ArgumentParser()
parser.add_argument('-s', '--samples', type=int)
parser.add_argument('-o', '--output')
options = parser.parse_args(argv)
options.output_path = Path(options.output)
if "RANDOMSEED" in os.environ:
seed_text = os.environ["RANDOMSEED"]
try:
options.seed = int(seed_text)
except ValueError:
sys.exit("RANDOMSEED={0!r} invalid".format(seed_text))
else:
options.seed = None
return options
这个函数收集命令行参数。它还检查os.environ环境变量的集合。
参数解析器将处理解析--samples和--output选项的细节。我们可以利用argparse的其他功能来更好地验证参数值。
output_path的值是从--output选项的值创建的。类似地,RANDOMSEED环境变量的值经过验证并放入options命名空间。options对象的使用将所有不同的参数放在一个地方。
- 编写最终的
main()函数,它包含了前面的三个元素,以创建最终的整体脚本:
def main():
options = get_options(sys.argv[1:])
face_count = write_rolls(options.output_path, roll_iter(options.samples, options.seed))
summarize(options, face_count)
这将应用程序的各个方面汇集在一起。它解析命令行和环境选项。它创建一个控制总计计数器。
roll_iter()函数是核心处理。它接受各种选项,并发出一系列掷骰子。
roll_iter()方法的主要输出由write_rolls()收集,并写入给定的输出路径。控制输出由一个单独的函数写入,这样我们可以在不影响主要输出的情况下更改摘要。
它的工作原理...
输出如下:
**slott$ python3 ch13_r05.py --samples 10 --output=x.yaml**
**Namespace(output='x.yaml', output_path=PosixPath('x.yaml'), samples=10, seed=None)**
**Counter({5: 7, 6: 7, 7: 7, 8: 5, 4: 4, 9: 4, 11: 3, 10: 1, 12: 1})**
**slott$ more x.yaml**
**--- [[5, 4], [3, 4]]**
**--- [[3, 5], [1, 3], [1, 4], [5, 3]]**
**--- [[3, 2], [2, 4], [6, 5], [1, 6]]**
**--- [[2, 4], [3, 6], [5, 2]]**
**--- [[1, 6]]**
**--- [[1, 3], [4, 1], [1, 4], [5, 6], [6, 5], [1, 5], [2, 6], [3, 4]]**
**--- [[3, 3], [3, 4]]**
**--- [[3, 5], [4, 1], [4, 2], [3, 1], [1, 4], [2, 3], [2, 6]]**
**--- [[2, 2], [1, 5], [5, 5], [1, 5], [6, 6], [4, 3]]**
**--- [[4, 5], [6, 3]]**
命令行请求了十个样本,并指定了一个名为x.yaml的输出文件。控制输出是选项的简单转储。它显示了参数的值以及在options对象中设置的附加值。
控制输出包括来自十个样本的计数。这提供了一些信心,例如六、七和八的值更常出现。它显示了像三和十二这样的值出现得更少。
这里的核心前提是关注点的分离。处理有三个明显的方面:
-
输入:来自命令行和环境变量的参数由一个名为
get_options()的单个函数收集。这个函数可以从各种来源获取输入,包括配置文件。 -
输出:主要输出由
write_rolls()函数处理。其他控制输出由在Counter对象中累积总数,然后在最后转储此输出来处理。 -
处理:应用程序的基本处理被分解到
roll_iter()函数中。这个函数可以在各种上下文中重复使用。
这种设计的目标是将roll_iter()函数与周围应用程序的细节分离开来。另一个应用程序可能有不同的命令行参数,或不同的输出格式,但可以重用基本算法。
例如,可能有第二个应用程序对掷骰子的序列进行一些统计分析。这可能包括掷骰子的次数,以及最终的输赢结果。我们可以假设这两个应用程序是generator.py(如前所示)和overview_stats.py。
在使用这两个应用程序创建骰子并总结它们之后,用户可能会确定将骰子创建和统计概述合并到一个单一应用程序中会更有利。因为每个应用程序的各个方面都被分开,所以重新排列功能并创建一个新应用程序变得相对容易。现在我们可以构建一个新应用程序,它将从以下两个导入开始:
from generator import roll_iter, craps_rules
from stats_overview import summarize
这个新应用程序可以在不对其他两个应用程序进行任何更改的情况下构建。这样一来,引入新功能不会影响原始应用程序。
更重要的是,新应用程序没有涉及任何代码的复制或粘贴。新应用程序导入工作软件——对一个应用程序进行修复的更改也将修复其他应用程序中的潜在错误。
提示
通过复制和粘贴进行重用会产生技术债务。避免复制和粘贴代码。
当我们尝试从一个应用程序复制代码来创建一个新应用程序时,我们会创建一个混乱的情况。对一个副本进行的任何更改不会奇迹般地修复另一个副本中的潜在错误。当对一个副本进行更改,而另一个副本没有保持更新时,这是一种代码腐烂。
还有更多...
在前一节中,我们跳过了craps_rules()函数的细节。这个函数创建了一个包含单个Craps游戏的骰子投掷序列。它可以从单次投掷到无限长度的序列。大约 98%的游戏将是十三次或更少的骰子投掷。
规则取决于两个骰子的总和。捕获的数据包括两个骰子的分开面。为了支持这些细节,有一个namedtuple实例,具有这两个相关属性:
Roll = namedtuple('Roll', ('faces', 'total'))
def roll(n=2):
faces = list(random.randint(1, 6) for _ in range(n))
total = sum(faces)
return Roll(faces, total)
这个roll()函数创建一个namedtuple实例,其中包含显示骰子面和骰子总和的序列。craps_game()函数将生成足够的规则来返回一个完整的游戏:
def craps_game():
come_out = roll()
if come_out.total in [2, 3, 12]:
return [come_out.faces]
elif come_out.total in [7, 11]:
return [come_out.faces]
elif come_out.total in [4, 5, 6, 8, 9, 10]:
sequence = [come_out.faces]
next = roll()
while next.total not in [7, come_out.total]:
sequence.append(next.faces)
next = roll()
sequence.append(next.faces)
return sequence
else:
raise Exception("Horrifying Logic Bug")
craps_game()函数实现了 Craps 的规则。如果第一次投掷是两、三或十二,序列只有一个值,游戏就输了。如果第一次投掷是七或十一,序列也只有一个值,游戏就赢了。其余的值建立了一个点。投掷序列从点值开始。序列一直持续,直到被七或点值结束。
设计为类层次结构
roll_iter(),roll()和craps_game()方法之间的密切关系表明,将这些函数封装到一个单一的类定义中可能更好。下面是一个将所有这些功能捆绑在一起的类:
class CrapsSimulator:
def __init__(self, seed=None):
self.rng = random.Random(seed)
self.faces = None
self.total = None
def roll(self, n=2):
self.faces = list(self.rng.randint(1, 6) for _ in range(n))
self.total = sum(self._faces)
def craps_game(sel):
self.roll()
if self.total in [2, 3, 12]:
return [self.faces]
elif self.total in [7, 11]:
return [self.faces]
elif self.total in [4, 5, 6, 8, 9, 10]:
point, sequence = self.total, [self.faces]
self.roll()
while self.total not in [7, point]:
sequence.append(self.faces)
self.roll()
sequence.append(self.faces)
return sequence
else:
raise Exception("Horrifying Logic Bug")
def roll_iter(total_games):
for i in range(total_games):
sequence = self.craps_game()
yield sequence
这个类包括模拟器的初始化,包括自己的随机数生成器。它将使用给定的种子值,或者内部算法将选择种子值。roll()方法将设置self.total和self.faces实例变量。
craps_game()生成一个游戏的骰子序列。它使用roll()方法和两个实例变量self.total和self.faces来跟踪骰子的状态。
roll_iter()方法生成游戏序列。请注意,此方法的签名与前面的roll_iter()函数并不完全相同。这个类将随机数种子的生成与游戏创建算法分开。
重写main()以使用CrapsSimulator类留给读者作为练习。由于方法名称与原始函数名称相似,重构不应该太复杂。
另请参阅
-
在第五章的用户输入和输出中查看使用 argparse 获取命令行输入的方法,了解使用
argparse从用户那里获取输入的背景知识 -
查看查找配置文件的方法,以便追踪配置文件
-
使用日志记录控制和审计输出的方法查看日志记录
-
在将两个应用程序合并为一个的配方中,我们将看看如何合并遵循这种设计模式的应用程序
使用日志记录控制和审计输出
在为组合设计脚本的配方中,我们考察了应用程序的三个方面:
-
收集输入
-
产生输出
-
连接输入和输出的基本处理
应用程序产生几种不同类型的输出:
-
帮助用户做出决策或采取行动的主要输出
-
确认程序完全正确工作的控制信息
-
用于跟踪持久数据库中状态变化历史的审计摘要
-
指示应用程序为什么不工作的任何错误消息
将所有这些不同方面都归并到写入标准输出的print()请求中并不是最佳选择。实际上,这可能会导致混乱,因为太多不同的输出被合并到一个流中。
操作系统提供了两个输出文件,标准输出和标准错误。在 Python 中,可以通过sys模块的sys.stdout和sys.stderr来看到这些文件。默认情况下,print()方法会写入sys.stdout文件。我们可以改变这一点,将控制、审计和错误消息写入sys.stderr。这是朝着正确方向迈出的重要一步。
Python 提供了logging包,可以用来将辅助输出定向到单独的文件。它还可以用来格式化和过滤附加输出。
我们如何正确使用日志记录?
准备工作
在为组合设计脚本的配方中,我们看了一个生成带有模拟原始输出的 YAML 文件的应用程序。在这个配方中,我们将看一个应用程序,它消耗这些原始数据并生成一些统计摘要。我们将称这个应用程序为overview_stats.py。
遵循分离输入、输出和处理的设计模式,我们将有一个类似这样的main()应用程序:
def main():
options = get_options(sys.argv[1:])
if options.output is not None:
report_path = Path(options.output)
with report_path.open('w') as result_file:
process_all_files(result_file, options.file)
else:
process_all_files(sys.stdout, options.file)
这个函数将从各种来源获取选项。如果命名了输出文件,它将使用with语句上下文创建输出文件。然后这个函数将处理所有命令行参数文件作为输入,从中收集统计信息。
如果没有提供输出文件名,这个函数将写入sys.stdout文件。这将显示可以使用操作系统 shell 的>运算符重定向的输出,以创建一个文件。
main()函数依赖于process_all_files()函数。process_all_files()函数将遍历每个参数文件,并从该文件中收集统计信息。这个函数看起来是这样的:
def process_all_files(result_file, file_names):
for source_path in (Path(n) for n in file_names):
with source_path.open() as source_file:
game_iter = yaml.load_all(source_file)
statistics = gather_stats(game_iter)
result_file.write(
yaml.dump(dict(statistics), explicit_start=True)
)
process_all_files()函数将gather_stats()应用于file_names可迭代中的每个文件。然后将生成的集合写入给定的result_file。
注意
这里显示的函数将处理和输出混合在一起,这种设计并不理想。我们将在将两个应用程序合并为一个的配方中解决这个设计缺陷。
基本处理在gather_stats()函数中。给定一个文件路径,它将读取并总结该文件中的游戏。然后产生的摘要对象可以作为整体显示的一部分,或者在这种情况下,附加到一系列 YAML 格式的摘要中:
def gather_stats(game_iter):
counts = Counter()
for game in game_iter:
if len(game) == 1 and sum(game[0]) in (2, 3, 12):
outcome = "loss"
elif len(game) == 1 and sum(game[0]) in (7, 11):
outcome = "win"
elif len(game) > 1 and sum(game[-1]) == 7:
outcome = "loss"
elif len(game) > 1 and sum(game[0]) == sum(game[-1]):
outcome = "win"
else:
raise Exception("Wait, What?")
event = (outcome, len(game))
counts[event] += 1
return counts
这个函数确定了四种游戏终止规则中的哪一种适用于骰子掷出的顺序。它首先打开给定的源文件,然后使用load_all()函数遍历所有的 YAML 文档。每个文档都是一个单独的游戏,表示为一系列骰子对。
这个函数使用第一个(和最后一个)掷骰子来确定游戏的整体结果。有四条规则,应该列举出所有可能的逻辑事件组合。如果在我们的推理中出现错误,异常将被引发以警示我们某种特殊情况没有符合设计的方式。
游戏被简化为一个具有结果和长度的单个事件。这些被累积到一个Counter对象中。游戏的结果和长度是我们正在计算的两个值。这些是更复杂或复杂的统计分析的替代品。
我们已经仔细地将几乎所有与文件相关的考虑从这个函数中分离出来。gather_stats()函数将使用任何可迭代的游戏数据源。
这是应用程序的输出。它不是很漂亮;这是一个 YAML 文档,可用于进一步处理:
**slott$ python3 ch13_r06.py x.yaml**
**---**
**? !!python/tuple [loss, 2]**
**: 2**
**? !!python/tuple [loss, 3]**
**: 1**
**? !!python/tuple [loss, 4]**
**: 1**
**? !!python/tuple [loss, 6]**
**: 1**
**? !!python/tuple [loss, 8]**
**: 1**
**? !!python/tuple [win, 1]**
**: 1**
**? !!python/tuple [win, 2]**
**: 1**
**? !!python/tuple [win, 4]**
**: 1**
**? !!python/tuple [win, 7]**
**: 1**
我们需要将日志记录功能插入所有这些函数中,以显示正在读取的文件以及处理文件时的任何错误或问题。
此外,我们将创建两个日志。一个将有详细信息,另一个将有已创建文件的最小摘要。第一个日志可以进入sys.stderr,当程序运行时将在控制台显示。另一个日志将附加到长期的log文件中,以覆盖应用程序的所有用途。
满足不同需求的一种方法是创建两个记录器,每个记录器具有不同的意图。这两个记录器还将具有截然不同的配置。另一种方法是创建一个单一的记录器,并使用Filter对象来区分每个记录器的内容。我们将专注于创建单独的记录器,因为这样更容易开发和更容易进行单元测试。
每个记录器都有各种方法,反映了消息的严重性。logging包中定义的严重级别包括以下内容:
-
DEBUG:通常不显示这些消息,因为它们的目的是支持调试。
-
INFO:这些消息提供有关正常、顺利处理的信息。
-
WARNING:这些消息表明处理可能以某种方式受到影响。警告的最明智用例是当函数或类已被弃用时:它们可以工作,但应该被替换。这些消息通常会显示。
-
ERROR:处理无效,输出不正确或不完整。在长时间运行的服务器的情况下,单个请求可能会出现问题,但作为一个整体,服务器可以继续运行。
-
CRITICAL:更严重的错误级别。通常,这是由长时间运行的服务器使用的,其中服务器本身无法继续运行,并且即将崩溃。
方法名称与严重级别类似。我们使用logging.info()来写入 INFO 级别的消息。
如何做到...
- 我们将首先将基本的日志记录功能实现到现有的函数中。这意味着我们需要
logging模块:
import logging
应用程序的其余部分将使用许多其他软件包:
import argparse
import sys
from pathlib import Path
from collections import Counter
import yaml
- 我们将创建两个作为模块全局变量的记录器对象。创建函数可以放在创建全局变量的脚本的任何位置。一个位置是在
import语句之后尽早放置这些内容。另一个常见的选择是在最后附近,但在任何__name__ == "__main__"脚本处理之外。这些变量必须始终被创建,即使模块作为库导入。
记录器具有分层名称。我们将使用应用程序名称和内容后缀来命名记录器。overview_stats.detail记录器将具有处理详细信息。overview_stats.write记录器将标识已读取和已写入的文件;这与审计日志的概念相对应,因为文件写入跟踪输出文件集合中的状态更改:
detail_log = logging.getLogger("overview_stats.detail")
write_log = logging.getLogger("overview_stats.write")
我们现在不需要配置这些记录器。如果我们什么都不做,这两个记录器对象将默默地接受单独的日志条目,但不会进一步处理数据。
- 我们将重写
main()函数以总结处理的两个方面。这将使用write_log记录器对象来显示何时创建新文件:
def main():
options = get_options(sys.argv[1:])
if options.output is not None:
report_path = Path(options.output)
with report_path.open('w') as result_file:
process_all_files(result_file, options.file)
write_log.info("wrote {}".format(report_path))
else:
process_all_files(sys.stdout, options.file)
我们添加了write_log.info("wrote {}".format(result_path))一行,将信息消息放入日志中已写入的文件。
- 我们将重写
process_all_files()函数,以在读取文件时提供注释:
def process_all_files(result_file, file_names):
for source_path in (Path(n) for n in file_names):
detail_log.info("read {}".format(source_path))
with source_path.open() as source_file:
game_iter = yaml.load_all(source_file)
statistics = gather_stats(game_iter)
result_file.write(
yaml.dump(dict(statistics), explicit_start=True)
)
我们添加了detail_log.info("read {}".format(source_path))行,以在每次读取文件时将信息消息放入详细日志中。
gather_stats()函数可以添加一行日志来跟踪正常操作。此外,我们还为逻辑错误添加了一个日志条目:
def gather_stats(game_iter):
counts = Counter()
for game in game_iter:
if len(game) == 1 and sum(game[0]) in (2, 3, 12):
outcome = "loss"
elif len(game) == 1 and sum(game[0]) in (7, 11):
outcome = "win"
elif len(game) > 1 and sum(game[-1]) == 7:
outcome = "loss"
elif len(game) > 1 and sum(game[0]) == sum(game[-1]):
outcome = "win"
else:
detail_log.error("problem with {}".format(game))
raise Exception("Wait, What?")
event = (outcome, len(game))
detail_log.debug("game {} -> event {}".format(game, event))
counts[event] += 1
return counts
detail_log记录器用于收集调试信息。如果将整体日志级别设置为包括调试消息,我们将看到此额外输出。
get_options()函数还将写入一个调试行。这可以通过将选项显示在日志中来帮助诊断问题:
def get_options(argv):
parser = argparse.ArgumentParser()
parser.add_argument('file', nargs='*')
parser.add_argument('-o', '--output')
options = parser.parse_args(argv)
detail_log.debug("options: {}".format(options))
return options
- 我们可以添加一个简单的配置来查看日志条目。这是作为第一步来简单确认有两个记录器,并且它们被正确使用:
if __name__ == "__main__":
logging.basicConfig(stream=sys.stderr, level=logging.INFO)
main()
此日志配置构建了默认处理程序对象。此对象仅在给定流上打印所有日志消息。此处理程序分配给根记录器;它将应用于此记录器的所有子记录器。因此,前面代码中创建的两个记录器将发送到同一个流。
以下是运行此脚本的示例:
**slott$ python3 ch13_r06a.py -o sum.yaml x.yaml
INFO:overview_stats.detail:read x.yaml
INFO:overview_stats.write:wrote sum.yaml**
日志中有两行。两者的严重性都是 INFO。第一行来自overview_stats.detail记录器。第二行来自overview_stats.write记录器。默认配置将所有记录器发送到sys.stdout。
- 为了将不同的记录器路由到不同的目的地,我们需要比
basicConfig()函数更复杂的配置。我们将使用logging.config模块。dictConfig()方法可以提供完整的配置选项。这样做的最简单方法是将配置写入 YAML,然后使用yaml.load()函数将其转换为内部的dict对象:
import logging.config
config_yaml = '''
version: 1
formatters:
default:
style: "{"
format: "{levelname}:{name}:{message}"
# Example: INFO:overview_stats.detail:read x.yaml
timestamp:
style: "{"
format: "{asctime}//{levelname}//{name}//{message}"
handlers:
console:
class: logging.StreamHandler
stream: ext://sys.stderr
formatter: default
file:
class: logging.FileHandler
filename: write.log
formatter: timestamp
loggers:
overview_stats.detail:
handlers:
- console
overview_stats.write:
handlers:
- file
- console
root:
level: INFO
'''
YAML 文档被包含在一个三重撇号字符串中。这使我们能够写入尽可能多的文本。我们使用 YAML 表示法在大块文本中定义了五件事:
-
version键的值必须为 1。 -
formatters键的值定义了日志格式。如果未指定此项,那么默认格式只显示消息正文,不包括级别或记录器信息: -
此处定义的
default格式化程序与basicConfig()函数创建的格式相同。 -
此处定义的
timestamp格式化程序是一个更复杂的格式,包括记录的日期时间戳。为了使文件更容易解析,使用了//作为列分隔符。 -
handlers键定义了两个记录器的两个处理程序。console处理程序写入流sys.stderr。我们指定了此处理程序将使用的格式化程序。此定义与basicConfig()函数创建的配置相对应。
file处理程序写入文件。打开文件的默认模式是a,这将追加到文件,文件大小没有上限。还有其他处理程序可以在多个文件之间轮换,每个文件都有限制的大小。我们提供了一个显式的文件名,以及一个将在文件中放入比在控制台上显示的更多细节的格式化程序:
-
loggers键为应用程序将创建的两个记录器提供了配置。任何以overview_stats.detail开头的记录器名称将仅由控制台处理程序处理。任何以overview_stats.write开头的记录器名称将同时发送到文件处理程序和控制台处理程序。 -
root键定义了顶级记录器。它的名称是''(空字符串),以防我们需要在代码中引用它。设置根记录器的级别将为该记录器的所有子记录器设置级别。
- 使用配置来包装
main()函数,如下所示:
logging.config.dictConfig(yaml.load(config_yaml))
main()
logging.shutdown()
- 这将以已知状态开始记录。它将处理应用程序。它将完成所有日志缓冲区的处理,并正确关闭任何文件。
工作原理...
将日志引入应用程序有三个部分:
-
创建记录器对象
-
在重要状态更改附近放置日志请求
-
作为一个整体配置日志系统
创建记录器可以通过多种方式完成。此外,也可以忽略。作为默认值,我们可以使用logging模块本身作为记录器。例如,如果我们使用logging.info()方法,这将隐式地使用根记录器。
更常见的方法是创建一个与模块名称相同的记录器:
logger = logging.getLogger(__name__)
对于顶级主脚本,这将具有名称"__main__"。对于导入的模块,名称将与模块名称匹配。
在更复杂的应用程序中,将有各种记录器用于各种目的。在这些情况下,仅仅将记录器命名为模块可能无法提供所需的灵活性。
有两个概念可以用来为记录器分配名称。通常最好选择其中一个,并在整个大型应用程序中坚持使用它:
-
遵循包和模块的层次结构。这意味着特定于类的记录器可能具有类似
package.module.class的名称。同一模块中的其他类将共享一个共同的父记录器名称。然后可以设置整个包的日志级别,特定模块之一的日志级别,或者只是其中一个类的日志级别。 -
根据受众或用例遵循层次结构。顶级名称将区分日志的受众或目的。我们可能会有名称为
event,audit和可能debug的顶级记录器。这样,所有审计记录器的名称都将以"audit."开头。这样可以很容易地将给定父级下的所有记录器路由到特定处理程序。
在这个示例中,我们使用了第一种命名风格。记录器名称与软件架构相对应。将日志请求放置在所有重要状态更改附近应该相对简单。日志中应包含各种有趣的状态更改:
-
对持久资源的任何更改都可能是包含
INFO级别消息的好地方。任何 OS 更改(通常是文件系统)都有可能进行日志记录。同样地,数据库更新和应该更改 Web 服务状态的请求也应该被记录。 -
每当出现无法进行持久状态更改的问题时,应该有一个
ERROR消息。任何 OS 级别的异常在被捕获和处理时都可以被记录。 -
在长时间的复杂计算中,可能有助于在特别重要的赋值语句之后记录
DEBUG消息。在某些情况下,这是一个提示,表明长时间的计算可能需要分解成函数,以便可以单独测试它们。 -
对内部应用程序资源的任何更改都应该产生一个
DEBUG消息,以便可以通过日志跟踪对象状态更改。 -
当应用程序进入错误状态时。这通常是由于异常。在某些情况下,将使用
assert语句来检测程序的状态,并在出现问题时引发异常。一些异常以EXCEPTION级别记录。然而,一些异常只需要DEBUG级别的消息,因为异常被屏蔽或转换。一些异常可能以ERROR或CRITICAL级别记录。
日志的第三个方面是配置记录器,以便将请求路由到适当的目的地。默认情况下,如果没有任何配置,记录器将悄悄地创建日志事件,但不会显示它们。
通过最小配置,我们可以在控制台上看到所有日志事件。这可以通过basicConfig()方法完成,并且涵盖了大量简单的用例,而无需任何真正的麻烦。我们可以使用文件名而不是流来提供命名文件。也许最重要的功能是通过basicConfig()方法在根记录器上设置日志级别,从而提供一种简单的启用调试的方法。
配方中的示例配置使用了两个常见的处理程序——StreamHandler和FileHandler类。还有十几个以上的处理程序,每个都具有独特的功能,用于收集和发布日志消息。
还有更多...
- 请参阅为组合设计脚本配方,了解这个应用程序的补充部分。
将两个应用程序合并为一个
在为组合设计脚本配方中,我们研究了一个简单的应用程序,通过模拟过程创建了一组统计数据。在使用日志记录进行控制和审计输出配方中,我们研究了一个总结统计数据的应用程序。在这个配方中,我们将结合这两个单独的应用程序,创建一个单一的复合应用程序,既创建又总结统计数据。
有几种常见的方法可以将这两个应用程序组合起来:
-
一个 shell 脚本可以运行模拟器,然后运行分析器
-
一个 Python 程序可以代替 shell 脚本,并使用
runpy模块来运行每个程序 -
我们可以从每个应用程序的基本特性构建一个复合应用程序
在为组合设计脚本配方中,我们研究了应用程序的三个方面:
-
输入收集
-
产生输出
-
连接输入和输出的基本处理
在这个配方中,我们研究了一种设计模式,可以将几个 Python 语言组件组合成一个更大的应用程序。
我们如何将应用程序组合成一个复合应用程序?
准备工作
在为组合设计脚本和使用日志记录进行控制和审计输出的配方中,我们遵循了一个设计模式,将输入收集、基本处理和输出产生分开。这个设计模式的目标是将有趣的部分聚集在一起,以便将它们组合和重新组合成更高级的结构。
请注意,这两个应用程序之间存在微小的不匹配。我们可以借用数据库工程(也是电气工程)的一个短语,称之为阻抗不匹配。在电气工程中,这是一个电路设计问题,通常通过使用一个叫做变压器的设备来解决。这可以用来匹配电路组件之间的阻抗。
在数据库工程中,当数据库具有规范化的扁平数据,但编程语言使用丰富结构的复杂对象时,这种问题会出现。对于 SQL 数据库,这是一个常见问题,使用SQLAlchemy等包作为对象关系管理(ORM)层。这一层是扁平数据库行(通常来自多个表)和复杂 Python 结构之间的变压器。
在构建复合应用程序时,这个例子中出现的阻抗不匹配是一个主要问题。模拟器的设计是比统计摘要更频繁地运行。对于解决这类问题,我们有几种选择:
-
总体重新设计:这可能不是一个明智的选择,因为这两个组件应用程序有一定数量的用户基础。在其他情况下,新的用例是一个机会,可以进行全面的修复并清理一些技术债务。
-
包括迭代器:这意味着当我们构建复合应用程序时,我们将添加一个
for语句来执行多次模拟运行,然后将其处理成一个单一的摘要。这与原始设计意图相符。 -
一个列表:这意味着复合应用程序将运行一个模拟,并将这个单一模拟输出提供给摘要。这修改了结构以进行更多的摘要;摘要可能需要组合成预期的单一结果。
在这两者之间的选择取决于首先导致创建复合应用程序的用户故事。这也可能取决于已建立的用户基础。对于我们的目的,我们将假设用户已经意识到 1,000 次模拟运行 1,000 个样本是标准的,并且他们希望遵循包括迭代器设计来创建一个复合过程。
作为练习,读者应该追求替代设计。假设用户更愿意在单个模拟中运行 1,000,000 个样本。对于这一点,用户更希望摘要工作采用一个列表设计。
我们还将看看另一个选项。在这种情况下,我们将执行 100 次模拟运行,分布在多个并发工作进程中。这将减少创建一百万个样本的时间。这是包括迭代器复合设计的变体。
如何做...
-
遵循将复杂过程分解为与输入或输出细节无关的函数的设计模式。有关此内容的详细信息,请参阅设计用于组合的脚本食谱。
-
从工作模块中导入基本函数。在这种情况下,这两个模块的名称相对不那么有趣,
ch13_r05和ch13_r06:
from ch13_r05 import roll_iter
from ch13_r06 import gather_stats
- 导入所需的任何其他模块。在本示例中,我们将使用
Counter函数来准备摘要:
from collections import Counter
- 创建一个新函数,该函数将来自其他应用程序的现有函数组合在一起。一个函数的输出是另一个函数的输入:
def summarize_games(total_games, *, seed=None):
game_statistics = gather_stats(roll_iter(total_games, seed=seed))
return game_statistics
在许多情况下,明确地堆叠函数,创建中间结果更有意义。当有多个函数创建一种映射-减少管道时,这一点尤为重要:
def summarize_games_2(total_games, *, seed=None):
game_roll_history = roll_iter(total_games, counts, seed=seed)
game_statistics = gather_stats(game_roll_history)
return game_statistics
我们已将处理分解为具有中间变量的步骤。game_roll_history变量是roll_iter()函数的输出。这个生成器的输出是gather_states()函数的可迭代输入,保存在game_statistics变量中。
- 编写使用此复合过程的输出格式化函数。例如,这是一个练习
summarize_games()函数的复合过程。这也编写了输出报告:
def simple_composite(games=100000):
start = time.perf_counter()
stats = summarize_games(games)
end = time.perf_counter()
games = sum(stats.values())
print('games', games)
print(win_loss(stats))
print("{:.2f} seconds".format(end-start))
- 可以使用
argparse模块来收集命令行选项。有关此内容的示例包括设计用于组合的脚本食谱。
工作原理...
这种设计的核心特点是将应用程序的各种关注点分离为独立的函数或类。这两个组件应用程序从输入、处理和输出关注点开始进行了设计。从这个基础开始,很容易导入和重用处理。这也使得两个原始应用程序保持不变。
目标是从工作模块中导入函数,并避免复制和粘贴编程。从一个文件复制一个函数并粘贴到另一个文件意味着对一个文件所做的任何更改不太可能被应用到另一个文件。这两个副本将慢慢分歧,导致有时被称为代码腐烂的现象。
当一个类或函数做了几件事时,重用潜力会减少。这导致了重用的反向幂定律的观察——类或函数的可重用性R(c)与该类或函数中特性数量的倒数F(c)有关:
R(c)∝ 1 / F(c)
单一特性有助于重用。多个特性会减少组件重用的机会。
当我们查看设计用于组合的脚本和使用日志记录进行控制和审计输出食谱中的两个原始应用程序时,我们可以看到基本函数的特性很少。roll_iter()函数模拟了一个游戏,并产生了结果。gather_stats()函数从任何数据源中收集统计信息。
计数特性的想法当然取决于抽象级别。从小规模的视角来看,函数会做很多小事情。从非常大的尺度来看,函数需要几个辅助程序来形成一个完整的应用程序;从这个角度来看,单个函数只是一个特性的一部分。
我们的重点是软件的技术特性。这与敏捷概念中的特性作为多个用户故事背后的统一概念无关。在这种情况下,我们谈论的是软件架构技术特性——输入、输出、处理、使用的操作系统资源、依赖关系等等。
从实用的角度来看,相关的技术特性与用户故事相关。这将把规模问题置于用户所感知的软件属性领域。如果用户看到多个特性,这意味着重用可能会有困难。
在这种情况下,一个应用程序创建文件。第二个应用程序总结文件。用户的反馈可能表明区分并不重要,或者可能令人困惑。这导致重新设计,从两个原始步骤创建一个一步操作。
还有更多...
我们将看看另外三个可以成为组合应用程序一部分的架构特性:
-
重构:将两个应用程序合并为一个的方法没有正确区分处理和输出。在尝试创建一个组合应用程序时,我们可能需要重构组件模块。
-
并发:并行运行多个
roll_iter()实例以使用多个核心。 -
日志记录:当多个应用程序组合在一起时,组合日志记录可能变得复杂。
重构
在某些情况下,有必要重新安排软件以提取有用的特性。在一个组件中,ch13_r06模块中有以下函数:
def process_all_files(result_file, file_names):
for source_path in (Path(n) for n in file_names):
detail_log.info("read {}".format(source_path))
with source_path.open() as source_file:
game_iter = yaml.load_all(source_file)
statistics = gather_stats(game_iter)
result_file.write(
yaml.dump(dict(statistics), explicit_start=True)
)
这将源文件迭代、详细处理和输出创建结合在一起。result_file.write()输出处理是一个单一的复杂语句,很难从这个函数中提取出来。
为了在两个应用程序之间正确地重用此特性,我们需要重构ch13_r06应用程序,以便文件输出不被埋在process_all_files()函数中。在这种情况下,重构并不太困难。在某些情况下,选择了错误的抽象,重构会变得非常困难。
一行代码,result_file.write(...),需要用一个单独的函数替换。这是一个小改变。具体细节留给读者作为练习。当定义为一个单独的函数时,更容易替换。
这种重构使得新函数可以用于其他组合应用程序。当多个应用程序共享一个公共函数时,这样输出之间的兼容性更高。
并发
运行许多模拟,然后进行单个摘要的根本原因是一种 map-reduce 设计。详细的模拟可以并行运行,使用多个核心和多个处理器。然而,最终摘要需要通过统计减少从所有模拟中创建。
我们经常使用操作系统特性来运行多个并发进程。POSIX shell 包括&运算符,可以用于分叉并发子进程。Windows 有一个**start**命令,类似。我们可以直接利用 Python 来生成多个并发模拟进程。
用于执行此操作的一个模块是concurrent包中的futures模块。我们可以通过创建ProcessPoolExecutor的实例来构建一个并行模拟处理器。我们可以向这个执行程序提交请求,然后收集这些并发请求的结果:
import concurrent.futures
def parallel():
start = time.perf_counter()
total_stats = Counter()
worker_list = []
with concurrent.futures.ProcessPoolExecutor() as executor:
for i in range(100):
worker_list.append(executor.submit(summarize_games, 1000))
for worker in worker_list:
stats = worker.result()
total_stats.update(stats)
end = time.perf_counter()
games = sum(total_stats.values())
print('games', games)
print(win_loss(total_stats))
print("{:.2f} seconds".format(end-start))
我们初始化了三个对象:start,total_stats和worker_list。start对象记录了处理开始的时间;time.perf_counter()通常是最准确的可用计时器。total_stats是一个Counter对象,将收集最终的统计摘要。worker_list将是一个单独的Future对象列表,每个请求都有一个。
ProcessPoolExecutor方法定义了一个处理上下文,其中有一个工作池可用于处理请求。默认情况下,池中的工作进程数量与处理器数量相同。每个工作进程都在导入给定模块的执行器中运行。模块中定义的所有函数和类都可供工作进程使用。
执行器的submit()方法会执行一个函数以及该函数的参数。在这个例子中,将进行 100 个请求,每个请求将模拟 1,000 场比赛,并返回这些比赛的骰子点数序列。submit()返回一个Future对象,它是工作请求的模型。
在提交所有 100 个请求后,收集结果。Future对象的result()方法等待处理完成并收集结果对象。在这个例子中,结果是 1,000 场比赛的统计摘要。然后将它们组合成整体的total_stats摘要。
以下是串行和并行执行之间的比较:
**games 100000**
**Counter({'loss': 50997, 'win': 49003})**
**2.83 seconds**
**games 100000**
**Counter({'loss': 50523, 'win': 49477})**
**1.49 seconds**
处理时间减少了一半。由于有 100 个并发请求,为什么时间没有减少原始时间的 1/100?观察到在生成子进程、通信请求数据和通信结果数据方面存在相当大的开销。
记录
在使用日志进行控制和审计输出的示例中,我们看到了如何使用logging模块进行控制、审计和错误输出。当构建复合应用程序时,我们将不得不结合原始应用程序的每个日志功能。
记录涉及三个部分的步骤:
-
创建记录器对象。通常是一行代码,如
logger = logging.get_logger('some_name')。通常在类或模块级别执行一次。 -
使用记录器对象收集事件。这涉及到诸如
logger.info('some message')这样的行。这些行分散在整个应用程序中。 -
整体配置日志系统。应用程序中有两种日志配置可能性:
- 尽可能外部化。在这种情况下,日志配置仅在应用程序的最外层全局范围内完成:
if __name__ == "__main__":
logging configuration goes only here.
main()
logging.shutdown()
这保证了日志系统只有一个配置。
- 在类、函数或模块的某个地方。在这种情况下,我们可能有几个模块都试图进行日志配置。这是由日志系统容忍的。但是,调试可能会令人困惑。
这些示例都遵循第一种方法。如果所有应用程序都在最全局范围内配置日志记录,那么很容易理解如何配置复合应用程序。
在存在多个日志配置的情况下,复合应用程序可以采用两种方法:
-
复合应用程序包含最终配置,它有意覆盖了先前定义的所有记录器。这是默认设置,并可以通过在 YAML 配置文档中明确说明
incremental: false来表示。 -
复合应用程序保留其他应用程序记录器,仅修改记录器配置,可能是通过设置整体级别。这是通过在 YAML 配置文档中包含
incremental: true来完成的。
当组合 Python 应用程序时,增量配置对于不隔离日志配置的应用程序非常有用。为了正确为复合应用程序配置日志,可能需要一些时间来阅读和理解每个应用程序的代码。
另请参阅
- 在为组合设计脚本配方中,我们看了一个可组合应用程序的核心设计模式
使用 Command 设计模式组合多个应用程序
许多复杂的应用程序套件遵循与 Git 程序类似的设计模式。有一个基本命令git,有许多子命令。例如,git pull,git commit和git push。
这个设计的核心是一系列单独的命令。git 的各种功能可以被看作是执行给定功能的单独类定义。
当我们输入诸如git pull这样的命令时,就好像程序git正在定位一个实现该命令的类。
我们如何创建一系列密切相关的命令?
准备工作
我们将想象一个由三个命令构建的应用程序。这是基于为组合设计脚本,使用日志进行控制和审计输出和将两个应用程序合并为一个配方中显示的应用程序。我们将有三个应用程序——模拟,总结和一个名为simsum的组合应用程序。
这些功能基于诸如ch13_r05,ch13_r06和ch13_r07之类的模块。这个想法是我们可以将这些单独的模块重组成一个遵循 Command 设计模式的单一类层次结构。
这种设计有两个关键要素:
-
客户端只依赖于抽象超类
Command。 -
Command超类的每个单独子类都有一个相同的接口。我们可以用其中任何一个替换其他任何一个。
当我们完成这个之后,一个整体的应用程序脚本可以创建和执行任何一个Command子类。
如何做...
- 整体应用程序将具有一种结构,试图将功能分为两类——参数解析和命令执行。每个子命令都将包括处理和输出捆绑在一起。
这是Command超类:
from argparse import Namespace
class Command:
def execute(self, options: Namespace):
pass
我们将依赖于argparse.Namespace为每个子类提供一个非常灵活的选项集合。这不是必需的,但在管理复合应用程序中的参数和配置配方中会很有帮助。由于该配方将包括选项解析,因此似乎最好专注于每个类使用argparse.Namespace。
- 为
Simulate命令创建Command超类的子类:
import ch13_r05
class Simulate(Command):
def __init__(self, seed=None):
self.seed = seed
def execute(self, options):
self.game_path = Path(options.game_file)
data = ch13_r05.roll_iter(options.games, self.seed)
ch13_r05.write_rolls(self.game_path, data)
我们已经将ch13_r05模块的处理和输出包装到这个类的execute()方法中。
- 为
Summarize命令创建Command超类的子类:
import ch13_r06
class Summarize(Command):
def execute(self, options):
self.summary_path = Path(options.summary_file)
with self.summary_path.open('w') as result_file:
ch13_r06.process_all_files(result_file, options.game_files)
对于这个类,我们已经将文件创建和文件处理包装到类的execute()方法中。
- 所有的整体过程都可以由以下
main()函数执行:
from argparse import Namespace
def main():
options_1 = Namespace(games=100, game_file='x.yaml')
command1 = Simulate()
command1.execute(options_1)
options_2 = Namespace(summary_file='y.yaml', game_files=['x.yaml'])
command2 = Summarize()
command2.execute(options_2)
我们创建了两个命令,一个是Simulate类的实例,另一个是Summarize类的实例。这些可以被执行以提供一个同时模拟和总结数据的组合功能。
工作原理...
为各种子命令创建可互换的多态类是提供可扩展设计的一种方便方式。Command设计模式强烈鼓励每个单独的子类具有相同的签名,以便可以创建和执行任何命令。此外,可以添加适合框架的新命令。
SOLID 设计原则之一是Liskov 替换原则(LSP)。Command抽象类的任何子类都可以替代父类。
每个Command实例都有一个简单的接口。有两个功能:
-
__init__()方法期望由参数解析器创建的命名空间对象。每个类将只从这个命名空间中选择所需的值,忽略其他任何值。这允许子命令忽略不需要的全局参数。 -
execute()方法执行处理并写入任何输出。这完全基于初始化期间提供的值。
使用命令设计模式可以确保它们可以互换。整个main()脚本可以创建Simulate或Summarize类的实例。替换原则意味着任一实例都可以执行,因为接口是相同的。这种灵活性使得解析命令行选项并创建任一可用类的实例变得容易。我们可以扩展这个想法并创建单个命令实例的序列。
还有更多...
这种设计模式的更常见扩展之一是提供组合命令。在将两个应用程序合并为一个的示例中,我们展示了创建组合的一种方法。这是另一种方法,基于定义一个实现现有命令组合的新命令:
class CommandSequence(Command):
def __init__(self, *commands):
self.commands = [command() for command in commands]
def execute(self, options):
for command in self.commands:
command.execute(options)
这个类将通过*commands参数接受其他Command类。这个序列将组合所有的位置参数值。它将从这些类中构建单独的类实例。
我们可以像这样使用CommandSequence类:
options = Namespace(games=100, game_file='x.yaml',
summary_file='y.yaml', game_files=['x.yaml']
)
sim_sum_command = CommandSequence(Simulate, Summarize)
sim_sum_command.execute(options)
我们使用了两个其他类Simulate和Summarize创建了一个CommandSequence的实例。__init__()方法将构建这两个对象的内部序列。然后sim_sum_command对象的execute()方法将按顺序执行这两个处理步骤。
这种设计虽然简单,但暴露了许多实现细节。特别是两个类名和中间的x.yaml文件是可以封装到更好的类设计中的细节。
如果我们专门关注被组合的两个命令,我们可以创建一个稍微更好的CommandSequence子类参数。这将有一个__init__()方法,遵循其他Command子类的模式:
class SimSum(CommandSequence):
def __init__(self):
super().__init__(Simulate, Summarize)
这个类定义将两个其他类合并到已定义的CommandSequence结构中。我们可以通过稍微修改选项来继续这个想法,以消除Simulate步骤中game_file的显式值,这也必须是Summarize步骤的game_files输入的一部分。
我们想要构建和使用一个更简单的Namespace,其选项如下:
options = Namespace(games=100, summary_file='y.yaml')
sim_sum_command = SimSum()
sim_sum_command.execute(options)
这意味着一些缺失的选项必须由execute()方法注入。我们将把这个方法添加到SimSum类中:
def execute(self, options):
new_namespace = Namespace(
game_file='x.yaml',
game_files=['x.yaml'],
**vars(options)
)
super().execute(new_namespace)
这个execute()方法克隆了选项。它添加了两个额外的值,这些值是命令集成的一部分,但不是用户应该提供的。
这种设计避免了更新有状态的选项集。为了保持原始选项对象不变,我们进行了复制。vars()函数将Namespace公开为一个简单的字典。然后我们可以使用**关键字参数技术将字典转换为新的Namespace对象的关键字参数。这将创建一个浅拷贝。如果命名空间内的有状态对象被更新,原始的options和new_namespace参数都可以访问相同的基础值对象。
由于new_namespace是一个独立的集合,我们可以向这个Namespace实例添加新的键和值。这些只会出现在new_namespace中,不会影响原始选项对象。
另请参阅
-
在为组合设计脚本、使用日志进行控制和审计输出和将两个应用程序合并为一个的示例中,我们看了这个组合应用程序的组成部分。在大多数情况下,我们需要结合所有这些示例的元素来创建一个有用的应用程序。
-
我们经常需要遵循在组合应用程序中管理参数和配置的示例。
在组合应用程序中管理参数和配置
当我们有一套复杂的单独应用程序(或系统)时,几个应用程序共享共同特征是很常见的。当然,我们可以使用普通的继承来定义一个库模块,为复杂套件中的每个单独应用程序提供共同的类和函数。
创建许多单独应用程序的缺点是外部 CLI 直接与软件架构相关联。重新排列软件组件变得笨拙,因为更改也会改变可见的 CLI。
许多应用文件之间共同特征的协调可能变得笨拙。例如,定义命令行参数的各种一字母缩写选项是困难的。这需要在所有单个应用文件之外保持某种选项的主列表。看起来这应该在代码的某个地方集中保存。
是否有继承的替代方案?如何确保一套应用程序可以重构而不会对 CLI 造成意外更改或需要复杂的额外设计说明?
准备工作
许多复杂的应用套件遵循与 Git 使用的相似的设计模式。有一个基本命令git,带有许多子命令。例如,git pull,git commit和git push。命令行界面的核心可以由git命令集中。然后可以根据需要组织和重新组织子命令,而对可见 CLI 的更改较少。
我们将想象一个由三个命令构建的应用程序。这是基于为组合设计脚本,使用日志记录进行控制和审计输出和将两个应用程序合并为一个配方中显示的应用程序。我们将有三个应用程序,每个应用程序有三个命令:craps simulate,craps summarize和组合应用程序craps simsum。
我们将依赖于使用命令设计模式合并多个应用程序配方中的子命令设计。这将提供Command子类的方便层次结构:
-
Command类是一个抽象超类。 -
Simulate子类执行为组合设计脚本配方中的模拟功能。 -
Summarize子类执行使用日志记录进行控制和审计输出配方中的总结功能。 -
SimSum子类可以执行组合模拟和总结,遵循将两个应用程序合并为一个的想法。
为了创建一个简单的命令行应用程序,我们需要适当的参数解析。
这个参数解析将依赖于argparse模块的子命令解析能力。我们可以创建适用于所有子命令的一组公共命令选项。我们还可以为每个子命令创建唯一的选项。
如何做...
- 定义命令界面。这是一种用户体验(UX)设计练习。虽然大多数 UX 都集中在 Web 和移动设备应用程序上,但核心原则也适用于 CLI 应用程序和服务器。
早些时候,我们注意到根应用程序将是craps。它将有以下三个子命令:
**craps simulate -o game_file -g games
craps summarize -o summary_file game_file ...
craps simsum -g games**
-
定义根 Python 应用程序。与本书中的其他文件一致,我们将称其为
ch13_r08.py。在操作系统级别,我们可以提供一个别名或链接,使可见界面与用户对craps的期望相匹配。 -
我们将从使用命令设计模式合并多个应用程序配方中导入类定义。这将包括
Command超类和Simulate,Summarize和SimSum子类。 -
创建整体参数解析器,然后创建一个子解析器构建器。
subparsers对象将用于创建每个子命令的参数定义:
import argparse
def get_options(argv):
parser = argparse.ArgumentParser(prog='craps')
subparsers = parser.add_subparsers()
对于每个命令,创建一个解析器,并添加该命令特有的参数。
- 使用两个唯一于模拟的选项定义
simulate命令。我们还将提供一个特殊的默认值,用于初始化生成的Namespace对象:
simulate_parser = subparsers.add_parser('simulate')
simulate_parser.add_argument('-g', '--games', type=int, default=100000)
simulate_parser.add_argument('-o', '--output', dest='game_file')
simulate_parser.set_defaults(command=Simulate)
- 定义
summarize命令,带有此命令特有的参数。提供将填充Namespace对象的默认值:
summarize_parser = subparsers.add_parser('summarize')
summarize_parser.add_argument('-o', '--output', dest='summary_file')
summarize_parser.add_argument('game_files', nargs='*')
summarize_parser.set_defaults(command=Summarize)
- 定义
simsum命令,并类似地提供一个独特的默认值,以便更轻松地处理Namespace:
simsum_parser = subparsers.add_parser('simsum')
simsum_parser.add_argument('-g', '--games', type=int, default=100000)
simsum_parser.add_argument('-o', '--output', dest='summary_file')
simsum_parser.set_defaults(command=SimSum)
- 解析命令行值。在这种情况下,
get_options()函数的整体参数预期是sys.argv[1:]的值,其中包括 Python 命令的参数。我们可以覆盖参数值以进行测试:
options = parser.parse_args(argv)
if 'command' not in options:
parser.print_help()
sys.exit(2)
return options
整体解析器包括三个子命令解析器。一个将处理craps simulate命令,另一个处理craps summarize,第三个处理craps simsum。每个子命令具有略有不同的选项组合。
command选项只能通过set_defaults()方法设置。这会发送有关要执行的命令的有用的附加信息。在这种情况下,我们提供了必须实例化的类。
- 整体应用程序由以下
main()函数定义:
def main():
options = get_options(sys.argv[1:])
command = options.command(options)
command.execute()
选项将被解析。每个不同的子命令为options.command参数设置一个唯一的类值。这个类用于构建Command子类的实例。这个对象将有一个execute()方法,用于执行这个命令的真正工作。
- 实现根命令的操作系统包装器。我们可能有一个名为
craps的文件。该文件将具有 rx 权限,以便其他用户可以读取。文件的内容可能是这一行:
**python3.5 ch13_r08.py $***
这个小的 shell 脚本提供了一个方便的方式来输入一个**craps**命令,并使其正确执行一个具有不同名称的 Python 脚本。
我们可以这样创建一个 bash shell 别名:
**alias craps='python3.5 ch13_r08.py'**
这可以放在.bashrc文件中以定义一个**craps**命令。
工作原理...
这个配方有两个部分:
-
使用
Command设计模式来定义一组相关的多态类。有关更多信息,请参阅使用命令设计模式组合多个应用程序配方。 -
使用
argparse模块的特性来处理子命令。
这里重要的argparse模块特性是解析器的add_subparsers()方法。此方法返回一个对象,用于构建每个不同的子命令解析器。我们将此对象分配给变量subparsers。
我们还在顶层解析器中定义了一个简单的command参数。这个参数只能由为每个子解析器定义的默认值填充。这提供了一个值,显示实际调用了哪个子命令。
每个子解析器都是使用子解析器对象的add_parser()方法构建的。然后返回的parser对象可以定义参数和默认值。
当执行整体解析器时,它将解析在子命令之外定义的任何参数。如果有子命令,这将用于确定如何解析剩余的参数。
看下面的命令:
**craps simulate -g 100 -o x.yaml**
这个命令将被解析为创建一个像这样的Namespace对象:
**Namespace(command=<class '__main__.Simulate'>, game_file='x.yaml', games=100)**
Namespace对象中的command属性是作为子命令定义的一部分提供的默认值。game_file和games的值来自-o和-g选项。
命令设计模式
为各种子命令创建可互换的多态类,创建一个易于重构或扩展的设计。Command设计模式强烈鼓励每个单独的子类具有相同的签名,以便可以创建和执行任何可用的命令类之一。
SOLID 设计原则之一是 Liskov 替换原则。命令抽象类的任何子类都可以用于替换父类。
每个Command都有一个一致的接口:
-
__init__()方法期望由参数解析器创建的命名空间对象。每个类将只从这个命名空间中选择所需的值,忽略其他任何值。这允许全局参数被不需要它的子命令忽略。 -
execute()方法执行处理并写入任何输出。这完全基于初始化时提供的值。
命令设计模式的使用使得很容易确保它们可以相互替换。替换原则意味着main()函数可以简单地创建一个实例,然后执行对象的execute()方法。
还有更多...
我们可以考虑将子命令解析器的细节下推到每个类定义中。例如,“模拟”类定义了两个参数:
simulate_parser.add_argument('-g', '--games', type=int, default=100000)
simulate_parser.add_argument('-o', '--output', dest='game_file')
get_option()函数似乎不应该定义关于实现类的这些细节。一个适当封装的设计似乎应该将这些细节分配给每个Command子类。
我们需要添加一个配置给定解析器的静态方法。新的类定义将如下所示:
import ch13_r05
class Simulate(Command):
def __init__(self, options, *, seed=None):
self.games = options.games
self.game_file = options.game_file
self.seed = seed
def execute(self):
data = ch13_r05.roll_iter(self.games, self.seed)
ch13_r05.write_rolls(self.game_file, data)
@staticmethod
def configure(simulate_parser):
simulate_parser.add_argument('-g', '--games', type=int, default=100000)
simulate_parser.add_argument('-o', '--output', dest='game_file')
我们添加了一个configure()方法来配置解析器。这个改变使得很容易看到__init__()参数将如何通过解析命令行值来创建。这使我们能够重写get_option()函数,如下:
import argparse
def get_options(argv):
parser = argparse.ArgumentParser(prog='craps')
subparsers = parser.add_subparsers()
simulate_parser = subparsers.add_parser('simulate')
Simulate.configure(simulate_parser)
simulate_parser.set_defaults(command=Simulate)
# etc. for each class
这将利用静态的configure()方法来提供参数细节。命令参数的默认值可以由整体的get_options()处理,因为它不涉及内部细节。
另请参阅
-
请参阅为组合设计脚本,使用日志记录进行控制和审计输出和将两个应用程序合并为一个的方法,了解组件的背景
-
在第五章的使用 argparse 获取命令行输入方法中,了解更多关于参数解析的背景
包装和组合 CLI 应用程序
一种常见的自动化类型涉及运行几个程序,这些程序实际上都不是 Python 应用程序。由于这些程序不是用 Python 编写的,因此不可能重写每个程序以创建一个复合的 Python 应用程序。我们无法遵循将两个应用程序合并为一个的方法。
与聚合功能不同,另一种选择是在 Python 中包装其他程序以提供更高级的构造。使用情况与编写 shell 脚本的使用情况非常相似。不同之处在于使用 Python 而不是 shell 语言。使用 Python 有一些优势:
-
Python 拥有丰富的数据结构集合。而 shell 只有字符串和字符串数组。
-
Python 拥有出色的单元测试框架。这可以确保 Python 版本的 shell 脚本可以正常工作,而不会使广泛使用的服务崩溃的风险。
我们如何从 Python 中运行其他应用程序?
准备工作
在为组合设计脚本的方法中,我们确定了一个应用程序,该应用程序进行了一些处理,导致了一个相当复杂的结果。对于这个方法,我们假设该应用程序不是用 Python 编写的。
我们想要运行这个程序几百次,但我们不想将必要的命令复制粘贴到脚本中。此外,由于 shell 很难测试并且数据结构很少,我们希望避免使用 shell。
对于这个方法,我们假设ch13_r05应用程序是一个本地二进制应用程序;它可能是用 C++或 Fortran 编写的。这意味着我们不能简单地导入包含应用程序的 Python 模块。相反,我们将不得不通过运行一个单独的操作系统进程来处理这个应用程序。
我们将使用subprocess模块在操作系统级别运行应用程序。从 Python 中运行另一个二进制程序有两种常见的用例:
-
没有输出,或者我们不想在我们的 Python 程序中收集它。第一种情况是当 OS 实用程序在成功或失败时返回状态码时的典型情况。第二种情况是当许多子程序都在写入标准错误日志时的典型情况;父 Python 程序只是启动子进程。
-
我们需要捕获并可能分析输出以检索信息或确定成功的级别。
在这个配方中,我们将看看第一种情况——输出不是我们需要捕获的东西。在包装程序并检查输出配方中,我们将看看第二种情况,即 Python 包装程序对输出进行了审查。
如何做...
- 导入
subprocess模块:
import subprocess
- 设计命令行。通常,应该在操作系统提示符下进行测试,以确保它执行正确的操作:
**slott$ python3 ch13_r05.py --samples 10 --output x.yaml**
输出文件名需要灵活,这样我们可以运行程序数百次。这意味着创建名称为 game_{n}.yaml 的文件。
- 编写一个语句,通过适当的命令进行迭代。每个命令可以构建为一系列单词的序列。从工作的 shell 命令开始,并在空格上拆分该行,以创建适当的单词序列:
files = 100
for n in range(files):
filename = 'game_{n}.yaml'.format_map(vars())
command = ['python3', 'ch13_r05.py',
'--samples', '10', '--output', filename]
这将创建各种命令。我们可以使用 print() 函数显示每个命令,并确认文件名是否定义正确。
- 评估
subprocess模块中的run()函数。这将执行给定的命令。提供check=True,这样如果有任何问题,它将引发subprocess.CalledProcessError异常:
subprocess.run(command, check=True)
- 为了正确测试这一点,整个序列应该转换为一个适当的函数。如果将来会有更多相关的命令,它应该是
Command类层次结构中的子类的方法。参见在复合应用程序中管理参数和配置配方。
它是如何工作的...
subprocess 模块是 Python 程序运行计算机上其他程序的方式。run() 函数为我们做了很多事情。
在 POSIX(如 Linux 或 Mac OS X)环境中,步骤类似于以下序列:
-
为子进程准备
stdin、stdout和stderr文件描述符。在这种情况下,我们接受了默认值,这意味着子进程继承了父进程正在使用的文件。如果子进程打印到stdout,它将出现在父进程使用的同一个控制台上。 -
调用
os.fork()函数将当前进程分成父进程和子进程。父进程将获得子进程的进程 ID;然后它可以等待子进程完成。 -
在子进程中,执行
os.execl()函数(或类似的函数)以提供子进程将执行的命令路径和参数。 -
然后子进程运行,使用给定的
stdin、stdout和stderr文件。 -
同时,父进程使用诸如
os.wait()的函数等待子进程完成并返回最终状态。 -
由于我们使用了
check=True选项,run()函数将非零状态转换为异常。
OS shell(如 bash)会向应用程序开发人员隐藏这些细节。subprocess.run() 函数同样隐藏了创建和等待子进程的细节。
Python 的 subprocess 模块提供了许多类似于 shell 的功能。最重要的是,Python 提供了几组额外的功能:
-
更丰富的数据结构。
-
异常用于识别出现的问题。这比在 shell 脚本中插入
if语句来检查状态码要简单得多且更可靠。 -
一种在不使用操作系统资源的情况下对脚本进行单元测试的方法。
还有更多...
我们将向这个脚本添加一个简单的清理功能。想法是所有的输出文件应该作为一个原子操作创建。我们希望所有文件都存在,或者没有文件存在。我们不希望有不完整的数据文件集。
这符合 ACID 属性:
-
原子性:整个数据集要么可用,要么不可用。集合是一个单一的、不可分割的工作单元。
-
一致性:文件系统应该从一个内部一致的状态转移到另一个一致的状态。任何摘要或索引都应该正确反映实际文件。
-
隔离性:如果我们想要并行处理数据,那么多个并行进程应该可以工作。并发操作不应该相互干扰。
-
持久性:一旦文件被写入,它们应该保留在文件系统上。对于文件来说,这个属性几乎是不言而喻的。对于更复杂的数据库,需要考虑可能被数据库客户端确认但实际上尚未写入服务器的事务数据。
使用操作系统进程和单独的工作目录可以相对简单地实现大多数这些特性。然而,原子性属性导致需要进行清理操作。
为了清理,我们需要用 try: 块包装核心处理。整个函数看起来像这样:
import subprocess
from pathlib import Path
def make_files(files=100):
try:
for n in range(files):
filename = 'game_{n}.yaml'.format_map(vars())
command = ['python3', 'ch13_r05.py',
'--samples', '10', '--output', filename]
subprocess.run(command, check=True)
except subprocess.CalledProcessError as ex:
for partial in Path('.').glob("game_*.yaml"):
partial.unlink()
raise
异常处理块有两个作用。首先,它会从当前工作目录中删除任何不完整的文件。其次,它会重新引发原始异常,以便故障传播到客户端应用程序。
由于处理失败,提高异常是很重要的。在某些情况下,应用程序可能会定义一个新的异常,特定于该应用程序。可以引发这个新的异常,而不是重新引发原始的 CalledProcessError 异常。
单元测试
为了对这个进行单元测试,我们需要模拟两个外部对象。我们需要模拟 subprocess 模块中的 run() 函数。我们不想实际运行其他进程,但我们想确保 run() 函数从 make_files() 函数中被适当地调用。
我们还需要模拟 Path 类和生成的 Path 对象。这些提供文件名,并将调用 unlink() 方法。我们需要为此创建模拟,以确保真实应用程序只取消链接适当的文件。
使用模拟对象进行测试意味着我们永远不会在测试时意外删除有用的文件。这是使用 Python 进行这种自动化的重要好处。
这是我们定义各种模拟对象的设置:
import unittest
from unittest.mock import *
class GIVEN_make_files_exception_WHEN_call_THEN_run(unittest.TestCase):
def setUp(self):
self.mock_subprocess_run = Mock(
side_effect = [
None,
subprocess.CalledProcessError(2, 'ch13_r05')]
)
self.mock_path_glob_instance = Mock()
self.mock_path_instance = Mock(
glob = Mock(
return_value = [self.mock_path_glob_instance]
)
)
self.mock_path_class = Mock(
return_value = self.mock_path_instance
)
我们已经定义了 self.mock_subprocess_run,它将表现得有点像 run() 函数。我们使用了 side_effect 属性为这个函数提供多个返回值。第一个响应将是 None 对象。然而,第二个响应将是一个 CalledProcessError 异常。这个异常需要两个参数,一个进程返回代码,和原始命令。
self.mock_path_class,最后显示,响应对 Path 类请求的调用。这将返回一个模拟的类实例。self.mock_path_instance 对象是 Path 的模拟实例。
创建的第一个路径实例将评估 glob() 方法。为此,我们使用了 return_value 属性来返回要删除的 Path 实例列表。在这种情况下,返回值将是一个我们期望被取消链接的单个 Path 对象。
self.mock_path_glob_instance 对象是从 glob() 返回的。如果算法操作正确,这应该被取消链接。
这是这个单元测试的 runTest() 方法:
def runTest(self):
with patch('__main__.subprocess.run', self.mock_subprocess_run), \
patch('__main__.Path', self.mock_path_class):
self.assertRaises(
subprocess.CalledProcessError, make_files, files=3)
self.mock_subprocess_run.assert_has_calls(
[call(
['python3', 'ch13_r05.py', '--samples', '10',
'--output', 'game_0.yaml'],
check=True),
call(
['python3', 'ch13_r05.py', '--samples', '10',
'--output', 'game_1.yaml'],
check=True),
]
)
self.assertEqual(2, self.mock_subprocess_run.call_count)
self.mock_path_class.assert_called_once_with('.')
self.mock_path_instance.glob.assert_called_once_with('game_*.yaml')
self.mock_path_glob_instance.unlink.assert_called_once_with()
我们应用了两个补丁:
-
在
__main__模块中,对subprocess的引用将使用self.mock_subprocess_run对象替换run()函数。这将允许我们跟踪run()被调用的次数。它将允许我们确认run()是否以正确的参数被调用。 -
在
__main__模块中,对Path的引用将被替换为self.mock_path_class对象。这将返回已知的值,并允许我们确认只有预期的调用被执行。
self.assertRaises方法用于确认在调用make_files()方法时,在这个特定的修补上下文中正确引发了CalledProcessError异常。run()方法的模拟版本将引发异常——我们期望确切的异常是停止处理的异常。
模拟的run()函数只被调用两次。第一次调用将成功。第二次调用将引发异常。我们可以使用Mock对象的call_count属性来确认确实调用了两次run()。
self.mock_path_instance 方法是Path('.')对象的模拟,该对象作为异常处理的一部分创建。这个对象必须评估glob()方法。测试断言检查参数值,以确保使用了'game_*.yaml'。
最后,self.mock_path_glob_instance是Path('.').glob('game_*.yaml')创建的Path对象的模拟。这个对象将评估unlink()方法。这将导致删除文件。
这个单元测试提供了算法将按照广告运行的信心。测试是在不占用大量计算资源的情况下进行的。最重要的是,测试是在不小心删除错误文件的情况下进行的。
另请参阅
-
这种自动化通常与其他 Python 处理结合使用。请参阅为组合设计脚本配方。
-
目标通常是创建一个复合应用程序;参见在复合应用程序中管理参数和配置配方。
-
有关此配方的变体,请参阅包装程序并检查输出配方。
包装程序并检查输出
一种常见的自动化类型涉及运行几个程序,其中没有一个实际上是 Python 应用程序。在这种情况下,不可能重写每个程序以创建一个复合的 Python 应用程序。为了正确地聚合功能,其他程序必须被包装为 Python 类或模块,以提供一个更高级的构造。
这种用例与编写 shell 脚本的用例非常相似。不同之处在于 Python 可能是比操作系统内置的 shell 语言更好的编程语言。
在某些情况下,Python 提供的优势是能够分析输出文件。Python 程序可能会转换、过滤或总结子进程的输出。
我们如何从 Python 中运行其他应用程序并处理它们的输出?
准备工作
在为组合设计脚本配方中,我们确定了一个应用程序进行了一些处理,导致了一个相当复杂的结果。我们想运行这个程序几百次,但我们不想复制和粘贴必要的命令到一个脚本中。此外,由于 shell 很难测试并且数据结构很少,我们想避免使用 shell。
对于这个配方,我们假设ch13_r05应用程序是用 Fortran 或 C++编写的本机二进制应用程序。这意味着我们不能简单地导入包含应用程序的 Python 模块。相反,我们将不得不通过运行一个单独的操作系统进程来处理这个应用程序。
我们将使用subprocess模块在操作系统级别运行应用程序。从 Python 中运行另一个二进制程序有两种常见的用例:
-
没有任何输出,或者我们不想在我们的 Python 程序中收集它。
-
我们需要捕获并可能分析输出以检索信息或确定成功的级别。我们可能需要转换、过滤或总结日志输出。
在这个配方中,我们将看看第二种情况——输出必须被捕获和总结。在包装和组合 CLI 应用程序配方中,我们将看看第一种情况,即输出被简单地忽略。
这是运行ch13_r05应用程序的一个例子:
**slott$ python3 ch13_r05.py --samples 10 --output=x.yaml**
**Namespace(output='x.yaml', output_path=PosixPath('x.yaml'), samples=10, seed=None)**
**Counter({5: 7, 6: 7, 7: 7, 8: 5, 4: 4, 9: 4, 11: 3, 10: 1, 12: 1})**
有两行输出写入操作系统标准输出文件。第一行有选项的摘要。第二行的输出是一个带有文件摘要的Counter对象。我们想要捕获这些'Counter'行的细节。
如何操作...
- 导入
subprocess模块:
import subprocess
-
设计命令行。通常,这应该在操作系统提示符下进行测试,以确保它执行正确的操作。我们展示了一个命令的示例。
-
为要执行的各种命令定义一个生成器。每个命令都可以作为一个单词序列构建。原始的 shell 命令被拆分成单词序列。
def command_iter(files):
for n in range(files):
filename = 'game_{n}.yaml'.format_map(vars())
command = ['python3', 'ch13_r05.py',
'--samples', '10', '--output', filename]
yield command
这个生成器将产生一系列命令字符串。客户端可以使用for语句来消耗生成的每个命令。
- 定义一个执行各种命令并收集输出的函数:
def command_output_iter(iterable):
for command in iterable:
process = subprocess.run(command, stdout=subprocess.PIPE, check=True)
output_bytes = process.stdout
output_lines = list(l.strip() for l in output_bytes.splitlines())
yield output_lines
使用stdout=subprocess.PIPE的参数值意味着父进程将收集子进程的输出。创建一个操作系统级的管道,以便父进程可以读取子进程的输出。
这个生成器将产生一系列行列表。每个行列表将是ch13_r05.py应用程序的输出行。通常每个列表中会有两行。第一行是参数摘要,第二行是Counter对象。
- 定义一个整体流程,将这两个生成器结合起来,以便执行生成的每个命令:
command_sequence = command_iter(100)
output_lines_sequence = command_output_iter(command_sequence)
for batch in output_lines_sequence:
for line in batch:
if line.startswith('Counter'):
batch_counter = eval(line)
print(batch_counter)
command_sequence变量是一个生成器,将产生多个命令。这个序列是由command_iter()函数构建的。
output_lines_sequence是一个生成器,将产生多个输出行列表。这是由command_output_iter()函数构建的,它将使用给定的command_sequence对象,运行多个命令,收集输出。
output_lines_sequence中的每个批次将是一个包含两行的列表。以Counter开头的行表示一个Counter对象。
我们使用eval()函数从文本表示中重新创建原始的Counter对象。我们可以使用这些Counter对象进行分析或总结。
大多数实际应用程序将不得不使用比内置的eval()更复杂的函数来解释输出。有关处理复杂行格式的信息,请参阅第一章中的使用正则表达式解析字符串,数字、字符串和元组,以及第九章中的使用正则表达式读取复杂格式,输入/输出、物理格式和逻辑布局。
工作原理...
subprocess模块是 Python 程序运行在给定计算机上的其他程序的方式。run()函数为我们做了很多事情。
在 POSIX(如 Linux 或 Mac OS X)环境中,步骤类似于以下步骤:
-
为子进程准备
stdin,stdout和stderr文件描述符。在这种情况下,我们安排父进程从子进程收集输出。子进程将stdout文件产生到一个共享缓冲区(在 Linux 术语中是一个管道),由父进程消耗。另一方面,stderr输出保持不变——子进程继承了父进程的相同连接,错误消息将显示在父进程使用的同一个控制台上。 -
调用
os.fork()和os.execl()函数将当前进程分成父进程和子进程,然后启动子进程。 -
然后子进程运行,使用给定的
stdin,stdout和stderr。 -
同时,父进程正在从子进程的管道中读取,同时等待子进程完成。
-
由于我们使用了
check=True选项,非零状态被转换为异常。
还有更多...
我们将向这个脚本添加一个简单的总结功能。每个样本批次产生两行输出。输出文本通过表达式list(l.strip() for l in output_bytes.splitlines())分割成两行的序列。这将文本分割成行,并从每行中去除前导和尾随空格,留下稍微容易处理的文本。
总体脚本过滤了这些行,寻找以'Counter'开头的行。这些行中的每一行都是Counter对象的文本表示。在行上使用eval()函数将重建原始的Counter的副本。许多 Python 类定义都是这样的——repr()和eval()函数是彼此的反函数。repr()函数将对象转换为文本,eval()函数可以将文本转换回对象。这并不适用于所有类,但对于许多类来说是正确的。
我们可以创建各种Counter对象的总结。为了做到这一点,有助于有一个生成器来处理批次并产生最终的总结。
函数应该是这样的:
def process_batches():
command_sequence = command_iter(2)
output_lines_sequence = command_output_iter(command_sequence)
for batch in output_lines_sequence:
for line in batch:
if line.startswith('Counter'):
batch_counter = eval(line)
yield batch_counter
这将使用command_iter()函数创建处理命令。command_output_iter()将处理每个单独的命令,收集整个输出行集。
嵌套的for语句将检查每个批次的行列表。在每个列表中,它将检查每一行。以Counter开头的行将使用eval()函数进行评估。Counter对象的结果序列是这个生成器的输出。
我们可以使用这样的流程来总结Counter实例的序列:
total_counter = Counter()
for batch_counter in process_batches():
print(batch_counter)
total_counter.update(batch_counter)
print("Total")
print(total_counter)
我们将创建Counter来保存总数,total_counter。process_batches()将从处理的每个文件中产生单独的Counter实例。这些批次级别的对象用于更新total_counter。然后我们可以打印总数,显示所有文件中数据的聚合分布。
另请参阅
-
参见包装和组合 CLI 应用程序食谱,了解这个食谱的另一种方法。
-
这种自动化通常与其他 Python 处理结合在一起。请参见为组合设计脚本食谱。
-
目标通常是创建一个组合应用程序;参见管理组合应用程序中的参数和配置食谱。

控制复杂的步骤序列
在将两个应用程序合并为一个食谱中,我们探讨了将多个 Python 脚本合并为一个更长、更复杂操作的方法。在包装和组合 CLI 应用程序和包装程序并检查输出食谱中,我们探讨了使用 Python 包装非 Python 程序的方法。
我们如何有效地结合这些技术?我们能否使用 Python 创建更长、更复杂的操作序列?
准备工作
在为组合设计脚本食谱中,我们创建了一个应用程序,进行了一些处理,导致了一个相当复杂的结果的产生。在使用日志进行控制和审计输出食谱中,我们看了第二个应用程序,它建立在这些结果的基础上,创建了一个复杂的统计摘要。
总体流程如下:
-
运行
ch13_r05程序 100 次,创建 100 个中间文件。 -
运行
ch13_r06程序总结这些中间文件。
我们保持这个简单,这样就可以专注于涉及的 Python 编程。
对于这个食谱,我们假设这两个应用程序都不是用 Python 编写的。我们假装它们是用 Fortran 或 Ada 或其他与 Python 不直接兼容的语言编写的。
在将两个应用程序合并为一个食谱中,我们看了如何可以组合 Python 应用程序。当应用程序是用 Python 编写时,这是首选的方法。当应用程序不是用 Python 编写时,需要额外的工作。
这个配方使用了命令设计模式;这支持命令序列的扩展和修改。
如何做...
- 我们将定义一个抽象的
Command类。其他命令将被定义为子类。我们将将子进程处理推入此类定义以简化子类:
import subprocess
class Command:
def execute(self, options):
self.command = self.create_command(options)
results = subprocess.run(self.command,
check=True, stdout=subprocess.PIPE)
self.output = results.stdout
return self.output
def create_command(self, options):
return ['echo', self.__class__.__name__, repr(self.options)]
execute()方法首先通过创建 OS 级别的要执行的命令来工作。每个子类将为包装的命令提供不同的规则。一旦命令构建完成,subprocess模块的run()函数将处理此命令。
create_command() 方法构建由操作系统执行的命令的单词序列。通常,选项将用于自定义创建的命令参数。此方法的超类实现提供了一些调试信息。每个子类必须重写此方法以产生有用的输出。
- 我们可以使用
Command超类来定义一个命令来模拟游戏并创建样本:
import ch13_r05
class Simulate(Command):
def __init__(self, seed=None):
self.seed = seed
def execute(self, options):
if self.seed:
os.environ['RANDOMSEED'] = str(self.seed)
super().execute(options)
def create_command(self, options):
return ['python3', 'ch13_r05.py`,
'--samples', str(options.samples),
'-o', options.game_file]
在这种情况下,我们提供了对execute()方法的重写,以便这个类可以更改环境变量。这允许集成测试设置特定的随机种子,并确认结果与固定的预期值匹配。
create_command() 方法发出了用于执行ch13_r05命令的命令行的单词。这将数字值options.samples转换为字符串。
- 我们还可以使用
Command超类来定义一个命令来总结各种模拟过程:
import ch13_r06
class Summarize(Command):
def create_command(self, options):
return ['python3', 'ch13_r06.py',
'-o', options.summary_file,
] + options.game_files
在这种情况下,我们只实现了create_command()。此实现为ch13_r06命令提供了参数。
- 鉴于这两个命令,整个主程序可以遵循为组合设计脚本配方的设计模式。我们需要收集选项,然后使用这些选项来执行这两个命令:
from argparse import Namespace
def demo():
options = Namespace(samples=100,
game_file='x12.yaml', game_files=['x12.yaml'],
summary_file='y12.yaml')
step1 = Simulate()
step2 = Summarize()
step1.execute(options)
step2.execute(options)
此演示函数demo()创建了一个带有可能来自命令行的参数的Namespace实例。它构建了两个处理步骤。最后,它执行每个步骤。
这种函数提供了一个执行一系列应用程序的高级脚本。它比 shell 要灵活得多,因为我们可以利用 Python 丰富的数据结构。因为我们使用 Python,我们也可以包括单元测试。
工作原理...
在这个配方中有两个相互交织的设计模式:
-
Command类层次结构 -
使用
subprocess.run()函数包装外部命令
Command类层次结构的想法是将每个单独的步骤或操作变成一个共同的、抽象的超类的子类。在这种情况下,我们称这个超类为Command。这两个操作是Command类的子类。这确保我们可以为所有类提供共同的特性。
包装外部命令有几个考虑因素。一个主要问题是如何构建所需的命令行选项。在这种情况下,run()函数将使用一个单词列表,非常容易将文字字符串、文件名和数值组合成一个程序的有效选项集。另一个主要问题是如何处理 OS 定义的标准输入、标准输出和标准错误文件。在某些情况下,这些文件可以显示在控制台上。在其他情况下,应用程序可能会捕获这些文件以进行进一步的分析和处理。
这里的基本思想是分开两个考虑因素:
-
执行命令的概述。这包括关于顺序、迭代、条件处理和可能对顺序进行更改的问题。这些是与用户故事相关的高级考虑因素。
-
执行每个命令的详细信息。这包括命令行选项、使用的输出文件和其他 OS 级别的考虑因素。这些是更多关于实现细节的技术考虑因素。
将两者分开使得更容易实现或修改用户故事。对操作系统级别的考虑的更改不应该改变用户故事;处理可能会更快或使用更少的内存,但其他方面是相同的。同样,对用户故事的更改不应该破坏操作系统级别的考虑。
还有更多...
一系列复杂的步骤可能涉及一个或多个步骤的迭代。由于高级脚本是用 Python 编写的,添加迭代是用for语句完成的:
def process_i(options):
step1 = Simulate()
options.game_files = []
for i in range(options.simulations):
options.game_file = 'game_{i}.yaml'.format_map(vars())
options.game_files.append(options.game_file)
step1.execute(options)
step2 = Summarize()
step2.execute(options)
此process_i()函数将多次处理Simulate步骤。它使用simulations选项来指定要运行多少次模拟。每次模拟将产生预期数量的样本。
此函数将为处理的每次迭代设置game_file选项的不同值。每个生成的文件名都将是唯一的,导致产生多个样本文件。文件列表也被收集到game_files选项中。
当执行下一步Summarize类时,它将具有适当的文件列表进行处理。分配给options变量的Namespace对象可用于跟踪全局状态变化,并将此信息提供给后续处理步骤。
构建有条件的处理。
由于高级编程是用 Python 编写的,因此很容易添加不基于封装的两个应用程序的附加处理。一个功能可能是可选的总结步骤。
例如,如果选项没有summary_file选项,则可以跳过处理。这可能会导致process()函数的一个版本看起来像这样:
def process_c(options):
step1 = Simulate()
step1.execute(options)
if 'summary_file' in options:
step2 = Summarize()
step2.execute(options)
此procees_c()函数将有条件地处理Summarize步骤。如果有summary_file选项,它将执行第二步。否则,它将跳过总结步骤。
在这种情况下,以及前面的例子中,我们已经使用了 Python 编程功能来增强这两个应用程序。
另请参阅
- 通常,这些类型的处理步骤是为更大或更复杂的应用程序完成的。有关与更大更复杂的复合应用程序一起使用的更多食谱,请参阅将两个应用程序合并为一个和在复合应用程序中管理参数和配置。


x 表示这一点。对于这个例子,我们只对产品中计算的值施加了一个简单的边界:
。
。
。
是
。
浙公网安备 33010602011771号