微型-Python-项目-全-
微型 Python 项目(全)
原文:Tiny Python Projects
译者:飞龙
前言
前言
为什么写 Python?
Python 是一种优秀、通用的编程语言。你可以用它编写程序向朋友发送秘密信息或玩国际象棋。有 Python 模块可以帮助你处理复杂的科学数据,探索机器学习算法,并生成可用于发表的图形。许多大学计算机科学课程已经从 C 和 Java 等语言转向 Python 作为它们的入门语言,因为 Python 是一种相对容易学习的语言。我们可以使用 Python 来研究计算机科学的基本和强大思想。当我向你展示正则表达式和高级函数等思想时,我希望鼓励你进一步学习。
我为什么要写这本书?
这些年来,我有许多机会帮助人们学习编程,而且我总是觉得这很有成就感。这本书的结构来源于我在课堂上的个人经验,在那里我认为正式规范和测试可以是有用的辅助工具,有助于学习如何将程序分解成需要解决以创建整个程序的小问题。
当我学习一门新语言时,我发现的最大障碍是语言的小概念通常是在任何有用的上下文中提出的。大多数编程语言教程都会从打印“HELLO, WORLD!”(这本书也不例外)开始。通常这很简单。之后,我通常很难编写一个完整的程序,该程序可以接受一些参数并做一些有用的事情。在这本书中,我会向你展示许多程序示例,这些程序做了一些有用的事情,希望你能修改这些程序来为自己创建更多的程序。
除此之外,我认为你需要多加练习。这就像那个古老的笑话:“卡内基音乐厅的路怎么走?练习,练习,再练习。”这些编码挑战足够短,你可能只需几个小时或几天就能完成每一个。这比我在一个学期的大学课程中能处理的内容要多,所以我设想这本书需要你几个月的时间。我希望你能解决这些问题,然后思考它们,之后回来看看你是否可以用不同的方法解决它们,也许使用更高级的技术或使它们运行得更快。
致谢
这是我写的第一本书,注意到许多帮助我完成它的人真是件有趣的事情。一切始于与 Manning 的采购编辑 Mike Stephens 的一次电话,他提出了写一本关于如何通过编写愚蠢的游戏和谜题来学习如何制作严肃、经过测试的软件的书。这最终导致我与出版商 Marjan Bace 的一次电话,她对使用测试驱动开发的想法来激励读者积极参与编写程序表示了热情。
我的第一个发展编辑 Susanna Kline 不得不帮助我把这本书的前几章改写成人们真正愿意阅读的内容。我的第二个发展编辑 Elesha Hyde 在几个月的写作、编辑和审阅过程中提供了耐心和深思熟虑的指导。我感谢我的技术编辑 Scott Chaussee、Al Scherer 和 Mathijs Affourtit,他们仔细检查了我所有的代码和文本中的错误。我感谢 Manning 的 MEAP 团队的努力,特别是 Mehmed Pašić,他制作了 PDF 文件,并在我如何使用 AsciiDoc 方面提供了技术指导。我还想感谢我的项目编辑 Deirdre Hiam、我的校对员 Andy Carroll、我的校对员 Katie Tennant 和我的审阅编辑 Aleksandar Dragosavljević。此外,还要感谢 liveBook 版本的读者以及那些提供了如此多宝贵反馈的技术审阅者:Amanda Debler、Conor Redmond、Drew Leon、Joaquin Beltran、José Apablaza、Kimberly Winston-Jackson、Maciej Jurkowski、Mafinar Khan、Manuel Ricardo Gonzalez Cova、Marcel van den Brink、Marcin Sȩk、Mathijs Affourtit、Paul R Hendrik、Shayn Cornwell、Víctor M. Pérez。
我特别想感谢那些创建所有这些构建在之上的开源软件的无数人。从维护 Python 语言和模块以及文档的人们,到在互联网上回答无数问题的黑客们,我感谢你们所做的一切。
当然,如果没有家人无私的爱和支持,这一切都不可能实现,尤其是我的妻子 Lori Kindler,她在我生命中超过 27 年的时间里一直是我不可置信的爱与支持的源泉。(我仍然非常抱歉关于我在山地自行车上出事故的事情,以及我恢复健康所花费的一年时间!)我们的三个孩子给我带来了如此多的挑战和快乐,我希望我能让他们感到骄傲。他们不得不假装对那些他们知道且不感兴趣的话题感兴趣,而且他们对我在写这本书所花费的无数小时表现出了如此多的耐心。
关于这本书
谁应该阅读这本书
在你阅读这本书并编写所有程序之后,我希望你将成为一个热衷于创建有文档、经过测试和可重复的程序的人。
我认为我的理想读者是那些一直在努力学习编码但不确定如何提升自己的人。也许你就是那种一直在玩 Python 或其他语法类似的编程语言,比如 Java(Script)或 Perl 的人。也许你曾在非常不同的语言上磨砺过,比如 Haskell 或 Scheme,你现在想知道如何将你的想法转化为 Python。也许你已经写了一段时间的 Python,正在寻找既有足够结构又能帮助你了解自己是否在正确方向上前进的有趣挑战。
这本书将教你如何用 Python 编写结构良好、有文档、可测试的代码。材料介绍了来自行业的最佳实践,如测试驱动开发——即程序本身的编写甚至在其测试之前就已经存在!我会向你展示如何阅读文档和 Python 增强提案(PEPs),以及如何编写其他 Python 程序员能立即识别和理解的习惯用法代码。
这可能不是一本适合完全初学者的书。我假设读者没有特定的 Python 语言知识,因为我考虑的是来自其他语言背景的人。如果你从未用任何语言编写过程序,那么在你熟悉变量、循环和函数等概念之后再回过头来学习这些材料会更好。
本书是如何组织的:一个路线图
本书是按章节顺序编写的,因此我强烈建议你从开头开始,按顺序学习材料。
-
每个程序都使用命令行参数,因此我们首先讨论如何使用
argparse来处理这些参数。每个程序都会进行测试,所以你将需要学习如何安装和使用pytest。引言和第一章将帮助你开始学习。 -
第 2-4 章讨论了 Python 的基本结构,如字符串、列表和字典。
-
第五章和第六章将探讨如何作为输入和输出与文件一起工作,以及文件与“标准输入”和“标准输出”(
STDIN/STDOUT)的关系。 -
第七章和第八章开始结合这些想法,以便你可以编写更复杂的程序。
-
第九章和第十章介绍了
random模块以及如何控制和测试随机事件。 -
在第 11-13 章中,你将学习如何将代码划分为函数以及如何为它们编写和运行测试。
-
在第 14-18 章中,我们将开始深入研究更密集的主题,如高阶函数以及正则表达式来查找文本模式。
-
在第 19-22 章中,我们将开始编写更复杂、更“真实世界”的程序,这些程序将综合运用你的所有技能,同时推动你对 Python 语言的知识和测试。
关于代码
书中展示的每个程序和测试都可以在github.com/kyclark/tiny_python_projects找到。
软件硬件要求
所有程序都是用 Python 3.8 编写的,但 3.6 版本对于几乎所有程序来说都足够了。需要几个额外的模块,例如用于运行测试的pytest。有关如何使用pip模块安装这些模块的说明。
liveBook 讨论论坛
购买《Tiny Python Projects》包括免费访问由曼宁出版社运行的私人网络论坛,你可以在那里对书籍发表评论,提出技术问题,并从作者和其他用户那里获得帮助。要访问论坛,请访问livebook.manning.com/book/tiny-python-projects/welcome/v-6。你还可以在livebook.manning.com/#!/discussion了解更多关于曼宁论坛和行为准则的信息。
曼宁对读者的承诺是提供一个平台,让读者之间以及读者与作者之间可以进行有意义的对话。这并不是对作者参与特定数量活动的承诺,作者对论坛的贡献仍然是自愿的(且未付费)。我们建议你尝试向他提出一些挑战性的问题,以免他的兴趣转移!只要这本书有售,论坛和以前讨论的存档将可通过出版社的网站访问。
其他在线资源
许多编程课程中缺少的一个元素是如何从没有程序到拥有一个可以工作的程序的过程展示。在我的课堂教学中,我花了很多时间向学生展示如何开始编写程序,然后如何通过添加和测试新功能的过程。我为每一章录制了视频,并在www.youtube.com/user/kyclark上分享了它们。每个章节都有一个播放列表,视频按照每个章节的模式进行,首先介绍问题和你可能用来编写程序的语言特性,然后讨论解决方案。
关于作者
我叫 Ken Youens-Clark。我在亚利桑那大学担任高级科学程序员。我的大部分职业生涯都在生物信息学领域工作,使用计算机科学的思想来研究生物数据。
我于 1990 年在北德克萨斯大学的爵士乐研究专业开始了我的本科学位,主修鼓组。我换了几次专业,最终在 1995 年获得了英语文学的学士学位。我并没有真正的职业规划,但我确实喜欢计算机。
大约在 1995 年,我在大学毕业后第一份工作中开始尝试玩弄数据库和 HTML,构建公司的邮件列表和第一个网站。我肯定是上瘾了!在那之后,我学会了在 Windows 3.1 上使用 Visual Basic,并在接下来的几年里,我使用了几种编程语言和公司,直到 2001 年加入冷泉港实验室的生物信息学小组,该小组由 Lincoln Stein 领导,他是 Perl 书籍和模块的著名作者,也是开放软件、数据和科学的早期倡导者。2014 年,我搬到了亚利桑那州的图森,在那里我在亚利桑那大学完成了我的生物系统工程硕士学位,并于 2019 年毕业。
当我不在编码时,我喜欢弹奏音乐、骑自行车、烹饪、阅读,以及与我的妻子和孩子们在一起。
关于封面
《Tiny Python Projects》封面上的图像标题为“Femme Turc allant par les rues”,或“Turkish woman going through the streets。”这幅插图取自 Jacques Grasset de Saint-Sauveur(1757-1810)的作品集,名为Costumes de Différents Pays,1788 年在法国出版。每一幅插图都是手工精心绘制和着色的。Grasset de Saint-Sauveur 丰富的收藏让我们生动地回忆起 200 年前世界各地的城镇和地区在文化上的差异。人们彼此隔离,说着不同的方言和语言。在街道或乡村,仅凭他们的服饰就能轻易识别他们居住的地方以及他们的职业或社会地位。
自那以后,我们的着装方式已经改变,当时的地区多样性已经逐渐消失。现在很难区分不同大陆、不同城镇、地区或国家的人们。也许我们用文化多样性换取了更加丰富多彩的个人生活——当然,还有更加多样化和快节奏的技术生活。
在难以区分一本计算机书籍与另一本的时候,曼宁通过书籍封面上的设计,庆祝了计算机行业的创新和进取精神。这些设计基于两百年前丰富多样的地区生活,通过 Grasset de Saint-Sauveur 的画作得以重现。
0 入门:简介和安装指南
这本书将教你如何编写在命令行上运行的 Python 程序。如果你以前从未使用过命令行,不要担心!你可以使用像 PyCharm(见图 0.1)或微软的 VS Code 这样的程序来帮助你编写和运行这些程序。如果你对编程或 Python 语言完全陌生,我会尽量涵盖我认为你需要知道的一切,尽管如果你从未听说过诸如变量和函数之类的东西,你可能觉得先读另一本书会有所帮助。
在这个简介中,我们将讨论
-
为什么你应该学习编写命令行程序
-
编写代码的工具和环境
-
我们如何以及为什么测试软件
编写命令行程序
为什么我想让你编写命令行程序?一方面,我认为它们将程序简化到最基本的形式。我们不会尝试编写像需要大量其他软件才能运行的交互式 3D 游戏这样的复杂程序。这本书中的程序都将使用最基本的数据输入,并仅创建文本输出。我们将专注于学习核心 Python 语言以及如何编写和测试程序。
专注于命令行程序的另一个原因是我想向你展示如何编写可以在任何安装了 Python 的计算机上运行的程序。我在这本书记录的是我的 Mac 笔记本电脑,但我可以在我的工作中使用的任何 Linux 机器或一个朋友的 Windows 机器上运行所有这些程序。任何具有相同 Python 版本的计算机都可以运行这些程序,这真的很酷。

图 0.1 这是使用 PyCharm 工具编辑和运行第一章的 hello.py 程序。“你好,世界!”
我最想向你展示如何编写命令行程序的最大原因,是因为我想向你展示如何测试程序以确保它们能正常工作。虽然我不认为如果我在我的程序中犯了一个错误,有人会死去,但我仍然非常、非常希望我的代码尽可能完美。
测试一个程序意味着什么?嗯,如果我的程序是用来将两个数字相加的,我需要运行它并使用许多数字对来检查它是否打印出正确的总和。我也可以给它一个数字和一个单词,以确保它不会尝试将“3”加上“海马”,而是抱怨我没有给它两个数字。测试给了我一些对我的代码的信心,我希望你能够看到测试如何帮助你更深入地理解编程。
这本书中的练习旨在足够有趣以激发你的兴趣,但每个练习都包含可以应用于各种现实世界问题的教训。我写的几乎每个程序都需要接受一些输入数据,无论是来自用户还是来自文件,并产生一些输出——有时是屏幕上的文本,也可能是新文件。通过编写这些程序,你将学会这些技能。
在每一章中,我将描述一些我想让你编写的程序以及你将使用的测试来检查你的程序是否正确工作。然后我会向你展示一个解决方案并讨论它是如何工作的。随着问题的难度增加,我会开始建议你可能编写自己的测试来探索和验证你的代码。
当你完成这本书后,你应该能够
-
编写和运行命令行 Python 程序
-
处理程序的参数
-
为你的程序和函数编写和运行测试
-
使用 Python 数据结构,如字符串、列表和字典
-
让你的程序读取和写入文本文件
-
使用正则表达式在文本中查找模式
-
使用和控制随机性,使你的程序行为不可预测
“代码是一个谜题。一种游戏,就像其他任何游戏一样。”
--艾伦·图灵
艾伦·图灵最著名的可能是破解纳粹在二战期间用来加密消息的恩尼格玛密码。盟军能够阅读敌方消息的事实被归功于缩短了战争数年并挽救了数百万人的生命。《模仿游戏》是一部有趣的电影,展示了图灵如何在报纸上发布谜题来寻找能帮助他破解被认为是不可破解的密码的人。
我认为我们可以从编写有趣的程序中学到很多东西,这些程序可以生成随机的侮辱或创作“圣诞十二天”的诗歌,或者玩井字棋。这本书中的一些程序甚至稍微涉猎了一点密码学,比如在第四章中,我们编码文本中的所有数字,或者在第十八章中,我们通过求和字母的数字表示来为单词创建签名。我希望你会发现这些程序既有趣又具有挑战性。
每个练习中的编程技术并不特定于 Python。几乎每种语言都有变量、循环、函数、字符串、列表和字典,以及参数化和测试程序的方法。在你用 Python 编写解决方案后,我鼓励你用你了解的其他语言编写解决方案,并比较不同语言中哪些部分使编写程序更容易或更难。如果你的程序支持相同的命令行选项,你甚至可以使用包含的测试来验证这些程序。
使用测试驱动开发
测试驱动开发由 Kent Beck 在他的 2002 年同名书中描述为创建更可靠程序的方法。基本思想是我们甚至在编写代码之前就编写测试。测试定义了我们的程序“正确工作”的含义。首先我们编写和运行测试以验证我们的代码失败。然后我们编写代码使每个测试通过。我们总是运行所有测试,这样,当我们修复新的测试时,我们确保我们没有破坏之前通过测试。当所有测试都通过时,我们至少可以保证我们编写的代码符合某种规范。
本书要求你编写的每个程序都附带测试,这些测试会告诉你代码何时可以接受地工作。每个练习的第一个测试检查预期的程序是否存在。第二个测试检查程序在请求帮助时是否会打印帮助信息。之后,你的程序将使用各种输入和选项运行。
由于我为本书的程序编写了大约 250 个测试,而你还没有编写过任何一个程序,你将遇到许多失败的测试。这是正常的!实际上,这是一个非常好的事情,因为当你通过所有测试时,你就知道你的程序是正确的。你将学会仔细阅读失败的测试,以找出需要修复的地方。然后你将修正程序并再次运行测试。你可能还会得到另一个失败的测试,在这种情况下,你需要重复这个过程,直到最终所有测试都通过。然后你就可以完成了。

无论你以何种方式解决我提供的解决方案中的问题,都没有关系。重要的是你要找到一种方法来通过测试。
设置你的环境
如果你想在你的计算机上编写这些程序,你需要 Python 3.6 或更高版本。它很可能已经安装在你的计算机上。
你还需要一种方式来执行python3命令——我们通常称之为命令行。如果你使用的是 Windows 计算机,你可能想安装 Windows Subsystem for Linux(WSL)。在 Mac 上,默认的终端应用就足够了。你也可以使用像 VS Code(如图 0.2 所示)或 PyCharm 这样的工具,这些工具内置了终端。

图 0.2 一个像 VS Code 这样的 IDE 将代码编辑器(用于编写代码)与终端(右下角窗口)结合起来,用于运行程序,以及许多其他工具。
我使用 Python 3.8 编写并测试了本书的程序,但它们应该与 3.6 或更新的版本兼容。Python 2 在 2019 年底达到了其生命的尽头,不应再使用。要查看你安装的 Python 版本,打开终端窗口并输入python3 --version。如果显示“找不到命令"python3"”,那么你需要安装 Python。你可以从 Python 网站下载最新版本(www.python.org/downloads)。
如果你使用的是没有 Python 的计算机,并且没有安装 Python 的方法,你可以通过 Repl.it 网站(repl.it)来完成本书中的所有内容。
代码示例
在整本书中,我将使用固定宽度的字体来展示命令和代码。当文本前带有美元符号($)时,这意味着你可以在命令行中输入的内容。例如,有一个名为cat(代表“连接”)的程序,它可以将文件的內容打印到屏幕上。以下是如何运行它来打印位于 inputs 目录中的 spiders.txt 文件的內容:
$ cat inputs/spiders.txt
Don't worry, spiders,
I keep house
casually.
如果你想要运行那个命令,不要复制前面的 $,只复制后面的文本。否则你可能会得到一个错误,比如“$: command not found。”
Python 有一个叫做 IDLE 的非常出色的工具,它允许你直接与语言交互来尝试新想法。你可以通过命令 idle3 启动它。这应该会打开一个新窗口,其中的提示符看起来像 >>>(见图 0.3)。

图 0.3 IDLE 应用程序允许你直接与 Python 语言交互。你按 Enter 键输入的每个语句都会被评估,结果会在窗口中显示。
你可以在那里键入 Python 语句,它们将被立即评估并打印出来。例如,键入 3 + 5 并按 Enter 键,你应该会看到 8:
>>> 3 + 5
8
这个界面被称为 REPL,因为它是一个读取-评估-打印-循环。(我把它读作“repple”,它有点像“pebble”押韵。)你可以在命令行中键入 python3 来获取一个类似的工具(见图 0.4)。

图 0.4 在终端中键入命令 python3 将会给你一个类似于 IDLE 界面的 REPL。
IPython 程序是另一个“交互式 Python”REPL,它在 IDLE 和 python3 上有很多增强功能。图 0.5 展示了在我的系统上的样子。
我还推荐你了解一下 Jupyter Notebooks,因为它们允许你交互式地运行代码,并且还有一个额外的优点,那就是你可以将笔记本保存为文件,并与其他人共享所有代码。

图 0.5 IPython 应用程序是另一个你可以用来尝试 Python 想法的 REPL 界面。
无论你使用哪个 REPL 界面,你都可以键入 Python 语句,例如 x = 10 并按 Enter 键,将值 10 赋给变量 x:
>>> x = 10
与命令行提示符 $ 一样,不要复制前面的 >>>,否则 Python 会报错:
>>> >>> x = 10
File "<stdin>", line 1
>>> x = 10
^
SyntaxError: invalid syntax
IPython REPL 有一个魔法 %paste 模式,它可以移除前面的 >>> 提示符,这样你就可以复制并粘贴所有代码示例:
In [1]: >>> x = 10
In [2]: x
Out[2]: 10
无论你选择哪种方式与 Python 交互,我建议你在本书中手动键入所有代码,因为这可以建立肌肉记忆并迫使你与语言的语法进行交互。
获取代码
所有测试和解决方案都可以在 github.com/kyclark/tiny_python_projects 找到。你可以使用 Git 程序(你可能需要安装)通过以下命令将代码复制到你的电脑上:
$ git clone https://github.com/kyclark/tiny_python_projects
现在,你应该在你的电脑上有一个名为 tiny_python_projects 的新目录。
您可能更喜欢将代码复制到自己的仓库中,这样您就可以跟踪您的更改并与他人分享您的解决方案。这被称为“Fork”,因为您正在从我的代码中分离出来,并将您自己的程序添加到仓库中。如果您计划使用 Repl.it 编写练习,我建议您将我的 repo Fork 到您自己的账户中,这样您就可以配置 Repl.it 与您自己的 GitHub 仓库进行交互。
要进行 Fork 操作,请执行以下步骤:
-
在 GitHub.com 上创建一个账户。
-
点击“Fork”按钮(见图 0.6)将仓库复制到您的账户中。

图 0.6 在我的 GitHub 仓库中的“Fork”按钮会将代码复制到您的账户中。
现在,您在自己的仓库中有了我的所有代码的副本。您可以使用 Git 将此代码复制到您的计算机上。请确保将“YOUR_GITHUB_ID”替换为您的实际 GitHub ID:
$ git clone https://github.com/YOUR_GITHUB_ID/tiny_python_projects
我可能会在您复制后更新仓库。如果您想获取这些更新,您需要配置 Git 将我的仓库设置为“上游”源。为此,在您将您的仓库克隆到您的计算机后,进入您的 tiny_python_projects 目录:
$ cd tiny_python_projects
然后执行以下命令:
$ git remote add upstream https://github.com/kyclark/tiny_python_projects.git
每当您想从我的仓库更新您的仓库时,您可以执行此命令:
$ git pull upstream master
安装模块
我建议使用一些可能未安装在本系统上的工具。您可以使用pip模块像这样安装它们:
$ python3 -m pip install black flake8 ipython mypy pylint pytest yapf
我还在仓库的顶级目录中包含了一个 requirements.txt 文件。您可以使用此命令安装所有模块和工具:
$ python3 -m pip install -r requirements.txt
例如,如果您想在 Repl.it 上编写练习,您需要运行此命令来设置您的环境,因为这些模块尚未安装。
代码格式化工具
大多数 IDE 和文本编辑器都会有工具帮助您格式化代码,使其更容易阅读和查找问题。此外,Python 社区已经为编写代码制定了一个标准,以便其他 Python 程序员可以轻松理解。位于www.python.org/dev/peps/pep-0008/的 PEP 8(Python 增强提案)文档描述了格式化代码的最佳实践,并且大多数编辑器会自动为您应用格式。例如,Repl.it 界面有一个自动格式化按钮(见图 0.7),VS Code 有一个“格式化文档”命令,PyCharm 有一个“重新格式化代码”命令。

图 0.7 Repl.it 工具有一个自动格式化按钮,可以根据社区标准重新格式化您的代码。界面还包括一个用于运行和测试程序的命令行。
此外,还有一些命令行工具可以与您的编辑器集成。我使用了 YAPF(Yet Another Python Formatter,github.com/google/yapf)来格式化本书中的每个程序,但另一个流行的格式化工具是 Black (github.com/psf/black)。无论您使用什么,我都鼓励您经常使用它。例如,我可以通过运行以下命令来告诉 YAPF 格式化我们在第一章将要编写的 hello.py 程序。请注意,-i 告诉 YAPF 在“原地”格式化代码,因此原始文件将被新格式化的代码覆盖。
$ yapf -i hello.py
代码检查器
代码检查器 是一种工具,它会报告您代码中的问题,例如声明了一个变量但从未使用它。我喜欢的两个是 Pylint (www.pylint.org/) 和 Flake8 (flake8.pycqa.org/en/latest/),它们都可以找到 Python 解释器本身不会抱怨的错误。
在最后一章中,我将向您展示如何将 类型提示 集成到您的代码中,Mypy 工具 (mypy-lang.org/) 可以使用这些类型提示来查找问题,例如在应该使用数字时使用了文本。
如何开始编写新程序
我认为使用标准模板开始编写代码要容易得多,所以我编写了一个名为 new.py 的程序,它将帮助您使用每个程序都期望的样板代码创建新的 Python 程序。它位于 bin 目录中,因此如果您在存储库的顶级目录中,您可以像这样运行它:
$ bin/new.py
usage: new.py [-h] [-s] [-n NAME] [-e EMAIL] [-p PURPOSE] [-f] program
new.py: error: the following arguments are required: program
在这里,您可以看到 new.py 正在要求您提供要创建的“程序”名称。对于每一章,您编写的程序需要位于包含该程序 test.py 文件的目录中。
例如,您可以使用 new.py 在 02_crowsnest 目录中启动第二章的 crowsnest.py 程序,如下所示:
$ bin/new.py 02_crowsnest/crowsnest.py
Done, see new script "02_crowsnest/crowsnest.py."
如果您现在打开该文件,您会看到它为您编写了大量的代码,我稍后会解释。现在,只需意识到生成的 crowsnest.py 程序可以像这样运行:
$ 02_crowsnest/crowsnest.py
usage: crowsnest.py [-h] [-a str] [-i int] [-f FILE] [-o] str
crowsnest.py: error: the following arguments are required: str
之后,您将学习如何修改程序以实现测试所期望的功能。
运行 new.py 的另一种方法是复制 template 目录中的 template.py 文件到您需要编写程序和目录名。您可以创建 crowsnest.py 程序文件如下所示:
$ cp template/template.py 02_crowsnest/crowsnest.py
您不必使用 new.py 或复制 template.py 文件来启动您的程序。这些文件提供给您节省时间并提供程序初始结构,但您当然可以按照您喜欢的任何方式编写程序。
为什么不使用 Notebooks?
许多人熟悉 Jupyter Notebooks,因为它们提供了一种将 Python 代码、文本和图像集成到文档中的方法,其他人可以像执行程序一样执行这些文档。我真的很喜欢 Notebooks,尤其是在交互式探索数据时,但我发现它们在教学上使用起来有些困难,以下是一些原因:
-
笔记本存储在 JavaScript 对象表示法(JSON)中,而不是按行排列的文本。这使得比较笔记本之间的差异变得非常困难。
-
代码、文本和图像可以混合存储在单独的单元中。这些单元可以以任何顺序交互式运行,这可能导致程序逻辑中非常微妙的问题。我们在这本书中编写的程序将始终在每次运行时从头到尾完整运行,我认为这使它们更容易理解。
-
笔记本在运行时无法接受不同的值。也就是说,如果您用一个输入文件测试程序,然后想切换到另一个文件,您必须更改程序本身。您将学习如何将文件作为参数传递给程序,这样您就可以在不更改代码的情况下更改值。
-
自动在笔记本或其包含的函数上运行测试是困难的。我们将使用
pytest模块反复运行我们的程序,并使用不同的输入值来验证程序是否生成正确的输出。
我们将涵盖的主题范围
这本书的目的是向您展示 Python 语言的所有内置功能是多么的惊人有用。练习将推动您练习操作字符串、列表、字典和文件。我们将用几章内容专注于正则表达式,除了最后一章的练习外,每个练习都需要您接受和验证不同类型和数量的命令行参数。
每位作者都会对某些主题有所偏见,我也不例外。我选择这些主题是因为它们反映了我过去 20 年工作中的一些基本理念。例如,我花了比我想承认的更多的时间来解析来自无数电子表格和 XML 文件的真正混乱的数据。占据了我大部分职业生涯的基因组学世界主要基于高效地解析文本文件,而我大部分的网页开发工作都是基于理解文本是如何编码以及如何从网页浏览器中传输的。因此,您会发现许多涉及处理文本和文件的练习,这些练习将挑战您思考如何将输入转换为输出。如果您完成每一个练习,我相信您将成为一个改进了很多的程序员,能够理解许多语言中普遍存在的基本思想。
为什么不使用面向对象编程?
您会注意到这本书中缺少的一个主题是使用 Python 编写面向对象的代码。如果您不熟悉面向对象编程(OOP),您可以跳过这一部分。
我认为面向对象编程是一个相对高级的话题,超出了这本书的范围。我更倾向于关注如何编写小的函数及其伴随的测试。我认为这会导致更透明的代码,因为函数应该是短的,只应使用显式传递的参数值,并且应该有足够的测试,这样你就可以完全理解它们在有利和不利的条件下会如何表现。
Python 语言本身是固有的面向对象。从字符串到我们将会使用的列表和字典等几乎所有事物实际上都是 对象,所以你将有很多使用对象的机会。但我不认为解决我提出的任何问题都需要创建对象。实际上,尽管我多年来一直在编写面向对象的代码,但我在过去几年里并没有以这种风格编写。我倾向于从纯函数式编程的世界中汲取灵感,并希望我在这本书的结尾能说服你,通过组合函数,你可以做任何你想做的事情。
虽然我本人避免使用面向对象编程(OOP),但我仍然建议你了解它。编程世界中已经发生了几次重大的范式转变,从过程式到面向对象,再到现在的函数式。你可以找到关于面向对象编程的几十本书,特别是关于在 Python 中编程对象的书籍。这是一个深奥且引人入胜的话题,我鼓励你尝试编写面向对象的解决方案,并将它们与我提供的解决方案进行比较。
关于术语的说明
在编程书籍中,你经常会看到在示例中使用 foobar。这个词本身没有实际意义,但它的起源可能来自军事缩写词“FUBAR”(Fouled Up Beyond All Recognition)。如果我在示例中使用“foobar”,那是因为我不想谈论宇宙中的任何特定事物,只是想表达一串字符的概念。如果我需要一个项目列表,通常第一个项目会是“foo”,接下来是“bar”。之后,按照惯例使用“baz”和“quux”,因为它们根本没有任何意义。不要纠结于“foobar”。它只是一个占位符,可能在未来会变得更有趣。
| 程序员还倾向于将代码中的错误称为 bugs。这来自计算机发明晶体管之前的计算时代。早期的机器使用真空管,机器产生的热量会吸引实际的虫子,如蛾子,这可能导致短路。操作员(运行机器的人)必须检查机器以找到并移除虫子;因此,产生了“to debug”这个术语。 | ![]() |
|---|
1 如何编写和测试 Python 程序
| 在你开始做练习之前,我想讨论如何编写有文档和测试的程序。具体来说,我们将
-
编写一个 Python 程序来输出“Hello, World!”
-
使用
argparse处理命令行参数 -
使用 Pytest 运行代码测试。
-
了解
$PATH -
使用像 YAPF 和 Black 这样的工具来格式化代码
-
使用像 Flake8 和 Pylint 这样的工具来查找代码中的问题
-
使用 new.py 程序来创建新程序
![]() |
|---|
1.1 创建你的第一个程序
在任何语言中,将“Hello, World!”作为你的第一个程序是很常见的,所以让我们从这里开始。我们将努力制作一个版本,它可以问候作为参数传递的任何名字。当需要时,它还会打印一条有用的消息,我们将使用测试来确保它正确地完成所有操作。
在 01_hello 目录中,你会看到我们将要编写的 hello 程序的几个版本。还有一个名为 test.py 的程序,我们将用它来测试程序。
首先,在那个目录中创建一个名为 hello.py 的文本文件。如果你在 VS Code 或 PyCharm 中工作,你可以使用文件 > 打开来将 01_hello 目录作为项目打开。这两个工具都有一个类似文件 > 新建菜单选项,允许你在该目录中创建新文件。在 01_hello 目录内创建 hello.py 文件非常重要,这样 test.py 程序才能找到它。
一旦你开始了一个新文件,添加以下行:
print('Hello, World!')
是时候运行你的新程序了!在 VS Code 或 PyCharm 中打开一个终端窗口,或者在任何其他终端中,导航到你的 hello.py 程序所在的目录。你可以使用命令 python3 hello.py 来运行它——这将导致 Python 3 版本执行名为 hello.py 的文件中的命令。你应该看到以下内容:
$ python3 hello.py
Hello, World!
图 1.1 显示了它在 Repl.it 界面中的样子。

图 1.1 使用 Repl.it 编写和运行我们的第一个程序
如果这是你的第一个 Python 程序,恭喜你!
1.2 注释行
在 Python 中,# 字符及其后面的任何内容都会被 Python 忽略。这有助于在代码中添加注释或在测试和调试时临时禁用代码行。始终记录你的程序是个好主意,表明程序的目的或作者的姓名和电子邮件地址,或者两者都要。我们可以用注释来做这件事: |
![]() |
|---|
# Purpose: Say hello
print('Hello, World!')
如果你再次运行此程序,你应该看到与之前相同的输出,因为“目的”行被忽略了。请注意,任何位于 # 左侧的文本都会被执行,所以如果你喜欢,可以在行尾添加注释。
1.3 测试你的程序
我最想教给你的基本思想是如何测试你的程序。我在 01_hello 目录中编写了一个 test.py 程序,我们可以用它来测试我们新的 hello.py 程序。
我们将使用 pytest 来执行所有命令并告诉我们我们通过了多少个测试。我们将包括 -v 选项,它告诉 pytest 创建“详细”输出。如果你这样运行它,你应该看到以下输出作为前几行。之后将跟随更多行,显示有关未通过测试的更多信息。
注意:如果你得到“pytest: 命令未找到”的错误,你需要安装 pytest 模块。请参阅本书引言中的“安装模块”部分。
$ pytest -v test.py
============================= test session starts ==============================
...
collected 5 items
test.py::test_exists PASSED [ 20%] ①
test.py::test_runnable PASSED [ 40%] ②
test.py::test_executable FAILED [ 60%] ③
test.py::test_usage FAILED [ 80%] ④
test.py::test_input FAILED [100%] ⑤
=================================== FAILURES ===================================
① 第一次测试始终检查预期的文件是否存在。这里测试正在寻找 hello.py。
② 第二次测试尝试使用 python3 hello.py 运行程序,然后检查程序是否打印了 “Hello, World!” 如果你遗漏了任何一个字符,比如忘记了一个逗号,测试将指出错误,所以请仔细阅读!
③ 第三次测试检查程序是否“可执行”。这个测试失败了,所以接下来我们将讨论如何让它通过。
④ 第四次测试请求程序帮助,但没有得到任何回应。我们将添加打印 “usage” 语句的能力,该语句描述了如何使用我们的程序。
⑤ 最后一次测试检查程序能否问候我们作为参数传递的名字。由于我们的程序尚未接受参数,我们还需要添加这个功能。
我已经按照我希望帮助你以逻辑顺序编写程序的顺序编写了测试。如果程序未通过某个测试,就没有理由在它之后继续运行测试。我建议你始终使用 -x 标志运行测试,以在第一个失败的测试时停止,并使用 -v 标志以打印详细输出。你可以将这些标志组合起来,例如 -xv 或 -vx。以下是使用这些选项时我们的测试看起来像什么:
$ pytest -xv test.py
============================= test session starts ==============================
...
collected 5 items
test.py::test_exists PASSED [ 20%]
test.py::test_runnable PASSED [ 40%]
test.py::test_executable FAILED [ 60%] ①
=================================== FAILURES ===================================
_______________________________ test_executable ________________________________
def test_executable():
"""Says 'Hello, World!' by default"""
out = getoutput({prg})
> assert out.strip() == 'Hello, World!' ②
E AssertionError: assert '/bin/sh: ./h...ission denied' == 'Hello, World!' ③
E - /bin/sh: ./hello.py: Permission denied ④
E + Hello, World! ⑤
test.py:30: AssertionError
!!!!!!!!!!!!!!!!!!!!!!!!!! stopping after 1 failures !!!!!!!!!!!!!!!!!!!!!!!!!!!
========================= 1 failed, 2 passed in 0.09s ==========================
① 这个测试失败了。因为我们使用了 -x 选项运行 pytest,所以不再运行更多测试。
② 这行开头的尖括号 (>) 表示后续错误的来源。
③ 这行开头的 “E” 表示这是一个“错误”你应该阅读。AssertionError 表示 test.py 程序正在尝试执行 ./hello.py 命令以查看它是否会输出文本 “Hello, World!”
④ 横杠字符 (-) 表示实际命令的输出是“权限被拒绝。”
⑤ 加号 (+) 表示测试期望得到 “Hello, World!”
让我们谈谈如何修复这个错误。
1.4 添加 #! (shebang) 行
你到目前为止学到的一件事是,Python 程序生活在纯文本文件中,你要求 python3 来执行。许多其他编程语言,如 Ruby 和 Perl,以相同的方式工作--我们把这些语言的命令输入到文本文件中,并用正确的语言运行它。在这些程序中放置一个特殊的注释行来指示需要使用哪种语言来执行文件中的命令是很常见的。
这条注释行以 #! 开头,这个昵称叫“shebang”(发音为“shuh-bang”——我总是把 # 想象成“shuh”,把 ! 想象成“bang!”)。就像任何其他注释一样,Python 会忽略 shebang,但操作系统(如 macOS 或 Windows)会使用它来决定使用哪个程序来运行文件的其余部分。
这里是你应该添加的 shebang:
#!/usr/bin/env python3
env 程序会告诉你关于你的“环境”的信息。当我在我电脑上运行 env 时,我看到许多输出行,如 USER=kyclark 和 HOME=/Users/kyclark。这些值可以作为变量 $USER 和 $HOME 访问:
$ echo $USER
kyclark
$ echo $HOME
/Users/kyclark
如果你在你电脑上运行 env,你应该看到你的登录名和你的主目录。当然,它们的值会与我的不同,但我们(可能)都有这两个概念。
你可以使用 env 命令来查找并运行程序。如果你运行 env python3,如果它能找到,就会运行一个 python3 程序。以下是我电脑上的显示结果:
$ env python3
Python 3.8.1 (v3.8.1:1b293b6006, Dec 18 2019, 14:08:53)
[Clang 6.0 (clang-600.0.57)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>>
env 程序正在环境中寻找 python3。如果 Python 没有安装,它将无法找到它,但 Python 也可能被安装了多次。你可以使用 which 命令来查看它找到了哪个 python3:
$ which python3
/Library/Frameworks/Python.framework/Versions/3.8/bin/python3
如果我在 Repl.it 上运行这个命令,我可以看到 python3 存在于不同的位置。它在你的电脑上存在在哪里?
$ which python3
/home/runner/.local/share/virtualenvs/python3/bin/python3
正如我的 $USER 名称与你的不同一样,我的 python3 可能也与你的不同。如果 env 命令能够找到 python3,它就会执行它。如前所述,如果你单独运行 python3,它将打开一个 REPL。
如果我把我的 python3 路径放在 shebang 行,就像这样,
#!/Library/Frameworks/Python.framework/Versions/3.8/bin/python3
如果我的程序在另一个安装了 python3 但位置不同的电脑上运行,它可能不会工作。我怀疑它在你电脑上也不会工作。这就是为什么你应该始终使用 env 程序来查找特定于运行它的机器的 python3。
现在你的程序应该看起来是这样的:
#!/usr/bin/env python3 ①
# Purpose: Say hello ②
print('Hello, World!') ③
① Shebang 行指示操作系统使用 /usr/bin/env 来查找 python3 以解释这个程序。
② 一条注释行,记录程序的用途
③ 一个 Python 命令,用于将一些文本打印到屏幕上
1.5 使程序可执行
到目前为止,我们一直是明确地告诉 python3 运行我们的程序,但自从我们添加了 shebang 之后,我们可以直接执行程序,让操作系统自行判断应该使用 python3。这种做法的优势在于,我们可以将我们的程序复制到其他程序所在的目录中,并在电脑的任何位置执行它。完成这一步的第一步是使用 chmod 命令(改变模式)使我们的程序“可执行”。把它想象成把你的程序“打开”。运行以下命令使 hello.py 可执行: |
![]() |
|---|
$ chmod +x hello.py ①
① +x 将向文件添加一个“可执行”属性。
现在你可以这样运行程序:
$ ./hello.py ①
Hello, World!
① ./ 是当前目录,当你与程序在同一个目录时运行程序是必要的。
1.6 理解 $PATH
设置 shebang 行并使你的程序可执行的最大原因之一是,你可以像其他命令和程序一样安装你的 Python 程序。我们之前使用了 which 命令来找到 Repl.it 实例中 python3 的位置:
$ which python3
/home/runner/.local/share/virtualenvs/python3/bin/python3
env 程序是如何找到它的?Windows、macOS 和 Linux 都有一个 $PATH 变量,它是操作系统查找程序的目录列表。例如,这是我 Repl.it 实例的 $PATH:
> echo $PATH
/home/runner/.local/share/virtualenvs/python3/bin:/usr/local/bin:\
/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
目录之间由冒号 (:) 分隔。注意,python3 所在的目录是 $PATH 中的第一个。这是一个相当长的字符串,所以我用反斜杠 (\) 字符将其断开,以便更容易阅读。如果你将你的 hello.py 程序复制到 $PATH 中列出的任何目录,你就可以执行像 hello.py 这样的程序,而不需要前面的 ./,也不需要在与程序相同的目录中。
这样想一下 $PATH:如果你在家里找不到钥匙,你会从左上角的厨房橱柜开始找,然后逐个检查每个橱柜,接着是存放餐具和厨房小工具的抽屉,然后转到浴室和卧室的衣柜?还是你会先从你通常放钥匙的地方开始找,比如前门旁边的钥匙挂钩,然后继续检查你最喜欢的夹克口袋和钱包或背包,然后可能看看沙发垫下,等等?
$PATH 变量是告诉计算机只在可执行程序可能存在的位置查找的一种方式。唯一的替代方案是让操作系统搜索 每一个目录,这可能会花费几分钟甚至几个小时!你可以控制 $PATH 变量中目录的名称及其相对顺序,以便操作系统可以找到你需要的程序。
程序通常会被安装到 /usr/local/bin,所以我们可以尝试使用 cp 命令将我们的程序复制到那里。不幸的是,我在 Repl.it 上没有权限这样做:
> cp 01_hello/hello.py /usr/local/bin
cp: cannot create regular file '/usr/local/bin/hello.py': Permission denied
但是我可以在自己的笔记本电脑上这样做:
$ cp hello.py /usr/local/bin/
我可以验证程序已被找到:
$ which hello.py
/usr/local/bin/hello.py
现在我可以从计算机上的任何目录执行它:
$ hello.py
Hello, World!
1.6.1 修改你的 $PATH
通常,你可能会发现自己正在一台不允许你将程序安装到 $PATH 的计算机上工作,比如在 Repl.it 上。一个替代方案是修改你的 $PATH,使其包括一个你可以放置程序的目录。例如,我经常在我的主目录中创建一个 bin 目录,这通常可以用波浪号 (~) 来表示。
在大多数计算机上,~/bin 意味着“我的主目录中的 bin 目录。”也常见到 $HOME/bin,其中 $HOME 是你主目录的名称。以下是我如何在 Repl.it 机器上创建这个目录,将程序复制到其中,并将其添加到我的 $PATH 的方法:
$ mkdir ~/bin ①
$ cp 01_hello/hello.py ~/bin ②
$ PATH=~/bin:$PATH ③
$ which hello.py ④
/home/runner/bin/hello.py
① 使用 mkdir(“创建目录”)命令创建 ~/bin。
② 使用 cp 命令将 01_hello/hello.py 程序复制到 ~/bin 目录。
③ 将 ~/bin 目录放在 $PATH 的第一位。
④ 使用 which 命令查找 hello.py 程序。如果前面的步骤成功了,操作系统现在应该能够在 $PATH 中列出的目录之一中找到程序。
现在,我可以在任何目录下,
$ pwd
/home/runner/tinypythonprojects
然后,我可以运行它:
$ hello.py
Hello, World!
尽管 shebang 和可执行内容可能看起来像很多工作,但回报是你可以创建一个 Python 程序,可以安装到你的电脑或任何其他人的电脑上,就像其他任何程序一样运行。
1.7 添加参数和帮助
在整本书中,我将使用弦图来可视化我们将要编写的程序的输入和输出。如果我们现在为我们的程序创建一个(如图 1.2 所示),那么将没有输入,输出将始终是“Hello, World!”

图 1.2 表示我们的 hello.py 程序的弦图,该程序不接受任何输入并总是产生相同的输出
对于我们的程序总是说“Hello, World!”来说,这并不特别有趣。如果它能对其他事物说“Hello”,比如整个宇宙,那就更好了。我们可以通过以下方式更改代码来实现这一点:
print('Hello, Universe')
但这意味着我们每次想要让它问候不同的名字时,都必须更改代码。更好的做法是在不总是需要更改程序本身的情况下更改程序的行为。
我们可以通过找到我们想要更改的程序的部分(比如要问候的名字)并将该值作为参数提供给我们的程序来实现这一点。也就是说,我们希望我们的程序像这样工作:
$ ./hello.py Terra
Hello, Terra!
使用我们程序的人将如何知道如何这样做?这是我们的程序的责任,提供帮助信息! 大多数命令行程序会对 -h 和 --help 这样的参数做出响应,显示有关如何使用程序的有用信息。我们需要我们的程序打印出类似以下内容:
$ ./hello.py -h
usage: hello.py [-h] name
Say hello
:
name Name to greet ①
:
-h, --help show this help message and exit
① 注意,name 被称为位置参数。
要做到这一点,我们可以使用 argparse 模块。模块是我们可以将它们带入我们程序的代码文件。我们还可以创建模块以与他人共享我们的代码。Python 中有数百到数千个模块可供使用,这也是为什么使用这种语言如此令人兴奋的原因之一。
argparse 模块将“解析”程序的“参数”。要使用它,按照以下方式更改你的程序。我建议你自己输入所有内容,不要复制粘贴。
#!/usr/bin/env python3 ①
# Purpose: Say hello ②
import argparse ③
parser = argparse.ArgumentParser(description='Say hello') ④
parser.add_argument('name', help='Name to greet') ⑤
args = parser.parse_args() ⑥
print('Hello, ' + args.name + '!') ⑦
① 命令行告诉操作系统使用哪个程序来执行此程序。
② 这条注释记录了程序的目的。
③ 我们必须导入 argparse 模块才能使用它。
④ 解析器将确定所有参数。描述将显示在帮助信息中。
⑤ 我们需要告诉解析器预期一个将成为我们问候对象的名称。
⑥ 我们要求解析器解析程序的任何参数。
⑦ 我们使用 args.name 的值打印问候语。
图 1.3 显示了我们现在程序的字符串图。
现在你尝试像以前一样运行程序时,会触发一个错误和一个“用法”语句(注意“用法”是输出的第一个单词):
$ ./hello.py ①
usage: hello.py [-h] name ②
hello.py: error: the following arguments are required: name ③
① 我们不带参数运行程序,但现在程序期望一个单独的参数(一个“名称”)。
② 由于程序没有获取到预期的参数,它停止并打印一个“用法”信息,让用户知道如何正确调用程序。
③ 错误信息告诉用户他们没有提供名为“名称”的必需参数。

图 1.3 现在我们的字符串图显示程序可以接受一个参数并根据该值生成消息。
我们已经更改了程序,使其需要名称才能运行。这很酷!让我们给它一个问候的名称:
$ ./hello.py Universe
Hello, Universe!
尝试使用-h和--help参数运行你的程序,并验证你是否看到了帮助信息。
程序现在工作得很好,并且有很好的文档,这都是因为我们添加了那些使用argparse的几行代码。这是一个很大的改进。
1.8 使参数可选
假设我们想像以前一样不带参数运行程序,并让它打印“Hello, World!”我们可以通过将参数名称更改为--name来使name参数可选:
#!/usr/bin/env python3
# Purpose: Say hello
import argparse
parser = argparse.ArgumentParser(description='Say hello')
parser.add_argument('-n', '--name', metavar='name', ①
default='World', help='Name to greet')
args = parser.parse_args()
print('Hello, ' + args.name + '!')
① 对这个程序唯一的更改是添加了-n 和--name 作为“短”和“长”选项名称。我们还指明了一个默认值。“metavar”将在用法中显示,以描述参数。
现在我们可以像以前一样运行它:
$ ./hello.py
Hello, World!
或者我们可以使用--name选项:
$ ./hello.py --name Terra
Hello, Terra!
并且我们的帮助信息已经更改:
$ ./hello.py -h
usage: hello.py [-h] [-n NAME]
Say hello
optional arguments:
-h, --help show this help message and exit
-n name, --name name Name to greet ①
① 现在参数是可选的,不再是位置参数。提供短名和长名以方便输入选项是很常见的。这里出现“name”的 metavar 值来描述值应该是什么。
图 1.4 显示了一个描述我们程序的字符串图。

图 1.4 现在name参数是可选的。程序将问候一个给定的名称,或者当它缺失时将使用默认值。
现在程序非常灵活,在没有参数运行时问候默认值,或者允许我们向其他东西说“嗨”。记住,以连字符开头的参数是可选的,因此可以省略,并且可能有默认值。不以连字符开头的参数是位置的,通常需要,因此没有默认值。
表 1.1 两种命令行参数
| 类型 | 示例 | 必需 | 默认 |
|---|---|---|---|
| 位置 | name |
是 | 否 |
| 可选 | -n(短),--name(长) |
否 | 是 |
1.9 运行我们的测试
让我们再次运行我们的测试,看看我们做得怎么样:
$ make test
pytest -xv test.py
============================= test session starts ==============================
...
collected 5 items
test.py::test_exists PASSED [ 20%]
test.py::test_runnable PASSED [ 40%]
test.py::test_executable PASSED [ 60%]
test.py::test_usage PASSED [ 80%]
test.py::test_input PASSED [100%]
============================== 5 passed in 0.38s ===============================
哇,我们通过了所有的测试!我每次看到我的程序通过所有测试都会感到兴奋,即使是我自己写的测试。在我们之前,使用和输入测试都失败了。添加 argparse 代码修复了这两个问题,因为它允许我们的程序在运行时接受参数,并且它还会创建有关如何运行我们的程序的文档。
1.10 添加 main() 函数
我们的程序现在工作得很好,但它还没有达到社区的标准和期望。例如,对于计算机程序来说,非常常见的是从名为 main() 的地方开始——不仅限于用 Python 编写的程序。大多数 Python 程序定义了一个名为 main() 的函数,并且在代码的末尾调用 main() 函数是一个惯例,如下所示:
#!/usr/bin/env python3
# Purpose: Say hello
import argparse
def main(): ①
parser = argparse.ArgumentParser(description='Say hello')
parser.add_argument('-n', '--name', metavar='name',
default='World', help='Name to greet')
args = parser.parse_args()
print('Hello, ' + args.name + '!')
if __name__ == '__main__': ②
main() ③
① def 定义了一个函数,在这个例子中命名为 main()。空括号表示这个函数不接受任何参数。
② 每个 Python 程序或模块都有一个名称,可以通过变量 name 访问。当程序正在执行时,name 被设置为 “main”。1
③ 如果这是真的,调用 main() 函数。
随着我们的程序变得越来越长,我们将开始创建更多的函数。Python 程序员以不同的方式处理这个问题,但在这本书中,我将始终创建和执行一个 main() 函数以保持一致性。一开始,我们总是将程序的主体部分放在 main() 函数中。
1.11 添加 get_args() 函数
从个人喜好来说,我喜欢将所有的 argparse 代码放在一个单独的地方,我总是称之为 get_args()。获取和验证参数在我的脑海中是一个概念,所以它应该单独存在。对于某些程序,这个函数可能会变得相当长。
我总是将 get_args() 放在第一个函数中,这样我可以在阅读源代码时立即看到它。我通常将 main() 放在它之后。当然,你可以按照你喜欢的任何方式来组织你的程序。
现在程序看起来是这样的:
#!/usr/bin/env python3
# Purpose: Say hello
import argparse
def get_args(): ①
parser = argparse.ArgumentParser(description='Say hello')
parser.add_argument('-n', '--name', metavar='name',
default='World', help='Name to greet')
return parser.parse_args() ②
def main(): ③
args = get_args() ④
print('Hello, ' + args.name + '!')
if __name__ == '__main__':
main()
① get_args() 函数专门用于获取参数。现在所有的 argparse 代码都放在这里。
② 我们需要调用 return 来将解析参数的结果发送回 main() 函数。
③ main() 函数现在要短得多。
④ 调用 get_args() 函数来获取解析后的参数。如果参数有问题或用户请求 --help,程序将不会到达这一点,因为 argparse 将导致它退出。如果我们的程序真的做到了这一点,那么...
程序的工作方式没有任何改变。我们只是组织代码,将想法分组在一起——处理 argparse 的代码现在位于 get_args() 函数中,其余的代码位于 main() 中。为了确保一切正常,去运行测试套件吧!
1.11.1 检查样式和错误
![]() |
我们的项目现在运行得非常好。我们可以使用像 Flake8 和 Pylint 这样的工具来检查我们的程序是否存在问题。这些工具被称为linters,它们的工作是建议改进程序的方法。如果您还没有安装它们,现在可以使用pip模块来安装: |
|---|
$ python3 -m pip install flake8 pylint
Flake8 程序要求我在每个函数def定义之间放置两个空白行:
$ flake8 hello.py
hello.py:6:1: E302 expected 2 blank lines, found 1
hello.py:12:1: E302 expected 2 blank lines, found 1
hello.py:16:1: E305 expected 2 blank lines after class or function definition, found 1
Pylint 表示函数缺少文档(“docstrings”):
$ pylint hello.py
************* Module hello
hello.py:1:0: C0114: Missing module docstring (missing-module-docstring)
hello.py:6:0: C0116: Missing function or method docstring (missing-function-docstring)
hello.py:12:0: C0116: Missing function or method docstring (missing-function-docstring)
---------------------------------------------------------------------
Your code has been rated at 7.00/10 (previous run: -10.00/10, +17.00)
docstring是一个出现在函数def之后的字符串。对于函数,通常会有几行文档,因此程序员经常使用 Python 的三重引号(单引号或双引号)来创建多行字符串。以下是我添加文档字符串后的程序的样子。我还使用了 YAPF 来格式化程序并修复间距问题,但您也可以使用 Black 或其他您喜欢的工具。
#!/usr/bin/env python3
""" ①
Author: Ken Youens-Clark <kyclark@gmail.com>
Purpose: Say hello
"""
import argparse
# -------------------------------------------- ②
def get_args():
"""Get the command-line arguments""" ③
parser = argparse.ArgumentParser(description='Say hello')
parser.add_argument('-n', '--name', default='World', help='Name to greet')
return parser.parse_args()
# --------------------------------------------------
def main():
"""Make a jazz noise here""" ④
args = get_args()
print('Hello, ' + args.name + '!')
# --------------------------------------------------
if __name__ == '__main__':
main()
① 整个程序的三重引号,多行文档字符串。在 shebang 之后写一个长的文档字符串来记录函数的整体目的是常见的做法。我喜欢包括至少我的名字、电子邮件地址和脚本的用途,这样任何未来使用这个程序的人都会知道是谁编写的,如果他们有问题如何与我联系,以及程序应该做什么。
② 一个大的水平“行”注释,帮助我找到函数。如果您不喜欢这些,可以省略。
③ get_args()函数的文档字符串。我喜欢即使是单行注释也使用三重引号,因为它们帮助我更好地看到文档字符串。
④ main()函数只是程序开始的地方,所以在文档字符串中没有什么好说的。我认为总是放“在这里制造爵士乐声音”是(至少有一点)有趣的,但您可以放任何您喜欢的东西。
要了解如何在命令行上使用 YAPF 或 Black,运行它们时使用-h或--help标志并阅读文档。如果您使用的是 VS Code 或 PyCharm 这样的 IDE,或者如果您使用的是 Repl.it 界面,都有命令可以重新格式化您的代码。
1.12 测试 hello.py
我们对我们的程序做了很多修改--我们确定它仍然可以正确运行吗?让我们再次运行我们的测试。
这是你将实际做数百次的事情,所以我创建了一个你可能喜欢的快捷方式。在每一个目录中,您都会找到一个名为 Makefile 的文件,看起来像这样:
$ cat Makefile
.PHONY: test
test:
pytest -xv test.py
如果您在计算机上安装了make程序,您可以在 01_hello 目录中运行make test。make程序将在您当前的工作目录中查找 Makefile,然后查找名为test的配方。在那里,它会找到运行test目标的命令是pytest -xv test.py,因此它会为您运行该命令。
$ make test
pytest -xv test.py
============================= test session starts ==============================
...
collected 5 items
test.py::test_exists PASSED [ 20%]
test.py::test_runnable PASSED [ 40%]
test.py::test_executable PASSED [ 60%]
test.py::test_usage PASSED [ 80%]
test.py::test_input PASSED [100%]
============================== 5 passed in 0.75s ===============================
如果您没有安装 make,您可能想安装它并了解 Makefiles 如何用于执行复杂的命令集。如果您不想安装或使用 make,您始终可以自己运行 pytest -xv test.py。它们都完成相同的任务。
重要的点是,我们能够使用我们的测试来验证我们的程序仍然完全按照预期执行。当您编写程序时,您可能想要尝试不同的解决方案。测试让您有自由重写程序(也称为“重构您的代码”)并知道它仍然有效。
1.13 使用 new.py 开始新程序
argparse 模块是一个标准模块,总是与 Python 一起安装。它被广泛使用,因为它可以为我们节省大量解析和验证程序参数的时间。您将在本书的每个程序中使用 argparse,您将学习如何使用它将文本转换为数字、验证和打开文件以及更多。有如此多的选项,我创建了一个名为 new.py 的 Python 程序,它将帮助您开始编写使用 argparse 的新 Python 程序。 |
![]() |
|---|
我已经将这个新的 new.py 程序放入了 GitHub 仓库的 bin 目录中。我建议您在编写每个新程序时都使用它。例如,您可以使用 new.py 创建 hello.py 的新版本。前往您的仓库顶层并运行以下命令:
$ bin/new.py 01_hello/hello.py
"01_hello/hello.py" exists. Overwrite? [yN] n
Will not overwrite. Bye!
new.py 程序不会覆盖现有文件,除非您告诉它这样做,因此您可以使用它而不用担心可能会擦除您的工作。尝试使用它创建一个具有不同名称的程序:
$ bin/new.py 01_hello/hello2.py
Done, see new script "01_hello/hello2.py."
现在尝试执行该程序:
$ 01_hello/hello2.py
usage: hello2.py [-h] [-a str] [-i int] [-f FILE] [-o] str
hello2.py: error: the following arguments are required: str
让我们看看新程序的源代码:
#!/usr/bin/env python3 ①
""" ②
Author : Ken Youens-Clark <kyclark@gmail.com>
Date : 2020-02-28
Purpose: Rock the Casbah
"""
import argparse ③
import os
import sys
# --------------------------------------------------
def get_args(): ④
"""Get command-line arguments"""
parser = argparse.ArgumentParser(
description='Rock the Casbah',
formatter_class=argparse.ArgumentDefaultsHelpFormatter)
parser.add_argument('positional', ⑤
metavar='str',
help='A positional argument')
parser.add_argument('-a', ⑥
'--arg',
help='A named string argument',
metavar='str',
type=str,
default='')
parser.add_argument('-i', ⑦
'--int',
help='A named integer argument',
metavar='int',
type=int,
default=0)
parser.add_argument('-f', ⑧
'--file',
help='A readable file',
metavar='FILE',
type=argparse.FileType('r'),
default=None)
parser.add_argument('-o', ⑨
'--on',
help='A boolean flag',
action='store_true')
return parser.parse_args() ⑩
# --------------------------------------------------
def main(): ⑪
"""Make a jazz noise here"""
args = get_args() ⑫
str_arg = args.arg ⑬
int_arg = args.int
file_arg = args.file
flag_arg = args.on
pos_arg = args.positional
print(f'str_arg = "{str_arg}"')
print(f'int_arg = "{int_arg}"')
print('file_arg = "{}"'.format(file_arg.name if file_arg else ''))
print(f'flag_arg = "{flag_arg}"')
print(f'positional = "{pos_arg}"')
# --------------------------------------------------
if __name__ == '__main__': ⑭
main() ⑮
① 应该使用 env 程序来查找 python3 程序的 shebang 行。
② 这个文档字符串是针对整个程序的。
③ 这些行导入了程序需要的各种模块。
④ get_args() 函数负责解析和验证参数。
⑤ 定义一个“位置”参数,就像我们 hello.py 的第一个版本中具有名称参数的那样。
⑥ 定义一个“可选”参数,就像我们改为使用 --name 选项时一样。
⑦ 定义一个可选参数,该参数必须是整数值。
⑧ 定义一个可选参数,该参数必须是文件。
⑨ 定义一个“标志”选项,当存在时为“开启”,当不存在时为“关闭”。您将在后面了解更多关于这些内容。
⑩ 将解析后的参数返回给主函数(main())。如果有任何问题,比如如果 --int 的值是文本而不是像 42 这样的数字,argparse 将打印错误消息和“用法”信息给用户。
⑪ 定义主函数(main()),程序从这里开始。
⑫ 我们的主函数(main())将始终做的第一件事是调用 get_args() 来获取参数。
⑬ 每个参数的值都可以通过参数的长名称访问。不需要同时具有短名称和长名称,但这很常见,并且往往会使您的程序更易于阅读。
⑭ 当程序正在执行时,name 的值将等于文本“main”。
⑮ 如果条件为真,则调用 main()函数。
此程序将接受以下参数:
-
一个单一的
str类型的定位参数。定位意味着它没有前缀来命名它,但因为它相对于命令名的位置而有意义。 -
一个自动的
-h或--help标志,这将导致argparse打印用法。 -
一个名为
-a或--arg的字符串选项。 -
一个名为
-i或--int的命名选项参数。 -
一个名为
-f或--file的文件选项。 -
一个名为
-o或--on的布尔(开/关)标志。
看看前面的列表,你可以看到 new.py 为您做了以下事情:
-
创建了一个名为 hello2.py 的新 Python 程序
-
使用模板生成一个包含文档字符串、一个用于启动程序的
main()函数、一个用于解析和记录各种类型的参数的get_args()函数以及用于在main()函数中启动程序运行的代码的完整工作程序。 -
使新程序可执行,以便可以像
./hello2.py一样运行。
结果是一个您可以立即执行并生成如何运行该程序的文档的程序。在您使用 new.py 创建新程序后,您应该使用您的编辑器打开它,并修改参数名称和类型以适应您程序的需求。例如,在第二章中,您将能够删除除了位置参数之外的所有内容,您应该将'positional'重命名为类似'word'的东西(因为该参数将是一个单词)。
注意,您可以通过在您的家目录中创建一个名为.new.py(注意前面的点!)的文件来控制 new.py 使用的“名称”和“电子邮件”值。以下是我的示例:
$ cat ~/.new.py
name=Ken Youens-Clark
email=kyclark@gmail.com
1.14 使用 template.py 作为 new.py 的替代方案
如果您不想使用 new.py,我已经包含了前面程序的样本作为 template/template.py,您可以复制它。例如,在第二章中,您将需要创建程序 02_crowsnest/crowsnest.py。
您可以从存储库的顶层使用 new.py 来做这件事:
$ bin/new.py 02_crowsnest/crowsnest.py
或者,您可以使用cp(复制)命令将模板复制到您的新的程序中:
$ cp template/template.py 02_crowsnest/crowsnest.py
主要观点是您不必每次都从头开始编写每个程序。我认为从一个完整且可工作的程序开始并对其进行修改要容易得多。
注意:您可以将 new.py 复制到您的~/bin 目录中。然后您可以从任何目录使用它来创建新的程序。
一定要浏览附录——它包含了许多使用argparse的程序示例。您可以将这些示例中的许多复制过来以帮助您完成练习。
摘要
-
Python 程序是存储在文件中的纯文本。您需要
python3程序来解释和执行程序文件。 -
您可以将程序设置为可执行,并将其复制到您的
$PATH中的某个位置,这样您就可以像运行计算机上的任何其他程序一样运行它。请确保设置 shebang 以使用env来找到正确的python3。 -
argparse模块将帮助您记录和解析程序的所有参数。您可以验证参数的类型和数量,这些参数可以是位置参数、可选参数或标志。用法将自动生成。 -
我们将使用
pytest程序运行每个练习的 test.py 程序。make test快捷键将执行pytest -xv test.py,或者您可以直接运行此命令。 -
您应该经常运行测试以确保一切正常工作。
-
代码格式化工具如 YAPF 和 Black 将自动将您的代码格式化为社区标准,使其更易于阅读和调试。
-
代码检查工具如 Pylint 和 Flake8 可以帮助您纠正程序性和风格问题。
-
您可以使用 new.py 程序生成使用
argparse的新 Python 程序。
1 请参阅 Python 对 main 的文档以获取更多信息:docs.python.org/3/library/__main__.html。
2 鹰巢:处理字符串
| 警告,你那愚蠢的圆脸小丑!你是这个岗哨的桶匠。你懂我的意思吗,你这笨拙的傻瓜?!啊,你是陆地上的家伙!好吧,那么,你就是船上的瞭望员--系在帆船桅杆顶部的那个小桶。你的任务是观察有趣或危险的事物,比如可以掠夺的船或需要避免的冰山。当你看到像 narwhal 这样的东西时,你应该大声喊出,“Ahoy,船长,a narwhal 在左舷前方!”如果你看到章鱼,你会喊出“Ahoy,船长,an octopus 在左舷前方!”(我们将假设这个练习中一切都是“在左舷前方”。这是一个很好的地方。) | ![]() |
|---|
从现在开始,每一章都将提出一个编码挑战,你应该自己完成。我将讨论解决这些问题所需的关键思想,以及如何使用提供的测试来确定程序是否正确。你应该在本地有一个 Git 仓库的副本(参见书中引言中的设置说明),你应该在该章节的目录中编写每个程序。例如,本章的程序应该编写在 02_crowsnest 目录中,那里有程序的测试。
在本章中,我们将开始使用字符串。到本章结束时,你将能够
-
创建一个程序,接受一个位置参数并生成用法文档
-
根据程序的输入创建一个新的输出字符串
-
运行测试套件
你的程序应该命名为 crowsnest.py。它将接受一个位置参数,并将给定的参数打印在“Ahoy”部分中,根据参数是否以辅音或元音开头,打印“a”或“an”。
也就是说,如果输入“narwhal”,它应该这样做:
$ ./crowsnest.py narwhal
Ahoy, Captain, a narwhal off the larboard bow!
如果输入“octopus”,
$ ./crowsnest.py octopus
Ahoy, Captain, an octopus off the larboard bow!
这意味着你需要编写一个程序,该程序可以在命令行接受一些输入,确定适当的冠词(“a”或“an”)用于输入,并打印出一个字符串,将这两个值放入“Ahoy”短语中。
2.1 开始
你可能已经准备好开始编写程序了!好吧,再等一会儿,四肢公爵。我们需要讨论你如何使用测试来知道你的程序何时在运行,以及你可能如何开始编程。
2.1.1 如何使用测试
“最大的老师,失败是。”
--Yoda
在代码仓库中,我包括了将指导你编写程序的测试。在你写下第一行代码之前,我希望你运行这些测试,这样你可以查看第一个失败的测试:
$ cd 02_crowsnest
$ make test
你也可以用pytest -xv test.py来代替make test。在输出中,你会看到这样一行:
$ pytest -xv test.py
============================= test session starts ==============================
...
collected 6 items
test.py::test_exists FAILED [ 16%] ①
① 这个测试失败了。在此之后还有更多的测试,但由于 pytest 的-x 标志,测试在这里停止。
你还会看到很多其他输出,试图说服你预期的文件 crowsnest.py 不存在。学习阅读测试输出本身就是一个技能——它需要大量的练习,所以尽量不要感到不知所措。在我的终端(Mac 上的 iTerm),pytest的输出显示颜色和粗体打印以突出关键失败。粗体红色文字通常是我开始的地方,但你的终端可能会有不同的表现。
让我们看看输出。一开始可能看起来有点令人畏惧,但你会习惯于阅读消息并找到你的错误。
=================================== FAILURES ===================================
_________________________________ test_exists __________________________________
def test_exists(): ①
"""exists"""
> assert os.path.isfile(prg) ②
E AssertionError: assert False ③
E + where False = <function isfile at 0x1086f1310>('./crowsnest.py')
E + where <function isfile at 0x1086f1310> = <module 'posixpath'
from '/Library/Frameworks/Python.framework/Versions/3.8/lib/python3.8/posixpath.py'>.isfile
E + where <module 'posixpath' from
'/Library/Frameworks/Python.framework/Versions/3.8/lib/python3.8/posixpath.py'> = os.path
test.py:22: AssertionError
!!!!!!!!!!!!!!!!!!!!!!!!!! stopping after 1 failures !!!!!!!!!!!!!!!!!!!!!!!!!!! ④
============================== 1 failed in 0.05s ===============================
① 这是 test.py 中实际运行的代码。它是一个名为 test_exists()的函数。
② 这行开头的大于号“>”表示错误开始于此行。测试正在检查是否存在名为 crowsnest.py 的文件。如果你还没有创建它,预期它会按预期失败。
③ 这行开头的大写字母“E”是你要阅读的“错误”。理解测试试图告诉你什么非常困难,但本质上./crowsnest.py 文件不存在。
④ 这警告说,在第一次失败之后将不再运行更多测试。这是因为我们使用带有停止在第一次失败时停止测试的标志来运行它。
书中每个程序的第一个测试都检查预期的文件是否存在,所以让我们创建它!
2.1.2 使用 new.py 创建程序
为了通过第一个测试,你需要在 test.py 所在的 02_crowsnest 目录内创建一个名为 crowsnest.py 的文件。虽然从头开始编写是完全可行的,但我建议你使用 new.py 程序打印一些你将在每个练习中需要的有用样板代码。
从存储库的顶层,你可以运行以下命令来创建新的程序。
$ bin/new.py 02_crowsnest/crowsnest.py
Done, see new script "02_crowsnest/crowsnest.py."
如果你不想使用 new.py,你可以复制 template/template.py 程序:
$ cp template/template.py 02_crowsnest/crowsnest.py
现在,你应该有一个工作程序的轮廓,该程序可以接受命令行参数。如果你不带参数运行你的 new crowsnest.py,它将打印出如下简短的用法说明(注意“用法”是输出的第一个单词):
$ ./crowsnest.py
usage: crowsnest.py [-h] [-a str] [-i int] [-f FILE] [-o] str
crowsnest.py: error: the following arguments are required: str
用./crowsnest.py --help运行它。它还会打印出更长的帮助信息。
注意:这些不是我们程序的正确参数,只是 new.py 提供的默认示例。你需要修改它们以适应这个程序。
2.1.3 编写、测试、重复
你刚刚创建了程序,所以你应该能够通过第一个测试。我希望你将发展出的循环是写一小段代码——最多一或两行——然后运行程序或测试以查看你的进度。
让我们再次运行测试:
$ make test
pytest -xv test.py
============================= test session starts ==============================
...
collected 6 items
test.py::test_exists PASSED [ 16%] ①
test.py::test_usage PASSED [ 33%] ②
test.py::test_consonant FAILED [ 50%] ③
① 预期的文件存在,所以这个测试通过了。
② 程序将响应-h 和--help。帮助信息实际上是不正确的并不重要。测试只是在检查你似乎有一个可以运行并处理帮助标志的程序轮廓。
③ test_consonant() 测试失败。这没关系!我们甚至还没有开始编写实际的程序,但至少我们有一个开始的地方。
如你所见,创建一个新的程序new.py将使你通过前两个测试:
-
程序存在吗?是的,你刚刚创建了它。
-
当你请求帮助时,程序会打印帮助信息吗?是的,你之前没有参数地运行了它,并使用了
--help标志,你看到了它会生成帮助信息。
现在你有一个可以接受一些参数(但不是正确的参数)的工作程序。接下来,你需要让你的程序接受需要宣布的“独角兽”或“章鱼”值。我们将使用命令行参数来实现这一点。
2.1.4 定义你的参数
图 2.1 一定会让你感到震惊,展示了程序的输入(或参数)和输出。我们将在这本书的整个过程中使用这些图表来想象代码和数据是如何一起工作的。在这个程序中,输入是一个单词,输出是与正确冠词结合的短语,包含该单词。

图 2.1 程序的输入是一个单词,输出是这个单词加上它的正确冠词(以及一些其他内容)。
我们需要修改获取参数的程序部分——名为get_args()的合适函数。这个函数使用argparse模块来解析命令行参数,而我们的程序需要接受一个单一的、位置参数。如果你不确定“位置参数”是什么,请务必阅读附录,特别是 A.4.1 节。
模板创建的get_args()函数将第一个参数命名为positional。记住,位置参数是通过它们的顺序定义的,并且不以短横线开头命名。你可以删除除了位置参数word之外的所有参数。修改你的程序中的get_args()部分,直到它能够打印出这个用法:
$ ./crowsnest.py
usage: crowsnest.py [-h] word
crowsnest.py: error: the following arguments are required: word
同样,它应该为-h或--help标志打印更长的用法文档:
$ ./crowsnest.py -h
usage: crowsnest.py [-h] word
Crow's Nest -- choose the correct article
positional arguments:
word A word ①
optional arguments:
-h, --help show this help message and exit ②
① 你需要定义一个单词参数。注意,它被列为位置参数。
② -h 和 --help 标志是由argparse自动创建的。你不允许将这些用作选项。它们用于创建你程序的文档。
不要继续,直到你的用法与前面的匹配!
当你的程序打印正确的用法时,你可以在main函数内部获取word参数。修改你的程序,使其能够打印word:
def main():
args = get_args()
word = args.word
print(word)
然后测试它是否工作:
$ ./crowsnest.py narwhal
narwhal
现在再次运行你的测试。你应该仍然通过两个测试,而第三个测试失败。让我们看看测试失败的原因:
=================================== FAILURES ===================================
________________________________ test_consonant ________________________________
def test_consonant():
"""brigantine -> a brigantine"""
for word in consonant_words:
out = getoutput(f'{prg} {word}') ①
> assert out.strip() == template.format('a', word) ②
E AssertionError: assert 'brigantine' == 'Ahoy, Captai...larboard bow!' ③
E - brigantine ④
E + Ahoy, Captain, a brigantine off the larboard bow! ⑤
① 现在理解这一行并不是非常重要,但 getoutput() 函数正在运行带有单词的程序。我们将在本章讨论 f-string。程序运行的输出将进入 out 变量,该变量将用于查看程序是否为给定的单词创建了正确的输出。这个函数中的代码没有任何你应该担心能够编写的内容。
② 以“>”开头的行显示了产生错误的代码。程序输出与预期字符串进行比较。由于它不匹配,断言产生了异常。
③ 这行以“E”开头,表示错误。
④ 以连字符(-)开头的行是测试运行时使用参数“brigantine”得到的输出——它返回了单词“brigantine”。
⑤ 以加号(+)开头的行是测试预期的内容:“Ahoy, Captain, a brigantine off the larboard bow!”
因此,我们需要将 word 放入“Ahoy”短语中。我们如何做到这一点?
2.1.5 连接字符串
将字符串拼接在一起称为连接或拼接字符串。为了演示,我将直接将一些代码输入到 Python 解释器中。我希望你一起输入。不,真的!输入你看到的一切,并亲自尝试。
打开终端并输入 python3 或 ipython 以启动交互式解释器。交互式解释器是一个读取-评估-打印-循环——Python 将会读取输入的每一行,评估它,并在循环中打印结果。以下是我系统上的样子:
$ python3
Python 3.8.1 (v3.8.1:1b293b6006, Dec 18 2019, 14:08:53)
[Clang 6.0 (clang-600.0.57)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>>
“>>>” 是你可以输入代码的提示符。记住不要输入那部分!要退出交互式解释器,可以输入 quit() 或按 Ctrl-D(控制键加上字母 D)。
注意:你可能更喜欢使用 Python 的 IDLE(集成开发和学习环境)程序、IPython 或 Jupyter 笔记本来与语言交互。我将在整本书中坚持使用 python3 交互式解释器。
让我们从将变量 word 赋值为'narwhal'开始。在交互式解释器中,输入 word = 'narwhal' 并按回车键:
>>> word = 'narwhal'
注意,你可以在 = 的周围放置任意多(或没有)空格,但传统、可读性(以及帮助你找到代码中错误的工具,如 Pylint 和 Flake8)要求你在两侧使用恰好一个空格。
如果你输入 word 并按回车键,Python 将打印 word 的当前值:
>>> word
'narwhal'
现在输入 werd 并按回车键:
>>> werd
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
NameError: name 'werd' is not defined
警告:没有 werd 变量,因为我们还没有将 werd 设置为任何值。使用未定义的变量会导致异常,这会使你的程序崩溃。当你为它赋值时,Python 会愉快地为你创建 werd。
我们需要在两个其他字符串之间插入 word。+ 运算符可以用来连接字符串:
>>> 'Ahoy, Captain, a ' + word + ' off the larboard bow!'
'Ahoy, Captain, a narwhal off the larboard bow!'
如果你将程序更改为 print() 该字符串而不是仅仅打印 word,你应该能够通过四个测试:
test.py::test_exists PASSED [ 16%]
test.py::test_usage PASSED [ 33%]
test.py::test_consonant PASSED [ 50%]
test.py::test_consonant_upper PASSED [ 66%]
test.py::test_vowel FAILED [ 83%]
如果你仔细观察失败,你会看到这个:
E - Ahoy, Captain, a aviso off the larboard bow!
E + Ahoy, Captain, an aviso off the larboard bow!
E ? +
我们在word之前硬编码了“a”,但实际上我们需要确定是否根据word是否以元音字母开头来使用“a”或“an”。我们该如何做到这一点?
2.1.6 变量类型
在我们进一步深入之前,我需要稍微退一步,指出我们的word变量是一个字符串。Python 中的每个变量都有一个类型,它描述了它所持有的数据类型。因为我们把word的值放在引号中('narwhal'),所以word包含一个字符串,Python 用名为str的类来表示。(类是我们可以使用的一组代码和函数。)
type()函数会告诉你 Python 认为某个数据是什么类型:
>>> type(word)
<class 'str'>
无论何时你在单引号('')或双引号("")中放置一个值,Python 都会将其解释为str:
>>> type("submarine")
<class 'str'>
警告 如果你忘记了引号,Python 将会寻找一些名为该名称的变量或函数。如果没有名为该名称的变量或函数,将会引发异常:
>>> word = narwhal
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
NameError: name 'narwhal' is not defined
异常很不好,我们将尝试编写避免它们或至少能够优雅地处理它们的代码。
2.1.7 获取字符串的一部分
回到我们的问题。我们需要根据word的第一个字符是元音字母还是辅音字母,在给定的word前放置“a”或“an”。
在 Python 中,我们可以使用方括号和索引来从字符串中获取单个字符。索引是序列中元素的位置数值,我们必须记住索引是从0开始的。
|
>>> word = 'narwhal'
>>> word[0]
'n'
你也可以对字面量字符串值进行索引:
>>> 'narwhal'[0]
'n'
![]() |
|---|
| 因为索引值从 0 开始,这意味着最后一个索引是字符串长度减一,这通常很令人困惑。 “narwhal”的长度是 7,但最后一个字符是在索引6处找到的:
>>> word[6]
'l'
![]() |
|---|
| 你也可以使用负索引数字从末尾开始计数,所以最后一个索引也是-1:
>>> word[-1]
'l'
![]() |
|---|
| 你可以使用切片表示法 [start:stop] 来获取字符范围。start和stop都是可选的。start的默认值是0(字符串的开始),而stop的值不包括在内:
>>> word[:3]
'nar'
![]() |
|---|
stop的默认值是字符串的末尾:
>>> word[3:]
'whal'
在下一章中,你会看到这与切片列表的语法相同。字符串(某种程度上)是字符的列表,所以这并不奇怪。
2.1.8 在 REPL 中寻找帮助
str 类有很多我们可以用来处理字符串的函数,但它们是什么?编程的大部分内容是知道如何提问和在哪里寻找答案。你可能经常听到的一个常见说法是“RTFM”——阅读详细手册。Python 社区已经创建了大量的文档,所有这些文档都可以在 docs.python.org/3/ 找到。你需要经常查阅文档来提醒自己(并发现)如何使用某些函数。字符串类的文档在这里:docs.python.org/3/library/string.html。
| 我更喜欢直接在 REPL 中阅读文档,在这种情况下,通过输入 help(str):
>>> help(str)
![]() |
|---|
在 help 中,你可以使用键盘上的上箭头和下箭头在文本中上下移动。你也可以按空格键或字母 F(有时是 Ctrl-F)跳转到下一页,按字母 B(有时是 Ctrl-B)跳转到上一页。你可以通过按 / 然后输入你想找到的文本来搜索文档。如果你在搜索后按 N(代表“下一个”),你会跳到下一个找到该字符串的地方。要离开帮助,请按 Q(代表“退出”)。
2.1.9 字符串方法
| 现在我们知道 word 是一个字符串 (str),我们就可以调用变量上的所有这些非常有用的方法。(方法 是属于变量,如 word 的函数。)例如,如果我想大声说出我们有一个独角鲸的事实,我可以用大写字母打印出来。如果我在帮助文档中搜索,我会看到有一个名为 str.upper() 的函数。下面是如何 调用 或 执行 该函数的方法:
>>> word.upper()
'NARWHAL'
![]() |
|---|
你必须包含括号,(),否则你就是在谈论 函数本身:
>>> word.upper
<built-in method upper of str object at 0x10559e500>
这实际上在以后使用像 map() 和 filter() 这样的函数时会很有用,但就现在而言,我们希望 Python 在变量 word 上执行 str.upper() 函数,所以我们添加了括号。请注意,该函数返回单词的大写版本,但 不会 改变 word 的值:
>>> word
'narwhal'
另有一个名为 str.isupper() 的 str 函数,其名称有助于你知道这将返回一个真/假类型的答案。让我们试一试:
>>> word.isupper()
False
我们可以将方法链接在一起,如下所示:
>>> word.upper().isupper()
True
这很有道理。如果我将 word 转换为大写,那么 word.isupper() 就会返回 True。
| 我觉得 str 类没有包含一个获取字符串长度的方法很奇怪。为此,我们必须使用一个单独的函数,称为 len(),代表“长度”:
>>> len('narwhal')
7
>>> len(word)
7
![]() |
|---|
你是自己将这些内容输入到 Python 中的吗?我建议你这样做!在 str 的帮助文档中找到其他方法,并尝试使用它们。
2.1.10 字符串比较
你现在知道如何通过使用 word[0] 来获取 word 的第一个字母。让我们将它赋值给变量 char:
>>> word = 'octopus'
>>> char = word[0]
>>> char
'o'
如果你检查新char变量的type(),它是一个str。即使是单个字符,Python 也会将其视为字符串:
>>> type(char)
<class 'str'>
现在,我们需要弄清楚char是元音还是辅音。我们将说字母“a”、“e”、“i”、“o”和“u”构成了我们的“元音”集合。你可以使用==来比较字符串:
>>> char == 'a'
False
>>> char == 'o'
True
注意:在给变量赋值时,如word = 'narwhal',始终使用一个等号(=),而在比较两个值时,如word == 'narwhal',使用两个等号(==,在我的脑海中,我将其读作“equal-equal”)。第一个是一个改变word值的语句,第二个是一个返回True或False的表达式(见图 2.2)。

图 2.2 表达式返回一个值。语句不返回值。
我们需要将我们的char与所有的元音进行比较。在这样的比较中,我们可以使用and和or,并且它们将根据标准的布尔代数进行组合:
>>> char == 'a' or char == 'e' or char == 'i' or char == 'o' or char == 'u'
True
如果word是“Octopus”或“OCTOPUS”会怎样?
>>> word = 'OCTOPUS'
>>> char = word[0]
>>> char == 'a' or char == 'e' or char == 'i' or char == 'o' or char == 'u'
False
我们是否必须进行 10 次比较才能检查大写版本?如果我们把word[0]转换为小写会怎样?记住word[0]返回一个str,因此我们可以将其他str方法链接到它上面:
>>> word = 'OCTOPUS'
>>> char = word[0].lower()
>>> char == 'a' or char == 'e' or char == 'i' or char == 'o' or char == 'u'
True
确定一个字符char是否为元音的一个更简单的方法是使用 Python 的x in y构造,这将告诉我们值x是否在集合y中。我们可以询问字母'a'是否在较长的字符串'aeiou'中:
>>> 'a' in 'aeiou'
True
![]() |
|---|
但字母'b'不是:
>>> 'b' in 'aeiou'
False
让我们用这个来测试小写word的第一个字符(即'o'):
>>> word = 'OCTOPUS'
>>> word[0].lower() in 'aeiou'
True
2.1.11 条件分支
一旦你弄清楚第一个字母是否为元音,你将需要选择一个冠词。我们将使用一个非常简单的规则:如果单词以元音开头,选择“an”;否则,选择“a。”这忽略了像单词开头的“h”是沉默的情况。例如,我们说“a hat”但“an honor。”我们也不会考虑初始元音有辅音发音的情况,例如在“union”中,“u”听起来像“y”。
我们可以创建一个新的变量article,并将其设置为空字符串,然后使用if/else语句来确定应该放入其中的内容:
article = '' ①
if word[0].lower() in 'aeiou': ②
article = 'an' ③
else: ④
article = 'a' ④
① 将文章初始化为空字符串。
② 检查单词的第一个小写字符是否为元音。
③ 如果第一个字符是元音,将文章设置为“an”。
④ 如果第一个字符不是元音,将文章设置为“a”。
使用if表达式(表达式返回值;语句不返回值)可以更简洁地写出这一点。if表达式写法稍微有些反常。首先来的是测试(或“谓词”)为True时的值,然后是谓词,最后是谓词为False时的值(见图 2.3)。

图 2.3 if表达式将在谓词为True时返回第一个值,否则返回第二个值。
这种方法也更安全,因为 if 表达式需要 else。我们不可能忘记处理两种情况:
>>> char = 'o'
>>> article = 'an' if char in 'aeiou' else 'a'
让我们验证一下我们是否得到了正确的 article:
>>> article
'an'
2.1.12 字符串格式化
现在我们有两个变量,article 和 word,需要将它们合并到我们的“Ahoy!”短语中。你之前看到,我们可以使用加号(+)来连接字符串。从其他字符串创建新字符串的另一种方法是使用 str.format() 方法。
要这样做,你需要创建一个带有花括号 {} 的字符串模板,这些花括号表示值的占位符。将要替换的值是 str.format() 方法的参数,并且它们将按照 {} 出现的顺序进行替换(图 2.4)。

图 2.4 使用 str.format() 方法来扩展字符串中变量的值。
这就是代码的样子:
>>> 'Ahoy, Captain, {} {} off the larboard bow!'.format(article, word)
'Ahoy, Captain, an octopus off the larboard bow!'
另一种组合字符串的方法是使用特殊的“f-string”,你可以在花括号 {} 中直接放置变量。选择哪种方法取决于个人喜好;我倾向于选择这种风格,因为我不必考虑哪个变量与哪一组括号相对应:
>>> f'Ahoy, Captain, {article} {word} off the larboard bow!'
'Ahoy, Captain, an octopus off the larboard bow!'
| 注意:在某些编程语言中,你必须声明变量的名称以及它将持有的 类型 数据。如果一个变量被声明为数字,它永远不能持有不同类型的值,比如字符串。这被称为 静态类型,因为变量的类型永远不能改变。Python 是一种 动态类型 语言,这意味着你不需要声明变量或变量将持有的数据类型。你可以在任何时候更改值和数据类型。这可能是个好消息,也可能是坏消息。正如哈姆雷特所说:“世间本无善恶,思想使之如此。” | ![]() |
|---|
2.1.13 写作时间
下面是编写解决方案的一些提示:
-
使用
new.py开始你的程序,并在get_args()中填充一个名为word的单个位置参数。 -
你可以通过像列表一样索引它来获取单词的第一个字符,
word[0]。 -
如果你不想检查大小写字母,你可以使用
str.lower()或str.upper()方法强制输入为一种情况,以检查第一个字符是否为元音或辅音。 -
元音(如果你还记得的话,有五个)比辅音少,所以检查第一个字符是否是那些中的一个可能更容易。
-
你可以使用
x in y语法来检查元素x是否在集合y中,这里的集合是一个list。 -
使用
str.format()或 f-strings 将正确的冠词插入到给定的单词中,形成较长的短语。 -
每次更改程序后,运行
maketest(或pytest-xvtest.py)以确保程序编译正确,并且处于正确的轨道。
在翻页并研究我的解决方案之前,现在就开始编写程序吧。别再那么暴躁了,shabaroon!
2.2 解决方案
下面是满足测试套件的一种编写程序的方法:
#!/usr/bin/env python3
"""Crow's Nest"""
import argparse
# --------------------------------------------------
def get_args(): ①
"""Get command-line arguments"""
parser = argparse.ArgumentParser( ②
description="Crow's Nest -- choose the correct article", ③
formatter_class=argparse.ArgumentDefaultsHelpFormatter) ④
parser.add_argument('word', metavar='word', help='A word') ⑤
return parser.parse_args() ⑥
# --------------------------------------------------
def main(): ⑦
"""Make a jazz noise here"""
args = get_args() ⑧
word = args.word ⑨
article = 'an' if word[0].lower() in 'aeiou' else 'a' ⑩
print(f'Ahoy, Captain, {article} {word} off the larboard bow!') ⑪
# --------------------------------------------------
if __name__ == '__main__': ⑫
main() ⑬
① 定义 get_args()函数来处理命令行参数。我喜欢把它放在第一位,这样我可以在阅读代码时立即看到它。
② 解析器将解析参数。
③ 描述显示在用法中,以描述程序的功能。
④ 在用法中显示每个参数的默认值。
⑤ 定义一个名为 word 的位置参数。
⑥ 解析参数的结果将返回给 main()。
⑦ 在程序开始的地方定义 main()函数。
⑧ args 包含来自 get_args()函数的返回值。
⑨ 将 args.word 值从参数中放入单词变量。
⑩ 使用 if 表达式检查单词的小写首字符是否在元音集合中。
⑪ 使用 f-string 打印输出字符串,在字符串中插入文章和单词变量。
⑫ 检查我们是否在“main”命名空间中,这意味着程序正在运行。
⑬ 如果我们在“main”命名空间中,调用 main()函数以使程序开始。
2.3 讨论
我想强调,前面列出的只是一个解决方案,而不是唯一的解决方案。在 Python 中,有许多表达相同想法的方法。只要你的代码通过了测试套件,它就是正确的。
话虽如此,我用 new.py 创建了我的程序,这自动给我提供了两个函数:
-
get_args(),我在其中定义了程序的参数 -
main(),程序从这里开始
让我们讨论这两个函数。
2.3.1 使用 get_args()定义参数
我更喜欢将get_args()函数放在第一位,这样我就可以立即看到程序期望的输入。你不必将其定义为单独的函数--如果你愿意,你可以把所有这些代码放在main()中。然而,我们的程序最终会变得更长,我认为将其作为一个单独的想法来保持是很好的。我展示的每个程序都将有一个get_args()函数,该函数将定义和验证输入。
我们的程序规范(“规范”)说明程序应接受一个位置参数。我将'positional'参数名称更改为'word',因为我期望一个单词:
parser.add_argument('word', metavar='word', help='Word')
我建议你永远不要使用名为'positional'的位置参数,因为它是一个完全不可描述的术语。根据它们是什么来命名你的变量会使你的代码更易于阅读。
程序不需要 new.py 创建的其他选项,所以你可以删除其余的parser.add_argument()调用。
get_args()函数将返回解析我放入变量args中的命令行参数的结果:
return parser.parse_args()
如果 argparse 无法解析参数——例如,如果没有参数——它将永远不会从 get_args() 返回,而是会打印出用户的“用法”信息并带错误代码退出,让操作系统知道程序没有成功退出。(在命令行中,退出值为 0 表示没有错误。任何非 0 的值都被视为错误。)
2.3.2 main() 的主要作用
许多编程语言会自动从 main() 函数开始,所以我总是定义一个 main() 函数并在那里开始我的程序。这不是强制要求,但在 Python 中这是一个极其常见的习语。我展示的每个程序都将从一个 main() 函数开始,该函数将首先调用 get_args() 来获取程序的输入:
def main():
args = get_args()
我现在可以通过调用 args.word 来访问 word。注意没有括号。这不是 args.word(),因为它不是一个函数调用。将 args.word 视为一个插槽,其中存储着单词的值:
word = args.word
我喜欢使用 REPL 来实现我的想法,所以我将假装 word 已经被设置为“octopus”:
>>> word = 'octopus'
2.3.3 对单词的第一个字符进行分类
为了确定我选择的文章应该是 a 还是 an,我需要查看 word 的第一个字符。在介绍中,我们使用了这个方法:
>>> word[0]
'o'
我可以检查第一个字符是否在元音字符串中,无论是大写还是小写:
>>> word[0] in 'aeiouAEIOU'
True
然而,如果使用 word.lower() 函数,我可以使它更短。然后我只需检查小写元音:
>>> word[0].lower() in 'aeiou'
True
记住,x in y 的形式是询问元素 x 是否在集合 y 中。你可以用它来检查较长字符串(如元音字母列表)中的字母:
>>> 'a' in 'aeiou'
True
| 你可以使用元音字母列表中的成员作为条件来选择“an”;否则,我们选择“a。”正如介绍中提到的,if 表达式是进行二进制选择(只有两种可能性)最短且最安全的方式:
>>> article = 'an' if word[0].lower() in 'aeiou' else 'a'
>>> article
'an'
![]() |
|---|
if 表达式的安全性来源于 Python 即使你忘记了 else 也不会运行这个程序。试一试,看看你会得到什么错误。
让我们改变 word 的值为“galleon”并检查它是否仍然有效:
>>> word = 'galleon'
>>> article = 'an' if word[0].lower() in 'aeiou' else 'a'
>>> article
'a'
2.3.4 打印结果
最后,我们需要打印出包含文章和单词的短语。正如介绍中提到的,你可以使用 str.format() 函数将变量合并到字符串中:
>>> article = 'a'
>>> word = 'ketch'
>>> print('Ahoy, Captain, {} {} off the larboard bow!'.format(article, word))
Ahoy, Captain, a ketch off the larboard bow!
Python 的 f-strings 会将 {} 占位符内的任何代码进行插值,因此变量会被转换为其内容:
>>> print(f'Ahoy, Captain, {article} {word} off the larboard bow!')
Ahoy, Captain, a ketch off the larboard bow!
无论你选择如何打印文章和单词都行,只要它通过了测试。虽然选择哪种方式是个人品味的问题,但我发现 f-strings 读取起来更容易,因为我的眼睛不需要在 {} 占位符和将要放入其中的变量之间来回跳跃。
2.3.5 运行测试套件
“计算机就像一个顽皮的精灵。它会给你你要求的东西,但并不总是你想要的东西。”
--Joe Sondow
计算机有点像坏精灵。它们会做你告诉它们的事情,但不一定是你想做的事情。在《X 档案》的一集中,角色 Mulder 愿望地球和平,一个精灵移除了所有人,只留下他。
测试是我们用来验证我们的程序是否真正按照我们 实际上 想要的方式运行的工具。测试永远不能证明我们的程序真正没有错误,只能证明我们在编写程序时想象或发现的错误不再存在。尽管如此,我们仍然编写和运行测试,因为它们确实非常有效,比不这样做要好得多。
这就是 测试驱动开发 的理念:
-
在编写软件之前编写测试。
-
运行测试以验证我们尚未编写的软件在执行某些任务时失败。
-
编写软件以满足需求。
-
运行测试以检查它现在 确实 工作。
-
持续运行所有测试以确保当我们添加一些新代码时,不会破坏现有的代码。
我们现在还不会讨论如何 编写 测试。这将在稍后进行。目前,我已经为你编写了所有测试。我希望到这本书的结尾,你将看到测试的价值,并且始终从编写 测试优先,代码其次 开始!
2.4 进一步探讨
|
-
让你的程序匹配输入单词的大小写(例如,“an octopus” 和 “An Octopus”)。将 test.py 中的现有
test_函数复制到测试中,以验证你的程序在通过所有其他测试的同时是否正确工作。先编写测试,然后再让程序通过测试。这就是 测试驱动开发! -
接受一个新的参数,将“larboard”(船的左侧)改为“starboard”(船的右侧 1)。你可以创建一个名为
--side的选项,默认为“larboard”,或者你可以创建一个--starboard标志,如果存在,则将侧面改为“starboard”。 -
提供的测试只给你以实际字母字符开头的单词。扩展你的代码以处理以数字或标点符号开头的单词。你的程序应该拒绝这些吗?添加更多测试以确保你的程序做你打算做的事情。
![]() |
|---|
摘要
-
所有 Python 的文档都可以在
docs.python.org/3/和通过 REPL 中的help命令找到。 -
Python 中的变量根据你为其分配的值动态类型化,并且在你为其分配值时创建。
-
字符串有
str.upper()和str.isupper()等方法,你可以调用它们来修改字符串或获取信息。 -
你可以通过使用方括号和索引如
[0]用于第一个字母或[-1]用于最后一个字母来获取字符串的一部分。 -
你可以使用
+运算符连接字符串。 -
str.format()方法允许你创建一个带有{}占位符的模板,这些占位符将被参数填充。 -
F-字符串如
f'{article}' {word}允许变量和代码直接放入括号内。 -
xiny这个表达式会报告值x是否存在于集合y中。 -
if/else这样的语句不返回值,而像xifyelsez这样的表达式则返回值。 -
测试驱动开发是一种确保程序满足某些最小正确性标准的方法。程序中的每个功能都应该有测试,编写和运行测试套件应该是编写程序的一个组成部分。
“右舷”与星星无关,而是与“舵板”或舵有关,对于右手舵手来说,舵通常位于船的右侧。
3 去野餐:使用列表
写代码让我饿了!让我们写一个程序来列出一些我们想吃的美食。
到目前为止,我们处理的是单个变量,比如一个名字来打招呼,或者一个航海主题的对象来指出。在这个程序中,我们想要跟踪一个或多个我们将存储在 list 中的食物,list 是一个可以存储任意数量项目的变量。我们在现实生活中经常使用列表。也许它是你最喜欢的五首歌曲,你的生日愿望清单,或者是一个最佳类型桶的清单。
在本章中,我们将去野餐,并想打印一个要携带的项目列表。你将学习到
-
编写一个接受多个位置参数的程序
-
使用
if、elif和else来处理三个或更多选项的条件分支 -
在列表中查找和修改项目
-
对列表进行排序和反转
-
将列表格式化为新的字符串
| 列表中的项目将通过位置参数传递。当只有一个项目时,你会打印它:
$ ./picnic.py salad
You are bringing salad.
![]() |
|---|
| 什么?谁会在野餐时带沙拉?当有两个项目时,你会在它们之间打印“和”:
$ ./picnic.py salad chips
You are bringing salad and chips.
![]() |
|---|
| 嗯,薯片。这是一个改进。当有三个或更多项目时,你会用逗号分隔它们:
$ ./picnic.py salad chips cupcakes
You are bringing salad, chips, and cupcakes.
还有另一个转折。程序还需要接受一个 --sorted 参数,这将要求你在打印之前对项目进行排序。我们稍后会处理这个问题。 |
|
因此,你的 Python 程序必须执行以下操作:
-
在列表中存储一个或多个位置参数
-
计算参数数量
-
可能对项目进行排序
-
使用
list打印一个新字符串,该字符串根据项目数量格式化参数
我们应该如何开始?
3.1 启动程序
我总是会推荐你通过运行 new.py 或将 template/template.py 复制到程序名称来开始编程。这次程序应该叫做 picnic.py,并且你需要将其创建在 03_picnic 目录中。
你可以使用存储在存储库顶层的 new.py 程序来做这件事:
$ bin/new.py 03_picnic/picnic.py
Done, see new script "03_picnic/picnic.py."
现在进入 03_picnic 目录并运行 make test 或 pytest -xv test.py。你应该通过前两个测试(程序存在,程序创建用法)并失败第三个测试:
test.py::test_exists PASSED [ 14%]
test.py::test_usage PASSED [ 28%]
test.py::test_one FAILED [ 42%]
其余的输出抱怨测试期望的是“你将带来薯片”,但得到的是其他东西:
=================================== FAILURES ===================================
___________________________________ test_one ___________________________________
def test_one():
"""one item"""
out = getoutput(f'{prg} chips') ①
> assert out.strip() == 'You are bringing chips.' ②
E assert 'str_arg = ""...nal = "chips"' == 'You are bringing chips.'
E + You are bringing chips. ③
E - str_arg = "" ④
E - int_arg = "0"
E - file_arg = ""
E - flag_arg = "False"
E - positional = "chips"
test.py:31: AssertionError
====================== 1 failed, 2 passed in 0.56 seconds ======================
① 程序正在使用参数“chips”运行。
② 这行代码导致了错误。输出被测试以查看它是否等于字符串“你将带来薯片”。
③ 以加号开始的行显示了预期的内容。
④ 以减号开始的行显示了程序返回的内容。
让我们用参数“chips”运行程序,看看它会得到什么:
$ ./picnic.py chips
str_arg = ""
int_arg = "0"
file_arg = ""
flag_arg = "False"
positional = "chips"
对,这完全不对!记住,模板还没有正确的参数,只是提供了一些示例,所以我们需要做的第一件事是修复 get_args() 函数。如果给定的 无参数,你的程序应该打印出如下用法说明:
$ ./picnic.py
usage: picnic.py [-h] [-s] str [str ...]
picnic.py: error: the following arguments are required: str
下面是 -h 或 --help 标志的用法:
$ ./picnic.py -h
usage: picnic.py [-h] [-s] str [str ...]
Picnic game
positional arguments:
str Item(s) to bring
optional arguments:
-h, --help show this help message and exit
-s, --sorted Sort the items (default: False)
我们需要一个或多个位置参数和一个名为 --sorted 的可选标志。修改你的 get_args() 函数,直到它产生前面的输出。
注意,应该有一个或多个 item 参数,因此你应该使用 nargs='+' 来定义它。有关详细信息,请参阅附录中的 A.4.5 部分。
3.2 编写 picnic.py
图 3.1 展示了我们将要编写的 picnic.py 程序的输入和输出。

图 3.1 picnic 程序的字符串图,显示了程序将处理的各个输入和输出
程序应该接受一个或多个位置参数,用于携带野餐的物品,以及一个 -s 或 --sorted 标志来指示是否对物品进行排序。输出将是“你将携带”,然后是按照以下规则格式化的物品列表:
如果只有一个项目,请说明该项目:
$ ./picnic.py chips
You are bringing chips.
-
如果有两个物品,在物品之间放置“and”。注意,“potato chips”只是一个恰好包含两个单词的 单个字符串。如果你省略引号,程序会有三个参数。这里你使用单引号还是双引号无关紧要:
$ ./picnic.py "potato chips" salad You are bringing potato chips and salad. -
如果有三个或更多物品,在物品和“and”之前放置一个逗号和空格,并在最后一个元素之前放置一个逗号(有时称为“牛津逗号”),因为你的作者是一名英语文学专业的学生,虽然我可能最终停止在句子末尾使用两个空格,但你可以从我冰冷、死去的双手中夺走牛津逗号:
$ ./picnic.py "potato chips" salad soda cupcakes You are bringing potato chips, salad, soda, and cupcakes.
如果指定了 -s 或 --sorted 标志,请确保对物品进行排序:
$ ./picnic.py --sorted salad soda cupcakes
You are bringing cupcakes, salad, and soda.
为了弄清楚我们有多少个物品,如何对它们进行排序和切片,以及如何格式化输出字符串,我们需要讨论 Python 中的 list 类型。
3.3 列表介绍
是时候学习如何定义位置参数,以便它们可以作为 list 使用。也就是说,如果我们像这样运行程序,
$ ./picnic.py salad chips cupcakes
参数 salad chips cupcakes 将作为程序内部的字符串 list 可用。如果你在 Python 中 print() 一个 list,你会看到如下内容:
['salad', 'chips', 'cupcakes']
方括号告诉我们这是一个 list,元素周围的引号告诉我们它们是字符串。注意,物品的顺序与命令行上提供的顺序相同。列表总是保持其顺序! |
![]() |
|---|
让我们进入 REPL,创建一个名为 items 的变量来保存一些美味的食物,以便在野餐上携带。我真的很希望你自己输入这些命令,无论是在 python3 REPL、IPython 还是 Jupyter Notebook 中。与语言进行实时交互非常重要。
要创建一个新的空 list,你可以使用 list() 函数:
>>> items = list()
或者你可以使用空方括号:
>>> items = []
检查 Python 对 type() 的输出。是的,它是一个 list:
>>> type(items)
<class 'list'>
我们需要知道的第一件事是我们要为野餐准备多少 items。就像 str 一样,我们可以使用 len()(长度)来获取 items 中的元素数量:
>>> len(items)
0
空列表的长度是 0。
3.3.1 向列表中添加一个元素
空列表并不很有用。让我们看看我们如何添加新项目。我们在上一章中使用了 help(str) 来阅读有关字符串方法的文档——属于 Python 中每个 str 的函数。这里我想让你使用 help(list) 来了解 list 方法:
>>> help(list)
记住,按空格键或 F 键(或 Ctrl-F)可以前进,按 B 键(或 Ctrl-B)可以后退。按 / 键可以搜索字符串。
你会看到很多“双下划线”方法,比如 __len__。跳过那些,第一个方法就是 list.append(),我们可以用它来向列表的末尾添加项目。
如果我们评估 items,空括号会告诉我们它是空的:
>>> items
[]
让我们向列表末尾添加“sammiches”:
>>> items.append('sammiches')
没有发生任何事,那么我们怎么知道它是否成功了?让我们检查长度。它应该是 1:
>>> len(items)
1
欢呼!这成功了。在测试的精神下,我们将使用 assert 语句来验证长度是 1:
>>> assert len(items) == 1
什么都没发生是好事。当断言失败时,它会触发一个异常,导致大量消息输出。
如果你输入 items 并在 REPL 中按 Enter 键,Python 将会显示其内容:
>>> items
['sammiches']
好极了,我们添加了一个元素。
3.3.2 向列表中添加多个元素
让我们尝试将“chips”和“ice cream”添加到 items 中:
>>> items.append('chips', 'ice cream')
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: append() takes exactly one argument (2 given)
这里有一个讨厌的异常,这些异常会导致你的程序 崩溃,我们无论如何都想避免这种情况。正如你所看到的,append() 函数需要 exactly 一个 argument,而我们给了它两个。如果你查看 items,你会看到什么都没加:
>>> items
['sammiches']
好吧,也许我们本应该给它一个要添加的 list?让我们试试:
>>> items.append(['chips', 'ice cream'])
好吧,这并没有引发异常,所以可能它成功了?我们预计会有三个 items,所以让我们用一个断言来检查一下:
>>> assert len(items) == 3
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
AssertionError
我们又遇到了另一个异常,因为 len(items) 不是 3。长度是多少?
>>> len(items)
2
只有 2 个?让我们看看 items:
>>> items
['sammiches', ['chips', 'ice cream']]
检查一下!列表可以存储任何类型的数据,比如字符串、数字,甚至其他列表(见图 3.2)。我们要求 items.append() 添加 ['chips', 'ice cream'],这是一个列表,这正是它所做的事情。当然,这并不是我们想要的。

图 3.2 列表可以存储任何混合类型的数据,例如字符串和另一个字符串列表。
让我们重置 items 以便修复这个问题:
>>> items = ['sammiches']
如果你继续阅读帮助文档,你会找到 list.extend() 方法:
| extend(self, iterable, /)
| Extend list by appending elements from the iterable.
| 让我们试试:
>>> items.extend('chips', 'ice cream')
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: extend() takes exactly one argument (2 given)
![]() |
|---|
哎,真让人沮丧!现在 Python 告诉我们extend()``恰好需要一个参数,如果你查看help,它应该是一个iterable。list是一种你可以迭代的(从头到尾遍历)东西,所以这会起作用:
>>> items.extend(['chips', 'ice cream'])
没有发生任何事情。没有异常,所以可能成功了?让我们检查长度。它应该是 3:
>>> assert len(items) == 3
是的!让我们看看我们添加的项目:
>>> items
['sammiches', 'chips', 'ice cream']
太好了!这听起来像是一次相当美味的出行。
如果你知道将要放入list中的所有内容,你可以这样创建它:
>>> items = ['sammiches', 'chips', 'ice cream']
list.append()和list.extend()方法将新元素添加到给定list的末尾。list.insert()方法允许你通过指定索引将新项目放置在任何位置。我可以使用索引0将新元素放在items的开始位置:
>>> items.insert(0, 'soda')
>>> items
['soda', 'sammiches', 'chips', 'ice cream']
我建议你阅读所有的list函数,这样你就可以了解这个数据结构有多强大。除了help(list)之外,你还可以在这里找到很多优秀的文档:docs.python.org/3/tutorial/datastructures.html。
3.3.3 列表索引
现在我们有一个list。我们知道如何使用len()来找出items列表中有多少个项目,现在我们需要知道如何获取列表的部分来格式化。
Python 中list的索引看起来与str的索引完全相同(图 3.3)。(这实际上让我有点不舒服,所以我倾向于想象一个str是一个字符的list,然后我感觉好一些。)

图 3.3 列表和字符串的索引是相同的。对于两者,你都是从 0 开始计数的,你也可以使用负数来从末尾索引。
Python 中的所有索引都是零偏移的,所以items的第一个元素在索引items[0]:
>>> items[0]
'soda'
如果索引是负数,Python 将从list的末尾开始反向计数。索引-1是list的最后一个元素:
>>> items[-1]
'ice cream'
当使用索引来引用list中的元素时,你应该非常小心。这是不安全的代码:
>>> items[10]
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
IndexError: list index out of range
警告 引用不存在的索引将导致异常。
你很快就会学会如何安全地迭代,或者遍历list,这样你就不必使用索引来获取元素。
3.3.4 列表切片
你可以通过使用list[start:stop]来从list中提取“切片”(子列表)。要获取前两个元素,你使用[0:2]。记住,2实际上是第三个元素的索引,但它不是包含的,如图 3.4 所示。
>>> items[0:2]
['soda', 'sammiches']

图 3.4 列表切片的stop值不包括在内。如果省略了stop值,切片将延伸到列表的末尾。
如果你省略了start,它将默认为0的值,所以以下行做的是同样的事情:
>>> items[:2]
['soda', 'sammiches']
如果你省略了stop,它将延伸到list的末尾:
>>> items[2:]
['chips', 'ice cream']
奇怪的是,切片使用不存在的列表索引是完全安全的。例如,我们可以要求从索引10到末尾的所有元素,即使索引10处没有任何内容。而不是异常,我们得到一个空的列表:
>>> items[10:]
[]
对于本章的练习,你需要将单词“and”插入到列表中,如果列表中有三个或更多元素。你能使用列表索引来完成这个操作吗?
3.3.5 在列表中查找元素
我们记得要带上薯片吗?
通常,你可能会想知道某个项目是否在列表中。index方法将返回元素在列表中的位置:
>>> items.index('chips')
2
注意,list.index()是不安全的代码,因为它如果参数不在列表中将会引发异常。看看如果我们检查一个烟雾机会发生什么:
>>> items.index('fog machine')
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
ValueError: 'fog machine' is not in list
你永远不应该使用list.index(),除非你首先验证元素是否存在。我们在第二章中使用x in y方法来检查一个字母是否在元音字符串中,也可以用于列表。如果x在y的集合中,我们得到一个True值:
>>> 'chips' in items
True
| 我希望它们是盐和醋味的薯片。如果元素不存在,相同的代码会返回False:
>>> 'fog machine' in items
False
我们需要与规划委员会谈谈。没有烟雾机,野餐会是什么样子?|
|
3.3.6 从列表中删除元素
list.pop()方法将删除并返回索引处的元素,如图 3.5 所示。默认情况下,它将删除最后一个项目(-1)。
>>> items.pop()
'ice cream'

图 3.5 list.pop()方法将从列表中删除一个元素。
如果我们查看items,我们会看到它现在比之前短了一个:
>>> items
['soda', 'sammiches', 'chips']
我们可以使用索引值来删除特定位置的元素。例如,我们可以使用0来删除第一个元素(见图 3.6):
>>> items.pop(0)
'soda'

图 3.6 你可以指定一个索引值给list.pop()以删除特定的元素。
现在items变得更短了:
>>> items
['sammiches', 'chips']
你也可以使用list.remove()方法来删除给定项目的第一个出现(见图 3.7):
>>> items.remove('chips')
>>> items
['sammiches']

图 3.7 list.remove()方法将删除与给定值匹配的元素。
警告:如果元素不存在,list.remove()方法将引发异常。
| 如果我们尝试使用items.remove()再次删除薯片,我们会得到一个异常:
>>> items.remove('chips')
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
ValueError: list.remove(x): x not in list
![]() |
|---|
所以,除非你已验证给定元素在列表中,否则不要使用此代码:
item = 'chips'
if item in items:
items.remove(item)
3.3.7 对列表进行排序和反转
如果使用--sorted标志调用我们的程序,我们需要对项目进行排序。你可能会在帮助文档中注意到,两个方法list.reverse()和list.sort()都强调它们是就地操作。这意味着列表本身将被反转或排序,而不会返回任何内容。所以,给定这个列表,
>>> items = ['soda', 'sammiches', 'chips', 'ice cream']
| items.sort()方法将返回空值:
>>> items.sort()
如果你检查items,你会看到项目已经按字母顺序排序:
>>> items
['chips', 'ice cream', 'sammiches', 'soda']
![]() |
|---|
与 list.sort() 一样,list.reverse() 调用不会返回任何内容:
>>> items.reverse()
但现在 items 的顺序是相反的:
>>> items
['soda', 'sammiches', 'ice cream', 'chips']
list.sort() 和 list.reverse()方法 容易与 sorted() 和 reversed()函数 混淆。sorted()函数 接受一个 list 作为参数并 返回 一个新的 list:
>>> items = ['soda', 'sammiches', 'chips', 'ice cream']
>>> sorted(items)
['chips', 'ice cream', 'sammiches', 'soda']
重要的是要注意,sorted() 函数 不会改变 给定的 list:
>>> items
['soda', 'sammiches', 'chips', 'ice cream']
注意,Python 会按 数值 对 list 进行排序,所以我们有这个优势,这很好:
>>> sorted([4, 2, 10, 3, 1])
[1, 2, 3, 4, 10]
警告:混合字符串和数字的 list 排序将导致异常!
>>> sorted([1, 'two', 3, 'four'])
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: '<' not supported between instances of 'str' and 'int'
list.sort()方法 是属于 list 的函数。它可以接受影响排序方式的参数。让我们查看 help(list.sort):
sort(self, /, *, key=None, reverse=False)
Stable sort *IN PLACE*.
这意味着我们也可以按相反顺序对 items 进行排序,如下所示:
>>> items.sort(reverse=True)
现在它们看起来是这样的:
>>> items
['soda', 'sammiches', 'ice cream', 'chips']
|
| reversed() 函数的工作方式略有不同:
>>> reversed(items)
<list_reverseiterator object at 0x10e012ef0>
我敢打赌你期望看到一个新的 list,其中的项目顺序相反。这是一个 Python 中 惰性 函数的例子。反转 list 的过程可能需要一段时间,所以 Python 正在显示它已经生成了一个 迭代器对象,当我们实际需要元素时,它将提供反转的 list。|
我们可以通过使用 list() 函数来评估迭代器,在 REPL 中查看我们的 reversed() 列表中的值:
>>> list(reversed(items))
['ice cream', 'chips', 'sammiches', 'soda']
与 sorted() 函数一样,原始的 items 保持不变:
>>> items
['soda', 'sammiches', 'chips', 'ice cream']
如果你使用 list.sort() 方法而不是 sorted() 函数,你可能会丢失你的数据。想象一下,你想要将 items 设置为排序后的 items 列表,如下所示:
>>> items = items.sort()
items 中现在有什么?如果你在 REPL 中打印 items,你不会看到任何有用的内容,所以检查 type():
>>> type(items)
<class 'NoneType'>
它不再是 list。我们将其设置为调用 items.sort() 方法的返回结果,这将 就地 改变 items 并返回 None。
如果你的程序中提供了 --sorted 标志,你需要对项目进行排序才能通过测试。你会使用 list.sort() 还是 sorted() 函数?
3.3.8 列表是可变的
正如你所看到的,我们可以很容易地改变 list。list.sort() 和 list.reverse() 方法改变整个列表,但你也可以通过索引引用来更改任何单个元素。也许我们应该通过用苹果替换薯片来使我们的野餐稍微健康一些:
>>> items
['soda', 'sammiches', 'chips', 'ice cream']
>>> if 'chips' in items: ①
... idx = items.index('chips') ②
... items[idx] = 'apples' ③
...
① 检查字符串 'chips' 是否在项目列表中。
② 将 'chips' 的索引分配给变量 idx。
③ 使用索引 idx 将元素更改为 'apples'。
让我们查看 items 以验证结果:
>>> items
['soda', 'sammiches', 'apples', 'ice cream']
| 我们也可以编写几个测试:
>>> assert 'chips' not in items ①
>>> assert 'apples' in items ②
① 确保菜单上不再有“chips”。② 检查我们现在是否有“apples”。 |
|
当有三个或更多项目时,你需要在最后一个元素之前将单词“and”放入你的列表中。你能用这个想法吗?
3.3.9 列表连接
在本章的练习中,你需要根据给定列表中的元素数量打印一个字符串。该字符串将在列表元素之间插入其他字符串,如逗号和空格(, ')。
以下语法将使用由逗号和空格组成的字符串连接列表:
>>> ', '.join(items)
'soda, sammiches, chips, ice cream'

之前的代码使用了 str.join() 方法并将 list 作为参数传递。这对我来说总感觉是反过来的,但就是这样。
str.join() 的结果是一个新的字符串:
>>> type(', '.join(items))
<class 'str'>
原始的 list 保持不变:
>>> items
['soda', 'sammiches', 'chips', 'apples']
我们可以用 Python 的 list 做很多事情,但这应该足以让你解决本章的问题。
3.4 使用 if/elif/else 的条件分支
你需要根据项目数量使用条件分支,以正确格式化输出。在第二章的练习中,有两个条件——要么是元音,要么不是——所以我们使用了 if/else 语句。这里我们有三个选项要考虑,所以你将不得不使用 elif(else-if)。
例如,假设我们想要通过三个选项来根据年龄对某人进行分类:
-
如果他们的年龄大于
0,则是有效的。 -
如果他们的年龄小于
18,他们是未成年人。 -
否则,他们 18 岁或以上,这意味着他们可以投票。
我们可以这样编写代码:
>>> age = 15
>>> if age < 0:
... print('You are impossible.')
... elif age < 18:
... print('You are a minor.')
... else:
... print('You can vote.')
...
You are a minor.
看看你是否能使用这个例子来弄清楚如何为 picnic.py 编写三个选项。首先编写处理一个项目的分支。然后编写处理两个项目的分支。接着编写处理三个或更多项目的最后一个分支。每次修改你的程序后都要运行测试。
3.4.1 写作时间
在你看我的解决方案之前,先自己写程序。这里有一些提示:
-
进入你的 03_picnic 目录并运行
new.pypicnic.py来创建你的程序。然后运行maketest(或pytest-xvtest.py)。你应该通过前两个测试。 -
接下来,努力让你的
--help使用说明看起来像本章前面展示的示例。正确定义你的参数非常重要。对于items参数,查看argparse中的nargs,如附录 A.4.5 节所述。 -
如果你使用 new.py 来启动你的程序,确保保留布尔标志并修改你的
sorted标志。 -
按顺序解决测试!首先处理一个项目,然后处理两个项目,接着处理三个。然后处理排序后的项目。
在你阅读解决方案之前,尝试编写程序并通过测试,这将让你从这本书中获得最大的收益!
3.5 解决方案
这是一种满足测试的方法。如果你写了不同的代码并且通过了,那也很好!
#!/usr/bin/env python3
"""Picnic game"""
import argparse
# --------------------------------------------------
def get_args(): ①
"""Get command-line arguments"""
parser = argparse.ArgumentParser(
description='Picnic game',
formatter_class=argparse.ArgumentDefaultsHelpFormatter)
parser.add_argument('item', ②
metavar='str',
nargs='+',
help='Item(s) to bring')
parser.add_argument('-s', ③
'--sorted',
action='store_true',
help='Sort the items')
return parser.parse_args() ④
# --------------------------------------------------
def main(): ⑤
"""Make a jazz noise here"""
args = get_args() ⑥
items = args.item ⑦
num = len(items) ⑧
if args.sorted: ⑨
items.sort() ⑩
bringing = '' ⑪
if num == 1: ⑫
bringing = items[0] ⑫
elif num == 2: ⑬
bringing = ' and '.join(items) ⑬
else: ⑭
items[-1] = 'and ' + items[-1] ⑭
bringing = ', '.join(items) ⑮
print('You are bringing {}.'.format(bringing)) ⑯
# --------------------------------------------------
if __name__ == '__main__': ⑰
main() ⑰
① 将 get_args() 函数放在前面,这样我们就可以在阅读时轻松地看到程序接受的内容。请注意,这里的函数顺序对 Python 来说并不重要,只对我们读者来说重要。
② 项目参数使用 nargs='+',这样它将接受一个或多个位置参数,这些参数将是字符串。
③ 短名(-s)和长名(--sorted)中的破折号使这成为一个选项。此参数没有关联的值。它要么存在(在这种情况下为 True),要么不存在(False)。
④ 处理命令行参数并将它们返回给调用者。
⑤ main()函数是程序开始的地方。
⑥ 调用 get_args()函数并将返回值放入变量 args 中。如果解析参数时出现问题,程序将在返回值之前失败。
⑦ 将 args 中的项目列表复制到新变量 items 中。
⑧ 使用 len()函数获取列表中的项目数量。由于我们使用 nargs='+’定义了参数,所以项目数量永远不会为零。
⑨ args.sorted 的值将是 True 或 False。
⑩ 如果我们需要排序项目,调用 items.sort()方法就地排序它们。
⑪ 使用空字符串初始化一个变量来保存我们要带来的项目。
⑫ 如果项目数量为 1,我们将一个项目分配给 bringing。
⑬ 如果项目数量为 2,在项目之间插入字符串'和'。
⑭ 否则,将 items 中的最后一个元素更改为在现有内容之前追加字符串'和'。
⑮ 将项目用逗号和空格连接起来。
⑯ 使用 str.format()方法将变量插入到输出字符串中。
⑰ 当 Python 运行程序时,它将读取到这一点的所有行,但不会运行任何内容。在这里我们查看我们是否在“main”命名空间中。如果是,我们调用 main()函数使程序开始。
3.6 讨论
怎么样?写你的版本花了很长时间吗?它与我的版本有多大的不同?让我们谈谈我的解决方案。如果你的解决方案与我的不同,只要通过测试就可以。
3.6.1 定义参数
此程序可以接受可变数量的参数,这些参数都是同一类型(字符串)。在我的get_args()方法中,我定义了一个item如下:
parser.add_argument('item', ①
metavar='str', ②
nargs='+', ③
help='Item(s) to bring') ④
① 调用参数
② 在使用说明中对用户指示这是一个字符串
③ 参数的数量,其中“+”表示一个或多个
④ 较长的帮助描述,在-h 或--help 选项中显示
此程序还接受-s和--sorted参数。它们是“标志”,通常意味着如果它们存在则为 True,不存在则为 False。记住,前面的破折号使它们成为可选的。
parser.add_argument('-s', ①
'--sorted', ②
action='store_true', ③
help='Sort the items') ④
① 短标志名
② 长标志名
③ 如果标志存在,存储 True 值。默认值将是 False。
④ 较长的帮助描述
3.6.2 分配和排序项目
在main()函数中,我调用get_args()来获取参数,并将它们分配给args变量。然后我创建items变量来保存args.item值:
def main():
args = get_args()
items = args.item
如果args.sorted为 True,我需要排序items。在这里我选择了就地sort方法:
if args.sorted:
items.sort()
现在我有了项目,如果需要,已经排序,我需要将它们格式化以供输出。
3.6.3 格式化项目
我建议你按顺序解决测试。我们需要解决四个条件:
-
没有项目
-
一个项目
-
两个项目
-
三个或更多项目
第一个测试实际上是由argparse处理的--如果用户没有提供任何参数,他们会收到一个用法信息:
$ ./picnic.py
usage: picnic.py [-h] [-s] str [str ...]
picnic.py: error: the following arguments are required: str
由于argparse处理了没有参数的情况,我们必须处理其他三种情况。这里有一个这样做的方法:
bringing = '' ①
if num == 1: ②
bringing = items[0] ③
elif num == 2: ④
bringing = ' and '.join(items) ⑤
else: ⑥
items[-1] = 'and ' + items[-1] ⑦
bringing = ', '.join(items) ⑧
① 初始化一个变量来表示我们要带什么。
② 检查项目数量是否为 1。
③ 如果只有一个项目,那就带这一个项目。
④ 检查项目数量是否为 2。
⑤ 如果有两个项目,我们将项目连接在字符串' and '上。
⑥ 否则...
⑦ 在最后一个项目之前插入字符串'and '。
⑧ 将所有项目连接在字符串', '上。
你能想出其他做这件事的方法吗?
3.6.4 打印项目
最后,为了print()输出,我使用了一个格式化字符串,其中{}表示一个值的占位符,如下所示:
>>> print('You are bringing {}.'.format(bringing))
You are bringing salad, soda, and cupcakes.
如果你愿意,可以使用一个f''-字符串:
>>> print(f'You are bringing {bringing}.')
You are bringing salad, soda, and cupcakes.
他们都完成了工作。
3.7 进一步了解
-
添加一个选项,让用户可以选择不使用牛津逗号(尽管这是一个在道德上无法辩护的选择)。
-
添加一个选项,允许用户使用传递给用户的字符(如如果项目列表需要包含逗号,则使用分号)来分隔项目。
确保在 test.py 程序中添加测试以确保你的新功能是正确的!
摘要
-
Python 列表是有序的 Python 数据类型的序列,如字符串和数字。
-
有像
list.append()和list.extend()这样的方法可以向list中添加元素。使用list.pop()和list.remove()来删除元素。 -
你可以使用
x in y来询问元素x是否在列表y中。你也可以使用list.index()来查找元素的索引,但如果元素不存在,这将引发异常。 -
列表可以排序和反转,列表内的元素可以修改。当元素的顺序很重要时,列表很有用。
-
字符串和列表共享许多特性,例如使用
len()来找到它们的长度,使用基于 0 的索引,其中0是第一个元素,-1是最后一个,以及使用切片从整体中提取较小的部分。 -
str.join()方法可以用来从一个list中创建一个新的str。 -
if/elif/else可以根据条件分支代码。
4 跳过五:与字典一起工作
“当我起床时,没有什么能让我沮丧。”
--D. L. Roth
| 在电视剧《火线》的一集中,毒品贩子认为警察正在拦截他们的短信。在犯罪阴谋过程中需要发送电话号码时,贩子会混淆这个号码。他们使用一个我们称之为“跳过五”的算法,因为如果你跳过 5,每个数字都会变成美国电话键盘另一侧的配对数字。在这个练习中,我们将讨论如何使用这个算法加密消息,然后我们将看到如何使用它来解密加密的消息,你明白吗? | ![]() |
|---|
如果我们从 1 按钮开始跳过 5,我们就会到达 9。6 跳过 5 变成 4,以此类推。数字 5 和 0 会相互交换。
在这个练习中,我们将编写一个名为 jump.py 的 Python 程序,它将接受一些文本作为位置参数。文本中的每个数字都将使用此算法进行编码。所有非数字文本将保持不变。这里有一些例子:
$ ./jump.py 867-5309
243-0751
$ ./jump.py 'Call 1-800-329-8044 today!'
Call 9-255-781-2566 today!
你需要某种方式来检查输入文本中的每个字符以识别数字--你将学习如何使用 for 循环来做这件事。然后你将看到如何将 for 循环重写为“列表推导式”。你需要某种方式将像 1 这样的数字与 9 这样的数字关联起来,等等--你将学习到 Python 中称为“字典”的数据结构,它将允许你做到这一点。
在本章中,你将学习到
-
创建一个字典
-
使用
for循环和列表推导式处理文本,逐字符处理 -
检查字典中是否存在项
-
从字典中检索值
-
打印一个新的字符串,用编码后的值替换数字
在我们开始编写代码之前,你需要了解 Python 的字典。
4.1 字典
| Python 字典允许我们将一些“东西”(一个“键”)与另一些“东西”(一个“值”)相关联。实际的字典就是这样做的。如果我们查找像“quirky”这样的词在字典中(www.merriam-webster.com/dictionary/ quirky),我们可以找到一个定义,如图 4.1 所示。我们可以将这个词本身视为“键”,将定义视为“值”。 | ![]() |
|---|

图 4.1 你可以通过在字典中查找来找到单词的定义。
字典实际上提供了关于单词的很多信息,例如发音、词性、派生词、历史、同义词、变体拼写、词源、首次使用等。(我真的喜欢字典。)每个属性都有一个值,因此我们也可以将单词的字典条目视为另一个“字典”(见图 4.2)。

图 4.2 “quirky”的条目可以包含比单个定义更多的内容。
让我们看看如何使用 Python 的字典来超越单词定义。
4.1.1 创建字典
| 在电影《蒙提·派森与圣杯》中,亚瑟王和他的骑士们必须穿越死亡之桥。任何想要过桥的人都必须正确回答守护者的三个问题。那些失败的人将被投入永恒的深渊。 | ![]() |
|---|
让我们骑马前往卡美洛……不,抱歉,让我们创建并使用一个字典来跟踪问题和答案作为键/值对。再次提醒,我希望你打开你的python3或 IPython 交互式解释器或 Jupyter 笔记本,并亲自输入以下内容。
兰斯洛特先开始。我们可以使用dict()函数为他创建一个空的答案字典。
>>> answers = dict()
或者我们可以使用空的花括号(两种方法等效):
>>> answers = {}
| 守护者的第一个问题是,“你叫什么名字?”兰斯洛特回答,“我叫卡美洛的兰斯洛特爵士。”我们可以通过使用方括号([]--不是花括号!)和字面字符串'name'来将键'name'添加到answers字典中:
>>> answers['name'] = 'Sir Lancelot'
![]() |
|---|

图 4.3 字典被打印在花括号内。键与值之间用冒号分隔。
如果你输入answers并在交互式解释器中按 Enter 键,Python 将显示一个花括号结构(见图 4.3),以指示这是一个dict:
>>> answers
{'name': 'Sir Lancelot'}
你可以使用type()函数来验证这一点:
>>> type(answers)
<class 'dict'>
接下来,守护者问道,“你的任务是做什么?”兰斯洛特回答,“寻找圣杯。”让我们将“quest”添加到answers中:
>>> answers['quest'] = 'To seek the Holy Grail'
| 没有返回值来告诉我们发生了什么,所以再次输入answers来检查变量,以确保新键/值已添加:
>>> answers
{'name': 'Sir Lancelot', 'quest': 'To seek theHoly Grail'}
最后,守护者问道,“你最喜欢的颜色是什么?”兰斯洛特回答,“蓝色。” |
|
>>> answers['favorite_color'] = 'blue'
>>> answers
{'name': 'Sir Lancelot', 'quest': 'To seek the Holy Grail', 'favorite_color': 'blue'}
注意我使用的是“favorite_color”(带下划线)作为键,但我可以使用“favorite color”(带空格)或“FavoriteColor”或“Favorite color,”但每个都会是一个单独且不同的字符串,或键。我更喜欢使用 PEP 8 命名约定来为字典键、变量和函数命名。PEP 8,即“Python 代码风格指南”(www.python.org/dev/peps/pep-0008/),建议使用小写名称,单词之间用下划线分隔。
如果你知道所有答案,你可以使用dict()函数并按照以下语法创建answers,其中你不需要引用键,键与值之间用等号分隔:
>>> answers = dict(name='Sir Lancelot', quest='To seek the Holy Grail', favorite_color='blue')
或者你可以使用以下语法,使用花括号{},其中键必须引用,并且它们后面跟着一个冒号(:):
>>> answers = {'name': 'Sir Lancelot', 'quest': 'To seek the Holy Grail', 'favorite_color': 'blue'}
将answers字典想象成一个盒子,里面装着键/值对,描述了兰斯洛特的答案(见图 4.4),就像“古怪”字典一样,它包含了关于那个单词的所有信息。

图 4.4 就像“古怪”的字典条目一样,Python 字典可以包含许多键/值对。
4.1.2 访问字典值
要检索值,您使用方括号内的键名。例如,您可以这样获取name:
>>> answers['name']
'Sir Lancelot'
| 让我们请求他的“年龄”:
>>> answers['age']
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
KeyError: 'age'
如您所见,如果您请求一个不存在的字典键,将会引发一个异常!|
|
就像字符串和列表一样,您可以使用x in y来首先检查键是否存在于dict中:
>>> 'quest' in answers
True
>>> 'age' in answers
False
|
| dict.get()方法是一种安全的方式来请求一个值:
>>> answers.get('quest')
'To seek the Holy Grail'
当请求的键不在dict中时,它将返回特殊值None:
>>> answers.get('age')
|
这不会打印任何内容,因为 REPL 不会打印None,但我们可以检查type()。注意,None的类型是NoneType:
>>> type(answers.get('age'))
<class 'NoneType'>
| 您可以向dict.get()传递一个可选的第二个参数,该参数是如果键不存在时返回的值:
>>> answers.get('age', 'NA')
'NA'
这对于解决方案来说很重要,因为我们只需要表示 0-9 这些字符。|
|
4.1.3 其他字典方法
如果您想知道字典“有多大”,dict上的len()(长度)函数会告诉您有多少键/值对:
>>> len(answers)
3
dict.keys()方法将只给出键:
>>> answers.keys()
dict_keys(['name', 'quest', 'favorite_color'])
而dict.values()将只给出值:
>>> answers.values()
dict_values(['Sir Lancelot', 'To seek the Holy Grail', 'blue'])
经常我们希望两者都有,所以您可能会看到这样的代码:
>>> for key in answers.keys():
... print(key, answers[key])
...
name Sir Lancelot
quest To seek the Holy Grail
favorite_color blue
更简单的方法是使用dict.items()方法,它将返回一个包含每个键/值对的新list:
>>> answers.items()
dict_items([('name', 'Sir Lancelot'), ('quest', 'To seek the Holy Grail'),
('favorite_color', 'blue')])
前面的for循环也可以使用dict.items()方法来编写:
>>> for key, value in answers.items(): ①
... print(f'{key:15} {value}') ②
...
name Sir Lancelot
quest To seek the Holy Grail
favorite_color blue
① 将每个键/值对解包到变量 key 和 value 中(见图 4.5)。注意,您不必称它们为 key 和 value。您可以使用 k 和 v 或问题与答案。
② 在 15 个字符宽的左对齐字段中打印键。值按正常方式打印。

图 4.5 我们可以将dict.items()返回的键/值对解包到变量中。
在 REPL 中,您可以执行help(dict)来查看所有可用的方法,如dict.pop(),它删除一个键/值,或dict.update(),它将一个字典与另一个合并。
提示:dict中的每个键都是唯一的。
这意味着如果您为给定的键设置两次值,
>>> answers = {}
>>> answers['favorite_color'] = 'blue'
>>> answers
{'favorite_color': 'blue'}
您将不会有两个条目,而是一个带有第二个值的条目:
>>> answers['favorite_color'] = 'red'
>>> answers
{'favorite_color': 'red'}
键不必是字符串--您也可以使用像int和float这样的数字类型。您使用的任何值都必须是不可变的。例如,列表不能使用,因为它们是可变的,就像您在上一章中看到的。随着我们进一步学习,您将了解哪些类型是不可变的。
4.2 编写 jump.py
现在让我们开始编写我们的程序。你需要在 04_jump_the_five 目录下创建一个名为 jump.py 的程序,这样你就可以使用那里的 test.py 了。图 4.6 显示了输入和输出的示意图。请注意,你的程序只会影响文本中的数字。任何不是数字的内容都将保持不变。

图 4.6 jump.py 程序的字符串图。输入文本中的任何数字都将更改为输出文本中的相应数字。
当你的程序没有参数、-h或--help时,它应该打印一个使用消息:
$ ./jump.py -h
usage: jump.py [-h] str
Jump the Five
positional arguments:
str Input text
optional arguments:
-h, --help show this help message and exit
注意,我们将处理“数字”的文本表示,所以字符串'1'将被转换为字符串'9'。我们不会将实际的整数值1更改为整数值9。在确定如何在表 4.1 中表示替换时,请记住这一点。
表 4.1 文本中数字字符的编码表
|
1 => 9
2 => 8
3 => 7
4 => 6
5 => 0
6 => 4
7 => 3
8 => 2
9 => 1
0 => 5
![]() |
|---|
你会如何使用dict来表示这个?尝试在 REPL 中创建一个名为jumper的dict,包含前面的键/值对,然后看看以下assert语句是否会抛出异常。记住,如果语句为True,assert将返回无内容。
>>> assert jumper['1'] == '9'
>>> assert jumper['5'] == '0'
接下来,你需要一种方法来遍历每个字符。我建议你使用一个for循环,如下所示:
>>> for char in 'ABC123':
... print(char)
...
A
B
C
1
2
3
而不是打印char,打印jumper表中char的值,或者打印char本身。查看dict.get()方法!另外,如果你阅读了help(print),你会看到有一个end选项可以替换粘附在末尾的换行符。
这里有一些其他的提示:
-
数字可以出现在文本的任何位置,所以我建议你使用
for循环逐个字符处理输入。 -
给定任意一个字符,你如何在你的表中查找它?
-
如果字符在你的表中,你如何获取其值(即翻译)?
-
如何在不打印换行的情况下
print()翻译或值?查看 REPL 中的help(print)来了解print()的选项。 -
如果你阅读了 Python 的
str类的help(str),你会看到有一个str.replace()方法。你能使用它吗?
在查看解决方案之前,先花时间自己编写程序。使用测试来指导你。
4.3 解决方案
这里有一个满足测试的解决方案。在讨论这个第一个版本之后,我会展示一些变体。
#!/usr/bin/env python3
"""Jump the Five"""
import argparse
# --------------------------------------------------
def get_args(): ①
"""Get command-line arguments"""
parser = argparse.ArgumentParser(
description='Jump the Five',
formatter_class=argparse.ArgumentDefaultsHelpFormatter)
parser.add_argument('text', metavar='str', help='Input text') ②
return parser.parse_args()
# --------------------------------------------------
def main(): ③
"""Make a jazz noise here"""
args = get_args() ④
jumper = {'1': '9', '2': '8', '3': '7', '4': '6', '5': '0', ⑤
'6': '4', '7': '3', '8': '2', '9': '1', '0': '5'}
for char in args.text: ⑥
print(jumper.get(char, char), end='') ⑦
print() ⑧
# --------------------------------------------------
if __name__ == '__main__':
main() ⑨
① 首先定义 get_args()函数,这样我在阅读程序时容易找到。
② 定义一个名为“text”的位置参数。
③ 定义一个名为 main()的主函数,程序从这里开始。
④ 从 get_args()获取命令行参数。
⑤ 创建一个查找表字典。
⑥ 处理输入文本中的每个字符。
⑦ 打印“jumper”表中的字符值或字符本身。将“end”值更改为 print()以避免添加换行符。
⑧ 在处理完字符后打印一个换行符。
⑨ 如果程序在“main”命名空间中,则调用main()函数。
4.4 讨论
让我们将这个程序分解成大的概念,比如我们如何定义参数,定义和使用字典,处理输入文本,以及打印输出。
4.4.1 定义参数
如同往常,首先定义get_args()函数。程序需要定义一个位置参数。由于我期望一些“文本”,我将参数命名为'text',然后将其分配给一个名为text的变量:
parser.add_argument('text', metavar='str', help='Input text')
虽然这看起来相当明显,但我认为给事物命名以反映其本质非常重要。也就是说,请不要将参数的名称保留为'positional'--这并不能描述它是什么。
使用argparse处理这样一个简单的程序可能看起来有些过度,但它处理了正确数量和类型的参数验证以及生成帮助文档,所以这是值得努力的。
4.4.2 使用字典进行编码
我建议你可以将替换表表示为一个dict,其中每个数字key在dict中都有其对应的value。例如,我知道如果我从 1 跳过 5,我应该落在 9 上:
>>> jumper = {'1': '9', '2': '8', '3': '7', '4': '6', '5': '0',
... '6': '4', '7': '3', '8': '2', '9': '1', '0': '5'}
>>> jumper['1']
'9'
由于只有 10 个数字需要编码,这可能是编写它的最简单方法。注意,数字被引号包围,因此它们实际上是str类型,而不是int(整数)。我这样做是因为我将从str中读取字符。如果我将它们作为实际数字存储,我必须使用int()函数强制转换str类型:
>>> type('4')
<class 'str'>
>>> type(4)
<class 'int'>
>>> type(int('4'))
<class 'int'>
4.4.3 以各种方式处理一系列项目
正如你之前看到的,Python 中的字符串和列表在索引方式上很相似。字符串和列表本质上都是元素的序列--字符串是字符的序列,而列表可以是任何东西的序列。
处理任何项目序列(在这里是字符串中的字符)有几种不同的方法。
方法 1:使用 for 循环打印()每个字符
正如我在介绍中建议的,我们可以使用for循环处理text中的每个字符。首先,我可能首先检查文本中的每个字符是否在jumper表中,使用x in y结构:
>>> text = 'ABC123'
>>> for char in text:
... print(char, char in jumper)
...
A False
B False
C False
1 True
2 True
3 True
注意 当print()被赋予多个参数时,它将在每段文本之间放置一个空格。您可以使用sep参数来更改这一点。阅读help(print)以了解更多信息。
现在,让我们尝试翻译这些数字。我可以使用一个if表达式,如果char存在,则从jumper表中打印值,否则打印char:
>>> for char in text:
... print(char, jumper[char] if char in jumper else char)
...
A A
B B
C C
1 9
2 8
3 7
检查每个字符确实有些费劲,但这是必要的,因为例如,字母“A”不在jumper中。如果我尝试检索该值,我会得到一个异常:
>>> jumper['A']
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
KeyError: 'A'
dict.get() 方法允许我在键存在时安全地请求一个值。请求 “A” 不会产生异常,但也不会在 REPL 中显示任何内容,因为它返回 None 值:
>>> jumper.get('A')
如果我们尝试 print() 值,会更容易看到:
>>> for char in text:
... print(char, jumper.get(char))
...
A None
B None
C None
1 9
2 8
3 7
我可以向 dict.get() 提供第二个可选参数,即当键不存在时返回的默认值。在这个程序中,当字符不在 jumper 中时,我想打印该字符本身。例如,如果我有 “A”,我想打印 “A”:
>>> jumper.get('A', 'A')
'A'
但如果我有 “5”,我想打印 “0”:
>>> jumper.get('5', '5')
'0'
我可以用它来处理所有字符:
>>> for char in text:
... print(jumper.get(char, char))
...
A
B
C
9
8
7
我不希望在每个字符后打印换行符,所以我可以使用 end='' 来告诉 Python 在 end 处放置空字符串而不是换行符。
当我在 REPL 中运行这段代码时,输出看起来会有些奇怪,因为我必须按下 Enter 键来运行 for 循环。然后我会剩下 ABC987,没有换行符,然后是 >>> 提示符:
>>> for char in text:
... print(jumper.get(char, char), end='')
...
ABC987>>>
在你的代码中,你将不得不添加另一个 print()。
你可以改变在 end 处添加的内容,并且你可以不带参数 print() 来打印换行符。print() 还可以做很多其他非常酷的事情,所以我鼓励你阅读 help(print) 并尝试它们。
方法 2:使用 for 循环构建新的字符串
你可以以几种其他方式解决这个问题。虽然探索我们可以用 print() 做的所有事情很有趣,但那段代码有点丑陋。我认为创建一个 new_text 变量并使用它来调用一次 print() 会更干净:
def main():
args = get_args()
jumper = {'1': '9', '2': '8', '3': '7', '4': '6', '5': '0',
'6': '4', '7': '3', '8': '2', '9': '1', '0': '5'}
new_text = '' ①
for char in args.text: ②
new_text += jumper.get(char, char) ③
print(new_text) ④
① 创建一个空的 new_text 变量。
② 使用相同的 for 循环。
③ 将编码后的数字或原始字符追加到 new_text 中。
④ 打印新的文本。
在这个版本中,我首先将 new_text 设置为空字符串:
>>> new_text = ''
我使用相同的 for 循环来处理 text 中的每个字符。每次循环迭代,我使用 += 将等式的右侧追加到左侧。+= 将右侧的值添加到左侧的变量中:
>>> new_text += 'a'
>>> assert new_text == 'a'
>>> new_text += 'b'
>>> assert new_text == 'ab'
在右侧,我使用 jumper.get() 方法。每个字符将被追加到 new_text 中,如图 4.7 所示。
>>> new_text = ''
>>> for char in text:
... new_text += jumper.get(char, char)
...

图 4.7 += 运算符将右侧的字符串追加到左侧的变量中。
现在,我可以使用新值调用一次 print()。
>>> print(new_text)
ABC987
方法 3:使用 for 循环构建新的列表
这种方法与前面的一种相同,但 new_text 不是 str,而是一个 list:
def main():
args = get_args()
jumper = {'1': '9', '2': '8', '3': '7', '4': '6', '5': '0',
'6': '4', '7': '3', '8': '2', '9': '1', '0': '5'}
new_text = [] ①
for char in args.text: ②
new_text.append(jumper.get(char, char)) ③
print(''.join(new_text)) ④
① 将 new_text 初始化为一个空列表。
② 遍历文本中的每个字符。
③ 将 jumper.get() 调用的结果追加到 new_text 变量中。
④ 使用空字符串连接 new_text 来创建一个新的字符串以打印。
在我们阅读这本书的过程中,我会不断地提醒你 Python 如何相似地处理字符串和列表。在这里,我使用new_text与之前完全相同,从一个空的结构开始,然后为每个字符使其更长。实际上,我可以使用完全相同的+=语法而不是list.append()方法:
for char in args.text:
new_text += jumper.get(char, char)
在for循环完成后,我将所有需要重新组合的新字符使用str.join()放入一个新的字符串中,然后我可以print()它。
方法 4:将 for 循环转换为列表推导式
一个更简短的解决方案使用列表推导式,它基本上是一个位于方括号[]中的一行for循环,它会产生一个新的list(见图 4.8)。
def main():
args = get_args()
jumper = {'1': '9', '2': '8', '3': '7', '4': '6', '5': '0',
'6': '4', '7': '3', '8': '2', '9': '1', '0': '5'}
print(''.join([jumper.get(char, char) for char in args.text]))

图 4.8 列表推导式将生成一个新list,其中包含使用for语句迭代的结果。
列表推导式是从for循环反向读取的,但它包含了所有内容。它只有一行代码,而不是四行!
>>> text = '867-5309'
>>> [jumper.get(char, char) for char in text]
['2', '4', '3', '-', '0', '7', '5', '1']
你可以使用str.join()在空字符串上,将那个list转换成一个新的字符串,然后你可以print()它:
>>> print(''.join([jumper.get(char, char) for char in text]))
243-0751
列表推导式的目的是创建一个新的列表,这是我们之前使用for循环代码试图做到的。列表推导式更有意义,并且使用的代码行数更少。
方法 5:使用 str.translate()函数
最后一种方法使用str类中的一个非常强大的方法来一步改变所有字符:
def main():
args = get_args()
jumper = {'1': '9', '2': '8', '3': '7', '4': '6', '5': '0',
'6': '4', '7': '3', '8': '2', '9': '1', '0': '5'}
print(args.text.translate(str.maketrans(jumper)))
str.translate()的参数是一个转换表,它描述了每个字符应该如何被转换。这正是jumper所做的事情。
>>> text = 'Jenny = 867-5309'
>>> text.translate(str.maketrans(jumper))
'Jenny = 243-0751'
我将在第八章中更详细地解释这一点。
4.4.4 (不)使用 str.replace()
我之前问过你是否可以使用str.replace()来改变所有的数字。结果发现你不能,因为你将改变一些值两次,最终它们会回到原始值。
看看我们是如何从这个字符串开始的:
>>> text = '1234567890'
当你将“1”改为“9”时,现在你有两个 9:
>>> text = text.replace('1', '9')
>>> text
'9234567890'
这意味着当你尝试将所有的 9 变成 1 时,你最终会得到两个 1。第一个位置上的 1 变成了 9,然后又变回 1:
>>> text = text.replace('9', '1')
>>> text
'1234567810'
所以如果你逐个数字地遍历“1234567890”并尝试使用str.replace()来改变它们,你最终会得到值“1234543215”:
>>> text = '1234567890'
>>> for n in jumper.keys():
... text = text.replace(n, jumper[n])
...
>>> text
'1234543215'
但正确编码的字符串是“9876043215”。str.translate()函数存在就是为了一步改变所有值,同时不改变不变的字符。
4.5 进一步学习
-
尝试创建一个类似的程序,用字符串来编码数字(例如,“5”变成“five”,“7”变成“seven”)。务必在
test.py中编写必要的测试来检查你的工作! -
如果将程序的输出反馈到程序中会发生什么?例如,如果你运行
./jump.py12345,你应该得到98760。如果你运行./jump.py98760,你能恢复原始数字吗?这被称为round-tripping,这是编码和解码文本的算法中常见的操作。
摘要
-
您可以使用
dict()函数或使用空的花括号 ({}) 来创建一个新的字典。 -
您可以通过在方括号内使用键或使用
dict.get()方法来检索字典的值。 -
对于名为
x的dict,您可以使用'key'inx来判断一个键是否存在。 -
您可以使用
for循环遍历str的字符,就像您遍历list的元素一样。您可以将字符串视为字符的列表。 -
print()函数接受可选的关键字参数,如end='',您可以使用它来在屏幕上打印一个值而不换行。
5 Howler:处理文件和标准输出
| 在《哈利·波特》故事中,“Howler”是一种通过猫头鹰送至霍格沃茨的恶毒信件。它会自己撕裂,对着收件人喊出刺耳的信息,然后燃烧。在这个练习中,我们将编写一个程序,将文本转换成一个相当温和的 Howler 版本,通过将所有字母转换为大写。我们将处理的文本将作为单个位置参数给出。 | ![图片 5-unnumb-1.png] |
|---|
例如,如果我们的程序接收到输入,“你怎么敢偷那辆车!”它应该尖叫回“你怎么敢偷那辆车!”记住,命令行上的空格分隔参数,因此多个单词需要用引号括起来才能被视为一个参数:
|
$ ./howler.py 'How dare you steal that car!'
HOW DARE YOU STEAL THAT CAR!
程序的参数也可能指定一个文件,在这种情况下,我们需要读取文件作为输入:
$ ./howler.py ../inputs/fox.txt
THE QUICK BROWN FOX JUMPS OVER THE LAZY DOG.
| ![图片 5-unnumb-2.png] |
|---|
| 我们的程序还将接受一个 -o 或 --outfile 选项,指定一个输出文件,输出文本应写入该文件。在这种情况下,命令行上不会打印任何内容:
$ ./howler.py -o out.txt 'How dare you steal that car!'
| ![图片 5-unnumb-3.png] |
|---|
现在应该有一个名为 out.txt 的文件,其中包含以下输出:
$ cat out.txt
HOW DARE YOU STEAL THAT CAR!
在这个练习中,你将学习到
-
从命令行或文件接受文本输入
-
将字符串转换为大写
-
将输出打印到命令行或需要创建的文件
-
使纯文本表现得像文件句柄
5.1 读取文件
| 这是我们的第一个涉及读取文件的练习。程序的参数将是某些文本,这些文本可能指定一个输入文件,在这种情况下,您将打开并读取该文件。如果文本不是文件名,您将使用文本本身。 | ![图片 5-unnumb-4.png] |
|---|
内置的 os(操作系统)模块有一个用于检测字符串是否为文件名的方法。要使用它,您必须导入 os 模块。例如,您的系统可能没有名为“blargh”的文件:
>>> import os
>>> os.path.isfile('blargh')
False
os 模块包含大量有用的子模块和函数。请参阅 docs.python.org/3/library/os.html 的文档或使用 REPL 中的 help(os)。
例如,os.path.basename() 和 os.path.dirname() 可以分别从路径中返回文件名或目录(见图 5.1):
>>> file = '/var/lib/db.txt'
>>> os.path.dirname(file)
'/var/lib'
>>> os.path.basename(file)
'db.txt'

图 5.1 os 模块包含一些方便的函数,如 os.path.dirname() 和 os.path.basename(),用于获取文件路径的部分。
在 GitHub 源代码仓库的最高级别中,有一个名为“inputs”的目录,其中包含我们将用于许多练习的几个文件。这里我将使用一个名为 inputs/fox.txt 的文件。请注意,为了使此操作生效,您需要位于仓库的主目录中。
>>> file = 'inputs/fox.txt'
>>> os.path.isfile(file)
True
一旦你确定参数是文件名,你必须open()它来read()它。open()的返回值是一个文件句柄。我通常将这个变量命名为fh,以提醒我它是一个文件句柄。如果我有多个打开的文件句柄,比如输入和输出句柄,我可能将它们命名为in_fh和out_fh。
>>> fh = open(file)
注意:根据 PEP 8(www.python.org/dev/peps/pep-0008/#function-and-variable-names),函数和变量“名称应该是小写,必要时用下划线分隔单词以提高可读性。”
如果你尝试open()一个不存在的文件,你会得到一个异常。这是不安全的代码:
>>> file = 'blargh'
>>> open(file)
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
FileNotFoundError: [Errno 2] No such file or directory: 'blargh'
总是检查文件是否存在!
>>> file = 'inputs/fox.txt'
>>> if os.path.isfile(file):
... fh = open(file)
我们将使用fh.read()方法来获取文件的内容。把文件想象成一个番茄罐可能会有所帮助。文件名,如“inputs/fox.txt”,是罐子的标签,这与内容不同。要获取里面的文本(或“番茄”),我们需要打开罐子。
看看图 5.2:
-
文件句柄(
fh)是我们用来获取文件内容的一种机制。要获取番茄,我们需要打开罐子。 -
fh.read()方法返回file内的内容。打开罐头后,我们可以获取内容。 -
一旦文件句柄被读取,就没有什么剩余了。

图 5.2 文件有点像番茄罐。我们必须先打开它,这样我们才能读取它,之后文件句柄就耗尽了。
注意:你可以使用fh.seek(0)将文件句柄重置到开始位置,如果你真的想再次读取它。
让我们看看fh的类型:
>>> type(fh)
<class '_io.TextIOWrapper'>
在计算机术语中,“io”代表“输入/输出”。fh对象是处理 I/O 操作的东西。你可以使用help(fh)(使用变量的名称本身)来阅读关于class TextIOWrapper的文档。
你将非常频繁使用的两种方法是read()和write()。现在,我们关注read()。让我们看看它给我们带来了什么:
>>> fh.read()
'The quick brown fox jumps over the lazy dog.\n'
| 请帮我把那一行再执行一次。你看到了什么?
>>> fh.read()
''
文件句柄与str这样的东西不同。一旦你读取了文件句柄,它就是空的。它就像把番茄从罐头倒出来一样。现在罐子是空的,你不能再次倒空它。|
|
我们实际上可以通过将那些方法链接在一起,将open()和fh.read()压缩成一行代码。open()方法返回一个文件句柄,可以用于fh.read()的调用(见图 5.3)。运行这个:
>>> open(file).read()
'The quick brown fox jumps over the lazy dog.\n'

图 5.3 open()函数返回一个文件句柄,因此我们可以将其链接到read()的调用。
现在再次运行它:
>>> open(file).read()
'The quick brown fox jumps over the lazy dog.\n'
每次你open()文件时,你都会得到一个全新的文件句柄来read()。
如果我们想保留内容,我们需要将它们复制到一个变量中。
>>> text = open(file).read()
>>> text
'The quick brown fox jumps over the lazy dog.\n'
结果的type()是str:
>>> type(text)
<class 'str'>
如果你愿意,可以将任何str方法链接到末尾。例如,你可能想删除尾随的换行符。str.rstrip()方法将从字符串的右侧删除任何空白字符(包括换行符)(见图 5.4)。
>>> text = open(file).read().rstrip()
>>> text
'The quick brown fox jumps over the lazy dog.'

图 5.4 open()方法返回一个文件句柄,我们可以将其与read()方法链式调用,read()返回一个字符串,然后我们再链式调用str.rstrip()。
一旦你有了输入文本——无论它来自命令行还是来自文件——你需要将其转换为大写。str.upper()方法可能是你想要的。 |
![]() |
|---|
5.2 写入文件
程序的输出应该出现在命令行上或写入文件中。命令行输出也称为标准输出或STDOUT。(它是标准或正常输出发生的地方。)现在让我们看看如何将输出写入文件。
我们仍然需要打开一个文件句柄,但我们必须使用可选的第二个参数字符串'w'来指示 Python 以写入模式打开它。其他模式包括表 5.1 中列出的'r'用于读取(默认)和'a'用于追加。
表 5.1 文件写入模式
| 模式 | 含义 |
|---|---|
w |
写入 |
r |
读取 |
a |
追加 |
你还可以描述内容的类型,是't'用于文本(默认)还是'b'用于二进制,如表 5.2 中所示。
表 5.2 文件内容模式
| 模式 | 含义 |
|---|---|
t |
文本 |
b |
字节 |
| 你可以将这两个表中的值组合起来,例如使用'rb'来读取一个二进制文件或使用'at'来追加到一个文本文件。在这里,我们将使用'wt'来写入一个文本文件。我将我的变量命名为out_fh以提醒我这是一个输出文件句柄:
>>> out_fh = open('out.txt', 'wt')
![]() |
|---|
如果文件不存在,它将被创建。如果文件已存在,它将被覆盖,这意味着所有之前的数据都将丢失!如果你不希望现有的文件丢失,你可以使用你之前看到的os.path.isfile()函数首先检查文件是否存在,并可能使用“追加”模式的open()。对于这个练习,我们将使用'wt'模式来写入文本。
你可以使用文件句柄的write()方法将文本放入文件。与print()函数会追加一个换行符(\n)除非你指示它不要这样做不同,write()方法不会添加换行符,所以你必须显式地添加一个。
如果你使用 REPL 中的out_fh.write()方法,你会看到它返回写入的字节数。在这里,每个字符,包括换行符(\n),都是一个字节:
>>> out_fh.write('this is some text\n')
18
你可以检查这是否正确:
>>> len('this is some text\n')
18
大多数代码通常会忽略这个返回值;也就是说,我们通常不会费心将结果捕获到变量中或检查我们是否得到了非零返回。如果write()失败,通常意味着你的系统存在一些更大的问题。
你也可以使用带有可选 file 参数的 print() 函数。注意,我没有在 print() 中包含换行符,因为它会自动添加一个。这种方法返回 None:
>>> print('this is some more text', file=out_fh)
当你完成向文件句柄写入后,你应该调用 out_fh.close(),这样 Python 就可以清理文件并释放与之相关的内存。此方法也返回 None:
>>> out_fh.close()
让我们检查我们打印到 out.txt 文件中的文本行是否已经写入,通过打开文件并读取它。注意,换行符在这里表示为 \n。我们需要 print() 这个字符串,以便它创建一个实际的换行:
>>> open('out.txt').read()
'this is some text\nthis is some more text\n'
当我们在打开的文件句柄上 print() 时,文本将被追加到之前写入的数据。不过,看看这段代码:
>>> print("I am what I am an' I'm not ashamed.", file=open('hagrid.txt', 'wt'))
如果你运行那行代码两次,名为 hagrid.txt 的文件将包含一行还是两行?让我们来看看:
>>> open('hagrid.txt').read()
"I am what I am an' I'm not ashamed\n"
只一次!为什么是那样?记住,每次调用 open() 都会给我们一个新的文件句柄,所以调用 open() 两次会导致新的文件句柄。每次运行这段代码,文件都会以 写入 模式重新打开,并且现有数据会被 覆盖。为了避免混淆,我建议你编写更接近以下这样的代码:
fh = open('hagrid.txt', 'wt')
fh.write("I am what I am an' I'm not ashamed.\n")
fh.close()
5.3 编写 howler.py
你需要在 05_howler 目录下创建一个名为 howler.py 的程序。你可以使用新的.py 程序,复制 template.py,或者以你喜欢的任何方式开始。图 5.5 是一个字符串图,展示了程序的整体概述和一些示例输入和输出。

图 5.5 一个字符串图,展示了我们的 howler.py 程序将接受字符串或文件作为输入,以及可能的输出文件名。
当不带参数运行时,它应该打印一条简短的用法信息:
$ ./howler.py
usage: howler.py [-h] [-o str] text
howler.py: error: the following arguments are required: text
当运行 -h 或 --help 时,程序应该打印一条较长的用法说明:
$ ./howler.py -h
usage: howler.py [-h] [-o str] text
Howler (upper-cases input)
positional arguments:
text Input string or file
optional arguments:
-h, --help show this help message and exit
-o str, --outfile str
Output filename (default: )
如果参数是一个普通字符串,它应该将那个字符串转换为大写:
$ ./howler.py 'How dare you steal that car!'
HOW DARE YOU STEAL THAT CAR!
如果参数是文件名,它应该将文件的 内容 转换为大写:
$ ./howler.py ../inputs/fox.txt
THE QUICK BROWN FOX JUMPS OVER THE LAZY DOG.
如果提供了 --outfile 文件名,则应将大写文本写入指定的文件,并且不应将任何内容打印到 STDOUT:
$ ./howler.py -o out.txt ../inputs/fox.txt
$ cat out.txt
THE QUICK BROWN FOX JUMPS OVER THE LAZY DOG.
这里有一些提示:
-
从 new.py 开始,修改
get_args()部分,直到你的用法说明与上面的一致。 -
运行测试套件,并尝试通过只处理命令行上的文本并打印到
STDOUT的第一个测试。 -
下一个测试是看看你是否能将输出写入指定的文件。找出如何做到这一点。
-
下一个测试是读取文件输入。不要试图一次性通过所有测试!
-
存在一个始终存在的特殊文件句柄,称为“标准输出”(通常
STDOUT)。如果你print()时没有提供file参数,它默认为sys.stdout。你需要importsys来使用它。
确保你真的尝试编写程序并通过所有测试,然后再阅读解决方案。如果你卡住了,也许可以制作一批 Polyjuice Potion(一种虚构的魔法药水),让你的朋友们感到惊讶。
5.4 解决方案
这是一个通过测试的解决方案。它相当简短,因为 Python 允许我们非常简洁地表达一些真正强大的想法。
#!/usr/bin/env python3
"""Howler"""
import argparse
import os
import sys
# --------------------------------------------------
def get_args():
"""get command-line arguments"""
parser = argparse.ArgumentParser(
description='Howler (upper-case input)',
formatter_class=argparse.ArgumentDefaultsHelpFormatter)
parser.add_argument('text', ①
metavar='text',
type=str,
help='Input string or file')
parser.add_argument('-o', ②
'--outfile',
help='Output filename',
metavar='str',
type=str,
default='')
args = parser.parse_args() ③
if os.path.isfile(args.text): ④
args.text = open(args.text).read().rstrip() ⑤
return args ⑥
# --------------------------------------------------
def main():
"""Make a jazz noise here"""
args = get_args() ⑦
out_fh = open(args.outfile, 'wt') if args.outfile else sys.stdout ⑧
out_fh.write(args.text.upper() + '\n') ⑨
out_fh.close() ⑩
# --------------------------------------------------
if __name__ == '__main__':
main()
① 文本参数是一个字符串,可能是文件名。
② --outfile 选项也是一个字符串,命名一个文件。
③ 将命令行参数解析到变量 args 中,以便我们可以手动检查文本参数。
④ 检查 args.text 是否是现有文件的名称。
⑤ 如果是,用读取文件的结果覆盖 args.text 的值。
⑥ 将参数返回给调用者。
⑦ 调用 get_args() 来获取程序的参数。
⑧ 使用 if 表达式选择 sys.stdout 或新打开的文件句柄来写入输出。
⑨ 使用打开的文件句柄将输出转换为大写并写入。
⑩ 关闭文件句柄。
5.5 讨论
你这次进展如何?我希望你没有再次偷偷溜进斯内普教授的办公室。你真的不希望有更多的周六留校。
5.5.1 定义参数
get_args() 函数,一如既往地首先出现。在这里,我定义了两个参数。第一个是一个位置参数 text。由于它可能或可能不是文件名,我所知道的就是它将是一个字符串。
parser.add_argument('text',
metavar='text',
type=str,
help='Input string or file')
注意:如果你定义了多个位置参数,它们之间的顺序 相对位置 是重要的。你定义的第一个位置参数将处理提供的第一个位置参数。然而,在选项和标志之前或之后定义位置参数并不重要。你可以按任何顺序声明它们。
另一个参数是一个选项,所以我给它取了一个简短的名称 -o 和一个长名称 --outfile。尽管所有参数的默认 type 都是 str,但我喜欢明确地声明这一点。默认值是空字符串。我同样可以使用特殊的 None 类型,它也是默认值,但我更倾向于使用一个定义明确的参数,比如空字符串。
parser.add_argument('-o',
'--outfile',
help='Output filename',
metavar='str',
type=str,
default='')
5.5.2 从文件或命令行读取输入
这是一个表面上简单的程序,展示了文件输入和输出的几个非常重要的元素。text 输入可能是一个普通字符串,也可能是一个文件名。这种模式将在本书中反复出现:
if os.path.isfile(args.text):
args.text = open(args.text).read().rstrip()
os.path.isfile() 函数会告诉我 text 中是否存在具有指定名称的文件。如果返回 True,我可以安全地 open(file) 来获取文件句柄,该句柄有一个名为 read 的方法,它将返回文件的所有内容。
警告:你应该意识到 fh.read() 将返回整个文件作为一个单独的字符串。你的计算机必须具有比文件大小更多的可用内存。对于本书中的所有程序,由于文件很小,你将安全无虞。在我的日常工作日,我经常处理吉字节大小的文件,如果不调用 fh.read(),我的程序甚至整个系统可能会崩溃,因为我将超出我的可用内存。
open(file).read()的结果是一个str,它有一个名为str.rstrip()的方法,将返回一个字符串副本,其右侧的任何空白都被移除(见图 5.6)。我这样做是为了使输入文本看起来相同,无论它来自文件还是直接来自命令行。当你直接在命令行上提供输入文本时,你必须按 Enter 键来终止命令。那个 Enter 是一个换行符,操作系统在将其传递给程序之前会自动将其移除。

图 5.6 open()函数返回一个文件句柄(fh)。fh.read()函数返回一个str。str.rstrip()函数返回一个新的str,其右侧的空白已被移除。所有这些函数都可以链接在一起。
写出前面语句的较长方式会是
if os.path.isfile(text):
fh = open(text)
text = fh.read()
text = text.rstrip()
fh.close()
在我的版本中,我选择在get_args()函数内部处理这个问题。这是我第一次向你展示你可以在将参数传递给main()之前拦截和修改参数。我们将在后面的练习中多次使用这个想法。
我喜欢在get_args()内部完成所有验证用户参数的工作。我也可以在调用get_args()之后在main()中这样做,所以这完全是一个风格问题。
5.5.3 选择输出文件句柄
以下行决定了程序输出的位置:
out_fh = open(args.outfile, 'wt') if args.outfile else sys.stdout
if表达式将根据用户是否提供了该参数来打开args.outfile以写入文本(wt);否则,它将使用sys.stdout,这是一个指向STDOUT的文件句柄。请注意,我不需要调用open()在sys.stdout上,因为它总是可用且已打开(见图 5.7)。

图 5.7 if表达式简洁地处理了二选一的情况。在这里,我们希望输出文件句柄是打开 outfile 参数的结果(如果存在);否则,它应该是sys.stdout。
5.5.4 打印输出
要获取大写文本,我可以使用text.upper()方法。然后我需要找到一种方法将其打印到输出文件句柄。我选择这样做:
out_fh.write(text.upper())
或者,你也可以这样做:
print(text.upper(), file=out_fh)
最后,我需要使用out_fh.close()关闭文件句柄。
5.5.5 低内存版本
在这个程序中,有一个可能严重的问题在等待着我们。在get_args()中,我们使用这一行将整个文件读入内存:
if os.path.isfile(args.text):
args.text = open(args.text).read().rstrip()
我们也可以只打开文件:
if os.path.isfile(args.text):
args.text = open(args.text)
之后我们可以逐行读取它:
for line in args.text:
out_fh.write(line.upper())
然而,问题是如何处理当text参数实际上是文本而不是文件名时的情况。Python 中的io(输入-输出)模块有一种将文本表示为流的方法:
>>> import io ①
>>> text = io.StringIO('foo\nbar\nbaz\n') ②
>>> for line in text: ③
... print(line, end='') ④
...
foo
bar
baz
① 导入 io 模块。
② 使用io.StringIO()函数将给定的 str 值转换为我们可以像处理打开的文件句柄一样处理的东西。
③ 使用 for 循环遍历由换行符分隔的文本“行”。
④ 使用 end=''选项打印行,以避免出现两个换行符。
这是您第一次看到可以将常规字符串值视为类似文件句柄的值生成器。这对于测试需要读取输入文件的任何代码来说是一个特别有用的技术。您可以使用 io.StringIO() 的返回值作为“模拟”文件句柄,这样您的代码就不必读取“实际”文件,只需给定可以产生“行”文本的值。
要使这可行,我们可以改变处理 args.text 的方式,如下所示:
#!/usr/bin/env python3
"""Low-memory Howler"""
import argparse
import os
import io
import sys
# --------------------------------------------------
def get_args():
"""get command-line arguments"""
parser = argparse.ArgumentParser(
description='Howler (upper-cases input)',
formatter_class=argparse.ArgumentDefaultsHelpFormatter)
parser.add_argument('text',
metavar='text',
type=str,
help='Input string or file')
parser.add_argument('-o',
'--outfile',
help='Output filename',
metavar='str',
type=str,
default='')
args = parser.parse_args()
if os.path.isfile(args.text): ①
args.text = open(args.text) ②
else:
args.text = io.StringIO(args.text + '\n') ③
return args
# --------------------------------------------------
def main():
"""Make a jazz noise here"""
args = get_args()
out_fh = open(args.outfile, 'wt') if args.outfile else sys.stdout
for line in args.text: ④
out_fh.write(line.upper()) ⑤
out_fh.close()
# --------------------------------------------------
if __name__ == '__main__':
main()
① 检查 args.text 是否是一个文件。
② 如果是,将 args.text 替换为通过打开文件创建的文件句柄。
③ 否则,将 args.text 替换为 io.StringIO() 的值,它将像打开的文件句柄一样操作。请注意,我们需要在文本中添加一个换行符,这样它看起来就像来自实际文件的输入行。
④ 逐行读取输入(无论是 io.StringIO() 还是文件句柄)。
⑤ 如前所述处理该行。
5.6 进一步
-
添加一个标志,将输入转换为小写。也许可以将其命名为
--ee,以纪念诗人 e e cummings,他喜欢写没有大写字母的诗。 -
修改程序以处理多个输入文件。将
--outfile改为--outdir,并将每个输入文件写入输出目录中的相同文件名。
摘要
|
-
要读取或写入文件,您必须首先
open()它们。 -
open()的默认模式是读取文件。 -
要写入文本文件,您必须使用
'wt'作为open()的第二个参数。 -
文本是写入文件句柄的默认数据类型。您必须使用
'b'标志来指示您想要写入二进制数据。 -
os.path模块包含许多有用的函数,例如os.path.isfile(),它将告诉您是否存在具有给定名称的文件。 -
STDOUT(标准输出)始终可以通过特殊的sys.stdout文件句柄获得,该句柄始终处于打开状态。 -
print()函数接受一个可选的file参数,指定输出放置的位置。该参数必须是一个打开的文件句柄,例如sys.stdout(默认)或open()的结果。
![]() |
|---|
6 单词计数:读取文件和 STDIN,迭代列表,格式化字符串
“我喜欢计数!”
--Count von Count
| 计数事物是一个令人惊讶的重要编程技能。也许你正在尝试找出每个季度卖出了多少披萨,或者你在一组文档中看到某些单词的次数。通常我们在计算机处理的数据以文件的形式到来,所以在本章中,我们将进一步探讨读取文件和操作字符串。 | ![]() |
|---|
我们将编写一个 Python 版本的备受推崇的wc(“单词计数”)程序。我们的程序将命名为 wc.py,它将计算每个输入参数中找到的行数、单词数和字节数。计数结果将显示在宽度为八个字符的列中,并跟随着文件名。例如,以下是 wc.py 应该为单个文件打印的内容:
$ ./wc.py ../inputs/scarlet.txt
7035 68061 396320 ../inputs/scarlet.txt
当计算多个文件时,将会有一个额外的“总计”行,汇总每一列:
$ ./wc.py ../inputs/const.txt ../inputs/sonnet-29.txt
865 7620 44841 ../inputs/const.txt
17 118 661 ../inputs/sonnet-29.txt
882 7738 45502 total
也可能没有参数,在这种情况下,我们将从标准输入读取,通常写作STDIN。我们在第五章中提到STDOUT时,将其用作文件句柄。STDIN是STDOUT的补充——它是命令行上读取输入的“标准”位置。当我们的程序没有提供任何位置参数时,它将从sys.stdin读取。
STDIN和STDOUT是许多命令行程序识别的常见文件句柄。我们可以将一个程序的STDOUT链接到另一个程序的STDIN,以创建临时程序。例如,cat程序会将文件内容打印到STDOUT。我们可以使用管道操作符(|)将输出作为输入通过STDIN传递到我们的程序中:
$ cat ../inputs/fox.txt | ./wc.py
1 9 45 <stdin>
另一个选项是使用<操作符从文件重定向输入:
$ ./wc.py < ../inputs/fox.txt
1 9 45 <stdin>
最实用的命令行工具之一是grep,它可以在文件中找到文本模式。例如,如果我们想在输入目录中的所有文件中找到包含单词“scarlet”的所有行文本,我们可以使用以下命令:
$ grep scarlet ../inputs/*.txt
在命令行中,星号(*)是一个通配符,可以匹配任何内容,所以*.txt将匹配任何以“.txt”结尾的文件。如果你运行前面的命令,你会看到相当多的输出。
要计算grep找到的行数,我们可以将那个输出通过以下方式管道输入到我们的 wc.py 程序中:
$ grep scarlet ../inputs/*.txt | ./wc.py
108 1192 9201 <stdin>
我们可以验证这和wc找到的结果是否一致:
$ grep scarlet ../inputs/*.txt | wc
108 1192 9201
在本章中,你将
-
学习如何处理零个或多个位置参数
-
验证输入文件
-
从文件或从标准输入读取
-
使用多级
for循环 -
将文件拆分为行、单词和字节
-
使用计数变量
-
格式化字符串输出
6.1 编写 wc.py
让我们开始吧!在 06_wc 目录下创建一个名为 wc.py 的程序,并修改参数,直到它使用-h或--help标志运行时打印以下用法:
$ ./wc.py -h
usage: wc.py [-h] [FILE [FILE ...]]
Emulate wc (word count)
positional arguments:
FILE Input file(s) (default: [<_io.TextIOWrapper name='<stdin>'
mode='r' encoding='UTF-8'>])
optional arguments:
-h, --help show this help message and exit
如果程序遇到一个不存在的文件,它应该打印一条错误消息并退出,退出码不为零:
$ ./wc.py blargh
usage: wc.py [-h] [FILE [FILE ...]]
wc.py: error: argument FILE: can't open 'blargh': \
[Errno 2] No such file or directory: 'blargh'
图 6.1 是一个字符串图,将帮助你思考程序应该如何工作。
6.1.1 定义文件输入
让我们谈谈如何使用 argparse 定义程序的参数。此程序接受 零个或多个 位置参数,不接受其他任何参数。记住,你永远不需要定义 -h 或 --help 参数,因为 argparse 会自动处理这些。
在第三章中,我们使用 nargs='+' 来表示野餐的一个或多个项目。这里我们想使用 nargs='*' 来表示 零个。如果没有参数,默认值将是 None。对于这个程序,如果没有参数,我们将读取 STDIN。

图 6.1 一个字符串图,显示 wc.py 将读取一个或多个文件输入或可能 STDIN,并将生成每个输入中包含的单词、行和字节的摘要。
nargs 的所有可能值列在表 6.1 中。
表 6.1 nargs 的可能值
| 符号 | 含义 |
|---|---|
| ? | 零个或一个 |
| * | 零个或多个 |
| + | 一个或多个 |
任何提供给我们的程序 必须是可以读取的文件。在第五章中,你学习了如何使用 os.path.isfile() 测试输入参数是否为文件。输入可以是纯文本或文件名,因此你必须自己检查这一点。
在这个程序中,输入参数必须是可以读取的文本文件,因此我们可以使用 type=argparse.FileType('rt') 定义我们的参数。这意味着 argparse 会承担所有验证用户输入并产生有用错误信息的工作。如果用户提供了有效的输入,argparse 将提供一个 打开的文件句柄列表。总的来说,这将为我们节省很多时间。(请务必查阅附录中的 A.4.6 节关于文件参数的内容。)
在第五章中,我们使用 sys.stdout 将内容写入 STDOUT。在这里从 STDIN 读取,我们将使用 Python 的 sys.stdin 文件句柄。与 sys.stdout 类似,sys.stdin 文件句柄不需要 open(),它始终存在并可读取。
因为我们使用 nargs='*' 来定义我们的参数,所以结果将始终是一个 list。为了将 sys.stdin 设置为 default 值,我们应该将其放在一个 list 中,如下所示:
parser.add_argument('file',
metavar='FILE',
nargs='*', ①
type=argparse.FileType('rt'), ②
default=[sys.stdin], ③
help='Input file(s)')
① 零个或多个此参数
② 如果提供了参数,它们必须是可读的文本文件。这些文件将由 argparse 打开,并作为文件句柄提供。
③ 默认值将是一个包含 sys.stdin 的列表,它类似于打开的文件句柄到 STDIN。我们不需要打开它。
6.1.2 列表迭代
你的程序最终将得到一个需要处理的 file handle 列表。在第四章中,我们使用 for 循环遍历输入文本中的字符。这里我们可以使用 for 循环遍历 args.file 输入,这些将是打开的文件句柄:
for fh in args.file:
# read each file
你可以为在for循环中使用的变量取任何名字,但我认为给它一个语义上有意义的名字非常重要。在这里,变量名fh让我想起这是一个打开的文件句柄。你在第五章中看到了如何手动open()和read()一个文件。在这里fh已经打开,所以我们可以直接用它来读取内容。
读取文件有许多方法。fh.read()方法会一次性给你文件的全部内容。如果文件很大——如果它超过了你机器上的可用内存——你的程序将会崩溃。我建议,相反,你在fh上使用另一个for循环。Python 会理解这意味着你希望逐行读取文件句柄。
for fh in args.file: # ONE LOOP!
for line in fh: # TWO LOOPS!
# process the line
这是有两层for循环,每一层对应一个文件句柄,然后是每个文件句柄中的每一行。一个循环!两个循环!我喜欢计数!
6.1.3 你在计数什么
每个文件的输出将是行数、单词数和字节数(如字符和空白),每个都在宽度为八个字符的字段中打印,然后是一个空格,然后是文件名,这可以通过fh.name获得。
让我们看看我的系统上标准wc程序的输出。注意,当它只带一个参数运行时,它只会为那个文件产生计数:
$ wc fox.txt
1 9 45 fox.txt
fox.txt 文件足够短,以至于你可以手动验证它确实包含 1 行,9 个单词,和 45 个字节,这包括所有字符、空格和尾随换行符(见图 6.2)。

图 6.2 fox.txt 文件包含 1 行文本,9 个单词,总共 45 个字节。
当运行多个文件时,标准wc程序也会显示一个“总计”行:
$ wc fox.txt sonnet-29.txt
1 9 45 fox.txt
17 118 669 sonnet-29.txt
18 127 714 total
我们将模拟这个程序的行为。对于每个文件,你需要创建变量来保存行数、单词数和字节数。例如,如果你使用我建议的for line in fh循环,你需要有一个像num_lines这样的变量,在每次迭代中增加。
也就是说,在你的代码中,你需要设置一个变量为0,然后,在for循环内部,让它每次增加1。在 Python 中,这种做法通常是通过使用+=运算符将右侧的值添加到左侧的变量上(如图 6.3 所示):
num_lines = 0
for line in fh:
num_lines += 1

图 6.3 +=运算符将右侧的值添加到左侧的变量上。
你还需要计算单词和字节数,所以你需要类似的num_words和num_bytes变量。
要获取单词,我们将使用str.split()方法将每一行按空格分割。然后你可以使用结果列表的长度作为单词的数量。对于字节数,你可以使用len()(长度)函数对line进行操作,并将结果添加到num_bytes变量中。
注意,在空格处分割文本实际上并不会产生“单词”,因为它不会将标点符号(如逗号和句号)与字母分开,但对于这个程序来说已经足够接近了。在第十五章中,我们将探讨如何使用正则表达式来区分看起来像单词的字符串和其他不是单词的字符串。
6.1.4 格式化你的结果
这是第一个需要以特定方式格式化输出的练习。不要尝试手动处理这部分内容——那样只会导致混乱。相反,你需要学习str.format()方法的魔法。help没有多少文档,所以我建议你阅读 PEP 3101 关于高级字符串格式化(www.python.org/dev/peps/pep-3101/))。
str.format()方法使用包含花括号({})的模板来创建用于传递的值的占位符。例如,我们可以这样打印math.pi的原始值:
>>> import math
>>> 'Pi is {}'.format(math.pi)
'Pi is 3.141592653589793'
你可以在冒号(:)之后添加格式化指令来指定你想要显示的值的方式。如果你熟悉 C 类型语言中的printf(),这个想法是相同的。例如,我可以通过指定0.02f来打印带有两个小数位的math.pi:
>>> 'Pi is {:0.02f}'.format(math.pi)
'Pi is 3.14'
在前面的例子中,冒号(:)引入了格式化选项,而0.02f描述了两位小数的精度。
你还可以使用 f-string 方法,其中变量位于冒号之前:
>>> f'Pi is {math.pi:0.02f}'
'Pi is 3.14'
在本章的练习中,你需要使用格式化选项{:8}来将每一行、单词和字符对齐到列中。8描述了字段的宽度。文本通常是左对齐的,如下所示:
>>> '{:8}'.format('hello')
'hello '
但当你格式化数值时,文本将右对齐:
>>> '{:8}'.format(123)
' 123'
你需要在最后一列和文件名之间放置一个空格,你可以在fh.name中找到这个文件名。
这里有一些提示:
-
从 new.py 开始,删除所有非位置参数。
-
使用
nargs='*'来表示file参数的零个或多个位置参数。 -
尝试一次通过一个测试。创建程序,正确设置帮助信息,然后关注第一个测试,然后是下一个,依此类推。
-
将你的版本的结果与系统上安装的
wc进行比较。请注意,并非每个系统都有相同的wc版本,因此结果可能会有所不同。
在阅读解决方案之前,现在是时候自己动手写了。恐惧是心灵的杀手。你可以做到这一点。
6.2 解决方案
这里是满足测试的一种方法。记住,如果你以不同的方式编写,只要它是正确的并且你理解你的代码,那就没问题!
#!/usr/bin/env python3
"""Emulate wc (word count)"""
import argparse
import sys
# --------------------------------------------------
def get_args():
"""Get command-line arguments"""
parser = argparse.ArgumentParser(
description='Emulate wc (word count)',
formatter_class=argparse.ArgumentDefaultsHelpFormatter)
parser.add_argument('file',
metavar='FILE',
nargs='*',
default=[sys.stdin], ①
type=argparse.FileType('rt'), ②
help='Input file(s)')
return parser.parse_args()
# --------------------------------------------------
def main():
"""Make a jazz noise here"""
args = get_args()
total_lines, total_bytes, total_words = 0, 0, 0 ③
for fh in args.file: ④
num_lines, num_words, num_bytes = 0, 0, 0 ⑤
for line in fh: ⑥
num_lines += 1 ⑦
num_bytes += len(line) ⑧
num_words += len(line.split()) ⑨
total_lines += num_lines ⑩
total_bytes += num_bytes
total_words += num_words
print(f'{num_lines:8}{num_words:8}{num_bytes:8} {fh.name}') ⑪
if len(args.file) > 1: ⑫
print(f'{total_lines:8}{total_words:8}{total_bytes:8} total') ⑬
# --------------------------------------------------
if __name__ == '__main__':
main()
① 如果你将默认值设置为包含 sys.stdin 的列表,你就已经处理了 STDIN 选项。
② 如果用户提供了任何参数,argparse 将检查它们是否是有效的文件输入。如果有问题,argparse 将停止程序执行并向用户显示错误信息。
③ 这些是“总计”行的变量,如果需要的话。
④ 遍历 arg.file 输入列表。我使用变量 fh 来提醒自己这些是打开的文件句柄,甚至是 STDIN。
⑤ 初始化变量以计算仅此文件的行数、单词数和字节数。
⑥ 遍历文件句柄的每一行。
⑦ 对于每一行,行数增加 1。
⑧ 字节数增加的量是行长。
⑨ 要获取单词数,我们可以调用 line.split()来在空白处拆分行。该列表的长度被添加到单词计数中。
⑩ 将此文件的行数、单词数和字节数的计数全部添加到总计计数的变量中。
⑪ 使用{:8}选项打印此文件的计数,以打印宽度为 8 个字符的字段,后跟一个空格,然后是文件名。
⑫ 检查我们是否有超过 1 个输入。
⑬ 打印“总计”行。
6.3 讨论
这个程序相当简短,看起来相当简单,但并不容易。让我们分解程序中的主要思想。
6.3.1 定义参数
这个练习的一个目的是熟悉argparse及其可以为你节省的麻烦。关键是定义file参数。我们使用type=argparse.FileType('rt')来表示提供的任何参数必须是可读文本文件。我们使用nargs='*'来表示零个或多个参数,并将default设置为包含sys.stdin的列表。这意味着我们知道argparse将始终给我们一个包含一个或多个打开文件句柄的列表。
这实际上是在一个小空间中打包了相当多的逻辑,并且验证输入、生成错误消息和处理默认值的大部分工作都是为我们完成的!
6.3.2 使用 for 循环读取文件
argparse为args.file返回的值将是一个包含打开文件句柄的list。我们可以在 REPL 中创建这样的列表来模拟我们从args.file得到的:
>>> files = [open('../inputs/fox.txt')]
在我们使用for循环遍历它们之前,我们需要设置三个变量来跟踪总计行数、单词数和字符数。我们可以在三行不同的地方定义它们:
>>> total_lines = 0
>>> total_words = 0
>>> total_bytes = 0
或者我们可以像下面这样在单行中声明:
>>> total_lines, total_words, total_bytes = 0, 0, 0
技术上,我们在右侧通过在三个零之间放置逗号来创建一个tuple,然后将它们“解包”到左侧的三个变量中。我会在稍后更多地讨论元组。
在每个文件句柄的for循环内部,我们初始化三个额外的变量来保存特定文件的行数、字符数和单词数计数。然后我们可以使用另一个for循环来迭代文件句柄(fh)中的每一行。对于lines,我们可以在每次通过for循环时添加1。对于bytes,我们可以将行的长度(len(line))添加到跟踪“字符”数量(这些可能是可打印字符或空白字符,因此最容易称它们为“字节”)。最后,对于words,我们可以使用line.split()在空白处拆分行以创建“单词”的列表。这不是计算实际单词的完美方法,但足够接近。我们可以使用len()函数在列表上操作,以将值添加到words变量中。
for循环在到达文件末尾时结束。接下来,我们可以使用print()输出计数和文件名,在打印模板中使用{:8}占位符来指示 8 个字符宽的文本字段:
>>> for fh in files:
... lines, words, bytes = 0, 0, 0
... for line in fh:
... lines += 1
... bytes += len(line)
... words += len(line.split())
... print(f'{lines:8}{words:8}{bytes:8} {fh.name}')
... total_lines += lines
... total_bytes += bytes
... total_words += words
...
1 9 45 ../inputs/fox.txt
注意,前面的print()调用与第二个for循环对齐,因此它将在我们迭代完fh中的行之后运行。我选择使用 f-string 方法以 8 个字符宽的间隔打印lines、words和bytes,然后是一个空格,然后是文件的fh.name。
打印后,我们可以将计数添加到“总计”变量中,以保持累计总和。

最后,如果文件参数的数量大于 1,我们需要打印总计:
if len(args.file) > 1:
print(f'{total_lines:8}{total_words:8}{total_bytes:8} total')
6.4 进一步学习
-
默认情况下,
wc会打印所有列,就像我们的程序一样,但它也会接受标志来打印-c表示字符数,-l表示行数,-w表示单词数。当出现这些标志中的任何一个时,只显示指定标志的列,因此wc.py-wc将只显示单词和字符的列。为这些选项添加短和长标志,以便程序的行为与wc完全一致。 -
编写自己的其他系统工具实现,例如
cat(将文件内容打印到STDOUT),head(打印文件的前 n行),tail(打印文件的最后 n行),以及tac(以相反顺序打印文件的行)。
摘要
-
argparse的nargs(参数数量)选项允许你验证用户提供的参数数量。星号('*')表示零个或多个,而'+'表示一个或多个。 -
如果你使用
type=argparse.FileType('rt')定义了一个参数,argparse将验证用户是否提供了一个可读的文本文件,并将该值作为打开的文件句柄在代码中提供。 -
你可以通过使用
sys.stdin和sys.stdout从标准输入/输出文件句柄中读取和写入。 -
你可以嵌套
for循环来处理多级处理。 -
str.split()方法会在空格处拆分字符串。 -
len()函数可以用于字符串和列表。对于列表,它将告诉你列表包含的元素数量。 -
str.format()和 Python 的 f-strings 都支持printf风格的格式化选项,以便您控制值的显示方式。
7 Gashlycrumb:在字典中查找项目
在本章中,我们将查找用户提供的字母开头的文本行。文本将来自默认的 Edward Gorey 的《The Gashlycrumb Tinies》,这是一本描述孩子们以各种可怕方式死亡的书。例如,图 7.1 显示“N 是 Neville,他因厌倦而死。”

表 7.1 N 是 Neville,他因厌倦而死。
我们的 gashlycrumb.py 程序将接受一个或多个字母作为位置参数,并从可选的输入文件中查找以该字母开头的文本行。我们将以不区分大小写的方式查找字母。
输入文件将为每个字母提供一个单独的行值:
$ head -2 gashlycrumb.txt
A is for Amy who fell down the stairs.
B is for Basil assaulted by bears.
当我们的不幸用户运行这个程序时,他们会看到以下内容:
$ ./gashlycrumb.py e f
E is for Ernest who choked on a peach.
F is for Fanny sucked dry by a leech.
在这个练习中,你将
-
接受一个或多个我们称之为
letter的位置参数。 -
接受一个可选的
--file参数,它必须是一个可读的文本文件。默认值将是'gashlycrumb.txt'(提供)。 -
读取文件,找到每行的第一个字母,并构建一个将字母与文本行关联的数据结构。(我们只会使用每行以单个唯一字母开头的文件。这个程序在其他任何文本格式中都会失败。)
-
对于用户提供的每个
letter,如果存在,则打印该letter的文本行,或者如果没有,则打印一条消息。 -
学习如何“美化打印”一个数据结构。
你可以从几个之前的程序中借鉴:
| 从第二章你知道如何获取一段文本的第一个字母。从第四章你知道如何构建一个字典并查找值。从第六章你知道如何接受一个文件输入参数并逐行读取。 | ![]() |
|---|
现在你将把这些技能结合起来,背诵一些令人毛骨悚然的诗歌!
7.1 编写 gashlycrumb.py
在你开始编写之前,我鼓励你运行 07_gashlycrumb 目录中的测试,使用 make test 或 pytest -xv test.py。第一个测试应该失败:
test.py::test_exists FAILED
这只是一个提醒,你需要做的第一件事是创建一个名为 gashlycrumb.py 的文件。你可以按你喜欢的方式做,比如在 07_gashlycrumb 目录中运行 new.py gashlycrumb.py,通过复制模板/template.py 文件,或者从头开始创建一个新文件。再次运行你的测试,你应该通过第一个测试,如果你的程序产生了用法声明,可能还会通过第二个测试。
接下来,让我们明确一下参数。修改你的程序在 get_args() 函数中的参数,以便在没有任何参数或使用 -h 或 --help 标志运行程序时产生以下用法声明:
$ ./gashlycrumb.py -h
usage: gashlycrumb.py [-h] [-f FILE] letter [letter ...]
Gashlycrumb
positional arguments:
letter Letter(s) ①
optional arguments:
-h, --help show this help message and exit ②
-f FILE, --file FILE Input file (default: gashlycrumb.txt) ③
① letter 是一个必需的位置参数,接受一个或多个值。
② -h 和 --help 参数是由 argparse 自动创建的。
③ -f 或 --file 参数是一个具有默认值 gashlycrumb.txt 的选项。
图 7.2 显示了程序将如何工作的字符串图。

表 7.2 我们的程序将接受一些字母(s)和可能一个文件。然后它将查找以给定字母(s)开头的文件行。
在main()函数中,首先回显每个letter参数:
def main():
args = get_args()
for letter in args.letter:
print(letter)
尝试运行它以确保它工作:
$ ./gashlycrumb.py a b
a
b
接下来,使用for循环逐行读取文件:
def main():
args = get_args()
for letter in args.letter:
print(letter)
for line in args.file:
print(line, end='')
注意,我在print()中使用end='',这样它就不会打印文件中每行已经附加的换行符:
尝试运行它以确保您能够读取输入文件:
$ ./gashlycrumb.py a b | head -4
a
b
A is for Amy who fell down the stairs.
B is for Basil assaulted by bears.
也使用 alternate.txt 文件:
$ ./gashlycrumb.py a b --file alternate.txt | head -4
a
b
A is for Alfred, poisoned to death.
B is for Bertrand, consumed by meth.
如果程序提供了一个不存在的--file参数,它应该带错误消息退出。注意,如果您在get_args()中声明参数时使用type=argparse.FileType('rt'),就像我们在上一章中做的那样,这个错误应该由argparse自动生成:
$ ./gashlycrumb.py -f blargh b
usage: gashlycrumb.py [-h] [-f FILE] letter [letter ...]
gashlycrumb.py: error: argument -f/--file: can't open 'blargh': \
[Errno 2] No such file or directory: 'blargh'
现在考虑如何使用每行的第一个字母在dict中创建一个条目。使用print()查看您的字典。弄清楚如何检查给定的letter是否在(眨眼,眨眼,暗示,暗示)您的字典中。
| 如果程序接收到一个在输入文件行首字符列表中不存在的值(在搜索时不区分大小写),应打印一条消息: | ![]() |
|---|
$ ./gashlycrumb.py 3
I do not know "3".
$ ./gashlycrumb.py CH
I do not know "CH".
如果给定的letter在字典中,打印其值(见图 7.3):
$ ./gashlycrumb.py a
A is for Amy who fell down the stairs.
$ ./gashlycrumb.py z
Z is for Zillah who drank too much gin.

表 7.3 我们需要创建一个字典,其中每行的第一个字母是键,行本身是值。
运行测试套件以确保您的程序满足所有要求。仔细阅读错误并修复您的程序。
这里有一些提示:
-
从 new.py 开始,删除除了位置
letter和可选的--file参数之外的所有内容。 -
使用
type=argparse.FileType('rt')来验证--file参数。 -
使用
nargs='+'来定义位置参数letter,使其需要一个或多个值。 -
字典是一种将像字母“A”这样的值与像“A is for Amy who fell down the stairs.”这样的短语关联的自然数据结构。创建一个新的空
dict。 -
一旦您有一个打开的文件句柄,您可以使用
for循环逐行读取文件。 -
每行文本都是一个字符串。您如何获取字符串的第一个字符?
-
使用第一个字符作为键和行本身作为值在字典中创建一个条目。
-
遍历每个
letter参数。您如何检查给定的值是否在字典中?
在您编写自己的版本之前不要跳到解决方案!如果您偷看,您将遭受可怕的死亡:被小猫踩踏。
7.2 解决方案
我真的希望您看了戈雷为其书籍创作的艺术作品。现在让我们谈谈如何从文件输入构建字典:
#!/usr/bin/env python3
"""Lookup tables"""
import argparse
# --------------------------------------------------
def get_args():
"""get command-line arguments"""
parser = argparse.ArgumentParser(
description='Gashlycrumb',
formatter_class=argparse.ArgumentDefaultsHelpFormatter)
parser.add_argument('letter',
help='Letter(s)',
metavar='letter',
nargs='+', ①
type=str)
parser.add_argument('-f',
'--file',
help='Input file',
metavar='FILE',
type=argparse.FileType('rt'), ②
default='gashlycrumb.txt')
return parser.parse_args()
# --------------------------------------------------
def main():
"""Make a jazz noise here"""
args = get_args()
lookup = {} ③
for line in args.file: ④
lookup[line[0].upper()] = line.rstrip() ⑤
for letter in args.letter: ⑥
if letter.upper() in lookup: ⑦
print(lookup[letter.upper()]) ⑧
else:
print(f'I do not know "{letter}".') ⑨
# --------------------------------------------------
if __name__ == '__main__':
main()
①名为 letter 的位置参数使用 nargs='+'来指示需要一个或多个值。
② 由于type=argparse.FileType('rt'),可选的--file 参数必须是一个可读的文件。默认值是 gashlycrumb.txt,我知道它存在。
③ 创建一个空字典来存储查找表。
④ 遍历args.file中的每一行,这将是一个打开的文件句柄。
⑤ 将行的第一个字符转换为大写,用作查找表的键,并将值设置为右侧移除空白字符的行。
⑥ 使用for循环遍历args.letter中的每个字母。
⑦ 使用letter.upper()来忽略大小写,检查字母是否在查找字典中。
⑧ 如果是,打印出查找表中该字母的文本行。
⑨ 否则,打印一条消息说该字母是未知的。
7.3 讨论部分
小猫的可怕爪子伤害得厉害吗?让我们谈谈我是如何解决这个问题。记住,我的只是许多可能的解决方案之一。
7.3.1 处理参数
我更喜欢在get_args()函数中处理解析和验证命令行参数的逻辑。特别是argparse可以很好地验证繁琐的事情,例如确保一个参数是一个现有且可读的文本文件,这就是为什么我使用type=argparse.FileType('rt')来处理这个参数。如果用户没有提供有效的参数,argparse将抛出一个错误,打印一条有用的消息以及简短的用法说明,并以错误代码退出。
当我到达args = get_args()这一行时,我知道我有一个或多个“字母”参数,以及一个有效的、打开的文件句柄在args.file槽中。在 REPL 中,我可以用open来获取文件句柄,我通常喜欢将其称为fh。出于版权目的,我将使用我的备用文本:
>>> fh = open('alternate.txt')
7.3.2 读取输入文件
我们想使用一个字典,其中键是每行的第一个字母,值是行本身。这意味着我们需要首先创建一个新的空字典,无论是使用dict()函数还是将变量设置为空花括号集合({})。让我们称这个变量为lookup:
>>> lookup = {}
我们可以使用一个for循环来读取文本的每一行。从第二章的 Crow’s Nest 程序中,你知道我们可以使用line[0].upper()来获取line的第一个字母并将其转换为大写。我们可以用这个作为lookup的键。
每一行的文本都以换行符结束,我想移除它。str.rstrip()方法将从line的右侧移除空白字符("rstrip" = right strip)。这将是我lookup的值:
for line in fh:
lookup[line[0].upper()] = line.rstrip()
让我们看看结果lookup字典。我们可以从程序中print()它或在 REPL 中输入lookup,但它将很难阅读。我鼓励你试试看。
幸运的是,有一个叫做pprint的可爱模块可以“美化打印”数据结构。以下是如何从pprint模块导入pprint()函数并使用别名pp:
>>> from pprint import pprint as pp
图 7.4 展示了这是如何工作的。

表 7.4 我们可以指定从模块中导入的确切函数,甚至给函数一个别名。
现在让我们来看看 lookup 表:
>>> pp(lookup)
{'A': 'A is for Alfred, poisoned to death.',
'B': 'B is for Bertrand, consumed by meth.',
'C': 'C is for Cornell, who ate some glass.',
'D': 'D is for Donald, who died from gas.',
'E': 'E is for Edward, hanged by the neck.',
'F': 'F is for Freddy, crushed in a wreck.',
'G': 'G is for Geoffrey, who slit his wrist.',
'H': "H is for Henry, who's neck got a twist.",
'I': 'I is for Ingrid, who tripped down a stair.',
'J': 'J is for Jered, who fell off a chair,',
'K': 'K is for Kevin, bit by a snake.',
'L': 'L is for Lauryl, impaled on a stake.',
'M': 'M is for Moira, hit by a brick.',
'N': 'N is for Norbert, who swallowed a stick.',
'O': 'O is for Orville, who fell in a canyon,',
'P': 'P is for Paul, strangled by his banyan,',
'Q': 'Q is for Quintanna, flayed in the night,',
'R': 'R is for Robert, who died of spite,',
'S': 'S is for Susan, stung by a jelly,',
'T': 'T is for Terrange, kicked in the belly,',
'U': "U is for Uma, who's life was vanquished,",
'V': 'V is for Victor, consumed by anguish,',
'W': "W is for Walter, who's socks were too long,",
'X': 'X is for Xavier, stuck through with a prong,',
'Y': 'Y is for Yoeman, too fat by a piece,',
'Z': 'Z is for Zora, smothered by a fleece.'}
嘿,这看起来像是一个方便的数据结构。为我们欢呼!当你试图编写和理解程序时,请不要低估使用大量 print() 调用的价值,以及当你需要查看复杂数据结构时使用 pprint() 函数的价值。
7.3.3 使用字典推导式
在第四章中,你看到了你可以使用列表推导式通过在 [] 内放置 for 循环来构建列表。如果我们把括号改为花括号 {},我们就可以创建一个字典推导式:
>>> fh = open('gashlycrumb.txt')
>>> lookup = { line[0].upper(): line.rstrip() for line in fh }
在图 7.5 中看看我们如何将 for 循环的三行代码重组成一行代码。

表 7.5 我们用来构建字典的 for 循环可以用字典推导式来写。
如果你再次打印 lookup 表,你应该看到之前相同的输出。写一行代码而不是三行代码可能看起来像是在炫耀,但紧凑、惯用的代码确实很有意义。代码越多,出错的机会就越多,所以我通常尽量编写尽可能简单的代码(但不要过于简单)。
7.3.4 字典查找
现在我有一个 lookup 表,我可以询问某个值是否在键中。我知道字母是大写的,而且由于用户可能会给我一个小写的字母,我使用 letter.upper() 来仅比较这种情况: |
![]() |
|---|
>>> letter = 'a'
>>> letter.upper() in lookup
True
>>> lookup[letter.upper()]
'A is for Amy who fell down the stairs.'
如果找到了字母,我可以打印该字母的文本行;否则,我可以打印一条消息,说明我不知道那个字母:
>>> letter = '4'
>>> if letter.upper() in lookup:
... print(lookup[letter.upper()])
... else:
... print('I do not know "{}".'.format(letter))
...
I do not know "4".
使用 dict.get() 方法可以更简洁地写这个:
def main():
args = get_args()
lookup = {line[0].upper(): line.rstrip() for line in args.file}
for letter in args.letter:
print(lookup.get(letter.upper(), f'I do not know "{letter}".')) ①
① lookup.get() 将返回 letter.upper() 对应的值,或者在我们的查找中找不到值的警告。
7.4 进一步学习
-
编写一个电话簿程序,该程序读取文件并从你朋友的姓名和他们的电子邮件或电话号码创建一个字典。
-
创建一个程序,使用字典来计算你在文档中看到每个单词的次数。
-
编写一个交互式版本的程序,该程序直接从用户那里获取输入。使用
whileTrue设置无限循环,并继续使用input()函数来获取用户的下一个letter:$ ./gashlycrumb_interactive.py Please provide a letter [! to quit]: t T is for Titus who flew into bits. Please provide a letter [! to quit]: 7 I do not know "7". Please provide a letter [! to quit]: ! Bye -
编写交互式程序很有趣,但你是如何测试它们的呢?在第十七章中,我会向你展示一种这样做的方法。
概述
-
字典推导式是一种在单行
for循环中构建字典的方法。 -
使用
argparse.FileType定义文件输入参数可以节省你时间和代码。 -
Python 的
pprint模块用于格式化打印复杂的数据结构。
8 苹果和香蕉:查找和替换
| 你有没有拼写错过一个单词?我没有,但我听说很多人经常这样做。我们可以使用计算机来查找并替换所有拼写错误的单词。或者你可能想在你的诗歌中用你新爱人的名字替换你前任的名字?查找和替换是你的朋友。 | ![]() |
|---|
为了让我们开始,让我们考虑儿童歌曲“苹果和香蕉”,其中我们吟唱我们最喜欢的水果来食用:
I like to eat, eat, eat apples and bananas
后续的诗句将水果中的主要元音音素替换为各种其他的元音音素,例如长元音“a”音(如在“hay”中):
I like to ate, ate, ate ay-ples and ba-nay-nays
或者流行的长元音“e”音(如在“knee”中):
I like to eat, eat, eat ee-ples and bee-nee-nees
以此类推。在这个练习中,我们将编写一个名为 apples.py 的 Python 程序,该程序接受一些文本,作为单个位置参数,并将文本中的所有元音替换为给定的 -v 或 --vowel 选项(默认为 a)。
程序应该编写在 08_apples_and_bananas 目录中,并应处理命令行上的文本:
$ ./apples.py foo
faa
并接受 -v 或 --vowel 选项:
$ ./apples.py foo -v i
fii
您的程序应该 保留输入元音的大小写:
$ ./apples.py -v i "APPLES AND BANANAS"
IPPLIS IND BININIS
与第五章中的 Howler 程序一样,文本参数可能是一个文件名,在这种情况下,您的程序应该读取文件的内容:
$ ./apples.py ../inputs/fox.txt
Tha qaack brawn fax jamps avar tha lazy dag.
$ ./apples.py --vowel e ../inputs/fox.txt
The qeeck brewn fex jemps ever the lezy deg.
图 8.1 显示了程序输入和输出的示意图。

图 8.1:我们的程序将接受一些文本和一个可能的元音。给定文本中的所有元音都将被更改为相同的元音,从而产生幽默。
这里是当没有参数时应该打印的用法说明:
$ ./apples.py
usage: apples.py [-h] [-v vowel] text
apples.py: error: the following arguments are required: text
并且程序应该始终打印 -h 和 --help 标志的用法:
$ ./apples.py -h
usage: apples.py [-h] [-v vowel] text
Apples and bananas
positional arguments:
text Input text or file
optional arguments:
-h, --help show this help message and exit
-v vowel, --vowel vowel
The vowel to substitute (default: a)
| 如果 --vowel 参数不是一个单独的小写元音,程序应该报错:
$ ./apples.py -v x foo
usage: apples.py [-h] [-v str] str
apples.py: error: argument -v/--vowel: \
invalid choice: 'x' (choose from 'a', 'e', 'i', 'o', 'u')
![]() |
|---|
您的程序需要执行以下操作:
-
接受一个位置参数,该参数可能是某些纯文本或可能是一个文件名
-
如果参数是一个文件,则使用文件内容作为输入文本
-
接受一个可选的
-v或--vowel参数,该参数默认为字母“a” -
验证
--vowel选项是否在元音集合“a”、“e”、“i”、“o”和“u”中 -
将输入文本中的所有元音替换为指定的(或默认的)
--vowel参数 -
将新文本打印到
STDOUT
8.1 修改字符串
到目前为止,在我们的 Python 字符串、数字、列表和字典的讨论中,我们看到了我们如何轻松地更改或 修改 变量。然而,有一个问题,那就是 字符串是不可变的。假设我们有一个 text 变量,它包含我们的输入文本:
>>> text = 'The quick brown fox jumps over the lazy dog.'
如果我们想将第一个“e”(索引为 2)变成“i”,我们无法这样做:
>>> text[2] = 'i'
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: 'str' object does not support item assignment
要更改 text,我们需要将其设置为完全新的值。在第四章中,你看到可以使用 for 循环遍历字符串中的字符。例如,我可以这样费力地将 text 转换为大写:
new = '' ①
for char in text: ②
new += char.upper() ③
① 初始化一个变量等于空字符串。
② 遍历文本中的每个字符。
③ 将字符的大写版本追加到变量中。
我们可以检查new的值来验证它是否全部为大写:
>>> new
'THE QUICK BROWN FOX JUMPS OVER THE LAZY DOG.'
使用这个想法,你可以遍历text中的字符并构建一个新的字符串。每当字符是元音时,你可以用给定的vowel替换它;否则,你可以使用字符本身。我们在第二章中识别了元音,所以你可以回顾一下你是如何做到这一点的。
8.1.1 使用str.replace()方法
在第四章中,我们讨论了使用str.replace()方法将字符串中的所有数字替换为不同的数字。这可能是一个解决问题的好方法?让我们使用 REPL 中的help(str.replace)查看该方法的文档:
>>> help(str.replace)
replace(self, old, new, count=-1, /)
Return a copy with all occurrences of substring old replaced by new.
count
Maximum number of occurrences to replace.
-1 (the default value) means replace all occurrences.
If the optional argument count is given, only the first count occurrences are replaced.
让我们试一试。我们可以将“T”替换为“X”:
>>> text.replace('T', 'X')
'Xhe quick brown fox jumps over the lazy dog.'
这看起来很有希望!你能看到一种用这个想法替换所有元音的方法吗?记住,这个方法永远不会修改给定的字符串,而是返回一个新的字符串,你需要将其赋值给一个变量。
8.1.2 使用str.translate()
我们也在第四章中讨论了str.translate()方法。在那里,我们创建了一个字典,描述了如何将一个字符,例如“1”,转换成另一个字符串,例如“9”。任何在字典中没有提到的字符都被保留原样。
这个方法的文档有点晦涩:
>>> help(str.translate)
translate(self, table, /)
Replace each character in the string using the given translation table.
table
Translation table, which must be a mapping of Unicode ordinals to
Unicode ordinals, strings, or None.
The table must implement lookup/indexing via __getitem__, for instance a
dictionary or list. If this operation raises LookupError, the character is
left untouched. Characters mapped to None are deleted.
在我的解决方案中,我创建了以下字典:
jumper = {'1': '9', '2': '8', '3': '7', '4': '6', '5': '0',
'6': '4', '7': '3', '8': '2', '9': '1', '0': '5'}
这就是传递给str.maketrans()函数的参数,它创建了一个转换表,然后与str.translate()一起使用,将字典中作为键的所有字符转换为它们的对应值:
>>> '876-5309'.translate(str.maketrans(jumper))
'234-0751'
如果你想将所有大小写字母的元音都转换为其他值,字典中应该有哪些键和值?
8.1.3 其他修改字符串的方法
如果你了解正则表达式,这是一个强大的解决方案。如果你还没有听说过它们,不要担心——我将在讨论中介绍它们。
目的是让你玩这个,并找到解决方案。我发现有八种方法可以将所有元音转换为新的字符,所以有几种方法可以解决这个问题。在你查看我的解决方案之前,你能找到多少种不同的方法?
这里有一些提示:
-
考虑在
argparse文档中使用choices选项来约束--vowel选项。务必阅读附录中的 A.4.3 部分以获取示例。 -
确保更改元音字母的大小写版本,同时保留输入字符的大小写。
现在是深入挖掘并看看在你查看我的解决方案之前你能做什么的时候了。
8.2 解决方案
这是我想分享的第一个解决方案。在此之后,我们将探索更多。
#!/usr/bin/env python3
"""Apples and Bananas"""
import argparse
import os
# --------------------------------------------------
def get_args():
"""get command-line arguments"""
parser = argparse.ArgumentParser(
description='Apples and bananas',
formatter_class=argparse.ArgumentDefaultsHelpFormatter)
parser.add_argument('text', metavar='text', help='Input text or file') ①
parser.add_argument('-v',
'--vowel',
help='The vowel(s) allowed',
metavar='vowel',
type=str,
default='a',
choices=list('aeiou')) ②
args = parser.parse_args()
if os.path.isfile(args.text): ③
args.text = open(args.text).read().rstrip() ④
return args
# --------------------------------------------------
def main():
"""Make a jazz noise here"""
args = get_args()
text = args.text
vowel = args.vowel
new_text = [] ⑤
for char in text: ⑥
if char in 'aeiou': ⑦
new_text.append(vowel) ⑧
elif char in 'AEIOU': ⑨
new_text.append(vowel.upper()) ⑩
else:
new_text.append(char) ⑪
print(''.join(new_text)) ⑫
# --------------------------------------------------
if __name__ == '__main__':
main()
① 输入可能是文本或文件名,所以我将其定义为字符串。
② 使用“choices”限制用户只能选择列表中的一个元音字母。
③ 检查文本参数是否是文件。
④ 如果是,使用str.rstrip()读取文件以删除任何尾随空格。
⑤ 创建一个新的列表来保存转换文本的字符。
⑥ 遍历文本中的每个字符。
⑦ 检查当前字符是否在小写元音字母的列表中。
⑧ 如果是,使用元音值而不是字符。
⑨ 检查当前字符是否在大写元音字母的列表中。
⑩ 如果是,使用元音字母的大写形式而不是字符。
⑪ 否则,使用字符本身。
⑫ 打印一个新的字符串,通过空字符串连接新的文本列表。
8.3 讨论
我想出了八种编写解决方案的方法。它们都以相同的 get_args() 函数开始,所以让我们先看看这个函数。
8.3.1 定义参数
这类问题有很多有效且有趣的解决方案。当然,首先要解决的问题当然是获取和验证用户的输入。像往常一样,我会使用 argparse。
我通常首先定义所有必需的参数。text 参数是一个位置字符串,可能是一个文件名:
parser.add_argument('text', metavar='str', help='Input text or file')
--vowel 选项也是一个字符串,我决定使用 choices 选项让 argparse 验证用户的输入是否在 list('aeiou') 中:
parser.add_argument('-v',
'--vowel',
help='The vowel to substitute'',
metavar='str',
type=str,
default='a',
choices=list('aeiou'))
即,choices 需要一个选项的 list。我可以传入 ['a', 'e', 'i', 'o', 'u'],但这需要我输入很多。使用 list('aeiou') 并让 Python 将字符串 “aeiou” 转换为字符的 list 要简单得多。两种方法都会产生相同的结果,因为 list(str) 会创建一个包含给定字符串中各个字符的 list。记住,使用单引号或双引号无关紧要。任何用这两种类型之一括起来的值都是 str,即使它只是一个字符:
>>> ['a', 'e', 'i', 'o', 'u']
['a', 'e', 'i', 'o', 'u']
>>> list('aeiou')
['a', 'e', 'i', 'o', 'u']
我们甚至可以为此编写一个测试。没有错误意味着它是可以的:
>>> assert ['a', 'e', 'i', 'o', 'u'] == list('aeiou')
下一个任务是检测 text 是否是应该读取文本的文件名,或者它本身是文本。这是我在第五章中使用过的相同代码,而且我又选择在 get_args() 函数内部处理 text 参数,这样,当我得到 main() 中的 text 时,它已经被处理过了。图 8.2 说明了我们如何将 open() 函数链接到文件句柄的 read() 方法,再链接到字符串的 rstrip() 方法。
if os.path.isfile(args.text):
args.text = open(args.text).read().rstrip()

图 8.2 我们可以将方法链式连接起来以创建操作管道。open() 返回一个我们可以读取的文件句柄。read() 操作返回一个字符串,我们将它去除空白字符。
到目前为止,程序的用户参数已经全部经过审查。我们得到了来自命令行或文件的 text,并且我们已经验证了 --vowel 的值是允许的字符之一。对我来说,这段代码是一个单一的“单元”,我在其中处理了参数。现在可以通过返回参数来继续处理:
return args
8.3.2 八种替换元音的方法
您找到了多少种替换元音的方法?当然,您只需要一种方法来通过测试,但我希望您探索了语言的边缘,看看有多少不同的技术。我知道 Python 之禅说
应该只有一个——最好是只有一个——明显的方法来做这件事。
www.python.org/dev/peps/pep-0020/
但我实际上来自 Perl 思维模式,其中“有多种方法可以做到”(TIMTOWTDI 或“Tim Toady”)。
方法 1:遍历每个字符
第一种方法与我们在第四章中做的方法类似,我们在字符串上使用for循环来访问每个字符。以下是一些您可以复制并粘贴到ipython REPL 中的代码:
>>> text = 'Apples and Bananas!' ①
>>> vowel = 'o' ②
>>> new_text = [] ③
>>> for char in text: ④
... if char in 'aeiou': ⑤
... new_text.append(vowel) ⑤
... elif char in 'AEIOU': ⑥
... new_text.append(vowel.upper()) ⑥
... else:
... new_text.append(char) ⑦
...
>>> text = ''.join(new_text) ⑧
>>> text
'Opplos ond Bononos!'
① 将文本设置为字符串“苹果和香蕉!”
② 将元音变量设置为字符串“o”。也就是说,我们将用这个字符替换所有的元音。
③ 将 new_text 变量设置为空列表。
④ 使用 for 循环遍历文本,将每个字符放入 char 变量中。
⑤ 如果字符在小写元音集合中,将元音“o”添加到新文本中。
⑥ 如果字符在 uppercase 元音集合中,将 vowel.upper()版本的“O”替换到新文本中。
⑦ 否则,将当前字符添加到新文本中。
⑧ 将 new_text 列表通过空字符串('')连接成一个新字符串。
注意,一开始将new_text设为空字符串,然后连接新字符是完全可行的。采用这种方法,您就不需要在最后使用str.join()了。无论您喜欢哪种方式:
new_text += vowel
接下来我将向您展示几个替代解决方案。它们在功能上是等效的,因为它们都通过了测试——这里的目的是探索 Python 语言并理解它。对于替代解决方案,我将只展示main()函数。
方法 2:使用 str.replace()方法
这里是一个使用str.replace()方法解决问题的方法:
def main():
args = get_args()
text = args.text
vowel = args.vowel
for v in 'aeiou': ①
text = text.replace(v, vowel).replace(v.upper(), vowel.upper()) ②
print(text)
① 遍历元音列表。我们不必在这里说 list('aeiou')——Python 会自动将字符串'aeiou'视为列表,因为我们正在使用它作为 for 循环中的列表上下文。
② 使用 str.replace()方法两次来替换文本中元音的两种大小写版本。
在本章前面,我提到了str.replace()方法,它将返回一个新的字符串,其中所有实例的一个字符串都被另一个字符串替换:
>>> s = 'foo'
>>> s.replace('o', 'a')
'faa'
>>> s.replace('oo', 'x')
'fx'
注意,原始字符串保持不变:
>>> s
'foo'
您不必链式调用两个str.replace()方法。它可以写成两个单独的语句,如图 8.3 所示。

图 8.3 如果您更喜欢,可以将str.replace()的链式调用写成两个单独的语句。
方法 3:使用 str.translate()方法
我们可以使用 str.translate() 方法解决这个问题吗?我在第四章展示了如何使用一个名为 jumper 的字典将字符“1”转换为字符“9”。在这个问题中,我们需要将所有的小写和大写元音(总共 10 个)转换为某个给定的 vowel。例如,要将所有元音转换为字母“o”,我们可以创建一个转换表 t,如下所示:
t = {'a': 'o',
'e': 'o',
'i': 'o',
'o': 'o',
'u': 'o',
'A': 'O',
'E': 'O',
'I': 'O',
'O': 'O',
'U': 'O'}
我们可以使用 t 与 str.translate() 方法一起使用:
>>> 'Apples and Bananas'.translate(str.maketrans(t))
'Opplos ond Bononos'
如果您阅读 str.maketrans() 的文档,您会发现指定转换表的另一种方法是提供两个长度相等的字符串:
maketrans(x, y=None, z=None, /)
Return a translation table usable for str.translate().
If there is only one argument, it must be a dictionary mapping Unicode
ordinals (integers) or characters to Unicode ordinals, strings or None.
Character keys will be then converted to ordinals.
If there are two arguments, they must be strings of equal length, and
in the resulting dictionary, each character in x will be mapped to the
character at the same position in y. If there is a third argument, it
must be a string, whose characters will be mapped to None in the result.
第一个字符串应包含要替换的字母,即小写和大写元音 'aeiouAEIOU'。第二个字符串由用于替换的字母组成。我们希望用 'ooooo' 替换 'aeiou',用 'OOOOO' 替换 'AEIOU'。我们可以使用 * 操作符(你通常将其与数值乘法关联)重复 vowel 五次。这(某种程度上)是“乘以”一个字符串,所以,好吧,我想:
>>> vowel * 5
'ooooo'
接下来我们处理大写版本:
>>> vowel * 5 + vowel.upper() * 5
'oooooOOOOO'
现在我们可以用一行代码创建转换表,如下所示:
>>> trans = str.maketrans('aeiouAEIOU', vowel * 5 + vowel.upper() * 5)
让我们检查 trans 表。我们将使用 pprint.pprint()(美化打印)函数,这样我们就可以轻松地阅读它:
>>> from pprint import pprint as pp
>>> pp(trans)
{65: 79,
69: 79,
73: 79,
79: 79,
85: 79,
97: 111,
101: 111,
105: 111,
111: 111,
117: 111}
包围的括号 {} 告诉我们 trans 是一个 dict。每个字符都由其 序数值 表示,这是字符在 ASCII 表中的位置(www.asciitable.com)。
您可以通过使用 chr() 和 ord() 函数在字符和它们的序数值之间来回转换。我们将在第十八章中探索并使用这些函数。以下是元音的 ord() 值:
>>> for char in 'aeiou':
... print(char, ord(char))
...
a 97
e 101
i 105
o 111
u 117
您可以通过从 ord() 值开始来创建相同的输出,以获取 chr() 值:
>>> for num in [97, 101, 105, 111, 117]:
... print(chr(num), num)
...
a 97
e 101
i 105
o 111
u 117
>>>
如果您想检查所有可打印字符的所有序数值,您可以运行此命令:
>>> import string
>>> for char in string.printable:
... print(char, ord(char))
我没有包括输出,因为有 100 个可打印字符:
>>> print(len(string.printable))
100
因此,trans 表是从一个字符到另一个字符的映射,就像在第四章的“跳过五个”练习中一样。小写元音(“aeiou”)都映射到序数值 111,即“o”。大写元音(“AEIOU”)映射到 79,即“O”。您可以使用 dict.items() 方法遍历 trans 的键/值对以验证这一点:
>>> for x, y in trans.items():
... print(f'{chr(x)} => {chr(y)}')
...
a => o
e => o
i => o
o => o
u => o
A => O
E => O
I => O
O => O
U => O
原始的 text 不会被 str.translate() 方法更改,因此我们可以用新版本覆盖 text。以下是我如何在解决方案中写下这个想法:
def main():
args = get_args()
vowel = args.vowel
trans = str.maketrans('aeiouAEIOU', vowel * 5 + vowel.upper() * 5) ①
text = args.text.translate(trans) ②
print(text)
① 从每个元音(大小写)创建一个转换表,将其映射到相应的字符。小写元音将匹配小写元音参数,大写元音将匹配大写元音参数。
② 在文本变量上调用 str.translate() 方法,并将转换表作为参数传递。
关于ord()和chr()以及字典等的解释很多,但看看这个解决方案是多么简单和优雅。这比方法 1 短得多。更少的代码行(LOC)意味着更少的错误机会!
方法 4:使用列表推导式
在方法 1 的基础上,我们可以使用列表推导式来显著缩短for循环。在第七章中,我们研究了字典推导式,作为使用for循环创建新字典的单行方法。这里我们可以做同样的事情,创建一个新的list:
def main():
args = get_args()
vowel = args.vowel
text = [ ①
vowel if c in 'aeiou' else vowel.upper() if c in 'AEIOU' else c ②
for c in args.text
]
print(''.join(text)) ③
① 使用列表推导式处理args.text中的所有字符,创建一个名为text的新列表。
② 使用复合if表达式来处理三种情况:小写元音,大写元音和默认情况。
③ 通过在空字符串上连接text列表来打印翻译后的字符串。
让我们再谈谈列表推导式。例如,我们可以通过使用range()函数从起始数字到结束数字(不包括)获取数字,来生成 1 到 4 的数字的平方值的列表。在 REPL 中,我们必须使用list()函数来强制生成值,但通常你的代码不需要这样做:
>>> list(range(1, 5))
[1, 2, 3, 4]
注意range()是 Python 中另一个惰性函数的例子,这意味着它实际上不会产生值,直到你的程序需要它们--一个惰性函数是一个承诺去做某事。如果你的程序以这种方式分支,以至于你永远不会需要产生值,那么工作就不会完成,这意味着你的代码更高效。
我们可以写一个for循环来print()平方数:
>>> for num in range(1, 5):
... print(num ** 2)
...
1
4
9
16
而不是打印值,想象一下我们想要创建一个包含这些值的新list。一种方法是在for循环中创建一个空的list,然后使用list.append()在循环中添加每个值:
>>> squares = []
>>> for num in range(1, 5):
... squares.append(num ** 2)
现在,我们可以验证我们得到了我们的平方数:
>>> assert len(squares) == 4
>>> assert squares == [1, 4, 9, 16]
我们可以使用列表推导式在更少的代码行中实现相同的结果,如图 8.4 所示。
>>> [num ** 2 for num in range(1, 5)]
[1, 4, 9, 16]

图 8.4 列表推导式通过一个for循环遍历源值来创建一个新的列表。
我们可以将这个列表赋值给变量squares并验证我们是否仍然得到了预期的结果。问问自己,你更愿意维护哪种版本的代码:带有for循环的较长的版本,还是带有列表推导式的较短的版本?
>>> squares = [num ** 2 for num in range(1, 5)]
>>> assert len(squares) == 4
>>> assert squares == [1, 4, 9, 16]
对于这个程序的版本,我们将把方法 1 中的if/elif/else逻辑压缩成一个复合if表达式。首先,让我们看看我们如何缩短for循环版本:
>>> text = 'Apples and Bananas!'
>>> new = []
>>> for c in text:
... new.append(vowel if c in 'aeiou' else vowel.upper() if c in 'AEIOU' else c)
...
>>> ''.join(new)
'Opplos ond Bononos!'
图 8.5 显示了表达式的各个部分如何与原始的if/elif/else匹配:

图 8.5 三种条件分支可以用两个if表达式来编写。
现在,让我们将其转换为列表推导式:
>>> text = 'Apples and Bananas!'
>>> new_text = [
... vowel if c in 'aeiou' else vowel.upper() if c in 'AEIOU' else c ①
... for c in text ] ②
...
>>> ''.join(new_text)
'Opplos ond Bononos!'
① 使用复合if表达式选择字符。
② 对文本中的每个字符执行此操作。
代码比之前的for循环更密集,但它在以下方面有优势:
-
列表推导式更短,并生成我们的列表,而不是使用
list.append()的副作用。 -
如果我们忘记其中一个条件分支,复合
if表达式将无法编译。
方法 5:使用带有函数的列表推导式
列表推导式中的复合if表达式足够复杂,可能应该是一个函数。我们可以使用def语句定义一个新的函数,并调用它new_char()。它接受一个我们将称之为c的字符。之后,我们可以使用与之前相同的复合if表达式:
def main():
args = get_args()
vowel = args.vowel
def new_char(c): ①
return vowel if c in 'aeiou' else vowel.upper() if c in 'AEIOU' else c ②
text = ''.join([new_char(c) for c in args.text]) ③
print(text)
① 定义一个函数来选择新字符。请注意,它使用元音变量,因为函数是在同一作用域内声明的。这被称为闭包,因为new_char()封闭在变量上。
② 使用复合if表达式选择正确的字符。
③ 使用列表推导式处理文本中的所有字符。
你可以通过在你的 REPL 中放入以下内容来玩new_char()函数:
vowel = 'o'
def new_char(c):
return vowel if c in 'aeiou' else vowel.upper() if c in 'AEIOU' else c
如果参数是元音字母,它应该始终返回字母“o”:
>>> new_char('a')
'o'
如果参数是大写元音,它应该返回“O”:
>>> new_char('A')
'O'
否则,它应该返回给定的字符:
>>> new_char('b')
'b'
我们可以使用new_char()函数通过列表推导式处理text中的所有字符:
>>> text = 'Apples and Bananas!'
>>> text = ''.join([new_char(c) for c in text])
>>> text
'Opplos ond Bononos!'
注意,new_char()函数是在main()函数内部声明的。是的,你可以这样做!然后函数就只在main()函数内部“可见”。我这样做是因为我们想在函数内部引用vowel变量,而不需要将其作为参数传递。
例如,让我们定义一个foo()函数,它内部有一个bar()函数。我们可以调用foo(),然后它会调用bar()。但从foo()外部,bar()函数不存在(它“不可见”或“不在作用域内”)。
>>> def foo():
... def bar():
... print('This is bar')
... bar()
...
>>> foo()
This is bar
>>> bar()
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
NameError: name 'bar' is not defined
我在main()内部声明了new_char()函数,因为我想要在函数内部引用vowel变量,如图 8.6 所示。因为new_char()“封闭”在vowel周围,它是一种特殊类型的函数,称为闭包。

图 8.6 new_char()函数只能在main()函数内部看到。它创建了一个闭包,因为它引用了vowel变量。main()外部代码无法看到或调用new_char()。
如果我们不将其作为闭包编写,我们将不得不将vowel作为参数传递:
def main():
args = get_args()
print(''.join([new_char(c, args.vowel) for c in args.text])) ①
def new_char(char, vowel): ②
return vowel if char in 'aeiou' else \
vowel.upper() if char in 'AEIOU' else char
① 我们需要将args.vowel作为参数传递给new_char()函数。
② 元音只可见于main()函数内部。由于new_char()不再在同一作用域内声明,我们需要将元音作为参数接受。
尽管闭包方法很有趣,但这个版本可能更容易理解。它也更容易为它编写单元测试,这是我们很快就会开始做的事情。
方法 6:使用map()函数
对于这种方法,我将介绍map()函数,因为它与列表推导式非常相似。map()函数接受两个参数:
-
一个函数
-
一个像列表、惰性函数或生成器这样的可迭代对象

我喜欢把map()想象成一个喷漆间--你把喷漆间装满,比如说,蓝色油漆。未上漆的汽车进去,涂上蓝色油漆,然后蓝色的汽车出来。
我们可以通过在前面添加字符串“blue”来创建一个“paint”汽车的功能:
>>> list(map(lambda car: 'blue ' + car, ['BMW', 'Alfa Romeo', 'Chrysler']))
['blue BMW', 'blue Alfa Romeo', 'blue Chrysler']
你在这里看到的第一个参数以关键字lambda开头,它用于创建一个匿名函数。使用常规的def关键字时,函数名跟在后面。使用lambda时,没有名字,只有参数列表和函数体。 |
![]() |
|---|
例如,一个add1()函数将1加到值上是一个常规的命名函数:
def add1(n):
return n + 1
它按预期工作:
>>> assert add1(10) == 11
>>> assert add1(add1(10)) == 12
将前面的定义与使用lambda创建的、分配给变量add1的定义进行比较:
>>> add1 = lambda n: n + 1
这种add1的定义在功能上与第一个版本等效。我们像调用add1()函数一样调用它:
>>> assert add1(10) == 11
>>> assert add1(add1(10)) == 12
lambda的体是一个简短的表达式(通常是单行)。因为没有return语句,所以表达式的最终评估是自动返回的。在图 8.7 中,你可以看到lambda将返回n + 1的结果。

图 8.7 def和lambda都用于创建函数。
在add1定义的两个版本中,无论是使用def还是lambda,函数的参数都是n。在常规的命名函数中,def add(n),参数定义在函数名后的括号内。而在lambda n版本中,没有函数名,也没有围绕参数n的括号。
你可以使用这两种类型的函数的方式没有区别。它们都是函数:
>>> type(lambda x: x)
<class 'function'>
如果你习惯于在列表推导中使用add1(),就像这样,
>>> [add1(n) for n in [1, 2, 3]]
[2, 3, 4]
使用map()函数只需很短的一步。
map()函数是一个惰性函数,就像我们之前看过的range()函数。它不会在真正需要时创建值,而列表推导则会立即生成结果列表。我个人不太担心代码的性能,而更关心可读性。当我为自己编写代码时,我更喜欢使用map(),但你应该编写对你和你的队友最有意义的代码。
要在 REPL 中强制map()的结果,我们需要使用list()函数:
>>> list(map(add1, [1, 2, 3]))
[2, 3, 4]
| 我们可以在一行中用add1()代码编写列表推导:
>>> [n + 1 for n in [1, 2, 3]]
[2, 3, 4]
这看起来非常类似于lambda代码(如图 8.8 所示):
>>> list(map(lambda n: n + 1, [1, 2, 3]))
[2, 3, 4]
这里是如何使用map()的示例:|
图 8.8 map()函数将创建一个新的列表,通过将可迭代对象的每个元素通过给定的函数进行处理。
def main():
args = get_args()
vowel = args.vowel
text = map( ①
lambda c: vowel if c in 'aeiou' else vowel.upper() ②
if c in 'AEIOU' else c, args.text) ③
print(''.join(text)) ④
① map()函数的第一个参数需要一个函数,第二个参数需要一个可迭代对象。
② 使用lambda创建一个接受字符c的匿名函数。
③ args.text 是 map() 的第二个参数。技术上,args.text 是一个字符串,但由于 map() 预期这个参数是一个列表,字符串将被强制转换为列表。
④ map() 将新的列表返回到 text 变量。我们使用空字符串将其连接起来以打印它。
高阶函数
map() 函数被称为 高阶函数 (HOF),因为它接受 另一个函数 作为参数,这非常酷。稍后我们将使用另一个名为 filter() 的高阶函数。
方法 7:使用带命名函数的 map()
我们不需要使用带有 lambda 表达式的 map()。任何函数都可以工作,所以让我们回到使用我们的 new_char() 函数:
def main():
args = get_args()
vowel = args.vowel
def new_char(c): ①
return vowel if c in 'aeiou' else vowel.upper() if c in 'AEIOU' else c
print(''.join(map(new_char, args.text))) ②
① 定义一个将返回正确字符的函数。请注意,我正在使用闭包版本以便引用“元音”参数。
② 使用 map() 将 new_char() 函数应用于 args.text 中的所有字符。结果是字符列表,我们可以使用 str.join() 将它们转换成一个新的字符串以供打印。
注意 map() 使用 new_char 不带括号 作为第一个参数。如果你添加了括号,你将 调用 函数,并会看到这个错误:
>>> text = ''.join(map(new_char(), text))
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: new_char() missing 1 required positional argument: 'c'
如图 8.9 所示,map() 从 text 中取每个字符,并将其作为参数传递给 new_char() 函数,该函数决定是否返回一个 元音 或原始字符。映射这些字符的结果是一个新的字符列表,我们使用空字符串 str.join() 来创建 text 的新版本。

图 8.9 map() 将给定的函数应用于可迭代对象的每个元素。字符串将被处理为字符列表。
方法 8:使用正则表达式
正则表达式 是描述文本模式的一种方式。正则表达式(也称为“regexes”)是一个独立的领域特定语言(DSL)。它们实际上与 Python 完全无关。它们有自己的语法和规则,并且被用于许多地方,从命令行工具到数据库。正则表达式非常强大,值得花时间学习。
要使用正则表达式,你必须在你的代码中 import re 来导入正则表达式模块:
>>> import re
在这个例子中,我们试图找到元音字符,我们可以将其定义为字母“a”、“e”、“i”、“o”和“u”。为了使用正则表达式描述这个想法,我们将这些字符放在方括号内:
>>> pattern = '[aeiou]'
我们可以使用“替换”函数 re.sub() 来查找所有元音并将其替换为给定的 元音。元音 '[aeiou]' 两侧的方括号创建了一个 字符类,意味着匹配方括号内列出的任何字符。
第二个参数是要替换的字符串,这里它是用户提供的 vowel。第三个参数是我们想要更改的字符串,即用户的 text:
>>> vowel = 'o'
>>> re.sub(pattern, vowel, 'Apples and bananas!')
'Applos ond bononos!'
这样就遗漏了大写字母“A”,所以我们必须同时处理大小写。下面是我们可以这样写的示例:
def main():
args = get_args()
text = args.text
vowel = args.vowel
text = re.sub('[aeiou]', vowel, text) ①
text = re.sub('[AEIOU]', vowel.upper(), text) ②
print(text)
① 将任何小写元音替换为给定的元音(由于 get_args()中的限制,它是小写的)。
② 将任何大写元音替换为大写元音。
如果你愿意,我们可以将两个re.sub()调用压缩成一个,就像我们之前展示的str.replace()方法一样:
>>> text = 'Apples and Bananas!'
>>> text = re.sub('[AEIOU]', vowel.upper(), re.sub('[aeiou]', vowel, text))
>>> text
'Opplos ond Bononos!'
与其他所有解决方案相比,最大的不同之处在于我们使用正则表达式来描述我们正在寻找的内容。我们不必编写识别元音的代码。这更像是声明式编程。我们声明我们想要什么,然后计算机完成繁重的工作!
8.4 使用测试进行重构
解决这个问题的方法有很多。最重要的步骤是确保你的程序能够正常工作。测试让你知道何时达到这一点。从那里,你可以探索其他解决问题的方法,并继续使用测试来确保你的程序仍然是正确的。
测试为你提供了极大的创造性自由。始终思考你可以为你的程序编写的测试,这样当你在以后更改它们时,它们总是会保持工作。
我展示了多种解决这个看似简单问题的方法。其中一些使用高阶函数和正则表达式的技术相当高级。这可能会感觉像是用大锤敲打钉子,但我想开始介绍一些编程思想,这些思想我将在后面的章节中反复提及。
如果你只真正理解了前几个解决方案,那也行!只要跟着我。你看到这些想法在不同环境中应用得越多,它们就越会开始变得有意义。
8.5 进一步探索
编写一个版本的程序,将多个相邻的元音压缩成一个替换值。例如,“quick”应该变成“qack”,而不是“qaack”。
摘要
-
你可以使用
argparse将一个参数的值限制为你定义的choices列表。 -
字符串不能直接修改,但
str.replace()和str.translate()方法可以从现有字符串创建一个新修改过的字符串。 -
字符串上的
for循环将迭代字符串的字符。 -
列表推导是一种简写方式,可以在
[]内编写for循环以创建一个新的list。 -
函数可以定义在其他函数内部。它们的可见性因此限制在封装函数内。
-
函数可以引用同一作用域内声明的变量,从而创建闭包。
-
map()函数类似于列表推导。它将通过将某个函数应用于给定列表的每个成员来创建一个新的、修改后的列表。原始列表将不会被改变。 -
正则表达式通过
re模块提供了一种描述文本模式的语法。re.sub()方法将找到的模式替换为新文本。原文将保持不变。
9. 生成随机侮辱语的“拨号诅咒”
“他或她是一个滑溜的、青蛙嘴的、吃淤泥的鼻涕虫,有着乌龟的头脑。”
--Dial-A-Curse
| 随机事件是有趣的游戏和谜题的核心。人类很快就会对总是相同的事情感到厌倦。我认为人们可能选择养宠物和孩子的原因之一是向他们的生活中注入一些随机性。让我们学习如何通过让程序在每次运行时表现不同来使我们的程序更有趣。 | ![]() |
|---|
这个练习将向你展示如何从选项列表中随机选择一个或多个元素。为了探索随机性,我们将创建一个名为 abuse.py 的程序,该程序将通过随机选择形容词和名词来侮辱用户,创建诽谤性的绰号。
然而,为了测试随机性,我们需要控制它。结果证明,计算机上的“随机”事件很少是真正的随机,而只是伪随机,这意味着我们可以通过使用“种子”来控制它们。每次使用相同的种子时,你都会得到相同的“随机”选择!
莎士比亚有一些最好的侮辱语,所以我们将从他的作品中汲取词汇。以下是您应该使用的形容词列表:
破产、基础、嚎叫、腐败、卑鄙、可憎、虚假、污秽、污秽、愚蠢、肮脏、粗心大意、无法区分的、感染的、贪得无厌的、令人烦恼的、放荡的、好色的、令人厌恶的、油腻的、老的、易怒的、恶心的、丑陋的、无礼的、卑鄙的、腐烂的、破坏性的、污秽的、恶心的、诽谤的、湿脑的、薄脸的、癞蛤蟆斑点的
这些是名词:
犹大、撒旦、猿猴、屁股、理发师、乞丐、石头、吹牛者、屁股、痈疽、懦夫、傻瓜、乌鸦、女巫、杰克、笨蛋、骗子、说谎者、疯子、嘴巴、奶头、寄生虫、捕鼠人、叛徒、流氓、泼妇、奴隶、猪、叛徒、恶棍、虫子
例如,它可能会产生以下结果:
$ ./abuse.py
You slanderous, rotten block!
You lubbery, scurilous ratcatcher!
You rotten, foul liar!
在这个练习中,你将学习如何
-
使用
parser.error()从argparse抛出错误 -
使用随机种子来控制随机性
-
从 Python 列表中获取随机选择和样本
-
使用
for循环迭代算法指定次数 -
格式化输出字符串
9.2 编写 abuse.py
你应该进入 09_abuse 目录来创建你的新程序。让我们先看看它应该生成的用法说明:
$ ./abuse.py -h
usage: abuse.py [-h] [-a adjectives] [-n insults] [-s seed]
Heap abuse
optional arguments:
-h, --help show this help message and exit
-a adjectives, --adjectives adjectives
Number of adjectives (default: 2)
-n insults, --number insults
Number of insults (default: 3)
-s seed, --seed seed Random seed (default: None)
所有参数都是具有默认值的选项,因此我们的程序可以在没有任何参数的情况下运行。
例如,-n 或 --number 选项将默认为 3,并将控制侮辱语的数量:
$ ./abuse.py --number 2
You filthsome, cullionly fiend!
You false, thin-faced minion!
-a 或 --adjectives 选项应该默认为 2,并将确定每个侮辱语中使用的形容词数量:
$ ./abuse.py --adjectives 3
You caterwauling, heedless, gross coxcomb!
You sodden-witted, rascaly, lascivious varlet!
You dishonest, lecherous, foolish varlet!
最后,-s 或 --seed 选项将通过设置一个初始值来控制程序中的随机选择。默认值应该是特殊的 None 值,它类似于一个未定义的值。
因为程序将使用随机种子,所以以下输出应该在任何时间、任何机器上的任何用户那里都是完全可重现的:
$ ./abuse.py --seed 1
You filthsome, cullionly fiend!
You false, thin-faced minion!
You sodden-witted, rascaly cur!
| 当不带参数运行时,程序应该使用默认值生成侮辱:
$ ./abuse.py
You foul, false varlet!
You filthy, insatiate fool!
You lascivious, corrupt recreant!
![]() |
|---|
我建议你首先将 template/template.py 文件复制到 abuse/abuse.py,或者使用 new.py 在你的仓库的 09_abuse 目录中创建 abuse.py 程序。
图 9.1 是一个字符串图,展示了程序的参数。

图 9.1 abuse.py 程序将接受创建侮辱的数量、每个侮辱的形容词数量以及随机种子值等选项。
9.1.1 验证参数
侮辱数量、形容词数量和随机种子的选项都应该都是 int 类型的值。如果你使用 type=int(记住,int 周围没有引号),argparse 将为你处理验证和将参数转换为 int 类型的值。也就是说,只需定义 type=int,如果输入了一个字符串,就会为你生成以下错误:
$ ./abuse.py -n foo
usage: abuse.py [-h] [-a adjectives] [-n insults] [-s seed]
abuse.py: error: argument -n/--number: invalid int value: 'foo'
不仅值必须是数字,而且它必须是一个 整数,这意味着它必须是一个整数,所以 argparse 如果你给它一个看起来像 float 的东西会抱怨。注意,当你实际上想要浮点值时,你可以使用 type=float:
$ ./abuse.py -a 2.1
usage: abuse.py [-h] [-a adjectives] [-n insults] [-s seed]
abuse.py: error: argument -a/--adjectives: invalid int value: '2.1'
此外,如果 --number 或 --adjectives 中的任何一个小于 1,你的程序应该使用错误代码和信息退出:
$ ./abuse.py -a -4
usage: abuse.py [-h] [-a adjectives] [-n insults] [-s seed]
abuse.py: error: --adjectives "-4" must be > 0
$ ./abuse.py -n -4
usage: abuse.py [-h] [-a adjectives] [-n insults] [-s seed]
abuse.py: error: --number "-4" must be > 0
当你开始编写自己的程序和测试时,我建议你借鉴我写的测试。2 让我们看看 test.py 中的一个测试,看看程序是如何被测试的:3
def test_bad_adjective_num(): ①
"""bad_adjectives"""
n = random.choice(range(-10, 0)) ②
rv, out = getstatusoutput(f'{prg} -a {n}') ③
assert rv != 0 ④
assert re.search(f'--adjectives "{n}" must be > 0', out) ⑤
① 函数的名称必须以 “test_” 开头,这样 Pytest 才能找到并运行它。
使用 random.choice() 函数从 -10 到 0 的数字范围内随机选择一个值。我们将在程序中使用这个相同的函数,所以请注意它的调用方式。
③ 使用 subprocess3 模块的 getstatusoutput() 函数运行程序,并使用一个错误的 -a 值。这个函数返回退出值(我将它放入 rv 以表示“返回值”)和标准输出(out)。
④ 断言返回值(rv)不是 0,其中 0 表示成功(或“零错误”)。
⑤ 断言输出中包含声明 --adjectives 参数必须大于 0 的语句。
没有一种简单的方法可以告诉 argparse 形容词和侮辱的数字必须大于零,所以我们将不得不自己检查这些值。我们将使用附录中 A.4.7 节的验证想法。在那里,我介绍了 parser.error() 函数,你可以在 get_args() 函数内部调用它来完成以下操作:
-
打印简短的用法说明
-
向用户打印错误信息
-
停止程序的执行
-
使用非零退出值来指示错误
即,get_args() 通常以这种方式结束:
return args.parse_args()
相反,我们将 args 放入一个变量中,并检查 args.adjectives 的值是否小于 1。如果是,我们将使用错误信息调用 parser.error() 来向用户报告错误:
args = parser.parse_args()
if args.adjectives < 1:
parser.error(f'--adjectives "{args.adjectives}" must be > 0')
我们也会对 args.number 做同样的事情。如果它们都很好,你可以将参数 return 给调用函数:
return args
9.1.2 导入和初始化随机模块
一旦你定义并验证了所有程序的参数,你就可以开始对用户进行侮辱了。首先,我们需要在我们的程序中添加 import random,这样我们就可以使用该模块中的函数来选择形容词和名词。在程序顶部逐个列出所有 import 语句是最佳实践。
在 main() 函数中,我们首先需要做的是调用 get_args() 来获取我们的参数。下一步是将 args.seed 的值传递给 random.seed() 函数:
def main()
args = get_args()
random.seed(args.seed) ①
① 我们调用 random.seed() 函数来设置随机模块状态的初始值。random.seed() 没有返回值——唯一的改变是随机模块内部的。
你可以在 REPL 中阅读关于 random.seed() 函数的信息:
>>> import random
>>> help(random.seed)
在那里,你会了解到该函数将“从可哈希对象初始化 random 模块的内部状态”。也就是说,我们从某种可哈希的 Python 类型设置一个初始值。int 和 str 类型都是可哈希的,但测试是按照你将 seed 参数定义为 int 的预期来编写的。(记住,字符 '1' 与整数值 1 是不同的!)
args.seed 的默认值应该是 None。如果用户没有指定任何种子,那么设置 random.seed(None) 与不设置它是一样的。
如果你查看 test.py 程序,你会注意到所有期望特定输出的测试都会传递一个 -s 或 --seed 参数。以下是输出测试的第一个测试:
def test_01():
out = getoutput(f'{prg} -s 1 -n 1') ①
assert out.strip() == 'You filthsome, cullionly fiend!' ②
① 使用 subprocess 模块的 getoutput() 函数运行程序,使用种子值 1 并请求 1 个侮辱。此函数仅返回程序的输出。
② 验证整个输出是否是预期的侮辱。
这意味着 test.py 将运行你的程序并将输出捕获到 out 变量中:
$ ./abuse.py -s 1 -n 1
You filthsome, cullionly fiend!
然后,它将验证程序是否确实产生了预期数量的侮辱,并且使用了预期的单词选择。
9.1.3 定义形容词和名词
在本章的早期,我给了你一个你应该在程序中使用的长形容词和名词列表。你可以通过逐个引用每个单词来创建一个 list:
>>> adjectives = ['bankrupt', 'base', 'caterwauling']
或者,你可以通过使用 str.split() 方法来创建一个新的 list,从 str 中通过空格分割,从而节省大量的输入:
>>> adjectives = 'bankrupt base caterwauling'.split()
>>> adjectives
['bankrupt', 'base', 'caterwauling']
如果你尝试将所有的形容词组合成一个巨大的字符串,它将会非常长,并在你的代码编辑器中换行,看起来会很丑。我建议你使用三引号(单引号或双引号),这将允许你包含换行符:
>>> """
... bankrupt base
... caterwauling
... """.split()
['bankrupt', 'base', 'caterwauling']
一旦你有了adjectives和nouns的变量,你应该检查你是否有正确数量的每个:
>>> assert len(adjectives) == 36
>>> assert len(nouns) == 39
注意,为了通过测试,你的形容词和名词必须按照提供的顺序排列。
9.1.4 随机抽样和选择
除了random.seed()函数,我们还将使用random.choice()和random.sample()函数。在 9.1.1 节中的test_bad_adjective_num函数中,你看到了使用random.choice()的一个例子。我们可以用类似的方式从nouns 的list中选择一个名词。注意,这个函数返回一个单独的项目,所以,给定一个str值的list,它将返回一个单独的str: |
![]() |
|---|
>>> random.choice(nouns)
'braggart'
>>> random.choice(nouns)
'milksop'
对于adjectives,你应该使用random.sample()。如果你阅读了help(random.sample)的输出,你会看到这个函数接受一些项目的list和一个k参数,用于指定返回多少个项目:
sample(population, k) method of random.Random instance
Chooses k unique random elements from a population sequence or set.
注意,这个函数返回一个新的list:
>>> random.sample(adjectives, 2)
['detestable', 'peevish']
>>> random.sample(adjectives, 3)
['slanderous', 'detestable', 'base']
此外,还有一个random.choices()函数,它的工作方式类似,但它可能会选择相同的项两次,因为它“有放回”地抽样。我们不会使用它。
9.1.5 格式化输出
程序的输出是一个--number数量的侮辱词,你可以使用for循环和range()函数来生成。这里range()从零开始并不重要。重要的是它生成了三个值:
>>> for n in range(3):
... print(n)
...
0
1
2
你可以循环--number次,选择你的形容词样本和名词,然后格式化输出。每个侮辱词应该以字符串“You”开头,然后是形容词(用逗号和空格连接),然后是名词,最后以感叹号结束(图 9.2)。你可以使用 f-string 或str.format()函数将输出print()到STDOUT。

图 9.2 每个侮辱词将把选定的形容词(用逗号连接)与选定的名词和一些静态文本片段组合起来。
这里有一些提示:
-
在
get_args()函数内部执行对--adjectives和--number的正值检查,并使用parser.error()在打印消息和使用说明时抛出错误。 -
如果你将
args.seed的默认值设置为None并使用type=int,你可以直接将值传递给random.seed()。当值为None时,它将像没有设置值一样。 -
使用
range()函数和for循环创建一个循环,该循环将执行--number次以生成每个侮辱词。 -
查看
random.sample()和random.choice()函数以获取选择一些形容词和一个名词的帮助。 -
你可以使用三个单引号(
''')或双引号(""")来创建一个多行字符串,然后使用str.split()来获取字符串的列表。这比逐个引用一个长列表中的短字符串(如形容词和名词列表)要简单。 -
要构造一个要打印的侮辱词,你可以使用
+运算符来连接字符串,使用str.join()方法,或者使用格式化字符串。
在阅读解决方案之前,尽力尝试一下,你这满嘴脏话的鹦鹉粪堆!
9.2 解决方案
这是第一个使用 parser.error() 来增强参数验证的解决方案。我还结合了三个引号字符串,并引入了 random 模块,这很有趣,除非你是个空虚的、咖啡味的、恶臭的混蛋。
#!/usr/bin/env python3
"""Heap abuse"""
import argparse
import random ①
# --------------------------------------------------
def get_args():
"""Get command-line arguments"""
parser = argparse.ArgumentParser(
description='Heap abuse',
formatter_class=argparse.ArgumentDefaultsHelpFormatter)
parser.add_argument('-a', ②
'--adjectives',
help='Number of adjectives',
metavar='adjectives',
type=int,
default=2)
parser.add_argument('-n', ③
'--number',
help='Number of adjectives',
metavar='adjectives',
type=int,
default=3)
parser.add_argument('-s', ④
'--seed',
help='Random seed',
metavar='seed',
type=int,
default=None)
args = parser.parse_args() ⑤
if args.adjectives < 1: ⑥
parser.error('--adjectives "{}" must be > 0'.format(args.adjectives))
if args.number < 1: ⑦
parser.error('--number "{}" must be > 0'.format(args.number))
return args ⑧
# --------------------------------------------------
def main():
"""Make a jazz noise here"""
args = get_args() ⑨
random.seed(args.seed) ⑩
adjectives = """ ⑪
bankrupt base caterwauling corrupt cullionly detestable dishonest false
filthsome filthy foolish foul gross heedless indistinguishable infected
insatiate irksome lascivious lecherous loathsome lubbery old peevish
rascaly rotten ruinous scurilous scurvy slanderous sodden-witted
thin-faced toad-spotted unmannered vile wall-eyed
""".strip().split()
nouns = """ ⑫
Judas Satan ape ass barbermonger beggar block boy braggart butt
carbuncle coward coxcomb cur dandy degenerate fiend fishmonger fool
gull harpy jack jolthead knave liar lunatic maw milksop minion
ratcatcher recreant rogue scold slave swine traitor varlet villain worm
""".strip().split()
for _ in range(args.number): ⑬
adjs = ', '.join(random.sample(adjectives, k=args.adjectives)) ⑭
print(f'You {adjs} {random.choice(nouns)}!') ⑮
# --------------------------------------------------
if __name__ == '__main__':
main()
带入 random 模块,以便我们可以调用函数。
② 定义形容词数量的参数,设置类型为 int 和默认值。
同样定义侮辱数量的参数,将其作为具有默认值的整数。
④ 随机种子默认值应该是 None。
⑤ 获取解析命令行参数的结果。argparse 模块将处理诸如非整数值之类的错误。
⑥ 检查 args.adjectives 是否大于 0。如果有问题,使用错误信息调用 parser.error()。
⑦ 同样检查 args.number。
⑧ 在这一点上,所有用户的参数都已验证,因此将参数返回给调用者。
这里程序实际上开始了,因为它在 main() 函数内部是第一个动作。我总是从获取参数开始。
⑩ 使用用户传递的任何值设置 random.seed()。任何整数值都是有效的,我知道 argparse 已经处理了参数的验证和转换为整数。
⑪ 通过分割包含在三个引号中的非常长的字符串来创建一个形容词列表。
⑫ 对名词列表做同样的处理。
使用一个 for 循环遍历 args.number 的范围。由于我实际上不需要 range() 的值,我可以使用 _ 来忽略它。
使用 random.sample() 函数选择正确数量的形容词,并将它们连接在逗号空格字符串上。
⑮ 使用 f-string 格式化输出以打印。
9.3 讨论
我相信你在通过所有测试之前没有偷看解决方案,否则你就是个肮脏的、恶心的猪。
9.3.1 定义参数
我解决方案的一半以上是定义程序参数给 argparse。这种努力是值得的,因为设置了 type=int,argparse 将确保每个参数都是有效的整数值。注意,int 周围没有引号——它不是字符串 'int',而是对 Python 中类的引用:
parser.add_argument('-a', ①
'--adjectives', ②
help='Number of adjectives', ③
metavar='adjectives', ④
type=int, ⑤
default=2) ⑥
① 短标志
② 长标志
③ 帮助信息
参数的描述
⑤ 实际用于转换输入的 Python 类型;注意,这是整数类的裸词 int
每个侮辱的形容词数量的默认值
我为程序的所有选项设置了合理的默认值,这样就不需要用户输入。--seed 选项应该默认为 None,这样默认行为就是生成伪随机侮辱。这个值仅在测试目的上很重要。
9.3.2 使用 parser.error()
我真的很喜欢argparse模块,因为它为我节省了大量的工作。特别是,当我发现某个参数有问题时,我经常使用parser.error()。这个函数将做四件事:
-
将程序的简短用法打印给用户
-
打印关于问题的特定信息
-
停止程序执行
-
向操作系统返回错误代码
我在这里使用parser.error()是因为,虽然我可以要求argparse验证给定的值是否为int,但我不能轻易地说它必须是一个正数。然而,我可以自己检查这个值,并在有问题时停止程序。我所有的这些操作都在get_args()内部完成,这样,当我在main()函数中得到args时,我知道它们已经被验证了。
我强烈建议你把这个技巧记在心里。它可以证明非常有用,可以节省你大量验证用户输入和生成有用错误信息的时间。(而且,你程序的未来用户很可能就是你,所以你真的会感激你的努力。)
9.3.3 程序退出值和 STDERR
我想强调程序的退出值。在正常情况下,程序应该以值0退出。在计算机科学中,我们通常认为0是一个False值,但在这里它是非常积极的。在这种情况下,我们应该将其视为“零错误”。
如果你使用sys.exit()在你的代码中提前退出程序,默认的退出值是0。如果你想向操作系统或某些调用程序指示你的程序以错误状态退出,你应该返回任何非 0的值。你也可以用字符串调用该函数,它将被打印为错误信息,Python 将以值1退出。如果你在 REPL 中运行此代码,你将返回到命令行:
>>> import sys
>>> sys.exit('You gross, thin-faced worm!')
You gross, thin-faced worm!
此外,通常所有错误信息都会打印到STDERR(标准错误),而不是STDOUT(标准输出)。许多命令行(如 Bash)可以使用1表示STDOUT,2表示STDERR来隔离这两个输出通道。当使用 Bash shell 时,请注意我如何使用2>将STDERR重定向到名为 err 的文件,这样就不会在STDOUT上显示任何内容:
$ ./abuse.py -a -1 2>err
我可以验证预期的错误信息都包含在 err 文件中:
$ cat err
usage: abuse.py [-h] [-a adjectives] [-n insults] [-s seed]
abuse.py: error: --adjectives "-1" must be > 0
如果你需要自己处理所有这些,你需要写点像这样的东西:
if args.adjectives < 1:
parser.print_usage() ①
print(f'--adjectives "{args.adjectives}" must be > 0', file=sys.stderr) ②
sys.exit(1) ③
① 打印简短用法。你也可以使用 parser.print_help()来打印-h 的更详细输出。
② 将错误信息打印到 sys.stderr 文件句柄。这与我们在第五章中使用的 sys.stdout 文件句柄类似。
③ 以非 0 的值退出程序以指示错误。
编写管道
随着你编写越来越多的程序,你最终可能会开始将它们串联起来。我们通常将这些称为 pipelines,因为一个程序的输出被“管道”传输成为下一个程序的输入。如果在管道的任何部分出现错误,你通常会希望整个操作停止,以便可以修复问题。任何程序的零返回值都是一个警告标志,表示停止操作。

9.3.4 使用 random.seed() 控制随机性
random 模块中的伪随机事件遵循一个给定的起始点。也就是说,每次从给定状态开始时,事件将以相同的方式发生。我们可以使用 random.seed() 函数来设置这个起始点。
种子值必须是 可哈希的。根据 Python 文档(docs.python.org/3.1/glossary.html),“Python 的所有不可变内置对象都是可哈希的,而所有可变容器(如列表或字典)都不是。”在这个程序中,我们必须使用整数值,因为测试是用整数种子编写的。当你编写自己的程序时,你可以选择使用字符串或其他可哈希类型。
我们种子的默认值是特殊的 None 值,这有点像未定义的状态。调用 random.seed(None) 实质上等同于没有设置种子,因此这使得编写如下代码变得安全:
random.seed(args.seed)
9.3.5 使用 range() 迭代并使用废弃变量
为了生成一些 --number 个侮辱性言论,我们可以使用 range() 函数。因为我们不需要 range() 返回的数字,所以我们使用下划线 (_) 作为变量名来表示这是一个废弃值:
>>> num_insults = 2
>>> for _ in range(num_insults):
... print('An insult!')
...
An insult!
An insult!
在 Python 中,下划线是一个有效的变量名。你可以将其赋值并使用:
>>> _ = 'You indistinguishable, filthsome carbuncle!'
>>> _
'You indistinguishable, filthsome carbuncle!'
将下划线用作变量名是一种约定,表示我们不打算使用该值。也就是说,如果我们说过 for num in range(...),一些工具如 Pylint 会看到 num 变量没有被使用,并将此报告为可能的错误(确实如此)。_ 表示你正在丢弃这个值,这对于你未来的自己、其他用户或外部工具来说是一个有用的信息。
注意,你可以在同一个语句中使用多个 _ 变量。例如,我可以解包一个 3 元组以获取中间值:
>>> x = 'Jesus', 'Mary', 'Joseph'
>>> _, name, _ = x
>>> name
'Mary'
9.3.6 构建侮辱性言论
为了创建我的形容词列表,我使用了 str.split() 方法在一个用三引号包裹的长多行字符串上。我认为这可能是将所有这些字符串放入程序中最简单的方法。三引号允许我们输入换行符,这是单引号所不允许的:
>>> adjectives = """
... bankrupt base caterwauling corrupt cullionly detestable dishonest
... false filthsome filthy foolish foul gross heedless indistinguishable
... infected insatiate irksome lascivious lecherous loathsome lubbery old
... peevish rascaly rotten ruinous scurilous scurvy slanderous
... sodden-witted thin-faced toad-spotted unmannered vile wall-eyed
... """.strip().split()
>>> nouns = """
... Judas Satan ape ass barbermonger beggar block boy braggart butt
... carbuncle coward coxcomb cur dandy degenerate fiend fishmonger fool
... gull harpy jack jolthead knave liar lunatic maw milksop minion
... ratcatcher recreant rogue scold slave swine traitor varlet villain worm
... """.strip().split()
>>> len(adjectives)
36
>>> len(nouns)
39
因为我们需要一个或多个形容词,所以 random.sample() 函数是一个很好的选择。它将返回一个从给定列表中随机选择的 list:
>>> import random
>>> random.sample(adjectives, k=3)
['filthsome', 'cullionly', 'insatiate']
| random.choice() 函数适用于从列表中选择一个项目,例如我们侮辱中的名词:
>>> random.choice(nouns)
'boy'
![]() |
|---|
接下来,我们需要使用 ', '(一个逗号和一个空格) 连接绰号,就像我们在第三章为野餐物品所做的那样。str.join() 函数非常适合这个任务:
>>> adjs = random.sample(adjectives, k=3)
>>> adjs
['thin-faced', 'scurvy', 'sodden-witted']
>>> ', '.join(adjs)
'thin-faced, scurvy, sodden-witted'
为了创建侮辱,我们可以使用 f-string 在模板内部组合形容词和名词:
>>> adjs = ', '.join(random.sample(adjectives, k=3))
>>> print(f'You {adjs} {random.choice(nouns)}!')
You heedless, thin-faced, gross recreant!
现在我有了一种方便的方法来制造敌人并影响人们。
9.4 进一步探索
-
从作为参数传递的文件中读取你的形容词和名词。
-
添加测试以验证文件是否正确处理,并且新的侮辱仍然具有攻击性。
摘要
-
使用
parser.error()函数打印简短的用法说明,报告问题,并以错误值退出程序。 -
三重引号字符串可以包含换行符,与常规的单引号或双引号字符串不同。
-
str.split()方法是从长字符串创建字符串值列表的有用方式。 -
random.seed()函数可以在每次程序运行时生成可重复的伪随机选择。 -
random.choice()和random.sample()函数分别用于从选择列表中随机选择一个或多个项目。
1 “随机数的生成太重要了,不能留给偶然。”--罗伯特·R·科维尤
2 “好作曲家借鉴,伟大的作曲家窃取。” -- 伊戈尔·斯特拉文斯基
3 subprocess 模块允许你在程序内部运行命令。subprocess.getoutput() 函数将捕获命令的输出,而 subprocess.getstatusoutput() 将捕获退出值和命令的输出。
10 电话:随机突变字符串
“我们这里的问题是沟通失败。”
--船长
| 现在我们已经玩过了随机性,让我们将这个想法应用到随机突变字符串上。这很有趣,因为字符串在 Python 中实际上是 不可变的。我们将不得不想出一个解决方案。为了探索这些想法,我们将编写一个电话游戏的版本,其中一条秘密信息通过一排或一圈人悄悄传递。每次传递信息时,它通常都会以某种不可预测的方式改变。最后收到信息的人会大声说出信息,以便与原始信息进行比较。通常结果是无意义的,可能是滑稽的。 | ![]() |
|---|
我们将编写一个名为 telephone.py 的程序来模拟这个游戏。它将打印 “你说:” 和原始文本,然后是修改后的消息版本,后面跟着 “我听到:”。正如第五章所述,输入文本可能来自命令行:
$ ./telephone.py 'The quick brown fox jumps over the lazy dog.'
You said: "The quick brown fox jumps over the lazy dog."
I heard : "TheMquick brown fox jumps ovMr t:e lamy dog."
或者它可能来自一个文件:
$ ./telephone.py ../inputs/fox.txt
You said: "The quick brown fox jumps over the lazy dog."
I heard : "The quick]b'own fox jumps ovek the lay dog."
程序应接受 -m 或 --mutations 选项,该选项应是一个介于 0 和 1 之间的浮点数,默认值为 0.1(10%)。这将是要更改的字母数量的百分比。例如,.5 表示 50% 的字母应该被更改:
$ ./telephone.py ../inputs/fox.txt -m .5
You said: "The quick brown fox jumps over the lazy dog."
I heard : "F#eYquJsY ZrHnna"o. Muz/$ Nver t/Relazy dA!."
由于我们使用了 random 模块,我们将接受 -s 或 --seed 选项的 int 值,这样我们就可以重现我们的伪随机选择:
$ ./telephone.py ../inputs/fox.txt -s 1
You said: "The quick brown fox jumps over the lazy dog."
I heard : "The 'uicq brown *ox jumps over the l-zy dog."
图 10.1 显示了程序的字符串图。
![
图 10.1 电话程序将接受文本和可能的突变百分比,以及一个随机种子。输出将是输入文本的随机突变版本。
在这个练习中,你将学习到
-
圆整数
-
使用
string模块 -
修改字符串和列表以引入随机突变
10.1 编写 telephone.py
我建议你使用 new.py 程序在 10_telephone 目录下创建一个名为 telephone.py 的新程序。你可以从存储库的顶层这样做:
$ ./bin/new.py 10_telephone/telephone.py
你也可以将 template/template.py 复制到 10_telephone/telephone.py。修改 get_args() 函数,直到你的 -h 输出与以下内容匹配。我建议你为突变参数使用 type=float:
$ ./telephone.py -h
usage: telephone.py [-h] [-s seed] [-m mutations] text
Telephone
positional arguments:
text Input text or file
optional arguments:
-h, --help show this help message and exit
-s seed, --seed seed Random seed (default: None)
-m mutations, --mutations mutations
Percent mutations (default: 0.1)
现在运行测试套件。你应该至少通过前两个测试(当使用 -h 或 --help 运行 telephone.py 程序时,程序存在并打印用法说明)。
下两个测试检查你的 --seed 和 --mutations 选项都拒绝非数值输入。如果你分别使用 int 和 float 类型定义这些参数,这应该会自动发生。也就是说,你的程序应该表现得像这样:
$ ./telephone.py -s blargh foo
usage: telephone.py [-h] [-s seed] [-m mutations] text
telephone.py: error: argument -s/--seed: invalid int value: 'blargh'
$ ./telephone.py -m blargh foo
usage: telephone.py [-h] [-s seed] [-m mutations] text
telephone.py: error: argument -m/--mutations: invalid float value: 'blargh'
下一个测试检查程序是否拒绝 --mutations 参数在 0-1 范围之外(其中两个边界都是包含的)。这不是一个可以轻易描述给 argparse 的检查,所以我建议您查看第九章中 abuse.py 中我们如何处理参数验证。在那个程序的 get_args() 函数中,我们手动检查参数的值,并使用 parser.error() 函数抛出错误。注意,--mutations 的值为 0 是可接受的,在这种情况下,我们将打印出未经修改的输入文本。您的程序应该这样做:
$ ./telephone.py -m -1 foobar
usage: telephone.py [-h] [-s seed] [-m mutations] text
telephone.py: error: --mutations "-1.0" must be between 0 and 1
这是一个接受从命令行或文件中输入文本的程序,我建议您查看第五章中的解决方案。在 get_args() 函数内部,您可以使用 os.path.isfile() 来检测文本参数是否为文件。如果是文件,则读取文件内容作为 text 值。
一旦您处理完所有程序参数,开始您的 main() 函数,设置 random.seed() 并回显给定的文本:
def main():
args = get_args()
random.seed(args.seed)
print(f'You said: "{args.text}"')
print(f'I heard : "{args.text}"')
您的程序应该处理命令行文本:
$ ./telephone.py 'The quick brown fox jumps over the lazy dog.'
You said: "The quick brown fox jumps over the lazy dog."
I heard : "The quick brown fox jumps over the lazy dog."
它应该处理输入文件:
$ ./telephone.py ../inputs/fox.txt
You said: "The quick brown fox jumps over the lazy dog."
I heard : "The quick brown fox jumps over the lazy dog."
到这一点,您的代码应该通过 test_for_echo() 测试。接下来的测试开始要求您对输入进行突变,让我们讨论如何进行突变。
10.1.1 计算突变数量
需要更改的字母数量可以通过将输入文本的长度乘以 args.mutations 值来计算。如果我们想更改 “The quick brown fox...” 字符串中的 20% 的字符,我们会发现它不是一个整数:
>>> text = 'The quick brown fox jumps over the lazy dog.'
>>> mutations = .20
>>> len(text) * mutations
8.8
我们可以使用 round() 函数来给出最近的整数值。阅读 help(round) 来了解如何将浮点数四舍五入到特定的小数位数:
>>> round(len(text) * mutations)
9
注意,您也可以通过使用 int 函数将 float 转换为 int,但这会截断数字的小数部分而不是四舍五入:
>>> int(len(text) * mutations)
8
您将需要这个值用于后续操作,所以让我们将其保存到一个变量中:
>>> num_mutations = round(len(text) * mutations)
>>> assert num_mutations == 9
10.1.2 突变空间
| 当我们更改一个字符时,我们会将其更改为什么?为此,我们将使用 string 模块。我鼓励您通过导入模块并阅读 help(string) 来查看文档:
>>> import string
>>> help(string)
![]() |
|---|
例如,我们可以获取所有小写 ASCII 字母如下。注意,这不是一个方法调用,因为末尾没有括号 ():
>>> string.ascii_lowercase
'abcdefghijklmnopqrstuvwxyz'
这将返回一个 str:
>>> type(string.ascii_lowercase)
<class 'str'>
对于我们的程序,我们可以使用 string.ascii_letters 和 string.punctuation 来获取所有字母和标点符号的字符串。要将这两个字符串连接起来,我们可以使用 + 操作符。我们将从这个字符串中随机选择一个字符来替换另一个字符:
>>> alpha = string.ascii_letters + string.punctuation
>>> alpha
'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ!"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~'
注意,即使我们使用相同的随机种子,如果我们的字母顺序不同,你和我得到的结果也会不同。为了确保我们的结果一致,我们需要对 alpha 字符进行排序,以确保它们处于一致顺序。
10.1.3 选择要突变的字符
至少有两种方法可以选择要更改的字符:一种是一种确定性方法,其结果总是保证相同;另一种是非确定性方法,我们利用机会来接近目标。让我们首先考察后者。
非确定性选择
选择要更改的字符的一种方法可以模仿第八章中的方法 1。我们可以遍历文本中的每个字符,并选择一个随机数来决定是否保留原始字符或将其更改为随机选择的值。如果我们的随机数小于或等于突变设置,我们应该更改字符:
new_text = '' ①
for char in args.text: ②
new_text += random.choice(alpha) if random.random() <= args.mutations else char ③
print(new_text) ④
① 将新文本初始化为一个空字符串。
② 遍历文本中的每个字符。
③ 使用random.random()生成一个介于 0 和 1 之间的均匀分布的浮点数。如果这个值小于或等于args.mutation值,我们就从alpha中随机选择;否则,我们使用原始字符。
④ 打印出结果的新文本。
在第九章的 abuse.py 中,我们使用了random.choice()函数从选择列表中随机选择一个值。我们可以在这里使用它来从alpha中选择一个字符,如果random.random()的值在args.mutation值的范围内(我们知道它也是一个float)。
这种方法的缺点是,到for循环结束时,我们并不能保证恰好进行了正确的数量更改。也就是说,当突变率为 20%时,我们计算出应该更改 44 个字符中的 9 个。我们预计使用此代码将更改大约 20%的字符,因为从 0 到 1 的均匀分布的随机值大约有 20%的时间会小于或等于 0.2。有时我们可能只更改 8 个字符,有时我们可能更改 10 个。由于这种不确定性,这种方法被认为是非确定性的。
然而,这是一个非常有用的技术,你应该注意。想象一下,你有一个包含数百万或可能数十亿行文本的输入文件,你想要随机抽取大约 10%的行。前面提到的方法将会相对快速且准确。更大的样本量将帮助你更接近所需的突变数量。
随机采样字符
对于百万行文件的一种确定性方法是首先读取整个输入来计算行数,选择要取的行,然后再次遍历文件以获取这些行。这种方法将比上面描述的方法花费更长的时间。根据输入文件的大小、程序的编写方式和计算机的内存量,程序甚至可能崩溃你的计算机!
| 我们输入的相当小,所以我们将会使用这个算法,因为它具有精确和可测试的优点。然而,我们不会专注于文本行,而是会考虑字符索引。你见过str .replace()方法(在第八章中),它允许我们将一个字符串的所有实例更改为另一个字符串:
>>> 'foo'.replace('o', 'a')
'faa'
![]() |
|---|
我们不能使用str.replace(),因为它会改变某些字符的所有出现,而我们只想改变单个字符。相反,我们可以使用random .sample()函数来选择文本中字符的一些索引。random.sample()的第一个参数需要是类似list的东西。我们可以给它一个range(),其数值范围是text的长度。
假设我们的text有 44 个字符长:
>>> text
'The quick brown fox jumps over the lazy dog.'
>>> len(text)
44
我们可以使用range()函数来创建一个包含最多 44 个数字的list:
>>> range(len(text))
range(0, 44)
注意,range()是一个惰性函数。它实际上不会产生 44 个值,直到我们强制它这样做,我们可以在 REPL 中使用list()函数来做到这一点:
>>> list(range(len(text)))
我们之前计算出,改变text的 20%的num_mutations值是 9。这里是一组可能被更改的索引选择:
>>> indexes = random.sample(range(len(text)), num_mutations)
>>> indexes
[13, 6, 31, 1, 24, 27, 0, 28, 17]
我建议你使用一个for循环来遍历这些索引值:
>>> for i in indexes:
... print(f'{i:2} {text[i]}')
...
13 w
6 i
31 t
1 h
24 s
27 v
0 T
28 e
17 o
你应该将每个索引位置的字符替换为从alpha中随机选择的字符:
>>> for i in indexes:
... print(f'{i:2} {text[i]} changes to {random.choice(alpha)}')
...
13 w changes to b
6 i changes to W
31 t changes to B
1 h changes to #
24 s changes to d
27 v changes to :
0 T changes to C
28 e changes to %
17 o changes to ,
我将引入一个额外的转折——我们不希望替换值与被替换的字符相同。你能想出如何得到一个不包含该位置字符的alpha子集吗?
10.1.4 修改字符串
Python 的str变量是不可变的,这意味着我们无法直接修改它们。例如,如果我们想将位置13的字符'w'更改为'b',直接修改text[13]将会很方便,但这样会抛出异常:
>>> text[13] = 'b'
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: 'str' object does not support item assignment
要修改str值text的唯一方法是用一个新的str覆盖它。我们需要创建一个新的str,如下所示,如图 10.2 所示:
-
text给定索引之前的部分 -
从
alpha中随机选择的值 -
text给定索引之后的部分

图 10.2 通过选择字符串的一部分,一个新字符以及索引之后的部分来创建一个新的字符串。
对于 1 和 3,你可以使用字符串切片。例如,如果索引i是 13,那么它之前的切片是
>>> text[:13]
'The quick bro'
它之后的部分是
>>> text[14:]
'n fox jumps over the lazy dog.'
使用前面列出的三个部分,你的for循环应该是
for i in index:
text = 1 + 2 + 3
你能理解这个吗?
10.1.5 写作时间
好了,课程结束了。你现在要去写这个了。使用测试。一次解决一个。你可以做到的。
10.2 解决方案
你的解决方案与我的解决方案有多大的不同?让我们看看一种编写满足测试的程序的方法:
#!/usr/bin/env python3
"""Telephone"""
import argparse
import os
import random
import string ①
# --------------------------------------------------
def get_args():
"""Get command-line arguments"""
parser = argparse.ArgumentParser(
description='Telephone',
formatter_class=argparse.ArgumentDefaultsHelpFormatter)
parser.add_argument('text', metavar='text', help='Input text or file') ②
parser.add_argument('-s', ③
'--seed',
help='Random seed',
metavar='seed',
type=int,
default=None)
parser.add_argument('-m', ④
'--mutations',
help='Percent mutations',
metavar='mutations',
type=float,
default=0.1)
args = parser.parse_args() ⑤
if not 0 <= args.mutations <= 1: ⑥
parser.error(f'--mutations "{args.mutations}" must be between 0 and 1')
if os.path.isfile(args.text): ⑦
args.text = open(args.text).read().rstrip()
return args ⑧
# --------------------------------------------------
def main():
"""Make a jazz noise here"""
args = get_args()
text = args.text
random.seed(args.seed) ⑨
alpha = ''.join(sorted(string.ascii_letters + string.punctuation)) ⑩
len_text = len(text) ⑪
num_mutations = round(args.mutations * len_text) ⑫
new_text = text ⑬
for i in random.sample(range(len_text), num_mutations): ⑭
new_char = random.choice(alpha.replace(new_text[i], '')) ⑮
new_text = new_text[:i] + new_char + new_text[i + 1:] ⑯
print(f'You said: "{text}"\nI heard : "{new_text}"') ⑰
# --------------------------------------------------
if __name__ == '__main__':
main()
① 导入我们需要用来选择随机字符的字符串模块。
② 为文本定义一个位置参数。这可以是文本字符串或需要读取的文件。
③ --seed 参数是一个默认值为 None 的整数值。
④ --mutations 参数是一个默认值为 0.1 的浮点数值。
⑤ 处理命令行中的参数。如果 argparse 检测到问题,例如种子或突变值的非数值,程序将在这里终止,用户将看到错误信息。如果此调用成功,则 argparse 已验证参数并转换了值。
⑥ 如果 args.mutations 不在可接受的 0-1 范围内,使用 parser.error() 停止程序并打印给定信息。注意使用反馈来将错误的 args.mutation 值回显给用户。
⑦ 如果 args.text 命名了一个现有文件,则读取该文件的内容并覆盖 args.text 的原始值。
⑧ 将处理后的参数返回给调用者。
⑨ 将 random.seed() 设置为用户提供的值。记住,args.seed 的默认值是 None,这与未设置种子相同。
⑩ 将 alpha 设置为我们将用于替换的字符。sorted() 函数将返回一个新列表,其中包含字符的正确顺序,然后我们可以使用 str.join() 函数将其转换回字符串值。
⑪ 由于我们多次使用 len(text),我们将其放入一个变量中。
⑫ 通过将突变率乘以文本的长度来计算 num_mutations。
⑬ 复制文本。
⑭ 使用 random.sample() 获取要更改的 num_mutations 索引。此函数返回一个列表,我们可以使用 for 循环进行迭代。
⑮ 使用 random.choice() 从由将 alpha 变量中的当前字符(text[i])替换为空创建的字符串中选择一个新字符。这确保了新字符不能与我们要替换的字符相同。
⑯ 通过连接当前索引之前的切片、新字符以及当前索引之后的切片来覆盖文本。
⑰ 打印文本。
10.3 讨论
get_args() 函数中没有你之前没有见过的内容。--seed 参数是一个 int 类型,我们将将其传递给 random.seed() 函数以控制测试中的随机性。默认的种子值是 None,这样我们就可以调用 random.seed(args.seed),其中 None 与未设置相同。--mutations 参数是一个具有合理默认值的 float 类型,如果值不在适当的范围内,我们使用 parser.error() 创建错误信息。与其他程序一样,我们测试 text 参数是否是一个文件,如果是,则读取其内容。
10.3.1 修改字符串
你之前看到我们不能只是改变 text 字符串:
>>> text = 'The quick brown fox jumps over the lazy dog.'
>>> text[13] = 'b'
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: 'str' object does not support item assignment
我们必须使用文本的 i 前后部分创建一个新的字符串,我们可以通过使用字符串切片 text[start:stop] 来获取它。如果你省略 start,Python 从 0(字符串的开始)开始,如果你省略 stop,它将延伸到末尾,所以 text[:] 是整个字符串的副本。
如果 i 是 13,则 i 前的位是
>>> i = 13
>>> text[:i]
'The quick bro'
i + 1 后的位是
>>> text[i+1:]
'n fox jumps over the lazy dog.'
现在来看看中间应该放什么。我注意到我们应该使用 random.choice() 从 alpha 中选择一个字符,alpha 是所有 ASCII 字母和标点符号的组合,但不包括当前字符。我使用 str.replace() 方法来去除当前的字母:
>>> alpha = ''.join(sorted(string.ascii_letters + string.punctuation))
>>> alpha.replace(text[i], '')
'!"#$%&\'()*+,-./:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvxyz{|}~'
然后,我使用它来获取一个新字母,它不会包括它所替换的内容:
>>> new_char = random.choice(alpha.replace(text[i], ''))
>>> new_char
'Q'
有许多方法可以将字符串连接成新的字符串。+ 操作符可能是最简单的:
>>> text = text[:i] + new_char + text[i+1:]
>>> text
'The quick broQn fox jumps over the lazy dog.'
我为 random.sample() 中的每个索引执行此操作,每次都覆盖 text。在 for 循环完成后,我已经突变了输入字符串的所有位置,并且可以 print() 它。
10.3.2 使用列表而不是字符串
字符串是不可变的,但列表不是。你已经看到像 text[13] = 'b' 这样的操作会创建一个异常,但我们可以将 text 改成一个列表,并使用相同的语法直接修改它:
>>> text = list(text)
>>> text[13] = 'b'
然后,我们可以通过在空字符串上连接它来将那个 list 转换回一个 str:
>>> ''.join(text)
'The quick brobn fox jumps over the lazy dog.'
这里是使用此方法的 main() 版本:
def main():
args = get_args()
text = args.text
random.seed(args.seed)
alpha = ''.join(sorted(string.ascii_letters + string.punctuation))
len_text = len(text)
num_mutations = round(args.mutations * len_text)
new_text = list(text) ①
for i in random.sample(range(len_text), num_mutations):
new_text[i] = random.choice(alpha.replace(new_text[i], '')) ②
print('You said: "{}"\nI heard : "{}"'.format(text, ''.join(new_text))) ③
① 将 new_text 初始化为原始文本值的列表。
② 现在我们可以直接修改 new_text 中的值。
③ 在空字符串上连接 new_list 以创建一个新的 str。
没有一种方法比另一种方法有特别的优势,但我会个人选择第二种方法,因为我不喜欢与字符串切片打交道。对我来说,就地修改 list 比反复切割和拼接 str 要有意义得多。
DNA 中的突变
值得注意的是,这个程序(某种程度上)模仿了 DNA 随时间变化的方式。复制 DNA 的机制会出错,突变会随机发生。通常,这种变化对生物体没有有害的影响。
我们的示例仅更改字符为其他字符——生物学家称之为“点突变”、“单核苷酸变异”(SNV)或“单核苷酸多态性”(SNP)。我们可以编写一个版本,它还会随机删除或插入新字符,这些称为“in-dels”(插入-删除)。突变(不会导致生物体死亡)以相当标准的速率发生,因此,通过计算任何两个生物体保守区域之间的突变数量,可以估计它们从共同祖先分离的时间。
10.4 进一步探索
-
将突变应用于随机选择的单词而不是整个字符串。
-
除了突变之外,还要执行插入和删除操作;也许可以为每种操作的百分比创建参数,并选择在指定的频率下添加或删除字符。
-
添加
-o或--output选项,指定要写入输出的文件名。默认情况下,应打印到STDOUT。 -
添加一个标志以限制替换仅限于字符值(不包括标点)。
-
为 test.py 中的每个新功能添加测试,并确保程序正常工作。
摘要
|
-
字符串不能直接修改,但包含字符串的变量可以被新值反复覆盖。
-
列表可以直接修改,因此有时使用
list将字符串转换为列表,修改该列表,然后使用str.join()将其转换回字符串可能会有所帮助。 -
string模块提供了各种字符串的便捷定义。
![]() |
|---|
11 瓶啤酒歌曲:编写和测试函数
| 没有歌曲像“墙上的 99 瓶啤酒”那样令人烦恼。希望你从未被迫在货车里和喜欢唱这首歌的中学生一起度过几个小时。我有过。这是一首相当简单的歌,我们可以编写一个算法来生成它。这将给我们一个机会来练习上下计数、格式化字符串,以及——在这个练习中是新的——编写函数及其测试! | ![]() |
|---|
我们的项目将命名为 bottles.py,并接受一个选项 -n 或 --num,它必须是一个 正数 int(默认值为 10)。程序应该从 --num 打印到 1 的所有节。每个节之间应该有两个换行符来视觉上分隔它们,但在最后一个节之后必须只有一个换行符(对于一瓶),应该打印 “墙上的啤酒瓶没有更多” 而不是 “0 瓶”:
$ ./bottles.py -n 3
3 bottles of beer on the wall,
3 bottles of beer,
Take one down, pass it around,
2 bottles of beer on the wall!
2 bottles of beer on the wall,
2 bottles of beer,
Take one down, pass it around,
1 bottle of beer on the wall!
1 bottle of beer on the wall,
1 bottle of beer,
Take one down, pass it around,
No more bottles of beer on the wall!
在这个练习中,你将
-
学习如何生成一个按值递减的数字列表
-
编写一个函数来创建歌曲的节,使用测试来验证节是否正确
-
探索如何将
for循环写成列表推导式,而列表推导式又可以写成map()函数的形式
11.1 编写 bottles.py
我们将在 11_bottles_of_beer 目录下工作。首先,复制 template.py 或使用 new.py 在那里创建 bottles.py 程序。然后修改 get_args() 函数,直到你的用法与以下用法说明相符。你需要定义的只有 --num 选项,其类型为 int,默认值为 10:
$ ./bottles.py -h
usage: bottles.py [-h] [-n number]
Bottles of beer song
optional arguments:
-h, --help show this help message and exit
-n number, --num number
How many bottles (default: 10)
如果 --num 参数不是一个 int 值,你的程序应该打印一个错误消息并退出,返回错误值。如果你正确地定义了参数到 argparse,这应该会自动发生:
$ ./bottles.py -n foo
usage: bottles.py [-h] [-n number]
bottles.py: error: argument -n/--num: invalid int value: 'foo'
$ ./bottles.py -n 2.4
usage: bottles.py [-h] [-n number]
bottles.py: error: argument -n/--num: invalid int value: '2.4'
由于我们不能唱零或更少的节,我们需要检查 --num 是否小于 1。为了处理这个问题,我建议你在 get_args() 函数中使用 parser.error(),就像之前的练习一样:
$ ./bottles.py -n 0
usage: bottles.py [-h] [-n number]
bottles.py: error: --num "0" must be greater than 0
图 11.1 显示了输入和输出的字符串图。

图 11.1 瓶子程序可能需要一个数字来指定起始节,或者它会从 10 开始唱这首歌。
11.1.1 倒数计数
歌曲从给定的 --num 值开始,比如 10,需要倒数到 9、8、7 等等。我们如何在 Python 中做到这一点?我们已经看到如何使用 range(start, stop) 来获取一个按值 递增 的整数列表。如果你只提供一个数字,那么这个数字将被认为是 stop,它将假设 start 为 0:
|
>>> list(range(5))
[0, 1, 2, 3, 4]
因为这是一个惰性函数,我们必须在 REPL 中使用 list() 函数来强制它产生数字。记住,stop 值永远不会包含在输出中,所以前面的输出在 4 处停止,而不是 5。 |
|
如果你给 range() 函数两个数字,它们被认为是 start 和 stop:
>>> list(range(1, 5))
[1, 2, 3, 4]
要反转这个序列,你可能想交换start和stop的值。不幸的是,如果start大于stop,你会得到一个空列表:
>>> list(range(5, 1))
[]
你在第三章中看到,我们可以使用reversed()函数来反转一个list。这是一个惰性函数,所以我会再次使用list()函数来强制 REPL 中的值:
>>> list(reversed(range(1, 5)))
[4, 3, 2, 1]
| range()函数也可以接受一个可选的第三个参数作为步长值。例如,你可以使用这个来以五的倍数计数:
>>> list(range(0, 50, 5))
[0, 5, 10, 15, 20, 25, 30, 35, 40, 45]
![]() |
|---|
另一种倒数的方法是交换start和stop,并使用-1作为步长:
>>> list(range(5, 0, -1))
[5, 4, 3, 2, 1]
因此,你有几种方法可以反向计数。
11.1.2 编写一个函数
到目前为止,我建议你所有的代码都放入main()函数中。这是第一个建议你编写函数的练习。我想让你考虑如何编写代码来唱仅一个节。这个函数可以接受节的数量,并返回该节的文本。
你可以从图 11.2 中的示例开始。def关键字“定义”了一个函数,函数名紧随其后。函数名应只包含字母、数字和下划线,并且不能以数字开头。在名称之后是括号,它描述了函数接受的任何参数。在我们的函数中,它将被命名为verse(),并且它有一个参数bottle(或number或你想要叫它的任何名字)。在参数之后是一个冒号,表示def行的结束。接下来是函数主体,所有行至少缩进四个空格。

图 11.2 Python 中函数定义的元素
图 11.2 中的文档字符串是一个函数定义之后的字符串。它将显示在你的函数的帮助信息中。
你可以将此函数输入到 REPL 中:
>>> def verse(bottle):
... """Sing a verse"""
... return ''
...
>>> help(verse)
当你这样做时,你会看到这个:
Help on function verse in module __main__:
verse(bottle)
Sing a verse
return语句告诉 Python 从函数中返回什么。现在它并不很有趣,因为它只会返回一个空字符串:
>>> verse(10)
''
使用pass语句作为虚拟函数的主体也是一种常见的做法。pass将不会做任何事情,函数将返回None而不是空字符串,就像我们在这里所做的那样。当你开始编写自己的函数和测试时,你可能喜欢在创建新函数的存根时使用pass,直到你决定函数将做什么。
11.1.3 为 verse()编写测试
在测试驱动开发的精神下,在我们继续之前,让我们为verse()编写一个测试。下面的列表显示了你可以使用的测试。将此代码添加到你的 bottles.py 程序中,在main()函数之后:
def verse(bottle):
"""Sing a verse"""
return ''
def test_verse():
"""Test verse"""
last_verse = verse(1)
assert last_verse == '\n'.join([
'1 bottle of beer on the wall,', '1 bottle of beer,',
'Take one down, pass it around,',
'No more bottles of beer on the wall!'
])
two_bottles = verse(2)
assert two_bottles == '\n'.join([
'2 bottles of beer on the wall,', '2 bottles of beer,',
'Take one down, pass it around,', '1 bottle of beer on the wall!'
])
你可以有很多种方式来编写这个程序。我心中所想的是,我的 verse() 函数将生成歌曲的单节,返回一个新的 str 值,它是节的行通过换行符连接起来的。你不必以这种方式编写你的程序,但我希望你能考虑编写一个函数和一个 单元测试 的意义。如果你阅读有关软件测试的内容,你会发现关于“单元”代码的不同定义。在这本书中,我认为一个 函数 是一个 单元,因此我的单元测试是对单个函数的测试。 |
![]() |
|---|
尽管这首歌可能有数百节,但这两个测试应该涵盖你需要检查的所有内容。查看图 11.3 中的音乐符号可能会有所帮助,因为它很好地图形化了歌曲的结构,因此我们的程序。

图 11.3 歌曲的乐谱显示了有两个情况需要处理:一个是处理到最后的一节,然后是最后一节。
我在符号上做了一些变通,混合了一些编程思想。如果你不知道如何阅读音乐,让我简要地解释一下重要部分。N 表示当前的 数字,比如 “99”,所以 (N - 1) 就会是 “98”。结尾部分标记为 1 - (N - 1),这有点令人困惑,因为我们在这个“等式”中同时使用了连字符来表示范围和减法。尽管如此,第一次结尾是在倒数第二次重复时使用的。第一次结尾前的冒号表示从歌曲开头重复。然后,在最后一次重复时使用 N 结尾,双横线表示歌曲/程序的结束。
从音乐中我们可以看到,我们只需要处理两种情况:最后一节和所有其他节。所以首先我们检查最后一节。我们寻找的是“1 瓶”(单数)而不是“1 瓶”(复数)。我们还需要检查最后一行说的是“没有更多的瓶子”而不是“0 瓶”。对于“2 瓶啤酒”的第二个测试,是确保数字是“2 瓶”然后是“1 瓶”。如果我们能够通过这两个测试,我们的程序应该能够处理所有节。
我编写了 test_verse() 来测试 verse() 函数。函数的名称很重要,因为我正在使用 pytest 模块来查找我的代码中以 test_ 开头的所有函数并运行它们。如果你的 bottles.py 程序有前面的 verse() 和 test_verse() 函数,你可以运行 pytest bottles.py。
尝试一下,你应该会看到类似这样的结果:
$ pytest bottles.py
============================= test session starts ==============================
...
collected 1 item
bottles.py F [100%]
=================================== FAILURES ===================================
__________________________________ test_verse __________________________________
def test_verse():
"""Test verse"""
last_verse = verse(1) ①
> assert last_verse == '\n'.join([ ②
'1 bottle of beer on the wall,', '1 bottle of beer,',
'Take one down, pass it around,',
'No more bottles of beer on the wall!'
])
E AssertionError: assert '' == '1 bottle of beer on the wal...ottles of beer on the wall!' ③
E + 1 bottle of beer on the wall,
E + 1 bottle of beer,
E + Take one down, pass it around,
E + No more bottles of beer on the wall!
bottles.py:49: AssertionError
=========================== 1 failed in 0.10 seconds ===========================
① 使用 1 作为参数调用 verse() 函数以获取歌曲的最后一节。
② 这行开头的 > 表示这是错误的来源。测试检查 last_verse 的值是否等于预期的 str 值。由于它不等于,这一行抛出异常,导致断言失败。
③ “E”行显示了接收到的内容与预期内容的差异。last_verse的值是空字符串 (''),这与预期的字符串“1 瓶啤酒...”等不匹配。
要通过第一个测试,你可以直接从测试中复制last_verse的预期值代码。将你的verse()函数修改为匹配以下内容:
def verse(bottle):
"""Sing a verse"""
return '\n'.join([
'1 bottle of beer on the wall,', '1 bottle of beer,',
'Take one down, pass it around,',
'No more bottles of beer on the wall!'
])
现在再次运行你的测试。第一个测试应该通过,而第二个应该失败。以下是相关的错误行:
=================================== FAILURES ===================================
__________________________________ test_verse __________________________________
def test_verse() -> None:
"""Test verse"""
last_verse = verse(1)
assert last_verse == '\n'.join([ ①
'1 bottle of beer on the wall,', '1 bottle of beer,',
'Take one down, pass it around,',
'No more bottles of beer on the wall!'
])
two_bottles = verse(2) ②
> assert two_bottles == '\n'.join([ ③
'2 bottles of beer on the wall,', '2 bottles of beer,',
'Take one down, pass it around,', '1 bottle of beer on the wall!'
])
E AssertionError: assert '1 bottle of ... on the wall!' == '2 bottles of ... on the wall!' ④
E - 1 bottle of beer on the wall,
E ? ^
E + 2 bottles of beer on the wall,
E ? ^ +
E - 1 bottle of beer,
E ? ^
E + 2 bottles of beer,...
E
E ...Full output truncated (7 lines hidden), use '-vv' to show
① 这个测试现在通过了。
② 使用值为 2 调用verse()以获取“Two bottles...”这首诗。
③ 断言这首诗等于预期的字符串。
④ 这些“E”行显示了问题。verse()函数返回了'1 瓶',但测试期望的是'2 瓶'等。
回去看看你的verse()定义。看看图 11.4,思考哪些部分需要更改--第一、第二和第四行。第三行总是相同的。你得到了一个bottle的值,需要用于前两行,以及根据bottle的值,bottle或“bottles”。(提示:当值为1时,它是单数;否则,它是复数。)第四行需要bottle的值-``1,以及根据该值适当的单数或复数。你能想出如何编写这个吗?

图 11.4 每一首诗有四行,其中前两行和最后一行非常相似。第三行总是相同的。找出不同的部分。
在你进入打印整首歌曲的下一阶段之前,专注于通过这两个测试。也就是说,在你看到这个之前不要尝试任何事情:
$ pytest bottles.py
============================= test session starts ==============================
...
collected 1 item
bottles.py . [100%]
=========================== 1 passed in 0.05 seconds ===========================
11.1.4 使用verse()函数
在这一点上,你知道
-
--num值是一个有效的整数,且大于 0 -
如何从
--num值开始倒数到 0 -
verse()函数将正确打印任何一首诗
现在您需要将它们组合起来。我建议您首先使用带有range()函数的for循环来倒计时。使用该循环中的每个值来生成一个verse()。除了最后一首诗外,每首诗后应有两个换行符。
你将使用pytest -xv test.py(或make test)来测试当前程序。在测试的术语中,test.py 是一个集成测试,因为它检查程序整体是否正常工作。从现在开始,我们将专注于如何编写单元测试来检查单个函数,以及除了集成测试外,确保所有函数协同工作。
一旦你可以使用 for 循环通过测试套件,尝试使用列表推导式或 map() 重新编写它。而不是从头开始,我建议你通过在行首添加 # 来注释掉你的工作代码,然后尝试其他编写算法的方式。使用测试来验证你的代码仍然可以通过。如果这有任何激励作用,我的解决方案只有一行长。你能写一行代码来结合 range() 和 verse() 函数以产生预期的输出吗?
这里有一些提示:
-
将
--num参数定义为默认值为10的int。 -
使用
parser.error()来让argparse打印出一个错误信息,当--num值为负数时。 -
编写
verse()函数。使用test_verse()函数和 Pytest 来确保其正确运行。 -
将
verse()函数与range()结合起来创建所有诗句。
在阅读解决方案之前,尽量写出程序。你也可以自由地以完全不同的方式解决问题,甚至编写你自己的单元测试。
11.2 解决方案
我决定向你展示一个稍微花哨一点的版本,它使用了 map() 函数。稍后我会展示如何使用 for 循环和列表推导式来编写它。
#!/usr/bin/env python3
"""Bottles of beer song"""
import argparse
# --------------------------------------------------
def get_args():
"""Get command-line arguments"""
parser = argparse.ArgumentParser(
description='Bottles of beer song',
formatter_class=argparse.ArgumentDefaultsHelpFormatter)
parser.add_argument('-n', ①
'--num',
metavar='number',
type=int,
default=10,
help='How many bottles')
args = parser.parse_args() ②
if args.num < 1: ③
parser.error(f'--num "{args.num}" must be greater than 0')
return args
# --------------------------------------------------
def main():
"""Make a jazz noise here"""
args = get_args()
print('\n\n'.join(map(verse, range(args.num, 0, -1)))) ④
# --------------------------------------------------
def verse(bottle): ⑤
"""Sing a verse"""
next_bottle = bottle - 1 ⑥
s1 = '' if bottle == 1 else 's' ⑦
s2 = '' if next_bottle == 1 else 's' ⑧
num_next = 'No more' if next_bottle == 0 else next_bottle ⑨
return '\n'.join([ ⑩
f'{bottle} bottle{s1} of beer on the wall,',
f'{bottle} bottle{s1} of beer,',
f'Take one down, pass it around,',
f'{num_next} bottle{s2} of beer on the wall!',
])
# --------------------------------------------------
def test_verse(): ⑪
"""Test verse"""
last_verse = verse(1) ⑫
assert last_verse == '\n'.join([
'1 bottle of beer on the wall,', '1 bottle of beer,',
'Take one down, pass it around,',
'No more bottles of beer on the wall!'
])
two_bottles = verse(2) ⑬
assert two_bottles == '\n'.join([
'2 bottles of beer on the wall,', '2 bottles of beer,',
'Take one down, pass it around,', '1 bottle of beer on the wall!'
])
# --------------------------------------------------
if __name__ == '__main__':
main()
① 将 --num 参数定义为默认值为 10 的整数。
② 将命令行参数解析到变量 args 中。
③ 如果 args.num 小于 1,使用 parser.error() 显示错误信息并退出程序,返回错误值。
④ map() 函数期望第一个参数是一个函数,第二个参数是一个可迭代的对象。在这里,我将 range() 函数中递减的数字传递给我的 verse() 函数。map() 的结果是一个新的诗句列表,可以用两个换行符连接。
⑤ 定义一个可以创建单个诗句的函数 verse()。
⑥ 定义一个 next_bottle,它的值比当前瓶子少一个。
⑦ 根据当前瓶子的值,定义一个 s1(第一个“s”),它可以是字符 's' 或空字符串。
⑧ 根据 next_bottle 的值,对 s2(第二个“s”)做同样的处理。
⑨ 根据下一个值是否为 0 来定义 next_num 的值。
⑩ 通过在换行符上连接四行文本来创建一个返回字符串。将变量代入以创建正确的诗句。
⑪ 为 verse() 函数定义一个名为 test_verse() 的单元测试。test_ 前缀意味着 pytest 模块将找到这个函数并执行它。
⑫ 使用值 1 测试最后的 verse() 函数。
⑬ 使用值 2 测试 verse()。
11.3 讨论
在这个程序的 get_args() 函数中没有什么新的内容。到目前为止,你已经有很多机会定义一个具有默认参数的可选整数参数,并使用 parser.error() 来在用户提供不良参数时停止程序。通过依赖 argparse 来处理这么多繁琐的工作,你不仅节省了大量的时间,而且还确保了你拥有良好的数据来工作。让我们继续学习新的内容!
11.3.1 倒计时
你知道如何从给定的 --num 开始倒计时,并且你知道你可以使用一个 for 循环来迭代:
>>> for n in range(3, 0, -1):
... print(f'{n} bottles of beer')
...
3 bottles of beer
2 bottles of beer
1 bottles of beer
而不是在 for 循环中直接创建每个诗行,我建议你可以创建一个名为 verse() 的函数来创建任何给定的诗行,并使用 range() 的数字范围。到目前为止,我们一直在 main() 函数中做所有的工作。然而,随着你作为一个程序员的成长,你的程序将变得更长——数百行甚至数千行代码(LOC)。长程序和函数可能非常难以测试和维护,因此你应该尝试将想法分解成小的、功能性的单元,这样你就可以理解和测试它们。理想情况下,函数应该只做 一件事。如果你理解并信任你的小而简单的 函数,那么你知道你可以安全地将它们组合成更长、更复杂的 程序。
11.3.2 测试驱动开发
我想让你在你的程序中添加一个 test_verse() 函数,以便使用 Pytest 创建一个工作的 verse() 函数。这个想法遵循 Kent Beck 在他的书 测试驱动开发(Addison-Wesley Professional,2002)中描述的原则:
-
为未实现的功能单元添加一个新测试。
-
运行所有先前编写的测试,并查看新添加的测试失败。
-
编写实现新功能的代码。
-
运行所有测试并查看它们成功。
-
重构(重写以改进可读性或结构)。
-
从开始处(重复)开始。
例如,假设我们想要一个将 1 加到任何给定数字的函数。我们将它命名为 add1() 并将函数体定义为 pass 以告诉 Python “这里没有东西可看”:
def add1(n):
pass
现在编写一个 test_add1() 函数,将一些参数传递给该函数,并使用 assert 验证你得到你预期的值:
def test_add1():
assert add1(0) = 1
assert add1(1) = 2
assert add1(-1) = 0
运行 pytest(或你喜欢的任何测试框架)并验证该函数 不工作(当然不会,因为它只是执行 pass)。然后去填写一些函数代码,使其 工作(将 return n + 1 替换为 pass)。传递你所能想象的各种参数,包括没有参数、一个参数和多个参数。1
11.3.3 verse() 函数
我为你提供了一个 test_verse() 函数,它显示了对于 1 和 2 的参数期望的确切内容。我喜欢先写测试的原因是,它给了我一个思考如何使用代码的机会,我想传递什么参数,以及我期望得到什么回报。例如,如果给定 |
![]() |
,函数 add1() 应该返回什么? |
|---|
-
无参数
-
多于一个参数
-
值
None -
任何非数值类型(如
str值或dict)
你可以编写测试来传递好的和坏的值,并决定你希望你的代码在有利和不利条件下如何表现。
这里是我在 verse() 函数中编写的,它通过了 test_verse() 函数:
def verse(bottle):
"""Sing a verse"""
next_bottle = bottle - 1
s1 = '' if bottle == 1 else 's'
s2 = '' if next_bottle == 1 else 's'
num_next = 'No more' if next_bottle == 0 else next_bottle
return '\n'.join([
f'{bottle} bottle{s1} of beer on the wall,',
f'{bottle} bottle{s1} of beer,',
f'Take one down, pass it around,',
f'{num_next} bottle{s2} of beer on the wall!',
])
这段代码在 11.2 节中有注释,但我基本上隔离了返回字符串中所有会变化的部分,并为这些位置创建了变量以进行替换。我使用 bottle 和 next_bottle 来决定在bottle字符串后面是否应该有“s”。我还需要弄清楚是否应该打印下一个瓶子作为数字,或者应该打印字符串“没有更多”(当 next_bottle 为 0 时)。为 s1、s2 和 num_next 选择值都涉及 二进制 决策,这意味着它们是在两个值之间的选择,所以我发现使用 if 表达式是最好的。
这个函数通过了 test_verse() 测试,所以我可以继续使用它来生成歌曲。
11.3.4 遍历诗行
我可以使用 for 循环进行倒计时并 print() 每个函数 verse():
>>> for n in range(3, 0, -1):
... print(verse(n))
...
3 bottles of beer on the wall,
3 bottles of beer,
Take one down, pass it around,
2 bottles of beer on the wall!
2 bottles of beer on the wall,
2 bottles of beer,
Take one down, pass it around,
1 bottle of beer on the wall!
1 bottle of beer on the wall,
1 bottle of beer,
Take one down, pass it around,
No more bottles of beer on the wall!
这几乎是正确的,但我们需要在所有诗行之间插入两个换行符。我可以使用 end 选项来 print 包括两个换行符的所有大于 1 的值:
>>> for n in range(3, 0, -1):
... print(verse(n), end='\n' * (2 if n > 1 else 1))
...
3 bottles of beer on the wall,
3 bottles of beer,
Take one down, pass it around,
2 bottles of beer on the wall!
2 bottles of beer on the wall,
2 bottles of beer,
Take one down, pass it around,
1 bottle of beer on the wall!
1 bottle of beer on the wall,
1 bottle of beer,
Take one down, pass it around,
No more bottles of beer on the wall!
我更愿意使用 str.join() 方法在 list 中的项目之间插入两个换行符。我的项目是诗行,我可以将一个 for 循环转换成如图 11.5 所示的列表推导式。

图 11.5 for 循环与列表推导式的比较
>>> verses = [verse(n) for n in range(3, 0, -1)]
>>> print('\n\n'.join(verses))
3 bottles of beer on the wall,
3 bottles of beer,
Take one down, pass it around,
2 bottles of beer on the wall!
2 bottles of beer on the wall,
2 bottles of beer,
Take one down, pass it around,
1 bottle of beer on the wall!
1 bottle of beer on the wall,
1 bottle of beer,
Take one down, pass it around,
No more bottles of beer on the wall!
这是一个很好的解决方案,但我希望你们开始注意到我们将反复看到的模式:将函数应用于序列的每个元素,这正是 map() 所做的!如图 11.6 所示,我们的列表推导式可以使用 map() 非常简洁地重写。

图 11.6 列表推导式可以用 map() 替换。它们都返回一个新的 list。
在我们这个例子中,我们的序列是一个递减的 range() 数列,我们希望将 verse() 函数应用于每个数字并收集结果诗行。这就像第八章中提到的喷漆间想法,其中函数“painted”汽车“蓝色”是通过在字符串开头添加单词“blue”来实现的。当我们想要将一个函数应用于序列中的每个元素时,我们可能会考虑使用 map() 对代码进行重构:
>>> verses = map(verse, range(3, 0, -1))
>>> print('\n\n'.join(verses))
3 bottles of beer on the wall,
3 bottles of beer,
Take one down, pass it around,
2 bottles of beer on the wall!
2 bottles of beer on the wall,
2 bottles of beer,
Take one down, pass it around,
1 bottle of beer on the wall!
1 bottle of beer on the wall,
1 bottle of beer,
Take one down, pass it around,
No more bottles of beer on the wall!
每当我需要使用某个函数转换一系列项目时,我总是先思考如何处理其中的一个项目。我发现,用单个输入编写和测试一个函数比用可能非常庞大的操作列表要容易得多。列表推导式通常被认为是更“Pythonic”的,但我更倾向于使用 map(),因为它通常涉及更短的代码。如果你在互联网上搜索“python list comprehension map”,你会发现有些人认为列表推导式比 map() 更容易阅读,但 map() 可能会稍微快一些。我不会说哪种方法比另一种方法更好。这完全取决于个人喜好或与队友的讨论。
如果你想要使用map(),请记住它需要一个函数作为第一个参数,然后是一个元素序列,这些元素将成为函数的参数。verse()函数(你已经测试过了!)是第一个参数,range()提供列表。map()函数将把range()的每个元素作为参数传递给verse()函数,如图 11.7 所示。结果是包含所有这些函数调用返回值的新列表。许多for循环都可以更好地写成映射函数到参数列表!

图 11.7 map()函数将使用range()函数产生的每个元素调用verse()函数。完全是函数的堆叠。
11.3.5 1,500 种其他解决方案
| 实际上,有数百种解决这个问题的方法。“99 瓶啤酒”网站(www.99-bottles-of-beer.net)声称有 1,500 种不同语言的变体。将你的解决方案与其他人进行比较。虽然程序可能很 trivial,但它使我们能够探索一些真正有趣的 Python、测试和算法思想。 | ![]() |
|---|
11.4 进一步探索
-
将阿拉伯数字(1、2、3)替换为文本(one、two、three)。
-
添加一个
--step选项(正整数,默认1),允许用户跳过数字,例如每隔两个或五个数字。 -
添加一个
--reverse标志来反转诗的顺序,从上往下计数而不是从下往上。
摘要
-
测试驱动开发(TDD)对于开发可靠、可重复的代码至关重要。测试还赋予你重构代码的自由(重新组织并改进它以提高速度或清晰度),因为你始终可以验证你的新版本仍然可以正常工作。当你编写代码时,始终编写测试!
-
如果你交换
start和stop并提供一个可选的第三个step值-1,range()函数将倒序计数。 -
循环
for通常可以用列表推导式或map()来替换,以获得更短、更简洁的代码。
1 一位计算机科学教授曾在办公时间告诉我要处理 0、1 和n(无穷大)的情况,这始终让我印象深刻。
12 勒索:随机大写文本
| 所有这些编写代码的辛勤工作让我感到烦躁。我准备好过上犯罪的生活了!我绑架了(猫绑架?)邻居的猫,我想给他们发送一份勒索信。在美好的旧日子里,我会从杂志上剪下字母,然后粘贴到一张纸上以拼写我的要求。这听起来像太多的工作。相反,我将编写一个名为 ransom.py 的 Python 程序,该程序将文本编码为随机大写的字母:
$ ./ransom.py 'give us 2 million dollars or the cat gets it!'
gIVe US 2 milLION DoLlArs or ThE cAt GEts It!
![]() |
|---|
如你所见,我的邪恶程序接受邪恶的输入文本作为位置参数。由于这个程序使用random模块,我想接受一个-s或--seed选项,这样我就可以复制邪恶的输出:
$ ./ransom.py --seed 3 'give us 2 million dollars or the cat gets it!'
giVE uS 2 MILlioN dolLaRS OR tHe cAt GETS It!
| 勇敢的位置参数可能指定一个邪恶的文件,在这种情况下,应该读取该文件以获取恶魔般的输入文本:
$ ./ransom.py --seed 2 ../inputs/fox.txt
the qUIck BROWN fOX JUmps ovEr ThE LAZY DOg.
![]() |
|---|
如果非法程序在没有参数的情况下运行,它应该打印一个简短的、令人讨厌的使用说明:
$ ./ransom.py
usage: ransom.py [-h] [-s int] text
ransom.py: error: the following arguments are required: text
如果邪恶程序以-h或--help标志运行,它应该打印一个更长、更邪恶的使用说明:
$ ./ransom.py -h
usage: ransom.py [-h] [-s int] text
Ransom Note
positional arguments:
text Input text or file
optional arguments:
-h, --help show this help message and exit
-s int, --seed int Random seed (default: None)
图 12.1 展示了一个有害的字符串图,用于可视化输入和输出。

图 12.1 这个糟糕的程序将通过随机大写字母将输入文本转换为勒索信。
在本章中,你将
-
学习如何使用
random模块来象征性地“抛硬币”以在两个选择之间做出决定 -
探索从现有字符串生成新字符串的方法,结合随机决策
-
研究循环、列表推导式和
map()函数的相似性
12.1 编写 ransom.py
我建议从 new.py 开始,或者将 template/template.py 文件复制到 12_ransom 目录中创建 ransom.py。这个程序,就像之前的几个程序一样,接受一个必需的位置字符串text和一个可选的整数(默认None)作为--seed。同样,在之前的练习中,text参数可以指定一个文件名,该文件应被读取以获取text值。
要开始,请使用以下main()代码:
def main():
args = get_args() ①
random.seed(args.seed) ②
print(args.text) ③
① 获取处理过的命令行参数。
② 使用用户提供的值设置random.seed()。默认是None,这与没有设置相同。
③ 首先回显输入。
如果你运行这个程序,它应该回显命令行中的输入:
$ ./ransom.py 'your money or your life!'
your money or your life!
或者从输入文件中的文本:
$ ./ransom.py ../inputs/fox.txt
The quick brown fox jumps over the lazy dog.
编写程序时的重要事情是要采取小步骤。你应该在每次更改后运行你的程序,手动和通过测试来检查你是否在进步。
一旦这个功能正常工作,就是时候考虑如何随机大写这个糟糕的消息了。
12.1.1 修改文本
你之前已经看到你不能直接修改一个str值:
>>> text = 'your money or your life!'
>>> text[0] = 'Y'
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: 'str' object does not support item assignment
那么我们如何随机改变一些字母的大小写呢?
我建议你与其考虑如何改变许多字母,不如考虑如何改变一个字母。也就是说,给定一个字母,你如何随机返回该字母的大写或小写版本?让我们创建一个模拟的choose()函数,它接受一个单个字符。现在,我们将让函数返回未更改的字符:
def choose(char):
return char
这里有一个测试用例:
def test_choose():
state = random.getstate() ①
random.seed(1) ②
assert choose('a') == 'a' ③
assert choose('b') == 'b'
assert choose('c') == 'C'
assert choose('d') == 'd'
random.setstate(state) ④
① 随机模块的状态是程序的全局状态。我们在这里所做的任何更改都可能影响程序未知的部分,因此我们保存当前状态。
② 将随机种子设置为已知值。这是对我们程序的全局更改。对随机模块中函数的任何其他调用都将受到影响!
③ choose()函数接受一系列字母,我们使用assert语句来测试函数返回的值是否是预期的字母。
④ 将全局状态重置为原始值。
随机种子
你是否想过我是如何知道给定随机种子choose()的结果会是什么?好吧,我承认我写了这个函数,然后设置了种子,并用给定的输入运行了它。我把结果记录成了你看到的断言。在未来,这些结果应该仍然是相同的。如果它们不是,那么我可能改动了某些东西,可能破坏了我的程序。
12.1.2 抛硬币
| 我们需要在返回给定字符的大写或小写版本之间进行选择。这是一个二进制选择,意味着我们有两个选项,因此我们可以使用抛硬币的类比。正面或反面?或者,就我们的目的而言,0 或 1:
>>> import random
>>> random.choice([0, 1])
1
或者True或False,如果你更喜欢:
>>> random.choice([False, True])
True
![]() |
|---|
考虑使用一个if表达式,当选择0或False选项时返回大写答案,否则返回小写版本。我的整个choose()函数就是这一行。
12.1.3 创建一个新的字符串
现在我们需要将我们的choose()函数应用到输入字符串中的每个字符上。我希望这开始感觉像是一种熟悉的策略。我鼓励你从模仿第八章中的第一个方法开始,在那里我们使用for循环遍历输入文本的每个字符,并将所有元音替换为单个元音。在这个程序中,我们可以遍历文本的字符,并将它们作为choose()函数的参数。结果将是一个新的转换字符的list(或str)。一旦你能通过for循环的测试,尝试将其重写为列表推导式,然后是map()。
现在开始吧!编写程序,通过测试。
12.2 解答
我们将探索许多处理输入文本中所有字符的方法。我们将从一个构建新列表的for循环开始,并希望说服你列表推导式是更好的方法。最后,我将向你展示如何使用map()创建一个非常简洁(甚至可能是优雅)的解决方案。
#!/usr/bin/env python3
"""Ransom note"""
import argparse
import os
import random
# --------------------------------------------------
def get_args():
"""get command-line arguments"""
parser = argparse.ArgumentParser(
description='Ransom Note',
formatter_class=argparse.ArgumentDefaultsHelpFormatter)
parser.add_argument('text', metavar='text', help='Input text or file') ①
parser.add_argument('-s', ②
'--seed',
help='Random seed',
metavar='int',
type=int,
default=None)
args = parser.parse_args() ③
if os.path.isfile(args.text): ④
args.text = open(args.text).read().rstrip()
return args ⑤
# --------------------------------------------------
def main():
"""Make a jazz noise here"""
args = get_args()
text = args.text
random.seed(args.seed) ⑥
ransom = [] ⑦
for char in args.text: ⑧
ransom.append(choose(char)) ⑨
print(''.join(ransom)) ⑩
# --------------------------------------------------
def choose(char): ⑪
"""Randomly choose an upper or lowercase letter to return"""
return char.upper() if random.choice([0, 1]) else char.lower() ⑫
# --------------------------------------------------
def test_choose(): ⑬
"""Test choose"""
state = random.getstate() ⑭
random.seed(1) ⑮
assert choose('a') == 'a' ⑯
assert choose('b') == 'b'
assert choose('c') == 'C'
assert choose('d') == 'd'
random.setstate(state) ⑰
# --------------------------------------------------
if __name__ == '__main__':
main()
① 文本参数是一个位置字符串值。
② --seed选项是一个默认为None的整数。
③ 将命令行参数处理到args变量中。
④ 如果args.text是一个文件,则使用该文件的内容作为新的args.text值。
⑤ 将参数返回给调用者。
⑥ 将random.seed()设置为给定的args.seed值。默认值为None,这与未设置相同。这意味着如果没有给出种子值,程序将看起来是随机的,但如果我们提供了种子值,则可以进行测试。
⑦ 创建一个空列表来保存新的密文消息。
⑧ 使用for循环遍历args.text中的每个字符。
⑨ 将选定的字母追加到密文列表中。
⑩ 使用空字符串将密文列表连接起来,以创建一个新的字符串进行打印。
⑪ 定义一个函数,随机返回给定字符的大写或小写版本。
⑫ 使用random.choice()选择 0 或 1,在if表达式的布尔上下文中,分别评估为 False 或 True。
⑬ 定义一个test_choose()函数,该函数将由 Pytest 运行。该函数不接受任何参数。
⑭ 保存随机模块的当前状态。
⑮ 将random.seed()设置为已知值,以便进行测试。
⑯ 使用assert语句验证从choose()函数得到的预期结果是否与已知参数相符。
⑰ 重置随机模块的状态,以确保我们的更改不会影响程序的任何其他部分。
12.3 讨论部分
我喜欢这个问题,因为它有如此多的有趣解决方法。我知道,我知道,Python 喜欢有“一个明显的方法”来解决问题,但让我们探索一下,好吗?get_args()中没有我们之前没有看到过的内容,所以让我们跳过它。 |
![]() |
|---|
12.3.1 遍历序列中的元素
假设我们有一个以下残酷的信息:
>>> text = '2 million dollars or the cat sleeps with the fishes!'
我想随机地将字母的大小写。正如问题早期描述中建议的那样,我们可以使用一个for循环来遍历每个字符。打印text的大写版本的一种方法是将每个字母的大写版本打印出来:
for char in text:
print(char.upper(), end='')
| 这将给出“200 万美元,否则猫和鱼一起睡觉!”现在,我不再总是打印char.upper(),我可以随机选择char.upper()和char.lower()。为此,我将使用random.choice()在两个值(如True和False或0和1)之间进行选择:
>>> import random
>>> random.choice([True, False])
False
>>> random.choice([0, 1])
0
>>> random.choice(['blue', 'green'])
'blue'
![]() |
|---|
沿着第八章的第一个解决方案,我创建了一个新的list来保存密文消息,并添加了这些随机选择:
ransom = []
for char in text:
if random.choice([False, True]):
ransom.append(char.upper())
else:
ransom.append(char.lower())
然后我将新字符连接到空字符串上,以打印一个新的字符串:
print(''.join(ransom))
使用if表达式来选择是否取大写或小写字符,代码更少,如图 12.2 所示:
ransom = []
for char in text:
ransom.append(char.upper() if random.choice([False, True]) else char.lower())

图 12.2 使用if表达式可以更简洁地编写二进制 if/else 分支。
你不必使用实际的布尔值(False和True)。你可以用0和1来代替:
ransom = []
for char in text:
ransom.append(char.upper() if random.choice([0, 1]) else char.lower())
当数字在布尔上下文中被评估时(也就是说,在 Python 期望看到布尔值的地方),0 被认为是 False,而其他所有数字都是 True。
12.3.2 编写一个选择字母的函数
if 表达式是一段可以放入函数中的代码。我发现它在 ransom.append() 中的可读性较差。
通过将其放入函数中,我可以给它一个描述性的名称,并为其编写测试:
def choose(char):
"""Randomly choose an upper or lowercase letter to return"""
return char.upper() if random.choice([0, 1]) else char.lower()
现在,我可以运行 test_choose() 函数来测试我的函数是否按预期工作。这段代码的可读性要好得多:
ransom = []
for char in text:
ransom.append(choose(char))
12.3.3 另一种写法:list.append()
12.2 节中的解决方案创建了一个空的 list,然后我将 choose() 的返回值 list.append() 到其中。另一种写法 list.append() 是使用 += 运算符将右侧值(要添加的元素)添加到左侧(列表),如图 12.3 所示。
def main():
args = get_args()
random.seed(args.seed)
ransom = []
for char in args.text:
ransom += choose(char)
print(''.join(ransom))

图 12.3 += 运算符是另一种写法 list.append().
这是将字符连接到字符串或将数字添加到另一个数字上的相同语法。
12.3.4 使用字符串而不是列表
前两种解决方案都需要将列表连接到空字符串上,以创建一个新的字符串进行打印。相反,我们可以从一个空字符串开始,逐个字符地使用 += 运算符构建:
def main():
args = get_args()
random.seed(args.seed)
ransom = ''
for char in args.text:
ransom += choose(char)
print(ransom)
正如我们刚才提到的,+= 运算符是另一种向列表中添加元素的方法。Python 经常将字符串和列表互换使用,有时是隐式的,不管是有益还是有害。
12.3.5 使用列表推导式
之前的模式都是先初始化一个空的 str 或 list,然后通过 for 循环逐步构建。我想说服你,几乎总是最好使用列表推导式来表达,因为它的整个存在目的就是返回一个新的列表。我们可以将我们的三行代码压缩成一行:
def main():
args = get_args()
random.seed(args.seed)
ransom = [choose(char) for char in args.text]
print(''.join(ransom))
或者,你可以完全跳过创建 ransom 变量的步骤。一般来说,我只在需要多次使用变量或觉得它可以使我的代码更易读时才给它赋值:
def main():
args = get_args()
random.seed(args.seed)
print(''.join([choose(char) for char in args.text]))
for 循环实际上是在遍历某个序列并产生 副作用,如打印值或处理文件中的行。如果你的目标是创建一个新的 list,那么列表推导式可能是最好的工具。任何本应放入 for 循环体中以处理元素的代码都最好放在一个带有测试的函数中。
12.3.6 使用 map() 函数
我之前提到过,map() 和列表推导式非常相似,尽管通常需要更少的输入。两种方法都是从一些可迭代对象中生成一个新的 list,如图 12.4 所示。在这种情况下,map() 返回的列表是通过将 choose() 函数应用于 args.text 的每个字符来创建的:
def main():
args = get_args()
random.seed(args.seed)
ransom = map(choose, args.text)
print(''.join(ransom))

图 12.4 列表推导式的思想可以用 map() 更简洁地表达。
或者,再次,你可以省略 ransom 赋值,并直接使用 map() 返回的 list:
def main():
args = get_args()
random.seed(args.seed)
print(''.join(map(choose, args.text)))
12.4 比较方法
花这么多时间解决一个本质上微不足道的问题似乎很愚蠢,但本书的一个目标就是探索 Python 中可用的各种思想。第 12.2 节中的第一个解决方案是一个非常命令式的解决方案,C 或 Java 程序员可能会编写。使用列表推导式的版本非常符合 Python 的风格——它就是“Pythonic”,正如 Pythonista 们所说的。map() 解决方案对来自纯函数式语言(如 Haskell)的人来说会非常熟悉。
所有这些方法都实现了相同的目标,但它们体现了不同的美学和编程范式。我首选的解决方案是最后一个,使用 map(),但你应该选择对你最有意义的方案。
MapReduce
2004 年,谷歌发布了一篇关于他们“MapReduce”算法的论文。在“map”阶段,对集合中的所有元素应用一些转换,例如需要索引以供搜索的互联网上的所有页面。这些操作可以并行进行,这意味着你可以使用多台机器分别处理页面,并且可以按任何顺序处理。然后,“reduce”阶段将所有处理过的元素重新组合在一起,可能将结果放入统一的数据库中。
在我们的 ransom.py 程序中,“map”部分为给定的字母选择了一个随机的案例,而“reduce”部分则是将这些比特重新组合成一个新的字符串。理论上,map() 可以利用多个处理器并行运行函数,而不是按顺序(如使用 for 循环)运行,这可能会缩短产生结果的时间。
Map/Reduce 的思想可以在许多地方找到,从索引互联网到我们的 ransom 程序。
对我来说,了解 MapReduce 就像学习一个新鸟的名字。我以前甚至没有注意到那只鸟,但一旦我知道了它的名字,我就到处都看到了。一旦你理解了这个模式,你将开始在很多地方看到它。

12.5 深入学习
编写一个 ransom.py 的版本,通过组合 ASCII 字符以其他方式表示字母,如下所示。请随意创造自己的替换。确保更新你的测试。
A 4 K |<
B |3 L |_
C ( M |\/|
D |) N |\|
E 3 P |`
F |= S 5
G (- T +
H |-| V \/
I 1 W \/\/
J _|
摘要
|
-
每当你有很多东西要处理时,试着想想你将如何处理其中之一。
-
编写一个测试,帮助你想象你如何使用该函数处理一个项目。你将传递什么,你期望得到什么?
-
编写你的函数以通过测试。确保考虑你将如何处理好的和坏的数据输入。
-
要将你的函数应用于输入中的每个元素,请使用
for循环、列表推导式或map()。
![]() |
|---|
13 圣诞节的十二天:算法设计
| 可能是有史以来最糟糕的歌曲之一,也是一定会破坏我的圣诞精神的一首歌,“圣诞节的十二天”。它什么时候才能停止?!还有,所有这些鸟是干什么用的?!尽管如此,编写一个从任何给定日期开始生成这首歌的算法仍然很有趣,因为你必须随着每段(天)的增加而向上计数,然后在段落(回顾前一天的礼物)中向下计数。你将能够在你为“99 瓶啤酒”编写的程序的基础上进行构建。 | ![]() |
|---|
本章中的程序将命名为 twelve_days.py,它将根据-n或--num参数(默认12)生成给定日期的“圣诞节的十二天”歌曲。请注意,段落之间应有两个换行符,但结尾处只有一个:
$ ./twelve_days.py -n 3
On the first day of Christmas,
My true love gave to me,
A partridge in a pear tree.
On the second day of Christmas,
My true love gave to me,
Two turtle doves,
And a partridge in a pear tree.
On the third day of Christmas,
My true love gave to me,
Three French hens,
Two turtle doves,
And a partridge in a pear tree.
如果没有提供-o或--outfile参数,文本将打印到STDOUT。在这种情况下,文本应放置在给定名称的文件中。请注意,整个歌曲应有 113 行文本:
$ ./twelve_days.py -o song.txt
$ wc -l song.txt
113 song.txt
在这个练习中,你需要
-
编写一个算法,从 1-12 范围内的任何给定日期生成“圣诞节的十二天”
-
反转一个列表
-
使用
range()函数 -
将文本写入文件或
STDOUT
13.1 编写 twelve_days.py
和往常一样,我建议你通过运行 new.py 或复制 template/template.py 文件来创建你的程序。这个程序必须命名为 twelve_days.py,并位于 13_twelve_days 目录中。
你的程序应该接受两个选项:
-
-n或--num--一个默认值为 12 的整数 -
-o或--outfile--用于写入输出的可选文件名
对于第二个选项,你可以回到第五章查看我们如何在 Howler 解决方案中处理这个问题。该程序将输出写入指定的文件名,如果没有提供,则写入sys.stdout。对于这个程序,我建议你使用type=argparse.FileType('wt')声明--outfile,以表明argparse将需要一个参数来命名一个可写文本文件。如果用户提供了有效的参数,args.outfile将是一个打开的、可写的文件句柄。如果你还使用默认的sys.stdout,你将很快就能处理写入文本文件或STDOUT的两种选项!
这种方法的唯一缺点是,程序的用法说明在描述--outfile参数的默认值时看起来有点奇怪:
$ ./twelve_days.py -h
usage: twelve_days.py [-h] [-n days] [-o FILE]
Twelve Days of Christmas
optional arguments:
-h, --help show this help message and exit
-n days, --num days Number of days to sing (default: 12)
-o FILE, --outfile FILE
Outfile (default: <_io.TextIOWrapper name='<stdout>'
mode='w' encoding='utf-8'>)
完成用法说明后,你的程序应该通过前两个测试。
图 13.1 显示了一个充满节日气氛的字符串图,以帮助你编写程序的其余部分。

图 13.1 twelve_days.py 程序接受开始日期和输出文件的选项。
如果--num值不在 1-12 的范围内,程序应该会抱怨。我建议你在get_args()函数内部进行检查,并使用parser.error()来停止并显示错误和用法信息:
$ ./twelve_days.py -n 21
usage: twelve_days.py [-h] [-n days] [-o FILE]
twelve_days.py: error: --num "21" must be between 1 and 12
一旦你处理了错误的 --num,你应该通过前三个测试。
13.1.1 计数
在“99 瓶啤酒”这首歌中,我们需要从给定的数字开始倒数。这里我们需要数到 --num,然后通过礼物倒回到 1。range() 函数会给我们所需的内容,但我们必须记住从 1 开始,因为我们不会从“圣诞节的零天”开始唱。记住,上限不包括在内:
>>> num = 3
>>> list(range(1, num))
[1, 2]
你需要将 --num 给定的任何值加 1:
>>> list(range(1, num + 1))
[1, 2, 3]
让我们从打印每个诗篇的第一行开始:
>>> for day in range(1, num + 1):
... print(f'On the {day} day of Christmas,')
...
On the 1 day of Christmas,
On the 2 day of Christmas,
On the 3 day of Christmas,
到目前为止,我开始思考我们是如何写“99 瓶啤酒”的。在那里,我们最终创建了一个 verse() 函数,它可以生成任何 一个 诗篇。然后我们使用 str.join() 将它们全部放在一起,并添加两个换行符。我建议我们在这里尝试同样的方法,所以我将 for 循环内的代码移动到它自己的函数中:
def verse(day):
"""Create a verse"""
return f'On the {day} day of Christmas,'
注意,该函数不会 print() 字符串,而是 return 诗篇,这样我们就可以测试它:
>>> assert verse(1) == 'On the 1 day of Christmas,'
让我们看看我们如何使用这个 verse() 函数:
>>> for day in range(1, num + 1):
... print(verse(day))
...
On the 1 day of Christmas,
On the 2 day of Christmas,
On the 3 day of Christmas,
这是一个简单的 test_verse() 函数,我们可以从这里开始:
def test_verse():
""" Test verse """
assert verse(1) == 'On the 1 day of Christmas,'
assert verse(2) == 'On the 2 day of Christmas,'
这当然是错误的,因为它应该说“On the first day”或“second day”,而不是“1 day”或“2 day”。尽管如此,它是一个起点。将 verse() 和 test_verse() 函数添加到你的 twelve_days.py 程序中,然后运行 pytest twelve_days.py 来验证这一点是否工作。
13.1.2 创建序数值
可能首先要做的事情是将数字值转换为它的序数位置,即“1”到“first”,“2”到“second”。你可以使用我们在“跳过五”中使用的类似字典来关联每个 int 值 1-12 与其 str 值。也就是说,你可能创建一个新的 dict 被称为 ordinal:
>>> ordinal = {} # what goes here?
然后你可以这样做:
>>> ordinal[1]
'first'
>>> ordinal[2]
'second'
你也可以使用一个 list,如果你考虑如何使用 range() 中的每个 day 来索引一个序数字符串的 list。
>>> ordinal = [] # what goes here?
你的 verse() 函数现在可能看起来像这样:
def verse(day):
"""Create a verse"""
ordinal = [] # something here!
return f'On the {ordinal[day]} of Christmas,'
你可以用你的预期来更新你的测试:
def test_verse():
""" Test verse """
assert verse(1) == 'On the first day of Christmas,'
assert verse(2) == 'On the second day of Christmas,'
一旦这个工作正常,你应该能够复制出类似这样的东西:
>>> for day in range(1, num + 1):
... print(verse(day))
...
On the day first day of Christmas,
On the day second day of Christmas,
On the day third day of Christmas,
如果你将 test_verse() 函数放在你的 twelve_days.py 程序中,你可以通过运行 pytest twelve_days.py 来验证你的 verse() 函数是否工作。pytest 模块会运行任何以 test_ 开头的函数名:
遮蔽
你可能会想使用变量名 ord,Python 允许你这样做。问题是 Python 有一个名为 ord() 的函数,它返回“一个单字符字符串的 Unicode 码点”:
>>> ord('a')
97
Python 不会抱怨如果你定义一个变量或另一个函数名为 ord,
>>> ord = {}
这样你就可以这样做:
>>> ord[1]
'first'
但这会覆盖实际的 ord 函数,从而导致函数调用出错:
>>> ord('a')
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: 'dict' object is not callable
这被称为“遮蔽”,它相当危险。任何在遮蔽作用域内的代码都会受到影响。
像 Pylint 这样的工具可以帮助你在程序中找到类似的问题。假设你有了以下代码:
$ cat shadow.py
#!/usr/bin/env python3
ord = {}
print(ord('a'))
这是 Pylint 的意见:
$ pylint shadow.py
************* Module shadow
shadow.py:3:0: W0622: Redefining built-in 'ord' (redefined-builtin)
shadow.py:1:0: C0111: Missing module docstring (missing-docstring)
shadow.py:4:6: E1102: ord is not callable (not-callable)
-------------------------------------
Your code has been rated at -25.00/10
使用像 Pylint 和 Flake8 这样的工具检查你的代码是个好习惯!
13.1.3 制作诗篇
现在我们有了程序的基本结构,让我们专注于创建正确的输出。我们将用前两首诗的实际值更新 test_verse()。当然,你可以添加更多测试,但如果我们能管理前两天,我们就可以处理所有其他天:
def test_verse():
"""Test verse"""
assert verse(1) == '\n'.join([
'On the first day of Christmas,', 'My true love gave to me,',
'A partridge in a pear tree.'
])
assert verse(2) == '\n'.join([
'On the second day of Christmas,', 'My true love gave to me,',
'Two turtle doves,', 'And a partridge in a pear tree.'
])
如果你将此添加到你的 twelve_days.py 程序中,你可以运行 pytest twelve_days.py 来查看你的 verse() 函数是如何失败的:
=================================== FAILURES ===================================
__________________________________ test_verse __________________________________
def test_verse():
"""Test verse"""
> assert verse(1) == '\n'.join([ ①
'On the first day of Christmas,', 'My true love gave to me,',
'A partridge in a pear tree.'
])
E AssertionError: assert 'On the first...of Christmas,' == 'On the first ... a pear tree.'
E - On the first day of Christmas, ②
E + On the first day of Christmas, ③
E ? +
E + My true love gave to me,
E + A partridge in a pear tree.
twelve_days.py:88: AssertionError
=========================== 1 failed in 0.11 seconds ===========================
① 前导 > 表示这是创建异常的代码。我们正在运行 verse(1) 并检查它是否等于预期的诗。
② 这实际上是 verse(1) 生成的文本,它只是诗的第一行。
③ 下面的行是预期的内容。
现在我们需要为每首诗提供其余的行。它们都开始于相同的内容:
On the {ordinal[day]} day of Christmas,
My true love gave to me,
| 然后我们需要为每一天添加这些礼物:
-
一只松鸡站在梨树上
-
两对 turtle doves
-
三只法国公鸡
-
四只报喜鸟
-
五个金戒指
-
六只鹅在产蛋
-
七只天鹅在游泳
-
八个女仆在挤奶
-
九个女士在跳舞
-
十个贵族在跳跃
-
十一个吹笛手吹笛
-
十二个鼓手在敲鼓
![]() |
|---|
注意,对于大于 1 的每一天,最后一行将“A partridge...”改为“And a partridge in a pear tree。”
每首诗都需要从给定的 day 开始倒数。例如,如果 day 是 3,那么诗列出了
-
三只法国公鸡
-
两对 turtle doves
-
以及一只松鸡站在梨树上
我们在第三章中讨论了如何使用 list.reverse() 方法或 reversed() 函数来反转 list。我们也在第十一章中使用了这些想法来从墙上拿走啤酒瓶,所以这段代码不应该让你感到陌生:
>>> day = 3
>>> for n in reversed(range(1, day + 1)):
... print(n)
...
3
2
1
尝试让函数返回前两行,然后是倒计时天数:
>>> print(verse(3))
On the third day of Christmas,
My true love gave to me,
3
2
1
然后,而不是 3 2 1,添加实际的礼物:
>>> print(verse(3))
On the third day of Christmas,
My true love gave to me,
Three French hens,
Two turtle doves,
And a partridge in a pear tree.
如果你能让它工作,你应该能够通过 test_verse() 测试。
13.1.4 使用 verse() 函数
一旦它工作正常,考虑一个调用你的 verse() 的最终结构。它可能是一个 for 循环:
|
verses = []
for day in range(1, args.num + 1):
verses.append(verse(day))
由于我们试图创建一个包含诗的 list,列表推导式是一个更好的选择:
verses = [verse(day) for day in range(1, args.num + 1)]
或者它可能是一个 map():
verses = map(verse, range(1, args.num + 1))
![]() |
|---|
13.1.5 打印
一旦你有了所有这些诗,你可以使用 str.join() 方法来打印输出。默认情况下,它会打印到“标准输出”(STDOUT),但程序还会接受一个可选的 --outfile,指定一个文件来写入输出。你可以复制我们在第五章中做的,但真正值得你花时间去学习如何使用 type=argparse.FileType('wt') 声明输出文件。你甚至可以将默认值设置为 sys.stdout,这样你永远不需要自己 open() 输出文件!
13.1.6 写作时间
并非一定要按照我描述的方式解决问题。正确的解决方案是你自己编写并理解,并且能够通过测试套件的解决方案。如果你喜欢为 verse() 创建一个函数并使用提供的测试,那也很好。如果你想走另一条路,那也很好,但请尽量考虑编写小的函数 和测试 来解决问题的各个小部分,然后将它们组合起来解决更大的问题。
如果你需要多次或几天时间来通过测试,请慢慢来。有时候,一次愉快的散步或小憩能对解决问题大有裨益。不要忽视你的吊床 1 或一杯好茶。
13.2 解决方案
在这首歌中,一个人几乎会收到 200 只鸟!无论如何,这里有一个使用 map() 的解决方案。之后,你将看到使用 for 循环和列表推导式的版本。
#!/usr/bin/env python3
"""Twelve Days of Christmas"""
import argparse
import sys
# --------------------------------------------------
def get_args():
"""Get command-line arguments"""
parser = argparse.ArgumentParser(
description='Twelve Days of Christmas',
formatter_class=argparse.ArgumentDefaultsHelpFormatter)
parser.add_argument('-n', ①
'--num',
help='Number of days to sing',
metavar='days',
type=int,
default=12)
parser.add_argument('-o', ②
'--outfile',
help='Outfile',
metavar='FILE',
type=argparse.FileType('wt'),
default=sys.stdout)
args = parser.parse_args() ③
if args.num not in range(1, 13): ④
parser.error(f'--num "{args.num}" must be between 1 and 12') ⑤
return args
# --------------------------------------------------
def main():
"""Make a jazz noise here"""
args = get_args() ⑥
verses = map(verse, range(1, args.num + 1)) ⑦
print('\n\n'.join(verses), file=args.outfile) ⑧
# --------------------------------------------------
def verse(day): ⑨
"""Create a verse"""
ordinal = [ ⑩
'first', 'second', 'third', 'fourth', 'fifth', 'sixth', 'seventh',
'eighth', 'ninth', 'tenth', 'eleventh', 'twelfth'
]
gifts = [ ⑪
'A partridge in a pear tree.',
'Two turtle doves,',
'Three French hens,',
'Four calling birds,',
'Five gold rings,',
'Six geese a laying,',
'Seven swans a swimming,',
'Eight maids a milking,',
'Nine ladies dancing,',
'Ten lords a leaping,',
'Eleven pipers piping,',
'Twelve drummers drumming,',
]
lines = [ ⑫
f'On the {ordinal[day - 1]} day of Christmas,',
'My true love gave to me,'
]
lines.extend(reversed(gifts[:day])) ⑬
if day > 1: ⑭
lines[-1] = 'And ' + lines[-1].lower() ⑮
return '\n'.join(lines) ⑯
# --------------------------------------------------
def test_verse(): ⑰
"""Test verse"""
assert verse(1) == '\n'.join([
'On the first day of Christmas,', 'My true love gave to me,',
'A partridge in a pear tree.'
])
assert verse(2) == '\n'.join([
'On the second day of Christmas,', 'My true love gave to me,',
'Two turtle doves,', 'And a partridge in a pear tree.'
])
# --------------------------------------------------
if __name__ == '__main__':
main()
选项 --num 是一个默认值为 12 的整数。
选项 --outfile 是一个默认为 sys.stdout 的 type=argparse.FileType('wt')。如果用户提供了值,它必须是可写文件的名称,在这种情况下,argparse 将为写入打开文件。
将解析命令行参数的结果捕获到 args 变量中。
检查给定的 args.num 是否在允许的范围内 1-12,包括 1 和 12。
如果 args.num 无效,使用 parser.error() 打印简短的用法说明和错误信息到标准错误输出,并以错误值退出程序。注意,错误信息包括用户的不良值,并明确指出良好的值应在 1-12 的范围内。
获取命令行参数。记住,所有参数验证都在 get_args() 内部进行。如果这个调用成功,我们就得到了用户提供的良好参数。
为给定的 args.num 天生成诗句。
将诗句通过两个换行符连接并打印到 args.outfile,它是一个打开的文件句柄,或者 sys.stdout。
定义一个函数,从给定的数字创建任何一首诗句。
序数值是一个包含字符串值的列表。
天的礼物是一个包含字符串值的列表。
每首诗句的行都是以相同的方式开始的,用给定日期的序数值替换。
使用 list.extend() 方法添加礼物,这些礼物是从给定日期的切片然后反转得到的。
检查这是否针对大于 1 的日子。
将最后一行改为在开头添加“并且”,并将其附加到该行的小写版本。
返回通过换行符连接的行。
对 verse() 函数进行单元测试。
13.3 讨论
在get_args()中没有什么新的,所以我们只是匆匆一瞥。--num选项是一个默认值为12的int值,我们使用parser.error()来阻止程序,如果用户提供了不良值。--outfile选项略有不同,因为我们使用type=argparse.FileType('wt')来声明它,表示值必须是可以写入的文件。这意味着我们从argparse得到的价值将是一个打开的、可写入的文件。我们将默认值设置为sys.stdout,它也是一个打开的、可写入的文件,所以我们完全通过argparse处理了两个输出选项,这真是一个节省时间的方法!
13.3.1 制作一行诗
我选择创建一个名为verse()的函数,用于根据给定日期的int值创建任何一行:
def verse(day):
"""Create a verse"""
我决定使用list来表示“日期”的序数值:
ordinal = [
'first', 'second', 'third', 'fourth', 'fifth', 'sixth', 'seventh',
'eighth', 'ninth', 'tenth', 'eleventh', 'twelfth'
]
由于“日期”是基于从 1 开始计数,但 Python 列表从 0 开始(见图 13.2),我必须减去 1:
>>> day = 3
>>> ordinal[day - 1]
'third'

图 13.2 我们从 1 开始计数,但 Python 索引从 0 开始。
我同样可以使用一个dict:
ordinal = {
1: 'first', 2: 'second', 3: 'third', 4: 'fourth',
5: 'fifth', 6: 'sixth', 7: 'seventh', 8: 'eighth',
9: 'ninth', 10: 'tenth', 11: 'eleventh', 12: 'twelfth',
}
在这种情况下,你不需要减去 1。对你来说什么有效:
>>> ordinal[3]
'third'
我还使用了list来表示“礼物”:
gifts = [
'A partridge in a pear tree.',
'Two turtle doves,',
'Three French hens,',
'Four calling birds,',
'Five gold rings,',
'Six geese a laying,',
'Seven swans a swimming,',
'Eight maids a milking,',
'Nine ladies dancing,',
'Ten lords a leaping,',
'Eleven pipers piping,',
'Twelve drummers drumming,',
]
这样稍微有点道理,因为我可以使用列表切片来获取给定日期的“礼物”(见图 13.3):
>>> gifts[:3]
['A partridge in a pear tree.',
'Two turtle doves,',
'Three French hens,']

图 13.3 礼物按日期的升序列出。
但我希望它们是倒序的。reversed()函数是懒惰的,所以我需要在 REPL 中使用list()函数来强制转换值:
>>> list(reversed(gifts[:3]))
['Three French hens,',
'Two turtle doves,',
'A partridge in a pear tree.']
任何诗的前两行都是相同的,用“日期”的序数值替换:
lines = [
f'On the {ordinal[day - 1]} day of Christmas,',
'My true love gave to me,'
]
我需要将这些两行与“礼物”组合起来。由于每行诗由一定数量的行组成,我认为使用list来表示整个诗是有意义的。
我需要将“礼物”添加到“行”中,并且我可以使用list.extend()方法来完成:
>>> lines.extend(reversed(gifts[:day]))
现在有五行了:
>>> lines
['On the third day of Christmas,',
'My true love gave to me,',
'Three French hens,',
'Two turtle doves,',
'A partridge in a pear tree.']
>>> assert len(lines) == 5
注意,我不能使用list.append()方法。很容易将它与list.extend()方法混淆,后者接受另一个list作为其参数,将其展开,并将所有单个元素添加到原始list中。list.append()方法旨在向list添加“仅一个”元素,所以如果你给它一个list,它将把整个list附加到原始列表的末尾!
在这种情况下,reversed()迭代器将被添加到lines的末尾,这样它将有三个元素而不是所需的五个:
>>> lines.append(reversed(gifts[:day]))
>>> lines
['On the third day of Christmas,',
'My true love gave to me,',
<list_reverseiterator object at 0x105bc8588>]
你可能认为你可以用list()函数强制转换reversed()?想着你是,年轻的绝地武士,但遗憾的是,这仍然会在末尾添加一个新的list:
>>> lines.append(list(reversed(gifts[:day])))
>>> lines
['On the third day of Christmas,',
'My true love gave to me,',
['Three French hens,', 'Two turtle doves,', 'A partridge in a pear tree.']]
我们仍然有三行而不是五行:
>>> len(lines)
3
如果“日期”大于 1,我需要将最后一行改为说“并且一个”而不是“一个”:
if day > 1:
lines[-1] = 'And ' + lines[-1].lower()
注意,这是将lines表示为list的另一个很好的理由,因为list的元素是可变的。我本来可以将lines表示为一个str,但字符串是不可变的,所以修改最后一行会困难得多。
我想从函数中返回一个单一的str值,所以我将在换行符上连接lines:
>>> print('\n'.join(lines))
On the third day of Christmas,
My true love gave to me,
Three French hens,
Two turtle doves,
A partridge in a pear tree.
我的函数返回连接的lines,并将传递我提供的test_verse()函数。
13.3.2 生成诗篇
给定verse()函数,我可以通过从 1 迭代到给定的--num来创建所有需要的诗篇。我可以在verses的list中收集它们:
day = 3
verses = []
for n in range(1, day + 1):
verses.append(verse(n))
我可以测试我是否有正确数量的诗篇:
>>> assert len(verses) == day
每当你看到创建一个空的str或list然后使用for循环向其中添加内容的模式时,考虑改用列表推导式:
>>> verses = [verse(n) for n in range(1, day + 1)]
>>> assert len(verses) == day
我个人更喜欢使用map()而不是列表推导式。参见图 13.4 回顾三种方法如何结合在一起。我需要在 REPL 中使用list()函数来强制map()函数懒惰,但在程序代码中这不是必要的:
>>> verses = list(map(verse, range(1, day + 1)))
>>> assert len(verses) == day
所有这些方法都会产生正确数量的诗篇。选择对你最有意义的一个。

图 13.4 使用for循环、列表推导式和map()构建list。
13.3.3 打印诗篇
就像第十一章中的“99 瓶啤酒”一样,我想在诗篇之间打印两个换行符。str.join()方法是一个不错的选择:
>>> print('\n\n'.join(verses))
On the first day of Christmas,
My true love gave to me,
A partridge in a pear tree.
On the second day of Christmas,
My true love gave to me,
Two turtle doves,
And a partridge in a pear tree.
On the third day of Christmas,
My true love gave to me,
Three French hens,
Two turtle doves,
And a partridge in a pear tree.
你可以使用带有可选file参数的print()函数将文本放入一个打开的文件句柄中。args.outfile的值将是用户指定的文件或sys.stdout:
print('\n\n'.join(verses), file=args.outfile)
| 或者你可以使用fh.write()方法,但你需要记住添加print()为你添加的尾随换行符:
args.outfile.write('\n\n'.join(verses) + '\n')
写这个算法有数十到数百种方法,就像“99 瓶啤酒”一样。如果你提出了一个完全不同的方法并且通过了测试,那太棒了!请与我分享。我想强调如何编写、测试和使用单个verse()函数的想法,但我很乐意看到其他方法! |
|
13.4 进一步探索
安装emoji模块(pypi.org/project/emoji/),并用各种表情符号代替文本来打印礼物。例如,你可以使用':bird:'来打印每只鸟,比如母鸡或鸽子。我还使用了':man:'、':woman:'和':drum:',但你可以使用任何你喜欢的:
On the twelfth day of Christmas,
My true love gave to me,
Twelve 🥁s drumming,
Eleven 👨s piping,
Ten 👨s a leaping,
Nine 👩s dancing,
Eight 👩s a milking,
Seven 🐦s a swimming,
Six 🐦s a laying,
Five gold 💍s,
Four calling 🐦s,
Three French 🐦s,
Two turtle 🐦s,
And a 🐦 in a pear tree.
摘要
-
有许多方法可以将算法编码以执行重复性任务。在我的版本中,我编写并测试了一个处理一个任务的函数,然后映射了一系列输入值到该函数上。
-
range()函数将返回给定起始值和停止值之间的int值,后者不包括在内。 -
你可以使用
reversed()函数来反转range()返回的值。 -
如果你使用
type=argparse.FileType('wt')在argparse中定义一个参数,你将得到一个用于写入文本的打开文件句柄。 -
sys.stdout文件句柄始终打开并可用于写入。 -
将
gifts建模为一个list使我能够使用列表切片来获取给定日期的所有礼物。我使用了reversed()函数来将它们按照歌曲的正确顺序排列。 -
我将
lines建模为一个list,因为list是可变的,这是我需要用来在天数大于 1 时更改最后一行的。 -
变量或函数的阴影是重用现有的变量或函数名称。例如,如果你创建了一个具有现有函数名称的变量,那么该函数由于阴影而实际上被隐藏了。通过使用 Pylint 等工具来查找这些以及其他许多常见的编码问题来避免阴影。
在互联网上搜索 Rich Hickey,Clojure 语言创造者的演讲“Hammock Driven Development”。
14 Rhymer:使用正则表达式创建押韵单词
| 在电影《公主新娘》中,角色伊尼奥和菲兹克喜欢玩一个押韵游戏,尤其是在他们残酷的老板维兹尼对他们大喊大叫时:伊尼奥:那个维兹尼,他可以挑剔。菲兹克:我想他喜欢对我们大喊大叫。伊尼奥:可能他并无恶意。菲兹克:他真的很缺乏魅力。当我为第七章编写 alternate.txt 时,我会想出一个像“氰化物”这样的词,然后想知道我能和它押韵什么。我在心里从字母表中的第一个辅音音素开始,用“b”代替“byanide”,跳过“c”,因为那已经是第一个字符,然后用“d”代替“dyanide”,以此类推。这种方法很有效,但也很繁琐,所以我决定写一个程序来帮我做这件事,就像人们通常会做的那样。 | ![]() |
|---|
这基本上又是一个查找并替换类型的程序,就像在第四章中交换字符串中的所有数字或在第八章中交换字符串中的所有元音一样。我们使用非常手动、命令式的方法编写了这些程序,比如遍历字符串中的所有字符,将它们与某个想要的价值进行比较,并可能返回一个新值。
在第八章的最终解决方案中,我们简要提到了“正则表达式”(也称为“regexes”——发音时“g”的音轻柔,就像在“George”中一样),它为我们提供了一种声明式的方式来描述文本的模式。这里的材料可能有点超出了范围,但我真的很想帮助你深入了解正则表达式,看看它们能做什么。
| 在本章中,我们将取一个给定的单词,并创建“押韵的单词”。例如,单词“bake”与“cake”、“make”和“thrake”等单词押韵,最后一个实际上并不是字典中的单词,而只是我用“thr”替换“bake”中的“b”创建的新字符串。我们将使用的算法将单词拆分为任何初始辅音和单词的其余部分,所以“bake”被拆分为“b”和“ake”。我们将用字母表中所有其他辅音以及这些辅音簇替换“b”: | ![]() |
|---|
bl br ch cl cr dr fl fr gl gr pl pr sc sh sk sl sm sn sp st
sw th tr tw thw wh wr sch scr shr sph spl spr squ str thr
这些是我们程序为“cake”生成的前三个单词:
$ ./rhymer.py cake | head -3
bake
blake
brake
这最后三个:
$ ./rhymer.py cake | tail -3
xake
yake
zake
确保你的输出按字母顺序排序,这对于测试很重要。
我们将用其他辅音音素列表替换任何开头的辅音,以创建总共 56 个单词:
$ ./rhymer.py cake | wc -l
56
注意,我们将替换所有开头的辅音,而不仅仅是第一个。例如,对于单词“chair”,我们需要替换“ch”:
$ ./rhymer.py chair | tail -3
xair
yair
zair
如果像“apple”这样的单词不以辅音开头,我们将把所有辅音音素附加到开头,以创建像“bapple”和“shrapple”这样的单词。
$ ./rhymer.py apple | head -3
bapple
blapple
brapple
因为没有辅音可以替换,以元音开头的单词将产生 57 个押韵单词:
$ ./rhymer.py apple | wc -l
57
为了使这更容易,输出应始终为全部小写,即使输入有大写字母:
$ ./rhymer.py GUITAR | tail -3
xuitar
yuitar
zuitar
如果一个单词只包含辅音,我们将打印一条消息,说明该单词不能押韵:
|
$ ./rhymer.py RDNZL
Cannot rhyme "RDNZL"
使用正则表达式可以显著简化查找首字母音的任务。在这个程序中,你将
-
学习编写和使用正则表达式
-
使用列表推导式中的守卫
-
探索列表推导式与守卫与
filter()函数的相似之处 -
在布尔上下文中评估 Python 类型时,考虑“真值”的概念
![]() |
|---|
14.1 编写 rhymer.py
该程序接受一个单一的位置参数,即要押韵的字符串。图 14.1 展示了一个时髦的、爵士的、混乱的、刺耳的字符串图。

图 14.1 我们 rhymer 程序的输入应该是单词,输出将是押韵单词的列表或错误。
如果没有提供参数或-h或--help标志,它应该打印一个用法说明:
$ ./rhymer.py -h
usage: rhymer.py [-h] word
Make rhyming "words"
positional arguments:
word A word to rhyme
optional arguments:
-h, --help show this help message and exit
14.1.1 分解单词
在我看来,该程序的主要问题是将给定的单词分解为首字母音和其余部分——类似于单词的“词干”。
首先,我们可以定义一个占位符函数,我称之为stemmer(),目前它什么也不做:
def stemmer():
"""Return leading consonants (if any), and 'stem' of word"""
pass ①
① pass语句将什么都不做。由于函数不返回值,Python 默认返回 None。
然后,我们可以定义一个test_stemmer()函数来帮助我们思考可能提供给函数的值以及我们期望它返回什么。我们想要一个包含像“cake”和“apple”这样的良好值,这些值可以押韵,以及像空字符串或数字这样的值,这些值不能押韵:
def test_stemmer():
""" Test stemmer """
assert stemmer('') == ('', '') ①
② assert stemmer('cake') == ('c', 'ake')
assert stemmer('chair') == ('ch', 'air') ③
④ assert stemmer('APPLE') == ('', 'apple')
assert stemmer('RDNZL') == ('rdnzl', '') ⑤
⑥ assert stemmer('123') == ('123', '')
测试涵盖了以下良好和不良输入:
① 空字符串
② 以单个首字母开头的单词
③ 以首字母音簇开头的单词
④ 没有首字母音的单词;也是一个大写单词,因此这检查是否返回了小写
⑤ 没有元音的单词
⑥ 完全不是单词的东西
我决定我的stemmer()函数将始终返回一个包含(start, rest)的 2 元组。(你可以编写一个执行不同操作的函数,但请确保更改测试以匹配。)我们可以使用该元组的第二部分——rest——来创建押韵的单词。例如,单词“cake”产生一个包含('c', 'ake')的元组,而“chair”被分割成('ch', 'air')。参数“APPLE”没有start,只有单词的rest部分,它是小写的。
当我编写测试时,我通常尝试为我的函数和程序提供良好和不良的数据。三个测试值不能押韵:空字符串('')、没有元音的字符串('RDNZL')和没有字母的字符串('123')。stemmer()函数仍然会返回一个包含在元组的第一个位置的小写字符串和在元组的第二个位置为单词的其余部分的空字符串的元组。处理一个没有可以用来押韵的部分的单词取决于调用代码。
14.1.2 使用正则表达式
当然,可能不使用正则表达式编写这个程序,但我希望你能看到使用正则表达式与手动编写自己的搜索和替换代码有多么不同。
首先,我们需要引入re模块:
>>> import re
我鼓励你阅读help(re)来了解你可以用正则表达式做什么。这是一个深奥的主题,有无数本书和整个学术分支都致力于这个主题(杰弗里·弗里德尔的《精通正则表达式》(O’Reilly,2006)是我推荐的一本书)。有许多有用的网站可以进一步解释正则表达式,有些可以帮助你编写它们(例如regexr.com/)。我们将只触及正则表达式所能做的表面。
在这个程序中,我们的目标是编写一个正则表达式,以找到字符串开头的辅音。我们可以将辅音定义为不是元音的英语字母(“a”,“e”,“i”,“o”和“u”)。我们的stemmer()函数将只返回小写字母,所以我们只需要定义 21 个辅音。你可以把它们写出来,但我更愿意写一点代码!
我们可以从string.ascii_lowercase开始:
>>> import string
>>> string.ascii_lowercase
'abcdefghijklmnopqrstuvwxyz'
接下来,我们可以使用一个带有“守卫子句”的列表推导式来过滤掉元音。因为我们想要一个str的辅音而不是一个list,我们可以使用str.join()来创建一个新的str值:
>>> import string as s
>>> s.ascii_lowercase
'abcdefghijklmnopqrstuvwxyz'
>>> consonants = ''.join([c for c in s.ascii_lowercase if c not in 'aeiou'])
>>> consonants
'bcdfghjklmnpqrstvwxyz'
使用for循环和if语句的更长的写法如下(见图 14.2):
consonants = ''
for c in string.ascii_lowercase:
if c not in 'aeiou':
consonants += c

图 14.2 for循环(顶部)可以写成列表推导式(底部)。这个列表推导式包含一个守卫子句,以确保只选择辅音,这就像顶部的if语句。
在第八章中,我们创建了一个“字符类”来匹配元音,通过在方括号中列出它们,如'[aeiou]'。我们也可以用我们的consonants这样做:
>>> pattern = '[' + consonants + ']'
>>> pattern
'[bcdfghjklmnpqrstvwxyz]'
re模块有两个类似于搜索的函数,称为re.match()和re.search(),我总是把它们搞混。它们都在某个text中寻找一个pattern(第一个参数),但re.match()函数从text的beginning开始,而re.search()函数将在text的任何地方进行匹配。
事实上,re.match()非常合适,因为我们正在寻找字符串开头的辅音(见图 14.3)。
>>> text = 'chair'
>>> re.match(pattern, text) ①
<re.Match object; span=(0, 1), match='c'> ②
① 尝试在给定文本中匹配给定的模式。如果成功,我们得到一个 re.Match 对象;否则,返回 None 值。
② 匹配成功,所以我们看到了 re.Match 对象的“字符串化”版本。

图 14.3 辅音字符类将与“chair”开头的“c”匹配。
match='c'显示正则表达式找到了字符串'c'在开头。re.match()和re.search()函数在成功时都会返回一个re.Match对象。你可以阅读help(re.Match)来了解更多关于你可以用它们做什么的酷东西:
>>> match = re.match(pattern, text)
>>> type(match)
<class 're.Match'>
我们如何让正则表达式匹配字母'ch'?我们可以在字符类后跟一个'+'符号来表示我们想要一个或多个(这听起来有点像nargs='+'来表示一个或多个参数?)我将在这里使用 f-string 来创建模式:
>>> re.match(f'[{consonants}]+', 'chair')
<re.Match object; span=(0, 2), match='ch'>

图 14.4 在类名中添加加号将匹配一个或多个字符。
对于像“apple”这样的没有前导辅音的字符串,它给我们带来了什么,如图 14.5 所示?
>>> re.match(f'[{consonants}]+', 'apple')

图 14.5 这个正则表达式未能匹配以辅音开头的单词。
看起来我们没有从那里得到任何东西。那个返回值的type()是什么?
>>> type(re.match(f'[{consonants}]+', 'apple'))
<class 'NoneType'>
re.match()和re.search()函数都返回None来指示未能匹配任何文本。我们知道只有一些单词会有前导辅音音素,所以这并不奇怪。我们很快就会看到如何将其变为可选匹配。
14.1.3 使用捕获组
找到(或未找到)前导辅音固然很好,但这里的目的是将text分成两部分:辅音(如果有)和单词的其余部分。
我们可以将正则表达式的一部分用括号括起来以创建“捕获组”。如果正则表达式匹配成功,我们可以使用re.Match.groups()方法恢复这些部分(见图 14.6):
>>> match = re.match(f'([{consonants}]+)', 'chair')
>>> match.groups()
('ch',)

图 14.6 在模式周围添加括号会使匹配的文本作为捕获组可用。
要捕获consonants之后的所有内容,我们可以使用点(.)来匹配任何内容,并添加一个加号(+)表示一个或多个。我们可以将这个放入括号中以便捕获它(见图 14.7):
>>> match = re.match(f'([{consonants}]+)(.+)', 'chair')
>>> match.groups()
('ch', 'air')

图 14.7 我们定义了两个捕获组来访问前导辅音音素和随后的任何内容。
当我们尝试在“apple”上使用这个模式时,它未能匹配到辅音,因此整个匹配失败并返回None(见图 14.8):
>>> match = re.match(f'([{consonants}]+)(.+)', 'apple')
>>> match.groups()
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
AttributeError: 'NoneType' object has no attribute 'groups'

图 14.8 当文本以元音开头时,模式仍然失败。
记住,re.match()在未能找到模式时返回None。我们可以在consonants模式的末尾添加一个问号(?)使其变为可选(见图 14.9):
>>> match = re.match(f'([{consonants}]+)?(.+)', 'apple')
>>> match.groups()
(None, 'apple')

图 14.9 在模式后跟一个问号使其变为可选。
match.groups()函数返回一个包含每个由括号创建的分组匹配的tuple。您也可以使用match.group()(单数)与一个组号来获取特定的组。请注意,这些从 1 开始编号:
>>> match.group(1) ①
>>> match.group(2) ②
'apple'
① 在“apple”上没有匹配到第一组,因此这是一个 None。
② 第二组捕获了整个单词。
如果我们在“chair”上匹配,两组都有值:
>>> match = re.match(f'([{consonants}]+)?(.+)', 'chair')
>>> match.group(1)
'ch'
>>> match.group(2)
'air'
到目前为止,我们只处理了小写文本,因为我们的程序将始终输出小写值。不过,让我们探索一下当我们尝试匹配大写文本时会发生什么:
>>> match = re.match(f'([{consonants}]+)?(.+)', 'CHAIR')
>>> match.groups()
(None, 'CHAIR')
毫不奇怪,这失败了。我们的模式只定义了小写字母。我们可以添加所有大写辅音,但使用 re.match() 的第三个可选参数来指定不区分大小写的搜索要容易一些:
>>> match = re.match(f'([{consonants}]+)?(.+)', 'CHAIR', re.IGNORECASE)
>>> match.groups()
('CH', 'AIR')
或者,你可以强制将你正在搜索的文本转换为小写:
>>> match = re.match(f'([{consonants}]+)?(.+)', 'CHAIR'.lower())
>>> match.groups()
('ch', 'air')
当你在只有辅音的文本上搜索时,你会得到什么?
>>> match = re.match(f'([{consonants}]+)?(.+)', 'rdnzl')
>>> match.groups()
('rdnz', 'l')
你是否期望第一个组包含所有辅音,而第二个组没有任何内容?它决定将“l”拆分到最后一组,如图 14.10 所示,这似乎有点奇怪,但我们必须极其字面地思考正则表达式引擎是如何工作的。我们描述了一个可选的包含一个或多个辅音的组,该组必须由一个或多个其他任何内容跟随。这里的“l”算作一个或多个其他任何内容,所以正则表达式正好匹配我们所请求的内容。

图 14.10 正则表达式正好做了我们要求的事情,但可能不是我们想要的。
如果我们将 (.+) 改为 (.*) 以使其变为“零或更多”,它将按预期工作:
>>> match = re.match(f'([{consonants}]+)?(.*)', 'rdnzl')
>>> match.groups()
('rdnzl', '')
我们的正则表达式还不完整,因为它不处理匹配类似 123 的情况。也就是说,它匹配得太好了,因为点号 (.) 会匹配数字,而我们不希望这样:
>>> re.match(f'([{consonants}]+)?(.*)?', '123')
<re.Match object; span=(0, 3), match='123'>
我们需要表明在辅音之后应该至少有一个元音,这可能由任何其他内容跟随。我们可以使用另一个字符类来描述任何元音。由于我们需要捕获这个,我们将它放在括号中,所以 ([aeiou])。这可能由零个或多个任何内容跟随,这也需要被捕获,所以 (.*),如图 14.11 所示。

图 14.11 正则表达式现在要求存在元音。
让我们回到这里,尝试在预期会工作的值上操作:
>>> re.match(f'([{consonants}]+)?([aeiou])(.*)', 'cake').groups()
('c', 'a', 'ke')
>>> re.match(f'([{consonants}]+)?([aeiou])(.*)', 'chair').groups()
('ch', 'a', 'ir')
>>> re.match(f'([{consonants}]+)?([aeiou])(.*)', 'apple').groups()
(None, 'a', 'pple')
如您所见,当字符串不包含元音或字母时,这无法匹配:
>>> type(re.match(f'([{consonants}]+)?([aeiou])(.*)', 'rdnzl'))
<class 'NoneType'>
>>> type(re.match(f'([{consonants}]+)?([aeiou])(.*)', '123'))
<class 'NoneType'>
14.1.4 真实性
我们知道我们的程序将接收到一些无法押韵的输入,那么 stemmer() 函数对这些应该怎么办?有些人喜欢在这种情况下使用异常。我们遇到过请求列表索引或不存在字典键的异常。如果异常没有被捕获和处理,它们会导致我们的程序崩溃!
我尽量避免编写会创建异常的代码。我决定我的 stemmer() 函数总是返回一个 (start, rest) 的 2-元组,并且我会始终使用空字符串来表示缺失值,而不是 None。以下是我可以编写代码返回这些元组的一种方式:
if match: ①
p1 = match.group(1) or '' ②
p2 = match.group(2) or ''
p3 = match.group(3) or ''
return (p1, p2 + p3) ③
else:
return (word, '') ④
① 如果正则表达式失败,匹配将是 None,这是“假值”。如果它成功,那么它将是“真值”。
② 我们可以将三个捕获组放入三个变量中。我们想要确保不返回任何 None 值,所以我们可以使用“或”来评估左侧作为“真值”,如果它不是,则取右侧的空字符串。
③ 返回一个元组,包含单词的第一个部分(可能是辅音)和“剩余”部分(元音加上其他任何内容)。
④ 如果匹配结果是 None,返回一个包含单词和空字符串的元组,以表示没有“剩余”的单词可以押韵。
让我们花点时间思考一下or运算符,我们正在使用它来决定是选择左边的内容还是右边的内容。or将返回第一个“真值”,即那种在布尔上下文中——某种程度上,多少有点——评估为True的值:
>>> True or False ①
True
>>> False or True ②
True
>>> 1 or 0 ③
1
>>> 0 or 1 ④
1
>>> 0.0 or 1.0 ⑤
1.0
>>> '0' or '' ⑥
'0'
>>> 0 or False ⑦
False
>>> [] or ['foo'] ⑧
['foo']
>>> {} or dict(foo=1) ⑨
{'foo': 1}
① 最容易看到的是直接的真值和假值。
② 不论顺序如何,都会取真值。
③ 在布尔上下文中,整数值 0 是“假值”,任何其他值都是“真值”。
④ 数字值的行为与实际的布尔值完全一样。
⑤ 浮点值也像整数值一样表现,其中 0.0 是“假值”,其他任何内容都是“真值”。
⑥ 对于字符串值,空字符串是“假值”,其他任何内容都是“真值”。这看起来可能有点奇怪,因为它返回的是'0',但这并不是数字 0,而是我们用来表示 0 值的字符串。哇,这真是有哲学意味。
⑦ 如果没有值是“真值”,则返回最后一个值。
⑧ 空列表是“假值”,所以任何非空列表都是“真值”。
⑨ 空字典是“假值”,任何非空字典都是“真值”。
你应该能够使用这些想法来编写一个stemmer()函数,该函数将传递test_stemmer()函数。记住,如果这两个函数都在你的rhymer.py程序中,你可以这样运行test_函数:
$ pytest -xv rhymer.py
14.1.5 创建输出
让我们回顾一下程序应该做什么:
-
接收一个位置字符串参数。
-
尝试将其分成两部分:任何前面的辅音字母和单词的其余部分。
-
如果拆分成功,将单词的“剩余”部分(如果前面没有辅音字母,实际上可能是整个单词)与所有其他辅音音素结合起来。确保不要包括原始的辅音音素,并对押韵字符串进行排序。
-
如果无法拆分单词,打印消息
Cannotrhyme"<word>"。
现在是时候编写程序了。祝你在攻打城堡时玩得开心!
14.2 解决方案
“现在没有押韵了,我是认真的!”
“有人想要花生吗?”
让我们看看解决这个问题的方法之一。你的解决方案与这个方法有多大的不同?
#!/usr/bin/env python3
"""Make rhyming words"""
import argparse
import re ①
import string
# --------------------------------------------------
def get_args():
"""get command-line arguments"""
parser = argparse.ArgumentParser(
description='Make rhyming "words"',
formatter_class=argparse.ArgumentDefaultsHelpFormatter)
parser.add_argument('word', metavar='word', help='A word to rhyme')
return parser.parse_args()
# --------------------------------------------------
def main():
"""Make a jazz noise here"""
args = get_args() ②
prefixes = list('bcdfghjklmnpqrstvwxyz') + ( ③
'bl br ch cl cr dr fl fr gl gr pl pr sc '
'sh sk sl sm sn sp st sw th tr tw thw wh wr '
'sch scr shr sph spl spr squ str thr').split()
start, rest = stemmer(args.word) ④
if rest: ⑤
print('\n'.join(sorted([p + rest for p in prefixes if p != start]))) ⑥
else:
print(f'Cannot rhyme "{args.word}"') ⑦
# --------------------------------------------------
def stemmer(word):
"""Return leading consonants (if any), and 'stem' of word"""
word = word.lower() ⑧
vowels = 'aeiou' ⑨
consonants = ''.join( ⑩
[c for c in string.ascii_lowercase if c not in vowels])
pattern = ( ⑪
'([' + consonants + ']+)?' # capture one or more, optional
'([' + vowels + '])' # capture at least one vowel
'(.*)' # capture zero or more of anything
)
match = re.match(pattern, word) ⑫
if match: ⑬
p1 = match.group(1) or '' ⑭
p2 = match.group(2) or ''
p3 = match.group(3) or ''
return (p1, p2 + p3) ⑮
else:
return (word, '') ⑯
# --------------------------------------------------
def test_stemmer(): ⑰
"""test the stemmer"""
assert stemmer('') == ('', '')
assert stemmer('cake') == ('c', 'ake')
assert stemmer('chair') == ('ch', 'air')
assert stemmer('APPLE') == ('', 'apple')
assert stemmer('RDNZL') == ('rdnzl', '')
assert stemmer('123') == ('', '')
# --------------------------------------------------
if __name__ == '__main__':
main()
① re模块是用于正则表达式的。
② 获取命令行参数。
③ 定义所有将要添加以创建押韵词的前缀。
④ 将单词参数分成两个可能的组成部分。因为stemmer()函数总是返回一个 2 元组,所以我们可以将值解包到两个变量中。
⑤ 检查是否有可以用来创建押韵字符串的单词部分。
⑥ 如果有,使用列表推导式遍历所有前缀并将它们添加到单词的词干中。使用守卫来确保任何给定前缀都不与单词的开头相同。对所有值进行排序并按新行连接打印它们。
⑦ 如果没有可以用来创建押韵的“剩余”单词部分,让用户知道。
⑧ 将单词转换为小写。
⑨ 由于我们将多次使用元音,所以将它们分配给一个变量。
⑩ 辅音是指不是元音的字母。我们只会匹配小写字母。
⑪ 模式使用连续的字符串字面量定义,Python 将将其连接成一个字符串。通过将片段拆分到单独的行上,我们可以对正则表达式的每一部分进行注释。
⑫ 使用 re.match() 函数从单词的开始进行匹配。
⑬ 如果模式匹配失败,re.match() 函数将返回 None,因此请检查匹配是否为“真值”(非 None)。
⑭ 将每个组放入变量中,始终确保我们使用空字符串而不是 None。
⑮ 返回一个新的元组,包含单词的“第一”部分(可能的引导辅音)和单词的“其余”部分(元音加上其他任何内容)。
⑯ 如果匹配失败,返回单词和空字符串作为“剩余”部分,以表示没有可以押韵的部分。
⑰ 对 stemmer() 函数进行测试。我通常喜欢将单元测试直接放在被测试函数之后。
14.3 讨论部分
你可以以多种方式编写这个,但,像往常一样,我想将问题分解成我可以编写和测试的单位。对我来说,这归结为将单词拆分为可能的引导辅音音素和单词的其余部分。如果我能做到这一点,我就可以创建押韵字符串;如果我不能,那么我需要提醒用户。
14.3.1 对单词进行词干提取
对于这个程序,单词的“词干”是指任何初始辅音之后的部分,我使用列表推导式并带有保护条件来定义它,以仅获取不是元音的字母:
>>> vowels = 'aeiou'
>>> consonants = ''.join([c for c in string.ascii_lowercase if c not in vowels])
在整个章节中,我展示了列表推导式是一个生成列表的简洁方式,并且比使用 for 循环向现有列表中追加更可取。在这里,我们添加了一个 if 语句,仅当字符不是元音时才包括它们。这被称为 保护语句,只有评估为“真值”的元素才会包含在结果列表中。
我们已经多次查看 map() 并讨论了它是一个 高阶函数 (HOF),因为它接受 另一个函数 作为第一个参数,并将它应用于某个 可迭代对象(可以迭代的,如 list)的所有元素。在这里,我想介绍另一个名为 filter() 的高阶函数,它也接受一个函数和一个可迭代对象(见图 14.12)。与带有保护条件的列表推导式一样,只有那些从函数返回“真值”的元素才允许包含在结果列表中。

图 14.12 map() 和 filter() 函数都接受一个函数和一个可迭代对象,并且都会生成一个新的列表。
这里是使用 filter() 编写列表推导式想法的另一种方式:
>>> consonants = ''.join(filter(lambda c: c not in vowels, string.ascii_lowercase))
正如与 map() 一样,我使用 lambda 关键字创建一个 匿名函数。c 是将持有参数的变量,在这种情况下,它将是 string.ascii_lowercase 中的每个字符。函数的整个主体是评估 c not in vowels。对于每个元音,它都会返回 False:
>>> 'a' not in vowels
False
每个辅音都会返回 True:
>>> 'b' not in vowels
True
因此,只有辅音会被允许通过 filter()。回想一下我们的“蓝色”汽车;让我们编写一个 filter() 函数,它只接受以字符串“blue”开头的汽车:
>>> cars = ['blue Honda', 'red Chevy', 'blue Ford']
>>> list(filter(lambda car: car.startswith('blue '), cars))
['blue Honda', 'blue Ford']
当 car 变量的值为“red Chevy”时,lambda 返回 False,该值被拒绝:
>>> car = 'red Chevy'
>>> car.startswith('blue ')
False
注意,如果原始可迭代对象中的没有任何元素被接受,filter() 将产生一个空 list ([])。例如,我可以 filter() 大于 10 的数字。注意 filter() 是另一个 惰性 函数,我必须使用 REPL 中的 list 函数来强制转换:
>>> list(filter(lambda n: n > 10, range(0, 5)))
[]
列表推导式也会返回一个空列表:
>>> [n for n in range(0, 5) if n > 10]
[]
图 14.13 展示了使用命令式 for-循环方法创建一个名为 consonants 的新 list、使用带守卫的惯用列表推导式以及使用 filter() 的纯函数方法之间的关系。所有这些方法都是完全可以接受的,尽管最 Pythonic 的技术可能是列表推导式。对于 C 或 Java 程序员来说,for 循环会非常熟悉,而对于 Haskell 或 Lisp 类语言的人来说,filter() 方法会立即被识别出来。filter() 可能比列表推导式慢,特别是如果可迭代对象很大。选择对你风格和应用更有意义的方法。

图 14.13 创建辅音列表的三种方法:使用带 if 语句的 for 循环、带守卫的列表推导式和 filter()
14.3.2 格式化和注释正则表达式
在引言中,我们讨论了我最终使用的正则表达式的各个部分。我想花一点时间来提及我在代码中格式化正则表达式的方式。我使用了 Python 解释器的一个有趣技巧,该技巧可以隐式地连接相邻的字符串字面量。看看这四个字符串如何变成一个:
>>> this_is_just_to_say = ('I have eaten '
... 'the plums '
... 'that were in '
... 'the icebox')
>>> this_is_just_to_say
'I have eaten the plums that were in the icebox'
注意,每个字符串后面都没有逗号,因为那将创建一个包含四个单独字符串的 tuple:
>>> this_is_just_to_say = ('I have eaten ',
... 'the plums ',
... 'that were in ',
... 'the icebox')
>>> this_is_just_to_say
('I have eaten ', 'the plums ', 'that were in ', 'the icebox')
将正则表达式写在单独的行上的优点是可以添加注释来帮助读者理解每一部分:
pattern = (
'([' + consonants + ']+)?' # capture one or more, optional
'([' + vowels + '])' # capture at least one vowel
'(.*)' # capture zero or more of anything
)
个体字符串将由 Python 连接成一个单独的字符串:
>>> pattern
'([bcdfghjklmnpqrstvwxyz]+)?([aeiou])(.*)'
我本可以将整个正则表达式写在一行中,但问问自己,你更愿意阅读和维护哪种版本,前面的版本还是下面的版本:1
pattern = f'([{consonants}]+)?([{vowels}])(.*)'
14.3.3 在程序外部使用 stemmer() 函数
关于 Python 代码的一个非常有趣的事情是,你的 rhymer.py 程序也是一种——有点, sort of——可共享的模块代码。也就是说,你没有明确地编写它来作为可重用(并且经过测试!)函数的容器,但它确实是。你甚至可以从 REPL 内部运行这些函数。
为了使这个功能正常工作,请确保你在与 rhymer.py 代码相同的目录下运行python3:
>>> from rhymer import stemmer
现在你可以手动运行和测试你的stemmer()函数:
>>> stemmer('apple')
('', 'apple')
>>> stemmer('banana')
('b', 'anana')
>>> import string
>>> stemmer(string.punctuation)
('!"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~', '')
if __name__ == '__main__':的深层含义
注意,如果你要将 rhymer.py 的最后两行从这里更改,
if __name__ == '__main__':
main()
到这里,
main()
当你尝试导入模块时,将会运行main()函数:
>>> from rhymer import stemmer
usage: [-h] str
: error: the following arguments are required: str
这是因为import rhymer会导致 Python 执行 rhymer.py 文件直到末尾。如果模块的最后一行调用main(),那么main()将会运行!
当 rhymer.py 作为程序运行时,__name__变量被设置为'__main__'。这是main()被执行的唯一时间。当模块被另一个模块导入时,__name__等于rhymer。
如果你没有明确import一个函数,你可以通过在前面添加模块名称来使用完全限定的函数名称:
>>> import rhymer
>>> rhymer.stemmer('cake')
('c', 'ake')
>>> rhymer.stemmer('chair')
('ch', 'air')
| 写很多小的函数而不是长而分散的程序有很多优点。其中之一是小的函数更容易编写、理解和测试。另一个优点是你可以将整洁、经过测试的函数放入模块中,并在你编写的不同程序之间共享它们。 | ![]() |
|---|
随着你编写越来越多的程序,你会发现自己在反复解决一些相同的问题。创建具有可重用代码的模块比从程序到程序复制代码要好得多。如果你在共享函数中找到一个错误,你可以修复一次,所有共享该函数的程序都会得到修复。另一种选择是在每个程序中找到重复的代码并更改它(希望这不会因为代码与其他代码纠缠而引入更多问题)。
14.3.4 创建押韵字符串
我决定我的stemmer()函数将始终返回一个包含(start, rest)的 2 元组,对于任何给定的单词。因此,我可以将这两个值解包到两个变量中:
>>> start, rest = stemmer('cat')
>>> start
'c'
>>> rest
'at'
如果有rest的值,我可以将所有的prefixes添加到前面:
>>> prefixes = list('bcdfghjklmnpqrstvwxyz') + (
... 'bl br ch cl cr dr fl fr gl gr pl pr sc '
... 'sh sk sl sm sn sp st sw th tr tw wh wr'
... 'sch scr shr sph spl spr squ str thr').split()
我决定使用另一个带有守卫器的列表推导式来跳过任何与单词start相同的prefix。结果将是一个新的list,我将它传递给sorted()函数以获取正确排序的字符串:
>>> sorted([p + rest for p in prefixes if p != start])
['bat', 'blat', 'brat', 'chat', 'clat', 'crat', 'dat', 'drat', 'fat',
'flat', 'frat', 'gat', 'glat', 'grat', 'hat', 'jat', 'kat', 'lat',
'mat', 'nat', 'pat', 'plat', 'prat', 'qat', 'rat', 'sat', 'scat',
'schat', 'scrat', 'shat', 'shrat', 'skat', 'slat', 'smat', 'snat',
'spat', 'sphat', 'splat', 'sprat', 'squat', 'stat', 'strat', 'swat',
'tat', 'that', 'thrat', 'thwat', 'trat', 'twat', 'vat', 'wat',
'what', 'wrat', 'xat', 'yat', 'zat']
然后print()那个列表,按新行连接。如果没有给定单词的rest,我print()一条消息,说明该单词不能押韵:
if rest:
print('\n'.join(sorted([p + rest for p in prefixes if p != start])))
else:
print(f'Cannot rhyme "{args.word}"')
14.3.5 不使用正则表达式编写 stemmer()函数
当然可以编写一个不使用正则表达式的解决方案。我们可以从找到给定字符串中元音字母的第一个位置开始。如果存在,我们可以使用列表切片来返回字符串到该位置的部分以及从该位置开始的字符串部分:
def stemmer(word):
"""Return leading consonants (if any), and 'stem' of word"""
word = word.lower() ①
vowel_pos = list(map(word.index, filter(lambda v: v in word, 'aeiou'))) ②
if vowel_pos: ③
first_vowel = min(vowel_pos) ④
return (word[:first_vowel], word[first_vowel:]) ⑤
else: ⑥
return (word, '') ⑦
① 将给定的单词转换为小写以避免处理大写字母。
② 过滤元音字母 'aeiou' 以找到单词中的那些,然后将存在的元音字母映射到word.index以找到它们的位置。这是我们需要使用list()函数来强制 Python 评估懒映射(lazy map())函数的罕见情况之一,因为下一个 if 语句需要一个具体的值。
③ 检查单词中是否存在元音字母。
④ 通过取位置的最小值(min)来找到第一个元音字母的索引。
⑤ 返回一个包含单词到第一个元音字母的切片和从第一个元音字母开始的另一个切片的元组。
⑥ 否则,单词中没有找到元音字母。
⑦ 返回一个包含单词和空字符串的 2 元组,以表示没有剩余的单词可用于押韵。
此函数还将通过test_stemmer()函数。通过为这个函数的想法编写一个测试,并使用所有我预期的不同值来练习它,我可以自由地重构我的代码。在我的心中,stemmer()函数是一个黑盒。函数内部发生的事情与调用它的代码无关。只要函数通过测试,它就是“正确”的(对于“正确”的某些值)。小型函数及其测试将使你自由地改进你的程序。首先让某物工作,并使其美观。然后尝试使其更好,使用测试来确保它按预期工作。 |
![]() |
|---|
14.4 深入学习
-
添加一个
--output选项,将单词写入指定的文件。默认情况下,应写入STDOUT。 -
读取输入文件并为文件中的所有单词创建押韵词。你可以从第六章的程序中借用,以读取文件并将其分解成单词,然后迭代每个单词,并为每个单词创建一个包含押韵词的输出文件。
-
编写一个新的程序,找出英语单词字典中所有独特的辅音音素。(我已经包括了 inputs/words.txt.zip,这是我机器上字典的压缩版本。解压文件以使用 inputs/words.txt。)以字母顺序打印输出,并使用这些来扩展程序中的辅音。
-
修改你的程序,使其只输出系统中字典(例如,inputs/words.txt)中存在的单词。
-
编写一个程序来创建 Pig Latin,将单词开头的辅音音素移动到末尾,并添加“-ay”,使“cat”变成“at-cay”。如果一个单词以元音字母开头,则在末尾添加“-yay”,使“apple”变成“apple-yay”。
-
编写一个程序来创建斯波纳姆(spoonerisms),即交换相邻单词的初始辅音音素,所以你会得到“blushing crow”而不是“crushing blow”。
摘要
-
正则表达式允许你声明你希望找到的模式。正则表达式 引擎 将确定模式是否被找到。这是一种 声明式 编程方法,与手动通过编写代码来寻找模式的 命令式 方法相反。
-
你可以将模式的部分用括号括起来以“捕获”它们,然后从
re.match()或re.search()的结果中获取这些组。 -
你可以在列表推导式中添加一个守卫来避免从可迭代对象中取一些元素。
-
filter()函数是另一种带有守卫的列表推导式写法。像map()一样,它是一个惰性、高阶函数,它接受一个将被应用于可迭代对象每个元素的函数。只有那些被函数认为是“真值”的元素才会被返回。 -
Python 可以在布尔上下文中评估许多类型--包括字符串、数字、列表和字典--以获得“真值”的感觉。也就是说,在
if表达式中,你不仅限于True和False。空字符串''、整数0、浮点数0.0、空列表[]和空字典{}都被认为是“假值”,所以那些类型中的任何非假值,如非空str、list或dict,或任何非零的数值,都将被认为是“真值”。 -
你可以在代码中将长字符串字面量拆分为较短的相邻字符串,让 Python 将它们连接成一个长字符串。建议将长正则表达式拆分为较短的字符串,并在每行添加注释以记录每个模式的功能。
-
编写小的函数和测试,并在模块中共享它们。每个
.py文件都可以是一个模块,你可以从中import函数。共享小的、经过测试的函数比编写长程序并在需要时复制/粘贴代码要好。
1 “查看你两周前写的代码就像第一次看到它一样。”--丹·赫尔维茨
15 肯塔基修士:更多正则表达式
| 我在美国南部长大,那里我们倾向于省略以“ing”结尾的单词的最后一个“g”,比如用“cookin’”而不是“cooking”。我们还说“y’all”作为第二人称复数代词,这很有道理,因为标准英语缺少一个独特的词来表示这个。在这个练习中,我们将编写一个名为 friar.py 的程序,它将接受一些输入作为单个位置参数,并通过将两个音节以“ing”结尾的单词的最后一个“g”替换为撇号(')以及将“you”改为“y’all”来转换文本。诚然,我们无法知道我们是在改变第一人称还是第二人称的“you”,但这仍然是一个有趣的挑战。 | ![]() |
|---|
图 15.1 是一个字符串图,它将帮助你看到输入和输出。当没有参数或使用-h或--help标志运行时,你的程序应该显示以下用法说明:
$ ./friar.py -h
usage: friar.py [-h] text
Southern fry text
positional arguments:
text Input text or file
optional arguments:
-h, --help show this help message and exit

图 15.1 我们的程序将修改输入文本,使其带有南方口音。
我们只会改变带有两个音节的“-ing”单词,所以“cooking”变成“cookin’”,但“swing”将保持不变。我们识别两个音节“-ing”单词的启发式方法是检查单词中“-ing”结尾之前的部分,看看它是否包含元音,在这个例子中包括“y”。我们可以将“cooking”分成“cook”和“ing”,因为“cook”中有一个“o”,所以我们应该去掉最后的“g”:
$ ./friar.py Cooking
Cookin'
当我们从“swing”中移除“ing”时,我们剩下的是“sw”,它不包含元音,所以它将保持不变:
$ ./friar.py swing
swing
当将“you”改为“y’all”时,请注意保持第一个字母的大小写不变。例如,“You”应该变成“Y’all”:
$ ./friar.py you
y'all
$ ./friar.py You
Y'all
与之前的几个练习一样,输入可能指定了一个文件,在这种情况下,你应该读取该文件以获取输入文本。为了通过测试,你需要保留输入的行结构,所以我建议你逐行读取文件。给定这个输入,
$ head -2 inputs/banner.txt
O! Say, can you see, by the dawn's early light,
What so proudly we hailed at the twilight's last gleaming -
输出应该有相同的换行符:
$ ./friar.py inputs/banner.txt | head -2
O! Say, can y'all see, by the dawn's early light,
What so proudly we hailed at the twilight's last gleamin' -
对我来说,以这种方式转换文本非常有趣,但也许我只是有点奇怪:
$ ./friar.py inputs/raven.txt
Presently my soul grew stronger; hesitatin' then no longer,
“Sir,” said I, “or Madam, truly your forgiveness I implore;
But the fact is I was nappin', and so gently y'all came rappin',
And so faintly y'all came tappin', tappin' at my chamber door,
That I scarce was sure I heard y'all” - here I opened wide the door: -
Darkness there and nothin' more.
在这个练习中,你将
|
-
了解更多关于使用正则表达式的信息
-
使用
re.match()和re.search()分别找到锚定到字符串开头或字符串任何位置的图案 -
学习正则表达式中的
$符号如何将模式锚定到字符串的末尾 -
学习如何使用
re.split()来分割字符串 -
探索如何编写一个手动解决方案来查找两个音节的“-ing”单词或单词“you”
![]() |
|---|
15.1 编写 friar.py
与往常一样,我建议你从new.py friar.py开始,或者将模板/模板.py 文件复制到 15_friar/friar.py。我建议你从一个简单的程序版本开始,该程序会回显命令行中的输入:
$ ./friar.py cooking
cooking
或者从一个文件中:
$ ./friar.py inputs/blake.txt
Father, father, where are you going?
Oh do not walk so fast!
Speak, father, speak to your little boy,
Or else I shall be lost.
我们需要逐行逐字地处理输入。你可以使用str.splitlines()方法来获取输入的每一行,然后使用str.split()方法在空格上拆分行,将其拆分为类似单词的单元。这段代码,
for line in args.text.splitlines():
print(line.split())
应该生成以下输出:
$ ./friar.py tests/blake.txt
['Father,', 'father,', 'where', 'are', 'you', 'going?']
['Oh', 'do', 'not', 'walk', 'so', 'fast!']
['Speak,', 'father,', 'speak', 'to', 'your', 'little', 'boy,']
['Or', 'else', 'I', 'shall', 'be', 'lost.']
如果你仔细观察,处理这些类似单词的单元将会很困难,因为相邻的标点符号仍然附着在单词上,例如'Father,'和'going?'。在空格上拆分文本是不够的,所以我将向你展示如何使用正则表达式拆分文本。
15.1.1 使用正则表达式拆分文本
正如第十四章所述,我们需要import re来使用正则表达式:
>>> import re
为了演示目的,我将text设置为第一行:
>>> text = 'Father, father, where are you going?'
默认情况下,str.split()在空格上拆分文本。请注意,用于拆分的任何文本都将从结果中缺失,因此这里没有空格:
>>> text.split()
['Father,', 'father,', 'where', 'are', 'you', 'going?']
你可以向str.split()传递一个可选值来指示你想要用于拆分的字符串。如果我们选择逗号,我们将得到三个字符串而不是六个。注意,结果列表中没有逗号,因为那是str.split()的参数:
>>> text.split(',')
['Father', ' father', ' where are you going?']
re模块有一个名为re.split()的函数,其工作方式类似。我建议你阅读help(re.split),因为这个函数非常强大且灵活。就像我们在第十四章中使用的re.match()函数一样,这个函数至少需要一个pattern和一个string。我们可以使用re.split()与逗号一起使用,以获得与str.split()相同的输出,并且,像之前一样,结果中没有逗号:
>>> re.split(',', text)
['Father', ' father', ' where are you going?']
15.1.2 简写类
我们寻找的是看起来像“单词”的东西,因为它们由通常出现在单词中的字符组成。那些不通常出现在单词中的字符(如标点符号)是我们想要用于拆分的。你之前已经看到,我们可以通过在方括号内放置字面值来创建一个字符类,例如'[aeiou]'用于元音。如果我们创建一个包含所有非字母字符的字符类会怎样?我们可以这样做:
>>> import string
>>> ''.join([c for c in string.printable if c not in string.ascii_letters])
'0123456789!"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~ \t\n\r\x0b\x0c'
这将不是必要的,因为几乎每个正则表达式引擎的实现都定义了简写字符类。表 15.1 列出了一些最常见的简写类以及它们如何以长写形式表示。
表 15.1 正则表达式简写类
| 字符类 | 简写 | 其他写法 |
|---|---|---|
| 数字 | \d |
[0123456789],[0-9] |
| 空白字符 | \s |
[ \t\n\r\x0b\x0c],与string.whitespace相同 |
| 单词字符 | \w |
[a-zA-Z0-9_-] |
注意,正则表达式语法的基本风味被从 Unix 命令行工具如awk到像 Perl、Python 和 Java 这样的语言中的正则表达式支持所识别。一些工具为它们的正则表达式添加了扩展,这些扩展可能不会被其他工具理解。例如,曾经有一段时间,Perl 的正则表达式引擎添加了许多新想法,这些想法最终成为了一个被称为“PCRE”(Perl 兼容正则表达式)的方言。并不是所有理解正则表达式的工具都会理解每种正则表达式的风味,但在我多年的正则表达式编写和使用经验中,我很少遇到这个问题。
简写\d表示任何数字,等同于'[0123456789]'。我可以使用re.search()方法在字符串的任何位置查找任何数字。在下面的例子中,它会在字符串'abc123!'中找到字符'1',因为这是字符串中的第一个数字(见图 15.2):
>>> re.search('\d', 'abc123!')
<re.Match object; span=(3, 4), match='1'>

图 15.2 数字简写将匹配任何单个数字。
这与使用长版本相同(见图 15.3):
>>> re.search('[0123456789]', 'abc123!')
<re.Match object; span=(3, 4), match='1'>

图 15.3 我们也可以创建一个包含所有数字的字符类。
它也等同于使用字符范围'[0-9]'的版本(见图 15.4):
>>> re.search('[0-9]', 'abc123!')
<re.Match object; span=(3, 4), match='1'>

图 15.4 字符类可以使用一系列连续的值,如 0-9。
要找到一行中的一个或多个连续的数字,请添加加号(见图 15.5):
>>> re.search('\d+', 'abc123!')
<re.Match object; span=(3, 6), match='123'>

图 15.5 加号意味着匹配前面表达式的一个或多个。
\w简写意味着“任何类似单词的字符。”它包括所有的阿拉伯数字,英语字母表中的字母,破折号('-')和下划线('_')。字符串中的第一个匹配是'a'(见图 15.6):
>>> re.search('\w', 'abc123!')
<re.Match object; span=(0, 1), match='a'>

图 15.6 简写为单词字符的是\w。
如果你像图 15.7 中那样添加加号,它将匹配一行中的一个或多个单词字符,包括abc123但不包括感叹号(!):
>>> re.search('\w+', 'abc123!')
<re.Match object; span=(0, 6), match='abc123'>

图 15.7 添加加号以匹配一个或多个单词字符。
15.1.3 否定简写类
你可以通过在字符类中立即放置撇号来补充或“否定”一个字符类,如图 15.8 所示。一个或多个不是数字的字符是'[⁰-9]+'。有了它,就能找到'abc':
>>> re.search('[⁰-9]+', 'abc123!')
<re.Match object; span=(0, 3), match='abc'>

图 15.8 在字符类中紧挨着字符的地方放置一个撇号将否定或补充字符。这个正则表达式匹配非数字。
非数字的简写类[⁰-9]+也可以写成\D+,如图 15.9 所示:
>>> re.search('\D+', 'abc123!')
<re.Match object; span=(0, 3), match='abc'>

图 15.9 简写\D+匹配一个或多个非数字。
非单词字符的简写是\W,它将匹配感叹号(见图 15.10):
>>> re.search('\W', 'abc123!')
<re.Match object; span=(6, 7), match='!'>

图 15.10 \W将匹配任何不是字母、数字、下划线或破折号的字符。
表 15.2 总结了这些简写类以及它们如何扩展。
表 15.2 否定正则表达式简写类
| 字符类 | 简写 | 其他写法 |
|---|---|---|
| 非数字 | \D |
[⁰¹²³⁴⁵⁶⁷⁸⁹], [⁰-9] |
| 非空白字符 | \S |
[^ \t\n\r\x0b\x0c] |
| 非单词字符 | \W |
[^a-zA-Z0-9_-] |
15.1.4 使用 re.split() 和捕获的正则表达式
我们可以使用 \W 作为 re.split() 的参数:
>>> re.split('\W', 'abc123!')
['abc123', '']
注意,如果我们在程序中的正则表达式中使用 '\W',Pylint 会抱怨,返回消息“字符串中的异常反斜杠:'\W'。字符串常量可能缺少 r 前缀。”我们可以使用 r 前缀来创建一个“原始”字符串,其中 Python 不会像解释 \n 为换行符或 \r 为回车符那样解释 \W。从现在开始,我将使用 r-string 语法来创建原始字符串。
然而,有一个问题,因为 re.split() 的结果 省略了匹配模式的字符串。这里我们丢失了感叹号!如果我们仔细阅读 help(re.split),我们可以找到解决方案:
如果在 [模式] 中使用 捕获括号,那么模式中所有组的文本也将作为结果列表的一部分返回。
在第十四章中,我们使用了捕获括号来告诉正则表达式引擎“记住”某些模式,比如辅音(s)、元音以及单词的其余部分。当正则表达式匹配时,我们能够使用 match.groups() 来检索由模式找到的字符串。在这里,我们将使用括号围绕模式来 re.split(),这样匹配模式的字符串也将被返回:
>>> re.split(r'(\W)', 'abc123!')
['abc123', '!', '']
如果我们在 text 上尝试这样做,结果是匹配和不匹配正则表达式的字符串的 list:
>>> re.split(r'(\W)', text)
['Father', ',', '', ' ', 'father', ',', '', ' ', 'where', ' ', 'are', ' ', 'you', ' ', 'going', '?', '']
我想通过在正则表达式中添加 + 来将所有非单词字符组合在一起(见图 15.11):
>>> re.split(r'(\W+)', text)
['Father', ', ', 'father', ', ', 'where', ' ', 'are', ' ', 'you', ' ', 'going', '?', '']

图 15.11 re.split() 函数可以使用捕获的正则表达式返回匹配正则表达式和不匹配的部分。
这真是太酷了!现在我们有了处理每个 实际 单词及其之间位的方法。
15.1.5 编写 fry() 函数
我们下一步要编写一个函数,该函数将决定是否以及如何修改 仅一个单词。也就是说,我们不会考虑如何一次性处理所有文本,而是会考虑一次处理一个单词。我们可以称这个函数为 fry()。
为了帮助我们思考这个函数应该如何工作,让我们先编写 test_fry() 函数和实际 fry() 函数的存根,该存根只包含单个命令 pass,它告诉 Python 不做任何事情。为了开始这个,你可以将以下内容粘贴到你的程序中:
def fry(word):
pass ①
def test_fry(): ②
assert fry('you') == "y'all" ③
assert fry('You') == "Y'all" ④
assert fry('fishing') == "fishin'" ⑤
assert fry('Aching') == "Achin'" ⑥
assert fry('swing') == "swing" ⑦
① pass 是一种什么也不做的做法。你可以称之为“无操作”或“NO-OP”,它看起来有点像“NOPE”,这也是另一种记住它什么也不做的记忆方法。我们只是将这个 fry() 函数定义为占位符,这样我们就可以编写测试。
② test_fry()函数将通过我们期望改变或不变的单词。我们无法检查每个单词,所以我们将依赖于抽查主要情况。
③ “you”这个词应该变成“y’all”。
④ 确保保留单词的大写形式。
⑤ 这是一个双音节“-ing”词,应该通过去掉最后的“g”来改为撇号形式。
⑥ 这是一个以元音开头的双音节“-ing”词,也应该同样进行修改。
⑦ 这是一个单音节“-ing”词,不应该改变。
现在运行pytest friar.py来查看,正如预期的那样,测试将失败:
=================================== FAILURES ===================================
___________________________________ test_fry ___________________________________
def test_fry():
> assert fry('you') == "y'all" ①
E assert None == "y'all" ②
E + where None = fry('you')
friar.py:47: AssertionError
=========================== 1 failed in 0.08 seconds ===========================
① 第一个测试失败了。
② fry('you')的结果是 None,这不等于“y’all”。
让我们把我们的fry()函数修改一下来处理这个字符串:
def fry(word):
if word == 'you':
return "y'all"
现在让我们再次运行我们的测试:
=================================== FAILURES ===================================
___________________________________ test_fry ___________________________________
def test_fry():
assert fry('you') == "y'all" ①
> assert fry('You') == "Y'all" ②
E assert None == "Y'all" ③
E + where None = fry('You')
friar.py:49: AssertionError
=========================== 1 failed in 0.16 seconds ===========================
① 现在第一个测试通过了。
② 第二个测试失败,因为“You”是大写的。
③ 函数返回了 None,但应该返回“Y’all”。
让我们处理这些问题:
def fry(word):
if word == 'you':
return "y'all"
elif word == 'You':
return "Y'all"
如果你现在运行测试,你会看到前两个测试通过;然而,我肯定对这个解决方案不满意。代码中已经有很多重复的部分。我们能找到一个更优雅的方式来匹配“you”和“You”,并且仍然返回正确的大写答案吗?是的,我们可以!
def fry(word):
if word.lower() == 'you':
return word[0] + "'all"
更好的是,我们可以写一个正则表达式!在“you”和“You”之间有一个区别——即“y”或“Y”,我们可以使用字符类'[yY]'来表示(见图 15.12)。这将匹配小写版本:
>>> re.match('[yY]ou', 'you')
<re.Match object; span=(0, 3), match='you'>

图 15.12 我们可以使用字符类来匹配小写和大写的 Y。
它也会匹配大写版本(见图 15.13):
>>> re.match('[yY]ou', 'You')
<re.Match object; span=(0, 3), match='You'>

图 15.13 这个正则表达式将匹配“you”和“You”。
现在我们想在返回值中重用初始字符(无论是“y”还是“Y”)。我们可以通过将其放入括号中来捕获它。尝试使用这个想法重写你的fry()函数,并在通过前两个测试后再继续:
>>> match = re.match('([yY])ou', 'You')
>>> match.group(1) + "'all"
"Y'all"
下一步是处理像“fishing”这样的单词:
=================================== FAILURES ===================================
___________________________________ test_fry ___________________________________
def test_fry():
assert fry('you') == "y'all"
assert fry('You') == "Y'all"
> assert fry('fishing') == "fishin'" ①
E assert None == "fishin'" ②
E + where None = fry('fishing')
friar.py:52: AssertionError
=========================== 1 failed in 0.10 seconds ===========================
① 第三个测试失败了。
② fry('fishing')的返回值是 None,但预期的值是“fishin’”。
我们如何识别以“ing”结尾的单词?使用str.endswith()函数:
>>> 'fishing'.endswith('ing')
True
一个用于在字符串末尾查找“ing”的正则表达式会在表达式的末尾使用$(发音为“美元”)来锚定表达式,使其与字符串的末尾对齐(见图 15.14):
>>> re.search('ing$', 'fishing')
<re.Match object; span=(4, 7), match='ing'>

图 15.14 美元符号表示单词的结束。
如图 15.15 所示,我们可以使用字符串切片来获取最后一个索引-1之前的所有字符,然后添加一个撇号。
将以下内容添加到你的fry()函数中,看看你能通过多少测试:
if word.endswith('ing'):
return word[:-1] + "'"

图 15.15 使用字符串切片获取最后一个字母之前的所有字母并添加撇号。
或者你可以在正则表达式中使用一个分组来捕获单词的前半部分(见图 15.16):
>>> match = re.search('(.+)ing$', 'fishing')
>>> match.group(1) + "in'"
"fishin'"

图 15.16 使用捕获组以便我们可以访问匹配的字符串
你应该能够得到这样的结果:
=================================== FAILURES ===================================
___________________________________ test_fry ___________________________________
def test_fry():
assert fry('you') == "y'all"
assert fry('You') == "Y'all"
assert fry('fishing') == "fishin'"
assert fry('Aching') == "Achin'"
> assert fry('swing') == "swing" ①
E assert "swin'" == 'swing' ②
E - swin' ③
E ? ^
E + swing
E ? ^
friar.py:59: AssertionError
=========================== 1 failed in 0.10 seconds ===========================
① 这个测试失败了。
② fry('swing')的结果是“swin'”,但它应该是“swing”。
③ 有时测试结果能够突出显示失败的确切点。这里你被展示出有一个撇号(')而不是应该有的“g”。
我们需要一种方法来识别有两个音节的单词。我之前提到我们将使用一种启发式方法,该方法查找单词“ing”结尾部分之前的元音'[aeiouy]',如图 15.17 所示。另一个正则表达式可以做到这一点:
>>> match = re.search('(.+)ing$', 'fishing') ①
>>> first = match.group(1) ②
>>> re.search('[aeiouy]', first) ③
<re.Match object; span=(1, 2), match='i'> ④
① 正则表达式中的(.+)将匹配并捕获一个或多个紧跟“ing”字符的任何字符。re.search()的返回值将是一个 re.Match 对象,如果找到了模式,或者 None,表示没有找到。
② 我们知道这里将有一个匹配值,所以我们可以使用 match.group(1)来获取第一个捕获组,它将是“ing”之前立即的任何内容。在实际代码中,我们应该检查 match 是否不是 None,否则尝试在 None 上执行 group 方法会引发异常。
③ 我们可以在字符串的前部分使用 re.search()来查找元音。
④ 由于 re.search()的返回值是一个 re.Match 对象,我们知道第一部分有一个元音,所以这个单词看起来有两个音节。

图 15.17 寻找以“ing”结尾的双音节词的一种可能方法是查找单词前部分的元音。
如果单词匹配这个测试,则返回将最终“g”替换为撇号的单词;否则,返回未更改的单词。我建议你完成所有test_fry()测试后再继续。
15.1.6 使用 fry()函数
现在你的程序应该能够
-
从命令行或文件读取输入
-
逐行读取输入
-
将每一行拆分为单词和非单词
-
fry()任何单个单词
下一步是将fry()函数应用于所有类似单词的单元。我希望你能看到一种熟悉的模式出现——将函数应用于列表的所有元素!你可以使用一个for循环:
for line in args.text.splitlines(): ①
words = [] ②
for word in re.split(r'(\W+)', line.rstrip()): ③
words.append(fry(word)) ④
print(''.join(words)) ⑤
① 使用 str.splitlines()保留 args.text 中的换行结构。
② 创建一个 words 变量来存储转换后的单词。
③ 将每一行拆分为单词和非单词。
④ 将炸过的单词添加到 words 列表中。
⑤ 打印一个由连接的单词组成的新字符串。
(或者类似的东西)应该足够好,可以通过测试。一旦你有一个版本可以工作,看看你是否可以将其重写为列表推导式和map()。
好吧!现在是时候全力以赴写这个了。
15.2 解决方案
这让我想起了罗宾汉的伙伴弗莱尔·图克被诺丁汉郡长逮捕的时候。弗莱尔被判在油中煮沸,他回答说“你不能煮我,我是一个修士!”
#!/usr/bin/env python3
"""Kentucky Friar"""
import argparse
import os
import re
# --------------------------------------------------
def get_args():
"""get command-line arguments"""
parser = argparse.ArgumentParser(
description='Southern fry text',
formatter_class=argparse.ArgumentDefaultsHelpFormatter)
parser.add_argument('text', metavar='text', help='Input text or file')
args = parser.parse_args()
if os.path.isfile(args.text): ①
args.text = open(args.text).read()
return args
# --------------------------------------------------
def main():
"""Make a jazz noise here"""
args = get_args() ②
for line in args.text.splitlines(): ③
print(''.join(map(fry, re.split(r'(\W+)', line.rstrip())))) ④
# --------------------------------------------------
def fry(word): ⑤
"""Drop the `g` from `-ing` words, change `you` to `y'all`"""
ing_word = re.search('(.+)ing$', word) ⑥
you = re.match('([Yy])ou$', word) ⑦
if ing_word: ⑧
prefix = ing_word.group(1) ⑨
if re.search('[aeiouy]', prefix, re.IGNORECASE): ⑩
return prefix + "in'" ⑪
elif you: ⑫
return you.group(1) + "'all" ⑬
return word ⑭
# --------------------------------------------------
def test_fry(): ⑮
"""Test fry"""
assert fry('you') == "y'all"
assert fry('You') == "Y'all"
assert fry('fishing') == "fishin'"
assert fry('Aching') == "Achin'"
assert fry('swing') == "swing"
# --------------------------------------------------
if __name__ == '__main__':
main()
① 如果参数是文件,则用文件内容替换文本值。
② 获取命令行参数。此时文本值将是命令行文本或文件内容。
③ 使用 str.splitlines()方法来保留输入文本中的换行符。
④ 将通过正则表达式分割的文本片段通过 fry()函数映射,该函数将返回所需修改的单词。使用 str.join()将结果列表转换回字符串以打印。
⑤ 定义一个 fry()函数,该函数将处理一个单词。
⑥ 搜索锚定在单词末尾的“ing”。使用捕获组来记住“ing”之前的字符串部分。
⑦ 从单词的开头搜索“you”或“You”,并将[yY]的交替捕获在组中。
⑧ 检查“ing”的搜索是否返回了匹配项。
⑨ 获取前缀(“ing”之前的部分),它在组号 1 中。
⑩ 在前缀中执行对元音(加上“y”)的不区分大小写的搜索。如果没有找到任何内容,None 将被返回,这在布尔上下文中评估为 False。如果返回了匹配项,则非 None 值将评估为 True。
⑪ 将“in”附加到前缀上并将其返回给调用者。
⑫ 检查“you”的匹配是否成功。
⑬ 返回捕获的第一个字符加上“’all。”
⑭ 否则,返回未更改的单词。
⑮ fry()函数的测试
15.3 讨论
再次强调,get_args()中没有新的内容,所以我们直接转到将文本拆分为行。在几个先前的练习中,我使用了一种将输入文件读取到args.text值的技术。如果输入来自文件,则每行文本之间将有新行分隔。我建议使用for循环来处理str.splitlines()返回的每一行输入文本以保留输出中的换行符。我还建议你从一个for循环开始,以处理re.split()返回的每个单词单元:
for line in args.text.splitlines():
words = []
for word in re.split(r'(\W+)', line.rstrip()):
words.append(fry(word))
print(''.join(words))
如果我们将第二个for循环替换为列表推导式,那么这五行代码可以缩减为两行:
for line in args.text.splitlines():
print(''.join([fry(w) for w in re.split(r'(\W+)', line.rstrip())]))
或者,使用map()可能会稍微短一些:
for line in args.text.splitlines():
print(''.join(map(fry, re.split(r'(\W+)', line.rstrip()))))
另一种稍微提高可读性的方法是使用re.compile()函数来编译正则表达式。当你在for循环中使用re.split()函数时,正则表达式必须每次迭代重新编译。通过首先编译正则表达式,编译只发生一次,所以你的代码(可能只是稍微)更快。更重要的是,我认为这稍微容易阅读一些,而且当正则表达式更复杂时,好处更大:
splitter = re.compile(r'(\W+)')
for line in args.text.splitlines():
print(''.join(map(fry, splitter.split(line.rstrip()))))
15.3.1 手动编写 fry()函数
当然,你不需要编写一个fry()函数。但无论你如何编写解决方案,我希望你为它编写了测试!
以下版本与我之前在章节中提出的一些建议相当接近。这个版本没有使用正则表达式:
def fry(word):
"""Drop the `g` from `-ing` words, change `you` to `y'all`"""
if word.lower() == 'you': ①
return word[0] + "'all" ②
if word.endswith('ing'): ③
if any(map(lambda c: c.lower() in 'aeiouy', word[:-3])): ④
return word[:-1] + "'" ⑤
else:
return word ⑥
return word ⑦
① 将单词转换为小写并查看它是否匹配“you。”
② 如果是这样,则返回第一个字符(以保留大小写)加上“’all。”
③ 检查单词是否以“ing”结尾。
④ 检查是否确实有任何元音在“ing”后缀之前的单词中。
⑤ 如果是这样,则返回从最后一个索引加上的撇号之前的单词。
⑥ 否则,返回未改变的单词。
⑦ 如果单词既不是“ing”结尾也不是“you”结尾,则返回其未改变的形式。
让我们花点时间来欣赏 any() 函数,因为它是我最喜欢的之一。前面的代码使用 map() 来检查每个元音是否存在于“ing”结尾的 word 部分中:
>>> word = "cooking"
>>> list(map(lambda c: (c, c.lower() in 'aeiouy'), word[:-3]))
[('c', False), ('o', True), ('o', True), ('k', False)]
“cooking”的第一个字符是“c”,它不在元音字符串中。接下来的两个字符(“o”)在元音中存在,但“k”不在。
让我们将其简化为仅包含 True/False 值:
>>> list(map(lambda c: c.lower() in 'aeiouy', word[:-3]))
[False, True, True, False]
现在我们可以使用 any 来告诉我们是否有任何值是 True:
>>> any([False, True, True, False])
True
这与使用 or 连接值相同:
>>> False or True or True or False
True
all() 函数仅在所有值都为真时返回 True:
>>> all([False, True, True, False])
False
这与使用 and 连接这些值相同:
>>> False and True and True and False
False
如果一个元音出现在 word 的第一部分,我们就确定这是一个(可能是)双音节词,并且我们可以返回将最后的“g”替换为撇号的 word。否则,我们返回未改变的 word:
if any(map(lambda c: c.lower() in 'aeiouy', word[:-3])):
return word[:-1] + "'"
else:
return word
这种方法工作得很好,但它相当手动,因为我们必须编写相当多的代码来找到我们的模式。
15.3.2 使用正则表达式编写 fry() 函数
让我们回顾一下使用正则表达式版本的 fry() 函数:
def fry(word):
"""Drop the `g` from `-ing` words, change `you` to `y'all`"""
ing_word = re.search('(.+)ing$', word) ①
you = re.match('([Yy])ou$', word) ②
if ing_word: ③
prefix = ing_word.group(1) ④
if re.search('[aeiouy]', prefix, re.IGNORECASE): ⑤
return prefix + "in'" ⑥
elif you: ⑦
return you.group(1) + "'all" ⑧
return word ⑨
① 模式 '(.+)ing$' 匹配一个或多个任何字符后跟“ing”。美元符号将模式锚定到字符串的末尾,因此这是在寻找以“ing”结尾的字符串,但字符串不能仅仅是“ing”,因为它前面至少要有一些内容。括号捕获了“ing”之前的部分。
② re.match() 从给定单词的开头开始匹配,它正在寻找一个大小写字母“y”后跟“ou”,然后是字符串的结尾($)。
③ 如果 ing_word 是 None,这意味着它没有匹配成功。如果它不是 None(所以它是“真值”),这意味着它是一个我们可以使用的 re.Match 对象。
④ 前缀是我们在括号中包装的部分。因为它是最先的一组括号,我们可以使用 ing_word.group(1) 来获取它。
⑤ 我们使用 re.search() 在前缀中搜索任何元音(加上“y”),并且以不区分大小写的方式进行。记住,re.match() 会从单词的开头开始匹配,这不是我们想要的。
⑥ 返回前缀加上字符串“in”,以去除最后的“g”。
⑦ 如果对“you”模式的 re.match() 失败,则“you”将为 None。如果它不是 None,则表示匹配成功,“you”是一个 re.Match 对象。
⑧ 我们使用括号来捕获第一个字符,以保持大小写。也就是说,如果单词是“You”,我们希望返回“Y’all”。在这里,我们返回第一个组加上字符串“’all”。
⑨ 如果单词既没有匹配到双音节“ing”模式也没有匹配到单词“you”,则返回未改变的单词。
| 我可能已经使用正则表达式有 20 年了,所以这个版本对我来说似乎比手动版本简单得多。你可能感觉不同。如果你是正则表达式的新手,相信我,它们非常值得努力去学习。没有它们,我绝对无法完成我的大部分工作。 | ![]() |
|---|
15.4 进一步学习
-
你也可以将“your”替换为“y’all’s”。例如,“你的裤子在哪里?”可以变成“y’all’s 裤子在哪里?”
-
将“准备”或“准备中”改为“fixin’”,例如,“我正在准备吃饭”改为“我 fixin’要吃饭”。同时,将字符串“think”改为“reckon”,例如,“我觉得这个很有趣”改为“我 reckon 这个很有趣。”你也应该将“thinking”改为“reckoning”,然后它应该变成“reckonin’”。这意味着你需要进行两次遍历来更改,或者在一次遍历中找到“think”和“thinking”。
-
为另一种地区方言制作程序的版本。我在波士顿住了一段时间,真的很喜欢一直说“wicked”而不是“very”,就像“IT’S WICKED COLD OUT!”一样。
摘要
-
正则表达式可以用来在文本中查找模式。这些模式可能相当复杂,比如在单词字符分组之间非单词字符的分组。
-
re模块有一些非常实用的函数,如re.match(),用于在文本的开始处查找模式,re.search()用于在文本的任何位置查找模式,re.split()用于在模式上分割文本,以及re.compile()用于编译正则表达式,这样你可以重复使用它。 -
如果你使用捕获括号在
re.split()的模式上,捕获的分割模式将包含在返回值中。这允许你使用由模式描述的字符串来重建原始字符串。
16 破碎器:随机重新排列单词的中间部分
| 你的大脑是一个令人惊讶的硬件和软件的结合体。即使单词顺序混乱,你也能理解它,因为每个单词的首尾字母都相同。你的大脑不会逐个字母阅读,而是读取整个单词。混乱的单词会引导你向下,但你实际上并不是在尝试重新排列字母,对吧?这只是巧合! | ![]() |
|---|
在本章中,你将编写一个名为 scrambler.py 的程序,该程序将打乱作为参数提供的文本中的每个单词。打乱操作仅适用于有四个或更多字符的单词,并且它仅打乱单词中间的字母,保持首尾字符不变。程序应接受 -s 或 --seed 选项(一个默认为 None 的 int),并将其传递给 random.seed()。
它应该处理命令行上的文本:
$ ./scrambler.py --seed 1 "foobar bazquux"
faobor buuzaqx
或者来自文件的文本:
$ cat ../inputs/spiders.txt
Don't worry, spiders,
I keep house
casually.
$ ./scrambler.py ../inputs/spiders.txt
D'not wrory, sdireps,
I keep hsuoe
csalluay.
图 16.1 显示了一个字符串图,以帮助您思考。

图 16.1 我们的程序将从命令行或文件中获取输入文本,并将单词中的字母打乱。
在本章中,你将
-
使用正则表达式将文本分割成单词
-
使用
random.shuffle()函数来打乱一个list -
通过重新排列中间字母而保持首尾字母不变来创建单词的混乱版本
16.1 编写 scrambler.py
我建议你首先使用 new.py scrambler.py 在 16_scrambler 目录中创建程序。或者,你可以将模板/template.py 复制到 16_scrambler/scrambler.py。你可以参考之前的练习,比如第五章中的练习,来回忆如何处理可能为文本或文本文件的定位参数。
当不提供参数或使用 -h 或 --help 标志时,scrambler.py 应该显示一个用法说明:
$ ./scrambler.py -h
usage: scrambler.py [-h] [-s seed] text
Scramble the letters of words
positional arguments:
text Input text or file
optional arguments:
-h, --help show this help message and exit
-s seed, --seed seed Random seed (default: None)
一旦你的程序的使用说明与这个匹配,就按以下方式更改你的 main() 定义:
def main():
args = get_args()
print(args.text)
然后验证你的程序是否可以从命令行回显文本:
$ ./scrambler.py hello
hello
或者来自输入文件:
$ ./scrambler.py ../inputs/spiders.txt
Don't worry, spiders,
I keep house
casually.
16.1.1 将文本分解成行和单词
与第十五章一样,我们想要通过使用 str.splitlines() 保留输入文本的换行符:
for line in args.text.splitlines():
print(line)
如果我们在读取 spiders.txt 俳句,这是第一行:
>>> line = "Don't worry, spiders,"
我们需要将 line 分解成单词。在第六章中,我们使用了 str.split(),但这种方法会将标点符号粘附在我们的单词上--worry 和 spiders 都有逗号:
>>> line.split()
["Don't", 'worry,', 'spiders,']
在第十五章中,我们使用了 re.split() 函数和正则表达式 (\W+) 来根据一个或多个非单词字符分割文本。让我们试试:
>>> re.split('(\W+)', line)
['Don', "'", 't', ' ', 'worry', ', ', 'spiders', ',', '']
这不会起作用,因为它将 Don’t 分成了三部分:Don、' 和 t。
也许我们可以使用 \b 来在 单词边界 上断开。注意,我们不得不在第一个引号前加上 r'',即 r'\b',以表示它是一个“原始”字符串。
这仍然不起作用,因为\b认为撇号是一个单词边界,因此将缩合词分割:
>>> re.split(r'\b', "Don't worry, spiders,")
['', 'Don', "'", 't', ' ', 'worry', ', ', 'spiders', ',']
当我在网上搜索一个可以正确分割这个文本的正则表达式时,我在一个 Java 论坛上找到了以下模式。它完美地将单词从非单词中分离出来:1
>>> re.split("(a-zA-Z?)", "Don't worry, spiders,")
['', "Don't", ' ', 'worry', ', ', 'spiders', ',']
正则表达式的美妙之处在于它们是一种自己的语言——一种在从 Perl 到 Haskell 的许多其他语言中使用的语言。让我们深入探讨图 16.2 中显示的这个模式。

图 16.2 一个将找到包含撇号的单词的正则表达式
16.1.2 捕获、非捕获和可选组
在图 16.2 中,你可以看到组可以包含其他组。例如,这里有一个正则表达式可以捕获整个字符串“foobarbaz”以及子字符串“bar”:
>>> match = re.match('(foo(bar)baz)', 'foobarbaz')
捕获组按其左括号的顺序编号。由于第一个左括号从“f”开始,延伸到“z”,因此是组1:
>>> match.group(1)
'foobarbaz'
第二个左括号从“b”之前开始,延伸到“r”:
>>> match.group(2)
'bar'
我们也可以通过使用起始序列(?:来使一个组非捕获。如果我们在这个第二个组上使用这个序列,我们就不再捕获子字符串“bar”:
>>> match = re.match('(foo(?:bar)baz)', 'foobarbaz')
>>> match.groups()
('foobarbaz',)
非捕获组通常在主要目的是通过在闭括号后放置一个?来使其可选时使用。例如,我们可以使“bar”可选,然后匹配“foobarbaz”和
>>> re.match('(foo(?:bar)?baz)', 'foobarbaz')
<re.Match object; span=(0, 9), match='foobarbaz'>
以及“foobaz”:
>>> re.match('(foo(?:bar)?baz)', 'foobaz')
<re.Match object; span=(0, 6), match='foobaz'>
16.1.3 编译正则表达式
我在第十五章中提到了re.compile()函数,作为一次性编译正则表达式成本的方法。每次你使用类似re.search()或re.split()的东西时,正则表达式引擎都必须将你提供的str值解析成它可以理解和使用的格式。这个解析步骤必须在你每次调用函数时发生。当你编译正则表达式并将其分配给变量时,解析步骤是在你调用函数之前完成的,这提高了性能。
我特别喜欢使用re.compile()将正则表达式分配给一个有意义的变量名,并在我的代码的多个地方重用正则表达式。因为这个正则表达式相当长且复杂,我认为将它分配给名为splitter的变量可以使代码更易于阅读,这将帮助我记住它将如何被使用:
>>> splitter = re.compile("(a-zA-Z?)")
>>> splitter.split("Don't worry, spiders,")
['', "Don't", ' ', 'worry', ', ', 'spiders', ',']
16.1.4 打乱单词
| 现在我们有了处理文本的行和单词的方法,让我们考虑一下我们如何通过只从一个单词开始来打乱单词。你和我需要使用相同的算法来打乱单词,以便通过测试,所以这里有规则:
-
如果单词是三个字符或更短,则返回单词不变。
-
使用字符串切片来复制字符,但不包括第一个和最后一个字符。
-
使用
random.shuffle()方法来打乱中间的字母。 -
返回新的“单词”通过组合第一、中间和最后一部分。
![]() |
|---|
我建议你创建一个名为 scramble() 的函数来完成所有这些操作,并为其创建一个测试。你可以自由地将此添加到你的程序中:
def scramble(word): ①
"""Scramble a word"""
pass
def test_scramble():
"""Test scramble"""
state = random.getstate() ②
random.seed(1) ③
assert scramble("a") == "a" ④
assert scramble("ab") == "ab"
assert scramble("abc") == "abc"
assert scramble("abcd") == "acbd" ⑤
assert scramble("abcde") == "acbde" ⑥
assert scramble("abcdef") == "aecbdf"
assert scramble("abcde'f") == "abcd'ef"
random.setstate(state) ⑦
① pass 是一个无操作(no operation),所以这个函数实际上什么也没做。这只是一个占位符,这样我们就可以编写测试并验证函数是否失败。
② 我们将在下一行通过设置 random.seed() 实现的更改将是全局性的。我们希望在测试后恢复状态,因此在这里我们使用 random.getstate() 来获取随机模块的当前状态。
③ 将 random.seed() 设置为已知值以进行测试。
④ 对于三个字符或更少的单词应返回不变。
⑤ 这个单词看起来没有变化,但这只是因为种子值为 1 时,洗牌并没有最终改变中间字符。
⑥ 现在更明显,单词正在被洗牌。
⑦ 将状态恢复到之前的值。
在 scramble() 函数内部,我们将有一个像 “worry” 这样的单词。我们可以使用字符串切片来提取字符串的一部分。由于 Python 从 0 开始编号,我们使用 1 来表示 第二个 字符:
>>> word = 'worry'
>>> word[1]
'o'
任何字符串的最后一个索引是 -1:
>>> word[-1]
'y'
要获取切片,我们使用 list[start:stop] 语法。由于 stop 位置不包括在内,我们可以这样获取 middle:
>>> middle = word[1:-1]
>>> middle
'orr'
我们可以 import random 来获取访问 random.shuffle() 函数的权限。与 list.sort() 和 list.reverse() 方法一样,参数将就地洗牌,函数将返回 None。也就是说,你可能想编写如下代码:
>>> import random
>>> x = [1, 2, 3]
>>> shuffled = random.shuffle(x)
shuffled 的值是多少?它是类似 [3, 1, 2] 的东西,还是 None?
>>> type(shuffled)
<class 'NoneType'>
现在 shuffled 的值是 None,而 x 列表已经就地洗牌(见图 16.3):
>>> x
[2, 3, 1]

图 16.3 random.shuffle() 的返回值是 None,因此 shuffled 被赋值为 None。
如果你一直在跟随,结果是我们不能像这样对 middle 进行洗牌:
>>> random.shuffle(middle)
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "/Users/kyclark/anaconda3/lib/python3.7/random.py", line 278, in shuffle
x[i], x[j] = x[j], x[i]
TypeError: 'str' object does not support item assignment
middle 变量是一个 str:
>>> type(middle)
<class 'str'>
random.shuffle() 函数试图直接就地修改一个 str 值,但 Python 中的 str 值是不可变的。一种解决方案是将 middle 转换为 word 中字符的新 list:
>>> middle = list(word[1:-1])
>>> middle
['o', 'r', 'r']
那是我们能洗牌的东西:
>>> random.shuffle(middle)
>>> middle
['r', 'o', 'r']
然后就是创建一个新的字符串,包含原始的第一个字母、洗牌后的中间部分和最后一个字母。我将把这个留给你来完成。
使用 pytest 和 scrambler.py 来让 Pytest 执行 test_scramble() 函数,以查看其是否正确工作。每次修改程序后都要运行此命令。确保程序始终正确编译和运行。一次只做一项更改,然后保存程序并运行测试。
16.1.5 洗牌所有单词
正如之前的几个练习一样,我们现在将 scramble() 函数应用于所有单词。你能看到熟悉的模式吗?
splitter = re.compile("(a-zA-Z?)")
for line in args.text.splitlines():
for word in splitter.split(line):
# what goes here?
我们已经讨论了如何将函数应用于序列中的每个元素。你可能尝试一个 for 循环、列表推导或 map()。考虑一下你如何将文本拆分成单词,将它们传递给 scramble() 函数,然后将它们重新连接以重建文本。
注意,这种方法会将单词和非单词(每个单词之间的部分)都传递给 scramble() 函数。你不想修改非单词,所以你需要一种方法来检查参数看起来像单词。也许是一个正则表达式?
这应该足够你继续了。编写你的解决方案,并使用包含的测试来检查你的程序。
16.2 解决方案
对我来说,程序归结为正确分割单词,然后找出 scramble() 函数。然后就是应用该函数并重建文本的问题。
#!/usr/bin/env python3
"""Scramble the letters of words"""
import argparse
import os
import re
import random
# --------------------------------------------------
def get_args():
"""Get command-line arguments"""
parser = argparse.ArgumentParser(
description='Scramble the letters of words',
formatter_class=argparse.ArgumentDefaultsHelpFormatter)
parser.add_argument('text', metavar='text', help='Input text or file') ①
parser.add_argument('-s', ②
'--seed',
help='Random seed',
metavar='seed',
type=int,
default=None)
args = parser.parse_args() ③
if os.path.isfile(args.text): ④
args.text = open(args.text).read().rstrip()
return args ⑤
# --------------------------------------------------
def main():
"""Make a jazz noise here"""
args = get_args() ⑥
random.seed(args.seed) ⑦
splitter = re.compile("(a-zA-Z?)") ⑧
for line in args.text.splitlines(): ⑨
print(''.join(map(scramble, splitter.split(line)))) ⑩
# --------------------------------------------------
def scramble(word): ⑪
"""For words over 3 characters, shuffle the letters in the middle"""
if len(word) > 3 and re.match(r'\w+', word): ⑫
middle = list(word[1:-1]) ⑬
random.shuffle(middle) ⑭
word = word[0] + ''.join(middle) + word[-1] ⑮
return word ⑯
# --------------------------------------------------
def test_scramble(): ⑰
"""Test scramble"""
random.seed(1)
assert scramble("a") == "a"
assert scramble("ab") == "ab"
assert scramble("abc") == "abc"
assert scramble("abcd") == "acbd"
assert scramble("abcde") == "acbde"
assert scramble("abcdef") == "aecbdf"
assert scramble("abcde'f") == "abcd'ef"
random.seed(None)
# --------------------------------------------------
if __name__ == '__main__':
main()
① 文本参数可以是命令行上的纯文本或要读取的文件名。
② 种子选项是一个默认为 None 的 int。
③ 获取参数以便我们可以检查文本值。
④ 如果 args.text 指定了现有文件,则将 args.text 的值替换为打开和读取文件内容的值。
⑤ 将参数返回给调用者。
⑥ 获取命令行参数。
⑦ 使用 args.seed 设置 random.seed() 的值。如果 args.seed 是默认的 None,则与未设置种子相同。
⑧ 将编译后的正则表达式保存到变量中。
⑨ 使用 str.splitlines() 保留输入文本中的换行符。
⑩ 使用拆分器将行拆分为一个新列表,该列表将映射()函数输入到打乱()函数中。使用空字符串连接结果列表以创建一个新字符串进行打印。
⑪ 定义一个函数来打乱()单个单词。
⑫ 只有当单词包含单词字符时,才对四个或更多字符的单词进行打乱。
⑬ 将单词的第二个到倒数第二个字符复制到一个名为 middle 的新列表中。
⑭ 打乱中间字母。
⑮ 将单词设置为第一个字符,加上中间部分,加上最后一个字符。
⑯ 返回单词,如果它符合条件,单词可能已被更改。
⑰ 对 scramble() 函数的测试
16.3 讨论
get_args() 中没有新内容,所以我相信你会理解那段代码。如果你想回顾如何处理来自命令行或文件的 args.text,请参阅第五章。
16.3.1 处理文本
如本章前面所述,我经常将一个 编译过的 正则表达式分配给变量。这里我是用 splitter 做的:
splitter = re.compile("(a-zA-Z?)")
我喜欢使用 re.compile() 的另一个原因是,我觉得它可以使我的代码更易读。没有它,我不得不这样写:
for line in args.text.splitlines():
print(''.join(map(scramble, re.split("(a-zA-Z?)", line))))
这最终会创建一个 86 个字符宽的代码行,PEP 8 风格指南(www.python.org/dev/peps/pep-0008/)建议我们“将所有行限制在最多 79 个字符。”我发现以下版本更容易阅读:
splitter = re.compile("(a-zA-Z?)")
for line in args.text.splitlines():
print(''.join(map(scramble, splitter.split(line))))
你可能仍然觉得代码有些难以理解。图 16.4 展示了数据流:
-
首先 Python 会将字符串
"Don’tworry,spiders,"分割。 -
分隔符创建了一个新列表,包含匹配我们正则表达式的单词和非单词(中间的片段)。
-
map()函数将scramble()函数应用于列表的每个元素。 -
map()的结果是包含每个scramble()函数应用结果的新列表。 -
str.join()的结果是一个新的字符串,它是print()的参数。

图 16.4 map() 函数中数据流动的可视化
使用 for 循环来写这段代码可能看起来像这样:
for line in args.text.splitlines(): ①
words = [] ②
for word in splitter.split(line): ③
words.append(scramble(word)) ④
print(''.join(words)) ⑤
① 使用 str.splitlines() 保留原始行分隔符。
② 对于输入的每一行,创建一个空列表来存储打乱的单词。
③ 使用分隔符拆分行。
④ 将 scramble(word) 的结果添加到单词列表中。
⑤ 使用空字符串连接单词,并将结果传递给 print()。
因为目标是创建一个新的 list,所以最好用列表推导式来写:
for line in args.text.splitlines():
words = [scramble(word) for word in splitter.split(line)]
print(''.join(words))
或者,你可以完全相反的方向,用 map() 替换所有的 for 循环:
print('\n'.join(
map(lambda line: ''.join(map(scramble, splitter.split(line))),
args.text.splitlines())))
这个最后的解决方案让我想起了我曾经一起工作的一个程序员,他开玩笑地说,“如果写起来难,读起来也应该难!”如果你重新排列代码,这会变得稍微清晰一些。注意,Pylint 会抱怨关于分配 lambda 的问题,但我真的不同意这种批评:
scrambler = lambda line: ''.join(map(scramble, splitter.split(line)))
print('\n'.join(map(scrambler, args.text.splitlines())))
编写正确、经过测试且易于理解的代码,既是一门艺术,也是一种手艺。选择你认为最易读的版本。
16.3.2 打乱单词
让我们更仔细地看看我的 scramble() 函数。我以使其易于集成到 map() 中的方式编写了它:
def scramble(word):
"""For words over 3 characters, shuffle the letters in the middle"""
if len(word) > 3 and re.match(r'\w+', word): ①
middle = list(word[1:-1]) ②
random.shuffle(middle) ③
word = word[0] + ''.join(middle) + word[-1] ④
return word ⑤
① 检查给定的单词是否应该打乱。首先,它必须长于三个字符。其次,它必须包含一个或多个单词字符,因为函数将传递“单词”和“非单词”字符串。如果任一检查返回 False,我将返回未更改的单词。使用 r'\w+' 创建一个“原始”字符串。请注意,正则表达式在有或没有原始字符串的情况下都可以正常工作,但 Pylint 会抱怨一个“无效的转义字符”,除非它是原始字符串。
② 将单词的中间部分复制到一个名为 middle 的新列表中。
③ 在原地打乱中间部分。记住,这个函数返回 None。
④ 通过连接第一个字符、打乱的中间部分和最后一个字符来重构单词。
⑤ 返回单词,该单词可能已经被打乱,也可能没有。
16.4 进一步探索
-
编写一个程序版本,其中
scramble()函数将中间字母按字母顺序排序,而不是打乱它们。 -
编写一个版本,它反转每个单词而不是打乱它们。
-
编写一个程序来 解乱码 文本。为此,你需要有一个英语单词的字典,我已经将其作为输入提供在 inputs/words.txt.zip 中。你需要将乱码文本拆分成单词和非单词,然后比较每个“单词”与你的字典中的单词。我建议你首先将单词作为字母组合(即它们有相同的字母组成/频率)进行比较,然后使用首尾字母来正确定义未乱码的单词。
摘要
|
-
我们用来将文本拆分成单词的正则表达式相当复杂,但它也正好给了我们我们需要的东西。如果没有这个部分,编写程序将会显著更加困难。正则表达式虽然复杂且深奥,但它们是一种强大的黑魔法,可以使你的程序变得极其灵活和有用。
-
random.shuffle()函数接受一个list,并且会就地修改。 -
列表推导和
map()经常可以使代码更加紧凑,但过度使用可能会降低可读性。明智地选择。
![]() |
|---|
1 我想强调,我的工作中有很大一部分时间是在寻找答案,这些答案既来自我拥有的书籍,也来自互联网!
17 Mad Libs:使用正则表达式
| 当我还是个小男孩时,我们经常玩好几个小时的游戏《疯狂填空》。请注意,这是在电脑、电视、收音机或甚至纸张出现之前。不,别误会,我们确实有纸张。无论如何,重点是当时我们只有《疯狂填空》可以玩,我们非常喜欢它!现在你也必须玩! | ![]() |
|---|
在本章中,我们将编写一个名为 mad.py 的程序,该程序将读取作为位置参数给出的文件,并找到所有尖括号中的占位符,如 <verb> 或 <adjective>。对于每个占位符,我们将提示用户请求的词性,例如“给我一个动词”和“给我一个形容词”。(请注意,你需要使用正确的冠词,就像在第二章中一样。)然后,用户提供的每个值将替换文本中的占位符,所以如果用户说drive作为动词,那么文本中的 <verb> 将被替换为 drive。当所有占位符都被用户输入替换后,我们将打印出新的文本。
在 17_mad_libs/inputs 目录中有一个包含一些示例文件的目录,你可以使用这些文件,但我也鼓励你创建自己的。例如,这里是一个“狐狸”文本的版本:
$ cd 17_mad_libs
$ cat inputs/fox.txt
The quick <adjective> <noun> jumps <preposition> the lazy <noun>.
当程序以该文件作为输入运行时,它将询问每个占位符,然后打印出愚蠢的内容:
$ ./mad.py inputs/fox.txt
Give me an adjective: surly
Give me a noun: car
Give me a preposition: under
Give me a noun: bicycle
The quick surly car jumps under the lazy bicycle.
默认情况下,这是一个交互式程序,将使用 input() 提示来询问用户的答案,但为了测试目的,我们将有一个 -i 或 --inputs 选项,以便测试套件可以传递所有答案并绕过交互式 input() 调用:
$ ./mad.py inputs/fox.txt -i surly car under bicycle
The quick surly car jumps under the lazy bicycle.
在这个练习中,你将
-
学习使用
sys.exit()来停止你的程序并指示错误状态 -
学习使用正则表达式进行贪婪匹配
-
使用
re.findall()找到所有正则表达式的匹配项 -
使用
re.sub()将找到的模式替换为新文本 -
探索不使用正则表达式编写解决方案的方法
17.1 编写 mad.py
首先,在 17_mad_libs 目录中创建程序 mad.py,使用 new.py 或将模板/template.py 复制到 17_mad_libs/mad.py。你还应该定义位置参数 file 为可读文本文件,使用 type=argparse.FileType('rt')。-i 或 --inputs 选项应使用 nargs='*' 来定义零个或多个 str 值的列表。
之后,当没有提供参数或提供 -h 或 --help 标志时,你的程序应该能够生成用法说明:
$ ./mad.py -h
usage: mad.py [-h] [-i [input [input ...]]] FILE
Mad Libs
positional arguments:
FILE Input file
optional arguments:
-h, --help show this help message and exit
-i [input [input ...]], --inputs [input [input ...]]
Inputs (for testing) (default: None)
如果给定的 file 参数不存在,程序应该报错:
$ ./mad.py blargh
usage: mad.py [-h] [-i [str [str ...]]] FILE
mad.py: error: argument FILE: can't open 'blargh': \
[Errno 2] No such file or directory: 'blargh'
如果文件的文本中没有 <> 占位符,程序应该打印一条消息并退出,返回错误值(非 0)。请注意,这个错误不需要打印用法说明,因此你不需要像以前练习中那样使用 parser.error():
$ cat no_blanks.txt
This text has no placeholders.
$ ./mad.py no_blanks.txt
"no_blanks.txt" has no placeholders.
图 17.1 显示了一个字符串图,帮助你可视化程序。

图 17.1 Mad Libs 程序必须有一个输入文件。它也可能有一个用于替换的字符串列表,或者它会交互式地询问用户输入值。
17.1.1 使用正则表达式查找尖角部分
我们之前已经讨论过将整个文件读入内存可能存在的风险。由于我们将在程序中解析文本以查找所有 <...> 段落,我们确实需要一次性读取整个文件。我们可以通过链式调用适当的函数来实现这一点:
>>> text = open('inputs/fox.txt').read().rstrip()
>>> text
'The quick <adjective> <noun> jumps <preposition> the lazy <noun>.'
我们正在寻找角括号内的文本模式,所以让我们使用正则表达式。我们可以找到这样的字面量 < 字符(见图 17.2):
>>> import re
>>> re.search('<', text)
<re.Match object; span=(10, 11), match='<'>

图 17.2 匹配一个字面量的小于号
现在我们来找到那个括号的配对。正则表达式中的 . 表示“任何东西”,我们可以在它后面添加一个 + 来表示“一个或多个”。我将捕获这个匹配,以便更容易看到:
>>> match = re.search('(<.+>)', text)
>>> match.group(1)
'<adjective> <noun> jumps <preposition> the lazy <noun>'
如图 17.3 所示,它匹配到了字符串的末尾,而不是在第一个可用的 > 处停止。当你使用 * 或 + 来表示零、一或多个时,正则表达式引擎通常会在“或更多”部分上“贪婪”。模式匹配超出了我们想要的范围,但从技术上讲,它确实匹配了我们描述的内容。记住,. 表示 任何东西,而右角括号(或大于号)也是“任何东西”。它尽可能多地匹配字符,直到找到最后一个右角括号停止,这就是为什么这个模式被称为“贪婪”。

图 17.3 匹配一个或多个的加号是一个贪婪匹配,匹配尽可能多的字符。
我们可以通过将 + 改为 +? 来使正则表达式“非贪婪”,这样它就会匹配可能的最短字符串(见图 17.4):
>>> re.search('<.+?>', text)
<re.Match object; span=(10, 21), match='<adjective>'>

图 17.4 加号后面的问号使得正则表达式在可能的最短匹配处停止。
而不是使用 . 来表示“任何东西”,更准确的说法是我们想要匹配一个或多个不是任意一个角括号的“任何东西”。字符类 [<>] 会匹配任一括号。我们可以通过将一个撇号(^)作为类的第一个字符来取反(或补全)这个类,所以我们有 [^<>](见图 17.5)。这将匹配任何不是左括号或右括号的字符:
>>> re.search('<[^<>]+>', text)
<re.Match object; span=(10, 21), match='<adjective>'>

图 17.5 用于匹配除角括号之外任何内容的取反字符类
为什么在取反的类中同时使用两个括号?难道右括号就足够了吗?嗯,我是为了防止不平衡的括号。只有右括号的话,它会匹配这段文本(见图 17.6):
>>> re.search('<[^>]+>', 'foo <<bar> baz')
<re.Match object; span=(4, 10), match='<<bar>'>

图 17.6 这个正则表达式留下了匹配不平衡括号的可能性。
但是,在取反的类中同时使用两个括号,它找到了正确的、平衡的配对(见图 17.7):
>>> re.search('<[^<>]+>', 'foo <<bar> baz')
<re.Match object; span=(5, 10), match='<bar>'>

图 17.7 这个正则表达式找到了正确平衡的括号和包含的文本。
我们将添加两组括号()。第一组将捕获整个占位符模式(见图 17.8):
>>> match = re.search('(<([^<>]+)>)', text)
>>> match.groups()
('<adjective>', 'adjective')

图 17.8 外层括号捕获了方括号和文本。
另一组是为了捕获<>内的字符串(见图 17.9):

图 17.9 内层括号只捕获文本。
有一个非常方便的函数叫做re.findall(),它将返回所有匹配的文本组作为一个包含tuple值的list:
>>> from pprint import pprint
>>> matches = re.findall('(<([^<>]+)>)', text)
>>> pprint(matches)
[('<adjective>', 'adjective'),
('<noun>', 'noun'),
('<preposition>', 'preposition'),
('<noun>', 'noun')]
注意,捕获组是按照它们打开括号的顺序返回的,所以整个占位符是每个tuple的第一个成员,包含的文本是第二个。我们可以遍历这个list,将每个tuple解包到变量中(见图 17.10):
>>> for placeholder, name in matches:
... print(f'Give me {name}')
...
Give me adjective
Give me noun
Give me preposition
Give me noun

图 17.10 由于列表包含 2 元组,我们可以在for循环中将它们解包成两个变量。
你应该插入正确的冠词(“a”或“an”,就像你在第二章中做的那样)作为input()的提示。
17.1.2 停止和打印错误
如果我们在文本中找不到占位符,我们需要打印一条错误信息。将错误信息打印到STDERR(标准错误)是很常见的,而print()函数允许我们指定一个file参数。我们将使用sys.stderr,就像我们在第九章中做的那样。为了做到这一点,我们需要导入该模块:
import sys
你可能还记得,sys.stderr就像一个已经打开的文件句柄,所以没有必要调用open():
print('This is an error!', file=sys.stderr)
如果确实没有占位符,我们应该以错误值退出程序,以向操作系统指示程序未能正常运行。程序的正常退出值是0,表示“零错误”,因此我们需要退出时使用一个不是0的int值。我总是使用1:
sys.exit(1)
其中一个测试检查你的程序是否可以检测到缺失的占位符,以及你的程序是否正确退出。
你也可以使用字符串值调用sys.exit(),在这种情况下,字符串将被打印到sys.stderr,并且程序将以值1退出:
sys.exit('This will kill your program and print an error message!')
17.1.3 获取值
对于文本中的每一个词性部分,我们需要一个值,这个值要么来自--inputs参数,要么直接来自用户。如果我们没有--inputs,我们可以使用input()函数从用户那里获取答案。
input()函数接受一个str值作为提示:
>>> value = input('Give me an adjective: ')
Give me an adjective: blue
它返回用户在按下回车键之前输入的str值:
>>> value
'blue'
然而,如果我们有输入值,我们可以使用这些值而不必使用input()函数。我之所以让你处理--inputs选项,是为了测试目的。你可以安全地假设你将始终有与占位符相同数量的输入(见图 17.11)。

图 17.11 如果从命令行提供输入,它们将与文本中的占位符匹配。
例如,你可能将以下内容作为 fox.txt 示例程序的--inputs选项:
>>> inputs = ['surly', 'car', 'under', 'bicycle']
你需要从inputs中移除并返回第一个字符串,“surly”,你需要使用list.pop()方法,但默认情况下它会移除最后一个元素:
>>> inputs.pop()
'bicycle'
list.pop()方法可以接受一个可选参数,以指示你想要移除的元素的索引。你能想出如何让它工作吗?如果你卡住了,请务必阅读help(list.pop)。
17.1.4 替换文本
当你有每个占位符的值时,你需要将它们替换到文本中。我建议你查看re.sub()(替换)函数,它将替换与给定正则表达式匹配的任何文本为其他值。我确实建议你阅读help(re.sub):
sub(pattern, repl, string, count=0, flags=0)
Return the string obtained by replacing the leftmost
non-overlapping occurrences of the pattern in string by the
replacement repl.
我不想泄露结局,但你将需要使用类似于前面的模式来将每个<placeholder>替换为每个value。
注意,使用re.sub()函数解决问题不是强制性的。实际上,我向你挑战,尝试编写一个完全不使用re模块的解决方案。现在去编写程序,并使用测试来指导你!
17.2 解决方案
你是否对正则表达式越来越熟悉了?我知道它们很复杂,但真正理解它们将比你想象的更有帮助。
#!/usr/bin/env python3
"""Mad Libs"""
import argparse
import re
import sys
# --------------------------------------------------
def get_args():
"""Get command-line arguments"""
parser = argparse.ArgumentParser(
description='Mad Libs',
formatter_class=argparse.ArgumentDefaultsHelpFormatter)
parser.add_argument('file', ①
metavar='FILE',
type=argparse.FileType('rt'),
help='Input file')
parser.add_argument('-i', ②
'--inputs',
help='Inputs (for testing)',
metavar='input',
type=str,
nargs='*')
return parser.parse_args()
# --------------------------------------------------
def main():
"""Make a jazz noise here"""
args = get_args()
inputs = args.inputs
text = args.file.read().rstrip() ③
blanks = re.findall('(<([^<>]+)>)', text) ④
if not blanks: ⑤
sys.exit(f'"{args.file.name}" has no placeholders.') ⑥
tmpl = 'Give me {} {}: ' ⑦
for placeholder, pos in blanks: ⑧
article = 'an' if pos.lower()[0] in 'aeiou' else 'a' ⑨
answer = inputs.pop(0) if inputs else input(tmpl.format(article, pos)) ⑩
text = re.sub(placeholder, answer, text, count=1) ⑪
print(text) ⑫
# --------------------------------------------------
if __name__ == '__main__':
main()
① 文件参数应该是一个可读的文本文件。
② --inputs 选项可以有零个或多个字符串。
③ 打开并读取输入文件,去除尾随换行符。
④ 使用正则表达式找到所有匹配左尖括号、后跟一个或多个不是左尖括号或右尖括号的任何字符、然后是右尖括号的匹配项。使用两个捕获组来捕获整个表达式和括号内的文本。
⑤ 检查是否存在占位符。
⑥ 向 STDERR 打印消息,指出指定的文件不包含占位符,并以非零状态退出程序,向操作系统指示错误。
⑦ 创建一个字符串模板,用于提示用户输入。
⑧ 遍历空格,将每个元组解包到变量中。
⑨ 根据名词短语的第一个字母选择正确的冠词:“an”用于以元音字母开头的那些,否则用“a”。
⑩ 如果有输入,移除第一个用于答案;否则,使用 input()提示用户输入一个值。
⑪ 将当前占位符文本替换为用户的答案。使用 count=1 确保只替换第一个值。覆盖现有的文本值,以便在循环结束时所有占位符都将被替换。
⑫ 将结果文本打印到 STDOUT。
17.3 讨论
我们首先定义好我们的参数。输入 file 应该使用 type=argparse.FileType('rt') 声明,这样 argparse 将验证该参数是否为可读文本文件。--inputs 是可选的,因此我们可以使用 nargs='*' 来表示零个或多个字符串。如果没有提供输入,默认值将是 None,所以请确保你不要假设它是一个 list 并尝试在 None 上执行列表操作。
17.3.1 使用正则表达式进行替换
在使用 re.sub() 时,有一个微小的错误在等待着你。假设我们已将第一个 <adjective> 替换为“blue”,所以我们有这个:
>>> text = 'The quick blue <noun> jumps <preposition> the lazy <noun>.'
现在我们想将 <noun> 替换为“dog”,所以我们尝试这样做:
>>> text = re.sub('<noun>', 'dog', text)
让我们检查一下 text 的值:
>>> text
'The quick blue dog jumps <preposition> the lazy dog.'
由于字符串 <noun> 有两个实例,它们都被替换成了“dog”,如图 17.12 所示。

图 17.12 re.sub() 函数将替换所有匹配项。
我们必须使用 count=1 来确保只更改第一个出现(见图 17.13):
>>> text = 'The quick blue <noun> jumps <preposition> the lazy <noun>.'
>>> text = re.sub('<noun>', 'dog', text, count=1)
>>> text
'The quick blue dog jumps <preposition> the lazy <noun>.'

图 17.13 使用 count 选项限制 re.sub() 的替换次数。
现在我们可以继续替换其他占位符。
17.3.2 不使用正则表达式查找占位符
我相信本章前面关于正则表达式解决方案的解释是充分的。我发现那个解决方案相当优雅,但当然可以不使用正则表达式来解决这个问题。下面是我可能手动解决它的方法。
首先,我需要一个方法来搜索文本中的 <...>。我开始编写一个测试,帮助我想象我可能给我的函数提供什么,以及对于好值和坏值我可能期望得到什么结果。
当模式缺失时,我决定返回 None,当模式存在时,返回 (start, stop) 索引的元组:
def test_find_brackets():
"""Test for finding angle brackets"""
assert find_brackets('') is None ①
assert find_brackets('<>') is None ②
assert find_brackets('<x>') == (0, 2) ③
assert find_brackets('foo <bar> baz') == (4, 8) ④
① 没有文本,因此它应该返回 None。
② 有角度括号,但它们内部没有任何文本,因此应该返回 None。
③ 模式应该位于字符串的开头。
④ 模式应该位于字符串的更深处。
现在我需要编写满足那个测试的代码。这是我写的:
def find_brackets(text):
"""Find angle brackets"""
start = text.index('<') if '<' in text else -1 ①
stop = text.index('>') if start >= 0 and '>' in text[start + 2:] else -1 ②
return (start, stop) if start >= 0 and stop >= 0 else None ③
① 如果在文本中找到了左括号,则找到其索引。
② 如果在左括号后两个位置找到了右括号,则找到其索引。
③ 如果找到了两个括号,则返回它们的起始和结束位置;否则,返回 None。
这个函数工作得足够好,可以通过给定的测试,但它并不完全正确,因为它将返回一个包含不平衡括号的区域:
>>> text = 'foo <<bar> baz'
>>> find_brackets(text)
[4, 9]
>>> text[4:10]
'<<bar>'
这可能看起来不太可能,但我选择角度括号让你想到像 <head> 和 <img> 这样的 HTML 标签。HTML 以其不正确而闻名,可能是因为它是手工生成的,有人弄错了标签,或者是因为生成 HTML 的工具有错误。关键是大多数网络浏览器在解析 HTML 时必须相当宽松,看到像 <<head> 这样的不正确标签而不是正确的 <head> 并不令人意外。
另一方面,正则表达式版本专门通过使用类 [^<>] 来定义不能包含任何尖括号的文本,以防止匹配不平衡的括号。我可以编写一个 find_brackets() 的版本,它只找到平衡的括号,但老实说,这并不值得。这个函数指出,正则表达式引擎的一个优点是它可以找到一个部分匹配(第一个左括号),看到它无法完成匹配,然后重新开始(在下一个左括号处)。自己编写这将是乏味的,而且坦白说,并不那么有趣。
尽管如此,这个函数适用于所有给定的测试输入。注意,它一次只返回一组括号。在我找到每一组括号之后,我将更改文本,这可能会改变任何后续括号的开头和结尾位置,因此最好一次处理一组。
这里是如何将其集成到 main() 函数中的:
def main():
args = get_args()
inputs = args.inputs
text = args.file.read().rstrip()
had_placeholders = False ①
tmpl = 'Give me {} {}: ' ②
while True: ③
brackets = find_brackets(text) ④
if not brackets: ⑤
break ⑥
start, stop = brackets ⑦
placeholder = text[start:stop + 1] ⑧
pos = placeholder[1:-1] ⑨
article = 'an' if pos.lower()[0] in 'aeiou' else 'a' ⑩
answer = inputs.pop(0) if inputs else input(tmpl.format(article, pos)) ⑪
text = text[0:start] + answer + text[stop + 1:] ⑫
had_placeholders = True ⑬
if had_placeholders: ⑭
print(text) ⑮
else:
sys.exit(f'"{args.file.name}" has no placeholders.') ⑯
① 创建一个变量来跟踪我们是否找到了占位符。假设最坏的情况。
② 创建一个用于 input() 提示的模板。
③ 开始一个无限循环。while 循环将一直继续,只要它有一个“真值”,True 总是会是。
④ 使用当前文本的值调用 find_brackets() 函数。
⑤ 如果返回值为 None,这将是一个“假值”。
⑥ 如果没有找到括号,则跳出 while 循环。
⑦ 现在我们知道我们已经找到了一些括号,解包它们的开始和结束值。
⑧ 使用带有开始和结束值的字符串切片以及将停止值加 1(包括该索引)来找到整个 <placeholder> 值。
⑨ “词性”是里面的部分,所以这将从 “
⑩ 为词性选择正确的冠词。
⑪ 从输入或从 input() 调用中获取答案。
⑫ 使用字符串切片覆盖文本,直到开始,然后是答案,最后是停止后的剩余文本。
⑬ 注意我们已经看到了一个占位符。
⑭ 当找不到更多占位符时,循环退出。现在我们已经完成了,检查我们是否看到了占位符。
⑮ 如果我们看到了占位符,则打印带有替换的新文本值。
⑯ 如果我们从未看到占位符,则向 STDERR 打印错误消息并退出,非零值表示错误。
17.4 进一步学习
-
扩展你的代码以找到从互联网下载的网页中所有
<...>和</...>包围的 HTML 标签。 -
编写一个程序,该程序将查找括号
()、方括号[]和花括号{}的不平衡的开放/关闭对。创建具有平衡和不平衡文本的输入文件,并编写测试以验证程序能够识别两者。
摘要
-
正则表达式几乎像是函数,我们 描述 我们想要找到的模式。正则表达式引擎将执行尝试找到模式的工作,处理不匹配并重新开始以在文本中找到模式。
-
带有
*或+的正则表达式模式是“贪婪”的,这意味着它们尽可能多地匹配字符。在它们后面添加一个?使它们变为“非贪婪”的,这样它们就尽可能少地匹配字符。 -
re.findall()函数将返回一个包含所有匹配字符串或给定模式的捕获组的list。 -
re.sub()函数将用新文本替换某些文本中的模式。 -
您可以使用
sys.exit()函数在任何时候停止您的程序。如果没有提供任何参数,默认退出值将是0,表示没有错误。如果您想表示发生了错误,可以使用任何非零值,例如1。或者使用字符串值,它将被打印到STDERR,并且将自动使用非零退出值。
18 素数编码:使用 ASCII 值对文本进行数值编码
素数编码是一种通过将每个字符的数值相加来给单词分配一个数字的系统 (en.wikipedia.org/wiki/Gematria)。在标准编码 (Mispar hechrechi) 中,希伯来字母表中的每个字符都被分配一个从 1 到 400 的数值,但还有十几种其他方法用于计算字母的数值。为了编码一个单词,这些值会被相加。基督教圣经的启示录 13:18 说:“让有洞察力的人计算野兽的数目,因为这是人的数目,它的数目是 666。”一些学者认为这个数字是从代表尼禄·凯撒的名字和头衔的字符编码中得出的,并且它被用作一种不提名字就写关于罗马皇帝的方式。 |
![]() |
|---|
我们将编写一个名为 gematria.py 的程序,该程序将通过对每个单词中的字符赋予相似的数值来对给定文本中的每个单词进行数值编码。我们可以以多种方式分配这些值。例如,我们可以从给“a”分配值 1,“b”分配值 2 等等开始。相反,我们将使用 ASCII 表 (en.wikipedia.org/wiki/ASCII) 来为英语字母表中的字符推导出数值。对于非英语字符,我们可以考虑使用 Unicode 值,但这个练习将坚持使用 ASCII 字母。
输入文本可以输入到命令行中:
$ ./gematria.py 'foo bar baz'
324 309 317
或者它也可以在一个文件中:
$ ./gematria.py ../inputs/fox.txt
289 541 552 333 559 444 321 448 314
图 18.1 显示了一个程序应如何工作的字符串图。

图 18.1 素数编码程序将接受输入文本并为每个单词生成一个数值编码。
在这个练习中,你将
-
了解
ord()和chr()函数 -
探索字符在 ASCII 表中的组织方式
-
理解正则表达式中使用的字符范围
-
使用
re.sub()函数 -
学习如何在不使用
lambda的情况下编写map() -
使用
sum()函数并看看它与使用reduce()的关系 -
学习如何执行不区分大小写的字符串排序
18.1 编写 gematria.py
我总是会推荐你以某种方式开始你的程序,以避免需要输入所有样板文本。要么将模板/template.py 复制到 18_gematria/gematria.py,要么在 18_gematria 目录中使用 new.py gematria.py 创建一个起点。
修改程序,直到在没有参数或 -h 或 --help 标志时打印以下用法说明:
$ ./gematria.py -h
usage: gematria.py [-h] text
Gematria
positional arguments:
text Input text or file
optional arguments:
-h, --help show this help message and exit
如前所述,输入可以来自命令行或文件。我建议你复制第五章中使用的代码来处理这个问题,然后按照以下方式修改你的 main() 函数:
def main():
args = get_args()
print(args.text)
验证你的程序是否可以从命令行打印文本,
$ ./gematria.py 'Death smiles at us all, but all a man can do is smile back.'
Death smiles at us all, but all a man can do is smile back.
或者从文件中:
$ ./gematria.py ../inputs/spiders.txt
Don't worry, spiders,
I keep house
casually.
18.1.1 清理单词
让我们讨论单个单词的编码方式,因为它将影响我们在下一节中如何分割文本。为了确保我们只处理 ASCII 值,让我们删除任何不是大写或小写英文字母或阿拉伯数字 0-9 的东西。我们可以使用正则表达式 [A-Za-z0-9] 来定义这个字符类。
我们可以使用第十七章中使用的re.findall()函数来找到word中所有匹配这个类的字符。例如,我们应该在单词“Don’t”中找到除了撇号之外的所有内容(见图 18.2):
>>> re.findall('[A-Za-z0-9]', "Don't")
['D', 'o', 'n', 't']

图 18.2 这个字符类只匹配字母数字值。
如果我们在类内部将撇号(^)作为第一个字符,例如 [^A-Za-z0-9],我们会发现任何不是这些字符的东西。现在我们预计只会匹配到撇号(见图 18.3):
>>> import re
>>> re.findall('[^A-Za-z0-9]', "Don't")
["'"]

图 18.3 破折号将找到字符类的补集,因此任何非字母数字字符。
我们可以使用re.sub()函数替换第二个类中的任何字符为空字符串。正如你在第十七章中学到的,这将替换模式的所有出现,除非我们使用count=n选项:
>>> word = re.sub('[^A-Za-z0-9]', '', "Don't")
>>> word
'Dont'
我们将想要使用这个操作来清理我们将要编码的每个单词,如图 18.4 所示。

图 18.4 re.sub()函数将替换与模式匹配的任何文本为另一个值。
18.1.2 序列字符值和范围
我们将通过将每个字符转换为数值并将它们相加来编码字符串“Dont”,所以让我们首先弄清楚如何编码单个字符。
Python 有一个名为ord()的函数,它将字符转换为它的“序数值”。对于我们所使用的所有字母数字值,这将等于字符在 American Standard Code for Information Interchange(ASCII,发音为“as-kee”)表中的位置:
>>> ord('D')
68
>>> ord('o')
111
chr()函数与ord()函数相反,它将数字转换为字符:
>>> chr(68)
'D'
>>> chr(111)
'o'
下面的 ASCII 表。为了简单起见,我将索引 31 之前的值显示为“NA”(不可用)。
$ ./asciitbl.py
0 NA 16 NA 32 SPACE 48 0 64 @ 80 P 96 ` 112 p
1 NA 17 NA 33 ! 49 1 65 A 81 Q 97 a 113 q
2 NA 18 NA 34 " 50 2 66 B 82 R 98 b 114 r
3 NA 19 NA 35 # 51 3 67 C 83 S 99 c 115 s
4 NA 20 NA 36 $ 52 4 68 D 84 T 100 d 116 t
5 NA 21 NA 37 % 53 5 69 E 85 U 101 e 117 u
6 NA 22 NA 38 & 54 6 70 F 86 V 102 f 118 v
7 NA 23 NA 39 ' 55 7 71 G 87 W 103 g 119 w
8 NA 24 NA 40 ( 56 8 72 H 88 X 104 h 120 x
9 NA 25 NA 41 ) 57 9 73 I 89 Y 105 i 121 y
10 NA 26 NA 42 * 58 : 74 J 90 Z 106 j 122 z
11 NA 27 NA 43 + 59 ; 75 K 91 [ 107 k 123 {
12 NA 28 NA 44 , 60 < 76 L 92 \ 108 l 124 |
13 NA 29 NA 45 - 61 = 77 M 93 ] 109 m 125 }
14 NA 30 NA 46 . 62 > 78 N 94 ^ 110 n 126 ~
15 NA 31 NA 47 / 63 ? 79 O 95 _ 111 o 127 DEL
注意,我已经将 asciitbl.py 程序包含在源代码仓库的 18_gematria 目录中。
我们可以使用一个for循环遍历字符串中的所有字符:
>>> word = "Dont"
>>> for char in word:
... print(char, ord(char))
...
D 68
o 111
n 110
t 116
注意,大写和小写字母有不同的ord()值。这是有道理的,因为它们是两个不同的字母:
>>> ord('D')
68
>>> ord('d')
100
我们可以通过找到它们的ord()值来迭代从“a”到“z”的值:
>>> [chr(n) for n in range(ord('a'), ord('z') + 1)]
['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm',
'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z']
如前一个 ASCII 表所示,字母“a”到“z”是连续的。对于“A”到“Z”和“0”到“9”也是如此,这就是为什么我们可以使用 [A-Za-z0-9] 作为正则表达式。
注意,大写字母的序数值比小写字母版本低,这就是为什么你不能使用范围 [a-Z]。在 REPL 中尝试这个,并注意你得到的错误:
>>> re.findall('[a-Z]', word)
如果我在 REPL 中执行前面的函数,我看到的错误信息的最后一行是:
re.error: bad character range a-Z at position 1
然而,你可以使用范围 [A-z]:
>>> re.findall('[A-z]', word)
['D', 'o', 'n', 't']
但请注意,“Z” 和 “a” 并不连续:
>>> ord('Z'), ord('a')
(90, 97)
两者之间还有其他字符:
>>> [chr(n) for n in range(ord('Z') + 1, ord('a'))]
['[', '\\', ']', '^', '_', '`']
如果我们尝试将这个范围应用于所有可打印字符,你会发现它匹配了不是字母的字符:
>>> import string
>>> re.findall('[A-z]', string.printable)
['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm',
'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z',
'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M',
'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z',
'[', '\\', ']', '^', '_', '`']
这就是为什么指定我们想要的字符为三个单独的范围 [A-Za-z0-9] 是最安全的,你有时可能会听到它被读作“从 A 到 Z,从 a 到 z,从 0 到 9”,因为它假设你理解有两个“从 a 到 z”的范围是不同的,根据它们的案例。 |
![]() |
|---|
18.1.3 求和与归约
让我们不断提醒自己我们的目标是什么:将单词中的所有字符转换,然后对这些值求和。有一个方便的 Python 函数叫做 sum(),它将添加一个数字列表:
>>> sum([1, 2, 3])
6
我们可以通过对每个字母调用 ord() 并将结果作为 list 传递给 sum() 来手动编码字符串 “Dont”:
>>> sum([ord('D'), ord('o'), ord('n'), ord('t')])
405
问题是如何将函数 ord() 应用到 str 中的所有字符上,并将一个 list 传递给 sum()。你现在已经看到这种模式很多次了。你首先会想到什么工具?我们总是可以从我们手头的 for 循环开始:
>>> word = 'Dont'
>>> vals = []
>>> for char in word:
... vals.append(ord(char))
...
>>> vals
[68, 111, 110, 116]
你能看出如何使用列表推导式将其简化为单行吗?
>>> vals = [ord(char) for char in word]
>>> vals
[68, 111, 110, 116]
从那里,我们可以转向一个 map():
>>> vals = map(lambda char: ord(char), word)
>>> list(vals)
[68, 111, 110, 116]
在这里,我想展示 map() 版本不需要 lambda 声明,因为 ord() 函数期望一个单一值,这正是它将从 map() 中获得的东西。这是一个更优雅的方式来写它:
>>> vals = map(ord, word)
>>> list(vals)
[68, 111, 110, 116]
在我看来,这是一段非常漂亮的代码!
现在,我们可以 sum() 它以得到 word 的最终值:
>>> sum(map(ord, word))
405
这是正确的:
>>> sum([68, 111, 110, 116])
405
18.1.4 使用 functools.reduce
如果 Python 有 sum() 函数,你可能会怀疑它也有一个 product() 函数来将数字列表相乘。然而,这并不是一个内置函数,但它确实代表了一个将值列表归约为一个值的常见想法。
functools 模块中的 reduce() 函数提供了一个通用的归约列表的方法。让我们查阅文档以了解如何使用它:
>>> from functools import reduce
>>> help(reduce)
reduce(...)
reduce(function, sequence[, initial]) -> value
Apply a function of two arguments cumulatively to the items of a sequence,
from left to right, so as to reduce the sequence to a single value.
For example, reduce(lambda x, y: x+y, [1, 2, 3, 4, 5]) calculates
((((1+2)+3)+4)+5). If initial is present, it is placed before the items
of the sequence in the calculation, and serves as a default when the
sequence is empty.
这是一个需要另一个函数作为第一个参数的高阶函数,就像 map() 和 filter() 一样。文档告诉我们如何编写我们自己的 sum() 函数:
>>> reduce(lambda x, y: x + y, [1, 2, 3, 4, 5])
15
如果我们将 + 运算符改为 *,我们得到一个乘积:
>>> reduce(lambda x, y: x * y, [1, 2, 3, 4, 5])
120
这是你可能写这个函数的方式:
def product(vals):
return reduce(lambda x, y: x * y, vals)
现在你可以调用它:
>>> product(range(1,6))
120
我们可以不用写自己的 lambda,而可以使用任何期望两个参数的函数。operator.mul 函数符合这个要求:
>>> import operator
>>> help(operator.mul)
mul(a, b, /)
Same as a * b.
因此,这将更容易写:
def product(vals):
return reduce(operator.mul, vals)
幸运的是,math 模块也包含一个 prod() 函数你可以使用:
>>> import math
>>> math.prod(range(1,6))
120
如果你仔细想想,str.join() 方法也将字符串列表归约为一个单一的 str 值。这是我们可以这样编写的:
def join(sep, vals):
return reduce(lambda x, y: x + sep + y, vals)
我非常偏好调用这个 join 而不是 str.join() 函数的语法:
>>> join(', ', ['Hey', 'Nonny', 'Nonny'])
'Hey, Nonny, Nonny'
当你有一个 list 的值需要组合成单个值时,考虑使用 reduce() 函数。
18.1.5 编码单词
只为了计算字符的序数值之和就做了很多工作,但这不是很有趣吗?不过,让我们回到正题。
我们可以创建一个函数来封装将单词转换为从字符序数值之和派生的数值的想法。我称之为 word2num(),以下是我的测试:
def test_word2num():
"""Test word2num"""
assert word2num("a") == "97"
assert word2num("abc") == "294"
assert word2num("ab'c") == "294"
assert word2num("4a-b'c,") == "346"
注意,我的函数返回一个 str 值,而不是 int。这是因为我想使用 str.join() 函数,该函数只接受 str 值——所以 '405' 而不是 405:
>>> from gematria import word2num
>>> word2num("Don't")
'405'
总结一下,word2num() 函数接受一个单词,删除不需要的字符,将剩余字符转换为 ord() 值,并返回这些值的 str 表示之和。
18.1.6 拆分文本
测试期望你保持与原文相同的行断行,所以我建议你使用 str.splitlines(),就像在其他练习中一样。在第十五章和第十六章中,我们使用了不同的正则表达式将每一行拆分为“单词”,这个过程在处理自然语言处理 (NLP) 的程序中有时被称为“分词”。如果你编写了一个 word2num() 函数,并且它通过了我所提供的测试,那么你可以使用 str.split() 在空格处拆分行,因为该函数会忽略任何不是字符或数字的内容。当然,你也可以使用你喜欢的任何方式将行拆分为单词。
以下代码将保持行断行并重建文本。你能修改它以添加 word2num() 函数,使其打印出如图 18.5 所示的编码单词吗?
def main():
args = get_args()
for line in args.text.splitlines():
for word in line.split():
# what goes here?
print(' '.join(line.split()))

图 18.5 文本中的每个单词都将被清理并编码成数字。
输出将为每个单词提供一个数字:
$ ./gematria.py ../inputs/fox.txt
289 541 552 333 559 444 321 448 314
是时候完成解决方案的编写了。务必使用测试!在另一边见。
18.2 解决方案
我确实喜欢密码学和编码消息的想法,而这个程序(某种程度上)正在加密输入文本,尽管这种方式不能被逆转。但思考其他可能处理文本并将其转换成其他值的方法仍然很有趣。
#!/usr/bin/env python3
"""Gematria"""
import argparse
import os
import re
# --------------------------------------------------
def get_args():
"""Get command-line arguments"""
parser = argparse.ArgumentParser(
description='Gematria',
formatter_class=argparse.ArgumentDefaultsHelpFormatter)
parser.add_argument('text', metavar='text', help='Input text or file') ①
args = parser.parse_args() ②
if os.path.isfile(args.text): ③
args.text = open(args.text).read().rstrip() ④
return args ⑤
# --------------------------------------------------
def main():
"""Make a jazz noise here"""
args = get_args() ⑥
for line in args.text.splitlines(): ⑦
print(' '.join(map(word2num, line.split()))) ⑧
# --------------------------------------------------
def word2num(word): ⑨
"""Sum the ordinal values of all the characters"""
return str(sum(map(ord, re.sub('[^A-Za-z0-9]', '', word)))) ⑩
# --------------------------------------------------
def test_word2num(): ⑪
"""Test word2num"""
assert word2num("a") == "97"
assert word2num("abc") == "294"
assert word2num("ab'c") == "294"
assert word2num("4a-b'c,") == "346"
# --------------------------------------------------
if __name__ == '__main__':
main()
① 文本参数可能是一个文件名。
② 获取解析的命令行参数。
③ 检查文本参数是否是现有文件。
④ 用文件内容覆盖 args.text。
⑤ 返回参数。
⑥ 获取解析的参数。
⑦ 在 args.text 上使用换行符拆分以保留行断行。
⑧ 在空格处拆分行,将结果通过 word2num() 函数映射,然后使用空格连接该结果。
⑨ 定义一个将单词转换为数字的函数。
⑩ 使用 re.sub() 删除任何非字母数字字符。将结果字符串通过 ord() 函数映射,计算字符的序数值之和,并返回该和的字符串表示。
⑪ 定义一个测试 word2num() 函数的函数。
18.3 讨论部分
我相信你已经理解了 get_args(),因为我们已经多次使用这段代码。让我们跳转到 word2num() 函数。
18.3.1 编写 word2num()
我可以像这样编写函数:
def word2num(word):
vals = [] ①
for char in re.sub('[^A-Za-z0-9]', '', word): ②
vals.append(ord(char)) ③
return str(sum(vals)) ④
① 初始化一个空列表来存储序数值。
② 遍历 re.sub() 返回的所有字符。
③ 将字符转换为序数值并将其追加到值列表中。
④ 求和这些值并返回一个字符串表示形式。
这比我所写的单行代码多了四行。我至少更愿意使用列表推导式,它将三行代码压缩成一行:
def word2num(word):
vals = [ord(char) for char in re.sub('[^A-Za-z0-9]', '', word)]
return str(sum(vals))
虽然可以将其写在一行中,但有人可能会说可读性会受到影响:
def word2num(word):
return str(sum([ord(char) for char in re.sub('[^A-Za-z0-9]', '', word)]))
我仍然认为 map() 版本是最易读和简洁的:
def word2num(word):
return str(sum(map(ord, re.sub('[^A-Za-z0-9]', '', word))))
图 18.6 展示了三种方法之间的关系。

图 18.6 展示了 for 循环、列表推导式和 map() 之间的关系。
图 18.7 将帮助你看到字符串“Don’t”通过 map() 版本的数据流动。
-
re.sub()函数将替换掉字符集中不存在的任何字符,将其替换为空字符串。这将把像“Don’t”这样的单词变成“Dont”(没有撇号)。 -
map()将给定的函数ord()应用到序列的每个元素上。在这里,“序列”是一个str,所以它将使用单词中的每个字符。 -
map()的结果是一个新的list,其中“Dont”中的每个字符都被ord()函数处理。 -
ord()函数调用的结果将是一个int值的list,每个字母对应一个。 -
sum()函数通过将它们相加将数字列表减少到单个值。 -
我们函数的最终值需要是一个
str,所以我们使用str()函数将sum()的返回值转换为数字的字符串表示形式。

图 18.7 展示了函数运算顺序的表示。
18.3.2 排序
这个练习的重点不是 ord() 和 chr() 函数,而是探索正则表达式、函数应用以及字符在像 Python 这样的编程语言中的表示方式。
例如,字符串排序是大小写敏感的,因为字符的 ord() 值的相对顺序(因为大写字母在 ASCII 表中定义的比小写字母早)。请注意,以大写字母开头的单词会排在以小写字母开头的单词之前:
>>> words = 'banana Apple Cherry anchovies cabbage Beets'
>>> sorted(words)
['Apple', 'Beets', 'Cherry', 'anchovies', 'banana', 'cabbage']
这是因为所有大写序数值都小于小写字母的序数值。为了对字符串进行大小写敏感的排序,你可以使用 key=str.casefold。str.casefold() 函数将返回“一个适合无大小写比较的字符串版本。”我们在这里使用函数名不带括号,因为我们正在将函数本身作为 key 的参数:
>>> sorted(words, key=str.casefold)
['anchovies', 'Apple', 'banana', 'Beets', 'cabbage', 'Cherry']
如果你添加括号,它将引发异常。这正是我们将函数作为参数传递给map()和filter()的方式:
>>> sorted(words, key=str.casefold())
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: descriptor 'casefold' of 'str' object needs an argument
如果你更喜欢就地排序列表,list.sort()的选项是相同的:
>>> words.sort(key=str.casefold)
>>> words
['anchovies', 'Apple', 'banana', 'Beets', 'cabbage', 'Cherry']
由于字符表示相同,命令行工具(如sort程序)的行为方式也相同。给定一个包含这些相同单词的文件,
$ cat words.txt
banana
Apple
Cherry
anchovies
cabbage
Beets
我 Mac 上的sort程序 1 将首先对大写单词进行排序,然后是对小写单词进行排序:
$ sort words
Apple
Beets
Cherry
anchovies
banana
cabbage
我必须阅读sort的手册页面(通过man sort)来找到-f标志以执行不区分大小写的排序:
$ sort -f words
anchovies
Apple
banana
Beets
cabbage
Cherry
18.3.3 测试
我想花点时间指出我有多频繁地使用自己的测试。每次我编写函数或程序的替代版本时,我都会运行自己的测试来验证我没有意外地展示出有错误的代码。拥有测试套件让我有自由和信心对我的程序进行大量重构,因为我知道我可以检查我的工作。如果我在代码中找到错误,我会添加一个测试来验证该错误是否存在。然后我修复错误并验证它是否被处理。我知道如果我意外地重新引入该错误,我的测试会捕捉到它。
对于本书的目的,我尽量不编写超过 100 行的程序。程序通常增长到数千行代码,分布在数十个模块中。我建议你开始编写和使用测试,无论你从哪里开始。这是一个早期建立的好习惯,并且它只会帮助你编写更长的代码。
18.4 进一步探索
-
分析文本文件以找到其他总和为 666 的单词。这些单词特别令人恐惧吗?
-
给定一些文本输入,找出
word2num()中最频繁出现的值以及所有转换为该值的单词。 -
创建一个使用每个字符的自己的数值版本的版本。例如,每个字母可以编码为其在字母表中的位置,使得“A”和“a”是 1,“B”和“b”是 2,以此类推。或者你可能决定将每个辅音的权重设为 1,每个元音的权重设为-1。创建你自己的方案,并编写测试以确保你的程序按预期运行。
摘要
-
ord()函数将返回字符的 Unicode 码点。对于我们的数字值,序数值对应于它们在 ASCII 表中的位置。 -
chr()函数将返回给定序数值的字符。 -
当字符的序数值连续时,例如在 ASCII 表中,你可以在正则表达式中使用字符范围,如
a-z。 -
re.sub()函数将替换字符串中匹配的文本模式为新值,例如,将所有非字符替换为空字符串以删除标点符号和空白。 -
如果函数期望一个单一的位置参数,可以使用函数引用而不是
lambda来编写map()。 -
sum()函数通过加法减少数字列表。你可以使用functools.reduce()函数手动编写这个版本的代码。 -
要对字符串值进行不区分大小写的排序,请使用
sorted()和list.sort()函数的key=str.casefold选项。
1 在我的 Linux 机器上,GNU coreutils 8.30 版本默认会进行不区分大小写的排序。你的sort命令是如何工作的?
19 每日锻炼:解析 CSV 文件,创建文本表格输出
| 几年前,我加入了一个锻炼小组。我们每周在教练未铺路的车道上见面几次。我们拿起和放下重物,四处跑动,试图让死神再远离我们一天。我不是力量和健康的典范,但这是锻炼和与朋友交流的好方法。我最喜欢的部分之一是教练会在黑板上写一个“每日锻炼”或“WOD”。无论上面写的是什么,我都会做。如果那天我实际上不想做 200 个俯卧撑,我也会不管花多长时间都完成它们。1 | ![]() |
|---|
按照这个精神,我们将编写一个名为 wod.py 的程序,帮助我们创建一个随机每日锻炼计划,无需提问:
$ ./wod.py
Exercise Reps
------------------ ------
Pushups 40
Plank 38
Situps 99
Hand-stand pushups 5
注意:每次运行程序时,你都必须立即执行所有练习。嘿,甚至只是 阅读 它们就意味着你必须做。就像 现在 一样。抱歉,我制定不了规则。还是开始做仰卧起坐吧!
我们将从存储在分隔文本文件中的一系列练习中选择。在这种情况下,“分隔符”是逗号,它将分隔每个字段值。使用逗号作为分隔符的数据文件通常被称为 逗号分隔值 或 CSV 文件。通常,文件的第一个行命名列,每个后续行代表表格中的一行:
$ head -3 inputs/exercises.csv
exercise,reps
Burpees,20-50
Situps,40-100
在这个练习中,你将
-
使用
csv模块解析分隔文本文件 -
将文本值强制转换为数字
-
使用
tabulate模块打印表格数据 -
处理缺失和格式不正确的数据
本章和下一章的难度将有所提高。你将应用在前几章中学到的许多技能,所以准备好吧!
19.1 编写 wod.py
你将在 19_wod 目录下创建一个名为 wod.py 的程序。让我们先看看当它用 -h 或 --help 运行时应该打印的用法。修改你的程序参数,直到它产生以下内容:
$ ./wod.py -h
usage: wod.py [-h] [-f FILE] [-s seed] [-n exercises] [-e]
Create Workout Of (the) Day (WOD)
optional arguments:
-h, --help show this help message and exit
-f FILE, --file FILE CSV input file of exercises (default:
inputs/exercises.csv)
-s seed, --seed seed Random seed (default: None)
-n exercises, --num exercises
Number of exercises (default: 4)
-e, --easy Halve the reps (default: False)
我们程序将读取一个输入 -f 或 --file,它应该是一个可读的文本文件(默认,inputs/exercises.csv)。输出将是一定数量的 -n 或 --num 练习(默认,4)。可能有一个 -e 或 --easy 标志来指示每个练习的重复次数应该减半。由于我们将使用 random 模块来选择练习,我们需要接受一个 -s 或 --seed 选项(默认为 None 的 int)来传递给 random.seed() 以进行测试目的。
19.1.1 读取分隔文本文件
我们将使用 csv 模块来解析输入文件。这是一个标准模块,应该已经安装在你的系统上。你可以通过打开一个 python3 REPL 并尝试导入它来验证。如果这成功了,你就准备好了:
>>> import csv
我们还将查看两个你可能需要安装的其他模块:
-
使用
csvkit模块的工具查看命令行上的输入文件 -
使用
tabulate模块来格式化输出表格
运行此命令来安装这些模块:
$ python3 -m pip install csvkit tabulate
此外,还有一个 requirements.txt 文件,这是记录程序依赖项的常见方式。您可以使用以下命令安装所有模块,而不是之前的命令:
$ python3 -m pip install -r requirements.txt
尽管名字中有“csv”,但 csvkit 模块可以处理几乎所有分隔文本文件。例如,通常也使用制表符(\t)作为分隔符。该模块包括许多工具,您可以在其文档中了解更多信息(csvkit.readthedocs.io/en/1.0.3/)。我在 19_wod/inputs 目录中包含了一些分隔文件,您可以使用它们来测试您的程序。 |
![]() |
|---|
安装 csvkit 后,您应该可以使用 csvlook 将 inputs/exercises.csv 文件解析为显示列的表格结构:
$ csvlook --max-rows 3 inputs/exercises.csv
| exercise | reps |
| -------- | ------ |
| Burpees | 20-50 |
| Situps | 40-100 |
| Pushups | 25-75 |
| ... | ... |
输入文件的“重复”列将包含由破折号分隔的两个数字,例如 10-20 表示“从 10 到 20 次重复。”要选择最终的重复次数,您将使用 random.randint() 函数在低值和高值之间选择一个整数值。当使用种子运行时,您的输出应与以下内容完全匹配:
$ ./wod.py --seed 1 --num 3
Exercise Reps
---------- ------
Pushups 32
Situps 71
Crunches 27
当使用 --easy 标志运行时,重复次数应减半:
$ ./wod.py --seed 1 --num 3 --easy
Exercise Reps
---------- ------
Pushups 16
Situps 35
Crunches 13
--file 选项应默认为 inputs/exercises.csv 文件,或者我们可以指定不同的输入文件:
$ ./wod.py --file inputs/silly-exercises.csv
Exercise Reps
----------------- ------
Hanging Chads 46
Squatting Chinups 46
Rock Squats 38
Red Barchettas 32
图 19.1 显示了我们信任的字符串图,以帮助您思考。

图 19.1 WOD 程序将随机从 CSV 文件中选择练习和重复次数,以创建列出每日锻炼的表格。
19.1.2 手动读取 CSV 文件
首先,我将向您展示如何手动解析 CSV 文件中的每个记录到字典列表中,然后我会向您展示如何使用 csv 模块更快地完成这项工作。我们想要将每个记录转换为字典的原因是,这样我们可以获取每个练习的值和重复次数(重复,或重复给定练习的次数)。我们需要将重复次数分为低值和高值,以便从其中随机选择重复次数。最后,我们将随机选择一些练习及其重复次数来制定锻炼计划。哇,仅仅描述这个过程就已经是一项锻炼了!
注意,重复次数是以一个低数到高数的范围给出的,由破折号分隔:
$ head -3 inputs/exercises.csv
exercise,reps
Burpees,20-50
Situps,40-100
读取为字典列表会更方便,其中第一行的列名与数据行的每一行结合,如下所示:
$ ./manual1.py
[{'exercise': 'Burpees', 'reps': '20-50'},
{'exercise': 'Situps', 'reps': '40-100'},
{'exercise': 'Pushups', 'reps': '25-75'},
{'exercise': 'Squats', 'reps': '20-50'},
{'exercise': 'Pullups', 'reps': '10-30'},
{'exercise': 'Hand-stand pushups', 'reps': '5-20'},
{'exercise': 'Lunges', 'reps': '20-40'},
{'exercise': 'Plank', 'reps': '30-60'},
{'exercise': 'Crunches', 'reps': '20-30'}]
对于只包含两列的记录使用字典似乎有些过度,但我经常处理包含数十甚至数百列的记录,这时字段名就变得至关重要。字典实际上处理大多数分隔文本文件的唯一合理方式,因此学习这样的小例子是很好的。
让我们来看看将执行此操作的 manual1.py 代码:
#!/usr/bin/env python3
from pprint import pprint ①
with open('inputs/exercises.csv') as fh: ②
headers = fh.readline().rstrip().split(',') ③
records = [] ④
for line in fh: ⑤
rec = dict(zip(headers, line.rstrip().split(','))) ⑥
records.append(rec) ⑦
pprint(records) ⑧
① 我们将使用 pretty-print 模块来打印数据结构。
② 使用 “with” 构造来以 fh 变量打开练习。使用 “with” 的一个优点是,当代码超出代码块时,文件句柄将自动关闭。
③ 使用 fh.readline() 只读取文件的第一行。从右侧移除空格(str.rstrip()),然后使用 str.split() 在逗号处拆分结果字符串,创建一个字符串列表,这些字符串是列标题。
④ 将记录初始化为一个空列表。
⑤ 使用 for 循环读取 fh 的其余行。
⑥ 去除空格并拆分文本行为一个字段值的列表。使用 zip() 函数创建一个包含每个标题与每个值配对的元组的新列表。使用 dict() 函数将这个元组列表转换为字典。
⑦ 将生成的字典追加到记录中。
⑧ 美化打印记录。
让我们更详细地分析一下。首先我们将 open() 文件并读取第一行:
>>> fh = open('exercises.csv')
>>> fh.readline()
'exercise,reps\n'
| 这行仍然有一个换行符,所以我们可以使用 str.rstrip() 函数来移除它:
>>> fh = open('exercises.csv')
>>> fh.readline().rstrip()
'exercise,reps'
| ![图片 19-unnumb-3.png] |
|---|
注意:为了演示,我需要不断重新打开这个文件,否则每次调用 fh.readline() 都会读取下一行的文本。
现在让我们使用 str.split() 来根据逗号拆分该行,以获取一个字符串 list:
>>> fh = open('exercises.csv')
>>> headers = fh.readline().rstrip().split(',')
>>> headers
['exercise', 'reps']
我们同样可以读取文件的下一 line,以获取字段值的 list:
>>> line = fh.readline().rstrip().split(',')
>>> line
['Burpees', '20-50']
接下来我们使用 zip() 函数将两个列表合并成一个列表,其中每个列表的元素都与其在相同位置的对应元素配对。这听起来可能很复杂,但想想婚礼仪式结束时,新娘和新郎转身面对聚集的人群。通常他们会手牵手,开始沿着通道走下,离开仪式。想象有三名伴郎 ('G') 和三名伴娘 ('B') 分别站在各自的两侧面对面站立:
>>> groomsmen = 'G' * 3
>>> bridesmaids = 'B' * 3
如果有两行,每行包含三个人,那么我们最终会得到一行包含三个配对的行:
>>> pairs = list(zip(groomsmen, bridesmaids))
>>> pairs
[('G', 'B'), ('G', 'B'), ('G', 'B')]
>>> len(pairs)
3
| 或者想象两行车辆合并出停车场。通常情况下,一个车道(比如说,“A”)的一辆车会并入交通,然后是另一个车道(比如说,“B”)的一辆车。车辆就像拉链的牙齿一样合并,结果是“A”,“B”,“A”,“B”,以此类推。 | ![图片 19-unnumb-4.png] |
|---|
zip() 函数会将列表的元素组合成元组,将第一个位置的所有元素组合在一起,然后是第二个位置,以此类推,如图 19.2 所示。请注意,这是一个另一个 lazy 函数,所以我在 REPL 中会使用 list 来强制转换:
>>> list(zip('abc', '123'))
[('a', '1'), ('b', '2'), ('c', '3')]

图 19.2 将两个列表“zipping”会创建一个包含元素对的新的列表。
zip() 函数可以处理超过两个列表。请注意,它只为最短的列表创建分组。在以下示例中,前两个列表有四个元素(“abcd”和“1234”),但最后一个只有三个(“xyz”),因此只创建了三个元组:
>>> list(zip('abcd', '1234', 'xyz'))
[('a', '1', 'x'), ('b', '2', 'y'), ('c', '3', 'z')]
在我们的数据中,zip() 将标题“exercise”与值“Burpees”以及标题“reps”与值“20-50”组合(见图 19.3):
>>> list(zip(headers, line))
[('exercise', 'Burpees'), ('reps', '20-50')]

图 19.3 将标题和值一起压缩以创建元组列表
这创建了一个 tuple 值的 list。而不是 list(),我们可以使用 dict() 来创建一个字典:
>>> rec = dict(zip(headers, line))
>>> rec
{'exercise': 'Burpees', 'reps': '20-50'}
回想一下,dict.items() 函数将 dict 转换为 tuple(键/值)对的 list,因此你可以将这些数据结构视为相当可互换的:
>>> rec.items()
dict_items([('exercise', 'Burpees'), ('reps', '20-50')])
我们可以通过用列表推导式替换 for 循环来大大缩短我们的代码:
with open('inputs/exercises.csv') as fh:
headers = fh.readline().rstrip().split(',') ①
records = [dict(zip(headers, line.rstrip().split(','))) for line in fh] ②
pprint(records)
① 我们仍然需要通过读取第一行来单独提取标题。
② 这将 for 循环的三个行合并为一个列表推导式。
我们可以使用 map() 来编写等效的代码:
with open('inputs/exercises.csv') as fh:
headers = fh.readline().rstrip().split(',')
mk_rec = lambda line: dict(zip(headers, line.rstrip().split(','))) ①
records = map(mk_rec, fh)
pprint(list(records))
① Flake8 会抱怨这个 lambda 表达式的赋值。我通常编写代码以产生无警告,但我确实不同意这个建议。我相当喜欢使用 lambda 赋值编写单行函数。
在下一节中,我将向您展示如何使用 csv 模块来处理大部分代码,这可能会让您想知道为什么我费心向您展示如何自己处理。不幸的是,我经常不得不处理格式极差的文件,以至于第一行不是标题,或者在标题行和实际数据之间有其他信息行。当你看到像我一样多的格式糟糕的 Excel 文件时,你会开始欣赏有时你别无选择,只能自己解析文件。
19.1.3 使用 csv 模块进行解析
以这种方式解析分隔文本文件非常常见,每次需要解析文件时都编写或复制此代码是没有意义的。幸运的是,csv 模块是 Python 预装的标准模块,并且可以非常优雅地处理所有这些。
让我们看看如果我们使用 csv.DictReader()(请参阅仓库中的 using_csv1.py)我们的代码会如何改变:
#!/usr/bin/env python3
import csv ①
from pprint import pprint
with open('inputs/exercises.csv') as fh:
reader = csv.DictReader(fh, delimiter=',') ②
records = [] ③
for rec in reader: ④
records.append(rec) ⑤
pprint(records)
① 导入 csv 模块。
② 创建一个 csv.DictReader(),它将为文件中的每条记录创建一个字典。它将第一行的标题与随后的行中的数据值进行压缩。它使用分隔符来指示用于分割文本列的字符串值。
③ 初始化一个空列表来保存记录。
④ 使用 for 循环遍历由读取器返回的每条记录。
⑤ 记录将是一个字典,它将被添加到记录列表中。
以下代码创建了与之前相同的 dict 值的 list,但代码更少。注意,每个记录都显示为 OrderedDict,这是一种字典类型,其中键保持其插入顺序:
$ ./using_csv1.py
[OrderedDict([('exercise', 'Burpees'), ('reps', '20-50')]),
OrderedDict([('exercise', 'Situps'), ('reps', '40-100')]),
OrderedDict([('exercise', 'Pushups'), ('reps', '25-75')]),
OrderedDict([('exercise', 'Squats'), ('reps', '20-50')]),
OrderedDict([('exercise', 'Pullups'), ('reps', '10-30')]),
OrderedDict([('exercise', 'Hand-stand pushups'), ('reps', '5-20')]),
OrderedDict([('exercise', 'Lunges'), ('reps', '20-40')]),
OrderedDict([('exercise', 'Plank'), ('reps', '30-60')]),
OrderedDict([('exercise', 'Crunches'), ('reps', '20-30')])]
我们可以移除整个 for 循环,并使用 list() 函数强制 reader 给我们相同的列表。这段代码(在 using_csv2.py 中使用)将打印相同的输出:
with open('inputs/exercises.csv') as fh: ①
reader = csv.DictReader(fh, delimiter=',') ②
records = list(reader) ③
pprint(records) ④
① 打开文件。
② 创建一个 csv.DictReader() 来读取 fh,使用逗号作为分隔符。
③ 使用 list() 函数强制转换读取器中的所有值。
④ 美化打印记录。
19.1.4 创建读取 CSV 文件的函数
让我们尝试想象如何编写和测试一个可能称为 read_csv() 的函数来读取我们的数据。让我们从函数的占位符和 test_read_csv() 定义开始:
def read_csv(fh):
"""Read the CSV input"""
pass
def test_read_csv():
"""Test read_csv"""
text = io.StringIO('exercise,reps\nBurpees,20-50\nSitups,40-100') ①
assert read_csv(text) == [('Burpees', 20, 50), ('Situps', 40, 100)] ②
① 使用 io.StringIO() 创建一个模拟文件句柄来包装可能从文件中读取的有效文本。\n 代表输入数据中每行的换行符,并且每行使用逗号来分隔字段。我们之前在第五章低内存版本的程序中使用了 io.StringIO()。
② 确认我们的假想 read_csv() 文件会将此文本转换为包含练习名称和已拆分为低和高值的 reps 的元组值的列表。注意,这些值已被转换为整数。
嘿,我们刚刚做了所有这些工作来创建一个 dict 值的 list,所以我为什么建议我们现在创建一个 tuple 值的 list 呢?我在这里向前看,考虑我们如何使用 tabulate 模块来打印结果,所以请在这里相信我。这是一个很好的方法!
让我们回到使用 csv.DictReader() 解析我们的文件,并思考如何将 reps 值拆分为 int 类型的低和高值:
reader = csv.DictReader(fh, delimiter=',')
exercises = []
for rec in reader:
name, reps = rec['exercise'], rec['reps']
low, high = 0, 0 # what goes here?
exercises.append((name, low, high))
你有几个工具可以使用。想象一下 reps 是这样的:
>>> reps = '20-50'
str.split() 函数可以将它拆分为两个字符串,“20”和“50”:
>>> reps.split('-')
['20', '50']
你如何将每个 str 值转换为整数?
另一种方法是使用正则表达式。记住,\d 匹配一个数字,所以 \d+ 匹配一个或多个数字。(参考第十五章,以刷新你对 \d 作为数字字符类的快捷方式的记忆。)你可以将这个表达式包裹在括号中,以捕获“低”和“高”值:
>>> match = re.match('(\d+)-(\d+)', reps)
>>> match.groups()
('20', '50')
你能编写一个 read_csv() 函数,使其通过之前的 test_read_csv() 测试吗?
19.1.5 选择练习
到目前为止,我希望你已经完全理解了 get_args() 并使 read_csv() 通过了给定的测试。现在我们可以在 main() 中开始打印数据结构:
def main():
args = get_args() ①
random.seed(args.seed) ②
pprint(read_csv(args.file)) ③
① 获取命令行参数。
② 使用 args.seed 值设置 random.seed()。
③ 使用 read_csv() 函数读取 args.file(它将是一个打开的文件句柄)并打印结果数据结构。注意,我为了演示目的导入了 pprint() 函数。
如果你运行前面的代码,你应该看到这个:
$ ./wod.py
[('Burpees', 20, 50),
('Situps', 40, 100),
('Pushups', 25, 75),
('Squats', 20, 50),
('Pullups', 10, 30),
('Hand-stand pushups', 5, 20),
('Lunges', 20, 40),
('Plank', 30, 60),
('Crunches', 20, 30)]
我们将使用 random.sample() 函数来选择用户指定的 --num 个练习。将 import random 添加到你的程序中,并修改你的 main 以匹配以下内容:
def main():
args = get_args()
random.seed(args.seed) ①
exercises = read_csv(args.file) ②
pprint(random.sample(exercises, k=args.num)) ③
① 在调用随机函数之前,始终设置随机种子。
② 读取输入文件。
③ 随机选择指定数量的练习。
现在它应该打印正确的练习数量的随机样本,而不是打印所有练习。此外,如果你的 random.seed() 值设置正确,你的采样应该与以下输出完全匹配:
$ ./wod.py -s 1
[('Pushups', 25, 75),
('Situps', 40, 100),
('Crunches', 20, 30),
('Burpees', 20, 50)]
我们需要遍历样本,并使用 random.randint() 函数选择一个单独的“reps”值。第一个练习是俯卧撑,范围是 25 到 75 次:
>>> import random
>>> random.seed(1)
>>> random.randint(25, 75)
33
如果 args.easy 是 True,你需要将这个值减半。不幸的是,我们不能有分数的俯卧撑:
>>> 33/2
16.5
你可以使用 int() 函数将数字截断到整数部分:
>>> int(33/2)
16
19.1.6 格式化输出
修改你的程序,直到它能够重现这个输出:
$ ./wod.py -s 1
[('Pushups', 56), ('Situps', 88), ('Crunches', 27), ('Burpees', 35)]
我们将使用 tabulate() 函数从 tabulate 模块中格式化这个 tuple 值列表为文本表:
>>> from tabulate import tabulate
>>> wod = [('Pushups', 56), ('Situps', 88), ('Crunches', 27), ('Burpees', 35)]
>>> print(tabulate(wod))
-------- --
Pushups 56
Situps 88
Crunches 27
Burpees 35
-------- --
如果你阅读 help(tabulate),你会看到有一个 headers 选项,你可以指定用于标题的字符串列表:
>>> print(tabulate(wod, headers=('Exercise', 'Reps')))
Exercise Reps
---------- ------
Pushups 56
Situps 88
Crunches 27
Burpees 35
如果你综合所有这些想法,你应该能够通过提供的测试。
19.1.7 处理坏数据
没有测试会给你的程序提供坏数据,但我已经在 19_wod/inputs 目录中提供了几个“坏”CSV 文件,你可能想找出如何处理这些文件:
-
bad-headers-only.csv 格式良好,但没有数据。它只有标题。
-
bad-empty.csv 是空的。也就是说,它是一个零长度的文件,我用
touchbad-empty.csv创建的,而且没有任何数据。 -
bad-headers.csv 的标题是大写的,所以“Exercise”而不是“exercise”,“Reps”而不是“reps”。
-
bad-delimiter.tab 使用制表符(
\t)而不是逗号(,)作为字段分隔符。 -
bad-reps.csv 包含不符合
x-y格式或不是数字或整数值的 reps。
一旦你的程序通过了给定的测试,尝试在“坏”文件上运行它,看看你的程序是如何崩溃的。当没有可用的数据时,你的程序应该做什么?当程序遇到坏或缺失的值时,它应该打印错误消息,还是应该安静地忽略错误并只打印可用的数据?这些都是你将在现实生活中遇到的实际问题,由你决定你的程序将做什么。在解决方案之后,我将向你展示我可能处理这些文件的方法。
19.1.8 开始编写
好了,别磨蹭了。是时候写这个程序了。每次你发现一个错误时,你必须做 10 个俯卧撑!
这里有一些提示:
-
使用
csv.DictReader()解析输入 CSV 文件。 -
在
-字符上断开reps字段,将低/高值强制转换为int值,然后使用random.randint()在该范围内选择一个随机整数。 -
使用
random.sample()来选择正确的练习数量。 -
使用
tabulate模块将输出格式化为文本表。
19.2 解决方案
你感觉怎么样?你成功修改了程序,使其优雅地处理所有不良输入文件了吗?
#!/usr/bin/env python3
"""Create Workout Of (the) Day (WOD)"""
import argparse
import csv
import io
import random
from tabulate import tabulate ①
# --------------------------------------------------
def get_args():
"""Get command-line arguments"""
parser = argparse.ArgumentParser(
description='Create Workout Of (the) Day (WOD)',
formatter_class=argparse.ArgumentDefaultsHelpFormatter)
parser.add_argument('-f',
'--file',
help='CSV input file of exercises',
metavar='FILE',
type=argparse.FileType('rt'), ②
default='exercises.csv')
parser.add_argument('-s',
'--seed',
help='Random seed',
metavar='seed',
type=int,
default=None)
parser.add_argument('-n',
'--num',
help='Number of exercises',
metavar='exercises',
type=int,
default=4)
parser.add_argument('-e',
'--easy',
help='Halve the reps',
action='store_true')
args = parser.parse_args()
if args.num < 1: ③
parser.error(f'--num "{args.num}" must be greater than 0')
return args
# --------------------------------------------------
def main():
"""Make a jazz noise here"""
args = get_args()
random.seed(args.seed)
wod = [] ④
exercises = read_csv(args.file) ⑤
for name, low, high in random.sample(exercises, k=args.num): ⑥
reps = random.randint(low, high) ⑦
if args.easy: ⑧
reps = int(reps / 2)
wod.append((name, reps)) ⑨
print(tabulate(wod, headers=('Exercise', 'Reps'))) ⑩
# --------------------------------------------------
def read_csv(fh): ⑪
"""Read the CSV input"""
exercises = [] ⑫
for row in csv.DictReader(fh, delimiter=','): ⑬
low, high = map(int, row['reps'].split('-')) ⑭
exercises.append((row['exercise'], low, high)) ⑮
return exercises ⑯
# --------------------------------------------------
def test_read_csv(): ⑰
"""Test read_csv"""
text = io.StringIO('exercise,reps\nBurpees,20-50\nSitups,40-100') ⑱
assert read_csv(text) == [('Burpees', 20, 50), ('Situps', 40, 100)] ⑲
# --------------------------------------------------
if __name__ == '__main__':
main()
① 导入我们将用于格式化输出表的 tabulate 函数。
② 如果提供了 --file 选项,则必须是一个可读文本文件。
③ 确保 args.num 是一个正数。
④ 将 wod 初始化为空列表。
⑤ 将输入文件读取到练习列表中。
⑥ 随机抽取给定数量的练习。结果将是一个包含三个值的元组的列表,可以直接解包到变量 name 和 low 以及 high 值中。
⑦ 随机选择一个在提供的范围内的 reps 值。
⑧ 如果 args.easy 是 “truthy”,则将 reps 减半。
⑨ 将包含练习名称和 reps 的元组附加到 wod 上。
⑩ 使用 tabulate() 函数将 wod 格式化为一个使用适当标题的文本表。
⑪ 定义一个函数来读取一个打开的 CSV 文件句柄。
⑫ 将练习初始化为空列表。
⑬ 使用 csv.DictReader() 遍历文件句柄,创建一个将第一行的列名与文件其余部分中的字段值结合的字典。使用逗号作为字段分隔符。
⑭ 在 dash 上拆分 “reps” 列,将这些值转换为整数,并分配给 low 和 high 变量。
⑮ 将包含练习名称以及 low 和 high 值的元组附加到 wod 上。
⑯ 将练习列表返回给调用者。
⑰ 定义一个函数,Pytest 将使用它来测试 read_csv() 函数。
⑱ 创建一个包含有效样本数据的模拟文件句柄。
⑲ 验证 read_csv() 是否可以处理有效输入数据。
19.3 讨论
程序中近一半的行都在 get_args() 函数中!尽管没有什么新内容可以讨论,但我真的想指出,为了验证输入、提供默认值、创建用法说明等,做了多少工作。让我们深入程序,从 read_csv() 函数开始。
19.3.1 读取 CSV 文件
在本章的早期,我给你留下一行代码,需要拆分 reps 列并转换值为整数。这里有一种方法:
def read_csv(fh):
exercises = []
for row in csv.DictReader(fh, delimiter=','):
low, high = map(int, row['reps'].split('-')) ①
exercises.append((row['exercise'], low, high))
return exercises
① 在 dash 上拆分 reps 字段,通过 int() 函数映射值,并将它们分配给 low 和 high。
注释行的工作原理如下。假设有一个 reps 值如下:
>>> '20-50'.split('-')
['20', '50']
我们需要将每个值转换为 int 类型,这正是 int() 函数要做的。我们可以使用列表推导式:
>>> [int(x) for x in '20-50'.split('-')]
[20, 50]
但在我看来,map() 更短且更容易阅读:
>>> list(map(int, '20-50'.split('-')))
[20, 50]
由于这会产生正好两个值,我们可以将它们分配给两个变量:
>>> low, high = map(int, '20-50'.split('-'))
>>> low, high
(20, 50)
19.3.2 可能的运行时错误
这段代码做出了许多假设,当数据不符合预期时会导致它失败。例如,如果 reps 字段不包含 dash 会发生什么?它将产生一个值:
>>> list(map(int, '20'.split('-')))
[20]
当我们尝试将一个值分配给两个变量时,这将导致 运行时 异常:
>>> low, high = map(int, '20'.split('-'))
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
ValueError: not enough values to unpack (expected 2, got 1)
如果一个或多个值不能被强制转换为int,它将引发异常,而且,同样,你只有在用坏数据运行程序时才会发现这一点:
>>> list(map(int, 'twenty-thirty'.split('-')))
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
ValueError: invalid literal for int() with base 10: 'twenty'
如果记录中没有reps字段,比如字段名是大写的情况下,会发生什么?
>>> rec = {'Exercise': 'Pushups', 'Reps': '20-50'}
然后,字典访问rec['reps']将引发异常:
>>> list(map(int, rec['reps'].split('-')))
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
KeyError: 'reps'
只要我们传递格式良好的数据,read_csv()函数似乎就能正常工作,但现实世界并不总是给我们干净的数据集。实际上,我的工作中很大一部分是寻找和纠正这类错误。
在本章的早期,我建议你可能使用正则表达式从reps字段中提取低值和高值。正则表达式的优点是检查整个字段,确保它看起来是正确的。这里是一个更健壮的read_csv()实现方式:
def read_csv(fh):
exercises = [] ①
for row in csv.DictReader(fh, delimiter=','): ②
name, reps = row.get('exercise'), row.get('reps') ③
if name and reps: ④
match = re.match('(\d+)-(\d+)', reps) ⑤
if match: ⑥
low, high = map(int, match.groups()) ⑦
exercises.append((name, low, high)) ⑧
return exercises ⑨
① 将练习初始化为一个空列表。
② 遍历数据的行。
③ 使用 dict.get()函数尝试检索“exercise”和“reps”的值。
④ 检查练习名称和重复次数是否有“真值”。
⑤ 使用正则表达式查找一个或多个数字,后面跟一个破折号,再跟一个或多个数字。使用捕获括号来提取数字。
⑥ 检查是否有匹配项。请记住,re.match()将返回 None 以指示匹配失败。
⑦ 从两个捕获组中提取低值和高值,并通过 int()函数将它们映射为整数,以强制转换字符串值。这是安全的,因为我们使用正则表达式来验证它们看起来像数字。
⑧ 将名称和低值和高值作为一个元组添加到练习中。
⑨ 将练习返回给调用者。如果没有找到有效数据,我们将返回一个空列表。
19.3.3 使用 pandas.read_csv()解析文件
许多熟悉统计学和数据科学的人可能会知道 Python 模块pandas,它模仿了许多 R 编程语言的思想。我特意选择了函数名read_csv(),因为这类似于 R 中内置的函数read.csv,而read.csv又反过来作为pandas.read_csv()函数的模型。R 和pandas都倾向于将分隔/CSV 文件中的数据视为“数据框”——一个二维对象,允许你处理数据的列和行。
要运行 using_pandas.py 版本,你需要像这样安装pandas:
$ python3 -m pip install pandas
现在你可以尝试运行这个程序:
import pandas as pd
df = pd.read_csv('inputs/exercises.csv')
print(df)
你将看到以下输出:
$ ./using_pandas.py
exercise reps
0 Burpees 20-50
1 Situps 40-100
2 Pushups 25-75
3 Squats 20-50
4 Pullups 10-30
5 Hand-stand pushups 5-20
6 Lunges 20-40
7 Plank 30-60
8 Crunches 20-30
学习如何使用pandas远远超出了本书的范围。我主要想让你知道,这是一种非常流行的解析分隔文本文件的方法,特别是如果你打算对数据的各个列进行统计分析的话。
19.3.4 格式化表格
让我们看看解决方案中包含的main()函数。你可能注意到一个运行时异常正在等待发生:
def main():
args = get_args()
random.seed(args.seed)
wod = []
exercises = read_csv(args.file)
for name, low, high in random.sample(exercises, k=args.num): ①
reps = random.randint(low, high)
if args.easy:
reps = int(reps / 2)
wod.append((name, reps))
print(tabulate(wod, headers=('Exercise', 'Reps')))
① 如果 args.num 大于练习中的元素数量,例如 read_csv()返回 None 或空列表,则此行将失败。
如果你使用 bad-headers-only.csv 文件测试给定的解决方案,你会看到这个错误:
$ ./wod.py -f inputs/bad-headers-only.csv
Traceback (most recent call last):
File "./wod.py", line 93, in <module>
main()
File "./wod.py", line 62, in main
for name, low, high in random.sample(exercises, k=args.num):
File "/Library/Frameworks/Python.framework/Versions/3.8/lib/python3.8/random.py", line 363, in sample
raise ValueError("Sample larger than population or is negative")
ValueError: Sample larger than population or is negative
处理这个问题的一个更安全的方法是检查 read_csv() 是否返回足够的数据传递给 random.sample()。我们有一些可能的错误:
-
在输入文件中没有找到可用的数据。
-
我们正在尝试从文件中采样过多的记录。
这里是处理这些问题的可能方法。记住,使用字符串值调用 sys.exit() 将导致程序将消息打印到 sys.stderr 并以值 1(错误值)退出:
def main():
"""Make a jazz noise here"""
args = get_args()
random.seed(args.seed)
exercises = read_csv(args.file) ①
if not exercises: ②
sys.exit(f'No usable data in --file "{args.file.name}"')
num_exercises = len(exercises)
if args.num > num_exercises: ③
sys.exit(f'--num "{args.num}" > exercises "{num_exercises}"')
wod = [] ④
for name, low, high in random.sample(exercises, k=args.num):
reps = random.randint(low, high)
if args.easy:
reps = int(reps / 2)
wod.append((name, reps))
print(tabulate(wod, headers=('Exercise', 'Reps')))
① 将输入文件读入 exercises。该函数应仅返回一个列表,可能为空。
② 检查 exercises 是否为“假”,例如空列表。
③ 检查我们是否尝试采样过多的记录。
④ 在我们验证有足够有效数据后继续。
solution2.py 中的版本有这些更新的函数,并且优雅地处理了所有不良输入文件。注意,我将 test_read_csv() 函数移动到 unit.py 文件中,因为随着我用各种不良输入进行测试,它变得很长。
你可以运行 pytest -xv unit.py 来运行单元测试。让我们检查 unit.py 以查看更严格的测试方案:
import io
from wod import read_csv ①
def test_read_csv():
"""Test read_csv"""
good = io.StringIO('exercise,reps\nBurpees,20-50\nSitups,40-100') ②
assert read_csv(good) == [('Burpees', 20, 50), ('Situps', 40, 100)]
no_data = io.StringIO('') ③
assert read_csv(no_data) == []
headers_only = io.StringIO('exercise,reps\n') ④
assert read_csv(headers_only) == []
bad_headers = io.StringIO('Exercise,Reps\nBurpees,20-50\nSitups,40-100') ⑤
assert read_csv(bad_headers) == []
bad_numbers = io.StringIO('exercise,reps\nBurpees,20-50\nSitups,forty-100') ⑥
assert read_csv(bad_numbers) == [('Burpees', 20, 50)]
no_dash = io.StringIO('exercise,reps\nBurpees,20\nSitups,40-100') ⑦
assert read_csv(no_dash) == [('Situps', 40, 100)]
tabs = io.StringIO('exercise\treps\nBurpees\t20-40\nSitups\t40-100') ⑧
assert read_csv(tabs) == []
① 记住你可以从自己的模块中导入自己的函数到其他程序中。这里我们引入了我们的 read_csv() 函数。如果我们使用了 import wod,我们就可以调用 wod.read_csv()。
② 原始、有效的输入
③ 测试没有任何数据的情况
④ 文件格式正确(正确的头和分隔符),但没有数据
⑤ 标题是大写的,但只期望小写标题。
⑥ 一个无法通过 int() 强制转换为数值的字符串(“forty”)
⑦ 缺少连字符的 “reps” 值(“20”)
⑧ 格式正确且带有正确头部的数据,但使用制表符作为分隔符
19.4 进一步学习
|
-
添加一个选项来使用不同的分隔符,或者如果输入文件扩展名为 “.tab”,如 bad-delimiter.tab 文件,则猜测分隔符为制表符。
-
tabulate模块支持许多表格格式,包括纯文本、简单、网格、管道、orgtbl、rst、mediawiki、latex、latex_raw 和 latex_booktabs。添加一个选项来选择不同的tabulate格式,使用这些作为有效选择。选择一个合理的默认值。
![]() |
|---|
摘要
-
csv模块用于解析分隔文本数据,如 CSV 和制表符分隔的文件。 -
表示数字的文本值必须使用
int()或float()转换为数值,才能在程序内部用作数字。 -
tabulate模块可以用来创建文本表格,以格式化表格输出。 -
必须非常小心地预测和处理不良和缺失的数据值。测试可以帮助你想象出你的代码可能会失败的所有方式。
1 请参阅巴里·施瓦茨的《更多并不总是更好》(hbr.org/2006/06/more-isnt-always-better)。他指出,给人们提供的选项数量增加实际上会带来更多的困扰和不满,无论做出何种选择。想象一下一家有三种口味的冰淇淋店:巧克力、香草和草莓。如果你选择了巧克力,你可能会对那个选择感到满意。现在想象一下,这家店有 60 种冰淇淋口味,包括 20 种不同的水果奶油和冰沙,以及从 Rocky Road 到 Fudgetastic Caramel Tiramisu Ripple 的 12 种不同的巧克力品种。现在当你选择“巧克力”口味时,你可能会因为错过了其他 11 种可以选择的口味而感到后悔。有时,没有选择本身就能带来一种平静感。称之为宿命论或其他什么都可以。
20 密码强度:生成安全且易记的密码
创建既难以猜测又容易记住的密码并不容易。一个 XKCD 漫画描述了一个算法,通过建议密码由“四个随机常用词汇”组成,提供了安全性和记忆力的双重保障 (xkcd.com/936/)。例如,漫画建议由“correct”、“horse”、“battery”和“staple”这些单词组成的密码将提供“~44 位的熵”,这意味着在每秒 1000 次猜测的情况下,需要大约 550 年才能被计算机猜出。
我们将编写一个名为 password.py 的程序,通过随机组合一些输入文件中的词汇来创建密码。许多计算机都有一个列出数千个英语单词的文件,每个单词单独一行。在我的大多数系统中,我可以在/usr/share/dict/words 中找到这个文件,它包含超过 235,000 个单词!由于文件可能因系统而异,我已经在仓库中添加了一个版本,这样我们就可以使用相同的文件。这个文件有点大,所以我将其压缩到 inputs/words.txt.zip。在使用之前,你应该解压缩它:
$ unzip inputs/words.txt.zip
现在我们都应该有相同的输入文件/words.txt,这样你才能重现这个过程:
$ ./password.py ../inputs/words.txt --seed 14
CrotalLeavesMeeredLogy
NatalBurrelTizzyOddman
UnbornSignerShodDehort
嗯,也许这些并不是最容易记住的!也许我们更应该对词汇的来源更加谨慎?我们正在从超过 200,000 个词汇的库中抽取,但平均说话者通常使用 20,000 到 40,000 个词汇。

(图片使用 xkcd.com 的许可)
我们可以通过从实际的英文文本中抽取,例如美国宪法,来生成更多易记的密码。请注意,为了以这种方式使用输入文本,我们需要移除任何标点符号,就像我们在之前的练习中所做的那样:
$ ./password.py --seed 8 ../inputs/const.txt
DulyHasHeadsCases
DebtSevenAnswerBest
ChosenEmitTitleMost
生成易记词汇的另一种策略可能是限制词汇池到更有趣的词性,例如从小说或诗歌等文本中提取的名词、动词和形容词。我包括了一个我编写的程序 harvest.py,它使用 Python 中的自然语言处理库 spaCy (spacy.io),可以将这些词性提取到文件中,我们可以将这些文件作为程序的输入。如果你想在自己的输入文件上使用这个程序,你需要确保首先安装这个模块:
$ python3 -m pip install spacy
我在几个文本上运行了 harvest.py 程序,并将输出放置在源仓库的 20_password 目录下的目录中。例如,这里是从美国宪法中找到的名词的输出:
$ ./password.py --seed 5 const/nouns.txt
TaxFourthYearList
TrialYearThingPerson
AidOrdainFifthThing
这里我们展示了仅使用纳撒尼尔·霍桑的《红字》中找到的动词生成的密码:
$ ./password.py --seed 1 scarlet/verbs.txt
CrySpeakBringHold
CouldSeeReplyRun
WearMeanGazeCast
这里还有一些是从威廉·莎士比亚的十四行诗中提取的形容词生成的密码:
$ ./password.py --seed 2 sonnets/adjs.txt
BoldCostlyColdPale
FineMaskedKeenGreen
BarrenWiltFemaleSeldom
如果这不足以生成一个足够强大的密码,我们还将提供一个--l33t标志,通过进一步混淆文本来提供:
-
将生成的密码通过第十二章的 ransom.py 算法进行传递
-
用给定的表格替换各种字符,就像我们在第四章的 jump_the_five.py 中做的那样
-
在末尾添加一个随机选择的标点符号字符
这是使用此编码的莎士比亚密码的样子:
$ ./password.py --seed 2 sonnets/adjs.txt --l33t
B0LDco5TLYColdp@l3,
f1n3M45K3dK3eNGR33N[
B4rReNW1LTFeM4l3seldoM/
在这个练习中,你将
-
将一个或多个输入文件作为位置参数
-
使用正则表达式删除非单词字符
-
通过某些最小长度要求过滤单词
-
使用集合创建唯一的列表
-
通过组合一定数量的随机选择的单词生成给定数量的密码
-
可选地使用我们之前编写的算法组合对文本进行编码
20.1 编写 password.py
我们应该将程序编写在 20_password 目录中,并将其命名为 password.py。它将通过从来自一个或多个输入文件的唯一单词集中随机选择一些 --num_words 数量的单词(默认,4)来创建一些 --num 数量的密码(默认,3)。由于它将使用 random 模块,程序还将接受一个随机的 --seed 参数,它应该是一个整数值,默认为 None。输入文件中的单词需要满足 --min_word_len 最小长度(默认,3)到 --max_word_len 最大长度(默认,6)的要求,在移除非字符后。
像往常一样,我们的首要任务是整理程序的输入。在程序能够通过 -h 或 --help 标志产生此用法并通过前八个测试之前,不要继续前进:
$ ./password.py -h
usage: password.py [-h] [-n num_passwords] [-w num_words] [-m minimum]
[-x maximum] [-s seed] [-l]
FILE [FILE ...]
Password maker
positional arguments:
FILE Input file(s)
optional arguments:
-h, --help show this help message and exit
-n num_passwords, --num num_passwords
Number of passwords to generate (default: 3)
-w num_words, --num_words num_words
Number of words to use for password (default: 4)
-m minimum, --min_word_len minimum
Minimum word length (default: 3)
-x maximum, --max_word_len maximum
Maximum word length (default: 6)
-s seed, --seed seed Random seed (default: None)
-l, --l33t Obfuscate letters (default: False)
输入文件中的单词将被首字母大写(第一个字母大写,其余小写),我们可以使用 str.title() 方法来实现这一点。这使得在输出中更容易看到和记住单个单词。请注意,我们可以调整每个密码中包含的单词数量以及生成的密码数量:
$ ./password.py --num 2 --num_words 3 --seed 9 sonnets/*
QueenThenceMasked
GullDeemdEven
--min_word_len 参数有助于过滤掉像“a”,“I”,“an”,“of”等较短、不太有趣的单词,而 --max_word_len 参数防止密码变得过长。如果你增加这些值,密码会有很大的变化:
$ ./password.py -n 2 -w 3 -s 9 -m 10 -x 20 sonnets/*
PerspectiveSuccessionIntelligence
DistillationConscienceCountenance
--l33t 标志是对“leet”-speak 的一种致敬,其中 31337 H4X0R 代表“ELITE HACKER”。1 当这个标志存在时,我们将以两种方式对每个密码进行编码。首先,我们将通过我们在第十二章中编写的 ransom() 算法传递单词:
$ ./ransom.py MessengerRevolutionImportune
MesSENGeRReVolUtIonImpoRtune
然后,我们将使用以下替换表以与第四章中相同的方式替换字符:
a => @
A => 4
O => 0
t => +
E => 3
I => 1
S => 5
最后,我们将使用 random.choice() 从 string.punctuation 中选择一个字符添加到末尾:
$ ./password.py --num 2 --num_words 3 --seed 9 --min_word_len 10 --max_word_len 20 sonnets/* --l33t
p3RsPeC+1Vesucces5i0niN+3lL1Genc3$
D1s+iLl@+ioNconsc1eNc3coun+eN@Nce^
图 20.1 显示了总结输入的字符串图。
20.1.1 创建唯一的单词列表
让我们从让我们的程序打印每个输入文件的名称开始:
def main():
args = get_args()
random.seed(args.seed) ①
for fh in args.file: ②
print(fh.name) ③
① 总是立即设置 random.seed(),因为它将全局影响 random 模块的所有操作。
② 遍历文件参数。
③ 打印文件名。

图 20.1 我们程序有许多可能的选项,但只需要一个或多个输入文件。输出将是不可破解的密码。
让我们用words.txt文件来测试它:
$ ./password.py ../inputs/words.txt
../inputs/words.txt
现在让我们用一些其他输入来试一试:
$ ./password.py scarlet/*
scarlet/adjs.txt
scarlet/nouns.txt
scarlet/verbs.txt
我们的首要目标是创建一个唯一的单词列表,我们可以用它来进行抽样。到目前为止,我们使用列表来保持有序集合,如字符串和数字。列表中的元素不必是唯一的。我们也使用字典来创建键/值对,字典的键是唯一的。由于我们不在乎值,我们可以将字典的每个键设置为某个任意值,比如1:
def main():
args = get_args()
random.seed(args.seed)
words = {} ①
for fh in args.file: ②
for line in fh: ③
for word in line.lower().split(): ④
words[word] = 1 ⑤
print(words)
① 创建一个空的字典来存储唯一的单词。
② 遍历文件。
③ 遍历文件的行。
④ 将行转换为小写并在空格处拆分为单词。
⑤ 将关键字[word]的键设置为 1 以表示我们看到了它。我们只使用字典来获取唯一的键。我们不在乎值,所以你可以使用任何你喜欢的值。
如果你在这份《美国宪法》上运行它,你应该看到一个相当长的单词列表(此处省略了一些输出):
$ ./password.py ../inputs/const.txt
{'we': 1, 'the': 1, 'people': 1, 'of': 1, 'united': 1, 'states,': 1, ...}
我可以注意到一个问题,即单词'states,'附有一个逗号。如果我们尝试在 REPL 中使用宪法的第一部分文本,我们可以看到这个问题:
>>> 'We the People of the United States,'.lower().split()
['we', 'the', 'people', 'of', 'the', 'united', 'states,']
我们如何去除标点符号?
20.1.2 清理文本
我们已经多次看到,在空格上拆分会留下标点符号,但在非单词字符上拆分可能会将缩写词如“Don’t”拆分成两部分。我们希望有一个函数可以对单词进行clean()。
首先,让我们想象一下对这个的测试。注意,在这个练习中,我会把所有的单元测试都放在一个名为unit.py的文件中,我可以使用pytest -xv unit.py来运行它。
这里是我们clean()函数的测试:
def test_clean():
assert clean('') == '' ①
assert clean("states,") == 'states' ②
assert clean("Don't") == 'Dont' ③
① 总是测试你的函数在无输入的情况下,以确保它做了合理的事情。
② 函数应该移除字符串末尾的标点符号。
③ 函数不应该将缩写词拆分成两部分。
我想将此应用于将每一行拆分为单词后返回的所有元素,map()是一个很好的方法来做这件事。我们经常在编写map()时使用lambda,如图 20.2 所示。

图 20.2 使用lambda编写map()以接受从字符串拆分得到的每个单词
我们实际上不需要为map()编写lambda,因为clean()函数期望一个单一参数,如图 20.3 所示。

图 20.3 不使用lambda编写map(),因为函数期望一个单一值
看看它是如何与代码集成的:
def main():
args = get_args()
random.seed(args.seed)
words = {}
for fh in args.file:
for line in fh:
for word in map(clean, line.lower().split()): ①
words[word] = 1
print(words)
① 使用map()将clean()函数应用于在空格上拆分行的结果。不需要lambda,因为clean()期望一个单一参数。
如果我们再次在《美国宪法》上运行它,我们可以看到'states'已经被固定:
$ ./password.py ../inputs/const.txt
{'we': 1, 'the': 1, 'people': 1, 'of': 1, 'united': 1, 'states': 1, ...}
我将把这个任务留给你来编写一个 clean() 函数,以满足那个测试。你可能可以使用列表推导式、filter() 或可能是一个正则表达式。选择权在你,只要它通过测试即可。
20.1.3 使用集合
对于我们的目的,有一个比 dict 更好的数据结构。它被称为 set,你可以把它想象成一个唯一的 list 或 dict 的键。以下是我们可以如何更改我们的代码以使用 set 来跟踪 唯一 单词的示例:
def main():
args = get_args()
random.seed(args.seed)
words = set() ①
for fh in args.file:
for line in fh:
for word in map(clean, line.lower().split()):
words.add(word) ②
print(words)
① 使用 set() 函数创建一个空集合。
② 使用 set.add() 向集合中添加一个值。
如果你现在运行这段代码,你会看到略微不同的输出,Python 会用花括号 ({}) 显示一个数据结构,这会让你想到 dict,但你也会注意到内容看起来更像一个 list(如图 20.4 所示):
$ ./password.py ../inputs/const.txt
{'', 'impartial', 'imposed', 'jared', 'levying', ...}

图 20.4 集合看起来像是字典和列表的交叉。
我们在这里使用集合,因为它们可以很容易地让我们保持一个唯一的单词列表,但集合的功能远不止于此。例如,你可以通过使用 set.intersection() 来找到两个列表之间的共享值:
>>> nums1 = set(range(1, 10))
>>> nums2 = set(range(5, 15))
>>> nums1.intersection(nums2)
{5, 6, 7, 8, 9}
你可以在 REPL 或在线文档中阅读 help(set) 来了解你可以用集合做所有惊人的事情。
20.1.4 过滤单词
如果我们再次查看我们的输出,我们会看到空字符串是第一个元素:
$ ./password.py ../inputs/const.txt
{'', 'impartial', 'imposed', 'jared', 'levying', ...}
我们需要一种方法来过滤掉不想要的值,比如太短的字符串。在第十四章中,我们研究了 filter() 函数,它是一个高阶函数,它接受两个参数:
-
一个接受一个元素并返回
True(如果元素应该被保留)或False(如果元素应该被排除)的函数 -
一些“可迭代”对象(如
list或map()),它产生一系列要过滤的元素
在我们的情况下,我们只想接受长度大于或等于 --min_word_len 参数且小于或等于 --max_word_len 参数的单词。在 REPL 中,我们可以使用 lambda 创建一个匿名函数,该函数接受一个 word 并进行这些比较。比较的结果是 True 或 False。只有长度在 3 到 6 之间的单词是被允许的,因此这起到了移除短且无趣单词的作用。记住,filter() 是惰性的,所以我必须使用 REPL 中的 list 函数来强制转换它以查看输出:
>>> shorter = ['', 'a', 'an', 'the', 'this']
>>> min_word_len = 3
>>> max_word_len = 6
>>> list(filter(lambda word: min_word_len <= len(word) <= max_word_len, shorter))
['the', 'this']
这个 filter() 也会移除会使我们的密码变得繁琐的较长的单词:
>>> longer = ['that', 'other', 'egalitarian', 'disequilibrium']
>>> list(filter(lambda word: min_word_len <= len(word) <= max_word_len, longer))
['that', 'other']
我们可以结合 filter() 的一种方法是通过创建一个 word_len() 函数来封装前面的 lambda。请注意,我将其定义在 main() 内部,是为了创建一个 闭包,因为我想要引用 args.min_word_len 和 args.max_word_len 的值:
def main():
args = get_args()
random.seed(args.seed)
words = set()
def word_len(word): ①
return args.min_word_len <= len(word) <= args.max_word_len
for fh in args.file:
for line in fh:
for word in filter(word_len, map(clean, line.lower().split())): ②
words.add(word)
print(words)
① 如果给定单词的长度在允许的范围内,这个函数将返回 True。
② 我们可以将 word_len(不带括号)用作 filter() 的函数参数。
我们可以再次尝试我们的程序,看看它会产生什么:
|
$ ./password.py ../inputs/const.txt
{'measures', 'richard', 'deprived', 'equal', ...}
在多个输入上尝试,例如来自《红字》的所有名词、形容词和动词:
$ ./password.py scarlet/*
{'walk', 'lose', 'could', 'law', ...}
![]() |
|---|
20.1.5 将单词大写
我们使用了line.lower()函数将所有输入转换为小写,但生成的密码需要每个单词都使用“标题大小写”,即第一个字母大写,其余单词小写。你能想出如何更改程序以生成这种输出吗?
$ ./password.py scarlet/*
{'Dark', 'Sinful', 'Life', 'Native', ...}
现在我们有了一种方法来处理任意数量的文件,以生成一个独特的标题化单词列表,其中已移除非单词字符,并已过滤掉太短或太长的单词。这只是在几行代码中打包了相当多的功能!
20.1.6 采样并创建密码
我们将使用random.sample()函数从我们的set中随机选择--num数量的单词来创建一个不可破解但易于记忆的密码。我们之前已经讨论过使用随机种子的重要性,以确保我们的“随机”选择是可重复的。同样重要的是,从其中采样的项目总是以相同的方式排序,以便做出相同的选择。如果我们对set使用sorted()函数,我们会得到一个排序后的list,这对于与random.sample()一起使用是完美的。
我们可以将此行添加到之前的代码中:
words = sorted(words)
print(random.sample(words, args.num_words))
现在我用The Scarlet Letter作为输入运行程序,我会得到一个可能有趣的密码的单词列表:
$ ./password.py scarlet/*
['Lose', 'Figure', 'Heart', 'Bad']
random.sample()的结果是一个list,您可以通过空字符串将其连接起来以创建一个新的密码:
>>> ''.join(random.sample(words, num_words))
'TokenBeholdMarketBegin'
您需要创建用户指示的密码数量,类似于我们在第九章中创建的一些侮辱性词汇。您将如何做到这一点?
20.1.7 l33t-ify
我们程序的最后一部分是创建一个l33t()函数,该函数将混淆密码。第一步是将密码转换为与我们在ransom.py中编写的相同算法。我将为这个创建一个ransom()函数,这里是unit.py中的测试:
def test_ransom():
state = random.getstate() ①
random.seed(1) ②
assert ransom('Money') == 'moNeY'
assert ransom('Dollars') == 'DOLlaRs'
random.setstate(state) ③
① 保存当前的全球状态。
② 将random.seed()设置为测试中已知的值。
③ 恢复状态。
我将把这个任务留给你来创建满足这个测试的函数。
注意:您可以通过运行pytest -xv unit.py来执行单元测试。程序将导入您的password.py文件中的各种函数进行测试。打开unit.py并检查它,以了解这是如何发生的。
接下来,我将根据以下表格替换一些字符。我建议您重新阅读第四章,看看您是如何做到这一点的:
a => @
A => 4
O => 0
t => +
E => 3
I => 1
S => 5
我编写了一个l33t()函数,该函数结合了ransom()函数和前面的替换操作,然后通过追加random.choice(string.punctuation)来添加一个标点符号。
这里是您可以使用来编写您函数的test_l33t()函数。它几乎与之前的测试完全相同,所以我将省略注释:
def test_l33t():
state = random.getstate()
random.seed(1)
assert l33t('Money') == 'moNeY{'
assert l33t('Dollars') == 'D0ll4r5`'
random.setstate(state)
20.1.8 将所有内容组合在一起
不透露结局,我想说的是,你需要非常小心包含 random 模块的运算顺序。在我的第一次实现中,当使用 --l33t 标志时,给定相同的种子会打印出不同的密码。以下是普通密码的输出:
$ ./password.py -s 1 -w 2 sonnets/*
EagerCarcanet
LilyDial
WantTempest
我原本期望得到的是完全相同的密码,只是进行了编码。以下是我程序产生的结果:
$ ./password.py -s 1 -w 2 sonnets/* --l33t
3@G3RC@rC@N3+{
m4dnes5iNcoN5+4n+|
MouTh45s15T4nCe^
第一个密码看起来没问题,但那另外两个是什么?我修改了我的代码来打印原始密码和 l33ted 版本:
$ ./password.py -s 1 -w 2 sonnets/* --l33t
3@G3RC@rC@N3+{ (EagerCarcanet)
m4dnes5iNcoN5+4n+| (MadnessInconstant)
MouTh45s15T4nCe^ (MouthAssistance)
random 模块使用全局状态来使其每个“随机”选择。在我的第一次实现中,我在选择第一个密码后立即使用 l33t() 函数修改新密码,从而修改了这个状态。因为 l33t() 函数也使用 random 函数,所以状态被改变,影响了下一个密码。我的解决方案是首先生成所有密码,然后如果需要,使用 l33t() 函数修改它们。
这些是你编写程序所需的所有部分。你有单元测试来帮助验证函数,你还有集成测试来确保你的程序作为一个整体工作。
20.2 解决方案
我希望你会使用你的程序来生成你的密码。务必与你的作者分享它们,特别是那些用于银行账户和最喜欢的购物网站的密码!
#!/usr/bin/env python3
"""Password maker, https://xkcd.com/936/"""
import argparse
import random
import re
import string
# --------------------------------------------------
def get_args():
"""Get command-line arguments"""
parser = argparse.ArgumentParser(
description='Password maker',
formatter_class=argparse.ArgumentDefaultsHelpFormatter)
parser.add_argument('file',
metavar='FILE',
type=argparse.FileType('rt'),
nargs='+',
help='Input file(s)')
parser.add_argument('-n',
'--num',
metavar='num_passwords',
type=int,
default=3,
help='Number of passwords to generate')
parser.add_argument('-w',
'--num_words',
metavar='num_words',
type=int,
default=4,
help='Number of words to use for password')
parser.add_argument('-m',
'--min_word_len',
metavar='minimum',
type=int,
default=3,
help='Minimum word length')
parser.add_argument('-x',
'--max_word_len',
metavar='maximum',
type=int,
default=6,
help='Maximum word length')
parser.add_argument('-s',
'--seed',
metavar='seed',
type=int,
help='Random seed')
parser.add_argument('-l',
'--l33t',
action='store_true',
help='Obfuscate letters')
return parser.parse_args()
# --------------------------------------------------
def main():
args = get_args()
random.seed(args.seed) ①
words = set() ②
def word_len(word): ③
return args.min_word_len <= len(word) <= args.max_word_len
for fh in args.file: ④
for line in fh: ⑤
for word in filter(word_len, map(clean, line.lower().split())): ⑥
words.add(word.title()) ⑦
words = sorted(words) ⑧
passwords = [ ⑨
''.join(random.sample(words, args.num_words)) for _ in range(args.num)
]
if args.l33t: ⑩
passwords = map(l33t, passwords) ⑪
print('\n'.join(passwords)) ⑫
# --------------------------------------------------
def clean(word): ⑬
"""Remove non-word characters from word"""
return re.sub('[^a-zA-Z]', '', word) ⑭
# --------------------------------------------------
def l33t(text): ⑮
"""l33t"""
text = ransom(text) ⑯
xform = str.maketrans({ ⑰
'a': '@', 'A': '4', 'O': '0', 't': '+', 'E': '3', 'I': '1', 'S': '5'
})
return text.translate(xform) + random.choice(string.punctuation) ⑱
# --------------------------------------------------
def ransom(text): ⑲
"""Randomly choose an upper or lowercase letter to return"""
return ''.join( ⑳
map(lambda c: c.upper() if random.choice([0, 1]) else c.lower(), text))
# --------------------------------------------------
if __name__ == '__main__':
main()
① 将 random.seed() 设置为给定的值或默认的 None,这与不设置种子相同。
② 创建一个空集合来存储我们从文本中提取的所有唯一单词。
③ 为 filter() 创建一个 word_len() 函数,如果单词的长度在允许的范围内则返回 True,否则返回 False。
④ 遍历每个打开的文件句柄。
⑤ 遍历文件句柄中的每一行文本。
⑥ 遍历由分割小写行并使用 clean() 函数移除非单词字符以及过滤出可接受长度的单词生成的每个单词。
⑦ 在将单词添加到集合之前将其转换为标题大小写。
⑧ 使用 sorted() 函数将单词排序到一个新的列表中。
⑨ 使用列表推导式和 range() 创建正确数量的密码。由于我不需要 range() 的实际值,我可以使用 _ 来忽略它。
⑩ 检查 args.l33t 标志是否为 True。
⑪ 使用 map() 运行所有密码通过 l33t() 函数,以产生一个新的密码列表。在这里调用 l33t() 函数是安全的。如果我们使用它在列表推导式中,它将改变随机模块的全局状态,从而改变后续的密码。
⑫ 打印在新行上连接的密码。
⑬ 定义一个 clean() 函数来清理单词。
⑭ 使用正则表达式将非英文字母字符替换为空字符串。
⑮ 定义一个 l33t() 函数来转换单词。
⑯ 使用 ransom() 函数随机大写字母。
⑰ 创建一个用于字符替换的转换表/字典。
⑱ 使用 str.translate()函数执行替换,并附加一个随机的标点符号。
⑲ 定义一个函数,用于从第十二章的 ransom()算法。
⑳ 返回一个新字符串,该字符串通过随机将每个单词中的字母大写或小写来创建。
20.3 讨论
希望您觉得这个程序具有挑战性和趣味性。get_args()中没有什么新内容,但,再次强调,大约一半的代码行都只在这个函数中。我觉得这表明正确定义和验证程序输入是多么重要!
现在,让我们继续讨论辅助函数。
20.3.1 清理文本
我选择使用正则表达式来删除任何不在大小写英文字符集合之外的字符:
def clean(word):
"""Remove non-word characters from word"""
return re.sub('[^a-zA-Z]', '', word) ①
① re.sub()函数将用第二个参数给出的值替换在给定文本中找到的与模式(第一个参数)匹配的任何文本。
回想第十八章,我们可以编写字符类[a-zA-Z]来定义 ASCII 表中由这两个范围限定的字符。然后我们可以通过在该类内部放置一个感叹号(^)来否定或补充该类,所以[^a-zA-Z]可以读作“任何不匹配 a 到 z 或 A 到 Z 的字符。”
在 REPL 中看到它的实际操作可能更容易理解。在下面的例子中,从文本“A1b*C!d4”中只会留下字母“AbCd”:
>>> import re
>>> re.sub('[^a-zA-Z]', '', 'A1b*C!d4')
'AbCd'
如果唯一的目标是匹配 ASCII 字母,可以通过查找string.ascii_letters中的成员资格来解决它:
>>> import string
>>> text = 'A1b*C!d4'
>>> [c for c in text if c in string.ascii_letters]
['A', 'b', 'C', 'd']
可以使用带有守卫器的列表推导式来编写filter():
>>> list(filter(lambda c: c in string.ascii_letters, text))
['A', 'b', 'C', 'd']
这两种非正则表达式版本在我看来似乎需要更多的努力。此外,如果函数需要更改以允许,例如,数字和几个特定的标点符号,正则表达式版本就变得更容易编写和维护。
20.3.2 一笔巨款
ransom()函数直接从第十二章的 ransom.py 程序中提取,所以关于它没有太多可说的,除了,嘿,看看我们走了多远!原本一个整章的想法现在在一个更长、更复杂的程序中只是一行:
def ransom(text):
"""Randomly choose an upper or lowercase letter to return"""
return ''.join( ①
map(lambda c: c.upper() if random.choice([0, 1]) else c.lower(), text)) ②
① 使用空字符串将 map()的结果列表连接起来,创建一个新的字符串。
② 使用 map()遍历文本中的每个字符,并根据“抛硬币”的结果选择字符的大写或小写版本,使用 random.choice()在“真值”(1)或“假值”(0)之间进行选择。
20.3.3 如何 l33t()
l33t()函数基于ransom()并添加了来自第四章的直接文本替换。我喜欢那个程序的str.translate()版本,所以我在这里再次使用了它:
def l33t(text):
"""l33t"""
text = ransom(text) ①
xform = str.maketrans({ ②
'a': '@', 'A': '4', 'O': '0', 't': '+', 'E': '3', 'I': '1', 'S': '5'
})
return text.translate(xform) + random.choice(string.punctuation) ③
① 随机大写给定的文本。
② 从给定的字典中创建一个转换表,描述如何将一个字符修改为另一个字符。任何未列在该字典键中的字符将被忽略。
③ 使用 str.translate()方法进行所有字符替换。使用 random.choice()从 string.punctuation 中选择一个额外的字符附加到末尾。
20.3.4 处理文件
要使用这些函数,我们需要创建一个包含我们输入文件中所有单词的唯一集合。我写这段代码时既关注性能也关注风格:
words = set()
for fh in args.file: ①
for line in fh: ②
for word in filter(word_len, map(clean, line.lower().split())): ③
words.add(word.title()) ④
① 遍历每个打开的文件句柄。
② 使用 for 循环逐行读取文件句柄,而不是使用像 fh.read()这样的方法,它会一次性读取文件的全部内容。
③ 阅读此代码需要从末尾开始,我在那里按空格分割 line.lower()。str.split()中的每个单词都进入 clean(),然后必须通过 filter()函数。
④ 在将单词添加到集合之前将其转换为标题格式。
图 20.5 显示了该for行的示意图。
-
line.lower()将返回line的小写版本。 -
str.split()方法将根据空白字符分割文本以返回单词。 -
每个单词都输入到
clean()函数中,以删除任何不在英语字母表中的字符。 -
清洗后的单词通过
word_len()函数进行过滤。 -
生成的
word已经过转换、清洗和过滤。

图 20.5 展示了各种函数操作顺序的可视化。
如果你不喜欢map()和filter()函数,你可以这样重写代码:
words = set()
for fh in args.file: ①
for line in fh: ②
for word in line.lower().split(): ③
word = map(clean) ④
if args.min_word_len <= len(word) <= args.max_word_len: ⑤
words.add(word.title() ⑥
① 遍历每个打开的文件句柄。
② 遍历文件句柄的每一行。
③ 遍历从按空格分割小写行得到的每个“单词”。
④ 移除不需要的字符。
⑤ 检查单词是否为可接受的长度。
⑥ 将标题格式的单词添加到集合中。
无论你选择如何处理文件,到这一点你应该有一个包含输入文件中所有唯一、标题格式单词的完整set。
20.3.5 抽样和创建密码
如前所述,为了验证我们正在做出一致的选择,对words进行排序对于我们的测试至关重要。如果你只想随机选择而不关心测试,你不需要担心排序——但如果你不测试,那么你就是一个道德上有缺陷的人,所以不要想这个!我选择使用sorted()函数,因为没有其他方法可以排序set:
words = sorted(words) ①
① 没有 set.sort()函数。集合在 Python 内部是有序的。对集合调用 sorted()将创建一个新的、排序后的列表。
我们需要创建一定数量的密码,我认为使用带有range()的for循环可能最简单。在我的代码中,我使用了for _ in range(...),就像在第九章中一样,因为我每次通过循环不需要知道值。下划线(_)是一种表示你正在忽略值的方式。如果你想,可以说for i in range(...),但一些 linters 可能会抱怨,如果看到你的代码声明了变量i但从未使用它。这可能是真正的错误,所以最好使用_来表明你打算忽略这个值。
这里是导致我之前提到的错误的代码的第一种写法,即使我使用了相同的随机种子,也会选择不同的密码。你能找到错误吗?
for _ in range(args.num): ①
password = ''.join(random.sample(words, args.num_words)) ②
print(l33t(password) if args.l33t else password) ③
① 遍历要创建的 args.num 个密码。
② 每个密码将基于从单词中随机采样生成,我将选择 args.num_words 中给出的值。random.sample()函数返回一个单词列表,我使用 str.join()在空字符串上连接这些单词以创建一个新的字符串。
③ 如果 args.l33t 标志为 True,我们将打印密码的 l33t 版本;否则,我将按原样打印密码。这是错误!在这里调用 l33t()修改了随机模块使用的全局状态,所以下次我调用 random.sample()时,我会得到不同的样本。
解决方案是将生成密码和可能修改密码的关注点分开:
passwords = [ ①
''.join(random.sample(words, args.num_words)) for _ in range(args.num)
]
if args.l33t: ②
passwords = map(l33t, passwords)
print('\n'.join(passwords)) ③
① 使用列表推导式遍历 range(args.num)以生成正确数量的密码。
② 如果 args.leet 标志为 True,使用 l33t()函数修改密码。
③ 打印以换行符连接的密码。
20.4 进一步学习
-
l33t()函数的替换部分会更改每个可用的字符,这可能会使密码难以记住。最好只修改密码的约 10%,就像我们在第十章的“电话”练习中更改输入字符串那样。 -
创建结合你所学其他技能的程序。比如,可能是一个歌词生成器,它会随机选择你最喜欢的乐队歌曲文件中的歌词,然后将文本编码成第十五章所述的形式,接着将所有元音字母都改为一个元音字母,就像第八章中做的那样,最后像第五章中那样大声喊出来?
摘要
|
-
set是一个唯一的值集合。集合可以与其他集合交互以创建差异、交集、并集等。 -
使用
random模块改变操作顺序可能会改变程序输出,因为random模块的全局状态可能会受到影响。 -
短小、经过测试的函数可以组合成更复杂、经过测试的程序。在这里,我们以简洁、强大的表达方式结合了之前练习中的许多想法。
![]() |
|---|
1 请参阅“Leet”维基百科页面(en.wikipedia.org/wiki/Leet)或 Cryptii 翻译器cryptii.com/.
21 井字棋:探索状态
| 我最喜欢的电影之一是 1983 年上映的《战争游戏》,由马修·布罗德里克主演,他的角色大卫是一个喜欢破解从学校成绩册到可能发射洲际弹道导弹的五角大楼服务器的年轻黑客。情节的核心是井字棋游戏,这是一个如此简单的游戏,通常以平局结束。 | ![]() |
|---|
在电影中,大卫与人工智能(AI)代理约书亚互动,约书亚能够玩很多很好的游戏,比如国际象棋。大卫更愿意与约书亚玩全球热核战争游戏。最终,大卫意识到约书亚正在使用战争游戏的模拟来欺骗美国军方对苏联进行核首先打击。理解相互确保摧毁(MAD)学说后,大卫要求约书亚自己玩井字棋,这样他就可以探索那些永远无法取得胜利的游戏的无意义。经过数百或数千轮都以平局结束,约书亚得出结论,“唯一的胜利策略是不玩”,这时约书亚停止了试图摧毁地球,并建议他们可以玩“一场美好的国际象棋游戏”。
我假设你已经知道井字棋游戏,但为了以防你的童年错过了无数与朋友玩的游戏,我们将简要回顾。游戏从一个 3x3 的方格网格开始。有两个玩家轮流在单元格中标记 X 和 O。一个玩家通过在水平、垂直或对角线上放置标记在任意三个单元格中获胜。这通常是不可能的,因为每个玩家通常都会利用他们的移动来阻止对手可能的胜利。
我们将在最后两章中编写井字棋。我们将探讨表示和跟踪程序状态的想法,这是一种思考程序组件随时间变化的方式。例如,我们将从一个空白的棋盘开始,第一个行动的玩家是 X。玩家轮流在单元格中标记 X 和 O,每一轮后,棋盘上的两个单元格将被两位玩家占据。我们需要记录这些移动以及更多内容,以便在任何时刻,我们都能知道游戏的状态。
如果你还记得,在第二十章中,random模块的隐藏状态证明是一个问题,我们探索的一个早期解决方案产生了不一致的结果,这取决于使用该模块的操作顺序。在这个练习中,我们将思考如何使我们的游戏状态及其任何更改明确化。
在本章中,我们将编写一个程序,该程序只玩一次游戏;然后在下一章中,我们将扩展程序以处理完整游戏。这个版本的程序将得到一个表示游戏过程中任何时刻棋盘状态的字符串。默认情况下,是游戏开始时空棋盘的状态,在任一玩家移动之前。程序还可以得到一个要添加到该棋盘上的移动。程序将打印棋盘的图片,并在移动后报告是否有赢家。
对于这个程序,我们需要在我们的状态中跟踪至少两个想法:
-
棋盘,标识哪个玩家标记了网格中的哪个方格
-
如果有赢家
对于下一个版本,我们将编写一个交互式游戏版本,其中我们需要通过完整的一局井字棋来跟踪和更新状态中的更多项目。
在这个练习中,你将
-
考虑如何使用字符串和列表等元素来表示程序的状态方面
-
在代码中强制执行游戏的规则,例如防止玩家在已被占用的单元格中玩游戏
-
使用正则表达式验证初始棋盘
-
使用
and和or将布尔值的组合减少到单个值 -
使用列表的列表来找到获胜的棋盘
-
使用
enumerate()函数迭代带有索引和值的list
21.1 编写 tictactoe.py
你将在 21_tictactoe 目录下创建一个名为 tictactoe.py 的程序。像往常一样,我建议你使用 new.py 或 template.py 来启动程序。让我们来讨论程序的参数。
棋盘的初始状态将来自-b或--board选项,该选项描述了哪个单元格被哪个玩家占据。由于有九个单元格,我们将使用一个九个字符长的字符串,只由字符X和O组成,或者用句点(.)表示单元格是开放的。默认棋盘将是一个由九个点组成的字符串。当你显示棋盘时,你将显示玩家的标记或单元格的编号,从一到九。在游戏的下一个版本中,这个数字将被玩家用来识别他们的移动。由于默认棋盘没有赢家,程序应打印“没有赢家”:
$ ./tictactoe.py
-------------
| 1 | 2 | 3 |
-------------
| 4 | 5 | 6 |
-------------
| 7 | 8 | 9 |
-------------
No winner.
--board选项将描述哪个单元格应该被哪个玩家标记,字符串中的位置描述了不同的单元格,从 1 到 9 递增。在字符串X.O..O..X中,位置 1 和 9 被“X”占据,位置 3 和 6 被“O”占据(见图 21.1)。

图 21.1 棋盘由九个字符描述棋盘的九个单元格。
这是程序如何渲染该网格的:
$ ./tictactoe.py -b X.O..O..X
-------------
| X | 2 | O |
-------------
| 4 | 5 | O |
-------------
| 7 | 8 | X |
-------------
No winner.
我们还可以通过传递-c或--cell选项 1-9 和-p或--player选项“X”或“O”来修改给定的--board。例如,我们可以这样标记第一个单元格为“X”:
$ ./tictactoe.py --cell 1 --player X
-------------
| X | 2 | 3 |
-------------
| 4 | 5 | 6 |
-------------
| 7 | 8 | 9 |
-------------
No winner.
如果有赢家,应该热情地宣布:
$ ./tictactoe.py -b XXXOO....
-------------
| X | X | X |
-------------
| O | O | 6 |
-------------
| 7 | 8 | 9 |
-------------
X has won!
如同往常,我们将使用测试套件来确保我们的程序正常工作。图 21.2 显示了字符串图。

图 21.2 我们的井字棋程序将使用棋盘、玩家和单元格进行一回合的游戏。它应该打印棋盘和胜者。
21.1.1 验证用户输入
需要进行相当多的输入验证。--board 需要确保任何参数恰好是 9 个字符,并且仅由 X、O 和 . 组成:
$ ./tictactoe.py --board XXXOOO..
usage: tictactoe.py [-h] [-b board] [-p player] [-c cell]
tictactoe.py: error: --board "XXXOOO.." must be 9 characters of ., X, O
同样,--player 只能是 X 或 O:
$ ./tictactoe.py --player A --cell 1
usage: tictactoe.py [-h] [-b board] [-p player] [-c cell]
tictactoe.py: error: argument -p/--player: \
invalid choice: 'A' (choose from 'X', 'O')
--cell 只能是一个从 1 到 9 的整数:
$ ./tictactoe.py --player X --cell 10
usage: tictactoe.py [-h] [-b board] [-p player] [-c cell]
tictactoe.py: error: argument -c/--cell: \
invalid choice: 10 (choose from 1, 2, 3, 4, 5, 6, 7, 8, 9)
--player 和 --cell 必须同时存在,或者两者都不存在:
$ ./tictactoe.py --player X
usage: tictactoe.py [-h] [-b board] [-p player] [-c cell]
tictactoe.py: error: Must provide both --player and --cell
最后,如果指定的 --cell 已经被 X 或 O 占据,程序应该报错:
$ ./tictactoe.py --player X --cell 1 --board X..O.....
usage: tictactoe.py [-h] [-b board] [-p player] [-c cell]
tictactoe.py: error: --cell "1" already taken
我建议你将这些错误检查放入 get_args() 中,这样你就可以使用 parser.error() 抛出错误并停止程序。
21.1.2 更改棋盘
初始棋盘,一旦验证通过,将描述哪些单元格被哪个玩家占据。可以通过添加 --player 和 --cell 参数来更改这个棋盘。虽然直接传递已经更改的 --board 可能看起来很愚蠢,但这对于编写交互式版本是必要的练习。
如果你将 board 表示为一个 str 值,例如 'XX.O.O..X',并且你需要将单元格 3 改为 X,例如,你将如何做?首先,单元格 3 并不在给定的 board 的 index 3 位置——索引比单元格号少一个。另一个问题是 str 是不可变的。就像第十章的电话程序一样,你需要想出一个方法来修改棋盘值中的一个字符。
21.1.3 打印棋盘
一旦你有了棋盘,你需要使用 ASCII 字符来格式化它,以创建一个网格。我建议你创建一个名为 format_board() 的函数,该函数接受 board 字符串作为参数,并返回一个使用破折号 (-) 和垂直管道 (|) 创建表格的 str。我已经提供了一个包含以下测试的 unit.py 文件,用于默认的、未被占据的网格:
def test_board_no_board():
"""makes default board"""
board = """ ①
-------------
| 1 | 2 | 3 |
-------------
| 4 | 5 | 6 |
-------------
| 7 | 8 | 9 |
-------------
""".strip()
assert format_board('.' * 9) == board ②
① 使用三引号,因为字符串中嵌入了换行。最后的 str.strip() 调用将移除用于格式化代码的尾部换行符。
② 如果你将一个字符串乘以一个整数值,Python 将重复给定的字符串该次数。这里我们创建了一个由九个点组成的字符串作为 format_board() 的输入。我们期望返回的是一个格式化后的空棋盘,如下所示。
现在尝试使用其他组合格式化一个棋盘。以下是我编写的一个你可能想使用的测试,但当然你也可以编写自己的:
def test_board_with_board():
"""makes board"""
board = """
-------------
| 1 | 2 | 3 |
-------------
| O | X | X |
-------------
| 7 | 8 | 9 |
-------------
""".strip()
assert format_board('...OXX...') == board ①
① 给定的棋盘应该第一行和第三行是开放的,第二行是“OXX”。
测试棋盘的每一种可能组合都是不切实际的。当你编写测试时,你通常会依赖于对代码进行抽查。这里我正在检查空棋盘和非空棋盘。假设函数可以处理这两个参数,它应该可以处理任何其他参数。
21.1.4 确定赢家
一旦你验证了输入并打印了棋盘,你的最后一个任务是如果有的话宣布赢家。我选择编写一个名为find_winner()的函数,如果其中一个玩家是赢家,则返回X或O,如果没有赢家,则返回None。为了测试这个,我编写了每个可能的获胜棋盘,以测试我的函数和两个玩家的值。你可以使用这个测试:
def test_winning():
"""test winning boards"""
wins = [('PPP......'), ('...PPP...'), ('......PPP'), ('P..P..P..'), ①
('.P..P..P.'), ('..P..P..P'), ('P...P...P'), ('..P.P.P..')]
for player in 'XO': ②
other_player = 'O' if player == 'X' else 'X' ③
for board in wins: ④
board = board.replace('P', player) ⑤
dots = [i for i in range(len(board)) if board[i] == '.'] ⑥
mut = random.sample(dots, k=2) ⑦
test_board = ''.join([ ⑧
other_player if i in mut else board[i]
for i in range(len(board))
])
assert find_winner(test_board) == player ⑨
① 这是一个列表,如果棋盘索引被同一玩家占据,就会赢。
② 检查 X 和 O 两个玩家。
③ 确定 X 或 O 的对立玩家。
④ 遍历每个获胜组合。
⑤ 将给定棋盘中的所有 P(代表“玩家”)值更改为我们要检查的玩家。
⑥ 找到空格的索引(用点表示)。
⑦ 随机抽取两个空格。我们将对这些空格进行变异,所以我称它们为 mut。
⑧ 将棋盘改变,将两个选定的 mut 单元格更改为 other_player。
⑨ 断言 find_winner()将确定这个棋盘为给定玩家赢得比赛。
我还想要确保我不会错误地声称一个输掉的棋盘是赢的,所以我写了以下测试来确保在没有赢家时返回None:
def test_losing():
"""test losing boards"""
losing_board = list('XXOO.....') ①
for _ in range(10): ②
random.shuffle(losing_board) ③
assert find_winner(''.join(losing_board)) is None ④
① 无论这个棋盘如何排列,它都不能赢,因为每个玩家只有两个标记。
② 运行 10 次测试。
③ 将输掉的棋盘打乱成另一种配置。
④ 断言无论棋盘如何排列,我们都不会找到赢家。
如果你选择与我相同的函数名,你可以运行pytest -xv unit.py来运行我编写的单元测试。如果你希望编写不同的函数,你可以在你的tictactoe.py文件内或另一个单元文件中创建自己的单元测试。
在打印棋盘后,务必打印“{Winner}赢得了比赛!”或“没有赢家”取决于结果。好了,你有了命令,所以开始行动吧!
21.2 解决方案
我们正在朝着下一章的完整、交互式游戏迈出第一步。现在我们需要巩固一些基本知识,即如何进行一回合的游戏。制作困难的程序迭代是很好的,你从尽可能简单开始,然后逐渐添加功能来构建更复杂的概念。
#!/usr/bin/env python3
"""Tic-Tac-Toe"""
import argparse
import re
# --------------------------------------------------
def get_args():
"""Get command-line arguments"""
parser = argparse.ArgumentParser(
description='Tic-Tac-Toe',
formatter_class=argparse.ArgumentDefaultsHelpFormatter)
parser.add_argument('-b', ①
'--board',
help='The state of the board',
metavar='board',
type=str,
default='.' * 9)
parser.add_argument('-p', ②
'--player',
help='Player',
choices='XO',
metavar='player',
type=str,
default=None)
parser.add_argument('-c', ③
'--cell',
help='Cell 1-9',
metavar='cell',
type=int,
choices=range(1, 10),
default=None)
args = parser.parse_args()
if any([args.player, args.cell]) and not all([args.player, args.cell]): ④
parser.error('Must provide both --player and --cell')
if not re.search('^[.XO]{9}$', args.board): ⑤
parser.error (f'--board "{args.board}" must be 9 characters of ., X, O')
if args.player and args.cell and args.board[args.cell - 1] in 'XO': ⑥
parser.error(f'--cell "{args.cell}" already taken')
return args
# --------------------------------------------------
def main():
"""Make a jazz noise here"""
args = get_args()
board = list(args.board) ⑦
if args.player and args.cell: ⑧
board[args.cell - 1] = args.player ⑨
print(format_board(board)) ⑩
winner = find_winner(board) ⑪
print(f'{winner} has won!' if winner else 'No winner.') ⑫
# --------------------------------------------------
def format_board(board): ⑬
"""Format the board"""
cells = [str(i) if c == '.' else c for i, c in enumerate(board, 1)] ⑭
bar = '-------------'
cells_tmpl = '| {} | {} | {} |'
return '\n'.join([ ⑮
cells_tmpl.format(*cells[:3]), bar,
cells_tmpl.format(*cells[3:6]), bar,
cells_tmpl.format(*cells[6:]), bar
])
# --------------------------------------------------
def find_winner(board): ⑯
"""Return the winner"""
winning = [[0, 1, 2], [3, 4, 5], [6, 7, 8], [0, 3, 6], [1, 4, 7],
[2, 5, 8], [0, 4, 8], [2, 4, 6]] ⑰
for player in ['X', 'O']: ⑱
for i, j, k in winning: ⑲
combo = [board[i], board[j], board[k]] ⑳
if combo == [player, player, player]: ㉑
return player ㉒
# --------------------------------------------------
if __name__ == '__main__':
main()
① 程序默认的棋盘是九个点。如果你使用乘法运算符(*)与一个字符串值和一个整数(任意顺序)相乘,结果是字符串值重复那么多次。所以“'.' * 9”将产生'.........'。
② --player 必须是 X 或 O,可以使用 choices 进行验证。
③ --cell 必须是从 1 到 9 的整数,可以使用 type=int 和 choices=range(1, 10)进行验证,记住上限(10)不包括在内。
④ any()和 all()的组合是一种测试两个参数是否都存在或都不存在的方法。
⑤ 使用正则表达式检查--board 是否由恰好九个有效字符组成。
⑥ 如果--player 和--cell 都存在且有效,验证棋盘中的单元格目前没有被占据。
⑦ 由于我们可能需要修改棋盘,因此将其转换为列表是最容易的。
⑧ 如果单元格和玩家都是“真值”,则修改棋盘。由于在 get_args()中验证了参数,因此在这里使用它们是安全的。也就是说,我不会意外地分配一个超出范围的索引值,因为我已经花时间检查单元格值是否可接受。
⑨ 由于单元格从 1 开始编号,因此从单元格中减去 1 以更改棋盘中的正确索引。
⑩ 打印棋盘。
⑪ 在棋盘中寻找胜者。
⑫ 打印游戏结果。如果某个玩家获胜,find_winner()函数返回 X 或 O,如果没有胜者,则返回None。
定义一个用于格式化棋盘的函数。该函数不使用 print()打印棋盘,因为这会使测试变得困难。函数返回一个新的字符串值,该值可以被打印或测试。
⑭ 遍历棋盘中的单元格,并决定是否打印玩家,如果单元格被占用,或者单元格编号,如果它没有被占用。
⑮ 函数的返回值是由所有网格行通过换行符连接而成的新字符串。
⑯ 定义一个函数,该函数返回胜者或如果没有胜者则返回None。同样,该函数不使用 print()打印胜者,而只返回可以打印或测试的答案。
⑰ 有八个获胜的棋盘,这些棋盘定义为八个需要由同一玩家占用的单元格列表。请注意,我选择在这里表示实际的零偏移索引值,而不是我期望的用户提供的基于 1 的值。
⑱ 遍历两个玩家,X 和 O。
⑲ 遍历每个获胜的单元格组合,将它们解包到变量 i、j 和 k 中。
⑳ 为每个 i、j 和 k 创建一个组合,该组合是棋盘的值。
㉑ 检查组合是否在每个位置上都是同一玩家。
㉒ 如果这是真的,则返回玩家。如果这些组合中的任何一个永远不会为真,我们将不返回值地退出函数,因此默认返回None。
21.2.1 验证参数和修改棋盘
大多数验证可以通过有效地使用argparse来处理。--player和--cell选项都可以通过choices选项来处理。花时间欣赏any()和all()在此代码中的使用是值得的:
if any([args.player, args.cell]) and not all([args.player, args.cell]):
parser.error('Must provide both --player and --cell')
我们可以在 REPL 中玩这些函数。any()函数与在布尔值之间使用or相同:
>>> True or False or True
True
如果给定list中的任何项是“真值”,则整个表达式将评估为True:
>>> any([True, False, True])
True
如果cell是一个非零值,并且player不是空字符串,它们都是“真值”:
>>> cell = 1
>>> player = 'X'
>>> any([cell, player])
True
all()函数与在list的所有元素之间使用and相同,因此为了使整个表达式为True,所有元素都需要是“真值”:
>>> cell and player
'X'
为什么返回X?它返回最后一个“真值”,即player值,因此如果我们反转参数,我们将得到cell值:
>>> player and cell
1
如果我们使用 all(),它将评估 and 值的真值,这将返回 True:
>>> all([cell, player])
True
我们正在试图弄清楚用户是否只提供了 --player 和 --cell 这两个参数中的一个,因为我们需要两者,或者我们都不需要。所以我们将 cell 假设为 None(默认值),而 player 是 X。确实,any() 这些值中的任何一个都是“真值”:
>>> cell = None
>>> player = 'X'
>>> any([cell, player])
True
但它们 两个 都不是:
>>> all([cell, player])
False
所以当我们 and 这两个表达式时,它们返回 False,
>>> any([cell, player]) and all([cell, player])
False
因为这等同于说:
>>> True and False
False
--board 的默认值是九个点,我们可以使用正则表达式来验证它是否正确:
>>> board = '.' * 9
>>> import re
>>> re.search('^[.XO]{9}$', board)
<re.Match object; span=(0, 9), match='.........'>
我们的正则表达式通过使用 [.XO] 创建了一个由点(.)、“X”和“O”组成的字符类。{9} 表示必须有恰好 9 个字符,^ 和 $ 字符分别将表达式锚定到字符串的开始和结束(见图 21.3)。

图 21.3 我们可以使用正则表达式精确地描述一个有效的 --board。
你可以使用 all() 的魔法手动验证这一点:
-
board的长度是否正好是 9 个字符? -
是否每个字符都是允许的字符之一?
这是一种编写方式:
>>> board = '...XXXOOO'
>>> len(board) == 9 and all([c in '.XO' for c in board])
True
all() 部分正在检查这一点:
>>> [c in '.XO' for c in board]
[True, True, True, True, True, True, True, True, True]
由于 board 中的每个字符 c(“单元格”)都在允许的字符集中,所有比较都是 True。如果我们更改其中一个字符,就会显示 False:
>>> board = '...XXXOOA'
>>> [c in '.XO' for c in board]
[True, True, True, True, True, True, True, True, False]
all() 表达式中的任何 False 值都会返回 False:
>>> all([c in '.XO' for c in board])
False
最后的验证检查即将设置的 --cell 为 --player 是否已被占用:
if args.player and args.cell and args.board[args.cell - 1] in 'XO':
parser.error(f'--cell "{args.cell}" already taken')
因为 --cell 从 1 开始计数而不是 0,所以当我们用它作为 --board 参数的索引时,我们必须减去 1。给定以下输入,第一个单元格已被设置为 X,现在 O 想要相同的单元格:
>>> board = 'X........'
>>> cell = 1
>>> player = 'O'
我们可以询问 board 中 cell - 1 的值是否已经被设置:
>>> board[cell - 1] in 'XO'
True
或者,你也可以检查该位置是否 不是 点:
>>> boards[cell - 1] != '.'
True
验证所有输入相当累人,但这是确保游戏正确进行的唯一方法。
在 main() 函数中,如果存在单元格和玩家的参数,我们可能需要更改游戏的 board。我决定将 board 变成一个 list,正是因为我可能需要以这种方式更改它:
if player and cell:
board[cell - 1] = player
21.2.2 格式化网格
现在是时候创建网格了。我选择创建一个返回字符串值的函数,这样我就可以测试它,而不是直接打印网格。这是我的版本:
def format_board(board):
"""Format the board"""
cells = [str(i) if c == '.' else c for i, c in enumerate(board, start=1)] ①
bar = '-------------'
cells_tmpl = '| {} | {} | {} |'
return '\n'.join([
bar,
cells_tmpl.format(*cells[:3]), bar, ②
cells_tmpl.format(*cells[3:6]), bar,
cells_tmpl.format(*cells[6:]), bar
])
① 我使用列表推导式通过 enumerate() 函数遍历 board 的每个位置和字符。因为我更愿意从索引位置 1 开始计数而不是 0,所以我使用了 start=1 选项。如果字符是点,我想打印位置作为单元格编号;否则,我打印字符,这将可能是 X 或 O。
② 星号,或“splat” (*),是展开列表切片操作返回的列表的简写,以便 str.format() 函数可以使用。
*cell[:3]的“展开”语法是编写代码的更简短方式,如下所示:
return '\n'.join([
bar,
cells_tmpl.format(cells[0], cells[1], cells[2]), bar,
cells_tmpl.format(cells[3], cells[4], cells[5]), bar,
cells_tmpl.format(cells[6], cells[7], cells[8]), bar
])
enumerate()函数返回一个包含列表中每个元素的索引和值的元组列表(见图 21.4)。由于它是一个惰性函数,我必须在 REPL 中使用list()函数来查看值:
>>> board = 'XX.O.O...'
>>> list(enumerate(board))
[(0, 'X'), (1, 'X'), (2, '.'), (3, 'O'), (4, '.'), (5, 'O'), (6, '.'), (7, '.'), (8, '.')]

图 21.4 enumerate()函数将返回一系列中的项的索引和值。默认情况下,初始索引为 0。
在这种情况下,我宁愿从 1 开始计数,所以我可以使用start=1选项:
>>> list(enumerate(board, start=1))
[(1, 'X'), (2, 'X'), (3, '.'), (4, 'O'), (5, '.'), (6, 'O'), (7, '.'), (8, '.'), (9, '.')]
这个列表推导式也可以写成for循环的形式:
cells = [] ①
for i, char in enumerate(board, start=1): ②
cells.append(str(i) if char == '.' else char) ③
① 初始化一个空列表来存储单元格。
② 将棋盘上每个字符的索引(从 1 开始)和值分别解包到变量 i(代表“整数”)和 char 中。
③ 如果 char 是一个点,使用 i 值的字符串版本;否则,使用 char 值。
图 21.5 说明了enumerate()是如何解包到i和char中的。

图 21.5 enumerate() 返回的包含索引和值的元组可以被分配到for循环中的两个变量中。
这个版本的format_board()通过了在 unit.py 中找到的所有测试。
21.2.3 寻找获胜者
程序的最后一个主要部分是确定是否有玩家通过在水平、垂直或对角线上放置三个标记来获胜。
def find_winner(board):
"""Return the winner"""
winning = [[0, 1, 2], [3, 4, 5], [6, 7, 8], [0, 3, 6], [1, 4, 7], ①
[2, 5, 8], [0, 4, 8], [2, 4, 6]]
for player in ['X', 'O']:
for i, j, k in winning: ②
combo = [board[i], board[j], board[k]]
if combo == [player, player, player]:
return player
① 有八个获胜位置——三个水平行、三个垂直列和两个对角线,所以我决定创建一个列表,其中每个元素也是一个列表,包含获胜配置中的三个单元格。
② 通常使用 i 作为“整数”值的变量名,尤其是当它们的生命周期相对较短时,就像这里一样。当需要在同一作用域中使用更多类似的名字时,也常见使用 j、k、l 等。你可能更喜欢使用 cell1、cell2 和 cell3 这样的名字,这些名字更具描述性,但打字也更长。单元格值的解包与之前enumerate()代码中的元组解包完全相同(见图 21.6)。

图 21.6 与enumerate()元组的展开类似,三个元素的列表可以在for循环中解包到三个变量中。
其余的代码检查X或O是否是三个位置中唯一的字符。我找到了六种不同的写法,但我会分享这个使用两个我最喜欢的函数all()和map()的替代版本:
for combo in winning: ①
group = list(map(lambda i: board[i], combo)) ②
for player in ['X', 'O']: ③
if all(x == player for x in group): ④
return player ⑤
① 遍历获胜的每个单元格组合。
② 使用map()获取组合中每个位置的棋盘值。
③ 检查每个玩家,X 和 O。
④ 检查组中的所有值是否都等于给定玩家。
⑤ 如果是这样,返回那个玩家。
如果一个函数没有明确的return语句或者从未执行过return语句,就像这里没有胜者的情况一样,Python 将使用None值作为默认的return。当我们打印游戏的输出结果时,我们将None解释为没有胜者:
winner = find_winner(board)
print(f'{winner} has won!' if winner else 'No winner.')
这涵盖了只玩一局井字棋的这个游戏版本。在下一章中,我们将扩展这些想法,将其扩展为一个交互式版本,该版本从空白棋盘开始,并动态请求用户输入来玩游戏。
21.3 进一步探索
- 编写一个游戏,该游戏将玩一局类似黑杰克(二十一点)或战争牌类的游戏。
摘要
-
这个程序使用一个
str值来表示井字棋棋盘,用九个字符表示X、O或.来指示已占用的或空白的单元格。我们有时将其转换为list,以便更容易修改。 -
正则表达式是一种方便的方式来验证初始棋盘。我们可以声明性地描述它应该是一个由
.、X和O字符组成的、恰好九个字符长的字符串。 -
any()函数类似于在多个布尔值之间使用or。如果任何值是“真值”,它就会返回True。 -
all()函数类似于在多个布尔值之间使用and。只有当所有值都是“真值”时,它才会返回True。 -
enumerate()函数将为可迭代对象(如list)中的每个元素返回索引和值。
22 井字棋重制:带有类型提示的交互式版本
在这个最后一个练习中,我们将回顾上一章的井字棋游戏。那个版本通过接受一个初始 --board 并在 --player 和 --cell 存在有效选项时修改它来玩一局游戏。它打印了一个板和获胜者(如果有的话)。我们将扩展这些想法到一个版本,它将始终从一个空板开始,并玩尽可能多的回合来完成游戏,以获胜或平局结束。 |
![]() |
|---|
这个程序将与其他本书中的所有程序不同,因为它不接受任何命令行参数。游戏将始终从一个空白的“板”开始,并以 X 玩家为先手。它将使用 input() 函数交互式地询问每个玩家(X 和 O),请求移动。任何无效的移动,如选择已占用的或不存在单元格,都将被拒绝。在每个回合结束时,游戏将决定停止,如果它确定有胜利或平局。
在本章中,你将
-
使用并跳出无限循环
-
为你的代码添加类型提示
-
探索元组、命名元组和类型化字典
-
使用
mypy分析代码中的错误,特别是类型误用
22.1 编写 itictactoe.py
这是我不会提供集成测试的一个程序。程序不接收任何参数,我无法轻松编写与程序动态交互的测试。这也使得展示字符串图变得困难,因为程序输出的结果将取决于你的操作。尽管如此,图 22.1 是你可以如何思考程序从没有输入开始,然后循环直到确定某个结果或玩家退出的大致情况。

图 22.1 这个版本的井字棋不接受任何参数,将在无限循环中玩,直到得出结论,如胜利、平局或弃权。
我鼓励你首先运行 solution1.py 程序,玩几轮游戏。你可能会注意到的第一件事是程序清除了屏幕上的所有文本,并显示了一个空板,以及 X 玩家的移动提示。我会输入 1 并按 Enter:
-------------
| 1 | 2 | 3 |
-------------
| 4 | 5 | 6 |
-------------
| 7 | 8 | 9 |
-------------
Player X, what is your move? [q to quit]: 1
然后你会看到单元格 1 现在已被 X 占用,玩家已切换到 O:
-------------
| X | 2 | 3 |
-------------
| 4 | 5 | 6 |
-------------
| 7 | 8 | 9 |
-------------
Player O, what is your move? [q to quit]:
如果我再次选择 1,我会被告知该单元格已被占用:
-------------
| X | 2 | 3 |
-------------
| 4 | 5 | 6 |
-------------
| 7 | 8 | 9 |
-------------
Cell “1” already taken
Player O, what is your move? [q to quit]:
注意,玩家仍然是 O,因为之前的操作无效。如果输入的值无法转换为整数,也会发生相同的情况:
-------------
| X | 2 | 3 |
-------------
| 4 | 5 | 6 |
-------------
| 7 | 8 | 9 |
-------------
Invalid cell “biscuit”, please use 1-9
Player O, what is your move? [q to quit]:
或者如果输入的整数超出了范围:
-------------
| X | 2 | 3 |
-------------
| 4 | 5 | 6 |
-------------
| 7 | 8 | 9 |
-------------
Invalid cell “10”, please use 1-9
Player O, what is your move? [q to quit]:
你应该能够重用第二十一章版本游戏中的一些想法来验证用户输入。
如果我把游戏玩到一方连赢三行,它会打印获胜的板并宣布胜利者:
-------------
| X | O | 3 |
-------------
| 4 | X | 6 |
-------------
| 7 | O | X |
-------------
X has won!
22.1.1 元组讨论
在这个版本中,我们将编写一个交互式游戏,它总是从一个空白的网格开始,并玩尽可能多的回合,以达到一个有赢或平局的结论。上一轮游戏中“状态”的概念仅限于棋盘——哪些玩家在哪些单元格。这个版本要求我们在游戏状态中跟踪更多变量:
-
棋盘的单元格,例如
..XO..X.O -
当前玩家,即
X或O -
任何错误,例如玩家输入一个已被占用或不存在或无法转换为数字的单元格
-
用户是否希望提前退出游戏
-
不论游戏是否平局,这种情况发生在网格的所有单元格都被占用,但没有赢家
-
如果有的话,赢家,这样我们知道游戏何时结束
你不需要按照我写的方式编写你的程序,但你仍然可能发现自己需要跟踪许多项目。dict是一个自然的数据结构,但我想要介绍一个新的数据结构,称为“命名元组”,因为它与 Python 的类型提示配合得很好,这将在我的解决方案中占重要地位。
在练习中我们已经遇到了元组。它们在正则表达式中包含捕获括号时被返回,例如在第十四章和第十七章;当使用zip组合两个列表时,例如在第十九章;或者当使用enumerate()从list中获取索引值和元素时。tuple是一个不可变的list,我们将探讨这种不可变性如何防止我们在程序中引入微妙的错误。
你在值之间放置逗号时创建tuple:
>>> cell, player
(1, 'X')
通常在它们周围加上括号以使其更明确:
>>> (cell, player)
(1, 'X')
我们可以将其分配给一个名为state的变量:
>>> state = (cell, player)
>>> type(state)
<class 'tuple'>
我们使用list索引值来索引tuple:
>>> state[0]
1
>>> state[1]
'X'
与list不同,我们无法更改tuple内的任何值:
>>> state[1] = 'O'
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: 'tuple' object does not support item assignment
记住第一个位置是cell,第二个位置是player会有些不方便,当我们添加所有其他字段时,情况会变得更糟。我们可以切换到使用dict,这样我们就可以使用字符串来访问state的值,但字典是可变的,而且很容易拼写键名错误。
22.1.2 命名元组
将不可变的tuple与命名字段结合起来会很方便,这正是namedtuple()函数所提供的。首先,你必须从collections模块导入它:
>>> from collections import namedtuple
namedtuple()函数允许我们描述一个用于值的新的class。假设我们想要创建一个描述State概念的class。一个class是一组变量、数据和函数的组合,可以一起用来表示某个概念。例如,Python 语言本身就有str类,它表示可以包含在具有某些len(长度)的变量中的字符序列的概念,并且可以使用str.upper()将其转换为大写,可以使用for循环迭代,等等。所有这些概念都被组合到str类中,我们使用help(str)在 REPL 中读取该类的文档。
类名是我们传递给namedtuple()的第一个参数,第二个参数是类中字段名称的list。将类名首字母大写是一种常见的做法:
>>> State = namedtuple('State', ['cell', 'player'])
我们刚刚创建了一个名为State的新type!
>>> type(State)
<class 'type'>
正如有一个名为list()的函数来创建list类型一样,我们现在可以使用State()函数来创建一个具有两个命名字段cell和player的State类型的命名元组:
>>> state = State(1, 'X')
>>> type(state)
<class '__main__.State'>
我们仍然可以使用索引值来访问字段,就像任何list或tuple一样:
>>> state[0]
1
>>> state[1]
'X'
但我们也可以使用它们的名称,这要优雅得多。注意,末尾没有括号,因为我们是在访问一个字段,而不是调用一个方法:
>>> state.cell
1
>>> state.player
'X'
因为state是一个元组,所以我们一旦创建了它的值,就不能再修改它:
>>> state.cell = 1
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
AttributeError: can't set attribute
这实际上在许多情况下是好的。一旦程序开始运行,更改数据值通常是非常危险的。当你想要一个不能意外修改的列表或字典样式的结构时,你应该使用元组或命名元组。
然而,这里有一个问题,那就是没有任何东西可以阻止我们用一个乱序且类型错误的state字段实例化——cell应该是int类型,而player应该是str类型!
>>> state2 = State('O', 2)
>>> state2
State(cell='O', player=2)
为了避免这种情况,你可以使用字段名称,这样它们的顺序就不再重要了:
>>> state2 = State(player='O', cell=2)
>>> state2
State(cell=2, player='O')
现在你有一个看起来像dict但具有tuple不可变性的数据结构!
22.1.3 添加类型提示
尽管如此,我们仍然有一个大问题,那就是没有任何东西可以阻止我们将一个str赋值给cell,而cell应该是一个int,反之亦然,对于int和player:
>>> state3 = State(player=3, cell='X')
>>> state3
State(cell='X', player=3)
从 Python 3.6 开始,typing模块允许你添加类型提示来描述变量的数据类型。你应该阅读 PEP 484(www.python.org/dev/peps/pep-0484/)以获取更多信息,但基本思想是我们可以使用这个模块来描述变量的适当类型和函数的类型签名。
我打算通过使用typing模块中的NamedTuple类作为基类来改进我们的State类。首先,我们需要从typing模块中导入我们将需要的类,例如NamedTuple、List和Optional,其中Optional描述了一种可以是None或像str这样的其他类的类型:
from typing import List, NamedTuple, Optional
现在,我们可以指定一个具有命名字段、类型甚至默认值的 State 类来表示游戏的初始状态,其中棋盘为空(所有点)且玩家 X 先走。注意,我决定将 board 存储为一个字符 list 而不是字符串:
class State(NamedTuple):
board: List[str] = list('.' * 9)
player: str = 'X'
quit: bool = False
draw: bool = False
error: Optional[str] = None
winner: Optional[str] = None
我们可以使用 State() 函数来创建一个设置为初始状态的新的值:
>>> state = State()
>>> state.board
['.', '.', '.', '.', '.', '.', '.', '.', '.']
>>> state.player
'X'
你可以通过提供字段名和值来覆盖任何默认值。例如,我们可以通过指定 player='O' 来以玩家 O 开始游戏。任何我们没有指定的字段将使用默认值:
>>> state = State(player='O')
>>> state.board
['.', '.', '.', '.', '.', '.', '.', '.', '.']
>>> state.player
'O'
如果我们拼写字段名错误,比如将 playre 而不是 player,我们会得到一个异常:
>>> state = State(playre='O')
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: __new__() got an unexpected keyword argument 'playre'
22.1.4 使用 Mypy 进行类型验证
虽然上述内容都很不错,但 Python 不会在赋值错误时生成运行时错误。例如,我可以将 quit 赋值为字符串 'True' 而不是布尔值 True,而什么都不会发生:
>>> state = State(quit='True')
>>> state.quit
'True'
类型提示的好处来自于使用像 Mypy 这样的程序来检查我们的代码。让我们将所有这些代码放入一个名为 typehints.py 的小程序中,存放在仓库里:
#!/usr/bin/env python3
""" Demonstrating type hints """
from typing import List, NamedTuple, Optional
class State(NamedTuple):
board: List[str] = list('.' * 9)
player: str = 'X'
quit: bool = False ①
draw: bool = False
error: Optional[str] = None
winner: Optional[str] = None
state = State(quit='False') ②
print(state)
① quit 被定义为布尔类型,这意味着它只能允许 True 和 False 的值。
② 我们正在将字符串值 'True' 而不是布尔值 True 赋值给,这可能会是一个容易犯的错误,尤其是在一个非常大的程序中。我们希望知道这种错误会被捕获!
程序将 无错误地执行:
$ ./typehints.py
State(board=['.', '.', '.', '.', '.', '.', '.', '.', '.'], player='X', \
quit='False', draw=False, error=None, winner=None)
但 Mypy 程序会报告我们的错误:
$ mypy typehints.py
typehints.py:16: error: Argument "quit" to "State" has incompatible type "str"; expected "bool"
Found 1 error in 1 file (checked 1 source file)
如果我像这样纠正程序,
#!/usr/bin/env python3
""" Demonstrating type hints """
from typing import List, NamedTuple, Optional
class State(NamedTuple):
board: List[str] = list('.' * 9)
player: str = 'X'
quit: bool = False ①
draw: bool = False
error: Optional[str] = None
winner: Optional[str] = None
state = State(quit=True) ②
print(state)
① 再次,quit 是一个布尔值。
② 我们必须分配一个实际的布尔值才能通过 Mypy 的检查。
现在 Mypy 会满意:
$ mypy typehints2.py
Success: no issues found in 1 source file
22.1.5 更新不可变结构
如果使用 NamedTuples 的一个优点是它们的不可变性,我们将如何跟踪程序中的更改?考虑我们的初始状态是一个空网格,玩家 X 先走:
>>> state = State()
假设 X 取了第 1 个格子,因此我们需要将 board 改为 X........,并将 player 改为 O。我们不能直接修改 state:
>>> state.board=list('X.........')
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
AttributeError: can't set attribute
我们可以使用 State() 函数来创建一个新的值来覆盖现有的 state。也就是说,由于我们无法更改 state 变量内部的任何内容,我们可以改为将 state 指向一个全新的值。我们在第八章的第二种解决方案中就是这样做的,因为我们需要更改一个 str 值,因为它们在 Python 中也是不可变的。
要做到这一点,我们可以复制所有未更改的当前值,并将它们与更改的值结合起来:
>>> state = State(board=list('X.........'), player='O', quit=state.quit, \
draw=state.draw, error=state.error, winner=state.winner)
然而,namedtuple._replace() 方法提供了一个更简单的方式来做到这一点。只有我们提供的值会被改变,结果是一个新的 State:
>>> state = state._replace(board=list('X.........'), player='O')
我们用 state._replace() 的返回值覆盖 state 变量,就像我们反复用新值覆盖字符串变量一样:
>>> state
State(board=['X', '.', '.', '.', '.', '.', '.', '.', '.', '.'], player='O', \
quit=False, draw=False, error=None, winner=None)
这比列出所有字段要方便得多——我们只需要指定已更改的字段。我们还防止意外修改其他任何字段,同样也防止忘记或拼写字段错误,或者将它们设置为错误的类型。
22.1.6 在函数定义中添加类型提示
现在,让我们看看我们如何可以在函数定义中添加类型提示。例如,我们可以修改我们的 format_board() 函数,通过添加 board: List[str] 来指示它接受一个名为 board 的参数,该参数是一个字符串值的列表。此外,该函数返回一个 str 值,因此我们可以在 def 后的冒号后添加 -> str 来表示这一点,如图 22.2 所示。

图 22.2 添加类型提示来描述参数和返回值的类型
main() 的注解表明返回 None 值,如图 22.3 所示。

图 22.3 main() 函数不接受任何参数并返回 None。
真正令人兴奋的是,我们可以定义一个接受 State 类型值的函数,Mypy 将检查是否实际传递了这种类型的值(见图 22.4)。

图 22.4 我们可以在类型提示中使用自定义类型。此函数接受并返回 State 类型的值。
尝试玩我的游戏版本,然后编写一个行为类似的自己的游戏。然后看看我是如何编写一个结合了数据不可变性和类型安全性的交互式解决方案的。
22.2 解决方案
这就是最后一个程序!我希望在前一章中编写的简单版本能给你一些想法,让你能够使这个程序工作。类型提示和单元测试也帮到了你吗?
#!/usr/bin/env python3
""" Interactive Tic-Tac-Toe using NamedTuple """
from typing import List, NamedTuple, Optional ①
class State(NamedTuple): ②
board: List[str] = list('.' * 9)
player: str = 'X'
quit: bool = False
draw: bool = False
error: Optional[str] = None
winner: Optional[str] = None
# --------------------------------------------------
def main() -> None:
"""Make a jazz noise here"""
state = State() ③
while True: ④
print("\033[H\033[J") ⑤
print(format_board(state.board)) ⑥
if state.error: ⑦
print(state.error)
elif state.winner: ⑧
print(f'{state.winner} has won!')
break
state = get_move(state) ⑨
if state.quit: ⑩
print('You lose, loser!')
break
elif state.draw: ⑪
print("All right, we'll call it a draw.")
break
# --------------------------------------------------
def get_move(state: State) -> State: ⑫
"""Get the player's move"""
player = state.player ⑬
cell = input(f'Player {player}, what is your move? [q to quit]: ') ⑭
if cell == 'q': ⑮
return state._replace(quit=True) ⑯
if not (cell.isdigit() and int(cell) in range(1, 10)): ⑰
return state._replace(error=f'Invalid cell "{cell}", please use 1-9') ⑱
cell_num = int(cell) ⑲
if state.board[cell_num - 1] in 'XO': ⑳
return state._replace(error=f'Cell "{cell}" already taken') ㉑
board = state.board ㉒
board[cell_num - 1] = player ㉓
return state._replace(board=board, ㉔
player='O' if player == 'X' else 'X',
winner=find_winner(board),
draw='.' not in board,
error=None)
# --------------------------------------------------
def format_board(board: List[str]) -> str: ㉕
"""Format the board"""
cells = [str(i) if c == '.' else c for i, c in enumerate(board, 1)]
bar = '-------------'
cells_tmpl = '| {} | {} | {} |'
return '\n'.join([
bar,
cells_tmpl.format(*cells[:3]), bar,
cells_tmpl.format(*cells[3:6]), bar,
cells_tmpl.format(*cells[6:]), bar
])
# --------------------------------------------------
def find_winner(board: List[str]) -> Optional[str]: ㉖
"""Return the winner"""
winning = [[0, 1, 2], [3, 4, 5], [6, 7, 8], [0, 3, 6], [1, 4, 7],
[2, 5, 8], [0, 4, 8], [2, 4, 6]]
for player in ['X', 'O']:
for i, j, k in winning:
combo = [board[i], board[j], board[k]]
if combo == [player, player, player]:
return player
return None
# --------------------------------------------------
if __name__ == '__main__':
main()
① 从 typing 模块导入我们需要的类。
② 声明一个基于 NamedTuple 类的类。定义该类可以持有的字段名称、类型和默认值。
③ 将初始状态实例化为一个空网格,并将第一个玩家标记为 X。
④ 开始一个无限循环。当我们有理由停止时,我们可以退出循环。
⑤ 打印一个特殊序列,大多数终端都会将其解释为清除屏幕的命令。
⑥ 打印当前棋盘的状态。
⑦ 打印任何错误,例如用户没有选择有效的单元格。
⑧ 如果有胜者,宣布胜利者并退出循环。
⑨ 从玩家那里获取下一步行动。get_move() 函数接受一个 State 类型并返回一个。我们每次通过循环都会覆盖现有的状态变量。
⑩ 如果用户决定提前退出游戏,侮辱他们并退出循环。
⑪ 如果游戏达到了僵局,所有单元格都被占用但没有胜者,宣布平局并退出循环。
⑫ 定义一个 get_move() 函数,它接受并返回一个 State 类型。
⑬ 从状态中复制玩家,因为我们将在函数体中多次引用它。
⑭ 使用 input() 函数询问玩家他们的下一步移动。告诉他们如何提前退出游戏,这样他们就不必使用 Ctrl-C 来中断程序。
⑮ 首先检查用户是否想要退出。
⑯ 如果是,将状态中的退出值替换为 True 并返回新状态。注意状态中的其他值没有修改。
⑰ 检查用户输入的值是否可以使用 str.isdigit() 转换为数字,以及该值的整数版本是否在有效范围内。
⑱ 如果不是,则返回一个带有错误的更新后的状态。注意当前状态和玩家保持不变,以便相同的玩家可以在相同的棋盘上重试,直到他们提供有效的输入。
⑲ 在我们验证单元格是一个有效的整数值之后,将其转换为整数。
⑳ 检查指示的单元格是否开放。
㉑ 如果不是,则返回一个带有错误的更新后的状态。同样,状态的其他部分没有改变,所以我们使用相同的玩家和状态重试这一轮。
㉒ 复制当前棋盘,因为我们需要修改它,而 state.board 是不可变的。
㉓ 使用单元格值来更新棋盘,以当前玩家为准。
㉔ 返回一个新状态值,包含新的棋盘值,当前玩家切换到另一玩家,以及是否有胜者或平局。
㉕ 与此函数的先前版本相比,唯一的更改是添加了类型提示。该函数接受一个字符串值列表(当前棋盘),并返回一个格式化的棋盘状态网格。
㉖ 这与之前的功能相同,但增加了类型提示。该函数接受一个字符串列表作为棋盘,并返回一个可选的字符串值,这意味着它也可以返回 None。
22.2.1 使用 TypedDict 的版本
Python 3.8 的新特性是 TypedDict 类,它看起来与 NamedTuple 非常相似。让我们看看使用这个作为基类如何改变我们的程序的一部分。一个关键的区别是,你(目前)不能为字段设置默认值:
#!/usr/bin/env python3
""" Interactive Tic-Tac-Toe using TypedDict """
from typing import List, Optional, TypedDict ①
class State(TypedDict): ②
board: str
player: str
quit: bool
draw: bool
error: Optional[str]
winner: Optional[str]
① 导入 TypedDict 而不是 NamedTuple。
② 在 TypedDict 上建立基础状态。
当我们实例化一个新的 state 时,我们必须设置我们的初始值:
def main() -> None:
"""Make a jazz noise here"""
state = State(board='.' * 9,
player='X',
quit=False,
draw=False,
error=None,
winner=None)
在语法上,我更喜欢使用 state.board 而不是 state['board'] 的字典访问:
while True:
print("\033[H\033[J")
print(format_board(state['board']))
if state['error']:
print(state['error'])
elif state['winner']:
print(f"{state['winner']} has won!")
break
state = get_move(state)
if state['quit']:
print('You lose, loser!')
break
elif state['draw']:
print('No winner.')
break
除了访问字段带来的便利之外,我更喜欢 NamedTuple 的只读特性,而不是可变的 TypedDict。注意在 get_move() 函数中,我们可以改变 state:
def get_move(state: State) -> State:
"""Get the player's move"""
player = state['player']
cell = input(f'Player {player}, what is your move? [q to quit]: ')
if cell == 'q':
state['quit'] = True ①
return state
if not (cell.isdigit() and int(cell) in range(1, 10)):
state['error'] = f'Invalid cell "{cell}", please use 1-9' ②
return state
cell_num = int(cell)
if state['board'][cell_num - 1] in 'XO':
state['error'] = f'Cell "{cell}" already taken'
return state
board = list(state['board'])
board[cell_num - 1] = player
return State(
board=''.join(board),
player='O' if player == 'X' else 'X',
winner=find_winner(board),
draw='.' not in board,
error=None,
quit=False,
)
① 这里我们直接修改了 TypedDict,而 NamedTuple 版本使用 state._replace() 来返回一个全新的状态值。
② 另一个可以直接修改状态的地方。你可能更喜欢这种方法。
在我看来,NamedTuple 拥有更简洁的语法、默认值和不可变性,优于 TypedDict 版本,所以我更喜欢它。无论你选择哪个,我希望传达的更重要的教训是,我们应该尽量明确程序的“状态”以及何时以及如何改变它。
22.2.2 思考状态
程序状态的概念是程序可以记住变量随时间的变化。在前一章中,我们的程序接受一个给定的 --board 和 --cell 以及 --player 的可能值,这些值可能会改变棋盘。然后游戏打印出棋盘的表示。在本章的交互式版本中,棋盘始终是一个空网格,并且随着每一回合的变化而变化,我们将它建模为一个无限循环。 |
![]() |
|---|
在这类程序中,程序员通常会在程序顶部声明全局变量,这些变量在函数定义之外,以便在整个程序中全局可见。虽然很常见,但这并不是最佳实践,我建议你除非没有其他选择,否则永远不要使用全局变量。相反,我建议你坚持使用接受所有所需值并返回单一类型值的函数。我还建议你使用像类型化、命名元组这样的数据结构来表示程序状态,并且要非常小心地保护状态的变化。
22.3 进一步探索
-
加入更辛辣的侮辱性言语。也许可以引入莎士比亚风格的生成器?
-
编写一个版本,允许用户在不退出和重新启动程序的情况下开始新游戏。
-
编写其他游戏,如猜谜游戏。
摘要
-
类型提示允许你注释变量以及函数参数和返回值,并指定它们的类型。
-
Python 本身会在运行时忽略类型提示,但 Mypy 可以在运行代码之前使用类型提示来查找错误。
-
NamedTuple的行为有点像字典和对象,但保留了元组的不可变性。 -
NamedTuple和TypedDict都允许你创建一个具有定义字段和类型的全新类型,你可以将其用作自己函数的类型提示。 -
我们程序使用
NamedTuple创建了一个复杂的数据结构来表示程序的状态。状态包括许多变量,例如当前棋盘、当前玩家、任何错误、获胜者等,每个变量都使用类型提示进行了描述。
虽然为交互式程序编写集成测试很困难,但我们仍然可以将程序分解成小的函数(例如 format_board() 或 get_winner()),为这些函数编写和运行单元测试。
后记
好吧,这就是整本书的内容。我们从第二章编写鸟瞰塔楼程序到第二十二章的交互式井字棋游戏,结合了基于命名元组的自定义类和类型提示。我希望你现在可以看到你可以用 Python 的字符串、列表、元组、字典、集合和函数做多少事情。我特别希望我已经说服你,最重要的是,你应该始终编写经过
-
灵活,通过使用命令行参数
-
文档化,通过使用类似
argparse的工具来解析你的参数并生成用法说明 -
测试的程序,通过为你的函数编写单元测试和为整个程序编写集成测试
使用你程序的人会非常感激知道如何使用你的程序以及如何让它表现出不同的行为。他们也会感激你花时间验证程序的正确性。但是,让我们说实话。最有可能使用和修改你程序的人将会是你,几个月后的你。我听说“文档是对你未来自己的情书。”你为使程序变得良好所付出的所有努力,当你回到代码时,你会非常感激。
现在你已经完成了所有练习,并看到了如何使用我编写的测试,我挑战你回到开始,阅读 test.py 程序。如果你打算采用测试驱动开发,你可能会发现你可以从那些程序中窃取许多想法和技术。
此外,每一章都包含了如何扩展所提出的思想和练习的建议。回顾并思考你如何可以使用你在书中稍后学到的想法来改进或扩展早期的程序。以下是一些想法:
-
第二章(鸟瞰塔楼)--添加一个选项,从“Hello”、“Hola”、“Salut”和“Ciao”等列表中随机选择除“Hello”之外的其他问候语。
-
第三章(野餐)--允许程序接受一个或多个选项,并将这些选项与每个物品的正确冠词结合到输出中,使用牛津逗号连接。
-
第七章(Gashlycrumb)--从 Project Gutenberg 下载安布罗斯·比尔斯(Ambrose Bierce)的《魔鬼词典》。编写一个程序,如果单词出现在文本中,它会查找单词的定义。
-
第十六章(打乱者)--使用打乱后的文本作为加密消息的基础。强制将打乱的单词转换为大写,删除所有标点符号和空格,然后将文本格式化为每五个字符后跟一个空格的“单词”,每行不超过五个。填充末尾,使文本完全填满最后一行。你能理解输出吗?
-
new.py--当我还是一个新手 Perl 黑客时,我首先编写了一个程序来创建一个新的程序。我的 new-pl 程序会添加威廉·布莱克(William Blake)诗歌中的随机引语(是的,真的--我也经历过勃朗特姐妹和狄金森的阶段)。修改你的 new.py 版本,添加随机引语或笑话,或者以某种方式定制它以适应你的程序。
| 我希望你在编写程序的过程中和我创造和教授它们一样快乐。我希望你现在感觉你已经拥有了数十个程序和测试,其中包含了你可以借鉴的想法和功能,来创造更多的程序。祝你编程冒险一切顺利! | ![]() |
|---|
附录。使用 argparse
通常,将正确数据输入到你的程序中是一项真正的苦差事。argparse 模块使得验证用户输入的参数以及在他们提供错误输入时生成有用的错误信息变得容易得多。它就像你程序的“保安”,只允许正确的值进入程序。使用 argparse 正确定义参数是使本书中的程序正常工作的关键第一步。例如,第一章讨论了一个非常灵活的程序,它可以向一个可选命名的实体(如“世界”或“宇宙”)发出热情的问候: |
![]() |
|---|
$ ./hello.py ①
Hello, World!
$ ./hello.py --name Universe ②
Hello, Universe!
① 当程序在没有输入值的情况下运行时,它将使用“World”作为问候的实体。
② 程序可以接受一个可选的 --name 值来覆盖默认值。
程序将对 -h 和 --help 标志做出有助的文档响应:
$ ./hello.py -h ①
usage: hello.py [-h] [-n str] ②
Say hello ③
optional arguments:
-h, --help show this help message and exit ④
-n str, --name str The name to greet (default: World) ⑤
① 程序的参数是 -h,这是请求帮助的“短”标志。
② 这一行显示了程序接受的所有选项的摘要。括号 [] 中的参数表明它们是可选的。
③ 这是程序的描述。
④ 我们可以使用“短”名 -h 或“长”名 --help 来请求程序关于如何运行的帮助。
⑤ 可选的“name”参数也有短名 -n 和长名 --name。
所有这些都可以通过 hello.py 程序中的两行代码实现:
parser = argparse.ArgumentParser(description='Say hello') ①
parser.add_argument('-n', '--name', default='World', help='Name to greet') ②
① 解析器将为我们解析参数。如果用户提供了未知参数或错误的参数数量,程序将显示用法语句并停止运行。
② 此程序唯一的参数是一个可选的 --name 值。
注意:您不需要定义 -h 或 --help 标志。这些是由 argparse 自动生成的。实际上,您永远不应该尝试使用这些值,因为它们几乎是通用的选项,大多数用户都会期望。
argparse 模块帮助我们定义参数解析器并生成帮助信息,节省了我们大量时间,并使我们的程序看起来更专业。本书中的每个程序都在不同的输入上进行测试,所以你将真正理解如何使用此模块。我建议你查看 argparse 文档 (docs.python.org/3/library/argparse.html)。
现在,让我们进一步探讨这个模块能为我们做什么。在本附录中,你将
-
学习如何使用
argparse处理位置参数、选项和标志 -
为选项设置默认值
-
使用
type强制用户提供数值或文件等值 -
使用
choices限制选项的值
A.1 参数类型
命令行参数可以按以下方式分类:
-
位置参数 ——参数的顺序和数量决定了它们的含义。一些程序可能期望,例如,第一个参数是文件名,第二个参数是输出目录。位置参数通常是必需的(非可选)参数。使它们成为可选的是困难的——你将如何编写一个接受两个或三个参数的程序,其中第二个和第三个参数是独立的和可选的?在第一章 hello.py 的第一个版本中,问候的名称作为位置参数提供。
-
命名选项 ——大多数命令行程序定义一个短名称,如
-n(一个短横线和单个字符),以及一个长名称,如--name(两个短横线和单词),后面跟一些值,如 hello.py 程序中的名称。命名选项允许以任何顺序提供参数——它们的位置并不重要。这使得它们在用户不需要提供它们时成为正确的选择(毕竟它们是选项)。为选项提供合理的默认值是很好的。当我们将 hello.py 中必需的位置name参数更改为可选的--name参数时,我们使用了“World”作为默认值,这样程序就可以在没有用户输入的情况下运行。请注意,某些其他语言,如 Java,可能使用单个短横线定义长名称,如-jar。 -
标志 ——类似于“是”/“否”或
True/False的布尔值,通过看起来像命名选项的东西表示,但名称后面没有值;例如,用于开启调试的-d或--debug标志。通常,标志的存在表示参数的值为True,其不存在则表示False,所以--debug会开启调试,而其不存在则表示关闭。
A.2 使用模板启动程序
记住使用argparse定义参数的所有语法并不容易,所以我为你创建了一种方法,让你可以从一个模板开始编写新程序,该模板包括这些以及其他一些将使你的程序更容易阅读和运行的结构。
开始一个新程序的一种方法是通过使用 new.py 程序。从存储库的顶层,你可以执行以下命令:
$ bin/new.py foo.py
或者,你可以复制模板:
$ cp template/template.py foo.py
生成的程序将与你创建它的方式无关,并且它将包含如何声明上一节中概述的每种参数类型的示例。此外,你可以使用argparse来验证输入,例如确保一个参数是数字,而另一个参数是文件。
让我们看看我们新程序生成的帮助信息:
$ ./foo.py -h ①
usage: foo.py [-h] [-a str] [-i int] [-f FILE] [-o] str ②
Rock the Casbah ③
positional arguments: ④
str A positional argument
optional arguments: ⑤
-h, --help show this help message and exit ⑥
-a str, --arg str A named string argument (default: ) ⑦
-i int, --int int A named integer argument (default: 0) ⑧
-f FILE, --file FILE A readable file (default: None) ⑨
-o, --on A boolean flag (default: False) ⑩
① 每个程序都应该对-h 和--help 做出响应,显示帮助信息。
② 这是对下面更详细描述的选项的简要总结。
③ 这是整个程序的描述。
④ 该程序定义了一个位置参数,但你可以有更多。你很快就会看到如何定义这些参数。
⑤ 可选参数可以省略,因此你应该为它们提供合理的默认值。
⑥ 当您使用 argparse 时,-h 和 --help 参数总是存在的;您不需要定义它们。
⑦ -a 或 --arg 选项接受一些文本,这通常被称为“字符串”。
⑧ -i 或 --int 选项必须是一个整数值。如果用户提供了“one”或“4.2”,这些将被拒绝。
⑨ -f 或 --file 选项必须是一个有效、可读的文件。
⑩ -o 或 --on 是一个标志。注意 -f FILE 描述指定“FILE”值应跟在 -f 之后,但对此标志没有值跟随选项。标志要么存在,要么不存在,因此它分别对应于布尔值 True 或 False。
A.3 使用 argparse
生成前面用法的代码位于一个名为 get_args() 的函数中,其代码如下:
def get_args():
"""Get command-line arguments"""
parser = argparse.ArgumentParser(
description='Rock the Casbah',
formatter_class=argparse.ArgumentDefaultsHelpFormatter)
parser.add_argument('positional',
metavar='str',
help='A positional argument')
parser.add_argument('-a',
'--arg',
help='A named string argument',
metavar='str',
type=str,
default='')
parser.add_argument('-i',
'--int',
help='A named integer argument',
metavar='int',
type=int,
default=0)
parser.add_argument('-f',
'--file',
help='A readable file',
metavar='FILE',
type=argparse.FileType('r'),
default=None)
parser.add_argument('-o',
'--on',
help='A boolean flag',
action='store_true')
return parser.parse_args()
您可以将此代码放在您喜欢的任何地方,但定义和验证参数有时可能会相当长。我喜欢将此代码分离到一个我称为 get_args() 的函数中,并且我总是在程序中首先定义此函数。这样,当我阅读源代码时,我就可以立即看到它。
get_args() 函数定义如下:
def get_args(): ①
"""Get command-line arguments""" ②
① def 关键字定义了一个新函数,函数的参数列在括号中。即使 get_args() 函数没有参数,括号仍然是必需的。
② 函数定义之后的三个引号行是“文档字符串”,它作为函数的一点点文档。文档字符串不是必需的,但它们是良好的风格,如果遗漏了它们,Pylint 会提出警告。
A.3.1 创建解析器
以下代码片段创建了一个parser,该parser将处理命令行中的参数。在这里,“解析”意味着从提供的作为参数的文本片段的顺序和语法中推导出一些含义:
parser = argparse.ArgumentParser( ①
description='Argparse Python script', ②
formatter_class=argparse.ArgumentDefaultsHelpFormatter) ③
① 调用 argparse.ArgumentParser() 函数来创建一个新的解析器。
② 您程序的用途的简要总结。
③ formatter_class 参数告诉 argparse 在用法中显示默认值。
您应该阅读 argparse 的文档,以查看您可以使用来定义 parser 或参数的所有其他选项。在 REPL 中,您可以从 help(argparse) 开始,或者您可以在互联网上查找docs.python.org/3/library/argparse.html上的文档。
A.3.2 创建位置参数
以下行将创建一个新的 位置 参数:
parser.add_argument('positional', ①
metavar='str', ②
help='A positional argument') ③
① 缺少前导破折号使得这是一个位置参数,而不是名为“位置”的参数。
② 向用户提供数据类型的提示。默认情况下,所有参数都是字符串。
③ 参数用法的简要描述
记住,参数不是位置参数,因为其 名称 是“位置”。那只是为了提醒你它 确实是 一个位置参数。argparse 将字符串 'positional' 解释为位置参数,因为其 名称 没有以任何破折号开头。
A.3.3 创建可选字符串参数
以下行创建了一个短名称为-a和长名称为--arg的可选参数。它将是一个默认值为''(空字符串)的str。
parser.add_argument('-a', ①
'--arg', ②
help='A named string argument', ③
metavar='str', ④
type=str, ⑤
default='') ⑥
① 简短名称
② 长名称
③ 用作使用的简要描述
④ 用作使用说明的类型提示
⑤ 实际的 Python 数据类型(注意str周围没有引号)
⑥ 默认值
注意:你可以在自己的程序中省略短名称或长名称,但提供两者是良好的做法。本书中的大多数测试都将使用短名称和长名称选项来测试你的程序。
如果你想要将其作为一个必需的命名参数,你应该移除default并添加required=True。
A.3.4 创建可选数字参数
以下行创建了一个名为-i或--int的选项,它接受一个默认值为0的int(整数)。如果用户提供的任何内容都不能被解释为整数,argparse模块将停止处理参数,并打印一条错误消息和简短的使用说明。
parser.add_argument('-i', ①
'--int', ②
help='A named integer argument', ③
metavar='int', ④
type=int, ⑤
default=0) ⑥
① 简短名称
② 长名称
③ 使用说明的简要描述
④ 用作使用说明的类型提示
⑤ 必须将字符串转换为的 Python 数据类型。你也可以使用float来表示浮点值(带有小数部分的数字,如 3.14)。
⑥ 默认值
在这种方式下定义数字参数的一个主要原因是argparse会将输入转换为正确的类型。所有来自命令行的值都是字符串,程序的任务是将每个值转换为实际的数值。如果你告诉argparse该选项应该是type=int,那么当你从parser请求值时,它已经转换成了实际的int值。
如果用户提供的值不能转换为int,该值将被拒绝。请注意,你也可以使用type=float来接受并转换输入为浮点值。这可以节省你大量的时间和精力。
A.3.5 创建可选文件参数
以下行创建了一个名为-f或--file的选项,它只接受有效的、可读的文件。这个参数本身就物有所值,因为它将为你节省大量验证用户输入的时间。请注意,几乎每个有文件作为输入的练习都将有测试,这些测试会传递无效的文件参数以确保你的程序拒绝它们。
parser.add_argument('-f', ①
'--file', ②
help='A readable file', ③
metavar='FILE', ④
type=argparse.FileType('r'), ⑤
default=None) ⑥
① 简短名称
② 长名称
③ 简短的使用说明
④ 类型建议
⑤ 表示该参数必须命名一个可读的('r')文件
⑥ 默认值
运行程序的人负责提供文件的地址。例如,如果你在存储库的顶层创建了 foo.py 程序,那里将有一个 README.md 文件。我们可以将其用作程序的输入,并且它将被接受为有效的参数:
$ ./foo.py -f README.md foo
str_arg = ""
int_arg = "0"
file_arg = "README.md"
flag_arg = "False"
positional = "foo"
如果我们提供一个无效的--file参数,如“blargh”,我们将得到一个错误消息:
$ ./foo.py -f blargh foo
usage: foo.py [-h] [-a str] [-i int] [-f FILE] [-o] str
foo.py: error: argument -f/--file: can't open 'blargh': \
[Errno 2] No such file or directory: 'blargh'
A.3.6 创建标志选项
标志选项略有不同,因为它不接收像字符串或整数这样的值。标志要么存在,要么不存在,并且它们通常表示某个想法是 True 或 False。
你已经看到了 -h 和 --help 标志。它们后面没有跟随任何值。它们要么存在,在这种情况下程序应该打印一个“用法”语句,要么不存在,在这种情况下程序不应该打印。在本书的所有练习中,我使用标志来表示当它们存在时为 True 值,否则为 False,我们可以使用 action='store_true' 来表示。
例如,new.py 展示了这种名为 -o 或 --on 的标志的示例:
parser.add_argument('-o', ①
'--on', ②
help='A boolean flag', ③
action='store_true') ④
① 短名称
② 长名称
③ 简要使用说明
④ 当此标志存在时应该做什么。当它存在时,我们为 on 使用 True 值。当标志不存在时,默认值将是 False。
并非所有像这样的“标志”都应该在存在时解释为 True。你可以使用 action='store_false' 来代替,在这种情况下,当标志存在时,on 将是 False,默认值将是 True。你还可以在标志存在时存储一个或多个常量值。
读取 argparse 文档,了解你可以定义此参数的各种方式。就本书的目的而言,我们将仅使用标志来开启某些行为。
A.3.7 从 get_args 返回
get_args() 函数中的最后一个语句是 return,它返回 parser 对象解析参数的结果。也就是说,调用 get_args() 的代码将接收到这个表达式的结果:
return parser.parse_args()
这个表达式可能会失败,因为 argparse 发现用户提供了无效的参数,例如当它期望一个 float 值时提供了一个字符串值,或者可能是一个拼写错误的文件名。如果解析成功,我们将能够从我们的程序内部访问用户提供的所有值。
此外,参数的值将是我们在其中指定的 类型。也就是说,如果我们指定 --int 参数应该是一个 int,那么当我们请求 args.int 时,它已经是一个 int。如果我们定义一个文件参数,我们将得到一个 打开的文件句柄。这可能现在看起来并不令人印象深刻,但它实际上非常有帮助。
如果你参考我们生成的 foo.py 程序,你会看到 main() 函数调用了 get_args(),因此 get_args() 的 return 将返回到 main()。从那里,我们可以使用位置参数的名称或可选参数的长名称来访问我们刚刚定义的所有值:
def main():
args = get_args()
str_arg = args.arg
int_arg = args.int
file_arg = args.file
flag_arg = args.on
pos_arg = args.positional
A.4 使用 argparse 的示例
本书中的许多程序测试可以通过学习如何有效地使用argparse来验证程序的参数来满足。我认为命令行是程序的边界,你需要明智地决定允许什么进入你的程序。你应该始终预期并防御每个参数可能出错的情况。1 第一章的 hello.py 程序是一个单位置参数和一个单可选参数的例子。让我们看看更多关于如何使用argparse的例子。
A.4.1 单个位置参数
这是第一章 hello.py 程序的第一版,它需要一个参数来指定问候的名字:
#!/usr/bin/env python3
"""A single positional argument"""
import argparse
# --------------------------------------------------
def get_args():
"""Get command-line arguments"""
parser = argparse.ArgumentParser(
description='A single positional argument',
formatter_class=argparse.ArgumentDefaultsHelpFormatter)
parser.add_argument('name', metavar='name', help='The name to greet') ①
return parser.parse_args()
# --------------------------------------------------
def main():
"""Make a jazz noise here"""
args = get_args()
print('Hello, ' + args.name + '!') ②
# --------------------------------------------------
if __name__ == '__main__':
main()
① 名称参数不以连字符开头,因此这是一个位置参数。metavar 将在帮助信息中显示,以使用户知道这个参数应该是什么。
② 程序的第一个位置参数提供的任何内容都将可在args.name槽中访问。
如果程序没有接收到恰好一个参数,它将不会打印“Hello”行。如果没有提供任何参数,它将打印一条简短的用法说明,关于如何正确调用程序的方法:
$ ./one_arg.py
usage: one_arg.py [-h] name
one_arg.py: error: the following arguments are required: name
如果我们提供多个参数,它将再次抱怨。这里“Emily”和“Bronte”是两个参数,因为命令行上的参数由空格分隔。程序抱怨收到了一个未定义的第二个参数:
$ ./one_arg.py Emily Bronte
usage: one_arg.py [-h] name
one_arg.py: error: unrecognized arguments: Bronte
只有当我们向程序提供一个参数时,它才会运行:
$ ./one_arg.py "Emily Bronte"
Hello, Emily Bronte!
虽然使用argparse来处理这样一个简单的程序可能显得有些过度,但它表明argparse可以为我们做大量的错误检查和参数验证。
A.4.2 两个不同的位置参数
假设你想要两个不同的位置参数,比如要订购的物品的颜色和大小。颜色应该是一个str类型,大小应该是一个int类型的值。当你按位置定义它们时,你声明它们的顺序就是用户必须提供参数的顺序。在这里,我们首先定义color,然后是size:
#!/usr/bin/env python3
"""Two positional arguments"""
import argparse
# --------------------------------------------------
def get_args():
"""get args"""
parser = argparse.ArgumentParser(
description='Two positional arguments',
formatter_class=argparse.ArgumentDefaultsHelpFormatter)
parser.add_argument('color', ①
metavar='color',
type=str,
help='The color of the garment')
parser.add_argument('size', ②
metavar='size',
type=int,
help='The size of the garment')
return parser.parse_args()
# --------------------------------------------------
def main():
"""main"""
args = get_args()
print('color =', args.color) ③
print('size =', args.size) ④
# --------------------------------------------------
if __name__ == '__main__':
main()
① 这将是第一个位置参数,因为它首先被定义。注意,metavar 已被设置为'color'而不是'str',因为它更能描述我们期望的字符串类型——一个描述服装“颜色”的字符串。
② 这将是第二个位置参数。这里 metavar='size',它可以是像 4 这样的数字,也可以是像'small'这样的字符串,所以它仍然是不确定的。
③ 通过颜色参数的名称访问“color”参数。
④ 通过大小参数的名称访问“size”参数。
再次,用户必须提供恰好两个位置参数。不输入任何参数将触发一条简短的用法说明:
$ ./two_args.py
usage: two_args.py [-h] color size
two_args.py: error: the following arguments are required: color, size
只输入一个参数也不行。我们被告知“size”缺失:
$ ./two_args.py blue
usage: two_args.py [-h] color size
two_args.py: error: the following arguments are required: size
如果我们提供两个字符串,比如“blue”用于颜色和“small”用于大小,大小值将被拒绝,因为它需要是一个整数值:
$ ./two_args.py blue small
usage: two_args.py [-h] color size
two_args.py: error: argument size: invalid int value: 'small'
| 如果我们给出两个参数,第二个参数可以解释为 int,那么一切正常:
$ ./two_args.py blue 4
color = blue
size = 4
记住,来自命令行的所有参数都是字符串。命令行不需要像 Python 那样在 blue 或 4 周围加上引号来将它们转换为字符串。在命令行中,一切都是字符串,所有参数都以字符串的形式传递给 Python。 |
|
当我们告诉 argparse 第二个参数需要是一个 int 时,argparse 将尝试将字符串 '4' 转换为整数 4。如果你提供 4.1,它也会被拒绝:
$ ./two_args.py blue 4.1
usage: two_args.py [-h] str int
two_args.py: error: argument int: invalid int value: '4.1'
|
| 位置参数要求用户记住参数的正确顺序。如果我们错误地将 str 和 int 参数调换顺序,argparse 将检测到无效值:
$ ./two_args.py 4 blue
usage: two_args.py [-h] COLOR SIZE
two_args.py: error: argument SIZE: invalid int value: 'blue'
|
然而,想象一下,有两个字符串或两个数字代表两个 不同 的值的情况,比如汽车的制造商和型号,或者一个人的身高和体重。你如何检测参数是否被反转?
一般而言,我只创建接受恰好一个位置参数或一个或多个相同参数的程序,比如要处理的文件列表。
A.4.3 使用选择选项限制值
在我们之前的示例中,没有任何阻止用户提供 两个整数值 的东西:
$ ./two_args.py 1 2
color = 1
size = 2
1 是一个字符串。它可能看起来像数字,但实际上它是 字符 '1'。这是一个有效的字符串值,所以我们的程序接受它。
我们的程序也会接受“size”为 -4,这显然不是一个有效的尺寸:
$ ./two_args.py blue -4
color = blue
size = -4
我们如何确保用户提供了有效的 color 和 size?假设我们只提供原色衬衫。我们可以使用 choices 选项传递一个有效值的列表。
在以下示例中,我们将 color 限制为“red”、“yellow”或“blue”。此外,我们可以使用 range(1, 11) 生成一个从 1 到 10 的数字列表(不包括 11!)作为衬衫的有效尺寸:
#!/usr/bin/env python3
"""Choices"""
import argparse
# --------------------------------------------------
def get_args():
"""get args"""
parser = argparse.ArgumentParser(
description='Choices',
formatter_class=argparse.ArgumentDefaultsHelpFormatter)
parser.add_argument('color',
metavar='str',
help='Color',
choices=['red', 'yellow', 'blue']) ①
parser.add_argument('size',
metavar='size',
type=int,
choices=range(1, 11), ②
help='The size of the garment')
return parser.parse_args()
# --------------------------------------------------
def main():
"""main"""
args = get_args() ③
print('color =', args.color)
print('size =', args.size)
# --------------------------------------------------
if __name__ == '__main__':
main()
① 选择选项接受一个值列表。如果用户未能提供这些值之一,argparse 将停止程序。
② 用户必须从 1-10 的数字中选择,否则 argparse 将因错误而停止。
③ 如果我们的程序到达这个点,我们知道 args.color 一定是这些值之一,而 args.size 是一个介于 1-10 之间的整数值。除非两个参数都有效,否则程序永远不会到达这个点。
列表中没有的任何值都将被拒绝,并且用户将看到有效的选择。再次强调,没有值被拒绝:
$ ./choices.py
usage: choices.py [-h] color size
choices.py: error: the following arguments are required: color, size
如果我们提供“purple”,它将被拒绝,因为它不在我们定义的 choices 中。argparse 生成的错误信息会告诉用户问题(“无效选择”),并列出可接受的颜色:
$ ./choices.py purple 1
usage: choices.py [-h] color size
choices.py: error: argument color: \
invalid choice: 'purple' (choose from 'red', 'yellow', 'blue')

同样,对于负的 size 参数:
$ ./choices.py red -1
usage: choices.py [-h] color size
choices.py: error: argument size: \
invalid choice: -1 (choose from 1, 2, 3, 4, 5, 6, 7, 8, 9, 10)
只有当两个参数都有效时,我们才能继续:
$ ./choices.py red 4
color = red
size = 4
这实际上是非常多的错误检查和反馈,你永远不需要编写。最好的代码是你不需要编写的代码!
A.4.4 同一位置参数的两个
如果我们正在编写一个加法两个数字的程序,我们可以将它们定义为两个位置参数,如number1和number2。但由于它们是相同类型的参数(我们将要相加的两个数字),使用nargs选项告诉argparse你想要恰好两个这样的参数可能更有意义:
#!/usr/bin/env python3
"""nargs=2"""
import argparse
# --------------------------------------------------
def get_args():
"""get args"""
parser = argparse.ArgumentParser(
description='nargs=2',
formatter_class=argparse.ArgumentDefaultsHelpFormatter)
parser.add_argument('numbers',
metavar='int',
nargs=2, ①
type=int, ②
help='Numbers')
return parser.parse_args()
# --------------------------------------------------
def main():
"""main"""
args = get_args()
n1, n2 = args.numbers ③
print(f'{n1} + {n2} = {n1 + n2}') ④
# --------------------------------------------------
if __name__ == '__main__':
main()
① nargs=2将要求恰好两个值。
② 每个值都必须可以被解析为整数值,否则程序将出错。
③ 由于我们定义了数字恰好有两个值,我们可以将它们复制到两个变量中。
④ 因为这些是实际的整数值,所以加号(+)的结果将是数值相加而不是字符串连接。
帮助信息表明我们想要两个数字:
$ ./nargs2.py
usage: nargs2.py [-h] int int
nargs2.py: error: the following arguments are required: int
当我们提供两个良好的整数值时,我们得到它们的和:
$ ./nargs2.py 3 5
3 + 5 = 8
| 注意,argparse将n1和n2的值转换为实际的整数值。如果你将type=int改为type=str,你会发现程序将打印35而不是8,因为 Python 中的+运算符既用于加法也用于字符串连接!
>>> 3 + 5
8
>>> '3' + '5'
![]() |
|---|
A.4.5 同一位置参数的一个或多个
你可以将你的两个数字加法程序扩展成一个可以累加你提供的任意多个数字的程序。当你想要某个参数的“一个或多个”值时,你可以使用nargs='+':
#!/usr/bin/env python3
"""nargs=+"""
import argparse
# --------------------------------------------------
def get_args():
"""get args"""
parser = argparse.ArgumentParser(
description='nargs=+',
formatter_class=argparse.ArgumentDefaultsHelpFormatter)
parser.add_argument('numbers',
metavar='int',
nargs='+', ①
type=int, ②
help='Numbers')
return parser.parse_args()
# --------------------------------------------------
def main():
"""main"""
args = get_args()
numbers = args.numbers ③
print('{} = {}'.format(' + '.join(map(str, numbers)), sum(numbers))) ④
# --------------------------------------------------
if __name__ == '__main__':
main()
① 加号(+)将使 nargs 接受一个或多个值。
② int表示所有值都必须是整数值。
③ 数字将是一个至少包含一个元素的列表。
④ 如果你不理解这一行,不要担心。你会在书的结尾理解它。
注意,这意味着args.numbers始终是一个list。即使用户只提供一个参数,args.numbers也将是一个包含该单个值的list:
$ ./nargs+.py 5
5 = 5
$ ./nargs+.py 1 2 3 4
1 + 2 + 3 + 4 = 10
你也可以使用nargs='*'来表示参数可以有零个或多个,而nargs='?'表示参数可以有零个或一个。
A.4.6 文件参数
到目前为止,你已经看到了如何指定一个参数应该是str(默认类型)、int或float等类型。还有很多需要文件作为输入的练习,对于这些,你可以使用argparse.FileType('r')的type来表示参数必须是一个可读的文件('r'部分)。
如果你还想要求文件必须是文本(而不是二进制文件),你可以添加一个't'。这些选项在你阅读第五章后会有更多的意义。
这里是一个 Python 中实现cat -n命令的示例,其中cat将连接一个可读的文本文件,而-n表示要编号输出行:
#!/usr/bin/env python3
"""Python version of `cat -n`"""
import argparse
# --------------------------------------------------
def get_args():
"""Get command-line arguments"""
parser = argparse.ArgumentParser(
description='Python version of `cat -n`',
formatter_class=argparse.ArgumentDefaultsHelpFormatter)
parser.add_argument('file',
metavar='FILE',
type=argparse.FileType('rt'), ①
help='Input file')
return parser.parse_args()
# --------------------------------------------------
def main():
"""Make a jazz noise here"""
args = get_args()
for i, line in enumerate(args.file, start=1): ②
print(f'{i:6} {line}', end='')
# --------------------------------------------------
if __name__ == '__main__':
main():
① 如果参数没有命名一个有效、可读的文本文件,它将被拒绝。
② args.file的值是一个打开的文件句柄,我们可以直接读取。再次提醒,如果你不理解这段代码,不要担心。我们将在章节中详细讨论文件句柄。
当我们将一个参数定义为type=int时,我们会得到一个实际的int值。在这里,我们将file参数定义为FileType,因此我们收到一个打开的文件句柄。如果我们把file参数定义为字符串,我们就必须手动检查它是否是一个文件,然后使用open()来获取文件句柄:
#!/usr/bin/env python3
"""Python version of `cat -n`, manually checking file argument"""
import argparse
import os
# --------------------------------------------------
def get_args():
"""Get command-line arguments"""
parser = argparse.ArgumentParser(
description='Python version of `cat -n`',
formatter_class=argparse.ArgumentDefaultsHelpFormatter)
parser.add_argument('file', metavar='str', type=str, help='Input file')
args = parser.parse_args() ①
if not os.path.isfile(args.file): ②
parser.error(f'"{args.file}" is not a file') ③
args.file = open(args.file) ④
return args
# --------------------------------------------------
def main():
"""Make a jazz noise here"""
args = get_args()
for i, line in enumerate(args.file, start=1):
print(f'{i:6} {line}', end='')
# --------------------------------------------------
if __name__ == '__main__':
main()
① 截获参数。
② 检查文件参数是否不是一个文件。
③ 打印错误信息并以非零值退出程序。
④ 将文件替换为打开的文件句柄。
使用FileType定义,你不需要编写任何这样的代码。
你也可以使用argparse.FileType('w')来表示你想要一个可以打开用于写入的文件名('w')。你可以传递额外的参数来指定如何打开文件,例如编码。有关更多信息,请参阅文档。
A.4.7 手动检查参数
在我们从get_args()返回之前,也可以手动验证参数。例如,我们可以定义--int应该是一个int,但如何要求它必须在 1 到 10 之间呢?
一种相当简单的方法是手动检查值。如果有问题,你可以使用parser.error()函数来停止程序的执行,打印错误信息以及简短的用法说明,然后以错误值退出:
#!/usr/bin/env python3
"""Manually check an argument"""
import argparse
# --------------------------------------------------
def get_args():
"""Get command-line arguments"""
parser = argparse.ArgumentParser(
description='Manually check an argument',
formatter_class=argparse.ArgumentDefaultsHelpFormatter)
parser.add_argument('-v',
'--val',
help='Integer value between 1 and 10',
metavar='int',
type=int,
default=5)
args = parser.parse_args() ①
if not 1 <= args.val <= 10: ②
parser.error(f'--val "{args.val}" must be between 1 and 10') ③
return args ④
# --------------------------------------------------
def main():
"""Make a jazz noise here"""
args = get_args()
print(f'val = "{args.val}"')
# --------------------------------------------------
if __name__ == '__main__':
main()
① 解析参数。
② 检查args.int的值是否不在 1 到 10 之间。
③ 使用错误信息调用parser.error()。错误信息和简短的用法说明将显示给用户,程序将立即以非零值退出以指示错误。
④ 如果我们到达这里,一切正常,程序将继续按正常方式运行。
如果我们提供一个好的--val,一切都会顺利:
$ ./manual.py -v 7
val = "7"
如果我们用类似20这样的值运行这个程序,我们会得到一个错误信息:
$ ./manual.py -v 20
usage: manual.py [-h] [-v int]
manual.py: error: --val "20" must be between 1 and 10
在这里无法判断,但parser.error()也会导致程序以非零状态退出。在命令行世界中,退出状态为0表示“没有错误”,所以任何不是0的都视为错误。你可能还没有意识到这有多么美妙,但请相信我,确实如此。
A.4.8 自动帮助
当你使用argparse定义程序参数时,-h和--help标志将被保留用于生成帮助文档。你不需要添加这些,也不允许将这些标志用于其他目的。
| 我认为这份文档就像是你程序的入口。门是我们进入建筑物和汽车等地方的方式。你有没有遇到过不知道如何打开的门?或者一个明明把手是设计用来“拉”的,却需要“推”的标志?唐·诺曼(Don Norman)在《日常事物的设计》(The Design of Everyday Things,Basic Books,2013)一书中使用“affordances”这个术语来描述对象向我们展示的界面,这些界面或描述了我们应该如何使用它们,或不描述。 | ![]() |
|---|
您程序的用法说明就像门把手一样。它应该让用户确切地知道如何使用它。当我遇到一个我从未使用过的程序时,我会要么不带参数运行它,要么使用-h或--help。我期望看到某种用法说明。唯一的替代方案是打开源代码本身并研究如何运行程序以及如何修改它,而这绝对是一种不可接受的软件编写和分发方式!
当您使用new.py或foo.py开始创建一个新的程序时,这将生成的帮助信息是:
$ ./foo.py -h
usage: foo.py [-h] [-a str] [-i int] [-f FILE] [-o] str
Rock the Casbah
positional arguments:
str A positional argument
optional arguments:
-h, --help show this help message and exit
-a str, --arg str A named string argument (default: )
-i int, --int int A named integer argument (default: 0)
-f FILE, --file FILE A readable file (default: None)
-o, --on A boolean flag (default: False)
不需要写一行代码,您就有
-
一个可执行的 Python 程序
-
各种命令行参数
-
一个标准且有用的帮助信息
这是您程序的“把手”,您不需要写一行代码就能得到它!
摘要
-
位置参数通常是必需参数。如果您有两个或更多代表不同概念的参数,最好将它们设置为命名选项。
-
可选参数可以命名,例如
--filefox.txt,其中fox.txt是--file选项的值。建议您始终为选项定义一个默认值。 -
argparse可以强制执行许多参数类型,包括像int和float这样的数字,甚至是文件。 -
像这样的
--help标志没有关联的值。如果存在,它们通常被认为是True;如果不存在,则是False。 -
-h和--help标志是为argparse保留的。如果您使用argparse,您的程序将自动对这些标志做出响应,显示用法说明。
1 我总是想象那个会为每个输入都输入“放屁”的孩子。

























































































浙公网安备 33010602011771号