AI-辅助-Python-编程学习指南第二版-全-

AI 辅助 Python 编程学习指南第二版(全)

原文:annas-archive.org/md5/e8d8fc4add1a7d41e1c0d2f5212d2aa9

译者:飞龙

协议:CC BY-NC-SA 4.0

第一章:1 使用 GitHub Copilot 引入 AI 辅助编程

本章内容包括

  • AI 助手如何改变新程序员的学习方式

  • 为什么编程永远不会像以前那样

  • AI 助手如 GitHub Copilot 是如何工作的

  • AI 辅助编程的潜在危险

计算机编程长期以来一直是经过特殊训练和具备高级技能的专业人士的领域。毕竟,你希望银行、手机、汽车等应用程序每次都能准确无误地运行!正如曾经需要占据整个房间的计算机、堆积如山的纸卡片和数英里的磁带已经被现代设备所取代,编程语言和工具也变得更加易于使用。而现在,像 ChatGPT 这样的人工智能(AI)工具使得几乎每个人都能接触到计算机编程。我们希望能帮助你打开这扇门!

学会编程,你就能够承担工作中的新任务,创造自己的计算机游戏,并让计算机为你在工作中服务。在本书中,我们将向你展示如何使用 ChatGPT 和 GitHub Copilot 编写自己的计算机程序。在此过程中,你还将学习 Python,这是一种最受欢迎的编程语言之一。

1.1 改善我们与计算机的对话方式

让我们从让计算机从 0 数到 9 开始。几十年前,一本关于编程的书可能会让你学习如何阅读和理解以下代码(基于mng.bz/EOdO):

section .text 
global _start 
_start: 
    mov ecx, 10 
    mov eax, '0' 
    l1: 
    mov [num], eax 
    mov eax, 4 
    mov ebx, 1 
    push ecx 
    mov ecx, num 
    mov edx, 1 
    int 0x80 
    mov eax, [num] 
    inc eax 
    pop ecx 
    loop l1 
    mov eax, 1 
    int 0x80 
section .bss 
    num resb 1

我们很高兴不再用那种方式来编程了。那种怪物代码是使用汇编语言写的,这是一种低级编程语言。低级编程语言,正如你所见,并不是人类可以轻松读写的语言。它们是为计算机设计的,而不是为人类设计的。

没有人希望像那样写程序,但尤其在过去,有时这是必要的。程序员可以使用它来精确定义他们希望计算机执行的操作,直到每一条指令。为了从性能较弱的计算机中榨取每一分性能,确实需要这种控制级别。例如,1990 年代最具速度要求的计算机游戏,如《毁灭战士》和《雷神之锤》,就像前面的代码示例一样是用汇编语言编写的。如果没有汇编语言,根本不可能制作这些游戏。

1.1.1 让它稍微变得更简单

好的,我们继续。这里有一个更现代的计算机程序,它同样打印数字。

for num in range(0, 9): 
     print(num)

这段代码使用的是 Python 语言,现代许多程序员都在使用它。与汇编语言这种低级语言不同,Python 被认为是一种高级语言,因为它更接近自然语言。即使你还不懂 Python 代码,你也许可以猜到这个程序试图做什么。第一行看起来是在处理从 0 到 9 的数字范围。第二行则是在打印某些东西。相信这段程序,就像那个汇编语言的怪物一样,应该是要打印出从 0 到 9 的数字。不幸的是,出了点问题,它打印的是 0 到 8 的数字。

尽管这段代码更接近英语,但它并不是英语。它是一种编程语言,就像汇编语言一样,有其特定的规则。正如前面的代码那样,误解这些规则的细节可能会导致程序出错。如果你感到好奇,误解的规则是 range 函数在第二个数字之前停止,因此它不包括数字 9。如果你想要从 0 打印到 9,你需要写 range(0,10)

与计算机交流的终极目标是使用像英语这样的自然语言。过去 80 年,我们之所以用各种编程语言与计算机交流,并非因为我们愿意,而是因为我们不得不这样做。计算机的能力不足以应对像英语这样的语言的复杂性和特异性。我们的编程语言不断改进——例如从符号混乱的汇编语言到 Python——但它们仍然是计算机语言,而非自然语言。现在,这种情况正在发生变化。

1.1.2 使其变得更加容易

使用 AI 助手,我们现在可以用英语提出需求,然后让计算机代码根据我们的需求生成回应。为了得到一个正确的 Python 程序,打印出从 0 到 9 的数字,我们可以像这样用普通英语向我们的 AI 助手(Copilot)提问:

# Output the numbers from 0 to 9

Copilot 可能会对这个提示生成如下内容:

for i in range(10):
    print(i)

与我们之前展示的例子不同,这段 Python 代码实际上是有效的!

AI 编程助手可以帮助人们编写代码。在本书中,我们将学习如何使用 Copilot 为我们编写代码。我们用英语提需求,然后返回 Python 代码。

此外,我们将能够将 Copilot 作为我们工作流程的无缝一部分使用。没有像 Copilot 这样的工具,程序员通常会同时打开两个窗口:一个用来编写代码,另一个用来搜索如何编写代码。第二个窗口中包含 Google 搜索结果、Python 文档或程序员讨论如何编写代码以解决特定问题的论坛。他们经常将这些结果中的代码粘贴到他们的代码中,稍作调整以适应他们的上下文,尝试替代方案等等。这已经成为程序员的生活方式,但你可以想象这里的低效率。据估计,程序员高达 35% 的时间用于搜索代码[1],而找到的大部分代码并不容易使用。Copilot 大大改善了这一体验,帮助我们编写代码。

1.2 关于技术

在本书中,我们将使用两种主要技术:Python 和 GitHub Copilot。Python 是我们将使用的编程语言,GitHub Copilot 是我们的 AI 助手,将帮助我们处理 Python 代码。

1.2.1 Python,你的编程语言

如前所述,Python 是一种编程语言,用于与计算机进行通信。人们使用它来编写各种有用的程序,如数据分析、游戏、交互式网站、可视化、文件组织应用程序、自动化例行任务等。

还有其他编程语言,如 Java、C++、Rust 等等。Copilot 也可以与这些语言一起使用,但在撰写本文时,它与 Python 的兼容性特别好。与许多其他语言(尤其是汇编语言)相比,Python 代码更容易编写。更重要的是,Python 容易阅读。毕竟,我们不会亲自编写 Python 代码——我们的 AI 助手会!

计算机不知道如何阅读和运行 Python 代码。计算机能理解的唯一东西叫做机器代码,它看起来比汇编代码更荒谬,因为它是汇编代码的二进制表示(没错,只是一堆 0 和 1!)。在幕后,你的计算机会在运行之前将你提供的任何 Python 代码转换为机器代码,如图 1.1 所示。

图 1.1

图 1.1 你的 Python 程序在显示屏上输出之前会经历几个步骤。

因此,再也没有人在计算机的机器代码语言中从头开始编写代码了。程序员们都选择在特定时刻最方便的语言,并使用软件帮助他们编写、运行和调试(即修复)代码,这种软件称为集成开发环境(IDE)。在本书中,我们将使用 Visual Studio Code(VS Code)作为我们的 IDE,因为它与 GitHub Copilot 配合得非常好。

1.2.2 GitHub Copilot,你的 AI 助手

什么是 AI 助手?AI 助手是一种帮助你完成工作的 AI 代理。也许你家里有一个 Amazon Alexa 设备,或者有一部带 Siri 的 iPhone——它们就是 AI 助手。它们帮助你订购物品、了解天气,或者确认在 哈利·波特 电影中扮演贝拉特里克斯的女人确实也出演了 搏击俱乐部。AI 助手就是一个计算机程序,它能够回应人类的典型输入,如语音和文本,并给出类人的回答。

Copilot 是一个具有特定功能的 AI 助手:它将英语转换为计算机程序(以及更多内容,我们很快就会看到)。还有其他像 Copilot 这样的 AI 助手,包括 Amazon Q Developer、Tabnine 和 Ghostwriter。我们选择 Copilot 来作为本书的工具,基于它能够生成的代码质量、稳定性(它从未崩溃过!)以及我们个人的偏好。当你觉得自己舒适时,我们鼓励你也去尝试其他工具。

1.2.3 Copilot 如何在幕后工作——30 秒内了解

你可以将 Copilot 想象成你与编写的计算机程序之间的一层。你不需要直接编写 Python 代码,而是用语言描述你想要的程序——这叫做提示——然后 Copilot 会为你生成程序。

Copilot 背后的“大脑”是一个复杂的计算机程序,叫做大型语言模型(LLM)。LLM 存储了关于单词之间关系的信息,包括哪些单词在某些上下文中是合适的,并利用这些信息来预测最佳的单词序列,以回应提示。

想象一下,我们问你该在这个句子中填入下一个词:“The person opened the ________。”你可以填入很多词,比如“door”(门)、“box”(箱子)或“conversation”(对话),但也有很多词不适合放在这里,比如“the”(定冠词)、“it”(它)或“open”(开放)。LLM 会考虑当前单词的上下文来生成下一个词,它会一直这样做,直到完成任务。它的工作方式是非确定性的,这意味着它的决策有点随机,也就是说,如果你要求它填入这个词,有时它会给你“door”,有时它会给你“box”。这也意味着,如果你让 Copilot 给你代码,它每次给出的答案可能会不同。

此外,请注意我们并没有说 Copilot 理解它正在做什么。它只是利用当前的上下文来继续编写代码。请在整个学习过程中牢记这一点:只有我们知道生成的代码是否达到了我们的预期。它通常是有效的,但你仍然应该保持健康的怀疑态度。图 1.2 可以让你了解 Copilot 如何从提示到程序的转化过程。

figure

图 1.2 使用 Copilot 从提示到程序的转化

你可能会想,为什么 Copilot 为我们编写 Python 代码,而不是直接编写机器代码呢?难道 Python 现在已经是一个可有可无的中间步骤了吗?其实不是,原因在于 Copilot 会犯错。如果它犯的错误我们需要修复,那么用 Python 来修复比用机器代码要容易得多。

事实上,几乎没有人检查从 Python 生成的机器代码是否正确。这部分是因为 Python 语言规范的确定性。可以想象,在未来,Copilot 的对话会精确到不再需要检查 Python 代码,但我们离这个目标还很远。

1.3 Copilot 如何改变我们学习编程的方式

过去,学习编程的学员常常花费大部分时间来处理程序的语法和基本结构。当我们使用 语**法 这个词时,我们指的是在特定语言中有效的符号和单词。程序员需要从零开始编写程序的所有语法(一个字符一个字符地写,一行一行地写)。以前学习编程的人往往需要花费数周甚至数月的时间,才能写出最基本的程序。而现在,Copilot 可以立即编写这些相同的基本程序,并提供几乎总是语法和结构正确的代码。正如你将在本书的后续部分看到的,我们仍然需要验证这些代码是否正确,因为 Copilot 也会犯错。然而,我们不再需要从零开始编写代码。我们相信,Copilot 和类似工具标志着传统编程学习方式的结束。

作为一个有兴趣学习编程的人,你完全不需要再为语法、如何调用某个 Python 函数以及编写代码时需要的其他 Python 概念而挣扎,像过去那样。没错,我们会在本书中学习这些概念,但并不是为了让你通过从零开始编写 Copilot 容易生成的代码来展示你的理解。不是的,我们学习这些概念只是因为它们帮助我们解决有意义的问题,并能与 Copilot 进行有效的互动。相反,由于 AI 助手从根本上改变了学习编程所需的技能,你可以更快地学习如何编写更大、更有意义的软件。

1.4 Copilot 还能为我们做些什么?

正如我们所见,我们可以使用 Copilot 根据我们想要的内容的英文描述来编写 Python 代码。因此,我们可以说 Copilot 接收一个英文语法的描述,并返回一个 Python 语法的代码。这是一个巨大的进步,因为学习编程语法历来是新程序员的一大难关。我要使用什么样的括号——[, (, 还是 {——这里应该使用哪一个?这里需要缩进吗?我们应该以什么顺序编写这些内容:先写 x 再写 y,还是先写 y 再写 x?

这类问题不胜枚举,——说实话——这不是什么有趣的话题。当我们只想编写程序以实现某些功能时,谁会在乎这些呢?Copilot 可以帮助我们摆脱语法的繁琐。我们认为这是帮助更多人成功编写程序的重要一步,并期待有一天这种人工障碍完全消失。目前,我们仍然需要 Python 语法,但至少 Copilot 可以帮助我们处理它。

但这并不是 Copilot 唯一能做的事情。以下是一些相关的——而且同样重要的——任务,Copilot 可以帮助我们完成:

  • 解释代码—当 Copilot 为我们生成 Python 代码时,我们需要判断这段代码是否做了我们想要的事情。正如我们之前所说,Copilot 会犯错误。虽然我们不打算教你 Python 工作的每一个细节(那是旧的编程模式),但我们会教你如何阅读 Python 代码,全面了解它的功能。我们还会使用 Copilot 中解释代码的功能,用英语向你解释代码。当你看完本书并理解我们的讲解后,Copilot 仍然可以帮助你理解它生成的下一段复杂代码。

  • 使代码更易理解—写代码的方式有很多种,完成相同任务的方法也不止一个。有些方式比其他的更容易理解。你可以让 Copilot 重新组织你的代码,使其更易于操作。更易读的代码通常也更容易在需要时进行增强或修复。

  • 修复 bugbug是编写程序时出现的错误,可能导致程序执行错误的操作。有时,你的 Python 代码几乎能工作,或者几乎总是能工作,但在某些特定情况下却不行。如果你听过程序员的故事,你可能听过这样一个常见的情节:程序员花了好几个小时,最后才发现一个等号(=)符号导致程序失败。那绝对不是一个愉快的几个小时!在这种情况下,你可以尝试 Copilot 的功能,帮助自动查找并修复程序中的 bug。

  • 解释错误—如果你的代码无法正常工作,你通常会从 Python 运行时环境中收到错误报告。这些错误有时可能相当晦涩,但 Copilot 可以帮助你解读这些错误,并指导你如何修复它们。

  • 寻找 Python 库—Python 是一种成熟的语言,拥有许多可以帮助特定任务的模块(库),例如数据分析、编写游戏、处理不同的图像文件格式等等。与 Copilot 进行简短对话,通常能帮助你找到让工作更轻松的模块,并提供入门的示例代码。

1.5 使用 Copilot 时的风险与挑战

现在,我们都迫不及待地想让 Copilot 为我们编写代码,但我们还需要讨论使用 AI 助手时的潜在风险(有关这些问题的详细解释,请参见参考资料 [2] 和 [3]):

  • 版权—Copilot 是通过使用人类编写的代码来学习编程的。(你会听到人们在谈论像 Copilot 这样的 AI 工具时使用“训练”这个词。在这个上下文中,训练是学习的另一种说法。)更具体来说,它是通过数百万个包含开源代码的 GitHub 仓库进行训练的。一个担忧是 Copilot 会“窃取”这些代码并提供给我们。根据我们的经验,Copilot 并不常常建议大量他人的代码,但这种可能性确实存在。即使 Copilot 给出的代码是融合和转化了其他人代码的不同部分,仍然可能会存在许可问题。例如,Copilot 生成的代码归谁所有?目前对此问题没有共识。Copilot 团队正在添加一些功能来帮助解决这个问题;例如,Copilot 可以告诉你它生成的代码是否与已有的代码相似,以及该代码的许可证是什么[4]。独立学习和实验是很棒的,我们鼓励这样做——但如果你打算将这些代码用于家以外的目的,请小心。在这方面我们故意有些模糊,因为法律可能需要一些时间才能赶上这项新技术。在社会进行这些辩论时,最好保持谨慎。

  • 教育—作为初级编程课程的讲师,我们亲眼见证了 Copilot 在我们过去给学生布置的作业中的表现。在一项研究[5]中,Copilot 被要求解决 166 个常见的初学者编程任务。它做得怎么样?在第一次尝试时,它解决了近 50% 的问题。如果给 Copilot 更多信息,这个数字上升到了 80%。鉴于像 Copilot 这样的工具,教育需要发生变化,讲师们目前正在讨论这些变化可能会是什么样子。在一些学校,学生被允许使用 Copilot 来帮助学习和完成作业。在其他学校,Copilot 在某些情况下(例如考试)或某些学生(如计算机科学专业学生)中不被允许使用。在许多学校,LLM 被允许作为学生的辅导员。在某些情况下,这些 LLM 辅导员只是像 Copilot 或 ChatGPT 这样的普通 LLM,但在其他情况下,LLM 界面已经被改变,以限制学生收到的回答类型。现在还为时过早去知道 LLM 将如何影响计算机教育,但像这样的趋势已经开始出现。

  • 代码质量——我们需要小心,不要轻易相信 Copilot,特别是涉及敏感代码或需要保证安全性的代码。例如,为医疗设备编写的代码,或者处理敏感用户数据的代码,必须始终彻底理解。询问 Copilot 获取代码,惊叹于它生成的代码,并接受这些代码而不加审查是很有诱惑力的。但这些代码可能是完全错误的。在本书中,我们将使用一些不会大规模部署的代码,因此,虽然我们会专注于编写正确的代码,但我们不会担心将这些代码用于更广泛用途的影响。我们还将为你打下基础,使你能够独立判断代码是否正确。

  • 代码安全性——与代码质量一样,使用 Copilot 生成的代码在安全性方面绝对没有保障。例如,如果我们正在处理用户数据,仅仅从 Copilot 获取代码是不够的。我们还需要进行安全审计,并拥有相关专业知识来确定代码的安全性。尽管如此,我们在本书中不会将 Copilot 的代码用于实际场景,因此我们不会过多关注安全问题。

  • 不是专家——成为专家的标志之一是了解自己知道什么,同样重要的是知道自己不知道什么。专家通常能够清楚地表明他们对自己的回答有多有信心,如果他们的信心不足,他们会继续学习,直到知道自己知道。Copilot,甚至更广泛的 LLM,并没有做到这一点。你问它们一个问题,它们就回答,简单明了。如果有必要,它们会胡乱编造:它们会把一些真相与一些垃圾信息混合成一个听起来合理,但总体上没有意义的回答。例如,我们曾看到 LLM 为活着的人编造讣告,这毫无意义,但这些“讣告”确实包含了一些关于人们生活的真实元素。当被问及为什么算盘能比计算机更快地进行数学运算时,我们曾看到 LLM 给出了自信的回答——包括关于算盘是机械的,因此必然是最快的某些说法。现在,LLM 在这方面仍在进行改进,以便能够说:“抱歉,我不知道这个”,但我们还没有达到这个水平。它们不知道自己不知道,这意味着它们需要监督。

  • 偏见——大型语言模型(LLMs)会再现它们所训练数据中存在的偏见。如果你让 Copilot 生成一个名字列表,它将主要生成英文名字。如果你要求生成图表,它可能会生成一个没有考虑到人类感知差异的图表。而且,如果你要求生成代码,它可能会生成一种风格,类似于某些特定群体编写代码的方式。(毕竟,计算机领域中较为普及的群体编写了世界上大部分的代码,而 Copilot 就是在这些代码的基础上训练的。)计算机科学和软件工程长期以来一直存在多样性缺失的问题。我们不能再进一步压制多样性,必须逆转这一趋势。我们需要让更多的人进入这个领域,并允许他们以自己的方式表达自己。目前,如何通过像 Copilot 这样的工具来处理这一问题仍在研究之中,这对编程的未来至关重要。然而,我们相信,Copilot 有潜力通过降低进入门槛来改善多样性。

1.6 我们需要的技能

如果 Copilot 可以编写我们的代码、解释它,并修复其中的错误,那我们就完成了吗?我们只需要告诉 Copilot 做什么,然后庆祝我们的卓越成就吗?

不,首先,Copilot 可能会犯错。它给出的代码可能在语法上是正确的,但有时并不会做我们希望它做的事情。我们需要保持警觉,及时发现 Copilot 的错误。其次,尽管程序员依赖的一些技能(例如,编写正确的语法)将变得不那么重要,但其他技能仍然至关重要。例如,你不能让 Copilot 完成一个巨大的任务,比如,“做一个视频游戏。哦,而且让它有趣。” Copilot 会失败。相反,我们需要将这样一个大任务分解成 Copilot 可以帮助完成的小任务。我们怎么才能把一个问题分解成这样?事实证明,这并不容易。人类在与像 Copilot 这样的工具进行对话时,需要培养这个关键技能,我们将在全书中教授这一技能。

其他技能,信不信由你,可能在 Copilot 中变得更加重要。测试代码一直是创建高质量代码中的一项关键任务。我们对测试人类编写的代码了解很多,因为我们知道在哪里寻找典型的问题。我们知道,人类在值的边界处经常会犯编程错误。例如,如果我们编写一个程序来乘两个数字,我们很可能在大多数情况下得出正确的结果,但如果其中一个值是 0 时,可能就会出错。那么 AI 编写的代码呢,20 行完美的代码可能隐藏着一行如此荒谬的代码,以至于我们可能根本没有预料到它会出现在这里?我们没有这方面的经验。我们需要比以往更加小心地进行测试。

我们还需要知道当代码错误时,如何修复错误。这个过程叫做调试,它仍然至关重要,尤其是当 Copilot 给出的是接近正确但尚未完全正确的代码时。

最后,一些必备技能是全新的。这里的主要技能叫做 提示工程,即如何告诉 Copilot 做什么。正如之前提到的,当我们要求 Copilot 编写代码时,我们使用提示来提出请求。虽然我们可以用英语写这个提示并要求我们想要的内容,但这还不够。如果我们希望 Copilot 做对的事,我们需要非常精准。而且,即使我们很精确,Copilot 仍然可能会做错事。在这种情况下,我们首先需要确认 Copilot 确实犯了错误。然后,我们可以尝试调整我们的描述,希望能将其引导到正确的方向。在我们的经验中,提示的看似微小的变化可能会对 Copilot 的输出产生巨大的影响。在本书中,我们将教你所有这些技能。

1.7 关于像 Copilot 这样的 AI 代码助手的社会担忧

目前社会上对像 Copilot 这样的 AI 代码助手存在不确定性。我们认为可以通过一些问题和我们目前的回答来结束这一章。或许你自己也在思考这些问题!我们的回答可能最终是荒谬的错误,但它们确实捕捉了我们作为两位教授和研究员的当前思考,我们已经将职业生涯奉献给了编程教学:

Q: 现在有了 Copilot,技术和编程工作会减少吗?

A: 可能不会。我们预计改变的是这些工作的性质。例如,我们认为 Copilot 能够帮助完成许多通常与初级编程工作相关的任务。这并不意味着初级编程工作会消失,只是随着程序员能够利用越来越复杂的工具完成更多工作,这些工作会发生变化。

Q: Copilot 会扼杀人类的创造力吗?它会一直在重复和回收人类已经写过的相同代码,限制新思想的引入吗?

A: 我们怀疑不会。Copilot 帮助我们在更高的层次上工作,远离底层的机器代码、汇编代码或 Python 代码。计算机科学家用 抽象 这个术语来指代我们能够与计算机的低级细节脱离的程度。自计算机科学诞生以来,抽象一直在发生,而且我们似乎并没有因此受到影响。相反,它使我们能够忽略那些已经解决的问题,专注于解决越来越广泛的问题。实际上,正是更好的编程语言的出现促成了更好的软件——例如支持 Google 搜索、亚马逊购物车和 macOS 的软件——这些软件在我们只有汇编语言的时候是无法(也可能不可能)编写出来的!

Q: 我一直听说 ChatGPT。它是什么?和 Copilot 是一样的吗?

A: 它与 Copilot 不同,但却建立在相同的技术基础上。不过,与其专注于代码不同,ChatGPT 更专注于一般知识。因此,它已经渗透到比 Copilot 更广泛的任务中。例如,它可以回答问题、写文章,甚至在沃顿商学院的 MBA 考试上表现出色[6]。教育将因此需要改变:我们不能让人们通过 ChatGPT 轻易获得 MBA!我们花时间的有价值方式可能会改变。人类是否会继续写书,如果是的话,又会以何种方式写书?人们是否愿意阅读知道它们部分或全部由 AI 编写的书籍?这将在包括金融、医疗保健和出版等行业产生影响[7]。与此同时,现在存在着无限的炒作,因此很难分辨真实和虚构。这个问题被一个简单的事实加剧,即没有人知道长期来看会发生什么。罗伊·阿玛拉(被称为阿玛拉定律)提出过一句古谚:“我们往往高估了技术在短期内的影响,却低估了其长期的影响。” 因此,我们需要尽力关注讨论,以便适应变化。

在下一章中,我们将帮助您在您的计算机上开始使用 Copilot,这样您就可以开始编写软件了。

总结

  • Copilot 是一个 AI 助手,是一个帮助您完成工作的 AI 代理。

  • Copilot 改变了人类与计算机的互动方式,以及我们编写程序的方式。

  • Copilot 改变了我们需要精进的技能重点(不再注重语法,而更注重问题分解和测试)。

  • Copilot 是不确定性的;有时它会生成正确的代码,有时则不会。我们需要保持警惕。

  • 有关代码版权、教育和职业培训方面的问题,以及 Copilot 结果中的偏见,仍需要解决。

第二章:2 开始使用 Copilot

本章内容包括

  • 在你的系统上设置 Python、Visual Studio Code 和 Copilot

  • 介绍 Copilot 设计过程

  • 理解 Copilot 在数据处理任务中的价值

我们希望你能够从一开始就自己创建软件。为此,我们将引导你在你的计算机上设置 Visual Studio Code (VS Code)、Python 和 Copilot,并让你熟悉如何与这些工具进行互动。设置好这些工具后,你将能够跟随我们的示例并开始自己创建软件。没有什么比实践更重要,我们相信你可以和我们一起学习,直到本书的最后。

一旦你设置好 Copilot,我们将通过一个有趣的示例来展示 Copilot 在解决标准任务中的强大功能。你将看到如何与 Copilot 互动,并且你将学会如何在不写任何实际代码的情况下编写软件。请记住,Copilot 并不完美,你需要能够阅读和理解一些 Python 代码才能获得你想要的结果,但它为你提供了一个巨大的起点。让我们开始创建你的第一个计算机程序吧。

2.1 设置你的计算机以开始学习

学习如何编写软件需要你不仅仅是阅读它,还要实际进行编写软件的任务。如果这本书是关于如何弹吉他,你会在不尝试弹吉他的情况下继续读下去吗?我们猜不会。阅读这本书而不跟着一起尝试,就像是看着马拉松选手完成比赛,然后想着自己也准备好去跑马拉松了。我们不再使用类比,但说真的,你需要先安装好软件并让它运行,然后我们才能继续。

现在我们最担心的是,我们刚刚触及到最常见的新手难点,甚至是那些渴望学习编程的人,往往都会在这一点上失败,而我们真的希望你能够成功。现在,你可能在想,“等一下,真的吗?我们才刚开始。” 是的,这正是关键所在。在 Leo 的热门 Coursera 课程中关于学习 Java 编程的部分,你能猜到大多数新学员什么时候退出吗?是课程末尾那个挑战性的作业,需要在全球实时绘制地震标记吗?不。实际上是那道热身作业,要求学员们设置他们的编程环境。因此,我们理解这可能是你面临的一个障碍。希望通过这次不那么微妙的提示,我们能帮助你实现购买本书时所设定的所有目标。所有的开始都从安装软件开始。

2.2 我们将使用的软件

为了轻松设置和使用 Copilot,我们将安装初学者和软件工程师都在使用的软件编辑工具。你将使用的工具有 Python、VS Code、GitHub 和 Copilot。当然,如果你已经安装了所有这些工具,可以跳到第 2.6.1 节。

2.2.1 Python

任何编程语言都可以用于本书的内容,但我们选择了 Python,因为它是世界上最流行的编程语言之一,也是我们大学入门课程中教授的语言。正如我们在第一章所说,与其他语言相比,Python 更易于阅读、更易于理解、也更易于编写。在本书中,Copilot 将主要生成代码,而不是你。但是,你需要阅读并理解 Copilot 生成的代码,Python 在这方面非常适合。

2.2.2 Visual Studio Code

你可以使用任何文本编辑器进行编程。然而,如果你想要一个良好的编程环境,可以在其中编写代码,轻松从 Copilot 获取建议并运行代码,那么 VS Code 是我们推荐的工具。VS Code 被初学者用来学习软件开发,并深受学生们的喜爱[2]。它在全球范围内也被专业软件工程师使用,这意味着你可以在完成本书后,继续使用这个环境进行工作和学习。为了让 VS Code 能够在本书中使用,你需要安装一些扩展,以便进行 Python 编程并使用 Copilot,但 VS Code 的一大优点是安装这些扩展非常简单。

2.2.3 GitHub 账户

GitHub 是一个业界标准工具,用于开发、维护和存储软件。然而,在本书中我们不会使用 GitHub。我们注册 GitHub 只是因为你需要一个账户来访问 Copilot。注册 GitHub 账户是免费的,但截至写作时,他们会对 Copilot 收费。如果你是学生,他们将免除这笔费用。如果你不是学生,截至写作时,你可以获得 30 天的免费试用。

你可能会问,为什么他们要收费提供这个服务,答案是有原因的。构建 GPT 模型的成本非常高(想象一下成千上万台计算机运行一年来构建模型),而 GitHub 通过提供来自模型的预测而产生费用(许多机器接收你的输入,将其通过模型处理,然后生成你的输出)。如果你还没有准备好长期使用 Copilot,你可以在注册后大约 25 天左右在日历上记下一个提醒,如果到时你没有使用 Copilot,就可以取消。如果你已经成功学会了如何使用 Copilot 编写软件,并且在工作或作为爱好时用它提高生产力,那么继续使用它是有意义的。

在本章中,我们将安装所有这些工具,但我们会分两部分进行。接下来的第一部分将帮助你设置好写代码和运行代码的环境,使你熟悉这个过程。第二部分将帮助你设置 Copilot,以便在编写代码的过程中为你提供帮助。

2.3 设置你的系统:第一部分

在本安装指南的第一部分,我们将安装 Python 和 VS Code。为了简化本节内容,我们仅列出你应该遵循的主要步骤。然而,在以下位置提供了更详细的说明:

  • VS Code 提供了一个关于如何开始编写 Python 代码的教程,网址是 mng.bz/znjQ

  • 本书的网站 (mng.bz/0M46) 提供了详细的 PC 和 macOS 系统设置说明。由于这些工具的网址可能会在我们编写本书后发生变化,我们建议你将 GitHub 链接和本书网站结合使用。

  • 在在线书籍论坛 (mng.bz/NBK1) 中,你可以寻求帮助并查看常见问题的答案。

你需要完成的主要步骤如下:

  1. 安装 Python:

  2. 安装 VS Code:

    • 访问 code.visualstudio.com/download,并选择适合你操作系统(如 Windows 或 Mac)的主下载版本。

    • 下载并安装最新版本的 VS Code。

  3. 安装 VS Code 扩展:有关详细信息,请参见 mng.bz/9o01

    • Python(由 Microsoft 提供)—请按照 mng.bz/j0gP 上的说明正确设置 Python 扩展(特别是选择正确的解释器)。

尽管这里的说明很简洁,但我们知道实际操作可能需要一些时间。如果你遇到任何问题,请参考之前提到的资源,获取更详细的设置说明。

2.4 在 Visual Studio Code 中使用 Python

现在你的系统已设置好,让我们熟悉一下图 2.1 中显示的 VS Code 界面(你可能需要点击左上方的资源管理器图标,才能看到相同的视图)。以下是图 2.1 中标识的区域:

  • 活动栏—在最左侧是活动栏,我们可以在其中打开文件夹(也称为目录)或安装扩展(如前一节中安装 Python 扩展时所做的)。

  • 侧边栏—侧边栏显示的是当前活动栏中打开的内容。在图 2.1 中,活动栏显示选择了资源管理器,因此侧边栏显示的是当前文件夹中的文件。

  • 编辑窗格—这些是我们创建软件的主要区域。编辑窗格中的编辑器类似于任何其他文本编辑器,你可以使用剪贴板来写作、编辑、复制和粘贴文本。这个编辑器不同之处在于它专门为代码编写设计,因此在这窗口中编写的代码会特别合适。此时,我们将在该窗口中编写代码,但在本章后面,你主要将在此窗口中通过请求 Copilot 生成代码,然后测试这些代码。

figure

图 2.1 VS Code 界面 [3]
  • 输出和终端面板——在这个界面区域,你将看到代码的输出或任何发生的错误,具体在以下标签页中:问题、输出、调试控制台和终端。我们主要使用问题标签页,在这里可以看到代码中的潜在错误,和终端标签页,在这里可以与 Python 互动并看到代码的输出。

VS Code 有多种不同的配色方案,你可以选择任何你喜欢的配色方案,因为功能是一样的。

2.4.1 设置你的工作文件夹

在 VS Code 左侧活动栏的顶部,你会找到资源管理器作为第一个图标。点击资源管理器后,你会看到一个“未打开文件夹”的消息。点击按钮打开一个文件夹,并选择你计算机上的一个文件夹(或者新建一个文件夹——我们喜欢将文件夹命名为 fun_with_Copilot)。打开这个文件夹后,你的工作空间将是你打开的文件夹,这意味着你应该将代码和任何数据文件(比如本章稍后会用到的文件)保存在这个文件夹中。

文件未找到或文件缺失错误

如果你收到一个错误提示,说缺少某个文件,请不要灰心:这些错误是每个人都会犯的。当编写软件时,这种错误特别烦人。也许你只是没有把文件放到工作文件夹里——这种情况很常见——但你可以通过将文件复制或移动到正确的文件夹来轻松修复它。然而,有时你会看到文件确实在文件夹里,但当你在 VS Code 中运行代码时,Python 似乎找不到它。如果你遇到这种情况(我们在写书时也遇到过!),请确保在 VS Code 中使用资源管理器打开包含代码和所需文件的文件夹(如图 2.1 中侧边栏所示)。

2.4.2 检查你的设置是否正常工作

让我们检查一下是否已正确设置一切。为此,我们首先创建一个新文件来保存我们的程序。你可以通过点击文件 > 新建文件(图 2.2)来实现,然后选择 Python 文件(图 2.3)。

figure

图 2.2 如何在 VS Code 中创建新文件

创建文件后,我们会确保保存文件。点击文件 > 另存为,然后将文件命名为 first_program.py。接下来,在文本编辑器中,按下面的内容准确输入:

print("Hello World")

你可能已经猜到,当我们运行这个程序时会发生什么。它应该会在屏幕上打印“Hello World”。让我们试试看!首先,你需要通过点击文件 > 保存来保存你的文件。

figure

图 2.3 选择 Python 文件,将新文件创建为 Python 文件。

警告  在运行代码之前,请确保保存你的文件!我们很不好意思地承认,我们曾花了很多时间修复那些正确但没有保存的代码。

要运行你的程序,请点击文本编辑器右上角的运行代码图标,如图 2.1 中所示。点击图标后,在底部的终端部分,你应该会看到如下内容:

> & C:/Users/<YOUR_NAME>/AppData/Local/Programs/Python/Python312/python.exe 
↪ c:/Users/<YOUR_NAME>/Copilot/first_program.py
Hello World

从 > 开始的第一行是计算机运行你代码的命令,它的意思是用 Python 运行你的 first_program.py。第二行是运行命令后的输出,显示“Hello World”,这是我们希望看到的结果。

恭喜!你已经写出了你的第一个程序!现在我们知道你的编程环境已经设置正确了。接下来,让我们再写几个小程序,熟悉这个工作流程,然后再转到使用 Copilot 的工作流程。

2.5 编写并运行一些小程序

你刚刚完成了安装 Python 和 VS Code 并使其正常工作的主要步骤。在开始使用我们的 AI 助手 Copilot 之前,让我们再写几个小程序,让你熟悉如何输入和运行代码。

让我们从写一个简单的加法程序开始。先删除你写的打印 Hello World 的那行代码,然后用下面的代码替换它:

a = 10
b = 20
print(a + b)

你认为那段代码会打印什么?它会打印 “a + b”,还是打印 30?运行一下看看吧。

计算机计算了 10 和 20 的和并打印出 30。是不是很棒?我们用计算机做了一些小计算。如果你改变了 ab 的值,你认为会发生什么?可以自己尝试一下。

本书稍后会花更多时间讲解如何阅读代码的细节,但如果你感兴趣,下面是如何阅读那段代码:

a = 10     #1
b = 20           #2
print(a + b)   #3

1 创建一个名为 “a” 的变量,并赋值为 10

2 创建一个名为 “b” 的变量,并赋值为 20

3 将 “a” 和 “b” 相加,然后将结果打印到屏幕上

现在关键的一点是,你要习惯在编辑器面板中输入代码,并通过点击运行按钮来执行代码。

再做一个简单的示例。假设你想计算一个正方形的面积。回想一下我们的几何公式,边长为 s 的正方形的面积公式如下:

Area = *s *²

让我们从定义一个边长并打印面积开始:

side = 5
area = side * side
print("Side length:", side, " Square area is:", area)

我们鼓励你输入并运行这段代码。运行时,你应该看到以下结果:

Side length: 5 Square area is: 25

输入那段代码时遇到问题了吗?我们猜很多人都会遇到。如果你漏掉了一个引号、逗号或括号,运行代码时可能会出现类似这种不愉快的错误(这里,如果你漏掉了一个逗号):

File "c:\Users\Leo\Copilot\first_program.py", line 7
    print("Side length:" side, " Square area is:", area)
          ^^^^^^^^^^^^^^^^^^
SyntaxError: invalid syntax. Perhaps you forgot a comma?

在传统的编程学习方式中,你会花很多时间确保自己理解每个字符的写法,以使编程语言 Python 能够正确运行。我们再次强调好消息:有了像 Copilot 这样的 AI 助手,语法变得不再那么重要。让我们安装 Copilot,这样你就可以学习如何使用这个工具。

2.6 设置你的系统:第二部分

现在我们准备进入安装指南的第二部分。这里有一些新的步骤,超出了之前的内容。再次提醒,您可以参考以下资源以获取更多关于安装过程的详细信息:

  • 请访问 GitHub 的文档:mng.bz/WVP1

  • 本书的网站(mng.bz/0M46)提供了详细的 PC 和 macOS 系统设置说明。由于这些工具的网址可能在本书编写后发生变化,我们建议您结合使用 GitHub 链接和本书网站。

  • 在在线书籍论坛(mng.bz/NBK1),您可以寻求帮助,并查看常见问题的答案。

本次安装的主要步骤如下:

  1. 设置您的 GitHub 账户,并注册 Copilot:

    • 访问github.com/signup,并注册一个 GitHub 账户。

    • 进入 GitHub 的设置,启用 Copilot。这时,您需要验证自己是学生,或者注册 30 天的免费试用(在编写本书时提供)。

  2. 安装以下 VS Code 扩展(详情请见mng.bz/9o01):

    • GitHub Copilot(由 GitHub 提供)——在编写本书时,安装 GitHub Copilot 会自动安装 GitHub Copilot Chat。请检查 GitHub Copilot Chat(由 GitHub 提供)是否已安装,方法是查看已安装的扩展。如果没有安装,您需要安装该扩展。

我们知道这里的步骤较为简洁。如果您遇到任何问题,我们建议您参考前面提到的资源,获取更详细的安装说明。

2.6.1 检查 Copilot 是否正常工作

如果您的安装正确,您应该会在 VS Code 界面的右下角看到 Copilot 图标(如图 2.4 所示)。

figure

图 2.4 VS Code 界面与 Copilot 运行中的状态

让我们通过编辑 first_program.py 文件来检查 Copilot 是否正常工作。您可以删除当前的代码并重新开始,或者创建一个新的.py 文件,随您便。在文本编辑器中,输入以下内容:

# output "Hello Copilot" to the screen

我们写的提示和代码将以粗体显示,以帮助区分我们写的内容与 Copilot 可能给出的代码和注释。行首的 # 符号很重要(你应该在输入的内容中包含它)。它表示你写的是注释(根据你在 VS Code 中使用的配色方案,可能与我们即将生成的代码颜色不同)。注释不是代码:计算机执行代码,而不执行注释。程序员使用注释来提供代码执行的人工可读摘要,以帮助其他软件工程师阅读代码。今天,注释的作用已扩展到提示 Copilot。在写完注释后(有时甚至在写注释时),Copilot 会尝试给我们提供建议。你可以把它看作是一个更复杂的自动完成,就像你在搜索引擎中输入“New York T”时,它会自动完成为“New York Times”。

要触发 Copilot 开始给我们提供代码(或更多注释),请按下行尾的 Enter 键,你将进入新的一行。稍等片刻,你应该会看到一些内容出现。在被接受之前,Copilot 的建议是浅灰色斜体。如果你还没有得到建议,可能需要按第二次 Enter 键来触发 Copilot 提供代码。以下是我们看到的情况:

# output "Hello Copilot" to the screen
*`*print("Hello Copilot")*`*

如果你仍然没有看到 Copilot 的建议,试着按 Ctrl-Enter(按住 Ctrl 然后按 Enter)。按下 Ctrl-Enter 后,编辑器窗口右侧会出现一个新窗口,里面有名为 GitHub Copilot Suggestions 的程序。如果这个窗口没有出现,可能是你的设置出了问题,我们建议你访问书籍网站,仔细检查是否按照所有步骤正确操作,或者寻求帮助(或者请教他人)。

如果你看到了 Copilot 的建议,按 Tab 键接受 Copilot 的建议。这样做后,之前浅灰色斜体的建议应该变成标准字体:

# output "Hello Copilot" to the screen       #1
print("Hello Copilot")     #2

1 我们写的提示

2 Copilot 生成的代码

如果你看到的代码与我们不同,那是因为我们之前提到过的原因:Copilot 是非确定性的,因此你看到的代码可能与我们不同。我们提到这一点是因为有时 Copilot 会在代码上犯一些小错误,并可能给出类似于这样的代码:

print "Hello Copilot"

你可能认为这个细微的差别("Hello Copilot" 周围没有圆括号)不重要,但其实它是有区别的。在 Python 3 之前,这是打印语句的正确语法,而 Python 3 引入后,语法变成了带圆括号的代码。因为我们正在运行 Python 3,代码必须有圆括号才能正常工作。你可能会问为什么 Copilot 会出错。问题在于 Copilot 在训练过程中使用了一些旧版本的 Python 代码。如果这让你感到恼火,我们表示理解。但这也是初学者在 Copilot 之前遇到的挫折之一。Copilot 提供的大部分建议在语法上是正确的。但如果你是一个初学者,从零开始编写代码,遗漏圆括号或某个地方漏掉冒号,可能会让你浪费大量时间。现在我们有了正确的代码,如下所示:

# output "Hello Copilot" to the screen
print("Hello Copilot")

这与我们之前手动编写的“Hello World”代码类似。我们希望看到 Copilot 能生成这样的代码能让你感到鼓舞!

现在,让我们看看 Copilot 在上一节中计算正方形面积的示例中表现如何。继续输入以下代码和提示(再次以粗体显示):

side = 5
area = side * side
# Print the side length is *** and the area is ***

我们收到了 Copilot 的以下回应:

print("The side length is", side, "and the area is", area)

注意 Copilot 如何良好地处理那段代码。对于人类,特别是初学编程的学习者,犯错很容易,但 Copilot 很少出错。我们希望 Copilot 给出了类似的回应。如果你想更熟悉界面,随时可以试着操作 Copilot 一段时间。

我们现在知道你的编程环境和 Copilot 已正确设置,因此可以开始更大的编程任务。但在开始之前,我们想先介绍一些与 Copilot 一起工作时常遇到的问题,并提供解决方案,以便你在处理下一个示例时能够使用这些提示。

2.7 解决常见的 Copilot 挑战

现在谈论 Copilot 的常见挑战可能显得有些早,但你在编写第一个程序时可能已经遇到了一些挑战。在接下来的示例和章节中,你肯定会遇到这些挑战,所以我们想提前给你提供这些信息。

在与 Copilot 合作的过程中,我们遇到了一些常见的挑战。随着 Copilot 的不断改进,这些挑战可能会减少,但在写作时它们仍然存在。尽管表 2.1 中列出的挑战不是你可能遇到的唯一问题,但我们希望通过我们的建议来解决这些常见挑战,帮助你快速上手。如果我们遗漏了什么,可以随时通过论坛联系我们,我们会在书籍网站上持续更新挑战清单。

表 2.1 常见的 Copilot 使用挑战
挑战 描述 解决方法

| 仅限注释 | 如果你使用注释符号(#)给 Copilot 一个提示,当你开始新的一行时,它会更倾向于给你更多的注释,而不是代码。例如:# 输出"Hello Copilot"到屏幕

# 打印 "Hello world" 到屏幕

我们曾看到 Copilot 生成一行又一行的注释,有时还会重复自己!当这种情况发生时,右侧栏中的补救措施 3(使用文档字符串)通常是最有效的。

| 1. 在你的注释和 Copilot 的建议之间添加一个换行符(按 Enter 键),以帮助它从注释切换到代码。2. 如果换行符不起作用,你可以输入一些代码字母(没有注释符号)。通常,提示中的关键字的几个字母就能起作用。例如:

# 输出"Hello Copilot"到屏幕

pr

几个关键字的字母通常会让 Copilot 给出代码建议。

3. 切换使用#注释到像这样的文档字符串注释:

"""

输出 "Hello Copilot" 到屏幕

"""

4. 使用 Ctrl-Enter 看看 Copilot 是否会给你提供代码而不是注释的建议。

|

| 错误代码 | 有时 Copilot 一开始就给出明显错误的代码。(在本书中你会学到如何识别错误的代码!)此外,有时 Copilot 似乎会陷入错误的路径。例如,它可能会尝试解决一个与你要求它解决的问题不同的问题。(特别是补救措施 3,可以帮助 Copilot 走上新的路径。)

| 本书的很多内容都在讲如何解决这个问题,但这里有一些快速的技巧来让 Copilot 帮助你:1. 更改你的提示,看看能否更好地描述你需要的内容。

2. 尝试使用 Ctrl-Enter 来查找 Copilot 给出的正确代码建议。

3. 关闭 VS Code 程序,等待片刻后重新启动。这有助于清除 Copilot 缓存并获取新的建议。

4. 尝试将问题分解成更小的步骤(详情见第七章)。

5. 调试代码(见第八章)。

6. 尝试向 ChatGPT 请求代码,并将其建议粘贴到 VS Code 中。另一个大型语言模型(LLM)有时能给出帮助另一个 LLM 走出困境的建议。

|

| Copilot 给出 # YOUR CODE HERE | 我们曾经看到 Copilot 在提示后生成了这样的内容(或类似文本),它好像在告诉我们自己编写代码:# YOUR CODE HERE

| 我们认为这是因为我们要求 Copilot 解决一个过去曾经由教师给学生布置的任务。为什么?嗯,当我们编写作业时,作为教师的我们通常会先写一些代码,然后告诉学生编写其余的代码,方法是写 # YOUR CODE HERE

我们希望学生在这里编写代码。学生们往往会把这条注释留在他们的解决代码中,这意味着 Copilot 被训练认为这个注释是解决方案的重要部分(但其实不是)。通常,我们能够通过在 Copilot 提供的建议中找到合理的解决方案并按 Ctrl-Enter 解决,但如果不行,请查看“错误代码”的解决办法。

|

缺少模块 Copilot 会给你代码,但它无法运行,因为缺少模块。 (模块 是可以添加到 Python 中以提供预构建功能的额外库。) 在 2.8.2 节中,查看“Python 模块”侧边栏以了解模块的描述,并参阅第五章了解如何使用模块。

2.8 我们前进的道路

你已经看到 Copilot 可以为你生成代码。那么,我们可以直接让 Copilot 为我们解决任务吗?恐怕不行。我们仍然需要学习一些基本技能,才能有效地与 Copilot 合作,并设计对我们有意义的软件。

为了帮助你理解你需要学习的技能,并激发你对未来的期待,我们将提供一个更大的例子,代表你大约在本书阅读和实践的中途,能够独立完成的任务。

2.8.1 本书中我们如何与 Copilot 合作

我们将在后续章节中扩展这个工作流程,但核心要素将保持不变:

  1. 使用注释(#)或文档字符串(""")向 Copilot 写提示。

  2. 让 Copilot 为你生成代码。

  3. 通过阅读代码并进行测试,检查代码是否正确:

    • 如果有效,进入第 1 步,继续下一个你希望它完成的任务。

    • 如果它不起作用,删除 Copilot 中的代码,回到第 1 步,并修改提示(同时查看表 2.1 中提供的解决办法)。

2.8.2 展示 Copilot 在数据处理任务中的价值

对于这个更大的例子,我们希望你关注我们如何与工具互动以及我们需要的技能。我们不指望你在本书早期就理解代码。我们提供代码仅仅是为了让你看到 Copilot 给我们生成了什么,但你不需要在本章试图理解代码。

欢迎你和我们一起跟进这个例子,或仅仅阅读它。如果你卡住了,我们还没有给你足够的技能来继续,所以可以等到后面的章节再进行类似的练习。在本节其余部分,我们假设你只是在阅读。

我们希望从数据处理开始,因为这是你们中许多人可能在个人或职业生活中做过的事情。为了找到一个数据集,我们访问了一个很棒的网站——Kaggle [4],这个网站提供了大量免费的数据集供使用。许多数据集包括一些重要数据,例如不同国家的健康统计信息、帮助追踪疾病传播的数据等。我们不会使用这些数据集,因为我们希望为我们的第一个程序选择一些更轻量的内容。因为我们俩都是美式足球迷,所以我们将使用国家橄榄球联盟(NFL)进攻统计数据库。这里是我们找到的数据集链接,如果你想自己查看,可以点击:mng.bz/86pw。该数据集包含了 2019 到 2022 年的 NFL 信息(见图 2.5)。

figure

图 2.5 nfl_offensive_stats.csv 数据集的前几列和行

步骤 1:理解我们的数据集

nfl_offensive_stats.csv 文件是一个逗号分隔值(CSV)文本文件(参见图 2.5 中的文件部分)。这是存储数据的标准格式。它有一行头部,用来解释每一列的内容。我们(或计算机)知道列之间的边界是通过在单元格之间使用逗号。此外,请注意,每一行都位于自己的单独一行上。好消息是:Python 有许多工具可以读取 CSV 文件。

让我们从探索文件中存储了什么开始。要预览文件的内容,你可以在 Kaggle 网页的“详细信息”部分查看它,或者下载并在 VS Code 或类似 Microsoft Excel 的电子表格软件中打开它。现在不需要担心下载和使用这个文件,这是本书后续部分的内容。然而,如果你下载并打开它,下面是头行(顶部)的开始部分(如图 2.5 中所示):

game_id,player_id,position ,player,team,pass_cmp,pass_att,pass_yds,…

虽然有更多列,但这些已经足够完成我们的第一个任务了。我们想知道在这段时间内,哪位四分卫的表现最好。

步骤 2:在这段时间内所有四分卫的表现如何?

如果你不关注美式足球,也没关系。你不需要了解这项运动就能跟得上。四分卫负责传球。我们首先看一下他们在这段时间内传了多少码数。我们只想和其他四分卫进行比较,因为他们是负责传球的球员。当然,跑卫每个赛季可能会传一次球,当他们这样做时通常会很有趣,但这不是他们的工作。为了只包括四分卫,我们需要告诉 Copilot 将结果限制为位置(第三列)为QB(四分卫的缩写)的球员。为了解决这个任务,我们创建了一个新的 Python 文件,名为 nfl_stats.py,并与 Copilot 进行了对话,代码可以在清单 2.1 中看到。

清单 2.1 Copilot 分析顶级四分卫的代码
"""                                                        #1
**open the csv file called "nfl_offensive_stats.csv" and** 
**read in the csv data from the file** 
**"""** 

# import the csv module      #2
import csv

# open the csv file
with open('nfl_offensive_stats.csv', 'r') as f:
    # read the csv data
    data = list(csv.reader(f))

**"""                                                        ** #3
**the 3rd column in data is player position, the fourth      ** #3
**column is the player, and the 8th column is the            ** #3
**passing yards. For each player whose position in           ** #3
**column 3 is "QB", determine the sum of yards from          ** #3
**column 8                                                   ** #3
**"""                                                        ** #3

# create a dictionary to hold the player name and passing yards
passing_yards = {}
# loop through the data
for row in data:
    # check if the player is a quarterback
    if row[2] == 'QB':
        # check if the player is already in the dictionary
        if row[3] in passing_yards:
            # add the passing yards to the existing value
            passing_yards[row[3]] += int(row[7])
        else:
            # add the player to the dictionary
            passing_yards[row[3]] = int(row[7])

**"""                                                 ** #4
**print the sum of the passing yards sorted by sum    ** #4
**of passing yards in descending order                ** #4
**"""                                                 ** #4
for player in sorted(passing_yards, key=passing_yards.get, reverse=True):
    print(player, passing_yards[player])

1 我们的第一个读取数据的提示

2 Copilot 的响应有时包括注释。从 Copilot 得到的注释不会是粗体的,而我们的提示是粗体的。

3 我们的第二个提示用于分析数据

4 我们的第三个提示用于打印结果

请注意,我们给了 Copilot 三个提示。第一个是处理输入数据,第二个是处理数据,第三个是输出响应。数据输入、数据处理和数据输出的循环在编程任务中非常常见。

如果你选择跟随我们并将这些提示提供给 Copilot,你可能会得到不同的代码。没关系,因为 Copilot 是非确定性的,它不会总是给出相同的结果。然而,如果它给出了不同的代码,我们还没有为你提供判断其是否正确的工具。因此,如果你想跟随我们的步骤,尽管使用我们的代码。

提醒:Copilot 是非确定性的

如第一章和本章所述,Copilot 是非确定性的,所以 Copilot 给出的结果可能与你得到的不同。这将是本书接下来的挑战:如果你得到一个错误的结果,而我们得到了正确的结果,该怎么办?我们非常有信心 Copilot 会给你一个正确的答案,但如果你从 Copilot 那里得到一个错误的答案,请继续阅读本节内容,而不是在 VS Code 中与 Copilot 一起操作。我们一定会为你提供所有必要的工具来修复 Copilot 给出的错误代码,但这项技能将在本书的后续章节中教授,所以我们不希望你现在就卡住了。

看着 Copilot 的结果,我们不得不说这非常令人印象深刻。你现在不需要担心代码,但请注意,从头编写这段代码对于许多新手学习者来说会很困难。好消息是,任何人都可以使用这个强大的工具做比没有它时更多的事情。

第 3 步:使用 Copilot 修改代码以排除一个球员

我们可以使用 Copilot 不仅生成新代码,还可以修改它。如果我们在数据集上运行列表 2.1 中的代码,以下是输出的前五行:

Patrick Mahomes 16132
Tom Brady 15876
Aaron Rodgers 13852
Josh Allen 13758
Derek Carr 13271

如果你跟随足球,这些结果应该不会让你感到惊讶。为了看看 Copilot 如何适应我们的需求,让我们尝试做一个小改动。假设由于 Tom Brady 已经被公认为历史上最好的四分卫之一,你宁愿从这次比较中省略他。

要做这个改动,我们只需修改底部的提示。我们来到代码中如下所示的位置:

"""
print the sum of the passing yards sorted by sum
of passing yards in descending order 
"""
for player in sorted(passing_yards, key=passing_yards.get, reverse=True):
    print(player, passing_yards[player])

我们删除了代码,只留下了注释,并在文档字符串中添加了另一行:

"""
print the sum of the passing yards sorted by sum
of passing yards in descending order 
Do not include Tom Brady because he wins too much
"""

然后,Copilot 向我们建议了以下内容:

for player in sorted(passing_yards, key=passing_yards.get, reverse=True):
    if player != "Tom Brady":                  #1
        print(player, passing_yards[player])

1 排除 Tom Brady 的数据代码

那正是我们希望在代码中看到改变的地方。(谢谢你,汤姆·布雷迪,在这个例子中表现得很好。)在打印结果的时候,代码排除了汤姆·布雷迪的所有数据。当我们保存文件并再次运行时,前五行现在看起来是这样的:

Patrick Mahomes 16132
Aaron Rodgers 13852
Josh Allen 13758
Derek Carr 13271
Matt Ryan 13015

反思例子和我们需要使用 Copilot 编写代码的技能

虽然我们自己没有编写任何代码,但我们能够解决一个大型的实际任务。然而,你仍然需要学习一些基本的编程技能。在接下来的章节中,我们将通过解决各种任务来教给你这些技能。到第七章结束时,你将能够解决这类大型任务。我们希望你从这个例子中得到以下收获:

  • Copilot 是一个强大的工具。 我们自己没有编写任何代码,但我们能让 Copilot 生成需要执行一些初始数据分析所需的代码。如果你使用过电子表格,你可能会想到使用诸如 Microsoft Excel 等电子表格应用程序来做这件事,但很可能不会像编写这样的代码那样简单。即使你以前没有使用过电子表格,你也必须承认,通过编写基本的、人类可读的提示就能产生正确的代码和输出,这真是令人惊讶。

  • 将问题分解成小任务是很重要的。 例如,我们尝试使用单个大提示(未显示)编写此代码,或者将其分解为较小的任务。我们发现,当给出一个大任务时,Copilot 更容易出错,而不是给出多个较小的任务。通常情况下,将问题分解为较小的任务显著增加了 Copilot 生成正确代码的可能性。你将看到在本书的其余部分如何将较大的问题分解为较小的任务,因为这是你需要的最重要的技能之一。事实上,即将到来的第三章将帮助你开始理解,什么样的任务被认为是适合交给 Copilot 的,第七章将完全专注于学习如何将较大的程序分解为较小的部分。

  • 我们仍然需要在某种程度上理解代码。 几个原因证明了这一点。其中一个原因是,编写良好的提示需要基本的理解计算机知道什么,以及不知道什么。我们不能只是给 Copilot 一个提示:“告诉我汤姆·布雷迪的传球码数。” Copilot 可能无法确定数据存储在哪里,数据的格式,哪些列对应于球员和传球码数,或者汤姆·布雷迪是一名球员。我们不得不向 Copilot 详细解释这些细节才能成功。另一个原因与确定 Copilot 生成的代码是否合理有关。当我们两个人阅读 Copilot 的响应时,我们可以确定代码正在做什么,因为我们知道如何阅读代码。你需要在某种程度上能够做到这一点,这就是为什么第四章和第五章专门讲解阅读代码的原因。

  • 测试很重要。 我们需要知道 Copilot 给我们的代码是否正确!当程序员谈论测试时,他们指的是确保代码在可能的意外情况下也能正常工作的实践。我们在这一部分没有花太多时间,除了使用一个数据集估算 Copilot 的答案是否合理,但一般来说,我们需要花更多时间进行测试,因为这是写代码过程中的关键部分。显然,代码中的错误可能会从令人尴尬(如果你告诉一个狂热的 NFL 球迷某个球员的传球码数错了)到危险(如果汽车中的软件表现不正确)再到代价高昂(如果企业根据错误的分析做决策)。当你学会如何阅读代码后,我们有亲身经验,即使代码看起来正确,也不一定是真的!为了解决这个问题,我们必须测试 Copilot 生成的每一段代码,确保它能完成应该做的事。你将在后续章节中,特别是第六章,学习如何严格测试 Copilot 的代码,该章专门讲解这一技能。

  • Python 很强大。 如果你没有注意到也没关系,但在清单 2.1 中,代码从 Python 的库中导入了一个模块(import csv)。Python 有许多内置工具(称为库),帮助人们完成任务。在这个例子中,它导入了一个库,使得读取和修改 CSV 文件变得更容易。我们将在后续章节中利用更多这些库。

Python 模块

Python 模块扩展了编程语言的功能。Python 中有许多模块,它们可以帮助你完成从数据分析到创建网站,再到编写视频游戏的各种任务。你可以通过代码中的import语句来识别何时使用 Python 模块。一些模块是 Python 内置的,但还有成千上万的模块不是内置的,如果你想使用它们,需要手动安装。第五章将向你展示如何使用模块。

在本章中,我们已经取得了很大进展!如果你已经完成了编程环境的设置,并开始使用 Copilot 写代码,你应该感到骄傲。你已经迈出了写软件的巨大一步!除了设置环境的细节之外,通过一起完成一个示例,你已经看到了我们前进的方向。

在本章的示例中,Copilot 给了我们想要的代码,而我们不需要更改提示词或调试代码来找出为什么它没有正常工作。这是展示使用 AI 助手编程的强大功能的一种好方式,但你会发现自己经常需要测试代码、修改提示词,有时还需要理解代码为什么错了。这就是 AI 助手编程的过程,你将在接下来的章节中学到更多。

总结

  • 你安装了 Python 和 VS Code,并设置了 Copilot,这样你就可以跟着书一起工作,开始自己写代码了。

  • VS Code 界面包含了文件管理、代码编辑和运行代码的区域,这些区域将在整本书中使用。

  • 提示是我们告诉 Copilot 生成代码的方式,当提示编写得当时,它可以成为一种高效的软件开发方式。

  • 数据分析是一个常见的编程任务,使用 CSV 文件是存储供计算机处理的数据的常见方式。

  • Copilot 是一款强大的工具,在适当的人工监督下,能够生成复杂的代码。

第三章:3 设计函数

本章内容包括

  • Python 中的函数及其在软件设计中的角色

  • 与 Copilot 互动的标准工作流程

  • 使用 Copilot 编写良好函数的示例

  • Copilot 可以解决的合理任务

编程新手面临的最大挑战之一就是如何知道给 Copilot 提供一个合理的任务,以便它能够找到一个好的解决方案。如果你给 Copilot 一个过于庞大的任务,它通常会以一种极为壮观的方式失败,这往往是非常难以修复的。那么,什么样的任务才是合理的呢?

这个问题对我们使用 Copilot 很重要,但远远超出了这个范畴。人类程序员同样也在与复杂性作斗争。如果经验丰富的软件工程师试图编写代码来解决一个过于复杂的问题,而没有将其拆解成更小、更易解决的子问题,他们通常也会遇到麻烦。人类的解决方案是使用一种叫做函数的东西,它的工作是执行一项任务。函数帮助我们组织代码,使其更容易理解并修复任何错误。关于如何编写合理的函数,通常有一些经验法则,特别是在代码行数方面,但从根本上说,这些规则试图确保编写的东西(1)执行一个单一的任务,(2)不至于复杂到难以正确实现。

对于那些通过传统方式学习编程的人来说,在接触函数之前,他们可能在写 5 到 10 行的代码时就已经为语法问题困扰。此时,他们通过经验自然地理解到,他们不应该在单一函数中写超过自己能测试和调试的代码。因为你正在学习与 Copilot 一起工作,而不是直接与语法打交道,所以本章的任务是教你有关函数的知识,以及什么样的任务在 Copilot 中被视为合理或不合理的单一函数任务。

为了帮助你更好地理解函数,本章将提供多个示例。这些示例使用了与 Copilot 互动的核心工作流程——具体来说,就是编写提示、接收 Copilot 提供的代码,并测试代码是否正确。在 Copilot 生成的函数中,你将开始看到核心编程工具,如循环、条件语句和列表等,这些内容将在接下来的两章中进一步扩展。

3.1 函数

在我们学习编写函数的细节之前,我们需要对函数在软件中的作用有一些基本的了解。函数是帮助完成更大任务的小任务,而这些大任务又帮助解决更复杂的任务,依此类推。你可能已经对将大任务拆解成小任务有了很多直觉。让我们通过一个示例将这一直觉具体化。

假设你在报纸上找到了一个字谜,并且想要解开它(请参见图 3.1 的示例谜题)。在这类谜题中,你需要找到字词列表中的每个单词。单词可以从左到右、从右到左、从上到下或从下到上查找。

figure

图 3.1 示例字谜

从高层次来看,你的任务是“找出字谜中的所有单词。”不幸的是,单独这样描述任务并没有帮助。它没有告诉我们需要采取哪些步骤来解决问题。

现在试着花几分钟时间解决这个问题。你是如何开始的?你如何将整体任务分解成更容易实现的小任务?

你可以做的一件事是说:“好吧,找出所有单词是一个大任务,但一个更小的任务就是先找到第一个单词(CAT)。让我先做这个!”这就是将一个大任务分解为更小任务的一个例子。为了完成整个难题,你可以对每个需要找到的单词重复执行这个较小的任务。

那么,我们如何找到一个单独的单词,比如 CAT 呢?即使是这个任务,也可以进一步分解,以便更容易完成。例如,我们可以将其分解为四个任务:从左到右查找 CAT,从右到左查找 CAT,从上到下查找 CAT,以及从下到上查找 CAT。我们不仅将任务变得越来越简单,而且还将工作组织成了逻辑性的部分。最重要的是,正如我们在本章中将看到的,正是这些更简单的任务的代码我们将要求 Copilot 编写,并最终组装成我们的完整程序。

将一个大问题分解成更小的任务被称为问题分解,这在软件设计中是一项非常重要的技能,以至于我们将第七章的内容专门讲解了这一点。目前,了解何时一个任务太大,无法让 Copilot 完成是至关重要的。让 Copilot 制作一个结合了 Wordscapes 和 Wordle 的新视频游戏根本行不通。然而,你可以让 Copilot 编写一个对解决更大问题至关重要的函数;例如,你可能需要一个函数来检查玩家提供的单词是否在有效单词列表中。Copilot 完全可以解决这个问题,这个函数也能帮助 Copilot 更接近解决更大问题。

3.1.1 函数的组成部分

函数这个名字的起源可以追溯到数学,在数学中,函数定义了基于输入的输出。例如,在高中数学中,你可能学过如何识别常见的函数写法如下:

f(x) = x²

这是一个函数,给定变量 x 的值并计算该值的平方。所以,我们可以说,当 x 等于 6 时,f(x) 就是 36。由于编程函数也有特定输入对应的预期输出,因此这个名字在编程中也很合适。

作为程序员,我们还喜欢把函数看作是一种承诺或合同。如果有一个叫做 larger 的函数,我们被告知它接受两个数字并返回其中较大的一个,那么当我们提供数字 2 和 5 时,我们相信它会返回 5。我们不需要知道这个函数是如何工作的,就像我们不需要知道汽车刹车的工作原理就能使用刹车踏板一样。踩下刹车踏板,汽车就会减速。给 larger 函数两个数字,它会返回两个数字中的较大者。

Python 中的每个函数都有一个函数头(也叫做函数签名),它是函数的第一行代码。由于函数头在代码中无处不在,我们需要能够读取和编写函数头。函数头描述了函数的名称及其输入。在某些其他编程语言中,函数头有时还会包括输出的信息,但在 Python 中,关于输出的内容需要在代码的其他地方寻找。

在第二章中,我们使用了#注释来告诉 Copilot 做什么。如果我们希望 Copilot 生成一个函数,仍然可以继续使用这种方法。例如,我们可以使用注释请求 Copilot 写一个函数,告诉我们两个数字中哪个更大:

# write a function that returns the larger of two numbers
# input is two numbers
# output is the larger of the two numbers
def larger(num1, num2):          #1
    if num1 > num2:        #2
        return num1        #2
    else:                  #2
        return num2        #2

1 这个函数头定义了一个名为“larger”的函数,它接受两个名为 num1 和 num2 的输入。

2 函数体

和上一章中的代码一样,我们只是写了注释来提示 Copilot 给我们生成代码。函数头有三个主要部分:def 关键字,它告诉 Python 这是一个函数;函数的名称;以及函数的输入。行尾还有一个冒号——一定要包含它,否则代码将不是有效的 Python 代码。def 这个词表示正在创建(定义)一个函数。在 def 后面是函数的名称;这个名称应该尽可能地描述函数的行为。这个函数的名称是 larger。如果给函数起名很困难,因为它做了很多不同的事情,那通常意味着这个函数的任务过于庞大,不适合由一个函数来完成(稍后会详细讲解)。

在函数声明的括号中,你会看到参数。参数是你向函数提供其运行所需信息的方式。一个函数可以有任意数量的参数,有些函数甚至没有参数。这个函数有两个参数,分别叫做 num1num2;这两个参数是因为函数需要知道它正在比较的两个数字。

一个函数只能有一个输出;在确定函数输出的内容时,关键字是return。紧随其后的return内容就是函数的输出。在这段代码中,num1num2将被返回。函数不一定需要返回任何内容(例如,打印列表到屏幕的函数没有返回任何内容的必要),所以如果你没有看到return语句,这不一定是问题,因为函数可能在做其他事情(例如与用户交互)而不是返回某些东西。函数必须决定是返回某些东西还是不返回任何东西:它不能在某些情况下返回某些东西,而在其他情况下什么都不返回。

虽然我们让 Copilot 通过#注释生成了这个函数,但这种方法实际上对 Copilot 来说工作量很大。首先,它必须正确写出函数头部,包括确定需要多少个参数。然后,它必须确保函数的实际代码正确。在这个例子中,Copilot 还提供了函数的代码(也叫函数体)。

还有一种方法可以提示 Copilot 写出函数的代码,这可能帮助它更准确地生成代码,并且帮助我们更好地理解我们希望函数做什么。这种方法涉及写文档字符串,而我们将在本书的大部分内容中使用文档字符串来编写函数。

文档字符串解释函数的行为

文档字符串是程序员用来描述 Python 函数的方式。它们位于函数头部之后,并以三个双引号开始和结束,如接下来的larger函数代码所示。

通过写出函数头部和文档字符串,你可以更容易地让 Copilot 生成正确的代码。在函数头部,你将决定函数的名称,并提供每个你希望函数使用的参数名称。写完函数头部后,你将提供文档字符串,告诉 Copilot 这个函数的作用。然后,像之前一样,Copilot 会生成函数的代码。因为我们提供了函数头部,它将能够从头部中学习,并且更不容易出错。这就是写同一个larger函数的另一种方法:

def larger(num1, num2): 
 """ **#1
 **num1 and num2 are two numbers.** #1
 #1
 **Return the larger of the two numbers.** #1
 **"""** #1
    if num1 > num2: #1
        return num1
    else:
        return num2**

#1 函数的文档字符串描述 **注意,我们写了函数头部和文档字符串,Copilot 提供了函数体。

3.1.2 使用函数

一旦我们有了一个函数,我们该怎么使用它呢?回想一下我们之前提到的 f(x) = x²的类比,我们如何给函数一个值 6 来作为x,使得它返回 36?让我们通过使用我们刚刚写的larger函数来看一下如何通过代码实现。

使用函数的方法是调用它。调用函数意味着在特定的参数值上调用函数。这些参数值称为实参。在 Python 中,每个值都有一个类型,我们需要小心地提供正确类型的值。例如,larger函数期望两个数字;如果我们提供不是数字的输入,它可能不会按预期工作。当我们调用函数时,它会运行其代码并返回结果。我们需要捕获这个结果,以便稍后使用;否则,它将丢失。要捕获结果,我们使用变量,它只是一个引用值的名称。

在这里,我们让 Copilot 调用函数,将结果存储在变量中,然后打印结果:

# call the larger function with the values 3 and 5
# store the result in a variable called result
# then print result
result = larger(3, 5)      #1
print(result)

1 调用larger函数,将 3 和 5 作为输入并存储结果

代码正确地调用了larger。注意,它在开括号后面放入了我们希望比较的两个值。当函数完成时,它返回一个值,我们将其赋给result。然后我们打印结果。如果你运行这个程序,你会看到输出为 5,因为 5 是我们要求比较的两个值中较大的一个。

如果你对这些细节不完全熟悉也没关系,但我们希望你能识别出何时正在调用一个函数,如下所示:

larger(3, 5)

函数调用的一般格式是

function_name(argument1, argument2, argument3,... )

因此,当你看到名字后面紧跟着的括号时,意味着有一个函数调用。像我们这里这样调用函数对我们的 Copilot 工作流非常重要,特别是在我们测试函数是否正常工作时。我们还需要调用函数来完成工作,因为函数在我们调用它们之前什么都不做。

3.2 与 Copilot 一起设计函数的循环

让我们介绍一下我们在接下来几章中将使用的工作流程。与 Copilot 一起设计函数涉及以下步骤的循环(见图 3.2):

  1. 确定函数的预期行为。

  2. 编写一个尽可能清晰描述该函数的提示。

  3. 允许 Copilot 生成代码。

  4. 阅读代码,看看它是否合理。

  5. 测试代码,看它是否正确:

    • 如果经过多次测试后代码正确,则继续进行。

    • 如果代码不正确,请转到步骤 2,编辑提示。

figure

图 3.2 与 Copilot 一起的通用编辑循环。假设你定义了一个合理的函数。

我们将在下一章学习如何进行第 4 步,但我们敢打赌你现在已经能够识别出代码明显错误的情况。例如,Copilot 可能只给你一些注释,要求你填写函数的主体。注释不做任何事情——它们不是代码——所以如果一大堆注释没有其他代码,这显然不是正确的做法。或者,Copilot 可能只写一行代码,如return -1,或者我们个人最喜欢的Your code here。Copilot 是从我们教授那里学到的,当我们给学生提供部分代码,并要求他们补全剩下的部分时,通常会加上“Your code here”。这些显然都是错误的,但在下一章中,我们将学习如何阅读代码,以便你能更快地发现更复杂的代码中的错误,并且,更重要的是,知道在哪里以及如何修复它。在后续的章节中,我们将继续扩展这个循环,包含有效的调试实践,同时不断练习如何改进提示。

3.3 使用 Copilot 创建优秀函数的示例

在本节中,我们将使用 Copilot 编写一系列函数。我们认为通过查看具体的示例,你将更好地理解函数的概念,我们将完全通过 Copilot 来编写这些代码,帮助你理解我们刚刚描述的函数设计循环。虽然我们在这一章的目标不是帮助你阅读代码,但我们会在解决方案中看到一些常见的编程特性(有时被称为构造),例如if语句和循环,我们会在看到它们时指出来。然后,在第四章中,我们会详细讲解如何阅读这些代码。

我们接下来要处理的很多函数彼此之间是没有关联的。例如,我们将从一个关于股票股价的函数开始,然后转到关于强密码的函数。通常,你不会把这些不相关的内容存储在同一个 Python 文件中。因为我们现在只是探讨一些好的函数示例,所以你可以把所有的函数都存储在同一个 Python 文件中,文件名可以是 function_ch3.py 或 function_practice.py。

3.3.1 Dan 的股票选择

Dan 是一个投资者,他购买了名为 AAAPL 的股票,每股价格为 15 美元,共买了 10 股。现在,每股的价格已经涨到 17 美元。Dan 想知道他在这只股票上赚了多少钱。

记住,我们希望将函数设计得尽可能通用。如果我们函数做的唯一事情就是计算这个特定的 AAAPL 情况,那它在一般情况下就不会很有用。当然,这对 Dan 现在有帮助,但当 AAAPL 的股价再次变化,或者当他对其他股票感兴趣时,这个函数就不再适用了。

这里有一个有用的一般函数,它需要三个参数,全部是数字。第一个参数是购买的股票数量,第二个参数是购买股票时的股价,第三个参数是当前的股价。我们称这个函数为 money_made,因为它将确定我们在股票上赚了多少钱或亏了多少钱。通常,你会将函数命名为一个动词或多个动词,描述函数正在做的事情。有了这些,我们就可以写出函数头了:

def money_made(num_shares, purchase_share_price, current_share_price):

现在,我们需要一个文档字符串。在文档字符串中,我们需要通过使用每个参数的名称来解释它的作用。我们还需要包含函数应该执行的内容。加入文档字符串后,这就是我们提供给 Copilot 的完整提示:

def money_made(num_shares, purchase_share_price, current_share_price):
 """
 num_shares is the number of shares of a stock that we purchased.
 purchase_share_price is the price of each of those shares.
 current_share_price is the current share price.

 Return the amount of money we have earned on the stock.
 """

在输入完提示后,转到下一行,按下 Tab 键。Copilot 会填写函数的代码。不要担心代码被缩进:函数的代码本应缩进,实际上,如果不缩进,那就是一个错误!这是我们从 Copilot 获得的内容:

    return num_shares * (current_share_price - purchase_share_price)

这段代码看起来合理。在括号中,它计算当前价格与购买价格之间的差额(- 用于减法),然后将其乘以我们拥有的股票数量(* 用于乘法)。检查这样的代码是一个有用的技能,我们将在下一章深入学习它。另一个有用的技能是测试函数。

要测试 money_made 函数,我们通过不同的输入来调用它,并观察每种情况的输出。我们可以通过让 Copilot 调用函数,然后运行程序,就像我们对 larger 函数做的一样。然后我们可以让 Copilot 通过不同的输入来改变函数调用,然后我们再次运行程序,根据需要重复多次。然而,我们发现从交互式窗口中自己调用函数更容易、更方便。这样,我们可以根据需要多次调用函数,而无需经过 Copilot,并且无需在程序中加入我们最终会删除的内容。

要尝试这种交互式方法,选择/高亮显示函数的所有代码,然后按下 Shift-Enter(你也可以通过选择文本,右键点击并选择在 Python 窗口中运行选择/行来访问类似的交互式会话,但这里的指导是如果使用 Shift-Enter)。图 3.3 显示了如果你选择函数的文本并按下 Shift-Enter 时的样子。

figure

图 3.3 在 VS Code 中运行 Python 的交互式会话。注意 TERMINAL 标签底部的 >>>。

在结果窗口的底部,你将看到三个大于号 >>>。这叫做 提示符,你可以在这里输入 Python 代码。(这个提示符与我们与 Copilot 交互时使用的提示符无关。)它会立即显示你输入的代码的结果,既方便又快速。

要调用我们的 money_made 函数,我们需要提供三个参数,它们会从左到右分配给函数的参数。我们首先输入的会被分配给 num_shares,第二个输入会被分配给 purchase_share_price,第三个输入会被分配给 current_share_price

来试试这个!在提示符下,键入以下内容并按回车(或 Shift+Enter)。不要键入 >>>,因为它已经存在;我们在书中一直使用它来清楚地标明我们正在输入的地方。图 3.4 显示了在 Python 提示符下运行该函数的示例:

>>> money_made(10, 15, 17)

你将看到以下输出:

20

20 正确吗?我们买了 10 股,每股涨了 2 美元(从 15 美元涨到 17 美元),所以我们确实赚了 20 美元。看起来没问题!

figure

图 3.4 从 VS Code 终端的 Python 提示符调用 money_made 函数

然而,我们的测试还没有完成。当测试一个函数时,你需要以多种方式进行测试,而不是只测试一次。所有的单一测试用例只能告诉你它在你提供的特定输入值下是否有效。我们尝试的测试用例越多,每个用例以不同的方式测试函数,我们就越有信心认为我们的函数是正确的。

如何以不同的方式测试这个函数?我们在寻找输入,它们在某种程度上属于不同的 类别。现在,一个不太好的测试是说:“如果我们的股票价格从 15 美元涨到 18 美元,而不是从 15 美元涨到 17 美元,结果会怎样?”这几乎与之前的测试相同,很可能它也会正常工作。

一个好主意是测试股票实际 亏损 的情况。我们期望在这种情况下得到一个负的回报值。看起来我们的函数在这个测试类别下也能正常工作。以下是我们的函数调用和返回的输出:

>>> money_made(10, 17, 15)
-20

我们还能做哪些其他测试?有时候股票价格根本没有变化。我们期望在这种情况下返回 0。让我们验证一下:

>>> money_made(10, 15, 15)  
0

看起来不错!测试是科学与艺术的结合。需要测试的内容有多少种类别?这两个调用真的是两个不同的类别吗?我们是否遗漏了某些类别?你会通过实践提高测试能力,我们将在第六章全面讨论测试。目前来看,我们的 money_made 函数似乎工作正常。

函数有可能在其代码中使用变量(而不仅仅是它的参数),我们现在想通过一个例子展示这种情况,以便你在看到 Copilot 这样做时能够理解。以下是我们 money_made 函数的等效写法:

    price_difference = current_share_price - purchase_share_price
    return num_shares * price_difference

这段代码可能对你来说更容易理解:它首先计算出股价的差异,然后将差异乘以股票数量。我们鼓励你测试这个版本,帮助自己确信它仍然是正确的。

3.3.2 Leo 的密码

Leo 正在注册一个新的社交网络网站,叫做 ProgrammerBook。他想确保他的密码是强密码。

Leo 对强密码的定义开始得比较简单:如果密码不是password,也不是qwerty,那就算是强密码。当然,这些密码肯定很糟糕,但实际上,我们必须比这个定义做得更好,才能确保我们的密码足够强!一个有用的函数应该是接收一个提议的密码,并告诉我们它是否强大的那种。

与本章之前的函数不同,这里我们不再处理数字。参数是待检查的密码,属于文本类型。而返回值应该表示一个是/否的结果。我们需要新的类型!

Python 中的文本类型叫做字符串。由于我们可以用字符串存储任何文本,所以可能有成千上万种字符串。Python 中表示是/否结果的类型叫做布尔值Boolean)或bool。一个布尔值只有两个值:TrueFalse

好的!我们准备好向 Copilot 发起提示了。对于返回布尔值(True/False)的函数,我们通常将函数命名为has_xis_x,或者使用其他动词来表示真/假的结果:

def is_strong_password(password):
 """
 A strong password is not the word 'password'
 and is not the word 'qwerty'.
 Return True if the password is a strong password, False if not.
 """

作为回应,以下是 Copilot 为我们写的内容:

    return password != 'password' and password != 'qwerty'

看一下那段代码。!=运算符是“非等于”运算符,每个不被认为是强密码的字符串都被单引号括起来。每当我们需要指定一个特定的字符串值时,就会使用引号。我们将在下一章深入讨论如何阅读代码,但现在,希望这段代码大致能够理解:它看起来是在检查密码是否不是password,并且不是qwerty,这符合我们对强密码的定义。

现在让我们通过进行几个测试调用来确保这个函数确实有效。我们从测试那些不强的密码开始。对于这些密码,我们最好能返回False—而事实也是如此:

>>> is_strong_password('password')        
False      

>>> is_strong_password('qwerty')          
False

好的!现在,让我们试试一个强密码。我们需要在这里看到True

>>> is_strong_password('N3w Y0rk J375')    
True

很棒!我们现在看到该函数有时返回False,有时返回True,所以至少它不总是返回False。让我们再试一次:

>>> is_strong_password('Leo') 
True

好吧,严格来说,True是正确的,但是,哇,Leo是一个糟糕的密码。我们真的应该在强密码的定义上做得更好。让我们更改提示,给出一个更合理的强密码定义。以下是我们的下一个提示和 Copilot 给出的内容:

def is_strong_password(password):
 """
 A strong password has at least one uppercase character,
 at least one number, and at least one special symbol.

 Return True if the password is a strong password, False if not.
 """
    return any(char.isupper() for char in password) and \
             any(char.isdigit() for char in password) and \
             any(not char.isalnum() for char in password)

那个for char in password反复出现,是 Python 考虑密码中每个字符的一种方式。它启用了一个叫做循环的功能,这是我们将在第五章学习的一个关键主题。

当然,也有可能你没有从 Copilot 得到和我们一样的代码。但根据我们得到的代码来看,第一部分是查找任何大写字母,第二部分是查找任何数字,第三部分是查找任何不是isalnum的字符。

也许isalnum的意思是“是字母数字”。那它包括特殊符号吗?如果包括,哪些符号呢?让我们测试一下这个函数,看看它的表现如何:

>>> is_strong_password('Leo')
False

>>> is_strong_password('N3w Y0rk J37$')
True

>>> is_strong_password('N3w Y0rk J375')
True

哎呀!我们得到了第三个密码的错误答案。看起来很强,但其实不强,因为它缺少一个特殊符号。我们的函数返回了True,但那是错误的返回值:它应该返回False

Copilot 给出的代码是错误的。也许你得到的代码没有问题,但我们没有那么幸运,重要的是我们知道如何识别(就像我们刚才做的那样)代码错误,然后修复它。现在不重要的是为什么它错了,但如果你对为什么我们的提示可能给出错误行为感到好奇,那是因为我们没有明确说明特殊字符的意思,而它给出的代码将空格也算作特殊字符。

目前,当我们开始排查错误代码时,我们有两个选择。一个选择是按 Ctrl-Enter,查看 Copilot 给出的其他建议。也许这些建议中的某一个与特殊符号有关,且看起来不同于那个没有成功的选项?

确实,在我们的电脑上,我们看到了这些选项,其中包括这个:

    num_upper = 0
    num_num = 0
    num_special = 0
    for char in password:
        if char.isupper():
            num_upper += 1
        elif char.isnumeric():
            num_num += 1
        elif char in '!@#$%^&*':     #1
            num_special += 1
    if num_upper >= 1 and num_num >= 1 and num_special >= 1:
        return True
    else:
        return False

1 特殊符号的具体选择

使用像LeoN3wY0rkJ375以及N3wY0rkJ375$这样的字符串测试这个函数,你应该能看到该函数运行得很好。

我们的第二个选择是进行一些提示工程,也就是调整我们的提示来影响 Copilot 给出的结果。在我们最初的提示中,我们提到了特殊符号。回头看,这个表述有些模糊。我们可能是想说更具体的内容,比如标点符号。如果我们在提示中具体谈论标点符号,就会得到这样的与 Copilot 的互动:

def is_strong_password(password):
 """
 A strong password has at least one uppercase character,
 at least one number, and at least one punctuation.

 Return True if the password is a strong password, False if not.
 """
    return any(char.isupper() for char in password) and \
           any(char.isdigit() for char in password) and \
           any(char in string.punctuation for char in password)

看起来不错!最后一行讲的是标点符号,这是令人期待的。让我们在这里测试一下:

>>> is_strong_password('Leo')
False

>>> is_strong_password('N3w Y0rk J375')
 Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "ch2.py", line 44, in is_strong_password
    any(char in string.punctuation for char in password)
  File "ch2.py", line 44, in <genexpr>
    any(char in string.punctuation for char in password)
                ^^^^^^
NameError: name 'string' is not defined

看那个错误信息的底部,你会看到'string'没有定义。Copilot 想要使用一个叫做string的模块,但这是一个需要在使用之前导入的模块。这个“未定义”结果可能是由于各种类型的错误,比如找不到名称,通常罪魁祸首是一个尚未导入的模块。Python 有很多模块,但string模块是非常著名的。随着你在使用 Copilot 的过程中越来越多,你会学到哪些模块是常用的,这样你就知道该导入哪些模块。你也可以快速在网上搜索,问“string 是 Python 模块吗”,结果会确认它是一个模块。我们需要做的是导入这个模块。

请注意,这与第二章中 Copilot 为我们生成的代码类似。那时,Copilot 使用了 csv 模块,这是一个已经与 Python 一起安装的模块。string 模块同样是 Python 自带的;Copilot 只是忘记导入它。正如我们在第二章中讨论的,有些模块确实需要手动安装,但 string 模块不是其中之一。所以,我们不需要安装 string;我们只需导入它。

导入模块

Python 提供了许多有用的模块。在第二章中,Copilot 使用了 csv 模块,使得处理逗号分隔值(CSV)文件变得更容易。但是,为了让 Python 代码利用某个模块,我们必须先导入该模块。你可能会问,为什么我们不能直接使用模块,而不需要导入它们,但如果没有导入模块,这将大大增加代码的复杂性以及 Python 在后台运行代码时需要做的工作。相反,Python 的模型是,如果你想使用某个模块,就必须导入它们,它们默认情况下并不包括在内。

让我们在代码顶部添加 import string

import string

def is_strong_password(password):
 """
 A strong password has at least one uppercase character,
 at least one number, and at least one punctuation.

 Return True if the password is a strong password, False if not.
 """
    return any(char.isupper() for char in password) and \
           any(char.isdigit() for char in password) and \
           any(char in string.punctuation for char in password)

现在我们状态良好:

>>> is_strong_password('Leo')
False

>>> is_strong_password('N3w Y0rk J375')
False

>>> is_strong_password('N3w Y0rk J375$')
True

最后一个是 True。它是一个强密码,因为它添加了 $ 符号。

我们希望你现在已经相信了测试的价值。有时候,新的程序员不会测试他们的代码。他们认为自己写的代码是正确的,因为它对他们来说是有意义的。新手和经验丰富的程序员之间一个有趣的区别是,新手通常认为自己的代码是正确的,而经验丰富的程序员则假设自己的代码是错的,直到经过彻底测试并证明没有问题为止。除此之外,我们还发现新手程序员有时不善于测试,因为得知代码是错误的令人沮丧。但现在发现错误比等到别人将你的代码用于重要应用时才发现要好得多。通过测试发现错误实际上是一件好事。

3.3.3 获取强密码

现在我们有了一个可以告诉我们密码是否强大的函数,让我们编写一个从用户处获取强密码的函数。它会一遍又一遍地要求输入密码,直到用户输入一个强密码。这种代码就是网站在告诉你“抱歉,你的密码太弱,请再试一次”时使用的那种代码。

这样的函数的头部应该是什么样的呢?嗯,它将会要求用户输入密码,但我们不会有密码参数。事实上,我们根本不需要任何参数,因为这个函数不需要任何信息来完成它的工作——它只需要提示用户,而用户会在键盘上输入密码。当函数完成其工作后,它将返回强密码,因此它将继续返回一个字符串作为输出。我们可以尝试这个提示:

def get_strong_password():
 """
 Keep asking the user for a password until it’s a strong 
 password, and return that strong password.
 """

我们从 Copilot 得到以下代码作为结果:

    password = input("Enter a strong password: ")
    while not is_strong_password(password):            #1
        password = input("Enter a strong password: ")
    return password

1 它正在调用我们之前的 is_strong_password 函数!

while关键字创建了另一种类型的循环,这种循环会一直继续,直到输入的密码足够强大。Copilot 足够聪明,会调用我们之前的is_strong_password函数来判断什么算是强密码。正如你将在后续章节中看到的,像这样将函数作为构建块使用,正是大型程序构建的方式。你会经常注意到 Copilot 调用你之前的函数来解决后面的任务,就像我们在这里观察到的那样。

让我们来测试一下!选中我们两个密码函数的所有代码,并按下 Shift-Enter。然后,我们调用我们想要测试的函数。接着,尝试输入密码,并在每次输入后按 Enter。你会注意到它会一直要求输入,直到你最终提供一个强密码:

>>> get_strong_password()
Enter a strong password: Leo
Enter a strong password: N3w Y0rk J375
Enter a strong password: N3w Y0rk J375$
'N3w Y0rk J375$'

注意,当我们最终提供一个强密码时,它就不再要求我们输入密码了。接着,我们会看到它返回的带引号的字符串,这正是我们的强密码。

3.3.4 拼字游戏计分

Dan 最喜欢的桌面游戏之一是拼字游戏(Scrabble)。你玩过吗?如果没有,你需要知道的是,你手中有一些字母牌,你的目标是用这些字母组成一个单词。你不必完全依赖你的字母牌来组成单词——你可以将这些字母连接到已有的字母上,从而创造更长的单词——但这里我们不讨论那个。对我们来说重要的是,不同的字母对应不同的分数。例如,a仅值 1 分,因为a是一个非常常见的字母。但qz呢?这些字母每个都值 10 分,因为它们难以使用——或者我们可以说,用它们令人费解。嗯,这样更好。

计算一个单词的分数时,我们将每个字母的分数加起来。例如,zap的分数是 14。原因是z值 10,a值 1,p值 3。

Dan 想要一个函数,给定一个单词,告诉他这个单词值多少分。好吧,所以我们需要一个函数,它接受一个单词(也就是一个参数)。让我们尝试这个提示,其中我们列出了每个字母在官方拼字游戏规则中的分值:

def num_points(word): 
 """ 
 Each letter is worth the following points: 
 a, e, i, o, u, l, n, s, t, r: 1 point 
 d, g: 2 points 
 b, c, m, p: 3 points 
 f, h, v, w, y: 4 points 
 k: 5 points 
 j, x: 8 points 
 q, z: 10 points 

 word is a word consisting of lowercase characters. 
 Return the sum of points for each letter in word. 
 """

对于这个函数,Copilot 不会仅通过按 Tab 键来填充整个函数体。Tab 键也用于缩进代码,在这里我们也需要这么做,Copilot 才会为我们展示代码。所以,我们需要按 Tab(有时按一次用于缩进,然后再按一次给 Copilot),等待代码出现,然后按 Tab 接受这段代码,再按 Enter 让更多代码显示出来,并重复这个过程。

要让 Copilot 建议代码,可能需要按下 Tab 键或 Enter 键。

我们发现 Copilot 有时会在提供建议时暂停,直到你按下一个键来帮助提示它。正如表 2.1 中所提到的,如果你给它评论,Copilot 有时只想给你更多的评论作为建议。再按一次回车通常能触发它开始生成代码。其他时候,按 Tab 键会使它继续提供建议。如果 Copilot 没有为你生成代码,请参考表 2.1。

最后,做了几次之后,整个代码展开,我们得到如下结果:

    points = 0
    for char in word:
        if char in "aeioulnstr":
            points += 1
        elif char in "dg":
            points += 2
        elif char in "bcmp":
            points += 3
        elif char in "fhvwy":
            points += 4
        elif char == "k":
            points += 5
        elif char in "jx":
            points += 8
        elif char in "qz":
            points += 10
    return points

请注意,这里已经列出了所有字母,并且每个类别的得分在下一行给出。我们将在下一章讨论这种整体的决策结构,但从高层次来看,它是逐个字符地遍历单词,对于每个字符,找出它是什么字母,并为该字母添加相应的分数。

让我们测试一下,确保我们的代码按预期执行。我们知道单词 zap 应该值 14 分,结果也确实是这样:

>>> num_points('zap')
14

让我们再尝试几个。在每个例子中,手动计算一下你预期的答案是什么——这样,你就能知道代码是否做对了:

>>> num_points('pack')
12

>>> num_points('quack') 
20

有很多方法可以编写正确的函数代码。如果你按 Ctrl-Enter 并查看 Copilot 的建议,你可能会看到不同类型的代码。这并不一定意味着某种类型是对的,其他的就是错的。例如,这是我们从其他 Copilot 建议中得到的num_points的另一种解法:

    points = {'a': 1, 'e': 1, 'i': 1, 'o': 1, 'u': 1, 'l': 1, 
              'n': 1, 's': 1, 't': 1, 'r': 1,
              'd': 2, 'g': 2,
              'b': 3, 'c': 3, 'm': 3, 'p': 3,
              'f': 4, 'h': 4, 'v': 4, 'w': 4, 'y': 4,
              'k': 5,
              'j': 8, 'x': 8,
              'q': 10, 'z': 10}
    return sum(points[char] for char in word)

请注意,这里每个字母都列出了,并附上了其得分。这种包含字母和分数的结构叫做 字典,而你将在后面的章节中更深入地学习字典。目前要记住的是,Copilot 通常会有多种同样有效的方式来编写函数代码。在继续之前,尝试一下这个,并运行一些测试用例!

3.3.5 最佳单词

让我们继续以拼字游戏为主题。假设 Dan 现在可以构造出一堆单词,但他不知道哪个单词能获得最多的分数。他应该拼写 zappack 还是 quack 呢?如果我们有一个函数,能够接受一堆单词并告诉我们哪个是最好的,那就太好了。

在这样的函数中,我们应该有多少个参数呢?你最初的直觉可能是三个参数,每个参数对应我们选项中的三个单词。但这并不够灵活。毕竟,如果我们想要知道最佳的 5 个单词、10 个单词或 50 个单词的情况怎么办呢?我们希望我们的函数无论考虑多少个单词都能正常工作。

诀窍是使用一个带有一个(没错,就是一个!)参数的函数,这个参数是一个单词列表。就像数字、字符串和字典一样,list是 Python 支持的一种类型。它非常有用,因为它允许我们将任意多个值收集到一个地方。我们可以像这样提示 Copilot 来获得我们想要的函数:

def best_word(word_list):
 """
 word_list is a list of words.

 Return the word worth the most points.
 """

那么,Copilot 如何知道每个单词的分数呢?好吧,它可以调用我们在上一节写的num_points函数!这是 Copilot 给出的代码:

    best_word = ""
    best_points = 0
    for word in word_list:
        points = num_points(word)
        if points > best_points:
            best_word = word
            best_points = points
    return best_word

在测试时,我们需要小心正确调用这个函数。使用

>>> best_word('zap', 'pack', 'quack')

是行不通的,因为那是传递了三个参数的值,而不是一个。正确的调用方法是将单词放入方括号中,像这样(我们将在下一章看到更多关于列表的内容):

>>> best_word(['zap', 'pack', 'quack'])  
'quack'

但是,像往常一样,你不应该仅仅满足于一个测试用例。这个函数也应该在其他有效但可能很奇怪的情况下工作,例如当我们列表中只有一个单词时:

>>> best_word(['zap'])
'zap'

然而,我们不会在一个没有单词的列表上测试这个函数。在这种情况下,做什么才有意义呢?无论函数怎么做,若没有正确的行为,判定它是否正确就变得困难。

3.4 函数的好处

现在你已经看过了许多函数,我们希望你已经开始对软件中函数的使用有了些许了解。我们之前提到过,函数在问题分解中至关重要。除了问题分解之外,函数在软件中的价值还体现在许多其他方面,包括以下几点:

  • **认知负荷 —你可能听说过认知负荷[1]。它是指大脑在任何给定时间内可以处理的有效信息量。如果给你四个随机单词并要求你重复,你可能可以做到。如果给你同样的任务,但是是 20 个单词,大多数人都会失败,因为这太多信息一下子无法处理。类似的,如果你曾经和家人一起自驾旅行,并试图优化旅行时间,同时还得考虑孩子们的休息、午餐、厕所休息、加油站、酒店位置等等,你可能会感觉头脑混乱,无法同时管理所有这些约束。那时你就超过了大脑的处理能力。程序员也面临同样的问题。如果他们试图在一次编写的代码中做太多事或解决过于复杂的问题,他们往往会遇到困难。函数的设计正是为了帮助程序员避免一次性做太多工作。

避免重复 —程序员(而且我们认为,一般人类也是)对于不断重复解决相同问题并不感兴趣。如果我写了一个能够正确计算圆的面积的函数,那么我就不需要再写这段代码。那意味着,如果我的代码中有两个部分需要计算圆的面积,我就会写一个函数来计算圆的面积,然后在这两个地方调用这个函数。 提高 测试 —相比做多件事的代码,测试只做一件事的代码要容易得多。程序员使用多种测试技术,其中一个关键的技术叫做单元测试。每个函数都有输入并产生输出。例如,对于一个计算圆面积的函数,输入是圆的半径,输出是它的面积。单元测试会给函数一个输入,然后将该输入与期望的结果进行比较。对于圆面积的函数,我们可能会通过给它不同的输入(例如一些小的正数,一些大的正数,和 0)来测试它,并将函数的结果与我们知道的正确值进行比较。如果函数的结果与预期一致,我们就可以更有信心地认为代码是正确的。如果代码有错误,我们不需要检查太多的代码来找到并修复问题。但如果一个函数做多项任务,那么测试过程会变得更加复杂,因为你需要测试每项任务及其相互作用。 提高可靠性 —当我们作为经验丰富的软件工程师写代码时,我们知道我们会犯错误。我们也知道 Copilot 会犯错误。如果你想象自己是一个很棒的程序员,并且你写的每行代码有 95%的概率是正确的,那么你认为你可以写多少行代码,直到至少有一行是错误的?答案是只有 14 行。我们认为每行 95%的正确率对于经验丰富的程序员来说可能已经是一个很高的标准,而且可能比 Copilot 产生的代码质量还要高。通过将任务保持在小范围内,每个任务可以在 12 到 20 行代码内解决,我们就能降低代码出错的可能性。如果结合之前提到的良好测试,我们可以更有信心地认为代码是正确的。最后,没有什么比代码中存在多个相互影响的错误更糟糕了,而随着你写的代码增多,出现多个错误的可能性也会增加。我们俩都曾经历过多小时的调试,因为我们的代码中有多个错误,而且我们因此变得更加善于频繁测试小段代码! 提高代码可读性 —*在本书中,我们主要使用 Copilot 从头开始写代码,但这并不是唯一的使用方式。如果你有一个较大的软件,且你或你的同事都在编辑和使用,Copilot 也可以帮助写这部分代码。无论代码大部分是由人类写的还是由 Copilot 写的,大家理解代码都是有利的。这样我们可以更容易地找到 bug,确定在我们想要添加新特性时应该修改哪些代码,并且高层次上了解哪些功能的实现容易,哪些则较难实现。将任务拆解成函数帮助我们理解代码的每一部分在做什么,这样我们就能更好地了解它们如何协同工作。这也有助于分配工作和责任,确保代码的正确性。***

**这些好处对程序员来说非常重要。编程语言并非一开始就有函数。即使在函数出现之前,程序员也尽力利用其他特性来模拟函数。它们是一些丑陋的技巧(如果你感兴趣,可以搜索“goto 语句”),所有程序员都很高兴现在我们有了真正的函数。

你可能会问,“我明白这些优势对人类有什么影响,但它们对 Copilot 有什么影响?”通常来说,我们认为适用于人类的所有原则也适用于 Copilot,尽管有时原因不同。Copilot 可能没有认知负担,但当我们让它解决与人类之前做过的相似的问题时,它会表现得更好。因为人类编写函数来解决任务,Copilot 也会模仿这种方式,编写函数。一旦我们编写并测试了一个函数,无论是手动编写还是 Copilot 编写,我们都不想再写一遍它。了解如何测试你的程序是否正常工作,对人类编写的代码和 Copilot 生成的代码同样重要。Copilot 在生成代码时也很可能会犯错,所以我们希望能尽早发现这些错误,就像我们对待人工编写的代码一样。即使你只是在处理自己的代码,永远没有人会阅读它,你也常常需要在几年后回去编辑你写过的内容。我们亲自经历过这一过程,可以证明,让你的代码对未来的自己可读是非常重要的!

3.5 函数的作用

在编程中,函数有许多不同的作用。从高层次来看,程序是函数,它们(通常)会调用其他函数。关键是,所有程序,包括 Python 程序,都从一个函数开始(在 Java、C 和 C++ 等语言中,这个函数通常叫做 main)。Python 的 main 函数本质上是代码中第一个不在函数中的代码行。但是,如果每个程序都从一个函数开始,而试图用一个函数解决一个大问题是错误的,那怎么解决呢?其实,main 会调用其他函数,后者又会调用其他函数,如此循环。代码仍然会在每个函数内按顺序执行,所以它可能从 main 开始,然后进入另一个函数,依此类推。

举个例子,下面的代码就是我们写的,不是 Copilot 写的,因为没有人会想要写这种毫无实际用途的代码,除非是为了教学。这段代码只是为了演示函数调用的工作原理。

示例 3.1 演示 Python 如何处理函数调用的代码
def funct1():
    print("there")
    funct2()
    print("friend")
    funct3()
    print("")

def funct2():
    print("my")

def funct3():
    print(".")

def funct4():
    print("well")
print("Hi")      #1
funct1()
print("I'm")
funct4()
funct3()
print("")
print("Bye.")

1 这是程序的开始部分。我们将把它称为“main”,就像其他编程语言中的主函数一样。

如果我们运行这个程序,输出结果将会是(我们稍后会解释原因):

Hi
there
my
friend
.

I'm
well
.

Bye.

在图 3.5 中,我们提供了一个示意图,展示了计算机如何执行清单 3.1 中的代码。我们故意提供了一个包含多个函数调用的示例,以便把我们刚学到的内容串联起来。再次强调,这不是实际的代码,它只是为了学习目的。让我们一起跟踪代码执行。你在跟随过程中可能会觉得参考图 3.5 比清单 3.1 更容易,但无论哪种方式都可以。

figure

图 3.5 我们示例中从清单 3.1 中的函数执行流程

程序将从 Python 代码中不是函数的第一行开始执行(print("Hi"))。尽管 Python 本身没有 main 函数,但为了方便解释,我们会把函数之后的代码块称为 main。代码会顺序执行,除非遇到命令指示它去执行其他地方的代码。所以,在执行完 print("Hi") 后,它会转到下一行,也就是对 funct1 的调用:funct1()。调用 funct1 会改变代码的执行位置,跳到该函数的开头,即以下语句:print("there")funct1 的下一行代码调用 funct2,所以程序会执行 funct2 的第一行:print("my")。有趣的是当 funct2 执行完后会发生什么。由于没有更多的代码可以执行,它会自动将执行控制返回到 funct1funct2 调用后的第一行(如果函数调用出现在其他语句的中间,那个语句会接着执行,但在这个示例中,每个函数调用都是单独的一行)。你可能会好奇,为什么它会回到 funct2 调用后的下一行,而不是回到 funct2 的调用处。问题在于,如果它回到 funct2 的调用处,程序就会陷入一直调用 funct2 的死循环。因此,函数始终会在调用之后返回到接下来的代码片段(在这个例子中是下一行)执行。

接着执行的下一行代码会打印 friend。接下来的那一行调用 funct3,打印一个句点(.),然后返回到调用它的地方。

所以,我们回到 funct1,在 print("") 这一行。打印一个空文本会导致换行。现在 funct1 执行完了,因此它将执行控制权返回到 main 中被调用后的下一行。我们猜到你已经大致理解了这个过程,现在让我们加快步伐:

  • main 接着打印 I'm,然后调用 funct4

  • funct4 打印 well,然后返回到 main,接下来的一行代码调用 funct3

  • funct3 打印一个句点(.),然后返回到 main。注意,funct3funct1main 都调用了,但这没问题,因为函数会记住如何返回到调用它们的函数。事实上,多个函数调用同一个函数是该函数可重用性强的标志。

  • funct3返回到主函数时,它会打印"",这会开始一个新行,然后打印单词Bye

这是一个长示例,但我们提供它是为了让你了解函数是如何执行的,以及程序是如何由定义和调用函数组成的。在你使用的任何软件中,想想它执行的具体任务:程序员可能为每个任务写了一个或多个函数。文本编辑器中将文本变为粗体的按钮可能会调用一个函数来改变文本的粗体样式。这个函数可能会改变编辑器内部对文本的理解(编辑器可能以不同于你所看到的格式存储文本),然后它可能会调用另一个函数来更新用户(你的)对文本的显示。

我们还想利用这个示例来讨论函数所扮演的不同角色。辅助函数是指通过简化其他函数的工作来完成任务的函数。从某种意义上来说,除了main函数之外的每个函数都是辅助函数。最好的辅助函数是那些执行某个其他任务的小而明确的部分,例如我们在第 3.3.2 节中介绍的is_strong_password函数,它作为get_strong_password函数的辅助函数,在第 3.3.3 节中有提到。

有些函数仅仅调用一堆其他函数,而没有做任何自己的工作。我们的示例中没有这样的函数。然而,如果你从funct1中删除了三条print语句,它就变成了这种类型的协调函数。其他函数可能会调用辅助函数,然后自己做一些工作。funct1是一个很好的例子,它调用了其他函数,但也做了一些自己的工作。

另一种类型的函数——我们称之为叶子函数——独立存在,不需要调用其他函数的帮助(除了可能是 Python 自带的函数)。为什么叫叶子?如果你把所有的函数调用想象成一棵大树,这些函数就是树的叶子,因为它们没有其他的调用。funct2funct3funct4都是我们示例中的叶子函数。本章我们主要关注叶子函数,但你会在这里以及后续章节中看到其他类型的函数的示例。

3.6 函数的合理任务是什么?

没有明确的规则来定义什么是一个好的函数,但我们可以分享一些直觉和建议。不过,不要误解——识别良好的函数是一项需要时间和实践的技能。为了帮助你,我们在第 3.3 节中提供了一些良好函数的示例。在这一节中,我们将概述我们的建议,并提供一些额外的好坏示例,帮助你培养这种直觉。

3.6.1 良好函数的特点

以下是我们认为有助于你理解什么是良好函数的指南:

  • 一个明确的 **任务执行—叶子函数可能是像“计算一个球体的体积”、“找出列表中的最大数字”或“检查列表是否包含特定值”这样的功能。非叶子函数可以实现更广泛的目标,比如“更新游戏图形”或“收集并清理用户输入”。非叶子函数仍然应该有明确的目标,但它们的设计是基于会调用其他函数来实现目标的前提。

** 行为清晰定义 —任务“在列表中找到最大的数字”定义明确。如果我给你一个数字列表并让你找出最大的数字,你知道该怎么做。相反,任务“找到列表中最好的单词”定义不清楚。你需要更多的信息:“最好的”单词是什么?是最长的,还是使用最少元音的,或者是与“Leo”或“Dan”没有任何相同字母的?你明白我的意思了;主观任务不适合计算机。相反,我们可以编写函数“在列表中找到字符最多的单词”,因为预期的结果是明确的。通常,程序员不能将函数的所有细节都写在函数名中,因此他们会在文档字符串中填充细节以澄清其用途。如果你发现自己不得不写超过几句话来描述函数的行为,那么这个任务可能对单个函数来说太复杂了。 代码行数简短 —多年来,我们听到过关于函数长度的不同规则,这些规则受到不同公司风格指南的影响。我们听到的规则长度从 12 行到 20 行 Python 代码不等,作为最大行数。在这些规则中,行数被用作代码复杂性的代理,作为一个经验法则,这并不是一个坏的做法。作为程序员,我们自己也会对代码应用类似的规则,以确保复杂性不会失控。通过 Copilot,我们也可以将此作为指导。如果你请求 Copilot 给你一个函数,而它给你回了 50 行代码,那么这个函数名或任务可能就不太合适了。正如我们之前讨论的,很多行代码很可能本来就会有错误。 通用值优于特定用途 —一个返回列表中大于 1 的值的个数的函数可能是程序中某部分的特定需求,但有一种方法可以让它变得更好。函数应该改写为返回列表中大于另一个参数的值的个数。新的函数将适用于你的使用案例(给第二个参数传 1)和任何其他值,而不仅仅是 1。我们努力让函数既简单又强大。这也是我们在第 3.3.1 节中编写函数以适用于任何股票,而不仅仅是 AAAPL 的原因。 清晰的输入和输出 —*你通常不希望有太多参数。但这并不意味着你不能有很多输入。一个单一的参数可以是一个项的列表,正如我们在第 3.3.5 节中的best_word函数。它的确意味着你希望找到方法将输入的数量保持到最小。你只能返回一个东西,但同样,你可以返回一个列表,这样你就不那么受限制了。但如果你发现自己在编写一个有时返回列表、有时返回单个值、有时什么都不返回的函数,那么这个函数可能就不好了。***

**### 3.6.2 良好(和不良)叶函数的示例

下面是一些好的叶函数示例(左侧是函数,右侧描述了该函数的目标):

  • 计算球体的体积 —给定球体的半径,返回其体积。

  • 查找列表中的最大值 —给定一个列表,返回最大值。

  • 检查列表是否包含特定值 —给定一个列表和一个值,如果列表中包含该值,则返回 True,否则返回 False。

  • 打印跳棋游戏的状态 —给定一个表示棋盘的二维列表,将棋盘以文本形式输出到屏幕上。

  • 在列表中插入一个值 —给定一个列表、新的值以及插入位置,返回一个新的列表,其中新值被插入到指定位置。

下面是一些不好的叶函数示例,以及我们解释为什么它们不好:

  • 请求用户的税务信息并返回他们今年应缴的税额 —也许在某些国家这并不算太麻烦,但考虑到税务规则的复杂性,在美国或加拿大,这几乎不可能是一个单独的函数!

  • 识别列表中的最大值并从列表中移除该值 —这看起来不算太复杂,但实际上做了两件事。第一件是找出列表中的最大值,第二件是从列表中移除一个值。我们建议使用两个叶函数,一个用来找到最大值,另一个用来移除该值。但如果你的程序需要频繁执行这个任务,这也可以作为一个非叶函数。

  • (思考我们第二章的数据集。)返回数据集中传球超过 4,000 码的四分卫名字 —这个函数的具体性过强。毫无疑问,4,000 这个数字应该是一个参数。但更好的做法是创建一个函数,接收位置(四分卫、跑卫)、统计数据(传球码数、比赛场次)以及我们关心的阈值(4,000、8,000)作为参数。这个新函数比原来更强大,允许用户调用该函数来确定不仅仅是传球超过 4,000 码的四分卫的名字,还可以查询例如跑卫超过 12 次冲球达阵的名字。

  • 确定有史以来最好的电影 —这个函数太模糊了。最佳电影按什么标准定义?应该考虑哪些电影?更好的版本可能是一个根据用户评分确定最高评分电影的函数,并且要求有至少一定数量的评分。这个函数很可能是一个大型程序的一部分,其中该函数将从一个电影数据库(比如 IMDB)获取数据,并且会有用户评分数量作为输入。该函数的输出将是评分最高且至少有指定数量评分的电影。

  • 玩《使命召唤》 —这可能是《使命召唤》大型代码库中的main函数,但它显然不是一个叶函数。

总的来说,在本章中,您了解了 Python 中的函数以及如何利用 Copilot 来帮助编写函数。您还学习了良好函数的特点,以及确保您的函数解决 Copilot 能够良好处理的任务的重要性。

本书接下来的步骤都围绕着理解 Copilot 生成的代码是否正确,以及当它不正确时如何修复它。在下一章中,我们将从解释如何阅读 Copilot 生成的代码的基础知识开始,因为这为我们提供了对 Copilot 是否按我们预期的方式工作的第一个理智检查。然后,在后续章节中,我们将深入探讨如何仔细测试代码以及当代码错误时该怎么办。

3.7 练习

  1. 审查以下函数描述,判断每个函数是否是叶函数。根据函数执行的任务复杂性以及是否可能需要调用额外的自定义函数,给出理由。

    1. 抓取数据。 给定一组 URL,从每个页面抓取数据,解析内容以提取特定信息,并将提取的数据保存到数据库中。

    2. 处理客户订单。 给定一个客户订单列表,通过检查库存、计算带有折扣和税费的总价、更新库存以及生成订单摘要来处理每个订单。

    3. 找出列表中的最大数字。 给定一个列表,返回其中的最大值。

    4. 检查列表是否包含特定值。 给定一个列表和一个值,如果列表包含该值,则返回 True,如果不包含,则返回 False

    5. 生成学生报告卡。 给定学生及其成绩的列表,计算最终成绩、分配字母等级、确定班级排名,并为每个学生生成格式化的报告卡。

  2. 审查本章中描述的使用 Copilot 设计函数的周期。这个周期包括从确定函数行为到测试生成代码的步骤。根据一般的编程实践,识别以下哪个步骤是包括在描述的周期中的,但通常是软件开发过程的一部分:

    1. 通过运行单元测试验证生成代码的逻辑正确性

    2. 在 Copilot 生成代码后手动编辑函数的代码,以简化复杂逻辑

    3. 对生成的代码进行性能基准测试,以确保其效率

    4. 查阅文档或外部资源,以完善函数的提示

  3. 根据提供的问题描述,为 Copilot 编写清晰简洁的提示,明确要完成的任务。确保您的提示包含所有必要的细节,例如预期输入、需要执行的处理步骤以及预期输出:

    1. 在一个宁静的小镇上,有一位谦逊的店主,他时不时地用折扣来让顾客们感到愉快。为了简化这一过程,他寻求创建一个神奇的公式。这个公式会根据商店里任何物品的原价和他希望提供的特别折扣,计算出顾客需要支付的最终价格。通过这两个信息,公式就能显示折扣后的价格,使得店主和顾客都能因为交易的简便而感到非常开心。

你能想到两三个输入示例来验证由 Copilot 根据你的提示生成的函数的正确性吗?包括一些场景,确保该函数正确处理不同类型的输入并计算出正确的输出。

    1. 回顾函数描述和提供的测试用例,计算包括税费和小费在内的餐费总额。根据场景和这种函数的典型要求,你能想到一些该函数无法正确处理的输入吗?解释为什么处理这些输入是必要的,以确保该函数在所有潜在用例中的可靠性和正确性。

    问题描述:创建一个函数,计算包括税费和小费在内的餐费总额。该函数应接受三个输入:餐费的基本价格、税率(百分比形式)和小费率(百分比形式)。该函数应计算并返回餐费的总额。

    测试用例:

    1. 标准案例—计算一顿 50 美元的餐费,加上 10%的税费和 15%的小费:

      • 输入:base_price = 50tax_rate = 10tip_rate = 15

      • 期望输出:62.50 美元(计算方式为 50 美元 + 5 美元税费 + 7.50 美元小费)

    2. 零小费案例—计算一顿 30 美元的餐费,加上 5%的税费,不加小费:

      • 输入:base_price = 30tax_rate = 5tip_rate = 0

      • 期望输出:31.50 美元(计算方式为 30 美元 + 1.50 美元税费 + 0 美元小费)

    3. 高税和高小费案例—计算一顿 100 美元的餐费,加上 20%的税费和 25%的小费:

      • 输入:base_price = 100tax_rate = 20tip_rate = 25

      • 期望输出:145 美元(计算方式为 100 美元 + 20 美元税费 + 25 美元小费)

    1. 回顾提供给 Copilot 的初始提示以及生成的 Python 函数。该代码并未满足要求,因为它仅能将华氏温度转换为摄氏温度,并且缺少根据用户指定的模式进行双向转换的能力。你会如何改进提示,以确保生成的代码符合问题描述的完整要求?
*Prompt: Write a Python function to convert temperature between Fahrenheit and Celsius.*

def convert_temperature(temp):
    celsius = (temp - 32) * 5.0/9.0
    return celsius

摘要

  • 问题分解是将一个大问题分解为更小的任务。

  • 我们使用函数在程序中进行问题分解。

  • 每个函数必须解决一个小的、明确的任务。

  • 函数头或签名是函数的第一行代码。

  • 参数用于向函数提供信息。

  • 函数头部表示函数的名称及其参数的名称。

  • 我们使用return将一个值从函数传递给其调用者。

  • 文档字符串使用每个函数参数的名称来描述函数的目的。

  • 要求 Copilot 编写一个函数时,我们提供函数头和文档字符串。

  • 我们通过为函数的参数提供值(也称为参数)来调用它,从而使函数执行工作。

  • 要测试一个函数是否正确,我们使用不同类型的输入来调用它。

  • 变量是指向一个值的名称。

  • 每个 Python 值都有一个类型,如数字、文本(字符串)、真假值(布尔值)或值的集合(列表或字典)。

  • 提示工程涉及修改我们给 Copilot 的提示,以影响返回的代码。

  • 我们需要确保导入代码中使用的任何模块(例如,string)。

  • 函数减少了重复代码,便于测试我们的代码,并降低了出错的可能性。

  • 单元测试涉及检查函数是否能在各种不同的输入下执行预期的操作。

  • 辅助函数是一个小函数,旨在使编写更大函数变得更容易。

  • 一个叶子函数不会调用其他函数来完成其工作。******

第四章:4 阅读 Python 代码:第一部分

本章内容包括

  • 为什么知道如何阅读代码很重要

  • 如何让 Copilot 解释代码

  • 使用函数分解大问题

  • 使用变量保存值

  • 使用 if 语句进行决策

  • 使用字符串存储和操作文本

  • 使用列表收集和操作多个值

在第三章中,我们让 Copilot 为我们写了几个函数。它们的作用是什么?也许我们的 money_made 函数可以成为股票交易系统的一部分,is_strong_password 函数可以用在社交网络网站上,或者 best_word 函数可以用在某个拼字游戏 AI 中。总的来说,我们编写了一些有用的函数,它们可以成为更大程序的一部分。而且,我们几乎没有自己写很多代码,甚至没有理解这些代码到底在做什么。

然而,我们认为你需要从高层次理解代码的作用。因为这需要一些时间来学习,我们将这一讨论分成了两章。在这一章中,我们将解释为什么阅读代码很重要,并向你介绍 Copilot 的一个功能,帮助你理解代码。之后,我们将深入介绍你需要识别的 10 个编程特性,这些特性帮助你阅读 Copilot 生成的大部分基础代码。我们将在本章讲解前五个,剩下的五个将在下一章介绍。别担心:实际上你已经非正式地接触过这 10 个特性——我们只是要加深你对每个特性的理解。

4.1 为什么我们需要阅读代码

当我们谈论阅读代码时,我们指的是通过查看代码来理解它的作用。理解有两个层次,这两个层次都很重要。

第一个层次是能够逐行理解一个程序的功能。这通常需要跟踪代码执行时变量的值,以确定代码在每一步到底做了什么。这很有用,但并不是全部内容。它就像一步一步地跟着食谱做菜,却不知道食谱要做什么,或者这些食材是如何配合的。最后,你可能做出了一个蛋糕,但你可能不理解为什么蛋糕做得那么好,或者你怎么能自己编出这样一个食谱。你需要了解整个食谱为何有效。

那么,第二个层次就是:确定程序的整体目的。也就是说,你需要能够从每一行代码中抽身出来,从高层次理解程序在做什么。

在这两章结束时,我们希望你能够从这两个层面开始理解 Copilot 生成的代码。我们将从逐行理解代码开始,但在本章结束时和本书剩余部分,你将开始能够查看一小段代码并判断它的整体目的。

我们可以通过参考我们第三章的best_word函数来说明阅读代码的两个级别之间的区别,在以下列表中重新列出。

列表 4.1 Scrabble 的best_word函数
def best_word(word_list):
 """
 word_list is a list of words.

 Return the word worth the most points.
 """
    best_word = ""
    best_points = 0
    for word in word_list:
        points = num_points(word)
        if points > best_points:
            best_word = word
            best_points = points
    return best_word

该程序的追踪描述是对每行代码做的描述。例如,我们会说我们正在定义一个名为best_word的函数,该函数接受一个名为word_list的参数。我们有一个称为best_word的变量,它最初是一个没有字符的字符串,也就是空字符串。(不幸的是,函数和此变量都称为best_word,因为这使得引用其中一个或另一个变得更加棘手,但这是 Copilot 给我们的。)我们还有另一个名为best_points的变量,我们从0开始。然后,我们在word_list中的每个单词上都有一个for循环。在for循环内部,我们调用我们的num_points辅助函数,并将其返回值存储在 points 变量中,等等。(我们将在本章和下一章解释我们如何知道每行代码的作用。)

相比之下,总体目的的描述将类似于我们的文档字符串描述:“从单词列表中返回具有最高 Scrabble 点值的单词。”而不是参考每一行,此描述在高层次上解释了代码的目的,说明它作为一个整体的功能。

通过追踪和测试的结合实践,您将达到一个总体目标水平的理解,并希望您能在本书结束时以完整的荣耀达到那里。在追踪级别上的工作通常先于在总体目标级别上的工作能力[1],因此在本章和下一章中,我们将重点关注追踪级别,通过理解每行代码的作用。我们希望您能够阅读代码的三个原因:

  1. 帮助确定代码是否正确 —— 在第三章中,我们练习了如何测试 Copilot 给出的代码。测试是确定代码是否正确的强大技能,我们将在整本书中继续使用它。但是许多程序员,包括我们两个,只有在代码看起来可能正确时才会测试。如果我们能通过检查确定代码是错误的,那么我们将不会浪费时间测试它:我们会尝试先修复代码。同样,我们希望您能够识别代码是否简单错误,而无需花时间进行测试。通过快速追踪或磨练您的总体目标技能来识别更多错误代码,您将节省更多测试损坏代码的时间。

  2. 为测试提供信息 — 逐行理解代码的作用本身就很有用,但它还能帮助你加速有效的测试能力。例如,在下一章中,你将学习到循环——它们可以让你的代码重复零次、一次、两次,或者根据需要重复任意次数。你可以将这些知识与已有的测试知识相结合,帮助你识别出需要测试的关键案例类别。

  3. 帮助你编写代码 — 我们知道,你希望 Copilot 为你编写所有代码!我们也希望如此。但不可避免地,总会有些代码是 Copilot 无法正确生成的,不管你做多少提示工程。或者,也许通过足够的提示工程,最终能让 Copilot 编写出正确的代码,但我们自己做可能更简单、更快捷。在编写本书时,我们俩尽量让 Copilot 编写尽可能多的代码。但由于我们对 Python 编程的了解,我们常常能够看到错误并直接修复,而不需要通过任何复杂的步骤让 Copilot 来修复。长期来看,我们希望你能有能力独立学习更多编程,而了解 Python 是我们为你从本书到其他资源搭建的一座桥梁。有研究表明,能够追踪并解释代码是能够编写代码的前提[1]。

在我们开始之前,我们需要明确我们追求的深度水平。我们不会教你每一行代码的每一个细节。这样做会让我们回到像 Copilot 这样的工具出现之前传统的编程教学方式。相反,通过 Copilot 工具和我们自己的解释的结合,我们将帮助你理解每一行代码的要点或整体目标。如果你以后打算编写大量程序,你需要的不止这些。我们力图找到“这段代码是魔法”与“我完全理解每行代码是如何工作的”之间的最佳平衡点。

4.2 请求 Copilot 解释代码

到目前为止,我们一直在使用 Copilot 为我们生成代码。这是它最著名的功能,但并不是唯一的功能。现在我们将向你展示它的另一个最棒的功能:解释 Python 代码的作用!

Copilot 扩展不断变化。我们在这里给出的具体步骤可能会有所不同,如果发生这种情况,我们鼓励你查阅更一般的 GitHub Copilot 文档。

要尝试 Explain 功能,你需要高亮显示一些代码,让 Copilot 为你描述它。我们用我们的 best_word 函数(清单 4.1)来试试。如果你还没有输入这段代码,请现在输入。

figure

图 4.1 编辑器中高亮显示的 best_word 函数代码

按照图 4.1 中那样高亮代码。之后,我们有几个选项。第一个选项是点击活动栏中的聊天图标(位于 VS Code 的左侧),或者按 Ctrl-Alt-I。这样将打开 Copilot Chat 界面,如图 4.2 所示。(还有其他聊天界面,包括你可以通过 Ctrl-Shift-I 打开的快速聊天——你可以使用任何一种聊天界面。)Copilot Chat 不仅能用自然语言回答你的问题,还能用代码块、可点击的按钮/链接来执行命令等。它还会建议你可能想要问的问题,当然,你也可以输入任何与代码相关的问题。

figure

图 4.2 VS Code 中的 Copilot Chat 界面

目前,我们将使用 Copilot Chat 来解释代码。在文本框中输入以下命令:

/explain

如果 Copilot 询问,你需要选择 @workspace 选项。如果这个不适用,你也可以尝试以下命令:

/explain how the selected code works

输入斜杠(/)是告诉 Copilot Chat 你正在指定一个命令(Copilot 文档称这些为斜杠命令),而不是使用自然语言。在这种情况下,我们要求 Copilot 对选中的文本执行/explain命令。

或者,你可以通过像图 4.1 中那样高亮函数中的代码,要求 Copilot 解释代码。然后,右键点击函数中的代码,你会看到图 4.3 中出现的菜单。

figure

图 4.3 右键点击选中的 best_word 函数时出现的菜单

在这个菜单中,点击 Copilot,你将看到图 4.4 中显示的选项列表。这些选项是与 Copilot 一起使用的更常见功能。

figure

图 4.4 你可以与 Copilot 一起使用的选项列表

最后的步骤是选择“解释此项”选项。Copilot Chat 窗口将打开(如图 4.2 中所示),并且你会得到代码的解释。像往常一样,Copilot 是非确定性的,因此你的结果可能与我们的不同。事实上,如果解释让你感到困惑,你可以尝试再次使用/explain命令,或者写下你自己的提示来获得不同的解释。作为教育工作者,这是梦想成真的开始,学习者可以根据需要请求尽可能多的解释,以便完全理解代码的工作原理。

Copilot 的解释可能是错误的

与 Copilot 生成的代码一样,Copilot 的解释也可能是错误的。你可能会想,“等等,如果一个工具能理解代码却也会出错,那它的价值在哪里?!”首先,我们同意,这确实可能让人感到沮丧。然而,根据我们的经验,Copilot 的解释足够准确,通常可以非常有帮助,尤其是在理解代码基础知识方面,例如我们在这两章中教授的内容。此外,当学习者对自己的代码感到困惑时,他们历史上通常会向朋友或网站询问代码,这同样可能导致错误。为了减少错误的可能性,一个有效的做法是多次询问 Copilot 解释,因为你不太可能在多次尝试中得到完全错误的答案。

这是我们第一次询问时,Copilot 给出的解释:

提供的 Python 代码定义了一个名为best_word的函数,该函数接受一个单词列表(word_list)作为参数。该函数的目的是从列表中确定并返回得分最高的单词。

函数首先初始化两个变量:best_wordbest_pointsbest_word是一个空字符串,最终将保存得分值最高的单词,而best_points初始化为 0,将保存找到的最高得分值。

然后,函数进入一个循环,遍历word_list中的每个单词。对于每个单词,它通过调用num_points函数(在提供的代码中未显示)来计算该单词的得分值。num_points函数预计接受一个单词作为参数并返回其得分值。

如果当前单词的得分值大于当前的best_points,函数会将best_word更新为当前单词,并将best_points更新为当前单词的得分值。这样,函数始终跟踪得分值最高的单词。

在循环遍历完列表中的所有单词后,函数返回best_word,此时它应该是列表中得分值最高的单词。如果列表为空或所有单词的得分值为 0,函数将返回一个空字符串。

对我们来说,这是一个非常好的代码描述。它首先给出代码的整体解释,然后再逐行解释。然而,请注意,为了完全理解它,你需要了解重要的 Python 术语和特性。Copilot 的解释涉及到函数、参数、变量、字符串、循环、做决策(“如果得分值”)和返回等内容。如果你理解这些术语,那么这个解释会非常有用,但如果你不理解,那么帮助就会大打折扣。通过本章和下一章的学习,你将掌握解读 Copilot 典型解释所需的主要 Python 术语和特性。

请求解释

通过 /explain 命令获得的解释类型可能无法满足你的需求。例如,假设你希望 Copilot 明确地逐行给出代码并解释每行的作用,以便你能更容易地将代码与其具体解释联系起来。你可以实现这一点!尝试以下提示:“再解释一次这段代码,但按行解释。给出每行代码及其功能解释。” 这样,我们得到了每行代码的详细分解,后面跟着它的解释。如果内置命令没有提供你想要的详细信息,我们鼓励你自行编写提示。

4.3 你需要了解的 10 大编程特性:第一部分

我们将为你带来一个关于你编程旅程中需要的 10 个 Python 特性的小小旋风之旅,从本章开始的前五个特性。Python 是一种交互式语言,这使得我们比其他语言更容易进行实验并观察效果。我们将在这里利用这一点,探索编程特性。这就是我们俩学 Python 的方式,也是成千上万程序员学习的方式。不要犹豫,尽管实验!首先,按下 Ctrl–Shift–P 并输入 REPL,然后选择 Python: Start REPL。这样应该会出现图 4.5 所示的情况。(REPL 代表读取-执行-打印-循环。之所以这么叫,是因为 Python 会读取你输入的内容,执行/运行它,再将结果打印回来,并不断循环这一过程。)

figure

图 4.5 从 VS Code 启动 REPL

这将让你回到与第三章相同的 Python 提示符(如图 4.6 所示),只是没有加载任何函数。

figure

图 4.6 在 VS Code 中运行的 REPL

然后,我们可以开始编写 Python 代码。例如,输入

>>> 5 * 4

然后按 Enter。你会看到响应 20。我们在这里不花时间做简单的数学运算,但你与 Python 的交互方式完全一样:你输入一些代码,Python 给你反馈。现在,让我们跳入我们提到的 10 个 Python 特性中的前五个。

4.3.1 #1. 函数

你在第三章已经学过了函数,所以我们只需总结一下我们学到的内容。你使用函数将一个大问题分解为更小的部分。回顾一下,我们在第三章编写的 best_word 函数是一个相当大的任务:它需要找出在单词列表中得分最高的单词。一个单词值多少分?啊哈——这是我们可以从这个函数中分离出来的一个子任务,我们在之前的 num_points 函数中做到了这一点。

我们设计一个函数来接受参数,每个参数代表函数需要完成工作所需的数据的一部分或一个集合。大多数函数在完成工作后,使用return将结果返回给调用它们的代码行。当我们调用函数时,我们传递值,称为参数,每个参数对应一个值,我们通常使用变量来存储返回值。

对于我们编写的每个程序,我们可能需要设计几个函数,但也有一些内置的 Python 函数可以免费使用。我们可以像调用自己的函数一样调用它们。例如,有一个内置的 max 函数,它接受一个或多个参数,并返回最大的那个:

>>> max(5, 2, 8, 1)
8

还有input函数,我们在第三章的get_strong_password函数中使用了它。它接受一个参数,该参数作为提示显示给用户,然后返回用户在键盘上输入的内容:

>>> name = input("What is your name? ")
What is your name? Dan
>>> name
'Dan'

如果input是接收键盘输入的函数,那么是否有一个output函数来将消息输出到屏幕上呢?当然有,不过它叫做print,而不是output

>>> print('Hello', name)
Hello Dan

4.3.2 #2. 变量

变量是指向某个值的名称。在第三章中,我们使用变量来跟踪函数的return值。我们这里也用一个变量来保存用户的姓名。每当我们需要记住某个值以供以后使用时,就会使用变量。

要给变量赋值,我们使用 =(等号)符号,这叫做赋值符号。它会计算等号右边的值,并将结果赋给左边的变量:

>>> age = 20 + 4      #1
>>> age
24

1 等号右边的表达式被计算,即 20 + 4 被计算为 24。然后,变量 age 被赋值为 24。

在 Python 中,= 符号与数学中的不同

在 Python 以及其他编程语言中,= 符号表示赋值。= 符号左边的变量将得到右边计算结果的值。这不是一个永久关系,因为变量的值可以被改变。对数学强的人来说,刚接触编程时可能会觉得困惑,但只需要记住,Python 中的 = 符号表示赋值,而不是相等。

我们可以在更大的上下文中使用变量,称为表达式。变量所指代的值会替代它的名称:

>>> age + 3      #1
27
>>> age      #2
24

1 age 仍然可用,并且其值为 24。24 + 3 被计算为 27。

2 age + 3 的表达式不会改变 age,因为我们没有重新赋值给 age

变量在 Python 提示符中保持存在

我们在前一段代码中给age赋了值。为什么我们可以一直引用它呢?任何在 Python 提示符下编程时声明的变量都会保留,直到退出为止。这也是程序中变量的工作方式。只要你给变量赋值,它就可以使用。

但是注意,当我们说 age + 3 时,变量 age 并没有变化!要改变它,我们需要另一个 = 赋值语句:

>>> age = age + 5     #1
>>> age
29

1 我们通过执行赋值操作(= 符号)改变了 age。

让我们看看更多改变变量引用方式的方法。我们将包含一些注释作为代码的说明:

>>> age += 5      #1
>>> age
34
>>> age *= 2      #2
>>> age
68

1 一种快捷的加法方式。age += 5 相当于 age = age + 5。

2 一种快捷的乘以 2 的方式。age *= 2 相当于 age = age * 2\。

4.3.3 #3. 条件语句

每当我们的程序需要做出决策时,我们就需要一个条件语句。例如,在第二章中,我们需要决定要在数据中包含哪些球员(我们只想要四分卫)。为此,我们使用了 if 语句。

还记得我们在第三章中的较大值函数吗?我们已经在下面的列表中重新编写了它。

列表 4.2 用于确定两个值中较大值的函数
def larger(num1, num2):
    if num1 > num2:     #1
        return num1           #2
    else:                    #3
        return num2     #4

1 如果 num1 大于 num2,num1 > num2 表达式将为 True;如果 num1 小于或等于 num2,则为 False。

2 当 num1 大于 num2 时,这行代码会被执行。

3 else 关键字必须与 if 关键字配对使用。当 if 不执行时(num1 > num2 为 False),else 会执行。

4 当 num1 不大于 num2 时,这行代码会被执行。

列表 4.2 中的 if-else 结构被称为 条件语句,它允许我们的程序做出决策。在这里,如果 num1 大于 num2,则返回 num1;否则,返回 num2。这就是它如何返回较大的那个!

if 后面,我们放置一个布尔条件(num1 > num2)。布尔条件是一个表达式,它测试一个条件,结果要么为 True,要么为 False。如果为 True,则运行 if 下的代码;如果为 False,则运行 else 下的代码。我们通过使用比较符号来创建布尔表达式,例如 >= 表示大于或等于,< 表示小于,> 表示大于,== 表示等于,!= 表示不等于。注意,我们不仅对函数的代码进行缩进,而且对 ifelse 部分的代码也进行缩进。缩进对于代码正常运行是必要的,因此值得注意(我们将在下一章进一步讨论缩进)。这就是 Python 知道哪些代码行属于函数,哪些代码行属于 ifelse 的原因。

我们也可以在 Python 提示符下玩条件语句——我们不需要在函数内部编写代码。这是一个例子:

>>> age = 40      #1
>>> if age < 40:        #2
...     print("Binging Friends")
... else:                         #3
...     print("What's binging?")
...
What's binging?

1 我们将 40 赋值给 age。

2 因为 age 为 40,这段代码在问是否 40 < 40. 不是,所以跳过了 if 部分的代码。

3 else 部分运行,因为 if 条件为 False。

你会注意到,当你在if语句内部输入时,提示符从>>>变成了...。提示符的变化让你知道你正在输入需要完成的代码。完成else代码后,按一次回车即可退出...提示符,回到>>>提示符。

我们将年龄变量设置为 40。因为40 < 40False,所以else部分执行。让我们再试一次,这次让if部分执行:

>>> age = 25             #1
>>> if age < 40:           #2
...     print("Binging Friends")
... else:                         #3
...     print("What's binging?")
...
Binging Friends

1 我们将 25 赋给年龄。

2 因为年龄是 25,所以这是在判断 25 < 40。是的,所以代码中的if部分会执行。

3 else部分不执行(我们已经运行了if部分的代码)。

你可能会看到一些没有else部分的if语句,这也是可以的:else部分是可选的。在这种情况下,如果条件为False,那么if语句将不会做任何事情:

>>> age = 25           #1
>>> if age == 30:            #2
...     print("You are exactly 30!")
...

1 我们将 25 赋给年龄。

2 == 用于测试两个值是否相等。

请注意,测试两个值是否相等的方式是使用两个等号(==),而不是一个等号。(我们已经知道一个等号用于赋值语句,将值赋给变量。)

如果有超过两个可能的结果该怎么办?例如,假设一个人的年龄决定了他们可能会狂看哪部电视剧,如表 4.1 所示。

表 4.1 不同年龄段可能喜欢的电视节目
年龄 显示
30–39 Friends
20–29 The Office
10–19 Pretty Little Liars
0–9 Chi's Sweet Home

我们不能仅通过if-else捕捉所有这些结果,因此elif(即else-if的缩写)使我们能够捕捉更多可能的结果,如下代码所示。我们在此展示的代码不包含 Python 提示符(>>>...),因为那样打字会太多:

if age >= 30 and age <= 39:       #1
    print("Binging Friends")
elif age >= 20 and age <= 29:      #2
    print("Binging The Office")
elif age >= 10 and age <= 19:
    print("Binging Pretty Little Liars")
elif age >= 0 and age <= 9:
    print("Binging Chi's Sweet Home")
else: 
    print("What's binging?")     #3

1 如果年龄大于或等于 30 且小于或等于 39 为真,则此条件为真;例如,如果年龄是 35。

2 如果上述条件为假,则检查此条件。

3 如果上述所有条件都为假,则执行此代码。

我们使用and来捕捉复杂的条件。例如,在第一行,我们希望年龄大于或等于 30 且小于或等于 39。Python 是从上到下执行的,当它找到一个条件为真时,它会运行对应的缩进代码。然后,它停止检查剩余的elifelse——所以如果两个条件恰好为真,只有第一个条件对应的代码会运行。

尝试用不同的age变量值进行实验,观察每种情况下正确的代码是否运行。实际上,如果我们真的要测试这段代码,我们可以使用if语句结构来大致确定我们要测试的值。关键在于测试值的边界。例如,我们肯定想要测试 30 和 39 岁,以确保我们正确捕捉到 30 到 39 岁的范围。同样,我们也应该测试 20、29、10、19、0、9,然后再测试一个大于 39 的值,以测试位于最底部的else语句。

如果你使用额外的if而不是elif,那么它们就变成了独立的if语句,而不是一个单独的if语句。这很重要,因为 Python 始终会单独检查每个独立的if语句,而不管之前的if语句发生了什么。

例如,让我们把代码中的elif改成if。这将给我们以下结果:

if age >= 30 and age <= 39: 
    print("Binging Friends") 
if age >= 20 and age <= 29:                #1
    print("Binging The Office")   
if age >= 10 and age <= 19:               
    print("Binging Pretty Little Liars") 
if age >= 0 and age <= 9:                 
    print("Binging Chi's Sweet Home") 
else:                          #2
    print("What's binging?")

1 这个条件总是会被检查。

2 这个else与最近的if语句相关联。

假设你在这段代码上方写入age = 25并运行它。你认为会发生什么?嗯,第二个if条件age >= 20 and age <= 29True,所以我们肯定会输出Binging The Office。但这并不是所有的结果!记住,因为我们在这里使用的是if,接下来的每一个条件都会被检查。(如果它们是elif,我们就完成了。)age >= 10 and age <= 19False,所以我们不会输出Binging Pretty Little Liars

最后的if条件age >= 0 and age <= 9也是False,所以我们不会输出Binging Chi's Sweet Home。但是这个if有一个else!所以,我们输出What's binging? 我们并没有预期这样!我们只想在年龄至少为 40 的人群中输出What's binging?。这就是要说明,ifelif的行为是不同的,我们需要使用符合我们想要行为的那个(如果我们想让多个代码块可能运行,使用if;如果我们只想让一个代码块运行,使用elif)。

4.3.4 #4. 字符串

正如我们在第三章中学到的,字符串是我们在想要存储文本时使用的类型。文本无处不在——像第二章中的统计数据、密码、书籍——因此字符串几乎出现在每个 Python 程序中。

我们使用引号来标识字符串的开始和结束。你会看到 Copilot 使用双引号或单引号。无论你使用哪种引号都没关系;只要确保字符串的开始和结束使用相同类型的引号。

字符串具有一组强大的方法。方法是与特定类型相关联的函数——在这个例子中是字符串。调用方法的方式与调用函数的方式有所不同。当你调用函数时,你将参数值放在括号内。对于方法,我们仍然将值放在括号内,但需要先放置调用该方法的值,在括号外面,并且后面加一个点。接下来我们将看到一个示例!

在第三章,Copilot 使用了一些字符串方法来实现is_strong_password。让我们在这里尝试使用这些方法,以更好地理解它们是如何工作的:

>>> 'abc'.isupper()     #1
False                  
>>> 'Abc'.isupper()    
False                  
>>> 'ABC'.isupper()    
True                   
>>> 'abc'.isdigit()       #2
False                     #2
>>> '345bc'.isdigit()     #2
False                      #2
>>> '345'.isdigit()       #2
True                      #2

>>> 'abc6'.isalnum()        #3
True                         #3
>>> 'abc def'.isalnum()     #3
False                        #3
>>> 'abcdef#'.isalnum()     #3
False                       #3

1 isupper 需要所有字符都是大写字母,才能返回 True。

2 isdigit 需要所有字符都是数字,才能返回 True。

3 isalnum 需要所有字符都是字母数字才能返回 True(空格不被视为字母数字)。

还有许多其他字符串方法。某些方法帮助你将字符串转换为小写或大写,其他方法帮助你搜索字符串,还有一些方法帮助你格式化字符串(例如,居中或右对齐)。

如果你不确定某个字符串方法的作用,Copilot 的解释功能通常会很有帮助。例如,这个函数做了什么:

def clean_number(phone_number):
    phone_number = phone_number.replace('(', '')    #1
    phone_number = phone_number.replace(')', '')     #2
    phone_number = phone_number.replace('-', '')    #3
    return phone_number

1 删除电话号码中的所有(字符

2 删除电话号码中的所有)字符

3 删除电话号码中的所有–字符

记住,我们可以让 Copilot 解释代码,就像我们在第 4.2 节演示的那样。以下是我们请求 Copilot 解释这段代码时得到的结果:

提供的 Python 代码定义了一个名为clean_number的函数。这个函数设计用于接受一个单一参数phone_number,它应该是一个表示电话号码的字符串。该函数的目的是从电话号码字符串中删除某些字符,特别是括号“(”和“)”,以及连字符“-”。

这个函数使用了replace()方法,这是 Python 内置的字符串方法。replace()方法将指定的子串替换为另一个指定的子串。它接受两个参数:第一个是需要被替换的子串,第二个是用来替换第一个子串的子串。

在这个函数中,replace()方法在phone_number上调用了三次。每次调用replace()都用于删除一种字符:首先是左括号“(”,然后是右括号“)”以及最后的连字符“-”。它通过将这些字符替换为空字符串"",有效地移除了它们。

完成所有替换后,函数返回清理后的phone_number。这将是一个字符串,包含电话号码的数字,没有任何括号或连字符。

这个函数在需要以一致且无字符的格式处理或存储电话号码的情况下可能会非常有用。例如,你可能希望在将电话号码存储到数据库时去除这些字符,或者在比较电话号码是否相等时进行去除。

这确认了这个函数的作用:移除电话号码字符串中的括号和连字符。并且它使用 replace 方法来实现这一点。

等一下——replace听起来像是我们在将某些字符替换为其他字符,而不是移除它们。那么,replace是如何帮助我们移除字符的呢?解释中说,“它通过将这些字符替换为空字符串 "",从而有效地移除它们。”这是replace如何做到这一点的线索,但我们可能还想了解更多。因此,我们接着使用了提示“replace方法是如何利用空字符串来移除字符的?”并从 Copilot 得到了以下解释:

Python 中的replace()方法将调用的字符串中所有指定子字符串的出现替换为另一个指定子字符串。

在这种情况下,指定的子字符串是字符“(”、“)”和“-”。该方法调用在 phone_number 字符串上,并且 replace() 的第二个参数是一个空字符串""。

所以,发生的事情是,phone_number 中每个指定字符的出现都被替换为空字符串。这有效地从字符串中移除了该字符,因为用空字符串替换相当于将其删除。

这在 clean_number 函数中执行了三次,每次移除一个需要去掉的字符。所有三个字符移除后,清理过的 phone_number 被返回。

如果你想了解更多,你可以继续向 Copilot 提问或在 Python 提示符下进行测试。例如,你可以验证 replace 是否会移除所有的 ()- 字符,即使每种字符有多个。你还可以探索使用 replace 来实际替换字符(它的典型用法),而不是移除它们。这种探索也很有帮助,如果我们需要从 Copilot 可能提供的不正确信息中分辨出真相。这就是为什么我们需要自己掌握 Python 知识的基础!

你还经常会看到看似数学运算符的符号应用于字符串。它们和数学符号相同,但对字符串做的操作不同。+ 运算符用于将两个字符串连接在一起,而 * 运算符用于将字符串重复特定次数。示例如下:

>>> first = 'This is a '
>>> second = 'sentence.'
>>> sentence = first + second      #1
>>> print(sentence)
This is a sentence.
>>> print('-=' * 5)     #2
-=-=-=-=-=

1 将第一个和第二个字符串合并,并将结果赋给句子

2 将“-=”字符串重复五次

4.3.5 #5. 列表

当我们需要存储一系列字符时,例如密码或一个 Scrabble 单词,字符串是很好的选择。但有时我们需要存储许多单词或许多数字。为此,我们需要一个列表。在第三章中,我们为 best_word 函数使用了列表,因为该函数需要处理单个单词的列表。

而我们用引号来开始和结束一个字符串,我们用方括号来开始和结束一个列表。而且,和字符串一样,列表也有许多可用的方法。为了让你了解列表方法的种类及其功能,让我们来探索其中一些:

>>> books = ['The Invasion', 'The Encounter', 'The Message']     #1
>>> books
['The Invasion', 'The Encounter', 'The Message']
>>> books.append('The Predator')                                 #2
>>> books
['The Invasion', 'The Encounter', 'The Message', 'The Predator']
>>> books.reverse()                                               #3
>>> books
['The Predator', 'The Message', 'The Encounter', 'The Invasion']

1 一个包含三个字符串值的列表

2 在列表末尾添加一个新的字符串值

3 反转列表(现在值的顺序是相反的)

许多 Python 类型,包括字符串和列表,允许你使用 索引 来处理特定的值。当你只想处理字符串或列表的一部分而不是整个内容时,你需要使用索引。索引就是一个数字,用来标识一个元素。索引从 0 开始,第一个元素的索引是 0(而不是 1!),第二个是 1,第三个是 2,以此类推。列表中的最后一个值的索引是列表长度减去 1。列表的长度可以通过使用 len 函数来确定。例如,如果我们做 len(books),我们会得到一个值 4(所以有效的索引范围是从 0 到 3)。人们也经常使用负索引,这为索引每个值提供了另一种方式:最右边的值索引为 -1,左边的值索引为 -2,以此类推。图 4.7 展示了这个例子,既包括正索引也包括负索引。

figure

图 4.7 列表元素可以通过正索引或负索引进行访问。

让我们练习一下当前 books 列表上的索引操作:

>>> books
['The Predator', 'The Message', 'The Encounter', 'The Invasion']
>>> books[0]        #1
'The Predator'
>>> books[1]
'The Message'
>>> books[2]
'The Encounter'
>>> books[3]
'The Invasion'
>>> books[4]                        #2
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
IndexError: list index out of range
>>> books[-1]                   #3
'The Invasion'
>>> books[-2]
'The Encounter'

1 books[0] 对应第一个元素。

2 错误,因为索引 3 是最后一本书!

3 books[-1] 指的是列表中的最后一个元素。

还有一种方法可以从字符串或列表中提取多个值,而不仅仅是一个。这种方法叫做 切片。我们指定第一个值的索引,一个冒号,以及右边的索引,像这样:

>>> books[1:3]             #1
['The Message', 'The Encounter']

1 从索引 1 开始,到索引 2 结束(而不是 3!)

我们指定了 1:3,所以你可能会期待获得包括索引 3 的值。但第二个索引(冒号后面的那个)对应的值并不包括在内。这是违反直觉的,但它确实是正确的!

如果我们省略起始索引或结束索引,Python 会根据需要使用起始或结束:

>>> books[:3]                                      #1
['The Predator', 'The Message', 'The Encounter']
>>> books[1:]                                      #2
['The Message', 'The Encounter', 'The Invasion']

1 与使用 books[0:3] 相同

2 与使用 books[1:4] 相同

我们也可以使用索引来更改列表中的特定值,例如:

>>> books
['The Predator', 'The Message', 'The Encounter', 'The Invasion']
>>> books[0] = 'The Android'      #1
>>> books[0]
'The Android'
>>> books[1] = books[1].upper()      #2
>>> books[1]
'THE MESSAGE'
>>> books
['The Android', 'THE MESSAGE', 'The Encounter', 'The Invasion']

1 将 books[0] 修改为引用字符串值“The Android”

2 将 books[1] 修改为全部大写

然而,如果我们对字符串这样做,就会出错:

>>> title = 'The Invasion'
>>> title[0]              #1
'T'
>>> title[1]
'h'
>>> title[-1]
'n'
>>> title[0] = 't'                   #2
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: 'str' object does not support item assignment

1 查找字符是可以正常工作的。

2 但赋值操作不行!

字符串被认为是不可变的值,这意味着您不能改变它的字符。您只能创建一个全新的字符串。相比之下,列表被认为是可变的值,这意味着您可以改变它。如果您遇到类型不支持项赋值的错误,您可能是在尝试更改一个不能更改的值。

在本章中,我们向您介绍了 Python 中五个最常见的代码特性。在下一章,我们将继续介绍另外五个特性。我们还向您展示了如何使用 Copilot 解释工具来帮助您理解代码的作用,并提供了验证这些解释真实性的指导。表 4.2 总结了本章中涉及的特性。

表 4.2 本章 Python 代码特性总结
代码元素 示例 简要说明
函数 def larger(num1, num2) 一种可以帮助我们管理代码复杂性的代码特性。函数接收输入,处理这些输入,并可能返回输出。
变量 age = 25 一个可读的名称,指向存储的值。它可以通过 = 赋值语句进行赋值。
条件语句 if age < 18: print("Can't vote") else: print("Can vote") 条件语句让代码能够做出决策。在 Python 中,我们有三个与条件语句相关的关键字:ifelifelse
字符串 name = 'Dan' 字符串存储一系列字符(文本)。有许多强大的方法可用于修改字符串。
列表 list = ['Leo', 'Dan'] 一个包含任何类型值的序列。修改列表有许多强大的方法。

4.4 练习

  1. 回忆一下我们在清单 4.2(4.3.3 节)中看到的条件语句代码。这个程序的行为与原始代码有何不同?
def larger(num1, num2):
    if num1 < num2:
        return num1
    else:
        return num2
  1. 2. 在以下代码中,如果 age 是 32,会打印什么?如果将每个 if 改成 elif(除了第一个),打印的内容会是什么?
if age >= 30 and age <= 39:
    print("Binging Friends")
if age >= 25 and age <= 34:      
    print("Binging The Office")
if age >= 15 and age <= 24:
    print("Binging Pretty Little Liars")
if age >= 0 and age <= 14:
    print("Binging Chi's Sweet Home")
else:
    print("What's binging?")
  1. 3. 您会使用哪种 Python 数据类型来保存一个传感器的温度读数,该传感器每分钟记录一次数据?这种数据类型将存储什么?

  2. 4. 将以下 Python 代码块排列起来,创建一个计算提供的列表中所有偶数之和的程序。

for number in numbers:
total += number
if number % 2 == 0: 
numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] 
total = 0 
print(f"The sum of all even numbers is: {total}")
  1. 5. (回顾 4.3.1 节。)考虑以下 Python 函数调用 min(input("Enter a number: "), input("Enter another number: "))。在运行此代码之前,思考这行代码应该做什么,然后回答以下问题:

    1. 当运行这段代码时,您预期会发生什么?描述执行过程的每一步。

    2. 在这种情况下,Python 如何同时处理输入和 min 函数?

    3. 如果输入不是数字,而是数字形式的字符串(例如,52),Python 的行为会有什么变化(如果有的话)?

    4. 如果一个或两个输入不是数字,会发生什么结果?通过尝试不同的输入来测试您的假设。

    1. 在 4.3.2 节中,我们讨论了变量在 Python 中的持久性。变量的这一特性如何影响你编写代码的结构?想象一个场景,在程序的不同部分之间保持变量状态会带来什么好处。
    1. 我们要求 Copilot 解释以下代码是如何工作的:
age = int(input("Enter your age: "))

if age < 13:
    print("You are a child.")
elif age < 20:
    print("You are a teenager.")
elif age < 60:
    print("You are an adult.")
else:
    print("You are a senior.")

Copilot 提供以下解释:

这段 Python 代码是一个简单的程序,询问用户的年龄,然后根据年龄范围打印出相应的消息。

代码的第一行使用input()函数提示用户输入年龄。input()函数读取输入的一行(通常来自用户的键盘),将其转换为字符串并返回该字符串。在此情况下,提示为“请输入你的年龄:”。返回的字符串随后使用int()函数转换为整数并存储在变量 age 中。

其余的代码是一些条件语句,检查年龄的值并打印相应的消息。if语句检查年龄是否小于 13,如果是,打印“你是一个孩子”。如果年龄不小于 13,程序会检查下一个条件,即elifelse if的简写)语句,检查年龄是否小于 20。如果是,打印“你是一个青少年”。如果年龄不小于 20,程序会继续检查下一个elif语句,检查年龄是否小于 60。如果是,打印“你是一个成年人”。

结果证明 Copilot 的解释是不完整的,因为它没有描述所有输入的情况。找出解释缺少的部分,并将你的答案添加到解释中。

    1. 回顾提供的 Python 代码和给 Copilot 的初始提示。返回的解释太模糊,无法帮助你理解代码中使用的字符串方法。
text = "Hello, World! Programming is fun."
index = text.find("World")
new_text = text[index:].replace("World", "Everyone")

print(new_text)

给 Copilot 的提示:

你能解释这段代码是做什么的吗?

Copilot 给出的解释:

代码涉及操作存储在 text 中的字符串,通过找到一个特定的词并将其替换为另一个词。它使用方法来定位该词的起始位置,然后构建一个新字符串,其中词被替换。最后,打印出修改后的字符串。

你的任务是做以下几件事:

  1. 确定为什么初始提示可能会导致模糊或不帮助的解释。

  2. 改进提示,以确保 Copilot 提供更详细、具体的解释。考虑一下你对字符串方法的理解上有哪些具体问题,以及代码如何使用这些方法。

  3. 写出修改后的提示给 Copilot,并讨论你的更改如何能引导出更好的解释。

总结

  • 我们需要能够阅读代码,以判断它是否正确,进行有效的测试,并在需要时编写自己的代码。

  • Copilot 可以提供逐行解释代码的功能,帮助解释代码在做什么,你可以使用自己的提示来影响解释的类型。

  • Python 有内建函数,如 maxinputprint,我们像调用自己的函数一样调用它们。

  • 变量是一个引用值的名称。

  • 赋值语句使变量引用特定的值。

  • if 语句用于让我们的程序做出决策,并沿着多个路径之一继续执行。

  • 字符串用于存储和操作文本。

  • 方法是与特定类型关联的函数。

  • 列表用于存储和操作一系列通用的值(例如,一系列数字或字符串)。

  • 字符串或列表中的每个值都有一个索引;索引从 0 开始,而不是 1\。

  • 字符串是不可变的(不可更改的);列表是可变的(可更改的)*。

第五章:5 阅读 Python 代码:第二部分

本章内容

  • 使用循环按需要的次数重复代码

  • 使用缩进来告诉 Python 哪些代码属于同一组

  • 构建字典以存储关联的值对

  • 设置文件以读取和处理数据

  • 使用模块在新领域中工作

在第四章中,我们探讨了五个 Python 特性,这些特性会在你继续编程时经常遇到:函数、变量、条件语句(if 语句)、字符串和列表。你需要了解这些特性才能阅读代码,我们也解释了无论是否使用 Copilot,能读懂代码都是非常重要的。

在本章中,我们将继续介绍另外五个 Python 特性,完成我们的前 10 大特性。如同第四章一样,我们将通过结合自己的解释、Copilot 的解释以及在 Python 提示符下的实验来进行讲解。

5.1 你需要了解的 10 大编程特性:第二部分

本节详细介绍了你需要了解的 10 大编程特性中的下五个。让我们从上一章的第 6 个特性:循环开始继续讲解。

5.1.1 #6. 循环

循环使计算机能够根据需要重复执行相同的代码块。如果我们需要的 10 大编程特性中有一个能够展示计算机为何如此有用,它就是这个特性。没有循环功能,我们的程序通常会按顺序逐行执行。当然,它们仍然可以调用函数并使用 if 语句做出决策,但程序的工作量将与我们编写的代码量成正比。循环不同:一个简单的循环就能轻松处理数千或数百万个值。

循环有两种类型:for 循环和 while 循环。一般来说,当我们知道循环需要执行多少次时,我们使用 for 循环;而当我们不知道时,我们使用 while 循环。例如,在第三章中,我们的 best_word 函数(在列表 5.1 中重现)使用了 for 循环,因为我们知道需要循环多少次:即对 word_list 中的每个单词执行一次!但在 get_strong_password 函数中,我们使用了 while 循环,因为我们无法预知用户输入多少次不安全密码后才会输入一个强密码。我们将从 for 循环开始,然后再介绍 while 循环。

列表 5.1 第三章中的 best_word 函数
def best_word(word_list):
 """
 word_list is a list of words.

 Return the word worth the most points.
 """
    best_word = ""
    best_points = 0
    for word in word_list:       **#1
        points = num_points(word)
        if points > best_points:
            best_word = word
            best_points = points
    return best_word**

#1 这是一个 for 循环的示例。 for 循环允许我们访问字符串或列表中的每个值。我们首先用字符串来试试:

>>> s = 'vacation'
>>> for char in s:       #1
...     print('Next letter is', char)    #2
...
Next letter is v
Next letter is a
Next letter is c
Next letter is a
Next letter is t
Next letter is i
Next letter is o
Next letter is n

1 这段代码会对字符串 s 中的每个字符执行一次缩进代码。

2 因为“vacation”有八个字母,所以这段代码将运行八次。

注意我们不需要为char赋值语句。那是因为它是一个特殊的变量,叫做循环变量,它由for循环自动管理。char代表字符,它是人们常用的循环变量的名字。char变量会自动赋值为字符串的每个字符。谈到循环时,我们常用迭代一词来指代每次通过循环时执行的代码。这里,比如我们可以说在第一次迭代时,char代表v;在第二次迭代时,char代表a;依此类推。还要注意,就像函数和if语句一样,循环内部的代码也有缩进。虽然这里循环体内只有一行代码,但就像函数和if语句一样,我们可以有更多的代码行。

让我们看一下这个for循环在列表上的示例(列表 5.2),展示我们如何像处理字符串的每个值一样处理列表中的每个值。我们还会在循环中添加两行代码,而不仅仅是一个,以展示它是如何工作的。

列表 5.2 使用for循环的示例
>>> lst = ['cat', 'dog', 'bird', 'fish']
>>> for animal in lst:               #1
...     print('Got', animal)       #2
...     print('Hello,', animal)   ** #2
...
Got cat
Hello, cat
Got dog
Hello, dog
Got bird
Hello, bird
Got fish
Hello, fish**

**#1 第一个是列表,所以这是一个在列表上的for循环。

2 这段代码在每次迭代时执行。**列表 5.2 中的代码只是通过列表循环的其中一种方式。for animal in lst 的写法会在每次循环时将变量animal赋值为列表中的下一个值。或者,你也可以使用索引来访问列表中的每个元素。要做到这一点,我们需要了解内置的range函数。

range函数会给你一个范围内的数字。我们可以提供一个起始数字和一个结束数字,它会生成从起始数字到结束数字之间的范围,但不包括结束数字。为了查看range生成的数字,我们需要将list函数放在它周围。以下是使用range的示例:

>>> list(range(3, 9))     #1
[3, 4, 5, 6, 7, 8]

1 生成从 3 到 8 的范围(不是从 3 到 9!)

注意,它从3开始,包含了从38之间的所有值。也就是说,它包含了从起始值3到结束值9之间的所有数字,但不包括结束值9

现在,range如何帮助我们写循环呢?好吧,和直接在范围中硬编码数字像 3 和 9 不同,我们可以包括一个字符串或列表的长度,像这样:

>>> lst
['cat', 'dog', 'bird', 'fish'] 
>>> list(range(0, len(lst)))      #1
[0, 1, 2, 3]

1 从 0 开始,直到(但不包括)第一个列表的长度。

注意,这里的范围值是 0、1、2、3,它们是我们lst列表的有效索引!因此,我们可以使用range来控制for循环,这样就可以访问字符串或列表中的每个有效索引。

我们可以使用range来执行列表 5.2 中的相同任务。请参阅列表 5.3 中的新代码。

列表 5.3 使用for循环和range的循环示例
>>> for index in range(0, len(lst)):        #1
...     print('Got', lst[index])        #2
...     print('Hello,', lst[index])    ** #2
...
Got cat
Hello, cat
Got dog
Hello, dog
Got bird
Hello, bird
Got fish
Hello, fish**

**#1 使用range函数的for循环

2 使用索引变量对列表进行索引** **我们这里使用了一个名为index的变量,但你也常常会看到人们为了简便起见使用i。该变量在第一次迭代时赋值为0,在第二次迭代时为1,在第三次迭代时为2,最后一次迭代时为3。它在3时停止,因为列表的长度是 4,而range在这个值之前停止。通过对列表进行索引,代码依次获取第一个元素、第二个元素、第三个元素,然后是第四个元素,使用递增的索引值。我们也可以不使用0来编写for循环;range将假设我们想要0和给定值之间的值,如下所示:

for index in range(len(lst)):    #1
    print('Got', lst[index])
    print('Hello,', lst[index])

1 使用一个参数时,range假设我们想从 0 开始。

我们将在这里结束关于for循环的讨论。但我们还没有结束循环的部分,因为有另一种类型的循环我们需要讨论:while循环。

我们使用while循环当我们不知道循环要执行多少次时。一个典型的例子就是在我们第三章的get_strong_password函数中。我们在这里重现了那段代码,见列表 5.4。

列表 5.4 第三章的get_strong_password函数
def get_strong_password():
 """
 Keep asking the user for a password until it’s a strong password,
 and return that strong password.
 """
    password = input("Enter a strong password: ")
    while not is_strong_password(password):           #1
        password = input("Enter a strong password: ")
    return password

1 当密码不强时持续循环

我们不知道用户要输入多少次密码,直到他们输入一个强密码。可能是第一次尝试,第二次尝试,还是第 50 次尝试?谁知道呢。这就是我们需要使用while循环的原因:它确保用户不断被询问,直到他们提供一个强密码。

要编写while循环,我们需要在while后面跟一个布尔条件。如果条件为True,Python 将执行一次循环迭代。如果该布尔条件在迭代后仍然为True,那么它会执行下一次迭代。它会一直这样做,直到条件为False,此时循环结束。

现在,我们已经有足够的信息来理解我们的get_strong_ password函数的代码。通过查看代码,我们可以看到它首先使用input函数请求用户输入密码。然后,接下来是一个while循环。这个循环会一直执行,直到密码是一个强密码。布尔条件not is_strong_password(password)等价于写成is_strong_password (password) == False。为了避免无限循环并提示用户再次尝试,我们在循环内又使用了一次input。我们需要在循环之前调用input来获取用户的第一次尝试。如果没有那样做,password变量将不存在,while循环那一行将会报错,因为它没有password值可以传递给is_strong_password函数。

别忘了,我们可以使用在第四章第 4.2 节学到的内容,请 Copilot 解释这段代码。这在你感觉自己不太理解代码的作用或如何工作时非常有用。以下是我们从 Copilot 得到的解释:

这段 Python 代码定义了一个名为get_strong_password的函数。该函数的目的是与用户互动,提示用户输入密码,并持续询问直到用户提供一个被认为是强密码的密码。强密码的标准在此函数中没有定义,而是由另一个名为is_strong_password的函数来确定。

该函数首先通过 Python 内置的input函数请求用户输入密码。输入的密码随后存储在password变量中。

接下来,函数进入一个while循环。该循环的条件是not is_strong_password(password)。这意味着只要is_strong_password函数在调用当前密码时返回False,循环就会继续执行。换句话说,循环将一直运行,直到用户输入一个被认为是强密码的密码。

在循环内部,函数再次提示用户输入密码。新的密码会替换之前存储在password变量中的密码。如果新的密码仍然不强,循环将继续提示用户。

一旦用户输入了一个被认为是强密码的密码(即is_strong_password(password)返回True),while循环条件变为False,循环结束。函数随后返回用户输入的强密码。

这个函数是使用while循环反复提示用户输入,直到输入符合某些标准的一个很好的示例。然而,强密码的实际标准并未在此函数中定义,需要在is_strong_password函数中实现。

这个解释很好地描述了该函数,并且与我们刚才提供的描述相符!它还包含了一些有用的 Python 常识,包括最后一段关于我们为何使用while循环的提醒。解释中还提到我们需要is_strong_password函数来判断密码是否强大;我们并未特别强调这个函数,这就是为什么 Copilot 提示我们需要实现它。

Copilot 的解释可能是错误的

我们选择了前一个 Copilot 的解释,因为它是在我们要求 Copilot 解释代码三次后得到的最佳答案。它给出的其中一个答案听起来相当合理,直到开始提到一些不存在的函数。我们认为这些解释可以作为学习的辅助工具,如果你多次运行并寻找共同的思路,它们是有帮助的,但本章的主要目标是给你提供理解它犯错时的工具。

我们鼓励你以后使用 Copilot 的解释,并且如果你有兴趣,可以让 Copilot 解释任何你仍然好奇的之前章节中的代码。同样,这些解释可能是错误的,因此你应该向 Copilot 请求多个解释,以避免只依赖一个可能有误的解释。

正如目前与 AI 编程助手相关的一切一样,它们会犯错。但我们在这里给出解释,因为我们认为这个 Copilot 功能现在是一个潜在的强大教学资源,随着 Copilot 的不断改进,这一功能将变得更加有用。

在这种情况下,我们应该使用while循环,因为我们不知道迭代次数。但即使我们知道迭代次数,仍然可以使用while循环。例如,我们可以使用while循环处理字符串中的字符或列表中的值。我们有时会看到 Copilot 在生成的代码中这样做,尽管使用for循环会是更好的选择。例如,我们可以使用while循环处理我们之前的animals列表中的动物,如以下列表所示。不过,这需要更多的工作!

列表 5.5 使用while循环的示例
>>> lst
['cat', 'dog', 'bird', 'fish'] 
>>> index = 0
>>> while index < len(lst):        #1
...     print('Got', lst[index])
...     print('Hello,', lst[index])
...     index += 1          #2
...
Got cat
Hello, cat
Got dog
Hello, dog
Got bird
Hello, bird
Got fish
Hello, fish

1 len告诉我们字符串的长度,也就是我们想要的迭代次数。

2 这是常见的人为错误,容易遗漏!

如果没有index += 1,我们将无法在字符串中增加索引,打印出的信息将一直是第一个值。这被称为无限循环。如果回想一下我们写的for循环,你会发现我们并不需要手动增加任何索引变量。出于这种原因,许多程序员在可以使用时更倾向于使用for循环。我们不需要在for循环中手动追踪任何索引,因此自动避免了某些索引问题和无限循环。

5.1.2 #7. 缩进

缩进在 Python 代码中至关重要,因为 Python 通过它来确定哪些代码行是一起执行的。例如,我们总是缩进函数内部的所有代码行,if语句的各个部分,以及forwhile循环的代码。这不仅仅是格式问题:如果我们缩进错误,代码就会出错。例如,假设我们想询问用户当前的小时数,然后根据早晨、下午或晚上的时间输出一些文本:

  • 如果是早晨,我们想输出“Good morning!”和“Have a nice day.”

  • 如果是下午,我们想输出“Good afternoon!”

  • 如果是晚上,我们想输出“Good evening!”和“Have a good night.”

看一下我们编写的以下代码,尝试找出缩进的问题:

hour = int(input('Please enter the current hour from 0 to 23: '))

if hour < 12:
    print('Good morning!')
    print('Have a nice day.')
elif hour < 18:
    print('Good afternoon!')
else:
    print('Good evening!')
print('Have a good night.')     #1

1 这一行没有缩进。

问题出在最后一行:它没有缩进,但应该有!由于没有缩进,无论用户输入什么小时,我们都会输出Have a good night.。我们需要缩进它,使其成为if语句的else部分,确保它只有在晚上时才会执行。

每当我们编写代码时,我们需要使用多个缩进级别来表示哪些代码块与函数、if 语句、循环等相关联。例如,当我们写一个函数头时,我们需要缩进所有与该函数相关的代码。如果你已经处于函数体内(一层缩进),并且写了一个循环,那么你就需要再缩进一次(二层缩进)来表示循环体,依此类推。

回顾我们第三章的函数,可以看到这一点的应用。例如,在我们的 larger 函数中(作为列表 5.6 复印),整个函数体都有缩进,但 if 部分和 else 部分的 if 语句有进一步的缩进。

列表 5.6 用于确定两个值较大的函数
def larger(num1, num2):
    if num1 > num2:       #1
        return num1    #2
    else:                 #3
        return num2       #4

1 这里展示了函数主体的单个缩进。

2 这里展示了函数体和 if 语句体的双重缩进。

3 这里展示了函数主体的单个缩进。

4 这里展示了函数体和 else 语句体的双重缩进。

接下来,考虑我们之前在列表 5.4 中看到的 get_strong_password 函数:像往常一样,函数中的所有内容都有缩进,但 while 循环的主体部分有进一步的缩进。

我们的 num_points 函数的第一个版本(从第三章作为列表 5.7 复现)有更多的缩进层次。这是因为,在对单词的每个字符进行 for 循环时,我们有一个 if 语句。正如我们所学,每个 if 语句的部分都需要缩进,这导致了额外的缩进层次。

列表 5.7 num_points 函数
def num_points(word): 
 """ 
 Each letter is worth the following points: 
 a, e, i, o, u, l, n, s, t, r: 1 point 
 d, g: 2 points 
 b, c, m, p: 3 points 
 f, h, v, w, y: 4 points 
 k: 5 points 
 j, x: 8 points 
 q, z: 10 points 

 word is a word consisting of lowercase characters. 
 Return the sum of points for each letter in word. 
 """
    points = 0
    for char in word:            #1
        if char in "aeioulnstr":     #2
            points += 1          #3
        elif char in "dg":
            points += 2
        elif char in "bcmp":
            points += 3
        elif char in "fhvwy":
            points += 4
        elif char == "k":
            points += 5
        elif char in "jx":
            points += 8
        elif char in "qz":
            points += 10
    return points

1 这里进行了缩进,以便在函数内部。

2 这里再次进行了缩进,以便在 for 循环内部。

3 这里再次进行了缩进,以便在 if 语句内部。

is_strong_password 中也有额外的缩进(从第三章作为列表 5.8 复现),但那只是为了将一行超长代码分布到多行中。注意,这些行的末尾有 \,这是允许我们将一行代码延续到下一行的字符。

列表 5.8 is_strong_password 函数
def is_strong_password(password):
 """
 A strong password has at least one uppercase character,
 at least one number, and at least one punctuation.

 Return True if the password is a strong password, 
 False if not.
 """
    return any(char.isupper() for char in password) and \     #1
           any(char.isdigit() for char in password) and \     #2
           any(char in string.punctuation for char in password)

1 该行以反斜杠结束,以继续语句。

2 这个缩进不是必须的,但对视觉上布局单个返回语句很有帮助。

类似地,我们的 num_points 第二版本(从第三章作为列表 5.9 复现)也有进一步的缩进,但那只是为了将字典分布到多行中,以提高可读性。

列表 5.9 num_points 替代解决方案
 def num_points(word): 
 """ 
 Each letter is worth the following points: 
 a, e, i, o, u, l, n, s, t, r: 1 point 
 d, g: 2 points 
 b, c, m, p: 3 points 
 f, h, v, w, y: 4 points 
 k: 5 points 
 j, x: 8 points 
 q, z: 10 points 

 word is a word consisting of lowercase characters. 
 Return the sum of points for each letter in word. 
 """ 
    points = {'a': 1, 'e': 1, 'i': 1, 'o': 1, 'u': 1, 'l': 1,     #1
              'n': 1, 's': 1, 't': 1, 'r': 1,        #2
              'd': 2, 'g': 2,
              'b': 3, 'c': 3, 'm': 3, 'p': 3,
              'f': 4, 'h': 4, 'v': 4, 'w': 4, 'y': 4,
              'k': 5,
              'j': 8, 'x': 8,
              'q': 10, 'z': 10}
    return sum(points[char] for char in word)

1 我们可以将字典值写成多行。

2 这个缩进不是必须的,但对视觉上布局字典很有帮助。

缩进对我们程序的最终行为有巨大影响。例如,让我们比较将两个循环放在一起与使用缩进将一个循环嵌套在另一个循环中的效果。以下是两个并列的循环:

>>> countries = ['Canada', 'USA', 'Japan']
>>> for country in countries:       #1
...     print(country)
...
Canada
USA
Japan
>>> for country in countries:      #2
...     print(country)
...
Canada
USA
Japan

1 这是第一个循环。

2 这是第二个循环(发生在第一个循环之后)。

这导致我们输出相同的结果两次,因为我们在国家列表中分别循环了两次。现在,如果我们将这两个循环嵌套在一起,就会出现以下情况:

>>> for country1 in countries:            #1
...     for country2 in countries:        #2
...         print(country1, country2)    #3
...
Canada Canada
Canada USA
Canada Japan
USA Canada
USA USA
USA Japan
Japan Canada
Japan USA
Japan Japan

1 这是第一个循环。

2 这是嵌套在第一个循环中的第二个循环。

3 打印语句嵌套在第二个循环中,该循环又嵌套在第一个循环中。

我们为每个 for 循环使用了不同的变量名,country1country2,以便我们可以同时引用它们。在 country1 循环的第一次迭代中,country1 引用的是 加拿大。在 country2 循环的第一次迭代中,country2 同样引用的是 加拿大。这就是为什么输出的第一行是 加拿大 加拿大。你是否期望接下来的输出行是 美国 美国?但实际情况并非如此!相反,country2 循环会进入它的下一次迭代,但 country1 循环仍然没有前进。country1 循环只有在 country2 循环完成时才会向前移动。这就是为什么我们会先看到 加拿大 美国加拿大 日本,然后 country1 循环才会进入它的第二次迭代。当一个循环嵌套在另一个循环内部时,这叫做 嵌套循环。一般来说,当有嵌套时,内层循环(for country2 in countries)会完成它的所有步骤,外层循环(for country1 in countries)才会继续进行下一步,这又会重新启动内层循环。

如果你看到一个循环嵌套在另一个循环中,很有可能这两个循环是用来处理二维数据的。二维数据是按行和列组织的,就像你在表格中看到的那样(例如,表 5.1)。这种数据在计算机中非常常见,因为它包括基本的电子表格数据,如 CSV 文件、图片(例如照片或视频的一帧)、或者计算机屏幕的数据。

在 Python 中,我们可以使用列表来存储二维数据,其中的值本身也是其他列表。整体列表中的每个子列表代表一行数据,每一行都有每一列的值。例如,假设我们有一些关于 2018 年冬奥会花样滑冰奖牌的数据,如表 5.1 所示。

表 5.1 2018 年冬奥会奖牌
国家 金牌 银牌 铜牌
加拿大 2 0 2
OAR 1 2 0
日本 1 1 0
中国 0 1 0
德国 1 0 0

我们可以将其存储为一个列表,每个国家占一行:

>>> medals = [[2, 0, 2],
...           [1, 2, 0],
...           [1, 1, 0],
...           [0, 1, 0],
...           [1, 0, 0]]

请注意,我们的列表列表仅存储了数值,并且我们可以通过引用其行和列来在列表列表中查找一个值(例如,日本的金牌对应的是索引 2 行和索引 0 列)。我们可以使用索引获取一整行数据:

>>> medals[0]    #1
[2, 0, 2]
>>> medals[1]    **#2
[1, 2, 0]
>>> medals[-1]    **#3
[1, 0, 0]****

****#1 这是第 0 行(第一行)。

2 这是第 1 行(第二行)。

3 这是最后一行。**** ****如果我们对这个列表进行for循环,我们将一次获得每一整行:

>>> for country_medals in medals:     #1
...     print(country_medals)
...
[2, 0, 2]
[1, 2, 0]
[1, 1, 0]
[0, 1, 0]
[1, 0, 0]

1 for循环一次给我们一个列表的值(即一次一个子列表)。

如果我们只想从奖牌列表中获取特定的值(而不是整行),我们需要索引两次:

>>> medals[0][0]   #1
2
>>> medals[0][1]    #2
0
>>> medals[1][0]    #3
1

1 这是第 0 行,第 0 列。

2 这是第 0 行,第 1 列。

3 这是第 1 行,第 0 列。

假设我们想要逐个遍历每个值。为此,我们可以使用嵌套的for循环。为了帮助我们准确跟踪当前位置,我们将使用range for循环,这样我们就可以打印出当前的行和列号,以及存储在其中的值。

外部循环将遍历行,因此我们需要使用range (len(medals))来控制它。内部循环将遍历列。有多少列呢?列数就是某一行中的值的数量,因此我们可以使用range(len(medals[0]))来控制这个循环。

每一行输出将提供三个数字:行坐标、列坐标以及该行列中的值(奖牌数)。以下是代码和输出:

>>> for i in range(len(medals)):          #1
...     for j in range(len(medals[i])):      #2
...             print(i, j, medals[i][j])
...
0 0 2
0 1 0
0 2 2
1 0 1
1 1 2
1 2 0
2 0 1
2 1 1
2 2 0
3 0 0
3 1 1
3 2 0
4 0 1
4 1 0
4 2 0

1 遍历行

2 循环当前行的列

请注意,在输出的前三行中,行保持不变,而列从 0 变化到 2。我们就是这样遍历第一行的。只有当行号增加到 1 时,我们才会开始处理第二行,并完成对该行的列 0 到 2 的工作。

嵌套循环为我们提供了一种系统化的方式来遍历二维列表中的每个值。当处理二维数据时,您将经常遇到它们,例如图像、棋盘游戏和电子表格。

5.1.3 #8. 字典

请记住,在 Python 中,每个值都有一个特定的类型。因为我们可能会使用许多不同种类的值,所以类型非常多!我们已经讨论过使用数字来处理数值,布尔值来处理True/False值,字符串来处理文本,以及列表来处理其他值的序列,例如数字或字符串。

还有一个在 Python 中经常出现的类型,它叫做字典。当我们在 Python 中谈论字典时,我们并不是指单词及其定义的列表。在 Python 中,字典是一种非常有用的存储数据的方式,尤其是当你需要跟踪数据之间的关联时。例如,假设你想知道在你最喜欢的书中哪些单词使用得最多。你可以使用字典将每个单词映射到它使用的次数。这个字典可能非常庞大,但一个小版本的字典可能是这样的:

>>> freq = {'DNA': 11, 'acquire': 11, 'Taxxon': 13, \
... 'Controller': 20, 'morph': 41}

字典中的每个条目都将一个单词映射到它的频率。例如,我们可以从这个字典中得知,单词 DNA 出现了 11 次,单词 Taxxon 出现了 13 次。这里的单词(DNAacquireTaxxon 等)被称为 ,而频率(11、11、13 等)被称为 。因此,字典将每个键映射到它的值。我们不允许有重复的键,但正如这里显示的那样,具有重复值并不成问题。

在第二章(列出 2.1)我们看到一个字典,存储了每个四分卫的名字和他们的传球码数。在第三章,我们再次在第二个解决方案中看到了一个字典,用来表示 num_points(在 5.9 列表中复现)。在那里,字典将每个字母映射到使用该字母时获得的点数。

就像字符串和列表一样,字典也有方法可以让你与它们进行交互。以下是一些操作我们的freq字典的方法:

>>> freq
{'DNA': 11, 'acquire': 11, 'Taxxon': 13, 'Controller': 20, 'morph': 41}
>>> freq.keys()                **#1
dict_keys(['DNA', 'acquire', 'Taxxon', 'Controller', 'morph'])
>>> freq.values()                   #2
dict_values([11, 11, 13, 20, 41])
>>> freq.pop('Controller')         #3
20
>>> freq
{'DNA': 11, 'acquire': 11, 'Taxxon': 13, 'morph': 41}**

**#1 获取所有键

**#2 获取所有值

#3 删除键及其关联值 **你还可以使用索引符号来访问给定键的值:

>>> freq['dna']  # Oops, wrong key name because it is case sensitive
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
KeyError: 'dna'
>>> freq['DNA']       #1
11
>>> freq['morph']
41

**#1 获取与键 “DNA” 关联的值

字典像列表一样是可变的。这意味着我们可以更改字典中的键和值,这对于建模随时间变化的数据非常有用。我们可以使用索引来更改一个值。当前与 'morph' 关联的值是 41,我们将其改为 6

>>> freq['morph'] = 6     #1
>>> freq
{'DNA': 11, 'acquire': 11, 'Taxxon': 13, 'morph': 6}

**#1 将与键 “morph” 关联的值改为 6

我们的 freq 字典允许我们从任何想要的单词开始,并找到它的频率。更一般地说,字典允许我们从 键到值。然而,它并不容易让我们从值到键反向查找。如果我们想做到这一点,我们需要生成一个反向字典——例如,键是频率,值是具有这些频率的单词列表。这样我们就能回答以下问题:哪些单词的频率恰好是 5?哪些单词的频率是所有单词中最小或最大的?

就像字符串和列表一样,我们也可以使用循环来处理字典中的信息。for 循环为我们提供字典的键,我们可以使用索引来获取每个键的关联值:

>>> for word in freq:                         #1
...     print('Word', word, 'has frequency', freq[word])    #2
...
Word DNA has frequency 11
Word acquire has frequency 11
Word Taxxon has frequency 13
Word morph has frequency 6

*#1 遍历 freq 字典中的每个键

#2 使用键(单词)及其关联值(freq[word]) *### 5.1.4 #9. 文件

我们通常需要处理存在于文件中的数据集。例如,在第二章,我们使用了 NFL 统计数据文件来确定最有效的四分卫。使用文件是其他数据科学任务中常见的做法。例如,如果你正在绘制世界各地的地震信息或确定两本书是否由同一位作者写作,你也需要处理这些数据集,而这些数据集通常会存储在文件中。

在第二章,我们处理了一个名为 nfl_offensive_stats.csv 的文件。确保该文件位于当前程序目录中,因为我们将用它来进一步理解我们在第二章中使用的一些代码。

处理文件数据的第一步是使用 Python 的open函数打开文件:

>>> nfl_file = open('nfl_offensive_stats.csv')

有时你会看到 Copilot 在这里添加r作为第二个参数:

>>> nfl_file = open('nfl_offensive_stats.csv', 'r')

但我们不需要rr只是表示我们想从文件中读取数据,但如果不指定,默认就是读取模式。

我们使用赋值语句将打开的文件分配给名为nfl_file的变量。现在,我们可以使用nfl_file来访问文件的内容。一个打开的文件是 Python 中的一种类型,就像数字、字符串以及你迄今为止看到的所有其他类型一样。因此,我们可以调用方法与文件进行交互。一个方法是readline,它返回文件的下一行作为字符串。我们现在就使用它来获取我们打开文件的第一行,但不用担心这一行,因为它很长,包含了很多关于我们最终不会使用的列的信息:

>>> line = nfl_file.readline()     #1
>>> line
'game_id,player_id,position,player,team,pass_cmp,pass_att,pass_yds,pass_td,pass_int,pass_sacked,pass_sacked_yds,pass_long,pass_rating,rush_att,
rush_yds,rush_td,rush_long,targets,rec,rec_yds,rec_td,rec_long,
fumbles_lost,rush_scrambles,designed_rush_att,comb_pass_rush_play,
comb_pass_play,comb_rush_play,Team_abbrev,Opponent_abbrev,two_point_conv,
total_ret_td,offensive_fumble_recovery_td,pass_yds_bonus,rush_yds_bonus,
rec_yds_bonus,Total_DKP,Off_DKP,Total_FDP,Off_FDP,Total_SDP,Off_SDP,
pass_target_yds,pass_poor_throws,pass_blitzed,pass_hurried,
rush_yds_before_contact,rush_yac,rush_broken_tackles,rec_air_yds,rec_yac,
rec_drops,offense,off_pct,vis_team,home_team,vis_score,home_score,OT,Roof,
Surface,Temperature,Humidity,Wind_Speed,Vegas_Line,Vegas_Favorite,
Over_Under,game_date\n'

1 从文件中读取一行

从这样的混乱字符串中提取单个值并不容易。因此,我们通常会先将这行数据拆分为各个列数据。我们可以使用字符串的split方法来做到这一点。该方法以分隔符作为参数,并使用该分隔符将字符串拆分成一个列表:

>>> lst = line.split(',')    #1
>>> len(lst)
69

1 使用逗号(,)作为分隔符来拆分字符串

现在我们可以查看各个列名:

>>> lst[0]
'game_id'
>>> lst[1]
'player_id'
>>> lst[2]
'position '     #1
>>> lst[3]
'player'
>>> lst[7]
'pass_yds'

1 这个词末尾的空格在原始数据集中存在,但其他列标题没有空格。

我们看到的文件中的第一行并不是真正的数据行——它只是告诉我们每列名称的标题。下次我们调用readline时,将获得第一行真正的数据:

>>> line = nfl_file.readline()
>>> lst = line.split(',')
>>> lst[3]
'Aaron Rodgers'
>>> lst[7]
'203'

像这样逐行读取文件对于探索文件内容是没问题的,但最终我们可能希望处理整个文件。为此,我们可以对文件使用for循环。每次迭代它都会返回一行,我们可以按需要处理这行数据。一旦我们处理完文件,就应该调用close来关闭它:

>>> nfl_file.close()

关闭后,我们将无法再使用该文件。现在我们已经讨论了如何读取、处理和关闭文件,让我们来看一个完整的例子。在列表 5.10 中,我们提供了来自第二章的新版本程序,该程序按四分卫的总传球码数对其进行排序。除了展示文件外,我们还使用了在第四章和本章中看到的许多 Python 特性,包括条件语句、字符串、列表、循环和字典。

列表 5.10 没有 csv 模块的替代 NFL 统计代码
nfl_file = open('nfl_offensive_stats.csv')
passing_yards = {}                    #1

for line in nfl_file:                #2
    lst = line.split(',')
    if lst[2] == 'QB':               #3
        if lst[3] in passing_yards:                   #4
            passing_yards[lst[3]] += int(lst[7])      #5
        else:                                         #6
            passing_yards[lst[3]] = int(lst[7])       #7

nfl_file.close()

for player in sorted(passing_yards, 
                     key=passing_yards.get, 
                     reverse=True):         #8
    print(player, passing_yards[player])

1 这个字典将四分卫的名字映射到他们的传球码数。

2 遍历文件的每一行

3 只关注四分卫

4 四分卫已经在我们的字典中了。

5 累加四分卫的总数;int 将像 '203' 这样的字符串转换为整数。

6 四分卫尚未出现在我们的字典中。

7 设置四分卫的初始总数

8 从最高到最低的传球码数遍历四分卫

最后面的那一行 for player in sorted(passing_yards, key=passing_yards.get, reverse=True): 涉及了很多内容。我们在注释中解释了这行代码是从最高到最低遍历四分卫。reverse=True 让我们按从高到低排序,而不是默认的从低到高排序。key=passing_yards.get 使排序的重点放在传球码数上(而不是,比如,球员的名字)。如果你想进一步解析这行代码,随时可以向 Copilot 请求更多解释。这突出了我们在这里要保持的平衡:了解足够的内容,能够把握代码的大意,而不一定需要理解每个细节。

这个程序运行得很好;如果你运行它,你会看到和运行第二章代码时相同的输出。不过,有时候,使用模块编写程序会更简单(我们将在下一节深入讲解模块),这正是第二章中的程序所做的。由于 CSV 文件非常常见,Python 提供了一个模块来简化对它们的处理。在第二章中,我们得到的解决方案使用了 csv 模块。所以,让我们讨论一下我们在列表 5.10 中未使用模块的代码和第二章代码之间的主要区别,下面的列表将再次展示第二章代码(我们提供给 Copilot 的提示未显示)。

列表 5.11 使用 csv 模块的 NFL 统计代码
# import the csv module
import csv

# open the csv file
with open('nfl_offensive_stats.csv', 'r') as f:    #1
    # read the csv data
    data = list(csv.reader(f))    #2

# create a dictionary to hold the player name and passing yards
passing_yards = {}

# loop through the data
for row in data:                  #3
    # check if the player is a quarterback
    if row[2] == 'QB':
        # check if the player is already in the dictionary
        if row[3] in passing_yards:
            # add the passing yards to the existing value
            passing_yards[row[3]] += int(row[7])
        else:
            # add the player to the dictionary
            passing_yards[row[3]] = int(row[7])

for player in sorted(passing_yards, key=passing_yards.get, reverse=True):
    print(player, passing_yards[player])

1 显示打开文件的另一种语法

2 使用特殊的 csv 模块;从文件中读取所有数据

3 遍历每一行数据

首先,列表 5.11 使用 csv 模块来简化处理 CSV 文件的过程。csv 模块知道如何操作 CSV 文件,因此,例如,我们不需要担心将一行数据拆分成各列。其次,列表 5.11 使用了 with 关键字,这样文件在程序完成后会自动关闭。第三,列表 5.11 在进行任何处理之前会先读取整个文件。相比之下,在列表 5.10 中,我们在读取每一行时会立即对其进行处理。

解决编程问题有多种方法

总是有多种不同的程序可以用来解决同一个任务。有些程序可能比其他程序更容易阅读。代码最重要的标准是它是否能正确完成任务。之后,我们最关心的是可读性和效率。因此,如果你发现自己难以理解某些代码的工作原理,可能值得花些时间看看 Copilot 的其他代码,看看是否有更简单或更易懂的解决方案。

文件在计算任务中广泛使用,因为它们是常见的数据来源,包括本节中的 CSV 文件、记录计算机或网站事件的日志文件,以及存储视频游戏中可能看到的图形数据的文件等等。由于文件的使用如此普遍,毫不奇怪有很多模块可以帮助我们读取各种文件格式。这引出了我们要讨论的更大话题:模块。

5.1.5 #10. 模块

人们使用 Python 来做各种各样的事情——游戏、网站、数据分析应用程序、自动化重复任务、控制机器人等等。你可能会好奇,Python 如何让你创建这么多不同类型的程序。肯定的,Python 的创建者不可能预见到所有需要的支持,或者为此编写所有代码!

事实是,默认情况下,您的 Python 程序仅能访问一些核心的 Python 功能(比如我们在上一章和本章中展示的那些功能)。要获得更多功能,我们需要使用模块。而且,要使用模块,您需要先导入它。

Python 中的模块

一个模块是为特定目的设计的代码集合。回想一下,我们在使用函数时并不需要了解它是如何工作的。模块也是如此:我们不需要了解模块是如何工作的,就能使用它,就像我们不需要了解电灯开关是如何内部工作的就能使用它一样。作为模块的使用者,我们只需要知道模块能帮助我们做什么,以及如何编写正确的代码来调用它的函数。当然,Copilot 可以帮助我们编写这种代码。

一些模块在安装 Python 时会随 Python 一起提供,但我们仍然需要导入它们。其他模块则需要我们先安装,才能导入它们。相信我们,如果你有某种特定的任务要用 Python 完成,可能已经有人写过一个模块来帮你了。

你可能会想知道如何判断应该使用哪些 Python 模块。如何知道有哪些模块?与 Copilot 聊天或进行 Google 搜索通常能帮助你。例如,如果我们在 Google 上搜索“Python 模块来创建 zip 文件”,第一个结果告诉我们,我们需要的模块是 Python 标准库的一部分,这意味着它随 Python 一起提供。如果我们搜索“Python 可视化模块”,我们会了解到像 matplotlib、plotly、seaborn 等模块。搜索这些模块的结果应该会带你看到一些展示它们能力的可视化内容,以及它们通常用于哪些场景。大多数模块都是免费的,尽管你的搜索结果可以帮助你确认模块是否免费以及其具体的使用许可证。我们会在第九章时再开始安装和使用新安装的模块,但到那时,你会看到如何查找、安装并使用相关模块来帮助我们完成任务。

表 5.2 列出了常用的 Python 模块,以及它们是否是内置模块。如果一个模块是内置的,你可以直接导入并开始使用;如果不是,你需要先安装它。

表 5.2 常用 Python 模块的总结
模块 内置 描述
csv 帮助读取、写入和分析 CSV 文件
zipfile 帮助创建和提取压缩的.zip 归档文件
matplotlib 用于绘图的图形库,是其他图形库的基础,并提供高度定制化功能
plotly 一个用于创建交互式网页图表的图形库
seaborn 基于 matplotlib 构建的图形库,比 matplotlib 更容易创建高质量的图表
pandas 一个数据处理库,专注于数据框(类似于电子表格)
scikit-learn 包含用于机器学习的基本工具(即帮助从数据中学习并做出预测)
numpy 提供高效的数据处理功能
pygame 一个游戏编程库,帮助在 Python 中构建交互式图形游戏
django 一个帮助设计网站和网页应用程序的 Web 开发库

在第二章中,我们的代码使用了 Python 自带的 csv 模块。让我们继续学习一个 Python 自带的不同模块。

当人们想要整理他们的文件,可能是在备份或上传之前,他们通常会先将它们打包成.zip 文件。然后他们可以传送那个单独的.zip 文件,而不是可能数百个甚至数千个单独的文件。Python 自带一个叫做 zipfile 的模块,可以帮助你创建.zip 文件。

为了尝试这个,创建几个文件并将它们的扩展名都设为.csv。你可以从你的 nfl_offensive_stats.csv 文件开始,然后再添加几个。例如,你可以添加一个名为 actors.csv 的文件,列出几个演员的名字和他们的年龄,如

Actor Name, Age
Anne Hathaway, 40
Daniel Radcliffe, 33

你可以添加一个名为 chores.csv 的文件,里面列出了一些家务事以及是否完成:

Chore, Finished?
Clean dishes, Yes
Read Chapter 6, No

只要你有几个.csv 文件用于测试,内容并不重要。现在我们可以使用 zipfile 模块将它们全部添加到一个新的.zip 文件中!

>>> import zipfile
>>> zf = zipfile.ZipFile('my_stuff.zip', 'w',
    ↪ zipfile.ZIP_DEFLATED)     **#1
>>> zf.write('nfl_offensive_stats.csv')       #2
>>> zf.write('actors.csv')       #3
>>> zf.write('chores.csv')   #4
>>> zf.close()**

**#1 创建新的.zip 文件

2 添加第一个文件

3 添加第二个文件

4 添加第三个文件** **如果你运行那个代码,你会发现一个名为 my_stuff.zip 的新文件,里面包含了你的三个.csv 文件。直接处理.zip 文件在其他早期编程语言中通常是一个非常专业且容易出错的任务,但在 Python 中并非如此。Python 自带的一些模块对数据科学、游戏开发、处理各种文件格式等方面都很有帮助,但同样,Python 并不能包含所有东西。当我们需要更多时,我们会转向可下载的模块,正如我们将在第九章中看到的那样。

在本章中,我们介绍了 Python 特性排名前 10 的后半部分,概览请见表 5.3。我们在前一章和本章已经讲了很多关于如何阅读代码的内容。虽然我们没有涵盖 Copilot 可能生成的所有代码,但你现在已经具备了检查 Copilot 代码的能力,能够判断它是否正确生成了你请求的代码。我们还展示了如何使用 Copilot 解释工具帮助你理解新的代码。在接下来的章节中,我们将学习如何测试 Copilot 生成的代码以确定其正确性,以及在代码不正确时可以做些什么。

表 5.3 本章 Python 代码特性总结
代码元素 示例 简短描述
循环 for 循环:for country in countries: print(country) while 循环:index = 0 while index < 4: print(index) index = index + 1 循环允许我们根据需要多次运行相同的代码。当我们知道循环次数时(例如字符串中的字符数),使用 for 循环;当我们不知道循环次数时(例如,询问用户输入强密码),使用 while 循环。
缩进 for country in countries: print(country) 缩进告诉 Python 代码块之间的关系,例如,print 调用属于 for 循环的一部分。
字典 points = {'a': 1, 'b': 3} 字典允许我们将一个键与一个值关联。例如,键 'a' 与值 1 关联。
文件 file = open('chores.csv') first_line = file.readline() 文件包含数据并存储在你的计算机上。Python 可以用来打开多种类型的文件并读取其内容,允许你处理文件中的数据。
模块 import csv 模块是已经存在的库,提供额外的功能。常用的模块包括 csv、numpy、matplotlib、pandas 和 scikit-learn。一些模块随标准 Python 配发;另一些则需要单独安装。

5.2 练习

  1. 回想一下我们在列表 5.3 中看到的 for 循环代码,用来打印列表中的动物。与本章中的原始示例相比,这段修改后的代码有什么不同之处?具体来说,它产生了哪些额外的输出?
lst = ['cat', 'dog', 'bird', 'fish']

for index in range(len(lst)):
    print('Got', lst[index])
    if lst[index] == 'bird':
        print('Found the bird!')
    print('Hello,', lst[index])
    1. 考虑以下 while 循环代码,试图重复我们在列表 5.3 中使用 for 循环做的事情。当我们运行代码时,我们发现它会无限运行。你能找出并修复导致它无限运行的错误吗?
lst = ['cat', 'dog', 'bird', 'fish']

index = 0
while index < len(lst):
    print('Got', lst[index])
    print('Hello,', lst[index])
    1. 将以下代码行排列成一个 while 循环,打印列表中的每个数字,直到遇到数字 7。注意缩进!
 index += 1
 while index < len(numbers) and numbers[index] != 7:
 index = 0
 numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9]
 print(numbers[index])
    1. 想一想,在什么样的现实场景下使用 while 循环比 for 循环更合适。描述这个场景并解释为什么 while 循环是更好的选择。
  1. 5. 修改get_strong_password函数(或它调用的is_strong_password函数),以提供关于输入密码不够强的具体反馈。例如,如果密码没有大写字母,则打印“密码必须包含大写字母”;如果没有数字,则打印“密码必须至少包含一个数字”。

  2. 6. 给定以下print_quarterbacks函数,你能否重写它,使用“with”语句来打开和关闭文件?为什么关闭文件很重要?

def print_quarterbacks():
    nfl_file = open('nfl_offensive_stats.csv')
    for line in nfl_file:
        lst = line.split(',')
        if lst[2] == 'QB':
            print(f"{lst[3]}: {lst[7]} passing yards")
    nfl_file.close()
  1. 7. 在这个练习中,我们将进一步练习使用 zipfile 模块来创建一个包含多个 CSV 文件的.zip 文件。按照以下步骤完成任务并回答问题:

    1. 首先,在当前目录下创建三个 CSV 文件:

      • nfl_offensive_stats.csv(你应该已经有这个文件)

      • actors.csv,包含以下内容:

                      Actor Name, Age
                      Anne Hathaway, 40
                      Daniel Radcliffe, 33
        
        
      • chores.csv,包含以下内容:

                      Chore, Finished?
                      Clean dishes, Yes
                      Read Chapter 6, No
        
        
    2. 使用 Copilot(不要像我们在本章中那样直接输入代码),编写一个 Python 脚本,使用 zipfile 模块将这三个 CSV 文件添加到名为 my_stuff.zip 的.zip 文件中。

    3. Copilot 建议的 zipfile 模块提供了哪些其他功能?它们如何有用?

总结

  • 循环用于根据需要重复执行代码。

  • 当我们知道循环将执行多少次时,我们使用for循环;当我们不知道循环将执行多少次时,我们使用while循环。

  • Python 使用缩进来确定哪些代码行是一起执行的。

  • 字典是从键(例如,书中的单词)到值(例如,它们的频率)的映射。

  • 在读取文件之前,我们需要先打开它。

  • 一旦文件被打开,我们可以使用方法(例如,readline)或循环来读取文件的行。

  • 一些模块,例如 csv 和 zipfile,是 Python 自带的,可以通过导入它们来使用。

  • 其他模块,如 matplotlib,需要先安装,然后才能导入并使用。***************

第六章:6 测试与提示工程

本章内容包括

  • 理解测试 Copilot 代码的重要性

  • 使用闭盒测试与开盒测试

  • 通过修改提示来解决 Copilot 的错误

  • 通过实例讲解如何测试 Copilot 生成的代码

在第三章中,我们首次开始意识到测试 Copilot 生成的代码的重要性。测试是编写软件的每个人都必须掌握的技能,因为它能让你确信代码是否正常工作。在本章中,我们将学习如何全面地测试代码,并通过修改提示帮助 Copilot 修复无法工作的代码。

测试是一个至关重要的技能,你需要学会如何独立做好这件事,以便检查代码是否正确。Copilot 可以生成测试,并且在生成测试的质量上已有所提高,但我们鼓励你暂时不要使用 Copilot 生成测试,因为你需要先学会如何独立做得足够好,才能验证 Copilot 生成的测试是否合理。这一点在接下来的几章中也适用——问题分解、测试和调试是你必须自己学会的技能,然后才能寻求 Copilot 的帮助,因为你需要知道自己是否掌握了这些技能,才能知道 Copilot 做的是否合理。

6.1 为什么测试代码至关重要

在第三章中,我们提到过你应该测试代码以确保它的正确性。不幸的是,根据我们的经验,初学编程的程序员似乎对测试有一种排斥感!为什么?我们认为有几个原因。第一个原因是存在一个被广泛记录的问题,叫做超级错误,即人类在刚开始学习编程时,往往认为计算机能够理解代码的意图并作出相应的反应[1]。因为他们写的代码对他们来说是有意义的,所以他们很难想象代码可能会不工作。第二个问题是对第一个问题的加剧:如果你认为自己的代码是对的,那么测试只会带来坏消息。如果你不测试,就无法发现代码是否有问题。这就像老话说的,把头埋在沙子里。

专业的软件工程师采用的测试方法与新手程序员截然不同。他们对待测试非常严谨,因为代码中的一个错误可能会给公司带来重大后果。没有人愿意成为那个让公司失去大量收入、让黑客访问机密用户数据,或者导致自动驾驶汽车发生事故的人。考虑到错误的成本,假设代码是错误的,直到经过验证是正确的,这种做法更为合理。只有在广泛测试之后,我们才会相信它正常工作。而且,公司不仅仅进行一次测试,他们会将测试保存在系统中,这样每次有人更改代码时,测试不仅会在更改的代码上运行,还会在任何可能受更改代码影响的代码上运行(这就是回归测试)。

公司非常重视这一点,以至于他们经常在编写代码之前就编写测试,这个过程叫做测试驱动开发(TDD)。这确保每个人都同意代码应该做什么或不应该做什么。我们认为,你(作为读者)不需要采用这种方法来编写本书中的程序,但我们在这里提到它是为了传达测试的重要性。在编写代码之前思考测试可以帮助你理解代码应该做什么,从而帮助你编写更好的提示。事实上,你可以直接在提示中包含测试用例!

最后,让我们记住关于 Copilot 的事情:它会犯错。我们不应该假设 Copilot 给我们的任何代码都是正确的。所有这些意味着,你从 Copilot 那里获得的任何代码都应该在信任它之前进行测试。

6.2 闭盒测试与开盒测试

软件工程师通常有两种方式来测试他们的代码。第一种叫做闭盒测试,这种方法假设你对代码的工作原理一无所知。因此,这种测试方法涉及到改变输入并观察输出。我们通常看到闭盒测试应用于函数或整个程序。闭盒测试的优点在于,你不需要查看代码就可以进行测试,因此可以专注于期望的行为。

第二种测试方法叫做开盒测试,在这种方法中,我们查看代码,找出可能出错的地方。开盒测试的优势在于,通过查看代码的具体结构,我们可以发现代码可能出错的位置,并为该部分代码设计额外的测试。我们将同时使用闭盒测试和开盒测试,结合这两种方法来强化我们的测试。闭盒测试和开盒测试的简要总结见表 6.1。在本节中,让我们看看如何使用这些方法测试一些函数。

表 6.1 闭盒测试和开盒测试的简要概述
闭盒测试 开盒测试
需要理解函数规格才能测试 需要理解函数规格和实现该函数的代码才能测试
测试不需要理解代码的功能。 测试应该根据代码的编写方式量身定制。
测试人员不需要具备关于他们测试的代码的技术专长。 测试人员需要能够理解代码,以便确定哪些测试可能更为重要。
通过改变输入并检查预期结果来测试函数 可以像封闭盒测试一样测试函数,但也可以在函数内进行更细粒度的测试

6.2.1 封闭盒测试

假设我们正尝试测试一个接受单词列表(字符串)并返回最长单词的函数。更准确地说,函数签名应该是

def longest_word(words):

期望的输入是一个单词列表。期望的输出是该列表中字符数最多的单词。如果多个单词字符数相同,则应返回第一个该长度的单词。

表达测试用例的简写

编写函数测试时,标准格式是写出函数名称及其输入和期望的结果。例如,调用

>>> longest_word(['a', 'bb', 'ccc'])
'ccc'

这意味着如果我们用输入列表['a', 'bb', 'ccc']调用longest_word函数,那么该函数返回的值应该是'ccc'

通常我们考虑编写测试用例的分类有两种:

  • 常见用例—这些案例包括一些你可以想象函数接收到的标准输入以及相应的结果。

  • 边缘案例—这些案例是不常见的,但可能会破坏代码的情况。这些输入可能会更深入地测试函数的一些规则,或者包含意外输入(例如,包含所有空字符串的列表)。

回顾我们在前一个示例中的longest_word函数签名,让我们思考一些我们可能会用来测试它的测试用例。在本章稍后,我们将看到如何实际运行这些测试用例,以判断我们的代码是否正常工作。让我们从常见用例开始。我们很可能想包括一个测试,测试几个单词,其中有一个单词比其他单词长:

>>> longest_word(['cat', 'dog', 'bird']) 
'bird'

这是另一个测试,包含更多的单词,且列表中的最长单词出现在其他位置:

>>> longest_word(['happy', 'birthday', 'my', 'cat']) 
'birthday'

最后,让我们来一个只有一个单词的测试:

>>> longest_word(['happy']) 
'happy'

如果程序能够处理这些常见的用例,我们的下一步就是考虑一些边缘案例。让我们来考虑一些边缘案例。

假设我们想检查函数是否符合我们的描述,当有多个相同长度的单词时,返回第一个单词。这个测试可能被视为常见案例或边缘案例,取决于你问谁:

>>> longest_word(['cat', 'dog', 'me']) 
'cat'

如果列表中的所有单词都没有字符,我们该怎么办?一个没有字符的字符串被称为空字符串,写作一个空的引号对。如果我们只有一个空字符串的列表,那么最长的单词就是空字符串!因此,包含所有空字符串的测试应该只会返回一个空字符串:

>>> longest_word(['', ''])
''

边缘情况这个术语来源于这样一个事实:错误通常发生在执行的“边缘”,即第一个或最后一个元素。在许多循环中,错误可能发生在循环开始时(例如,忘记或错误处理列表中的第一个元素)或在结束时(例如,忘记最后一个元素或超出列表的末尾,尝试访问一个不存在的元素)。特别是当代码可能有循环处理多个元素时,你需要注意循环开始和结束时的行为。

错误输入测试

另一类测试将测试函数在接收到不正确输入时的响应。我们在本书中不会深入讨论这一点,因为我们假设你会正确调用自己的函数,但在生产代码中,这种测试可能很常见。一些调用函数时传递不正确输入的例子可能是通过使用None而不是实际列表来传递一个不存在的列表(例如,longest_word(None)),给函数一个空列表(例如,longest_word([])),给函数传递一个包含整数的列表作为输入(例如,longest_word([1,2])),或者提供一个包含字符串的列表,但字符串中含有空格或多个单词(例如,longest_word(['hi there', 'my', 'friend']))。很难说当函数接收到不正确的输入时应该做什么,程序员需要决定他们是否关心这一点,但我们在本书中忽略了这一类测试,因为我们假设你会以函数设计所能处理的方式来调用你的函数。

6.2.2 我们怎么知道应该使用哪些测试用例?

在第三章中,我们讨论了好的测试涉及捕获不同类别的函数调用。找到这些类别的一种方法是通过使用参数类型并改变它们的值。

例如,如果函数接受字符串或列表作为参数,那么测试该字符串或列表为空、只有一个元素或有多个元素的情况可能是合理的。如果我们要测试多个元素的情况,可能会使用四个元素。例如,使用五个或六个元素可能没有意义,因为如果我们的代码能处理四个元素,那么在增加到五个时,不太可能突然出错。有时,某些测试用例对于特定函数可能没有意义;例如,要求从一个没有任何单词的列表中找出最长的单词就没有意义,因此我们不会为longest_word函数测试空列表。

另一个例子是,如果一个函数接受两个数字作为参数,那么当一个数字为零、两个数字都为零、一个数字为负数、两个数字都为负数以及两个数字都为正数时,测试是有意义的。

找到类别的另一种方法是考虑函数的具体任务。例如,对于我们的longest_word函数,它应该是找出最长的单词,所以我们应该测试它在典型情况下是否真正执行了这个任务。而且,如果多个单词是最长的,它应该返回第一个这些单词,所以我们应该有一个测试用例,其中列表中有多个最长的单词。

寻找测试类别是科学与艺术的结合。我们在这里给出了一些经验法则,但什么算是有用的测试用例通常取决于测试的具体功能。正如通常情况一样,练习你的测试技能是提高你编写有用测试的能力的最佳方式,最终有助于你改进代码。

6.2.3 开盒测试

开盒测试和闭盒测试之间的最大区别在于,开盒测试会检查代码,以查看是否有其他种类的测试用例需要检查。理论上,闭盒测试可能足以完全测试该函数,但开盒测试通常会给你更多的思路,了解代码可能在哪些地方出错。假设我们要求 Copilot 编写我们的longest_word函数,并且得到了以下列出的代码。

第 6.1 号列表 查找最长单词的函数(错误的!)
def longest_word(words):
 ''' 
 words is a list of words

 return the word from the list with the most characters
 if multiple words are the longest, return the first 
 such word 
 '''
    longest = ""
    for i in range(0,len(words)):
        if len(words[i]) >= len(longest):     #1
            longest = words[i]
    return longest

1 >=是错的。应该是>

对于这个例子,我们故意在代码中引入了一个错误,以帮助解释开盒测试的作用。假设在思考测试用例时,你忘记了测试当words列表中有两个单词都拥有最多字符时会发生什么。那么,阅读这段代码时你可能会发现如下的if语句:

   if len(words[i]) >= len(longest):
        longest = words[i]

阅读if语句时,你可能会注意到,当最新元素的长度大于或等于我们迄今为止见过的最长单词时,它会更新单词列表中的最长单词。这是一个错误;应该是>,而不是>=,但假设你不确定。这会促使你写一个像我们之前描述的那样的测试用例,其中有多个单词,其中一个或多个是最长的:

>>> longest_word(['cat', 'dog', 'me']) 
'cat'

这个测试会在第 6.1 号列表中的代码中失败,因为它会返回'dog',而不是正确的答案'cat'。测试失败是有价值的信息,说明第 6.1 号列表中的代码是错误的。

正如我们所说的,开箱测试很有用,因为它能帮助我们构建符合代码本身结构的测试用例。例如,如果我们的代码使用了一个循环,在进行开箱测试时我们会看到这个循环。列表 6.1 中的循环是正确的,但通过查看代码中的循环,我们会提醒自己测试边界情况,确保它能够正确处理第一个元素、最后一个元素和空列表。总而言之,了解代码如何处理输入,往往能帮助我们洞察程序可能在哪些情况下出错。

6.3 如何测试你的代码

有许多有效的方式来测试你的代码,这些方式从简单的测试开始,可能只是为了检查你的代码是否正常工作,到构建在公司回归测试套件中的测试。对于生产代码,Python 程序员通常使用比我们在本章展示的更强大、更完整的测试工具。最常见的工具是 pytest,它是一个需要安装才能使用的模块。我们认为 pytest 超出了我们在这里介绍测试核心概念的需求。我们将集中在更轻量级的测试上,以帮助你确保 Copilot 的代码能够正常工作。我们可以通过在 Python 提示符中测试或使用 Python 内置的模块 doctest 来实现这一点。

6.3.1 使用 Python 提示符进行测试

测试的第一种方式是通过 Python 提示符,在交互窗口中进行,就像我们在之前的章节中所做的那样。这种测试的优点是它运行迅速,而且你可以根据上一个测试的输出轻松地添加更多的测试。到目前为止,我们运行的测试就是通过 Python 提示符进行的测试。例如,

>>> longest_word(['cat', 'dog', 'me']) 
'cat'

在运行那个测试时,如果你期望结果是 'cat',你会很高兴看到这个结果。然而,如果测试显示你的代码是错误的,那么你现在有机会回去修正它。

当你修复了代码之后,你需要测试新代码。这时候,使用 Python 提示符单独进行测试可能会出错。当你回到刚刚修改过的代码进行测试时,你可能会倾向于仅仅运行之前失败的那个测试用例。然而,在修复代码以正确处理之前失败的测试用例时,你可能引入了一个错误,导致那些已经通过的测试用例现在失败。因此,你真正需要的是一种方法,可以不仅运行当前的测试,还能运行所有之前的测试。

6.3.2 在你的 Python 文件中进行测试(我们不会使用这种方式)

你可能会想将所有测试用例放入你的 Python 程序中(在函数外部,即在类似主函数的位置),以便它们都能运行。这个解决方案解决了我们刚才描述的 Python 提示问题,但也引入了一个新问题。当你希望你的 Python 程序执行其原本设计的主要任务,而不仅仅是运行测试时,会发生什么呢?你可以删除所有测试,但如果希望重新运行它们,那就失去了意义。你也可以将它们注释掉,以便未来可以运行,但这也不是一个很干净的解决方案。那么,我们想要的就是一种方法,能够在需要时运行所有测试,但仍然能够运行程序。做到这一点的方法是使用一个名为 doctest 的模块。

6.3.3 doctest

doctest 模块是 Python 的内置模块。使用 doctest 的好处在于,我们只需将测试用例添加到描述函数的 docstring 中。这个增强版的 docstring 具有双重功能。首先,我们可以随时使用 doctest 运行所有这些测试用例。其次,它有时能帮助 Copilot 生成更好的代码,或者修复已经编写但没有完全工作的代码。让我们编写包含所有测试用例并准备好通过 doctest 执行的longest_word函数(见清单 6.2)。

清单 6.2 使用 doctest 测试longest_word函数
def longest_word(words): 
 ''' 
 words is a list of words

 return the word from the list with the most characters
 if multiple words are the longest, return the first 
 such word 

 >>> longest_word(['cat', 'dog', 'bird']) #1
 **'bird'** #1
 #1
 **>>> longest_word(['happy', 'birthday', 'my', 'cat'])** #1
 **'birthday'** #1

 **>>> longest_word(['happy'])** #1
 **'happy'** #1
 #1
 **>>> longest_word(['cat', 'dog', 'me'])** #1
 **'cat'** #1
 #1
 **>>> longest_word(['', ''])** #1
 **''** #1
 **'''** #1
    longest = ''                             #2
    for i in range(0,len(words)):            #2
        if len(words[i]) > len(longest):     #2
            longest = words[i]               #2
    return longest                           #2
**import doctest                  ** #3
**doctest.testmod(verbose=True)   ** #3

1 显示了 doctest 的测试用例

2 显示了函数的正确代码

3 调用 doctest 进行测试的代码(在主程序中)

在这段代码中,我们看到 docstring 中包含了我们提供的测试用例,作为 Copilot 的提示。Copilot 生成了正确的代码来实现这个函数。然后,我们手动编写了最后两行代码来执行测试。运行后,我们得到了如下清单中的输出。

清单 6.3 从运行清单 6.2 中的程序得到的 doctest 输出
Trying:
    longest_word(['cat', 'dog', 'bird'])  
Expecting:
    'bird'
ok              #1
Trying:
    longest_word(['happy', 'birthday', 'my', 'cat'])
Expecting:
    'birthday'
ok               #2
Trying:
    longest_word(['happy'])
Expecting:
    'happy'
ok             #3
Trying:
    longest_word(['cat', 'dog', 'me'])
Expecting:
    'cat'
ok           #4
Trying:
    longest_word(['', ''])
Expecting:
    ''
ok                   #5
1 items had no tests:          #6
    __main__                   #6
1 items passed all tests:          #7
   5 tests in __main__.longest_word
5 tests in 2 items.
5 passed and 0 failed.     #8
Test passed.

1 longest_word 中的第一个测试通过了

2 longest_word 中的第二个测试通过了

3 longest_word 中的第三个测试通过了

4 longest_word 中的第四个测试通过了

5 longest_word 中的第五个测试通过了

6 主程序中(函数外部)没有测试。

7 longest_word 通过了所有测试。

8 0 失败是你希望看到的结果。

从这个输出中,我们可以看到每个测试都运行并且通过了。之所以这些测试能运行,是因为我们在清单 6.2 中添加的最后两行代码:

import doctest
doctest.testmod(verbose=True)

在第一行,我们导入了 doctest 模块。这个模块帮助我们通过在运行程序时自动运行测试用例来测试代码。在第二行,我们调用了 doctest 模块中的 testmod 函数。这个函数调用告诉 doctest 执行所有的测试;参数 verbose=True 告诉 doctest 给出所有测试的结果,无论它们是否通过。如果我们切换为 verbose=False,它只会在测试用例失败时给出输出(verbose=False 实际上是默认值,所以你可以在不传递任何参数的情况下调用这个函数,它默认不会提供输出,除非一个或多个测试失败)。这个特性非常有用,因为我们可以让 doctest 继续运行,只有在测试失败时才会看到输出。

在这种情况下,我们的代码通过了所有测试用例。但是,让我们体验一下当代码未通过时会发生什么。

如果我们发现一个单词与当前最长的单词长度相同,我们应该忽略它,因为当有多个单词的长度相同且为最长时,我们始终希望返回第一个最长的单词。因此,正确的做法是使用 >if 语句中(只有当找到的新单词确实比当前最长的单词长时才会更新最长单词),而不是 >=

我们可以通过将 > 改为 >= 来破坏清单 6.2 中的代码,这会导致它选择最长长度单词中的最后一个,而不是第一个。让我们将以下行改为:

if len(words[i]) > len(longest):

改为:

if len(words[i]) >= len(longest):

现在,测试不应该全部通过。另外,让我们把最后一行改为:

doctest.testmod()

通过不给 testmod 函数传递任何参数,verbose 现在被设置为 False。当我们运行代码时,输出如下:

**********************************************************************
File "c:\Users\leo\Copilot_book\Chapter6\test_longest_word.py", 
line 12, in __main__.longest_word
Failed example:
    longest_word(['cat', 'dog', 'me'])
Expected:
    'cat'
Got:
    'dog'
**********************************************************************        
1 items had failures:
   1 of   5 in __main__.longest_word
***Test Failed*** 1 failures.

doctest 方便地告诉我们运行了哪些测试、期望的输出是什么,以及函数实际输出了什么。这可以帮助我们捕捉到错误,并返回修复它。

Copilot 不会自动运行测试用例。

我们经常听到这样的问题:为什么 Copilot 在生成代码时不直接结合测试用例?例如,如果我们添加了测试用例,希望 Copilot 能尝试生成函数,并仅提供能够通过这些测试用例的代码。遗憾的是,这样做存在一些技术挑战,截至本文写作时,这个功能尚未包含在内。所以,如果你添加了测试用例,它只会改进对 Copilot 的提示,但不能保证 Copilot 生成的代码能通过这些测试。

到目前为止,我们已经看到如何通过 Python 提示和 doctest 运行测试。现在我们知道如何测试我们的代码了,让我们思考一下这将如何改变我们的代码设计周期。

6.4 再次审视与 Copilot 一起设计函数的周期

在第三章中,我们给出了函数设计的早期版本(见图 3.3)。当时,我们并不像现在这样了解如何检查我们的代码(这一点我们在第 4 和第五章学到了),也不太清楚如何测试代码。因此,让我们创建这个周期的新版本(图 6.1),以反映我们新的理解。

figure

图 6.1 使用 Copilot 的函数设计周期,增加了更多关于测试的内容

这个图比之前稍微复杂一些,但如果我们仔细查看,可以看到大部分原始过程都被保留了。新增或更改的内容包括以下几点:

  • 在编写提示时,我们可以将 doctests 作为初步提示的一部分,帮助 Copilot 生成代码。

  • 在完成了第 4 和第五章的内容后,我们已经具备了良好的能力,能够检查代码是否正常运行,因此现在我们增加了一步来处理当 Copilot 提供的初始代码看起来不对时该怎么办。如果发生这种情况,我们将使用 Ctrl-Enter 来查看 Copilot 的建议,希望能找到解决方案。如果我们找到了这样的解决方案,我们将选择它并继续前进。如果没有找到,我们需要修改我们的提示,以帮助 Copilot 生成更好的建议。

  • 在找到看起来正确的代码后,我们将运行 doctest,查看代码是否通过了我们在提示中包含的 doctests(如果我们没有包含任何,它会自动通过)。如果 doctest 通过,我们可以继续添加测试并检查,直到我们满意代码正常运行。如果 doctest 失败,我们需要弄清楚如何修改提示,以解决失败的测试。一旦提示被修改,希望它能够帮助 Copilot 生成新的代码,从而通过我们提供的测试。

有了这个新的工作流程,我们不仅能更好地判断代码是否正常工作,还能在代码没有正常工作的情况下修复它。在接下来的章节中,我们将为自己提供更多的工具,以便在代码没有正常工作时提供帮助,但现在,让我们通过解决一个新问题并使用我们在这里描述的工作流程来测试 Copilot 提供的解决方案,将这些内容整合起来。

6.5 完整的测试示例

让我们尝试一个问题,帮助学生决定在面对面课堂上应该坐哪里。虽然我们讨论的是在教室中找到空座位,但这个问题与在音乐会大厅、剧院、电影院或办公室布局中寻找空座位的问题是相同的。

假设我们有一个教室布局,表示为一个包含座位排的二维列表。每个座位要么为空格(' '),要么是一个学生('S'),分别表示空座位或已占用座位。我们将要求 Copilot 创建一个函数,确定我们可以在哪一排添加最多学生,同时在此过程中,我们还将设计测试,以检查代码是否正常工作。

6.5.1 找到我们能在一排中增加的最多学生数

我们想弄清楚我们能在教室的一排中最多增加多少新学生。(等价地,我们在找出任意一排中空座位最多的数量。)为此,让我们从一个有些模糊的提示开始,看看 Copilot 的表现如何。

列表 6.4 我们能在一排中增加的最大学生数
def most_students(classroom):
 '''
 classroom is a list of lists
 Each ' ' is an empty seat
 Each 'S' is a student
 How many new students can sit in a row? #1
 **'''**
    max_students = 0
    for row in classroom:
        students = 0
        for seat in row:
            if seat == 'S':
                students += 1
            else:
                students = 0
            if students > max_students:
                max_students = students
    return max_students

1 这是提示中一个有些模糊的部分。

为了这个例子,我们的提示语不太好。“一排可以坐多少新学生?”是给人类的合理起点,但它不够具体,很容易想象 Copilot 在解读我们意思时会感到困惑。Copilot 可能会搞不清楚我们是要计算学生人数还是每排的空座位数量,或者它可能会返回整个教室的空座位总数,而不是某一排的空座位数。事实证明,我们的提示语确实让 Copilot 感到困惑,代码也不正确,但在我们深入代码之前,让我们先考虑应该运行哪些测试用例。我们提出了以下一组测试用例:

  • 一个教室,有一些连续的空座位和一些不连续的空座位,确保它不仅仅是在计算连续空座位的数量

  • 一个没有空座位的教室,确保在这种情况下返回 0

  • 一个教室中有一排空座位,确保所有座位都被计算在内,包括第一和最后一个座位(边缘情况)

  • 一个教室有多排空座位相同数量的座位,确保它只返回其中一个值(而不是可能返回所有这些排的空座位总和)

让我们先添加第一个测试用例,并添加 doctest 代码来运行该测试,如下所示。

列表 6.5 我们能在一排中增加的最大学生数
def most_students(classroom):
 '''
 classroom is a list of lists
 Each ' ' is an empty seat
 Each 'S' is a student

 How many new students can sit in a row?

 >>> most_students([['S', ' ', 'S', 'S', 'S', 'S'], \ #1
 **['S', 'S', 'S', 'S', 'S', 'S'], \** 
 **[' ', 'S', ' ', 'S', ' ', ' ']])** 
 **4**
 **'''**
    max_students = 0
    for row in classroom:
        students = 0
        for seat in row:
            if seat == 'S':
                students += 1
            else:
                students = 0
            if students > max_students:
                max_students = students
    return max_students

**import doctest**
**doctest.testmod(verbose=False)**

1 常见情况的 doctest。如果需要换行,docstring 测试用例中必须使用 \。

当我们运行这段代码时,我们得到了这个输出(我们手动清理了教室座位列表的格式,以便于答案的可读性):

**********************************************************************
Failed example:
    most_students([['S', ' ', 'S', 'S', 'S', 'S'],
                   ['S', 'S', 'S', 'S', 'S', 'S'], 
                   [' ', 'S', ' ', 'S', ' ', ' ']])
Expected:
    4
Got:
    6
**********************************************************************
1 items had failures:
   1 of   1 in __main__.most_students
***Test Failed*** 1 failures.

虽然我们希望代码能够正常工作,但我们很感激第一个测试用例找到了一个错误。空座位最多的排是第三排,有四个空座位。但 Copilot 提供的代码错误地告诉我们答案是六。这个问题很奇怪。即使不读代码,你也可以推测它可能是在计算每排座位数,或者每排可坐的最大学生数。我们的测试用例中第二排已经坐满了学生,因此很难判断。我们能做的是将教室设置为:

 >>> most_students([['S', ' ', 'S', 'S', 'S', 'S'], \ 
 [' ', 'S', 'S', 'S', 'S', 'S'], \ #1
 **[' ', 'S', ' ', 'S', ' ', ' ']])** 
 **4**

1 我们从第二排删除了第一个学生。

因此,第二行现在有五个学生。当我们再次运行代码时,测试又失败了,代码给出的答案是五。看起来这段代码不仅仅告诉我们每行的座位数。它一定是在做一些与学生座位相关的事情。我们的下一步是改进提示词,看看是否能从 Copilot 得到更好的代码,但为了完整性,首先让我们解释一下代码在下面的列表中真正的操作。

列表 6.6 Copilot 错误代码的详细解析
 max_students = 0    #1
    for row in classroom:     #2
        students = 0             #3
        for seat in row:           #4
            if seat == 'S':        #5
                students += 1      #5
            else:                        #6
                students = 0             #6
            if students > max_students:     #7
                max_students = students    
    return max_students

1 从将 max_students 初始化为 0 开始

2 对于课堂中的每一行

3 为这一行初始化学生计数器为 0

4 对每个座位进行处理

5 如果座位上有学生,递增计数器。

6 如果有空座位,重置计数器。

7 检查学生计数器是否大于之前看到的最大值,如果是,则更新最大值。

从代码描述中可以看到每行的操作,但是从宏观上看,这段代码是在统计每行中连续坐着的学生人数。它通过为每行初始化一个计数器为 0,然后在每次看到一个学生坐下时递增计数器。当它看到空座位时,计数器会被重置。内循环结尾的if语句是一个相当标准的方式,用于跟踪之前看到的最大值,在这里,它是跟踪最大连续学生数。这完全不是我们想要的,而我们可怜的提示词部分也得为此负责。然而,关键的一点是,我们的测试让我们知道代码是不正确的。(如果你在阅读代码时自己发现了错误,那也很好!)

6.5.2 改进提示词以寻找更好的解决方案

让我们重写提示词,保留测试用例,看看是否能得到更好的结果,见下面的列表。

列表 6.7 再次尝试找出最多的学生人数
def most_students(classroom):
 '''
 classroom is a list of lists #1
 Each ' ' is an empty seat #1
 Each 'S' is a student #1
 #1
 Return the maximum total number of ' ' characters #1
 **In a given row.** 

 **>>> most_students([['S', ' ', 'S', 'S', 'S', 'S'], \**
 **[' ', 'S', 'S', 'S', 'S', 'S'], \**
 **[' ', 'S', ' ', 'S', ' ', ' ']])**
 **4**
 **'''**
    max_seats = 0
    for row in classroom:
        seats = row.count(' ')      #2
        if seats > max_seats:     #3
            max_seats = seats              #3
    return max_seats

**import doctest**
**doctest.testmod(verbose=False)**

1 改进后的提示词明确要求我们在任何给定的行中寻找最多的 ' ' 字符。

2 count 是一个列表函数,返回列表中某个参数出现的次数。

3 用于跟踪最大座位数的代码

为了得到这个解决方案,我们必须通过 Ctrl-Enter 查找 Copilot 的可能解决方案。现在一些解决方案计算了连续出现的 ' ',而其他的,如 6.7 列表中的那个,成功通过了 doctest。奇怪的是,当我们第一次尝试改进后的提示词时,推荐的解决方案是正确的。这又提醒我们,Copilot 输出的非确定性使得测试变得如此重要。

让我们花点时间看看是什么让第二个提示词比第一个更好。两个提示词都包含了

def most_students(classroom):
 '''
 classroom is a list of lists
 Each ' ' is an empty seat
 Each 'S' is a student

提示词中导致我们收到错误答案的部分是

How many new students can sit in a row?

提示词中得出正确答案的部分是

Return the maximum total number of ' ' characters in a given row. 

你永远无法真正知道像 Copilot 这样的大型语言模型(LLM)为什么会给出它的答案,但我们要记住,它是通过基于给定的单词和训练数据中出现过的单词来做出下一个单词的预测(即,GitHub 上的大量代码)。

第一个提示要求 Copilot 做出一些推理,有些推理它做得很好,有些则不太好。从某种意义上说,提示是在让 Copilot 知道在一个列表的列表中,什么是“行”。幸运的是,这在编程中非常常见,因此它在这里没有问题。

然后,提示要求 Copilot 做出一个基本的逻辑推理,即推断空座位是一个学生可以坐的位置。这里是 Copilot 遇到困难的地方。我们怀疑是因为我们在询问新学生坐在哪一排时,Copilot 没有能够做出推理,意识到“新”学生需要弄清楚你能添加多少学生,换句话说,就是有多少个空座位。相反,Copilot 专注于提示中的“学生 °.°.°. 在一排”部分,并开始计算每一排中的学生。它本来也可以利用函数名(说实话,函数名本可以更好,例如max_empty_seats_per_row)来思考它需要计算最多的学生数。那并不是我们想要的,但我们能理解 Copilot 为什么会犯这个错误。

现在让我们来谈谈为什么在面对我们模糊的第一个提示时,Copilot 决定计算某一行中连续的学生人数。也许计算连续学生是 Copilot 训练数据中更常见的模式。也可能是因为“坐在一排”可以解释为“坐得连续”。或者也许是因为当我们编写这个示例时,我们正在处理另一个版本的问题,该问题要求计算连续的空座位,而 Copilot 记住了那次对话。我们不知道 Copilot 为什么给出这个答案,但我们知道我们的提示太模糊了。

相比之下,我们的第二个提示在几个方面更加具体。首先,它明确要求最大值。其次,它要求计算一排中的空格或空座位的数量。这消除了 Copilot 推断空座位意味着为新学生留位置的需求。我们还使用了“总计”和“给定行”来试图让 Copilot 摆脱它当前的连续值计数方法,但这并没有完全解决问题。因此,我们最终不得不筛选 Copilot 的回答(使用 Ctrl-Enter),这些回答有时在寻找连续的空座位,有时则是在寻找空座位的数量。

6.5.3 测试新解决方案

回到我们的示例,由于新代码通过了当前的测试,让我们再给它做更多的测试,以确保它的行为是正确的。在下一个测试中,我们将检查当任何行中没有空座位时,代码是否能正确返回 0:

    >>> most_students([['S', 'S', 'S'], \
                       ['S', 'S', 'S'], \
                       ['S', 'S', 'S']])
    0

下一个测试将确保代码正确地计算出单行中的所有三个空座位(这里是第二行),以避免出现边缘情况问题(例如,未能计算第一个或最后一个元素)。诚然,从代码中可以看出,count 函数正在被使用,并且由于该函数是 Python 内置的,我们应该相当有信心这个测试会通过。然而,还是更安全进行测试,以确保:

    >>> most_students([['S', 'S', 'S'], \
                       [' ', ' ', ' '], \
                       ['S', 'S', 'S']])
    3

最后一个测试检查 Copilot 是否正确处理两个行中空座位数量相同的情况:

    >>> most_students([[' ', ' ', 'S'], \
                       ['S', ' ', ' '], \
                       ['S', 'S', 'S']])
    2

在添加了这些测试用例后,我们再次运行了完整程序,如下所示,所有测试用例都通过了。

列表 6.8 最大学生人数的完整代码和文档测试
def most_students(classroom):
 '''
 classroom is a list of lists
 Each ' ' is an empty seat
 Each 'S' is a student

 Return the maximum total number of ' ' characters in a 
 given row. 

 >>> most_students([['S', ' ', 'S’, 'S', 'S', 'S'], \
 [' ', 'S', 'S', 'S', 'S', 'S'], \
 [' ', 'S', ' ', 'S', ' ', ' ']])
 4
 >>> most_students([['S', 'S', 'S'], \
 ['S', 'S', 'S'], \
 ['S', 'S', 'S']])
 0
 >>> most_students([['S', 'S', 'S'], \
 [' ', ' ', ' '], \
 ['S', 'S', 'S']])
 3
 >>> most_students([[' ', ' ', 'S'], \
 ['S', ' ', ' '], \
 ['S', 'S', 'S']])
 2
 '''
    max_seats = 0
    for row in classroom:
        seats = row.count(' ')
        if seats > max_seats:
            max_seats = seats
    return max_seats

import doctest
doctest.testmod(verbose=False)

在这个示例中,我们演示了如何编写一个函数来从头到尾解决一个问题。Copilot 给出了错误的答案,部分原因是提示语难以理解。我们发现它给出错误答案是因为代码在我们的第一个测试中失败了。然后,我们改进了提示,并利用前两章学到的代码阅读技巧挑选出一个看起来适合我们需求的解决方案。新代码通过了我们最初的基本测试,因此我们添加了更多的测试用例,以验证代码在更多情况下是否有效。在看到它通过这些额外的测试后,我们有了更多证据证明代码是正确的。此时,我们已经测试了常见情况和边缘情况,因此我们对当前的代码非常有信心。关于测试,这个例子展示了测试如何帮助我们发现错误增强我们对代码功能正确性的信心。

6.6 另一个完整的测试示例:文件测试

在大多数情况下,您可以像我们在前面的示例中一样,通过向文档字符串中添加示例来测试代码。然而,有时测试可能会变得更加具有挑战性。当您需要测试与某种外部输入交互的代码时,就会出现这种情况。一个例子是当我们需要测试与外部网站交互的代码,但这种情况在高级代码中更常见,而不是本书范围内的代码。

本书范围内的一个示例是处理文件。当输入是文件时,如何编写测试用例?Python 确实支持在文档字符串内部进行此类操作,但为了保持与我们已做内容的一致性,我们将不采用这种方式。相反,我们将使用外部文件来测试我们的代码。让我们通过修改第二章中的 NFL 四分卫(QB)示例来看如何做到这一点。

我们可以使用整个文件走一遍示例,但因为我们查询四分卫的信息仅限于文件的前九列,所以我们将去掉文件中其余的列,以使内容更易读。去除剩余列后,表 6.2 展示了文件的前四行。

表 6.2 NFL 数据集简化版的前四行
game_id player_id position player team pass_cmp pass_att pass_yds pass_td
201909050chi RodgAa00 QB Aaron Rodgers GNB 18 30 203 1
201909050chi JoneAa00 RB Aaron Jones GNB 0 0 0 0
201909050chi ValdMa00 WR Marquez Valdes-Scantling GNB 0 0 0 0

我们假设在接下来的例子中,数据集的每一行仅包含这九列,但我们希望你能理解如何处理完整数据集(你只需要在每种情况下添加所有其他列)。

假设我们想要编写一个函数,该函数接受数据集的文件名和球员的名字作为输入,然后输出该球员在数据集中所达到的总传球码数。我们假设用户提供的数据格式与第二章和表 6.2 中的 NFL 进攻统计文件一致。在编写提示或函数之前,我们该如何进行测试呢?我们有几种选择:

  • 在更大的数据集中寻找测试——一种解决方案是将完整的数据集和多个球员名字作为输入传递给函数。挑战在于确定我们是否正确。我们可以使用 Google Sheets 或 Microsoft Excel 等软件打开文件,并利用电子表格的功能为每个球员找出答案。例如,我们可以将文件作为表格在 Excel 中打开,按球员排序,找到某个球员,并使用 Excel 中的求和函数计算该球员的所有传球码数。这并不是一个坏的解决方案,但它也需要不少工作,如果你花足够的时间去寻找测试答案,你可能已经完成了需求,而不再需要 Python 代码了!换句话说,弄清楚测试案例的答案,可能就已经得到了你最初想要的答案,这样代码就不那么有价值了。另一个问题是,如何找到所有你想测试的边界情况:你的数据集是否包含所有你想要测试的边界情况,以便编写一个在其他数据集上也能运行的程序呢?这种方法的另一个缺点是,当函数执行的操作比简单地在多行中求和复杂得多时,如何处理这种情况:在这种情况下,弄清楚某些真实测试值的答案可能需要大量的工作。

  • 创建人工数据集进行测试——另一种解决方案是创建人工数据集,在这些数据集中你知道多种可能查询的答案。因为数据集是人工创建的,你可以加入边界情况,看看代码在这些情况下如何表现,而不必在真实数据集中寻找这种罕见的例子。(有时,真实数据集中可能没有这些边界情况,但你仍然希望进行测试,以便当数据集更新或新增时,代码能够正常运行。)

鉴于在人工数据集上创建测试用例的优势,我们将在这里采用这种方法。

6.6.1 我们应该运行哪些测试?

让我们思考一下我们希望测试的常见情况和边缘情况。对于常见情况,我们希望有几个测试:

  • 一个球员在数据集中多次出现(非连续),包括最后一行。 这个测试确保代码在返回结果之前迭代所有球员(即,不做出数据按球员名字排序的错误假设)。

  • 一个球员出现在数据集的连续行中。 这个测试确保没有出现某种错误,其中连续的值被跳过。

  • 某球员在数据集中仅出现一次。 这个测试确保即使只是对一个值求和时,结果也能正常处理。

  • 数据集中可能出现非四分卫球员。 对此,我们确保代码包含所有球员,而不仅仅是四分卫。

  • 某球员在一场比赛中传球码数为 0。 这个测试确保代码在球员没有传球码数时正确运行。因为球员可能因伤缺席比赛,所以这是一个常见的测试情况。

对于边缘情况,我们希望再测试几个内容:

  • 该球员不在数据集中。 这实际上非常有趣:我们希望代码在这种情况下做什么?一个合理的答案是返回他们传球了 0 码。如果我们询问数据集关于勒布朗·詹姆斯(篮球运动员,而非美式足球运动员)在 2019 到 2022 年间在 NFL 的传球码数,0 是正确答案。然而,这可能不是生产代码中最优雅的解决方案。例如,如果我们询问关于亚伦·罗杰斯(拼写错误为 Aron Rodgers)的传球码数,我们宁愿让代码告诉我们他不在数据集中,而不是说他传了 0 码,这在他在此期间两次获得联盟 MVP 的情况下可能会非常混淆我们。为了表示该名字缺失,我们可能会返回一个大负值(例如,-9999),或者我们可能会使用异常处理,但这些超出了本书的讨论范围。

  • 一个球员在所有比赛中的总码数为负,或者某个球员在单场比赛中的码数为负,以确保代码正确处理负值。 如果你不熟悉美式足球,这种情况可能发生在球员接到球并被擒抱在起始点(进攻线)后面。四分卫在整场比赛中的传球码数为负的情况很少见,但如果他们传出一次负码数的传球,并且在同一时刻受伤,导致他们之后无法继续比赛,那么这种情况是有可能发生的。

现在我们已经对测试内容有了大致的了解,接下来让我们构建一个人工文件来捕捉这些测试用例。我们本可以将这些测试分布在多个文件中,这也是一个合理的选择,但将它们放在一个文件中的优点是,我们可以将所有测试用例保存在一起。表 6.3 是我们构建并保存为 test_file.csv 的内容。

表 6.3 我们的文件来测试 NFL 传球码数功能
game_id player_id position player team pass_cmp pass_att pass_yds pass_td
201909050chi RodgAa00 QB 亚伦·罗杰斯 GNB 20 30 200 1
201909080crd JohnKe06 RB 凯里昂·约翰逊 DET 1 1 5 0
201909080crd PortLe00 QB 莱奥·波特 UCSD 0 1 0 0
201909080car GoffJa00 QB 贾里德·高夫 LAR 20 25 200 1
201909050chi RodgAa00 QB 亚伦·罗杰斯 GNB 10 15 150 1
201909050chi RodgAa00 QB 亚伦·罗杰斯 GNB 25 35 300 1
201909080car GoffJa00 QB 贾里德·高夫 LAR 1 1 –10 0
201909080crd ZingDa00 QB 丹·津加罗 UT 1 1 –10 0
201909050chi RodgAa00 QB 亚伦·罗杰斯 GNB 15 25 150 0

请注意,这里的数据完全是人工构造的。(这些不是任何球员的真实统计数据,你可以从丹和莱奥现在变成 NFL 四分卫这一点看出来。)我们确实保留了部分真实球员的名字,以及来自原始数据集的真实game_idplayer_id。通常来说,尽量让你的人工数据尽可能接近真实数据,这样测试更为真实,更有可能代表实际数据的情况。

让我们看看如何在这个测试文件中融入所有的测试用例(表 6.3)。亚伦·罗杰斯在文件中出现多次,包括连续出现和非连续出现,也作为最后一条记录出现。贾里德·高夫多次出现,我们为他在一场比赛中人工设置了–10 码(作为一名顶级 NFL 四分卫,我希望他能接受我们给他设置一个不太理想的单场比赛数据)。我们保留了凯里昂·约翰逊作为跑卫(RB)并从真实数据集中给了他 5 码的传球码数,以确保解决方案不会仅仅过滤四分卫。凯里昂·约翰逊在数据中只有一条记录。我们将莱奥·波特添加到数据集中,并为他设置了 0 码传球码数(他确信自己会尽一切努力避免被 NFL 球员擒抱)。我们还添加了丹·津加罗并给了他一次成功的传球,但为–10 码,覆盖了单个球员的总数据为负的情况。表 6.4 展示了当我们针对每个球员运行查询时,它应该返回的结果。

表 6.4 测试用例中每个球员的传球码数
Player Passing yards in the test case
亚伦·罗杰斯 800
凯里昂·约翰逊 5
莱奥·波特 0
贾里德·高夫 190
丹·津加罗 –10

6.6.2 创建函数

我们先从函数名开始,在文档字符串中描述问题。这个问题可能足够复杂,我们可能需要在函数中提供提示,帮助它打开文件、处理数据并关闭文件,但让我们先试着描述函数,看看 Copilot 如何处理。

列表 6.9 查找每个球员总传球码数的函数
def tot_pass_yds_player(input_file, player):     #1
 **"""**
 **input_file is a string that is the name of a file**
 **player is the name of a player**

 **The file is a csv file with a header row**
 **Column 4 is the player's name and column**
 **8 is the number of passing yards for that player**

 **return the total number of passing yards for the player**
 **"""**
    import csv
    with open(input_file, 'r') as csvfile:
        reader = csv.reader(csvfile)
        next(reader)
        total = 0
        for row in reader:
            if row[3] == player:
                total += int(row[7])
        return total

1 我们简化了函数名。

在查看生成的代码之前,我们想指出的是,我们没有为这个示例包含测试用例,因为它们可能对 Copilot 没有帮助。如果你的测试用例只是打开并使用人工文件,我们建议你在添加测试用例之前让 Copilot 生成代码。每个测试用例将只是一个文件名、一个球员和预期的输出。Copilot 可能无法读取与该文件名对应的文件,也无法理解我们是在对第八列求和以获得期望的结果。这意味着,给 Copilot 提供测试用例不会增加提示的质量。事实上,我们数据的人工性质甚至可能让它感到困惑(例如,现实中的 NFL 球员什么时候会有负的传球码数?)。

现在看看 Copilot 给我们的代码,我们可以看到它非常合理,几乎和我们在第二章看到的代码完全相同。既然我们阅读后觉得它合理,让我们看看如何测试它。

6.6.3 测试函数

为了测试这个函数,我们将增加文档字符串,包含完整代码的测试用例,如下所示。

列表 6.10 测试查找球员传球码数的函数
def tot_pass_yds_player(input_file, player):
 """
 input_file is a string that is the name of a file
 player is the name of a player

 The file is a csv file with a header row
 Column 4 is the player's name and column
 8 is the number of passing yards for that player

 return the total number of passing yards for the player

 >>> tot_pass_yds_player('test_file.csv’, 'Aaron Rodgers')
 800
 >>> tot_pass_yds_player('test_file.csv', 'Kerryon Johnson')
 5
 >>> tot_pass_yds_player('test_file.csv', 'Leo Porter')
 0
 >>> tot_pass_yds_player('test_file.csv', 'Jared Goff')
 190
 >>> tot_pass_yds_player('test_file.csv', 'Dan Zingaro')
 -10
 >>> tot_pass_yds_player('test_file.csv', 'Tom Brady')
 0 #1
    """
    import csv
    with open(input_file, 'r') as csvfile:
        reader = csv.reader(csvfile)
        next(reader)
        total = 0
        for row in reader:
            if row[3] == player:
                total += int(row[7])
        return total

import doctest
doctest.testmod(verbose=False)

1 针对数据集中没有该球员的额外测试用例

我们运行了这段代码,所有的测试用例都通过了。(我们知道所有的测试用例都通过了,因为 doctest 没有输出。)现在我们有了额外的证据,证明代码正常运行!

6.6.4 doctest 的常见挑战

让我们重写之前的提示,并在第一个测试中添加一个非常微妙的错误,如下所示。

列表 6.11 doctest 中的错误
def tot_pass_yds_player(input_file, player):
 """
 input_file is a string that is the name of a file
 player is the name of a player
 The file is a csv file with a header row
 The 4th Column is the player's name and the 8th column
 is the number of passing yards for that player

 return the total number of passing yards for the player

 >>> tot_pass_yds_player('test_file.csv', 'Aaron Rodgers')
 800 #1
 **>>> tot_pass_yds_player('test_file.csv', 'Kerryon Johnson')**
 **5**
 **>>> tot_pass_yds_player('test_file.csv', 'Leo Porter')**
 **0**
 **>>> tot_pass_yds_player('test_file.csv', 'Jared Goff')**
 **190**
 **>>> tot_pass_yds_player('test_file.csv', 'Dan Zingaro')**
 **-10**
 **>>> tot_pass_yds_player('test_file.csv', 'Tom Brady')**
 **0** 
    """
    import csv
    with open(input_file, 'r') as csvfile:
        reader = csv.reader(csvfile)
        next(reader)
        total = 0
        for row in reader:
            if row[3] == player:
                total += int(row[7])
        return total

import doctest
doctest.testmod(verbose=False)

1 800 后面有一个不可见的空格。

当我们运行这段代码时,出现了以下错误:

Failed example:
    tot_pass_yds_player('test_file.csv', 'Aaron Rodgers')
Expected:
    800 
Got:
    800

初看起来,这似乎很奇怪。测试用例期望得到 800,但得到了 800,却告诉我们失败了。实际上,事实是我们在编写测试用例时犯了个错误,写成了“800 ”(后面有空格)而不是“800”。这个错误导致 Python 认为空格很重要,从而导致测试失败。坏消息是,这是使用 doctest 时一个非常常见的问题!我们犯这个错误的次数比我们愿意承认的要多。好消息是,只需要找到并删除空格,就能轻松修复。如果一个测试失败了,但 doctest 的输出提示它应该通过,请始终检查输出的行末是否有空格,或者输出中任何地方的多余或缺失的空格,看看与 doctest 的预期是否完全一致。

鉴于我们的所有测试用例都通过了,我们可以有信心返回到更大的数据集并使用我们刚刚创建的函数。这个例子中的关键点是,我们可以且应该创建人工文件来测试与文件打交道的函数。再次强调,测试是为了确保代码正常运行,你要确保测试你写的或由 Copilot 提供的任何代码。

在本章中,我们学习了测试代码的重要性、如何测试代码以及如何通过两个详细的示例进行测试。在我们的示例中,我们编写并测试了函数。那么,我们如何决定应该编写哪些函数来解决更大的问题呢?好吧,我们通过一个被称为问题分解的过程来解决这个问题,我们将在下一章详细介绍这个过程。

6.7 练习

  1. 给定以下场景,确定是闭盒测试还是开盒测试更为合适,并解释为什么:

    1. 测试人员收到一个函数规格说明,需要确保该函数在不查看实现的情况下正确执行。

    2. 测试人员需要通过编写测试,特别是针对通过理解代码实现而揭示的边缘情况,来调试一个函数。

  2. 这里有一个包含一些测试用例的函数。识别以下哪些测试用例是常见用例,哪些是边缘用例:

def calculate_average(numbers):
    if not numbers:
        return 0
    return sum(numbers) / len(numbers)
# Test cases:
calculate_average([1, 2, 3, 4, 5])
calculate_average([10, 20, 30])
calculate_average([])
calculate_average([-1, -2, -3, -4, -5])
calculate_average([1000000, 2000000, 3000000])
calculate_average([3.5, 4.5, 5.5])
calculate_average([1, 2, "a", 4])
calculate_average([0])
  1. 3. 我们正在构建一个分析网站流量的程序。网站流量表示为一个字典列表。每个字典有两个键:"weekday"(表示星期几的字符串)和"visitors"(表示该天访问者数量的整数)。同一个星期几可以出现在多个字典中。我们的目标是找出访问人数最多的星期几。

这是我们给 AI 代码生成工具的初始提示:

def busiest_day(traffic_data): # traffic_data 是一个字典列表。# 找出最繁忙的日期。

工具生成了以下代码,但似乎不太正确。

def busiest_day(traffic_data):
  most_visitors = 0
  busiest_day = None
  for day in traffic_data:
    if day["visitors"] > most_visitors:
      most_visitors = day["visitors"]
      busiest_day = day["weekday"]
  return busiest_day

你能解释为什么以及如何改进提示,以获得所需的功能吗?

  1. 4. 假设你有一个函数(find_highest_grossing_book(filename)),它分析书籍销售数据并返回总收入最高的书籍信息。销售数据存储在一个 CSV 文件中,每一行代表一次销售。CSV 文件的列如下:

    1. title—书籍的标题(字符串)

    2. author—书籍的作者(字符串)

    3. price—书籍的价格(浮动数值)

    4. quantity—该销售的销售数量(整数)

创建一个包含find_highest_grossing_book函数的 Python 脚本(此处不提供实现)。包含一个说明函数目的的文档字符串,并使用 doctest 模块添加测试用例。

  1. 5. 检查提供的is_palindrome函数的测试用例。找出哪些测试用例是错误的,并解释原因。提供正确版本的测试用例。
def is_palindrome(s):
    """
    Return True if string s reads the same forward and backward.
    >>> is_palindrome("racecar")
    True
    >>> is_palindrome("hello")
    False
    >>> is_palindrome("A man, a plan, a canal, Panama")
    True
    >>> is_palindrome("")
    True
    >>> is_palindrome("a")
    True
    >>> is_palindrome("Noon")
    True
    """
    cleaned = ''.join(char.lower() for char in s if char.isalnum())
    return cleaned == cleaned[::-1]
  1. 6. 分析 find_max 函数的测试覆盖率。现有的测试用例是否覆盖了所有场景?如有必要,请提出额外的测试用例。
def find_max(numbers):
    """ 
    Returns the maximum number from a list.
    >>> find_max([1, 2, 3, 4, 5])
    5
    >>> find_max([-1, -2, -3, -4, -5])
    -1 
    >>> find_max([42])
    42
    >>> find_max([])
    None
    """ 
    if not numbers:
        return None
    max_number = numbers[0]
    for num in numbers:
        if num > max_number:
            max_number = num
    return max_number

总结

  • 测试是在使用 Copilot 编写软件时一项关键技能。

  • 封闭盒测试和开放盒测试是确保代码正确性的不同方法。在封闭盒测试中,我们根据已知的关于问题的信息来设计测试用例;而在开放盒测试中,我们还会额外检查代码本身。

  • Doctest 是 Python 自带的一个模块,帮助我们测试代码。

  • 使用 doctest 时,我们将测试用例添加到函数的文档字符串描述中。

  • 创建人工文件是测试使用文件的代码的一种有效方法。**

第七章:7 问题分解

本章内容涵盖

  • 理解问题分解以及我们为什么需要这样做

  • 使用自顶向下设计来进行问题分解并编写程序

  • 使用自顶向下设计编写拼写建议程序

在第三章中,我们讨论了为什么不应该让 Copilot 解决大问题。试想一下,如果我们让 Copilot“设计一个双人策略游戏”,会发生什么情况。

在最坏的情况下,Copilot 可能什么有用的事都做不了。我们有时会看到这种情况,Copilot 反复给我们评论,却从不提供真正的代码。

在最佳情况下,我们会得到一个现成的程序,所有决策都已经为我们做出。这个程序可能与我们想要的并不完全匹配。作为程序员的一个强大之处在于定制我们所创建的内容。即使我们不想定制任何东西,如果 Copilot 给出的程序有缺陷,我们该怎么办呢?如果程序来自 Copilot 并且我们不理解它,修复一个大程序将会非常困难。

为了让我们得到一个符合要求的程序,我们需要将小的子问题交给 Copilot,并将这些解决方案组合成我们自己的程序。本章的重点是学习如何将大问题分解成更小的子问题,这对于解决我们想解决的大问题至关重要。

7.1 问题分解

问题分解包括从一个可能没有完全指定的大问题开始,并将其分解为子问题,每个子问题都被明确定义并有助于解决我们的整体问题。我们的目标是为每个子问题编写一个函数来解决。对于某些子问题,我们可能能够通过适量的代码来解决,但其他子问题可能仍然太大,无法在合理大小的函数中捕获。(在第三章中,我们提到我们希望保持函数简短——大约 12 到 20 行——这样才能有最佳的机会从 Copilot 获得优质的代码,测试这些代码,并在必要时修复代码中的错误。)如果某个子问题仍然太大,无法在一个函数中实现,那么我们会进一步将该子问题分解成自己的子子问题。希望这些子子问题现在已经足够小,但如果没有,我们会继续分解它们!我们这么做的关键原因是为了管理复杂性。每个函数应该足够简单,以便我们能理解它的目的,并且 Copilot 能够很好地解决它。如果出现问题,我们也不会有太多的代码需要翻找来找到问题。当我们编写极其复杂的代码时,我们很可能会犯错误。Copilot 也是如此!而且,当我们在冗长复杂的代码中出现错误时,问题并不容易找到和修复。问题分解对于高质量代码的重要性无法过分强调。

从一个大问题开始,并将其分解成更小的任务的过程叫做问题分解。我们在这里采用的这种方式与软件工程技术中的自顶向下设计是同义的。之所以称之为自顶向下设计,是因为我们从想要完成的大任务开始,然后将其分解为更小的任务。完成自顶向下设计后,我们可以在代码中实现这些函数。我们将有一个针对整体问题的函数,它将调用针对每个子问题的函数。每个子问题函数会根据需要进一步调用它们自己的函数,以解决任何子子问题,依此类推。

正如我们在第三章中讨论的那样,我们希望最终得到的函数在我们的整体程序中扮演一个小角色,并且其行为是明确定义的。我们需要这些函数,以便可以调用它们来减少本来会过于复杂的函数的复杂性。为了提高清晰度和易理解性,我们寻求设计具有少量参数并返回少量高效有用信息的函数。

7.2 自顶向下设计的小示例

我们很快就会深入探讨一个更真实的自顶向下设计如何工作的例子,但我们首先想用我们之前的一些例子来铺垫。让我们回顾一下我们在第三章中写的一个函数的设计:get_strong_password。这个函数会反复提示用户输入密码,直到他们输入一个强密码为止。不要去回头看那段代码——我们希望从头开始。

假设我们想用自顶向下设计来解决这个任务。如果这是一个小而明确的任务,我们可以直接实现为一个单一的函数。然而,对于这个任务,我们确实看到有一个子任务;即,什么是强密码?关于强密码有什么规则?对我们来说,这听起来像是一个可以从这个函数中拆分出来的子任务,从而简化它。事实上,在第三章当我们写这个函数时,我们确实调用了我们之前写的is_strong_password函数,该函数负责判断一个密码是否强大。

我们可以如图 7.1 所示描绘这种自顶向下的设计。为了便于展示本章后面会变得很大的图形,我们将始终从左到右展示设计,而不是从上到下,但相同的基本原理依然适用。

figure

图 7.1 get_strong_password函数的函数图。get_strong_password调用is_strong_password

这张图表明我们的目标是让get_strong_password调用is_strong_password来完成它的一部分工作。

现在,回到我们的第二个示例,回想一下第三章,我们也写了一个best_word函数,它接受一个单词列表作为参数,并返回得分最高的单词。同样,别回去看那段代码——我们想在这里重新想一想它的实现。让我们思考一下这个任务的代码可能是什么样子的。它可能会使用一个循环来考虑每个单词,在这个循环中,它需要跟踪到目前为止我们看到的最好单词。对于每个单词,我们需要通过加总每个字母的分数来计算它的得分。记住,a值 1 分,b值 3 分,c值 3 分,d值 2 分,e值 1 分,依此类推。

哇,慢点!我们真的在深入探讨这个“每个字母值多少分”的问题。这听起来像是一个子任务。如果我们有一个函数,可以告诉我们每个单词值多少分,那么在best_word函数中就不需要担心这个分数问题了。在第三章中,我们写了一个叫num_points的函数,正好完成这个子任务:接受一个单词作为参数并返回它的总得分。我们可以从best_word中调用num_points,如图 7.2 所示。这样,best_word的任务就变得更容易了。

figure

图 7.2 best_word的函数图

在第三章中,我们恰好是从子任务写到任务,从叶子函数写到父函数。本章我们会继续这样做,但我们会先进行自顶向下的设计,以弄清楚我们需要哪些函数。

我们刚才谈到的这两个来自第三章的示例比较小,实际上你可能能通过一个函数快速写出它们的代码。但对于更大的示例,问题分解是唯一能控制复杂度的办法。

接下来我们将深入探讨一个更大的自顶向下设计示例。我们希望你从这一章中学到的关键技能是如何将一个大问题拆解成更小的子问题。我们鼓励你多次阅读接下来的示例。第一次阅读时,目标是从高层次理解各个部分如何结合起来解决整体问题。第二次阅读时,可以更深入地了解每个函数如何单独工作。

7.3 拼写建议

在本章剩下的部分,我们将从头到尾使用自顶向下的设计解决一个问题。我们希望你在自己使用这种方法解决问题时能够成功,所以你会在本书的其余部分看到自顶向下设计的身影。

哎呀——我们这里有个拼写错误吗?Sucessful?应该是successful。英语单词有时真的很难拼写!你可能已经遇到过许多这样的单词。是thorough还是thourough?是acceptable还是acceptible?是receive还是recieve?我们要编写一个程序,接受一个可能拼错的单词,并提供可能的修正建议。这是一个基本的单词拼写检查程序!

我们该如何提出这些可能的修正建议呢?好吧,让我们看看刚才提供的单词对,并讨论它们所示范的错误类型。

我们在sucessful这个单词中的错误是漏掉了一个字母——我们需要在其中添加一个c,才能得到正确的单词successful。因此,通常来说,考虑向拼错的单词中添加字母似乎是一个好主意,因为这可能正是修正它所需要的。我们将考虑在任何可能的位置添加任何可能的字母,而不仅仅是重复已有的字母。这将帮助我们修正诸如acknowlege(缺少d)这样的拼写错误。

还有其他类型的错误我们也需要考虑。例如,thourough中的错误不是缺少一个字母,而是我们多了一个字母——我们需要删除第一个u,变成thorough。因此,我们将考虑从拼错的单词中删除任何单个字母,看看是否能修正它。

我们还能修正什么其他的错误呢?比如那个拼错的单词acceptible。那是一种新的错误类型:没有缺失字母,也没有多余的字母,但有一个字母需要改为另一个字母。也就是说,如果我们将i改为a,就能得到正确拼写的单词acceptable。为此,我们可以尝试将每个字母替换为字母表中的另一个字母,看看是否能修正拼写错误。

除了我们刚才提到的三种方法,还有很多修正拼写错误的方式。但是我们在这里停下来,因为我们认为修正这三种错误已经足够展示自顶向下设计的目的。这意味着我们的程序将无法修正拼错的单词recievereceive,因为我们不会修正两个字母顺序错误的问题。我们也无法修正拼错的单词camoflague为 camouflage,因为我们只会修正一个拼写错误(camoflague有两个不同的错误,一个字母缺失u,另一个字母多了u)。当你完成这一章后,如果你希望深入学习,我们鼓励你继续改进你的程序,了解更多关于修正拼写错误的方法。

7.4 使用自顶向下设计进行拼写建议

我们的任务是“编写一个程序,接受一个可能拼写错误的单词,并提供可能的纠正建议。”这是一个很大的任务,我们肯定不希望将其塞入一个单一的函数中。

许多程序——无论是分析数据、提供拼写建议,还是猜测未知文本的作者——都有三个不同的任务要执行。首先是输入任务:我们需要获取程序运行所需的数据。其次是处理任务,程序对数据执行其应做的操作。现在,处理数据是好事,但如果用户不知道程序通过处理得出的结果,那它就没什么用。这就是第三步,输出步骤的作用,它是我们与用户沟通的地方。表 7.1 总结了这个过程。

表 7.1 输入、处理数据和输出任务的总结
阶段 角色 拼写建议示例
输入 获取函数所需的输入信息。 提供拼写错误的单词sucessful和一组正确拼写的单词。
处理数据 对这些数据执行函数指定的操作。 考虑对该单词进行更改,可能会得到正确拼写的单词,例如,在sucessfulsu后面或前面添加字母c,将得到正确的单词successful。许多其他错误单词(例如,通过在u前添加字母c而得到的scucessful)也可能被尝试,但结果中应仅包含真实单词。
输出 返回数据处理的结果。 返回建议“successful”。

你可以在第二章的数据处理示例中看到这个输入-处理-输出模型的应用。我们需要从 CSV 文件中读取数据(那是输入步骤),确定每个四分卫的传球码数(那是处理步骤),然后输出四分卫及其传球码数(那是输出步骤)。

我们可以用类似的方式来思考我们的拼写建议问题。这里的神奇之处在于,输入-处理-输出模型恰好为我们提供了在自顶向下设计中需要解决的三个子问题。我们所说的意思是(也参见表 7.1 中的示例):

  • 对于输入步骤,我们需要询问用户他们希望获取拼写建议的单词。

  • 对于处理步骤,我们需要弄清楚用户单词的所有可能建议。

  • 对于输出步骤,我们需要告诉用户在处理步骤中找到的所有拼写建议。

请注意,我们从一个大问题开始解决(总体拼写建议问题),现在我们有了三个更小的问题需要解决。我们的主函数或顶级函数最终会调用从这个问题分解中得到的任何函数。我们将把这个主函数命名为spell_check

通常,但并非总是如此,我们需要为每个识别出的子问题拆分出一个单独的函数。再看看输入步骤。我们需要让用户输入一个单词。虽然我们可以为这个子任务拆分出一个单独的函数,但那样就过头了。为什么?因为 Python 已经有一个内置的函数用来向用户请求输入!这个函数叫做 input,我们在第三章的第 3.3.7 节中使用过它,那个时候我们让用户输入密码。

我们需要为输出步骤拆分一个单独的函数吗?再次不需要!输出步骤只是将内容输出给用户。我们知道可以使用 Python 已有的 print 函数来做到这一点。同样,拆分出一个函数来处理这个步骤并不会是错误的,如果你自己进行问题分解时可能会这么做。但你会注意到,这个函数会非常简短,几乎只包含对 print 的调用——到那时你可能会再次考虑是否真的需要将其拆分为单独的函数。

相比之下,处理步骤将涉及更多的工作。找出所有可能的拼写建议需要做大量的工作!我们需要支持删除一个字母、插入一个字母、将一个字母更改为另一个字母等等,这些都太复杂,无法全部放在我们的主 spell_check 函数中。我们需要为处理步骤单独拆分一个函数。接下来我们就要开始着手处理这个问题。

7.5 分解处理子问题

我们需要为实现处理步骤的函数起个名字。我们将其命名为 get_spelling_suggestions,因为它将负责返回用户输入的拼写建议。它肯定需要将用户拼写错误的单词作为参数传入,否则就无法访问它!

但在这里稍作停顿:你认为这个函数需要任何额外的参数吗?答案是:需要!以某种方式,这个函数需要知道哪些字符串是真正的英文单词。例如,它必须知道单词 successfulthoroughacceptable 和成千上万的其他英文单词。我们可以通过几种方式来实现这一点:我们可以将一个包含所有真实单词的列表或(Python)字典作为参数传递,或者我们可以将包含所有真实单词的文件名作为参数传递。当你设计函数时,你需要做出类似的决策,专注于函数需要的输入以及完成任务后需要的返回值。

除了拼写错误的单词外,我们还将让函数接受一个参数,传入包含有效单词列表的文件名。这个文件中的每一行包含一个有效单词。在本书的资源中,我们包含了一个名为 wordlist.txt 的示例单词列表文件,供你使用。(我们通过简单的互联网搜索找到了一份免费的词典单词列表。)

我们在这个过程步骤中需要做什么?我们可以考虑四个子任务。这就是更多的问提分解!这些子任务如下:

  • 从单词列表文件中获取单词列表。单词文件是一个不错的起点,但将单词放在 Python 列表中会更加方便。这样,我们就可以轻松地判断一个字符串是否是有效单词。我们将这个函数命名为create_word_list

  • 从用户的字符串生成所有可能的单词列表。我们需要删除一个字母、插入一个字母,或者将一个字母更换成另一个字母。这将生成许多字符串,其中一些是实际的单词,而另一些则不是。例如,从字符串sucessful中,我们会生成实际单词successful,但也会生成xsuccesfulsucxcesfulsuccesfrl等字符串,这些显然不是实际单词。但没关系。现在我们只是想生成每一个可能的单词,以免错过任何一个。我们将这个函数命名为all_possible_words

  • 使用所有可能单词的列表,生成仅包含实际单词的列表。这是我们将潜在单词的完整列表筛选为那些实际存在的英语单词的步骤。我们将这个函数命名为all_real_words

  • 返回唯一单词的列表。为什么是唯一的?我们不希望返回相同的拼写建议两次,尽管可能有两种或更多方法从用户的字符串得出该拼写建议。例如,要修正单词sucessful,有两种方法:我们可以在已经存在的c前面再加一个c,或者我们可以在e前面加上c。这两种方法都会得到正确拼写的单词successful,但我们只希望保留一个。

如果我们将最终的子任务——获取唯一单词的列表——分解成一个独立的函数,我们可能会将其命名为unique_wordsonly_unique_words。虽然我们可以将这个子任务分解开,你也完全有理由这么做,但我们决定将其保留在get_spelling_suggestions函数中。原因是,在 Python 中,从列表中去除重复项实际上只需要一行代码。为了方便起见,我们现在告诉你这一点,但如果你自己进行自顶向下设计,这也是一个完全合理的子任务。

多种方式分解问题

进行自顶向下设计没有唯一正确的方式,问题分解成子问题也没有唯一正确的方式。例如,你可能会对我们首先生成所有可能的单词(包括那些并非真正英语单词的伪单词)并在此基础上筛选出实际的英语单词感到惊讶。为什么不先检查每个可能的单词,只有在它是真正的单词时才将其添加到列表中呢?我们当然也可以这样做!如果你有这种替代的分解方式,或者我们激发了你的好奇心,我们鼓励你在完成本章内容后,自己尝试这种替代设计。

尝试自上而下的设计,然后在设计无法奏效时放弃,这并不丢人。那么,设计无法奏效是什么意思呢?嗯,也许你发现很难将大型函数拆分为明确的子问题。或者,也许你得到了几十个小函数,开始担心这些函数是否过于具体,不能解决一般问题。或者,也许你需要传递很多参数,其中一些参数可能只是为了继续传递下去,直到最终一个函数需要它们。或者,也许你只是想试试另一种自上而下的设计,看看效果如何!

我们鼓励在设计阶段进行实验,在最终确定代码的设计之前尝试不同的函数和不同的输入/输出。例如,之前我们讨论过get_spelling_suggestions函数应该接受一个包含真实单词的文件名,还是一个包含真实单词的列表(或字典)。在设计阶段,考虑这两种选择都是值得的。

这就剩下我们需要解决的三个子任务了。我们将在稍后对这些进行自上而下设计。但首先,让我们看看目前的进展如何(见图 7.3)。

figure

图 7.3 函数图,展示了get_spelling_suggestions的三个子任务

7.5.1 从词汇表文件中获取单词列表

这个任务的函数是create_word_list。它将接受一个参数,即词汇表文件的名称,并返回该文件中的单词列表。

我们是否需要对这个函数进行进一步的自上而下设计?让我们假设我们认为答案是肯定的。我们可以拆分出哪些子任务?我们可以考虑打开文件、读取内容和关闭文件的任务。但是,正如我们在第五章第 5.1.4 节学到的,打开文件只是通过调用 Python 的open函数来完成的。同样,关闭文件也是通过调用 Python 的close函数来完成的。那么,如何从文件中读取单词呢?这听起来和第五章第 5.1.4 节中读取 CSV 文件的行没有太大区别。所以我们认为没有必要进一步拆分这个函数的子任务。

7.5.2 生成所有可能单词的列表

这个任务的函数是all_possible_words,它将接受一个参数,给出我们希望提供拼写建议的字符串。它将返回所有可能的单词列表,这些单词可以通过添加一个字母、删除一个字母或更改一个字母得到。

添加一个字母、删除一个字母和更改一个字母是三种不同类型的任务。而且,它们看起来并不是特别简单的任务:它们需要在用户的字符串中的字母上进行某种循环。啊哈!看来我们需要对这个进行进一步的自上而下设计。特别地,我们将从这个函数中拆分出三个子任务:

  • add_letter—此函数将接受一个字符串参数,并返回一个通过在单词中任意位置添加一个字母可以得到的所有字符串列表。

  • delete_letter—此函数将接受一个字符串参数,并返回一个通过删除一个字母可以得到的所有字符串列表。

  • change_letter—此函数将接受一个字符串参数,并返回一个通过更改一个字母可以得到的所有字符串列表。

表 7.2 提供了我们期望每个函数对于两个不同输入字符串的返回结果。对于 add_letterchange_letter,返回了大量字符串,因为它们会考虑在输入字符串中的每个位置添加或更改每个字母。

表 7.2 add_letterdelete_letterchange_letter 函数示例
输入字符串 add_letter 返回的字符串 delete_letter 返回的字符串 change_letter 返回的字符串
cu acu, bcu, ccu, …, zcu cau, cbu, ccu, …, czu cua, cub, cuc, …, cuz u, c au, bu, du, …, zu, ca, cb, cc, …, cz
cad acad, bcad, ccad, …, zcad caad, cbad, ccad, …, czad caad, cabd, cacd, …, cazd cada, cadb, cadc, …, cadz ad, cd, ca aad, bad, dad, …, za cbd, ccd, cdd, …, czd caa, cab, cac, …, caz

像往常一样,重要的是要思考是否需要将这三个函数进一步拆分为子任务。然而,考虑到我们期望看到的模式只是对字母的循环遍历,我们会觉得可以继续推进,如果假设不正确,我们可以重新审视自顶向下的设计。现在我们可以告诉你,我们在没有进一步拆分的情况下也能做到。

知道何时停止将任务划分为子任务

通常,知道何时停止将任务分解为更小的子任务,更像是一种艺术而非科学。设计程序时,需要通过实践来获得直觉,判断何时停止。许多经验丰富的开发人员通常会为每个子任务停下来,考虑编程的复杂性,并在脑海中勾画出函数的步骤。如果解决方案不直接,他们通常会选择将其分解为更多步骤。但我们不期望你现在就能做到这一点。一些公司发布了指导方针,通过建议函数行数的限制(例如,不超过 12 行)来鼓励简化函数,但许多人认为限制应该更多地基于复杂性,而不是长度,尽管长度和复杂性是相关的。

当你刚开始时,一个合适的代理是将每个函数的行数限制在 12 到 20 行之间。如果你后来发现某个函数最终只有一行,它仍然是一个子任务,但可能不需要自己的函数。(有时,如果一个短函数被不同的函数多次调用,或者有助于简化调用函数,保留它也是可以的。)

在刚开始练习时,犯错是正常的;我们肯定也犯过。有时一个子任务看起来只需要实现一个简单的函数,结果却比预期的要困难得多。在这种情况下,你知道只需要再将它拆分得更小。同样,我们也遇到过一些函数只有一行,如果它们经常被使用或者让调用函数更易理解,我们就将那一行保持为一个函数。

7.5.3 生成所有真实单词的列表

这个任务的函数是all_real_words。它将接受两个参数:真实单词的列表和所有可能单词的列表。它将返回一个仅包含真实单词的列表,从所有可能单词的列表中筛选出来。这个函数的代码将遍历所有可能的单词列表,检查每个单词是否出现在真实单词的列表中。由于这是一个小而明确的任务,不会生成很多代码,所以我们可以直接将这个函数保留,而不需要进一步拆分。

7.6 自顶向下设计总结

我们已经完成了自顶向下设计的过程。你可以在图 7.4 中看到我们的最终设计。

figure

图 7.4 添加了all_possible_words三个子任务的函数图

我们不要只看到树木,忽略了森林。如果我们放眼全局,我们所做的是将最初的一个大问题拆解为几个更小的问题,每个问题我们都会实现为一个函数。最初的spell_check问题可能让人觉得很有压力,但没关系,因为我们将它拆解为一个主要子任务get_spelling_suggestionsget_spelling_suggestions函数本身仍然是一个大问题,但我们通过相同的过程解决了它:我们将其拆解为三个子任务。两个子任务,create_word_listall_real_words,看起来可以用一个函数来解决,但另一个任务,all_possible_words,则足够复杂,我们觉得需要三个额外的子任务(add_letterdelete_letterchange_letter)。我们希望你从中学到的一个重要点是,我们在每一步都使用了相同的分解问题的方法,把最初看起来令人生畏的任务,变成了一系列可以解决的小任务,而这些小任务将变成函数。

我们的下一步是从设计转向实现。现在我们知道了解决问题所需的函数。是时候写代码了!

7.7 实现我们的函数

当你进行自上而下设计时,正如我们刚刚做的那样,你从整体问题开始,并将其拆解成子问题。这就是为什么我们从spell_check(我们的整体问题)开始,最终到了像add_letter这样的函数,它不需要进一步拆分。但当我们要求 Copilot 实现这些函数时,我们并不会按照相同的顺序工作。相反,我们将按相反的顺序工作,先做最小的子问题,然后再做依赖于这些子问题的函数。这对应于从图 7.4 中的右到左移动,从叶子函数开始,继续到非叶子函数。

我们以相反的顺序实现函数,这样当我们想实现更大的函数时,Copilot 已经知道了较小的函数。这样,Copilot 就更有可能按照我们的预期调用子任务函数。

我们想在这里专注于问题分解,并且已朝着这个目标做出了一些决策。首先,虽然我们会在文档字符串中包含一些测试,但我们不会像第六章那样进行完整的测试。我们鼓励你使用 doctest 运行我们提供的测试,并添加你自己的测试,以进一步验证代码。其次,我们没有过多关注提示工程,而是专注于那些产生良好结果的提示。在下一章,我们将专注于调试,那时我们会回到提示工程的内容。第三,我们不专注于全面阅读和理解代码。话虽如此,我们已包括一些注释来解释代码的作用和工作原理。

7.7.1 create_word_list

我们将从我们的create_word_list函数开始。和第三章一样,我们先写函数头(def 行)和文档字符串,然后 Copilot 会填写代码。这就是我们让 Copilot 为本章所有函数编写代码的方式。

我们已经知道我们的create_word_list函数应该做什么:从单词列表文件中读取单词,并将它们作为一个 Python 单词列表返回。我们小心地在文档字符串中写下我们想要的内容,如列表 7.1 所示。

列表 7.1 读取单词列表的函数
def create_word_list(filename):   #1
 **'''** #2
 **filename is the name of a file that has one English word per line.**

 **Return a list of the words in the file.**
    '''
    file = open(filename)        #3
    words = []
    for line in file:              #4
        words.append(line.strip())    #5
    file.close()
    return words

1 我们自己写的标题

2 我们自己写的文档字符串

3 打开文件

4 遍历文件的每一行

5 将每个单词添加到我们的单词列表中

我们使用了一个描述性的参数名 filename,它清楚地表明了参数的用途。我们也小心地在文档字符串中使用了这个参数名。我们的文档字符串还明确表示我们想要返回列表(而不是,例如,打印它)。

我们在这个函数的文档字符串中没有包含测试,因为我们不想分散实现每个函数的整体目标。但如果你想的话,你知道怎么做!具体来说,我们在第六章中做了这个,我们创建了一个小文件用于测试目的。在这里,你可以创建一个每行一个单词的小文件,但只包含少数几个英语单词,然后测试我们从文件中读取的单词列表是否与我们放入文件中的单词相匹配。我们已经完成了create_word_list函数,因此我们可以将其标记为完成,如图 7.5 所示。

figure

图 7.5 完整的函数图,create_word_list已完成

为了节省空间,我们不会在每次实现完一个函数后展示更新的图示,但我们会偶尔展示它。

7.7.2 add_letter

现在我们将开始处理all_possible_words所需的函数,从add_letter开始。add_letter函数以一个字符串作为参数,并返回通过向该字符串添加一个字母可以形成的所有可能单词列表。让我们简要地考虑一下使用这个函数时预期的结果,通过一个快速的例子。如果我们给函数传递字符串cu(如表 7.2 所示),它将会生成一个单词列表,其中每个字母都添加到每个可能的位置。这意味着它应该包括在cu之前添加a得到acu,在cu之前添加b得到bcu,以此类推,直到添加z得到zcu。该函数还应该包括介于cu之间的每个字母,形成caucbuccu...、czu。最后(因为有三个可以添加字母的位置),我们预期该函数将添加cu后面的每个可能字母,形成cuacubcuc...、cuz。我们的提示词和由 Copilot 生成的代码如列表 7.2 所示。

列表 7.2 添加任何可能字母的函数
def add_letter(word):
 '''
 word is a string with at least one letter.

 Return a list of all the strings that can be obtained by
 adding one letter to word.
 '''
    words = []
    for i in range(len(word) + 1):         #1
        for c in 'abcdefghijklmnopqrstuvwxyz':  #2
            new_word = word[:i] + c + word[i:]    #3
            words.append(new_word)       #4
    return words

1 遍历每个索引,从 0 到刚好超过最后一个字符

2 遍历每个小写字母

3 通过将字符 c 放置在索引 i 处来形成一个新单词

4 将这个新单词添加到我们的单词列表中

这里的代码相当微妙,需要仔细测试。例如,请注意外层循环使用的是len(word) + 1,而不是更标准的len(word)。如果没有+ 1,我们将会在单词字符串的每个现有索引处添加字符。但这样实际上会忽略掉我们还希望能够在现有字母之后添加字母的事实!+ 1增加了一个额外的迭代,我们可以在字符串的末尾添加一个字符。

对于外层循环的每个索引,我们在内层循环中考虑每个可能的小写字母。new_word = word[:i] + c + word[i:]这行代码使用了字符串切片技术,这是一种通过两个索引从字符串中提取字母的技术,用于将当前内层循环的字符添加到当前外层循环的位置。

虽然在本章中我们不会花太多时间在测试上,因为重点是问题分解,但你会希望通过给它一个拼写错误的单词(例如cu)来测试此函数,然后打印返回的单词并确保它包括如acu(添加到开头)、cau(添加到中间)和cua(添加到末尾)等字符串,以及可能的真实单词,如cupcut

请注意,我们不希望在文档字符串中包括确切的测试,因为这个函数返回的列表非常庞大!例如,尝试在 Python 提示符下键入:

>>> add_letter('cu')

你会得到一个包含 78 个字符串的列表!这只是针对我们小小的'cu'字符串。随着参数字符串中字符数量的增加,返回列表中的字符串数量显著增加。

7.7.3 delete_letter

add_letter类似,delete_letter将一个字符串作为参数并返回一个潜在单词的列表。不同之处在于,delete_letter不是添加字母,而是删除字符串中的每个可能字母,从而得到新的潜在单词。让我们思考一下我们期望这个函数执行的操作。如果我们给它字符串“carf”,它应该尝试删除每个字母来生成字符串“arf”(删除“c”)、“crf”(删除“a”)、“caf”(删除“r”)和“car”(删除“f”)。同样,我们为这个函数给了 Copilot 提示,如清单 7.3 所示。

清单 7.3 删除任何字母的函数
def delete_letter(word):
 '''
 word is a string with at least one letter.

 Return a list of all the strings that can be obtained by
 deleting one letter from word.

 >>> delete_letter('carf')
 ['arf', 'crf', 'caf', 'car']
 >>> delete_letter('a')
 ['']
 '''
    words = []
    for i in range(len(word)):        #1
            new_word = word[:i] + word[i + 1:]      #2
            words.append(new_word)     #3
    return words

1 遍历每个索引,从 0 到最后一个字符

2 通过删除索引 i 处的字符来形成一个新单词

3 将这个新单词添加到我们的单词列表中

这段代码类似于我们的add_letter代码。不同之处在于这次只有一个循环,而不是两个。原因是我们不需要一个内部循环来遍历字母abcd等。因为我们删除的是已经存在于单词中的字母,而不是找出要添加哪个字母。

7.7.4 change_letter

现在,是时候处理all_possible_words的第三个也是最后一个子任务change_letter了!这个函数负责生成通过将字符串中的一个字母替换为其他字母而得到的单词。考虑到我们希望在这里看到的效果,让我们再考虑一下字符串cu(如表 7.2 所示)。我们希望这个函数将字母c替换为所有可能的字母,以生成字符串aubudu……,zu,并将字母u替换为所有可能的字母,以生成字符串cacbcc……,cz。(请注意,我们省略了尝试cu,因为它是初始字符串,我们只考虑字母的变化。)请参见清单 7.4,查看我们为此函数提供的提示和 Copilot 的代码。

清单 7.4 更改任何字母的函数
def change_letter(word):
 '''
 word is a string with at least one letter.

 Return a list of all the strings that can be obtained by
 changing one letter to another letter in word.
 '''
    words = []
    for i in range(len(word)):          #1
        for c in 'abcdefghijklmnopqrstuvwxyz':       #2
            if c != word[i]:                             #3
                new_word = word[:i] + c + word[i + 1:]    #4
                words.append(new_word)    #5
    return words

1 遍历每个索引,从 0 到最后一个字符

2 遍历每个小写字母

3 如果 c 与索引 i 处的字母不同

4 通过将索引 i 处的字符替换为字符 c 来形成一个新单词

5 将这个新单词添加到我们的单词列表中

这段代码与我们的add_letter代码非常相似!主要区别在于我们使用字符串切片来改变一个已有的字符,而不是添加一个新字符。

到目前为止,我们已经完成了实现all_possible_words所需的三个函数。查看图 7.6,我们标出了已取得的重要进展!接下来,我们将处理all_possible_words

图像

图 7.6 完整的函数图,其中all_possible_words的辅助函数已完成

7.7.5 all_possible_words

我们现在能够实现all_possible_words的原因是,我们已经实现了all_possible_words所需的三个子任务函数。当我们要求 Copilot 编写这段代码时,我们期待看到add_letterdelete_letterchange_letter的调用。查看列表 7.5,你会看到 Copilot 正是这样做的,并且是根据我们提供的提示。

列表 7.5 生成所有可能单词的函数
def all_possible_words(word):
 '''
 word is a string with at least one letter.

 Return a list of all the strings that can be obtained by
 adding one letter to word, deleting one letter from word,
 or changing one letter in word.
 '''
    return add_letter(word) + delete_letter(word) \    #1
             + change_letter(word)    #2

1 调用辅助函数添加一个字母并删除一个字母

2 调用辅助函数更改一个字母

生成所有可能的单词可不简单。然而,我们通过一行 Python 代码就成功实现了!它仅调用了三个辅助函数,仅此而已。这正是我们进行自顶向下设计的原因:通过将大部分复杂性转移到辅助函数中,使得复杂的函数变得更容易实现。

7.7.6 all_real_words

我们离实现get_spelling_suggestions已经不远了,但还没有完全完成,因为我们首先需要实现它的子任务函数all_real_wordsall_real_words函数接受两个参数。第一个参数是英文单词列表(来自英文单词列表文件)。第二个参数是可能的单词列表(来自all_possible_words)。该函数返回的是那些在possible_words中且是实际单词的单词列表。让我们开始吧!

列表 7.6 生成所有真实单词的函数
def all_real_words(word_list, possible_words):
 '''
 word_list is a list of English words.
 possible_words is a list of possible words.

 Return a list of words from possible_words that are in word_list.
 >>> english_words = ['scarf', 'cat', 'card', 'cafe']
 >>> possible_words = ['carfe', 'card', 'cat', 'cafe']
 >>> all_real_words(english_words, possible_words)
 ['card', 'cat', 'cafe']
 '''
    real_words = []
    for word in possible_words:       #1
        if word in word_list:           #2
            real_words.append(word)    #3
    return real_words

1 遍历可能的单词

2 这个词是一个真实单词吗?

3 是的:将这个单词添加到我们将返回的真实单词列表中

文档字符串中的测试是这个函数如何工作的一个好例子。它使用english_words列表作为第一个参数,possible_words列表作为第二个参数。该函数将返回那些既在possible_words中又在english_words中的单词。我们使用这个非常小的测试案例,而不是使用成千上万的英文单词,是因为这样更容易手动确定正确的返回值!

让我们看看图 7.7 中我们的进展如何。我们已经接近完成了——只差两个函数!

图像

图 7.7 完整的函数图,其中all_possible_wordsall_real_words已完成

7.7.7 get_spelling_suggestions

我们已经实现了create_word_listall_possible_wordsall_real_words,这三个子任务函数正是get_spelling_suggestions所需要的。那就意味着,是时候实现get_spelling_suggestions了!

这个函数接受两个参数:一个包含真实单词(每行一个单词)文件的名称和我们希望提供拼写建议的字符串。它返回唯一拼写建议的列表。像往常一样,在检查 Copilot 代码中的非叶函数时,你需要确认它正在调用你预期的辅助函数,正如在列表 7.7 中所示。在继续之前,确保它调用了所有应该调用的辅助函数。

列表 7.7 返回拼写建议的函数
def get_spelling_suggestions(word_file, possible_word):
 '''
 word_file is the name of a file that has one English word per line.
 possible_word is a string that may or may not be a real word.

 Return the list of all possible unique corrections 
 for possible_word.
 '''
    word_list = create_word_list(word_file)        #1
    possible_words = all_possible_words(possible_word)        #2
    real_words = all_real_words(word_list, possible_words)    #3
    return list(set(real_words))    #4

1 获取英语单词列表

2 获取可能的单词列表

3 确定哪些可能的单词是拼写建议

4 返回唯一的拼写建议列表

最后一行很棘手。显然,它确保我们只返回唯一的单词——但怎么做到呢?让我们请 Copilot 来解释一下!以下是我们在使用/explain命令向 Copilot 请求时得到的解释:

chatGptCopilot 这行 Python 代码执行了两个操作:

  1. set(real_words):这段代码是从real_words列表创建一个集合。在 Python 中,集合是一个无序的唯一元素集合。所以,这个操作会去除real_words列表中的任何重复元素。

  2. list(set(real_words)):这段代码是将集合转换回列表。结果是一个所有元素都是唯一的列表。

总结一下,这行代码的作用是从real_words列表中去除任何重复的单词。

7.7.8 拼写检查

我们已经到了最后一个函数spell_check!它接受一个参数:一个表示单词列表文件名的字符串。它要求用户输入一个字符串,然后向用户展示该字符串的所有拼写更正。

列表 7.8 我们的主函数,和用户交互
def spell_check(word_file):
 '''
 word_file is the name of a file that has one English word per line.
 Ask user for a word.
 Print all possible corrections for the word, one per line.
 '''
    word = input('Enter a word: ')                          #1
    suggestions = get_spelling_suggestions(word_file, word)    #2
    for suggestion in suggestions:     #3
        print(suggestion)    #4

1 让用户输入一个字符串

2 为用户获取拼写建议

3 遍历拼写建议

4 将每个拼写建议单独打印在一行上

查阅图 7.8,确认我们确实已经实现了所有的函数!

figure

图 7.8 完整的函数图,所有函数都已完成!

你需要在 Python 程序的底部添加一行代码,来实际调用这个函数。否则,你的程序什么也不做,因为没有函数被调用!所以,在底部添加这一行:

spell_check('wordlist.txt')

现在,假设你的目录中有wordlist.txt文件以及 Python 程序,你可以运行它!它会让你输入一个单词。试着输入拼写错误的单词sucessful(就是那个一开始出问题的单词!),然后你应该会看到程序提供拼写建议successful,像这样:

Enter a word: sucessful
successful

也尝试其他拼写错误的词,例如thouroughacceptible。目前为止,程序只给出了一个拼写建议,因为只有一个真正的单词与我们尝试的词只有一次编辑的差距。我们鼓励你尝试输入carf,看看程序能否提供多个拼写建议。

恭喜你!你已经完成了第一个现实世界的自上而下设计。你的程序为一个单词提供拼写建议,就像拼写检查器一样。通过进行一些前期设计工作,将原始问题分解成更小的子问题,我们使得问题的实现变得更加容易。

许多计算机科学家认为问题分解是编写优秀软件所需的关键技能[1]。我们在本章中看到了问题分解的价值:通过将一个大问题分解成更小的步骤,直到每个步骤都更容易解决,它使得一个大问题变得可解。我们在本章中通过自上而下的设计(从大任务开始,将其分解成小任务)来实践这一技能。使用像 Copilot 和 ChatGPT 这样的工具时,这项技能仍然至关重要,因为它们在解决小而明确的问题时表现更好,而不是解决大问题。如本章开头所提到的,问题分解更多的是一种艺术而非科学,需要通过实践才能掌握。我们将在接下来的章节中做更多问题分解的练习,帮助你更直观地理解如何自己去做。

7.8 练习

  1. 使用之前讨论过的get_strong_passwordbest_word的示例,采用自上而下的设计方法来解决一个新问题。假设我们要编写一个名为find_highest_scoring_word的函数,它接受一个包含多个句子的列表作为参数,并返回所有句子中得分最高的单词。每个单词的得分计算方法与best_word函数中的计算方法相同:

    1. 确定解决此问题所需的子任务。你会设计哪些单独的函数,将任务分解成更小、更易管理的部分?

    2. 绘制一个类似于图 7.1 和 7.2 的函数图,描述这些函数如何相互调用来解决整体问题。

  2. 我们谈到了有时任务足够简单,不需要将其分解成更小的任务。对于以下任务,决定是否将其分解成更小的子任务。如果是,请列出子任务并解释为什么。如果不是,请解释为什么任务足够简单,能够保持为单一函数。

    1. 读取一个文件并打印其内容

    2. 根据学生成绩列表计算班级的平均分

    3. 在一个数字列表中查找最大值

    4. 处理在线商店订单,包括验证订单、计算总价、应用折扣和生成发票

  3. 为什么我们选择将create_word_list函数作为一个单独的函数来创建?这个任务可以作为一个更大函数的一部分吗?请解释你的理由。

  4. 假设你需要更改拼写检查器的工作方式。具体来说,你想修改add_letter函数,排除某些字母(例如,qxz)不被添加。你将如何修改我们写的程序?

  5. 以下函数处理一个订单列表,计算含税总价,若适用则应用折扣,并生成汇总报告。

def process_orders(orders):
    total_price = 0
    for order in orders:
        price = order['price']
        quantity = order['quantity']
        total_price += price * quantity
    tax = total_price * 0.08
    total_price_with_tax = total_price + tax
    report = f"Total price: ${total_price:.2f}\n"
    report += f"Tax: ${tax:.2f}\n"
    if total_price_with_tax > 100:
        discount = total_price_with_tax * 0.1
        total_price_with_tax -= discount
        report += f"Discount: ${discount:.2f}\n"
    report += f"Total price with tax: ${total_price_with_tax:.2f}\n"
    print(report)

a = {'price': 20, 'quantity': 5}
lst = [a]
process_orders(lst)

通过将process_orders函数重构(重新设计),将其分解为更小的子问题。将每个子问题实现为一个单独的函数,并确保整体行为保持不变。

  1. 6. 在这个练习中,给定了一个已经分解成两个函数的代码片段:一个主函数和一个辅助函数。当我们使用以下代码中的测试用例 2 调用该函数时,会出现ZeroDivisionError。你的任务是根据提供的错误信息识别并修复这个错误。
def calculate_average(numbers):
    total = sum(numbers)
    count = len(numbers)
    return total / count

def process_numbers(data):
    valid_numbers = [n for n in data if isinstance(n, int)]
    average = calculate_average(valid_numbers)
    print(f"The average of the valid numbers is: {average}")

# Test Case 1
data = [10, 20, 'a', 30, 'b', 40]
process_numbers(data)

# Test Case 2
data = ['a', 'b']
process_numbers(data)

总结

  • 在我们有效实现一个大的编程问题之前,需要将其分解为更小的子问题。这被称为问题分解。

  • 自顶向下设计是一种将问题分解为小的子任务函数的系统化技术。

  • 在自顶向下设计中,我们寻求能够解决明确任务的小函数,这些函数可以被一个或多个其他函数使用。

  • 当我们准备实现从自顶向下设计中产生的函数时,我们是从自底向上实现的;也就是说,我们首先实现叶子函数,然后是依赖于这些叶子函数的函数,依此类推,直到我们实现最顶层的函数。

第八章:8 调试并更好地理解你的代码

本章内容包括

  • 确定 bug 的来源

  • 使用 Copilot 技巧修复错误

  • 使用 Copilot 调试代码

  • 使用 VS Code 调试器查看代码的运行情况

每个程序员的职业生涯中,都会遇到代码无法按预期运行的情况。这可能已经发生在你身上了,放心,这是学习编程过程中很正常的一部分。我们该如何修复代码呢?有时,仅仅改变提示语或更好地分解问题(就像你在之前的章节中学到的那样)就足以解决问题。但当你无法让 Copilot 给你不同或更好的代码,或者无法弄清楚为何你得到的代码无法正常工作时该怎么办呢?

本章有两个目的。主要目标是学习如何找到代码中的错误(称为 bug)并修复它们。为了找到这些 bugs,你需要实现第二个目标,即在运行代码时更深入地理解你的代码是如何工作的。

好消息是,代码中出现错误对程序员来说是再常见不过的事情,因此编程环境(如 Visual Studio Code,VS Code)提供了帮助我们发现问题的工具。在本章中,我们将学习如何使用这个叫做调试器的工具。

就像在之前几章中我们从小例子开始一样,我们将通过一些小例子来学习如何找到和修复错误的概念,然后通过深入一个更大的示例来更真实地展示这个过程,这个示例类似于你未来可能编写的代码。

8.1 错误(bug)是如何产生的?

首先是一个简短的术语课程。几十年前,计算机使用继电器或真空管,而不是晶体管。故事是这样的:Grace Hopper 博士在编程早期的计算机时,遇到了一次由继电器中的一只真实的虫子(蛾)引起的错误。今天,我们使用“bug”这个词来指代程序中的错误,尽管我们现在不再处理真实的虫子了。现在,bug 是由程序员的错误引起的;当我们调试代码时,我们是在去除这些 bugs。(Python 本身的实现或者计算机硬件中也可能存在 bugs,但它们非常罕见,所以我们可以假设代码中的错误是由于代码本身的原因。)

如果程序员真心想解决问题,他们是不会故意在代码中引入 bug 的。那么,为什么会出现 bug 呢?软件工程师和 Copilot 也会犯错误。你可能会问,是什么样的错误呢?有两种主要的 bug 类别:

  • 语法错误——当代码没有遵循 Python 语法要求时,就会发生这些错误。例如,在 for 循环的第一行末尾忘记写 : 就是一个语法错误。当你运行 Python 时,由于它试图根据你的程序描述生成机器代码,程序会遇到错误,不知道该怎么做,并给出错误提示。有时,这些错误信息比其他错误信息更易读。当人们以传统方式学习编程时,没有 Copilot,这些错误非常常见。学习所有 Python 语法规则并将其养成习惯需要一些时间。即使是我们两个,尽管编写了几十年的代码,有时仍然会写出带有语法错误的代码。好消息是,使用 Copilot 编写代码几乎完全消除了这个问题!坏消息是,在两种类型的 bug 中,语法错误是最容易发现和修复的。

*** 逻辑错误——这些错误发生在程序的逻辑上有问题时。例如,也许代码的目的是统计“Dan”这个词在单词列表中出现的次数,但代码实际上统计的是单词列表中包含“dan”(不区分大小写)这个子串的单词的数量。这段代码在两个方面做错了:它会将 danDAN 这样的词视为匹配项,尽管我们并不希望这些匹配;而且,它会把 dan 当作一个单词的一部分,而不是完整的单词。也就是说,它会把 Danieldangerdan 这样的词计数进来,尽管我们并不希望将这些计入!代码的某个地方出了问题,我们需要找出问题所在并理解原因。通常,找到逻辑错误是最困难的部分。一旦我们知道了错误的所在,我们需要修复它,修复这些 bug 可能需要更改一个字符,也可能需要完全重写代码。逻辑错误可能发生在提示描述不清晰,或者 Copilot 因各种原因生成了错误的代码。****

****## 8.2 如何找到 bug

找到 bug 可能是一个挑战。从根本上来说,不管是你还是 Copilot 编写了代码,你在编写时都认为(或 Copilot “认为”)代码是正确的。这就是为什么同事往往比作者更容易发现代码中的 bug,作者自己反而看不出来,因为是自己编写的代码!

在本书中,bug 对我们来说并不陌生,因为我们已经看到过代码中的错误,我们通过阅读代码和/或测试找出了这些错误。在前几章中,我们通过阅读代码找到了 bug 的源头。在本章中,我们将讨论你在测试时发现的 bug,但无法弄清楚代码为什么出错。通常,你可以通过尝试其他 Copilot 提示、使用新提示修复错误,或者请求 Copilot 为你修复 bug 来绕过弄清楚代码出错的过程,但根据我们的经验,这些方法并不总是有效。那么,我们需要更多的工具来帮助我们找出代码中的错误位置。

8.2.1 使用 print 语句了解代码行为

从根本上讲,逻辑错误意味着作者认为代码会做的事情和代码实际做的事情之间存在不匹配。识别这种不匹配的一种常见方法是使用 print 语句来了解程序的行为,因为它们能告诉你计算机实际在做什么。一种有用的做法是,在不同的时间点打印变量,以查看这些变量在这些时刻的值。让我们试试我们刚才给出的那个例子,查找单词 Dan 在单词列表中的位置。这里是错误的代码。

列表 8.1 计数匹配单词的错误函数
def count_words(words):
    count = 0
    for word in words:
        if "dan" in word.lower():    #1
            count += 1
    return count

1 lower 方法将单词转换为全小写字母。

你可能已经看出代码哪里出错了,但让我们假设我们不知道问题出在哪里,正在尝试弄清楚代码出了什么问题。假设我们通过运行以下测试用例发现我们的代码是错误的:

>>> words = ["Dan", "danger", "Leo"]   
>>> count_words(words)
2

我们预计得到的答案是 1,但我们得到了 2。值得注意的是,将 danger 包含在这个测试用例中帮助我们找到了代码中的错误。问题出在哪里呢?为了弄清楚这一点,我们可以添加 print 语句。当你想这样做时,你需要阅读代码,弄清楚应该在哪里放置这些语句。看着这段代码,在函数开始时打印列表可能是个不错的主意,具体取决于我们看到的 bug,但这里的 bug 似乎与计数有关,而不特别是与单词列表有关。我们可以在 for 循环的第一行打印每个单词,以便检查代码是否正在处理每个单词。我们可能会在返回 count 之前打印它,但我们已经对返回的值有了较好的了解。这些都是合理的想法,有助于你更接近 bug,但这不是我们开始的地方。明确来说,如果我们从这些其他想法开始,也不会错;可能只是要多走几步才能找到 bug。

因为 bug 是将太多单词错误地计数为 Dan 单词,所以我们会将 print 语句放在 if 语句中,正是 count 被递增的地方,如以下列表所示。

列表 8.2 查找 bug 的 print 语句示例
def count_words(words):
    count = 0
    for word in words:
        if "dan" in word.lower():
            print(word,"is being counted")    #1
            count += 1
    return count

1 显示被计数的单词的 print 语句

使用相同的测试用例重新运行代码,现在打印的是:

>>> words = ["Dan", "danger", "Leo"]   
>>> count_words(words) 
Dan is being counted
danger is being counted
2

哦!这告诉我们程序在不应该的情况下将单词 danger 计算在内。接着我们可以给 Copilot 提供一个新的提示,将我们刚刚学到的知识结合进来,希望 Copilot 能修复这个问题。以下是我们在编辑器中添加的提示,Copilot 在接下来的示例中修复了代码。

示例 8.3 使用提示修复已知的 bug
def count_words(words):
    count = 0
    for word in words:
        # only count words that are exactly "Dan"    #1
        if word == "Dan":    #2
            count += 1       #2
    return count             #2

1 触发 Copilot 生成正确代码的提示

2 Copilot 根据提示添加的代码

一旦我们知道了 bug,告诉 Copilot 如何修复它就容易多了。当然,这是一个简单的例子,但这个方法同样适用于更复杂的代码。这个过程通常是迭代的。你让代码打印出一些内容,看看它打印的结果是否与预期一致。接着你再让代码打印其他内容,并与预期进行对比。你不断重复这个过程,直到发现代码打印的内容与预期不符。那时你就找到了 bug,可以给 Copilot 提供提示,帮助它生成更好的代码;如果修复很简单,你也可以直接修改代码。

根据代码的复杂性,使用 print 语句通常是调试的一种有效方式。我们通常将它作为调试的第一步。

调试:一种艺术,更多的是一门技巧

在调试时,迭代过程是必要的,因为我们真的不清楚代码在做什么,为什么它没有达到我们的预期。添加一堆 print 语句,告诉你最初认为会发生的事情是没问题的,因为这些 print 语句会帮助你排除不该看的地方,这是一个有效的排除过程。找出应该寻找 bug 的地方需要时间和实践,所以如果你刚开始时发现自己花了很多时间,不要担心。

8.2.2 使用 VS Code 的调试器了解代码行为

VS Code 被初学者和专业人士都在使用,因此它具备帮助调试过程的工具。由于它被专业人士使用,调试工具拥有许多功能。为了本书的目的,我们将重点讲解一些最常用的功能,但如果你感兴趣,可以查看更多关于如何使用 VS Code 调试器的资源 (code.visualstudio.com/docs/editor/debugging)。

为了展示这些工具,我们将通过几种方式调试前面示例中的同一个函数(请参考示例 8.1)。示例 8.4 提供了我们在本节中使用的代码。请注意,我们已经将 count_words 函数的测试添加到程序中。

示例 8.4 用于调试的错误 count_words 函数
def count_words(words): 
    count = 0 
    for word in words: 
        if "dan" in word.lower(): 
            count += 1 
    return count 

words = ["Dan", "danger", "Leo"]     #1
print(count_words(words))            #1

1 直接调用 count_words 函数的代码

使用调试器并设置断点

要使用调试器,我们需要设置一个 断点 并启动调试器。设置断点告诉调试器在你希望开始调试程序执行的地方停下来。一旦到达断点,你将能够检查变量值并逐行调试程序。断点非常有用。对于一个大型程序,你不想逐行调试整个程序,因为那样可能会花费大量时间。相反,通过使用断点,你可以只调试与你最相关的代码行。

要设置断点,将鼠标光标悬停在代码左侧,你将看到一个圆点出现。点击该圆点,如图 8.1 所示,这样就设置了一个断点。在你的项目中,你可能会想先在函数执行的开始处创建一个断点,然后逐步调试每一步。

figure

图 8.1 在 VS Code 中通过点击代码行左侧设置断点

如果你移开鼠标后看到断点左侧有一个红点,如图 8.2 所示,那么说明断点设置正确。

figure

图 8.2 我们在 VS Code 中的 count_words.py 文件的第 2 行设置了一个断点。

你可以设置多个断点,但在这个例子中,我们只设置一个断点在第 2 行。(注意,你可以通过再次点击那个红点来移除断点。)接下来,我们将启动调试器并查看它如何与断点一起工作。

如何逐行调试代码

要启动调试器,选择运行 > 开始调试,如图 8.3 所示。

figure

图 8.3 在 VS Code 中启动调试器

一旦调试器启动,你应该看到类似于图 8.4 的界面。(如果这是第一次使用调试器,它可能会要求你选择一个调试配置,在这种情况下,你需要选择 Python。)

figure

图 8.4 VS Code 中的调试视图

VS Code 调试器有多个组件[1]。在左侧,调试侧边栏包含变量部分、观察部分和调用堆栈部分。让我们简要了解一下这些部分:

  • 变量部分包含当前作用域内声明的变量(例如,count_words 内的变量)及其当前值。例如,words 参数被定义为包含 ['Dan', 'danger', 'Leo'] 的列表。你可以点击 words 左侧的箭头(>)查看该变量的更多详细信息。这个部分非常有用,因为你可以检查每个变量的值。

  • 观察部分包含你希望特别监视的表达式。例如,你可以将表达式:"dan" in word.lower() 添加到观察表达式中,然后你就可以看到它对于每个不同的 word 值是否为 TrueFalse。要添加一个表达式,只需将鼠标悬停在观察部分并点击 + 号。

  • 调用堆栈部分包含了导致这行代码执行的已调用的函数。在这里,主函数(VS Code 中称之为 <module>)在第 9 行调用了 count_words 函数。在 count_words 函数内,我们当前在第 2 行。你可以通过查看当前高亮的代码行来再次确认这一点,因为第 2 行在图 8.4 中被高亮显示。

说到这一点,在右侧显示我们的代码编辑器,我们可以看到高亮显示的行 count = 0。这就是当前还未执行的代码行。该行尚未执行的原因是因为我们在这行代码上设置了断点,通过带有点的箭头表示。当我们启动调试器时,它运行了代码,直到执行 count = 0 之前。

调试工具栏(图 8.5)至关重要,因为它在你开始调试后驱动整个过程:你可以推进指令、重新启动调试或停止调试。

figure

图 8.5 调试工具栏

工具栏上的按钮,从左到右依次是

  • 继续(F5)—此按钮将继续执行,直到下一个断点被触发。在我们的示例中,count_words 中的第 2 行不会再执行,所以点击继续将使程序和调试会话一直运行到结束。

*** 步过(F10)—此按钮将跳到当前函数中的下一行代码。如果当前函数中的一行代码调用了另一个函数(例如,当第 4 行调用 word.lower() 时),调试器将保持在 count_words 函数中,只会运行被调用的函数(例如,word.lower())直到完成。*** 步入(F11)—此按钮将跳到下一行代码,包括进入所有被调用的函数。与步过不同,当你使用步入时,调试器将进入当前函数调用的任何函数。例如,如果你在调用另一个函数的代码行上使用步入,它会进入该函数并继续从该函数内部逐行调试。默认情况下,它不会进入库函数调用(例如,word.lower() 是 Python 标准库的一部分),但会进入你编写的函数。*** 步出(Shift + F11)—此按钮将执行代码直到当前函数结束,然后继续从该函数退出后的地方调试。*** 重新启动—此按钮将重新启动调试过程。它将重新启动程序,并执行到第一个断点为止。*** 停止—此按钮将停止调试过程。**********

******#### 一行一行地调试代码

现在我们已经掌握了调试器的使用,让我们继续我们的示例,使用“步过”按钮。点击一次“步过”按钮,看看我们的视图如何变化(见图 8.6)。“步过”执行到了下一行代码,第 3 行。这意味着它执行了

count = 0

并且即将准备好执行

for word in words:

figure

图 8.6 我们在点击一次“Step Over”后的调试器视图。下一行(第 3 行)现在被高亮显示。

我们可以从界面中的各种线索看到 count = 0 这一行代码已经执行。首先,在右侧的编辑器中,我们可以看到 for 循环被高亮显示,并且左侧的箭头指向了这一行代码。在左侧的调用堆栈部分,我们可以看到它现在位于 count_words 的第 3 行(如左侧显示 count_words 被高亮并且右侧的数字显示 3:1)。也许最重要的是,在左侧的变量部分,我们可以看到变量 count 已经被添加到局部变量中,并且其值为 0。最后这一点相当惊人,因为如果你试图逐行阅读代码并跟踪发生的事情,第 2 行的 count = 0 会意味着一个名为 count 的变量被创建并赋值为 0。这正是 VS Code 调试器告诉我们的内容。我们希望你现在已经开始看到这个工具有多强大了。

让我们再点击一次“Step Over”。现在我们停在这一行之前。

if "dan" in word.lower():

我们可以看到现在有了一个新的变量 word,其值为 "Dan",这正是我们期望的:word 被赋值为 words 列表中的第一个元素。这是一个很好的时机来指出,除了在变量部分查看变量的值之外,你还可以将鼠标悬停在任何已经声明的变量上,来查看它的值。相当不错吧?

让我们再点击一次“Step Over”并观察 if 语句中的条件,"dan" in word.lower() 被评估为 True,所以我们将执行这一行代码。

count += 1

现在我们已经掌握了这个操作,继续多点击几次“Step Over”。第一次点击时,它会返回到 for 循环,并且可以看到 count 已经增加到了 1。第二次点击时,它会停在 if 语句处,并且可以看到 word 的值现在是 "danger"。我们可以在这里停下来并添加一个监视表达式,看看这个 if 语句会做什么。要添加监视表达式,将鼠标悬停在 Watch 区域,然后点击出现在 Watch 右侧的加号箭头。这将允许你输入任何你想要的内容。我们输入了 "dan" in word.lower() 并按下回车键来添加这个监视表达式,如图 8.7 的调试侧边栏所示。

figure

图 8.7 添加监视表达式后的调试器视图

如果我们在本章之前没有找到 bug,那么就是在这里我们会发现它。表达式 "dan" in word.lower() 被评估为 True,这意味着 count 会再次增加。但是我们只希望精确匹配 "Dan" 这个单词,而不希望 "danger" 被计算在内!

这是调试函数的完全合理方式。正如我们之前注意到的,将断点放在函数执行的开头,然后逐步执行是调试的一个很好的起点。唯一可能使您难以使用这种方法的时候是for循环在出错前运行了成千上万个值。为了解决这样的挑战,我们可能会在特定位置放置一个断点,以避免在调试器中花费大量时间。让我们停止调试器(在调试工具栏中点击“停止”),移除第 2 行的断点(点击行左侧的红点),并尝试一个不同的断点。

使用断点进行选择性调试

这一次,让我们将断点放在我们想要更密切监视的代码位置上。基于我们的测试案例显示列表中“计数”了两个单词,而我们只期望一个,我们应该尝试将断点放在递增count的行上,就像我们在图 8.8 中做的那样。

figure

图 8.8 在第 5 行放置新断点后的代码视图

一旦我们启动调试器,代码将运行,直到第一次if语句评估为True并且准备执行count += 1的行。图 8.9 显示了我们启动调试器后的调试器视图。

figure

图 8.9 调试器首次遇到断点(第 5 行)时的视图

我们将断点放在递增count的位置上,因为我们想看看列表中的哪个项目导致count增加。检查我们的局部变量,我们可以看到word'Dan',这时我们希望count变量增加。考虑到这是我们想要发生的,我们还没有找到我们的错误。

现在是我们真正利用断点位置的时候。我们希望代码执行直到再次遇到断点。这样做的方法是在调试工具栏中点击“继续”(参见图 8.5)。点击“继续”后,调试器应该显示如图 8.10 中所示。

figure

图 8.10 调试器在第二次遇到断点(第 5 行)时的视图

在变量部分,请注意count的值和word的值被突出显示。这是告诉您因为您点击了“继续”,这两个值已经改变了。count的值增加到了1,正如预期的那样,找到单词'Dan'后。word的值更新为'danger'。尽管'danger'是单词列表中的下一个值,但我们不应该期望找到代码为单词'danger'递增count。在这一点上,我们应该找到了我们的错误。请注意,通过良好放置断点,找到错误可以更快。但我们警告说,知道放置断点的最佳位置很像知道调试时要打印的最佳变量一样,这需要时间和练习来学会如何做得好。

在本节中,我们已经看到调试器在 VS Code 中的强大功能。设置断点并开始调试会话非常容易。当调试器监视函数中的变量时,你可以在调试侧边栏中检查这些变量,随着代码的执行,查看它们的变化。

8.3 如何修复错误(找到错误后)

在旧的编程学习方式中,这部分内容会告诉你如何自己修复错误。虽然这仍然是一个选择,但现在我们有了更多的选择,可以通过 Copilot 来解决问题。这些选择包括要求 Copilot 修复它,给它一个新的提示,重新描述问题,避免出现错误;给 Copilot 一个有针对性的提示,修复你找到的错误代码部分;如果是一个小问题,也可以自己修复。让我们看看如何做这些。

8.3.1 通过 Copilot Chat 请求 Copilot 修复您的错误

在下面的示例中,我们将与 Copilot Chat 功能进行交互。Copilot Chat 功能允许你像与 ChatGPT 互动一样与 Copilot 互动,但仅限于编程相关的问题。你也可以与 ChatGPT 进行相同的互动(可以通过openai.com访问,在写这篇文章时,ChatGPT 提供免费层)。我们更倾向于使用 Copilot Chat,因为它的界面是内置在最新版本的 VS Code 和 Copilot 扩展中的。

在之前的章节中,我们大多只要求 Copilot Chat 给出单一的答案,而在本章及后续章节中,我们将使用 Copilot Chat 与工具进行对话。当我们与工具对话时,我们将努力将我们写的内容(“Human”)与 Copilot 的回答(“GitHub Copilot”)分开。通过 Copilot Chat 功能,让我们看看 Copilot 是否能帮我们解决这个问题:

LPHuman 我有一个函数,下面是它的代码,应该统计单词“Dan”在参数列表“words”中出现的次数。相反,它似乎统计的是字符“dan”在列表中出现的次数。你能帮我修复这个问题吗?

*`*def count_words(words):*`* 
 *`*count = 0*`* 
 *`*for word in words:*`* 
 *`*if "dan" in word.lower():*`* 
 *`*count += 1*`* 
 *`*return count*`*

chatGptGitHub Copilot 当然!要计算单词“Dan”在列表中出现的次数,您可以修改if语句,检查整个单词是否等于“Dan”(不区分大小写)。这是修改后的代码:

*`*def count_words(words):*`* 
 *`*count = 0*`* 
 *`*for word in words:*`* 
 *`*if word.lower() == "dan":*`* 
 *`*count += 1*`* 
 *`*return count*`*

这段代码将计算单词“Dan”(不区分大小写)在列表中出现的次数。

Copilot Chat 返回的答案更改了这一行代码

if "dan" in word.lower():

if word.lower() == "dan":

尽管这个变化看起来很小,但它的影响却很大。原始的代码行会在word中搜索字符串"dan"(在通过.lower()方法将单词转换为小写字母之后)。新的代码行检查word(同样,通过.lower()方法将其转换为小写字母)是否与字符串"dan"完全匹配。

这个来自 Copilot 的回答离正确的解决方案更近一步,因为它只会匹配整个单词,而不是在单词的任何位置查找dan。然而,调用.lower()会让它变得不区分大小写,因此它也会匹配dan,而我们之前的解决方案(列表 8.3)是区分大小写的(必须是完全的Dan)。我们希望保持区分大小写,因此我们可以继续使用 Copilot Chat 来让 Copilot 为我们修复这个问题。

LPHuman:你能给我相同的代码,但只统计单词“Dan”(区分大小写)出现的次数吗?

chatGptGitHub Copilot:当然!这是修改后的代码,它只统计单词“Dan”(区分大小写):

*`*def count_words(words):*`* 
 *`*count = 0*`* 
 *`*for word in words:*`* 
 *`*if word == "Dan":*`* 
 *`*count += 1*`* 
 *`*return count*`* 

这段代码将统计单词“Dan”(区分大小写)在列表中出现的次数。

在这种情况下,当我们知道代码出错的方式时,Copilot Chat 能够为我们解决问题。我们并没有明确说明哪一行代码有问题,但如果你知道这一点,也可以把它告诉 Copilot Chat。知道哪一行有问题的一个好处是,我们可以检查 Copilot 的修复是否确实更改了那一行代码。接下来,我们将继续探索其他修复该错误的选项。

8.3.2 给 Copilot 一个新的整个函数的提示

你已经看到我们在本书中多次重写函数的提示了。这仅仅是涉及到重写函数提示,并看看 Copilot 是否能够做得更好,有时使用 Ctrl-Enter 来探索 Copilot 的解决方案。我们在这里不再重复这一过程,因为你已经在之前的章节中看到过很多次了(例如,在第六章第 6.5.2 节)。

8.3.3 给 Copilot 一个有针对性的部分函数提示

我们在本章的列表 8.3 中使用了这个技巧。我们已将其作为列表 8.5 重新呈现。

列表 8.5 使用提示修复已知错误(重新列出)
def count_words(words):
    count = 0
    for word in words:
        # only count words that are exactly "Dan"     #1
        if word == "Dan":
            count += 1
    return count

1 导致 Copilot 生成正确代码的提示

在这里,我们已经弄清楚了前一行

if "dan" in word.lower():

并没有达到我们想要的效果。所以,我们删除了错误的代码,并特别添加了一个提示,明确告诉 Copilot 我们想要的是什么,结果它能够生成正确的代码。

8.3.4 自行修改代码修复错误

修复损坏代码的方法是本章的新内容。到目前为止,你在本书中已经看到了足够多的代码,可能能够通过直接修改代码来修复一些问题。例如,假设我们编写了以下代码。

列表 8.6 统计指定数字出现在列表中的函数
def count_between(numbers, x, y):
    count = 0
    for number in numbers:
        if number >= x and number <= y:
            count += 1
    return count

我们在本书的早些时候见过类似的函数。它会遍历数字列表中的所有数字,并检查每个数字是否大于或等于x且小于或等于y。如果数字位于xy之间,它就会增加count。在函数的最后,它会返回count。如果目标是统计列表中位于xy之间的数字(包括xy),那么这里的代码是没有错的。

然而,between这个术语可能有些模糊。你想包含xy,还是只想包括它们之间的值(不包括它们)?假设当你想象这个函数时,你希望不包括xy。例如,如果列表是 2、4、8,而你提供的xy值分别是 3 和 8,你希望count的值为 1(包括 4,但不包括 8)。你可能已经看到这个函数在这个目标下是错误的,并且知道如何修复它,但让我们假装你还没有意识到代码并没有达到你的期望。

和任何由 Copilot 或我们自己创建的函数一样,我们应该测试该函数。幸运的是,在编写第一个测试用例时,我们在测试用例(numbers)中包括了一个等于x(下限)和等于y(上限)的值:

>>> numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9]
>>> print(count_between(numbers, 3, 7))
5

在测试提示中的代码时,我们发现函数返回的答案是 5,但我们认为答案应该是 3。根据我们对between的定义,我们认为只有 4、5 和 6 位于 3 和 7 之间,所以答案应该是 3,也就是说,3 和 7 之间有三个数字。相反,代码给出的答案是 5。因此,我们知道代码没有正确运行,这得益于我们的测试用例。无论是通过检查代码,还是使用本章的调试技巧来寻找错误,我们意识到语句

if number >= x and number <= y:

是罪魁祸首。在这里,完整的修复要求我们将if语句从

if number >= x and number <= y:

if number > x and number < y:

这是我们可以直接进行的更改,无需寻求 Copilot 的帮助(尽管 Copilot 也可能能帮助我们)。列表 8.7 中是更改后的修复函数。

列表 8.7 修正后的计算指定数字的函数
def count_between(numbers, x, y):
    count = 0
    for number in numbers:
        if number > x and number < y:    #1
            count += 1
    return count

1 手动将 >= 改为 >,将 <= 改为 <

8.4 根据我们新学到的技能修改工作流程

现在我们知道如何以更加有意识的方式查找和修复错误,让我们回顾一下在第六章最后回顾过的工作流程。请注意,这个工作流程是关于如何设计单个函数的,因此它假设你已经完成了第七章中描述的函数分解,以确定适当的函数。新工作流程出现在图 8.11 中。

figure

图 8.11 与 Copilot 一起的函数设计周期,增加了调试部分

虽然图表开始变得有些复杂,但大部分内容与第六章中看到的类似。这里唯一的变化是加入了调试的内容。具体来说,如果你已经尝试修改提示,仍然无法得到有效的代码,那么就该开始显式地进行调试了。利用本章学到的工具,你可能能成功地找出一些错误,但不一定能发现所有的错误。如果你发现了错误并认为已经修复了它,那么流程图会让你回到测试阶段,以确保你的修复有效(并且没有破坏其他测试用例)。如果你万一通过新的提示无法让代码工作,且调试似乎无法进行,我们发现问题分解往往是你最好的下一步。换句话说,如果你怎么也调试不好某个函数,可能是该函数应该被拆分成多个小函数,这样你更有可能成功地编写这些小函数。

8.5 将我们的调试技能应用到新问题上

配备了新的技能和工作流程后,让我们尝试解决一个更具挑战性的调试问题。还记得第六章中我们试图确定教室中某一排座位中空座位最多的数量吗?我们将要在这里解决一个类似的问题。

与其寻找空座位,不如说我们想要计算在一排座位中连续坐着的最多学生,因为你可能想在考试前更好地安排他们的位置。你还可以想象,如果人们想要更多的私人空间,或者如果发生了疫情,这个方法可以用来更好地分散人群。我们编写了提示和一个基本的 doctest,Copilot 生成了一个解决方案。代码如下所示。

列表 8.8 尝试计数连续座位上的学生
def most_students(classroom):                                 #1
 **'''** 
 **classroom is a list of lists** 
 **Each ' ' is an empty seat** 
 **Each 'S' is a student** 

 **Find the most students seated consecutively in a row** 

 **>>> most_students([['S', ' ', 'S', ' ', 'S', 'S'],\** #2
 **['S', ' ', 'S', 'S', 'S', ' '],\** #2
 **[' ', 'S', ' ', 'S', ' ', ' ']])** #2
 **3** #2
 **'''**
    max_count = 0                          #3
    for row in classroom:                  #3
        count = 0                          #3
        for seat in row:                   #3
            if seat == 'S':                #3
                count += 1                 #3
            else:                          #3
                if count > max_count:      #3
                    max_count = count      #3
                count = 0                  #3
    return max_count                       #3

import doctest                     #4
doctest.testmod(verbose=True)      #4

1 我们给 Copilot 的提示

2 第一个测试用例

3 Copilot 生成的代码

4 我们添加的 doctest 运行代码

既然本章是关于调试的,你可能已经猜到代码并没有正确工作。我们在阅读 Copilot 给我们的代码时发现了这个错误,但它是一个微妙的错误,我们怀疑很容易被忽略。如果你已经发现了这个问题,做得很好,但假装你没有看到它,并继续阅读本章。如果你还没有发现它,那么本章的内容对你会更有价值。

假设我们刚刚编写了这个提示和测试用例。我们通读了代码,发现它似乎是在跟踪最多的连续学生。只要看到座位上有学生,它就会增加计数。当座位上没有学生时,它会检查计数是否比之前的任何计数都大,并重置计数。看起来它至少走在了正确的轨道上。我们加入了一个测试用例,因此我们运行了代码,测试用例通过了。我们对这段代码感觉不错,但知道我们需要做更多的测试用例,特别是用于捕捉边界情况的测试用例(记住,边界情况是那些可能会导致代码出错的不常见情况)。

我们知道在处理列表时,检查代码是否在列表的开始和结束处正确执行是很重要的。为了测试列表的末尾,我们添加了一个测试用例,其中最大的连续学生组包含最后一个座位,然后重新运行代码。以下是我们在文档字符串中添加的新测试用例:

>>> most_students([['S', ' ', 'S', 'S', 'S', 'S'],\    #1
                  ['S', ' ', 'S', 'S', 'S', ' '],\
                  [' ', 'S', ' ', 'S', ' ', ' ']])
4

1 连续学生中最大的组数是 4。

我们再次运行代码,结果测试用例失败,令我们吃惊。以下是它告诉我们的信息(我们为了可读性重新格式化了输出):

Trying:
    most_students([['S', ' ', 'S', 'S', 'S', 'S'],
                   ['S', ' ', 'S', 'S', 'S', ' '],                
                   [' ', 'S’, ' ', 'S’, ' ', ' ']])
Expecting:
    4
**********************************************************************
File "c:\Copilot\max_consecutive.py", 
line 12, in __main__.most_students

Failed example:
    most_students([['S', ' ', 'S', 'S', 'S', 'S'],
                   ['S', ' ', 'S', 'S', 'S', ' '],                
                   [' ', 'S', ' ', 'S', ' ', ' ']])
Expected:
    4
Got:
    3

这很奇怪——代码似乎正常工作。这个边缘情况暴露了错误。在这一点上,我们希望生成一些假设,帮助我们引导调试工作。(如果你确实陷入困境,可以采取在函数的第一行代码处设置断点并逐步调试的方式,而不是尝试创建假设。)以下是我想到的两个假设:

  • count的更新跳过了列表中的最后一个元素。

  • max_count的更新缺少了列表中的最后一个元素。

为了简化调试过程,我们去除了通过的测试(将其放置一旁稍后恢复),并只运行失败的测试。以下代码显示了我们在开始调试过程之前的完整代码。

列表 8.9 我们将调试的代码来计算连续学生数
def most_students(classroom): 
 '''
 classroom is a list of lists
 Each ' ' is an empty seat
 Each 'S' is a student

 Find the most students seated consecutively in a row

 >>> most_students([['S', ' ', 'S', 'S', 'S', 'S'],\ #1
 **['S', ' ', 'S', 'S', 'S', ' '],\** #1
 **[' ', 'S', ' ', 'S', ' ', ' ']])** #1
 **4**
 **'''**
    max_count = 0
    for row in classroom:
        count = 0
        for seat in row:
            if seat == 'S':
                count += 1
            else:
                if count > max_count:
                    max_count = count
                count = 0
    return max_count

import doctest
doctest.testmod(verbose=True)

1 失败的测试用例

我们将从第一个假设开始,即count在列表末尾未正确更新,并在count更新的位置设置断点。图 8.12 显示了调试器在启动后第一次暂停的情况。

figure

图 8.12 调试器在count第一次更新之前停止

从调试器中我们可以看到,count仍然是0,所以它还没有被更新。我们处于第一个测试用例的第一行,因为row['S', ' ', 'S', 'S', 'S', 'S']。我们正在查看的是一个'S',这就是count在增加的原因。让我们点击调试工具栏中的继续按钮,查看count的下一个更新。点击继续后,调试器的状态如图 8.13 所示。

figure

图 8.13 调试器在count第二次更新之前停止

看来自从上次更新count以来发生了不少事情,因为此时max_count的值是1。这一定是在处理空位时发生的,因为max_count被设置为1,并且count被重置为0。此时,我们已经到了这一行的第三个座位,并且有学生坐在那里,count准备好更新了。我们需要检查count是否随着每个新学生的出现而持续更新。我们点击了继续,count增加到1。我们再次点击继续,count增加到2。我们再次点击继续,count增加到3。此时,我们已经到了该行的最后一个学生,接下来要检查count是否增加到4。为此,我们点击了“Step Over”一次,count确实更新为4。你可以在图 8.14 中看到此时调试器的状态。

figure

图 8.14 调试器在count连续四次更新后停下

好消息和坏消息此时都来了。好消息是count正在正确更新。坏消息是我们的第一个假设是错的,我们还没找到我们的 bug。我们可以将断点移到max_count更新的那一行(第 22 行),然后点击重新启动并从头开始调试第二个假设,但考虑到目前在调试器中count的值是4,我们不如继续跟踪代码,确保max_count得到更新。或者说,我们知道它不会更新,所以我们想要看看为什么。

在点击“Step Over”之前,调试器中已经有了一个线索。这个线索来自于下一个要执行的代码行是for seat in row。但我们刚才看到的学生是row中的最后一个学生。这意味着这个for循环即将结束(意味着我们不会再次执行循环体,也就意味着max_count无法更新)。让我们通过点击“Step Over”看看是否真是这样。调试器的状态如图 8.15 所示。

figure

图 8.15 调试器在完成第一行后停止

我们刚刚完成了第一行的处理,但我们从未更新max_count。下一行代码将选择下一个row,之后的代码会将count重新设置为0。我们在没有更新max_count的情况下完成了行的循环,尽管我们找到了一个比当前max_count更大的count。如果你还没看到 bug,建议你继续步进,直到下次max_count更新为止,那时问题可能会更加明显。

代码中的错误在于只有遇到空座位时才会更新max_count。这意味着如果一行以学生结束,那么检查是否应更新max_count的代码将永远不会在该行运行。仔细检查代码后发现,检查是否应更新max_count和更新max_count的操作应该发生在if-else语句之外,或者在count更新后紧接着进行。

这是一个我们可能可以手动修复的问题,因为我们只需要将两行代码移动到更合适的位置。列表 8.10 中的代码是修正后的函数(没有测试或提示)。

列表 8.10 修正后的统计连续学生的函数
def most_students(classroom):
    max_count = 0
    for row in classroom:
        count = 0
        for seat in row:
            if seat == 'S':
                count += 1
                if count > max_count:    #1
                    max_count = count    #1
            else:
                count = 0
    return max_count

1 将 count 与 max_count 的测试移动到更新 count 后面

这段新代码通过了之前旧代码和原始测试未能通过的测试。添加了另一个测试,确保当连续学生的最长组出现在行的开头时,代码仍然有效。现在我们更加确信代码已正确运行。

8.6 使用调试器更好地理解代码

我们猜想你已经对调试器印象深刻了,我们也是。当学生以传统方式学习编程时,很多时间都花在确保学生能够像调试器一样逐步跟踪代码,绘制所有变量的状态,并在每一行执行后更新它们。事实上,网络上甚至有一个免费的工具叫做 Python Tutor [2],它可以创建内存状态的图示,比调试器更易于阅读,帮助新程序员了解代码的执行过程。

无论你喜欢使用调试器,还是想使用像 Python Tutor 这样的工具,我们鼓励你尝试一下自己在书中早些章节写的代码。从我们个人与学习编程的人合作的经验来看,逐行跟踪程序并观察变量的状态如何变化,可能是一次真正启发性的体验,我们希望你也能从中受益。

8.7 调试的注意事项

从与学生的互动中,我们也发现调试对新手学习者来说,可能是一种非常令人沮丧的体验 [3]。在学习编程时,每个人都希望自己的代码能正常工作,找到并修复错误是在代码没有正常工作时花费的时间。有几种方法可以帮助克服这种沮丧。首先,问题分解可以极大帮助你获得正确的 Copilot 代码,而无需进行大量的调试。其次,记住每个人的代码有时也不会工作,包括我们的代码。这是编程过程中很自然的一部分,也是需要一些练习的部分。最后,始终,必须始终,测试你编写的每个函数。我们发现,当学生在调试中卡住时,通常是因为多个函数之间的相互作用中存在错误,导致没有对每个函数进行测试。当这种情况发生时,找到并修复错误是非常困难的。调试相互作用的错误非常令人沮丧,避免这种经历是我们俩都严格测试每个编写的函数的一个重要原因。

好消息是,如果你测试每个编写的函数,并且仔细将问题分解成小而易于管理的步骤,你就不会经常遇到调试的情况。而且,即便遇到调试,你也只是在调试某个函数中的错误,这正是几乎每个程序员都会做的事。通过一些练习,你会掌握这一技能。

8.8 练习

  1. 给定一个 Python 函数,应该计算列表中所有偶数的总和。然而,函数并未正确工作。当你调用该函数并传入列表[1, 2, 3, 4]时,它返回4而不是6
def sum_even_numbers(numbers):
    total = 0
    for number in numbers:
        if number % 2 == 0:
            total += number
        else:
            total = 0
    return total

识别sum_even_numbers函数中的错误,并解释如何修复它。修改代码以修正错误,并使用提供的测试用例验证解决方案。

  1. 2. 你给定了一个 Python 函数,应该查找列表中数字的最大值。然而,函数并未按预期工作。你的任务是确定该函数为何不正确工作,以及如何修复它(提示,考虑输入列表中可能的整数值):
def find_max(numbers): 
    max_number = 0 
    for i in range(0, len(numbers)): 
        if numbers[i] > max_number: 
            max_number = numbers[i] 
   return max_number
  1. 3. 你给定了一个 Python 函数,应该检查一个字符串是否是有效的密码。一个有效的密码必须满足以下标准:

    1. 密码长度必须至少为 8 个字符。

    2. 密码必须包含至少一个大写字母。

    3. 密码必须包含至少一个小写字母。

    4. 密码必须包含至少一个数字。

然而,函数并未正确工作。确定函数的问题所在,并修复它:

def is_valid_password(password):
    if len(password) <= 8:
        if any(char.isupper() for char in password):
            if any(char.islower() for char in password):
                if any(char.isdigit() for char in password):
                    return True
            return False
        return False
    return False
  1. 4. 你正在编写一个 Python 函数,根据一天中的时间向用户打招呼。该函数接受一个小时(0 到 23 之间的整数)作为输入,并返回问候消息(“Good morning”,“Good afternoon”或“Good evening”):
def greet_user(hour):
  # This code has an error!
  if hour < 12:
    greeting = "Good morning"
  else:
    greeting = "Good evening"
  return greeting

你正在考虑使用以下提示来让 Copilot 帮助你修复错误:“我有一个根据时间向用户问候的函数,但它似乎将中午分类为‘Good evening’。你能帮我修复逻辑吗?”该提示提到了问题,但没有准确指出错误所在。看看你是否能修改提供的提示,使其更加具体,并引导 Copilot 提出正确的修复方法。

摘要

  • 调试是一项重要的技能,它包括查找代码中的错误并加以修正。

  • Print语句是了解代码运行情况的一种有效方式。

  • VS Code 调试器是另一种了解代码运行情况的方式,提供了强大的功能来监控变量在代码执行过程中如何变化。

  • 一旦发现错误,有多种方法可以帮助 Copilot 修复错误,但如果失败,你通常也可以直接修复代码。

  • 现在,我们的函数设计流程包括调试,而凭借调试技能,你更有可能编写出你想要的软件。

  • 除了调试外,VS Code 调试器还可以作为学习如何理解代码的重要工具。

第九章:9 自动化繁琐任务

本章内容包括

  • 理解为什么程序员编写工具

  • 确定我们需要编写哪些模块来制作一个给定的工具

  • 自动化清理包含 > > > 符号的电子邮件

  • 自动化处理 PDF 文件

  • 自动化移除多个图片库中的重复图片

假设你负责为 100 个人创建 100 份报告。也许你是老师,需要给每个学生发送一份报告。也许你在 HR 工作,需要给每位员工发送年度评估报告。不管你是什么角色,你面临的都是需要创建这些报告的问题,而且你决定将报告准备为.pdf 文件。你还需要为每份报告准备定制化的封面页,这些封面页是由你的同事(一个平面设计师)设计的。

你和你的同事独立工作,最后,工作完成了。等等,别急。因为现在你必须把每个封面页放到每份报告的开头。

在这一点上,非程序员可能会咬紧牙关,开始手动处理这个任务,将封面页与第一份报告合并,第二个封面页与第二份报告合并,依此类推。这可能需要几个小时。如果他们没有意识到可能有其他方法,非程序员可能会继续推进,直到任务完成。

但现在你是程序员了。而大多数程序员,包括我们俩在内,绝不会继续手动做这种工作。

在本章中,我们将向你展示如何编写程序来自动化繁琐的任务。本章中的第二个例子将自动化“合并封面页和报告”的情况。但我们还会展示其他例子。收到一封被转发了很多次的电子邮件

它看起来

这样吗?

或者你们家有好几部手机,每部手机里都有数百张图片,而你只想把这些图片集中到一个地方,这样就可以归档而不丢失任何内容?在本章中,我们将向你展示如何自动化这样的任务。

9.1 为什么程序员制作工具

有一个程序员常常表达的普遍观点:我们很懒。这并不意味着我们不想做工作,而是说我们不想做重复、无聊、繁琐的工作,因为这些正是计算机擅长的。程序员们会对这种辛苦的工作产生一种类似蜘蛛感应的敏锐感觉。假设 Leo 有几百张照片,他想要删除所有重复的照片。没有人会手动去做这件事。又比如 Dan 需要给他的每个学生发送定制化的邮件。如果学生人数不多也就算了,但如果是几位以上的学生,他绝对不会手动去做这件事。一旦程序员们开始意识到自己反复按相同的键盘或执行同样的步骤,他们就会停下来,编写工具来自动化这一过程。

当程序员谈论工具时,他们是在谈论那些可以节省时间的程序。工具通常不是最终目标,编写工具本身可能感觉枯燥乏味,也不怎么光彩。但一旦我们有了工具,我们就可以用它来节省时间。有时,我们会用工具一次,处理一个特定的任务,然后再也不使用它了。然而,通常情况下,工具会反复派上用场,无论我们是按照原样使用它,还是进行一些小修改。例如,在 Dan 完成每一门课程的教学后,他会使用自己编写的程序将所有学生的成绩整理并提交给学校。他每次都对工具进行小修改——比如改变每个作业的权重——然后 Dan 就可以使用这个略微修改过的工具来完成工作。

使用 Copilot 的一个伟大之处在于,它让编写这些工具变得更加轻松。以下是一位软件工程师对这一点的解释:

我们都知道工具的重要性,知道有效的工具难以创建,也知道管理层并不关心或理解工具的需求……我无法表达现在我每天可以构建两个高质量工具,满足我每个想解决的问题时,编程的感觉是多么根本性的不同。[1]

9.2 如何使用 Copilot 编写工具

正如我们在第五章讨论模块时所学到的那样,有时我们需要使用模块来帮助我们编写所需的程序。有些模块是 Python 内置的。例如,在第五章中,我们使用了内置的 zipfile 模块来帮助我们创建一个 .zip 文件。其他模块则不是内置的,我们需要先安装它们,然后才能使用。

在编写工具时,我们通常会处理一些专业化的数据格式(如 zip 文件、PDF 文件、Microsoft Excel 表格、图片),或者执行一些特定的任务(如发送电子邮件、与网站互动、移动文件)。对于这些大多数情况,我们需要使用某个模块。那么,使用哪个模块呢?它是内置的,还是需要我们安装的?这些是我们需要首先回答的问题。

幸运的是,我们可以使用 Copilot Chat(或 ChatGPT)来帮助我们入门。作为提醒,我们使用 Copilot Chat 功能是因为它已经内置于我们的 Visual Studio Code (VS Code) 集成开发环境(IDE)中,并且 Copilot Chat 可以访问我们当前正在编写的代码,因此它能够将我们正在做的事情融入到它的回答中。

计划是与 Copilot 进行对话,确定我们需要使用哪个模块。一旦我们知道了,并且安装了该模块(如果需要的话),我们就可以开始编写工具的代码了。我们会按照以往的方式进行:编写函数头和文档字符串,然后让 Copilot 为我们填充代码。一旦 Copilot 开始编写代码,我们需要按照之前章节中的相同步骤进行,包括检查代码的正确性、修复错误,甚至可能需要进行一些问题分解。为了将我们的注意力集中在编写自动化任务工具上,我们将尽量减少在这些额外任务上花费的时间。

我们可能可以让 Copilot 或 ChatGPT 为我们编写整个工具,甚至不需要将它放入函数中。不过我们在这里不会这么做,因为我们仍然认为函数的好处是值得的。一个函数将帮助我们记录代码,以便我们知道它的功能,并且如果我们后来决定,比如说,添加额外的参数来改变工具的行为,它也能提供灵活性。

9.3 示例 1:清理电子邮件文本

有时,一封电子邮件被回复并转发多次,结果变得很混乱,某些行前面有很多大于号(>)和空格。以下是我们所说的示例邮件:

嗨,Leo,

Dan——你的自然语言研究有进展吗?

是的!你给我看的那个网站

www.kaggle.com/

非常有用。我在这里找到了一组数据集,收集了

非常感谢

可能对我的研究有用的问答。

谢谢,

Dan

假设你想保存这封电子邮件中的信息以备将来使用。你可能想清理每行开头的 > 和空格符号。你可以手动删除它们——毕竟这封邮件并不长——但不要这么做,因为你现在有机会设计一个通用工具,以后每当你需要执行这个任务时都可以使用它。无论你的邮件有五行、一百行,还是一百万行,都没关系:只需要使用工具,轻松完成。

9.3.1 与 Copilot 对话

我们需要让工具能够处理凌乱的电子邮件,以便它可以清理这些邮件。我们可以通过首先将电子邮件的文本复制到剪贴板(使用操作系统上的复制到剪贴板命令,比如 Ctrl-C)来实现这一点。

每个计算机操作系统都有一个剪贴板,用来存储最后复制的内容。如果你曾经使用过复制粘贴功能,那你就使用过剪贴板。我们将使用剪贴板暂时存储文本,以便我们可以编辑剪贴板中的文本,然后粘贴到需要的地方。

将杂乱的电子邮件文本复制到剪贴板后,我们可以在剪贴板中的文本上运行工具,工具将清理电子邮件并用清理后的版本替换剪贴板中的内容。为了让工具执行合理的操作,我们假设你的剪贴板中有电子邮件文本。该工具将删除每行开头的>和空格字符,以便你可以将清理后的电子邮件粘贴(使用操作系统的粘贴命令,如 Ctrl-V)到你想要的位置。在 Python 中,它需要将剪贴板内容读取到字符串中(即复制剪贴板内容),进行清理,然后将清理后的新文本放回剪贴板(即粘贴到剪贴板),替换掉之前存在的杂乱电子邮件版本。

那么,如何在 Python 程序中访问剪贴板呢?这是我们需要弄明白的,因此这将构成我们与 Copilot 对话的框架。与第八章类似,我们将使用“Human”表示我们输入的提示,使用“GitHub Copilot”表示 Copilot 的回答。你也可以使用 Copilot Chat 功能或 ChatGPT 进行类似的对话。我们将提示输入到 Copilot Chat 窗口。

在与 Copilot 的对话中,我们会广泛探索,然后才会定下一个解决方案。我们会在通过一个示例掌握操作后解释为什么这样做,但在此先说明一下,以便你为这种看似间接的方法做好准备。Copilot 在回答中提供了不少示例代码。在探索过程中,可以浏览这些代码,但不要在其中迷失。我们将在确定方案并进行详细讨论后,再仔细查看代码的具体内容。让我们开始学习吧!

LPHuman 我想编写一个 Python 程序,能够访问剪贴板并让我使用剪贴板进行复制和粘贴。我该怎么做?

chatGptGitHub Copilot 你可以使用 pyperclip 模块在 Python 中访问剪贴板。下面是一个示例程序,演示如何使用剪贴板进行复制和粘贴:

*````*```pypython*````*

*`*import pyperclip*`*

*`*# 复制到剪贴板*`*

*`*pyperclip.copy('Hello, world!')*`*

*`*# 从剪贴板粘贴*`*

*`*text = pyperclip.paste()*`*

*`*print(text)*`*

*```py`*```*```py`*

这个程序首先导入 pyperclip 模块。然后,它使用copy()函数将字符串'Hello, world!'复制到剪贴板。最后,它使用paste()函数将剪贴板的内容粘贴到“text”变量中,并打印出结果。

你可以通过在终端中运行pip install pyperclip来安装 pyperclip 模块。

Copilot 的回答教会了我们如何在 Python 程序中使用 pyperclip 模块与剪贴板进行交互。它提供的示例代码甚至向我们展示了如何从程序中复制内容到剪贴板,并将剪贴板中的内容粘贴到变量(text)中。我们正在取得很大的进展!

响应的最后一段告诉我们如何安装 pyperclip。啊哈!所以 pyperclip 默认并不包含在 Python 中。虽然我们稍后会学习如何安装模块,但最好还是不要担心这个问题。那么,让我们继续讨论,询问 Copilot 是否有更好的解决办法。

LPHuman 有没有一种方法可以在不安装 Python 模块的情况下完成?

chatGptGitHub Copilot 是的,你可以使用 Python 自带的 tkinter 模块来访问剪贴板。下面是一个示例程序,它让你无需安装任何额外的模块就可以通过剪贴板进行复制和粘贴:

*````*```pypython*````*

*`*import tkinter as tk*`*

*`*# 创建一个 Tkinter 窗口*`*

*`*root = tk.Tk()*`*

*`*# 隐藏窗口*`*

*`*root.withdraw()*`*

*`*# 复制到剪贴板*`*

*`*root.clipboard_clear()*`*

*`*root.clipboard_append('Hello, world!')*`*

*`*# 从剪贴板粘贴*`*

*`*text = root.clipboard_get()*`*

*`*print(text)*`*

*```py`*```*```py`*

该程序首先使用Tk()函数创建一个隐藏的 Tkinter 窗口,并通过withdraw()方法将其隐藏。接着,使用clipboard_clear()方法清空剪贴板,并使用clipboard_append()方法将字符串'Hello, world!'添加到剪贴板中。最后,使用clipboard_get()方法获取剪贴板的内容并打印结果。

请注意,这个方法只有在你有图形环境的情况下才有效,例如在 Linux 或 macOS 上的桌面环境,或 Windows 上的 Windows 桌面。如果你在一个仅有终端的环境中运行 Python,你需要使用其他方法来访问剪贴板。

看起来我们得到了一些东西,同时也失去了一些东西。收获是这个 tkinter 模块是内置于 Python 中的。失去的是“这个方法只有在你有图形环境的情况下才有效。”我们在 Python 环境中是否有图形环境呢?虽然我们可以尝试运行代码来找出答案,但这个要求让我们犹豫不决。毕竟,pyperclip 解决方案没有这个要求,所以我们也许不需要它(无论它是什么意思)。让我们再试一次。

LPHuman 有没有不需要安装 Python 模块的其他方法?

chatGptGitHub Copilot 是的,你可以使用 win32clipboard 模块来访问剪贴板,这个模块在 Windows 的标准库中包含。下面是一个示例程序,它让你无需安装任何额外的模块就可以通过剪贴板进行复制和粘贴:

*````*```pypython*````*

*`*import win32clipboard*`*

*`*# 复制到剪贴板*`*

*`*text = "Hello, world!"*`*

*`*win32clipboard.OpenClipboard()*`*

*`*win32clipboard.EmptyClipboard()*`*

*`*win32clipboard.SetClipboardText(text)*`*

*`*win32clipboard.CloseClipboard()*`*

*`*# 从剪贴板粘贴*`*

*`*win32clipboard.OpenClipboard()*`*

*`*pasted_text = win32clipboard.GetClipboardData()*`*

*`*win32clipboard.CloseClipboard()*`*

*`*print(pasted_text)*`*

*```py`*```*```py`*

这个程序首先导入了 win32clipboard 模块。要将文本复制到剪贴板,可以调用 OpenClipboard() 函数打开剪贴板,然后调用 EmptyClipboard() 清除现有数据,最后调用 SetClipboardText() 设置剪贴板上的文本。设置文本后,应调用 CloseClipboard() 关闭剪贴板。

要从剪贴板粘贴,可以调用 OpenClipboard() 打开剪贴板,然后调用 GetClipboardData() 获取剪贴板中的文本。获取文本后,应调用 CloseClipboard() 关闭剪贴板。在这个示例中,程序将文本 "Hello, world!" 复制到剪贴板,然后将其粘贴并打印到控制台。

“Windows 上的标准库,”嗯?这令人担忧,因为这意味着我们的工具可能无法在其他操作系统上运行。让我们来看看这个问题。

LPHuman:这也能在 Linux 或 macOS 上工作吗?

chatGptGitHub Copilot 不,win32clipboard 模块是专门为 Windows 设计的,在 Linux 或 macOS 上无法使用。在 Linux 上,你可以使用 xclip 命令行工具来访问剪贴板。以下是一个示例程序,演示如何在 Linux 上使用剪贴板进行复制和粘贴:

*````*```pypython*````*

*`*import subprocess*`*

*`*# 复制到剪贴板*`*

*`*text = "Hello, world!"*`*

*`*subprocess.run(['xclip', '-selection', 'clipboard'], input=text.encode('utf-8'))*`*

*`*# 从剪贴板粘贴*`*

*`*p = subprocess.Popen(['xclip', '-selection', 'clipboard', '-o'], stdout=subprocess.PIPE)*`*

*`*pasted_text = p.stdout.read().decode('utf-8')*`*

*`*print(pasted_text)*`*

*```py`*```*```py`*

这个程序使用 subprocess 模块来运行 xclip 命令行工具。要将文本复制到剪贴板,可以调用 subprocess.run() 并传入带有 -selection clipboard 选项的 xclip 命令,以指定剪贴板选择,并通过 input 参数传入你想复制的文本。要从剪贴板粘贴,可以调用 subprocess.Popen() 并传入带有 -selection clipboard -o 选项的 xclip 命令来读取剪贴板中的文本,然后使用 Popen 对象的 stdout 属性读取命令的输出。在这个示例中,程序将文本 "Hello, world!" 复制到剪贴板,然后将其粘贴并打印到控制台。

在 macOS 上,你可以使用 pbcopypbpaste 命令行工具来访问剪贴板。使用方法与 xclip 类似。

我们已经获得了一些针对不同操作系统的解决方案。我们不喜欢这样!我们希望我们的工具能够在任何操作系统上都能工作。(即使我们自己只使用 Windows,也许有一天,我们会与使用不同操作系统的其他人分享这个工具。)

我们本可以继续讨论,但回头看,安装那个单独的 pyperclip 模块似乎也没那么糟糕。我们就继续吧。一般来说,在决定如何开始编写工具时,探索你的选项是有价值的,而与 Copilot 的对话有助于进行这种探索。

为什么我们应该探索并理解我们的选项

有几个原因说明你不应该仅仅使用 Copilot 聊天告诉你使用的第一个模块:

  • 它可能无法在所有操作系统上运行。一般来说,我们希望找到在多个操作系统上都能使用的模块,这样无论你(如果以后切换操作系统)还是你分享工具的其他人都能使用该工具。

  • 结果可能是它最终不适用于我们的使用场景,或者我们可能会很难让代码正常运行。如果我们有其他选择,我们可以先尝试那些选项,而不是把太多时间花在一个可能最终无法成功的策略上。

  • 模块会频繁更新。通常,这是为了添加新功能和修复错误,但有时在这个过程中,某些功能也会被移除或更改。当模块的作者移除某个功能时,称为废弃该功能。一旦某个功能被废弃,它可能不再工作,我们需要找到替代品。Copilot 仅会间歇性地进行训练,而不是持续训练,因此,如果在 Copilot 接受训练后有新版本的模块发布,Copilot 就不了解这些更新。这可能导致 Copilot 建议的代码依赖于比你实际拥有的模块版本更旧的版本。我们稍后会在本章中看到解决这个问题的方法。但如果遇到麻烦,拥有其他选项可供探索是非常有用的。

9.3.2 编写清理邮件的工具

我们需要做的第一件事是安装 pyperclip 模块。但是我们该如何安装呢?好消息是,Python 让安装新模块变得非常容易。进入 VS Code 右下角的终端,输入以下命令:

pip install pyperclip

注意  对于某些操作系统,你可能需要使用pip3而不是pip。在 Windows 机器上,如果你按照第二章的安装说明进行安装,我们建议使用pip。在 Mac 或 Linux 机器上,我们建议使用pip3

当你运行这个命令时,你可能会看到其他模块也被安装。别担心:有时模块需要其他模块才能正常工作,Python 会将它们一起安装,这样你就不必自己担心安装这些依赖项。

安装好后,我们可以导入模块,然后编写我们用于删除行首的>和空格字符的函数的头部和文档字符串。我们在这里导入模块的原因是为了影响 Copilot 在为我们编写函数代码时使用该模块。以下是我们使用的提示和 Copilot 提供的代码。

列表 9.1 第一次尝试:清理剪贴板中的邮件文本
import pyperclip

def clean_email():
 '''
 The clipboard contains lines of text.
 Clean up the text by removing any > or space
 characters from the beginning of each line.
 Replace the clipboard with the cleaned text.
 '''
    text = pyperclip.paste()                      #1
    lines = text.splitlines()                 #2
    for i in range(len(lines)):                   #3
        lines[i] = lines[i].lstrip(' >')             #4
    text = '        #5

'.join(lines)       #5

    pyperclip.copy(text)       #6

if __name__ == '__main__':            #7
    clean_email()       #8

1 将剪贴板的当前内容放入文本字符串中

2 将字符串拆分为行的列表

3 遍历每一行

4 从行的开头删除空格和 > 符号

5 这看起来很奇怪。

6 将清理后的文本复制到剪贴板

7 注意一个常见的 Python 约定(参见以下讨论)。

8 调用我们的函数来自动清理剪贴板!

我们可以看到,程序正在按照正确的步骤顺序执行:它获取剪贴板内容,清理文本,然后将清理后的文本复制回剪贴板。正如我们之前与 Copilot 的对话中学到的那样,我们希望 Copilot 编写的代码使用 pyperclippastecopy 函数,而它确实是在这么做。程序中的倒数第二行是我们在本书中从未见过的:

if __name__ == '__main__':

如果你愿意,其实可以删除这一行(如果你删除,也请取消缩进下面的那一行)。它确保 clean_email 函数只有在你运行程序时才会被调用,而不是在你作为模块导入时调用。毕竟,如果你确实想将其作为模块导入(作为更大程序的一部分使用),你会在需要该功能时调用 clean_email,而不一定是在模块导入后立即调用。(而且,一般来说,任何时候你想更深入理解一行代码时,都可以向 Copilot 询问!)不幸的是,这段代码不能正常工作。如果你运行它,你会收到以下错误:

File "C:\repos\book_code\ch9\email_cleanup.py", line 14
    text = '
           ^
SyntaxError: unterminated string literal (detected at line 14)

语法错误意味着我们的程序并不是有效的 Python 代码。我们现在要修复这个问题!我们有几种方式来解决这个问题。一种是高亮显示你的代码并要求 Copilot Chat 修复错误。对我们来说,这确实修复了问题。你也可以尝试向 ChatGPT 提出问题:“为我的代码 <insert your code> 提供修复建议。”这是一个有用的技巧,当 Copilot 返回的代码无法按预期工作时,可以考虑使用。

Copilot 通过修复语法错误的那一行代码为我们修正了程序。新的代码如下所示。

列表 9.2 第二次尝试:清理剪贴板中的电子邮件文本
import pyperclip

def clean_email():
 '''
 The clipboard contains lines of text.
 Clean up the text by removing any > or space
 characters from the beginning of each line.
 Replace the clipboard with the cleaned text.
 '''
    text = pyperclip.paste()
    lines = text.splitlines()
    for i in range(len(lines)):
        lines[i] = lines[i].lstrip(' >')
    text = '\n'.join(lines)        #1

    pyperclip.copy(text)

if __name__ == '__main__':
    clean_email()

1 将各个行重新连接成一个字符串

新的代码行,已经从我们之前那行奇怪的代码改动过来,是

text = '\n'.join(lines)

这一行的目标是将所有文本行连接成一个单一的字符串,程序稍后会将该字符串复制到剪贴板。\n 代表什么?它表示代码中的换行符。join 方法是什么?它将列表中的所有项(行)连接成一个单一的字符串。

我们可以通过稍微实验一下 join 来更详细地了解它是如何工作的。这里是一个使用空字符串而不是 '\n' 字符串的 join 示例:

>>> lines = ['first line', 'second', 'the last line']   #1
>>> print(''.join(lines))           #2
first linesecondthe last line

1 显示包含三行的列表

2 在空字符串上调用 join 方法

注意,有些词语被挤在一起了。这并不是我们想要的——我们需要在它们之间加点东西。怎么加一个空格呢?我们再试试使用 join,这次在字符串中使用空格,而不是空字符串:

>>> print(' '.join(lines))
first line second the last line

或者,我们可以使用 '*'

>>> print('*'.join(lines))
first line*second*the last line

这解决了我们紧凑的词语问题。而且,*s 告诉我们每行的结尾位置,但如果能够保持邮件是三行的事实就更好了。

我们需要在 Python 中找到一种使用换行符或新行字符的方法,而不是空格或 *。我们不能直接按 Enter,因为那样会把字符串分成两行,这不是有效的 Python 语法。解决方法是使用 '\n'

>>> print('\n'.join(lines))
first line
second
the last line

现在我们的工具准备好使用了。如果你把一些杂乱的邮件文本复制到剪贴板,运行我们的程序并粘贴剪贴板,你会看到邮件已经被清理好了。例如,如果我们运行它处理之前的样本邮件,得到的清理后版本如下:

你好,Leo,

Dan —— 你的自然语言研究有进展吗?

是的!你给我看的那个网站

www.kaggle.com/

非常有用。我在上面找到了一个数据集,收集了

很多

可能对我的研究有用的问答数据集。

谢谢,

Dan

当然,我们还可以做得更多。那封电子邮件中的换行不太理想(“a lot”这一行非常短且不必要地短),你可能也想把它清理一下。你可以通过在给 Copilot 的提示中加入新要求,开始进行这些改进。我们在这里停下,因为我们已经完成了初步的邮件清理,但我们鼓励你继续探索更强大的解决方案。

9.4 示例 2:为 PDF 文件添加封面

让我们回到本章开始时的场景。我们已经写了 100 份 .pdf 格式的报告。我们的同事为这些报告设计了 100 个封面,封面也是 .pdf 格式的,我们需要将封面和报告合并,使每个最终的 .pdf 文件都以封面开始,接着是报告内容。图 9.1 提供了所需流程的示意图。

figure

图 9.1 创建合并后的 .pdf 文件的期望过程示意图,方法是将封面目录中的报告封面与报告目录中的报告合并。请注意,报告可能不止一页。
PDF 文件(以及 Microsoft Word 和 Excel 文件)不是文本文件

你可能会想,为什么我们不能像操作 .csv 文件那样,直接使用 Python 的 readwrite 方法来处理 .pdf 文件呢?毕竟,在第二章处理 .csv 文件时我们就是这么做的。

最大的区别在于.csv 文件是文本文件,这些文件是人类可读的,不带格式或特殊命令。然而,许多其他文件格式不是文本文件。例如,.pdf 文件不是人类可读的,需要通过理解.pdf 格式的代码来处理。Microsoft Word 文件和 Microsoft Excel 文件也是如此:它们不是文本文件,所以我们需要专门的工具来处理它们。

在本书的资源中,在 ch9 文件夹内,你会找到两个子文件夹:报告子文件夹包含 100 个报告,格式为.pdf 文件,封面子文件夹包含 100 个封面,格式为.pdf 文件,每个报告都有一个封面。报告的命名方式为 1.pdf、2.pdf、3.pdf,依此类推;对应的封面命名为 cover1.pdf、cover2.pdf、cover3.pdf,依此类推。我们将在本节中将这些报告和封面合并在一起。

打开一些封面.pdf 文件和一些报告.pdf 文件。你可以使用 Adobe Acrobat 等程序,或者用你通常在电脑上打开.pdf 文件的方法。你会注意到封面总是恰好一页,而报告可能不止一页。当我们编写代码时,我们应该注意告诉 Copilot,报告可能有多页;否则,Copilot 可能会假设报告像封面一样只有一页。

你可能会想知道我们是如何创建这些 100 个封面和报告的。如果我们是手动制作它们,一次一个,你一定会感到失望,对吧?别担心,我们自动化了这个创建过程,就像我们接下来将自动化合并过程一样。

9.4.1 与 Copilot 对话

我们需要我们的工具来找到所有报告和封面,将封面添加到每个报告的前面,并生成一个包含封面和报告页面的最终.pdf 文件。我们如何使用 Python 合并.pdf 文件呢?让我们来看看。

LPHuman 我需要编写一个合并.pdf 文件的 Python 程序。我该怎么做?

**chatGptGitHub Copilot 你可以使用 PyPDF2 库在 Python 中合并 PDF 文件。下面是一个示例代码片段,展示了如何合并两个 PDF 文件:

*````*```pypython*````*

*`*import PyPDF2*`*

*`*# 打开需要合并的 PDF 文件*`*

*`*pdf1 = open('file1.pdf', 'rb')*`*

*`*pdf2 = open('file2.pdf', 'rb')*`*

*`*# 为每个文件创建一个 PDF 读取对象*`*

*`*reader1 = PyPDF2.PdfFileReader(pdf1)*`*

*`*reader2 = PyPDF2.PdfFileReader(pdf2)*`*

*`*# 创建一个 PDF 写入对象*`*

*`*writer = PyPDF2.PdfFileWriter()*`*

*`*# 循环遍历每个 PDF 的页面并将它们添加到写入对象*`*

*`*for i in range(reader1.getNumPages()):*`*

*`*page = reader1.getPage(i)*`*

*`*writer.addPage(page)*`*

*`*for i in range(reader2.getNumPages()):*`*

*`*page = reader2.getPage(i)*`*

*`*writer.addPage(page)*`*

*`*# 将合并后的 PDF 写入文件*`*

*`*output = open('merged.pdf', 'wb')*`*

*`*writer.write(output)*`*

*`*# 关闭输入和输出文件*`*

*`*pdf1.close()*`*

*`*pdf2.close()*`*

*`*output.close()*`*

*```py`*```*```py`*

在这个示例中,我们首先使用 open() 函数打开要合并的两个 PDF 文件。然后,我们使用 PyPDF2 中的 PdfFileReader() 函数为每个文件创建一个 PDF 阅读器对象。

接下来,我们使用 PdfFileWriter() 函数创建一个 PDF 写入对象。然后,我们通过每个 PDF 文件的页面,使用阅读器对象的 getNumPages()getPage() 方法,并使用 addPage() 方法将每一页添加到写入对象中。

最后,我们使用写入对象的 write() 方法将合并后的 PDF 写入文件,并使用 close() 方法关闭所有输入和输出文件。

我们有了线索!这个 PyPDF2 模块似乎做到了我们需要的功能。它看起来提供了 PdfFileReader 来读取现有的 .pdf 文件和 PdfFileWriter 来创建新的 .pdf 文件。我们看到代码使用 getPage 从现有的 .pdf 文件中获取页面,并用 addPage 将其写入新 .pdf 文件——非常有前景。有没有什么 Python 内置的功能可以使用,这样我们就不必担心安装 PyPDF2?

LPHuman 是否有方法不需要安装 Python 模块就能做到这一点?

chatGptGitHub Copilot 是的,你可以使用 subprocess 模块来调用像 pdftk 这样的命令行工具来合并 PDF 文件。下面是一个示例代码片段,展示如何使用 pdftk 合并两个 PDF 文件:

*````*```pypython*````*

*`*import subprocess*`*

*`*# 调用 pdftk 合并 PDF 文件*`*

*`*subprocess.call(['pdftk', 'file1.pdf', 'file2.pdf',*`*

*`*'cat', 'output', 'merged.pdf'])*`*

*```py`*```*```py`*

在这个示例中,我们使用 subprocess.call() 函数来调用 pdftk,并传递适当的参数以合并两个 PDF 文件 file1.pdffile2.pdfcat 参数告诉 pdftk 连接输入文件,而 output 参数指定输出文件名为 merged.pdf

请注意,你需要在系统中安装 pdftk 才能使这段代码正常工作。如果你没有安装 pdftk,可以从官方网站下载:www.pdflabs.com/tools/pdftk-the-pdf-toolkit/

这种方法可能最终会有价值,但它并不是一个内置的解决方案。毕竟,最后一段提到我们需要安装一个叫做 pdftk 的新软件!

此时,我们可能会选择使用 PyPDF2 的第一种选项。但让我们继续使用 Copilot,以确保不会错过任何好的选择。

LPHuman 是否有其他 Python 的 PDF 库我应该考虑使用?

chatGptGitHub Copilot 是的,确实有几个其他的 Python 库可以用来处理 PDF 文件。以下是一些流行的库:

  1. ReportLab——一个用于从零开始创建 PDF 文档的库,支持文本、图形和图片。

  2. FPDF——一个用于从零开始创建 PDF 文档的库,支持文本、图形和图片。

  3. PDFMiner——一个用于从 PDF 文件中提取文本和元数据的库。

  4. PyMuPDF——一个用于处理 PDF 文件的库,支持文本、图形和图像。

  5. pdfrw——一个用于读取和写入 PDF 文件的库,支持文本、图形和图像。

每个库都有其优缺点,因此你应选择最适合自己需求的库。如果你只需要合并 PDF 文件,那么 PyPDF2 或 pdftk 可能就足够了。然而,如果你需要更复杂地创建或操作 PDF 文件,那么其他一些库可能会是更好的选择。

最后一段告诉我们,如果我们只需要合并.pdf 文件,使用 PyPDF2 可能已经足够了。但是,让我们通过了解所有这些工具的优缺点来结束这个对话。这样,如果我们在使用 PyPDF2 时遇到问题,就能知道接下来该去哪里寻求帮助。

LPHuman 能总结一下这些库的优缺点吗?

**chatGptGitHub Copilot 当然!以下是我提到的这些库的优缺点总结:

PyPDF2——一个用于处理 PDF 文件的库,支持文本、图形和图像。

优点:

  1. 用于简单任务(如合并 PDF 文件)时易于使用。

  2. 提供良好的文档和示例。

  3. 支持广泛的 PDF 功能。

缺点:

  1. 对像表单和注释这样的高级 PDF 功能支持有限。

  2. 对于大型 PDF 文件可能较慢。

ReportLab——一个用于从头创建 PDF 文档的库,支持文本、图形和图像。

优点:

  1. 在创建复杂 PDF 文档时非常强大和灵活。

  2. 支持广泛的 PDF 功能。

  3. 提供良好的文档和示例。

缺点:

  1. 对初学者来说有较高的学习曲线。

  2. 对于大型 PDF 文件可能较慢。

FPDF——一个用于从头创建 PDF 文档的库,支持文本、图形和图像。

优点:

  1. 轻量且易于使用。

  2. 提供良好的文档和示例。

  3. 支持广泛的 PDF 功能。

缺点:

  1. 对像表单和注释这样的高级 PDF 功能支持有限。

  2. 对于大型 PDF 文件可能较慢。

PDFMiner——一个用于从 PDF 文件中提取文本和元数据的库。

优点:

  1. 在从 PDF 文件中提取文本和元数据时非常强大和灵活。

  2. 支持广泛的 PDF 功能。

  3. 提供良好的文档和示例。

缺点:

  1. 对初学者来说有较高的学习曲线。

  2. 对创建或操作 PDF 文件的支持有限。

PyMuPDF——一个用于处理 PDF 文件的库,支持文本、图形和图像。

优点:

  1. 在处理 PDF 文件时非常快速高效。

  2. 支持广泛的 PDF 功能。

  3. 提供良好的文档和示例。

缺点:

  1. 对初学者来说有较高的学习曲线。

  2. 对创建或操作 PDF 文件的支持有限。

pdfrw——一个用于读取和写入 PDF 文件的库,支持文本、图形和图像。

优点:

  1. 在读取和写入 PDF 文件时非常强大和灵活。

  2. 支持广泛的 PDF 功能。

  3. 提供良好的文档和示例。

缺点:

  1. 初学者面临陡峭的学习曲线。

  2. 处理大型 PDF 文件时可能会比较慢。

不要被其中的“初学者陡峭的学习曲线”警告吓到。如果 Copilot 已经对这个库进行了训练,那么你仍然可能会成功。

根据我们在这里学到的内容,我们可能会去掉 PDFMiner,因为它显然“对创建或操作 PDF 文件的支持有限”,但我们会保留其他所有库,以防我们需要它们。目前,我们将继续使用 PyPDF2,看看效果如何。

9.4.2 编写工具

请继续安装 PyPDF2 模块。注意大小写:这个模块叫做 PyPDF2,而不是 pypdf2、pyPDF2 或其他任何大小写形式。

在我们之前的示例中,我们编写了一个函数来清理剪贴板上的电子邮件。我们不需要向那个函数传递任何信息,因此函数没有参数。在这里,我们的函数需要三个参数:包含报告的目录名、包含封面的目录名,以及我们希望写入最终(合并).pdf 文件的目录名。

在我们的文档字符串中,我们需要解释这些目录的用途,以及目录中文件名的模式。毕竟,Copilot 需要知道报告是如何与封面页匹配的。

尝试 PyPDF2

我们将使用 PyPDF2 Python 库来尝试完成这个任务。回顾一下 Copilot Chat 告诉我们的关于 PyPDF2 库的信息。它提供的示例代码使用了 PdfFileReader 来读取现有的 .pdf 文件,并使用 PdfFileWriter 创建一个新的 .pdf 文件。它还使用了 getPageaddPage 来操作页面。注意这些代码,它们是 Copilot 正在生成正确代码的标志。下面是我们使用的提示和 Copilot 给我们的代码。

列表 9.3 添加封面到 .pdf 文档的函数
import PyPDF2

def add_covers(report_dir, cover_dir, final_dir):
 '''
 report_dir is a directory of reports named 1.pdf, 2.pdf, and so on.
 These files may be one page or more than one page.

 cover_dir is a directory of covers, with one cover per report.
 The filenames in this directory are cover1.pdf, cover2.pdf, and 
 so on. Each of these files is one page.

 Add the cover to the beginning of each report,
 and store all resulting pdfs in final_dir.
 '''
    report_files = os.listdir(report_dir)      #1
    for report_file in report_files:                  #2
        report = open(os.path.join(report_dir, report_file), 'rb')
        report_reader = PyPDF2.PdfFileReader(report)         #3
        report_writer = PyPDF2.PdfFileWriter()                         #4
        for page_num in range(report_reader.numPages):              #5
            report_writer.addPage(  \       #6
            report_reader.getPage(page_num))     #6
        cover = open(os.path.join(cover_dir,                       #7
                     'cover' + report_file), 'rb')       #6
        cover_reader = PyPDF2.PdfFileReader(cover)            #8
        report_writer.insertPage(  \                                #9
                      cover_reader.getPage(0), 0)         #6
        result = open(os.path.join(final_dir, report_file), 'wb') #6
        report_writer.write(result)   #10
        report.close() #6
        cover.close() #6
        result.close() #6

if __name__ == '__main__': #6
    add_covers('reports', 'covers', 'final')   #11

1 获取 .pdf 报告的列表

2 遍历 .pdf 报告

3 我们可以使用 report_reader 来读取报告的页面。

4 我们可以使用 report_writer 将页面写入新的 .pdf 文件

5 遍历报告的每一页

6 将页面添加到我们的新 .pdf 文件中

7 打开与该报告相关的封面

8 我们可以使用 cover_reader 来读取封面。

9 将封面添加到我们新 .pdf 文件的开头

10 保存我们的新 .pdf 文件

11 调用我们的函数来完成所有合并!

小心自动化程序

我们编写的像合并 .pdf 文件这样的程序,可以非常快速地处理成百上千个文件。如果它们没有正确运行,可能会导致文件损坏或丢失。每当我们使用open并且将'w''wb'作为第二个参数时,就意味着我们正在覆盖一个文件。

让我们关注我们程序中第 9.3 列表中的这一行:

result = open(os.path.join(final_dir, report_file), 'wb')

它使用open函数打开一个文件。具体来说,它在final_dir目录中打开当前的report_file文件。open函数的第二个参数'wb'表示我们想以写入模式('w')打开文件,并且文件是二进制文件('b'),而不是文本文件。如果文件不存在,'w'会导致文件被创建。那并不是危险的部分。危险的部分在于,当文件已经存在时,'w'会清空文件的内容,给我们一个空文件来开始写入。现在,如果我们的程序正常工作并且只在final_dir中进行此操作,那么我们是安全的。但是这正是我们在放开程序之前需要仔细验证的部分。

我们强烈建议您首先在一个不重要的小文件目录中进行测试。此外,我们建议将打开文件的代码行(使用'w''wb')改为打印一个无害的输出消息,这样您就能清楚地看到哪些文件将被覆盖或创建。例如,在我们的程序中,我们需要注释掉以下两行:

result = open(os.path.join(final_dir, report_file), 'wb')
report_writer.write(result)

相反,我们将使用print来打印出本来会被创建或覆盖的文件。

print('Will write', os.path.join(final_dir, report_file))

然后,当您运行程序时,您会看到程序计划写入的文件名。如果输出看起来正常——也就是说,程序正好作用于您想要它作用的文件——那么您就可以取消注释实际执行操作的代码。

请小心,并始终保留重要文件的备份!

清单 9.3 中程序的最后一行假设报告目录叫做reports,封面目录叫做covers,最终的.pdf 文件应该存放在final目录中。

现在,创建final目录。它应该和reportscovers目录一起存在。

代码的整体结构对我们来说看起来很有前景:它获取了一份.pdf 报告的列表,然后对于每一份报告,它将这些页面与封面页合并。它使用for循环遍历报告的各个页面,这是一个不错的做法,因为这样可以抓取所有的页面。相比之下,它没有对封面.pdf 文件使用for循环,这也是正确的做法,因为我们知道封面只有一页。

然而,它给我们的第一行代码看起来是使用了一个名为listdir的函数,它位于一个名为 os 的模块中。还有其他几行代码也使用了这个模块。我们需要导入这个 os 模块吗?确实需要!我们可以通过运行代码来证明这一点。如果您运行代码,您会得到一个错误:

Traceback (most recent call last):
  File "merge_pdfs.py", …
    add_covers('reports', 'covers', 'final')
  File " merge_pdfs.py",  …
    report_files = os.listdir(report_dir)
                   ^^
NameError: name 'os' is not defined

我们需要在程序开头添加import os来修复这个问题。更新后的代码如下。

清单 9.4 改进后的函数,用于将封面添加到.pdf 文档
import os          #1
**import PyPDF2**

**def add_covers(report_dir, cover_dir, final_dir):**
 **'''**
 **report_dir is a directory of reports named 1.pdf, 2.pdf, and so on.**
 **These files may be one page or more than one page.**

 **cover_dir is a directory of covers, with one cover per report.**
 **The filenames in this directory are cover1.pdf, cover2.pdf, and so on.**
 **Each of these files is one page.**

 **Add the cover to the beginning of each report,**
 **and store all resulting pdfs in final_dir.**
 **'''**
    report_files = os.listdir(report_dir)
    for report_file in report_files:
        report = open(os.path.join(report_dir, report_file), 'rb')
        report_reader = PyPDF2.PdfFileReader(report)
        report_writer = PyPDF2.PdfFileWriter()
        for page_num in range(report_reader.numPages):
            report_writer.addPage(report_reader.getPage(page_num))
        cover = open(os.path.join(cover_dir, 'cover' + report_file), 'rb')
        cover_reader = PyPDF2.PdfFileReader(cover)
        report_writer.insertPage(cover_reader.getPage(0), 0)
        result = open(os.path.join(final_dir, report_file), 'wb')
        report_writer.write(result)
        report.close()
        cover.close()
        result.close()

if __name__ == '__main__':
    add_covers('reports', 'covers', 'final')

1 我们之前缺少了这个导入。

尽管如此,我们仍然未能摆脱困境。当我们运行我们的程序时,我们得到以下错误:

Traceback (most recent call last):
  File "merge_pdfs.py", line 34, in <module>
    add_covers('reports', 'covers', 'final')
  File "merge_pdfs.py", line 20, in add_covers
    report_reader = PyPDF2.PdfFileReader(report)     #1
                    ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "...\PyPDF2\_reader.py", line 1974, in __init__
    deprecation_with_replacement("PdfFileReader", "PdfReader", "3.0.0")
  File "...\PyPDF2\_utils.py", line 369, in deprecation_with_replacement
    deprecation(DEPR_MSG_HAPPENED.format(old_name, removed_in, new_name))
  File "...\PyPDF2\_utils.py", line 351, in deprecation
    raise DeprecationError(msg)
PyPDF2.errors.DeprecationError: PdfFileReader is  #2
deprecated and was removed in PyPDF2 3.0.0\. Use   #2
PdfReader instead.                                #2

1 在我们的代码中,导致错误的那一行

2 我们不能再使用 PdfFileReader 了 —— 它已经消失了!

我们遇到了一个问题,Copilot 认为:“嘿,让我们使用 PdfFileReader,因为我被训练过这是 PyPDF2 的一部分”,但在 Copilot 受训和我们写作的时间之间,PyPDF2 的维护者已经移除了 PdfFileReader,并用其他东西替换了它(根据错误消息的最后一行,应该是 PdfReader)。如果你读到这本书的时候,这个差异可能已经被修复了,但我们希望假装它仍然混乱,这样我们就可以教你如果未来发生这种情况时应该怎么做。此时,我们有三个选择:

  • 安装 PyPDF2 的早期版本。 错误消息的最后两行告诉我们,我们从 PyPDF2 需要的函数 PdfFileReader 已在 PyPDF2 3.0.0 中被移除。因此,如果我们安装比 3.0.0 早的版本,我们应该可以恢复我们的功能。一般来说,安装库的早期版本并不建议,因为安全问题可能存在于那些后来在更近期版本中修复的版本中。此外,旧版本可能存在已经在较新版本中修复的 bug。值得 Google 一下最近库中的变化,以确定使用旧版本是否安全。在这种情况下,我们已经做了这项工作,看不出使用 PyPDF2 的旧版本存在明显风险。

  • 按照错误消息中的建议自行修复代码。 也就是说,我们会将 PdfFileReader 替换为 PdfReader 并再次运行程序。在这种情况下,我们将被告知其他已弃用的内容,我们需要按照相同的过程进行修复。PyPDF2 的作者告诉我们如何在错误消息中做这些更新,这非常好。为了练习,你可能会喜欢按照错误消息建议的每次更新来进行。我们希望所有错误消息都这么有用,但情况并非总是如此。有时,一个函数将被移除而我们没有任何后路。在这种情况下,考虑下一步可能会更容易。

  • 使用不同的库。 之前,我们向 Copilot 请求了其他可能的 .pdf Python 库的建议,我们收到了许多建议。如果这里的前两个选项不令人满意,我们可以尝试其中之一。

我们将说明如何通过第一选项(使用 PyPDF2 的早期版本)和第三选项(完全使用不同的库)来解决问题并使我们的代码运行。

使用 PyPDF2 的早期版本

当使用 pip install 安装 Python 库时,默认情况下会获取库的最新版本。通常这是我们想要的——最新和最好的——但也可以显式请求库的旧版本。

这里,我们需要一个 PyPDF2 版本低于 3.0.0 的版本。与标准的 pip 使用方法不同,

pip install PyPDF2

我们可以改用

pip install "PyPDF2 < 3.0.0"

< 3.0.0 是我们用来请求版本小于 3.0.0 的库的命令。执行此命令时,应该会输出类似以下内容:

Collecting PyPDF2<3.0.0
  Installing collected packages: PyPDF2
  Attempting uninstall: PyPDF2
    Found existing installation: PyPDF2 3.0.1     #1
    Uninstalling PyPDF2-3.0.1:                  #2
      Successfully uninstalled PyPDF2-3.0.1
Successfully installed PyPDF2-2.12.1          #3

1 我们曾有一个版本 >= 3.0.0 的版本。

2 正在移除该版本……

3 ……并且正在安装版本 2.12.1。

现在我们安装了旧版本的 PyPDF2。此时,你应该能够运行我们的程序。它只需要几秒钟,任务就完成了。你可以检查最终目录,找到新创建的 .pdf 文件。打开其中几个文件,确保程序已经正确处理:每个 .pdf 文件应该以封面页开始,并包含报告的所有页面(如果报告有多页,不只是第一页)。

想想手动做这件事会多么繁琐,而现在我们已经让计算机为我们完成了这项工作。而且,下次我们需要做同样的事情(更多的报告给更多的人)时,我们已经有了一个工具,可以帮助我们快速完成任务。

使用不同的 .pdf 库

现在让我们试着用 PyPDF2 来解决问题,使用另一个库——其中一个是 Copilot 在我们关于 .pdf 库的初始对话中提到的。

我们不会向你详细说明我们如何决定尝试哪个库,但我们会简要回顾我们旅程的亮点。我们首先向 Copilot Chat 询问如何使用 ReportLab Python 模块来合并 .pdf 文件,但这将我们引导回使用 PyPDF2。对于 FPDF,我们得到了相同的回应:我们被告知 FPDF 无法合并 .pdf 文件,但 PyPDF2 可以。然后我们转向询问 PyMuPDF,并且这部分对话很有成效:

LPHuman 如何使用 Python 的 PyMuPDF 库合并 .pdf 文件?

chatGptGitHub Copilot 你可以使用 PyMuPDF 库中的 fitz 模块来合并 PDF 文件。以下是一个示例代码片段,展示了如何使用 PyMuPDF 合并两个 PDF 文件:

*````*```pypython*````*

*`*import fitz*`*

*`*# 合并两个 PDF 文件*`*

*`*pdf_files = ['file1.pdf', 'file2.pdf']*`*

*`*pdf_writer = fitz.Document()*`*

*`*for pdf_file in pdf_files:*`*

*`*pdf_reader = fitz.open(pdf_file)*`*

*`*for page in pdf_reader:*`*

*`*pdf_writer.insert_pdf(page)*`*

*`*pdf_writer.save('merged.pdf')*`*

*`*pdf_writer.close()*`*

*```py`*```*```py`*

这段代码首先创建一个要合并的 PDF 文件列表(file1.pdffile2.pdf)。然后它使用 fitz 创建一个新的 Document 对象,代表合并后的 PDF。它遍历列表中的每个 PDF 文件,使用 fitz 打开,并通过 insert_pdf() 方法将每一页 PDF 文件插入到 pdf_writer 对象中。最后,使用 save() 方法将合并后的 PDF 保存到一个名为 merged.pdf 的新文件中。

您可以修改此代码,以使用 PyMuPDF 向合并的 PDF 中添加任何额外的内容。只需创建一个新的 Page 对象,将内容添加到页面中,然后使用 insert_page() 方法将该页面插入到 pdf_writer 对象中。

这里的示例代码使用insert_pdf将每个 .pdf 文件添加到合并后的 .pdf 文件中。我们也应该关注我们的代码是否使用了insert_pdf。这个库叫做 PyMuPDF,因此我们需要通过以下方式安装它:

pip install PyMuPDF

但我们为什么要使用 import fitz?为什么用 import fitz 而不是 import PyMuPDF?显然,这个库的设计者更喜欢 fitz 而不是 PyMuPDF,所以我们需要使用 fitz。虽然这种情况很少见,但偶尔我们会遇到安装库时使用一个名字,而导入时用另一个名字!

如果在导入时遇到 NameError

如果 Python 提示您 NameError,并且该错误指向您尝试导入的库,那么很可能是您没有成功导入该库。可能是该库实际上没有安装,或者您需要使用不同的名称来导入它。如果您发现已经安装了库,但似乎无法导入它,请使用 Copilot Chat 与我们对话。

现在我们准备生成一些代码。以下清单显示了我们使用的提示和 Copilot 给出的代码。

清单 9.5 改进的函数,用于将封面添加到 .pdf 文档中
import os
import fitz 

def add_covers(report_dir, cover_dir, final_dir):
 '''
 report_dir is a directory of reports named 1.pdf, 2.pdf, and so on.
 These files may be one page or more than one page.

 cover_dir is a directory of covers, with one cover per report.
 The filenames in this directory are cover1.pdf, cover2.pdf, and so on.
 Each of these files is one page.

 Add the cover to the beginning of each report,
 and store all resulting pdfs in final_dir.
 '''
    if not os.path.exists(final_dir):          #1
        os.mkdir(final_dir)                         #2
    for report in os.listdir(report_dir):             #3
        report_path = os.path.join(report_dir, report)
        cover_path = os.path.join(cover_dir, f'cover{report}')
        final_path = os.path.join(final_dir, report)
        cover_doc = fitz.open(cover_path)                   #4
        report_doc = fitz.open(report_path)       #5
        final_doc = fitz.open()                #6
        final_doc.insert_pdf(cover_doc)       #7
        final_doc.insert_pdf(report_doc)       #8
        final_doc.save(final_path)            #9

if __name__ == '__main__':
    add_covers('reports', 'covers', 'final')

1 如果 final_dir 目录不存在,. . .

2 . . . 创建 final_dir 目录。

3 遍历 .pdf 报告

4 用于读取封面

5 用于读取报告

6 用作我们的新输出文件

7 将封面添加到我们的新 .pdf 文件

8 将报告添加到我们的新 .pdf 文件

9 保存我们新的 .pdf 文件

这个程序恰好从一些代码开始,如果输出目录尚不存在,它会创建该目录。之后,步骤与我们早前使用 PyPDF2 的程序类似,尽管每个程序调用的函数不同。(这是有道理的,因为 PyPDF2 和 PyMuPDF 是不同的库,具有不同的功能和特性。)在这两种情况下,我们都使用 listdir 来获取报告 .pdf 文件名的列表。在 for 循环中,我们遍历这些报告;循环中的代码负责创建一个新的 .pdf 文件,其中包含封面,随后是报告。在我们的 PyPDF2 代码中,有一个内层 for 循环,我们需要循环遍历报告的所有页面。在当前的程序中,我们不需要这样做,因为 Copilot 使用了 insert_pdf 函数,它可以一次性将 .pdf 文件插入到另一个 .pdf 文件中(而不是逐页插入)。无论你是选择安装旧版库,还是选择使用其他库,我们都解决了问题,并自动化了本可能非常繁琐的任务。

注意到我们稍微修改了上一章中描述的工作流程,以考虑处理可能帮助您完成任务的不同 Python 模块。修改后的工作流程见图 9.2。

figure

图 9.2 对我们的工作流程的补充,以应对使用不同 Python 模块的情况

9.5 示例 3:合并手机图片库

现在假设你在手机上拍了很多照片。你的伴侣(或兄弟姐妹、父母或孩子)也在他们的手机上拍了很多照片。你们每个人都有成百上千张照片!有时你把照片发给你的伴侣,他们也会把照片发给你,这样你和你的伴侣就有了对方的一部分照片,但并不是全部。

你过了一段时间这样的生活,但老实说,这开始变得有点混乱了。每当你想找一张照片时,半数时候你找不到,因为那是你伴侣在他们手机上拍的照片,他们没发给你。而且,你开始在各处看到很多重复的照片。

然后你有了一个想法:“如果我们把我手机里的所有照片和你手机里的所有照片都拿出来,然后创建一个合并后的照片库!这样我们就能把所有照片都集中在一个地方!”记住,你们两个的手机可能都有数百张照片,所以手动操作是不现实的。我们要自动化这个过程!

为了更精确地描述我们的任务,我们可以说我们有两个图片目录(可以把每个目录想象成一部手机的内容),我们想将它们合并到一个新目录里。图片的常见文件格式是.png 格式,所以我们在这里将处理这些文件。你的手机实际可能使用的是.jpg 格式,而不是.png 格式,但不用担心。如果你愿意,你可以根据我们在这里做的操作,适应其他图片文件格式(或任何其他图片格式)。

在本书的资源中,在 ch9 目录下,你会找到两个子目录,里面是图片文件。这些子目录名为 pictures1 和 pictures2。你可以想象,pictures1 是你手机上的照片(98 张),而 pictures2 是你伴侣手机上的照片(112 张)。我们将把这两个手机目录合并成一个新的目录。

以你在电脑上打开图片或照片的方式打开一些.png 文件。我们生成的图片只是一些随机的形状,但我们在这里编写的程序无论图片里是什么内容,都能正常工作。

一开始我们说过,同一张照片可能出现在两部手机上,所以我们在照片中生成了一些重复的文件。(我们总共有 210 个图片文件,但其中有 10 个是重复的,所以实际上只有 200 张独特的照片。)例如,在 pictures1 目录下,有一个名为 1566.png 的文件,而在 pictures2 目录下,有一个名为 2471.png 的文件。这两张文件是相同的,当我们从两部手机生成文件目录时,我们只想保留其中一张。这里的问题是,这些图片是相同的,尽管它们的文件名不同。

如果两个文件名相同呢?这是否意味着这些图片是相同的?举个例子,注意到每个目录,pictures1 和 pictures2,都有一个叫做 9595.png 的文件。你可能认为文件名相同意味着里面的图片也会是一样的。但不,打开这些图片你会发现它们是不同的!这在现实生活中也可能发生:你和你的伙伴可能各自拍了不同的照片,虽然可能性微小,但你们的手机选择的文件名恰好可能相同。

如果我们不小心,我们可能会把 pictures1 中的 9595.png 复制到新目录,然后再把 pictures2 中的 9595.png 覆盖过去。我们需要确保在复制文件时,不会覆盖掉已经存在的同名不同图片。我们在图 9.3 中提供了这个过程的示例。

figure

图 9.3 示例:将 pictures1 和 pictures2 文件夹中的照片合并到 pictures–combined 文件夹中,并管理重复项的删除
我们处理的是目录,而不是实际的手机

我们假设你已经在电脑上有两个图片目录。这些目录可能来自任何地方,且我们在这里所做的与手机无关。

但是如果你想真正执行这个任务,处理手机上的图片,你首先需要把这些图片传到你的电脑上。你可以用 ChatGPT 讨论如何实现!由于这些细节与不同手机相关,超出了我们使用 Copilot 自动化的范围,所以我们这里不会深入探讨。

9.5.1 与 Copilot 的对话

我们的工具将从 pictures1 和 pictures2 中创建一个合并的图片目录,但不会有重复的图片。如果我们的原始合并图片目录中有相同的图片多次,我们希望在新目录中只保留其中一张。

在这个时候,我们至少有两个问题需要解答:如何使用 Python 复制文件,如何判断两个文件是否是同一张图片?我们可以先向 Copilot Chat 询问如何复制文件:

LPHuman 我需要写一个 Python 程序来复制文件。怎么做?

chatGptGitHub Copilot 你可以使用 Python 中的 shutil 模块来复制文件。以下是一个示例:

*````*```pypython*````*

*`*import shutil*`*

*`*# 用你自己的源路径和目标路径替换这里*`*

*`*source = '/path/to/source/file'*`*

*`*destination = '/path/to/destination/file'*`*

*`*# 复制文件*`*

*`*shutil.copy(source, destination)*`*

*```py`*```*```py`*

这将把文件从源路径复制到目标路径。如果目标文件已经存在,它将被覆盖。

很好。我们可以使用 shutil 模块来复制文件;我们需要记得导入这个模块。Copilot 给出的最后一句话是一个重要的警告。我们应当小心,避免将文件覆盖到已经存在的文件上。接下来,让我们确认这个 shutil 模块是否是 Python 内置的。

LPHuman 这个 shutil 模块是 Python 内置的吗?

chatGptGitHub Copilot 是的,shutil 模块是 Python 内置的模块,因此你不需要额外安装任何东西来使用它。

你可能会疑惑,为什么我们要将文件复制到新的目录中,而不是直接从原始目录移动到新目录。原因是我们希望保留原始目录,以防出现 bug,导致无法将所有文件成功移动到新目录。如果新目录出了问题也无妨,因为原始目录依然完好无损。

这就是关于复制文件我们需要了解的全部内容。现在,让我们继续学习如何判断两个文件是否是相同的图片。

正如我们之前提到的,光凭文件名无法判断图片是否相同。那么我们该怎么做呢?

LPHuman 使用 Python,我如何判断 .png 文件是否相同?

chatGptGitHub Copilot 你可以使用 Python 中的 filecmp 模块来比较两个文件,检查它们是否相同。以下是一个示例代码片段,用于比较两个 .png 文件:

*````*```pypython*````*

*`*import filecmp*`*

*`*# 将这些文件路径替换为实际的 .png 文件路径*`*

*`*# 你想要比较的文件*`*

*`*file1 = '/path/to/file1.png'*`*

*`*file2 = '/path/to/file2.png'*`*

*`*if filecmp.cmp(file1, file2):*`*

*`*print('这些文件是相同的。')*`*

*`*else:*`*

*`*print('这些文件不同。')*`*

*```py`*```*```py`*
```*

这段代码将比较两个文件的内容,如果它们相同,将返回 `True`,如果不同,则返回 `False`。

顺便提一下,Copilot 的最后一句话是错的。那段代码并没有返回 `True` 或 `False`。就像 Copilot 在我们的 Python 文件中可能会生成错误的代码一样,Copilot Chat 也可能会产生错误的解释。

我们的提示没有明确说明需要比较多少个文件。是两个文件?数百个文件?提示中并没有说明。Copilot 告诉我们如果要比较两个文件应该怎么做,这是一种合理的做法。但这并不是我们需要的。我们需要能够在成百上千的文件中判断哪些文件是重复的。这样,我们就能检测到重复的文件,并避免将其复制到新目录中。让我们再试一次。

LPHuman 如果我有数百个 .png 文件,想找到重复的文件——你给的方法还适用吗?

![chatGpt](https://github.com/OpenDocCN/ibooker-python-zh/raw/master/docs/lrn-ai-ass-py-prog-2e/img/chatGpt.png)GitHub Copilot 如果你有成百上千个 .png 文件并想找出重复文件,你可以使用 os 模块遍历目录中的所有文件,并将每个文件与目录中的其他文件进行比较。下面是一个执行此操作的代码示例:

```py
*````*```pypython*````*

*`*import os*`*

*`*import filecmp*`*

*`*# 将此替换为实际的目录路径,其中包含您的 .png*`*

*`*# 文件所在位置*`*

*`*directory = '/path/to/directory'*`*

*`*# 遍历目录中的所有文件*`*

*`*for filename1 in os.listdir(directory):*`*

*`*if filename1.endswith('.png'):*`*

*`*for filename2 in os.listdir(directory):*`*

*`*if filename2.endswith('.png') and filename1 != filename2:*`*

*`*file1 = os.path.join(directory, filename1)*`*

*`*file2 = os.path.join(directory, filename2)*`*

*`*if filecmp.cmp(file1, file2):*`*

*`*print(f'{filename1} 和 {filename2} 是重复的。')*`*

*```py`*```*```py`*

这段代码将遍历目录中的所有文件,并将每个.png文件与目录中的其他.png文件进行比较。如果两个文件相同,它将打印一条消息,表示它们是重复的。请注意,对于包含大量文件的大目录,这种方法可能会比较慢。

外部 for 循环遍历目录中的每个文件。我们如何知道当前文件是否有重复呢?好吧,我们可以遍历目录中的每个文件并将其与当前文件进行比较。这就是内部 for 循环的作用:它使用 filecmp.cmp 来比较当前文件(file1)与每个其他文件(file2)。

我们在提示中没有提到我们关心的是跨多个目录查找重复项,所以 Copilot 在这里只专注于单个目录。如果这个差异成了障碍,我们可以使我们的提示更精确。

Copilot 在这里使用了另外两个模块,os 和 filecmp。我们本可以问 Copilot 这些模块是否是 Python 内置的,但为了节省时间,我们直接告诉你它们是内置模块。

现在我们希望你关注 Copilot 最后一句话:“请注意,这种方法对于包含大量文件的大目录可能会比较慢。” 什么是“慢”?什么是“多”?我们并不知道。

你可能会想让 Copilot 提供一个更好的解决方案,一个不会在“大目录和许多文件”中变慢的解决方案。但是许多程序员不会那样做。在我们尝试(未优化的,看似较慢的)方法之前,优化解决方案往往是一个错误,原因有两个。首先,也许我们的“慢”程序实际上足够快!我们完全可以先试试。其次,更优化的程序通常是更复杂的程序,而且它们可能更难以正确实现。虽然这并不总是如此,但它确实有可能。而且,再说一遍,如果我们的未优化程序已经完成了任务,我们根本不必担心更优化的版本。

现在,如果我们的程序真的太慢,或者你发现自己反复使用这个程序,那么继续与 Copilot 一起工作,寻找更快的解决方案可能是值得的。但目前来说,我们的程序足够用了。

9.5.2 自上而下的设计

这个任务比我们之前的两个任务稍微复杂一些。一方面,我们需要小心不要覆盖新目录中已经存在的文件。另一方面,我们需要先确定哪些文件需要复制(记住,我们只想复制那些在新目录中没有相同文件的文件)。与我们刚完成的.pdf 合并任务相比,我们没有这些额外的考虑。

为此,我们将在这里使用自上而下的设计和问题分解。别担心,这不会是像第七章那样的完整自上而下设计示例。我们这里的任务比那一章中的拼写建议任务要小得多。我们只会做一些简单的自上而下设计,这将帮助 Copilot 为我们提供我们需要的内容。

我们的顶级函数将负责解决我们的整体任务:将 pictures1 和 pictures2 目录中的所有独特图片放入目标目录中。在第三章中,我们学到了应该尽可能地让函数更通用,以便使其在其他任务中也能更有用或更具通用性。在这里,我们一直在考虑将两个图片目录合并在一起。但为什么不合并 3 个、5 个或 50 个目录呢?目录的数量不重要;我们应该能够合并任意数量的目录。

所以,我们不再设计一个顶级函数,它接受两个字符串(目录名称)作为参数,而是让这个函数接受一个字符串列表。这样,我们可以在任意数量的图片目录上使用它。而且,我们仍然可以在两个图片目录上方便地使用它——我们只需传递一个包含两个目录名称的列表。

我们将为顶级函数命名为make_copies。我们需要两个参数:我们刚才讨论过的目录名称列表,以及我们希望所有文件存放的目标目录名称。

这个函数会做什么呢?它将遍历目录列表中的每个目录,然后对于每个目录,它会遍历其中的每个文件。对于每个文件,我们需要判断是否需要复制它,如果需要复制,就进行实际的复制操作。

判断是否需要复制文件,并且可能执行复制操作,这是一个可以从make_copies中拆分出来的子任务。我们将为这个子任务命名函数为make_copy。我们的make_copy函数将接受两个参数:文件名和目标目录。如果文件与目标目录中的任何文件不相同,则该函数将把文件复制到目标目录。

假设我们想把名为 9595.png 的文件从我们的某个图片目录复制到目标目录,但该文件已经存在于目标目录中。我们不想覆盖已经存在的文件,因此需要想出一个新的文件名。我们可以尝试在文件名的.png 部分前面添加一个 (下划线)字符。这样我们就得到了 9595.png。这个文件可能在目标目录中不存在,但如果存在,我们可以尝试 9595__.png、9595___.png,依此类推,直到找到一个在目标目录中不存在的文件名。

生成唯一文件名是一个可以从make_copy函数中拆分出来的任务。我们将把它命名为get_good_filename。它将接受一个文件名作为参数,并返回一个该文件名的版本,该版本在目标目录中不存在。

就这样,我们的自上而下设计完成了。图 9.4 将我们的工作展示为一棵树(好吧,至少是树干),显示了哪个函数由哪个其他函数调用。

figure

图 9.4 图像合并的自上而下设计。最顶部(最左侧)的函数是make_copies,它的子函数是make_copy,而它的子函数是get_good_filename

9.5.3 编写工具

这次我们不需要安装任何模块。我们从与 Copilot 的对话中知道,我们将使用内建的 shutil 模块来复制文件。我们还将使用内建的 filecmp 模块来比较文件,使用内建的 os 模块来获取目录中的文件列表。因此,我们将在 Python 程序的顶部导入这三个模块。

和第七章一样,我们将通过从函数树的底部开始,逐步向上解决问题。我们这么做是为了让 Copilot 在为父函数编写代码时能调用我们已经写好的函数。对于每个函数,我们提供def行和文档字符串,Copilot 编写代码。我们还提供了一些注释来解释代码的工作原理。

再次查看图 9.4,我们看到我们需要实现的第一个函数是get_good_filename。让我们在以下列表中完成它。

列表 9.6 用于我们的图片合并任务的get_good_filename函数
import shutil
import filecmp
import os

def get_good_filename(fname):
 '''
 fname is the name of a png file.

 While the file fname exists, add an _ character
 right before the .png part of the filename;
 e.g. 9595.png becomes 9595_.png.

 Return the resulting filename.
 '''
    while os.path.exists(fname):           #1
        fname = fname.replace('.png', '_.png')      #2
    return fname          #3

1 当文件名存在时 . . .

2 . . . 在.png 之前插入一个 _,通过将.png 替换为 _.png。

3 返回我们现在知道不存在的文件名

我们需要编写的下一个函数是make_copy。这个函数用于将文件复制到目标目录,但仅在该文件与我们已复制的文件不相同时进行复制。我们希望 Copilot 在编写代码时做几件事情:

  • 使用os.listdir获取目标目录中文件的列表。

  • 使用filecmp.cmp来判断两个文件是否相同。

  • 如果没有相同的文件,使用shutil.copy来复制文件。

  • 调用我们刚刚编写的get_good_filename函数。

以下列表展示了我们的提示和 Copilot 提供的代码。请注意,代码做了我们想要它做的一切。

清单 9.7 make_copy 函数用于我们的图片合并任务
def make_copy(fname, target_dir):
 '''
 fname is a filename like pictures1/1262.png.
 target_dir is the name of a directory. #2
 #2
 Compare the file fname to all files in target_dir. #2
 If fname is not identical to any file in #2
 target_dir, copy it to target_dir #2
 ''' #2
    for target_fname in os.listdir(target_dir):      #1
        if filecmp.cmp(fname, os.path.join(  \    #2
                   target_dir, target_fname)):   
            return                                    #3
    shutil.copy(fname, get_good_filename(      #4
            os.path.join(target_dir,  \        #4
                 os.path.basename(fname))))   #4

1 遍历目标目录中的文件

2 如果文件与目标目录中的文件相同,. . .

3 . . . 从函数返回,而没有复制文件。

4 否则,复制文件并使用一个不存在的合适文件名。

只剩下一个函数了,那就是我们的顶级 make_copies 函数。对于我们每个图片目录中的每个文件,我们期望代码调用 make_copy 来复制文件(如果需要),如下所示。

清单 9.8 make_copies 函数用于我们的图片合并任务
def make_copies(dirs, target_dir):
 '''
 dirs is a list of directory names.
 target_dir is the name of a directory.

 Check each file in the directories and compare it to all files 
 in target_dir. If a file is not identical to any file in 
 target_dir, copy it to target_dir
 '''
    for dir in dirs:                         #1
        for fname in os.listdir(dir):              #2
            make_copy(os.path.join(dir, fname),  \   #3
                      target_dir)                    #3

make_copies(['pictures1', 'pictures2'],  #4
             'pictures_combined')        #3

1 遍历我们的图片目录

2 遍历当前图片目录中的文件

3 如果需要,将当前文件复制到目标目录

4 在我们的两个图片目录和给定目标目录上运行程序

Copilot 的最后一行代码,在 make_copies 函数下面,假设我们的目标目录将命名为 pictures_combined。现在创建该目录,使其与 pictures1 和 pictures2 图片目录并列。

正如我们在本章早些时候处理 .pdf 文件时所讨论的,首先在你不关心的样本目录上测试程序是非常重要的。你的样本目录应该只有几个文件,这样你就可以手动判断程序是否按预期工作。你还应该包括一些重要的边界情况,比如在每个目录中都有相同的文件名。

一旦你有了样本目录,你应该创建一个“无害”的版本,该版本仅输出消息,而不实际复制文件。对于我们的程序,你应该将 make_copy 中的行改为使用 print 而不是 shutil.copy

如果输出结果检查无误,只有在此之后,你才应在实际目录上运行真正的程序。记住,我们的程序是复制文件(而非移动文件),所以即使在实际目录中,如果出现问题,很可能是新目录出了问题,而不是我们真正关心的原始目录。

我们假设你现在已经准备好在 pictures1 和 pictures2 目录上运行程序。运行后,你可以检查你的 pictures_combined 目录中的结果。你应该看到该目录下有 200 个文件,正好是我们在两个图片目录中独有的图片数量。我们是否正确处理了同名文件存在于两个图片目录中的情况,但它们是不同的图片?是的,你可以看到我们有名为 9595.png 和 9595_.png 的文件,因此我们没有将一个文件覆盖成另一个文件。

哦,程序在你计算机上运行了多久?最多几秒钟,对吧?事实证明,Copilot 提出的“对于包含许多文件的大目录,这种方法可能很慢”并没有对我们产生影响。

现在,我们都知道人们往往在手机上拥有成千上万张照片,而不是几百张。如果你在两个真实的手机图片库上运行这个程序,你仍然需要确定它是否在可接受的时间内完成。你可以运行程序并让它运行一两分钟,或者等待你愿意等的时间。为了有趣,我们还在总共 10,000 个小图像文件上测试了我们的程序(这比我们在本章中使用的 210 张照片更具现实意义),结果发现它只用了 1 分钟就完成了。在某个时刻,我们的程序会变得过于缓慢,无法实际使用,那时你需要通过 Copilot Chat 进行进一步研究,找到一个更高效的程序。

在本章中,我们成功地实现了自动化三个繁琐的任务:清理电子邮件、为数百个.pdf 文件添加封面,以及将多个图片库合并成一个。每个案例的处理方法都是相同的:使用 Copilot Chat 确定需要使用的模块,然后按照我们在本书中总结的方法,让 Copilot 编写所需的代码。

记住,每当你发现自己在重复同样的任务时,值得尝试使用 Copilot 和 Python 将其自动化。除了本章展示的内容之外,还有许多有用的 Python 模块可以帮助完成此类任务。例如,有用于处理图像、操作 Microsoft Excel 或 Microsoft Word 文件、发送电子邮件、抓取网站数据等的模块。如果是繁琐的任务,很可能有人已经创建了一个 Python 模块来帮助你处理,并且 Copilot 能够帮助你有效地使用该模块。

9.6 练习

  1. 你已经有一个工具,可以通过删除每行开头的>或空格字符来清理电子邮件文本。你将采取哪些步骤来增强此工具,以便也能删除过短的行(例如,删除字符少于五个的行,排除空格)?

  2. 你正在编写一个 Python 程序来清理存储在计算机上的一堆图片,决定使用 Pillow 库(Python 图像库[PIL]的一个分支)。在安装了最新版本的 Pillow 后,你运行程序时遇到了以下错误:

Traceback (most recent call last):
  File "image_cleanup.py", line 4, in <module>
    resized_image = image.resize((new_width, new_height), Image.ANTIALIAS)
                                                          ^^^^^^^^^^^^^^^
AttributeError: module 'PIL.Image' has no attribute 'ANTIALIAS'

你可以采取哪些步骤来解决这个问题?

    1. 你被提供了一个名为 sales_data.xlsx 的 Excel 文件,其中包含不同产品的月度销售数据。你的任务是编写一个 Python 程序,读取销售数据,计算每个产品的总销售额,并将结果写入一个名为 total_sales.xlsx 的新 Excel 文件。sales_data.xlsx 文件包含每个月(1 月、2 月等)的列。

你的程序应执行以下操作:

  1. 销售数据.xlsx中读取数据。

  2. 计算每个产品在所有月份中的总销售额。

  3. 将产品名称及其总销售额写入total_sales.xlsx

提示:对输入文件做合理假设,你可能需要导入一些库来帮助你处理.xlsx 文件。如果你没有 Excel 或 OpenOffice 来读写.xlsx 文件,可以改用.csv 文件来完成任务。

  1. 4. 每天从不同来源寻找新闻文章阅读可能会非常繁琐。你的任务是用 Python 编写一个网页爬虫,从新闻网站提取并显示最新文章的标题和网址。你需要使用 beautifulsoup4 和 requests 模块。

总结

  • 程序员经常创建工具来自动化繁琐的任务。

  • 使用 Python 模块来帮助我们编写工具是很常见的。

  • 我们可以通过 Copilot Chat 来确定应使用哪些 Python 模块。

  • 与 Copilot 交流有助于理解各种可能适用的 Python 模块的优缺点。

  • 有许多 Python 模块可以用来处理剪贴板、.pdf 文件和其他文件格式、复制文件等任务。****

第十章:10 制作一些游戏

本章内容

  • 为我们的程序添加随机性

  • 设计和编程一个破译密码的逻辑游戏

  • 设计和编程一个“押运运气”骰子游戏

人们学习编程的原因有很多。有些人希望像我们在上一章中所做的那样自动化繁琐的任务。有些人希望从事人工智能(AI)的工作(你将在第十一章中体验到)。还有些人希望制作互动网站、安卓或 iOS 应用程序,或者 Alexa 技能。程序员可以制作的东西是无穷无尽的。

另一个学习编程的热门原因是创造游戏。因此,我们决定通过设计两个小型计算机游戏来继续我们的 Copilot 编程旅程。第一个是一个破译密码游戏,玩家通过线索来识别计算机的秘密密码。第二个是一个双人骰子游戏,两个玩家需要平衡风险和运气,在另一个玩家之前达到要求的点数。与使用图形和动画不同,这些游戏使用文本。我们做出这个决定,是为了帮助我们专注于游戏逻辑,而不是游戏的呈现方式或玩家与游戏的互动方式。在此过程中,如果你有兴趣进一步提升你的游戏制作能力,我们也提供了一些后续步骤。别担心,你目前的技能已经是一个很好的起点了!

10.1 游戏程序

如果你考虑和家人或朋友一起玩棋盘游戏,你可以将整个过程分为两个主要阶段。第一个阶段是游戏设置。这包括设置游戏棋盘、为每个玩家分发起始资金或卡牌等。第二个阶段是游戏进行。在棋盘游戏中,通常是一个人轮流行动,如果没有人获胜,另一人再轮流行动。每一轮,游戏状态(棋盘、玩家资金等)都会发生变化。在编写计算机游戏时,我们也需要小心地实现这些阶段。

在许多专门为视频游戏设计的编程环境中,通常有两个分开处理的标准功能。一个是游戏设置功能,用来设置游戏内容,另一个是更新功能,用来在玩家采取行动或时间流逝时改变游戏状态。图 10.1 展示了视频游戏的基本流程。

figure

图 10.1 大多数视频游戏的基本流程

让我们先集中讨论游戏的更新部分。假设玩家可以按下键盘上的一个按钮,在 2D 游戏中向前移动。更新函数将使玩家的角色向前移动,并检查玩家的移动是否使其与游戏世界中的某个元素发生交互。一些游戏还会根据时间变化状态,而不管玩家的行为如何。在这些游戏中,更新函数还会更新游戏本身的状态(例如,在吃豆人游戏中移动幽灵)。在每个时间步骤或用户操作的结束时,游戏将检查结束条件。结束条件通常包括以下内容:

  • 玩家通过完成某个目标赢得游戏。

  • 玩家因掉入悬崖、撞到怪物等原因失去游戏。

  • 玩家选择退出游戏。

有许多编程环境专门针对游戏设计,我们推荐 Python 的 pygame 模块[1]作为一个很好的起点。如果你在此基础上继续深入游戏设计,建议你查看 Unity[2],这是一个用于 3D 游戏设计的工具。然而,在本章中,我们将仅使用标准 Python 来向你展示如何设计一个基本的游戏,而无需深入这些新的环境。

10.2 添加随机性

许多游戏,包括我们在本章中将要制作的两个游戏,都依赖于随机性来增加乐趣。在第一个游戏中,我们需要计算机生成我们将要推断的数字的随机数字。在第二个游戏中,我们需要掷骰子,这实际上就是生成介于一和六之间的随机数字。敌人的行为、谜题的初始朝向或攻击造成的伤害量,也就是随机性,通常涉及游戏设计中的这些以及其他许多方面。

因此,我们需要能够在 Python 程序中生成随机数字。为了学习如何做这一点,我们将像在上一章中一样使用 Copilot Chat。

LPHuman 我需要在我的Python 程序中生成随机数字。我该怎么做?

**chatGptGitHub Copilot 你可以使用 Python 中的 random 模块生成随机数字。以下是一个示例:

*````*```pypython*````*

*`*import random*`*

*`*# 生成一个介于 1 和 10 之间的随机整数(包括 1 和 10)*`*

*`*random_number = random.randint(1, 10)*`*

*`*print(random_number)*`*

*`*# 生成一个介于 0 和 1 之间的随机浮动数*`*

*`*random_float = random.random()*`*

*`*print(random_float)*`*

*```py`*```*```py`*

这段代码导入了 random 模块,然后使用randint函数生成一个介于 1 到 10 之间的随机整数(包括 1 和 10),并使用random函数生成一个介于 0 到 1 之间的随机浮动数。

Copilot 建议使用一个名为 random 的模块中的函数来生成随机数字。让我们检查一下 random 模块是否已经内置于 Python 中。

LPHuman 这个 random 模块是内置于 Python 中的吗?

chatGptGitHub Copilot 是的,random 模块是 Python 的一个内置模块,因此你无需安装任何额外的模块就能使用它。你只需在 Python 代码中导入它并开始使用其函数。

我们将按照 Copilot 的建议导入这个模块,测试我们刚学到的两个函数。我们将在 Python >>> 提示符下进行测试,这是我们在前几章中用来探索 Python 功能的地方。要进入 Python 提示符,按 Ctrl-Shift-P,输入REPL,然后选择 Python: 启动 REPL。

基于 Copilot 对我们第一个问题的回答,我们知道可以使用randint函数生成一个在最低允许数字和最高允许数字之间的随机整数。我们可能会使用这个函数生成可以组合成更大数字的单个数字。每个数字的范围在 0 到 9 之间,所以我们将让randint生成 0 到 9 之间的随机数:

>>> import random
>>> random.randint(0, 9)
5
>>> random.randint(0, 9)
1
>>> random.randint(0, 9)
9                            #1
>>> random.randint(0, 9)
9
>>> random.randint(0, 9)
5
>>> random.randint(0, 9)
0                           #2
>>> random.randint(0, 9)
4

1 我们恰好得到了最大值。

2 我们恰好得到了最小值。

或者,我们可能想要生成骰子的掷点。每次掷骰子的结果在 1 到 6 之间,所以我们可以让randint生成 1 到 6 之间的随机数:

>>> random.randint(1, 6)
2
>>> random.randint(1, 6)
2
>>> random.randint(1, 6)
4
>>> random.randint(1, 6)
1
>>> random.randint(1, 6)
5

Copilot 告诉我们的另一个函数叫做random。(是的,模块和这个函数都叫random!所以,我们需要使用random.random()来调用这个函数。)这个函数不会生成一个随机整数;而是生成一个在 0 和 1 之间的随机小数(不包括 1)。例如,你可能得到的不是像 5 这样的随机数,而是像 0.1926502 这样的随机数。这类带小数的数字被称为浮动数(或浮点数)。下面是一些调用这个函数的例子:

>>> random.random()
0.03853937835258148
>>> random.random()
0.44152027974631813
>>> random.random()
0.774000627219771
>>> random.random()
0.4388949032154501

我们可以想象这个函数对于游戏也会很有用。例如,你可以将这些浮动值视为事件发生的概率,较高的数字对应较高的概率。然后,你可以使用这些浮动值来判断事件是否应该发生。不过,在本章的游戏中,我们不需要这个函数。

10.3 示例 1:公牛与奶牛

我们的第一个游戏将基于一个叫做“公牛与奶牛”的旧破译游戏。它可能让你想起游戏 Wordle(但如果你没玩过 Wordle 也没关系)。我们将能与计算机一起玩这个游戏。随机性在这个游戏中扮演了至关重要的角色,正如我们将看到的那样。

*### 10.3.1 游戏如何运作

在这个游戏中,玩家 1 想出一个秘密代码,这个代码是一个由四个数字组成的序列。玩家 2 必须猜出这个秘密代码是什么。在我们版本的游戏中,计算机将充当玩家 1,而人类玩家将充当玩家 2。

它的工作原理是这样的。计算机会随机选择四个不同的数字(不允许重复数字)作为秘密代码。例如,它可能选择数字 1862。然后,你猜测你认为计算机选择的四个数字是什么。例如,你可能猜测是 3821。

对于每个猜测,你会被告知两件事情。首先,你会被告知你猜测中有多少个数字与秘密代码中对应位置的数字完全匹配。我们将出现在正确位置的数字称为“正确”。假设秘密代码是 1862,你猜测的是 3821。你的猜测和秘密代码中的第二个数字都是 8,所以这是一个匹配。没有其他匹配,因此你会被告知这个猜测中正确的数字数量是 1。

其次,你会被告知在你的猜测中有多少个数字出现在秘密代码的其他位置。我们将出现在秘密代码中但位置错误的数字称为“错误位置”。我们再次使用 1862 作为秘密代码,3821 作为你的猜测。你猜测中的第三个数字是 2。它与秘密代码中的第三个数字不匹配(那是 6),但在秘密代码的其他位置有一个 2。同样,你猜测中的第四个数字是 1。它与秘密代码中的第四个数字不匹配,但在秘密代码的其他位置也有一个 1。总的来说,你的两个数字(1 和 2)存在于秘密代码中,尽管它们不在预期的位置。通过这个猜测,你会被告知有 2 个数字在错误位置。你可以利用这些线索逐渐缩小秘密代码的可能性。

Wordle

如果你玩过 Wordle,你可能会注意到 Wordle 和我们的游戏之间的一些相似之处。Wordle 使用字母,而我们的游戏使用数字,但你在猜测中得到的反馈类型是相似的。在这两种情况下,你都会被告知字母或数字是否在正确的位置或错误的位置。在 Wordle 中,你会得到关于每个字母的线索。例如,如果你猜测的第一个字母是h,你可能会被告知h在单词中,但位置不对。相比之下,在我们的游戏中,你不会被单独告知每个数字的提示,而是通过聚合提示来告知你整个猜测的情况。尽管如此,我们希望你能注意到这些相似之处,以及你正在构建的东西与最近全球流行的游戏现象相似!

我们找到了一款免费的“牛与牛”游戏,你可以在www.mathsisfun.com/games/bulls-and-cows.html玩。我们建议你在继续之前先玩几轮游戏,这样你可以清楚地理解游戏的规则。(注意,他们使用术语来表示正确,使用来表示错误位置。)

在表格 10.1 中,我们提供了一个与游戏的交互示例。我们包括了“评论”列来传达我们的思考和从每个猜测中学到的内容。

表格 10.1 游戏示例
猜测 错误位置 正确 评论
0123 1 0 在答案中,0、1、2、3 中的一个数字存在,但都不在正确的位置。
4567 3 0 在答案中,4、5、6、7 中的三个数字存在,但都不在正确的位置。
9045 0 1 因为答案中包含一个来自 0123 的数字和三个来自 4567 的数字,我们知道 8 和 9 不在答案中。根据之前的猜测,我们知道至少有一个数字 4 或 5 必须在答案中,并且 0 可能在答案中。一个正确的提示意味着 4 或 5 在正确的位置,4 或 5 不在解决方案中,0 不在解决方案中。
9048 0 0 我们知道 8、9 和 0 不在答案中,依据之前的猜测。零个正确和零个错位的提示告诉我们 4 也不在答案中,根据之前的猜测,我们现在知道 5 是最后一位数字。
1290 1 0 回到最初的猜测,我们想知道 1、2 和 3 中的哪一位数字在答案中。我们知道 9 和 0 不在答案中,所以一个错位的提示意味着 1 或 2 在答案中,3 不在答案中。此外,无论 1 或 2 哪个在答案中,它目前的位置是错误的。
6715 2 1 因为 4 不在解决方案中,我们从第二次猜测中得知 5、6 和 7 在答案中。我们的猜测告诉我们 1 不在答案中,6 和 7 的位置错误。由于 1 不在答案中,2 必须在答案中(根据之前的猜测)。因为 5 在最后,并且我们曾在第二和第三位置尝试过 2,但没有正确的提示,2 必须在第一位置。由于我们曾在第一和第三位置尝试过 6,并且都不正确,6 必须在第二位置。这样就剩下 7 在第三位置了。我们猜对了。
2675 0 4 是的,这是正确的。

游戏的挑战在于你有有限的猜测次数,必须在这些次数内成功猜出计算机的秘密代码。在我们表格 10.1 中的例子中,我们用七次猜测猜出了代码 2675。每次猜测后,我们会根据提示中的错位数字和正确数字数量来指导我们的思考。

在我们刚提到的免费版本游戏中,不允许在猜测中多次使用相同的数字。例如,猜测 1231 就不被允许,因为有两个 1。在我们的游戏版本中,我们也会保持这个限制。

10.3.2 自顶向下设计

我们的总体任务是编写一个程序来与计算机一起玩《猜数字》游戏。让我们像在第七章和第九章那样,对这个大任务进行自顶向下的设计。游戏过程中必须发生什么呢?回答这个问题将帮助我们将游戏分解成更小的任务。为了帮助我们完成这一任务,我们根据游戏规则和示例思考了游戏每一步发生的事情。每一个高层步骤都出现在图 10.2 中,所以让我们逐一分析它们。

figure

图 10.2 《猜数字》游戏的步骤

我们将从设置开始。为了让我们能够玩这个游戏,计算机必须随机生成一个秘密代码。我们需要确保秘密代码中没有重复的数字。对我们来说,这听起来像是一个足够复杂且自成一体的任务,因此它应该是一个独立的子任务函数。

在计算机生成秘密代码后,我们可以进入实际的游戏玩法。这时玩家开始进行猜测。我们可能认为可以仅使用input来询问玩家的猜测,从而避免使用一个单独的函数来处理这个问题。但我们确实需要确保玩家输入的数字是正确的,并且他们的猜测中没有重复的数字。单单通过一次input调用无法做到这些,因此我们也会为此创建一个函数。

一旦玩家做出有效的猜测,我们需要弄清楚两件事:有多少数字是正确的,多少数字是位置错误的?我们应该使用一个函数来完成这两个任务吗?或者,也许我们应该有两个函数,一个用于正确的数字,另一个用于位置错误的数字?我们看到每种做法都有很好的理由。如果我们将这两个任务合并成同一个函数,我们可以将玩家反馈集中在一个地方,这可能会让我们更容易确认代码是正确的。另一方面,使用两个独立的函数会让我们更容易分别测试每种类型的反馈(正确或错误),但这也会导致反馈的逻辑分散到两个函数中。我们有些随意地选择在这里使用一个函数,但如果你希望使用两个独立的函数,我们鼓励你在完成本节内容后自己尝试。

让我们总结一下。我们有一个函数来生成计算机的秘密代码。我们有一个函数来获取玩家的下一个猜测。我们有一个函数来获取玩家猜测的正确/错误线索。这些是我们很高兴从顶层函数中拆分出来的三个主要子任务。

还有其他子任务可以拆分吗?我们的顶层函数肯定还有一些工作要做。例如,我们需要检测玩家的猜测是否与秘密代码匹配,并在这种情况下结束游戏。不过,我们觉得不需要为此单独写一个函数。要判断用户的猜测是否等于秘密代码,我们可以使用 Python 的==运算符,它可以直接告诉我们两个值是否相等。而结束游戏,我们可以使用return语句来结束顶层游戏函数,从而停止程序。类似地,如果玩家在用完所有猜测次数后仍未猜中秘密代码,我们需要告诉他们游戏结束,但同样,我们应该能通过少量的 Python 代码做到这一点。因此,我们将在此结束顶层函数,调用三个子任务函数。

当我们在第七章解决拼写建议问题时,我们需要将一个子任务拆分为子子任务。但是在这里,我们的三个子任务将作为单个函数来处理。

例如,让我们再次考虑第一个子任务:生成计算机的秘密代码,且不允许重复数字。我们能从中拆分出任何子子任务吗?也许我们可以有一个函数来检查提议的秘密代码中是否有重复的数字。然后,我们可以继续生成秘密代码,调用我们的子子任务函数,直到它告诉我们没有重复数字为止。这样是可行的,但我们也可以逐位生成秘密代码,并且在一开始就不允许重复数字被加入到代码中。这个后者的方案似乎不需要拆分任何子子任务。

现在让我们考虑第二个子任务:获取玩家的下一个猜测。我们可以拆分出一个子子任务,告诉我们一个猜测是否有效(即,它具有正确的长度并且没有重复)。虽然我们当然可以这么做,但通过在子任务函数内部进行几个检查就能轻松完成这件事。(你是不是又回想起第七章关于有效密码和检测有效密码的例子,其中我们将有效性检查拆分成了一个独立的函数?如果是的话,区别在于检查密码是否有效很可能比我们这里需要的有效性检查要复杂得多。)将其拆分为另一个子子任务当然是可以的,但我们将不这样做。我们已经认为我们的第三个子任务按原样处理就很好,因此我们将在这里停止我们的自顶向下设计。

我们将把顶级函数命名为play。在其中,我们将调用对应于我们刚刚确定的三个子任务的三个函数。我们将第一个子任务(生成计算机的秘密代码)对应的函数命名为random_string,第二个子任务(获取玩家的下一个猜测)对应的函数命名为get_guess,第三个子任务(向玩家提供反馈)对应的函数命名为guess_result。有关这个自顶向下设计的树状图,请参见图 10.3。

figure

图 10.3 《公牛与母牛》游戏的自顶向下设计。最上面(最左边)的函数是play,它调用random_stringget_guessguess_result

10.3.3 参数和返回类型

通常,我们会在自顶向下设计中为每个函数定义参数类型和返回值类型,但我们希望在这里单独讨论,因为这次有一些微妙的方面。例如,你可能已经想象到我们会使用整数来表示秘密代码和猜测;但正如我们稍后会看到的,这并不是最好的选择。在我们编写每个函数之前,我们将做出一些决定,关于如何表示所有函数的数据。

*play函数是我们顶层的函数,也是游戏的起始点。这个函数本来可以不接受任何参数。在函数的代码中,我们必须硬编码密钥是四位数字以及玩家可以获得例如 10 次猜测的事实。但这并不是很灵活。如果我们想玩一个密钥是七位数、玩家可以猜测 100 次的游戏版本怎么办?我们就得进入代码并做出所有必要的更改。因此,为了让游戏更容易配置,我们可以为这个函数提供一些参数。例如,尽管密钥的长度通常是四位数,但我们可以使用一个参数来设置密钥的长度,允许它设定为我们想要的任何值。同样地,玩家可以猜测的最大次数也可以不直接写在函数中,而是作为参数,这样我们就可以很方便地更改它。然后,我们所需要做的就是调用这个函数并传入不同的参数值,来改变游戏玩法,而不必修改函数本身的代码。

使用参数和变量来避免魔法数字

允许猜测次数和密钥位数的数量是我们用来解释代码设计中一个重要原则的好例子。这个原则是,当我们编写代码时,如果一个数字可以是参数或变量,它就应该是。这一原则确保了代码的灵活性。当程序员看到代码中出现数字,而不是友好的名称时,他们称这种数字为“魔法数字”,而我们要避免这种情况。在我们讨论玩家可猜测次数或密钥位数时,如果遵循这一原则,这些数字应该作为参数。在某个时刻,这些参数需要被赋予具体的数值以使代码正常工作,但我们应该尽可能在代码的最高层次为它们赋值(例如,玩家可能在游戏开始时设置这些参数)。

为了帮助遵守这一通用原则,每当你在代码中看到一个原始数字(例如 4),就问问自己,是否可以将它作为参数或变量。大多数情况下,它应该是。

添加这些参数是另一个例子,正如我们在第三章讨论的那样,将函数设为通用而不是不必要地限制性。我们的random_string函数是生成计算机密钥的函数。为什么我们在这个函数名中使用string?难道我们不应该返回一个随机整数,例如 1862 吗?字符串和这个有什么关系呢?

返回一个整数的问题在于,密钥可能以 0 开始。像 0825 这样的密钥是一个完全有效的四位数密钥。但作为整数,0825 是 825,这就没有足够的位数。字符串'0825'仅仅是四个数字字符,因此以'0'开头的字符串没有问题。

除此之外,让我们提前思考一下我们最终需要做的事情——计算机的秘密代码。我们需要将其与玩家的猜测逐位比较,以确定哪些数字匹配。使用字符串的索引可以让我们轻松访问字符串中的每个字符,这正是我们所需要的。访问整数的每个单独数字则更加具有挑战性:整数不支持索引!

所以,我们的random_string函数将接受秘密代码所需的位数作为参数,并返回一个该长度的随机字符串,其中每个字符都是一个数字字符。当我们谈论字符串中的数字时,我们指的只是字符串中的字符。它们恰好是数字字符,但它们仍然是字符,就像'a''*'一样。不要被字符串看起来像一个数字所困扰!这里有一个示例,显示这些字符串与其他字符串的工作方式相同:

>>> s = '1862'
>>> s[0]        #1
'1'
>>> s[1]
'8'
>>> s[2]
'6'
>>> s[3]
'2'
>>> s + 1           #2
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: can only concatenate str (not "int") to str
>>> s + '1'              #3
'18621'

1 我们像往常一样访问字符串的字符。

2 我们不能将字符串和整数相加。

3 这是字符串连接,而不是数字加法。

那么,get_guess呢?这是一个从玩家那里获取下一个猜测的函数。与random_string一样,这个函数需要知道一个有效猜测的位数,所以我们将其设为一个参数。它将返回一个字符串,表示玩家的猜测。

最后,让我们谈谈guess_result,这个函数告诉我们有多少数字是正确的,有多少数字是错位的。这个函数需要玩家的猜测字符串和计算机的秘密代码字符串,以便进行比较,因此我们需要这个函数接受这两个参数。我们需要返回两条信息——正确的数字数量和错位的数字数量——所以我们将返回一个包含两个整数的列表。

10.3.4 实现我们的函数

完成了自上而下的设计后,我们现在可以与 Copilot 一起编写每个函数的代码了。像往常一样,我们将按自下而上的顺序编写函数。这意味着我们将首先实现我们的三个子任务函数,然后最终实现我们最高层次的play函数。

random_string

像往常一样,当使用 Copilot 来生成代码时,我们提供def行和文档字符串,然后 Copilot 编写代码。在每个代码列表中,我们还会提供一些关于代码如何工作的指导。

我们希望random_string函数接受一个秘密代码的位数作为参数,并返回一个没有重复数字的随机秘密代码。在这个函数的代码中,我们可以预期 Copilot 使用random.randint在一个循环中运行,直到我们得到足够的位数。为了避免添加重复的数字,代码会仅在该数字不在代码中时才将一个随机数字添加到秘密代码中。我们在 Copilot 的结果中找到了这样的解决方案,并且我们选择在以下列表中展示它。

列表 10.1 用于生成秘密代码的 random_string 函数
import random           #1

**def random_string(length):**
 **'''**
 **length is an integer.**

 **Return a string of the given length, where each character**
 **is a digit from 0 to 9, and with no repeated digits.**
 **'''**
    s = ''          #2
    while len(s) < length:             #3
        r = random.randint(0, 9)     #4
        if str(r) not in s:        #5
            s += str(r)          #6
    return s           #7

1 不要忘记导入 random 模块。

2 从空字符串(当前没有数字)作为秘密代码开始

3 当我们还需要数字时,继续循环

4 生成一个从 0 到 9 的随机整数

5 如果这个数字还没有出现在秘密代码中 . . .

6 . . . 将此数字添加到秘密代码中。

7 返回秘密代码

本章我们不专注于测试,但即使专注于测试,我们也不会在该函数的文档字符串中包括具体的测试。这是因为这个函数的结果是随机的。随机性会使得测试函数变得困难,因为很难控制你得到的随机值。我们可以做的是在文档字符串中添加一个测试,至少检查生成的秘密代码包含正确数量的字符,没有重复的字符,并且每个字符都是有效的数字。你也可以通过运行这些函数并观察它们的结果来进行测试,确保它们的结果总是在你的随机范围内,并且结果看起来是随机的。

如果你进行了测试,你会发现我们对这个函数的实现完全没有问题,不需要做更多的修改。如果你只是希望游戏能够运行,可以跳到我们开始编写get_guess函数的部分。如果你希望更深入了解 Python 和函数设计,我们愿意借此机会花些时间探讨不同的解决方案。

我们鼓励你查看在按下 Ctrl-Enter 时弹出的窗口中的 Copilot 提议,看看其他可能的方案。例如,我们可以从一个包含所有整数从 0 到 9 的列表开始,然后使用一个名为 random.shuffle 的函数将该列表打乱顺序。然后,我们只需要从打乱后的列表中取出前 length 个整数(其中 length 是秘密代码的长度),这就是秘密代码!我们可以确保不会得到重复的数字,因为我们打乱的原始列表中没有重复的数字。这个版本的函数可能看起来像以下列出的代码。

列表 10.2 random_string 函数的替代解决方案
import random
def random_string(length):
 '''
 length is an integer.

 Return a string of the given length, where each character
 is a digit from 0 to 9, and with no repeated digits.
 '''
    digits = list(range(10))               #1
    random.shuffle(digits)             #2
    return ''.join(str(d) for d in digits[:length])    #3

1 创建列表 [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]

2 随机打乱列表的顺序

3 从打乱后的列表中返回前 length 个数字

如果你想了解这段代码在做什么,你完全有工具去了解!不要认为这段代码是无法理解的。你可以请 Copilot 解释代码,可能得到如下解释。

LPHuman 我的 random_string 函数做了什么?

chatGptGitHub Copilot 这段代码是一个名为 random_string 的 Python 函数,接受一个整数 length 作为输入,返回一个给定长度的字符串,其中每个字符都是 0 到 9 之间的数字,并且没有重复的数字。

函数首先使用 range 函数和 list 构造器创建一个从 0 到 9 的数字列表。然后,使用随机模块中的 shuffle 函数对列表进行洗牌。这样可以确保数字顺序是随机的。

最后,函数使用列表推导式将洗牌后的列表中的前 length 位数字转换为字符串,并使用 join 方法将它们连接成一个单一的字符串。

总体来说,random_string 函数生成一个不重复的随机数字字符串,这对于生成唯一的标识符或密码非常有用。

什么是 列表推导式?你不需要自己弄明白,你可以让 Copilot 解释给你听。

LPHuman:那个列表推导式是怎么工作的?

chatGptGitHub Copilot random_string 函数中的列表推导式用于将洗牌后的列表中的前 length 位数字转换为字符串,并使用 join 方法将它们连接成一个单一的字符串。

以下是列表推导式如何工作的细节:

  1. str(d) 对于 digits[:length] 是生成字符串列表的表达式。它会取 digits 列表中前 length 个元素,将每个数字使用 str 函数转换为字符串,并返回结果字符串。

  2. digits[:length] 是列表推导式操作的可迭代对象。它取 digits 列表中前 length 个元素,这些元素已经被随机洗牌过。

  3. 整个列表推导式被方括号括起来,这样可以从表达式生成的元素中创建一个新列表。

最后,join 方法被调用在生成的字符串列表上,将它们连接成一个没有分隔符的单一字符串。

Copilot 中的一些文本可能不完全能理解,但你可以继续让它解释你不理解的部分,直到你确信你明白它是如何工作的。

我们本不需要展示这个第二个有效解决方案,但我们想借此机会提醒你你已经取得了多大的进展。随着你在本书中的学习,你不断发展了理解和创建软件的工具和技巧,同时也培养了学习和持续学习的信心!回到当前的问题,让我们编写下一个函数。

get_guess

我们的 get_guess 函数接受所需数字的个数作为参数,并从玩家那里返回一个有效的猜测。记住,要使猜测有效,它必须包含正确数量的数字,并且不能有重复的数字。以下是我们从 Copilot 获得的代码。

清单 10.3 函数 get_guess 用于用户输入
def get_guess(length):
 '''
 length is an integer.

 Keep asking the player to enter a string where each character 
 is a digit from 0 to 9, until they enter a valid guess.
 A valid guess has the given length and has no repeated digits.
 '''
    guess = input('Enter a guess: ')                           #1
    while len(guess) != length or not guess.isdigit()\    #2
            or len(set(guess)) != length:                
        guess = input('Enter a guess: ')                #3
    return guess       #4

1 首先尝试从玩家那里获取有效猜测

2 如果长度错误,或者猜测不是全数字或有重复 . . .

3 . . . 随后尝试从玩家那里获取有效猜测

4 返回有效的猜测

那个while-loop条件是个复杂的条件!最后一部分,len(set(guess)) != length,用于检查用户是否输入了重复的数字。它使用了 Python 集合来完成这个任务。我们在第七章第 7.7.7 节中曾见过这个技巧,不过那是很久以前的事了!所以,如果你需要复习一下,不妨请教 Copilot 来解释这一行。

guess_result

我们的guess_result函数接受两个字符串作为参数:玩家最近的猜测和秘密代码。它返回一个包含两个整数的列表:正确数字的数量和位置错误数字的数量。

这是一个非常难以正确实现的函数,因此尽管我们在之前的函数中没有展示我们希望运行的测试,但我们将在这个函数的文档字符串中提供一些明确的测试。我们之所以添加这些文档字符串测试,是因为这个函数的逻辑将足够复杂,仅凭阅读代码很难判断代码是否正确。对于我们的文档字符串测试,我们需要一些不同秘密代码和用户猜测的良好示例案例,以确保函数返回的正确和错误位置的数字数量符合预期。当然,按照第六章所说,如果你有兴趣进行更深入的测试,你可能还想添加更多的测试用例。我们的第一个测试用例是

>>> guess_result('3821', '1862')

这里正确的返回值是[1, 2],因为有一个数字是正确的(8),有两个数字是位置错误的(2 和 1)。我们的第二个测试用例是

>>> guess_result('1234', '4321')

这次正确的返回值是[0, 4],因为没有正确的数字,但猜测的四个数字都被放错了位置。以下是我们的完整文档字符串,包括测试用例,以及 Copilot 的代码。

列表 10.4 函数guess_result用于获取猜测结果
def guess_result(guess, secret_code):
 '''
 guess and secret_code are strings of the same length.

 Return a list of two values:
 the first value is the number of indices in guess where
 the character at that index matches the character at the
 same index in secret_code; the second value is the 
 number of indices in guess where the character at that 
 index exists at a different index in secret_code.

 >>> guess_result('3821', '1862')
 [1, 2]
 >>> guess_result('1234', '4321')
 [0, 4]
 '''
    correct = 0                     #1
    misplaced = 0          #2
    for i in range(len(guess)):             #3
        if guess[i] == secret_code[i]:    #4
            correct += 1        #5
        elif guess[i] in secret_code:     #6
            misplaced += 1             #7
    return [correct, misplaced]      #8

1 这个变量用于正确的数字。

2 这个变量用于位置错误的数字。

3 遍历每个数字字符串的索引

4 这个数字是正确的。

5 将正确的计数增加 1

6 这不是一个匹配项,但被放错了位置。

7 将位置错误的计数增加 1

8 返回一个包含两个结果的列表

这里elif的使用非常微妙。如果它是用if而不是elif写的,那么代码就错了!你看出来为什么吗?如果没有,试着和 Copilot 交谈一下,再继续阅读我们这里的解释。

假设if条件guess[i] == secret_code[i]True。在这种情况下,我们将correct增加 1,并跳过elif语句。(记住,elif语句只有在前面的if和任何前面的elif条件为False时才会执行。)

现在,假设我们将elif改为if。如果if条件guess[i] == secret_code[i]True,我们仍然会将correct增加 1。但接着,我们会检查guess[i] in secret_code条件,而这个条件也会为True。毕竟,我们刚才说了guess[i] == secret_code[i],这证明guess[i]确实在secret_code中某个位置。所以,我们会错误地将misplaced += 1,这显然是我们不希望发生的(它是一个匹配的数字,而不是一个错误位置的数字)!

play

我们已经完成了子任务函数!现在我们只需要编写play顶级函数。

我们的play函数接受两个整数作为参数:秘密代码和猜测的位数,以及玩家用来猜测秘密代码的猜测次数。它不返回任何内容——它只是进行游戏!我们为这个函数提供的最终提示和代码如下所示。

列表 10.5 函数play用于玩游戏
def play(num_digits, num_guesses):
 '''
 Generate a random string with num_digits digits.
 The player has num_guesses guesses to guess the random 
 string. After each guess, the player is told how many 
 digits in the guess are in the correct place, and how 
 many digits exist but are in the wrong place.
 '''
    answer = random_string(num_digits)                         #1
    print('I generated a random {}-digit number.'.format(num_digits))
    print('You have {} guesses to guess the number.'.format(num_guesses))
    for i in range(num_guesses):                        #2
        guess = get_guess(num_digits)            #3
        result = guess_result(guess, answer)          #4
        print('Correct: {}, Misplaced: {}'.format( \   #5
                            result[0], result[1]))     #5
        if guess == answer:            #6
            print('You win!')
            return             #7
    print('You lose! The correct answer was \   #8
                     {}.'.format(answer))       #8

1 生成计算机的秘密代码

2 每个可能的玩家猜测循环一次

3 获取玩家的下一个有效猜测

4 获取该猜测的“正确”和“错误位置”反馈

5 告诉玩家提示

6 玩家正确猜出了秘密代码!

7 退出函数;这结束了游戏。

8 如果我们到这里,说明玩家已经用完了猜测次数。

如果我们现在运行程序,什么也不会发生。那是因为我们还没有调用play函数!我们总是需要记得调用我们的顶级函数。为了调用它,我们可以在play函数下添加这一行:

play(4, 10)

4表示我们正在玩四位数的代码和猜测,10表示我们有 10 次猜测机会来正确猜出秘密代码。你可以更改这些参数,调整游戏的设置。

在继续之前,让我们先试试我们的游戏。我们会展示我们最初的几个猜测,然后是我们的最终猜测:

I generated a random 4-digit number.
You have 10 guesses to guess the number.
Enter a guess: 0123
Correct: 1, Misplaced: 0
Enter a guess: 4567
Correct: 1, Misplaced: 0
Enter a guess: 8901
Correct: 2, Misplaced: 0
Enter a guess: 8902
Correct: 2, Misplaced: 1
...
Enter a guess: 2897
Correct: 1, Misplaced: 3
You lose! The correct answer was 8927.

我们成功设计了一个计算机游戏,这是一种与我们在本书中编写的其他程序非常不同的程序。我们的计算机游戏与用户互动,具有随机性,涉及两位玩家(人类玩家猜测,计算机玩家提供正确/错误位置的线索),并且有胜负条件。这是很多新东西!然而,我们也希望你能看到我们从之前的章节学到了多少并带了多少内容过来。我们依然在做自顶向下的设计,依然在设计函数,依然在测试代码,依然在阅读代码,依然在与 Copilot 交流。你在这一章节之前没有写过游戏,但从本质上讲,你已经具备了做到这一点的技能。不要让看似新的程序或应用类型阻止你尝试去做它。

10.3.5 为“猜数字”添加图形界面

也许您会对我们的游戏看起来不像您最近玩过的游戏感到失望,因为没有图形,只有文本。例如,没有一个漂亮的区域来输入文本,没有按钮可以点击,完全没有图形界面。在本章中,我们已经讨论了为什么专注于文本游戏,但这并不意味着将此游戏转换为图形界面版本完全超出了您的能力范围,因为您可以与 Copilot 交互,看看它是否可以帮助您入门。

挑战在于程序员使用称为事件驱动编程的编程样式创建图形用户界面,我们尚未在本章中教授,并且在本章中无法全部教授。您可以阅读事件驱动编程代码并理解其大致含义,这就是如果要求 Copilot 编写代码生成图形界面时需要做的事情。尽管如此,如果 Copilot 未能提供您想要的结果,则在熟悉事件驱动编程之前可能很难修复。如果您希望了解更多信息,关于使用 Python 进行游戏编程和包括图形用户界面和事件驱动编程的优秀书籍是Invent Your Own Computer Games with Python [3]。

事件驱动编程

事件驱动编程通常用于与用户交互的程序。在高层次上,程序建立了一种用户与程序交互的方式(例如,按按钮或输入文本),然后只是等待用户采取行动,有时在等待用户输入时更新游戏状态。一旦用户与游戏交互,代码将此交互识别为事件,每个事件都有与之关联的代码,在事件发生时应执行。例如,当用户点击退出游戏按钮时,触发需要执行的代码(例如,保存游戏状态并退出程序)。

当我们要求 Copilot 为 Bulls and Cows 创建图形界面时,它给了我们令人印象深刻的结果,因此我们想向您展示它的表现。即使不了解事件驱动编程,代码也相对易读。例如,您可能会找到游戏的标题并更改它。为此,我们向 Copilot Chat 提出了以下问题:

LPHuman 您能将此程序转换为使用图形界面而不是文本界面吗?

Copilot 回应我们,逐步列出了我们需要进行的更改。然后,它重写了代码,使游戏现在使用带有按钮和编辑框的图形界面,而不是文本界面。有关新界面,请参见图 10.4。要生成此界面的代码,请访问该书籍的网站,以便自行运行或了解代码。

figure

图 10.4 由 Copilot 提供的游戏图形界面。请注意,界面中显示了秘密代码,可能是为了帮助测试(你会在玩游戏之前删除它)。

10.4 示例 2:Bogart

我们的第二个游戏将是一个双人骰子游戏。在“牛与牛”游戏中,一个玩家是人类,另一个是计算机。这一次,我们将为两个人类玩家编写一个游戏。随机性再次将在游戏中发挥关键作用。一旦我们完成,你可以与朋友或家人一起玩!

*### 10.4.1 游戏规则

我们将在这里编写的游戏叫做 Bogart,由 Crab Fragment Labs 设计。这是一个为两名玩家设计的骰子游戏。游戏还使用了一个筹码或硬币的锅。不过,由于我们将其实现为计算机游戏,因此显然不需要实际的骰子或筹码。

Bogart

Bogart 游戏由 James Ernest 设计(© 1999 James Ernest 和 Cheapass Games,已获许可;crabfragmentlabs.com/)。你可以免费下载原版游戏的说明书,作为游戏.pdf 文件的一部分:crabfragmentlabs.com/shop/p/chief-herman-1。如果你喜欢这个游戏并且停不下来,我们鼓励你支持 Crab Fragment Labs 所做的工作。感谢他们允许我们在这里使用他们的游戏!

游戏开始时,锅中的筹码为空(没有任何筹码)。随机选择两名玩家中的一位开始游戏。从那里开始,每个玩家轮流进行,直到游戏结束。我们首先解释玩家进行回合意味着什么,然后给出游戏结束时的规则。图 10.5 也提供了游戏流程的概述。

在玩家回合开始时,向锅中添加一个筹码,玩家掷一个骰子。如果掷出的骰子是 1,那么该玩家的回合结束,并且他们不会收集任何筹码。如果掷出的骰子不是 1,那么玩家可以决定是否继续他们的回合。如果他们决定不继续回合,他们将收集锅中的所有筹码(使锅为空)。

figure

图 10.5 Bogart 中的玩家回合

如果他们决定继续回合,那么向锅中添加两个筹码,并且他们掷两个骰子。如果其中一个或两个骰子是 1,那么玩家的回合结束。否则,玩家再次可以决定是否继续他们的回合。

如果他们继续回合,那么他们先掷三个骰子,再掷四个骰子,然后掷五个骰子。(玩家不能跳过某个骰子的数量;他们必须按顺序掷骰,从一个骰子开始。)如果他们掷出了 1,那么回合结束,他们不会收集任何筹码。如果他们决定不继续回合,那么他们将收集锅中的所有筹码。

在这个游戏中,掷出 1 对当前玩家来说是坏消息。当掷一个骰子时,掷出 1 的概率较低,只有 1/6(约 17%)。当掷两个骰子时,掷出至少一个 1 的概率更高,这次大约是 31%。掷三个骰子的概率更高(42%),掷四个骰子的概率更高(52%),掷五个骰子的概率更高(60%)。因此,回合延长得越久,你最终掷出 1 的概率越大,这样你回合什么也得不到。更糟糕的是,如果另一个玩家比你更保守地玩,他们可能会收集到很多你留在锅里的筹码。与此同时,回合延长得越久,锅里的筹码积累得越快,假设你最终成功结束回合,你可以收集到更多的筹码。整个诀窍在于决定什么时候冒险争取更多筹码,什么时候拿走你已经拥有的筹码。

这就是玩家回合的运作方式。玩家轮流进行回合,掷骰子并收集筹码,直到游戏结束。游戏有两种结束方式:

  1. 如果一个玩家收集了 30 个或更多筹码,那么这个玩家就赢了。

  2. 假设一个玩家在他们的回合里先掷一个骰子,然后是两个骰子,接着是三个、四个、五个骰子,而且他们从来没有掷出 1。然后,这个玩家立刻赢了。不管他们收集了多少筹码:如果他们掷了五个骰子,且其中没有一个是 1,他们就赢了。

博加特示例游戏

让我们模拟几回合游戏,确保我们清楚地了解一切是如何运作的。锅开始时是空的。假设玩家 1 是随机选择的第一个玩家。我们往锅里加一个筹码,玩家 1 掷了一个骰子。假设他们掷出了一个 5。现在玩家 1 必须决定是结束回合拿走锅里的一个筹码,还是继续回合。

假设他们继续他们的回合。我们往锅里再加两个筹码;现在锅里有三个筹码。玩家 1 掷了两个骰子。假设他们掷出了一个 4 和一个 2。

他们应该结束回合并拿走三个筹码吗?不,不行。他们想要更多。他们继续他们的回合。我们往锅里再加三个筹码;现在锅里有六个筹码。玩家 1 掷了三个骰子。这次他们掷出了一个 6,一个 5,还有——哦不!——一个 1。玩家 1 的回合结束了。他们没有拿到任何筹码,并且留了六个诱人的筹码在锅里,供玩家 2 收集。

现在轮到玩家 2 了。我们往锅里加一个筹码——现在锅里有七个筹码!——玩家 2 掷了一个骰子。假设他们掷出了一个 2。如果玩家 2 现在结束回合,他们将收集到锅里的七个筹码。这看起来好多啊,所以假设玩家 2 决定结束回合。

现在锅里空了,该轮到玩家 1 了。玩家 1 有很多追赶的任务:他们有 0 个筹码,而玩家 2 有 7 个筹码。我们暂时停在这里,但游戏会继续,直到有一个玩家收集到 30 个或更多筹码,或者他们成功地掷了五个骰子且没有掷出任何 1。

10.4.2 自顶向下设计

正如我们在实现“牛与牛”游戏时所遇到的那样,实现“博加特”是一个大问题,我们需要自上而下的设计。我们现在提供这个设计,但在继续之前,我们鼓励你自己尝试一下。我们推荐这样做,因为我们发现,对于这个游戏来说,有效的自上而下设计是微妙的,原因在于许多元素之间的相互作用。例如,玩家的回合可以以三种方式结束:收集筹码、不收集筹码以及直接获胜。我们需要能够确定发生了哪种情况。另一个例子是,在每个玩家的回合结束后,我们需要切换到另一个玩家——不过,遗憾的是,并非总是这样:如果玩家获胜,我们希望立即停止游戏并宣布他们为胜者,而不是切换到另一个玩家!我们将主要专注于我们成功的自上而下设计,但偶尔也会说明我们做出某些决策的原因,以及其他决策中的错误。

我们将把顶层函数命名为play。以下是我们需要为游戏解决的主要子任务:

  1. 初始化锅,并让玩家 1 和玩家 2 从零筹码开始。这是游戏设置阶段的一部分。

  2. 随机选择玩家 1 或玩家 2 开始游戏,这也是游戏设置的一部分。

  3. 现在进入游戏阶段。在游戏未结束时,执行以下操作:

    1. 打印锅中的筹码数量,玩家 1 的筹码数量,以及玩家 2 的筹码数量。

    2. 执行当前玩家的完整回合。

    3. 如果当前玩家赢得了筹码,将筹码给当前玩家,并将锅中的筹码重置为零。

    4. 切换到另一位玩家的回合。

  4. 打印出获胜玩家的名字(玩家 1 或玩家 2)。

在本书的这一部分,我们推测你已经足够熟悉来自 Copilot 的代码,能够直观地理解哪些任务需要单独的函数。任务 1 只是一些变量赋值,因此我们不需要为此创建单独的函数。其他不需要单独函数的任务有任务 2(只是调用了random.randint),任务 3a(只是几个print调用),和任务 4(一个print调用)。我们将把剩余的每个子任务封装在一个函数中。以下子部分描述了剩余子任务的具体内容。

任务 3。游戏未结束时

我们将有一个while循环,它在游戏未结束时持续进行,因此我们需要一个函数来判断游戏是否结束!这个函数如何知道游戏是否结束呢?它需要知道玩家 1 和玩家 2 当前各自的筹码数量。这样,它就可以检查是否有玩家的筹码至少为 30。不过记住,游戏结束的另一种方式是当玩家掷出五个骰子,其中没有一个是 1。因此,这个函数还需要知道当前玩家最近的骰子投掷结果。

我们将把这个函数命名为game_over。它将接受三个参数:玩家 1 的筹码、玩家 2 的筹码和骰子掷出的列表。若游戏结束则返回True,否则返回False。该函数的代码需要检查一些条件,但我们应该能够在不拆分为更多子任务的情况下完成。

任务 3b:为当前玩家进行完整回合

我们将把这个函数命名为take_full_turn。该函数需要知道目前锅中有多少筹码,以便在需要时进行更新。它还需要返回更新后的锅中筹码数量。除此之外,我们需要管理很多事情才能执行完整的回合,因此我们需要控制这个函数的复杂性。下面是我们认为这个函数可能需要执行的操作:

  1. 允许玩家先掷一个骰子,然后是两个骰子,接着是三个骰子,依此类推,直到玩家的回合结束。

  2. 根据本回合发生的情况更新当前玩家的筹码数量。我们可以添加一个额外的返回值,将这个更新的信息传递给调用此函数的对象。

  3. 确定游戏是否结束。我们可以添加一个额外的返回值,其中True表示游戏已结束,False表示游戏尚未结束。

我们最初尝试让这个函数完成这三项任务,但未能从 Copilot 那里得到令人满意的代码。这并不令人意外,因为我们要求这个函数做的事情太多了。于是我们将注意力集中在这个函数的核心任务上,即第 1 点。

但是好吧,如果我们只关注第 1 点,那么我们将如何更新当前玩家的筹码数量(第 2 点),以及我们如何知道游戏是否结束(第 3 点)?对于第 2 点,我们想到的解决方案是,在这个函数中不更新当前玩家的筹码数量,而是返回回合结束后锅中筹码的总数。

例如,如果锅中有 10 个筹码,而这次玩家的回合产生了 6 个筹码,那么我们将返回 16。玩家是否能收集到这 16 个筹码—这取决于他们的回合结束方式—但我们在这里不讨论这一点(这是由调用函数来处理的)。

对于第 3 点(判断游戏是否结束),我们的解决方案是让函数将最近的骰子掷出列表作为返回值的一部分。(调用此函数的函数还将需要这个掷出列表来处理第 2 点。)调用此函数的函数可以使用这些掷出结果来判断游戏是否结束。

总结来说,我们的函数将接受锅中筹码的数量作为参数,并返回一个包含两个值的列表:玩家回合结束后锅中筹码的新数量,以及最近一次的骰子掷出列表。

为了进行完整的回合,我们需要能够掷骰子:首先是一颗骰子,然后是两颗骰子,接着是三颗骰子,以此类推。我们将这部分拆解成一个名为roll_dice的函数。该函数将以骰子的数量作为参数,并返回一个骰子结果列表。例如,如果我们要求该函数掷三颗骰子,返回的可能是[6, 1, 4]

我们还需要根据最近的骰子结果列表来判断回合是否结束。如果玩家掷出了任何 1,或者掷出了五颗骰子且没有一颗是 1,那么回合结束。我们将这部分也拆解成一个名为turn_over的函数。该函数将以骰子结果列表为参数,如果回合结束,则返回True,否则返回False

如果回合没有结束,那么我们需要问玩家是否希望继续他们的回合。我们会要求玩家给出是(y)或否(n)的回答。我们可以通过调用input函数来实现。如果玩家想继续进行回合,我们可以再次调用roll_dice。我们不需要一个单独的函数来获取用户输入,因此不再进一步拆解这一部分。如果我们想要验证用户的输入(拒绝任何不是 y 或 n 的回答),这会增加足够的复杂性,可能会导致我们将其拆解出来。

总结一下,我们为take_full_turn函数分解了两个子任务:roll_diceturn_over。这些函数不需要进一步拆解。对于roll_dice,我们可以在循环中使用random.randint来生成我们需要的骰子结果。而turn_over将包括对骰子结果的几次检查,以确定是否结束了本轮,无需进一步拆解。

任务 3c。如果当前玩家赢得了筹码

当玩家的回合结束时,我们将得到新筹码数和结束回合的最终骰子结果列表。我们需要判断玩家是否获得了这些筹码。(如果最终的骰子结果列表中没有 1,则玩家获得筹码;否则,他们没有获得筹码,筹码留在了奖池中。)

我们将拆解出一个函数来判断玩家是否获得筹码。我们将其命名为wins_chips。该函数将以最近的骰子结果列表为参数,如果玩家赢得了筹码,则返回True,否则返回False

任务 3d. 切换到另一个玩家的回合

我们将这个函数命名为 switch_player。假设当前玩家刚刚完成了他们的回合。我们需要切换到另一个玩家的回合,但前提是游戏还没有结束。我们的函数将封装这一逻辑。它需要调用 game_over 来判断游戏是否结束,因此 switch_player 至少需要三个参数:玩家 1 的筹码,玩家 2 的筹码,以及最近一次的骰子掷出结果列表。我们还需要一个参数来指示当前玩家(1 或 2),这样我们就可以返回下一个切换到的玩家编号。因此,函数将接受这四个参数,并返回 1 或 2,表示我们切换到的下一个玩家。

如果游戏已经结束,这个函数不会做任何事情。如果游戏没有结束,那么我们需要将 1 改为 2 或将 2 改为 1。我们已经有了 game_over 函数,这对于拆解这个函数已经足够了。

我们已经完成了自顶向下的设计!请查看图 10.6,查看我们的函数树。

figure

图 10.6 Bogart 游戏的自顶向下设计

10.4.3 实现我们的函数

现在是时候与 Copilot 一起编写每个函数的代码了。和往常一样,我们将从叶子函数开始,逐步实现自顶向下的 play 函数,只有当其他所有函数都实现之后,我们才会实现顶层的 play 函数。我们可以从任何一个叶子函数开始。我们将从 game_over 函数开始,因为它同时被 playswitch_player 需要。然后,我们将继续实现其他函数。

game_over

这个函数接受三个参数:玩家 1 拥有的筹码数量,玩家 2 拥有的筹码数量,以及最近一次的掷骰结果列表。如果游戏结束,它返回 True,如果没有结束,则返回 False,如下所示的列表所示。

列表 10.6 中的 game_over 函数,位于 Bogart 中
def game_over(player1, player2, rolls):
 '''
 player1 is the number of chips that player 1 has.
 player2 is the number of chips that player 2 has.
 rolls is the last list of dice rolls.

 Return True if the game is over, False otherwise.

 The game is over if player1 has at least 30 chips, 
 or player 2 has at least 30 chips, 
 or there are 5 rolls none of which is a 1.
 '''
    return player1 >= 30 or player2 >= 30 or \       #1
            (len(rolls) == 5 and not 1 in rolls)     #1

1 游戏结束的三种方式

记住,游戏有三种方式结束:玩家 1 至少有 30 个筹码,玩家 2 至少有 30 个筹码,或者某个玩家在最后一次回合中成功掷出了五个骰子且没有掷出 1。

直接返回布尔值

你可能预期代码应该是这样,使用 if-else 和实际的 return Truereturn False 语句:

if player1 >= 30 or player2 >= 30 or (len(rolls) == 5
        and not 1 in rolls):
    return True
else:
    return False

这样也可以,但更常见的是程序员直接使用 return 语句与 True/False 表达式一起使用。它之所以有效,是因为表达式的结果就是返回的内容。如果表达式为真,返回 True,如果表达式为假,返回 False。这与 if-else 版本做的事情完全一样!

roll_dice

这是一个用于掷骰子并为游戏添加随机性的函数。它以要掷的骰子数量为参数,并返回骰子掷出的列表。我们可以预期 Copilot 会在这里使用 random.randint,如列表 10.7 所示。

除了返回投掷结果列表之外,如果这个函数还能够打印每次投掷的结果,那就更好了。这样玩家就能准确地看到他们投掷了什么。在文档字符串中,我们要求 Copilot 打印投掷结果(除了返回投掷结果列表之外)。

示例 10.7 roll_dice 函数在 Bogart 中
import random
def roll_dice(n):
 '''
 Create a list of n random integers between 1 and 6.
 Print each of these integers, and return the list.
 '''
    rolls = []                   #1
    for i in range(n):          #2
        roll = random.randint(1, 6)      #3
        print(roll)                   #4
        rolls.append(roll)      #5
    return rolls      #6

1 骰子投掷列表(1 到 6 之间的整数),初始为空

2 循环 n 次,每次投掷一次

3 使用 “randint” 生成一个 1 到 6 之间的随机整数

4 打印投掷结果,供玩家查看

5 将投掷结果添加到投掷列表中

6 返回投掷结果列表

turn_over

这个函数接受最近的投掷结果列表,并用它来判断当前玩家的回合是否结束。如果回合结束,返回 True,否则返回 False,如下所示的示例。

示例 10.8 turn_over 函数在 Bogart 中
def turn_over(rolls):
 '''
 Return True if the turn is over, False otherwise.

 The turn is over if any of the rolls is a 1, 
 or if there are exactly five rolls.
 '''
    return 1 in rolls or len(rolls) == 5      #1

1 回合结束的两种方式

玩家回合结束有两种方式。第一种是投掷结果中出现了 1。第二种是玩家投掷了五个骰子。

len(rolls) 是否为 == 5 真的是回合结束的标志吗?我们难道不需要检查是否有投掷到 1 吗?不,如果玩家投掷了五个骰子,那么他们的回合就结束,无论骰子的结果如何。如果他们投掷了 1,那么回合结束(且他们不会收集任何筹码)。如果他们没有投掷到 1,那么回合仍然结束(而且在这种情况下他们将自动获胜)。

take_full_turn

我们现在可以使用 take_full_turn,如示例 10.9 所示。这个函数接受当前奖池中的筹码数量作为参数。它将处理当前玩家完整回合中的所有投掷结果,并返回一个包含两个值的列表:奖池中新筹码的数量和最终的投掷结果列表。

示例 10.9 take_full_turn 函数在 Bogart 中
def take_full_turn(pot_chips):
 '''
 The pot has pot_chips chips.

 Take a full turn for the current player and, once done, 
 return a list of two values:
 the number of chips in the pot, and the final list of dice rolls.

 Begin by rolling 1 die, and put 1 chip into the pot.
 Then, if the turn isn't over, ask the player whether 
 they'd like to continue their turn.
 If they respond 'n', then the turn is over.
 If they respond 'y', then roll one more die than last time, 
 and add 1 chip to the pot for each die that is rolled.
 (for example, if 3 dice were rolled last time, then 
 roll 4 dice and add 4 chips to the pot.)
 If the turn is not over, repeat by asking the player again 
 whether they'd like to continue their turn.
 '''
    rolls = roll_dice(1)      #1
    pot_chips += 1                       #2
    while not turn_over(rolls):                   #3
        keep_going = input('Continue? (y/n) ')      #4
        if keep_going == 'y':                       #5
            rolls = roll_dice(len(rolls) + 1)         #6
            pot_chips += len(rolls)           #7
        else:
            break                  #8
    return pot_chips, rolls    #9

1 投掷一个骰子

2 向奖池中添加一个筹码

3 当当前玩家的回合没有结束时 . . .

4 . . . 询问玩家是否要继续他们的回合。

5 如果玩家确实想继续他们的回合 . . .

6 . . . 比上次多投掷一个骰子。

7 向奖池中添加新筹码

8 跳出 while 循环

9 返回奖池中的筹码数量和最终的投掷结果列表

wins_chips

这个函数接受投掷结果列表作为参数。如果投掷结果有效(即没有 1),玩家将收集筹码。如果投掷结果中包含任何 1,玩家将无法收集筹码。这个函数返回 True 如果玩家能够收集筹码,否则返回 False,如下所示的示例。

示例 10.10 wins_chips 函数在 Bogart 中
def wins_chips(rolls):
 '''
 Return True if the player wins chips, False otherwise.

 The player wins the chips if none of the rolls is a 1.
 '''
    return not 1 in rolls      #1

1 当没有 1 出现时返回 True,否则返回 False

switch_player

这个函数接受四个参数:玩家 1 拥有的筹码数,玩家 2 拥有的筹码数,最近一轮的掷骰子列表(由当前玩家掷出),以及当前玩家的编号。如果游戏没有结束,这个函数返回另一个玩家的编号。如果游戏结束了,函数则返回当前玩家(因为另一个玩家已经没有回合了!)。请参见以下代码清单。

清单 10.11 博加特中的switch_player函数
def switch_player(player1, player2, rolls, current_player):
 '''
 player1 is the number of chips that player 1 has.
 player2 is the number of chips that player 2 has.
 rolls is the last list of dice rolls.
 current_player is the current player (1 or 2).

 If the game is not over, switch current_player to the other player.
 Return the new current_player.
 '''
    if not game_over(player1, player2, rolls):     #1
        if current_player == 1:                #2
            current_player = 2
        else:
            current_player = 1
    return current_player        #3

1 如果游戏未结束…

2 …从 1 切换到 2,或者从 2 切换到 1。

3 返回新的当前玩家

play

我们已经完成了play函数!我们决定不使用任何参数来编写这个函数。你可能会想,如果我们添加一个参数来指定赢得游戏所需的筹码数(目前是 30),代码会不会更灵活呢?当然可以这样做;我们在这里没有这样做,以保持代码中参数的数量较小。这个函数不返回任何内容,而是像下面的清单所示那样进行游戏。

清单 10.12 博加特中的play函数
def play():
 '''
 Play the game until the game is over.

 The pot starts with 0 chips, and each player starts with 0 chips.

 Randomly decide whether player 1 or player 2 goes first.

 Before each turn, print three lines of information:
 1\. The number of chips in the pot
 2\. The number of chips that each player has
 3\. Whether it is player 1's turn or player 2's turn

 Take a full turn for the current player. 
 If they won the chips, add the chips in the pot to the 
 total for that player and reset the pot to have 0 chips.

 Then, switch to the other player's turn.

 Once the game is over, print the current player 
 (that's the player who won).
 '''
    pot_chips = 0      #1
    player1 = 0        #1
    player2 = 0        #1
    current_player = random.randint(1, 2)      #2
    rolls = []
    while not game_over(player1, player2, rolls):     #3
        print('Pot chips:', pot_chips)               #4
        print('Player 1 chips:', player1)              #4
        print('Player 2 chips:', player2)            #4
        print('Player', current_player, 'turn')      #4
        pot_chips, rolls = take_full_turn(pot_chips)     #5
        if wins_chips(rolls):                           #6
            if current_player == 1:                   #7
                player1 += pot_chips                       #8
            else:
                player2 += pot_chips          #9
            pot_chips = 0                              #10
        current_player = switch_player(player1, player2,  #11
                                       rolls, current_player)
    print('Player', current_player, 'wins!')   #12

1 初始化底池和玩家,开始时都没有筹码

2 随机决定由玩家 1 或玩家 2 开始游戏

3 当游戏没有结束时…

4 …打印当前游戏状态。

5 让当前玩家进行他们的回合

6 如果当前玩家赢得筹码…

7 …然后是玩家 1 的回合…

8 …给玩家 1 从底池中拿筹码;…

9 …否则,给玩家 2 从底池中拿筹码。

10 重置底池,底池现在有 0 筹码

11 切换到另一个玩家的回合

12 打印出谁赢了

现在我们拥有了游戏所需的所有代码。只需要添加

play()

在你现有的代码下方调用我们的play函数,然后你就能开始游戏了!

自定义游戏

我们对 Copilot 给出的结果很满意,游戏也确实可以玩。但是,老实说,与玩家的互动可以做得更具用户友好性。例如,以下是当我们运行游戏并按下 y 几次后,游戏开始时可能会出现的情况:

底池筹码:0

玩家 1 的筹码:0

玩家 2 的筹码:0

玩家 2 的回合

4

继续吗?(y/n) y

5

2

继续吗?(y/n) y

3

1

4

底池筹码:6

玩家 1 的筹码:0

玩家 2 的筹码:0

玩家 1 的回合

2

继续吗?(y/n)

没有欢迎信息。像 4、5、2 这样的数字只是出现在那里,毫无解释它们的含义。游戏问我们是否要“继续吗?(y/n)。”但是,继续什么呢?并不明确。

我们可以通过增加更多的print调用来使与玩家的互动更加愉快。我们可以直接在每个print语句中放入我们想要的内容。为什么不直接使用 Copilot 呢?实际上,直接做可能比让 Copilot 准确打印我们想要的内容要简单。例如,以下清单展示了一个新版的play函数,我们增加了更多的print调用来解释发生了什么并更好地格式化游戏输出。

清单 10.13 博加特中格式更好的play函数
def play():
 '''
 Play the game until the game is over.

 The pot starts with 0 chips, and each player starts with 0 chips.

 Randomly decide whether player 1 or player 2 goes first.

 Before each turn, print three lines of information:
 1\. The number of chips in the pot
 2\. The number of chips that each player has
 3\. Whether it is player 1's turn or player 2's turn

 Take a full turn for the current player. 
 If they won the chips, add the chips in the pot to the
 total for that player
 and reset the pot to have 0 chips.

 Then, switch to the other player's turn.

 Once the game is over, print the current player 
 (that's the player who won).
 '''
    pot_chips = 0
    player1 = 0
    player2 = 0
    current_player = random.randint(1, 2)
    rolls = []

    print('Welcome to Bogart!')       #1
    print()                            #2

    while not game_over(player1, player2, rolls):
        print('Pot chips:', pot_chips)
        print('Player 1 chips:', player1)
        print('Player 2 chips:', player2)
        print('Player', current_player, 'turn')
        pot_chips, rolls = take_full_turn(pot_chips)
        if wins_chips(rolls):
            print('Player', current_player, 'gets',  #3
                  pot_chips, 'chips!')                 #3
            if current_player == 1:
                player1 += pot_chips
            else:
                player2 += pot_chips
            pot_chips = 0
        current_player = switch_player(player1, player2, 
                                       rolls, current_player)

        print()
        print()
        print('-=' * 20)     #4
        print()

    print('Player', current_player, 'wins!')

1 打印欢迎信息

2 打印空行

3 打印消息,说明当前玩家获得了筹码

4 将每个回合与下一个回合分开

我们鼓励你在其他地方也添加 print 调用,以使游戏互动更加生动!例如,在 roll_dice 中,你可以添加一个 print 调用,告诉玩家即将掷骰(掷骰将在下面的输出中显示)。在 take_full_turn 中,你可以添加一个 print 调用,在询问玩家是否要继续回合之前,打印出底池中的筹码数量。以下是我们做出这些修改后,游戏互动的样子:

欢迎来到博加特!

底池筹码:0

玩家 1 的筹码:0

玩家 2 的筹码:0

玩家 2 回合

掷骰

2

底池当前有 1 个筹码。

继续回合吗?(y/n)y

掷骰

6

3

底池当前有 3 个筹码。

继续回合吗?(y/n)y

掷骰

1

1

6

-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=

底池筹码:6

玩家 1 的筹码:0

玩家 2 的筹码:0

玩家 1 回合

掷骰

5

底池当前有 7 个筹码。

继续回合吗?(y/n)

尝试对你的代码做类似的修改。让游戏变成你的作品!

在本章中,我们使用 Copilot 帮助我们编写了两个计算机游戏:一个逻辑游戏(类似于 Wordle,但用数字),以及一个两人掷骰游戏。我们能够做到这一点,利用了我们在整本书中培养的技能,包括问题分解、编写清晰的文档字符串和与 Copilot Chat 的互动。

10.5 练习

  1. 以下 guess_result 函数中存在一个错误,导致正确和错误位置数字的计数不正确。找出并修复该错误:
def guess_result(guess, secret_code): 
   correct = 0 
   misplaced = 0 
   for i in range(len(guess)): 
      if guess[i] == secret_code[i]: 
  correct += 1 
     if guess[i] in secret_code: 
         misplaced += 1 
return [correct, misplaced]
  1. 2. 修改本章中的 get_guess 函数,以包含错误消息,指导玩家输入有效的猜测。确保这些消息清晰且有帮助。发挥创意!以下是一些示例:

    1. “错误:猜测必须仅包含数字。”

    2. “错误:猜测不得包含重复的数字。”

  2. 3. 为什么我们选择将数字个数和猜测次数作为参数传递给 play 函数来玩“公牛与犊牛”游戏?这个决策如何增强了游戏的灵活性?

  3. 4. 实现一个简单的掷骰游戏,玩家掷两个骰子。如果总和为 7 或 11,玩家获胜。如果总和为 2、3 或 12,玩家失败。对于任何其他总和,玩家继续掷骰,直到他们要么匹配最初的掷骰结果(获胜),要么掷出 7(失败)。仔细思考一下你如何将这个主要问题分解成更小的子问题!

  4. 5. 实现一个博加特游戏版本,其中一名玩家是人类,另一名是计算机。为此,你需要制定一些规则,供计算机在结束其回合时使用。例如,当计算机在回合中已积累的筹码数量足够赢得游戏时,计算机肯定会选择停止掷骰子!此外,当计算机能在当前回合赚取大量筹码时,它也可能会选择停止掷骰子。

    1. 价格对决游戏节目是一个很好的小游戏来源,可能会很有趣实现。例如,你可以尝试实现名为“任何数字”的游戏:

priceisright.fandom.com/wiki/Any_Number

在你的游戏中,玩家应该继续选择数字,直到揭示出某个奖品的所有数字;这就是玩家赢得的奖品。

    1. 使用 Pygame 库创建一个简单的游戏。在这个游戏中,玩家控制一个角色左右移动,收集掉落的物体。目标是尽可能多地收集物体而不漏掉它们。你可以灵活设计游戏中的物体外观,所以尽情发挥吧!作为一个潜在的扩展:你能想到如何记录玩家的最高得分吗?

总结

  • 游戏有一个共同的程序流程,包括游戏设置和游戏玩法。

  • 随机性是许多游戏中的一个重要元素。

  • 我们可以通过使用 random 模块中的函数为我们的 Python 游戏添加随机性。

  • 我们可以使用与本书中相同的工作流,通过问题分解发挥关键作用,利用 Copilot 实现游戏。*****

第十一章:11 创建一个作者身份识别程序

本章内容包括

  • 使用自顶向下设计编写一个作者身份识别程序

  • 学习重构代码以及为什么要进行重构

在第七章中,我们通过编写拼写建议程序学习了问题分解和自顶向下设计。在这里,我们将自顶向下设计提升到一个新的层次,解决一个更大的问题。我们依然在做与第七章相同的事情:将一个问题分解成子问题,必要时再将这些子问题分解成更小的子问题。而且,就像之前一样,我们希望设计出具有少量参数的函数,并且这些函数能返回一个有意义且对调用者有用的结果。如果我们能设计出被多个其他函数调用的函数,那就更好了——这有助于减少代码重复!

我们包括这一章是因为我们想提供一个比第七章中我们解决的拼写建议问题更真实的例子。我们希望这里的例子能够激励你,并让你感觉这是一个你自己也能想象并希望解决的实际问题。

在本章中,我们将编写一个程序,试图识别一本神秘书籍的未知作者。这将是一个使用人工智能(AI)来进行预测的程序示例。我们无法抗拒在一本关于编程与人工智能的书中加入一个 AI 示例!

11.1 作者身份识别

这个问题基于我们的同事 Michelle Craig[1]创建的一个作业。让我们先来看一下这两段书籍摘录:

  • 摘录 1——我尚未向你们描述最独特的部分。大约六年前——确切地说,是 1882 年 5 月 4 日——《泰晤士报》上刊登了一则广告,寻求玛丽·莫尔斯坦小姐的地址,并表示如果她能够现身,将对她有利。广告上没有附上姓名或地址。那时我刚刚进入塞西尔·福雷斯特夫人家中,担任家庭教师。在她的建议下,我在广告栏中发布了我的地址。就在同一天,一只小纸板盒通过邮寄送到我这里,盒子里装着一颗非常大且光泽明亮的珍珠。盒中没有附带任何文字。自那时以来,每年同一日期,总会出现一个类似的盒子,里面有一颗类似的珍珠,且没有任何关于寄件人的线索。经过专家鉴定,它们属于一种稀有种类,且价值不菲。你们可以自己看,这些珍珠非常美丽。

  • 片段 2——这是十一月的一个星期五晚上,道佛路上的景象,正是此历史事件中的第一位与此有关系的人出现之前。对他来说,道佛路延伸至道佛邮车前方,当邮车轰隆轰隆地驶上射手山时,他走在泥泞的山路上,与其他乘客一样;不是因为在这种情况下他们特别喜欢走路,而是因为山路、马具、泥巴和邮车太重,以至于马车已经停了三次,甚至有一次试图把车拉到路边,意图带着车回到布莱克希思。尽管如此,缰绳、鞭子、车夫和警卫的组合,还是遵循了那条禁止某些野兽拥有理性目的的战争条款;于是,马车队终于屈服,恢复了职责。

假设我们问你,这两个片段是否很可能是同一个作者写的。你可能做出的一个合理假设是,不同的作者写作风格不同,而这些差异会在我们可以计算的文本指标中表现出来。

例如,无论是谁写的第一个片段,与第二个片段相比,似乎使用了更多的短句。从第一个片段中,我们可以看到像“没有附上姓名或地址”和“没有写的字条”这样的短句;而这些句子在第二个片段中是没有的。同样,第一个片段中的句子似乎比第二个片段的句子更简单;看看第二个片段中那些逗号和分号。

这个分析可能会让你相信这些文本是由不同的作者写的,事实上,它们确实是。第一个片段是由阿瑟·柯南·道尔爵士写的,第二个片段则是由查尔斯·狄更斯写的。

公平地说,我们完全是精挑细选了这两个片段。道尔确实使用了一些长而复杂的句子。狄更斯确实使用了一些短句。但总的来说,至少对于我们从中提取的两本书而言,道尔写的句子比狄更斯短。更一般来说,如果我们看两本不同作者写的书,我们可能会发现在某些可量化的方面存在平均差异。

假设我们有一堆书籍,且我们知道它们的作者。我们有一本是由道尔写的,一本是由狄更斯写的,等等。然后,一本神秘的书出现了。哦不!我们不知道它是谁写的!它是道尔失落的《福尔摩斯故事》吗?还是狄更斯失落的《雾都孤儿》续集?我们想弄清楚那位未知的作者是谁,为此,我们将使用一种基本的人工智能技术。

我们的策略是为每位作者想出一个签名,使用我们知道他们写的其中一本书。我们将这些签名称为已知签名。每个签名将捕捉关于书籍文本的度量,如每句平均单词数和句子的平均复杂度。接着,我们将为那本作者未知的神秘书籍想出一个签名。我们将这个签名称为未知签名。然后,我们将查看所有已知签名,将每个与我们的未知签名进行比较。我们将使用与未知签名最接近的那个作为我们对未知作者的猜测。

当然,我们并不清楚未知作者是否真的是我们已有签名的其中一位作者。比如说,可能是一个全新的作者。即使未知作者确实是我们已有签名的其中一位作者,我们仍然可能猜错。毕竟,可能同一位作者会以不同的风格写书(这会导致他们的书籍有非常不同的签名),或者我们根本没能捕捉到每位作者写作时最具代表性的特点。事实上,我们在这一章并非要开发一个行业级的作者身份识别程序。尽管如此,考虑到这一任务的难度,我们认为我们将在这里展示的方法取得的效果仍然非常令人印象深刻。

机器学习

我们在这里进行的作者身份识别是一个机器学习(ML)任务。机器学习是人工智能的一个分支,旨在帮助计算机“从数据中学习”,从而进行预测。机器学习有多种形式;我们在这里使用的是有监督学习。在有监督学习中,我们可以访问训练数据,这些数据由对象及其已知类别(或标签)组成。在我们的案例中,对象是书籍文本,而每本书的类别是写这本书的作者。我们可以通过计算每本书的特征——如每句平均单词数、句子平均复杂度等——在训练集上进行训练(即学习)。之后,当我们得到一本作者未知的书时,我们可以利用在训练中学到的内容来进行预测(或猜测)。

11.2 使用自顶向下设计的作者身份识别

好吧,我们想“编写一个程序来确定一本书的作者”。这看起来是一个艰巨的任务,如果我们尝试一次性完成,使用一个单独的函数来解决,确实会很困难。但就像在第七章的拼写建议例子中一样,我们并不打算这么做。我们将系统地将这个问题分解为可以解决的子问题。

在第七章中,我们通过使用读取输入、处理输入并生成输出结果的模型解决了拼写建议问题。我们可以认为我们的作者身份识别程序也遵循了这个模型:

  • 输入步骤 ——对于输入步骤,我们需要询问用户神秘书籍的文件名。

  • 过程步骤 — 在过程步骤中,我们需要找出神秘书籍的签名(也就是未知签名),以及我们已知作者的每本书的签名(这些是已知签名)。为每本书创建签名通常称为机器学习中的训练阶段。我们还需要将未知签名与每个已知签名进行比较,以找出哪个已知签名最接近。这些比较是机器学习中的预测阶段。

  • 输出步骤 — 在输出步骤中,我们需要向用户报告与已知签名最接近的未知签名。

也就是说,为了解决我们的整体作者身份识别问题,我们需要解决这三个子问题。我们正在开始顶层设计!

我们将顶层函数命名为make_guess。在这个函数中,我们将解决我们识别的三个子问题。

对于输入步骤,我们只需要询问用户一个文件名。这是我们可以用少量代码完成的事情,因此我们可能不需要为此单独创建一个函数。输出步骤似乎也类似:假设我们已经知道哪个已知签名最接近,我们可以直接向用户报告。相比之下,过程步骤看起来需要做的工作比较多,我们肯定希望进一步拆解这个子问题。这就是我们接下来要做的。

11.3 拆解过程子问题

我们将整体过程函数命名为process_data。它将接受神秘书籍文件名和已知作者书籍目录名称作为参数,并返回最接近的已知签名的名称。

查看我们对过程步骤的描述,似乎我们有三个子问题需要解决:

  • 找出神秘书籍的签名。 这是我们的未知签名。我们将这个函数命名为make_signature

  • 找出我们已知作者的每本书的签名。 这些是我们的已知签名。我们将这个函数命名为get_all_signatures

  • 将未知签名与每个已知签名进行比较,以找出哪个已知签名最接近。 因为相似的签名差异很小,我们将这个函数命名为lowest_score

我们将依次处理这些子问题的顶层设计。图 11.1 展示了到目前为止我们所得到的设计图。

figure

图 11.1 展示了process_data的三个子任务的函数图

11.3.1 找出神秘书籍的签名

这个任务的函数make_signature将以我们书本的文本作为参数,并返回书本的签名。在此时,我们需要决定将用来确定每本书签名的特征。我们可以通过回顾之前的示例段落来拆解这个问题。我们注意到,作者的段落在句子复杂度和长度上有所不同。你可能也已经猜到,作者们在使用的单词长度和单词的使用方式上也会有所不同(例如,有些作者可能比其他作者更加重复)。因此,我们需要一些特征基于作者句子的结构,另一些则基于作者使用的单词。我们将详细探讨这些特征。

与作者句子结构相关的特征

在我们之前提到的道尔与狄更斯的例子中,我们讨论了将每句的单词平均数作为一个特征。我们可以通过将单词总数除以句子总数来计算这一点。例如,考虑以下文本:

就在那天,一只小纸板盒通过邮寄送到我这里,里面有一颗非常大且光亮的珍珠。我没有发现任何写信的字句。

如果你计算单词和句子的数量,你会发现共有 32 个单词(card-board 算作一个单词)和两句话,因此我们将计算平均每句单词数,结果为 32/2 = 16。这将是平均每句单词数特征。

我们还注意到,句子的复杂度可能因作者而异(即,有些作者的句子中使用更多的逗号和分号),因此将句子复杂度作为一个特征是合理的。更复杂的句子通常包含更多的短语,而短语是句子中有逻辑关系的部分。将一个句子拆解成其组成的短语本身就是一个挑战,尽管我们可以尝试更准确地进行拆解,但我们将在此采用一个更简便的经验法则。也就是说,我们认为,句子中的短语之间是由逗号、分号或冒号分隔的。再看看之前的文本,我们发现共有三个短语。第一句有两个短语:“The same day there arrived through the post a small card-board box addressed to me”和“which I found to contain a very large and lustrous pearl”。第二句没有逗号、分号或冒号,因此只有一个短语。由于共有三个短语和两句话,我们可以认为这段文本的句子复杂度为 3/2 = 1.5。这将是平均句子复杂度特征。

我们希望这些基于句子的特征在直觉上能合理地帮助我们区分作者的写作风格。接下来,让我们开始看看作者在使用单词时可能的不同之处。

与作者的单词选择相关的特征

你可能已经能想到自己的单词级特征指标,但我们在这里使用三个在我们经验中效果良好的指标。首先,有可能某些作者使用的单词比其他作者更短。为此,我们将使用平均单词长度,即每个单词的平均字母数。让我们考虑这段我们创建的示例文本:

一颗珍珠!珍珠!光辉的珍珠!稀有。多么好的发现。

如果你计算字母和单词,你应该会发现总共有 41 个字母和 10 个单词。(这里不要将标点符号算作字母。)所以,我们计算平均单词长度为 41/10 = 4.1。这就是 平均单词长度 特征。

第二,有些作者可能比其他作者使用更多重复的词。为了捕捉这一点,我们将计算作者使用的不同词汇数,并将其除以总词数。以我们之前的示例文本为例,使用了七个不同的单词:apearllustrousrarewhatnicefind。总共有 10 个单词,因此我们计算此指标为 7/10 = 0.7。这就是 不同词汇数除以总词数 特征。

第三,有些作者可能倾向于使用很多一次性单词,而其他作者则倾向于重复使用单词。为了计算这一点,我们将计算那些仅使用一次的单词数,并将其除以总词数。以我们的示例文本为例,有五个单词仅使用一次:lustrousrarewhatnicefind。总共有 10 个单词,因此我们计算此指标为 5/10 = 0.5。这就是 仅使用一次的单词数除以总词数 特征。

总的来说,我们有五个特征将组成每个签名。我们需要将这些数字存储在一个单一值中,因此每个签名将使用一个包含五个数字的列表。

让我们深入探讨如何实现这些特征,从单词级别的特征开始,然后转向句子级别的特征。我们将按以下顺序进行:

  • 平均单词长度

  • 不同单词数除以总词数

  • 仅使用一次的单词数除以总词数

  • 每个句子的平均单词数

  • 平均句子复杂度

对于每一个子任务,我们最终都将编写一个函数。我们有一个更新的图,列出了这些新函数的函数名,帮助我们在图 11.2 中实现 make_signature。我们是否需要进一步细化这些问题,还是它们就这样可以?让我们看看!

figure

图 11.2 函数图,展示了 make_signature 的附加五个子任务

平均单词长度

这个任务的函数average_word_length将把书籍的文本作为参数,并返回平均单词长度。我们可以通过对文本使用split方法来开始解决这个任务。提醒一下,split方法用于将一个字符串分割成其各个部分的列表。默认情况下,split会以空格为分隔符。书籍文本是一个字符串,如果我们按空格分割,就能得到它的单词!这正是我们需要的。接着,我们可以遍历这个单词列表,统计字母数和单词数。

这是一个不错的开始,但我们需要小心一些,因为我们不想把非字母当作字母来计算。例如,“pearl”有五个字母,但“pearl.”或“pearl!!”或“(pearl)”也有五个字母。哈哈,这听起来像是一个子任务!换句话说,我们可以将清理单词的子任务提取到一个独立的函数中,由average_word_length来使用。我们将这个清理函数命名为clean_word

另一个好处是拥有clean_word函数,它帮助我们识别“单词”实际上并不是单词。例如,假设文本中的一个“单词”是....当我们将其传递给clean_word时,它会返回一个空字符串。这意味着它实际上不是一个单词,因此我们不会将其计入单词数量。

不同的单词除以总单词数

这个任务的函数different_to_total将把书籍的文本作为参数,并返回使用的不同单词数量与总单词数量的比例。

average_word_length一样,我们需要小心,只计算字母,而不包括标点符号。但是等等——我们刚才谈到过clean_word函数,它在average_word_length中需要用到。我们也可以在这里使用这个函数!事实上,我们将在五个特性任务的大部分中使用clean_word。这就是一个有用的通用函数的标志!我们的自顶向下设计进行得很顺利。我们可以在图 11.3 中的更新函数图中看到,clean_word函数将被两个函数调用。

figure

图 11.3 函数图,展示了两个函数,它们都使用clean_word函数来辅助处理

这里还有一个额外的复杂情况,涉及到像pearlPearlPEARL这样的单词。我们希望将它们视为相同的单词,但如果我们仅仅使用字符串比较,它们会被视为不同的单词。一个解决方案是将其分离为另一个子问题,将字符串转换为全小写字母。我们也可以把这看作是清理单词的另一个部分,就像去除标点符号一样。我们选择第二个选项。那么,我们将做的是让clean_word函数不仅去除标点符号,还将单词转换为小写字母。

你可能会想,是否需要拆分出另一个子任务,来确定不同单词的数量。你可以这么做,且这样做不会错。然而,如果我们不这样做,我们会发现这个函数依然非常可管理,因此我们就不再拆分。随着实践和经验的积累,你会逐渐学会判断哪些任务需要进一步拆解。

仅使用一次的单词数与总单词数的比值

这个任务的函数exactly_once_to_total将以书籍的文本作为参数,返回仅使用一次的单词数与总单词数的比值。我们还需要使用clean_word函数,原因与前两个任务类似:确保我们只处理字母,而不是标点符号。同样,尽管我们可以拆分一个子任务来确定仅使用一次的单词数,但我们会发现,编写这段 Python 代码其实并不复杂,因此我们将直接完成这个任务,不再进一步拆分。

每个句子的平均单词数

这个任务的函数average_sentence_length将以书籍的文本作为参数,返回每个句子的平均单词数。为了分割我们文本中的单词,前面三个任务我们使用了字符串的 split 方法。那么,我们该如何将文本分割成句子呢?有没有字符串方法可以做到这一点?

不幸的是,没有。为此,将有助于拆分一个任务,将我们的文本字符串分割成句子。我们将这个子任务的函数命名为get_sentencesget_sentences函数将以书籍的文本作为参数,并返回一个由文本中句子组成的列表。

什么是句子?我们将句子定义为由句号(.)、问号(?)或感叹号(!)分隔的文本。这个规则虽然方便且简单,但会犯错误。例如,这段文本有多少个句子?

当时,我刚刚以家庭教师的身份进入塞西尔·福雷斯特夫人的家庭。

答案是有的。然而,我们的程序将会提取出两个句子,而不是一个。它会被像Mrs.这样的词误导,因为它以一个句号结尾。如果你在这一章后继续做作者身份识别,你可以尝试让规则更健壮,或者使用复杂的自然语言处理(NLP)软件来做得更好。然而,对于我们的目的来说,我们会满足于这个规则,尽管它有时会将句子识别错误,因为大多数时候我们会识别正确。如果我们偶尔出错一次,这些错误对我们的度量标准不会产生显著影响。

平均句子复杂度

这个任务的函数我们命名为average_sentence_complexity。它将以一句话的文本作为参数,并返回句子复杂度的度量。

如前所述,我们对使用句子中的短语数量来量化句子复杂性感兴趣。就像我们用标点符号将句子分开一样,我们将使用不同的标点符号来将短语分开。也就是说,我们认为短语是由逗号(,)、分号(;)或冒号(:)分隔的。

有一个子任务来将句子分解成短语,像我们曾经有一个子任务来将文本分解成句子一样,那将是很好的。我们来实现这个目标!我们将为这个子任务创建一个名为get_phrases的函数。get_phrases函数将接受书中的一段句子作为参数,并返回从句子中分离出的短语列表。

让我们暂停一下,思考一下我们正在做的get_sentencesget_phrases函数的工作。想一想,这两个函数其实非常相似。它们唯一的区别在于用来分隔文本的字符。get_sentences关心句号、问号和感叹号,而get_phrases关心逗号、分号和冒号。我们看到了一个可以简化这两个任务的父任务的机会!

换句话说,假设我们有一个split_string函数,它接受两个参数:文本和一个分隔字符的字符串,并返回一个由这些分隔符分隔开的文本片段列表。我们可以用'.?!'来分割句子,或者用,;:来分割短语。这样,get_sentencesget_phrases的实现就会更简单,且能减少代码重复。这是一个胜利!

到目前为止,我们已经完整地展开了支持make_signature高级函数所需的所有函数,这一点在图 11.4 中有所体现。接下来我们将转向get_all_signatures函数。

figure

图 11.4 完整的make_signature函数支持函数图

计算每个已知签名

我们刚刚努力将make_signature函数拆解成五个主要任务,每个任务对应签名的一个特征。我们设计这个函数是为了确定未知签名——即我们正在尝试识别作者的神秘文本的签名。

接下来的任务是找出每本我们知道作者的书籍的签名。在本书的资源文件夹中的 ch11 目录下,你会找到一个名为known_authors的目录。在这个目录下,你会发现几个文件,每个文件以作者的名字命名。每个文件包含该作者的书籍。例如,如果你打开 Arthur_Conan_Doyle.txt,你会看到由亚瑟·柯南·道尔(Arthur Conan Doyle)所著的《血字的研究》(A Study in Scarlet)的文本。我们需要为这些文件确定签名。

令人惊讶的是,解决这个问题的工作量比看起来要少得多。这是因为我们可以使用相同的make_signature函数,那个我们为确定神秘书籍的签名而设计的函数,也能用来确定任何已知书籍的签名!

我们为这个任务命名函数为get_all_signatures。让这个函数接受一本书的文本作为参数是没有意义的,因为它应该能够获取我们所有已知书籍的签名。相反,它将接受一个已知书籍目录作为参数。它的行为是遍历该目录中的文件,为每个文件计算签名。

我们需要这个函数告诉我们哪个签名对应哪本书。换句话说,我们需要它将每本书与其对应的签名关联起来。正因为如此,Python 才有字典!因此,我们让这个函数返回一个字典,其中键是文件名,值是对应的签名。我们的函数图并不需要任何函数来支持get_all_signatures函数,因此我们在图 11.5 中的更新图只展示了get_all_signatures如何调用make_signature

figure

图 11.5 函数图已经更新,get_all_signatures调用make_signature

寻找最接近的已知签名

让我们回顾一下我们目前设计的内容:

  • 我们设计了make_signature函数,以便获取神秘书籍的未知签名。

  • 我们设计了get_all_signatures函数,以便获取我们所有已知的签名。

现在,我们需要设计一个函数,告诉我们哪个已知签名最好;也就是说,哪个已知签名最接近我们的未知签名。我们每个签名将是一个包含五个数字的列表,表示每个特征的数量。这些数字的顺序将与之前使用的顺序相同:平均单词长度、不同单词占总单词的比例、仅出现一次的单词占总单词的比例、每个句子的平均单词数,以及平均句子复杂度。

假设我们有两个签名。第一个签名是[4.6, 0.1, 0.05, 10, 2],这意味着该书的平均单词长度为 4.6,不同单词占总单词的比例为 0.1,依此类推。第二个签名是[4.3, 0.1, 0.04, 16, 4]

有很多种方法可以通过比较签名的差异来得出整体评分。我们将使用的方法是给每个特征一个差异评分,然后将这些评分加起来得到我们的整体评分。

让我们来看一下每个签名在第一个特征上的值:4.6 和 4.3。如果我们相减,得到的差值是 4.6 – 4.3 = 0.3。我们可以将 0.3 作为这个特征的答案,但实际上,如果我们使用不同的权重来对每个差值进行加权,效果会更好。每个权重表示该特征的重要性。我们将使用一些权重([11, 33, 50, 0.4, 4]),这些在我们的经验中证明效果良好。你可能会好奇这些权重到底从哪里来。但请注意,它们并没有什么神秘之处:多年来与我们的学生合作,我们发现这些权重似乎有效。这只是一个强大的作者识别程序的起点。在进行这种类型的研究时,人们通常会调整他们的训练,也就是调整权重,以获得更强的结果。

当我们说我们使用权重[11, 33, 50, 0.4, 4]时,这意味着我们会将第一个特征的差异乘以 11,第二个特征的差异乘以 33,依此类推。因此,我们不会仅仅得到第一个特征的差值 0.3,而是得到 0.3 × 11 = 3.3。

我们需要小心像第四个特征这样的特征,其中差异是负数。我们不希望从 10 – 16 = -6 开始,因为这是一个负数,这会抵消其他特征的正差异。相反,我们需要先将这个数变为正数,然后再乘以它的权重。去掉数字的负号称为取绝对值,绝对值用abs表示。那么,第四个特征的完整计算是 abs(10 – 16) × 0.4 = 2.4。

表 11.1 给出了每个特征的计算。如果我们将五个得分加起来,我们得到一个总得分 14.2。

表 11.1 计算两个签名之间的差异
特征编号 签名 1 中该特征的值 签名 2 中该特征的值 特征的权重 特征的贡献
1 4.6 4.3 11 abs(4.6 – 4.3) × 11 = 3.3
2 0.1 0.1 33 abs(0.1 – 0.1) × 33 = 0
3 0.05 0.04 50 abs(0.05 – 0.04) × 50 = 0.5
4 10 16 0.4 abs(10 – 16) × 0.4 = 2.4
5 2 4 4 abs(2 – 4) × 4 = 8
总和 14.2

记住我们在自顶向下设计中的位置:我们需要一个函数来告诉我们哪个已知的签名是最好的。现在,我们知道如何比较两个签名并获取比较得分。我们需要将这种比较应用于未知签名与每个已知签名之间,以确定哪个已知签名是最好的。得分越低,签名越相似;得分越高,签名之间的差异越大。因此,我们最终需要选择得分最低的签名。

我们将为这个任务命名函数为lowest_score。它将接受三个参数:一个字典,将作者名映射到他们已知的签名,一个未知的签名,以及一个权重列表。该函数将返回与我们的未知签名比较得分最低的签名。

想一想这个函数需要做的工作。它需要遍历已知的签名。我们可以用for循环来实现——这里不需要子任务。它将需要将未知签名与当前已知签名进行比较。哦!那就是一个子任务,它体现了我们在表 11.1 中概述的评分机制。我们将为这个子任务命名函数为get_score。我们的get_score函数将接受两个签名进行比较,以及权重列表,并返回这两个签名比较的得分。

11.4 我们自上而下设计的总结

我们做到了!我们将原本的大问题分解成了几个较小的问题,这些问题可以实现为一个函数。

图 11.6 展示了我们在分解问题过程中所做的所有工作。记住,我们从make_guess函数开始,它将解决整体问题。为了帮助我们实现make_guess,我们创建了一个process_data函数,它将为make_guess做一些工作。为了帮助process_data,我们又创建了三个函数,make_signatureget_all_signatureslowest_score,它们各自有自己的辅助函数,等等。在勾画出我们为解决问题所需的函数后,我们的下一步将是实现它们。

figure

图 11.6 make_guess的完整函数图

11.5 实现我们的函数

现在我们准备好让 Copilot 实现我们所需的每个函数。我们通过从顶部开始——最大的问题——然后向下分解为更小的问题来设计我们的函数。但请记住,在第七章中提到过,这并不是我们实现函数的顺序;相反,我们应该按照相反的顺序实现函数,从底部到顶部(或者在图 11.6 中从右到左)。

就像在第七章中的示例一样,我们不会过多关注测试、提示工程、调试或代码阅读。我们确实鼓励你运行我们提供的文档字符串中的 doctest 测试,并进一步鼓励你为每个函数添加额外的测试。

11.5.1 clean_word

我们将从clean_word函数开始。像往常一样,我们提供函数头(def行)和文档字符串,让 Copilot 填充代码。我们还提供了一些注释,简要说明代码的工作原理。

记住,我们希望我们的clean_word函数去除可能出现在单词周围的标点符号,并将单词转换为小写。但是我们不希望修改单词中间的标点符号,例如card-board中的“-”。我们已经写了文档字符串来明确我们想要的效果。

列表 11.1 分析的干净单词
def clean_word(word):
 '''
 word is a string.

 Return a version of word in which all letters have been
 converted to lowercase, and punctuation characters have been
 stripped from both ends. Inner punctuation is left untouched.

 >>> clean_word('Pearl!')
 'pearl'
 >>> clean_word('card-board')
 'card-board'
 '''
    word = word.lower()                #1
    word = word.strip(string.punctuation)   #2
    return word

1 将单词转换为小写

2 使用 string 模块去除两端的标点符号

在第三章处理密码功能时,我们看到 Copilot 使用了 string 模块,现在我们再次看到 Copilot 在这里使用它。根据我们在第三章的工作,我们知道如果不先导入 string,这将不起作用,因此请添加

import string

如我们在以下列表中所做的那样,放置在该函数上方。

列表 11.2 用于分析的清理单词,已完成
import string
def clean_word(word):
 '''
 word is a string.

 Return a version of word in which all letters have been
 converted to lowercase, and punctuation characters have 
 been stripped from both ends. Inner punctuation is left
 untouched.

 >>> clean_word('Pearl!')
 'pearl'
 >>> clean_word('card-board')
 'card-board'
 '''
    word = word.lower() 
    word = word.strip(string.punctuation) 
    return word

这完成了 clean_word 函数,所以我们可以在图 11.7 的函数图表中将其标记为已完成。

figure

图 11.7 完整的函数图表,clean_word 已完成

11.5.2 average_word_length

现在让我们处理五个标志性特性函数中的第一个:average_word_length。它需要确定每个单词的平均字母数,但我们不希望将周围的标点符号算作字母,也不包括没有字母的单词。我们希望在这里使用我们的clean_word函数,如以下列表所示。和往常一样,我们已经编写了文档字符串,希望能引导 Copilot 做出这些决策。

列表 11.3 平均单词长度
def average_word_length(text):
 '''
 text is a string of text.

 Return the average word length of the words in text.
 Do not count empty words as words.
 Do not include surrounding punctuation.

 >>> average_word_length('A pearl! Pearl! Lustrous pearl! \
Rare. What a nice find.')
 4.1
 '''
    words = text.split()        #1
    total = 0                      #2
    count = 0                #3
    for word in words:        #4
        word = clean_word(word)       #5
        if word != '':              #6
            total += len(word)     #7
            count += 1            #8
    return total / count        #9

1 将字符串分割成单词

2 total 将计算所有单词中字母的总数。

3 count 将计算单词数。

4 遍历每个单词

5 Copilot 为我们调用 clean_word!

6 仅在单词不为空时考虑此单词

7 添加单词中字母的数量

8 给这个单词的计数加 1

9 返回字母数除以单词数

你会注意到在这里的 doctest 中,我们将字符串拆分为两行,第一行以 \ 字符结尾。我们这么做的原因是,字符串否则无法在书中适应一行。我们还需要保持第二行没有任何缩进;否则,doctest 会将那个缩进作为字符串中的空格。在你的计算机上,你可以将字符串输入为一行,而无需担心 \ 或缺少缩进。

我们现在可以在更新后的图表(图 11.8)中标记 average_word_length 为完成。尽管这样做令人满意,但逐个标记这些功能可能会造成太多干扰,因此我们将仅定期回顾图表。

figure

图 11.8 完整的函数图表,average_word_length 已完成

11.5.3 different_to_total

这是我们的第二个标志性特性。我们需要这个功能来计算不同单词的数量除以总单词数。同样,我们不希望包含周围的标点符号或空单词。

列表 11.4 按总词数划分的不同单词
def different_to_total(text):
 '''
 text is a string of text.

 Return the number of unique words in text
 divided by the total number of words in text.
 Do not count empty words as words.
 Do not include surrounding punctuation.
 >>> different_to_total('A pearl! Pearl! Lustrous pearl! \
Rare. What a nice find.')
 0.7
 '''
    words = text.split()     #1
    total = 0                     #2
    unique = set()               #3
    for word in words:
        word = clean_word(word)     #4
        if word != '':
            total += 1
            unique.add(word)      #5
    return len(unique) / total      #6

1 将字符串分割成单词

2 total 将计算所有非空单词的总数。

3 一组唯一的单词

4 Copilot 再次为我们调用 clean_word!

5 集合像列表一样,但不接受重复项。

6 返回不同单词的数量除以总单词数

这段代码使用 Python 集合,而不是 Python 列表,来存储它找到的唯一单词。集合类似于列表,但它不允许重复,因此如果你尝试多次添加相同的单词,它只会保留一个副本。这就是为什么我们可以在返回语句中使用 len(unique) 来获取唯一单词的总数。

在这里我们不需要了解更多关于 Python 集合的知识。但如果你有兴趣了解更多,你可以向 Copilot 请求代码解释,或在 Google 上搜索关于 Python 集合的信息。

11.5.4 exactly_once_to_total

我们准备实现我们的第三个签名功能。这个功能计算了仅使用一次的单词的数量与总单词数量的比值,如下所示。我们向 Copilot 提供了提示,并收到了以下函数。

列表 11.5 仅使用一次的单词的比例:尝试 1
def exactly_once_to_total(text):
 '''
 text is a string of text.

 Return the number of words that show up exactly once in text
 divided by the total number of words in text.
 Do not count empty words as words.
 Do not include surrounding punctuation.

 >>> exactly_once_to_total('A pearl! Pearl! Lustrous pearl! \
Rare. What a nice find.')
 0.5
 '''
    words = text.split()
    unique = set()
    total = 0
    for word in words:
        word = clean_word(word)
        if word != '':
            if word not in unique:
                unique.add(word)
            else:
                unique.remove(word)   #1
            total += 1
    return len(unique) / total

1 从唯一集合中移除单词?

当我们快速浏览代码时,看起来 unique 是一个集合,用于跟踪唯一的单词。在最后,我们将唯一单词的数量除以总单词的数量。

然而,当我们使用 doctest 运行我们的测试用例时,你会看到我们的代码没有通过测试。我们需要的结果是 0.5,但我们的代码不幸给出了 0.6。我们的代码是错误的,我们需要保持警惕!

让我们回顾一下代码,看看为什么它是错误的。假设某个单词在文本中出现了三次。当我们的代码第一次找到这个单词时,它会将其添加到unique中。第二次,它会从unique移除该单词。到目前为止,这没问题,因为该单词不再是只使用一次,所以它不应该在其中。但第三次,它会将这个单词再次添加到unique中!因此,我们的代码会将一些本应计算为只使用一次的单词错误地计算为使用了多次。我们查看了按 Ctrl-Enter 按下时出现的列表,找到了这个正确的替代版本,如下所示。

列表 11.6 仅使用一次的单词的比例:尝试 2
def exactly_once_to_total(text):
 '''
 text is a string of text.

 Return the number of words that show up exactly once in text
 divided by the total number of words in text.
 Do not count empty words as words.
 Do not include surrounding punctuation.

 >>> exactly_once_to_total('A pearl! Pearl! Lustrous pearl! \
Rare. What a nice find.')
 0.5
 '''
    words = text.split()
    total = 0
    unique = set()             #1
    once = set()             #2
    for word in words:
        word = clean_word(word)
        if word != '':
            total += 1
            if word in unique:     #3
                once.discard(word)     #4
            else:
                unique.add(word)     #5
                once.add(word)        #6
    return len(once) / total        #7

1 跟踪我们看到的所有单词

2 跟踪仅使用一次的单词——这是我们最终关心的

3 我们再次看到了这个单词。

4 所以这个单词必须从仅使用一次的集合中移除。

5 我们现在已经看到了这个单词。

6 到目前为止,这个单词只使用了一次。

7 返回仅使用一次的单词数量与总单词数量的比值

这段代码很棘手!要理解它,首先要关注 else 代码。这是我们第一次看到每个单词时运行的代码。这个单词会同时被添加到 uniqueonce 集合中。正是 once 集合会帮助我们跟踪仅使用一次的单词。

现在假设我们第二次遇到一个单词。由于这个单词已经在 unique 中(我们第一次遇到这个单词时将它添加到了 unique),if 代码会在这种情况下运行。现在,由于我们已经遇到该单词多次,我们需要将它从 once 集中移除。这正是 if 代码所做的:它使用 once.discard(word) 将该单词从 once 中移除。

总结一下,第一次遇到一个单词时,它会被添加到 once 中。当我们再次遇到它时,它会从 once 中移除,并且永远不会再被添加回 onceonce 集正确地跟踪了仅出现一次的单词。

11.5.5 split_string

我们已经完成了三个基于单词级别的特征函数。在我们能够继续处理两个基于句子级别的特征函数之前,我们需要编写 get_sentences。但要编写 get_sentences,我们首先需要 split_string,这就是我们现在要处理的内容。

我们的 split_string 函数应该能够根据任意数量的分隔符拆分字符串。它本身与句子或短语无关。我们已经包括了一个文档字符串测试来突出这一点:尽管我们将用它来拆分句子和短语,但它比这更通用。请看下面的列表。

列表 11.7 根据分隔符拆分字符串
def split_string(text, separators):
 '''
 text is a string of text.
 separators is a string of separator characters.

 Split the text into a list using any of the one-character
 separators and return the result.
 Remove spaces from beginning and end
 of a string before adding it to the list.
 Do not include empty strings in the list.

 >>> split_string('one*two[three', '*[')
 ['one', 'two', 'three']
 >>> split_string('A pearl! Pearl! Lustrous pearl! Rare. \
What a nice find.', '.?!') 
 ['A pearl', 'Pearl', 'Lustrous pearl', 'Rare', \
'What a nice find']
 '''
    words = []        #1
    word = ''             **#2
    for char in text:
        if char in separators:    #3
            word = word.strip()      #4
            if word != '':          #5
                words.append(word)      #6
            word = ''                 #7
        else:
            word += char           #8
    word = word.strip()      #9
    if word != '':          **
        words.append(word)  ** #9
    return words #9******

******#1 一个更好的变量名应该是 all_strings。

2 一个更好的变量名应该是 current_string。

3 当前字符串在此结束。

4 移除当前字符串开头和结尾的空格

5 如果当前字符串不为空 . . .

6 . . . 将其保存为拆分字符串之一。

7 清空当前字符串,为下一个字符串做准备

8 添加到当前字符串(暂不拆分)

9 处理最终的拆分字符串,若不为空****** ******你可能会对 for 循环之后和 return 语句之前的代码感到好奇。它似乎在重复 for 循环中的一些代码,那么它在那里做什么呢?这些代码存在是因为循环只有在找到分隔符字符时,才会将拆分字符串添加到我们的字符串列表中。如果文本没有以分隔符字符结尾,循环就不会添加最终的拆分字符串。循环下方的代码确保了这个最终的拆分字符串不会丢失。

自从我们更新了函数图后已经有一段时间了,是时候更新了!这也提醒我们,我们是从底层向上(图中的从右到左)完成函数的。因此,图 11.9 展示了我们迄今完成的所有函数。

图示

图 11.9 完整的函数图已更新,包含 different_to_totalexactly_once_to_total 和现已完成的 split_string

11.5.6 get_sentences

在我们的自上而下设计中,我们将大部分 get_sentences 的工作委派给了 split_string 函数。因此,我们期望的是 Copilot 通过合适的调用来实现 get_sentences,并调用 split_string

我们不希望句子开头或结尾有空格,也不希望有空句子。最初我们在文档字符串中有这些要求,但即使没有这些要求,Copilot 也正确地调用了split_string,该函数本身也能处理这些边缘情况。

示例 11.8 返回文本中的句子列表
def get_sentences(text):
 '''
 text is a string of text.

 Return a list of the sentences from text.
 Sentences are separated by a '.', '?' or '!'.

 >>> get_sentences('A pearl! Pearl! Lustrous pearl! Rare. \
What a nice find.')
 ['A pearl', 'Pearl', 'Lustrous pearl', 'Rare', \
'What a nice find']
 '''
    return split_string(text, '.?!')     #1

1 使用句子分隔符字符调用 split_string

11.5.7 平均句子长度

现在我们已经有了get_sentences,可以继续实现我们的第四个签名特征average_sentence_length。这个函数计算每个句子的平均单词数。我们向 Copilot 提供了提示,并得到了如下的函数。

示例 11.9 每个句子的平均单词数
def average_sentence_length(text):
 '''
 text is a string of text.

 Return the average number of words per sentence in text.
 Do not count empty words as words.

 >>> average_sentence_length('A pearl! Pearl! Lustrous pearl! \
Rare. What a nice find.')
 2.0
 '''
    sentences = get_sentences(text)    #1
    total = 0
    for sentence in sentences:        #2
        words = sentence.split()     #3
        for word in words:       #4
            if word != '':
                total += 1
    return total / len(sentences)     #5

1 获取句子列表

2 遍历句子

3 将当前句子分解成单词

4 遍历句子的单词

5 返回单词数除以句子数

11.5.8 get_phrases

就像我们在实现average_sentence_length之前需要get_sentences一样,我们在实现average_sentence_complexity之前也需要get_phrases

get_sentences一样,我们期望 Copilot 调用split_string来获取短语。正如它在这里所做的那样,下面的示例展示了这一过程。

示例 11.10 返回句子的短语列表
def get_phrases(sentence):
 '''
 sentence is a sentence string.

 Return a list of the phrases from sentence.
 Phrases are separated by a ',', ';' or ':'.

 >>> get_phrases('Lustrous pearl, Rare, What a nice find')
 ['Lustrous pearl', 'Rare', 'What a nice find']
 '''
    return split_string(sentence, ',;:')    #1

1 使用短语分隔符字符调用 split_string

11.5.9 平均句子复杂度

完成get_phrases之后,我们可以提示实现average_sentence_complexity。代码如下所示。

示例 11.11 每个句子的平均短语数
def average_sentence_complexity(text):
 '''
 text is a string of text.

 Return the average number of phrases per sentence in text.

 >>> average_sentence_complexity('A pearl! Pearl! Lustrous \
pearl! Rare. What a nice find.')
 1.0
 >>> average_sentence_complexity('A pearl! Pearl! Lustrous \
pearl! Rare, what a nice find.')
 1.25 **#1
 **'''**
    sentences = get_sentences(text)     #2
    total = 0
    for sentence in sentences:               #3
        phrases = get_phrases(sentence)       #4
        total += len(phrases)             #5
    return total / len(sentences)      #6**

**#1 我们将句号改为逗号,使得这个 5/4 = 1.25。

2 获取句子列表

3 遍历句子

4 获取当前句子的短语列表

5 添加当前句子的短语数

6 返回短语数除以句子数 **我们现在进展顺利!我们已经完成了生成make_signature所需的所有函数,如图 11.10 所示。

figure

图 11.10 完整的函数图更新,显示我们现在已经准备好编写make_signature

11.5.10 make_signature

到目前为止,我们已经编写了九个函数,虽然它们都很重要,但我们现在可能有些不满足,因为我们甚至还没有处理文本签名。我们有一些函数来清理单词,按不同方式拆分字符串,计算签名的个别特征,但没有一个函数能生成完整的签名。

现在情况发生了变化,因为我们终于准备好实现make_signature来为文本生成签名。此函数将接收一本书的文本,并返回五个数字的列表,每个数字都是调用我们五个特征函数中的一个的结果。

示例 11.12 文本的数值签名
def make_signature(text):
 '''
 The signature for text is a list of five elements:
 average word length, different words divided by total words, 
 words used exactly once divided by total words,
 average sentence length, and average sentence complexity.

 Return the signature for text. 

 >>> make_signature('A pearl! Pearl! Lustrous pearl! \
Rare, what a nice find.')
 [4.1, 0.7, 0.5, 2.5, 1.25]
 '''
    return [average_word_length(text),            #1
            different_to_total(text),            ** #1
            exactly_once_to_total(text),         ** #1
            average_sentence_length(text),        #1
            average_sentence_complexity(text)]   ** #1******

#1 调用了我们的五个特性函数中的每一个。 ******注意,这个函数可以实现为仅仅调用我们五个特性函数中的每一个。现在暂停一下,思考一下如果没有先进行良好的自顶向下设计,这个函数会有多乱。我们在这里调用的所有五个函数的代码本应在一个单独的函数中,而且它们各自的变量和计算会混合成一团糟。幸运的是,我们使用了自顶向下设计!因此,我们的函数更易于阅读,也更容易让我们确信它做的事情是对的。

11.5.11 get_all_signatures

我们的 process_data 函数有三个子任务需要我们实现。我们刚刚完成了第一个子任务(make_signature),现在我们将继续第二个子任务,即我们的 get_all_signatures 函数。

从现在开始,我们假设你的工作目录中有你的代码,并且它还包含我们提供的书籍子目录。我们需要这个函数返回我们已知作者目录中每个文件的签名。我们希望 Copilot 在这里调用 make_signature,使这个函数比它本来会更简单。

Copilot 确实为我们做了这个,但我们得到的代码仍然有两个问题。我们最初的代码如下面的列表所示。

列表 11.13 从已知作者处获取所有签名:尝试 1
def get_all_signatures(known_dir):
 '''
 known_dir is the name of a directory of books.
 For each file in directory known_dir, determine its signature.

 Return a dictionary where each key is
 the name of a file, and the value is its signature.
 '''
    signatures = {}                                 #1
    for filename in os.listdir(known_dir):             #2
        with open(os.path.join(known_dir,           #3
                               filename)) as f:    ** #3
            text = f.read()                            #4
            signatures[filename] = make_signature(text)   #5
    return signatures**

**#1 我们的字典,最初为空,映射文件名到签名。

2 遍历已知作者目录中的每个文件

3 打开当前文件

4 读取文件中的所有文本

5 为文本生成签名并将其存储在字典中 ** ** 尝试从 Python 提示符运行此函数

>>> get_all_signatures('known_authors')

然后你会得到以下错误:

Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "C:\repos\book_code\ch11\authorship.py", line 207, 
  in get_all_signatures
    for filename in os.listdir(known_dir):
                    ^^
NameError: name 'os' is not defined

错误告诉我们该函数尝试使用一个名为 os 的模块,但我们没有这个模块可用。这个模块是 Python 内置的,我们知道该怎么办:导入它!也就是说,我们需要添加

import os

在这个函数上方。之后,我们仍然会得到一个错误:

>>> get_all_signatures('known_authors')
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "C:\repos\book_code\ch11\authorship.py", line 209, 
  in get_all_signatures
    text = f.read()
           ^^^^^^^^
  File "…\Lib\encodings\cp1252.py", line 23, in decode
    return codecs.charmap_decode(input,self.errors,decoding_table)[0]
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
UnicodeDecodeError: 'charmap' codec can't decode byte 0x9d in 
position 2913: character maps to <undefined>

你可能会想知道什么是 UnicodeDecodeError。如果你对技术解释感兴趣,可以去谷歌搜索或者问 ChatGPT。我们需要知道的是,我们打开的每个文件都是以特定的方式编码的,而 Python 选择了错误的编码方式来读取这个文件。

然而,我们可以通过在函数顶部附近添加注释来指导 Copilot 修复它。 (当你遇到类似的错误时,可以尝试在生成的错误代码上方直接添加注释。然后,一旦删除了错误代码,Copilot 通常可以生成正确的代码。)一旦我们这样做,一切都会顺利,如下所示。

列表 11.14 从已知作者处获取所有签名:尝试 2
import os

def get_all_signatures(known_dir):
 '''
 known_dir is the name of a directory of books.
 For each file in directory known_dir, determine its signature.

 Return a dictionary where each key is
 the name of a file, and the value is its signature.
 '''
    signatures = {}
 # Fix UnicodeDecodeError **#1
    for filename in os.listdir(known_dir):
        with open(os.path.join(known_dir, filename), 
                  encoding='utf-8') as f:
            text = f.read()
            signatures[filename] = make_signature(text)
    return signatures**

#1 此提示告诉 Copilot 修复我们之前看到的错误。 **现在,如果你运行这个函数,你应该会看到一个包含作者及其签名的字典,像这样:

>>> get_all_signatures('known_authors')
{'Arthur_Conan_Doyle.txt': [4.3745884086670195, 
0.1547122890234636, 0.09005503235165442, 
15.48943661971831, 2.082394366197183], 
'Charles_Dickens.txt': [4.229579999566339, 
0.0796743207788547, 0.041821158307855766, 
17.286386709736963, 2.698477157360406], 
'Frances_Hodgson_Burnett.txt': [4.230464334694739, 
0.08356818832607418, 0.04201769324672584, 
13.881251286272896, 1.9267338958633464], 
'Jane_Austen.txt': [4.492473405509028, 
0.06848572461149259, 0.03249477538065084, 
17.507478923035084, 2.607560511286375], 
'Mark_Twain.txt': [4.372851190055795, 
0.1350377851543188, 0.07780210466840878, 
14.395167731629392, 2.16194089456869]}

为了简便起见,我们没有在此函数的文档字符串中添加测试。不过,如果我们添加了测试,我们会创建一个假的小书,类似于我们在第六章第二个示例中做的那样。尽管如此,我们还是希望继续推进函数分解的总体目标,因此如果你想继续深入,可以自己完成这个练习。如图 11.11 所示,我们已经完成了两个 process_data 子任务。让我们继续前进!

图示

图 11.11 完整的功能图,已更新以显示 make_signatureget_all_signatures 已完成

11.5.12 get_score

让我们实现 get_score,在这里我们需要编码比较签名的方式。记住,之前我们讨论过,找到每个特征上的差异,乘以权重,然后将所有差异加起来得出总分?这正是我们希望 get_score 实现的功能。

在文档字符串中解释这个公式将是一个挑战。我们甚至不确定它是否应该放在那里:文档字符串应该解释如何使用你的函数,而不是它的内部工作原理。而且,可以说,我们函数的使用者反而不会在乎这个具体的公式。我们可以做的是使用一个通用的文档字符串,不涉及具体公式,看看 Copilot 如何处理它。接下来我们在下面的列表中演示。

列表 11.15 比较两个签名
def get_score(signature1, signature2, weights):
 '''
 signature1 and signature2 are signatures.
 weights is a list of five weights.

 Return the score for signature1 and signature2.

 >>> get_score([4.6, 0.1, 0.05, 10, 2],\
 [4.3, 0.1, 0.04, 16, 4],\
 [11, 33, 50, 0.4, 4]) **#1
 **14.2**
 **'''**
    score = 0
    for i in range(len(signature1)):                #2
        score += abs(signature1[i] - signature2[i]) \   #3
                 * weights[i]                           #3
    return score**

**#1 这些权重,[11, 33, 50, 0.4, 4],对我们非常有效。

2 遍历每个签名索引

3 将加权差异添加到得分** **Copilot 完全实现了我们想要的公式。现在,在我们开始认为 Copilot 能读取我们的心思或类似的事情之前,请记住,我们使用的这个公式是一个非常常见的用于比较签名的度量标准。多年来,许多学生和程序员都用这个公式实现了作者身份识别。Copilot 之所以给我们返回这个公式,是因为它在其训练数据中出现得非常频繁。如果 Copilot 给出了一个不同的公式,我们本可以尝试在注释中描述我们的需求,或者如果那样做无效,我们自己更改代码来实现所需功能。

11.5.13 lowest_score

我们的 lowest_score 函数最终将完成我们需要实现 process_data 的所有工作。我们刚刚实现的 get_score 函数给出了任意两个签名之间的得分。我们的 lowest_score 函数将会调用 get_score,对每个已知签名与未知签名进行比较。然后它将返回与未知签名得分最低的已知签名,如以下列表所示。

列表 11.16 最接近的已知签名
def lowest_score(signatures_dict, unknown_signature, weights):
 '''
 signatures_dict is a dictionary mapping keys to signatures.
 unknown_signature is a signature.
 weights is a list of five weights.
 Return the key whose signature value has the lowest 
 score with unknown_signature.

 >>> d = {'Dan': [1, 1, 1, 1, 1],\ **#1
 **'Leo': [3, 3, 3, 3, 3]}** #1
 **>>> unknown = [1, 0.8, 0.9, 1.3, 1.4]**
 **>>> weights = [11, 33, 50, 0.4, 4]**
 **>>> lowest_score(d, unknown, weights)** **#2
 **'Dan'**
 **'''**
    lowest = None
    for key in signatures_dict:           **#3
        score = get_score(signatures_dict[key],           #4
                          unknown_signature, weights) 
        if lowest is None or score < lowest[1]:      #5
            lowest = (key, score)       #6
    return lowest[0]   #7******

****#1 使用变量在 doctest 中使测试本身更容易阅读

2 这一行更容易阅读,因为我们使用了变量。

3 遍历每个作者名字

4 获取将此已知签名与未知签名比较的得分

5 如果这是第一次比较,或者我们找到更低的分数……

6 . . . 这会存储最佳键及其对应的分数。

7 lowest[0] 是最佳键。**** ****第一个参数signatures_dict是一个字典,将作者的名字映射到他们已知的签名。这最终会通过get_all_signatures函数得到。第二个参数unknown_signature将最终来自调用make_signature生成的神秘书籍签名。第三个参数weights将在我们调用这个函数时由我们硬编码。

11.5.14 process_data

只剩下两个函数了!其中之一是process_data—感觉我们花了很久时间,但终于准备好了。

我们的process_data函数将在以下代码中接收两个参数:神秘书籍的文件名和已知作者书籍的目录。它将返回我们认为写这本神秘书籍的作者。

示例 11.17 最接近神秘作者的签名
def process_data(mystery_filename, known_dir):
 '''
 mystery_filename is the filename of a mystery book whose 
 author we want to know.
 known_dir is the name of a directory of books.

 Return the name of the signature closest to 
 the signature of the text of mystery_filename.
 '''
    signatures = get_all_signatures(known_dir)           #1
    with open(mystery_filename, encoding='utf-8') as f:    #2
        text = f.read()                                #3
        unknown_signature = make_signature(text)         #4
    return lowest_score(signatures, unknown_signature,    #5
                        [11, 33, 50, 0.4, 4])             #5

1 获取所有已知的签名

2 Copilot 利用我们的先前工作来正确获取编码。

3 读取神秘书籍的文本

4 获取未知的签名

5 返回具有最低比较分数的签名

再次注意我们多么依赖于早期的函数。这个极其有用的process_data函数现在实际上只是一个经过精心排序的函数调用列表。

在本章节的书籍资源中,我们包含了几个未知作者的文件,例如 unknown1.txt 和 unknown2.txt。这些文件应该与您的代码(以及已知作者文件的子目录)一起放在当前工作目录中。

让我们调用process_data来猜测是谁写了'unknown1.txt'

>>> process_data('unknown1.txt', 'known_authors')
'Arthur_Conan_Doyle.txt'

我们的程序猜测是亚瑟·柯南·道尔写了 unknown1.txt。如果你通过打开文件查看 unknown1.txt 的内容,你会发现我们的猜测是正确的。这本书叫做《四签名》,是一本著名的亚瑟·柯南·道尔作品。

11.5.15 make_guess

目前,为了猜测一本书的作者,我们需要输入 Python 代码来运行process_data。这对用户来说并不友好;如果我们能运行程序并让它询问我们要使用哪个神秘书籍文件,那就太好了。

我们将通过实现make_guess这个最顶层函数,为我们的程序画上完美的句号!这个函数将会要求用户输入一个神秘书籍的文件名,使用process_data获取最佳猜测,并告知用户这一猜测,具体实现请参见以下代码。

示例 11.18 与用户互动并猜测文本的作者
def make_guess(known_dir):
 '''
 Ask user for a filename.
 Get all known signatures from known_dir,
 and print the name of the one that has the lowest score 
 with the user's filename.
 '''
    filename = input('Enter filename: ')       #1
    print(process_data(filename, known_dir))     #2

1 向用户询问神秘书籍的文件名

2 调用process_data完成所有工作并报告我们的猜测

这完成了我们图表中的所有函数!图 11.12 展示了我们已经从底部到最顶部检查完了每个函数。

figure

图 11.12 make_guess所需的所有函数现在都已完成!

如果你将我们所有的代码放入你的 Python 文件中,并在该文件的底部添加以下代码行,你就能运行程序来猜测一本神秘书籍的作者:

make_guess('known_authors')

例如,下面是当我们运行程序并输入unknown1.txt作为未知书籍时发生的情况:

Enter filename: unknown1.txt       
Arthur_Conan_Doyle.txt

它正确地告诉我们,unknown1.txt是由阿瑟·柯南·道尔所写!尝试对我们提供的每个其他未知书籍文件进行运行。它能正确猜出多少本?又错猜了哪些?

恭喜你!你已经完成了你的第一个真实世界的自顶向下设计。看看我们完成了什么——一个任何初学者程序员都应该为之自豪的作者识别程序。你的程序使用人工智能学习各个作者的写作风格(他们平均使用短词还是长词,平均使用短句还是长句,等等),通过使用其训练数据中的书籍文本。然后,它应用这些学习,预测一本神秘书籍的作者,通过确定哪位作者的风格最接近这本神秘书籍——非常酷!我们成功地解决了一个非常困难的问题,并通过将问题分解并让 Copilot 为每个子问题编写代码来实现它。

11.6 进一步发展

在进行自顶向下设计之后,人们通常会发现重构代码的机会,这意味着在不改变代码行为的情况下,使代码更清晰或更有条理。我们可以通过多种方式重构我们的程序。例如,你可能会注意到,我们的许多标志性特征函数将字符串拆分成单词,然后忽略空白单词。这个任务(从字符串中返回一个非空单词的列表)可以被拆分成一个独立的子任务函数,这样任何调用它的函数都会更加简洁。

我们也可能决定将权重传递给process_data,而不是在该函数中硬编码权重。然后,权重将在make_guess中硬编码,这将使决策在函数层次结构中更高,从而使得需要时更容易找到并更改。

我们也可以在功能或效率方面改进程序。在功能方面,现在我们的程序只是打印出它对神秘书籍作者的最佳猜测。但我们对这个猜测一无所知。是否有第二个作者的猜测与我们猜测的作者非常接近?如果有,我们可能会想知道。更一般来说,我们可能希望知道前几名的猜测,而不仅仅是最顶端的猜测。这样,即使最顶端的猜测错误,我们也能获得关于作者是谁的有用信息。这些是我们可以添加到程序中的附加功能。

为了提高效率,让我们再想一想那个get_all_signatures函数。那个函数做了很多工作!如果我们在已知目录中有五本书,那么它将读取每个文件并计算每个签名。没什么大不了的,对吧?只有五个文件,计算机处理起来很快。但试想一下,如果我们有 100 个文件或 1 万个文件呢?将这些工作当作一次性任务来做可能是可以接受的,但这不是我们程序的做法。事实上,每次我们运行程序来猜测神秘书籍的作者时,它都会运行那个get_all_signatures函数,这意味着每次都需要重新创建那些签名。这是一个巨大的浪费;如果我们能将那些签名存储在某个地方,以后再也不需要计算它们,那就好了。的确,如果我们要重新设计代码以提高效率,第一步就是确保已知文本的签名只计算一次,并且以后复用。

这正是像 Copilot 这样的工具所做的!OpenAI 只在大量代码语料库上训练了 GitHub Copilot 一次。那花费了成千上万小时的计算时间。但现在训练完成后,它就能不断为我们写代码,而无需每次从头开始训练。一次训练,之后多次预测的这个理念,在所有的机器学习中都是一个常见的范式。

11.7 练习

  1. 以下哪一项不是本章中描述的基于 AI 的作者身份识别过程中的一步?

    1. 计算神秘书籍的平均单词长度

    2. 比较神秘书籍的签名与已知签名

    3. 请求用户提供神秘书籍的文件名

    4. 计算神秘书籍的总页数

  2. 构建一个能够根据邮件内容区分垃圾邮件和非垃圾邮件(正常邮件)的分类器。使用特征如单词频率、某些关键词的出现和邮件长度。以下是你需要采取的步骤:

    1. 收集垃圾邮件和非垃圾邮件的数据集。你可以在线找到公开的数据集,例如 Enron 垃圾邮件数据集。

    2. 对邮件进行预处理(去除停用词、标点符号等)。

    3. 提取特征(例如,单词计数、某些单词的出现)。

    4. 使用我们标记的数据训练分类器(监督学习)。对于分类器,一个简单有效的选择是朴素贝叶斯分类器(可以使用 Python 库来帮助实现)。

    5. 使用一个单独的邮件集来测试分类器,检查其准确性。

  3. 在这个练习中,你将创建一个简单的文本生成程序,使用 n-gram。N-gram 是给定文本或语音样本中的连续序列的n项。你将使用这些 n-gram 来生成模仿输入文本风格的新文本。关键思想是构建一个模型,训练它知道哪些单词通常跟在其他单词后面(例如,“猫吃”是合理的,“纸巾吃”不合理),然后在可能的选择中随机选择下一个单词。如有需要,可以查阅 n-gram 获取更多信息。以下是你需要采取的步骤:

    1. 选择可以加载到 Python 中的输入文本。你可以使用像是:“傲慢与偏见”(简·奥斯汀著)这样的作品。

    2. 预处理文本,将其转换为小写并去除标点符号。

    3. 从输入文本创建 n-gram。一个 n-gram 是给定文本中的连续序列的n项。在这个例子中,我们将使用二元组(n = 2)。

    4. 使用生成的 n-gram 来产生新文本。从一个随机的 n-gram 开始,并根据 n-gram 模型不断添加新单词,直到达到所需的长度。

概述

  • 随着我们程序复杂度的增加,自顶向下的设计变得越来越关键。

  • 作者识别是猜测神秘书籍作者的过程。

  • 我们可以使用关于单词的特征(例如,平均单词长度)和句子的特征(例如,每个句子的平均单词数)来表征每位已知作者的写作风格。

  • 机器学习是计算机科学中一个重要的领域,它研究机器如何从数据中学习并进行预测。

  • 在监督学习中,我们有一些训练数据,以对象(例如书籍)和它们的类别(谁写了每本书)的形式存在。我们可以从这些数据中学习,以便对新对象进行预测。

  • 一个签名由特征列表组成,每个对象一个签名。

  • 重构代码意味着改进代码的设计(例如,通过减少代码重复)。

第十二章:12 未来发展方向

本章内容包括

  • 使用提示模式编写和解释代码

  • 生成式 AI 工具的当前局限性与未来发展方向

在本章的最后,我们想给你一个 glimpse(瞥见)目前人们使用生成式 AI 工具(如 GitHub Copilot 和 ChatGPT)的一些创造性方法的机会。例如,现在可以让 Copilot Chat 问你问题,而不是你去问它。而且,可以让 Copilot 采取不同的人物角色,以便更好地帮助你完成当前的编程任务。我们会简短地介绍这些内容,虽然目前还不清楚其中有多少会成为标准实践,但我们希望借此机会展示如何通过创造性地使用这些新工具来发挥它们的强大功能。我们还将讨论生成式 AI 工具的当前局限性(你已经在本书中看到过其中的一些!),并提出我们对未来的看法。

12.1 提示模式

在本书中,我们传达了为什么直接用 Python 编程与使用 Copilot 编程是非常不同的体验。我们的重点不再是编写代码,而是编写提示语,并与生成的代码进行交互,以确定其是否正确,然后在需要时进行修正。但与这些差异相伴的,还有一些令人惊讶的相似之处,既有不使用 Copilot 时的编程方式,也有使用 Copilot 时的编程方式。

当程序员编写代码时,他们并不是每次编写新程序时都从零开始。研究人员和程序员们创建了设计的模式,即组织程序的通用方法,使得编写、调试和扩展程序变得更加容易。最著名的这种模式目录是一本名为《设计模式:可重用面向对象软件的元素》的书[1],也被称为“四人帮书”,因为它是由四位作者共同编写的。它要求熟悉面向对象编程,而我们在本书中并未教授这一内容,因此我们不建议你现在就去读这本书。只要知道,成千上万的程序员通过使用这本书中的模式,节省了成千上万个小时的时间,而不是重复发明轮子。

作为设计模式的一个例子,假设你正在编写一个电脑游戏,其中玩家与计算机对战。你希望实现多个具有不同难度(例如:初级、中级、高级和专家级)的 AI 对手,为人类玩家提供不同的对战技能水平。每个 AI 对手将拥有自己的代码来决定其行为。可以使用if条件来确定 AI 对手应该做什么:

if ai_opponent == 'beginner':
    # make decision consistent with beginner AI opponent
elif ai_opponent == 'intermediate':
    # make decision consistent with intermediate AI opponent
…

然而,这种方式的缺点是组织上的。我们会将所有 AI 对手的 AI 代码放在同一个地方。从本书所学,我们知道像这样的庞大函数并不容易设计和测试。

组织这种程序的简洁方式是使用一种名为策略模式的模式。我们在这里不会详细讨论该模式,但重要的是,人们已经为他人记录了这一模式[1]。该模式的文档告诉你它的意图(目的)、使用它的动机、实现它所需的代码结构以及示例代码。

研究人员开始对可以与生成式人工智能工具(如 GitHub Copilot 和 ChatGPT)一起使用的模式进行编目[2]。这些模式被称为提示模式,类似于设计模式,它们告诉你如何构建提示,以实现期望的目标。每个模式的文档类似于设计模式的文档,但与示例代码不同,我们得到的是可以使用的示例提示。

在本书中,我们大部分时间集中在两种类型的 AI 回应:生成代码(通过在 VS Code 中按 Tab 键)和解释代码(通过使用 Copilot Chat)。正在出现的新提示模式可以帮助生成式 AI 工具超越这些,协助其他与代码相关的任务。

为了让你了解提示模式的最新进展,我们将在这里展示其中一些样本。更多的模式,请参考我们所使用的提示模式目录[2]。不过要注意,我们推荐的是一篇学术论文,而不是初学者指南,因为目前我们没有看到任何初学者指南(这仍然是新兴的内容)。

Copilot Chat 可能不会参与这些提示模式

在撰写本文时,Copilot Chat 拒绝参与一些已编目的提示模式,声称它们与编程辅助无关。虽然其中一些可能与编程的关系不那么具体,但它们都旨在帮助程序员。与 ChatGPT 不同,Copilot 被设计成尽量始终专注于编程主题。如果你决定进一步探索这些多样的模式,可能希望在与 Copilot 的互动不太有效时,使用 ChatGPT 进行尝试。

12.1.1 翻转互动模式

到目前为止,我们一直是请求代码并提问,而 Copilot 则是回答。但有可能翻转剧本,让 Copilot 来提问,我们来回答。这被称为翻转互动模式

这个模式的目的是帮助我们与人工智能互动,尤其是在我们不知道它需要什么信息来执行任务时。其动机是,人工智能可能比我们提供不完整或无效信息时,知道应该向我们提问什么,以便更快速地达到目标。我们的提示结构将如下所示:

我希望你向我提问,以实现 X。你应该继续提问,直到我们实现这个目标,其中 X 是我们希望实现的目标。

这个结构并不是为了精确规定我们应该使用的提示词。相反,它提醒我们需要在提示中传达的关键想法。例如,这里的关键想法是我们希望 Copilot 提问,并且希望它在我们达成目标之前不要停止。

在第三章中,我们编写了一个函数,不断要求用户输入密码,直到他们输入一个强密码。假设我们不确定如何设计这个函数。我们可以请求 Copilot 为我们设计它,通过提供代码提示,但那样可能会做出我们不同意的决定。相反,我们可以让 Copilot 问我们问题。我们来使用 Copilot Chat 来实现这一点。

LPHuman 我希望有一个 Python 函数,不断要求用户输入密码,直到他们提供一个强密码。请每次问我一个问题,直到你能编写出这个 Python 函数。

chatGptGitHub Copilot 当然,我很乐意帮助你编写一个 Python 函数,不断要求用户输入强密码!以下是一些问题,供你开始:

  1. 你认为什么是强密码?

  2. 这个函数应该有任何输入参数吗?

  3. 这个函数应该返回什么?

  4. 这个函数应该有任何副作用,比如向控制台打印信息吗?

  5. 这个函数应该有任何错误处理机制吗,比如处理来自用户的无效输入?

Copilot 没有按照我们的指示“每次问我一个问题”来操作。我们来提醒它一下。

LPHuman 每次问我一个问题。

chatGptGitHub Copilot 好的,让我们从第一个问题开始:

  1. 你认为什么是强密码?

现在我们可以开始回答问题了。Copilot 将根据这些答案为我们编写代码。

LPHuman 密码必须至少包含一个大写字母、一个数字和一个标点符号。

chatGptGitHub Copilot 太好了,感谢你告诉我!这是下一个问题:

  1. 2. 这个函数应该有任何输入参数吗?

LPHuman 我不知道。它们会被用来做什么?

chatGptGitHub Copilot 输入参数用于将数据传递到函数中,以便函数对这些数据执行某些操作。例如,如果我们想编写一个将两个数字相加的函数,我们需要将这两个数字作为输入参数传递给函数。

在我们的密码函数中,我们可能希望传入一些参数来定义强密码的要求。例如,我们可能希望传入密码的最小长度、要求的大写字母数量、要求的数字数量以及要求的标点符号数量。

或者,我们也可以将这些要求硬编码到函数中,但使用输入参数会使函数更加灵活和可重用。

LPHumanPlease 硬编码它们。

chatGptGitHub Copilot 当然,我们可以将密码要求硬编码到函数本身。接下来是下一个问题:

  1. 3. 该函数应该返回什么?

LPHuman 它应该返回用户的强密码。

chatGptGitHub Copilot 太好了,谢谢你告诉我!接下来是下一个问题:

  1. 4. 该函数是否应该有副作用,例如向控制台打印消息?

我们到此为止,因为我们认为阅读完整的交互过程并不有趣。我们继续与 Copilot 对话并回答了问题 4 和 5,Copilot 获取了所需的信息,能够编写我们想要的代码。最终我们收到的代码,在回答完所有五个问题后,显示在以下列表中。

列表 12.1 通过反转交互模式设计的函数
import string

def get_strong_password():
    '''
    Keep asking the user to enter a password until they provide 
    a strong password.
    A strong password has at least one uppercase character, one 
    number, and one punctuation character.
    '''
    # Print the password requirements
    print("Your password must contain at least:")
    print("- One uppercase letter")
    print("- One number")
    print("- One punctuation character")

    # Keep asking for a password until a strong one is provided
    while True:                                          #1
        password = input("Enter a password: ")

        # Check if the password meets the requirements
        if not any(c.isupper() for c in password):        #2
            print("Password does not contain an uppercase letter.")
        elif not any(c.isdigit() for c in password):        #3
            print("Password does not contain a number.")
        elif not any(c in string.punctuation      #4
                     for c in password):          #4
            print("Password does not contain a punctuation character.") #4
        else: #4
            print("Password is strong!") #4
            return password                     #5

1 这个 True 条件永远无法变为 False!

2 如果密码中没有大写字母

3 如果密码中没有数字字符

4 如果密码中没有标点符号

5 这就是我们退出“while True”循环的方法。

在我们到目前为止的while循环中,我们总是使用一个我们希望最终变为False的循环条件(否则我们将遇到无限循环)。例如,我们可以想象有一个循环,实际上是“当密码无效时”。但在这里,代码使用的是while True,而True是一个永远不会变为False的条件。必须有其他方式可以退出循环——确实有!当密码有效时,我们通过return语句退出循环。也就是说,这里的逻辑是“循环永远进行;如果密码有效,则停止循环。”while True形式的while循环是我们在本书中看到的一种常见替代结构。

这段代码很好地实现了我们的任务。它首先通过一些print调用告诉用户强密码的规则。如果密码不够强,它还会告诉用户密码的错误之处。它会一直循环,直到用户输入一个强密码。当用户最终输入强密码时,我们就返回它。

现在,鉴于我们在第三章中的工作,我们已经知道如何编写这个函数。到目前为止,你的 Python 知识已经足够强大,你可以直接切入正题,写出函数签名,让 Copilot 按照我们在本书中一直做的方式编写代码。但对于一些你不太熟悉的任务,并且可能不知道从哪里开始的情况,这种反转交互模式可能会有所帮助。

12.1.2 人物模式

这种模式的目的是让 AI 扮演特定的角色或观点。为什么我们会有动机这么做呢?举个例子:假设你是一个初学 Python 编程课程的学生。你的老师在课程中使用 Copilot,但他们禁止了一些他们认为会影响学习初级 Python 的 Python 功能。你可以要求 Copilot 扮演这位教师的角色,然后帮助你检查代码,找出并在必要时移除任何不允许使用的 Python 功能。我们提示的结构看起来会是这样的:

从现在开始,扮演角色 X。提供角色 X 会创建的输出。

我们将用一个特定的人物角色来填充 X。让我们尝试使用我们介绍性编程教师的角色。我们将要求 Copilot 扮演这个角色,同时禁止一些更高级的 Python 概念。(即使你从未听说过这些概念,也不用担心!)

LPHuman 从现在开始,扮演一名初级 Python 编程课程的教师。在你教授的课程中,以下 Python 特性是不允许的:列表推导式、生成器、lambda 以及 any 函数。我将提供代码,你将检查这些代码,找出任何不允许使用的 Python 功能,并建议如何重写代码以不使用这些功能。

chatGptGitHub Copilot 好的,我可以做到!请提供你希望我审查的代码。

让我们输入上一节的 get_strong_password 函数。它使用了 any 函数,而根据我们的角色规则,这个函数是不允许的。以下是我们原始的代码和 Copilot 做出的响应。

LPHuman

*`*def get_strong_password():*`*
 *`*'''*`*
 *`*Keep asking the user to enter a password until they provide*`* 
 *`*a strong password.*`*
 *`*A strong password has at least one uppercase character, one*`* 
 *`*number, and one punctuation character.*`*
 *`*'''*`*
 *`*# Print the password requirements*`*
 *`*print("Your password must contain at least:")*`*
 *`*print("- One uppercase letter")*`*
 *`*print("- One number")*`*
 *`*print("- One punctuation character")*`*

 *`*# Keep asking for a password until a strong one is provided*`*
 *`*while True:*`*
 *`*password = input("Enter a password: ")*`*

 *`*# Check if the password meets the requirements*`*
 *`*if not any(c.isupper() for c in password):*`*
 *`*print("Password does not contain an uppercase letter.")*`*
 *`*elif not any(c.isdigit() for c in password):*`*
 *`*print("Password does not contain a number.")*`*
 *`*elif not any(c in string.punctuation for c in password):*`*
 *`*print("Password does not contain a punctuation character.")*`*
 *`*else:*`*
 *`*print("Password is strong!")*`*
 *`*return password*`*

chatGptGitHub Copilot get_strong_password 函数使用了 any 函数,而你的课程中不允许使用该函数。以下是一个不使用 any 函数的修改版本:

*`*import string*`*

*`*def get_strong_password():*`*
 *`*'''*`*
 *`*Keep asking the user to enter a password until they provide*`*
 *`*a strong password.*`*
 *`*A strong password has at least one uppercase character, one*`* 
 *`*number, and one punctuation character.*`*
 *`*'''*`*
 *`*# Print the password requirements*`*
 *`*print("Your password must contain at least:")*`*
 *`*print("- One uppercase letter")*`*
 *`*print("- One number")*`*
 *`*print("- One punctuation character")*`*

 *`*# Keep asking for a password until a strong one is provided*`*
 *`*while True:*`*
 *`*password = input("Enter a password: ")*`*

 *`*# Check if the password meets the requirements*`*
 *`*has_uppercase = False*`*
 *`*has_number = False*`*
 *`*has_punctuation = False*`*
 *for c in password:* #1
 *`*if c.isupper():*`*
 *`*has_uppercase = True*`*
 *`*elif c.isdigit():*`*
 *`*has_number = True*`*
 *`*elif c in string.punctuation:*`*
 *`*has_punctuation = True*`*
 *`*if not has_uppercase:*`*
 *`*print("Password does not contain an uppercase letter.")*`*
 *`*elif not has_number:*`*
 *`*print("Password does not contain a number.")*`*
 *`*elif not has_punctuation:*`*
 *`*print("Password does not contain a punctuation character.")*`*
 *`*else:*`*
 *`*print("Password is strong!")*`*
 *`*return password*`*

1 替换“any”的循环

这个版本的函数使用 for 循环遍历密码中的每个字符,检查它是否符合要求。这个方法比使用 any 函数更冗长,但它没有使用被禁止的 Python 功能。

正如 Copilot Chat 所指出的,这个程序没有使用 any 函数。Copilot 已经重写了程序,避免了使用该函数,从而遵守了我们 Python 编程角色设定的规则。

any 函数会返回 True,如果它的参数中有一个或多个值为真;如果所有的值都为假,则返回 False。在幕后,any 必须查看每个值以检查它是对还是错。这听起来像是一个循环,对吧?事实上,Copilot 正是用循环来替代 any 函数调用的。

12.2 限制与未来方向

在我们与 Copilot(以及学生)合作的经验中,并阅读了在我们编写本书期间发布的研究文献时,我们观察到了一些生成式 AI 模型的局限性和未来可能性,我们希望分享这些观察。

12.2.1 Copilot(当前)面临的挑战

我们发现 Copilot 在三种不同的场景中遇到了困难:人为设计的问题、不明确的问题和大型任务。

人为设计的问题

在 ChatGPT 和 GitHub Copilot 横扫全球后不久,编程教师们疯狂地寻找这些工具无法做好的任务类型。一项早期提议是使用人为设计的任务:通过使任务变得模糊和复杂,试图引导像 Copilot 这样的工具生成错误的代码。对我们来说,这些任务完全没有趣味,并且对学生不公平,它们的使用似乎是在试图坚持之前的编程课程风格,而没有意识到像 Copilot 这样的工具所带来的颠覆性变化。我们的感觉是,随着 Copilot 模型的不断改进,这些人为设计的任务最终会被 Copilot 解决,但即使没有解决,这些问题通常也不是必须要解决的。

不明确的问题

一个不明确的问题是指一个没有被精确描述的问题,这意味着我们并不完全知道或尚未决定在所有情况下应该做什么。例如,要求 Copilot 编写一个函数来判断密码是否强大,这就是一个不明确的问题,直到我们定义“强密码”是什么意思为止。你可能首先认为 Copilot 不可能在解决这类问题上变得更好。毕竟,如果我们很难精确定义我们想要的行为,怎么能把所需的行为传达给 Copilot 呢?不明确的问题对于 Copilot 确实是一个挑战,但并不是不可能解决的。还记得我们在本章早些时候讨论的反转交互提示模式吗?也许,不久的将来,当 Copilot 没有足够的精确信息来解决问题时,它会自动切换到这种模式。

大型任务

本书中我们花了很多时间教你如何设计小功能,以及如何通过自顶向下的设计将这些功能组织起来,以解决大型问题。我们这么做是因为当面对一个整体性的任务时,Copilot 很难解决。这个问题是类似 Copilot 的工具固有的局限性,还是人工智能能够克服它?我们不知道。现在,Copilot 在问题分解方面很挣扎,即便它能够做到这一点,概率也不在它的一边。具体来说,它写代码越多,出错的概率就越高。例如,如果它需要写 20 个函数来解决一个任务,每个函数平均 10 行代码,它几乎肯定会在某个地方出错。但与这些系统对抗也是愚蠢的。随着 Copilot 学习方式的进步,也许这一点并不遥远。

12.2.2 Copilot 是一种新的编程语言吗?

当我们用像 Python 这样的语言编写计算机代码时,背后有一个编译器将 Python 代码转换为计算机能够理解的汇编代码或机器代码。曾经有一段时间,程序员用像 Fortran 或 C 这样的旧计算机语言编写代码,运行编译器生成汇编语言代码,然后检查汇编代码确保它是正确的。那时候我们还没出现过,可能也不会发生太多这样的情况,但我们能理解为什么当时程序员不信任编译器。编译器是一项新技术,而且需要时间来解决其中的错误。此外,编译器输出的代码相对于手写的汇编代码来说,可能效率较低。但经过几十年的改进,无论是在正确性还是效率方面(编译器优化对于任何好奇的人来说都是一个非常激动人心和有趣的主题!),现在很少有程序员会查看编译器的输出。是否可能会有这样的一天,人类只是将大型语言模型(LLMs)作为与计算机交互的主要接口,而不花太多时间检查它们生成的代码?让我们考虑一下这个问题的两种可能答案。

为什么 LLMs 可能不会取代编程语言

有理由认为 LLMs 不会成为人类编程计算机的主要接口。最主要的原因是,LLMs 不是一种严格的编程语言。我们信任编译器,因为每种编程语言都有其语言规范,并且每行代码都有指定的、精确的预期行为。而 LLMs 并没有这些。它只是有人用英语或其他自然语言向 LLMs 输入内容。LLMs 并不一定按照某种特定的方式来解释自然语言。它不需要基于任何严格的规范输出答案。而且,由于随机性和非确定性,它给出的答案可能会有所不同或错误!编译器是确定性的,而且现在已经是一个成熟、值得信赖的技术。它们没有这些问题。

为什么 LLMs 可能会取代编程语言

也有理由相信,像编译器一样,大型语言模型(LLMs)将变得更好,并成为我们主要的编程接口。事实上,在数据科学领域,这已经开始发生了。

正如我们在本书中所学到的,与 Copilot 合作的一个关键挑战是判断生成的代码是否正确。从某种意义上说,这对非程序员是不公平的:我们给 Copilot 提供自然语言(我们的偏好),而它给我们回馈的代码却不是自然语言(计算机的偏好)。如果我们可以“跳过代码”,不仅用自然语言与 Copilot 沟通,还能直接得到我们的答案,那就太好了。

研究人员开始在计算机用户关注的受限领域探索这一可能性。举个例子,考虑数据科学。数据科学家通过探索数据、可视化数据以及利用数据进行预测来理解数据。他们所做的许多工作涉及以受限且广为人知的方式操作数据,例如合并电子表格、清理特定数据列,或进行聚类分析将数据划分为有意义的类别,或者简化数据以专注于其核心结构。使用 Python 的数据显示科学家通常会使用许多库来处理数据,但一个极为流行的库叫做 pandas。

研究人员成功地在使用 pandas 进行数据科学的过程中“跳过了代码”[3]。它是如何运作的呢:

  1. 用户用自然语言表达他们的意图,例如英语。

  2. AI 生成 Python 代码并运行它,获取用户的结果(例如,分析结果表或新的电子表格)。重要的是,用户看不到这些 Python 代码。

  3. AI 将代码转换回自然语言,并将其(而不是 Python 代码!)呈现给用户。用户收到的自然语言是以一致的格式呈现的,AI 可以可靠地理解。研究人员这样描述 AI 回应的目的:“这就是你应该告诉系统做你刚才要求它做的事情的方式。” 这个回应帮助用户理解 AI 的能力以及哪些查询是有效的。

  4. 如果自然语言从第三步返回的内容不正确,用户可以进行编辑。如果用户做出修改,他们可以提交新的提示,从而重复整个周期。

研究人员提供的一个例子阐明了这个过程[3]。假设我们有一份每行代表一个宇航员的电子表格。每一行有三列相关数据:宇航员的名字、他们在太空的总时长以及他们参与的任务的逗号分隔列表。我们想要计算每个宇航员的平均任务时长。

在第一步中,用户写下类似“计算平均任务时长”的提示。在第二步中,AI 生成与该提示相对应的代码。它运行该代码,并将包含平均任务时长的新列添加到用户的电子表格中。在第三步中,AI 将代码转换为自然语言的任务列表,如下所示:

  1. 创建列“任务时长”。

  2. 列“太空飞行(小时)”除以(列“任务”中的逗号数 + 1)。

在第 4 步中,用户可以编辑第 3 步中的自然语言,并将更新后的任务重新提交给 AI。我们是否能够在更广泛的“Python 编程”领域中“跳过代码”,而不是局限于“使用 pandas 进行数据科学”的更狭窄场景呢?现在还为时过早。数据处理的优势在于它能够以可视化的方式呈现,通过电子表格和图表直接展示给用户,用户可能能判断分析是否正确,或者是否需要进一步的提示工程。这样的可视化表示对于通用编程而言并不容易显现。

尽管如此,我们仍然能想象一个新时代,在这个时代里,人类继续执行重要任务,如问题分解、指定程序行为、编写测试、设计算法等等,而程序的编写完全由 LLM 来完成。人类向 AI 工具说明程序需要做什么,并提供测试用例,AI 则生成代码。然后,人类可以检查程序是否正常工作,而无需查看代码。

关于 LLM 是否会取代编程语言的另一种看法,我们推荐由编程和编译专家 Chris Lattner 所写的博客文章[4]。Lattner 认为,至少在短期内,甚至可能更长时间内,编程语言不会消失,因为 LLM 生成的代码可能会存在细微的错误。所以,如果编程语言在一段时间内还会存在,那么问题就变成了:我们应该使用哪种编程语言?Lattner 表示:“最适合 LLM 的语言是那种对人类来说易于使用和阅读,但其实现能够扩展到许多不同的用例和应用场景的语言”[4]。现有的语言能达到这一目标吗?我们能否通过设计一种比 Python 更易读的编程语言来做得更好?敬请期待!

好吧,也许编程语言会消失,也许它们不会消失;也许它们会改变。不管怎样,我们是否需要担心程序员的工作?我们认为不需要。任何在软件公司工作过的人都会告诉你,编写代码并不是程序员用来打发时间的唯一,甚至是主要的工作。程序员还需要与客户会面,确定他们的需求。他们会指定程序的功能以及它们如何协同工作。他们检查系统的性能和安全问题。他们与其他团队合作,协调设计大型软件项目。如果编写代码的步骤变得更简单,也许我们只是得到了更有用的软件。这就像我们当初拥有了高级语言一样。没人会用汇编语言来编写下一个杀手级应用程序!编译器提高了我们制作软件的效率。我们认为,LLM 如果被谨慎使用,也能达到这一目标。

激动人心的未来

尽管我们对未来仍然有些不确定,但显然 LLM 将会极大地改变编程的未来。也许现在它们只是帮助软件工程师编写更好代码的辅助工具。然而,或许五年后,大多数软件将由 LLM 编写,只有一小部分软件工程师会从零开始编写代码。无论哪种结果最终成真,变化正在迅速到来,它们可能会让更多人能够编写符合自己需求的软件。

截至目前,我们已经对这些工具的使用方式有了一些见解。在一项调查中,92%的软件开发人员报告称定期使用这些工具[5]。最近的研究发现,使用这些工具的开发人员比没有使用工具时更具生产力[6]。在一项特别有趣的研究中,UC 圣地亚哥的研究人员发现,专业开发人员使用这些工具有两种方式:加速和探索[7]。在加速阶段,开发人员使用这些工具来帮助他们比自己手写代码更快地生成代码。在探索阶段,开发人员使用这些工具来帮助他们找到可能有用的库,或者理解现有的代码库。这些发现与我们在本书中所学的一致,我们期待未来的研究。

最重要的是,我们希望你现在能够做出自己的、明智的决定,关于如何使用 LLM(大语言模型)进行编程,以及 LLM 可能对编程的未来意味着什么。在这样的时刻,一些人热烈宣称:“编程已经结束!”而另一些人则同样热烈地宣称:“编程几乎不会发生太大变化!”[8],对我们来说,能够自己权衡这些论点以及这种变化如何影响我们和其他人非常重要。这些工具能帮助我们吗?我们认为可以。因此,我们应该使用它们,但要负责任地使用它们。有担忧吗?同样,我们认为是有的,正如我们在书中所讨论的。我们应该采取措施,如测试和调试,来减轻这些担忧。

本书中我们采用的编程教学方法是全新的。像我们这样的教师和教授才刚刚开始将类似 Copilot 的工具纳入编程课程[9],尽管我们仍在努力平衡何时让学习者使用 Copilot 以及何时不使用这些工具。无论程序员选择什么样的方式来学习编程,我们都希望强调你已经学到了每个人在编写优秀软件时都需要掌握的至关重要的技能,无论你是在工作中偶尔编写代码以自动化繁琐的任务,还是计划成为一名专业的软件工程师。你已经打下了坚实的基础,无论你的道路将带你走向何方。

12.3 练习

  1. 解释如何使用人物角色模式来强制执行公司的编码标准。例如,公司可能要求员工始终使用驼峰命名法(例如:xVal,ZValOut)。提供一个示例提示,说明如何让 GitHub Copilot 充当管理者来执行特定的编码标准。

  2. 你需要创建一个 Python 函数,根据用户的首选用户名和全名来生成一个用户名。该函数应遵循以下规则:

    1. 如果首选用户名可用(即尚未被占用),则使用该用户名。

    2. 如果首选用户名已被占用,通过在用户名后附加一个数字(例如 1、2、3……)生成一个新的用户名。

    3. 如果生成的用户名已被占用,使用用户的全名,通过组合其名字和姓氏的一部分来生成用户名。

    4. 如果生成的用户名已被占用,函数应继续请求新的首选用户名。

你会使用哪种交互模式(反转交互模式人物角色模式)来设计这个函数,并借助 GitHub Copilot?请说明你的选择,并概述你如何开始这个交互。

摘要

  • 提示模式是一个模板,帮助我们构建一个能够实现特定目标的提示。

  • 反转交互模式(flipped interaction pattern)改变了规则:与其向 LLM 提问,不如让 LLM 向我们提问。

  • 反转交互模式在我们不知道如何有效地向 LLM 提出提示时非常有用。

  • 人物角色模式用于让 AI 扮演特定的人物角色,例如“初级编程讲师”或某个特定的视角。

  • 当我们希望大型语言模型(LLM)从某个特定角度进行回应时,人物角色模式(persona pattern)非常有用。

  • Copilot 当前在处理那些人为设计、定义不清或规模较大的任务时表现较差。

  • 有些人认为,LLM 将取代编程语言;而另一些人则认为编程语言将继续存在。

  • 大型语言模型正在帮助人们完成数据科学任务,而这些人甚至没有接触到底层的编程语言代码。

  • 可能编程语言本身不会被取代,但领先的编程语言将会发展得比今天的语言更具可读性。

posted @ 2025-11-09 18:00  绝不原创的飞龙  阅读(6)  评论(0)    收藏  举报