AI 助手如何改变新程序员的学习方式
为什么编程永远不会一成不变
如何使用 GitHub Copilot 等 AI 助手工作
AI 辅助编程的可能风险
计算机编程长期以来一直是受过特殊培训并拥有高级技能的专业人士的领域。毕竟,你希望运行银行、电话、汽车等应用的程序每次都能完全正确地运行!正如曾经占据房间大小的计算机,堆满纸卡片和数英里长的磁带被现代设备所取代一样,编程语言和工具也变得更容易使用。现在,像 ChatGPT 这样的 AI 工具让计算机编程几乎对每个人来说都触手可及。我们希望帮助你打开这扇门!
学习如何编程,你将能够承担工作中的新任务,创建自己的电脑游戏,并在工作中让电脑为你工作。在这本书中,我们将向你展示如何使用 ChatGPT 和 GitHub Copilot 编写自己的计算机程序。在这个过程中,你将学习一些 Python 编程语言中的技能,Python 是最受欢迎的编程语言之一。
我们很高兴我们不再是这样编程的。那个怪物是用汇编语言编写的,汇编语言是一种低级编程语言。正如你所见,低级编程语言并不是人类可以轻松阅读和编写的语言。它们是为计算机设计的,而不是为人类设计的。
没有人想编写那样的程序,但,特别是在过去,有时这是必要的。程序员可以使用它来精确定义他们希望计算机执行的操作,甚至到单个指令。这种程度的控制是必要的,以便从性能不足的计算机中榨取每一丝性能。例如,90 年代最速度关键的电脑游戏,如 Doom 和 Quake,就是用汇编语言编写的,就像之前的代码示例一样。否则,制作那些游戏是不可能的。
好的,让我们继续前进。这是一个更现代的计算机程序,它也会打印数字。
这段代码是用 Python 语言编写的,这是许多程序员现在使用的语言。与低级语言汇编语言不同,Python 被认为是一种高级语言,因为它与自然语言更加接近。即使你还不了解 Python 代码,你也可能猜出这个程序试图做什么。第一行看起来像是在处理从 0 到 9 的数字范围。第二行是在打印某些内容。相信这个程序,就像汇编语言的怪物一样,旨在打印从 0 到 9 的数字并不困难。不幸的是,出了点问题,它只打印了 0 到 8 的数字。
虽然这段代码更接近英语,但它并不是英语。它是一种像汇编语言一样的编程语言,具有特定的规则。就像之前的代码一样,误解这些规则的细节可能会导致程序出错。如果你好奇,误解的规则是range函数在第二个数字之前停止,所以它不包括数字 9。如果你想从 0 到 9,你需要说range(0,10)。
与计算机进行交流的圣杯是使用像英语这样的自然语言。在过去 80 年里,我们使用各种编程语言与计算机交谈,并不是因为我们想这么做,而是因为我们不得不这么做。计算机的运算能力不足以处理像英语这样的语言的复杂性和特殊性。我们的编程语言从符号汤汇编语言发展到例如 Python,但它们仍然是计算机语言,而不是自然语言。这种状况正在改变。
使用人工智能助手,我们现在可以用英语提出我们想要的内容,并让计算机为我们编写相应的代码。为了得到一个正确打印从 0 到 9 的 Python 程序,我们可以用正常的英语语言向我们的 AI 助手(Copilot)提出如下请求:
Copilot 可能会对此提示做出如下回应:
与我们之前展示的例子不同,这段 Python 代码实际上是可以运行的!
人工智能编码助手可以帮助人们编写代码。在这本书中,我们将学习如何使用 Copilot 为我们编写代码。我们可以用英语提出我们想要的内容,然后得到 Python 代码的回应。
不仅如此,我们还将能够将 Copilot 作为工作流程中无缝的一部分来使用。如果没有像 Copilot 这样的工具,程序员通常会打开两个窗口:一个用于编写代码,另一个用于询问 Google 如何编写代码。第二个窗口包含 Google 搜索结果、Python 文档或程序员论坛,他们讨论如何编写代码来解决特定问题。他们经常将这些结果中的代码粘贴到他们的代码中,然后根据他们的上下文稍作修改,尝试其他替代方案等等。这已经成为程序员的一种生活方式,但你可以想象这里的低效。据估计,高达 35%的程序员时间用于搜索代码[1],而且找到的代码往往不易使用。Copilot 通过帮助我们编写代码,极大地改善了这种体验。
本书我们将使用两种主要技术:Python 和 GitHub Copilot。Python 是我们将使用的编程语言,GitHub Copilot 是我们的人工智能助手,它将帮助我们与 Python 代码一起工作。
如前所述,Python 是一种编程语言,它是与计算机进行交流的方式。人们用它编写各种程序,执行有用的任务,如数据分析、游戏、交互式网站、可视化、文件组织应用、自动化常规任务等等。
还有其他编程语言,例如 Java、C++、Rust 以及许多其他语言。Copilot 也可以与这些语言配合使用,但截至本书编写时,它与 Python 配合得非常好。与许多其他语言(尤其是汇编语言)相比,Python 代码编写起来要容易得多。更重要的是,Python 易于阅读。毕竟,我们不会是编写 Python 代码的人——我们的 AI 助手会!
计算机不知道如何读取和运行 Python 代码。计算机唯一能理解的是一种称为机器码的东西,它看起来甚至比汇编码还要荒谬,因为它实际上是汇编码的二进制表示(是的,只是一堆 0 和 1!)。在幕后,你的计算机将你提供的任何 Python 代码转换为机器码,然后再运行,如图 1.1 所示。
所以,现在没有人再从零开始用计算机的机器码语言编写代码了。程序员们都会选择当时对他们特定任务最方便的语言,并使用软件来帮助他们编写、运行和调试(即修复)代码,这种软件被称为集成开发环境(IDE)。在本书中,我们将使用 Visual Studio Code(VS Code)作为我们的 IDE,因为它与 GitHub Copilot 配合得非常好。
什么是 AI 助手?AI 助手是一种 AI 代理,它帮助你完成工作。也许你家里有 Amazon Alexa 设备或者一部带有 Siri 的 iPhone——这些是 AI 助手。它们帮助你订购杂货,了解天气,或者确定,是的,在 哈利·波特 电影中扮演贝拉特里克斯的女演员真的在 斗士俱乐部 中出现过。AI 助手只是一个能够对典型的人类输入,如语音和文本,以类似人类的回答进行响应的计算机程序。
Copilot 是一个具有特定任务的 AI 助手:它将英语转换为计算机程序(以及更多,我们很快就会看到)。还有其他像 Copilot 一样的 AI 助手,包括 Amazon Q Developer、Tabnine 和 Ghostwriter。我们选择 Copilot 来编写这本书,是基于我们能够产生的代码质量、稳定性(它从未崩溃过!)以及我们自己的个人偏好。我们鼓励你在感到舒适的时候也去检查其他工具。
你可以把 Copilot 看作是你和你要编写的计算机程序之间的一个层。你不需要直接编写 Python,你只需用文字描述你想要的程序——这被称为 提示——然后 Copilot 就会为你生成程序。
Copilot 背后的“大脑”是一个名为 大型语言模型(LLM)的复杂计算机程序。LLM 存储关于词语之间关系的信息,包括哪些词语在特定上下文中是有意义的,并使用这些信息来预测对提示的最佳词语序列。
想象一下,我们问你这句话的下一个词应该是什么:“这个人打开了 ________。”这里可以填入很多词,比如“门”、“盒子”或“对话”,但也有许多词不适合这里,比如“the”、“it”或“open”。LLM 会考虑到词语的当前上下文来生成下一个词,并且它会一直这样做,直到完成任务。它以 非确定性 的方式做这件事,这意味着它的决策是有些随机的,也就是说,如果你要求它填入那个词,有时它会给你“门”这个词,有时它会给你“盒子”这个词。这意味着如果你要求 Copilot 给你代码,它可能会每次给出不同的答案。
此外,我们并没有说 Copilot 理解它在做什么。它只是使用当前上下文来继续编写代码。在整个过程中请记住这一点:只有我们知道生成的代码是否是我们想要它做的。通常它确实做到了,但无论如何,你都应该保持健康的怀疑态度。图 1.2 展示了 Copilot 从提示到程序的过程。
你可能会想知道为什么 Copilot 为我们编写 Python 代码而不是直接编写机器代码。Python 现在不是可替代的中间步骤吗?嗯,不是的,原因在于 Copilot 会犯错误。而且如果它要犯我们需要修复的错误,用 Python 来做比用机器代码要容易得多。
事实上,几乎没有人检查从 Python 生成的机器代码是否正确。这部分原因是因为 Python 语言规范的确定性。我们可以想象一个未来,Copilot 的对话如此精确,以至于检查 Python 代码变得没有必要,但我们离那个未来还有很长的路要走。
在过去学习如何编程时,学习者通常花费大部分时间与程序的语法和基本结构打交道。当我们提到 语法 时,我们指的是在给定语言中有效的符号和单词。程序员需要从头开始(一个字符一个字符,一行一行)编写程序的所有语法。学习编程的人过去常常需要花费数周或数月才能达到能够编写甚至基本程序的水平。现在,Copilot 可以立即编写这些相同的基本程序,并提供的代码几乎总是语法和结构上正确的。正如你将在本书的其余部分看到的那样,我们仍然需要验证这些代码是否正确,因为 Copilot 可能会出错。然而,我们不再需要从头编写它。我们相信 Copilot 和类似工具标志着人们学习编程的旧方式的终结。
你作为一个对学习编程感兴趣的人,根本不需要与语法作斗争,理解如何准确调用给定的 Python 函数,以及编写代码时所需的其他 Python 概念。当然,我们将在本书中学习这些概念,但不是为了让你通过从头编写 Copilot 可以轻松生成的代码来展示你的理解。不,我们学习这些概念仅仅是因为它们帮助我们解决有意义的问题,并与 Copilot 有效地互动。相反,你将能够更快地学习如何编写更大、更有意义的软件,因为 AI 助手从根本上改变了学习编程所需掌握的技能。
正如我们所看到的,我们可以使用 Copilot 根据我们想要的英文描述来为我们编写 Python 代码。因此,我们可以这样说,Copilot 接收英文语法的描述,并以 Python 语法的代码返回。这是一个巨大的进步,因为学习编程语法在历史上一直是新程序员的一个主要障碍。我应该在这里使用哪种括号——[、( 或 {]?我需要在这里缩进吗?我们应该按照什么顺序编写这些内容:x 然后是 y,还是 y 然后是 x?
这样的问题层出不穷,而且——让我们说实话——这并不有趣。当我们只想编写一个程序让某事发生时,谁会在乎这些呢?Copilot 可以帮助我们摆脱语法上的枯燥。我们认为这是帮助更多人成功编写程序的重要一步,我们期待着有一天这个人工障碍被完全消除。目前,我们仍然需要 Python 语法,但至少 Copilot 在这方面帮助我们。
解释代码 — 当 Copilot 为我们生成 Python 代码时,我们需要确定该代码是否满足我们的需求。同样,正如我们之前所说的,Copilot 可能会犯错误。尽管我们并不想教你 Python 工作的每一个细节(那是旧的编程模式),但我们会教你如何阅读 Python 代码,以获得对其功能的整体理解。我们还会使用 Copilot 的代码解释功能,用英语向你解释代码。当你完成这本书和我们的解释后,你仍然可以使用 Copilot 来帮助你理解它给出的下一部分复杂的代码。
使代码更容易理解 — 有不同的方式来编写代码以完成同一任务。有些可能比其他更容易理解。你可以要求 Copilot 重新组织你的代码,使其更容易使用。易于阅读的代码在需要时通常更容易增强或修复。
修复错误 — 一个 错误 是在编写程序时犯下的错误,可能会导致程序执行错误。有时,你的 Python 代码几乎可以工作,或者几乎总是可以工作,但在一种特定情况下却不行。如果你听过程序员的谈话,你可能听说过这样一个常见的例子:程序员可能花费数小时,最终只移除了一个导致程序失败的等号符号。那可不是几个小时的乐趣!在这些情况下,你可以尝试 Copilot 的功能,该功能可以帮助自动找到并修复程序中的错误。
解释错误 — 如果你的代码没有正确工作,你通常会从 Python 运行时环境得到一个错误报告。有时,这些错误可能相当晦涩难懂,但 Copilot 可以帮助你解释错误,并指导你如何修复它。
寻找 Python 库 — Python 是一种成熟的编程语言,拥有许多模块(库)可以帮助完成特定任务,例如数据分析、编写游戏、处理不同的图像文件格式等等。与 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 在某些情况下(例如,考试)或对某些学生(计算机科学专业学生)是不被允许的。在许多学校,LLMs 被允许作为学生的辅导老师。在某些情况下,LLM 辅导老师只是像 Copilot 或 ChatGPT 这样的常规 LLM,但在其他情况下,LLM 界面已被改变以限制学生收到的答案类型。目前还太早知道 LLMs 将如何影响计算机教育,但这样的趋势已经初露端倪。
代码质量—我们需要小心不要信任 Copilot,特别是对于敏感代码或需要安全的代码。例如,为医疗设备编写的代码或处理敏感用户数据的代码必须始终彻底理解。向 Copilot 请求代码,对它产生的代码感到惊奇,并未经审查就接受这些代码是很诱人的。但在本书中,我们将处理不会大规模部署的代码,因此,虽然我们将关注获取正确的代码,但我们不会担心使用此代码的更广泛用途的影响。我们还将建立你需要的独立确定代码是否正确的基础。
代码安全—与代码质量一样,当我们从 Copilot 获取代码时,代码的安全性绝对不能得到保证。例如,如果我们正在处理用户数据,仅仅从 Copilot 获取代码是不够的。我们需要进行安全审计,并拥有专业知识来确定代码的安全性。然而,尽管如此,我们不会在实际场景中使用 Copilot 的代码,因此我们不会关注安全问题。
非专家—成为专家的一个标志是意识到自己知道什么,同样重要的是,知道自己不知道什么。专家通常也能表达他们对回答的信心程度,如果他们不够自信,他们会进一步学习,直到他们确信自己知道。Copilot 和更广泛的 LLMs 则不具备这种能力。你问他们一个问题,他们就会直接回答,就是这样。如果需要,他们会编造:他们会将真实信息与垃圾信息混合,形成一个听起来合理但实际上毫无意义的回答。例如,我们见过 LLMs 为活着的人编造讣告,这显然是没有意义的,但这些“讣告”确实包含了一些关于人们生活的真实信息。当被问及算盘为什么能比电脑更快地完成数学运算时,我们见过 LLMs 给出听起来自信的回答——包括关于算盘是机械的,因此必然是最快的说法。LLMs 在这个领域正在进行工作,以便能够说,“抱歉,不,我不知道这个。”但我们还没有达到那个阶段。他们不知道自己不知道什么,这意味着他们需要监督。
偏见——LLMs 会复制它们在训练数据中存在的相同偏见。如果你要求 Copilot 生成一个名字列表,它将主要生成英语名字。如果你要求一个图表,它可能产生的图表没有考虑人类之间的感知差异。而且,如果你要求代码,它可能产生一种特定群体编写代码风格的代码。(毕竟,在计算机科学中,人口群体得到了很好的代表,Copilot 就是在这些代码上训练的。)计算机科学和软件工程长期以来一直遭受着缺乏多样性的困扰。我们无法进一步压制多样性,我们需要扭转这一趋势。我们需要让更多的人加入,并允许他们以自己的方式表达自己。如何使用像 Copilot 这样的工具来处理这个问题目前正在被解决,这对编程的未来至关重要。然而,我们相信 Copilot 有潜力通过降低进入该领域的门槛来提高多样性。
如果 Copilot 可以编写我们的代码、解释它并修复其中的错误,我们是不是就完成了?我们是不是只需要告诉 Copilot 要做什么,然后庆祝我们的纯粹厉害?
不。首先,Copilot 可能会犯错误。它给出的代码可能在语法上是正确的,但有时它并不做我们想要它做的事情。我们需要保持警惕,以便在 Copilot 犯这些错误时能够捕捉到。其次,尽管程序员依赖的一些技能(例如,编写正确的语法)的重要性可能会降低,但其他技能仍然至关重要。例如,你不能把一个巨大的任务扔给 Copilot,比如,“制作一个视频游戏。哦,还要让它有趣。”Copilot 会失败的。相反,我们需要将这样一个大问题分解成 Copilot 可以帮助我们的小任务。我们如何那样分解问题?实际上并不容易。当与像 Copilot 这样的工具进行对话时,人类需要发展这种关键技能,我们在整本书中都在教授这种技能。
无论如何,其他技能在 Copilot 的帮助下可能会变得更加重要。测试代码在创建高质量代码中一直是一项关键任务。我们了解人类编写的代码的测试,因为我们知道在哪里寻找典型问题。我们知道人类经常在值的边界处犯编程错误。例如,如果我们编写了一个乘以两个数的程序,我们可能会得到大多数值正确,但可能不会在其中一个值为 0 时正确。那么,AI 编写的代码呢?20 行无瑕疵的代码可能隐藏着一行如此荒谬的代码,我们可能不会期望它在那里。我们没有这方面的经验。我们需要比以前更加仔细地测试。
我们还需要知道如何在代码出错时修复错误。这个过程被称为调试,并且仍然非常重要,尤其是当 Copilot 给你接近正确但还不够准确的代码时。
最后,一些必需的技能完全是新的。其中最主要的是被称为提示工程的技能,它涉及如何告诉 Copilot 要做什么。如前所述,当我们要求 Copilot 编写一些代码时,我们使用提示来发出请求。虽然我们可以用英语来编写这个提示并要求我们想要的东西,但这还不够。如果我们想让 Copilot 有做对事情的机会,我们需要非常精确。即使我们很精确,Copilot 仍然可能做错事情。在这种情况下,我们首先需要确定 Copilot 确实犯了一个错误。然后,我们可以尝试调整我们的描述,希望将其引导到正确的方向。根据我们的经验,对提示的看似微小的改变可能会对 Copilot 产生的结果产生不成比例的影响。在这本书中,我们将教你所有这些技能。
目前社会对像 Copilot 这样的 AI 代码助手存在不确定性。我们认为应该以几个问题和我们的当前答案结束本章。也许你自己也在思考这些问题!我们的答案可能最终会显得荒谬错误,但它们确实捕捉了我们作为两位致力于编程教学的教授和研究者的当前想法:
Q: 现在我们有了 Copilot,技术编程工作会减少吗?
A: 很可能不是。我们预期会改变的是这些工作的性质。例如,我们认为 Copilot 可以帮助完成许多通常与初级编程工作相关的任务。这并不意味着初级编程工作会消失,只是随着程序员能够利用越来越复杂的工具完成更多工作,这些工作会发生变化。
Q: Copilot 会扼杀人类的创造力吗?它会不会只是不断循环和重复人类已经编写过的代码,限制新想法的引入?
A: 我们怀疑不是。Copilot 帮助我们以更高的层次工作,远离底层机器代码、汇编代码或 Python 代码。计算机科学家使用术语抽象来指代我们与计算机底层细节断开连接的程度。抽象自计算机科学诞生以来一直在发生,我们似乎并未因此遭受损失。相反,它使我们能够忽略已经解决的问题,并专注于解决越来越广泛的问题。事实上,更好的编程语言的诞生促进了更好的软件的发展——当只有汇编语言时,Google 搜索、Amazon 购物车和 macOS 等软件都没有被编写(很可能也无法编写)!
Q: 我经常听到关于 ChatGPT 的消息。那是什么?它和 Copilot 一样吗?
A: 它与 Copilot 不同,但建立在相同的技术之上。然而,ChatGPT 并不是专注于代码,而是关注一般性的知识。因此,它已经渗透到比 Copilot 更广泛的各种任务中。例如,它可以回答问题,撰写文章,甚至在沃顿商学院的 MBA 考试中表现出色[6]。因此,教育将需要改变:我们不能让人们通过 ChatGPT 的方式获得 MBA!我们花费时间的有价值的方式可能会改变。人类是否会继续写书,如果是的话,会以什么方式?人们是否愿意阅读那些部分或全部由 AI 撰写的书籍?这将在包括金融、医疗保健和出版在内的各个行业产生影响[7]。同时,目前存在无节制的炒作,因此很难区分真相与虚构。这个问题由于一个简单的事实而加剧,即没有人知道长期会发生什么。罗伊·阿玛拉(被称为阿玛拉定律)提出了一句古老的谚语说:“我们往往高估了技术在短期内的影响,而低估了在长期的影响。”因此,我们需要尽我们所能关注讨论,以便我们可以相应地适应。
在下一章中,我们将指导您在电脑上开始使用 Copilot,以便您可以开始编写软件。
Copilot 是一个 AI 助手,它是一种 AI 代理,可以帮助您完成工作。
Copilot 改变了人类与计算机的互动方式,以及我们编写程序的方式。
Copilot 是非确定性的;有时它会产生正确的代码,有时则不会。我们需要保持警惕。
在你的系统上设置 Python、Visual Studio Code 和 Copilot
介绍 Copilot 的设计过程
理解 Copilot 在数据处理任务中的价值
我们希望你能从一开始就能自己创建软件。为此,我们将引导你设置你的机器上的 Visual Studio Code(VS Code)、Python 和 Copilot,并让你熟悉如何与这些工具交互。在你设置好工具后,你将能够跟随我们的示例,并开始自己创建软件。实践是检验真理的唯一标准,我们相信你可以在我们这本书的剩余部分边学边实践。
一旦你设置了 Copilot,我们将通过一个展示 Copilot 在解决标准任务中强大功能的有趣示例来引导你。你会看到如何与 Copilot 交互,并学习你如何在不实际编写任何代码的情况下编写软件。记住,Copilot 并不完美,你需要能够阅读并理解一点 Python 才能得到你想要的结果,但它给了你一个很大的起点。让我们开始创建你的第一个计算机程序。
学习如何编写软件需要你超越仅仅阅读它,并实际执行编写软件的任务。如果这是一本关于如何弹吉他的书,你会不尝试弹吉他就继续阅读吗?我们认为你不会。不跟随并亲自尝试,就阅读这本书,就像看着马拉松运动员完成比赛,然后认为你准备好自己跑一场一样。我们将停止使用类比,但说真的,在我们继续前进之前,你需要安装并运行你的软件。
目前最让我们感到害怕的是,我们刚刚到达了一个新手们,甚至那些渴望学习编程的新手们往往会失败的最常见点,我们真的希望看到你成功。现在,你可能正在想,“等等,真的吗?我们才刚刚开始。”是的,这正是关键所在。在 Leo 在 Coursera 上关于学习 Java 编程的流行课程[1]中,你能猜到大多数新学习者离开的点是什么吗?是课程末尾的挑战性作业,涉及在实时地球上绘制地震标记吗?不是的。实际上,是热身作业,学习者必须设置他们的编程环境。因此,我们理解这可能对你来说是一个障碍。我们希望,通过这个不太明显的推动,我们可以帮助你实现你购买这本书时心中所设定的所有目标。一切从安装软件开始。
为了轻松设置和使用 Copilot,我们将安装新手和软件工程师都会使用的软件编辑工具。你将使用的工具是 Python、VS Code、GitHub 和 Copilot。当然,如果你已经安装了所有这些工具,请跳转到 2.6.1 节。
任何编程语言都可以用于这本书,但我们选择了 Python,因为它是世界上最受欢迎的编程语言之一,也是我们在大学入门课程中教授的语言。正如我们在第一章中提到的,与其它语言相比,Python 更易于阅读、理解和编写。对于这本书,Copilot 将主要生成代码,而不是你。然而,你将想要阅读并理解 Copilot 生成的代码,Python 在这方面做得很好。
你可以使用任何文本编辑器来编程。然而,如果你想有一个良好的编程环境,可以编写代码,轻松地从 Copilot 获得建议,并运行你的代码,VS Code 是我们的首选工具。VS Code 被学习软件的初学者使用,并且受到学生的喜爱[2]。它也被全球的专业软件工程师广泛使用,这意味着在完成这本书后,你可以在使用这个环境的同时工作和学习。为了让 VS Code 适用于这本书,你需要安装一些扩展,以便使用 Python 和 Copilot,但 VS Code 的一个优点是安装这些扩展很容易。
GitHub 是开发、维护和存储软件的行业标准工具。然而,在这本书中,我们不会使用 GitHub。我们注册 GitHub 仅仅是因为你需要一个账户来访问 Copilot。注册 GitHub 账户是免费的,但截至写作时,他们为 Copilot 收费。如果你是学生,他们将免除这项费用。如果你不是学生,截至写作时,你可以获得 30 天的免费试用。
你可能会问为什么他们要收费,有一个很好的答案。构建 GPT 模型(想象有成千上万的计算机运行一年来构建模型)的成本很高,GitHub 通过提供模型预测来承担成本(许多机器接收你的输入,将其通过模型运行,并生成你的输出)。如果你还没有准备好使用 Copilot,你可以在注册后的大约 25 天内做一个日历提醒,如果你在那个时间没有使用 Copilot,只需取消即可。另一方面,如果你已经成功地学会了如何使用 Copilot 编写软件,并且正在用它来提高工作效率或作为爱好,那么保留它可能是有意义的。
在本章的整个过程中,我们将安装所有这些工具,但我们将其分为两部分进行。第一部分,将在下一节中介绍,将帮助你设置自己的环境以编写和运行代码,让你熟悉这个过程。第二部分将帮助你设置使用 Copilot 来辅助编写代码的环境。
在我们安装指南的第一部分,我们将安装 Python 和 VS Code。为了简化这一部分,我们只是概述了你应该遵循的主要步骤。然而,更详细的说明可以在以下位置找到:
您需要完成的步骤主要包括以下几步:
安装 Python:
下载并安装 Python 的最新版本(写作时为 3.12.3)。
安装 VS Code:
下载并安装 VS Code 的最新版本。
现在您的系统已经设置好了,让我们熟悉图 2.1 所示的 VS Code 界面。(您可能需要点击左上中的资源管理器图标以获得相同的视图。)图 2.1 中标识了以下区域:
活动栏—在左侧远端是活动栏,我们可以在此打开文件文件夹(也称为目录)或安装扩展程序(就像您在上一节中安装 Python 扩展程序那样)。
侧边栏—侧边栏显示活动栏中当前打开的内容。在图 2.1 中,活动栏显示了选中的资源管理器,因此侧边栏显示了当前文件夹中的文件。
编辑器面板—这是我们创建软件的主要区域。编辑器面板中的编辑器类似于任何其他文本编辑器,您可以使用剪贴板编写、编辑、复制和粘贴文本。然而,编辑器是专门为与代码良好协作而设计的。在此阶段,我们将在此窗口中编写代码,但在此章的后面,您将主要通过请求 Copilot 生成代码并在该窗口中测试代码来工作。
VS Code 有许多不同的配色方案,所以你可以使用你喜欢的任何配色方案,因为功能是相同的。
在 VS Code 左侧的活动栏顶部,你会找到作为顶部图标的资源管理器。点击资源管理器后,你应该会看到一个“未打开文件夹”的消息。点击按钮打开一个文件夹,并在你的电脑上选择一个文件夹(或者创建一个新的——我们喜欢命名为 fun_with_Copilot 的文件夹)。一旦你打开了这个文件夹,你的工作区将是你打开的文件夹,这意味着你应该在这个文件夹中拥有你的代码和任何数据文件,比如我们将在本章后面使用的那个文件。
如果你收到一个错误消息说缺少文件,不要灰心:这些是每个人都会犯的错误。在编写软件时,这些错误可能会非常令人烦恼。也许你只是没有把文件放在你的工作文件夹里——这种情况可能发生——但是通过复制或移动文件到正确的文件夹就可以轻松修复。然而,有时你会在文件夹中看到文件,但当你用 VS Code 运行你的代码时,Python 似乎找不到它。如果你遇到这种情况(在我们编写这本书的时候也遇到了这种情况!),请确保使用 VS Code 中的资源管理器(如图 2.1 中的侧边栏所示)打开包含代码和所需文件的文件夹。
让我们检查我们是否正确设置了所有内容。为此,我们首先创建一个新的文件来存放我们的程序。你可以通过转到文件 > 新建文件(如图 2.2 所示),然后选择 Python 文件(如图 2.3 所示)来完成此操作。
你可能已经猜到了当我们运行这个程序时会发生什么。它应该在屏幕上打印“Hello World”。让我们运行它看看!首先,你需要保存你的文件,方法是转到文件 > 保存。
要运行你的程序,转到文本编辑器的右上角,并点击之前在图 2.1 中显示的运行代码图标。点击图标后,在底部的终端部分,你应该会看到类似以下的内容:
恭喜!你已经编写了你的第一个程序!现在我们知道你的编程环境已经设置正确。让我们开始编写几段更小的代码,以便熟悉这个工作流程,然后我们将转向与 Copilot 一起的工作流程。
你刚刚完成了安装 Python 和 VS Code 以及使其工作的大步骤。在我们开始使用我们的 AI 助手 Copilot 之前,让我们编写几个小程序,这样你就可以对输入和运行代码有所体会。
让我们从编写一个涉及加法的小程序开始。请删除你写的打印 Hello World 的行,并用以下代码替换:
你认为这段代码会打印什么?它会打印“a + b”,还是会打印 30?请运行它看看结果。
计算机计算了 10 和 20 的和,并打印了 30。这很酷,对吧?我们使用计算机进行了一些小计算。如果你改变a或b的值,你认为会发生什么?你可以自己尝试一下。
当前关键的是让你习惯在编辑器窗格中输入代码并使用运行按钮来运行它。
让我们再举一个简单的例子。假设你想计算一个正方形的面积。回忆一下我们关于形状的公式,边长为s的正方形的面积如下:
面积 = s²
让我们从定义一个边长并打印面积开始:
我们鼓励你输入并运行这段代码。当你运行它时,你应该得到以下结果:
你在输入这段代码时遇到任何问题吗?我们怀疑很多人会遇到。如果你漏掉了引号、逗号或括号,当你运行代码时,你可能会得到一些不愉快的错误,比如(这里,如果你忘了逗号):
在传统的编程学习方法中,你需要花费大量时间确保你理解了编写编程语言 Python 所需的精确字符。我们将再次强调好消息:有了像 Copilot 这样的 AI 助手,语法变得不再那么重要。让我们安装 Copilot,这样你就可以学习如何使用这个工具了。
现在,我们已经准备好安装指南的第二部分。这里有一些新的步骤,超出了你之前所做的工作。再次提醒,你可以自由地参考以下资源以获取更多关于安装过程的详细信息:
这次你需要完成的主要步骤如下:
设置您的 GitHub 账户,并注册 Copilot:
进入 GitHub 的设置并启用 Copilot。这是你需要验证你是学生或注册 30 天免费试用(在撰写本文时可用)的地方。
我们知道这里的步骤很简短。如果你遇到任何问题,我们鼓励你参考前面提到的资源以获取更详细的设置说明。
如果你的安装工作正常,你应该在 VS Code 界面的右下角看到 Copilot 图标(如图 2.4 所示)。
我们编写的提示和代码将以粗体显示,以帮助我们区分我们编写的内容,以及 Copilot 可能给出的代码和注释。起始处的 # 符号很重要(你应该将其包含在你输入的内容中)。这意味着你编写的内容是一个注释(根据你的 VS Code 颜色方案,它可能与我们即将生成的代码颜色不同)。注释不是代码:计算机执行代码,不执行注释。程序员使用注释来提供代码对其他软件工程师可读的摘要,以帮助阅读代码。今天,它的用途已经扩展到也可以提示 Copilot。在编写注释(有时甚至在编写注释时),Copilot 将尝试给我们提供建议。你可以将其视为一种更复杂的自动完成功能,就像你在搜索引擎中键入“New York T”时,它会自动完成为“New York Times”。
要触发 Copilot 开始提供代码(或更多注释),请在行尾按 Enter 键,然后你会处于新行的开始处。暂停片刻,你应该会看到一些东西出现。直到被接受,Copilot 的建议将以浅灰色斜体显示。如果你还没有收到建议,你可能需要再次按 Enter 键来触发 Copilot 提供代码。以下是我们遇到的情况:
如果你仍然没有看到 Copilot 的建议,尝试按 Ctrl-Enter(按住 Ctrl 键的同时按 Enter 键)。当你按 Ctrl-Enter 时,一个新窗口将在你的编辑器窗口右侧出现,该程序称为 GitHub Copilot 建议。如果该窗口没有出现,可能你的设置有问题,我们鼓励你访问书籍网站,检查你是否正确地遵循了所有步骤,或者寻找(或请求)帮助。
如果你看到了 Copilot 的建议,按 Tab 键接受 Copilot 的建议。一旦这样做,之前以浅灰色斜体显示的建议现在应该以标准字体显示:
如果你看到与此不同的代码,那是因为我们之前提到的原因:Copilot 是非确定性的,所以你可能看到的代码与我们不同。我们提到这一点是因为有时 Copilot 在这里的代码中会犯一些小错误,可能会给你提供类似这样的代码:
你可能会认为这个细微的差异("Hello"和"Copilot"之间没有括号)不会影响什么,但实际上它会影响。在 Python 3 之前,这是打印语句的正确语法,而当 Python 3 被引入时,它切换到了带括号的代码。因为我们正在运行 Python 3,所以你需要有括号才能使代码工作。你可能会问为什么 Copilot 会出错。问题在于 Copilot 在训练过程中使用了部分旧的 Python 代码。如果这让你感到烦恼,我们表示同意。但这又是新手程序员在 Copilot 出现之前所经历的挫折的另一个提示。Copilot 建议的大部分内容在语法上是正确的。但如果你是一个从头开始编写代码的新手,遗漏括号或某处缺少冒号可能会让你浪费很多时间。现在我们有了正确的代码,
这与我们在之前手动编写的“Hello World”代码类似。我们希望看到 Copilot 能生成这样的代码能让你感到鼓舞!
现在,让我们看看它在确定上节中正方形面积的例子中的表现。请输入以下代码(再次,以粗体显示):
我们从 Copilot 那里收到了以下回应:
注意 Copilot 如何处理这段代码。对于人类来说,尤其是编程新手,犯错很容易,但 Copilot 很少出错。我们希望 Copilot 给了你类似的回应。如果你想要更熟悉界面,不妨随意用 Copilot 试一试。
现在我们知道你的编程环境和 Copilot 都设置正确了,所以我们可以进入一个更大的编程任务。但在我们这样做之前,我们想回顾一下我们在与 Copilot 一起工作时遇到的常见问题的处理技巧,这样你就可以在处理下一个例子时使用这些技巧。
现在讨论 Copilot 的常见挑战可能似乎还为时尚早,但你可能在编写第一个程序时就已经遇到了挑战。当你处理我们接下来的例子和下一章的内容时,你肯定会遇到一些这些挑战,所以我们现在就想把这些信息提供给你。
在我们与 Copilot 一起工作的这段时间里,我们遇到了一些常见的挑战。随着 Copilot 的改进,这些挑战可能会减少,但它们在撰写本文时仍然是问题。尽管表 2.1 中的挑战不是你可能会遇到的唯一挑战,但我们希望我们关于如何解决这些常见挑战的建议能帮助你快速上手。我们将在我们书籍的网站上保持一个持续更新的列表,所以如果你觉得我们遗漏了什么,请随时在论坛上联系我们。
| 只有注释 | 如果你使用注释符号(#)给 Copilot 一个提示,并在新的一行开始时,它只想给你更多的注释而不是代码。例如:# output "Hello Copilot" to the screen
# print "Hello world" to the screen
我们看到 Copilot 生成了一行又一行的注释,有时甚至重复自己!当这种情况发生时,右侧列中的 3 号补救措施(使用 docstrings)有时是最有效的。
| 1. 在你的注释和 Copilot 的建议之间添加一个换行符(按 Enter 键),以帮助它从注释切换到代码。2. 如果换行符不起作用,你可以输入一两个字母的代码(没有注释符号)。通常,你的提示中的关键词的一两个字母会起作用。例如:
# output "Hello Copilot" to the screen
pr
关键词的一两个字母通常会导致 Copilot 提供代码建议。
""
output "Hello Copilot" to the screen
""
| 错误的代码 | 有时 Copilot 从一开始就给你显然错误的代码。(你将在本书中学习如何识别错误的代码!)此外,有时 Copilot 似乎陷入了错误的道路。例如,它可能看起来在试图解决与你要求它解决的问题不同的另一个问题。(特别是补救措施 3 可以帮助 Copilot 走上一条新的道路。)
| 本书的大部分内容是关于如何解决这个问题,但这里有一些快速提示来帮助 Copilot:1. 改变你的提示,看看你是否能更好地描述你需要的内容。
尝试使用 Ctrl-Enter 来找到 Copilot 提供的正确代码建议。
关闭 VS Code 程序,稍等片刻,然后重新启动。这有助于清除 Copilot 缓存以获取新的建议。
尝试将问题分解成更小的步骤(更多细节见第七章)。
调试代码(见第八章)。
尝试向 ChatGPT 请求代码,并将它的建议粘贴到 VS Code 中。不同的大型语言模型(LLM)有时会给出有助于其他 LLM 解锁的建议。
| Copilot 给你 # YOUR CODE HERE | 我们看到 Copilot 似乎在提示我们编写自己的代码,通过在提示后生成此文本(或类似文本)来实现:# YOUR CODE HERE
| 我们认为这是当我们要求 Copilot 解决一个过去由教师分配给学生解决的问题时发生的。为什么?好吧,当我们为学生编写作业时,我们(作为教师)通常会编写一些代码,然后告诉学生通过编写 # YOUR CODE HERE 来编写其余部分
我们希望学生在那里编写他们的代码。学生往往会将此注释留在他们的解决方案代码中,这意味着 Copilot 被训练成认为这个注释是解决方案的一个重要部分(其实不是)。通常,我们能够通过使用 Ctrl-Enter 在 Copilot 的建议中找到合理的解决方案来解决这个问题,但如果这不起作用,请参阅关于错误代码的补救措施。
您已经看到 Copilot 可以为您生成代码。那么,我们能否让 Copilot 为我们解决任务呢?我们担心不能。我们仍然需要学习一些基本技能,以便能够有效地与 Copilot 合作并设计对我们有意义的软件。
为了帮助您欣赏您需要学习的技能,并让您对您将要去往的方向感到兴奋,我们将提供一个更大的示例,代表我们希望您在阅读和完成这本书的过程中大约一半的时候能够独立完成的事情。
我们将在后面的章节中扩展这个工作流程,但核心元素将是相同的:
使用注释(#)或文档字符串(""")向 Copilot 编写提示。
让 Copilot 为您生成代码。
通过阅读和测试来检查代码是否正确:
如果它起作用,请转到步骤 1 以进行您想要它执行的下一步。
如果不起作用,请从 Copilot 中删除代码,回到步骤 1,并修改提示(并查看之前在表 2.1 中显示的补救措施)。
对于这个更大的示例,我们希望您关注我们与工具的交互以及我们需要的技能。我们不期望您在本书的较晚部分理解代码。我们提供代码仅是为了让您看到 Copilot 为我们提供了什么,但您不需要在本章中尝试理解代码。
您可以跟随我们完成这个示例,或者只是阅读它。我们还没有给您提供在遇到困难时继续前进的技能,所以您可以等到稍后的章节再尝试完成这样的示例。我们假设在本节的其余部分,您只是会阅读。
让我们先探索一下这个文件中存储了什么内容。为了预览文件的内容,你可以在 Kaggle 网页上的“详情”部分查看它,或者下载并使用 VS Code 或 Microsoft Excel 等电子表格软件打开它。不用担心现在就下载和使用这个文件,那将在本书的后面部分进行。然而,如果你下载并打开它,这里就是标题(顶部)行的开始(如图 2.5 所示):
注意到我们给了 Copilot 三个提示。第一个是处理输入数据,第二个是处理数据,第三个是输出响应。这种输入数据、处理数据和输出数据的循环在编程任务中非常常见。
如果你选择跟随我们,并将这些提示给 Copilot,你可能会得到不同的代码。这没关系,因为 Copilot 是非确定性的,所以它不会总是给出相同的结果。然而,如果你得到了不同的代码,我们还没有提供工具让你知道它是否正确。因此,如果你愿意跟随,请随意使用我们的代码。
正如第一章和本章所述,Copilot 是非确定性的,所以 Copilot 给你提供的内容可能与我们的不同。这将是本书剩余部分的一个挑战:当我们得到正确结果时,如果你得到错误结果,你会怎么做?我们相当有信心 Copilot 会在这里给你一个正确的答案,但如果你从 Copilot 那里得到一个错误答案,请继续阅读本节的剩余部分,而不是在 VS Code 中与 Copilot 一起工作。当 Copilot 给你一个错误答案时,我们绝对会给你所有需要的工具来修复代码,但这项技能将在本书的剩余部分教授,所以我们不希望你现在就卡在这里。
看着 Copilot 的结果,我们不得不说这相当令人印象深刻。你目前不需要担心代码,但请注意,从头开始编写这段代码对许多新学习者来说可能很困难。好消息是,任何人都可以使用这个强大的工具来做很多没有它不可能做到的事情。
我们不仅可以用 Copilot 生成新代码,还可以修改它。如果我们从 2.1 列表中的数据集运行该代码,以下是输出中的前五行:
如果你关注足球,这些结果对你来说可能不会感到惊讶。为了看看 Copilot 能多好地适应我们的愿望,让我们尝试进行一个小的修改。假设因为汤姆·布雷迪已经被公认为有史以来最优秀的四分卫之一,你更愿意从这次比较中省略他。
为了进行这个更改,我们只需修改底部的提示。我们到了代码中写着以下内容的地方:
我们删除了代码,只留下注释,并在文档字符串中添加了另一行:
Copilot 随后向我们建议了以下内容:
这正是我们希望在代码中看到的变化。(感谢汤姆·布雷迪在这个例子中表现得如此好。)代码在打印结果时排除了所有关于汤姆·布雷迪的数据。当我们保存文件并再次运行时,前五行现在看起来像这样:
尽管我们没有自己编写任何代码,但我们能够解决一个大型现实世界任务。然而,你仍然需要学习一些基本的编程技能。我们将在接下来的章节中通过解决各种任务来教你这些技能。到第七章结束时,你将能够解决这样的大型任务。我们希望你能从这个例子中吸取以下内容:
Copilot 是一个强大的工具。 我们没有自己编写任何代码,但能够让 Copilot 生成执行一些初步数据分析所需的代码。如果你使用过电子表格,你可能会想到使用像 Microsoft Excel 这样的电子表格应用程序来完成这项任务,但这可能不像编写这样的代码那样容易。即使你以前没有使用过电子表格,你也必须承认,编写基本、可读的提示能够生成这样的正确代码和输出是令人惊讶的。
将问题分解成小任务是重要的。 对于这个例子,我们尝试通过一个单一的较大提示(未显示)或将其分解成更小的任务来编写这段代码。我们发现,当给 Copilot 一个较大的任务而不是多个较小的任务时,Copilot 更容易出错。一般来说,将问题分解成更小的任务会显著增加 Copilot 生成正确代码的可能性。你将在本书的剩余部分看到如何将较大的问题分解成较小的任务,因为这是你需要掌握的最重要技能之一。实际上,即将到来的第三章将帮助你开始理解哪些任务被认为是合理的 Copilot 任务,而第七章将完全致力于学习如何将较大的程序分解成较小的部分。
我们仍然需要在某种程度上理解代码。 这有几个原因。其中一个原因是编写好的提示需要基本了解计算机知道什么以及不知道什么。我们不能只是给 Copilot 一个提示说,“给我汤姆·布雷迪的传球码数。”Copilot 可能无法弄清楚数据存储在哪里,数据的格式是什么,哪些列对应球员和传球码数,或者汤姆·布雷迪是一名球员。我们必须将这些细节详细地告诉 Copilot,以便它能够成功。另一个原因与确定 Copilot 生成的代码是否合理有关。当我们两个人阅读 Copilot 的响应时,我们能确定代码在做什么,因为我们知道如何阅读代码。你需要在某种程度上能够做到这一点,这就是为什么第四章和第五章专门用于阅读代码。
测试很重要。 我们需要知道 Copilot 给出的代码是否正确!当程序员谈论测试时,他们指的是确保他们的代码在可能的情况下正确工作的实践。我们在这个方面没有花太多时间,除了检查 Copilot 的答案是否合理,仅使用一个数据集的估计,但总的来说,我们需要在测试上花费更多的时间,因为这是代码编写过程中的一个关键部分。可能不用说,但代码中的错误从尴尬(如果你告诉你的 NFL 铁杆粉丝朋友一个球员的错误传球码数)到危险(如果汽车中的软件表现不当)再到昂贵(如果企业基于错误的分析做出决策)。在你学会阅读代码之后,我们亲身体验到,即使代码看起来是正确的,它可能也不是!为了解决这个问题,我们必须测试 Copilot 创建的每一块代码,以确保它按预期工作。你将在后面的章节中学习如何严格测试 Copilot 的代码,特别是第六章,该章节专门介绍这项技能。
Python 非常强大。 如果你错过了这一点,也没关系,但在列表 2.1 中,代码从 Python 库中导入了一个模块(import csv)。Python 有许多内置工具(称为库)来帮助人们完成任务。在这种情况下,它是导入一个库来使读取和修改 CSV 文件变得更加容易。我们将在后面的章节中利用更多的这些库。
Python 模块扩展了编程语言的功能。Python 中有许多模块,它们可以帮助你从数据分析到创建网站再到编写视频游戏做任何事情。你可以通过代码中的import语句来识别代码想要使用 Python 模块。一些模块是 Python 内置的,但还有数千个不是,如果你想要使用它们,你需要手动安装。第五章将向你展示如何使用模块。
在本章中,我们取得了巨大的成就!如果你已经完成了编程环境的设置,并开始使用 Copilot 编写代码,你应该感到自豪。你朝着编写软件迈出了巨大的一步!除了设置环境的细节之外,通过一起工作,你已经看到了我们的方向。
在本章的示例中,Copilot 为我们提供了我们想要的代码,而无需我们更改提示或调试代码来找出为什么它不能正常工作。这是一种展示使用 AI 助手编程力量的好方法,但你会发现,你经常需要测试代码,更改提示,有时甚至试图理解代码为什么是错误的。这是你将在接下来的章节中了解更多内容的 AI 助手编程过程。
你已经安装了 Python 和 VS Code,并设置了 Copilot,这样你就可以与本书一起工作,并开始自己编写代码。
VS Code 界面包含文件管理、代码编辑和运行代码的区域,这些功能将在整本书中使用。
提示是我们告诉 Copilot 生成代码的方式,如果编写得仔细,可以是一种创建软件的高效方法。
数据分析是常见的编程任务,使用 CSV 文件是存储计算机要处理的数据的常见方式。
Copilot 是一个强大的工具,在适当的人类监督下可以生成复杂的代码。
Python 中的函数及其在软件设计中的作用
与 Copilot 交互的标准工作流程
使用 Copilot 编写良好函数的示例
Copilot 可以解决的合理任务
对于编程新手来说,最难的一个挑战是知道给 Copilot 分配什么合理的任务,以便它能找到好的解决方案。如果你给 Copilot 分配的任务太大,它通常会以非常壮观的方式失败,这可能非常难以修复。那么,什么是一个合理的任务呢?
这个问题对于我们的 Copilot 使用很重要,但它远远超出了这个范围。人类程序员也会遇到复杂性问题。如果经验丰富的软件工程师试图在不将其分解为更小的、更易解决的子部分的情况下编写代码来解决过于复杂的问题,他们通常也会遇到麻烦。对于人类来说,解决方案是使用一种称为“函数”的东西,其任务是执行一个任务。函数帮助我们组织代码,并使其更容易思考并修复任何错误。关于如何编写合理函数有一些经验法则,但本质上,这些规则试图捕捉到编写(1)执行单个任务且(2)不会过于复杂以至于难以正确执行的东西。
对于那些以传统方式学习编程的人来说,没有 Copilot 的帮助,他们可能在接触到函数之前,就已经在 5-10 行长的代码中挣扎于语法问题。在那个阶段,他们通过经验自然地理解到,他们不应该在一个函数中编写超出自己能够测试和调试能力的代码。因为你是学习与 Copilot 合作而不是直接与语法打交道,所以本章的任务是教你关于函数以及什么被认为是合理和不合理的任务来请求 Copilot 在单个函数中解决。
为了帮助你了解函数,我们将在本章提供一些示例。这些示例采用了与 Copilot 交互的核心工作流程——具体来说,是编写提示、从 Copilot 接收代码以及测试该代码是否正确的过程。在 Copilot 生成的函数中,你将开始看到核心编程工具,如循环、条件和列表,我们将在接下来的两章中对其进行扩展。
在我们学习编写函数的细节之前,我们需要了解它们在软件中的目的。函数是帮助完成更大任务的较小任务,反过来,这些任务又帮助解决更大的任务,如此类推。你可能已经对将大任务分解为小任务有很多直觉。让我们用一个例子来具体化这种直觉。
假设你在报纸上找到了一个你想要解决的单词搜索谜题(见图 3.1 中的示例谜题)。在这些类型的谜题中,你需要找到单词列表中的每个单词。单词可以从左到右、从右到左、从上到下或从下到上找到。
在高层次上,你的任务是“找到单词搜索中的所有单词。”不幸的是,这个任务的描述本身并没有帮助。它没有告诉我们需要采取哪些步骤来解决问题。
你可能会这样做,比如说,“好吧,找到每个单词是一个大任务,但一个更小的任务是找到第一个单词(CAT)。让我先从那开始!”这是一个将大任务分解成小任务的例子。因此,为了解决整个谜题,你可以为需要找到的每个单词重复执行这个较小的任务。
现在,我们如何找到一个单独的单词,比如 CAT?甚至这个任务也可以进一步分解,使其更容易完成。例如,我们可以将其分解为四个任务:从左到右搜索 CAT,从右到左搜索 CAT,从上到下搜索 CAT,以及从下到上搜索 CAT。我们不仅使任务越来越简单,而且还将我们的工作组织成逻辑部分。最重要的是,正如我们将在本章中看到的那样,我们将要求 Copilot 编写这些简单任务的代码,并将其最终组装成我们的完整程序。
将一个大问题分解成更小的任务称为问题分解,这是软件设计中的一项重要技能,我们在第七章中专门对其进行了讨论。目前,了解何时一个任务太大,以至于不能让 Copilot 完成它是很重要的。让 Copilot 制作一个结合 Wordscapes 和 Wordle 的新视频游戏是完全行不通的。然而,你可以让 Copilot 编写一个对解决更大问题很重要的函数;例如,你可能有一个函数,用于检查玩家提供的单词是否在有效单词列表中。Copilot 可以很好地解决这个问题,而这个函数将帮助 Copilot 更接近解决更大的问题。
“函数”这个名字的起源可以追溯到数学,在数学中,函数根据输入定义某物的输出。例如,在高中数学中,你可能学习了以下常见的函数写法:
f(x) = x²
这是一个给定变量x的值的函数,并计算该值的平方。因此,当x为 6 时,f(x)是 36。由于编程函数也有特定输入的预期输出,所以这个名字对编程来说也很合适。
作为程序员,我们也喜欢将函数视为承诺或合同。如果一个名为 larger 的函数被告诉我们它接受两个数字并给出这两个数字中较大的一个,我们就相信当我们给这个函数数字 2 和 5 时,它会返回答案 5。我们不需要看到这个函数是如何工作的就能使用它,就像我们不需要知道汽车的工作原理就能使用刹车踏板一样。按下刹车踏板,汽车就会减速。给 larger 函数两个数字,它会返回这两个数字中较大的一个。
Python 中的每个函数都有一个 函数头(也称为 函数签名),它是函数的第一行代码。鉴于它们的普遍性,我们希望阅读和编写函数头。函数头描述了函数的名称和它的输入。在其他一些语言中,函数头有时会包含有关输出外观的信息,但在 Python 中,你必须在代码的其他地方找到它。
在第二章中,我们写了 # 注释来告诉 Copilot 要做什么。如果我们想让 Copilot 生成一个函数,我们可以继续使用那种方法。例如,我们可以使用注释来要求 Copilot 编写一个函数,告诉我们两个数字中哪个更大:
与上一章的代码一样,我们只是写了注释来提示 Copilot 给我们代码。函数头有三个主要组成部分:def 关键字,它告诉 Python 这是一个函数;函数的名称;以及函数的输入。行尾还有一个冒号——一定要包含它,否则代码将不是有效的 Python 代码。单词 def 表示它正在创建(定义)一个函数。def 之后是函数的名称;这个名字应该尽可能地描述函数的行为。这个函数的名称是 larger。如果一个函数很难命名因为它做了很多不同的事情,这通常是一个线索,表明这个任务对于单个函数来说太大(关于这一点稍后还会讨论)。
在函数声明的括号中,你会找到参数。参数 是你向函数提供它运行所需信息的方式。一个函数可以有任意数量的参数,有些函数没有参数。这个函数有两个参数,分别命名为 num1 和 num2;有两个参数是因为它需要知道它正在比较的两个数字。
函数只能有一个输出;在确定函数输出内容时需要查找的关键字是 return。return 后面跟随的内容即为函数的输出。在这个代码中,num1 或 num2 将会被返回。函数不一定要返回任何内容(例如,一个将列表打印到屏幕上的函数没有必要返回任何内容),所以如果你没有看到返回语句,这并不一定是一个问题,因为函数可能正在做其他事情(例如与用户交互)而不是返回内容。函数必须要么返回内容,要么不返回内容:它们不能在某些情况下返回内容,在其他情况下不返回内容。
尽管我们使用 # 注释让 Copilot 生成这个函数,但实际上这对 Copilot 来说是一项大量工作。它首先必须正确获取头部,包括确定你需要多少个参数。然后,它必须正确获取函数的实际代码。在这个例子中,Copilot 还提供了函数的代码(也称为函数的主体)。
有一种方法可以提示 Copilot 为函数编写代码,这可能有助于它更准确地生成代码,并可能帮助我们更好地理解我们想要函数做什么。这涉及到编写文档字符串,我们将使用文档字符串来编写本书的大多数函数。
文档字符串 是程序员描述 Python 函数的方式。它们跟在函数头部之后,并以三个双引号开始和结束,如即将展示的 larger 函数的代码所示。
通过编写头部和文档字符串,你会让 Copilot 更容易地生成正确的代码。在头部,你将决定函数的名称,并提供函数要使用的每个参数的名称。在函数头部之后,你将提供一个文档字符串,告诉 Copilot 函数做什么。然后,就像之前一样,Copilot 将为函数生成代码。因为我们给了 Copilot 函数头部,它将能够从头部学习,并减少出错的可能性。以下是在编写相同的 larger 函数时,这种替代方法的样子:
#1 函数的文档字符串描述 **注意,我们不仅写了函数头部,还写了文档字符串,Copilot 提供了函数的主体。
一旦我们有了函数,我们如何使用它?回想一下我们的 f(x) = x² 类比,我们如何给 x 赋予 6 的值,以便它返回 36?让我们看看如何通过使用我们刚刚编写的 larger 函数来用代码实现这一点。
使用函数的方法是调用它。调用函数意味着在特定参数值上调用函数。这些参数值被称为参数。Python 中的每个值都有一个类型,我们需要注意给出正确类型的值。例如,larger函数期望两个数字;如果我们提供非数字的输入,它可能不会按预期工作。当我们调用函数时,它会运行其代码并返回结果。我们需要捕获这个结果,以便稍后使用;否则,它将会丢失。要捕获结果,我们使用一个变量,它只是一个指向值的名称。
在这里,我们要求 Copilot 调用函数,将结果存储在变量中,然后打印结果:
代码正确地调用了larger。注意,它将我们要比较的两个值放在了开括号之后。当函数执行完毕后,它返回一个值,我们将该值赋给结果。然后我们打印结果。如果你运行这个程序,你会看到输出 5,因为 5 是我们询问的两个值中较大的一个。
如果你在这里对所有的细节都不舒服,那没关系,但我们希望你能认识到当函数被调用时,如下所示:
函数调用的通用格式是
因此,当你看到名称后面紧跟着的括号时,这意味着有一个函数调用。像我们这里这样做函数调用对于与 Copilot 的工作流程非常重要,尤其是在测试函数是否正常工作方面。我们还需要调用函数来完成工作,因为函数在调用之前不会做任何事情。
让我们向你介绍接下来几章我们将使用的流程。使用 Copilot 设计函数涉及以下步骤周期(见图 3.2):
确定函数的预期行为。
编写一个尽可能清晰地描述函数的提示。
允许 Copilot 生成代码。
仔细阅读代码,看看它是否合理。
测试代码以查看其是否正确:
如果代码经过多次测试后仍然正确,则继续。
如果代码不正确,则回到步骤 2,并编辑提示。
我们将在下一章学习如何进行第 4 步,但我们确信你已经能够识别出代码明显错误的情况。例如,Copilot 可能只给出注释来填充函数的主体。注释并不做任何事情——它们不是代码——所以一大堆没有其他代码的注释显然不是正确的事情。或者,Copilot 可能只写一行,比如return -1,或者,我们个人的最爱,Your code here。Copilot 是从我们教授那里学到的,当我们提供学生部分代码并要求他们用“Your code here”来编写其余部分时。这些都是明显错误的,但在下一章中,我们将讨论如何阅读代码,这样你可以更快地发现更复杂的代码错误,也许更重要的是,看到在哪里以及如何修复它。在后面的章节中,我们将继续扩展这个周期,包括有效的调试实践,并继续练习如何改进提示。
在本节中,我们将与 Copilot 一起编写一系列函数。我们认为通过查看具体示例,你会对函数有更深的理解,我们将完全在 Copilot 中编写这些函数,以便帮助你看到我们刚刚描述的功能设计周期。尽管本章的目标还不是帮助你阅读代码,但我们会看到解决方案中的编程特性(有时称为构造),这些特性在代码中非常常见(例如,if语句,循环),因此当我们看到它们时,我们会指出这些特性。然后,在第四章中,我们将更详细地说明如何阅读这段代码。
Dan 是 AAAPL 这支股票的投资者。他以每股 15 美元的价格购买了 10 股。现在,这些股票每股的价值是 17 美元。Dan 想知道他在股票上赚了多少钱。
记住,我们希望使我们的函数尽可能通用。如果我们的函数唯一能做的就是计算这个特定的 AAAPL 情况,那么它在一般情况下就不会很有用。当然,它现在会帮助 Dan,但当他再次对 AAAPL 的股价感兴趣或他对另一支股票感兴趣时呢?
这里的一个有用的通用函数将接受三个参数,所有这些参数都是数字。第一个参数是购买的股票数量,第二个参数是购买股票时的股价,第三个参数是当前的股价。让我们称这个函数为money_made,因为它将确定我们在股票上赚了多少钱或损失了多少钱。一般来说,你希望给你的函数命名为一个动作词或描述函数所做事情的词组。有了这个,我们就有了编写函数头部的足够信息:
现在,我们需要一个文档字符串。在文档字符串中,我们需要通过在句子中使用其名称来解释每个参数的作用。我们还需要包括函数应该做什么。添加我们的文档字符串,以下是提供给 Copilot 的完整提示:
在输入提示后,转到下一行,并按 Tab 键。Copilot 将填充函数的代码。不用担心代码会被缩进:函数的代码应该是缩进的,实际上,如果没有缩进,那将是一个错误!以下是 Copilot 给出的代码:
这段代码看起来很有道理。在括号内,它计算出当前价格与购买价格之间的差异(这里使用-表示减法),然后将其乘以我们所持有的股票数量(这里使用*表示乘法)。检查这样的代码是一种有用的技能,我们将在下一章中认真对待它。另一个有用的技能是测试函数。
要测试money_made函数,我们可以使用各种输入调用它,并观察每种情况下的输出。我们可以通过让 Copilot 调用函数,然后运行我们的程序来实现这一点,就像我们之前对larger函数所做的那样。然后我们可以要求 Copilot 通过使用不同的输入来更改函数调用,然后我们再次运行程序,重复所需的次数。然而,我们发现直接从交互窗口调用函数更容易、更方便。这样,我们可以多次调用函数,而无需通过 Copilot,也不会在我们的程序中添加我们打算删除的东西。
要尝试这种交互式方法,选择/高亮显示函数的所有代码,然后按 Shift-Enter(你可以在 Python 窗口中选择文本,右键单击,然后在 Python 窗口中选择运行选择/行来访问类似的交互会话,但这里的指导是如果你使用 Shift-Enter)。图 3.3 显示了如果你选择函数的文本并按 Shift-Enter 会出现什么情况。
在结果窗口的底部,你会看到三个大于号>>>。这被称为提示符,你可以在那里输入 Python 代码。(这个提示符与我们与 Copilot 交互时使用的提示符没有关系。)它将立即显示我们输入的代码的结果,这既方便又快捷。
要调用我们的money_made函数,我们需要提供三个参数,它们将从左到右分配给参数。我们放第一位的将分配给num_shares,放第二位的将分配给purchase_share_price,放第三位的将分配给current_share_price。
让我们试试!在提示符下,输入以下内容,然后按 Enter(或 Shift-Enter)。不要输入>>>,因为那里已经有了;我们在整本书中都包括它,以便清楚地表明我们在哪里输入。图 3.4 显示了在 Python 提示符下终端运行函数的示例:
你将看到以下输出:
20是正确的吗?嗯,我们买了 10 股,每股上涨了 2 美元(从 15 美元到 17 美元),所以我们确实赚了 20 美元。看起来不错!
尽管如此,我们还没有完成测试。当你测试一个函数时,你希望以各种方式测试它,而不仅仅是测试一次。一个测试用例只能告诉你,它碰巧在你提供的特定输入值上工作。我们尝试的测试用例越多,每个测试用例都以不同的方式测试函数,我们就越有信心我们的函数是正确的。
我们如何以不同的方式测试这个函数?我们正在寻找某种不同类别的输入。一个不太好的测试方法现在可能是说,“如果我们的股票从 15 美元涨到 18 美元,而不是从 15 美元涨到 17 美元怎么办?”这几乎和之前的测试一样,而且很可能它会正常工作。
一个好主意是测试股票实际亏损时会发生什么。我们预计在这种情况下会得到一个负的返回值。而且看起来我们的函数在这个测试类别中工作得很好。这是我们的函数调用和返回给我们的输出:
我们还能进行哪些其他测试?嗯,有时候股票价格根本不会变化。我们预计在这种情况下会是 0。让我们验证一下:
看起来不错!测试是科学和艺术的结合。有多少种事物类别需要测试?这两个调用是否真的属于两个不同的类别?我们是否遗漏了任何类别?通过实践,你会提高你的测试能力,我们将在第六章全部讨论测试。目前来看,我们的money_made函数似乎正在正常工作。
函数可以在其代码中使用变量(而不仅仅是它的参数),我们现在想给你展示一个例子,这样当你看到 Copilot 这样做时,你就能准备好了。这是编写我们的money_made函数代码的等效方式:
这段代码可能对你来说更容易阅读:它首先计算出股价的差异,然后将其乘以股票数量。我们鼓励你测试这个版本,以帮助你自己确信它仍然是正确的。
Leo 正在注册一个新的社交网络网站,名为 ProgrammerBook。他想确保自己的密码是强的。
Leo 对密码强弱的定义很朴素:如果它不是单词password也不是单词qwerty,那么它就是强的。当然,这些密码很糟糕,但在现实中,我们必须比这个定义做得更好,以确保我们的密码是强的!一个有用的函数将是一个可以接受一个建议的密码并告诉我们它是否强大的函数。
与本章中我们之前的函数不同,我们这里处理的是文本,而不是数字。参数,即要检查的密码,是文本。返回值应该表示某种是/否结果。我们需要新的类型!
Python 中用于文本的类型称为字符串。由于我们可以使用字符串来存储我们想要的任何文本,因此可能的字符串数量是无数的。Python 中用于是/否结果的类型称为布尔型或bool。布尔型只有两个值:True或False。
好的!我们已经准备好提示 Copilot 了。对于返回布尔值(True/False)的函数,我们通常将函数命名为has_x、is_x或类似名称,使用一些表示真/假结果的动词:
作为回应,Copilot 为我们写了以下内容:
仔细看看那段代码。那个!=运算符是“不等”运算符,每个不被视为强密码的字符串都用单引号括起来。每次我们需要指定特定的字符串值时,我们都会使用引号。再次提醒,我们将在下一章中更多地讨论如何阅读代码,但到目前为止,我们希望这段代码看起来有点模糊的合理性:它看起来像是在检查密码不是password也不是qwerty,这符合我们强密码的定义。
现在,让我们通过进行一些测试调用来确保这个函数确实能工作。我们将首先测试那些不是强密码的密码。我们最好为这些密码都返回False——我们确实做到了:
好的!现在,让我们尝试一个强密码。我们需要在这里看到True:
太好了!我们现在看到这个函数有时返回False,有时返回True,所以至少它不是总是返回False。让我们再试一次:
好吧,从技术上讲,True是正确的,但哇,Leo 这个密码真是太糟糕了。我们真的应该对我们的强密码定义做得更好。让我们改变提示,给出一个更合理的定义,说明一个密码为什么是强的。这是我们的下一个提示和 Copilot 给出的内容:
这里出现几次的for char in password是 Python 考虑密码中每个字符的一种方式。它启用了一种称为循环的功能,这是我们将在第五章学习的关键主题。
当然,有可能你没有从 Copilot 那里得到与我们相同的代码。但根据我们得到的代码,看起来第一部分是在寻找任何大写字母,第二部分是在寻找任何数字,而第三部分是在寻找不是isalnum的东西。
也许isalnum意味着“是字母数字的”。这包括特殊符号吗?如果是的话,是哪些符号?让我们测试这个函数看看我们做得怎么样:
哎呀!我们为那个第三个密码得到了错误的答案。它看起来相当强大,但实际上并不行,因为它缺少一个特殊符号。我们的函数返回了True,但这是错误的返回值:它本应该返回False。
Copilot 给出的代码是错误的。也许你得到的代码运行得很好,但我们没有这么幸运,而且了解如何识别(就像我们刚才做的那样)代码是错误的并且修复它是很重要的。现在知道为什么它是错误的并不重要,但如果你对我们的提示可能导致的错误行为感到好奇,那是因为我们没有具体说明我们所说的特殊字符是什么,而它给出的代码将空格视为特殊字符。
在这个阶段,当我们开始调试我们的错误代码时,我们有两个选择。一个选择是按 Ctrl-Enter 并查看 Copilot 给出的其他建议。也许其中的一条建议与那个没有工作的特殊符号有所不同?
的确,在我们的电脑上,我们看到这样的选项,包括这个:
用Leo、N3w Y0rk J375和N3w Y0rk J375$这样的字符串测试这个函数,你应该看到函数运行良好。
我们的第二个选择是进行一些提示工程,这意味着调整我们的提示以影响 Copilot 给出的内容。在我们的原始提示中,我们提到了特殊符号。回想起来,这是模糊的。我们可能指的是更具体的东西,比如标点符号。如果我们具体地在提示中提到标点符号,我们会得到与 Copilot 的这种互动:
看起来不错!最后一行是在谈论标点符号,这让人感到希望。让我们在这里测试它:
看看错误信息的底部,你会看到'string'没有定义。Copilot 想要使用一个名为string的模块,但这是一个在使用之前需要导入的模块。这种“未定义”的结果可能发生在各种与找不到名称相关的错误中,而且很多时候罪魁祸首是一个尚未导入的模块。Python 中有许多模块,但string模块是众所周知的。随着你更多地使用 Copilot,你会了解到哪些模块是常用的,这样你就知道要导入它们。你也可以快速进行网络搜索来询问,“string 是 Python 模块吗?”搜索结果会确认它是。我们需要做的是导入这个模块。
注意,这与第二章中 Copilot 为我们生成的代码发生的情况相似。当时,Copilot 正在使用 csv 模块,这是一个与 Python 一起安装的模块。字符串模块也类似地包含在 Python 中;Copilot 只是忘记导入它。正如我们在第二章中讨论的,一些模块确实需要手动安装,但字符串模块不是其中之一。因此,我们不需要安装字符串;我们只需要导入它即可。
Python 中有许多有用的模块。在第二章中,Copilot 使用了 csv 模块来简化处理逗号分隔值(CSV)文件。但为了 Python 代码能够利用一个模块,我们必须导入该模块。你可能会问为什么我们没有导入模块就可用,但那将大大增加代码的复杂性和 Python 在幕后运行代码所需的工作。相反,模型是,如果你想使用模块,就包含它们,它们不是默认包含的。
让我们在代码顶部添加import string:
现在我们处于良好的状态:
最后一个是True。这是一个强密码,因为它添加了$标点符号。
我们希望你现在已经相信了测试的价值。有时候,新程序员不会测试他们的代码。他们假设他们写的代码是正确的,因为它们对他们来说是有意义的。新手程序员和经验丰富的程序员之间一个有趣的不同之处在于,新手程序员经常假设他们的代码是正确的,而经验丰富的程序员则假设他们的代码是错误的,直到经过彻底测试并证明否则。除此之外,我们发现新程序员有时无法很好地进行测试,因为得知代码有错误会令人沮丧。但现在知道总比在别人在严重应用中使用你的代码时才知道要好。通过测试发现错误实际上是一件好事。
现在我们有一个函数可以告诉我们密码是否强大,让我们编写一个函数从用户那里获取强密码。它将不断要求用户输入密码,直到用户输入一个强大的密码。这是网站在告诉你“抱歉,你的密码太弱了,请再试一次”时使用的代码。
这样的函数的标题应该是什么样的呢?嗯,它将要求用户输入密码,但我们不会有一个密码参数。实际上,我们根本不会有任何参数,因为该函数不需要任何信息来完成其工作——它只需要提示用户,用户将在键盘上输入密码。当函数完成其工作后,它将返回强密码,因此它将继续以字符串作为输出返回。让我们尝试这个提示:
我们从 Copilot 得到了以下代码作为结果:
这个 while 关键字创建了一种另一种循环,只要输入的密码不强,这个循环就会继续。Copilot 也足够聪明,可以调用我们之前的 is_strong_password 函数来确定什么算作强密码。正如你将在未来的章节中看到的,以这种方式使用函数作为构建块正是构建大型程序的方法。你经常会注意到 Copilot 调用你之前的函数来解决后续的问题,就像我们在这里观察到的那样。
让我们来测试一下!高亮显示我们两个密码函数的所有代码,然后按 Shift-Enter。我们将调用我们想要测试的函数。然后,尝试输入密码,每次输入后按 Enter。你会注意到它会一直询问,直到你最终提供一个强密码:
注意,当我们最终提供一个强密码时,它就不再询问我们密码了。然后,我们看到它返回的字符串,这确实是我们的强密码。
丹最喜欢的桌面游戏之一是 Scrabble。你玩过吗?如果没有,你只需要知道你手里有一些带有字母的方块,你的目标是使用这些字母的任意组合来形成一个单词。你不需要只用你的方块来形成单词——你可以将这些字母附加到板上的现有字母上以形成更长的单词——但我们在这里不会担心这一点。对我们来说,重要的是不同的字母有不同的分数。例如,a 只值 1 分,因为 a 是如此常见的字母。但 q 和 z 呢?这些宝贝每个都值 10 分,因为它们很难使用——或者说 令人困惑 的使用。是的,这样更好。
要计算一个单词的分数,我们将它的每个字母的分数加起来。例如,zap 的分数将是 14。这是因为 z 值 10,a 值 1,而 p 值 3。
丹想要一个函数,给定一个单词,告诉他这个单词值多少分。好吧,所以我们需要一个函数,它接受一个单词(这只是一个参数)。让我们尝试这个提示,其中我们包括了每个字母根据官方 Scrabble 规则的价值:
对于这个函数,Copilot 不会通过按一次 Tab 键来为我们填写整个函数体。Tab 也可以用来缩进代码,我们在这里也需要这样做,这样 Copilot 才会展示代码。所以,我们不得不按 Tab(有时一次用于缩进,然后再次用于 Copilot),等待代码出现,然后按 Tab 接受该代码,然后再次按 Enter 以展示更多代码,并重复此过程。
我们发现,Copilot 有时会在给出建议时暂停,直到你按下键来帮助提示它。如表格 2.1 中提到的,如果你给它评论,Copilot 有时只想给你更多的评论作为建议。再次按下 Enter 键通常可以触发它开始生成代码。有时,按下 Tab 键会导致它继续提供建议。如果 Copilot 没有为你生成代码,请参考表格 2.1。
最后,经过几次这样的操作后,整个代码展开,我们得到以下结果:
注意,这里列出了所有字母,每个类别应得的分数在下一行。我们将在下一章讨论这种整体决策结构,但简单来说,就是逐个字符遍历单词,对于每个字符,确定它是哪个字母,并为该字母添加相应的分数。
让我们来测试一下,确保我们的代码正在按照我们期望的方式运行。我们知道单词 zap 应该值 14 分,确实如此:
让我们再试几个。在每种情况下,手动计算你预期的答案——这样你就可以知道代码是否正确地执行了:
编写函数的正确代码有多种方式。如果你按下 Ctrl-Enter 并查看 Copilot 的建议,你可能会看到不同类型的代码。这并不意味着其中一种类型是正确的,而其他类型是错误的。例如,这里是从其他 Copilot 建议中得到的 num_points 的另一个解决方案:
注意这里,每个字母都单独列出,并附上其对应的分数。这种包含字母和分数的结构称为 字典,再次强调,你将在后面的章节中详细了解字典。现在重要的是,Copilot 编写函数代码通常有多种等效的好方法。在继续之前,先尝试用一些测试案例来验证这个方法!
让我们继续 Scrabble 主题。假设 Dan 现在有一堆单词可以组成,但他不知道哪个单词能给他带来最多的分数。他应该组成单词 zap、pack 还是 quack?如果有一个函数能接受一堆单词并告诉我们哪个是最好的,那就太好了。
在这样的函数中,我们应该有多少个参数?你的第一反应可能是三个参数,每个参数对应我们选项中的三个单词。但这不会很灵活。毕竟,如果我们想知道 5 个单词、10 个单词或 50 个单词中的最佳单词,怎么办?我们希望我们的函数能够处理我们考虑的任何数量的单词。
技巧是使用一个只有一个(是的,只有一个!)参数的函数,这个参数是一个单词列表。就像数字、字符串和字典一样,列表是 Python 支持的一种类型。它非常有用,因为它允许我们将尽可能多的值收集到一个地方。我们可以像这样提示 Copilot 来获取我们想要的函数:
Copilot 将如何知道每个单词值是多少?嗯,它可以调用我们在上一节中编写的那个num_points函数!这是 Copilot 给出的代码:
我们在测试时需要小心地正确调用这个函数。使用
是不会工作的,因为那样传递了三个参数,而不是一个。正确调用它的方法是把单词放在方括号里,就像这样(我们将在下一章中了解更多关于列表的内容):
但,像往常一样,你不应该只满足于一个测试用例。这个函数也应该在其他有效但可能奇怪的用例中工作,例如,当我们列表中只有一个单词时:
然而,我们不会在一个没有任何单词的列表上测试这个函数。在那个情况下,这样做又有什么意义呢?不管这个函数做什么,在实际上没有正确行为的情况下,很难说它是否做了正确的事情!
现在你已经看到了许多函数,我们希望你在开始了解函数在软件中的应用。我们已经提到,函数在执行问题分解中是至关重要的。除了问题分解之外,函数在软件中还有许多其他价值,包括以下方面:
避免重复 —程序员(我们可以说,人类普遍来说)并不热衷于反复解决相同的问题。如果我写了一个函数,可以正确计算圆的面积,那么我就不需要再写那段代码了。这意味着如果我的代码中有两个部分需要计算圆的面积,我会写一个计算圆面积的函数,然后在这两个地方都调用这个函数。** 改进 测试 —与只做一件事情的代码相比,测试做多件事情的代码要困难得多。程序员使用各种测试技术,但一个关键的技术被称为单元测试。每个函数都接受一些输入并产生一些输出。例如,对于一个计算圆面积的函数,输入将是圆的半径,输出将是其面积。单元测试会给函数一个输入,然后比较这个输入与期望的结果。对于圆面积函数,我们可能会通过给它不同的输入(例如,一些小的正数,一些大的正数,以及 0)来测试它,并将函数的结果与我们已知是正确的值进行比较。如果函数的答案与我们的预期相符,我们就对代码的正确性有了更高的信心。但如果代码产生了错误,我们检查以找到和修复问题的代码就不会很多。但是,如果一个函数执行多个任务,它会极大地复杂化测试过程,因为你需要测试每个任务以及这些任务之间的交互。** 提高可靠性 —当我们作为经验丰富的软件工程师编写代码时,我们知道我们会犯错误。我们也知道 Copilot 会犯错误。如果你想象自己是一位出色的程序员,你写的每一行代码有 95%的可能性是正确的,你认为你能在至少有一行可能不正确之前写多少行代码?答案是只有 14 行。我们认为每行 95%的正确性可能即使是经验丰富的程序员也是一个很高的标准,可能比 Copilot 产生的标准还要高。通过保持任务小,任务可以在 12-20 行代码内解决,我们降低了代码中存在错误的概率。如果与前面提到的良好测试相结合,我们可以更有信心地认为代码是正确的。最后,没有比代码中存在多个相互作用的错误更糟糕的了,随着你写的代码越多,多个错误的概率就越大。我们俩都经历过因为代码中存在多个错误而进行的数小时调试之旅,并且我们都因此变得更加擅长频繁测试短小的代码片段!** 提高代码可读性 —*在这本书中,我们主要使用 Copilot 从头开始编写代码,但这不是使用 Copilot 的唯一方式。如果你有一个更大的软件项目,你或你的同事都在编辑和使用,Copilot 也可以跳进来帮助编写那个项目的代码。无论是大部分代码是由人类还是由 Copilot 编写,理解代码对每个人来说都是有益的。这样,我们可以更容易地找到错误,确定当我们想要添加新功能时,从哪里开始修改代码,以及从高层次上理解使用我们的整体程序设计实现什么会容易或困难。将任务分解成函数有助于我们理解代码的每一部分在做什么,这样我们可以更好地了解它们是如何一起工作的。它还有助于分配工作责任,确保代码的正确性。*****
**这些好处对程序员来说非常巨大。编程语言并不总是有函数。但在它们有函数之前,程序员们尽力使用其他特性来模仿函数。这些方法很丑陋(如果你感兴趣,可以谷歌搜索“goto 语句”),所有程序员都为现在有了真正的函数而感到高兴。
你可能会问,“我明白这些优势对人类的重要性,但它们是如何影响 Copilot 的?”总的来说,我们认为适用于人类的所有原则也适用于 Copilot,尽管有时原因不同。Copilot 可能没有认知负荷,但当我们要求它解决与人类之前所做类似的问题时,它会表现得更好。因为人类编写函数来解决任务,Copilot 会模仿这一点并编写函数。一旦我们手动或通过 Copilot 编写并测试了一个函数,我们就不想再写一次。知道如何测试你的程序是否正常工作对于人类编写的代码和 Copilot 编写的代码来说同样重要。Copilot 在生成代码时可能会犯错误,所以我们想快速捕捉这些错误,就像我们处理人类编写的代码一样。即使你只处理自己的代码,并且从未让其他人阅读它,你也经常需要多年后回过头来编辑你之前写的代码。我们自己做这件事,可以证明,对于你未来的自己来说,让你的代码可读是非常重要的!
函数在编程中扮演着多种不同的角色。在较高层次上,程序本身就是函数,它们(通常)会调用其他函数。关键的是,所有程序,包括 Python 程序,都起源于一个单一函数(在 Java、C 和 C++等语言中命名为main)。Python 的main函数本质上是非函数中的第一行代码。但如果我们认为每个程序都从一个单一函数开始,并且试图用一个函数解决一个大问题是一个错误,那么这是如何工作的呢?嗯,main会调用其他函数,而这些函数又会调用其他函数,如此类推。代码仍然会在每个函数中(主要)顺序执行,所以它可能从main开始,然后移动到另一个函数,依此类推。
例如,让我们使用以下列表中的代码。我们编写这段代码,而不是 Copilot,因为没有人会想编写这段代码来用于任何有用的教学之外的事情。这只是为了演示函数调用是如何工作的。
如果我们运行这个程序,输出将会是(我们将在下文中解释原因):
在图 3.5 中,我们提供了一个示例 3.1 中代码在计算机中如何被执行的示意图。我们故意提供了一个包含许多函数调用的例子,以便将我们刚刚学到的知识联系起来。再次强调,这不是实际可用的代码;这只是用于学习目的。让我们一起追踪代码执行。在跟随时,你可能更容易参考图 3.5 而不是列表 3.1,但两者都可以。
程序将从 Python 代码中第一个不是函数的第一行开始执行(print("Hi"))。尽管 Python 本身没有 main 函数,但为了便于解释,我们将函数之后的代码块称为 main。代码按顺序执行,除非它遇到告诉它执行其他地方代码的命令。因此,执行 print("Hi") 后,它将转到下一行,即调用 funct1:funct1()。对 funct1 的调用改变了代码执行的地点,到该函数的开始,即以下语句:print("there")。funct1 的下一行调用 funct2,因此程序将执行 funct2 的第一行:print("my")。有趣的是,当 funct2 完成时会发生什么。没有更多的代码行可以执行,所以它自动将执行权返回到 funct1 中调用 funct2 的下一行。(如果函数调用在另一个语句的中间,那个语句将恢复执行,但在这个例子中,函数调用都在单独的一行上。)你可能好奇为什么它会在调用 funct2 后转到下一行而不是返回到 funct2 的调用处。问题是如果它返回到 funct2 的调用处,它将永远陷入调用 funct2 的循环中。因此,函数在被调用后总是返回到下一行代码以执行(在这个例子中,是下一行)。
继续这个例子,接下来执行的代码行将打印 friend 的那一行。下一行调用 funct3,它打印一个点(.)然后返回到调用者。
因此,我们又回到了 funct1,在 print("") 这一行。打印一段空文本会导致新的一行。现在 funct1 已经完成,所以它将执行权传回给 main 中调用它的下一行。现在你可能已经明白了这个概念,所以我们稍微快一点进行:
main 下一步打印 I'm 然后调用 funct4.
funct4 打印 well 然后返回到 main,在那里下一行代码调用 funct3。
funct3 打印一个点(.)然后返回到 main。注意,funct3 既有 funct1 又有 main 调用,但这没关系,因为函数记得如何返回到调用它们的函数。实际上,多个函数调用同一个函数是函数被多次调用且具有重用价值的标志。
funct3 返回到主函数后,它将打印 "",这会导致开始新的一行,然后它打印单词 Bye。
这是一个很长的例子,但我们提供它是为了让你了解函数是如何执行的,以及程序是如何由定义和调用函数组成的。在任何你使用的软件中,想想它执行的具体任务:程序员可能为每个任务编写了一个或多个函数。在文本编辑器中改变文本为粗体的按钮可能调用一个函数来改变文本为粗体。这个函数可能会改变编辑器对文本的内部理解(编辑器可能以不同于你查看的方式存储你的文本),然后它可能调用另一个函数来更新用户(你)对文本的视图。
我们还希望用这个例子来讨论函数扮演的不同角色。一个辅助函数是一个其工作是为了使另一个函数的工作更容易的函数。从某种意义上说,每个不是 main 的函数都是一个辅助函数。最好的辅助函数是那些执行其他任务的一些小而定义明确的部分的函数,比如我们在 3.3.2 节中的 is_strong_password 函数作为 3.3.3 节中的 get_strong_password 函数的辅助函数。
有些函数只是调用了一堆其他函数而没有做任何自己的工作。在我们的例子中没有这样的函数。然而,如果你从 funct1 中移除了三个 print 语句,它就变成了这种协调函数。其他函数可能调用辅助函数,然后自己做一些工作。funct1 是一个很好的例子,它调用其他函数同时也自己做工作。
另一种类型的函数——我们称之为叶函数——独立存在,不需要调用其他函数来帮助(除了可能已经包含在 Python 中的函数)。为什么叫叶函数?如果你想象所有的函数调用就像一棵大树,这些函数就是树的叶子,因为它们没有东西输出。在我们的例子中,funct2、funct3 和 funct4 都是叶函数。我们主要关注本章中的叶函数,但你在这里和后面的章节中也会看到其他类型的函数的例子。
没有明确的规则来决定什么是一个好的函数,但有一些直觉和建议我们可以分享。不过,不要误解——识别好的函数是一种需要时间和实践技巧的技能。为了帮助你,我们在 3.3 节中给你提供了一些好的函数的例子。在本节中,我们将概述我们的建议,并提供一些额外的好的和坏的例子来帮助你建立这种直觉。
这里有一些我们认为会帮助你看到什么使一个函数好的指南:
** 行为定义明确 —“在列表中找到最大的数字”这个任务是明确定义的。如果你给我一个数字列表并问我最大的数字是什么,你知道你应该做什么。相比之下,“在列表中找到最好的单词”这个任务定义得并不好,如上所述。你需要更多信息:什么是“最好的”单词?是长度最长的,使用最少元音的,还是不与“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 是否按照我们的预期工作的第一个合理性检查。然后,在后面的章节中,我们将深入探讨如何仔细测试代码,以及当它出错时应该做什么。
审查以下函数描述,并确定每个是否是叶子函数。根据函数执行的任务的复杂性和它们是否可能需要调用额外的自定义函数,提供你的答案的理由。
抓取数据。 给定一个 URL 列表,从每个页面抓取数据,解析内容以提取特定信息,并将提取的数据保存到数据库中。
处理客户订单。 给定客户订单列表,通过检查库存,计算包含折扣和税的总价,更新库存,并生成订单摘要来处理每个订单。
在列表中找到最大的数字。 给定一个列表,返回最大的值。
检查列表中是否包含特定值。 给定一个列表和一个值,如果列表包含该值则返回 True,如果不包含则返回 False。
生成学生成绩单。 给定学生名单和他们的成绩,计算最终成绩,分配字母等级,确定班级排名,并为每个学生生成格式化的成绩单。
审查本章概述的与 Copilot 一起设计函数的周期。这个周期包括从确定函数的行为到测试生成的代码的步骤。根据一般的编程实践,确定以下哪个步骤不包括在本章描述的周期中,但通常是软件开发过程的一部分:
通过运行单元测试验证生成的代码的逻辑正确性
在 Copilot 生成后手动编辑函数的代码以简化复杂的逻辑
对生成的代码进行性能基准测试以确保效率
咨询文档或外部资源以完善函数的提示
根据提供的问题描述,为 Copilot 编写一个清晰简洁的提示,指定要完成的任务。确保你的提示包括所有必要的细节,例如预期的输入、要执行的处理以及预期的输出:
你能想到两个或三个输入示例来验证 Copilot 根据你的提示生成的函数的正确性吗?包括一些场景以确保函数能够正确处理不同类型的输入并计算出正确的输出。
问题描述:创建一个函数,计算餐点的总费用,包括税费和小费。该函数应接受三个输入:餐点的基准价格、税率(百分比)和小费率(百分比)。该函数应计算并返回餐点的总费用。
测试用例:
标准情况—计算一个$50 餐点的总价格,包含 10%的税费和 15%的小费:
输入:base_price = 50, tax_rate = 10, tip_rate = 15
预期输出:$62.50(计算为$50 + $5 税费 + $7.50 小费)
无小费情况—计算一个$30 餐点的总价格,包含 5%的税费和没有小费:
输入:base_price = 30, tax_rate = 5, tip_rate = 0
预期输出:$31.50(计算为$30 + $1.50 税费 + $0 小费)
高税费和小费情况—计算一个$100 餐点的总价格,包含 20%的税费和 25%的小费:
输入:base_price = 100, tax_rate = 20, tip_rate = 25
预期输出:$145(计算为$100 + $20 税费 + $25 小费)
问题分解涉及将一个大问题分解成更小的任务。
我们在程序中使用函数来进行问题分解。
每个函数必须解决一个小的、定义明确的任务。
函数头或签名是函数的第一行代码。
参数用于向函数提供信息。
函数头指示了函数的名称及其参数的名称。
我们使用return从函数传递值给其调用者。
文档字符串使用每个函数参数的名称来描述函数的目的。
要让 Copilot 编写一个函数,我们提供函数头和文档字符串。
通过为参数提供值(也称为参数)来调用函数,我们让函数执行其工作。
为了测试一个函数是否正确,我们用不同类型的输入调用它。
变量是一个指向值的名称。
每个 Python 值都有一个类型,例如数字、文本(字符串)、真/假值(bool)或值的集合(列表或字典)。
提示工程涉及修改 Copilot 的提示,以影响我们得到的代码。
我们需要确保导入代码中使用的任何模块(例如,string)。
函数减少重复,使测试我们的代码更容易,并降低错误的可能性。
单元测试涉及检查函数在多种不同输入下是否按预期执行。
辅助函数是一个小型函数,编写它的目的是为了更容易地编写更大的函数。
叶函数不调用任何其他函数来完成其工作。******
知道如何阅读代码的重要性
如何让 Copilot 解释代码
使用函数分解大问题
使用变量来保留值
使用 if 语句进行决策
使用字符串存储和操作文本
使用列表收集和操作多个值
在第三章中,我们使用 Copilot 为我们编写了几个函数。它们有什么用?也许我们的money_made函数可以是股票交易系统的一部分,我们的is_strong_password函数可以用作社交网站的一部分,或者我们的best_word函数可以用作某些 Scrabble AI 的一部分。总的来说,我们编写了一些有用的函数,它们可以是更大程序的一部分。而且,我们没有写很多代码,实际上甚至不理解代码的作用。
然而,我们认为你需要从高层次上理解代码的作用。因为这需要一些时间来学习,所以我们把这个讨论分成了两章。在本章中,我们将解释阅读代码的重要性,并介绍一个可以帮助你理解代码的 Copilot 功能。之后,我们将深入探讨你需要识别的前 10 个编程特性,以阅读 Copilot 生成的最基本代码。我们将在这章中介绍前五个,在下一章中介绍剩下的五个。不用担心:你实际上已经非正式地介绍了这 10 个——我们只是要加深你对每个特性的理解。
当我们谈论阅读代码时,我们的意思是通过查看代码来理解代码的作用。有两种这样的理解层次,它们都很重要。
第一层是能够逐行理解程序将做什么。这通常涉及到在代码运行时追踪变量的值,以确定代码在每一步的具体行为。这很有用,但并不是全部。就像不知道食谱要做什么或其成分如何搭配的情况下,一步一步地跟随烹饪食谱。最后,你可能会有一个蛋糕,但你可能无法理解蛋糕为什么做得这么好,或者你如何能自己想出这样的食谱。你需要理解整个食谱为什么有效。
所以,这是第二层:确定程序的整体目的。也就是说,你需要能够从每一行中抽离出来,从高层次上理解程序在做什么。
我们可以通过参考第三章中重新打印的best_word函数来阐述阅读代码两个层次之间的区别。
追踪描述这个程序的功能是描述每一行。例如,我们会说我们定义了一个名为best_word的函数,它接受一个名为word_list的参数。我们有一个名为best_word的变量,我们一开始将其初始化为一个空字符串,即没有字符的字符串。(不幸的是,函数和这个变量都叫best_word,这使得指代其中一个或另一个变得复杂,但这是 Copilot 给我们的。)我们还有一个名为best_points的变量,我们将其初始化为0。然后,我们有一个遍历word_list中每个单词的for循环。在for循环内部,我们调用我们的num_points辅助函数,并将它的返回值存储在points变量中,等等。(我们将在本章和下一章中解释我们如何知道每一行代码的作用。)
相反,一个整体目的描述可能类似于我们的文档字符串描述:“从单词列表中返回具有最高 Scrabble 得分值的单词。”这个描述不是针对每一行,而是指代代码的整体目的,从高层次解释它做什么。
你将通过结合追踪和测试的实践达到整体目的的理解水平,我们希望你在书的结尾能够完全实现这一目标。在追踪层次上工作通常先于在整体目的层次上工作[1],因此在本章和下一章中,我们将通过理解每一行代码的作用来专注于追踪层次。我们希望你能够阅读代码有三个原因:
为了帮助确定代码是否正确—在第三章中,我们练习了如何测试 Copilot 给出的代码。测试是确定代码是否正确执行的一项强大技能,我们将在整本书中继续使用它。但许多程序员,包括我们两个,只有在代码看起来合理正确的情况下才会进行测试。如果我们通过检查就能确定代码是错误的,那么我们就不费心去测试它:我们会先尝试修复代码。同样,我们希望你能识别出代码的错误,而无需花费时间去测试。你能识别出更多错误的代码(通过快速追踪或提高你的整体技能),你就能节省更多测试错误代码的时间。
为了指导测试—理解代码逐行执行的内容本身很有用,但它也有助于提升你测试有效性的能力。例如,在下一章中,你将学习到循环——它们可以使代码重复零次、一次、两次或所需次数。你将能够将这一知识与你已经了解的测试知识相结合,帮助你确定重要的测试案例类别。
为了帮助你编写代码—我们知道,你希望 Copilot 为你编写所有代码!我们也有同样的愿望。但不可避免的是,Copilot 可能无法正确处理某些代码,无论你如何进行提示工程。或者,也许足够的提示工程最终能够说服 Copilot 编写正确的代码,但自己动手做会更简单、更快。在编写这本书的过程中,我们两个人努力让 Copilot 尽可能多地编写代码。但是,由于我们对 Python 编程的了解,我们经常能够发现错误并直接修复,而无需通过任何繁琐的步骤让 Copilot 为我们修复。从长远来看,我们希望你能获得自主学习的编程能力,而理解 Python 编程则是我们为你提供从这本书到其他资源桥梁的方式。有研究表明,能够追踪和解释代码是能够编写代码的前提条件 [1]。
在我们深入探讨之前,我们需要明确我们追求的深度水平。我们不会教给你每一行代码的每一个细微之处。这样做会让我们回到在 Copilot 等工具出现之前传统的编程教学方式。相反,通过 Copilot 工具和我们的解释相结合,我们将帮助你理解每一行代码的精髓或总体目标。如果你未来打算编写大量程序,你需要的东西将不止于此。我们试图在“这段代码是魔法”和“我确切知道代码的每一行是如何工作的”之间找到最佳平衡点。
到目前为止,我们使用 Copilot 为我们生成代码。这是它最知名的功能,但并非唯一。现在,我们将向你展示它的另一个最佳功能:解释 Python 代码的功能!
Copilot 扩展总是在变化中。我们这里给出的具体步骤可能会有所不同,在这种情况下,我们鼓励你查阅更通用的 GitHub Copilot 文档。
要尝试解释功能,你需要突出显示一些你希望 Copilot 为你描述的代码。让我们用我们的best_word函数(列表 4.1)来试一试。如果你没有从第三章输入这段代码,请现在输入。
如图 4.1 所示,高亮显示代码。之后,我们有几个选项。第一个是点击活动栏中的聊天图标(位于 VS Code 的左侧),或者按 Ctrl-Alt-I。这将打开如图 4.2 所示的 Copilot 聊天界面。(还有其他聊天界面,包括你可以通过 Ctrl-Shift-I 打开的快速聊天——使用哪个聊天界面无关紧要。)Copilot 聊天能够不仅用自然语言,还能用代码块、你可以点击以调用命令的按钮/链接等方式来回答你的问题。它还建议你可能想问的问题,当然,也允许你输入任何你喜欢的与代码相关的问题。
现在,我们将使用 Copilot 聊天来解释代码。在文本框中,输入以下命令:
如果 Copilot 询问,你想要选择@workspace 选项。如果这个选项对你不起作用,你也可以尝试以下命令:
输入一个正斜杠(/)是告诉 Copilot 聊天你正在指定一个命令(Copilot 文档将这些称为斜杠命令),而不是自然语言。在这种情况下,我们正在请求 Copilot 运行/explain命令来解释选中的文本。
或者,你可以通过如图 4.1 所示的方式在函数中高亮显示代码来请求 Copilot 解释代码。然后,右键点击函数中的代码,你将看到如图 4.3 所示的菜单。
在这个菜单中,高亮显示 Copilot,你将看到图 4.4 中显示的选项列表。这些选项是使用 Copilot 时常用的功能。
最后一步是选择“解释此代码”选项。Copilot 聊天窗口将打开(如图 4.2 中之前所示),你将得到代码的解释。像往常一样,Copilot 是非确定性的,所以你的结果可能与我们的大不相同。事实上,如果解释让你感到困惑,你可能再次尝试使用/explain命令,或者编写自己的提示以获得不同的解释。作为教育工作者,这是梦想成真的开始,学习者将能够请求他们需要的任何解释,以完全理解代码是如何工作的。
与 Copilot 生成的代码一样,Copilot 的解释也可能出错。你可能想,“等等,如果这个工具可以出错,那么理解代码的价值在哪里?!”首先,我们同意这确实可能非常令人沮丧。然而,根据我们的经验,Copilot 的解释通常足够准确,可以非常有帮助,尤其是在理解代码基础知识方面,比如我们在这两章中教授的内容。此外,当学习者对他们的代码感到困惑时,他们通常会向朋友或网站寻求关于代码的帮助,这也可能导致错误。为了帮助减轻错误的可能性,你可以多次请求 Copilot 的解释,因为你在多次尝试中不太可能得到完全错误的答案。
这是第一次请求 Copilot 时它给出的:
提供的 Python 代码定义了一个名为best_word的函数,它接受一个单词列表作为参数(word_list)。这个函数的目的是确定并返回列表中价值最高的单词。
函数首先初始化两个变量:best_word和best_points。best_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 明确地给你每一行代码并解释其功能,这样你就可以更容易地将代码与其具体解释联系起来。你可以做到这一点!尝试这个提示:“再次解释那行代码,但逐行解释。给出每一行代码及其功能的解释。”这样做给了我们每一行代码及其解释的非常详细的分解。我们鼓励你如果内置命令没有提供你想要的详细程度,就自己制作提示。
我们将带您快速浏览您在接下来的编程旅程中需要的 10 个 Python 特性,从本章中提到的这五个开始。Python 是一种交互式语言,这使得我们比其他语言更容易进行实验和查看各种功能。我们将利用这一点来探索编程特性。这是我们学习 Python 的方式,也是成千上万的程序员学习 Python 的方式。不要犹豫去尝试!要开始,请按 Ctrl–Shift–P 并输入 REPL,然后选择 Python:启动 REPL。这应该会导致图 4.5 中所示的情况。(REPL 代表读取-执行-打印-循环。之所以这样称呼,是因为 Python 会读取你输入的内容,执行/运行它,将结果打印回你,并在循环中反复这样做。)
这将把你带回到第三章中相同的 Python 提示符(如图 4.6 所示),但没有任何你的函数被加载。
然后,我们可以开始输入 Python 代码。例如,输入
然后按 Enter 键。你会看到 20 的响应。我们在这里不会花费时间在简单的数学上,但你是如何与 Python 交互以了解其工作方式的,这与我们之前提到的那 10 个 Python 特性中的第一个完全相同:你输入一些代码,Python 就会响应。现在,让我们深入探讨我们之前提到的 10 个 Python 特性中的前五个。
你在第三章已经学到了所有关于函数的知识,所以让我们只总结一下我们学到了什么。你使用函数将一个大问题分解成更小的部分。回顾一下,我们在第三章写的 best_word 函数是一个相当大的任务:它必须确定在一系列单词中哪个单词值得最多的分数。一个单词值多少分?啊——这是一个可以从该函数中分离出来的子任务,我们在之前的 num_points 函数中就是这样做的。
我们设计一个函数来接受参数,每个参数对应于函数需要执行其工作所需的数据块或数据集合。在完成它们的工作后,大多数函数使用 return 将答案发送回调用它们的代码行。当我们调用一个函数时,我们传递值,称为参数,每个参数一个值,我们通常使用变量来存储那个返回值。
对于我们编写的每个程序,我们可能需要设计几个函数,但 Python 也内置了一些函数,我们可以免费使用。我们可以像调用自己的函数一样调用它们。例如,有一个内置的 max 函数,它接受一个或多个参数,并告诉我们最大的值:
还有 input 函数,我们在第三章的 get_strong_password 函数中使用过。它接受一个参数,该参数成为用户的提示,并返回用户在键盘上输入的内容:
如果 input 是从键盘接收输入的函数,那么有没有一个 output 函数可以将消息输出到屏幕?嗯,是的,但它被称为 print,而不是 output:
变量是一个指向值的名称。我们在第三章中使用变量来跟踪函数的 return 值。我们在这里也使用了一个变量来保存用户的姓名。每次我们需要记住一个值以供以后使用时,我们都会使用变量。
要将值赋给变量,我们使用等号(=)符号,这被称为 赋值 符号。它计算出右侧的值,并将其赋给变量:
在 Python 和其他编程语言中,等号(=)用于表示 赋值。等号左侧的变量被赋予等号右侧计算得到的值。这不是一个永久的关系,因为变量的值可以改变。对于数学能力强但编程新手的人来说,这可能会令人困惑,但只需记住 Python 中的等号表示赋值,而不是相等。
我们可以在更大的上下文中使用变量,这被称为 表达式。变量所指向的值会替换其名称:
我们在之前的代码块中分配了 age。为什么我们可以继续引用它?在 Python 提示符的编程会话期间声明的任何变量都会持续存在,直到你退出。这就是程序中变量的工作方式。一旦你为它们赋值,它们就可用。
但请注意,当我们说 age + 3 时,变量 age 并没有改变!要改变它,我们需要另一个 = 赋值语句:
让我们看看更多改变变量引用的方法。我们将在代码旁边添加一些注释来解释:
每当我们的程序需要做出决定时,我们都需要一个条件语句。例如,在第二章中,我们需要决定包含在数据中的球员(我们只想包含四分卫)。为此,我们使用了 if 语句。
记得我们在第三章中提到的较大值函数吗?我们在这里的列表中重新实现了它。
列表 4.2 中的 if-else 结构被称为 条件语句,它允许我们的程序做出决定。在这里,如果 num1 大于 num2,则返回 num1;否则,返回 num2。这就是它返回较大值的方式!
在 if 后面,我们放置一个布尔条件(num1 > num2)。布尔条件是一个测试条件的表达式,其结果要么是 True,要么是 False。如果是 True,则 if 下的代码执行;如果是 False,则 else 下的代码执行。我们使用比较符号(如 >= 表示大于等于,< 表示小于,> 表示大于,== 表示等于,!= 表示不等于)来创建布尔表达式。请注意,我们不仅使用缩进来表示函数的代码,还使用缩进来表示 if 和 else 语句的代码部分。缩进对于代码正确执行是必要的,所以值得注意(我们将在下一章中更多地讨论缩进)。这就是 Python 如何知道哪些代码行属于函数,哪些代码行还属于 if 或 else。
我们也可以在 Python 提示符中玩转条件语句——我们不需要在函数内部编写代码。以下是一个例子:
当你在if语句中输入时,你会注意到提示符从>>>变为...。提示符的变化让你知道你正在输入需要完成的代码。当你完成else代码后,你需要额外按一次 Enter 键,以退出...提示符并回到>>>提示符。
我们将年龄变量设置为 40。因为40 < 40是False,所以 else 部分会执行。让我们再试一次,这次让 if 部分执行:
你可能会看到一些没有else部分的if语句,这是可以的:else部分是可选的。在这种情况下,如果条件为False,则if语句不会执行任何操作:
注意,测试两个值是否相等的方法是使用两个等号(==),而不是一个等号。(我们已知一个等号是用来执行赋值语句,将值赋给变量的。)
如果你有多于两种可能的结果怎么办?例如,假设一个人的年龄决定了他们可能会狂热的电视剧,如表 4.1 所示。
我们不能仅用if-else来捕捉所有这些结果,所以elif(即else-if)允许我们捕捉多于两个可能结果的条件逻辑,如下面的代码所示。我们在这里不包含 Python 提示符(>>>和...),因为这会非常繁琐:
我们使用and来捕捉一个复杂条件。例如,在第一行,我们希望年龄大于等于 30 且小于等于 39。Python 从上到下工作,当它找到一个成立的条件时,它会运行相应的缩进代码。然后,它停止检查剩余的elif或else——所以如果两个条件都成立,只有第一个条件的代码会运行。
尝试使用不同的年龄变量值进行实验,以观察在每种情况下正确的代码是否都能运行。实际上,如果我们认真测试这段代码,我们可以使用if语句结构来获得我们想要测试的值的良好感觉。这全部都是为了测试值的边界。例如,我们肯定想要测试 30 岁和 39 岁,以确保我们正确地用第一个条件捕捉到了完整的 30-39 岁范围。同样,我们还想测试 20 岁、29 岁、10 岁、19 岁、0 岁、9 岁,以及大于 39 岁的某个值,以测试底部的else方式。
如果你使用额外的if而不是elif,那么它们就变成了独立的if语句,而不是单个if语句。这很重要,因为 Python 总是独立地检查每个独立的if语句,而不管之前的if语句中发生了什么。
例如,让我们将我们的年龄代码中的elif改为if。这将给我们以下结果:
假设你在上述代码中将age设置为25并运行它。你认为会发生什么?嗯,第二个if条件age``>=``20``and``age``<=``29是True,所以我们肯定会输出Binging The Office。但这并不是全部发生的事情!记住,因为我们在这里使用的是if,所以每个剩余的都会被检查。(如果它们是elif,我们就完成了。)age``>=``10``and``age``<=``19是False,所以我们不会输出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?。这一切都是为了说明if和elif的行为不同,并且我们需要使用符合我们想要的行为的其中一个(如果我们想要多个代码块可能运行,则使用if;如果我们只想运行一个,则使用elif)。
正如我们在第三章中学到的,字符串是我们想要存储文本时使用的类型。文本无处不在——第二章中的统计数据、密码、书籍——所以字符串几乎出现在每个 Python 程序中。
我们使用引号来表示字符串的开始和结束。你会看到 Copilot 使用双引号或单引号。使用哪种引号都无关紧要;只需确保字符串以相同类型的引号开始和结束。
字符串附带了一组强大的方法。方法是与特定类型相关联的函数——在本例中是字符串。调用方法的方式与调用函数的方式略有不同。当你调用函数时,你将参数值放在括号内。对于方法,我们仍然将值放在括号内,但我们需要首先将我们要调用方法的价值放在括号外,然后是一个点。我们将在下一个例子中看到这一点!
在第三章中,Copilot 使用了一些字符串方法来实现is_strong_password。让我们在这里尝试使用这些方法,以便更好地理解它们是如何工作的:
还有许多其他的字符串方法。一些帮助你将字符串在大小写之间转换,其他帮助你搜索字符串,还有一些帮助你格式化字符串(例如,居中或右对齐)。
如果你对某个特定的字符串方法不确定,Copilot 的 Explain 功能通常很有帮助。例如,这个函数做什么:
记住,我们可以像在第 4.2 节中演示的那样要求 Copilot 解释代码。这是当我们要求 Copilot 解释这段代码时得到的结果:
提供的 Python 代码定义了一个名为clean_number的函数。这个函数旨在接受一个参数,phone_number,它预期是一个表示电话号码的字符串。这个函数的目的是从电话号码字符串中移除某些字符,特别是括号“(”和“)”以及连字符“-”。
函数使用了replace()方法,这是 Python 中字符串的一个内置方法。replace()方法用另一个指定的子串替换指定的子串。它接受两个参数:第一个是需要替换的子串,第二个是替换第一个子串的子串。
在这个函数中,replace()方法在phone_number上被调用了三次。每次调用replace()都用于移除一种类型的字符:首先移除开括号“(”,然后移除闭括号“)”,最后移除连字符“-”。它是通过将这些字符替换为空字符串""来实现的,从而有效地移除了它们。
在所有替换完成后,函数返回清理后的phone_number。这将是一个不包含任何括号或连字符的数字字符串。
等等——replace听起来像我们正在将一些字符更改为其他字符(替换它们),而不是移除它们。那么replace是如何为我们移除字符的呢?解释说:“它是通过将这些字符替换为空字符串""来实现的,从而有效地移除了它们。”这是了解replace如何工作的一个线索,但我们可能还想了解更多。因此,我们接下来使用了提示“replace 方法是如何使用空字符串来移除字符的?”并从 Copilot 那里收到了以下解释:
Python 中的replace()方法在其被调用的字符串中替换所有指定的子字符串。
在这种情况下,指定的子字符串是字符“(”,”)”和“-”。方法被调用在phone_number字符串上,replace()的第二个参数是一个空字符串""。
因此,发生的情况是phone_number中指定字符的每个出现都被替换为空字符串。这实际上从字符串中移除了该字符,因为用无替换某物等同于移除它。
这在clean_number函数中进行了三次,每次移除一个字符。在所有三个字符都被移除后,清理后的phone_number被返回。
如果你想要学习更多,你可以继续向 Copilot 提问或在 Python 提示符下进行自己的测试。例如,你可以验证replace会移除所有(, ), 和 - 字符,即使每种字符有多个。你还可以探索使用replace来实际替换字符(其典型用例),而不是移除它们。这种探索如果需要区分真相和 Copilot 可能提供的不正确信息片段也是很有帮助的。这就是为什么我们需要自己的 Python 知识基础!
你也会经常看到在字符串上使用看起来像数学运算符的情况。它们与数学符号相同,但在字符串上执行不同的操作。+运算符用于将两个字符串拼接在一起,而*运算符用于将字符串重复特定次数。示例如下:
当我们有一个字符序列时,例如密码或单个 Scrabble 单词,字符串就非常棒。但有时,我们需要存储许多单词或许多数字。为此,我们需要一个列表。我们在第三章使用列表来处理best_word函数,因为这个函数需要与单词列表一起工作。
*与我们在字符串的开始和结束处使用引号一样,我们使用开闭方括号来开始和结束列表。至于字符串,列表上有许多可用方法。为了给你一个关于可用列表方法和它们做什么的印象,让我们探索一些这些方法:
许多 Python 类型,包括字符串和列表,允许你使用索引来处理特定的值。当你想要处理字符串或列表的一部分而不是整个字符串或列表时,你需要使用索引。索引只是一个标识元素的数字。索引从 0 开始,对于第一个元素,然后一直增加到但不包括值的数量。第一个值有索引 0(不是索引 1!),第二个有索引 1,第三个有索引 2,依此类推。列表中的最后一个值在索引处,该索引是列表长度减 1。列表的长度可以通过使用len函数来确定。例如,如果我们做len(books),我们会得到一个值为4(因此有效的索引是从 0 到包括 3)。人们也经常使用负索引,这为每个值提供了另一种索引方式:最右侧的值有索引-1,其左侧的值有索引-2,依此类推。图 4.7 展示了正索引和负索引的示例。
让我们在当前的书籍列表上练习索引:
此外,还有一种方法可以从字符串或列表中提取多个值,而不仅仅是单个值。这被称为切片。我们指定第一个值的索引,一个冒号,以及值右侧的索引,如下所示:
我们指定了 1:3,所以你可能期望得到包括索引 3 在内的值。但冒号后面的第二个索引(即值之后的索引)不包括在内。这听起来很反直觉,但却是真的!
如果我们省略起始或结束索引,Python 会根据需要使用起始或结束索引:
我们还可以使用索引来更改列表中的特定值,例如:
如果我们在字符串上尝试这样做,我们会得到一个错误:
字符串被称为不可变值,这意味着您不能更改其字符。您只能创建一个全新的字符串。相比之下,列表被称为可变值,这意味着您可以更改它。如果您收到有关类型不支持项赋值错误的错误,您很可能是尝试更改无法更改的值。
在本章中,我们向您介绍了 Python 中最常见的五个代码特性。我们将在下一章继续介绍另外五个。我们还向您展示了如何使用 Copilot 解释工具来帮助您理解代码,并提供了验证这些解释真实性的指导。表 4.2 提供了本章所涵盖特性的总结。
(回忆第 4.3.1 节。)考虑以下 Python 函数调用min(input("Enter a number: "), input("Enter another number: "))。在运行此代码之前,思考这一行代码的预期功能,然后回答以下问题:
你期望运行此代码时会发生什么?描述执行过程的每个步骤。
在这个场景中,Python 是如何处理输入和min函数的?
如果输入的不是数字,而是可以表示数字的字符串(例如,5或2),Python 的行为会怎样改变,如果有的话?
如果一个或两个输入不是数字,结果会怎样?通过尝试不同的输入来测试您的假设。
Copilot 提供了以下解释:
这段 Python 代码是一个简单的程序,它会询问用户的年龄,然后根据用户所属的年龄范围打印出一条消息。
代码的第一行使用了input()函数来提示用户输入他们的年龄。input()函数从输入中读取一行(通常来自用户的键盘),将其转换为字符串,并返回该字符串。在这种情况下,提示是“Enter your age: ”。然后使用int()函数将该字符串转换为整数并存储在变量age中。
代码的其余部分是一系列条件语句,用于检查年龄的值并打印出相应的消息。if语句检查年龄是否小于 13 岁,如果是,则打印“你是一个孩子。”如果年龄不小于 13 岁,程序将移动到下一个条件,即elif(代表else if)语句,它检查年龄是否小于 20 岁。如果是,则打印“你是一个青少年。”如果年龄不小于 20 岁,它将移动到下一个elif语句,该语句检查年龄是否小于 60 岁。如果是,则打印“你是一个成年人。”
结果表明,Copilot 的解释不完整,因为它没有描述代码对所有输入的处理。确定解释中缺少的内容,并将你的答案添加到解释中。
给 Copilot 的提示:
你能解释一下这段代码的作用吗?
Copilot 给出的解释:
代码涉及通过查找特定单词并将其替换为另一个单词来操作存储在文本中的字符串。它使用方法来定位单词的开始位置,然后构建一个新的字符串,其中单词已被替换。最后,它打印出修改后的字符串。
你的任务是执行以下操作:
确定为什么初始提示可能导致模糊或不实用的解释。
改进提示以确保 Copilot 提供更详细和具体的解释。考虑你具体不理解字符串方法以及代码如何使用它们。
将修订后的提示写给 Copilot,并讨论你的更改如何导致更好的解释。
我们需要能够阅读代码以确定其正确性,有效地测试它,并在需要时编写自己的代码。
Copilot 可以逐行解释代码,以解释代码正在做什么,你可以使用自己的提示来影响你获得的解释类型。
Python 有内置函数,如max、input和print,我们可以像调用自己的函数一样调用它们。
变量是一个指向值的名称。
一个赋值语句使一个变量指向一个特定的值。
一个if语句用于让我们的程序做出决策,并沿着多条路径中的其中一条继续执行。
字符串用于存储和操作文本。
列表用于存储和操作一系列通用的值(例如,一系列数字或一系列字符串)。
字符串或列表中的每个值都有一个索引;索引从 0 开始,而不是 1。
字符串是不可变的(不可更改的);列表是可变的(可更改的)*。
使用循环重复所需的代码次数
使用缩进来告诉 Python 哪些代码属于一组
设置文件以读取和处理数据
使用模块在新的领域工作
在第四章中,我们探讨了你在继续你的编程之旅时将经常看到的五个 Python 特性:函数、变量、条件语句(if 语句)、字符串和列表。你需要了解这些特性来阅读代码,我们也解释了为什么无论是否使用 Copilot,能够阅读代码都很重要。
在本章中,我们将继续介绍五个更多的 Python 特性,这将使我们的前 10 个特性更加完整。与第四章一样,我们将通过我们自己的解释、Copilot 的解释以及在 Python 提示符下的实验来做到这一点。
本节详细介绍了你需要知道的下一个五个顶级编程特性。让我们从上一章留下的地方继续,即第 6 个特性:循环。
循环允许计算机根据需要重复执行相同的代码块。如果我们前 10 个编程特性中的任何一个能体现为什么计算机对我们完成工作如此有用,那就是这个特性。如果没有循环的能力,我们的程序通常会按顺序逐行执行。当然,它们仍然可以调用函数并使用 if 语句来做出决定,但程序完成的工作量将与我们编写的代码量成比例。但循环不是这样:一个循环可以轻松处理成千上万的值。
有两种类型的循环:for 循环和 while 循环。一般来说,当我们知道循环需要运行多少次时,我们使用 for 循环;当我们不知道时,我们使用 while 循环。例如,在第三章中,我们的 best_word 函数(如列表 5.1 所示)使用了一个 for 循环,因为我们知道循环需要运行多少次:对 word_list 中的每个单词运行一次!但在 get_strong_password 中,我们将在列表 5.4 中再次看到它,我们使用了一个 while 循环,因为我们不知道用户在输入一个强密码之前会输入多少个坏密码。我们将从 for 循环开始,然后转向 while 循环。
#1 这是一个 for 循环的例子。 **for 循环允许我们访问字符串或列表中的每个值。让我们先从一个字符串开始尝试:
注意,我们不需要为 char 赋值语句。这是因为它是一个特殊的变量,称为循环变量,它由 for 循环自动管理。char 代表字符,这是人们用来命名循环变量的一个非常流行的名字。char 变量会自动分配字符串中的每个字符。在谈论循环时,我们经常使用单词 迭代 来指代每次通过循环执行的代码。例如,我们可以说在第一次迭代中,char 指的是 v;在第二次迭代中,它指的是 a;依此类推。注意,就像函数和 if 语句一样,我们为构成循环的代码有缩进。在这个循环的主体中,我们只有一行代码,但就像函数和 if 语句一样,我们也可以有更多。
让我们看看一个 for 循环在列表上的示例(列表 5.2),演示我们可以像处理字符串的每个值一样处理列表的每个值。我们还会在循环中放入两行代码,而不是一行,以演示这是如何工作的。
**#1 第一个是一个列表,所以这是一个列表上的 for 循环。
range 函数可以给你一个范围内的数字。我们可以提供一个起始数字和一个结束数字,它将生成从起始数字开始,但不包括结束数字的范围。要查看 range 生成的数字,我们需要在它周围放置 list 函数。以下是一个使用 range 的示例:
注意,它从值 3 开始,包括 3 和 8 之间的所有值。也就是说,它包括从起始值 3 到,但不包括,结束值 9 的所有数字。
那么,range 如何帮助我们编写循环呢?嗯,而不是在范围中硬编码像 3 和 9 这样的数字,我们可以包括字符串或列表的长度,如下所示:
注意,这里的范围值是 0、1、2、3,这是我们的 lst 列表的有效索引!因此,我们可以使用 range 来控制 for 循环,这将使我们能够访问字符串或列表中的每个有效索引。
我们可以使用 range 在列表 5.2 中执行相同的任务。查看列表 5.3 以获取新代码。
**#1 使用 range 函数的 for 循环
我们在这里就停止for循环的讨论。但我们还没有结束循环的讨论,因为还有另一种类型的循环需要我们讨论:while循环。
当我们不知道要循环多少次时,我们会使用while循环。这种情况的一个很好的例子是在第三章的get_strong_password函数中。我们在这里将其代码作为列表 5.4 重现。
我们不知道用户会输入多少个密码,直到他们输入一个强大的密码。是第一次尝试、第二次尝试,还是第 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函数。
不要忘记,我们可以向 Copilot 请求对这个代码的解释,使用我们在第四章第 4.2 节学到的知识。当你觉得你并不完全理解代码正在做什么或它是如何工作时,这很有用。这是我们从 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 提供多个解释以减少对单个错误解释的依赖。
就像现在与任何与 AI 编码助手相关的事情一样,它们可能会出错。但我们在这里给出解释,因为我们认为 Copilot 的这个功能现在是一个潜在的有力教学资源,而且随着 Copilot 的改进,这一点将变得更加真实。
在我们不知道迭代次数的情况下,我们应该使用 while 循环。但即使我们知道迭代次数,我们也可以使用 while 循环。例如,我们可以使用 while 循环来处理字符串中的字符或列表中的值。我们有时会在 Copilot 生成的代码中看到它这样做,尽管使用 for 循环可能更好。例如,我们可以使用 while 循环来处理我们之前提到的 animals 列表中的动物,如下所示。但这会多做一些工作!
如果没有 index += 1,我们就永远不会增加字符串中的索引,我们会不断地输出第一个值的详细信息。这被称为无限循环。如果你回想一下我们是如何编写 for 循环的,你会发现我们不必手动增加任何索引变量。出于这些原因,许多程序员在可能的情况下更喜欢使用 for 循环。我们不必在 for 循环中手动跟踪任何索引,因此我们自动避免了某些类型的索引问题和无限循环。
在 Python 代码中,缩进至关重要,因为 Python 使用它来确定哪些代码行属于一起。这就是为什么,例如,我们总是在函数内部的代码行、if 语句的各个部分以及 for 或 while 循环的代码中进行缩进。这不仅仅是格式化得更好:如果我们缩进错误,那么代码也会出错。例如,假设我们想要询问用户当前的小时,然后根据是早上、下午还是晚上输出一些文本:
如果是早上,我们想要输出“早上好!”和“祝您有个愉快的一天。”
如果是下午,我们想要输出“下午好!”
如果是晚上,我们想要输出“晚上好!”和“祝您有个美好的夜晚。”
看看我们编写的以下代码,并尝试找出缩进的问题:
问题在于最后一行:它没有缩进,但它应该缩进!因为它没有缩进,所以无论用户输入哪个小时,我们都会输出 Have a good night.。我们需要缩进它,使其成为 if 语句的 else 部分的一部分,确保它只在晚上执行。
无论何时编写代码,我们都需要使用多级缩进来表达哪些代码片段与函数、if语句、循环等相关联。例如,当我们编写函数头时,我们需要将函数头下面的所有相关代码缩进。一些语言使用括号(例如{})来显示这一点,但 Python 只是缩进。如果你已经在函数体(一个缩进)中编写了一个循环,那么你将需要再次缩进(两个缩进)以缩进循环体,依此类推。
回顾第三章中的函数,我们可以看到这一点。例如,在我们的larger函数(重新打印为列表 5.6)中,整个函数体都是缩进的,但在if语句的if部分和else部分有进一步的缩进。
接下来,考虑我们之前在列表 5.4 中查看的get_strong_password函数:通常,函数中的所有内容都是缩进的,但while循环体的缩进更深。
在我们num_points函数的第一版中(此处从第三章的列表 5.7 中复制),甚至还有更多级别的缩进。这是因为,在遍历单词每个字符的for循环内部,我们有一个if语句。正如我们所学的,if语句的每一部分都需要缩进,从而导致额外的缩进级别。
在is_strong_password函数中也有额外的缩进(此处从第三章的列表 5.8 中复制),但这只是为了将一条超长的代码行扩展到多行。注意,这些行以\结尾,这是我们可以在下一行继续代码行的字符。
类似地,在我们的num_points函数的第二版中(此处从第三章的列表 5.9 中复制)也有一些进一步的缩进,但这只是为了将字典扩展到多行,使其更易于阅读。
缩进对程序最终执行的结果有很大影响。例如,让我们比较使用连续的两个循环与使用缩进嵌套一个循环在另一个循环中使用的情况。这里有连续的两个循环:
这导致我们得到了相同的输出两次,因为我们两次分别遍历了国家列表。现在,如果我们嵌套循环,情况如下:
我们为每个for循环使用了不同的变量名,country1和country2,这样我们就可以引用它们。在country1循环的第一次迭代中,country1指的是加拿大。在country2循环的第一次迭代中,country2同样指的是加拿大。这就是为什么第一行输出是加拿大 加拿大。你期望下一行输出是USA USA吗?但这并不是发生的事情!相反,country2循环继续到它的下一次迭代,但country1循环还没有移动。country1循环只有在country2循环完成后才会向前移动。这就是为什么我们在country1循环最终移动到第二次迭代之前得到了加拿大 USA和加拿大 日本。当一个循环在另一个循环内部时,这被称为嵌套循环。一般来说,当有嵌套时,内循环(for country2 in countries)将在外循环(for country1 in countries)移动到它的下一步之前完成所有步骤,然后外循环将重新启动内循环。
如果你看到嵌套在另一个循环内部的循环,那么很可能这些循环正在用于处理二维数据。二维数据组织成行和列,就像你在表格中看到的那样(例如,表 5.1)。这种数据在计算机中非常常见,因为它包括基本的工作表数据,如 CSV 文件,图像如照片或视频的单帧,或者计算机屏幕。
在 Python 中,我们可以使用一个列表来存储二维数据,其中值本身是其他列表。列表中的每个子列表是整体列表中的一行数据,每行都有一个列值。例如,假设我们有关于 2018 年冬季奥运会花样滑冰奖牌的一些数据,如表 5.1 所示。
我们可以将这些存储为一个列表,每行一个国家:
注意,我们的列表列表只是存储了数值,我们可以通过引用其行和列来找到列表列表中的值(例如,日本的金牌对应于索引为 2 的行和索引为 0 的列)。我们可以使用索引来获取完整的数据行:
****#1 这是第一行(第一行)。
如果我们只想从奖牌列表中获取特定的值(而不是整个行),我们必须索引两次:
假设我们想要逐个遍历每个值。为了做到这一点,我们可以使用嵌套for循环。为了帮助我们确切地跟踪我们的位置,我们将使用range for循环,这样我们就可以打印出当前的行和列数字,以及存储在该处的值。
外层循环将遍历行,因此我们需要使用range (len(medals))来控制它。内层循环将遍历列。有多少列?嗯,列的数量是行中值的数量,因此我们可以使用range(len(medals[0]))来控制这个循环。
每行输出将提供三个数字:行坐标、列坐标以及在该行和列的值(奖牌数量)。以下是代码和输出:
注意,在输出的前三行中,行保持不变,而列从 0 到 2 变化。这就是我们如何遍历第一行的方式。只有在行增加到 1 之后,我们才完成对这一新行上列 0 到 2 的工作。
嵌套循环为我们提供了一种系统地遍历二维列表中每个值的系统方法。在处理二维数据时,你经常会看到它们,例如图像、棋盘游戏和电子表格。
记住,Python 中的每个值都有一个特定的类型。由于我们可能想要使用许多不同类型的值,因此存在许多不同的类型!我们已经讨论了使用数字来处理数值,布尔值来处理True/False值,字符串来处理文本,以及列表来处理其他值(如数字或字符串)的序列。
在 Python 中,还有一个经常出现的类型,它被称为字典。当我们谈论 Python 中的字典时,我们并不是指单词及其定义的列表。在 Python 中,字典是一种在需要跟踪数据之间的关联时非常有用的存储数据的方式。例如,想象一下,如果你想知道你最喜欢的书中使用最频繁的单词。你可以使用字典将每个单词映射到其使用的次数。这样一个字典可能非常大,但这样一个字典的小版本可能看起来像这样:
字典中的每个条目将一个单词映射到其频率。例如,我们可以从这个字典中得知单词DNA出现了 11 次,而单词Taxxon出现了 13 次。这里的单词(DNA,acquire,Taxxon等)被称为键,而频率(11,11,13 等)被称为值。因此,字典将每个键映射到其值。我们不允许有重复的键,但正如这里所示的两个11值,有重复的值是没有问题的。
我们在第二章(列表 2.1)中看到了一个字典,它存储了每个四分卫的名字和他们相关的传球码数。在第三章中,我们又看到了一个字典,这是我们的第二个num_points解决方案(在列表 5.9 中较早重现)。在那里,字典将每个字母映射到使用该字母所获得的分数。
就像字符串和列表一样,字典也有你可以用来与之交互的方法。以下是一些在freq字典上操作的方法:
**#1 获取所有键
字典,就像列表一样,是可变的。这意味着我们可以更改字典中的键和值,这对于模拟随时间变化的数据很有用。我们可以使用索引来更改值。与'morph'关联的值目前是41。让我们将其更改为6:
我们的freq字典允许我们从任何我们想要的单词开始,并找到它的频率。更普遍地说,字典允许我们从键到值进行转换。然而,它不允许我们轻松地朝相反的方向转换,从值到键。如果我们想这样做,我们需要生成相反的字典——例如,一个键是频率,值是具有这些频率的单词列表的字典。这将使我们能够回答以下问题:哪些单词的频率正好是 5?哪些单词的频率是所有单词中的最小或最大?
就像字符串和列表一样,我们也可以使用循环来处理字典中的信息。for循环给我们字典的键,我们可以使用索引来获取每个键的关联值:
*#1 遍历 freq 字典中的每个键
我们经常需要处理存在于文件中的数据集。例如,在第二章中,我们使用 NFL 统计数据文件来确定最有效的四分卫。使用文件对于其他数据科学任务也很常见。例如,如果你正在绘制关于全球地震的信息或确定两本书是否由同一作者撰写,你需要处理这些数据集,通常这些数据集会存储在文件中。
处理文件中的数据的第一步是使用 Python 的 open 函数打开文件:
你有时会看到 Copilot 在这里添加一个 r 作为第二个参数:
但我们不需要 r;r 只意味着我们想要从文件中读取,但如果我们没有指定,这已经是默认的。
我们使用赋值语句将打开的文件分配给名为 nfl_file 的变量。现在,我们可以使用 nfl_file 来访问文件的内容。一个打开的文件是 Python 类型,就像数字和字符串以及到目前为止你看到的所有其他类型一样。因此,我们可以调用一些方法来与文件交互。其中一个方法是 readline,它以字符串的形式给出文件的下一行。我们现在将使用它来获取打开文件的第一行,但不用担心这一行本身,因为它非常长,包含大量我们最终不会使用的列信息:
从这样的混乱字符串中提取单个值并不容易。因此,我们首先倾向于做的是将这样的行拆分成其单个列数据。我们可以使用字符串 split 方法来做这件事。该方法接受一个分隔符作为参数,并使用该分隔符将字符串拆分成一个列表:
现在我们可以查看单个列名:
我们正在查看的文件的第一行不是真实的数据行——它只是告诉我们每个列名的标题。下次我们调用 readline 时,我们得到第一行真实的数据:
逐行移动这种方式适合探索文件中的内容,但最终我们可能想要处理整个文件。要做到这一点,我们可以在文件上使用一个 for 循环。它会在每次迭代时给我们一行,我们可以以任何我们喜欢的方式处理它。一旦我们完成了一个文件,我们应该调用它的 close 方法:
关闭后,我们不再允许使用该文件。现在我们已经讨论了如何读取、处理和关闭文件,让我们看看一个完整的示例。在列表 5.10 中,我们提供了一个第二章程序的版本,该程序按总传球码数对四分卫进行排序。除了展示文件外,我们还使用了第四章和本章中看到的大多数 Python 功能,包括条件语句、字符串、列表、循环和字典。
这个程序运行得很好;如果你运行它,你会看到与从第二章运行代码相同的输出。不过,有时候,使用模块(我们将在下一节更深入地介绍模块)可以更容易地编写程序,这就是第二章的程序所做的事情。由于 CSV 文件非常常见,Python 自带了一个模块来简化处理它们。在第二章中,我们给出的解决方案使用了 csv 模块。因此,让我们讨论一下列表 5.10 中的代码(不使用模块)和第二章中的代码(以下列表中重新打印)之间的主要区别(我们给 Copilot 的提示没有显示)。
首先,列表 5.11 使用 csv 模块使处理 CSV 文件变得更容易。csv 模块知道如何操作 CSV 文件,因此,例如,我们不必担心将行拆分成列。其次,列表 5.11 使用了with关键字,这意味着当程序完成对该文件的操作时,文件会自动关闭。第三,列表 5.11 在开始任何处理之前先读取整个文件。相比之下,在列表 5.10 中,我们读取并处理每行,一旦读取。
总是存在许多不同的程序可以用来解决同一个任务。有些可能比其他更容易阅读。代码最重要的标准是它能正确地完成工作。之后,我们最关心的是可读性和效率。所以,如果你发现自己难以理解某些代码的工作方式,花些时间查看 Copilot 的其他代码可能值得,以防那里有更简单或更易于理解的解决方案。
在计算任务中,文件被广泛使用,因为它们是常见的数据来源,需要被处理。这包括本节中的 CSV 文件,记录计算机或网站事件日志文件,以及存储你在视频游戏中可能看到的图形数据的文件等。由于文件被如此广泛地使用,因此并不奇怪有许多模块帮助我们读取各种文件格式。这引出了模块的更大主题。
人们使用 Python 制作各种东西——游戏、网站、用于数据分析、自动化重复任务、控制机器人等应用程序。你可能想知道 Python 怎么可能让你创建如此多种类的程序。当然,Python 的创造者不可能预见到或创建所有需要的支持!
事实上,默认情况下,你的 Python 程序只能访问一些核心 Python 功能(例如我们在上一章和本章中向您展示的那些)。要获取更多功能,我们需要使用模块。而且,要使用一个模块,你需要导入它。
模块 是为特定目的设计的代码集合。回想一下,我们不需要知道一个函数是如何工作的就可以使用它。模块也是一样:我们不需要知道模块是如何工作的就可以使用它们,就像我们不需要知道电灯开关内部是如何工作的就可以使用它一样。作为模块的用户,我们只需要知道模块能帮助我们做什么以及如何编写代码来正确调用其函数。当然,Copilot 可以帮助我们编写这种代码。
当你安装 Python 时,一些模块会随 Python 一起安装,但我们需要导入它们。其他模块我们首先需要安装,然后才能导入。相信我们,如果你想在 Python 中完成特定的任务,可能已经有某人编写了一个模块来帮助你。
你可能想知道如何确定应该使用哪些 Python 模块。你怎么知道哪些模块存在呢?与 Copilot 或 Google 搜索进行简单对话通常很有帮助。例如,如果我们搜索“Python 模块创建 zip 文件”,第一个结果告诉我们所需的模块是 Python 标准库的一部分,这意味着它随 Python 一起提供。如果我们搜索“Python 模块用于可视化”,我们会了解到名为 matplotlib、plotly、seaborn 等模块。搜索这些模块应该会引导你到展示它们功能和典型用途的可视化画廊。大多数模块都可以免费下载和使用,尽管你的搜索结果可以帮助你确认模块是否免费以及其具体的使用许可。我们将在第九章中推迟安装和使用新安装的模块,但到那时,你会看到这个过程:寻找、安装和使用相关模块来帮助我们完成任务。
表 5.2 列出了一些常用的 Python 模块以及它们是否为内置模块。如果一个模块是内置的,你可以直接导入该模块并开始使用它;如果不是,你需要先安装它。
在第二章中,我们的代码使用了 Python 自带的 csv 模块。让我们继续学习 Python 自带的其他模块。
当人们想要组织他们的文件,可能是在备份或上传之前,他们通常会首先将它们存档到一个.zip 文件中。然后他们可以传递这个单一的.zip 文件,而不是可能成百上千的单独文件。Python 自带了一个名为 zipfile 的模块,可以帮助你创建.zip 文件。
内容并不重要,只要你有一两个.csv 文件来测试即可。现在我们可以使用 zipfile 模块将它们全部添加到一个新的.zip 文件中!
| django | 否 | 一个辅助设计网站和 Web 应用的 Web 开发库 |
在本章中,我们向您介绍了我们前 10 个 Python 特性的后半部分,总结如表 5.3 所示。在前一章和本章中,我们讨论了很多关于阅读代码的内容。尽管我们没有涵盖你可能会看到 Copilot 生成的一切,但你处于一个很好的位置来检查 Copilot 代码,以确定它是否尽力按照你请求的方式生成代码。我们还展示了更多使用 Copilot 解释工具的示例,以帮助你理解新代码。在接下来的章节中,我们将看到如何测试 Copilot 生成的代码,以确定其是否正确,以及当它不正确时你可以做什么。
在这个练习中,我们将进一步练习使用 zipfile 模块创建包含多个 CSV 文件的.zip 文件。按照以下步骤完成任务并回答问题:
首先,在你的当前目录中创建三个 CSV 文件:
Copilot 建议的 zipfile 模块提供的其他一些功能有哪些?它们有什么用?
循环用于重复执行代码,直到满足所需次数。
当我们知道循环将执行多少次迭代时,我们使用for循环;当我们不知道循环将执行多少次迭代时,我们使用while循环。
Python 使用缩进来确定哪些代码行属于同一组。
字典是从键(例如,一本书中的单词)到值(例如,它们的频率)的映射。
在读取文件之前,我们需要先打开文件。
一旦文件打开,我们可以使用方法(例如,readline)或循环来读取其行。
一些模块,如 csv 和 zipfile,与 Python 一起提供,可以通过导入它们来使用。
其他模块,如 matplotlib,需要先安装,然后才能导入和使用。***************
在第三章中,我们首先开始看到测试 Copilot 生成的代码的重要性。对于任何编写软件的人来说,测试是一项基本技能,因为它能让你对代码的正常运行有信心。在本章中,我们将学习如何彻底测试我们的代码,以及如何通过修改我们的提示来帮助 Copilot 修复不工作的代码。
使用封闭盒与开放盒测试
通过修改提示来解决 Copilot 的错误
通过测试 Copilot 生成的代码的示例
理解测试 Copilot 代码的重要性
测试是一项你需要学会如何独立做好的基本技能,这样你才能检查代码是否正确运行。Copilot 可以生成测试,并且它在生成的测试质量上一直在改进,但我们鼓励你暂时不要使用 Copilot 来生成测试,因为你需要学会自己足够好地做这件事,以便能够验证 Copilot 生成的测试是否合理。这一点在接下来的几章中也将是真实的——问题分解、测试和调试都是在你请求 Copilot 的帮助之前必须学会的技能,因为你需要知道如何自己完成这些任务,才能知道 Copilot 是否在合理地做事。
回到第三章,我们提到你应该测试代码以确保它是正确的。不幸的是,根据我们的经验,初学者似乎对测试有抵触情绪!为什么?我们认为有几个因素在起作用。第一个是有一个被广泛记录的问题,被称为超级虫,即人类在刚开始学习编程时,认为计算机可以理解代码的意图并相应地做出反应[1]。因为他们写了代码,代码对他们来说是有意义的,所以他们很难想象代码可能不起作用。第二个问题是在第一个问题的基础上加剧的:如果你认为你的代码是正确的,测试只能给你带来坏消息。如果你不测试,你就无法发现代码是否错误。这就像那句古老的谚语,把头埋在沙子里。
软件工程师与新手程序员采取的方法完全不同。他们非常重视测试,因为代码中的错误可能会给他们的公司带来重大后果。没有人愿意成为导致公司损失大量收入、让黑客获取用户机密数据或导致自动驾驶汽车发生事故的代码编写者。考虑到错误的成本,假设代码是错误的直到证明其正确更有意义。只有经过广泛的测试后,我们才应该相信它正在正确运行。而且,公司不仅仅测试一次代码,他们还保留测试系统,以便每次有人更改代码时,不仅对更改的代码进行测试,而且对更改的代码可能影响的任何代码也进行测试(这被称为回归测试)。
公司对此非常重视,以至于他们经常在编写代码之前编写测试,这个过程被称为测试驱动开发(TDD)。这确保了每个人都同意代码应该或不应该做什么。我们认为你(作为读者)不需要采取这种方法来编写这本书中的程序,但我们在这里提到它,以传达测试的重要性。在编写代码之前考虑测试可以帮助你理解代码应该做什么,这将帮助你编写更好的提示。事实上,你可以在提示中直接包含测试案例!
最后,让我们记住关于 Copilot 我们所知道的事情:它会犯错误。我们不应该对我们从 Copilot 那里得到的任何代码的正确性做出任何假设。所有这些都意味着,在信任 Copilot 给出的任何代码之前,都应该对其进行测试。
软件工程师通常有两种测试代码的方法。第一种被称为封闭盒测试,这种方法假设你对代码的工作方式一无所知。因此,这种测试涉及改变输入并观察输出。我们经常看到封闭盒测试应用于函数或整个程序。封闭盒测试的优势在于,你不需要查看代码来执行测试,因此可以简单地专注于期望的行为。
测试的第二种方法被称为开箱测试,在这种方法中,我们查看代码以确定错误可能发生的位置。开箱测试的优势在于,通过查看代码的特定结构,我们可能看到代码可能失败的地方,并可以针对该代码设计特定的额外测试。我们将使用封闭盒子和开箱子测试来制定测试案例,以增强我们的测试。封闭盒子和开箱子测试的简要概述见表 6.1。在本节中,让我们看看我们可能如何使用这些方法来测试一些函数。
让我们想象我们正在尝试测试一个函数,该函数接受一个单词(字符串)列表并返回最长的单词。为了更精确,函数签名将是
预期输入是一个单词列表。预期输出是列表中最长的单词。如果多个单词字符数相同,则应返回该长度的第一个单词。
在编写函数测试时,标准格式是编写函数名及其输入以及期望的结果。例如,调用
意味着如果我们用输入列表['a', 'bb', 'ccc']调用函数longest_word,那么函数返回的值应该是'ccc'。
对于我们通常考虑编写测试用例的两个类别:
常见用例—这些情况包括一些标准输入,你可以想象函数会接收这些输入并得到相应的结果。
边缘情况—这些情况是不常见但可能破坏代码的情况。这些输入可能更深入地测试函数的一些规则或包含意外的输入(例如,所有字符串都为空的列表)。
**回顾一下前一个例子中的longest_word函数签名,让我们考虑一些可能用来测试它的测试用例。在本章的后面部分,我们将看到如何实际运行这些测试用例以确定我们的代码是否正确工作。让我们从常见用例开始。我们可能希望包括一个只有几个单词的测试,其中一个单词比其他单词长:
这里有一个包含更多单词的测试,其中最长的单词出现在列表的其他地方:
最后,让我们有一个只有一个单词的测试:
如果程序适用于这些常见用途,我们的下一步将是考虑一些边缘情况。让我们考虑一些边缘情况。
假设我们想要检查函数是否按照我们的描述返回多个相同长度单词中的第一个单词。这个测试可能被认为是常见情况或边缘情况,这取决于你问的是谁:
如果列表中的所有单词都没有字符怎么办?没有字符的字符串被称为空字符串,写作只是一个空的引号对。如果我们只有一串空字符串,那么最长的单词就是空字符串!因此,使用所有空字符串的测试应该只返回一个空字符串:
术语边缘情况来源于错误通常发生在执行的“边缘”,意味着第一个或最后一个元素。在许多循环中,错误可能发生在循环开始时(例如,忘记或错误处理列表中的第一个元素)或结束时(例如,忘记最后一个元素或超出列表的末尾并尝试访问一个不存在的元素)。特别是当代码可能需要处理许多元素的循环时,你将希望观察循环的开始和结束处的行为。
另一类测试将测试函数在接收到不正确输入时的响应。在我们的书中我们不会过多地讨论这一点,因为我们假设你正在正确地调用自己的函数,但在实际代码中,这种测试可能是常见的。调用此函数的不正确输入的几个例子可能包括使用值None而不是实际的列表来给函数一个不存在的列表(例如,longest_word(None)),给函数一个空列表(例如,longest_word([])),给函数一个整数列表作为输入(例如,longest_word([1,2])),或者提供一个包含空格或超过单个单词的字符串列表(例如,longest_word(['hi there', 'my', 'friend']))。很难说函数在接收到不正确输入时应该做什么,程序员需要决定他们是否关心在更大的代码库中这一点,但在这本书中我们将忽略这一类测试,因为我们假设你会以函数设计的方式来调用自己的函数。
在第三章中,我们讨论了良好的测试涉及捕获不同类别的函数调用。找到这些类别的一种方法是通过使用参数类型并改变它们的值。
例如,如果函数接受一个字符串或列表作为参数,那么测试该字符串或列表为空、有一个元素和有多个元素的情况是有意义的。如果我们试图测试多个元素,我们可能会使用四个元素,例如。使用五个或六个元素或更多进行测试可能也没有意义,因为如果我们的代码可以处理四个元素,那么当我们增加到五个时,突然出错的可能性不大。有时,某些测试用例可能对给定的函数没有意义;例如,在列表中没有单词的情况下要求最长的单词是没有意义的,因此我们不会为我们的longest_word函数测试空列表。
作为另一个例子,如果一个函数接受两个数字作为参数,那么测试一个数字为零、两个数字都为零、一个数字为负、两个数字都为负以及两个数字都为正的情况可能是有意义的。
另一种寻找类别的方法是思考函数的具体任务。例如,对于我们的longest_word函数,它的任务应该是找到最长的单词,因此我们应该测试它在典型情况下是否真的做到了这一点。而且,如果存在多个最长的单词,它应该返回这些单词中的第一个,因此我们应该有一个测试案例,其中列表包含多个最长的单词。
寻找要测试的类别是一种科学与艺术的结合。我们在这里提供了一些经验法则,但什么是有用的测试案例往往取决于正在测试的具体功能。正如经常发生的那样,练习你的测试技能是提高你编写有用测试的能力的最佳方式,这些测试最终有助于你改进代码。
开放盒测试和封闭盒测试之间的主要区别在于,开放盒测试会检查代码以查看是否有额外的测试案例类型需要检查。理论上,封闭盒测试可能足以完全测试函数,但开放盒测试往往能给你更多关于代码可能失败的地方的思路。假设我们要求 Copilot 编写我们的longest_word函数,并得到了以下列表中所示的代码。
在这个例子中,我们故意在代码中引入了一个错误,以帮助解释开放盒测试的作用。假设你在思考测试案例时,忘记测试当words列表中有两个单词都具有最多字符时会发生什么。通过阅读这段代码,你可能会注意到以下if语句:
在阅读if语句时,你可能会注意到它将在最近一个元素的长度大于或等于我们迄今为止看到的最长单词时更新单词列表中的最长单词。这是一个错误;它应该是>,而不是>=,但假设你不确定。这会促使你编写一个像我们之前描述的那样具有多个单词的测试案例:
这个测试将因为列表 6.1 中的代码而失败,因为它会返回'dog'而不是正确的答案'cat'。测试失败是宝贵的情报,表明列表 6.1 中的代码是错误的。
正如我们所说的,开箱测试是有用的,因为它会导致遵循代码自身结构的测试用例。例如,如果我们的代码使用了循环,我们将在进行开箱测试时找到那个循环。列表 6.1 中的循环是正确的,但通过看到我们的代码中的循环,我们会提醒自己测试边界情况,以确保它正确地处理了第一个元素、最后一个元素以及空列表。总的来说,了解代码如何处理输入通常可以提供关于程序何时可能出现故障的见解。
有许多好的方法可以测试你的代码,这些方法从你为了检查代码是否对你自己工作而进行的快速测试,到集成到公司回归测试套件中的测试。对于生产代码,Python 程序员通常使用比我们在本章将要展示的更强大、功能更全面的测试工具。其中最常见的是 pytest,这是一个在使用之前需要安装的模块。我们认为 pytest 超出了我们在这里介绍测试核心思想的需要。我们将专注于更轻量级的测试,以帮助你确信 Copilot 生成的代码能够正常工作。我们可以通过在 Python 提示符中进行测试或使用一个名为 doctest 的内置 Python 模块来实现这一点。
测试的第一种方式是在 Python 提示符中通过交互式窗口进行,就像我们在前面的章节中所做的那样。这种测试的优势在于它可以快速运行,并且你可以根据前一次测试的输出轻松地添加更多测试。我们迄今为止运行的测试是使用 Python 提示符进行测试的例子。例如,
在运行那个测试时,如果你期望结果是 'cat',你会很高兴看到那个结果。然而,如果测试显示你的代码有误,你现在有机会回去修复它。
修复代码后,你将想要测试新的代码。这就是你可能会在使用 Python 提示符进行测试时出错的地方。当你回来测试你刚刚更改的代码时,你可能会倾向于只运行之前失败的测试用例。然而,在修复代码以正确处理失败的测试用例时,你可能会引入一个错误,导致之前已经通过的测试用例现在失败。你真正想要的是一种方法,不仅可以运行当前的测试,还可以运行所有之前的测试。
很有可能会把所有的测试用例都放在你的 Python 程序中(在函数外部,相当于主函数中),这样它们都可以运行。这种解决方案解决了我们刚才描述的 Python 提示问题,但它引入了新的问题。当你想要你的 Python 程序执行它被设计的主要任务而不是仅仅运行测试时会发生什么?你可以删除所有测试,但这样做的目的是在需要时再次运行它们。你可以将它们注释掉,以便将来运行它们,但这也不是一个很干净的办法。我们想要的,然后,是在我们想要的时候运行我们函数的所有测试,但仍然能够运行程序。要做到这一点,可以使用一个名为 doctest 的模块。
doctest 模块是 Python 内置的。使用 doctest 的好处在于,我们只需将测试用例添加到描述函数的文档字符串中。这个增强的文档字符串具有双重作用。首先,我们可以使用 doctest 在需要时运行所有这些测试用例。其次,它有时可以帮助 Copilot 在最初生成更好的代码,或者修复已经编写但不太正常工作的代码。让我们编写一个包含所有测试用例并准备好用 doctest 执行的longest_word函数(参见列表 6.2)。
在这段代码中,我们看到文档字符串中提供了我们的测试用例作为 Copilot 的提示。Copilot 生成了正确的代码来实现这个函数。然后我们手动编写了代码的最后两行来执行测试。运行后,我们得到以下列表中的输出。
从这个输出中,我们可以看到每个测试都运行了,并且每个测试都通过了。这些测试之所以能运行,是因为我们在列表 6.2 中添加的最后两行:
在第一行,我们导入了 doctest 模块。这个模块帮助我们通过在运行程序时自动执行测试用例来测试我们的代码。在第二行,我们调用 doctest 模块中的testmod函数。这个函数调用告诉 doctest 执行所有测试;参数verbose=True告诉 doctest 为我们提供所有测试的结果,无论它们是否通过。如果我们切换到verbose=False,它只有在测试用例失败时才会给出输出(verbose=False实际上是默认值,所以你可以不带参数调用函数,它将默认不提供输出,除非一个或多个测试失败)。这可以是一个很好的特性,因为我们可以在 doctest 运行时保持其运行,并且只有在测试失败时才看到输出。
在这种情况下,我们的代码通过了所有测试用例。但让我们体验一下当我们的代码没有通过时会发生什么。
如果我们发现一个单词的长度与当前最长的单词相同,我们应该忽略它,因为我们总是希望在有多个单词长度相同时返回第一个最长的单词。这就是为什么在if语句中使用>(只有当新找到的最长单词确实比当前最长的单词长时才寻找新的最长单词)而不是>=是正确做法的原因。
我们可以通过将列表 6.2 中的>改为>=来中断代码,这将导致它选择最长单词的最后一个单词而不是第一个。让我们将以下行更改为
更改为
现在,测试不应该全部通过。此外,让我们将最后一行更改为
通过不对testmod函数提供任何参数,verbose现在被设置为False。当我们运行代码时,这是输出:
Doctest 方便地告诉我们哪个测试被执行了,预期的输出是什么,以及函数实际产生了什么。这将捕获错误并允许我们返回去修复错误。
我们经常听到以下问题:为什么 Copilot 在生成代码时不直接包含测试用例?例如,如果我们添加测试用例,如果 Copilot 能够尝试生成函数并且只提供通过那些测试用例的代码,那会很好。不幸的是,在这方面存在一些技术挑战,并且截至写作时,这个功能尚未包含。所以,如果你添加测试用例,它只是改善了 Copilot 的提示,但不能保证 Copilot 的代码建议通过那些测试。
到目前为止,我们已经看到了如何使用 Python 提示符和 doctest 运行我们的测试。现在我们知道如何测试我们的代码了,让我们考虑一下这如何修改我们的代码设计周期。
在第三章中,我们在图 3.3 中给出了如何设计函数的早期版本。在那个阶段,我们对检查我们的代码(我们在第四章和第五章中学习了)或如何测试我们的代码(我们现在知道的更多)了解得不多。因此,让我们创建这个周期的新的版本(图 6.1),以反映我们新的理解。
这个图比之前复杂一些,但如果我们仔细观察,我们可以看到保留了大部分原始过程。增加或改变的内容包括以下:
当我们编写提示时,我们可能将 doctests 作为初始提示的一部分来帮助 Copilot 生成代码。
经过第四章和第五章的学习,我们现在可以阅读代码来检查其行为是否正常,因此我们现在有一个额外的步骤来处理 Copilot 的初始代码看起来不正确的情况。如果发生这种情况,我们将使用 Ctrl-Enter 来探索 Copilot 的建议,希望能找到解决方案。如果我们能找到这样的解决方案,我们将选择它并继续前进。如果我们找不到,我们需要修改我们的提示来帮助 Copilot 生成改进的建议。
在找到看起来可能正确的代码后,我们将运行 doctest 来查看代码是否通过了我们在提示中包含的 doctests(如果没有包含任何,它将自动通过)。如果 doctest 通过,那么我们可以继续添加测试并检查它们,直到我们满意代码正常工作。如果 doctest 失败,我们需要找出如何修改提示来解决失败的测试。一旦修改了提示,它可能会帮助 Copilot 生成新的代码,可能能够通过我们提供的测试。
使用这个新的工作流程,我们不仅能够确定我们的代码是否正常工作,而且在代码尚未正常工作时也能修复代码。在接下来的章节中,我们将提供更多的工具来帮助我们在代码不正常工作时进行操作,但到目前为止,让我们通过解决一个新的问题并使用这里描述的工作流程测试 Copilot 的解决方案来将这些内容综合起来。
让我们尝试一个可能需要解决的问题,以帮助学生决定在面对面课程中坐在哪里。虽然我们谈论的是在教室中寻找空座位,但这个问题与尝试在音乐厅、剧院、电影院或办公室布局中找到空座位是相同的。
假设我们有一个教室布局,它是一个二维列表,包含座位行。每个座位要么包含一个空格(' '),要么包含一个学生('S'),分别表示空座位或占用座位。我们将要求 Copilot 创建一个函数来确定我们可以添加最多学生的行,在这个过程中,我们将设计检查代码是否正常工作的测试。
我们想找出我们可以在教室的单行中添加的最大新学生数。(等价地,我们正在寻找任何一行中的最大空座位数。)为此,让我们从一个有些含糊的提示开始,看看 Copilot 的表现如何。
为了这个例子,我们的提示不是很清楚。“一行中可以坐多少新学生?”对于一个人类来说是一个合理的起点,但它不是很具体,很容易想象 Copilot 在解释我们的意思时会感到困惑。Copilot 可能会弄不清楚我们是计算每行的学生数还是空座位数,或者它可能会返回教室中可用的总座位数而不是一行的座位数。结果证明,我们的提示让 Copilot 感到困惑,代码并不正确,但在我们深入代码之前,让我们考虑我们应该运行哪些测试。我们已经想出了一套测试案例:
一个教室中有一些连续的空座位和一些非连续的空座位,以确保它不仅仅是在计算连续的空座位数量
一个没有空座位的教室,以确保在这种情况下返回 0
一个行满空座的教室,以确保所有座位都被计算在内,包括第一和最后一个座位(边缘情况)
一个有多行且每行空座位数相同的教室,以确保它只返回这些值中的一个(而不是,也许,所有这些行中空座位的总和)
让我们先添加第一个测试案例,并添加 doctest 代码来运行测试,如下所示。
当我们运行这段代码时,我们得到以下输出(我们手动清理了教室列表的格式,以帮助提高答案的可读性):
虽然我们希望代码能正常工作,但我们欣赏第一个测试案例发现了错误。最多空座位的行是第三行,有四个座位可用。但 Copilot 的代码错误地告诉我们答案是六个。这很奇怪。即使不阅读代码,你也可能假设它是在计算每行的座位数或每行最多可坐的学生数。我们的测试案例中第二行有一排满的学生,所以很难判断。我们可以做的是将教室改为
因此,现在第二行有五个学生。当我们再次运行代码时,测试再次失败,代码给出的答案是五。看起来代码不仅仅是在告诉我们每行的座位数。它一定是在做与学生们坐的位置有关的事情。我们的下一步是改进提示,确定我们是否能从 Copilot 那里得到更好的代码,但为了完整性,让我们首先在以下列表中解释代码实际上做了什么。
你可以从代码描述中看到每行的操作,但从高层次来看,这段代码是在计算每行连续学生的数量。它是通过为每行初始化一个计数器为 0 来做到这一点的,只要它看到座位上有学生,就会增加这个计数器。一旦它看到空座位,它就会重置计数器。内循环末尾的 if 语句是跟踪之前看到的东西的最大值的一种相当标准的方式,在这种情况下,它是在跟踪之前看到的连续学生数量的最大值。这根本不是我们想要的,我们糟糕的提示部分要为此负责。然而,关键的部分是,我们的测试让我们知道代码是错误的。(如果你在阅读代码时发现了错误,那也很好!)
让我们重写提示,保留测试用例,看看我们是否能在以下列表中做得更好。
为了得到这个解决方案,我们必须使用 Ctrl-Enter 遍历可能的 Copilot 解决方案。其中一些解决方案现在计算了 ' ' 的连续出现次数,而其他一些,如列表 6.7 中的,通过了 doctest。奇怪的是,我们第一次尝试改进提示时,建议的解决方案是正确的。这再次提醒我们为什么 Copilot 输出的非确定性使得测试如此重要。
让我们花一分钟看看是什么让第二个提示比第一个提示更好。这两个提示都有
导致我们得到错误答案的提示部分是
得到正确答案的提示部分是
你永远无法真正了解像 Copilot 这样的大型语言模型(LLM)为什么会给出这样的答案,但让我们记住,它是被训练去仅根据它所接收到的单词以及训练数据中的单词(即 GitHub 上的大量代码)来预测下一个单词。
第一个提示要求 Copilot 进行一些推断,其中一些做得很好,而有些则不太理想。从某种意义上说,提示是在询问 Copilot 在列表的列表中知道什么是行。幸运的是,这在编程中非常常见,所以在这方面它没有问题。
然后,提示要求 Copilot 进行基本的逻辑步骤,推断出一个空座位是新生可以坐的地方。这正是 Copilot 遇到困难的地方。我们怀疑,因为我们询问的是新生坐在一行,它无法跳跃到意识到“新生”需要计算出你可以添加多少学生,换句话说,有多少空座位。相反,Copilot 专注于提示中的“学生在一行中”的部分,并开始计算每行的学生人数。它也可以使用函数名(诚然,可以更好;例如,max_empty_seats_per_row)来思考它需要计算最大学生数。这并不是我们想要的,但我们能理解 Copilot 为什么会犯这个错误。
现在,让我们来谈谈为什么,针对我们模糊的第一个提示,Copilot 决定计算给定行中的连续学生人数。也许在 Copilot 的训练数据中,连续学生人数是一个更常见的模式。也许是因为“坐在一行”可以被解释为“连续地坐”。或者也许是因为当我们编写这个示例时,我们一直在处理另一个要求连续空座位的版本的问题,Copilot 记得那次对话。我们不知道为什么 Copilot 给出了这个答案,但我们知道我们的提示太模糊了。
相比之下,我们的第二个提示在几个方面更为具体。首先,它明确要求最大值。其次,它要求一行中的空位或空座位的数量。这消除了 Copilot 推断空座位意味着新生座位的需求。我们还使用了“总数”和“给定行”来试图让 Copilot 摆脱其当前连续值计数的方法,但这并没有完全奏效。因此,我们最终不得不筛选 Copilot 的答案(使用 Ctrl-Enter),这些答案有时在寻找连续空座位,有时又找到了空座位的数量。
返回到我们的示例,因为新的代码通过了当前的测试,让我们给它更多的测试以确保它表现正确。在下一个测试中,我们将检查代码在没有任何行有空座位时是否正确返回 0:
下一个测试将确保代码正确地计算单行(这里指第二行)中的所有三个空座位,以避免出现边缘案例问题(例如,未能计算第一个或最后一个元素)。诚然,查看代码,我们可以看到正在使用count函数,并且因为这个函数是 Python 内置的,所以我们应该相当有信心这个测试会通过。然而,为了确保安全,测试它仍然是更安全的做法:
最后的测试是为了检查 Copilot 是否正确处理了两行具有相同空座位的案例:
在添加了这些测试案例之后,我们再次运行了完整的程序,如下所示列表,所有测试案例都通过了。
在这个例子中,我们看到了如何从头到尾编写一个函数来解决一个问题。Copilot 给出了错误的答案,部分原因是难以理解的提示。我们意识到它给出错误答案是因为代码在我们的第一次测试中失败了。然后我们改进了提示,并利用在前两章中学到的代码阅读技巧挑选出一个看起来符合我们需求的解决方案。新的代码通过了我们的初始基本测试,因此我们添加了更多的测试案例来查看代码是否在更多情况下都能工作。在看到它通过这些额外的测试后,我们有更多的证据表明代码是正确的。在这个时候,我们已经测试了常见的案例和边缘案例,所以我们非常自信我们的当前代码是正确的。关于测试,这个例子向我们展示了测试如何帮助我们找到错误,并且给我们更多的信心,相信代码将正常工作。
在大多数情况下,你将能够通过向文档字符串添加示例来测试你的代码,就像我们在前面的例子中所做的那样。然而,有时测试可能会更具挑战性。当你需要测试你的代码与某种外部输入进行交互时,这一点是正确的。一个例子是我们需要测试与外部网站交互的代码,但这在高级代码中比本书范围内你将创建的代码更常见。
本书范围内的一个例子是处理文件。当你的输入是一个文件时,你如何编写测试案例?Python 支持在文档字符串内部以这种方式进行,但为了与我们已经完成的内容保持一致,我们不会那样做。相反,我们将使用外部文件来测试我们的代码。让我们通过修改第二章中的 NFL 四分卫(QB)示例来看看如何做到这一点。
我们可以逐行通过一个示例,但由于我们对四分卫的查询仅针对文件的前九列,我们将去掉文件中剩余的列,以便使内容更易于阅读。在去掉剩余的列后,表 6.2 显示了文件的头四行。
在本例的剩余部分,我们将假设数据集的每一行只有这九个列,但我们希望想象如何处理整个数据集不会太困难(你只需在每个情况下添加所有额外的列即可)。
假设我们想要创建一个函数,该函数接受数据集的文件名和玩家名称作为输入,然后输出该玩家在数据集中取得的传球总码数。我们假设用户将提供的数据格式与第二章和表 6.2 中描述的 NFL 进攻统计数据文件格式相同。在我们编写提示或函数之前,我们应该如何测试这个?嗯,我们有一些选择:
在更大的数据集中查找测试用例—一个解决方案是将完整的数据集和多个玩家名称作为输入传递给函数。挑战在于确定我们是否正确。我们可以使用像 Google Sheets 或 Microsoft Excel 这样的软件打开文件,并使用电子表格功能来确定每个玩家的答案。例如,我们可以将文件作为 Excel 中的一个工作表打开,按玩家排序,找到玩家,并使用 Excel 中的求和函数来计算该玩家的所有传球码数。这并不是一个坏解决方案,但它也需要相当多的工作,而且如果你花足够的时间来找到测试答案,你可能已经满足了你的需求,不再需要 Python 代码!换句话说,确定测试用例的答案可能只是给你最初想要的答案,使得代码的价值降低。另一个问题是寻找你可能想要测试的所有边缘情况:你的数据集是否包含你想要测试的所有边缘情况,以便编写可以在以后的数据集上运行的程序?这种方法的另一个缺点是确定当函数执行比在多行中求和值复杂得多的操作时你应该做什么。在那里,确定一些真实测试值的答案可能是一项大量工作。
创建用于测试的人工数据集—另一个解决方案是创建已知多个可能查询答案的人工数据集。因为数据集是人工的,你可以添加边缘情况来查看代码在这些情况下的表现,而无需在真实数据集中找到这样的罕见示例。(有时真实数据集可能不包括这些边缘情况,但你仍然想要测试它们,以便代码在获取更新或新数据集时表现正确。)
考虑到在人工数据集中创建测试用例的优势,我们将继续采用这种方法。
让我们思考一下我们想要测试的常见情况和边缘情况。对于常见情况,我们想要有一些测试:
球员在数据集的不同行中多次出现(非连续的),包括最后一行。 这个测试确保代码在返回结果之前遍历了所有球员(即不会错误地假设数据是按球员姓名排序的)。
一个球员在数据集中的连续行中出现。 这个测试确保没有某种错误导致连续值被跳过。
一个球员在数据集中只出现一次。 这个测试确保即使只是累加一个值,求和操作也能正确执行。
一个非四分卫球员也可能出现在数据集中。 为了这一点,我们确保代码包括所有球员,而不仅仅是四分卫。
一个球员在一场比赛中总传球码数为 0。 这检查确保当球员没有传球码数时,代码的行为是正确的。这是一个常见的测试案例,因为球员可能会受伤并因伤错过比赛。
对于边缘情况,我们还想测试一些其他的事情:
球员不在数据集中。 这实际上很有趣:在这种情况下,我们希望代码做什么?一个合理的回答是返回他们传球码数为 0。如果我们询问数据集中 2019 年至 2022 年勒布朗·詹姆斯(篮球运动员,不是足球运动员)在 NFL 的传球码数,0 是正确的答案。然而,这可能不是生产代码中最优雅的解决方案。例如,如果我们询问阿隆·罗杰斯(拼写错误为 Aron Rodgers)的传球码数,我们更希望代码告诉我们他不在数据集中,而不是他传球了 0 码数,这可能会在他在这个时间段内两次获得联盟最有价值球员时真正让我们困惑。为了表示名称缺失,我们可能返回一个很大的负值(例如,-9999),或者我们可能使用称为异常的东西,但这些超出了本书的范围。
一个球员在所有比赛中总码数是负数,或者一个球员在单场比赛中码数是负数,以确保代码正确处理负值。 如果你不懂美式足球,这种情况可能发生,如果球员接球后被对方球员在起点(进攻线)后方擒抱。四分卫在整个比赛中传球码数是负数的情况不太可能发生,但如果他们传球一次导致码数减少(负码数)并且同时受伤,导致他们无法参加剩余的比赛,这种情况是有可能发生的。
注意这里的数据完全是虚构的。(这些不是任何球员的真实统计数据,正如你可以从丹和利奥现在神奇地成为 NFL 四分卫这一事实中看出。)我们仍然保留了某些真实球员的名字以及原始数据集中的真实game_id和player_id。通常,使你的虚构数据尽可能接近真实数据是一个好主意,这样测试才是真实的,并且更有可能代表真实数据可能发生的情况。
让我们看看我们是如何将所有测试用例纳入这个测试文件(表 6.3)的。Aaron Rodgers 在文件中多次出现,既有连续的也有非连续的,并且作为最后一条记录。Jared Goff 也多次出现,我们在一场比赛中给他虚构了-10 码(作为一个精英 NFL 四分卫,我希望他能够接受我们给他虚构的糟糕单场比赛)。我们保留了从真实数据集中来的 Kerryon Johnson 作为跑卫(RB),并给他 5 码传球码数以确保解决方案不会仅过滤四分卫。Kerryon Johnson 在数据中只有一条记录。我们还添加了 Leo Porter 到数据集中,并给他 0 码传球码数(他肯定愿意做任何事情来避免被 NFL 球员擒抱)。我们还添加了 Dan Zingaro,并给他一个完成的传球,但传球码数为-10 码,覆盖了单个球员总码数为负的情况。表 6.4 显示了当我们为每位球员运行查询时,每个球员应该返回的内容。
让我们从函数名开始,并在文档字符串中描述问题。这个问题可能足够复杂,以至于我们需要在函数内提供提示来帮助它知道如何打开文件、处理数据和关闭文件,但让我们先尝试描述函数,看看 Copilot 的表现如何。
在查看生成的代码之前,我们想指出,我们没有包括这个示例的测试用例,因为它们可能不会对 Copilot 有所帮助。如果你的测试用例只是打开并使用人工文件,我们建议你在添加测试用例之前让 Copilot 生成代码。每个测试用例将只是一个文件名、一个球员和预期的输出。Copilot 可能无法读取与该文件名对应的文件,并推断出我们正在对第八列求和以获得所需的结果。这意味着给 Copilot 测试用例并不会提高提示的质量。事实上,我们数据的人工性质甚至可能使其困惑(例如,在什么情况下一个真正的 NFL 球员会有负的传球码数总额?)。
现在来看 Copilot 给出的代码,我们可以看到它相当合理,几乎与我们第二章看到的代码相同。鉴于我们阅读它时看起来合理,让我们看看如何测试它。
为了测试这个函数,我们将扩展文档字符串以包含整个代码块的所有测试用例,如下所示。
我们运行了这段代码,所有的测试用例都通过了。(我们知道所有测试用例都通过了,因为 doctest 没有输出。)我们现在有额外的证据表明代码正在正常工作!
让我们重写之前的提示,并在第一个测试中添加一个非常微妙的错误,如下所示。
当我们运行这段代码时,我们收到了以下错误:
乍一看,这似乎非常奇怪。测试用例期望得到 800,但它告诉我们失败了。好吧,事实证明我们在编写测试用例时犯了一个错误,写了“800 ”(末尾有一个空格)而不是“800”。这个错误导致 Python 认为空格很重要,并导致测试失败。坏消息是,这真的是一个在使用 doctest 时非常常见的问题!我们犯了这个错误比我们愿意承认的要多。好消息是,通过仅仅找到并删除空格就可以很容易地修复它。如果一个测试失败了,但 doctest 的输出表明它应该通过,那么请检查行尾是否有空格或多余的空格,或者与 doctest 期望的输出相比,你的输出中是否有任何缺失的空格。
由于所有测试用例都通过了,我们可以有信心回到更大的数据集并使用我们刚刚创建的函数。从这个例子中,关键的一点是我们可以,也应该创建人工文件来测试与文件一起工作的函数。再次强调,测试完全是关于获得代码正常工作的信心,你想要确保测试你编写的或由 Copilot 提供的任何代码。
在本章中,我们整体学习了代码测试的重要性、如何测试代码,以及如何在两个详细示例中实施。在我们的示例中,我们编写并测试了函数。但我们是如何决定编写哪些函数来解决更大的问题的呢?嗯,我们通过一个称为问题分解的过程来解决这个问题,我们将在下一章中详细讨论。
给定以下场景,确定是封闭盒测试还是开放盒测试更合适,并解释原因:
测试员被给出一个函数规范,需要确保该函数在没有查看实现的情况下表现正确。
测试员需要通过编写针对由理解代码实现揭示的边缘情况的测试来调试函数。
这是一个带有一些测试用例的函数。确定以下哪些是常见用例,哪些是边缘情况:
这是我们在 AI 代码生成工具中给出的初始提示:
def busiest_day(traffic_data): # traffic_data 是一个字典列表。# 找到最繁忙的一天。
工具生成了以下代码,但它似乎并不完全正确。
你能解释为什么以及如何改进提示以获得所需的功能吗?
假设你有一个函数(find_highest_grossing_book(filename)),该函数分析图书销售数据并返回关于总收入最高的图书的信息。销售数据存储在一个 CSV 文件中,其中每行代表一次销售。CSV 文件中的列如下:
title—售出图书的标题(字符串)
author—图书的作者(字符串)
price—图书的价格(浮点数)
quantity—特定销售中售出的副本数量(整数)
创建一个包含find_highest_grossing_book函数(实现未在此提供)的 Python 脚本。包括一个文档字符串来解释函数的目的,并使用 doctest 模块添加测试用例。
在使用 Copilot 编写软件时,测试是一项关键技能。
封闭盒测试和开放盒测试是确保代码正确性的不同方法。在封闭盒测试中,我们根据对问题的了解来制定测试用例;在开放盒测试中,我们还会检查代码本身。
Doctest 是 Python 内置的一个模块,它帮助我们测试代码。
要使用 doctest,我们需要将测试用例添加到函数的文档字符串描述中。
创建人工文件是测试使用文件功能的代码的有效方法。**
理解问题分解及其必要性
使用自顶向下的设计进行问题分解和编写程序
使用自顶向下的设计编写拼写建议程序
在第三章中,我们讨论了为什么我们不应该要求 Copilot 解决大问题。想象一下,如果我们要求 Copilot“设计一个两人策略游戏”,会发生什么。
在最佳情况下,我们会得到一个预制的程序,其中所有决策都为我们做好了。这个程序可能不符合我们的需求。作为程序员的力量之一就是定制我们正在创建的内容。即使我们不想定制任何内容,如果 Copilot 的程序有缺陷,我们该怎么办?如果我们不理解一个大型程序,那么修复它将非常困难。
为了得到一个能够实现我们想要的功能的程序,我们需要向 Copilot 提供小的子问题,并将这些解决方案组装成我们自己的程序。本章的重点是学习如何将大问题分解成更小的子问题,这对于解决我们想要解决的大问题至关重要。
问题分解涉及从一个可能没有完全定义的大问题开始,将其分解成更小的子问题,每个子问题都定义明确且对我们解决整体问题有用。我们的目标是编写一个函数来解决这些子问题。我们可能能够用一定量的代码解决一些子问题,但其他子问题可能仍然太大,无法用合理大小的函数来捕捉。 (在第三章中,我们提到我们希望保持函数简短——大约 12-20 行——以增加从 Copilot 获得良好代码、测试该代码以及在必要时修复代码中的错误的机会。) 如果一个子问题仍然太大,无法在一个函数中实现,那么我们将进一步将这个子问题分解成更小的子子问题。希望现在每个子子问题都足够小,但如果不是的话,我们也会继续分解它们!我们这样做的主要原因是管理复杂性。每个函数都应该足够简单,以至于我们可以理解其目的,并且 Copilot 可以很好地解决它。如果出了问题,我们不会有很多代码需要检查以找到问题。当我们编写极其复杂的代码时,我们很可能会犯错误。Copilot 也是如此!而且,当我们有长而复杂的代码中的错误时,它们并不容易找到和修复。问题分解对于编写高质量代码的重要性不容小觑。
从一个大的问题开始,并将其分解的过程被称为问题分解。我们在这里所做的方式与被称为自上而下设计的软件工程技术同义。它被称为自上而下设计,因为我们是从我们想要完成的大任务开始的,并将其分解成更小的任务。一旦我们完成了自上而下的设计,我们就可以在代码中实现产生的函数。我们将有一个用于整体问题的函数,它将调用每个子问题的函数。每个子问题函数将根据需要进一步调用它们自己的函数,以解决任何子子问题,依此类推。
正如我们在第三章中讨论的那样,我们希望最终得到每个函数都在我们的整体程序中扮演一个小角色,并且其行为是明确定义的。我们需要这些函数,以便我们可以调用它们来降低其他函数的复杂性。为了提高清晰度和易于理解,我们寻求设计具有少量参数并返回少量高度有用信息的函数。
我们很快将跳入一个更真实的例子,展示自上而下设计是如何工作的,但首先我们想要使用我们之前的一些例子来设定场景。让我们思考一下我们在第三章中编写的函数的设计:get_strong_password。它反复提示用户输入密码,直到他们输入一个强密码。不要回去看那段代码——我们想要从这里开始。
假设我们想要使用自上而下设计来解决这个问题。如果它是一个小而明确的任务,我们可以直接将其实现为一个单独的函数。然而,对于这个任务,我们确实看到了一个子任务;也就是说,什么是强密码?围绕这个问题的规则是什么?对我们来说,这听起来像是一个我们可以尝试从这个函数中分离出来的子任务,以使其更简单。确实,在第三章中,当我们编写这个函数时,我们确实调用了我们之前的is_strong_password函数,它对密码是否强大做出了真/假的判断。
我们可以将这种自上而下的设计描绘如图 7.1 所示。为了便于在章节后面展示最终可能变得很大的图,我们将一致地从左到右展示设计,而不是从上到下,但同样的基本原理仍然适用。
此图表明,我们的目标是让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任务变得更简单。
在第三章中,我们恰好是从子任务到任务,从叶函数到父函数来编写这些函数的。我们将在本章继续这样做,但我们将首先进行自顶向下的设计,以确定我们需要哪些函数。
我们刚才在第三章中讨论的两个例子都很小,你确实可能能够通过一个函数快速编写它们的代码。但是,对于大型例子,问题分解是唯一控制复杂性的方法。
接下来,我们将深入探讨一个更大的自顶向下设计的例子。我们希望你在本章中掌握的关键技能是如何将一个大问题分解成更小的子问题。我们鼓励你多次阅读即将到来的例子。在你第一次阅读时,目标是了解各个部分如何组合起来解决整体问题。在你第二次阅读时,你可以自由地深入了解每个函数是如何独立工作的。
在本章的剩余部分,我们将使用自顶向下的设计方法从头到尾解决一个问题。我们希望你在自己解决问题时使用这种方法能够成功,所以你将在本书的其余部分看到自顶向下设计方法的重复出现。
哎呀——我们那里有误拼吗?Sucessful?那应该是successful。有时候英语单词的拼写可能很棘手!你可能遇到过很多这样的单词。是 thorough 还是 thourough?Acceptable 还是 acceptible?Receive 还是 recieve?我们将编写一个程序,该程序接受一个可能拼写错误的单词,并为此拼写错误提供可能的更正。这是一个针对单个单词的基本拼写检查器!
我们该如何想出这些可能的更正呢?好吧,让我们看看我们刚刚提供的单词对,并讨论它们所体现的错误类型。
我们在单词sucessful中犯的错误是漏掉了一个字母——我们需要在那里添加一个c来得到正确的单词successful。所以,总的来说,考虑向拼写错误的单词中添加一个字母似乎是个好主意,因为这可能是修复它的确切方法。我们将考虑在可能的任何位置添加任何可能的字母,而不仅仅是添加已经存在的字母的副本。这将帮助我们修复像acknowlege(缺少一个d)这样的拼写错误。
我们还需要考虑其他类型的错误。例如,在单词thourough中的错误并不是我们漏掉了一个字母,而是我们多了一个字母——我们需要删除第一个u来得到正确的单词thorough。因此,我们将考虑从任何拼写错误的单词中删除任何单个字母,看看是否可以修复它。
我们还能修复哪些错误?好吧,那个误拼的单词acceptible是一个新的错误类型:没有缺少或多余的字母,但有一个字母应该被改为另一个字母。也就是说,如果我们把i改为a,我们就能得到正确拼写的单词acceptable。为此,我们可以尝试将每个字母都改为字母表中的每个字母,看看是否可以修复误拼。
除了我们刚刚给出的三种方法之外,还有许多方法可以修复拼写错误。但我们将在这里停止,因为我们认为修复三种类型的错误对于我们的目的——展示自顶向下的设计——是足够的。这意味着我们的程序将无法纠正拼写错误的单词recieve到receive,因为我们不会修复两个字母顺序错误的错误。我们也将无法纠正拼写错误的单词camoflague到camouflage,因为我们只会修复拼写错误中的一个错误(camoflague有两个不同的错误,一个是缺少u,另一个是添加了u)。一旦你完成这一章,我们鼓励你在愿意更进一步的情况下继续改进你的程序,并学习更多关于纠正拼写错误的知识。
我们的任务是“编写一个程序,该程序接受一个可能拼写错误的单词,并为此拼写错误提供可能的更正。”这是一个很大的任务,我们绝对不希望将其强行塞入一个单独的函数中。
许多程序——无论是分析数据、提供拼写建议还是猜测未知文本的作者——都有三个不同的任务要执行。首先,是输入任务:我们需要获取程序运行所需的数据。其次,是处理任务,程序用这些数据做它应该做的事情。然而,数据处理得再好,如果用户不知道程序通过处理发现了什么,那么它也是无用的。这就是第三步,输出步骤,我们在这里向用户传达信息。表 7.1 总结了这一过程。
你可以在第二章的数据处理示例中看到这个输入-处理-输出模型是如何工作的。我们需要从 CSV 文件中读取数据(这是输入步骤),确定每个四分卫的传球码数(这是处理步骤),然后输出四分卫及其传球码数(这是输出步骤)。
我们可以用类似的方式思考我们的拼写建议问题。这里令人惊讶的是,输入-处理-输出模型为我们提供了我们将在自顶向下设计中想要解决的三个子问题。我们的意思如下(也请参见表 7.1 中的示例):
对于输入步骤,我们需要询问用户他们想要获取拼写建议的单词。
对于处理步骤,我们需要找出用户单词的所有可能建议。
对于输出步骤,我们需要告诉用户我们在处理步骤中找到的所有拼写建议。
注意,我们最初有一个大问题要解决(即整体的拼写建议问题),而现在我们有三个更小的问题要解决。我们主要或顶级函数最终将调用由此问题分解产生的任何函数。我们将把这个主要函数命名为spell_check。
我们通常需要为每个识别出的子问题创建一个单独的函数,但这并不是绝对的。再次看看输入步骤。我们需要要求用户输入一个单词。虽然我们可以为这个子任务分离出一个单独的函数,但这将是过度设计。为什么?这是因为 Python 已经有一个内置的函数可以用来获取用户的输入!这个函数叫做input,我们在第三章第 3.3.7 节中看到它在工作,当时我们要求用户输入密码。
我们是否需要为输出步骤分离出一个单独的函数?不,再次不是!输出步骤只是将内容输出给用户。我们知道我们可以使用 Python 已经存在的print函数来做这件事。再次强调,为这个步骤分离出一个函数并不是一个错误,如果你自己进行这个问题分解,你可能已经这样做了。不过,你会注意到,这个函数会非常短,可能只是调用了一次print——在这个时候,你可能又会考虑是否真的需要将其作为一个单独的函数。
与之相比,这个过程步骤将涉及更多的工作。在确定所有可能的拼写建议时,需要做很多事情!我们必须支持删除字母、插入字母、将一个字母更改为另一个字母等等,这远远超出了在我们的主spell_check函数中保留所有这些操作的范围。我们需要一个单独的函数来处理这个过程步骤。这正是我们接下来需要努力的地方。
我们需要一个名字来命名实现过程步骤的函数。我们将称之为get_spelling_suggestions,因为它将负责返回用户输入的拼写建议。它当然需要将用户拼错的单词作为参数,否则它将无法访问它!
但是,在这里暂停一下:你认为这个函数需要任何额外的参数吗?答案:是的!这个函数必须以某种方式知道哪些字符串是英语中的真实单词。例如,它必须知道关于单词successful、thorough、acceptable以及成千上万的其它英语单词。我们可以通过几种方式来做这件事:我们可以传递一个包含真实单词列表或(Python)字典的参数,或者我们可以传递一个包含所有真实单词的文件的名称作为参数。当你设计你的函数时,你需要做出类似的决策,专注于函数完成其工作所需的输入以及完成时我们需要的返回值。
我们需要对这个过程步骤做什么?我们可以将其视为四个子任务。这是更多的问题分解!这些子任务如下:
从单词列表文件中获取单词列表。 单词文件是一个好的开始,但将单词放在 Python 列表中更方便。这样,我们可以轻松地确定一个字符串是否是有效单词。我们将把这个函数命名为 create_word_list。
生成用户字符串中所有可能的单词列表。 我们需要删除一个字母,插入一个字母,或者将一个字母改为另一个字母。这将生成许多字符串,其中一些是真实单词,而另一些则不是。例如,从字符串 sucessful 中,它会生成真实单词 successful,但也会生成字符串 xsuccesful、sucxcesful 和 succesfrl,这些显然不是真实单词。但没关系。目前,我们只想生成所有可能的单词,以免遗漏任何。我们将把这个函数命名为 all_possible_words。
使用所有可能的单词列表,生成仅包含真实单词的列表。 这是修剪我们潜在单词完整列表到实际存在于英语中的单词的步骤。我们将把这个函数命名为 all_real_words。
返回唯一单词的列表。 为什么是唯一的?我们不希望返回相同的拼写建议两次,即使可能有两种或更多种从用户字符串到达那个拼写建议的方法。例如,为了修复单词 sucessful,有两种方法:我们可以在已有的 c 前面添加缺失的 c,或者我们可以在 e 前面添加 c。两者都导致正确拼写的单词 successful,但我们只想保留那个单词一次。
如果我们将那个最终子任务——获取唯一单词列表——分解成它自己的函数,我们可以将其命名为 unique_words 或 only_unique_words。虽然我们可以将其分解出来,你这样做也是合理的,但我们决定将其保留为 get_spelling_suggestions 函数的一部分。原因是,在 Python 中,从列表中删除重复项只需要一行代码。为了方便,我们现在告诉你这个,但再次强调,如果你自己进行自上而下的设计,这也会是一个非常好的子任务。
没有一种单一的正确方法来进行自上而下的设计,也没有一种单一的正确方法将问题分解为子问题。例如,你可能对我们的决定感到惊讶,即首先生成所有可能的单词(包括实际上不是英语单词的假单词),然后将其修剪成实际存在的英语单词列表。为什么不先检查每个可能的单词,只有当它是真实单词时才将其添加到我们的列表中呢?我们当然也可以那样做!如果你已经有了这种替代分解的想法,或者我们激起了你的好奇心,我们鼓励你在完成本章后自己尝试这种替代设计。
在尝试自顶向下的设计并且如果设计不成功就放弃它,这并不是什么丢人的事情。设计不成功可能意味着什么?好吧,也许你发现很难将大函数分解成不同的子问题。或者也许你得到了几十个微小的函数,开始担心你的函数太具体,没有解决一般问题。或者也许你需要传递许多参数,其中一些可能只是为了传递得更远,直到最终一个函数需要它们。或者也许你只是想尝试一个不同的自顶向下的设计,看看结果如何!
我们鼓励你在确定代码的最终设计之前,在设计阶段进行实验。在这个实验中,你会尝试不同的可能函数以及函数的不同输入/输出。例如,我们之前讨论过get_spelling_suggestions函数是否应该接受包含真实单词的文件名或包含真实单词的列表(或字典)。在设计阶段,这两个选项都值得考虑。
这就剩下三个子任务我们需要解决。我们很快将对这些子任务进行自顶向下的设计。但首先,让我们看看我们现在处于什么位置(见图 7.3)。
这个任务的函数是create_word_list。它将接受一个参数,即单词列表文件的名称,并将返回该文件中的单词列表。
我们是否需要对这个函数进行进一步的自顶向下的设计?让我们假设我们认为答案是肯定的。我们可以分解出哪些子任务?我们可以想象打开文件、读取其内容以及关闭文件的任务。但是,正如我们在第五章第 5.1.4 节中学到的,打开文件只需调用 Python 的open函数。同样,关闭文件只需调用 Python 的close函数。那么从文件中读取单词呢?这听起来并不比第五章第 5.1.4 节中读取 CSV 文件的行复杂多少。所以我们觉得有理由保留这个函数不变,不进行任何进一步的子任务分解。
这个任务的函数是all_possible_words,它将接受一个参数,即我们想要提供拼写建议的字符串。它将返回通过添加一个字母、删除一个字母或更改一个字母可以获得的所有可能单词的列表。
添加一个字母、删除一个字母和更改一个字母是三种不同的任务类型。而且,它们在我们看来并不特别简单:它们将涉及到对用户字符串中的字母进行某种形式的循环。啊!看起来我们在这个任务上需要进一步的自顶向下的设计。特别是,我们将从这个函数中分解出三个子任务:
add_letter—此函数将接受一个字符串参数,并返回所有可以通过在单词的任何位置添加一个字母而获得的所有字符串。
delete_letter—此函数将接受一个字符串参数,并返回所有可以通过删除一个字母而获得的所有字符串。
change_letter—此函数将接受一个字符串参数,并返回所有可以通过更改一个字母而获得的所有字符串。
表 7.2 提供了我们期望每个函数对两个不同输入字符串将返回的内容。对于add_letter和change_letter,返回了大量的字符串,因为它们将考虑在输入字符串的每个位置添加或更改英语中的每个字母。
与往常一样,思考是否需要从这三个函数中进一步分解出子任务是很重要的。然而,鉴于我们期望看到的是仅通过字母的循环,我们在这里可以推进,如果我们的假设被证明是错误的,我们再回头审视我们的自顶向下的设计。现在,我们可以告诉你,我们不需要进一步分解也能做得很好。
通常情况下,知道何时停止将任务分解成更小的子任务,更多的是一种艺术而非科学。这需要通过设计程序来获得关于何时停止的直觉。许多经验丰富的开发者通常会为每个子任务暂停一下,考虑编程的复杂程度,并在心中勾勒出函数的步骤。如果解决方案不是直截了当的,他们通常会选择将其分解成更多步骤。但我们不期望你能够做到这一点。一些公司发布指南,通过建议允许的行数上限(例如,不超过 12 行)来鼓励简单的函数,但许多人认为限制应该更多地在于复杂性,尽管长度和复杂性当然是有关系的。
当你刚开始时,一个合理的代理方法是限制每个函数的行数在 12-20 行左右。如果你后来发现一个函数最终只有一行,它仍然是一个子任务,但可能不值得拥有自己的函数。(有时,如果它被不同的函数多次调用或有助于简化调用函数,保留一个短函数也是可以的。)
在最初练习时犯错是可以接受的;我们确实犯过。有时候一个子任务看起来会形成一个简单的函数,但最终却比预期的要困难得多。在这种情况下,您知道只需进一步细分它。同样,我们也有一些只有一行代码的函数,如果它们被频繁使用或使调用函数更容易理解,我们就保留那行代码作为一个函数。
这个任务的函数是all_real_words。它将接受两个参数:真实单词列表和所有可能的单词列表。它将返回一个仅包含完整可能单词列表中真实单词的列表。这个函数的代码将涉及遍历可能单词列表,并检查每个单词是否出现在真实单词列表中。由于这个任务是一个小而定义良好的任务,不会产生很多代码,所以我们很放心地保留这个函数,不做任何进一步的拆分。
我们已经完成了自顶向下的设计过程。您可以在图 7.4 中看到我们的最终自顶向下设计。
在这里,我们不要因为树木而忽略了森林。如果我们退一步看,我们所做的是将原始的大问题分解成几个更小的问题,我们将每个问题都作为函数来实现。我们的原始spell_check问题可能感觉令人望而生畏。不过,没关系,因为我们将其分解为get_spelling_suggestions的一个主要子任务。get_spelling_suggestions函数本身也是一个大问题,但我们能够通过相同的过程来解决它:我们将其分解为三个子任务。其中两个子任务create_word_list和all_real_words似乎可以作为一个函数解决,但另一个任务all_possible_words足够复杂,我们觉得它需要三个额外的子任务(add_letter、delete_letter和change_letter)。我们希望您从中学到的重要一点是,我们在每个步骤都使用了相同的问题分解技术,将最初令人畏惧的任务变成了一组可解决的子任务,这些子任务将成为函数。
我们下一步是从设计转向实施。我们知道现在需要哪些函数来解决我们的问题。是时候编写代码了!
当你进行自顶向下的设计时,就像我们刚才做的那样,你从整体问题开始,将其分解为子问题。这就是为什么我们从spell_check(我们的整体问题)开始,最终达到了像add_letter这样的函数,这些函数不需要进一步分解。但是当我们要求 Copilot 实现这些函数时,我们不会按照相同的顺序工作。相反,我们将以相反的顺序工作,首先做最小的子问题,然后转向依赖于这些子问题的函数。这对应于在如图 7.4 所示的图中从右到左移动。
我们以这种方式以相反的顺序实现函数,这样 Copilot 在我们想要实现较大函数的时候就会了解较小的函数。这样,Copilot 更有可能按照我们的期望调用我们的子任务函数。
我们希望在这里完全专注于问题分解,为此我们已经做出了一些决定。首先,虽然我们将在文档字符串中包含一些测试,但在这个例子中,我们不会像在第六章中那样追求完整的测试。我们鼓励您使用 doctest 运行我们提供的测试,并添加您自己的测试以增加对代码的信心。其次,我们没有过多关注我们的提示工程,而是专注于产生良好结果的提示。在下一章中,我们将专注于调试,那时我们将回到提示工程。第三,我们不是专注于全面阅读和理解代码。话虽如此,我们包含了一些注释来解释代码正在做什么以及它是如何工作的。
我们将从我们的create_word_list函数开始。正如第三章中所述,我们编写函数头(def行)和文档字符串,Copilot 填写代码。这就是我们将让 Copilot 编写本章中所有函数代码的方式。
我们已经知道我们的create_word_list函数应该做什么:从单词列表文件中读取单词,并以 Python 单词列表的形式返回它们。我们仔细地在文档字符串中写下我们想要的内容,如列表 7.1 所示。
我们使用了一个描述性的参数名称,filename,这为参数的目的提供了一个很好的提示。我们还小心地在这个文档字符串中使用这个参数名称。我们的文档字符串还明确指出,我们希望返回列表(而不是,比如说,打印它)。
我们没有在函数的文档字符串中包含测试,这是因为我们不希望分散实现我们每个函数的整体目标。尽管如此,如果你想要测试,你知道如何做!具体来说,我们在第六章中这样做,在那里我们创建了一个小文件用于测试目的。在这里,你可以创建一个包含每行一个单词但只有几个英语单词的小文件,然后测试从文件中读取的单词列表是否与放入文件中的单词匹配。我们的 create_word_list 函数已经完成,所以我们可以像图 7.5 中那样将其标记为完成。
为了节省空间,我们在实现每个函数后不会展示更新后的图形,但我们会偶尔展示。
现在我们将处理 all_possible_words 所需要的函数,从 add_letter 函数开始。add_letter 函数接受一个字符串作为参数,并返回通过向该字符串添加一个字母可以形成的潜在单词列表。让我们简要思考一下这个函数的预期输出,通过一个快速示例。如果我们给这个函数字符串 cu(如表 7.2 所示),它将生成一个列表,其中包含每个字母添加到每个可能位置的字词。这意味着它应该包括在 cu 前添加 a 以形成 acu,在 cu 前添加 b 以形成 bcu,以及对于 cu 前面的字符,直到 zcu。该函数还应包括 c 和 u 之间的每个字母,以形成 cau、cbu、ccu…,直到 czu。最后(因为有三个可能的位置可以添加字母),我们期望该函数在 cu 之后添加每个可能的字母以形成 cua、cub、cuc…,直到 cuz。我们的 Copilot 提示和 Copilot 生成的代码显示在列表 7.2 中。
这里的代码相当微妙,需要仔细测试。例如,注意外循环使用 len(word) + 1,而不是更标准的 len(word)。如果没有 + 1,我们将在单词字符串的每个现有索引处添加字符。但实际上,我们会错过我们还想能够在现有字母之后添加字母的事实!+ 1 添加了一个额外的迭代,在这个迭代中我们在字符串的末尾添加了一个字符。
对于外循环的每个索引,我们在内循环中考虑每个可能的 lowercase 字母。代码行 new_word = word[:i] + c + word[i:] 使用了字符串切片技术,即通过两个索引从字符串中提取字母,以将当前内循环字符添加到当前外循环位置。
虽然我们在本章中花费在测试上的时间不多,因为重点是问题分解,但你仍然想通过给这个函数一个单个的拼写错误单词(即 cu)来测试它,然后打印返回的单词并确保它包括诸如 acu(在开头添加),cau(在中间添加),和 cua(在末尾添加)这样的字符串,以及可能的真实单词如 cup 和 cut。
注意,我们不想在文档字符串中包含确切的测试,因为这个函数返回的列表非常大!例如,尝试在 Python 提示符中输入以下内容:
你会得到一个包含 78 个字符串的列表!这是针对我们微小的 'cu' 字符串。随着参数字符串中字符数量的增加,列表中返回的字符串数量会显著增加。
与 add_letter 类似,delete_letter 函数接受一个字符串作为参数并返回一个潜在单词的列表。不同之处在于,delete_letter 不是添加一个字母,而是从字符串中删除每个可能的字母以到达新的可能单词。让我们思考我们期望这个函数做什么。如果我们给它字符串 “carf”,它应该尝试删除每个字母以产生字符串 “arf”(通过删除 “c”),“crf”(通过删除 “a”),“caf”(通过删除 “r”),和 “car”(通过删除 “f”)。同样,我们为这个函数提供了提示,如列表 7.3 所示。
这段代码与我们的 add_letter 函数代码类似。一个不同之处在于,这次我们只有一个循环,而不是两个。原因是,我们不需要内循环来遍历字母 a、b、c、d 等等。那是因为我们是在删除单词中已经存在的字母,而不是确定要添加哪个字母。
现在,是我们 all_possible_words 的第三个也是最后一个子任务 change_letter 的时候了!这个函数负责生成将字符串中的一个字母更改为其他字母的单词。在思考我们希望在这里看到什么时,让我们再次考虑字符串 cu(如表 7.2 所示)。我们希望这个函数将字母 c 替换为所有可能的字母以创建字符串 au、bu、du… . ,以及将字母 u 替换为所有可能的字母以创建字符串 ca、cb、cc… . ,cz。 (注意,我们省略了尝试 cu,因为那是初始字符串,我们只考虑字母的变化。)请参阅列表 7.4 中的提示和 Copilot 的代码。
这段代码与我们add_letter的代码非常相似!主要区别在于我们使用字符串切片不是添加新字符,而是更改现有字符。
到目前为止,我们已经完成了实现all_possible_words所需的三个函数。查看图 7.6,我们标记了我们所取得的重大进展!接下来,我们将处理all_possible_words。
我们现在能够实现all_possible_words的原因是我们已经实现了all_possible_words完成其工作所需的三个子任务函数。当我们要求 Copilot 编写这段代码时,我们期望看到对add_letter、delete_letter和change_letter的调用。查看列表 7.5,你会发现 Copilot 确实按照我们给出的提示做了这件事。
生成所有可能的单词并非易事。然而,我们在这里用一行 Python 代码就做到了!它只是调用了三个辅助函数,就是这样。这正是我们进行自顶向下设计的原因:通过将大部分复杂性转移到辅助函数,使复杂函数的实现变得更加容易。
我们接近能够实现get_spelling_suggestions,但还不是完全接近,因为我们首先需要实现它的子任务函数all_real_words。all_real_words函数接受两个参数。第一个参数是英语单词列表(这将从英语单词列表文件中获取)。第二个参数是可能的单词列表(这将从all_possible_words中获取)。该函数返回可能的单词列表,这些单词是真实单词。让我们来做这件事!
文档字符串中的测试是一个很好的例子,说明了这个函数是如何工作的。它使用english_words列表作为第一个参数,possible_words列表作为第二个参数。函数将返回possible_words中也在english_words中的那些单词。我们使用这个非常小的测试案例,而不是使用成千上万的英语单词,因为这使我们能够更容易地手动确定正确的返回值!
让我们检查一下图 7.7 中我们的进度如何。我们接近完成了——只剩下两个函数需要完成!
我们已经实现了create_word_list、all_possible_words和all_real_words,这些是get_spelling_suggestions需要的唯一三个子任务函数。这意味着是我们实现get_spelling_suggestions的时候了!
这个函数接受两个参数:包含真实单词的文件名(每行一个单词)以及我们想要提供建议的字符串。它返回唯一建议的列表。像往常一样,当审查 Copilot 的非叶函数代码时,你想要检查它是否调用了你期望它调用的辅助函数,如列表 7.7 所示。在你继续之前,请确保它调用了它应该调用的所有辅助函数。
那最后一行有点棘手。显然,它确保我们只返回唯一的单词——但是如何做到的呢?让我们问问 Copilot 来解释!当我们使用/explain命令询问 Copilot 时,我们得到了以下解释:
set(real_words):这是从real_words列表创建一个集合。在 Python 中,集合是一个无序的唯一元素集合。因此,这个操作是从real_words列表中删除任何重复的元素。
list(set(real_words)):这是将集合转换回列表。结果是所有元素都是唯一的列表。
因此,总结来说,这一行代码是从real_words列表中删除任何重复的单词。
我们已经到达了我们的最终功能,spell_check!它接受一个参数:一个表示单词列表文件名称的字符串。它会要求用户输入一个字符串,然后显示该字符串的所有拼写修正。
请参考图 7.8,以确认我们已经实现了所有我们的函数!
你需要在你的 Python 程序底部添加一行代码来实际调用这个函数。否则,你的程序将不会做任何事情,因为没有函数被调用!所以,在底部添加这一行:
也尝试其他拼写错误的单词,例如 thourough 和 acceptible。到目前为止,由于只有一个真正的单词与我们要尝试的单词只有一个编辑距离,我们的程序只回复了一个拼写建议。我们鼓励你尝试输入单词 carf,看看我们的程序能否提供许多可能的拼写建议。
恭喜!你已经完成了你的第一个真正的自上而下的设计。你的程序为单词提供拼写建议,就像拼写检查器一样。我们通过一些前期设计工作,将原始问题分解成更小的子问题,使得问题实现起来变得容易得多。
许多计算机科学家认为问题分解是编写良好软件所需的最关键技能[1]。我们在本章中看到了问题分解的价值:它通过将其分解成更小的步骤,直到每个步骤都更容易解决,使得大问题变得可解。我们通过本章使用自上而下的设计(从大任务开始,将其分解成更小的任务)来应用这项技能。当使用 Copilot 和 ChatGPT 等工具时,这项技能仍然至关重要,因为它们在解决小而定义明确的问题时表现更好,而不是大问题。如本章开头所述,问题分解更多的是一种艺术,而不是科学,而且需要练习才能做得正确。我们将在接下来的章节中进行更多的问题分解,以帮助你更好地理解如何自己处理它。
使用之前讨论过的 get_strong_password 和 best_word 的例子,让我们将自上而下的设计方法应用到新的问题上。假设我们想要编写一个名为 find_highest_scoring_word 的函数,它接受一个句子列表作为参数,并返回所有句子中得分最高的单词。每个单词的得分计算方式与 best_word 函数中的方式相同:
确定解决此问题所需的子任务。你会设计哪些单独的函数来将任务分解成更小、更易管理的部分?
绘制一个类似于图 7.1 和 7.2 的功能图,描述这些函数如何相互调用以解决整体问题。
我们讨论了有时一个任务足够简单,可以保持原样,即不分解成更小的任务。针对以下任务,决定你是否会将其分解成更小的子任务。如果是,列出子任务并解释原因。如果不是,解释为什么任务足够简单,可以保持为一个单独的函数。
读取文件并打印其内容
计算学生成绩列表的平均成绩
在数字列表中找到最大值
处理在线商店的订单,包括验证订单、计算总价、应用折扣和生成发票
为什么我们选择将create_word_list函数作为一个单独的函数来创建?这个任务能否作为更大函数的一部分保留?请解释你的理由。
假设你需要改变拼写检查器的工作方式。具体来说,你想要修改add_letter函数,以排除某些字母(例如,q,x,z)的添加。你将如何修改我们编写的程序?
以下函数处理订单列表,计算含税的总价,如果适用则应用折扣,并生成总结报告。
通过将其分解成更小的子问题来重构(重新设计)process_orders函数。将每个子问题实现为一个单独的函数,并确保整体行为保持不变。
我们需要将一个大型的编程问题分解成更小的子问题,然后才能有效地实现它。这被称为问题分解。
自顶向下设计是一种将问题分解成小任务函数的系统技术。
在自顶向下设计中,我们寻求解决定义明确的任务的小函数,并且这些函数可以被一个或多个其他函数使用。
当我们准备实现从自顶向下设计产生的函数时,我们是从下往上实现的;也就是说,我们首先实现叶函数,然后是实现依赖于这些叶函数的函数,依此类推,直到我们实现了最顶层的函数。
使用 Copilot 技巧修复错误
使用 Copilot 调试代码
使用 VS Code 调试器查看代码如何运行
在每位程序员的职业生涯中,都会有一个时刻,他们的代码无法按照预期执行。这很可能已经发生在你身上了,请放心,这是学习编程的正常部分。我们如何修复代码?有时候,改变提示或像在前面章节中学到的那样更好地分解问题,就足以解决问题。但当你无法让 Copilot 提供不同或更好的代码,也无法弄清楚为什么你给出的代码无法正常工作时,你该怎么办?
本章有两个目的。主要目标是学习如何找到代码中的错误(称为 bug)并修复它们。为了找到这些 bug,你需要实现第二个目标,即在运行代码时更深入地理解代码的工作原理。
好消息是,代码中存在错误对程序员来说如此普遍,以至于编程环境,如 Visual Studio Code(VS Code),都有工具帮助我们揭示出了什么问题。我们将在本章学习如何使用这个工具,即调试器。
就像过去几章我们从小的例子开始一样,我们将通过一些小例子学习查找和修复错误的概念,然后我们将通过深入研究一个更大的例子来更真实地展示这个过程,这个例子与你未来可能编写的代码类似。
首先,是一个快速术语课程。许多年前,计算机使用继电器或真空管而不是晶体管。据说,Grace Hopper 博士在编程这些早期计算机之一时,遇到了由继电器中的实际虫子(蛾)引起的错误。今天,我们使用“bug”一词来指代程序中的错误,尽管我们不再处理真正的虫子了。现在,bug 是由程序员的错误造成的;当我们调试代码时,我们正在努力移除这些 bug。(Python 的实现或计算机硬件中也可能存在 bug,但它们非常罕见,可以安全地假设代码中的错误是因为你的代码。)
如果程序员真正试图解决问题,他们不会故意在代码中引入 bug。那么,bug 为什么会发生呢?好吧,软件工程师和 Copilot 会犯错误。你可能会问,什么样的错误?bug 主要有两大类:
*** 逻辑错误—这些错误发生在程序中存在逻辑错误时。例如,也许代码的意图是计算一个单词列表中“Dan”这个单词出现的次数,但实际上代码计算的是列表中包含“dan”(忽略大小写)的单词数量。这段代码会在两个方面出错:它会将像 dan 和 DAN 这样的单词视为匹配项,尽管我们不想将它们视为匹配项,并且它会将 dan 视为一个单词的任何部分而不是整个单词。也就是说,它会计算像 Daniel、danger 和 dan 这样的单词,尽管我们不想计算这些单词中的任何一个!某个地方,代码没有做它应该做的事情,我们必须找出在哪里以及为什么。通常,找到逻辑错误是最困难的部分。一旦我们知道错误在哪里,我们就必须修复它,修复这些错误可能从更改单个字符到完全重写代码。逻辑错误可能发生在提示描述不佳或由于各种原因 Copilot 生成了错误的代码时。****
****## 8.2 如何找到错误
找到错误可能具有挑战性。从根本上讲,无论你或 Copilot 编写了代码,你在编写代码时都认为或 Copilot “认为”代码是正确的。这就是为什么通常更容易让同事而不是作者在代码中找到错误。作者看不到错误,因为他们创造了它!
在这本书中,错误对我们来说并不陌生,因为我们已经看到了代码中的错误,并且我们是通过阅读代码和/或测试来发现这些错误的。在前几章中,我们通过阅读代码找到了错误的来源。在这一章中,我们将解决你在测试中发现的错误,但似乎无法弄清楚代码为什么是错误的。通常,你可以通过尝试其他 Copilot 建议、使用新的提示来修复错误或要求 Copilot 为你修复错误来绕过找出代码错误的原因的过程,但根据我们的经验,这些技术并不总是有效。那么,我们需要更多的工具来帮助我们找出代码中的错误在哪里。
从根本上讲,逻辑错误意味着作者认为代码会做什么和代码实际做什么之间存在不匹配。识别这种不匹配的常见方法是通过使用print语句来深入了解程序的行为,因为它们会告诉你计算机实际上在做什么。一个有用的做法是在不同的时间点打印变量,以查看这些变量在这些点的值。让我们尝试一下我们刚才给出的在单词列表中查找Dan的例子。以下是错误的代码。
你可能已经看到了代码中的问题,但让我们假设我们不知道发生了什么,我们正在试图找出代码出错的地方。假设我们通过运行以下测试用例发现我们的代码是错误的:
我们期望得到 1 的结果,但得到了 2。值得注意的是,将danger包含在这个测试用例中帮助我们发现了代码中的错误。代码中的问题出在哪里?为了找出这一点,我们可以添加print语句。当你想这样做时,你需要阅读代码以确定在哪里放置它们。查看这段代码,在函数开始时打印列表可能不是一个坏主意,取决于我们看到的错误,但这里的错误似乎与计数有关,而不是与单词列表具体有关。在for循环的第一行打印列表中的每个单词可能有所帮助,这样我们就可以检查代码是否正在处理每个单词。我们可能在返回之前打印count,但我们已经对返回的值有了很好的了解。这些都是合理的想法,可以帮助你更接近错误,但不是我们从哪里开始的。为了清楚起见,如果我们从这些其他想法中的一个开始,我们不会错;可能只需要再走几步就能找到错误。
因为错误在于将过多的单词计为Dan单词,所以我们会在count增加的地方的if语句内放置print语句,如下所示。
使用相同的测试用例重新运行我们的代码,现在打印出来的结果是:
哎!这会告诉我们,当程序不应该计数时,它却在计数单词危险。然后我们可以给 Copilot 一个新的提示,结合我们刚刚学到的东西,希望 Copilot 能修复这个问题。以下是我们在编辑器中添加的提示,以及 Copilot 在以下列表中修复的代码。
一旦我们知道了这个错误,告诉 Copilot 如何修复它就变得容易多了。当然,这是一个基本的例子,但这个想法适用于更复杂的代码。这个过程通常是迭代的。你要求代码打印一些内容,然后打印的内容与你认为它应该执行的操作相符。然后你要求代码打印其他内容,并检查它是否符合你的预期。你继续这样做,直到有一个点,代码打印的内容与你想要它执行的操作不匹配。那时你就找到了错误,可以给 Copilot 一个提示来帮助它给你更好的代码,或者如果修复很简单,你可以直接更改代码。
根据代码的复杂性,使用print语句通常是调试的有效方法。我们通常将其作为调试的第一步。
调试过程中的迭代是必要的,因为我们真的不清楚代码在做什么,以及为什么它不符合我们的预期。添加一大堆只告诉你最初认为发生了什么的print语句是可以的,因为每个这样的print语句都告诉你哪里不是要找错误的地方,这是一种有效的排除过程。找出哪里找错误需要时间和实践,所以当你刚开始时,如果你发现自己花了很多时间在上面,不要担心。
为了展示这些工具,我们将通过几种方式调试上一个例子中的同一个函数(参考列表 8.1)。列表 8.4 提供了我们将在这个部分中使用的代码。请注意,我们已经将count_words函数的测试添加到了程序中。
要使用调试器,我们需要设置一个断点并启动调试器。设置断点告诉调试器在程序执行时你想开始调试程序的时间。一旦达到断点,你将能够检查变量值并逐行执行程序。断点非常有用。对于大型程序,你不想逐行执行整个程序,因为这可能需要大量时间。相反,使用断点,你可以只执行与你最相关的代码行。
要设置断点,将鼠标光标悬停在代码左侧,你会看到一个点出现。如图 8.1 所示,点击那个点,你就可以设置一个断点。对于你的项目,你可能想从在函数执行开始处创建一个断点开始,然后逐行执行。
如果你将鼠标移开后,在断点左侧看到一个红色点,如图 8.2 所示,这意味着它已经正确设置。
你可以有多个断点,但在这个例子中,我们只保留第 2 行的单个断点。(注意,你可以通过再次点击那个红色点来移除断点。)接下来,我们将启动调试器并看看它是如何与断点一起工作的。
要启动调试器,选择运行>开始调试,如图 8.3 所示。
调试器启动后,你应该看到一个类似于图 8.4 的屏幕。(如果你是第一次使用调试器,它可能会要求你选择调试配置,在这种情况下,你应该选择 Python。)
VS Code 调试器有几个组件[1]。在左侧,调试侧边栏包含变量部分、监视部分和调用堆栈部分。让我们简要地检查每个部分:
变量部分包含在当前作用域内声明的变量(例如,在count_words内)及其当前值。例如,words参数被定义为包含['Dan', 'danger', 'Leo']的列表。你可以点击words左侧的箭头(>)来查看该变量的更多详细信息。这个部分非常有用,因为你可以检查每个变量的值。
调用堆栈部分 包含导致执行此代码行的已调用函数。在这里,主函数(由 VS Code 称为 <module>)在第 9 行调用了 count_words 函数。在 count_words 函数内部,我们目前在第 2 行。您可以通过查看当前突出显示为第 2 行的代码来验证这一点,如图 8.4 所示。
说到这里,在右侧,显示的是我们的代码编辑器,我们可以看到行 count = 0 被突出显示。这是当前尚未执行的代码行。该行尚未执行的原因是我们在这里设置了断点,由带有点的箭头表示,在代码的这一行。当我们启动调试器时,它运行代码直到即将执行 count = 0 之前。
调试工具栏(图 8.5)在开始调试后至关重要,因为它驱动了整个过程:您可以前进一个指令、重新启动调试或停止调试。
工具栏上的按钮,从左到右依次是
******#### 逐行执行代码
现在我们已经掌握了调试器的使用方法,让我们通过使用“单步执行”按钮继续我们的示例。让我们点击一次“单步执行”按钮,看看我们的视图如何变化(图 8.6)。单步执行跳到了下一行代码,即第 3 行。这意味着它执行了
并且即将准备执行
我们可以从界面的各种线索中看到,执行了count = 0这一行。首先,在右侧的编辑器中,我们可以看到for循环被突出显示,左侧的箭头指向这一行代码。在左侧的调用栈部分,我们可以看到它现在位于count_words的第三行(如count_words被突出显示,右侧椭圆中的数字读作 3:1)。也许最重要的是,在左侧的变量部分,我们现在可以看到变量count已被添加到局部变量中,其值为0。这一点非常令人惊讶,因为如果你试图逐行阅读代码并跟踪正在发生的事情,第 2 行的count = 0意味着创建了一个名为count的变量,并赋予了一个值为0。这正是 VS Code 调试器告诉我们的事情。我们希望你现在开始看到这个工具是多么强大。
让我们再次点击“单步执行”。现在我们在该行之前停止。
我们可以看到现在有一个新的变量word,其值被赋为"Dan",这正是我们所期望的:word被赋予了列表words中的第一个元素的值。这是一个很好的地方来指出,除了能够在变量部分读取变量的值之外,你还可以将鼠标悬停在已经声明的任何变量上,以查看其值。真是太棒了,不是吗?
这是一种完全合理的调试函数的方法。正如我们之前提到的,在函数执行的开始处放置断点,然后逐个步骤地执行,通常是调试的一个很好的起点。你可能只有在for循环运行了数千个值之后才犯错误时,才会发现这种方法有难度。为了解决这类挑战,我们可能会在特定的位置放置断点,以避免在调试器中花费大量时间。让我们停止调试器(在调试工具栏中点击停止),从第 2 行(点击行左侧的红点)移除断点,并尝试不同的断点。
这次,让我们在代码中我们想要更密切监控的点放置断点。根据我们的测试用例显示,当我们期望只有一个单词“counted”时,列表中的两个单词“counted”,我们应该尝试在count增加的行放置断点,就像我们在图 8.8 中所做的那样。
一旦我们开始调试器,代码将运行直到第一次if语句评估为True并且count += 1行准备执行。图 8.9 展示了启动调试器后的调试器视图。
我们在count增加的地方放置断点,因为我们想看看列表中的哪个项目导致了count的增加。检查我们的局部变量,我们可以看到word是'Dan',这是我们想要count变量增加的时候。鉴于这是我们想要发生的事情,我们还没有找到我们的错误。
现在是我们真正可以利用断点放置的优势的时候了。我们希望代码执行直到再次遇到断点。要做到这一点,就是在调试工具栏中点击继续(参见图 8.5)。点击继续后,调试器应该像图 8.10 所示那样出现。
在变量部分,注意count和word的值被突出显示。这是为了告诉你,因为你点击了继续,这两个值已经改变。在找到单词'Dan'后,count中的值如预期地增加到1。word中的值更新为'danger'。尽管'danger'是单词列表中的下一个值,但我们不应该期望代码会为单词'danger'增加count。在这个时候,我们已经找到了我们的错误。注意,通过良好的断点放置,找到错误可以变得多么快。但我们警告说,知道放置断点的最佳位置就像知道调试时最佳打印变量一样——这需要时间和实践来学会如何做得好。
在本节中,我们看到了在 VS Code 中调试器是多么强大。设置断点并开始调试会话非常简单。当调试器监控函数中的变量时,你可以在代码执行时在调试侧边栏中检查这些变量。
在传统的编程学习方法中,这将是简短的一节,告诉你自己修复 bug。尽管这仍然是一个选择,但现在我们有更多的选择与 Copilot 一起使用。这些选择包括请求 Copilot 修复它,为整个函数提供一个新的提示,以更好地描述问题并避免 bug,为 Copilot 提供一个有针对性的提示来修复你发现的代码中的错误部分,如果这是一个小修复,你自己进行修复。让我们看看我们如何做每一件事。
在之前的章节中,我们主要只是向 Copilot Chat 请求单个答案,而在这个章节以及之后的章节中,我们将使用 Copilot Chat 与工具进行对话。当我们与工具进行对话时,我们将努力区分我们写的部分(“Human”)和 Copilot 的响应(“GitHub Copilot”)。使用 Copilot Chat 功能,让我们看看 Copilot 能否在这里帮助我们:
LPHuman 我有一个函数,如下所示,该函数本应计算单词“Dan”在参数列表“words”中出现的次数。然而,它似乎是在计算列表中“dan”字符出现的次数。你能帮我修复这个问题吗?
这段代码将计算单词“Dan”(不区分大小写)在列表中出现的次数。
Copilot Chat 的回应改变了以下行
虽然这个变化看起来很小,但它有很大的影响。原始的代码行在word(在单词通过.lower()方法转换为全小写字母后)中搜索字符串"dan"的任何位置。新的代码行检查word(同样,通过.lower()方法转换为全小写字母)是否与字符串"dan"完全匹配。
Copilot 的此答案更接近正确解决方案,因为它只会匹配整个单词,而不是在单词的任何地方寻找dan的出现。然而,调用.lower()使其不区分大小写,因此它也会匹配dan,而不是我们之前(列表 8.3)的解决方案那样区分大小写(它必须正好是Dan)。我们希望区分大小写,因此我们可以继续使用 Copilot Chat,让 Copilot 为我们修复这个问题。
LPHuman 你能给我相同的代码,但只计算单词“Dan”(区分大小写)吗?
此代码将计算单词“Dan”(区分大小写)在列表中出现的次数。
在这种情况下,当我们知道代码是如何出错的时候,Copilot Chat 能够为我们解决问题。我们没有明确指出哪一行代码是错误的,但如果你知道这一点,你也可以将这一点传达给 Copilot Chat。像我们这里这样知道哪一行是错误的优点之一是,我们可以确保 Copilot 的修复更改了错误的行。让我们继续,并检查其他修复错误的选项。
你已经在这本书中多次看到我们重写函数的提示了。这仅仅涉及重写函数提示,看看 Copilot 是否能做得更好,有时使用 Ctrl-Enter 来探索 Copilot 的解决方案。我们在这里不会重复,因为你已经多次看到过(例如,在第六章的第 6.5.2 节)。
我们在章节 8.3 中较早使用了这种技术。我们将其作为列表 8.5 重现。
这里,我们已经确定上一行
没有做我们想要的事情。因此,我们移除了错误的代码,并特别添加了一个提示,明确指出我们想要 Copilot 做什么,并且它能够生成正确的代码。
这种修复错误代码的方法在本章中是新的。到目前为止,你在本书中已经看到了足够的代码,你很可能可以通过直接修改它来修复一些代码。例如,假设我们编写了以下代码。
我们在本书的早期部分已经看到过类似这样的函数。它遍历数字列表中的所有数字,并检查每个数字是否大于或等于x且小于或等于y。如果一个数字在x和y之间,它会增加count。函数结束时,它返回count。如果目标是计算列表中在x和y之间(包括x和y)的数字,这里的代码并没有错误。
然而,“之间”这个术语可能有点含糊不清。你是想包括 x 和 y,还是只包括它们之间的值(不包括它们)?假设当你想象这个函数时,你想要不包括 x 和 y。例如,如果列表是 2、4、8,而你提供了 3 和 8 作为 x 和 y 的值,你希望计数为 1(包括 4,但不包括 8)。你可能已经看到了函数在这个目标上的错误以及如何修复它,但让我们假装你还没有意识到代码并不符合你的要求。
就像任何由 Copilot 或我们创建的函数一样,我们应该测试这个函数。幸运的是,在编写第一个测试用例时,我们在测试用例(numbers)中包含了一个等于 x(下限)和等于 y(上限)的值:
在提示符下测试代码,我们发现我们的函数返回 5 作为答案,但我们认为答案应该是 3。根据我们对“之间”的定义,我们觉得只有数字 4、5 和 6 在 3 和 7 之间,所以答案应该是 3,即 3 和 7 之间有三个数字。相反,代码给出了 5 的答案。因此,我们知道它没有正确工作,多亏了我们的测试用例。无论我们是通过检查代码还是使用本章中的调试技术来继续寻找错误,我们都会意识到
是罪魁祸首。在这里,完整的修复需要我们将if语句从
to
这是一个我们可以直接做出的更改,无需请求 Copilot 的帮助(尽管 Copilot 可能也能在这里帮助我们)。列表 8.7 在更改后有修正后的函数。
现在我们知道了如何更刻意地寻找和修复错误,让我们回顾一下我们在第六章中最后访问的工作流程。请注意,这个工作流程是关于如何设计一个单一函数的,所以它假设你已经完成了第七章中描述的功能分解,以确定适当的函数。新的工作流程出现在图 8.11 中。
虽然这个图开始变得有点复杂,但大部分都是你在第六章中看到的内容。这里唯一的改变是包括了调试。具体来说,如果你已经尝试修改提示,但仍然无法得到可以工作的代码,那么是时候尝试显式地进行调试了。凭借你在本章中获得的技术,你可能会成功地找到一些错误,但不是所有可能的错误。如果你找到了错误,并觉得你已经成功地修复了它,流程图会把你送回测试,以确保你的修复是有效的(并且没有破坏其他测试用例)。万一你无法通过新的提示使代码工作,而且似乎无法调试它,我们发现问题分解通常是下一步的最佳选择。换句话说,如果你无论多么努力都无法使一个函数工作,那么这个函数可能应该被分解成多个函数,你更有可能成功地编写这些较小的函数。
在我们掌握新技能和新的工作流程之后,让我们尝试解决一个更具挑战性的调试问题。记得在第六章中我们试图确定教室中给定行中最大的空座位数吗?我们在这里将解决一个类似的问题。
我们不是在寻找空座位,而是假设我们想要计算一排中连续坐下的最多学生人数,因为你试图在考试前更好地分配空间。你也可以想象如果他们想要更多的个人空间或者有流行病时,也可以使用这个方法来更好地分配空间。我们编写了提示和一个基本的 doctest,Copilot 生成了解决方案。代码如下所示。
由于本章是关于调试的,你可能猜到代码可能没有正确工作。我们在阅读 Copilot 给出的代码时发现了这个错误,但它是一个微妙的错误,我们怀疑可能会被轻易忽略。如果你已经看到了它,做得好,但请假装你没有看到本章的其余部分。如果你还没有看到它,本章的其余部分对你来说将更有价值。
让我们假设我们刚刚写下了这个提示和测试用例。我们阅读了代码,看起来它可能是在跟踪连续最多的学生人数。只要它看到有学生在座位上,它就会增加计数。当座位上没有学生时,它会检查计数是否比之前看到的任何计数都大,并重置计数。看起来它至少是在正确的轨道上。我们包含了一个测试用例,所以运行了代码,测试用例通过了。我们对代码感到相当满意,但知道我们需要做更多的测试用例,特别是那些用来捕捉边缘情况的测试用例(记住边缘情况是不常见的,可能会破坏代码)。
我们知道当我们处理列表时,检查代码在列表的开始和结束处是否正确执行是很好的。为了测试列表的末尾,让我们添加一个测试用例,其中最大的连续学生组包括最后一个座位,然后重新运行代码。以下是我们要添加到文档字符串中的新测试用例:
我们再次运行代码,并对测试用例失败感到惊讶。这是它告诉我们的(我们重新格式化输出以提高可读性):
这很奇怪——代码看起来工作得很好。这个边缘情况揭示了一个错误。在这个时候,我们想要生成一些关于代码为什么没有正确工作的假设,以帮助指导我们的调试工作。(如果你真的陷入了困境,你可以采取在函数的第一行代码处设置断点并逐步执行的方法,而不是试图创建一个假设。)以下是两个想到的假设:
count 的更新跳过了列表中的最后一个元素。
max_count 的更新缺少列表中的最后一个元素。
为了简化调试过程,我们移除了通过测试(只是将其放在一边稍后恢复)并只运行失败的测试。以下列表显示了我们在开始调试过程之前的完整代码。
我们将从第一个假设开始,即 count 在列表末尾没有正确更新,并在 count 的更新处设置一个断点。图 8.12 显示了调试器启动后第一次暂停的情况。
从调试器中,我们可以看到 count 仍然是 0,所以它还没有被更新。我们处于第一个测试用例的第一行,因为 row 是 ['S', ' ', 'S', 'S', 'S', 'S']。我们正在查看的座位是 'S',这就是为什么计数在增加。让我们在调试工具栏中点击继续,以查看 count 的下一个更新。点击继续后调试器的状态如图 8.13 所示。
从上次更新 count 以来,似乎发生了很多事情,因为 max_count 现在是 1。这一定是在处理空位时发生的,因为 max_count 被设置为 1,而 count 被重置回 0。此时,我们处于行中的第三个座位,那里有一个学生,count 准备更新。我们希望检查 count 是否会随着每个新学生的到来而更新。我们点击了“继续”,count 增加到 1。我们再次点击“继续”,count 增加到 2。我们再次点击“继续”,count 增加到 3。此时,我们处于行中的最后一个学生,我们希望检查 count 是否增加到 4。为了检查这一点,我们点击了一次“单步执行”,count 确实更新到 4。您可以在图 8.14 中看到此时调试器的状态。
好吧,此时我们有好消息和坏消息。好消息是 count 正确更新了。坏消息是我们的第一个假设是错误的,我们还没有找到我们的错误。我们可以将断点移动到更新 max_count 的行(第 22 行),然后点击“重启”并重新开始我们的第二个假设的调试过程,但鉴于我们的调试器中 count 现在是 4,让我们继续跟踪代码,确保 max_count 被更新。或者更确切地说,我们知道它不会更新,所以我们要看看为什么。
在点击“单步执行”之前,调试器中已经存在一个线索。这个线索来自于即将执行的下一行代码是 for seat in row。但刚刚看到的那个学生是 row 中的最后一个学生。这意味着这个 for 循环即将结束(这意味着我们不会再次执行循环体,这意味着 max_count 无法更新)。让我们通过点击“单步执行”来查看这是否会发生。调试器的状态如图 8.15 所示。
我们刚刚处理完第一行,但从未更新 max_count。下一行代码将选择下一行 row,然后下一行将 count 设置为 0。我们在遍历行时没有更新 max_count,尽管我们找到了一个比当前 max_count 更大的 count。如果您还没有看到这个错误,我们鼓励您单步执行,直到下一次 max_count 更新,那时它可能更明显。
代码中的错误在于它只在遇到空座位时更新 max_count。这意味着如果一行以学生结束,检查 max_count 是否应该更新的代码将永远不会为那一行运行。更仔细地检查代码,检查 max_count 是否应该更新以及更新 max_count 都应该在 if-else 语句之外或 count 更新之后发生。
这是我们可能只需手动修复的修复,因为我们只需要将两行代码移动到更好的位置。列表 8.10 中的代码是修正后的函数(不包括测试或提示)。
这段新代码通过了旧代码和原始测试中失败的测试。在添加另一个确保当最长连续学生组出现在行首时代码能够正常工作的测试之后,我们更有信心代码现在正在正确工作。
我们怀疑你已经对调试器印象深刻了。我们也是。当学生以传统方式学习编程时,花费大量时间确保学生能够像调试器一样跟踪代码,绘制所有变量的状态,并随着每条新执行行的更新而更新它们。确实,网络上甚至有一个名为 Python Tutor [2] 的免费工具,它可以创建内存状态的图表,比调试器更容易阅读,仅此帮助新程序员学习代码的执行方式。
无论你是否喜欢使用调试器,还是想使用像 Python Tutor 这样的工具,我们都鼓励你玩一玩书中前面章节中编写的一些代码。在我们个人与学习编程的人合作的经验中,逐行遍历程序并观察变量状态的变化可以是一次真正启发性的体验,我们希望你也同样欣赏。
从与学生的合作中,我们还看到调试对于新学习者来说可能是一个非常令人沮丧的经历 [3]。在学习如何编程时,每个人都希望他们的代码能够工作,而找到并修复错误是在事情不工作时的耗时。有一些方法可以帮助克服这种挫折。首先,问题分解可以大大帮助你从 Copilot 获取正确的代码,而无需进行大量的调试。其次,记住,每个人的代码有时都会出问题,包括我们的。这只是编程过程中的一个自然部分,也是需要一些练习的部分。最后,始终如一,我们真的意味着始终,测试你写的每一个函数。很多时候,当我们的学生在调试时遇到困难,那通常是因为没有测试每个函数,导致多个函数相互作用产生了错误。当这种情况发生时,找到和修复错误特别困难。调试相互作用的错误如此令人沮丧,以至于避免这种经历是我们两个都虔诚地测试我们写的每一个函数的主要原因。
好消息是,如果你测试你写的每一个函数,并且勤奋地将问题分解成小而可管理的步骤,你不应该经常遇到调试问题。而且,如果你确实遇到了,你将调试一个函数中的错误,这正是地球上几乎每一个程序员都在做的事情。经过一些练习,你会掌握它的。
识别sum_even_numbers函数中的错误,并解释如何修复它。修改代码以纠正错误,并使用提供的测试用例验证你的解决方案。
你被提供了一个 Python 函数,该函数本应检查一个字符串是否是有效的密码。一个有效的密码必须满足以下标准:
它必须至少有 8 个字符长。
它必须至少包含一个大小写字母。
它必须至少包含一个小写字母。
它必须至少包含一个数字。
然而,这个函数没有正确工作。确定这个函数有什么问题,并修复它:
你正在考虑使用这个提示来请求 Copilot 帮助你修复 bug:“我有一个基于一天中时间的问候用户的函数,但它似乎把中午分类为‘晚上好’。你能帮我修复这个逻辑吗?”这个提示提到了问题,但没有指出具体的错误。看看你是否可以编辑提供的提示,使其更加具体,并引导 Copilot 提出正确的修复方案。
调试是一个重要的技能,包括在代码中查找错误,然后纠正它们。
打印语句可以是一种了解你的代码中正在发生什么的有效方式。
VS Code 调试器是另一种了解你的代码中正在发生什么的方法,它提供了强大的功能来监控变量在代码执行过程中的变化。
一旦发现错误,有几种方法可以帮助 Copilot 为你修复错误,但如果那失败了,你通常可以直接修复代码。
我们设计函数的工作流程现在包括调试,而调试技能将使你更有可能编写你想要的软件。
除了调试之外,VS Code 调试器还可以是一个强大的工具,用于更深入地了解代码的工作方式。
理解程序员编写工具的原因
确定我们需要编写特定工具的模块
自动化清理带有> > >符号的电子邮件
自动化操作 PDF 文件
自动化在多个图像库中删除重复图片
假设你负责创建 100 份报告,每份报告对应 100 个人中的每一个。也许你是一名教师,需要给每个学生发送一份报告。也许你在人力资源部门工作,需要给每个员工发送年度评估报告。无论你的角色如何,你都有必须创建这些报告的问题,你决定将这些报告作为.pdf 文件准备。你还需要为每份报告准备定制的封面页,这些封面页是由你的同事(一位平面设计师)设计的。
你和你的同事独立工作,最后工作完成了。或者等等,不是那么快。因为现在你必须将每个封面页放在每份报告的开头。
在这个阶段,一个非程序员可能会咬紧牙关开始工作,手动将封面页与第一份报告合并,第二封面页与第二份报告合并,以此类推。这可能会花费数小时。由于不知道还有其他方法,一个非程序员可能会一直努力直到工作完成。
但你现在是一名程序员。而且大多数程序员,包括我们两个,都不会手动进行这样的工作。
在本章中,我们将向你展示如何编写程序来自动化繁琐的任务。章节中的第二个例子将自动化“合并封面页与报告”的情况。但我们还会做其他的事情。收到已被转发多次的电子邮件
它看起来
像这样
这个?
或者,你的家人有几部手机,每部手机都有几百张图片,你只是想将所有图片放在同一个地方,以便你可以归档它们而不会丢失任何东西?在本章中,我们将向你展示如何自动化这类任务。
程序员常常表达的一种普遍观点是:我们很懒惰。这并不意味着我们不想完成我们的工作。这意味着我们不想做那些重复、无聊、繁琐的工作,因为那是计算机擅长的事情。程序员对这种苦差事有一种类似蜘蛛侠的感觉。假设 Leo 有几百张照片,他想要删除任何重复的照片。他不可能手动做这件事。或者假设 Dan 必须给他的每个学生发送定制的电子邮件。如果学生人数超过几个,他也不可能手动做这件事。一旦程序员开始注意到他们在键盘上重复相同的按键或一遍又一遍地执行相同的步骤,他们就会停下来,制作一个工具来自动化这个过程。
当程序员谈论工具时,他们是在谈论那些能节省他们时间的程序。一个工具通常不是最终目标,编写一个工具本身可能会感觉枯燥乏味,并不光彩。但一旦我们有了工具,我们就可以用它来节省时间。有时,我们可能会用一次工具,完成一项特定的任务,然后就不会再用了。然而,通常情况下,一个工具会反复被证明是有用的,无论是我们按照我们编写的方式使用它,还是通过做一些小的改动。例如,丹完成每一门课程的授课后,他会使用自己编写的程序来整理所有学生的成绩并提交给大学。每次他都会对工具做一些小的改动——比如改变每个作业的权重——但丹就可以使用这个略微修改过的工具来完成工作。
使用 Copilot 的好处是它使制作这些工具变得更加容易。以下是某位软件工程师的解释:
我们都知道工具很重要,有效的工具很难创建,而管理层并不关心或理解对工具的需求……我无法表达现在编程感觉有多么根本性地不同,因为我现在每天可以构建两个质量工具,以满足我每一个想要解决的问题。[1]
正如我们在第五章讨论模块时所学到的,有时我们需要使用一个模块来帮助我们编写我们想要的程序。一些模块是内置在 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 请求为我们编写整个工具,甚至不需要将其放入函数中。不过,我们在这里不会这样做,因为我们仍然认为函数的好处是值得的。函数将帮助我们记录代码,以便我们知道它做什么,并且它使我们能够在以后决定,例如,向函数添加额外的参数以改变工具的行为。
有时候,一封电子邮件被回复和转发多次,变得一团糟,有些行上有许多大于(>)符号和空格。以下是我们所指的样本电子邮件:
嗨,利奥,
丹 -- 你的自然语言研究有什么进展吗?
是的!你给我看的那个网站
非常有用。我在那里找到一个数据集,它收集了
很多
可能对我的研究有用的问答。
谢谢,
假设你想保存这封电子邮件信息以备将来使用。你可能希望清理行首的>和空格符号。你可以开始手动删除它们——毕竟,这封电子邮件并不长——但不要这样做,因为这里你有机会设计一个通用的工具,你可以在需要执行此任务时使用它。无论你的电子邮件有五行、一百行还是一百万行,这都不会有关系:只需使用工具,完成任务即可。
我们需要让杂乱的电子邮件可供我们的工具使用,以便工具可以清理它。我们可以做到这一点的一种方法是将电子邮件的文本首先复制到剪贴板(使用您操作系统的复制到剪贴板命令,例如 Ctrl-C)。
每个计算机操作系统都有一个剪贴板来存储最后复制的内容。如果你曾经使用过复制粘贴,你就使用过那个剪贴板。我们将使用剪贴板作为文本的临时存储,以便我们可以在剪贴板中编辑文本,然后将其粘贴到所需的位置。
在将乱糟糟的电子邮件内容复制到剪贴板后,我们可以在剪贴板中的文本上运行工具,该工具可以清理电子邮件并将剪贴板内容替换为清理后的版本。为了使工具执行合理操作,我们假设您的剪贴板上有电子邮件文本。工具将删除每行开头的>和空格字符,这样您就可以使用操作系统的粘贴命令(如 Ctrl-V)将新清理的电子邮件粘贴到您想要的位置。在 Python 方面,它需要将剪贴板内容拉入一个字符串(即复制剪贴板的内容),清理它,然后将新的清理文本放回剪贴板(即粘贴到剪贴板),替换之前那里的垃圾邮件版本。
那么,我们如何在 Python 程序中访问剪贴板?这正是我们需要弄清楚的问题,这将是我们与 Copilot 对话的框架。类似于第八章,我们将使用“Human”作为我们输入的提示,使用“GitHub Copilot”作为 Copilot 的响应。您可以使用 Copilot Chat 功能或 ChatGPT 进行类似的对话。我们正在将提示输入到 Copilot Chat 窗口中。
在我们与 Copilot 的对话中,我们将广泛探索,然后确定解决方案。一旦我们通过一个示例掌握了事情,我们就会解释为什么这样做,但在这里我们注明这一点,以便您为这种看似不那么直接的方法做好准备。Copilot 在其响应中提供了大量的示例代码。在我们探索的过程中,您可以随意浏览那些代码,但不要陷入其中。一旦我们完成探索并确定计划,我们再来看代码的更多细节。让我们开始学习吧!
LPHuman 我想写一个 Python 程序,可以访问剪贴板并让我使用剪贴板进行复制和粘贴。我该如何操作?
此程序首先导入 pyperclip 模块。然后使用copy()函数将字符串'Hello, world!'复制到剪贴板。最后,使用paste()函数将剪贴板的内容粘贴到“text”变量中,并打印结果。
您可以通过在终端中运行pip install pyperclip来使用 pip 安装 pyperclip 模块。
Copilot 的响应告诉我们,我们可以在 Python 程序中使用 pyperclip 模块与剪贴板进行交互。它提供的示例代码甚至展示了如何从我们的程序中复制内容到剪贴板,并将剪贴板中的内容粘贴到一个变量(text)中。我们取得了很大的进步!
响应的最后一段告诉我们如何安装 pyperclip。啊哈!所以 pyperclip 并不是 Python 默认自带的。虽然我们稍后会学习如何安装模块,但不必担心这一点可能是个好事。所以,让我们继续对话,询问 Copilot 是否有更好的方法。
LPHuman 想知道是否有办法在不安装 Python 模块的情况下完成这个操作?
这个程序首先使用 Tk() 函数创建一个隐藏的 Tkinter 窗口,并使用 withdraw() 方法将其隐藏。然后,它使用 clipboard_clear() 方法清除剪贴板,并使用 clipboard_append() 方法将字符串 'Hello, world!' 添加到剪贴板。最后,它使用 clipboard_get() 方法检索剪贴板的内容并打印结果。
注意,这种方法仅在存在图形环境时才有效,例如 Linux 或 macOS 上的桌面环境,或 Windows 上的 Windows 桌面。如果你在仅终端的环境中运行 Python,你需要使用不同的方法来访问剪贴板。
看起来我们既有所得也有所失。所得是,这个 tkinter 模块是 Python 内置的。所失是,“该方法仅在存在图形环境时才有效。”在我们的 Python 设置中是否有图形环境呢?虽然我们可以尝试代码来找出答案,但这个要求让我们犹豫。毕竟,pyperclip 解决方案没有这个要求,所以我们可能不需要它(无论它具体意味着什么)。让我们再试一次。
LPHuman 还有没有其他方法可以在不安装 Python 模块的情况下完成这个操作?
这个程序首先导入 win32clipboard 模块。要复制文本到剪贴板,你可以调用 OpenClipboard() 函数来打开剪贴板,然后调用 EmptyClipboard() 来清除任何现有数据,最后调用 SetClipboardText() 来设置剪贴板上的文本。设置文本后,你应该调用 CloseClipboard() 来关闭剪贴板。
要从剪贴板粘贴,你可以调用 OpenClipboard() 来打开剪贴板,然后调用 GetClipboardData() 来从剪贴板获取文本。获取文本后,你应该调用 CloseClipboard() 来关闭剪贴板。在这个例子中,程序将文本 "Hello, world!" 复制到剪贴板,然后粘贴并打印到控制台。
“Windows 上的标准库?”这令人担忧,因为这意味着我们的工具可能在其他操作系统上无法工作。让我们看看情况。
LPHuman 这个在 Linux 或 macOS 上也工作吗?
在 macOS 上,你可以使用 pbcopy 和 pbpaste 命令行工具来访问剪贴板。用法与 xclip 类似。
我们已经得到了一些针对不同操作系统的特定解决方案。我们不喜欢这样!我们希望我们的工具无论在哪个操作系统上都能工作。(即使我们只使用 Windows,也许有一天,我们会将这个工具与其他使用不同操作系统的用户分享。)
我们可以继续我们的对话,但回顾起来,安装那个单一的 pyperclip 模块似乎并不那么糟糕。让我们继续吧。一般来说,在决定如何开始编写工具时探索你的选项是有价值的,与 Copilot 进行对话有助于进行这种探索。
有几个原因说明为什么你不应该仅仅使用 Copilot 聊天告诉你的第一个模块:
模块经常更新。通常这是为了添加新功能并修复错误,但有时在这个过程中,功能也可能被移除或更改。当一个模块的作者移除一个功能时,这被称为*弃用该功能**。一旦一个功能被弃用,它可能就不再工作,我们需要找到一个替代方案。Copilot 只是偶尔进行训练,而不是持续训练,所以如果 Copilot 在训练后该模块的新版本发布,Copilot 将不会知道这些更新。这可能导致 Copilot 建议依赖于比你所拥有的模块旧版本的代码。我们将在本章后面看到解决这个问题的方法。但如果你遇到麻烦,有其他选项可供探索是有用的。
我们需要做的第一件事是安装 pyperclip 模块。那么我们该如何安装它呢?好消息是 Python 使得安装新模块变得很容易。前往 VS Code 右下角的终端,并输入以下命令:
注意:对于某些操作系统,你可能需要使用pip3而不是pip。在 Windows 机器上,如果你遵循了第二章中的安装说明,我们建议使用pip。在 Mac 或 Linux 机器上,我们建议使用pip3。
当你运行这个命令时,你可能会看到还安装了其他模块。不用担心这个问题:有时模块需要其他模块来执行它们的工作,Python 会自动安装所有这些模块,这样你就不必担心自己安装这些依赖项了。
安装完成后,我们可以导入该模块,然后为我们的函数编写头和文档字符串,该函数将从行首删除>和空格字符。我们在这里导入模块的原因是影响 Copilot 在编写函数代码时使用该模块。以下是我们的提示和 Copilot 给出的代码。
我们可以看到程序正在执行正确的步骤序列:它正在获取剪贴板内容,清理文本,然后将清理后的文本复制回剪贴板。正如我们从与 Copilot 的早期聊天中学到的,我们希望 Copilot 编写使用 pyperclip 的 paste 和 copy 函数的代码,这正是它所做的事情。程序倒数第二行是我们在这本书中之前没有见过的:
实际上,如果你喜欢,可以删除这一行(如果你这样做,也要取消下一行的缩进)。这确保了 clean_email 函数只有在运行你的程序时才会被调用,而不是在将其作为模块导入时。毕竟,如果你确实想将其作为模块导入(作为更大程序的一部分使用),你会在需要该功能时调用 clean_email,而不一定是模块导入时。 (而且,一般来说,每当你想更全面地理解一行代码时,你都可以询问 Copilot!)不幸的是,这段代码不起作用。如果你运行它,你会收到以下错误:
语法错误意味着我们有一个没有用有效 Python 代码编写的程序。我们现在将修复它!我们有几个选项可以这样做。一个是突出显示你的代码,并要求 Copilot Chat 修复错误。对我们来说,这确实解决了问题。你也可以尝试在与 ChatGPT 的对话中提出:“为我的代码中的错误提出修复建议 <插入你的代码>。” 这是一个有用的提示,当你从 Copilot 得到的代码没有按预期工作时要记住!
Copilot 通过修复有语法错误的行为我们修复了代码。新的代码如下所示。
新的代码行,与之前奇怪的一行代码不同,是
这行的目的是将所有文本行连接成一个单独的字符串,程序稍后会将其复制到剪贴板。那个 \n 代表什么?它代表代码中换行的开始。join 方法是什么?它接受列表(行)中的所有项并将它们连接成一个单独的字符串。
我们可以通过稍微实验 join 来更详细地理解它是如何工作的。这里是一个使用空字符串而不是 '\n' 字符串的 join 示例:
注意,有些单词挤在一起。这并不是我们想要的--我们需要在它们之间留点空间。怎么样,让我们再次尝试使用join,这次在字符串中使用空格而不是空字符串:
或者,我们也可以使用'*':
这样就解决了我们的单词挤压问题。而且,*s告诉我们每行在哪里结束,但最好实际上保持电子邮件是三行的事实。
在 Python 中,我们需要一种方法来使用换行符或换行符字符,而不是空格或*。我们不能只是按 Enter 键,因为这会将字符串分成两行,这不是有效的 Python 语法。要做到这一点,我们可以使用'\n':
现在我们的工具已经准备好使用了。如果你将一些杂乱的电子邮件文本复制到剪贴板,运行我们的程序,然后粘贴剪贴板,你会看到电子邮件已经被清理。例如,如果我们对之前的样本电子邮件运行它,我们会得到以下清理后的版本:
嗨,利奥,
丹--你在自然语言研究方面有什么进展吗?
是的!你给我看的那个网站
非常有用。我在那里找到一个数据集,它收集了
很多
可能对我的研究有用的问答。
谢谢,
当然,我们还可以做更多。那封电子邮件中的换行符不太好(“很多”这一行非常短且没有必要),你可能还想清理一下。你可以通过向 Copilot 的提示中添加新要求来开始进行这些改进。我们在这里停下来,因为我们已经完成了电子邮件清理的初步目标,但我们鼓励你继续独立探索更稳健的解决方案。
让我们回到本章开头的场景。我们已经编写了 100 份报告,这些报告都是.pdf 格式的。我们的同事为这些报告设计了 100 个封面,这些封面也是.pdf 格式的,我们需要将封面与报告合并,以便每个最终生成的.pdf 文件从封面开始,然后继续是报告。图 9.1 展示了所需的过程。
你可能想知道为什么我们不能简单地使用 Python 的read和write方法来操作.pdf 文件。毕竟,我们在第二章处理.csv 文件时就是这样做的。
最大的区别在于.csv 文件是文本文件,这些文件是未经格式化或特殊命令的人类可读文件。然而,许多其他文件格式不是文本文件。例如,.pdf 文件不是人类可读的,需要由了解.pdf 格式的代码进行处理。同样,Microsoft Word 文件和 Microsoft Excel 文件也是如此:它们不是文本文件,因此我们需要专门的工具来处理它们。
打开一些封面.pdf 文件和一些报告.pdf 文件。您可以使用 Adobe Acrobat 或您通常在计算机上打开.pdf 文件的方式来实现。您会注意到封面总是恰好一页,但报告可能有多页。当我们到达代码时,我们应该注意告诉 Copilot 报告可能有多页;否则,Copilot 可能会编写假设报告,就像封面一样,只有一页。
您可能想知道我们是如何创建这 100 个封面和报告的。如果我们一个接一个地手动创建,您会感到失望,对吧?别担心。我们以与自动化合并过程相同的方式自动化了创建过程。
我们的工具需要找到所有报告和封面,将封面添加到每个报告的前面,并为每个报告生成一个包含封面页和报告页的最终.pdf 文件。我们如何使用 Python 合并.pdf 文件?让我们来看看。
LPHuman 我需要编写一个Python 程序来合并.pdf 文件。我该如何操作?
在这个例子中,我们首先使用open()函数打开要合并的两个 PDF 文件。然后,我们使用 PyPDF2 的PdfFileReader()函数为每个文件创建一个 PDF 读取对象。
接下来,我们使用PdfFileWriter()函数创建一个 PDF 写入对象。然后,我们使用 reader 对象的getNumPages()和getPage()方法遍历每个 PDF 文件的页面,并使用addPage()方法将每个页面添加到 writer 对象中。
最后,我们使用 writer 对象的write()方法将合并后的 PDF 写入文件,并使用close()方法关闭所有输入和输出文件。
我们有线索了!这个 PyPDF2 模块似乎能完成我们需要的功能。它看起来提供了PdfFileReader来读取现有的.pdf 文件和PdfFileWriter来创建新的.pdf 文件。我们看到代码使用getPage从现有的.pdf 文件中获取页面,并使用addPage将其写入新的.pdf 文件——非常有前景。Python 中有没有内置的我们可以使用的东西,这样我们就不必担心安装 PyPDF2 了?
LPHuman 有没有一种方法可以在不安装 Python 模块的情况下完成?
这种方法最终可能很有价值,但并不是一个内置的解决方案。毕竟,最后一段提到了我们不得不安装一些名为pdftk的新软件!
到目前为止,我们可能会选择使用 PyPDF2 的第一个选项。但让我们再次使用 Copilot 来确保我们没有错过任何好的选项。
LPHuman 我应该考虑其他 Python .pdf 库吗?
ReportLab——一个用于从头创建 PDF 文档的库,支持文本、图形和图像。
FPDF——一个用于从头创建 PDF 文档的库,支持文本、图形和图像。
PDFMiner——一个用于从 PDF 文件中提取文本和元数据的库。
PyMuPDF——一个用于处理 PDF 文件的库,支持文本、图形和图像。
pdfrw——一个用于读取和写入 PDF 文件的库,支持文本、图形和图像。
每个库都有其自身的优缺点,因此您应该选择最适合您需求的库。如果您只需要合并 PDF 文件,那么 PyPDF2 或 pdftk 可能就足够了。然而,如果您需要以更复杂的方式创建或操作 PDF 文件,那么其他库可能是一个更好的选择。
最后一段告诉我们,如果我们只需要合并.pdf 文件,那么使用 PyPDF2 可能就足够了。但让我们通过了解所有这些工具的优缺点来结束我们的对话。这样,如果我们遇到 PyPDF2 的问题,我们就知道下一步该怎么做。
LPHuman 您可以总结一下这些库的优缺点吗?
PyPDF2——一个用于处理 PDF 文件的库,支持文本、图形和图像。
优点:
对于简单任务(如合并 PDF 文件)来说易于使用。
提供良好的文档和示例。
支持广泛的 PDF 功能。
缺点:
对高级 PDF 功能(如表单和注释)的支持有限。
大型 PDF 文件可能运行缓慢。
ReportLab——一个用于从头创建 PDF 文档的库,支持文本、图形和图像。
优点:
创建复杂的 PDF 文档非常强大和灵活。
支持广泛的 PDF 功能。
提供良好的文档和示例。
缺点:
对于初学者来说,学习曲线陡峭。
大型 PDF 文件可能运行缓慢。
FPDF——一个用于从头创建 PDF 文档的库,支持文本、图形和图像。
优点:
轻量级且易于使用。
提供良好的文档和示例。
支持广泛的 PDF 功能。
缺点:
对高级 PDF 功能(如表单和注释)的支持有限。
大型 PDF 文件可能运行缓慢。
PDFMiner——一个用于从 PDF 文件中提取文本和元数据的库。
优点:
从 PDF 文件中提取文本和元数据非常强大和灵活。
支持广泛的 PDF 功能。
提供良好的文档和示例。
缺点:
对于初学者来说,学习曲线陡峭。
创建或操作 PDF 文件的支持有限。
PyMuPDF——一个用于处理 PDF 文件的库,支持文本、图形和图像。
优点:
处理 PDF 文件非常快速和高效。
支持广泛的 PDF 功能。
提供良好的文档和示例。
缺点:
对于初学者来说,学习曲线陡峭。
创建或操作 PDF 文件的支持有限。
pdfrw——一个用于读取和写入 PDF 文件的库,支持文本、图形和图像。
优点:
读取和写入 PDF 文件非常强大和灵活。
支持广泛的 PDF 功能。
提供良好的文档和示例。
缺点:
对于初学者来说学习曲线陡峭。
对于大型 PDF 文件可能运行缓慢。
不要被“对于初学者来说学习曲线陡峭”的警告吓倒。如果 Copilot 在该库上进行了训练,那么你仍然可能成功。
根据我们在这里学到的知识,我们可能会消除 PDFMiner,因为它显然对创建或操作 PDF 文件的支持“有限”,但我们仍会保留所有其他选项,以防万一我们需要它们。目前,我们将继续使用 PyPDF2 并看看效果如何。
前往安装 PyPDF2 模块。注意大小写:模块名为 PyPDF2,而不是 pypdf2、pyPDF2 或任何其他大小写形式。
在我们之前的例子中,我们编写了一个用于清理剪贴板上的电子邮件的函数。我们不需要向该函数传递任何信息,因此该函数没有参数。在这里,我们的函数将需要三个参数:包含报告的目录名称、包含封面的目录名称以及我们想要写入最终(合并).pdf 文件的目录名称。
在我们的文档字符串中,我们需要解释每个目录的作用,以及目录中文件名的模式。毕竟,Copilot 需要知道报告是如何与封面页匹配的。
我们将使用 PyPDF2 Python 库来尝试完成这项工作。回顾 Copilot Chat 告诉我们的关于 PyPDF2 库的信息。它给出的示例代码使用了 PdfFileReader 来读取现有的 .pdf 文件,并使用 PdfFileWriter 来创建新的 .pdf 文件。它还使用了 getPage 和 addPage 来操作页面。注意 Copilot 生成的代码中的这些内容——它们是 Copilot 正在生成正确代码的明显迹象。以下是我们的提示和 Copilot 给出的代码。
我们编写的用于合并 .pdf 文件的程序可以非常快速地处理数百或数千个文件。如果它们操作不正确,它们很容易导致文件损坏或丢失。每次我们使用 open 并将 'w' 或 'wb' 作为第二个参数时,这意味着我们正在覆盖一个文件。
它正在使用open函数打开一个文件。具体来说,它正在打开final_dir目录中的当前report_file文件。open函数这里的第二个参数'wb'意味着我们想要打开文件以便写入(这就是'w'),而我们正在写入的文件是一个二进制文件(这就是'b'),而不是文本文件。如果文件不存在,那么我们包含的'w'将导致文件被创建。这不是危险的部分。危险的部分是当文件已经存在时会发生什么。在这种情况下,'w'会清除其内容,给我们一个空文件,我们可以开始写入。现在,如果我们的程序运行正确,并且只在我们final_dir中这样做,那么我们就没问题。但这是我们让程序运行之前需要仔细验证的。
我们强烈建议您首先在一个您不关心的文件小目录上测试。此外,我们建议将使用 'w' 或 'wb' 打开文件的代码行更改为打印一条无害的输出消息,这样您就可以确切地看到哪些文件将被覆盖或创建。例如,在我们的程序中,我们需要注释掉这两行:
相反,我们将使用print打印出我们本应创建或覆盖的文件:
然后,当您运行程序时,您将看到程序打算写入的文件名。如果输出看起来不错——也就是说,程序正在对您想要的文件进行操作——那么您就可以取消注释实际执行工作的代码。
练习谨慎,并且始终备份您的重要文件!
列表 9.3 中的程序最后一行假设报告目录被称为reports,封面页目录被称为covers,最终.pdf 文件应该放入的目录被称为final。
现在,创建final目录。它应该与您的reports和covers目录一起存在。
代码的整体结构对我们来说很有希望:它获取了一份.pdf 报告的列表,然后,对于每一个,它将那些页面与封面页合并。它使用for循环遍历报告的页面,这是好的,因为它可以通过这种方式抓取所有页面。相比之下,它没有在封面.pdf 文件上使用for循环,这同样很好,因为我们知道封面页只有一页。
然而,它给出的第一行代码看起来像是在一个名为os的模块中使用了一个名为listdir的函数。还有其他一些行也使用了这个模块。我们需要导入这个os模块吗?实际上,我们需要!我们可以通过运行代码来证明这一点。如果您运行代码,您将得到一个错误:
我们需要在程序开始处添加import os来修复这个问题。更新的代码在下面的列表中。
尽管如此,我们还没有走出困境。我们的更新程序仍然无法工作。当我们运行程序时,我们得到了以下错误:
我们遇到了一个问题,Copilot 认为,“嘿,让我们使用PdfFileReader,因为我被训练过这是 PyPDF2 的一部分”,但是在 Copilot 被训练和我们现在写作的时间之间,PyPDF2 的维护者已经移除了PdfFileReader,并用其他东西(根据错误信息的最后一行,是PdfReader)替换了它。这个差异可能在你阅读这本书的时候已经被修复了,但我们想假装它仍然有问题,这样我们就可以教你如果将来这种情况发生在你身上应该怎么做。到目前为止,我们有三种选择:
安装 PyPDF2 的早期版本。 错误信息的最后两行告诉我们,我们需要从 PyPDF2 中获取的函数PdfFileReader在 PyPDF2 3.0.0 版本中被移除了。因此,如果我们安装一个低于 3.0.0 版本的 PyPDF2,我们应该能够恢复我们的函数。一般来说,安装库的早期版本是不推荐的,因为这些版本可能存在安全漏洞,这些漏洞在较新版本中已经被修复。此外,旧版本中可能存在一些后来被修复的 bug。值得谷歌一下最近库中发生了什么变化,以确定使用旧版本是否安全。在这种情况下,我们已经完成了这项作业,并看到使用 PyPDF2 的旧版本没有明显的风险。
自己修复代码,使用错误信息中的建议。 也就是说,我们会将PdfFileReader替换为PdfReader,然后再次运行程序。在这种情况下,我们会被告知其他弃用的情况,我们需要按照相同的过程修复它们。PyPDF2 的作者在错误信息中告诉我们该怎么做是非常好的。为了练习,你可能喜欢通过这个,按照错误信息中提出的每个更新进行操作。我们希望所有的错误信息都这么有用,但这种情况并不总是如此。有时,一个函数会被移除,而不给我们任何回旋的余地。在这种情况下,考虑我们的下一个选项可能更容易。
使用不同的库。 之前,我们向 Copilot 询问了其他可能的.pdf Python 库,我们收到了很多建议。如果这里的头两个选项不满意,我们可以尝试其中之一。
我们将展示如何使用第一个选项(使用 PyPDF2 的早期版本)和第三个选项(使用完全不同的库)来解决问题,并让我们的代码运行起来。
当使用pip install来安装 Python 库时,默认情况下,我们会得到库的最新版本。这通常是我们想要的——最新和最好的,但也可以明确请求库的旧版本。
在这里,我们需要 PyPDF2 的一个低于 3.0.0 版本的版本。而不是使用 pip 的标准用法,
我们可以使用
< 3.0.0是我们用来请求小于 3.0.0 版本的最新的库版本。该命令应该产生如下所示的输出:
现在我们已经安装了 PyPDF2 的较旧版本。到这一点,你应该能够运行我们的程序。这可能需要几秒钟,然后工作就会完成。你可以检查你的最终目录,看看是否有新创建的.pdf 文件。打开几个文件,以确保我们的程序已经正确操作:每个.pdf 文件都应该从封面页开始,并包含报告的所有页面(如果报告有多页,不仅仅是第一页)。
想想如果手动操作会有多麻烦,我们已经让计算机帮我们完成了这项工作。此外,下次我们在工作中需要做同样的事情(为更多的人制作更多报告)时,我们已经有一个工具可以快速帮助我们完成。
现在我们尝试使用另一个库(Copilot 在我们关于.pdf 库的初始对话中提到的一个库)通过 PyPDF2 来解决我们的问题。
我们不会详细向您介绍我们如何决定尝试下一个库的细节,但我们会概述我们的旅程亮点。我们首先询问 Copilot Chat 如何使用 ReportLab Python 模块来合并 PDF 文件,但这又让我们回到了使用 PyPDF2。对于 FPDF,我们也得到了同样的回应:我们被告知 FPDF 不能合并 PDF 文件,但 PyPDF2 可以。然后我们转向询问 PyMuPDF,这次对话是有成效的:
LPHuman 我如何使用 Python PyMuPDF 库来合并.pdf 文件?
你可以使用 PyMuPDF 修改此代码,以向合并的 PDF 添加任何其他内容。只需创建一个新的 Page 对象,将内容添加到页面,然后使用 insert_page() 方法将页面插入到 pdf_writer 对象中。
此处提供的示例代码使用 insert_pdf 将每个 .pdf 文件添加到合并的 .pdf 文件中。我们应该留意我们使用 insert_pdf 的代码。这个库叫做 PyMuPDF,所以我们需要使用以下命令来安装它:
但然后我们必须使用 import fitz?为什么使用 import fitz 而不是 import PyMuPDF?显然,这个库的设计者更喜欢 fitz 而不是 PyMuPDF,所以我们将使用它。这确实很少见,但偶尔我们会使用一个名称来安装库,另一个名称来导入它!
如果 Python 给你一个关于你试图导入的库的 NameError,那么很可能你没有成功导入那个库。可能是库实际上没有安装,或者你需要使用不同的名称来导入它。如果你发现你安装了库但似乎无法导入它,请使用 Copilot Chat 进行对话。
现在,我们准备生成一些代码。以下列表显示了我们所使用的提示和 Copilot 给出的代码。
这个程序恰好以一些代码开始,这些代码会在输出目录不存在的情况下创建它。之后,步骤与我们在之前使用 PyPDF2 的程序中使用的步骤相似,尽管每个程序调用的函数不同。(这是有道理的,因为 PyPDF2 和 PyMuPDF 是不同的库,具有不同的功能和函数。)在两种情况下,我们都在使用 listdir 来获取报告 .pdf 文件名列表。在 for 循环中,我们遍历这些报告;循环中的代码负责创建一个新的 .pdf 文件,其中包含封面后跟报告。在我们的 PyPDF2 代码中,有一个嵌套的 for 循环,我们需要遍历报告的所有页面。在我们的当前程序中,我们不需要这样做,因为 Copilot 使用了 insert_pdf 函数,该函数一次(而不是逐页)将 .pdf 文件插入到另一个 .pdf 文件中。无论你选择安装较旧的库还是选择使用不同的库,我们都解决了问题,并自动化了原本可能是一项令人不愉快的繁琐任务。
注意,我们已经稍微修改了上一章中描述的工作流程,以考虑到处理可能帮助你完成任务的不同 Python 模块。图 9.2 提供了一个修改后的工作流程。
现在假设你在手机上拍了大量照片。你的伴侣(或兄弟姐妹、父母或孩子)也在他们的手机上拍了大量照片。你们每个人都有数百或数千张照片!有时你会给伴侣发送照片,他们也会给你发送照片,这样你和伴侣就拥有了一些但不是全部的照片。
然后,你有了这样一个想法:“如果我们把我的手机上的所有照片和你的手机上的所有照片都合并在一起,创建一个包含所有照片的合并库!那么我们就会有一个地方可以找到所有的照片!”记住,你们两个的手机可能都有数百张照片,所以手动做这件事是不可能的。我们将自动化这个过程!
为了更精确地指定我们的任务,我们将说我们有两个图片目录(将每个目录想象成手机的存储内容),我们希望将它们合并到一个新的目录中。图片的常见文件格式是.png 文件,所以我们将在这里处理这些文件。你的实际手机可能使用.jpg 文件而不是.png 文件,但不用担心。如果你喜欢,你可以将我们在这里做的事情适应到那种图片文件格式(或任何其他图片文件格式)。
在本书的资源中,在 ch9 目录下,你可以找到两个图片子目录。这些子目录被命名为 pictures1 和 pictures2。你可以想象 pictures1 包含你手机上的照片(98 张照片)和 pictures2 包含你伴侣手机上的照片(112 张照片)。我们将把这两个手机目录合并到一个新的目录中。
以与你在电脑上打开图片或照片相同的方式打开一些.png 文件。我们生成的图片只是随机形状,但我们在这里编写的程序将适用于图片中的任何内容。
我们在这里假设你已经在电脑上有了两个图片目录。这些目录可能来自任何地方,我们在这里所做的一切都与手机无关。
但如果你真的想用手机上的图片来完成这个任务,你需要首先将这些图片上传到电脑上。你可以使用 ChatGPT 来进行这个讨论!由于这些细节与不同的手机有关,并且超出了我们想要使用 Copilot 自动化的范围,所以我们在这里不会进一步讨论。
我们的工具将从 pictures1 和 pictures2 中创建一个图片的合并目录,但不会包含重复的图片。如果我们合并的原始图片目录中有相同的图片多次,我们只想保留其中一张图片的副本在我们的新目录中。
到目前为止,我们可能想知道至少两件事:我们如何使用 Python 复制文件,以及我们如何知道两个文件是否是同一张图片?让我们先询问 Copilot Chat 我们如何复制文件:
LPHuman 我需要编写一个 Python 程序来复制文件。我该如何做?
这将把文件从源路径复制到目标路径。如果目标文件已存在,它将被覆盖。
很好。我们可以使用那个 shutil 模块来复制文件;我们需要记住导入这个模块。Copilot 给出的最后一句是一个重要的警告。我们应该小心避免覆盖已经存在的文件。接下来,让我们确定这个 shutil 模块是否是 Python 内置的。
LPHuman 这个 shutil 模块是 Python 内置的吗?
你可能会想知道,为什么我们专注于在新的目录中复制文件而不是将它们从原始目录移动到新目录。原因是我们想保持原始目录不变,以防我们有一个 bug 并且未能将所有文件都复制到新目录中。如果新目录中发生任何问题,那没关系,因为我们的原始目录仍然完好无损。
这就结束了我们需要了解的关于复制文件的知识。现在让我们继续学习如何确定两个文件是否是同一张图片。
如我们之前所说,仅使用文件名来确定图片是否相同是不够的。那么我们该如何做呢?
LPHuman 使用 Python,我该如何确定.png 文件是否相同?
这段代码将比较两个文件的 内容,如果它们相同则返回True,如果不同则返回False。
作为一个小插曲,Copilot 的最后一句是错误的。那段代码在任何地方都没有返回True或False。就像 Copilot 可以在我们的 Python 文件中为我们生成错误的代码一样,Copilot Chat 也可以产生错误的解释。
我们的提示并没有精确说明我们需要比较的文件数量。我们是想比较两个文件吗?还是几百个文件?我们的提示没有说。Copilot 告诉我们如果想要比较两个文件应该怎么做,这是合理的。但这并不是我们需要的。我们需要能够在几百个文件中确定哪些文件是其他文件的副本。这样,我们就可以检测到重复文件,并且不会将其复制到新目录中。让我们再试一次。
LPHuman 如果我有一百多个.png 文件,并且我想找到重复的文件,你给我的那种方法仍然有效吗?
此代码将遍历目录中的所有文件,并将目录中的每个.png文件与每个其他.png文件进行比较。如果两个文件相同,它将打印一条消息,指出它们是重复的。请注意,对于包含大量文件的目录,这种方法可能很慢。
Copilot 在这里使用了两个其他模块,os 和 filecmp。我们可以询问 Copilot 这些是否是内置的 Python 模块,但我们将节省一点时间,在这里直接告诉您它们是内置的。
您可能会想要求 Copilot 提供一个更好的解决方案,一个对于“对于包含大量文件的目录来说可能很慢”的解决方案。但许多程序员不会这样做。在我们尝试我们的(未优化的、显然慢的)方法之前,优化我们的解决方案通常是一个错误,原因有两个。首先,也许我们的“慢”程序最终足够快!我们不妨试试。其次,更优化的程序通常是更复杂的程序,它们可能更难正确实现。这并不总是这种情况,但它可能是。而且,如果我们的未优化程序完成了工作,我们甚至不必担心更优化的版本。
现在,如果我们的程序真的太慢,或者你发现自己反复使用这个程序,那么继续与 Copilot 合作以获得更快的解决方案可能值得额外的投资。不过,目前来说,我们做得很好。
这个任务比我们之前两个任务要复杂一些。一方面,我们需要小心不要覆盖我们新目录中已经存在的文件。另一方面,我们需要确定首先需要复制哪些文件(记住我们只想复制那些在新目录中不匹配的文件)。这与我们刚刚完成的.pdf 合并任务形成对比,在那个任务中我们没有这些额外的担忧。
为了达到这个目的,我们将在这里使用自顶向下设计和问题分解。不用担心,这不会是一个像我们在第七章中做的完全自顶向下的设计示例。我们这里的任务比第七章中的拼写建议任务要小得多。我们只需进行一点自顶向下的设计,这将帮助 Copilot 为我们提供我们想要的结果。
我们的顶级函数将负责解决我们的整体任务:将 pictures1 和 pictures2 目录中的所有独特图片放入目标目录。在第三章,我们学习了我们应该尽可能使函数通用,以便使它们更有用或更易于推广到其他任务。在这里,我们一直在考虑合并两个图片目录。但为什么不是 3、5 或 50 个目录呢?谁在乎我们有多少个目录;我们应该能够合并我们想要的任意多个目录。
因此,我们不会设计我们的顶级函数以接受两个字符串(目录名)作为参数,我们将让函数接受一个字符串列表。这样,我们就可以用它来处理我们想要的任意多个图片目录。而且,我们仍然可以轻松地用它来处理两个图片目录——我们只需传递一个包含两个目录名称的列表。
我们将命名我们的顶级函数为make_copies。我们需要两个参数:我们刚才讨论的目录名称列表,以及我们想要所有文件都放入的目标目录的名称。
这个函数将要做什么?它将遍历目录列表中的每个目录,然后,对于每个目录,它将遍历每个文件。对于每个文件,我们需要确定是否复制它,如果需要复制,则执行实际的复制操作。
确定是否复制文件,然后可能复制它,这是一个可以从make_copies中分离出来的子任务。我们将为这个子任务命名函数为make_copy。我们的make_copy函数将接受两个参数:文件的名称和目标目录。如果文件与目标目录中的任何文件都不相同,那么该函数将把文件复制到目标目录中。
生成一个唯一的文件名是我们可以从make_copy函数中分离出来的任务。我们将称它为get_good_filename。它将接受一个文件名作为参数,并返回一个不存在的文件名版本。
有了这些,我们的自顶向下设计就完成了。图 9.4 描绘了我们的工作作为一个树(至少是树的树干),显示了哪个函数被哪个其他函数调用。
这次我们没有需要安装的模块。我们知道从我们的 Copilot 对话中,我们将使用内置的 shutil 模块来复制文件。我们还将使用内置的 filecmp 模块来比较文件,以及内置的 os 模块来获取目录中的文件列表。因此,我们将在 Python 程序的顶部导入这三个模块。
正如第七章所述,我们将从函数树的底部开始解决问题,逐步向上工作。我们这样做是为了当 Copilot 为父函数编写代码时,可以调用我们已编写的函数。对于每个函数,我们提供def行和文档字符串,然后 Copilot 编写代码。我们还提供了一些注释来解释代码的工作原理。
再次查看图 9.4,我们看到我们需要实现的第一项功能是get_good_filename。现在让我们在下面的列表中完成这个功能。
下一个我们需要编写的函数是make_copy。这个函数将文件复制到目标目录,但前提是文件与我们之前复制的文件不相同。我们希望 Copilot 在其代码中完成以下几件事情:
调用我们刚刚编写的get_good_filename函数。
下面的列表显示了我们的提示和 Copilot 提供的代码。请注意,代码正在做我们希望它做的事情。
只剩下最后一个函数,那就是我们的顶级make_copies函数。对于我们的每个图片目录中的每个文件,我们期望代码调用make_copy来复制文件(如果需要的话),如下面的列表所示。
Copilot 在make_copies函数下面的最后一行代码假设我们的目标目录将被命名为 pictures_combined。现在创建该目录,以便它位于你的图片 1 和图片 2 目录旁边。
正如我们在本章前面处理.pdf 文件时讨论的那样,你首先在你不关心的样本目录上测试程序是很重要的。你的样本目录中应该只有几个文件,这样你可以手动确定程序是否按预期工作。你还应该包括重要的边缘情况,例如每个目录中都有相同的文件名。
在仔细检查结果后,如果输出看起来不错,那么你应该只在你的真实目录上运行真正的程序。记住,我们的程序是在复制(而不是移动)文件,所以即使在我们的真实目录中,如果出现问题,有很大可能性问题出在我们的新目录中,而不是我们真正关心的原始目录中。
哦,你的程序在你的电脑上运行了多久?最多几秒钟,对吧?结果是,Copilot 的警告“这种方法对于包含许多文件的大型目录可能会很慢”对我们来说并不重要。
现在,我们都知道人们通常在手机上有成千上万的照片,而不是几百张。如果您在两个真实的手机图片库上运行这个程序,您还需要确定它是否在可接受的时间内完成。您可以运行程序,让它运行一分钟左右,或者您愿意等待多久。为了好玩,我们还测试了我们的程序在总共 10,000 个小图像文件上的运行情况(比我们在本章中使用的图片 1 和图片 2 目录中的 210 张图片更现实的场景),我们发现它只用了 1 分钟就完成了。在某个时候,我们的程序可能会变得太慢而无法实用,那时您就需要使用 Copilot Chat 进行进一步的研究,以得到一个更高效的程序。
在本章中,我们成功地自动化了三个繁琐的任务:清理电子邮件、为数百个.pdf 文件添加封面,以及将多个图片库合并为一个。在每种情况下,方法都是相同的:使用 Copilot Chat 确定要使用哪个模块,然后遵循我们在整本书中磨练的方法,让 Copilot 编写所需的代码。
记住,无论何时您发现自己重复执行相同的任务,尝试使用 Copilot 和 Python 来自动化它都是值得的。除了本章中展示的之外,还有很多有用的 Python 模块可以做到这一点。例如,有用于操作图像、处理 Microsoft Excel 或 Microsoft Word 文件、发送电子邮件、从网站抓取数据等模块。如果是一项繁琐的任务,那么很可能有人已经编写了一个 Python 模块来帮助完成这项任务,并且 Copilot 可以帮助您有效地使用该模块。
您已经有一个工具可以清理电子邮件文本,通过从每行的开头删除任何>或空格字符。您将采取哪些步骤来增强这个工具,使其也能删除过短的行(例如,少于五个字符的行,不包括空格)?
您正在编写一个 Python 程序来清理您存储在电脑上的大量图片,您决定使用 Pillow 库(Python 图像库[PIL]的一个分支)。在安装了 Pillow 的最新版本后,您运行了您的程序,但遇到了以下错误:
您可以采取哪些步骤来解决这个问题?
您的程序应该执行以下操作:
计算所有月份中每个产品的总销售额。
提示:对输入文件做出合理的假设,你可能需要导入库来帮助你处理 .xlsx 文件。如果你没有 Excel 或 OpenOffice 来读取/写入 .xlsx 文件,请随意使用 .csv 文件来完成这项任务。
程序员经常制作工具来自动化繁琐的任务。
通常需要使用 Python 模块来帮助我们编写工具。
我们可以使用 Copilot Chat 来确定我们应该使用哪些 Python 模块。
与 Copilot 对话以了解可能对我们可用的各种 Python 模块的优缺点是有帮助的。
有 Python 模块可以用于处理剪贴板、处理 .pdf 文件和其他文件格式、复制文件等。
在我们的程序中添加随机性
设计和编程一个密码破解逻辑游戏
设计和编程一个“运气”掷骰子游戏
人们学习编程的原因有很多。有些人想要自动化我们在上一章中做的繁琐任务。有些人想要与人工智能(AI;你将在第十一章中尝到一些)一起工作。其他人想要制作交互式网站、Android 或 iOS 应用,或者 Alexa 技能。程序员可以制作的东西是无穷无尽的。
另一个学习编程的流行原因是创建游戏。因此,我们认为我们应该继续我们的 Copilot 编程之旅,与你一起设计两个小型电脑游戏。第一个是一个密码破解游戏,你使用线索来识别电脑的秘密代码。第二个是一个两人掷骰子游戏,每个玩家都需要平衡风险和运气,在另一个玩家之前达到所需的分数。这些游戏不使用图形和动画,而是使用文本。我们做出这个决定是为了帮助我们专注于游戏逻辑,而不是游戏的表现方式或玩家与游戏交互的方式。在这个过程中,如果你对进一步提高你的游戏制作能力感兴趣,我们会提供一些下一步的建议。而且不用担心,你现在的技能是迈向那个目标的良好开端!
如果你考虑与家人或朋友玩桌面游戏,你可以将发生的事情分解为两个主要阶段。第一个阶段是游戏设置。这包括设置游戏板,给每个玩家提供起始资金或卡片等。第二个阶段是游戏进行。在桌面游戏中,游戏通常包括一个人轮流,假设还没有人获胜,另一个人轮流。随着每一轮的进行,游戏状态(板,玩家的资金等)都会发生变化。在编程电脑游戏时,我们需要注意实现这些阶段中的每一个。
在许多针对视频游戏设计的编程环境中,这两个阶段通常被作为独立的常规函数。有一个设置函数用于设置游戏,还有一个更新函数用于改变游戏状态,这可能是因为玩家采取了行动,或者是因为时间已经过去。图 10.1 显示了视频游戏的基本流程。
让我们暂时关注一下游戏的更新部分。假设玩家可以按键盘上的按钮在 2D 游戏中向前移动。更新函数将使玩家的角色向前移动,并检查玩家的移动是否使玩家与游戏世界中的某个物体发生了交互。一些游戏也会根据时间改变状态,而不管玩家的动作如何。在这些游戏中,更新函数也会更新游戏本身的状态(例如,在 Pac-Man 游戏中移动幽灵)。在每个时间步或用户动作结束时,游戏将检查结束条件。结束条件通常包括以下内容:
玩家通过完成目标赢得了游戏。
玩家因从悬崖上掉下来、撞到怪物等原因输掉了游戏。
玩家选择退出游戏。
有许多编程环境针对游戏设计,我们推荐 Python 的 pygame 模块 [1] 作为良好的起点。如果您继续追求游戏设计,我们建议您查看 Unity [2] 以进行 3D 游戏设计。然而,在本章中,我们将仅使用标准 Python 来向您展示如何设计一个基本游戏,而无需深入这些新环境。
许多游戏,包括我们在本章中将要构建的两个游戏,都依赖于随机性来使它们变得有趣。在我们的第一个游戏中,我们需要计算机生成我们将要推断的数字的随机数字。在第二个游戏中,我们需要掷骰子,这仅仅相当于生成介于 1 和 6 之间的随机数。敌人的行为、谜题的初始方向或攻击造成的伤害,即随机性,通常涉及游戏设计的所有这些和其他许多方面。
因此,我们需要能够在我们的 Python 程序中生成随机数。为了了解如何做到这一点,让我们使用 Copilot Chat,就像我们在上一章中所做的那样。
LPHuman 我需要在我的 Python 程序中生成随机数。我该如何操作?
此代码导入 random 模块,然后使用 randint 函数生成一个介于 1 和 10(包含)之间的随机整数,并使用 random 函数生成一个介于 0 和 1 之间的随机浮点数。
Copilot 建议使用名为 random 的模块中的函数来生成随机数。让我们检查一下 random 模块是否是 Python 内置的。
LPHuman 这个随机模块是 Python 内置的吗?
我们将遵循 Copilot 的建议导入这个模块来测试我们刚刚学到的两个函数。我们将在 Python >>>提示符下这样做,这是我们之前章节用来探索 Python 功能的地方。要进入 Python 提示符,请按 Ctrl-Shift-P,输入REPL,然后选择 Python: Start REPL。
根据 Copilot 对我们第一个问题的回答,我们知道我们可以使用randint函数在从最低允许数值到最高允许数值的范围内生成一个随机整数。我们可能会使用这个函数来生成可以组合成更大数字的单独的数字。每个数字在 0 到 9 之间,因此我们将请求randint函数生成 0 到 9 之间的随机数:
或者,我们可能想要生成骰子的点数。每次投掷的点数在 1 到 6 之间,因此我们可以请求randint函数生成 1 到 6 之间的随机数:
我们可以想象这个函数在游戏中也很有用。例如,你可以将这些浮点值视为事件发生的概率,数值越高表示概率越高。然后你可以使用这些浮点值来确定事件是否应该发生。然而,在本章的游戏中,我们不需要这个函数。
我们的第一个游戏将基于一个古老的密码破解游戏,叫做猜数字游戏。这可能会让你想起 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 中,您会得到关于您每个字母的线索。例如,如果您的猜测的第一个字母是 h,您可能会被告知 h 在单词中,但位置不正确。相比之下,在我们的游戏中,您不会得到关于每个数字的单独线索,而是会得到关于您的猜测的整体线索。尽管如此,我们希望您会被这些相似之处以及您正在构建的东西与最近全球现象的游戏相似之处所吸引!
在表 10.1 中,我们提供了一个与游戏的交互示例。我们包括了一个注释列,以传达我们的思考和从每个猜测中学到的知识。
游戏的挑战在于你必须在有限的猜测次数内成功猜出计算机的秘密代码。在我们表 10.1 中的例子中,我们用了七个猜测来猜出代码 2675。对于每个猜测,我们都得到了数字放错的数量和数字正确的数量来指导我们的思考。
在我们刚才提到的游戏的免费版本中,你不允许在猜测中包含相同的数字多次。例如,猜测 1231 是不允许的,因为有两个 1。在我们的游戏版本中,我们也将保持这一限制。
我们的整体任务是编写一个程序来与计算机玩 Bulls and Cows 游戏。让我们对这个大任务进行自顶向下的设计,就像我们在第七章和第九章中所做的那样。在这个游戏中,必须发生什么?回答这个问题将帮助我们把这个游戏分解成更小的任务。为了帮助我们,我们思考了游戏的规则和我们的例子,并思考了游戏每个步骤会发生什么。这些高级步骤都出现在图 10.2 中,所以让我们逐一分解它们。
我们将从设置开始。为了能够玩游戏,电脑必须随机生成一个秘密代码。我们需要确保这个秘密代码没有重复的数字。对我们来说,这听起来像是足够复杂且自成一体的东西,因此它应该是一个独立的子任务函数。
电脑生成其秘密代码后,我们可以进入游戏本身。这里是玩家开始猜测的地方。我们可能会认为我们可以简单地使用input来询问玩家的猜测,从而避免为这个任务创建一个单独的函数。但我们需要确保玩家输入了正确的数字数量,并且他们的猜测中没有重复的数字。这超出了我们用一个input调用的能力,所以我们将把这个任务也作为一个单独的函数来处理。
一旦玩家做出有效的猜测,我们需要弄清楚两件事:有多少数字是正确的,有多少数字是错误的?我们应该有一个函数来完成这两个任务吗?或者,也许我们应该有两个函数,一个用于正确信息,一个用于错误信息?我们在两边都看到了很好的论据。如果我们把任务合并到同一个函数中,我们可以把玩家反馈集中在一个地方,这可能使我们更容易确认它是否正确编写。另一方面,有两个独立的函数会使测试每种类型的反馈(正确或错误)变得更容易,但代价是分散了反馈逻辑到两个函数中。我们在这里多少有些任意地选择使用一个函数,但如果你希望有两个独立的函数,我们鼓励你在完成本节内容后自己尝试一下。
让我们盘点一下。我们有一个生成电脑秘密代码的函数。我们有一个获取玩家下一个猜测的函数。我们有一个获取玩家猜测的正确/错误提示的函数。这些都是我们乐意从顶层函数中分离出来的三个主要子任务。
是否还有其他可以分离的子任务?在我们的顶层函数中确实还有更多的工作要做。例如,我们需要检测玩家的猜测是否与秘密代码匹配,并在这种情况下结束游戏。尽管如此,我们觉得我们不需要为这个任务单独创建一个函数。为了确定用户的猜测是否等于秘密代码,我们可以使用 Python 的==运算符,它直接告诉我们两个值是否相等。而为了结束游戏,我们可以使用return语句来结束顶层游戏函数,从而停止程序。同样,如果玩家用完了所有的猜测而没有猜出秘密代码,那么我们需要告诉他们他们输了游戏,但同样,我们可以用一小段 Python 代码来完成这个任务。因此,我们将在这里停止,我们的主要顶层函数调用三个子任务函数。
当我们在第七章中处理拼写建议问题时,我们需要将我们的一个子任务分割成子子任务。但在这里,我们的三个子任务中的每一个都可以作为一个单独的函数来管理。
例如,让我们再次思考我们的第一个子任务:生成计算机的秘密代码,不允许有重复的数字。我们能否从这个地方分割出任何子子任务?也许我们可以有一个函数来检查一个建议的秘密代码中是否有任何重复的数字。然后,我们可以继续生成秘密代码,调用我们的子子任务函数,直到它告诉我们没有重复。这会有效,但我们也可以逐个生成秘密代码的数字,并从一开始就不允许将重复的数字添加到代码中。这个后者的计划似乎不需要分割出任何子子任务。
现在我们来考虑我们的第二个子任务:获取玩家的下一个猜测。我们可以将一个子子任务分割出来,告诉我们一个猜测是否有效(即,它有正确的长度且没有重复)。虽然我们当然可以这样做,但在子任务函数本身进行几个检查就足够了。(你的思绪是否回到了第七章中的例子,关于有效密码和检测有效密码,我们将有效性检查分割成自己的函数?如果是这样,区别在于检查密码是否有效可能是一个比我们这里需要的有效性检查更实质性的任务。)当然,我们可以将其分割成另一个子子任务,但我们不会这样做。我们已经论证了我们的第三个子任务本身是可行的,所以我们的自顶向下设计就到这里为止。
我们将命名我们的顶级函数为play。在其中,我们将调用三个函数,分别对应我们刚刚确定的三个子任务。我们将我们的第一个子任务(生成计算机的秘密代码)的函数命名为random_string,第二个子任务(获取玩家的下一个猜测)的函数命名为get_guess,第三个子任务(向玩家提供反馈)的函数命名为guess_result。请参见图 10.3,了解这种自顶向下设计以树状图的形式表示。
通常,我们在自顶向下的设计过程中本身定义每个函数的参数类型和返回值,但这次我们想在这里单独讨论这一点,因为有一些细微的方面。例如,你可能已经在想象我们将使用整数来表示秘密代码和猜测;但是,正如我们很快就会看到的,这并不是最佳选择。在我们为每个函数编写之前,我们将就如何表示所有函数的数据做出一些决定。
*play函数是我们顶级函数,也是我们游戏的起点。这个函数可以没有任何参数。在函数的某个地方,我们不得不硬编码秘密代码有四个数字以及玩家有,比如说,10 次猜测的事实。但这不会很灵活。如果我们想玩一个秘密代码有七个数字且玩家有 100 次猜测的游戏版本,我们就必须进入代码并做出所有必要的更改。因此,为了使游戏易于配置,我们可以向这个函数提供一些参数。例如,而不是总是让秘密代码是四位数字,我们可以使用一个参数来允许秘密代码的长度设置为任何我们想要的。同样,而不是直接将玩家猜测的最大次数放入函数中,我们也可以将其作为一个参数,这样我们就可以轻松地更改它。然后,要改变游戏玩法,我们只需要用这些参数的不同值调用这个函数,而不必修改函数本身的代码。
允许猜测的次数和秘密代码中的数字位数是我们可以用来解释代码设计中一个重要原则的好例子。这个原则是,当我们编写代码时,如果一个数字可以是参数或变量,它应该是。这个原则确保代码尽可能灵活。当程序员看到使用的是数字而不是友好的名称时,他们称之为“魔法数字”,这正是我们想要避免的。在我们讨论玩家获得的猜测次数或秘密代码的位数时,如果我们遵守这个原则,那些应该是参数。在某个时候,这些参数需要给出具体的数字以便代码能够工作,但我们应该尽可能在代码的最高级别分配它们的值(例如,玩家可能在游戏开始时设置这些参数)。
为了帮助遵守这一基本原则,每当你在代码中看到原始数字(例如,4)时,问问自己这能否是一个参数或变量。通常情况下,它应该是。
根据我们在第三章中的讨论,添加这些参数是另一个例子,说明如何使函数通用而不是不必要的限制。我们的random_string函数是生成计算机秘密代码的函数。为什么我们在函数名中放string?我们不应该返回一个像 1862 这样的随机整数吗?字符串与这个有什么关系?
嗯,返回整数的问题在于秘密代码可能以 0 开头。像 0825 这样的秘密代码是一个完全有效的四位秘密代码。但 0825 作为一个整数是 825,这没有足够的数字。字符串'0825'只是恰好每个都是数字的四个字符,所以以'0'开头没有问题。
除了这些,让我们提前思考我们最终需要与计算机的秘密代码做什么。我们需要逐位比较它与玩家的猜测,以确定哪些数字匹配。使用字符串的索引将使我们能够轻松访问字符串中的每个字符,这正是我们所需要的。访问整数的每个单独的数字更具挑战性:整数不支持索引!
因此,我们的random_string函数将接受秘密代码所需数字的数量作为参数,并返回一个相同长度的随机字符串,其中字符串中的每个字符都是数字字符。当我们谈论字符串中的数字时,我们指的是字符串的字符。它们碰巧是数字字符,但它们仍然是字符,就像'a'或'*'一样。不要被字符串看起来像数字的事实所迷惑!以下是一个示例,说明这些字符串与其他字符串以相同的方式工作:
那么get_guess函数呢,它是用来从玩家那里获取下一个猜测的?与random_string一样,这个函数需要知道有效猜测的数字数量,所以我们将它作为一个参数。它将返回一个字符串,给出玩家的猜测。
最后,让我们谈谈guess_result函数,这个函数告诉我们有多少位数字是正确的,有多少位是放错了位置的。这个函数将需要玩家的猜测字符串和计算机的秘密代码字符串来进行比较,因此我们需要这个函数接受这两个参数。我们需要返回两份信息——正确的数字数量和放错位置的数字数量——所以我们将返回一个包含两个整数的列表。
完成了我们的自顶向下设计后,我们现在可以与 Copilot 一起编写我们每个函数的代码。一如既往,我们将从下到上编写函数。这意味着我们首先将实现我们的三个子任务函数,然后最终实现我们的顶级play函数。
像往常一样,当与 Copilot 一起生成代码时,我们提供def行和文档字符串,然后 Copilot 编写代码。在每一份代码列表中,我们还会提供一些关于代码如何工作的指导。
在本章中,我们并没有专注于测试,即使我们是在测试,我们也不会在这个函数的文档字符串中包含精确的测试。这是因为这个函数的结果是随机的。随机性可能会使测试函数变得困难,因为很难控制你得到的随机值。我们可以做的是在我们的文档字符串中添加一个测试,至少检查生成的秘密代码包含正确的字符数,没有重复的字符,并且每个字符都是有效的数字。你也可以通过运行这些函数并观察它们的结果始终在你的随机范围内,并且结果看起来是随机的来测试这样的函数。
如果你坚持进行测试,你会发现我们对这个函数的实现非常完美,没有必要做更多。如果你只想让游戏工作,你可以跳到开始编写get_guess函数的地方。如果你想了解更多关于 Python 和函数设计的信息,我们想利用这个机会花更多的时间来查看这个函数的不同解决方案。
如果你想了解更多关于这段代码的功能,你拥有相应的工具去做!不要假设代码超出了你的理解范围。你可以让 Copilot 解释代码。你可能会得到以下这样的解释。
LPHuman 我的random_string函数做什么?
函数首先使用range函数和list构造函数创建一个从 0 到 9 的数字列表。然后使用 random 模块中的shuffle函数打乱列表。这确保了数字的随机顺序。
最后,该函数使用列表推导式将打乱列表的前length个数字转换为字符串,并使用join方法将它们连接成一个单一的字符串。
总体来说,random_string函数生成一个没有重复数字的随机数字字符串,这对于生成唯一的标识符或密码很有用。
什么是列表推导式?你不需要自己解决这个问题,你可以询问 Copilot 来解释它。
LPHuman 这个列表推导式是如何工作的?
下面是列表推导式是如何工作的分解:
str(d) for d in digits[:length]是生成字符串列表的表达式。它将digits列表前length个元素中的每个数字转换为字符串,使用str函数,并返回结果字符串。
digits[:length]是列表推导式操作的迭代器。它取digits列表的前length个元素,这些元素已经被随机打乱。
整个列表推导式被括号包围,它从表达式生成的元素中创建了一个新列表。
最后,在生成的字符串列表上调用join方法,将它们连接成一个没有分隔符的单个字符串。
Copilot 的一些文本可能不完全易懂,但你可以继续询问它解释你不确定的部分,直到你满意地理解了它是如何工作的。
我们不需要展示这个函数的第二个工作解决方案,但我们要借此机会提醒你已经走了多远。随着你通过本书的进展,你一直在开发工具和技术来理解和创建软件,以及学习和持续学习的信心!回到我们手头的这个问题,让我们编写我们的下一个函数。
我们的get_guess函数接受所需数字的数量作为参数,并从玩家那里返回一个有效的猜测。记住,为了使猜测有效,它必须包含正确的数字数量,并且不能有任何重复的数字。以下是我们从 Copilot 获取的以下列表中的内容。
那个 while-loop 条件是复杂的!最后一部分,len(set(guess)) != length,负责检查用户是否输入了任何重复的数字。它是使用 Python 集合来做到这一点的。我们确实在第七章的 7.7.7 节中看到过这个技巧,但那是很久以前的事情了!所以,如果你需要复习,不要犹豫,向 Copilot 请求解释那一行。
我们的 guess_result 函数接受两个字符串作为参数:玩家的最新猜测和秘密代码。它返回一个包含两个整数的列表:正确数字的数量和错误数字的数量。
这是一个难以正确实现的函数,因此尽管我们之前没有在先前的函数中展示我们想要运行的测试,但我们将在该函数的文档字符串中提供一些明确的测试。我们添加这些文档字符串测试是因为这个函数的逻辑将足够复杂,以至于仅通过阅读代码很难确定代码是否正确。对于我们的文档字符串测试,我们需要一些不同秘密代码和用户猜测的示例案例,以确保函数返回的正确和错误数字的数量符合预期。当然,根据第六章,如果你对进行严肃的测试感兴趣,你可能还想添加更多的测试用例。我们的第一个测试用例是
这里的正确返回值是 [1, 2],因为有一个数字是正确的(8),还有两个错误的数字(2 和 1)。我们的第二个测试用例是
这次的正确返回值是 [0, 4],因为没有正确的数字,但猜测中的所有四个数字都是错误的。以下是我们的完整文档字符串,包括测试用例和 Copilot 的代码。
在这里使用 elif 是微妙的。如果它被写成 if 而不是 elif,那么代码就会出错!你明白为什么吗?如果不明白,在继续阅读这里的解释之前,试着与 Copilot 进行对话。
假设 if 条件 guess[i] == secret_code[i] 是 True。在这种情况下,我们将 correct 增加 1,并跳过 elif 语句。(记住,只有当前面的 if 和任何前面的 elif 条件为 False 时,elif 语句才会运行。)
现在,想象一下,如果我们把elif改为if。如果if条件guess[i] == secret_code[i]为True,我们仍然会增加correct的值。但然后,我们会检查guess[i] in secret_code条件,这个条件也会是True。毕竟,我们刚刚说过guess[i] == secret_code[i],这证明了guess[i]确实在secret_code中某个位置。所以,我们会错误地执行misplaced += 1,而这绝对是我们不希望做的(它是一个匹配的数字,而不是一个错误的数字)!
我们已经完成了子任务函数!现在我们只需要编写play顶级函数。
我们的play函数接受两个整数作为参数:秘密代码中的数字数量和猜测次数,以及玩家需要猜测秘密代码的猜测次数。它不返回任何内容——它只是玩游戏!我们这个函数的最终提示和代码如下所示。
如果我们现在运行程序,什么也不会发生。那是因为我们还没有调用play函数!我们总是需要记得调用我们的顶级函数。要调用它,我们可以在play函数下方添加这一行:
数字4表示我们正在使用四位数代码和猜测,而数字10表示我们有 10 次机会正确猜测秘密代码。你可以更改这些参数来调整你喜欢的游戏。
在我们继续之前,让我们试玩一下我们的游戏。我们将展示我们的前几个猜测,然后是我们的最终猜测:
我们已经成功设计了一个计算机游戏,这与其他我们在本书中编写的程序非常不同。我们的计算机游戏与用户互动,具有随机性,涉及两个玩家(人类玩家猜测和计算机玩家提供正确/错误提示),并且有胜负条件。这有很多新内容!然而,我们也希望你能看到我们从前面的章节中学到了多少,并取得了哪些进步。我们仍在进行自顶向下的设计,仍在设计函数,仍在测试代码,仍在阅读代码,并且仍在与 Copilot 进行对话。在写这一章之前,你可能没有编写过游戏,但本质上,你已经具备了编写游戏所需的技能。不要让看似新的程序或应用类型阻止你尝试去做。
你可能会对我们的游戏看起来不像你最近玩过的游戏而感到失望,因为没有图形,只有文本。例如,没有好的输入区域,没有可以点击的按钮,也没有任何图形界面。我们已经在本章中讨论了为什么我们专注于文本游戏,但这并不意味着将这个游戏转换成图形界面版本完全超出了你的能力范围,因为你可以通过与 Copilot 交互来查看它是否可以帮助你开始。
挑战在于程序员使用一种称为事件驱动编程的编程风格来创建图形用户界面,这是我们还没有教过你的,而且在这个章节中也无法详细讲解。你可能会阅读事件驱动编程的代码并对其有所理解,如果你要求 Copilot 编写代码来生成图形界面,你将需要这样做。然而,如果 Copilot 没有给你你想要的结果,在没有首先熟悉事件驱动编程的情况下,可能很难修复。如果你想要了解更多,有一本关于游戏编程的好书,它包括使用 Python 的图形用户界面和事件驱动编程,书名为用 Python 发明你自己的电脑游戏 [3]。
事件驱动编程通常用于与用户交互的程序。在较高层次上,程序设置了一种用户与程序交互的方式(例如,通过按按钮或输入文本),然后只是坐着等待用户做些什么,有时在等待用户输入的同时更新游戏状态。一旦人类与游戏交互,代码就会将这种交互识别为事件,每个事件都与执行时应该运行的代码相关联。例如,当用户点击退出游戏按钮时,它会触发当用户想要退出时需要执行的代码(例如,保存游戏状态并退出程序)。
当我们要求 Copilot 为 Bulls and Cows 创建图形界面时,我们对它给出的结果印象深刻,所以我们想展示它做得有多好。即使不了解事件驱动编程,代码也相对容易阅读。例如,你可能可以找到游戏的标题并更改它。为此,我们向 Copilot Chat 提出了以下问题:
LPHuman 能否将这个程序转换为使用图形界面而不是文本界面?
Copilot 通过逐步列出我们需要做出的更改来回应我们。然后它重写了代码,使游戏现在使用带有按钮和编辑框的图形界面,而不是文本界面。有关新界面的详细信息,请参阅图 10.4。生成此界面的代码可以在本书的网站上找到,如果你有兴趣运行它或了解代码的话。
我们的第二个游戏将是一款两人掷骰子游戏。在 Bulls and Cows 中,我们的一个玩家是人类,另一个是电脑。这次,我们将为两个玩家编写游戏。随机性再次将扮演关键角色。完成后,您可以和朋友或家人玩!
*### 10.4.1 游戏的工作原理
我们将要编写的游戏被称为 Bogart,由 Crab Fragment Labs 设计。这是一款两人掷骰子游戏。游戏还使用筹码或硬币。显然,我们不需要实际的骰子或筹码,因为我们正在将其作为电脑游戏实现。
当游戏开始时,筹码堆是空的(里面没有筹码)。两个玩家中的一个被随机选中开始游戏。从那里,每个玩家轮流进行,直到游戏结束。我们首先解释玩家如何进行回合,然后给出游戏结束的规则。图 10.5 还提供了游戏流程的概述。
在玩家回合开始时,一枚筹码被加入筹码堆,该玩家掷一枚骰子。如果那枚骰子是 1,那么玩家的回合就结束了,他们不会收集任何筹码。如果那枚骰子不是 1,那么玩家可以决定是否继续他们的回合。如果他们决定不继续回合,他们将收集筹码堆中的所有筹码(使筹码堆为空)。
如果他们决定继续他们的回合,那么将有两枚筹码加入筹码堆,然后他们掷两枚骰子。如果其中一枚或两枚骰子是 1,那么玩家的回合就结束了。否则,玩家再次决定是否继续他们的回合。
如果他们继续他们的回合,那么他们掷三枚骰子,然后掷四枚骰子,然后掷五枚骰子。(玩家不能选择跳过一定数量的骰子;他们必须从一枚骰子开始依次掷出所有骰子。)如果他们掷出了 1,那么他们的回合结束,他们不会收集任何筹码。如果他们决定不继续回合,那么他们将从筹码堆中收集所有筹码。
在这个游戏中掷出 1 点对当前玩家来说是个坏消息。掷一枚骰子时,掷出 1 点的概率很低,只有 1/6(大约 17%)。掷两枚骰子时,至少掷出一个 1 点的概率更高,这次大约是 31%。掷三枚骰子时(42%),掷四枚骰子时(52%),或者掷五枚骰子时(60%),概率更高。所以,你延长回合的时间越长,最终掷出 1 点并一无所获的概率就越高。更糟糕的是,如果你比对方更保守地玩,你可能会给对方留下一个满满的赌注。同时,你延长回合的时间越长,赌注中的筹码积累得就越快,假设你最终成功结束回合,你可以收集的筹码就越多。整个技巧就是决定何时冒险获取更多筹码,何时收手。
这就是玩家回合的工作方式。玩家轮流进行回合,掷骰子并收集筹码,直到游戏结束。游戏有三种结束方式:
如果一名玩家总共收集到 30 枚或更多的筹码,那么这名玩家就赢了。
假设一名玩家在他们的回合中掷出一枚骰子,然后两枚,接着三枚,然后四枚,最后五枚,一次也没有掷出 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 点。
正如我们在实现我们的牛牛游戏时的情况一样,实现博加特是一个大问题,我们需要自顶向下的设计。我们现在将提供这个设计,但在这里我们鼓励你在继续之前自己尝试一下。我们推荐这样做是因为我们发现,由于交互元素的众多,这个游戏的自顶向下设计非常微妙。例如,玩家的回合可以以三种方式结束:收集筹码、不收集筹码,以及立即获胜。我们需要能够确定这些情况中的哪一种发生了。作为另一个例子,在每个玩家的回合之后,我们需要切换到另一个玩家——但不幸的是,并不总是这样:如果一个玩家赢得了游戏,我们希望在那一刻停止游戏并宣布他们为胜者,而不是切换到另一个玩家!我们将主要关注我们的成功自顶向下设计,但偶尔我们会解释我们为什么做出某些决定,以及其他决定为什么出错。
我们将命名我们的顶级函数为play。以下是我们需要解决游戏的主要子任务:
初始化筹码池,并将玩家 1 和玩家 2 的筹码数设为零。这是游戏设置阶段的一部分。
随机选择玩家 1 或玩家 2 开始游戏。这同样是游戏设置的一部分。
现在我们进入游戏阶段。只要游戏没有结束,就执行以下操作:
打印筹码池中的筹码数,玩家 1 的筹码数,以及玩家 2 的筹码数。
为当前玩家转动一圈。
如果当前玩家赢得了筹码,就将筹码给当前玩家,并将筹码池重置为零筹码。
切换到另一个玩家的回合。
打印获胜游戏的玩家(玩家 1 或玩家 2)的名字。
我们将有一个while循环,只要游戏没有结束就会继续,所以我们需要一个函数来告诉我们游戏是否结束!这个函数如何知道游戏是否结束?它需要知道当前玩家 1 和玩家 2 的筹码数。这样,它可以检查这些数中的任何一个是否至少为 30。但记住,游戏还有另一种结束方式,那就是当一个玩家掷出五颗骰子,且没有一颗是 1 时。因此,这个函数还需要知道当前玩家的最近一次骰子滚动情况。
我们将把这个函数命名为game_over。它将接受三个参数:玩家 1 的筹码、玩家 2 的筹码和骰子点数列表。如果游戏结束,则返回True,否则返回False。这个函数的代码需要检查几个条件,但我们应该能够做到这一点,而无需将其拆分成更小的子任务。
我们将把这个函数命名为take_full_turn。这个函数需要知道现在筹码堆中有多少筹码,以便根据需要更新它。它还需要返回更新后的筹码堆中的筹码数量。除此之外,我们还需要管理很多事情才能完成一个完整的回合,因此我们需要控制这个函数的复杂性。以下是这个函数可能需要做的事情:
允许玩家掷一个骰子,然后掷两个骰子,然后掷三个骰子,依此类推,直到玩家的回合结束。
根据本轮发生的情况更新当前玩家的筹码数量。我们可以添加一个额外的返回值来将此更新信息传达给调用此函数的人。
判断游戏是否结束。我们可以在返回值中添加一个额外的值,其中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 的回应),这将增加足够的复杂性,以至于我们可能会将其拆分出来。
当玩家的回合结束时,我们会得到新筹码数量和结束回合的最终骰子点数列表。我们需要确定玩家是否获得了这些筹码。(如果最终的点数列表中不包含 1,则玩家收集筹码;否则,他们不收集,筹码留在筹码堆中。)
我们将拆分出一个函数来告诉我们玩家是否收集了筹码。我们将将其命名为wins_chips。这个函数将接受最近的点数列表作为参数,如果玩家赢得了筹码则返回True,否则返回False。
我们将把这个函数命名为switch_player。假设当前玩家刚刚完成他们的回合。我们需要切换到另一个玩家的回合,但前提是游戏还没有结束。我们的函数将封装这个逻辑。它需要调用game_over来确定游戏是否结束,因此我们需要至少三个参数给switch_player:玩家 1 的筹码,玩家 2 的筹码,以及最近的骰子滚动列表。我们还需要一个参数来指示当前玩家(1 或 2),这样我们就可以返回下一个新当前玩家的编号。因此,该函数将接受这四个参数,并返回一个 1 或 2,表示我们将切换到的下一个玩家。
如果游戏结束,这个函数不会做任何事情。如果游戏没有结束,那么我们需要将 1 改为 2 或将 2 改为 1。我们已经有game_over函数,这足以分解这个函数。
我们已经完成了自顶向下的设计!查看图 10.6 以了解我们的函数树。
现在是时候与 Copilot 一起编写我们每个函数的代码了。一如既往,我们将从叶函数开始,逐步向上(在图中向左),只有在所有其他函数都已实现后,才实现顶级play函数。我们可以从任何叶函数开始。我们将从game_over函数开始,因为它既需要play,也需要switch_player。然后,我们将继续处理其他函数。
这个函数接受三个参数:玩家 1 拥有的筹码数量,玩家 2 拥有的筹码数量,以及最近的骰子滚动列表。如果游戏结束,则返回True,如果没有结束,则返回False,如下所示。
记住,游戏有三种结束方式:玩家 1 至少有 30 个筹码,玩家 2 至少有 30 个筹码,或者某个玩家在最后一轮中成功掷出五个骰子而没有掷出 1。
你可能期望看到代码是这样的,使用if-else和实际的return True和return False行:
这会工作,但更常见的是程序员直接使用return语句与True/False表达式。这是因为表达式的结果是返回的内容。如果表达式为真,则返回True,如果表达式为假,则返回False。这与if-else版本做的是完全相同的事情!
除了返回滚动列表外,如果此函数还能打印出每次掷骰子的结果,那就更有帮助了。这样,玩家就可以看到他们确切掷出了什么。在文档字符串中,我们要求 Copilot 打印滚动结果(除了返回滚动列表之外)。
此函数接受最近的滚动列表,并使用该列表确定当前玩家的回合是否结束。如果回合结束,则返回 True,如果没有结束,则返回 False,如以下列表所示。
玩家的回合结束有两种方式。第一种是当滚动列表中出现 1 时。第二种是当玩家掷了五颗骰子。
len(rolls) == 5 真的足够使回合结束吗?我们不是还要检查是否掷出了任何 1 吗?不,如果玩家掷了五颗骰子,那么无论骰子结果如何,他们的回合就结束了。如果他们掷出了任何 1,那么回合就结束了(因为他们掷出了 1,并且不会收集任何筹码)。如果他们没有掷出任何 1,那么回合仍然结束(在这种情况下,他们将自动赢得游戏)。
现在我们已经准备好 take_full_turn,如列表 10.9 所示。此函数接受当前赌注中筹码的数量作为参数。它将处理当前玩家的完整回合的所有滚动,然后返回两个值:赌注中新的筹码数量和最终的滚动列表。
此函数接受一个滚动列表作为参数。如果滚动结果良好(即不包含 1),则玩家将收集筹码。如果滚动结果包含任何 1,则玩家不会收集筹码。此函数在以下列表中返回 True 如果玩家可以收集筹码,否则返回 False。
这个函数接受四个参数:玩家 1 拥有的筹码数量、玩家 2 拥有的筹码数量、当前玩家最近掷出的骰子列表(由当前玩家掷出)以及当前玩家的编号。如果游戏尚未结束,则该函数返回另一个玩家的编号。如果游戏结束,则该函数返回当前玩家(因为另一个玩家没有回合!)。请参见以下列表。
我们已经到达了play函数!我们决定不添加任何参数来编写这个函数。你可能想知道我们是否可以通过添加一个参数来使代码更灵活,比如为赢得所需的筹码数量(目前为 30)添加一个参数。你当然可以这样做;我们在这里没有这样做是为了保持代码中参数数量的较小。这个函数不返回任何内容。相反,它玩游戏,如下面的列表所示。
我们现在拥有了游戏所需的所有代码。只需添加
在你现有的所有代码下方添加调用我们的play函数,然后你就可以玩游戏了!
我们对 Copilot 给出的结果很满意,游戏当然可以玩。但是,诚实地讲,与玩家的互动可以更友好一些。例如,当我们运行游戏并多次按下 y 时,游戏可能会这样开始:
筹码池筹码:0
玩家 1 筹码:0
玩家 2 筹码:0
玩家 2 回合
Continue? (y/n) y
Continue? (y/n) y
筹码池筹码:6
玩家 1 筹码:0
玩家 2 筹码:0
玩家 1 回合
继续吗?(y/n)
没有欢迎信息。像 4、5、2 这样的数字只是在那里,没有任何上下文说明它们的意义。游戏会询问我们是否想要“继续?(y/n)”。继续什么?这并不清楚。
我们可以通过在我们的函数中增加print调用来使与玩家的互动更加愉快。我们只需自己将想要的内容放入每个print语句中即可。为什么不直接使用 Copilot 呢?这样做可能比试图说服 Copilot 打印出我们想要的确切内容要容易得多。例如,下面的列表显示了play的新版本,我们在其中添加了更多的print调用,以解释正在发生的事情,并更好地格式化游戏输出。
我们鼓励你在其他地方也添加print调用,以使游戏交互更加出色!例如,在roll_dice中,你可以添加一个print调用来告诉玩家骰子即将被掷出(以下输出中的ROLLS)。在take_full_turn中,你可以添加一个print调用来在询问玩家是否想继续他们的回合之前打印出筹码池中的筹码数量。以下是我们在做出这些更改后与我们的游戏交互的示例:
欢迎来到 Bogart!
筹码池筹码:0
玩家 1 筹码:0
玩家 2 筹码:0
玩家 2 回合
ROLLS
筹码池目前有 1 个筹码。
继续回合?(y/n) y
ROLLS
筹码池目前有 3 个筹码。
继续回合?(y/n) y
ROLLS
-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=
筹码池筹码:6
玩家 1 筹码:0
玩家 2 筹码:0
玩家 1 回合
ROLLS
筹码池目前有 7 个筹码。
继续回合?(y/n)
也尝试对你的代码做出这样的修改。让你的游戏变得独一无二!
在本章中,我们使用 Copilot 帮助我们编写了两个电脑游戏:一个逻辑游戏(类似于 Wordle,但使用数字),和一个两人掷骰子游戏。我们能够做到这一点,得益于我们在整本书中学到的技能,包括问题分解、编写清晰的 docstrings 以及与 Copilot Chat 交互。
将本章中的get_guess函数修改为包含错误信息,引导玩家输入有效的猜测。确保这些信息清晰且有帮助。发挥创意!以下是一些示例:
“错误:猜测必须只包含数字。”
“错误:猜测不得包含重复的数字。”
在你的游戏中,玩家应该继续选择数字,直到揭示出其中一个奖项的所有数字;那就是玩家赢得的奖项。
游戏有一个常见的程序流程,包括游戏设置和游戏玩法。
随机性是许多游戏的重要成分。
我们可以通过使用 random 模块中的函数来为我们的 Python 游戏添加随机性。
我们可以使用与本书中相同的流程实现游戏,其中问题分解起着关键作用。*****
使用自顶向下设计编写作者身份识别程序
了解代码重构以及为什么你会这样做
在第七章中,我们在编写拼写建议程序时学习了问题分解和自顶向下的设计。在这里,我们将把自顶向下的设计提升到下一个层次,解决一个更大的问题。我们仍然在做第七章中同样的工作:将问题分解为子问题,并在需要时进一步将那些子问题分解为更小的子子问题。而且,就像之前一样,我们希望设计出具有少量参数且能返回对调用者有意义的和有用的结果的函数。如果我们能够设计出被多个其他函数调用的函数,那也是一个好兆头——这有助于减少代码重复!
我们包括这一章节,是因为我们希望提供一个比第七章中解决的拼写建议问题更真实的例子。我们希望这里的例子能够激励人心,感觉像是一个你可能会想要解决的问题。
在本章中,我们将编写一个尝试识别神秘书籍未知作者的程序。这将是使用人工智能(AI)进行预测的程序的一个例子。我们无法抗拒在关于编程与 AI 的书籍中包含一个 AI 例子的机会!
这个问题基于我们同事米歇尔·克雷格[1]创建的作业。让我们先看看这两段书籍摘录:
摘录 1——我还没有向你描述最独特的一部分。大约六年前——确切地说,是在 1882 年 5 月 4 日——泰晤士报上出现了一则广告,要求提供玛丽·莫斯坦小姐的地址,并表示她出面将对她有利。广告中没有附上姓名或地址。当时我刚刚以家庭教师身份加入了塞西尔·福雷斯特夫人的家庭。在她的建议下,我在广告栏中发布了我的地址。同一天,通过邮局寄来一个小纸板箱,上面写着我收,我发现里面装有一颗非常大的、光泽的珍珠。里面没有附上任何文字。从那时起,每年的同一天,总会出现类似的箱子,里面装着类似的珍珠,没有任何关于发送者的线索。专家们认为这些珍珠是罕见品种,价值不菲。你们自己可以看到,它们非常漂亮。
摘录 2——那是 11 月底的一个周五夜晚,多佛路就在与这个历史有关的第一批人之前。对于他来说,多佛路位于多佛邮件之外,因为邮件车笨拙地爬上射手山。他和其他乘客一样沿着邮件车旁边的泥泞小路上山;并不是因为他们在这种情况下对步行锻炼有丝毫的兴趣,而是因为山丘、马具、泥泞和邮件车都如此沉重,以至于马匹已经三次停下来,而且一次还把马车拉到路上,意图把它拉回布莱克希思。然而,缰绳、鞭子、车夫和守卫联合起来,读到了那条禁止有强烈支持论点的文章,即某些动物被赋予了理性;于是马队屈服了,并返回了它们的职责。
假设我们问你这两段摘录是否可能是由同一作者写的。你可能做出的一个合理假设是,不同的作者有不同的写作风格,这些差异会在我们可以计算其文本的指标中体现出来。
例如,与第二段摘录相比,似乎撰写第一段摘录的人使用了相当多的短句。我们在第一段摘录中发现了像“没有附上名字或地址”和“没有附上任何文字”这样的短句;这些句子在第二段中不存在。同样,第一段摘录中的句子似乎比第二段中的句子简单;看看第二段中所有的逗号和分号。
这种分析可能会让你认为这些文本是由不同的作者写的,确实如此。第一段是由亚瑟·柯南·道尔爵士写的,第二段是由查尔斯·狄更斯写的。
公平地说,我们绝对是有选择性地挑选了这两段摘录。道尔确实使用了一些长而复杂的句子。狄更斯确实使用了一些简短的句子。但是,至少对于我们从中摘录这两段摘录的两本书来说,道尔的句子平均来说比狄更斯的短。更普遍地说,如果我们比较两位不同作者所写的两本书,我们可能会期望在平均意义上找到一些可以量化的差异。
假设我们有一系列我们知道作者的书。我们有一本道尔写的,一本狄更斯写的,等等。然后,出现了一本神秘的书。哦不!我们不知道是谁写的!这是道尔的失落《福尔摩斯探案故事》?狄更斯的失落《雾都孤儿》续集?我们想知道这位未知作者是谁,为了做到这一点,我们将转向一种基本的 AI 技术。
我们的策略将是为每位作者找到一个 签名,使用我们知道他们写过的其中一本书。我们将把这些签名称为 已知签名。每个这样的签名都将捕捉关于书籍文本的指标,例如每句话的平均单词数和平均句子复杂度。然后,我们将为未知作者的神秘书籍找到一个签名。我们将称之为 未知签名。我们将查看所有已知签名,将每个签名与我们的未知签名进行比较。我们将使用最接近的一个作为我们对未知作者的猜测。
当然,我们并不知道这个未知的作者是否真的是我们拥有的签名作者之一。它可能是一个全新的作者,例如。即使这个未知的作者确实是我们拥有的签名作者之一,我们仍然可能猜错。毕竟,也许同一个作者以不同的风格写作书籍(给他们的书籍带来非常不同的签名),或者我们未能捕捉到我们每位作者写作中最显著的特点。实际上,在本章中,我们并不是追求一个行业级的作者识别程序。尽管如此,考虑到这个任务的难度,我们认为我们将向您展示的方法效果非常好。
正如我们在这里所做的那样,作者识别是一个 机器学习(ML) 任务。机器学习是人工智能的一个分支,旨在帮助计算机“学习”数据以便做出预测。机器学习有多种形式;我们在这里使用的是监督学习。在监督学习中,我们可以访问训练数据,它由对象及其已知的类别(或标签)组成。在我们的案例中,我们的对象是书籍文本,每本书的类别是写这本书的作者。我们可以通过计算每本书的特征(例如,每句话的平均单词数、平均句子复杂度等)来在训练集上训练(即学习)。后来,当我们得到一本我们不知道作者的书时,我们可以使用我们在训练中学到的知识来做出预测(或猜测)。
好的,我们想要“编写一个程序来确定一本书的作者。”这似乎是一个艰巨的任务,如果我们试图一次性完成,使用一个单独的函数,确实会是这样。但就像在第七章中我们的拼写建议示例中做的那样,我们不会这么做。我们将系统地把这个问题分解成我们可以解决的子问题。
在第七章中,我们通过读取输入、处理输入并产生输出结果的方式来解决拼写建议问题。我们可以把我们的作者识别程序看作也是遵循这个模型:
输入步骤 — 对于输入步骤,我们需要询问用户神秘书籍的文件名。
过程步骤 — 对于过程步骤,我们需要确定神秘书籍的签名(这是未知签名),以及我们知道作者每本书的签名(这些是已知签名)。为每本书创建签名通常被称为机器学习中的训练阶段。我们还需要将未知签名与每个已知签名进行比较,以确定哪个已知签名最接近。这些比较是机器学习中的预测阶段。
输出步骤 — 对于输出步骤,我们需要向用户报告最接近已知签名的未知签名。
也就是说,为了解决我们的整体作者识别问题,我们需要解决这三个子问题。我们开始自顶向下的设计!
我们将顶层函数命名为make_guess。在其中,我们将解决我们确定的三个子问题。
对于输入步骤,我们只是要求用户输入一个文件名。这可以通过少量代码完成,所以我们可能不需要为这个步骤创建一个单独的函数。输出步骤看起来类似:假设我们已经知道哪个已知签名最接近,我们可以直接向用户报告。相比之下,过程步骤看起来需要做很多工作,我们当然希望进一步分解这个子问题。这就是我们接下来要做的。
我们将整体过程函数命名为process_data。它将接受神秘书籍的文件名和已知作者书籍目录的名称作为参数,并返回最接近的已知签名的名称。
观察我们对该过程步骤的描述,似乎我们在这里有三个子问题需要解决:
确定神秘书籍的签名。这是我们未知的签名。我们将这个函数命名为make_signature。
确定已知作者每本书的签名。这些是我们的已知签名。我们将这个函数命名为get_all_signatures。
将未知签名与每个已知签名进行比较,以确定哪个已知签名最接近。因为接近的签名会有小的差异,我们将这个函数命名为lowest_score。
我们将依次为这些子问题制定自顶向下的设计方案。图 11.1 展示了我们目前的设计图。
这个任务的函数make_signature将接受我们书籍的文本作为参数,并返回书籍的签名。在这个阶段,我们需要决定我们将使用哪些特征来确定每本书的签名。让我们通过回顾之前的例子段落来分析这个问题。我们注意到,根据句子的复杂性和长度,作者之间的段落存在差异。你可能也怀疑过,作者在使用的单词长度和用词方式上可能存在差异(例如,一些作者可能比其他作者更重复)。因此,我们希望有一些特征基于作者句子的结构,而其他特征基于作者使用的单词。我们将详细探讨这些特征。
在我们之前的道尔对狄更斯例子中,我们讨论了使用每句话的平均单词数作为一个特征。我们可以通过将总单词数除以总句子数来计算这个值。例如,考虑以下文本:
就在同一天,通过邮局寄来一个小纸板箱,上面写着我的名字,我发现里面有一个非常巨大而光亮的珍珠。没有附上任何字条。
如果你计算单词和句子,你应该会发现有 32 个单词(纸板算作一个单词)和两个句子,因此我们将计算每句话的平均单词数为 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。这将是平均单词长度的特征。
第二,可能有些作者比其他作者更频繁地使用相同的单词。为了捕捉这一点,我们将使用作者使用的不同单词的数量除以总单词数。对于我们之前的样本文本,只有七个不同的单词被使用:一个,珍珠,光泽的,稀有的,什么,好的,和找到的。总共有十个单词,所以这个指标的计算结果是 7/10 = 0.7。这将是不同单词除以总单词数的特征。
第三,可能有些作者倾向于一次性使用很多单词,而其他作者则倾向于多次使用相同的单词。为了计算这个指标,我们将使用一次的单词数量除以总单词数。对于我们的样本文本,有五个单词是只使用了一次:光泽的,稀有的,什么,好的,和找到的。总共有十个单词,所以这个指标的计算结果是 5/10 = 0.5。这将是使用一次的单词数除以总单词数的特征。
总的来说,我们有五个特征将构成每个签名。我们需要将这些数字存储在一个单独的值中,所以我们将为每个签名使用五个数字的列表。
让我们深入了解我们将如何实现每个特征,从单词级特征开始,然后到句子级特征。我们将按以下顺序进行:
平均单词长度
不同单词除以总单词数
使用一次的单词数除以总单词数
每句平均单词数
平均句子复杂度
对于每一个,我们最终将编写一个函数。我们有一个更新后的图,其中包含每个这五个新函数的函数名,这将帮助我们实现图 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。这是有用的一般用途函数的标志!我们的自顶向下的设计进展顺利。我们可以看到clean_word函数如何在图 11.3 中更新的功能图中被两个函数调用。
然而,这里有一个额外的复杂性,涉及到像pearl、Pearl和PEARL这样的单词。我们希望将这些视为相同的单词,但如果我们简单地使用字符串比较,它们将被视为不同的单词。这里的解决方案之一是将这个子问题分离出来,将其转换为全小写的字符串。我们也可以将其视为清理单词的另一个部分,与去除标点符号一样。我们将选择第二个选项。那么,我们将使我们的clean_word函数不仅去除标点符号,还将单词转换为小写。
你可能会想知道我们是否需要在这里拆分另一个子任务,一个确定不同单词数量的子任务。你可以这样做,这样做并不错误。然而,如果我们坚持不这样做,我们会发现函数在没有这个额外子任务的情况下仍然相当易于管理。随着时间的推移,实践和经验将帮助你预测何时需要进一步分解任务。
这个任务的函数exactly_once_to_total将接受书籍的文本作为参数,并返回一次使用的单词数除以总单词数。我们还需要在这里使用clean_word函数,原因与我们在前两个任务中需要它的原因类似:确保我们只处理字母,而不是标点符号。同样,虽然我们可以拆分一个子任务来确定一次使用的单词数量,但我们会发现用 Python 代码做这件事并不需要太多,所以我们不会进一步拆分这个任务。
这个任务的函数average_sentence_length将接受书籍的文本作为参数,并返回每句的平均单词数。为了将我们的文本拆分为单词以进行前三个任务,我们可以使用字符串拆分方法。我们如何将文本拆分为句子?有没有一个字符串方法可以做到这一点?
很遗憾,没有。因此,将任务拆分为将文本字符串拆分成句子的任务将是有帮助的。我们将为这个子任务命名函数为get_sentences。get_sentences函数将接受书籍的文本作为参数,并从文本中返回一个句子列表。
什么是句子?我们将句子定义为由句号(.)、问号(?)或感叹号(!)分隔的文本。这个规则虽然方便且简单,但会犯错误。例如,这个文本中有多少个句子?
在那时,我只是以家庭教师的身份加入了塞西尔·福雷斯特夫人的家庭。
答案是一。然而,我们的程序将提取两个句子,而不是一个。它会被单词Mrs.欺骗,该单词在结尾处有一个句号。如果你继续在本章之后进行作者识别,你可以尝试使你的规则更加健壮,或者使用复杂的高级自然语言处理(NLP)软件来做得更好。然而,对于我们来说,我们将满足于这个有时会出错但大多数时候会正确的规则。如果我们偶尔出错,这些错误对我们的指标的影响将不会很大。
我们将为这个任务命名的函数是average_sentence_complexity。它将接受句子的文本作为参数,并返回句子复杂度的度量。
正如我们之前讨论的,我们感兴趣的是使用句子中的短语数量来量化句子复杂性。就像我们使用标点符号来分隔句子一样,我们将使用不同的标点符号来分隔短语。具体来说,我们将说短语是由逗号(,)、分号(;)或冒号(:)分隔的。
有一个子任务来将句子分解成短语,就像我们有一个子任务来将文本分解成句子一样,那会很棒!我们将把这个子任务命名为 get_phrases。get_phrases 函数将接受一本书的句子作为参数,并返回句子中的短语列表。
让我们暂停一下,思考一下我们正在用 get_sentences 和 get_phrases 函数做什么。回想起来,它们非常相似。它们之间的区别仅在于它们用来进行分割的字符。get_sentences 关注的是句号、问号和感叹号,而 get_phrases 关注的是逗号、分号和冒号。我们看到一个机会,可以创建一个父任务来简化这两个任务!
也就是说,想象一下我们有一个名为 split_string 的函数,它接受两个参数,即文本和分隔符字符的字符串,并返回由任何分隔符分隔的文本片段列表。然后我们可以用 '.?!' 来分割成句子,用 ',;:' 来分割成短语。这将使 get_sentences 和 get_phrases 的实现更加容易,并减少代码重复。这是一个胜利!
到目前为止,我们已经完全实现了支持高级函数 make_signature 所需的所有函数,如图 11.4 所示。接下来,我们将转向 get_all_signatures 函数。
我们刚刚努力将 make_signature 函数分解为五个主要任务,每个任务对应于我们签名的一个特性。我们设计该函数是为了确定未知签名——即试图识别的神秘文本的签名。
令人惊讶的是,我们解决这个问题的实际工作量远小于表面上看起来那么多。这是因为我们可以使用那个相同的 make_signature 函数,即我们为确定神秘书籍的签名而设计的函数,来同样确定任何已知书籍的签名!
我们将把这个任务的函数命名为get_all_signatures。这个函数接受一本书的文本作为参数是没有意义的,因为它应该能够获取我们所有已知书籍的签名。相反,它将接受一个已知书籍的目录作为参数。它的行为将是遍历该目录中的文件,计算每个文件的签名。
我们需要一个函数来告诉我们哪个签名对应哪本书。换句话说,我们需要它将每本书与其对应的签名关联起来。这种关联正是 Python 有字典的原因!因此,我们将让这个函数返回一个字典,其中键是文件名,值是对应的签名。我们的函数图不需要任何新的函数来支持get_all_signatures函数,所以图 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。
记住我们在自顶向下的设计中的位置:我们需要一个函数来告诉我们哪个已知签名是最好的。现在我们知道如何比较两个签名并得到该比较的分数。我们希望将未知签名与每个已知签名进行比较,以确定哪个已知签名最好。分数越低,签名越接近;分数越高,签名越不同。因此,我们最终会选择比较分数最低的签名。
我们将为这个任务命名的函数为lowest_score。它将接受三个参数:一个将作者名字映射到他们已知签名的字典、一个未知签名和权重列表。该函数将返回与我们的未知签名比较分数最低的签名。
考虑这个函数需要完成的工作。它需要遍历已知的签名。我们可以用for循环来完成这个任务——不需要子任务。它需要将未知签名与当前已知签名进行比较。哦!那是一个子任务,体现了我们在表 11.1 中概述的评分机制。我们将为这个子任务命名的函数为get_score。我们的get_score函数将接受两个要比较的签名和权重列表,并返回这两个签名之间的比较分数。
我们做到了!我们已经将原始的大问题分解成几个更小的问题,这些问题可以作为一个函数来实现。
图 11.6 展示了我们在分解问题的过程中所做的工作。记住,我们从一个make_guess函数开始,这个函数将解决整体问题。为了帮助make_guess,我们创建了一个process_data函数,它将为make_guess做一些工作。为了帮助process_data,我们又创建了三个更多函数,make_signature、get_all_signatures和lowest_score,每个函数都有自己的辅助函数,等等。在勾勒出解决我们问题的所需函数后,我们的下一步将是实现它们。
现在我们已经准备好让 Copilot 实现我们需要的每个函数。我们设计函数的方式是从上到下——从最大的问题开始,逐步细化到更小的问题。但请记住,从第七章我们知道这不是实现函数的顺序;相反,我们是从下到上(或如图 11.6 所示从右到左)实现函数的。
我们将从clean_word函数开始。像往常一样,我们提供函数头(def行)和文档字符串,并让 Copilot 填写代码。我们还提供了一些注释,简要说明代码的工作原理。
记住,我们希望clean_word函数能够移除可能出现在单词周围的标点符号,并将单词转换为小写。但我们不希望干扰单词中间的标点符号,比如card-board中的“-”。我们已经编写了文档字符串来清楚地说明我们的需求。
当我们在第三章处理密码函数时,我们看到了 Copilot 使用字符串模块,在这里我们又看到了 Copilot 在这样做。我们知道从第三章的工作中,除非我们首先导入字符串,否则这不会起作用,所以添加
上述函数如以下列表所示。
这完成了 clean_word 函数,因此我们可以在图 11.7 的函数图中标记为完成。
现在我们来处理我们五个标志性功能函数中的第一个:average_word_length。它需要确定每个单词的平均字母数,但我们不想将周围的标点符号算作字母,也不包括没有字母的单词。我们想在这里使用 clean_word 函数,如以下列表所示。和往常一样,我们编写了文档字符串,希望它能指导 Copilot 做出这些决定。
你会注意到在这个 doctest 中,我们将字符串分成了两行,第一行以 \ 字符结束。我们这样做的原因是,如果不这样做,字符串将无法在书的一行中显示。我们还需要保持第二行没有任何缩进;否则,doctest 会将那个缩进作为字符串中的空格。在你的电脑上,你可以将字符串放在一行中输入,不必担心 \ 或缩进。
现在,我们可以在更新的图(图 11.8)中标记 average_word_length 为完成。尽管令人满意,但一个接一个地在图中标记这些可能会有些过于嘈杂,所以我们将定期回顾这个图。
这是我们的第二个标志性功能。我们需要这个功能来计算不同单词的使用次数与总单词数的比例。同样,我们不想包括周围的标点符号或空单词。
这段代码使用 Python 集合而不是 Python 列表来存储它找到的唯一单词。集合与列表类似,但不会接受重复项,所以如果你尝试多次添加相同的单词,它只会保留一个单词的副本。这就是为什么我们可以在返回语句中使用len(unique)来获取唯一单词的总数。
我们准备实现第三个签名功能。这个功能计算使用一次的单词数与总单词数的比值,如下所示。我们向 Copilot 提供了提示,并收到了以下函数。
当我们浏览这段代码时,看起来unique是一个记录唯一单词的集合。最后,我们将唯一单词的数量除以总单词数。
然而,使用 doctest 运行我们的测试用例,你会发现我们的代码没有通过测试。我们需要的结果是 0.5,但遗憾的是,我们的代码给出了 0.6。我们的代码是错误的。我们需要保持警惕!
让我们回顾一下代码,看看为什么它是错误的。想象一下一个单词在文本中使用了三次。第一次我们的代码找到它时,代码会将其添加到unique中。第二次,它会从unique中移除它。到目前为止一切正常,因为这个词不再只使用一次了,所以它不应该在那里。但是第三次,它会将这个词再次添加到unique中!所以,我们的代码将会把一些单词计为只使用了一次,而实际上它们被使用了多次。我们查看了按下 Ctrl-Enter 时出现的列表,并找到了这个替代版本,如下所示,它是正确的。
这段代码有点棘手!要理解它,首先关注else代码。这是第一次看到每个单词时运行的代码。那个单词会被添加到unique和once集合中。once集合将为我们跟踪使用一次的单词。
总结来说,当我们第一次看到某个单词时,它会添加到once中。当我们再次看到它时,它会从once中移除,并且永远无法再次添加到once中。once集合正确地跟踪了只使用过一次的单词。
我们已经完成了三个单词级别的签名特征函数。在我们能够继续到两个句子级别的签名特征函数之前,我们需要编写get_sentences。但为了编写get_sentences,我们首先需要split_string,这正是我们现在要工作的。
我们的split_string函数应该能够根据任意数量的分隔符分割字符串。它本质上与句子或短语无关。我们包含了一个 docstring 测试来强调这一点:尽管我们将使用它来分割句子和短语,但它比这更通用。看看下面的列表。
******#1 更好的变量名应该是 all_strings。
自从我们更新了包含我们已完成函数的图表以来已经有一段时间了。是时候更新了!这也提醒我们,我们是从底部向上(在图表中从右到左)完成函数的。因此,图 11.9 显示了到目前为止我们已完成的函数。
在我们的自顶向下设计中,我们将get_sentences的大部分工作委托给了split_string函数。因此,我们希望 Copilot 通过调用split_string来实施get_sentences。
我们不希望我们的句子开头或结尾有任何空格,也不希望有任何空句子。我们最初在文档字符串中提出了这些要求,但即使没有它们,Copilot 也能正确地调用split_string函数,并且该函数无论如何都会处理这些边缘情况。
现在我们有了get_sentences函数,我们可以继续进行average_sentence_length,这是我们第四个签名特征。这个特征计算每个句子的平均单词数。我们向 Copilot 提供了提示,并收到了以下列表中显示的函数。
就像我们需要get_sentences函数才能实现average_sentence_length一样,我们需要get_phrases函数才能实现average_sentence_complexity。
与get_sentences函数一样,我们期望 Copilot 调用split_string来获取短语。这正是它为我们所做的事情,如下列所示。
在完成get_phrases后,我们现在可以提示实现average_sentence_complexity。代码如下列所示。
**#1 我们将一个句号改为逗号,使其成为 5/4 = 1.25。
到目前为止,我们已经编写了九个函数,虽然它们都很重要,但我们现在可能感到有点不满意,因为我们甚至还没有处理文本签名。我们有一些函数可以清理单词,以各种方式分割字符串,并计算签名的单个特征,但没有一个函数可以创建完整的签名。
现在情况有所改变,因为我们终于准备实现make_signature来为我们提供文本的签名。这个函数将接受一本书的文本,并返回一个包含五个数字的列表,每个数字都是调用我们五个特征函数之一的结果。
#1 我们五个特征函数都被调用了。 ******请注意,这个函数可以简单地通过调用我们五个特征函数来实现。现在停下来思考一下,如果没有先进行良好的自顶向下的设计,这个函数会多么混乱。我们在这里调用的所有五个函数的代码都必须在一个函数中,它们各自的变量和计算混合在一起,形成了一团糟。幸运的是,我们使用了自顶向下的设计!因此,我们的函数更容易阅读,也更容易让我们相信自己正在做正确的事情。
我们的process_data函数有三个子任务需要我们实现。我们刚刚完成了第一个(make_signature),所以现在我们将继续进行其第二个子任务,即我们的get_all_signatures函数。
从现在起,我们假设你的工作目录包含你的代码,并且它还包含我们提供的书籍子目录。我们需要这个函数为我们目录中的每个已知作者文件返回签名。我们希望 Copilot 在这里调用make_signature,使这个函数比其他方式简单得多。
Copilot 确实为我们做了这件事,但我们得到的代码仍然有两个问题。我们的初始代码如下所示。
#1 我们最初为空的字典将文件名映射到签名。
你会得到以下错误:
错误告诉我们,该函数试图使用名为 os 的模块,但我们没有这个模块。这个模块是 Python 内置的,我们知道在这种情况下要做什么:导入它!也就是说,我们需要添加
在此函数上方。之后,我们仍然得到一个错误:
你可能想知道UnicodeDecodeError是什么。如果你对技术解释感兴趣,可以谷歌搜索或向 ChatGPT 提问。我们需要知道的是,我们打开的每个文件都是用特定的方式编码的,而 Python 选择了错误的编码来尝试读取这个文件。
然而,我们可以通过在函数顶部附近添加注释来指导 Copilot 修复它。(当你遇到这些错误时,你可以在生成的错误代码上方直接放置一个注释。然后,一旦你删除了错误的代码,Copilot 通常可以生成新的正确代码。)一旦我们这样做,一切都会好起来,如下所示。
#1 这个提示告诉 Copilot 修复我们之前看到的错误。 现在,如果你运行这个函数,你应该会看到一个作者和他们的签名的字典,如下所示:
为了简单起见,我们没有在这个函数的文档字符串中添加测试。如果我们这样做,我们会创建一个假的、小的书籍,类似于我们在第六章第二个示例中所做的。不过,我们在这里想继续我们的函数分解的整体目的,所以如果你愿意继续这个练习,我们可以留给你。如图 11.11 所示,我们已经完成了两个 process_data 子任务。让我们继续前进!
让我们实现 get_score,我们需要编码比较签名的方式。记得我们之前在每个特征上找到差异,乘以权重,然后将所有这些加在一起得到一个总分?这正是我们希望 get_score 做的。
在文档字符串中解释这个公式将是一个挑战。我们甚至不确定它是否应该放在那里:文档字符串应该解释如何使用你的函数,而不是它内部的工作方式。而且,可以说,我们的函数用户不会关心这个特定的公式。我们可以做的是使用一个通用的文档字符串,不包含我们的特定公式,看看 Copilot 会如何处理它。以下是在以下列表中的内容。
**#1 这些权重 [11, 33, 50, 0.4, 4] 对我们来说效果很好。
我们的 lowest_score 函数最终将汇总我们实现 process_data 所需要的一切。我们刚刚实现的 get_score 函数为我们提供了任何两个签名之间的分数。我们的 lowest_score 函数将针对每个已知签名调用一次 get_score,将未知签名与每个已知签名进行比较。然后,它将返回与未知签名分数最低的已知签名,如下所示。
****#1 在 doctest 中使用变量以使测试本身更容易阅读
我们在下面的列表中process_data函数将接受两个参数:神秘书籍的文件名和已知作者书籍的目录。它将返回我们认为写了神秘书籍的作者。
再次注意我们有多么依赖我们之前的功能。这个极其有用的process_data函数现在实际上不过是一个精心编排的函数调用列表。
要猜测一本书的作者,我们目前需要输入 Python 代码来运行process_data。这对用户来说不是很友好;如果我们能运行程序并让它询问我们想要处理哪个神秘书籍文件,那就太好了。
我们将通过实现make_guess,我们最高级别的函数,来给我们的程序添加最后一笔!这个函数将询问用户神秘书籍的文件名,使用process_data获取最佳猜测,并告诉用户关于这个猜测的信息,如下所示。
这完成了我们图中的所有函数!图 11.12 显示我们已经从底部到顶部检查了图中的每个函数。
如果你将所有代码都放在你的 Python 文件中,你将在文件底部添加以下代码行后能够运行它来猜测神秘书籍的作者:
恭喜!你已经完成了你的第一个真实世界的自顶向下设计。看看我们取得了什么成就——一个任何初学者程序员都应该为之自豪的作者识别程序。你的程序使用 AI 通过使用训练数据中的书籍文本来学习个别作者是如何写作的(他们平均使用较短或较长的单词,平均使用较短或较长的句子等?)。然后,它将这种学习应用到对神秘书籍的预测中,通过确定神秘书籍最接近哪个作者来做出预测——非常酷!我们解决了一个非常困难的问题,我们通过分解问题并让 Copilot 为每个子问题编写代码来做到了这一点。
在人们完成自顶向下的设计之后,他们通常会看到重构代码的机会,这意味着在不改变其行为的前提下,使代码更加整洁或更有组织性。我们可以以几种方式重构我们的程序。例如,你可能会注意到我们许多签名功能函数将字符串拆分成单词,然后忽略空单词。这个任务(从字符串中返回非空单词列表)可以拆分成一个独立的子任务函数,这将进一步简化任何调用它的函数。
我们还可能决定应该将权重传递给process_data函数,而不是在该函数中硬编码权重。然后,权重将在make_guess函数中硬编码,将决策提升到函数层次结构中的更高位置,因此如果需要的话,更容易找到和更改。
还有可能在程序的功能或效率方面进行改进。就功能而言,目前我们的程序只是简单地打印出对神秘书籍作者的最好猜测。但我们对那个猜测一无所知。是否有另一个作者与猜测的作者非常接近?如果是这样,我们可能想知道这一点。更普遍地说,我们可能想知道前几个猜测,而不仅仅是最好的猜测。这样,即使最好的猜测是错误的,我们也有关于作者可能是谁的有用信息。这些都是我们可以添加到程序中的额外功能。
为了提高效率,让我们再次思考那个get_all_signatures函数。这个函数做了很多工作!如果我们已知目录中有五本书,那么它将读取这五个文件并计算每个签名。这有什么大不了的?只有五个文件,而计算机真的很快。但想象一下,如果我们有 100 个文件或 10,000 个文件。可能一次只做所有这些工作是可以接受的,但我们的程序并不是这样做的。实际上,每次我们运行程序来猜测神秘书籍的作者时,它都会运行那个get_all_signatures函数,这意味着每次都要重新创建那些签名。这是一大笔浪费的努力;如果我们可以将这些签名存储在某个地方,以后再也不用计算它们,那将很棒。确实,如果我们为了效率而重新设计代码,第一步就是确保已知文本的签名只计算一次,之后可以重复使用。
这正是像 Copilot 这样的工具所做的事情!OpenAI 只对大量代码语料库训练了 GitHub Copilot 一次。这需要成千上万或数百万的计算机小时。但现在训练完成之后,它可以为我们的代码编写提供帮助,而无需每次都从头开始训练。一次训练然后使用该训练进行许多后续预测的想法是机器学习中的常见范式。
以下哪一项不是本章描述的基于 AI 的作者识别过程中的步骤?
计算神秘书籍的平均词长
将神秘书籍的签名与已知签名进行比较
询问用户神秘书籍的文件名
查找神秘书籍的总页数
建立一个可以根据邮件内容区分垃圾邮件和非垃圾邮件(ham)的分类器。使用诸如词频、某些关键词的存在以及邮件长度等特征。以下是你需要采取的步骤:
收集垃圾邮件和非垃圾邮件的数据集。你可以在网上找到公开可用的数据集,例如 Enron 垃圾邮件数据集。
预处理邮件(移除停用词、标点符号等)。
提取特征(例如,词数、某些单词的存在)。
使用我们的标记数据(监督学习)训练一个分类器。一个简单而有效的分类器选择是朴素贝叶斯分类器(你可以自由使用 Python 库来帮助你)。
使用单独的邮件集测试分类器以检查其准确性。
在这个练习中,你将创建一个简单的基于 n-gram 的文本生成程序。n-gram 是从给定文本或语音样本中连续的n个项的序列。你将使用这些 n-gram 来生成模仿输入文本风格的新的文本。关键思想是构建一个模型,该模型经过训练,知道哪些词通常跟在哪些词后面(例如,“猫吃”是有意义的,“纸巾吃”则没有意义),然后,在可能的选项中随机选择下一个词。如有需要,可以查阅 n-gram 以获取更多信息。以下是你需要采取的步骤:
选择可以加载到 Python 中的输入文本。你可以使用类似“简·奥斯汀的《傲慢与偏见》”的东西。
通过将其转换为小写并删除标点符号来预处理文本。
从输入文本中创建 n-gram。n-gram 是从给定文本中连续的n个项的序列。为了简单起见,在这个例子中我们将使用二元组(n = 2)。
使用生成的 n-gram 来生成新的文本。从一个随机的 n-gram 开始,根据 n-gram 模型不断添加新词,直到达到所需的长度。
随着我们程序复杂性的增加,自顶向下的设计变得越来越重要。
作者识别是猜测神秘书籍作者的过程。
我们可以使用关于单词(例如,平均单词长度)和句子(例如,每句平均单词数)的特征来描述每个已知作者是如何写作的。
机器学习是计算机科学的一个重要领域,研究机器如何从数据中学习并做出预测。
在监督学习中,我们有某些以对象(例如,书籍)及其类别(谁写了每本书)形式存在的训练数据。我们可以从这些数据中学习,以对新对象做出预测。
签名由一系列特征组成,每个对象一个签名。
代码重构意味着改进代码的设计(例如,通过减少代码重复)。************************
使用提示模式编写和解释代码
生成式 AI 工具的当前局限性和未来方向
在这一章的最后,我们希望让你一窥人们目前正在使用生成式 AI 工具(如 GitHub Copilot 和 ChatGPT)的创造性方式。例如,可以使得 Copilot Chat 向你提问,而不是反过来。还可以让 Copilot 扮演不同的角色,以更好地帮助你完成当前的编程任务。我们将简要介绍,目前还不清楚这将成为多少标准做法,但我们想借此机会展示这些新工具的创造力。我们还将讨论一些生成式 AI 工具的当前局限性(你已经在本书中看到了一些!),并分享我们对未来可能性的看法。
在整本书中,我们阐述了为什么直接在 Python 中编程与使用 Copilot 编程相比有着非常不同的体验。我们关注的焦点不是编写代码,而是编写提示并与生成的代码交互,以确定其是否正确,并在需要时进行修复。但伴随着这些差异,没有 Copilot 的编码与有 Copilot 的编码之间也存在令人惊讶的相似之处。
当程序员编写代码时,他们不会每次编写新程序时都从头开始。研究人员和程序员已经创建了设计模式的目录,即组织程序以使其更容易编写、调试和扩展的通用方法。最著名的此类目录是一本名为《设计模式:可重用面向对象软件元素》的书[1],也被称为“四人帮”书,因为它是由四位作者共同撰写的。它需要熟悉面向对象编程,而我们在这本书中没有教授,所以我们不建议你马上拿起那本书。只需知道,成千上万的程序员已经通过使用这本书中的模式节省了数千小时,而没有重新发明轮子。
以设计模式的一个例子来说明,假设你正在编写一个电脑游戏,其中人类玩家与电脑对战。你希望实现几个不同难度(例如,初学者、中级、高级和专家)的 AI 对手,为人类提供不同技能水平的对手。每个 AI 对手都将有自己的代码来决定其行为。使用if条件确定 AI 对手应该做什么是可能的:
但那样做的缺点是组织上的。我们会把所有 AI 代码放在同一个地方。我们从这本书中学到的知识告诉我们,像那样的大型函数不容易设计和测试。
组织这类程序的干净方式是使用一种称为策略模式的模式。我们不会在这里详细介绍该模式,但重要的是,人们已经为他人使用记录了这种模式[1]。模式文档会告诉你其意图(目的)、我们为什么要使用它的动机、实现它所需的代码结构以及示例代码。
研究人员开始整理可用于 GitHub Copilot 和 ChatGPT 等生成 AI 工具的模式。它们被称为提示模式,类似于设计模式,它们告诉你如何构建一个提示以实现一个期望的目标。每个这些模式的文档与设计模式的文档类似,但与示例代码不同,我们得到的是我们可以使用的示例提示。
在这本书中,我们大部分时间都在关注两种类型的 AI 响应:生成代码(通过在 VS Code 中按 Tab 键)和解释代码(通过使用 Copilot Chat)。新兴的提示模式可以帮助生成 AI 工具超越这些,帮助处理其他与代码相关的任务。
为了让你了解提示模式的前沿状态,我们将在这里采样几个。对于更多,我们鼓励你查看我们使用的提示模式目录[2]。但请注意:我们正在引用一篇学术论文,而不是入门指南,因为我们目前不知道有任何入门指南(这还是新事物)。
在撰写本文时,Copilot Chat 拒绝参与一些已记录的提示模式,声称它们与编程辅助无关。虽然其中一些可能不太具体地与编程相关,但它们都是为了帮助程序员设计的。与 ChatGPT 不同,Copilot 被设计成始终试图专注于编程主题。如果你决定进一步探索各种模式,你可能希望在无法与 Copilot 有效互动时使用 ChatGPT。
到目前为止,我们一直是请求代码和提问的人,而 Copilot 一直是回答的人。但我们可以反转脚本,让 Copilot 提问,我们回答。这被称为交互模式反转。
此模式的目的是在我们不知道 AI 需要什么信息来完成任务时,帮助我们与之互动。动机是,AI 可能知道如何更快地通过询问我们达到其目标,而不是如果我们提供不完整或不帮助的信息。我们的提示结构将看起来像这样:
我希望你问我问题以达到 X。你应该继续提问,直到我们达到这个目标,其中 X 是我们想要达到的目标。
这种结构并不是为了精确地规定我们应该使用的提示。相反,它提示我们需要在提示中传达的关键思想。例如,这里的关键思想是我们希望 Copilot 提问,并且我们希望它在我们达到目标之前不要停止。
在第三章中,我们编写了一个函数,不断询问用户输入密码,直到他们输入了一个强大的密码。假设我们不确定如何设计这个函数。我们可以通过提供代码提示让 Copilot 为我们设计它,但这样它可能会做出我们不认同的决定。相反,我们可以让 Copilot 提问。让我们使用 Copilot Chat 来做这件事。
LPHuman 我想要一个 Python 函数,它会不断询问用户输入密码,直到他们提供了一个强大的密码。在你能写出那个 Python 函数之前,一次问我一个问题。
你认为什么是一把强大的密码?
函数应该有任何输入参数吗?
函数应该返回什么?
函数应该有任何副作用,比如打印消息到控制台吗?
函数应该有任何错误处理,比如处理用户无效的输入吗?
Copilot 未能遵循我们“一次问一个问题”的指示。让我们提醒它。
LPHuman 一次问我一个问题。
现在,我们可以开始回答这些问题。Copilot 将使用这些答案为我们编写代码。
LPHuman 密码必须至少包含一个大小写字母,至少一个数字和一个标点符号。
LPHuman 我不知道。它们会被用来做什么?
在我们的密码函数的情况下,我们可能想要传递一些参数来定义强大密码的要求。例如,我们可能想要传递密码的最小长度、所需的大小写字母数量、所需的数字数量和所需的标点符号数量。
或者,我们可以将这些要求硬编码到函数本身中,但使用输入参数会使函数更加灵活和可重用。
LPHuman 请将它们硬编码。
LPHuman 该函数应返回用户的强密码。
我们在这里停止,因为我们认为阅读完整的交互并不有趣。我们继续与 Copilot 交谈并回答了问题 4 和 5,Copilot 拥有了编写我们想要的代码所需的信息。在回答了所有五个问题后,我们最终收到的代码如下所示。
到目前为止,在我们的 while 循环中,我们始终使用一个我们希望最终变为 False 的循环条件(否则,我们会遇到无限循环)。例如,我们可以想象有一个循环,实际上说的是,“当密码无效时继续循环。”但在这里,代码使用的是 while True,而 True 是一个永远不会变为 False 的条件。必须有一种其他方式退出循环——确实有!当密码有效时,我们通过 return 语句退出循环。也就是说,这里的逻辑是“永远循环;如果密码有效,停止循环。”while True 形式的 while 循环是本书中看到的一种常见替代结构。
这段代码很好地实现了我们的任务。它开始时通过一些 print 调用来告诉用户强密码的规则。如果用户的密码实际上不强,它还会告诉用户密码有什么问题。它持续循环,直到用户输入一个强密码。当我们最终从用户那里得到一个强密码时,我们返回它。
现在,我们已经知道了如何编写这个函数,因为我们已经在第三章的工作中做到了这一点。而且,到这一点,你的 Python 知识已经足够强大,你可以直接跳到编写函数签名,让 Copilot 编写代码,就像我们在整本书中做的那样。但对于你不太熟悉且可能不知道从何开始的任务,这种翻转交互模式可能会有所帮助。
这种模式的目的是让 AI 扮演一个特定的角色或观点。我们为什么会想要这样做呢?好吧,这里有一个例子:假设你是一名入门级 Python 编程课程的学生。你的讲师正在课程中使用 Copilot,但他们禁止了一些他们认为会妨碍入门级 Python 学习的 Python 特性。你可以要求 Copilot 扮演那个讲师的角色,然后帮助你检查代码,以找到并(如果需要)移除任何不允许的 Python 特性。我们的提示结构将如下所示:
从现在起,扮演角色 X。提供角色 X 会创建的输出。
我们将用特定的角色来填充 X。让我们尝试使用我们的入门级编程讲师角色。我们将要求 Copilot 扮演这个角色,禁止一系列更高级的 Python 概念。(如果你从未听说过这些,请不要担心!)
LPHuman 从现在起,扮演一个入门级 Python 编程讲师的角色。在你教授的课程中,以下 Python 特性是不允许的:列表推导式、生成器、“lambda”以及“any”函数。我会提供代码,你需要检查代码中是否有不允许的 Python 特性,并提出如何重写代码以避免使用这些特性。
让我们给它提供上一节中的 get_strong_password 函数。它使用了 any 函数,根据我们的角色规则,这个函数是不允许的。以下是我们的原始代码以及 Copilot 的响应。
LPHuman
这个版本的函数使用 for 循环遍历密码中的每个字符并检查它是否符合要求。这种方法比使用 any 函数更冗长,但它没有使用不允许的 Python 特性。
如 Copilot Chat 所示,这个程序没有使用 any 函数。Copilot 已经重写了程序以避免使用该函数,从而遵循了我们 Python 编程角色的规则。
any 函数在其参数中有一个或多个值为真时返回 True,如果所有值都为假,则返回 False。在幕后,any 必须查看每个值以检查它是否为真或假。听起来像是一个循环,对吧?确实,Copilot 就是用一个循环来替换这里的 any 函数调用的。
我们发现 Copilot 在三种不同类型的场景中存在挑战:虚构问题、不明确问题和大型任务。
在 ChatGPT 和 GitHub Copilot 风靡全球之后不久,编程教师们急忙寻找这些工具无法很好地完成的任务类型。一个早期的建议是使用虚构任务:通过使任务变得晦涩和复杂来试图误导像 Copilot 这样的工具生成错误的代码。对我们来说,这类任务似乎完全无趣,而且对学生来说也不公平,它们的用途似乎是一种最后的努力,试图在不承认 Copilot 等工具造成的破坏的情况下保留之前的编程课程风格。我们的感觉是,随着模型的持续改进,这些虚构任务最终会被 Copilot 解决,但即使它们没有被解决,这类问题通常也不是必须解决的。
一个不明确的问题是指那些没有精确指定的问题,这意味着我们并不确切知道或在所有情况下都没有决定我们应该做什么。例如,向 Copilot 请求一个函数来决定密码是否强大,直到我们定义了“强密码”的含义,这个问题才是不明确的。你可能会想,Copilot 不可能在这些类型的问题上变得更好。毕竟,如果我们难以精确指定我们想要的行为,我们如何将所需的行为传达给 Copilot 呢?不明确的问题对 Copilot 来说确实具有挑战性,但它们并不是不可能解决的。记得我们在本章前面讨论过的翻转交互提示模式吗?也许,不久的将来,Copilot 会自动切换到那种模式,当你没有足够精确的信息来解决问题时。
我们在整本书中花了大量时间教你如何设计小型函数,以及如何使用自顶向下的设计来组织这些函数以解决大型问题。我们这样做是因为当给 Copilot 一个单一的任务来解决时,Copilot 会感到困难。这是像 Copilot 这样的工具的固有局限性,还是 AI 可以克服它?我们不知道。目前,Copilot 在问题分解方面存在困难,即使它能够正确地做到这一点,概率也不利于它。具体来说,它编写代码越多,出错的可能性就越高。例如,如果它需要编写 20 个函数来解决一个任务,每个函数平均有 10 行代码,它几乎肯定会在某个地方犯错误。但反对这些系统也是不明智的。随着 Copilot 学习方式的进步,也许这一切并没有想象中那么遥远。
当我们用 Python 这样的语言编写计算机代码时,背后有一个编译器将 Python 转换为计算机可以理解的汇编代码或机器代码。曾经有一段时间,程序员用 Fortran 或 C 这样的老式计算机语言编写代码,运行编译器生成汇编语言的代码,然后检查汇编代码以确保它是正确的。我们当时不在场,这可能没有发生很多,但我们能理解为什么当时的程序员不相信编译器。编译器是一种新技术,需要时间来修复错误。此外,编译器输出的代码可能比手写的汇编代码效率低。但是,经过几十年的改进,无论是在正确性还是效率上(编译器优化对任何好奇的人来说都是一个真正令人兴奋和有趣的话题!),很少有程序员会查看编译器的输出。有没有可能有一天,人类仅仅使用大型语言模型(LLMs)作为与计算机的主要接口,并且花很少的时间检查他们产生的代码?让我们思考这个问题的两种可能的答案。
有理由相信 LLMs 不会成为人类编程计算机的主要接口。最主要的原因是 LLMs 不是一个严格的编程语言。我们信任编译器,因为每种编程语言都有一个规范,并且每行代码都有预期的确切行为。LLMs 没有这个。这只是有人用英语或其他自然语言向 LLM 写作。LLM 并不一定要以特定方式解释自然语言。它不需要根据任何严格的规范输出答案。而且,随机性和非确定性使得它给出的答案可能会变化或错误!编译器是确定性的,到目前为止,编译器是一种成熟、可信的技术。它们不面临这些问题。
也有理由相信,就像编译器一样,LLMs 会变得更好,并成为我们的主要编程接口。事实上,对于数据科学来说,这已经开始发生了。
正如我们在整本书中学到的,与 Copilot 一起工作的一个关键挑战是确定生成的代码是否正确。从某种意义上说,这对非程序员来说并不公平:我们给 Copilot 自然语言(我们的偏好),而它给我们回的是非自然语言的代码(计算机的偏好)。如果能“跳过代码”,不仅用自然语言与 Copilot 沟通,还能得到我们的答案,那就太好了。
研究人员开始探索在数百万计算机用户感兴趣的受限领域内这一可能性。以数据科学为例,数据科学家通过探索数据、可视化数据和使用数据来做出预测来理解数据。他们所做的大部分工作都涉及以约束和易于理解的方式操作数据,例如合并电子表格、清理特定列的数据或进行诸如将数据聚类到有意义的类别或简化数据以仅关注其核心基础结构的分析。使用 Python 的数据科学家使用许多库来处理他们的数据,但一个极其流行的库叫做 pandas。
研究人员已经在这个使用 pandas 进行数据科学的环境中“跳过了代码”[3]。以下是它是如何工作的:
用户以自然语言(如英语)表达他们的意图。
人工智能生成 Python 代码并运行以获取结果(例如,分析结果表格或新的电子表格)供用户使用。重要的是,用户看不到这段 Python 代码。
人工智能将代码转换回自然语言,并将转换后的内容(而不是 Python 代码!)呈现给用户。用户收到的自然语言格式一致,可以被人工智能可靠地解释。研究人员这样描述人工智能响应的目的:“这就是你应该向系统提出你刚刚要求系统做的事情。”这个响应有助于用户了解人工智能的能力和有效的查询类型。
如果第 3 步的自然语言不正确,用户可以编辑。如果用户进行了编辑,他们可以提交新的提示,这会重复循环。
研究人员提供的一个例子阐明了这个过程[3]。假设我们有一个电子表格,每行代表一名宇航员。每行有三个相关列:宇航员的名字、他们在太空中的总时间以及他们参与的以逗号分隔的任务列表。我们希望计算每位宇航员的平均任务长度。
在第 1 步中,用户编写一个提示,如“计算平均任务长度”。在第 2 步中,人工智能生成与该提示相对应的代码。它运行代码,并在用户的电子表格中添加一个包含平均任务长度的新的列。在第 3 步中,人工智能将代码转换为自然语言任务列表,如下所示:
创建“任务长度”列。
在第 4 步中,用户可以编辑第 3 步的自然语言,并将更新后的任务重新提交给 AI。我们是否能够在“Python 编程”这个更广泛的领域“跳过代码”,而不是在“使用 pandas 的数据科学”这个更受限的环境中呢?现在还言之过早。数据处理的好处在于可以在视觉媒介中工作,使用电子表格和图表可以直接展示给用户,用户可能能够判断分析是否正确,或者是否需要进一步的提示工程。这样的视觉表示对于通用编程来说并不明显。
尽管如此,我们可以想象一个新时代,在这个时代,人类继续执行重要的任务,如问题分解、指定程序行为、编写测试、设计算法等,但函数编程完全由 LLM(大型语言模型)完成。人类告诉 AI 工具程序需要做什么,并提供测试用例,然后 AI 生成代码。人类可以检查程序是否正常工作,而无需查看代码。
对于 LLM 是否会取代编程语言的另一种看法,我们推荐阅读 Chris Lattner(编程和编译器专家)撰写的博客文章[4]。Lattner 认为,至少在短期内,甚至可能是更长的时间内,编程语言不会消失,因为 LLM 生成的代码可能会存在细微的错误。因此,如果编程语言还会存在一段时间,那么问题是:我们应该使用哪种编程语言?Lattner 说:“最适合 LLM 的语言是那种对人类来说高度可用且易于阅读,但其实现可以扩展到许多不同的用例和应用程序的语言” [4]。现有的语言是否满足这个目标?我们能否通过设计一种比 Python 更容易阅读的编程语言来做得更好?请继续关注!
好吧,也许编程语言会消失,也许不会;也许它们会改变。无论如何,我们是否需要担心程序员的工作?我们认为不需要。任何在软件公司工作过的人都会告诉你,编写代码并不是程序员用他们时间做的唯一或甚至主要的事情。程序员还会与客户会面,确定他们的需求。他们指定程序的功能以及它们如何组合。他们会检查系统的性能和安全问题。他们与其他团队合作,协调大型软件的设计。如果编写代码的步骤变得更容易,也许我们只是得到了更多有用的软件。这就是当我们得到高级语言时发生的事情。没有人会使用汇编语言编写下一个杀手级应用!编译器改善了我们的软件制作方式。如果我们明智地使用 LLM,我们认为 LLM 也能做到这一点。
虽然我们对未来部分不确定,但很清楚,LLMs 将极大地改变编程的未来。也许,现在它们只是帮助软件工程师编写更好代码的辅助工具。也许五年后,然而,大多数软件将由 LLMs 编写,只有一小部分软件工程师会从头开始编写代码。无论哪种结果证明是正确的,变化正在迅速发生,它们可能会使更多的人能够编写满足他们需求的软件。
到目前为止,我们已经对如何使用这些工具有一些见解。在一项调查中,92%的软件开发者表示他们定期使用这些工具[5]。最近的研究发现,使用这些工具的开发者比不使用工具的开发者更有效率[6]。在加州大学圣地亚哥分校进行的一项特别有趣的研究中,研究人员发现,专业开发者使用这些工具的方式有两种:加速和探索[7]。在加速阶段,开发者使用这些工具来帮助他们比如果自己编写代码更快地生成代码。在探索阶段,开发者使用这些工具来帮助他们找到可能有所帮助的库或理解现有的代码库。这些发现与我们在这本书中学到的东西相一致,我们期待未来的研究。
无论如何,我们希望你现在能够根据自己的判断来决定如何使用大型语言模型(LLMs)进行编程,以及 LLMs 可能对编程的未来意味着什么。在这样的时刻,有些人热情地宣称,“编程已经结束了!”而另一些人同样热情地宣称,“编程不会发生太大的变化!”[8],对我们来说,能够自己权衡这些论点以及这种变化可能对我们以及其他人产生的影响是很重要的。这些工具能帮助我们吗?我们认为可以。因此,我们应该使用它们,但也要负责任地使用。有没有担忧?同样,我们认为有,正如我们在整本书中讨论的那样。我们应该采取一些措施,比如测试和调试,来减轻这些担忧。
在这本书中,我们采用的编程教学方法是新的。像我们这样的教师和教授刚刚开始将像 Copilot 这样的工具融入编程课程[9],尽管我们仍在努力平衡我们希望学习者何时使用 Copilot 以及何时不使用 Copilot。无论程序员选择何种平衡来学习编程,我们都要强调,你已经学习了每个人都需要的基本技能,无论是你偶尔在工作时编写代码来自动化繁琐的任务,还是你计划成为一名专业的软件工程师。你有一个强大的基础,无论你的道路如何。
解释如何使用人格模式来加强公司中的编码标准。例如,公司可能要求其员工始终使用驼峰命名法(例如,xVal、ZValOut)。提供一个提示示例,让 GitHub Copilot 扮演一个经理的角色,执行特定的编码标准。
你想创建一个 Python 函数,该函数根据用户的首选用户名和全名生成用户名。该函数应遵循以下规则:
如果首选用户名可用(即尚未被占用),请使用它。
如果首选用户名已被占用,通过在首选用户名后附加单个数字(例如,1、2、3、……)来生成新的用户名。
如果生成的用户名也被占用,使用用户的全名通过组合他们的姓氏和名字的部分来生成用户名。
如果生成的用户名也被占用,函数应继续请求新的首选用户名。
你会使用哪种交互模式(翻转交互模式或人格模式)来帮助 GitHub Copilot 设计这个功能?请证明你的选择,并概述你将如何开始交互。
提示模式是一个模板,帮助我们构建一个提示以实现既定目标。
翻转交互模式翻转了剧本:不是向 LLM 提出问题,而是 LLM 向我们提出问题。
当我们不知道如何有效地提示 LLM 时,翻转交互模式很有用。
人格模式用于让 AI 承担特定的角色,例如“入门级编程讲师”或特定的观点。
当我们希望 LLM 从给定视角回应时,人格模式很有用。
Copilot 目前在处理设计、定义不明确或规模较大的任务时存在困难。
有些人认为 LLM 将取代编程语言;另一些人认为编程语言将永远存在。
LLM 正在帮助人们执行数据科学任务,而这些人们从未见过底层编程语言代码。
可能编程语言本身不会被取代,但主导语言将被开发得比今天的语言更易读。