Python-学徒(全)

Python 学徒(全)

原文:zh.annas-archive.org/md5/4702C628AD6B03CA92F1B4B8E471BB27

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

这本书是通过迂回的方式产生的。2013 年,当我们成立了位于挪威的软件咨询和培训公司 Sixty North 时,我们受到了在线视频培训材料出版商 Pluralsight 的追捧,他们希望我们为迅速增长的大规模在线开放课程(MOOC)市场制作 Python 培训视频。当时,我们没有制作视频培训材料的经验,但我们确定希望仔细构建我们的 Python 入门内容,以尊重某些限制。例如,我们希望最少使用前向引用,因为这对我们的观众来说非常不方便。我们都是言辞之人,遵循图灵奖得主莱斯利·兰波特的格言 “如果你在不写作的情况下思考,你只是以为自己在思考”,因此,我们首先通过撰写脚本来攻击视频课程制作。

很快,我们的在线视频课程被 PluralsightPython 基础知识 的形式写成、录制并发布,受到了极其积极的反响,这种反响已经持续了几年。从最早的日子起,我们就想到这个脚本可以成为一本书的基础,尽管可以说我们低估了将内容从一个好的脚本转化为一本更好的书所需的努力。

Python 学徒 就是这种转变的结果。它可以作为独立的 Python 教程,也可以作为我们视频课程的配套教材,具体取决于哪种学习方式更适合您。Python 学徒 是三本书中的第一本,另外两本分别是 Python 熟练者Python 大师。后两本书对应于我们随后的 Pluralsight 课程 Python - 进阶高级 Python

勘误和建议

本书中的所有材料都经过了彻底的审查和测试;然而,不可避免地会出现一些错误。如果您发现了错误,我们会非常感激您通过 Leanpub Python 学徒讨论 页面让我们知道,这样我们就可以进行修正并部署新版本。

本书中使用的约定

本书中的代码示例显示为带有语法高亮的固定宽度文本:

>>> def square(x):
...     return x * x
...

我们的一些示例显示了保存在文件中的代码,而其他一些示例(如上面的示例)来自交互式 Python 会话。在这种交互式情况下,我们包括 Python 会话中的提示符,如三角形箭头(>>>)和三个点(...)提示符。您不需要输入这些箭头或点。同样,对于操作系统的 shell 命令,我们将使用 Linux、macOS 和其他 Unix 系统的美元提示符($),或者在特定操作系统对于当前任务无关紧要的情况下使用。

$ python3 words.py

在这种情况下,您不需要输入 $ 字符。

对于特定于 Windows 的命令,我们将使用一个前导大于提示符:

> python words.py

同样,无需输入 > 字符。

对于需要放置在文件中而不是交互输入的代码块,我们显示的代码没有任何前导提示符:

def write_sequence(filename, num):
    """Write Recaman's sequence to a text file."""
    with open(filename, mode='wt', encoding='utf-8') as f:
        f.writelines("{0}\n".format(r)
                     for r in islice(sequence(), num + 1))

我们努力确保我们的代码行足够短,以便每一行逻辑代码对应于您的书中的一行物理代码。然而,电子书发布到不同设备的变化和偶尔需要长行代码的真正需求意味着我们无法保证行不会换行。然而,我们可以保证,如果一行换行,出版商已经在最后一列插入了一个反斜杠字符 \。您需要自行判断这个字符是否是代码的合法部分,还是由电子书平台添加的。

>>> print("This is a single line of code which is very long. Too long, in fact, to fi\
t on a single physical line of code in the book.")

如果您在上述引用的字符串中看到一条反斜杠,那么它是代码的一部分,不应该输入。

偶尔,我们会对代码行进行编号,这样我们就可以很容易地从下一个叙述中引用它们。这些行号不应该作为代码的一部分输入。编号的代码块看起来像这样:

 1 def write_grayscale(filename, pixels):
 2    height = len(pixels)
 3    width = len(pixels[0])
 4 
 5    with open(filename, 'wb') as bmp:
 6        # BMP Header
 7        bmp.write(b'BM')
 8 
 9        # The next four bytes hold the filesize as a 32-bit
10         # little-endian integer. Zero placeholder for now.
11         size_bookmark = bmp.tell()
12         bmp.write(b'\x00\x00\x00\x00')

有时我们需要呈现不完整的代码片段。通常这是为了简洁起见,我们要向现有的代码块添加代码,并且我们希望清楚地了解代码块的结构,而不重复所有现有的代码块内容。在这种情况下,我们使用包含三个点的 Python 注释# ...来指示省略的代码:

class Flight:

    # ...

    def make_boarding_cards(self, card_printer):
        for passenger, seat in sorted(self._passenger_seats()):
            card_printer(passenger, seat, self.number(), self.aircraft_model())

这里暗示了在Flight类块中的make_boarding_cards()函数之前已经存在一些其他代码。

最后,在书的文本中,当我们提到一个既是标识符又是函数的标识符时,我们将使用带有空括号的标识符,就像我们在前面一段中使用make_boarding_cards()一样。

第一章:欢迎学徒!

欢迎来到《Python 学徒》!我们的目标是为您提供对 Python 编程语言的实用和全面的介绍,为您提供您在几乎任何 Python 项目中成为高效成员所需的工具和见解。Python 是一种庞大的语言,我们并不打算在这本书中涵盖所有需要了解的内容。相反,我们希望帮助您建立坚实的基础,让您在 Python 这个有时令人困惑的宇宙中找到方向,并让您有能力自主继续学习。

这本书主要面向有其他语言编程经验的人。如果你目前正在使用 C++、C#或 Java 等主流命令式或面向对象的语言进行编程,那么你将拥有你需要从这本书中获益的背景知识。如果你有其他类型语言的经验,比如函数式或基于角色的语言,那么你可能会在学习 Python 时遇到一些困难,但不会遇到严重的困难。大多数程序员发现 Python 非常易于接近和直观,只需稍加练习,他们很快就能熟悉它。

另一方面,如果你没有任何编程经验,这本书可能会有点吓人。你将不仅学习一种编程语言,同时学习许多所有语言共同的主题和问题。公平地说,我们并没有花很多时间来解释这些“假定知识”领域。这并不意味着你不能从这本书中学到东西!这只是意味着你可能需要更努力地学习,多次阅读章节,并可能需要他人的指导。然而,这种努力的回报是,你将开始发展处理其他语言的知识和直觉,这对于专业程序员来说是至关重要的技能。

在本章中,我们将快速了解 Python 语言。我们将介绍 Python 是什么(提示:它不仅仅是一种语言!),看看它是如何开发的,以及它对许多程序员如此吸引人的原因。我们还将简要预览本书的其余结构。

Python 促销

首先,Python 有什么好处?为什么你想学它?对于这些问题有很多好答案。其中一个是 Python 很强大。Python 语言表达力强,高效,它带有一个很棒的标准库,并且它是一个巨大的精彩第三方库的中心。使用 Python,你可以从简单的脚本到复杂的应用程序,你可以快速完成,你可以安全地完成,而且你可以用比你可能认为可能的更少的代码行数完成。

但这只是 Python 之所以伟大的一部分。另一个是 Python 非常开放。它是开源的,所以如果你愿意,你可以了解它的每个方面。同时,Python 非常受欢迎,并且有一个伟大的社区来支持你当你遇到问题时。这种开放性和庞大的用户群意味着几乎任何人 - 从业余程序员到专业软件开发人员 - 都可以以他们需要的水平参与到这种语言中。

Python 拥有庞大的用户群体的另一个好处是它在越来越多的地方出现。你可能想要学习 Python,仅仅因为它是你想要使用的某种技术的语言,这并不奇怪 - 世界上许多最受欢迎的网络和科学软件包都是用 Python 编写的。

但对于许多人来说,这些原因都不如更重要的东西:Python 很有趣!Python 的表达力强,可读性强的风格,快速的编辑和运行开发周期,以及“电池包含”哲学意味着你可以坐下来享受编写代码,而不是与编译器和棘手的语法斗争。而且 Python 会随着你的成长而成长。当你的实验变成原型,你的原型变成产品时,Python 使编写软件的体验不仅更容易,而且真正令人愉快。

兰德尔·门罗的话来说,“快来加入我们!编程再次变得有趣!”

概述

本书包括 10 章(不包括本章)。这些章节是相互关联的,所以除非您已经对 Python 有一定了解,否则需要按顺序进行学习。我们将从安装 Python 到您的系统并对其进行定位开始。

然后,我们将涵盖语言元素、特性、习惯用法和库,所有这些都是通过实际示例驱动的,您将能够随着文本一起构建这些示例。我们坚信,通过实践而非仅仅阅读,您将学到更多,因此我们鼓励您自己运行这些示例。

在本书结束时,您将了解 Python 语言的基础知识。您还将了解如何使用第三方库,以及开发它们的基础知识。我们甚至会介绍测试的基础知识,以便您可以确保和维护您开发的代码的质量。

章节包括:

  1. 入门:我们将介绍安装 Python,了解一些基本的 Python 工具,并涵盖语言和语法的核心要素。

  2. 字符串和集合:我们将介绍一些基本的复杂数据类型:字符串、字节序列、列表和字典。

  3. 模块化:我们将介绍 Python 用于构建代码结构的工具,如函数和模块。

  4. 内置类型和对象模型:我们将详细研究 Python 的类型系统和对象系统,并培养对 Python 引用语义的深刻理解。

  5. 集合类型:我们将更深入地介绍一些 Python 集合类型,并介绍一些新的类型。

  6. 处理异常:我们了解 Python 的异常处理系统以及异常在语言中的核心作用。

  7. 理解、可迭代和生成器:我们将探讨 Python 中优雅、普遍和强大的面向序列的部分,如理解和生成器函数。

  8. 使用类定义新类型:我们介绍如何使用类来开发自己的复杂数据类型,以支持面向对象编程。

  9. 文件和资源管理:我们将介绍如何在 Python 中处理文件,并介绍 Python 用于资源管理的工具。

  10. 使用 Python 标准库进行单元测试:我们将向您展示如何使用 Python 的unittest包来生成预期的无缺陷代码。

Python 是什么?

它是一种编程语言!

那么 Python 是什么?简单地说,Python 是一种编程语言。它最初是由 Guido van Rossum 在 20 世纪 80 年代末在荷兰开发的。Guido 继续积极参与指导语言的发展和演变,以至于他被赋予了“终身仁慈独裁者”的称号,或者更常见的BDFL。Python 是作为一个开源项目开发的,可以自由下载和使用。非营利性的Python 软件基金会管理 Python 的知识产权,在推广语言方面发挥着重要作用,并在某些情况下资助其发展。

在技术层面上,Python 是一种强类型语言。这意味着语言中的每个对象都有一个确定的类型,通常没有办法规避该类型。与此同时,Python 是动态类型的,这意味着在运行代码之前没有对代码进行类型检查。这与 C++或 Java 等静态类型语言形成对比,编译器会为您进行大量的类型检查,拒绝错误使用对象的程序。最终,对 Python 类型系统的最佳描述是它使用鸭子类型,其中对象在运行时才确定其适用于上下文。我们将在第八章中更详细地介绍这一点。

Python 是一种通用编程语言。它并不是用于任何特定领域或环境,而是可以丰富地用于各种任务。当然,也有一些领域不太适合它 - 例如在极端时间敏感或内存受限的环境中 - 但大多数情况下,Python 像许多现代编程语言一样灵活和适应性强,比大多数编程语言更灵活。

Python 是一种解释型语言。从技术上讲,这有点错误,因为 Python 在执行之前通常会被编译成一种字节码形式。然而,这种编译是隐形的,使用 Python 的体验通常是立即执行代码,没有明显的编译阶段。编辑和运行之间的中断缺失是使用 Python 的一大乐趣之一。

Python 的语法旨在清晰、可读和富有表现力。与许多流行的语言不同,Python 使用空格来界定代码块,并在这个过程中摒弃了大量不必要的括号,同时强制执行统一的布局。这意味着所有 Python 代码在重要方面看起来都是相似的,你可以很快学会阅读 Python。与此同时,Python 富有表现力的语法意味着你可以在一行代码中表达很多含义。这种富有表现力、高度可读的代码意味着 Python 的维护相对容易。

Python 语言有多种实现。最初 - 也是迄今为止最常见的 - 实现是用 C 编写的。这个版本通常被称为CPython。当有人谈论“运行 Python”时,通常可以安全地假设他们在谈论 CPython,这也是我们在本书中将使用的实现。

Python 的其他实现包括:

  • Jython,编写以针对 Java 虚拟机

  • IronPython,编写以针对.NET 平台

  • PyPy,用一种称为 RPython 的语言编写(有点循环),该语言旨在开发像 Python 这样的动态语言

这些实现通常落后于 CPython,后者被认为是该语言的“标准”。本书中学到的大部分内容都适用于所有这些实现。

Python 语言的版本

Python 语言目前有两个重要的常用版本:Python 2 和 Python 3。这两个版本代表了语言中一些关键元素的变化,除非你采取特殊预防措施,否则为其中一个版本编写的代码通常不会适用于另一个版本。Python 2 比 Python 3 更老,更为成熟,但 Python 3 解决了较老版本中的一些已知缺陷。Python 3 是 Python 的明确未来,如果可能的话,你应该使用它。

虽然 Python 2 和 3 之间存在一些关键差异,但这两个版本的大部分基础知识是相同的。如果你学会了其中一个,大部分知识都可以顺利转移到另一个版本。在本书中,我们将教授 Python 3,但在必要时我们会指出版本之间的重要差异。

这是一个标准库!

除了作为一种编程语言外,Python 还附带一个强大而广泛的标准库。Python 哲学的一部分是“电池包含”,这意味着你可以直接使用 Python 来处理许多复杂的现实任务,无需安装第三方软件包。这不仅非常方便,而且意味着通过使用有趣、引人入胜的示例来学习 Python 更容易 - 这也是我们在本书中的目标!

“电池包含”方法的另一个重要影响是,这意味着许多脚本 - 即使是非平凡的脚本 - 可以立即在任何 Python 安装上运行。这消除了在安装软件时可能面临的其他语言的常见烦人障碍。

标准库有相当高水平的良好文档。API 有很好的文档,模块通常有良好的叙述描述,包括快速入门指南、最佳实践信息等。标准库文档始终可在线获取,如果需要,你也可以在本地安装它。

由于标准库是 Python 的重要组成部分,我们将在本书中涵盖其中的部分内容。即便如此,我们也不会涵盖其中的一小部分,因此鼓励你自行探索。

这是一种哲学

最后,没有描述 Python 的内容是完整的,没有提到对许多人来说,Python 代表了编写代码的一种哲学。清晰和可读性的原则是编写正确或pythonic代码的一部分。在所有情况下,pythonic的含义并不总是清晰,有时可能没有单一的正确写法。但 Python 社区关注简单、可读性和明确性的事项意味着 Python 代码往往更…嗯…美丽!

Python 的许多原则体现在所谓的“Python 之禅”中。这个“禅”不是一套严格的规则,而是一套在编码时牢记的指导原则或准则。当你试图在几种行动方案之间做出决定时,这些原则通常可以给你一个正确的方向。我们将在本书中突出显示“Python 之禅”的元素。

千里之行,始于足下。

我们认为 Python 是一种很棒的语言,我们很高兴能帮助你开始学习它。当你读完这本书时,你将能够编写大量的 Python 程序,甚至能够阅读更复杂的程序。更重要的是,你将拥有你需要的基础知识,可以去探索语言中所有更高级的主题,希望我们能让你对 Python 感到兴奋,真正去做。Python 是一种庞大的语言,拥有庞大的软件生态系统,围绕它构建了大量软件,发现它所提供的一切可能是一次真正的冒险。

欢迎来到 Python!

第二章:入门

在本章中,我们将介绍如何在 Windows、Ubuntu Linux 和 macOS 上获取和安装 Python。我们还将编写我们的第一个基本 Python 代码,并熟悉 Python 编程文化的基本知识,比如 Python 之禅,同时永远不要忘记语言名称的滑稽起源。

获取和安装 Python 3

Python 语言有两个主要版本,Python 2是广泛部署的传统语言,Python 3是语言的现在和未来。许多 Python 代码在 Python 2 的最后一个版本(即Python 2.7)和 Python 3 的最新版本之间可以无需修改地工作,比如Python 3.5。然而,主要版本之间存在一些关键差异,严格意义上来说,这两种语言是不兼容的。我们将在本书中使用 Python 3.5,但在介绍过程中我们将指出与 Python 2 的主要差异。此外,很可能,作为一本关于 Python 基础知识的书,我们所介绍的一切都适用于未来版本的 Python 3,因此不要害怕在这些版本推出时尝试它们。

在我们开始使用 Python 进行编程之前,我们需要获得一个 Python 环境。Python 是一种高度可移植的语言,可在所有主要操作系统上使用。您将能够在 Windows、Mac 或 Linux 上阅读本书,并且我们只有在安装 Python 3 时才会涉及到平台特定的主要部分。当我们涵盖这三个平台时,可以随意跳过对您不相关的部分。

Windows

  1. 对于 Windows,您需要访问官方 Python 网站,然后通过单击左侧的链接转到下载页面。对于 Windows,您应该根据您的计算机是 32 位还是 64 位选择其中一个 MSI 安装程序。

  2. 下载并运行安装程序。

  3. 在安装程序中,决定您是只为自己安装 Python,还是为计算机上的所有用户安装 Python。

  4. 选择 Python 分发的位置。默认位置将在C:\Python35中,位于C:驱动器的根目录下。我们不建议将 Python 安装到Program Files中,因为 Windows Vista 及更高版本中用于隔离应用程序的虚拟化文件存储可能会干扰轻松安装第三方 Python 包。

  5. 在向导的自定义 Python页面上,我们建议保持默认设置,这将使用不到 40MB 的空间。

  6. 除了安装 Python 运行时和标准库外,安装程序还将使用 Python 解释器注册各种文件类型,例如*.py文件。

  7. Python 安装完成后,您需要将 Python 添加到系统的PATH环境变量中。要做到这一点,从控制面板中选择系统和安全,然后选择系统。另一种更简单的方法是按住 Windows 键,然后按键盘上的 Break 键。在左侧的任务窗格中选择高级系统设置以打开系统属性对话框的高级选项卡。单击环境变量以打开子对话框。

  8. 如果您拥有管理员权限,您应该能够将路径C:\Python35C:\Python35\Scripts添加到与PATH系统变量关联的分号分隔的条目列表中。如果没有,您应该能够创建或附加到特定于您的用户的PATH变量,其中包含相同的值。

  9. 现在打开一个的控制台窗口——Powershell 或 cmd 都可以——并验证您是否可以从命令行运行 python:

 > python
Python 3.5.0 (v3.5.0:374f501f4567, Sep 13 2015, 02:27:37) [MSC v.1900 64 bit (AMD64)]\
 on win32
Type "help", "copyright", "credits" or "license" for more information.
>>>

欢迎使用 Python!

三角箭头提示您 Python 正在等待您的输入。

在这一点上,您可能想要跳过,同时我们展示如何在 Mac 和 Linux 上安装 Python。

macOS

  1. 对于 macOS,您需要访问官方 Python 网站python.org。点击左侧的链接进入下载页面。在下载页面上,找到与您的 macOS 版本匹配的 macOS 安装程序,并单击链接下载它。

  2. 一个 DMG 磁盘映像文件将被下载,您可以从下载堆栈或 Finder 中打开它。

  3. 在打开的 Finder 窗口中,您将看到文件Python.mpkg多包安装程序文件。使用“次要”点击操作打开该文件的上下文菜单。从该菜单中,选择“打开”。

  4. 在某些版本的 macOS 上,您现在可能会收到文件来自未知开发者的通知。按下此对话框上的“打开”按钮以继续安装。

  5. 您现在在 Python 安装程序中。按照说明,通过向导进行点击。

  6. 无需定制安装,并且应保持标准设置。当可用时,单击“安装”按钮安装 Python。您可能会被要求输入密码以授权安装。安装完成后,单击“关闭”以关闭安装程序。

  7. 现在 Python 3 已安装,请打开一个终端窗口并验证您是否可以从命令行运行 Python 3:

 > python
Python 3.5.0 (default, Nov  3 2015, 13:17:02) 
[GCC 4.2.1 Compatible Apple LLVM 6.1.0 (clang-602.0.53)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>> 

欢迎使用 Python!

三重箭头提示显示 Python 正在等待您的输入。

Linux

  1. 要在 Linux 上安装 Python,您需要使用系统的软件包管理器。我们将展示如何在最新版本的 Ubuntu 上安装 Python,但在大多数其他现代 Linux 发行版上,该过程非常相似。

  2. 在 Ubuntu 上,首先启动“Ubuntu 软件中心”。通常可以通过单击启动器中的图标来运行。或者,您可以在仪表板上搜索“Ubuntu 软件中心”并单击选择来运行它。

  3. 一旦进入软件中心,在右上角的搜索栏中输入搜索词“python 3.5”并按回车键。

  4. 您将获得一个结果,上面写着“Python(v3.5)”,下面以较小的字体写着“Python 解释器(v3.5)”。选择此条目并单击出现的“安装”按钮。

  5. 此时可能需要输入密码来安装软件。

  6. 现在您应该看到一个进度指示器出现,安装完成后将消失。

  7. 打开终端(使用Ctrl-Alt-T)并验证您是否可以从命令行运行 Python 3.5:

$ python3.5
Python 3.5.0+ (default, Oct 11 2015, 09:05:38)
[GCC 5.2.1 20151010] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>>

欢迎使用 Python!

三重箭头提示显示 Python 正在等待您的输入。

启动 Python 命令行 REPL

现在 Python 已安装并运行,您可以立即开始使用它。这是了解语言的好方法,也是正常开发过程中进行实验和快速测试的有用工具。

这个 Python 命令行环境是一个读取-求值-打印-循环。Python 将读取我们输入的任何内容,求值它,打印结果,然后循环回到开始。您经常会听到它被缩写为“REPL”。

启动时,REPL 将打印有关您正在运行的 Python 版本的一些信息,然后会给出一个三重箭头提示。此提示告诉您 Python 正在等待您输入。

在交互式 Python 会话中,您可以输入 Python 程序的片段并立即看到结果。让我们从一些简单的算术开始:

>>> 2 + 2
4
>>> 6 * 7
42

正如您所看到的,Python 读取我们的输入,对其进行求值,打印结果,并循环执行相同的操作。

我们可以在 REPL 中为变量赋值:

>>> x = 5

通过输入它们的名称打印它们的内容:

>>> x
5

并在表达式中引用它们:

>>> 3 * x
15

在 REPL 中,您可以使用特殊的下划线变量来引用最近打印的值,这是 Python 中非常少数的晦涩快捷方式之一:

>>> _
15

或者您可以在表达式中使用特殊的下划线变量:

>>> _ * 2
30

请注意,并非所有语句都有返回值。当我们将 5 赋给x时,没有返回值,只有将变量x带入的副作用。其他语句具有更明显的副作用。

尝试:

>>> print('Hello, Python')
Hello, Python

您会发现 Python 立即评估并执行此命令,打印字符串“Hello, Python”,然后返回到另一个提示符。重要的是要理解,这里的响应不是由 REPL 评估和显示的表达式结果,而是print()函数的副作用。

离开 REPL

在这一点上,我们应该向您展示如何退出 REPL 并返回到系统 shell 提示符。我们通过向 Python 发送文件结束控制字符来实现这一点,尽管不幸的是,发送此字符的方式在不同平台上有所不同。

Windows

如果您在 Windows 上,按Ctrl-Z退出。

Unix

如果您在 Mac 或 Linux 上,按Ctrl-D退出。

如果您经常在不同平台之间切换,而在类 Unix 系统上意外按下Ctrl-Z,您将意外地挂起 Python 解释器并返回到操作系统 shell。要通过再次使 Python 成为前台进程来重新激活 Python,请运行fg命令:

$ fg

然后按Enter键几次,以获取三角形箭头 Python 提示符:

>>>

代码结构和重要缩进

启动 Python 3 解释器:

> python

在 Windows 上或:

$ python3

在 Mac 或 Linux 上。

Python 的控制流结构,如 for 循环、while 循环和 if 语句,都是由以冒号结尾的语句引入的,表示后面要跟着构造的主体。例如,for 循环需要一个主体,所以如果您输入:

>>> for i in range(5):
...

Python 会向您显示三个点的提示,要求您提供主体。

Python 一个与众不同的(有时是有争议的)方面是,前导空格在语法上是有意义的。这意味着 Python 使用缩进级别来标示代码块,而不是其他语言使用的大括号。按照惯例,当代 Python 代码每个级别缩进四个空格。

因此,当 Python 向我们显示三个点的提示时,我们提供这四个空格和一个语句来形成循环的主体:

...     x = i * 10

我们的循环主体将包含第二个语句,因此在下一个三点提示符处按Return后,我们将输入另外四个空格,然后调用内置的print()函数:

...     print(x)

要终止我们的块,我们必须在 REPL 中输入一个空行:

...

块完成后,Python 执行挂起的代码,打印出小于 50 的 10 的倍数:

0
10
20
30
40


看着屏幕上的 Python 代码,我们可以看到缩进清晰地匹配 - 实际上必须匹配 - 程序的结构。

Python 源代码

Python 源代码

即使我们用灰色线代替代码,程序的结构也是清晰的。

灰色的代码

灰色的代码

每个以冒号结尾的语句都会开始一个新行,并引入一个额外的缩进级别,直到取消缩进将缩进恢复到先前的级别。每个缩进级别通常是四个空格,尽管我们稍后会更详细地介绍规则。

Python 对重要空白的处理方式有三个很大的优势:

  1. 它强制开发人员在代码块中使用单一级别的缩进。这通常被认为是任何语言中的良好实践,因为它使代码更易读。

  2. 具有重要空白的代码不需要被不必要的大括号混乱,您也不需要就大括号应该放在哪里进行代码标准的辩论。Python 代码中的所有代码块都很容易识别,每个人都以相同的方式编写它们。

  3. 重要的空白要求作者、Python 运行时系统和未来需要阅读代码的维护者对代码的结构给出一致的解释。因此,你永远不会有从 Python 的角度来看包含一个代码块,但从肤浅的人类角度来看却不像包含代码块的代码。


Python 缩进的规则可能看起来复杂,但在实践中它们是非常简单的。

  • 你使用的空白可以是空格或制表符。一般的共识是空格优于制表符四个空格已经成为 Python 社区的标准

  • 一个基本的规则是绝对不要混合使用空格和制表符。Python 解释器会抱怨,你的同事会追捕你。

  • 如果你愿意,你可以在不同的时间使用不同数量的缩进。基本规则是相同缩进级别的连续代码行被认为是同一个代码块的一部分

  • 这些规则有一些例外,但它们几乎总是与以其他方式改善代码可读性有关,例如通过将必要的长语句分成多行。

这种严格的代码格式化方法是“Guido 所期望的编程”或者更恰当地说是“Guido 所*打算的编程”!重视代码质量,如可读性的哲学贯穿于 Python 文化的核心,现在我们将暂停一下来探讨一下。

Python 文化

许多编程语言都处于文化运动的中心。它们有自己的社区、价值观、实践和哲学,Python 也不例外。Python 语言本身的发展是通过一系列称为Python 增强提案PEPs的文件来管理的。其中一份 PEP,称为 PEP 8,解释了你应该如何格式化你的代码,我们在本书中遵循它的指导方针。例如,PEP 8 建议我们在新的 Python 代码中使用四个空格进行缩进。

另一个 PEP,称为 PEP 20,被称为“Python 的禅宗”。它涉及到 20 条格言,描述了 Python 的指导原则,其中只有 19 条被写下来。方便的是,Python 的禅宗从来都不会比最近的 Python 解释器更远,因为它总是可以通过在 REPL 中输入来访问:

>>> import this
The Zen of Python, by Tim Peters

Beautiful is better than ugly.
Explicit is better than implicit.
Simple is better than complex.
Complex is better than complicated.
Flat is better than nested.
Sparse is better than dense.
Readability counts.
Special cases aren't special enough to break the rules.
Although practicality beats purity.
Errors should never pass silently.
Unless explicitly silenced.
In the face of ambiguity, refuse the temptation to guess.
There should be one-- and preferably only one --obvious way to do it.
Although that way may not be obvious at first unless you're Dutch.
Now is better than never.
Although never is often better than *right* now.
If the implementation is hard to explain, it's a bad idea.
If the implementation is easy to explain, it may be a good idea.
Namespaces are one honking great idea -- let's do more of those!

在本书中,我们将突出显示 Python 禅宗中的特定智慧之处,以了解它们如何适用于我们所学到的知识。由于我们刚刚介绍了 Python 的重要缩进,现在是我们第一个禅宗时刻的好时机。


随着时间的推移,你会开始欣赏 Python 的重要空白,因为它为你的代码带来了优雅,以及你可以轻松阅读他人的代码。


导入标准库模块

如前所述,Python 附带了一个庞大的标准库,这是 Python 的一个重要方面,通常被称为“电池包括在内”。标准库被组织为模块,这是我们将在后面深入讨论的一个主题。在这个阶段重要的是要知道,你可以通过使用import关键字来访问标准库模块。

导入模块的基本形式是import关键字后跟一个空格和模块的名称。例如,让我们看看如何使用标准库的math模块来计算平方根。在三角箭头提示下,我们输入:

>>> import math

由于import是一个不返回值的语句,如果导入成功,Python 不会打印任何内容,我们会立即返回到提示符。我们可以通过使用模块的名称,后跟一个点,后跟您需要的模块中的属性的名称,来访问导入模块的内容。与许多面向对象的语言一样,点运算符用于深入到对象结构中。作为 Python 专家,我们知道math模块包含一个名为sqrt()的函数。让我们尝试使用它:

>>> math.sqrt(81)
9.0

获取help()

但是我们如何找出math模块中还有哪些其他函数可用?

REPL 有一个特殊的函数help(),它可以检索已提供文档的对象的任何嵌入式文档,例如标准库模块。

要获取帮助,请在提示符处输入“help”:

>>> help
Type help() for interactive help, or help(object) for help about object.

我们将让您在自己的时间里探索第一种形式——交互式帮助。在这里,我们将选择第二个选项,并将math模块作为我们想要帮助的对象传递:

>>> help(math)
Help on module math:

NAME
    math

MODULE REFERENCE
        http://docs.python.org/3.3/library/math

    The following documentation is automatically generated from the Python
    source files.  It may be incomplete, incorrect or include features that
    are considered implementation detail and may vary between Python
    implementations.  When in doubt, consult the module reference at the
    location listed above.

DESCRIPTION
    This module is always available.  It provides access to the
    mathematical functions defined by the C standard.

FUNCTIONS
    acos(...)
    acos(x)

        Return the arc cosine (measured in radians) of x.

您可以使用空格键翻页帮助,如果您使用的是 Mac 或 Linux,则可以使用箭头键上下滚动。

浏览函数时,您会发现有一个名为factorial的数学函数,用于计算阶乘。按“q”退出帮助浏览器,返回到 Python REPL。

现在练习使用help()来请求factorial函数的特定帮助:

>>> help(math.factorial)
Help on built-in function factorial in module math:

factorial(...)
    factorial(x) -> Integral

    Find x!. Raise a ValueError if x is negative or non-integral.

按“q”返回到 REPL。

让我们稍微使用一下factorial()。该函数接受一个整数参数并返回一个整数值:

>>> math.factorial(5)
120
>>> math.factorial(6)
720

请注意,我们需要使用模块命名空间来限定函数名。这通常是一个很好的做法,因为它清楚地表明了函数的来源。尽管如此,它可能导致代码过于冗长。

使用math.factorial()计算水果的数量

让我们使用阶乘来计算从五种水果中抽取三种水果的方式,使用我们在学校学到的一些数学:

>>> n = 5
>>> k = 3
>>> math.factorial(n) / (math.factorial(k) * math.factorial(n - k))
10.0

这个简单的表达式对于所有这些对 math 模块的引用来说相当冗长。Python 的import语句有一种替代形式,允许我们使用from关键字将模块中的特定函数引入当前命名空间:

>>> from math import factorial
>>> factorial(n) / (factorial(k) * factorial(n - k))
10.0

这是一个很好的改进,但对于这样一个简单的表达式来说仍然有点冗长。

导入语句的第三种形式允许我们重命名导入的函数。这对于可读性或避免命名空间冲突是有用的。尽管它很有用,但我们建议尽量少地和审慎地使用这个功能:

>>> from math import factorial as fac
>>> fac(n) / (fac(k) * fac(n - k))
10.0

不同类型的数字

请记住,当我们单独使用factorial()时,它返回一个整数。但是我们上面用于计算组合的更复杂的表达式产生了一个浮点数。这是因为我们使用了/,Python 的浮点除法运算符。由于我们知道我们的操作只会返回整数结果,我们可以通过使用//,Python 的整数除法运算符来改进我们的表达式:

>>> from math import factorial as fac
>>> fac(n) // (fac(k) * fac(n - k))
10

值得注意的是,许多其他编程语言在上面的表达式中会在n的中等值上失败。在大多数编程语言中,常规的有符号整数只能存储小于2^{31}的值:

>>> 2**31 - 1
2147483647

然而,阶乘增长得如此之快,以至于您可以将最大的阶乘放入 32 位有符号整数中为 12!因为 13!太大了:

>>> fac(13)
6227020800

在大多数广泛使用的编程语言中,您要么需要更复杂的代码,要么需要更复杂的数学来计算从 13 个水果中抽取三个水果的方式。

Python 遇到这样的问题,并且可以计算任意大的整数,仅受计算机内存的限制。为了进一步证明这一点,让我们尝试计算从 100 种不同的水果中挑选多少种不同的水果对(假设我们可以拿到这么多水果!):

>>> n = 100
>>> k = 2
>>> fac(n) // (fac(k) * fac(n - k))
4950

只是为了强调一下表达式的第一项的大小有多大,计算 100!:

>>> fac(n)
9332621544394415268169923885626670049071596826438162146859296389521759999322991560894\
1463976156518286253697920827223758251185210916864000000000000000000000000

这个数字甚至比已知宇宙中的原子数量还要大得多,有很多数字。如果像我们一样,你很好奇到底有多少位数字,我们可以将整数转换为文本字符串,并像这样计算其中的字符数:

>>> len(str(fac(n)))
158

这绝对是很多数字。还有很多水果。它也开始展示了 Python 的不同数据类型——在这种情况下,整数、浮点数和文本字符串——如何以自然的方式协同工作。在下一节中,我们将在此基础上继续深入研究整数、字符串和其他内置类型。

标量数据类型:整数、浮点数、None 和布尔值

Python 带有许多内置数据类型。这些包括像整数这样的原始标量类型,以及像字典这样的集合类型。这些内置类型足够强大,可以单独用于许多编程需求,并且它们可以用作创建更复杂数据类型的构建块。

我们将要看的基本内置标量类型是:

  • int——有符号、无限精度整数

  • float——IEEE 754 浮点数

  • None——特殊的、唯一的空值

  • bool——true/false 布尔值

现在我们只会看一下它们的基本细节,展示它们的文字形式以及如何创建它们。

int

我们已经看到 Python 整数的很多用法。Python 整数是有符号的,对于所有实际目的来说,具有无限精度。这意味着它们可以容纳的值的大小没有预定义的限制。

Python 中的整数字面量通常以十进制指定:

>>> 10
10

它们也可以用0b前缀指定为二进制:

>>> 0b10
2

八进制,使用0o前缀:

>>> 0o10
8

或十六进制,使用0x前缀:

>>> 0x10
16

我们还可以通过调用int构造函数来构造整数,该构造函数可以将其他数字类型(如浮点数)转换为整数:

>>> int(3.5)
3

请注意,当使用int构造函数时,四舍五入总是朝着零的方向进行:

>>> int(-3.5)
-3
>>> int(3.5)
3

我们还可以将字符串转换为整数:

>>> int("496")
496

但要注意,如果字符串不表示整数,Python 会抛出异常(稍后会更多地讨论这些!)。

在从字符串转换时,甚至可以提供一个可选的数字基数。例如,要从基数 3 转换,只需将 3 作为构造函数的第二个参数传递:

>>> int("10000", 3)
81

浮点数

Python 通过float类型支持浮点数。Python 浮点数实现为IEEE-754 双精度浮点数,具有 53 位二进制精度。这相当于十进制中 15 到 16 个有效数字。

任何包含小数点的文字数字都会被 Python 解释为float

>>> 3.125
3.125

科学计数法可以使用,因此对于大数字——例如3\times10⁸,即每秒米数的光速的近似值——我们可以写成:

>>> 3e8
300000000.0

对于像普朗克常数1.616\times10^{ - 35}这样的小数字,我们可以输入:

>>> 1.616e-35
1.616e-35

请注意,Python 会自动切换显示表示形式为最可读的形式。

至于整数,我们可以使用float构造函数从其他数字或字符串类型转换为浮点数。例如,构造函数可以接受一个int

>>> float(7)
7.0

或一个字符串:

>>> float("1.618")
1.618

特殊浮点值

通过将某些字符串传递给float构造函数,我们可以创建特殊的浮点值NaN(缩写为Not a Number),以及正无穷大和负无穷大:

>>> float("nan")
nan
>>> float("inf")
inf
>>> float("-inf")
-inf

提升为浮点数

任何涉及intfloat的计算结果都会提升为float

>>> 3.0 + 1
4.0

您可以在 Python 文档中了解更多关于 Python 数字类型的信息(http://docs.python.org/3/library/stdtypes.html#numeric-types-int-float-complex)。

None

Python 有一个特殊的空值叫做None,拼写为大写的“N”。None经常用来表示值的缺失。Python REPL 从不打印None结果,因此在 REPL 中键入None没有任何效果:

>>> None
>>>

None可以绑定到变量名,就像任何其他对象一样:

>>> a = None

我们可以使用 Python 的is运算符测试对象是否为None

>>> a is None
True

我们可以看到这里的响应是True,这方便我们进入bool类型。

bool

bool类型表示逻辑状态,并在 Python 的几个控制流结构中扮演重要角色,我们很快就会看到。

正如您所期望的那样,有两个 bool 值,TrueFalse,都以大写字母开头:

>>> True
True
>>> False
False

还有一个bool构造函数,可以用于将其他类型转换为bool。让我们看看它是如何工作的。对于int,零被认为是“falsey”,所有其他值都被认为是“truthy”:

>>> bool(0)
False
>>> bool(42)
True
>>> bool(-1)
True

我们在float中看到相同的行为,只有零被认为是“falsey”:

>>> bool(0.0)
False
>>> bool(0.207)
True
>>> bool(-1.117)
True
>>> bool(float("NaN"))
True

在从集合(例如字符串或列表)转换时,只有空集合被视为“falsey”。在从列表转换时,我们看到只有空列表(在这里以[]的文字形式显示)被评估为False

>>> bool([])
False
>>> bool([1, 5, 9])
True

类似地,对于字符串,只有空字符串""在传递给bool时才会被评估为False

>>> bool("")
False
>>> bool("Spam")
True

特别地,您不能使用bool构造函数来从TrueFalse的字符串表示中进行转换:

>>> bool("False")
True

由于字符串“False”不为空,它将被评估为True

这些转换为bool非常重要,因为它们在 Python 的 if 语句和 while 循环中被广泛使用,这些结构接受它们的条件中的bool值。

关系运算符

布尔值通常由 Python 的关系运算符产生,这些运算符可用于比较对象。

两个最常用的关系运算符是 Python 的相等和不相等测试,实际上是测试值的等价或不等价。也就是说,如果一个对象可以用于替代另一个对象,则两个对象是*等价的。我们将在本书的后面学习更多关于对象等价的概念。现在,我们将比较简单的整数。

让我们首先给变量g赋值或绑定一个值:

>>> g = 20

我们使用==来测试相等:

>>> g == 20
True
>>> g == 13
False

或者使用!=进行不等式比较:

>>> g != 20
False
>>> g != 13
True

丰富的比较运算符

我们还可以使用丰富的比较运算符来比较数量的顺序。使用<来确定第一个参数是否小于第二个参数:

>>> g < 30
True

同样,使用>来确定第一个是否大于第二个:

>>> g > 30
False

您可以使用<=来测试小于或等于:

>>> g <= 20
True

大于或等于使用>=

>>> g >= 20
True

如果您对来自其他语言的关系运算符有经验,那么 Python 的运算符可能一点也不令人惊讶。只需记住这些运算符正在比较等价性,而不是身份,这是我们将在接下来的章节中详细介绍的区别。

控制流:if 语句和 while 循环

现在我们已经检查了一些基本的内置类型,让我们看看两个依赖于bool类型转换的重要控制流结构:if 语句和 while 循环。

条件控制流:if 语句

条件语句允许我们根据表达式的值来分支执行。语句的形式是if关键字,后跟一个表达式,以冒号结束以引入一个新的块。让我们在 REPL 中尝试一下:

>>> if True:

记住在块内缩进四个空格,我们添加一些代码,如果条件为True,则执行该代码,然后跟一个空行来终止该块:

...     print("It's true!")
...
It's true!

在这一点上,该块将被执行,因为显然条件是True。相反,如果条件是False,则块中的代码不会执行:

>>> if False:
...     print("It's true!")
...
>>>

bool()构造函数一样,与 if 语句一起使用的表达式将被转换为bool,因此:

>>> if bool("eggs"):
...     print("Yes please!")
...
Yes please!

与以下内容完全等价:

>>> if "eggs":
...     print("Yes please!")
...
Yes please!

由于这种有用的简写,使用bool构造函数进行显式转换为bool在 Python 中很少使用。

if...else

if 语句支持一个可选的else子句,该子句放在由else关键字引入的块中(后跟一个冒号),并且缩进到与if关键字相同的级别。让我们首先创建(但不完成)一个 if 块:

>>> h = 42
>>> if h > 50:
...     print("Greater than 50")

在这种情况下开始else块,我们只需在三个点之后省略缩进:

... else:
...     print("50 or smaller")
...
50 or smaller

if...elif...else

对于多个条件,您可能会尝试做这样的事情:

>>> if h > 50:
...     print("Greater than 50")
... else:
...     if h < 20:
...         print("Less than 20")
...     else:
...         print("Between 20 and 50")
...
Between 20 and 50

每当您发现自己有一个包含嵌套 if 语句的 else 块时,就像这样,您应该考虑使用 Python 的elif关键字,它是一个组合的else-if

在 Python 的禅宗中提醒我们,“平面比嵌套更好”:

>>> if h > 50:
...     print("Greater than 50")
... elif h < 20:
...     print("Less than 20")
... else:
...      print("Between 20 and 50")
...
Between 20 and 50

这个版本读起来更容易。

条件重复:while 循环

Python 有两种类型的循环:for 循环和 while 循环。我们已经在介绍重要的空格时简要遇到了 for 循环,并且很快会回到它们,但现在我们将介绍 while 循环。

在 Python 中,while 循环由while关键字引入,后面跟着一个布尔表达式。与 if 语句的条件一样,表达式被隐式转换为布尔值,就好像它已经传递给了bool()构造函数。while语句以冒号结束,因为它引入了一个新的块。

让我们在 REPL 中编写一个循环,从五倒数到一。我们将初始化一个名为c的计数器变量,循环直到达到零为止。这里的另一个新语言特性是使用增强赋值运算符-=,在每次迭代中从计数器的值中减去一。类似的增强赋值运算符也适用于其他基本数学运算,如加法和乘法:

>>> c = 5
>>> while c != 0:
...     print(c)
...     c -= 1
...
5
4
3
2
1

因为条件 - 或谓词 - 将被隐式转换为bool,就像存在对bool()构造函数的调用一样,我们可以用以下版本替换上面的代码:

>>> c = 5
>>> while c:
...     print(c)
...     c -= 1
...
5
4
3
2
1

这是因为将c的整数值转换为bool的结果为True,直到我们达到零,转换为False。也就是说,在这种情况下使用这种简短形式可能被描述为非 Pythonic,因为根据 Python 的禅宗,显式优于隐式。我们更看重第一种形式的可读性,而不是第二种形式的简洁性。

在 Python 中,while 循环经常用于需要无限循环的情况。我们通过将True作为谓词表达式传递给 while 结构来实现这一点:

>>> while True:
...     print("Looping!")
...
Looping!
Looping!
Looping!
Looping!
Looping!
Looping!
Looping!
Looping!

现在您可能想知道我们如何走出这个循环并重新控制我们的 REPL!只需按Ctrl-C

Looping!
Looping!
Looping!
Looping!
Looping!
Looping!^C
Traceback (most recent call last):
File "<stdin>", line 2, in <module>
KeyboardInterrupt
>>>

Python 拦截按键并引发一个特殊的异常,该异常终止循环。我们将在第六章后面更详细地讨论异常是什么,以及如何使用它们。

使用break退出循环

许多编程语言支持一个循环结构,该结构将谓词测试放在循环的末尾而不是开头。例如,C、C++、C#和 Java 支持 do-while 结构。其他语言也有重复-直到循环。在 Python 中不是这种情况,Python 的习惯用法是使用while True以及通过break语句实现早期退出。

break语句跳出循环 - 如果有多个循环被嵌套,只跳出最内层的循环 - 并在循环体之后立即继续执行。

让我们看一个break的例子,一路上介绍一些其他 Python 特性,并逐行检查它:

>>> while True:
...     response = input()
...     if int(response) % 7 == 0:
...         break
...

我们从while True:开始一个无限循环。在 while 块的第一条语句中,我们使用内置的input()函数从用户那里请求一个字符串。我们将该字符串赋给一个名为response的变量。

现在我们使用 if 语句来测试提供的值是否能被七整除。我们使用int()构造函数将响应字符串转换为整数,然后使用取模运算符%来除以七并给出余数。如果余数等于零,则响应可以被七整除,我们进入 if 块的主体。

在 if 块内,现在有两个缩进级别,我们从八个空格开始并使用break关键字。break终止最内层的循环 - 在本例中是 while 循环 - 并导致执行跳转到循环后的第一条语句。

在这里,“语句”是程序的结尾。我们在三个点的提示符下输入一个空行,以关闭 if 块和 while 块。

我们的循环将开始执行,并将在调用input()时暂停,等待我们输入一个数字。让我们试试几个:

12
67
34
28
>>>

一旦我们输入一个可以被七整除的数字,谓词就变为True,我们进入 if 块,然后我们真正地跳出循环到程序的末尾,返回到 REPL 提示符。

总结

  • 从 Python 开始

  • 获取和安装 Python 3

  • 开始读取-求值-打印循环或 REPL

  • 简单的算术

  • 通过将对象绑定到名称创建变量

  • 使用内置的print()函数打印

  • 使用Ctrl-Z(Windows)或Ctrl-D(Unix)退出 REPL

  • 成为 Pythonic

  • 重要的缩进

  • PEP 8 - Python 代码风格指南

  • PEP 20 - Python 之禅

  • 以各种形式使用 import 语句导入模块

  • 查找和浏览help()

  • 基本类型和控制流

  • intfloatNonebool,以及它们之间的转换

  • 用于相等性和排序测试的关系运算符

  • 带有elseelif块的 if 语句

  • 带有隐式转换为bool的 while 循环

  • 使用Ctrl-C中断无限循环

  • 使用break跳出循环

  • 使用input()从用户那里请求文本

  • 增强赋值运算符

第三章:字符串和集合

Python 包括丰富的内置集合类型,这些类型通常足以满足复杂程序的需求,而无需定义自己的数据结构。我们将概述一些基本的集合类型,足以让我们编写一些有趣的代码,尽管我们将在后面的章节中重新讨论这些集合类型,以及一些额外的类型。

让我们从这些类型开始:

  • str - 不可变的 Unicode 代码点字符串

  • bytes - 不可变的字节字符串

  • list - 可变的对象序列

  • dict - 可变的键值对映射

在这个过程中,我们还将介绍 Python 的 for 循环。

str - 一个不可变的 Unicode 代码点序列

Python 中的字符串具有数据类型str,我们已经广泛地使用了它们。字符串是 Unicode 代码点的序列,大部分情况下你可以将代码点看作字符,尽管它们并不严格等价。Python 字符串中的代码点序列是不可变的,所以一旦你构造了一个字符串,就不能修改它的内容。

字符串引用样式

Python 中的字面字符串由引号括起来:

>>> 'This is a string'

你可以使用单引号,就像我们上面所做的那样。或者你可以使用双引号,就像下面所示的那样:

>>> "This is also a string"

但是,你必须保持一致。例如,你不能使用双引号和单引号配对:

>>> "inconsistent'
  File "<stdin>", line 1
    "inconsistent'
                  ^
SyntaxError: EOL while scanning string literal

支持两种引用样式使你可以轻松地将另一种引号字符合并到字面字符串中,而不必使用丑陋的转义字符技巧:

>>> "It's a good thing."
"It's a good thing."
>>> '"Yes!", he said, "I agree!"'
'"Yes!", he said, "I agree!"'

请注意,REPL 在将字符串回显给我们时利用了相同的引用灵活性。


禅境时刻

乍一看,支持两种引用样式似乎违反了 Python 风格的一个重要原则。来自 Python 之禅:

“应该有一种 - 最好只有一种 - 显而易见的方法来做到这一点。”

然而,在这种情况下,同一来源的另一句格言占据了主导地位:

“……实用性胜过纯粹性,”

支持两种引用样式的实用性比另一种选择更受重视:单一引用样式与更频繁使用丑陋的转义序列的结合,我们很快就会遇到。


相邻字符串的连接

Python 编译器将相邻的字面字符串连接成一个字符串:

>>> "first" "second"
'firstsecond'

虽然乍一看这似乎毫无意义,但正如我们将在后面看到的那样,它可以用于很好地格式化代码。

多行字符串和换行符

如果要创建包含换行符的字面字符串,有两种选择:使用多行字符串或使用转义序列。首先,让我们看看多行字符串。

多行字符串由三个引号字符而不是一个来界定。下面是一个使用三个双引号的例子:

>>> """This is
... a multiline
... string"""
'This is\na multiline\nstring'

请注意,当字符串被回显时,换行符由\n转义序列表示。

我们也可以使用三个单引号:

>>> '''So
... is
... this.'''
'So\nis\nthis.'

作为使用多行引用的替代方案,我们可以自己嵌入控制字符:

>>> m = 'This string\nspans mutiple\nlines'
>>> m
'This string\nspans mutiple\nlines'

为了更好地理解我们在这种情况下所表示的内容,我们可以使用内置的print()函数来查看字符串:

>>> print(m)
This string
spans mutiple
lines

如果你在 Windows 上工作,你可能会认为换行应该由回车换行对\r\n表示,而不仅仅是换行字符\n。在 Python 中不需要这样做,因为 Python 3 具有一个称为通用换行符支持的功能,它可以将简单的\n转换为你的平台上的本机换行序列。你可以在PEP 278中了解更多关于通用换行符支持的信息。

我们也可以使用转义序列进行其他用途,比如用\t来插入制表符,或者在字符串中使用\"来使用引号字符:

>>> "This is a \" in a string"
'This is a " in a string'

或者反过来:

>>> 'This is a \' in a string'
"This is a ' in a string"

正如您所看到的,Python 比我们更聪明地使用了最方便的引号分隔符,尽管当我们在字符串中使用两种类型的引号时,Python 也会使用转义序列:

>>> 'This is a \" and a \' in a string'
'This is a " and a \' in a string'

因为反斜杠具有特殊含义,所以要在字符串中放置一个反斜杠,我们必须用反斜杠本身来转义反斜杠:

>>> k = 'A \\ in a string'
'A \\ in a string'

为了让自己确信该字符串中确实只有一个反斜杠,我们可以使用print()来打印它:

>>> print(k)
A \ in a string

您可以在Python 文档中阅读更多关于转义序列的信息。

原始字符串

有时,特别是在处理诸如 Windows 文件系统路径或大量使用反斜杠的正则表达式模式^(2)时,要求双重反斜杠可能会很丑陋和容易出错。Python 通过原始字符串来解决这个问题。原始字符串不支持任何转义序列,非常直观。要创建原始字符串,请在开头引号前加上小写的r

>>> path = r'C:\Users\Merlin\Documents\Spells'
>>>
>>> path
'C:\\Users\\Merlin\\Documents\\Spells'
>>> print(path)
C:\Users\Merlin\Documents\Spells

str构造函数

我们可以使用str构造函数来创建其他类型的字符串表示,比如整数:

>>> str(496)
>>> '496'

或浮点数:

>>> str(6.02e23)
'6.02e+23'

字符串作为序列

Python 中的字符串是所谓的序列类型,这意味着它们支持查询有序元素序列的某些常见操作。例如,我们可以使用方括号和基于零的整数索引来访问单个字符:

>>> s = 'parrot'
>>> s[4]
'o'

与许多其他编程语言相比,Python 没有与字符串类型不同的单独的字符类型。索引操作返回一个包含单个代码点元素的完整字符串,这一点我们可以使用 Python 的内置type()函数来证明:

>>> type(s[4])
<class 'str'>

我们将在本书的后面更详细地讨论类型和类。

字符串方法

字符串对象还支持作为方法实现的各种操作。我们可以使用help()来列出字符串类型的方法:

>>> help(str)

当您按下回车时,您应该看到这样的显示:

Help on class str in module builtins:

class str(object)
 |  str(object='') -> str
 |  str(bytes_or_buffer[, encoding[, errors]]) -> str
 |
 |  Create a new string object from the given object. If encoding or
 |  errors is specified, then the object must expose a data buffer
 |  that will be decoded using the given encoding and error handler.
 |  Otherwise, returns the result of object.__str__() (if defined)
 |  or repr(object).
 |  encoding defaults to sys.getdefaultencoding().
 |  errors defaults to 'strict'.
 |
 |  Methods defined here:
 |
 |  __add__(self, value, /)
 |      Return self+value.
 |
 |  __contains__(self, key, /)
 |      Return key in self.
 |
 |  __eq__(self, value, /)
:

在任何平台上,您可以通过按空格键以每次前进一页的方式浏览帮助页面,直到看到capitalize()方法的文档,跳过所有以双下划线开头和结尾的方法:

 |      Create and return a new object.  See help(type) for accurate signature.
 |
 |  __repr__(self, /)
 |      Return repr(self).
 |
 |  __rmod__(self, value, /)
 |      Return value%self.
 |
 |  __rmul__(self, value, /)
 |      Return self*value.
 |
 |  __sizeof__(...)
 |      S.__sizeof__() -> size of S in memory, in bytes
 |
 |  __str__(self, /)
 |      Return str(self).
 |
 |  capitalize(...)
 |      S.capitalize() -> str
 |
 |      Return a capitalized version of S, i.e. make the first character
 |      have upper case and the rest lower case.
 |
:

按下'q'退出帮助浏览器,然后我们将尝试使用capitalize()。让我们创建一个值得大写的字符串 - 一个首都的名字!

>>> c = "oslo"

在 Python 中调用对象的方法时,我们在对象名称之后和方法名称之前使用点。方法是函数,所以我们必须使用括号来指示应该调用方法。

>>> c.capitalize()
'Oslo'

请记住,字符串是不可变的,所以capitalize()方法没有直接修改c。相反,它返回了一个新的字符串。我们可以通过显示c来验证这一点,它保持不变:

>>> c
'oslo'

您可能想花点时间熟悉一下字符串类型提供的各种有用方法,可以通过浏览帮助来了解。

带 Unicode 的字符串

字符串完全支持 Unicode,因此您可以轻松地在国际字符中使用它们,甚至在文字中,因为 Python 3 的默认源代码编码是 UTF-8。例如,如果您可以访问挪威字符,您可以简单地输入这个:

>>> "Vi er så glad for å høre og lære om Python!"
'Vi er så glad for å høre og lære om Python!'

或浮点数:

>>> "Vi er s\u00e5 glad for \u00e5 h\xf8re og l\u00e6re om Python!"
'Vi er så glad for å høre og lære om Python!'

不过,我们相信这有点不太方便。

同样,您可以使用\x转义序列,后跟一个 2 字符的十六进制字符串,以在字符串文字中包含一个字节的 Unicode 代码点:

>>> '\xe5'
'å'

您甚至可以使用一个转义的八进制字符串,使用一个反斜杠后跟三个零到七之间的数字,尽管我们承认我们从未见过这种用法,除非无意中作为错误:

>>> '\345'
'å'

在否则类似的bytes类型中没有这样的 Unicode 功能,我们将在下一节中介绍。

bytes - 一个不可变的字节序列

bytes类型类似于str类型,不同之处在于每个实例不是 Unicode 代码点的序列,而是字节的序列。因此,bytes对象用于原始二进制数据和固定宽度的单字节字符编码,如 ASCII。

文字bytes

与字符串一样,它们有一个简单的文字形式,由单引号或双引号分隔,尽管对于文字bytes,开头引号必须由小写b前缀:

>>> b'data'
b'data'
>>> b"data"
b'data'

还有一个bytes构造函数,但它有相当复杂的行为,我们将在本系列的第二本书The Python Journeyman中进行介绍。在我们的旅程中的这一点上,我们只需要认识到bytes文字并理解它们支持与str相同的许多操作,如索引和分割:

>>> d = b'some bytes'
>>> d.split()
[b'some', b'bytes']

您会看到split()方法返回一个bytes对象的list

bytesstr之间转换

要在bytesstr之间转换,我们必须知道用于表示字符串的 Unicode 代码点的字节序列的编码。Python 支持各种所谓的codecs,如 UTF-8、UTF-16、ASCII、Latin-1、Windows-1251 等等-请参阅 Python 文档以获取当前 codecs 列表

在 Python 中,我们可以将 Unicodestr编码bytes对象,反之亦然,我们可以将bytes对象解码为 Unicodestr。在任何方向上,我们都必须指定编码。Python 不会-通常也不能-阻止您使用 CP037 编解码bytes对象中存储的 UTF-16 数据,例如处理旧 IBM 主机上的字符串。如果你幸运的话,解码将在运行时失败并显示UnicodeError;如果你不幸的话,你将得到一个充满垃圾的str,这将不会被你的程序检测到。

编码和解码字符串。

编码和解码字符串。

让我们开始一个交互式会话,查看字符串,其中包含 29 个字母的挪威字母表-一个全字母句:

>>> norsk = "Jeg begynte å fortære en sandwich mens jeg kjørte taxi på vei til quiz"

我们现在将使用 UTF-8 编解码器将其编码为bytes对象,使用str对象的encode()方法:

>>> data = norsk.encode('utf-8')
>>> data
b'Jeg begynte \xc3\xa5 fort\xc3\xa6re en sandwich mens jeg kj\xc3\xb8rte taxi p\xc3\x\
a5 vei til quiz'

看看每个挪威字母是如何被渲染为一对字节的。

我们可以使用bytes对象的decode()方法来反转这个过程。同样,我们必须提供正确的编码:

>>> norwegian = data.decode('utf-8')

我们可以检查编码/解码往返是否给我们带来了与我们开始时相等的结果:

>>> norwegian == norsk
True

并显示它以好的方式:

>>> norwegian
'Jeg begynte å fortære en sandwich mens jeg kjørte taxi på vei til quiz'

在这个时刻,所有这些与编码有关的操作可能看起来像是不必要的细节-特别是如果您在一个英语环境中操作-但这是至关重要的,因为文件和网络资源(如 HTTP 响应)是作为字节流传输的,而我们更喜欢使用 Unicode 字符串的便利。

list-对象的序列

Python list,例如字符串split()方法返回的那些,是对象的序列。与字符串不同,list是可变的,因为其中的元素可以被替换或移除,并且可以插入或追加新元素。list是 Python 数据结构的工作马。

文字列表由方括号分隔,并且list中的项目由逗号分隔。这是一个包含三个数字的list

>>> [1, 9, 8]
[1, 9, 8]

这是一个包含三个字符串的list

>>> a = ["apple", "orange", "pear"]

我们可以使用零为基础的索引用方括号检索元素:

>>> a[1]
"orange"

我们可以通过分配给特定元素来替换元素:

>>> a[1] = 7
>>> a
['apple', 7, 'pear']

看看list在包含的对象的类型方面可以是异构的。我们现在有一个包含strint和另一个strlist

创建一个空列表通常是有用的,我们可以使用空方括号来做到这一点:

>>> b = []

我们可以以其他方式修改list。让我们使用append()方法在list的末尾添加一些float

>>> b.append(1.618)
>>> b
[1.618]
>>> b.append(1.414)
[1.618, 1.414]

有许多其他有用的方法可以操作list,我们将在后面的章节中介绍。现在,我们只需要能够执行基本的list操作。

还有一个list构造函数,可以用来从其他集合(如字符串)创建列表:

>>> list("characters")
['c', 'h', 'a', 'r', 'a', 'c', 't', 'e', 'r', 's']

尽管 Python 中的显著空格规则起初似乎非常严格,但实际上有很大的灵活性。例如,如果一行末尾有未关闭的括号、大括号或括号,可以继续到下一行。这对于表示长的字面集合或甚至改善短集合的可读性非常有用:

>>> c = ['bear',
...      'giraffe',
...      'elephant',
...      'caterpillar',]
>>> c
['bear', 'giraffe', 'elephant', 'caterpillar']

还要注意,我们可以在最后一个元素后使用额外的逗号,这是一个方便的功能,可以提高代码的可维护性。

dict - 将键与值关联起来

字典 - 体现在dict类型中 - 对 Python 语言的工作方式非常基本,并且被广泛使用。字典将键映射到值,在某些语言中被称为映射或关联数组。让我们看看如何在 Python 中创建和使用字典。

使用花括号创建字面上的字典,其中包含键值对。每对由逗号分隔,每个键与其对应的值由冒号分隔。在这里,我们使用字典创建一个简单的电话目录:

>>> d = {'alice': '878-8728-922', 'bob': '256-5262-124', 'eve': '198-2321-787'}

我们可以使用方括号运算符按键检索项目:

>>> d['alice']
'878-8728-922'

我们可以通过方括号进行赋值来更新与特定键关联的值:

>>> d['alice'] = '966-4532-6272'
>>> d
{'bob': '256-5262-124', 'eve': '198-2321-787', 'alice': '966-4532-6272'}

如果我们为尚未添加的键赋值,将创建一个新条目:

>>> d['charles'] = '334-5551-913'
>>> d
{'bob': '256-5262-124', 'eve': '198-2321-787',
'charles': '334-5551-913', 'alice': '966-4532-6272'}

请注意,字典中的条目不能依赖于以任何特定顺序存储,并且实际上 Python 选择的顺序甚至可能在同一程序的多次运行之间发生变化。

与列表类似,可以使用空的花括号创建空字典:

>>> e = {}

这只是对字典的一个非常粗略的介绍,但我们将在第五章中更详细地重新讨论它们。

for 循环 - 迭代一系列项目

现在我们有了制作一些有趣的数据结构的工具,我们将看看 Python 的另一种循环结构,即 for 循环。在 Python 中,for 循环对应于许多其他编程语言中称为 for-each 循环的东西。它们从集合中逐个请求项目 - 或更严格地说是从可迭代系列中(但稍后会详细介绍) - 并将它们依次分配给我们指定的变量。让我们创建一个list集合,并使用 for 循环对其进行迭代,记得将 for 循环内的代码缩进四个空格:

>>> cities = ["London", "New York", "Paris", "Oslo", "Helsinki"]
>>> for city in cities:
...     print(city)
...
London
New York
Paris
Oslo
Helsinki

因此,对list进行迭代会逐个返回项目。如果对字典进行迭代,你会得到看似随机顺序的键,然后可以在 for 循环体内使用这些键来检索相应的值。让我们定义一个字典,将颜色名称字符串映射到存储为整数的十六进制整数颜色代码:

>>> colors = {'crimson': 0xdc143c, 'coral': 0xff7f50, 'teal': 0x008080}
>>> for color in colors:
...    print(color, colors[color])
...
coral 16744272
crimson 14423100
teal 32896

在这里,我们使用内置的print()函数接受多个参数的能力,分别传递每种颜色的键和值。还要注意返回给我们的颜色代码是十进制的。

现在,在我们将学到的一些东西整合到一个有用的程序中之前,练习使用Ctrl-Z(Windows)或Ctrl-D(Mac 或 Linux)退出 Python REPL。

把所有东西放在一起

让我们稍微偏离一下,尝试一下我们在稍大的示例中介绍的一些工具。教科书通常避免这种实用主义,特别是在早期章节,但我们认为将新的想法应用到实际情况中是有趣的。为了避免走样,我们需要引入一些“黑匣子”组件来完成工作,但你以后会详细了解它们,所以不用担心。

我们将在 REPL 中编写一个更长的片段,并简要介绍with语句。我们的代码将使用 Python 标准库函数urlopen()从网络中获取一些经典文学的文本数据。以下是在 REPL 中输入的完整代码。我们已经用行号注释了这段代码片段,以便参考解释中的行:

1 >>> from urllib.request import urlopen
2 >>> with urlopen('http://sixty-north.com/c/t.txt') as story:
3 ...     story_words = []
4 ...     for line in story:
5 ...         line_words = line.split()
6 ...         for word in line_words:
7 ...             story_words.append(word)
8 ...

我们将逐行解释这段代码,依次解释每一行。

  1. 要访问urlopen(),我们需要从request模块中导入该函数,该模块本身位于标准库urllib包中。

  2. 我们将使用 URL 调用urlopen()来获取故事文本。我们使用一个称为 with 块的 Python 构造来管理从 URL 获取的资源,因为从网络获取资源需要操作系统套接字等。我们将在后面的章节中更多地讨论with语句,但现在知道,对于使用外部资源的对象使用with语句是良好的做法,以避免所谓的资源泄漏with语句调用urlopen()函数,并将响应对象绑定到名为story的变量。

  3. 请注意,with语句以冒号结尾,引入了一个新的代码块,因此在代码块内部我们必须缩进四个空格。我们创建一个空的list,最终将保存从检索到的文本中提取出的所有单词。

  4. 我们打开一个 for 循环,它将遍历整个故事。请记住,for 循环会从in关键字右侧的表达式(在本例中是story)逐个请求项目,并依次将它们分配给左侧的名称(在本例中是line)。碰巧,由story引用的 HTTP 响应对象类型以这种方式迭代时会从响应主体中产生连续的文本行,因此 for 循环会逐行从故事中检索文本。for语句也以冒号结尾,因为它引入了 for 循环的主体,这是一个新的代码块,因此需要进一步缩进。

  5. 对于每一行文本,我们使用split()方法将其按空白边界分割成单词,得到一个我们称为line_words的单词列表。

  6. 现在我们使用嵌套在第一个循环内部的第二个 for 循环来遍历这个单词列表。

  7. 我们依次将每个单词append()到累积的story_words列表中。

  8. 最后,在三个点的提示下输入一个空行,以关闭所有打开的代码块——在本例中,内部 for 循环、外部 for 循环和 with 块都将被终止。代码块将被执行,稍后,Python 现在将我们返回到常规的三角形提示符。此时,如果 Python 给出错误,比如SyntaxErrorIndentationError,您应该回去,检查您输入的内容,并仔细重新输入代码,直到 Python 接受整个代码块而不抱怨。如果出现HTTPError,则表示无法通过互联网获取资源,您应该检查您的网络连接或稍后重试,尽管值得检查您是否正确输入了 URL。

我们可以通过要求 Python 评估story_words的值来查看我们收集到的单词:

>>> story_words
[b'It', b'was', b'the', b'best', b'of', b'times', b'it', b'was', b'the',
b'worst', b'of', b'times',b'it', b'was', b'the', b'age', b'of', b'wisdom',
b'it', b'was', b'the', b'age', b'of', b'foolishness', b'it', b'was',
b'the', b'epoch', b'of', b'belief', b'it', b'was', b'the', b'epoch', b'of',
b'incredulity', b'it', b'was', b'the', b'season', b'of', b'Light', b'it',
b'was', b'the', b'season', b'of', b'Darkness', b'it', b'was', b'the',
b'spring', b'of', b'hope', b'it', b'was', b'the', b'winter', b'of',
b'despair', b'we', b'had', b'everything', b'before', b'us', b'we', b'had',
b'nothing', b'before', b'us', b'we', b'were', b'all', b'going', b'direct',
b'to', b'Heaven', b'we', b'were', b'all', b'going', b'direct', b'the',
b'other', b'way', b'in', b'short', b'the', b'period', b'was', b'so', b'far',
b'like', b'the', b'present', b'period', b'that', b'some', b'of', b'its',
b'noisiest', b'authorities', b'insisted', b'on', b'its', b'being',
b'received', b'for', b'good', b'or', b'for', b'evil', b'in', b'the',
b'superlative', b'degree', b'of', b'comparison', b'only']

在 REPL 中进行这种探索性编程对于 Python 来说非常常见,因为它允许我们在决定使用它们之前弄清楚代码的各个部分。在这种情况下,请注意每个用单引号引起来的单词前面都有一个小写字母b,这意味着我们有一个bytes对象的列表,而我们更希望有一个str对象的列表。这是因为 HTTP 请求通过网络向我们传输了原始字节。要获得一个字符串列表,我们应该将每行中的字节流从 UTF-8 解码为 Unicode 字符串。我们可以通过插入decode()方法的调用来做到这一点,然后对生成的 Unicode 字符串进行操作。Python REPL 支持一个简单的命令历史记录,通过仔细使用上下箭头键,我们可以重新输入我们的片段,尽管没有必要重新导入urlopen,所以我们可以跳过第一行:

1 >>> with urlopen('http://sixty-north.com/c/t.txt') as story:
2 ...     story_words = []
3 ...     for line in story:
4 ...         line_words = line.decode('utf-8').split()
5 ...         for word in line_words:
6 ...             story_words.append(word)
7 ...

这里我们改变了第四行 - 当你到达命令历史的那部分时,你可以使用左右箭头键编辑它,插入对decode()的必要调用。当我们重新运行这个块并重新查看story_words时,我们应该看到我们有一个字符串列表:

>>> story_words
['It', 'was', 'the', 'best', 'of', 'times', 'it',
'was', 'the', 'worst', 'of', 'times', 'it', 'was', 'the', 'age', 'of',
'wisdom', 'it', 'was', 'the', 'age', 'of', 'foolishness', 'it', 'was',
'the', 'epoch', 'of', 'belief', 'it', 'was', 'the', 'epoch', 'of',
'incredulity', 'it', 'was', 'the', 'season', 'of', 'Light', 'it',
'was', 'the', 'season', 'of', 'Darkness', 'it', 'was', 'the',
'spring', 'of', 'hope', 'it', 'was', 'the', 'winter', 'of', 'despair',
'we', 'had', 'everything', 'before', 'us', 'we', 'had', 'nothing',
'before', 'us', 'we', 'were', 'all', 'going', 'direct', 'to',
'Heaven', 'we', 'were', 'all', 'going', 'direct', 'the', 'other',
'way', 'in', 'short', 'the', 'period', 'was', 'so', 'far', 'like',
'the', 'present', 'period', 'that', 'some', 'of', 'its', 'noisiest',
'authorities', 'insisted', 'on', 'its', 'being', 'received', 'for',
'good', 'or', 'for', 'evil', 'in', 'the', 'superlative', 'degree',
'of', 'comparison', 'only']

我们几乎达到了在 Python REPL 中舒适输入和修改的极限,所以在下一章中,我们将看看如何将这段代码移到一个文件中,在那里可以更容易地在文本编辑器中处理。

总结

  • str Unicode 字符串和bytes字符串:

  • 我们看了看引号的各种形式(单引号或双引号)来引用字符串,这对于将引号本身合并到字符串中非常有用。Python 在你使用哪种引号风格上很灵活,但在界定特定字符串时必须保持一致。

  • 我们演示了所谓的三重引号,由三个连续的引号字符组成,可以用来界定多行字符串。传统上,每个引号字符本身都是双引号,尽管也可以使用单引号。

  • 我们看到相邻的字符串文字会被隐式连接。

  • Python 支持通用换行符,所以无论你使用什么平台,只需使用一个\n字符,就可以放心地知道它将在 I/O 期间被适当地从本机换行符转换和转换。

  • 转义序列提供了将换行符和其他控制字符合并到文字字符串中的另一种方法。

  • 用于转义的反斜杠可能会对 Windows 文件系统路径或正则表达式造成阻碍,因此可以使用带有r前缀的原始字符串来抑制转义机制。

  • 其他类型,比如整数,可以使用str()构造函数转换为字符串。

  • 可以使用带有整数从零开始的索引的方括号检索单个字符,返回一个字符字符串。

  • 字符串支持丰富多样的操作,比如通过它们的方法进行分割。

  • 在 Python 3 中,文字字符串可以直接包含任何 Unicode 字符,这在源代码中默认解释为 UTF-8。

  • bytes类型具有许多字符串的功能,但它是字节序列而不是 Unicode 代码点序列。

  • bytes文字以小写的b为前缀。

  • 要在字符串和字节实例之间转换,我们使用strencode()方法或bytesdecode()方法,在这两种情况下都要传递编解码器的名称,这是我们必须事先知道的。

  • 列表

  • 列表是可变的、异构的对象序列。

  • 列表文字由方括号界定,项目之间用逗号分隔。

  • 可以通过使用包含从零开始的整数索引的方括号从列表中检索单个元素。

  • 与字符串相反,可以通过对索引项赋值来替换单个列表元素。

  • 列表可以通过append()来扩展,也可以使用list()构造函数从其他序列构造。

  • dict

  • 字典将键与值关联起来。

  • 字面上的字典由花括号括起来。键值对之间用逗号分隔,每个键与其相应的值用冒号关联。

  • for 循环

  • For 循环逐个从可迭代对象(如 list)中取出项目,并将相同的名称绑定到当前项目。

  • 它们对应于其他语言中称为 for-each 循环的内容。

第四章:模块化

模块化对于除了微不足道的软件系统以外的任何东西都是一个重要的属性,因为它赋予我们能力去创建自包含、可重复使用的部分,这些部分可以以新的方式组合来解决不同的问题。在 Python 中,与大多数编程语言一样,最细粒度的模块化设施是可重复使用函数的定义。但是 Python 还给了我们几种其他强大的模块化机制。

相关函数的集合本身被组合在一起形成了一种称为模块的模块化形式。模块是可以被其他模块引用的源代码文件,允许在一个模块中定义的函数在另一个模块中被重用。只要你小心避免任何循环依赖,模块是组织程序的一种简单灵活的方式。

在之前的章节中,我们已经看到我们可以将模块导入 REPL。我们还将向您展示模块如何直接作为程序或脚本执行。作为这一部分的一部分,我们将调查 Python 执行模型,以确保您对代码何时被评估和执行有一个很好的理解。我们将通过向您展示如何使用命令行参数将基本配置数据传递到您的程序中并使您的程序可执行来结束本章。

为了说明本章,我们将从上一章末尾开发的从网络托管的文本文档中检索单词的代码片段开始。我们将通过将代码组织成一个完整的 Python 模块来详细说明该代码。

在一个.py 文件中组织代码

让我们从第二章中我们使用的代码片段开始。打开一个文本编辑器 - 最好是一个支持 Python 语法高亮的编辑器 - 并配置它在按下 tab 键时插入四个空格的缩进级别。你还应该检查你的编辑器是否使用 UTF 8 编码保存文件,因为这是 Python 3 运行时的默认设置。

在你的主目录下创建一个名为pyfund的目录。这是我们将放置本章代码的地方。

所有的 Python 源文件都使用.py扩展名,所以让我们把我们在 REPL 中写的片段放到一个名为pyfund/words.py的文本文件中。文件的内容应该是这样的:

from urllib.request import urlopen

with urlopen('http://sixty-north.com/c/t.txt') as story:
    story_words = []
    for line in story:
        line_words = line.decode('utf-8').split()
        for word in line_words:
            story_words.append(word)

你会注意到上面的代码和我们之前在 REPL 中写的代码之间有一些细微的差异。现在我们正在使用一个文本文件来编写我们的代码,所以我们可以更加注意可读性,例如,在import语句后我们加了一个空行。

在继续之前保存这个文件。

从操作系统 shell 运行 Python 程序

切换到带有操作系统 shell 提示符的控制台,并切换到新的pyfund目录:

$ cd pyfund

我们可以通过调用 Python 并传递模块的文件名来执行我们的模块:

$ python3 words.py

在 Mac 或 Linux 上,或者:

> python words.py

在 Windows 上。

当你按下回车键后,经过短暂的延迟,你将返回到系统提示符。并不是很令人印象深刻,但如果你没有得到任何响应,那么程序正在按预期运行。另一方面,如果你看到一些错误,那么就有问题了。例如,HTTPError表示有网络问题,而其他类型的错误可能意味着你输入了错误的代码。

让我们在程序的末尾再添加一个 for 循环,每行打印一个单词。将这段代码添加到你的 Python 文件的末尾:

for word in story_words:
    print(word)

如果你去命令提示符并再次执行代码,你应该会看到一些输出。现在我们有了一个有用程序的开端!

将模块导入到 REPL 中

我们的模块也可以导入到 REPL 中。让我们试试看会发生什么。启动 REPL 并导入你的模块。当导入一个模块时,你使用import <module-name>,省略模块名称的.py扩展名。在我们的情况下,看起来是这样的:

$ python
Python 3.5.0 (default, Nov  3 2015, 13:17:02)
[GCC 4.2.1 Compatible Apple LLVM 6.1.0 (clang-602.0.53)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>> import words
It
was
the
best
of
times
. . .

当导入模块时,模块中的代码会立即执行!这也许不是你期望的,而且肯定不是很有用。为了更好地控制代码的执行时间,并允许其被重用,我们需要将代码放入一个函数中。

定义函数

使用def关键字定义函数,后面跟着函数名、括号中的参数列表和一个冒号来开始一个新的块。让我们在 REPL 中快速定义一些函数来了解一下:

>>> def square(x):
...     return x * x
...

我们使用return关键字从函数中返回一个值。

正如我们之前所看到的,我们通过在函数名后的括号中提供实际参数来调用函数:

>>> square(5)
5

函数并不需要显式返回一个值 - 也许它们会产生副作用:

>>> def launch_missiles():
...     print("Missiles launched!")
...
>>> launch_missiles()
Missiles launched!

您可以使用return关键字而不带参数来提前从函数中返回:

>>> def even_or_odd(n):
...     if n % 2 == 0:
...         print("even")
...         return
...     print("odd")
...
>>> even_or_odd(4)
even
>>> even_or_odd(5)
odd

如果函数中没有显式的return,Python 会在函数末尾隐式添加一个return。这个隐式的返回,或者没有参数的return,实际上会导致函数返回None。不过要记住,REPL 不会显示None结果,所以我们看不到它们。通过将返回的对象捕获到一个命名变量中,我们可以测试是否为None

>>> w = even_or_odd(31)
odd
>>> w is None
True

将我们的模块组织成函数

让我们使用函数来组织我们的 words 模块。

首先,我们将除了导入语句之外的所有代码移动到一个名为fetch_words()的函数中。您可以通过添加def语句并将其下面的代码缩进一级来实现这一点:

from urllib.request import urlopen

def fetch_words():
    with urlopen('http://sixty-north.com/c/t.txt') as story:
        story_words = []
        for line in story:
            line_words = line.decode('utf-8').split()
            for word in line_words:
                story_words.append(word)

    for word in story_words:
        print(word)

保存模块,并使用新的 Python REPL 重新加载模块:

$ python3
Python 3.5.0 (default, Nov  3 2015, 13:17:02)
[GCC 4.2.1 Compatible Apple LLVM 6.1.0 (clang-602.0.53)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>> import words

模块已导入,但直到我们调用fetch_words()函数时,单词才会被获取:

>>> words.fetch_words()
It
was
the
best
of
times

或者我们可以导入我们的特定函数:

>>> from words import fetch_words
>>> fetch_words()
It
was
the
best
of
times

到目前为止一切都很好,但当我们尝试直接从操作系统 shell 运行我们的模块时会发生什么?

从 Mac 或 Linux 使用Ctrl-D退出 REPL,或者从 Windows 使用Ctrl-Z,然后运行 Python 3 并传递模块文件名:

$ python3 words.py

没有单词被打印。这是因为现在模块所做的只是定义一个函数,然后立即退出。为了创建一个我们可以有用地从中导入函数到 REPL 并且可以作为脚本运行的模块,我们需要学习一个新的 Python 习惯用法。

__name__和从命令行执行模块

Python 运行时系统定义了一些特殊变量和属性,它们的名称由双下划线分隔。其中一个特殊变量叫做__name__,它为我们的模块提供了一种方式来确定它是作为脚本运行还是被导入到另一个模块或 REPL 中。要查看如何操作,请添加:

print(__name__)

fetch_words()函数之外的模块末尾添加。

首先,让我们将修改后的 words 模块重新导入到 REPL 中:

$ python3
Python 3.5.0 (default, Nov  3 2015, 13:17:02)
[GCC 4.2.1 Compatible Apple LLVM 6.1.0 (clang-602.0.53)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>> import words
words

我们可以看到,当导入__name__时,它确实会评估为模块的名称。

顺便说一句,如果再次导入模块,print 语句将不会被执行;模块代码只在第一次导入时执行一次:

>>> import words
>>>

现在让我们尝试将模块作为脚本运行:

$ python3 words.py
__main__

在这种情况下,特殊的__name__变量等于字符串“main”,也由双下划线分隔。我们的模块可以利用这种行为来检测它的使用方式。我们用一个 if 语句替换 print 语句,该语句测试__name__的值。如果值等于“main”,那么我们的函数就会被执行:

if __name__ == '__main__':
    fetch_words()

现在我们可以安全地导入我们的模块,而不会过度执行我们的函数:

$ python3
>>> import words
>>>

我们可以有用地将我们的函数作为脚本运行:

$ python3 words.py
It
was
the
best
of
times

Python 执行模型

为了在 Python 中有一个真正坚实的基础,了解 Python 的执行模型是很重要的。我们指的是定义模块导入和执行期间发生的函数定义和其他重要事件的规则。为了帮助你发展这种理解,我们将专注于def关键字,因为你已经熟悉它。一旦你了解了 Python 如何处理def,你就会知道大部分关于 Python 执行模型的知识。

重要的是要理解这一点:def不仅仅是一个声明,它是一个语句。这意味着def实际上是在运行时执行的,与其他顶层模块范围代码一起。def的作用是将函数体中的代码绑定到def后面的名称。当模块被导入或运行时,所有顶层语句都会运行,这是模块命名空间中的函数定义的方式。

重申一下,def是在运行时执行的。这与许多其他语言中处理函数定义的方式非常不同,特别是在编译语言如 C++、Java 和 C#中。在这些语言中,函数定义是由编译器在编译时处理的,而不是在运行时。^(4)实际执行程序时,这些函数定义已经固定。在 Python 中没有编译器^(5),函数在执行之前并不存在任何形式,除了源代码。事实上,由于函数只有在导入时处理其def时才被定义,因此在从未导入的模块中的函数将永远不会被定义。

理解 Python 函数定义的这种动态特性对于后面本书中的重要概念至关重要,所以确保你对此感到舒适。如果你有 Python 调试器,比如在 IDE 中,你可以花一些时间逐步执行你的words.py模块。

模块、脚本和程序之间的区别

有时我们会被问及 Python 模块、Python 脚本和 Python 程序之间的区别。任何.py文件都构成一个 Python 模块,但正如我们所见,模块可以被编写为方便导入、方便执行,或者使用if __name__ == "__main__"的习惯用法,两者兼而有之。

我们强烈建议即使是简单的脚本也要可导入,因为如果可以从 Python REPL 访问代码,这样可以极大地简化开发和测试。同样,即使是只在生产环境中导入的模块也会受益于具有可执行的测试代码。因此,我们创建的几乎所有模块都采用了定义一个或多个可导入函数的形式,并附有后缀以便执行。

将模块视为 Python 脚本或 Python 程序取决于上下文和用法。将 Python 仅视为脚本工具是错误的,因为许多大型复杂的应用程序都是专门使用 Python 构建的,而不是像 Windows 批处理文件或 Unix shell 脚本那样。

设置带有命令行参数的主函数

让我们进一步完善我们的单词获取模块。首先,我们将进行一些小的重构,将单词检索和收集与单词打印分开:

from urllib.request import urlopen

# This fetches the words and returns them as a list.
def fetch_words():
    with urlopen('http://sixty-north.com/c/t.txt') as story:
        story_words = []
        for line in story:
            line_words = line.decode('utf-8').split()
            for word in line_words:
                story_words.append(word)
    return story_words

# This prints a list of words
def print_words(story_words):
    for word in story_words:
      print(word)

if __name__ == '__main__':
    words = fetch_words()
    print_words(words)

我们这样做是因为它分离了两个重要的关注点:在导入时,我们宁愿得到单词列表,但在直接运行时,我们更希望单词被打印出来。

接下来,我们将从if __name__ == '__main__'块中提取代码到一个名为main()的函数中:

def main():
    words = fetch_words()
    print_words(words)

if __name__ == '__main__':
    main()

通过将这段代码移到一个函数中,我们可以在 REPL 中测试它,而在模块范围的 if 块中是不可能的。

现在我们可以在 REPL 中尝试这些函数:

>>> from words import (fetch_words, print_words)
>>> print_words(fetch_words())

我们利用这个机会介绍了import语句的一些新形式。第一种新形式使用逗号分隔的列表从模块中导入多个对象。括号是可选的,但如果列表很长,它们可以允许您将此列表分成多行。这种形式可能是最广泛使用的import语句的形式之一。

第二种新形式使用星号通配符从模块中导入所有内容:

>>> from words import *

后一种形式仅建议在 REPL 上进行临时使用。它可能会在程序中造成严重破坏,因为导入的内容现在可能超出您的控制范围,从而在将来可能导致潜在的命名空间冲突。

完成这些后,我们可以从 URL 获取单词:

>>> fetch_words()
['It', 'was', 'the', 'best', 'of', 'times', 'it', 'was', 'the', 'worst',
'of', 'times', 'it', 'was', 'the', 'age', 'of', 'wisdom', 'it', 'was',
'the', 'age', 'of', 'foolishness', 'it', 'was', 'the', 'epoch', 'of',
'belief', 'it', 'was', 'the', 'epoch', 'of', 'incredulity', 'it', 'was',
'the', 'season', 'of', 'Light', 'it', 'was', 'the', 'season', 'of',
'Darkness', 'it', 'was', 'the', 'spring', 'of', 'hope', 'it', 'was', 'the',
'winter', 'of', 'despair', 'we', 'had', 'everything', 'before', 'us', 'we',
'had', 'nothing', 'before', 'us', 'we', 'were', 'all', 'going', 'direct',
'to', 'Heaven', 'we', 'were', 'all', 'going', 'direct', 'the', 'other',
'way', 'in', 'short', 'the', 'period', 'was', 'so', 'far', 'like', 'the',
'present', 'period', 'that', 'some', 'of', 'its', 'noisiest', 'authorities',
'insisted', 'on', 'its', 'being', 'received', 'for', 'good', 'or', 'for',
'evil', 'in', 'the', 'superlative', 'degree', 'of', 'comparison', 'only']

由于我们已将获取代码与打印代码分开,因此我们还可以打印任何单词列表:

>>> print_words(['Any', 'list', 'of', 'words'])
Any
list
of
words

事实上,我们甚至可以运行主程序:

>>> main()
It
was
the
best
of
times

请注意,print_words()函数对列表中的项目类型并不挑剔。它可以很好地打印数字列表:

>>> print_words([1, 7, 3])
1
7
3

因此,也许print_words()不是最好的名称。实际上,该函数也没有提到列表-它可以很高兴地打印任何 for 循环能够迭代的集合,例如字符串:

>>> print_words("Strings are iterable too")
S
t
r
i
n
g
s

a
r
e

i
t
e
r
a
b
l
e

t
o
o

因此,让我们进行一些小的重构,并将此函数重命名为print_items(),并相应地更改函数内的变量名:

def print_items(items):
    for item in items:
        print(item)

最后,对我们的模块的一个明显改进是用一个可以传递的值替换硬编码的 URL。让我们将该值提取到fetch_words()函数的参数中:

def fetch_words(url):
    with urlopen(url) as story:
        story_words = []
        for line in story:
            line_words = line.decode('utf-8').split()
            for word in line_words:
                story_words.append(word)
    return story_words

接受命令行参数

最后一次更改实际上破坏了我们的main(),因为它没有传递新的url参数。当我们将模块作为独立程序运行时,我们需要接受 URL 作为命令行参数。在 Python 中访问命令行参数是通过sys模块的一个属性argv,它是一个字符串列表。要使用它,我们必须首先在程序顶部导入sys模块:

import sys

然后我们从列表中获取第二个参数(索引为 1):

def main():
    url = sys.argv[1]
    words = fetch_words(url)
    print_items(words)

当然,这按预期工作:

$ python3 words.py http://sixty-north.com/c/t.txt
It
was
the
best
of
times

这看起来很好,直到我们意识到我们无法从 REPL 有用地测试main(),因为它引用sys.argv[1],在该环境中这个值不太可能有用:

$ python3
Python 3.5.0 (default, Nov  3 2015, 13:17:02)
[GCC 4.2.1 Compatible Apple LLVM 6.1.0 (clang-602.0.53)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>> from words import *
>>> main()
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/Users/sixtynorth/projects/sixty-north/the-python-apprentice/manuscript/code/\
pyfund/words.py", line 21, in main
    url = sys.argv[1]
IndexError: list index out of range
>>>

解决方案是允许将参数列表作为main()函数的形式参数传递,使用sys.argv作为if __name__ == '__main__'块中的实际参数:

def main(url):
    words = fetch_words(url)
    print_items(words)

if __name__ == '__main__':
    main(sys.argv[1])

再次从 REPL 进行测试,我们可以看到一切都按预期工作:

>>> from words import *
>>> main("http://sixty-north.com/c/t.txt")
It
was
the
best
of
times

Python 是开发命令行工具的好工具,您可能会发现您需要处理许多情况的命令行参数。对于更复杂的命令行处理,我们建议您查看Python 标准库argparse模块或受启发的第三方docopt模块


禅意时刻

您会注意到我们的顶级函数之间有两个空行。这是现代 Python 代码的传统。

根据PEP 8 风格指南,在模块级函数之间使用两个空行是习惯的。我们发现这种约定对我们有所帮助,使代码更容易导航。同样,我们在函数内使用单个空行进行逻辑分隔。


文档字符串

我们之前看到了如何在 REPL 上询问 Python 函数的帮助。让我们看看如何将这种自我记录的能力添加到我们自己的模块中。

Python 中的 API 文档使用一种称为docstrings的设施。 Docstrings 是出现在命名块(例如函数或模块)的第一条语句中的文字字符串。让我们记录fetch_words()函数:

def fetch_words(url):
    """Fetch a list of words from a URL."""
    with urlopen(url) as story:
        story_words = []
        for line in story:
            line_words = line.decode('utf-8').split()
            for word in line_words:
                story_words.append(word)
    return story_words

我们甚至使用三引号字符串来编写单行文档字符串,因为它们可以很容易地扩展以添加更多细节。

Python 文档字符串的一个约定在PEP 257中有记录,尽管它并没有被广泛采用。各种工具,如Sphinx,可用于从 Python 文档字符串构建 HTML 文档,每个工具都规定了其首选的文档字符串格式。我们的首选是使用Google 的 Python 风格指南中提出的形式,因为它适合被机器解析,同时在控制台上仍然可读:

def fetch_words(url):
    """Fetch a list of words from a URL.

 Args:
 url: The URL of a UTF-8 text document.

 Returns:
 A list of strings containing the words from
 the document.
 """
    with urlopen(url) as story:
        story_words = []
        for line in story:
            line_words = line.decode('utf-8').split()
            for word in line_words:
                story_words.append(word)
    return story_words

现在我们将从 REPL 中访问这个help()

$ python3
Python 3.5.0 (default, Nov  3 2015, 13:17:02)
[GCC 4.2.1 Compatible Apple LLVM 6.1.0 (clang-602.0.53)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>> from words import *
>>> help(fetch_words)

Help on function fetch_words in module words:

fetch_words(url)
    Fetch a list of words from a URL.

    Args:
        url: The URL of a UTF-8 text document.

    Returns:
        A list of strings containing the words from
        the document.
(END)

我们将为其他函数添加类似的文档字符串:

def print_items(items):
    """Print items one per line.

 Args:
 items: An iterable series of printable items.
 """
    for item in items:
        print(item)

def main(url):
    """Print each word from a text document from at a URL.

 Args:
 url: The URL of a UTF-8 text document.
 """
    words = fetch_words(url)
    print_items(words)

以及模块本身的文档字符串。模块文档字符串应放在模块的开头,任何语句之前:

"""Retrieve and print words from a URL.

Usage:

 python3 words.py <URL>
"""

import sys
from urllib.request import urlopen

现在当我们在整个模块上请求help()时,我们会得到相当多有用的信息:

$ python3
Python 3.5.0 (default, Nov  3 2015, 13:17:02)
[GCC 4.2.1 Compatible Apple LLVM 6.1.0 (clang-602.0.53)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>> import words
>>> help(words)

Help on module words:

NAME
    words - Retrieve and print words from a URL.

DESCRIPTION
    Usage:

        python3 words.py <URL>

FUNCTIONS
    fetch_words(url)
        Fetch a list of words from a URL.

        Args:
            url: The URL of a UTF-8 text document.

        Returns:
            A list of strings containing the words from
            the document.

    main(url)
        Print each word from a text document from at a URL.

        Args:
            url: The URL of a UTF-8 text document.

    print_items(items)
        Print items one per line.

        Args:
            items: An iterable series of printable items.

FILE
    /Users/sixtynorth/the-python-apprentice/words.py

(END)

注释

我们认为文档字符串是 Python 代码中大多数文档的正确位置。它们解释了如何使用模块提供的功能,而不是它的工作原理。理想情况下,您的代码应该足够清晰,不需要辅助解释。尽管如此,有时需要解释为什么选择了特定的方法或使用了特定的技术,我们可以使用 Python 注释来做到这一点。Python 中的注释以#开头,直到行尾。

作为演示,让我们记录这样一个事实,即为什么我们在调用main()时使用sys.argv[1]而不是sys.argv[0]可能不是立即明显的:

if __name__ == '__main__':
    main(sys.argv[1])  # The 0th arg is the module filename.

Shebang

在类 Unix 系统上,脚本的第一行通常包括一个特殊的注释#!,称为shebang。这允许程序加载器识别应该使用哪个解释器来运行程序。Shebang 还有一个额外的目的,方便地在文件顶部记录 Python 代码是 Python 2 还是 Python 3。

您的 shebang 命令的确切细节取决于系统上 Python 的位置。典型的 Python 3 shebang 使用 Unix 的env程序来定位您的PATH环境变量上的 Python 3,这一点非常重要,它与 Python 虚拟环境兼容:

#!/usr/bin/env python3

Linux 和 Mac 上可执行的 Python 程序

在 Mac 或 Linux 上,我们必须在 shebang 生效之前使用chmod命令将脚本标记为可执行:

$ chmod +x words.py

做完这些之后,我们现在可以直接运行我们的脚本:

$ ./words.py http://sixty-north.com/c/t.txt

Windows 上可执行的 Python 程序

从 Python 3.3 开始,Windows 上的 Python 也支持使用 shebang 来使 Python 脚本直接可执行,即使看起来只能在类 Unix 系统上正常工作的 shebang 也会在 Windows 上按预期工作。这是因为 Windows Python 发行版现在使用一个名为PyLauncher的程序。 PyLauncher 的可执行文件名为py.exe,它将解析 shebang 并找到适当版本的 Python。

例如,在 Windows 的cmd提示符下,这个命令就足以用 Python 3 运行你的脚本(即使你也安装了 Python 2):

> words.py http://sixty-north.com/c/t.txt

在 Powershell 中,等效的是:

PS> .\words.py http://sixty-north.com/c/t.txt

您可以在PEP 397中了解更多关于 PyLauncher 的信息。

总结

  • Python 模块:

  • Python 代码放在名为模块的*.py文件中。

  • 模块可以通过将它们作为 Python 解释器的第一个参数直接执行。

  • 模块也可以被导入到 REPL 中,此时模块中的所有顶级语句将按顺序执行。

  • Python 函数:

  • 使用def关键字定义命名函数,后面跟着函数名和括号中的参数列表。

  • 我们可以使用return语句从函数中返回对象。

  • 没有参数的返回语句返回None,在每个函数体的末尾也是如此。

  • 模块执行:

  • 我们可以通过检查特殊的__name__变量的值来检测模块是否已导入或执行。如果它等于字符串"__main__",我们的模块已直接作为程序执行。通过在模块末尾使用顶层if __name__ == '__main__'习语来执行函数,如果满足这个条件,我们的模块既可以被有用地导入,又可以被执行,这是一个重要的测试技术,即使对于短脚本也是如此。

  • 模块代码只在第一次导入时执行一次。

  • def关键字是一个语句,将可执行代码绑定到函数名。

  • 命令行参数可以作为字符串列表访问,通过sys模块的argv属性。零号命令行参数是脚本文件名,因此索引为 1 的项是第一个真正的参数。

  • Python 的动态类型意味着我们的函数可以非常通用,关于它们参数的类型。

  • 文档字符串:

  • 作为函数定义的第一行的文字字符串形成函数的文档字符串。它们通常是包含使用信息的三引号多行字符串。

  • 在 REPL 中,可以使用help()检索文档字符串中提供的函数文档。

  • 模块文档字符串应放置在模块的开头,先于任何 Python 语句,如导入语句。

  • 注释:

  • Python 中的注释以井号字符开头,并延续到行尾。

  • 模块的第一行可以包含一个特殊的注释,称为 shebang,允许程序加载器在所有主要平台上启动正确的 Python 解释器。

第五章:内置类型和对象模型

Python 语言最基本的设计元素之一是其对对象的使用。对象不仅是用户级构造的中心数据结构,也是语言本身许多内部工作的中心数据结构。在本章中,我们将开始发展对这一概念的理解,无论是在原则上还是在实践中,希望您开始意识到对象在整个 Python 中是多么普遍。

我们将看看对象是什么,如何使用它们以及如何管理对它们的引用。我们还将开始探索 Python 中类型的概念,并且我们将看到 Python 的类型既类似于许多其他流行语言中的类型,又有所不同。作为这一探索的一部分,我们将更深入地了解一些我们已经遇到的集合类型,并介绍一些其他集合类型。

Python 对象引用的性质

在之前的章节中,我们已经讨论并在 Python 中使用了“变量”,但变量到底是什么?考虑将整数分配给变量这样简单的事情:

>>> x = 1000

当我们这样做时,实际上发生了什么?首先,Python 创建了一个值为1000int 对象。这个对象在本质上是匿名的,因为它本身没有名称(x或其他)。它是由 Python 运行时系统分配和跟踪的对象。

创建对象后,Python 创建了一个名为x对象引用,并安排x ^(6)指向int(1000)对象:

将名称'x'分配给一个值为 1000 的整数对象

将名称‘x’分配给一个值为 1000 的整数对象

重新分配引用

现在我们将使用另一个赋值来修改x的值:

>>> x = 500

不会导致我们之前构造的int(1000)对象的任何更改。Python 中的整数对象是不可变的,不能被更改。实际上,这里发生的是 Python 首先创建一个新的不可变整数对象,其值为 500,然后将x引用重定向到新对象:

将名称'x'重新分配给一个值为 500 的新整数对象

重新将名称‘x’分配给一个值为 500 的新整数对象

由于我们没有对原始int(1000)对象的其他引用,我们现在无法从我们的代码中访问它。因此,Python 垃圾收集器可以在选择时收集它。^(7)

分配一个引用给另一个引用

当我们从一个变量分配到另一个变量时,我们实际上是从一个对象引用分配到另一个对象引用,这样两个引用就指向同一个对象。例如,让我们将现有变量x分配给一个新变量y

>>> y = x

这给我们了这个引用对象图:

将现有名称'x'分配给名称'y'

将现有名称“x”分配给名称“y”

现在两个引用都指向同一个对象。我们现在将x重新分配给另一个新的整数:

>>> x = 3000

这样做会给我们一个引用对象图,显示我们的两个引用和两个对象:

将一个新的整数 3000 分配给'x'

将一个新的整数 3000 分配给‘x’

在这种情况下,垃圾收集器没有工作要做,因为所有对象都可以从活动引用中访问。

使用id()探索值与标识的差异

让我们使用内置的id()函数深入探讨对象和引用之间的关系。id()接受任何对象作为参数,并返回一个整数标识符,该标识符对于对象的整个生命周期是唯一且恒定的。让我们使用id()重新运行先前的实验:

>>> a = 496
>>> id(a)
4302202064
>>> b = 1729
>>> id(b)
4298456016
>>> b = a
>>> id(b)
4302202064
>>> id(a) == id(b)
True

在这里,我们看到最初 ab 指向不同的对象,因此 id() 为每个变量给出了不同的值。然而,当我们将 a 分配给 b 时,两个名称都指向同一个对象,因此 id() 为两者给出了相同的值。这里的主要教训是,id() 可以用来确定对象的 身份,而不依赖于对它的任何特定引用。

使用 is 测试身份相等

实际上,在生产 Python 代码中很少使用 id() 函数。它的主要用途是在对象模型教程(比如这个!)和作为调试工具中。比 id() 函数更常用的是测试身份相等的 is 运算符。也就是说,is 测试两个引用是否指向同一个对象:

>>> a is b
True

我们在第一章已经遇到了 is 运算符,当时我们测试了 None

>>> a is None
False

重要的是要记住,is 总是测试 身份相等,也就是说,两个引用是否指向完全相同的对象。我们将深入研究另一种主要类型的相等,值相等,稍后会详细介绍。

在不进行变异的情况下进行变异

即使看起来自然会进行变异的操作也不一定如此。考虑增强赋值运算符:

>>> t = 5
>>> id(t)
4297261280
>>> t += 2
>>> id(t)
4297261344

乍一看,似乎我们要求 Python 将整数值 t 增加两个。但这里的 id() 结果清楚地显示,在增强赋值之前和之后,t 指向两个不同的对象。

而不是修改整数对象,这里展示的实际发生的情况。最初,我们有名称 t 指向一个 int(5) 对象:

'x' 指向整数 5

‘x’ 指向整数 5

接下来,为了执行将 2 增强赋值给 t,Python 在幕后创建了一个 int(2) 对象。请注意,我们从未对此对象进行命名引用;它完全由 Python 代表我们管理:

Python 在幕后创建一个整数 2

Python 在幕后创建一个整数 2

然后,Python 在 t 和匿名 int(2) 之间执行加法运算,得到 —— 你猜对了! —— 另一个整数对象,这次是 int(7)

Python 创建一个新的整数作为加法的结果

Python 创建一个新的整数作为加法的结果

最后,Python 的增强赋值运算符将名称 t 重新分配给新的 int(7) 对象,使其他整数对象由垃圾收集器处理:

Python 重新分配了名称 't' 给加法的结果

Python 重新分配了名称 ‘t’ 给加法的结果

对可变对象的引用

Python 对所有类型都显示这种名称绑定行为。赋值运算符只会将对象绑定到名称,它永远不会通过值复制对象。为了更清楚地说明这一点,让我们看另一个使用可变对象的例子:列表。与我们刚刚看到的不可变的 int 不同,list 对象具有可变状态,这意味着 list 对象的值可以随时间改变。

为了说明这一点,我们首先创建一个具有三个元素的列表对象,并将列表对象绑定到名为 r 的引用:

>>> r = [2, 4, 6]
>>> r
[2, 4, 6]

然后,我们将引用 r 分配给一个新的引用 s

>>> s = r
>>> s
[2, 4, 6]

这种情况的引用对象图表清楚地表明我们有两个名称指向单个 list 实例:

's' 和 'r' 指向同一个列表对象

‘s’ 和 ‘r’ 指向同一个列表对象

当我们通过更改由 s 引用的列表来修改列表时,我们看到由 r 引用的列表也发生了变化:

>>> s[1] = 17
>>> s
[2, 17, 6]
>>> r
[2, 17, 6]

同样,这是因为名称 sr 指向相同的 可变 对象 ^(8),我们可以通过使用之前学到的 is 关键字来验证这一事实:

>>> s is r
True

这次讨论的主要观点是,Python 实际上并没有变量的隐喻意义上的值。它只有对对象的命名引用,这些引用的行为更像是标签,允许我们检索对象。也就是说,在 Python 中谈论变量仍然很常见,因为这很方便。我们将在本书中继续这样做,确信您现在了解了幕后发生了什么。

值的相等性(等同性)与身份的相等性

让我们将该行为与值相等性或等同性的测试进行对比。我们将创建两个相同的列表:

>>> p = [4, 7, 11]
>>> q = [4, 7, 11]
>>> p == q
True
>>> p is q
False

在这里,我们看到pq指的是不同的对象,但它们指的对象具有相同的值。

!'p'和'q'不同的列表对象,具有相同的值

'p'和'q'不同的列表对象,具有相同的值

正如您期望的那样,在测试值相等性时,对象应始终等同于自身^(9):

>>> p == p
True

值相等性和身份是“相等”的基本不同概念,重要的是要在脑海中将它们分开。

值比较也值得一提,它是以编程方式定义的。当您定义类型时,您可以控制该类如何确定值的相等性。相反,身份比较是由语言定义的,您无法更改该行为。

参数传递语义 - 按对象引用传递

现在让我们看看所有这些与函数参数和返回值的关系。当我们调用函数时,我们实际上创建了新的名称绑定 - 那些在函数定义中声明的名称绑定 - 到现有对象 - 那些在调用时传递的对象。^(10) 因此,如果您想知道您的函数如何工作,真正理解 Python 引用语义是很重要的。

在函数中修改外部对象

为了演示 Python 的参数传递语义,我们将在 REPL 中定义一个函数,该函数将一个值附加到列表并打印修改后的列表。首先我们将创建一个list并将其命名为m

>>> m = [9, 15, 24]

然后我们将定义一个名为modify()的函数,该函数将附加到传递给它的列表并打印该列表。该函数接受一个名为k的单个形式参数:

>>> def modify(k):
...     k.append(39)
...     print("k =", k)
...

然后我们调用modify(),将我们的列表m作为实际参数传递:

>>> modify(m)
k = [9, 15, 24, 39]

这确实打印了具有四个元素的修改后的列表。但是我们在函数外部的列表引用m现在指向什么?

>>> m
[9, 15, 24, 39]

m引用的列表已被修改,因为它是函数内部由k引用的同一列表。正如我们在本节开头提到的,当我们将对象引用传递给函数时,我们实质上是将实际参数引用(在本例中为m)分配给形式参数引用(在本例中为k)。

!在函数内外引用同一列表

在函数内外引用同一列表

正如我们所见,赋值会导致被赋值的引用指向与被赋值的引用相同的对象。这正是这里正在发生的事情。如果您希望函数修改对象的副本,那么函数有责任进行复制。

在函数中绑定新对象

让我们看另一个有教育意义的例子。首先,我们将创建一个新列表f

>>> f = [14, 23, 37]

然后我们将创建一个名为replace()的新函数。顾名思义,replace()不会修改其参数,而是会更改其参数所引用的对象:

>>> def replace(g):
...     g = [17, 28, 45]
...     print("g =", g)
...

我们现在使用实际参数f调用replace()

>>> replace(f)
g = [17, 28, 45]

这正是我们所期望的。但是外部引用f现在的值是多少?

>>> f
[14, 23, 37]

f仍然指向原始的未修改列表。这一次,函数没有修改传入的对象。发生了什么?

答案是:对象引用f被分配给了形式参数g,所以gf确实引用了同一个对象,就像前面的例子一样。

最初'f'和'g'引用相同的列表对象

最初'f'和'g'引用相同的列表对象

然而,在函数的第一行,我们重新分配了引用g,指向一个新构造的列表[17, 28, 45],所以在函数内部,对原始[14, 23, 37]列表的引用被覆盖了,尽管未修改的对象本身仍然被f引用在函数外部。

重新分配后,'f'和'g'引用不同的对象

重新分配后,'f'和'g'引用不同的对象

参数传递是引用绑定

所以我们已经看到通过函数参数引用修改对象是完全可能的,但也可以重新绑定参数引用到新值。如果你想改变列表参数的内容,并且希望在函数外部看到这些变化,你可以像这样修改列表的内容:

>>> def replace_contents(g):
...     g[0] = 17
...     g[1] = 28
...     g[2] = 45
...     print("g =", g)
...
>>> f
[14, 23, 37]
>>> replace_contents(f)
g = [17, 28, 45]

确实,如果你检查f的内容,你会发现它们已经被修改了:

>>> f
[17, 28, 45]

函数参数是通过所谓的“对象引用传递”传递的。这意味着引用的被复制到函数参数中,而不是所引用对象的值;没有对象被复制。

Python 返回语义

Python 的return语句使用与函数参数相同的对象引用传递语义。当你在 Python 中从函数返回一个对象时,你真正做的是将一个对象引用传递回调用者。如果调用者将返回值分配给一个引用,他们所做的只是将一个新的引用分配给返回的对象。这使用了与显式引用赋值和参数传递相同的语义和机制。

我们可以通过编写一个返回它的唯一参数的函数来证明这一点:

>>> def f(d):
...     return d
...

如果我们创建一个对象,比如一个列表,并通过这个简单的函数传递它,我们会发现它返回的是我们传入的完全相同的对象:

>>> c = [6, 10, 16]
>>> e = f(c)
>>> c is e
True

记住,只有当两个名称引用完全相同的对象时,is才会返回True,所以这个例子表明列表没有被复制。

详细的函数参数

现在我们理解了对象引用和对象之间的区别,我们将看一些函数参数的更多功能。

默认参数值

使用def关键字定义函数时指定的形式函数参数是一个逗号分隔的参数名称列表。通过提供默认值,这些参数可以变成可选的。考虑一个函数,它在控制台上打印一个简单的横幅:

1 >>> def banner(message, border='-'):
2 ...     line = border * len(message)
3 ...     print(line)
4 ...     print(message)
5 ...     print(line)
6 ...

这个函数接受两个参数,并且我们提供了一个默认值——在这种情况下是'-'——在一个字面字符串中。当我们使用默认参数定义函数时,具有默认参数的参数必须在没有默认值的参数之后,否则我们将得到一个SyntaxError

在函数的第 2 行,我们将我们的边框字符串乘以消息字符串的长度。这一行展示了两个有趣的特点。首先,它演示了我们如何使用内置的len()函数确定 Python 集合中的项目数。其次,它展示了如何将一个字符串(在这种情况下是单个字符字符串边框)乘以一个整数,结果是一个包含原始字符串重复多次的新字符串。我们在这里使用这个特性来使一个与我们的消息长度相等的字符串。

在 3 到 5 行,我们打印全宽边框、消息和再次边框。

当我们调用我们的banner()函数时,我们不需要提供边框字符串,因为我们提供了一个默认值:

>>> banner("Norwegian Blue")
--------------
Norwegian Blue
--------------

然而,如果我们提供可选参数,它会被使用:

>>> banner("Sun, Moon and Stars", "*")
***************
Sun, Moon and Stars
***************

关键字参数

在生产代码中,这个函数调用并不特别自我说明。我们可以通过在调用站点命名border参数来改善这种情况:

>>> banner("Sun, Moon and Stars", border="*")
***************
Sun, Moon and Stars
***************

在这种情况下,message字符串被称为“位置参数”,border字符串被称为“关键字参数”。在调用中,位置参数按照函数定义中声明的形式参数的顺序进行匹配。另一方面,关键字参数则按名称进行匹配。如果我们为我们的两个参数使用关键字参数,我们可以自由地以任何顺序提供它们:

>>> banner(border=".", message="Hello from Earth")
................
Hello from Earth
................

但请记住,所有关键字参数必须在任何位置参数之后指定。

默认参数何时被评估?

当您为函数提供默认参数值时,您通过提供一个表达式来实现。这个表达式可以是一个简单的文字值,也可以是一个更复杂的函数调用。为了实际使用您提供的默认值,Python 必须在某个时候评估该表达式。

因此,关键是要确切了解 Python 何时评估默认值表达式。这将帮助您避免一个常见的陷阱,这个陷阱经常会使 Python 的新手陷入困境。让我们使用 Python 标准库time模块仔细研究这个问题:

>>> import time

我们可以通过使用time模块的ctime()函数轻松地将当前时间作为可读字符串获取:

>>> time.ctime()
'Sat Feb 13 16:06:29 2016'

让我们编写一个使用从ctime()检索的值作为默认参数值的函数:

>>> def show_default(arg=time.ctime()):
...     print(arg)
...
>>> show_default()
Sat Feb 13 16:07:11 2016

到目前为止一切顺利,但请注意当您几秒钟后再次调用show_default()时会发生什么:

>>> show_default()
Sat Feb 13 16:07:11 2016

再一次:

>>> show_default()
Sat Feb 13 16:07:11 2016

正如你所看到的,显示的时间永远不会进展。

还记得我们说过def是一个语句,当执行时将函数定义绑定到函数名吗?好吧,默认参数表达式只在def语句执行时评估一次。在许多情况下,默认值是一个简单的不可变常量,如整数或字符串,因此这不会引起任何问题。但是对于那些通常在使用可变集合作为参数默认值时出现的困惑陷阱,这可能是一个令人困惑的陷阱。

让我们仔细看看。考虑这个使用空列表作为默认参数的函数。它接受一个菜单作为字符串列表,将项目"spam"附加到列表中,并返回修改后的菜单:

>>> def add_spam(menu=[]):
...     menu.append("spam")
...     return menu
...

让我们来制作一个简单的培根和鸡蛋早餐:

>>> breakfast = ['bacon', 'eggs']

当然,我们会向其中添加垃圾邮件:

>>> add_spam(breakfast)
['bacon', 'eggs', 'spam']

我们将为午餐做类似的事情:

>>> lunch = ['baked beans']
>>> add_spam(lunch)
['baked beans', 'spam']

到目前为止没有什么意外的。但是看看当您依赖默认参数而不传递现有菜单时会发生什么:

>>> add_spam()
['spam']

当我们向空菜单添加'spam'时,我们只得到spam。这可能仍然是您所期望的,但如果我们再次这样做,我们的菜单中就会添加两个spam

>>> add_spam()
['spam', 'spam']

还有三个:

>>> add_spam()
['spam', 'spam', 'spam']

还有四个:

>>> add_spam()
['spam', 'spam', 'spam', 'spam']

这里发生的情况是这样的。首先,在def语句执行时,用于默认参数的空列表被创建一次。这是一个像我们迄今为止看到的任何其他普通列表一样的列表,Python 将在整个程序执行期间使用这个确切的列表。

第一次我们实际使用默认值,然后,我们最终直接将spam添加到默认列表对象中。当我们第二次使用默认值时,我们使用的是同一个默认列表对象——我们刚刚添加了spam的对象,并且我们最终将第二个spam实例添加到其中。第三次调用会无限地添加第三个 spam。或者也许是无限地恶心。

解决这个问题很简单,但也许不是显而易见的:始终使用不可变对象,如整数或字符串作为默认值。遵循这个建议,我们可以通过使用不可变的None对象作为标记来解决这个特定的问题:

>>> def add_spam(menu=None):
...     if menu is None:
...         menu = []
...     menu.append('spam')
...     return menu
...
>>> add_spam()
['spam']
>>> add_spam()
['spam']
>>> add_spam()
['spam']

现在我们的add_spam()函数按预期工作。

Python 类型系统

编程语言可以通过几个特征来区分,但其中最重要的特征之一是它们的类型系统的性质。Python 可以被描述为具有动态类型系统。让我们来研究一下这意味着什么。

Python 中的动态类型

动态类型意味着对象引用的类型直到程序运行时才能解析,并且在编写程序时无需事先指定。看一下这个简单的函数来添加两个对象:

>>> def add(a, b):
...     return a + b
...

在这个定义中我们没有提到任何类型。我们可以用整数使用add()

>>> add(5, 7):
12

我们也可以用它来表示浮点数:

>>> add(3.1, 2.4)
5.5

你可能会惊讶地看到它甚至适用于字符串:

>>> add("news", "paper")
'newspaper'

事实上,这个函数适用于任何类型,比如list,对于这些类型,加法运算符已经被定义:

>>> add([1, 6], [21, 107])
[1, 6, 21, 107]

这些示例说明了类型系统的动态性:add()函数的两个参数ab可以引用任何类型的对象。

Python 中的强类型

另一方面,类型系统的强度可以通过尝试为未定义加法的类型(如字符串和浮点数)add()来证明:

>>> add("The answer is", 42)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<stdin>", line 2, in add
TypeError: Can't convert 'int' object to str implicitly

尝试这样做会导致TypeError,因为 Python 通常不会在对象类型之间执行隐式转换,或者试图将一种类型强制转换为另一种类型。这个主要的例外是用于 if 语句和 while 循环谓词的bool转换。^(11)

变量声明和作用域

正如我们所见,Python 中不需要类型声明,变量本质上只是未经类型化的名称绑定到对象。因此,它们可以被重新绑定 - 或重新分配 - 任意多次,甚至可以是不同类型的对象。

但是当我们将一个名称绑定到一个对象时,该绑定存储在哪里?要回答这个问题,我们必须看一下 Python 中的作用域和作用域规则。

LEGB 规则

Python 中有四种作用域类型,它们按层次排列。每个作用域都是存储名称并在其中查找名称的上下文。从最狭窄到最宽广的四个作用域是:

  • 本地 - 在当前函数内定义的名称。

  • 封闭 - 在任何封闭函数中定义的名称。(这个作用域对本书的内容并不重要。)

  • 全局 - 在模块的顶层定义的名称。每个模块都带有一个新的全局作用域。

  • 内置 - 通过特殊的builtins模块内置到 Python 语言中的名称。

这些作用域共同构成了 LEGB 规则:

LEGB 规则

名称在最相关的上下文中查找。

重要的是要注意,Python 中的作用域通常不对应于缩进所标示的源代码块。for 循环、with 块等不会引入新的嵌套作用域。

作用域的实际应用

考虑我们的words.py模块。它包含以下全局名称:

  • main - 由def main()绑定

  • sys - 由import sys绑定

  • __name__ - 由 Python 运行时提供

  • urlopen - 由from urllib.request import urlopen绑定

  • fetch_words - 由def fetch_words()绑定

  • print_items - 由def print_items()绑定

模块范围名称绑定通常是由import语句和函数或类定义引入的。在模块范围内使用其他对象是可能的,这通常用于常量,尽管它也可以用于变量。

fetch_words()函数内部,我们有六个本地名称:

  • word - 由内部 for 循环绑定

  • line_words - 通过赋值绑定

  • line - 由外部 for 循环绑定

  • story_words - 通过赋值绑定

  • url - 由形式函数参数绑定

  • story - 由 with 语句绑定

这些绑定中的每一个都是在首次使用时创建的,并在函数完成时继续存在于函数作用域内,此时引用将被销毁。

全局和本地作用域中的相同名称

非常偶尔,我们需要在函数内部从模块范围重新绑定全局名称。考虑以下简单模块:

count = 0

def show_count():
    print(count)

def set_count(c):
    count = c

如果我们将这个模块保存在scopes.py中,我们可以将其导入 REPL 进行实验:

$ python3
Python 3.5.0 (default, Nov  3 2015, 13:17:02)
[GCC 4.2.1 Compatible Apple LLVM 6.1.0 (clang-602.0.53)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>> from scopes import *
>>> show_count()
count =  0

当调用show_count()时,Python 在本地命名空间(L)中查找名称count。它找不到,所以在下一个最外部的命名空间中查找,这种情况下是全局模块命名空间(G),在那里它找到名称count并打印所引用的对象。

现在我们用一个新值调用set_count()

>>> set_count(5)

然后我们再次调用show_count()

>>> show_count()
count =  0

您可能会惊讶,在调用set_count(5)后,show_count()显示0,所以让我们一起来看看发生了什么。

当我们调用set_count()时,赋值count = c本地作用域中为名称count创建了一个绑定。这个新绑定当然是指传递的对象c。关键是,在模块范围定义的全局count不会进行查找。我们创建了一个新变量,它遮蔽了同名的全局变量,从而阻止访问。

global关键字

为了避免在全局范围内遮蔽名称,我们需要指示 Python 将set_count()函数中的名称count解析为模块命名空间中定义的count。我们可以使用global关键字来做到这一点。让我们修改set_count()来这样做:

def set_count(c):
    global count
    count = c

global在本地作用域中引入了一个来自全局作用域的名称绑定。

退出并重新启动 Python 解释器以运行我们修改后的模块:

>>> from scopes import *
>>> show_count()
count =  0
>>> set_count(5)
>>> show_count()
count =  5

它现在展示了所需的行为。


禅的时刻

正如我们所展示的,Python 中的所有变量都是对象的引用,即使在基本类型(如整数)的情况下也是如此。这种对对象导向的彻底方法是 Python 的一个重要主题,实际上 Python 中的几乎所有东西都是对象,包括函数和模块。


一切都是对象

让我们回到我们的words模块,并在 REPL 中进一步进行实验。这次我们只会导入模块:

$ python3
Python 3.5.0 (default, Nov  3 2015, 13:17:02)
[GCC 4.2.1 Compatible Apple LLVM 6.1.0 (clang-602.0.53)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>> import words

import语句在当前命名空间中将模块对象绑定到名称words。我们可以使用type()内置函数确定任何对象的类型:

>>> type(words)
<class 'module'>

如果我们想要查看对象的属性,我们可以在 Python 交互会话中使用dir()内置函数来审视对象:

>>> dir(words)
['__builtins__', '__cached__', '__doc__', '__file__', '__initializing__',
'__loader__', '__name__', '__package__', 'fetch_words', 'main',
'print_items', 'sys', 'urlopen']

dir()函数返回模块属性名称的排序列表,包括:

  • 我们定义的一些,比如函数fetch_words()

  • 任何导入的名称,比如sysurlopen

  • 各种特殊的dunder属性,比如__name____doc__,揭示了 Python 的内部工作。

检查一个函数

我们可以使用type()函数对任何这些属性进行更多了解。例如,我们可以看到fetch_words是一个函数对象:

>>> type(words.fetch_words)
<class 'function'>

我们可以反过来在函数上使用dir()来揭示它的属性:

>>> dir(words.fetch_words)
['__annotations__', '__call__', '__class__', '__closure__', '__code__',
'__defaults__', '__delattr__', '__dict__', '__dir__', '__doc__', '__eq__',
'__format__', '__ge__', '__get__', '__getattribute__', '__globals__',
'__gt__', '__hash__', '__init__', '__kwdefaults__', '__le__', '__lt__',
'__module__', '__name__', '__ne__', '__new__', '__qualname__', '__reduce__',
'__reduce_ex__', '__repr__', '__setattr__', '__sizeof__', '__str__',
'__subclasshook__']

我们可以看到函数对象有许多与 Python 函数在幕后实现方式有关的特殊属性。现在,我们只看一些简单的属性。

正如您所期望的那样,它的__name__属性是函数对象的名称作为字符串:

>>> words.fetch_words.__name__
'fetch_words'

同样,__doc__是我们提供的文档字符串,给出了一些关于内置help()函数如何实现的线索。

>>> words.fetch_words.__doc__
'Fetch a list of words from a URL.\n\n Args:\n url: The URL of a
UTF-8 text document.\n\n    Returns:\n        A list of strings containing
the words from\n        the document.\n    '

这只是一个小例子,展示了您可以在运行时审查 Python 对象,还有许多更强大的工具可以帮助您了解更多关于您正在使用的对象。也许这个例子最有教育意义的部分是,我们正在处理一个函数对象,这表明 Python 的普遍对象导向包括其他语言中可能根本无法访问的语言元素。

总结

  • Python 对象引用

  • 将 Python 视为对对象的命名引用,而不是变量和值。

  • 赋值不会将值放入一个盒子中。它会将一个名称标签附加到一个对象上。

  • 从一个引用分配到另一个引用会在同一个对象上放置两个名称标签。

  • Python 垃圾收集器将回收不可达的对象-那些没有名称标签的对象。

  • 对象标识和等价性

  • id()函数返回一个唯一且恒定的标识符,但在生产中很少使用。

  • is运算符确定身份的相等性。也就是说,两个名称是否引用同一个对象。

  • 我们可以使用双等号运算符测试等价性。

  • 函数参数和返回值

  • 函数参数通过对象引用传递,因此如果它们是可变对象,函数可以修改它们的参数。

  • 如果通过赋值重新绑定形式函数参数,则传入对象的引用将丢失。要更改可变参数,应该替换其内容而不是替换整个对象。

  • 返回语句也通过对象引用传递。不会进行复制。

  • 函数参数可以指定默认值。

  • 默认参数表达式在执行def语句时只被评估一次。

  • Python 类型系统

  • Python 使用动态类型,因此我们不需要提前指定引用类型。

  • Python 使用强类型。类型不会被强制匹配。

  • 范围

  • 根据 LEGB 规则,Python 引用名称在四个嵌套范围中查找:局部函数中,封闭函数中,全局(或模块)命名空间中和内置函数。

  • 全局引用可以从局部范围读取

  • 从局部范围分配给全局引用需要使用 global 关键字声明引用为全局引用。

  • 对象和内省

  • Python 中的所有内容都是对象,包括模块和函数。它们可以像其他对象一样对待。

  • importdef关键字会绑定到命名引用。

  • 内置的type()函数可以用来确定对象的类型。

  • 内置的dir()函数可以用来内省对象并返回其属性名称的列表。

  • 函数或模块对象的名称可以通过其__name__属性访问。

  • 函数或模块对象的文档字符串可以通过其__doc__属性访问。

  • 杂项

  • 我们可以使用len()来测量字符串的长度。

  • 如果我们将字符串“乘以”一个整数,我们将得到一个新的字符串,其中包含操作数字符串的多个副本。这称为“重复”操作。

第六章:探索内置的集合类型

我们已经遇到了一些内置集合

  • str - 不可变的 Unicode 代码点序列

  • list - 可变的对象序列

  • dict - 从不可变键到可变对象的可变字典映射

我们只是浅尝辄止地了解了这些集合的工作原理,所以我们将在本章更深入地探索它们的功能。我们还将介绍三种新的内置集合类型:

  • tuple - 不可变的对象序列

  • range - 用于整数的算术级数

  • set - 一个包含唯一不可变对象的可变集合

我们不会在这里进一步讨论bytes类型。我们已经讨论了它与str的基本区别,大部分关于str的内容也适用于bytes

这不是 Python 集合类型的详尽列表,但对于你在野外遇到或可能自己编写的绝大多数 Python 3 程序来说,这完全足够了。

在本章中,我们将按照上述顺序介绍这些集合,最后概述协议,这些协议将这些集合联系在一起,并允许它们以一致和可预测的方式使用。

tuple - 一个不可变的对象序列

Python 中的元组是任意对象的不可变序列。一旦创建,其中的对象就不能被替换或移除,也不能添加新元素。

文字元组

元组具有与列表类似的文字语法,只是它们用括号而不是方括号括起来。这是一个包含字符串、浮点数和整数的文字元组:

>>> t = ("Norway", 4.953, 3)
>>> t
('Norway', 4.953, 3)

元组元素访问

我们可以使用方括号通过零基索引访问元组的元素:

>>> t[0]
'Norway'
>>> t[2]
3

元组的长度

我们可以使用内置的len()函数来确定元组中的元素数量:

>>> len(t)
3

对元组进行迭代

我们可以使用 for 循环对其进行迭代:

>>> for item in t:
>>>    print(item)
Norway
4.953
3

元组的连接和重复

我们可以使用加号运算符连接元组:

>>> t + (338186.0, 265E9)
('Norway', 4.953, 3, 338186.0, 265000000000.0)

同样,我们可以使用乘法运算符重复它们:

>>> t * 3
('Norway', 4.953, 3, 'Norway', 4.953, 3, 'Norway', 4.953, 3)

嵌套元组

由于元组可以包含任何对象,因此完全可以有嵌套元组:

>>> a = ((220, 284), (1184, 1210), (2620, 2924), (5020, 5564), (6232, 6368))

我们使用索引运算符的重复应用来访问内部元素:

>>> a[2][1]
2924

单元素元组

有时需要一个单元素元组。要写这个,我们不能只使用括号中的简单对象。这是因为 Python 将其解析为数学表达式的优先控制括号中的对象:

>>> h = (391)
>>> h
391
>>> type(h)
<class 'int'>

要创建一个单元素元组,我们使用尾随逗号分隔符,你会记得,我们允许在指定文字元组、列表和字典时使用尾随逗号。带有尾随逗号的单个元素被解析为单个元素元组:

>>> k = (391,)
>>> k
(391,)
>>> type(k)
<class 'tuple'>

空元组

这让我们面临一个问题,如何指定一个空元组。实际上答案很简单,我们只需使用空括号:

>>> e = ()
>>> e
>>> type(e)
<class 'tuple'>

可选的括号

在许多情况下,可以省略文字元组的括号:

>>> p = 1, 1, 1, 4, 6, 19
>>> p
(1, 1, 1, 4, 6, 19)
>>> type(p)
<class 'tuple'>

返回和解包元组

这个特性经常在从函数返回多个值时使用。在这里,我们创建一个函数来返回序列的最小值和最大值,这是由两个内置函数min()max()完成的:

>>> def minmax(items):
...     return min(items), max(items)
...
>>> minmax([83, 33, 84, 32, 85, 31, 86])
(31, 86)

将多个值作为元组返回经常与 Python 的一个称为元组解包的精彩特性一起使用。元组解包是一种所谓的解构操作,它允许我们将数据结构解包为命名引用。例如,我们可以将minmax()函数的结果分配给两个新引用,如下所示:

>>> lower, upper = minmax([83, 33, 84, 32, 85, 31, 86])
>>> lower
31
>>> upper
86

这也适用于嵌套元组:

>>> (a, (b, (c, d))) = (4, (3, (2, 1)))
>>> a
4
>>> b
3
>>> c
2
>>> d
1

使用元组解包交换变量

元组解包导致了 Python 中交换两个(或更多)变量的美丽习惯用法:

>>> a = 'jelly'
>>> b = 'bean'
>>> a, b = b, a
>>> a
bean
>>> b
jelly

元组构造函数

如果需要从现有集合对象(如列表)创建元组,可以使用tuple()构造函数。在这里,我们从一个list创建一个tuple

>>> tuple([561, 1105, 1729, 2465])
(561, 1105, 1729, 2465)

在这里,我们创建一个包含字符串字符的元组:

>>> tuple("Carmichael")
('C', 'a', 'r', 'm', 'i', 'c', 'h', 'a', 'e', 'l')

成员资格测试

最后,与 Python 中大多数集合类型一样,我们可以使用in运算符测试成员资格:

>>>  5 in (3, 5, 17, 257, 65537)
True

或使用not in运算符进行非成员资格测试:

>>> 5 not in (3, 5, 17, 257, 65537)
False

字符串的应用

我们在第二章已经详细介绍了str类型,但现在我们将花时间更深入地探索它的功能。

字符串的长度

与任何其他 Python 序列一样,我们可以使用内置的len()函数确定字符串的长度。

>>> len("llanfairpwllgwyngyllgogerychwyrndrobwllllantysiliogogogoch")
58

威尔士安格尔西岛上的兰韦尔普尔古因吉尔戈盖里希温德罗布尔兰蒂斯利奥戈戈戈乔火车站的标志 - 欧洲最长的地名。

威尔士安格尔西岛上的兰韦尔普尔古因吉尔戈盖里希温德罗布尔兰蒂斯利奥戈戈戈乔火车站的标志 - 欧洲最长的地名。

连接字符串

使用加号运算符支持字符串的连接:

>>> "New" + "found" + "land"
Newfoundland

或相关的增强赋值运算符:

>>> s = "New"
>>> s += "found"
>>> s += "land"
>>> s
'Newfoundland'

纽芬兰岛,世界第十六大岛,是英语中相对较少的封闭的三重复合词之一。

纽芬兰岛是世界第十六大岛,是英语中相对较少的封闭的三重复合词之一。

请记住,字符串是不可变的,因此在这里,增强赋值运算符在每次使用时将一个新的字符串对象绑定到s上。修改s的假象是可行的,因为s是对对象的引用,而不是对象本身。也就是说,虽然字符串本身是不可变的,但对它的引用是可变的。

连接字符串

对于连接大量字符串,避免使用++=运算符。相反,应优先使用join()方法,因为它效率更高。这是因为使用加法运算符或其增强赋值版本进行连接可能会导致生成大量临时变量,从而导致内存分配和复制的成本。让我们看看join()是如何使用的。

join()str上的一个方法,它接受一个字符串集合作为参数,并通过在它们之间插入分隔符来生成一个新的字符串。join()的一个有趣之处在于分隔符的指定方式:它是在调用join()的字符串。

与 Python 的许多部分一样,示例是最好的解释。将 HTML 颜色代码字符串列表连接成分号分隔的字符串:

>>> colors = ';'.join(['#45ff23', '#2321fa', '#1298a3', '#a32312'])
>>> colors
'#45ff23;#2321fa;#1298a3;#a32312'

在这里,我们在我们希望使用的分隔符上调用join() - 分号 - 并传入要连接的字符串列表。

将一组字符串连接在一起的广泛且快速的 Python 习惯用法是使用空字符串作为分隔符进行join()

>>> ''.join(['high', 'way', 'man'])
highwayman

分割字符串

然后我们可以再次使用split()方法来分割字符串(我们已经遇到过,但这次我们将提供它的可选参数):

>>> colors.split(';')
['#45ff23', '#2321FA', '#1298A3', '#A32912']

可选参数允许您指定要在其上分割字符串的字符串 - 不仅仅是字符。因此,例如,您可以通过在单词“and”上分割来解析匆忙的早餐订单:

>>> 'eggsandbaconandspam'.split('and')
['eggs', 'bacon', 'spam']


禅之时刻

这种使用join()的方法常常让初学者感到困惑,但随着使用,Python 采取的方法将被认为是自然和优雅的。


字符串分区

另一个非常有用的字符串方法是partition(),它将字符串分成三个部分;分隔符之前的部分,分隔符本身,以及分隔符之后的部分:

>>> "unforgettable".partition('forget')
('un', 'forget', 'table')

partition()方法返回一个元组,因此这通常与元组解包一起使用:

>>> departure, separator, arrival = "London:Edinburgh".partition(':')
>>> departure
London
>>> arrival
Edinburgh

通常,我们对捕获分隔符值不感兴趣,所以您可能会看到下划线变量名被使用。这在 Python 语言中并没有特殊对待,但有一个不成文的惯例,即下划线变量用于未使用或虚拟值:

>>> origin, _, destination = "Seattle-Boston".partition('-')

这个约定得到了许多 Python 感知开发工具的支持,这些工具将抑制对下划线未使用变量的警告。

字符串格式化

最有趣和经常使用的字符串方法之一是format()。这取代了旧版本 Python 中使用的字符串插值技术,虽然没有取代它,并且我们在本书中没有涵盖。format()方法可以有用地调用任何包含所谓的替换字段的字符串,这些字段用花括号括起来。作为format()参数提供的对象被转换为字符串,并用于填充这些字段。这里是一个例子:

>>> "The age of {0} is {1}".format('Jim', 32)
'The age of Jim is 32'

在这种情况下,字段名称(01)与format()的位置参数匹配,并且每个参数在幕后被转换为字符串。

一个字段名称可能被多次使用:

>>> "The age of {0} is {1}. {0}'s birthday is on {2}".format('Fred', 24, 'October 31')

然而,如果字段名恰好只使用一次,并且按照相同的顺序作为参数,它们可以被省略:

>>> "Reticulating spline {} of {}.".format(4, 23)
'Reticulating spline 4 of 23.'

如果向format()提供了关键字参数,则可以使用命名字段而不是序数:

>>> "Current position {latitude} {longitude}".format(latitude="60N", longitude="5E")
'Current position 60N 5E'

可以使用方括号索引到序列,并放在替换字段中:

>>> "Galactic position x={pos[0]}, y={pos[1]}, z={pos[2]}".format(pos=(65.2, 23.1, 82\
.2))
'Galactic position x=65.2, y=23.1, z=82.2'

我们甚至可以访问对象属性。在这里,我们使用关键字参数将整个math模块传递给format()(记住 - 模块也是对象!),然后从替换字段中访问它的两个属性:

>>> import math
>>> "Math constants: pi={m.pi}, e={m.e}".format(m=math)
'Math constants: pi=3.141592653589793 e=2.718281828459045'

格式化字符串还可以让我们对字段对齐和浮点格式化有很多控制。这里是相同的常量,只显示到小数点后三位:

>>> "Math constants: pi={m.pi:.3f}, e={m.e:.3f}".format(m=math)
'Math constants: pi=3.142, e=2.718'

其他字符串方法

我们建议您花一些时间熟悉其他字符串方法。记住,您可以使用以下方法找出它们是什么:

>>> help(str)

range - 一组均匀间隔的整数

让我们继续看看range,许多开发人员不认为它是一个集合^(12),尽管我们会看到在 Python 3 中它绝对是。

range是一种用于表示整数的算术级数的序列类型。范围是通过调用range()构造函数创建的,没有文字形式。通常我们只提供停止值,因为 Python 默认为零起始值:

>>> range(5)
range(0, 5)

范围有时用于创建连续的整数,用作循环计数器:

>>> for i in range(5):
...     print(i)
...
0
1
2
3
4

请注意,提供给range()的停止值比序列的末尾多一个,这就是为什么之前的循环没有打印 5 的原因。

起始值

如果需要,我们还可以提供一个起始值:

>>> range(5, 10)
range(5, 10)

将这个放在list()构造函数中是一种强制生成每个项目的方便方式:

>>> list(range(5, 10))
[5, 6, 7, 8, 9]

这种所谓的半开放范围约定 - 停止值不包括在序列中 - 乍看起来很奇怪,但如果你处理连续范围,它实际上是有道理的,因为一个范围指定的结束是下一个范围的开始:

>>> list(range(10, 15))
[10, 11, 12, 13, 14]
>>> list(range(5, 10)) + list(range(10, 15))
[5, 6, 7, 8, 9, 10, 11, 12, 13, 14]

步长参数

Range 还支持步长参数:

>>> list(range(0, 10, 2))
[0, 2, 4, 6, 8]

请注意,为了使用步长参数,我们必须提供所有三个参数。范围很奇怪,因为它通过计算其参数来确定它们的含义。只提供一个参数意味着该参数是stop值。两个参数是startstop,三个参数是startstopstep。Python range()以这种方式工作,因此第一个参数start可以是可选的,这在通常情况下是不可能的。此外,range构造函数不支持关键字参数。你几乎可以说它是不符合 Python 风格的!

arguably unPythonic constructor for range, where the interpretation of the arguments depends on whether one, two, or three are provided.

对于范围的构造函数,这可能是不符合 Python 风格的,因为参数的解释取决于提供了一个、两个还是三个参数。

不使用rangeenumerate()

在这一点上,我们将向您展示另一个样式不佳的代码示例,但这次是您可以,也应该避免的。这是一个打印列表中元素的不好的方法:

>>> s = [0, 1, 4, 6, 13]
>>> for i in range(len(s)):
...     print(s[i])
...
0
1
4
6
13

尽管这样做是有效的,但绝对不是 Pythonic 的。始终更喜欢使用对象本身的迭代:

>>> s = [0, 1, 4, 6, 13]
>>> for v in s:
...     print(v)
0
1
4
6
13

如果您需要一个计数器,您应该使用内置的enumerate()函数,它返回一个可迭代的成对序列,每对都是一个tuple。每对的第一个元素是当前项目的索引,每对的第二个元素是项目本身:

>>> t = [6, 372, 8862, 148800, 2096886]
>>> for p in enumerate(t):
>>>     print(p)
(0, 6)
(1, 372)
(2, 8862)
(3, 148800)
(4, 2096886)

更好的是,我们可以使用元组解包,避免直接处理元组:

>>> for i, v in enumerate(t):
...     print("i = {}, v = {}".format(i, v))
...
i = 0, v = 6
i = 1, v = 372
i = 2, v = 8862
i = 3, v = 148800
i = 4, v = 2096886

list的操作

我们已经稍微介绍了列表,并且已经充分利用了它们。我们知道如何使用文字语法创建列表,使用append()方法添加到列表中,并使用带有正数、从零开始的索引的方括号索引来获取和修改它们的内容。

零和正整数从列表的前面索引,因此索引四是列表中的第五个元素。

零和正整数从列表的前面索引,因此索引四是列表中的第五个元素。

现在我们将深入研究一下。

列表(和其他序列)的负索引

列表(以及其他 Python 序列,对于元组也适用)的一个非常方便的特性是能够从末尾而不是从开头进行索引。这是通过提供索引来实现的。例如:

>>> r = [1, -4, 10, -16, 15]
>>> r[-1]
15
>>> r[-2]
-16

负整数是从末尾向后的-1,因此索引-5 是最后但第四个元素。

负整数是从末尾向后的-1,因此索引-5 是最后但第四个元素。

这比计算正索引的笨拙等效方法要优雅得多,否则您将需要使用它来检索最后一个元素:

>>> r[len(r) - 1]

请注意,使用-0 进行索引与使用 0 进行索引相同,并返回列表中的第一个元素。由于 0 和负零之间没有区别,负索引基本上是基于 1 而不是基于 0 的。如果您正在计算具有相当复杂逻辑的索引,这一点很重要:负索引很容易出现一次性错误。^(13)

切片列表

切片是一种扩展索引的形式,允许我们引用列表的部分。为了使用它,我们传递一个半开放范围的开始和停止索引,用冒号分隔,作为方括号索引参数。这是如何做的:

>>> s = [3, 186, 4431, 74400, 1048443]
>>> s[1:3]
[186, 4431]

请注意,第二个索引超出了返回范围的末尾。

切片。切片提取列表的一部分。切片范围是半开放的,因此停止索引处的值不包括在内。

切片[1:4]。切片提取列表的一部分。切片范围是半开放的,因此停止索引处的值不包括在内。

此功能可以与负索引结合使用。例如,除了第一个和最后一个元素之外,可以获取所有元素:

>>> s[1:-1]
[186, 4431, 74400]

切片对于排除列表的第一个和最后一个元素非常有用。

切片[1:-1]对于排除列表的第一个和最后一个元素非常有用。

开始和停止索引都是可选的。要从第三个元素开始切片到列表的末尾:

>>> s[3:]
[74400, 1048443]

切片保留了从第四个元素到最后一个元素的所有元素。

切片[3:]保留了从第四个元素到最后一个元素的所有元素。

要从开头切片到第三个元素,但不包括第三个元素:

>>> s[:3]
[3, 186, 4431]

![切片[:3]保留了列表开头的所有元素,直到,

包括第四个元素。](images/m05----slice-from-beginning.png)

切片[:3]保留了列表开头的所有元素,但包括第四个元素。

请注意,这两个列表是互补的,并且一起形成整个列表,展示了半开范围约定的便利性。

切片和是互补的。

切片[:3][3:]是互补的。

由于开始和停止切片索引都是可选的,完全可以省略两者并检索所有元素:

>>> s[:]
[3, 186, 4431, 74400, 1048443]

这被称为完整切片,在 Python 中是一种重要的技术。

切片是完整切片,包含列表中的所有元素。这是一个重要的习语,用于复制列表。

切片[:]是完整切片,包含列表中的所有元素。这是一个重要的习语,用于复制列表。

复制列表

事实上,完整切片是复制列表的重要习语。请记住,分配引用永远不会复制对象,而只是复制对对象的引用:

>>> t = s
>>> t is s
True

我们使用完整切片将其复制到一个新列表中:

>>> r = s[:]

并确认使用完整切片获得的列表具有独特的身份:

>>> r is s
False

尽管它具有等效的值:

>>> r == s
True

重要的是要理解,虽然我们有一个可以独立修改的新列表对象,但其中的元素是对原始列表引用的相同对象的引用。如果这些对象都是可变的并且被修改(而不是替换),则更改将在两个列表中都可见。

我们展示这种完整切片列表复制习语,因为您可能会在实际应用中看到它,而且它的作用并不是立即明显的。您应该知道还有其他更可读的复制列表的方法,比如copy()方法:

>>> u = s.copy()
>>> u is s
False

或者简单调用列表构造函数,传递要复制的列表:

>>> v = list(s)

在这些技术之间的选择在很大程度上是品味的问题。我们更偏好使用列表构造函数的第三种形式,因为它具有使用任何可迭代系列作为源的优势,而不仅仅是列表。

浅复制

然而,您必须意识到,所有这些技术都执行复制。也就是说,它们创建一个新的列表,其中包含对源列表中相同对象的引用,但它们不复制被引用的对象。为了证明这一点,我们将使用嵌套列表,其中内部列表充当可变对象。这是一个包含两个元素的列表,每个元素本身都是一个列表:

>>> a = [ [1, 2], [3, 4] ]

我们使用完整切片复制这个列表:

>>> b = a[:]

并且让我们确信我们实际上有不同的列表:

>>> a is b
False

具有等效值:

>>> a == b
True

然而,请注意,这些不同列表中的引用不仅指向等效对象:

>>> a[0]
[1, 2]
>>> b[0]
[1, 2]

但实际上是指向相同的对象:

>>> a[0] is b[0]
True

复制是浅层的。当复制列表时,对包含对象的引用(黄色菱形)进行复制,但被引用的对象(蓝色矩形)不会被复制。

复制是浅层的。当复制列表时,对包含对象的引用(黄色菱形)进行复制,但被引用的对象(蓝色矩形)不会被复制。

这种情况持续到我们将a的第一个元素重新绑定到一个新构造的列表为止:

>>> a[0] = [8, 9]

现在,ab的第一个元素指向不同的列表:

>>> a[0]
[8, 9]
>>> b[0]
[1, 2]

列表和的第一个元素现在是唯一拥有的,而第二个元素是共享的。

列表ab的第一个元素现在是唯一拥有的,而第二个元素是共享的。

ab的第二个元素仍然指向相同的对象。我们将通过a列表对该对象进行变异来证明这一点:

>>> a[1].append(5)
>>> a[1]
[3, 4, 5]

我们看到改变通过b列表反映出来:

>>> b[1]
[3, 4, 5]

修改两个列表所引用的对象。

修改两个列表所引用的对象。

为了完整起见,这是ab列表的最终状态:

>>> a
[[8, 9], [3, 4, 5]]
>>> b
[[1, 2], [3, 4, 5]]

列表的最终状态。

列表a的最终状态。

列表的最终状态。

列表b的最终状态。

如果需要对这样的层次数据结构执行真正的深层复制-根据我们的经验,这种情况很少见-我们建议查看 Python 标准库中的copy模块。

重复列表

与字符串和元组一样,列表支持使用乘法运算符进行重复。很容易使用:

>>> c = [21, 37]
>>> d = c * 4
>>> d
[21, 37, 21, 37, 21, 37, 21, 37]

尽管在这种形式中很少见。它最常用于将已知大小的列表初始化为常量值,例如零:

>>> [0] * 9
[0, 0, 0, 0, 0, 0, 0, 0, 0]

但要注意,在可变元素的情况下,这里也存在同样的陷阱,因为重复将重复对每个元素的引用,而不是复制值。让我们再次使用嵌套列表作为我们的可变元素来演示:

>>> s = [ [-1, +1] ] * 5
>>> s
[[-1, 1], [-1, 1], [-1, 1], [-1, 1], [-1, 1]]

重复是浅层的。

重复是浅层的。

如果我们现在修改外部列表的第三个元素:

>>> s[2].append(7)

我们通过外部列表元素的所有五个引用看到了变化:

>>> s
[[-1, 1, 7], [-1, 1, 7], [-1, 1, 7], [-1, 1, 7], [-1, 1, 7]]

改变列表中重复内容的变异。对对象的任何更改都会反映在外部列表的每个索引中。

改变列表中重复内容的变异。对对象的任何更改都会反映在外部列表的每个索引中。

使用index()查找列表元素

要在列表中找到一个元素,使用index()方法并传递你要搜索的对象。元素将被比较直到找到你要找的那个:

>>> w = "the quick brown fox jumps over the lazy dog".split()
>>> w
['the', 'quick', 'brown', 'fox', 'jumps', 'over', 'the', 'lazy', 'dog']
>>> i = w.index('fox')
>>> i
3
>>> w[i]
'fox'

如果搜索一个不存在的值,你会收到一个ValueError

>>> w.index('unicorn')
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
ValueError: 'unicorn' is not in list

我们将在第六章学习如何优雅地处理这些错误。

使用count()in进行成员测试。

另一种搜索的方法是使用count()来计算匹配的元素:

>>> w.count("the")
2

如果只想测试成员资格,可以使用in运算符:

>>> 37 in [1, 78, 9, 37, 34, 53]
True

或者使用not in进行非成员测试:

>>> 78 not in [1, 78, 9, 37, 34, 53]
False

使用del按索引删除列表元素

使用一个我们尚未熟悉的关键字来删除元素:deldel关键字接受一个参数,即对列表元素的引用,并将其从列表中删除,从而缩短列表:

>>> u = "jackdaws love my big sphinx of quartz".split()
>>> u
['jackdaws', 'love', 'my', 'big', 'sphinx', 'of', 'quartz']
>>> del u[3]
>>> u
['jackdaws', 'love', 'my', 'sphinx', 'of', 'quartz']

使用remove()按值删除列表元素

也可以使用remove()方法按值而不是按位置删除元素:

>>> u.remove('jackdaws')
>>> u
['love', 'my', 'sphinx', 'of', 'quartz']

这相当于更冗长的形式:

>>> del u[u.index('jackdaws')]

尝试remove()一个不存在的项目也会引发ValueError

>>> u.remove('pyramid')
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
ValueError: list.remove(x): x not in list

插入到列表

可以使用insert()方法将项目插入列表,该方法接受新项目的索引和新项目本身:

>>> a = 'I accidentally the whole universe'.split()
>>> a
['I', 'accidentally', 'the', 'whole', 'universe']
>>> a.insert(2, "destroyed")
>>> a
['I', 'accidentally', 'destroyed', 'the', 'whole', 'universe']
>>> ' '.join(a)
'I accidentally destroyed the whole universe'

连接列表

使用加法运算符连接列表会产生一个新的列表,而不会修改任何操作数:

>>> m = [2, 1, 3]
>>> n = [4, 7, 11]
>>> k = m + n
>>> k
[2, 1, 3, 4, 7, 11]

增强赋值运算符+=会就地修改被赋值的对象:

>>> k += [18, 29, 47]
>>> k
[2, 1, 3, 4, 7, 11, 18, 29, 47]

也可以使用extend()方法来实现类似的效果:

>>> k.extend([76, 129, 199])
>>> k
[2, 1, 3, 4, 7, 11, 18, 29, 47, 76, 123, 199]

增强赋值和extend()方法将与右侧的任何可迭代系列一起工作。

重新排列list元素

在我们离开列表之前,让我们看看两个可以就地重新排列元素的操作:反转和排序。

可以通过调用reverse()方法来就地反转列表:

>>> g = [1, 11, 21, 1211, 112111]
>>> g.reverse()
>>> g
[112111, 1211, 21, 11, 1]

可以使用sort()方法就地对列表进行排序:

>>> d = [5, 17, 41, 29, 71, 149, 3299, 7, 13, 67]
>>> d.sort()
>>> d
[5, 7, 13, 17, 29, 41, 67, 71, 149, 3299]

sort()方法接受两个可选参数,keyreverse。后者不言自明,当设置为True时,会进行降序排序:

>>> d.sort(reverse=True)
>>> d
[3299, 149, 71, 67, 41, 29, 17, 13, 7, 5]

key参数更有趣。它接受任何可调用对象,然后用于从每个项目中提取。然后根据这些键的相对顺序对项目进行排序。在 Python 中有几种类型的可调用对象,尽管到目前为止我们遇到的唯一一种是谦卑的函数。例如,len()函数是一个可调用对象,用于确定集合的长度,例如字符串。

考虑以下单词列表:

>>> h = 'not perplexing do handwriting family where I illegibly know doctors'.split()
>>> h
['not', 'perplexing', 'do', 'handwriting', 'family', 'where', 'I', 'illegibly', 'know\
', 'doctors']
>>> h.sort(key=len)
>>> h
['I', 'do', 'not', 'know', 'where', 'family', 'doctors', 'illegibly', 'perplexing', '\
handwriting']
>>> ' '.join(h)
'I do not know where family doctors illegibly perplexing handwriting'

不在原地重新排列

有时候,in situ排序或反转并不是所需的。例如,它可能会导致函数参数被修改,给函数带来混乱的副作用。对于reverse()sort()列表方法的 out-of-place 等价物,可以使用reversed()sorted()内置函数,它们分别返回一个反向迭代器和一个新的排序列表。例如:

>>> x = [4, 9, 2, 1]
>>> y = sorted(x)
>>> y
[1, 2, 4, 9]
>>> x
[4, 9, 2, 1]

和:

>>> p = [9, 3, 1, 0]
>>> q = reversed(p)
>>> q
<list_reverseiterator object at 0x1007bf290>
>>> list(q)
[0, 1, 3, 9]

注意我们如何使用列表构造函数来评估reversed()的结果。这是因为reversed()返回一个迭代器,这是我们以后会更详细地讨论的一个主题。

这些函数的优点是它们可以用于任何有限的可迭代源对象。

字典

现在我们将回到字典,它是许多 Python 程序的核心,包括 Python 解释器本身。我们之前简要地看过字面上的字典,看到它们用花括号界定,并包含逗号分隔的键值对,每对由冒号绑定在一起:

>>> urls = {'Google': 'http://google.com',
...         'Twitter': 'http://twitter.com',
...         'Sixty North': 'http://sixty-north.com',
...         'Microsoft': 'http://microsoft.com' }
>>>

一个 URL 字典。字典键的顺序不被保留。

一个 URL 字典。字典键的顺序不被保留。

值可以通过键访问:

>>> urls['Twitter']
http://twitter.com

由于每个键只与一个值相关联,并且查找是通过键进行的,因此在任何单个字典中,键必须是唯一的。但是,拥有重复的值是可以的。

在内部,字典维护了对键对象和值对象的引用对。键对象必须是不可变的,所以字符串、数字和元组都可以,但列表不行。值对象可以是可变的,在实践中通常是可变的。我们的示例 URL 映射使用字符串作为键和值,这是可以的。

与其他集合一样,还有一个名为dict()的命名构造函数,它可以将其他类型转换为字典。我们可以使用构造函数从存储在元组中的可迭代的键值对系列中复制,就像这样:

>>> names_and_ages = [ ('Alice', 32), ('Bob', 48), ('Charlie', 28), ('Daniel', 33) ]
>>> d = dict(names_and_ages)
>>> d
{'Charlie': 28, 'Bob': 48, 'Alice': 32, 'Daniel': 33}

请记住,字典中的项目不以任何特定顺序存储,因此列表中的项目顺序不被保留。

只要键是合法的 Python 标识符,甚至可以直接从传递给dict()的关键字参数创建字典:

>>> phonetic = dict(a='alfa', b='bravo', c='charlie', d='delta', e='echo', f='foxtrot\
')
>>> phonetic
{'a': 'alfa', 'c': 'charlie', 'b': 'bravo', 'e': 'echo', 'd': 'delta', 'f': 'foxtrot'}

同样,关键字参数的顺序不被保留。

复制字典

与列表一样,默认情况下字典复制是浅复制,只复制对键和值对象的引用,而不是对象本身。有两种复制字典的方法,我们最常见的是第二种。第一种技术是使用copy()方法:

>>> d = dict(goldenrod=0xDAA520, indigo=0x4B0082, seashell=0xFFF5EE)
>>> e = d.copy()
>>> e
{'indigo': 4915330, 'goldenrod': 14329120, 'seashell': 16774638}

第二种方法是将现有的字典传递给dict()构造函数:

>>> f = dict(e)
>>> f
{'indigo': 4915330, 'seashell': 16774638, 'goldenrod': 14329120}

更新字典

如果需要使用另一个字典的定义来扩展字典,可以使用update()方法。这个方法被调用在要更新的字典上,并传递要合并的字典的内容:

>>> g = dict(wheat=0xF5DEB3, khaki=0xF0E68C, crimson=0xDC143C)
>>> f.update(g)
>>> f
>>> {'crimson': 14423100, 'indigo': 4915330, 'goldenrod': 14329120,
      'wheat': 16113331, 'khaki': 15787660, 'seashell': 16774638}

如果update()的参数包括已经存在于目标字典中的键,则这些键关联的值将被源字典中对应的值替换掉:

>>> stocks = {'GOOG': 891, 'AAPL': 416, 'IBM': 194}
>>> stocks.update({'GOOG': 894, 'YHOO': 25})
>>> stocks
{'YHOO': 25, 'AAPL': 416, 'IBM': 194, 'GOOG': 894}

遍历字典键

正如我们在前面的章节中看到的,字典是可迭代的,因此可以与 for 循环一起使用。字典在每次迭代中只产生一个,我们需要使用方括号运算符进行查找来检索相应的值:

>>> colors = dict(aquamarine='#7FFFD4', burlywood='#DEB887',
...               chartreuse='#7FFF00', cornflower='#6495ED',
...               firebrick='#B22222', honeydew='#F0FFF0',
...               maroon='#B03060', sienna='#A0522D')
>>> for key in colors:
...     print("{key} => {value}".format(key=key, value=colors[key]))
...
firebrick => #B22222
maroon => #B03060
aquamarine => #7FFFD4
burlywood => #DEB887
honeydew => #F0FFF0
sienna => #A0522D
chartreuse => #7FFF00
cornflower => #6495ED

注意,键以任意顺序返回,既不是它们被指定的顺序,也不是任何其他有意义的排序顺序。

遍历字典值

如果我们只想遍历值,可以使用values()字典方法。这将返回一个对象,它提供了一个可迭代的视图,而不会导致值被复制:

>>> for value in colors.values():
...     print(value)
...
#B22222
#B03060
#7FFFD4
#DEB887
#F0FFF0
#A0522D
#DEB887
#6495ED

没有有效或方便的方法来从值中检索相应的,所以我们只打印值

为了对称起见,还有一个keys()方法,尽管由于直接对字典对象进行迭代会产生键,因此这种方法不太常用:

>>> for key in colors.keys():
...     print(key)
...
firebrick
maroon
aquamarine
burlywood
honeydew
sienna
chartreuse
cornflower

遍历键值对

通常,我们想要同时遍历键和值。字典中的每个键值对称为,我们可以使用items()字典方法获得项的可迭代视图。当迭代items()视图时,会将每个键值对作为一个元组产生。通过在 for 语句中使用元组解包,我们可以在一次操作中获取键和值,而无需额外查找:

>>> for key, value in colors.items():
...     print("{key} => {value}".format(key=key, value=value))
...
firebrick => #B22222
maroon => #B03060
aquamarine => #7FFFD4
burlywood => #DEB887
honeydew => #F0FFF0
sienna => #A0522D
chartreuse => #DEB887
cornflower => #6495ED

用于字典键的成员测试

使用innot in运算符对字典的成员测试适用于键:

>>> symbols = dict(
...     usd='\u0024', gbp='\u00a3', nzd='\u0024', krw='\u20a9',
...     eur='\u20ac', jpy='\u00a5',  nok='kr', hhg='Pu', ils='\u20aa')
>>> symbols
{'jpy': '¥', 'krw': '₩', 'eur': '€', 'ils': '₪', 'nzd': '$', 'nok': 'kr',
  'gbp': '£', 'usd': '$', 'hhg': 'Pu'}
>>> 'nzd' in symbols
True
>>> 'mkd' not in symbols
True

移除字典条目

至于列表,要从字典中删除条目,我们使用del关键字:

>>> z = {'H': 1, 'Tc': 43, 'Xe': 54, 'Un': 137, 'Rf': 104, 'Fm': 100}
>>> del z['Un']
>>> z
{'H': 1, 'Fm': 100, 'Rf': 104, 'Xe': 54, 'Tc': 43}

字典的可变性

字典中的键应该是不可变的,尽管值可以被修改。这是一个将元素符号映射到该元素不同同位素的质量数列表的字典:

>>> m = {'H': [1, 2, 3],
...      'He': [3, 4],
...      'Li': [6, 7],
...      'Be': [7, 9, 10],
...      'B': [10, 11],
...      'C': [11, 12, 13, 14]}

看看我们如何将字典文字分成多行。这是允许的,因为字典文字的花括号是开放的。

我们的字符串键是不可变的,这对于字典的正确功能是件好事。但是,如果我们发现一些新的同位素,修改字典的值也没有问题:

>>> m['H'] += [4, 5, 6, 7]
>>> m
{'H': [1, 2, 3, 4, 5, 6, 7], 'Li': [6, 7], 'C': [11, 12, 13, 14], 'B':
[10, 11], 'He': [3, 4], 'Be': [7, 9, 10]}

在这里,增强赋值运算符应用于通过‘H’(表示氢)键访问的列表对象;字典没有被修改。

当然,字典本身是可变的;我们知道可以添加新的条目:

>>> m['N'] = [13, 14, 15]

漂亮的打印

对于复合数据结构,比如我们的同位素表,将它们以更可读的形式打印出来会很有帮助。我们可以使用 Python 标准库中的漂亮打印模块pprint来做到这一点,其中包含一个名为pprint的函数:

>>> from pprint import pprint as pp

请注意,如果我们没有将pprint函数绑定到另一个名称pp,函数引用将覆盖模块引用,阻止进一步访问模块的内容^(14):

>>> pp(m)
{'B': [10, 11],
  'Be': [7, 9, 10],
  'C': [11, 12, 13, 14],
  'H': [1, 2, 3, 4, 5, 6, 7],
  'He': [3, 4],
  'Li': [6, 7],
  'N': [13, 14, 15]}

给我们提供了一个更易理解的显示。

让我们离开字典,看看一个新的内置数据结构,set

set - 一个无序的唯一元素的集合

set数据类型是一个无序的唯一元素的集合。集合是可变的,因为可以向集合添加和移除元素,但每个元素本身必须是不可变的,就像字典的键一样。

集合是无序的不同元素的组合。

集合是无序的不同元素的组合。

集合的文字形式与字典非常相似,同样由花括号括起来,但每个项目都是单个对象,而不是由冒号连接的一对对象:

>>> p = {6, 28, 496, 8128, 33550336}

请注意,与字典一样,set是无序的。

>>> p
{33550336, 8128, 28, 496, 6}

当然,集合的类型是set

>>> type(p)
<class 'set'>

集合构造函数

请记住,有点令人困惑的是,空花括号创建的是一个空的字典,而不是一个空的集合:

>>> d = {}
>>> type(d)
<class 'dict'>

要创建一个空集合,我们必须使用set()构造函数:

>>> e = set()
>>> e
set()

这也是 Python 对我们空集合的回显形式。

set()构造函数可以从任何可迭代序列(如列表)创建集合:

>>> s = set([2, 4, 16, 64, 4096, 65536, 262144])
>>> s
{64, 4096, 2, 4, 65536, 16, 262144}

输入序列中的重复项将被丢弃。事实上,集合的常见用途是从对象序列中高效地移除重复项:

>>> t = [1, 4, 2, 1, 7, 9, 9]
>>> set(t)
{1, 2, 4, 9, 7}

遍历集合

当然,集合是可迭代的,尽管顺序是任意的:

>>> for x in {1, 2, 4, 8, 16, 32}:
>>>     print(x)
32
1
2
4
8
16

集合的成员测试

成员测试是集合的基本操作,与其他集合类型一样,使用innot in运算符执行:

>>> q = { 2, 9, 6, 4 }
>>> 3 in q
False
>>> 3 not in q
True

向集合添加元素

要向集合添加单个元素,请使用add()方法:

>>> k = {81, 108}
>>> k
{81, 108}
>>> k.add(54)
>>> k
{81, 108, 54}
>>> k.add(12)
>>> k
{81, 108, 54, 12}

添加已经存在的元素不会产生任何效果:

>>> k.add(108)

尽管也不会产生错误。

可以一次性从任何可迭代序列中添加多个元素,包括另一个集合,使用update()方法:

>>> k.update([37, 128, 97])
>>> k
{128, 81, 37, 54, 97, 12, 108}

从集合中移除元素

提供了两种方法来从集合中删除元素。第一种remove()要求要删除的元素必须存在于集合中,否则会给出KeyError

>>> k.remove(97)
>>> k
{128, 81, 37, 54, 12, 108}
>>> k.remove(98)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
KeyError: 98

第二种方法discard()不那么挑剔,如果元素不是集合的成员,则没有影响:

>>> k.discard(98)
>>> k
{128, 81, 37, 54, 12, 108}

复制集合

与其他内置集合一样,set具有copy()方法,执行集合的浅复制(复制引用而不是对象):

>>> j = k.copy()
>>> j
{128, 81, 37, 54, 108, 12}

正如我们已经展示的,可以使用set()构造函数:

>>> m = set(j)
>>> m
{128, 81, 37, 54, 108, 12}

集合代数操作

也许集合类型最有用的方面是提供的一组强大的集合代数操作。这些操作使我们能够轻松计算集合的并集、差集和交集,并评估两个集合是否具有子集、超集或不相交的关系。

为了演示这些方法,我们将根据不同的表型构建一些人的集合:

>>> blue_eyes = {'Olivia', 'Harry', 'Lily', 'Jack', 'Amelia'}
>>> blond_hair = {'Harry', 'Jack', 'Amelia', 'Mia', 'Joshua'}
>>> smell_hcn = {'Harry', 'Amelia'}
>>> taste_ptc = {'Harry', 'Lily', 'Amelia', 'Lola'}
>>> o_blood = {'Mia', 'Joshua', 'Lily', 'Olivia'}
>>> b_blood = {'Amelia', 'Jack'}
>>> a_blood = {'Harry'}
>>> ab_blood = {'Joshua', 'Lola'}

集合代数操作。

集合代数操作。

联合

要查找所有金发、蓝眼睛或两者都有的人,我们可以使用union()方法:

>>> blue_eyes.union(blond_hair)
{'Olivia', 'Jack', 'Joshua', 'Harry', 'Mia', 'Amelia', 'Lily'}

集合并集将所有在两个集合中的元素收集在一起。

我们可以演示union()是可交换操作(即,我们可以交换操作数的顺序),使用值相等运算符来检查结果集的等价性:

>>> blue_eyes.union(blond_hair) == blond_hair.union(blue_eyes)
True

交集

要找到所有金发蓝眼睛的人,我们可以使用intersection()方法:

>>> blue_eyes.intersection(blond_hair)
{'Amelia', 'Jack', 'Harry'}

它只收集两个集合中都存在的元素。

这也是可交换的:

>>> blue_eyes.intersection(blond_hair) == blond_hair.intersection(blue_eyes)
True

差异

要识别金发但没有蓝眼睛的人,我们可以使用difference()方法:

>>> blond_hair.difference(blue_eyes)
{'Joshua', 'Mia'}

这找到了第一个集合中存在但不在第二个集合中的所有元素。

这是非交换的,因为金发但没有蓝眼睛的人与有蓝眼睛但没有金发的人不同:

>>> blond_hair.difference(blue_eyes) == blue_eyes.difference(blond_hair)
False

对称差

然而,如果我们想确定哪些人只有金发蓝眼睛,但不是两者都有,我们可以使用symmetric_difference()方法:

>>> blond_hair.symmetric_difference(blue_eyes)
{'Olivia', 'Joshua', 'Mia', 'Lily'}

这收集了第一个集合第二个集合中存在的所有元素,但不是两者都有。

从名称上可以看出,symmetric_difference()确实是可交换的:

>>> blond_hair.symmetric_difference(blue_eyes) == blue_eyes.symmetric_difference(blon\
d_hair)
True

子集关系

设置关系。

设置关系。

此外,还提供了三种谓词方法,告诉我们集合之间的关系。我们可以使用issubset()方法检查一个集合是否是另一个集合的子集。例如,要检查所有能闻到氰化氢的人是否也有金发:

>>> smell_hcn.issubset(blond_hair)
True

这检查第一个集合中的所有元素是否也存在于第二个集合中。

要测试所有能品尝苯硫脲(PTC)的人是否也能闻到氰化氢,使用issuperset()方法:

>>> taste_ptc.issuperset(smell_hcn)
True

这检查第二个集合中的所有元素是否都存在于第一个集合中。

苯硫脲(PTC)的表示。它具有不寻常的特性,即根据品尝者的遗传学,它可能非常苦或几乎没有味道。

苯硫脲(PTC)的表示。它具有不寻常的特性,即根据品尝者的遗传学,它可能非常苦或几乎没有味道。

要测试两个集合是否没有共同成员,使用isdisjoint()方法。例如,你的血型要么是 A 型,要么是 O 型,永远不会同时有:

>>> a_blood.isdisjoint(o_blood)
True

集合协议

在 Python 中,协议是类型必须支持的一组操作或方法。协议不需要在源代码中定义为单独的接口或基类,就像在 C#或 Java 等名义类型的语言中那样。只要对象提供这些操作的功能实现即可。

我们可以根据它们支持的协议来组织我们在 Python 中遇到的不同集合:

协议 实现集合
容器 str, list, dict, range, tuple, set, bytes
大小 str, list, dict, range, tuple, set, bytes
可迭代 str, list, dict, range, tuple, set, bytes
序列 str, list, tuple, range, bytes
可变序列 list
可变集 set
可变映射 dict

对协议的支持要求类型具有特定的行为。

容器协议

容器协议要求支持使用innot in运算符进行成员测试:

item in container
item not in container

大小协议

大小协议要求可以通过调用len(sized_collection)来确定集合中的元素数量。

可迭代协议

迭代是一个如此重要的概念,我们在本书的后面专门为它开辟了一个章节。简而言之,可迭代提供了一种逐个产生元素的方法,只要它们被请求。

可迭代的一个重要特性是它们可以与 for 循环一起使用:

for item in iterable:
    print(item)

序列协议

序列协议要求可以使用整数索引和方括号检索项目:

item = sequence[index]

可以使用index()搜索项目:

i = sequence.index(item)

可以使用count()对项目进行计数:

num = sequence.count(item)

并且可以使用reversed()生成序列的反向副本:

r = reversed(sequence)

此外,序列协议要求对象支持可迭代大小容器

其他协议

我们不会在这里涵盖可变序列可变映射可变集。由于我们只涵盖了每个协议的一个代表类型,协议概念所提供的一般性在这一时刻并没有给我们带来太多好处。

总结

  • 元组是不可变的序列类型

  • 文字语法是可选的,可以在逗号分隔的列表周围加上括号。

  • 单个元组的值使用尾随逗号的特殊语法。

  • 元组解包 - 用于多个返回值和交换

  • 字符串

  • 字符串连接最有效的方法是使用join()方法,而不是使用加法或增强赋值运算符。

  • partition()方法是一个有用且优雅的字符串解析工具。

  • format()方法提供了一个强大的方法,用字符串化的值替换占位符。

  • 范围

  • range对象表示算术级数。

  • enumerate()内置函数通常是生成循环计数器的一个更好的选择,而不是range()

  • 列表

  • 列表支持使用负索引从列表末尾进行索引

  • 切片语法允许我们复制列表的全部或部分。

  • 完整切片是 Python 中常见的习语,尽管copy()方法和list()构造函数不那么晦涩。

  • Python 中的列表(和其他集合)副本是浅层副本。引用被复制,但被引用的对象没有被复制。

  • 字典从键映射到值

  • 对字典进行迭代和成员测试是针对键进行的。

  • keys()values()items()方法提供了对字典不同方面的视图,允许方便的迭代。

  • 集合存储无序的唯一元素集合。

  • 集合支持强大的集合代数操作和谓词。

  • 内置的集合可以根据它们支持的协议进行组织,比如可迭代序列映射

顺便说一句,我们还发现:

  • 下划线通常用于虚拟或多余的变量

  • pprint模块支持复杂数据结构的漂亮打印。

第七章:异常

异常处理是一种停止“正常”程序流程并在某个周围上下文或代码块中继续的机制。

中断正常流程的行为称为“引发”异常。在某个封闭的上下文中,引发的异常必须被处理,这意味着控制流被转移到异常处理程序。如果异常传播到程序的起始点,那么未处理的异常将导致程序终止。异常对象包含有关异常事件发生的位置和原因的信息,被从引发异常的点传输到异常处理程序,以便处理程序可以询问异常对象并采取适当的行动。

如果您已经在其他流行的命令式语言(如 C++或 Java)中使用过异常,那么您已经对 Python 中异常的工作原理有了一个很好的了解。

关于什么构成“异常事件”的长期而令人厌倦的辩论一直存在,核心问题是异常性实际上是一个程度的问题(有些事情比其他事情更异常)。这是有问题的,因为编程语言通过坚持事件要么完全异常要么根本不异常的假二分法来强加了一个错误的二分法。

Python 的哲学在使用异常方面处于自由的一端。异常在 Python 中无处不在,了解如何处理异常至关重要。

异常和控制流

由于异常是一种控制流的手段,在 REPL 中演示可能会很笨拙,因此在本章中,我们将使用 Python 模块来包含我们的代码。让我们从一个非常简单的模块开始,以便探索这些重要的概念和行为。将以下代码放入名为exceptional.py的模块中:

"""A module for demonstrating exceptions."""

def convert(s):
    """Convert to an integer."""
    x = int(s)
    return x

将此模块中的convert()函数导入 Python REPL 中:

$ python3
Python 3.5.1 (v3.5.1:37a07cee5969, Dec  5 2015, 21:12:44)
[GCC 4.2.1 (Apple Inc. build 5666) (dot 3)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>> from exceptional import convert

并使用一个字符串调用我们的函数,以查看它是否产生了预期的效果:

>>> convert("33")
33

如果我们使用无法转换为整数的对象调用我们的函数,我们将从int()调用中获得一个回溯:

>>> convert("hedgehog")
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "./exceptional.py", line 7, in convert
    x = int(s)
ValueError: invalid literal for int() with base 10: 'hedgehog'

这里发生的是int()引发了一个异常,因为它无法合理地执行转换。我们没有设置处理程序,所以它被 REPL 捕获并显示了堆栈跟踪。换句话说,异常未被处理。

堆栈跟踪中提到的ValueError是异常对象的类型,错误消息"invalid literal for int() with base 10: 'hedgehog'"是异常对象的有效负载的一部分,已被 REPL 检索并打印。

请注意,异常在调用堆栈中传播了几个级别:

调用堆栈 效果
int() 异常在此引发
convert() 异常在这里概念上通过
REPL 异常在这里被捕获

处理异常

让我们通过使用try..except结构来使我们的convert()函数更加健壮,处理ValueErrortryexcept关键字都引入了新的代码块。try块包含可能引发异常的代码,except块包含在引发异常时执行错误处理的代码。修改convert()函数如下:

def convert(s):
    """Convert a string to an integer."""
    try:
        x = int(s)
    except ValueError:
        x = -1
    return x

我们已经决定,如果提供了一个非整数字符串,我们将返回负一。为了加强您对控制流的理解,我们还将添加一些打印语句:

def convert(s):
    """Convert a string to an integer."""
    try:
        x = int(s)
        print("Conversion succeeded! x =", x)
    except ValueError:
        print("Conversion failed!")
        x = -1
    return x

让我们在重新启动 REPL 后进行交互式测试:

>>> from exceptional import convert
>>> convert("34")
Conversion succeeded! x = 34
34
>>> convert("giraffe")
Conversion failed!
-1

请注意,当我们将'giraffe'作为函数参数传递时,try块中在引发异常后的print()没有被执行。相反,执行直接转移到了except块的第一条语句。

int()构造函数只接受数字或字符串,所以让我们看看如果我们将另一种类型的对象,比如列表,传递给它会发生什么:

>>> convert([4, 6, 5])
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "./exceptional.py", line 8, in convert
    x = int(s)
TypeError: int() argument must be a string or a number, not 'list'

这次我们的处理程序没有拦截异常。如果我们仔细看跟踪,我们会发现这次我们收到了一个TypeError - 一种不同类型的异常。

处理多个异常

每个try块可以有多个对应的except块,拦截不同类型的异常。让我们也为TypeError添加一个处理程序:

def convert(s):
    """Convert a string to an integer."""
    try:
        x = int(s)
        print("Conversion succeeded! x =", x)
    except ValueError:
        print("Conversion failed!")
        x = -1
    except TypeError:
        print("Conversion failed!")
        x = -1
    return x

现在,如果我们在一个新的 REPL 中重新运行相同的测试,我们会发现TypeError也被处理了:

>>> from exceptional import convert
>>> convert([1, 3, 19])
Conversion failed!
-1

我们的两个异常处理程序之间存在一些代码重复,有重复的print语句和赋值。我们将赋值移到try块的前面,这不会改变程序的行为:

def convert(s):
    """Convert a string to an integer."""
    x = -1
    try:
        x = int(s)
        print("Conversion succeeded! x =", x)
    except ValueError:
        print("Conversion failed!")
    except TypeError:
        print("Conversion failed!")
    return x

然后我们将利用except语句接受异常类型元组的能力,将两个处理程序合并为一个:

def convert(s):
    """Convert a string to an integer."""
    x = -1
    try:
        x = int(s)
        print("Conversion succeeded! x =", x)
    except (ValueError, TypeError):
        print("Conversion failed!")
    return x

现在我们看到一切仍然按设计工作:

>>> from exceptional import convert
>>> convert(29)
Conversion succeeded! x = 29
29
>>> convert("elephant")
Conversion failed!
-1
>>> convert([4, 5, 1])
Conversion failed!
-1

程序员错误

既然我们对异常行为的控制流感到自信,我们可以删除打印语句了:

def convert(s):
    """Convert a string to an integer."""
    x = -1
    try:
        x = int(s)
    except (ValueError, TypeError):
    return x

但是现在当我们尝试导入我们的程序时:

>>> from exceptional import convert
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "./exceptional.py", line 11
    return x
          ^
IndentationError: expected an indented block

我们得到了另一种类型的异常,一个IndentationError,因为我们的except块现在是空的,Python 程序中不允许空块。

这不是一个有用的异常类型,可以用except块捕获!Python 程序出现的几乎所有问题都会导致异常,但某些异常类型,比如IndentationErrorSyntaxErrorNameError,是程序员错误的结果,应该在开发过程中被识别和纠正,而不是在运行时处理。这些异常的存在大多数情况下是有用的,如果你正在创建一个 Python 开发工具,比如 Python IDE,将 Python 本身嵌入到一个更大的系统中以支持应用程序脚本,或者设计一个动态加载代码的插件系统。

空块 - pass语句

话虽如此,我们仍然有一个问题,那就是如何处理我们的空except块。解决方案以pass关键字的形式出现,这是一个什么都不做的特殊语句!它是一个空操作,它的唯一目的是允许我们构造在语法上允许但在语义上为空的块:

def convert(s):
    """Convert a string to an integer."""
    x = -1
    try:
        x = int(s)
    except (ValueError, TypeError):
        pass
    return x

不过,在这种情况下,通过使用多个return语句进一步简化会更好,完全摆脱x变量:

def convert(s):
    """Convert a string to an integer."""
    try:
        return int(s)
    except (ValueError, TypeError):
        return -1

异常对象

有时,我们想要获取异常对象 - 在这种情况下是ValueErrorTypeError类型的对象,并对其进行详细的询问出了什么问题。我们可以通过在except语句的末尾添加一个as子句并使用一个变量名来获得对异常对象的命名引用:

def convert(s):
    """Convert a string to an integer."""
    try:
        return int(s)
    except (ValueError, TypeError) as e:
        return -1

我们将修改我们的函数,在返回之前向stderr流打印异常详细信息的消息。要打印到stderr,我们需要从sys模块中获取对流的引用,所以在我们的模块顶部,我们需要import sys。然后我们可以将sys.stderr作为一个名为file的关键字参数传递给print()

import sys

def convert(s):
    """Convert a string to an integer."""
    try:
        return int(s)
    except (ValueError, TypeError) as e:
        print("Conversion error: {}".format(str(e)), file=sys.stderr)
        return -1

我们利用异常对象可以使用str()构造函数转换为字符串的事实。

让我们在 REPL 中看看:

>>> from exceptional import convert
>>> convert("fail")
Conversion error: invalid literal for int() with base 10: 'fail'
-1

轻率的返回代码

让我们在我们的模块中添加第二个函数string_log(),它调用我们的convert()函数并计算结果的自然对数:

from math import log

def string_log(s):
    v = convert(s)
    return log(v)

在这一点上,我们必须承认,我们在这里通过将完全正常的int()转换(在失败时引发异常)包装在我们的convert()函数中,返回一个老式的负错误代码,这是非常不符合 Python 风格的。请放心,这种不可饶恕的 Python 异端行为仅仅是为了展示错误返回代码的最大愚蠢:它们可以被调用者忽略,在程序的后期对毫无戒心的代码造成严重破坏。稍微好一点的程序可能会在继续进行日志调用之前测试v的值。

如果没有这样的检查,当传递负错误代码值时,log()当然会失败:

>>> from exceptional import string_log
>>> string_log("ouch!")
Conversion error: invalid literal for int() with base 10: 'ouch!'
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "./exceptional.py", line 15, in string_log
    return log(v)
ValueError: math domain error

当然,log()失败的后果是引发另一个异常,也是ValueError

更好,而且更符合 Python 风格的是,完全忘记错误返回代码,并恢复到从convert()引发异常。

重新引发异常

我们可以发出我们的错误消息并重新引发我们当前正在处理的异常对象,而不是返回一个非 Python 风格的错误代码。这可以通过在我们的异常处理块的末尾用raise语句替换return -1来完成:

def convert(s):
    """Convert a string to an integer."""
    try:
        return int(s)
    except (ValueError, TypeError) as e:
        print("Conversion error: {}".format(str(e)), file=sys.stderr)
        raise

没有参数raise重新引发当前正在处理的异常。

在 REPL 中进行测试,我们可以看到原始异常类型被重新引发,无论是ValueError还是TypeError,我们的“Conversion error”消息都会打印到stderr

>>> from exceptional import string_log
>>> string_log("25")
3.2188758248682006
>>> string_log("cat")
Conversion error: invalid literal for int() with base 10: 'cat'
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "./exceptional.py", line 14, in string_log
    v = convert(s)
  File "./exceptional.py", line 6, in convert
    return int(s)
ValueError: invalid literal for int() with base 10: 'cat'
>>> string_log([5, 3, 1])
Conversion error: int() argument must be a string or a number, not 'list'
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "./exceptional.py", line 14, in string_log
    v = convert(s)
  File "./exceptional.py", line 6, in convert
    return int(s)
TypeError: int() argument must be a string or a number, not 'list'

异常是函数 API 的一部分

异常是函数 API 的重要组成部分。函数的调用者需要知道在各种条件下期望哪些异常,以便他们可以确保适当的异常处理程序已经就位。我们将使用寻找平方根作为示例,使用一个自制的平方根函数,由亚历山大的赫罗(尽管他可能没有使用 Python)提供。

函数的调用者需要知道期望哪些异常。

函数的调用者需要知道期望哪些异常。

将以下代码放入一个名为roots.py的文件中:

def sqrt(x):
    """Compute square roots using the method of Heron of Alexandria.

 Args:
 x: The number for which the square root is to be computed.

 Returns:
 The square root of x.
 """
    guess = x
    i = 0
    while guess * guess != x and i < 20:
        guess = (guess + x / guess) / 2.0
        i += 1
    return guess

def main():
    print(sqrt(9))
    print(sqrt(2))

if __name__ == '__main__':
    main()

在这个程序中,我们之前没有遇到过的只有一个语言特性:逻辑and运算符,我们在这种情况下使用它来测试循环的每次迭代上两个条件是否为True。Python 还包括一个逻辑or运算符,它可以用来测试它的操作数是否一个或两个都为True

运行我们的程序,我们可以看到赫罗是真的有所发现:

$ python3 roots.py
3.0
1.41421356237

Python 引发的异常

让我们在main()函数中添加一行新代码,它对-1 进行平方根运算:

def main():
    print(sqrt(9))
    print(sqrt(2))
    print(sqrt(-1))

如果我们运行它,我们会得到一个新的异常:

$ python3 sqrt.py
3.0
1.41421356237
Traceback (most recent call last):
  File "sqrt.py", line 14, in <module>
    print(sqrt(-1))
  File "sqrt.py", line 7, in sqrt
    guess = (guess + x / guess) / 2.0
ZeroDivisionError: float division

发生的情况是 Python 拦截了除零,这发生在循环的第二次迭代中,并引发了一个异常-ZeroDivisionError

捕获异常

让我们修改我们的代码,在异常传播到调用堆栈的顶部之前捕获异常(从而导致我们的程序停止),使用try..except结构:

def main():
    print(sqrt(9))
    print(sqrt(2))
    try:
        print(sqrt(-1))
    except ZeroDivisionError:
        print("Cannot compute square root of a negative number.")

    print("Program execution continues normally here.")

现在当我们运行脚本时,我们看到我们干净地处理了异常:

$ python sqrt.py
3.0
1.41421356237
Cannot compute square root of a negative number.
Program execution continues normally here.

我们应该小心避免初学者在异常处理块中使用过于严格的范围的错误;我们可以很容易地对我们所有对sqrt()的调用使用一个try..except块。我们还添加了第三个打印语句,以显示封闭块的执行是如何终止的:

def main():
    try:
        print(sqrt(9))
        print(sqrt(2))
        print(sqrt(-1))
        print("This is never printed.")
    except ZeroDivisionError:
        print("Cannot compute square root of a negative number.")

    print("Program execution continues normally here.")

显式引发异常

这是对我们开始的改进,但最有可能sqrt()函数的用户不希望它抛出ZeroDivisionError

Python 为我们提供了几种标准的异常类型来表示常见的错误。如果函数参数提供了非法值,习惯上会引发ValueError。我们可以通过使用raise关键字和通过调用ValueError构造函数创建的新异常对象来实现这一点。

我们可以处理除零的两种方法。第一种方法是将寻找平方根的 while 循环包装在try..except ZeroDivisionError结构中,然后在异常处理程序内部引发一个新的ValueError异常。

def sqrt(x):
    """Compute square roots using the method of Heron of Alexandria.

 Args:
 x: The number for which the square root is to be computed.

 Returns:
 The square root of x.
 """
    guess = x
    i = 0
    try:
        while guess * guess != x and i < 20:
            guess = (guess + x / guess) / 2.0
            i += 1
    except ZeroDivisionError:
        raise ValueError()
    return guess

虽然它可以工作,但这将是浪费的;我们会明知道继续进行一个最终毫无意义的非平凡计算。

守卫子句

我们知道这个例程总是会失败,所以我们可以在早期检测到这个前提条件,并在那一点上引发异常,这种技术称为守卫子句

def sqrt(x):
    """Compute square roots using the method of Heron of Alexandria.

 Args:
 x: The number for which the square root is to be computed.

 Returns:
 The square root of x.

 Raises:
 ValueError: If x is negative.
 """

    if x < 0:
        raise ValueError("Cannot compute square root of negative number {}".format(x))

    guess = x
    i = 0
    while guess * guess != x and i < 20:
        guess = (guess + x / guess) / 2.0
        i += 1
    return guess

测试是一个简单的 if 语句和一个调用raise传递一个新铸造的异常对象。ValueError()构造函数接受一个错误消息。看看我们如何修改文档字符串,以明确sqrt()将引发哪种异常类型以及在什么情况下。

但是看看如果我们运行程序会发生什么-我们仍然会得到一个回溯和一个不优雅的程序退出:

$ python roots.py
3.0
1.41421356237
Traceback (most recent call last):
  File "sqrt.py", line 25, in <module>
    print(sqrt(-1))
  File "sqrt.py", line 12, in sqrt
    raise ValueError("Cannot compute square root of negative number {0}".format(x))
ValueError: Cannot compute square root of negative number -1

这是因为我们忘记修改我们的异常处理程序来捕获ValueError而不是ZeroDivisionError。让我们修改我们的调用代码来捕获正确的异常类,并将捕获的异常对象分配给一个命名变量,这样我们就可以在捕获后对其进行询问。在这种情况下,我们的询问是print异常对象,它知道如何将自己显示为 stderr 的消息:

import sys

def main():
    try:
        print(sqrt(9))
        print(sqrt(2))
        print(sqrt(-1))
        print("This is never printed.")
    except ValueError as e:
        print(e, file=sys.stderr)

    print("Program execution continues normally here.")

再次运行程序,我们可以看到我们的异常被优雅地处理了:

$ python3 sqrt.py
3.0
1.41421356237
Cannot compute square root of negative number -1
Program execution continues normally here.

异常、API 和协议

异常是函数的 API 的一部分,更广泛地说,是某些协议的一部分。例如,实现序列协议的对象应该为超出范围的索引引发IndexError异常。

引发的异常与函数的参数一样,是函数规范的一部分,必须适当地记录。

Python 中有几种常见的异常类型,通常当您需要在自己的代码中引发异常时,内置类型之一是一个不错的选择。更少见的是,您需要定义新的异常类型,但我们在本书中没有涵盖这一点。(请参阅本系列的下一本书Python Journeyman,了解如何做到这一点。)

如果您决定您的代码应该引发哪些异常,您应该在现有代码中寻找类似的情况。您的代码遵循现有模式的越多,人们集成和理解起来就越容易。例如,假设您正在编写一个键值数据库:使用KeyError来指示对不存在的键的请求是很自然的,因为这是dict的工作方式。也就是说,Python 中的“映射”集合遵循某些协议,异常是这些协议的一部分。

让我们看一些常见的异常类型。

IndexError

当整数索引超出范围时,会引发IndexError

当我们在列表末尾索引时,您可以看到这一点:

>>> z = [1, 4, 2]
>>> z[4]
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
IndexError: list index out of range

ValueError

当对象的类型正确,但包含不适当的值时,会引发ValueError

当尝试从非数字字符串构造int时,我们已经看到了这一点:

>>> int("jim")
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
ValueError: invalid literal for int() with base 10: 'jim'

KeyError

当查找映射失败时,会引发KeyError

您可以在这里看到,当我们在字典中查找一个不存在的键时:

>>> codes = dict(gb=44, us=1, no=47, fr=33, es=34)
>>> codes['de']
  Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
KeyError: 'de'

选择不防范TypeError

我们不倾向于保护 Python 中的TypeErrors。这样做违反了 Python 中的动态类型的规则,并限制了我们编写的代码的重用潜力。

例如,我们可以使用内置的isinstance()函数测试参数是否为str,如果不是,则引发TypeError异常:

def convert(s):
    """Convert a string to an integer."""
    if not isinstance(s, str):
        raise TypeError("Argument must be a string")

    try:
        return int(s)
    except (ValueError, TypeError) as e:
        print("Conversion error: {}".format(str(e)), file=sys.stderr)
        raise

但是我们还希望允许作为float实例的参数。如果我们想要检查我们的函数是否能够处理诸如有理数、复数或任何其他类型的数字的类型,情况很快就会变得复杂,而且无论如何,谁能说它会呢?!

或者我们可以在函数内部拦截TypeError并重新引发它,但是有什么意义呢?

通常不必处理 TypeErrors。

通常不必处理 TypeErrors。

通常在 Python 中,向函数添加类型检查是不值得的。如果函数使用特定类型-即使是您在设计函数时可能不知道的类型-那就太好了。如果不是,执行可能最终会导致TypeError。同样,我们往往不会非常频繁地捕获TypeErrors

Pythonic 风格- EAFP 与 LBYL

现在让我们看看 Python 哲学和文化的另一个原则,即“宁求原谅,不要问权限”。

处理可能失败的程序操作只有两种方法。第一种方法是在尝试操作之前检查所有易于失败的操作的前提条件是否满足。第二种方法是盲目地希望一切顺利,但准备好处理后果如果事情不顺利。

在 Python 文化中,这两种哲学被称为“先入为主”(LBYL)和“宁求原谅,不要问权限”(EAFP)-顺便说一句,这是由编译器发明者 Grace Hopper 女将军创造的。

Python 强烈支持 EAFP,因为它将“快乐路径”的主要逻辑以最可读的形式呈现,而与主要流程交织在一起的异常情况则单独处理。

让我们考虑一个例子-处理一个文件。处理的细节并不重要。我们只需要知道process_file()函数将打开一个文件并从中读取一些数据。

首先是 LBYL 版本:

import os

p = '/path/to/datafile.dat'

if os.path.exists(p):
    process_file(p)
else:
    print('No such file as {}'.format(p))

在尝试调用process_file()之前,我们检查文件是否存在,如果不存在,我们避免调用并打印一条有用的消息。这种方法存在一些明显的问题,有些是显而易见的,有些是隐匿的。一个明显的问题是我们只执行了存在性检查。如果文件存在但包含垃圾怎么办?如果路径指的是一个目录而不是一个文件怎么办?根据 LBYL,我们应该为这些情况添加预防性测试。

一个更微妙的问题是这里存在竞争条件。例如,文件可能在存在性检查和process_file()调用之间被另一个进程删除……这是一个经典的竞争条件。实际上没有好的方法来处理这个问题-无论如何都需要处理process_file()的错误!

现在考虑另一种选择,使用更符合 Python 风格的 EAFP 方法:

p = '/path/to/datafile.dat'

try:
    process_file(f)
except OSError as e:
  print('Could not process file because {}'.format(str(e)))

在这个版本中,我们尝试在事先不进行检查的情况下进行操作,但我们已经准备好了异常处理程序来处理任何问题。我们甚至不需要详细了解可能出现的问题。在这里,我们捕获了OSError,它涵盖了各种条件,比如文件未找到以及在期望文件的位置使用目录。

EAFP 在 Python 中是标准的,遵循这种哲学主要是通过异常来实现的。没有异常,并且被迫使用错误代码,你需要直接在逻辑的主流程中包含错误处理。由于异常中断了主流程,它们允许你非局部地处理异常情况。

异常与 EAFP 结合也更优越,因为与错误代码不同,异常不能轻易被忽略。默认情况下,异常会产生很大影响,而错误代码默认情况下是静默的。因此,基于异常/EAFP 的风格使问题很难被悄悄忽略。

清理操作

有时,你需要执行一个清理操作,无论操作是否成功。在后面的模块中,我们将介绍上下文管理器,这是这种常见情况的现代解决方案,但在这里我们将介绍try..finally结构,因为在简单情况下创建上下文管理器可能有些过头。无论如何,了解try..finally对于制作自己的上下文管理器是有用的。

考虑这个函数,它使用标准库os模块的各种功能来更改当前工作目录,创建一个新目录,并恢复原始工作目录:

import os

def make_at(path, dir_name):
    original_path = os.getcwd()
    os.chdir(path)
    os.mkdir(dir_name)
    os.chdir(original_path)

乍一看,这似乎是合理的,但是如果os.mkdir()的调用因某种原因失败,Python 进程的当前工作目录将不会恢复到其原始值,并且make_at()函数将产生意外的副作用。

为了解决这个问题,我们希望函数在任何情况下都能恢复原始的当前工作目录。我们可以通过try..finally块来实现这一点。finally块中的代码将被执行,无论执行是通过到达块的末尾而正常离开try块,还是通过引发异常而异常地离开。

这种结构可以与except块结合在一起,如下所示,用于添加一个简单的失败日志记录设施:

import os
import sys

def make_at(path, dir_name):
  original_path = os.getcwd()
  try:
      os.chdir(path)
      os.mkdir(dir_name)
  except OSError as e:
      print(e, file=sys.stderr)
      raise
  finally:
      os.chdir(original_path)

现在,如果os.mkdir()引发OSError,则将运行OSError处理程序并重新引发异常。但由于finally块始终运行,无论 try 块如何结束,我们可以确保最终的目录更改将在所有情况下发生。


禅意时刻


特定于平台的代码

从 Python 中检测单个按键 - 例如在控制台上的“按任意键继续。”功能 - 需要使用特定于操作系统的模块。我们不能使用内置的input()函数,因为它等待用户按Enter键然后给我们一个字符串。要在 Windows 上实现这一点,我们需要使用仅限于 Windows 的msvcrt模块的功能,在 Linux 和 macOS 上,我们需要使用仅限于 Unix 的ttytermios模块的功能,以及sys模块。

这个例子非常有教育意义,因为它演示了许多 Python 语言特性,包括importdef作为语句,而不仅仅是声明:

"""keypress - A module for detecting a single keypress."""

try:
    import msvcrt

    def getkey():
        """Wait for a keypress and return a single character string."""
        return msvcrt.getch()

except ImportError:

    import sys
    import tty
    import termios

    def getkey():
        """Wait for a keypress and return a single character string."""
        fd = sys.stdin.fileno()
        original_attributes = termios.tcgetattr(fd)
        try:
            tty.setraw(sys.stdin.fileno())
            ch = sys.stdin.read(1)
        finally:
            termios.tcsetattr(fd, termios.TCSADRAIN, original_attributes)
        return ch

    # If either of the Unix-specific tty or termios modules are
    # not found, we allow the ImportError to propagate from here

请记住,顶层模块代码在首次导入时执行。在第一个 try 块中,我们尝试import msvcrt,即 Microsoft Visual C Runtime。如果成功,然后我们继续定义一个名为getkey()的函数,该函数委托给msvcrt.getch()函数。即使在这一点上我们在 try 块内部,该函数也将在当前范围内声明,即模块范围。

然而,如果msvcrt的导入失败,因为我们不在 Windows 上运行,将引发ImportError,并且执行将转移到 except 块。这是一个明确消除错误的情况,因为我们将尝试在异常处理程序中采取替代行动。

在 except 块内,我们导入了三个在类 Unix 系统上实现getkey()所需的模块,然后继续使用替代定义getkey(),再次将函数实现绑定到模块范围内的名称。

这个 Unix 实现的getkey()使用try..finally结构,在将终端置于原始模式以读取单个字符的目的后,恢复各种终端属性。

如果我们的程序在既不是 Windows 也不是类 Unix 的系统上运行,import tty语句将引发第二个ImportError。这次我们不尝试拦截此异常;我们允许它传播到我们的调用者 - 无论尝试导入此keypress模块的是什么。我们知道如何发出此错误,但不知道如何处理它,因此我们将这个决定推迟给我们的调用者。错误不会悄悄地传递。

如果调用者具有更多的知识或可用的替代策略,它可以依次拦截此异常并采取适当的操作,也许降级到使用 Python 的input()内置函数并向用户提供不同的消息。

总结

  • 引发异常会中断正常的程序流程,并将控制转移到异常处理程序。

  • 异常处理程序使用try..except结构定义。

  • try块定义了可以检测异常的上下文。

  • 相应的except块为特定类型的异常定义处理程序。

  • Python 广泛使用异常,并且许多内置语言功能依赖于它们。

  • except块可以捕获异常对象,通常是标准类型,如ValueErrorKeyErrorIndexError

  • 程序员错误,如IndentationErrorSyntaxError通常不应该被处理。

  • 可以使用raise关键字发出异常条件,它接受异常对象的单个参数。

  • except块中没有参数的raise重新引发当前正在处理的异常。

  • 我们倾向于不经常检查TypeErrors。这样做会否定 Python 动态类型系统所提供的灵活性。

  • 异常对象可以使用str()构造函数转换为字符串,以便打印消息载荷。

  • 函数抛出的异常是其 API 的一部分,应该得到适当的文档支持。

  • 在引发异常时,最好使用最合适的内置异常类型。

  • 可以使用try..finally结构执行清理和恢复操作,这可能可以与except块一起使用。

在这个过程中,我们看到:

  • print()函数的输出可以使用可选的file参数重定向到stderr

  • Python 支持逻辑运算符andor来组合布尔表达式。

  • 返回代码很容易被忽略。

  • 可以使用“宁可请求原谅,也不要问权限”的方法来实现特定于平台的操作,通过拦截ImportErrors并提供替代实现。

第八章:推导式、可迭代对象和生成器

对象序列的抽象概念在编程中是无处不在的。它可以用来模拟简单的字符串、复杂对象的列表和无限长的传感器输出流等各种概念。也许你不会感到惊讶的是,Python 包含了一些非常强大和优雅的工具来处理序列。事实上,Python 对于创建和操作序列的支持是许多人认为这门语言的亮点之一。

在这一章中,我们将看到 Python 提供的三个用于处理序列的关键工具:推导式、可迭代对象和生成器。推导式包括了一个专门的语法,用于声明性地创建各种类型的序列。可迭代对象迭代协议构成了 Python 中序列和迭代的核心抽象和 API;它们允许你定义新的序列类型,并对迭代进行精细控制。最后,生成器允许我们以命令式的方式定义惰性序列,在许多情况下是一种令人惊讶的强大技术。

让我们直接进入推导式。

推导式

在 Python 中,推导式是一种简洁的语法,用于以声明性或函数式风格描述列表、集合或字典。这种简写是可读的和表达性强的,这意味着推导式非常有效地传达了人类读者的意图。一些推导式几乎读起来像自然语言,使它们成为很好的自我文档化。

列表推导式

如上所示,列表推导式是创建列表的一种简写方式。它是使用简洁的语法来描述如何定义列表元素的表达式。推导式比解释更容易演示,所以让我们打开一个 Python REPL。首先,我们将通过拆分一个字符串来创建一个单词列表:

>>> words = "If there is hope it lies in the proles".split()
>>> words
['If', 'there', 'is', 'hope', 'it', 'lies', 'in', 'the', 'proles']

现在是列表推导式的时候了。推导式被包含在方括号中,就像一个字面上的列表一样,但它包含的不是字面上的元素,而是一段描述如何构造列表元素的声明性代码片段。

>>> [len(word) for word in words]
[2, 5, 2, 4, 2, 4, 2, 3, 6]

这里,新列表是通过将名称word依次绑定到words中的每个值,然后评估len(word)来创建新列表中的相应值而形成的。换句话说,这构建了一个包含words中字符串长度的新列表;很难想象有更有效地表达这个新列表的方式了!

列表推导式语法

列表推导式的一般形式是:

[ expr(item) for item in iterable ]

也就是说,对于右侧的iterable中的每个item,我们在左侧评估expr(item)表达式(几乎总是,但不一定是关于该项的)。我们使用该表达式的结果作为我们正在构建的列表的下一个元素。

上面的推导式是以下命令式代码的声明性等价物:

>>> lengths = []
>>> for word in words:
...     lengths.append(len(word))
...
>>> lengths
[2, 5, 2, 4, 2, 4, 2, 3, 6]

列表推导式的元素

请注意,在列表推导式中我们迭代的源对象不需要是列表本身。它可以是任何实现了可迭代协议的对象,比如元组。

推导式的表达式部分可以是任何 Python 表达式。在这里,我们使用 range() 来找出前 20 个阶乘中每个数的十进制位数 —— range() 是一个可迭代对象 —— 以生成源序列。

>>> from math import factorial
>>> f = [len(str(factorial(x))) for x in range(20)]
>>> f
[1, 1, 1, 1, 2, 3, 3, 4, 5, 6, 7, 8, 9, 10, 11, 13, 14, 15, 16, 18]

还要注意,列表推导式产生的对象类型只不过是一个普通的 list

>>> type(f)
<class 'list'>

在我们看其他类型的推导式并考虑如何对无限序列进行迭代时,牢记这一点是很重要的。

集合推导式

集合支持类似的推导式语法,使用的是花括号,正如你所期望的那样。我们之前的“阶乘中的数字位数”结果包含了重复项,但通过构建一个集合而不是一个列表,我们可以消除它们:

>>> s = {len(str(factorial(x))) for x in range(20)}
>>> s
{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 13, 14, 15, 16, 18}

与列表推导式类似,集合推导式产生标准的 set 对象:

>>> type(s)
<class 'set'>

请注意,由于集合是无序容器,所以结果集不一定以有意义的顺序存储。

字典理解

第三种理解类型是字典理解。与集合理解语法类似,字典理解也使用大括号。它与集合理解的区别在于,我们现在提供了两个以冒号分隔的表达式 - 第一个用于键,第二个用于值 - 这将同时为结果字典中的每个新项目进行评估。这是一个我们可以玩的字典:

>>> country_to_capital = { 'United Kingdom': 'London',
...                        'Brazil': 'Brasília',
...                        'Morocco': 'Rabat',
...                        'Sweden': 'Stockholm' }

字典理解的一个很好的用途是反转字典,这样我们就可以在相反的方向上执行高效的查找:

>>> capital_to_country = {capital: country for country, capital in country_to_capital\
.items()}
>>> from pprint import pprint as pp
>>> pp(capital_to_country)
{'Brasília': 'Brazil',
 'London': 'United Kingdom',
 'Rabat': 'Morocco',
 'Stockholm': 'Sweden'}

请注意,字典理解不直接作用于字典源!^(16) 如果我们想要从源字典中获取键和值,那么我们应该使用items()方法结合元组解包来分别访问键和值。

你的理解应该产生一些相同的键,后面的键将覆盖先前的键。在这个例子中,我们将单词的首字母映射到单词本身,但只保留最后一个 h 开头的单词:

>>> words = ["hi", "hello", "foxtrot", "hotel"]
>>> { x[0]: x for x in words }
{'h': 'hotel', 'f': 'foxtrot'}

理解的复杂性

记住,你可以在任何理解中使用的表达式的复杂性没有限制。但是为了你的同行程序员着想,你应该避免过度。相反,将复杂的表达式提取到单独的函数中以保持可读性。以下是接近于字典理解的合理限制:

>>> import os
>>> import glob
>>> file_sizes = {os.path.realpath(p): os.stat(p).st_size for p in glob.glob('*.py')}
>>> pp(file_sizes)
{'/Users/pyfund/examples/exceptional.py': 400,
 '/Users/pyfund/examples/keypress.py': 778,
 '/Users/pyfund/examples/scopes.py': 133,
 '/Users/pyfund/examples/words.py': 1185}

这使用glob模块在目录中查找所有的 Python 源文件。然后它创建了一个从这些文件中的路径到文件大小的字典。

过滤理解

所有三种集合理解类型都支持一个可选的过滤子句,它允许我们选择由左侧表达式评估的源的哪些项目。过滤子句是通过在理解的序列定义之后添加if <boolean expression>来指定的;如果布尔表达式对输入序列中的项目返回 false,则在结果中不会对该项目进行评估。

为了使这个有趣,我们首先定义一个确定其输入是否为质数的函数:

>>> from math import sqrt
>>> def is_prime(x):
...     if x < 2:
...         return False
...     for i in range(2, int(sqrt(x)) + 1):
...         if x % i == 0:
...             return False
...     return True
...

现在我们可以在列表理解的过滤子句中使用这个来产生小于 100 的所有质数:

>>> [x for x in range(101) if is_prime(x)]
[2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37, 41, 43, 47, 53, 59, 61, 67, 71, 73, 79, \
83, 89, 97]

结合过滤和转换

我们在这里有一个看起来有点奇怪的x for x构造,因为我们没有对过滤值应用任何转换;关于x的表达式只是x本身。然而,没有什么能阻止我们将过滤谓词与转换表达式结合起来。这是一个将具有三个约数的数字映射到这些约数的元组的字典理解:

>>> prime_square_divisors = {x*x:(1, x, x*x) for x in range(101) if is_prime(x)}
>>> pp(prime_square_divisors)
{4: (1, 2, 4),
 9: (1, 3, 9),
 25: (1, 5, 25),
 49: (1, 7, 49),
 121: (1, 11, 121),
 169: (1, 13, 169),
 289: (1, 17, 289),
 361: (1, 19, 361),
 529: (1, 23, 529),
 841: (1, 29, 841),
 961: (1, 31, 961),
 1369: (1, 37, 1369),
 1681: (1, 41, 1681),
 1849: (1, 43, 1849),
 2209: (1, 47, 2209),
 2809: (1, 53, 2809),
 3481: (1, 59, 3481),
 3721: (1, 61, 3721),
 4489: (1, 67, 4489),
 5041: (1, 71, 5041),
 5329: (1, 73, 5329),
 6241: (1, 79, 6241),
 6889: (1, 83, 6889),
 7921: (1, 89, 7921),
 9409: (1, 97, 9409)}


禅的时刻

理解通常比替代方法更易读。然而,过度使用理解是可能的。有时,一个长或复杂的理解可能比等价的 for 循环难读。关于何时应该优先选择哪种形式没有硬性规定,但在编写代码时要谨慎,并尽量选择适合你情况的最佳形式。

首先,你的理解理想上应该是纯函数的 - 也就是说,它们不应该有任何副作用。如果你需要创建副作用,比如在迭代过程中打印到控制台,那么使用另一种构造,比如 for 循环。


迭代协议

理解迭代的最常用语言特性是推导和 for 循环。它们都从源中逐个获取项目并依次处理。然而,推导和 for 循环默认情况下都会遍历整个序列,有时需要更精细的控制。在本节中,我们将看到如何通过研究两个重要概念来实现这种精细控制,这两个概念构成了大量 Python 语言行为的基础:可迭代对象和迭代器对象,这两个对象都反映在标准 Python 协议中。

可迭代协议定义了可迭代对象必须实现的 API。也就是说,如果要使用 for 循环或推导来迭代对象,该对象必须实现可迭代协议。内置类如list实现了可迭代协议。您可以将实现可迭代协议的对象传递给内置的iter()函数,以获取可迭代对象的迭代器

迭代器则支持迭代器协议。该协议要求我们可以将迭代器对象传递给内置的next()函数,以从底层集合中获取下一个值。

迭代协议的示例

通常情况下,在 Python REPL 上进行演示将有助于将所有这些概念凝结成可以操作的东西。我们从一个包含季节名称的列表作为我们的可迭代对象开始:

>>> iterable = ['Spring', 'Summer', 'Autumn', 'Winter']

然后我们要求可迭代对象使用内置的iter()给我们一个迭代器:

>>> iterator = iter(iterable)

接下来我们使用内置的next()从迭代器中请求一个值:

>>> next(iterator)
'Spring'

每次调用next()都会通过序列移动迭代器:

>>> next(iterator)
'Summer'
>>> next(iterator)
'Autumn'
>>> next(iterator)
'Winter'

但是当我们到达末尾时会发生什么?

>>> next(iterator)
Traceback (most recent call last):
    File "<stdin>", line 1, in <module>
StopIteration

在 Python 中,异常会引发StopIteration异常,这显示了 Python 的自由主义精神。那些来自对异常处理更为严格的其他编程语言的人可能会觉得这有点令人不安,但实际上,还有什么比到达集合的末尾更特殊的呢?毕竟它只有一个结束!

考虑到可迭代系列可能是潜在的无限数据流,这种尝试对 Python 语言设计决策进行合理化的做法更有意义。在这种情况下到达末尾确实是一件值得写信或引发异常的事情。

迭代协议的更实际的示例

使用 for 循环和推导时,这些较低级别的迭代协议的实用性可能不太明显。为了演示更具体的用途,这里有一个小型实用函数,当传递一个可迭代对象时,它会返回该系列的第一个项目,或者如果该系列为空,则引发ValueError

>>> def first(iterable):
...     iterator = iter(iterable)
...     try:
...         return next(iterator)
...     except StopIteration:
...         raise ValueError("iterable is empty")
...

这在任何可迭代对象上都能按预期工作,本例中包括listset

>>> first(["1st", "2nd", "3rd"])
'1st'
>>> first({"1st", "2nd", "3rd"})
'1st'
>>> first(set())
Traceback (most recent call last):
  File "./iterable.py", line 17, in first
    return next(iterator)
StopIteration

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "./iterable.py", line 19, in first
    raise ValueError("iterable is empty")
ValueError: iterable is empty

值得注意的是,高级迭代构造,如 for 循环和推导,直接建立在这种低级别的迭代协议之上。

生成器函数

现在我们来介绍生成器函数^(17),这是 Python 编程语言中最强大和优雅的特性之一。Python 生成器提供了使用函数中的代码描述可迭代系列的方法。这些序列是惰性求值的,这意味着它们只在需要时计算下一个值。这一重要特性使它们能够模拟没有明确定义结束的无限值序列,例如来自传感器的数据流或活动日志文件。通过精心设计生成器函数,我们可以制作通用的流处理元素,这些元素可以组合成复杂的管道。

yield关键字

生成器由任何在其定义中至少使用一次yield关键字的 Python 函数定义。它们也可以包含没有参数的return关键字,就像任何其他函数一样,在定义的末尾有一个隐式的return

为了理解生成器的作用,让我们从 Python REPL 中的一个简单示例开始。让我们定义生成器,然后我们将研究生成器的工作原理。

生成器函数由def引入,就像普通的 Python 函数一样:

>>> def gen123():
...     yield 1
...     yield 2
...     yield 3
...

现在让我们调用gen123()并将其返回值赋给g

>>> g = gen123()

正如你所看到的,gen123()就像任何其他 Python 函数一样被调用。但它返回了什么?

>>> g
<generator object gen123 at 0x1006eb230>

生成器是迭代器

g是一个生成器对象。生成器实际上是 Python 的迭代器,因此我们可以使用迭代器协议从系列中检索或产生连续的值:

>>> next(g)
1
>>> next(g)
2
>>> next(g)
3

请注意,现在我们已经从生成器中产生了最后一个值后会发生什么。对next()的后续调用会引发StopIteration异常,就像任何其他 Python 迭代器一样:

>>> next(g)
Traceback (most recent call last):
    File "<stdin>", line 1, in <module>
StopIteration

因为生成器是迭代器,而迭代器也必须是可迭代的,它们可以在所有期望可迭代对象的常规 Python 结构中使用,例如 for 循环:

>>> for v in gen123():
...     print(v)
...
1
2
3

请注意,对生成器函数的每次调用都会返回一个新的生成器对象:

>>> h = gen123()
>>> i = gen123()
>>> h
<generator object gen123 at 0x1006eb2d0>
>>> i
<generator object gen123 at 0x1006eb280>
>>> h is i
False

还要注意每个生成器对象可以独立推进:

>>> next(h)
1
>>> next(h)
2
>>> next(i)
1

生成器代码何时执行?

让我们更仔细地看一下我们的生成器函数体中的代码是如何执行的,以及关键的何时执行。为了做到这一点,我们将创建一个稍微复杂一点的生成器,它将用老式的打印语句跟踪它的执行:

>>> def gen246():
...     print("About to yield 2")
...     yield 2
...     print("About to yield 4")
...     yield 4
...     print("About to yield 6")
...     yield 6
...     print("About to return")
...
>>> g = gen246()

此时生成器对象已经被创建并返回,但是生成器函数体内的代码尚未执行。让我们对next()进行初始调用:

>>> next(g)
About to yield 2
2

看看当我们请求第一个值时,生成器体运行到第一个yield语句为止。代码执行到足够的地方,以便字面上yield下一个值。

>>> next(g)
About to yield 4
4

当我们从生成器请求下一个值时,生成器函数的执行会在离开的地方恢复,并继续运行直到下一个yield

>>> next(g)
About to yield 6
6

在最后一个值返回后,下一个请求会导致生成器函数执行,直到它在函数体的末尾返回,这将引发预期的StopIteration异常。

>>> next(g)
About to return
Traceback (most recent call last):
    File "<stdin>", line 1, in <module>
StopIteration

现在我们已经看到生成器执行是通过对next()的调用来启动,并通过yield语句来中断,我们可以继续将更复杂的代码放在生成器函数体中。

在生成器函数中保持显式状态

现在我们将看看我们的生成器函数如何在每次请求下一个值时恢复执行,并在本地变量中保持状态。在这个过程中,我们的生成器将变得更有趣和更有用。我们将展示两个演示惰性评估的生成器,稍后我们将把它们合并成一个生成器管道。

第一个有状态的生成器:take()

我们将要查看的第一个生成器是take(),它从序列的前面检索指定数量的元素:

def take(count, iterable):
    """Take items from the front of an iterable.

    Args:
        count: The maximum number of items to retrieve.
        iterable: The source of the items.

    Yields:
        At most 'count' items from 'iterable'.
    """
    counter = 0
    for item in iterable:
        if counter == count:
            return
        counter += 1
        yield item

请注意,该函数定义了一个生成器,因为它包含至少一个yield语句。这个特定的生成器还包含一个return语句来终止产生的值流。生成器使用一个计数器来跟踪到目前为止已经产生了多少元素,当请求超出请求的计数时返回。

由于生成器是惰性的,并且只在请求时产生值,我们将在run_take()函数中使用 for 循环来驱动执行:

def run_take():
    items = [2, 4, 6, 8, 10]
    for item in take(3, items):
        print(item)

在这里,我们创建了一个名为items的源list,并将其与3一起传递给我们的生成器函数。在内部,for 循环将使用迭代器协议从take()生成器中检索值,直到它终止。

第二个有状态的生成器:distinct()

现在让我们把第二个生成器带入图片。这个名为distinct()的生成器函数通过跟踪它已经在set中看到的元素来消除重复项:

def distinct(iterable):
    """Return unique items by eliminating duplicates.

    Args:
        iterable: The source of the items.

    Yields:
        Unique elements in order from 'iterable'.
    """
    seen = set()
    for item in iterable:
        if item in seen:
            continue
        yield item
        seen.add(item)

在这个生成器中,我们还使用了一个之前没有见过的控制流构造:continue关键字。continue语句结束当前循环的迭代,并立即开始下一个迭代。在这种情况下执行时,执行将被转移到for语句,但与break一样,它也可以与 while 循环一起使用。

在这种情况下,continue用于跳过已经产生的任何值。我们还可以添加一个run_distinct()函数来使用distinct()

def run_distinct():
    items = [5, 7, 7, 6, 5, 5]
    for item in distinct(items):
        print(item)

理解这些生成器!

在这一点上,您应该花一些时间探索这两个生成器,然后再继续。确保您了解它们的工作方式以及它们如何在维护状态时控制流进出。如果您正在使用 IDE 运行这些示例,您可以使用调试器通过在生成器和使用它们的代码中设置断点来跟踪控制流。您也可以使用 Python 的内置pdb调试器(我们稍后会介绍)或者甚至只是使用老式的打印语句来实现相同的效果。

无论如何,确保在继续下一节之前,您真正了解这些生成器的工作方式。

惰性生成器管道

现在您已经了解了单独的生成器,我们将把它们两个安排成一个惰性管道。我们将使用take()distinct()一起从集合中获取前三个唯一的项目:

def run_pipeline():
    items = [3, 6, 6, 2, 1, 1]
    for item in take(3, distinct(items)):
        print(item)

请注意,distinct()生成器只做足够的工作来满足take()生成器的需求,后者正在迭代它 - 它永远不会到达源列表的最后两个项目,因为它们不需要产生前三个唯一的项目。这种对计算的懒惰方法非常强大,但它产生的复杂控制流可能很难调试。在开发过程中,强制评估所有生成的值通常很有用,最简单的方法是插入一个对list()构造函数的调用:

take(3, list(distinct(items)))

这个交错调用list()导致distinct()生成器在take()执行其工作之前彻底处理其源项目。有时,当您调试惰性计算的序列时,这可以让您了解正在发生什么。

懒惰和无限

生成器是惰性的,这意味着计算只会在下一个结果被请求时才会发生。生成器的这种有趣和有用的特性意味着它们可以用来模拟无限序列。由于值只在调用者请求时产生,并且不需要构建数据结构来包含序列的元素,因此生成器可以安全地用于生成永无止境(或者只是非常大)的序列,比如:

  • 传感器读数

  • 数学序列(例如素数、阶乘等)^(18)

  • 多太字节文件的内容

生成 Lucas 系列

让我们介绍一个 Lucas 系列的生成器函数^(19):

def lucas():
    yield 2
    a = 2
    b = 1
    while True:
        yield b
        a, b = b, a + b

Lucas 系列以2, 1开始,之后每个值都是前两个值的和。因此,序列的前几个值是:

2, 1, 3, 4, 7, 11

第一个yield产生值2。然后函数初始化ab,它们保存着函数进行时所需的“前两个值”。然后函数进入一个无限的 while 循环,其中:

  1. 它产生b的值

  2. ab被更新以保存新的“前两个”值,使用元组解包的巧妙应用

现在我们有了一个生成器,它可以像任何其他可迭代对象一样使用。例如,要打印 Lucas 数,您可以使用以下循环:

>>> for x in lucas():
...     print(x)
...
2
1
3
4
7
11
18
29
47
76
123
199

当然,由于 Lucas 序列是无限的,这将永远运行,打印出值,直到您的计算机耗尽内存。使用 Control-C 来终止循环。

生成器表达式

生成器表达式是推导和生成器函数之间的交叉。它们使用与推导类似的语法,但它们会产生一个生成器对象,该对象会懒惰地产生指定的序列。生成器表达式的语法与列表推导非常相似:

( expr(item) for item in iterable )

它由括号界定,而不是用于列表推导的方括号。

生成器表达式在您希望使用推导的声明性简洁性进行懒惰评估的情况下非常有用。例如,这个生成器表达式产生了前一百万个平方数的列表:

>>> million_squares = (x*x for x in range(1, 1000001))

此时,还没有创建任何一个平方数;我们只是将序列的规范捕捉到了一个生成器对象中:

>>> million_squares
<generator object <genexpr> at 0x1007a12d0>

我们可以通过使用它来创建一个(长!)list来强制评估生成器:

>>> list(million_squares)
. . .
999982000081, 999984000064, 999986000049, 999988000036, 999990000025,
999992000016, 999994000009, 999996000004, 999998000001, 1000000000000]

这个列表显然消耗了大量的内存 - 在这种情况下,列表对象和其中包含的整数对象大约为 40MB。

生成器对象只运行一次

注意,生成器对象只是一个迭代器,一旦以这种方式耗尽,就不会再产生任何项目。重复前面的语句会返回一个空列表:

>>> list(million_squares)
[]

生成器是一次性对象。每次调用生成器函数时,我们都会创建一个新的生成器对象。要从生成器表达式中重新创建生成器,我们必须再次执行表达式本身。

无内存迭代

让我们通过使用内置的sum()函数来计算前一千万个平方数的和来提高赌注,该函数接受一个可迭代的数字序列。如果我们使用列表推导,我们可以期望它消耗大约 400MB 的内存。使用生成器表达式,内存使用将是微不足道的:

>>> sum(x*x for x in range(1, 10000001))
333333383333335000000

这将在一秒钟左右产生一个结果,并且几乎不使用内存。

可选的括号

仔细观察,您会发现在这种情况下,我们没有为生成器表达式提供单独的括号,除了sum()函数调用所需的括号。这种优雅的能力使得用于函数调用的括号也可以用于生成器表达式,有助于可读性。如果您愿意,您可以包含第二组括号。

在生成器表达式中使用 if 子句

与推导一样,您可以在生成器表达式的末尾包含一个 if 子句。重复使用我们承认效率低下的is_prime()谓词,我们可以这样确定前一千个整数中是质数的整数的总和:

>>> sum(x for x in range(1001) if is_prime(x))
76127

请注意,这与计算前 1000 个质数的总和不同,这是一个更棘手的问题,因为我们事先不知道在我们累积了一千个质数之前需要测试多少个整数。

“电池包含”迭代工具

到目前为止,我们已经介绍了 Python 提供的创建可迭代对象的许多方法。推导、生成器和遵循可迭代或迭代器协议的任何对象都可以用于迭代,因此应该清楚迭代是 Python 的一个核心特性。

Python 提供了许多用于执行常见迭代器操作的内置函数。这些函数构成了一种用于处理迭代器的词汇,它们可以组合在一起,以产生非常简洁、可读的代码中的强大语句。我们已经遇到了其中一些函数,包括用于生成整数索引的enumerate()和用于计算数字总和的sum()

介绍itertools

除了内置函数之外,itertools模块还包含了大量用于处理可迭代数据流的有用函数和生成器。

我们将通过使用内置的sum()itertools中的两个生成器函数:islice()count()来解决前一千个质数问题来开始演示这些函数。

早些时候,我们为了懒惰地检索序列的开头而制作了自己的take()生成器函数。然而,我们不需要费心,因为islice()允许我们执行类似于内置列表切片功能的懒惰切片。要获取前 1000 个质数,我们需要做类似这样的事情:

from itertools import islice, count

islice(all_primes, 1000)

但是如何生成all_primes呢?以前,我们一直使用range()来创建原始的整数序列,以供我们的质数测试使用,但范围必须始终是有限的,即在两端都有界。我们想要的是range()的开放版本,这正是itertools.count()提供的。使用count()islice(),我们的前 1000 个质数表达式可以写成:

>>> thousand_primes = islice((x for x in count() if is_prime(x)), 1000)

这返回一个特殊的islice对象,它是可迭代的。我们可以使用列表构造函数将其转换为列表。

>>> thousand_primes
<itertools.islice object at 0x1006bae10>
>>> list(thousand_primes)
[2, 3, 5, 7, 11, 13 ... ,7877, 7879, 7883, 7901, 7907, 7919]

现在回答我们关于前 1000 个质数之和的问题很容易,记得重新创建生成器:

>>> sum(islice((x for x in count() if is_prime(x)), 1000))
3682913

布尔序列

另外两个非常有用的内置函数是any()all()。它们相当于逻辑运算符andor,但适用于bool值的可迭代序列,

>>> any([False, False, True])
True
>>> all([False, False, True])
False

在这里,我们将使用any()与生成器表达式一起来回答一个问题,即 1328 到 1360 范围内是否有任何质数:

>>> any(is_prime(x) for x in range(1328, 1361))
False

对于完全不同类型的问题,我们可以检查所有这些城市名称是否都是以大写字母开头的专有名词:

>>> all(name == name.title() for name in ['London', 'Paris', 'Tokyo', 'New York', 'Sy\
dney', 'Kuala Lumpur'])
True

使用zip合并序列

我们将要看的最后一个内置函数是zip(),顾名思义,它给我们提供了一种同步迭代两个可迭代序列的方法。例如,让我们一起zip两列温度数据,一个来自星期日,一个来自星期一:

>>> sunday = [12, 14, 15, 15, 17, 21, 22, 22, 23, 22, 20, 18]
>>> monday = [13, 14, 14, 14, 16, 20, 21, 22, 22, 21, 19, 17]
>>> for item in zip(sunday, monday):
...     print(item)
...
(12, 13)
(14, 14)
(15, 14)
(15, 14)
(17, 16)
(21, 20)
(22, 21)
(22, 22)
(23, 22)
(22, 21)
(20, 19)
(18, 17)

我们可以看到,当迭代时,zip()会产生元组。这反过来意味着我们可以在 for 循环中使用元组解包来计算这些天每小时的平均温度:

>>> for sun, mon in zip(sunday, monday):
...     print("average =", (sun + mon) / 2)
...
average = 12.5
average = 14.0
average = 14.5
average = 14.5
average = 16.5
average = 20.5
average = 21.5
average = 22.0
average = 22.5
average = 21.5
average = 19.5
average = 17.5

使用zip()处理两个以上的序列

事实上,zip()可以接受任意数量的可迭代参数。让我们添加第三个时间序列,并使用其他内置函数来计算相应时间的统计数据:

>>> tuesday = [2, 2, 3, 7, 9, 10, 11, 12, 10, 9, 8, 8]
>>> for temps in zip(sunday, monday, tuesday):
...     print("min = {:4.1f}, max={:4.1f}, average={:4.1f}".format(
...            min(temps), max(temps), sum(temps) / len(temps)))
...
min =  2.0, max=13.0, average= 9.0
min =  2.0, max=14.0, average=10.0
min =  3.0, max=15.0, average=10.7
min =  7.0, max=15.0, average=12.0
min =  9.0, max=17.0, average=14.0
min = 10.0, max=21.0, average=17.0
min = 11.0, max=22.0, average=18.0
min = 12.0, max=22.0, average=18.7
min = 10.0, max=23.0, average=18.3
min =  9.0, max=22.0, average=17.3
min =  8.0, max=20.0, average=15.7
min =  8.0, max=18.0, average=14.3

注意我们如何使用字符串格式化功能来控制数字列的宽度为四个字符。

使用chain()懒惰地连接序列

也许,我们想要一个长的星期日、星期一和星期二的温度序列。我们可以使用itertools.chain()懒惰地连接可迭代对象,而不是通过急切地组合三个温度列表来创建一个新列表:

>>> from itertools import chain
>>> temperatures = chain(sunday, monday, tuesday)

temperatures是一个可迭代对象,首先产生来自星期日的值,然后是来自星期一的值,最后是来自星期二的值。虽然它是懒惰的,但它从来不会创建一个包含所有元素的单个列表;事实上,它从来不会创建任何中间列表!

现在我们可以检查所有这些温度是否都高于冰点,而不会造成数据重复的内存影响:

>>> all(t > 0 for t in temperatures)
True

将所有内容汇总在一起

在总结之前,让我们把我们做的一些事情整合起来,让你的计算机计算卢卡斯质数:

>>> for x in (p for p in lucas() if is_prime(p)):
...     print(x)
...
2
3
7
11
29
47
199
521
2207
3571
9349
3010349
54018521
370248451
6643838879
119218851371
5600748293801
688846502588399
32361122672259149

当你看够了这些内容后,我们建议你花一些时间探索itertools模块。你越熟悉 Python 对可迭代对象的现有支持,你自己的代码就会变得更加优雅和简洁。

总结

  • 理解是描述列表、集合和字典的简洁语法。

  • 理解操作可迭代源对象,并应用可选的谓词过滤器和强制表达式,这两者通常都是关于当前项目的。

  • 可迭代对象是我们可以逐个迭代的对象。

  • 我们使用内置的iter()函数从可迭代对象中检索迭代器。

  • 迭代器每次传递给内置的next()函数时,都会从底层可迭代序列中逐个产生项目。

  • 当集合耗尽时,迭代器会引发StopIteration异常。

生成器

  • 生成器函数允许我们使用命令式代码描述序列。

  • 生成器函数至少包含一次使用yield关键字。

  • 生成器是迭代器。当迭代器使用next()进行推进时,生成器会开始或恢复执行,直到包括下一个yield为止。

  • 对生成器函数的每次调用都会创建一个新的生成器对象。

  • 生成器可以在迭代之间的局部变量中维护显式状态。

  • 生成器是懒惰的,因此可以模拟无限的数据系列。

  • 生成器表达式具有类似的语法形式,可以更声明式和简洁地创建生成器对象。

迭代工具

  • Python 包括一套丰富的工具,用于处理可迭代系列,包括内置函数如sum()any()zip(),以及itertools模块中的工具。

第九章:使用类定义新类型

使用内置的标量和集合类型可以在 Python 中走得很远。对于许多问题,内置类型以及 Python 标准库中提供的类型完全足够。但有时候,它们并不完全符合要求,创建自定义类型的能力就是的用武之地。

正如我们所见,Python 中的所有对象都有一个类型,当我们使用内置的type()函数报告该类型时,结果是以该类型的为基础的:

>>> type(5)
<class 'int'>
>>> type("python")
<class 'str'>
>>> type([1, 2, 3])
<class 'list'>
>>> type(x*x for x in [2, 4, 6])
<class 'generator'>

类用于定义一个或多个对象的结构和行为,我们称之为类的实例。总的来说,Python 中的对象在创建时具有固定的类型^(20) - 或者在被销毁之前^(21)。将类视为一种模板或模具,用于构建新对象可能有所帮助。对象的类控制其初始化以及通过该对象可用的属性和方法。例如,在字符串对象上,我们可以使用的方法,如split(),是在str类中定义的。

类是 Python 中面向对象编程(OOP)的重要机制,尽管 OOP 可以用于使复杂问题更易处理,但它往往会使简单问题的解决方案变得不必要复杂。Python 的一个很棒的地方是它高度面向对象,而不会强迫你处理类,直到你真正需要它们。这使得该语言与 Java 和 C#截然不同。

定义类

类定义由class关键字引入,后面跟着类名。按照惯例,在 Python 中,新的类名使用驼峰命名法 - 有时被称为帕斯卡命名法 - 每个组件单词的首字母都大写,不使用下划线分隔。由于在 REPL 中定义类有点麻烦,我们将使用 Python 模块文件来保存我们在本章中使用的类定义。

让我们从非常简单的类开始,逐步添加功能。在我们的示例中,我们将通过将此代码放入airtravel.py来模拟两个机场之间的客机航班:

"""Model for aircraft flights."""

class Flight:
    pass

class语句引入了一个新的块,所以我们在下一行缩进。空块是不允许的,所以最简单的类至少需要一个无用的pass语句才能在语法上被接受。

就像使用def来定义函数一样,class是一个语句,可以出现在程序的任何地方,并将类定义绑定到类名。当执行airtravel模块中的顶层代码时,类将被定义。

现在我们可以将我们的新类导入 REPL 并尝试它。

>>> from airtravel import Flight

我们刚刚导入的东西是类对象。在 Python 中,一切都是对象,类也不例外。

>>> Flight
<class 'airtravel.Flight'>

要使用这个类来创建一个新对象,我们必须调用它的构造函数,这是通过调用类来完成的,就像调用函数一样。构造函数返回一个新对象,这里我们将其赋给一个名为f的变量:

>>> f = Flight()

如果我们使用type()函数来请求f的类型,我们会得到airtravel.Flight

>>> type(f)
<class 'airtravel.Flight'>

f的类型就是类。

实例方法

让我们通过添加所谓的实例方法来使我们的类更有趣,该方法返回航班号。方法只是在类块内定义的函数,实例方法是可以在我们的类的实例对象上调用的函数,比如f。实例方法必须接受对其调用方法的实例的引用作为第一个形式参数^(22),按照惯例,这个参数总是被称为self

我们还没有办法配置航班号的值,所以我们将返回一个常量字符串:

class Flight:

    def number(self):
        return "SN060"

并从一个新的 REPL 开始:

>>> from airtravel import Flight
>>> f = Flight()
>>> f.number()
SN060

请注意,当我们调用该方法时,我们不会为实际参数self在参数列表中提供实例f。这是因为标准的方法调用形式与点一起,就像这样:

>>> f.number()
SN060

是语法糖:

>>> Flight.number(f)
SN060

如果你尝试后者,你会发现它按预期工作,尽管你几乎永远不会看到这种形式被真正使用。

实例初始化程序

这个类并不是很有用,因为它只能表示一个特定的航班。我们需要在创建Flight时使航班号可配置。为此,我们需要编写一个初始化程序方法。

如果提供,初始化程序方法将作为创建新对象的过程的一部分被调用,当我们调用构造函数时。初始化程序方法必须被称为__init__(),用于 Python 运行时机制的双下划线限定。与所有其他实例方法一样,__init__()的第一个参数必须是self

在这种情况下,我们还向__init__()传递了第二个形式参数,即航班号:

class Flight:

    def __init__(self, number):
        self._number = number

    def number(self):
        return self._number

初始化程序不应返回任何东西-它修改了由self引用的对象。

如果你来自 Java、C#或 C++背景,很容易认为__init__()是构造函数。这并不完全准确;在 Python 中,__init__()的目的是在调用__init__()时配置已经存在的对象。然而,self参数在 Python 中类似于 Java、C#或 C++中的this。在 Python 中,实际的构造函数是由 Python 运行时系统提供的,它的其中一个功能是检查实例初始化程序的存在并在存在时调用它。

在初始化程序中,我们分配给新创建实例的属性称为_number。分配给尚不存在的对象属性足以使其存在。

就像我们不需要在创建变量之前声明它们一样,我们也不需要在创建对象属性之前声明它们。我们选择了带有前导下划线的_number有两个原因。首先,因为它避免了与同名方法的名称冲突。方法是函数,函数是对象,这些函数绑定到对象的属性,所以我们已经有一个名为number的属性,我们不想替换它。其次,有一个广泛遵循的约定,即对象的实现细节不应该由对象的客户端消费或操作,应该以下划线开头。

我们还修改了我们的number()方法来访问_number属性并返回它。

传递给飞行构造函数的任何实际参数都将转发到初始化程序,因此要创建和配置我们的Flight对象,我们现在可以这样做:

>>> from airtravel import Flight
>>> f = Flight("SN060")
>>> f.number()
SN060

我们还可以直接访问实现细节:

>>> f._number
SN060

尽管这不建议用于生产代码,但对于调试和早期测试非常方便。

缺乏访问修饰符

如果你来自像 Java 或 C#这样的束缚和纪律语言,具有publicprivateprotected访问修饰符,Python 的“一切都是公开的”方法可能看起来过于开放。

Pythonista 之间普遍的文化是“我们都是自愿成年人”。实际上,前导下划线约定已经被证明足以保护我们所使用的大型和复杂的 Python 系统。人们知道不直接使用这些属性,事实上他们也不倾向于这样做。就像许多教条一样,缺乏访问修饰符在理论上比在实践中更成问题。

验证和不变量

对于对象的初始化程序来说,建立所谓的类不变量是一个好的做法。不变量是关于该类的对象应该在对象的生命周期内持续存在的真理。对于航班来说,这样的不变量是,航班号始终以大写的两个字母航空公司代码开头,后面跟着三位或四位数字路线号。

在 Python 中,我们在__init__()方法中建立类不变量,并在无法实现时引发异常:

class Flight:

    def __init__(self, number):
        if not number[:2].isalpha():
            raise ValueError("No airline code in '{}'".format(number))

        if not number[:2].isupper():
            raise ValueError("Invalid airline code '{}'".format(number))

        if not (number[2:].isdigit() and int(number[2:]) <= 9999):
            raise ValueError("Invalid route number '{}'".format(number))

        self._number = number

    def number(self):
        return self._number

我们使用字符串切片和字符串类的各种方法进行验证。在本书中,我们还首次看到逻辑否定运算符not

在 REPL 中的Ad hoc测试是开发过程中非常有效的技术:

>>> from airtravel import Flight
>>> f = Flight("SN060")
>>> f = Flight("060")
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "./airtravel.py", line 8, in __init__
    raise ValueError("No airline code in '{};".format(number))
ValueError: No airline code in '060'
>>> f = Flight("sn060")
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "./airtravel.py", line 11, in __init__
    raise ValueError("Invalid airline code '{}'".format(number))
ValueError: Invalid airline code 'sn060'
>>> f = Flight("snabcd")
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "./airtravel.py", line 11, in __init__
    raise ValueError("Invalid airline code '{}'".format(number))
ValueError: Invalid airline code 'snabcd'
>>> f = Flight("SN12345")
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "./airtravel.py", line 11, in __init__
    raise ValueError("Invalid airline code '{}'".format(number))
ValueError: Invalid airline code 'sn12345'

现在我们确信有一个有效的航班号,我们将添加第二个方法,只返回航空公司代码。一旦类不变量被建立,大多数查询方法都可以非常简单:

def airline(self):
    return self._number[:2]

添加第二个类

我们想要做的事情之一是接受座位预订。为此,我们需要知道座位布局,为此我们需要知道飞机的类型。让我们制作第二个类来模拟不同类型的飞机:

class Aircraft:

    def __init__(self, registration, model, num_rows, num_seats_per_row):
        self._registration = registration
        self._model = model
        self._num_rows = num_rows
        self._num_seats_per_row = num_seats_per_row

    def registration(self):
        return self._registration

    def model(self):
        return self._model

初始化程序为飞机创建了四个属性:注册号、型号名称、座位行数和每行座位数。在生产代码场景中,我们可以验证这些参数,以确保例如行数不是负数。

这足够简单了,但对于座位计划,我们希望有一些更符合我们预订系统的东西。飞机的行数从一开始编号,每行的座位用字母表示,字母表中省略了‘I’,以避免与‘1’混淆。

飞机座位计划。

飞机座位计划。

我们将添加一个seating_plan()方法,它返回允许的行和座位,包含一个range对象和一个座位字母的字符串的 2 元组:

def seating_plan(self):
  return (range(1, self._num_rows + 1),
          "ABCDEFGHJK"[:self._num_seats_per_row])

值得停顿一下,确保你理解这个函数是如何工作的。对range()构造函数的调用产生一个范围对象,它可以用作飞机行数的可迭代系列。字符串及其切片方法返回一个每个座位一个字符的字符串。这两个对象-范围和字符串-被捆绑成一个元组。

让我们构造一个有座位计划的飞机:

  >>> from airtravel import *
  >>> a = Aircraft("G-EUPT", "Airbus A319", num_rows=22, num_seats_per_row=6)
  >>> a.registration()
  'G-EUPT'
  >>> a.model()
  'Airbus A319'
  >>> a.seating_plan()
  (range(1, 23), 'ABCDEF')

看看我们如何为行和座位使用关键字参数进行文档目的。回想一下,范围是半开放的,所以 23 正确地超出了范围的末端。

合作类

德米特尔法则是一个面向对象的设计原则,它说你不应该调用从其他调用中接收到的对象的方法。换句话说:只与你直接的朋友交谈。

德米特尔法则-只与你直接的朋友交谈。这个法则实际上只是一个指导方针,是以一个面向方面的编程项目命名的,而这个项目又是以象征着自下而上哲学的希腊农业女神的名字命名的只是一个指导方针,是以一个面向方面的编程项目命名的,这又是以农业女神的名字命名的,她象征着自下而上的哲学其自下而上的哲学

德米特尔法则-只与你直接的朋友交谈。这个法则实际上只是一个指导方针,是以一个面向方面的编程项目命名的,而这个项目又是以象征着自下而上哲学的希腊农业女神的名字命名的

我们现在将修改我们的Flight类,以在构造时接受一个飞机对象,并且我们将遵循德米特尔法则,通过添加一个方法来报告飞机型号。这个方法将代表客户委托Aircraft,而不是允许客户“通过”Flight并询问Aircraft对象:

class Flight:
    """A flight with a particular passenger aircraft."""

    def __init__(self, number, aircraft):
        if not number[:2].isalpha():
            raise ValueError("No airline code in '{}'".format(number))

        if not number[:2].isupper():
            raise ValueError("Invalid airline code '{}'".format(number))

        if not (number[2:].isdigit() and int(number[2:]) <= 9999):
            raise ValueError("Invalid route number '{}'".format(number))

        self._number = number
        self._aircraft = aircraft

    def number(self):
        return self._number

    def airline(self):
        return self._number[:2]

    def aircraft_model(self):
        return self._aircraft.model()

我们还为类添加了一个文档字符串。这些工作方式就像函数和模块的文档字符串一样,并且必须是类主体内的第一个非注释行。

现在我们可以用特定的飞机构造一个航班:

>>> from airtravel import *
>>> f = Flight("BA758", Aircraft("G-EUPT", "Airbus A319", num_rows=22,
...                              num_seats_per_row=6))
>>> f.aircraft_model()
'Airbus A319'

注意,我们构造了Aircraft对象,并直接将其传递给Flight构造函数,而无需为其命名中间引用。


禅宗时刻

aircraft_model()方法是“复杂比复杂好”的一个例子:

def aircraft_model(self):
    return self._aircraft.model()

Flight 类更加复杂——它包含额外的代码来深入到飞机引用中找到模型。然而,所有的Flight客户端现在可以更少复杂;它们都不需要知道Aircraft类,从而大大简化了系统。


预订座位

现在我们可以继续实现一个简单的预订系统。对于每个航班,我们需要跟踪谁坐在每个座位上。我们将使用一个字典列表来表示座位分配。列表将包含每个座位行的一个条目,每个条目将是一个从座位字母到乘客姓名的映射的字典。如果一个座位没有被占用,相应的字典值将包含None

我们在Flight.__init__()中使用这个片段初始化座位计划:

rows, seats = self._aircraft.seating_plan()
self._seating = [None] + [{letter: None for letter in seats} for _ in rows]

在第一行中,我们检索飞机的座位计划,并使用元组解包将行和座位标识符放入本地变量rowsseats中。在第二行中,我们为座位分配创建一个列表。我们选择浪费列表开头的一个条目,而不是不断处理行索引是基于一的事实,而 Python 列表使用基于零的索引。这个第一个浪费的条目是包含None的单元素列表。对于飞机中的每一行,我们将这个列表连接到另一个列表中。这个列表是通过列表推导构建的,它遍历了从前一行的_aircraft中检索到的行号的range对象。

座位计划数据结构的对象图,这是一个列表的字典。

座位计划数据结构的对象图,这是一个字典列表。

我们实际上对行号不感兴趣,因为我们知道它将与最终列表中的列表索引匹配,所以我们通过使用虚拟下划线变量将其丢弃。

列表推导的项目表达式本身就是一个推导;具体来说是一个字典推导!这遍历每个行字母,并创建从单个字符字符串到None的映射,以指示空座位。

我们使用列表推导,而不是使用乘法运算符进行列表复制,因为我们希望为每一行创建一个不同的字典对象;记住,重复是浅层的。

在我们将代码放入初始化程序后,代码如下:

def __init__(self, number, aircraft):
    if not number[:2].isalpha():
        raise ValueError("No airline code in '{}'".format(number))

    if not number[:2].isupper():
        raise ValueError("Invalid airline code '{}'".format(number))

    if not (number[2:].isdigit() and int(number[2:]) <= 9999):
        raise ValueError("Invalid route number '{}'".format(number))

    self._number = number
    self._aircraft = aircraft

    rows, seats = self._aircraft.seating_plan()
    self._seating = [None] + [{letter: None for letter in seats} for _ in rows]

在我们进一步之前,让我们在 REPL 中测试我们的代码:

>>> from airtravel import *
>>> f = Flight("BA758", Aircraft("G-EUPT", "Airbus A319", num_rows=22,
...                              num_seats_per_row=6))
>>>

由于一切都是“公开的”,我们可以在开发过程中访问实现细节。很明显,我们在开发过程中故意违反了惯例,因为前导下划线提醒我们什么是“公开的”和什么是“私有的”:

>>> f._seating
[None, {'F': None, 'D': None, 'E': None, 'B': None, 'C': None, 'A': None},
{'F': None, 'D': None, 'E': None, 'B': None, 'C': None, 'A': None}, {'F': None,
'D': None, 'E': None, 'B': None, 'C': None, 'A': None}, {'F': None, 'D': None,
'E': None, 'B': None, 'C': None, 'A': None}, {'F': None, 'D': None, 'E': None,
'B': None, 'C': None, 'A': None}, {'F': None, 'D': None, 'E': None, 'B': None,
'C': None, 'A': None}, {'F': None, 'D': None, 'E': None, 'B': None, 'C': None,
'A': None}, {'F': None, 'D': None, 'E': None, 'B': None, 'C': None, 'A': None},
{'F': None, 'D': None, 'E': None, 'B': None, 'C': None, 'A': None}, {'F': None,
'D': None, 'E': None, 'B': None, 'C': None, 'A': None}, {'F': None, 'D': None,
'E': None, 'B': None, 'C': None, 'A': None}, {'F': None, 'D': None, 'E': None,
'B': None, 'C': None, 'A': None}, {'F': None, 'D': None, 'E': None, 'B': None,
'C': None, 'A': None}, {'F': None, 'D': None, 'E': None, 'B': None, 'C': None,
'A': None}, {'F': None, 'D': None, 'E': None, 'B': None, 'C': None, 'A': None},
{'F': None, 'D': None, 'E': None, 'B': None, 'C': None, 'A': None}, {'F': None,
'D': None, 'E': None, 'B': None, 'C': None, 'A': None}, {'F': None, 'D': None,
'E': None, 'B': None, 'C': None, 'A': None}, {'F': None, 'D': None, 'E': None,
'B': None, 'C': None, 'A': None}, {'F': None, 'D': None, 'E': None, 'B': None,
'C': None, 'A': None}, {'F': None, 'D': None, 'E': None, 'B': None, 'C': None,
'A': None}, {'F': None, 'D': None, 'E': None, 'B': None, 'C': None, 'A': None}]

这是准确的,但不是特别美观。让我们尝试用漂亮的打印:

>>> from pprint import pprint as pp
>>> pp(f._seating)
[None,
  {'A': None, 'B': None, 'C': None, 'D': None, 'E': None, 'F': None},
  {'A': None, 'B': None, 'C': None, 'D': None, 'E': None, 'F': None},
  {'A': None, 'B': None, 'C': None, 'D': None, 'E': None, 'F': None},
  {'A': None, 'B': None, 'C': None, 'D': None, 'E': None, 'F': None},
  {'A': None, 'B': None, 'C': None, 'D': None, 'E': None, 'F': None},
  {'A': None, 'B': None, 'C': None, 'D': None, 'E': None, 'F': None},
  {'A': None, 'B': None, 'C': None, 'D': None, 'E': None, 'F': None},
  {'A': None, 'B': None, 'C': None, 'D': None, 'E': None, 'F': None},
  {'A': None, 'B': None, 'C': None, 'D': None, 'E': None, 'F': None},
  {'A': None, 'B': None, 'C': None, 'D': None, 'E': None, 'F': None},
  {'A': None, 'B': None, 'C': None, 'D': None, 'E': None, 'F': None},
  {'A': None, 'B': None, 'C': None, 'D': None, 'E': None, 'F': None},
  {'A': None, 'B': None, 'C': None, 'D': None, 'E': None, 'F': None},
  {'A': None, 'B': None, 'C': None, 'D': None, 'E': None, 'F': None},
  {'A': None, 'B': None, 'C': None, 'D': None, 'E': None, 'F': None},
  {'A': None, 'B': None, 'C': None, 'D': None, 'E': None, 'F': None},
  {'A': None, 'B': None, 'C': None, 'D': None, 'E': None, 'F': None},
  {'A': None, 'B': None, 'C': None, 'D': None, 'E': None, 'F': None},
  {'A': None, 'B': None, 'C': None, 'D': None, 'E': None, 'F': None},
  {'A': None, 'B': None, 'C': None, 'D': None, 'E': None, 'F': None},
  {'A': None, 'B': None, 'C': None, 'D': None, 'E': None, 'F': None},
  {'A': None, 'B': None, 'C': None, 'D': None, 'E': None, 'F': None}]

太好了!

为乘客分配座位

现在我们将为Flight添加行为,将座位分配给乘客。为了保持简单,乘客将是一个字符串名称:

 1 class Flight:
 2 
 3    # ...
 4 
 5    def allocate_seat(seat, passenger):
 6        """Allocate a seat to a passenger.
 7 
 8        Args:
 9            seat: A seat designator such as '12C' or '21F'.
10             passenger: The passenger name.
11 
12         Raises:
13             ValueError: If the seat is unavailable.
14         """
15         rows, seat_letters = self._aircraft.seating_plan()
16 
17         letter = seat[-1]
18         if letter not in seat_letters:
19             raise ValueError("Invalid seat letter {}".format(letter))
20 
21         row_text = seat[:-1]
22         try:
23             row = int(row_text)
24         except ValueError:
25             raise ValueError("Invalid seat row {}".format(row_text))
26 
27         if row not in rows:
28             raise ValueError("Invalid row number {}".format(row))
29 
30         if self._seating[row][letter] is not None:
31             raise ValueError("Seat {} already occupied".format(seat))
32 
33         self._seating[row][letter] = passenger

大部分代码都是座位指示符的验证,其中包含一些有趣的片段:

  • 第 6 行:方法是函数,因此也应该有文档字符串。

  • 第 17 行:我们通过在seat字符串中使用负索引来获取座位字母。

  • 第 18 行:我们通过使用in成员测试运算符检查seat_letters的成员资格来测试座位字母是否有效。

  • 第 21 行:我们使用字符串切片提取行号,以去掉最后一个字符。

  • 第 23 行:我们尝试使用int()构造函数将行号子字符串转换为整数。如果失败,我们捕获ValueError,并在处理程序中引发一个更合适的消息负载的ValueError

  • 第 27 行:我们通过使用in运算符对rows对象进行验证行号。我们可以这样做,因为range()对象支持容器协议。

  • 第 30 行:我们使用None进行身份测试来检查请求的座位是否空闲。如果被占用,我们会引发ValueError

  • 第 33 行:如果我们走到这一步,一切都很好,我们可以分配座位。

这段代码也包含一个错误,我们很快就会发现!

在 REPL 中尝试我们的座位分配器:

>>> from airtravel import *
>>> f = Flight("BA758", Aircraft("G-EUPT", "Airbus A319",
...            num_rows=22, num_seats_per_row=6))
>>> f.allocate_seat('12A', 'Guido van Rossum')
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  TypeError: allocate_seat() takes 2 positional arguments but 3 were given

哦,天哪!在你的面向对象的 Python 职业生涯早期,你很可能经常会看到像这样的TypeError消息。问题出现在我们忘记在allocate_seat()方法的定义中包含self参数:

def allocate_seat(self, seat, passenger):
    # ...

一旦我们修复了这个问题,我们可以再试一次:

>>> from airtravel import *
>>> from pprint import pprint as pp
>>> f = Flight("BA758", Aircraft("G-EUPT", "Airbus A319",
...            num_rows=22, num_seats_per_row=6))
>>> f.allocate_seat('12A', 'Guido van Rossum')
>>> f.allocate_seat('12A', 'Rasmus Lerdorf')
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "./airtravel.py", line 57, in allocate_seat
    raise ValueError("Seat {} already occupied".format(seat))
ValueError: Seat 12A already occupied
>>> f.allocate_seat('15F', 'Bjarne Stroustrup')
>>> f.allocate_seat('15E', 'Anders Hejlsberg')
>>> f.allocate_seat('E27', 'Yukihiro Matsumoto')
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "./airtravel.py", line 45, in allocate_seat
    raise ValueError("Invalid seat letter {}".format(letter))
ValueError: Invalid seat letter 7
>>> f.allocate_seat('1C', 'John McCarthy')
>>> f.allocate_seat('1D', 'Richard Hickey')
>>> f.allocate_seat('DD', 'Larry Wall')
Traceback (most recent call last):
  File "./airtravel.py", line 49, in allocate_seat
    row = int(row_text)
ValueError: invalid literal for int() with base 10: 'D'

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "./airtravel.py", line 51, in allocate_seat
    raise ValueError("Invalid seat row {}".format(row_text))
ValueError: Invalid seat row D

>>> pp(f._seating)
[None,
  {'A': None,
  'B': None,
  'C': 'John McCarthy',
  'D': 'Richard Hickey',
  'E': None,
  'F': None},
  {'A': None, 'B': None, 'C': None, 'D': None, 'E': None, 'F': None},
  {'A': None, 'B': None, 'C': None, 'D': None, 'E': None, 'F': None},
  {'A': None, 'B': None, 'C': None, 'D': None, 'E': None, 'F': None},
  {'A': None, 'B': None, 'C': None, 'D': None, 'E': None, 'F': None},
  {'A': None, 'B': None, 'C': None, 'D': None, 'E': None, 'F': None},
  {'A': None, 'B': None, 'C': None, 'D': None, 'E': None, 'F': None},
  {'A': None, 'B': None, 'C': None, 'D': None, 'E': None, 'F': None},
  {'A': None, 'B': None, 'C': None, 'D': None, 'E': None, 'F': None},
  {'A': None, 'B': None, 'C': None, 'D': None, 'E': None, 'F': None},
  {'A': None, 'B': None, 'C': None, 'D': None, 'E': None, 'F': None},
  {'A': 'Guido van Rossum',
  'B': None,
  'C': None,
  'D': None,
  'E': None,
  'F': None},
  {'A': None, 'B': None, 'C': None, 'D': None, 'E': None, 'F': None},
  {'A': None, 'B': None, 'C': None, 'D': None, 'E': None, 'F': None},
  {'A': None,
  'B': None,
  'C': None,
  'D': None,
  'E': 'Anders Hejlsberg',
  'F': 'Bjarne Stroustrup'},
  {'A': None, 'B': None, 'C': None, 'D': None, 'E': None, 'F': None},
  {'A': None, 'B': None, 'C': None, 'D': None, 'E': None, 'F': None},
  {'A': None, 'B': None, 'C': None, 'D': None, 'E': None, 'F': None},
  {'A': None, 'B': None, 'C': None, 'D': None, 'E': None, 'F': None},
  {'A': None, 'B': None, 'C': None, 'D': None, 'E': None, 'F': None},
  {'A': None, 'B': None, 'C': None, 'D': None, 'E': None, 'F': None},
  {'A': None, 'B': None, 'C': None, 'D': None, 'E': None, 'F': None}]

荷兰人在 12 排有些孤单,所以我们想把他和丹麦人一起移回 15 排。为此,我们需要一个relocate_passenger()方法。

为实现细节命名方法

首先,我们将进行一些小的重构,并将座位标识符解析和验证逻辑提取到它自己的方法_parse_seat()中。我们在这里使用了前导下划线,因为这个方法是一个实现细节:

class Flight:

    # ...

    def _parse_seat(self, seat):
        """Parse a seat designator into a valid row and letter.

 Args:
 seat: A seat designator such as 12F

 Returns:
 A tuple containing an integer and a string for row and seat.
 """
        row_numbers, seat_letters = self._aircraft.seating_plan()

        letter = seat[-1]
        if letter not in seat_letters:
            raise ValueError("Invalid seat letter {}".format(letter))

        row_text = seat[:-1]
        try:
            row = int(row_text)
        except ValueError:
            raise ValueError("Invalid seat row {}".format(row_text))

        if row not in row_numbers:
            raise ValueError("Invalid row number {}".format(row))

        return row, letter

新的_parse_seat()方法返回一个整数行号和一个座位字母字符串的元组。这使得allocate_seat()变得更简单:

def allocate_seat(self, seat, passenger):
    """Allocate a seat to a passenger.

 Args:
 seat: A seat designator such as '12C' or '21F'.
 passenger: The passenger name.

 Raises:
 ValueError: If the seat is unavailable.
 """
    row, letter = self._parse_seat(seat)

    if self._seating[row][letter] is not None:
        raise ValueError("Seat {} already occupied".format(seat))

    self._seating[row][letter] = passenger

注意到调用_parse_seat()也需要使用self前缀进行显式限定。

实现relocate_passenger()

现在我们已经为我们的relocate_passenger()方法奠定了基础:

class Flight:

    # ...

    def relocate_passenger(self, from_seat, to_seat):
        """Relocate a passenger to a different seat.

 Args:
 from_seat: The existing seat designator for the
 passenger to be moved.

 to_seat: The new seat designator.
 """
        from_row, from_letter = self._parse_seat(from_seat)
        if self._seating[from_row][from_letter] is None:
            raise ValueError("No passenger to relocate in seat {}".format(from_seat))

        to_row, to_letter = self._parse_seat(to_seat)
        if self._seating[to_row][to_letter] is not None:
            raise ValueError("Seat {} already occupied".format(to_seat))

        self._seating[to_row][to_letter] = self._seating[from_row][from_letter]
        self._seating[from_row][from_letter] = None

这解析和验证了from_seatto_seat参数,然后将乘客移动到新位置。

每次重新创建Flight对象也变得很烦人,所以我们也会为此添加一个模块级别的便利函数:

def make_flight():
    f = Flight("BA758", Aircraft("G-EUPT", "Airbus A319",
                num_rows=22, num_seats_per_row=6))
    f.allocate_seat('12A', 'Guido van Rossum')
    f.allocate_seat('15F', 'Bjarne Stroustrup')
    f.allocate_seat('15E', 'Anders Hejlsberg')
    f.allocate_seat('1C', 'John McCarthy')
    f.allocate_seat('1D', 'Richard Hickey')
    return f

在 Python 中,将相关的函数和类混合放在同一个模块中是非常正常的。现在,从 REPL:

>>> from airtravel import make_flight
>>> f = make_flight()
>>> f
<airtravel.Flight object at 0x1007a6690>

你可能会觉得很奇怪,我们只导入了一个函数make_flight,但我们却可以访问Flight类。这是非常正常的,这是 Python 动态类型系统的一个强大方面,它促进了代码之间的这种非常松散的耦合。

让我们继续把 Guido 移回到 15 排和他的欧洲同胞一起:

>>> f.relocate_passenger('12A', '15D')
>>> from pprint import pprint as pp
>>> pp(f._seating)
[None,
  {'A': None,
  'B': None,
  'C': 'John McCarthy',
  'D': 'Richard Hickey',
  'E': None,
  'F': None},
  {'A': None, 'B': None, 'C': None, 'D': None, 'E': None, 'F': None},
  {'A': None, 'B': None, 'C': None, 'D': None, 'E': None, 'F': None},
  {'A': None, 'B': None, 'C': None, 'D': None, 'E': None, 'F': None},
  {'A': None, 'B': None, 'C': None, 'D': None, 'E': None, 'F': None},
  {'A': None, 'B': None, 'C': None, 'D': None, 'E': None, 'F': None},
  {'A': None, 'B': None, 'C': None, 'D': None, 'E': None, 'F': None},
  {'A': None, 'B': None, 'C': None, 'D': None, 'E': None, 'F': None},
  {'A': None, 'B': None, 'C': None, 'D': None, 'E': None, 'F': None},
  {'A': None, 'B': None, 'C': None, 'D': None, 'E': None, 'F': None},
  {'A': None, 'B': None, 'C': None, 'D': None, 'E': None, 'F': None},
  {'A': None, 'B': None, 'C': None, 'D': None, 'E': None, 'F': None},
  {'A': None, 'B': None, 'C': None, 'D': None, 'E': None, 'F': None},
  {'A': None, 'B': None, 'C': None, 'D': None, 'E': None, 'F': None},
  {'A': None,
  'B': None,
  'C': None,
  'D': 'Guido van Rossum',
  'E': 'Anders Hejlsberg',
  'F': 'Bjarne Stroustrup'},
  {'A': None, 'B': None, 'C': None, 'D': None, 'E': None, 'F': None},
  {'A': None, 'B': None, 'C': None, 'D': None, 'E': None, 'F': None},
  {'A': None, 'B': None, 'C': None, 'D': None, 'E': None, 'F': None},
  {'A': None, 'B': None, 'C': None, 'D': None, 'E': None, 'F': None},
  {'A': None, 'B': None, 'C': None, 'D': None, 'E': None, 'F': None},
  {'A': None, 'B': None, 'C': None, 'D': None, 'E': None, 'F': None},
  {'A': None, 'B': None, 'C': None, 'D': None, 'E': None, 'F': None}]

计算可用座位

在预订期间知道有多少个座位是很重要的。为此,我们将编写一个num_available_seats()方法。这使用了两个嵌套的生成器表达式。外部表达式过滤出所有不是None的行,以排除我们的虚拟第一行。外部表达式中每个项目的值是每行中None值的总和。内部表达式遍历字典的值,并为每个找到的None添加 1:

def num_available_seats(self):
    return sum( sum(1 for s in row.values() if s is None)
                for row in self._seating
                if row is not None )

注意我们如何将外部表达式分成三行以提高可读性。

>>> from airtravel import make_flight
>>> f = make_flight()
>>> f.num_available_seats()
127

快速检查显示我们的新计算是正确的:

>>> 6 * 22 - 5
127

有时你只需要一个函数

现在我们将展示如何在不需要类的情况下编写良好的面向对象代码是完全可能的。我们需要按字母顺序为乘客制作登机牌。但是,我们意识到航班类可能不是打印登机牌的细节的好位置。我们可以继续创建一个BoardingCardPrinter类,尽管这可能有些过度。记住,函数也是对象,对于许多情况来说完全足够。不要觉得没有充分理由就要创建类。

我们不希望让卡片打印机从航班中查询所有乘客的详细信息,我们将遵循面向对象设计原则“告诉!不要问。”,让Flight 告诉一个简单的卡片打印函数该做什么。

首先是卡片打印机,它只是一个模块级函数:

def console_card_printer(passenger, seat, flight_number, aircraft):
    output = "| Name: {0}"     \
              "  Flight: {1}"   \
              "  Seat: {2}"     \
              "  Aircraft: {3}" \
              " |".format(passenger, flight_number, seat, aircraft)
    banner = '+' + '-' * (len(output) - 2) + '+'
    border = '|' + ' ' * (len(output) - 2) + '|'
    lines = [banner, border, output, border, banner]
    card = '\n'.join(lines)
    print(card)
    print()

我们在这里引入的一个 Python 特性是使用行继续反斜杠字符‘\’,它允许我们将长语句分成几行。这里使用了它,连同相邻字符串的隐式连接,以产生一个没有换行的长字符串。

我们测量这个输出行的长度,围绕它建立一些横幅和边框,然后使用join()方法将行连接在一起,该方法在换行符上调用。然后打印整张卡片,然后是一个空行。卡片打印机对FlightsAircraft一无所知-它们之间的耦合非常松散。您可能很容易想象具有相同接口的 HTML 卡片打印机。

使Flight创建登机牌

我们向Flight类添加一个新方法make_boarding_cards(),它接受一个card_printer

class Flight:

    # ...

    def make_boarding_cards(self, card_printer):
        for passenger, seat in sorted(self._passenger_seats()):
            card_printer(passenger, seat, self.number(), self.aircraft_model())

这告诉card_printer打印每个乘客,已经排序了从_passenger_seats()实现细节方法(注意前导下划线)获得的乘客-座位元组列表。实际上,这个方法是一个生成器函数,它搜索所有座位的占用情况,找到后产生乘客和座位号:

def _passenger_seats(self):
    """An iterable series of passenger seating allocations."""
    row_numbers, seat_letters = self._aircraft.seating_plan()
    for row in row_numbers:
        for letter in seat_letters:
            passenger = self._seating[row][letter]
            if passenger is not None:
                yield (passenger, "{}{}".format(row, letter))

现在,如果我们在 REPL 上运行这个,我们可以看到新的登机牌打印系统起作用了:

>>> from airtravel import console_card_printer, make_flight
>>> f = make_flight()
>>> f.make_boarding_cards(console_card_printer)
+-------------------------------------------------------------------------+
|                                                                         |
| Name: Anders Hejlsberg  Flight: BA758  Seat: 15E  Aircraft: Airbus A319 |
|                                                                         |
+-------------------------------------------------------------------------+

+--------------------------------------------------------------------------+
|                                                                          |
| Name: Bjarne Stroustrup  Flight: BA758  Seat: 15F  Aircraft: Airbus A319 |
|                                                                          |
+--------------------------------------------------------------------------+

+-------------------------------------------------------------------------+
|                                                                         |
| Name: Guido van Rossum  Flight: BA758  Seat: 12A  Aircraft: Airbus A319 |
|                                                                         |
+-------------------------------------------------------------------------+

+---------------------------------------------------------------------+
|                                                                     |
| Name: John McCarthy  Flight: BA758  Seat: 1C  Aircraft: Airbus A319 |
|                                                                     |
+---------------------------------------------------------------------+

+----------------------------------------------------------------------+
|                                                                      |
| Name: Richard Hickey  Flight: BA758  Seat: 1D  Aircraft: Airbus A319 |
|                                                                      |
+----------------------------------------------------------------------+

多态和鸭子类型

多态是一种编程语言特性,它允许我们通过统一接口使用不同类型的对象。多态的概念适用于函数和更复杂的对象。我们刚刚在卡片打印示例中看到了多态的一个例子。make_boarding_card()方法不需要知道实际的-或者我们说“具体的”-卡片打印类型,只需要知道其接口的抽象细节。这个接口本质上只是它的参数顺序。用假想的html_card_printer替换我们的console_card_printer将会实现多态。

Python 中的多态是通过鸭子类型实现的。鸭子类型又以美国诗人詹姆斯·惠特科姆·赖利的“鸭子测试”而命名。

詹姆斯·惠特科姆·赖利-美国诗人和作家

詹姆斯·惠特科姆·赖利-美国诗人和作家

当我看到一只走路像鸭子、游泳像鸭子、嘎嘎叫像鸭子的鸟时,我就称那只鸟为鸭子。

鸭子类型,其中对象的适用性仅在运行时确定,是 Python 对象系统的基石。这与许多静态类型的语言不同,其中编译器确定对象是否可以使用。特别是,这意味着对象的适用性不是基于继承层次结构、基类或除了对象在使用时具有的属性之外的任何东西。

这与诸如 Java 之类的语言形成鲜明对比,后者依赖于所谓的名义子类型,通过从基类和接口继承。我们很快会在 Python 的上下文中更多地讨论继承。

重构Aircraft

让我们回到我们的Aircraft类:

class Aircraft:

    def __init__(self, registration, model, num_rows, num_seats_per_row):
        self._registration = registration
        self._model = model
        self._num_rows = num_rows
        self._num_seats_per_row = num_seats_per_row

    def registration(self):
        return self._registration

    def model(self):
        return self._model

    def seating_plan(self):
        return (range(1, self._num_rows + 1),
                "ABCDEFGHJK"[:self._num_seats_per_row])

这个类的设计有些缺陷,因为使用它实例化的对象依赖于提供与飞机型号匹配的座位配置。在这个练习中,我们可以假设每架飞机型号的座位安排是固定的。

也许更好、更简单的方法是完全摆脱Aircraft类,并为每种特定型号的飞机制作单独的类,具有固定的座位配置。这是空中客车 A319:

class AirbusA319:

    def __init__(self, registration):
        self._registration = registration

    def registration(self):
        return self._registration

    def model(self):
        return "Airbus A319"

    def seating_plan(self):
        return range(1, 23), "ABCDEF"

这是波音 777:

class Boeing777:

    def __init__(self, registration):
        self._registration = registration

    def registration(self):
        return self._registration

    def model(self):
        return "Boeing 777"

    def seating_plan(self):
        # For simplicity's sake, we ignore complex
        # seating arrangement for first-class
        return range(1, 56), "ABCDEGHJK"

这两个飞机类与彼此或我们原始的Aircraft类之间没有明确的关系,除了具有相同的接口(初始化程序除外,现在需要的参数更少)。因此,我们可以在彼此之间使用这些新类型。

让我们将我们的make_flight()方法更改为make_flights(),这样我们就可以使用它们了:

def make_flights():
    f = Flight("BA758", AirbusA319("G-EUPT"))
    f.allocate_seat('12A', 'Guido van Rossum')
    f.allocate_seat('15F', 'Bjarne Stroustrup')
    f.allocate_seat('15E', 'Anders Hejlsberg')
    f.allocate_seat('1C', 'John McCarthy')
    f.allocate_seat('1D', 'Richard Hickey')

    g = Flight("AF72", Boeing777("F-GSPS"))
    g.allocate_seat('55K', 'Larry Wall')
    g.allocate_seat('33G', 'Yukihiro Matsumoto')
    g.allocate_seat('4B', 'Brian Kernighan')
    g.allocate_seat('4A', 'Dennis Ritchie')

    return f, g

不同类型的飞机在与Flight一起使用时都可以正常工作,因为它们都像鸭子一样嘎嘎叫。或者像飞机一样飞。或者其他什么:

>>> from airtravel import *
>>> f, g = make_flights()
>>> f.aircraft_model()
'Airbus A319'
>>> g.aircraft_model()
'Boeing 777'
>>> f.num_available_seats()
127
>>> g.num_available_seats()
491
>>> g.relocate_passenger('55K', '13G')
>>> g.make_boarding_cards(console_card_printer)
+---------------------------------------------------------------------+
|                                                                     |
| Name: Brian Kernighan  Flight: AF72  Seat: 4B  Aircraft: Boeing 777 |
|                                                                     |
+---------------------------------------------------------------------+

+--------------------------------------------------------------------+
|                                                                    |
| Name: Dennis Ritchie  Flight: AF72  Seat: 4A  Aircraft: Boeing 777 |
|                                                                    |
+--------------------------------------------------------------------+

+-----------------------------------------------------------------+
|                                                                 |
| Name: Larry Wall  Flight: AF72  Seat: 13G  Aircraft: Boeing 777 |
|                                                                 |
+-----------------------------------------------------------------+

+-------------------------------------------------------------------------+
|                                                                         |
| Name: Yukihiro Matsumoto  Flight: AF72  Seat: 33G  Aircraft: Boeing 777 |
|                                                                         |
+-------------------------------------------------------------------------+

鸭子类型和多态在 Python 中非常重要。事实上,它是我们讨论的集合协议的基础,如迭代器可迭代序列

继承和实现共享

继承是一种机制,其中一个类可以从基类派生,从而使我们能够在子类中使行为更具体。在像 Java 这样的名义类型语言中,基于类的继承是实现运行时多态性的手段。但在 Python 中并非如此,正如我们刚刚展示的那样。直到调用方法或属性查找的实际对象绑定到对象时,即延迟绑定,我们才能尝试使用任何对象进行多态,并且如果对象合适,它将成功。

尽管 Python 中的继承可以用于促进多态性——毕竟,派生类将具有与基类相同的接口——但 Python 中的继承最有用的是在类之间共享实现。

飞机的基类

像往常一样,通过示例会更容易理解。我们希望我们的飞机类AirbusA319Boeing777提供一种返回总座位数的方法。我们将在两个类中添加一个名为num_seats()的方法来实现这一点:

def num_seats(self):
    rows, row_seats = self.seating_plan()
    return len(rows) * len(row_seats)

由于可以从座位计划中计算出来,所以两个类中的实现可以是相同的。

不幸的是,现在我们在两个类中有重复的代码,随着我们添加更多的飞机类型,代码重复将变得更糟。

解决方案是将AirbusA319Boeing777的共同元素提取到一个基类中,两种飞机类型都将从中派生。让我们重新创建Aircraft类,这次的目标是将其用作基类:

class Aircraft:

    def num_seats(self):
        rows, row_seats = self.seating_plan()
        return len(rows) * len(row_seats)

Aircraft类只包含我们想要继承到派生类中的方法。这个类本身无法使用,因为它依赖于一个叫做seating_plan()的方法,这个方法在这个级别不可用。任何尝试单独使用它都会失败:

>>> from airtravel import *
>>> base = Aircraft()
>>> base.num_seats()
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "./airtravel.py", line 125, in num_seats
    rows, row_seats = self.seating_plan()
AttributeError: 'Aircraft' object has no attribute 'seating_plan'

类在抽象方面是不可用的,因为单独实例化它是没有用的。

Aircraft继承

现在是派生类。我们使用括号在class语句中的类名后面立即包含基类名来指定 Python 中的继承。

这是空客类:

class AirbusA319(Aircraft):

    def __init__(self, registration):
        self._registration = registration

    def registration(self):
        return self._registration

    def model(self):
        return "Airbus A319"

    def seating_plan(self):
        return range(1, 23), "ABCDEF"

这是波音类:

class Boeing777(Aircraft):

    def __init__(self, registration):
        self._registration = registration

    def registration(self):
        return self._registration

    def model(self):
        return "Boeing 777"

    def seating_plan(self):
        # For simplicity's sake, we ignore complex
        # seating arrangement for first-class
        return range(1, 56), "ABCDEGHJK"

让我们在 REPL 中练习一下:

>>> from airtravel import *
>>> a = AirbusA319("G-EZBT")
>>> a.num_seats()
132
>>> b = Boeing777("N717AN")
>>> b.num_seats()
495

我们可以看到两个子类型飞机都继承了num_seats方法,现在它可以正常工作,因为在运行时成功解析了对seating_plan()的调用。

将通用功能提升到基类

现在我们有了基本的Aircraft类,我们可以通过将其他通用功能提升到其中来进行重构。例如,初始化程序和registration()方法在两个子类型之间是相同的:

class Aircraft:

    def __init__(self, registration):
        self._registration = registration

    def registration(self):
        return self._registration

    def num_seats(self):
        rows, row_seats = self.seating_plan()
        return len(rows) * len(row_seats)

class AirbusA319(Aircraft):

    def model(self):
        return "Airbus A319"

    def seating_plan(self):
        return range(1, 23), "ABCDEF"

class Boeing777(Aircraft):

    def model(self):
        return "Boeing 777"

    def seating_plan(self):
        # For simplicities sake, we ignore complex
        # seating arrangement for first-class
        return range(1, 56), "ABCDEGHJK"

这些派生类只包含该飞机类型的具体信息。所有通用功能都是通过继承从基类中共享的。

由于鸭子类型的存在,继承在 Python 中的使用要少于其他语言。这通常被认为是一件好事,因为继承是类之间非常紧密的耦合。

摘要

  • Python 中的所有类型都有一个“类”。

  • 类定义了对象的结构和行为。

  • 对象的类是在创建对象时确定的,几乎总是在对象的生命周期内固定的。

  • 类是 Python 中面向对象编程的关键支持。

  • 类是使用class关键字定义的,后面跟着类名,类名采用驼峰命名法。

  • 类的实例是通过调用类来创建的,就好像它是一个函数一样。

  • 实例方法是在类内部定义的函数,应该接受一个名为self的对象实例作为第一个参数。

  • 方法是使用instance.method()语法调用的,这是将实例作为形式参数self传递给方法的语法糖。

  • 可以提供一个可选的特殊初始化方法__init__(),用于在创建时配置self对象。

  • 如果存在构造函数,则调用__init__()方法。

  • __init__()方法不是构造函数。在初始化程序被调用时,对象已经被构造。初始化程序在返回给构造函数的调用者之前配置新创建的对象。

  • 传递给构造函数的参数将转发到初始化程序。

  • 实例属性通过分配给它们而存在。

  • 按照惯例,实现细节的属性和方法以下划线为前缀。Python 中没有公共、受保护或私有访问修饰符。

  • 从类外部访问实现细节在开发、测试和调试过程中非常有用。

  • 类不变量应该在初始化程序中建立。如果不变量无法建立,则引发异常以表示失败。

  • 方法可以有文档字符串,就像常规函数一样。

  • 类可以有文档字符串。

  • 即使在对象方法内部,方法调用也必须用self限定。

  • 你可以在一个模块中拥有任意多的类和函数。相关的类和全局函数通常以这种方式分组在一起。

  • Python 中的多态是通过鸭子类型实现的,其中属性和方法仅在使用时解析 - 这种行为称为延迟绑定。

  • Python 中的多态不需要共享基类或命名接口。

  • Python 中的类继承主要用于共享实现,而不是必须的多态。

  • 所有方法都被继承,包括初始方法。

在这个过程中,我们发现:

  • 字符串支持切片,因为它们实现了序列协议。

  • 遵循迪米特法则可以减少耦合。

  • 我们可以嵌套理解。

  • 有时候,在理解中丢弃当前项目是有用的,使用一个虚拟引用,通常是下划线。

  • 处理基于一的集合时,通常更容易只浪费第零个列表条目。

  • 当一个简单的函数足够时,不要感到被迫使用类。函数也是对象。

  • 复杂的理解或生成器表达式可以分成多行以帮助可读性。

  • 语句可以使用反斜杠行继续字符分成多行。只有在提高可读性时才节俭地使用这个功能。

  • 面向对象的设计,其中一个对象告诉另一个对象信息,可以比其中一个对象查询另一个对象更松散耦合。“告诉!不要问。”

第十章:文件和资源管理

读写文件是许多现实世界程序的关键部分。然而,文件的概念有点抽象。在某些情况下,文件可能意味着硬盘上的一系列字节;在其他情况下,它可能意味着例如远程系统上的 HTTP 资源。这两个实体共享一些行为。例如,您可以从每个实体中读取一系列字节。同时,它们并不相同。例如,您通常可以将字节写回本地文件,而无法对 HTTP 资源进行这样的操作。

在本章中,我们将看一下 Python 对文件的基本支持。由于处理本地文件既常见又重要,我们将主要关注与它们一起工作。但请注意,Python 及其库生态系统为许多其他类型的实体提供了类似文件的 API,包括基于 URI 的资源、数据库和许多其他数据源。这种使用通用 API 非常方便,使得可以编写可以在各种数据源上工作而无需更改的代码变得容易。

在本章中,我们还将看一下上下文管理器,这是 Python 管理资源的主要手段之一。上下文管理器允许您编写在发生异常时健壮且可预测的代码,确保资源(如文件)在发生错误时被正确关闭和处理。

文件

要在 Python 中打开本地文件,我们调用内置的open()函数。这需要一些参数,但最常用的是:

  • file:文件的路径。这是必需的

  • mode:读取、写入、追加和二进制或文本。这是可选的,但我们建议始终明确指定以便清晰。显式优于隐式。

  • encoding:如果文件包含编码的文本数据,要使用哪种编码。通常最好指定这一点。如果不指定,Python 将为您选择默认编码。

二进制和文本模式

在文件系统级别,当然,文件只包含一系列字节。然而,Python 区分以二进制和文本模式打开的文件,即使底层操作系统没有这样做。当您以二进制模式打开文件时,您正在指示 Python 使用文件中的数据而不进行任何解码;二进制模式文件反映了文件中的原始数据。

另一方面,以文本模式打开的文件将其内容视为包含str类型的文本字符串。当您从文本模式文件中获取数据时,Python 首先使用平台相关的编码或者open()encoding参数对原始字节进行解码。

默认情况下,文本模式文件还支持 Python 的通用换行符。这会导致我们程序字符串中的单个可移植换行符('\n')与文件系统中存储的原始字节中的平台相关换行表示(例如 Windows 上的回车换行('\r\n'))之间的转换。

编码的重要性

正确编码对于正确解释文本文件的内容至关重要,因此我们希望重点强调一下。Python^(24)无法可靠地确定文本文件的编码,因此不会尝试。然而,如果不知道文件的编码,Python 就无法正确操作文件中的数据。这就是为什么告诉 Python 要使用哪种编码非常重要。

如果您不指定编码,Python 将使用sys.getdefaultencoding()中的默认编码。在我们的情况下,默认编码是'utf-8'

>>> import sys
>>> sys.getdefaultencoding()
'utf-8'

但请记住,您的系统上的默认编码与您希望交换文件的另一个系统上的默认编码可能不同。最好是为了所有相关方都明确决定文本到字节的编码,通过在对open()的调用中指定它。您可以在Python 文档中获取支持的文本编码列表。

打开文件进行写入

让我们通过以写入模式打开文件来开始处理文件。我们将明确使用 UTF-8 编码,因为我们无法知道您的默认编码是什么。我们还将使用关键字参数使事情更加清晰:

>>> f = open('wasteland.txt', mode='wt', encoding='utf-8')

第一个参数是文件名。mode参数是一个包含不同含义字母的字符串。在这种情况下,‘w’表示写入,‘t’表示文本

所有模式字符串应该由读取写入追加模式中的一个组成。此表列出了模式代码以及它们的含义:

代码 意义
r 以读取模式打开文件。流定位在
文件的开头。这是默认设置。
r+ 用于读取和写入。流定位在
文件的开头。
w 截断文件至零长度或创建文件以进行写入。
流定位在文件的开头。
w+ 用于读取和写入。如果文件不存在,则创建
存在,则截断。流定位在
文件的开头。
a 用于写入。如果文件不存在,则创建
流定位在文件的末尾。后续写入
文件的写入将始终结束在文件的当前末尾
无论有任何寻址或类似。
a+ 用于读取和写入。如果文件不存在,则创建文件
存在。流定位在文件的末尾。
对文件的后续写入将始终结束在文件的当前末尾
无论有任何寻址或
类似。

前面的内容之一应与下表中的选择器结合使用,以指定文本二进制模式:

代码 意义
t 文件内容被解释为编码文本字符串。从文件中接受和返回
文件将根据指定的文本编码进行编码和解码,并进行通用换行符转换
指定的文本编码,并且通用换行符转换将
生效(除非明确禁用)。所有写入方法
str对象。
这是默认设置
b 文件内容被视为原始字节。所有写入方法
从文件中接受和返回bytes对象。

典型模式字符串的示例可能是'wb'表示“写入二进制”,或者'at'表示“追加文本”。虽然模式代码的两部分都支持默认设置,但为了可读性起见,我们建议明确指定。

open()返回的对象的确切类型取决于文件的打开方式。这就是动态类型的作用!然而,对于大多数目的来说,open()返回的实际类型并不重要。知道返回的对象是类似文件的对象就足够了,因此我们可以期望它支持某些属性和方法。

向文件写入

我们之前已经展示了如何请求模块、方法和类型的help(),但实际上我们也可以请求实例的帮助。当你记住一切都是对象时,这是有意义的。

>>> help(f)
. . .
 |  write(self, text, /)
 |      Write string to stream.
 |      Returns the number of characters written (which is always equal to
 |      the length of the string).
. . .

浏览帮助文档,我们可以看到f支持write()方法。使用‘q’退出帮助,并在 REPL 中继续。

现在让我们使用write()方法向文件写入一些文本:

>>> f.write('What are the roots that clutch, ')
32

write()的调用返回写入文件的代码点或字符数。让我们再添加几行:

>>> f.write('what branches grow\n')
19
>>> f.write('Out of this stony rubbish? ')
27

你会注意到我们在写入文件时明确包括换行符。调用者有责任在需要时提供换行符;Python 不提供writeline()方法。

关闭文件

当我们完成写入后,应该记得通过调用close()方法关闭文件:

>>> f.close()

请注意,只有在关闭文件后,我们才能确保我们写入的数据对外部进程可见。关闭文件很重要!

还要记住,关闭文件后就不能再从文件中读取或写入。这样做会导致异常。

Python 之外的文件

如果现在退出 REPL,并查看你的文件系统,你会看到你确实创建了一个文件。在 Unix 上使用ls命令:

$ ls -l
-rw-r--r--   1 rjs  staff    78 12 Jul 11:21 wasteland.txt

你应该看到wasteland.txt文件大小为 78 字节。

在 Windows 上使用dir

> dir
 Volume is drive C has no label.
 Volume Serial Number is 36C2-FF83

 Directory of c:\Users\pyfund

12/07/2013  20:54                79 wasteland.txt
 1 File(s)             79 bytes
 0 Dir(s)  190,353,698,816 bytes free

在这种情况下,你应该看到wasteland.txt大小为 79 字节,因为 Python 对文件的通用换行行为已经将行尾转换为你平台的本地行尾。

write()方法返回的数字是传递给write()的字符串中的码点(或字符)的数量,而不是编码和通用换行符转换后写入文件的字节数。通常情况下,在处理文本文件时,你不能通过write()返回的数量之和来确定文件的字节长度。

读取文件

要读取文件,我们再次使用open(),但这次我们以'rt'作为模式,表示读取文本

>>> g = open('wasteland.txt', mode='rt', encoding='utf-8')

如果我们知道要读取多少字节,或者想要读取整个文件,我们可以使用read()。回顾我们的 REPL,我们可以看到第一次写入是 32 个字符长,所以让我们用read()方法读取回来:

>>> g.read(32)
'What are the roots that clutch, '

在文本模式下,read()方法接受要从文件中读取的字符数,而不是字节数。调用返回文本并将文件指针移动到所读取内容的末尾。因为我们以文本模式打开文件,返回类型是str

要读取文件中所有剩余的数据,我们可以调用read()而不带参数:

>>> g.read()
'what branches grow\nOut of this stony rubbish? '

这给我们一个字符串中的两行部分 —— 注意中间的换行符。

在文件末尾,进一步调用read()会返回一个空字符串:

>>> g.read()
''

通常情况下,当我们完成读取文件时,会使用close()关闭文件。不过,为了本练习的目的,我们将保持文件处于打开状态,并使用参数为零的seek()将文件指针移回文件的开头:

>>> g.seek(0)
0

seek()的返回值是新的文件指针位置。

逐行读取

对于文本使用read()相当麻烦,幸运的是 Python 提供了更好的工具来逐行读取文本文件。其中第一个就是readline()函数:

>>> g.readline()
'What are the roots that clutch, what branches grow\n'
>>> g.readline()
'Out of this stony rubbish? '

每次调用readline()都会返回一行文本。如果文件中存在换行符,返回的行将以单个换行符结尾。

这里的最后一行没有以换行符结尾,因为文件末尾没有换行序列。你不应该依赖readline()返回的字符串以换行符结尾。还要记住,通用换行符支持会将平台本地的换行序列转换为'\n'

一旦我们到达文件末尾,进一步调用readline()会返回一个空字符串:

>>> g.readline()
''

一次读取多行

让我们再次将文件指针倒回并以不同的方式读取文件:

>>> g.seek(0)

有时,当我们知道我们想要读取文件中的每一行时 —— 并且如果我们确信有足够的内存来这样做 —— 我们可以使用readlines()方法将文件中的所有行读入列表中:

>>> g.readlines()
['What are the roots that clutch, what branches grow\n',
'Out of this stony rubbish? ']

如果解析文件涉及在行之间来回跳转,这将特别有用;使用行列表比使用字符流更容易。

这次,在继续之前我们会关闭文件:

>>> g.close()

追加到文件

有时我们希望追加到现有文件中,我们可以通过使用模式'a'来实现。在这种模式下,文件被打开以进行写入,并且文件指针被移动到任何现有数据的末尾。在这个例子中,我们将'a''t'结合在一起,以明确使用文本模式:

>>> h = open('wasteland.txt', mode='at', encoding='utf-8')

虽然 Python 中没有writeline()方法,但有一个writelines()方法,它可以将可迭代的字符串系列写入流。如果您希望在字符串上有行结束符*,则必须自己提供。这乍一看可能有点奇怪,但它保持了与readlines()的对称性,同时也为我们使用writelines()将任何可迭代的字符串系列写入文件提供了灵活性:

>>> h.writelines(
... ['Son of man,\n',
... 'You cannot say, or guess, ',
... 'for you know only,\n',
... 'A heap of broken images, ',
... 'where the sun beats\n'])
>>> h.close()

请注意,这里只完成了三行——我们说完成,因为我们追加的文件本身没有以换行符结束。

文件对象作为迭代器

这些越来越复杂的文本文件读取工具的顶点在于文件对象支持迭代器协议。当您在文件上进行迭代时,每次迭代都会产生文件中的下一行。这意味着它们可以在 for 循环和任何其他可以使用迭代器的地方使用。

此时,我们有机会创建一个 Python 模块文件files.py

import sys

def main(filename):
    f = open(filename, mode='rt', encoding='utf-8')
    for line in f:
        print(line)
    f.close()

if __name__ == '__main__':
    main(sys.argv[1])

我们可以直接从系统命令行调用它,传递我们的文本文件的名称:

$ python3 files.py wasteland.txt
What are the roots that clutch, what branches grow

Out of this stony rubbish? Son of man,

You cannot say, or guess, for you know only

A heap of broken images, where the sun beats

您会注意到诗歌的每一行之间都有空行。这是因为文件中的每一行都以换行符结尾,然后print()添加了自己的换行符。

为了解决这个问题,我们可以使用strip()方法在打印之前删除每行末尾的空白。相反,我们将使用stdout流的write()方法。这与我们之前用来写入文件的write()方法完全相同,因为stdout流本身就是一个类似文件的对象,所以可以使用它。

我们从sys模块中获得了对stdout流的引用:

import sys

def main(filename):
    f = open(filename, mode='rt', encoding='utf-8')
    for line in f:
        sys.stdout.write(line)
    f.close()

if __name__ == '__main__':
    main(sys.argv[1])

如果我们重新运行我们的程序,我们会得到:

$ python3 files.py wasteland.txt
What are the roots that clutch, what branches grow
Out of this stony rubbish? Son of man,
You cannot say, or guess, for you know only
A heap of broken images, where the sun beats

现在,不幸的是,是时候离开二十世纪最重要的诗歌之一,开始着手处理几乎同样令人兴奋的东西,上下文管理器。

上下文管理器

对于接下来的一组示例,我们将需要一个包含一些数字的数据文件。使用下面的recaman.py中的代码,我们将一个名为Recaman 序列的数字序列写入文本文件,每行一个数字:

import sys
from itertools import count, islice

def sequence():
    """Generate Recaman's sequence."""
    seen = set()
    a = 0
    for n in count(1):
        yield a
        seen.add(a)
        c = a - n
        if c < 0 or c in seen:
            c = a + n
        a = c

def write_sequence(filename, num):
    """Write Recaman's sequence to a text file."""
    f = open(filename, mode='wt', encoding='utf-8')
    f.writelines("{0}\n".format(r)
                 for r in islice(sequence(), num + 1))
    f.close()

if __name__ == '__main__':
    write_sequence(filename=sys.argv[1],
                   num=int(sys.argv[2]))

Recaman 序列本身对这个练习并不重要;我们只需要一种生成数字数据的方法。因此,我们不会解释sequence()生成器。不过,随意进行实验。

该模块包含一个用于产生 Recaman 数的生成器,以及一个使用writelines()方法将序列的开头写入文件的函数。生成器表达式用于将每个数字转换为字符串并添加换行符。itertools.islice()用于截断否则无限的序列。

通过执行模块,将文件名和序列长度作为命令行参数传递,我们将前 1000 个 Recaman 数写入文件:

$ python3 recaman.py recaman.dat 1000

现在让我们创建一个补充模块series.py,它可以重新读取这个数据文件:

"""Read and print an integer series."""

import sys

def read_series(filename):
    f = open(filename, mode='rt', encoding='utf-8')
    series = []
    for line in f:
        a = int(line.strip())
        series.append(a)
    f.close()
    return series

def main(filename):
    series = read_series(filename)
    print(series)

if __name__ == '__main__':
    main(sys.argv[1])

我们从打开的文件中一次读取一行,使用strip()字符串方法去除换行符,并将其转换为整数。如果我们从命令行运行它,一切都应该如预期般工作:

$ python3 series.py recaman.dat
[0, 1, 3, 6, 2, 7, 13,
 ...
,3683, 2688, 3684, 2687, 3685, 2686, 3686]

现在让我们故意制造一个异常情况。在文本编辑器中打开recaman.dat,并用不是字符串化整数的内容替换其中一个数字:

0
1
3
6
2
7
13
oops!
12
21

保存文件,然后重新运行series.py

$ python3 series.py recaman.dat
Traceback (most recent call last):
  File "series.py", line 19, in <module>
    main(sys.argv[1])
  File "series.py", line 15, in main
    series = read_series(filename)
  File "series.py", line 9, in read_series
    a = int(line.strip())
ValueError: invalid literal for int() with base 10: 'oops!'

当传递我们的新的无效行时,int()构造函数会引发ValueError。异常未处理,因此程序以堆栈跟踪终止。

使用finally管理资源

这里的一个问题是我们的f.close()调用从未执行过。

为了解决这个问题,我们可以插入一个try..finally块:

def read_series(filename):
    try:
        f = open(filename, mode='rt', encoding='utf-8')
        series = []
        for line in f:
            a = int(line.strip())
            series.append(a)
    finally:
        f.close()
    return series

现在文件将始终关闭,即使存在异常。进行这种更改开启了另一种重构的机会:我们可以用列表推导来替换 for 循环,并直接返回这个列表:

def read_series(filename):
    try:
        f = open(filename, mode='rt', encoding='utf-8')
        return [ int(line.strip()) for line in f ]
    finally:
        f.close()

即使在这种情况下,close()仍然会被调用;无论try块如何退出,finally块都会被调用。

with-blocks

到目前为止,我们的例子都遵循一个模式:open()一个文件,处理文件,close()文件。close()很重要,因为它通知底层操作系统你已经完成了对文件的操作。如果你在完成文件操作后不关闭文件,可能会丢失数据。可能会有待写入的缓冲区,可能不会完全写入。此外,如果你打开了很多文件,你的系统可能会耗尽资源。由于我们总是希望每个open()都与一个close()配对,我们希望有一个机制,即使我们忘记了,也能强制执行这种关系。

这种资源清理的需求是很常见的,Python 实现了一个特定的控制流结构,称为with-blocks来支持它。with-blocks 可以与支持上下文管理器协议的任何对象一起使用,这包括open()返回的文件对象。利用文件对象是上下文管理器的事实,我们的read_series()函数可以变成:

def read_series(filename):
    with open(filename, mode='rt', encoding='utf-8') as f:
        return [int(line.strip()) for line in f]

我们不再需要显式调用close(),因为with结构将在执行退出块时为我们调用它,无论我们如何退出块。

现在我们可以回去修改我们的 Recaman 系列写作程序,也使用一个 with-block,再次消除了显式的close()的需要:

def write_sequence(filename, num):
    """Write Recaman's sequence to a text file."""
    with open(filename, mode='wt', encoding='utf-8') as f:
        f.writelines("{0}\n".format(r)
                     for r in islice(sequence(), num + 1))


禅的时刻

with-block 的语法如下:

with EXPR as VAR:
    BLOCK

这被称为语法糖,用于更复杂的try...excepttry...finally块的安排:

mgr = (EXPR)
exit = type(mgr).__exit__  # Not calling it yet
value = type(mgr).__enter__(mgr)
exc = True
try:
    try:
        VAR = value  # Only if "as VAR" is present
        BLOCK
    except:
        # The exceptional case is handled here
        exc = False
        if not exit(mgr, *sys.exc_info()):
            raise
        # The exception is swallowed if exit() returns true
finally:
    # The normal and non-local-goto cases are handled here
    if exc:
        exit(mgr, None, None, None)

^(25)

你更喜欢哪个?

我们中很少有人希望我们的代码看起来如此复杂,但这就是没有with语句的情况下它需要看起来的样子。糖可能对你的健康不好,但对你的代码可能非常有益!


二进制文件

到目前为止,我们已经看过文本文件,其中我们将文件内容处理为 Unicode 字符串。然而,有许多情况下,文件包含的数据并不是编码文本。在这些情况下,我们需要能够直接处理文件中存在的确切字节,而不需要任何中间编码或解码。这就是二进制模式的用途。

BMP 文件格式

为了演示处理二进制文件,我们需要一个有趣的二进制数据格式。BMP 是一种包含设备无关位图的图像文件格式。它足够简单,我们可以从头开始制作一个 BMP 文件写入器。^(26)将以下代码放入一个名为bmp.py的模块中:

 1 # bmp.py
 2 
 3 """A module for dealing with BMP bitmap image files."""
 4 
 5 
 6 def write_grayscale(filename, pixels):
 7    """Creates and writes a grayscale BMP file.
 8 
 9    Args:
10         filename: The name of the BMP file to me created.
11 
12         pixels: A rectangular image stored as a sequence of rows.
13             Each row must be an iterable series of integers in the
14             range 0-255.
15 
16     Raises:
17         OSError: If the file couldn't be written.
18     """
19     height = len(pixels)
20     width = len(pixels[0])
21 
22     with open(filename, 'wb') as bmp:
23         # BMP Header
24         bmp.write(b'BM')
25 
26         # The next four bytes hold the filesize as a 32-bit
27         # little-endian integer. Zero placeholder for now.
28         size_bookmark = bmp.tell()
29         bmp.write(b'\x00\x00\x00\x00')
30 
31         # Two unused 16-bit integers - should be zero
32         bmp.write(b'\x00\x00')
33         bmp.write(b'\x00\x00')
34 
35         # The next four bytes hold the integer offset
36         # to the pixel data. Zero placeholder for now.
37         pixel_offset_bookmark = bmp.tell()
38         bmp.write(b'\x00\x00\x00\x00')
39 
40         # Image Header
41         bmp.write(b'\x28\x00\x00\x00')  # Image header size in bytes - 40 decimal
42         bmp.write(_int32_to_bytes(width))   # Image width in pixels
43         bmp.write(_int32_to_bytes(height))  # Image height in pixels
44         # Rest of header is essentially fixed
45         bmp.write(b'\x01\x00')          # Number of image planes
46         bmp.write(b'\x08\x00')          # Bits per pixel 8 for grayscale
47         bmp.write(b'\x00\x00\x00\x00')  # No compression
48         bmp.write(b'\x00\x00\x00\x00')  # Zero for uncompressed images
49         bmp.write(b'\x00\x00\x00\x00')  # Unused pixels per meter
50         bmp.write(b'\x00\x00\x00\x00')  # Unused pixels per meter
51         bmp.write(b'\x00\x00\x00\x00')  # Use whole color table
52         bmp.write(b'\x00\x00\x00\x00')  # All colors are important
53 
54         # Color palette - a linear grayscale
55         for c in range(256):
56             bmp.write(bytes((c, c, c, 0)))  # Blue, Green, Red, Zero
57 
58         # Pixel data
59         pixel_data_bookmark = bmp.tell()
60         for row in reversed(pixels):  # BMP files are bottom to top
61             row_data = bytes(row)
62             bmp.write(row_data)
63             padding = b'\x00' * ((4 - (len(row) % 4)) % 4)  # Pad row to multiple
64                                                             # of four bytes
65             bmp.write(padding)
66 
67         # End of file
68         eof_bookmark = bmp.tell()
69 
70         # Fill in file size placeholder
71         bmp.seek(size_bookmark)
72         bmp.write(_int32_to_bytes(eof_bookmark))
73 
74         # Fill in pixel offset placeholder
75         bmp.seek(pixel_offset_bookmark)
76         bmp.write(_int32_to_bytes(pixel_data_bookmark))

这可能看起来很复杂,但你会发现它相对简单。

为了简单起见,我们决定只处理 8 位灰度图像。这些图像有一个很好的特性,即每个像素一个字节。write_grayscale()函数接受两个参数:文件名和像素值的集合。正如文档字符串所指出的那样,这个集合应该是整数序列的序列。例如,一个int对象的列表列表就可以了。此外:

  • 每个int必须是从 0 到 255 的像素值

  • 每个内部列表都是从左到右的像素行

  • 外部列表是从上到下的像素行的列表。

我们要做的第一件事是通过计算行数(第 19 行)来确定图像的大小,以给出高度,并计算零行中的项目数来获得宽度(第 20 行)。我们假设,但不检查,所有行的长度都相同(在生产代码中,这是我们想要进行检查的)。

接下来,我们使用'wb'模式字符串在二进制写入模式下open()(第 22 行)文件。我们不指定编码 - 这对于原始二进制文件没有意义。

在 with 块内,我们开始编写所谓的“BMP 头”,这是 BMP 格式的开始。

头部必须以所谓的“魔术”字节序列b'BM'开头,以识别它为 BMP 文件。我们使用write()方法(第 24 行),因为文件是以二进制模式打开的,所以我们必须传递一个bytes对象。

接下来的四个字节应该包含一个 32 位整数,其中包含文件大小,这是我们目前还不知道的值。我们本可以提前计算它,但我们将采取不同的方法:我们将写入一个占位符值,然后返回到这一点以填写细节。为了能够回到这一点,我们使用文件对象的tell()方法(第 28 行);这给了我们文件指针从文件开头的偏移量。我们将把这个偏移量存储在一个变量中,它将充当一种书签。我们写入四个零字节作为占位符(第 29 行),使用转义语法来指定这些零。

接下来的两对字节是未使用的,所以我们也将零字节写入它们(第 32 和 33 行)。

接下来的四个字节是另一个 32 位整数,应该包含从文件开头到像素数据开始的偏移量(以字节为单位)。我们也不知道这个值,所以我们将使用tell()(第 37 行)存储另一个书签,并写入另外四个字节的占位符(第 38 行);当我们知道更多信息时,我们将很快返回到这里。

接下来的部分称为“图像头”。我们首先要做的是将图像头的长度写入一个 32 位整数(第 41 行)。在我们的情况下,头部总是 40 个字节长。我们只需将其硬编码为十六进制。注意 BMP 格式是小端序的 - 最不重要的字节先写入。

接下来的四个字节是图像宽度,作为小端序的 32 位整数。我们在这里调用一个模块范围的实现细节函数,名为_int32_to_bytes(),它将一个int对象转换为一个包含恰好四个字节的bytes对象(第 42 行)。然后我们再次使用相同的函数来处理图像高度(第 43 行)。

头部的其余部分对于 8 位灰度图像基本上是固定的,这里的细节并不重要,除了要注意整个头部实际上总共是 40 个字节(第 45 行)。

8 位 BMP 图像中的每个像素都是颜色表中 256 个条目的索引。每个条目都是一个四字节的 BGR 颜色。对于灰度图像,我们需要按线性比例写入 256 个 4 字节的灰度值(第 54 行)。这段代码是实验的肥沃土壤,这个函数的一个自然增强功能将是能够单独提供这个调色板作为可选的函数参数。

最后,我们准备写入像素数据,但在这之前,我们要使用tell()(第 59 行)方法记录当前文件指针的偏移量,因为这是我们需要稍后返回并填写的位置之一。

写入像素数据本身是相当简单的。我们使用内置函数reversed()(第 60 行)来翻转行的顺序;BMP 图像是从底部向顶部写入的。对于每一行,我们将整数的可迭代系列传递给bytes()构造函数(第 61 行)。如果任何整数超出了 0-255 的范围,构造函数将引发ValueError

BMP 文件中的每一行像素数据必须是四个字节的整数倍长,与图像宽度无关。为了做到这一点(第 63 行),我们取行长度模四,得到一个介于零和三之间的数字,这是我们行末尾距离前一个四字节边界的字节数。为了得到填充字节数,使我们达到下一个四字节边界,我们从四中减去这个模数值,得到一个介于 4 到 1 之间的值。然而,我们永远不希望用四个字节填充,只用一、二或三个,所以我们必须再次取模四,将四字节填充转换为零字节填充。

这个值与重复操作符应用于单个零字节一起使用,以产生一个包含零、一个、两个或三个字节的字节对象。我们将这些写入文件,以终止每一行(第 65 行)。

在像素数据之后,我们已经到达了文件的末尾。我们之前承诺记录了这个偏移值,所以我们使用tell()(第 68 行)将当前位置记录到一个文件末尾书签变量中。

现在我们可以回来实现我们的承诺,通过用我们记录的真实偏移量替换占位符。首先是文件长度。为此,我们seek()(第 71 行)回到我们在文件开头附近记住的size_bookmark,并使用我们的_int32_to_bytes()函数将存储在eof_bookmark中的大小作为小端 32 位整数write()(第 72 行)。

最后,我们seek()(第 75 行)到由pixel_offset_bookmark标记的像素数据偏移量的位置,并将存储在pixel_data_bookmark中的 32 位整数(第 76 行)写入。

当我们退出 with 块时,我们可以放心,上下文管理器将关闭文件并将任何缓冲写入文件系统。

位运算符

处理二进制文件通常需要在字节级别拆分或组装数据。这正是我们的_int32_to_bytes()函数在做的事情。我们将快速查看它,因为它展示了一些我们以前没有见过的 Python 特性:

def _int32_to_bytes(i):
    """Convert an integer to four bytes in little-endian format."""
    return bytes((i & 0xff,
                  i >> 8 & 0xff,
                  i >> 16 & 0xff,
                  i >> 24 & 0xff))

该函数使用>>位移)和&按位与)运算符从整数值中提取单个字节。请注意,按位与使用和符号来区分它与逻辑与,后者是拼写出来的单词“and”。>>运算符将整数的二进制表示向右移动指定的位数。该例程在每次移位后使用&提取最低有效字节。得到的四个整数用于构造一个元组,然后传递给bytes()构造函数以产生一个四字节序列。

写一个 BMP 文件

为了生成一个 BMP 图像文件,我们需要一些像素数据。我们包含了一个简单的模块fractal.py,它为标志性的Mandelbrot 集合分形生成像素值。我们不打算详细解释分形生成代码,更不用说背后的数学。但这段代码足够简单,而且不依赖于我们以前遇到的任何 Python 特性:

# fractal.py

"""Computing Mandelbrot sets."""

import math

def mandel(real, imag):
    """The logarithm of number of iterations needed to
 determine whether a complex point is in the
 Mandelbrot set.

 Args:
 real: The real coordinate
 imag: The imaginary coordinate

 Returns:
 An integer in the range 1-255.
 """
    x = 0
    y = 0
    for i in range(1, 257):
        if x*x + y*y > 4.0:
            break
        xt = real + x*x - y*y
        y = imag + 2.0 * x * y
        x = xt
    return int(math.log(i) * 256 / math.log(256)) - 1

def mandelbrot(size_x, size_y):
    """Make an Mandelbrot set image.

 Args:
 size_x: Image width
 size_y: Image height

 Returns:
 A list of lists of integers in the range 0-255.
 """
    return [ [mandel((3.5 * x / size_x) - 2.5,
                     (2.0 * y / size_y) - 1.0)
              for x in range(size_x) ]
            for y in range(size_y) ]

关键是mandelbrot()函数使用嵌套的列表推导来生成一个范围在 0-255 的整数列表的列表。这个列表代表了分形的图像。每个点的整数值是由mandel()函数产生的。

生成分形图像

让我们启动一个 REPL,并将fractalbmp模块一起使用。首先,我们使用mandelbrot()函数生成一个 448x256 像素的图像。使用长宽比为 7:4 的图像会获得最佳结果:

>>> import fractal
>>> pixels = fractal.mandelbrot(448, 256)

这个对mandelbrot()的调用可能需要一秒左右 - 我们的分形生成器简单而不是高效!

我们可以查看返回的数据结构:

>>> pixels
[[31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31,
  31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31,
  ...
  49, 49, 49, 49, 49, 49, 49, 49, 49, 49, 49, 49, 49, 49, 49, 49, 49, 49]]

这是一个整数列表的列表,就像我们所承诺的那样。让我们把这些像素值写入一个 BMP 文件:

>>> import bmp
>>> bmp.write_grayscale("mandel.bmp", pixels)

找到文件并在图像查看器中打开它,例如通过在 Web 浏览器中打开它。

读取二进制文件

现在我们正在生成美丽的 Mandelbrot 图像,我们应该尝试用 Python 读取这些 BMP 文件。我们不打算编写一个完整的 BMP 阅读器,尽管那将是一个有趣的练习。我们只是制作一个简单的函数来确定 BMP 文件中的像素维度。我们将把代码添加到bmp.py中:

def dimensions(filename):
    """Determine the dimensions in pixels of a BMP image.

 Args:
 filename: The filename of a BMP file.

 Returns:
 A tuple containing two integers with the width
 and height in pixels.

 Raises:
 ValueError: If the file was not a BMP file.
 OSError: If there was a problem reading the file.
 """

    with open(filename, 'rb') as f:
        magic = f.read(2)
        if magic != b'BM':
            raise ValueError("{} is not a BMP file".format(filename))

        f.seek(18)
        width_bytes = f.read(4)
        height_bytes = f.read(4)

        return (_bytes_to_int32(width_bytes),
                _bytes_to_int32(height_bytes))

当然,我们使用 with 语句来管理文件,所以我们不必担心它是否被正确关闭。在 with 块内,我们通过查找我们在 BMP 文件中期望的前两个魔术字节来执行简单的验证检查。如果不存在,我们会引发ValueError,这当然会导致上下文管理器关闭文件。

回顾一下我们的 BMP 写入器,我们可以确定图像尺寸恰好存储在文件开头的 18 个字节处。我们seek()到该位置,并使用read()方法读取两个四字节的块,分别代表尺寸的两个 32 位整数。因为我们以二进制模式打开文件,read()返回一个bytes对象。我们将这两个bytes对象传递给另一个实现细节函数_bytes_to_int32(),它将它们重新组装成一个整数。这两个整数,代表图像的宽度和高度,作为一个元组返回。

_bytes_to_int32()函数使用<<按位左移)和|按位或),以及对bytes对象的索引,来重新组装整数。请注意,对bytes对象进行索引返回一个整数:

def _bytes_to_int32(b):
    """Convert a bytes object containing four bytes into an integer."""
    return b[0] | (b[1] << 8) | (b[2] << 16) | (b[3] << 24)

如果我们使用我们的新的读取器代码,我们可以看到它确实读取了正确的值:

>>> bmp.dimensions("mandel.bmp")
(448, 256)

类似文件的对象

Python 中有一个“类似文件的对象”的概念。这并不像特定的协议^(27)那样正式,但由于鸭子类型所提供的多态性,在实践中它运作良好。

之所以没有严格规定它,是因为不同类型的数据流和设备具有许多不同的功能、期望和行为。因此,实际上定义一组模拟它们的协议将是相当复杂的,而且实际上并没有太多的实际意义,除了一种理论成就感。这就是 EAFP^(28)哲学的优势所在:如果你想在类似文件的对象上执行seek(),而事先不知道它是否支持随机访问,那就试试看(字面上!)。只是要做好准备,如果seek()方法不存在,或者存在但行为不符合你的期望,那么就会失败。

你可能会说“如果它看起来像一个文件,读起来像一个文件,那么它就是一个文件”。

你已经看到了类似文件的对象!

我们已经看到了类似文件的对象的实际应用;当我们以文本和二进制模式打开文件时,返回给我们的对象实际上是不同类型的,尽管都具有明确定义的类似文件的行为。Python 标准库中还有其他类型实现了类似文件的行为,实际上我们在书的开头就看到了其中一个,当时我们使用urlopen()从互联网上的 URL 检索数据。

使用类似文件的对象

让我们通过编写一个函数来利用类似文件的对象的多态性,来统计文件中每行的单词数,并将该信息作为列表返回:

>>> def words_per_line(flo):
...    return [len(line.split()) for line in flo.readlines()]

现在我们将打开一个包含我们之前创建的 T.S.艾略特杰作片段的常规文本文件,并将其传递给我们的新函数:

>>> with open("wasteland.txt", mode='rt', encoding='utf-8') as real_file:
...     wpl = words_per_line(real_file)
...
>>> wpl
[9, 8, 9, 9]

real_file的实际类型是:

>>> type(real_file)
<class '_io.TextIOWrapper'>

但通常你不应该关心这个具体的类型;这是 Python 内部的实现细节。你只需要关心它的行为“像一个文件”。

现在我们将使用代表 URL 引用的 Web 资源的类似文件对象执行相同的操作:

>>> from urllib.request import urlopen
>>> with urlopen("http://sixty-north.com/c/t.txt") as web_file:
...    wpl = words_per_line(web_file)
...
>>> wpl
[6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 5, 5, 7, 8, 14, 12, 8]

web_file的类型与我们刚刚看到的类型相当不同:

>>> type(web_file)
<class 'http.client.HTTPResponse'>

然而,由于它们都是类似文件的对象,我们的函数可以与两者一起使用。

类似文件的对象并没有什么神奇之处;它只是一个方便且相当非正式的描述,用于描述我们可以对对象提出的一组期望,这些期望是通过鸭子类型来实现的。

其他资源

with 语句结构可以与实现上下文管理器协议的任何类型的对象一起使用。我们不会在本书中向您展示如何实现上下文管理器 - 为此,您需要参考The Python Journeyman - 但我们会向您展示一种简单的方法,使您自己的类可以在 with 语句中使用。将这段代码放入模块fridge.py中:

# fridge.py

"""Demonstrate raiding a refrigerator."""

class RefrigeratorRaider:
    """Raid a refrigerator."""

    def open(self):
        print("Open fridge door.")

    def take(self, food):
        print("Finding {}...".format(food))
        if food == 'deep fried pizza':
            raise RuntimeError("Health warning!")
        print("Taking {}".format(food))

    def close(self):
        print("Close fridge door.")

def raid(food):
    r = RefrigeratorRaider()
    r.open()
    r.take(food)
    r.close()

我们将raid()导入 REPL 并开始肆虐:

>>> from fridge import raid
>>> raid("bacon")
Open fridge door.
Finding bacon...
Taking bacon
Close fridge door.

重要的是,我们记得关闭了门,所以食物会保存到我们下次袭击。让我们尝试另一次袭击,找一些稍微不那么健康的东西:

>>> raid("deep fried pizza")
Open fridge door.
Finding deep fried pizza...
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "./fridge.py", line 23, in raid
    r.take(food)
  File "./fridge.py", line 14, in take
    raise RuntimeError("Health warning!")
RuntimeError: Health warning!

这次,我们被健康警告打断,没有来得及关闭门。我们可以通过使用 Python 标准库中的contextlib模块中的closing()函数来解决这个问题。导入函数后,我们将RefrigeratorRaider构造函数调用包装在closing()的调用中。这样可以将我们的对象包装在一个上下文管理器中,在退出之前始终调用包装对象上的close()方法。我们使用这个对象来初始化一个 with 块:

"""Demonstrate raiding a refrigerator."""

from contextlib import closing

class RefrigeratorRaider:
    """Raid a refrigerator."""

    def open(self):
        print("Open fridge door.")

    def take(self, food):
        print("Finding {}...".format(food))
        if food == 'deep fried pizza':
            raise RuntimeError("Health warning!")
        print("Taking {}".format(food))

    def close(self):
        print("Close fridge door.")

def raid(food):
    with closing(RefrigeratorRaider()) as r:
        r.open()
        r.take(food)
        r.close()

现在当我们执行袭击时:

>>> raid("spam")
Open fridge door.
Finding spam...
Taking spam
Close fridge door.
Close fridge door.

我们看到我们对close()的显式调用是不必要的,所以让我们来修复一下:

def raid(food):
    with closing(RefrigeratorRaider()) as r:
        r.open()
        r.take(food)

更复杂的实现会检查门是否已经关闭,并忽略其他请求。

那么它是否有效呢?让我们再试试吃一些油炸比萨:

>>> raid("deep fried pizza")
Open fridge door.
Finding deep fried pizza...
Close fridge door.
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "./fridge.py", line 23, in raid
    r.take(food)
  File "./fridge.py", line 14, in take
    raise RuntimeError("Health warning!")
RuntimeError: Health warning!

这一次,即使触发了健康警告,上下文管理器仍然为我们关闭了门。

总结

  • 文件是使用内置的open()函数打开的,该函数接受文件模式来控制读取/写入/追加行为,以及文件是作为原始二进制数据还是编码文本数据进行处理。

  • 对于文本数据,应指定文本编码。

  • 文本文件处理字符串对象,并执行通用换行符转换和字符串编码。

  • 二进制文件处理bytes对象,不进行换行符转换或编码。

  • 在写文件时,您有责任为换行符提供换行字符。

  • 文件在使用后应始终关闭。

  • 文件提供各种面向行的方法进行读取,并且也是迭代器,逐行产生行。

  • 文件是上下文管理器,可以与上下文管理器一起使用,以确保执行清理操作,例如关闭文件。

  • 文件样对象的概念定义不严格,但在实践中非常有用。尽量使用 EAFP 来充分利用它们。

  • 上下文管理器不仅限于类似文件的对象。我们可以使用contextlib标准库模块中的工具,例如closing()包装器来创建我们自己的上下文管理器。

沿途我们发现:

  • help()可以用于实例对象,而不仅仅是类型。

  • Python 支持按位运算符&|<<>>

第十一章:使用 Python 标准库进行单元测试

当我们构建甚至是轻微复杂的程序时,代码中会有无数种缺陷的方式。这可能发生在我们最初编写代码时,但当我们对其进行修改时,我们同样有可能引入缺陷。为了帮助掌握缺陷并保持代码质量高,拥有一组可以运行的测试通常非常有用,这些测试可以告诉您代码是否按照您的期望行事。

为了帮助进行这样的测试,Python 标准库包括unittest模块。尽管其名称暗示了它只有单元测试,但实际上,这个模块不仅仅用于单元测试。事实上,它是一个灵活的框架,可以自动化各种测试,从验收测试到集成测试再到单元测试。它的关键特性,就像许多语言中的许多测试框架一样,是帮助您进行自动化可重复的测试。有了这样的测试,您可以在任何时候廉价且轻松地验证代码的行为。

测试用例

unittest模块围绕着一些关键概念构建,其中心是测试用例的概念。测试用例 - 体现在unittest.TestCase中 - 将一组相关的测试方法组合在一起,它是unittest框架中的测试组织的基本单元。正如我们稍后将看到的,单个测试方法是作为unittest.TestCase子类上的方法实现的。

固定装置

下一个重要概念是固定装置。固定装置是在每个测试方法之前和/或之后运行的代码片段。固定装置有两个主要目的:

  1. 设置固定装置确保测试环境在运行测试之前处于预期状态。

  2. 清理固定装置在测试运行后清理环境,通常是通过释放资源。

例如,设置固定装置可能在运行测试之前在数据库中创建特定条目。类似地,拆卸固定装置可能会删除测试创建的数据库条目。测试不需要固定装置,但它们非常常见,通常对于使测试可重复至关重要。

断言

最终的关键概念是断言。断言是测试方法中的特定检查,最终决定测试是否通过或失败。除其他事项外,断言可以:

  • 进行简单的布尔检查

  • 执行对象相等性测试

  • 验证是否抛出了适当的异常

如果断言失败,那么测试方法也会失败,因此断言代表了您可以执行的最低级别的测试。您可以在unittest文档中找到断言的完整列表

单元测试示例:文本分析

有了这些概念,让我们看看如何实际在实践中使用unittest模块。在这个例子中,我们将使用测试驱动开发^(29)来编写一个简单的文本分析函数。这个函数将以文件名作为唯一参数。然后它将读取该文件并计算:

  • 文件中的行数

  • 文件中的字符数

TDD 是一个迭代的开发过程,因此我们不会在 REPL 上工作,而是将我们的测试代码放在一个名为text_analyzer.py的文件中。首先,我们将创建我们的第一个测试^(30),并提供足够的支持代码来实际运行它。

# text_analyzer.py

import unittest

class TextAnalysisTests(unittest.TestCase):
    """Tests for the ``analyze_text()`` function."""

    def test_function_runs(self):
        """Basic smoke test: does the function run."""
        analyze_text()

if __name__ == '__main__':
    unittest.main()

我们首先导入unittest模块。然后,我们通过定义一个从unittest.TestCase派生的类TextAnalysisTests来创建我们的测试用例。这是您使用unittest框架创建测试用例的方法。

要在测试用例中定义单独的测试方法,只需在TestCase子类上创建以“test_”开头的方法。unittest框架在执行时会自动发现这样的方法,因此您不需要显式注册您的测试方法。

在这种情况下,我们定义了最简单的测试:我们检查analyze_text()函数是否运行!我们的测试没有进行任何明确的检查,而是依赖于测试方法如果抛出任何异常则会失败的事实。在这种情况下,如果analyze_text()没有被定义,我们的测试将失败。

最后,我们定义了惯用的“main”块,当这个模块被执行时调用unittest.main()unittest.main()将在模块中搜索所有的TestCase子类,并执行它们所有的测试方法。

运行初始测试

由于我们正在使用测试驱动设计,我们期望我们的测试一开始会失败。事实上,我们的测试失败了,原因很简单,我们还没有定义analyze_text()

$ python text_analyzer.py
E
======================================================================
ERROR: test_function_runs (__main__.TextAnalysisTests)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "text_analyzer.py", line 5, in test_function_runs
    analyze_text()
NameError: global name 'analyze_text' is not defined

----------------------------------------------------------------------
Ran 1 test in 0.001s

FAILED (errors=1)

正如你所看到的,unittest.main()生成了一个简单的报告,告诉我们运行了多少个测试,有多少个失败了。它还向我们展示了测试是如何失败的,比如在我们尝试运行不存在的函数analyze_text()时,它告诉我们我们得到了一个NameError

使测试通过

通过定义analyze_text()来修复我们失败的测试。请记住,在测试驱动开发中,我们只编写足够满足测试的代码,所以现在我们只是创建一个空函数。为了简单起见,我们将把这个函数放在text_analyzer.py中,尽管通常你的测试代码和实现代码会在不同的模块中:

# text_analyzer.py

def analyze_text():
    """Calculate the number of lines and characters in a file.
 """
    pass

将这个函数放在模块范围。再次运行测试,我们发现它们现在通过了:

% python text_analyzer.py
.
----------------------------------------------------------------------
Ran 1 test in 0.001s

OK

我们已经完成了一个 TDD 周期,但当然我们的代码还没有真正做任何事情。我们将迭代地改进我们的测试和实现,以得到一个真正的解决方案。

使用固定装置创建临时文件

接下来要做的事情是能够向analyze_text()传递一个文件名,以便它知道要处理什么。当然,为了让analyze_text()工作,这个文件名应该指的是一个实际存在的文件!为了确保我们的测试中存在一个文件,我们将定义一些固定装置。

我们可以定义的第一个固定装置是TestCase.setUp()方法。如果定义了,这个方法会在TestCase中的每个测试方法之前运行。在这种情况下,我们将使用setUp()为我们创建一个文件,并将文件名记住为TestCase的成员:

# text_analyzer.py

class TextAnalysisTests(unittest.TestCase):
    . . .
    def setUp(self):
        "Fixture that creates a file for the text methods to use."
        self.filename = 'text_analysis_test_file.txt'
        with open(self.filename, 'w') as f:
            f.write('Now we are engaged in a great civil war,\n'
                    'testing whether that nation,\n'
                    'or any nation so conceived and so dedicated,\n'
                    'can long endure.')

我们可以使用的第二个固定装置是TestCase.tearDown()tearDown()方法在TestCase中的每个测试方法之后运行,在这种情况下,我们将使用它来删除在setUp()中创建的文件:

# text_analyzer.py

import os
. . .
class TextAnalysisTests(unittest.TestCase):
    . . .
    def tearDown(self):
        "Fixture that deletes the files used by the test methods."
        try:
            os.remove(self.filename)
        except OSError:
            pass

请注意,由于我们在tearDown()中使用了os模块,我们需要在文件顶部导入它。

还要注意tearDown()如何吞没了os.remove()抛出的任何异常。我们这样做是因为tearDown()实际上不能确定文件是否存在,所以它尝试删除文件,并假设任何异常都可以安全地被忽略。

使用新的固定装置

有了我们的两个固定装置,我们现在每个测试方法之前都有一个文件被创建,并且在每个测试方法之后都被删除。这意味着每个测试方法都是从一个稳定的、已知的状态开始的。这对于制作可重复的测试是至关重要的。让我们通过修改现有的测试将这个文件名传递给analyze_text()

# text_analyzer.py

class TextAnalysisTests(unittest.TestCase):
    . . .
    def test_function_runs(self):
        "Basic smoke test: does the function run."
        analyze_text(self.filename)

记住我们的setUp()将文件名存储在self.filename上。由于传递给固定装置的self参数与传递给测试方法的实例相同,我们的测试可以使用该属性访问文件名。

当我们运行我们的测试时,我们发现这个测试失败了,因为analyze_text()还没有接受任何参数:

% python text_analyzer.py
E
======================================================================
ERROR: test_function_runs (__main__.TextAnalysisTests)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "text_analyzer.py", line 25, in test_function_runs
    analyze_text(self.filename)
TypeError: analyze_text() takes no arguments (1 given)

----------------------------------------------------------------------
Ran 1 test in 0.003s

FAILED (errors=1)

我们可以通过向analyze_text()添加一个参数来修复这个问题:

# text_analyzer.py

def analyze_text(filename):
    pass

如果我们再次运行我们的测试,我们会再次通过:

% python text_analyzer.py
.
----------------------------------------------------------------------
Ran 1 test in 0.003s

OK

我们仍然没有一个做任何有用事情的实现,但你可以开始看到测试如何驱动实现。

使用断言来测试行为

现在我们满意analyze_text()存在并接受正确数量的参数,让我们看看是否可以让它做真正的工作。我们首先想要的是函数返回文件中的行数,所以让我们定义那个测试:

# text_analyzer.py

class TextAnalysisTests(unittest.TestCase):
    . . .
    def test_line_count(self):
        "Check that the line count is correct."
        self.assertEqual(analyze_text(self.filename), 4)

这里我们看到了我们的第一个断言示例。TestCase类有许多断言方法,在这种情况下,我们使用assertEqual()来检查我们的函数计算的行数是否等于四。如果analyze_text()返回的值不等于四,这个断言将导致测试方法失败。如果我们运行我们的新测试,我们会看到这正是发生的:

% python text_analyzer.py
.F
======================================================================
FAIL: test_line_count (__main__.TextAnalysisTests)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "text_analyzer.py", line 28, in test_line_count
    self.assertEqual(analyze_text(self.filename), 4)
AssertionError: None != 4

----------------------------------------------------------------------
Ran 2 tests in 0.003s

FAILED (failures=1)

在这里我们看到我们现在运行了两个测试,其中一个通过了,而新的一个失败了,出现了AssertionError

计算行数

现在让我们暂时违反 TDD 规则,加快一点速度。首先我们将更新函数以返回文件中的行数:

# text_analyzer.py

def analyze_text(filename):
    """Calculate the number of lines and characters in a file.

 Args:
 filename: The name of the file to analyze.

 Raises:
 IOError: If ``filename`` does not exist or can't be read.

 Returns: The number of lines in the file.
 """
    with open(filename, 'r') as f:
        return sum(1 for _ in f)

这个改变确实给了我们想要的结果^(33):

% python text_analyzer.py
..
----------------------------------------------------------------------
Ran 2 tests in 0.003s

OK

计算字符

所以让我们添加一个我们想要的另一个功能的测试,即计算文件中字符的数量。由于analyze_text()现在应该返回两个值,我们将它返回一个元组,第一个位置是行数,第二个位置是字符数。我们的新测试看起来像这样:

# text_analyzer.py

class TextAnalysisTests(unittest.TestCase):
    . . .
    def test_character_count(self):
        "Check that the character count is correct."
        self.assertEqual(analyze_text(self.filename)[1], 131)

并且如预期的那样失败了:

% python text_analyzer.py
E..
======================================================================
ERROR: test_character_count (__main__.TextAnalysisTests)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "text_analyzer.py", line 32, in test_character_count
    self.assertEqual(analyze_text(self.filename)[1], 131)
TypeError: 'int' object has no attribute '__getitem__'

----------------------------------------------------------------------
Ran 3 tests in 0.004s

FAILED (errors=1)

这个结果告诉我们它无法索引analyze_text()返回的整数。所以让我们修复analyze_text()以返回正确的元组:

# text_analyzer.py

def analyze_text(filename):
    """Calculate the number of lines and characters in a file.

 Args:
 filename: The name of the file to analyze.

 Raises:
 IOError: If ``filename`` does not exist or can't be read.

 Returns: A tuple where the first element is the number of lines in
 the files and the second element is the number of characters.

 """
    lines = 0
    chars = 0
    with open(filename, 'r') as f:
        for line in f:
            lines += 1
            chars += len(line)
    return (lines, chars)

这修复了我们的新测试,但我们发现我们破坏了旧的测试:

% python text_analyzer.py
..F
======================================================================
FAIL: test_line_count (__main__.TextAnalysisTests)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "text_analyzer.py", line 34, in test_line_count
    self.assertEqual(analyze_text(self.filename), 4)
AssertionError: (4, 131) != 4

----------------------------------------------------------------------
Ran 3 tests in 0.004s

FAILED (failures=1)

幸运的是,这很容易修复,因为我们只需要在早期的测试中考虑新的返回类型:

# text_analyzer.py

class TextAnalysisTests(unittest.TestCase):
    . . .
    def test_line_count(self):
        "Check that the line count is correct."
        self.assertEqual(analyze_text(self.filename)[0], 4)

现在一切又通过了:

% python text_analyzer.py
...
----------------------------------------------------------------------
Ran 3 tests in 0.004s

OK

测试异常

我们还想测试的另一件事是,当analyze_text()传递一个不存在的文件名时,它会引发正确的异常,我们可以这样测试:

# text_analyzer.py

class TextAnalysisTests(unittest.TestCase):
    . . .
    def test_no_such_file(self):
        "Check the proper exception is thrown for a missing file."
        with self.assertRaises(IOError):
            analyze_text('foobar')

在这里,我们使用了TestCase.assertRaises()断言。这个断言检查指定的异常类型——在这种情况下是IOError——是否从 with 块的主体中抛出。

由于open()对于不存在的文件引发IOError,我们的测试已经通过,无需进一步实现:

% python text_analyzer.py
....
----------------------------------------------------------------------
Ran 4 tests in 0.004s

OK

测试文件是否存在

最后,我们可以通过编写一个测试来验证analyze_text()不会删除文件——这是对函数的合理要求!:

# text_analyzer.py

class TextAnalysisTests(unittest.TestCase):
    . . .
    def test_no_deletion(self):
        "Check that the function doesn't delete the input file."
        analyze_text(self.filename)
        self.assertTrue(os.path.exists(self.filename))

TestCase.assertTrue() 检查传递给它的值是否评估为True。还有一个等效的assertFalse(),它对 false 值进行相同的测试。

正如你可能期望的那样,这个测试已经通过了:

% python text_analyzer.py
.....
----------------------------------------------------------------------
Ran 5 tests in 0.002s

OK

所以现在我们有了一个有用的、通过的测试集!这个例子很小,但它演示了unittest模块的许多重要部分。unittest模块还有更多的部分,但是你可以通过我们在这里看到的技术走得很远。


禅宗时刻

猜测的诱惑,或者用一厢情愿的想法忽略模棱两可,可能会带来短期收益。但它往往会导致未来的混乱,以及难以理解和修复的错误。在进行下一个快速修复之前,问问自己需要什么信息才能正确地进行操作。


总结

  • unittest模块是一个开发可靠自动化测试的框架。

  • 通过从unittest.TestCase继承来定义测试用例

  • unittest.main()函数对于运行模块中的所有测试非常有用。

  • setUp()tearDown()装置用于在每个测试方法之前和之后运行代码。

  • 测试方法是通过在测试用例对象上创建以test_开头的方法名称来定义的。

  • 各种TestCase.assert...方法可用于在不满足正确条件时使测试方法失败。

  • 使用TestCase.assertRaises()在 with 语句中检查测试中是否抛出了正确的异常。

第十二章:使用 PDB 进行调试

即使有全面的自动化测试套件,我们仍然可能遇到需要调试器来弄清楚发生了什么的情况。幸运的是,Python 包含了一个强大的调试器,即标准库中的 PDB。PDB 是一个命令行调试器,如果您熟悉像 GDB 这样的工具,那么您已经对如何使用 PDB 有了一个很好的了解。

PDB 相对于其他 Python 调试器的主要优势在于,作为 Python 本身的一部分,PDB 几乎可以在 Python 存在的任何地方使用,包括将 Python 语言嵌入到较大系统中的专用环境,例如 ESRI 的ArcGIS地理信息系统。也就是说,使用所谓的图形调试器可能会更加舒适,例如JetbrainsPyCharmMicrosoftPython Tools for Visual Studio中包含的调试器。您应该随时跳过本章,直到熟悉 PDB 变得更加紧迫;您不会错过我们在本书中或在Python 学徒Python 大师中依赖的任何内容。

PDB 与许多调试工具不同,它实际上并不是一个单独的程序,而是像任何其他 Python 模块一样的模块。您可以将pdb导入任何程序,并使用set_trace()函数调用启动调试器。此函数在程序执行的任何点开始调试器。

对于我们对 PDB 的第一次尝试,让我们使用 REPL 并使用set_trace()启动调试器:

>>> import pdb
>>> pdb.set_trace()
--Return--
> <stdin>(1)<module>()->None
(Pdb)

您会看到在执行set_trace()后,您的提示从三个尖括号变为(Pdb)-这是您知道自己在调试器中的方式。

调试命令

我们要做的第一件事是查看调试器中有哪些命令,方法是键入help

(Pdb) help

Documented commands (type help <topic>):
========================================
EOF    cl         disable  interact  next     return  u          where
a      clear      display  j         p        retval  unalias
alias  commands   down     jump      pp       run     undisplay
args   condition  enable   l         print    rv      unt
b      cont       exit     list      q        s       until
break  continue   h        ll        quit     source  up
bt     d          help     longlist  r        step    w
c      debug      ignore   n         restart  tbreak  whatis

Miscellaneous help topics:
==========================
pdb  exec

这列出了几十个命令,其中一些你几乎在每个调试会话中都会使用,而另一些你可能根本不会使用。

您可以通过键入help后跟命令名称来获取有关命令的具体帮助。例如,要查看continue的功能,请键入help continue

    (Pdb) help continue
    c(ont(inue))
            Continue execution, only stop when a breakpoint is encountered.

命令名称中的奇怪括号告诉您,continue可以通过键入ccont或完整单词continue来激活。了解常见 PDB 命令的快捷方式可以极大地提高您在调试时的舒适度和速度。

回文调试

我们将不列出所有常用的 PDB 命令,而是调试一个简单的函数。我们的函数is_palindrome()接受一个整数,并确定整数的数字是否是回文。回文是一个正向和反向都相同的序列。

我们要做的第一件事是创建一个新文件palindrome.py,其中包含以下代码:

import unittest

def digits(x):
    """Convert an integer into a list of digits.

 Args:
 x: The number whose digits we want.

 Returns: A list of the digits, in order, of ``x``.

 >>> digits(4586378)
 [4, 5, 8, 6, 3, 7, 8]
 """

    digs = []
    while x != 0:
        div, mod = divmod(x, 10)
        digs.append(mod)
        x = mod
    digs.reverse()
    return digs

def is_palindrome(x):
    """Determine if an integer is a palindrome.

 Args:
 x: The number to check for palindromicity.

 Returns: True if the digits of ``x`` are a palindrome,
 False otherwise.

 >>> is_palindrome(1234)
 False
 >>> is_palindrome(2468642)
 True
 """
    digs = digits(x)
    for f, r in zip(digs, reversed(digs)):
        if f != r:
            return False
    return True

class Tests(unittest.TestCase):
    """Tests for the ``is_palindrome()`` function."""
    def test_negative(self):
        "Check that it returns False correctly."
        self.assertFalse(is_palindrome(1234))

    def test_positive(self):
        "Check that it returns True correctly."
        self.assertTrue(is_palindrome(1234321))

    def test_single_digit(self):
        "Check that it works for single digit numbers."
        for i in range(10):
            self.assertTrue(is_palindrome(i))

if __name__ == '__main__':
    unittest.main()

正如您所看到的,我们的代码有三个主要部分。第一个是digits()函数,它将整数转换为数字列表。

第二个是is_palindrome()函数,它首先调用digits(),然后检查结果列表是否是回文。

第三部分是一组单元测试。我们将使用这些测试来驱动程序。

正如您可能期望的,由于这是一个关于调试的部分,这段代码中有一个错误。我们将首先运行程序并注意到错误,然后我们将看看如何使用 PDB 来找到错误。

使用 PDB 进行错误调试

因此,让我们运行程序。我们有三个测试希望运行,由于这是一个相对简单的程序,我们期望它运行得非常快:

$ python palindrome.py

我们看到这个程序似乎运行了很长时间!如果您查看其内存使用情况,还会看到它随着运行时间的增加而增加。显然出现了问题,所以让我们使用 Ctrl-C 来终止程序。

让我们使用 PDB 来尝试理解这里发生了什么。由于我们不知道问题可能出在哪里,也不知道在哪里放置set_trace()调用,所以我们将使用命令行调用来在 PDB 的控制下启动程序:

$ python -m pdb palindrome.py
> /Users/sixty_north/examples/palindrome.py(1)<module>()
-> import unittest
(Pdb)

在这里,我们使用了-m参数,告诉 Python 执行特定的模块 - 在这种情况下是 PDB - 作为脚本。其余的参数传递给该脚本。所以在这里,我们告诉 Python 执行 PDB 模块作为脚本,并将我们的错误文件的名称传递给它。

我们看到的是,我们立即进入了 PDB 提示符。指向import unittest的箭头告诉我们,这是我们继续执行时将执行的下一条语句。但是那条语句在哪里?

让我们使用where命令来找出:

(Pdb) where
  /Library/Frameworks/Python.framework/Versions/3.5/lib/python3.5/bdb.py(387)run()
-> exec cmd in globals, locals
  <string>(1)<module>()
> /Users/sixty_north/examples/palindrome.py(1)<module>()
-> import unittest

where命令报告我们当前的调用堆栈,最近的帧在底部,我们可以看到 PDB 已经在palindrome.py的第一行暂停了执行。这强调了 Python 执行的一个重要方面,我们之前已经讨论过:一切都在运行时评估。在这种情况下,我们在import语句之前暂停了执行。

我们可以通过使用next命令执行此导入到下一条语句:

(Pdb) next
> /Users/sixty_north/examples/palindrome.py(3)<module>()
-> def digits(x):
(Pdb)

我们看到这将我们带到digits()函数的def调用。当我们执行另一个next时,我们移动到is_palindrome()函数的定义:

(Pdb) next
> /Users/sixty_north/examples/palindrome.py(12)<module>()
-> def is_palindrome(x):
(Pdb)

使用采样查找无限循环

我们可以继续使用next来移动程序的执行,但由于我们不知道错误出在哪里,这可能不是一个非常有用的技术。相反,记住我们程序的问题是似乎一直在运行。这听起来很像一个无限循环!

因此,我们不是逐步执行我们的代码,而是让它执行,然后当我们认为我们可能在那个循环中时,我们将使用 Ctrl-C 中断回到调试器:

(Pdb) cont
^C
Program interrupted. (Use 'cont' to resume).
> /Users/sixty_north/examples/palindrome.py(9)digits()
-> x = mod
(Pdb)

让程序运行几秒钟后,我们按下 Ctrl-C,这将停止程序并显示我们在palindrome.pydigits()函数中。如果我们想在那一行看到源代码,我们可以使用 PDB 命令list

(Pdb) list
  4       "Convert an integer into a list of digits."
  5       digs = []
  6       while x != 0:
  7           div, mod = divmod(x, 10)
  8           digs.append(mod)
  9  ->       x = mod
 10       return digs
 11
 12   def is_palindrome(x):
 13       "Determine if an integer is a palindrome."
 14       digs = digits(x)
(Pdb)

我们看到这确实是在一个循环内部,这证实了我们的怀疑可能涉及无限循环。

我们可以使用return命令尝试运行到当前函数的末尾。如果这不返回,我们将有非常强有力的证据表明这是一个无限循环:

(Pdb) r

我们让它运行几秒钟,以确认我们从未退出该函数,然后我们按下 Ctrl-C。一旦我们回到 PDB 提示符,让我们使用quit命令退出 PDB:

(Pdb) quit
%

设置显式断点

由于我们知道问题出在digits()中,让我们使用之前提到的pdb.set_trace()函数在那里设置一个显式断点:

def digits(x):
    """Convert an integer into a list of digits.

 Args:
 x: The number whose digits we want.

 Returns: A list of the digits, in order, of ``x``.

 >>> digits(4586378)
 [4, 5, 8, 6, 3, 7, 8]
 """

    import pdb; pdb.set_trace()

    digs = []
    while x != 0:
        div, mod = divmod(x, 10)
        digs.append(mod)
        x = mod
    digs.reverse()
    return digs

记住,set_trace()函数将停止执行并进入调试器。

所以现在我们可以执行我们的脚本,而不指定 PDB 模块:

% python palindrome.py
> /Users/sixty_north/examples/palindrome.py(8)digits()
-> digs = []
(Pdb)

我们看到我们几乎立即进入 PDB 提示符,执行在我们的digits()函数的开始处暂停。

为了验证我们知道我们在哪里,让我们使用where来查看我们的调用堆栈:

(Pdb) where
  /Users/sixty_north/examples/palindrome.py(35)<module>()
-> unittest.main()
  /Library/Frameworks/Python.framework/Versions/3.5/lib/python3.5/unittest/main.py(95\
)__init__()
-> self.runTests()
  /Library/Frameworks/Python.framework/Versions/3.5/lib/python3.5/unittest/main.py(22\
9)runTests()
-> self.result = testRunner.run(self.test)
  /Library/Frameworks/Python.framework/Versions/3.5/lib/python3.5/unittest/runner.py(\
151)run()
-> test(result)
  /Library/Frameworks/Python.framework/Versions/3.5/lib/python3.5/unittest/suite.py(7\
0)__call__()
-> return self.run(*args, **kwds)
  /Library/Frameworks/Python.framework/Versions/3.5/lib/python3.5/unittest/suite.py(1\
08)run()
-> test(result)
  /Library/Frameworks/Python.framework/Versions/3.5/lib/python3.5/unittest/suite.py(7\
0)__call__()
-> return self.run(*args, **kwds)
  /Library/Frameworks/Python.framework/Versions/3.5/lib/python3.5/unittest/suite.py(1\
08)run()
-> test(result)
  /Library/Frameworks/Python.framework/Versions/3.5/lib/python3.5/unittest/case.py(39\
1)__call__()
-> return self.run(*args, **kwds)
  /Library/Frameworks/Python.framework/Versions/3.5/lib/python3.5/unittest/case.py(32\
7)run()
-> testMethod()
  /Users/sixty_north/examples/palindrome.py(25)test_negative()
-> self.assertFalse(is_palindrome(1234))
  /Users/sixty_north/examples/palindrome.py(17)is_palindrome()
-> digs = digits(x)
> /Users/sixty_north/examples/palindrome.py(8)digits()
-> digs = []

记住,最近的帧在此列表的末尾。经过很多unittest函数后,我们看到我们确实在digits()函数中,并且它是由is_palindrome()调用的,正如我们所预期的那样。

逐步执行

现在我们要做的是观察执行,并看看为什么我们从未退出这个函数的循环。让我们使用next移动到循环体的第一行:

(Pdb) next
> /Users/sixty_north/examples/palindrome.py(9)digits()
-> while x != 0:
(Pdb) next
> /Users/sixty_north/examples/palindrome.py(10)digits()
-> div, mod = divmod(x, 10)
(Pdb)

现在让我们看一下一些变量的值,并尝试决定我们期望发生什么。我们可以使用print命令来检查值^(34):

(Pdb) print(digs)
[]
(Pdb) print x
1234

这看起来是正确的。digs列表 - 最终将包含数字序列 - 是空的,x是我们传入的。我们期望divmod()函数返回1234,所以让我们试试看:

(Pdb) next
> /Users/sixty_north/examples/palindrome.py(11)digits()
-> digs.append(mod)
(Pdb) print div,mod
123 4

这看起来正确:divmod()已经从我们的数字中剪掉了最低有效位数字,下一行将该数字放入我们的结果列表中:

(Pdb) next
> /Users/sixty_north/examples/palindrome.py(12)digits()
-> x = mod

如果我们查看digs,我们会看到它现在包含mod

(Pdb) print digs
[4]

下一行现在将更新x,以便我们可以继续从中剪切数字:

(Pdb) next
> /Users/sixty_north/examples/palindrome.py(9)digits()
-> while x != 0:

我们看到执行回到了 while 循环,正如我们所预期的那样。让我们查看x,确保它有正确的值:

(Pdb) print x
4

等一下!我们期望x保存的是不在结果列表中的数字。相反,它只包含结果列表中的数字。显然我们在更新x时犯了一个错误!

如果我们查看我们的代码,很快就会发现我们应该将div而不是mod分配给x。让我们退出 PDB:

(Pdb) quit

请注意,由于 PDB 和unittest的交互方式,您可能需要运行几次quit

修复错误

当您退出 PDB 后,让我们删除set_trace()调用并修改digits()来解决我们发现的问题:

def digits(x):
    """Convert an integer into a list of digits.

    Args:
      x: The number whose digits we want.

    Returns: A list of the digits, in order, of ``x``.

    >>> digits(4586378)
    [4, 5, 8, 6, 3, 7, 8]
    """

    digs = []
    while x != 0:
        div, mod = divmod(x, 10)
        digs.append(mod)
        x = div
    digs.reverse()
    return digs

如果我们现在运行我们的程序,我们会看到我们通过了所有的测试,并且运行非常快:

$ python palindrome.py
...
----------------------------------------------------------------------
Ran 3 tests in 0.001s

OK

这就是一个基本的 PDB 会话,并展示了 PDB 的一些核心特性。然而,PDB 还有许多其他命令和特性,学习它们的最佳方法是开始使用 PDB 并尝试这些命令。这个回文程序可以作为学习 PDB 大多数特性的一个很好的例子。

总结

  • Python 的标准调试器称为 PDB。

  • PDB 是一个标准的命令行调试器。

  • pdb.set_trace()方法可用于停止程序执行并进入调试器。

  • 当您处于调试器中时,您的 REPL 提示将更改为(Pdb)。

  • 您可以通过输入“help”来访问 PDB 的内置帮助系统。

  • 您可以使用python -m pdb后跟脚本名称来从头开始在 PDB 下运行程序。

  • PDB 的where命令显示当前的调用堆栈。

  • PDB 的next命令让执行继续到下一行代码。

  • PDB 的continue命令让程序执行无限期地继续,或者直到您使用 control-c 停止它。

  • PDB 的list命令显示您当前位置的源代码。

  • PDB 的return命令恢复执行,直到当前函数的末尾。

  • PDB 的print命令让您在调试器中查看对象的值。

  • 使用quit退出 PDB。

在这个过程中,我们发现:

  • divmod()可以一次计算除法运算的商和余数。

  • reversed()函数可以反转一个序列。

  • 您可以通过在 Python 命令后传递-m来使其作为脚本运行一个模块。

  • 调试使得清楚 Python 在运行时评估一切。

后记:只是个开始。

正如我们在开头所说,Python 是一门庞大的语言。我们的目标是通过本书让您朝着正确的方向开始,为您提供不仅能有效地编写 Python 程序,而且能够引导自己的语言成长所需的基础。希望我们做到了!

我们鼓励您尽可能多地运用在这里学到的知识。实践这些技能确实是掌握它们的唯一途径,我们相信,随着您运用这门语言,您对 Python 的欣赏会不断加深。也许您可以立即在工作或学校中使用 Python,但如果不行,还有无数的开源项目希望得到您的帮助。或者您可以开始自己的项目!有很多方式可以获得 Python 的经验,真正的问题可能是找到最适合您的那一种。

当然,Python 还有很多内容没有在本书中涉及。我们的书籍《Python 初学者》和《Python 大师》涵盖了许多这里没有涉及的更高级的主题,所以当您准备学习更多时,可以看一看它们。或者,如果您有兴趣以其他形式学习 Python,请务必查看 PluralSight 上的 Python 课程,《Python 基础》、《Python:进阶》和《高级 Python》。我们还提供公司 Sixty North 的内部 Python 培训和咨询,如果您有更多实质性的需求。

无论您的 Python 之旅如何,我们真诚地希望您喜欢这本书。Python 是一门很棒的语言,拥有一个伟大的社区,我们希望您能像我们一样从中获得快乐。编程愉快!

附录 A:虚拟环境

虚拟环境是一个轻量级的、独立的 Python 安装。虚拟环境的主要动机是允许不同的项目控制安装的 Python 包的版本,而不会干扰同一主机上安装的其他 Python 项目。虚拟环境包括一个目录,其中包含对现有 Python 安装的符号链接(Unix),或者是一个副本(Windows),以及一个空的site-packages目录,用于安装特定于该虚拟环境的 Python 包。虚拟环境的第二个动机是,用户可以在不需要系统管理员权限的情况下创建虚拟环境,这样他们可以轻松地在本地安装软件包。第三个动机是,不同的虚拟环境可以基于不同版本的 Python,这样可以更容易地在同一台计算机上测试代码,比如在 Python 3.4 和 Python 3.5 上。

如果你使用的是 Python 3.3 或更高版本,那么你的系统上应该已经安装了一个叫做venv的模块。你可以通过在命令行上运行它来验证:

$ python3 -m venv
usage: venv [-h] [--system-site-packages] [--symlinks | --copies] [--clear]
            [--upgrade] [--without-pip]
            ENV_DIR [ENV_DIR ...]
venv: error: the following arguments are required: ENV_DIR

如果你没有安装venv,还有另一个工具叫做virtualenv,它的工作方式非常类似。你可以从Python Package Index (PyPI)获取它。我们将在附录 C 中解释如何从 PyPI 安装软件包。你可以使用venvvirtualenv,不过我们将在这里使用venv,因为它已经内置在最新版本的 Python 中。

创建虚拟环境

使用venv非常简单:你指定一个目录的路径,该目录将包含新的虚拟环境。该工具会创建新目录并填充它的安装内容:

$ python3 -m venv my_python_3_5_project_env

激活虚拟环境

创建环境后,你可以通过在环境的bin目录中使用activate脚本来激活它。在 Linux 或 macOS 上,你需要source该脚本:

$ source my_python_3_5_project_env/bin/activate

在 Windows 上运行它:

> my_python_3_5_project_env\bin\activate

一旦你这样做,你的提示符将会改变,提醒你当前处于虚拟环境中:

(my_python_3_5_project_env) $

运行python时执行的 Python 来自虚拟环境。实际上,使用虚拟环境是获得可预测的 Python 版本的最佳方式,而不是记住要使用python来运行 Python 2,python3来运行 Python 3。

一旦进入虚拟环境,你可以像平常一样工作,放心地知道包安装与系统 Python 和其他虚拟环境是隔离的。

退出虚拟环境

要离开虚拟环境,请使用deactivate命令,这将使你返回到激活虚拟环境的父 shell:

(my_python_3_5_project_env) $ deactivate
$

其他用于虚拟环境的工具

如果你经常使用虚拟环境——我们建议你几乎总是在其中工作——管理大量的环境本身可能会变得有些繁琐。集成开发环境,比如JetBrains’ PyCharm,提供了出色的支持来创建和使用虚拟环境。在命令行上,我们推荐一个叫做virtualenv wrapper的工具,它可以使在依赖不同虚拟环境的项目之间切换几乎变得轻而易举,一旦你做了一些初始配置。

附录 B:打包和分发

打包和分发你的 Python 代码可能是一个复杂的,有时令人困惑的任务,特别是如果你的项目有很多依赖项或涉及比纯 Python 代码更奇特的组件。然而,对于许多情况来说,以标准方式使你的代码对他人可访问是非常直接的,我们将在本节中看到如何使用标准的distutils模块来做到这一点。distutils的主要优势是它包含在 Python 标准库中。对于远非最简单的打包要求,你可能会想要使用setuptools,它具有超出distutils的功能,但相应地更加令人困惑。

distutils模块允许你编写一个简单的 Python 脚本,它知道如何将你的 Python 模块安装到任何 Python 安装中,包括托管在虚拟环境中的安装。按照惯例,这个脚本被称为setup.py,并且存在于项目结构的顶层。然后可以执行此脚本来执行实际安装。

使用distutils配置包

让我们看一个distutils的简单例子。我们将为我们在第十一章中编写的palindrome模块创建一个基本的setup.py安装脚本。

我们想要做的第一件事是创建一个目录来保存我们的项目。让我们称之为palindrome

$ mkdir palindrome
$ cd palindrome

让我们把我们的palindrome.py复制到这个目录中:

"""palindrome.py - Detect palindromic integers"""

import unittest

def digits(x):
    """Convert an integer into a list of digits.

 Args:
 x: The number whose digits we want.

 Returns: A list of the digits, in order, of ``x``.

 >>> digits(4586378)
 [4, 5, 8, 6, 3, 7, 8]
 """

    digs = []
    while x != 0:
        div, mod = divmod(x, 10)
        digs.append(mod)
        x = div
    digs.reverse()
    return digs

def is_palindrome(x):
    """Determine if an integer is a palindrome.

 Args:
 x: The number to check for palindromicity.

 Returns: True if the digits of ``x`` are a palindrome,
 False otherwise.

 >>> is_palindrome(1234)
 False
 >>> is_palindrome(2468642)
 True
 """
    digs = digits(x)
    for f, r in zip(digs, reversed(digs)):
        if f != r:
            return False
    return True

class Tests(unittest.TestCase):
    "Tests for the ``is_palindrome()`` function."
    def test_negative(self):
        "Check that it returns False correctly."
        self.assertFalse(is_palindrome(1234))

    def test_positive(self):
        "Check that it returns True correctly."
        self.assertTrue(is_palindrome(1234321))

    def test_single_digit(self):
        "Check that it works for single digit numbers."
        for i in range(10):
            self.assertTrue(is_palindrome(i))

if __name__ == '__main__':
    unittest.main()

最后让我们创建setup.py脚本:

from distutils.core import setup

setup(
    name = 'palindrome',
    version = '1.0',
    py_modules  = ['palindrome'],

    # metadata
    author = 'Austin Bingham',
    author_email = 'austin@sixty-north.com',
    description = 'A module for finding palindromic integers.',
    license = 'Public domain',
    keywords = 'palindrome',
    )

文件中的第一行从distutils.core模块导入我们需要的功能,即setup()函数。这个函数完成了安装我们代码的所有工作,所以我们需要告诉它我们正在安装的代码。当然,我们通过传递给函数的参数来做到这一点。

我们告诉setup()的第一件事是这个项目的名称。在这种情况下,我们选择了palindrome,但你可以选择任何你喜欢的名称。不过,一般来说,最简单的方法是将名称与项目名称保持一致。

我们传递给setup()的下一个参数是版本。同样,这可以是任何你想要的字符串。Python 不依赖于版本遵循任何规则。

下一个参数py_modules可能是最有趣的。我们使用它来指定我们想要安装的 Python 模块。列表中的每个条目都是模块的名称,不包括.py扩展名。setup()将查找匹配的.py文件并安装它。所以,在我们的例子中,我们要求setup()安装palindrome.py,当然,这是我们项目中的一个文件。

我们在这里使用的其余参数都相当不言自明,主要是为了帮助人们正确使用你的模块,并知道如果他们遇到问题应该联系谁。

在我们开始使用我们的setup.py之前,我们首先需要创建一个虚拟环境,我们将在其中安装我们的模块。在你的palindrome目录中,创建一个名为palindrome_env的虚拟环境:

$ python3 -m venv palindrome_env

当这完成后,激活新的环境。在 Linux 或 macOS 上,执行激活脚本:

$ source palindrome_env/bin/activate

或者在 Windows 上直接调用脚本:

> palindrome_env\bin\activate

使用distutils安装

现在我们有了setup.py,我们可以用它来做一些有趣的事情。我们可以做的第一件事,也许是最明显的,就是将我们的模块安装到我们的虚拟环境中!我们通过向setup.py传递install参数来实现这一点:

(palindrome_env)$ python setup.py install
running install
running build
running build_py
copying palindrome.py -> build/lib
running install_lib
copying build/lib/palindrome.py -> /Users/sixty_north/examples/palindrome/palindrome_\
env/lib/python3.5/site-packages
byte-compiling /Users/sixty_north/examples/palindrome/palindrome_env/lib/python3.5/si\
te-packages/palindrome.py to palindrome.cpython-35.pyc
running install_egg_info
Writing /Users/sixty_north/examples/palindrome/palindrome_env/lib/python3.5/site-pack\
ages/palindrome-1.0-py3.5.egg-info

当调用setup()时,它会打印出几行来告诉你它的进度。对我们来说最重要的一行是它实际将palindrome.py复制到安装文件夹的地方:

copying build/lib/palindrome.py -> /Users/sixty_north/examples/palindrome/palindrome_\
env/lib/python3.5/site-packages

Python 安装的site-packages目录是第三方包通常安装的地方,就像我们的包看起来安装成功了一样。

让我们通过运行 Python 来验证这一点,并看到我们的模块可以被导入。请注意,在我们这样做之前,我们要改变目录,否则当我们导入palindrome时,Python 会加载我们当前目录中的源文件:

(palindrome_env)$ cd ..
(palindrome_env)$ python
Python 3.5.2 (v3.5.2:4def2a2901a5, Jun 26 2016, 10:47:25)
[GCC 4.2.1 (Apple Inc. build 5666) (dot 3)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>> import palindrome
>>> palindrome.__file__
'/Users/sixty_north/examples/palindrome/palindrome_env/lib/python3.5/site-packages/pa\
lindrome.py'

在这里,我们使用模块的__file__属性来查看它是从哪里导入的,我们看到我们是从我们的虚拟环境的site-packages中导入的,这正是我们想要的。

退出 Python REPL 后,不要忘记切换回你的源目录:

(palindrome_env)$ cd palindrome

使用distutils进行打包

setup()的另一个有用的特性是它可以创建各种类型的“分发”格式。它将把你指定的所有模块打包成易于分发给他人的包。你可以使用sdist命令来实现这一点(这是“源分发”的缩写):

(palindrome_env)$ python setup.py sdist --format zip
running sdist
running check
warning: check: missing required meta-data: url

warning: sdist: manifest template 'MANIFEST.in' does not exist (using default file li\
st)

warning: sdist: standard file not found: should have one of README, README.txt

writing manifest file 'MANIFEST'
creating palindrome-1.0
making hard links in palindrome-1.0...
hard linking palindrome.py -> palindrome-1.0
hard linking setup.py -> palindrome-1.0
creating dist
creating 'dist/palindrome-1.0.zip' and adding 'palindrome-1.0' to it
adding 'palindrome-1.0/palindrome.py'
adding 'palindrome-1.0/PKG-INFO'
adding 'palindrome-1.0/setup.py'
removing 'palindrome-1.0' (and everything under it)

如果我们查看,我们会发现这个命令创建了一个新的目录dist,其中包含了新生成的分发文件:

(palindrome_env) $ ls dist
palindrome-1.0.zip

如果我们解压缩该文件,我们会看到它包含了我们项目的源代码,包括setup.py

(palindrome_env)$ cd dist
(palindrome_env)$ unzip palindrome-1.0.zip
Archive:  palindrome-1.0.zip
  inflating: palindrome-1.0/palindrome.py
  inflating: palindrome-1.0/PKG-INFO
  inflating: palindrome-1.0/setup.py

现在你可以把这个 zip 文件发送给任何想要使用你的代码的人,他们可以使用setup.py将其安装到他们的系统中。非常方便!

请注意,sdist命令可以生成各种类型的分发。要查看可用的选项,可以使用--help-formats选项:

(palindrome_env) $ python setup.py sdist --help-formats
List of available source distribution formats:
  --formats=bztar  bzip2'ed tar-file
  --formats=gztar  gzip'ed tar-file
  --formats=tar    uncompressed tar file
  --formats=zip    ZIP file
  --formats=ztar   compressed tar file

这一部分只是简单地介绍了distutils的基础知识。你可以通过向setup.py传递--help来了解更多关于如何使用distutils的信息:

(palindrome_env) $ python setup.py --help
Common commands: (see '--help-commands' for more)

  setup.py build      will build the package underneath 'build/'
  setup.py install    will install the package

Global options:
  --verbose (-v)      run verbosely (default)
  --quiet (-q)        run quietly (turns verbosity off)
  --dry-run (-n)      don't actually do anything
  --help (-h)         show detailed help message
  --command-packages  list of packages that provide distutils commands

Information display options (just display information, ignore any commands)
  --help-commands     list all available commands
  --name              print package name
  --version (-V)      print package version
  --fullname          print <package name>-<version>
  --author            print the author's name
  --author-email      print the author's email address
  --maintainer        print the maintainer's name
  --maintainer-email  print the maintainer's email address
  --contact           print the maintainer's name if known, else the author's
  --contact-email     print the maintainer's email address if known, else the
                      author's
  --url               print the URL for this package
  --license           print the license of the package
  --licence           alias for --license
  --description       print the package description
  --long-description  print the long package description
  --platforms         print the list of platforms
  --classifiers       print the list of classifiers
  --keywords          print the list of keywords
  --provides          print the list of packages/modules provided
  --requires          print the list of packages/modules required
  --obsoletes         print the list of packages/modules made obsolete

usage: setup.py [global_opts] cmd1 [cmd1_opts] [cmd2 [cmd2_opts] ...]
   or: setup.py --help [cmd1 cmd2 ...]
   or: setup.py --help-commands
   or: setup.py cmd --help

对于许多简单的项目,你会发现我们刚刚介绍的几乎就是你需要了解的全部内容。

附录 C:安装第三方软件包

Python 的打包历史曾经饱受困扰和混乱。幸运的是,情况已经稳定下来,一个名为pip的工具已经成为通用 Python 使用中包安装工具的明确领先者。对于依赖NumpyScipy软件包的数值或科学计算等更专业的用途,您应该考虑Anaconda作为pip的一个强大替代品。

介绍pip

在本附录中,我们将专注于pip,因为它是由核心 Python 开发人员正式认可的,并且具有开箱即用的支持。pip工具已包含在 Python 3.4 及以上版本中。对于较旧版本的 Python 3,您需要查找有关如何为您的平台安装pip的具体说明,因为您可能需要使用操作系统的软件包管理器,这取决于您最初安装 Python 的方式。开始的最佳地方是Python 包装用户指南

venv模块还将确保pip安装到新创建的环境中。

pip工具是独立于标准库的其余部分开发的,因此通常有比随附 Python 分发的版本更近的版本可用。您可以使用pip来升级自身:

$ pip install --upgrade pip

这是有用的,可以避免pip重复警告您不是最新版本。但请记住,这只会在当前 Python 环境中生效,这可能是一个虚拟环境。

Python 包索引

pip工具可以在中央存储库(Python 包索引PyPI,也被昵称为“奶酪店”)中搜索软件包,然后下载和安装它们以及它们的依赖项。您可以在pypi.python.org/pypi上浏览 PyPI。这是一种非常方便的安装 Python 软件的方式,因此了解如何使用它是很好的。

使用pip安装

我们将演示如何使用pip来安装nose测试工具。nose是一种用于运行基于unittest的测试的强大工具,例如我们在第十章中开发的测试。它可以做的一个非常有用的事情是发现所有的测试并运行它们。这意味着您不需要将unittest.main()添加到您的代码中;您可以使用 nose 来查找和运行您的测试。

不过,首先我们需要做一些准备工作。让我们创建一个虚拟环境(参见附录 B),这样我们就不会意外地安装到系统 Python 安装中。使用pyenv创建一个虚拟环境,并激活它:

$ python3 -m venv test_env
$ source activate test_env/bin/activate
(test_env) $

由于pip的更新频率远远超过 Python 本身,因此在任何新的虚拟环境中升级pip是一个良好的做法,所以让我们这样做。幸运的是,pip能够更新自身:

(test_env) $ pip install --upgrade pip
Collecting pip
  Using cached pip-8.1.2-py2.py3-none-any.whl
Installing collected packages: pip
  Found existing installation: pip 8.1.1
    Uninstalling pip-8.1.1:
      Successfully uninstalled pip-8.1.1
Successfully installed pip-8.1.2

如果您不升级pip,每次使用它时都会收到警告,如果自上次升级以来已有新版本可用。

现在让我们使用pip来安装nosepip使用子命令来决定要执行的操作,并且要安装模块,您可以使用pip install package-name

(test_env) $ pip install nose
Collecting nose
  Downloading nose-1.3.7-py3-none-any.whl (154kB)
    100% |████████████████████████████████| 163kB 2.1MB/s
Installing collected packages: nose
Successfully installed nose-1.3.7

如果成功,nose已准备好在我们的虚拟环境中使用。让我们通过尝试在 REPL 中导入它并检查安装路径来确认它是否可用:

(test_env) $ python
Python 3.5.2 (v3.5.2:4def2a2901a5, Jun 26 2016, 10:47:25)
[GCC 4.2.1 (Apple Inc. build 5666) (dot 3)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>> import nose
>>> nose.__file__
'/Users/sixty_north/.virtualenvs/test_env/lib/python3.5/site-packages/nose/__init__.p\
y'

除了安装模块外,nose还会在虚拟环境的bin目录中安装nosetests程序。为了真正锦上添花,让我们使用nosetests来运行第十一章中的palindrome.py中的测试:

(test_env) $ cd palindrome
(test_env) $ nosetests palindrome.py
...
----------------------------------------------------------------------
Ran 3 tests in 0.001s

OK

使用pip安装本地软件包

您还可以使用pip从文件中安装本地软件包,而不是从 Python 包索引中安装。要做到这一点,请将打包分发的文件名传递给pip install。例如,在附录 B 中,我们展示了如何使用distutils构建所谓的源分发。要使用pip安装这个,做:

(test_env) $ palindrome/dist
(test_env) $ pip install palindrome-1.0.zip

卸载软件包

使用pip安装软件包而不是直接调用源分发的setup.py的一个关键优势是,pip知道如何卸载软件包。要这样做,使用uninstall子命令:

(test_env) $ pip uninstall palindrome-1.0.zip
Uninstalling palindrome-1.0:
Proceed (y/n)? y
  Successfully uninstalled palindrome-1.0

注释

1 尽管越来越多的项目开始“主要是 Python 3”甚至“仅限 Python 3”。↩

2 我们不在本书中涵盖正则表达式,也称为regexes。有关更多信息,请参阅 Python 标准库re模块的文档。docs.python.org/3/library/re.html

3 从技术上讲,模块不一定是简单的源代码文件,但对于本书的目的,这是一个足够的定义。↩

4 从技术上讲,一些编译语言确实提供了在运行时动态定义函数的机制。然而,在几乎所有情况下,这些方法都是例外而不是规则。↩

5Python 代码实际上是编译成字节码的,因此从这个意义上说,Python 有一个编译器。但是编译器所做的工作与您从流行的编译、静态类型语言中所熟悉的工作大不相同。↩

6 您会注意到,这里我们用名称x来引用对象引用,并将其表示为x。这有点懒散,因为当然,x通常意味着由名称x的对象引用引用的对象。但这有点啰嗦和过于迂腐。一般来说,引用名称的使用上下文足以告诉您我们是指对象还是引用。↩

7 垃圾回收是一个我们在本书中不会涵盖的高级主题。简而言之,这是 Python 用来释放和回收它确定不再使用的资源(即对象)的系统。↩

8 由于将列表引用分配给另一个名称不会复制列表,您可能想知道如果需要的话如何进行复制。这需要其他技术,我们稍后在更详细地讨论列表时会看到。↩

9 然而,请注意,Python 不强制执行此行为。完全有可能创建一个对象,该对象报告它与自身不是值相同。我们将在后面的章节中看看如何做到这一点 - 如果您因某种原因感到有冲动的话。↩

10 虽然没有普遍接受的术语,但您经常会看到术语参数形式参数用来表示在函数定义中声明的名称。同样,术语参数经常用来表示传递给函数的实际对象(因此,绑定到参数)。我们将根据需要在本书中使用这些术语。↩

11 这种行为是语法实现的一部分,而不是类型系统的一部分。↩

12 在 Python 2 时代,range()是一个返回列表的函数。Python 3 版本的range更加高效、有用和强大。↩

13 当然,这让人想起了一个经典笑话:编程中最困难的两个问题是命名、缓存一致性和一次性错误。↩

14 可以说,一个包含相同名称函数的模块设计不好,因为会出现这个问题。↩

15 我们稍后在本章中详细介绍可迭代协议。↩

16 嗯,它们可以,但请记住,遍历字典只会产生键!↩

17 我们经常只使用术语生成器来指代生成器函数,尽管有时可能需要区分生成器函数和生成器表达式,我们稍后会涵盖这一点。↩

18 作者们发誓永远不会在演示或练习中使用斐波那契或快速排序的实现。

19 这与您应该观看 Star Wars 剧集的顺序无关。如果您正在寻找这方面的建议,我们可以建议Machete Order

20 实际上,可以在运行时更改对象的类,尽管这是一个高级话题,而且这种技术很少被使用。

21 在 Python 中,考虑对象的销毁通常是没有帮助的。最好考虑对象变得不可访问。

22 函数的形式参数是函数定义中列出的参数。

23 函数的实际参数是函数调用中列出的参数。

24 或者任何语言。

25 您可以在PEP 343中找到关于 with 语句的语法等价的完整细节。

26 您可以在这里了解有关 BMP 格式的所有细节。

27 比如,sequence协议是用于类似元组的对象。

28Easier to Ask Forgiveness Than Permission

29 测试驱动开发,或 TDD,是一种软件开发形式,其中测试是首先编写的,即在编写要测试的实际功能之前。这乍看起来可能有些反常,但它实际上是一种非常强大的技术。您可以在这里了解更多关于 TDD 的信息。

30 请注意,我们实际上并没有尝试测试任何功能。这只是我们测试套件的初始框架,让我们验证测试方法是否执行。

31TDD 的原则是,您的测试应该在通过之前失败,并且您只能编写足够的实现代码来使测试通过。通过这种方式,您的测试就是对代码应该如何行为的完整描述。

32 您可能已经注意到,setUp()tearDown()方法的名称与 PEP 8 规定的不一致。这是因为unittest模块早于 PEP 8 规定的函数名称应为小写并带下划线的部分。Python 标准库中有几种这样的情况,但大多数新的 Python 代码都遵循 PEP 8 风格。

33 如果我们在这里严格解释 TDD,这种实现量就太多了。为了使现有的测试通过,我们不需要实际实现行计数;我们只需要返回值 4。随后的测试将不断强迫我们“更新”我们的实现,因为它们描述了更完整的分析算法版本。我们认为您会同意,在这里以及实际开发中,这种教条主义的方法都是不合适的。

34 请注意,我们可以使用print,无论是否带括号。不要惊慌——我们没有退回到 Python 2。在这种情况下,print是 PDB 的命令,而不是 Python 3 的函数

posted @ 2024-05-04 21:30  绝不原创的飞龙  阅读(38)  评论(0编辑  收藏  举报